bindbox-game/.claude/plan/channel-stats-optimization.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

312 lines
11 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/:channel_id/stats` 接口:
| 指标 | 当前实现 | 优化后 |
|------|---------|--------|
| 累计用户 | `COUNT(users WHERE channel_id = X)` | 保持不变 — **全量统计,不限时间** |
| 累计订单 | `COUNT(orders JOIN users ...)` | 保持不变 — **全量统计,不限时间** |
| 累计实付金额 | `SUM(orders.actual_amount)` | remark → activityID → `activities.price_draw × count`**全量统计,不限时间** |
| 趋势图表 | 按**月**分组(`days` 参数实际当月用) | 修正为按**天**分组,`days` 参数控制天数范围 |
## 确认的决策
- ✅ 直接用 remark 中 activityID 查 `activities.price_draw`
- ✅ 软删除活动也计入(使用 `Unscoped`
-`days` 参数修正为按天计算
- ✅ Overview 三个指标为全量(不受 days 限制)
## 受影响的代码
| 方法 | 文件 | 行号 | 改动内容 |
|------|------|------|---------|
| `GetStats` | `internal/service/channel/channel.go` | L238-355 | 核心改动:金额计算 + days 修正 + 按天分组 |
| `List` | `internal/service/channel/channel.go` | L157-236 | 同步改动:列表 paid_amount 用 price_draw 计算 |
| `StatsOutput` / `StatsDailyItem` | `internal/service/channel/channel.go` | L66-84 | 结构体不变,`Daily` 改为按天粒度 |
## 实施步骤
### Step 1: 新增 `orderRemarkRow` 类型和 `calcPaidByPriceDraw` 辅助函数
**文件**`internal/service/channel/channel.go`
```go
type orderRemarkRow struct {
Remark string
CreatedAt time.Time
}
// calcPaidByPriceDraw 解析订单 remark 中的 activityID + count
// 批量查 activities.price_draw含软删除计算实付金额
// 返回:总金额(分)、按日期key分组的金额
func (s *service) calcPaidByPriceDraw(ctx context.Context, rows []orderRemarkRow, dateFmt string) (int64, map[string]int64, error) {
if len(rows) == 0 {
return 0, nil, nil
}
// 1. 解析 remark收集 unique activityIDs
type parsed struct {
activityID int64
count int64
dateKey string
}
var items []parsed
idSet := make(map[int64]struct{})
for _, r := range rows {
rmk := remark.Parse(r.Remark)
if rmk.ActivityID > 0 {
items = append(items, parsed{
activityID: rmk.ActivityID,
count: rmk.Count,
dateKey: r.CreatedAt.Format(dateFmt),
})
idSet[rmk.ActivityID] = struct{}{}
}
}
// 2. 批量查 activities.price_draw含软删除 Unscoped
actIDs := make([]int64, 0, len(idSet))
for id := range idSet {
actIDs = append(actIDs, id)
}
priceMap := make(map[int64]int64)
if len(actIDs) > 0 {
var acts []model.Activities
s.readDB.Activities.WithContext(ctx).UnderlyingDB().
Unscoped().
Table("activities").
Select("id, price_draw").
Where("id IN ?", actIDs).
Find(&acts)
for _, a := range acts {
priceMap[a.ID] = a.PriceDraw
}
}
// 3. 计算
var total int64
byDate := make(map[string]int64)
for _, item := range items {
if price, ok := priceMap[item.activityID]; ok {
amt := price * item.count
total += amt
byDate[item.dateKey] += amt
}
}
return total, byDate, nil
}
```
### Step 2: 重写 `GetStats` — 日期逻辑修正 + 金额计算
**改动要点**
1. **参数 `days` 真正按天**`startDate = now.AddDate(0, 0, -days+1)`
2. **Overview 全量不限时间**:用户数、订单数、实付金额均查全量
3. **趋势按天分组**`DATE_FORMAT(..., '%Y-%m-%d')` 替代 `'%Y-%m'`
4. **金额用 price_draw**:调用 `calcPaidByPriceDraw`
```go
func (s *service) GetStats(ctx context.Context, channelID int64, days int, startDateStr, endDateStr string) (*StatsOutput, error) {
now := time.Now()
// 校验渠道存在
_, err := s.readDB.Channels.WithContext(ctx).Where(s.readDB.Channels.ID.Eq(channelID)).First()
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ErrChannelNotFound
}
return nil, err
}
out := &StatsOutput{}
// ========== 1. Overview全量不限时间==========
// 1a. 累计用户
userCount, _ := s.readDB.Users.WithContext(ctx).
Where(s.readDB.Users.ChannelID.Eq(channelID)).Count()
out.Overview.TotalUsers = userCount
// 1b. 累计订单数
orderFilter := "users.channel_id = ? AND users.deleted_at IS NULL AND orders.status = 2 AND orders.actual_amount > 0 AND orders.source_type IN (1,2,3,4) AND (orders.ext_order_id = '' OR orders.ext_order_id IS NULL)"
type countResult struct{ Count int64 }
var cr countResult
s.readDB.Orders.WithContext(ctx).UnderlyingDB().Table("orders").
Joins("JOIN users ON users.id = orders.user_id").
Select("count(*) as count").
Where(orderFilter, channelID).
Scan(&cr)
out.Overview.TotalOrders = cr.Count
// 1c. 累计实付金额(全量订单 remark → price_draw × count
var allRemarks []orderRemarkRow
s.readDB.Orders.WithContext(ctx).UnderlyingDB().Table("orders").
Joins("JOIN users ON users.id = orders.user_id").
Select("orders.remark, orders.created_at").
Where(orderFilter, channelID).
Scan(&allRemarks)
totalPaid, _, _ := s.calcPaidByPriceDraw(ctx, allRemarks, "2006-01-02")
out.Overview.TotalPaidCents = totalPaid
out.Overview.TotalGMV = totalPaid / 100
// ========== 2. 趋势图(按天分组,受 days 限制)==========
// 2a. 计算日期范围
var startDate, endDate time.Time
if startDateStr != "" && endDateStr != "" {
startDate, _ = time.Parse("2006-01-02", startDateStr)
endDate, _ = time.Parse("2006-01-02", endDateStr)
endDate = endDate.Add(24*time.Hour - time.Second)
} else {
if days <= 0 {
days = 12
}
startDate = time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()).
AddDate(0, 0, -days+1)
endDate = now
}
// 2b. 初始化日期桶(每天一个)
dateMap := make(map[string]*StatsDailyItem)
var dateList []string
for d := startDate; !d.After(endDate); d = d.AddDate(0, 0, 1) {
key := d.Format("2006-01-02")
dateList = append(dateList, key)
dateMap[key] = &StatsDailyItem{Date: key}
}
// 2c. 每日新增用户
type dailyCount struct {
Date string
Count int64
}
var dailyUsers []dailyCount
s.readDB.Users.WithContext(ctx).UnderlyingDB().Table("users").
Select("DATE_FORMAT(created_at, '%Y-%m-%d') as date, count(*) as count").
Where("channel_id = ? AND deleted_at IS NULL AND created_at >= ? AND created_at <= ?",
channelID, startDate, endDate).
Group("date").Scan(&dailyUsers)
for _, u := range dailyUsers {
if item, ok := dateMap[u.Date]; ok {
item.UserCount = u.Count
}
}
// 2d. 每日订单数
var dailyOrders []dailyCount
s.readDB.Orders.WithContext(ctx).UnderlyingDB().Table("orders").
Joins("JOIN users ON users.id = orders.user_id").
Select("DATE_FORMAT(orders.created_at, '%Y-%m-%d') as date, count(*) as count").
Where(orderFilter+" AND orders.created_at >= ? AND orders.created_at <= ?",
channelID, startDate, endDate).
Group("date").Scan(&dailyOrders)
for _, o := range dailyOrders {
if item, ok := dateMap[o.Date]; ok {
item.OrderCount = o.Count
}
}
// 2e. 每日实付金额remark → price_draw
var rangeRemarks []orderRemarkRow
s.readDB.Orders.WithContext(ctx).UnderlyingDB().Table("orders").
Joins("JOIN users ON users.id = orders.user_id").
Select("orders.remark, orders.created_at").
Where(orderFilter+" AND orders.created_at >= ? AND orders.created_at <= ?",
channelID, startDate, endDate).
Scan(&rangeRemarks)
_, dailyPaid, _ := s.calcPaidByPriceDraw(ctx, rangeRemarks, "2006-01-02")
for dateKey, paid := range dailyPaid {
if item, ok := dateMap[dateKey]; ok {
item.PaidCents = paid
item.GMV = paid / 100
}
}
// 2f. 组装输出
for _, d := range dateList {
out.Daily = append(out.Daily, *dateMap[d])
}
return out, nil
}
```
### Step 3: 同步修改 `List` 方法的金额计算
**文件**`internal/service/channel/channel.go`L206-223
**当前**`SUM(orders.actual_amount)` 聚合。
**修改为**:按渠道查询所有订单 remark分渠道调用 `calcPaidByPriceDraw`
```go
// 替换原有 paidResults 查询逻辑:
if len(channelIDs) > 0 {
// ... userCount 查询保持不变 ...
// 实付金额:查所有渠道的订单 remark
type remarkWithChannel struct {
ChannelID int64
Remark string
CreatedAt time.Time
}
var chRemarks []remarkWithChannel
s.readDB.Orders.WithContext(ctx).UnderlyingDB().Table("orders").
Joins("JOIN users ON users.id = orders.user_id").
Select("users.channel_id, orders.remark, orders.created_at").
Where("users.channel_id IN ? AND users.deleted_at IS NULL AND orders.status = 2 AND orders.actual_amount > 0 AND orders.source_type IN (1,2,3,4) AND (orders.ext_order_id = '' OR orders.ext_order_id IS NULL)", channelIDs).
Scan(&chRemarks)
// 按渠道分组
grouped := make(map[int64][]orderRemarkRow)
for _, r := range chRemarks {
grouped[r.ChannelID] = append(grouped[r.ChannelID], orderRemarkRow{
Remark: r.Remark, CreatedAt: r.CreatedAt,
})
}
for chID, rows := range grouped {
total, _, _ := s.calcPaidByPriceDraw(ctx, rows, "2006-01-02")
paidStats[chID] = total
}
}
```
### Step 4: 添加 remark import
确保文件顶部 import 包含:
```go
"bindbox-game/internal/pkg/util/remark"
```
## 风险与缓解
| 风险 | 严重程度 | 缓解措施 |
|------|---------|----------|
| remark 格式不一致 | 中 | `remark.Parse()` 已处理 `activity:``lottery:activity:` 两种前缀 |
| 软删除活动 | 已解决 | 使用 `Unscoped()` 查询,确保被删活动仍有 price_draw |
| List 方法大量订单性能 | 中 | 单次查询所有渠道订单 remarkGo 中分组计算,比 N+1 高效 |
| days 参数前端兼容 | 低 | 前端传 `days=12` 原意应为12天修正后行为与参数名一致 |
## 验收标准
- [ ] Overview 累计用户:全量统计 `users.channel_id = X` 的用户数(不限时间)
- [ ] Overview 累计订单:全量统计有效订单数(不限时间)
- [ ] Overview 累计实付金额:全量基于 `activities.price_draw × count` 计算(包含软删除活动)
- [ ] 趋势图按**天**分组,`days` 参数控制显示天数
- [ ] 渠道列表页 `paid_amount` 同步使用 price_draw 计算
- [ ] 编译通过 `make build-mac`
- [ ] 现有功能无回归
## SESSION_ID供 /ccg:execute 使用)
- CODEX_SESSION: N/A
- GEMINI_SESSION: N/A