Zuncle 0a397adf41 feat: 支持批量合成并收口不包邮运费规则
本次提交同时完成碎片批量合成与盒柜发货运费规则改造,减少用户重复操作,并确保不包邮商品在任何件数下都必须支付运费。

- 合成功能:新增批量合成接口与前端一键合成入口,按配方可合成上限批量消耗碎片并生成对应资产,同时补充批量合成测试
- 运费规则:后端新增统一运费判定逻辑,命中 category_id 14/15 的商品时整单强制收取运费,否则继续沿用少于 5 件收运费的旧规则
- 发货流程:新增运费检查接口,前端发货前先向后端确认是否需要支付运费,并根据“件数不足”或“包含不包邮商品”展示不同提示文案
- 接口校验:运费预下单与批量申请发货统一复用后端判定逻辑,避免前端规则被绕过
2026-04-21 02:06:56 +08:00

250 lines
8.7 KiB
Go

package synthesis
import (
"context"
"testing"
"bindbox-game/internal/repository/mysql"
"bindbox-game/internal/repository/mysql/model"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
func newSynthesisServiceForTest(t *testing.T) *service {
t.Helper()
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatalf("open sqlite failed: %v", err)
}
statements := []string{
`CREATE TABLE product_categories (
id INTEGER PRIMARY KEY AUTOINCREMENT,
is_fragment INTEGER NOT NULL DEFAULT 0,
deleted_at DATETIME NULL
);`,
`CREATE TABLE products (
id INTEGER PRIMARY KEY AUTOINCREMENT,
category_id INTEGER NOT NULL DEFAULT 0,
name TEXT NOT NULL DEFAULT '',
price INTEGER NOT NULL DEFAULT 0,
status INTEGER NOT NULL DEFAULT 1,
images_json TEXT NOT NULL DEFAULT '',
deleted_at DATETIME NULL
);`,
`CREATE TABLE fragment_synthesis_recipes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL DEFAULT '',
description TEXT NOT NULL DEFAULT '',
target_product_id INTEGER NOT NULL DEFAULT 0,
status INTEGER NOT NULL DEFAULT 1,
created_at DATETIME NULL,
updated_at DATETIME NULL,
deleted_at DATETIME NULL
);`,
`CREATE TABLE fragment_synthesis_recipe_materials (
id INTEGER PRIMARY KEY AUTOINCREMENT,
recipe_id INTEGER NOT NULL DEFAULT 0,
fragment_product_id INTEGER NOT NULL DEFAULT 0,
required_count INTEGER NOT NULL DEFAULT 0,
created_at DATETIME NULL,
updated_at DATETIME NULL,
deleted_at DATETIME NULL
);`,
`CREATE TABLE fragment_synthesis_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
created_at DATETIME NULL,
user_id INTEGER NOT NULL DEFAULT 0,
recipe_id INTEGER NOT NULL DEFAULT 0,
consumed_inventory_ids TEXT NOT NULL DEFAULT '',
produced_inventory_id INTEGER NOT NULL DEFAULT 0
);`,
`CREATE TABLE user_inventory (
id INTEGER PRIMARY KEY AUTOINCREMENT,
created_at DATETIME NULL,
updated_at DATETIME NULL,
user_id INTEGER NOT NULL DEFAULT 0,
product_id INTEGER NOT NULL DEFAULT 0,
value_cents INTEGER NOT NULL DEFAULT 0,
value_source INTEGER NOT NULL DEFAULT 0,
value_snapshot_at DATETIME NULL,
order_id INTEGER NOT NULL DEFAULT 0,
activity_id INTEGER NOT NULL DEFAULT 0,
reward_id INTEGER NOT NULL DEFAULT 0,
status INTEGER NOT NULL DEFAULT 1,
shipping_no TEXT NOT NULL DEFAULT '',
remark TEXT NOT NULL DEFAULT ''
);`,
}
for _, stmt := range statements {
if err := db.Exec(stmt).Error; err != nil {
t.Fatalf("exec schema failed: %v", err)
}
}
return New(mysql.NewTestRepo(db)).(*service)
}
func TestValidateRecipeProducts_TargetCannotBeFragment(t *testing.T) {
svc := newSynthesisServiceForTest(t)
ctx := context.Background()
if err := svc.repo.GetDbW().Exec("INSERT INTO product_categories(id, is_fragment) VALUES (1, 1), (2, 0)").Error; err != nil {
t.Fatalf("seed categories failed: %v", err)
}
if err := svc.repo.GetDbW().Exec("INSERT INTO products(id, category_id) VALUES (10, 1), (11, 1)").Error; err != nil {
t.Fatalf("seed products failed: %v", err)
}
err := svc.validateRecipeProducts(ctx, 10, []MaterialInput{{FragmentProductID: 11, RequiredCount: 1}})
if err == nil || err.Error() != "target_product_cannot_be_fragment" {
t.Fatalf("expected target_product_cannot_be_fragment, got: %v", err)
}
}
func TestValidateRecipeProducts_MaterialMustBeFragment(t *testing.T) {
svc := newSynthesisServiceForTest(t)
ctx := context.Background()
if err := svc.repo.GetDbW().Exec("INSERT INTO product_categories(id, is_fragment) VALUES (1, 1), (2, 0)").Error; err != nil {
t.Fatalf("seed categories failed: %v", err)
}
if err := svc.repo.GetDbW().Exec("INSERT INTO products(id, category_id) VALUES (20, 2), (21, 2)").Error; err != nil {
t.Fatalf("seed products failed: %v", err)
}
err := svc.validateRecipeProducts(ctx, 20, []MaterialInput{{FragmentProductID: 21, RequiredCount: 1}})
want := "material_must_be_fragment:21"
if err == nil || err.Error() != want {
t.Fatalf("expected %s, got: %v", want, err)
}
}
func TestValidateRecipeProducts_ValidCombination(t *testing.T) {
svc := newSynthesisServiceForTest(t)
ctx := context.Background()
if err := svc.repo.GetDbW().Exec("INSERT INTO product_categories(id, is_fragment) VALUES (1, 1), (2, 0)").Error; err != nil {
t.Fatalf("seed categories failed: %v", err)
}
if err := svc.repo.GetDbW().Exec("INSERT INTO products(id, category_id) VALUES (30, 2), (31, 1)").Error; err != nil {
t.Fatalf("seed products failed: %v", err)
}
err := svc.validateRecipeProducts(ctx, 30, []MaterialInput{{FragmentProductID: 31, RequiredCount: 2}})
if err != nil {
t.Fatalf("expected nil error, got: %v", err)
}
}
func TestBatchSynthesizeProducesAllPossibleItems(t *testing.T) {
svc := newSynthesisServiceForTest(t)
ctx := context.Background()
seedBatchSynthesisFixture(t, svc)
result, err := svc.BatchSynthesize(ctx, 1001, 1)
if err != nil {
t.Fatalf("batch synthesize failed: %v", err)
}
if result.SynthesizedCount != 3 {
t.Fatalf("expected 3 syntheses, got %d", result.SynthesizedCount)
}
if len(result.ProducedInventoryIDs) != 3 {
t.Fatalf("expected 3 produced ids, got %d", len(result.ProducedInventoryIDs))
}
if result.ConsumedInventoryCount != 9 {
t.Fatalf("expected 9 consumed inventory items, got %d", result.ConsumedInventoryCount)
}
assertInventoryStatusCount(t, svc, 1001, 11, 2, 6)
assertInventoryStatusCount(t, svc, 1001, 12, 2, 3)
assertInventoryStatusCount(t, svc, 1001, 10, 1, 3)
assertSynthesisLogCount(t, svc, 1001, 1, 3)
}
func TestBatchSynthesizeUsesShortestMaterial(t *testing.T) {
svc := newSynthesisServiceForTest(t)
ctx := context.Background()
seedBatchSynthesisFixture(t, svc)
if err := svc.repo.GetDbW().Exec("INSERT INTO user_inventory(user_id, product_id, value_cents, status, remark) VALUES (1001, 12, 0, 1, 'extra_fragment')").Error; err != nil {
t.Fatalf("seed extra fragment failed: %v", err)
}
result, err := svc.BatchSynthesize(ctx, 1001, 1)
if err != nil {
t.Fatalf("batch synthesize failed: %v", err)
}
if result.SynthesizedCount != 3 {
t.Fatalf("expected shortest material to cap at 3, got %d", result.SynthesizedCount)
}
assertInventoryStatusCount(t, svc, 1001, 12, 1, 1)
}
func TestBatchSynthesizeFailsWhenInsufficient(t *testing.T) {
svc := newSynthesisServiceForTest(t)
ctx := context.Background()
seedBatchSynthesisFixture(t, svc)
if err := svc.repo.GetDbW().Exec("DELETE FROM user_inventory WHERE user_id = ? AND product_id = ?", 1001, 12).Error; err != nil {
t.Fatalf("clear fragments failed: %v", err)
}
_, err := svc.BatchSynthesize(ctx, 1001, 1)
if err == nil || err.Error() != "insufficient_fragments" {
t.Fatalf("expected insufficient_fragments, got %v", err)
}
}
func seedBatchSynthesisFixture(t *testing.T, svc *service) {
t.Helper()
db := svc.repo.GetDbW()
statements := []string{
"INSERT INTO product_categories(id, is_fragment) VALUES (1, 1), (2, 0)",
"INSERT INTO products(id, category_id, name, price, status) VALUES (10, 2, '目标商品', 1999, 1), (11, 1, '碎片A', 0, 1), (12, 1, '碎片B', 0, 1)",
"INSERT INTO fragment_synthesis_recipes(id, name, description, target_product_id, status) VALUES (1, '配方1', '测试配方', 10, 1)",
"INSERT INTO fragment_synthesis_recipe_materials(id, recipe_id, fragment_product_id, required_count) VALUES (1, 1, 11, 2), (2, 1, 12, 1)",
}
for _, stmt := range statements {
if err := db.Exec(stmt).Error; err != nil {
t.Fatalf("seed fixture failed: %v", err)
}
}
for i := 0; i < 6; i++ {
if err := db.Create(&model.UserInventory{UserID: 1001, ProductID: 11, ValueCents: 0, Status: 1, Remark: "fragment_a"}).Error; err != nil {
t.Fatalf("seed fragment a failed: %v", err)
}
}
for i := 0; i < 3; i++ {
if err := db.Create(&model.UserInventory{UserID: 1001, ProductID: 12, ValueCents: 0, Status: 1, Remark: "fragment_b"}).Error; err != nil {
t.Fatalf("seed fragment b failed: %v", err)
}
}
}
func assertInventoryStatusCount(t *testing.T, svc *service, userID, productID int64, status int32, want int64) {
t.Helper()
var count int64
if err := svc.repo.GetDbR().Model(&model.UserInventory{}).Where("user_id = ? AND product_id = ? AND status = ?", userID, productID, status).Count(&count).Error; err != nil {
t.Fatalf("count inventory failed: %v", err)
}
if count != want {
t.Fatalf("expected %d inventory rows for product %d status %d, got %d", want, productID, status, count)
}
}
func assertSynthesisLogCount(t *testing.T, svc *service, userID, recipeID int64, want int64) {
t.Helper()
var count int64
if err := svc.repo.GetDbR().Model(&model.FragmentSynthesisLogs{}).Where("user_id = ? AND recipe_id = ?", userID, recipeID).Count(&count).Error; err != nil {
t.Fatalf("count logs failed: %v", err)
}
if count != want {
t.Fatalf("expected %d synthesis logs, got %d", want, count)
}
}