package admin import ( "testing" "time" "bindbox-game/internal/pkg/logger" "bindbox-game/internal/repository/mysql" ) func TestBuildLivestreamMetrics_UsesProductCostAndIgnoresTransfers(t *testing.T) { repo, err := mysql.NewSQLiteRepoForTest() if err != nil { t.Fatal(err) } ddls := []string{ `CREATE TABLE livestream_activities (id INTEGER PRIMARY KEY, ticket_price INTEGER)`, `CREATE TABLE douyin_orders (id INTEGER PRIMARY KEY, shop_order_id TEXT, actual_pay_amount INTEGER, order_status INTEGER, pay_type_desc TEXT, product_count INTEGER)`, `CREATE TABLE livestream_prizes (id INTEGER PRIMARY KEY, activity_id INTEGER, product_id INTEGER, cost_price INTEGER)`, `CREATE TABLE products (id INTEGER PRIMARY KEY, cost_price INTEGER)`, `CREATE TABLE livestream_draw_logs ( id INTEGER PRIMARY KEY, activity_id INTEGER, prize_id INTEGER, douyin_order_id INTEGER, shop_order_id TEXT, local_user_id INTEGER, douyin_user_id TEXT, product_id INTEGER, prize_name TEXT, user_nickname TEXT, created_at DATETIME, is_refunded INTEGER )`, `CREATE TABLE user_inventory ( id INTEGER PRIMARY KEY, user_id INTEGER, reward_id INTEGER, product_id INTEGER, status INTEGER, value_cents INTEGER, remark TEXT, created_at DATETIME )`, } for _, ddl := range ddls { if err := repo.GetDbW().Exec(ddl).Error; err != nil { t.Fatal(err) } } sqls := []string{ `INSERT INTO livestream_activities (id, ticket_price) VALUES (1, 990)`, `INSERT INTO products (id, cost_price) VALUES (101, 500), (102, 700)`, `INSERT INTO livestream_prizes (id, activity_id, product_id, cost_price) VALUES (11, 1, 101, 9999), (12, 1, 102, 8888)`, `INSERT INTO douyin_orders (id, shop_order_id, actual_pay_amount, order_status, pay_type_desc, product_count) VALUES (201, 'SO-1', 990, 2, '微信支付', 1)`, `INSERT INTO livestream_draw_logs (id, activity_id, prize_id, douyin_order_id, shop_order_id, local_user_id, product_id, prize_name, user_nickname, created_at, is_refunded) VALUES (301, 1, 11, 201, 'SO-1', 9001, 101, 'P1', 'U1', '2026-04-01 10:00:00', 0), (302, 1, 12, 201, 'SO-1', 9001, 102, 'P2', 'U1', '2026-04-01 10:01:00', 0)`, `INSERT INTO user_inventory (id, user_id, reward_id, product_id, status, value_cents, remark, created_at) VALUES (401, 9999, 0, 101, 3, 999999, 'transferred_from_9001|shipping_requested', '2026-04-01 11:00:00'), (402, 9999, 0, 102, 3, 999999, 'transferred_from_9001|shipping_requested', '2026-04-01 11:01:00')`, } for _, sql := range sqls { if err := repo.GetDbW().Exec(sql).Error; err != nil { t.Fatal(err) } } lg, err := logger.NewCustomLogger(logger.WithOutputInConsole()) if err != nil { t.Fatal(err) } h := &handler{logger: lg, repo: repo} start := mustParseDateTime(t, "2026-04-01 00:00:00") end := mustParseDateTime(t, "2026-04-01 23:59:59") metrics, err := h.buildLivestreamMetrics(livestreamMetricsFilter{ActivityID: 1, StartTime: &start, EndTime: &end}, 990) if err != nil { t.Fatal(err) } if metrics.TotalCost != 1200 { t.Fatalf("TotalCost=%d want 1200", metrics.TotalCost) } if metrics.TotalRevenue != 990 { t.Fatalf("TotalRevenue=%d want 990", metrics.TotalRevenue) } if metrics.NetProfit != -210 { t.Fatalf("NetProfit=%d want -210", metrics.NetProfit) } if len(metrics.Daily) != 1 || metrics.Daily[0].TotalCost != 1200 { t.Fatalf("unexpected daily=%+v", metrics.Daily) } } func TestBuildLivestreamMetrics_ExcludesRefundedOrdersFromCost(t *testing.T) { repo, err := mysql.NewSQLiteRepoForTest() if err != nil { t.Fatal(err) } ddls := []string{ `CREATE TABLE livestream_activities (id INTEGER PRIMARY KEY, ticket_price INTEGER)`, `CREATE TABLE douyin_orders (id INTEGER PRIMARY KEY, shop_order_id TEXT, actual_pay_amount INTEGER, order_status INTEGER, pay_type_desc TEXT, product_count INTEGER)`, `CREATE TABLE livestream_prizes (id INTEGER PRIMARY KEY, activity_id INTEGER, product_id INTEGER, cost_price INTEGER)`, `CREATE TABLE products (id INTEGER PRIMARY KEY, cost_price INTEGER)`, `CREATE TABLE livestream_draw_logs ( id INTEGER PRIMARY KEY, activity_id INTEGER, prize_id INTEGER, douyin_order_id INTEGER, shop_order_id TEXT, local_user_id INTEGER, douyin_user_id TEXT, product_id INTEGER, prize_name TEXT, user_nickname TEXT, created_at DATETIME, is_refunded INTEGER )`} for _, ddl := range ddls { if err := repo.GetDbW().Exec(ddl).Error; err != nil { t.Fatal(err) } } sqls := []string{ `INSERT INTO livestream_activities (id, ticket_price) VALUES (1, 990)`, `INSERT INTO products (id, cost_price) VALUES (101, 500), (102, 700)`, `INSERT INTO livestream_prizes (id, activity_id, product_id, cost_price) VALUES (11, 1, 101, 9999), (12, 1, 102, 8888)`, `INSERT INTO douyin_orders (id, shop_order_id, actual_pay_amount, order_status, pay_type_desc, product_count) VALUES (201, 'SO-1', 990, 2, '微信支付', 1), (202, 'SO-2', 1990, 4, '微信支付', 1)`, `INSERT INTO livestream_draw_logs (id, activity_id, prize_id, douyin_order_id, shop_order_id, local_user_id, product_id, prize_name, user_nickname, created_at, is_refunded) VALUES (301, 1, 11, 201, 'SO-1', 9001, 101, 'P1', 'U1', '2026-04-01 10:00:00', 0), (302, 1, 12, 202, 'SO-2', 9002, 102, 'P2', 'U2', '2026-04-01 10:01:00', 1)`, } for _, sql := range sqls { if err := repo.GetDbW().Exec(sql).Error; err != nil { t.Fatal(err) } } lg, err := logger.NewCustomLogger(logger.WithOutputInConsole()) if err != nil { t.Fatal(err) } h := &handler{logger: lg, repo: repo} start := mustParseDateTime(t, "2026-04-01 00:00:00") end := mustParseDateTime(t, "2026-04-01 23:59:59") metrics, err := h.buildLivestreamMetrics(livestreamMetricsFilter{ActivityID: 1, StartTime: &start, EndTime: &end}, 990) if err != nil { t.Fatal(err) } if metrics.TotalRevenue != 2980 { t.Fatalf("TotalRevenue=%d want 2980", metrics.TotalRevenue) } if metrics.TotalRefund != 1990 { t.Fatalf("TotalRefund=%d want 1990", metrics.TotalRefund) } if metrics.TotalCost != 500 { t.Fatalf("TotalCost=%d want 500", metrics.TotalCost) } } func TestBuildLivestreamMetrics_DoesNotCountZeroOrderIDAsOrder(t *testing.T) { repo, err := mysql.NewSQLiteRepoForTest() if err != nil { t.Fatal(err) } ddls := []string{ `CREATE TABLE livestream_activities (id INTEGER PRIMARY KEY, ticket_price INTEGER)`, `CREATE TABLE douyin_orders (id INTEGER PRIMARY KEY, shop_order_id TEXT, actual_pay_amount INTEGER, order_status INTEGER, pay_type_desc TEXT, product_count INTEGER)`, `CREATE TABLE livestream_prizes (id INTEGER PRIMARY KEY, activity_id INTEGER, product_id INTEGER, cost_price INTEGER)`, `CREATE TABLE products (id INTEGER PRIMARY KEY, cost_price INTEGER)`, `CREATE TABLE livestream_draw_logs ( id INTEGER PRIMARY KEY, activity_id INTEGER, prize_id INTEGER, douyin_order_id INTEGER, shop_order_id TEXT, local_user_id INTEGER, douyin_user_id TEXT, product_id INTEGER, prize_name TEXT, user_nickname TEXT, created_at DATETIME, is_refunded INTEGER )`} for _, ddl := range ddls { if err := repo.GetDbW().Exec(ddl).Error; err != nil { t.Fatal(err) } } sqls := []string{ `INSERT INTO livestream_activities (id, ticket_price) VALUES (1, 990)`, `INSERT INTO products (id, cost_price) VALUES (101, 500)`, `INSERT INTO livestream_prizes (id, activity_id, product_id, cost_price) VALUES (11, 1, 101, 9999)`, `INSERT INTO livestream_draw_logs (id, activity_id, prize_id, douyin_order_id, shop_order_id, local_user_id, product_id, prize_name, user_nickname, created_at, is_refunded) VALUES (301, 1, 11, 0, '', 9001, 101, 'P1', 'U1', '2026-04-01 10:00:00', 0), (302, 1, 11, 0, '', 9001, 101, 'P1', 'U1', '2026-04-01 10:01:00', 0)`} for _, sql := range sqls { if err := repo.GetDbW().Exec(sql).Error; err != nil { t.Fatal(err) } } lg, err := logger.NewCustomLogger(logger.WithOutputInConsole()) if err != nil { t.Fatal(err) } h := &handler{logger: lg, repo: repo} start := mustParseDateTime(t, "2026-04-01 00:00:00") end := mustParseDateTime(t, "2026-04-01 23:59:59") metrics, err := h.buildLivestreamMetrics(livestreamMetricsFilter{ActivityID: 1, StartTime: &start, EndTime: &end}, 990) if err != nil { t.Fatal(err) } if metrics.OrderCount != 0 { t.Fatalf("OrderCount=%d want 0", metrics.OrderCount) } if metrics.TotalRevenue != 0 { t.Fatalf("TotalRevenue=%d want 0", metrics.TotalRevenue) } if metrics.TotalCost != 1000 { t.Fatalf("TotalCost=%d want 1000", metrics.TotalCost) } } func TestBuildLivestreamMetrics_FallbackToPrizeProductIDWhenDrawLogProductMissing(t *testing.T) { repo, err := mysql.NewSQLiteRepoForTest() if err != nil { t.Fatal(err) } ddls := []string{ `CREATE TABLE livestream_activities (id INTEGER PRIMARY KEY, ticket_price INTEGER)`, `CREATE TABLE douyin_orders (id INTEGER PRIMARY KEY, shop_order_id TEXT, actual_pay_amount INTEGER, order_status INTEGER, pay_type_desc TEXT, product_count INTEGER)`, `CREATE TABLE livestream_prizes (id INTEGER PRIMARY KEY, activity_id INTEGER, product_id INTEGER, cost_price INTEGER)`, `CREATE TABLE products (id INTEGER PRIMARY KEY, cost_price INTEGER)`, `CREATE TABLE livestream_draw_logs ( id INTEGER PRIMARY KEY, activity_id INTEGER, prize_id INTEGER, douyin_order_id INTEGER, shop_order_id TEXT, local_user_id INTEGER, douyin_user_id TEXT, product_id INTEGER, prize_name TEXT, user_nickname TEXT, created_at DATETIME, is_refunded INTEGER )`} for _, ddl := range ddls { if err := repo.GetDbW().Exec(ddl).Error; err != nil { t.Fatal(err) } } sqls := []string{ `INSERT INTO livestream_activities (id, ticket_price) VALUES (1, 990)`, `INSERT INTO products (id, cost_price) VALUES (101, 500)`, `INSERT INTO livestream_prizes (id, activity_id, product_id, cost_price) VALUES (11, 1, 101, 9999)`, `INSERT INTO douyin_orders (id, shop_order_id, actual_pay_amount, order_status, pay_type_desc, product_count) VALUES (201, 'SO-1', 990, 2, '微信支付', 1)`, `INSERT INTO livestream_draw_logs (id, activity_id, prize_id, douyin_order_id, shop_order_id, local_user_id, product_id, prize_name, user_nickname, created_at, is_refunded) VALUES (301, 1, 11, 201, 'SO-1', 9001, 0, 'P1', 'U1', '2026-04-01 10:00:00', 0)`} for _, sql := range sqls { if err := repo.GetDbW().Exec(sql).Error; err != nil { t.Fatal(err) } } lg, err := logger.NewCustomLogger(logger.WithOutputInConsole()) if err != nil { t.Fatal(err) } h := &handler{logger: lg, repo: repo} start := mustParseDateTime(t, "2026-04-01 00:00:00") end := mustParseDateTime(t, "2026-04-01 23:59:59") metrics, err := h.buildLivestreamMetrics(livestreamMetricsFilter{ActivityID: 1, StartTime: &start, EndTime: &end}, 990) if err != nil { t.Fatal(err) } if metrics.TotalCost != 500 { t.Fatalf("TotalCost=%d want 500", metrics.TotalCost) } } func TestBuildLivestreamMetrics_CountsDouyinUsersWhenLocalUserMissing(t *testing.T) { repo, err := mysql.NewSQLiteRepoForTest() if err != nil { t.Fatal(err) } ddls := []string{ `CREATE TABLE douyin_orders (id INTEGER PRIMARY KEY, shop_order_id TEXT, actual_pay_amount INTEGER, order_status INTEGER, pay_type_desc TEXT, product_count INTEGER)`, `CREATE TABLE livestream_prizes (id INTEGER PRIMARY KEY, activity_id INTEGER, product_id INTEGER, cost_price INTEGER)`, `CREATE TABLE products (id INTEGER PRIMARY KEY, cost_price INTEGER)`, `CREATE TABLE livestream_draw_logs ( id INTEGER PRIMARY KEY, activity_id INTEGER, prize_id INTEGER, douyin_order_id INTEGER, shop_order_id TEXT, local_user_id INTEGER, douyin_user_id TEXT, product_id INTEGER, prize_name TEXT, user_nickname TEXT, created_at DATETIME, is_refunded INTEGER )`} for _, ddl := range ddls { if err := repo.GetDbW().Exec(ddl).Error; err != nil { t.Fatal(err) } } sqls := []string{ `INSERT INTO products (id, cost_price) VALUES (101, 500)`, `INSERT INTO livestream_prizes (id, activity_id, product_id, cost_price) VALUES (11, 1, 101, 9999)`, `INSERT INTO livestream_draw_logs (id, activity_id, prize_id, douyin_order_id, shop_order_id, local_user_id, douyin_user_id, product_id, prize_name, user_nickname, created_at, is_refunded) VALUES (301, 1, 11, 0, '', 0, 'dy-1', 101, 'P1', 'U1', '2026-04-01 10:00:00', 0), (302, 1, 11, 0, '', 0, 'dy-2', 101, 'P1', 'U2', '2026-04-01 10:01:00', 0)`} for _, sql := range sqls { if err := repo.GetDbW().Exec(sql).Error; err != nil { t.Fatal(err) } } lg, err := logger.NewCustomLogger(logger.WithOutputInConsole()) if err != nil { t.Fatal(err) } h := &handler{logger: lg, repo: repo} start := mustParseDateTime(t, "2026-04-01 00:00:00") end := mustParseDateTime(t, "2026-04-01 23:59:59") metrics, err := h.buildLivestreamMetrics(livestreamMetricsFilter{ActivityID: 1, StartTime: &start, EndTime: &end}, 990) if err != nil { t.Fatal(err) } if metrics.UserCount != 2 { t.Fatalf("UserCount=%d want 2", metrics.UserCount) } } func TestBuildLivestreamMetrics_ExcludesRefundedLogsWhenOrderRowMissing(t *testing.T) { repo, err := mysql.NewSQLiteRepoForTest() if err != nil { t.Fatal(err) } ddls := []string{ `CREATE TABLE douyin_orders (id INTEGER PRIMARY KEY, shop_order_id TEXT, actual_pay_amount INTEGER, order_status INTEGER, pay_type_desc TEXT, product_count INTEGER)`, `CREATE TABLE livestream_prizes (id INTEGER PRIMARY KEY, activity_id INTEGER, product_id INTEGER, cost_price INTEGER)`, `CREATE TABLE products (id INTEGER PRIMARY KEY, cost_price INTEGER)`, `CREATE TABLE livestream_draw_logs ( id INTEGER PRIMARY KEY, activity_id INTEGER, prize_id INTEGER, douyin_order_id INTEGER, shop_order_id TEXT, local_user_id INTEGER, douyin_user_id TEXT, product_id INTEGER, prize_name TEXT, user_nickname TEXT, created_at DATETIME, is_refunded INTEGER )`} for _, ddl := range ddls { if err := repo.GetDbW().Exec(ddl).Error; err != nil { t.Fatal(err) } } sqls := []string{ `INSERT INTO products (id, cost_price) VALUES (101, 500)`, `INSERT INTO livestream_prizes (id, activity_id, product_id, cost_price) VALUES (11, 1, 101, 9999)`, `INSERT INTO livestream_draw_logs (id, activity_id, prize_id, douyin_order_id, shop_order_id, local_user_id, product_id, prize_name, user_nickname, created_at, is_refunded) VALUES (301, 1, 11, 201, 'SO-1', 9001, 101, 'P1', 'U1', '2026-04-01 10:00:00', 1)`} for _, sql := range sqls { if err := repo.GetDbW().Exec(sql).Error; err != nil { t.Fatal(err) } } lg, err := logger.NewCustomLogger(logger.WithOutputInConsole()) if err != nil { t.Fatal(err) } h := &handler{logger: lg, repo: repo} start := mustParseDateTime(t, "2026-04-01 00:00:00") end := mustParseDateTime(t, "2026-04-01 23:59:59") metrics, err := h.buildLivestreamMetrics(livestreamMetricsFilter{ActivityID: 1, StartTime: &start, EndTime: &end}, 990) if err != nil { t.Fatal(err) } if metrics.TotalCost != 0 { t.Fatalf("TotalCost=%d want 0", metrics.TotalCost) } } func mustParseDateTime(t *testing.T, value string) time.Time { t.Helper() ts, err := time.ParseInLocation("2006-01-02 15:04:05", value, time.Local) if err != nil { t.Fatalf("parse %q: %v", value, err) } return ts }