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