2026-03-05 12:50:06 +08:00

535 lines
14 KiB
Go
Executable File

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
}