Files
zhengchiliu 0863234bc5 feat(backend): 对齐业务服务额度与内容治理口径
仪表盘待办与穿搭概况改为公开/私密/已封禁口径,移除恒为零的待审核统计;补齐额度凭证来源 source=4 与流水 change_type=7 枚举及订单商品类型、额度有效期快照字段;管理员发放与回收额度凭证在同一事务内写入人工调整流水,回收流水使用独立 order_no 避免撞唯一索引,并增加凭证归属校验;对齐 Quota 配置免费额度值,同步更新 README 与配置说明。
2026-09-16 02:58:56 +08:00

246 lines
8.6 KiB
Go
Raw Permalink 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 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(&quota).Error; err != nil {
span.RecordError(err)
return nil, err
}
return &quota, 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(&quota).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
}