353 lines
11 KiB
Go
Executable File
353 lines
11 KiB
Go
Executable File
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,
|
||
})
|
||
}
|
||
}
|