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 (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"bindbox-game/internal/code"
|
"bindbox-game/internal/code"
|
||||||
@ -783,38 +781,9 @@ func (h *handler) ListLivestreamDrawLogs() core.HandlerFunc {
|
|||||||
pageSize = 20
|
pageSize = 20
|
||||||
}
|
}
|
||||||
|
|
||||||
// 解析时间范围 (支持 YYYY-MM-DD HH:mm:ss 和 YYYY-MM-DD)
|
startTime, endTime := parseLivestreamDateRange(req.StartTime, req.EndTime, true)
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 解析排除用户ID
|
excludeUIDs := parseExcludeUserIDs(req.ExcludeUserIDs)
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 使用底层 GORM 直接查询以支持 keyword
|
// 使用底层 GORM 直接查询以支持 keyword
|
||||||
db := h.repo.GetDbR().Model(&model.LivestreamDrawLogs{}).Where("activity_id = ?", activityID)
|
db := h.repo.GetDbR().Model(&model.LivestreamDrawLogs{}).Where("activity_id = ?", activityID)
|
||||||
@ -839,135 +808,25 @@ func (h *handler) ListLivestreamDrawLogs() core.HandlerFunc {
|
|||||||
// 计算统计数据 (仅当有数据时)
|
// 计算统计数据 (仅当有数据时)
|
||||||
var stats *livestreamDrawLogsStats
|
var stats *livestreamDrawLogsStats
|
||||||
if total > 0 {
|
if total > 0 {
|
||||||
stats = &livestreamDrawLogsStats{}
|
metrics, err := h.buildLivestreamMetrics(livestreamMetricsFilter{
|
||||||
// 1. 统计用户数
|
ActivityID: activityID,
|
||||||
// 使用 Session() 避免污染主 db 对象
|
StartTime: startTime,
|
||||||
db.Session(&gorm.Session{}).Select("COUNT(DISTINCT douyin_user_id)").Scan(&stats.UserCount)
|
EndTime: endTime,
|
||||||
|
Keyword: req.Keyword,
|
||||||
// 2. 获取所有相关的 douyin_order_id 和 prize_id,用于在内存中聚合金额和成本
|
ExcludeUserIDs: excludeUIDs,
|
||||||
// 注意:如果数据量极大,这里可能有性能隐患。但考虑到这是后台查询且通常带有筛选,暂且全量拉取 ID。
|
}, ticketPrice)
|
||||||
// 优化:只查需要的字段
|
if err != nil {
|
||||||
type logMeta struct {
|
ctx.AbortWithError(core.Error(http.StatusInternalServerError, code.ServerError, err.Error()))
|
||||||
DouyinOrderID int64
|
return
|
||||||
PrizeID int64
|
|
||||||
ShopOrderID string // 用于关联退款状态查 douyin_orders
|
|
||||||
LocalUserID int64
|
|
||||||
}
|
}
|
||||||
var metas []logMeta
|
stats = &livestreamDrawLogsStats{
|
||||||
// 使用不带分页的 db 克隆
|
UserCount: metrics.UserCount,
|
||||||
if err := db.Session(&gorm.Session{}).Select("douyin_order_id, prize_id, shop_order_id, local_user_id").Scan(&metas).Error; err == nil {
|
OrderCount: metrics.OrderCount,
|
||||||
orderIDs := make([]int64, 0, len(metas))
|
TotalRev: metrics.TotalRevenue,
|
||||||
distinctOrderIDs := make(map[int64]bool)
|
TotalRefund: metrics.TotalRefund,
|
||||||
prizeIDCount := make(map[int64]int64)
|
TotalCost: metrics.TotalCost,
|
||||||
|
NetProfit: metrics.NetProfit,
|
||||||
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.NetProfit = (stats.TotalRev - stats.TotalRefund) - stats.TotalCost
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var logs []model.LivestreamDrawLogs
|
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
|
package admin
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"math"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"bindbox-game/internal/code"
|
"bindbox-game/internal/code"
|
||||||
"bindbox-game/internal/pkg/core"
|
"bindbox-game/internal/pkg/core"
|
||||||
@ -58,295 +55,33 @@ func (h *handler) GetLivestreamStats() core.HandlerFunc {
|
|||||||
EndTime string `form:"end_time"`
|
EndTime string `form:"end_time"`
|
||||||
})
|
})
|
||||||
_ = ctx.ShouldBindQuery(req)
|
_ = 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
|
var activity model.LivestreamActivities
|
||||||
if err := h.repo.GetDbR().Where("id = ?", id).First(&activity).Error; err != nil {
|
if err := h.repo.GetDbR().Where("id = ?", id).First(&activity).Error; err != nil {
|
||||||
ctx.AbortWithError(core.Error(http.StatusNotFound, code.ServerError, "活动不存在"))
|
ctx.AbortWithError(core.Error(http.StatusNotFound, code.ServerError, "活动不存在"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
ticketPrice := int64(activity.TicketPrice)
|
|
||||||
|
|
||||||
// 2. 统计营收/退款:基于订单去重并兼容次卡(0元订单按门票价计入)
|
metrics, err := h.buildLivestreamMetrics(livestreamMetricsFilter{
|
||||||
type orderRef struct {
|
ActivityID: id,
|
||||||
OrderID int64
|
StartTime: startTime,
|
||||||
FirstDrawAt time.Time
|
EndTime: endTime,
|
||||||
}
|
}, int64(activity.TicketPrice))
|
||||||
|
if err != nil {
|
||||||
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()))
|
ctx.AbortWithError(core.Error(http.StatusInternalServerError, code.ServerError, err.Error()))
|
||||||
return
|
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{
|
ctx.Payload(&livestreamStatsResponse{
|
||||||
TotalRevenue: totalRevenue,
|
TotalRevenue: metrics.TotalRevenue,
|
||||||
TotalRefund: totalRefund,
|
TotalRefund: metrics.TotalRefund,
|
||||||
TotalCost: totalCost,
|
TotalCost: metrics.TotalCost,
|
||||||
NetProfit: netProfit,
|
NetProfit: metrics.NetProfit,
|
||||||
OrderCount: orderCount,
|
OrderCount: metrics.OrderCount,
|
||||||
RefundCount: refundCount,
|
RefundCount: metrics.RefundCount,
|
||||||
ProfitMargin: math.Trunc(margin*100) / 100,
|
ProfitMargin: metrics.ProfitMargin,
|
||||||
Daily: dailyList,
|
Daily: metrics.Daily,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user