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>
This commit is contained in:
Blizzard
2026-07-17 10:39:24 +08:00
parent 929bbf334b
commit efd185b779
11 changed files with 921 additions and 46 deletions
@@ -191,6 +191,86 @@ func (p *Postgres) Redeem(ctx context.Context, code, tenantID, userID string) (*
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 {