Files
zt-closet-backend/internal/service/service.go
T
zhengchiliu b94dd6dffb feat(order): 重构退款全流程并新增申请退款中状态
- 订单状态新增「6 申请退款中」,用户申请退款原子流转并落待审核退款流水
- 退款失败回退订单至已支付并恢复被取消的权益发放任务
- 新增虚拟支付退款查单兜底任务,反查超时订单并自动订正
- 会员包来源额度不叠加,扣减/回退/回收口径统一为只取生效一份
- 退款回收流水按吊销前后有效剩余差值记账,避免无效回收记录
- 同步更新 README 与 Stitch 设计文档
2026-09-16 03:44:09 +08:00

641 lines
23 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 service 承载业务逻辑层,编排 dto、mysqldao、redisdao 与 pkg 能力。
//
// 约定:
// - 每个业务域一个 Service 结构体,NewXxxService() 构造,内部持有所需 DAO。
// - 方法统一返回 (*dto.XxxResponse, *errcode.Error),将底层 error 映射为语义化错误。
// - redis 未命中返回 redis.Nil,按缓存未命中处理,不视为错误。
// - 所有"我的"资源操作在本层做归属校验(AuthZ)。
package service
import (
"context"
"errors"
"fmt"
"log/slog"
"time"
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/global"
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/mysqldao"
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/mysqlmodel"
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/redisdao"
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/redismodel"
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/pkg/errcode"
"github.com/go-sql-driver/mysql"
"github.com/google/uuid"
"github.com/redis/go-redis/v9"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
// isNotFound 判断 gorm 是否为「记录不存在」。
func isNotFound(err error) bool {
return errors.Is(err, gorm.ErrRecordNotFound)
}
// isCacheMiss 判断 redis 是否为「未命中」。
func isCacheMiss(err error) bool {
return errors.Is(err, redis.Nil)
}
// isDuplicateKey 判断是否为 MySQL 唯一键冲突(Error 1062),用于幂等发放/回退。
func isDuplicateKey(err error) bool {
var mysqlErr *mysql.MySQLError
return errors.As(err, &mysqlErr) && mysqlErr.Number == 1062
}
// 分页默认值。
const (
defaultPage = 1
defaultPageSize = 10
maxPageSize = 50
)
// normalizePage 规整分页参数,返回可直接用于 DAO 的 page/pageSize。
func normalizePage(page, pageSize int) (int, int) {
if page <= 0 {
page = defaultPage
}
if pageSize <= 0 {
pageSize = defaultPageSize
}
if pageSize > maxPageSize {
pageSize = maxPageSize
}
return page, pageSize
}
// nowUnix 当前秒级时间戳。
func nowUnix() int64 {
return time.Now().Unix()
}
// todayStatDate 返回本地时区今日的 YYYYMMDD 整数。
func todayStatDate() int64 {
now := time.Now()
return int64(now.Year()*10000 + int(now.Month())*100 + now.Day())
}
// genUUID 生成新的 UUID 字符串。
func genUUID() string {
return uuid.NewString()
}
// recordDailyUsage 记录用户当日使用计数(best-effort,用于统计,不作为硬限制;失败仅告警)。
// kind 复用 QuotaType 语义:QuotaTypeAI / QuotaTypeOutfitShare / QuotaTypeClosetShare。
func recordDailyUsage(ctx context.Context, userID int64, kind int8, delta int64) {
if err := mysqldao.NewUserDailyUsageDao().UserDailyUsageAdjust(ctx, userID, todayStatDate(), kind, delta); err != nil {
slog.WarnContext(ctx, "adjust daily usage failed", "user_id", userID, "kind", kind, "delta", delta, "error", err)
}
}
// ---- 新额度体系:每日额度 + 凭证有效期 ----
// EffectiveQuota 某类额度的有效额度概览。
// Cos:Total=永久总额度、Used=永久累计已用、Remain=永久剩余;
// AI/Outfit/Closet:Total=每日总额度、Used=当日已用、Remain=当日剩余。
type EffectiveQuota struct {
Total int64
Used int64
Remain int64
}
// getUserVIPExpire 读取用户 VIP 到期时间(秒);用户不存在或查询失败返回 0(视为非会员)。
func getUserVIPExpire(ctx context.Context, userID int64) int64 {
user, err := mysqldao.NewUserDao().UserGetByID(ctx, userID)
if err != nil {
// 查询失败一律视为非会员(跟随 VIP 的凭证将按失效处理),必须留痕便于排查额度差异。
if !isNotFound(err) {
slog.WarnContext(ctx, "get user vip expire failed, treat as non-vip", "user_id", userID, "error", err)
}
return 0
}
return user.VIPExpire
}
// grantExpired 判断凭证是否已失效:expire_time=0 表示跟随 VIP,其余按到期时间判断。
func grantExpired(expireTime, vipExpire, now int64) bool {
if expireTime == 0 {
return vipExpire <= now
}
return expireTime <= now
}
// listValidGrants 查询用户某类型当前有效的额度凭证。
func listValidGrants(ctx context.Context, userID int64, quotaType int8, vipExpire, now int64) ([]mysqlmodel.QuotaGrant, error) {
grants, err := mysqldao.NewQuotaGrantDao().QuotaGrantListByUserIDAndType(ctx, userID, quotaType)
if err != nil {
return nil, err
}
valid := make([]mysqlmodel.QuotaGrant, 0, len(grants))
for _, g := range grants {
if !grantExpired(g.ExpireTime, vipExpire, now) {
valid = append(valid, g)
}
}
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 永久 / 每日额度)。
func getEffectiveQuota(ctx context.Context, userID int64, quotaType int8) (*EffectiveQuota, *errcode.Error) {
now := nowUnix()
vipExpire := getUserVIPExpire(ctx, userID)
grants, err := listValidGrants(ctx, userID, quotaType, vipExpire, now)
if err != nil {
slog.ErrorContext(ctx, "get effective quota: list valid grants failed",
"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。
freeUsed := int64(0)
if q, qerr := mysqldao.NewQuotaDao().QuotaGetByUserIDAndType(ctx, userID, quotaType); qerr == nil {
freeUsed = q.Used
} else if !isNotFound(qerr) {
slog.WarnContext(ctx, "get effective quota: query free quota failed, treat as 0",
"user_id", userID, "quota_type", quotaType, "error", qerr)
}
total := freeTotal + grantTotal
used := freeUsed + grantUsed
remain := total - used
if remain < 0 {
remain = 0
}
return &EffectiveQuota{Total: total, Used: used, Remain: remain}
}
// 每日额度:免费与赠送合并为每日总额度,已用为当日计数。
total := freeTotal + grantTotal
used := getDailyUsed(ctx, userID, quotaType)
remain := total - used
if remain < 0 {
remain = 0
}
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。
func dailyQuotaKey(quotaType int8, userID int64, date int64) string {
switch quotaType {
case QuotaTypeAI:
return fmt.Sprintf(redismodel.KeyQuotaDailyAI, userID, date)
case QuotaTypeOutfitShare:
return fmt.Sprintf(redismodel.KeyQuotaDailyOutfitShare, userID, date)
case QuotaTypeClosetShare:
return fmt.Sprintf(redismodel.KeyQuotaDailyClosetShare, userID, date)
default:
return ""
}
}
// getDailyUsed 读取 MySQL 权威当日计数;数据库异常时只把 Redis 作为展示兜底。
func getDailyUsed(ctx context.Context, userID int64, quotaType int8) int64 {
usage, err := mysqldao.NewUserDailyUsageDao().UserDailyUsageGetByUserIDAndDate(ctx, userID, todayStatDate())
if err == nil {
switch quotaType {
case QuotaTypeAI:
return usage.AIRecommendCount
case QuotaTypeOutfitShare:
return usage.OutfitShareCount
case QuotaTypeClosetShare:
return usage.ClosetShareCount
}
}
key := dailyQuotaKey(quotaType, userID, todayStatDate())
if val, rerr := redisdao.DailyQuotaValue(ctx, key); rerr == nil {
return val
}
return 0
}
// getDailyUsedFromMySQL 从 user_daily_usages 读取当日计数(Redis 故障兜底)。
func getDailyUsedFromMySQL(ctx context.Context, userID int64, quotaType int8) int64 {
usage, err := mysqldao.NewUserDailyUsageDao().UserDailyUsageGetByUserIDAndDate(ctx, userID, todayStatDate())
if err != nil {
return 0
}
switch quotaType {
case QuotaTypeAI:
return usage.AIRecommendCount
case QuotaTypeOutfitShare:
return usage.OutfitShareCount
case QuotaTypeClosetShare:
return usage.ClosetShareCount
default:
return 0
}
}
// consumeDailyQuota 原子消费 AI/Outfit/Closet 当日额度。
// 额度不足返回 errcode.QuotaInsufficient;Redis 异常返回 errcode.CacheError。
func consumeDailyQuota(ctx context.Context, userID int64, quotaType int8, n int64, reason string) *errcode.Error {
quota, qerr := getEffectiveQuota(ctx, userID, quotaType)
if qerr != nil {
return qerr
}
key := dailyQuotaKey(quotaType, userID, todayStatDate())
if key == "" {
return errcode.QuotaInsufficient
}
consumed, err := mysqldao.NewUserDailyUsageDao().UserDailyUsageConsume(ctx, userID, todayStatDate(), quotaType, n, quota.Total)
if err != nil {
return errcode.DatabaseError
}
if !consumed {
return errcode.QuotaInsufficient
}
// Redis 只作缓存;删除后下次从 MySQL 权威计数恢复,不受丢键影响。
if err := redisdao.DailyQuotaDelete(ctx, key); err != nil {
slog.WarnContext(ctx, "delete daily quota cache failed", "user_id", userID, "quota_type", quotaType, "error", err)
}
return nil
}
// refundDailyQuota 业务失败时回退当日已用计数(best-effort,保证不小于 0)。
func refundDailyQuota(ctx context.Context, userID int64, quotaType int8, n int64) {
key := dailyQuotaKey(quotaType, userID, todayStatDate())
if key == "" {
return
}
if err := mysqldao.NewUserDailyUsageDao().UserDailyUsageAdjust(ctx, userID, todayStatDate(), quotaType, -n); err != nil {
slog.WarnContext(ctx, "refund daily quota failed", "user_id", userID, "quota_type", quotaType, "error", err)
return
}
if err := redisdao.DailyQuotaDelete(ctx, key); err != nil {
slog.WarnContext(ctx, "delete daily quota cache after refund failed", "user_id", userID, "quota_type", quotaType, "error", err)
}
}
// 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)
err := global.MysqlEngine.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
// 1. 有效凭证(expire_time>now,或跟随 VIP 且 VIP 有效),按先到期先用锁定。
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.Order("CASE WHEN expire_time = 0 THEN 1 ELSE 0 END ASC").
Order("expire_time ASC").
Clauses(clause.Locking{Strength: "UPDATE"}).
Find(&grants).Error; err != nil {
return err
}
// consumeGrant 扣减单份凭证的已用(条件更新防并发超扣,扣不动则整单回滚)。
consumeGrant := func(g *mysqlmodel.QuotaGrant, want int64) (int64, error) {
delta := g.Amount - g.Used
if delta <= 0 {
return 0, nil
}
if delta > want {
delta = want
}
res := tx.Model(&mysqlmodel.QuotaGrant{}).
Where("id = ? AND used + ? <= amount", g.ID, delta).
UpdateColumn("used", gorm.Expr("used + ?", delta))
if res.Error != nil {
return 0, res.Error
}
if res.RowsAffected == 0 {
// 凭证在行锁下被并发改写(理论上不应发生)。此时若继续累加会少扣额度,
// 直接回滚整个事务并报额度不足,宁可让用户重试也不超额放行。
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 {
freeLimit := defaultQuotaValue(QuotaTypeCos)
res := tx.Model(&mysqlmodel.Quota{}).
Where("user_id = ? AND quota_type = ? AND used + ? <= ?", userID, QuotaTypeCos, remain, freeLimit).
UpdateColumn("used", gorm.Expr("used + ?", remain))
if res.Error != nil {
return res.Error
}
if res.RowsAffected == 0 {
return errcode.QuotaInsufficient
}
}
// 3. 写消费流水(old/new 记录 Cos 有效剩余)。
before := cosRemainTx(ctx, tx, userID, vipExpire, now)
flow := &mysqlmodel.QuotaFlowLog{
QuotaFlowUUID: genUUID(),
UserID: userID,
ChangeTarget: QuotaChangeTargetUse,
ChangeType: QuotaChangeTypeConsume,
ChangeNum: -n,
OldValue: before + n,
NewValue: before,
QuotaType: QuotaTypeCos,
RelatedID: relatedID,
Reason: reason,
}
// 消费类流水无业务订单号,用流水 UUID 填充 order_no 避免唯一索引空串冲突。
flow.OrderNo = flow.QuotaFlowUUID
return tx.Create(flow).Error
})
if err != nil {
if errors.Is(err, errcode.QuotaInsufficient) {
return errcode.QuotaInsufficient
}
slog.ErrorContext(ctx, "consume cos quota: transaction failed",
"user_id", userID, "amount", n, "related_id", relatedID, "error", err)
return errcode.DatabaseError
}
return nil
}
// cosRemainTx 在事务内计算 Cos 有效剩余(免费剩余 + 有效凭证剩余)。
// 会员包来源不叠加:只计生效的一份,与 getEffectiveQuota 展示口径保持一致。
func cosRemainTx(ctx context.Context, tx *gorm.DB, userID int64, vipExpire, now int64) int64 {
freeRemain := int64(0)
var freeQ mysqlmodel.Quota
if err := tx.Where("user_id = ? AND quota_type = ?", userID, QuotaTypeCos).First(&freeQ).Error; err == nil {
freeRemain = defaultQuotaValue(QuotaTypeCos) - freeQ.Used
}
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.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
}
return freeRemain + grantRemain
}
// refundCosQuotaTx 在事务内回退 Cos 额度并写流水。
// 回退策略(后进先出):优先回退免费额度(最后扣减的),再回退非会员包凭证(最后到期优先),
// 最后回退会员包凭证(不叠加口径:优先当前生效份,生效份无可退额度时复活刚被扣尽的份)。
func refundCosQuotaTx(ctx context.Context, tx *gorm.DB, userID int64, vipExpire int64, n int64, relatedID int64, reason string) error {
now := nowUnix()
remain := n
// 1. 优先回退免费额度(used >= n 时整体回退,否则不满足则跳过)。
res := tx.Model(&mysqlmodel.Quota{}).
Where("user_id = ? AND quota_type = ? AND used >= ?", userID, QuotaTypeCos, remain).
UpdateColumn("used", gorm.Expr("used - ?", remain))
if res.Error != nil {
return res.Error
}
if res.RowsAffected > 0 {
remain = 0
}
// 2. 免费额度不足,先回退非会员包凭证(加量包/广告/体验,按到期时间倒序,最后到期的优先回退)。
if remain > 0 {
var grants []mysqlmodel.QuotaGrant
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 {
return err
}
for i := range grants {
if remain <= 0 {
break
}
delta := grants[i].Used
if delta > remain {
delta = remain
}
if err := tx.Model(&mysqlmodel.QuotaGrant{}).
Where("id = ? AND used >= ?", grants[i].ID, delta).
UpdateColumn("used", gorm.Expr("used - ?", delta)).Error; err != nil {
return err
}
remain -= delta
}
}
// 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,退不满时数字虚高。现统一按实际回退量记账,
// 退不满/退不动时在原因里标注并告警(已用额度不足通常意味着重复回退或数据异常,必须留痕)。
refunded := n - remain
flowReason := reason
if refunded < n {
flowReason = fmt.Sprintf("%s(实际回退 %d/%d,可用已退额度不足)", reason, refunded, n)
slog.WarnContext(ctx, "refund cos quota partially applied",
"user_id", userID, "want", n, "refunded", refunded, "related_id", relatedID)
}
after := cosRemainTx(ctx, tx, userID, vipExpire, now)
flow := &mysqlmodel.QuotaFlowLog{
QuotaFlowUUID: genUUID(),
UserID: userID,
ChangeTarget: QuotaChangeTargetUse,
ChangeType: QuotaChangeTypeRefund,
ChangeNum: refunded,
OldValue: after - refunded,
NewValue: after,
QuotaType: QuotaTypeCos,
RelatedID: relatedID,
Reason: flowReason,
}
// 回退类流水无业务订单号,用流水 UUID 填充 order_no 避免唯一索引空串冲突。
flow.OrderNo = flow.QuotaFlowUUID
return tx.Create(flow).Error
}
// refundCosQuota 独立事务回退 Cos 额度(上传失败等非事务场景)。
func refundCosQuota(ctx context.Context, userID int64, n int64, relatedID int64, reason string) {
vipExpire := getUserVIPExpire(ctx, userID)
if err := global.MysqlEngine.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
return refundCosQuotaTx(ctx, tx, userID, vipExpire, n, relatedID, reason)
}); err != nil {
slog.WarnContext(ctx, "refund cos quota failed",
"user_id", userID, "amount", n, "related_id", relatedID, "reason", reason, "error", err)
}
}