feat(channel): 渠道统计新增盈亏计算并修复成本口径

后端:
- StatsOverview/StatsDailyItem 新增 cost/profit 字段
- 新增 calcPaidByPriceDraw 三路收入分类(抽奖/对对碰/一番赏)
- 新增 calcCostByInventory 成本计算(含道具卡倍数)
- 修复成本统计未过滤 source_type 导致直播间免费发奖资产被错误计入
- remark.go 新增 PkgID 解析支持一番赏订单

前端:
- 渠道统计弹窗新增"总成本"和"盈亏"卡片
- 趋势图新增"盈亏分析"Tab

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
win 2026-03-11 02:29:19 +08:00
parent d45096d13f
commit 91dd42ca1c
5 changed files with 639 additions and 90 deletions

View File

@ -0,0 +1,145 @@
package main
import (
"fmt"
"strings"
"gorm.io/driver/mysql"
"gorm.io/gorm"
)
func main() {
dsn := "root:bindbox2025kdy@tcp(150.158.78.154:3306)/dev_game?charset=utf8mb4&parseTime=True&loc=Local"
db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{})
if err != nil {
fmt.Println("连接失败:", err)
return
}
channelID := 3
// 1. 成本统计(复用 calcCostByInventory 的 SQL 逻辑)
type costRow struct {
UnitCost int64
Multiplier int64
}
var rows []costRow
db.Table("user_inventory").
Select(`
COALESCE(NULLIF(user_inventory.value_cents, 0), activity_reward_settings.price_snapshot_cents, products.price, 0) AS unit_cost,
GREATEST(COALESCE(system_item_cards.reward_multiplier_x1000, 1000), 1000) AS multiplier
`).
Joins("JOIN users ON users.id = user_inventory.user_id").
Joins("LEFT JOIN orders ON orders.id = user_inventory.order_id").
Joins("LEFT JOIN activity_reward_settings ON activity_reward_settings.id = user_inventory.reward_id").
Joins("LEFT JOIN products ON products.id = user_inventory.product_id").
Joins("LEFT JOIN user_item_cards ON user_item_cards.id = orders.item_card_id").
Joins("LEFT JOIN system_item_cards ON system_item_cards.id = user_item_cards.card_id").
Where("users.channel_id = ? AND users.deleted_at IS NULL", channelID).
Where("user_inventory.status IN ?", []int{1, 3}).
Where("COALESCE(user_inventory.remark, '') NOT LIKE ?", "%void%").
Where("(orders.status = 2 OR user_inventory.order_id = 0 OR user_inventory.order_id IS NULL)").
Where("(orders.source_type IN (1,2,3,4) OR user_inventory.order_id = 0 OR user_inventory.order_id IS NULL)").
Scan(&rows)
var totalCostBase, totalCostFinal int64
var withCard, withoutCard int
for _, r := range rows {
cost := r.UnitCost * r.Multiplier / 1000
totalCostFinal += cost
totalCostBase += r.UnitCost
if r.Multiplier > 1000 {
withCard++
} else {
withoutCard++
}
}
fmt.Println("========================================")
fmt.Printf("渠道 %d 盈亏分析\n", channelID)
fmt.Println("========================================")
fmt.Println()
fmt.Println("【成本统计】")
fmt.Printf(" 资产记录数: %d 条\n", len(rows))
fmt.Printf(" 无道具卡: %d 条\n", withoutCard)
fmt.Printf(" 有道具卡: %d 条(成本×倍数)\n", withCard)
fmt.Printf(" 基础成本: %d 分 = %.2f 元\n", totalCostBase, float64(totalCostBase)/100)
fmt.Printf(" 含卡成本: %d 分 = %.2f 元\n", totalCostFinal, float64(totalCostFinal)/100)
if totalCostBase > 0 {
fmt.Printf(" 道具卡加成: +%.2f 元 (%.1f%%)\n",
float64(totalCostFinal-totalCostBase)/100,
float64(totalCostFinal-totalCostBase)/float64(totalCostBase)*100)
}
fmt.Println()
// 2. 收入统计(已有的 price_draw × count
// 简化:直接用 SQL 统计 actual_amount 作为对比参考
type amountResult struct {
TotalCents int64
}
var ar amountResult
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)"
db.Table("orders").
Joins("JOIN users ON users.id = orders.user_id").
Select("COALESCE(SUM(orders.actual_amount), 0) as total_cents").
Where(orderFilter, channelID).
Scan(&ar)
fmt.Println("【收入参考 (actual_amount)】")
fmt.Printf(" 实付金额: %d 分 = %.2f 元\n", ar.TotalCents, float64(ar.TotalCents)/100)
fmt.Println()
// 3. 盈亏
profit := ar.TotalCents - totalCostFinal
fmt.Println("【盈亏】")
fmt.Printf(" 收入(实付): %.2f 元\n", float64(ar.TotalCents)/100)
fmt.Printf(" 成本(含卡): %.2f 元\n", float64(totalCostFinal)/100)
fmt.Println(strings.Repeat("-", 40))
fmt.Printf(" 盈亏: %.2f 元\n", float64(profit)/100)
if profit > 0 {
fmt.Printf(" 状态: 盈利 ✅\n")
} else if profit < 0 {
fmt.Printf(" 状态: 亏损 ❌\n")
} else {
fmt.Printf(" 状态: 持平\n")
}
fmt.Println()
// 4. 道具卡详情
type cardDetail struct {
CardName string
Multiplier int64
Count int64
}
var cards []cardDetail
db.Table("user_inventory").
Select(`
system_item_cards.name as card_name,
system_item_cards.reward_multiplier_x1000 as multiplier,
COUNT(*) as count
`).
Joins("JOIN users ON users.id = user_inventory.user_id").
Joins("LEFT JOIN orders ON orders.id = user_inventory.order_id").
Joins("LEFT JOIN user_item_cards ON user_item_cards.id = orders.item_card_id").
Joins("LEFT JOIN system_item_cards ON system_item_cards.id = user_item_cards.card_id").
Where("users.channel_id = ? AND users.deleted_at IS NULL", channelID).
Where("user_inventory.status IN ?", []int{1, 3}).
Where("COALESCE(user_inventory.remark, '') NOT LIKE ?", "%void%").
Where("(orders.status = 2 OR user_inventory.order_id = 0 OR user_inventory.order_id IS NULL)").
Where("(orders.source_type IN (1,2,3,4) OR user_inventory.order_id = 0 OR user_inventory.order_id IS NULL)").
Where("system_item_cards.id IS NOT NULL").
Group("system_item_cards.id").
Scan(&cards)
if len(cards) > 0 {
fmt.Println("【道具卡使用详情】")
fmt.Printf("%-20s %-10s %-10s\n", "卡名", "倍数", "次数")
fmt.Println(strings.Repeat("-", 40))
for _, c := range cards {
fmt.Printf("%-20s ×%.1f %-10d\n", c.CardName, float64(c.Multiplier)/1000, c.Count)
}
} else {
fmt.Println("【道具卡使用详情】无道具卡使用记录")
}
}

View File

@ -12,6 +12,7 @@ type OrderRemark struct {
IssueID int64 IssueID int64
Count int64 Count int64
ItemCardID int64 ItemCardID int64
PkgID int64 // 一番赏游戏包 ID (game_pass_packages.id)
Slots []SlotInfo Slots []SlotInfo
Coupons []CouponInfo Coupons []CouponInfo
} }
@ -60,6 +61,8 @@ func Parse(remark string) *OrderRemark {
} else if strings.HasPrefix(p, "slots:") { } else if strings.HasPrefix(p, "slots:") {
// 处理多个 slots:X:Y,X:Y // 处理多个 slots:X:Y,X:Y
r.Slots = append(r.Slots, parseSlots(p[6:])...) r.Slots = append(r.Slots, parseSlots(p[6:])...)
} else if strings.HasPrefix(p, "pkg_id:") {
r.PkgID = parseInt64(p[7:])
} else if strings.HasPrefix(p, "c:") { } else if strings.HasPrefix(p, "c:") {
// 处理优惠券 c:ID:AMT // 处理优惠券 c:ID:AMT
r.Coupons = append(r.Coupons, parseCoupon(p[2:])) r.Coupons = append(r.Coupons, parseCoupon(p[2:]))

View File

@ -0,0 +1,171 @@
package remark
import (
"testing"
)
func TestParse_Lottery(t *testing.T) {
// 抽奖订单: lottery:activity:82|issue:89|count:1|slots:15:1
rm := Parse("lottery:activity:82|issue:89|count:1|slots:15:1")
if rm.ActivityID != 82 {
t.Errorf("ActivityID = %d, want 82", rm.ActivityID)
}
if rm.IssueID != 89 {
t.Errorf("IssueID = %d, want 89", rm.IssueID)
}
if rm.Count != 1 {
t.Errorf("Count = %d, want 1", rm.Count)
}
if rm.PkgID != 0 {
t.Errorf("PkgID = %d, want 0", rm.PkgID)
}
if len(rm.Slots) != 1 || rm.Slots[0].SlotIndex != 15 {
t.Errorf("Slots = %+v, want [{SlotIndex:15 Count:1}]", rm.Slots)
}
}
func TestParse_MatchingGamePaid(t *testing.T) {
// 对对碰付费: matching_game:issue:104|coupon:267|c:267:500
rm := Parse("matching_game:issue:104|coupon:267|c:267:500")
if rm.ActivityID != 0 {
t.Errorf("ActivityID = %d, want 0 (matching game paid has no activity prefix)", rm.ActivityID)
}
if rm.IssueID != 104 {
t.Errorf("IssueID = %d, want 104", rm.IssueID)
}
if rm.PkgID != 0 {
t.Errorf("PkgID = %d, want 0", rm.PkgID)
}
if rm.Count != 1 {
t.Errorf("Count = %d, want 1 (default)", rm.Count)
}
if len(rm.Coupons) != 1 || rm.Coupons[0].UserCouponID != 267 || rm.Coupons[0].AppliedAmount != 500 {
t.Errorf("Coupons = %+v, want [{UserCouponID:267 AppliedAmount:500}]", rm.Coupons)
}
}
func TestParse_MatchingGameFree(t *testing.T) {
// 对对碰免费(game_pass): activity:50|game_pass:12|matching_game:issue:96
rm := Parse("activity:50|game_pass:12|matching_game:issue:96")
if rm.ActivityID != 50 {
t.Errorf("ActivityID = %d, want 50", rm.ActivityID)
}
if rm.IssueID != 96 {
t.Errorf("IssueID = %d, want 96", rm.IssueID)
}
if rm.PkgID != 0 {
t.Errorf("PkgID = %d, want 0", rm.PkgID)
}
}
func TestParse_Ichiban(t *testing.T) {
// 一番赏: game_pass_package:梦的起点!|pkg_id:11|count:4
rm := Parse("game_pass_package:梦的起点!|pkg_id:11|count:4")
if rm.PkgID != 11 {
t.Errorf("PkgID = %d, want 11", rm.PkgID)
}
if rm.Count != 4 {
t.Errorf("Count = %d, want 4", rm.Count)
}
if rm.ActivityID != 0 {
t.Errorf("ActivityID = %d, want 0", rm.ActivityID)
}
if rm.IssueID != 0 {
t.Errorf("IssueID = %d, want 0", rm.IssueID)
}
}
func TestParse_IchibanSingle(t *testing.T) {
// 一番赏单包: game_pass_package:幸运一番|pkg_id:5|count:1
rm := Parse("game_pass_package:幸运一番|pkg_id:5|count:1")
if rm.PkgID != 5 {
t.Errorf("PkgID = %d, want 5", rm.PkgID)
}
if rm.Count != 1 {
t.Errorf("Count = %d, want 1", rm.Count)
}
}
func TestParse_Empty(t *testing.T) {
rm := Parse("")
if rm.ActivityID != 0 || rm.IssueID != 0 || rm.PkgID != 0 || rm.Count != 1 {
t.Errorf("empty remark should have zero IDs and count=1, got %+v", rm)
}
}
func TestParse_ActivityOnly(t *testing.T) {
// activity: 前缀(直购)
rm := Parse("activity:30|issue:45|count:2")
if rm.ActivityID != 30 {
t.Errorf("ActivityID = %d, want 30", rm.ActivityID)
}
if rm.IssueID != 45 {
t.Errorf("IssueID = %d, want 45", rm.IssueID)
}
if rm.Count != 2 {
t.Errorf("Count = %d, want 2", rm.Count)
}
}
func TestParse_ClassificationPriority(t *testing.T) {
// 验证三路分类逻辑:
// Case 1: ActivityID > 0 → 抽奖/直购
// Case 2: ActivityID==0 && IssueID > 0 → 对对碰
// Case 3: ActivityID==0 && IssueID==0 && PkgID > 0 → 一番赏
tests := []struct {
name string
remark string
wantCase string
activityID int64
issueID int64
pkgID int64
}{
{
name: "lottery → case1",
remark: "lottery:activity:10|issue:20|count:1",
wantCase: "case1",
activityID: 10, issueID: 20, pkgID: 0,
},
{
name: "matching_game paid → case2",
remark: "matching_game:issue:50",
wantCase: "case2",
activityID: 0, issueID: 50, pkgID: 0,
},
{
name: "ichiban → case3",
remark: "game_pass_package:test|pkg_id:7|count:2",
wantCase: "case3",
activityID: 0, issueID: 0, pkgID: 7,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
rm := Parse(tt.remark)
if rm.ActivityID != tt.activityID {
t.Errorf("ActivityID = %d, want %d", rm.ActivityID, tt.activityID)
}
if rm.IssueID != tt.issueID {
t.Errorf("IssueID = %d, want %d", rm.IssueID, tt.issueID)
}
if rm.PkgID != tt.pkgID {
t.Errorf("PkgID = %d, want %d", rm.PkgID, tt.pkgID)
}
// 验证分类
var gotCase string
if rm.ActivityID > 0 {
gotCase = "case1"
} else if rm.IssueID > 0 {
gotCase = "case2"
} else if rm.PkgID > 0 {
gotCase = "case3"
}
if gotCase != tt.wantCase {
t.Errorf("classification = %s, want %s", gotCase, tt.wantCase)
}
})
}
}

View File

@ -8,6 +8,7 @@ import (
"time" "time"
"bindbox-game/internal/pkg/logger" "bindbox-game/internal/pkg/logger"
"bindbox-game/internal/pkg/util/remark"
"bindbox-game/internal/repository/mysql" "bindbox-game/internal/repository/mysql"
"bindbox-game/internal/repository/mysql/dao" "bindbox-game/internal/repository/mysql/dao"
"bindbox-game/internal/repository/mysql/model" "bindbox-game/internal/repository/mysql/model"
@ -69,18 +70,24 @@ type StatsOutput struct {
} }
type StatsOverview struct { type StatsOverview struct {
TotalUsers int64 `json:"total_users"` TotalUsers int64 `json:"total_users"`
TotalOrders int64 `json:"total_orders"` TotalOrders int64 `json:"total_orders"`
TotalGMV int64 `json:"total_gmv"` TotalGMV int64 `json:"total_gmv"`
TotalPaidCents int64 `json:"total_paid_cents"` 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 { type StatsDailyItem struct {
Date string `json:"date"` Date string `json:"date"`
UserCount int64 `json:"user_count"` UserCount int64 `json:"user_count"`
OrderCount int64 `json:"order_count"` OrderCount int64 `json:"order_count"`
GMV int64 `json:"gmv"` GMV int64 `json:"gmv"`
PaidCents int64 `json:"paid_cents"` PaidCents int64 `json:"paid_cents"`
CostCents int64 `json:"cost_cents"` // 当日成本(分)
ProfitCents int64 `json:"profit_cents"` // 当日盈亏(分)
} }
type SearchUsersInput struct { type SearchUsersInput struct {
@ -122,6 +129,213 @@ var (
ErrSearchKeywordEmpty = errors.New("search_keyword_empty") ErrSearchKeywordEmpty = errors.New("search_keyword_empty")
) )
type orderRemarkRow struct {
Remark string
CreatedAt time.Time
}
// calcPaidByPriceDraw 解析订单 remark按游戏类型分三路计算实付金额
// - Case 1 (抽奖/直购): ActivityID > 0 → activities.price_draw × count
// - Case 2 (对对碰): IssueID > 0 → activity_issues → activities.price_draw × count
// - Case 3 (一番赏): PkgID > 0 → game_pass_packages.price × count
//
// 返回:总金额(分)、按 dateFmt 格式分组的金额。
func (s *service) calcPaidByPriceDraw(ctx context.Context, rows []orderRemarkRow, dateFmt string) (int64, map[string]int64) {
if len(rows) == 0 {
return 0, nil
}
type parsedActivity struct {
activityID int64
count int64
dateKey string
}
type parsedIssue struct {
issueID int64
count int64
dateKey string
}
type parsedPkg struct {
pkgID int64
count int64
dateKey string
}
var actItems []parsedActivity
var issueItems []parsedIssue
var pkgItems []parsedPkg
actIDSet := make(map[int64]struct{})
issueIDSet := make(map[int64]struct{})
pkgIDSet := make(map[int64]struct{})
for _, r := range rows {
rmk := remark.Parse(r.Remark)
dateKey := r.CreatedAt.Format(dateFmt)
if rmk.ActivityID > 0 {
// Case 1: 抽奖/直购 — 直接有 activityID
actItems = append(actItems, parsedActivity{rmk.ActivityID, rmk.Count, dateKey})
actIDSet[rmk.ActivityID] = struct{}{}
} else if rmk.IssueID > 0 {
// Case 2: 对对碰付费路径 — 只有 issueID需查 activity_issues
issueItems = append(issueItems, parsedIssue{rmk.IssueID, rmk.Count, dateKey})
issueIDSet[rmk.IssueID] = struct{}{}
} else if rmk.PkgID > 0 {
// Case 3: 一番赏 — 有 pkgID需查 game_pass_packages
pkgItems = append(pkgItems, parsedPkg{rmk.PkgID, rmk.Count, dateKey})
pkgIDSet[rmk.PkgID] = struct{}{}
}
}
// ── Case 2: 批量查 activity_issues → 拿到 activityID ──
issueActivityMap := make(map[int64]int64) // issueID → activityID
if len(issueIDSet) > 0 {
issueIDs := make([]int64, 0, len(issueIDSet))
for id := range issueIDSet {
issueIDs = append(issueIDs, id)
}
type issueRow struct {
ID int64
ActivityID int64
}
var issueRows []issueRow
s.readDB.ActivityIssues.WithContext(ctx).UnderlyingDB().
Table("activity_issues").
Select("id, activity_id").
Where("id IN ?", issueIDs).
Scan(&issueRows)
for _, ir := range issueRows {
issueActivityMap[ir.ID] = ir.ActivityID
actIDSet[ir.ActivityID] = struct{}{} // 合并到 actIDSet 一起查 price_draw
}
}
// ── Case 1+2: 批量查 activities.price_draw含软删除──
priceMap := make(map[int64]int64) // activityID → price_draw
if len(actIDSet) > 0 {
actIDs := make([]int64, 0, len(actIDSet))
for id := range actIDSet {
actIDs = append(actIDs, id)
}
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
}
}
// ── Case 3: 批量查 game_pass_packages.price ──
pkgPriceMap := make(map[int64]int64) // pkgID → price
if len(pkgIDSet) > 0 {
pkgIDs := make([]int64, 0, len(pkgIDSet))
for id := range pkgIDSet {
pkgIDs = append(pkgIDs, id)
}
type pkgRow struct {
ID int64
Price int64
}
var pkgRows []pkgRow
s.readDB.Activities.WithContext(ctx).UnderlyingDB().
Unscoped().
Table("game_pass_packages").
Select("id, price").
Where("id IN ?", pkgIDs).
Scan(&pkgRows)
for _, pr := range pkgRows {
pkgPriceMap[pr.ID] = pr.Price
}
}
// ── 累加金额 ──
var total int64
byDate := make(map[string]int64)
// Case 1: 抽奖/直购
for _, item := range actItems {
if price, ok := priceMap[item.activityID]; ok {
amt := price * item.count
total += amt
byDate[item.dateKey] += amt
}
}
// Case 2: 对对碰
for _, item := range issueItems {
if actID, ok := issueActivityMap[item.issueID]; ok {
if price, ok := priceMap[actID]; ok {
amt := price * item.count
total += amt
byDate[item.dateKey] += amt
}
}
}
// Case 3: 一番赏
for _, item := range pkgItems {
if price, ok := pkgPriceMap[item.pkgID]; ok {
amt := price * item.count
total += amt
byDate[item.dateKey] += amt
}
}
return total, byDate
}
// calcCostByInventory 计算渠道用户获得奖品的成本(含道具卡倍数)。
// 成本 = SUM(奖品价值 × 道具卡倍数)
// 奖品价值优先级: user_inventory.value_cents → activity_reward_settings.price_snapshot_cents → products.price
// 道具卡倍数: system_item_cards.reward_multiplier_x1000 / 1000无卡时 ×1.0
func (s *service) calcCostByInventory(ctx context.Context, channelID int64, dateFmt string, startDate, endDate *time.Time) (int64, map[string]int64) {
type costRow struct {
UnitCost int64
Multiplier int64
CreatedAt time.Time
}
q := s.readDB.UserInventory.WithContext(ctx).UnderlyingDB().
Table("user_inventory").
Select(`
COALESCE(NULLIF(user_inventory.value_cents, 0), activity_reward_settings.price_snapshot_cents, products.price, 0) AS unit_cost,
GREATEST(COALESCE(system_item_cards.reward_multiplier_x1000, 1000), 1000) AS multiplier,
user_inventory.created_at
`).
Joins("JOIN users ON users.id = user_inventory.user_id").
Joins("LEFT JOIN orders ON orders.id = user_inventory.order_id").
Joins("LEFT JOIN activity_reward_settings ON activity_reward_settings.id = user_inventory.reward_id").
Joins("LEFT JOIN products ON products.id = user_inventory.product_id").
Joins("LEFT JOIN user_item_cards ON user_item_cards.id = orders.item_card_id").
Joins("LEFT JOIN system_item_cards ON system_item_cards.id = user_item_cards.card_id").
Where("users.channel_id = ? AND users.deleted_at IS NULL", channelID).
Where("user_inventory.status IN ?", []int{1, 3}).
Where("COALESCE(user_inventory.remark, '') NOT LIKE ?", "%void%").
Where("(orders.status = 2 OR user_inventory.order_id = 0 OR user_inventory.order_id IS NULL)").
Where("(orders.source_type IN (1,2,3,4) OR user_inventory.order_id = 0 OR user_inventory.order_id IS NULL)")
if startDate != nil && endDate != nil {
q = q.Where("user_inventory.created_at >= ? AND user_inventory.created_at <= ?", *startDate, *endDate)
}
var rows []costRow
q.Scan(&rows)
var total int64
byDate := make(map[string]int64)
for _, r := range rows {
cost := r.UnitCost * r.Multiplier / 1000
total += cost
byDate[r.CreatedAt.Format(dateFmt)] += cost
}
return total, byDate
}
func (s *service) Create(ctx context.Context, in CreateInput) (*model.Channels, error) { func (s *service) Create(ctx context.Context, in CreateInput) (*model.Channels, error) {
m := &model.Channels{Name: in.Name, Code: in.Code, Type: in.Type, Remarks: in.Remarks} m := &model.Channels{Name: in.Name, Code: in.Code, Type: in.Type, Remarks: in.Remarks}
if err := s.writeDB.Channels.WithContext(ctx).Create(m); err != nil { if err := s.writeDB.Channels.WithContext(ctx).Create(m); err != nil {
@ -206,19 +420,26 @@ func (s *service) List(ctx context.Context, in ListInput) (items []*ChannelWithS
type PaidResult struct { type PaidResult struct {
ChannelID int64 ChannelID int64
Paid int64 Remark string
CreatedAt time.Time
} }
var paidResults []PaidResult var paidResults []PaidResult
err = s.readDB.Orders.WithContext(ctx).UnderlyingDB().Table("orders"). err = s.readDB.Orders.WithContext(ctx).UnderlyingDB().Table("orders").
Joins("JOIN users ON users.id = orders.user_id"). Joins("JOIN users ON users.id = orders.user_id").
Select("users.channel_id as channel_id, coalesce(sum(orders.actual_amount),0) as paid"). Select("users.channel_id, orders.remark, orders.created_at").
Where("users.channel_id IN ?", channelIDs). Where("users.channel_id IN ?", channelIDs).
Where("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)"). Where("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)").
Group("users.channel_id").
Scan(&paidResults).Error Scan(&paidResults).Error
if err == nil { if err == nil {
grouped := make(map[int64][]orderRemarkRow)
for _, r := range paidResults { for _, r := range paidResults {
paidStats[r.ChannelID] = r.Paid 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
} }
} }
} }
@ -235,10 +456,8 @@ func (s *service) List(ctx context.Context, in ListInput) (items []*ChannelWithS
return return
} }
func (s *service) GetStats(ctx context.Context, channelID int64, months int, startDateStr, endDateStr string) (*StatsOutput, error) { func (s *service) GetStats(ctx context.Context, channelID int64, days int, startDateStr, endDateStr string) (*StatsOutput, error) {
now := time.Now() now := time.Now()
var startDate, endDate time.Time
var startBucket, endBucket time.Time
_, err := s.readDB.Channels.WithContext(ctx).Where(s.readDB.Channels.ID.Eq(channelID)).First() _, err := s.readDB.Channels.WithContext(ctx).Where(s.readDB.Channels.ID.Eq(channelID)).First()
if err != nil { if err != nil {
@ -248,102 +467,113 @@ func (s *service) GetStats(ctx context.Context, channelID int64, months int, sta
return nil, err return nil, err
} }
// 如果指定了日期范围,使用自定义日期
if startDateStr != "" && endDateStr != "" {
var err error
startDate, err = time.Parse("2006-01-02", startDateStr)
if err != nil {
return nil, err
}
endDate, err = time.Parse("2006-01-02", endDateStr)
if err != nil {
return nil, err
}
// 确保 endDate 是当天结束
endDate = endDate.Add(24*time.Hour - time.Second)
startBucket = time.Date(startDate.Year(), startDate.Month(), 1, 0, 0, 0, 0, startDate.Location())
endBucket = time.Date(endDate.Year(), endDate.Month(), 1, 0, 0, 0, 0, endDate.Location())
months = (endBucket.Year()-startBucket.Year())*12 + int(endBucket.Month()-startBucket.Month()) + 1
if months <= 0 {
months = 1
}
} else {
// 默认按月份计算
if months <= 0 {
months = 12
}
startMonth := now.AddDate(0, -months+1, 0)
startDate = time.Date(startMonth.Year(), startMonth.Month(), 1, 0, 0, 0, 0, startMonth.Location())
endDate = now
startBucket = startDate
}
out := &StatsOutput{} out := &StatsOutput{}
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)"
// ========== 1. Overview全量不限时间==========
// 1. Overview
// Users
userCount, _ := s.readDB.Users.WithContext(ctx).Where(s.readDB.Users.ChannelID.Eq(channelID)).Count() userCount, _ := s.readDB.Users.WithContext(ctx).Where(s.readDB.Users.ChannelID.Eq(channelID)).Count()
out.Overview.TotalUsers = userCount out.Overview.TotalUsers = userCount
// Orders & GMV type countResult struct{ Count int64 }
type OrderStat struct { var cr countResult
Count int64
GMV int64
}
var os OrderStat
s.readDB.Orders.WithContext(ctx).UnderlyingDB().Table("orders"). s.readDB.Orders.WithContext(ctx).UnderlyingDB().Table("orders").
Joins("JOIN users ON users.id = orders.user_id"). Joins("JOIN users ON users.id = orders.user_id").
Select("count(*) as count, coalesce(sum(actual_amount),0) as gmv"). Select("count(*) as count").
Where("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)", channelID). Where(orderFilter, channelID).
Scan(&os) Scan(&cr)
out.Overview.TotalOrders = os.Count out.Overview.TotalOrders = cr.Count
out.Overview.TotalGMV = os.GMV / 100
out.Overview.TotalPaidCents = os.GMV
// 2. Monthly Stats var allRemarks []orderRemarkRow
dateMap := make(map[string]*StatsDailyItem) s.readDB.Orders.WithContext(ctx).UnderlyingDB().Table("orders").
var dateList []string Joins("JOIN users ON users.id = orders.user_id").
for i := 0; i < months; i++ { Select("orders.remark, orders.created_at").
d := startBucket.AddDate(0, i, 0).Format("2006-01") Where(orderFilter, channelID).
dateList = append(dateList, d) Scan(&allRemarks)
dateMap[d] = &StatsDailyItem{Date: d}
totalPaid, _ := s.calcPaidByPriceDraw(ctx, allRemarks, "2006-01-02")
out.Overview.TotalPaidCents = totalPaid
out.Overview.TotalGMV = totalPaid / 100
// 1d. 累计成本(全量,含道具卡倍数)
totalCost, _ := s.calcCostByInventory(ctx, channelID, "2006-01-02", nil, nil)
out.Overview.TotalCostCents = totalCost
out.Overview.TotalCost = totalCost / 100
out.Overview.TotalProfitCents = totalPaid - totalCost
out.Overview.TotalProfit = out.Overview.TotalProfitCents / 100
// ========== 2. 趋势图(按天分组,受 days 限制)==========
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
} }
// Monthly Users dateMap := make(map[string]*StatsDailyItem)
type MonthlyUser struct { 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}
}
type dailyCount struct {
Date string Date string
Count int64 Count int64
} }
var monthlyUsers []MonthlyUser
s.readDB.Users.WithContext(ctx).UnderlyingDB().Table("users").
Select("DATE_FORMAT(created_at, '%Y-%m') 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(&monthlyUsers)
for _, u := range monthlyUsers { 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 { if item, ok := dateMap[u.Date]; ok {
item.UserCount = u.Count item.UserCount = u.Count
} }
} }
// Monthly Orders var dailyOrders []dailyCount
type MonthlyOrder struct {
Date string
Count int64
GMV int64
}
var monthlyOrders []MonthlyOrder
s.readDB.Orders.WithContext(ctx).UnderlyingDB().Table("orders"). s.readDB.Orders.WithContext(ctx).UnderlyingDB().Table("orders").
Joins("JOIN users ON users.id = orders.user_id"). Joins("JOIN users ON users.id = orders.user_id").
Select("DATE_FORMAT(orders.created_at, '%Y-%m') as date, count(*) as count, coalesce(sum(actual_amount),0) as gmv"). Select("DATE_FORMAT(orders.created_at, '%Y-%m-%d') as date, count(*) as count").
Where("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) AND orders.created_at >= ? AND orders.created_at <= ?", channelID, startDate, endDate). Where(orderFilter+" AND orders.created_at >= ? AND orders.created_at <= ?", channelID, startDate, endDate).
Group("date").Scan(&monthlyOrders) Group("date").Scan(&dailyOrders)
for _, o := range dailyOrders {
for _, o := range monthlyOrders {
if item, ok := dateMap[o.Date]; ok { if item, ok := dateMap[o.Date]; ok {
item.OrderCount = o.Count item.OrderCount = o.Count
item.GMV = o.GMV / 100 }
item.PaidCents = o.GMV }
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. 每日成本(含道具卡倍数)
_, 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
} }
} }

@ -1 +1 @@
Subproject commit 7b8a8766c6b607976b3856cd3775a795873f085d Subproject commit 212aacb5f8dac05b214a4c19eed4395ed7922943