Files
sundynix-agentix/sundynix-gateway/internal/store/payment.go
T
Blizzard f8f7359723 feat(admin): 订阅管理页 + 修「余额列为 NULL 导致充值永不到账」
管理端「支付 → 订阅」:套餐配置 + 全平台订阅观测。配置时直接算出「一个周期
发几次、合计多少积分」,时长不能被间隔整除时橙字提示到期前会有空档 —— 让人在
配的时候就看见后果,而不是上线后才发现只发了一次。

顺带修了个真 bug,是拿真库验订阅时撞出来的(本地 42 个租户里 11 个中招):

  credit_balance_micro 是后加的列,早于它创建的租户行值为 NULL。而入账语句是
  「余额 + N」—— SQL 里 NULL + N 仍是 NULL,于是这些租户**充值永远不到账**:
  分录照写、余额不动、不报错。这条路径是充值/兑换码/退款/扣费/订阅发放共用的,
  不是订阅引入的问题。

三处修:
  - 5 处余额增减一律改 coalesce(credit_balance_micro, 0),新写入自愈;
  - 启动迁移回填存量 NULL(按账本求和,让「余额 = SUM(ledger)」重新成立);
  - 模型只加 default:0,**刻意不加 not null** —— 存量库有 NULL 行,AutoMigrate
    尝试 SET NOT NULL 会直接失败,而且它在回填之前跑,等于把部署搞挂。

回归测试先证明能失败(去掉 coalesce → 余额 0)再确认修复。第一版测试因为我给
模型加了 not null 而无法造出 NULL,恰好暴露了上面那个部署风险。

真库验证:回填后 42 个租户 0 个 NULL;那个"有分录但余额 NULL"的租户余额
2981 = 账本合计 2981.34。管理端页面显示真实订阅(已发放 3 次 = 首笔 + 补发 2 笔)。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 16:28:32 +08:00

505 lines
19 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"
"log"
"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 (
// 订单类型:一次性积分包 vs 订阅周期
OrderKindPack = "pack"
OrderKindSub = "sub"
)
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 渠道为空
Kind string `gorm:"size:16;default:pack" json:"kind"` // pack=积分包(一次性) / sub=订阅
PlanID string `gorm:"size:24" json:"plan_id"` // kind=sub 时的订阅套餐
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("coalesce(credit_balance_micro, 0) + ?", 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
var paid *PaymentOrder
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
}
paid = &o
// 订阅单自身不带积分(积分由订阅按周期发放),跳过零额分录避免账本噪声。
if o.CreditsMicro != 0 {
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("coalesce(credit_balance_micro, 0) + ?", o.CreditsMicro)).Error; err != nil {
return err
}
}
changed = true
return nil
})
if err != nil {
return changed, err
}
// 订阅开通放在这里、而不是各调用方:回调与掉单补偿两条路都经过 MarkOrderPaid
// 放在这一处才没人能漏掉。ActivateSubscription 按 orderID 幂等,重复调用无害。
if paid != nil && paid.Kind == OrderKindSub && paid.PlanID != "" {
if _, aerr := p.ActivateSubscription(ctx, paid.TenantID, paid.PlanID, paid.ID); aerr != nil {
// 钱已收、订单已 paid:这里失败**不能**回滚订单(否则用户付了钱订单还回到 pending,
// 补偿定时器会再入账一次)。出声即可,可人工或下次回调补开通。
log.Printf("[payment] ⚠️ 订单 %s 已入账但订阅开通失败: %v(需人工补开通)", paid.ID, aerr)
}
}
return changed, nil
}
// RefundOrder 人工退款(PAYMENT_DESIGN §5admin 发起 → 订单置 refunded + 记 adjust 负分录 + 回退余额)。
// 只退 paid 单。幂等照抄 MarkOrderPaid 范式:**订单 CAS(paid→refunded) 为主闸**——RowsAffected==0
// 表示已退过(或非 paid),直接幂等成功,不重复冲销。credit_ledger 的 (kind='adjust', ref=订单号)
// 部分唯一索引兜底(与 grant 的双闸对称)。
// 注:若积分已被消费,回退后物化余额可能为负——这是人工退款的预期(钱退了、积分早花了),
// 账本仍自洽(余额 = SUM(ledger)),后续消费被积分硬拦截挡住直到再充值。
// 返回 changed=false 表示这单本就无需退(已退/未支付/不存在),幂等。
func (p *Postgres) RefundOrder(ctx context.Context, orderID, operatorUserID, memo string) (bool, error) {
if p.db == nil {
return false, errStoreDisabled
}
if strings.TrimSpace(orderID) == "" {
return false, errors.New("订单号必填")
}
if strings.TrimSpace(memo) == "" {
memo = "人工退款"
}
changed := false
err := p.db.WithContext(WithoutTenant(ctx)).Transaction(func(tx *gorm.DB) error {
res := tx.Model(&PaymentOrder{}).
Where("id = ? AND status = ?", orderID, OrderPaid).
Update("status", OrderRefunded)
if res.Error != nil {
return res.Error
}
if res.RowsAffected == 0 {
return nil // 已退 / 非 paid / 不存在 —— 幂等,不冲销
}
var o PaymentOrder
if err := tx.First(&o, "id = ?", orderID).Error; err != nil {
return err
}
// 负分录:kind=adjust、ref=订单号(不撞 grant 的 kind=grant 同 refadjust 部分唯一索引兜住重复退)。
if err := tx.Create(&CreditLedger{
TenantID: o.TenantID, Kind: LedgerAdjust, CreditsMicro: -o.CreditsMicro, Ref: o.ID,
Memo: "退款 " + o.Channel + "" + memo + "(操作人 " + operatorUserID + "",
}).Error; err != nil {
return err
}
if err := tx.Model(&Tenant{}).Where("id = ?", o.TenantID).
UpdateColumn("credit_balance_micro", gorm.Expr("coalesce(credit_balance_micro, 0) - ?", 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
}