From bf6d7d4f1eb347af303dec5919511f63bcc154dc Mon Sep 17 00:00:00 2001 From: zhx Date: Fri, 17 Jul 2026 02:07:24 +0800 Subject: [PATCH] =?UTF-8?q?=E5=B9=B8=E8=BF=90=E7=A4=BC=E7=89=A9=E5=AE=9E?= =?UTF-8?q?=E9=AA=8C=E7=A1=AC=E7=BA=A6=E6=9D=9F=EF=BC=9A=E4=B8=A4=E7=BB=84?= =?UTF-8?q?=20v2=20=E9=AB=98=E6=B0=B4=E4=BD=8D=E5=8A=A0=E6=9D=83=E4=B8=89?= =?UTF-8?q?=E5=85=83=E7=BB=84=E5=BF=85=E9=A1=BB=E4=B8=80=E8=87=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 滞回标志按 app+pool 单行存储且由每个用户钉住的组内配置推进; 两组三元组不同(尤其 control 关/treatment 开的灰度形态)会互踩: control 逐抽清掉 treatment 的标志,阈值不同的组按对方状态加权付奖。 加权特性的灰度必须走常规发布,不能走实验。 Co-Authored-By: Claude Fable 5 --- api/proto/luckygift/v1/luckygift.pb.go | 331 +++++++++++++++--- api/proto/luckygift/v1/luckygift.proto | 31 ++ api/proto/luckygift/v1/luckygift_grpc.pb.go | 38 ++ server/admin/cmd/server/main.go | 1 + .../integration/luckygiftadmin/client.go | 5 + .../internal/modules/databi/lucky_gift_v3.go | 162 +++++++++ .../modules/databi/lucky_gift_v3_test.go | 110 ++++++ .../admin/internal/modules/databi/service.go | 26 ++ .../mysql/initdb/001_lucky_gift_service.sql | 5 +- .../012_databi_day_stats_strategy.sql | 24 ++ .../internal/domain/luckygift/lucky_gift.go | 24 ++ .../internal/service/luckygift/service.go | 41 +++ .../mysql/lucky_gift_daily_stats_test.go | 136 +++++++ .../mysql/lucky_gift_experiment_repository.go | 9 + .../lucky_gift_experiment_repository_test.go | 40 +++ .../storage/mysql/lucky_gift_repository.go | 120 ++++++- .../transport/grpc/lucky_gift_server.go | 31 ++ 17 files changed, 1078 insertions(+), 56 deletions(-) create mode 100644 server/admin/internal/modules/databi/lucky_gift_v3.go create mode 100644 server/admin/internal/modules/databi/lucky_gift_v3_test.go create mode 100644 services/lucky-gift-service/deploy/mysql/migrations/012_databi_day_stats_strategy.sql create mode 100644 services/lucky-gift-service/internal/storage/mysql/lucky_gift_daily_stats_test.go diff --git a/api/proto/luckygift/v1/luckygift.pb.go b/api/proto/luckygift/v1/luckygift.pb.go index 04692ccf..c7cb4800 100644 --- a/api/proto/luckygift/v1/luckygift.pb.go +++ b/api/proto/luckygift/v1/luckygift.pb.go @@ -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, }, diff --git a/api/proto/luckygift/v1/luckygift.proto b/api/proto/luckygift/v1/luckygift.proto index b3e5e183..c2660594 100644 --- a/api/proto/luckygift/v1/luckygift.proto +++ b/api/proto/luckygift/v1/luckygift.proto @@ -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; +} diff --git a/api/proto/luckygift/v1/luckygift_grpc.pb.go b/api/proto/luckygift/v1/luckygift_grpc.pb.go index 4dc66011..9448619c 100644 --- a/api/proto/luckygift/v1/luckygift_grpc.pb.go +++ b/api/proto/luckygift/v1/luckygift_grpc.pb.go @@ -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", diff --git a/server/admin/cmd/server/main.go b/server/admin/cmd/server/main.go index bf00ea17..dec1773d 100644 --- a/server/admin/cmd/server/main.go +++ b/server/admin/cmd/server/main.go @@ -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)...), ) diff --git a/server/admin/internal/integration/luckygiftadmin/client.go b/server/admin/internal/integration/luckygiftadmin/client.go index 24260819..8ccaa21d 100644 --- a/server/admin/internal/integration/luckygiftadmin/client.go +++ b/server/admin/internal/integration/luckygiftadmin/client.go @@ -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) +} diff --git a/server/admin/internal/modules/databi/lucky_gift_v3.go b/server/admin/internal/modules/databi/lucky_gift_v3.go new file mode 100644 index 00000000..f28d4235 --- /dev/null +++ b/server/admin/internal/modules/databi/lucky_gift_v3.go @@ -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 +} diff --git a/server/admin/internal/modules/databi/lucky_gift_v3_test.go b/server/admin/internal/modules/databi/lucky_gift_v3_test.go new file mode 100644 index 00000000..80dd7067 --- /dev/null +++ b/server/admin/internal/modules/databi/lucky_gift_v3_test.go @@ -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) + } +} diff --git a/server/admin/internal/modules/databi/service.go b/server/admin/internal/modules/databi/service.go index fe91fb4d..3952cfec 100644 --- a/server/admin/internal/modules/databi/service.go +++ b/server/admin/internal/modules/databi/service.go @@ -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 不具备角色/付费身份/新老用户维度;筛选后必须把金额标为不可用, diff --git a/services/lucky-gift-service/deploy/mysql/initdb/001_lucky_gift_service.sql b/services/lucky-gift-service/deploy/mysql/initdb/001_lucky_gift_service.sql index 04f3534b..0471278c 100644 --- a/services/lucky-gift-service/deploy/mysql/initdb/001_lucky_gift_service.sql +++ b/services/lucky-gift-service/deploy/mysql/initdb/001_lucky_gift_service.sql @@ -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 日维度汇总'; diff --git a/services/lucky-gift-service/deploy/mysql/migrations/012_databi_day_stats_strategy.sql b/services/lucky-gift-service/deploy/mysql/migrations/012_databi_day_stats_strategy.sql new file mode 100644 index 00000000..758c5339 --- /dev/null +++ b/services/lucky-gift-service/deploy/mysql/migrations/012_databi_day_stats_strategy.sql @@ -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; diff --git a/services/lucky-gift-service/internal/domain/luckygift/lucky_gift.go b/services/lucky-gift-service/internal/domain/luckygift/lucky_gift.go index f2437e3d..e42cbe75 100644 --- a/services/lucky-gift-service/internal/domain/luckygift/lucky_gift.go +++ b/services/lucky-gift-service/internal/domain/luckygift/lucky_gift.go @@ -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 { diff --git a/services/lucky-gift-service/internal/service/luckygift/service.go b/services/lucky-gift-service/internal/service/luckygift/service.go index 1069196f..273c8bdc 100644 --- a/services/lucky-gift-service/internal/service/luckygift/service.go +++ b/services/lucky-gift-service/internal/service/luckygift/service.go @@ -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 diff --git a/services/lucky-gift-service/internal/storage/mysql/lucky_gift_daily_stats_test.go b/services/lucky-gift-service/internal/storage/mysql/lucky_gift_daily_stats_test.go new file mode 100644 index 00000000..3c0cf871 --- /dev/null +++ b/services/lucky-gift-service/internal/storage/mysql/lucky_gift_daily_stats_test.go @@ -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) + } +} diff --git a/services/lucky-gift-service/internal/storage/mysql/lucky_gift_experiment_repository.go b/services/lucky-gift-service/internal/storage/mysql/lucky_gift_experiment_repository.go index 75fb7ec4..83418cfa 100644 --- a/services/lucky-gift-service/internal/storage/mysql/lucky_gift_experiment_repository.go +++ b/services/lucky-gift-service/internal/storage/mysql/lucky_gift_experiment_repository.go @@ -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 死锁检测 // 回滚其一——管理端低频操作,报错重试即可,不为此引入额外串行化。 diff --git a/services/lucky-gift-service/internal/storage/mysql/lucky_gift_experiment_repository_test.go b/services/lucky-gift-service/internal/storage/mysql/lucky_gift_experiment_repository_test.go index 39c93c6e..d1b4d14e 100644 --- a/services/lucky-gift-service/internal/storage/mysql/lucky_gift_experiment_repository_test.go +++ b/services/lucky-gift-service/internal/storage/mysql/lucky_gift_experiment_repository_test.go @@ -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) + } +} diff --git a/services/lucky-gift-service/internal/storage/mysql/lucky_gift_repository.go b/services/lucky-gift-service/internal/storage/mysql/lucky_gift_repository.go index f0e2ffb8..a7f5c258 100644 --- a/services/lucky-gift-service/internal/storage/mysql/lucky_gift_repository.go +++ b/services/lucky-gift-service/internal/storage/mysql/lucky_gift_repository.go @@ -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) { diff --git a/services/lucky-gift-service/internal/transport/grpc/lucky_gift_server.go b/services/lucky-gift-service/internal/transport/grpc/lucky_gift_server.go index 47a7ac09..0b5f06de 100644 --- a/services/lucky-gift-service/internal/transport/grpc/lucky_gift_server.go +++ b/services/lucky-gift-service/internal/transport/grpc/lucky_gift_server.go @@ -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 租户。