bindbox-game/internal/service/channel/channel_stats_test.go
win c0267c7a33 feat(channel): GMV支付方式拆分(现金/优惠券/积分)
后端:
- GMVBreakdown 结构体拆分 total/cash/coupon/points 四个维度
- calcGMVByTotalAmount 返回值改为 GMVBreakdown,SELECT 增加 actual_amount/discount_amount/points_amount
- StatsOverview/StatsDailyItem 新增 cash_cents/coupon_cents/points_cents 字段
- 测试数据增加优惠券场景,新增拆分断言

前端:
- API 接口类型同步增加 3 个拆分字段
- 概览区新增支付构成行:现金/优惠券/积分 各带金额和占比
2026-03-16 23:33:48 +08:00

305 lines
15 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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=0total_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)
}
}