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%
11 KiB
11 KiB
渠道统计接口优化计划
需求概述
优化 /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 — 日期逻辑修正 + 金额计算
改动要点:
- 参数
days真正按天:startDate = now.AddDate(0, 0, -days+1) - Overview 全量不限时间:用户数、订单数、实付金额均查全量
- 趋势按天分组:
DATE_FORMAT(..., '%Y-%m-%d')替代'%Y-%m' - 金额用 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.go,L206-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 方法大量订单性能 | 中 | 单次查询所有渠道订单 remark,Go 中分组计算,比 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