win 8d1eef2f7f fix(channel): 修复渠道统计GMV重复计数和商城直购误计入
1. 排除商城直购(source_type=1):GMV和成本过滤条件从IN(1,2,3,4)改为IN(2,3,4)
2. 排除次卡免费使用订单(actual_amount=0):避免购买次卡和使用次卡双重计入GMV
   - source_type=4 一番赏使用次卡:1578单 44032元重复
   - source_type=3 对对碰使用次卡:422单 7042元重复
   - 合计去除51074元虚增GMV(29.1%)
3. 成本过滤条件同步修正:source_type IN(2,3,4),total_amount>0

修正后:GMV从175600降至124527元,毛利率从37.4%回到真实的11.8%
2026-03-16 21:41:39 +08:00

618 lines
18 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/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 orderAmountRow struct {
ActualAmount int64
CreatedAt time.Time
}
// calcGMVByTotalAmount 按订单原价total_amount统计渠道GMV涵盖全部游戏类型抽奖、对对碰、一番赏
// 使用 total_amount活动原价而非 actual_amount确保优惠券、道具卡、积分抵扣的订单也完整计入
// 与成本(商品价值)保持同一口径,使盈亏计算真实反映业务健康度。
// 返回:总金额(分)、按 dateFmt 格式分组的金额。
func (s *service) calcGMVByTotalAmount(ctx context.Context, channelID int64, dateFmt string, orderFilter string, startDate, endDate *time.Time) (int64, map[string]int64) {
type row struct {
TotalAmount 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.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 int64
byDate := make(map[string]int64)
for _, r := range rows {
total += r.TotalAmount
byDate[r.CreatedAt.Format(dateFmt)] += r.TotalAmount
}
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
}
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
totalPaid, _ := s.calcGMVByTotalAmount(ctx, channelID, "2006-01-02", orderFilter, nil, nil)
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
}
}
_, 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
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
}