将直播间统计从基于 user_inventory 当前持有状态和 remark 反推成本,改为基于 livestream_draw_logs 中奖事实直接关联 products.cost_price 计算成本。统一 /livestream/activities/:id/stats 与 /livestream/activities/:id/draw_logs 两个接口的营收、退款、成本和净利润口径,避免因转赠、remark 覆盖或订单行缺失导致统计失真,并补充针对转赠、退款、零订单和 product 回退场景的回归测试。
421 lines
15 KiB
Go
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
|
|
}
|