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

7.4 KiB
Raw Permalink Blame History

渠道统计 — 盈亏计算

需求概述

/admin/channels/:id/stats 接口的 Overview 和趋势图中新增盈亏指标。

盈亏公式

盈亏 = 收入(price_draw × count) - 成本(奖品价值 × 道具卡倍数)

数据源

维度 来源 说明
收入 已有 calcPaidByPriceDraw 三路分类:抽奖/对对碰/一番赏
成本 user_inventory.value_cents 奖品价值快照fallback: activity_reward_settings.price_snapshot_centsproducts.price
道具卡倍数 orders.item_card_iduser_item_cards.card_idsystem_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_iduser_item_cards.card_idsystem_item_cards.reward_multiplier_x1000 链路获取

Step 3: 在 GetStats 中调用成本计算

文件internal/service/channel/channel.goGetStats 方法

// ========== 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_centsproducts.price
概率提升卡EffectType=2不影响成本 reward_multiplier_x1000 只在 EffectType=1 时 > 1000概率卡该字段为 1000GREATEST 确保最小为 1.0

验收标准

  • Overview 新增 total_cost_centstotal_profit_centstotal_costtotal_profit
  • 趋势图每天新增 cost_centsprofit_cents
  • 道具卡双倍正确计入成本×2
  • 无道具卡时成本不受影响×1
  • 成本计算排除 status=2作废和 void 备注的资产
  • 编译通过 make build-mac

SESSION_ID供 /ccg:execute 使用)

  • CODEX_SESSION: N/A
  • GEMINI_SESSION: N/A