fix(livestream): 统一直播间盈亏成本口径并消除转赠影响
将直播间统计从基于 user_inventory 当前持有状态和 remark 反推成本,改为基于 livestream_draw_logs 中奖事实直接关联 products.cost_price 计算成本。统一 /livestream/activities/:id/stats 与 /livestream/activities/:id/draw_logs 两个接口的营收、退款、成本和净利润口径,避免因转赠、remark 覆盖或订单行缺失导致统计失真,并补充针对转赠、退款、零订单和 product 回退场景的回归测试。
This commit is contained in:
parent
fb4b266bac
commit
c927f46cdd
@ -3,10 +3,8 @@ package admin
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"bindbox-game/internal/code"
|
||||
@ -783,38 +781,9 @@ func (h *handler) ListLivestreamDrawLogs() core.HandlerFunc {
|
||||
pageSize = 20
|
||||
}
|
||||
|
||||
// 解析时间范围 (支持 YYYY-MM-DD HH:mm:ss 和 YYYY-MM-DD)
|
||||
var startTime, endTime *time.Time
|
||||
if req.StartTime != "" {
|
||||
// 尝试解析完整时间
|
||||
if t, err := time.ParseInLocation("2006-01-02 15:04:05", req.StartTime, time.Local); err == nil {
|
||||
startTime = &t
|
||||
} else if t, err := time.ParseInLocation("2006-01-02", req.StartTime, time.Local); err == nil {
|
||||
// 只有日期,默认 00:00:00
|
||||
startTime = &t
|
||||
}
|
||||
}
|
||||
if req.EndTime != "" {
|
||||
if t, err := time.ParseInLocation("2006-01-02 15:04:05", req.EndTime, time.Local); err == nil {
|
||||
endTime = &t
|
||||
} else if t, err := time.ParseInLocation("2006-01-02", req.EndTime, time.Local); err == nil {
|
||||
// 只有日期,设为当天结束 23:59:59.999
|
||||
end := t.Add(24*time.Hour - time.Nanosecond)
|
||||
endTime = &end
|
||||
}
|
||||
}
|
||||
startTime, endTime := parseLivestreamDateRange(req.StartTime, req.EndTime, true)
|
||||
|
||||
// 解析排除用户ID
|
||||
var excludeUIDs []int64
|
||||
if req.ExcludeUserIDs != "" {
|
||||
parts := strings.Split(req.ExcludeUserIDs, ",")
|
||||
for _, p := range parts {
|
||||
p = strings.TrimSpace(p)
|
||||
if val, err := strconv.ParseInt(p, 10, 64); err == nil && val > 0 {
|
||||
excludeUIDs = append(excludeUIDs, val)
|
||||
}
|
||||
}
|
||||
}
|
||||
excludeUIDs := parseExcludeUserIDs(req.ExcludeUserIDs)
|
||||
|
||||
// 使用底层 GORM 直接查询以支持 keyword
|
||||
db := h.repo.GetDbR().Model(&model.LivestreamDrawLogs{}).Where("activity_id = ?", activityID)
|
||||
@ -839,135 +808,25 @@ func (h *handler) ListLivestreamDrawLogs() core.HandlerFunc {
|
||||
// 计算统计数据 (仅当有数据时)
|
||||
var stats *livestreamDrawLogsStats
|
||||
if total > 0 {
|
||||
stats = &livestreamDrawLogsStats{}
|
||||
// 1. 统计用户数
|
||||
// 使用 Session() 避免污染主 db 对象
|
||||
db.Session(&gorm.Session{}).Select("COUNT(DISTINCT douyin_user_id)").Scan(&stats.UserCount)
|
||||
|
||||
// 2. 获取所有相关的 douyin_order_id 和 prize_id,用于在内存中聚合金额和成本
|
||||
// 注意:如果数据量极大,这里可能有性能隐患。但考虑到这是后台查询且通常带有筛选,暂且全量拉取 ID。
|
||||
// 优化:只查需要的字段
|
||||
type logMeta struct {
|
||||
DouyinOrderID int64
|
||||
PrizeID int64
|
||||
ShopOrderID string // 用于关联退款状态查 douyin_orders
|
||||
LocalUserID int64
|
||||
metrics, err := h.buildLivestreamMetrics(livestreamMetricsFilter{
|
||||
ActivityID: activityID,
|
||||
StartTime: startTime,
|
||||
EndTime: endTime,
|
||||
Keyword: req.Keyword,
|
||||
ExcludeUserIDs: excludeUIDs,
|
||||
}, ticketPrice)
|
||||
if err != nil {
|
||||
ctx.AbortWithError(core.Error(http.StatusInternalServerError, code.ServerError, err.Error()))
|
||||
return
|
||||
}
|
||||
var metas []logMeta
|
||||
// 使用不带分页的 db 克隆
|
||||
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))
|
||||
distinctOrderIDs := make(map[int64]bool)
|
||||
prizeIDCount := make(map[int64]int64)
|
||||
|
||||
for _, m := range metas {
|
||||
if !distinctOrderIDs[m.DouyinOrderID] {
|
||||
distinctOrderIDs[m.DouyinOrderID] = true
|
||||
orderIDs = append(orderIDs, m.DouyinOrderID)
|
||||
}
|
||||
}
|
||||
stats.OrderCount = int64(len(orderIDs))
|
||||
|
||||
// 3. 查询订单金额和退款状态
|
||||
if len(orderIDs) > 0 {
|
||||
var orders []model.DouyinOrders
|
||||
// 分批查询防止 IN 子句过长? 暂时假设量级可控
|
||||
h.repo.GetDbR().Select("id, actual_pay_amount, order_status, pay_type_desc, product_count").
|
||||
Where("id IN ?", orderIDs).Find(&orders)
|
||||
|
||||
orderRefundMap := make(map[int64]bool)
|
||||
|
||||
for _, o := range orders {
|
||||
// 统计营收 (总流水)
|
||||
orderAmount := calcLivestreamOrderAmount(&o, ticketPrice)
|
||||
stats.TotalRev += orderAmount
|
||||
|
||||
if o.OrderStatus == 4 { // 已退款
|
||||
stats.TotalRefund += orderAmount
|
||||
orderRefundMap[o.ID] = true
|
||||
}
|
||||
}
|
||||
|
||||
// 4. 统计成本 (剔除退款订单,优先资产快照,缺失回退 prize.cost_price)
|
||||
for _, m := range metas {
|
||||
if !orderRefundMap[m.DouyinOrderID] {
|
||||
prizeIDCount[m.PrizeID]++
|
||||
}
|
||||
}
|
||||
|
||||
prizeCostMap := make(map[int64]int64)
|
||||
if len(prizeIDCount) > 0 {
|
||||
prizeIDs := make([]int64, 0, len(prizeIDCount))
|
||||
for pid := range prizeIDCount {
|
||||
prizeIDs = append(prizeIDs, pid)
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
// 预加载用户资产快照用于 shop_order_id 命中
|
||||
type invRow struct {
|
||||
UserID int64
|
||||
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("user_inventory.status IN (1,3)").
|
||||
Where("COALESCE(user_inventory.remark, '') NOT LIKE ?", "%void%").
|
||||
Where("user_inventory.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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if snapshotSum > 0 {
|
||||
stats.TotalCost += snapshotSum
|
||||
continue
|
||||
}
|
||||
|
||||
for _, r := range rows {
|
||||
stats.TotalCost += prizeCostMap[r.PrizeID]
|
||||
}
|
||||
}
|
||||
}
|
||||
stats = &livestreamDrawLogsStats{
|
||||
UserCount: metrics.UserCount,
|
||||
OrderCount: metrics.OrderCount,
|
||||
TotalRev: metrics.TotalRevenue,
|
||||
TotalRefund: metrics.TotalRefund,
|
||||
TotalCost: metrics.TotalCost,
|
||||
NetProfit: metrics.NetProfit,
|
||||
}
|
||||
stats.NetProfit = (stats.TotalRev - stats.TotalRefund) - stats.TotalCost
|
||||
}
|
||||
|
||||
var logs []model.LivestreamDrawLogs
|
||||
|
||||
335
internal/api/admin/livestream_metrics.go
Normal file
335
internal/api/admin/livestream_metrics.go
Normal file
@ -0,0 +1,335 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"bindbox-game/internal/repository/mysql/model"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type livestreamMetricsFilter struct {
|
||||
ActivityID int64
|
||||
StartTime *time.Time
|
||||
EndTime *time.Time
|
||||
Keyword string
|
||||
ExcludeUserIDs []int64
|
||||
}
|
||||
|
||||
type livestreamMetrics struct {
|
||||
UserCount int64
|
||||
OrderCount int64
|
||||
RefundCount int64
|
||||
TotalRevenue int64
|
||||
TotalRefund int64
|
||||
TotalCost int64
|
||||
NetProfit int64
|
||||
ProfitMargin float64
|
||||
Daily []dailyLivestreamStats
|
||||
}
|
||||
|
||||
type livestreamOrderSummary struct {
|
||||
UserCount int64
|
||||
OrderCount int64
|
||||
RefundCount int64
|
||||
TotalRevenue int64
|
||||
TotalRefund int64
|
||||
RefundedOrderIDs map[int64]bool
|
||||
RevenueByDate map[string]int64
|
||||
RefundByDate map[string]int64
|
||||
OrderCountByDate map[string]int64
|
||||
RefundCountByDate map[string]int64
|
||||
}
|
||||
|
||||
type livestreamCostSummary struct {
|
||||
TotalCost int64
|
||||
CostByDate map[string]int64
|
||||
}
|
||||
|
||||
func (h *handler) buildLivestreamDrawLogScope(db *gorm.DB, f livestreamMetricsFilter) *gorm.DB {
|
||||
q := db.Table("livestream_draw_logs AS dl").Where("dl.activity_id = ?", f.ActivityID)
|
||||
if f.StartTime != nil {
|
||||
q = q.Where("dl.created_at >= ?", f.StartTime)
|
||||
}
|
||||
if f.EndTime != nil {
|
||||
q = q.Where("dl.created_at <= ?", f.EndTime)
|
||||
}
|
||||
if keyword := strings.TrimSpace(f.Keyword); keyword != "" {
|
||||
kw := "%" + keyword + "%"
|
||||
q = q.Where("(dl.user_nickname LIKE ? OR dl.shop_order_id LIKE ? OR dl.prize_name LIKE ?)", kw, kw, kw)
|
||||
}
|
||||
if len(f.ExcludeUserIDs) > 0 {
|
||||
q = q.Where("dl.local_user_id NOT IN ?", f.ExcludeUserIDs)
|
||||
}
|
||||
return q
|
||||
}
|
||||
|
||||
func (h *handler) buildLivestreamMetrics(f livestreamMetricsFilter, ticketPrice int64) (*livestreamMetrics, error) {
|
||||
scope := h.buildLivestreamDrawLogScope(h.repo.GetDbR(), f)
|
||||
orderSummary, err := h.loadLivestreamOrderSummary(scope, ticketPrice)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
costSummary, err := h.loadLivestreamCostSummary(scope, orderSummary.RefundedOrderIDs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
dailyMap := make(map[string]*dailyLivestreamStats)
|
||||
mergeDailyAmount := func(source map[string]int64, apply func(*dailyLivestreamStats, int64)) {
|
||||
for dateKey, value := range source {
|
||||
ds := dailyMap[dateKey]
|
||||
if ds == nil {
|
||||
ds = &dailyLivestreamStats{Date: dateKey}
|
||||
dailyMap[dateKey] = ds
|
||||
}
|
||||
apply(ds, value)
|
||||
}
|
||||
}
|
||||
mergeDailyCount := func(source map[string]int64, apply func(*dailyLivestreamStats, int64)) {
|
||||
for dateKey, value := range source {
|
||||
ds := dailyMap[dateKey]
|
||||
if ds == nil {
|
||||
ds = &dailyLivestreamStats{Date: dateKey}
|
||||
dailyMap[dateKey] = ds
|
||||
}
|
||||
apply(ds, value)
|
||||
}
|
||||
}
|
||||
|
||||
mergeDailyAmount(orderSummary.RevenueByDate, func(ds *dailyLivestreamStats, v int64) { ds.TotalRevenue += v })
|
||||
mergeDailyAmount(orderSummary.RefundByDate, func(ds *dailyLivestreamStats, v int64) { ds.TotalRefund += v })
|
||||
mergeDailyAmount(costSummary.CostByDate, func(ds *dailyLivestreamStats, v int64) { ds.TotalCost += v })
|
||||
mergeDailyCount(orderSummary.OrderCountByDate, func(ds *dailyLivestreamStats, v int64) { ds.OrderCount += v })
|
||||
mergeDailyCount(orderSummary.RefundCountByDate, func(ds *dailyLivestreamStats, v int64) { ds.RefundCount += v })
|
||||
|
||||
dailyList := make([]dailyLivestreamStats, 0, len(dailyMap))
|
||||
for _, ds := range dailyMap {
|
||||
ds.NetProfit = (ds.TotalRevenue - ds.TotalRefund) - ds.TotalCost
|
||||
netRevenue := ds.TotalRevenue - ds.TotalRefund
|
||||
if netRevenue > 0 {
|
||||
ds.ProfitMargin = math.Trunc(float64(ds.NetProfit)/float64(netRevenue)*10000) / 100
|
||||
} else if netRevenue == 0 && ds.TotalCost > 0 {
|
||||
ds.ProfitMargin = -100
|
||||
}
|
||||
dailyList = append(dailyList, *ds)
|
||||
}
|
||||
sort.Slice(dailyList, func(i, j int) bool { return dailyList[i].Date > dailyList[j].Date })
|
||||
|
||||
netProfit := (orderSummary.TotalRevenue - orderSummary.TotalRefund) - costSummary.TotalCost
|
||||
netRevenue := orderSummary.TotalRevenue - orderSummary.TotalRefund
|
||||
margin := 0.0
|
||||
if netRevenue > 0 {
|
||||
margin = math.Trunc(float64(netProfit)/float64(netRevenue)*10000) / 100
|
||||
} else if netRevenue == 0 && costSummary.TotalCost > 0 {
|
||||
margin = -100
|
||||
}
|
||||
|
||||
return &livestreamMetrics{
|
||||
UserCount: orderSummary.UserCount,
|
||||
OrderCount: orderSummary.OrderCount,
|
||||
RefundCount: orderSummary.RefundCount,
|
||||
TotalRevenue: orderSummary.TotalRevenue,
|
||||
TotalRefund: orderSummary.TotalRefund,
|
||||
TotalCost: costSummary.TotalCost,
|
||||
NetProfit: netProfit,
|
||||
ProfitMargin: margin,
|
||||
Daily: dailyList,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (h *handler) countLivestreamUsers(scope *gorm.DB) (int64, error) {
|
||||
type userRef struct {
|
||||
DouyinUserID string `gorm:"column:douyin_user_id"`
|
||||
LocalUserID int64 `gorm:"column:local_user_id"`
|
||||
}
|
||||
|
||||
var refs []userRef
|
||||
if err := scope.Session(&gorm.Session{}).
|
||||
Select("douyin_user_id, local_user_id").
|
||||
Scan(&refs).Error; err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
seen := make(map[string]struct{}, len(refs))
|
||||
for _, ref := range refs {
|
||||
if id := strings.TrimSpace(ref.DouyinUserID); id != "" {
|
||||
seen["dy:"+id] = struct{}{}
|
||||
continue
|
||||
}
|
||||
if ref.LocalUserID > 0 {
|
||||
seen[fmt.Sprintf("local:%d", ref.LocalUserID)] = struct{}{}
|
||||
}
|
||||
}
|
||||
return int64(len(seen)), nil
|
||||
}
|
||||
|
||||
func (h *handler) loadLivestreamOrderSummary(scope *gorm.DB, ticketPrice int64) (*livestreamOrderSummary, error) {
|
||||
type orderRef struct {
|
||||
OrderID int64 `gorm:"column:order_id"`
|
||||
FirstDrawAtRaw string `gorm:"column:first_draw_at"`
|
||||
}
|
||||
var orderRefs []orderRef
|
||||
if err := scope.Session(&gorm.Session{}).
|
||||
Select("dl.douyin_order_id AS order_id, MIN(dl.created_at) AS first_draw_at").
|
||||
Where("dl.douyin_order_id > 0").
|
||||
Group("dl.douyin_order_id").
|
||||
Scan(&orderRefs).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
result := &livestreamOrderSummary{
|
||||
RefundedOrderIDs: make(map[int64]bool),
|
||||
RevenueByDate: make(map[string]int64),
|
||||
RefundByDate: make(map[string]int64),
|
||||
OrderCountByDate: make(map[string]int64),
|
||||
RefundCountByDate: make(map[string]int64),
|
||||
}
|
||||
|
||||
userCount, err := h.countLivestreamUsers(scope)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result.UserCount = userCount
|
||||
|
||||
orderIDs := make([]int64, 0, len(orderRefs))
|
||||
for _, ref := range orderRefs {
|
||||
if ref.OrderID > 0 {
|
||||
orderIDs = append(orderIDs, ref.OrderID)
|
||||
}
|
||||
}
|
||||
if len(orderIDs) == 0 {
|
||||
return result, nil
|
||||
}
|
||||
|
||||
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 {
|
||||
return nil, err
|
||||
}
|
||||
orderMap := make(map[int64]*model.DouyinOrders, len(orders))
|
||||
for i := range orders {
|
||||
orderMap[orders[i].ID] = &orders[i]
|
||||
}
|
||||
|
||||
for _, ref := range orderRefs {
|
||||
order := orderMap[ref.OrderID]
|
||||
if order == nil {
|
||||
continue
|
||||
}
|
||||
amount := calcLivestreamOrderAmount(order, ticketPrice)
|
||||
if amount < 0 {
|
||||
amount = 0
|
||||
}
|
||||
dateKey := time.Now().In(time.Local).Format("2006-01-02")
|
||||
if ref.FirstDrawAtRaw != "" {
|
||||
if parsed, err := time.ParseInLocation("2006-01-02 15:04:05.999", ref.FirstDrawAtRaw, time.Local); err == nil {
|
||||
dateKey = parsed.In(time.Local).Format("2006-01-02")
|
||||
} else if parsed, err := time.ParseInLocation("2006-01-02 15:04:05", ref.FirstDrawAtRaw, time.Local); err == nil {
|
||||
dateKey = parsed.In(time.Local).Format("2006-01-02")
|
||||
} else if parsed, err := time.ParseInLocation(time.RFC3339Nano, ref.FirstDrawAtRaw, time.Local); err == nil {
|
||||
dateKey = parsed.In(time.Local).Format("2006-01-02")
|
||||
}
|
||||
}
|
||||
result.OrderCount++
|
||||
result.TotalRevenue += amount
|
||||
result.RevenueByDate[dateKey] += amount
|
||||
result.OrderCountByDate[dateKey]++
|
||||
if order.OrderStatus == 4 {
|
||||
result.RefundCount++
|
||||
result.TotalRefund += amount
|
||||
result.RefundedOrderIDs[order.ID] = true
|
||||
result.RefundByDate[dateKey] += amount
|
||||
result.RefundCountByDate[dateKey]++
|
||||
}
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (h *handler) loadLivestreamCostSummary(scope *gorm.DB, refundedOrderIDs map[int64]bool) (*livestreamCostSummary, error) {
|
||||
type costRow struct {
|
||||
DouyinOrderID int64 `gorm:"column:douyin_order_id"`
|
||||
CreatedAt time.Time `gorm:"column:created_at"`
|
||||
IsRefunded int32 `gorm:"column:is_refunded"`
|
||||
UnitCost int64 `gorm:"column:unit_cost"`
|
||||
}
|
||||
var rows []costRow
|
||||
if err := scope.Session(&gorm.Session{}).
|
||||
Table("livestream_draw_logs AS dl").
|
||||
Select(`
|
||||
dl.douyin_order_id,
|
||||
dl.created_at,
|
||||
dl.is_refunded,
|
||||
COALESCE(p.cost_price, 0) AS unit_cost
|
||||
`).
|
||||
Joins("LEFT JOIN livestream_prizes lp ON lp.id = dl.prize_id").
|
||||
Joins("LEFT JOIN products p ON p.id = COALESCE(NULLIF(dl.product_id, 0), lp.product_id)").
|
||||
Scan(&rows).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
result := &livestreamCostSummary{CostByDate: make(map[string]int64)}
|
||||
for _, row := range rows {
|
||||
if row.IsRefunded == 1 || refundedOrderIDs[row.DouyinOrderID] {
|
||||
continue
|
||||
}
|
||||
result.TotalCost += row.UnitCost
|
||||
result.CostByDate[row.CreatedAt.In(time.Local).Format("2006-01-02")] += row.UnitCost
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func parseLivestreamDateRange(startRaw, endRaw string, withTime bool) (*time.Time, *time.Time) {
|
||||
var startTime, endTime *time.Time
|
||||
if startRaw != "" {
|
||||
if withTime {
|
||||
if t, err := time.ParseInLocation("2006-01-02 15:04:05", startRaw, time.Local); err == nil {
|
||||
startTime = &t
|
||||
} else if t, err := time.ParseInLocation("2006-01-02", startRaw, time.Local); err == nil {
|
||||
startTime = &t
|
||||
}
|
||||
} else if t, err := time.ParseInLocation("2006-01-02", startRaw, time.Local); err == nil {
|
||||
startTime = &t
|
||||
}
|
||||
}
|
||||
if endRaw != "" {
|
||||
if withTime {
|
||||
if t, err := time.ParseInLocation("2006-01-02 15:04:05", endRaw, time.Local); err == nil {
|
||||
endTime = &t
|
||||
} else if t, err := time.ParseInLocation("2006-01-02", endRaw, time.Local); err == nil {
|
||||
end := t.Add(24*time.Hour - time.Nanosecond)
|
||||
endTime = &end
|
||||
}
|
||||
} else if t, err := time.ParseInLocation("2006-01-02", endRaw, time.Local); err == nil {
|
||||
end := t.Add(24*time.Hour - time.Nanosecond)
|
||||
endTime = &end
|
||||
}
|
||||
}
|
||||
return startTime, endTime
|
||||
}
|
||||
|
||||
func parseExcludeUserIDs(raw string) []int64 {
|
||||
if strings.TrimSpace(raw) == "" {
|
||||
return nil
|
||||
}
|
||||
parts := strings.Split(raw, ",")
|
||||
ids := make([]int64, 0, len(parts))
|
||||
for _, part := range parts {
|
||||
part = strings.TrimSpace(part)
|
||||
if part == "" {
|
||||
continue
|
||||
}
|
||||
if id, err := strconv.ParseInt(part, 10, 64); err == nil && id > 0 {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
}
|
||||
return ids
|
||||
}
|
||||
420
internal/api/admin/livestream_metrics_test.go
Normal file
420
internal/api/admin/livestream_metrics_test.go
Normal file
@ -0,0 +1,420 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"bindbox-game/internal/pkg/logger"
|
||||
"bindbox-game/internal/repository/mysql"
|
||||
)
|
||||
|
||||
func TestBuildLivestreamMetrics_UsesProductCostAndIgnoresTransfers(t *testing.T) {
|
||||
repo, err := mysql.NewSQLiteRepoForTest()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
ddls := []string{
|
||||
`CREATE TABLE livestream_activities (id INTEGER PRIMARY KEY, ticket_price INTEGER)`,
|
||||
`CREATE TABLE douyin_orders (id INTEGER PRIMARY KEY, shop_order_id TEXT, actual_pay_amount INTEGER, order_status INTEGER, pay_type_desc TEXT, product_count INTEGER)`,
|
||||
`CREATE TABLE livestream_prizes (id INTEGER PRIMARY KEY, activity_id INTEGER, product_id INTEGER, cost_price INTEGER)`,
|
||||
`CREATE TABLE products (id INTEGER PRIMARY KEY, cost_price INTEGER)`,
|
||||
`CREATE TABLE livestream_draw_logs (
|
||||
id INTEGER PRIMARY KEY,
|
||||
activity_id INTEGER,
|
||||
prize_id INTEGER,
|
||||
douyin_order_id INTEGER,
|
||||
shop_order_id TEXT,
|
||||
local_user_id INTEGER,
|
||||
douyin_user_id TEXT,
|
||||
product_id INTEGER,
|
||||
prize_name TEXT,
|
||||
user_nickname TEXT,
|
||||
created_at DATETIME,
|
||||
is_refunded INTEGER
|
||||
)`,
|
||||
`CREATE TABLE user_inventory (
|
||||
id INTEGER PRIMARY KEY,
|
||||
user_id INTEGER,
|
||||
reward_id INTEGER,
|
||||
product_id INTEGER,
|
||||
status INTEGER,
|
||||
value_cents INTEGER,
|
||||
remark TEXT,
|
||||
created_at DATETIME
|
||||
)`,
|
||||
}
|
||||
for _, ddl := range ddls {
|
||||
if err := repo.GetDbW().Exec(ddl).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
sqls := []string{
|
||||
`INSERT INTO livestream_activities (id, ticket_price) VALUES (1, 990)`,
|
||||
`INSERT INTO products (id, cost_price) VALUES (101, 500), (102, 700)`,
|
||||
`INSERT INTO livestream_prizes (id, activity_id, product_id, cost_price) VALUES (11, 1, 101, 9999), (12, 1, 102, 8888)`,
|
||||
`INSERT INTO douyin_orders (id, shop_order_id, actual_pay_amount, order_status, pay_type_desc, product_count) VALUES (201, 'SO-1', 990, 2, '微信支付', 1)`,
|
||||
`INSERT INTO livestream_draw_logs (id, activity_id, prize_id, douyin_order_id, shop_order_id, local_user_id, product_id, prize_name, user_nickname, created_at, is_refunded) VALUES
|
||||
(301, 1, 11, 201, 'SO-1', 9001, 101, 'P1', 'U1', '2026-04-01 10:00:00', 0),
|
||||
(302, 1, 12, 201, 'SO-1', 9001, 102, 'P2', 'U1', '2026-04-01 10:01:00', 0)`,
|
||||
`INSERT INTO user_inventory (id, user_id, reward_id, product_id, status, value_cents, remark, created_at) VALUES
|
||||
(401, 9999, 0, 101, 3, 999999, 'transferred_from_9001|shipping_requested', '2026-04-01 11:00:00'),
|
||||
(402, 9999, 0, 102, 3, 999999, 'transferred_from_9001|shipping_requested', '2026-04-01 11:01:00')`,
|
||||
}
|
||||
for _, sql := range sqls {
|
||||
if err := repo.GetDbW().Exec(sql).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
lg, err := logger.NewCustomLogger(logger.WithOutputInConsole())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
h := &handler{logger: lg, repo: repo}
|
||||
start := mustParseDateTime(t, "2026-04-01 00:00:00")
|
||||
end := mustParseDateTime(t, "2026-04-01 23:59:59")
|
||||
metrics, err := h.buildLivestreamMetrics(livestreamMetricsFilter{ActivityID: 1, StartTime: &start, EndTime: &end}, 990)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if metrics.TotalCost != 1200 {
|
||||
t.Fatalf("TotalCost=%d want 1200", metrics.TotalCost)
|
||||
}
|
||||
if metrics.TotalRevenue != 990 {
|
||||
t.Fatalf("TotalRevenue=%d want 990", metrics.TotalRevenue)
|
||||
}
|
||||
if metrics.NetProfit != -210 {
|
||||
t.Fatalf("NetProfit=%d want -210", metrics.NetProfit)
|
||||
}
|
||||
if len(metrics.Daily) != 1 || metrics.Daily[0].TotalCost != 1200 {
|
||||
t.Fatalf("unexpected daily=%+v", metrics.Daily)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildLivestreamMetrics_ExcludesRefundedOrdersFromCost(t *testing.T) {
|
||||
repo, err := mysql.NewSQLiteRepoForTest()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
ddls := []string{
|
||||
`CREATE TABLE livestream_activities (id INTEGER PRIMARY KEY, ticket_price INTEGER)`,
|
||||
`CREATE TABLE douyin_orders (id INTEGER PRIMARY KEY, shop_order_id TEXT, actual_pay_amount INTEGER, order_status INTEGER, pay_type_desc TEXT, product_count INTEGER)`,
|
||||
`CREATE TABLE livestream_prizes (id INTEGER PRIMARY KEY, activity_id INTEGER, product_id INTEGER, cost_price INTEGER)`,
|
||||
`CREATE TABLE products (id INTEGER PRIMARY KEY, cost_price INTEGER)`,
|
||||
`CREATE TABLE livestream_draw_logs (
|
||||
id INTEGER PRIMARY KEY,
|
||||
activity_id INTEGER,
|
||||
prize_id INTEGER,
|
||||
douyin_order_id INTEGER,
|
||||
shop_order_id TEXT,
|
||||
local_user_id INTEGER,
|
||||
douyin_user_id TEXT,
|
||||
product_id INTEGER,
|
||||
prize_name TEXT,
|
||||
user_nickname TEXT,
|
||||
created_at DATETIME,
|
||||
is_refunded INTEGER
|
||||
)`}
|
||||
for _, ddl := range ddls {
|
||||
if err := repo.GetDbW().Exec(ddl).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
sqls := []string{
|
||||
`INSERT INTO livestream_activities (id, ticket_price) VALUES (1, 990)`,
|
||||
`INSERT INTO products (id, cost_price) VALUES (101, 500), (102, 700)`,
|
||||
`INSERT INTO livestream_prizes (id, activity_id, product_id, cost_price) VALUES (11, 1, 101, 9999), (12, 1, 102, 8888)`,
|
||||
`INSERT INTO douyin_orders (id, shop_order_id, actual_pay_amount, order_status, pay_type_desc, product_count) VALUES
|
||||
(201, 'SO-1', 990, 2, '微信支付', 1),
|
||||
(202, 'SO-2', 1990, 4, '微信支付', 1)`,
|
||||
`INSERT INTO livestream_draw_logs (id, activity_id, prize_id, douyin_order_id, shop_order_id, local_user_id, product_id, prize_name, user_nickname, created_at, is_refunded) VALUES
|
||||
(301, 1, 11, 201, 'SO-1', 9001, 101, 'P1', 'U1', '2026-04-01 10:00:00', 0),
|
||||
(302, 1, 12, 202, 'SO-2', 9002, 102, 'P2', 'U2', '2026-04-01 10:01:00', 1)`,
|
||||
}
|
||||
for _, sql := range sqls {
|
||||
if err := repo.GetDbW().Exec(sql).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
lg, err := logger.NewCustomLogger(logger.WithOutputInConsole())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
h := &handler{logger: lg, repo: repo}
|
||||
start := mustParseDateTime(t, "2026-04-01 00:00:00")
|
||||
end := mustParseDateTime(t, "2026-04-01 23:59:59")
|
||||
metrics, err := h.buildLivestreamMetrics(livestreamMetricsFilter{ActivityID: 1, StartTime: &start, EndTime: &end}, 990)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if metrics.TotalRevenue != 2980 {
|
||||
t.Fatalf("TotalRevenue=%d want 2980", metrics.TotalRevenue)
|
||||
}
|
||||
if metrics.TotalRefund != 1990 {
|
||||
t.Fatalf("TotalRefund=%d want 1990", metrics.TotalRefund)
|
||||
}
|
||||
if metrics.TotalCost != 500 {
|
||||
t.Fatalf("TotalCost=%d want 500", metrics.TotalCost)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildLivestreamMetrics_DoesNotCountZeroOrderIDAsOrder(t *testing.T) {
|
||||
repo, err := mysql.NewSQLiteRepoForTest()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
ddls := []string{
|
||||
`CREATE TABLE livestream_activities (id INTEGER PRIMARY KEY, ticket_price INTEGER)`,
|
||||
`CREATE TABLE douyin_orders (id INTEGER PRIMARY KEY, shop_order_id TEXT, actual_pay_amount INTEGER, order_status INTEGER, pay_type_desc TEXT, product_count INTEGER)`,
|
||||
`CREATE TABLE livestream_prizes (id INTEGER PRIMARY KEY, activity_id INTEGER, product_id INTEGER, cost_price INTEGER)`,
|
||||
`CREATE TABLE products (id INTEGER PRIMARY KEY, cost_price INTEGER)`,
|
||||
`CREATE TABLE livestream_draw_logs (
|
||||
id INTEGER PRIMARY KEY,
|
||||
activity_id INTEGER,
|
||||
prize_id INTEGER,
|
||||
douyin_order_id INTEGER,
|
||||
shop_order_id TEXT,
|
||||
local_user_id INTEGER,
|
||||
douyin_user_id TEXT,
|
||||
product_id INTEGER,
|
||||
prize_name TEXT,
|
||||
user_nickname TEXT,
|
||||
created_at DATETIME,
|
||||
is_refunded INTEGER
|
||||
)`}
|
||||
for _, ddl := range ddls {
|
||||
if err := repo.GetDbW().Exec(ddl).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
sqls := []string{
|
||||
`INSERT INTO livestream_activities (id, ticket_price) VALUES (1, 990)`,
|
||||
`INSERT INTO products (id, cost_price) VALUES (101, 500)`,
|
||||
`INSERT INTO livestream_prizes (id, activity_id, product_id, cost_price) VALUES (11, 1, 101, 9999)`,
|
||||
`INSERT INTO livestream_draw_logs (id, activity_id, prize_id, douyin_order_id, shop_order_id, local_user_id, product_id, prize_name, user_nickname, created_at, is_refunded) VALUES
|
||||
(301, 1, 11, 0, '', 9001, 101, 'P1', 'U1', '2026-04-01 10:00:00', 0),
|
||||
(302, 1, 11, 0, '', 9001, 101, 'P1', 'U1', '2026-04-01 10:01:00', 0)`}
|
||||
for _, sql := range sqls {
|
||||
if err := repo.GetDbW().Exec(sql).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
lg, err := logger.NewCustomLogger(logger.WithOutputInConsole())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
h := &handler{logger: lg, repo: repo}
|
||||
start := mustParseDateTime(t, "2026-04-01 00:00:00")
|
||||
end := mustParseDateTime(t, "2026-04-01 23:59:59")
|
||||
metrics, err := h.buildLivestreamMetrics(livestreamMetricsFilter{ActivityID: 1, StartTime: &start, EndTime: &end}, 990)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if metrics.OrderCount != 0 {
|
||||
t.Fatalf("OrderCount=%d want 0", metrics.OrderCount)
|
||||
}
|
||||
if metrics.TotalRevenue != 0 {
|
||||
t.Fatalf("TotalRevenue=%d want 0", metrics.TotalRevenue)
|
||||
}
|
||||
if metrics.TotalCost != 1000 {
|
||||
t.Fatalf("TotalCost=%d want 1000", metrics.TotalCost)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildLivestreamMetrics_FallbackToPrizeProductIDWhenDrawLogProductMissing(t *testing.T) {
|
||||
repo, err := mysql.NewSQLiteRepoForTest()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
ddls := []string{
|
||||
`CREATE TABLE livestream_activities (id INTEGER PRIMARY KEY, ticket_price INTEGER)`,
|
||||
`CREATE TABLE douyin_orders (id INTEGER PRIMARY KEY, shop_order_id TEXT, actual_pay_amount INTEGER, order_status INTEGER, pay_type_desc TEXT, product_count INTEGER)`,
|
||||
`CREATE TABLE livestream_prizes (id INTEGER PRIMARY KEY, activity_id INTEGER, product_id INTEGER, cost_price INTEGER)`,
|
||||
`CREATE TABLE products (id INTEGER PRIMARY KEY, cost_price INTEGER)`,
|
||||
`CREATE TABLE livestream_draw_logs (
|
||||
id INTEGER PRIMARY KEY,
|
||||
activity_id INTEGER,
|
||||
prize_id INTEGER,
|
||||
douyin_order_id INTEGER,
|
||||
shop_order_id TEXT,
|
||||
local_user_id INTEGER,
|
||||
douyin_user_id TEXT,
|
||||
product_id INTEGER,
|
||||
prize_name TEXT,
|
||||
user_nickname TEXT,
|
||||
created_at DATETIME,
|
||||
is_refunded INTEGER
|
||||
)`}
|
||||
for _, ddl := range ddls {
|
||||
if err := repo.GetDbW().Exec(ddl).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
sqls := []string{
|
||||
`INSERT INTO livestream_activities (id, ticket_price) VALUES (1, 990)`,
|
||||
`INSERT INTO products (id, cost_price) VALUES (101, 500)`,
|
||||
`INSERT INTO livestream_prizes (id, activity_id, product_id, cost_price) VALUES (11, 1, 101, 9999)`,
|
||||
`INSERT INTO douyin_orders (id, shop_order_id, actual_pay_amount, order_status, pay_type_desc, product_count) VALUES (201, 'SO-1', 990, 2, '微信支付', 1)`,
|
||||
`INSERT INTO livestream_draw_logs (id, activity_id, prize_id, douyin_order_id, shop_order_id, local_user_id, product_id, prize_name, user_nickname, created_at, is_refunded) VALUES
|
||||
(301, 1, 11, 201, 'SO-1', 9001, 0, 'P1', 'U1', '2026-04-01 10:00:00', 0)`}
|
||||
for _, sql := range sqls {
|
||||
if err := repo.GetDbW().Exec(sql).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
lg, err := logger.NewCustomLogger(logger.WithOutputInConsole())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
h := &handler{logger: lg, repo: repo}
|
||||
start := mustParseDateTime(t, "2026-04-01 00:00:00")
|
||||
end := mustParseDateTime(t, "2026-04-01 23:59:59")
|
||||
metrics, err := h.buildLivestreamMetrics(livestreamMetricsFilter{ActivityID: 1, StartTime: &start, EndTime: &end}, 990)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if metrics.TotalCost != 500 {
|
||||
t.Fatalf("TotalCost=%d want 500", metrics.TotalCost)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildLivestreamMetrics_CountsDouyinUsersWhenLocalUserMissing(t *testing.T) {
|
||||
repo, err := mysql.NewSQLiteRepoForTest()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
ddls := []string{
|
||||
`CREATE TABLE douyin_orders (id INTEGER PRIMARY KEY, shop_order_id TEXT, actual_pay_amount INTEGER, order_status INTEGER, pay_type_desc TEXT, product_count INTEGER)`,
|
||||
`CREATE TABLE livestream_prizes (id INTEGER PRIMARY KEY, activity_id INTEGER, product_id INTEGER, cost_price INTEGER)`,
|
||||
`CREATE TABLE products (id INTEGER PRIMARY KEY, cost_price INTEGER)`,
|
||||
`CREATE TABLE livestream_draw_logs (
|
||||
id INTEGER PRIMARY KEY,
|
||||
activity_id INTEGER,
|
||||
prize_id INTEGER,
|
||||
douyin_order_id INTEGER,
|
||||
shop_order_id TEXT,
|
||||
local_user_id INTEGER,
|
||||
douyin_user_id TEXT,
|
||||
product_id INTEGER,
|
||||
prize_name TEXT,
|
||||
user_nickname TEXT,
|
||||
created_at DATETIME,
|
||||
is_refunded INTEGER
|
||||
)`}
|
||||
for _, ddl := range ddls {
|
||||
if err := repo.GetDbW().Exec(ddl).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
sqls := []string{
|
||||
`INSERT INTO products (id, cost_price) VALUES (101, 500)`,
|
||||
`INSERT INTO livestream_prizes (id, activity_id, product_id, cost_price) VALUES (11, 1, 101, 9999)`,
|
||||
`INSERT INTO livestream_draw_logs (id, activity_id, prize_id, douyin_order_id, shop_order_id, local_user_id, douyin_user_id, product_id, prize_name, user_nickname, created_at, is_refunded) VALUES
|
||||
(301, 1, 11, 0, '', 0, 'dy-1', 101, 'P1', 'U1', '2026-04-01 10:00:00', 0),
|
||||
(302, 1, 11, 0, '', 0, 'dy-2', 101, 'P1', 'U2', '2026-04-01 10:01:00', 0)`}
|
||||
for _, sql := range sqls {
|
||||
if err := repo.GetDbW().Exec(sql).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
lg, err := logger.NewCustomLogger(logger.WithOutputInConsole())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
h := &handler{logger: lg, repo: repo}
|
||||
start := mustParseDateTime(t, "2026-04-01 00:00:00")
|
||||
end := mustParseDateTime(t, "2026-04-01 23:59:59")
|
||||
metrics, err := h.buildLivestreamMetrics(livestreamMetricsFilter{ActivityID: 1, StartTime: &start, EndTime: &end}, 990)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if metrics.UserCount != 2 {
|
||||
t.Fatalf("UserCount=%d want 2", metrics.UserCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildLivestreamMetrics_ExcludesRefundedLogsWhenOrderRowMissing(t *testing.T) {
|
||||
repo, err := mysql.NewSQLiteRepoForTest()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
ddls := []string{
|
||||
`CREATE TABLE douyin_orders (id INTEGER PRIMARY KEY, shop_order_id TEXT, actual_pay_amount INTEGER, order_status INTEGER, pay_type_desc TEXT, product_count INTEGER)`,
|
||||
`CREATE TABLE livestream_prizes (id INTEGER PRIMARY KEY, activity_id INTEGER, product_id INTEGER, cost_price INTEGER)`,
|
||||
`CREATE TABLE products (id INTEGER PRIMARY KEY, cost_price INTEGER)`,
|
||||
`CREATE TABLE livestream_draw_logs (
|
||||
id INTEGER PRIMARY KEY,
|
||||
activity_id INTEGER,
|
||||
prize_id INTEGER,
|
||||
douyin_order_id INTEGER,
|
||||
shop_order_id TEXT,
|
||||
local_user_id INTEGER,
|
||||
douyin_user_id TEXT,
|
||||
product_id INTEGER,
|
||||
prize_name TEXT,
|
||||
user_nickname TEXT,
|
||||
created_at DATETIME,
|
||||
is_refunded INTEGER
|
||||
)`}
|
||||
for _, ddl := range ddls {
|
||||
if err := repo.GetDbW().Exec(ddl).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
sqls := []string{
|
||||
`INSERT INTO products (id, cost_price) VALUES (101, 500)`,
|
||||
`INSERT INTO livestream_prizes (id, activity_id, product_id, cost_price) VALUES (11, 1, 101, 9999)`,
|
||||
`INSERT INTO livestream_draw_logs (id, activity_id, prize_id, douyin_order_id, shop_order_id, local_user_id, product_id, prize_name, user_nickname, created_at, is_refunded) VALUES
|
||||
(301, 1, 11, 201, 'SO-1', 9001, 101, 'P1', 'U1', '2026-04-01 10:00:00', 1)`}
|
||||
for _, sql := range sqls {
|
||||
if err := repo.GetDbW().Exec(sql).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
lg, err := logger.NewCustomLogger(logger.WithOutputInConsole())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
h := &handler{logger: lg, repo: repo}
|
||||
start := mustParseDateTime(t, "2026-04-01 00:00:00")
|
||||
end := mustParseDateTime(t, "2026-04-01 23:59:59")
|
||||
metrics, err := h.buildLivestreamMetrics(livestreamMetricsFilter{ActivityID: 1, StartTime: &start, EndTime: &end}, 990)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if metrics.TotalCost != 0 {
|
||||
t.Fatalf("TotalCost=%d want 0", metrics.TotalCost)
|
||||
}
|
||||
}
|
||||
|
||||
func mustParseDateTime(t *testing.T, value string) time.Time {
|
||||
t.Helper()
|
||||
ts, err := time.ParseInLocation("2006-01-02 15:04:05", value, time.Local)
|
||||
if err != nil {
|
||||
t.Fatalf("parse %q: %v", value, err)
|
||||
}
|
||||
return ts
|
||||
}
|
||||
@ -1,11 +1,8 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"math"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"bindbox-game/internal/code"
|
||||
"bindbox-game/internal/pkg/core"
|
||||
@ -58,295 +55,33 @@ func (h *handler) GetLivestreamStats() core.HandlerFunc {
|
||||
EndTime string `form:"end_time"`
|
||||
})
|
||||
_ = ctx.ShouldBindQuery(req)
|
||||
startTime, endTime := parseLivestreamDateRange(req.StartTime, req.EndTime, false)
|
||||
|
||||
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 {
|
||||
metrics, err := h.buildLivestreamMetrics(livestreamMetricsFilter{
|
||||
ActivityID: id,
|
||||
StartTime: startTime,
|
||||
EndTime: endTime,
|
||||
}, int64(activity.TicketPrice))
|
||||
if 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,
|
||||
TotalRevenue: metrics.TotalRevenue,
|
||||
TotalRefund: metrics.TotalRefund,
|
||||
TotalCost: metrics.TotalCost,
|
||||
NetProfit: metrics.NetProfit,
|
||||
OrderCount: metrics.OrderCount,
|
||||
RefundCount: metrics.RefundCount,
|
||||
ProfitMargin: metrics.ProfitMargin,
|
||||
Daily: metrics.Daily,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user