feat(backend): 对齐业务服务额度与内容治理口径
仪表盘待办与穿搭概况改为公开/私密/已封禁口径,移除恒为零的待审核统计;补齐额度凭证来源 source=4 与流水 change_type=7 枚举及订单商品类型、额度有效期快照字段;管理员发放与回收额度凭证在同一事务内写入人工调整流水,回收流水使用独立 order_no 避免撞唯一索引,并增加凭证归属校验;对齐 Quota 配置免费额度值,同步更新 README 与配置说明。
This commit is contained in:
+13
-11
@@ -40,11 +40,12 @@ type DashboardRevenue struct {
|
||||
RefundTotal int64 `json:"refund_total"` // 累计退款
|
||||
}
|
||||
|
||||
// DashboardTodo 待办统计。
|
||||
// DashboardTodo 待办与核心概览。
|
||||
type DashboardTodo struct {
|
||||
RefundPending int64 `json:"refund_pending"` // 待处理退款
|
||||
OutfitPendingAudit int64 `json:"outfit_pending_audit"` // 待审核穿搭
|
||||
GoodsOnShelf int64 `json:"goods_on_shelf"` // 上架商品数
|
||||
RefundPending int64 `json:"refund_pending"` // 待处理退款
|
||||
OutfitPublicTotal int64 `json:"outfit_public_total"` // 公开展示中的穿搭数(不含已封禁)
|
||||
OutfitBanned int64 `json:"outfit_banned"` // 已封禁穿搭
|
||||
GoodsOnShelf int64 `json:"goods_on_shelf"` // 上架商品数
|
||||
}
|
||||
|
||||
// ===== 内容审核概况 =====
|
||||
@@ -56,13 +57,14 @@ type DashboardContent struct {
|
||||
Share DashboardShareStats `json:"share"`
|
||||
}
|
||||
|
||||
// DashboardOutfitStats 穿搭审核统计。
|
||||
// DashboardOutfitStats 穿搭内容概况。
|
||||
// 业务侧用户公开穿搭时即自动过审,审核不再是公开的前置条件,故不再输出审核状态分布;
|
||||
// 公开/私密按 is_public 统计且均已剔除已封禁,三者在图表中互不重叠。
|
||||
type DashboardOutfitStats struct {
|
||||
Total int64 `json:"total"` // 穿搭总数
|
||||
Pending int64 `json:"pending"` // 待审核
|
||||
Passed int64 `json:"passed"` // 审核通过
|
||||
Rejected int64 `json:"rejected"` // 审核不通过
|
||||
Banned int64 `json:"banned"` // 封禁
|
||||
Total int64 `json:"total"` // 穿搭总数
|
||||
Public int64 `json:"public"` // 公开展示中(不含已封禁)
|
||||
Private int64 `json:"private"` // 私密(不含已封禁)
|
||||
Banned int64 `json:"banned"` // 已封禁
|
||||
}
|
||||
|
||||
// DashboardClothesStats 衣物图片处理统计。
|
||||
@@ -105,7 +107,7 @@ type DashboardGoods struct {
|
||||
|
||||
// GoodsTypeCount 商品类型数量。
|
||||
type GoodsTypeCount struct {
|
||||
Type int8 `json:"type"` // 1会员包 2上传包 3AI推荐包 4衣柜分享包 5搭配分享包
|
||||
Type int8 `json:"type"` // 1会员包 2上传包 3AI推荐包 4衣柜分享包 5搭配分享包
|
||||
Count int64 `json:"count"`
|
||||
}
|
||||
|
||||
|
||||
@@ -22,6 +22,8 @@ type OrderItem struct {
|
||||
OrderStatus int8 `json:"order_status"`
|
||||
TransactionID string `json:"transaction_id"`
|
||||
MemberDays int64 `json:"member_days"`
|
||||
GoodsType int8 `json:"goods_type"` // 商品类型快照 1=会员包 2=上传包 3=AI推荐包 4=衣柜分享包 5=搭配分享包
|
||||
QuotaValidDays int64 `json:"quota_valid_days"` // 额度有效期天数快照(加量包按此计算赠送有效期;会员包为 0 表示跟随 VIP)
|
||||
UploadQuota int64 `json:"upload_quota"`
|
||||
AIRecommendQuota int64 `json:"ai_recommend_quota"`
|
||||
ClosetShareQuota int64 `json:"closet_share_quota"`
|
||||
|
||||
@@ -47,7 +47,7 @@ type UserGrantItem struct {
|
||||
QuotaType int8 `json:"quota_type"` // 1=上传 2=AI 3=社区分享 4=衣柜分享
|
||||
Amount int64 `json:"amount"` // 赠送总量(Cos=一次性库存;AI/社区/衣柜=有效期内每日加量)
|
||||
Used int64 `json:"used"` // 已用量(仅 Cos 扣减)
|
||||
Source int8 `json:"source"` // 1=会员包 2=加量包 3=广告激励
|
||||
Source int8 `json:"source"` // 1=会员包 2=加量包 3=广告激励 4=体验会员赠送
|
||||
ExpireTime int64 `json:"expire_time"` // 到期时间(秒,0=跟随 VIP)
|
||||
RefID string `json:"ref_id"` // 关联ID(订单号/广告记录ID/人工发放标识)
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
|
||||
+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
|
||||
}
|
||||
|
||||
+37
-40
@@ -46,10 +46,10 @@ type RevenueStats struct {
|
||||
}
|
||||
|
||||
// TodoStats 待办统计。
|
||||
// 穿搭相关指标(公开展示中数量、已封禁数量)由 ContentStatsGet 一并聚合,此处不重复查询。
|
||||
type TodoStats struct {
|
||||
RefundPending int64 // 待处理退款数
|
||||
OutfitPendingAudit int64 // 待审核穿搭数
|
||||
GoodsOnShelf int64 // 上架商品数
|
||||
RefundPending int64 // 待处理退款数
|
||||
GoodsOnShelf int64 // 上架商品数
|
||||
}
|
||||
|
||||
// UserStatsGet 聚合用户总数与今日新增
|
||||
@@ -169,12 +169,6 @@ func (d *StatsDao) TodoStatsGet(ctx context.Context) (*TodoStats, error) {
|
||||
span.RecordError(err)
|
||||
return nil, err
|
||||
}
|
||||
if err := d.db.WithContext(ctx).Model(&mysqlmodel.Outfit{}).
|
||||
Where("is_public = ? AND audit_status = ?", int8(2), int8(1)).
|
||||
Count(&s.OutfitPendingAudit).Error; err != nil {
|
||||
span.RecordError(err)
|
||||
return nil, err
|
||||
}
|
||||
if err := d.db.WithContext(ctx).Model(&mysqlmodel.Goods{}).
|
||||
Where("status = ?", int8(1)).
|
||||
Count(&s.GoodsOnShelf).Error; err != nil {
|
||||
@@ -184,6 +178,13 @@ func (d *StatsDao) TodoStatsGet(ctx context.Context) (*TodoStats, error) {
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// outfitDistRow 穿搭 (is_public, status) 组合分布行。
|
||||
type outfitDistRow struct {
|
||||
IsPublic int8
|
||||
Status int8
|
||||
Cnt int64
|
||||
}
|
||||
|
||||
// countByInt8 通用分组计数行(小整型枚举 + 计数)。
|
||||
type countByInt8 struct {
|
||||
K int8
|
||||
@@ -205,20 +206,19 @@ type dateRevenue struct {
|
||||
|
||||
// ContentStats 内容审核统计。
|
||||
type ContentStats struct {
|
||||
OutfitTotal int64
|
||||
OutfitPending int64
|
||||
OutfitPassed int64
|
||||
OutfitRejected int64
|
||||
OutfitBanned int64
|
||||
ClothesTotal int64
|
||||
ClothesProcessing int64
|
||||
ClothesSuccess int64
|
||||
ClothesFailed int64
|
||||
ShareTotal int64
|
||||
ShareNormal int64
|
||||
ShareCancelled int64
|
||||
ShareExpired int64
|
||||
ShareBanned int64
|
||||
OutfitTotal int64 // 穿搭总数
|
||||
OutfitPublic int64 // 公开展示中(is_public=2 且未封禁)
|
||||
OutfitPrivate int64 // 私密(is_public=1 且未封禁)
|
||||
OutfitBanned int64 // 已封禁(status=2)
|
||||
ClothesTotal int64 // 衣物总数
|
||||
ClothesProcessing int64 // 处理中(抠图/分类/排队)
|
||||
ClothesSuccess int64 // 处理成功
|
||||
ClothesFailed int64 // 处理失败
|
||||
ShareTotal int64 // 衣柜分享总数
|
||||
ShareNormal int64 // 正常
|
||||
ShareCancelled int64 // 用户取消
|
||||
ShareExpired int64 // 已过期
|
||||
ShareBanned int64 // 管理员封禁
|
||||
}
|
||||
|
||||
// VIPStats 会员统计。
|
||||
@@ -258,32 +258,29 @@ func (d *StatsDao) ContentStatsGet(ctx context.Context) (*ContentStats, error) {
|
||||
|
||||
s := &ContentStats{}
|
||||
|
||||
// 穿搭:audit_status 分布 + 封禁数
|
||||
outfitRows := []countByInt8{}
|
||||
// 穿搭:一次 group by 取回 (is_public, status) 组合分布。
|
||||
// 业务侧「公开穿搭」会在同一次更新中写入 is_public=2 与 audit_status=2(自动过审),
|
||||
// 因此审核状态不再作为公开的前置条件,内容治理以封禁为准;这里按 is_public 统计公开/私密,
|
||||
// 并把已封禁(status=2)单独成列、从公开/私密中剔除,使三者互不重叠、饼图不会重复计数。
|
||||
outfitRows := []outfitDistRow{}
|
||||
if err := d.db.WithContext(ctx).Model(&mysqlmodel.Outfit{}).
|
||||
Select("audit_status AS k, COUNT(*) AS cnt").
|
||||
Group("audit_status").
|
||||
Select("is_public, status, COUNT(*) AS cnt").
|
||||
Group("is_public, status").
|
||||
Scan(&outfitRows).Error; err != nil {
|
||||
span.RecordError(err)
|
||||
return nil, err
|
||||
}
|
||||
for _, r := range outfitRows {
|
||||
s.OutfitTotal += r.Cnt
|
||||
switch r.K {
|
||||
case 1:
|
||||
s.OutfitPending = r.Cnt
|
||||
case 2:
|
||||
s.OutfitPassed = r.Cnt
|
||||
case 3:
|
||||
s.OutfitRejected = r.Cnt
|
||||
switch {
|
||||
case r.Status == int8(2): // 已封禁
|
||||
s.OutfitBanned += r.Cnt
|
||||
case r.IsPublic == int8(2): // 公开
|
||||
s.OutfitPublic += r.Cnt
|
||||
case r.IsPublic == int8(1): // 私密
|
||||
s.OutfitPrivate += r.Cnt
|
||||
}
|
||||
}
|
||||
if err := d.db.WithContext(ctx).Model(&mysqlmodel.Outfit{}).
|
||||
Where("status = ?", int8(2)).
|
||||
Count(&s.OutfitBanned).Error; err != nil {
|
||||
span.RecordError(err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 衣物:image_process_status 分布(1/2/3 处理中,4 成功,5 失败)
|
||||
clothesRows := []countByInt8{}
|
||||
|
||||
@@ -71,6 +71,8 @@ type Order struct {
|
||||
OrderStatus int8 `gorm:"column:order_status"` // 订单状态 1=待支付 2=已支付 3=已取消 4=已过期 5=已退款
|
||||
TransactionID string `gorm:"column:transaction_id"` // 微信支付流水号
|
||||
MemberDays int64 `gorm:"column:member_days"` // 购买的会员天数(快照)
|
||||
GoodsType int8 `gorm:"column:goods_type"` // 商品类型(快照)
|
||||
QuotaValidDays int64 `gorm:"column:quota_valid_days"` // 额度有效期天数(快照)
|
||||
CosQuota int64 `gorm:"column:cos_quota"` // 赠送上传额度(快照)
|
||||
AIRecommendQuota int64 `gorm:"column:ai_recommend_quota"` // 赠送AI推荐额度(快照)
|
||||
ClosetShareQuota int64 `gorm:"column:closet_share_quota"` // 赠送衣柜分享额度(快照)
|
||||
@@ -225,7 +227,7 @@ type QuotaGrant struct {
|
||||
QuotaType int8 `gorm:"column:quota_type;index:idx_grant_user_type_expire,priority:2;uniqueIndex:uk_grant_ref_type,priority:2"` // 额度类型 1=cos 2=AI 3=社区分享 4=衣柜分享
|
||||
Amount int64 `gorm:"column:amount"` // 赠送总量
|
||||
Used int64 `gorm:"column:used"` // 已用量(仅 Cos 扣减)
|
||||
Source int8 `gorm:"column:source"` // 来源 1=会员包 2=加量包 3=广告激励
|
||||
Source int8 `gorm:"column:source"` // 来源 1=会员包 2=加量包 3=广告激励 4=体验会员赠送
|
||||
ExpireTime int64 `gorm:"column:expire_time;index:idx_grant_user_type_expire,priority:3"` // 到期时间(秒,0 表示跟随 VIP)
|
||||
RefID string `gorm:"column:ref_id;type:varchar(64);uniqueIndex:uk_grant_ref_type,priority:1"` // 关联ID(订单号/广告记录ID)
|
||||
}
|
||||
@@ -254,7 +256,7 @@ type QuotaFlowLog struct {
|
||||
QuotaFlowUUID string `gorm:"column:quota_flow_uuid"` // 配额流水UUID
|
||||
UserID int64 `gorm:"column:user_id"` // 用户ID
|
||||
ChangeTarget int8 `gorm:"column:change_target"` // 调整额度类型 1=配额调整 2=使用
|
||||
// ChangeType 变更类型 1=购买获赠 2=活动赠送 3=上传消耗 4=删除回退 5=人工调整 6=退款回收
|
||||
// ChangeType 变更类型 1=购买获赠 2=活动赠送 3=上传消耗 4=删除回退 5=人工调整 6=退款回收 7=到期失效
|
||||
ChangeType int8 `gorm:"column:change_type;uniqueIndex:uk_quota_flow_idem,priority:3"`
|
||||
ChangeNum int64 `gorm:"column:change_num"` // 变更数量(正数增加,负数减少)
|
||||
OldValue int64 `gorm:"column:old_value"` // 变更前数值
|
||||
@@ -314,7 +316,7 @@ func (UserNotification) TableName() string {
|
||||
type Like struct {
|
||||
Model
|
||||
// LikeType 业务类型 1=社区穿搭;与 TargetID、UserID 组成联合唯一索引,支撑点赞/取消点赞 upsert。
|
||||
LikeType int8 `gorm:"column:like_type;uniqueIndex:uk_like_user_target,priority:1"`
|
||||
LikeType int8 `gorm:"column:like_type;uniqueIndex:uk_like_user_target,priority:1"`
|
||||
TargetID int64 `gorm:"column:target_id;uniqueIndex:uk_like_user_target,priority:2"` // 穿搭ID
|
||||
UserID int64 `gorm:"column:user_id;uniqueIndex:uk_like_user_target,priority:3"` // 点赞用户ID
|
||||
Status int8 `gorm:"column:status"` // 状态 1=已点赞 0=已取消
|
||||
@@ -327,7 +329,7 @@ func (Like) TableName() string {
|
||||
type Collect struct {
|
||||
Model
|
||||
// CollectType 业务类型 1=社区穿搭;与 TargetID、UserID 组成联合唯一索引,支撑收藏/取消收藏 upsert。
|
||||
CollectType int8 `gorm:"column:collect_type;uniqueIndex:uk_collect_user_target,priority:1"`
|
||||
CollectType int8 `gorm:"column:collect_type;uniqueIndex:uk_collect_user_target,priority:1"`
|
||||
TargetID int64 `gorm:"column:target_id;uniqueIndex:uk_collect_user_target,priority:2"` // 业务目标ID
|
||||
UserID int64 `gorm:"column:user_id;uniqueIndex:uk_collect_user_target,priority:3"` // 收藏用户ID
|
||||
Status int8 `gorm:"column:status"` // 状态 1=已收藏 0=已取消
|
||||
|
||||
@@ -33,14 +33,19 @@ const (
|
||||
QuotaTypeClosetShare int8 = 4 // 衣柜分享
|
||||
|
||||
// 额度凭证来源(quota_grants.source)
|
||||
QuotaGrantSourceMember int8 = 1 // 会员包(跟随 VIP 有效期)
|
||||
QuotaGrantSourceMember int8 = 1 // 会员包(跟随 VIP 有效期,续期自动延长)
|
||||
QuotaGrantSourceAddon int8 = 2 // 加量包(独立有效期)
|
||||
QuotaGrantSourceAdReward int8 = 3 // 广告激励(当天有效)
|
||||
QuotaGrantSourceTrial int8 = 4 // 体验会员赠送(新用户注册;固定有效期=体验期结束,不随续费延长)
|
||||
|
||||
QuotaChangeTargetAdjust int8 = 1 // 配额调整
|
||||
QuotaChangeTargetUse int8 = 2 // 使用
|
||||
|
||||
QuotaChangeTypeManual int8 = 5 // 人工调整
|
||||
// 额度流水变更类型(quota_flow_logs.change_type)。
|
||||
// 购买获赠(1)/活动赠送(2)/上传消耗(3)/删除回退(4)/退款回收(6)/到期失效(7) 由业务服务写入,
|
||||
// 管理后台仅写入人工调整(5)。
|
||||
QuotaChangeTypeManual int8 = 5 // 人工调整(管理后台发放/回收额度时写入)
|
||||
QuotaChangeTypeExpire int8 = 7 // 到期失效(固定有效期凭证过期,由业务服务定时任务补记)
|
||||
)
|
||||
|
||||
// 免费额度内置兜底值(与业务服务 DefaultQuota* 对齐;仅当 Quota 配置段未配置时使用)。
|
||||
|
||||
@@ -92,17 +92,18 @@ func (s *DashboardService) Summary(ctx context.Context) (*dto.DashboardResponse,
|
||||
RefundTotal: revenueStats.RefundTotal,
|
||||
},
|
||||
Todo: dto.DashboardTodo{
|
||||
RefundPending: todoStats.RefundPending,
|
||||
OutfitPendingAudit: todoStats.OutfitPendingAudit,
|
||||
GoodsOnShelf: todoStats.GoodsOnShelf,
|
||||
RefundPending: todoStats.RefundPending,
|
||||
// 穿搭口径复用内容概况聚合结果,避免对 outfits 表重复计数。
|
||||
OutfitPublicTotal: contentStats.OutfitPublic,
|
||||
OutfitBanned: contentStats.OutfitBanned,
|
||||
GoodsOnShelf: todoStats.GoodsOnShelf,
|
||||
},
|
||||
Content: dto.DashboardContent{
|
||||
Outfit: dto.DashboardOutfitStats{
|
||||
Total: contentStats.OutfitTotal,
|
||||
Pending: contentStats.OutfitPending,
|
||||
Passed: contentStats.OutfitPassed,
|
||||
Rejected: contentStats.OutfitRejected,
|
||||
Banned: contentStats.OutfitBanned,
|
||||
Total: contentStats.OutfitTotal,
|
||||
Public: contentStats.OutfitPublic,
|
||||
Private: contentStats.OutfitPrivate,
|
||||
Banned: contentStats.OutfitBanned,
|
||||
},
|
||||
Clothes: dto.DashboardClothesStats{
|
||||
Total: contentStats.ClothesTotal,
|
||||
|
||||
@@ -164,6 +164,8 @@ func buildOrderItem(o *mysqlmodel.Order) dto.OrderItem {
|
||||
OrderStatus: o.OrderStatus,
|
||||
TransactionID: o.TransactionID,
|
||||
MemberDays: o.MemberDays,
|
||||
GoodsType: o.GoodsType,
|
||||
QuotaValidDays: o.QuotaValidDays,
|
||||
UploadQuota: o.CosQuota,
|
||||
AIRecommendQuota: o.AIRecommendQuota,
|
||||
ClosetShareQuota: o.ClosetShareQuota,
|
||||
|
||||
@@ -233,7 +233,18 @@ func (s *UserService) QuotaAdjust(ctx context.Context, adminID int64, userUUID s
|
||||
grant.CreatedBy = adminID
|
||||
grant.UpdatedBy = adminID
|
||||
|
||||
if err := s.quotaGrantDao.QuotaGrantCreate(ctx, grant); err != nil {
|
||||
// 凭证与「人工调整」流水同事务写入:小程序额度明细中能看到对应的增加记录,
|
||||
// 避免业务侧到期失效任务补记了扣减、却查不到发放来源的账实不符。
|
||||
if err := s.quotaGrantDao.QuotaGrantCreateWithFlow(ctx, grant, mysqldao.QuotaFlowParams{
|
||||
UserID: user.ID,
|
||||
VipExpire: user.VIPExpire,
|
||||
QuotaType: request.QuotaType,
|
||||
Delta: request.Amount,
|
||||
FreeTotal: defaultQuotaValue(request.QuotaType),
|
||||
StatDate: todayStatDate(),
|
||||
OrderNo: grant.RefID, // 与业务侧到期失效流水按 ref_id 关联的口径保持一致
|
||||
Reason: "管理员发放额度凭证",
|
||||
}); err != nil {
|
||||
return errcode.DatabaseError
|
||||
}
|
||||
return nil
|
||||
@@ -252,14 +263,36 @@ func (s *UserService) GrantRevoke(ctx context.Context, adminID int64, userUUID s
|
||||
return errcode.DatabaseError
|
||||
}
|
||||
|
||||
if _, err := s.quotaGrantDao.QuotaGrantGetByID(ctx, grantID); err != nil {
|
||||
grant, err := s.quotaGrantDao.QuotaGrantGetByID(ctx, grantID)
|
||||
if err != nil {
|
||||
if isNotFound(err) {
|
||||
return errcode.UserGrantNotFound
|
||||
}
|
||||
return errcode.DatabaseError
|
||||
}
|
||||
// 归属校验:凭证必须属于该用户,防止越权回收他人凭证。
|
||||
if grant.UserID != user.ID {
|
||||
return errcode.UserGrantNotFound
|
||||
}
|
||||
|
||||
if err := s.quotaGrantDao.QuotaGrantSoftDelete(ctx, grantID, user.ID); err != nil {
|
||||
// 回收使「未用完的部分」失效:Cos 为 Amount-Used,每日额度 Used 恒为 0(口径与业务侧到期失效一致)。
|
||||
revoke := grant.Amount - grant.Used
|
||||
if revoke < 0 {
|
||||
revoke = 0
|
||||
}
|
||||
// 回收流水使用独立 order_no:同一凭证的发放流水同为 change_type=5,
|
||||
// 复用 ref_id 会撞 uk_quota_flow_idem(order_no, quota_type, change_type) 唯一索引。
|
||||
if err := s.quotaGrantDao.QuotaGrantRevokeWithFlow(ctx, mysqldao.QuotaFlowParams{
|
||||
UserID: user.ID,
|
||||
VipExpire: user.VIPExpire,
|
||||
QuotaType: grant.QuotaType,
|
||||
Delta: -revoke,
|
||||
FreeTotal: defaultQuotaValue(grant.QuotaType),
|
||||
StatDate: todayStatDate(),
|
||||
OrderNo: "revoke_" + genUUID(),
|
||||
RelatedID: grant.ID,
|
||||
Reason: "管理员回收额度凭证",
|
||||
}); err != nil {
|
||||
return errcode.DatabaseError
|
||||
}
|
||||
return nil
|
||||
|
||||
Reference in New Issue
Block a user