feat(order): 重构退款全流程并新增申请退款中状态

- 订单状态新增「6 申请退款中」,用户申请退款原子流转并落待审核退款流水
- 退款失败回退订单至已支付并恢复被取消的权益发放任务
- 新增虚拟支付退款查单兜底任务,反查超时订单并自动订正
- 会员包来源额度不叠加,扣减/回退/回收口径统一为只取生效一份
- 退款回收流水按吊销前后有效剩余差值记账,避免无效回收记录
- 同步更新 README 与 Stitch 设计文档
This commit is contained in:
2026-09-16 03:44:09 +08:00
parent 89b59c7b91
commit b94dd6dffb
29 changed files with 2141 additions and 158 deletions
+192 -27
View File
@@ -135,12 +135,73 @@ func listValidGrants(ctx context.Context, userID int64, quotaType int8, vipExpir
return valid, nil
}
// pickEffectiveMemberGrant 在有效凭证中选出会员包来源(source=1)的生效份。
// 会员包额度不叠加:月度包与年度包同时持有时只取一份计入有效额度,避免额度翻倍。
//
// 规则:排除已用尽(Amount-Used<=0)的份,保证旧包用完后新购包能自动接续、不浪费已购额度;
// 候选中按 Amount 降序(取额度最大的一份)→ Used 降序(保留正在使用的一份,避免购买新包后
// 剩余额度抖动)→ ID 降序(新购优先)取第一条;无候选返回 nil,表示会员包贡献为 0。
//
// 纯函数、无副作用,调用方需保证 grants 为「当前有效」的凭证集合;
// getEffectiveQuota / consumeCosQuota / cosRemainTx / refundCosQuotaTx 共用该规则,保证展示、扣减、回退口径一致。
func pickEffectiveMemberGrant(grants []mysqlmodel.QuotaGrant) *mysqlmodel.QuotaGrant {
var picked *mysqlmodel.QuotaGrant
for i := range grants {
g := &grants[i]
if g.Source != QuotaGrantSourceMember || g.Amount-g.Used <= 0 {
continue
}
if picked == nil || memberGrantBetter(g, picked) {
picked = g
}
}
return picked
}
// memberGrantBetter 报告 a 是否应优先于 b:Amount 降序 → Used 降序 → ID 降序。
func memberGrantBetter(a, b *mysqlmodel.QuotaGrant) bool {
switch {
case a.Amount != b.Amount:
return a.Amount > b.Amount
case a.Used != b.Used:
return a.Used > b.Used
default:
return a.ID > b.ID
}
}
// pickMemberGrantForRefund 选择会员包来源(source=1)的回退目标份(跳过 handled 中已处理的份)。
// 会员包额度不叠加,回退需与展示口径一致:
// 1. 存在「当前生效份」(未用尽且 used>0)时优先回退它,保证剩余额度即时回升、账实一致;
// 2. 生效份无可退额度(used=0)时,退而选择 used>0 的份中 memberGrantBetter 最优的一条,
// 用于复活刚被扣至用尽的一份(例如消费恰好把生效份用尽后回退)。
func pickMemberGrantForRefund(grants []mysqlmodel.QuotaGrant, handled map[int64]bool) *mysqlmodel.QuotaGrant {
candidates := make([]mysqlmodel.QuotaGrant, 0, len(grants))
for i := range grants {
g := grants[i]
if g.Source != QuotaGrantSourceMember || g.Used <= 0 || handled[g.ID] {
continue
}
candidates = append(candidates, g)
}
if len(candidates) == 0 {
return nil
}
if effective := pickEffectiveMemberGrant(candidates); effective != nil {
return effective
}
picked := &candidates[0]
for i := 1; i < len(candidates); i++ {
if memberGrantBetter(&candidates[i], picked) {
picked = &candidates[i]
}
}
return picked
}
// getEffectiveQuota 返回某类额度的有效额度概览(Cos 永久 / 每日额度)。
// 有效总额度 = 免费配置值 + SUM(有效凭证 amount);已用按类型分别取永久累计或当日计数。
func getEffectiveQuota(ctx context.Context, userID int64, quotaType int8) (*EffectiveQuota, *errcode.Error) {
now := nowUnix()
freeTotal := defaultQuotaValue(quotaType)
vipExpire := getUserVIPExpire(ctx, userID)
grants, err := listValidGrants(ctx, userID, quotaType, vipExpire, now)
if err != nil {
@@ -148,12 +209,28 @@ func getEffectiveQuota(ctx context.Context, userID int64, quotaType int8) (*Effe
"user_id", userID, "quota_type", quotaType, "error", err)
return nil, errcode.DatabaseError
}
return effectiveQuotaFromGrants(ctx, userID, quotaType, grants), nil
}
// effectiveQuotaFromGrants 基于有效凭证快照聚合某类额度的有效额度概览(免费部分从库读取)。
// 有效总额度 = 免费配置值 + 会员包生效份(source=1,不叠加只取一份)+ 其他来源凭证 SUM;
// 已用按类型分别取永久累计或当日计数。
func effectiveQuotaFromGrants(ctx context.Context, userID int64, quotaType int8, grants []mysqlmodel.QuotaGrant) *EffectiveQuota {
freeTotal := defaultQuotaValue(quotaType)
// 会员包来源不叠加:只取生效的一份;加量包/广告/体验等其他来源仍叠加求和。
var grantTotal, grantUsed int64
for i := range grants {
if grants[i].Source == QuotaGrantSourceMember {
continue
}
grantTotal += grants[i].Amount
grantUsed += grants[i].Used
}
if memberGrant := pickEffectiveMemberGrant(grants); memberGrant != nil {
grantTotal += memberGrant.Amount
grantUsed += memberGrant.Used
}
if quotaType == QuotaTypeCos {
// 永久额度:免费已用来自 user_quotas.used(永久累计),赠送已用来自凭证 used。
@@ -170,7 +247,7 @@ func getEffectiveQuota(ctx context.Context, userID int64, quotaType int8) (*Effe
if remain < 0 {
remain = 0
}
return &EffectiveQuota{Total: total, Used: used, Remain: remain}, nil
return &EffectiveQuota{Total: total, Used: used, Remain: remain}
}
// 每日额度:免费与赠送合并为每日总额度,已用为当日计数。
@@ -180,7 +257,27 @@ func getEffectiveQuota(ctx context.Context, userID int64, quotaType int8) (*Effe
if remain < 0 {
remain = 0
}
return &EffectiveQuota{Total: total, Used: used, Remain: remain}, nil
return &EffectiveQuota{Total: total, Used: used, Remain: remain}
}
// effectiveQuotaRemainExcluding 计算「排除指定凭证(按 ID)」后的某类有效剩余,供退款回收差值记账使用。
// 聚合口径与 getEffectiveQuota 完全一致(会员包不叠加只取一份,其他来源叠加),
// 保证「退款回收」流水的变更量等于用户真实可感知的剩余变化。
func effectiveQuotaRemainExcluding(ctx context.Context, userID int64, quotaType int8, excluded map[int64]bool) (int64, error) {
now := nowUnix()
vipExpire := getUserVIPExpire(ctx, userID)
grants, err := listValidGrants(ctx, userID, quotaType, vipExpire, now)
if err != nil {
return 0, err
}
kept := make([]mysqlmodel.QuotaGrant, 0, len(grants))
for i := range grants {
if excluded[grants[i].ID] {
continue
}
kept = append(kept, grants[i])
}
return effectiveQuotaFromGrants(ctx, userID, quotaType, kept).Remain, nil
}
// dailyQuotaKey 按额度类型构造当日计数 Redis key。
@@ -275,7 +372,8 @@ func refundDailyQuota(ctx context.Context, userID int64, quotaType int8, n int64
}
}
// consumeCosQuota 消费 Cos 永久额度(先到期凭证先用,再扣免费额度),事务内保证数值与流水一致。
// consumeCosQuota 消费 Cos 永久额度(先到期凭证先用,再扣会员包生效份,最后扣免费额度),事务内保证数值与流水一致。
// 会员包来源(source=1)不叠加:多份会员包凭证中只扣生效的一份,未生效的份不参与扣减。
func consumeCosQuota(ctx context.Context, userID int64, n int64, relatedID int64, reason string) *errcode.Error {
now := nowUnix()
vipExpire := getUserVIPExpire(ctx, userID)
@@ -296,32 +394,54 @@ func consumeCosQuota(ctx context.Context, userID int64, n int64, relatedID int64
return err
}
remain := n
for i := range grants {
if remain <= 0 {
break
// consumeGrant 扣减单份凭证的已用(条件更新防并发超扣,扣不动则整单回滚)。
consumeGrant := func(g *mysqlmodel.QuotaGrant, want int64) (int64, error) {
delta := g.Amount - g.Used
if delta <= 0 {
return 0, nil
}
avail := grants[i].Amount - grants[i].Used
if avail <= 0 {
continue
}
delta := avail
if delta > remain {
delta = remain
if delta > want {
delta = want
}
res := tx.Model(&mysqlmodel.QuotaGrant{}).
Where("id = ? AND used + ? <= amount", grants[i].ID, delta).
Where("id = ? AND used + ? <= amount", g.ID, delta).
UpdateColumn("used", gorm.Expr("used + ?", delta))
if res.Error != nil {
return res.Error
return 0, res.Error
}
if res.RowsAffected == 0 {
// 凭证在行锁下被并发改写(理论上不应发生)。此时若继续累加会少扣额度,
// 直接回滚整个事务并报额度不足,宁可让用户重试也不超额放行。
return errcode.QuotaInsufficient
return 0, errcode.QuotaInsufficient
}
return delta, nil
}
remain := n
// 1.1 先扣非会员包凭证(加量包/广告/体验,先到期先用)。
for i := range grants {
if remain <= 0 {
break
}
if grants[i].Source == QuotaGrantSourceMember {
continue
}
delta, cerr := consumeGrant(&grants[i], remain)
if cerr != nil {
return cerr
}
remain -= delta
}
// 1.2 会员包额度不叠加:只扣生效的一份,未生效的份不参与扣减。
if remain > 0 {
if memberGrant := pickEffectiveMemberGrant(grants); memberGrant != nil {
delta, cerr := consumeGrant(memberGrant, remain)
if cerr != nil {
return cerr
}
remain -= delta
}
}
// 2. 凭证不够再扣免费额度(免费上限以配置值为准,避免历史 user_quotas.total 污染)。
if remain > 0 {
@@ -368,6 +488,7 @@ func consumeCosQuota(ctx context.Context, userID int64, n int64, relatedID int64
}
// cosRemainTx 在事务内计算 Cos 有效剩余(免费剩余 + 有效凭证剩余)。
// 会员包来源不叠加:只计生效的一份,与 getEffectiveQuota 展示口径保持一致。
func cosRemainTx(ctx context.Context, tx *gorm.DB, userID int64, vipExpire, now int64) int64 {
freeRemain := int64(0)
var freeQ mysqlmodel.Quota
@@ -375,16 +496,31 @@ func cosRemainTx(ctx context.Context, tx *gorm.DB, userID int64, vipExpire, now
freeRemain = defaultQuotaValue(QuotaTypeCos) - freeQ.Used
}
var grantRemain int64
q := tx.Model(&mysqlmodel.QuotaGrant{}).Where("user_id = ? AND quota_type = ?", userID, QuotaTypeCos)
var grants []mysqlmodel.QuotaGrant
q := tx.Where("user_id = ? AND quota_type = ?", userID, QuotaTypeCos)
if vipExpire > now {
q = q.Where("(expire_time > ? OR expire_time = 0)", now)
} else {
q = q.Where("expire_time > ?", now)
}
if err := q.Select("COALESCE(SUM(amount - used), 0)").Scan(&grantRemain).Error; err != nil {
if err := q.Find(&grants).Error; err != nil {
slog.WarnContext(ctx, "calc cos grant remain failed", "user_id", userID, "error", err)
}
var grantRemain int64
for i := range grants {
if grants[i].Source == QuotaGrantSourceMember {
continue
}
if r := grants[i].Amount - grants[i].Used; r > 0 {
grantRemain += r
}
}
if memberGrant := pickEffectiveMemberGrant(grants); memberGrant != nil {
if r := memberGrant.Amount - memberGrant.Used; r > 0 {
grantRemain += r
}
}
if freeRemain < 0 {
freeRemain = 0
}
@@ -392,7 +528,8 @@ func cosRemainTx(ctx context.Context, tx *gorm.DB, userID int64, vipExpire, now
}
// refundCosQuotaTx 在事务内回退 Cos 额度并写流水。
// 回退策略(后进先出):优先回退免费额度(最后扣减的),不足再回退凭证(最后到期优先)。
// 回退策略(后进先出):优先回退免费额度(最后扣减的),再回退非会员包凭证(最后到期优先),
// 最后回退会员包凭证(不叠加口径:优先当前生效份,生效份无可退额度时复活刚被扣尽的份)。
func refundCosQuotaTx(ctx context.Context, tx *gorm.DB, userID int64, vipExpire int64, n int64, relatedID int64, reason string) error {
now := nowUnix()
@@ -408,10 +545,10 @@ func refundCosQuotaTx(ctx context.Context, tx *gorm.DB, userID int64, vipExpire
remain = 0
}
// 2. 免费额度不足,回退凭证(按到期时间倒序,最后到期的优先回退)。
// 2. 免费额度不足,先回退非会员包凭证(加量包/广告/体验,按到期时间倒序,最后到期的优先回退)。
if remain > 0 {
var grants []mysqlmodel.QuotaGrant
if err := tx.Where("user_id = ? AND quota_type = ? AND used > 0", userID, QuotaTypeCos).
if err := tx.Where("user_id = ? AND quota_type = ? AND used > 0 AND source <> ?", userID, QuotaTypeCos, QuotaGrantSourceMember).
Order("expire_time DESC").
Clauses(clause.Locking{Strength: "UPDATE"}).
Find(&grants).Error; err != nil {
@@ -434,7 +571,35 @@ func refundCosQuotaTx(ctx context.Context, tx *gorm.DB, userID int64, vipExpire
}
}
// 3. 写回退流水:变更量取「实际回退量」。
// 3. 再回退会员包凭证(不叠加口径:按回退选择规则逐份回退,未生效份不作为回退目标)。
if remain > 0 {
var grants []mysqlmodel.QuotaGrant
if err := tx.Where("user_id = ? AND quota_type = ? AND used > 0 AND source = ?", userID, QuotaTypeCos, QuotaGrantSourceMember).
Clauses(clause.Locking{Strength: "UPDATE"}).
Find(&grants).Error; err != nil {
return err
}
handled := make(map[int64]bool, len(grants))
for remain > 0 {
target := pickMemberGrantForRefund(grants, handled)
if target == nil {
break
}
delta := target.Used
if delta > remain {
delta = remain
}
if err := tx.Model(&mysqlmodel.QuotaGrant{}).
Where("id = ? AND used >= ?", target.ID, delta).
UpdateColumn("used", gorm.Expr("used - ?", delta)).Error; err != nil {
return err
}
remain -= delta
handled[target.ID] = true
}
}
// 4. 写回退流水:变更量取「实际回退量」。
// 此前 `remain > 0` 时直接 return,导致额度已被部分改回却没有流水(账实不一致);
// 同时 ChangeNum 写的是请求量 n,退不满时数字虚高。现统一按实际回退量记账,
// 退不满/退不动时在原因里标注并告警(已用额度不足通常意味着重复回退或数据异常,必须留痕)。