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 | expired;paid →(人工)refunded。 const ( // 订单类型:一次性积分包 vs 订阅周期 OrderKindPack = "pack" OrderKindSub = "sub" ) const ( OrderPending = "pending" OrderPaid = "paid" OrderFailed = "failed" OrderExpired = "expired" OrderRefunded = "refunded" ) // 渠道名。P5.1 只有 redeem;wechat 在 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/rand,31^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 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("credit_balance_micro + ?", 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 §5:admin 发起 → 订单置 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 同 ref;adjust 部分唯一索引兜住重复退)。 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("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 单置为 expired(CAS,已 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:复用同一个会累加 WHERE(status=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 }