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%
188 lines
7.4 KiB
Markdown
188 lines
7.4 KiB
Markdown
# 渠道统计 — 盈亏计算
|
||
|
||
## 需求概述
|
||
|
||
在 `/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,概率卡该字段为 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
|