fix(activity): 修复复制活动时奖励快照时间写入异常

本次提交修复了管理端复制活动接口在复制奖励配置时写入零时间导致的 MySQL datetime 报错,并补齐了活动奖励复制过程遗漏的快照与业务字段,避免新活动复制后出现配置丢失或插入失败。

- 复制逻辑:在 CopyActivity 中完整复制 price_snapshot_cents、cost_snapshot_cents、price_snapshot_at、min_score、drop_quantity 等奖励字段
- 异常兜底:当历史奖励快照时间为空或为零值时,复制时自动回填为当前时间;当价格/成本快照缺失且存在商品 ID 时,回退读取当前商品价格与成本价
- 兼容历史数据:确保旧奖励配置即使未完整回填快照字段,也能被安全复制,不再因 0000-00-00 时间值触发插入失败
- 测试覆盖:补充活动复制场景测试,验证快照字段原样复制、缺失快照自动补齐,以及 min_score/drop_quantity 等字段不会在复制时丢失
This commit is contained in:
Zuncle 2026-04-21 03:08:21 +08:00
parent 0a397adf41
commit 471e21a68b
2 changed files with 188 additions and 14 deletions

View File

@ -71,15 +71,43 @@ func (s *service) CopyActivity(ctx context.Context, activityID int64) (int64, er
}
for _, r := range rewards {
priceSnapshot := r.PriceSnapshotCents
costSnapshot := r.CostSnapshotCents
snapshotAt := r.PriceSnapshotAt
if r.ProductID > 0 && (priceSnapshot == 0 || costSnapshot == 0) {
product, err := s.readDB.Products.WithContext(ctx).Where(s.readDB.Products.ID.Eq(r.ProductID)).First()
if err != nil {
return err
}
if priceSnapshot == 0 {
priceSnapshot = product.Price
}
if costSnapshot == 0 {
costSnapshot = product.CostPrice
}
}
if snapshotAt.IsZero() {
snapshotAt = time.Now()
}
dropQuantity := r.DropQuantity
if dropQuantity < 1 {
dropQuantity = 1
}
nr := &model.ActivityRewardSettings{
IssueID: idMap[r.IssueID],
ProductID: r.ProductID,
Weight: r.Weight,
Quantity: r.Quantity,
OriginalQty: r.OriginalQty,
Level: r.Level,
Sort: r.Sort,
IsBoss: r.IsBoss,
IssueID: idMap[r.IssueID],
ProductID: r.ProductID,
PriceSnapshotCents: priceSnapshot,
PriceSnapshotAt: snapshotAt,
Weight: r.Weight,
Quantity: r.Quantity,
OriginalQty: r.OriginalQty,
Level: r.Level,
Sort: r.Sort,
IsBoss: r.IsBoss,
MinScore: r.MinScore,
DropQuantity: dropQuantity,
CostSnapshotCents: costSnapshot,
}
if err := tx.ActivityRewardSettings.WithContext(ctx).Create(nr); err != nil {
return err

View File

@ -3,6 +3,7 @@ package activity
import (
"context"
"testing"
"time"
"bindbox-game/internal/repository/mysql/dao"
@ -12,7 +13,7 @@ import (
func newRewardSnapshotTestService(t *testing.T) (*service, *dao.Query, *gorm.DB) {
t.Helper()
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{})
if err != nil {
t.Fatalf("open sqlite failed: %v", err)
}
@ -20,6 +21,7 @@ func newRewardSnapshotTestService(t *testing.T) (*service, *dao.Query, *gorm.DB)
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT,
price INTEGER NOT NULL,
cost_price INTEGER NOT NULL DEFAULT 0,
stock INTEGER NOT NULL,
images_json TEXT,
updated_at DATETIME,
@ -27,6 +29,54 @@ func newRewardSnapshotTestService(t *testing.T) (*service, *dao.Query, *gorm.DB)
);`).Error; err != nil {
t.Fatalf("create products failed: %v", err)
}
if err := db.Exec(`CREATE TABLE activities (
id INTEGER PRIMARY KEY AUTOINCREMENT,
created_at DATETIME,
updated_at DATETIME,
name TEXT,
banner TEXT,
activity_category_id INTEGER,
status INTEGER,
price_draw INTEGER,
is_boss INTEGER,
allow_item_cards BOOLEAN,
allow_coupons BOOLEAN,
end_time DATETIME,
start_time DATETIME,
scheduled_time DATETIME,
last_settled_at DATETIME,
draw_mode TEXT,
play_type TEXT,
min_participants INTEGER,
interval_minutes INTEGER,
refund_coupon_id INTEGER,
deleted_at DATETIME,
image TEXT,
commitment_algo TEXT,
commitment_seed_master TEXT,
commitment_seed_hash TEXT,
commitment_state_version INTEGER,
commitment_items_root TEXT,
gameplay_intro TEXT,
daily_seed TEXT,
daily_seed_date TEXT,
last_daily_seed TEXT,
last_daily_seed_date TEXT
);`).Error; err != nil {
t.Fatalf("create activities failed: %v", err)
}
if err := db.Exec(`CREATE TABLE activity_issues (
id INTEGER PRIMARY KEY AUTOINCREMENT,
created_at DATETIME,
updated_at DATETIME,
activity_id INTEGER NOT NULL,
issue_number TEXT,
status INTEGER,
sort INTEGER,
deleted_at DATETIME
);`).Error; err != nil {
t.Fatalf("create activity_issues failed: %v", err)
}
if err := db.Exec(`CREATE TABLE activity_reward_settings (
id INTEGER PRIMARY KEY AUTOINCREMENT,
created_at DATETIME,
@ -42,13 +92,15 @@ func newRewardSnapshotTestService(t *testing.T) (*service, *dao.Query, *gorm.DB)
sort INTEGER,
is_boss INTEGER,
min_score INTEGER NOT NULL DEFAULT 0,
drop_quantity INTEGER NOT NULL DEFAULT 1,
cost_snapshot_cents INTEGER NOT NULL DEFAULT 0,
deleted_at DATETIME
);`).Error; err != nil {
t.Fatalf("create activity_reward_settings failed: %v", err)
}
q := dao.Use(db)
svc := &service{readDB: q, writeDB: q}
svc := &service{readDB: q, writeDB: q, repo: nil}
return svc, q, db
}
@ -56,7 +108,7 @@ func TestCreateIssueRewards_SnapshotFromProductPrice(t *testing.T) {
svc, q, db := newRewardSnapshotTestService(t)
ctx := context.Background()
if err := db.Exec("INSERT INTO products (id, name, price, stock, images_json) VALUES (101, 'A', 1000, 10, '[]')").Error; err != nil {
if err := db.Exec("INSERT INTO products (id, name, price, cost_price, stock, images_json) VALUES (101, 'A', 1000, 500, 10, '[]')").Error; err != nil {
t.Fatalf("insert product failed: %v", err)
}
@ -83,15 +135,21 @@ func TestCreateIssueRewards_SnapshotFromProductPrice(t *testing.T) {
if row.PriceSnapshotCents != 1000 {
t.Fatalf("expected snapshot=1000, got=%d", row.PriceSnapshotCents)
}
if row.CostSnapshotCents != 500 {
t.Fatalf("expected cost snapshot=500, got=%d", row.CostSnapshotCents)
}
if row.PriceSnapshotAt.IsZero() {
t.Fatalf("expected price snapshot time to be set")
}
}
func TestModifyIssueReward_ProductChanged_RecomputeSnapshot(t *testing.T) {
svc, q, db := newRewardSnapshotTestService(t)
ctx := context.Background()
_ = db.Exec("INSERT INTO products (id, name, price, stock, images_json) VALUES (101, 'A', 1000, 10, '[]')").Error
_ = db.Exec("INSERT INTO products (id, name, price, stock, images_json) VALUES (102, 'B', 2300, 10, '[]')").Error
_ = db.Exec("INSERT INTO activity_reward_settings (id, issue_id, product_id, price_snapshot_cents, weight, quantity, original_qty, level, sort, is_boss, min_score) VALUES (1, 9, 101, 1000, 1, 1, 1, 1, 1, 0, 0)").Error
_ = db.Exec("INSERT INTO products (id, name, price, cost_price, stock, images_json) VALUES (101, 'A', 1000, 500, 10, '[]')").Error
_ = db.Exec("INSERT INTO products (id, name, price, cost_price, stock, images_json) VALUES (102, 'B', 2300, 1300, 10, '[]')").Error
_ = db.Exec("INSERT INTO activity_reward_settings (id, issue_id, product_id, price_snapshot_cents, cost_snapshot_cents, price_snapshot_at, weight, quantity, original_qty, level, sort, is_boss, min_score, drop_quantity) VALUES (1, 9, 101, 1000, 500, CURRENT_TIMESTAMP, 1, 1, 1, 1, 1, 0, 0, 1)").Error
newProductID := int64(102)
if err := svc.ModifyIssueReward(ctx, 1, ModifyRewardInput{ProductID: &newProductID}); err != nil {
@ -108,4 +166,92 @@ func TestModifyIssueReward_ProductChanged_RecomputeSnapshot(t *testing.T) {
if row.PriceSnapshotCents != 2300 {
t.Fatalf("expected snapshot=2300, got=%d", row.PriceSnapshotCents)
}
if row.CostSnapshotCents != 1300 {
t.Fatalf("expected cost snapshot=1300, got=%d", row.CostSnapshotCents)
}
}
func TestCopyActivity_CopiesRewardSnapshotsAndMissingFields(t *testing.T) {
svc, q, db := newRewardSnapshotTestService(t)
ctx := context.Background()
snapshotAt := time.Date(2026, 4, 21, 10, 0, 0, 0, time.UTC)
if err := db.Exec("INSERT INTO activities (id, name, banner, activity_category_id, status, price_draw, is_boss, end_time) VALUES (118, '源活动', 'banner', 3, 1, 199, 0, CURRENT_TIMESTAMP)").Error; err != nil {
t.Fatalf("insert activity failed: %v", err)
}
if err := db.Exec("INSERT INTO activity_issues (id, activity_id, issue_number, status, sort) VALUES (201, 118, '001', 1, 9)").Error; err != nil {
t.Fatalf("insert issue failed: %v", err)
}
if err := db.Exec("INSERT INTO activity_reward_settings (issue_id, product_id, price_snapshot_cents, cost_snapshot_cents, price_snapshot_at, weight, quantity, original_qty, level, sort, is_boss, min_score, drop_quantity) VALUES (201, 101, 1800, 900, ?, 5, 10, 10, 1, 7, 0, 88, 3)", snapshotAt).Error; err != nil {
t.Fatalf("insert reward failed: %v", err)
}
newActivityID, err := svc.CopyActivity(ctx, 118)
if err != nil {
t.Fatalf("CopyActivity failed: %v", err)
}
if newActivityID == 118 || newActivityID == 0 {
t.Fatalf("expected new activity id, got=%d", newActivityID)
}
issues, err := q.ActivityIssues.WithContext(ctx).Where(q.ActivityIssues.ActivityID.Eq(newActivityID)).Find()
if err != nil || len(issues) != 1 {
t.Fatalf("query new issues failed: %v len=%d", err, len(issues))
}
reward, err := q.ActivityRewardSettings.WithContext(ctx).Where(q.ActivityRewardSettings.IssueID.Eq(issues[0].ID)).First()
if err != nil {
t.Fatalf("query copied reward failed: %v", err)
}
if reward.PriceSnapshotCents != 1800 || reward.CostSnapshotCents != 900 {
t.Fatalf("unexpected copied snapshots: price=%d cost=%d", reward.PriceSnapshotCents, reward.CostSnapshotCents)
}
if !reward.PriceSnapshotAt.Equal(snapshotAt) {
t.Fatalf("expected copied snapshot time %v, got %v", snapshotAt, reward.PriceSnapshotAt)
}
if reward.MinScore != 88 || reward.DropQuantity != 3 {
t.Fatalf("expected min_score/drop_quantity copied, got min_score=%d drop_quantity=%d", reward.MinScore, reward.DropQuantity)
}
}
func TestCopyActivity_FillsMissingSnapshotData(t *testing.T) {
svc, q, db := newRewardSnapshotTestService(t)
ctx := context.Background()
if err := db.Exec("INSERT INTO products (id, name, price, cost_price, stock, images_json) VALUES (101, 'A', 2600, 1700, 10, '[]')").Error; err != nil {
t.Fatalf("insert product failed: %v", err)
}
if err := db.Exec("INSERT INTO activities (id, name, banner, activity_category_id, status, price_draw, is_boss, end_time) VALUES (118, '源活动', 'banner', 3, 1, 199, 0, CURRENT_TIMESTAMP)").Error; err != nil {
t.Fatalf("insert activity failed: %v", err)
}
if err := db.Exec("INSERT INTO activity_issues (id, activity_id, issue_number, status, sort) VALUES (201, 118, '001', 1, 9)").Error; err != nil {
t.Fatalf("insert issue failed: %v", err)
}
if err := db.Exec("INSERT INTO activity_reward_settings (issue_id, product_id, price_snapshot_cents, cost_snapshot_cents, weight, quantity, original_qty, level, sort, is_boss, min_score, drop_quantity) VALUES (201, 101, 0, 0, 5, 10, 10, 1, 7, 0, 0, 0)").Error; err != nil {
t.Fatalf("insert legacy reward failed: %v", err)
}
newActivityID, err := svc.CopyActivity(ctx, 118)
if err != nil {
t.Fatalf("CopyActivity failed: %v", err)
}
issues, err := q.ActivityIssues.WithContext(ctx).Where(q.ActivityIssues.ActivityID.Eq(newActivityID)).Find()
if err != nil || len(issues) != 1 {
t.Fatalf("query new issues failed: %v len=%d", err, len(issues))
}
reward, err := q.ActivityRewardSettings.WithContext(ctx).Where(q.ActivityRewardSettings.IssueID.Eq(issues[0].ID)).First()
if err != nil {
t.Fatalf("query copied reward failed: %v", err)
}
if reward.PriceSnapshotCents != 2600 || reward.CostSnapshotCents != 1700 {
t.Fatalf("expected fallback snapshots from product, got price=%d cost=%d", reward.PriceSnapshotCents, reward.CostSnapshotCents)
}
if reward.PriceSnapshotAt.IsZero() {
t.Fatalf("expected snapshot time to be backfilled")
}
if reward.DropQuantity != 1 {
t.Fatalf("expected default drop quantity 1, got %d", reward.DropQuantity)
}
}