package mysqldao import ( "context" "errors" "time" "git.lzcwy.cn/zhengchiliu/zt-closet-manage-backend/global" "git.lzcwy.cn/zhengchiliu/zt-closet-manage-backend/internal/mysqlmodel" "gorm.io/gorm" ) // 额度类型(user_quotas.quota_type / quota_grants.quota_type / quota_flow_logs.quota_type), // 取值与业务服务保持一致;mysqldao 不反向依赖 service 常量,故在此独立定义。 const ( quotaTypeCos int8 = 1 // COS 上传 quotaTypeAI int8 = 2 // AI 推荐 quotaTypeOutfitShare int8 = 3 // 社区分享 quotaTypeClosetShare int8 = 4 // 衣柜分享 quotaChangeTargetAdjust int8 = 1 // 额度流水 change_target:配额调整 quotaChangeTypeManual int8 = 5 // 额度流水 change_type:人工调整(管理后台发放/回收写入) ) // QuotaGrantDao 额度凭证数据访问(有有效期赠送额度)。 type QuotaGrantDao struct { db *gorm.DB } // NewQuotaGrantDao 创建额度凭证 DAO。 func NewQuotaGrantDao() *QuotaGrantDao { return &QuotaGrantDao{db: global.MysqlEngine} } // QuotaGrantListByUserID 查询用户全部凭证(未删除),按 id 倒序。 func (d *QuotaGrantDao) QuotaGrantListByUserID(ctx context.Context, userID int64) ([]mysqlmodel.QuotaGrant, error) { ctx, span := global.Tracer.Start(ctx, "mysqldao.QuotaGrantListByUserID") defer span.End() var list []mysqlmodel.QuotaGrant if err := d.db.WithContext(ctx).Where("user_id = ?", userID).Order("id DESC").Find(&list).Error; err != nil { span.RecordError(err) return nil, err } return list, nil } // QuotaGrantGetByID 按主键查询凭证(未删除)。 func (d *QuotaGrantDao) QuotaGrantGetByID(ctx context.Context, id int64) (*mysqlmodel.QuotaGrant, error) { ctx, span := global.Tracer.Start(ctx, "mysqldao.QuotaGrantGetByID") defer span.End() var grant mysqlmodel.QuotaGrant if err := d.db.WithContext(ctx).First(&grant, id).Error; err != nil { span.RecordError(err) return nil, err } return &grant, nil } // QuotaGetByUserIDAndType 查询用户某类配额(Cos 永久额度的已用/底值)。 func (d *QuotaGrantDao) QuotaGetByUserIDAndType(ctx context.Context, userID int64, quotaType int8) (*mysqlmodel.Quota, error) { ctx, span := global.Tracer.Start(ctx, "mysqldao.QuotaGetByUserIDAndType") defer span.End() var quota mysqlmodel.Quota if err := d.db.WithContext(ctx). Where("user_id = ? AND quota_type = ?", userID, quotaType). First("a).Error; err != nil { span.RecordError(err) return nil, err } return "a, nil } // UserDailyUsageGetByUserIDAndDate 查询用户某日使用计数(当日额度已用,Redis 异常时 MySQL 兜底)。 func (d *QuotaGrantDao) UserDailyUsageGetByUserIDAndDate(ctx context.Context, userID, statDate int64) (*mysqlmodel.UserDailyUsage, error) { ctx, span := global.Tracer.Start(ctx, "mysqldao.UserDailyUsageGetByUserIDAndDate") defer span.End() var usage mysqlmodel.UserDailyUsage if err := d.db.WithContext(ctx). Where("user_id = ? AND stat_date = ?", userID, statDate). First(&usage).Error; err != nil { span.RecordError(err) return nil, err } return &usage, nil } // QuotaFlowParams 人工额度调整流水参数。 // OldValue/NewValue 语义与业务服务一致:变更前后该类额度的「有效剩余」,满足「变更前 + ChangeNum = 变更后」。 type QuotaFlowParams struct { UserID int64 // 用户ID VipExpire int64 // 用户 VIP 到期时间(凭证 expire_time=0 时跟随 VIP) QuotaType int8 // 额度类型 1=cos 2=AI 3=社区分享 4=衣柜分享 Delta int64 // 本次有效额度变化量(发放为正、回收为负) FreeTotal int64 // 免费额度底值(由 service 依 Quota 配置段计算,DAO 不反向依赖 service 常量) StatDate int64 // 当日统计日期 YYYYMMDD(每日额度已用取数用) OrderNo string // 流水关联号:发放用凭证 ref_id;回收用独立 revoke_ 前缀(避免同凭证撞唯一索引) RelatedID int64 // 关联凭证ID Reason string // 变更原因 } // QuotaGrantCreateWithFlow 事务内创建额度凭证并写「人工调整」流水(change_type=5)。 // 凭证与流水同事务提交,避免出现「凭证已发但无流水」导致小程序额度明细账实不符。 func (d *QuotaGrantDao) QuotaGrantCreateWithFlow(ctx context.Context, grant *mysqlmodel.QuotaGrant, p QuotaFlowParams) error { ctx, span := global.Tracer.Start(ctx, "mysqldao.QuotaGrantCreateWithFlow") defer span.End() err := d.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { if err := tx.Create(grant).Error; err != nil { return err } p.RelatedID = grant.ID // 变更后剩余从事务内取,保证与本次写入一致(发放后新增量已计入)。 remainAfter, err := quotaRemainTx(ctx, tx, p) if err != nil { return err } return tx.Create(buildQuotaFlow(p, remainAfter)).Error }) if err != nil { span.RecordError(err) } return err } // QuotaGrantRevokeWithFlow 事务内软删除额度凭证并写「人工调整」流水(change_type=5)。 func (d *QuotaGrantDao) QuotaGrantRevokeWithFlow(ctx context.Context, p QuotaFlowParams) error { ctx, span := global.Tracer.Start(ctx, "mysqldao.QuotaGrantRevokeWithFlow") defer span.End() err := d.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { // 按 id + user_id 归属校验,防越权回收他人凭证。 res := tx.Where("id = ? AND user_id = ?", p.RelatedID, p.UserID).Delete(&mysqlmodel.QuotaGrant{}) if res.Error != nil { return res.Error } if res.RowsAffected == 0 { return gorm.ErrRecordNotFound } remainAfter, err := quotaRemainTx(ctx, tx, p) if err != nil { return err } return tx.Create(buildQuotaFlow(p, remainAfter)).Error }) if err != nil { span.RecordError(err) } return err } // buildQuotaFlow 依据「变更后有效剩余」反推变更前值,构造人工调整流水, // 保证明细满足「变更前 + 变更量 = 变更后」(与业务服务到期失效流水的构造方式一致)。 func buildQuotaFlow(p QuotaFlowParams, remainAfter int64) *mysqlmodel.QuotaFlowLog { return &mysqlmodel.QuotaFlowLog{ QuotaFlowUUID: genFlowUUID(), UserID: p.UserID, ChangeTarget: quotaChangeTargetAdjust, ChangeType: quotaChangeTypeManual, ChangeNum: p.Delta, OldValue: remainAfter - p.Delta, NewValue: remainAfter, OrderNo: p.OrderNo, QuotaType: p.QuotaType, RelatedID: p.RelatedID, Reason: p.Reason, } } // quotaRemainTx 事务内计算用户某类额度的有效剩余,口径与 service 层用户详情一致: // 有效剩余 = 免费底值 + 有效凭证总量 - 已用量(Cos 为永久累计已用;其余为当日已用),不足则归零。 func quotaRemainTx(ctx context.Context, tx *gorm.DB, p QuotaFlowParams) (int64, error) { now := time.Now().Unix() // GORM 软删除自动过滤已回收凭证;此处只需再按有效期判断(数量级为用户维度,直接内存过滤)。 var grants []mysqlmodel.QuotaGrant if err := tx.WithContext(ctx). Where("user_id = ? AND quota_type = ?", p.UserID, p.QuotaType). Find(&grants).Error; err != nil { return 0, err } var validAmount, validUsed int64 for i := range grants { g := &grants[i] if quotaGrantExpired(g.ExpireTime, p.VipExpire, now) { continue } validAmount += g.Amount validUsed += g.Used } var used int64 if p.QuotaType == quotaTypeCos { used = validUsed var quota mysqlmodel.Quota switch err := tx.WithContext(ctx). Where("user_id = ? AND quota_type = ?", p.UserID, p.QuotaType). First("a).Error; { case err == nil: used += quota.Used case errors.Is(err, gorm.ErrRecordNotFound): // 无免费额度记录视为已用 0。 default: return 0, err } } else { var usage mysqlmodel.UserDailyUsage switch err := tx.WithContext(ctx). Where("user_id = ? AND stat_date = ?", p.UserID, p.StatDate). First(&usage).Error; { case err == nil: switch p.QuotaType { case quotaTypeAI: used = usage.AIRecommendCount case quotaTypeOutfitShare: used = usage.OutfitShareCount case quotaTypeClosetShare: used = usage.ClosetShareCount } case errors.Is(err, gorm.ErrRecordNotFound): // 当日无使用记录视为已用 0。 default: return 0, err } } remain := p.FreeTotal + validAmount - used if remain < 0 { remain = 0 } return remain, nil } // quotaGrantExpired 判断凭证是否失效(与 service.grantExpired 口径一致): // expire_time=0 表示跟随 VIP 到期;其余按自身到期时间判断。 func quotaGrantExpired(expireTime, vipExpire, now int64) bool { if expireTime == 0 { return vipExpire <= now } return expireTime <= now }