zhx bf6d7d4f1e 幸运礼物实验硬约束:两组 v2 高水位加权三元组必须一致
滞回标志按 app+pool 单行存储且由每个用户钉住的组内配置推进;
两组三元组不同(尤其 control 关/treatment 开的灰度形态)会互踩:
control 逐抽清掉 treatment 的标志,阈值不同的组按对方状态加权付奖。
加权特性的灰度必须走常规发布,不能走实验。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 02:07:24 +08:00

163 lines
7.3 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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
}