Files
sundynix-agentix/sundynix-gateway/internal/store/payment.go
T
Blizzard 3db2de1ef6 feat(billing): 支付 P5.3 —— 掉单补偿定时器 + admin 订单流 + 日终对账
支付线封口。此前 pending 单只在「用户开着账单页轮询」时才查单确认——用户扫完码
关页面,钱付了、积分永不到账。

- 掉单补偿定时器(payment_reconcile.go):gateway 内每分钟扫 pending 微信单,
  逐单 reconcileOrder 主动查单落态。把「用户在不在场」从入账链路摘掉。
  reconcileOrder 从 BillingOrderStatus 抽出、前端轮询与定时器共用一份幂等
  落态逻辑(不重蹈 GenerateReport/SubmitTask 的漂移)。渠道未配置时空转不炸。
- admin 订单流 GET /admin/orders(状态计数+全平台订单,可筛)。
- 日终对账 GET /admin/orders/reconcile:paid 单 ↔ 账本 grant 分录逐单比对,
  抓 order_without_ledger(钱到了积分没给,最严重)/ ledger_without_paid_order。
- admin 计费页「充值订单与对账」块:计数卡片+订单流+一键对账。

⚠️ live 抓到并修掉一个真 bug:OrderStats 复用同一个 gorm.DB 链式 Count 三次,
WHERE 累加成 status=A AND status=B → 恒 0(订单流显示 2 单但计数全 0)。
改成每次起新 query builder。—— 又一次只有 live 才暴露的。

验证:go 6 包测试+tsc+41 vitest 全绿;live 造差异单对账正确抓出
order_without_ledger、清账后回零差异;补偿器启动日志+渠道未配置空转不炸;
浏览器验订单流卡片+一键对账绿条。TTL 过期路径需真渠道触发,部署后自然覆盖。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 11:25:37 +08:00

430 lines
16 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
}
// PendingWechatOrders 捞出所有 pending 的微信订单(掉单补偿定时器扫描用)。
// 只取微信单:兑换码单核销即 paid,永不 pending,不需要查单。按创建时间升序,先补老单。
func (p *Postgres) PendingWechatOrders(ctx context.Context, limit int) ([]PaymentOrder, error) {
if p.db == nil {
return nil, nil
}
if limit <= 0 || limit > 500 {
limit = 200
}
var out []PaymentOrder
err := p.db.WithContext(WithoutTenant(ctx)).
Where("status = ? AND channel = ?", OrderPending, ChannelWechat).
Order("created_at asc").Limit(limit).Find(&out).Error
return out, 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
}
// OrderSummary 是 admin 订单流一行(带租户名,免前端二次查)。
type OrderSummary struct {
PaymentOrder
TenantName string `json:"tenant_name"`
}
// AllOrders 全平台充值订单流(admin 观测;可按状态过滤)。倒序,翻页。
func (p *Postgres) AllOrders(ctx context.Context, status string, limit int) ([]OrderSummary, error) {
if p.db == nil {
return nil, nil
}
if limit <= 0 || limit > 200 {
limit = 50
}
q := p.db.WithContext(WithoutTenant(ctx)).Table("sundynix_payment_order o").
Select("o.*, t.name as tenant_name").
Joins("LEFT JOIN sundynix_tenant t ON t.id = o.tenant_id").
Where("o.deleted_at IS NULL")
if status != "" {
q = q.Where("o.status = ?", status)
}
var out []OrderSummary
err := q.Order("o.created_at desc").Limit(limit).Scan(&out).Error
return out, err
}
// OrderStats 全平台订单状态计数(观测卡片:pending/paid/expired 分布 + 累计到账额)。
type OrderStats struct {
Pending int64 `json:"pending"`
Paid int64 `json:"paid"`
Expired int64 `json:"expired"`
PaidFenTotal int64 `json:"paid_fen_total"` // 累计到账金额(分),只算真渠道 amount_fen>0
}
func (p *Postgres) OrderStats(ctx context.Context) OrderStats {
var s OrderStats
if p.db == nil {
return s
}
ctx = WithoutTenant(ctx)
// 每次都起新 query builder:复用同一个会累加 WHEREstatus=A AND status=B → 恒 0)。
countBy := func(status string) int64 {
var n int64
p.db.WithContext(ctx).Model(&PaymentOrder{}).Where("status = ?", status).Count(&n)
return n
}
s.Pending = countBy(OrderPending)
s.Paid = countBy(OrderPaid)
s.Expired = countBy(OrderExpired)
p.db.WithContext(ctx).Model(&PaymentOrder{}).Where("status = ?", OrderPaid).
Select("coalesce(sum(amount_fen),0)").Scan(&s.PaidFenTotal)
return s
}
// ReconcileRow 对账差异一行:paid 订单在账本里找不到对应 grant 分录(或反之)。
type ReconcileRow struct {
OrderID string `json:"order_id"`
TenantID string `json:"tenant_id"`
CreditsMicro int64 `json:"credits_micro"`
Issue string `json:"issue"` // order_without_ledger / ledger_without_order
}
// ReconcileOrders 日终对账:paid 订单 ↔ ledger(kind=grant, ref=订单号) 逐单比对,列出对不上的。
// 正常应返回空列表(双闸保证 paid 单必有且仅有一条 grant 分录)。有差异即数据出了问题,需人工查。
func (p *Postgres) ReconcileOrders(ctx context.Context, limit int) ([]ReconcileRow, error) {
if p.db == nil {
return nil, nil
}
if limit <= 0 || limit > 500 {
limit = 200
}
ctx = WithoutTenant(ctx)
var out []ReconcileRow
// paid 订单但账本无对应 grant 分录(钱记了、积分没到——最严重)
if err := p.db.WithContext(ctx).
Raw(`SELECT o.id AS order_id, o.tenant_id, o.credits_micro, 'order_without_ledger' AS issue
FROM sundynix_payment_order o
WHERE o.status='paid' AND o.deleted_at IS NULL
AND NOT EXISTS (SELECT 1 FROM sundynix_credit_ledger l
WHERE l.kind='grant' AND l.ref=o.id AND l.deleted_at IS NULL)
ORDER BY o.created_at DESC LIMIT ?`, limit).Scan(&out).Error; err != nil {
return nil, err
}
// grant 分录指向的订单不是 paid(积分到了、订单态不对——重复入账/状态错乱)
var out2 []ReconcileRow
if err := p.db.WithContext(ctx).
Raw(`SELECT l.ref AS order_id, l.tenant_id, l.credits_micro, 'ledger_without_paid_order' AS issue
FROM sundynix_credit_ledger l
JOIN sundynix_payment_order o ON o.id = l.ref
WHERE l.kind='grant' AND l.deleted_at IS NULL AND o.status <> 'paid'
ORDER BY l.created_at DESC LIMIT ?`, limit).Scan(&out2).Error; err != nil {
return nil, err
}
return append(out, out2...), nil
}
// 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
}