package databi // 幸运礼物V3 section:数据来自 lucky-gift-service 的按日读模型(lucky_draw_pool_day_stats), // 不走 statistics-service。注入方式与 Finance overlay 同层:appRequirements 组装 sections 时追加。 // 口径:只统计 dynamic_v3 策略族的房间抽奖(不含外部接入),返奖为用户可见金额(含未发放/失败行)。 import ( "context" "strings" "time" luckygiftv1 "hyapp.local/api/proto/luckygift/v1" ) const luckyGiftV3SectionKey = "lucky_gift_v3" // luckyGiftV3MaxDays 与 lucky-gift-service 侧的窗口上限对齐,防御异常长区间把零填充行撑爆。 const luckyGiftV3MaxDays = 400 // luckyGiftStatZone 与 lucky_draw_pool_day_stats 的北京日分桶一致;FixedZone 规避容器缺 tzdata。 var luckyGiftStatZone = time.FixedZone("Asia/Shanghai", 8*60*60) // LuckyGiftDailyStatsSource 是 databi 对幸运礼物统计的最小依赖面;luckygiftadmin.Client 天然满足。 type LuckyGiftDailyStatsSource interface { ListLuckyGiftDailyStats(ctx context.Context, req *luckygiftv1.ListLuckyGiftDailyStatsRequest) (*luckygiftv1.ListLuckyGiftDailyStatsResponse, error) } // WithLuckyGiftDailyStats 注入幸运礼物按日统计源;不注入时数据需求宽表没有幸运礼物V3板块。 func WithLuckyGiftDailyStats(source LuckyGiftDailyStatsSource, requestTimeout time.Duration) ServiceOption { return func(s *Service) { s.luckyGiftStats = source s.luckyGiftTimeout = requestTimeout } } // requirementsIncludesLuckyGiftV3 与 requirementsIncludesRevenue 同语义:空/all 视为命中。 func requirementsIncludesLuckyGiftV3(section string) bool { switch strings.ToLower(strings.TrimSpace(section)) { case "", "all", luckyGiftV3SectionKey: return true } return false } // requirementsOnlyLuckyGiftV3 为真时整个请求只要幸运礼物板块;此时必须跳过 statistics-service—— // 它不认识该 section key,会回退成全量六板块的最重查询路径。 func requirementsOnlyLuckyGiftV3(section string) bool { return strings.ToLower(strings.TrimSpace(section)) == luckyGiftV3SectionKey } // luckyGiftV3RegionIDs 与 Finance overlay 相同的区域参数收敛:单区域查询归一化后覆盖多区域列表。 func luckyGiftV3RegionIDs(query RequirementsQuery, regions []RegionInfo, regionIDs []int64) []int64 { if query.RegionID > 0 { return []int64{normalizeRegionID(regions, query.RegionID)} } return regionIDs } // luckyGiftV3StatDays 按北京日展开 [startMS, endMS) 覆盖的日列表,用于零填充。 func luckyGiftV3StatDays(startMS, endMS int64) []string { if endMS <= startMS { return nil } start := time.UnixMilli(startMS).In(luckyGiftStatZone) end := time.UnixMilli(endMS - 1).In(luckyGiftStatZone) startDay := time.Date(start.Year(), start.Month(), start.Day(), 0, 0, 0, 0, luckyGiftStatZone) endDay := time.Date(end.Year(), end.Month(), end.Day(), 0, 0, 0, 0, luckyGiftStatZone) days := make([]string, 0, 32) for day := startDay; !day.After(endDay) && len(days) < luckyGiftV3MaxDays; day = day.AddDate(0, 0, 1) { days = append(days, day.Format("2006-01-02")) } return days } // luckyGiftV3Row 组装单行;比例在投入为 0 时输出 nil(前端渲染 "--"),不伪造 0%。 func luckyGiftV3Row(statDay, label string, drawCount, turnoverCoin, payoutCoin float64) map[string]any { row := map[string]any{ "stat_day": statDay, "label": label, "draw_count": drawCount, "turnover_coin": turnoverCoin, "payout_coin": payoutCoin, "profit_coin": turnoverCoin - payoutCoin, } if turnoverCoin > 0 { row["payout_rate"] = payoutCoin / turnoverCoin row["profit_rate"] = (turnoverCoin - payoutCoin) / turnoverCoin } else { row["payout_rate"] = nil row["profit_rate"] = nil } return row } func luckyGiftV3Columns() []map[string]any { return []map[string]any{ {"key": "draw_count", "label": "抽奖次数", "type": "count", "tooltip": "dynamic_v3 策略下的房间付费抽奖次数(不含外部 App 接入)"}, {"key": "turnover_coin", "label": "投入金币", "type": "coin", "tooltip": "抽奖消耗金币"}, {"key": "payout_coin", "label": "返奖金币", "type": "coin", "tooltip": "用户可见返奖金币,含未发放/失败行,与幸运礼物后台汇总口径一致"}, {"key": "profit_coin", "label": "利润金币", "type": "coin", "tooltip": "投入金币 - 返奖金币"}, {"key": "payout_rate", "label": "返奖率", "type": "ratio", "tooltip": "返奖金币 / 投入金币(实际 RTP)"}, {"key": "profit_rate", "label": "利润率", "type": "ratio", "tooltip": "利润金币 / 投入金币"}, } } // luckyGiftV3Section 拉取 dynamic_v3 按日统计并组装成数据需求 section。 // 数据按北京日零填充整个查询窗口:v3 未开量时明确展示 0,而不是空表让人误以为接口失败。 func (s *Service) luckyGiftV3Section(ctx context.Context, appCode string, query RequirementsQuery, regionIDs []int64) (map[string]any, error) { if s.luckyGiftTimeout > 0 { var cancel context.CancelFunc ctx, cancel = context.WithTimeout(ctx, s.luckyGiftTimeout) defer cancel() } resp, err := s.luckyGiftStats.ListLuckyGiftDailyStats(ctx, &luckygiftv1.ListLuckyGiftDailyStatsRequest{ Meta: &luckygiftv1.RequestMeta{Caller: "admin-server", AppCode: appCode, SentAtMs: time.Now().UnixMilli()}, StrategyVersion: "dynamic_v3", StartMs: query.StartMS, EndMs: query.EndMS, RegionIds: regionIDs, }) if err != nil { return nil, err } byDay := map[string]*luckygiftv1.LuckyGiftDailyStat{} for _, stat := range resp.GetStats() { byDay[stat.GetStatDay()] = stat } days := luckyGiftV3StatDays(query.StartMS, query.EndMS) rows := make([]map[string]any, 0, len(days)) var totalDraws, totalTurnover, totalPayout int64 for _, day := range days { stat := byDay[day] rows = append(rows, luckyGiftV3Row(day, day, float64(stat.GetDrawCount()), float64(stat.GetTurnoverCoin()), float64(stat.GetPayoutCoin()))) totalDraws += stat.GetDrawCount() totalTurnover += stat.GetTurnoverCoin() totalPayout += stat.GetPayoutCoin() } section := map[string]any{ "key": luckyGiftV3SectionKey, "label": "幸运礼物V3", // 幸运礼物没有用户身份/付费身份/新老用户维度,筛选恒为 all;前端该 tab 不渲染筛选器。 "filters": map[string]any{"user_role": "all", "payer_type": "all", "new_user_type": "all"}, "strategy_version": "dynamic_v3", "columns": luckyGiftV3Columns(), "metric_definitions": []map[string]any{ {"metric": "draw_count", "definition": "dynamic_v3 策略规则版本下的房间付费抽奖次数"}, {"metric": "payout_coin", "definition": "用户可见返奖(effective),含未发放/失败行;口径与幸运礼物后台汇总一致"}, {"metric": "payout_rate", "definition": "汇总/平均值行的比例按区间合计重新计算,不是逐日比例的算术平均"}, }, "total": luckyGiftV3Row("total", "汇总", float64(totalDraws), float64(totalTurnover), float64(totalPayout)), "daily_series": rows, } if dayCount := len(days); dayCount > 0 { average := luckyGiftV3Row("average", "平均值", float64(totalDraws)/float64(dayCount), float64(totalTurnover)/float64(dayCount), float64(totalPayout)/float64(dayCount), ) section["average"] = average } return section, nil }