first commit
This commit is contained in:
@@ -0,0 +1,465 @@
|
||||
// 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) {
|
||||
usageDao := mysqldao.NewUserDailyUsageDao()
|
||||
statDate := todayStatDate()
|
||||
|
||||
// 确保当日计数行存在。
|
||||
if _, err := usageDao.UserDailyUsageGetByUserIDAndDate(ctx, userID, statDate); err != nil {
|
||||
if isNotFound(err) {
|
||||
if cerr := usageDao.UserDailyUsageCreate(ctx, &mysqlmodel.UserDailyUsage{
|
||||
UserID: userID,
|
||||
StatDate: statDate,
|
||||
}); cerr != nil {
|
||||
slog.WarnContext(ctx, "create daily usage failed", "user_id", userID, "error", cerr)
|
||||
return
|
||||
}
|
||||
} else {
|
||||
slog.WarnContext(ctx, "get daily usage failed", "user_id", userID, "error", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
var err error
|
||||
switch kind {
|
||||
case QuotaTypeAI:
|
||||
err = usageDao.UserDailyUsageIncrementAI(ctx, userID, statDate)
|
||||
case QuotaTypeOutfitShare:
|
||||
err = usageDao.UserDailyUsageIncrementOutfitShare(ctx, userID, statDate)
|
||||
case QuotaTypeClosetShare:
|
||||
err = usageDao.UserDailyUsageIncrementClosetShare(ctx, userID, statDate)
|
||||
}
|
||||
if err != nil {
|
||||
slog.WarnContext(ctx, "increment daily usage failed", "user_id", userID, "kind", kind, "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 读取当日已用计数(Redis 优先,异常回落 MySQL 统计)。
|
||||
func getDailyUsed(ctx context.Context, userID int64, quotaType int8) int64 {
|
||||
key := dailyQuotaKey(quotaType, userID, todayStatDate())
|
||||
if key == "" {
|
||||
return 0
|
||||
}
|
||||
if val, err := redisdao.DailyQuotaValue(ctx, key); err == nil {
|
||||
return val
|
||||
}
|
||||
return getDailyUsedFromMySQL(ctx, userID, quotaType)
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
val, err := redisdao.DailyQuotaConsume(ctx, key, quota.Total, n)
|
||||
if err != nil {
|
||||
return errcode.CacheError
|
||||
}
|
||||
if val < 0 {
|
||||
return errcode.QuotaInsufficient
|
||||
}
|
||||
// 同步 MySQL 当日计数(统计/兜底)。
|
||||
recordDailyUsage(ctx, userID, quotaType)
|
||||
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 := redisdao.DailyQuotaRefund(ctx, key, n); err != nil {
|
||||
slog.WarnContext(ctx, "refund daily quota 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
|
||||
}
|
||||
if err := tx.Model(&mysqlmodel.QuotaGrant{}).
|
||||
Where("id = ? AND used + ? <= amount", grants[i].ID, delta).
|
||||
UpdateColumn("used", gorm.Expr("used + ?", delta)).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
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
|
||||
}
|
||||
if remain > 0 {
|
||||
// 无足够已用额度可回退(数据异常),跳过写流水。
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 写回退流水。
|
||||
after := cosRemainTx(tx, userID, vipExpire, now)
|
||||
flow := &mysqlmodel.QuotaFlowLog{
|
||||
QuotaFlowUUID: genUUID(),
|
||||
UserID: userID,
|
||||
ChangeTarget: QuotaChangeTargetUse,
|
||||
ChangeType: QuotaChangeTypeRefund,
|
||||
ChangeNum: n,
|
||||
OldValue: after - n,
|
||||
NewValue: after,
|
||||
QuotaType: QuotaTypeCos,
|
||||
RelatedID: relatedID,
|
||||
Reason: reason,
|
||||
}
|
||||
// 回退类流水无业务订单号,用流水 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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user