后端: - GMVBreakdown 结构体拆分 total/cash/coupon/points 四个维度 - calcGMVByTotalAmount 返回值改为 GMVBreakdown,SELECT 增加 actual_amount/discount_amount/points_amount - StatsOverview/StatsDailyItem 新增 cash_cents/coupon_cents/points_cents 字段 - 测试数据增加优惠券场景,新增拆分断言 前端: - API 接口类型同步增加 3 个拆分字段 - 概览区新增支付构成行:现金/优惠券/积分 各带金额和占比
305 lines
15 KiB
Go
305 lines
15 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,
|
||
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)
|
||
}
|
||
}
|