package channel import ( "context" "errors" "strconv" "strings" "time" "bindbox-game/internal/pkg/logger" "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"` } 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"` } 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") ) 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 Paid int64 } 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"). 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 { for _, r := range paidResults { paidStats[r.ChannelID] = r.Paid } } } 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, months 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 { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, ErrChannelNotFound } 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{} // 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 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 // 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} } // Monthly Users type MonthlyUser 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 { 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 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 { if item, ok := dateMap[o.Date]; ok { item.OrderCount = o.Count item.GMV = o.GMV / 100 item.PaidCents = o.GMV } } 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 }