bindbox-game/internal/service/channel/channel_stats_test.go
win 8d1eef2f7f fix(channel): 修复渠道统计GMV重复计数和商城直购误计入
1. 排除商城直购(source_type=1):GMV和成本过滤条件从IN(1,2,3,4)改为IN(2,3,4)
2. 排除次卡免费使用订单(actual_amount=0):避免购买次卡和使用次卡双重计入GMV
   - source_type=4 一番赏使用次卡:1578单 44032元重复
   - source_type=3 对对碰使用次卡:422单 7042元重复
   - 合计去除51074元虚增GMV(29.1%)
3. 成本过滤条件同步修正:source_type IN(2,3,4),total_amount>0

修正后:GMV从175600降至124527元,毛利率从37.4%回到真实的11.8%
2026-03-16 21:41:39 +08:00

284 lines
14 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,
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=2actual_amount < total_amount道具卡折扣
mustExec(t, repo, `INSERT INTO orders (user_id, status, actual_amount, total_amount, source_type, ext_order_id, remark) VALUES (1, 2, 800, 1000, 2, '', 'lottery:activity:10|count:1')`)
// 对对碰付费 source=3
mustExec(t, repo, `INSERT INTO orders (user_id, status, actual_amount, total_amount, source_type, ext_order_id, remark) VALUES (1, 2, 500, 500, 3, '', 'matching_game:issue:50')`)
// 一番赏 source=4
mustExec(t, repo, `INSERT INTO orders (user_id, status, actual_amount, total_amount, source_type, ext_order_id, remark) VALUES (1, 2, 800, 800, 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, source_type, ext_order_id, remark) VALUES (1, 2, 0, 600, 2, '', 'lottery:activity:10|count:1|use_game_pass')`)
// 过滤条件status!=2不应计入
mustExec(t, repo, `INSERT INTO orders (user_id, status, actual_amount, total_amount, source_type, ext_order_id, remark) VALUES (1, 1, 9999, 9999, 2, '', 'lottery:activity:10|count:1')`)
// 过滤条件total_amount=0不应计入
mustExec(t, repo, `INSERT INTO orders (user_id, status, actual_amount, total_amount, source_type, ext_order_id, remark) VALUES (1, 2, 0, 0, 2, '', 'lottery:activity:10|count:1')`)
// 过滤条件:有 ext_order_id不应计入
mustExec(t, repo, `INSERT INTO orders (user_id, status, actual_amount, total_amount, source_type, ext_order_id, remark) VALUES (1, 2, 9999, 9999, 2, 'EXT-1', 'lottery:activity:10|count:1')`)
total, byDate := svc.calcGMVByTotalAmount(context.Background(), 1, "2006-01-02", orderFilter, nil, nil)
// 1000 + 500 + 800 = 2300次卡免费使用actual=0的600不计入
if total != 2300 {
t.Errorf("total = %d, want 2300 (抽奖1000 + 对对碰500 + 一番赏800)", total)
}
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 != 300 {
t.Errorf("total = %d, want 300 (only 2026-03-05 order)", total)
}
if byDate["2026-03-05"] != 300 {
t.Errorf("byDate[2026-03-05] = %d, want 300", byDate["2026-03-05"])
}
if byDate["2026-03-01"] != 0 && byDate["2026-03-10"] != 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 != 1000 {
t.Errorf("channel1 total = %d, want 1000", total1)
}
if total2 != 2000 {
t.Errorf("channel2 total = %d, want 2000", total2)
}
}
// 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, source_type, ext_order_id, remark) VALUES (1, 1, 2, 4600, 4600, 2, '', 'lottery:activity:10|count:1')`) // 抽奖 46元
mustExec(t, repo, `INSERT INTO orders (id, user_id, status, actual_amount, total_amount, source_type, ext_order_id, remark) VALUES (2, 1, 2, 1086, 1086, 3, '', 'matching_game:issue:50')`) // 对对碰 10.86元
mustExec(t, repo, `INSERT INTO orders (id, user_id, status, actual_amount, total_amount, source_type, ext_order_id, remark) VALUES (3, 1, 2, 3320, 3320, 4, '', 'game_pass_package:x|pkg_id:7|count:2')`) // 一番赏 33.20元
// 次卡免费使用actual_amount=0total_amount=2000不计入GMV避免重复计数但成本仍计入
mustExec(t, repo, `INSERT INTO orders (id, user_id, status, actual_amount, total_amount, source_type, ext_order_id, remark) VALUES (4, 1, 2, 0, 2000, 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 - totalCost
// GMV = 4600 + 1086 + 3320 = 9006次卡免费使用的2000不计入
if totalGMV != 9006 {
t.Errorf("totalGMV = %d, want 9006 (抽奖4600 + 对对碰1086 + 一番赏3320)", totalGMV)
}
// 成本 = 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 != 0 {
t.Errorf("empty channel total = %d, want 0", total)
}
if len(byDate) != 0 {
t.Errorf("byDate should be empty, got %v", byDate)
}
}