Files
sundynix-agentix/sundynix-gateway/internal/store/payment.go
T
Blizzard efd185b779 feat(billing): 支付 P5.2 —— 微信支付 Native 渠道(扫码充值)
wechatpay-go v0.2.21。凭据全 env 注入(WECHAT_MCHID/MCH_CERT_SERIAL/
MCH_PRIVATE_KEY/APIV3_KEY/APPID/NOTIFY_URL),缺一渠道即隐藏——半配置/假凭据
只打日志不拖垮 gateway(用假私钥实测过降级)。

- internal/payment:Native 下单出 code_url、APIv3 回调验签解密、主动查单,
  三者统一收敛为 QueryResult。
- 下单 POST /billing/orders {pack_id}(≥member+审计):金额/积分按在售包服务端
  锁定进订单行,不信任客户端;渠道下单失败即作废,不留付不了的 pending。
- 到账两条路汇入同一个 MarkOrderPaid 幂等闸(CAS+唯一索引双闸,同 P5.1):
  ①公开回调路由(验签是唯一的门;金额与订单不符不入账);②前端轮询的
  GET /billing/orders/:id 在 pending 时顺路主动查单——本地/内网收不到公网
  回调也能确认到账,回调只是生产更快的通道。pending 超 30 分钟置 expired。
- Web 面:在售包卡片(渠道亮才出现)→扫码弹窗(qrcode 画 code_url,二维码底色
  固定纯白——暗色主题下低对比码扫不出来)→2.5s 轮询→到账 toast+刷余额。

验证:go/tsc/vitest 全绿;无凭据+假凭据两种降级 live 四连
(channels 只剩 redeem/下单 400 引导兑换码/回调 503/兑换码闭环不受影响)。
⚠️ 真通道(prepay→扫码→回调/查单→入账)需真实商户号,未 live——用户配好
env 后用小额包实测,建议先配 ¥0.01 测试包走一单。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 10:39:24 +08:00

318 lines
11 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package store
import (
"context"
"crypto/rand"
"errors"
"strings"
"time"
"gorm.io/gorm"
)
// 支付(P5,设计见 PAYMENT_DESIGN.md):预付积分包充值。
// 两层汇率各管各的:钱→积分 = 本文件的积分包定价;积分→token = SettingTokensPerCredit(已有)。
//
// 三个模型都**不标 isTenantScoped**
// - PaymentOrder 归「计费租户」,与请求 ctx 的活跃租户可能不同(共享计费分叉),
// 插件自动注入会写错归属——tenant_id 一律显式赋值、查询显式过滤(RecentRuns 同款教训)。
// - CreditPack / RedeemCode 是平台级配置与凭证,不属于任何租户。
// 订单状态机:pending → paid | failed | expiredpaid →(人工)refunded。
const (
OrderPending = "pending"
OrderPaid = "paid"
OrderFailed = "failed"
OrderExpired = "expired"
OrderRefunded = "refunded"
)
// 渠道名。P5.1 只有 redeemwechat 在 P5.2 挂上。
const (
ChannelRedeem = "redeem"
ChannelWechat = "wechat"
)
// CreditPack 积分包(admin 可改价/上下架;订单锁定下单当时的价与积分,改包不影响已付订单)。
type CreditPack struct {
BaseModel
Name string `gorm:"size:64" json:"name"`
CreditsMicro int64 `gorm:"column:credits_micro" json:"credits_micro"`
PriceFen int64 `gorm:"column:price_fen" json:"price_fen"` // 应付人民币(分)
Active bool `json:"active"`
Sort int `json:"sort"`
}
func (CreditPack) TableName() string { return "sundynix_credit_pack" }
// PaymentOrder 充值订单——支付侧事实源(与 credit_ledger 对账的另一条腿)。
// 兑换码入账也写一行(channel=redeem、amount_fen=0、即时 paid),全部充值一个查法。
type PaymentOrder struct {
BaseModel
TenantID string `gorm:"size:64;index" json:"tenant_id"` // 计费租户(下单时解析并锁定)
UserID string `gorm:"size:64;index" json:"user_id"` // 操作人(审计)
PackID string `gorm:"size:24" json:"pack_id"` // redeem 渠道为空
AmountFen int64 `gorm:"column:amount_fen" json:"amount_fen"`
CreditsMicro int64 `gorm:"column:credits_micro" json:"credits_micro"`
Channel string `gorm:"size:16;index" json:"channel"`
Status string `gorm:"size:16;index" json:"status"`
ChannelTxn string `gorm:"size:128" json:"channel_txn"` // 渠道流水号 / 兑换码 id
PaidAt *time.Time `json:"paid_at"`
}
func (PaymentOrder) TableName() string { return "sundynix_payment_order" }
// RedeemCode 兑换码(平台级凭证;admin 生成,任意租户核销一次)。
type RedeemCode struct {
BaseModel
Code string `gorm:"size:32;uniqueIndex" json:"code"`
CreditsMicro int64 `gorm:"column:credits_micro" json:"credits_micro"`
Status string `gorm:"size:16;index" json:"status"` // unused / used
UsedTenant string `gorm:"size:64" json:"used_tenant"`
UsedBy string `gorm:"size:64" json:"used_by"`
UsedAt *time.Time `json:"used_at"`
Memo string `gorm:"size:255" json:"memo"`
}
func (RedeemCode) TableName() string { return "sundynix_redeem_code" }
// codeAlphabet 去掉易混字符(0/O、1/I/L)的 base32 变体。
const codeAlphabet = "ABCDEFGHJKMNPQRSTUVWXYZ23456789"
// newRedeemCode 生成 SDX-XXXX-XXXX-XXXX 形式的兑换码(crypto/rand31^12 ≈ 7.9e17 空间)。
func newRedeemCode() (string, error) {
b := make([]byte, 12)
if _, err := rand.Read(b); err != nil {
return "", err
}
var sb strings.Builder
sb.WriteString("SDX")
for i, c := range b {
if i%4 == 0 {
sb.WriteByte('-')
}
sb.WriteByte(codeAlphabet[int(c)%len(codeAlphabet)])
}
return sb.String(), nil
}
// GenerateRedeemCodes admin 批量生成兑换码(每张 creditsMicro 积分)。返回明文码列表。
func (p *Postgres) GenerateRedeemCodes(ctx context.Context, n int, creditsMicro int64, memo string) ([]string, error) {
if p.db == nil {
return nil, errStoreDisabled
}
if n <= 0 || n > 200 || creditsMicro <= 0 {
return nil, errors.New("数量须在 1-200、面额须为正")
}
out := make([]string, 0, n)
err := p.db.WithContext(WithoutTenant(ctx)).Transaction(func(tx *gorm.DB) error {
for i := 0; i < n; i++ {
code, err := newRedeemCode()
if err != nil {
return err
}
if err := tx.Create(&RedeemCode{Code: code, CreditsMicro: creditsMicro, Status: "unused", Memo: memo}).Error; err != nil {
return err // 撞唯一索引概率约 n/31^12,整批重试比码内重试省事——直接报错让 admin 再点一次
}
out = append(out, code)
}
return nil
})
if err != nil {
return nil, err
}
return out, nil
}
// ListRedeemCodes admin 查看兑换码(倒序)。
func (p *Postgres) ListRedeemCodes(ctx context.Context, limit int) ([]RedeemCode, error) {
if p.db == nil {
return nil, nil
}
if limit <= 0 || limit > 500 {
limit = 100
}
var out []RedeemCode
err := p.db.WithContext(WithoutTenant(ctx)).Order("created_at desc").Limit(limit).Find(&out).Error
return out, err
}
// Redeem 核销兑换码:一个事务里完成「码 CAS 占用 → 建已支付订单 → 入账(分录+物化余额)」。
// 幂等双闸:码状态 CAS 是主闸(unused→used 只成功一次);credit_ledger 的
// (kind,ref) 部分唯一索引兜底(ref=订单号)。任何一步失败整体回滚,码不会白烧。
func (p *Postgres) Redeem(ctx context.Context, code, tenantID, userID string) (*PaymentOrder, error) {
if p.db == nil {
return nil, errStoreDisabled
}
code = strings.ToUpper(strings.TrimSpace(code))
if code == "" || tenantID == "" {
return nil, errors.New("兑换码必填")
}
var order *PaymentOrder
// 计费租户可能 ≠ 请求 ctx 的活跃租户,且账本表是 tenant-scoped——旁路插件、全部显式赋值。
err := p.db.WithContext(WithoutTenant(ctx)).Transaction(func(tx *gorm.DB) error {
var rc RedeemCode
if err := tx.First(&rc, "code = ?", code).Error; err != nil {
return errors.New("兑换码不存在")
}
now := time.Now()
res := tx.Model(&RedeemCode{}).
Where("id = ? AND status = ?", rc.ID, "unused").
Updates(map[string]any{"status": "used", "used_tenant": tenantID, "used_by": userID, "used_at": now})
if res.Error != nil {
return res.Error
}
if res.RowsAffected == 0 {
return errors.New("兑换码已被使用")
}
o := &PaymentOrder{
TenantID: tenantID, UserID: userID,
AmountFen: 0, CreditsMicro: rc.CreditsMicro,
Channel: ChannelRedeem, Status: OrderPaid, ChannelTxn: rc.ID, PaidAt: &now,
}
if err := tx.Create(o).Error; err != nil {
return err
}
if err := tx.Create(&CreditLedger{
TenantID: tenantID, Kind: LedgerGrant, CreditsMicro: rc.CreditsMicro, Ref: o.ID, Memo: "兑换码 " + code,
}).Error; err != nil {
return err
}
if err := tx.Model(&Tenant{}).Where("id = ?", tenantID).
UpdateColumn("credit_balance_micro", gorm.Expr("credit_balance_micro + ?", rc.CreditsMicro)).Error; err != nil {
return err
}
order = o
return nil
})
if err != nil {
return nil, err
}
return order, nil
}
// CreateOrder 落一张 pending 订单(价与积分由服务端按包锁定后传入)。
func (p *Postgres) CreateOrder(ctx context.Context, o *PaymentOrder) error {
if p.db == nil {
return errStoreDisabled
}
return p.db.WithContext(WithoutTenant(ctx)).Create(o).Error
}
// GetOrder 按 id 取订单。
func (p *Postgres) GetOrder(ctx context.Context, id string) (*PaymentOrder, error) {
if p.db == nil {
return nil, errStoreDisabled
}
var o PaymentOrder
if err := p.db.WithContext(WithoutTenant(ctx)).First(&o, "id = ?", id).Error; err != nil {
return nil, err
}
return &o, nil
}
// MarkOrderPaid 渠道确认已支付后的入账:一个事务里「订单 CAS(pending→paid) → 分录 → 物化余额」。
// 返回 changed=false 表示这单已被处理过(回调重复推送/回调与主动查单赛跑),幂等直接成功。
// 双闸:CAS 是主闸;credit_ledger (kind,ref=订单号) 唯一索引兜底。
func (p *Postgres) MarkOrderPaid(ctx context.Context, orderID, channelTxn string) (bool, error) {
if p.db == nil {
return false, errStoreDisabled
}
changed := false
err := p.db.WithContext(WithoutTenant(ctx)).Transaction(func(tx *gorm.DB) error {
now := time.Now()
res := tx.Model(&PaymentOrder{}).
Where("id = ? AND status = ?", orderID, OrderPending).
Updates(map[string]any{"status": OrderPaid, "channel_txn": channelTxn, "paid_at": now})
if res.Error != nil {
return res.Error
}
if res.RowsAffected == 0 {
return nil // 已处理过(或订单不存在/已过期)——幂等,不重复入账
}
var o PaymentOrder
if err := tx.First(&o, "id = ?", orderID).Error; err != nil {
return err
}
if err := tx.Create(&CreditLedger{
TenantID: o.TenantID, Kind: LedgerGrant, CreditsMicro: o.CreditsMicro, Ref: o.ID, Memo: "充值 " + o.Channel,
}).Error; err != nil {
return err
}
if err := tx.Model(&Tenant{}).Where("id = ?", o.TenantID).
UpdateColumn("credit_balance_micro", gorm.Expr("credit_balance_micro + ?", o.CreditsMicro)).Error; err != nil {
return err
}
changed = true
return nil
})
return changed, err
}
// ExpireOrder 把超时未付的 pending 单置为 expiredCAS,已 paid 的不动)。
func (p *Postgres) ExpireOrder(ctx context.Context, orderID string) error {
if p.db == nil {
return errStoreDisabled
}
return p.db.WithContext(WithoutTenant(ctx)).Model(&PaymentOrder{}).
Where("id = ? AND status = ?", orderID, OrderPending).
Update("status", OrderExpired).Error
}
// GetPack 按 id 取在售积分包(下单锁价用;下架的包不可下单)。
func (p *Postgres) GetPack(ctx context.Context, id string) (*CreditPack, error) {
if p.db == nil {
return nil, errStoreDisabled
}
var pk CreditPack
if err := p.db.WithContext(WithoutTenant(ctx)).First(&pk, "id = ? AND active = ?", id, true).Error; err != nil {
return nil, err
}
return &pk, nil
}
// ActivePacks 在售积分包(用户侧展示)。
func (p *Postgres) ActivePacks(ctx context.Context) ([]CreditPack, error) {
if p.db == nil {
return nil, nil
}
var out []CreditPack
err := p.db.WithContext(ctx).Where("active = ?", true).Order("sort asc, price_fen asc").Find(&out).Error
return out, err
}
// SavePack admin 新建/更新积分包(id 空=新建)。
func (p *Postgres) SavePack(ctx context.Context, pk *CreditPack) error {
if p.db == nil {
return errStoreDisabled
}
if strings.TrimSpace(pk.Name) == "" || pk.CreditsMicro <= 0 || pk.PriceFen < 0 {
return errors.New("name/credits 必填且为正")
}
return p.db.WithContext(WithoutTenant(ctx)).Save(pk).Error
}
// ListPacks admin 查看全部积分包(含下架)。
func (p *Postgres) ListPacks(ctx context.Context) ([]CreditPack, error) {
if p.db == nil {
return nil, nil
}
var out []CreditPack
err := p.db.WithContext(WithoutTenant(ctx)).Order("sort asc, created_at asc").Find(&out).Error
return out, err
}
// TenantOrders 某计费租户的充值订单(倒序)。tenant_id 显式过滤(模型未挂租户插件)。
func (p *Postgres) TenantOrders(ctx context.Context, tenantID string, limit int) ([]PaymentOrder, error) {
if p.db == nil || tenantID == "" {
return nil, nil
}
if limit <= 0 || limit > 100 {
limit = 20
}
var out []PaymentOrder
err := p.db.WithContext(WithoutTenant(ctx)).Where("tenant_id = ?", tenantID).
Order("created_at desc").Limit(limit).Find(&out).Error
return out, err
}