# 渠道统计 — 盈亏计算 ## 需求概述 在 `/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