first commit

This commit is contained in:
2026-09-11 01:41:52 +08:00
commit 9f22e6ccae
263 changed files with 28935 additions and 0 deletions
+502
View File
@@ -0,0 +1,502 @@
package mysqldao
import (
"context"
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/global"
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/mysqlmodel"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
// QuotaDao 用户配额数据访问
type QuotaDao struct {
db *gorm.DB
}
// NewQuotaDao 创建配额 DAO
func NewQuotaDao() *QuotaDao {
return &QuotaDao{db: global.MysqlEngine}
}
// QuotaCreate 创建配额记录
func (d *QuotaDao) QuotaCreate(ctx context.Context, quota *mysqlmodel.Quota) error {
ctx, span := global.Tracer.Start(ctx, "mysqldao.QuotaCreate")
defer span.End()
if err := d.db.WithContext(ctx).Create(quota).Error; err != nil {
span.RecordError(err)
return err
}
return nil
}
// QuotaIncreaseUsed 原子增加已用量(扣减配额),剩余不足时返回 false。
// 已废弃:仅改数值不写流水,存在账实不符风险;新代码请使用 QuotaConsumeWithFlow。
func (d *QuotaDao) QuotaIncreaseUsed(ctx context.Context, userID int64, quotaType int8, n int64) (bool, error) {
ctx, span := global.Tracer.Start(ctx, "mysqldao.QuotaIncreaseUsed")
defer span.End()
res := d.db.WithContext(ctx).Model(&mysqlmodel.Quota{}).
Where("user_id = ? AND quota_type = ? AND used + ? <= total", userID, quotaType, n).
UpdateColumn("used", gorm.Expr("used + ?", n))
if res.Error != nil {
span.RecordError(res.Error)
return false, res.Error
}
return res.RowsAffected > 0, nil
}
// QuotaDecreaseUsed 原子减少已用量(回退配额),已用量不足时返回 false。
// 已废弃:仅改数值不写流水,存在账实不符风险;新代码请使用 QuotaRefundWithFlow。
func (d *QuotaDao) QuotaDecreaseUsed(ctx context.Context, userID int64, quotaType int8, n int64) (bool, error) {
ctx, span := global.Tracer.Start(ctx, "mysqldao.QuotaDecreaseUsed")
defer span.End()
res := d.db.WithContext(ctx).Model(&mysqlmodel.Quota{}).
Where("user_id = ? AND quota_type = ? AND used >= ?", userID, quotaType, n).
UpdateColumn("used", gorm.Expr("used - ?", n))
if res.Error != nil {
span.RecordError(res.Error)
return false, res.Error
}
return res.RowsAffected > 0, nil
}
// QuotaConsumeWithFlow 事务内扣减配额并写流水,保证数值变更与流水强一致。
// 条件更新不满足(额度不足)返回 (false, nil);其余错误(含流水写入失败整体回滚)返回 error。
func (d *QuotaDao) QuotaConsumeWithFlow(ctx context.Context, userID int64, quotaType int8, n int64, flow *mysqlmodel.QuotaFlowLog) (bool, error) {
ctx, span := global.Tracer.Start(ctx, "mysqldao.QuotaConsumeWithFlow")
defer span.End()
var ok bool
err := d.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
res := tx.Model(&mysqlmodel.Quota{}).
Where("user_id = ? AND quota_type = ? AND used + ? <= total", userID, quotaType, n).
UpdateColumn("used", gorm.Expr("used + ?", n))
if res.Error != nil {
return res.Error
}
if res.RowsAffected == 0 {
return nil // 额度不足,无变更。
}
ok = true
// 回读真实值填充流水 old/new。
var cur mysqlmodel.Quota
if err := tx.Where("user_id = ? AND quota_type = ?", userID, quotaType).First(&cur).Error; err != nil {
return err
}
remain := cur.Total - cur.Used
flow.OldValue = remain + n
flow.NewValue = remain
// 写流水(失败则整个事务回滚,扣减一并撤销)。
ensureFlowOrderNo(flow)
return tx.Create(flow).Error
})
if err != nil {
span.RecordError(err)
return false, err
}
return ok, nil
}
// QuotaRefundWithFlow 事务内回退配额并写流水,保证数值变更与流水强一致。
// 条件更新不满足(已用量不足/重复回退)返回 (false, nil);其余错误(含流水失败回滚)返回 error。
func (d *QuotaDao) QuotaRefundWithFlow(ctx context.Context, userID int64, quotaType int8, n int64, flow *mysqlmodel.QuotaFlowLog) (bool, error) {
ctx, span := global.Tracer.Start(ctx, "mysqldao.QuotaRefundWithFlow")
defer span.End()
var ok bool
err := d.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
res := tx.Model(&mysqlmodel.Quota{}).
Where("user_id = ? AND quota_type = ? AND used >= ?", userID, quotaType, n).
UpdateColumn("used", gorm.Expr("used - ?", n))
if res.Error != nil {
return res.Error
}
if res.RowsAffected == 0 {
return nil // 已用量不足,无变更。
}
ok = true
// 回读真实值填充流水 old/new。
var cur mysqlmodel.Quota
if err := tx.Where("user_id = ? AND quota_type = ?", userID, quotaType).First(&cur).Error; err != nil {
return err
}
remain := cur.Total - cur.Used
flow.OldValue = remain - n
flow.NewValue = remain
// 写流水(失败则整个事务回滚,回退一并撤销)。
ensureFlowOrderNo(flow)
return tx.Create(flow).Error
})
if err != nil {
span.RecordError(err)
return false, err
}
return ok, nil
}
// QuotaInitWithFlow 事务内初始化配额并写流水。
// 依赖 uk_user_quota 唯一索引(OnConflict DoNothing)实现并发安全幂等:已存在则跳过且不写流水。
func (d *QuotaDao) QuotaInitWithFlow(ctx context.Context, userID int64, quotaType int8, total int64, flow *mysqlmodel.QuotaFlowLog) error {
ctx, span := global.Tracer.Start(ctx, "mysqldao.QuotaInitWithFlow")
defer span.End()
return d.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
q := &mysqlmodel.Quota{
UserID: userID,
QuotaType: quotaType,
Total: total,
Used: 0,
}
res := tx.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "user_id"}, {Name: "quota_type"}},
DoNothing: true,
}).Create(q)
if res.Error != nil {
return res.Error
}
if res.RowsAffected == 0 {
return nil // 已存在,幂等跳过。
}
// 回读真实值填充流水 old/new。
var cur mysqlmodel.Quota
if err := tx.Where("user_id = ? AND quota_type = ?", userID, quotaType).First(&cur).Error; err != nil {
return err
}
remain := cur.Total - cur.Used
flow.OldValue = remain - total
flow.NewValue = remain
// 写流水(失败则整个事务回滚,初始化一并撤销)。
ensureFlowOrderNo(flow)
return tx.Create(flow).Error
})
}
// ensureFlowOrderNo 流水联合唯一索引 (order_no, quota_type, change_type) 的兜底:
// 消费/回退/初始化流水无业务订单号时(order_no 为空串),MySQL 唯一索引将空串视为相同值,
// 第二条同类流水会 1062 冲突。此处用流水自身 UUID 填充 order_no 保证唯一,不影响
// 购买/退款类幂等流水(其 order_no 非空,直接沿用业务订单号)。
func ensureFlowOrderNo(flow *mysqlmodel.QuotaFlowLog) {
if flow != nil && flow.OrderNo == "" {
flow.OrderNo = flow.QuotaFlowUUID
}
}
// QuotaGrantIdempotent 幂等发放额度:事务内先累加 total(不存在则创建,存在则累加),
// 回读真实值后写流水;流水唯一索引(order_no+quota_type+change_type)冲突时事务回滚,
// 调用方据此识别为「已发放」从而幂等。
func (d *QuotaDao) QuotaGrantIdempotent(ctx context.Context, userID int64, quotaType int8, n int64, flow *mysqlmodel.QuotaFlowLog) error {
ctx, span := global.Tracer.Start(ctx, "mysqldao.QuotaGrantIdempotent")
defer span.End()
return d.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
// 1. 累加 total(记录不存在则创建,存在则原子累加)。
q := &mysqlmodel.Quota{
UserID: userID,
QuotaType: quotaType,
Total: n,
Used: 0,
}
if err := tx.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "user_id"}, {Name: "quota_type"}},
DoUpdates: clause.Assignments(map[string]interface{}{"total": gorm.Expr("total + ?", n)}),
}).Create(q).Error; err != nil {
return err
}
// 2. 回读真实值填充流水 old/new。
var cur mysqlmodel.Quota
if err := tx.Where("user_id = ? AND quota_type = ?", userID, quotaType).First(&cur).Error; err != nil {
return err
}
remain := cur.Total - cur.Used
flow.OldValue = remain - n
flow.NewValue = remain
// 3. 写流水(唯一索引冲突会回滚整个事务,保证不重复发放)。
return tx.Create(flow).Error
})
}
// QuotaRevokeIdempotent 幂等回退额度(退款回收):事务内先扣减 total(GREATEST 保证 total 不低于 used,
// 即已用部分不回退),回读真实值后写回退流水;流水唯一索引(order_no+quota_type+change_type)冲突时
// 事务回滚,调用方据此识别为「已回退」从而幂等。
func (d *QuotaDao) QuotaRevokeIdempotent(ctx context.Context, userID int64, quotaType int8, n int64, flow *mysqlmodel.QuotaFlowLog) error {
ctx, span := global.Tracer.Start(ctx, "mysqldao.QuotaRevokeIdempotent")
defer span.End()
return d.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
// 1. 扣减 total(GREATEST(used, total - n) 保证 total >= used)。
res := tx.Model(&mysqlmodel.Quota{}).
Where("user_id = ? AND quota_type = ?", userID, quotaType).
UpdateColumn("total", gorm.Expr("GREATEST(used, total - ?)", n))
if res.Error != nil {
return res.Error
}
// 2. 回读真实值填充流水 old/new。
var cur mysqlmodel.Quota
if err := tx.Where("user_id = ? AND quota_type = ?", userID, quotaType).First(&cur).Error; err != nil {
return err
}
remain := cur.Total - cur.Used
flow.OldValue = remain + n
flow.NewValue = remain
// 3. 写回退流水(唯一索引冲突回滚整个事务,保证不重复回退)。
return tx.Create(flow).Error
})
}
// QuotaGetByUserIDAndType 查询用户某类配额
func (d *QuotaDao) 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
}
// QuotaListByUserID 查询用户的所有配额
func (d *QuotaDao) QuotaListByUserID(ctx context.Context, userID int64) ([]mysqlmodel.Quota, error) {
ctx, span := global.Tracer.Start(ctx, "mysqldao.QuotaListByUserID")
defer span.End()
var quotas []mysqlmodel.Quota
if err := d.db.WithContext(ctx).Where("user_id = ?", userID).Find(&quotas).Error; err != nil {
span.RecordError(err)
return nil, err
}
return quotas, nil
}
// UserDailyUsageDao 每日使用计数数据访问
type UserDailyUsageDao struct {
db *gorm.DB
}
// NewUserDailyUsageDao 创建每日使用计数 DAO
func NewUserDailyUsageDao() *UserDailyUsageDao {
return &UserDailyUsageDao{db: global.MysqlEngine}
}
// UserDailyUsageCreate 创建每日使用计数
func (d *UserDailyUsageDao) UserDailyUsageCreate(ctx context.Context, usage *mysqlmodel.UserDailyUsage) error {
ctx, span := global.Tracer.Start(ctx, "mysqldao.UserDailyUsageCreate")
defer span.End()
if err := d.db.WithContext(ctx).Create(usage).Error; err != nil {
span.RecordError(err)
return err
}
return nil
}
// UserDailyUsageIncrementAI 当日AI推荐次数 +1
func (d *UserDailyUsageDao) UserDailyUsageIncrementAI(ctx context.Context, userID int64, statDate int64) error {
ctx, span := global.Tracer.Start(ctx, "mysqldao.UserDailyUsageIncrementAI")
defer span.End()
if err := d.db.WithContext(ctx).Model(&mysqlmodel.UserDailyUsage{}).
Where("user_id = ? AND stat_date = ?", userID, statDate).
UpdateColumn("ai_recommend_count", gorm.Expr("ai_recommend_count + ?", 1)).Error; err != nil {
span.RecordError(err)
return err
}
return nil
}
// UserDailyUsageIncrementOutfitShare 当日社区分享次数 +1
func (d *UserDailyUsageDao) UserDailyUsageIncrementOutfitShare(ctx context.Context, userID int64, statDate int64) error {
ctx, span := global.Tracer.Start(ctx, "mysqldao.UserDailyUsageIncrementOutfitShare")
defer span.End()
if err := d.db.WithContext(ctx).Model(&mysqlmodel.UserDailyUsage{}).
Where("user_id = ? AND stat_date = ?", userID, statDate).
UpdateColumn("outfit_share_count", gorm.Expr("outfit_share_count + ?", 1)).Error; err != nil {
span.RecordError(err)
return err
}
return nil
}
// UserDailyUsageIncrementClosetShare 当日衣柜分享次数 +1
func (d *UserDailyUsageDao) UserDailyUsageIncrementClosetShare(ctx context.Context, userID int64, statDate int64) error {
ctx, span := global.Tracer.Start(ctx, "mysqldao.UserDailyUsageIncrementClosetShare")
defer span.End()
if err := d.db.WithContext(ctx).Model(&mysqlmodel.UserDailyUsage{}).
Where("user_id = ? AND stat_date = ?", userID, statDate).
UpdateColumn("closet_share_count", gorm.Expr("closet_share_count + ?", 1)).Error; err != nil {
span.RecordError(err)
return err
}
return nil
}
// UserDailyUsageGetByUserIDAndDate 查询用户某日使用计数
func (d *UserDailyUsageDao) UserDailyUsageGetByUserIDAndDate(ctx context.Context, userID int64, 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
}
// QuotaFlowLogDao 配额流水数据访问
type QuotaFlowLogDao struct {
db *gorm.DB
}
// NewQuotaFlowLogDao 创建配额流水 DAO
func NewQuotaFlowLogDao() *QuotaFlowLogDao {
return &QuotaFlowLogDao{db: global.MysqlEngine}
}
// QuotaFlowLogCreate 创建配额流水
func (d *QuotaFlowLogDao) QuotaFlowLogCreate(ctx context.Context, log *mysqlmodel.QuotaFlowLog) error {
ctx, span := global.Tracer.Start(ctx, "mysqldao.QuotaFlowLogCreate")
defer span.End()
if err := d.db.WithContext(ctx).Create(log).Error; err != nil {
span.RecordError(err)
return err
}
return nil
}
// QuotaFlowLogExistsByOrderNoAndType 判断指定订单+额度类型是否已有发放流水(补偿重试幂等判断)。
func (d *QuotaFlowLogDao) QuotaFlowLogExistsByOrderNoAndType(ctx context.Context, orderNo string, quotaType int8) (bool, error) {
ctx, span := global.Tracer.Start(ctx, "mysqldao.QuotaFlowLogExistsByOrderNoAndType")
defer span.End()
var count int64
if err := d.db.WithContext(ctx).Model(&mysqlmodel.QuotaFlowLog{}).
Where("order_no = ? AND quota_type = ?", orderNo, quotaType).
Count(&count).Error; err != nil {
span.RecordError(err)
return false, err
}
return count > 0, nil
}
// QuotaFlowLogListByUserID 分页查询用户配额流水(quotaType 为 0 不筛选)
func (d *QuotaFlowLogDao) QuotaFlowLogListByUserID(ctx context.Context, userID int64, quotaType int8, page, pageSize int) ([]mysqlmodel.QuotaFlowLog, int64, error) {
ctx, span := global.Tracer.Start(ctx, "mysqldao.QuotaFlowLogListByUserID")
defer span.End()
var list []mysqlmodel.QuotaFlowLog
var total int64
q := d.db.WithContext(ctx).Model(&mysqlmodel.QuotaFlowLog{}).Where("user_id = ?", userID)
if quotaType != 0 {
q = q.Where("quota_type = ?", quotaType)
}
if err := q.Count(&total).Error; err != nil {
span.RecordError(err)
return nil, 0, err
}
if err := q.Offset((page - 1) * pageSize).Limit(pageSize).Order("id DESC").Find(&list).Error; err != nil {
span.RecordError(err)
return nil, 0, err
}
return list, total, nil
}
// QuotaGrantDao 额度凭证数据访问(有有效期赠送额度)。
type QuotaGrantDao struct {
db *gorm.DB
}
// NewQuotaGrantDao 创建额度凭证 DAO。
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
}
// QuotaGrantListByUserIDAndType 查询用户某类型全部凭证(未删除)。
// 按到期时间升序(expire_time=0 表示跟随 VIP,排最后),配合「先到期先用」扣减策略。
func (d *QuotaGrantDao) QuotaGrantListByUserIDAndType(ctx context.Context, userID int64, quotaType int8) ([]mysqlmodel.QuotaGrant, error) {
ctx, span := global.Tracer.Start(ctx, "mysqldao.QuotaGrantListByUserIDAndType")
defer span.End()
var list []mysqlmodel.QuotaGrant
if err := d.db.WithContext(ctx).
Where("user_id = ? AND quota_type = ?", userID, quotaType).
Order("CASE WHEN expire_time = 0 THEN 1 ELSE 0 END ASC").
Order("expire_time ASC").
Find(&list).Error; err != nil {
span.RecordError(err)
return nil, err
}
return list, nil
}
// QuotaGrantConsume 条件扣减单条凭证已用量(used + n <= amount),返回是否扣减成功。
// 用于 Cos 永久额度凭证维度的扣减,避免并发超扣。
func (d *QuotaGrantDao) QuotaGrantConsume(ctx context.Context, grantID int64, n int64) (bool, error) {
ctx, span := global.Tracer.Start(ctx, "mysqldao.QuotaGrantConsume")
defer span.End()
res := d.db.WithContext(ctx).Model(&mysqlmodel.QuotaGrant{}).
Where("id = ? AND used + ? <= amount", grantID, n).
UpdateColumn("used", gorm.Expr("used + ?", n))
if res.Error != nil {
span.RecordError(res.Error)
return false, res.Error
}
return res.RowsAffected > 0, nil
}
// QuotaGrantInvalidateByRefID 按关联ID软删除凭证(退款回收时使赠送额度失效)。
func (d *QuotaGrantDao) QuotaGrantInvalidateByRefID(ctx context.Context, refID string) error {
ctx, span := global.Tracer.Start(ctx, "mysqldao.QuotaGrantInvalidateByRefID")
defer span.End()
if err := d.db.WithContext(ctx).Where("ref_id = ?", refID).Delete(&mysqlmodel.QuotaGrant{}).Error; err != nil {
span.RecordError(err)
return err
}
return nil
}
// QuotaGrantExistsByRefIDAndType 判断凭证是否已存在(广告激励防重复领取)。
func (d *QuotaGrantDao) QuotaGrantExistsByRefIDAndType(ctx context.Context, refID string, quotaType int8) (bool, error) {
ctx, span := global.Tracer.Start(ctx, "mysqldao.QuotaGrantExistsByRefIDAndType")
defer span.End()
var count int64
if err := d.db.WithContext(ctx).Model(&mysqlmodel.QuotaGrant{}).
Where("ref_id = ? AND quota_type = ?", refID, quotaType).
Count(&count).Error; err != nil {
span.RecordError(err)
return false, err
}
return count > 0, nil
}