幸运礼物实验硬约束:两组 v2 高水位加权三元组必须一致

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

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
zhx 2026-07-17 02:07:24 +08:00
parent 3ca36d2c8d
commit bf6d7d4f1e
17 changed files with 1078 additions and 56 deletions

View File

@ -5165,6 +5165,219 @@ func (x *GetLuckyGiftUserProfileResponse) GetJackpotHasMore() bool {
return false return false
} }
// ListLuckyGiftDailyStats 面向 Databi 的按日聚合:只读 lucky_draw_pool_day_stats 读模型
// (北京日 +08:00 分桶,由后台游标增量刷新),不扫抽奖事实表,避免 BI 访问打热事实库。
// strategy_version 过滤用于按策略族拆分展示如幸运礼物V3 tab 只看 dynamic_v3
type ListLuckyGiftDailyStatsRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
Meta *RequestMeta `protobuf:"bytes,1,opt,name=meta,proto3" json:"meta,omitempty"`
// pool_id 为空表示全部奖池汇总。
PoolId string `protobuf:"bytes,2,opt,name=pool_id,json=poolId,proto3" json:"pool_id,omitempty"`
// strategy_version 为空表示不按策略过滤;取值 fixed_v2 / dynamic_v3。
StrategyVersion string `protobuf:"bytes,3,opt,name=strategy_version,json=strategyVersion,proto3" json:"strategy_version,omitempty"`
// [start_ms, end_ms) UTC epoch ms服务端按北京日换算成 stat_day 闭区间。
StartMs int64 `protobuf:"varint,4,opt,name=start_ms,json=startMs,proto3" json:"start_ms,omitempty"`
EndMs int64 `protobuf:"varint,5,opt,name=end_ms,json=endMs,proto3" json:"end_ms,omitempty"`
// region_ids 为空表示全部区域;按房间可见区域 ID 过滤0=GLOBAL 房间只在不过滤时计入)。
RegionIds []int64 `protobuf:"varint,6,rep,packed,name=region_ids,json=regionIds,proto3" json:"region_ids,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *ListLuckyGiftDailyStatsRequest) Reset() {
*x = ListLuckyGiftDailyStatsRequest{}
mi := &file_proto_luckygift_v1_luckygift_proto_msgTypes[54]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *ListLuckyGiftDailyStatsRequest) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*ListLuckyGiftDailyStatsRequest) ProtoMessage() {}
func (x *ListLuckyGiftDailyStatsRequest) ProtoReflect() protoreflect.Message {
mi := &file_proto_luckygift_v1_luckygift_proto_msgTypes[54]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use ListLuckyGiftDailyStatsRequest.ProtoReflect.Descriptor instead.
func (*ListLuckyGiftDailyStatsRequest) Descriptor() ([]byte, []int) {
return file_proto_luckygift_v1_luckygift_proto_rawDescGZIP(), []int{54}
}
func (x *ListLuckyGiftDailyStatsRequest) GetMeta() *RequestMeta {
if x != nil {
return x.Meta
}
return nil
}
func (x *ListLuckyGiftDailyStatsRequest) GetPoolId() string {
if x != nil {
return x.PoolId
}
return ""
}
func (x *ListLuckyGiftDailyStatsRequest) GetStrategyVersion() string {
if x != nil {
return x.StrategyVersion
}
return ""
}
func (x *ListLuckyGiftDailyStatsRequest) GetStartMs() int64 {
if x != nil {
return x.StartMs
}
return 0
}
func (x *ListLuckyGiftDailyStatsRequest) GetEndMs() int64 {
if x != nil {
return x.EndMs
}
return 0
}
func (x *ListLuckyGiftDailyStatsRequest) GetRegionIds() []int64 {
if x != nil {
return x.RegionIds
}
return nil
}
type LuckyGiftDailyStat struct {
state protoimpl.MessageState `protogen:"open.v1"`
// stat_day 是北京日(+08:00YYYY-MM-DD。
StatDay string `protobuf:"bytes,1,opt,name=stat_day,json=statDay,proto3" json:"stat_day,omitempty"`
DrawCount int64 `protobuf:"varint,2,opt,name=draw_count,json=drawCount,proto3" json:"draw_count,omitempty"`
TurnoverCoin int64 `protobuf:"varint,3,opt,name=turnover_coin,json=turnoverCoin,proto3" json:"turnover_coin,omitempty"`
// payout_coin 为用户可见返奖effective与既有 DrawSummary 口径一致,含未发放/失败行。
PayoutCoin int64 `protobuf:"varint,4,opt,name=payout_coin,json=payoutCoin,proto3" json:"payout_coin,omitempty"`
BaseRewardCoin int64 `protobuf:"varint,5,opt,name=base_reward_coin,json=baseRewardCoin,proto3" json:"base_reward_coin,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *LuckyGiftDailyStat) Reset() {
*x = LuckyGiftDailyStat{}
mi := &file_proto_luckygift_v1_luckygift_proto_msgTypes[55]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *LuckyGiftDailyStat) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*LuckyGiftDailyStat) ProtoMessage() {}
func (x *LuckyGiftDailyStat) ProtoReflect() protoreflect.Message {
mi := &file_proto_luckygift_v1_luckygift_proto_msgTypes[55]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use LuckyGiftDailyStat.ProtoReflect.Descriptor instead.
func (*LuckyGiftDailyStat) Descriptor() ([]byte, []int) {
return file_proto_luckygift_v1_luckygift_proto_rawDescGZIP(), []int{55}
}
func (x *LuckyGiftDailyStat) GetStatDay() string {
if x != nil {
return x.StatDay
}
return ""
}
func (x *LuckyGiftDailyStat) GetDrawCount() int64 {
if x != nil {
return x.DrawCount
}
return 0
}
func (x *LuckyGiftDailyStat) GetTurnoverCoin() int64 {
if x != nil {
return x.TurnoverCoin
}
return 0
}
func (x *LuckyGiftDailyStat) GetPayoutCoin() int64 {
if x != nil {
return x.PayoutCoin
}
return 0
}
func (x *LuckyGiftDailyStat) GetBaseRewardCoin() int64 {
if x != nil {
return x.BaseRewardCoin
}
return 0
}
type ListLuckyGiftDailyStatsResponse struct {
state protoimpl.MessageState `protogen:"open.v1"`
Stats []*LuckyGiftDailyStat `protobuf:"bytes,1,rep,name=stats,proto3" json:"stats,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *ListLuckyGiftDailyStatsResponse) Reset() {
*x = ListLuckyGiftDailyStatsResponse{}
mi := &file_proto_luckygift_v1_luckygift_proto_msgTypes[56]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *ListLuckyGiftDailyStatsResponse) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*ListLuckyGiftDailyStatsResponse) ProtoMessage() {}
func (x *ListLuckyGiftDailyStatsResponse) ProtoReflect() protoreflect.Message {
mi := &file_proto_luckygift_v1_luckygift_proto_msgTypes[56]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use ListLuckyGiftDailyStatsResponse.ProtoReflect.Descriptor instead.
func (*ListLuckyGiftDailyStatsResponse) Descriptor() ([]byte, []int) {
return file_proto_luckygift_v1_luckygift_proto_rawDescGZIP(), []int{56}
}
func (x *ListLuckyGiftDailyStatsResponse) GetStats() []*LuckyGiftDailyStat {
if x != nil {
return x.Stats
}
return nil
}
var File_proto_luckygift_v1_luckygift_proto protoreflect.FileDescriptor var File_proto_luckygift_v1_luckygift_proto protoreflect.FileDescriptor
const file_proto_luckygift_v1_luckygift_proto_rawDesc = "" + const file_proto_luckygift_v1_luckygift_proto_rawDesc = "" +
@ -5679,12 +5892,30 @@ const file_proto_luckygift_v1_luckygift_proto_rawDesc = "" +
"\fjackpot_page\x18\x06 \x01(\x05R\vjackpotPage\x12*\n" + "\fjackpot_page\x18\x06 \x01(\x05R\vjackpotPage\x12*\n" +
"\x11jackpot_page_size\x18\a \x01(\x05R\x0fjackpotPageSize\x12.\n" + "\x11jackpot_page_size\x18\a \x01(\x05R\x0fjackpotPageSize\x12.\n" +
"\x13next_jackpot_cursor\x18\b \x01(\tR\x11nextJackpotCursor\x12(\n" + "\x13next_jackpot_cursor\x18\b \x01(\tR\x11nextJackpotCursor\x12(\n" +
"\x10jackpot_has_more\x18\t \x01(\bR\x0ejackpotHasMore2\xf7\x03\n" + "\x10jackpot_has_more\x18\t \x01(\bR\x0ejackpotHasMore\"\xea\x01\n" +
"\x1eListLuckyGiftDailyStatsRequest\x123\n" +
"\x04meta\x18\x01 \x01(\v2\x1f.hyapp.luckygift.v1.RequestMetaR\x04meta\x12\x17\n" +
"\apool_id\x18\x02 \x01(\tR\x06poolId\x12)\n" +
"\x10strategy_version\x18\x03 \x01(\tR\x0fstrategyVersion\x12\x19\n" +
"\bstart_ms\x18\x04 \x01(\x03R\astartMs\x12\x15\n" +
"\x06end_ms\x18\x05 \x01(\x03R\x05endMs\x12\x1d\n" +
"\n" +
"region_ids\x18\x06 \x03(\x03R\tregionIds\"\xbe\x01\n" +
"\x12LuckyGiftDailyStat\x12\x19\n" +
"\bstat_day\x18\x01 \x01(\tR\astatDay\x12\x1d\n" +
"\n" +
"draw_count\x18\x02 \x01(\x03R\tdrawCount\x12#\n" +
"\rturnover_coin\x18\x03 \x01(\x03R\fturnoverCoin\x12\x1f\n" +
"\vpayout_coin\x18\x04 \x01(\x03R\n" +
"payoutCoin\x12(\n" +
"\x10base_reward_coin\x18\x05 \x01(\x03R\x0ebaseRewardCoin\"_\n" +
"\x1fListLuckyGiftDailyStatsResponse\x12<\n" +
"\x05stats\x18\x01 \x03(\v2&.hyapp.luckygift.v1.LuckyGiftDailyStatR\x05stats2\xf7\x03\n" +
"\x10LuckyGiftService\x12g\n" + "\x10LuckyGiftService\x12g\n" +
"\x0eCheckLuckyGift\x12).hyapp.luckygift.v1.CheckLuckyGiftRequest\x1a*.hyapp.luckygift.v1.CheckLuckyGiftResponse\x12y\n" + "\x0eCheckLuckyGift\x12).hyapp.luckygift.v1.CheckLuckyGiftRequest\x1a*.hyapp.luckygift.v1.CheckLuckyGiftResponse\x12y\n" +
"\x14ExecuteLuckyGiftDraw\x12/.hyapp.luckygift.v1.ExecuteLuckyGiftDrawRequest\x1a0.hyapp.luckygift.v1.ExecuteLuckyGiftDrawResponse\x12\x88\x01\n" + "\x14ExecuteLuckyGiftDraw\x12/.hyapp.luckygift.v1.ExecuteLuckyGiftDrawRequest\x1a0.hyapp.luckygift.v1.ExecuteLuckyGiftDrawResponse\x12\x88\x01\n" +
"\x19BatchExecuteLuckyGiftDraw\x124.hyapp.luckygift.v1.BatchExecuteLuckyGiftDrawRequest\x1a5.hyapp.luckygift.v1.BatchExecuteLuckyGiftDrawResponse\x12t\n" + "\x19BatchExecuteLuckyGiftDraw\x124.hyapp.luckygift.v1.BatchExecuteLuckyGiftDrawRequest\x1a5.hyapp.luckygift.v1.BatchExecuteLuckyGiftDrawResponse\x12t\n" +
"\x17ExecuteExternalGiftDraw\x12+.hyapp.luckygift.v1.ExternalGiftDrawRequest\x1a,.hyapp.luckygift.v1.ExternalGiftDrawResponse2\xe4\x0e\n" + "\x17ExecuteExternalGiftDraw\x12+.hyapp.luckygift.v1.ExternalGiftDrawRequest\x1a,.hyapp.luckygift.v1.ExternalGiftDrawResponse2\xe9\x0f\n" +
"\x15AdminLuckyGiftService\x12s\n" + "\x15AdminLuckyGiftService\x12s\n" +
"\x12GetLuckyGiftConfig\x12-.hyapp.luckygift.v1.GetLuckyGiftConfigRequest\x1a..hyapp.luckygift.v1.GetLuckyGiftConfigResponse\x12|\n" + "\x12GetLuckyGiftConfig\x12-.hyapp.luckygift.v1.GetLuckyGiftConfigRequest\x1a..hyapp.luckygift.v1.GetLuckyGiftConfigResponse\x12|\n" +
"\x15UpsertLuckyGiftConfig\x120.hyapp.luckygift.v1.UpsertLuckyGiftConfigRequest\x1a1.hyapp.luckygift.v1.UpsertLuckyGiftConfigResponse\x12y\n" + "\x15UpsertLuckyGiftConfig\x120.hyapp.luckygift.v1.UpsertLuckyGiftConfigRequest\x1a1.hyapp.luckygift.v1.UpsertLuckyGiftConfigResponse\x12y\n" +
@ -5699,7 +5930,8 @@ const file_proto_luckygift_v1_luckygift_proto_rawDesc = "" +
"\x1fSetLuckyGiftExperimentOverrides\x12:.hyapp.luckygift.v1.SetLuckyGiftExperimentOverridesRequest\x1a;.hyapp.luckygift.v1.SetLuckyGiftExperimentOverridesResponse\x12\x7f\n" + "\x1fSetLuckyGiftExperimentOverrides\x12:.hyapp.luckygift.v1.SetLuckyGiftExperimentOverridesRequest\x1a;.hyapp.luckygift.v1.SetLuckyGiftExperimentOverridesResponse\x12\x7f\n" +
"\x16EndLuckyGiftExperiment\x121.hyapp.luckygift.v1.EndLuckyGiftExperimentRequest\x1a2.hyapp.luckygift.v1.EndLuckyGiftExperimentResponse\x12\x88\x01\n" + "\x16EndLuckyGiftExperiment\x121.hyapp.luckygift.v1.EndLuckyGiftExperimentRequest\x1a2.hyapp.luckygift.v1.EndLuckyGiftExperimentResponse\x12\x88\x01\n" +
"\x19ListLuckyGiftUserProfiles\x124.hyapp.luckygift.v1.ListLuckyGiftUserProfilesRequest\x1a5.hyapp.luckygift.v1.ListLuckyGiftUserProfilesResponse\x12\x82\x01\n" + "\x19ListLuckyGiftUserProfiles\x124.hyapp.luckygift.v1.ListLuckyGiftUserProfilesRequest\x1a5.hyapp.luckygift.v1.ListLuckyGiftUserProfilesResponse\x12\x82\x01\n" +
"\x17GetLuckyGiftUserProfile\x122.hyapp.luckygift.v1.GetLuckyGiftUserProfileRequest\x1a3.hyapp.luckygift.v1.GetLuckyGiftUserProfileResponseB0Z.hyapp.local/api/proto/luckygift/v1;luckygiftv1b\x06proto3" "\x17GetLuckyGiftUserProfile\x122.hyapp.luckygift.v1.GetLuckyGiftUserProfileRequest\x1a3.hyapp.luckygift.v1.GetLuckyGiftUserProfileResponse\x12\x82\x01\n" +
"\x17ListLuckyGiftDailyStats\x122.hyapp.luckygift.v1.ListLuckyGiftDailyStatsRequest\x1a3.hyapp.luckygift.v1.ListLuckyGiftDailyStatsResponseB0Z.hyapp.local/api/proto/luckygift/v1;luckygiftv1b\x06proto3"
var ( var (
file_proto_luckygift_v1_luckygift_proto_rawDescOnce sync.Once file_proto_luckygift_v1_luckygift_proto_rawDescOnce sync.Once
@ -5713,7 +5945,7 @@ func file_proto_luckygift_v1_luckygift_proto_rawDescGZIP() []byte {
return file_proto_luckygift_v1_luckygift_proto_rawDescData return file_proto_luckygift_v1_luckygift_proto_rawDescData
} }
var file_proto_luckygift_v1_luckygift_proto_msgTypes = make([]protoimpl.MessageInfo, 54) var file_proto_luckygift_v1_luckygift_proto_msgTypes = make([]protoimpl.MessageInfo, 57)
var file_proto_luckygift_v1_luckygift_proto_goTypes = []any{ var file_proto_luckygift_v1_luckygift_proto_goTypes = []any{
(*RequestMeta)(nil), // 0: hyapp.luckygift.v1.RequestMeta (*RequestMeta)(nil), // 0: hyapp.luckygift.v1.RequestMeta
(*LuckyGiftMeta)(nil), // 1: hyapp.luckygift.v1.LuckyGiftMeta (*LuckyGiftMeta)(nil), // 1: hyapp.luckygift.v1.LuckyGiftMeta
@ -5769,6 +6001,9 @@ var file_proto_luckygift_v1_luckygift_proto_goTypes = []any{
(*LuckyGiftUserJackpotCondition)(nil), // 51: hyapp.luckygift.v1.LuckyGiftUserJackpotCondition (*LuckyGiftUserJackpotCondition)(nil), // 51: hyapp.luckygift.v1.LuckyGiftUserJackpotCondition
(*LuckyGiftUserJackpotHit)(nil), // 52: hyapp.luckygift.v1.LuckyGiftUserJackpotHit (*LuckyGiftUserJackpotHit)(nil), // 52: hyapp.luckygift.v1.LuckyGiftUserJackpotHit
(*GetLuckyGiftUserProfileResponse)(nil), // 53: hyapp.luckygift.v1.GetLuckyGiftUserProfileResponse (*GetLuckyGiftUserProfileResponse)(nil), // 53: hyapp.luckygift.v1.GetLuckyGiftUserProfileResponse
(*ListLuckyGiftDailyStatsRequest)(nil), // 54: hyapp.luckygift.v1.ListLuckyGiftDailyStatsRequest
(*LuckyGiftDailyStat)(nil), // 55: hyapp.luckygift.v1.LuckyGiftDailyStat
(*ListLuckyGiftDailyStatsResponse)(nil), // 56: hyapp.luckygift.v1.ListLuckyGiftDailyStatsResponse
} }
var file_proto_luckygift_v1_luckygift_proto_depIdxs = []int32{ var file_proto_luckygift_v1_luckygift_proto_depIdxs = []int32{
0, // 0: hyapp.luckygift.v1.LuckyGiftMeta.meta:type_name -> hyapp.luckygift.v1.RequestMeta 0, // 0: hyapp.luckygift.v1.LuckyGiftMeta.meta:type_name -> hyapp.luckygift.v1.RequestMeta
@ -5825,47 +6060,51 @@ var file_proto_luckygift_v1_luckygift_proto_depIdxs = []int32{
46, // 51: hyapp.luckygift.v1.GetLuckyGiftUserProfileResponse.profile:type_name -> hyapp.luckygift.v1.LuckyGiftUserProfile 46, // 51: hyapp.luckygift.v1.GetLuckyGiftUserProfileResponse.profile:type_name -> hyapp.luckygift.v1.LuckyGiftUserProfile
50, // 52: hyapp.luckygift.v1.GetLuckyGiftUserProfileResponse.multiplier_distribution:type_name -> hyapp.luckygift.v1.LuckyGiftUserMultiplierStat 50, // 52: hyapp.luckygift.v1.GetLuckyGiftUserProfileResponse.multiplier_distribution:type_name -> hyapp.luckygift.v1.LuckyGiftUserMultiplierStat
52, // 53: hyapp.luckygift.v1.GetLuckyGiftUserProfileResponse.jackpot_hits:type_name -> hyapp.luckygift.v1.LuckyGiftUserJackpotHit 52, // 53: hyapp.luckygift.v1.GetLuckyGiftUserProfileResponse.jackpot_hits:type_name -> hyapp.luckygift.v1.LuckyGiftUserJackpotHit
5, // 54: hyapp.luckygift.v1.LuckyGiftService.CheckLuckyGift:input_type -> hyapp.luckygift.v1.CheckLuckyGiftRequest 0, // 54: hyapp.luckygift.v1.ListLuckyGiftDailyStatsRequest.meta:type_name -> hyapp.luckygift.v1.RequestMeta
8, // 55: hyapp.luckygift.v1.LuckyGiftService.ExecuteLuckyGiftDraw:input_type -> hyapp.luckygift.v1.ExecuteLuckyGiftDrawRequest 55, // 55: hyapp.luckygift.v1.ListLuckyGiftDailyStatsResponse.stats:type_name -> hyapp.luckygift.v1.LuckyGiftDailyStat
10, // 56: hyapp.luckygift.v1.LuckyGiftService.BatchExecuteLuckyGiftDraw:input_type -> hyapp.luckygift.v1.BatchExecuteLuckyGiftDrawRequest 5, // 56: hyapp.luckygift.v1.LuckyGiftService.CheckLuckyGift:input_type -> hyapp.luckygift.v1.CheckLuckyGiftRequest
12, // 57: hyapp.luckygift.v1.LuckyGiftService.ExecuteExternalGiftDraw:input_type -> hyapp.luckygift.v1.ExternalGiftDrawRequest 8, // 57: hyapp.luckygift.v1.LuckyGiftService.ExecuteLuckyGiftDraw:input_type -> hyapp.luckygift.v1.ExecuteLuckyGiftDrawRequest
14, // 58: hyapp.luckygift.v1.AdminLuckyGiftService.GetLuckyGiftConfig:input_type -> hyapp.luckygift.v1.GetLuckyGiftConfigRequest 10, // 58: hyapp.luckygift.v1.LuckyGiftService.BatchExecuteLuckyGiftDraw:input_type -> hyapp.luckygift.v1.BatchExecuteLuckyGiftDrawRequest
16, // 59: hyapp.luckygift.v1.AdminLuckyGiftService.UpsertLuckyGiftConfig:input_type -> hyapp.luckygift.v1.UpsertLuckyGiftConfigRequest 12, // 59: hyapp.luckygift.v1.LuckyGiftService.ExecuteExternalGiftDraw:input_type -> hyapp.luckygift.v1.ExternalGiftDrawRequest
18, // 60: hyapp.luckygift.v1.AdminLuckyGiftService.ListLuckyGiftConfigs:input_type -> hyapp.luckygift.v1.ListLuckyGiftConfigsRequest 14, // 60: hyapp.luckygift.v1.AdminLuckyGiftService.GetLuckyGiftConfig:input_type -> hyapp.luckygift.v1.GetLuckyGiftConfigRequest
20, // 61: hyapp.luckygift.v1.AdminLuckyGiftService.ListLuckyGiftDraws:input_type -> hyapp.luckygift.v1.ListLuckyGiftDrawsRequest 16, // 61: hyapp.luckygift.v1.AdminLuckyGiftService.UpsertLuckyGiftConfig:input_type -> hyapp.luckygift.v1.UpsertLuckyGiftConfigRequest
24, // 62: hyapp.luckygift.v1.AdminLuckyGiftService.GetLuckyGiftDrawSummary:input_type -> hyapp.luckygift.v1.GetLuckyGiftDrawSummaryRequest 18, // 62: hyapp.luckygift.v1.AdminLuckyGiftService.ListLuckyGiftConfigs:input_type -> hyapp.luckygift.v1.ListLuckyGiftConfigsRequest
26, // 63: hyapp.luckygift.v1.AdminLuckyGiftService.ListLuckyGiftPoolBalances:input_type -> hyapp.luckygift.v1.ListLuckyGiftPoolBalancesRequest 20, // 63: hyapp.luckygift.v1.AdminLuckyGiftService.ListLuckyGiftDraws:input_type -> hyapp.luckygift.v1.ListLuckyGiftDrawsRequest
42, // 64: hyapp.luckygift.v1.AdminLuckyGiftService.AdjustLuckyGiftPoolBalance:input_type -> hyapp.luckygift.v1.AdjustLuckyGiftPoolBalanceRequest 24, // 64: hyapp.luckygift.v1.AdminLuckyGiftService.GetLuckyGiftDrawSummary:input_type -> hyapp.luckygift.v1.GetLuckyGiftDrawSummaryRequest
31, // 65: hyapp.luckygift.v1.AdminLuckyGiftService.CreateLuckyGiftExperiment:input_type -> hyapp.luckygift.v1.CreateLuckyGiftExperimentRequest 26, // 65: hyapp.luckygift.v1.AdminLuckyGiftService.ListLuckyGiftPoolBalances:input_type -> hyapp.luckygift.v1.ListLuckyGiftPoolBalancesRequest
33, // 66: hyapp.luckygift.v1.AdminLuckyGiftService.ListLuckyGiftExperiments:input_type -> hyapp.luckygift.v1.ListLuckyGiftExperimentsRequest 42, // 66: hyapp.luckygift.v1.AdminLuckyGiftService.AdjustLuckyGiftPoolBalance:input_type -> hyapp.luckygift.v1.AdjustLuckyGiftPoolBalanceRequest
35, // 67: hyapp.luckygift.v1.AdminLuckyGiftService.UpdateLuckyGiftExperiment:input_type -> hyapp.luckygift.v1.UpdateLuckyGiftExperimentRequest 31, // 67: hyapp.luckygift.v1.AdminLuckyGiftService.CreateLuckyGiftExperiment:input_type -> hyapp.luckygift.v1.CreateLuckyGiftExperimentRequest
37, // 68: hyapp.luckygift.v1.AdminLuckyGiftService.SetLuckyGiftExperimentOverrides:input_type -> hyapp.luckygift.v1.SetLuckyGiftExperimentOverridesRequest 33, // 68: hyapp.luckygift.v1.AdminLuckyGiftService.ListLuckyGiftExperiments:input_type -> hyapp.luckygift.v1.ListLuckyGiftExperimentsRequest
39, // 69: hyapp.luckygift.v1.AdminLuckyGiftService.EndLuckyGiftExperiment:input_type -> hyapp.luckygift.v1.EndLuckyGiftExperimentRequest 35, // 69: hyapp.luckygift.v1.AdminLuckyGiftService.UpdateLuckyGiftExperiment:input_type -> hyapp.luckygift.v1.UpdateLuckyGiftExperimentRequest
47, // 70: hyapp.luckygift.v1.AdminLuckyGiftService.ListLuckyGiftUserProfiles:input_type -> hyapp.luckygift.v1.ListLuckyGiftUserProfilesRequest 37, // 70: hyapp.luckygift.v1.AdminLuckyGiftService.SetLuckyGiftExperimentOverrides:input_type -> hyapp.luckygift.v1.SetLuckyGiftExperimentOverridesRequest
49, // 71: hyapp.luckygift.v1.AdminLuckyGiftService.GetLuckyGiftUserProfile:input_type -> hyapp.luckygift.v1.GetLuckyGiftUserProfileRequest 39, // 71: hyapp.luckygift.v1.AdminLuckyGiftService.EndLuckyGiftExperiment:input_type -> hyapp.luckygift.v1.EndLuckyGiftExperimentRequest
6, // 72: hyapp.luckygift.v1.LuckyGiftService.CheckLuckyGift:output_type -> hyapp.luckygift.v1.CheckLuckyGiftResponse 47, // 72: hyapp.luckygift.v1.AdminLuckyGiftService.ListLuckyGiftUserProfiles:input_type -> hyapp.luckygift.v1.ListLuckyGiftUserProfilesRequest
9, // 73: hyapp.luckygift.v1.LuckyGiftService.ExecuteLuckyGiftDraw:output_type -> hyapp.luckygift.v1.ExecuteLuckyGiftDrawResponse 49, // 73: hyapp.luckygift.v1.AdminLuckyGiftService.GetLuckyGiftUserProfile:input_type -> hyapp.luckygift.v1.GetLuckyGiftUserProfileRequest
11, // 74: hyapp.luckygift.v1.LuckyGiftService.BatchExecuteLuckyGiftDraw:output_type -> hyapp.luckygift.v1.BatchExecuteLuckyGiftDrawResponse 54, // 74: hyapp.luckygift.v1.AdminLuckyGiftService.ListLuckyGiftDailyStats:input_type -> hyapp.luckygift.v1.ListLuckyGiftDailyStatsRequest
13, // 75: hyapp.luckygift.v1.LuckyGiftService.ExecuteExternalGiftDraw:output_type -> hyapp.luckygift.v1.ExternalGiftDrawResponse 6, // 75: hyapp.luckygift.v1.LuckyGiftService.CheckLuckyGift:output_type -> hyapp.luckygift.v1.CheckLuckyGiftResponse
15, // 76: hyapp.luckygift.v1.AdminLuckyGiftService.GetLuckyGiftConfig:output_type -> hyapp.luckygift.v1.GetLuckyGiftConfigResponse 9, // 76: hyapp.luckygift.v1.LuckyGiftService.ExecuteLuckyGiftDraw:output_type -> hyapp.luckygift.v1.ExecuteLuckyGiftDrawResponse
17, // 77: hyapp.luckygift.v1.AdminLuckyGiftService.UpsertLuckyGiftConfig:output_type -> hyapp.luckygift.v1.UpsertLuckyGiftConfigResponse 11, // 77: hyapp.luckygift.v1.LuckyGiftService.BatchExecuteLuckyGiftDraw:output_type -> hyapp.luckygift.v1.BatchExecuteLuckyGiftDrawResponse
19, // 78: hyapp.luckygift.v1.AdminLuckyGiftService.ListLuckyGiftConfigs:output_type -> hyapp.luckygift.v1.ListLuckyGiftConfigsResponse 13, // 78: hyapp.luckygift.v1.LuckyGiftService.ExecuteExternalGiftDraw:output_type -> hyapp.luckygift.v1.ExternalGiftDrawResponse
21, // 79: hyapp.luckygift.v1.AdminLuckyGiftService.ListLuckyGiftDraws:output_type -> hyapp.luckygift.v1.ListLuckyGiftDrawsResponse 15, // 79: hyapp.luckygift.v1.AdminLuckyGiftService.GetLuckyGiftConfig:output_type -> hyapp.luckygift.v1.GetLuckyGiftConfigResponse
25, // 80: hyapp.luckygift.v1.AdminLuckyGiftService.GetLuckyGiftDrawSummary:output_type -> hyapp.luckygift.v1.GetLuckyGiftDrawSummaryResponse 17, // 80: hyapp.luckygift.v1.AdminLuckyGiftService.UpsertLuckyGiftConfig:output_type -> hyapp.luckygift.v1.UpsertLuckyGiftConfigResponse
27, // 81: hyapp.luckygift.v1.AdminLuckyGiftService.ListLuckyGiftPoolBalances:output_type -> hyapp.luckygift.v1.ListLuckyGiftPoolBalancesResponse 19, // 81: hyapp.luckygift.v1.AdminLuckyGiftService.ListLuckyGiftConfigs:output_type -> hyapp.luckygift.v1.ListLuckyGiftConfigsResponse
44, // 82: hyapp.luckygift.v1.AdminLuckyGiftService.AdjustLuckyGiftPoolBalance:output_type -> hyapp.luckygift.v1.AdjustLuckyGiftPoolBalanceResponse 21, // 82: hyapp.luckygift.v1.AdminLuckyGiftService.ListLuckyGiftDraws:output_type -> hyapp.luckygift.v1.ListLuckyGiftDrawsResponse
32, // 83: hyapp.luckygift.v1.AdminLuckyGiftService.CreateLuckyGiftExperiment:output_type -> hyapp.luckygift.v1.CreateLuckyGiftExperimentResponse 25, // 83: hyapp.luckygift.v1.AdminLuckyGiftService.GetLuckyGiftDrawSummary:output_type -> hyapp.luckygift.v1.GetLuckyGiftDrawSummaryResponse
34, // 84: hyapp.luckygift.v1.AdminLuckyGiftService.ListLuckyGiftExperiments:output_type -> hyapp.luckygift.v1.ListLuckyGiftExperimentsResponse 27, // 84: hyapp.luckygift.v1.AdminLuckyGiftService.ListLuckyGiftPoolBalances:output_type -> hyapp.luckygift.v1.ListLuckyGiftPoolBalancesResponse
36, // 85: hyapp.luckygift.v1.AdminLuckyGiftService.UpdateLuckyGiftExperiment:output_type -> hyapp.luckygift.v1.UpdateLuckyGiftExperimentResponse 44, // 85: hyapp.luckygift.v1.AdminLuckyGiftService.AdjustLuckyGiftPoolBalance:output_type -> hyapp.luckygift.v1.AdjustLuckyGiftPoolBalanceResponse
38, // 86: hyapp.luckygift.v1.AdminLuckyGiftService.SetLuckyGiftExperimentOverrides:output_type -> hyapp.luckygift.v1.SetLuckyGiftExperimentOverridesResponse 32, // 86: hyapp.luckygift.v1.AdminLuckyGiftService.CreateLuckyGiftExperiment:output_type -> hyapp.luckygift.v1.CreateLuckyGiftExperimentResponse
40, // 87: hyapp.luckygift.v1.AdminLuckyGiftService.EndLuckyGiftExperiment:output_type -> hyapp.luckygift.v1.EndLuckyGiftExperimentResponse 34, // 87: hyapp.luckygift.v1.AdminLuckyGiftService.ListLuckyGiftExperiments:output_type -> hyapp.luckygift.v1.ListLuckyGiftExperimentsResponse
48, // 88: hyapp.luckygift.v1.AdminLuckyGiftService.ListLuckyGiftUserProfiles:output_type -> hyapp.luckygift.v1.ListLuckyGiftUserProfilesResponse 36, // 88: hyapp.luckygift.v1.AdminLuckyGiftService.UpdateLuckyGiftExperiment:output_type -> hyapp.luckygift.v1.UpdateLuckyGiftExperimentResponse
53, // 89: hyapp.luckygift.v1.AdminLuckyGiftService.GetLuckyGiftUserProfile:output_type -> hyapp.luckygift.v1.GetLuckyGiftUserProfileResponse 38, // 89: hyapp.luckygift.v1.AdminLuckyGiftService.SetLuckyGiftExperimentOverrides:output_type -> hyapp.luckygift.v1.SetLuckyGiftExperimentOverridesResponse
72, // [72:90] is the sub-list for method output_type 40, // 90: hyapp.luckygift.v1.AdminLuckyGiftService.EndLuckyGiftExperiment:output_type -> hyapp.luckygift.v1.EndLuckyGiftExperimentResponse
54, // [54:72] is the sub-list for method input_type 48, // 91: hyapp.luckygift.v1.AdminLuckyGiftService.ListLuckyGiftUserProfiles:output_type -> hyapp.luckygift.v1.ListLuckyGiftUserProfilesResponse
54, // [54:54] is the sub-list for extension type_name 53, // 92: hyapp.luckygift.v1.AdminLuckyGiftService.GetLuckyGiftUserProfile:output_type -> hyapp.luckygift.v1.GetLuckyGiftUserProfileResponse
54, // [54:54] is the sub-list for extension extendee 56, // 93: hyapp.luckygift.v1.AdminLuckyGiftService.ListLuckyGiftDailyStats:output_type -> hyapp.luckygift.v1.ListLuckyGiftDailyStatsResponse
0, // [0:54] is the sub-list for field type_name 75, // [75:94] is the sub-list for method output_type
56, // [56:75] is the sub-list for method input_type
56, // [56:56] is the sub-list for extension type_name
56, // [56:56] is the sub-list for extension extendee
0, // [0:56] is the sub-list for field type_name
} }
func init() { file_proto_luckygift_v1_luckygift_proto_init() } func init() { file_proto_luckygift_v1_luckygift_proto_init() }
@ -5880,7 +6119,7 @@ func file_proto_luckygift_v1_luckygift_proto_init() {
GoPackagePath: reflect.TypeOf(x{}).PkgPath(), GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
RawDescriptor: unsafe.Slice(unsafe.StringData(file_proto_luckygift_v1_luckygift_proto_rawDesc), len(file_proto_luckygift_v1_luckygift_proto_rawDesc)), RawDescriptor: unsafe.Slice(unsafe.StringData(file_proto_luckygift_v1_luckygift_proto_rawDesc), len(file_proto_luckygift_v1_luckygift_proto_rawDesc)),
NumEnums: 0, NumEnums: 0,
NumMessages: 54, NumMessages: 57,
NumExtensions: 0, NumExtensions: 0,
NumServices: 2, NumServices: 2,
}, },

View File

@ -452,6 +452,7 @@ service AdminLuckyGiftService {
rpc EndLuckyGiftExperiment(EndLuckyGiftExperimentRequest) returns (EndLuckyGiftExperimentResponse); rpc EndLuckyGiftExperiment(EndLuckyGiftExperimentRequest) returns (EndLuckyGiftExperimentResponse);
rpc ListLuckyGiftUserProfiles(ListLuckyGiftUserProfilesRequest) returns (ListLuckyGiftUserProfilesResponse); rpc ListLuckyGiftUserProfiles(ListLuckyGiftUserProfilesRequest) returns (ListLuckyGiftUserProfilesResponse);
rpc GetLuckyGiftUserProfile(GetLuckyGiftUserProfileRequest) returns (GetLuckyGiftUserProfileResponse); rpc GetLuckyGiftUserProfile(GetLuckyGiftUserProfileRequest) returns (GetLuckyGiftUserProfileResponse);
rpc ListLuckyGiftDailyStats(ListLuckyGiftDailyStatsRequest) returns (ListLuckyGiftDailyStatsResponse);
} }
// LuckyGiftHit // LuckyGiftHit
@ -631,3 +632,33 @@ message GetLuckyGiftUserProfileResponse {
string next_jackpot_cursor = 8; string next_jackpot_cursor = 8;
bool jackpot_has_more = 9; bool jackpot_has_more = 9;
} }
// ListLuckyGiftDailyStats Databi lucky_draw_pool_day_stats
// +08:00 BI 访
// strategy_version V3 tab dynamic_v3
message ListLuckyGiftDailyStatsRequest {
RequestMeta meta = 1;
// pool_id
string pool_id = 2;
// strategy_version fixed_v2 / dynamic_v3
string strategy_version = 3;
// [start_ms, end_ms) UTC epoch ms stat_day
int64 start_ms = 4;
int64 end_ms = 5;
// region_ids ID 0=GLOBAL
repeated int64 region_ids = 6;
}
message LuckyGiftDailyStat {
// stat_day +08:00YYYY-MM-DD
string stat_day = 1;
int64 draw_count = 2;
int64 turnover_coin = 3;
// payout_coin effective DrawSummary /
int64 payout_coin = 4;
int64 base_reward_coin = 5;
}
message ListLuckyGiftDailyStatsResponse {
repeated LuckyGiftDailyStat stats = 1;
}

View File

@ -249,6 +249,7 @@ const (
AdminLuckyGiftService_EndLuckyGiftExperiment_FullMethodName = "/hyapp.luckygift.v1.AdminLuckyGiftService/EndLuckyGiftExperiment" AdminLuckyGiftService_EndLuckyGiftExperiment_FullMethodName = "/hyapp.luckygift.v1.AdminLuckyGiftService/EndLuckyGiftExperiment"
AdminLuckyGiftService_ListLuckyGiftUserProfiles_FullMethodName = "/hyapp.luckygift.v1.AdminLuckyGiftService/ListLuckyGiftUserProfiles" AdminLuckyGiftService_ListLuckyGiftUserProfiles_FullMethodName = "/hyapp.luckygift.v1.AdminLuckyGiftService/ListLuckyGiftUserProfiles"
AdminLuckyGiftService_GetLuckyGiftUserProfile_FullMethodName = "/hyapp.luckygift.v1.AdminLuckyGiftService/GetLuckyGiftUserProfile" AdminLuckyGiftService_GetLuckyGiftUserProfile_FullMethodName = "/hyapp.luckygift.v1.AdminLuckyGiftService/GetLuckyGiftUserProfile"
AdminLuckyGiftService_ListLuckyGiftDailyStats_FullMethodName = "/hyapp.luckygift.v1.AdminLuckyGiftService/ListLuckyGiftDailyStats"
) )
// AdminLuckyGiftServiceClient is the client API for AdminLuckyGiftService service. // AdminLuckyGiftServiceClient is the client API for AdminLuckyGiftService service.
@ -269,6 +270,7 @@ type AdminLuckyGiftServiceClient interface {
EndLuckyGiftExperiment(ctx context.Context, in *EndLuckyGiftExperimentRequest, opts ...grpc.CallOption) (*EndLuckyGiftExperimentResponse, error) EndLuckyGiftExperiment(ctx context.Context, in *EndLuckyGiftExperimentRequest, opts ...grpc.CallOption) (*EndLuckyGiftExperimentResponse, error)
ListLuckyGiftUserProfiles(ctx context.Context, in *ListLuckyGiftUserProfilesRequest, opts ...grpc.CallOption) (*ListLuckyGiftUserProfilesResponse, error) ListLuckyGiftUserProfiles(ctx context.Context, in *ListLuckyGiftUserProfilesRequest, opts ...grpc.CallOption) (*ListLuckyGiftUserProfilesResponse, error)
GetLuckyGiftUserProfile(ctx context.Context, in *GetLuckyGiftUserProfileRequest, opts ...grpc.CallOption) (*GetLuckyGiftUserProfileResponse, error) GetLuckyGiftUserProfile(ctx context.Context, in *GetLuckyGiftUserProfileRequest, opts ...grpc.CallOption) (*GetLuckyGiftUserProfileResponse, error)
ListLuckyGiftDailyStats(ctx context.Context, in *ListLuckyGiftDailyStatsRequest, opts ...grpc.CallOption) (*ListLuckyGiftDailyStatsResponse, error)
} }
type adminLuckyGiftServiceClient struct { type adminLuckyGiftServiceClient struct {
@ -419,6 +421,16 @@ func (c *adminLuckyGiftServiceClient) GetLuckyGiftUserProfile(ctx context.Contex
return out, nil return out, nil
} }
func (c *adminLuckyGiftServiceClient) ListLuckyGiftDailyStats(ctx context.Context, in *ListLuckyGiftDailyStatsRequest, opts ...grpc.CallOption) (*ListLuckyGiftDailyStatsResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(ListLuckyGiftDailyStatsResponse)
err := c.cc.Invoke(ctx, AdminLuckyGiftService_ListLuckyGiftDailyStats_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
// AdminLuckyGiftServiceServer is the server API for AdminLuckyGiftService service. // AdminLuckyGiftServiceServer is the server API for AdminLuckyGiftService service.
// All implementations must embed UnimplementedAdminLuckyGiftServiceServer // All implementations must embed UnimplementedAdminLuckyGiftServiceServer
// for forward compatibility. // for forward compatibility.
@ -437,6 +449,7 @@ type AdminLuckyGiftServiceServer interface {
EndLuckyGiftExperiment(context.Context, *EndLuckyGiftExperimentRequest) (*EndLuckyGiftExperimentResponse, error) EndLuckyGiftExperiment(context.Context, *EndLuckyGiftExperimentRequest) (*EndLuckyGiftExperimentResponse, error)
ListLuckyGiftUserProfiles(context.Context, *ListLuckyGiftUserProfilesRequest) (*ListLuckyGiftUserProfilesResponse, error) ListLuckyGiftUserProfiles(context.Context, *ListLuckyGiftUserProfilesRequest) (*ListLuckyGiftUserProfilesResponse, error)
GetLuckyGiftUserProfile(context.Context, *GetLuckyGiftUserProfileRequest) (*GetLuckyGiftUserProfileResponse, error) GetLuckyGiftUserProfile(context.Context, *GetLuckyGiftUserProfileRequest) (*GetLuckyGiftUserProfileResponse, error)
ListLuckyGiftDailyStats(context.Context, *ListLuckyGiftDailyStatsRequest) (*ListLuckyGiftDailyStatsResponse, error)
mustEmbedUnimplementedAdminLuckyGiftServiceServer() mustEmbedUnimplementedAdminLuckyGiftServiceServer()
} }
@ -489,6 +502,9 @@ func (UnimplementedAdminLuckyGiftServiceServer) ListLuckyGiftUserProfiles(contex
func (UnimplementedAdminLuckyGiftServiceServer) GetLuckyGiftUserProfile(context.Context, *GetLuckyGiftUserProfileRequest) (*GetLuckyGiftUserProfileResponse, error) { func (UnimplementedAdminLuckyGiftServiceServer) GetLuckyGiftUserProfile(context.Context, *GetLuckyGiftUserProfileRequest) (*GetLuckyGiftUserProfileResponse, error) {
return nil, status.Error(codes.Unimplemented, "method GetLuckyGiftUserProfile not implemented") return nil, status.Error(codes.Unimplemented, "method GetLuckyGiftUserProfile not implemented")
} }
func (UnimplementedAdminLuckyGiftServiceServer) ListLuckyGiftDailyStats(context.Context, *ListLuckyGiftDailyStatsRequest) (*ListLuckyGiftDailyStatsResponse, error) {
return nil, status.Error(codes.Unimplemented, "method ListLuckyGiftDailyStats not implemented")
}
func (UnimplementedAdminLuckyGiftServiceServer) mustEmbedUnimplementedAdminLuckyGiftServiceServer() {} func (UnimplementedAdminLuckyGiftServiceServer) mustEmbedUnimplementedAdminLuckyGiftServiceServer() {}
func (UnimplementedAdminLuckyGiftServiceServer) testEmbeddedByValue() {} func (UnimplementedAdminLuckyGiftServiceServer) testEmbeddedByValue() {}
@ -762,6 +778,24 @@ func _AdminLuckyGiftService_GetLuckyGiftUserProfile_Handler(srv interface{}, ctx
return interceptor(ctx, in, info, handler) return interceptor(ctx, in, info, handler)
} }
func _AdminLuckyGiftService_ListLuckyGiftDailyStats_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(ListLuckyGiftDailyStatsRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(AdminLuckyGiftServiceServer).ListLuckyGiftDailyStats(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: AdminLuckyGiftService_ListLuckyGiftDailyStats_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(AdminLuckyGiftServiceServer).ListLuckyGiftDailyStats(ctx, req.(*ListLuckyGiftDailyStatsRequest))
}
return interceptor(ctx, in, info, handler)
}
// AdminLuckyGiftService_ServiceDesc is the grpc.ServiceDesc for AdminLuckyGiftService service. // AdminLuckyGiftService_ServiceDesc is the grpc.ServiceDesc for AdminLuckyGiftService service.
// It's only intended for direct use with grpc.RegisterService, // It's only intended for direct use with grpc.RegisterService,
// and not to be introspected or modified (even as a copy) // and not to be introspected or modified (even as a copy)
@ -825,6 +859,10 @@ var AdminLuckyGiftService_ServiceDesc = grpc.ServiceDesc{
MethodName: "GetLuckyGiftUserProfile", MethodName: "GetLuckyGiftUserProfile",
Handler: _AdminLuckyGiftService_GetLuckyGiftUserProfile_Handler, Handler: _AdminLuckyGiftService_GetLuckyGiftUserProfile_Handler,
}, },
{
MethodName: "ListLuckyGiftDailyStats",
Handler: _AdminLuckyGiftService_ListLuckyGiftDailyStats_Handler,
},
}, },
Streams: []grpc.StreamDesc{}, Streams: []grpc.StreamDesc{},
Metadata: "proto/luckygift/v1/luckygift.proto", Metadata: "proto/luckygift/v1/luckygift.proto",

View File

@ -350,6 +350,7 @@ func main() {
appRegistryService, appRegistryService,
userclient.NewGRPC(userConn), userclient.NewGRPC(userConn),
databimodule.WithFinanceRechargeOverviewSource(paymentHandler), databimodule.WithFinanceRechargeOverviewSource(paymentHandler),
databimodule.WithLuckyGiftDailyStats(luckygiftadmin.NewGRPC(luckyGiftConn), cfg.LuckyGiftService.RequestTimeout),
databimodule.WithLegacyApps(databiLegacyApps(cfg.DashboardExternalSources, cfg.MoneyRegionSources)...), databimodule.WithLegacyApps(databiLegacyApps(cfg.DashboardExternalSources, cfg.MoneyRegionSources)...),
databimodule.WithLegacyRegionCatalogs(databiLegacyRegionCatalogs(moneyRegionSources)...), databimodule.WithLegacyRegionCatalogs(databiLegacyRegionCatalogs(moneyRegionSources)...),
) )

View File

@ -25,6 +25,7 @@ type Client interface {
UpdateLuckyGiftExperiment(ctx context.Context, req *luckygiftv1.UpdateLuckyGiftExperimentRequest) (*luckygiftv1.UpdateLuckyGiftExperimentResponse, error) UpdateLuckyGiftExperiment(ctx context.Context, req *luckygiftv1.UpdateLuckyGiftExperimentRequest) (*luckygiftv1.UpdateLuckyGiftExperimentResponse, error)
SetLuckyGiftExperimentOverrides(ctx context.Context, req *luckygiftv1.SetLuckyGiftExperimentOverridesRequest) (*luckygiftv1.SetLuckyGiftExperimentOverridesResponse, error) SetLuckyGiftExperimentOverrides(ctx context.Context, req *luckygiftv1.SetLuckyGiftExperimentOverridesRequest) (*luckygiftv1.SetLuckyGiftExperimentOverridesResponse, error)
EndLuckyGiftExperiment(ctx context.Context, req *luckygiftv1.EndLuckyGiftExperimentRequest) (*luckygiftv1.EndLuckyGiftExperimentResponse, error) EndLuckyGiftExperiment(ctx context.Context, req *luckygiftv1.EndLuckyGiftExperimentRequest) (*luckygiftv1.EndLuckyGiftExperimentResponse, error)
ListLuckyGiftDailyStats(ctx context.Context, req *luckygiftv1.ListLuckyGiftDailyStatsRequest) (*luckygiftv1.ListLuckyGiftDailyStatsResponse, error)
} }
type GRPCClient struct { type GRPCClient struct {
@ -90,3 +91,7 @@ func (c *GRPCClient) SetLuckyGiftExperimentOverrides(ctx context.Context, req *l
func (c *GRPCClient) EndLuckyGiftExperiment(ctx context.Context, req *luckygiftv1.EndLuckyGiftExperimentRequest) (*luckygiftv1.EndLuckyGiftExperimentResponse, error) { func (c *GRPCClient) EndLuckyGiftExperiment(ctx context.Context, req *luckygiftv1.EndLuckyGiftExperimentRequest) (*luckygiftv1.EndLuckyGiftExperimentResponse, error) {
return c.client.EndLuckyGiftExperiment(ctx, req) return c.client.EndLuckyGiftExperiment(ctx, req)
} }
func (c *GRPCClient) ListLuckyGiftDailyStats(ctx context.Context, req *luckygiftv1.ListLuckyGiftDailyStatsRequest) (*luckygiftv1.ListLuckyGiftDailyStatsResponse, error) {
return c.client.ListLuckyGiftDailyStats(ctx, req)
}

View File

@ -0,0 +1,162 @@
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
}

View File

@ -0,0 +1,110 @@
package databi
import (
"context"
"testing"
"time"
luckygiftv1 "hyapp.local/api/proto/luckygift/v1"
)
type fakeLuckyGiftDailyStatsSource struct {
lastReq *luckygiftv1.ListLuckyGiftDailyStatsRequest
stats []*luckygiftv1.LuckyGiftDailyStat
}
func (f *fakeLuckyGiftDailyStatsSource) ListLuckyGiftDailyStats(_ context.Context, req *luckygiftv1.ListLuckyGiftDailyStatsRequest) (*luckygiftv1.ListLuckyGiftDailyStatsResponse, error) {
f.lastReq = req
return &luckygiftv1.ListLuckyGiftDailyStatsResponse{Stats: f.stats}, nil
}
func TestRequirementsLuckyGiftV3Gating(t *testing.T) {
for _, tc := range []struct {
section string
includes bool
only bool
}{
{"", true, false},
{"all", true, false},
{"lucky_gift_v3", true, true},
{" Lucky_Gift_V3 ", true, true},
{"revenue", false, false},
{"game", false, false},
} {
if got := requirementsIncludesLuckyGiftV3(tc.section); got != tc.includes {
t.Fatalf("includes(%q)=%v, want %v", tc.section, got, tc.includes)
}
if got := requirementsOnlyLuckyGiftV3(tc.section); got != tc.only {
t.Fatalf("only(%q)=%v, want %v", tc.section, got, tc.only)
}
}
}
// 回归section 组装——按北京日零填充整个窗口、比例按合计重算、v3 策略与区域过滤透传到 RPC。
func TestLuckyGiftV3Section(t *testing.T) {
source := &fakeLuckyGiftDailyStatsSource{stats: []*luckygiftv1.LuckyGiftDailyStat{
{StatDay: "2026-07-11", DrawCount: 4, TurnoverCoin: 8_000, PayoutCoin: 6_000, BaseRewardCoin: 5_500},
}}
service := &Service{luckyGiftStats: source, luckyGiftTimeout: time.Second}
// 北京 2026-07-10 00:00 ~ 2026-07-12 00:00排他= 两个北京日。
startMS := time.Date(2026, 7, 9, 16, 0, 0, 0, time.UTC).UnixMilli()
endMS := time.Date(2026, 7, 11, 16, 0, 0, 0, time.UTC).UnixMilli()
section, err := service.luckyGiftV3Section(context.Background(), "lalu", RequirementsQuery{StartMS: startMS, EndMS: endMS}, []int64{7, 8})
if err != nil {
t.Fatalf("build section: %v", err)
}
if source.lastReq.GetStrategyVersion() != "dynamic_v3" || source.lastReq.GetMeta().GetAppCode() != "lalu" {
t.Fatalf("rpc request mismatch: %+v", source.lastReq)
}
if regions := source.lastReq.GetRegionIds(); len(regions) != 2 || regions[0] != 7 || regions[1] != 8 {
t.Fatalf("rpc region ids mismatch: %v", regions)
}
if section["key"] != luckyGiftV3SectionKey {
t.Fatalf("section key mismatch: %v", section["key"])
}
rows, ok := section["daily_series"].([]map[string]any)
if !ok || len(rows) != 2 {
t.Fatalf("daily_series rows=%v, want 2 zero-filled days", section["daily_series"])
}
if rows[0]["stat_day"] != "2026-07-10" || rows[0]["draw_count"] != float64(0) {
t.Fatalf("zero-filled day mismatch: %+v", rows[0])
}
if rows[0]["payout_rate"] != nil {
t.Fatalf("zero turnover day must render nil ratio, got %v", rows[0]["payout_rate"])
}
if rows[1]["stat_day"] != "2026-07-11" || rows[1]["turnover_coin"] != float64(8_000) || rows[1]["profit_coin"] != float64(2_000) {
t.Fatalf("data day mismatch: %+v", rows[1])
}
if rate := rows[1]["payout_rate"].(float64); rate != 0.75 {
t.Fatalf("payout_rate=%v, want 0.75", rate)
}
total, ok := section["total"].(map[string]any)
if !ok || total["draw_count"] != float64(4) || total["profit_coin"] != float64(2_000) {
t.Fatalf("total mismatch: %+v", total)
}
average, ok := section["average"].(map[string]any)
if !ok || average["draw_count"] != float64(2) || average["turnover_coin"] != float64(4_000) {
t.Fatalf("average mismatch: %+v", average)
}
// 平均值行的比例 = 合计比例4000/… 与 6000/8000 相同),不是逐日比例算术平均。
if rate := average["payout_rate"].(float64); rate != 0.75 {
t.Fatalf("average payout_rate=%v, want 0.75", rate)
}
}
func TestLuckyGiftV3StatDaysCrossesBeijingMidnight(t *testing.T) {
// UTC 2026-07-10 15:00 ~ 17:00 跨北京 7-10/7-11 两日。
startMS := time.Date(2026, 7, 10, 15, 0, 0, 0, time.UTC).UnixMilli()
endMS := time.Date(2026, 7, 10, 17, 0, 0, 0, time.UTC).UnixMilli()
days := luckyGiftV3StatDays(startMS, endMS)
if len(days) != 2 || days[0] != "2026-07-10" || days[1] != "2026-07-11" {
t.Fatalf("days=%v, want [2026-07-10 2026-07-11]", days)
}
if got := luckyGiftV3StatDays(endMS, startMS); got != nil {
t.Fatalf("inverted range must return nil, got %v", got)
}
}

View File

@ -52,6 +52,8 @@ type Service struct {
apps *appregistry.Service apps *appregistry.Service
user userclient.Client user userclient.Client
financeRecharge payment.FinanceRechargeOverviewSource financeRecharge payment.FinanceRechargeOverviewSource
luckyGiftStats LuckyGiftDailyStatsSource
luckyGiftTimeout time.Duration
legacyApps []LegacyAppDescriptor legacyApps []LegacyAppDescriptor
legacyRegionCatalogs []LegacyRegionCatalogSource legacyRegionCatalogs []LegacyRegionCatalogSource
@ -483,6 +485,22 @@ func (s *Service) appRequirements(ctx context.Context, app AppInfo, regions []Re
// 顶栏多选区域时保留多区域筛选;单区域仍走 region_id 以兼容旧统计入口。 // 顶栏多选区域时保留多区域筛选;单区域仍走 region_id 以兼容旧统计入口。
regionIDs = requestRegionIDs regionIDs = requestRegionIDs
} }
if requirementsOnlyLuckyGiftV3(query.Section) {
// 只要幸运礼物V3板块时跳过 statistics-service它不认识该 section key
// 透传会触发"未知 key 回退全量六板块"的最重查询路径。
if s.luckyGiftStats == nil {
out.Error = "幸运礼物V3统计源未配置"
return out
}
luckySection, err := s.luckyGiftV3Section(ctx, app.AppCode, query, luckyGiftV3RegionIDs(query, regions, regionIDs))
if err != nil {
out.Error = fmt.Sprintf("获取幸运礼物V3统计失败: %v", err)
return out
}
out.Sections = append(out.Sections, luckySection)
out.UpdatedAtMS = time.Now().UnixMilli()
return out
}
requirements, err := s.dashboards.SocialRequirements(ctx, dashboard.SocialRequirementsQuery{ requirements, err := s.dashboards.SocialRequirements(ctx, dashboard.SocialRequirementsQuery{
AppCode: app.AppCode, AppCode: app.AppCode,
StatTZ: query.StatTZ, StatTZ: query.StatTZ,
@ -500,6 +518,14 @@ func (s *Service) appRequirements(ctx context.Context, app AppInfo, regions []Re
return out return out
} }
out.Sections = anyRowSlice(requirements["sections"]) out.Sections = anyRowSlice(requirements["sections"])
if s.luckyGiftStats != nil && requirementsIncludesLuckyGiftV3(query.Section) {
luckySection, err := s.luckyGiftV3Section(ctx, app.AppCode, query, luckyGiftV3RegionIDs(query, regions, regionIDs))
if err != nil {
out.Error = fmt.Sprintf("获取幸运礼物V3统计失败: %v", err)
return out
}
out.Sections = append(out.Sections, luckySection)
}
if s.financeRecharge != nil && requirementsIncludesRevenue(query.Section) { if s.financeRecharge != nil && requirementsIncludesRevenue(query.Section) {
if requirementsHasCohortFilter(query) { if requirementsHasCohortFilter(query) {
// Finance 不具备角色/付费身份/新老用户维度;筛选后必须把金额标为不可用, // Finance 不具备角色/付费身份/新老用户维度;筛选后必须把金额标为不可用,

View File

@ -368,17 +368,18 @@ CREATE TABLE IF NOT EXISTS lucky_draw_pool_stats (
CREATE TABLE IF NOT EXISTS lucky_draw_pool_day_stats ( CREATE TABLE IF NOT EXISTS lucky_draw_pool_day_stats (
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码', app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码',
stat_day DATE NOT NULL COMMENT 'UTC 统计日', stat_day DATE NOT NULL COMMENT '北京日(+08:00与 statistics-service 社交 BI 的分日口径一致',
visible_region_id BIGINT NOT NULL DEFAULT 0 COMMENT '房间可见区域 ID', visible_region_id BIGINT NOT NULL DEFAULT 0 COMMENT '房间可见区域 ID',
pool_id VARCHAR(96) NOT NULL COMMENT '幸运礼物奖池 ID', pool_id VARCHAR(96) NOT NULL COMMENT '幸运礼物奖池 ID',
gift_id VARCHAR(96) NOT NULL DEFAULT '' COMMENT '礼物 ID空值表示奖池汇总', gift_id VARCHAR(96) NOT NULL DEFAULT '' COMMENT '礼物 ID空值表示奖池汇总',
strategy_version VARCHAR(32) NOT NULL DEFAULT 'fixed_v2' COMMENT '策略族 fixed_v2/dynamic_v3AB 实验下同池同日会双行BI 按策略过滤',
draw_count BIGINT NOT NULL DEFAULT 0 COMMENT '抽奖次数', draw_count BIGINT NOT NULL DEFAULT 0 COMMENT '抽奖次数',
turnover_coin BIGINT NOT NULL DEFAULT 0 COMMENT '消耗金币', turnover_coin BIGINT NOT NULL DEFAULT 0 COMMENT '消耗金币',
payout_coin BIGINT NOT NULL DEFAULT 0 COMMENT '用户可见返奖金币', payout_coin BIGINT NOT NULL DEFAULT 0 COMMENT '用户可见返奖金币',
base_reward_coin BIGINT NOT NULL DEFAULT 0 COMMENT '基础 RTP 返奖', base_reward_coin BIGINT NOT NULL DEFAULT 0 COMMENT '基础 RTP 返奖',
created_at_ms BIGINT NOT NULL COMMENT '创建时间UTC epoch ms', created_at_ms BIGINT NOT NULL COMMENT '创建时间UTC epoch ms',
updated_at_ms BIGINT NOT NULL COMMENT '更新时间UTC epoch ms', updated_at_ms BIGINT NOT NULL COMMENT '更新时间UTC epoch ms',
PRIMARY KEY (app_code, stat_day, visible_region_id, pool_id, gift_id), PRIMARY KEY (app_code, stat_day, visible_region_id, pool_id, gift_id, strategy_version),
KEY idx_lucky_draw_pool_day_overview (app_code, stat_day, pool_id) KEY idx_lucky_draw_pool_day_overview (app_code, stat_day, pool_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='幸运礼物 Databi 日维度汇总'; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='幸运礼物 Databi 日维度汇总';

View File

@ -0,0 +1,24 @@
SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci;
USE hyapp_lucky_gift;
-- lucky_draw_pool_day_stats 增加策略族维度,供社交 BI「幸运礼物V3」tab 按 dynamic_v3 过滤。
-- 存量行(含迁移 003 从 hyapp_activity 导入的历史)全部产生于 fixed_v2 时代,列默认值即正确归因;
-- 不清数据、不动 draw_records_databi_day 游标,刷新循环从当前位置继续,新行由代码显式写策略值。
-- 主键需要纳入 strategy_versionAB 实验下同池同日两策略并存必须分行),
-- DROP/ADD PRIMARY KEY 走 INPLACE 在线重建;该表是天×区域×奖池×礼物量级的小汇总表,重建秒级。
SET @needs := (
SELECT COUNT(*) = 0 FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'lucky_draw_pool_day_stats'
AND COLUMN_NAME = 'strategy_version'
);
SET @ddl := IF(@needs,
'ALTER TABLE lucky_draw_pool_day_stats
ADD COLUMN strategy_version VARCHAR(32) NOT NULL DEFAULT ''fixed_v2'' COMMENT '' fixed_v2/dynamic_v3AB BI '' AFTER gift_id,
DROP PRIMARY KEY,
ADD PRIMARY KEY (app_code, stat_day, visible_region_id, pool_id, gift_id, strategy_version),
ALGORITHM=INPLACE, LOCK=NONE',
'SELECT 1'
);
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;

View File

@ -281,6 +281,30 @@ type DrawSummary struct {
FailedDraws int64 FailedDraws int64
} }
// DailyStatsQuery 是 Databi 按日聚合的查询输入;只读 lucky_draw_pool_day_stats 读模型,
// [StartMS, EndMS) 由存储层按北京日(+08:00换算成 stat_day 闭区间。
type DailyStatsQuery struct {
AppCode string
// PoolID 为空表示全部奖池。
PoolID string
// StrategyVersion 为空表示不按策略过滤;否则必须是 fixed_v2 / dynamic_v3。
StrategyVersion string
StartMS int64
EndMS int64
// RegionIDs 为空表示全部区域(含 GLOBAL=0 房间);非空时按房间可见区域过滤。
RegionIDs []int64
}
// DailyStat 是单个北京日的抽奖汇总;口径与 DrawSummary 一致:
// payout 用用户可见返奖effective不按 reward_status 过滤。
type DailyStat struct {
StatDay string
DrawCount int64
TurnoverCoin int64
PayoutCoin int64
BaseRewardCoin int64
}
// PoolBalance 是后台看到的 app+pool_id 单池账本快照。 // PoolBalance 是后台看到的 app+pool_id 单池账本快照。
// materialized=false 表示该池还没有真实 lucky_pools 行,展示的是规则默认初始水位,不能当成已经入库的资金流水。 // materialized=false 表示该池还没有真实 lucky_pools 行,展示的是规则默认初始水位,不能当成已经入库的资金流水。
type PoolBalance struct { type PoolBalance struct {

View File

@ -45,6 +45,7 @@ type Repository interface {
ListLuckyGiftUserProfiles(ctx context.Context, query domain.UserProfileQuery, nowMS int64) ([]domain.UserProfile, int64, string, bool, error) ListLuckyGiftUserProfiles(ctx context.Context, query domain.UserProfileQuery, nowMS int64) ([]domain.UserProfile, int64, string, bool, error)
GetLuckyGiftUserProfile(ctx context.Context, query domain.UserProfileQuery, nowMS int64) (domain.UserProfileDetail, bool, error) GetLuckyGiftUserProfile(ctx context.Context, query domain.UserProfileQuery, nowMS int64) (domain.UserProfileDetail, bool, error)
ListLuckyGiftPoolBalances(ctx context.Context, query domain.PoolBalanceQuery) ([]domain.PoolBalance, error) ListLuckyGiftPoolBalances(ctx context.Context, query domain.PoolBalanceQuery) ([]domain.PoolBalance, error)
ListLuckyGiftDailyStats(ctx context.Context, query domain.DailyStatsQuery) ([]domain.DailyStat, error)
AdjustLuckyGiftPoolBalance(ctx context.Context, command domain.PoolAdjustmentCommand, nowMS int64) (domain.PoolAdjustmentResult, error) AdjustLuckyGiftPoolBalance(ctx context.Context, command domain.PoolAdjustmentCommand, nowMS int64) (domain.PoolAdjustmentResult, error)
GetLuckyGiftDrawRewardState(ctx context.Context, appCode string, drawIDs []string) (domain.DrawRewardState, error) GetLuckyGiftDrawRewardState(ctx context.Context, appCode string, drawIDs []string) (domain.DrawRewardState, error)
ClaimPendingLuckyGiftOutbox(ctx context.Context, workerID string, nowMS int64, lockTTL time.Duration, batchSize int) ([]domain.DrawOutbox, error) ClaimPendingLuckyGiftOutbox(ctx context.Context, workerID string, nowMS int64, lockTTL time.Duration, batchSize int) ([]domain.DrawOutbox, error)
@ -806,6 +807,46 @@ func (s *Service) GetDrawSummary(ctx context.Context, query domain.DrawQuery) (d
return s.repository.GetLuckyGiftDrawSummary(ctx, normalizeDrawQuery(query)) return s.repository.GetLuckyGiftDrawSummary(ctx, normalizeDrawQuery(query))
} }
// ListDailyStats 是 Databi 的按日聚合读入口app_code fail-close 必填,避免跨租户读汇总。
func (s *Service) ListDailyStats(ctx context.Context, query domain.DailyStatsQuery) ([]domain.DailyStat, error) {
if err := s.requireRepository(); err != nil {
return nil, err
}
query.AppCode = strings.ToLower(strings.TrimSpace(query.AppCode))
if query.AppCode == "" {
return nil, xerr.New(xerr.InvalidArgument, "lucky gift daily stats app_code is required")
}
if strings.TrimSpace(query.PoolID) != "" {
query.PoolID = normalizePoolID(query.PoolID)
} else {
query.PoolID = ""
}
query.StrategyVersion = strings.ToLower(strings.TrimSpace(query.StrategyVersion))
if query.StrategyVersion != "" && query.StrategyVersion != domain.StrategyFixedV2 && query.StrategyVersion != domain.StrategyDynamicV3 {
return nil, xerr.New(xerr.InvalidArgument, "unsupported lucky gift strategy version")
}
if query.StartMS <= 0 || query.EndMS <= query.StartMS {
return nil, xerr.New(xerr.InvalidArgument, "lucky gift daily stats requires a valid [start_ms, end_ms) range")
}
// 读模型按日聚合本身很轻,上限只防御异常调用方传超长窗口拖垮分组。
const maxDailyStatsRangeMS = 400 * 24 * int64(time.Hour/time.Millisecond)
if query.EndMS-query.StartMS > maxDailyStatsRangeMS {
return nil, xerr.New(xerr.InvalidArgument, "lucky gift daily stats range is too large")
}
regionIDs := make([]int64, 0, len(query.RegionIDs))
for _, regionID := range query.RegionIDs {
if regionID > 0 {
regionIDs = append(regionIDs, regionID)
}
}
if len(query.RegionIDs) > 0 && len(regionIDs) == 0 {
// 区域过滤全部非法时不能退化成"不过滤"——那会把受限调用方放大到全量口径。
return []domain.DailyStat{}, nil
}
query.RegionIDs = regionIDs
return s.repository.ListLuckyGiftDailyStats(ctx, query)
}
func (s *Service) ListUserProfiles(ctx context.Context, query domain.UserProfileQuery) ([]domain.UserProfile, int64, string, bool, int64, error) { func (s *Service) ListUserProfiles(ctx context.Context, query domain.UserProfileQuery) ([]domain.UserProfile, int64, string, bool, int64, error) {
if err := s.requireRepository(); err != nil { if err := s.requireRepository(); err != nil {
return nil, 0, "", false, 0, err return nil, 0, "", false, 0, err

View File

@ -0,0 +1,136 @@
package mysql
import (
"context"
"testing"
"time"
"hyapp/internal/testutil/mysqlschema"
"hyapp/pkg/appcode"
domain "hyapp/services/lucky-gift-service/internal/domain/luckygift"
)
func dailyStatsTestRule(poolID string, strategy string, targetRTPPPM int64) domain.RuleConfig {
return domain.RuleConfig{
PoolID: poolID,
Enabled: true,
StrategyVersion: strategy,
TargetRTPPPM: targetRTPPPM,
PoolRatePPM: 980_000,
SettlementWindowWager: 1_000_000,
ControlBandPPM: 10_000,
GiftPriceReference: 100,
NoviceMaxEquivalentDraws: 2_000,
NormalMaxEquivalentDraws: 20_000,
Stages: []domain.RuleStage{
{Stage: domain.StageNovice, Tiers: []domain.RuleTier{
{Stage: domain.StageNovice, TierID: "novice_1x", MultiplierPPM: 1_000_000, BaseWeightPPM: 1_000_000, Enabled: true},
}},
},
}
}
// 回归Databi 日汇总的策略归因与北京日分桶——刷新循环把抽奖按 rule_version 归因到策略族,
// 分日用 +08:0023:00 UTC 的抽奖落在次日北京日),日聚合只读奖池汇总行不重复计礼物明细行,
// 策略/区域过滤和找不到规则行时回落 fixed_v2 的行为都在此锁死。
func TestLuckyGiftDailyStatsStrategyAttribution(t *testing.T) {
caller := mysqlschema.CallerFile(t, 1)
schema := mysqlschema.New(t, mysqlschema.Config{
EnvVar: "LUCKY_GIFT_SERVICE_MYSQL_TEST_DSN",
InitDBPath: mysqlschema.InitDBPath(t, caller, "..", "..", "..", "deploy", "mysql", "initdb", "001_lucky_gift_service.sql"),
DatabasePrefix: "hy_lucky_gift_day",
})
repo := &Repository{db: schema.DB}
ctx := appcode.WithContext(context.Background(), "lalu")
if _, err := repo.PublishLuckyGiftRuleConfig(ctx, dailyStatsTestRule("lucky", domain.StrategyFixedV2, 950_000), 1_700_000_000_000); err != nil {
t.Fatalf("publish fixed_v2 rule: %v", err)
}
if _, err := repo.PublishLuckyGiftRuleConfig(ctx, dailyStatsTestRule("lucky", domain.StrategyDynamicV3, 960_000), 1_700_000_100_000); err != nil {
t.Fatalf("publish dynamic_v3 rule: %v", err)
}
seedDraw := func(drawID string, ruleVersion, regionID int64, giftID string, spent, reward, createdAtMS int64) {
t.Helper()
if _, err := schema.DB.Exec(`
INSERT INTO lucky_draw_records (
app_code, draw_id, command_id, user_id, device_id, room_id, anchor_id, visible_region_id, gift_id, pool_id,
rule_version, experience_pool, selected_tier_id, coin_spent,
base_reward_coins, effective_reward_coins, reward_status, rtp_window_index, gift_rtp_window_index,
candidate_tiers_json, pool_snapshot_json, rtp_snapshot_json,
paid_at_ms, created_at_ms, updated_at_ms
) VALUES ('lalu', ?, ?, 1, 'd', 'room', 'anchor', ?, ?, 'lucky', ?, 'novice', 't', ?, ?, ?, 'granted', 1, 1, '{}', '{}', '{}', ?, ?, ?)`,
drawID, "cmd-"+drawID, regionID, giftID, ruleVersion, spent, reward, reward, createdAtMS, createdAtMS, createdAtMS,
); err != nil {
t.Fatalf("seed draw %s: %v", drawID, err)
}
}
// 2026-07-10 12:00 UTC = 北京日 2026-07-102026-07-10 23:00 UTC = 北京日 2026-07-11。
noonUTC := time.Date(2026, 7, 10, 12, 0, 0, 0, time.UTC).UnixMilli()
lateUTC := time.Date(2026, 7, 10, 23, 0, 0, 0, time.UTC).UnixMilli()
seedDraw("v2-a", 1, 11, "g1", 1_000, 900, noonUTC)
seedDraw("v3-a", 2, 11, "g1", 2_000, 1_500, noonUTC)
seedDraw("v3-b", 2, 22, "g2", 3_000, 3_600, noonUTC)
// 北京日跨界UTC 当日 23 点的 v3 抽奖必须落在次日北京日。
seedDraw("v3-c", 2, 11, "g1", 5_000, 0, lateUTC)
// 找不到规则行的历史抽奖按 fixed_v2 归因。
seedDraw("orphan", 99, 11, "g1", 700, 0, noonUTC)
if _, err := repo.RefreshLuckyGiftDatabiStatsSnapshots(ctx, lateUTC+1_000, 100); err != nil {
t.Fatalf("refresh databi stats: %v", err)
}
rangeStart := time.Date(2026, 7, 9, 16, 0, 0, 0, time.UTC).UnixMilli() // 北京 2026-07-10 00:00
rangeEnd := time.Date(2026, 7, 11, 16, 0, 0, 0, time.UTC).UnixMilli() // 北京 2026-07-12 00:00排他
v3Stats, err := repo.ListLuckyGiftDailyStats(ctx, domain.DailyStatsQuery{
AppCode: "lalu", StrategyVersion: domain.StrategyDynamicV3, StartMS: rangeStart, EndMS: rangeEnd,
})
if err != nil {
t.Fatalf("list v3 daily stats: %v", err)
}
if len(v3Stats) != 2 {
t.Fatalf("v3 stats days=%d, want 2 (got %+v)", len(v3Stats), v3Stats)
}
day0, day1 := v3Stats[0], v3Stats[1]
if day0.StatDay != "2026-07-10" || day0.DrawCount != 2 || day0.TurnoverCoin != 5_000 || day0.PayoutCoin != 5_100 {
t.Fatalf("v3 day0 mismatch: %+v", day0)
}
if day1.StatDay != "2026-07-11" || day1.DrawCount != 1 || day1.TurnoverCoin != 5_000 || day1.PayoutCoin != 0 {
t.Fatalf("v3 day1 (北京跨日) mismatch: %+v", day1)
}
// fixed_v2 口径包含孤儿 rule_version 的回落归因。
v2Stats, err := repo.ListLuckyGiftDailyStats(ctx, domain.DailyStatsQuery{
AppCode: "lalu", StrategyVersion: domain.StrategyFixedV2, StartMS: rangeStart, EndMS: rangeEnd,
})
if err != nil {
t.Fatalf("list v2 daily stats: %v", err)
}
if len(v2Stats) != 1 || v2Stats[0].DrawCount != 2 || v2Stats[0].TurnoverCoin != 1_700 {
t.Fatalf("v2 stats mismatch (want orphan fallback included): %+v", v2Stats)
}
// 不带策略过滤时全量聚合;礼物明细行不能重复计数。
allStats, err := repo.ListLuckyGiftDailyStats(ctx, domain.DailyStatsQuery{
AppCode: "lalu", StartMS: rangeStart, EndMS: rangeEnd,
})
if err != nil {
t.Fatalf("list all daily stats: %v", err)
}
if len(allStats) != 2 || allStats[0].DrawCount != 4 || allStats[0].TurnoverCoin != 6_700 {
t.Fatalf("all stats mismatch: %+v", allStats)
}
// 区域过滤:只看 region 22 的 v3 抽奖。
regionStats, err := repo.ListLuckyGiftDailyStats(ctx, domain.DailyStatsQuery{
AppCode: "lalu", StrategyVersion: domain.StrategyDynamicV3, StartMS: rangeStart, EndMS: rangeEnd, RegionIDs: []int64{22},
})
if err != nil {
t.Fatalf("list region daily stats: %v", err)
}
if len(regionStats) != 1 || regionStats[0].DrawCount != 1 || regionStats[0].TurnoverCoin != 3_000 {
t.Fatalf("region stats mismatch: %+v", regionStats)
}
}

View File

@ -155,6 +155,15 @@ func (r *Repository) CreateLuckyGiftExperiment(ctx context.Context, cmd domain.C
if control.StrategyVersion != cmd.TreatmentConfig.StrategyVersion { if control.StrategyVersion != cmd.TreatmentConfig.StrategyVersion {
return domain.Experiment{}, domain.RuleConfig{}, xerr.New(xerr.InvalidArgument, "lucky gift experiment arms must share the same strategy version; migrate strategy via a regular publish instead") return domain.Experiment{}, domain.RuleConfig{}, xerr.New(xerr.InvalidArgument, "lucky gift experiment arms must share the same strategy version; migrate strategy via a regular publish instead")
} }
// v2 高水位加权滞回标志按 app+pool 单行存储lucky_pools.v2_boost_active不随 rule_version 键控;
// advance 用"当前用户被钉住的组内三元组"推进这份共享状态。两组三元组不同(尤其 control 关/treatment 开
// 的灰度形态control 的每一抽都会清掉 treatment 刚置位的标志,滞回带内加权几乎恒为关;两组阈值不同
// 还会让一组在自己从未越过阈值的水位按对方的状态加权付奖。加权特性的灰度必须走常规发布,不能走实验。
if control.V2HighWaterBoostThresholdCoins != cmd.TreatmentConfig.V2HighWaterBoostThresholdCoins ||
control.V2HighWaterBoostFactorPPM != cmd.TreatmentConfig.V2HighWaterBoostFactorPPM ||
control.V2HighWaterBoostRecoverCoins != cmd.TreatmentConfig.V2HighWaterBoostRecoverCoins {
return domain.Experiment{}, domain.RuleConfig{}, xerr.New(xerr.InvalidArgument, "lucky gift experiment arms must share identical v2 high-water boost settings; the pool-level hysteresis flag cannot serve two different boost configs")
}
// 锁读确保看见其他事务刚提交的实验行;同奖池并发建实验最终也会被唯一键兜底拒绝。 // 锁读确保看见其他事务刚提交的实验行;同奖池并发建实验最终也会被唯一键兜底拒绝。
// 已知取舍:不同奖池并发建实验可能在该唯一索引的共享间隙上互持 gap lock 后插入,触发 InnoDB 死锁检测 // 已知取舍:不同奖池并发建实验可能在该唯一索引的共享间隙上互持 gap lock 后插入,触发 InnoDB 死锁检测
// 回滚其一——管理端低频操作,报错重试即可,不为此引入额外串行化。 // 回滚其一——管理端低频操作,报错重试即可,不为此引入额外串行化。

View File

@ -345,3 +345,43 @@ func TestExperimentMigrationIsIdempotentAndEnforcesSingleActive(t *testing.T) {
t.Fatal("second unfinished experiment must violate uk_lucky_gift_experiments_active_pool") t.Fatal("second unfinished experiment must violate uk_lucky_gift_experiments_active_pool")
} }
} }
// 回归v2 高水位加权滞回标志按奖池单行共享两组三元组不同会互踩control 清 treatment 的标志、
// 阈值不同的组按对方状态加权付奖);实验创建必须硬性要求两组三元组一致。
func TestCreateLuckyGiftExperimentRejectsMismatchedV2HighWaterBoost(t *testing.T) {
caller := mysqlschema.CallerFile(t, 1)
schema := mysqlschema.New(t, mysqlschema.Config{
EnvVar: "LUCKY_GIFT_SERVICE_MYSQL_TEST_DSN",
InitDBPath: mysqlschema.InitDBPath(t, caller, "..", "..", "..", "deploy", "mysql", "initdb", "001_lucky_gift_service.sql"),
DatabasePrefix: "hy_lucky_gift_expboost",
})
repo := &Repository{db: schema.DB}
ctx := appcode.WithContext(context.Background(), "lalu")
control := experimentTestFixedRule("boost_pool", 950_000)
control.V2HighWaterBoostThresholdCoins = 2_000_000
control.V2HighWaterBoostFactorPPM = 1_500_000
control.V2HighWaterBoostRecoverCoins = 1_000_000
if _, err := repo.PublishLuckyGiftRuleConfig(ctx, control, 1_700_000_000_000); err != nil {
t.Fatalf("publish control config: %v", err)
}
// control 开启加权、treatment 关闭:正是会互踩滞回标志的灰度形态,必须被拒绝。
mismatched := experimentTestFixedRule("boost_pool", 950_000)
if _, _, err := repo.CreateLuckyGiftExperiment(ctx, domain.CreateExperimentCommand{
Name: "boost off arm", TreatmentConfig: mismatched, OperatorAdminID: 7,
}, 1_700_000_100_000); err == nil {
t.Fatal("experiment with mismatched v2 boost triples should be rejected")
}
// 三元组逐字段一致时照常创建。
matched := experimentTestFixedRule("boost_pool", 960_000)
matched.V2HighWaterBoostThresholdCoins = control.V2HighWaterBoostThresholdCoins
matched.V2HighWaterBoostFactorPPM = control.V2HighWaterBoostFactorPPM
matched.V2HighWaterBoostRecoverCoins = control.V2HighWaterBoostRecoverCoins
if _, _, err := repo.CreateLuckyGiftExperiment(ctx, domain.CreateExperimentCommand{
Name: "boost aligned", TreatmentConfig: matched, OperatorAdminID: 7,
}, 1_700_000_200_000); err != nil {
t.Fatalf("experiment with identical v2 boost triples should be allowed: %v", err)
}
}

View File

@ -729,6 +729,8 @@ type luckyDrawStatsRecord struct {
BaseRewardCoins int64 BaseRewardCoins int64
RewardStatus string RewardStatus string
CreatedAtMS int64 CreatedAtMS int64
// RuleVersion 只在 Databi 日汇总扫描时填充,用于把抽奖归因到策略族。
RuleVersion int64
} }
func (r *Repository) RefreshLuckyGiftAdminStatsSnapshots(ctx context.Context, nowMS int64, batchSize int) (int, error) { func (r *Repository) RefreshLuckyGiftAdminStatsSnapshots(ctx context.Context, nowMS int64, batchSize int) (int, error) {
@ -856,6 +858,7 @@ type luckyDrawPoolDayStatKey struct {
VisibleRegionID int64 VisibleRegionID int64
PoolID string PoolID string
GiftID string GiftID string
StrategyVersion string
} }
type luckyDrawPoolDayStatDelta struct { type luckyDrawPoolDayStatDelta struct {
@ -891,7 +894,7 @@ func (r *Repository) RefreshLuckyGiftDatabiStatsSnapshots(ctx context.Context, n
rows, err := tx.QueryContext(ctx, ` rows, err := tx.QueryContext(ctx, `
SELECT app_code, draw_id, visible_region_id, pool_id, gift_id, user_id, room_id, coin_spent, SELECT app_code, draw_id, visible_region_id, pool_id, gift_id, user_id, room_id, coin_spent,
effective_reward_coins, base_reward_coins, reward_status, effective_reward_coins, base_reward_coins, reward_status,
created_at_ms created_at_ms, rule_version
FROM lucky_draw_records FORCE INDEX (idx_lucky_draw_created) FROM lucky_draw_records FORCE INDEX (idx_lucky_draw_created)
WHERE app_code = ? WHERE app_code = ?
AND (created_at_ms > ? OR (created_at_ms = ? AND draw_id > ?)) AND (created_at_ms > ? OR (created_at_ms = ? AND draw_id > ?))
@ -905,7 +908,7 @@ func (r *Repository) RefreshLuckyGiftDatabiStatsSnapshots(ctx context.Context, n
for rows.Next() { for rows.Next() {
var record luckyDrawStatsRecord var record luckyDrawStatsRecord
if err := rows.Scan(&record.AppCode, &record.DrawID, &record.VisibleRegionID, &record.PoolID, &record.GiftID, &record.UserID, &record.RoomID, if err := rows.Scan(&record.AppCode, &record.DrawID, &record.VisibleRegionID, &record.PoolID, &record.GiftID, &record.UserID, &record.RoomID,
&record.TotalSpentCoins, &record.TotalRewardCoins, &record.BaseRewardCoins, &record.RewardStatus, &record.CreatedAtMS); err != nil { &record.TotalSpentCoins, &record.TotalRewardCoins, &record.BaseRewardCoins, &record.RewardStatus, &record.CreatedAtMS, &record.RuleVersion); err != nil {
return 0, err return 0, err
} }
records = append(records, record) records = append(records, record)
@ -919,6 +922,10 @@ func (r *Repository) RefreshLuckyGiftDatabiStatsSnapshots(ctx context.Context, n
} }
return 0, nil return 0, nil
} }
strategies, err := r.luckyRuleStrategyVersions(ctx, tx, appCode, records)
if err != nil {
return 0, err
}
grouped := map[luckyDrawPoolDayStatKey]*luckyDrawPoolDayStatDelta{} grouped := map[luckyDrawPoolDayStatKey]*luckyDrawPoolDayStatDelta{}
last := records[len(records)-1] last := records[len(records)-1]
for _, record := range records { for _, record := range records {
@ -927,9 +934,14 @@ func (r *Repository) RefreshLuckyGiftDatabiStatsSnapshots(ctx context.Context, n
regionID = 0 regionID = 0
} }
day := luckyDrawStatDay(record.CreatedAtMS) day := luckyDrawStatDay(record.CreatedAtMS)
keys := []luckyDrawPoolDayStatKey{{AppCode: record.AppCode, StatDay: day, VisibleRegionID: regionID, PoolID: record.PoolID, GiftID: ""}} strategy := strategies[luckyPoolRuleVersionKey{PoolID: record.PoolID, RuleVersion: record.RuleVersion}]
if strategy == "" {
// 找不到规则行的历史抽奖(含 rule_version=0 的早期记录)与 rule_versions 列默认一致,按 fixed_v2 归因。
strategy = domain.StrategyFixedV2
}
keys := []luckyDrawPoolDayStatKey{{AppCode: record.AppCode, StatDay: day, VisibleRegionID: regionID, PoolID: record.PoolID, GiftID: "", StrategyVersion: strategy}}
if giftID := strings.TrimSpace(record.GiftID); giftID != "" { if giftID := strings.TrimSpace(record.GiftID); giftID != "" {
keys = append(keys, luckyDrawPoolDayStatKey{AppCode: record.AppCode, StatDay: day, VisibleRegionID: regionID, PoolID: record.PoolID, GiftID: giftID}) keys = append(keys, luckyDrawPoolDayStatKey{AppCode: record.AppCode, StatDay: day, VisibleRegionID: regionID, PoolID: record.PoolID, GiftID: giftID, StrategyVersion: strategy})
} }
for _, key := range keys { for _, key := range keys {
delta := grouped[key] delta := grouped[key]
@ -965,23 +977,115 @@ func (r *Repository) RefreshLuckyGiftDatabiStatsSnapshots(ctx context.Context, n
func (r *Repository) upsertLuckyDrawPoolDayStatsDelta(ctx context.Context, tx *sql.Tx, key luckyDrawPoolDayStatKey, delta luckyDrawPoolDayStatDelta, nowMS int64) error { func (r *Repository) upsertLuckyDrawPoolDayStatsDelta(ctx context.Context, tx *sql.Tx, key luckyDrawPoolDayStatKey, delta luckyDrawPoolDayStatDelta, nowMS int64) error {
_, err := tx.ExecContext(ctx, ` _, err := tx.ExecContext(ctx, `
INSERT INTO lucky_draw_pool_day_stats ( INSERT INTO lucky_draw_pool_day_stats (
app_code, stat_day, visible_region_id, pool_id, gift_id, draw_count, turnover_coin, payout_coin, app_code, stat_day, visible_region_id, pool_id, gift_id, strategy_version, draw_count, turnover_coin, payout_coin,
base_reward_coin, created_at_ms, updated_at_ms base_reward_coin, created_at_ms, updated_at_ms
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE ON DUPLICATE KEY UPDATE
draw_count = draw_count + VALUES(draw_count), draw_count = draw_count + VALUES(draw_count),
turnover_coin = turnover_coin + VALUES(turnover_coin), turnover_coin = turnover_coin + VALUES(turnover_coin),
payout_coin = payout_coin + VALUES(payout_coin), payout_coin = payout_coin + VALUES(payout_coin),
base_reward_coin = base_reward_coin + VALUES(base_reward_coin), base_reward_coin = base_reward_coin + VALUES(base_reward_coin),
updated_at_ms = VALUES(updated_at_ms)`, updated_at_ms = VALUES(updated_at_ms)`,
key.AppCode, key.StatDay, key.VisibleRegionID, key.PoolID, key.GiftID, delta.DrawCount, delta.TurnoverCoin, delta.PayoutCoin, key.AppCode, key.StatDay, key.VisibleRegionID, key.PoolID, key.GiftID, key.StrategyVersion, delta.DrawCount, delta.TurnoverCoin, delta.PayoutCoin,
delta.BaseRewardCoin, nowMS, nowMS, delta.BaseRewardCoin, nowMS, nowMS,
) )
return err return err
} }
type luckyPoolRuleVersionKey struct {
PoolID string
RuleVersion int64
}
// luckyRuleStrategyVersions 按批次取涉及奖池的全部规则版本→策略族映射;
// 版本表是发布次数级小表,按奖池整取比 (pool, version) 行构造 IN 更贴合 PK 前缀。
func (r *Repository) luckyRuleStrategyVersions(ctx context.Context, tx *sql.Tx, appCode string, records []luckyDrawStatsRecord) (map[luckyPoolRuleVersionKey]string, error) {
poolSet := map[string]struct{}{}
pools := make([]string, 0, 4)
for _, record := range records {
if _, ok := poolSet[record.PoolID]; !ok {
poolSet[record.PoolID] = struct{}{}
pools = append(pools, record.PoolID)
}
}
out := make(map[luckyPoolRuleVersionKey]string, len(pools)*8)
if len(pools) == 0 {
return out, nil
}
args := make([]any, 0, len(pools)+1)
args = append(args, appCode)
for _, pool := range pools {
args = append(args, pool)
}
rows, err := tx.QueryContext(ctx, `
SELECT pool_id, rule_version, strategy_version
FROM lucky_gift_rule_versions
WHERE app_code = ? AND pool_id IN (`+luckySQLPlaceholders(len(pools))+`)`, args...)
if err != nil {
return nil, err
}
defer rows.Close()
for rows.Next() {
var key luckyPoolRuleVersionKey
var strategy string
if err := rows.Scan(&key.PoolID, &key.RuleVersion, &strategy); err != nil {
return nil, err
}
out[key] = strategy
}
return out, rows.Err()
}
// luckyDayStatZone 与 statistics-service 的 Asia/Shanghai 口径一致;用 FixedZone 规避容器缺 tzdata。
// lucky_draw_pool_day_stats 的 stat_day 从上线起就是北京日(迁移 003 由 statistics 同口径旧表导入)。
var luckyDayStatZone = time.FixedZone("Asia/Shanghai", 8*60*60)
func luckyDrawStatDay(ms int64) string { func luckyDrawStatDay(ms int64) string {
return time.UnixMilli(ms).UTC().Format("2006-01-02") return time.UnixMilli(ms).In(luckyDayStatZone).Format("2006-01-02")
}
// ListLuckyGiftDailyStats 只读 lucky_draw_pool_day_stats 的奖池汇总行gift_id=''
// 不触碰抽奖事实表;数据新鲜度受后台刷新游标节奏限制,不保证到秒级实时。
func (r *Repository) ListLuckyGiftDailyStats(ctx context.Context, query domain.DailyStatsQuery) ([]domain.DailyStat, error) {
if r == nil || r.db == nil {
return nil, xerr.New(xerr.Unavailable, "mysql repository is not configured")
}
where := []string{"app_code = ?", "gift_id = ''", "stat_day >= ?", "stat_day <= ?"}
args := []any{query.AppCode, luckyDrawStatDay(query.StartMS), luckyDrawStatDay(query.EndMS - 1)}
if query.PoolID != "" {
where = append(where, "pool_id = ?")
args = append(args, query.PoolID)
}
if query.StrategyVersion != "" {
where = append(where, "strategy_version = ?")
args = append(args, query.StrategyVersion)
}
if len(query.RegionIDs) > 0 {
where = append(where, "visible_region_id IN ("+luckySQLPlaceholders(len(query.RegionIDs))+")")
for _, regionID := range query.RegionIDs {
args = append(args, regionID)
}
}
// DATE_FORMAT 直接取日字符串,避免 DATE 列的扫描类型随 DSN parseTime 参数漂移。
rows, err := r.db.QueryContext(ctx, `
SELECT DATE_FORMAT(stat_day, '%Y-%m-%d'), SUM(draw_count), SUM(turnover_coin), SUM(payout_coin), SUM(base_reward_coin)
FROM lucky_draw_pool_day_stats
WHERE `+strings.Join(where, " AND ")+`
GROUP BY stat_day
ORDER BY stat_day ASC`, args...)
if err != nil {
return nil, err
}
defer rows.Close()
stats := make([]domain.DailyStat, 0, 32)
for rows.Next() {
var stat domain.DailyStat
if err := rows.Scan(&stat.StatDay, &stat.DrawCount, &stat.TurnoverCoin, &stat.PayoutCoin, &stat.BaseRewardCoin); err != nil {
return nil, err
}
stats = append(stats, stat)
}
return stats, rows.Err()
} }
func (r *Repository) lockLuckyDrawStatsCursor(ctx context.Context, tx *sql.Tx, appCode string, nowMS int64) (luckyDrawStatsCursor, bool, error) { func (r *Repository) lockLuckyDrawStatsCursor(ctx context.Context, tx *sql.Tx, appCode string, nowMS int64) (luckyDrawStatsCursor, bool, error) {

View File

@ -331,6 +331,37 @@ func (s *AdminLuckyGiftServer) ListLuckyGiftPoolBalances(ctx context.Context, re
return resp, nil return resp, nil
} }
func (s *AdminLuckyGiftServer) ListLuckyGiftDailyStats(ctx context.Context, req *luckygiftv1.ListLuckyGiftDailyStatsRequest) (*luckygiftv1.ListLuckyGiftDailyStatsResponse, error) {
appCode := strings.TrimSpace(req.GetMeta().GetAppCode())
if appCode == "" {
// 按日汇总是单租户口径,缺失 app_code 不能静默落到默认 lalu 租户。
return nil, xerr.ToGRPCError(xerr.New(xerr.InvalidArgument, "lucky gift daily stats app_code is required"))
}
ctx = appcode.WithContext(ctx, appCode)
stats, err := s.svc.ListDailyStats(ctx, domain.DailyStatsQuery{
AppCode: appCode,
PoolID: req.GetPoolId(),
StrategyVersion: req.GetStrategyVersion(),
StartMS: req.GetStartMs(),
EndMS: req.GetEndMs(),
RegionIDs: req.GetRegionIds(),
})
if err != nil {
return nil, xerr.ToGRPCError(err)
}
resp := &luckygiftv1.ListLuckyGiftDailyStatsResponse{Stats: make([]*luckygiftv1.LuckyGiftDailyStat, 0, len(stats))}
for _, stat := range stats {
resp.Stats = append(resp.Stats, &luckygiftv1.LuckyGiftDailyStat{
StatDay: stat.StatDay,
DrawCount: stat.DrawCount,
TurnoverCoin: stat.TurnoverCoin,
PayoutCoin: stat.PayoutCoin,
BaseRewardCoin: stat.BaseRewardCoin,
})
}
return resp, nil
}
func (s *AdminLuckyGiftServer) AdjustLuckyGiftPoolBalance(ctx context.Context, req *luckygiftv1.AdjustLuckyGiftPoolBalanceRequest) (*luckygiftv1.AdjustLuckyGiftPoolBalanceResponse, error) { func (s *AdminLuckyGiftServer) AdjustLuckyGiftPoolBalance(ctx context.Context, req *luckygiftv1.AdjustLuckyGiftPoolBalanceRequest) (*luckygiftv1.AdjustLuckyGiftPoolBalanceResponse, error) {
if req == nil || strings.TrimSpace(req.GetMeta().GetAppCode()) == "" { if req == nil || strings.TrimSpace(req.GetMeta().GetAppCode()) == "" {
// 资金 RPC 必须 fail-closed缺失 meta 不能像普通兼容查询一样静默落到默认 lalu 租户。 // 资金 RPC 必须 fail-closed缺失 meta 不能像普通兼容查询一样静默落到默认 lalu 租户。