幸运礼物实验硬约束:两组 v2 高水位加权三元组必须一致
滞回标志按 app+pool 单行存储且由每个用户钉住的组内配置推进; 两组三元组不同(尤其 control 关/treatment 开的灰度形态)会互踩: control 逐抽清掉 treatment 的标志,阈值不同的组按对方状态加权付奖。 加权特性的灰度必须走常规发布,不能走实验。 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
3ca36d2c8d
commit
bf6d7d4f1e
@ -5165,6 +5165,219 @@ func (x *GetLuckyGiftUserProfileResponse) GetJackpotHasMore() bool {
|
||||
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:00)YYYY-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
|
||||
|
||||
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" +
|
||||
"\x11jackpot_page_size\x18\a \x01(\x05R\x0fjackpotPageSize\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" +
|
||||
"\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" +
|
||||
"\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" +
|
||||
"\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" +
|
||||
@ -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" +
|
||||
"\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" +
|
||||
"\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 (
|
||||
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
|
||||
}
|
||||
|
||||
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{
|
||||
(*RequestMeta)(nil), // 0: hyapp.luckygift.v1.RequestMeta
|
||||
(*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
|
||||
(*LuckyGiftUserJackpotHit)(nil), // 52: hyapp.luckygift.v1.LuckyGiftUserJackpotHit
|
||||
(*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{
|
||||
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
|
||||
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
|
||||
5, // 54: hyapp.luckygift.v1.LuckyGiftService.CheckLuckyGift:input_type -> hyapp.luckygift.v1.CheckLuckyGiftRequest
|
||||
8, // 55: hyapp.luckygift.v1.LuckyGiftService.ExecuteLuckyGiftDraw:input_type -> hyapp.luckygift.v1.ExecuteLuckyGiftDrawRequest
|
||||
10, // 56: hyapp.luckygift.v1.LuckyGiftService.BatchExecuteLuckyGiftDraw:input_type -> hyapp.luckygift.v1.BatchExecuteLuckyGiftDrawRequest
|
||||
12, // 57: hyapp.luckygift.v1.LuckyGiftService.ExecuteExternalGiftDraw:input_type -> hyapp.luckygift.v1.ExternalGiftDrawRequest
|
||||
14, // 58: hyapp.luckygift.v1.AdminLuckyGiftService.GetLuckyGiftConfig:input_type -> hyapp.luckygift.v1.GetLuckyGiftConfigRequest
|
||||
16, // 59: hyapp.luckygift.v1.AdminLuckyGiftService.UpsertLuckyGiftConfig:input_type -> hyapp.luckygift.v1.UpsertLuckyGiftConfigRequest
|
||||
18, // 60: hyapp.luckygift.v1.AdminLuckyGiftService.ListLuckyGiftConfigs:input_type -> hyapp.luckygift.v1.ListLuckyGiftConfigsRequest
|
||||
20, // 61: hyapp.luckygift.v1.AdminLuckyGiftService.ListLuckyGiftDraws:input_type -> hyapp.luckygift.v1.ListLuckyGiftDrawsRequest
|
||||
24, // 62: hyapp.luckygift.v1.AdminLuckyGiftService.GetLuckyGiftDrawSummary:input_type -> hyapp.luckygift.v1.GetLuckyGiftDrawSummaryRequest
|
||||
26, // 63: hyapp.luckygift.v1.AdminLuckyGiftService.ListLuckyGiftPoolBalances:input_type -> hyapp.luckygift.v1.ListLuckyGiftPoolBalancesRequest
|
||||
42, // 64: hyapp.luckygift.v1.AdminLuckyGiftService.AdjustLuckyGiftPoolBalance:input_type -> hyapp.luckygift.v1.AdjustLuckyGiftPoolBalanceRequest
|
||||
31, // 65: hyapp.luckygift.v1.AdminLuckyGiftService.CreateLuckyGiftExperiment:input_type -> hyapp.luckygift.v1.CreateLuckyGiftExperimentRequest
|
||||
33, // 66: hyapp.luckygift.v1.AdminLuckyGiftService.ListLuckyGiftExperiments:input_type -> hyapp.luckygift.v1.ListLuckyGiftExperimentsRequest
|
||||
35, // 67: hyapp.luckygift.v1.AdminLuckyGiftService.UpdateLuckyGiftExperiment:input_type -> hyapp.luckygift.v1.UpdateLuckyGiftExperimentRequest
|
||||
37, // 68: hyapp.luckygift.v1.AdminLuckyGiftService.SetLuckyGiftExperimentOverrides:input_type -> hyapp.luckygift.v1.SetLuckyGiftExperimentOverridesRequest
|
||||
39, // 69: hyapp.luckygift.v1.AdminLuckyGiftService.EndLuckyGiftExperiment:input_type -> hyapp.luckygift.v1.EndLuckyGiftExperimentRequest
|
||||
47, // 70: hyapp.luckygift.v1.AdminLuckyGiftService.ListLuckyGiftUserProfiles:input_type -> hyapp.luckygift.v1.ListLuckyGiftUserProfilesRequest
|
||||
49, // 71: hyapp.luckygift.v1.AdminLuckyGiftService.GetLuckyGiftUserProfile:input_type -> hyapp.luckygift.v1.GetLuckyGiftUserProfileRequest
|
||||
6, // 72: hyapp.luckygift.v1.LuckyGiftService.CheckLuckyGift:output_type -> hyapp.luckygift.v1.CheckLuckyGiftResponse
|
||||
9, // 73: hyapp.luckygift.v1.LuckyGiftService.ExecuteLuckyGiftDraw:output_type -> hyapp.luckygift.v1.ExecuteLuckyGiftDrawResponse
|
||||
11, // 74: hyapp.luckygift.v1.LuckyGiftService.BatchExecuteLuckyGiftDraw:output_type -> hyapp.luckygift.v1.BatchExecuteLuckyGiftDrawResponse
|
||||
13, // 75: hyapp.luckygift.v1.LuckyGiftService.ExecuteExternalGiftDraw:output_type -> hyapp.luckygift.v1.ExternalGiftDrawResponse
|
||||
15, // 76: hyapp.luckygift.v1.AdminLuckyGiftService.GetLuckyGiftConfig:output_type -> hyapp.luckygift.v1.GetLuckyGiftConfigResponse
|
||||
17, // 77: hyapp.luckygift.v1.AdminLuckyGiftService.UpsertLuckyGiftConfig:output_type -> hyapp.luckygift.v1.UpsertLuckyGiftConfigResponse
|
||||
19, // 78: hyapp.luckygift.v1.AdminLuckyGiftService.ListLuckyGiftConfigs:output_type -> hyapp.luckygift.v1.ListLuckyGiftConfigsResponse
|
||||
21, // 79: hyapp.luckygift.v1.AdminLuckyGiftService.ListLuckyGiftDraws:output_type -> hyapp.luckygift.v1.ListLuckyGiftDrawsResponse
|
||||
25, // 80: hyapp.luckygift.v1.AdminLuckyGiftService.GetLuckyGiftDrawSummary:output_type -> hyapp.luckygift.v1.GetLuckyGiftDrawSummaryResponse
|
||||
27, // 81: hyapp.luckygift.v1.AdminLuckyGiftService.ListLuckyGiftPoolBalances:output_type -> hyapp.luckygift.v1.ListLuckyGiftPoolBalancesResponse
|
||||
44, // 82: hyapp.luckygift.v1.AdminLuckyGiftService.AdjustLuckyGiftPoolBalance:output_type -> hyapp.luckygift.v1.AdjustLuckyGiftPoolBalanceResponse
|
||||
32, // 83: hyapp.luckygift.v1.AdminLuckyGiftService.CreateLuckyGiftExperiment:output_type -> hyapp.luckygift.v1.CreateLuckyGiftExperimentResponse
|
||||
34, // 84: hyapp.luckygift.v1.AdminLuckyGiftService.ListLuckyGiftExperiments:output_type -> hyapp.luckygift.v1.ListLuckyGiftExperimentsResponse
|
||||
36, // 85: hyapp.luckygift.v1.AdminLuckyGiftService.UpdateLuckyGiftExperiment:output_type -> hyapp.luckygift.v1.UpdateLuckyGiftExperimentResponse
|
||||
38, // 86: hyapp.luckygift.v1.AdminLuckyGiftService.SetLuckyGiftExperimentOverrides:output_type -> hyapp.luckygift.v1.SetLuckyGiftExperimentOverridesResponse
|
||||
40, // 87: hyapp.luckygift.v1.AdminLuckyGiftService.EndLuckyGiftExperiment:output_type -> hyapp.luckygift.v1.EndLuckyGiftExperimentResponse
|
||||
48, // 88: hyapp.luckygift.v1.AdminLuckyGiftService.ListLuckyGiftUserProfiles:output_type -> hyapp.luckygift.v1.ListLuckyGiftUserProfilesResponse
|
||||
53, // 89: hyapp.luckygift.v1.AdminLuckyGiftService.GetLuckyGiftUserProfile:output_type -> hyapp.luckygift.v1.GetLuckyGiftUserProfileResponse
|
||||
72, // [72:90] is the sub-list for method output_type
|
||||
54, // [54:72] is the sub-list for method input_type
|
||||
54, // [54:54] is the sub-list for extension type_name
|
||||
54, // [54:54] is the sub-list for extension extendee
|
||||
0, // [0:54] is the sub-list for field type_name
|
||||
0, // 54: hyapp.luckygift.v1.ListLuckyGiftDailyStatsRequest.meta:type_name -> hyapp.luckygift.v1.RequestMeta
|
||||
55, // 55: hyapp.luckygift.v1.ListLuckyGiftDailyStatsResponse.stats:type_name -> hyapp.luckygift.v1.LuckyGiftDailyStat
|
||||
5, // 56: hyapp.luckygift.v1.LuckyGiftService.CheckLuckyGift:input_type -> hyapp.luckygift.v1.CheckLuckyGiftRequest
|
||||
8, // 57: hyapp.luckygift.v1.LuckyGiftService.ExecuteLuckyGiftDraw:input_type -> hyapp.luckygift.v1.ExecuteLuckyGiftDrawRequest
|
||||
10, // 58: hyapp.luckygift.v1.LuckyGiftService.BatchExecuteLuckyGiftDraw:input_type -> hyapp.luckygift.v1.BatchExecuteLuckyGiftDrawRequest
|
||||
12, // 59: hyapp.luckygift.v1.LuckyGiftService.ExecuteExternalGiftDraw:input_type -> hyapp.luckygift.v1.ExternalGiftDrawRequest
|
||||
14, // 60: hyapp.luckygift.v1.AdminLuckyGiftService.GetLuckyGiftConfig:input_type -> hyapp.luckygift.v1.GetLuckyGiftConfigRequest
|
||||
16, // 61: hyapp.luckygift.v1.AdminLuckyGiftService.UpsertLuckyGiftConfig:input_type -> hyapp.luckygift.v1.UpsertLuckyGiftConfigRequest
|
||||
18, // 62: hyapp.luckygift.v1.AdminLuckyGiftService.ListLuckyGiftConfigs:input_type -> hyapp.luckygift.v1.ListLuckyGiftConfigsRequest
|
||||
20, // 63: hyapp.luckygift.v1.AdminLuckyGiftService.ListLuckyGiftDraws:input_type -> hyapp.luckygift.v1.ListLuckyGiftDrawsRequest
|
||||
24, // 64: hyapp.luckygift.v1.AdminLuckyGiftService.GetLuckyGiftDrawSummary:input_type -> hyapp.luckygift.v1.GetLuckyGiftDrawSummaryRequest
|
||||
26, // 65: hyapp.luckygift.v1.AdminLuckyGiftService.ListLuckyGiftPoolBalances:input_type -> hyapp.luckygift.v1.ListLuckyGiftPoolBalancesRequest
|
||||
42, // 66: hyapp.luckygift.v1.AdminLuckyGiftService.AdjustLuckyGiftPoolBalance:input_type -> hyapp.luckygift.v1.AdjustLuckyGiftPoolBalanceRequest
|
||||
31, // 67: hyapp.luckygift.v1.AdminLuckyGiftService.CreateLuckyGiftExperiment:input_type -> hyapp.luckygift.v1.CreateLuckyGiftExperimentRequest
|
||||
33, // 68: hyapp.luckygift.v1.AdminLuckyGiftService.ListLuckyGiftExperiments:input_type -> hyapp.luckygift.v1.ListLuckyGiftExperimentsRequest
|
||||
35, // 69: hyapp.luckygift.v1.AdminLuckyGiftService.UpdateLuckyGiftExperiment:input_type -> hyapp.luckygift.v1.UpdateLuckyGiftExperimentRequest
|
||||
37, // 70: hyapp.luckygift.v1.AdminLuckyGiftService.SetLuckyGiftExperimentOverrides:input_type -> hyapp.luckygift.v1.SetLuckyGiftExperimentOverridesRequest
|
||||
39, // 71: hyapp.luckygift.v1.AdminLuckyGiftService.EndLuckyGiftExperiment:input_type -> hyapp.luckygift.v1.EndLuckyGiftExperimentRequest
|
||||
47, // 72: hyapp.luckygift.v1.AdminLuckyGiftService.ListLuckyGiftUserProfiles:input_type -> hyapp.luckygift.v1.ListLuckyGiftUserProfilesRequest
|
||||
49, // 73: hyapp.luckygift.v1.AdminLuckyGiftService.GetLuckyGiftUserProfile:input_type -> hyapp.luckygift.v1.GetLuckyGiftUserProfileRequest
|
||||
54, // 74: hyapp.luckygift.v1.AdminLuckyGiftService.ListLuckyGiftDailyStats:input_type -> hyapp.luckygift.v1.ListLuckyGiftDailyStatsRequest
|
||||
6, // 75: hyapp.luckygift.v1.LuckyGiftService.CheckLuckyGift:output_type -> hyapp.luckygift.v1.CheckLuckyGiftResponse
|
||||
9, // 76: hyapp.luckygift.v1.LuckyGiftService.ExecuteLuckyGiftDraw:output_type -> hyapp.luckygift.v1.ExecuteLuckyGiftDrawResponse
|
||||
11, // 77: hyapp.luckygift.v1.LuckyGiftService.BatchExecuteLuckyGiftDraw:output_type -> hyapp.luckygift.v1.BatchExecuteLuckyGiftDrawResponse
|
||||
13, // 78: hyapp.luckygift.v1.LuckyGiftService.ExecuteExternalGiftDraw:output_type -> hyapp.luckygift.v1.ExternalGiftDrawResponse
|
||||
15, // 79: hyapp.luckygift.v1.AdminLuckyGiftService.GetLuckyGiftConfig:output_type -> hyapp.luckygift.v1.GetLuckyGiftConfigResponse
|
||||
17, // 80: hyapp.luckygift.v1.AdminLuckyGiftService.UpsertLuckyGiftConfig:output_type -> hyapp.luckygift.v1.UpsertLuckyGiftConfigResponse
|
||||
19, // 81: hyapp.luckygift.v1.AdminLuckyGiftService.ListLuckyGiftConfigs:output_type -> hyapp.luckygift.v1.ListLuckyGiftConfigsResponse
|
||||
21, // 82: hyapp.luckygift.v1.AdminLuckyGiftService.ListLuckyGiftDraws:output_type -> hyapp.luckygift.v1.ListLuckyGiftDrawsResponse
|
||||
25, // 83: hyapp.luckygift.v1.AdminLuckyGiftService.GetLuckyGiftDrawSummary:output_type -> hyapp.luckygift.v1.GetLuckyGiftDrawSummaryResponse
|
||||
27, // 84: hyapp.luckygift.v1.AdminLuckyGiftService.ListLuckyGiftPoolBalances:output_type -> hyapp.luckygift.v1.ListLuckyGiftPoolBalancesResponse
|
||||
44, // 85: hyapp.luckygift.v1.AdminLuckyGiftService.AdjustLuckyGiftPoolBalance:output_type -> hyapp.luckygift.v1.AdjustLuckyGiftPoolBalanceResponse
|
||||
32, // 86: hyapp.luckygift.v1.AdminLuckyGiftService.CreateLuckyGiftExperiment:output_type -> hyapp.luckygift.v1.CreateLuckyGiftExperimentResponse
|
||||
34, // 87: hyapp.luckygift.v1.AdminLuckyGiftService.ListLuckyGiftExperiments:output_type -> hyapp.luckygift.v1.ListLuckyGiftExperimentsResponse
|
||||
36, // 88: hyapp.luckygift.v1.AdminLuckyGiftService.UpdateLuckyGiftExperiment:output_type -> hyapp.luckygift.v1.UpdateLuckyGiftExperimentResponse
|
||||
38, // 89: hyapp.luckygift.v1.AdminLuckyGiftService.SetLuckyGiftExperimentOverrides:output_type -> hyapp.luckygift.v1.SetLuckyGiftExperimentOverridesResponse
|
||||
40, // 90: hyapp.luckygift.v1.AdminLuckyGiftService.EndLuckyGiftExperiment:output_type -> hyapp.luckygift.v1.EndLuckyGiftExperimentResponse
|
||||
48, // 91: hyapp.luckygift.v1.AdminLuckyGiftService.ListLuckyGiftUserProfiles:output_type -> hyapp.luckygift.v1.ListLuckyGiftUserProfilesResponse
|
||||
53, // 92: hyapp.luckygift.v1.AdminLuckyGiftService.GetLuckyGiftUserProfile:output_type -> hyapp.luckygift.v1.GetLuckyGiftUserProfileResponse
|
||||
56, // 93: hyapp.luckygift.v1.AdminLuckyGiftService.ListLuckyGiftDailyStats:output_type -> hyapp.luckygift.v1.ListLuckyGiftDailyStatsResponse
|
||||
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() }
|
||||
@ -5880,7 +6119,7 @@ func file_proto_luckygift_v1_luckygift_proto_init() {
|
||||
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)),
|
||||
NumEnums: 0,
|
||||
NumMessages: 54,
|
||||
NumMessages: 57,
|
||||
NumExtensions: 0,
|
||||
NumServices: 2,
|
||||
},
|
||||
|
||||
@ -452,6 +452,7 @@ service AdminLuckyGiftService {
|
||||
rpc EndLuckyGiftExperiment(EndLuckyGiftExperimentRequest) returns (EndLuckyGiftExperimentResponse);
|
||||
rpc ListLuckyGiftUserProfiles(ListLuckyGiftUserProfilesRequest) returns (ListLuckyGiftUserProfilesResponse);
|
||||
rpc GetLuckyGiftUserProfile(GetLuckyGiftUserProfileRequest) returns (GetLuckyGiftUserProfileResponse);
|
||||
rpc ListLuckyGiftDailyStats(ListLuckyGiftDailyStatsRequest) returns (ListLuckyGiftDailyStatsResponse);
|
||||
}
|
||||
|
||||
// LuckyGiftHit 只返回批量逐份开奖中实际中奖的精简位置,不复制全部审计明细。
|
||||
@ -631,3 +632,33 @@ message GetLuckyGiftUserProfileResponse {
|
||||
string next_jackpot_cursor = 8;
|
||||
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:00)YYYY-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;
|
||||
}
|
||||
|
||||
@ -249,6 +249,7 @@ const (
|
||||
AdminLuckyGiftService_EndLuckyGiftExperiment_FullMethodName = "/hyapp.luckygift.v1.AdminLuckyGiftService/EndLuckyGiftExperiment"
|
||||
AdminLuckyGiftService_ListLuckyGiftUserProfiles_FullMethodName = "/hyapp.luckygift.v1.AdminLuckyGiftService/ListLuckyGiftUserProfiles"
|
||||
AdminLuckyGiftService_GetLuckyGiftUserProfile_FullMethodName = "/hyapp.luckygift.v1.AdminLuckyGiftService/GetLuckyGiftUserProfile"
|
||||
AdminLuckyGiftService_ListLuckyGiftDailyStats_FullMethodName = "/hyapp.luckygift.v1.AdminLuckyGiftService/ListLuckyGiftDailyStats"
|
||||
)
|
||||
|
||||
// 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)
|
||||
ListLuckyGiftUserProfiles(ctx context.Context, in *ListLuckyGiftUserProfilesRequest, opts ...grpc.CallOption) (*ListLuckyGiftUserProfilesResponse, 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 {
|
||||
@ -419,6 +421,16 @@ func (c *adminLuckyGiftServiceClient) GetLuckyGiftUserProfile(ctx context.Contex
|
||||
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.
|
||||
// All implementations must embed UnimplementedAdminLuckyGiftServiceServer
|
||||
// for forward compatibility.
|
||||
@ -437,6 +449,7 @@ type AdminLuckyGiftServiceServer interface {
|
||||
EndLuckyGiftExperiment(context.Context, *EndLuckyGiftExperimentRequest) (*EndLuckyGiftExperimentResponse, error)
|
||||
ListLuckyGiftUserProfiles(context.Context, *ListLuckyGiftUserProfilesRequest) (*ListLuckyGiftUserProfilesResponse, error)
|
||||
GetLuckyGiftUserProfile(context.Context, *GetLuckyGiftUserProfileRequest) (*GetLuckyGiftUserProfileResponse, error)
|
||||
ListLuckyGiftDailyStats(context.Context, *ListLuckyGiftDailyStatsRequest) (*ListLuckyGiftDailyStatsResponse, error)
|
||||
mustEmbedUnimplementedAdminLuckyGiftServiceServer()
|
||||
}
|
||||
|
||||
@ -489,6 +502,9 @@ func (UnimplementedAdminLuckyGiftServiceServer) ListLuckyGiftUserProfiles(contex
|
||||
func (UnimplementedAdminLuckyGiftServiceServer) GetLuckyGiftUserProfile(context.Context, *GetLuckyGiftUserProfileRequest) (*GetLuckyGiftUserProfileResponse, error) {
|
||||
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) testEmbeddedByValue() {}
|
||||
|
||||
@ -762,6 +778,24 @@ func _AdminLuckyGiftService_GetLuckyGiftUserProfile_Handler(srv interface{}, ctx
|
||||
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.
|
||||
// It's only intended for direct use with grpc.RegisterService,
|
||||
// and not to be introspected or modified (even as a copy)
|
||||
@ -825,6 +859,10 @@ var AdminLuckyGiftService_ServiceDesc = grpc.ServiceDesc{
|
||||
MethodName: "GetLuckyGiftUserProfile",
|
||||
Handler: _AdminLuckyGiftService_GetLuckyGiftUserProfile_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "ListLuckyGiftDailyStats",
|
||||
Handler: _AdminLuckyGiftService_ListLuckyGiftDailyStats_Handler,
|
||||
},
|
||||
},
|
||||
Streams: []grpc.StreamDesc{},
|
||||
Metadata: "proto/luckygift/v1/luckygift.proto",
|
||||
|
||||
@ -350,6 +350,7 @@ func main() {
|
||||
appRegistryService,
|
||||
userclient.NewGRPC(userConn),
|
||||
databimodule.WithFinanceRechargeOverviewSource(paymentHandler),
|
||||
databimodule.WithLuckyGiftDailyStats(luckygiftadmin.NewGRPC(luckyGiftConn), cfg.LuckyGiftService.RequestTimeout),
|
||||
databimodule.WithLegacyApps(databiLegacyApps(cfg.DashboardExternalSources, cfg.MoneyRegionSources)...),
|
||||
databimodule.WithLegacyRegionCatalogs(databiLegacyRegionCatalogs(moneyRegionSources)...),
|
||||
)
|
||||
|
||||
@ -25,6 +25,7 @@ type Client interface {
|
||||
UpdateLuckyGiftExperiment(ctx context.Context, req *luckygiftv1.UpdateLuckyGiftExperimentRequest) (*luckygiftv1.UpdateLuckyGiftExperimentResponse, error)
|
||||
SetLuckyGiftExperimentOverrides(ctx context.Context, req *luckygiftv1.SetLuckyGiftExperimentOverridesRequest) (*luckygiftv1.SetLuckyGiftExperimentOverridesResponse, error)
|
||||
EndLuckyGiftExperiment(ctx context.Context, req *luckygiftv1.EndLuckyGiftExperimentRequest) (*luckygiftv1.EndLuckyGiftExperimentResponse, error)
|
||||
ListLuckyGiftDailyStats(ctx context.Context, req *luckygiftv1.ListLuckyGiftDailyStatsRequest) (*luckygiftv1.ListLuckyGiftDailyStatsResponse, error)
|
||||
}
|
||||
|
||||
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) {
|
||||
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)
|
||||
}
|
||||
|
||||
162
server/admin/internal/modules/databi/lucky_gift_v3.go
Normal file
162
server/admin/internal/modules/databi/lucky_gift_v3.go
Normal 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
|
||||
}
|
||||
110
server/admin/internal/modules/databi/lucky_gift_v3_test.go
Normal file
110
server/admin/internal/modules/databi/lucky_gift_v3_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@ -52,6 +52,8 @@ type Service struct {
|
||||
apps *appregistry.Service
|
||||
user userclient.Client
|
||||
financeRecharge payment.FinanceRechargeOverviewSource
|
||||
luckyGiftStats LuckyGiftDailyStatsSource
|
||||
luckyGiftTimeout time.Duration
|
||||
legacyApps []LegacyAppDescriptor
|
||||
legacyRegionCatalogs []LegacyRegionCatalogSource
|
||||
|
||||
@ -483,6 +485,22 @@ func (s *Service) appRequirements(ctx context.Context, app AppInfo, regions []Re
|
||||
// 顶栏多选区域时保留多区域筛选;单区域仍走 region_id 以兼容旧统计入口。
|
||||
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{
|
||||
AppCode: app.AppCode,
|
||||
StatTZ: query.StatTZ,
|
||||
@ -500,6 +518,14 @@ func (s *Service) appRequirements(ctx context.Context, app AppInfo, regions []Re
|
||||
return out
|
||||
}
|
||||
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 requirementsHasCohortFilter(query) {
|
||||
// Finance 不具备角色/付费身份/新老用户维度;筛选后必须把金额标为不可用,
|
||||
|
||||
@ -368,17 +368,18 @@ CREATE TABLE IF NOT EXISTS lucky_draw_pool_stats (
|
||||
|
||||
CREATE TABLE IF NOT EXISTS lucky_draw_pool_day_stats (
|
||||
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',
|
||||
pool_id VARCHAR(96) NOT NULL COMMENT '幸运礼物奖池 ID',
|
||||
gift_id VARCHAR(96) NOT NULL DEFAULT '' COMMENT '礼物 ID,空值表示奖池汇总',
|
||||
strategy_version VARCHAR(32) NOT NULL DEFAULT 'fixed_v2' COMMENT '策略族 fixed_v2/dynamic_v3;AB 实验下同池同日会双行,BI 按策略过滤',
|
||||
draw_count BIGINT NOT NULL DEFAULT 0 COMMENT '抽奖次数',
|
||||
turnover_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 返奖',
|
||||
created_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)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='幸运礼物 Databi 日维度汇总';
|
||||
|
||||
|
||||
@ -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_version(AB 实验下同池同日两策略并存必须分行),
|
||||
-- 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_v3;AB 实验下同池同日会双行,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;
|
||||
@ -281,6 +281,30 @@ type DrawSummary struct {
|
||||
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 单池账本快照。
|
||||
// materialized=false 表示该池还没有真实 lucky_pools 行,展示的是规则默认初始水位,不能当成已经入库的资金流水。
|
||||
type PoolBalance struct {
|
||||
|
||||
@ -45,6 +45,7 @@ type Repository interface {
|
||||
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)
|
||||
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)
|
||||
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)
|
||||
@ -806,6 +807,46 @@ func (s *Service) GetDrawSummary(ctx context.Context, query domain.DrawQuery) (d
|
||||
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) {
|
||||
if err := s.requireRepository(); err != nil {
|
||||
return nil, 0, "", false, 0, err
|
||||
|
||||
@ -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:00(23: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-10;2026-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)
|
||||
}
|
||||
}
|
||||
@ -155,6 +155,15 @@ func (r *Repository) CreateLuckyGiftExperiment(ctx context.Context, cmd domain.C
|
||||
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")
|
||||
}
|
||||
// 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 死锁检测
|
||||
// 回滚其一——管理端低频操作,报错重试即可,不为此引入额外串行化。
|
||||
|
||||
@ -345,3 +345,43 @@ func TestExperimentMigrationIsIdempotentAndEnforcesSingleActive(t *testing.T) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -729,6 +729,8 @@ type luckyDrawStatsRecord struct {
|
||||
BaseRewardCoins int64
|
||||
RewardStatus string
|
||||
CreatedAtMS int64
|
||||
// RuleVersion 只在 Databi 日汇总扫描时填充,用于把抽奖归因到策略族。
|
||||
RuleVersion int64
|
||||
}
|
||||
|
||||
func (r *Repository) RefreshLuckyGiftAdminStatsSnapshots(ctx context.Context, nowMS int64, batchSize int) (int, error) {
|
||||
@ -856,6 +858,7 @@ type luckyDrawPoolDayStatKey struct {
|
||||
VisibleRegionID int64
|
||||
PoolID string
|
||||
GiftID string
|
||||
StrategyVersion string
|
||||
}
|
||||
|
||||
type luckyDrawPoolDayStatDelta struct {
|
||||
@ -891,7 +894,7 @@ func (r *Repository) RefreshLuckyGiftDatabiStatsSnapshots(ctx context.Context, n
|
||||
rows, err := tx.QueryContext(ctx, `
|
||||
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,
|
||||
created_at_ms
|
||||
created_at_ms, rule_version
|
||||
FROM lucky_draw_records FORCE INDEX (idx_lucky_draw_created)
|
||||
WHERE app_code = ?
|
||||
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() {
|
||||
var record luckyDrawStatsRecord
|
||||
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
|
||||
}
|
||||
records = append(records, record)
|
||||
@ -919,6 +922,10 @@ func (r *Repository) RefreshLuckyGiftDatabiStatsSnapshots(ctx context.Context, n
|
||||
}
|
||||
return 0, nil
|
||||
}
|
||||
strategies, err := r.luckyRuleStrategyVersions(ctx, tx, appCode, records)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
grouped := map[luckyDrawPoolDayStatKey]*luckyDrawPoolDayStatDelta{}
|
||||
last := records[len(records)-1]
|
||||
for _, record := range records {
|
||||
@ -927,9 +934,14 @@ func (r *Repository) RefreshLuckyGiftDatabiStatsSnapshots(ctx context.Context, n
|
||||
regionID = 0
|
||||
}
|
||||
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 != "" {
|
||||
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 {
|
||||
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 {
|
||||
_, err := tx.ExecContext(ctx, `
|
||||
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
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
draw_count = draw_count + VALUES(draw_count),
|
||||
turnover_coin = turnover_coin + VALUES(turnover_coin),
|
||||
payout_coin = payout_coin + VALUES(payout_coin),
|
||||
base_reward_coin = base_reward_coin + VALUES(base_reward_coin),
|
||||
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,
|
||||
)
|
||||
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 {
|
||||
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) {
|
||||
|
||||
@ -331,6 +331,37 @@ func (s *AdminLuckyGiftServer) ListLuckyGiftPoolBalances(ctx context.Context, re
|
||||
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) {
|
||||
if req == nil || strings.TrimSpace(req.GetMeta().GetAppCode()) == "" {
|
||||
// 资金 RPC 必须 fail-closed;缺失 meta 不能像普通兼容查询一样静默落到默认 lalu 租户。
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user