后端: - StatsOverview/StatsDailyItem 新增 cost/profit 字段 - 新增 calcPaidByPriceDraw 三路收入分类(抽奖/对对碰/一番赏) - 新增 calcCostByInventory 成本计算(含道具卡倍数) - 修复成本统计未过滤 source_type 导致直播间免费发奖资产被错误计入 - remark.go 新增 PkgID 解析支持一番赏订单 前端: - 渠道统计弹窗新增"总成本"和"盈亏"卡片 - 趋势图新增"盈亏分析"Tab Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
765 lines
22 KiB
Go
Executable File
765 lines
22 KiB
Go
Executable File
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
|
||
}
|