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%
284 lines
14 KiB
Go
284 lines
14 KiB
Go
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=2,actual_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=0,total_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)
|
||
}
|
||
}
|