win 91dd42ca1c feat(channel): 渠道统计新增盈亏计算并修复成本口径
后端:
- StatsOverview/StatsDailyItem 新增 cost/profit 字段
- 新增 calcPaidByPriceDraw 三路收入分类(抽奖/对对碰/一番赏)
- 新增 calcCostByInventory 成本计算(含道具卡倍数)
- 修复成本统计未过滤 source_type 导致直播间免费发奖资产被错误计入
- remark.go 新增 PkgID 解析支持一番赏订单

前端:
- 渠道统计弹窗新增"总成本"和"盈亏"卡片
- 趋势图新增"盈亏分析"Tab

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-11 02:29:19 +08:00

765 lines
22 KiB
Go
Executable File
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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
}