1. 排除商城直购(source_type=1):GMV和成本过滤条件从IN(1,2,3,4)改为IN(2,3,4) 2. 排除次卡免费使用订单(actual_amount=0):避免购买次卡和使用次卡双重计入GMV - source_type=4 一番赏使用次卡:1578单 44032元重复 - source_type=3 对对碰使用次卡:422单 7042元重复 - 合计去除51074元虚增GMV(29.1%) 3. 成本过滤条件同步修正:source_type IN(2,3,4),total_amount>0 修正后:GMV从175600降至124527元,毛利率从37.4%回到真实的11.8%
7.4 KiB
7.4 KiB
渠道统计 — 盈亏计算
需求概述
在 /admin/channels/:id/stats 接口的 Overview 和趋势图中新增盈亏指标。
盈亏公式
盈亏 = 收入(price_draw × count) - 成本(奖品价值 × 道具卡倍数)
数据源
| 维度 | 来源 | 说明 |
|---|---|---|
| 收入 | 已有 calcPaidByPriceDraw |
三路分类:抽奖/对对碰/一番赏 |
| 成本 | user_inventory.value_cents |
奖品价值快照(分),fallback: activity_reward_settings.price_snapshot_cents → products.price |
| 道具卡倍数 | orders.item_card_id → user_item_cards.card_id → system_item_cards.reward_multiplier_x1000 |
双倍卡 = 2000(千分比),无卡 = 1000 |
成本计算公式(参考已有 dashboard_activity.go:L234-239)
单件成本 = COALESCE(NULLIF(user_inventory.value_cents, 0),
activity_reward_settings.price_snapshot_cents,
products.price, 0)
道具卡倍数 = GREATEST(COALESCE(system_item_cards.reward_multiplier_x1000, 1000), 1000) / 1000
总成本 = SUM(单件成本 × 道具卡倍数)
实施步骤
Step 1: 扩展响应结构体
文件:internal/service/channel/channel.go
type StatsOverview struct {
TotalUsers int64 `json:"total_users"`
TotalOrders int64 `json:"total_orders"`
TotalGMV int64 `json:"total_gmv"`
TotalPaidCents int64 `json:"total_paid_cents"`
// 新增
TotalCostCents int64 `json:"total_cost_cents"` // 总成本(分)
TotalProfitCents int64 `json:"total_profit_cents"` // 盈亏(分) = paid - cost
TotalCost int64 `json:"total_cost"` // 总成本(元)
TotalProfit int64 `json:"total_profit"` // 盈亏(元)
}
type StatsDailyItem struct {
Date string `json:"date"`
UserCount int64 `json:"user_count"`
OrderCount int64 `json:"order_count"`
GMV int64 `json:"gmv"`
PaidCents int64 `json:"paid_cents"`
// 新增
CostCents int64 `json:"cost_cents"` // 当日成本(分)
ProfitCents int64 `json:"profit_cents"` // 当日盈亏(分)
}
Step 2: 新增 calcCostByInventory 辅助函数
文件:internal/service/channel/channel.go
输入:渠道用户 ID 列表 + 日期范围(可选) 输出:总成本(分)、按日期分组的成本
type costRow struct {
ValueCents int64
Multiplier int64 // reward_multiplier_x1000,无卡时=1000
CreatedAt time.Time
}
func (s *service) calcCostByInventory(ctx context.Context, channelID int64, dateFmt string, startDate, endDate *time.Time) (int64, map[string]int64) {
// SQL 核心逻辑(复用 dashboard_activity.go:L234-239 模式):
//
// SELECT
// COALESCE(NULLIF(ui.value_cents, 0), ars.price_snapshot_cents, p.price, 0) AS unit_cost,
// GREATEST(COALESCE(sic.reward_multiplier_x1000, 1000), 1000) AS multiplier,
// ui.created_at
// FROM user_inventory ui
// JOIN users u ON u.id = ui.user_id
// LEFT JOIN orders o ON o.id = ui.order_id
// LEFT JOIN activity_reward_settings ars ON ars.id = ui.reward_id
// LEFT JOIN products p ON p.id = ui.product_id
// LEFT JOIN user_item_cards uic ON uic.id = o.item_card_id
// LEFT JOIN system_item_cards sic ON sic.id = uic.card_id
// WHERE u.channel_id = ? AND u.deleted_at IS NULL
// AND ui.status IN (1, 3) -- 持有 or 已使用/发货
// AND COALESCE(ui.remark, '') NOT LIKE '%void%'
// AND (o.status = 2 OR ui.order_id = 0 OR ui.order_id IS NULL) -- 兼容历史
// [AND ui.created_at >= ? AND ui.created_at <= ?] -- 可选时间范围
// Go 侧计算:
// for each row:
// cost += unit_cost * multiplier / 1000
// byDate[dateKey] += unit_cost * multiplier / 1000
}
关键点:
- 通过
users.channel_id过滤渠道用户 ui.status IN (1, 3):只统计有效资产(持有 + 已发货),排除作废NOT LIKE '%void%':排除作废备注(o.status = 2 OR ui.order_id = 0 OR ui.order_id IS NULL):兼容历史数据- 道具卡倍数通过
orders.item_card_id→user_item_cards.card_id→system_item_cards.reward_multiplier_x1000链路获取
Step 3: 在 GetStats 中调用成本计算
文件:internal/service/channel/channel.go,GetStats 方法
// ========== Overview 全量成本 ==========
totalCost, _ := s.calcCostByInventory(ctx, channelID, "2006-01-02", nil, nil)
out.Overview.TotalCostCents = totalCost
out.Overview.TotalCost = totalCost / 100
out.Overview.TotalProfitCents = out.Overview.TotalPaidCents - totalCost
out.Overview.TotalProfit = out.Overview.TotalProfitCents / 100
// ========== 趋势图日维度成本 ==========
_, dailyCost := s.calcCostByInventory(ctx, channelID, "2006-01-02", &startDate, &endDate)
for dateKey, cost := range dailyCost {
if item, ok := dateMap[dateKey]; ok {
item.CostCents = cost
item.ProfitCents = item.PaidCents - cost
}
}
Step 4: 在 List 中可选加入成本(列表页)
暂不实施。列表页已有 paid_amount,盈亏是详情页指标,列表页展示所有渠道的成本查询开销较大。后续按需添加。
关键文件
| 文件 | 操作 | 说明 |
|---|---|---|
internal/service/channel/channel.go |
修改 | 扩展结构体 + 新增 calcCostByInventory + 修改 GetStats |
查询关系链
user_inventory
├── JOIN users ON users.id = ui.user_id (过滤渠道)
├── LEFT JOIN orders ON orders.id = ui.order_id (获取 item_card_id)
├── LEFT JOIN activity_reward_settings ON ars.id = ui.reward_id (价格快照)
├── LEFT JOIN products ON p.id = ui.product_id (商品价格 fallback)
├── LEFT JOIN user_item_cards ON uic.id = o.item_card_id (道具卡实例)
└── LEFT JOIN system_item_cards ON sic.id = uic.card_id (道具卡倍数)
道具卡逻辑说明
| 场景 | reward_multiplier_x1000 |
效果 | 成本影响 |
|---|---|---|---|
| 无道具卡 | NULL → COALESCE → 1000 | ×1.0 | 成本 = 奖品原价 |
| 双倍卡 | 2000 | ×2.0 | 成本 = 奖品原价 × 2 |
| 三倍卡(如有) | 3000 | ×3.0 | 成本 = 奖品原价 × 3 |
原理:双倍卡让用户以相同支付价格获得双倍奖品,收入不变但成本翻倍,利润下降。
风险与缓解
| 风险 | 严重程度 | 缓解措施 |
|---|---|---|
user_inventory 数据量大,全量查询慢 |
中 | 通过 users.channel_id 索引过滤,只查渠道用户 |
历史资产无 order_id |
已解决 | (o.status = 2 OR ui.order_id = 0 OR ui.order_id IS NULL) 兼容 |
value_cents = 0 的历史数据 |
已解决 | COALESCE 链式 fallback 到 price_snapshot_cents → products.price |
| 概率提升卡(EffectType=2)不影响成本 | 低 | reward_multiplier_x1000 只在 EffectType=1 时 > 1000,概率卡该字段为 1000,GREATEST 确保最小为 1.0 |
验收标准
- Overview 新增
total_cost_cents、total_profit_cents、total_cost、total_profit - 趋势图每天新增
cost_cents、profit_cents - 道具卡(双倍)正确计入成本(×2)
- 无道具卡时成本不受影响(×1)
- 成本计算排除 status=2(作废)和 void 备注的资产
- 编译通过
make build-mac
SESSION_ID(供 /ccg:execute 使用)
- CODEX_SESSION: N/A
- GEMINI_SESSION: N/A