diff --git a/cmd/channel_stats_compare/profit_loss.go b/cmd/channel_stats_compare/profit_loss.go new file mode 100644 index 0000000..ee306ce --- /dev/null +++ b/cmd/channel_stats_compare/profit_loss.go @@ -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("【道具卡使用详情】无道具卡使用记录") + } +} diff --git a/internal/pkg/util/remark/remark.go b/internal/pkg/util/remark/remark.go index 049899f..e37132a 100755 --- a/internal/pkg/util/remark/remark.go +++ b/internal/pkg/util/remark/remark.go @@ -12,6 +12,7 @@ type OrderRemark struct { IssueID int64 Count int64 ItemCardID int64 + PkgID int64 // 一番赏游戏包 ID (game_pass_packages.id) Slots []SlotInfo Coupons []CouponInfo } @@ -60,6 +61,8 @@ func Parse(remark string) *OrderRemark { } else if strings.HasPrefix(p, "slots:") { // 处理多个 slots:X:Y,X:Y 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:") { // 处理优惠券 c:ID:AMT r.Coupons = append(r.Coupons, parseCoupon(p[2:])) diff --git a/internal/pkg/util/remark/remark_test.go b/internal/pkg/util/remark/remark_test.go new file mode 100644 index 0000000..53fc5fe --- /dev/null +++ b/internal/pkg/util/remark/remark_test.go @@ -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) + } + }) + } +} diff --git a/internal/service/channel/channel.go b/internal/service/channel/channel.go index 1f2f30c..a271ffb 100755 --- a/internal/service/channel/channel.go +++ b/internal/service/channel/channel.go @@ -8,6 +8,7 @@ import ( "time" "bindbox-game/internal/pkg/logger" + "bindbox-game/internal/pkg/util/remark" "bindbox-game/internal/repository/mysql" "bindbox-game/internal/repository/mysql/dao" "bindbox-game/internal/repository/mysql/model" @@ -69,18 +70,24 @@ type StatsOutput struct { } type StatsOverview struct { - TotalUsers int64 `json:"total_users"` - TotalOrders int64 `json:"total_orders"` - TotalGMV int64 `json:"total_gmv"` - TotalPaidCents int64 `json:"total_paid_cents"` + 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"` + 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"` // 当日盈亏(分) } type SearchUsersInput struct { @@ -122,6 +129,213 @@ var ( 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) { 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 { @@ -206,19 +420,26 @@ func (s *service) List(ctx context.Context, in ListInput) (items []*ChannelWithS type PaidResult struct { ChannelID int64 - Paid int64 + Remark string + CreatedAt time.Time } var paidResults []PaidResult err = s.readDB.Orders.WithContext(ctx).UnderlyingDB().Table("orders"). 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.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 if err == nil { + grouped := make(map[int64][]orderRemarkRow) 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 } -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() - var startDate, endDate time.Time - var startBucket, endBucket time.Time _, err := s.readDB.Channels.WithContext(ctx).Where(s.readDB.Channels.ID.Eq(channelID)).First() if err != nil { @@ -248,102 +467,113 @@ func (s *service) GetStats(ctx context.Context, channelID int64, months int, sta 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{} + 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() out.Overview.TotalUsers = userCount - // Orders & GMV - type OrderStat struct { - Count int64 - GMV int64 - } - var os OrderStat + 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, coalesce(sum(actual_amount),0) as gmv"). - 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). - Scan(&os) - out.Overview.TotalOrders = os.Count - out.Overview.TotalGMV = os.GMV / 100 - out.Overview.TotalPaidCents = os.GMV + Select("count(*) as count"). + Where(orderFilter, channelID). + Scan(&cr) + out.Overview.TotalOrders = cr.Count - // 2. Monthly Stats - dateMap := make(map[string]*StatsDailyItem) - var dateList []string - for i := 0; i < months; i++ { - d := startBucket.AddDate(0, i, 0).Format("2006-01") - dateList = append(dateList, d) - dateMap[d] = &StatsDailyItem{Date: d} + 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 + + // 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 - type MonthlyUser struct { + 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} + } + + type dailyCount struct { Date string 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 { item.UserCount = u.Count } } - // Monthly Orders - type MonthlyOrder struct { - Date string - Count int64 - GMV int64 - } - var monthlyOrders []MonthlyOrder + 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') as date, count(*) as count, coalesce(sum(actual_amount),0) as gmv"). - 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). - Group("date").Scan(&monthlyOrders) - - for _, o := range monthlyOrders { + 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 - 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 } } diff --git a/web/admin b/web/admin index 7b8a876..212aacb 160000 --- a/web/admin +++ b/web/admin @@ -1 +1 @@ -Subproject commit 7b8a8766c6b607976b3856cd3775a795873f085d +Subproject commit 212aacb5f8dac05b214a4c19eed4395ed7922943