package channel import ( "context" "testing" "time" "bindbox-game/internal/pkg/logger" "bindbox-game/internal/repository/mysql" "bindbox-game/internal/repository/mysql/dao" ) // setupTestService 创建使用 SQLite 内存库的 service 实例及基础表结构。 func setupTestService(t *testing.T) (*service, mysql.Repo) { t.Helper() repo, err := mysql.NewSQLiteRepoForTest() if err != nil { t.Fatal(err) } ddls := []string{ `CREATE TABLE channels ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, code TEXT NOT NULL, type TEXT NOT NULL DEFAULT 'other', remarks TEXT, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, deleted_at DATETIME )`, `CREATE TABLE users ( id INTEGER PRIMARY KEY AUTOINCREMENT, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, deleted_at DATETIME, nickname TEXT NOT NULL, avatar TEXT, mobile TEXT, openid TEXT, unionid TEXT, invite_code TEXT NOT NULL, inviter_id INTEGER DEFAULT 0, status INTEGER NOT NULL DEFAULT 1, douyin_id TEXT, channel_id INTEGER DEFAULT 0, douyin_user_id TEXT, remark TEXT NOT NULL DEFAULT '' )`, `CREATE TABLE orders ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL, status INTEGER NOT NULL, actual_amount INTEGER NOT NULL DEFAULT 0, total_amount INTEGER NOT NULL DEFAULT 0, discount_amount INTEGER NOT NULL DEFAULT 0, points_amount INTEGER NOT NULL DEFAULT 0, source_type INTEGER NOT NULL DEFAULT 1, ext_order_id TEXT NOT NULL DEFAULT '', remark TEXT NOT NULL DEFAULT '', item_card_id INTEGER DEFAULT 0, created_at DATETIME DEFAULT CURRENT_TIMESTAMP )`, `CREATE TABLE user_inventory ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL, order_id INTEGER DEFAULT 0, reward_id INTEGER DEFAULT 0, product_id INTEGER DEFAULT 0, status INTEGER NOT NULL DEFAULT 1, value_cents INTEGER DEFAULT 0, remark TEXT NOT NULL DEFAULT '', created_at DATETIME DEFAULT CURRENT_TIMESTAMP )`, `CREATE TABLE activity_reward_settings ( id INTEGER PRIMARY KEY AUTOINCREMENT, price_snapshot_cents INTEGER DEFAULT 0 )`, `CREATE TABLE products ( id INTEGER PRIMARY KEY AUTOINCREMENT, price INTEGER DEFAULT 0 )`, `CREATE TABLE user_item_cards ( id INTEGER PRIMARY KEY AUTOINCREMENT, card_id INTEGER DEFAULT 0 )`, `CREATE TABLE system_item_cards ( id INTEGER PRIMARY KEY AUTOINCREMENT, reward_multiplier_x1000 INTEGER DEFAULT 1000 )`, } for _, ddl := range ddls { if err := repo.GetDbW().Exec(ddl).Error; err != nil { t.Fatalf("DDL failed: %v\nSQL: %s", err, ddl) } } lg, err := logger.NewCustomLogger(logger.WithOutputInConsole()) if err != nil { t.Fatal(err) } q := dao.Use(repo.GetDbR()) svc := &service{logger: lg, readDB: q, writeDB: dao.Use(repo.GetDbW())} return svc, repo } // mustExec 执行 SQL,失败则 Fatal。 func mustExec(t *testing.T, repo mysql.Repo, sql string, args ...interface{}) { t.Helper() if err := repo.GetDbW().Exec(sql, args...).Error; err != nil { t.Fatalf("exec failed: %v\nSQL: %s", err, sql) } } // TestCalcGMVByTotalAmount_ThreeGameTypes 验证三种游戏类型的原价都被正确统计。 // 使用 total_amount(活动原价)确保优惠券、道具卡免单的订单也完整计入。 func TestCalcGMVByTotalAmount_ThreeGameTypes(t *testing.T) { svc, repo := setupTestService(t) mustExec(t, repo, `INSERT INTO channels (id, name, code, type, remarks) VALUES (1, '测试渠道', 'TEST', 'other', '')`) mustExec(t, repo, `INSERT INTO users (id, nickname, invite_code, status, channel_id) VALUES (1, 'u1', 'I1', 1, 1)`) orderFilter := "users.channel_id = ? AND users.deleted_at IS NULL AND orders.status = 2 AND orders.total_amount > 0 AND orders.actual_amount > 0 AND orders.source_type IN (2,3,4) AND (orders.ext_order_id = '' OR orders.ext_order_id IS NULL)" // 抽奖订单 source=2,含优惠券200 mustExec(t, repo, `INSERT INTO orders (user_id, status, actual_amount, total_amount, discount_amount, points_amount, source_type, ext_order_id, remark) VALUES (1, 2, 800, 1000, 200, 0, 2, '', 'lottery:activity:10|count:1')`) // 对对碰付费 source=3 mustExec(t, repo, `INSERT INTO orders (user_id, status, actual_amount, total_amount, discount_amount, points_amount, source_type, ext_order_id, remark) VALUES (1, 2, 500, 500, 0, 0, 3, '', 'matching_game:issue:50')`) // 一番赏 source=4,含优惠券300 mustExec(t, repo, `INSERT INTO orders (user_id, status, actual_amount, total_amount, discount_amount, points_amount, source_type, ext_order_id, remark) VALUES (1, 2, 500, 800, 300, 0, 4, '', 'game_pass_package:幸运|pkg_id:7|count:2')`) // 次卡免费使用:actual_amount=0 但 total_amount=600,不应计入GMV(避免与购买次卡重复计数) mustExec(t, repo, `INSERT INTO orders (user_id, status, actual_amount, total_amount, discount_amount, points_amount, source_type, ext_order_id, remark) VALUES (1, 2, 0, 600, 0, 0, 2, '', 'lottery:activity:10|count:1|use_game_pass')`) // 过滤条件:status!=2,不应计入 mustExec(t, repo, `INSERT INTO orders (user_id, status, actual_amount, total_amount, discount_amount, points_amount, source_type, ext_order_id, remark) VALUES (1, 1, 9999, 9999, 0, 0, 2, '', 'lottery:activity:10|count:1')`) // 过滤条件:total_amount=0,不应计入 mustExec(t, repo, `INSERT INTO orders (user_id, status, actual_amount, total_amount, discount_amount, points_amount, source_type, ext_order_id, remark) VALUES (1, 2, 0, 0, 0, 0, 2, '', 'lottery:activity:10|count:1')`) // 过滤条件:有 ext_order_id,不应计入 mustExec(t, repo, `INSERT INTO orders (user_id, status, actual_amount, total_amount, discount_amount, points_amount, source_type, ext_order_id, remark) VALUES (1, 2, 9999, 9999, 0, 0, 2, 'EXT-1', 'lottery:activity:10|count:1')`) total, byDate := svc.calcGMVByTotalAmount(context.Background(), 1, "2006-01-02", orderFilter, nil, nil) // GMV = 1000 + 500 + 800 = 2300(次卡免费使用actual=0的600不计入) if total.Total != 2300 { t.Errorf("total.Total = %d, want 2300 (抽奖1000 + 对对碰500 + 一番赏800)", total.Total) } // 现金 = 800 + 500 + 500 = 1800 if total.Cash != 1800 { t.Errorf("total.Cash = %d, want 1800", total.Cash) } // 优惠券 = 200 + 300 = 500 if total.Coupon != 500 { t.Errorf("total.Coupon = %d, want 500", total.Coupon) } if total.Points != 0 { t.Errorf("total.Points = %d, want 0", total.Points) } if len(byDate) == 0 { t.Error("byDate should not be empty") } } // TestCalcGMVByTotalAmount_DateFilter 验证时间范围过滤正确。 func TestCalcGMVByTotalAmount_DateFilter(t *testing.T) { svc, repo := setupTestService(t) mustExec(t, repo, `INSERT INTO channels (id, name, code, type, remarks) VALUES (1, '测试渠道', 'TEST', 'other', '')`) mustExec(t, repo, `INSERT INTO users (id, nickname, invite_code, status, channel_id) VALUES (1, 'u1', 'I1', 1, 1)`) orderFilter := "users.channel_id = ? AND users.deleted_at IS NULL AND orders.status = 2 AND orders.total_amount > 0 AND orders.actual_amount > 0 AND orders.source_type IN (2,3,4) AND (orders.ext_order_id = '' OR orders.ext_order_id IS NULL)" mustExec(t, repo, `INSERT INTO orders (user_id, status, actual_amount, total_amount, source_type, ext_order_id, created_at) VALUES (1, 2, 500, 500, 2, '', '2026-03-01 10:00:00')`) mustExec(t, repo, `INSERT INTO orders (user_id, status, actual_amount, total_amount, source_type, ext_order_id, created_at) VALUES (1, 2, 300, 300, 3, '', '2026-03-05 10:00:00')`) mustExec(t, repo, `INSERT INTO orders (user_id, status, actual_amount, total_amount, source_type, ext_order_id, created_at) VALUES (1, 2, 700, 700, 4, '', '2026-03-10 10:00:00')`) start, _ := time.Parse("2006-01-02", "2026-03-02") end, _ := time.Parse("2006-01-02", "2026-03-09") end = end.Add(24*time.Hour - time.Second) total, byDate := svc.calcGMVByTotalAmount(context.Background(), 1, "2006-01-02", orderFilter, &start, &end) // 只有 03-05 的 300 在范围内 if total.Total != 300 { t.Errorf("total.Total = %d, want 300 (only 2026-03-05 order)", total.Total) } if byDate["2026-03-05"].Total != 300 { t.Errorf("byDate[2026-03-05].Total = %d, want 300", byDate["2026-03-05"].Total) } if byDate["2026-03-01"].Total != 0 && byDate["2026-03-10"].Total != 0 { t.Error("dates outside range should not appear") } } // TestCalcGMVByTotalAmount_MultiChannel 验证不同渠道数据互不干扰。 func TestCalcGMVByTotalAmount_MultiChannel(t *testing.T) { svc, repo := setupTestService(t) mustExec(t, repo, `INSERT INTO channels (id, name, code, type, remarks) VALUES (1, '渠道A', 'CA', 'other', ''), (2, '渠道B', 'CB', 'other', '')`) mustExec(t, repo, `INSERT INTO users (id, nickname, invite_code, status, channel_id) VALUES (1, 'u1', 'I1', 1, 1)`) mustExec(t, repo, `INSERT INTO users (id, nickname, invite_code, status, channel_id) VALUES (2, 'u2', 'I2', 1, 2)`) orderFilter := "users.channel_id = ? AND users.deleted_at IS NULL AND orders.status = 2 AND orders.total_amount > 0 AND orders.actual_amount > 0 AND orders.source_type IN (2,3,4) AND (orders.ext_order_id = '' OR orders.ext_order_id IS NULL)" mustExec(t, repo, `INSERT INTO orders (user_id, status, actual_amount, total_amount, source_type, ext_order_id) VALUES (1, 2, 1000, 1000, 2, '')`) mustExec(t, repo, `INSERT INTO orders (user_id, status, actual_amount, total_amount, source_type, ext_order_id) VALUES (2, 2, 2000, 2000, 3, '')`) total1, _ := svc.calcGMVByTotalAmount(context.Background(), 1, "2006-01-02", orderFilter, nil, nil) total2, _ := svc.calcGMVByTotalAmount(context.Background(), 2, "2006-01-02", orderFilter, nil, nil) if total1.Total != 1000 { t.Errorf("channel1 total = %d, want 1000", total1.Total) } if total2.Total != 2000 { t.Errorf("channel2 total = %d, want 2000", total2.Total) } } // TestCalcCostByInventory_Basic 验证成本从 value_cents 读取。 func TestCalcCostByInventory_Basic(t *testing.T) { svc, repo := setupTestService(t) mustExec(t, repo, `INSERT INTO channels (id, name, code, type, remarks) VALUES (1, '测试渠道', 'TEST', 'other', '')`) mustExec(t, repo, `INSERT INTO users (id, nickname, invite_code, status, channel_id) VALUES (1, 'u1', 'I1', 1, 1)`) // status=1(待发货) 和 status=3(已发货) 都计入成本 mustExec(t, repo, `INSERT INTO user_inventory (user_id, order_id, status, value_cents, remark) VALUES (1, 0, 1, 500, '')`) mustExec(t, repo, `INSERT INTO user_inventory (user_id, order_id, status, value_cents, remark) VALUES (1, 0, 3, 300, '')`) // status=2 不计入 mustExec(t, repo, `INSERT INTO user_inventory (user_id, order_id, status, value_cents, remark) VALUES (1, 0, 2, 999, '')`) // remark含void 不计入 mustExec(t, repo, `INSERT INTO user_inventory (user_id, order_id, status, value_cents, remark) VALUES (1, 0, 1, 888, 'void-item')`) total, byDate := svc.calcCostByInventory(context.Background(), 1, "2006-01-02", nil, nil) // 500 + 300 = 800 if total != 800 { t.Errorf("cost total = %d, want 800", total) } if len(byDate) == 0 { t.Error("byDate should not be empty") } } // TestProfitLoss_AllGameTypes 端到端验证盈亏 = GMV(原价) - 成本,覆盖三种游戏类型及道具卡免单。 // 核心场景:道具卡免单订单 actual_amount=0 但 total_amount=活动原价,成本真实存在, // 使用 total_amount 口径确保盈亏计算准确。 func TestProfitLoss_AllGameTypes(t *testing.T) { svc, repo := setupTestService(t) mustExec(t, repo, `INSERT INTO channels (id, name, code, type, remarks) VALUES (1, '测试渠道', 'TEST', 'other', '')`) mustExec(t, repo, `INSERT INTO users (id, nickname, invite_code, status, channel_id) VALUES (1, 'u1', 'I1', 1, 1)`) orderFilter := "users.channel_id = ? AND users.deleted_at IS NULL AND orders.status = 2 AND orders.total_amount > 0 AND orders.actual_amount > 0 AND orders.source_type IN (2,3,4) AND (orders.ext_order_id = '' OR orders.ext_order_id IS NULL)" // 收入:3种游戏(total_amount = 活动原价),含优惠券拆分 mustExec(t, repo, `INSERT INTO orders (id, user_id, status, actual_amount, total_amount, discount_amount, points_amount, source_type, ext_order_id, remark) VALUES (1, 1, 2, 3600, 4600, 1000, 0, 2, '', 'lottery:activity:10|count:1')`) // 抽奖 46元(现金36+券10) mustExec(t, repo, `INSERT INTO orders (id, user_id, status, actual_amount, total_amount, discount_amount, points_amount, source_type, ext_order_id, remark) VALUES (2, 1, 2, 1086, 1086, 0, 0, 3, '', 'matching_game:issue:50')`) // 对对碰 10.86元(全现金) mustExec(t, repo, `INSERT INTO orders (id, user_id, status, actual_amount, total_amount, discount_amount, points_amount, source_type, ext_order_id, remark) VALUES (3, 1, 2, 2820, 3320, 500, 0, 4, '', 'game_pass_package:x|pkg_id:7|count:2')`) // 一番赏 33.20元(现金28.20+券5) // 次卡免费使用:actual_amount=0,total_amount=2000,不计入GMV(避免重复计数),但成本仍计入 mustExec(t, repo, `INSERT INTO orders (id, user_id, status, actual_amount, total_amount, discount_amount, points_amount, source_type, ext_order_id, remark) VALUES (4, 1, 2, 0, 2000, 0, 0, 2, '', 'lottery:activity:10|count:1|use_game_pass')`) // 成本:库存资产 mustExec(t, repo, `INSERT INTO user_inventory (user_id, order_id, status, value_cents, remark) VALUES (1, 0, 1, 8000, '')`) // 成本 80元 totalGMV, _ := svc.calcGMVByTotalAmount(context.Background(), 1, "2006-01-02", orderFilter, nil, nil) totalCost, _ := svc.calcCostByInventory(context.Background(), 1, "2006-01-02", nil, nil) profit := totalGMV.Total - totalCost // GMV = 4600 + 1086 + 3320 = 9006(次卡免费使用的2000不计入) if totalGMV.Total != 9006 { t.Errorf("totalGMV.Total = %d, want 9006 (抽奖4600 + 对对碰1086 + 一番赏3320)", totalGMV.Total) } // 现金 = 3600 + 1086 + 2820 = 7506 if totalGMV.Cash != 7506 { t.Errorf("totalGMV.Cash = %d, want 7506", totalGMV.Cash) } // 优惠券 = 1000 + 500 = 1500 if totalGMV.Coupon != 1500 { t.Errorf("totalGMV.Coupon = %d, want 1500", totalGMV.Coupon) } // 成本 = 8000 if totalCost != 8000 { t.Errorf("totalCost = %d, want 8000", totalCost) } // 盈亏 = 9006 - 8000 = 1006 if profit != 1006 { t.Errorf("profit = %d, want 1006", profit) } } // TestCalcGMVByTotalAmount_Empty 验证无订单时返回零值。 func TestCalcGMVByTotalAmount_Empty(t *testing.T) { svc, repo := setupTestService(t) mustExec(t, repo, `INSERT INTO channels (id, name, code, type, remarks) VALUES (1, '空渠道', 'EMPTY', 'other', '')`) orderFilter := "users.channel_id = ? AND users.deleted_at IS NULL AND orders.status = 2 AND orders.total_amount > 0 AND orders.actual_amount > 0 AND orders.source_type IN (2,3,4) AND (orders.ext_order_id = '' OR orders.ext_order_id IS NULL)" total, byDate := svc.calcGMVByTotalAmount(context.Background(), 1, "2006-01-02", orderFilter, nil, nil) if total.Total != 0 { t.Errorf("empty channel total = %d, want 0", total.Total) } if len(byDate) != 0 { t.Errorf("byDate should be empty, got %v", byDate) } }