334 lines
11 KiB
Go
334 lines
11 KiB
Go
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. 计算成本(只统计未退款订单的奖品成本)
|
||
prizeIDCountMap := make(map[int64]int64)
|
||
for _, log := range drawLogs {
|
||
// 排除已退款的订单 (检查 douyin_orders 状态)
|
||
if refundedShopOrderIDs[log.ShopOrderID] {
|
||
continue
|
||
}
|
||
prizeIDCountMap[log.PrizeID]++
|
||
}
|
||
|
||
prizeIDs := make([]int64, 0, len(prizeIDCountMap))
|
||
for pid := range prizeIDCountMap {
|
||
prizeIDs = append(prizeIDs, pid)
|
||
}
|
||
|
||
var totalCost int64
|
||
prizeCostMap := make(map[int64]int64)
|
||
if len(prizeIDs) > 0 {
|
||
var prizes []model.LivestreamPrizes
|
||
h.repo.GetDbR().Where("id IN ?", prizeIDs).Find(&prizes)
|
||
|
||
productIDsNeedingFallback := make([]int64, 0)
|
||
prizeProductMap := make(map[int64]int64)
|
||
|
||
for _, p := range prizes {
|
||
if p.CostPrice > 0 {
|
||
prizeCostMap[p.ID] = p.CostPrice
|
||
} else if p.ProductID > 0 {
|
||
productIDsNeedingFallback = append(productIDsNeedingFallback, p.ProductID)
|
||
prizeProductMap[p.ID] = p.ProductID
|
||
}
|
||
}
|
||
|
||
if len(productIDsNeedingFallback) > 0 {
|
||
var products []model.Products
|
||
h.repo.GetDbR().Where("id IN ?", productIDsNeedingFallback).Find(&products)
|
||
productPriceMap := make(map[int64]int64)
|
||
for _, prod := range products {
|
||
productPriceMap[prod.ID] = prod.Price
|
||
}
|
||
for prizeID, productID := range prizeProductMap {
|
||
if _, ok := prizeCostMap[prizeID]; !ok {
|
||
if price, found := productPriceMap[productID]; found {
|
||
prizeCostMap[prizeID] = price
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
for prizeID, count := range prizeIDCountMap {
|
||
if cost, ok := prizeCostMap[prizeID]; ok {
|
||
totalCost += cost * count
|
||
}
|
||
}
|
||
}
|
||
|
||
// 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)
|
||
for _, log := range drawLogs {
|
||
// 排除退款订单
|
||
if refundedShopOrderIDs[log.ShopOrderID] {
|
||
continue
|
||
}
|
||
dateKey := log.CreatedAt.Format("2006-01-02")
|
||
ds := dailyMap[dateKey]
|
||
if ds != nil {
|
||
if cost, ok := prizeCostMap[log.PrizeID]; 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,
|
||
})
|
||
}
|
||
}
|