fix: treat livestream pass orders as ticket price
This commit is contained in:
parent
8b7af03400
commit
9972427cea
@ -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
|
||||||
|
|||||||
@ -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]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
36
internal/api/admin/livestream_helpers.go
Normal file
36
internal/api/admin/livestream_helpers.go
Normal 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
|
||||||
|
}
|
||||||
@ -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. 汇总每日数据并计算总体指标
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user