gpt
This commit is contained in:
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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) + ?
|
||||
|
||||
Reference in New Issue
Block a user