feat(backend): 对齐业务服务额度与内容治理口径
仪表盘待办与穿搭概况改为公开/私密/已封禁口径,移除恒为零的待审核统计;补齐额度凭证来源 source=4 与流水 change_type=7 枚举及订单商品类型、额度有效期快照字段;管理员发放与回收额度凭证在同一事务内写入人工调整流水,回收流水使用独立 order_no 避免撞唯一索引,并增加凭证归属校验;对齐 Quota 配置免费额度值,同步更新 README 与配置说明。
This commit is contained in:
+170
-26
@@ -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("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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user