Files
2026-09-16 00:23:49 +08:00

698 lines
26 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}
// UserDailyUsageAdjust 原子调整每日使用量;正数使用 upsert,负数保证不低于 0。
func (d *UserDailyUsageDao) UserDailyUsageAdjust(ctx context.Context, userID, statDate int64, quotaType int8, delta int64) error {
ctx, span := global.Tracer.Start(ctx, "mysqldao.UserDailyUsageAdjust")
defer span.End()
column := ""
switch quotaType {
case 2:
column = "ai_recommend_count"
case 3:
column = "outfit_share_count"
case 4:
column = "closet_share_count"
default:
return nil
}
var err error
if delta > 0 {
usage := &mysqlmodel.UserDailyUsage{UserID: userID, StatDate: statDate}
switch quotaType {
case 2:
usage.AIRecommendCount = delta
case 3:
usage.OutfitShareCount = delta
case 4:
usage.ClosetShareCount = delta
}
err = d.db.WithContext(ctx).Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "user_id"}, {Name: "stat_date"}},
DoUpdates: clause.Assignments(map[string]any{column: gorm.Expr(column+" + ?", delta)}),
}).Create(usage).Error
} else if delta < 0 {
err = d.db.WithContext(ctx).Model(&mysqlmodel.UserDailyUsage{}).
Where("user_id = ? AND stat_date = ?", userID, statDate).
UpdateColumn(column, gorm.Expr("GREATEST(0, "+column+" + ?)", delta)).Error
}
if err != nil {
span.RecordError(err)
}
return err
}
// UserDailyUsageConsume 以 MySQL 为权威账本原子预占每日额度,避免 Redis 丢键后重复消费。
func (d *UserDailyUsageDao) UserDailyUsageConsume(ctx context.Context, userID, statDate int64, quotaType int8, n, limit int64) (bool, error) {
column := ""
switch quotaType {
case 2:
column = "ai_recommend_count"
case 3:
column = "outfit_share_count"
case 4:
column = "closet_share_count"
default:
return false, nil
}
var consumed bool
err := d.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
usage := &mysqlmodel.UserDailyUsage{UserID: userID, StatDate: statDate}
if err := tx.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "user_id"}, {Name: "stat_date"}}, DoNothing: true,
}).Create(usage).Error; err != nil {
return err
}
res := tx.Model(&mysqlmodel.UserDailyUsage{}).
Where("user_id = ? AND stat_date = ? AND "+column+" + ? <= ?", userID, statDate, n, limit).
UpdateColumn(column, gorm.Expr(column+" + ?", n))
if res.Error != nil {
return res.Error
}
consumed = res.RowsAffected > 0
return nil
})
return consumed, err
}
// 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}
}
// QuotaGrantCreateWithFlow 事务内创建额度凭证并写发放流水(购买赠送/活动赠送)。
// 返回值 created 表示本次是否新建了凭证:依赖 uk_grant_ref_type(ref_id, quota_type) 唯一索引实现幂等,
// 凭证已存在时返回 (false, nil) 且不写流水,避免重复回调/补偿重试重复记账
// (流水侧另有 (order_no, quota_type, change_type) 唯一索引兜底)。
func (d *QuotaGrantDao) QuotaGrantCreateWithFlow(ctx context.Context, grant *mysqlmodel.QuotaGrant, flow *mysqlmodel.QuotaFlowLog) (bool, error) {
ctx, span := global.Tracer.Start(ctx, "mysqldao.QuotaGrantCreateWithFlow")
defer span.End()
created := false
err := d.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
res := tx.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "ref_id"}, {Name: "quota_type"}},
DoNothing: true,
}).Create(grant)
if res.Error != nil {
return res.Error
}
if res.RowsAffected == 0 {
return nil // 凭证已存在:幂等跳过,不重复写流水。
}
created = true
ensureFlowOrderNo(flow)
return tx.Create(flow).Error
})
if err != nil {
span.RecordError(err)
return false, err
}
return created, nil
}
// AdRewardGrantCreateWithFlow 在用户行锁内执行每日限额、事件幂等、凭证与流水原子写入。
func (d *QuotaGrantDao) AdRewardGrantCreateWithFlow(ctx context.Context, grant *mysqlmodel.QuotaGrant, flow *mysqlmodel.QuotaFlowLog, dayStart, dayEnd int64, maxClaims int) (bool, error) {
created := false
err := d.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var user mysqlmodel.User
if err := tx.Select("id").Clauses(clause.Locking{Strength: "UPDATE"}).Where("id = ?", grant.UserID).First(&user).Error; err != nil {
return err
}
var count int64
if err := tx.Model(&mysqlmodel.QuotaGrant{}).Where("user_id = ? AND quota_type = ? AND source = ? AND created_at >= ? AND created_at < ?", grant.UserID, grant.QuotaType, grant.Source, dayStart, dayEnd).Count(&count).Error; err != nil {
return err
}
if count >= int64(maxClaims) {
return nil
}
res := tx.Clauses(clause.OnConflict{Columns: []clause.Column{{Name: "ref_id"}, {Name: "quota_type"}}, DoNothing: true}).Create(grant)
if res.Error != nil || res.RowsAffected == 0 {
return res.Error
}
ensureFlowOrderNo(flow)
if err := tx.Create(flow).Error; err != nil {
return err
}
created = true
return nil
})
return created, err
}
// 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
}
// QuotaGrantListByRefID 查询某关联ID(订单号/广告记录ID)下未删除的额度凭证。
func (d *QuotaGrantDao) QuotaGrantListByRefID(ctx context.Context, refID string) ([]mysqlmodel.QuotaGrant, error) {
ctx, span := global.Tracer.Start(ctx, "mysqldao.QuotaGrantListByRefID")
defer span.End()
var list []mysqlmodel.QuotaGrant
if err := d.db.WithContext(ctx).Where("ref_id = ?", refID).Find(&list).Error; err != nil {
span.RecordError(err)
return nil, err
}
return list, nil
}
// QuotaGrantInvalidateByRefIDWithFlows 事务内按关联ID软删除凭证,并写「退款回收」流水。
// 凭证软删除后不再计入有效额度;已用部分不追回,故流水变更量由调用方按 (amount - used) 记负数。
// 凭证失效与流水同事务,保证「额度被回收」与「额度明细」一致。
func (d *QuotaGrantDao) QuotaGrantInvalidateByRefIDWithFlows(ctx context.Context, refID string, flows []*mysqlmodel.QuotaFlowLog) error {
ctx, span := global.Tracer.Start(ctx, "mysqldao.QuotaGrantInvalidateByRefIDWithFlows")
defer span.End()
return d.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Where("ref_id = ?", refID).Delete(&mysqlmodel.QuotaGrant{}).Error; err != nil {
return err
}
for _, f := range flows {
ensureFlowOrderNo(f)
if err := tx.Create(f).Error; err != nil {
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
}
// quotaFlowChangeTypeExpire 与 service.QuotaChangeTypeExpire 对应。
// DAO 层不反向依赖 service,故此处用字面量并在此说明。
const quotaFlowChangeTypeExpire int8 = 7
// QuotaGrantListExpiredFixed 查询「固定有效期」且已过期、但尚未补记到期失效流水的凭证。
//
// 只处理 expire_time > 0 的凭证:expire_time=0 表示跟随 VIP,用户续费后凭证会重新生效,
// 不应记为「失效」(否则续费后额度凭空多一笔负数流水,反而污染明细)。
//
// 幂等依据:(ref_id, quota_type, change_type=7) 的流水是否已存在;写入失败下轮会重新捞取。
func (d *QuotaGrantDao) QuotaGrantListExpiredFixed(ctx context.Context, now int64, limit int) ([]mysqlmodel.QuotaGrant, error) {
ctx, span := global.Tracer.Start(ctx, "mysqldao.QuotaGrantListExpiredFixed")
defer span.End()
var list []mysqlmodel.QuotaGrant
err := d.db.WithContext(ctx).Model(&mysqlmodel.QuotaGrant{}).
Where("expire_time > 0 AND expire_time <= ?", now).
Where("NOT EXISTS (?)", d.db.Model(&mysqlmodel.QuotaFlowLog{}).
Select("1").
Where("quota_flow_logs.order_no = quota_grants.ref_id").
Where("quota_flow_logs.quota_type = quota_grants.quota_type").
Where("quota_flow_logs.change_type = ?", quotaFlowChangeTypeExpire)).
Order("id ASC").Limit(limit).Find(&list).Error
if err != nil {
span.RecordError(err)
return nil, err
}
return list, nil
}
// QuotaFlowLogCreateIgnoreDup 写入额度流水,命中唯一索引 uk_quota_flow_idem 冲突时静默忽略。
// 返回 created 表示本次是否真正插入,供「到期失效」流水的幂等补记使用。
func (d *QuotaGrantDao) QuotaFlowLogCreateIgnoreDup(ctx context.Context, flow *mysqlmodel.QuotaFlowLog) (bool, error) {
ctx, span := global.Tracer.Start(ctx, "mysqldao.QuotaFlowLogCreateIgnoreDup")
defer span.End()
res := d.db.WithContext(ctx).Clauses(clause.OnConflict{
Columns: []clause.Column{
{Name: "order_no"},
{Name: "quota_type"},
{Name: "change_type"},
},
DoNothing: true,
}).Create(flow)
if res.Error != nil {
span.RecordError(res.Error)
return false, res.Error
}
return res.RowsAffected > 0, nil
}