Replace channel cost aggregation with draw-source based cost calculation that follows activity profit-loss logic and keeps cost attribution on the original ordering user's channel. Update channel stats tests to cover the new cost path and related schema fields.
279 lines
14 KiB
Go
279 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,
|
||
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,
|
||
paid_at DATETIME,
|
||
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,
|
||
product_id INTEGER DEFAULT 0,
|
||
price_snapshot_cents INTEGER DEFAULT 0,
|
||
drop_quantity INTEGER DEFAULT 1
|
||
)`,
|
||
`CREATE TABLE products (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
price INTEGER DEFAULT 0,
|
||
cost_price INTEGER DEFAULT 0
|
||
)`,
|
||
`CREATE TABLE activity_draw_logs (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
order_id INTEGER DEFAULT 0,
|
||
reward_id INTEGER DEFAULT 0,
|
||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||
)`,
|
||
`CREATE TABLE user_item_cards (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
card_id INTEGER DEFAULT 0,
|
||
used_draw_log_id INTEGER DEFAULT 0
|
||
)`,
|
||
`CREATE TABLE system_item_cards (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
effect_type INTEGER DEFAULT 0,
|
||
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)"
|
||
|
||
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')`)
|
||
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')`)
|
||
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')`)
|
||
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')`)
|
||
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')`)
|
||
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')`)
|
||
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)
|
||
|
||
if total.Total != 2300 {
|
||
t.Errorf("total.Total = %d, want 2300 (抽奖1000 + 对对碰500 + 一番赏800)", total.Total)
|
||
}
|
||
if total.Cash != 1800 {
|
||
t.Errorf("total.Cash = %d, want 1800", total.Cash)
|
||
}
|
||
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")
|
||
}
|
||
}
|
||
|
||
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)
|
||
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)
|
||
}
|
||
}
|
||
|
||
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)
|
||
}
|
||
}
|
||
|
||
func TestCalcCostByDrawSource_TransferSafe(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)`)
|
||
mustExec(t, repo, `INSERT INTO orders (id, user_id, status, actual_amount, total_amount, source_type, ext_order_id, paid_at, created_at) VALUES (10, 1, 2, 1000, 1000, 2, '', '2026-03-05 10:00:00', '2026-03-05 10:00:00')`)
|
||
mustExec(t, repo, `INSERT INTO products (id, price, cost_price) VALUES (100, 1500, 900)`)
|
||
mustExec(t, repo, `INSERT INTO activity_reward_settings (id, product_id, price_snapshot_cents, drop_quantity) VALUES (200, 100, 0, 1)`)
|
||
mustExec(t, repo, `INSERT INTO activity_draw_logs (id, order_id, reward_id, created_at) VALUES (300, 10, 200, '2026-03-05 10:00:00')`)
|
||
|
||
totalA, byDateA := svc.calcCostByDrawSource(context.Background(), 1, "2006-01-02", nil, nil)
|
||
totalB, _ := svc.calcCostByDrawSource(context.Background(), 2, "2006-01-02", nil, nil)
|
||
|
||
if totalA != 900 {
|
||
t.Errorf("channel A cost = %d, want 900", totalA)
|
||
}
|
||
if totalB != 0 {
|
||
t.Errorf("channel B cost = %d, want 0", totalB)
|
||
}
|
||
if byDateA["2026-03-05"] != 900 {
|
||
t.Errorf("channel A cost on 2026-03-05 = %d, want 900", byDateA["2026-03-05"])
|
||
}
|
||
}
|
||
|
||
func TestCalcCostByDrawSource_DoubleRewardAndDropQuantity(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)`)
|
||
mustExec(t, repo, `INSERT INTO system_item_cards (id, effect_type, reward_multiplier_x1000) VALUES (1, 1, 2000)`)
|
||
mustExec(t, repo, `INSERT INTO user_item_cards (id, card_id, used_draw_log_id) VALUES (1, 1, 300)`)
|
||
mustExec(t, repo, `INSERT INTO orders (id, user_id, status, actual_amount, total_amount, source_type, ext_order_id, item_card_id, paid_at, created_at) VALUES (10, 1, 2, 1000, 1000, 2, '', 1, '2026-03-05 10:00:00', '2026-03-05 10:00:00')`)
|
||
mustExec(t, repo, `INSERT INTO products (id, price, cost_price) VALUES (100, 1500, 500)`)
|
||
mustExec(t, repo, `INSERT INTO activity_reward_settings (id, product_id, price_snapshot_cents, drop_quantity) VALUES (200, 100, 0, 2)`)
|
||
mustExec(t, repo, `INSERT INTO activity_draw_logs (id, order_id, reward_id, created_at) VALUES (300, 10, 200, '2026-03-05 10:00:00')`)
|
||
|
||
total, byDate := svc.calcCostByDrawSource(context.Background(), 1, "2006-01-02", nil, nil)
|
||
if total != 1500 {
|
||
t.Errorf("cost total = %d, want 1500", total)
|
||
}
|
||
if byDate["2026-03-05"] != 1500 {
|
||
t.Errorf("cost on 2026-03-05 = %d, want 1500", byDate["2026-03-05"])
|
||
}
|
||
}
|
||
|
||
func TestCalcCostByDrawSource_DateFilterUsesOrderDate(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)`)
|
||
mustExec(t, repo, `INSERT INTO orders (id, user_id, status, actual_amount, total_amount, source_type, ext_order_id, paid_at, created_at) VALUES (10, 1, 2, 1000, 1000, 2, '', '2026-03-05 10:00:00', '2026-03-05 10:00:00')`)
|
||
mustExec(t, repo, `INSERT INTO products (id, price, cost_price) VALUES (100, 1500, 700)`)
|
||
mustExec(t, repo, `INSERT INTO activity_reward_settings (id, product_id, price_snapshot_cents, drop_quantity) VALUES (200, 100, 0, 1)`)
|
||
mustExec(t, repo, `INSERT INTO activity_draw_logs (id, order_id, reward_id, created_at) VALUES (300, 10, 200, '2026-03-09 10:00:00')`)
|
||
mustExec(t, repo, `INSERT INTO user_inventory (user_id, order_id, reward_id, product_id, status, value_cents, remark, created_at) VALUES (1, 10, 200, 100, 1, 1500, '', '2026-03-09 10:00:00')`)
|
||
|
||
start, _ := time.Parse("2006-01-02", "2026-03-05")
|
||
end, _ := time.Parse("2006-01-02", "2026-03-05")
|
||
end = end.Add(24*time.Hour - time.Second)
|
||
|
||
total, byDate := svc.calcCostByDrawSource(context.Background(), 1, "2006-01-02", &start, &end)
|
||
if total != 700 {
|
||
t.Errorf("cost total = %d, want 700", total)
|
||
}
|
||
if byDate["2026-03-05"] != 700 {
|
||
t.Errorf("cost on 2026-03-05 = %d, want 700", byDate["2026-03-05"])
|
||
}
|
||
}
|