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.
This commit is contained in:
Zuncle 2026-04-09 17:04:30 +08:00
parent c7a6e1e017
commit 03214dddf2
2 changed files with 120 additions and 96 deletions

View File

@ -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

View File

@ -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=0total_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"])
}
}