bindbox-game/internal/api/admin/livestream_stats.go
2026-02-03 17:44:02 +08:00

319 lines
10 KiB
Go
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 admin
import (
"math"
"net/http"
"strconv"
"bindbox-game/internal/code"
"bindbox-game/internal/pkg/core"
"bindbox-game/internal/repository/mysql/model"
"time"
)
type dailyLivestreamStats struct {
Date string `json:"date"` // 日期
TotalRevenue int64 `json:"total_revenue"` // 营收
TotalRefund int64 `json:"total_refund"` // 退款
TotalCost int64 `json:"total_cost"` // 成本
NetProfit int64 `json:"net_profit"` // 净利润
ProfitMargin float64 `json:"profit_margin"` // 利润率
OrderCount int64 `json:"order_count"` // 订单数
RefundCount int64 `json:"refund_count"` // 退款单数
}
type livestreamStatsResponse struct {
TotalRevenue int64 `json:"total_revenue"` // 总营收(分)
TotalRefund int64 `json:"total_refund"` // 总退款(分)
TotalCost int64 `json:"total_cost"` // 总成本(分)
NetProfit int64 `json:"net_profit"` // 净利润(分)
OrderCount int64 `json:"order_count"` // 订单数
RefundCount int64 `json:"refund_count"` // 退款数
ProfitMargin float64 `json:"profit_margin"` // 利润率 %
Daily []dailyLivestreamStats `json:"daily"` // 每日明细
}
// GetLivestreamStats 获取直播间盈亏统计
// @Summary 获取直播间盈亏统计
// @Description 计算逻辑:净利润 = (营收 - 退款) - 奖品成本。营收 = 抽奖次数 * 门票价格。成本 = 中奖奖品成本总和。
// @Tags 管理端.直播间
// @Accept json
// @Produce json
// @Param id path integer true "活动ID"
// @Success 200 {object} livestreamStatsResponse
// @Failure 400 {object} code.Failure
// @Router /api/admin/livestream/activities/{id}/stats [get]
// @Security LoginVerifyToken
func (h *handler) GetLivestreamStats() core.HandlerFunc {
return func(ctx core.Context) {
id, err := strconv.ParseInt(ctx.Param("id"), 10, 64)
if err != nil || id <= 0 {
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, "无效的活动ID"))
return
}
req := new(struct {
StartTime string `form:"start_time"`
EndTime string `form:"end_time"`
})
_ = ctx.ShouldBindQuery(req)
var startTime, endTime *time.Time
if req.StartTime != "" {
if t, err := time.ParseInLocation("2006-01-02", req.StartTime, time.Local); err == nil {
startTime = &t
}
}
if req.EndTime != "" {
if t, err := time.ParseInLocation("2006-01-02", req.EndTime, time.Local); err == nil {
end := t.Add(24*time.Hour - time.Nanosecond)
endTime = &end
}
}
// 1. 获取活动信息(门票价格)
var activity model.LivestreamActivities
if err := h.repo.GetDbR().Where("id = ?", id).First(&activity).Error; err != nil {
ctx.AbortWithError(core.Error(http.StatusNotFound, code.ServerError, "活动不存在"))
return
}
// ticketPrice 暂未使用,但在统计中可能作为参考,这里移除未使用的报错
// 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 {
queryRevenue += " AND l.created_at >= '" + startTime.Format("2006-01-02 15:04:05") + "'"
}
if endTime != nil {
queryRevenue += " AND l.created_at <= '" + endTime.Format("2006-01-02 15:04:05") + "'"
}
queryRevenue += ") as distinct_orders"
_ = h.repo.GetDbR().Raw(queryRevenue, id).Row().Scan(&totalRevenue, &orderCount)
// 统计退款:来自已参与过抽奖且标记为退款的订单 (order_status = 4)
var totalRefund, refundCount int64
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)
// 3. 获取所有抽奖记录用于成本计算
var drawLogs []model.LivestreamDrawLogs
db := h.repo.GetDbR().Where("activity_id = ?", id)
if startTime != nil {
db = db.Where("created_at >= ?", startTime)
}
if endTime != nil {
db = db.Where("created_at <= ?", endTime)
}
db.Find(&drawLogs)
// 3.1 获取该时间段内所有退款的 shop_order_id 集合,用于过滤成本
refundedShopOrderIDs := make(map[string]bool)
var refundedOrders []string
qRefundIDs := `
SELECT DISTINCT o.shop_order_id
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 {
qRefundIDs += " AND l.created_at >= '" + startTime.Format("2006-01-02 15:04:05") + "'"
}
if endTime != nil {
qRefundIDs += " AND l.created_at <= '" + endTime.Format("2006-01-02 15:04:05") + "'"
}
h.repo.GetDbR().Raw(qRefundIDs, id).Scan(&refundedOrders)
for _, oid := range refundedOrders {
refundedShopOrderIDs[oid] = true
}
// 4. 计算成本(使用 draw_logs.product_id 直接关联 products 表)
// 收集未退款订单的 product_id 和对应数量
productIDCountMap := make(map[int64]int64)
for _, log := range drawLogs {
// 排除已退款的订单
if refundedShopOrderIDs[log.ShopOrderID] {
continue
}
// 使用 draw_logs 中记录的 product_id
if log.ProductID > 0 {
productIDCountMap[log.ProductID]++
}
}
var totalCost int64
productCostMap := make(map[int64]int64)
if len(productIDCountMap) > 0 {
productIDs := make([]int64, 0, len(productIDCountMap))
for pid := range productIDCountMap {
productIDs = append(productIDs, pid)
}
var products []model.Products
h.repo.GetDbR().Where("id IN ?", productIDs).Find(&products)
for _, p := range products {
productCostMap[p.ID] = p.Price
}
for productID, count := range productIDCountMap {
if cost, ok := productCostMap[productID]; ok {
totalCost += cost * count
}
}
}
// 构建 productID -> cost 映射供每日统计使用
prizeCostMap := productCostMap
// 5. 按天分组统计
dailyMap := make(map[string]*dailyLivestreamStats)
// 5.1 统计每日营收和退款(直接累加订单实付金额)
type DailyAmount struct {
DateKey string
Amount int64
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
}
if log.ProductID <= 0 {
continue
}
dateKey := log.CreatedAt.Format("2006-01-02")
ds := dailyMap[dateKey]
if ds != nil {
if cost, ok := prizeCostMap[log.ProductID]; ok {
ds.TotalCost += cost
}
}
}
// 6. 汇总每日数据并计算总体指标
var calcTotalRevenue, calcTotalRefund, calcTotalCost int64
dailyList := make([]dailyLivestreamStats, 0, len(dailyMap))
for _, ds := range dailyMap {
ds.NetProfit = (ds.TotalRevenue - ds.TotalRefund) - ds.TotalCost
netRev := ds.TotalRevenue - ds.TotalRefund
if netRev > 0 {
ds.ProfitMargin = math.Trunc(float64(ds.NetProfit)/float64(netRev)*10000) / 100
} else if netRev == 0 && ds.TotalCost > 0 {
ds.ProfitMargin = -100
}
dailyList = append(dailyList, *ds)
calcTotalRevenue += ds.TotalRevenue
calcTotalRefund += ds.TotalRefund
calcTotalCost += ds.TotalCost
}
netProfit := (totalRevenue - totalRefund) - totalCost
var margin float64
netRevenue := totalRevenue - totalRefund
if netRevenue > 0 {
margin = float64(netProfit) / float64(netRevenue) * 100
} else if netRevenue == 0 && totalCost > 0 {
margin = -100
} else {
margin = 0
}
ctx.Payload(&livestreamStatsResponse{
TotalRevenue: totalRevenue,
TotalRefund: totalRefund,
TotalCost: totalCost,
NetProfit: netProfit,
OrderCount: orderCount,
RefundCount: refundCount,
ProfitMargin: math.Trunc(margin*100) / 100,
Daily: dailyList,
})
}
}