bindbox-game/internal/api/admin/livestream_metrics_test.go
Zuncle c927f46cdd fix(livestream): 统一直播间盈亏成本口径并消除转赠影响
将直播间统计从基于 user_inventory 当前持有状态和 remark 反推成本,改为基于 livestream_draw_logs 中奖事实直接关联 products.cost_price 计算成本。统一 /livestream/activities/:id/stats 与 /livestream/activities/:id/draw_logs 两个接口的营收、退款、成本和净利润口径,避免因转赠、remark 覆盖或订单行缺失导致统计失真,并补充针对转赠、退款、零订单和 product 回退场景的回归测试。
2026-04-12 21:23:36 +08:00

421 lines
15 KiB
Go

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
}