From 03214dddf2923ee2c814be011c1d08e81b5c792b Mon Sep 17 00:00:00 2001 From: Zuncle <34310384@qq.com> Date: Thu, 9 Apr 2026 17:04:30 +0800 Subject: [PATCH] fix(channel): align channel cost with draw-source attribution 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. --- internal/service/channel/channel.go | 58 ++++++- .../service/channel/channel_stats_test.go | 158 ++++++++---------- 2 files changed, 120 insertions(+), 96 deletions(-) diff --git a/internal/service/channel/channel.go b/internal/service/channel/channel.go index d843a01..d14ad6b 100755 --- a/internal/service/channel/channel.go +++ b/internal/service/channel/channel.go @@ -234,6 +234,56 @@ func (s *service) calcCostByInventory(ctx context.Context, channelID int64, date return total, byDate } +// calcCostByDrawSource 按订单/抽奖来源统计渠道奖品成本。 +// 成本口径与活动盈亏保持一致: +// - 来源:activity_draw_logs + activity_reward_settings + products.cost_price +// - 数量:drop_quantity,默认 1 +// - 倍数:命中特定道具卡翻倍规则时 +1 份 +// +// 注意:这里按 orders.user_id -> users.channel_id 归因,而不是按当前 user_inventory.user_id。 +// 这样 inventory 转赠后,成本仍归到原下单用户所属渠道。 +func (s *service) calcCostByDrawSource(ctx context.Context, channelID int64, dateFmt string, startDate, endDate *time.Time) (int64, map[string]int64) { + type costRow struct { + CostCents int64 + PaidAt time.Time + } + + q := s.readDB.ActivityDrawLogs.WithContext(ctx).UnderlyingDB(). + Table("activity_draw_logs"). + Select(` + SUM(COALESCE(products.cost_price, 0) * ( + COALESCE(NULLIF(activity_reward_settings.drop_quantity, 0), 1) + + CASE WHEN user_item_cards.used_draw_log_id = activity_draw_logs.id AND system_item_cards.effect_type = 1 AND system_item_cards.reward_multiplier_x1000 >= 2000 THEN 1 ELSE 0 END + )) AS cost_cents, + orders.paid_at + `). + Joins("JOIN orders ON orders.id = activity_draw_logs.order_id"). + Joins("JOIN users ON users.id = orders.user_id"). + Joins("LEFT JOIN activity_reward_settings ON activity_reward_settings.id = activity_draw_logs.reward_id"). + Joins("LEFT JOIN products ON products.id = activity_reward_settings.product_id"). + Joins("LEFT JOIN user_item_cards ON user_item_cards.id = orders.item_card_id"). + Joins("LEFT JOIN system_item_cards ON system_item_cards.id = user_item_cards.card_id"). + Where("users.channel_id = ? AND users.deleted_at IS NULL", channelID). + Where("orders.status = 2"). + Where("orders.source_type IN ?", []int{2, 3, 4}). + Where("orders.ext_order_id = '' OR orders.ext_order_id IS NULL") + + if startDate != nil && endDate != nil { + q = q.Where("orders.paid_at >= ? AND orders.paid_at <= ?", *startDate, *endDate) + } + + var rows []costRow + q.Group("orders.id, orders.paid_at").Scan(&rows) + + var total int64 + byDate := make(map[string]int64) + for _, r := range rows { + total += r.CostCents + byDate[r.PaidAt.Format(dateFmt)] += r.CostCents + } + return total, byDate +} + func (s *service) Create(ctx context.Context, in CreateInput) (*model.Channels, error) { m := &model.Channels{Name: in.Name, Code: in.Code, Type: in.Type, Remarks: in.Remarks} if err := s.writeDB.Channels.WithContext(ctx).Create(m); err != nil { @@ -383,8 +433,8 @@ func (s *service) GetStats(ctx context.Context, channelID int64, days int, start out.Overview.CouponCents = totalGMV.Coupon out.Overview.PointsCents = totalGMV.Points - // 1d. 累计成本(全量,含道具卡倍数) - totalCost, _ := s.calcCostByInventory(ctx, channelID, "2006-01-02", nil, nil) + // 1d. 累计成本(全量,按原始订单/抽奖来源归因) + totalCost, _ := s.calcCostByDrawSource(ctx, channelID, "2006-01-02", nil, nil) out.Overview.TotalCostCents = totalCost out.Overview.TotalCost = totalCost / 100 out.Overview.TotalProfitCents = totalGMV.Total - totalCost @@ -452,8 +502,8 @@ func (s *service) GetStats(ctx context.Context, channelID int64, days int, start } } - // 2f. 每日成本(含道具卡倍数) - _, dailyCost := s.calcCostByInventory(ctx, channelID, "2006-01-02", &startDate, &endDate) + // 2f. 每日成本(按原始订单/抽奖来源归因) + _, dailyCost := s.calcCostByDrawSource(ctx, channelID, "2006-01-02", &startDate, &endDate) for dateKey, cost := range dailyCost { if item, ok := dateMap[dateKey]; ok { item.CostCents = cost diff --git a/internal/service/channel/channel_stats_test.go b/internal/service/channel/channel_stats_test.go index 15f2038..2d5af5b 100644 --- a/internal/service/channel/channel_stats_test.go +++ b/internal/service/channel/channel_stats_test.go @@ -58,6 +58,7 @@ func setupTestService(t *testing.T) (*service, mysql.Repo) { 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 ( @@ -73,18 +74,29 @@ func setupTestService(t *testing.T) (*service, mysql.Repo) { )`, `CREATE TABLE activity_reward_settings ( id INTEGER PRIMARY KEY AUTOINCREMENT, - price_snapshot_cents INTEGER DEFAULT 0 + 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 + 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 + 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 )`, } @@ -120,32 +132,22 @@ func TestCalcGMVByTotalAmount_ThreeGameTypes(t *testing.T) { 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) } @@ -157,7 +159,6 @@ func TestCalcGMVByTotalAmount_ThreeGameTypes(t *testing.T) { } } -// TestCalcGMVByTotalAmount_DateFilter 验证时间范围过滤正确。 func TestCalcGMVByTotalAmount_DateFilter(t *testing.T) { svc, repo := setupTestService(t) @@ -175,20 +176,14 @@ func TestCalcGMVByTotalAmount_DateFilter(t *testing.T) { 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) @@ -212,93 +207,72 @@ func TestCalcGMVByTotalAmount_MultiChannel(t *testing.T) { } } -// TestCalcCostByInventory_Basic 验证成本从 value_cents 读取。 -func TestCalcCostByInventory_Basic(t *testing.T) { +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')`) - // 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) + total, byDate := svc.calcCostByDrawSource(context.Background(), 1, "2006-01-02", nil, nil) + if total != 1500 { + t.Errorf("cost total = %d, want 1500", total) } - if len(byDate) == 0 { - t.Error("byDate should not be empty") + if byDate["2026-03-05"] != 1500 { + t.Errorf("cost on 2026-03-05 = %d, want 1500", byDate["2026-03-05"]) } } -// TestProfitLoss_AllGameTypes 端到端验证盈亏 = GMV(原价) - 成本,覆盖三种游戏类型及道具卡免单。 -// 核心场景:道具卡免单订单 actual_amount=0 但 total_amount=活动原价,成本真实存在, -// 使用 total_amount 口径确保盈亏计算准确。 -func TestProfitLoss_AllGameTypes(t *testing.T) { +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')`) - 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)" + 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) - // 收入: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) + total, byDate := svc.calcCostByDrawSource(context.Background(), 1, "2006-01-02", &start, &end) + if total != 700 { + t.Errorf("cost total = %d, want 700", 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) + if byDate["2026-03-05"] != 700 { + t.Errorf("cost on 2026-03-05 = %d, want 700", byDate["2026-03-05"]) } }