This commit is contained in:
zhengchiliu
2026-09-15 00:53:04 +08:00
parent 32a0b87e9e
commit 112c5f8e46
42 changed files with 1121 additions and 304 deletions
+67
View File
@@ -0,0 +1,67 @@
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"
)
type BenefitRevokeTaskDao struct{ db *gorm.DB }
func NewBenefitRevokeTaskDao() *BenefitRevokeTaskDao {
return &BenefitRevokeTaskDao{db: global.MysqlEngine}
}
func (d *BenefitRevokeTaskDao) GetByOrderNo(ctx context.Context, orderNo string) (*mysqlmodel.BenefitRevokeTask, error) {
var task mysqlmodel.BenefitRevokeTask
err := d.db.WithContext(ctx).Where("order_no = ?", orderNo).First(&task).Error
return &task, err
}
func (d *BenefitRevokeTaskDao) ListPending(ctx context.Context, limit int) ([]mysqlmodel.BenefitRevokeTask, error) {
var list []mysqlmodel.BenefitRevokeTask
err := d.db.WithContext(ctx).Where("status = ?", 0).Order("id ASC").Limit(limit).Find(&list).Error
return list, err
}
func (d *BenefitRevokeTaskDao) Claim(ctx context.Context, id int64) (bool, error) {
r := d.db.WithContext(ctx).Model(&mysqlmodel.BenefitRevokeTask{}).Where("id = ? AND status IN ?", id, []int8{0, 2}).Update("status", 3)
return r.RowsAffected > 0, r.Error
}
func (d *BenefitRevokeTaskDao) Update(ctx context.Context, id int64, updates map[string]any) error {
return d.db.WithContext(ctx).Model(&mysqlmodel.BenefitRevokeTask{}).Where("id = ?", id).Updates(updates).Error
}
func (d *BenefitRevokeTaskDao) RecoverStale(ctx context.Context, before int64) error {
return d.db.WithContext(ctx).Model(&mysqlmodel.BenefitRevokeTask{}).Where("status = ? AND updated_at < ?", 3, before).Update("status", 0).Error
}
// RefundSettleWithRevokeTask 原子写入退款订单终态、取消未执行发放并创建回收任务。
func (d *BenefitRevokeTaskDao) RefundSettleWithRevokeTask(ctx context.Context, orderNo string, refundAmount, refundTime int64) (bool, error) {
settled := false
err := d.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var order mysqlmodel.Order
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("order_no = ?", orderNo).First(&order).Error; err != nil {
return err
}
if order.OrderStatus == 5 {
return nil
}
if order.OrderStatus != 2 && order.OrderStatus != 3 {
return nil
}
if err := tx.Model(&mysqlmodel.Order{}).Where("id = ?", order.ID).Updates(map[string]any{"order_status": 5, "refund_time": refundTime, "refund_amount": refundAmount}).Error; err != nil {
return err
}
if err := tx.Model(&mysqlmodel.BenefitGrantTask{}).Where("order_no = ? AND status IN ?", orderNo, []int8{0, 2}).Update("status", 4).Error; err != nil {
return err
}
if order.OrderStatus == 2 {
if err := tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&mysqlmodel.BenefitRevokeTask{OrderNo: orderNo, UserID: order.UserID, MaxRetry: 10}).Error; err != nil {
return err
}
}
settled = true
return nil
})
return settled, err
}
+18
View File
@@ -2,6 +2,7 @@ package mysqldao
import (
"context"
"time"
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/global"
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/mysqlmodel"
@@ -136,3 +137,20 @@ func (d *ClothesDao) ClothesListByUserID(ctx context.Context, userID int64, cate
}
return list, total, nil
}
// ClothesListStaleProcessing 查询超时未完成的图片任务。
func (d *ClothesDao) ClothesListStaleProcessing(ctx context.Context, before int64, limit int) ([]mysqlmodel.Clothes, error) {
var list []mysqlmodel.Clothes
err := d.db.WithContext(ctx).
Where("image_process_status IN ? AND updated_at < ?", []int8{1, 2, 3}, before).
Order("id ASC").Limit(limit).Find(&list).Error
return list, err
}
// ClothesClaimStaleProcessing 原子认领超时任务,避免多实例重复恢复。
func (d *ClothesDao) ClothesClaimStaleProcessing(ctx context.Context, clothesUUID string, before int64) (bool, error) {
res := d.db.WithContext(ctx).Model(&mysqlmodel.Clothes{}).
Where("clothes_uuid = ? AND image_process_status IN ? AND updated_at < ?", clothesUUID, []int8{1, 2, 3}, before).
Updates(map[string]any{"image_process_status": 3, "updated_at": time.Now().Unix()})
return res.RowsAffected > 0, res.Error
}
+1 -1
View File
@@ -101,7 +101,7 @@ func (d *CollectDao) CollectListTargetIDsByUserPage(ctx context.Context, collect
}
var ids []int64
if err := q.Order("id DESC").Offset((page - 1) * pageSize).Limit(pageSize).Pluck("target_id", &ids).Error; err != nil {
if err := q.Order("id DESC").Offset((page-1)*pageSize).Limit(pageSize).Pluck("target_id", &ids).Error; err != nil {
span.RecordError(err)
return nil, 0, err
}
+76
View File
@@ -0,0 +1,76 @@
package mysqldao
import (
"context"
"testing"
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/mysqlmodel"
"github.com/DATA-DOG/go-sqlmock"
)
func TestUserDailyUsageConsumeIsAtomic(t *testing.T) {
for _, tc := range []struct {
name string
rows int64
want bool
}{
{name: "within_limit", rows: 1, want: true},
{name: "over_limit", rows: 0, want: false},
} {
t.Run(tc.name, func(t *testing.T) {
base, mock := newQuotaTestDAO(t)
dao := &UserDailyUsageDao{db: base.db}
mock.ExpectBegin()
mock.ExpectExec("INSERT INTO `user_daily_usages`").WillReturnResult(sqlmock.NewResult(1, 1))
mock.ExpectExec("UPDATE `user_daily_usages` SET `ai_recommend_count`=ai_recommend_count \\+ \\? WHERE").
WithArgs(int64(1), int64(7), int64(20260915), int64(1), int64(3), int64(0)).
WillReturnResult(sqlmock.NewResult(0, tc.rows))
mock.ExpectCommit()
got, err := dao.UserDailyUsageConsume(context.Background(), 7, 20260915, 2, 1, 3)
if err != nil || got != tc.want {
t.Fatalf("consumed=%v err=%v, want %v", got, err, tc.want)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatal(err)
}
})
}
}
func TestVIPGrantUsesOrderWideIdempotency(t *testing.T) {
base, mock := newQuotaTestDAO(t)
dao := &UserDao{db: base.db}
mock.ExpectBegin()
mock.ExpectQuery("SELECT .* FROM `users` .*FOR UPDATE").
WillReturnRows(sqlmock.NewRows([]string{"id", "vip_type", "vip_expire"}).AddRow(7, 2, 2000))
mock.ExpectQuery("SELECT count\\(\\*\\) FROM `vip_flow_logs`").
WithArgs("ORDER-1", int8(1), int8(2), int64(0)).
WillReturnRows(sqlmock.NewRows([]string{"count(*)"}).AddRow(1))
mock.ExpectCommit()
flow := &mysqlmodel.VIPFlowLog{OrderNo: "ORDER-1", ChangeType: 2}
if err := dao.VIPGrantIdempotent(context.Background(), 7, 1000, 30, 2, flow); err != nil {
t.Fatal(err)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatal(err)
}
}
func TestRefundSettlementCreatesRevokeTaskAtomically(t *testing.T) {
base, mock := newQuotaTestDAO(t)
dao := &BenefitRevokeTaskDao{db: base.db}
mock.ExpectBegin()
mock.ExpectQuery("SELECT .* FROM `orders` .*FOR UPDATE").
WillReturnRows(sqlmock.NewRows([]string{"id", "order_no", "user_id", "order_status"}).AddRow(8, "ORDER-1", 7, 2))
mock.ExpectExec("UPDATE `orders` SET").WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectExec("UPDATE `benefit_grant_tasks` SET").WillReturnResult(sqlmock.NewResult(0, 2))
mock.ExpectExec("INSERT INTO `benefit_revoke_tasks`").WillReturnResult(sqlmock.NewResult(3, 1))
mock.ExpectCommit()
settled, err := dao.RefundSettleWithRevokeTask(context.Background(), "ORDER-1", 990, 1234)
if err != nil || !settled {
t.Fatalf("settled=%v err=%v", settled, err)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatal(err)
}
}
+58
View File
@@ -0,0 +1,58 @@
package mysqldao
import (
"context"
"fmt"
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/global"
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/mysqlmodel"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
// MemberQuotaGrantCreateWithFlow 按用户、会员商品、额度类型仅发放一次。
// 锁定用户行将同一用户的并发续费串行化,检查与写入使用同一事务。
// 保留原始订单号用于退款追溯;历史凭证(包括已退款软删除的凭证)也参与去重,
// 因而额度用完、会员过期或退款后再次购买都不会重新领取首次赠送。
func (d *QuotaGrantDao) MemberQuotaGrantCreateWithFlow(ctx context.Context, goodsID int64, grant *mysqlmodel.QuotaGrant, flow *mysqlmodel.QuotaFlowLog) (bool, error) {
ctx, span := global.Tracer.Start(ctx, "mysqldao.MemberQuotaGrantCreateWithFlow")
defer span.End()
if goodsID <= 0 || grant == nil || flow == nil || grant.UserID <= 0 || grant.RefID == "" || grant.Source != 1 {
return false, fmt.Errorf("invalid member quota grant")
}
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
}
// 当前读确保 MySQL REPEATABLE READ 下等待用户锁后可见上一事务的发放。
// 关联订单快照识别商品,不依赖当前商品名称或用户是否仍为新用户。
var previous []mysqlmodel.QuotaGrant
if err := tx.Unscoped().Model(&mysqlmodel.QuotaGrant{}).
Select("quota_grants.*").
Joins("JOIN orders ON orders.order_no = quota_grants.ref_id AND orders.user_id = quota_grants.user_id").
Where("quota_grants.user_id = ? AND orders.goods_id = ? AND quota_grants.quota_type = ? AND quota_grants.source = ?", grant.UserID, goodsID, grant.QuotaType, grant.Source).
Clauses(clause.Locking{Strength: "UPDATE"}).Limit(1).Find(&previous).Error; err != nil {
return err
}
if len(previous) > 0 {
return nil
}
if err := tx.Create(grant).Error; err != nil {
return err
}
ensureFlowOrderNo(flow)
if err := tx.Create(flow).Error; err != nil {
return err
}
created = true
return nil
})
if err != nil {
span.RecordError(err)
return false, err
}
return created, nil
}
+135
View File
@@ -0,0 +1,135 @@
package mysqldao
import (
"context"
"errors"
"regexp"
"testing"
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/global"
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/mysqlmodel"
"github.com/DATA-DOG/go-sqlmock"
"go.opentelemetry.io/otel/trace/noop"
"gorm.io/driver/mysql"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
func newQuotaTestDAO(t *testing.T) (*QuotaGrantDao, sqlmock.Sqlmock) {
t.Helper()
conn, mock, err := sqlmock.New()
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { conn.Close() })
db, err := gorm.Open(mysql.New(mysql.Config{Conn: conn, SkipInitializeWithVersion: true}), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)})
if err != nil {
t.Fatal(err)
}
oldTracer := global.Tracer
global.Tracer = noop.NewTracerProvider().Tracer("quota-test")
t.Cleanup(func() { global.Tracer = oldTracer })
return &QuotaGrantDao{db: db}, mock
}
func TestMemberQuotaFirstPurchaseAndRenewal(t *testing.T) {
// 每次请求都是新订单;已用完、过期和退款软删除的历史凭证仍代表已领取。
for _, tc := range []struct {
name string
prior bool
used, deleted int64
want bool
}{
{name: "first_purchase", want: true},
{name: "renewal_new_order", prior: true},
{name: "exhausted_quota", prior: true, used: 100},
{name: "refunded_purchase", prior: true, deleted: 123},
{name: "compensation_retry", prior: true},
} {
t.Run(tc.name, func(t *testing.T) {
dao, mock := newQuotaTestDAO(t)
mock.ExpectBegin()
mock.ExpectQuery("SELECT .* FROM `users` .*FOR UPDATE").WithArgs(7, 0, 1).
WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow(7))
rows := sqlmock.NewRows([]string{"id", "user_id", "quota_type", "source", "amount", "used", "deleted_at", "ref_id"})
if tc.prior {
rows.AddRow(1, 7, 2, 1, 100, tc.used, tc.deleted, "OLD-ORDER")
}
// 精确匹配确认按用户/商品/额度类型过滤,且没有 deleted_at/有效期/剩余量过滤。
query := "SELECT quota_grants.* FROM `quota_grants` JOIN orders ON orders.order_no = quota_grants.ref_id AND orders.user_id = quota_grants.user_id WHERE quota_grants.user_id = ? AND orders.goods_id = ? AND quota_grants.quota_type = ? AND quota_grants.source = ? LIMIT ? FOR UPDATE"
mock.ExpectQuery(regexp.QuoteMeta(query)).WithArgs(7, 9, 2, 1, 1).WillReturnRows(rows)
if !tc.prior {
mock.ExpectExec("INSERT INTO `quota_grants`").WillReturnResult(sqlmock.NewResult(2, 1))
mock.ExpectExec("INSERT INTO `quota_flow_logs`").WillReturnResult(sqlmock.NewResult(3, 1))
}
mock.ExpectCommit()
grant := &mysqlmodel.QuotaGrant{UserID: 7, QuotaType: 2, Amount: 100, Source: 1, RefID: "NEW-ORDER"}
flow := &mysqlmodel.QuotaFlowLog{QuotaFlowUUID: "flow", UserID: 7, OrderNo: "NEW-ORDER", QuotaType: 2}
created, err := dao.MemberQuotaGrantCreateWithFlow(context.Background(), 9, grant, flow)
if err != nil || created != tc.want {
t.Fatalf("created=%v err=%v", created, err)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatal(err)
}
})
}
}
func TestMemberQuotaFailureRollsBack(t *testing.T) {
for _, stage := range []string{"user_lock", "history_read", "grant_insert", "flow_insert"} {
t.Run(stage, func(t *testing.T) {
dao, mock := newQuotaTestDAO(t)
failure := errors.New("database unavailable")
mock.ExpectBegin()
lock := mock.ExpectQuery("SELECT .* FROM `users` .*FOR UPDATE")
if stage == "user_lock" {
lock.WillReturnError(failure)
} else {
lock.WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow(7))
history := mock.ExpectQuery("SELECT quota_grants.* .*FOR UPDATE")
if stage == "history_read" {
history.WillReturnError(failure)
} else {
history.WillReturnRows(sqlmock.NewRows([]string{"id"}))
insert := mock.ExpectExec("INSERT INTO `quota_grants`")
if stage == "grant_insert" {
insert.WillReturnError(failure)
} else {
insert.WillReturnResult(sqlmock.NewResult(2, 1))
mock.ExpectExec("INSERT INTO `quota_flow_logs`").WillReturnError(failure)
}
}
}
mock.ExpectRollback()
created, err := dao.MemberQuotaGrantCreateWithFlow(context.Background(), 9,
&mysqlmodel.QuotaGrant{UserID: 7, QuotaType: 2, Amount: 100, Source: 1, RefID: "NEW"},
&mysqlmodel.QuotaFlowLog{QuotaFlowUUID: "flow"})
if created || !errors.Is(err, failure) {
t.Fatalf("created=%v err=%v", created, err)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatal(err)
}
})
}
}
func TestAddonQuotaStillGrantedPerOrder(t *testing.T) {
dao, mock := newQuotaTestDAO(t)
for _, order := range []string{"ADDON-1", "ADDON-2"} {
mock.ExpectBegin()
mock.ExpectExec("INSERT INTO `quota_grants`").WillReturnResult(sqlmock.NewResult(1, 1))
mock.ExpectExec("INSERT INTO `quota_flow_logs`").WillReturnResult(sqlmock.NewResult(1, 1))
mock.ExpectCommit()
created, err := dao.QuotaGrantCreateWithFlow(context.Background(),
&mysqlmodel.QuotaGrant{UserID: 7, QuotaType: 2, Source: 2, Amount: 100, RefID: order},
&mysqlmodel.QuotaFlowLog{QuotaFlowUUID: order, OrderNo: order})
if err != nil || !created {
t.Fatalf("order=%s created=%v err=%v", order, created, err)
}
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatal(err)
}
}
+13
View File
@@ -91,6 +91,19 @@ func (d *OrderDao) OrderListVirtualUnconfirmed(ctx context.Context, since, until
return list, nil
}
// OrderListPaidMissingBenefitTasks 查询存在未建权益任务的已支付订单,修复支付回调与任务落库之间的崩溃窗口。
func (d *OrderDao) OrderListPaidMissingBenefitTasks(ctx context.Context, limit int) ([]mysqlmodel.Order, error) {
var list []mysqlmodel.Order
err := d.db.WithContext(ctx).Where("order_status = ?", 2).
Where(`(member_days > 0 AND NOT EXISTS (SELECT 1 FROM benefit_grant_tasks t WHERE t.order_no=orders.order_no AND t.benefit_type=1)) OR
(cos_quota > 0 AND NOT EXISTS (SELECT 1 FROM benefit_grant_tasks t WHERE t.order_no=orders.order_no AND t.benefit_type=2)) OR
(ai_recommend_quota > 0 AND NOT EXISTS (SELECT 1 FROM benefit_grant_tasks t WHERE t.order_no=orders.order_no AND t.benefit_type=3)) OR
(closet_share_quota > 0 AND NOT EXISTS (SELECT 1 FROM benefit_grant_tasks t WHERE t.order_no=orders.order_no AND t.benefit_type=4)) OR
(outfit_share_quota > 0 AND NOT EXISTS (SELECT 1 FROM benefit_grant_tasks t WHERE t.order_no=orders.order_no AND t.benefit_type=5))`).
Order("id ASC").Limit(limit).Find(&list).Error
return list, err
}
// OrderGetByID 按主键 ID 查询订单
func (d *OrderDao) OrderGetByID(ctx context.Context, id int64) (*mysqlmodel.Order, error) {
ctx, span := global.Tracer.Start(ctx, "mysqldao.OrderGetByID")
+4 -1
View File
@@ -117,7 +117,10 @@ func (d *OutfitShareReceiverDao) OutfitShareReceivedList(ctx context.Context, re
q := d.db.WithContext(ctx).
Model(&mysqlmodel.OutfitShare{}).
Joins("JOIN outfit_share_receivers r ON r.share_uuid = outfit_shares.share_uuid").
Where("r.receiver_user_id = ?", receiverUserID)
Joins("JOIN outfits o ON o.id = outfit_shares.outfit_id").
Where("r.receiver_user_id = ?", receiverUserID).
Where("outfit_shares.status = ?", 1).
Where("o.is_public = ? AND o.audit_status = ? AND o.status = ? AND o.deleted_at = 0", 2, 2, 1)
if status != 0 {
q = q.Where("outfit_shares.status = ?", status)
}
+104 -1
View File
@@ -346,6 +346,80 @@ func (d *UserDailyUsageDao) UserDailyUsageIncrementClosetShare(ctx context.Conte
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")
@@ -431,7 +505,7 @@ func NewQuotaGrantDao() *QuotaGrantDao {
// QuotaGrantCreateWithFlow 事务内创建额度凭证并写发放流水(购买赠送/活动赠送)。
// 返回值 created 表示本次是否新建了凭证:依赖 uk_grant_ref_type(ref_id, quota_type) 唯一索引实现幂等,
// 凭证已存在时返回 (false, nil) 且不写流水,避免重复回调/补偿重试重复记账
//(流水侧另有 (order_no, quota_type, change_type) 唯一索引兜底)。
// (流水侧另有 (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()
@@ -459,6 +533,35 @@ func (d *QuotaGrantDao) QuotaGrantCreateWithFlow(ctx context.Context, grant *mys
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) {
+20
View File
@@ -6,6 +6,7 @@ import (
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/global"
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/mysqlmodel"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
// UserDao 用户数据访问
@@ -56,6 +57,25 @@ func (d *UserDao) VIPGrantIdempotent(ctx context.Context, userID int64, now int6
defer span.End()
return d.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var before mysqlmodel.User
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("id = ?", userID).First(&before).Error; err != nil {
return err
}
// 购买开通/续费使用稳定的订单级幂等判断,不让重试后的 change_type 变化绕过唯一键。
if flow.ChangeType == 1 || flow.ChangeType == 2 {
var count int64
if err := tx.Model(&mysqlmodel.VIPFlowLog{}).Where("order_no = ? AND change_type IN ?", flow.OrderNo, []int8{1, 2}).Count(&count).Error; err != nil {
return err
}
if count > 0 {
return nil
}
flow.ChangeType = 1
if before.VIPType != 1 && before.VIPExpire > now {
flow.ChangeType = 2
}
flow.OldVIPType, flow.OldVIPExpire = before.VIPType, before.VIPExpire
}
sql := `UPDATE users SET
vip_start = CASE WHEN vip_type = 1 OR vip_expire <= ? THEN ? ELSE vip_start END,
vip_expire = (CASE WHEN vip_expire > ? THEN vip_expire ELSE ? END) + ?