bindbox-game/.claude/plan/channel-stats-profit-loss.md
win 8d1eef2f7f fix(channel): 修复渠道统计GMV重复计数和商城直购误计入
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%
2026-03-16 21:41:39 +08:00

188 lines
7.4 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 渠道统计 — 盈亏计算
## 需求概述
`/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
```sql
单件成本 = 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`
```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 列表 + 日期范围(可选)
**输出**:总成本(分)、按日期分组的成本
```go
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` 方法
```go
// ========== 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概率卡该字段为 1000GREATEST 确保最小为 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