fix: treat livestream pass orders as ticket price

This commit is contained in:
win 2026-02-24 10:02:11 +08:00
parent 8b7af03400
commit 9972427cea
4 changed files with 512 additions and 223 deletions

View File

@ -5,6 +5,7 @@ import (
"bindbox-game/internal/pkg/core" "bindbox-game/internal/pkg/core"
"bindbox-game/internal/pkg/validation" "bindbox-game/internal/pkg/validation"
"bindbox-game/internal/repository/mysql/model" "bindbox-game/internal/repository/mysql/model"
financesvc "bindbox-game/internal/service/finance"
"encoding/json" "encoding/json"
"fmt" "fmt"
"net/http" "net/http"
@ -23,20 +24,25 @@ type activityProfitLossRequest struct {
} }
type activityProfitLossItem struct { type activityProfitLossItem struct {
ActivityID int64 `json:"activity_id"` ActivityID int64 `json:"activity_id"`
ActivityName string `json:"activity_name"` ActivityName string `json:"activity_name"`
Status int32 `json:"status"` Status int32 `json:"status"`
DrawCount int64 `json:"draw_count"` DrawCount int64 `json:"draw_count"`
GamePassCount int64 `json:"game_pass_count"` // 次卡抽奖次数 GamePassCount int64 `json:"game_pass_count"` // 次卡抽奖次数
PaymentCount int64 `json:"payment_count"` // 现金/优惠券抽奖次数 PaymentCount int64 `json:"payment_count"` // 现金/优惠券抽奖次数
RefundCount int64 `json:"refund_count"` // 退款/取消抽奖次数 RefundCount int64 `json:"refund_count"` // 退款/取消抽奖次数
PlayerCount int64 `json:"player_count"` PlayerCount int64 `json:"player_count"`
TotalRevenue int64 `json:"total_revenue"` // 实际支付金额 (分) TotalRevenue int64 `json:"total_revenue"` // 实际支付金额 (分)
TotalDiscount int64 `json:"total_discount"` // 优惠券抵扣金额 (分) TotalDiscount int64 `json:"total_discount"` // 优惠券抵扣金额 (分)
TotalGamePassValue int64 `json:"total_game_pass_value"` // 次卡价值 (分) TotalGamePassValue int64 `json:"total_game_pass_value"` // 次卡价值 (分)
TotalCost int64 `json:"total_cost"` // 奖品标价总和 (分) TotalCost int64 `json:"total_cost"` // 奖品标价总和 (分)
Profit int64 `json:"profit"` // (Revenue + Discount + GamePassValue) - Cost SpendingPaidCoupon int64 `json:"spending_paid_coupon"`
ProfitRate float64 `json:"profit_rate"` // Profit / (Revenue + Discount + GamePassValue) SpendingGamePass int64 `json:"spending_game_pass"`
PrizeCostBase int64 `json:"prize_cost_base"`
PrizeCostMultiplier int64 `json:"prize_cost_multiplier"`
PrizeCostFinal int64 `json:"prize_cost_final"`
Profit int64 `json:"profit"` // (Revenue + Discount + GamePassValue) - Cost
ProfitRate float64 `json:"profit_rate"` // Profit / (Revenue + Discount + GamePassValue)
} }
type activityProfitLossResponse struct { type activityProfitLossResponse struct {
@ -170,14 +176,19 @@ func (h *handler) DashboardActivityProfitLoss() core.HandlerFunc {
} }
var revenueStats []revenueStat var revenueStats []revenueStat
// 修正: 按抽奖次数比例分摊订单金额 (解决多活动订单归因问题) // 修正: 按抽奖次数比例分摊订单金额,且次卡订单不计入支付+优惠券口径(严格二选一)
// 逻辑: 活动分摊收入 = 订单实际金额 * (该活动在该订单中的抽奖次数 / 该订单总抽奖次数)
var err error var err error
err = db.Table(model.TableNameOrders). err = db.Table(model.TableNameOrders).
Select(` Select(`
order_activity_draws.activity_id, order_activity_draws.activity_id,
SUM(1.0 * orders.actual_amount * order_activity_draws.draw_count / order_total_draws.total_count) as total_revenue, SUM(CASE WHEN orders.source_type = 4 OR orders.order_no LIKE 'GP%' OR (orders.actual_amount = 0 AND orders.remark LIKE '%use_game_pass%')
SUM(CASE WHEN orders.source_type = 4 OR orders.order_no LIKE 'GP%' THEN 0 ELSE 1.0 * orders.discount_amount * order_activity_draws.draw_count / order_total_draws.total_count END) as total_discount THEN 0
ELSE 1.0 * orders.actual_amount * order_activity_draws.draw_count / order_total_draws.total_count
END) as total_revenue,
SUM(CASE WHEN orders.source_type = 4 OR orders.order_no LIKE 'GP%' OR (orders.actual_amount = 0 AND orders.remark LIKE '%use_game_pass%')
THEN 0
ELSE 1.0 * orders.discount_amount * order_activity_draws.draw_count / order_total_draws.total_count
END) as total_discount
`). `).
// Subquery 1: Calculate draw counts per order per activity (and link to issue->activity) // Subquery 1: Calculate draw counts per order per activity (and link to issue->activity)
Joins(`JOIN ( Joins(`JOIN (
@ -211,21 +222,41 @@ func (h *handler) DashboardActivityProfitLoss() core.HandlerFunc {
// 4. 统计成本 (通过 user_inventory 关联 products 和 orders) // 4. 统计成本 (通过 user_inventory 关联 products 和 orders)
// 修正:增加关联 orders 表,过滤掉已退款/取消的订单 (status!=2) // 修正:增加关联 orders 表,过滤掉已退款/取消的订单 (status!=2)
type costStat struct { type costStat struct {
ActivityID int64 ActivityID int64
TotalCost int64 TotalCost int64
TotalCostBase int64
AvgMultiplierX10 int64
} }
var costStats []costStat var costStats []costStat
db.Table(model.TableNameUserInventory). if err := db.Table(model.TableNameUserInventory).
Select("user_inventory.activity_id, SUM(user_inventory.value_cents) as total_cost"). Select(`
COALESCE(NULLIF(user_inventory.activity_id, 0), cost_issues.activity_id) as activity_id,
CAST(SUM(COALESCE(NULLIF(user_inventory.value_cents, 0), activity_reward_settings.price_snapshot_cents, products.price, 0) * GREATEST(COALESCE(system_item_cards.reward_multiplier_x1000, 1000), 1000) / 1000) AS SIGNED) as total_cost,
SUM(COALESCE(NULLIF(user_inventory.value_cents, 0), activity_reward_settings.price_snapshot_cents, products.price, 0)) as total_cost_base,
CAST(COALESCE(AVG(GREATEST(COALESCE(system_item_cards.reward_multiplier_x1000, 1000), 1000) / 100), 10) AS SIGNED) as avg_multiplier_x10
`).
Joins("LEFT JOIN orders ON orders.id = user_inventory.order_id"). Joins("LEFT JOIN orders ON orders.id = user_inventory.order_id").
Where("user_inventory.activity_id IN ?", activityIDs). Joins("LEFT JOIN activity_reward_settings ON activity_reward_settings.id = user_inventory.reward_id").
Where("orders.status = ?", 2). // 仅统计已支付订单产生的成本 Joins("LEFT JOIN activity_issues AS cost_issues ON cost_issues.id = activity_reward_settings.issue_id").
Group("user_inventory.activity_id"). Joins("LEFT JOIN products ON products.id = user_inventory.product_id").
Scan(&costStats) 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").
for _, s := range costStats { Where("COALESCE(NULLIF(user_inventory.activity_id, 0), cost_issues.activity_id) IN ?", activityIDs).
if item, ok := activityMap[s.ActivityID]; ok { Where("user_inventory.status IN ?", []int{1, 3}).
item.TotalCost = s.TotalCost Where("COALESCE(user_inventory.remark, '') NOT LIKE ?", "%void%").
// 兼容历史数据:部分老资产可能未写入 order_id避免被 JOIN 条件整批过滤为0
Where("(orders.status = ? OR user_inventory.order_id = 0 OR user_inventory.order_id IS NULL)", 2).
Group("COALESCE(NULLIF(user_inventory.activity_id, 0), cost_issues.activity_id)").
Scan(&costStats).Error; err != nil {
h.logger.Error(fmt.Sprintf("GetActivityProfitLoss cost stats error: %v", err))
} else {
for _, s := range costStats {
if item, ok := activityMap[s.ActivityID]; ok {
item.TotalCost = s.TotalCost
item.PrizeCostBase = s.TotalCostBase
item.PrizeCostFinal = s.TotalCost
item.PrizeCostMultiplier = s.AvgMultiplierX10
}
} }
} }
@ -263,15 +294,14 @@ func (h *handler) DashboardActivityProfitLoss() core.HandlerFunc {
} }
// 6. 计算盈亏和比率 // 6. 计算盈亏和比率
// 公式: 盈亏 = (支付金额 + 优惠券抵扣 + 次卡价值) - 产品成本 // 公式: 盈亏 = 用户支出(普通单支付+优惠券 或 次卡价值) - 奖品成本(含道具卡倍率)
finalList := make([]activityProfitLossItem, 0, len(activities)) finalList := make([]activityProfitLossItem, 0, len(activities))
for _, a := range activities { for _, a := range activities {
item := activityMap[a.ID] item := activityMap[a.ID]
totalIncome := item.TotalRevenue + item.TotalDiscount + item.TotalGamePassValue item.SpendingPaidCoupon = item.TotalRevenue + item.TotalDiscount
item.Profit = totalIncome - item.TotalCost item.SpendingGamePass = item.TotalGamePassValue
if totalIncome > 0 { totalIncome := item.SpendingPaidCoupon + item.SpendingGamePass
item.ProfitRate = float64(item.Profit) / float64(totalIncome) item.Profit, item.ProfitRate = financesvc.ComputeProfit(totalIncome, item.TotalCost)
}
finalList = append(finalList, *item) finalList = append(finalList, *item)
} }
@ -417,6 +447,7 @@ func (h *handler) DashboardActivityLogs() core.HandlerFunc {
DrawCount int64 // 该订单的总抽奖次数(用于金额分摊) DrawCount int64 // 该订单的总抽奖次数(用于金额分摊)
UsedDrawLogID int64 // 道具卡实际使用的日志ID UsedDrawLogID int64 // 道具卡实际使用的日志ID
CreatedAt time.Time CreatedAt time.Time
ActivityPrice int64
} }
logsQuery := db.Table(model.TableNameActivityDrawLogs). logsQuery := db.Table(model.TableNameActivityDrawLogs).
@ -444,9 +475,11 @@ func (h *handler) DashboardActivityLogs() core.HandlerFunc {
COALESCE(orders.order_no, '') as order_no, COALESCE(orders.order_no, '') as order_no,
COALESCE(order_draw_counts.draw_count, 1) as draw_count, COALESCE(order_draw_counts.draw_count, 1) as draw_count,
COALESCE(user_item_cards.used_draw_log_id, 0) as used_draw_log_id, COALESCE(user_item_cards.used_draw_log_id, 0) as used_draw_log_id,
activity_draw_logs.created_at activity_draw_logs.created_at,
COALESCE(activities.price_draw, 0) as activity_price
`). `).
Joins("JOIN activity_issues ON activity_issues.id = activity_draw_logs.issue_id"). Joins("JOIN activity_issues ON activity_issues.id = activity_draw_logs.issue_id").
Joins("JOIN activities ON activities.id = activity_issues.activity_id").
Joins("LEFT JOIN users ON users.id = activity_draw_logs.user_id"). Joins("LEFT JOIN users ON users.id = activity_draw_logs.user_id").
Joins("LEFT JOIN activity_reward_settings ON activity_reward_settings.id = activity_draw_logs.reward_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 products ON products.id = activity_reward_settings.product_id").
@ -589,6 +622,14 @@ func (h *handler) DashboardActivityLogs() core.HandlerFunc {
perDrawDiscountAmount := l.DiscountAmount / drawCount perDrawDiscountAmount := l.DiscountAmount / drawCount
perDrawPointsAmount := l.PointsAmount / drawCount perDrawPointsAmount := l.PointsAmount / drawCount
if paymentDetails.GamePassUsed {
if l.ActivityPrice > 0 {
perDrawOrderAmount = l.ActivityPrice
} else if perDrawOrderAmount == 0 {
perDrawOrderAmount = l.OrderAmount / drawCount
}
}
// 设置支付详情中的分摊金额 // 设置支付详情中的分摊金额
paymentDetails.CouponDiscount = perDrawDiscountAmount paymentDetails.CouponDiscount = perDrawDiscountAmount
paymentDetails.PointsDiscount = perDrawPointsAmount paymentDetails.PointsDiscount = perDrawPointsAmount

View File

@ -1,6 +1,9 @@
package admin package admin
import ( import (
"context"
"errors"
"fmt"
"net/http" "net/http"
"strconv" "strconv"
"strings" "strings"
@ -10,6 +13,7 @@ import (
"bindbox-game/internal/pkg/core" "bindbox-game/internal/pkg/core"
"bindbox-game/internal/pkg/validation" "bindbox-game/internal/pkg/validation"
"bindbox-game/internal/repository/mysql/model" "bindbox-game/internal/repository/mysql/model"
"bindbox-game/internal/service/channel"
"bindbox-game/internal/service/livestream" "bindbox-game/internal/service/livestream"
"gorm.io/gorm" "gorm.io/gorm"
@ -21,6 +25,7 @@ type createLivestreamActivityRequest struct {
Name string `json:"name" binding:"required"` Name string `json:"name" binding:"required"`
StreamerName string `json:"streamer_name"` StreamerName string `json:"streamer_name"`
StreamerContact string `json:"streamer_contact"` StreamerContact string `json:"streamer_contact"`
ChannelID *int64 `json:"channel_id"`
DouyinProductID string `json:"douyin_product_id"` DouyinProductID string `json:"douyin_product_id"`
OrderRewardType string `json:"order_reward_type"` // 下单奖励类型: flip_card/minesweeper OrderRewardType string `json:"order_reward_type"` // 下单奖励类型: flip_card/minesweeper
OrderRewardQuantity int32 `json:"order_reward_quantity"` // 下单奖励数量: 1-100 OrderRewardQuantity int32 `json:"order_reward_quantity"` // 下单奖励数量: 1-100
@ -34,6 +39,9 @@ type livestreamActivityResponse struct {
Name string `json:"name"` Name string `json:"name"`
StreamerName string `json:"streamer_name"` StreamerName string `json:"streamer_name"`
StreamerContact string `json:"streamer_contact"` StreamerContact string `json:"streamer_contact"`
ChannelID int64 `json:"channel_id"`
ChannelCode string `json:"channel_code"`
ChannelName string `json:"channel_name"`
AccessCode string `json:"access_code"` AccessCode string `json:"access_code"`
DouyinProductID string `json:"douyin_product_id"` DouyinProductID string `json:"douyin_product_id"`
OrderRewardType string `json:"order_reward_type"` // 下单奖励类型 OrderRewardType string `json:"order_reward_type"` // 下单奖励类型
@ -64,10 +72,35 @@ func (h *handler) CreateLivestreamActivity() core.HandlerFunc {
return return
} }
var channelCode string
var channelName string
if req.ChannelID != nil && *req.ChannelID > 0 {
if ch, err := h.channel.GetByID(ctx.RequestContext(), *req.ChannelID); err == nil {
channelCode = ch.Code
channelName = ch.Name
if req.StreamerName == "" {
req.StreamerName = ch.Name
}
} else if err == channel.ErrChannelNotFound {
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, "渠道不存在"))
return
} else {
ctx.AbortWithError(core.Error(http.StatusInternalServerError, code.ServerError, err.Error()))
return
}
}
input := livestream.CreateActivityInput{ input := livestream.CreateActivityInput{
Name: req.Name, Name: req.Name,
StreamerName: req.StreamerName, StreamerName: req.StreamerName,
StreamerContact: req.StreamerContact, StreamerContact: req.StreamerContact,
ChannelID: func() int64 {
if req.ChannelID != nil {
return *req.ChannelID
}
return 0
}(),
ChannelCode: channelCode,
DouyinProductID: req.DouyinProductID, DouyinProductID: req.DouyinProductID,
OrderRewardType: req.OrderRewardType, OrderRewardType: req.OrderRewardType,
OrderRewardQuantity: req.OrderRewardQuantity, OrderRewardQuantity: req.OrderRewardQuantity,
@ -91,11 +124,19 @@ func (h *handler) CreateLivestreamActivity() core.HandlerFunc {
return return
} }
displayChannelName := channelName
if displayChannelName == "" && activity.ChannelCode != "" {
displayChannelName = activity.ChannelCode
}
ctx.Payload(&livestreamActivityResponse{ ctx.Payload(&livestreamActivityResponse{
ID: activity.ID, ID: activity.ID,
Name: activity.Name, Name: activity.Name,
StreamerName: activity.StreamerName, StreamerName: activity.StreamerName,
StreamerContact: activity.StreamerContact, StreamerContact: activity.StreamerContact,
ChannelID: activity.ChannelID,
ChannelCode: activity.ChannelCode,
ChannelName: displayChannelName,
AccessCode: activity.AccessCode, AccessCode: activity.AccessCode,
DouyinProductID: activity.DouyinProductID, DouyinProductID: activity.DouyinProductID,
OrderRewardType: activity.OrderRewardType, OrderRewardType: activity.OrderRewardType,
@ -111,6 +152,7 @@ type updateLivestreamActivityRequest struct {
Name string `json:"name"` Name string `json:"name"`
StreamerName string `json:"streamer_name"` StreamerName string `json:"streamer_name"`
StreamerContact string `json:"streamer_contact"` StreamerContact string `json:"streamer_contact"`
ChannelID *int64 `json:"channel_id"`
DouyinProductID string `json:"douyin_product_id"` DouyinProductID string `json:"douyin_product_id"`
OrderRewardType string `json:"order_reward_type"` // 下单奖励类型 OrderRewardType string `json:"order_reward_type"` // 下单奖励类型
OrderRewardQuantity *int32 `json:"order_reward_quantity"` // 下单奖励数量 OrderRewardQuantity *int32 `json:"order_reward_quantity"` // 下单奖励数量
@ -146,6 +188,29 @@ func (h *handler) UpdateLivestreamActivity() core.HandlerFunc {
return return
} }
var channelCodeValue string
var channelCodePtr *string
if req.ChannelID != nil {
if *req.ChannelID > 0 {
if ch, err := h.channel.GetByID(ctx.RequestContext(), *req.ChannelID); err == nil {
channelCodeValue = ch.Code
channelCodePtr = &channelCodeValue
if req.StreamerName == "" {
req.StreamerName = ch.Name
}
} else if err == channel.ErrChannelNotFound {
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, "渠道不存在"))
return
} else {
ctx.AbortWithError(core.Error(http.StatusInternalServerError, code.ServerError, err.Error()))
return
}
} else {
channelCodeValue = ""
channelCodePtr = &channelCodeValue
}
}
input := livestream.UpdateActivityInput{ input := livestream.UpdateActivityInput{
Name: req.Name, Name: req.Name,
StreamerName: req.StreamerName, StreamerName: req.StreamerName,
@ -155,6 +220,8 @@ func (h *handler) UpdateLivestreamActivity() core.HandlerFunc {
OrderRewardQuantity: req.OrderRewardQuantity, OrderRewardQuantity: req.OrderRewardQuantity,
TicketPrice: req.TicketPrice, TicketPrice: req.TicketPrice,
Status: req.Status, Status: req.Status,
ChannelID: req.ChannelID,
ChannelCode: channelCodePtr,
} }
if req.StartTime != "" { if req.StartTime != "" {
@ -224,6 +291,14 @@ func (h *handler) ListLivestreamActivities() core.HandlerFunc {
return return
} }
channelIDs := make([]int64, 0, len(list))
for _, a := range list {
if a.ChannelID > 0 {
channelIDs = append(channelIDs, a.ChannelID)
}
}
channelNameMap := h.loadChannelNames(ctx.RequestContext(), channelIDs)
res := &listLivestreamActivitiesResponse{ res := &listLivestreamActivitiesResponse{
List: make([]livestreamActivityResponse, len(list)), List: make([]livestreamActivityResponse, len(list)),
Total: total, Total: total,
@ -245,6 +320,13 @@ func (h *handler) ListLivestreamActivities() core.HandlerFunc {
Status: a.Status, Status: a.Status,
CreatedAt: a.CreatedAt.Format("2006-01-02 15:04:05"), CreatedAt: a.CreatedAt.Format("2006-01-02 15:04:05"),
} }
item.ChannelID = a.ChannelID
item.ChannelCode = a.ChannelCode
if name := channelNameMap[a.ChannelID]; name != "" {
item.ChannelName = name
} else if a.ChannelCode != "" {
item.ChannelName = a.ChannelCode
}
if !a.StartTime.IsZero() { if !a.StartTime.IsZero() {
item.StartTime = a.StartTime.Format("2006-01-02 15:04:05") item.StartTime = a.StartTime.Format("2006-01-02 15:04:05")
} }
@ -283,11 +365,24 @@ func (h *handler) GetLivestreamActivity() core.HandlerFunc {
return return
} }
channelName := ""
if activity.ChannelID > 0 {
if names := h.loadChannelNames(ctx.RequestContext(), []int64{activity.ChannelID}); len(names) > 0 {
channelName = names[activity.ChannelID]
}
}
if channelName == "" && activity.ChannelCode != "" {
channelName = activity.ChannelCode
}
res := &livestreamActivityResponse{ res := &livestreamActivityResponse{
ID: activity.ID, ID: activity.ID,
Name: activity.Name, Name: activity.Name,
StreamerName: activity.StreamerName, StreamerName: activity.StreamerName,
StreamerContact: activity.StreamerContact, StreamerContact: activity.StreamerContact,
ChannelID: activity.ChannelID,
ChannelCode: activity.ChannelCode,
ChannelName: channelName,
AccessCode: activity.AccessCode, AccessCode: activity.AccessCode,
DouyinProductID: activity.DouyinProductID, DouyinProductID: activity.DouyinProductID,
OrderRewardType: activity.OrderRewardType, OrderRewardType: activity.OrderRewardType,
@ -335,6 +430,41 @@ func (h *handler) DeleteLivestreamActivity() core.HandlerFunc {
} }
} }
func (h *handler) loadChannelNames(ctx context.Context, ids []int64) map[int64]string {
result := make(map[int64]string)
if len(ids) == 0 {
return result
}
unique := make([]int64, 0, len(ids))
seen := make(map[int64]struct{})
for _, id := range ids {
if id <= 0 {
continue
}
if _, ok := seen[id]; ok {
continue
}
seen[id] = struct{}{}
unique = append(unique, id)
}
if len(unique) == 0 {
return result
}
channels, err := h.readDB.Channels.WithContext(ctx).
Select(h.readDB.Channels.ID, h.readDB.Channels.Name).
Where(h.readDB.Channels.ID.In(unique...)).
Find()
if err != nil {
return result
}
for _, ch := range channels {
result[ch.ID] = ch.Name
}
return result
}
// ========== 直播间奖品管理 ========== // ========== 直播间奖品管理 ==========
type createLivestreamPrizeRequest struct { type createLivestreamPrizeRequest struct {
@ -630,6 +760,17 @@ func (h *handler) ListLivestreamDrawLogs() core.HandlerFunc {
return return
} }
var activity model.LivestreamActivities
if err := h.repo.GetDbR().Select("id, ticket_price").Where("id = ?", activityID).First(&activity).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
ctx.AbortWithError(core.Error(http.StatusNotFound, code.ParamBindError, "活动不存在"))
} else {
ctx.AbortWithError(core.Error(http.StatusInternalServerError, code.ServerError, err.Error()))
}
return
}
ticketPrice := int64(activity.TicketPrice)
req := new(listLivestreamDrawLogsRequest) req := new(listLivestreamDrawLogsRequest)
_ = ctx.ShouldBindForm(req) _ = ctx.ShouldBindForm(req)
@ -710,10 +851,11 @@ func (h *handler) ListLivestreamDrawLogs() core.HandlerFunc {
DouyinOrderID int64 DouyinOrderID int64
PrizeID int64 PrizeID int64
ShopOrderID string // 用于关联退款状态查 douyin_orders ShopOrderID string // 用于关联退款状态查 douyin_orders
LocalUserID int64
} }
var metas []logMeta var metas []logMeta
// 使用不带分页的 db 克隆 // 使用不带分页的 db 克隆
if err := db.Session(&gorm.Session{}).Select("douyin_order_id, prize_id, shop_order_id").Scan(&metas).Error; err == nil { if err := db.Session(&gorm.Session{}).Select("douyin_order_id, prize_id, shop_order_id, local_user_id").Scan(&metas).Error; err == nil {
orderIDs := make([]int64, 0, len(metas)) orderIDs := make([]int64, 0, len(metas))
distinctOrderIDs := make(map[int64]bool) distinctOrderIDs := make(map[int64]bool)
prizeIDCount := make(map[int64]int64) prizeIDCount := make(map[int64]int64)
@ -730,61 +872,97 @@ func (h *handler) ListLivestreamDrawLogs() core.HandlerFunc {
if len(orderIDs) > 0 { if len(orderIDs) > 0 {
var orders []model.DouyinOrders var orders []model.DouyinOrders
// 分批查询防止 IN 子句过长? 暂时假设量级可控 // 分批查询防止 IN 子句过长? 暂时假设量级可控
h.repo.GetDbR().Select("id, actual_pay_amount, order_status"). h.repo.GetDbR().Select("id, actual_pay_amount, order_status, pay_type_desc, product_count").
Where("id IN ?", orderIDs).Find(&orders) Where("id IN ?", orderIDs).Find(&orders)
orderRefundMap := make(map[int64]bool) orderRefundMap := make(map[int64]bool)
for _, o := range orders { for _, o := range orders {
// 统计营收 (总流水) // 统计营收 (总流水)
stats.TotalRev += int64(o.ActualPayAmount) orderAmount := calcLivestreamOrderAmount(&o, ticketPrice)
stats.TotalRev += orderAmount
if o.OrderStatus == 4 { // 已退款 if o.OrderStatus == 4 { // 已退款
stats.TotalRefund += int64(o.ActualPayAmount) stats.TotalRefund += orderAmount
orderRefundMap[o.ID] = true orderRefundMap[o.ID] = true
} }
} }
// 4. 统计成本 (剔除退款订单) // 4. 统计成本 (剔除退款订单,优先资产快照,缺失回退 prize.cost_price)
for _, m := range metas { for _, m := range metas {
if !orderRefundMap[m.DouyinOrderID] { if !orderRefundMap[m.DouyinOrderID] {
prizeIDCount[m.PrizeID]++ prizeIDCount[m.PrizeID]++
} }
} }
// 计算奖品成本 (逻辑参考 GetLivestreamStats简化版) prizeCostMap := make(map[int64]int64)
if len(prizeIDCount) > 0 { if len(prizeIDCount) > 0 {
prizeIDs := make([]int64, 0, len(prizeIDCount)) prizeIDs := make([]int64, 0, len(prizeIDCount))
for pid := range prizeIDCount { for pid := range prizeIDCount {
prizeIDs = append(prizeIDs, pid) prizeIDs = append(prizeIDs, pid)
} }
var prizes []model.LivestreamPrizes var prizes []model.LivestreamPrizes
h.repo.GetDbR().Where("id IN ?", prizeIDs).Find(&prizes) h.repo.GetDbR().Select("id, cost_price").Where("id IN ?", prizeIDs).Find(&prizes)
// 批量获取关联商品
productIDs := make([]int64, 0)
for _, p := range prizes { for _, p := range prizes {
if p.CostPrice == 0 && p.ProductID > 0 { prizeCostMap[p.ID] = p.CostPrice
productIDs = append(productIDs, p.ProductID)
}
} }
productPriceMap := make(map[int64]int64) }
if len(productIDs) > 0 {
var products []model.Products // 预加载用户资产快照用于 shop_order_id 命中
h.repo.GetDbR().Select("id, price").Where("id IN ?", productIDs).Find(&products) type invRow struct {
for _, prod := range products { UserID int64
productPriceMap[prod.ID] = prod.Price ValueCents int64
Remark string
}
var invRows []invRow
_ = h.repo.GetDbR().Table("user_inventory").
Select("user_inventory.user_id, COALESCE(NULLIF(user_inventory.value_cents, 0), activity_reward_settings.price_snapshot_cents, products.price, 0) as value_cents, user_inventory.remark").
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").
Where("status IN (1,3)").
Where("COALESCE(user_inventory.remark, '') NOT LIKE ?", "%void%").
Where("user_id > 0").
Scan(&invRows).Error
invByUser := make(map[int64][]invRow)
for _, v := range invRows {
invByUser[v.UserID] = append(invByUser[v.UserID], v)
}
metasByKey := make(map[string][]logMeta)
keyUser := make(map[string]int64)
keyOrder := make(map[string]string)
for _, m := range metas {
if orderRefundMap[m.DouyinOrderID] {
continue
}
key := fmt.Sprintf("%d|%s", m.LocalUserID, m.ShopOrderID)
metasByKey[key] = append(metasByKey[key], m)
keyUser[key] = m.LocalUserID
keyOrder[key] = m.ShopOrderID
}
for key, rows := range metasByKey {
if len(rows) == 0 {
continue
}
uid := keyUser[key]
shopOrderID := keyOrder[key]
var snapshotSum int64
if uid > 0 && shopOrderID != "" {
for _, inv := range invByUser[uid] {
if strings.Contains(inv.Remark, shopOrderID) {
snapshotSum += inv.ValueCents
}
} }
} }
for _, p := range prizes { if snapshotSum > 0 {
cost := p.CostPrice stats.TotalCost += snapshotSum
if cost == 0 && p.ProductID > 0 { continue
cost = productPriceMap[p.ProductID] }
}
count := prizeIDCount[p.ID] for _, r := range rows {
stats.TotalCost += cost * count stats.TotalCost += prizeCostMap[r.PrizeID]
} }
} }
} }

View File

@ -0,0 +1,36 @@
package admin
import (
"strings"
"bindbox-game/internal/repository/mysql/model"
)
// calcLivestreamOrderAmount returns the effective revenue contribution for a Douyin order.
// For regular paid orders it returns actual_pay_amount; for 次卡订单 (actual pay is 0 but
// pay_type_desc contains 次卡), it falls back to the activity ticket price.
func calcLivestreamOrderAmount(order *model.DouyinOrders, ticketPrice int64) int64 {
if order == nil {
return 0
}
amount := int64(order.ActualPayAmount)
if amount > 0 || ticketPrice <= 0 {
return amount
}
desc := strings.ReplaceAll(strings.TrimSpace(order.PayTypeDesc), " ", "")
if desc == "" {
return amount
}
if strings.Contains(desc, "次卡") {
multiplier := int64(order.ProductCount)
if multiplier <= 0 {
multiplier = 1
}
return ticketPrice * multiplier
}
return amount
}

View File

@ -4,6 +4,7 @@ import (
"math" "math"
"net/http" "net/http"
"strconv" "strconv"
"strings"
"bindbox-game/internal/code" "bindbox-game/internal/code"
"bindbox-game/internal/pkg/core" "bindbox-game/internal/pkg/core"
@ -77,54 +78,98 @@ func (h *handler) GetLivestreamStats() core.HandlerFunc {
ctx.AbortWithError(core.Error(http.StatusNotFound, code.ServerError, "活动不存在")) ctx.AbortWithError(core.Error(http.StatusNotFound, code.ServerError, "活动不存在"))
return return
} }
// ticketPrice 暂未使用,但在统计中可能作为参考,这里移除未使用的报错 ticketPrice := int64(activity.TicketPrice)
// 2. 统计营收/退款基于订单去重并兼容次卡0元订单按门票价计入
type orderRef struct {
OrderID int64
FirstDrawAt time.Time
}
orderQuery := h.repo.GetDbR().Table(model.TableNameLivestreamDrawLogs).
Select("douyin_order_id AS order_id, MIN(created_at) AS first_draw_at").
Where("activity_id = ?", id).
Where("douyin_order_id > 0")
// 2. 核心统计逻辑重构:从关联的订单表获取真实金额
// 使用子查询或 Join 来获取不重复的订单金额,确保即便一次订单对应多次抽奖,金额也不重计
var totalRevenue, orderCount int64
// 统计营收:来自已参与过抽奖(产生过日志)且未退款的订单 (order_status != 4)
// 使用 actual_pay_amount (实付金额)
queryRevenue := `
SELECT
CAST(IFNULL(SUM(distinct_orders.actual_pay_amount), 0) AS SIGNED) as rev,
COUNT(*) as cnt
FROM (
SELECT DISTINCT o.id, o.actual_pay_amount
FROM douyin_orders o
JOIN livestream_draw_logs l ON o.id = l.douyin_order_id
WHERE l.activity_id = ?
`
if startTime != nil { if startTime != nil {
queryRevenue += " AND l.created_at >= '" + startTime.Format("2006-01-02 15:04:05") + "'" orderQuery = orderQuery.Where("created_at >= ?", startTime)
} }
if endTime != nil { if endTime != nil {
queryRevenue += " AND l.created_at <= '" + endTime.Format("2006-01-02 15:04:05") + "'" orderQuery = orderQuery.Where("created_at <= ?", endTime)
} }
queryRevenue += ") as distinct_orders"
_ = h.repo.GetDbR().Raw(queryRevenue, id).Row().Scan(&totalRevenue, &orderCount) var orderRefs []orderRef
if err := orderQuery.Group("douyin_order_id").Scan(&orderRefs).Error; err != nil {
// 统计退款:来自已参与过抽奖且标记为退款的订单 (order_status = 4) ctx.AbortWithError(core.Error(http.StatusInternalServerError, code.ServerError, err.Error()))
var totalRefund, refundCount int64 return
queryRefund := `
SELECT
CAST(IFNULL(SUM(distinct_orders.actual_pay_amount), 0) AS SIGNED) as ref_amt,
COUNT(*) as ref_cnt
FROM (
SELECT DISTINCT o.id, o.actual_pay_amount
FROM douyin_orders o
JOIN livestream_draw_logs l ON o.id = l.douyin_order_id
WHERE l.activity_id = ? AND o.order_status = 4
`
if startTime != nil {
queryRefund += " AND l.created_at >= '" + startTime.Format("2006-01-02 15:04:05") + "'"
} }
if endTime != nil {
queryRefund += " AND l.created_at <= '" + endTime.Format("2006-01-02 15:04:05") + "'"
}
queryRefund += ") as distinct_orders"
_ = h.repo.GetDbR().Raw(queryRefund, id).Row().Scan(&totalRefund, &refundCount) orderIDs := make([]int64, 0, len(orderRefs))
for _, ref := range orderRefs {
if ref.OrderID == 0 {
continue
}
orderIDs = append(orderIDs, ref.OrderID)
}
orderMap := make(map[int64]*model.DouyinOrders, len(orderIDs))
if len(orderIDs) > 0 {
var orders []model.DouyinOrders
if err := h.repo.GetDbR().
Select("id, shop_order_id, actual_pay_amount, order_status, pay_type_desc, product_count").
Where("id IN ?", orderIDs).
Find(&orders).Error; err != nil {
ctx.AbortWithError(core.Error(http.StatusInternalServerError, code.ServerError, err.Error()))
return
}
for i := range orders {
orderMap[orders[i].ID] = &orders[i]
}
}
dailyMap := make(map[string]*dailyLivestreamStats)
refundedShopOrderIDs := make(map[string]bool)
var totalRevenue, totalRefund int64
var orderCount, refundCount int64
for _, ref := range orderRefs {
order := orderMap[ref.OrderID]
if order == nil {
continue
}
amount := calcLivestreamOrderAmount(order, ticketPrice)
if amount < 0 {
amount = 0
}
dateKey := ref.FirstDrawAt.In(time.Local).Format("2006-01-02")
if ref.FirstDrawAt.IsZero() {
dateKey = time.Now().In(time.Local).Format("2006-01-02")
}
refunded := order.OrderStatus == 4
orderCount++
totalRevenue += amount
if refunded {
totalRefund += amount
refundCount++
}
if refunded && order.ShopOrderID != "" {
refundedShopOrderIDs[order.ShopOrderID] = true
}
ds := dailyMap[dateKey]
if ds == nil {
ds = &dailyLivestreamStats{Date: dateKey}
dailyMap[dateKey] = ds
}
ds.TotalRevenue += amount
ds.OrderCount++
if refunded {
ds.TotalRefund += amount
ds.RefundCount++
}
}
// 3. 获取所有抽奖记录用于成本计算 // 3. 获取所有抽奖记录用于成本计算
var drawLogs []model.LivestreamDrawLogs var drawLogs []model.LivestreamDrawLogs
@ -138,141 +183,130 @@ func (h *handler) GetLivestreamStats() core.HandlerFunc {
db.Find(&drawLogs) db.Find(&drawLogs)
// 3.1 获取该时间段内所有退款的 shop_order_id 集合,用于过滤成本 // 3.1 获取该时间段内所有退款的 shop_order_id 集合,用于过滤成本
refundedShopOrderIDs := make(map[string]bool) // 4. 计算成本(优先资产快照 user_inventory.value_cents缺失回退 livestream_prizes.cost_price
var refundedOrders []string prizeCostMap := make(map[int64]int64)
qRefundIDs := ` prizeIDs := make([]int64, 0)
SELECT DISTINCT o.shop_order_id prizeIDSet := make(map[int64]struct{})
FROM douyin_orders o userIDSet := make(map[int64]struct{})
JOIN livestream_draw_logs l ON o.id = l.douyin_order_id for _, log := range drawLogs {
WHERE l.activity_id = ? AND o.order_status = 4 if log.PrizeID > 0 {
` if _, ok := prizeIDSet[log.PrizeID]; !ok {
if startTime != nil { prizeIDSet[log.PrizeID] = struct{}{}
qRefundIDs += " AND l.created_at >= '" + startTime.Format("2006-01-02 15:04:05") + "'" prizeIDs = append(prizeIDs, log.PrizeID)
} }
if endTime != nil { }
qRefundIDs += " AND l.created_at <= '" + endTime.Format("2006-01-02 15:04:05") + "'" if log.LocalUserID > 0 {
} userIDSet[log.LocalUserID] = struct{}{}
h.repo.GetDbR().Raw(qRefundIDs, id).Scan(&refundedOrders) }
for _, oid := range refundedOrders {
refundedShopOrderIDs[oid] = true
} }
// 4. 计算成本(使用 draw_logs.product_id 直接关联 products 表) if len(prizeIDs) > 0 {
// 收集未退款订单的 product_id 和对应数量 var prizes []model.LivestreamPrizes
productIDCountMap := make(map[int64]int64) h.repo.GetDbR().Select("id, cost_price").Where("id IN ?", prizeIDs).Find(&prizes)
for _, p := range prizes {
prizeCostMap[p.ID] = p.CostPrice
}
}
type inventorySnapshot struct {
UserID int64
ValueCents int64
Remark string
CreatedAt time.Time
}
invByUser := make(map[int64][]inventorySnapshot)
if len(userIDSet) > 0 {
userIDs := make([]int64, 0, len(userIDSet))
for uid := range userIDSet {
userIDs = append(userIDs, uid)
}
var inventories []inventorySnapshot
invDB := h.repo.GetDbR().Table("user_inventory").
Select("user_inventory.user_id, COALESCE(NULLIF(user_inventory.value_cents, 0), activity_reward_settings.price_snapshot_cents, products.price, 0) as value_cents, user_inventory.remark, user_inventory.created_at").
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").
Where("user_id IN ?", userIDs).
Where("status IN (1, 3)").
Where("COALESCE(user_inventory.remark, '') NOT LIKE ?", "%void%")
if startTime != nil {
invDB = invDB.Where("created_at >= ?", startTime.Add(-2*time.Hour))
}
if endTime != nil {
invDB = invDB.Where("created_at <= ?", endTime.Add(24*time.Hour))
}
_ = invDB.Scan(&inventories).Error
for _, inv := range inventories {
invByUser[inv.UserID] = append(invByUser[inv.UserID], inv)
}
}
type logRef struct {
PrizeID int64
DateKey string
}
logsByKey := make(map[string][]logRef)
keyUser := make(map[string]int64)
keyOrder := make(map[string]string)
for _, log := range drawLogs { for _, log := range drawLogs {
// 排除已退款的订单
if refundedShopOrderIDs[log.ShopOrderID] { if refundedShopOrderIDs[log.ShopOrderID] {
continue continue
} }
// 使用 draw_logs 中记录的 product_id key := strconv.FormatInt(log.LocalUserID, 10) + "|" + log.ShopOrderID
if log.ProductID > 0 { logsByKey[key] = append(logsByKey[key], logRef{
productIDCountMap[log.ProductID]++ PrizeID: log.PrizeID,
} DateKey: log.CreatedAt.Format("2006-01-02"),
})
keyUser[key] = log.LocalUserID
keyOrder[key] = log.ShopOrderID
} }
costByDate := make(map[string]int64)
var totalCost int64 var totalCost int64
productCostMap := make(map[int64]int64) for key, refs := range logsByKey {
if len(productIDCountMap) > 0 { if len(refs) == 0 {
productIDs := make([]int64, 0, len(productIDCountMap)) continue
for pid := range productIDCountMap {
productIDs = append(productIDs, pid)
} }
uid := keyUser[key]
shopOrderID := keyOrder[key]
var products []model.Products var snapshotSum int64
h.repo.GetDbR().Where("id IN ?", productIDs).Find(&products) if uid > 0 && shopOrderID != "" {
for _, p := range products { for _, inv := range invByUser[uid] {
productCostMap[p.ID] = p.Price if strings.Contains(inv.Remark, shopOrderID) {
} snapshotSum += inv.ValueCents
}
for productID, count := range productIDCountMap {
if cost, ok := productCostMap[productID]; ok {
totalCost += cost * count
} }
} }
}
// 构建 productID -> cost 映射供每日统计使用 if snapshotSum > 0 {
prizeCostMap := productCostMap avg := snapshotSum / int64(len(refs))
rem := snapshotSum - avg*int64(len(refs))
// 5. 按天分组统计 for i, r := range refs {
dailyMap := make(map[string]*dailyLivestreamStats) c := avg
if i == 0 {
// 5.1 统计每日营收和退款(直接累加订单实付金额) c += rem
type DailyAmount struct { }
DateKey string totalCost += c
Amount int64 costByDate[r.DateKey] += c
Count int64 }
IsRefunded int32
}
var dailyAmounts []DailyAmount
queryDailyCorrect := `
SELECT
date_key,
CAST(SUM(actual_pay_amount) AS SIGNED) as amount,
COUNT(id) as cnt,
refund_flag as is_refunded
FROM (
SELECT
o.id,
DATE_FORMAT(MIN(l.created_at), '%Y-%m-%d') as date_key,
o.actual_pay_amount,
IF(o.order_status = 4, 1, 0) as refund_flag
FROM douyin_orders o
JOIN livestream_draw_logs l ON o.id = l.douyin_order_id
WHERE l.activity_id = ?
`
if startTime != nil {
queryDailyCorrect += " AND l.created_at >= '" + startTime.Format("2006-01-02 15:04:05") + "'"
}
if endTime != nil {
queryDailyCorrect += " AND l.created_at <= '" + endTime.Format("2006-01-02 15:04:05") + "'"
}
queryDailyCorrect += `
GROUP BY o.id
) as t
GROUP BY date_key, is_refunded
`
rows, _ := h.repo.GetDbR().Raw(queryDailyCorrect, id).Rows()
defer rows.Close()
for rows.Next() {
var da DailyAmount
_ = rows.Scan(&da.DateKey, &da.Amount, &da.Count, &da.IsRefunded)
dailyAmounts = append(dailyAmounts, da)
}
for _, da := range dailyAmounts {
if _, ok := dailyMap[da.DateKey]; !ok {
dailyMap[da.DateKey] = &dailyLivestreamStats{Date: da.DateKey}
}
// 修正口径:营收(Revenue) = 总流水 (含退款与未退款)
// 这样下面的 NetProfit = TotalRevenue - TotalRefund - TotalCost 才不会双重扣除
dailyMap[da.DateKey].TotalRevenue += da.Amount
dailyMap[da.DateKey].OrderCount += da.Count
if da.IsRefunded == 1 {
dailyMap[da.DateKey].TotalRefund += da.Amount
dailyMap[da.DateKey].RefundCount += da.Count
}
}
// 5.2 统计每日成本(基于 Logs 的 ProductID
for _, log := range drawLogs {
// 排除退款订单
if refundedShopOrderIDs[log.ShopOrderID] {
continue continue
} }
if log.ProductID <= 0 {
continue for _, r := range refs {
c := prizeCostMap[r.PrizeID]
totalCost += c
costByDate[r.DateKey] += c
} }
dateKey := log.CreatedAt.Format("2006-01-02") }
// 5. 按天分组统计 (营收/退款已在 dailyMap 中累计),补充成本
for dateKey, c := range costByDate {
ds := dailyMap[dateKey] ds := dailyMap[dateKey]
if ds != nil { if ds == nil {
if cost, ok := prizeCostMap[log.ProductID]; ok { ds = &dailyLivestreamStats{Date: dateKey}
ds.TotalCost += cost dailyMap[dateKey] = ds
}
} }
ds.TotalCost += c
} }
// 6. 汇总每日数据并计算总体指标 // 6. 汇总每日数据并计算总体指标