diff --git a/internal/api/admin/livestream_admin.go b/internal/api/admin/livestream_admin.go index bc157d8..60243a5 100755 --- a/internal/api/admin/livestream_admin.go +++ b/internal/api/admin/livestream_admin.go @@ -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 diff --git a/internal/api/admin/livestream_metrics.go b/internal/api/admin/livestream_metrics.go new file mode 100644 index 0000000..70d7a06 --- /dev/null +++ b/internal/api/admin/livestream_metrics.go @@ -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 +} diff --git a/internal/api/admin/livestream_metrics_test.go b/internal/api/admin/livestream_metrics_test.go new file mode 100644 index 0000000..c4ac860 --- /dev/null +++ b/internal/api/admin/livestream_metrics_test.go @@ -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 +} diff --git a/internal/api/admin/livestream_stats.go b/internal/api/admin/livestream_stats.go index 4f4c009..6bc5ba9 100755 --- a/internal/api/admin/livestream_stats.go +++ b/internal/api/admin/livestream_stats.go @@ -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, }) } }