535 lines
14 KiB
Go
Executable File
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
|
|
}
|