// 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 { 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 } // 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 { return nil, errcode.DatabaseError } var grantTotal, grantUsed int64 for i := range grants { grantTotal += grants[i].Amount grantUsed += grants[i].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 } total := freeTotal + grantTotal used := freeUsed + grantUsed remain := total - used if remain < 0 { remain = 0 } return &EffectiveQuota{Total: total, Used: used, Remain: remain}, nil } // 每日额度:免费与赠送合并为每日总额度,已用为当日计数。 total := freeTotal + grantTotal used := getDailyUsed(ctx, userID, quotaType) remain := total - used if remain < 0 { remain = 0 } return &EffectiveQuota{Total: total, Used: used, Remain: 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 永久额度(先到期凭证先用,再扣免费额度),事务内保证数值与流水一致。 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 } remain := n for i := range grants { if remain <= 0 { break } avail := grants[i].Amount - grants[i].Used if avail <= 0 { continue } delta := avail if delta > remain { delta = remain } res := tx.Model(&mysqlmodel.QuotaGrant{}). Where("id = ? AND used + ? <= amount", grants[i].ID, delta). UpdateColumn("used", gorm.Expr("used + ?", delta)) if res.Error != nil { return res.Error } if res.RowsAffected == 0 { // 凭证在行锁下被并发改写(理论上不应发生)。此时若继续累加会少扣额度, // 直接回滚整个事务并报额度不足,宁可让用户重试也不超额放行。 return errcode.QuotaInsufficient } 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(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 } return errcode.DatabaseError } return nil } // cosRemainTx 在事务内计算 Cos 有效剩余(免费剩余 + 有效凭证剩余)。 func cosRemainTx(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 grantRemain int64 q := tx.Model(&mysqlmodel.QuotaGrant{}).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 { slog.WarnContext(context.Background(), "calc cos grant remain failed", "user_id", userID, "error", err) } if freeRemain < 0 { freeRemain = 0 } return freeRemain + grantRemain } // refundCosQuotaTx 在事务内回退 Cos 额度并写流水。 // 回退策略(后进先出):优先回退免费额度(最后扣减的),不足再回退凭证(最后到期优先)。 func refundCosQuotaTx(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", userID, QuotaTypeCos). 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. 写回退流水:变更量取「实际回退量」。 // 此前 `remain > 0` 时直接 return,导致额度已被部分改回却没有流水(账实不一致); // 同时 ChangeNum 写的是请求量 n,退不满时数字虚高。现统一按实际回退量记账, // 退不满/退不动时在原因里标注并告警(已用额度不足通常意味着重复回退或数据异常,必须留痕)。 refunded := n - remain flowReason := reason if refunded < n { flowReason = fmt.Sprintf("%s(实际回退 %d/%d,可用已退额度不足)", reason, refunded, n) slog.WarnContext(context.Background(), "refund cos quota partially applied", "user_id", userID, "want", n, "refunded", refunded, "related_id", relatedID) } after := cosRemainTx(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(tx, userID, vipExpire, n, relatedID, reason) }); err != nil { slog.WarnContext(ctx, "refund cos quota failed", "user_id", userID, "error", err) } }