bindbox-game/internal/api/admin/livestream_stats.go

353 lines
11 KiB
Go
Executable File
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package admin
import (
"math"
"net/http"
"strconv"
"strings"
"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 := 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")
if startTime != nil {
orderQuery = orderQuery.Where("created_at >= ?", startTime)
}
if endTime != nil {
orderQuery = orderQuery.Where("created_at <= ?", endTime)
}
var orderRefs []orderRef
if err := orderQuery.Group("douyin_order_id").Scan(&orderRefs).Error; err != nil {
ctx.AbortWithError(core.Error(http.StatusInternalServerError, code.ServerError, err.Error()))
return
}
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. 获取所有抽奖记录用于成本计算
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 集合,用于过滤成本
// 4. 计算成本(优先资产快照 user_inventory.value_cents缺失回退 livestream_prizes.cost_price
prizeCostMap := make(map[int64]int64)
prizeIDs := make([]int64, 0)
prizeIDSet := make(map[int64]struct{})
userIDSet := make(map[int64]struct{})
for _, log := range drawLogs {
if log.PrizeID > 0 {
if _, ok := prizeIDSet[log.PrizeID]; !ok {
prizeIDSet[log.PrizeID] = struct{}{}
prizeIDs = append(prizeIDs, log.PrizeID)
}
}
if log.LocalUserID > 0 {
userIDSet[log.LocalUserID] = struct{}{}
}
}
if len(prizeIDs) > 0 {
var prizes []model.LivestreamPrizes
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 {
if refundedShopOrderIDs[log.ShopOrderID] {
continue
}
key := strconv.FormatInt(log.LocalUserID, 10) + "|" + log.ShopOrderID
logsByKey[key] = append(logsByKey[key], logRef{
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
for key, refs := range logsByKey {
if len(refs) == 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
}
}
}
if snapshotSum > 0 {
avg := snapshotSum / int64(len(refs))
rem := snapshotSum - avg*int64(len(refs))
for i, r := range refs {
c := avg
if i == 0 {
c += rem
}
totalCost += c
costByDate[r.DateKey] += c
}
continue
}
for _, r := range refs {
c := prizeCostMap[r.PrizeID]
totalCost += c
costByDate[r.DateKey] += c
}
}
// 5. 按天分组统计 (营收/退款已在 dailyMap 中累计),补充成本
for dateKey, c := range costByDate {
ds := dailyMap[dateKey]
if ds == nil {
ds = &dailyLivestreamStats{Date: dateKey}
dailyMap[dateKey] = ds
}
ds.TotalCost += c
}
// 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,
})
}
}