feat(backend): 对齐业务服务额度与内容治理口径

仪表盘待办与穿搭概况改为公开/私密/已封禁口径,移除恒为零的待审核统计;补齐额度凭证来源 source=4 与流水 change_type=7 枚举及订单商品类型、额度有效期快照字段;管理员发放与回收额度凭证在同一事务内写入人工调整流水,回收流水使用独立 order_no 避免撞唯一索引,并增加凭证归属校验;对齐 Quota 配置免费额度值,同步更新 README 与配置说明。
This commit is contained in:
2026-09-16 02:58:56 +08:00
parent 5d6109bb52
commit 0863234bc5
13 changed files with 460 additions and 110 deletions
+170 -26
View File
@@ -2,12 +2,26 @@ 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
@@ -18,18 +32,6 @@ func NewQuotaGrantDao() *QuotaGrantDao {
return &QuotaGrantDao{db: global.MysqlEngine}
}
// QuotaGrantCreate 创建额度凭证(ref_id+quota_type 唯一索引兜底幂等,冲突返回 1062)。
func (d *QuotaGrantDao) QuotaGrantCreate(ctx context.Context, grant *mysqlmodel.QuotaGrant) error {
ctx, span := global.Tracer.Start(ctx, "mysqldao.QuotaGrantCreate")
defer span.End()
if err := d.db.WithContext(ctx).Create(grant).Error; err != nil {
span.RecordError(err)
return err
}
return nil
}
// QuotaGrantListByUserID 查询用户全部凭证(未删除),按 id 倒序。
func (d *QuotaGrantDao) QuotaGrantListByUserID(ctx context.Context, userID int64) ([]mysqlmodel.QuotaGrant, error) {
ctx, span := global.Tracer.Start(ctx, "mysqldao.QuotaGrantListByUserID")
@@ -56,20 +58,6 @@ func (d *QuotaGrantDao) QuotaGrantGetByID(ctx context.Context, id int64) (*mysql
return &grant, nil
}
// QuotaGrantSoftDelete 软删除凭证(按 id + user_id 归属校验)。
func (d *QuotaGrantDao) QuotaGrantSoftDelete(ctx context.Context, id, userID int64) error {
ctx, span := global.Tracer.Start(ctx, "mysqldao.QuotaGrantSoftDelete")
defer span.End()
if err := d.db.WithContext(ctx).
Where("id = ? AND user_id = ?", id, userID).
Delete(&mysqlmodel.QuotaGrant{}).Error; err != nil {
span.RecordError(err)
return err
}
return 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")
@@ -99,3 +87,159 @@ func (d *QuotaGrantDao) UserDailyUsageGetByUserIDAndDate(ctx context.Context, us
}
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
}