Files
zt-closet-backend/internal/service/service.go
T
2026-09-15 16:02:45 +08:00

464 lines
16 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 {
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)
}
}