package channel import ( "context" "errors" "strconv" "strings" "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" "gorm.io/gen/field" "gorm.io/gorm" ) type Service interface { Create(ctx context.Context, in CreateInput) (*model.Channels, error) Modify(ctx context.Context, id int64, in ModifyInput) error Delete(ctx context.Context, id int64) error List(ctx context.Context, in ListInput) (items []*ChannelWithStat, total int64, err error) GetStats(ctx context.Context, channelID int64, days int, startDate, endDate string) (*StatsOutput, error) GetByID(ctx context.Context, id int64) (*model.Channels, error) SearchUsers(ctx context.Context, in SearchUsersInput) (items []*ChannelUserItem, total int64, err error) BindUsers(ctx context.Context, channelID int64, userIDs []int64) (*BindUsersOutput, error) } type service struct { logger logger.CustomLogger readDB *dao.Query writeDB *dao.Query } func New(l logger.CustomLogger, db mysql.Repo) Service { return &service{logger: l, readDB: dao.Use(db.GetDbR()), writeDB: dao.Use(db.GetDbW())} } type CreateInput struct { Name string Code string Type string Remarks string } type ModifyInput struct { Name *string Type *string Remarks *string } type ListInput struct { Name string Page int PageSize int } type ChannelWithStat struct { *model.Channels UserCount int64 `json:"user_count"` PaidAmountCents int64 `json:"paid_amount_cents"` PaidAmount int64 `json:"paid_amount"` } type StatsOutput struct { Overview StatsOverview `json:"overview"` Daily []StatsDailyItem `json:"daily"` } type StatsOverview struct { 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"` CostCents int64 `json:"cost_cents"` // 当日成本(分) ProfitCents int64 `json:"profit_cents"` // 当日盈亏(分) } type SearchUsersInput struct { Keyword string ChannelID int64 Page int PageSize int } type ChannelUserItem struct { ID int64 `json:"id"` Nickname string `json:"nickname"` Mobile string `json:"mobile"` Avatar string `json:"avatar"` ChannelID int64 `json:"channel_id"` ChannelName string `json:"channel_name"` ChannelCode string `json:"channel_code"` } type BindUsersOutput struct { SuccessCount int `json:"success_count"` FailedCount int `json:"failed_count"` SkippedCount int `json:"skipped_count"` Details []BindUserDetail `json:"details"` } type BindUserDetail struct { UserID int64 `json:"user_id"` Status string `json:"status"` // success | failed | skipped Message string `json:"message,omitempty"` OldChannelID int64 `json:"old_channel_id"` NewChannelID int64 `json:"new_channel_id"` } var ( ErrChannelNotFound = errors.New("channel_not_found") ErrBindUsersEmpty = errors.New("bind_users_empty") ErrBindUsersTooMany = errors.New("bind_users_too_many") 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 { return nil, err } return m, nil } func (s *service) Modify(ctx context.Context, id int64, in ModifyInput) error { updater := s.writeDB.Channels.WithContext(ctx).Where(s.writeDB.Channels.ID.Eq(id)) set := map[string]any{} if in.Name != nil { set["name"] = *in.Name } if in.Type != nil { set["type"] = *in.Type } if in.Remarks != nil { set["remarks"] = *in.Remarks } if len(set) == 0 { return nil } _, err := updater.Updates(set) return err } func (s *service) Delete(ctx context.Context, id int64) error { _, err := s.writeDB.Channels.WithContext(ctx).Where(s.writeDB.Channels.ID.Eq(id)).Delete() return err } func (s *service) List(ctx context.Context, in ListInput) (items []*ChannelWithStat, total int64, err error) { if in.Page <= 0 { in.Page = 1 } if in.PageSize <= 0 { in.PageSize = 20 } q := s.readDB.Channels.WithContext(ctx) if in.Name != "" { like := "%" + in.Name + "%" q = q.Where(s.readDB.Channels.Name.Like(like)).Or(s.readDB.Channels.Code.Like(like)) } total, err = q.Count() if err != nil { return } // List channels channels, err := q.Order(s.readDB.Channels.ID.Desc()).Limit(in.PageSize).Offset((in.Page - 1) * in.PageSize).Find() if err != nil { return } // Get user counts var channelIDs []int64 for _, c := range channels { channelIDs = append(channelIDs, c.ID) } stats := make(map[int64]int64) paidStats := make(map[int64]int64) if len(channelIDs) > 0 { type Result struct { ChannelID int64 Count int64 } var results []Result // Using raw query for grouping err = s.readDB.Users.WithContext(ctx).UnderlyingDB().Table("users"). Select("channel_id, count(*) as count"). Where("channel_id IN ?", channelIDs). Group("channel_id"). Scan(&results).Error if err == nil { for _, r := range results { stats[r.ChannelID] = r.Count } } type PaidResult struct { ChannelID 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, 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)"). Scan(&paidResults).Error if err == nil { grouped := make(map[int64][]orderRemarkRow) for _, r := range paidResults { 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 } } } for _, c := range channels { paidAmountCents := paidStats[c.ID] items = append(items, &ChannelWithStat{ Channels: c, UserCount: stats[c.ID], PaidAmountCents: paidAmountCents, PaidAmount: paidAmountCents / 100, }) } return } 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{} 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(全量,不限时间)========== userCount, _ := s.readDB.Users.WithContext(ctx).Where(s.readDB.Users.ChannelID.Eq(channelID)).Count() out.Overview.TotalUsers = userCount 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 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 } 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 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 } } 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 } } 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 } } for _, d := range dateList { out.Daily = append(out.Daily, *dateMap[d]) } return out, nil } func (s *service) GetByID(ctx context.Context, id int64) (*model.Channels, error) { if id <= 0 { return nil, ErrChannelNotFound } ch, err := s.readDB.Channels.WithContext(ctx).Where(s.readDB.Channels.ID.Eq(id)).First() if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, ErrChannelNotFound } return nil, err } return ch, nil } func (s *service) SearchUsers(ctx context.Context, in SearchUsersInput) (items []*ChannelUserItem, total int64, err error) { keyword := strings.TrimSpace(in.Keyword) if keyword == "" && in.ChannelID <= 0 { return nil, 0, ErrSearchKeywordEmpty } if in.Page <= 0 { in.Page = 1 } if in.PageSize <= 0 { in.PageSize = 20 } if in.PageSize > 50 { in.PageSize = 50 } u := s.readDB.Users c := s.readDB.Channels q := s.readDB.Users.WithContext(ctx).ReadDB(). LeftJoin(c, c.ID.EqCol(u.ChannelID)). Select( u.ID, u.Nickname, u.Mobile, u.Avatar, u.ChannelID, c.Name.As("channel_name"), c.Code.As("channel_code"), ) if in.ChannelID > 0 { q = q.Where(u.ChannelID.Eq(in.ChannelID)) } if keyword != "" { like := "%" + keyword + "%" if id, parseErr := strconv.ParseInt(keyword, 10, 64); parseErr == nil { q = q.Where(field.Or(u.ID.Eq(id), u.Mobile.Like(like), u.Nickname.Like(like))) } else { q = q.Where(field.Or(u.Mobile.Like(like), u.Nickname.Like(like))) } } total, err = q.Count() if err != nil { return nil, 0, err } type row struct { ID int64 Nickname string Mobile string Avatar string ChannelID int64 ChannelName string ChannelCode string } var rows []row if err = q.Order(u.ID.Desc()).Offset((in.Page - 1) * in.PageSize).Limit(in.PageSize).Scan(&rows); err != nil { return nil, 0, err } items = make([]*ChannelUserItem, 0, len(rows)) for _, r := range rows { items = append(items, &ChannelUserItem{ ID: r.ID, Nickname: r.Nickname, Mobile: r.Mobile, Avatar: r.Avatar, ChannelID: r.ChannelID, ChannelName: r.ChannelName, ChannelCode: r.ChannelCode, }) } return items, total, nil } func (s *service) BindUsers(ctx context.Context, channelID int64, userIDs []int64) (*BindUsersOutput, error) { if len(userIDs) == 0 { return nil, ErrBindUsersEmpty } seen := make(map[int64]struct{}, len(userIDs)) deduped := make([]int64, 0, len(userIDs)) for _, uid := range userIDs { if uid <= 0 { continue } if _, ok := seen[uid]; ok { continue } seen[uid] = struct{}{} deduped = append(deduped, uid) } if len(deduped) == 0 { return nil, ErrBindUsersEmpty } if len(deduped) > 200 { return nil, ErrBindUsersTooMany } result := &BindUsersOutput{ Details: make([]BindUserDetail, 0, len(deduped)), } err := s.writeDB.Transaction(func(tx *dao.Query) error { _, chErr := tx.Channels.WithContext(ctx).Where(tx.Channels.ID.Eq(channelID)).First() if chErr != nil { if errors.Is(chErr, gorm.ErrRecordNotFound) { return ErrChannelNotFound } return chErr } users, findErr := tx.Users.WithContext(ctx).Where(tx.Users.ID.In(deduped...)).Find() if findErr != nil { return findErr } userMap := make(map[int64]*model.Users, len(users)) for _, u := range users { userMap[u.ID] = u } for _, uid := range deduped { detail := BindUserDetail{ UserID: uid, NewChannelID: channelID, } userRow, ok := userMap[uid] if !ok { detail.Status = "failed" detail.Message = "user_not_found" result.FailedCount++ result.Details = append(result.Details, detail) continue } detail.OldChannelID = userRow.ChannelID if userRow.ChannelID == channelID { detail.Status = "skipped" detail.Message = "already_in_channel" result.SkippedCount++ result.Details = append(result.Details, detail) continue } if _, updateErr := tx.Users.WithContext(ctx). Where(tx.Users.ID.Eq(uid)). Update(tx.Users.ChannelID, channelID); updateErr != nil { return updateErr } detail.Status = "success" result.SuccessCount++ result.Details = append(result.Details, detail) } return nil }) if err != nil { return nil, err } return result, nil }