From 471e21a68b8a7086c890bcc3bac7872edb41afae Mon Sep 17 00:00:00 2001 From: Zuncle <34310384@qq.com> Date: Tue, 21 Apr 2026 03:08:21 +0800 Subject: [PATCH] =?UTF-8?q?fix(activity):=20=E4=BF=AE=E5=A4=8D=E5=A4=8D?= =?UTF-8?q?=E5=88=B6=E6=B4=BB=E5=8A=A8=E6=97=B6=E5=A5=96=E5=8A=B1=E5=BF=AB?= =?UTF-8?q?=E7=85=A7=E6=97=B6=E9=97=B4=E5=86=99=E5=85=A5=E5=BC=82=E5=B8=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 本次提交修复了管理端复制活动接口在复制奖励配置时写入零时间导致的 MySQL datetime 报错,并补齐了活动奖励复制过程遗漏的快照与业务字段,避免新活动复制后出现配置丢失或插入失败。 - 复制逻辑:在 CopyActivity 中完整复制 price_snapshot_cents、cost_snapshot_cents、price_snapshot_at、min_score、drop_quantity 等奖励字段 - 异常兜底:当历史奖励快照时间为空或为零值时,复制时自动回填为当前时间;当价格/成本快照缺失且存在商品 ID 时,回退读取当前商品价格与成本价 - 兼容历史数据:确保旧奖励配置即使未完整回填快照字段,也能被安全复制,不再因 0000-00-00 时间值触发插入失败 - 测试覆盖:补充活动复制场景测试,验证快照字段原样复制、缺失快照自动补齐,以及 min_score/drop_quantity 等字段不会在复制时丢失 --- internal/service/activity/activity_copy.go | 44 ++++- .../service/activity/reward_snapshot_test.go | 158 +++++++++++++++++- 2 files changed, 188 insertions(+), 14 deletions(-) diff --git a/internal/service/activity/activity_copy.go b/internal/service/activity/activity_copy.go index 0f10bda..ab1afe9 100755 --- a/internal/service/activity/activity_copy.go +++ b/internal/service/activity/activity_copy.go @@ -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 diff --git a/internal/service/activity/reward_snapshot_test.go b/internal/service/activity/reward_snapshot_test.go index c4c3b71..f64e953 100644 --- a/internal/service/activity/reward_snapshot_test.go +++ b/internal/service/activity/reward_snapshot_test.go @@ -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) + } }