Replace channel cost aggregation with draw-source based cost calculation that follows activity profit-loss logic and keeps cost attribution on the original ordering user's channel. Update channel stats tests to cover the new cost path and related schema fields.
699 lines
22 KiB
Go
Executable File
699 lines
22 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"`
|
||
TotalCostCents int64 `json:"total_cost_cents"` // 总成本(分)
|
||
TotalProfitCents int64 `json:"total_profit_cents"` // 盈亏(分) = paid - cost
|
||
TotalCost int64 `json:"total_cost"` // 总成本(元)
|
||
TotalProfit int64 `json:"total_profit"` // 盈亏(元)
|
||
CashCents int64 `json:"cash_cents"` // 现金支付(分)
|
||
CouponCents int64 `json:"coupon_cents"` // 优惠券抵扣(分)
|
||
PointsCents int64 `json:"points_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"`
|
||
CostCents int64 `json:"cost_cents"` // 当日成本(分)
|
||
ProfitCents int64 `json:"profit_cents"` // 当日盈亏(分)
|
||
CashCents int64 `json:"cash_cents"` // 当日现金(分)
|
||
CouponCents int64 `json:"coupon_cents"` // 当日优惠券(分)
|
||
PointsCents int64 `json:"points_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 orderAmountRow struct {
|
||
ActualAmount int64
|
||
CreatedAt time.Time
|
||
}
|
||
|
||
// GMVBreakdown GMV 支付方式拆分
|
||
type GMVBreakdown struct {
|
||
Total int64 // total_amount 合计(分)
|
||
Cash int64 // actual_amount 现金(分)
|
||
Coupon int64 // discount_amount 优惠券(分)
|
||
Points int64 // points_amount 积分(分)
|
||
}
|
||
|
||
// calcGMVByTotalAmount 按订单原价(total_amount)统计渠道GMV,同时拆分支付方式。
|
||
// total_amount = actual_amount(现金) + discount_amount(优惠券) + points_amount(积分)
|
||
// 返回:总拆分、按 dateFmt 格式分组的拆分。
|
||
func (s *service) calcGMVByTotalAmount(ctx context.Context, channelID int64, dateFmt string, orderFilter string, startDate, endDate *time.Time) (GMVBreakdown, map[string]GMVBreakdown) {
|
||
type row struct {
|
||
TotalAmount int64
|
||
ActualAmount int64
|
||
DiscountAmount int64
|
||
PointsAmount int64
|
||
CreatedAt time.Time
|
||
}
|
||
q := s.readDB.Orders.WithContext(ctx).UnderlyingDB().Table("orders").
|
||
Joins("JOIN users ON users.id = orders.user_id").
|
||
Select("orders.total_amount, orders.actual_amount, orders.discount_amount, orders.points_amount, orders.created_at").
|
||
Where(orderFilter, channelID)
|
||
if startDate != nil && endDate != nil {
|
||
q = q.Where("orders.created_at >= ? AND orders.created_at <= ?", *startDate, *endDate)
|
||
}
|
||
var rows []row
|
||
q.Scan(&rows)
|
||
|
||
var total GMVBreakdown
|
||
byDate := make(map[string]GMVBreakdown)
|
||
for _, r := range rows {
|
||
total.Total += r.TotalAmount
|
||
total.Cash += r.ActualAmount
|
||
total.Coupon += r.DiscountAmount
|
||
total.Points += r.PointsAmount
|
||
key := r.CreatedAt.Format(dateFmt)
|
||
d := byDate[key]
|
||
d.Total += r.TotalAmount
|
||
d.Cash += r.ActualAmount
|
||
d.Coupon += r.DiscountAmount
|
||
d.Points += r.PointsAmount
|
||
byDate[key] = d
|
||
}
|
||
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,
|
||
CASE WHEN COALESCE(system_item_cards.reward_multiplier_x1000, 1000) < 1000 THEN 1000 ELSE COALESCE(system_item_cards.reward_multiplier_x1000, 1000) END 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 (2,3,4) OR user_inventory.order_id = 0 OR user_inventory.order_id IS NULL)").
|
||
Where("(orders.total_amount > 0 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
|
||
}
|
||
|
||
// calcCostByDrawSource 按订单/抽奖来源统计渠道奖品成本。
|
||
// 成本口径与活动盈亏保持一致:
|
||
// - 来源:activity_draw_logs + activity_reward_settings + products.cost_price
|
||
// - 数量:drop_quantity,默认 1
|
||
// - 倍数:命中特定道具卡翻倍规则时 +1 份
|
||
//
|
||
// 注意:这里按 orders.user_id -> users.channel_id 归因,而不是按当前 user_inventory.user_id。
|
||
// 这样 inventory 转赠后,成本仍归到原下单用户所属渠道。
|
||
func (s *service) calcCostByDrawSource(ctx context.Context, channelID int64, dateFmt string, startDate, endDate *time.Time) (int64, map[string]int64) {
|
||
type costRow struct {
|
||
CostCents int64
|
||
PaidAt time.Time
|
||
}
|
||
|
||
q := s.readDB.ActivityDrawLogs.WithContext(ctx).UnderlyingDB().
|
||
Table("activity_draw_logs").
|
||
Select(`
|
||
SUM(COALESCE(products.cost_price, 0) * (
|
||
COALESCE(NULLIF(activity_reward_settings.drop_quantity, 0), 1) +
|
||
CASE WHEN user_item_cards.used_draw_log_id = activity_draw_logs.id AND system_item_cards.effect_type = 1 AND system_item_cards.reward_multiplier_x1000 >= 2000 THEN 1 ELSE 0 END
|
||
)) AS cost_cents,
|
||
orders.paid_at
|
||
`).
|
||
Joins("JOIN orders ON orders.id = activity_draw_logs.order_id").
|
||
Joins("JOIN users ON users.id = orders.user_id").
|
||
Joins("LEFT JOIN activity_reward_settings ON activity_reward_settings.id = activity_draw_logs.reward_id").
|
||
Joins("LEFT JOIN products ON products.id = activity_reward_settings.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("orders.status = 2").
|
||
Where("orders.source_type IN ?", []int{2, 3, 4}).
|
||
Where("orders.ext_order_id = '' OR orders.ext_order_id IS NULL")
|
||
|
||
if startDate != nil && endDate != nil {
|
||
q = q.Where("orders.paid_at >= ? AND orders.paid_at <= ?", *startDate, *endDate)
|
||
}
|
||
|
||
var rows []costRow
|
||
q.Group("orders.id, orders.paid_at").Scan(&rows)
|
||
|
||
var total int64
|
||
byDate := make(map[string]int64)
|
||
for _, r := range rows {
|
||
total += r.CostCents
|
||
byDate[r.PaidAt.Format(dateFmt)] += r.CostCents
|
||
}
|
||
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 GMVResult struct {
|
||
ChannelID int64
|
||
TotalAmount int64
|
||
}
|
||
var gmvResults []GMVResult
|
||
err = s.readDB.Orders.WithContext(ctx).UnderlyingDB().Table("orders").
|
||
Joins("JOIN users ON users.id = orders.user_id").
|
||
Select("users.channel_id, orders.total_amount").
|
||
Where("users.channel_id IN ?", channelIDs).
|
||
Where("users.deleted_at IS NULL AND orders.status = 2 AND orders.total_amount > 0 AND orders.actual_amount > 0 AND orders.source_type IN (2,3,4) AND (orders.ext_order_id = '' OR orders.ext_order_id IS NULL)").
|
||
Scan(&gmvResults).Error
|
||
if err == nil {
|
||
for _, r := range gmvResults {
|
||
paidStats[r.ChannelID] += r.TotalAmount
|
||
}
|
||
}
|
||
}
|
||
|
||
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{}
|
||
// source_type: 2=小程序抽奖 3=对对碰 4=一番赏/次卡 5=直播间抽奖抖店(不计入);排除商城直购(1)
|
||
// actual_amount>0 排除次卡免费使用的订单(避免与购买次卡的订单重复计入GMV)
|
||
orderFilter := "users.channel_id = ? AND users.deleted_at IS NULL AND orders.status = 2 AND orders.total_amount > 0 AND orders.actual_amount > 0 AND orders.source_type IN (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
|
||
|
||
totalGMV, _ := s.calcGMVByTotalAmount(ctx, channelID, "2006-01-02", orderFilter, nil, nil)
|
||
out.Overview.TotalPaidCents = totalGMV.Total
|
||
out.Overview.TotalGMV = totalGMV.Total / 100
|
||
out.Overview.CashCents = totalGMV.Cash
|
||
out.Overview.CouponCents = totalGMV.Coupon
|
||
out.Overview.PointsCents = totalGMV.Points
|
||
|
||
// 1d. 累计成本(全量,按原始订单/抽奖来源归因)
|
||
totalCost, _ := s.calcCostByDrawSource(ctx, channelID, "2006-01-02", nil, nil)
|
||
out.Overview.TotalCostCents = totalCost
|
||
out.Overview.TotalCost = totalCost / 100
|
||
out.Overview.TotalProfitCents = totalGMV.Total - 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
|
||
}
|
||
}
|
||
|
||
_, dailyPaid := s.calcGMVByTotalAmount(ctx, channelID, "2006-01-02", orderFilter, &startDate, &endDate)
|
||
for dateKey, paid := range dailyPaid {
|
||
if item, ok := dateMap[dateKey]; ok {
|
||
item.PaidCents = paid.Total
|
||
item.GMV = paid.Total / 100
|
||
item.CashCents = paid.Cash
|
||
item.CouponCents = paid.Coupon
|
||
item.PointsCents = paid.Points
|
||
}
|
||
}
|
||
|
||
// 2f. 每日成本(按原始订单/抽奖来源归因)
|
||
_, dailyCost := s.calcCostByDrawSource(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
|
||
}
|