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

11 KiB
Raw Blame History

渠道统计接口优化计划

需求概述

优化 /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

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
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.goL206-223

当前SUM(orders.actual_amount) 聚合。

修改为:按渠道查询所有订单 remark分渠道调用 calcPaidByPriceDraw

// 替换原有 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 包含:

"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