merge: promote lucky gift test changes to main

This commit is contained in:
zhx 2026-07-17 01:00:28 +08:00
commit a35beb8d5f
68 changed files with 10505 additions and 1078 deletions

View File

@ -1,7 +1,7 @@
// Code generated by protoc-gen-go. DO NOT EDIT.
// versions:
// protoc-gen-go v1.36.11
// protoc v5.29.2
// protoc v7.35.0
// source: proto/activity/v1/activity.proto
package activityv1

View File

@ -1,7 +1,7 @@
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
// versions:
// - protoc-gen-go-grpc v1.6.2
// - protoc v5.29.2
// - protoc v7.35.0
// source: proto/activity/v1/activity.proto
package activityv1

View File

@ -1,7 +1,7 @@
// Code generated by protoc-gen-go. DO NOT EDIT.
// versions:
// protoc-gen-go v1.36.11
// protoc v5.29.2
// protoc v7.35.0
// source: proto/events/luckygift/v1/events.proto
package luckygifteventsv1

View File

@ -1,7 +1,7 @@
// Code generated by protoc-gen-go. DO NOT EDIT.
// versions:
// protoc-gen-go v1.36.11
// protoc v5.29.2
// protoc v7.35.0
// source: proto/events/room/v1/events.proto
package roomeventsv1

View File

@ -1,7 +1,7 @@
// Code generated by protoc-gen-go. DO NOT EDIT.
// versions:
// protoc-gen-go v1.36.11
// protoc v5.29.2
// protoc v7.35.0
// source: proto/game/v1/game.proto
package gamev1

View File

@ -1,7 +1,7 @@
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
// versions:
// - protoc-gen-go-grpc v1.6.2
// - protoc v5.29.2
// - protoc v7.35.0
// source: proto/game/v1/game.proto
package gamev1

File diff suppressed because it is too large Load Diff

View File

@ -42,6 +42,9 @@ message LuckyGiftMeta {
int64 last_recharged_at_ms = 21;
// gift_income_coins wallet dynamic_v3
int64 gift_income_coins = 22;
// user_registered_at_ms user-service
// dynamic_v3 48 fail-close
int64 user_registered_at_ms = 23;
}
message LuckyGiftRuleTier {
@ -91,8 +94,9 @@ message LuckyGiftRuleConfig {
int64 recharge_boost_factor_ppm = 26;
repeated int64 jackpot_multiplier_ppms = 27;
int64 jackpot_global_rtp_max_ppm = 28;
int64 jackpot_user_day_rtp_max_ppm = 29;
int64 jackpot_user_72h_rtp_max_ppm = 30;
int64 jackpot_user_round_rtp_max_ppm = 29;
int64 jackpot_user_48h_rtp_max_ppm = 30;
// UTC
int64 jackpot_spend_threshold_coins = 31;
int64 max_jackpot_hits_per_user_day = 32;
int64 max_single_payout = 33;
@ -101,6 +105,10 @@ message LuckyGiftRuleConfig {
int64 device_daily_payout_cap = 36;
int64 room_hourly_payout_cap = 37;
int64 anchor_daily_payout_cap = 38;
// 24 RTP 0
int64 user_24h_rtp_threshold_ppm = 39;
// 24 RTP 1000000
int64 user_24h_ordinary_win_factor_ppm = 40;
}
message CheckLuckyGiftRequest {
@ -183,6 +191,8 @@ message ExternalGiftDrawRequest {
string pool_id = 12;
// dynamic_v3 luck-gateway /fixed_v2
string device_id = 13;
// user_registered_at_ms App dynamic_v3 fail-close
int64 user_registered_at_ms = 14;
}
message ExternalGiftDrawResponse {
@ -433,6 +443,8 @@ service AdminLuckyGiftService {
rpc UpdateLuckyGiftExperiment(UpdateLuckyGiftExperimentRequest) returns (UpdateLuckyGiftExperimentResponse);
rpc SetLuckyGiftExperimentOverrides(SetLuckyGiftExperimentOverridesRequest) returns (SetLuckyGiftExperimentOverridesResponse);
rpc EndLuckyGiftExperiment(EndLuckyGiftExperimentRequest) returns (EndLuckyGiftExperimentResponse);
rpc ListLuckyGiftUserProfiles(ListLuckyGiftUserProfilesRequest) returns (ListLuckyGiftUserProfilesResponse);
rpc GetLuckyGiftUserProfile(GetLuckyGiftUserProfileRequest) returns (GetLuckyGiftUserProfileResponse);
}
// LuckyGiftHit
@ -477,3 +489,138 @@ message AdjustLuckyGiftPoolBalanceResponse {
LuckyGiftPoolBalance pool = 2;
bool idempotent_replay = 3;
}
// message index owner
// external_user_id
message LuckyGiftUserProfileWindow {
int64 draws = 1;
int64 wager_coins = 2;
int64 payout_coins = 3;
int64 net_coins = 4;
int64 rtp_ppm = 5;
bool has_rtp = 6;
}
message LuckyGiftUserProfile {
string app_code = 1;
string pool_id = 2;
string identity_type = 3;
int64 internal_user_id = 4;
string external_user_id = 5;
int64 rule_version = 6;
string strategy_version = 7;
string stage = 8;
int64 paid_draws = 9;
int64 equivalent_draws = 10;
int64 loss_streak = 11;
int64 pending_spend_jackpot_tokens = 12;
int64 guarantee_draws_remaining = 13;
bool downweight_active = 14;
int64 downweight_factor_ppm = 15;
LuckyGiftUserProfileWindow one_hour = 16;
LuckyGiftUserProfileWindow twenty_four_hours = 17;
LuckyGiftUserProfileWindow forty_eight_hours = 18;
LuckyGiftUserProfileWindow lifetime = 19;
int64 base_reward_coins = 20;
int64 ordinary_win_count = 21;
int64 high_multiplier_ordinary_win_count = 22;
int64 rtp_compensation_jackpot_count = 23;
int64 cumulative_spend_jackpot_count = 24;
int64 guarantee_hit_count = 25;
int64 zero_draw_count = 26;
int64 granted_draw_count = 27;
int64 pending_draw_count = 28;
int64 failed_draw_count = 29;
int64 max_multiplier_ppm = 30;
int64 max_reward_coins = 31;
int64 first_draw_at_ms = 32;
int64 last_draw_at_ms = 33;
int64 aggregated_at_ms = 34;
int64 data_lag_ms = 35;
int64 user_24h_rtp_threshold_ppm = 36;
}
message ListLuckyGiftUserProfilesRequest {
RequestMeta meta = 1;
string pool_id = 2;
string identity_type = 3;
int64 internal_user_id = 4;
string external_user_id = 5;
string stage = 6;
string status = 7;
string sort_by = 8;
string sort_direction = 9;
int32 page = 10;
int32 page_size = 11;
// cursor owner service keyset page OFFSET
string cursor = 12;
}
message ListLuckyGiftUserProfilesResponse {
repeated LuckyGiftUserProfile profiles = 1;
int64 total = 2;
int64 snapshot_at_ms = 3;
string next_cursor = 4;
bool has_more = 5;
}
message GetLuckyGiftUserProfileRequest {
RequestMeta meta = 1;
string pool_id = 2;
string identity_type = 3;
int64 internal_user_id = 4;
string external_user_id = 5;
int32 jackpot_page = 6;
int32 jackpot_page_size = 7;
// jackpot_cursor next_jackpot_cursor jackpot_page
string jackpot_cursor = 8;
}
message LuckyGiftUserMultiplierStat {
string hit_type = 1;
int64 multiplier_ppm = 2;
int64 draw_count = 3;
int64 wager_coins = 4;
int64 payout_coins = 5;
}
message LuckyGiftUserJackpotCondition {
string name = 1;
int64 numerator = 2;
int64 denominator = 3;
int64 ratio_ppm = 4;
int64 limit_ppm = 5;
int64 factor_ppm = 6;
bool passed = 7;
string reason = 8;
}
message LuckyGiftUserJackpotHit {
string draw_id = 1;
string request_id = 2;
int64 rule_version = 3;
string stage = 4;
string mechanism = 5;
string reason_code = 6;
string reason_summary = 7;
string tier_id = 8;
int64 multiplier_ppm = 9;
int64 wager_coins = 10;
int64 payout_coins = 11;
int64 occurred_at_ms = 12;
repeated LuckyGiftUserJackpotCondition conditions = 13;
// event_key
string event_key = 14;
}
message GetLuckyGiftUserProfileResponse {
LuckyGiftUserProfile profile = 1;
repeated LuckyGiftUserMultiplierStat multiplier_distribution = 2;
repeated LuckyGiftUserJackpotHit jackpot_hits = 3;
int64 jackpot_hit_total = 4;
int64 snapshot_at_ms = 5;
int32 jackpot_page = 6;
int32 jackpot_page_size = 7;
string next_jackpot_cursor = 8;
bool jackpot_has_more = 9;
}

View File

@ -247,6 +247,8 @@ const (
AdminLuckyGiftService_UpdateLuckyGiftExperiment_FullMethodName = "/hyapp.luckygift.v1.AdminLuckyGiftService/UpdateLuckyGiftExperiment"
AdminLuckyGiftService_SetLuckyGiftExperimentOverrides_FullMethodName = "/hyapp.luckygift.v1.AdminLuckyGiftService/SetLuckyGiftExperimentOverrides"
AdminLuckyGiftService_EndLuckyGiftExperiment_FullMethodName = "/hyapp.luckygift.v1.AdminLuckyGiftService/EndLuckyGiftExperiment"
AdminLuckyGiftService_ListLuckyGiftUserProfiles_FullMethodName = "/hyapp.luckygift.v1.AdminLuckyGiftService/ListLuckyGiftUserProfiles"
AdminLuckyGiftService_GetLuckyGiftUserProfile_FullMethodName = "/hyapp.luckygift.v1.AdminLuckyGiftService/GetLuckyGiftUserProfile"
)
// AdminLuckyGiftServiceClient is the client API for AdminLuckyGiftService service.
@ -265,6 +267,8 @@ type AdminLuckyGiftServiceClient interface {
UpdateLuckyGiftExperiment(ctx context.Context, in *UpdateLuckyGiftExperimentRequest, opts ...grpc.CallOption) (*UpdateLuckyGiftExperimentResponse, error)
SetLuckyGiftExperimentOverrides(ctx context.Context, in *SetLuckyGiftExperimentOverridesRequest, opts ...grpc.CallOption) (*SetLuckyGiftExperimentOverridesResponse, error)
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)
}
type adminLuckyGiftServiceClient struct {
@ -395,6 +399,26 @@ func (c *adminLuckyGiftServiceClient) EndLuckyGiftExperiment(ctx context.Context
return out, nil
}
func (c *adminLuckyGiftServiceClient) ListLuckyGiftUserProfiles(ctx context.Context, in *ListLuckyGiftUserProfilesRequest, opts ...grpc.CallOption) (*ListLuckyGiftUserProfilesResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(ListLuckyGiftUserProfilesResponse)
err := c.cc.Invoke(ctx, AdminLuckyGiftService_ListLuckyGiftUserProfiles_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *adminLuckyGiftServiceClient) GetLuckyGiftUserProfile(ctx context.Context, in *GetLuckyGiftUserProfileRequest, opts ...grpc.CallOption) (*GetLuckyGiftUserProfileResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(GetLuckyGiftUserProfileResponse)
err := c.cc.Invoke(ctx, AdminLuckyGiftService_GetLuckyGiftUserProfile_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.
@ -411,6 +435,8 @@ type AdminLuckyGiftServiceServer interface {
UpdateLuckyGiftExperiment(context.Context, *UpdateLuckyGiftExperimentRequest) (*UpdateLuckyGiftExperimentResponse, error)
SetLuckyGiftExperimentOverrides(context.Context, *SetLuckyGiftExperimentOverridesRequest) (*SetLuckyGiftExperimentOverridesResponse, error)
EndLuckyGiftExperiment(context.Context, *EndLuckyGiftExperimentRequest) (*EndLuckyGiftExperimentResponse, error)
ListLuckyGiftUserProfiles(context.Context, *ListLuckyGiftUserProfilesRequest) (*ListLuckyGiftUserProfilesResponse, error)
GetLuckyGiftUserProfile(context.Context, *GetLuckyGiftUserProfileRequest) (*GetLuckyGiftUserProfileResponse, error)
mustEmbedUnimplementedAdminLuckyGiftServiceServer()
}
@ -457,6 +483,12 @@ func (UnimplementedAdminLuckyGiftServiceServer) SetLuckyGiftExperimentOverrides(
func (UnimplementedAdminLuckyGiftServiceServer) EndLuckyGiftExperiment(context.Context, *EndLuckyGiftExperimentRequest) (*EndLuckyGiftExperimentResponse, error) {
return nil, status.Error(codes.Unimplemented, "method EndLuckyGiftExperiment not implemented")
}
func (UnimplementedAdminLuckyGiftServiceServer) ListLuckyGiftUserProfiles(context.Context, *ListLuckyGiftUserProfilesRequest) (*ListLuckyGiftUserProfilesResponse, error) {
return nil, status.Error(codes.Unimplemented, "method ListLuckyGiftUserProfiles not implemented")
}
func (UnimplementedAdminLuckyGiftServiceServer) GetLuckyGiftUserProfile(context.Context, *GetLuckyGiftUserProfileRequest) (*GetLuckyGiftUserProfileResponse, error) {
return nil, status.Error(codes.Unimplemented, "method GetLuckyGiftUserProfile not implemented")
}
func (UnimplementedAdminLuckyGiftServiceServer) mustEmbedUnimplementedAdminLuckyGiftServiceServer() {}
func (UnimplementedAdminLuckyGiftServiceServer) testEmbeddedByValue() {}
@ -694,6 +726,42 @@ func _AdminLuckyGiftService_EndLuckyGiftExperiment_Handler(srv interface{}, ctx
return interceptor(ctx, in, info, handler)
}
func _AdminLuckyGiftService_ListLuckyGiftUserProfiles_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(ListLuckyGiftUserProfilesRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(AdminLuckyGiftServiceServer).ListLuckyGiftUserProfiles(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: AdminLuckyGiftService_ListLuckyGiftUserProfiles_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(AdminLuckyGiftServiceServer).ListLuckyGiftUserProfiles(ctx, req.(*ListLuckyGiftUserProfilesRequest))
}
return interceptor(ctx, in, info, handler)
}
func _AdminLuckyGiftService_GetLuckyGiftUserProfile_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(GetLuckyGiftUserProfileRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(AdminLuckyGiftServiceServer).GetLuckyGiftUserProfile(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: AdminLuckyGiftService_GetLuckyGiftUserProfile_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(AdminLuckyGiftServiceServer).GetLuckyGiftUserProfile(ctx, req.(*GetLuckyGiftUserProfileRequest))
}
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)
@ -749,6 +817,14 @@ var AdminLuckyGiftService_ServiceDesc = grpc.ServiceDesc{
MethodName: "EndLuckyGiftExperiment",
Handler: _AdminLuckyGiftService_EndLuckyGiftExperiment_Handler,
},
{
MethodName: "ListLuckyGiftUserProfiles",
Handler: _AdminLuckyGiftService_ListLuckyGiftUserProfiles_Handler,
},
{
MethodName: "GetLuckyGiftUserProfile",
Handler: _AdminLuckyGiftService_GetLuckyGiftUserProfile_Handler,
},
},
Streams: []grpc.StreamDesc{},
Metadata: "proto/luckygift/v1/luckygift.proto",

View File

@ -1,7 +1,7 @@
// Code generated by protoc-gen-go. DO NOT EDIT.
// versions:
// protoc-gen-go v1.36.11
// protoc v5.29.2
// protoc v7.35.0
// source: proto/robot/v1/robot.proto
package robotv1

View File

@ -1,7 +1,7 @@
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
// versions:
// - protoc-gen-go-grpc v1.6.2
// - protoc v5.29.2
// - protoc v7.35.0
// source: proto/robot/v1/robot.proto
package robotv1

View File

@ -1,7 +1,7 @@
// Code generated by protoc-gen-go. DO NOT EDIT.
// versions:
// protoc-gen-go v1.36.11
// protoc v5.29.2
// protoc v7.35.0
// source: proto/room/v1/room.proto
package roomv1
@ -9488,8 +9488,11 @@ type SendGiftRequest struct {
// combo_session_id + batch_seq 只描述 Flutter 微批会话command_id 仍是跨服务唯一幂等键。
ComboSessionId string `protobuf:"bytes,19,opt,name=combo_session_id,json=comboSessionId,proto3" json:"combo_session_id,omitempty"`
BatchSeq int64 `protobuf:"varint,20,opt,name=batch_seq,json=batchSeq,proto3" json:"batch_seq,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
// sender_registered_at_ms 是 gateway 从 user-service 读取的账号创建事实;客户端不能提交或覆盖。
// room-service 只随首次送礼命令持久化并透传给 lucky-gift-service不据此拥有用户主数据。
SenderRegisteredAtMs int64 `protobuf:"varint,21,opt,name=sender_registered_at_ms,json=senderRegisteredAtMs,proto3" json:"sender_registered_at_ms,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *SendGiftRequest) Reset() {
@ -9662,6 +9665,13 @@ func (x *SendGiftRequest) GetBatchSeq() int64 {
return 0
}
func (x *SendGiftRequest) GetSenderRegisteredAtMs() int64 {
if x != nil {
return x.SenderRegisteredAtMs
}
return 0
}
// SendGiftResponse 返回扣费成功并落到房间后的状态结果。
type SendGiftResponse struct {
state protoimpl.MessageState `protogen:"open.v1"`
@ -13432,7 +13442,7 @@ const file_proto_room_v1_room_proto_rawDesc = "" +
"\x0ftarget_user_ids\x18\x14 \x03(\x03R\rtargetUserIds\x12<\n" +
"\atargets\x18\x15 \x03(\v2\".hyapp.room.v1.SendGiftBatchTargetR\atargets\x12\x1d\n" +
"\n" +
"command_id\x18\x16 \x01(\tR\tcommandId\"\xa9\a\n" +
"command_id\x18\x16 \x01(\tR\tcommandId\"\xe0\a\n" +
"\x0fSendGiftRequest\x12.\n" +
"\x04meta\x18\x01 \x01(\v2\x1a.hyapp.room.v1.RequestMetaR\x04meta\x12$\n" +
"\x0etarget_user_id\x18\x02 \x01(\x03R\ftargetUserId\x12\x17\n" +
@ -13456,7 +13466,8 @@ const file_proto_room_v1_room_proto_rawDesc = "" +
"\x17target_display_profiles\x18\x11 \x03(\v2%.hyapp.room.v1.SendGiftDisplayProfileR\x15targetDisplayProfiles\x12!\n" +
"\fdisplay_mode\x18\x12 \x01(\tR\vdisplayMode\x12(\n" +
"\x10combo_session_id\x18\x13 \x01(\tR\x0ecomboSessionId\x12\x1b\n" +
"\tbatch_seq\x18\x14 \x01(\x03R\bbatchSeq\"\x9e\x05\n" +
"\tbatch_seq\x18\x14 \x01(\x03R\bbatchSeq\x125\n" +
"\x17sender_registered_at_ms\x18\x15 \x01(\x03R\x14senderRegisteredAtMs\"\x9e\x05\n" +
"\x10SendGiftResponse\x124\n" +
"\x06result\x18\x01 \x01(\v2\x1c.hyapp.room.v1.CommandResultR\x06result\x12,\n" +
"\x12billing_receipt_id\x18\x02 \x01(\tR\x10billingReceiptId\x12\x1b\n" +

View File

@ -1163,6 +1163,9 @@ message SendGiftRequest {
// combo_session_id + batch_seq Flutter command_id
string combo_session_id = 19;
int64 batch_seq = 20;
// sender_registered_at_ms gateway user-service
// room-service lucky-gift-service
int64 sender_registered_at_ms = 21;
}
// SendGiftResponse

View File

@ -1,7 +1,7 @@
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
// versions:
// - protoc-gen-go-grpc v1.6.2
// - protoc v5.29.2
// - protoc v7.35.0
// source: proto/room/v1/room.proto
package roomv1

View File

@ -1,7 +1,7 @@
// Code generated by protoc-gen-go. DO NOT EDIT.
// versions:
// protoc-gen-go v1.36.11
// protoc v5.29.2
// protoc v7.35.0
// source: proto/user/v1/auth.proto
package userv1

View File

@ -1,7 +1,7 @@
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
// versions:
// - protoc-gen-go-grpc v1.6.2
// - protoc v5.29.2
// - protoc v7.35.0
// source: proto/user/v1/auth.proto
package userv1

View File

@ -1,7 +1,7 @@
// Code generated by protoc-gen-go. DO NOT EDIT.
// versions:
// protoc-gen-go v1.36.11
// protoc v5.29.2
// protoc v7.35.0
// source: proto/user/v1/host.proto
package userv1

View File

@ -1,7 +1,7 @@
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
// versions:
// - protoc-gen-go-grpc v1.6.2
// - protoc v5.29.2
// - protoc v7.35.0
// source: proto/user/v1/host.proto
package userv1

View File

@ -1,7 +1,7 @@
// Code generated by protoc-gen-go. DO NOT EDIT.
// versions:
// protoc-gen-go v1.36.11
// protoc v5.29.2
// protoc v7.35.0
// source: proto/user/v1/user.proto
package userv1
@ -13348,6 +13348,105 @@ func (x *ListAppsResponse) GetApps() []*App {
return nil
}
// ResolveAdminUserIdentifierRequest 是后台精确查人的内部契约。与客户端展示号解析不同,
// user_identifier 可以是永久默认短号、当前有效展示号或系统长 user_id。
type ResolveAdminUserIdentifierRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
Meta *RequestMeta `protobuf:"bytes,1,opt,name=meta,proto3" json:"meta,omitempty"`
UserIdentifier string `protobuf:"bytes,2,opt,name=user_identifier,json=userIdentifier,proto3" json:"user_identifier,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *ResolveAdminUserIdentifierRequest) Reset() {
*x = ResolveAdminUserIdentifierRequest{}
mi := &file_proto_user_v1_user_proto_msgTypes[182]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *ResolveAdminUserIdentifierRequest) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*ResolveAdminUserIdentifierRequest) ProtoMessage() {}
func (x *ResolveAdminUserIdentifierRequest) ProtoReflect() protoreflect.Message {
mi := &file_proto_user_v1_user_proto_msgTypes[182]
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 ResolveAdminUserIdentifierRequest.ProtoReflect.Descriptor instead.
func (*ResolveAdminUserIdentifierRequest) Descriptor() ([]byte, []int) {
return file_proto_user_v1_user_proto_rawDescGZIP(), []int{182}
}
func (x *ResolveAdminUserIdentifierRequest) GetMeta() *RequestMeta {
if x != nil {
return x.Meta
}
return nil
}
func (x *ResolveAdminUserIdentifierRequest) GetUserIdentifier() string {
if x != nil {
return x.UserIdentifier
}
return ""
}
// ResolveAdminUserIdentifierResponse 返回匹配用户的当前有效身份投影。
type ResolveAdminUserIdentifierResponse struct {
state protoimpl.MessageState `protogen:"open.v1"`
Identity *UserIdentity `protobuf:"bytes,1,opt,name=identity,proto3" json:"identity,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *ResolveAdminUserIdentifierResponse) Reset() {
*x = ResolveAdminUserIdentifierResponse{}
mi := &file_proto_user_v1_user_proto_msgTypes[183]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *ResolveAdminUserIdentifierResponse) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*ResolveAdminUserIdentifierResponse) ProtoMessage() {}
func (x *ResolveAdminUserIdentifierResponse) ProtoReflect() protoreflect.Message {
mi := &file_proto_user_v1_user_proto_msgTypes[183]
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 ResolveAdminUserIdentifierResponse.ProtoReflect.Descriptor instead.
func (*ResolveAdminUserIdentifierResponse) Descriptor() ([]byte, []int) {
return file_proto_user_v1_user_proto_rawDescGZIP(), []int{183}
}
func (x *ResolveAdminUserIdentifierResponse) GetIdentity() *UserIdentity {
if x != nil {
return x.Identity
}
return nil
}
var File_proto_user_v1_user_proto protoreflect.FileDescriptor
const file_proto_user_v1_user_proto_rawDesc = "" +
@ -14477,7 +14576,12 @@ const file_proto_user_v1_user_proto_rawDesc = "" +
"\x0fListAppsRequest\x12.\n" +
"\x04meta\x18\x01 \x01(\v2\x1a.hyapp.user.v1.RequestMetaR\x04meta\":\n" +
"\x10ListAppsResponse\x12&\n" +
"\x04apps\x18\x01 \x03(\v2\x12.hyapp.user.v1.AppR\x04apps*s\n" +
"\x04apps\x18\x01 \x03(\v2\x12.hyapp.user.v1.AppR\x04apps\"|\n" +
"!ResolveAdminUserIdentifierRequest\x12.\n" +
"\x04meta\x18\x01 \x01(\v2\x1a.hyapp.user.v1.RequestMetaR\x04meta\x12'\n" +
"\x0fuser_identifier\x18\x02 \x01(\tR\x0euserIdentifier\"]\n" +
"\"ResolveAdminUserIdentifierResponse\x127\n" +
"\bidentity\x18\x01 \x01(\v2\x1b.hyapp.user.v1.UserIdentityR\bidentity*s\n" +
"\n" +
"UserStatus\x12\x1b\n" +
"\x17USER_STATUS_UNSPECIFIED\x10\x00\x12\x16\n" +
@ -14561,10 +14665,11 @@ const file_proto_user_v1_user_proto_rawDesc = "" +
"\vListRegions\x12!.hyapp.user.v1.ListRegionsRequest\x1a\".hyapp.user.v1.ListRegionsResponse\x12K\n" +
"\tGetRegion\x12\x1f.hyapp.user.v1.GetRegionRequest\x1a\x1d.hyapp.user.v1.RegionResponse\x12Q\n" +
"\fUpdateRegion\x12\".hyapp.user.v1.UpdateRegionRequest\x1a\x1d.hyapp.user.v1.RegionResponse\x12e\n" +
"\x16ReplaceRegionCountries\x12,.hyapp.user.v1.ReplaceRegionCountriesRequest\x1a\x1d.hyapp.user.v1.RegionResponse2\xea\x06\n" +
"\x16ReplaceRegionCountries\x12,.hyapp.user.v1.ReplaceRegionCountriesRequest\x1a\x1d.hyapp.user.v1.RegionResponse2\xee\a\n" +
"\x13UserIdentityService\x12`\n" +
"\x0fGetUserIdentity\x12%.hyapp.user.v1.GetUserIdentityRequest\x1a&.hyapp.user.v1.GetUserIdentityResponse\x12o\n" +
"\x14ResolveDisplayUserID\x12*.hyapp.user.v1.ResolveDisplayUserIDRequest\x1a+.hyapp.user.v1.ResolveDisplayUserIDResponse\x12l\n" +
"\x14ResolveDisplayUserID\x12*.hyapp.user.v1.ResolveDisplayUserIDRequest\x1a+.hyapp.user.v1.ResolveDisplayUserIDResponse\x12\x81\x01\n" +
"\x1aResolveAdminUserIdentifier\x120.hyapp.user.v1.ResolveAdminUserIdentifierRequest\x1a1.hyapp.user.v1.ResolveAdminUserIdentifierResponse\x12l\n" +
"\x13ChangeDisplayUserID\x12).hyapp.user.v1.ChangeDisplayUserIDRequest\x1a*.hyapp.user.v1.ChangeDisplayUserIDResponse\x12{\n" +
"\x18ApplyPrettyDisplayUserID\x12..hyapp.user.v1.ApplyPrettyDisplayUserIDRequest\x1a/.hyapp.user.v1.ApplyPrettyDisplayUserIDResponse\x12\x8a\x01\n" +
"\x1dListAvailablePrettyDisplayIDs\x123.hyapp.user.v1.ListAvailablePrettyDisplayIDsRequest\x1a4.hyapp.user.v1.ListAvailablePrettyDisplayIDsResponse\x12\x87\x01\n" +
@ -14593,7 +14698,7 @@ func file_proto_user_v1_user_proto_rawDescGZIP() []byte {
}
var file_proto_user_v1_user_proto_enumTypes = make([]protoimpl.EnumInfo, 1)
var file_proto_user_v1_user_proto_msgTypes = make([]protoimpl.MessageInfo, 185)
var file_proto_user_v1_user_proto_msgTypes = make([]protoimpl.MessageInfo, 187)
var file_proto_user_v1_user_proto_goTypes = []any{
(UserStatus)(0), // 0: hyapp.user.v1.UserStatus
(*RequestMeta)(nil), // 1: hyapp.user.v1.RequestMeta
@ -14778,9 +14883,11 @@ var file_proto_user_v1_user_proto_goTypes = []any{
(*ListCPFormationGiftFeedResponse)(nil), // 180: hyapp.user.v1.ListCPFormationGiftFeedResponse
(*ListAppsRequest)(nil), // 181: hyapp.user.v1.ListAppsRequest
(*ListAppsResponse)(nil), // 182: hyapp.user.v1.ListAppsResponse
nil, // 183: hyapp.user.v1.BatchGetUsersResponse.UsersEntry
nil, // 184: hyapp.user.v1.BatchGetUserAdminProfilesResponse.ProfilesEntry
nil, // 185: hyapp.user.v1.BatchGetRoomBasicUsersResponse.UsersEntry
(*ResolveAdminUserIdentifierRequest)(nil), // 183: hyapp.user.v1.ResolveAdminUserIdentifierRequest
(*ResolveAdminUserIdentifierResponse)(nil), // 184: hyapp.user.v1.ResolveAdminUserIdentifierResponse
nil, // 185: hyapp.user.v1.BatchGetUsersResponse.UsersEntry
nil, // 186: hyapp.user.v1.BatchGetUserAdminProfilesResponse.ProfilesEntry
nil, // 187: hyapp.user.v1.BatchGetRoomBasicUsersResponse.UsersEntry
}
var file_proto_user_v1_user_proto_depIdxs = []int32{
1, // 0: hyapp.user.v1.ResolveAppRequest.meta:type_name -> hyapp.user.v1.RequestMeta
@ -14854,15 +14961,15 @@ var file_proto_user_v1_user_proto_depIdxs = []int32{
1, // 68: hyapp.user.v1.SubmitReportRequest.meta:type_name -> hyapp.user.v1.RequestMeta
78, // 69: hyapp.user.v1.SubmitReportResponse.report:type_name -> hyapp.user.v1.UserReport
1, // 70: hyapp.user.v1.BatchGetUsersRequest.meta:type_name -> hyapp.user.v1.RequestMeta
183, // 71: hyapp.user.v1.BatchGetUsersResponse.users:type_name -> hyapp.user.v1.BatchGetUsersResponse.UsersEntry
185, // 71: hyapp.user.v1.BatchGetUsersResponse.users:type_name -> hyapp.user.v1.BatchGetUsersResponse.UsersEntry
0, // 72: hyapp.user.v1.ActiveUserBanSummary.user_status:type_name -> hyapp.user.v1.UserStatus
5, // 73: hyapp.user.v1.UserAdminProfile.user:type_name -> hyapp.user.v1.User
83, // 74: hyapp.user.v1.UserAdminProfile.ban:type_name -> hyapp.user.v1.ActiveUserBanSummary
1, // 75: hyapp.user.v1.BatchGetUserAdminProfilesRequest.meta:type_name -> hyapp.user.v1.RequestMeta
184, // 76: hyapp.user.v1.BatchGetUserAdminProfilesResponse.profiles:type_name -> hyapp.user.v1.BatchGetUserAdminProfilesResponse.ProfilesEntry
186, // 76: hyapp.user.v1.BatchGetUserAdminProfilesResponse.profiles:type_name -> hyapp.user.v1.BatchGetUserAdminProfilesResponse.ProfilesEntry
1, // 77: hyapp.user.v1.AdminIssueUserAccessTokenRequest.meta:type_name -> hyapp.user.v1.RequestMeta
1, // 78: hyapp.user.v1.BatchGetRoomBasicUsersRequest.meta:type_name -> hyapp.user.v1.RequestMeta
185, // 79: hyapp.user.v1.BatchGetRoomBasicUsersResponse.users:type_name -> hyapp.user.v1.BatchGetRoomBasicUsersResponse.UsersEntry
187, // 79: hyapp.user.v1.BatchGetRoomBasicUsersResponse.users:type_name -> hyapp.user.v1.BatchGetRoomBasicUsersResponse.UsersEntry
1, // 80: hyapp.user.v1.ListUserIDsRequest.meta:type_name -> hyapp.user.v1.RequestMeta
1, // 81: hyapp.user.v1.UpdateUserProfileRequest.meta:type_name -> hyapp.user.v1.RequestMeta
5, // 82: hyapp.user.v1.UpdateUserProfileResponse.user:type_name -> hyapp.user.v1.User
@ -14952,176 +15059,180 @@ var file_proto_user_v1_user_proto_depIdxs = []int32{
178, // 166: hyapp.user.v1.ListCPFormationGiftFeedResponse.items:type_name -> hyapp.user.v1.CPFormationGiftFeedItem
1, // 167: hyapp.user.v1.ListAppsRequest.meta:type_name -> hyapp.user.v1.RequestMeta
2, // 168: hyapp.user.v1.ListAppsResponse.apps:type_name -> hyapp.user.v1.App
5, // 169: hyapp.user.v1.BatchGetUsersResponse.UsersEntry.value:type_name -> hyapp.user.v1.User
84, // 170: hyapp.user.v1.BatchGetUserAdminProfilesResponse.ProfilesEntry.value:type_name -> hyapp.user.v1.UserAdminProfile
89, // 171: hyapp.user.v1.BatchGetRoomBasicUsersResponse.UsersEntry.value:type_name -> hyapp.user.v1.RoomBasicUser
17, // 172: hyapp.user.v1.UserService.GetUser:input_type -> hyapp.user.v1.GetUserRequest
9, // 173: hyapp.user.v1.UserService.GetInviteAttribution:input_type -> hyapp.user.v1.GetInviteAttributionRequest
19, // 174: hyapp.user.v1.UserService.BusinessUserLookup:input_type -> hyapp.user.v1.BusinessUserLookupRequest
23, // 175: hyapp.user.v1.UserService.GetMyProfileStats:input_type -> hyapp.user.v1.GetMyProfileStatsRequest
81, // 176: hyapp.user.v1.UserService.BatchGetUsers:input_type -> hyapp.user.v1.BatchGetUsersRequest
85, // 177: hyapp.user.v1.UserService.BatchGetUserAdminProfiles:input_type -> hyapp.user.v1.BatchGetUserAdminProfilesRequest
87, // 178: hyapp.user.v1.UserService.AdminIssueUserAccessToken:input_type -> hyapp.user.v1.AdminIssueUserAccessTokenRequest
90, // 179: hyapp.user.v1.UserService.BatchGetRoomBasicUsers:input_type -> hyapp.user.v1.BatchGetRoomBasicUsersRequest
92, // 180: hyapp.user.v1.UserService.ListUserIDs:input_type -> hyapp.user.v1.ListUserIDsRequest
12, // 181: hyapp.user.v1.UserService.GetUserMicLifetimeStats:input_type -> hyapp.user.v1.GetUserMicLifetimeStatsRequest
94, // 182: hyapp.user.v1.UserService.UpdateUserProfile:input_type -> hyapp.user.v1.UpdateUserProfileRequest
96, // 183: hyapp.user.v1.UserService.UpdateUserProfileBackground:input_type -> hyapp.user.v1.UpdateUserProfileBackgroundRequest
98, // 184: hyapp.user.v1.UserService.UpdateUserContactInfo:input_type -> hyapp.user.v1.UpdateUserContactInfoRequest
100, // 185: hyapp.user.v1.UserService.UpdateUserWithdrawAddress:input_type -> hyapp.user.v1.UpdateUserWithdrawAddressRequest
102, // 186: hyapp.user.v1.UserService.ChangeUserCountry:input_type -> hyapp.user.v1.ChangeUserCountryRequest
103, // 187: hyapp.user.v1.UserService.AdminChangeUserCountry:input_type -> hyapp.user.v1.AdminChangeUserCountryRequest
105, // 188: hyapp.user.v1.UserService.SetUserStatus:input_type -> hyapp.user.v1.SetUserStatusRequest
108, // 189: hyapp.user.v1.UserService.AdminBanUser:input_type -> hyapp.user.v1.AdminBanUserRequest
110, // 190: hyapp.user.v1.UserService.AdminUnbanUser:input_type -> hyapp.user.v1.AdminUnbanUserRequest
113, // 191: hyapp.user.v1.UserService.CreateManagerUserBlock:input_type -> hyapp.user.v1.CreateManagerUserBlockRequest
115, // 192: hyapp.user.v1.UserService.ListManagerUserBlocks:input_type -> hyapp.user.v1.ListManagerUserBlocksRequest
117, // 193: hyapp.user.v1.UserService.UnblockManagerUser:input_type -> hyapp.user.v1.UnblockManagerUserRequest
119, // 194: hyapp.user.v1.UserService.CompleteOnboarding:input_type -> hyapp.user.v1.CompleteOnboardingRequest
121, // 195: hyapp.user.v1.UserService.SearchInviteReferrer:input_type -> hyapp.user.v1.SearchInviteReferrerRequest
123, // 196: hyapp.user.v1.UserService.BindInviteReferrer:input_type -> hyapp.user.v1.BindInviteReferrerRequest
25, // 197: hyapp.user.v1.UserSocialService.RecordProfileVisit:input_type -> hyapp.user.v1.RecordProfileVisitRequest
28, // 198: hyapp.user.v1.UserSocialService.ListProfileVisitors:input_type -> hyapp.user.v1.ListProfileVisitorsRequest
30, // 199: hyapp.user.v1.UserSocialService.FollowUser:input_type -> hyapp.user.v1.FollowUserRequest
32, // 200: hyapp.user.v1.UserSocialService.UnfollowUser:input_type -> hyapp.user.v1.UnfollowUserRequest
35, // 201: hyapp.user.v1.UserSocialService.ListFollowing:input_type -> hyapp.user.v1.ListFollowingRequest
37, // 202: hyapp.user.v1.UserSocialService.ApplyFriend:input_type -> hyapp.user.v1.ApplyFriendRequest
39, // 203: hyapp.user.v1.UserSocialService.AcceptFriendApplication:input_type -> hyapp.user.v1.AcceptFriendApplicationRequest
41, // 204: hyapp.user.v1.UserSocialService.DeleteFriend:input_type -> hyapp.user.v1.DeleteFriendRequest
44, // 205: hyapp.user.v1.UserSocialService.ListFriends:input_type -> hyapp.user.v1.ListFriendsRequest
47, // 206: hyapp.user.v1.UserSocialService.ListFriendApplications:input_type -> hyapp.user.v1.ListFriendApplicationsRequest
79, // 207: hyapp.user.v1.UserSocialService.SubmitReport:input_type -> hyapp.user.v1.SubmitReportRequest
59, // 208: hyapp.user.v1.UserCPService.ListCPApplications:input_type -> hyapp.user.v1.ListCPApplicationsRequest
61, // 209: hyapp.user.v1.UserCPService.AcceptCPApplication:input_type -> hyapp.user.v1.AcceptCPApplicationRequest
63, // 210: hyapp.user.v1.UserCPService.RejectCPApplication:input_type -> hyapp.user.v1.RejectCPApplicationRequest
65, // 211: hyapp.user.v1.UserCPService.ListCPRelationships:input_type -> hyapp.user.v1.ListCPRelationshipsRequest
67, // 212: hyapp.user.v1.UserCPService.ListCPIntimacyLeaderboard:input_type -> hyapp.user.v1.ListCPIntimacyLeaderboardRequest
69, // 213: hyapp.user.v1.UserCPService.PrepareBreakCPRelationship:input_type -> hyapp.user.v1.PrepareBreakCPRelationshipRequest
71, // 214: hyapp.user.v1.UserCPService.ConfirmBreakCPRelationship:input_type -> hyapp.user.v1.ConfirmBreakCPRelationshipRequest
73, // 215: hyapp.user.v1.UserCPService.CancelBreakCPRelationship:input_type -> hyapp.user.v1.CancelBreakCPRelationshipRequest
179, // 216: hyapp.user.v1.UserCPService.ListCPFormationGiftFeed:input_type -> hyapp.user.v1.ListCPFormationGiftFeedRequest
76, // 217: hyapp.user.v1.UserCPInternalService.ConsumeRoomGiftCPEvent:input_type -> hyapp.user.v1.ConsumeRoomGiftCPEventRequest
57, // 218: hyapp.user.v1.UserCPInternalService.ListCPWeeklyRankEntries:input_type -> hyapp.user.v1.ListCPWeeklyRankEntriesRequest
14, // 219: hyapp.user.v1.UserCronService.ProcessLoginIPRiskBatch:input_type -> hyapp.user.v1.CronBatchRequest
14, // 220: hyapp.user.v1.UserCronService.ProcessRegionRebuildBatch:input_type -> hyapp.user.v1.CronBatchRequest
14, // 221: hyapp.user.v1.UserCronService.CompensateMicOpenSessions:input_type -> hyapp.user.v1.CronBatchRequest
14, // 222: hyapp.user.v1.UserCronService.CompensateRoomOpenSessions:input_type -> hyapp.user.v1.CronBatchRequest
14, // 223: hyapp.user.v1.UserCronService.ExpireManagerUserBlocks:input_type -> hyapp.user.v1.CronBatchRequest
14, // 224: hyapp.user.v1.UserCronService.ExpireAdminUserBans:input_type -> hyapp.user.v1.CronBatchRequest
14, // 225: hyapp.user.v1.UserCronService.RefreshCPIntimacyLeaderboard:input_type -> hyapp.user.v1.CronBatchRequest
125, // 226: hyapp.user.v1.UserDeviceService.BindPushToken:input_type -> hyapp.user.v1.BindPushTokenRequest
127, // 227: hyapp.user.v1.UserDeviceService.DeletePushToken:input_type -> hyapp.user.v1.DeletePushTokenRequest
3, // 228: hyapp.user.v1.AppRegistryService.ResolveApp:input_type -> hyapp.user.v1.ResolveAppRequest
181, // 229: hyapp.user.v1.AppRegistryService.ListApps:input_type -> hyapp.user.v1.ListAppsRequest
131, // 230: hyapp.user.v1.CountryAdminService.ListCountries:input_type -> hyapp.user.v1.ListCountriesRequest
133, // 231: hyapp.user.v1.CountryAdminService.UpdateCountry:input_type -> hyapp.user.v1.UpdateCountryRequest
135, // 232: hyapp.user.v1.CountryQueryService.ListRegistrationCountries:input_type -> hyapp.user.v1.ListRegistrationCountriesRequest
138, // 233: hyapp.user.v1.CountryQueryService.ListLoginRiskBlockedCountries:input_type -> hyapp.user.v1.ListLoginRiskBlockedCountriesRequest
140, // 234: hyapp.user.v1.RegionAdminService.ListRegions:input_type -> hyapp.user.v1.ListRegionsRequest
142, // 235: hyapp.user.v1.RegionAdminService.GetRegion:input_type -> hyapp.user.v1.GetRegionRequest
143, // 236: hyapp.user.v1.RegionAdminService.UpdateRegion:input_type -> hyapp.user.v1.UpdateRegionRequest
144, // 237: hyapp.user.v1.RegionAdminService.ReplaceRegionCountries:input_type -> hyapp.user.v1.ReplaceRegionCountriesRequest
147, // 238: hyapp.user.v1.UserIdentityService.GetUserIdentity:input_type -> hyapp.user.v1.GetUserIdentityRequest
149, // 239: hyapp.user.v1.UserIdentityService.ResolveDisplayUserID:input_type -> hyapp.user.v1.ResolveDisplayUserIDRequest
151, // 240: hyapp.user.v1.UserIdentityService.ChangeDisplayUserID:input_type -> hyapp.user.v1.ChangeDisplayUserIDRequest
153, // 241: hyapp.user.v1.UserIdentityService.ApplyPrettyDisplayUserID:input_type -> hyapp.user.v1.ApplyPrettyDisplayUserIDRequest
160, // 242: hyapp.user.v1.UserIdentityService.ListAvailablePrettyDisplayIDs:input_type -> hyapp.user.v1.ListAvailablePrettyDisplayIDsRequest
162, // 243: hyapp.user.v1.UserIdentityService.ApplyPrettyDisplayIDFromPool:input_type -> hyapp.user.v1.ApplyPrettyDisplayIDFromPoolRequest
155, // 244: hyapp.user.v1.UserIdentityService.ExpirePrettyDisplayUserID:input_type -> hyapp.user.v1.ExpirePrettyDisplayUserIDRequest
164, // 245: hyapp.user.v1.UserPrettyDisplayIDAdminService.ListPrettyDisplayIDPools:input_type -> hyapp.user.v1.ListPrettyDisplayIDPoolsRequest
166, // 246: hyapp.user.v1.UserPrettyDisplayIDAdminService.CreatePrettyDisplayIDPool:input_type -> hyapp.user.v1.CreatePrettyDisplayIDPoolRequest
167, // 247: hyapp.user.v1.UserPrettyDisplayIDAdminService.UpdatePrettyDisplayIDPool:input_type -> hyapp.user.v1.UpdatePrettyDisplayIDPoolRequest
169, // 248: hyapp.user.v1.UserPrettyDisplayIDAdminService.GeneratePrettyDisplayIDs:input_type -> hyapp.user.v1.GeneratePrettyDisplayIDsRequest
171, // 249: hyapp.user.v1.UserPrettyDisplayIDAdminService.ListPrettyDisplayIDs:input_type -> hyapp.user.v1.ListPrettyDisplayIDsRequest
173, // 250: hyapp.user.v1.UserPrettyDisplayIDAdminService.SetPrettyDisplayIDStatus:input_type -> hyapp.user.v1.SetPrettyDisplayIDStatusRequest
174, // 251: hyapp.user.v1.UserPrettyDisplayIDAdminService.RecyclePrettyDisplayID:input_type -> hyapp.user.v1.RecyclePrettyDisplayIDRequest
176, // 252: hyapp.user.v1.UserPrettyDisplayIDAdminService.AdminGrantPrettyDisplayID:input_type -> hyapp.user.v1.AdminGrantPrettyDisplayIDRequest
18, // 253: hyapp.user.v1.UserService.GetUser:output_type -> hyapp.user.v1.GetUserResponse
10, // 254: hyapp.user.v1.UserService.GetInviteAttribution:output_type -> hyapp.user.v1.GetInviteAttributionResponse
21, // 255: hyapp.user.v1.UserService.BusinessUserLookup:output_type -> hyapp.user.v1.BusinessUserLookupResponse
24, // 256: hyapp.user.v1.UserService.GetMyProfileStats:output_type -> hyapp.user.v1.GetMyProfileStatsResponse
82, // 257: hyapp.user.v1.UserService.BatchGetUsers:output_type -> hyapp.user.v1.BatchGetUsersResponse
86, // 258: hyapp.user.v1.UserService.BatchGetUserAdminProfiles:output_type -> hyapp.user.v1.BatchGetUserAdminProfilesResponse
88, // 259: hyapp.user.v1.UserService.AdminIssueUserAccessToken:output_type -> hyapp.user.v1.AdminIssueUserAccessTokenResponse
91, // 260: hyapp.user.v1.UserService.BatchGetRoomBasicUsers:output_type -> hyapp.user.v1.BatchGetRoomBasicUsersResponse
93, // 261: hyapp.user.v1.UserService.ListUserIDs:output_type -> hyapp.user.v1.ListUserIDsResponse
13, // 262: hyapp.user.v1.UserService.GetUserMicLifetimeStats:output_type -> hyapp.user.v1.GetUserMicLifetimeStatsResponse
95, // 263: hyapp.user.v1.UserService.UpdateUserProfile:output_type -> hyapp.user.v1.UpdateUserProfileResponse
97, // 264: hyapp.user.v1.UserService.UpdateUserProfileBackground:output_type -> hyapp.user.v1.UpdateUserProfileBackgroundResponse
99, // 265: hyapp.user.v1.UserService.UpdateUserContactInfo:output_type -> hyapp.user.v1.UpdateUserContactInfoResponse
101, // 266: hyapp.user.v1.UserService.UpdateUserWithdrawAddress:output_type -> hyapp.user.v1.UpdateUserWithdrawAddressResponse
104, // 267: hyapp.user.v1.UserService.ChangeUserCountry:output_type -> hyapp.user.v1.ChangeUserCountryResponse
104, // 268: hyapp.user.v1.UserService.AdminChangeUserCountry:output_type -> hyapp.user.v1.ChangeUserCountryResponse
106, // 269: hyapp.user.v1.UserService.SetUserStatus:output_type -> hyapp.user.v1.SetUserStatusResponse
109, // 270: hyapp.user.v1.UserService.AdminBanUser:output_type -> hyapp.user.v1.AdminBanUserResponse
111, // 271: hyapp.user.v1.UserService.AdminUnbanUser:output_type -> hyapp.user.v1.AdminUnbanUserResponse
114, // 272: hyapp.user.v1.UserService.CreateManagerUserBlock:output_type -> hyapp.user.v1.CreateManagerUserBlockResponse
116, // 273: hyapp.user.v1.UserService.ListManagerUserBlocks:output_type -> hyapp.user.v1.ListManagerUserBlocksResponse
118, // 274: hyapp.user.v1.UserService.UnblockManagerUser:output_type -> hyapp.user.v1.UnblockManagerUserResponse
120, // 275: hyapp.user.v1.UserService.CompleteOnboarding:output_type -> hyapp.user.v1.CompleteOnboardingResponse
122, // 276: hyapp.user.v1.UserService.SearchInviteReferrer:output_type -> hyapp.user.v1.SearchInviteReferrerResponse
124, // 277: hyapp.user.v1.UserService.BindInviteReferrer:output_type -> hyapp.user.v1.BindInviteReferrerResponse
26, // 278: hyapp.user.v1.UserSocialService.RecordProfileVisit:output_type -> hyapp.user.v1.RecordProfileVisitResponse
29, // 279: hyapp.user.v1.UserSocialService.ListProfileVisitors:output_type -> hyapp.user.v1.ListProfileVisitorsResponse
31, // 280: hyapp.user.v1.UserSocialService.FollowUser:output_type -> hyapp.user.v1.FollowUserResponse
33, // 281: hyapp.user.v1.UserSocialService.UnfollowUser:output_type -> hyapp.user.v1.UnfollowUserResponse
36, // 282: hyapp.user.v1.UserSocialService.ListFollowing:output_type -> hyapp.user.v1.ListFollowingResponse
38, // 283: hyapp.user.v1.UserSocialService.ApplyFriend:output_type -> hyapp.user.v1.ApplyFriendResponse
40, // 284: hyapp.user.v1.UserSocialService.AcceptFriendApplication:output_type -> hyapp.user.v1.AcceptFriendApplicationResponse
42, // 285: hyapp.user.v1.UserSocialService.DeleteFriend:output_type -> hyapp.user.v1.DeleteFriendResponse
45, // 286: hyapp.user.v1.UserSocialService.ListFriends:output_type -> hyapp.user.v1.ListFriendsResponse
48, // 287: hyapp.user.v1.UserSocialService.ListFriendApplications:output_type -> hyapp.user.v1.ListFriendApplicationsResponse
80, // 288: hyapp.user.v1.UserSocialService.SubmitReport:output_type -> hyapp.user.v1.SubmitReportResponse
60, // 289: hyapp.user.v1.UserCPService.ListCPApplications:output_type -> hyapp.user.v1.ListCPApplicationsResponse
62, // 290: hyapp.user.v1.UserCPService.AcceptCPApplication:output_type -> hyapp.user.v1.AcceptCPApplicationResponse
64, // 291: hyapp.user.v1.UserCPService.RejectCPApplication:output_type -> hyapp.user.v1.RejectCPApplicationResponse
66, // 292: hyapp.user.v1.UserCPService.ListCPRelationships:output_type -> hyapp.user.v1.ListCPRelationshipsResponse
68, // 293: hyapp.user.v1.UserCPService.ListCPIntimacyLeaderboard:output_type -> hyapp.user.v1.ListCPIntimacyLeaderboardResponse
70, // 294: hyapp.user.v1.UserCPService.PrepareBreakCPRelationship:output_type -> hyapp.user.v1.PrepareBreakCPRelationshipResponse
72, // 295: hyapp.user.v1.UserCPService.ConfirmBreakCPRelationship:output_type -> hyapp.user.v1.ConfirmBreakCPRelationshipResponse
74, // 296: hyapp.user.v1.UserCPService.CancelBreakCPRelationship:output_type -> hyapp.user.v1.CancelBreakCPRelationshipResponse
180, // 297: hyapp.user.v1.UserCPService.ListCPFormationGiftFeed:output_type -> hyapp.user.v1.ListCPFormationGiftFeedResponse
77, // 298: hyapp.user.v1.UserCPInternalService.ConsumeRoomGiftCPEvent:output_type -> hyapp.user.v1.ConsumeRoomGiftCPEventResponse
58, // 299: hyapp.user.v1.UserCPInternalService.ListCPWeeklyRankEntries:output_type -> hyapp.user.v1.ListCPWeeklyRankEntriesResponse
15, // 300: hyapp.user.v1.UserCronService.ProcessLoginIPRiskBatch:output_type -> hyapp.user.v1.CronBatchResponse
15, // 301: hyapp.user.v1.UserCronService.ProcessRegionRebuildBatch:output_type -> hyapp.user.v1.CronBatchResponse
15, // 302: hyapp.user.v1.UserCronService.CompensateMicOpenSessions:output_type -> hyapp.user.v1.CronBatchResponse
15, // 303: hyapp.user.v1.UserCronService.CompensateRoomOpenSessions:output_type -> hyapp.user.v1.CronBatchResponse
15, // 304: hyapp.user.v1.UserCronService.ExpireManagerUserBlocks:output_type -> hyapp.user.v1.CronBatchResponse
15, // 305: hyapp.user.v1.UserCronService.ExpireAdminUserBans:output_type -> hyapp.user.v1.CronBatchResponse
15, // 306: hyapp.user.v1.UserCronService.RefreshCPIntimacyLeaderboard:output_type -> hyapp.user.v1.CronBatchResponse
126, // 307: hyapp.user.v1.UserDeviceService.BindPushToken:output_type -> hyapp.user.v1.BindPushTokenResponse
128, // 308: hyapp.user.v1.UserDeviceService.DeletePushToken:output_type -> hyapp.user.v1.DeletePushTokenResponse
4, // 309: hyapp.user.v1.AppRegistryService.ResolveApp:output_type -> hyapp.user.v1.ResolveAppResponse
182, // 310: hyapp.user.v1.AppRegistryService.ListApps:output_type -> hyapp.user.v1.ListAppsResponse
132, // 311: hyapp.user.v1.CountryAdminService.ListCountries:output_type -> hyapp.user.v1.ListCountriesResponse
134, // 312: hyapp.user.v1.CountryAdminService.UpdateCountry:output_type -> hyapp.user.v1.CountryResponse
136, // 313: hyapp.user.v1.CountryQueryService.ListRegistrationCountries:output_type -> hyapp.user.v1.ListRegistrationCountriesResponse
139, // 314: hyapp.user.v1.CountryQueryService.ListLoginRiskBlockedCountries:output_type -> hyapp.user.v1.ListLoginRiskBlockedCountriesResponse
141, // 315: hyapp.user.v1.RegionAdminService.ListRegions:output_type -> hyapp.user.v1.ListRegionsResponse
145, // 316: hyapp.user.v1.RegionAdminService.GetRegion:output_type -> hyapp.user.v1.RegionResponse
145, // 317: hyapp.user.v1.RegionAdminService.UpdateRegion:output_type -> hyapp.user.v1.RegionResponse
145, // 318: hyapp.user.v1.RegionAdminService.ReplaceRegionCountries:output_type -> hyapp.user.v1.RegionResponse
148, // 319: hyapp.user.v1.UserIdentityService.GetUserIdentity:output_type -> hyapp.user.v1.GetUserIdentityResponse
150, // 320: hyapp.user.v1.UserIdentityService.ResolveDisplayUserID:output_type -> hyapp.user.v1.ResolveDisplayUserIDResponse
152, // 321: hyapp.user.v1.UserIdentityService.ChangeDisplayUserID:output_type -> hyapp.user.v1.ChangeDisplayUserIDResponse
154, // 322: hyapp.user.v1.UserIdentityService.ApplyPrettyDisplayUserID:output_type -> hyapp.user.v1.ApplyPrettyDisplayUserIDResponse
161, // 323: hyapp.user.v1.UserIdentityService.ListAvailablePrettyDisplayIDs:output_type -> hyapp.user.v1.ListAvailablePrettyDisplayIDsResponse
163, // 324: hyapp.user.v1.UserIdentityService.ApplyPrettyDisplayIDFromPool:output_type -> hyapp.user.v1.ApplyPrettyDisplayIDFromPoolResponse
156, // 325: hyapp.user.v1.UserIdentityService.ExpirePrettyDisplayUserID:output_type -> hyapp.user.v1.ExpirePrettyDisplayUserIDResponse
165, // 326: hyapp.user.v1.UserPrettyDisplayIDAdminService.ListPrettyDisplayIDPools:output_type -> hyapp.user.v1.ListPrettyDisplayIDPoolsResponse
168, // 327: hyapp.user.v1.UserPrettyDisplayIDAdminService.CreatePrettyDisplayIDPool:output_type -> hyapp.user.v1.PrettyDisplayIDPoolResponse
168, // 328: hyapp.user.v1.UserPrettyDisplayIDAdminService.UpdatePrettyDisplayIDPool:output_type -> hyapp.user.v1.PrettyDisplayIDPoolResponse
170, // 329: hyapp.user.v1.UserPrettyDisplayIDAdminService.GeneratePrettyDisplayIDs:output_type -> hyapp.user.v1.GeneratePrettyDisplayIDsResponse
172, // 330: hyapp.user.v1.UserPrettyDisplayIDAdminService.ListPrettyDisplayIDs:output_type -> hyapp.user.v1.ListPrettyDisplayIDsResponse
175, // 331: hyapp.user.v1.UserPrettyDisplayIDAdminService.SetPrettyDisplayIDStatus:output_type -> hyapp.user.v1.PrettyDisplayIDResponse
175, // 332: hyapp.user.v1.UserPrettyDisplayIDAdminService.RecyclePrettyDisplayID:output_type -> hyapp.user.v1.PrettyDisplayIDResponse
177, // 333: hyapp.user.v1.UserPrettyDisplayIDAdminService.AdminGrantPrettyDisplayID:output_type -> hyapp.user.v1.AdminGrantPrettyDisplayIDResponse
253, // [253:334] is the sub-list for method output_type
172, // [172:253] is the sub-list for method input_type
172, // [172:172] is the sub-list for extension type_name
172, // [172:172] is the sub-list for extension extendee
0, // [0:172] is the sub-list for field type_name
1, // 169: hyapp.user.v1.ResolveAdminUserIdentifierRequest.meta:type_name -> hyapp.user.v1.RequestMeta
146, // 170: hyapp.user.v1.ResolveAdminUserIdentifierResponse.identity:type_name -> hyapp.user.v1.UserIdentity
5, // 171: hyapp.user.v1.BatchGetUsersResponse.UsersEntry.value:type_name -> hyapp.user.v1.User
84, // 172: hyapp.user.v1.BatchGetUserAdminProfilesResponse.ProfilesEntry.value:type_name -> hyapp.user.v1.UserAdminProfile
89, // 173: hyapp.user.v1.BatchGetRoomBasicUsersResponse.UsersEntry.value:type_name -> hyapp.user.v1.RoomBasicUser
17, // 174: hyapp.user.v1.UserService.GetUser:input_type -> hyapp.user.v1.GetUserRequest
9, // 175: hyapp.user.v1.UserService.GetInviteAttribution:input_type -> hyapp.user.v1.GetInviteAttributionRequest
19, // 176: hyapp.user.v1.UserService.BusinessUserLookup:input_type -> hyapp.user.v1.BusinessUserLookupRequest
23, // 177: hyapp.user.v1.UserService.GetMyProfileStats:input_type -> hyapp.user.v1.GetMyProfileStatsRequest
81, // 178: hyapp.user.v1.UserService.BatchGetUsers:input_type -> hyapp.user.v1.BatchGetUsersRequest
85, // 179: hyapp.user.v1.UserService.BatchGetUserAdminProfiles:input_type -> hyapp.user.v1.BatchGetUserAdminProfilesRequest
87, // 180: hyapp.user.v1.UserService.AdminIssueUserAccessToken:input_type -> hyapp.user.v1.AdminIssueUserAccessTokenRequest
90, // 181: hyapp.user.v1.UserService.BatchGetRoomBasicUsers:input_type -> hyapp.user.v1.BatchGetRoomBasicUsersRequest
92, // 182: hyapp.user.v1.UserService.ListUserIDs:input_type -> hyapp.user.v1.ListUserIDsRequest
12, // 183: hyapp.user.v1.UserService.GetUserMicLifetimeStats:input_type -> hyapp.user.v1.GetUserMicLifetimeStatsRequest
94, // 184: hyapp.user.v1.UserService.UpdateUserProfile:input_type -> hyapp.user.v1.UpdateUserProfileRequest
96, // 185: hyapp.user.v1.UserService.UpdateUserProfileBackground:input_type -> hyapp.user.v1.UpdateUserProfileBackgroundRequest
98, // 186: hyapp.user.v1.UserService.UpdateUserContactInfo:input_type -> hyapp.user.v1.UpdateUserContactInfoRequest
100, // 187: hyapp.user.v1.UserService.UpdateUserWithdrawAddress:input_type -> hyapp.user.v1.UpdateUserWithdrawAddressRequest
102, // 188: hyapp.user.v1.UserService.ChangeUserCountry:input_type -> hyapp.user.v1.ChangeUserCountryRequest
103, // 189: hyapp.user.v1.UserService.AdminChangeUserCountry:input_type -> hyapp.user.v1.AdminChangeUserCountryRequest
105, // 190: hyapp.user.v1.UserService.SetUserStatus:input_type -> hyapp.user.v1.SetUserStatusRequest
108, // 191: hyapp.user.v1.UserService.AdminBanUser:input_type -> hyapp.user.v1.AdminBanUserRequest
110, // 192: hyapp.user.v1.UserService.AdminUnbanUser:input_type -> hyapp.user.v1.AdminUnbanUserRequest
113, // 193: hyapp.user.v1.UserService.CreateManagerUserBlock:input_type -> hyapp.user.v1.CreateManagerUserBlockRequest
115, // 194: hyapp.user.v1.UserService.ListManagerUserBlocks:input_type -> hyapp.user.v1.ListManagerUserBlocksRequest
117, // 195: hyapp.user.v1.UserService.UnblockManagerUser:input_type -> hyapp.user.v1.UnblockManagerUserRequest
119, // 196: hyapp.user.v1.UserService.CompleteOnboarding:input_type -> hyapp.user.v1.CompleteOnboardingRequest
121, // 197: hyapp.user.v1.UserService.SearchInviteReferrer:input_type -> hyapp.user.v1.SearchInviteReferrerRequest
123, // 198: hyapp.user.v1.UserService.BindInviteReferrer:input_type -> hyapp.user.v1.BindInviteReferrerRequest
25, // 199: hyapp.user.v1.UserSocialService.RecordProfileVisit:input_type -> hyapp.user.v1.RecordProfileVisitRequest
28, // 200: hyapp.user.v1.UserSocialService.ListProfileVisitors:input_type -> hyapp.user.v1.ListProfileVisitorsRequest
30, // 201: hyapp.user.v1.UserSocialService.FollowUser:input_type -> hyapp.user.v1.FollowUserRequest
32, // 202: hyapp.user.v1.UserSocialService.UnfollowUser:input_type -> hyapp.user.v1.UnfollowUserRequest
35, // 203: hyapp.user.v1.UserSocialService.ListFollowing:input_type -> hyapp.user.v1.ListFollowingRequest
37, // 204: hyapp.user.v1.UserSocialService.ApplyFriend:input_type -> hyapp.user.v1.ApplyFriendRequest
39, // 205: hyapp.user.v1.UserSocialService.AcceptFriendApplication:input_type -> hyapp.user.v1.AcceptFriendApplicationRequest
41, // 206: hyapp.user.v1.UserSocialService.DeleteFriend:input_type -> hyapp.user.v1.DeleteFriendRequest
44, // 207: hyapp.user.v1.UserSocialService.ListFriends:input_type -> hyapp.user.v1.ListFriendsRequest
47, // 208: hyapp.user.v1.UserSocialService.ListFriendApplications:input_type -> hyapp.user.v1.ListFriendApplicationsRequest
79, // 209: hyapp.user.v1.UserSocialService.SubmitReport:input_type -> hyapp.user.v1.SubmitReportRequest
59, // 210: hyapp.user.v1.UserCPService.ListCPApplications:input_type -> hyapp.user.v1.ListCPApplicationsRequest
61, // 211: hyapp.user.v1.UserCPService.AcceptCPApplication:input_type -> hyapp.user.v1.AcceptCPApplicationRequest
63, // 212: hyapp.user.v1.UserCPService.RejectCPApplication:input_type -> hyapp.user.v1.RejectCPApplicationRequest
65, // 213: hyapp.user.v1.UserCPService.ListCPRelationships:input_type -> hyapp.user.v1.ListCPRelationshipsRequest
67, // 214: hyapp.user.v1.UserCPService.ListCPIntimacyLeaderboard:input_type -> hyapp.user.v1.ListCPIntimacyLeaderboardRequest
69, // 215: hyapp.user.v1.UserCPService.PrepareBreakCPRelationship:input_type -> hyapp.user.v1.PrepareBreakCPRelationshipRequest
71, // 216: hyapp.user.v1.UserCPService.ConfirmBreakCPRelationship:input_type -> hyapp.user.v1.ConfirmBreakCPRelationshipRequest
73, // 217: hyapp.user.v1.UserCPService.CancelBreakCPRelationship:input_type -> hyapp.user.v1.CancelBreakCPRelationshipRequest
179, // 218: hyapp.user.v1.UserCPService.ListCPFormationGiftFeed:input_type -> hyapp.user.v1.ListCPFormationGiftFeedRequest
76, // 219: hyapp.user.v1.UserCPInternalService.ConsumeRoomGiftCPEvent:input_type -> hyapp.user.v1.ConsumeRoomGiftCPEventRequest
57, // 220: hyapp.user.v1.UserCPInternalService.ListCPWeeklyRankEntries:input_type -> hyapp.user.v1.ListCPWeeklyRankEntriesRequest
14, // 221: hyapp.user.v1.UserCronService.ProcessLoginIPRiskBatch:input_type -> hyapp.user.v1.CronBatchRequest
14, // 222: hyapp.user.v1.UserCronService.ProcessRegionRebuildBatch:input_type -> hyapp.user.v1.CronBatchRequest
14, // 223: hyapp.user.v1.UserCronService.CompensateMicOpenSessions:input_type -> hyapp.user.v1.CronBatchRequest
14, // 224: hyapp.user.v1.UserCronService.CompensateRoomOpenSessions:input_type -> hyapp.user.v1.CronBatchRequest
14, // 225: hyapp.user.v1.UserCronService.ExpireManagerUserBlocks:input_type -> hyapp.user.v1.CronBatchRequest
14, // 226: hyapp.user.v1.UserCronService.ExpireAdminUserBans:input_type -> hyapp.user.v1.CronBatchRequest
14, // 227: hyapp.user.v1.UserCronService.RefreshCPIntimacyLeaderboard:input_type -> hyapp.user.v1.CronBatchRequest
125, // 228: hyapp.user.v1.UserDeviceService.BindPushToken:input_type -> hyapp.user.v1.BindPushTokenRequest
127, // 229: hyapp.user.v1.UserDeviceService.DeletePushToken:input_type -> hyapp.user.v1.DeletePushTokenRequest
3, // 230: hyapp.user.v1.AppRegistryService.ResolveApp:input_type -> hyapp.user.v1.ResolveAppRequest
181, // 231: hyapp.user.v1.AppRegistryService.ListApps:input_type -> hyapp.user.v1.ListAppsRequest
131, // 232: hyapp.user.v1.CountryAdminService.ListCountries:input_type -> hyapp.user.v1.ListCountriesRequest
133, // 233: hyapp.user.v1.CountryAdminService.UpdateCountry:input_type -> hyapp.user.v1.UpdateCountryRequest
135, // 234: hyapp.user.v1.CountryQueryService.ListRegistrationCountries:input_type -> hyapp.user.v1.ListRegistrationCountriesRequest
138, // 235: hyapp.user.v1.CountryQueryService.ListLoginRiskBlockedCountries:input_type -> hyapp.user.v1.ListLoginRiskBlockedCountriesRequest
140, // 236: hyapp.user.v1.RegionAdminService.ListRegions:input_type -> hyapp.user.v1.ListRegionsRequest
142, // 237: hyapp.user.v1.RegionAdminService.GetRegion:input_type -> hyapp.user.v1.GetRegionRequest
143, // 238: hyapp.user.v1.RegionAdminService.UpdateRegion:input_type -> hyapp.user.v1.UpdateRegionRequest
144, // 239: hyapp.user.v1.RegionAdminService.ReplaceRegionCountries:input_type -> hyapp.user.v1.ReplaceRegionCountriesRequest
147, // 240: hyapp.user.v1.UserIdentityService.GetUserIdentity:input_type -> hyapp.user.v1.GetUserIdentityRequest
149, // 241: hyapp.user.v1.UserIdentityService.ResolveDisplayUserID:input_type -> hyapp.user.v1.ResolveDisplayUserIDRequest
183, // 242: hyapp.user.v1.UserIdentityService.ResolveAdminUserIdentifier:input_type -> hyapp.user.v1.ResolveAdminUserIdentifierRequest
151, // 243: hyapp.user.v1.UserIdentityService.ChangeDisplayUserID:input_type -> hyapp.user.v1.ChangeDisplayUserIDRequest
153, // 244: hyapp.user.v1.UserIdentityService.ApplyPrettyDisplayUserID:input_type -> hyapp.user.v1.ApplyPrettyDisplayUserIDRequest
160, // 245: hyapp.user.v1.UserIdentityService.ListAvailablePrettyDisplayIDs:input_type -> hyapp.user.v1.ListAvailablePrettyDisplayIDsRequest
162, // 246: hyapp.user.v1.UserIdentityService.ApplyPrettyDisplayIDFromPool:input_type -> hyapp.user.v1.ApplyPrettyDisplayIDFromPoolRequest
155, // 247: hyapp.user.v1.UserIdentityService.ExpirePrettyDisplayUserID:input_type -> hyapp.user.v1.ExpirePrettyDisplayUserIDRequest
164, // 248: hyapp.user.v1.UserPrettyDisplayIDAdminService.ListPrettyDisplayIDPools:input_type -> hyapp.user.v1.ListPrettyDisplayIDPoolsRequest
166, // 249: hyapp.user.v1.UserPrettyDisplayIDAdminService.CreatePrettyDisplayIDPool:input_type -> hyapp.user.v1.CreatePrettyDisplayIDPoolRequest
167, // 250: hyapp.user.v1.UserPrettyDisplayIDAdminService.UpdatePrettyDisplayIDPool:input_type -> hyapp.user.v1.UpdatePrettyDisplayIDPoolRequest
169, // 251: hyapp.user.v1.UserPrettyDisplayIDAdminService.GeneratePrettyDisplayIDs:input_type -> hyapp.user.v1.GeneratePrettyDisplayIDsRequest
171, // 252: hyapp.user.v1.UserPrettyDisplayIDAdminService.ListPrettyDisplayIDs:input_type -> hyapp.user.v1.ListPrettyDisplayIDsRequest
173, // 253: hyapp.user.v1.UserPrettyDisplayIDAdminService.SetPrettyDisplayIDStatus:input_type -> hyapp.user.v1.SetPrettyDisplayIDStatusRequest
174, // 254: hyapp.user.v1.UserPrettyDisplayIDAdminService.RecyclePrettyDisplayID:input_type -> hyapp.user.v1.RecyclePrettyDisplayIDRequest
176, // 255: hyapp.user.v1.UserPrettyDisplayIDAdminService.AdminGrantPrettyDisplayID:input_type -> hyapp.user.v1.AdminGrantPrettyDisplayIDRequest
18, // 256: hyapp.user.v1.UserService.GetUser:output_type -> hyapp.user.v1.GetUserResponse
10, // 257: hyapp.user.v1.UserService.GetInviteAttribution:output_type -> hyapp.user.v1.GetInviteAttributionResponse
21, // 258: hyapp.user.v1.UserService.BusinessUserLookup:output_type -> hyapp.user.v1.BusinessUserLookupResponse
24, // 259: hyapp.user.v1.UserService.GetMyProfileStats:output_type -> hyapp.user.v1.GetMyProfileStatsResponse
82, // 260: hyapp.user.v1.UserService.BatchGetUsers:output_type -> hyapp.user.v1.BatchGetUsersResponse
86, // 261: hyapp.user.v1.UserService.BatchGetUserAdminProfiles:output_type -> hyapp.user.v1.BatchGetUserAdminProfilesResponse
88, // 262: hyapp.user.v1.UserService.AdminIssueUserAccessToken:output_type -> hyapp.user.v1.AdminIssueUserAccessTokenResponse
91, // 263: hyapp.user.v1.UserService.BatchGetRoomBasicUsers:output_type -> hyapp.user.v1.BatchGetRoomBasicUsersResponse
93, // 264: hyapp.user.v1.UserService.ListUserIDs:output_type -> hyapp.user.v1.ListUserIDsResponse
13, // 265: hyapp.user.v1.UserService.GetUserMicLifetimeStats:output_type -> hyapp.user.v1.GetUserMicLifetimeStatsResponse
95, // 266: hyapp.user.v1.UserService.UpdateUserProfile:output_type -> hyapp.user.v1.UpdateUserProfileResponse
97, // 267: hyapp.user.v1.UserService.UpdateUserProfileBackground:output_type -> hyapp.user.v1.UpdateUserProfileBackgroundResponse
99, // 268: hyapp.user.v1.UserService.UpdateUserContactInfo:output_type -> hyapp.user.v1.UpdateUserContactInfoResponse
101, // 269: hyapp.user.v1.UserService.UpdateUserWithdrawAddress:output_type -> hyapp.user.v1.UpdateUserWithdrawAddressResponse
104, // 270: hyapp.user.v1.UserService.ChangeUserCountry:output_type -> hyapp.user.v1.ChangeUserCountryResponse
104, // 271: hyapp.user.v1.UserService.AdminChangeUserCountry:output_type -> hyapp.user.v1.ChangeUserCountryResponse
106, // 272: hyapp.user.v1.UserService.SetUserStatus:output_type -> hyapp.user.v1.SetUserStatusResponse
109, // 273: hyapp.user.v1.UserService.AdminBanUser:output_type -> hyapp.user.v1.AdminBanUserResponse
111, // 274: hyapp.user.v1.UserService.AdminUnbanUser:output_type -> hyapp.user.v1.AdminUnbanUserResponse
114, // 275: hyapp.user.v1.UserService.CreateManagerUserBlock:output_type -> hyapp.user.v1.CreateManagerUserBlockResponse
116, // 276: hyapp.user.v1.UserService.ListManagerUserBlocks:output_type -> hyapp.user.v1.ListManagerUserBlocksResponse
118, // 277: hyapp.user.v1.UserService.UnblockManagerUser:output_type -> hyapp.user.v1.UnblockManagerUserResponse
120, // 278: hyapp.user.v1.UserService.CompleteOnboarding:output_type -> hyapp.user.v1.CompleteOnboardingResponse
122, // 279: hyapp.user.v1.UserService.SearchInviteReferrer:output_type -> hyapp.user.v1.SearchInviteReferrerResponse
124, // 280: hyapp.user.v1.UserService.BindInviteReferrer:output_type -> hyapp.user.v1.BindInviteReferrerResponse
26, // 281: hyapp.user.v1.UserSocialService.RecordProfileVisit:output_type -> hyapp.user.v1.RecordProfileVisitResponse
29, // 282: hyapp.user.v1.UserSocialService.ListProfileVisitors:output_type -> hyapp.user.v1.ListProfileVisitorsResponse
31, // 283: hyapp.user.v1.UserSocialService.FollowUser:output_type -> hyapp.user.v1.FollowUserResponse
33, // 284: hyapp.user.v1.UserSocialService.UnfollowUser:output_type -> hyapp.user.v1.UnfollowUserResponse
36, // 285: hyapp.user.v1.UserSocialService.ListFollowing:output_type -> hyapp.user.v1.ListFollowingResponse
38, // 286: hyapp.user.v1.UserSocialService.ApplyFriend:output_type -> hyapp.user.v1.ApplyFriendResponse
40, // 287: hyapp.user.v1.UserSocialService.AcceptFriendApplication:output_type -> hyapp.user.v1.AcceptFriendApplicationResponse
42, // 288: hyapp.user.v1.UserSocialService.DeleteFriend:output_type -> hyapp.user.v1.DeleteFriendResponse
45, // 289: hyapp.user.v1.UserSocialService.ListFriends:output_type -> hyapp.user.v1.ListFriendsResponse
48, // 290: hyapp.user.v1.UserSocialService.ListFriendApplications:output_type -> hyapp.user.v1.ListFriendApplicationsResponse
80, // 291: hyapp.user.v1.UserSocialService.SubmitReport:output_type -> hyapp.user.v1.SubmitReportResponse
60, // 292: hyapp.user.v1.UserCPService.ListCPApplications:output_type -> hyapp.user.v1.ListCPApplicationsResponse
62, // 293: hyapp.user.v1.UserCPService.AcceptCPApplication:output_type -> hyapp.user.v1.AcceptCPApplicationResponse
64, // 294: hyapp.user.v1.UserCPService.RejectCPApplication:output_type -> hyapp.user.v1.RejectCPApplicationResponse
66, // 295: hyapp.user.v1.UserCPService.ListCPRelationships:output_type -> hyapp.user.v1.ListCPRelationshipsResponse
68, // 296: hyapp.user.v1.UserCPService.ListCPIntimacyLeaderboard:output_type -> hyapp.user.v1.ListCPIntimacyLeaderboardResponse
70, // 297: hyapp.user.v1.UserCPService.PrepareBreakCPRelationship:output_type -> hyapp.user.v1.PrepareBreakCPRelationshipResponse
72, // 298: hyapp.user.v1.UserCPService.ConfirmBreakCPRelationship:output_type -> hyapp.user.v1.ConfirmBreakCPRelationshipResponse
74, // 299: hyapp.user.v1.UserCPService.CancelBreakCPRelationship:output_type -> hyapp.user.v1.CancelBreakCPRelationshipResponse
180, // 300: hyapp.user.v1.UserCPService.ListCPFormationGiftFeed:output_type -> hyapp.user.v1.ListCPFormationGiftFeedResponse
77, // 301: hyapp.user.v1.UserCPInternalService.ConsumeRoomGiftCPEvent:output_type -> hyapp.user.v1.ConsumeRoomGiftCPEventResponse
58, // 302: hyapp.user.v1.UserCPInternalService.ListCPWeeklyRankEntries:output_type -> hyapp.user.v1.ListCPWeeklyRankEntriesResponse
15, // 303: hyapp.user.v1.UserCronService.ProcessLoginIPRiskBatch:output_type -> hyapp.user.v1.CronBatchResponse
15, // 304: hyapp.user.v1.UserCronService.ProcessRegionRebuildBatch:output_type -> hyapp.user.v1.CronBatchResponse
15, // 305: hyapp.user.v1.UserCronService.CompensateMicOpenSessions:output_type -> hyapp.user.v1.CronBatchResponse
15, // 306: hyapp.user.v1.UserCronService.CompensateRoomOpenSessions:output_type -> hyapp.user.v1.CronBatchResponse
15, // 307: hyapp.user.v1.UserCronService.ExpireManagerUserBlocks:output_type -> hyapp.user.v1.CronBatchResponse
15, // 308: hyapp.user.v1.UserCronService.ExpireAdminUserBans:output_type -> hyapp.user.v1.CronBatchResponse
15, // 309: hyapp.user.v1.UserCronService.RefreshCPIntimacyLeaderboard:output_type -> hyapp.user.v1.CronBatchResponse
126, // 310: hyapp.user.v1.UserDeviceService.BindPushToken:output_type -> hyapp.user.v1.BindPushTokenResponse
128, // 311: hyapp.user.v1.UserDeviceService.DeletePushToken:output_type -> hyapp.user.v1.DeletePushTokenResponse
4, // 312: hyapp.user.v1.AppRegistryService.ResolveApp:output_type -> hyapp.user.v1.ResolveAppResponse
182, // 313: hyapp.user.v1.AppRegistryService.ListApps:output_type -> hyapp.user.v1.ListAppsResponse
132, // 314: hyapp.user.v1.CountryAdminService.ListCountries:output_type -> hyapp.user.v1.ListCountriesResponse
134, // 315: hyapp.user.v1.CountryAdminService.UpdateCountry:output_type -> hyapp.user.v1.CountryResponse
136, // 316: hyapp.user.v1.CountryQueryService.ListRegistrationCountries:output_type -> hyapp.user.v1.ListRegistrationCountriesResponse
139, // 317: hyapp.user.v1.CountryQueryService.ListLoginRiskBlockedCountries:output_type -> hyapp.user.v1.ListLoginRiskBlockedCountriesResponse
141, // 318: hyapp.user.v1.RegionAdminService.ListRegions:output_type -> hyapp.user.v1.ListRegionsResponse
145, // 319: hyapp.user.v1.RegionAdminService.GetRegion:output_type -> hyapp.user.v1.RegionResponse
145, // 320: hyapp.user.v1.RegionAdminService.UpdateRegion:output_type -> hyapp.user.v1.RegionResponse
145, // 321: hyapp.user.v1.RegionAdminService.ReplaceRegionCountries:output_type -> hyapp.user.v1.RegionResponse
148, // 322: hyapp.user.v1.UserIdentityService.GetUserIdentity:output_type -> hyapp.user.v1.GetUserIdentityResponse
150, // 323: hyapp.user.v1.UserIdentityService.ResolveDisplayUserID:output_type -> hyapp.user.v1.ResolveDisplayUserIDResponse
184, // 324: hyapp.user.v1.UserIdentityService.ResolveAdminUserIdentifier:output_type -> hyapp.user.v1.ResolveAdminUserIdentifierResponse
152, // 325: hyapp.user.v1.UserIdentityService.ChangeDisplayUserID:output_type -> hyapp.user.v1.ChangeDisplayUserIDResponse
154, // 326: hyapp.user.v1.UserIdentityService.ApplyPrettyDisplayUserID:output_type -> hyapp.user.v1.ApplyPrettyDisplayUserIDResponse
161, // 327: hyapp.user.v1.UserIdentityService.ListAvailablePrettyDisplayIDs:output_type -> hyapp.user.v1.ListAvailablePrettyDisplayIDsResponse
163, // 328: hyapp.user.v1.UserIdentityService.ApplyPrettyDisplayIDFromPool:output_type -> hyapp.user.v1.ApplyPrettyDisplayIDFromPoolResponse
156, // 329: hyapp.user.v1.UserIdentityService.ExpirePrettyDisplayUserID:output_type -> hyapp.user.v1.ExpirePrettyDisplayUserIDResponse
165, // 330: hyapp.user.v1.UserPrettyDisplayIDAdminService.ListPrettyDisplayIDPools:output_type -> hyapp.user.v1.ListPrettyDisplayIDPoolsResponse
168, // 331: hyapp.user.v1.UserPrettyDisplayIDAdminService.CreatePrettyDisplayIDPool:output_type -> hyapp.user.v1.PrettyDisplayIDPoolResponse
168, // 332: hyapp.user.v1.UserPrettyDisplayIDAdminService.UpdatePrettyDisplayIDPool:output_type -> hyapp.user.v1.PrettyDisplayIDPoolResponse
170, // 333: hyapp.user.v1.UserPrettyDisplayIDAdminService.GeneratePrettyDisplayIDs:output_type -> hyapp.user.v1.GeneratePrettyDisplayIDsResponse
172, // 334: hyapp.user.v1.UserPrettyDisplayIDAdminService.ListPrettyDisplayIDs:output_type -> hyapp.user.v1.ListPrettyDisplayIDsResponse
175, // 335: hyapp.user.v1.UserPrettyDisplayIDAdminService.SetPrettyDisplayIDStatus:output_type -> hyapp.user.v1.PrettyDisplayIDResponse
175, // 336: hyapp.user.v1.UserPrettyDisplayIDAdminService.RecyclePrettyDisplayID:output_type -> hyapp.user.v1.PrettyDisplayIDResponse
177, // 337: hyapp.user.v1.UserPrettyDisplayIDAdminService.AdminGrantPrettyDisplayID:output_type -> hyapp.user.v1.AdminGrantPrettyDisplayIDResponse
256, // [256:338] is the sub-list for method output_type
174, // [174:256] is the sub-list for method input_type
174, // [174:174] is the sub-list for extension type_name
174, // [174:174] is the sub-list for extension extendee
0, // [0:174] is the sub-list for field type_name
}
func init() { file_proto_user_v1_user_proto_init() }
@ -15137,7 +15248,7 @@ func file_proto_user_v1_user_proto_init() {
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
RawDescriptor: unsafe.Slice(unsafe.StringData(file_proto_user_v1_user_proto_rawDesc), len(file_proto_user_v1_user_proto_rawDesc)),
NumEnums: 1,
NumMessages: 185,
NumMessages: 187,
NumExtensions: 0,
NumServices: 12,
},

View File

@ -1568,6 +1568,18 @@ message ListAppsResponse {
repeated App apps = 1;
}
// ResolveAdminUserIdentifierRequest
// user_identifier user_id
message ResolveAdminUserIdentifierRequest {
RequestMeta meta = 1;
string user_identifier = 2;
}
// ResolveAdminUserIdentifierResponse
message ResolveAdminUserIdentifierResponse {
UserIdentity identity = 1;
}
// AppRegistryService gateway package_name -> app_code
service AppRegistryService {
rpc ResolveApp(ResolveAppRequest) returns (ResolveAppResponse);
@ -1600,6 +1612,8 @@ service RegionAdminService {
service UserIdentityService {
rpc GetUserIdentity(GetUserIdentityRequest) returns (GetUserIdentityResponse);
rpc ResolveDisplayUserID(ResolveDisplayUserIDRequest) returns (ResolveDisplayUserIDResponse);
// ResolveAdminUserIdentifier active
rpc ResolveAdminUserIdentifier(ResolveAdminUserIdentifierRequest) returns (ResolveAdminUserIdentifierResponse);
rpc ChangeDisplayUserID(ChangeDisplayUserIDRequest) returns (ChangeDisplayUserIDResponse);
rpc ApplyPrettyDisplayUserID(ApplyPrettyDisplayUserIDRequest) returns (ApplyPrettyDisplayUserIDResponse);
rpc ListAvailablePrettyDisplayIDs(ListAvailablePrettyDisplayIDsRequest) returns (ListAvailablePrettyDisplayIDsResponse);

View File

@ -1,7 +1,7 @@
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
// versions:
// - protoc-gen-go-grpc v1.6.2
// - protoc v5.29.2
// - protoc v7.35.0
// source: proto/user/v1/user.proto
package userv1
@ -3213,6 +3213,7 @@ var RegionAdminService_ServiceDesc = grpc.ServiceDesc{
const (
UserIdentityService_GetUserIdentity_FullMethodName = "/hyapp.user.v1.UserIdentityService/GetUserIdentity"
UserIdentityService_ResolveDisplayUserID_FullMethodName = "/hyapp.user.v1.UserIdentityService/ResolveDisplayUserID"
UserIdentityService_ResolveAdminUserIdentifier_FullMethodName = "/hyapp.user.v1.UserIdentityService/ResolveAdminUserIdentifier"
UserIdentityService_ChangeDisplayUserID_FullMethodName = "/hyapp.user.v1.UserIdentityService/ChangeDisplayUserID"
UserIdentityService_ApplyPrettyDisplayUserID_FullMethodName = "/hyapp.user.v1.UserIdentityService/ApplyPrettyDisplayUserID"
UserIdentityService_ListAvailablePrettyDisplayIDs_FullMethodName = "/hyapp.user.v1.UserIdentityService/ListAvailablePrettyDisplayIDs"
@ -3228,6 +3229,8 @@ const (
type UserIdentityServiceClient interface {
GetUserIdentity(ctx context.Context, in *GetUserIdentityRequest, opts ...grpc.CallOption) (*GetUserIdentityResponse, error)
ResolveDisplayUserID(ctx context.Context, in *ResolveDisplayUserIDRequest, opts ...grpc.CallOption) (*ResolveDisplayUserIDResponse, error)
// ResolveAdminUserIdentifier 仅供内部后台精确搜索;不得替代客户端 active 展示号解析语义。
ResolveAdminUserIdentifier(ctx context.Context, in *ResolveAdminUserIdentifierRequest, opts ...grpc.CallOption) (*ResolveAdminUserIdentifierResponse, error)
ChangeDisplayUserID(ctx context.Context, in *ChangeDisplayUserIDRequest, opts ...grpc.CallOption) (*ChangeDisplayUserIDResponse, error)
ApplyPrettyDisplayUserID(ctx context.Context, in *ApplyPrettyDisplayUserIDRequest, opts ...grpc.CallOption) (*ApplyPrettyDisplayUserIDResponse, error)
ListAvailablePrettyDisplayIDs(ctx context.Context, in *ListAvailablePrettyDisplayIDsRequest, opts ...grpc.CallOption) (*ListAvailablePrettyDisplayIDsResponse, error)
@ -3263,6 +3266,16 @@ func (c *userIdentityServiceClient) ResolveDisplayUserID(ctx context.Context, in
return out, nil
}
func (c *userIdentityServiceClient) ResolveAdminUserIdentifier(ctx context.Context, in *ResolveAdminUserIdentifierRequest, opts ...grpc.CallOption) (*ResolveAdminUserIdentifierResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(ResolveAdminUserIdentifierResponse)
err := c.cc.Invoke(ctx, UserIdentityService_ResolveAdminUserIdentifier_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *userIdentityServiceClient) ChangeDisplayUserID(ctx context.Context, in *ChangeDisplayUserIDRequest, opts ...grpc.CallOption) (*ChangeDisplayUserIDResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(ChangeDisplayUserIDResponse)
@ -3321,6 +3334,8 @@ func (c *userIdentityServiceClient) ExpirePrettyDisplayUserID(ctx context.Contex
type UserIdentityServiceServer interface {
GetUserIdentity(context.Context, *GetUserIdentityRequest) (*GetUserIdentityResponse, error)
ResolveDisplayUserID(context.Context, *ResolveDisplayUserIDRequest) (*ResolveDisplayUserIDResponse, error)
// ResolveAdminUserIdentifier 仅供内部后台精确搜索;不得替代客户端 active 展示号解析语义。
ResolveAdminUserIdentifier(context.Context, *ResolveAdminUserIdentifierRequest) (*ResolveAdminUserIdentifierResponse, error)
ChangeDisplayUserID(context.Context, *ChangeDisplayUserIDRequest) (*ChangeDisplayUserIDResponse, error)
ApplyPrettyDisplayUserID(context.Context, *ApplyPrettyDisplayUserIDRequest) (*ApplyPrettyDisplayUserIDResponse, error)
ListAvailablePrettyDisplayIDs(context.Context, *ListAvailablePrettyDisplayIDsRequest) (*ListAvailablePrettyDisplayIDsResponse, error)
@ -3342,6 +3357,9 @@ func (UnimplementedUserIdentityServiceServer) GetUserIdentity(context.Context, *
func (UnimplementedUserIdentityServiceServer) ResolveDisplayUserID(context.Context, *ResolveDisplayUserIDRequest) (*ResolveDisplayUserIDResponse, error) {
return nil, status.Error(codes.Unimplemented, "method ResolveDisplayUserID not implemented")
}
func (UnimplementedUserIdentityServiceServer) ResolveAdminUserIdentifier(context.Context, *ResolveAdminUserIdentifierRequest) (*ResolveAdminUserIdentifierResponse, error) {
return nil, status.Error(codes.Unimplemented, "method ResolveAdminUserIdentifier not implemented")
}
func (UnimplementedUserIdentityServiceServer) ChangeDisplayUserID(context.Context, *ChangeDisplayUserIDRequest) (*ChangeDisplayUserIDResponse, error) {
return nil, status.Error(codes.Unimplemented, "method ChangeDisplayUserID not implemented")
}
@ -3414,6 +3432,24 @@ func _UserIdentityService_ResolveDisplayUserID_Handler(srv interface{}, ctx cont
return interceptor(ctx, in, info, handler)
}
func _UserIdentityService_ResolveAdminUserIdentifier_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(ResolveAdminUserIdentifierRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(UserIdentityServiceServer).ResolveAdminUserIdentifier(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: UserIdentityService_ResolveAdminUserIdentifier_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(UserIdentityServiceServer).ResolveAdminUserIdentifier(ctx, req.(*ResolveAdminUserIdentifierRequest))
}
return interceptor(ctx, in, info, handler)
}
func _UserIdentityService_ChangeDisplayUserID_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(ChangeDisplayUserIDRequest)
if err := dec(in); err != nil {
@ -3519,6 +3555,10 @@ var UserIdentityService_ServiceDesc = grpc.ServiceDesc{
MethodName: "ResolveDisplayUserID",
Handler: _UserIdentityService_ResolveDisplayUserID_Handler,
},
{
MethodName: "ResolveAdminUserIdentifier",
Handler: _UserIdentityService_ResolveAdminUserIdentifier_Handler,
},
{
MethodName: "ChangeDisplayUserID",
Handler: _UserIdentityService_ChangeDisplayUserID_Handler,

View File

@ -1,7 +1,7 @@
// Code generated by protoc-gen-go. DO NOT EDIT.
// versions:
// protoc-gen-go v1.36.11
// protoc v5.29.2
// protoc v7.35.0
// source: proto/wallet/v1/wallet.proto
package walletv1

View File

@ -1,7 +1,7 @@
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
// versions:
// - protoc-gen-go-grpc v1.6.2
// - protoc v5.29.2
// - protoc v7.35.0
// source: proto/wallet/v1/wallet.proto
package walletv1

View File

@ -6,9 +6,9 @@
- `strategy_version=fixed_v2`:继续按历史不可变规则执行;旧请求或旧记录缺少 `strategy_version` 时必须归一为 `fixed_v2`
- `strategy_version=dynamic_v3`:只有显式发布并启用的规则才进入动态水位、充值加权、保底、大奖和六维风控。不能因为服务升级而把存量奖池隐式切换到 V3。
- 新奖池未配置时返回 `dynamic_v3``disabled` 草稿。各 App 必须先补齐累计消费大奖门槛、普通/高阶充值门槛及六项金额上限,才允许启用。
- 新奖池未配置时返回 `dynamic_v3``disabled` 草稿。各 App 必须先补齐普通/高阶充值门槛及六项金额上限,才允许启用;独立的“累计消费达到门槛就获得大奖”机制已经停用,不再是发布必填项,也不能绕过 RTP 资格
- 启用前还必须确认 user/gateway/room 已升级到由 `auth_sessions.device_id` 签入 access JWT再通过 `room.RequestMeta` 和可恢复 command 原样传递设备作用域。旧 JWT 缺 `device_id``fixed_v2` 仍兼容,`dynamic_v3` 必须 fail-close不得用 `session_id`/`command_id` 伪造设备。
- 启用前还必须确认 user/gateway/room/wallet 已按顺序升级access JWT 和 room meta 携带 auth session 绑定的可信 `device_id`wallet 回执携带交易 `paid_at_ms`、充值快照和 `gift_income_coins`任一动态字段缺失或真实收礼返币比例与规则 `anchor_rate_ppm` 不一致时运行侧 fail-close。
- 启用前还必须确认 user/gateway/room/wallet 已按顺序升级access JWT 和 room meta 携带 auth session 绑定的可信 `device_id`wallet 回执携带交易 `paid_at_ms`、充值快照和 `gift_income_coins`可信 `device_id`、owner `paid_at_ms` 缺失,或真实收礼返币比例与规则 `anchor_rate_ppm` 不一致时,整次动态开奖 fail-close`user_registered_at_ms` 缺失或晚于扣费时间时普通开奖继续,但本轮补偿大奖资格 fail-close。
- 比例、概率和倍率统一使用 ppm`1_000_000=100%=1x`;钱只使用整数金币;业务时间只使用 UTC epoch milliseconds。
- 离线模拟使用固定 seed 的 `math/rand`;生产抽奖必须注入不可预测的安全随机源,不能复用模拟 RNG。
@ -38,7 +38,8 @@ public_pool_coins + profit_coins + anchor_return_coins = coin_spent
- RTP 分子是已经开奖形成的不可撤销返奖负债,分母是对应已扣费流水;分母为 0 时是“无有效样本”,不能当成 0% 去触发补偿大奖。
- 95%101% 是资金充足且样本量足够时的运营观察目标,不是每抽、每用户、每窗口的硬保证。
- 当奖池、单次限额或任一风控窗口不足时,安全约束优先;允许窗口 `underpaid` 或短期偏离 95%101%,禁止为了追 RTP 透支奖池。
- `settlement_window_wager` 是当前不可变规则版本的真实金币流水阈值。单抽整体计入它开始时所在窗口;若本抽使累计流水达到或超过阈值,下一抽才开启新窗口。`gift_price_reference` 只用于体验阶段的等价抽数,不参与 V3 滚窗。
- `settlement_window_wager` 是当前不可变规则版本的真实金币流水阈值。单抽整体计入它开始时所在窗口;若本抽使累计流水达到或超过阈值,该抽完成后立即关闭并结算当前大盘轮次,下一抽进入新轮次。`gift_price_reference` 只用于体验阶段的等价抽数,不参与 V3 滚窗。
- 大盘轮次按 `app_code + pool_id + rule_version` 独立结算。大盘在 9:00 重置、10:00 再次重置时10:00 形成的资格只读取刚关闭的 9:0010:00 大盘 RTP以及同一个 9:0010:00 轮次内该用户实际发生的总流水和总返奖;不能拿用户 UTC 日 RTP 或正在进行的新轮次代替。
### 2.3 原图概率表不能作为生产默认概率
@ -55,7 +56,7 @@ public_pool_coins + profit_coins + anchor_return_coins = coin_spent
因此该表的名义 `EV=26.5x`,即 `RTP=2650%`,不可能同时是 98% RTP。它只用于验证原图的 `P/W` 删档、删 0 和重抽分支,不能写入生产默认基础奖档。
`dynamic_v3` 的安全发布草稿采用独立的 98% 静态 EV`0x@5% + 0.5x@4% + 1x@86% + 2x@5% = 0.98x``200x/500x/1000x` 作为大奖集合单独配置,不能在缺少经过成本校验的概率时照搬进基础概率表
`dynamic_v3` 的安全发布草稿采用 98% 静态 EV`0x@5% + 0.5x@4% + 1x@86% + 2x@5% = 0.98x`普通概率表可以配置 `200x/500x/1000x`,也可以与大奖集合使用相同倍率,但这些普通高倍档必须与其他普通档一起纳入静态 EV 和控制带校验,不能因为倍率也出现在大奖集合就从成本计算中扣除。运行时为同倍率建立独立的 `multiplier_*` 普通 tier 和 `jackpot_*` 补偿 tier前者按阶段概率随机后者普通权重固定为 0只能通过大奖资格路径命中
## 3. 一次抽奖的确定顺序
@ -65,10 +66,10 @@ public_pool_coins + profit_coins + anchor_return_coins = coin_spent
2. `wallet-service` 完成真实扣费,并在幂等回执中固化 `coin_spent`、交易 `paid_at_ms`、最近 7/30 个 UTC 自然日充值额、最后充值时间及主播收益金额。
3. `lucky-gift-service` 只消费上述可信事实不直连钱包库不按当前余额、IP、服务时区、恢复时间或客户端自报值推断充值画像和时间归属。
4. 本抽金币按 98/1/1 拆分;公共池份额先计入 `P`,再比较本抽奖金 `W`
5. 按 `app_code + pool_id + rule_version` 读取不可变规则,锁定公共池、用户日状态、72 小时桶/边界事件、连 0 状态和六维风控计数。
5. 按 `app_code + pool_id + rule_version` 读取不可变规则,锁定公共池、已结算大盘轮次、用户同轮次状态、48 小时桶/边界事件、连 0 状态和六维风控计数。
6. 用钱包快照选择充值层级,再对所有非 0 基础档应用水位和最近充值因子0 档接收剩余概率。
7. 优先处理已持久化的“消费里程碑大奖 token”其次判断 RTP 补偿大奖,最后进入普通概率抽奖。
8. 对选中的 `W` 依次执行奖池、单用户日大奖次数和六维风控检查;任何一项不满足都不能发奖
7. 若用户有一个尚未尝试的已结算轮次,先按“全局 `<=98%`、用户同轮次 `<96%`、注册满 48 小时、用户滚动 48 小时总 RTP `<96%`”完整判断资格;否则直接进入普通概率抽奖。
8. 有资格时只在用户下一次子抽尝试一次大奖,并对选中的 `W` 执行奖池、单用户日大奖次数和六维风控检查;成功时本抽直接结束,奖池不足或风控阻断时消费该轮资格并回落普通规则。普通路径即使命中同倍率也不是大奖,不增加或受制于日大奖次数
9. 写入抽奖事实、决策快照、奖池/窗口/风控/用户状态及 outbox。钱包返奖状态独立收敛为 `pending/granted/failed`
这套顺序的核心约束是:先有已落账的扣费事实,再入池,再开奖;任何体验规则都不能制造不存在的资金。
@ -126,33 +127,44 @@ adjusted_weight = base_weight × water_factor × recharge_factor
原图 `P=800`、单价 10 的脚本化示例只用于分支验收:先抽到 `200x(W=2000)` 时删除 `200x+0x`;若再抽到 `1000x(W=10000)`,再删除 `1000x`;随后只能在仍有权重且 `W<=800` 的正奖中选择。该示例证明重抽语义,不证明原图概率具有可用 RTP。
奖池不足、风险额度不足和个人大奖次数达到上限是不同的审计原因。只有 `W>P` 触发原图规定的“删中奖档并同时删 0”其他风控拒绝不能伪装成奖池不足。
奖池不足、风险额度不足和补偿大奖次数达到上限是不同的审计原因。只有普通候选的 `W>P` 触发原图规定的“删中奖档并同时删 0”其他风控拒绝不能伪装成奖池不足。普通奖档不读取补偿大奖次数,即使倍率相同也仍按普通 P/W 和六维风控执行。
## 6. 两种大奖机制
## 6. 大奖资格:已结算轮次的下一次尝试
大奖集合由 `jackpot_multiplier_ppms` 配置,默认产品集合为 `200x/500x/1000x`。原图没有给出三个大奖之间的概率,因此当前不可变规则明确采用“在可支付集合中等权随机”;这不属于普通基础概率。若产品需要非等权,必须先新增显式权重契约和成本验收,不能从普通奖档概率猜算。
大奖集合由 `jackpot_multiplier_ppms` 配置,默认产品集合为 `200x/500x/1000x`。原图没有给出三个大奖之间的概率,因此当前不可变规则明确采用“在可支付集合中等权随机”;这个等权只决定补偿路径内部如何选大奖,与普通基础概率相互独立。相同倍率可以同时出现在普通概率表和大奖集合,审计分别记录 `multiplier_*``jackpot_*` tier ID不能用倍率反推命中来源。若产品需要补偿大奖非等权必须先新增显式权重契约和成本验收不能借用普通奖档概率猜算。
### 6.1 机制一RTP 亏损补偿大奖
### 6.1 RTP、注册年龄和安全门必须同时通过
本抽开始时同时满足以下条件,才允许从当前可支付大奖集合随机选择:
大盘轮次结算时固化本轮快照;用户下一次送出幸运礼物时,只有以下条件同时通过,才允许从当前可支付大奖集合随机选择:
- 全局结算 RTP `<= jackpot_global_rtp_max_ppm`,默认 98%
- 用户当前 UTC 日 RTP `<= jackpot_user_day_rtp_max_ppm`,默认 96%
- 用户滚动 72 小时 RTP `<= jackpot_user_72h_rtp_max_ppm`,默认 96%
- 用户本 UTC 日大奖次数 `< max_jackpot_hits_per_user_day`,默认 5
- 刚关闭的大盘轮次 RTP `<= jackpot_global_rtp_max_ppm`,默认 98%;等于 98% 可以继续判断,超过 98% 直接失败,保证平台不因补偿大奖扩大亏损;
- 该用户在同一个刚关闭轮次内的 RTP **严格 `< jackpot_user_round_rtp_max_ppm`**,默认严格小于 96%;等于 96% 不通过;
- 该用户在轮次关闭时已经注册满 48 小时;判断的是账号注册年龄,不是累计活跃、累计游戏或累计有流水的时长;
- `closed_at_ms` 保存触发结算的末笔支付毫秒;汇总使用 `[closed_at_ms+1ms-48h, closed_at_ms+1ms)`,因此会纳入这笔结算事实。全部真实消费与全部真实返奖相除后,用户滚动 48 小时总 RTP **严格 `< jackpot_user_48h_rtp_max_ppm`**,默认严格小于 96%
- 用户本 UTC 日通过补偿路径命中的大奖总次数 `< max_jackpot_hits_per_user_day`,默认 5所有 `jackpot_*` 倍率合计计数,`multiplier_*` 普通命中不计数;
- 候选 `W<=P` 且不超过六维风控的最小剩余额度。
任一 RTP 分母为 0、任一 RTP 高于阈值、奖池不足或风控不足,都只回到普通抽奖,不强发大奖。滚动 72 小时是 `[now_ms-72h, now_ms)` 的 UTC 业务窗口,不能替换成“最近 3 个本地自然日”
用户两条 RTP 门是逻辑 **AND**:同轮次 95% 但滚动 48 小时 97% 不通过;同轮次 97% 但滚动 48 小时 95% 也不通过。两个 96% 边界都使用精确分子/分母交叉相乘判断,不能先向下取整成 ppm 后把实际略高于或等于 96% 误判为通过
### 6.2 机制二:消费里程碑大奖
滚动 48 小时必须先汇总总额再相除,不允许对小时 RTP、单次 RTP或若干分段 RTP 做算术平均。例如 48 小时消费 10,000 金币、返奖 9,500 金币RTP 为 95%。分母为 0 表示没有有效样本,不是 0% RTP不能获得大奖资格。
- `jackpot_spend_threshold_coins` 是当前不可变规则版本动态配置的“用户 UTC 日累计消费金币门槛”。它不绑定任何固定法币金额,服务也不读取实时汇率推断。
- 用户 UTC 日累计消费每跨过一个完整门槛,持久化一个大奖 token同一流水重试不能重复发 token。消费进度和当日命中次数在 UTC 日切换时归零,但已赚到且因资金不足未消费的 token 保存在用户/奖池状态中,可跨日等待下一抽。
- 当前抽跨过的门槛只能影响下一抽,不能回头改变已经完成的当前抽。
- 下一抽优先消费已有 token若大奖集合因 `P`、日 5 次上限或风控不可支付token 保留,用户仍获得本次普通抽奖。
- 同一抽同时满足机制一和机制二时,已承诺的里程碑 token 优先;一个 draw 最多结算一个最终奖档。
### 6.2 注册时间和“只玩最后 5 分钟”边界
原图写的是“定时任务”。实现改为在 owner 事务里按已结算送礼事实同步计算跨过的整数门槛并落 token结果仍只影响下一抽但不会出现“用户已经完成门槛、下一抽先于 cron 扫描到达”的竞态,也不需要 cron-service 回扫明细或直接拥有大奖状态。这个事件驱动实现是有意的工程等价替换;若产品需要固定延迟发放,必须另增明确生效时间字段,而不是依赖不确定的扫描周期。
- 注册 47 小时的用户,即使本轮 RTP=95%、已有流水的滚动 RTP=95%,也因账号未满 48 小时而不通过。
- 注册恰满 48 小时即满足成熟度边界;不需要“玩满 48 小时”。
- 用户已注册超过 48 小时,但前 47 小时 55 分钟都没玩、最后 5 分钟消费 100 金币并返奖 95 金币时,最近 48 小时的有效总流水就是 100/95滚动 RTP=95%。若这 5 分钟也正好构成刚关闭的用户轮次,则两条用户 RTP 门都通过。
- 注册时间必须来自 user owner 的 `created_at_ms` 可信事实。内部链路缺失或恢复记录无法还原时,大奖资格 fail-close不能用首次送礼时间、请求到达时间或外部自报的活跃时长兜底。
### 6.3 一轮资格只尝试一次
资格属于一个已关闭的 `app_code + pool_id + rule_version + window_index + user_id` 轮次,只能用于该用户的下一次子抽:
- 能支付且风控允许:随机命中一个可支付大奖,资格消费;
- 奖池不足、达到日次数上限或任一风控不足:不强发大奖,资格仍消费,本抽继续走普通概率和保底;
- 用户迟迟不送礼:该轮资格可以等到下一次个人送礼再尝试,但不会因后续关闭更多轮次而叠加多个机会;只保留当前规则版本下最新待尝试轮次,旧轮次失效;
- 规则版本改变:旧版本资格不跨版本继承。
独立的累计消费门槛/里程碑 token 机制在新版规则中停用。历史字段只用于旧数据兼容读取,生产 `dynamic_v3` 不产生、不优先、不保留这类 token也不能把消费金额作为第三条获奖通道。
## 7. 六维风控与时间口径
@ -171,20 +183,20 @@ adjusted_weight = base_weight × water_factor × recharge_factor
设备日 scope 只接受 user-service 已落库 session 的 `device_id`,由签名 JWT 贯通到 room 和 lucky旧 token/旧 command 缺字段时 `fixed_v2` 可继续重放,但 `dynamic_v3` 必须拒绝开奖,不能回退到 `session_id` 或每单变化的 `command_id`。外部 App 的 device ID 由已认证调用方负责稳定提供V3 缺失同样拒绝;服务只保存 App 内稳定 hash避免暴露原始标识。
所有时间范围遵守 `[start_ms,end_ms)`包含开始毫秒不包含结束毫秒。UTC 日、UTC 小时、充值 5 分钟和滚动 72 小时都不得使用 `time.Local`、容器时区或客户端时区切分。
所有时间范围遵守 `[start_ms,end_ms)`包含开始毫秒不包含结束毫秒。UTC 日、UTC 小时、充值 5 分钟和滚动 48 小时都不得使用 `time.Local`、容器时区或客户端时区切分。滚动 48 小时以上一个已关闭轮次的 `closed_at_ms` 为上界,不能用用户下一次送礼的到达时间漂移窗口。
## 8. 批量 N 次的语义
`gift_count=N` 表示按顺序执行 N 次抽奖,不是把总金额当成一次抽奖,也不是“只开奖一次但保证中奖”。例如 `gift_count=99` 必须产生 99 次状态推进。
- 总 `coin_spent` 拆成 N 个整数单份,余数从前往后每份加 1三资金桶使用联合累计配额保证每一抽 `public_i+profit_i+anchor_i=unit_spent_i`,同时整批三桶精确等于钱包回执总拆账。
- 第 `i` 抽看到第 `i-1` 抽后的 `P`、连 0、RTP、token、日大奖次数和风控计数;不能并行抽 N 次后再合并。
- 若第 `i` 抽使当前 RTP 窗口真实累计流水达到或超过 `settlement_window_wager``i+1` 抽必须先滚到新窗口;不能按 `ceil(window_wager/gift_price_reference)` 折算抽数,也不能把整批强塞进旧窗口后再一次性关窗。
- 第 `i` 抽看到第 `i-1` 抽后的 `P`、连 0、已结算轮次资格、日大奖次数和风控计数;不能并行抽 N 次后再合并。
- 若第 `i` 抽使当前 RTP 窗口真实累计流水达到或超过 `settlement_window_wager`该抽完成后立即关闭大盘和参与用户的同轮次快照;第 `i+1` 抽先使用这个已关闭轮次判断该用户的一次性资格,再进入新窗口。不能按 `ceil(window_wager/gift_price_reference)` 折算抽数,也不能把整批强塞进旧窗口后再一次性关窗。
- 每个子抽用稳定子幂等键(现有约定为 `command_id#序号`)保留独立 draw record重放整批不能跳过、补写或重复支付其中一抽。
- 一批内共享状态只锁一次并按顺序在内存推进,最终批量持久化;性能优化不能改变顺序语义。
- 聚合钱包返奖和房间表现可以按送礼命令合并,但审计仍保留 N 条单抽事实。
外部 App 的 `/lucky-gifts/send` 也遵守同一语义:`dynamic_v3` 逐次执行最多 999 抽、写 `external_lucky_gift_draw_items`,最后只把总奖励聚合返回;外部调用方自行入账,因此不生成 HyApp wallet 返奖 outbox。外部 `dynamic_v3` 必须提交稳定 `device_id` 和真实扣费 `paid_at_ms`,同设备多账号共享设备日上限;但 luck-gateway 目前只有 App allowlist不能独立证明设备真实性,所以调用方必须在自身认证/请求签名边界内绑定这些值owner 不得用 `external_user_id``request_id` 或请求到达时间兜底。当前外部协议没有可验证的充值 owner 快照,运行侧明确按 `novice` 且不加最近充值因子,不能从 `metadata` 猜充值额。`fixed_v2` 外部请求继续保持缺少新事实的历史兼容路径。
外部 App 的 `/lucky-gifts/send` 也遵守同一语义:`dynamic_v3` 逐次执行最多 999 抽、写 `external_lucky_gift_draw_items`,最后只把总奖励聚合返回;外部调用方自行入账,因此不生成 HyApp wallet 返奖 outbox。外部 `dynamic_v3` 必须提交稳定 `device_id`、真实扣费 `paid_at_ms` 和账号真实 `user_registered_at_ms`,同设备多账号共享设备日上限;但 luck-gateway 目前只有 App allowlist不能独立证明这些调用方事实,所以接入方必须在自身认证/请求签名边界内把它们绑定到 `external_user_id`。owner 不得用 `request_id`、首次送礼时间或请求到达时间兜底注册年龄。当前外部协议没有可验证的充值 owner 快照,运行侧明确按 `novice` 且不加最近充值因子,不能从 `metadata` 猜充值额。`fixed_v2` 外部请求继续保持缺少新事实的历史兼容路径。
## 9. 接口和配置字段
@ -200,13 +212,14 @@ adjusted_weight = base_weight × water_factor × recharge_factor
`luckygift.v1.LuckyGiftMeta` 接收:
- `device_id`(从已验签 access JWT 穿过 room command 恢复链路)
- `user_registered_at_ms`(来自 user owner 的账号创建时间,供轮次关闭时判断是否注册满 48 小时)
- `paid_at_ms`(逐目标 wallet transaction 的稳定创建时间)
- `recharge_7d_coins`
- `recharge_30d_coins`
- `last_recharged_at_ms`
- `gift_income_coins`
`room.v1.RequestMeta.device_id` 只由 gateway 从验签 JWT 的 `device_id` claim 写入;`ExternalGiftDrawRequest.device_id` 由外部认证调用方承担稳定性责任。V3 对两个入口都不提供 session/command fallback。其余结算字段只来自 wallet owner 的成功回执。`request_id` 只做链路追踪;抽奖幂等仍使用 `command_id`
`room.v1.RequestMeta.device_id` 只由 gateway 从验签 JWT 的 `device_id` claim 写入;内部送礼请求的注册时间由 gateway 查询 user owner 后固化到可恢复 room command`ExternalGiftDrawRequest.device_id``user_registered_at_ms` 由外部认证调用方承担真实性责任。V3 对两个入口都不提供 session/command/首次送礼时间 fallback。其余结算字段只来自 wallet owner 的成功回执。`request_id` 只做链路追踪;抽奖幂等仍使用 `command_id`
### 9.2 不可变规则字段
@ -218,7 +231,7 @@ adjusted_weight = base_weight × water_factor × recharge_factor
| 资金拆分 | `pool_rate_ppm`, `profit_rate_ppm`, `anchor_rate_ppm`, `initial_pool_coins` |
| 保底和水位 | `loss_streak_guarantee`, `low_watermark_coins`, `low_water_nonzero_factor_ppm`, `high_watermark_coins`, `high_water_nonzero_factor_ppm` |
| 最近充值 | `recharge_boost_window_ms`, `recharge_boost_factor_ppm` |
| 大奖 | `jackpot_multiplier_ppms`, `jackpot_global_rtp_max_ppm`, `jackpot_user_day_rtp_max_ppm`, `jackpot_user_72h_rtp_max_ppm`, `jackpot_spend_threshold_coins`, `max_jackpot_hits_per_user_day` |
| 大奖 | `jackpot_multiplier_ppms`, `jackpot_global_rtp_max_ppm`, `jackpot_user_round_rtp_max_ppm`, `jackpot_user_48h_rtp_max_ppm`, `max_jackpot_hits_per_user_day`;历史 `jackpot_spend_threshold_coins` 在新版规则中停用 |
| 六维风控 | `max_single_payout`, `user_hourly_payout_cap`, `user_daily_payout_cap`, `device_daily_payout_cap`, `room_hourly_payout_cap`, `anchor_daily_payout_cap` |
| 充值层级 | `stages[].min_recharge_7d_coins`, `stages[].min_recharge_30d_coins`, `stages[].tiers[]` |
@ -231,18 +244,19 @@ adjusted_weight = base_weight × water_factor × recharge_factor
| `lucky_gift_rule_versions` | `fixed_v2/dynamic_v3` 不可变版本、资金拆分、水位、大奖门槛和六项上限 |
| `lucky_gift_stage_tiers` | 普通/高阶充值门槛、新手兜底 sentinel、基础倍率和基础概率 |
| `lucky_pools` | 公共池 `balance/total_in/total_out`,以及 `profit_total/anchor_income_total/initial_seed_total` |
| `lucky_rtp_windows` | 全局/奖池窗口的流水、目标返奖、实际返奖和 `open/closed/underpaid` 状态 |
| `lucky_user_rtp_hour_buckets` | 用户 UTC 小时流水/返奖桶,用于严格滚动 72 小时 |
| `lucky_user_rtp_boundary_events` | 只补齐 `[now-72h,now)` 首尾两个不足一小时的片段;与完整小时桶组合后不近似、不回扫全量 draw |
| `lucky_user_strategy_days` | 用户 UTC 日流水、返奖、消费和已命中大奖次数 |
| `lucky_user_states` | 累计抽数、等价流水、`loss_streak` 和可跨日保留的待消费 token |
| `lucky_rtp_windows` | 全局/奖池窗口的流水、目标返奖、实际返奖、`started_at_ms/closed_at_ms``open/closed/underpaid` 状态关闭时间是48小时统计与注册年龄判断的固定锚点 |
| `lucky_user_rtp_windows` | 每个用户在同一大盘 `rule_version + window_index` 内的总消费/总返奖,以及 `pending/attempted/ineligible/expired` 资格状态;一轮只尝试一次 |
| `lucky_user_rtp_hour_buckets` | 用户 UTC 小时流水/返奖桶,用于汇总严格滚动 48 小时 |
| `lucky_user_rtp_boundary_events` | 只补齐 `[closed_at+1ms-48h,closed_at+1ms)` 首尾两个不足一小时的片段;与完整小时桶组合后不近似、不回扫全量 draw |
| `lucky_user_strategy_days` | 用户 UTC 日消费和已命中大奖次数;不再作为用户 RTP 资格来源,也不再生成消费里程碑资格 |
| `lucky_user_states` | 累计抽数、等价流水和 `loss_streak`;历史 token 字段仅兼容旧记录,新版规则不产生或消费 |
| `lucky_risk_counters` | 用户/设备/房间/主播的 UTC 小时或日累计返奖 |
| `lucky_draw_records` | 单抽最终奖档、金额、规则版本、候选/奖池/RTP 决策快照和钱包状态 |
| `external_lucky_gift_draw_items` | 外部 App `gift_count=N` 的 N 条顺序子抽事实;外部聚合行只负责幂等响应 |
| `lucky_gift_command_locks` | 内部 `app_code+command_id` 首次并发互斥;等待者在首事务提交后回读同一批 draw 事实 |
| `lucky_gift_outbox` | 钱包补偿与投递审计,不替代 draw 业务事实 |
每条 V3 draw 的审计快照至少要能还原:阶段、规则版本、抽前池余额、本抽入池、可用池、六维最小容量、各档基础/调整后权重及因子、原始档、每次删档/重抽、三项 RTP 条件、大奖机制、token 产生/消费/保留、最终原因和阻断原因
每条 V3 draw 的审计快照至少要能还原:阶段、规则版本、抽前池余额、本抽入池、可用池、六维最小容量、各档基础/调整后权重及因子、原始档、每次删档/重抽、来源轮次及关闭时间、大盘 RTP、用户同轮次 RTP、滚动 48 小时总消费/总返奖、注册年龄、一次性资格是否已尝试、最终原因和阻断原因。同倍率双来源必须保存实际 `selected_tier_id` 和该来源的实际选择权重:普通奖记录调整后的阶段权重,补偿大奖记录大奖集合内权重,不能把补偿 tier 的普通权重 0 写成命中权重
状态边界:
@ -267,12 +281,13 @@ adjusted_weight = base_weight × water_factor × recharge_factor
| 充值分层 | 各 7/30 日门槛的下 1、等于、上 1 | 必须同时达到两维,选择最高满足层 |
| 批量 | N=1、N=99、金额有余数 | 恰好 N 次顺序推进,金额守恒,单抽事实与重放一致 |
| 结算窗口 | 阈值 99、参考价 10、已 10 抽但仅 98 流水,再执行 99 份共 100 金币 | 第 1 抽进入旧窗并使流水到 100后 98 抽进入新窗;证明不按抽数提前滚动 |
| 大奖机制一 | 全局/当日/72h 各自通过和失败、分母 0 | 三门同时通过才有资格,零样本不触发 |
| 大奖机制二 | 门槛前、恰好跨过、一次跨多个门槛、资金不足 | token 只影响下一抽,幂等产生,资金不足保留 |
| 已结算轮次资格 | 注册47h、恰48h、只玩最后5分钟、用户轮次=96%、滚动48h=96%、全局=98%/略高于98%、任一用户门单独失败、零样本 | 注册年龄按账号创建时间两个用户RTP严格<96%且AND大盘允许=98%只玩5分钟按实际总额计算零样本不触发 |
| 一次性尝试 | 资格成立但奖池不足,随后补池再次送礼 | 首次送礼不强发大奖且消费资格;补池后旧轮次资格不能再次使用 |
| 大奖集合 | 200x/500x/1000x 分别可付/不可付 | 只在可付集合随机,不能选中后透支 |
| 日 5 次 | 已中 4 次、5 次UTC 日边界 | 第 5 次可中,第 6 次大奖被阻断,新 UTC 日重新计数 |
| 日 5 次 | 已中 4 次、5 次UTC 日边界 | 第 5 次补偿大奖可中,第 6 次补偿大奖被阻断,新 UTC 日重新计数;普通奖不占次数 |
| 同倍率双来源 | 100/200/500/1000x 分别配置普通与补偿路径,并在达到日上限后再抽普通 1000x | 每个倍率的普通命中都不计大奖、补偿命中都计数补偿达到上限后同倍率普通奖仍可命中tier ID 和选择权重可区分来源 |
| 六维风控 | 每一维分别取 `W=remaining``W=remaining+1` | 等于允许,超过任一维拒绝,原因可审计 |
| 组合优先级 | token + RTP 补偿 + 连 0 + 低水位 + 充值 | token 优先,其后 RTP再普通安全门永远最后裁决 |
| 组合优先级 | 已结算轮次资格 + 连 0 + 低水位 + 充值 | 已结算轮次资格先尝试,再进入普通/保底;奖池与安全门永远裁决且消费本轮资格 |
| 并发/幂等 | 同 command 重放、同用户并发状态版本 | 只结算一次;状态无丢失更新,奖池不为负 |
| 有资金长跑 | 固定 seed、足量冷启动资金、各充值层 | 报告实际 RTP/命中率/池底,不把 95%101%当资金不足时硬断言 |
@ -289,6 +304,8 @@ go run ./services/lucky-gift-service/cmd/strategy-sim \
-json-out /tmp/lucky-gift-dynamic-v3-simulation.json
```
2026-07-15 按上述默认规模生成的完整机器可读报告保存在 `幸运礼物动态策略V3模拟结果-20260715.json`;旧的 2026-07-12 文件只代表历史 day/72h 规则,不得作为本次 48 小时版本的验收依据。
策略模拟进程只有在全部场景满足断言时退出 0。总报告必须同时满足
- `nominal_probability_table.expected_multiplier=26.5`
@ -301,6 +318,6 @@ go run ./services/lucky-gift-service/cmd/strategy-sim \
1. 存量规则空 `strategy_version` 读取和重放仍为 `fixed_v2`,且无需补 V3 金额字段。
2. 新 `dynamic_v3` 启用规则的三项拆分必须合计 100%,每层基础概率必须合计 100%,静态 EV 必须落在目标 RTP±控制带内。
3. 累计消费大奖门槛、低/高水位、普通/高阶充值门槛、大奖集合、日次数和六项风控都必须按 App 明确配置;不能依赖代码默认值直接上线。
4. user JWT、gateway/room meta、wallet 回执、lucky-gift 请求、protobuf 生成代码、配置读写和 MySQL schema 必须端到端包含新增字段;动态入口缺可信 `device_id` 或 owner `paid_at_ms` 必须 fail-close
3. 低/高水位、普通/高阶充值门槛、大奖集合、日次数和六项风控都必须按 App 明确配置;不能依赖代码默认值直接上线。累计消费大奖门槛保持停用,不能成为独立大奖通道。
4. user JWT、gateway/room meta、wallet 回执、lucky-gift 请求、protobuf 生成代码、配置读写和 MySQL schema 必须端到端包含新增字段;动态入口缺可信 `device_id` 或 owner `paid_at_ms` 时整次开奖 fail-close缺失/无效 `user_registered_at_ms` 时只关闭补偿大奖资格,普通开奖继续
5. 本地单测、完整模拟、`make proto``make test` 通过后,仍需用隔离奖池灰度;灰度验收重点是奖池不为负、幂等不重复发奖、审计可回放,而不是短窗口 RTP 恰好等于 98%。

File diff suppressed because it is too large Load Diff

View File

@ -17,6 +17,8 @@
对接 App 负责:
- 调用前完成用户扣费。
- 从自己的账务和用户主数据提供真实的扣费时间、稳定设备标识和账号注册时间。
- 在自己的鉴权或请求签名边界内绑定上述业务事实,不能让 App 或 Web 客户端直接伪造。
- 成功收到结果后,在自己的账务系统中向用户发放 `reward_amount`
- 对超时或网络失败使用原 `request_id` 重试,不得重新生成业务请求 ID。
@ -43,9 +45,19 @@ GET https://lucky-api.global-interaction.com/healthz/ready
1. 向平台申请唯一 `app_code`,并加入幸运礼物网关白名单。
2. 确认该 App 的幸运礼物规则已启用。
3. 对接 App 必须先扣费,再调用抽奖接口。
4. 本接口只允许对接 App 服务端调用,禁止将请求能力下发到 App 或 Web 客户端。
4. 如果使用 `dynamic_v3`,对接 App 必须能够提供真实 `paid_at_ms`、稳定 `device_id` 和真实 `user_registered_at_ms`
5. 本接口只允许对接 App 服务端调用,禁止将请求能力下发到 App 或 Web 客户端。
当前版本的 HTTP 入口使用 `app_code` 白名单,尚未启用 Authorization Token 或请求签名,因此必须仅由已开通的对接 App 服务端调用。
### 3.1 真实性与信任边界
当前 HTTP 入口只校验 `app_code` 是否在白名单,尚未启用 Authorization Token 或平台侧请求签名。白名单只能决定“这个 App 是否已开通”,不能独立证明某次请求中的用户、设备、扣费时间和注册时间是真实的。
对接 App 必须在自己的服务端边界内完成以下保障:
- `external_user_id` 来自已登录用户,不直接信任客户端自报值。
- `device_id` 与该次已鉴权的用户/设备关系绑定,并在同一设备的后续请求中保持稳定;不能用随机数、请求号或会话 token 临时代替。
- `paid_at_ms` 由扣费成功的账务事实产生,`user_registered_at_ms` 从用户主数据读取,不使用幸运礼物接口的收包时间或用户首次抽奖时间代替。
- 如果对接 App 自身使用请求签名,签名内容必须覆盖这些字段;重试时必须保持原始业务事实不变。
## 4. 发送幸运礼物
@ -67,8 +79,11 @@ X-Request-Id: trace-20260715-000001
"app_code": "partner_app",
"request_id": "gift-order-20260715-000001",
"external_user_id": "user-10001",
"device_id": "device-installation-7f80d90b",
"gift_count": 3,
"unit_amount": 100,
"paid_at_ms": 1784073590000,
"user_registered_at_ms": 1783814400000,
"currency": "COIN",
"metadata": {
"external_order_id": "order-1000001",
@ -85,8 +100,11 @@ X-Request-Id: trace-20260715-000001
| `app_code` | string | 是 | 去除首尾空格后 132 字符 | 平台分配的 App 编码;按小写存储和校验。 |
| `request_id` | string | 是 | 去除首尾空格后 1128 字符 | 对接 App 生成的业务幂等 ID建议直接使用送礼订单号或 UUID。 |
| `external_user_id` | string | 是 | 去除首尾空格后 1128 字符 | 用户在对接 App 中的稳定唯一 ID。 |
| `device_id` | string | `dynamic_v3` 是 | 去除首尾空格后 1128 字符 | 对接 App 在自己鉴权/签名边界内绑定的稳定设备标识;用于设备级风控,不得每次请求重新生成。 |
| `gift_count` | int64 | 是 | `> 0` | 本次已扣费的礼物数量。 |
| `unit_amount` | int64 | 是 | `> 0` | 单个礼物的金币整数价格。 |
| `paid_at_ms` | int64 | `dynamic_v3` 是 | `> 0`Unix epoch milliseconds | 对接 App 账务系统完成本次扣费的真实 UTC 时间;系统不会用接口收到请求的时间代替。 |
| `user_registered_at_ms` | int64 | `dynamic_v3` 业务必填 | `> 0`Unix epoch milliseconds | 用户账号的真实创建时间,必须从对接 App 的用户主数据获取。技术上缺失不会阻断普通抽奖,但系统会按不满足注册 48 小时处理,关闭该用户的 RTP 补偿大奖资格。 |
| `currency` | string | 否 | 只支持 `COIN` | 缺省或空字符串时按 `COIN` 处理。 |
| `metadata` | object | 否 | JSON object | 对接方的展示或排查元数据;不参与金额、规则或幂等判定。 |
@ -96,7 +114,9 @@ X-Request-Id: trace-20260715-000001
total_amount = gift_count * unit_amount
```
请求体不得传入 `total_amount``pool_id``paid_at_ms`。奖池由平台规则选择,扣费完成时间由网关按 UTC 服务端时间写入。接口会拒绝所有未定义的顶层字段。
请求体不得传入 `total_amount``pool_id``total_amount` 由系统根据数量和单价计算,奖池由平台已发布的规则选择。接口会拒绝所有未定义的顶层字段。
`dynamic_v3` 下,缺失 `paid_at_ms``device_id` 会使整次抽奖失败;缺失、不合理或尚未满 48 小时的 `user_registered_at_ms` 不阻断普通抽奖,但 RTP 补偿大奖资格会严格按“不符合”处理。`fixed_v2` 为历史接入保留了缺省兼容,对接方不应依赖该兼容,否则规则切换到 `dynamic_v3` 后会失败或丢失大奖资格。
一个请求只返回一个聚合抽奖结果;`gift_count` 不会产生多个独立响应。
@ -183,7 +203,7 @@ app_code + request_id
| 400 | 40000 | JSON 无效、出现未定义字段、必填字段缺失、金额非正数或币种不支持 | 修正请求,不要原样重试。 |
| 403 | 40300 | `app_code` 未加入当前环境白名单 | 联系平台开通或检查环境。 |
| 404 | 无统一业务码 | 请求路径或 HTTP 方法错误 | 检查 Base URL、路径和请求方法。 |
| 500 | 50000 | 抽奖服务超时或内部处理失败 | 保持原 `request_id` 重试;不得重新扣费。 |
| 500 | 50000 | 抽奖服务超时、内部处理失败,或 `dynamic_v3` 在规则层发现 `paid_at_ms` / `device_id` 缺失 | 先本地校验 V3 业务事实已齐全;事实齐全时保持原 `request_id` 重试,不得重新扣费。 |
业务成功必须同时满足 HTTP Status 为 `200` 且响应 `code``0`
@ -198,8 +218,11 @@ curl --request POST \
"app_code": "partner_app",
"request_id": "gift-order-20260715-000001",
"external_user_id": "user-10001",
"device_id": "device-installation-7f80d90b",
"gift_count": 3,
"unit_amount": 100,
"paid_at_ms": 1784073590000,
"user_registered_at_ms": 1783814400000,
"currency": "COIN",
"metadata": {
"external_order_id": "order-1000001",
@ -211,17 +234,22 @@ curl --request POST \
## 8. 标准处理流程
1. 对接 App 生成全局唯一的 `request_id`
2. 对接 App 在自有账务系统扣除 `gift_count * unit_amount`
3. 对接 App 调用幸运礼物接口。
4. 平台按 `app_code + request_id` 创建或返回已有抽奖结果。
5. 对接 App 在响应成功后按 `reward_amount` 入账,并保存 `draw_id`
6. 任何未知结果都使用原 `request_id` 重试;禁止重复扣费或重新生成抽奖业务号。
1. 对接 App 在已鉴权的服务端上确定 `external_user_id`、稳定 `device_id` 和用户主数据中的 `user_registered_at_ms`
2. 对接 App 生成全局唯一的 `request_id`
3. 对接 App 在自有账务系统扣除 `gift_count * unit_amount`,并以扣费成功事实的 UTC 毫秒时间作为 `paid_at_ms`
4. 对接 App 调用幸运礼物接口,一次传入上述业务事实。
5. 平台按 `app_code + request_id` 创建或返回已有抽奖结果。
6. 对接 App 在响应成功后按 `reward_amount` 入账,并保存 `draw_id`
7. 任何未知结果都使用原 `request_id` 和原始业务事实重试;禁止重复扣费或重新生成抽奖业务号。
## 9. 联调检查清单
- [ ] `app_code` 已加入幸运礼物网关白名单。
- [ ] 规则已启用,并确认金币单位一致。
- [ ] `dynamic_v3` 请求中的 `paid_at_ms` 来自真实扣费事实,不是当前时间的临时填充值。
- [ ] `device_id` 已绑定已鉴权设备且跨请求稳定,不用请求号或会话 token 代替。
- [ ] `user_registered_at_ms` 来自用户主数据已验证缺失时普通抽奖可继续、RTP 补偿大奖资格会关闭。
- [ ] 调用能力仅保留在对接 App 服务端,上述业务事实已纳入自身鉴权或请求签名边界。
- [ ] 正常送礼可获得 HTTP 200 / `code=0`
- [ ] 重复发送相同 `request_id` 可获得相同 `draw_id` 和结果。
- [ ] 模拟超时后只重试抽奖请求,不重复扣费。

View File

@ -353,7 +353,8 @@ func main() {
databimodule.WithLegacyApps(databiLegacyApps(cfg.DashboardExternalSources, cfg.MoneyRegionSources)...),
databimodule.WithLegacyRegionCatalogs(databiLegacyRegionCatalogs(moneyRegionSources)...),
)
luckyGiftHandler := luckygiftmodule.New(luckygiftadmin.NewGRPC(luckyGiftConn), cfg.LuckyGiftService.RequestTimeout, auditHandler)
luckyGiftHandler := luckygiftmodule.New(luckygiftadmin.NewGRPC(luckyGiftConn), cfg.LuckyGiftService.RequestTimeout, auditHandler).
BindUserClient(userclient.NewGRPC(userConn))
handlers := router.Handlers{
Audit: auditHandler,
Auth: authmodule.New(store, auth, auditHandler, cfg),

View File

@ -16,6 +16,8 @@ type Client interface {
ListLuckyGiftConfigs(ctx context.Context, req *luckygiftv1.ListLuckyGiftConfigsRequest) (*luckygiftv1.ListLuckyGiftConfigsResponse, error)
ListLuckyGiftDraws(ctx context.Context, req *luckygiftv1.ListLuckyGiftDrawsRequest) (*luckygiftv1.ListLuckyGiftDrawsResponse, error)
GetLuckyGiftDrawSummary(ctx context.Context, req *luckygiftv1.GetLuckyGiftDrawSummaryRequest) (*luckygiftv1.GetLuckyGiftDrawSummaryResponse, error)
ListLuckyGiftUserProfiles(ctx context.Context, req *luckygiftv1.ListLuckyGiftUserProfilesRequest) (*luckygiftv1.ListLuckyGiftUserProfilesResponse, error)
GetLuckyGiftUserProfile(ctx context.Context, req *luckygiftv1.GetLuckyGiftUserProfileRequest) (*luckygiftv1.GetLuckyGiftUserProfileResponse, error)
ListLuckyGiftPoolBalances(ctx context.Context, req *luckygiftv1.ListLuckyGiftPoolBalancesRequest) (*luckygiftv1.ListLuckyGiftPoolBalancesResponse, error)
AdjustLuckyGiftPoolBalance(ctx context.Context, req *luckygiftv1.AdjustLuckyGiftPoolBalanceRequest) (*luckygiftv1.AdjustLuckyGiftPoolBalanceResponse, error)
CreateLuckyGiftExperiment(ctx context.Context, req *luckygiftv1.CreateLuckyGiftExperimentRequest) (*luckygiftv1.CreateLuckyGiftExperimentResponse, error)
@ -53,6 +55,14 @@ func (c *GRPCClient) GetLuckyGiftDrawSummary(ctx context.Context, req *luckygift
return c.client.GetLuckyGiftDrawSummary(ctx, req)
}
func (c *GRPCClient) ListLuckyGiftUserProfiles(ctx context.Context, req *luckygiftv1.ListLuckyGiftUserProfilesRequest) (*luckygiftv1.ListLuckyGiftUserProfilesResponse, error) {
return c.client.ListLuckyGiftUserProfiles(ctx, req)
}
func (c *GRPCClient) GetLuckyGiftUserProfile(ctx context.Context, req *luckygiftv1.GetLuckyGiftUserProfileRequest) (*luckygiftv1.GetLuckyGiftUserProfileResponse, error) {
return c.client.GetLuckyGiftUserProfile(ctx, req)
}
func (c *GRPCClient) ListLuckyGiftPoolBalances(ctx context.Context, req *luckygiftv1.ListLuckyGiftPoolBalancesRequest) (*luckygiftv1.ListLuckyGiftPoolBalancesResponse, error) {
return c.client.ListLuckyGiftPoolBalances(ctx, req)
}

View File

@ -177,6 +177,13 @@ type ResolveDisplayUserIDRequest struct {
DisplayUserID string
}
// ResolveAdminUserIdentifierRequest 仅供后台精确搜索永久默认短号、当前有效展示号或系统长 user_id。
type ResolveAdminUserIdentifierRequest struct {
RequestID string
Caller string
UserIdentifier string
}
type UserIdentity struct {
UserID int64 `json:"userId,string"`
DisplayUserID string `json:"displayUserId"`
@ -437,6 +444,19 @@ func (c *GRPCClient) ResolveDisplayUserID(ctx context.Context, req ResolveDispla
return fromProtoUserIdentity(resp.GetIdentity()), nil
}
// ResolveAdminUserIdentifier 调用 user-service owner 的后台专用解析契约admin-server 不读取用户库,
// 也不会为了搜索默认短号而改变客户端 ResolveDisplayUserID 的 active-only 语义。
func (c *GRPCClient) ResolveAdminUserIdentifier(ctx context.Context, req ResolveAdminUserIdentifierRequest) (*UserIdentity, error) {
resp, err := c.identityClient.ResolveAdminUserIdentifier(ctx, &userv1.ResolveAdminUserIdentifierRequest{
Meta: requestMeta(ctx, req.RequestID, req.Caller),
UserIdentifier: req.UserIdentifier,
})
if err != nil {
return nil, err
}
return fromProtoUserIdentity(resp.GetIdentity()), nil
}
func fromProtoRegisterRiskConfig(config *userv1.RegisterRiskConfig) *RegisterRiskConfig {
if config == nil {
return nil

View File

@ -2,12 +2,14 @@ package luckygift
import (
"context"
"encoding/json"
"fmt"
"math"
"strings"
"time"
"hyapp-admin-server/internal/integration/luckygiftadmin"
"hyapp-admin-server/internal/integration/userclient"
"hyapp-admin-server/internal/middleware"
"hyapp-admin-server/internal/modules/shared"
"hyapp-admin-server/internal/response"
@ -20,10 +22,18 @@ import (
type Handler struct {
luckyGift luckygiftadmin.Client
users luckyGiftUserClient
requestTimeout time.Duration
audit shared.OperationLogger
}
// luckyGiftUserClient 只暴露画像页面需要的 owner 能力。后台搜索必须走专用精确解析 RPC
// 用户资料仍批量读取,避免给全局 userclient.Client 增加画像模块专属依赖。
type luckyGiftUserClient interface {
BatchGetUsers(ctx context.Context, req userclient.BatchGetUsersRequest) (map[int64]*userclient.User, error)
ResolveAdminUserIdentifier(ctx context.Context, req userclient.ResolveAdminUserIdentifierRequest) (*userclient.UserIdentity, error)
}
func New(luckyGift luckygiftadmin.Client, requestTimeout time.Duration, audit shared.OperationLogger) *Handler {
if requestTimeout <= 0 {
requestTimeout = 3 * time.Second
@ -31,42 +41,81 @@ func New(luckyGift luckygiftadmin.Client, requestTimeout time.Duration, audit sh
return &Handler{luckyGift: luckyGift, requestTimeout: requestTimeout, audit: audit}
}
// BindUserClient 注入 user-service 主数据读取能力。幸运礼物 owner 只保存不可变的内部 user_id
// 短号、默认号、靓号和用户资料必须在 admin-server 展示边界实时解析,不能复制进幸运礼物画像表。
func (h *Handler) BindUserClient(users luckyGiftUserClient) *Handler {
h.users = users
return h
}
type configRequest struct {
AppCode string `json:"app_code"`
PoolID string `json:"pool_id"`
StrategyVersion string `json:"strategy_version"`
Enabled bool `json:"enabled"`
TargetRTPPercent float64 `json:"target_rtp_percent"`
PoolRatePercent float64 `json:"pool_rate_percent"`
ProfitRatePercent float64 `json:"profit_rate_percent"`
AnchorRatePercent float64 `json:"anchor_rate_percent"`
SettlementWindowWager int64 `json:"settlement_window_wager"`
ControlBandPercent float64 `json:"control_band_percent"`
GiftPriceReference int64 `json:"gift_price_reference"`
NoviceMaxEquivalentDraws int64 `json:"novice_max_equivalent_draws"`
NormalMaxEquivalentDraws int64 `json:"normal_max_equivalent_draws"`
EffectiveFromMS int64 `json:"effective_from_ms"`
InitialPoolCoins int64 `json:"initial_pool_coins"`
LossStreakGuarantee int64 `json:"loss_streak_guarantee"`
LowWatermarkCoins int64 `json:"low_watermark_coins"`
LowWaterNonzeroFactorPercent float64 `json:"low_water_nonzero_factor_percent"`
HighWatermarkCoins int64 `json:"high_watermark_coins"`
HighWaterNonzeroFactorPercent float64 `json:"high_water_nonzero_factor_percent"`
RechargeBoostWindowMS int64 `json:"recharge_boost_window_ms"`
RechargeBoostFactorPercent float64 `json:"recharge_boost_factor_percent"`
JackpotMultipliers []float64 `json:"jackpot_multipliers"`
JackpotGlobalRTPMaxPercent float64 `json:"jackpot_global_rtp_max_percent"`
JackpotUserDayRTPMaxPercent float64 `json:"jackpot_user_day_rtp_max_percent"`
JackpotUser72hRTPMaxPercent float64 `json:"jackpot_user_72h_rtp_max_percent"`
JackpotSpendThresholdCoins int64 `json:"jackpot_spend_threshold_coins"`
MaxJackpotHitsPerUserDay int64 `json:"max_jackpot_hits_per_user_day"`
MaxSinglePayout int64 `json:"max_single_payout"`
UserHourlyPayoutCap int64 `json:"user_hourly_payout_cap"`
UserDailyPayoutCap int64 `json:"user_daily_payout_cap"`
DeviceDailyPayoutCap int64 `json:"device_daily_payout_cap"`
RoomHourlyPayoutCap int64 `json:"room_hourly_payout_cap"`
AnchorDailyPayoutCap int64 `json:"anchor_daily_payout_cap"`
Stages []stageDTO `json:"stages"`
AppCode string `json:"app_code"`
PoolID string `json:"pool_id"`
StrategyVersion string `json:"strategy_version"`
Enabled bool `json:"enabled"`
TargetRTPPercent float64 `json:"target_rtp_percent"`
PoolRatePercent float64 `json:"pool_rate_percent"`
ProfitRatePercent float64 `json:"profit_rate_percent"`
AnchorRatePercent float64 `json:"anchor_rate_percent"`
SettlementWindowWager int64 `json:"settlement_window_wager"`
ControlBandPercent float64 `json:"control_band_percent"`
GiftPriceReference int64 `json:"gift_price_reference"`
NoviceMaxEquivalentDraws int64 `json:"novice_max_equivalent_draws"`
NormalMaxEquivalentDraws int64 `json:"normal_max_equivalent_draws"`
EffectiveFromMS int64 `json:"effective_from_ms"`
InitialPoolCoins int64 `json:"initial_pool_coins"`
LossStreakGuarantee int64 `json:"loss_streak_guarantee"`
LowWatermarkCoins int64 `json:"low_watermark_coins"`
LowWaterNonzeroFactorPercent float64 `json:"low_water_nonzero_factor_percent"`
HighWatermarkCoins int64 `json:"high_watermark_coins"`
HighWaterNonzeroFactorPercent float64 `json:"high_water_nonzero_factor_percent"`
RechargeBoostWindowMS int64 `json:"recharge_boost_window_ms"`
RechargeBoostFactorPercent float64 `json:"recharge_boost_factor_percent"`
User24HourRTPThresholdPercent float64 `json:"user_24h_rtp_threshold_percent"`
User24HourOrdinaryWinFactorPercent float64 `json:"user_24h_ordinary_win_factor_percent"`
JackpotMultipliers []float64 `json:"jackpot_multipliers"`
JackpotGlobalRTPMaxPercent float64 `json:"jackpot_global_rtp_max_percent"`
JackpotUserRoundRTPMaxPercent float64 `json:"jackpot_user_round_rtp_max_percent"`
JackpotUser48hRTPMaxPercent float64 `json:"jackpot_user_48h_rtp_max_percent"`
// Deprecated in-process aliases keep historical handler builders source-compatible. They are not
// accepted or emitted on HTTP; the deployed admin UI and protobuf use the unique round/48h names.
JackpotUserDayRTPMaxPercent float64 `json:"-"`
JackpotUser72hRTPMaxPercent float64 `json:"-"`
JackpotSpendThresholdCoins int64 `json:"jackpot_spend_threshold_coins"`
MaxJackpotHitsPerUserDay int64 `json:"max_jackpot_hits_per_user_day"`
MaxSinglePayout int64 `json:"max_single_payout"`
UserHourlyPayoutCap int64 `json:"user_hourly_payout_cap"`
UserDailyPayoutCap int64 `json:"user_daily_payout_cap"`
DeviceDailyPayoutCap int64 `json:"device_daily_payout_cap"`
RoomHourlyPayoutCap int64 `json:"room_hourly_payout_cap"`
AnchorDailyPayoutCap int64 `json:"anchor_daily_payout_cap"`
Stages []stageDTO `json:"stages"`
}
// configUpsertRequest 只服务 HTTP 写入口。滚动发布期间旧前端仍可能发送 day/72h 字段,
// 因此输入 DTO 明确接收两代名字configRequest 继续只暴露 canonical JSON保证 GET 和写入响应不会双写字段。
type configUpsertRequest struct {
configRequest
JackpotUserRoundRTPMaxPercent *float64 `json:"jackpot_user_round_rtp_max_percent"`
JackpotUser48hRTPMaxPercent *float64 `json:"jackpot_user_48h_rtp_max_percent"`
LegacyUserDayRTPMaxPercent *float64 `json:"jackpot_user_day_rtp_max_percent"`
LegacyUser72hRTPMaxPercent *float64 `json:"jackpot_user_72h_rtp_max_percent"`
}
func (req configUpsertRequest) canonicalConfig() configRequest {
config := req.configRequest
// canonical 字段优先;指针用于区分“未发送”和显式发送 0避免新旧字段同时存在时旧值覆盖新值。
if req.JackpotUserRoundRTPMaxPercent != nil {
config.JackpotUserRoundRTPMaxPercent = *req.JackpotUserRoundRTPMaxPercent
} else if req.LegacyUserDayRTPMaxPercent != nil {
config.JackpotUserRoundRTPMaxPercent = *req.LegacyUserDayRTPMaxPercent
}
if req.JackpotUser48hRTPMaxPercent != nil {
config.JackpotUser48hRTPMaxPercent = *req.JackpotUser48hRTPMaxPercent
} else if req.LegacyUser72hRTPMaxPercent != nil {
config.JackpotUser48hRTPMaxPercent = *req.LegacyUser72hRTPMaxPercent
}
return config
}
type configDTO struct {
@ -189,11 +238,15 @@ func (h *Handler) GetLuckyGiftConfig(c *gin.Context) {
}
func (h *Handler) UpsertLuckyGiftConfig(c *gin.Context) {
var req configRequest
if err := c.ShouldBindJSON(&req); err != nil {
var input configUpsertRequest
decoder := json.NewDecoder(c.Request.Body)
// 兼容字段必须进入显式 DTO不能退回 map 或宽松二次反序列化,否则拼错的配置字段会被静默忽略。
decoder.DisallowUnknownFields()
if err := decoder.Decode(&input); err != nil {
response.BadRequest(c, "幸运礼物配置参数不正确")
return
}
req := input.canonicalConfig()
if req.PoolID == "" {
req.PoolID = strings.TrimSpace(c.Query("pool_id"))
}
@ -413,6 +466,11 @@ func (h *Handler) luckyGiftContext(c *gin.Context) (context.Context, context.Can
func configToProto(req configRequest) *luckygiftv1.LuckyGiftRuleConfig {
strategyVersion := strings.ToLower(strings.TrimSpace(req.StrategyVersion))
user24HourOrdinaryWinFactorPercent := req.User24HourOrdinaryWinFactorPercent
if req.User24HourRTPThresholdPercent == 0 && user24HourOrdinaryWinFactorPercent == 0 {
// 旧 ops-center 不认识这两个字段proto3 又会把缺失标量解成 00/0 统一解释为关闭并保留 100% 权重。
user24HourOrdinaryWinFactorPercent = 100
}
initialPoolCoins := req.InitialPoolCoins
if strategyVersion == "dynamic_v3" {
// V3 启动资金已经迁移为独立人工 credit旧页面残留的 1,000,000 不能在再次保存时回写不可变配置。
@ -443,42 +501,52 @@ func configToProto(req configRequest) *luckygiftv1.LuckyGiftRuleConfig {
Tiers: tiers,
})
}
roundRTPMaxPercent := req.JackpotUserRoundRTPMaxPercent
if roundRTPMaxPercent == 0 {
roundRTPMaxPercent = req.JackpotUserDayRTPMaxPercent
}
rolling48hRTPMaxPercent := req.JackpotUser48hRTPMaxPercent
if rolling48hRTPMaxPercent == 0 {
rolling48hRTPMaxPercent = req.JackpotUser72hRTPMaxPercent
}
return &luckygiftv1.LuckyGiftRuleConfig{
AppCode: strings.ToLower(strings.TrimSpace(req.AppCode)),
PoolId: strings.TrimSpace(req.PoolID),
StrategyVersion: strategyVersion,
Enabled: req.Enabled,
TargetRtpPpm: percentToPPM(req.TargetRTPPercent),
PoolRatePpm: percentToPPM(req.PoolRatePercent),
ProfitRatePpm: percentToPPM(req.ProfitRatePercent),
AnchorRatePpm: percentToPPM(req.AnchorRatePercent),
SettlementWindowWager: req.SettlementWindowWager,
ControlBandPpm: percentToPPM(req.ControlBandPercent),
GiftPriceReference: req.GiftPriceReference,
NoviceMaxEquivalentDraws: req.NoviceMaxEquivalentDraws,
NormalMaxEquivalentDraws: req.NormalMaxEquivalentDraws,
EffectiveFromMs: req.EffectiveFromMS,
InitialPoolCoins: initialPoolCoins,
LossStreakGuarantee: req.LossStreakGuarantee,
LowWatermarkCoins: req.LowWatermarkCoins,
LowWaterNonzeroFactorPpm: percentToPPM(req.LowWaterNonzeroFactorPercent),
HighWatermarkCoins: req.HighWatermarkCoins,
HighWaterNonzeroFactorPpm: percentToPPM(req.HighWaterNonzeroFactorPercent),
RechargeBoostWindowMs: req.RechargeBoostWindowMS,
RechargeBoostFactorPpm: percentToPPM(req.RechargeBoostFactorPercent),
JackpotMultiplierPpms: multipliersToPPM(req.JackpotMultipliers),
JackpotGlobalRtpMaxPpm: percentToPPM(req.JackpotGlobalRTPMaxPercent),
JackpotUserDayRtpMaxPpm: percentToPPM(req.JackpotUserDayRTPMaxPercent),
JackpotUser_72HRtpMaxPpm: percentToPPM(req.JackpotUser72hRTPMaxPercent),
JackpotSpendThresholdCoins: req.JackpotSpendThresholdCoins,
MaxJackpotHitsPerUserDay: req.MaxJackpotHitsPerUserDay,
MaxSinglePayout: req.MaxSinglePayout,
UserHourlyPayoutCap: req.UserHourlyPayoutCap,
UserDailyPayoutCap: req.UserDailyPayoutCap,
DeviceDailyPayoutCap: req.DeviceDailyPayoutCap,
RoomHourlyPayoutCap: req.RoomHourlyPayoutCap,
AnchorDailyPayoutCap: req.AnchorDailyPayoutCap,
Stages: stages,
AppCode: strings.ToLower(strings.TrimSpace(req.AppCode)),
PoolId: strings.TrimSpace(req.PoolID),
StrategyVersion: strategyVersion,
Enabled: req.Enabled,
TargetRtpPpm: percentToPPM(req.TargetRTPPercent),
PoolRatePpm: percentToPPM(req.PoolRatePercent),
ProfitRatePpm: percentToPPM(req.ProfitRatePercent),
AnchorRatePpm: percentToPPM(req.AnchorRatePercent),
SettlementWindowWager: req.SettlementWindowWager,
ControlBandPpm: percentToPPM(req.ControlBandPercent),
GiftPriceReference: req.GiftPriceReference,
NoviceMaxEquivalentDraws: req.NoviceMaxEquivalentDraws,
NormalMaxEquivalentDraws: req.NormalMaxEquivalentDraws,
EffectiveFromMs: req.EffectiveFromMS,
InitialPoolCoins: initialPoolCoins,
LossStreakGuarantee: req.LossStreakGuarantee,
LowWatermarkCoins: req.LowWatermarkCoins,
LowWaterNonzeroFactorPpm: percentToPPM(req.LowWaterNonzeroFactorPercent),
HighWatermarkCoins: req.HighWatermarkCoins,
HighWaterNonzeroFactorPpm: percentToPPM(req.HighWaterNonzeroFactorPercent),
RechargeBoostWindowMs: req.RechargeBoostWindowMS,
RechargeBoostFactorPpm: percentToPPM(req.RechargeBoostFactorPercent),
User_24HRtpThresholdPpm: percentToPPM(req.User24HourRTPThresholdPercent),
User_24HOrdinaryWinFactorPpm: percentToPPM(user24HourOrdinaryWinFactorPercent),
JackpotMultiplierPpms: multipliersToPPM(req.JackpotMultipliers),
JackpotGlobalRtpMaxPpm: percentToPPM(req.JackpotGlobalRTPMaxPercent),
JackpotUserRoundRtpMaxPpm: percentToPPM(roundRTPMaxPercent),
JackpotUser_48HRtpMaxPpm: percentToPPM(rolling48hRTPMaxPercent),
JackpotSpendThresholdCoins: req.JackpotSpendThresholdCoins,
MaxJackpotHitsPerUserDay: req.MaxJackpotHitsPerUserDay,
MaxSinglePayout: req.MaxSinglePayout,
UserHourlyPayoutCap: req.UserHourlyPayoutCap,
UserDailyPayoutCap: req.UserDailyPayoutCap,
DeviceDailyPayoutCap: req.DeviceDailyPayoutCap,
RoomHourlyPayoutCap: req.RoomHourlyPayoutCap,
AnchorDailyPayoutCap: req.AnchorDailyPayoutCap,
Stages: stages,
}
}
@ -512,46 +580,56 @@ func configFromProto(config *luckygiftv1.LuckyGiftRuleConfig) configDTO {
// 兼容读取迁移前 V3 快照时也展示 0避免运营误以为规则字段仍会自动形成账本资金。
initialPoolCoins = 0
}
user24HourRTPThresholdPercent := ppmToPercent(config.GetUser_24HRtpThresholdPpm())
user24HourOrdinaryWinFactorPercent := ppmToPercent(config.GetUser_24HOrdinaryWinFactorPpm())
if user24HourRTPThresholdPercent == 0 && user24HourOrdinaryWinFactorPercent == 0 {
// 读取迁移前规则或滚动升级中的旧服务时也只返回唯一禁用态,前端无需猜测 0 因子是否表示全禁中。
user24HourOrdinaryWinFactorPercent = 100
}
return configDTO{
RuleVersion: config.GetRuleVersion(),
CreatedByAdminID: config.GetCreatedByAdminId(),
CreatedAtMS: config.GetCreatedAtMs(),
configRequest: configRequest{
AppCode: config.GetAppCode(),
PoolID: poolID,
StrategyVersion: strategyVersion,
Enabled: config.GetEnabled(),
TargetRTPPercent: ppmToPercent(config.GetTargetRtpPpm()),
PoolRatePercent: ppmToPercent(config.GetPoolRatePpm()),
ProfitRatePercent: ppmToPercent(config.GetProfitRatePpm()),
AnchorRatePercent: ppmToPercent(config.GetAnchorRatePpm()),
SettlementWindowWager: config.GetSettlementWindowWager(),
ControlBandPercent: ppmToPercent(config.GetControlBandPpm()),
GiftPriceReference: config.GetGiftPriceReference(),
NoviceMaxEquivalentDraws: config.GetNoviceMaxEquivalentDraws(),
NormalMaxEquivalentDraws: config.GetNormalMaxEquivalentDraws(),
EffectiveFromMS: config.GetEffectiveFromMs(),
InitialPoolCoins: initialPoolCoins,
LossStreakGuarantee: config.GetLossStreakGuarantee(),
LowWatermarkCoins: config.GetLowWatermarkCoins(),
LowWaterNonzeroFactorPercent: ppmToPercent(config.GetLowWaterNonzeroFactorPpm()),
HighWatermarkCoins: config.GetHighWatermarkCoins(),
HighWaterNonzeroFactorPercent: ppmToPercent(config.GetHighWaterNonzeroFactorPpm()),
RechargeBoostWindowMS: config.GetRechargeBoostWindowMs(),
RechargeBoostFactorPercent: ppmToPercent(config.GetRechargeBoostFactorPpm()),
JackpotMultipliers: ppmsToMultipliers(config.GetJackpotMultiplierPpms()),
JackpotGlobalRTPMaxPercent: ppmToPercent(config.GetJackpotGlobalRtpMaxPpm()),
JackpotUserDayRTPMaxPercent: ppmToPercent(config.GetJackpotUserDayRtpMaxPpm()),
JackpotUser72hRTPMaxPercent: ppmToPercent(config.GetJackpotUser_72HRtpMaxPpm()),
JackpotSpendThresholdCoins: config.GetJackpotSpendThresholdCoins(),
MaxJackpotHitsPerUserDay: config.GetMaxJackpotHitsPerUserDay(),
MaxSinglePayout: config.GetMaxSinglePayout(),
UserHourlyPayoutCap: config.GetUserHourlyPayoutCap(),
UserDailyPayoutCap: config.GetUserDailyPayoutCap(),
DeviceDailyPayoutCap: config.GetDeviceDailyPayoutCap(),
RoomHourlyPayoutCap: config.GetRoomHourlyPayoutCap(),
AnchorDailyPayoutCap: config.GetAnchorDailyPayoutCap(),
Stages: stages,
AppCode: config.GetAppCode(),
PoolID: poolID,
StrategyVersion: strategyVersion,
Enabled: config.GetEnabled(),
TargetRTPPercent: ppmToPercent(config.GetTargetRtpPpm()),
PoolRatePercent: ppmToPercent(config.GetPoolRatePpm()),
ProfitRatePercent: ppmToPercent(config.GetProfitRatePpm()),
AnchorRatePercent: ppmToPercent(config.GetAnchorRatePpm()),
SettlementWindowWager: config.GetSettlementWindowWager(),
ControlBandPercent: ppmToPercent(config.GetControlBandPpm()),
GiftPriceReference: config.GetGiftPriceReference(),
NoviceMaxEquivalentDraws: config.GetNoviceMaxEquivalentDraws(),
NormalMaxEquivalentDraws: config.GetNormalMaxEquivalentDraws(),
EffectiveFromMS: config.GetEffectiveFromMs(),
InitialPoolCoins: initialPoolCoins,
LossStreakGuarantee: config.GetLossStreakGuarantee(),
LowWatermarkCoins: config.GetLowWatermarkCoins(),
LowWaterNonzeroFactorPercent: ppmToPercent(config.GetLowWaterNonzeroFactorPpm()),
HighWatermarkCoins: config.GetHighWatermarkCoins(),
HighWaterNonzeroFactorPercent: ppmToPercent(config.GetHighWaterNonzeroFactorPpm()),
RechargeBoostWindowMS: config.GetRechargeBoostWindowMs(),
RechargeBoostFactorPercent: ppmToPercent(config.GetRechargeBoostFactorPpm()),
User24HourRTPThresholdPercent: user24HourRTPThresholdPercent,
User24HourOrdinaryWinFactorPercent: user24HourOrdinaryWinFactorPercent,
JackpotMultipliers: ppmsToMultipliers(config.GetJackpotMultiplierPpms()),
JackpotGlobalRTPMaxPercent: ppmToPercent(config.GetJackpotGlobalRtpMaxPpm()),
JackpotUserRoundRTPMaxPercent: ppmToPercent(config.GetJackpotUserRoundRtpMaxPpm()),
JackpotUser48hRTPMaxPercent: ppmToPercent(config.GetJackpotUser_48HRtpMaxPpm()),
JackpotUserDayRTPMaxPercent: ppmToPercent(config.GetJackpotUserRoundRtpMaxPpm()),
JackpotUser72hRTPMaxPercent: ppmToPercent(config.GetJackpotUser_48HRtpMaxPpm()),
JackpotSpendThresholdCoins: config.GetJackpotSpendThresholdCoins(),
MaxJackpotHitsPerUserDay: config.GetMaxJackpotHitsPerUserDay(),
MaxSinglePayout: config.GetMaxSinglePayout(),
UserHourlyPayoutCap: config.GetUserHourlyPayoutCap(),
UserDailyPayoutCap: config.GetUserDailyPayoutCap(),
DeviceDailyPayoutCap: config.GetDeviceDailyPayoutCap(),
RoomHourlyPayoutCap: config.GetRoomHourlyPayoutCap(),
AnchorDailyPayoutCap: config.GetAnchorDailyPayoutCap(),
Stages: stages,
},
}
}

View File

@ -0,0 +1,604 @@
package luckygift
import (
"context"
"fmt"
"strconv"
"strings"
"hyapp-admin-server/internal/appctx"
"hyapp-admin-server/internal/integration/userclient"
"hyapp-admin-server/internal/middleware"
"hyapp-admin-server/internal/modules/shared"
"hyapp-admin-server/internal/response"
luckygiftv1 "hyapp.local/api/proto/luckygift/v1"
"github.com/gin-gonic/gin"
"google.golang.org/grpc/codes"
grpcstatus "google.golang.org/grpc/status"
)
type luckyGiftInternalUserDTO struct {
// UserID 使用字符串返回,避免浏览器把 64 位长号转成 JavaScript number 后丢失末位精度。
UserID string `json:"user_id"`
DisplayUserID string `json:"display_user_id"`
DefaultDisplayID string `json:"default_display_user_id"`
PrettyID string `json:"pretty_id"`
PrettyDisplayUserID string `json:"pretty_display_user_id"`
Name string `json:"name"`
Avatar string `json:"avatar"`
Status string `json:"status"`
}
type luckyGiftUserProfileWindowDTO struct {
Draws int64 `json:"draws"`
WagerCoins int64 `json:"wager_coins"`
PayoutCoins int64 `json:"payout_coins"`
NetCoins int64 `json:"net_coins"`
RTPPPM int64 `json:"rtp_ppm"`
HasRTP bool `json:"has_rtp"`
}
type luckyGiftUserProfileDTO struct {
AppCode string `json:"app_code"`
PoolID string `json:"pool_id"`
IdentityType string `json:"identity_type"`
InternalUserID string `json:"internal_user_id,omitempty"`
ExternalUserID string `json:"external_user_id,omitempty"`
InternalUser *luckyGiftInternalUserDTO `json:"internal_user,omitempty"`
RuleVersion int64 `json:"rule_version"`
StrategyVersion string `json:"strategy_version"`
Stage string `json:"stage"`
PaidDraws int64 `json:"paid_draws"`
EquivalentDraws int64 `json:"equivalent_draws"`
LossStreak int64 `json:"loss_streak"`
PendingSpendJackpotTokens int64 `json:"pending_spend_jackpot_tokens"`
GuaranteeDrawsRemaining int64 `json:"guarantee_draws_remaining"`
DownweightActive bool `json:"downweight_active"`
DownweightFactorPPM int64 `json:"downweight_factor_ppm"`
User24HourRTPThresholdPPM int64 `json:"user_24h_rtp_threshold_ppm"`
CurrentRTPPPM int64 `json:"current_rtp_ppm"`
HasCurrentRTP bool `json:"has_current_rtp"`
OneHour luckyGiftUserProfileWindowDTO `json:"one_hour"`
TwentyFourHours luckyGiftUserProfileWindowDTO `json:"twenty_four_hours"`
FortyEightHours luckyGiftUserProfileWindowDTO `json:"forty_eight_hours"`
Lifetime luckyGiftUserProfileWindowDTO `json:"lifetime"`
BaseRewardCoins int64 `json:"base_reward_coins"`
OrdinaryWinCount int64 `json:"ordinary_win_count"`
HighMultiplierOrdinaryWinCount int64 `json:"high_multiplier_ordinary_win_count"`
RTPCompensationJackpotCount int64 `json:"rtp_compensation_jackpot_count"`
CumulativeSpendJackpotCount int64 `json:"cumulative_spend_jackpot_count"`
JackpotWinCount int64 `json:"jackpot_win_count"`
GuaranteeHitCount int64 `json:"guarantee_hit_count"`
ZeroDrawCount int64 `json:"zero_draw_count"`
GrantedDrawCount int64 `json:"granted_draw_count"`
PendingDrawCount int64 `json:"pending_draw_count"`
FailedDrawCount int64 `json:"failed_draw_count"`
MaxMultiplierPPM int64 `json:"max_multiplier_ppm"`
MaxRewardCoins int64 `json:"max_reward_coins"`
FirstDrawAtMS int64 `json:"first_draw_at_ms"`
LastDrawAtMS int64 `json:"last_draw_at_ms"`
AggregatedAtMS int64 `json:"aggregated_at_ms"`
DataLagMS int64 `json:"data_lag_ms"`
}
type luckyGiftUserProfilePageDTO struct {
Items []luckyGiftUserProfileDTO `json:"items"`
Page int `json:"page"`
PageSize int `json:"page_size"`
Total int64 `json:"total"`
SnapshotAtMS int64 `json:"snapshot_at_ms"`
NextCursor string `json:"next_cursor,omitempty"`
HasMore bool `json:"has_more"`
}
type luckyGiftUserMultiplierStatDTO struct {
HitType string `json:"hit_type"`
HitTypeLabel string `json:"hit_type_label"`
MultiplierPPM int64 `json:"multiplier_ppm"`
DrawCount int64 `json:"draw_count"`
WagerCoins int64 `json:"wager_coins"`
PayoutCoins int64 `json:"payout_coins"`
}
type luckyGiftUserJackpotConditionDTO struct {
Name string `json:"name"`
Label string `json:"label"`
Actual string `json:"actual"`
Threshold string `json:"threshold"`
Operator string `json:"operator"`
Numerator int64 `json:"numerator"`
Denominator int64 `json:"denominator"`
RatioPPM int64 `json:"ratio_ppm"`
LimitPPM int64 `json:"limit_ppm"`
FactorPPM int64 `json:"factor_ppm"`
Factor string `json:"factor"`
Passed bool `json:"passed"`
Reason string `json:"reason"`
}
type luckyGiftUserJackpotHitDTO struct {
EventKey string `json:"event_key"`
DrawID string `json:"draw_id"`
RequestID string `json:"request_id"`
RuleVersion int64 `json:"rule_version"`
Stage string `json:"stage"`
Mechanism string `json:"mechanism"`
MechanismLabel string `json:"mechanism_label"`
ReasonCode string `json:"reason_code"`
ReasonSummary string `json:"reason_summary"`
TierID string `json:"tier_id"`
MultiplierPPM int64 `json:"multiplier_ppm"`
WagerCoins int64 `json:"wager_coins"`
PayoutCoins int64 `json:"payout_coins"`
OccurredAtMS int64 `json:"occurred_at_ms"`
Conditions []luckyGiftUserJackpotConditionDTO `json:"conditions"`
}
type luckyGiftUserProfileDetailDTO struct {
Profile luckyGiftUserProfileDTO `json:"profile"`
MultiplierDistribution []luckyGiftUserMultiplierStatDTO `json:"multiplier_distribution"`
JackpotHits []luckyGiftUserJackpotHitDTO `json:"jackpot_hits"`
JackpotHitTotal int64 `json:"jackpot_hit_total"`
JackpotPage int32 `json:"jackpot_page"`
JackpotPageSize int32 `json:"jackpot_page_size"`
NextJackpotCursor string `json:"next_jackpot_cursor,omitempty"`
JackpotHasMore bool `json:"jackpot_has_more"`
SnapshotAtMS int64 `json:"snapshot_at_ms"`
}
// ListLuckyGiftUserProfiles 查询已经由 lucky-gift-service 增量物化的用户画像。列表只按 app+pool
// 读取聚合表;内部账号的短号/靓号解析和资料补全统一走 user-service不在 HTTP 请求里扫描开奖事实表。
func (h *Handler) ListLuckyGiftUserProfiles(c *gin.Context) {
appCode, poolID, ok := requiredLuckyGiftProfileScope(c)
if !ok {
return
}
identityType, ok := normalizeLuckyGiftProfileIdentity(firstNonEmpty(c.Query("identity_type"), c.Query("source")))
if !ok {
response.BadRequest(c, "identity_type must be all, internal or external")
return
}
options := shared.ListOptions(c)
if options.PageSize > 100 {
options.PageSize = 100
}
ctx, cancel := h.luckyGiftContext(c)
defer cancel()
ctx = appctx.WithContext(ctx, appCode)
internalUserID := parseOptionalInt64(firstNonEmpty(c.Query("internal_user_id"), c.Query("user_id")))
externalUserID := strings.TrimSpace(c.Query("external_user_id"))
keyword := strings.TrimSpace(c.Query("user_keyword"))
if keyword != "" {
resolvedInternalID, resolvedExternalID, internalMatched, err := h.resolveLuckyGiftProfileKeyword(ctx, appCode, identityType, keyword, middleware.CurrentRequestID(c))
if err != nil {
response.ServerError(c, "搜索用户失败")
return
}
internalUserID, externalUserID = resolvedInternalID, resolvedExternalID
if identityType == "internal" && !internalMatched {
// 未解析的内部短号/长号/靓号必须返回空页,不能把“查无此人”退化为无条件全量列表。
response.OK(c, luckyGiftUserProfilePageDTO{Items: []luckyGiftUserProfileDTO{}, Page: options.Page, PageSize: options.PageSize})
return
}
}
resp, err := h.luckyGift.ListLuckyGiftUserProfiles(ctx, &luckygiftv1.ListLuckyGiftUserProfilesRequest{
Meta: h.metaForApp(c, appCode),
PoolId: poolID,
IdentityType: identityType,
InternalUserId: internalUserID,
ExternalUserId: externalUserID,
Stage: strings.TrimSpace(c.Query("stage")),
Status: strings.TrimSpace(c.Query("status")),
SortBy: strings.TrimSpace(c.Query("sort_by")),
SortDirection: strings.TrimSpace(c.Query("sort_direction")),
Page: int32(options.Page),
PageSize: int32(options.PageSize),
Cursor: strings.TrimSpace(c.Query("cursor")),
})
if err != nil {
h.writeLuckyGiftUserProfileError(c, err, "获取幸运礼物用户画像失败")
return
}
users, err := h.loadLuckyGiftInternalUsers(ctx, resp.GetProfiles(), middleware.CurrentRequestID(c))
if err != nil {
response.ServerError(c, "获取用户资料失败")
return
}
items := make([]luckyGiftUserProfileDTO, 0, len(resp.GetProfiles()))
for _, profile := range resp.GetProfiles() {
items = append(items, luckyGiftUserProfileFromProto(profile, users[profile.GetInternalUserId()]))
}
response.OK(c, luckyGiftUserProfilePageDTO{
Items: items, Page: options.Page, PageSize: options.PageSize,
Total: resp.GetTotal(), SnapshotAtMS: resp.GetSnapshotAtMs(),
NextCursor: resp.GetNextCursor(), HasMore: resp.GetHasMore(),
})
}
// GetLuckyGiftUserProfile 返回单个用户的倍率分布和大奖解释。大奖明细只在展开行时请求,
// 避免列表页同时解码所有用户的条件 trace保证高密度页面仍保持稳定响应时间。
func (h *Handler) GetLuckyGiftUserProfile(c *gin.Context) {
appCode, poolID, ok := requiredLuckyGiftProfileScope(c)
if !ok {
return
}
identityType, ok := normalizeLuckyGiftProfileIdentity(firstNonEmpty(c.Query("identity_type"), c.Query("source")))
if !ok || identityType == "" {
response.BadRequest(c, "identity_type must be internal or external")
return
}
ctx, cancel := h.luckyGiftContext(c)
defer cancel()
ctx = appctx.WithContext(ctx, appCode)
internalUserID := parseOptionalInt64(firstNonEmpty(c.Query("internal_user_id"), c.Query("user_id")))
externalUserID := strings.TrimSpace(c.Query("external_user_id"))
if keyword := strings.TrimSpace(c.Query("user_keyword")); keyword != "" {
resolvedInternalID, resolvedExternalID, internalMatched, err := h.resolveLuckyGiftProfileKeyword(ctx, appCode, identityType, keyword, middleware.CurrentRequestID(c))
if err != nil {
response.ServerError(c, "搜索用户失败")
return
}
internalUserID, externalUserID = resolvedInternalID, resolvedExternalID
if identityType == "internal" && !internalMatched {
response.NotFound(c, "未找到该用户的幸运礼物画像")
return
}
}
if (identityType == "internal" && internalUserID <= 0) || (identityType == "external" && externalUserID == "") {
response.BadRequest(c, "internal_user_id or external_user_id is required")
return
}
jackpotPage := parseLuckyGiftProfilePositiveInt(c.Query("jackpot_page"), 1, 1_000_000)
jackpotPageSize := parseLuckyGiftProfilePositiveInt(c.Query("jackpot_page_size"), 20, 50)
resp, err := h.luckyGift.GetLuckyGiftUserProfile(ctx, &luckygiftv1.GetLuckyGiftUserProfileRequest{
Meta: h.metaForApp(c, appCode), PoolId: poolID, IdentityType: identityType,
InternalUserId: internalUserID, ExternalUserId: externalUserID,
JackpotPage: int32(jackpotPage), JackpotPageSize: int32(jackpotPageSize),
JackpotCursor: strings.TrimSpace(c.Query("jackpot_cursor")),
})
if err != nil {
h.writeLuckyGiftUserProfileError(c, err, "获取幸运礼物用户画像详情失败")
return
}
users, err := h.loadLuckyGiftInternalUsers(ctx, []*luckygiftv1.LuckyGiftUserProfile{resp.GetProfile()}, middleware.CurrentRequestID(c))
if err != nil {
response.ServerError(c, "获取用户资料失败")
return
}
detail := luckyGiftUserProfileDetailDTO{
Profile: luckyGiftUserProfileFromProto(resp.GetProfile(), users[resp.GetProfile().GetInternalUserId()]),
MultiplierDistribution: make([]luckyGiftUserMultiplierStatDTO, 0, len(resp.GetMultiplierDistribution())),
JackpotHits: make([]luckyGiftUserJackpotHitDTO, 0, len(resp.GetJackpotHits())),
JackpotHitTotal: resp.GetJackpotHitTotal(), JackpotPage: resp.GetJackpotPage(),
JackpotPageSize: resp.GetJackpotPageSize(), NextJackpotCursor: resp.GetNextJackpotCursor(),
JackpotHasMore: resp.GetJackpotHasMore(), SnapshotAtMS: resp.GetSnapshotAtMs(),
}
for _, stat := range resp.GetMultiplierDistribution() {
detail.MultiplierDistribution = append(detail.MultiplierDistribution, luckyGiftUserMultiplierStatFromProto(stat))
}
for _, hit := range resp.GetJackpotHits() {
detail.JackpotHits = append(detail.JackpotHits, luckyGiftUserJackpotHitFromProto(hit))
}
response.OK(c, detail)
}
func parseLuckyGiftProfilePositiveInt(value string, fallback, maximum int) int {
parsed, err := strconv.Atoi(strings.TrimSpace(value))
if err != nil || parsed <= 0 {
return fallback
}
if parsed > maximum {
return maximum
}
return parsed
}
func requiredLuckyGiftProfileScope(c *gin.Context) (string, string, bool) {
appCode := strings.ToLower(strings.TrimSpace(c.Query("app_code")))
poolID := strings.TrimSpace(c.Query("pool_id"))
if appCode == "" || poolID == "" {
response.BadRequest(c, "app_code and pool_id are required")
return "", "", false
}
return appCode, poolID, true
}
func normalizeLuckyGiftProfileIdentity(value string) (string, bool) {
switch strings.ToLower(strings.TrimSpace(value)) {
case "", "all":
// owner 服务用空值表达“内部与外部”,避免引入第三种持久身份类型。
return "", true
case "internal", "external":
return strings.ToLower(strings.TrimSpace(value)), true
default:
return "", false
}
}
func (h *Handler) resolveLuckyGiftProfileKeyword(ctx context.Context, appCode, identityType, keyword, requestID string) (int64, string, bool, error) {
var externalUserID string
if identityType == "" || identityType == "external" {
// 外部接入没有 HyApp 用户主数据,原样按 external_user_id 精确匹配;稳定策略哈希永不出 owner 服务。
externalUserID = keyword
}
if identityType == "external" {
return 0, externalUserID, false, nil
}
if h.users == nil {
return 0, externalUserID, false, fmt.Errorf("lucky gift user profile resolver is not configured")
}
identity, err := h.users.ResolveAdminUserIdentifier(appctx.WithContext(ctx, appCode), userclient.ResolveAdminUserIdentifierRequest{
RequestID: requestID, Caller: "admin-server", UserIdentifier: keyword,
})
if err == nil && identity != nil && identity.UserID > 0 {
return identity.UserID, externalUserID, true, nil
}
if err != nil {
switch grpcstatus.Code(err) {
case codes.NotFound, codes.InvalidArgument:
// all 模式下 keyword 也可能是任意 external_user_id内部展示号格式不合法只代表内部未命中不能阻断外部精确搜索。
return 0, externalUserID, false, nil
default:
return 0, externalUserID, false, err
}
}
return 0, externalUserID, false, nil
}
func (h *Handler) loadLuckyGiftInternalUsers(ctx context.Context, profiles []*luckygiftv1.LuckyGiftUserProfile, requestID string) (map[int64]*userclient.User, error) {
ids := make([]int64, 0, len(profiles))
seen := make(map[int64]struct{}, len(profiles))
for _, profile := range profiles {
if profile == nil || profile.GetIdentityType() != "internal" || profile.GetInternalUserId() <= 0 {
continue
}
if _, exists := seen[profile.GetInternalUserId()]; exists {
continue
}
seen[profile.GetInternalUserId()] = struct{}{}
ids = append(ids, profile.GetInternalUserId())
}
if len(ids) == 0 {
return map[int64]*userclient.User{}, nil
}
if h.users == nil {
return nil, fmt.Errorf("lucky gift user profile user client is not configured")
}
return h.users.BatchGetUsers(ctx, userclient.BatchGetUsersRequest{RequestID: requestID, Caller: "admin-server", UserIDs: ids})
}
func luckyGiftUserProfileFromProto(profile *luckygiftv1.LuckyGiftUserProfile, user *userclient.User) luckyGiftUserProfileDTO {
if profile == nil {
return luckyGiftUserProfileDTO{}
}
jackpotCount := profile.GetRtpCompensationJackpotCount() + profile.GetCumulativeSpendJackpotCount()
dto := luckyGiftUserProfileDTO{
AppCode: profile.GetAppCode(), PoolID: profile.GetPoolId(), IdentityType: profile.GetIdentityType(),
RuleVersion: profile.GetRuleVersion(),
StrategyVersion: profile.GetStrategyVersion(), Stage: profile.GetStage(), PaidDraws: profile.GetPaidDraws(),
EquivalentDraws: profile.GetEquivalentDraws(), LossStreak: profile.GetLossStreak(),
PendingSpendJackpotTokens: profile.GetPendingSpendJackpotTokens(), GuaranteeDrawsRemaining: profile.GetGuaranteeDrawsRemaining(),
DownweightActive: profile.GetDownweightActive(), DownweightFactorPPM: profile.GetDownweightFactorPpm(),
User24HourRTPThresholdPPM: profile.GetUser_24HRtpThresholdPpm(),
OneHour: luckyGiftUserProfileWindowFromProto(profile.GetOneHour()),
TwentyFourHours: luckyGiftUserProfileWindowFromProto(profile.GetTwentyFourHours()),
FortyEightHours: luckyGiftUserProfileWindowFromProto(profile.GetFortyEightHours()),
Lifetime: luckyGiftUserProfileWindowFromProto(profile.GetLifetime()),
BaseRewardCoins: profile.GetBaseRewardCoins(), OrdinaryWinCount: profile.GetOrdinaryWinCount(),
HighMultiplierOrdinaryWinCount: profile.GetHighMultiplierOrdinaryWinCount(),
RTPCompensationJackpotCount: profile.GetRtpCompensationJackpotCount(),
CumulativeSpendJackpotCount: profile.GetCumulativeSpendJackpotCount(), JackpotWinCount: jackpotCount,
GuaranteeHitCount: profile.GetGuaranteeHitCount(), ZeroDrawCount: profile.GetZeroDrawCount(),
GrantedDrawCount: profile.GetGrantedDrawCount(), PendingDrawCount: profile.GetPendingDrawCount(), FailedDrawCount: profile.GetFailedDrawCount(),
MaxMultiplierPPM: profile.GetMaxMultiplierPpm(), MaxRewardCoins: profile.GetMaxRewardCoins(),
FirstDrawAtMS: profile.GetFirstDrawAtMs(), LastDrawAtMS: profile.GetLastDrawAtMs(),
AggregatedAtMS: profile.GetAggregatedAtMs(), DataLagMS: profile.GetDataLagMs(),
CurrentRTPPPM: profile.GetTwentyFourHours().GetRtpPpm(), HasCurrentRTP: profile.GetTwentyFourHours().GetHasRtp(),
}
if profile.GetIdentityType() == "internal" && profile.GetInternalUserId() > 0 {
// 外部画像即使 owner 发生协议回归误填了 internal_user_id也不得在 admin HTTP 边界透出策略哈希。
dto.InternalUserID = strconv.FormatInt(profile.GetInternalUserId(), 10)
dto.InternalUser = luckyGiftInternalUserFromClient(profile.GetInternalUserId(), user)
} else if profile.GetIdentityType() == "external" {
dto.ExternalUserID = profile.GetExternalUserId()
}
return dto
}
func luckyGiftInternalUserFromClient(userID int64, user *userclient.User) *luckyGiftInternalUserDTO {
dto := &luckyGiftInternalUserDTO{UserID: strconv.FormatInt(userID, 10)}
if user == nil {
return dto
}
dto.DisplayUserID = user.DisplayUserID
dto.DefaultDisplayID = user.DefaultDisplayUserID
dto.PrettyID = user.PrettyID
dto.PrettyDisplayUserID = user.PrettyDisplayUserID
dto.Name = user.Username
dto.Avatar = user.Avatar
dto.Status = user.Status
return dto
}
func luckyGiftUserProfileWindowFromProto(window *luckygiftv1.LuckyGiftUserProfileWindow) luckyGiftUserProfileWindowDTO {
if window == nil {
return luckyGiftUserProfileWindowDTO{}
}
return luckyGiftUserProfileWindowDTO{
Draws: window.GetDraws(), WagerCoins: window.GetWagerCoins(), PayoutCoins: window.GetPayoutCoins(),
NetCoins: window.GetNetCoins(), RTPPPM: window.GetRtpPpm(), HasRTP: window.GetHasRtp(),
}
}
func luckyGiftUserMultiplierStatFromProto(stat *luckygiftv1.LuckyGiftUserMultiplierStat) luckyGiftUserMultiplierStatDTO {
if stat == nil {
return luckyGiftUserMultiplierStatDTO{}
}
return luckyGiftUserMultiplierStatDTO{
HitType: stat.GetHitType(), HitTypeLabel: luckyGiftHitTypeLabel(stat.GetHitType()),
MultiplierPPM: stat.GetMultiplierPpm(), DrawCount: stat.GetDrawCount(),
WagerCoins: stat.GetWagerCoins(), PayoutCoins: stat.GetPayoutCoins(),
}
}
func luckyGiftUserJackpotHitFromProto(hit *luckygiftv1.LuckyGiftUserJackpotHit) luckyGiftUserJackpotHitDTO {
if hit == nil {
return luckyGiftUserJackpotHitDTO{}
}
conditions := make([]luckyGiftUserJackpotConditionDTO, 0, len(hit.GetConditions()))
for _, condition := range hit.GetConditions() {
conditions = append(conditions, luckyGiftUserJackpotConditionFromProto(condition))
}
return luckyGiftUserJackpotHitDTO{
EventKey: hit.GetEventKey(), DrawID: hit.GetDrawId(), RequestID: hit.GetRequestId(), RuleVersion: hit.GetRuleVersion(), Stage: hit.GetStage(),
Mechanism: hit.GetMechanism(), MechanismLabel: luckyGiftJackpotMechanismLabel(hit.GetMechanism()),
ReasonCode: hit.GetReasonCode(), ReasonSummary: luckyGiftJackpotReasonSummary(hit), TierID: hit.GetTierId(),
MultiplierPPM: hit.GetMultiplierPpm(), WagerCoins: hit.GetWagerCoins(), PayoutCoins: hit.GetPayoutCoins(),
OccurredAtMS: hit.GetOccurredAtMs(), Conditions: conditions,
}
}
func luckyGiftUserJackpotConditionFromProto(condition *luckygiftv1.LuckyGiftUserJackpotCondition) luckyGiftUserJackpotConditionDTO {
if condition == nil {
return luckyGiftUserJackpotConditionDTO{}
}
actual, threshold, operator := luckyGiftJackpotConditionValues(condition)
return luckyGiftUserJackpotConditionDTO{
Name: condition.GetName(), Label: luckyGiftJackpotConditionLabel(condition.GetName()),
Actual: actual, Threshold: threshold, Operator: operator,
Numerator: condition.GetNumerator(), Denominator: condition.GetDenominator(),
RatioPPM: condition.GetRatioPpm(), LimitPPM: condition.GetLimitPpm(), FactorPPM: condition.GetFactorPpm(),
Factor: luckyGiftPercent(condition.GetFactorPpm()), Passed: condition.GetPassed(), Reason: condition.GetReason(),
}
}
func luckyGiftJackpotConditionValues(condition *luckygiftv1.LuckyGiftUserJackpotCondition) (string, string, string) {
name := condition.GetName()
switch name {
case "global_rtp", "spend_global_rtp", "user_round_rtp", "user_48h_rtp", "user_day_rtp", "user_72h_rtp":
return fmt.Sprintf("%s返奖 %d / 消耗 %d", luckyGiftPercent(condition.GetRatioPpm()), condition.GetNumerator(), condition.GetDenominator()), luckyGiftPercent(condition.GetLimitPpm()), "≤"
case "user_24h_rtp_downweight":
return fmt.Sprintf("%s返奖 %d / 消耗 %d", luckyGiftPercent(condition.GetRatioPpm()), condition.GetNumerator(), condition.GetDenominator()), luckyGiftPercent(condition.GetLimitPpm()), ">"
case "user_registered_48h":
return luckyGiftDurationHours(condition.GetNumerator()), luckyGiftDurationHours(condition.GetDenominator()), "≥"
case "daily_jackpot_limit", "spend_daily_jackpot_limit":
return fmt.Sprintf("%d 次", condition.GetNumerator()), fmt.Sprintf("%d 次", condition.GetLimitPpm()), "<"
case "settled_round_qualification", "payable_jackpot_candidate":
if condition.GetPassed() {
return "已满足", "必须满足", "="
}
return "未满足", "必须满足", "="
default:
if condition.GetLimitPpm() != 0 || condition.GetRatioPpm() != 0 {
return luckyGiftPercent(condition.GetRatioPpm()), luckyGiftPercent(condition.GetLimitPpm()), "≤"
}
if condition.GetPassed() {
return "已满足", "必须满足", "="
}
return "未满足", "必须满足", "="
}
}
func luckyGiftJackpotConditionLabel(name string) string {
labels := map[string]string{
"global_rtp": "全奖池当前返奖率",
"spend_global_rtp": "累计消费大奖检查时的全奖池返奖率",
"settled_round_qualification": "上一结算轮次是否达到补偿条件",
"user_round_rtp": "用户当前结算轮次返奖率",
"user_48h_rtp": "用户近 48 小时返奖率",
"user_registered_48h": "用户注册是否已满 48 小时",
"user_24h_rtp_downweight": "用户近 24 小时普通中奖降权",
"user_day_rtp": "用户当天返奖率",
"user_72h_rtp": "用户近 72 小时返奖率",
"daily_jackpot_limit": "用户当天已中大奖次数",
"spend_daily_jackpot_limit": "累计消费大奖的当天次数限制",
"payable_jackpot_candidate": "奖池余额是否足够支付所选大奖",
}
if label := labels[name]; label != "" {
return label
}
return strings.ReplaceAll(strings.TrimSpace(name), "_", " ")
}
func luckyGiftJackpotMechanismLabel(mechanism string) string {
switch mechanism {
case "rtp_compensation", "jackpot_mechanism_1":
return "RTP 补偿大奖"
case "spend_milestone", "jackpot_mechanism_2":
return "累计消费大奖"
default:
return "大奖"
}
}
func luckyGiftJackpotReasonSummary(hit *luckygiftv1.LuckyGiftUserJackpotHit) string {
switch hit.GetReasonCode() {
case "rtp_compensation_jackpot":
return "用户与奖池均满足补偿条件,且奖池余额足够支付,因此触发 RTP 补偿大奖。"
case "milestone_token_jackpot":
return "用户累计消费已获得一次大奖机会,且奖池余额足够支付,因此触发累计消费大奖。"
}
if summary := strings.TrimSpace(hit.GetReasonSummary()); summary != "" {
return summary
}
return "本次开奖满足下列全部条件,因此命中该大奖。"
}
func luckyGiftHitTypeLabel(hitType string) string {
switch hitType {
case "zero":
return "未中奖"
case "ordinary":
return "普通奖"
case "rtp_compensation":
return "RTP 补偿大奖"
case "spend_milestone":
return "累计消费大奖"
default:
return hitType
}
}
func luckyGiftPercent(ppm int64) string {
value := strconv.FormatFloat(float64(ppm)/10_000, 'f', 2, 64)
value = strings.TrimRight(strings.TrimRight(value, "0"), ".")
if value == "" {
value = "0"
}
return value + "%"
}
func luckyGiftDurationHours(milliseconds int64) string {
value := strconv.FormatFloat(float64(milliseconds)/3_600_000, 'f', 1, 64)
value = strings.TrimRight(strings.TrimRight(value, "0"), ".")
return value + " 小时"
}
func firstNonEmpty(values ...string) string {
for _, value := range values {
if strings.TrimSpace(value) != "" {
return value
}
}
return ""
}
func (h *Handler) writeLuckyGiftUserProfileError(c *gin.Context, err error, fallback string) {
status := grpcstatus.Convert(err)
switch status.Code() {
case codes.InvalidArgument:
response.BadRequest(c, status.Message())
case codes.NotFound:
response.NotFound(c, "未找到该用户的幸运礼物画像")
default:
response.ServerError(c, fallback)
}
}

View File

@ -24,6 +24,8 @@ func RegisterRoutes(protected *gin.RouterGroup, h *Handler) {
group.GET("/lucky-gifts/configs", middleware.RequirePermission("lucky-gift:view"), h.luckyGift.ListLuckyGiftConfigs)
group.GET("/lucky-gifts/draws", middleware.RequirePermission("lucky-gift:view"), h.luckyGift.ListLuckyGiftDraws)
group.GET("/lucky-gifts/summary", middleware.RequirePermission("lucky-gift:view"), h.luckyGift.GetLuckyGiftDrawSummary)
group.GET("/lucky-gifts/user-profiles", middleware.RequirePermission("lucky-gift:view"), h.luckyGift.ListLuckyGiftUserProfiles)
group.GET("/lucky-gifts/user-profile", middleware.RequirePermission("lucky-gift:view"), h.luckyGift.GetLuckyGiftUserProfile)
group.GET("/lucky-gifts/pools", middleware.RequirePermission("lucky-gift:view"), h.luckyGift.ListLuckyGiftPoolBalances)
group.POST("/lucky-gifts/pools/credit", middleware.RequirePermission("lucky-gift:pool-credit"), h.luckyGift.CreditLuckyGiftPoolBalance)
group.POST("/lucky-gifts/pools/debit", middleware.RequirePermission("lucky-gift:pool-debit"), h.luckyGift.DebitLuckyGiftPoolBalance)

View File

@ -15,12 +15,15 @@ type SendCommand struct {
RequestID string
ExternalUserID string
DeviceID string
GiftCount int64
UnitAmount int64
TotalAmount int64
Currency string
MetadataJSON string
PaidAtMS int64
// UserRegisteredAtMS 是外部 App 用户 owner 提供的账号创建事实integration 层只原样透传,
// 不用网关收包时间或该用户首次请求时间填充,否则会错误改变完整历史窗口资格。
UserRegisteredAtMS int64
GiftCount int64
UnitAmount int64
TotalAmount int64
Currency string
MetadataJSON string
PaidAtMS int64
}
type SendResult struct {
@ -58,16 +61,17 @@ func (c *GRPCClient) SendLuckyGift(ctx context.Context, cmd SendCommand) (SendRe
SentAtMs: time.Now().UTC().UnixMilli(),
AppCode: cmd.AppCode,
},
AppCode: cmd.AppCode,
ExternalUserId: cmd.ExternalUserID,
DeviceId: cmd.DeviceID,
RequestId: cmd.RequestID,
GiftCount: cmd.GiftCount,
UnitAmount: cmd.UnitAmount,
TotalAmount: cmd.TotalAmount,
Currency: cmd.Currency,
PaidAtMs: cmd.PaidAtMS,
MetadataJson: cmd.MetadataJSON,
AppCode: cmd.AppCode,
ExternalUserId: cmd.ExternalUserID,
DeviceId: cmd.DeviceID,
UserRegisteredAtMs: cmd.UserRegisteredAtMS,
RequestId: cmd.RequestID,
GiftCount: cmd.GiftCount,
UnitAmount: cmd.UnitAmount,
TotalAmount: cmd.TotalAmount,
Currency: cmd.Currency,
PaidAtMs: cmd.PaidAtMS,
MetadataJson: cmd.MetadataJSON,
})
if err != nil {
return SendResult{}, err

View File

@ -35,11 +35,14 @@ type sendRequest struct {
DeviceID string `json:"device_id"`
// PaidAtMS 是外部 App 自己完成扣费的 UTC epoch ms调用方必须把它纳入自己的认证/签名边界。
// gateway 不用到达时间替代。fixed_v2 允许 0 兼容dynamic_v3 由 owner 读取规则后强制要求正值。
PaidAtMS int64 `json:"paid_at_ms"`
GiftCount int64 `json:"gift_count"`
UnitAmount int64 `json:"unit_amount"`
Currency string `json:"currency"`
Metadata map[string]any `json:"metadata"`
PaidAtMS int64 `json:"paid_at_ms"`
// UserRegisteredAtMS 是外部 App 用户主数据中的账号创建时间,不是本次请求首次出现时间。
// fixed_v2 允许 0 兼容旧接入dynamic_v3 缺失时由 owner 让补偿大奖资格 fail-close。
UserRegisteredAtMS int64 `json:"user_registered_at_ms"`
GiftCount int64 `json:"gift_count"`
UnitAmount int64 `json:"unit_amount"`
Currency string `json:"currency"`
Metadata map[string]any `json:"metadata"`
}
func (h *Handler) Send(w http.ResponseWriter, r *http.Request) {
@ -64,16 +67,17 @@ func (h *Handler) Send(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), h.requestTimeout)
defer cancel()
result, err := h.client.SendLuckyGift(ctx, luckygiftclient.SendCommand{
TraceRequestID: requestID,
AppCode: appCode,
RequestID: strings.TrimSpace(req.RequestID),
ExternalUserID: strings.TrimSpace(req.ExternalUserID),
DeviceID: strings.TrimSpace(req.DeviceID),
GiftCount: req.GiftCount,
UnitAmount: req.UnitAmount,
TotalAmount: req.GiftCount * req.UnitAmount,
Currency: strings.TrimSpace(req.Currency),
MetadataJSON: metadataJSON(req.Metadata),
TraceRequestID: requestID,
AppCode: appCode,
RequestID: strings.TrimSpace(req.RequestID),
ExternalUserID: strings.TrimSpace(req.ExternalUserID),
DeviceID: strings.TrimSpace(req.DeviceID),
UserRegisteredAtMS: req.UserRegisteredAtMS,
GiftCount: req.GiftCount,
UnitAmount: req.UnitAmount,
TotalAmount: req.GiftCount * req.UnitAmount,
Currency: strings.TrimSpace(req.Currency),
MetadataJSON: metadataJSON(req.Metadata),
// pool 仍由 owner 默认规则选择paid_at_ms 则必须保持外部账务事实,不能改写成 gateway 收包时间。
PaidAtMS: req.PaidAtMS,
})
@ -109,8 +113,11 @@ func (r sendRequest) validate() error {
if r.PaidAtMS < 0 {
return errors.New("paid_at_ms is invalid")
}
// 不在这里统一要求 device_id网关无法获知本次命中的不可变规则是 fixed_v2 还是 dynamic_v3。
// owner 读到规则后对 dynamic_v3 权威拒绝空值fixed_v2 继续兼容旧调用方。
if r.UserRegisteredAtMS < 0 {
return errors.New("user_registered_at_ms is invalid")
}
// 不在这里统一要求 device_id/user_registered_at_ms网关无法获知本次命中的不可变规则是 fixed_v2 还是 dynamic_v3。
// owner 读到规则后对 dynamic_v3 权威处理缺失事实fixed_v2 继续兼容旧调用方。
if r.GiftCount <= 0 || r.UnitAmount <= 0 {
return errors.New("gift_count and unit_amount must be positive")
}

View File

@ -2030,13 +2030,16 @@ func (h *Handler) handleSendGift(writer http.ResponseWriter, request *http.Reque
firstTargetScope := firstGiftTargetHostScope(targetHostScopes, firstUserID(targetUserIDs))
resp, err := h.roomClient.SendGift(request.Context(), &roomv1.SendGiftRequest{
Meta: httpkit.RoomMeta(request, roomID, commandID),
TargetType: targetType,
TargetUserIds: targetUserIDs,
TargetUserId: firstUserID(targetUserIDs),
GiftId: body.GiftID,
GiftCount: body.GiftCount,
PoolId: body.PoolID,
Meta: httpkit.RoomMeta(request, roomID, commandID),
TargetType: targetType,
TargetUserIds: targetUserIDs,
TargetUserId: firstUserID(targetUserIDs),
GiftId: body.GiftID,
GiftCount: body.GiftCount,
PoolId: body.PoolID,
// 注册时间属于 user-service 主数据快照;只允许 gateway 从已鉴权 sender 资料注入,
// room/lucky-gift 后续据此判断账号是否已跨过完整历史窗口,不能信任客户端自报。
SenderRegisteredAtMs: userSnapshots.senderRegisteredAtMS,
SenderCountryId: userSnapshots.senderCountryID,
SenderRegionId: userSnapshots.senderRegionID,
TargetIsHost: firstTargetScope.GetTargetIsHost(),
@ -2126,6 +2129,7 @@ func (h *Handler) resolveRoomUserDisplayProfileBestEffort(request *http.Request,
}
type giftUserSnapshots struct {
senderRegisteredAtMS int64
senderCountryID int64
senderRegionID int64
senderDisplayProfile *roomv1.SendGiftDisplayProfile
@ -2161,6 +2165,9 @@ func (h *Handler) resolveGiftUserSnapshots(request *http.Request, targetUserIDs
}
}
return giftUserSnapshots{
// created_at_ms 是账号 owner 返回的稳定事实;即使同一 command_id 重试时资料发生变化,
// room-service 也会恢复首次持久化的命令快照,避免补偿大奖资格随重试漂移。
senderRegisteredAtMS: sender.GetCreatedAtMs(),
senderCountryID: sender.GetCountryId(),
senderRegionID: sender.GetRegionId(),
senderDisplayProfile: giftDisplayProfileFromUser(sender),

View File

@ -92,16 +92,21 @@ func runSimulation(options simulationOptions) simulationReport {
simulateBatchCounts(config, options.Seed),
simulateSettlementWagerWindow(),
simulateWaterBoundaries(config),
simulateUser24HourRTPDownweight(config, options.Seed+5),
simulateUser24HourRTPBatchCrossing(config),
simulateUser24HourRTPMillionDraws(config, options.Seed+6),
simulateRechargeWindowBoundaries(config),
simulateProbabilityMonteCarlo(config, options.Seed+10, options.MonteCarloN),
simulateFundedRTPLongRun(config, options.Seed+20, options.LongRunN),
simulateMechanismOneTruthTable(config, options.Seed+30),
simulateMechanismTwo(config, options.Seed+40),
simulateSettledRoundQualification(config, options.Seed+30),
simulatePoolBlockedQualificationConsumption(config, options.Seed+40),
simulateJackpotSet(config, options.Seed+50),
simulateDailyJackpotLimit(config, options.Seed+60),
simulateDualSourceOverlap(config),
simulateSixRiskCapacities(config),
simulateRechargeStages(config),
simulateCombinationPriority(config, options.Seed+70),
simulateDualJackpotSpendStrategy(config, options.Seed+75),
simulateConcurrencyAndIdempotency(config, options.Seed+80),
simulateLongRunGroups(config, options.Seed+90, options.GroupLongRunN),
}

File diff suppressed because it is too large Load Diff

View File

@ -99,10 +99,14 @@ CREATE TABLE IF NOT EXISTS lucky_gift_rule_versions (
high_water_nonzero_factor_ppm BIGINT NOT NULL DEFAULT 0 COMMENT '高水位非零奖档概率因子ppm',
recharge_boost_window_ms BIGINT NOT NULL DEFAULT 0 COMMENT '最近充值概率加权窗口,毫秒',
recharge_boost_factor_ppm BIGINT NOT NULL DEFAULT 0 COMMENT '最近充值非零奖档概率因子ppm',
user_24h_rtp_threshold_ppm BIGINT NOT NULL DEFAULT 0 COMMENT '用户滚动24小时RTP降权触发线ppm0表示关闭',
user_24h_ordinary_win_factor_ppm BIGINT NOT NULL DEFAULT 1000000 COMMENT '触发后普通非零奖档保留权重ppm1000000表示不降权',
jackpot_multiplier_ppms JSON NOT NULL DEFAULT (JSON_ARRAY()) COMMENT '动态大奖倍率列表1x=1000000',
jackpot_global_rtp_max_ppm BIGINT NOT NULL DEFAULT 0 COMMENT '动态大奖全局 RTP 上限ppm',
jackpot_user_day_rtp_max_ppm BIGINT NOT NULL DEFAULT 0 COMMENT '动态大奖用户当日 RTP 上限ppm',
jackpot_user_72h_rtp_max_ppm BIGINT NOT NULL DEFAULT 0 COMMENT '动态大奖用户滚动 72 小时 RTP 上限ppm',
jackpot_user_day_rtp_max_ppm BIGINT NOT NULL DEFAULT 0 COMMENT '兼容旧实例的用户日 RTP 上限影子列;新代码与轮次列双写',
jackpot_user_round_rtp_max_ppm BIGINT NOT NULL DEFAULT 0 COMMENT '动态大奖用户已结算轮次 RTP 严格上限ppm',
jackpot_user_72h_rtp_max_ppm BIGINT NOT NULL DEFAULT 0 COMMENT '兼容旧实例的用户 72 小时 RTP 上限影子列;新代码与 48 小时列双写',
jackpot_user_48h_rtp_max_ppm BIGINT NOT NULL DEFAULT 0 COMMENT '动态大奖用户滚动 48 小时 RTP 严格上限ppm',
jackpot_spend_threshold_coins BIGINT NOT NULL DEFAULT 0 COMMENT '规则版本动态配置的用户 UTC 日累计消费大奖门槛金币',
max_jackpot_hits_per_user_day BIGINT NOT NULL DEFAULT 0 COMMENT '单用户 UTC 日动态大奖命中上限',
max_single_payout BIGINT NOT NULL DEFAULT 0 COMMENT '单次返奖金币上限',
@ -145,12 +149,37 @@ CREATE TABLE IF NOT EXISTS lucky_rtp_windows (
actual_base_payout_coins BIGINT NOT NULL DEFAULT 0 COMMENT '窗口实际基础返奖',
carry_ppm BIGINT NOT NULL DEFAULT 0 COMMENT '整数除法余数',
status VARCHAR(32) NOT NULL DEFAULT 'open' COMMENT 'open/closed/underpaid',
started_at_ms BIGINT NOT NULL DEFAULT 0 COMMENT '本窗口首笔扣费事实时间UTC epoch ms0 表示历史窗口未知',
last_paid_at_ms BIGINT NOT NULL DEFAULT 0 COMMENT '本窗口末笔扣费事实时间UTC epoch ms0 表示历史窗口未知',
closed_at_ms BIGINT NOT NULL DEFAULT 0 COMMENT '达到结算流水并冻结资格的时间UTC epoch ms未关闭或历史未知为 0',
created_at_ms BIGINT NOT NULL COMMENT '创建时间UTC epoch ms',
updated_at_ms BIGINT NOT NULL COMMENT '更新时间UTC epoch ms',
PRIMARY KEY (app_code, scope_type, scope_id, window_index),
KEY idx_lucky_rtp_windows_open (app_code, scope_type, scope_id, status, window_index)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='幸运礼物 RTP 控制窗口';
-- 每个用户在每个全局结算轮次只保留一行聚合与一次性资格状态。滚动 48 小时合计在闭窗时冻结,
-- 后续同毫秒子抽或延迟使用资格都读取该快照,不会被闭窗后的新事实反向污染。
CREATE TABLE IF NOT EXISTS lucky_user_rtp_windows (
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码',
pool_id VARCHAR(96) NOT NULL COMMENT '幸运礼物奖池 ID',
rule_version BIGINT NOT NULL COMMENT '不可变规则版本;跨版本资格不能继承',
window_index BIGINT NOT NULL COMMENT '对应全局 RTP 结算窗口序号',
user_id BIGINT NOT NULL COMMENT '用户 ID',
user_registered_at_ms BIGINT NOT NULL DEFAULT 0 COMMENT '用户参与本轮时由用户 owner 固化的账号创建时间;缺失时资格关闭',
wager_coins BIGINT NOT NULL DEFAULT 0 COMMENT '用户在本结算轮次的总消耗金币',
payout_coins BIGINT NOT NULL DEFAULT 0 COMMENT '用户在本结算轮次的总返奖金币',
rolling_48h_wager_coins BIGINT NOT NULL DEFAULT 0 COMMENT '闭窗时最近 48 小时总消耗快照',
rolling_48h_payout_coins BIGINT NOT NULL DEFAULT 0 COMMENT '闭窗时最近 48 小时总返奖快照',
qualification_status VARCHAR(16) NOT NULL DEFAULT 'pending' COMMENT 'pending/attempted/ineligible/expired',
attempted_draw_id VARCHAR(128) NOT NULL DEFAULT '' COMMENT '消费本轮资格的下一次子抽 ID未尝试为空',
evaluated_at_ms BIGINT NOT NULL DEFAULT 0 COMMENT '本轮资格判定或消费时间UTC epoch ms',
created_at_ms BIGINT NOT NULL COMMENT '创建时间UTC epoch ms',
updated_at_ms BIGINT NOT NULL COMMENT '更新时间UTC epoch ms',
PRIMARY KEY (app_code, pool_id, rule_version, window_index, user_id),
KEY idx_lucky_user_rtp_windows_pending (app_code, pool_id, rule_version, user_id, qualification_status, window_index)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户按全局结算轮次聚合的 RTP 与一次性大奖资格';
CREATE TABLE IF NOT EXISTS lucky_pools (
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码',
scope_type VARCHAR(32) NOT NULL COMMENT 'pool',
@ -214,10 +243,11 @@ CREATE TABLE IF NOT EXISTS lucky_user_rtp_hour_buckets (
updated_at_ms BIGINT NOT NULL COMMENT '更新时间UTC epoch ms',
PRIMARY KEY (app_code, pool_id, user_id, bucket_hour_ms),
KEY idx_lucky_user_rtp_hour_retention (app_code, bucket_hour_ms)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户滚动72小时RTP完整小时聚合桶';
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户滚动48小时RTP完整小时聚合桶';
-- 小时桶负责完整小时,边界事件只用于精确补齐 [now-72h,now) 两端不足一小时的片段。
-- 这样热路径最多扫描 71 个小时桶和两个边界小时内的用户事件,不回扫全量抽奖事实。
-- 小时桶负责完整小时closed_at_ms 保存实际末笔支付毫秒,查询用 [closed_at+1ms-48h,closed_at+1ms)
-- 纳入触发结算的末笔事实,边界事件只补齐两端不足一小时的片段。
-- 这样闭窗热路径最多扫描 47 个完整小时桶和两个边界小时内的用户事件,不回扫全量抽奖事实。
CREATE TABLE IF NOT EXISTS lucky_user_rtp_boundary_events (
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码',
pool_id VARCHAR(96) NOT NULL COMMENT '幸运礼物奖池 ID',
@ -229,7 +259,7 @@ CREATE TABLE IF NOT EXISTS lucky_user_rtp_boundary_events (
created_at_ms BIGINT NOT NULL COMMENT '创建时间UTC epoch ms',
PRIMARY KEY (app_code, pool_id, user_id, draw_id),
KEY idx_lucky_user_rtp_boundary_range (app_code, pool_id, user_id, occurred_at_ms)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户严格滚动72小时RTP边界事件';
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户严格滚动48小时RTP边界事件';
CREATE TABLE IF NOT EXISTS lucky_user_strategy_days (
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码',
@ -254,7 +284,8 @@ CREATE TABLE IF NOT EXISTS lucky_user_states (
cumulative_wager_coins BIGINT NOT NULL DEFAULT 0 COMMENT '该用户在当前奖池累计消耗金币',
equivalent_draws BIGINT NOT NULL DEFAULT 0 COMMENT '累计金币按规则参考价格折算后的等价抽数',
loss_streak BIGINT NOT NULL DEFAULT 0 COMMENT '连续未获得可见奖励次数,中奖后清零',
pending_jackpot_tokens BIGINT NOT NULL DEFAULT 0 COMMENT '未消费大奖机会按UTC日消费产生资金不足时允许跨日保留',
pending_jackpot_tokens BIGINT NOT NULL DEFAULT 0 COMMENT '旧版消费资格;新版运行时仅惰性清零,永不参与开奖',
pending_spend_jackpot_tokens BIGINT NOT NULL DEFAULT 0 COMMENT '新版累计消费大奖资格受阻时跨轮次和UTC日保留成功开奖仅扣1',
created_at_ms BIGINT NOT NULL COMMENT '创建时间UTC epoch ms',
updated_at_ms BIGINT NOT NULL COMMENT '更新时间UTC epoch ms',
PRIMARY KEY (app_code, user_id, gift_id)
@ -375,6 +406,118 @@ CREATE TABLE IF NOT EXISTS lucky_draw_pool_stat_cursors (
PRIMARY KEY (app_code, cursor_name)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='幸运礼物后台统计快照游标';
CREATE TABLE IF NOT EXISTS lucky_gift_user_profiles (
app_code VARCHAR(32) NOT NULL COMMENT '应用编码',
pool_id VARCHAR(96) NOT NULL COMMENT '幸运礼物奖池 ID',
strategy_version VARCHAR(32) NOT NULL COMMENT 'fixed_v2/dynamic_v3两套奖池画像严格隔离',
identity_type VARCHAR(16) NOT NULL COMMENT 'internal/external',
identity_key VARCHAR(128) COLLATE utf8mb4_bin NOT NULL COMMENT '内部 user_id 文本或外部 external_user_id',
strategy_user_id BIGINT NOT NULL COMMENT '仅供本服务关联策略状态,绝不下发',
internal_user_id BIGINT NOT NULL DEFAULT 0 COMMENT '内部真实 user_id',
external_user_id VARCHAR(128) COLLATE utf8mb4_bin NOT NULL DEFAULT '' COMMENT '外部原始用户 ID',
rule_version BIGINT NOT NULL DEFAULT 0 COMMENT '最近规则版本',
current_stage VARCHAR(32) NOT NULL DEFAULT '' COMMENT '最近阶段',
total_draws BIGINT NOT NULL DEFAULT 0 COMMENT '累计子抽次数',
total_wager_coins BIGINT NOT NULL DEFAULT 0 COMMENT '累计消耗金币',
total_payout_coins BIGINT NOT NULL DEFAULT 0 COMMENT '累计返奖金币',
lifetime_rtp_ppm BIGINT NOT NULL DEFAULT 0 COMMENT '累计 RTP物化后用于无 filesort 游标排序',
total_base_reward_coins BIGINT NOT NULL DEFAULT 0 COMMENT '累计基础返奖',
ordinary_win_count BIGINT NOT NULL DEFAULT 0 COMMENT '普通非零奖次数',
high_multiplier_ordinary_win_count BIGINT NOT NULL DEFAULT 0 COMMENT '高倍率普通奖次数',
rtp_compensation_jackpot_count BIGINT NOT NULL DEFAULT 0 COMMENT 'RTP 补偿大奖次数',
cumulative_spend_jackpot_count BIGINT NOT NULL DEFAULT 0 COMMENT '累计消费大奖次数',
jackpot_count BIGINT NOT NULL DEFAULT 0 COMMENT '两类独立大奖累计次数,物化后用于排序和筛选',
guarantee_hit_count BIGINT NOT NULL DEFAULT 0 COMMENT '连续未中保护命中次数',
zero_draw_count BIGINT NOT NULL DEFAULT 0 COMMENT '未中奖次数',
granted_draw_count BIGINT NOT NULL DEFAULT 0 COMMENT '已发放次数',
pending_draw_count BIGINT NOT NULL DEFAULT 0 COMMENT '待发放次数',
failed_draw_count BIGINT NOT NULL DEFAULT 0 COMMENT '发放失败次数',
max_multiplier_ppm BIGINT NOT NULL DEFAULT 0 COMMENT '最高倍率',
max_reward_coins BIGINT NOT NULL DEFAULT 0 COMMENT '最高单抽返奖',
last_downweight_active TINYINT(1) NOT NULL DEFAULT 0 COMMENT '最近是否触发 24h RTP 降权',
current_equivalent_draws BIGINT NOT NULL DEFAULT 0 COMMENT '当前运行状态的等价抽数快照',
current_loss_streak BIGINT NOT NULL DEFAULT 0 COMMENT '当前连续未中奖次数快照',
current_pending_spend_jackpot_tokens BIGINT NOT NULL DEFAULT 0 COMMENT '当前累计消费大奖资格快照',
first_draw_at_ms BIGINT NOT NULL DEFAULT 0 COMMENT '首次抽奖时间',
last_draw_at_ms BIGINT NOT NULL DEFAULT 0 COMMENT '最近抽奖时间',
aggregated_at_ms BIGINT NOT NULL COMMENT '最近聚合时间',
created_at_ms BIGINT NOT NULL COMMENT '创建时间',
updated_at_ms BIGINT NOT NULL COMMENT '更新时间',
PRIMARY KEY (app_code, pool_id, strategy_version, identity_type, identity_key),
KEY idx_lucky_user_profile_sort_last (app_code, pool_id, strategy_version, last_draw_at_ms DESC, identity_type DESC, identity_key DESC),
KEY idx_lucky_user_profile_sort_rtp (app_code, pool_id, strategy_version, lifetime_rtp_ppm DESC, identity_type DESC, identity_key DESC),
KEY idx_lucky_user_profile_sort_wager (app_code, pool_id, strategy_version, total_wager_coins DESC, identity_type DESC, identity_key DESC),
KEY idx_lucky_user_profile_sort_payout (app_code, pool_id, strategy_version, total_payout_coins DESC, identity_type DESC, identity_key DESC),
KEY idx_lucky_user_profile_sort_jackpot (app_code, pool_id, strategy_version, jackpot_count DESC, identity_type DESC, identity_key DESC),
KEY idx_lucky_user_profile_sort_loss (app_code, pool_id, strategy_version, current_loss_streak DESC, identity_type DESC, identity_key DESC),
KEY idx_lucky_user_profile_stage (app_code, pool_id, strategy_version, current_stage, identity_type, identity_key),
KEY idx_lucky_user_profile_pending (app_code, pool_id, strategy_version, current_pending_spend_jackpot_tokens DESC, identity_type DESC, identity_key DESC),
KEY idx_lucky_user_profile_internal (app_code, pool_id, strategy_version, internal_user_id),
KEY idx_lucky_user_profile_external (app_code, pool_id, strategy_version, external_user_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='幸运礼物用户画像累计快照';
CREATE TABLE IF NOT EXISTS lucky_gift_user_profile_events (
app_code VARCHAR(32) NOT NULL COMMENT '应用编码',
source_type VARCHAR(24) NOT NULL COMMENT 'internal/external_item/external_legacy',
source_draw_id VARCHAR(128) NOT NULL COMMENT '来源抽奖事实 ID',
pool_id VARCHAR(96) NOT NULL COMMENT '幸运礼物奖池 ID',
strategy_version VARCHAR(32) NOT NULL COMMENT 'fixed_v2/dynamic_v3',
identity_type VARCHAR(16) NOT NULL COMMENT 'internal/external',
identity_key VARCHAR(128) COLLATE utf8mb4_bin NOT NULL COMMENT '用户身份键',
draw_count BIGINT NOT NULL DEFAULT 1 COMMENT '代表的子抽次数fixed_v2 外部整单固定为 1',
wager_coins BIGINT NOT NULL DEFAULT 0 COMMENT '消耗金币',
payout_coins BIGINT NOT NULL DEFAULT 0 COMMENT '返奖金币',
base_reward_coins BIGINT NOT NULL DEFAULT 0 COMMENT '基础返奖',
multiplier_ppm BIGINT NOT NULL DEFAULT 0 COMMENT '命中倍率',
hit_type VARCHAR(32) NOT NULL COMMENT 'zero/ordinary/rtp_compensation/spend_milestone',
reward_status VARCHAR(32) NOT NULL COMMENT 'pending/granted/failed',
occurred_at_ms BIGINT NOT NULL COMMENT '支付事实时间',
aggregation_token VARCHAR(96) NOT NULL DEFAULT '' COMMENT '首次插入者的聚合批次令牌',
created_at_ms BIGINT NOT NULL COMMENT '读模型写入时间',
PRIMARY KEY (app_code, source_type, source_draw_id),
KEY idx_lucky_user_profile_event_window (app_code, pool_id, strategy_version, identity_type, identity_key, occurred_at_ms),
KEY idx_lucky_user_profile_event_token (app_code, source_type, aggregation_token)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='幸运礼物用户画像窗口事件';
CREATE TABLE IF NOT EXISTS lucky_gift_user_multiplier_stats (
app_code VARCHAR(32) NOT NULL COMMENT '应用编码',
pool_id VARCHAR(96) NOT NULL COMMENT '幸运礼物奖池 ID',
strategy_version VARCHAR(32) NOT NULL COMMENT 'fixed_v2/dynamic_v3',
identity_type VARCHAR(16) NOT NULL COMMENT 'internal/external',
identity_key VARCHAR(128) COLLATE utf8mb4_bin NOT NULL COMMENT '用户身份键',
hit_type VARCHAR(32) NOT NULL COMMENT '普通奖和两类大奖独立分类',
multiplier_ppm BIGINT NOT NULL COMMENT '倍率',
draw_count BIGINT NOT NULL DEFAULT 0 COMMENT '命中次数',
wager_coins BIGINT NOT NULL DEFAULT 0 COMMENT '消耗金币',
payout_coins BIGINT NOT NULL DEFAULT 0 COMMENT '返奖金币',
updated_at_ms BIGINT NOT NULL COMMENT '更新时间',
PRIMARY KEY (app_code, pool_id, strategy_version, identity_type, identity_key, hit_type, multiplier_ppm)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='幸运礼物用户画像倍率分布';
CREATE TABLE IF NOT EXISTS lucky_gift_user_jackpot_hits (
app_code VARCHAR(32) NOT NULL COMMENT '应用编码',
source_type VARCHAR(24) NOT NULL COMMENT 'internal/external_item',
source_draw_id VARCHAR(128) NOT NULL COMMENT '来源抽奖事实 ID',
pool_id VARCHAR(96) NOT NULL COMMENT '幸运礼物奖池 ID',
strategy_version VARCHAR(32) NOT NULL COMMENT 'fixed_v2/dynamic_v3',
identity_type VARCHAR(16) NOT NULL COMMENT 'internal/external',
identity_key VARCHAR(128) COLLATE utf8mb4_bin NOT NULL COMMENT '用户身份键',
request_id VARCHAR(128) NOT NULL DEFAULT '' COMMENT '内部 command_id 或外部 request_id',
rule_version BIGINT NOT NULL DEFAULT 0 COMMENT '规则版本',
stage VARCHAR(32) NOT NULL DEFAULT '' COMMENT '命中时阶段',
mechanism VARCHAR(32) NOT NULL COMMENT '大奖机制',
reason_code VARCHAR(96) NOT NULL DEFAULT '' COMMENT '最终原因码',
tier_id VARCHAR(96) NOT NULL DEFAULT '' COMMENT '大奖 tier ID',
multiplier_ppm BIGINT NOT NULL DEFAULT 0 COMMENT '大奖倍率',
wager_coins BIGINT NOT NULL DEFAULT 0 COMMENT '本抽消耗',
payout_coins BIGINT NOT NULL DEFAULT 0 COMMENT '本抽返奖',
conditions_json JSON NOT NULL COMMENT '大奖条件快照',
occurred_at_ms BIGINT NOT NULL COMMENT '支付事实时间',
created_at_ms BIGINT NOT NULL COMMENT '读模型写入时间',
PRIMARY KEY (app_code, source_type, source_draw_id),
KEY idx_lucky_user_jackpot_profile (app_code, pool_id, strategy_version, identity_type, identity_key, occurred_at_ms DESC, source_type DESC, source_draw_id DESC)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='幸运礼物用户画像大奖解释';
CREATE TABLE IF NOT EXISTS external_lucky_gift_request_locks (
app_code VARCHAR(32) NOT NULL COMMENT '外部 App 应用编码',
request_id VARCHAR(128) NOT NULL COMMENT '外部业务幂等 ID',
@ -405,7 +548,8 @@ CREATE TABLE IF NOT EXISTS external_lucky_gift_draws (
UNIQUE KEY uk_external_lucky_draw_id (app_code, draw_id),
KEY idx_external_lucky_user (app_code, external_user_id, created_at_ms),
KEY idx_external_lucky_pool (app_code, pool_id, created_at_ms),
KEY idx_external_lucky_status (app_code, reward_status, created_at_ms)
KEY idx_external_lucky_status (app_code, reward_status, created_at_ms),
KEY idx_external_lucky_profile_cursor (app_code, created_at_ms, draw_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='外部 App 幸运礼物抽奖事实表';
CREATE TABLE IF NOT EXISTS external_lucky_gift_draw_items (
@ -430,7 +574,8 @@ CREATE TABLE IF NOT EXISTS external_lucky_gift_draw_items (
created_at_ms BIGINT NOT NULL COMMENT '创建时间UTC epoch ms',
PRIMARY KEY (app_code, request_id, item_index),
UNIQUE KEY uk_external_lucky_item_draw (app_code, draw_id),
KEY idx_external_lucky_item_user (app_code, external_user_id, created_at_ms)
KEY idx_external_lucky_item_user (app_code, external_user_id, created_at_ms),
KEY idx_external_lucky_item_profile_cursor (app_code, created_at_ms, draw_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='外部dynamic_v3顺序子抽审计';
-- AB 实验事实表:两组各钉住一个不可变 rule_version运行时按 bucket_salt + user 哈希分流。

View File

@ -0,0 +1,17 @@
SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci;
USE hyapp_lucky_gift;
-- 性能边界lucky_user_states 是热状态表,本迁移只添加一个常量默认列并强制
-- ALGORITHM=INSTANT。若目标 MySQL 不支持即时 DDL 就直接失败,绝不退化成 COPY
-- 上线前仍需检查长事务,避免短暂 MDL 等待。迁移不扫描、不更新历史用户行。
-- 历史 pending_jackpot_tokens 保持原值,业务在该用户下一次付费抽奖事务内按主键惰性清零,
-- 且从不把旧值复制到新列,因此停用期间的资格和消费都不会被追溯补发。
SET @ddl := IF(
(SELECT COUNT(*) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'lucky_user_states'
AND COLUMN_NAME = 'pending_spend_jackpot_tokens') = 0,
'ALTER TABLE lucky_user_states ADD COLUMN pending_spend_jackpot_tokens BIGINT NOT NULL DEFAULT 0 COMMENT ''新版累计消费大奖资格受阻时跨轮次和UTC日保留成功开奖仅扣1'' AFTER pending_jackpot_tokens, ALGORITHM=INSTANT',
'SELECT 1'
);
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;

View File

@ -0,0 +1,23 @@
SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci;
USE hyapp_lucky_gift;
-- 性能边界:规则版本表只增加常量默认列,强制 ALGORITHM=INSTANT迁移不扫描或回写历史版本。
-- 若目标 MySQL 不支持即时 DDL 则直接失败,不能退化为 COPY执行前仍需检查长事务避免元数据锁等待。
SET @ddl := IF(
(SELECT COUNT(*) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'lucky_gift_rule_versions'
AND COLUMN_NAME = 'user_24h_rtp_threshold_ppm') = 0,
'ALTER TABLE lucky_gift_rule_versions ADD COLUMN user_24h_rtp_threshold_ppm BIGINT NOT NULL DEFAULT 0 COMMENT ''用户滚动24小时RTP降权触发线ppm0表示关闭'' AFTER recharge_boost_factor_ppm, ALGORITHM=INSTANT',
'SELECT 1'
);
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl := IF(
(SELECT COUNT(*) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'lucky_gift_rule_versions'
AND COLUMN_NAME = 'user_24h_ordinary_win_factor_ppm') = 0,
'ALTER TABLE lucky_gift_rule_versions ADD COLUMN user_24h_ordinary_win_factor_ppm BIGINT NOT NULL DEFAULT 1000000 COMMENT ''触发后普通非零奖档保留权重ppm1000000表示不降权'' AFTER user_24h_rtp_threshold_ppm, ALGORITHM=INSTANT',
'SELECT 1'
);
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;

View File

@ -0,0 +1,213 @@
SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci;
USE hyapp_lucky_gift;
-- 用户画像是幸运礼物事实的专用读模型。列表、窗口指标和大奖解释都只能读取这些表,
-- 不能在运营查询时临时 GROUP BY 全量内部/外部抽奖事实。
CREATE TABLE IF NOT EXISTS lucky_gift_user_profiles (
app_code VARCHAR(32) NOT NULL COMMENT '应用编码',
pool_id VARCHAR(96) NOT NULL COMMENT '幸运礼物奖池 ID',
strategy_version VARCHAR(32) NOT NULL COMMENT 'fixed_v2/dynamic_v3两套奖池画像严格隔离',
identity_type VARCHAR(16) NOT NULL COMMENT 'internal/external',
identity_key VARCHAR(128) COLLATE utf8mb4_bin NOT NULL COMMENT '内部 user_id 文本或外部 external_user_id永不存展示稳定哈希',
strategy_user_id BIGINT NOT NULL COMMENT '策略状态用户键;外部值仅供本服务关联状态,绝不下发',
internal_user_id BIGINT NOT NULL DEFAULT 0 COMMENT '内部真实 user_id外部固定为 0',
external_user_id VARCHAR(128) COLLATE utf8mb4_bin NOT NULL DEFAULT '' COMMENT '外部原始用户 ID内部固定为空',
rule_version BIGINT NOT NULL DEFAULT 0 COMMENT '最近一次不可变规则版本',
current_stage VARCHAR(32) NOT NULL DEFAULT '' COMMENT '最近一次抽奖阶段',
total_draws BIGINT NOT NULL DEFAULT 0 COMMENT '累计子抽次数',
total_wager_coins BIGINT NOT NULL DEFAULT 0 COMMENT '累计消耗金币',
total_payout_coins BIGINT NOT NULL DEFAULT 0 COMMENT '累计用户可见返奖金币',
lifetime_rtp_ppm BIGINT NOT NULL DEFAULT 0 COMMENT '累计 RTP物化后用于无 filesort 游标排序',
total_base_reward_coins BIGINT NOT NULL DEFAULT 0 COMMENT '累计基础返奖金币',
ordinary_win_count BIGINT NOT NULL DEFAULT 0 COMMENT '普通非零奖命中次数',
high_multiplier_ordinary_win_count BIGINT NOT NULL DEFAULT 0 COMMENT '高倍率普通奖命中次数;与大奖独立',
rtp_compensation_jackpot_count BIGINT NOT NULL DEFAULT 0 COMMENT 'RTP 补偿大奖命中次数',
cumulative_spend_jackpot_count BIGINT NOT NULL DEFAULT 0 COMMENT '累计消费大奖命中次数',
jackpot_count BIGINT NOT NULL DEFAULT 0 COMMENT '两类独立大奖累计次数,物化后用于排序和筛选',
guarantee_hit_count BIGINT NOT NULL DEFAULT 0 COMMENT '连续未中奖保护命中次数',
zero_draw_count BIGINT NOT NULL DEFAULT 0 COMMENT '0 倍未中奖次数',
granted_draw_count BIGINT NOT NULL DEFAULT 0 COMMENT '已发放次数',
pending_draw_count BIGINT NOT NULL DEFAULT 0 COMMENT '待发放次数',
failed_draw_count BIGINT NOT NULL DEFAULT 0 COMMENT '发放失败次数',
max_multiplier_ppm BIGINT NOT NULL DEFAULT 0 COMMENT '历史最高倍率',
max_reward_coins BIGINT NOT NULL DEFAULT 0 COMMENT '历史单抽最高返奖',
last_downweight_active TINYINT(1) NOT NULL DEFAULT 0 COMMENT '最近一次抽奖是否触发 24h RTP 降权',
current_equivalent_draws BIGINT NOT NULL DEFAULT 0 COMMENT '当前运行状态的等价抽数快照',
current_loss_streak BIGINT NOT NULL DEFAULT 0 COMMENT '当前连续未中奖次数快照',
current_pending_spend_jackpot_tokens BIGINT NOT NULL DEFAULT 0 COMMENT '当前累计消费大奖资格快照',
first_draw_at_ms BIGINT NOT NULL DEFAULT 0 COMMENT '首次抽奖事实时间',
last_draw_at_ms BIGINT NOT NULL DEFAULT 0 COMMENT '最近抽奖事实时间',
aggregated_at_ms BIGINT NOT NULL COMMENT '最近聚合时间UTC epoch ms',
created_at_ms BIGINT NOT NULL COMMENT '创建时间UTC epoch ms',
updated_at_ms BIGINT NOT NULL COMMENT '更新时间UTC epoch ms',
PRIMARY KEY (app_code, pool_id, strategy_version, identity_type, identity_key),
KEY idx_lucky_user_profile_sort_last (app_code, pool_id, strategy_version, last_draw_at_ms DESC, identity_type DESC, identity_key DESC),
KEY idx_lucky_user_profile_sort_rtp (app_code, pool_id, strategy_version, lifetime_rtp_ppm DESC, identity_type DESC, identity_key DESC),
KEY idx_lucky_user_profile_sort_wager (app_code, pool_id, strategy_version, total_wager_coins DESC, identity_type DESC, identity_key DESC),
KEY idx_lucky_user_profile_sort_payout (app_code, pool_id, strategy_version, total_payout_coins DESC, identity_type DESC, identity_key DESC),
KEY idx_lucky_user_profile_sort_jackpot (app_code, pool_id, strategy_version, jackpot_count DESC, identity_type DESC, identity_key DESC),
KEY idx_lucky_user_profile_sort_loss (app_code, pool_id, strategy_version, current_loss_streak DESC, identity_type DESC, identity_key DESC),
KEY idx_lucky_user_profile_stage (app_code, pool_id, strategy_version, current_stage, identity_type, identity_key),
KEY idx_lucky_user_profile_pending (app_code, pool_id, strategy_version, current_pending_spend_jackpot_tokens DESC, identity_type DESC, identity_key DESC),
KEY idx_lucky_user_profile_internal (app_code, pool_id, strategy_version, internal_user_id),
KEY idx_lucky_user_profile_external (app_code, pool_id, strategy_version, external_user_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='幸运礼物用户画像累计快照';
-- 画像事件只保留统计所需的窄字段。永久唯一键既支持严格滚动窗口,也让历史回灌与实时写入幂等共存。
CREATE TABLE IF NOT EXISTS lucky_gift_user_profile_events (
app_code VARCHAR(32) NOT NULL COMMENT '应用编码',
source_type VARCHAR(24) NOT NULL COMMENT 'internal/external_item/external_legacy',
source_draw_id VARCHAR(128) NOT NULL COMMENT '来源抽奖事实 ID',
pool_id VARCHAR(96) NOT NULL COMMENT '幸运礼物奖池 ID',
strategy_version VARCHAR(32) NOT NULL COMMENT 'fixed_v2/dynamic_v3',
identity_type VARCHAR(16) NOT NULL COMMENT 'internal/external',
identity_key VARCHAR(128) COLLATE utf8mb4_bin NOT NULL COMMENT '可搜索用户身份键',
draw_count BIGINT NOT NULL DEFAULT 1 COMMENT '该事件代表的子抽次数fixed_v2 外部整单固定为 1旧 dynamic 聚合事实可大于 1',
wager_coins BIGINT NOT NULL DEFAULT 0 COMMENT '消耗金币',
payout_coins BIGINT NOT NULL DEFAULT 0 COMMENT '用户可见返奖金币',
base_reward_coins BIGINT NOT NULL DEFAULT 0 COMMENT '基础返奖金币',
multiplier_ppm BIGINT NOT NULL DEFAULT 0 COMMENT '命中倍率',
hit_type VARCHAR(32) NOT NULL COMMENT 'zero/ordinary/rtp_compensation/spend_milestone',
reward_status VARCHAR(32) NOT NULL COMMENT 'pending/granted/failed',
occurred_at_ms BIGINT NOT NULL COMMENT '支付事实时间UTC epoch ms',
aggregation_token VARCHAR(96) NOT NULL DEFAULT '' COMMENT '首次插入者的聚合批次令牌;用于在线写入与回灌并发去重',
created_at_ms BIGINT NOT NULL COMMENT '读模型写入时间UTC epoch ms',
PRIMARY KEY (app_code, source_type, source_draw_id),
KEY idx_lucky_user_profile_event_window (app_code, pool_id, strategy_version, identity_type, identity_key, occurred_at_ms),
KEY idx_lucky_user_profile_event_token (app_code, source_type, aggregation_token)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='幸运礼物用户画像窗口事件';
CREATE TABLE IF NOT EXISTS lucky_gift_user_multiplier_stats (
app_code VARCHAR(32) NOT NULL COMMENT '应用编码',
pool_id VARCHAR(96) NOT NULL COMMENT '幸运礼物奖池 ID',
strategy_version VARCHAR(32) NOT NULL COMMENT 'fixed_v2/dynamic_v3',
identity_type VARCHAR(16) NOT NULL COMMENT 'internal/external',
identity_key VARCHAR(128) COLLATE utf8mb4_bin NOT NULL COMMENT '可搜索用户身份键',
hit_type VARCHAR(32) NOT NULL COMMENT '普通奖和两类大奖分别聚合,允许同倍率共存',
multiplier_ppm BIGINT NOT NULL COMMENT '倍率1x=1000000',
draw_count BIGINT NOT NULL DEFAULT 0 COMMENT '命中次数',
wager_coins BIGINT NOT NULL DEFAULT 0 COMMENT '对应抽奖消耗',
payout_coins BIGINT NOT NULL DEFAULT 0 COMMENT '对应返奖',
updated_at_ms BIGINT NOT NULL COMMENT '更新时间UTC epoch ms',
PRIMARY KEY (app_code, pool_id, strategy_version, identity_type, identity_key, hit_type, multiplier_ppm)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='幸运礼物用户画像倍率分布';
CREATE TABLE IF NOT EXISTS lucky_gift_user_jackpot_hits (
app_code VARCHAR(32) NOT NULL COMMENT '应用编码',
source_type VARCHAR(24) NOT NULL COMMENT 'internal/external_item',
source_draw_id VARCHAR(128) NOT NULL COMMENT '来源抽奖事实 ID',
pool_id VARCHAR(96) NOT NULL COMMENT '幸运礼物奖池 ID',
strategy_version VARCHAR(32) NOT NULL COMMENT 'fixed_v2/dynamic_v3',
identity_type VARCHAR(16) NOT NULL COMMENT 'internal/external',
identity_key VARCHAR(128) COLLATE utf8mb4_bin NOT NULL COMMENT '可搜索用户身份键',
request_id VARCHAR(128) NOT NULL DEFAULT '' COMMENT '内部 command_id 或外部 request_id',
rule_version BIGINT NOT NULL DEFAULT 0 COMMENT '不可变规则版本',
stage VARCHAR(32) NOT NULL DEFAULT '' COMMENT '命中时阶段',
mechanism VARCHAR(32) NOT NULL COMMENT 'rtp_compensation/spend_milestone',
reason_code VARCHAR(96) NOT NULL DEFAULT '' COMMENT '内核最终原因码',
tier_id VARCHAR(96) NOT NULL DEFAULT '' COMMENT '独立大奖 tier ID',
multiplier_ppm BIGINT NOT NULL DEFAULT 0 COMMENT '大奖倍率',
wager_coins BIGINT NOT NULL DEFAULT 0 COMMENT '本抽消耗',
payout_coins BIGINT NOT NULL DEFAULT 0 COMMENT '本抽返奖',
conditions_json JSON NOT NULL COMMENT '命中当刻条件快照',
occurred_at_ms BIGINT NOT NULL COMMENT '支付事实时间UTC epoch ms',
created_at_ms BIGINT NOT NULL COMMENT '读模型写入时间UTC epoch ms',
PRIMARY KEY (app_code, source_type, source_draw_id),
KEY idx_lucky_user_jackpot_profile (app_code, pool_id, strategy_version, identity_type, identity_key, occurred_at_ms DESC, source_type DESC, source_draw_id DESC)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='幸运礼物用户画像大奖解释';
-- migration 可在建表完成、进程尚未回灌时安全重跑。列使用 INSTANT画像表在首次上线时为空
-- 排序索引使用 INPLACE+LOCK=NONE明确禁止意外 COPY 大表。
SET @ddl := IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='lucky_gift_user_profiles' AND COLUMN_NAME='lifetime_rtp_ppm')=0,
'ALTER TABLE lucky_gift_user_profiles ADD COLUMN lifetime_rtp_ppm BIGINT NOT NULL DEFAULT 0 COMMENT ''累计 RTP物化后用于无 filesort 游标排序'' AFTER total_payout_coins, ALGORITHM=INSTANT', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl := IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='lucky_gift_user_profiles' AND COLUMN_NAME='jackpot_count')=0,
'ALTER TABLE lucky_gift_user_profiles ADD COLUMN jackpot_count BIGINT NOT NULL DEFAULT 0 COMMENT ''两类独立大奖累计次数'' AFTER cumulative_spend_jackpot_count, ALGORITHM=INSTANT', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl := IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='lucky_gift_user_profiles' AND COLUMN_NAME='current_equivalent_draws')=0,
'ALTER TABLE lucky_gift_user_profiles ADD COLUMN current_equivalent_draws BIGINT NOT NULL DEFAULT 0 COMMENT ''当前运行状态的等价抽数快照'' AFTER last_downweight_active, ALGORITHM=INSTANT', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl := IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='lucky_gift_user_profiles' AND COLUMN_NAME='current_loss_streak')=0,
'ALTER TABLE lucky_gift_user_profiles ADD COLUMN current_loss_streak BIGINT NOT NULL DEFAULT 0 COMMENT ''当前连续未中奖次数快照'' AFTER current_equivalent_draws, ALGORITHM=INSTANT', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl := IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='lucky_gift_user_profiles' AND COLUMN_NAME='current_pending_spend_jackpot_tokens')=0,
'ALTER TABLE lucky_gift_user_profiles ADD COLUMN current_pending_spend_jackpot_tokens BIGINT NOT NULL DEFAULT 0 COMMENT ''当前累计消费大奖资格快照'' AFTER current_loss_streak, ALGORITHM=INSTANT', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl := IF((SELECT COUNT(*) FROM information_schema.STATISTICS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='lucky_gift_user_profiles' AND INDEX_NAME='idx_lucky_user_profile_sort_last')=0,
'ALTER TABLE lucky_gift_user_profiles ADD KEY idx_lucky_user_profile_sort_last (app_code, pool_id, strategy_version, last_draw_at_ms DESC, identity_type DESC, identity_key DESC), ALGORITHM=INPLACE, LOCK=NONE', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl := IF((SELECT COUNT(*) FROM information_schema.STATISTICS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='lucky_gift_user_profiles' AND INDEX_NAME='idx_lucky_user_profile_sort_rtp')=0,
'ALTER TABLE lucky_gift_user_profiles ADD KEY idx_lucky_user_profile_sort_rtp (app_code, pool_id, strategy_version, lifetime_rtp_ppm DESC, identity_type DESC, identity_key DESC), ALGORITHM=INPLACE, LOCK=NONE', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl := IF((SELECT COUNT(*) FROM information_schema.STATISTICS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='lucky_gift_user_profiles' AND INDEX_NAME='idx_lucky_user_profile_sort_wager')=0,
'ALTER TABLE lucky_gift_user_profiles ADD KEY idx_lucky_user_profile_sort_wager (app_code, pool_id, strategy_version, total_wager_coins DESC, identity_type DESC, identity_key DESC), ALGORITHM=INPLACE, LOCK=NONE', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl := IF((SELECT COUNT(*) FROM information_schema.STATISTICS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='lucky_gift_user_profiles' AND INDEX_NAME='idx_lucky_user_profile_sort_payout')=0,
'ALTER TABLE lucky_gift_user_profiles ADD KEY idx_lucky_user_profile_sort_payout (app_code, pool_id, strategy_version, total_payout_coins DESC, identity_type DESC, identity_key DESC), ALGORITHM=INPLACE, LOCK=NONE', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl := IF((SELECT COUNT(*) FROM information_schema.STATISTICS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='lucky_gift_user_profiles' AND INDEX_NAME='idx_lucky_user_profile_sort_jackpot')=0,
'ALTER TABLE lucky_gift_user_profiles ADD KEY idx_lucky_user_profile_sort_jackpot (app_code, pool_id, strategy_version, jackpot_count DESC, identity_type DESC, identity_key DESC), ALGORITHM=INPLACE, LOCK=NONE', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl := IF((SELECT COUNT(*) FROM information_schema.STATISTICS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='lucky_gift_user_profiles' AND INDEX_NAME='idx_lucky_user_profile_sort_loss')=0,
'ALTER TABLE lucky_gift_user_profiles ADD KEY idx_lucky_user_profile_sort_loss (app_code, pool_id, strategy_version, current_loss_streak DESC, identity_type DESC, identity_key DESC), ALGORITHM=INPLACE, LOCK=NONE', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl := IF((SELECT COUNT(*) FROM information_schema.STATISTICS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='lucky_gift_user_profiles' AND INDEX_NAME='idx_lucky_user_profile_stage')=0,
'ALTER TABLE lucky_gift_user_profiles ADD KEY idx_lucky_user_profile_stage (app_code, pool_id, strategy_version, current_stage, identity_type, identity_key), ALGORITHM=INPLACE, LOCK=NONE', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl := IF((SELECT COUNT(*) FROM information_schema.STATISTICS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='lucky_gift_user_profiles' AND INDEX_NAME='idx_lucky_user_profile_pending')=0,
'ALTER TABLE lucky_gift_user_profiles ADD KEY idx_lucky_user_profile_pending (app_code, pool_id, strategy_version, current_pending_spend_jackpot_tokens DESC, identity_type DESC, identity_key DESC), ALGORITHM=INPLACE, LOCK=NONE', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- 早期本地 009 使用全 ASC 的 list 索引;完整 sort_last 已覆盖其正向和反向扫描,删除重复索引避免每抽双写。
SET @ddl := IF((SELECT COUNT(*) FROM information_schema.STATISTICS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='lucky_gift_user_profiles' AND INDEX_NAME='idx_lucky_user_profile_list')>0,
'ALTER TABLE lucky_gift_user_profiles DROP INDEX idx_lucky_user_profile_list, ALGORITHM=INPLACE, LOCK=NONE', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- 正常首次上线时画像表尚为空;该更新只用于半完成 migration 后已有画像的幂等修复。
-- 只扫描专用小型读模型并按主键关联状态,不触碰 8 万级开奖事实表。
UPDATE lucky_gift_user_profiles p
LEFT JOIN lucky_user_states s
ON s.app_code = p.app_code AND s.user_id = p.strategy_user_id AND s.gift_id = p.pool_id
SET p.lifetime_rtp_ppm = CASE
WHEN p.total_wager_coins <= 0 THEN 0
ELSE CAST(LEAST(CAST(9223372036854775807 AS DECIMAL(65,0)),
FLOOR(CAST(p.total_payout_coins AS DECIMAL(65,0)) * 1000000 / p.total_wager_coins)) AS SIGNED)
END,
p.jackpot_count = p.rtp_compensation_jackpot_count + p.cumulative_spend_jackpot_count,
p.current_equivalent_draws = COALESCE(s.equivalent_draws, 0),
p.current_loss_streak = COALESCE(s.loss_streak, 0),
p.current_pending_spend_jackpot_tokens = COALESCE(s.pending_spend_jackpot_tokens, 0);
-- 早期半完成的 009 只有 occurred_at_ms完整大奖游标还必须把 source_type/source_draw_id 纳入索引顺序。
SET @jackpot_cursor_columns := (SELECT GROUP_CONCAT(COLUMN_NAME ORDER BY SEQ_IN_INDEX SEPARATOR ',') FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='lucky_gift_user_jackpot_hits' AND INDEX_NAME='idx_lucky_user_jackpot_profile');
SET @ddl := CASE
WHEN COALESCE(@jackpot_cursor_columns, '') = '' THEN
'ALTER TABLE lucky_gift_user_jackpot_hits ADD KEY idx_lucky_user_jackpot_profile (app_code, pool_id, strategy_version, identity_type, identity_key, occurred_at_ms DESC, source_type DESC, source_draw_id DESC), ALGORITHM=INPLACE, LOCK=NONE'
WHEN @jackpot_cursor_columns <> 'app_code,pool_id,strategy_version,identity_type,identity_key,occurred_at_ms,source_type,source_draw_id' THEN
'ALTER TABLE lucky_gift_user_jackpot_hits DROP INDEX idx_lucky_user_jackpot_profile, ADD KEY idx_lucky_user_jackpot_profile (app_code, pool_id, strategy_version, identity_type, identity_key, occurred_at_ms DESC, source_type DESC, source_draw_id DESC), ALGORITHM=INPLACE, LOCK=NONE'
ELSE 'SELECT 1'
END;
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- 两个来源索引按 information_schema 守卫,允许 migration 在网络中断或半完成后安全重跑。
-- INPLACE+LOCK=NONE 禁止退化成 COPY上线前仍必须检查表大小和长事务避免 MDL 等待。
SET @ddl := IF(
(SELECT COUNT(*) FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'external_lucky_gift_draws'
AND INDEX_NAME = 'idx_external_lucky_profile_cursor') = 0,
'ALTER TABLE external_lucky_gift_draws ADD KEY idx_external_lucky_profile_cursor (app_code, created_at_ms, draw_id), ALGORITHM=INPLACE, LOCK=NONE',
'SELECT 1'
);
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl := IF(
(SELECT COUNT(*) FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'external_lucky_gift_draw_items'
AND INDEX_NAME = 'idx_external_lucky_item_profile_cursor') = 0,
'ALTER TABLE external_lucky_gift_draw_items ADD KEY idx_external_lucky_item_profile_cursor (app_code, created_at_ms, draw_id), ALGORITHM=INPLACE, LOCK=NONE',
'SELECT 1'
);
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;

View File

@ -0,0 +1,108 @@
SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci;
USE hyapp_lucky_gift;
-- 生产 main 的 006 已用于 AB 实验;本迁移编号调整为 010 以保证发布目录内版本唯一。
-- 性能边界:
-- 1. 规则版本表只保存低频发布的不可变配置。生产使用双实例滚动,旧实例仍硬编码 day/72h 列,
-- 因此本次只能执行 expand补齐 old/new 四列并双向回填,绝不 CHANGE/DROP 旧列。
-- 新版本仓储会双写两套列;确认所有旧实例与回滚窗口都结束后,才能用独立 contract migration 删除影子列。
-- 所有加列显式要求 ALGORITHM=INSTANT若目标 MySQL 不支持则直接失败,禁止退化为整表 COPY。
-- 2. lucky_rtp_windows 是热状态表。三个常量默认列分别使用即时 DDL不回写历史行每条 ALTER 仍需短暂 MDL
-- 上线前应确认没有长事务占用该表,否则迁移会等待元数据锁,但不会扫描 lucky_draw_records 等明细大表。
-- 3. lucky_user_rtp_windows 是全新空表,主键与待资格索引随建表一次完成;迁移不做历史资格回填,避免全表聚合。
-- 旧库只有 day/72h新测试库可能只有 round/48h逐列补齐四列让新旧二进制在发布和回滚窗口内都能工作。
SET @has_old_column := (SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'lucky_gift_rule_versions' AND COLUMN_NAME = 'jackpot_user_day_rtp_max_ppm');
SET @ddl := IF(@has_old_column = 0,
'ALTER TABLE lucky_gift_rule_versions ADD COLUMN jackpot_user_day_rtp_max_ppm BIGINT NOT NULL DEFAULT 0 COMMENT ''兼容旧实例的用户日 RTP 上限影子列;新代码与轮次列双写'' AFTER jackpot_global_rtp_max_ppm, ALGORITHM=INSTANT',
'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @has_new_column := (SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'lucky_gift_rule_versions' AND COLUMN_NAME = 'jackpot_user_round_rtp_max_ppm');
SET @ddl := IF(@has_new_column = 0,
'ALTER TABLE lucky_gift_rule_versions ADD COLUMN jackpot_user_round_rtp_max_ppm BIGINT NOT NULL DEFAULT 0 COMMENT ''动态大奖用户已结算轮次 RTP 严格上限ppm'' AFTER jackpot_user_day_rtp_max_ppm, ALGORITHM=INSTANT',
'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @has_old_column := (SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'lucky_gift_rule_versions' AND COLUMN_NAME = 'jackpot_user_day_rtp_max_ppm');
SET @has_new_column := (SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'lucky_gift_rule_versions' AND COLUMN_NAME = 'jackpot_user_round_rtp_max_ppm');
SET @ddl := IF(@has_old_column > 0 AND @has_new_column > 0,
'UPDATE lucky_gift_rule_versions SET jackpot_user_round_rtp_max_ppm = jackpot_user_day_rtp_max_ppm WHERE jackpot_user_round_rtp_max_ppm = 0 AND jackpot_user_day_rtp_max_ppm <> 0', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl := IF(@has_old_column > 0 AND @has_new_column > 0,
'UPDATE lucky_gift_rule_versions SET jackpot_user_day_rtp_max_ppm = jackpot_user_round_rtp_max_ppm WHERE jackpot_user_day_rtp_max_ppm = 0 AND jackpot_user_round_rtp_max_ppm <> 0', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @has_old_column := (SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'lucky_gift_rule_versions' AND COLUMN_NAME = 'jackpot_user_72h_rtp_max_ppm');
SET @ddl := IF(@has_old_column = 0,
'ALTER TABLE lucky_gift_rule_versions ADD COLUMN jackpot_user_72h_rtp_max_ppm BIGINT NOT NULL DEFAULT 0 COMMENT ''兼容旧实例的用户 72 小时 RTP 上限影子列;新代码与 48 小时列双写'' AFTER jackpot_user_round_rtp_max_ppm, ALGORITHM=INSTANT',
'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @has_new_column := (SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'lucky_gift_rule_versions' AND COLUMN_NAME = 'jackpot_user_48h_rtp_max_ppm');
SET @ddl := IF(@has_new_column = 0,
'ALTER TABLE lucky_gift_rule_versions ADD COLUMN jackpot_user_48h_rtp_max_ppm BIGINT NOT NULL DEFAULT 0 COMMENT ''动态大奖用户滚动 48 小时 RTP 严格上限ppm'' AFTER jackpot_user_72h_rtp_max_ppm, ALGORITHM=INSTANT',
'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @has_old_column := (SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'lucky_gift_rule_versions' AND COLUMN_NAME = 'jackpot_user_72h_rtp_max_ppm');
SET @has_new_column := (SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'lucky_gift_rule_versions' AND COLUMN_NAME = 'jackpot_user_48h_rtp_max_ppm');
SET @ddl := IF(@has_old_column > 0 AND @has_new_column > 0,
'UPDATE lucky_gift_rule_versions SET jackpot_user_48h_rtp_max_ppm = jackpot_user_72h_rtp_max_ppm WHERE jackpot_user_48h_rtp_max_ppm = 0 AND jackpot_user_72h_rtp_max_ppm <> 0', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl := IF(@has_old_column > 0 AND @has_new_column > 0,
'UPDATE lucky_gift_rule_versions SET jackpot_user_72h_rtp_max_ppm = jackpot_user_48h_rtp_max_ppm WHERE jackpot_user_72h_rtp_max_ppm = 0 AND jackpot_user_48h_rtp_max_ppm <> 0', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- 历史窗口无法可靠推导首笔、末笔和闭窗业务时间,因此默认 0 明确表示 unknown新运行逻辑只对新窗口写真实 paid_at。
SET @ddl := IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'lucky_rtp_windows' AND COLUMN_NAME = 'started_at_ms') = 0,
'ALTER TABLE lucky_rtp_windows ADD COLUMN started_at_ms BIGINT NOT NULL DEFAULT 0 COMMENT ''本窗口首笔扣费事实时间UTC epoch ms0 表示历史窗口未知'', ALGORITHM=INSTANT', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl := IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'lucky_rtp_windows' AND COLUMN_NAME = 'last_paid_at_ms') = 0,
'ALTER TABLE lucky_rtp_windows ADD COLUMN last_paid_at_ms BIGINT NOT NULL DEFAULT 0 COMMENT ''本窗口末笔扣费事实时间UTC epoch ms0 表示历史窗口未知'', ALGORITHM=INSTANT', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl := IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'lucky_rtp_windows' AND COLUMN_NAME = 'closed_at_ms') = 0,
'ALTER TABLE lucky_rtp_windows ADD COLUMN closed_at_ms BIGINT NOT NULL DEFAULT 0 COMMENT ''达到结算流水并冻结资格的时间UTC epoch ms未关闭或历史未知为 0'', ALGORITHM=INSTANT', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- 用户轮次行与全局窗口同版本、同 window_index 对齐。滚动 48 小时分子/分母在闭窗时冻结,
-- qualification_status 只允许业务层在 pending/attempted/ineligible/expired 间单向推进,资格不会跨规则版本复用。
CREATE TABLE IF NOT EXISTS lucky_user_rtp_windows (
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码',
pool_id VARCHAR(96) NOT NULL COMMENT '幸运礼物奖池 ID',
rule_version BIGINT NOT NULL COMMENT '不可变规则版本;跨版本资格不能继承',
window_index BIGINT NOT NULL COMMENT '对应全局 RTP 结算窗口序号',
user_id BIGINT NOT NULL COMMENT '用户 ID',
user_registered_at_ms BIGINT NOT NULL DEFAULT 0 COMMENT '用户参与本轮时由用户 owner 固化的账号创建时间;缺失时资格关闭',
wager_coins BIGINT NOT NULL DEFAULT 0 COMMENT '用户在本结算轮次的总消耗金币',
payout_coins BIGINT NOT NULL DEFAULT 0 COMMENT '用户在本结算轮次的总返奖金币',
rolling_48h_wager_coins BIGINT NOT NULL DEFAULT 0 COMMENT '闭窗时最近 48 小时总消耗快照',
rolling_48h_payout_coins BIGINT NOT NULL DEFAULT 0 COMMENT '闭窗时最近 48 小时总返奖快照',
qualification_status VARCHAR(16) NOT NULL DEFAULT 'pending' COMMENT 'pending/attempted/ineligible/expired',
attempted_draw_id VARCHAR(128) NOT NULL DEFAULT '' COMMENT '消费本轮资格的下一次子抽 ID未尝试为空',
evaluated_at_ms BIGINT NOT NULL DEFAULT 0 COMMENT '本轮资格判定或消费时间UTC epoch ms',
created_at_ms BIGINT NOT NULL COMMENT '创建时间UTC epoch ms',
updated_at_ms BIGINT NOT NULL COMMENT '更新时间UTC epoch ms',
PRIMARY KEY (app_code, pool_id, rule_version, window_index, user_id),
KEY idx_lucky_user_rtp_windows_pending (app_code, pool_id, rule_version, user_id, qualification_status, window_index)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户按全局结算轮次聚合的 RTP 与一次性大奖资格';
-- 兼容极少数曾提前创建初版用户轮次表的环境;正式 CREATE 已包含这些列,正常路径均为空操作。
SET @ddl := IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'lucky_user_rtp_windows' AND COLUMN_NAME = 'user_registered_at_ms') = 0,
'ALTER TABLE lucky_user_rtp_windows ADD COLUMN user_registered_at_ms BIGINT NOT NULL DEFAULT 0 COMMENT ''用户参与本轮时由用户 owner 固化的账号创建时间;缺失时资格关闭'', ALGORITHM=INSTANT', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl := IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'lucky_user_rtp_windows' AND COLUMN_NAME = 'rolling_48h_wager_coins') = 0,
'ALTER TABLE lucky_user_rtp_windows ADD COLUMN rolling_48h_wager_coins BIGINT NOT NULL DEFAULT 0 COMMENT ''闭窗时最近 48 小时总消耗快照'', ALGORITHM=INSTANT', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl := IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'lucky_user_rtp_windows' AND COLUMN_NAME = 'rolling_48h_payout_coins') = 0,
'ALTER TABLE lucky_user_rtp_windows ADD COLUMN rolling_48h_payout_coins BIGINT NOT NULL DEFAULT 0 COMMENT ''闭窗时最近 48 小时总返奖快照'', ALGORITHM=INSTANT', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl := IF((SELECT COUNT(*) FROM information_schema.STATISTICS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'lucky_user_rtp_windows' AND INDEX_NAME = 'idx_lucky_user_rtp_windows_pending') = 0,
'ALTER TABLE lucky_user_rtp_windows ADD INDEX idx_lucky_user_rtp_windows_pending (app_code, pool_id, rule_version, user_id, qualification_status, window_index), ALGORITHM=INPLACE, LOCK=NONE', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;

View File

@ -57,27 +57,37 @@ type Config struct {
AnchorRatePPM int64 `json:"anchor_rate_ppm"`
ControlBandPPM int64 `json:"control_band_ppm"`
// SettlementWindowWager 是 dynamic_v3 结算窗口的真实金币流水阈值;不能按参考礼物价格换算成抽数。
SettlementWindowWager int64 `json:"settlement_window_wager"`
GlobalWindowDraws int64 `json:"global_window_draws"`
GiftWindowDraws int64 `json:"gift_window_draws"`
NoviceMaxEquivalentDraws int64 `json:"novice_max_equivalent_draws"`
NormalMaxEquivalentDraws int64 `json:"normal_max_equivalent_draws"`
HighMultiplier int64 `json:"high_multiplier"`
HighWaterPoolMultiple int64 `json:"high_water_pool_multiple"`
InitialBasePool int64 `json:"initial_base_pool"`
BasePoolReserve int64 `json:"base_pool_reserve"`
InitialPoolCoins int64 `json:"initial_pool_coins"`
LossStreakGuarantee int64 `json:"loss_streak_guarantee"`
LowWatermarkCoins int64 `json:"low_watermark_coins"`
LowWaterNonzeroFactorPPM int64 `json:"low_water_nonzero_factor_ppm"`
HighWatermarkCoins int64 `json:"high_watermark_coins"`
HighWaterNonzeroFactorPPM int64 `json:"high_water_nonzero_factor_ppm"`
RechargeBoostWindowMS int64 `json:"recharge_boost_window_ms"`
RechargeBoostFactorPPM int64 `json:"recharge_boost_factor_ppm"`
JackpotMultiplierPPMs []int64 `json:"jackpot_multiplier_ppms"`
JackpotGlobalRTPMaxPPM int64 `json:"jackpot_global_rtp_max_ppm"`
JackpotUserDayRTPMaxPPM int64 `json:"jackpot_user_day_rtp_max_ppm"`
JackpotUser72hRTPMaxPPM int64 `json:"jackpot_user_72h_rtp_max_ppm"`
SettlementWindowWager int64 `json:"settlement_window_wager"`
GlobalWindowDraws int64 `json:"global_window_draws"`
GiftWindowDraws int64 `json:"gift_window_draws"`
NoviceMaxEquivalentDraws int64 `json:"novice_max_equivalent_draws"`
NormalMaxEquivalentDraws int64 `json:"normal_max_equivalent_draws"`
HighMultiplier int64 `json:"high_multiplier"`
HighWaterPoolMultiple int64 `json:"high_water_pool_multiple"`
InitialBasePool int64 `json:"initial_base_pool"`
BasePoolReserve int64 `json:"base_pool_reserve"`
InitialPoolCoins int64 `json:"initial_pool_coins"`
LossStreakGuarantee int64 `json:"loss_streak_guarantee"`
LowWatermarkCoins int64 `json:"low_watermark_coins"`
LowWaterNonzeroFactorPPM int64 `json:"low_water_nonzero_factor_ppm"`
HighWatermarkCoins int64 `json:"high_watermark_coins"`
HighWaterNonzeroFactorPPM int64 `json:"high_water_nonzero_factor_ppm"`
RechargeBoostWindowMS int64 `json:"recharge_boost_window_ms"`
RechargeBoostFactorPPM int64 `json:"recharge_boost_factor_ppm"`
// User24HourRTPThresholdPPM/Factor 只调整普通非零奖档;大奖资格和保底仍由各自策略独立决定。
User24HourRTPThresholdPPM int64 `json:"user_24h_rtp_threshold_ppm"`
User24HourOrdinaryWinFactorPPM int64 `json:"user_24h_ordinary_win_factor_ppm"`
JackpotMultiplierPPMs []int64 `json:"jackpot_multiplier_ppms"`
JackpotGlobalRTPMaxPPM int64 `json:"jackpot_global_rtp_max_ppm"`
JackpotUserRoundRTPMaxPPM int64 `json:"jackpot_user_round_rtp_max_ppm"`
JackpotUser48hRTPMaxPPM int64 `json:"jackpot_user_48h_rtp_max_ppm"`
// Deprecated aliases only keep in-process callers built against the previous draft source-compatible.
// Persistence, protobuf and runtime decisions use the round/48h fields above; these aliases never create a second rule meaning.
JackpotUserDayRTPMaxPPM int64 `json:"-"`
JackpotUser72hRTPMaxPPM int64 `json:"-"`
// SettledRoundRTPEligibility marks configs received through the round/48h contract.
// It is transport metadata only: persistence stores the two canonical limits and reconstructs this marker on read.
SettledRoundRTPEligibility bool `json:"-"`
JackpotSpendThresholdCoins int64 `json:"jackpot_spend_threshold_coins"`
MaxJackpotHitsPerUserDay int64 `json:"max_jackpot_hits_per_user_day"`
MaxSinglePayout int64 `json:"max_single_payout"`
@ -117,35 +127,42 @@ type RuleStage struct {
// RuleConfig 是幸运礼物不可变配置版本快照。fixed_v2 与 dynamic_v3 共用基础 RTP 契约,动态字段只由后者解释;
// 比例和倍率统一使用 ppm后台百分比/倍率业务单位只在发布入口转换。
type RuleConfig struct {
AppCode string `json:"app_code"`
PoolID string `json:"pool_id"`
RuleVersion int64 `json:"rule_version"`
StrategyVersion string `json:"strategy_version"`
Enabled bool `json:"enabled"`
TargetRTPPPM int64 `json:"target_rtp_ppm"`
PoolRatePPM int64 `json:"pool_rate_ppm"`
ProfitRatePPM int64 `json:"profit_rate_ppm"`
AnchorRatePPM int64 `json:"anchor_rate_ppm"`
SettlementWindowWager int64 `json:"settlement_window_wager"`
ControlBandPPM int64 `json:"control_band_ppm"`
GiftPriceReference int64 `json:"gift_price_reference"`
NoviceMaxEquivalentDraws int64 `json:"novice_max_equivalent_draws"`
NormalMaxEquivalentDraws int64 `json:"normal_max_equivalent_draws"`
EffectiveFromMS int64 `json:"effective_from_ms"`
CreatedByAdminID int64 `json:"created_by_admin_id"`
CreatedAtMS int64 `json:"created_at_ms"`
InitialPoolCoins int64 `json:"initial_pool_coins"`
LossStreakGuarantee int64 `json:"loss_streak_guarantee"`
LowWatermarkCoins int64 `json:"low_watermark_coins"`
LowWaterNonzeroFactorPPM int64 `json:"low_water_nonzero_factor_ppm"`
HighWatermarkCoins int64 `json:"high_watermark_coins"`
HighWaterNonzeroFactorPPM int64 `json:"high_water_nonzero_factor_ppm"`
RechargeBoostWindowMS int64 `json:"recharge_boost_window_ms"`
RechargeBoostFactorPPM int64 `json:"recharge_boost_factor_ppm"`
JackpotMultiplierPPMs []int64 `json:"jackpot_multiplier_ppms"`
JackpotGlobalRTPMaxPPM int64 `json:"jackpot_global_rtp_max_ppm"`
JackpotUserDayRTPMaxPPM int64 `json:"jackpot_user_day_rtp_max_ppm"`
JackpotUser72hRTPMaxPPM int64 `json:"jackpot_user_72h_rtp_max_ppm"`
AppCode string `json:"app_code"`
PoolID string `json:"pool_id"`
RuleVersion int64 `json:"rule_version"`
StrategyVersion string `json:"strategy_version"`
Enabled bool `json:"enabled"`
TargetRTPPPM int64 `json:"target_rtp_ppm"`
PoolRatePPM int64 `json:"pool_rate_ppm"`
ProfitRatePPM int64 `json:"profit_rate_ppm"`
AnchorRatePPM int64 `json:"anchor_rate_ppm"`
SettlementWindowWager int64 `json:"settlement_window_wager"`
ControlBandPPM int64 `json:"control_band_ppm"`
GiftPriceReference int64 `json:"gift_price_reference"`
NoviceMaxEquivalentDraws int64 `json:"novice_max_equivalent_draws"`
NormalMaxEquivalentDraws int64 `json:"normal_max_equivalent_draws"`
EffectiveFromMS int64 `json:"effective_from_ms"`
CreatedByAdminID int64 `json:"created_by_admin_id"`
CreatedAtMS int64 `json:"created_at_ms"`
InitialPoolCoins int64 `json:"initial_pool_coins"`
LossStreakGuarantee int64 `json:"loss_streak_guarantee"`
LowWatermarkCoins int64 `json:"low_watermark_coins"`
LowWaterNonzeroFactorPPM int64 `json:"low_water_nonzero_factor_ppm"`
HighWatermarkCoins int64 `json:"high_watermark_coins"`
HighWaterNonzeroFactorPPM int64 `json:"high_water_nonzero_factor_ppm"`
RechargeBoostWindowMS int64 `json:"recharge_boost_window_ms"`
RechargeBoostFactorPPM int64 `json:"recharge_boost_factor_ppm"`
// 0/100% 是历史兼容 sentinel阈值大于 0 时factor 表示触发后保留的普通中奖权重。
User24HourRTPThresholdPPM int64 `json:"user_24h_rtp_threshold_ppm"`
User24HourOrdinaryWinFactorPPM int64 `json:"user_24h_ordinary_win_factor_ppm"`
JackpotMultiplierPPMs []int64 `json:"jackpot_multiplier_ppms"`
JackpotGlobalRTPMaxPPM int64 `json:"jackpot_global_rtp_max_ppm"`
JackpotUserRoundRTPMaxPPM int64 `json:"jackpot_user_round_rtp_max_ppm"`
JackpotUser48hRTPMaxPPM int64 `json:"jackpot_user_48h_rtp_max_ppm"`
// Deprecated aliases are normalized at service/repository boundaries and are never persisted as separate values.
JackpotUserDayRTPMaxPPM int64 `json:"-"`
JackpotUser72hRTPMaxPPM int64 `json:"-"`
SettledRoundRTPEligibility bool `json:"-"`
JackpotSpendThresholdCoins int64 `json:"jackpot_spend_threshold_coins"`
MaxJackpotHitsPerUserDay int64 `json:"max_jackpot_hits_per_user_day"`
MaxSinglePayout int64 `json:"max_single_payout"`
@ -165,23 +182,26 @@ type CheckCommand struct {
}
type DrawCommand struct {
CommandID string
PoolID string
UserID int64
TargetUserID int64
DeviceID string
RoomID string
AnchorID string
GiftID string
GiftCount int32
CoinSpent int64
PaidAtMS int64
VisibleRegionID int64
CountryID int64
Recharge7DCoins int64
Recharge30DCoins int64
LastRechargedAtMS int64
GiftIncomeCoins int64
CommandID string
PoolID string
UserID int64
TargetUserID int64
DeviceID string
RoomID string
AnchorID string
GiftID string
GiftCount int32
CoinSpent int64
PaidAtMS int64
// UserRegisteredAtMS is the user owner snapshot captured by gateway at the original gift request.
// Zero is tolerated for an old recovered command but makes the 48-hour jackpot maturity gate fail closed.
UserRegisteredAtMS int64
VisibleRegionID int64
CountryID int64
Recharge7DCoins int64
Recharge30DCoins int64
LastRechargedAtMS int64
GiftIncomeCoins int64
// Sender* 是扣费时刻的公开展示快照owner outbox 必须携带快照,避免下游为一条飘屏同步反查 user-service。
SenderName string
SenderAvatar string
@ -327,6 +347,138 @@ type DrawQuery struct {
ExternalOnly bool
}
const (
UserProfileIdentityInternal = "internal"
UserProfileIdentityExternal = "external"
UserProfileHitZero = "zero"
UserProfileHitOrdinary = "ordinary"
UserProfileHitRTPCompensation = "rtp_compensation"
UserProfileHitSpendMilestone = "spend_milestone"
)
// UserProfileQuery 只允许按已经物化的身份键分页。内部短号、长号和靓号由 admin-server
// 通过 user-service 解析成 InternalUserID外部用户只用原始 ExternalUserID 搜索,稳定哈希绝不跨服务暴露。
type UserProfileQuery struct {
AppCode string
PoolID string
IdentityType string
InternalUserID int64
ExternalUserID string
Stage string
Status string
SortBy string
SortDirection string
Cursor string
Page int32
PageSize int32
JackpotPage int32
JackpotPageSize int32
JackpotCursor string
}
// UserProfileWindow 是严格时间窗内的用户资金结果。NetCoins 从用户视角计算为返奖减消耗;
// RTP 分母为 0 时保持 0并由 HasRTP 区分“真实 0%”和“没有样本”。
type UserProfileWindow struct {
Draws int64
WagerCoins int64
PayoutCoins int64
NetCoins int64
RTPPPM int64
HasRTP bool
}
// UserProfile 是 app+pool+用户的运营画像,不携带外部 strategy_user_id。
// StrategyUserID 只在 lucky-gift-service 仓储层关联 current statetransport 映射时必须丢弃。
type UserProfile struct {
AppCode string
PoolID string
IdentityType string
IdentityKey string
StrategyUserID int64
InternalUserID int64
ExternalUserID string
RuleVersion int64
StrategyVersion string
Stage string
PaidDraws int64
EquivalentDraws int64
LossStreak int64
PendingSpendJackpotTokens int64
GuaranteeDrawsRemaining int64
DownweightActive bool
DownweightFactorPPM int64
User24HourRTPThresholdPPM int64
OneHour UserProfileWindow
TwentyFourHour UserProfileWindow
FortyEightHour UserProfileWindow
Lifetime UserProfileWindow
BaseRewardCoins int64
OrdinaryWinCount int64
HighMultiplierOrdinaryWinCount int64
RTPCompensationJackpotCount int64
CumulativeSpendJackpotCount int64
GuaranteeHitCount int64
ZeroDrawCount int64
GrantedDrawCount int64
PendingDrawCount int64
FailedDrawCount int64
MaxMultiplierPPM int64
MaxRewardCoins int64
FirstDrawAtMS int64
LastDrawAtMS int64
AggregatedAtMS int64
DataLagMS int64
}
type UserProfileMultiplierStat struct {
HitType string
MultiplierPPM int64
DrawCount int64
WagerCoins int64
PayoutCoins int64
}
type UserProfileJackpotCondition struct {
Name string
Numerator int64
Denominator int64
RatioPPM int64
LimitPPM int64
FactorPPM int64
Passed bool
Reason string
}
type UserProfileJackpotHit struct {
// SourceType 只参与 owner service 的稳定 keyset 游标,不通过 protobuf/HTTP 对外展示。
SourceType string
DrawID string
RequestID string
RuleVersion int64
Stage string
Mechanism string
ReasonCode string
ReasonSummary string
TierID string
MultiplierPPM int64
WagerCoins int64
PayoutCoins int64
OccurredAtMS int64
Conditions []UserProfileJackpotCondition
}
type UserProfileDetail struct {
Profile UserProfile
MultiplierDistribution []UserProfileMultiplierStat
JackpotHits []UserProfileJackpotHit
JackpotHitTotal int64
JackpotPage int32
JackpotPageSize int32
NextJackpotCursor string
JackpotHasMore bool
}
// ExternalDrawCommand 是外部 App “已扣费后请求抽奖”的业务事实。
// HyApp 不接管外部 App 余额,因此这里没有 target_user_id、room_id 或 wallet command_id幂等边界固定为
// app_code + request_id任何重试都必须返回同一条抽奖结果。
@ -334,15 +486,18 @@ type ExternalDrawCommand struct {
AppCode string
ExternalUserID string
// DeviceID 是外部调用方在自己认证/签名边界内绑定的稳定设备标识dynamic_v3 必填。
DeviceID string
RequestID string
GiftCount int64
UnitAmount int64
TotalAmount int64
Currency string
PaidAtMS int64
MetadataJSON string
PoolID string
DeviceID string
RequestID string
GiftCount int64
UnitAmount int64
TotalAmount int64
Currency string
PaidAtMS int64
// UserRegisteredAtMS is supplied by the external App's authenticated user-data boundary.
// Missing or inconsistent facts do not block an ordinary draw, but can never mature a compensation jackpot.
UserRegisteredAtMS int64
MetadataJSON string
PoolID string
}
// ExternalDrawResult 是返回给外部 App 的最终抽奖结果。

View File

@ -26,7 +26,8 @@ const (
StrategyReasonPoolInsufficient = "pool_insufficient_w_gt_p"
StrategyReasonRiskCapacity = "risk_capacity_exceeded"
StrategyReasonNoPayableJackpot = "no_payable_jackpot_candidate"
StrategyJackpotMechanismMilestone = "milestone_token"
StrategyReasonSpendGlobalRTPBlocked = "spend_global_rtp_gate_failed"
StrategyJackpotMechanismMilestone = "spend_milestone"
StrategyJackpotMechanismRTPCompensation = "rtp_compensation"
StrategyDrawKindOriginal = "original"
StrategyDrawKindRedraw = "redraw"
@ -35,11 +36,21 @@ const (
StrategyRemovalMissProtection = "miss_protection_excludes_zero"
StrategyRemovalPWRedrawZero = "w_gt_p_redraw_excludes_zero"
StrategyConditionGlobalRTP = "global_rtp"
StrategyConditionUserDayRTP = "user_day_rtp"
StrategyConditionUser72HourRTP = "user_72h_rtp"
StrategyConditionDailyJackpotLimit = "daily_jackpot_limit"
StrategyConditionPayableJackpot = "payable_jackpot_candidate"
StrategyDefaultZeroTierID = "0x"
StrategyConditionSpendGlobalRTP = "spend_global_rtp"
StrategyConditionSpendDailyJackpotLimit = "spend_daily_jackpot_limit"
StrategyConditionSettledRound = "settled_round_qualification"
StrategyConditionUserRoundRTP = "user_round_rtp"
StrategyConditionUser48HourRTP = "user_48h_rtp"
StrategyConditionUser48HourMaturity = "user_registered_48h"
// StrategyConditionUser24HourRTPDownweight is an ordinary-draw control only. A trace can show
// this condition as active on a jackpot decision, but neither jackpot selection path reads it.
StrategyConditionUser24HourRTPDownweight = "user_24h_rtp_downweight"
// Deprecated trace names remain available to the pure legacy compatibility path only.
StrategyConditionUserDayRTP = "user_day_rtp"
StrategyConditionUser72HourRTP = "user_72h_rtp"
StrategyConditionDailyJackpotLimit = "daily_jackpot_limit"
StrategyConditionPayableJackpot = "payable_jackpot_candidate"
StrategyDefaultZeroTierID = "0x"
)
var (
@ -76,9 +87,10 @@ func (r *seededStrategyRandom) Int63n(n int64) (int64, error) {
return r.source.Int63n(n), nil
}
// StrategyTier is one configured multiplier. BaseWeightPPM is the ordinary draw
// weight; JackpotWeight only chooses among the configured large-prize set after a
// jackpot mechanism has passed all RTP, pool, daily-limit and risk gates.
// StrategyTier is one source-specific selectable tier, not a multiplier identity.
// The same multiplier may have an ordinary tier and a separate jackpot tier:
// BaseWeightPPM drives only ordinary draws, while JackpotWeight is used only after
// all RTP, pool, daily-limit and risk gates pass.
type StrategyTier struct {
ID string `json:"id"`
MultiplierPPM int64 `json:"multiplier_ppm"`
@ -114,6 +126,10 @@ type StrategyConfig struct {
RechargeFactorPPM int64 `json:"recharge_factor_ppm"`
RechargeBoostStartMS int64 `json:"recharge_boost_start_ms"`
RechargeBoostEndMS int64 `json:"recharge_boost_end_ms"`
// A zero threshold disables the rolling control for historical rules. When enabled, FactorPPM
// is the retained ordinary winning weight (700000 means keep 70%, not subtract 70 points).
User24HourRTPThresholdPPM int64 `json:"user_24h_rtp_threshold_ppm"`
User24HourOrdinaryWinFactorPPM int64 `json:"user_24h_ordinary_win_factor_ppm"`
MissProtectionZeroDraws int64 `json:"miss_protection_zero_draws"`
DailyJackpotLimit int64 `json:"daily_jackpot_limit"`
@ -122,8 +138,14 @@ type StrategyConfig struct {
JackpotMechanism1Enabled bool `json:"jackpot_mechanism_1_enabled"`
JackpotMechanism2Enabled bool `json:"jackpot_mechanism_2_enabled"`
GlobalRTPMaxPPM int64 `json:"global_rtp_max_ppm"`
UserDayRTPMaxPPM int64 `json:"user_day_rtp_max_ppm"`
User72HourRTPMaxPPM int64 `json:"user_72h_rtp_max_ppm"`
UserRoundRTPMaxPPM int64 `json:"user_round_rtp_max_ppm"`
User48HourRTPMaxPPM int64 `json:"user_48h_rtp_max_ppm"`
// SettledRoundRTPEligibility switches production V3 to the closed-window qualification flow.
// False preserves the isolated legacy kernel API used by fixed historical simulations; runtime V3 always sets true.
SettledRoundRTPEligibility bool `json:"settled_round_rtp_eligibility"`
// Deprecated aliases are read only by the legacy kernel branch.
UserDayRTPMaxPPM int64 `json:"user_day_rtp_max_ppm"`
User72HourRTPMaxPPM int64 `json:"user_72h_rtp_max_ppm"`
RechargeStages []StrategyRechargeStage `json:"recharge_stages"`
}
@ -140,15 +162,31 @@ type StrategyRTP struct {
// must update it atomically with the draw record; Version is provided for CAS/optimistic
// locking but the kernel itself intentionally performs no I/O.
type StrategyState struct {
PoolBalanceCoins int64 `json:"pool_balance_coins"`
ConsecutiveZeroDraws int64 `json:"consecutive_zero_draws"`
PendingMilestoneTokens int64 `json:"pending_milestone_tokens"`
UserDailyJackpotWins int64 `json:"user_daily_jackpot_wins"`
UserDaySpendCoins int64 `json:"user_day_spend_coins"`
GlobalRTP StrategyRTP `json:"global_rtp"`
UserDayRTP StrategyRTP `json:"user_day_rtp"`
User72HourRTP StrategyRTP `json:"user_72h_rtp"`
Version int64 `json:"version"`
PoolBalanceCoins int64 `json:"pool_balance_coins"`
ConsecutiveZeroDraws int64 `json:"consecutive_zero_draws"`
// PendingMilestoneTokens is the legacy pure-kernel field. Production dynamic_v3 never reads it:
// the separate field below prevents tokens created by the retired implementation from becoming
// a jackpot promise after the dual-strategy rollout.
PendingMilestoneTokens int64 `json:"pending_milestone_tokens"`
PendingSpendJackpotTokens int64 `json:"pending_spend_jackpot_tokens"`
UserDailyJackpotWins int64 `json:"user_daily_jackpot_wins"`
UserDaySpendCoins int64 `json:"user_day_spend_coins"`
// SpendGlobalRTP is the latest fully closed platform window observed before this subdraw. It is
// deliberately independent from GlobalRTP, which is frozen with a user's one-shot 48h qualification.
SpendGlobalRTP StrategyRTP `json:"spend_global_rtp"`
GlobalRTP StrategyRTP `json:"global_rtp"`
UserRoundRTP StrategyRTP `json:"user_round_rtp"`
User48HourRTP StrategyRTP `json:"user_48h_rtp"`
// User24HourRTP is loaded once at batch entry and advanced in memory after each subdraw. The
// current subdraw therefore cannot downweight itself, while the next subdraw sees its outcome.
User24HourRTP StrategyRTP `json:"user_24h_rtp"`
SettledRoundPending bool `json:"settled_round_pending"`
QualificationClosedAtMS int64 `json:"qualification_closed_at_ms"`
UserRegisteredAtMS int64 `json:"user_registered_at_ms"`
// Deprecated aliases are updated only by the legacy kernel branch.
UserDayRTP StrategyRTP `json:"user_day_rtp"`
User72HourRTP StrategyRTP `json:"user_72h_rtp"`
Version int64 `json:"version"`
}
// StrategyRiskCapacity contains the six hard payout ceilings from the product rule.
@ -216,6 +254,7 @@ type StrategyConditionTrace struct {
Denominator int64 `json:"denominator,omitempty"`
RatioPPM int64 `json:"ratio_ppm,omitempty"`
LimitPPM int64 `json:"limit_ppm,omitempty"`
FactorPPM int64 `json:"factor_ppm,omitempty"`
Passed bool `json:"passed"`
Reason string `json:"reason"`
}
@ -237,9 +276,14 @@ type DecisionTrace struct {
FinalReason string `json:"final_reason"`
BlockedReason string `json:"blocked_reason,omitempty"`
JackpotMechanism string `json:"jackpot_mechanism,omitempty"`
MilestoneTokenConsumed bool `json:"milestone_token_consumed"`
MilestoneTokenRetained bool `json:"milestone_token_retained"`
MilestoneTokensEarned int64 `json:"milestone_tokens_earned"`
MilestoneTokenConsumed bool `json:"spend_milestone_token_consumed"`
MilestoneTokenRetained bool `json:"spend_milestone_token_retained"`
MilestoneTokensEarned int64 `json:"spend_milestone_tokens_earned"`
// The exact before/after balance is persisted for every draw, including a successful 48h
// compensation that deliberately leaves an existing spend token untouched. The boolean fields
// above keep their event meaning; these counters make multi-token incident replay unambiguous.
SpendMilestoneTokensBefore int64 `json:"pending_spend_jackpot_tokens_before"`
SpendMilestoneTokensAfter int64 `json:"pending_spend_jackpot_tokens_after"`
}
type StrategyDecision struct {
@ -263,6 +307,11 @@ type StrategyFundSplit struct {
type weightedStrategyTier struct {
Tier StrategyTier
Weight int64
// MissProtectionWeight freezes the ordinary probability after the existing water/recharge
// normalization but before the optional 24-hour RTP downweight. Protection is a hard product
// guarantee: integer apportioning may round a small retained tier to zero, but that rounding must
// not make an otherwise payable ordinary prize ineligible once the miss threshold is reached.
MissProtectionWeight int64
}
// DefaultLuckyGiftStrategyConfig is a financially safe production baseline: its
@ -285,6 +334,7 @@ func DefaultLuckyGiftStrategyConfig() StrategyConfig {
LowWaterThresholdCoins: 10_000_000, HighWaterThresholdCoins: 20_000_000,
LowWaterFactorPPM: 700_000, HighWaterFactorPPM: 1_300_000,
RechargeFactorPPM: 1_100_000, RechargeBoostStartMS: 0, RechargeBoostEndMS: 5 * 60 * 1000,
User24HourRTPThresholdPPM: 1_500_000, User24HourOrdinaryWinFactorPPM: 700_000,
// 累计消费门槛是不可变规则版本的动态金币配置0 表示该内核默认值尚未配置。
MissProtectionZeroDraws: 5, DailyJackpotLimit: 5, MilestoneSpendCoins: 0,
JackpotMechanism1Enabled: true, JackpotMechanism2Enabled: true,
@ -379,14 +429,14 @@ func PreviewLuckyGiftStrategyWeights(config StrategyConfig, state StrategyState,
return "", nil, err
}
stage := SelectLuckyGiftRechargeStage(config, input.Recharge7DCoins, input.Recharge30DCoins)
_, traces, err := buildStrategyWeights(config, stage, available, input)
_, traces, err := buildStrategyWeights(config, state, stage, available, input)
return stage, traces, err
}
// DecideLuckyGiftStrategy is the shared production/simulation kernel. Its ordering is
// intentional and traceable: persisted mechanism-2 token -> mechanism-1 RTP gates ->
// ordinary weighted P/W draw and redraw -> state transition. A blocked special
// mechanism falls through to an ordinary draw while preserving its token.
// DecideLuckyGiftStrategy is the shared production/simulation kernel. Production V3 evaluates the
// one-shot closed-round/48h qualification first, then the independent durable spend milestone, and
// finally the ordinary weighted draw. A spend token is consumed only by a paid spend jackpot; all
// failed global-RTP, daily-limit, pool and risk checks leave it available for a later paid draw.
func DecideLuckyGiftStrategy(config StrategyConfig, state StrategyState, input StrategyInput, random StrategyRandomSource) (StrategyDecision, error) {
if random == nil {
return StrategyDecision{}, ErrStrategyRandomSource
@ -399,7 +449,7 @@ func DecideLuckyGiftStrategy(config StrategyConfig, state StrategyState, input S
return StrategyDecision{}, err
}
stage := SelectLuckyGiftRechargeStage(config, input.Recharge7DCoins, input.Recharge30DCoins)
weighted, weightTrace, err := buildStrategyWeights(config, stage, available, input)
weighted, weightTrace, err := buildStrategyWeights(config, state, stage, available, input)
if err != nil {
return StrategyDecision{}, err
}
@ -407,48 +457,45 @@ func DecideLuckyGiftStrategy(config StrategyConfig, state StrategyState, input S
Stage: stage, PoolBeforeCoins: state.PoolBalanceCoins,
PoolContributionCoins: input.PoolContributionCoins, AvailablePoolCoins: available,
EffectiveRiskCapacityCoins: input.RiskCapacity.capacity(), Weights: weightTrace,
SpendMilestoneTokensBefore: pendingSpendJackpotTokens(config, state),
SpendMilestoneTokensAfter: pendingSpendJackpotTokens(config, state),
}
// Record the personal rolling condition before either jackpot path runs. It is intentionally
// observational for jackpots: only buildStrategyWeights consumes the active flag.
condition, _ := user24HourRTPDownweightCondition(config, state.User24HourRTP)
trace.Conditions = append(trace.Conditions, condition)
// A persisted milestone token is a promise about the next draw, so it outranks the
// opportunistic RTP compensation mechanism. Insufficient pool/risk capacity keeps
// the token durable and lets the user retain the ordinary draw they paid for.
if config.JackpotMechanism2Enabled && state.PendingMilestoneTokens > 0 {
tier, payout, ok, reason, removed, err := selectPayableJackpot(config, state, input, available, random)
if config.SettledRoundRTPEligibility {
decision, decided, err := tryRTPCompensationJackpot(config, state, input, available, random, &trace)
if err != nil {
return StrategyDecision{}, err
}
trace.Removed = append(trace.Removed, removed...)
if ok {
trace.JackpotMechanism = StrategyJackpotMechanismMilestone
trace.MilestoneTokenConsumed = true
trace.FinalReason = StrategyReasonMilestoneJackpot
trace.Draws = append(trace.Draws, StrategyDrawTrace{Sequence: 1, Kind: StrategyDrawKindMilestone, TierID: tier.ID, MultiplierPPM: tier.MultiplierPPM, WeightPPM: jackpotWeight(tier), PayoutCoins: payout})
return finalizeStrategyDecision(config, state, input, available, tier, payout, StrategyJackpotMechanismMilestone, true, trace)
if decided {
return decision, nil
}
trace.MilestoneTokenRetained = true
trace.BlockedReason = reason
trace.Conditions = append(trace.Conditions, StrategyConditionTrace{Name: StrategyConditionPayableJackpot, Passed: false, Reason: reason})
}
if config.JackpotMechanism1Enabled {
conditions, eligible := mechanismOneConditions(config, state)
trace.Conditions = append(trace.Conditions, conditions...)
if eligible {
tier, payout, ok, reason, removed, err := selectPayableJackpot(config, state, input, available, random)
if err != nil {
return StrategyDecision{}, err
}
trace.Removed = append(trace.Removed, removed...)
trace.Conditions = append(trace.Conditions, StrategyConditionTrace{Name: StrategyConditionPayableJackpot, Passed: ok, Reason: reason})
if ok {
trace.JackpotMechanism = StrategyJackpotMechanismRTPCompensation
trace.FinalReason = StrategyReasonRTPCompensationJackpot
trace.Draws = append(trace.Draws, StrategyDrawTrace{Sequence: 1, Kind: StrategyDrawKindRTPCompensation, TierID: tier.ID, MultiplierPPM: tier.MultiplierPPM, WeightPPM: jackpotWeight(tier), PayoutCoins: payout})
return finalizeStrategyDecision(config, state, input, available, tier, payout, StrategyJackpotMechanismRTPCompensation, false, trace)
}
if trace.BlockedReason == "" {
trace.BlockedReason = reason
}
decision, decided, err = trySpendMilestoneJackpot(config, state, input, available, random, true, &trace)
if err != nil {
return StrategyDecision{}, err
}
if decided {
return decision, nil
}
} else {
// The isolated legacy kernel keeps its historical order and gate shape. Runtime dynamic_v3
// always takes the branch above and therefore never reads PendingMilestoneTokens.
decision, decided, err := trySpendMilestoneJackpot(config, state, input, available, random, false, &trace)
if err != nil {
return StrategyDecision{}, err
}
if decided {
return decision, nil
}
decision, decided, err = tryRTPCompensationJackpot(config, state, input, available, random, &trace)
if err != nil {
return StrategyDecision{}, err
}
if decided {
return decision, nil
}
}
@ -467,11 +514,91 @@ func DecideLuckyGiftStrategy(config StrategyConfig, state StrategyState, input S
return finalizeStrategyDecision(config, state, input, available, tier, payout, "", false, trace)
}
func tryRTPCompensationJackpot(config StrategyConfig, state StrategyState, input StrategyInput, available int64, random StrategyRandomSource, trace *DecisionTrace) (StrategyDecision, bool, error) {
if !config.JackpotMechanism1Enabled {
return StrategyDecision{}, false, nil
}
conditions, eligible := mechanismOneConditions(config, state)
trace.Conditions = append(trace.Conditions, conditions...)
if !eligible {
return StrategyDecision{}, false, nil
}
tier, payout, ok, reason, removed, err := selectPayableJackpot(config, state, input, available, random)
if err != nil {
return StrategyDecision{}, false, err
}
trace.Removed = append(trace.Removed, removed...)
trace.Conditions = append(trace.Conditions, StrategyConditionTrace{Name: StrategyConditionPayableJackpot, Passed: ok, Reason: reason})
if !ok {
if trace.BlockedReason == "" {
trace.BlockedReason = reason
}
return StrategyDecision{}, false, nil
}
trace.JackpotMechanism = StrategyJackpotMechanismRTPCompensation
trace.FinalReason = StrategyReasonRTPCompensationJackpot
trace.Draws = append(trace.Draws, StrategyDrawTrace{Sequence: 1, Kind: StrategyDrawKindRTPCompensation, TierID: tier.ID, MultiplierPPM: tier.MultiplierPPM, WeightPPM: jackpotWeight(tier), PayoutCoins: payout})
decision, err := finalizeStrategyDecision(config, state, input, available, tier, payout, StrategyJackpotMechanismRTPCompensation, false, *trace)
return decision, err == nil, err
}
func trySpendMilestoneJackpot(config StrategyConfig, state StrategyState, input StrategyInput, available int64, random StrategyRandomSource, requireSettledGlobal bool, trace *DecisionTrace) (StrategyDecision, bool, error) {
if !config.JackpotMechanism2Enabled || pendingSpendJackpotTokens(config, state) <= 0 {
return StrategyDecision{}, false, nil
}
if requireSettledGlobal {
ratio, valid := state.SpendGlobalRTP.RatioPPM()
globalPassed := valid && rtpAtOrBelowExact(state.SpendGlobalRTP, config.GlobalRTPMaxPPM)
trace.Conditions = append(trace.Conditions, rtpAtOrBelowConditionTrace(StrategyConditionSpendGlobalRTP, state.SpendGlobalRTP, ratio, config.GlobalRTPMaxPPM, valid, globalPassed))
dailyPassed := state.UserDailyJackpotWins < config.DailyJackpotLimit
trace.Conditions = append(trace.Conditions, StrategyConditionTrace{
Name: StrategyConditionSpendDailyJackpotLimit, Numerator: state.UserDailyJackpotWins, LimitPPM: config.DailyJackpotLimit,
Passed: dailyPassed, Reason: boolReason(dailyPassed, "below_limit", StrategyReasonDailyJackpotLimit),
})
if !globalPassed || !dailyPassed {
trace.MilestoneTokenRetained = true
if trace.BlockedReason == "" {
trace.BlockedReason = StrategyReasonSpendGlobalRTPBlocked
if globalPassed {
trace.BlockedReason = StrategyReasonDailyJackpotLimit
}
}
return StrategyDecision{}, false, nil
}
}
tier, payout, ok, reason, removed, err := selectPayableJackpot(config, state, input, available, random)
if err != nil {
return StrategyDecision{}, false, err
}
trace.Removed = append(trace.Removed, removed...)
trace.Conditions = append(trace.Conditions, StrategyConditionTrace{Name: StrategyConditionPayableJackpot, Passed: ok, Reason: reason})
if !ok {
trace.MilestoneTokenRetained = true
if trace.BlockedReason == "" {
trace.BlockedReason = reason
}
return StrategyDecision{}, false, nil
}
trace.JackpotMechanism = StrategyJackpotMechanismMilestone
trace.MilestoneTokenConsumed = true
trace.FinalReason = StrategyReasonMilestoneJackpot
trace.Draws = append(trace.Draws, StrategyDrawTrace{Sequence: 1, Kind: StrategyDrawKindMilestone, TierID: tier.ID, MultiplierPPM: tier.MultiplierPPM, WeightPPM: jackpotWeight(tier), PayoutCoins: payout})
decision, err := finalizeStrategyDecision(config, state, input, available, tier, payout, StrategyJackpotMechanismMilestone, true, *trace)
return decision, err == nil, err
}
func pendingSpendJackpotTokens(config StrategyConfig, state StrategyState) int64 {
if config.SettledRoundRTPEligibility {
return state.PendingSpendJackpotTokens
}
return state.PendingMilestoneTokens
}
func validateStrategy(config StrategyConfig, state StrategyState, input StrategyInput, requireTiers bool) error {
if state.PoolBalanceCoins < 0 || state.ConsecutiveZeroDraws < 0 || state.PendingMilestoneTokens < 0 || state.UserDailyJackpotWins < 0 || state.UserDaySpendCoins < 0 || state.Version < 0 {
if state.PoolBalanceCoins < 0 || state.ConsecutiveZeroDraws < 0 || state.PendingMilestoneTokens < 0 || state.PendingSpendJackpotTokens < 0 || state.UserDailyJackpotWins < 0 || state.UserDaySpendCoins < 0 || state.Version < 0 {
return fmt.Errorf("%w: state counters and money cannot be negative", ErrStrategyInput)
}
for _, rtp := range []StrategyRTP{state.GlobalRTP, state.UserDayRTP, state.User72HourRTP} {
for _, rtp := range []StrategyRTP{state.SpendGlobalRTP, state.GlobalRTP, state.UserRoundRTP, state.User48HourRTP, state.User24HourRTP, state.UserDayRTP, state.User72HourRTP} {
if rtp.WagerCoins < 0 || rtp.PayoutCoins < 0 {
return fmt.Errorf("%w: RTP numerator and denominator cannot be negative", ErrStrategyInput)
}
@ -485,10 +612,19 @@ func validateStrategy(config StrategyConfig, state StrategyState, input Strategy
if config.LowWaterThresholdCoins < 0 || config.HighWaterThresholdCoins < config.LowWaterThresholdCoins || config.LowWaterFactorPPM <= 0 || config.HighWaterFactorPPM <= 0 || config.RechargeFactorPPM <= 0 {
return fmt.Errorf("%w: water thresholds or dynamic factors are invalid", ErrStrategyConfig)
}
user24HourFactor, _ := normalizedUser24HourOrdinaryWinFactor(config)
if config.User24HourRTPThresholdPPM < 0 || user24HourFactor <= 0 || user24HourFactor > StrategyPPMScale ||
(config.User24HourRTPThresholdPPM == 0 && user24HourFactor != StrategyPPMScale) {
return fmt.Errorf("%w: user 24h RTP threshold/factor must be disabled as 0/100%% or use a positive threshold and retained factor within (0,100%%]", ErrStrategyConfig)
}
if config.RechargeBoostStartMS < 0 || config.RechargeBoostEndMS <= config.RechargeBoostStartMS || config.MissProtectionZeroDraws < 0 || config.DailyJackpotLimit < 0 || config.MilestoneSpendCoins < 0 || config.ColdStartPoolCoins < 0 {
return fmt.Errorf("%w: recharge window, miss protection or daily limit is invalid", ErrStrategyConfig)
}
if config.GlobalRTPMaxPPM < 0 || config.GlobalRTPMaxPPM > StrategyPPMScale || config.UserDayRTPMaxPPM < 0 || config.UserDayRTPMaxPPM > StrategyPPMScale || config.User72HourRTPMaxPPM < 0 || config.User72HourRTPMaxPPM > StrategyPPMScale {
if config.GlobalRTPMaxPPM < 0 || config.GlobalRTPMaxPPM > StrategyPPMScale ||
config.UserRoundRTPMaxPPM < 0 || config.UserRoundRTPMaxPPM > StrategyPPMScale ||
config.User48HourRTPMaxPPM < 0 || config.User48HourRTPMaxPPM > StrategyPPMScale ||
config.UserDayRTPMaxPPM < 0 || config.UserDayRTPMaxPPM > StrategyPPMScale ||
config.User72HourRTPMaxPPM < 0 || config.User72HourRTPMaxPPM > StrategyPPMScale {
return fmt.Errorf("%w: RTP jackpot limits must be within [0,100%%]", ErrStrategyConfig)
}
var splitRateTotal int64
@ -526,6 +662,11 @@ func validateStrategy(config StrategyConfig, state StrategyState, input Strategy
if strings.TrimSpace(tier.ID) == "" || seen[tier.ID] || tier.MultiplierPPM < 0 || tier.BaseWeightPPM < 0 || tier.JackpotWeight < 0 {
return fmt.Errorf("%w: enabled tiers require unique IDs and non-negative multiplier/weights", ErrStrategyConfig)
}
// 大奖身份属于受 RTP 条件控制的独立路径,不能同时携带普通随机权重;
// 同倍率普通奖应使用另一个 Jackpot=false tier避免每日 cap 和审计来源再次耦合。
if tier.Jackpot && tier.BaseWeightPPM > 0 {
return fmt.Errorf("%w: jackpot tier %s cannot carry ordinary base weight", ErrStrategyConfig, tier.ID)
}
seen[tier.ID] = true
if tier.MultiplierPPM == 0 {
zeroCount++
@ -534,6 +675,9 @@ func validateStrategy(config StrategyConfig, state StrategyState, input Strategy
if !seenStages[stage] || weight < 0 {
return fmt.Errorf("%w: tier %s has unknown stage or negative stage weight", ErrStrategyConfig, tier.ID)
}
if tier.Jackpot && weight > 0 {
return fmt.Errorf("%w: jackpot tier %s cannot carry ordinary stage weight", ErrStrategyConfig, tier.ID)
}
}
}
if len(seen) == 0 || zeroCount != 1 {
@ -549,10 +693,11 @@ func validateStrategy(config StrategyConfig, state StrategyState, input Strategy
return nil
}
func buildStrategyWeights(config StrategyConfig, stage string, available int64, input StrategyInput) ([]weightedStrategyTier, []StrategyWeightTrace, error) {
func buildStrategyWeights(config StrategyConfig, state StrategyState, stage string, available int64, input StrategyInput) ([]weightedStrategyTier, []StrategyWeightTrace, error) {
tiers := make([]weightedStrategyTier, 0, len(config.Tiers))
traces := make([]StrategyWeightTrace, 0, len(config.Tiers))
positiveIndexes := make([]int, 0, len(config.Tiers))
ordinaryPositiveIndexes := make([]int, 0, len(config.Tiers))
var positiveSum int64
zeroIndex := -1
for _, tier := range config.Tiers {
@ -592,6 +737,9 @@ func buildStrategyWeights(config StrategyConfig, stage string, available int64,
return nil, nil, err
}
positiveIndexes = append(positiveIndexes, len(tiers))
if !tier.Jackpot {
ordinaryPositiveIndexes = append(ordinaryPositiveIndexes, len(tiers))
}
}
trace.RawAdjustedWeightPPM = weight
tiers = append(tiers, weightedStrategyTier{Tier: tier, Weight: weight})
@ -616,6 +764,50 @@ func buildStrategyWeights(config StrategyConfig, stage string, available int64,
}
tiers[zeroIndex].Weight = 0
}
// Snapshot the complete ordinary table at the exact point where the historical algorithm ended.
// The 24-hour control is applied below only to normal random selection; miss protection restores
// these weights so its eligibility cannot be weakened by the later retained-mass rounding.
for index := range tiers {
if tiers[index].Tier.MultiplierPPM > 0 && !tiers[index].Tier.Jackpot {
tiers[index].MissProtectionWeight = tiers[index].Weight
}
}
_, downweightActive := user24HourRTPDownweightCondition(config, state.User24HourRTP)
if downweightActive {
// Water-level and recharge factors first shape the relative ordinary table. Only after the
// normal 1,000,000-PPM normalization do we shrink the complete non-zero ordinary mass, so a
// table previously normalized to 100% becomes exactly 70% when the retained factor is 70%.
factor, _ := normalizedUser24HourOrdinaryWinFactor(config)
var ordinaryPositiveSum int64
ordinaryWeights := make([]int64, 0, len(ordinaryPositiveIndexes))
for _, index := range ordinaryPositiveIndexes {
ordinaryWeights = append(ordinaryWeights, tiers[index].Weight)
var err error
ordinaryPositiveSum, err = safeAdd(ordinaryPositiveSum, tiers[index].Weight)
if err != nil {
return nil, nil, err
}
}
if ordinaryPositiveSum > 0 {
targetPositiveSum := mulPPM(ordinaryPositiveSum, factor)
// PPM is discrete. Preserve one selectable unit for any non-empty configured table so miss
// protection can still guarantee a payable ordinary award even at a very small factor.
if targetPositiveSum == 0 {
targetPositiveSum = 1
}
downweighted, err := apportion(targetPositiveSum, ordinaryWeights, ordinaryPositiveSum)
if err != nil {
return nil, nil, err
}
for offset, index := range ordinaryPositiveIndexes {
tiers[index].Weight = downweighted[offset]
traces[index].Factors = append(traces[index].Factors, StrategyWeightFactor{
Name: StrategyConditionUser24HourRTPDownweight, FactorPPM: factor,
})
}
tiers[zeroIndex].Weight = StrategyPPMScale - targetPositiveSum
}
}
for index := range tiers {
traces[index].AdjustedWeightPPM = tiers[index].Weight
}
@ -630,7 +822,46 @@ func rechargeBoostActive(config StrategyConfig, input StrategyInput) bool {
return age >= config.RechargeBoostStartMS && age < config.RechargeBoostEndMS
}
func normalizedUser24HourOrdinaryWinFactor(config StrategyConfig) (int64, bool) {
factor := config.User24HourOrdinaryWinFactorPPM
// Pure-kernel callers compiled before this field existed still produce 0/0 struct literals. The
// published-rule adapter canonicalizes them to 0/100%, while this guard preserves source-level
// compatibility without turning a missing factor into “remove every win”.
if config.User24HourRTPThresholdPPM == 0 && factor == 0 {
factor = StrategyPPMScale
}
enabled := config.User24HourRTPThresholdPPM > 0 && factor > 0 && factor < StrategyPPMScale
return factor, enabled
}
func user24HourRTPDownweightCondition(config StrategyConfig, rtp StrategyRTP) (StrategyConditionTrace, bool) {
factor, enabled := normalizedUser24HourOrdinaryWinFactor(config)
ratio, valid := rtp.RatioPPM()
active := enabled && valid && compareRTPExact(rtp, config.User24HourRTPThresholdPPM) > 0
reason := "at_or_below_threshold_normal_weight"
switch {
case !enabled:
reason = "disabled_or_identity_factor"
case !valid:
reason = "denominator_zero_normal_weight"
case active:
reason = "above_threshold_downweighted"
}
return StrategyConditionTrace{
Name: StrategyConditionUser24HourRTPDownweight,
Numerator: rtp.PayoutCoins, Denominator: rtp.WagerCoins,
RatioPPM: ratio, LimitPPM: config.User24HourRTPThresholdPPM, FactorPPM: factor,
Passed: active, Reason: reason,
}, active
}
func mechanismOneConditions(config StrategyConfig, state StrategyState) ([]StrategyConditionTrace, bool) {
if config.SettledRoundRTPEligibility {
return settledRoundJackpotConditions(config, state)
}
// Historical pure-kernel callers used opportunistic current/day/72h checks. Keeping this branch
// source-compatible does not expose it to dynamic_v3: the production adapter always enables the
// settled-round flag and disables the independent milestone mechanism.
checks := []struct {
name string
rtp StrategyRTP
@ -659,6 +890,85 @@ func mechanismOneConditions(config StrategyConfig, state StrategyState) ([]Strat
return traces, eligible && limitPassed
}
func settledRoundJackpotConditions(config StrategyConfig, state StrategyState) ([]StrategyConditionTrace, bool) {
traces := make([]StrategyConditionTrace, 0, 6)
pending := state.SettledRoundPending
traces = append(traces, StrategyConditionTrace{
Name: StrategyConditionSettledRound, Passed: pending,
Reason: boolReason(pending, "closed_round_pending", "no_closed_round_pending"),
})
eligible := pending
// The platform permits equality at 98%, while both user gates are deliberately strict: an exact
// 96% user ratio is not “losing more than two points” and must not receive compensation.
globalRatio, globalValid := state.GlobalRTP.RatioPPM()
globalPassed := globalValid && rtpAtOrBelowExact(state.GlobalRTP, config.GlobalRTPMaxPPM)
traces = append(traces, rtpConditionTrace(StrategyConditionGlobalRTP, state.GlobalRTP, globalRatio, config.GlobalRTPMaxPPM, globalValid, globalPassed))
eligible = eligible && globalPassed
roundRatio, roundValid := state.UserRoundRTP.RatioPPM()
roundPassed := roundValid && rtpBelowExact(state.UserRoundRTP, config.UserRoundRTPMaxPPM)
traces = append(traces, rtpConditionTrace(StrategyConditionUserRoundRTP, state.UserRoundRTP, roundRatio, config.UserRoundRTPMaxPPM, roundValid, roundPassed))
eligible = eligible && roundPassed
const maturityMS = int64(48 * 60 * 60 * 1000)
ageMS := int64(0)
if state.UserRegisteredAtMS > 0 && state.QualificationClosedAtMS >= state.UserRegisteredAtMS {
ageMS = state.QualificationClosedAtMS - state.UserRegisteredAtMS
}
mature := state.UserRegisteredAtMS > 0 && ageMS >= maturityMS
traces = append(traces, StrategyConditionTrace{
Name: StrategyConditionUser48HourMaturity, Numerator: ageMS, Denominator: maturityMS,
Passed: mature, Reason: boolReason(mature, "registered_48h", "registered_under_48h_or_unknown"),
})
eligible = eligible && mature
rollingRatio, rollingValid := state.User48HourRTP.RatioPPM()
rollingPassed := rollingValid && rtpBelowExact(state.User48HourRTP, config.User48HourRTPMaxPPM)
traces = append(traces, rtpConditionTrace(StrategyConditionUser48HourRTP, state.User48HourRTP, rollingRatio, config.User48HourRTPMaxPPM, rollingValid, rollingPassed))
eligible = eligible && rollingPassed
limitPassed := state.UserDailyJackpotWins < config.DailyJackpotLimit
traces = append(traces, StrategyConditionTrace{Name: StrategyConditionDailyJackpotLimit, Numerator: state.UserDailyJackpotWins, LimitPPM: config.DailyJackpotLimit, Passed: limitPassed, Reason: boolReason(limitPassed, "below_limit", StrategyReasonDailyJackpotLimit)})
return traces, eligible && limitPassed
}
func rtpConditionTrace(name string, rtp StrategyRTP, ratio, limit int64, valid, passed bool) StrategyConditionTrace {
reason := "within_limit"
if !valid {
reason = "denominator_zero"
} else if !passed {
reason = "above_or_equal_limit"
}
return StrategyConditionTrace{Name: name, Numerator: rtp.PayoutCoins, Denominator: rtp.WagerCoins, RatioPPM: ratio, LimitPPM: limit, Passed: passed, Reason: reason}
}
func rtpAtOrBelowConditionTrace(name string, rtp StrategyRTP, ratio, limit int64, valid, passed bool) StrategyConditionTrace {
reason := "within_limit"
if !valid {
reason = "denominator_zero"
} else if !passed {
reason = "above_limit"
}
return StrategyConditionTrace{Name: name, Numerator: rtp.PayoutCoins, Denominator: rtp.WagerCoins, RatioPPM: ratio, LimitPPM: limit, Passed: passed, Reason: reason}
}
func rtpBelowExact(rtp StrategyRTP, limitPPM int64) bool {
return compareRTPExact(rtp, limitPPM) < 0
}
func rtpAtOrBelowExact(rtp StrategyRTP, limitPPM int64) bool {
return compareRTPExact(rtp, limitPPM) <= 0
}
func compareRTPExact(rtp StrategyRTP, limitPPM int64) int {
// Cross multiplication keeps the configured mathematical boundary exact even when payout*1e6
// exceeds int64. RatioPPM remains a display/audit value and is never trusted for eligibility.
left := new(big.Int).Mul(big.NewInt(rtp.PayoutCoins), big.NewInt(StrategyPPMScale))
right := new(big.Int).Mul(big.NewInt(rtp.WagerCoins), big.NewInt(limitPPM))
return left.Cmp(right)
}
func selectPayableJackpot(config StrategyConfig, state StrategyState, input StrategyInput, available int64, random StrategyRandomSource) (StrategyTier, int64, bool, string, []StrategyRemovalTrace, error) {
if state.UserDailyJackpotWins >= config.DailyJackpotLimit {
return StrategyTier{}, 0, false, StrategyReasonDailyJackpotLimit, nil, nil
@ -700,7 +1010,18 @@ func selectOrdinaryTier(config StrategyConfig, state StrategyState, input Strate
candidates := append([]weightedStrategyTier(nil), weighted...)
if state.ConsecutiveZeroDraws >= config.MissProtectionZeroDraws {
trace.Removed = append(trace.Removed, StrategyRemovalTrace{TierID: zeroTier.ID, Reason: StrategyRemovalMissProtection})
candidates = withoutStrategyTier(candidates, zeroTier.ID)
// Protection deliberately rebuilds only ordinary positive candidates from their pre-downweight
// weights. A 1-PPM retained mass can round some tiers to zero, but if any such tier is payable the
// guarantee still has to award one; jackpot tiers remain exclusively controlled by jackpot gates.
protected := make([]weightedStrategyTier, 0, len(candidates))
for _, candidate := range candidates {
if candidate.Tier.MultiplierPPM <= 0 || candidate.Tier.Jackpot || candidate.MissProtectionWeight <= 0 {
continue
}
candidate.Weight = candidate.MissProtectionWeight
protected = append(protected, candidate)
}
candidates = protected
if !hasPayableOrdinaryTier(config, state, input, available, candidates) {
trace.FinalReason = StrategyReasonMissProtectionBlocked
trace.BlockedReason = StrategyReasonMissProtectionBlocked
@ -788,6 +1109,11 @@ func finalizeStrategyDecision(config StrategyConfig, state StrategyState, input
return StrategyDecision{}, fmt.Errorf("%w: payout=%d exceeds available pool=%d", ErrStrategyInput, payout, available)
}
next := state
if config.SettledRoundRTPEligibility {
// A closed-round qualification is exactly one attempt. Pool/risk rejection falls through to the
// ordinary draw in this same call, but the qualification can never repeat on a later paid draw.
next.SettledRoundPending = false
}
next.PoolBalanceCoins = available - payout
if next.PoolBalanceCoins < 0 {
return StrategyDecision{}, fmt.Errorf("%w: strategy would make pool negative", ErrStrategyInput)
@ -798,10 +1124,14 @@ func finalizeStrategyDecision(config StrategyConfig, state StrategyState, input
next.ConsecutiveZeroDraws = 0
}
if consumeToken {
if next.PendingMilestoneTokens <= 0 {
if pendingSpendJackpotTokens(config, next) <= 0 {
return StrategyDecision{}, fmt.Errorf("%w: cannot consume absent milestone token", ErrStrategyInput)
}
next.PendingMilestoneTokens--
if config.SettledRoundRTPEligibility {
next.PendingSpendJackpotTokens--
} else {
next.PendingMilestoneTokens--
}
}
jackpot := payout > 0 && tier.Jackpot
if jackpot {
@ -812,24 +1142,45 @@ func finalizeStrategyDecision(config StrategyConfig, state StrategyState, input
if next.UserDaySpendCoins, err = safeAdd(next.UserDaySpendCoins, input.GiftPriceCoins); err != nil {
return StrategyDecision{}, err
}
// A threshold crossed by this draw is persisted after selection, so the newly
// earned token can affect only the next locked draw and never the current one.
earnedTokens := MilestoneTokensEarned(previousDaySpend, next.UserDaySpendCoins, config.MilestoneSpendCoins)
// A threshold crossed by this draw is persisted after selection, so the newly earned token can
// affect only the next locked subdraw and never the current one. Production V3 stores it in the
// dedicated field; the retired legacy token is neither read nor migrated into the new strategy.
earnedTokens := int64(0)
if config.JackpotMechanism2Enabled {
earnedTokens = MilestoneTokensEarned(previousDaySpend, next.UserDaySpendCoins, config.MilestoneSpendCoins)
}
if earnedTokens > 0 {
next.PendingMilestoneTokens, err = safeAdd(next.PendingMilestoneTokens, earnedTokens)
if config.SettledRoundRTPEligibility {
next.PendingSpendJackpotTokens, err = safeAdd(next.PendingSpendJackpotTokens, earnedTokens)
} else {
next.PendingMilestoneTokens, err = safeAdd(next.PendingMilestoneTokens, earnedTokens)
}
if err != nil {
return StrategyDecision{}, err
}
trace.MilestoneTokensEarned = earnedTokens
}
if next.GlobalRTP, err = next.GlobalRTP.add(input.GiftPriceCoins, payout); err != nil {
return StrategyDecision{}, err
// Set the final balance after both the optional consumption and threshold-crossing accrual.
// This assignment runs for ordinary, 48h and spend-jackpot decisions alike, so a 48h win with
// one pending spend promise is durably recorded as 1 -> 1 instead of looking like no promise existed.
trace.SpendMilestoneTokensAfter = pendingSpendJackpotTokens(config, next)
if _, enabled := normalizedUser24HourOrdinaryWinFactor(config); enabled {
// The caller supplies the pre-draw rolling snapshot. Advancing it only after selection makes
// this draw visible to the next item in the same gift_count batch, never to its own decision.
if next.User24HourRTP, err = next.User24HourRTP.add(input.GiftPriceCoins, payout); err != nil {
return StrategyDecision{}, err
}
}
if next.UserDayRTP, err = next.UserDayRTP.add(input.GiftPriceCoins, payout); err != nil {
return StrategyDecision{}, err
}
if next.User72HourRTP, err = next.User72HourRTP.add(input.GiftPriceCoins, payout); err != nil {
return StrategyDecision{}, err
if !config.SettledRoundRTPEligibility {
if next.GlobalRTP, err = next.GlobalRTP.add(input.GiftPriceCoins, payout); err != nil {
return StrategyDecision{}, err
}
if next.UserDayRTP, err = next.UserDayRTP.add(input.GiftPriceCoins, payout); err != nil {
return StrategyDecision{}, err
}
if next.User72HourRTP, err = next.User72HourRTP.add(input.GiftPriceCoins, payout); err != nil {
return StrategyDecision{}, err
}
}
if next.Version == math.MaxInt64 {
return StrategyDecision{}, fmt.Errorf("%w: state version overflow", ErrStrategyInput)

View File

@ -34,9 +34,12 @@ func strategyTestConfig() StrategyConfig {
{ID: "0x", MultiplierPPM: 0, BaseWeightPPM: 600_000, Enabled: true},
{ID: "5x", MultiplierPPM: 5_000_000, BaseWeightPPM: 200_000, Enabled: true},
{ID: "50x", MultiplierPPM: 50_000_000, BaseWeightPPM: 150_000, Enabled: true},
{ID: "200x", MultiplierPPM: 200_000_000, BaseWeightPPM: 40_000, Jackpot: true, JackpotWeight: 4, Enabled: true},
{ID: "500x", MultiplierPPM: 500_000_000, BaseWeightPPM: 0, Jackpot: true, JackpotWeight: 2, Enabled: true},
{ID: "1000x", MultiplierPPM: 1_000_000_000, BaseWeightPPM: 10_000, Jackpot: true, JackpotWeight: 1, Enabled: true},
{ID: "200x", MultiplierPPM: 200_000_000, BaseWeightPPM: 40_000, Enabled: true},
{ID: "500x", MultiplierPPM: 500_000_000, BaseWeightPPM: 0, Enabled: true},
{ID: "1000x", MultiplierPPM: 1_000_000_000, BaseWeightPPM: 10_000, Enabled: true},
{ID: "jackpot_200x", MultiplierPPM: 200_000_000, Jackpot: true, JackpotWeight: 4, Enabled: true},
{ID: "jackpot_500x", MultiplierPPM: 500_000_000, Jackpot: true, JackpotWeight: 2, Enabled: true},
{ID: "jackpot_1000x", MultiplierPPM: 1_000_000_000, Jackpot: true, JackpotWeight: 1, Enabled: true},
}
// Core P/W tests isolate the base distribution; dynamic factors have dedicated
// boundary tests below and must not silently move scripted intervals here.
@ -291,23 +294,196 @@ func TestLuckyGiftStrategyMilestoneTokenRetainedThenConsumed(t *testing.T) {
}
}
func TestLuckyGiftStrategyDailyJackpotLimitAlsoConstrainsBaseDraw(t *testing.T) {
func TestLuckyGiftStrategyDualJackpotSpendGlobalGateAndDurability(t *testing.T) {
config := strategyTestConfig()
config.SettledRoundRTPEligibility = true
config.JackpotMechanism1Enabled = true
config.JackpotMechanism2Enabled = true
config.MilestoneSpendCoins = 50
config.GlobalRTPMaxPPM = 980_000
config.UserRoundRTPMaxPPM = 960_000
config.User48HourRTPMaxPPM = 960_000
config.DailyJackpotLimit = 5
base := StrategyState{PoolBalanceCoins: 10_000, PendingSpendJackpotTokens: 1, SpendGlobalRTP: StrategyRTP{WagerCoins: 100, PayoutCoins: 98}}
paid, err := DecideLuckyGiftStrategy(config, base, strategyTestInput(10), NewSeededStrategyRandom(80))
if err != nil {
t.Fatal(err)
}
if paid.JackpotMechanism != StrategyJackpotMechanismMilestone || !paid.ConsumedMilestoneToken || paid.NextState.PendingSpendJackpotTokens != 0 ||
paid.Trace.SpendMilestoneTokensBefore != 1 || paid.Trace.SpendMilestoneTokensAfter != 0 {
t.Fatalf("global equality should pay exactly one spend token: %+v", paid)
}
for _, test := range []struct {
name string
rtp StrategyRTP
}{
{name: "no closed platform sample", rtp: StrategyRTP{}},
{name: "platform above configured maximum", rtp: StrategyRTP{WagerCoins: 100, PayoutCoins: 99}},
} {
t.Run(test.name, func(t *testing.T) {
state := base
state.SpendGlobalRTP = test.rtp
blocked, err := DecideLuckyGiftStrategy(config, state, strategyTestInput(10), NewSeededStrategyRandom(81))
if err != nil {
t.Fatal(err)
}
if blocked.JackpotMechanism != "" || blocked.NextState.PendingSpendJackpotTokens != 1 || !blocked.Trace.MilestoneTokenRetained {
t.Fatalf("blocked spend token must remain durable: %+v", blocked)
}
})
}
capState := base
capState.UserDailyJackpotWins = config.DailyJackpotLimit
blockedBySharedCap, err := DecideLuckyGiftStrategy(config, capState, strategyTestInput(10), NewSeededStrategyRandom(82))
if err != nil {
t.Fatal(err)
}
if blockedBySharedCap.NextState.PendingSpendJackpotTokens != 1 || blockedBySharedCap.Jackpot {
t.Fatalf("daily cap must be shared without consuming spend token: %+v", blockedBySharedCap)
}
}
func TestLuckyGiftStrategyDualJackpotCrossingAndPriority(t *testing.T) {
config := strategyTestConfig()
config.SettledRoundRTPEligibility = true
config.JackpotMechanism1Enabled = true
config.JackpotMechanism2Enabled = true
config.MilestoneSpendCoins = 50
config.GlobalRTPMaxPPM = 980_000
config.UserRoundRTPMaxPPM = 960_000
config.User48HourRTPMaxPPM = 960_000
config.DailyJackpotLimit = 5
// One paid subdraw can cross several exact multiples, but its new promises are appended only
// after selection. Even with a valid global sample this draw must stay on the ordinary path.
crossingState := StrategyState{
PoolBalanceCoins: 10_000, UserDaySpendCoins: 40,
SpendGlobalRTP: StrategyRTP{WagerCoins: 100, PayoutCoins: 98},
}
crossing, err := DecideLuckyGiftStrategy(config, crossingState, strategyTestInput(110), &scriptedStrategyRandom{t: t, indexes: []int64{0}, bounds: []int64{1_000_000}})
if err != nil {
t.Fatal(err)
}
if crossing.Jackpot || crossing.NextState.PendingSpendJackpotTokens != 3 || crossing.Trace.MilestoneTokensEarned != 3 ||
crossing.Trace.SpendMilestoneTokensBefore != 0 || crossing.Trace.SpendMilestoneTokensAfter != 3 {
t.Fatalf("current draw must only earn three next-draw tokens: %+v", crossing)
}
const hourMS = int64(60 * 60 * 1000)
qualified := StrategyState{
PoolBalanceCoins: 10_000,
PendingSpendJackpotTokens: 1,
SpendGlobalRTP: StrategyRTP{WagerCoins: 100, PayoutCoins: 98},
SettledRoundPending: true,
GlobalRTP: StrategyRTP{WagerCoins: 100, PayoutCoins: 98},
UserRoundRTP: StrategyRTP{WagerCoins: 100, PayoutCoins: 95},
User48HourRTP: StrategyRTP{WagerCoins: 10_000, PayoutCoins: 9_500},
UserRegisteredAtMS: hourMS,
QualificationClosedAtMS: 49 * hourMS,
}
compensated, err := DecideLuckyGiftStrategy(config, qualified, strategyTestInput(10), NewSeededStrategyRandom(83))
if err != nil {
t.Fatal(err)
}
if compensated.JackpotMechanism != StrategyJackpotMechanismRTPCompensation || compensated.NextState.PendingSpendJackpotTokens != 1 || compensated.NextState.SettledRoundPending ||
compensated.Trace.SpendMilestoneTokensBefore != 1 || compensated.Trace.SpendMilestoneTokensAfter != 1 {
t.Fatalf("48h qualification must win first without consuming spend token: %+v", compensated)
}
// A failed personal RTP condition consumes only the one-shot 48h qualification. The same child
// draw may still use the independent spend promise because registration and personal RTP do not gate it.
qualified.UserRoundRTP.PayoutCoins = 96
spendFallback, err := DecideLuckyGiftStrategy(config, qualified, strategyTestInput(10), NewSeededStrategyRandom(84))
if err != nil {
t.Fatal(err)
}
if spendFallback.JackpotMechanism != StrategyJackpotMechanismMilestone || spendFallback.NextState.PendingSpendJackpotTokens != 0 || spendFallback.NextState.SettledRoundPending {
t.Fatalf("ineligible 48h path should fall through to spend jackpot: %+v", spendFallback)
}
qualified.PoolBalanceCoins = 49
qualified.UserRoundRTP.PayoutCoins = 95
poolBlocked, err := DecideLuckyGiftStrategy(config, qualified, strategyTestInput(10), NewSeededStrategyRandom(85))
if err != nil {
t.Fatal(err)
}
if poolBlocked.Jackpot || poolBlocked.NextState.SettledRoundPending || poolBlocked.NextState.PendingSpendJackpotTokens != 1 {
t.Fatalf("48h is one-shot while pool-blocked spend token remains: %+v", poolBlocked)
}
}
func TestLuckyGiftStrategySpendJackpotSharesAllSixInclusiveRiskCaps(t *testing.T) {
config := strategyTestConfig()
config.SettledRoundRTPEligibility = true
config.JackpotMechanism1Enabled = true
config.JackpotMechanism2Enabled = true
config.MilestoneSpendCoins = 50
config.GlobalRTPMaxPPM = 980_000
config.UserRoundRTPMaxPPM = 960_000
config.User48HourRTPMaxPPM = 960_000
config.DailyJackpotLimit = 5
setters := []struct {
name string
set func(*StrategyRiskCapacity, int64)
}{
{name: "single", set: func(c *StrategyRiskCapacity, v int64) { c.SingleDrawCoins = v }},
{name: "user-hour", set: func(c *StrategyRiskCapacity, v int64) { c.UserHourCoins = v }},
{name: "user-day", set: func(c *StrategyRiskCapacity, v int64) { c.UserDayCoins = v }},
{name: "device-day", set: func(c *StrategyRiskCapacity, v int64) { c.DeviceDayCoins = v }},
{name: "room-hour", set: func(c *StrategyRiskCapacity, v int64) { c.RoomHourCoins = v }},
{name: "anchor-day", set: func(c *StrategyRiskCapacity, v int64) { c.AnchorDayCoins = v }},
}
for _, test := range setters {
t.Run(test.name, func(t *testing.T) {
state := StrategyState{
PoolBalanceCoins: 10_000, PendingSpendJackpotTokens: 1,
SpendGlobalRTP: StrategyRTP{WagerCoins: 100, PayoutCoins: 98},
}
capacity := StrategyRiskCapacity{Enabled: true, SingleDrawCoins: 10_000, UserHourCoins: 10_000, UserDayCoins: 10_000, DeviceDayCoins: 10_000, RoomHourCoins: 10_000, AnchorDayCoins: 10_000}
test.set(&capacity, 1_999)
input := strategyTestInput(10)
input.RiskCapacity = capacity
blocked, err := DecideLuckyGiftStrategy(config, state, input, NewSeededStrategyRandom(90))
if err != nil {
t.Fatal(err)
}
if blocked.Jackpot || blocked.NextState.PendingSpendJackpotTokens != 1 {
t.Fatalf("capacity below minimum jackpot must retain token: %+v", blocked)
}
test.set(&capacity, 2_000)
input.RiskCapacity = capacity
paid, err := DecideLuckyGiftStrategy(config, state, input, NewSeededStrategyRandom(91))
if err != nil {
t.Fatal(err)
}
if !paid.Jackpot || paid.PayoutCoins != 2_000 || paid.NextState.PendingSpendJackpotTokens != 0 {
t.Fatalf("capacity equality must permit the 200x jackpot: %+v", paid)
}
})
}
}
func TestLuckyGiftStrategyDailyJackpotLimitDoesNotConstrainOrdinarySameMultiplier(t *testing.T) {
config := strategyTestConfig()
config.JackpotMechanism2Enabled = true
config.DailyJackpotLimit = 5
state := StrategyState{PoolBalanceCoins: 20_000, PendingMilestoneTokens: 1, UserDailyJackpotWins: 5}
// Milestone is blocked first; ordinary draw then selects 200x, removes it for
// the same hard daily limit, removes zero, and redraws an allowed 5x.
random := &scriptedStrategyRandom{t: t, indexes: []int64{950_000, 600_000}, bounds: []int64{1_000_000, 960_000}}
// 大奖 token 受每日上限阻断,但普通概率里的同倍率 200x 仍按原权重命中;
// 倍率相同不代表来源相同,也不能消耗大奖次数或 token。
random := &scriptedStrategyRandom{t: t, indexes: []int64{950_000}, bounds: []int64{1_000_000}}
decision, err := DecideLuckyGiftStrategy(config, state, strategyTestInput(10), random)
if err != nil {
t.Fatal(err)
}
if decision.Jackpot || decision.SelectedTier.ID != "5x" || decision.NextState.PendingMilestoneTokens != 1 || decision.NextState.UserDailyJackpotWins != 5 {
if decision.Jackpot || decision.SelectedTier.ID != "200x" || decision.PayoutCoins != 2_000 || decision.NextState.PendingMilestoneTokens != 1 || decision.NextState.UserDailyJackpotWins != 5 {
t.Fatalf("daily-limit decision=%+v", decision)
}
if decision.Trace.Draws[0].RemovedReason != StrategyReasonDailyJackpotLimit {
t.Fatalf("daily-limit removal=%+v", decision.Trace.Draws)
if len(decision.Trace.Draws) != 1 || decision.Trace.Draws[0].RemovedReason != "" {
t.Fatalf("ordinary overlap must not be removed by jackpot cap: %+v", decision.Trace.Draws)
}
}
@ -422,6 +598,24 @@ func TestDefaultLuckyGiftStrategyNominalEVIsExactlyNinetyEightPercent(t *testing
}
}
func TestLuckyGiftStrategyRejectsJackpotTierWithOrdinaryWeight(t *testing.T) {
for _, mutate := range []func(*StrategyTier){
func(tier *StrategyTier) { tier.BaseWeightPPM = 1 },
func(tier *StrategyTier) { tier.StageWeightPPM = map[string]int64{StageNovice: 1} },
} {
config := DefaultLuckyGiftStrategyConfig()
for index := range config.Tiers {
if config.Tiers[index].Jackpot {
mutate(&config.Tiers[index])
break
}
}
if _, _, err := PreviewLuckyGiftStrategyWeights(config, StrategyState{}, strategyTestInput(10)); err == nil {
t.Fatal("jackpot tier carrying an ordinary weight should be rejected")
}
}
}
func TestLuckyGiftStrategyCombinationPriority(t *testing.T) {
config := strategyTestConfig()
config.JackpotMechanism1Enabled = true

View File

@ -38,11 +38,17 @@ func DefaultRuleConfig(appCode, poolID string) domain.RuleConfig {
HighWaterNonzeroFactorPPM: 1_300_000,
RechargeBoostWindowMS: 300_000,
RechargeBoostFactorPPM: 1_100_000,
JackpotMultiplierPPMs: []int64{200_000_000, 500_000_000, 1_000_000_000},
JackpotGlobalRTPMaxPPM: 980_000,
JackpotUserDayRTPMaxPPM: 960_000,
JackpotUser72hRTPMaxPPM: 960_000,
MaxJackpotHitsPerUserDay: 5,
// 这里只生成 rule_version=0 的未发布草稿,因此使用产品新建默认 150%/70%。已发布
// 历史版本始终从不可变规则表读取其 0/100% sentinel不会因本默认值被自动升级。
User24HourRTPThresholdPPM: 1_500_000,
User24HourOrdinaryWinFactorPPM: 700_000,
JackpotMultiplierPPMs: []int64{200_000_000, 500_000_000, 1_000_000_000},
JackpotGlobalRTPMaxPPM: 980_000,
// Deprecated aliases keep old in-process draft builders source-compatible. New API requests populate
// the canonical round/48h fields and therefore opt into the settled-window algorithm.
JackpotUserDayRTPMaxPPM: 960_000,
JackpotUser72hRTPMaxPPM: 960_000,
MaxJackpotHitsPerUserDay: 5,
// 用户阶段按累计金币流水折算的等价抽数推进;后台仍看到“抽数”,运行侧不会再被不同价格礼物误导。
NoviceMaxEquivalentDraws: 2_000,
NormalMaxEquivalentDraws: 20_000,
@ -94,6 +100,29 @@ func defaultRuleStage(stage string, minRecharge7DCoins, minRecharge30DCoins int6
}
func normalizeRuleConfig(config domain.RuleConfig) domain.RuleConfig {
if config.User24HourRTPThresholdPPM == 0 && config.User24HourOrdinaryWinFactorPPM == 0 {
// proto3 标量无法区分旧客户端“未发送”和显式 0将 0/0 收敛到唯一的禁用 sentinel
// 使升级前规则重发后仍保持原中奖概率,而不是意外启用或因新增字段发布失败。
config.User24HourOrdinaryWinFactorPPM = ppmScale
}
// The new contract names are the only persisted/runtime meaning. During a rolling source upgrade an
// old caller may still fill the day/72h aliases; copy them once without pretending that caller opted
// into the settled-round algorithm. A config read from the new database columns sets the marker.
if config.JackpotUserRoundRTPMaxPPM > 0 || config.JackpotUser48hRTPMaxPPM > 0 {
config.SettledRoundRTPEligibility = true
}
if config.JackpotUserRoundRTPMaxPPM == 0 {
config.JackpotUserRoundRTPMaxPPM = config.JackpotUserDayRTPMaxPPM
}
if config.JackpotUser48hRTPMaxPPM == 0 {
config.JackpotUser48hRTPMaxPPM = config.JackpotUser72hRTPMaxPPM
}
if config.JackpotUserDayRTPMaxPPM == 0 {
config.JackpotUserDayRTPMaxPPM = config.JackpotUserRoundRTPMaxPPM
}
if config.JackpotUser72hRTPMaxPPM == 0 {
config.JackpotUser72hRTPMaxPPM = config.JackpotUser48hRTPMaxPPM
}
config.PoolID = normalizePoolID(config.PoolID)
config.StrategyVersion = strings.ToLower(strings.TrimSpace(config.StrategyVersion))
if config.StrategyVersion == "" {
@ -116,6 +145,7 @@ func normalizeRuleConfig(config domain.RuleConfig) domain.RuleConfig {
}
func validateRuleConfig(config domain.RuleConfig) error {
config = normalizeRuleConfig(config)
if normalizePoolID(config.PoolID) == "" {
return xerr.New(xerr.InvalidArgument, "lucky gift pool id is required")
}
@ -197,12 +227,21 @@ func validateDynamicRuleConfig(config domain.RuleConfig, stages map[string]domai
if config.RechargeBoostWindowMS <= 0 || config.RechargeBoostFactorPPM <= ppmScale {
return xerr.New(xerr.InvalidArgument, "dynamic_v3 recharge boost window must be positive and factor must exceed 100%")
}
if config.User24HourRTPThresholdPPM == 0 {
if config.User24HourOrdinaryWinFactorPPM != ppmScale {
return xerr.New(xerr.InvalidArgument, "dynamic_v3 disabled 24-hour RTP downweight must keep ordinary win factor at 100%")
}
} else if config.User24HourRTPThresholdPPM < 0 || config.User24HourOrdinaryWinFactorPPM <= 0 || config.User24HourOrdinaryWinFactorPPM > ppmScale {
return xerr.New(xerr.InvalidArgument, "dynamic_v3 24-hour RTP threshold must be positive and ordinary win factor must be greater than 0% and no more than 100%")
}
if err := validateJackpotMultipliers(config.JackpotMultiplierPPMs); err != nil {
return err
}
// 倍率只是返奖金额,不再承担“大奖身份”。同一倍率可以同时存在于普通概率表和补偿大奖集合;
// 运行适配器会生成两个独立 tier避免普通随机绕过补偿门槛或误占每日大奖次数。
if config.JackpotGlobalRTPMaxPPM <= 0 || config.JackpotGlobalRTPMaxPPM > ppmScale ||
config.JackpotUserDayRTPMaxPPM <= 0 || config.JackpotUserDayRTPMaxPPM > config.JackpotGlobalRTPMaxPPM ||
config.JackpotUser72hRTPMaxPPM <= 0 || config.JackpotUser72hRTPMaxPPM > config.JackpotGlobalRTPMaxPPM {
config.JackpotUserRoundRTPMaxPPM <= 0 || config.JackpotUserRoundRTPMaxPPM > config.JackpotGlobalRTPMaxPPM ||
config.JackpotUser48hRTPMaxPPM <= 0 || config.JackpotUser48hRTPMaxPPM > config.JackpotGlobalRTPMaxPPM {
return xerr.New(xerr.InvalidArgument, "dynamic_v3 jackpot RTP limits must be positive; user limits cannot exceed the global limit")
}
if config.MaxJackpotHitsPerUserDay <= 0 {
@ -222,6 +261,8 @@ func validateDynamicRuleConfig(config domain.RuleConfig, stages map[string]domai
return nil
}
if config.JackpotSpendThresholdCoins <= 0 {
// Enabled V3 runs the 48h compensation and spend-milestone strategies together. A zero amount
// would silently disable one of the two published promises, so only disabled drafts may keep 0.
return xerr.New(xerr.InvalidArgument, "dynamic_v3 jackpot cumulative spend threshold coins must be configured as a positive amount before enabling")
}
return validateDynamicPayoutCaps(config, true)

View File

@ -106,6 +106,27 @@ func TestValidateDynamicV3RechargeStagesAreStrictlyMonotonic(t *testing.T) {
}
}
func TestValidateDynamicV3AllowsOrdinaryProbabilityAtJackpotMultiplier(t *testing.T) {
config := DefaultRuleConfig("lalu", "lucky")
for stageIndex := range config.Stages {
stage := &config.Stages[stageIndex]
for tierIndex := range stage.Tiers {
if stage.Tiers[tierIndex].MultiplierPPM == 1_000_000 {
stage.Tiers[tierIndex].BaseWeightPPM -= 100
}
}
// 200x 同时位于 jackpot_multipliers 与普通概率表是合法配置;这里用极小权重
// 保持静态 RTP 位于控制带内,确保测试只验证“来源可重叠”这一条边界。
stage.Tiers = append(stage.Tiers, domain.RuleTier{
Stage: stage.Stage, TierID: stage.Stage + "_ordinary_200x",
MultiplierPPM: 200_000_000, BaseWeightPPM: 100, HighWaterOnly: true, Enabled: true,
})
}
if err := validateRuleConfig(config); err != nil {
t.Fatalf("ordinary and jackpot sources at the same multiplier should validate independently: %v", err)
}
}
func TestValidateRuleConfigKeepsLegacyFixedV2Compatible(t *testing.T) {
config := DefaultRuleConfig("lalu", "lucky")
config.StrategyVersion = ""

View File

@ -42,6 +42,8 @@ type Repository interface {
ExecuteExternalGiftDraw(ctx context.Context, cmd domain.ExternalDrawCommand, nowMS int64) (domain.ExternalDrawResult, error)
ListLuckyGiftDraws(ctx context.Context, query domain.DrawQuery) ([]domain.DrawResult, int64, error)
GetLuckyGiftDrawSummary(ctx context.Context, query domain.DrawQuery) (domain.DrawSummary, error)
ListLuckyGiftUserProfiles(ctx context.Context, query domain.UserProfileQuery, nowMS int64) ([]domain.UserProfile, int64, string, bool, error)
GetLuckyGiftUserProfile(ctx context.Context, query domain.UserProfileQuery, nowMS int64) (domain.UserProfileDetail, bool, error)
ListLuckyGiftPoolBalances(ctx context.Context, query domain.PoolBalanceQuery) ([]domain.PoolBalance, error)
AdjustLuckyGiftPoolBalance(ctx context.Context, command domain.PoolAdjustmentCommand, nowMS int64) (domain.PoolAdjustmentResult, error)
GetLuckyGiftDrawRewardState(ctx context.Context, appCode string, drawIDs []string) (domain.DrawRewardState, error)
@ -59,6 +61,12 @@ type Repository interface {
MarkLuckyGiftDrawsFailed(ctx context.Context, appCode string, drawIDs []string, failureReason string, nowMS int64) error
}
// luckyGiftUserProfileRefresher 是 MySQL 读模型的可选历史回灌能力。测试仓储和其他最小实现
// 不需要为了后台异步任务扩宽业务接口;生产仓储实现后由 worker 自动发现并运行。
type luckyGiftUserProfileRefresher interface {
RefreshLuckyGiftUserProfiles(ctx context.Context, nowMS int64, batchSize int) (int, error)
}
// WalletClient 是幸运礼物返奖唯一账务依赖wallet-service 仍负责持久 outboxactivity 只做成功落库后的低延迟余额 IM。
type WalletClient interface {
CreditLuckyGiftReward(ctx context.Context, req *walletv1.CreditLuckyGiftRewardRequest, opts ...grpc.CallOption) (*walletv1.CreditLuckyGiftRewardResponse, error)
@ -283,6 +291,7 @@ func (s *Service) RunWorker(ctx context.Context, options WorkerOptions) {
ticker := time.NewTicker(options.PollInterval)
defer ticker.Stop()
nextStatsRefresh := time.Time{}
nextUserProfileRefresh := time.Time{}
for {
if _, err := s.ProcessPendingDrawOutbox(ctx, options); err != nil && ctx.Err() == nil {
// 批处理失败不删除 outbox锁过期后下一轮或其他实例会继续补偿。
@ -298,6 +307,24 @@ func (s *Service) RunWorker(ctx context.Context, options WorkerOptions) {
}
nextStatsRefresh = now.Add(options.StatsInterval)
}
if refresher, ok := s.repository.(luckyGiftUserProfileRefresher); ok &&
options.StatsInterval > 0 && (nextUserProfileRefresh.IsZero() || !now.Before(nextUserProfileRefresh)) {
// 画像历史回灌独立于 admin/databi 的十分钟快照:每轮只串行处理 5000 条,保持事务和内存有界;
// 有进度就在下一个 worker poll 继续,直到全部游标返回 0 才进入低频等待。
const profileBatchSize = 5000
processed, refreshErr := refresher.RefreshLuckyGiftUserProfiles(ctx, now.UnixMilli(), profileBatchSize)
switch {
case refreshErr != nil:
if ctx.Err() == nil {
logx.Error(ctx, "lucky_gift_user_profile_refresh_failed", refreshErr, slog.String("worker_id", options.WorkerID))
}
nextUserProfileRefresh = now.Add(options.StatsInterval)
case processed > 0:
nextUserProfileRefresh = now.Add(options.PollInterval)
default:
nextUserProfileRefresh = now.Add(options.StatsInterval)
}
}
select {
case <-ctx.Done():
return
@ -779,6 +806,52 @@ func (s *Service) GetDrawSummary(ctx context.Context, query domain.DrawQuery) (d
return s.repository.GetLuckyGiftDrawSummary(ctx, normalizeDrawQuery(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
}
query, err := normalizeUserProfileQuery(query)
if err != nil {
return nil, 0, "", false, 0, err
}
snapshotAtMS := s.now().UTC().UnixMilli()
profiles, total, nextCursor, hasMore, err := s.repository.ListLuckyGiftUserProfiles(ctx, query, snapshotAtMS)
return profiles, total, nextCursor, hasMore, snapshotAtMS, err
}
func (s *Service) GetUserProfile(ctx context.Context, query domain.UserProfileQuery) (domain.UserProfileDetail, int64, error) {
if err := s.requireRepository(); err != nil {
return domain.UserProfileDetail{}, 0, err
}
query, err := normalizeUserProfileQuery(query)
if err != nil {
return domain.UserProfileDetail{}, 0, err
}
// 详情入口必须选择且只选择一种真实身份键;否则空 identity_type 会退化成“取列表第一行”,
// 错配的另一种 key 还可能让运营打开与请求不一致的用户画像。
switch query.IdentityType {
case domain.UserProfileIdentityInternal:
if query.InternalUserID <= 0 || query.ExternalUserID != "" {
return domain.UserProfileDetail{}, 0, xerr.New(xerr.InvalidArgument, "internal lucky gift profile requires only user_id")
}
case domain.UserProfileIdentityExternal:
if query.ExternalUserID == "" || query.InternalUserID != 0 {
return domain.UserProfileDetail{}, 0, xerr.New(xerr.InvalidArgument, "external lucky gift profile requires only external_user_id")
}
default:
return domain.UserProfileDetail{}, 0, xerr.New(xerr.InvalidArgument, "lucky gift profile identity_type must be internal or external")
}
snapshotAtMS := s.now().UTC().UnixMilli()
detail, found, err := s.repository.GetLuckyGiftUserProfile(ctx, query, snapshotAtMS)
if err != nil {
return domain.UserProfileDetail{}, 0, err
}
if !found {
return domain.UserProfileDetail{}, 0, xerr.New(xerr.NotFound, "lucky gift user profile not found")
}
return detail, snapshotAtMS, nil
}
func (s *Service) ListPoolBalances(ctx context.Context, query domain.PoolBalanceQuery) ([]domain.PoolBalance, error) {
if err := s.requireRepository(); err != nil {
return nil, err
@ -855,6 +928,53 @@ func normalizeDrawQuery(query domain.DrawQuery) domain.DrawQuery {
return query
}
func normalizeUserProfileQuery(query domain.UserProfileQuery) (domain.UserProfileQuery, error) {
query.AppCode = strings.ToLower(strings.TrimSpace(query.AppCode))
if query.AppCode == "" {
return domain.UserProfileQuery{}, xerr.New(xerr.InvalidArgument, "lucky gift user profile app_code is required")
}
if strings.TrimSpace(query.PoolID) == "" {
return domain.UserProfileQuery{}, xerr.New(xerr.InvalidArgument, "lucky gift user profile pool_id is required")
}
query.PoolID = normalizePoolID(query.PoolID)
query.IdentityType = strings.ToLower(strings.TrimSpace(query.IdentityType))
if query.IdentityType != "" && query.IdentityType != domain.UserProfileIdentityInternal && query.IdentityType != domain.UserProfileIdentityExternal {
return domain.UserProfileQuery{}, xerr.New(xerr.InvalidArgument, "unsupported lucky gift profile identity_type")
}
query.ExternalUserID = strings.TrimSpace(query.ExternalUserID)
if utf8.RuneCountInString(query.ExternalUserID) > 128 {
return domain.UserProfileQuery{}, xerr.New(xerr.InvalidArgument, "external_user_id is too long")
}
query.Stage = strings.ToLower(strings.TrimSpace(query.Stage))
query.Status = strings.ToLower(strings.TrimSpace(query.Status))
query.SortBy = strings.ToLower(strings.TrimSpace(query.SortBy))
query.SortDirection = strings.ToLower(strings.TrimSpace(query.SortDirection))
query.Cursor = strings.TrimSpace(query.Cursor)
query.JackpotCursor = strings.TrimSpace(query.JackpotCursor)
if len(query.Cursor) > 2048 || len(query.JackpotCursor) > 2048 {
return domain.UserProfileQuery{}, xerr.New(xerr.InvalidArgument, "lucky gift profile cursor is too long")
}
if query.Page <= 0 {
query.Page = 1
}
if query.PageSize <= 0 {
query.PageSize = 20
}
if query.PageSize > 100 {
query.PageSize = 100
}
if query.JackpotPage <= 0 {
query.JackpotPage = 1
}
if query.JackpotPageSize <= 0 {
query.JackpotPageSize = 20
}
if query.JackpotPageSize > 50 {
query.JackpotPageSize = 50
}
return query, nil
}
type luckyGiftDrawnPayload struct {
EventType string `json:"event_type"`
EventID string `json:"event_id"`

View File

@ -94,15 +94,19 @@ func (r *Repository) ExecuteExternalGiftDraw(ctx context.Context, cmd domain.Ext
}
poolID := luckyPoolID(cmd.PoolID)
// External and internal draws must lock the same immutable rule row before any
// consistent-read query. Under InnoDB REPEATABLE READ, an unlocked read here
// could pin a snapshot from before the previous user's settlement commit and
// make the rolling 48-hour numerator/denominator miss that completed draw.
// 外部用户与内部用户共用实验分流,只是 userKey 前缀不同;同一 external_user_id 的分组同样确定。
rule, exists, err := r.resolveLuckyGiftRuleConfigForUser(ctx, tx, cmd.AppCode, poolID, domain.ExperimentExternalUserKey(cmd.ExternalUserID), false)
rule, exists, err := r.resolveLuckyGiftRuleConfigForUser(ctx, tx, cmd.AppCode, poolID, domain.ExperimentExternalUserKey(cmd.ExternalUserID), true)
if err != nil {
return domain.ExternalDrawResult{}, err
}
if exists && rule.StrategyVersion == domain.StrategyDynamicV3 {
if cmd.PaidAtMS <= 0 {
// 外部 dynamic_v3 同样只能消费调用方已完成扣费的事实时间;请求到达 lucky 的时间
// 不是支付时间不能用于充值短窗、UTC 日/小时或精确 72h 风控归属。
// 不是支付时间不能用于充值短窗、UTC 日/小时或精确滚动 48h 风控归属。
return domain.ExternalDrawResult{}, xerr.New(xerr.InvalidArgument, "external dynamic lucky gift paid_at_ms is required")
}
} else if cmd.PaidAtMS <= 0 {
@ -151,6 +155,29 @@ func (r *Repository) ExecuteExternalGiftDraw(ctx context.Context, cmd domain.Ext
); err != nil {
return domain.ExternalDrawResult{}, err
}
// dynamic_v3 已由逐抽 item 同事务写画像fixed_v2 和停用规则没有 item必须用聚合事实补一条二者不能重复累计。
if !(exists && rule.Enabled && rule.StrategyVersion == domain.StrategyDynamicV3) {
strategyVersion := strings.TrimSpace(rule.StrategyVersion)
if strategyVersion == "" {
strategyVersion = domain.StrategyFixedV2
}
fact := luckyUserProfileFact{
AppCode: cmd.AppCode, SourceType: luckyUserProfileSourceExternalLegacy, SourceDrawID: drawID,
RequestID: cmd.RequestID, PoolID: poolID, IdentityType: domain.UserProfileIdentityExternal,
IdentityKey: cmd.ExternalUserID, StrategyUserID: externalLuckyUserID(cmd.AppCode, cmd.ExternalUserID),
ExternalUserID: cmd.ExternalUserID, RuleVersion: ruleVersion, StrategyVersion: strategyVersion,
// fixed_v2 的外部请求把 gift_count 合并成一次随机:流水是整单 total_amount
// 但 paid_draws/loss_streak 只推进一次。画像必须沿用同一口径,不能把一条结果放大成 N 次命中。
Stage: selected.Stage, DrawCount: 1, WagerCoins: cmd.TotalAmount,
PayoutCoins: rewardAmount, BaseRewardCoins: rewardAmount, MultiplierPPM: selected.MultiplierPPM,
RewardStatus: row.RewardStatus, TierID: selected.TierID, OccurredAtMS: cmd.PaidAtMS, CreatedAtMS: nowMS,
}
fact.HitType = luckyUserProfileHitType(fact.PayoutCoins, "", nil, nil)
fact.HighOrdinary = selected.HighMultiplier && fact.HitType == domain.UserProfileHitOrdinary
if err := r.persistLuckyGiftUserProfileFacts(ctx, tx, []luckyUserProfileFact{fact}, nowMS); err != nil {
return domain.ExternalDrawResult{}, err
}
}
payload, err := json.Marshal(map[string]any{
"event_type": domain.EventTypeExternalLuckyGiftDrawn,
"event_id": domain.EventTypeExternalLuckyGiftDrawn + ":" + drawID,
@ -256,7 +283,10 @@ func (r *Repository) executeExternalGiftEconomy(ctx context.Context, tx *sql.Tx,
if err := r.applyExternalLuckyEconomy(ctx, tx, cmd.AppCode, config, drawCommand, candidate, globalWindow, giftWindow, basePool, nextCumulativeWager, nextEquivalentDraws, nowMS); err != nil {
return externalDrawCandidate{}, 0, err
}
return externalDrawCandidate{TierID: candidate.TierID, MultiplierPPM: candidate.MultiplierPPM}, candidate.effectiveReward(), nil
return externalDrawCandidate{
TierID: candidate.TierID, MultiplierPPM: candidate.MultiplierPPM,
Stage: experiencePool, HighMultiplier: candidate.HighMultiplier,
}, candidate.effectiveReward(), nil
}
// executeExternalDynamicGiftEconomy 让外部 App 复用与房间送礼完全相同的 dynamic_v3 内核。
@ -279,17 +309,18 @@ func (r *Repository) executeExternalDynamicGiftEconomy(ctx context.Context, tx *
}
externalUserID := externalLuckyUserID(cmd.AppCode, cmd.ExternalUserID)
drawCommand := domain.DrawCommand{
CommandID: "external:" + cmd.AppCode + ":" + cmd.RequestID,
PoolID: config.PoolID,
UserID: externalUserID,
DeviceID: externalLuckyDeviceID(cmd.AppCode, trustedDeviceID),
RoomID: externalLuckyScopeID(cmd.AppCode),
AnchorID: externalLuckyAnchorID(cmd.AppCode),
GiftID: config.GiftID,
GiftCount: int32(cmd.GiftCount),
CoinSpent: cmd.TotalAmount,
PaidAtMS: cmd.PaidAtMS,
GiftIncomeCoins: split.AnchorReturnCoins,
CommandID: "external:" + cmd.AppCode + ":" + cmd.RequestID,
PoolID: config.PoolID,
UserID: externalUserID,
DeviceID: externalLuckyDeviceID(cmd.AppCode, trustedDeviceID),
RoomID: externalLuckyScopeID(cmd.AppCode),
AnchorID: externalLuckyAnchorID(cmd.AppCode),
GiftID: config.GiftID,
GiftCount: int32(cmd.GiftCount),
CoinSpent: cmd.TotalAmount,
PaidAtMS: cmd.PaidAtMS,
UserRegisteredAtMS: cmd.UserRegisteredAtMS,
GiftIncomeCoins: split.AnchorReturnCoins,
}
// 当前外部协议没有可验证的充值 owner 快照;保持 0/0 会落 novice 且关闭五分钟加成,
// 比从 metadata 或客户端自报字段猜测更安全。后续若扩协议,必须把签名覆盖的充值事实显式入契约。
@ -309,6 +340,9 @@ func (r *Repository) executeExternalDynamicGiftEconomy(ctx context.Context, tx *
reward += result.EffectiveRewardCoins
}
candidate := externalDrawCandidate{TierID: "dynamic_v3_batch"}
if len(results) > 0 {
candidate.Stage = results[len(results)-1].ExperiencePool
}
if len(results) == 1 {
candidate.TierID = results[0].SelectedTierID
candidate.MultiplierPPM = results[0].MultiplierPPM
@ -392,8 +426,10 @@ func (r *Repository) getExternalGiftDrawForUpdate(ctx context.Context, tx *sql.T
}
type externalDrawCandidate struct {
TierID string
MultiplierPPM int64
TierID string
MultiplierPPM int64
Stage string
HighMultiplier bool
}
func selectExternalDrawCandidate(rule domain.RuleConfig) (externalDrawCandidate, error) {

View File

@ -5,6 +5,7 @@ import (
"database/sql"
"fmt"
"strings"
"time"
"hyapp/pkg/appcode"
"hyapp/pkg/idgen"
@ -30,7 +31,12 @@ type luckyDynamicBatchState struct {
RiskCounters map[string]luckyDynamicRiskCounter
DayState luckyUserStrategyDay
HourState luckyUserRTPHour
Strategy domain.StrategyState
UserWindow luckyUserRTPWindow
UserWindows []luckyUserRTPWindow
// QualificationWindow is the one closed round consumed by the next personal subdraw. It is
// persisted after decision so pool/risk rejection cannot leave an endlessly reusable promise.
QualificationWindow *luckyUserRTPWindow
Strategy domain.StrategyState
}
// luckyDynamicPersistence 把内部钱包结算与外部 App 自行结算区分开。
@ -48,10 +54,18 @@ func (p luckyDynamicPersistence) external() bool {
// 所有共享行只锁一次gift_count=N 在内存里严格推进 N 次,事务尾再批量落 draw facts 和聚合钱包 outbox。
func (r *Repository) executeDynamicLuckyGiftDrawBatch(ctx context.Context, tx *sql.Tx, cmd domain.DrawCommand, drawCount int32, rule domain.RuleConfig, persistence luckyDynamicPersistence, nowMS int64) ([]domain.DrawResult, error) {
if cmd.PaidAtMS <= 0 {
// dynamic_v3 的充值后五分钟、UTC 日/小时和精确 72h 都必须归属 wallet owner 的扣费事实时间。
// dynamic_v3 的充值后五分钟、UTC 日/小时和精确滚动 48h 都必须归属 wallet owner 的扣费事实时间。
// 外部调用也必须传它自己的扣费事实;缺失时绝不能用 lucky 收到请求或 room saga/recovery 时钟补齐。
return nil, xerr.New(xerr.InvalidArgument, "dynamic lucky gift paid_at_ms is required")
}
// Account creation is an eligibility-only owner fact: an unknown value or a
// timestamp later than this payment cannot describe the paying account. Keep
// ordinary drawing available, but freeze 0 into the round so compensation
// remains fail-closed even if that malformed timestamp would age past 48 hours
// before the platform window eventually settles.
if cmd.UserRegisteredAtMS <= 0 || cmd.UserRegisteredAtMS > cmd.PaidAtMS {
cmd.UserRegisteredAtMS = 0
}
// 设备日额度是 dynamic_v3 的硬风控;只有显式的可信 device_id 才能进入事务状态。
// 不允许用 session_id/command_id 填充,否则换 token 或换命令即可重置设备日上限。
deviceID, err := normalizeLuckyDynamicDeviceID(cmd.DeviceID)
@ -69,17 +83,24 @@ func (r *Repository) executeDynamicLuckyGiftDrawBatch(ctx context.Context, tx *s
}
strategyConfig := luckyDynamicStrategyConfig(config)
referencePrice := config.GiftPrice
paidAtMS := luckyPaidTime(cmd, nowMS).UnixMilli()
userState, err := r.getLuckyUserStateForUpdate(ctx, tx, appCode, cmd.UserID, config.GiftID, nowMS)
if err != nil {
return nil, err
}
windowScopeID := luckyRuleWindowScopeID(config)
globalWindow, err := r.getOpenLuckyDynamicRTPWindow(ctx, tx, appCode, "pool", windowScopeID, config.SettlementWindowWager, config.GlobalWindowDraws, config.TargetRTPPPM, nowMS)
globalWindow, err := r.getOpenLuckyDynamicRTPWindow(ctx, tx, appCode, "pool", windowScopeID, config.SettlementWindowWager, config.GlobalWindowDraws, config.TargetRTPPPM, paidAtMS, nowMS)
if err != nil {
return nil, err
}
giftWindow, err := r.getOpenLuckyDynamicRTPWindow(ctx, tx, appCode, "pool_gift", windowScopeID, config.SettlementWindowWager, config.GiftWindowDraws, config.TargetRTPPPM, nowMS)
// Consumption milestones use the newest fully closed platform sample, independent of whether
// this user participated in that round. Missing history deliberately leaves a zero denominator.
spendGlobalWindow, hasSpendGlobalWindow, err := r.getLatestClosedLuckyDynamicRTPWindow(ctx, tx, appCode, windowScopeID, globalWindow.WindowIndex)
if err != nil {
return nil, err
}
giftWindow, err := r.getOpenLuckyDynamicRTPWindow(ctx, tx, appCode, "pool_gift", windowScopeID, config.SettlementWindowWager, config.GiftWindowDraws, config.TargetRTPPPM, paidAtMS, nowMS)
if err != nil {
return nil, err
}
@ -100,7 +121,29 @@ func (r *Repository) executeDynamicLuckyGiftDrawBatch(ctx context.Context, tx *s
if err != nil {
return nil, err
}
rolling72, err := r.getLuckyUserRolling72HourRTP(ctx, tx, appCode, config.PoolID, cmd.UserID, luckyPaidTime(cmd, nowMS).UnixMilli())
rolling24HourRTP := domain.StrategyRTP{}
if strategyConfig.User24HourRTPThresholdPPM > 0 &&
strategyConfig.User24HourOrdinaryWinFactorPPM > 0 &&
strategyConfig.User24HourOrdinaryWinFactorPPM < domain.StrategyPPMScale {
// One exact [paid_at+1ms-24h, paid_at+1ms) read covers the whole gift_count batch. Existing
// persisted events at paid_at_ms are included, while this batch is not inserted until commit;
// finalizeStrategyDecision advances the snapshot after every subdraw entirely in memory.
rolling24HourRTP, err = r.getLuckyUserRolling24HourRTP(
ctx, tx, appCode, config.PoolID, cmd.UserID, luckyExclusiveEndMS(paidAtMS),
)
if err != nil {
// Failing closed here rolls back the complete draw transaction. Silently using zero would
// let a user bypass the control whenever the aggregate source is unavailable.
return nil, err
}
}
userWindow, err := r.getOrCreateLuckyUserRTPWindow(ctx, tx, appCode, config.PoolID, config.RuleVersion, globalWindow.WindowIndex, cmd.UserID, cmd.UserRegisteredAtMS, nowMS)
if err != nil {
return nil, err
}
qualificationWindow, qualificationGlobal, hasQualification, err := r.getLatestPendingLuckyUserRTPWindow(
ctx, tx, appCode, config.PoolID, config.RuleVersion, globalWindow.WindowIndex, cmd.UserID, windowScopeID, nowMS,
)
if err != nil {
return nil, err
}
@ -125,29 +168,41 @@ func (r *Repository) executeDynamicLuckyGiftDrawBatch(ctx context.Context, tx *s
RiskCounters: riskCounters,
DayState: dayState,
HourState: hourState,
UserWindow: userWindow,
UserWindows: make([]luckyUserRTPWindow, 0, 2),
Strategy: domain.StrategyState{
PoolBalanceCoins: basePool.Balance,
ConsecutiveZeroDraws: userState.LossStreak,
PendingMilestoneTokens: userState.PendingJackpotTokens,
UserDailyJackpotWins: dayState.JackpotHits,
UserDaySpendCoins: dayState.SpendCoins,
GlobalRTP: domain.StrategyRTP{WagerCoins: globalWindow.WagerCoins, PayoutCoins: globalWindow.ActualPayoutCoins},
UserDayRTP: domain.StrategyRTP{WagerCoins: dayState.WagerCoins, PayoutCoins: dayState.PayoutCoins},
User72HourRTP: rolling72,
Version: userState.PaidDraws,
PoolBalanceCoins: basePool.Balance,
ConsecutiveZeroDraws: userState.LossStreak,
// The old pending_jackpot_tokens column is intentionally not copied. It is cleared in the same
// transaction while only the new column can authorize the restored spend strategy.
PendingSpendJackpotTokens: userState.PendingSpendJackpotTokens,
UserDailyJackpotWins: dayState.JackpotHits,
UserDaySpendCoins: dayState.SpendCoins,
UserRegisteredAtMS: cmd.UserRegisteredAtMS,
UserDayRTP: domain.StrategyRTP{WagerCoins: dayState.WagerCoins, PayoutCoins: dayState.PayoutCoins},
User24HourRTP: rolling24HourRTP,
Version: userState.PaidDraws,
},
}
if hasSpendGlobalWindow {
state.Strategy.SpendGlobalRTP = domain.StrategyRTP{WagerCoins: spendGlobalWindow.WagerCoins, PayoutCoins: spendGlobalWindow.ActualPayoutCoins}
}
if hasQualification {
if err := r.prepareLuckySettledRoundQualification(ctx, tx, appCode, config, cmd, qualificationGlobal, qualificationWindow, &state, nowMS); err != nil {
return nil, err
}
}
for index := int32(1); index <= drawCount; index++ {
// gift_count=N 的每一份都必须像独立命令一样跨 RTP 窗口;若上一抽正好填满,
// 本抽先创建下一窗口并让 mechanism1 使用新窗口 RTP不能等整批结束后才滚动。
if err := r.rollLuckyDynamicWindows(ctx, tx, appCode, config.SettlementWindowWager, &state, nowMS); err != nil {
return nil, err
}
unitCommand := cmd
unitCommand.CommandID = luckyDrawSubCommandID(cmd.CommandID, index, drawCount)
unitCommand.GiftCount = 1
unitCommand.CoinSpent = luckyDrawUnitSpend(cmd.CoinSpent, drawCount, index)
// gift_count=N 的每一份都必须像独立命令一样跨 RTP 窗口;若上一抽正好填满,
// 本抽先创建下一窗口并把刚闭窗的用户快照冻结成一次性资格,不能等整批结束后才结算。
if err := r.rollLuckyDynamicWindows(ctx, tx, appCode, config, unitCommand, &state, nowMS); err != nil {
return nil, err
}
unitSplit := unitSplits[index-1]
unitCommand.GiftIncomeCoins = unitSplit.AnchorReturnCoins
if unitCommand.CoinSpent <= 0 {
@ -199,36 +254,53 @@ func normalizeLuckyDynamicDeviceID(value string) (string, error) {
return value, nil
}
func (r *Repository) rollLuckyDynamicWindows(ctx context.Context, tx *sql.Tx, appCode string, controlWager int64, state *luckyDynamicBatchState, nowMS int64) error {
if controlWager <= 0 {
func (r *Repository) rollLuckyDynamicWindows(ctx context.Context, tx *sql.Tx, appCode string, config domain.Config, cmd domain.DrawCommand, state *luckyDynamicBatchState, nowMS int64) error {
if config.SettlementWindowWager <= 0 {
return xerr.New(xerr.Internal, "dynamic lucky gift RTP window wager must be positive")
}
// 图中“结算窗口流水”是金币阈值,不是抽数。整笔单抽归属进入时所在窗口;本抽把流水推过阈值后,下一抽才开启新窗口,避免拆分一次中奖事实。
if state.GlobalWindow.WagerCoins >= controlWager {
paidAtMS := luckyPaidTime(cmd, nowMS).UnixMilli()
// 图中“结算窗口流水”是金币阈值,不是抽数。越过阈值的完整单抽归属旧窗口;窗口已在
// 上一抽结束时冻结,本抽先创建下一窗口并消费刚结算用户轮次的一次性资格。
if state.GlobalWindow.Status == "closed" || state.GlobalWindow.WagerCoins >= config.SettlementWindowWager {
closed := state.GlobalWindow
closed.Status = "closed"
if closed.Status != "closed" {
closed.Status = "closed"
closed.ClosedAtMS = maxInt64(closed.LastPaidAtMS, paidAtMS)
}
// The next subdraw in this batch must immediately see the just-closed platform sample. The draw
// that completed the window was already selected before this assignment and cannot use it.
state.Strategy.SpendGlobalRTP = domain.StrategyRTP{WagerCoins: closed.WagerCoins, PayoutCoins: closed.ActualPayoutCoins}
closedUser := state.UserWindow
if err := r.prepareLuckySettledRoundQualification(ctx, tx, appCode, config, cmd, closed, closedUser, state, nowMS); err != nil {
return err
}
state.GlobalWindows = append(state.GlobalWindows, closed)
next, err := r.createLuckyRTPWindow(
state.UserWindows = append(state.UserWindows, closedUser)
next, err := r.createLuckyRTPWindowAt(
ctx, tx, appCode, closed.ScopeType, closed.ScopeID, closed.WindowIndex+1,
closed.CarryPPM, closed.ControlDraws, closed.TargetRTPPPM, nowMS,
closed.CarryPPM, closed.ControlDraws, closed.TargetRTPPPM, paidAtMS, nowMS,
)
if err != nil {
return err
}
state.GlobalWindow = next
// 大奖机制一只看当前结算窗口;窗口滚动后分母为 0必须先积累新样本不能沿用旧窗口资格。
state.Strategy.GlobalRTP = domain.StrategyRTP{
WagerCoins: next.WagerCoins,
PayoutCoins: next.ActualPayoutCoins,
state.UserWindow, err = r.getOrCreateLuckyUserRTPWindow(
ctx, tx, appCode, config.PoolID, config.RuleVersion, next.WindowIndex, cmd.UserID, cmd.UserRegisteredAtMS, nowMS,
)
if err != nil {
return err
}
}
if state.GiftWindow.WagerCoins >= controlWager {
if state.GiftWindow.Status == "closed" || state.GiftWindow.WagerCoins >= config.SettlementWindowWager {
closed := state.GiftWindow
closed.Status = "closed"
if closed.Status != "closed" {
closed.Status = "closed"
closed.ClosedAtMS = maxInt64(closed.LastPaidAtMS, paidAtMS)
}
state.GiftWindows = append(state.GiftWindows, closed)
next, err := r.createLuckyRTPWindow(
next, err := r.createLuckyRTPWindowAt(
ctx, tx, appCode, closed.ScopeType, closed.ScopeID, closed.WindowIndex+1,
closed.CarryPPM, closed.ControlDraws, closed.TargetRTPPPM, nowMS,
closed.CarryPPM, closed.ControlDraws, closed.TargetRTPPPM, paidAtMS, nowMS,
)
if err != nil {
return err
@ -238,6 +310,57 @@ func (r *Repository) rollLuckyDynamicWindows(ctx context.Context, tx *sql.Tx, ap
return nil
}
func (r *Repository) prepareLuckySettledRoundQualification(ctx context.Context, tx *sql.Tx, appCode string, config domain.Config, cmd domain.DrawCommand, global luckyRTPWindow, user luckyUserRTPWindow, state *luckyDynamicBatchState, nowMS int64) error {
if global.WindowIndex != user.WindowIndex || user.UserID != cmd.UserID {
return xerr.New(xerr.Internal, "dynamic lucky gift user RTP window is not aligned with platform settlement")
}
rolling := domain.StrategyRTP{WagerCoins: user.Rolling48hWagerCoins, PayoutCoins: user.Rolling48hPayoutCoins}
if rolling.WagerCoins == 0 && global.ClosedAtMS > 0 {
rollingEndMS := luckyExclusiveEndMS(global.ClosedAtMS)
var err error
rolling, err = r.getLuckyUserRolling48HourRTP(ctx, tx, appCode, config.PoolID, cmd.UserID, rollingEndMS)
if err != nil {
return err
}
startMS := rollingEndMS - int64(48*time.Hour/time.Millisecond)
for _, record := range state.Records {
occurredAtMS := luckyPaidTime(record.Command, nowMS).UnixMilli()
if occurredAtMS < startMS || occurredAtMS >= rollingEndMS {
continue
}
var addErr error
rolling.WagerCoins, addErr = luckyCheckedAdd(rolling.WagerCoins, record.Command.CoinSpent)
if addErr != nil {
return addErr
}
rolling.PayoutCoins, addErr = luckyCheckedAdd(rolling.PayoutCoins, record.Candidate.effectiveReward())
if addErr != nil {
return addErr
}
}
user.Rolling48hWagerCoins = rolling.WagerCoins
user.Rolling48hPayoutCoins = rolling.PayoutCoins
}
qualification := user
state.QualificationWindow = &qualification
state.Strategy.SettledRoundPending = true
state.Strategy.GlobalRTP = domain.StrategyRTP{WagerCoins: global.WagerCoins, PayoutCoins: global.ActualPayoutCoins}
state.Strategy.UserRoundRTP = domain.StrategyRTP{WagerCoins: user.WagerCoins, PayoutCoins: user.PayoutCoins}
state.Strategy.User48HourRTP = rolling
state.Strategy.QualificationClosedAtMS = global.ClosedAtMS
// Maturity is evaluated from the owner snapshot captured while the user participated in the
// closed round, not a value supplied again on the later draw that consumes the qualification.
state.Strategy.UserRegisteredAtMS = user.UserRegisteredAtMS
return nil
}
func luckyCheckedAdd(left, right int64) (int64, error) {
if right < 0 || left > int64(^uint64(0)>>1)-right {
return 0, xerr.New(xerr.Internal, "dynamic lucky gift RTP aggregate overflow")
}
return left + right, nil
}
func (r *Repository) stageLuckyDynamicDecision(appCode string, config domain.Config, referencePrice int64, cmd domain.DrawCommand, split domain.StrategyFundSplit, decision domain.StrategyDecision, nowMS int64, state *luckyDynamicBatchState) error {
state.PublicIn += split.PublicPoolCoins
state.ProfitIn += split.ProfitPoolCoins
@ -250,16 +373,31 @@ func (r *Repository) stageLuckyDynamicDecision(appCode string, config domain.Con
state.GlobalWindow = state.GlobalWindow.withCurrentWager(cmd.CoinSpent)
state.GlobalWindow.PaidDraws++
state.GlobalWindow.ActualPayoutCoins += decision.PayoutCoins
state.GlobalWindow.LastPaidAtMS = maxInt64(state.GlobalWindow.LastPaidAtMS, luckyPaidTime(cmd, nowMS).UnixMilli())
state.GiftWindow = state.GiftWindow.withCurrentWager(cmd.CoinSpent)
state.GiftWindow.PaidDraws++
state.GiftWindow.ActualPayoutCoins += decision.PayoutCoins
state.GiftWindow.LastPaidAtMS = maxInt64(state.GiftWindow.LastPaidAtMS, luckyPaidTime(cmd, nowMS).UnixMilli())
state.UserWindow.WagerCoins += cmd.CoinSpent
state.UserWindow.PayoutCoins += decision.PayoutCoins
// Reaching the configured wager resets the platform RTP immediately at this draw boundary. The
// complete draw remains in the closed round; the next subdraw is the first member of the new round.
if state.GlobalWindow.WagerCoins >= config.SettlementWindowWager {
state.GlobalWindow.Status = "closed"
state.GlobalWindow.ClosedAtMS = state.GlobalWindow.LastPaidAtMS
}
if state.GiftWindow.WagerCoins >= config.SettlementWindowWager {
state.GiftWindow.Status = "closed"
state.GiftWindow.ClosedAtMS = state.GiftWindow.LastPaidAtMS
}
state.UserState.PaidDraws++
state.UserState.CumulativeWagerCoins += cmd.CoinSpent
state.UserState.EquivalentDraws = luckyEquivalentDraws(state.UserState.CumulativeWagerCoins, referencePrice)
state.UserState.LossStreak = decision.NextState.ConsecutiveZeroDraws
state.UserState.PendingJackpotTokens = decision.NextState.PendingMilestoneTokens
state.DayState.WagerCoins = decision.NextState.UserDayRTP.WagerCoins
state.DayState.PayoutCoins = decision.NextState.UserDayRTP.PayoutCoins
state.UserState.PendingJackpotTokens = 0
state.UserState.PendingSpendJackpotTokens = decision.NextState.PendingSpendJackpotTokens
state.DayState.WagerCoins += cmd.CoinSpent
state.DayState.PayoutCoins += decision.PayoutCoins
state.DayState.SpendCoins = decision.NextState.UserDaySpendCoins
state.DayState.JackpotHits = decision.NextState.UserDailyJackpotWins
state.HourState.WagerCoins += cmd.CoinSpent
@ -278,6 +416,18 @@ func (r *Repository) stageLuckyDynamicDecision(appCode string, config domain.Con
HighMultiplier: decision.Jackpot || decision.SelectedTier.MultiplierPPM >= 10_000_000,
}
drawID := idgen.New("lucky_draw")
if state.QualificationWindow != nil {
qualification := *state.QualificationWindow
qualification.AttemptedDrawID = drawID
qualification.EvaluatedAtMS = nowMS
if luckySettledRoundConditionsPassed(decision.Trace) {
qualification.QualificationStatus = "attempted"
} else {
qualification.QualificationStatus = "ineligible"
}
state.UserWindows = append(state.UserWindows, qualification)
state.QualificationWindow = nil
}
rewardStatus := luckyInitialRewardStatus(candidate, false)
limited := luckyDynamicLimitedReasons(decision.Trace)
decisionCopy := decision
@ -297,14 +447,14 @@ func (r *Repository) stageLuckyDynamicDecision(appCode string, config domain.Con
BasePool: poolSnapshot,
StrategyDecision: &decisionCopy,
FundSplit: split,
UserDayRTP: decision.NextState.UserDayRTP,
User72HourRTP: decision.NextState.User72HourRTP,
UserRoundRTP: domain.StrategyRTP{WagerCoins: state.UserWindow.WagerCoins, PayoutCoins: state.UserWindow.PayoutCoins},
User48HourRTP: decision.NextState.User48HourRTP,
RewardStatus: rewardStatus,
NowMS: nowMS,
}
state.DrawIDs = append(state.DrawIDs, drawID)
state.Records = append(state.Records, record)
globalRTP, _ := decision.NextState.GlobalRTP.RatioPPM()
globalRTP := luckyRTPPPM(globalSnapshot.WagerCoins, globalSnapshot.ActualPayoutCoins)
giftRTP := luckyRTPPPM(giftSnapshot.WagerCoins, giftSnapshot.ActualPayoutCoins)
state.Results = append(state.Results, domain.DrawResult{
DrawID: drawID,
@ -329,6 +479,33 @@ func (r *Repository) stageLuckyDynamicDecision(appCode string, config domain.Con
return nil
}
func luckySettledRoundConditionsPassed(trace domain.DecisionTrace) bool {
required := map[string]bool{
domain.StrategyConditionSettledRound: false,
domain.StrategyConditionGlobalRTP: false,
domain.StrategyConditionUserRoundRTP: false,
domain.StrategyConditionUser48HourMaturity: false,
domain.StrategyConditionUser48HourRTP: false,
domain.StrategyConditionDailyJackpotLimit: false,
}
seen := make(map[string]bool, len(required))
for _, condition := range trace.Conditions {
if _, ok := required[condition.Name]; !ok {
continue
}
seen[condition.Name] = true
if !condition.Passed {
return false
}
}
for name := range required {
if !seen[name] {
return false
}
}
return true
}
func (r *Repository) persistLuckyDynamicBatch(ctx context.Context, tx *sql.Tx, appCode string, cmd domain.DrawCommand, config domain.Config, state luckyDynamicBatchState, persistence luckyDynamicPersistence, nowMS int64) error {
if err := validateLuckyDynamicBatchState(state, cmd.CoinSpent); err != nil {
return err
@ -339,24 +516,33 @@ func (r *Repository) persistLuckyDynamicBatch(ctx context.Context, tx *sql.Tx, a
if _, err := tx.ExecContext(ctx, `
UPDATE lucky_rtp_windows
SET paid_draws = ?, wager_coins = ?, target_payout_coins = ?,
actual_base_payout_coins = ?, carry_ppm = ?, status = ?, updated_at_ms = ?
actual_base_payout_coins = ?, carry_ppm = ?, status = ?,
last_paid_at_ms = ?, closed_at_ms = ?, updated_at_ms = ?
WHERE app_code = ? AND scope_type = ? AND scope_id = ? AND window_index = ?`,
window.PaidDraws, window.WagerCoins, window.TargetPayoutCoins, window.ActualPayoutCoins, window.CarryPPM, window.Status, nowMS,
window.PaidDraws, window.WagerCoins, window.TargetPayoutCoins, window.ActualPayoutCoins, window.CarryPPM, window.Status,
window.LastPaidAtMS, window.ClosedAtMS, nowMS,
appCode, window.ScopeType, window.ScopeID, window.WindowIndex,
); err != nil {
return err
}
}
// Values are ordered so a later evaluated copy of the same closed row overwrites its earlier
// pending snapshot. Multiple UPDATEs stay bounded by subdraw window crossings, never user count.
for _, userWindow := range append(state.UserWindows, state.UserWindow) {
if err := r.persistLuckyUserRTPWindow(ctx, tx, appCode, userWindow, nowMS); err != nil {
return err
}
}
if err := r.updateLuckyDynamicPoolNet(ctx, tx, appCode, luckyBasePoolScopeID(config), state.PublicIn, state.Payout, state.ProfitIn, state.AnchorIn, nowMS); err != nil {
return err
}
if _, err := tx.ExecContext(ctx, `
UPDATE lucky_user_states
SET paid_draws = ?, cumulative_wager_coins = ?, equivalent_draws = ?, loss_streak = ?,
pending_jackpot_tokens = ?, updated_at_ms = ?
pending_jackpot_tokens = 0, pending_spend_jackpot_tokens = ?, updated_at_ms = ?
WHERE app_code = ? AND user_id = ? AND gift_id = ?`,
state.UserState.PaidDraws, state.UserState.CumulativeWagerCoins, state.UserState.EquivalentDraws, state.UserState.LossStreak,
state.UserState.PendingJackpotTokens, nowMS, appCode, cmd.UserID, config.GiftID,
state.UserState.PendingSpendJackpotTokens, nowMS, appCode, cmd.UserID, config.GiftID,
); err != nil {
return err
}
@ -387,6 +573,11 @@ func (r *Repository) persistLuckyDynamicBatch(ctx context.Context, tx *sql.Tx, a
}
func luckyDynamicSelectedWeight(decision domain.StrategyDecision) int64 {
// 大奖 tier 的 stage weight 按设计恒为 0审计必须记录大奖集合内的选择权重
// 不能从普通概率 trace 读到 0 后误报为“零概率命中”。
if decision.Jackpot {
return decision.SelectedTier.JackpotWeight
}
for _, item := range decision.Trace.Weights {
if item.TierID == decision.SelectedTier.ID {
return item.AdjustedWeightPPM
@ -452,5 +643,11 @@ func (r *Repository) insertExternalLuckyGiftDrawItems(ctx context.Context, tx *s
return err
}
}
return nil
facts := make([]luckyUserProfileFact, 0, len(records))
strategyUserID := externalLuckyUserID(appCode, persistence.ExternalUserID)
for _, record := range records {
facts = append(facts, luckyUserProfileFactFromRecord(record, luckyUserProfileSourceExternalItem,
domain.UserProfileIdentityExternal, persistence.ExternalUserID, persistence.ExternalUserID, strategyUserID))
}
return r.persistLuckyGiftUserProfileFacts(ctx, tx, facts, nowMS)
}

View File

@ -131,6 +131,8 @@ func TestDynamicLuckyGiftMySQLBatchAndExternalPaths(t *testing.T) {
}
assertDynamicMySQLBatchFacts(t, schema.DB, "lalu", internalRule.PoolID, command.UserID, command.CommandID, 99, 100, 98, 1, 1)
assertDynamicTokenSurvivesUTCDayBoundary(t, repo, schema.DB, ctx, now)
assertDynamicSpendBatchRollbackAndRestart(t, repo, schema.DB, ctx, now+10_000)
assertExternalNewUserSpendBatch(t, repo, schema.DB, ctx, now+20_000)
externalRule := dynamicMySQLTestRule("external-dynamic")
if _, err := repo.PublishLuckyGiftRuleConfig(ctx, externalRule, now-500); err != nil {
@ -244,9 +246,10 @@ func seedDynamicRTPWindowsBelowWagerThreshold(t *testing.T, db *sql.DB, poolID s
if _, err := db.Exec(`
INSERT INTO lucky_rtp_windows (
app_code,scope_type,scope_id,window_index,target_rtp_ppm,control_window_draws,
paid_draws,wager_coins,target_payout_coins,actual_base_payout_coins,carry_ppm,status,created_at_ms,updated_at_ms
) VALUES ('lalu',?,?,1,980000,10,10,98,96,0,40000,'open',?,?)`,
scopeType, scopeID, nowMS, nowMS,
paid_draws,wager_coins,target_payout_coins,actual_base_payout_coins,carry_ppm,status,
started_at_ms,last_paid_at_ms,closed_at_ms,created_at_ms,updated_at_ms
) VALUES ('lalu',?,?,1,980000,10,10,98,96,0,40000,'open',?,?,0,?,?)`,
scopeType, scopeID, nowMS-1_000, nowMS, nowMS, nowMS,
); err != nil {
t.Fatalf("seed %s RTP window: %v", scopeType, err)
}
@ -316,6 +319,11 @@ func assertDynamicTokenSurvivesUTCDayBoundary(t *testing.T, repo *Repository, db
t.Fatalf("earn milestone token: %v", err)
}
assertPendingTokenCount(t, db, rule.PoolID, userID, 1)
// A retired token is deliberately planted after the new token was earned. The next transaction
// must clear it without copying or adding it to the new durable balance.
if _, err := db.Exec(`UPDATE lucky_user_states SET pending_jackpot_tokens=7 WHERE app_code='lalu' AND user_id=? AND gift_id=?`, userID, rule.PoolID); err != nil {
t.Fatalf("seed retired pending token: %v", err)
}
if result, err := db.Exec(`UPDATE lucky_pools SET balance=0 WHERE app_code='lalu' AND scope_type=? AND scope_id=?`, luckyDynamicPoolScopeType, rule.PoolID); err != nil {
t.Fatalf("drain token test pool: %v", err)
} else if rows, _ := result.RowsAffected(); rows != 1 {
@ -331,11 +339,27 @@ func assertDynamicTokenSurvivesUTCDayBoundary(t *testing.T, repo *Repository, db
t.Fatalf("retain token across UTC day: %v", err)
}
assertPendingTokenCount(t, db, rule.PoolID, userID, 1)
var retired int64
if err := db.QueryRow(`SELECT pending_jackpot_tokens FROM lucky_user_states WHERE app_code='lalu' AND user_id=? AND gift_id=?`, userID, rule.PoolID).Scan(&retired); err != nil || retired != 0 {
t.Fatalf("retired token was not lazily cleared: got=%d err=%v", retired, err)
}
if result, err := db.Exec(`UPDATE lucky_pools SET balance=1000000 WHERE app_code='lalu' AND scope_type=? AND scope_id=?`, luckyDynamicPoolScopeType, rule.PoolID); err != nil {
t.Fatalf("refill token test pool: %v", err)
} else if rows, _ := result.RowsAffected(); rows != 1 {
t.Fatalf("refill token test pool affected=%d, want 1", rows)
}
// Freeze the current platform window as a valid <=98% sample. Until this close, the spend token
// correctly remains blocked because an open/current RTP can never authorize a spend jackpot.
if result, err := db.Exec(`
UPDATE lucky_rtp_windows
SET wager_coins=1000000, actual_base_payout_coins=980000, status='closed',
started_at_ms=?, last_paid_at_ms=?, closed_at_ms=?
WHERE app_code='lalu' AND scope_type='pool' AND scope_id LIKE CONCAT(?, ':v%') AND status='open'`,
dayOneMS, dayTwoMS, dayTwoMS, rule.PoolID); err != nil {
t.Fatalf("close platform RTP sample: %v", err)
} else if rows, _ := result.RowsAffected(); rows != 1 {
t.Fatalf("closed platform RTP windows=%d want=1", rows)
}
consume := retain
consume.CommandID = "token-consume-next-day"
consume.PaidAtMS++
@ -349,11 +373,228 @@ func assertDynamicTokenSurvivesUTCDayBoundary(t *testing.T, repo *Repository, db
assertPendingTokenCount(t, db, rule.PoolID, userID, 0)
}
// assertDynamicSpendBatchRollbackAndRestart exercises the production transaction rather than only
// the pure kernel: child 2 earns the token after selection and child 3 consumes it. A trigger fails
// the first attempt at the final fact insert, proving all earlier in-transaction state transitions
// roll back; rebuilding Repository and retrying the same command must then apply the sequence once.
func assertDynamicSpendBatchRollbackAndRestart(t *testing.T, repo *Repository, db *sql.DB, ctx context.Context, nowMS int64) {
t.Helper()
rule := dynamicMySQLTestRule("dual-spend-batch")
rule.JackpotSpendThresholdCoins = 20
published, err := repo.PublishLuckyGiftRuleConfig(ctx, rule, nowMS-2_000)
if err != nil {
t.Fatalf("publish dual spend batch rule: %v", err)
}
fundDynamicSpendTestPool(t, repo, ctx, published.PoolID, "dual-spend-batch-credit", nowMS-1_900)
seedClosedDynamicSpendGlobalWindow(t, db, published, nowMS-1_500)
command := domain.DrawCommand{
CommandID: "dual-spend-batch-command", PoolID: published.PoolID, UserID: 4_040, TargetUserID: 1,
DeviceID: "dual-spend-batch-device", RoomID: "dual-spend-batch-room", AnchorID: "dual-spend-batch-anchor", GiftID: "super-lucky",
GiftCount: 3, CoinSpent: 30, GiftIncomeCoins: 0, PaidAtMS: nowMS,
}
const rollbackTrigger = "trg_lucky_dual_spend_rollback"
if _, err := db.Exec("DROP TRIGGER IF EXISTS " + rollbackTrigger); err != nil {
t.Fatalf("drop stale dual spend rollback trigger: %v", err)
}
t.Cleanup(func() { _, _ = db.Exec("DROP TRIGGER IF EXISTS " + rollbackTrigger) })
if _, err := db.Exec(`
CREATE TRIGGER trg_lucky_dual_spend_rollback
BEFORE INSERT ON lucky_draw_records
FOR EACH ROW SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'forced dual spend rollback'`); err != nil {
t.Fatalf("create dual spend rollback trigger: %v", err)
}
if _, err := repo.ExecuteLuckyGiftDraw(ctx, command, nowMS); err == nil {
t.Fatal("forced persistence failure unexpectedly committed dual spend batch")
}
if _, err := db.Exec("DROP TRIGGER " + rollbackTrigger); err != nil {
t.Fatalf("drop dual spend rollback trigger: %v", err)
}
var stateRows, drawRows, dayRows, balanceAfterRollback int64
if err := db.QueryRow(`SELECT COUNT(*) FROM lucky_user_states WHERE app_code='lalu' AND user_id=? AND gift_id=?`, command.UserID, published.PoolID).Scan(&stateRows); err != nil {
t.Fatalf("query rolled-back spend state: %v", err)
}
if err := db.QueryRow(`SELECT COUNT(*) FROM lucky_draw_records WHERE app_code='lalu' AND command_id LIKE 'dual-spend-batch-command#%'`).Scan(&drawRows); err != nil {
t.Fatalf("query rolled-back spend draws: %v", err)
}
if err := db.QueryRow(`SELECT COUNT(*) FROM lucky_user_strategy_days WHERE app_code='lalu' AND pool_id=? AND user_id=?`, published.PoolID, command.UserID).Scan(&dayRows); err != nil {
t.Fatalf("query rolled-back spend day: %v", err)
}
if err := db.QueryRow(`SELECT balance FROM lucky_pools WHERE app_code='lalu' AND scope_type=? AND scope_id=?`, luckyDynamicPoolScopeType, published.PoolID).Scan(&balanceAfterRollback); err != nil {
t.Fatalf("query rolled-back spend pool: %v", err)
}
if stateRows != 0 || drawRows != 0 || dayRows != 0 || balanceAfterRollback != 1_000_000 {
t.Fatalf("failed spend batch leaked state/draw/day/pool=%d/%d/%d/%d", stateRows, drawRows, dayRows, balanceAfterRollback)
}
restarted := &Repository{db: db}
first, err := restarted.ExecuteLuckyGiftDraw(ctx, command, nowMS+1)
if err != nil {
t.Fatalf("retry dual spend batch after repository restart: %v", err)
}
assertSpendTraceSnapshot(t, db, "lucky_draw_records", "command_id", luckyDrawSubCommandID(command.CommandID, 1, 3), 0, 0, 0, "")
assertSpendTraceSnapshot(t, db, "lucky_draw_records", "command_id", luckyDrawSubCommandID(command.CommandID, 2, 3), 0, 1, 1, "")
assertSpendTraceSnapshot(t, db, "lucky_draw_records", "command_id", luckyDrawSubCommandID(command.CommandID, 3, 3), 1, 0, 0, domain.StrategyJackpotMechanismMilestone)
var paidDraws, pendingTokens, daySpend, committedDraws, balanceBeforeReplay int64
if err := db.QueryRow(`SELECT paid_draws, pending_spend_jackpot_tokens FROM lucky_user_states WHERE app_code='lalu' AND user_id=? AND gift_id=?`, command.UserID, published.PoolID).Scan(&paidDraws, &pendingTokens); err != nil {
t.Fatalf("query committed dual spend state: %v", err)
}
if err := db.QueryRow(`SELECT spend_coins FROM lucky_user_strategy_days WHERE app_code='lalu' AND pool_id=? AND user_id=? AND stat_day=?`, published.PoolID, command.UserID, luckyPaidTime(command, nowMS).Format("2006-01-02")).Scan(&daySpend); err != nil {
t.Fatalf("query committed dual spend day: %v", err)
}
if err := db.QueryRow(`SELECT COUNT(*) FROM lucky_draw_records WHERE app_code='lalu' AND command_id LIKE 'dual-spend-batch-command#%'`).Scan(&committedDraws); err != nil {
t.Fatalf("query committed dual spend draws: %v", err)
}
if err := db.QueryRow(`SELECT balance FROM lucky_pools WHERE app_code='lalu' AND scope_type=? AND scope_id=?`, luckyDynamicPoolScopeType, published.PoolID).Scan(&balanceBeforeReplay); err != nil {
t.Fatalf("query committed dual spend pool: %v", err)
}
if paidDraws != 3 || pendingTokens != 0 || daySpend != 30 || committedDraws != 3 {
t.Fatalf("committed spend batch state paid/token/day/draws=%d/%d/%d/%d", paidDraws, pendingTokens, daySpend, committedDraws)
}
replayed, err := (&Repository{db: db}).ExecuteLuckyGiftDraw(ctx, command, nowMS+2)
if err != nil {
t.Fatalf("replay committed dual spend batch: %v", err)
}
var replayPaidDraws, replayPendingTokens, replayDaySpend, replayDraws, balanceAfterReplay int64
if err := db.QueryRow(`SELECT paid_draws, pending_spend_jackpot_tokens FROM lucky_user_states WHERE app_code='lalu' AND user_id=? AND gift_id=?`, command.UserID, published.PoolID).Scan(&replayPaidDraws, &replayPendingTokens); err != nil {
t.Fatalf("query replayed dual spend state: %v", err)
}
if err := db.QueryRow(`SELECT spend_coins FROM lucky_user_strategy_days WHERE app_code='lalu' AND pool_id=? AND user_id=? AND stat_day=?`, published.PoolID, command.UserID, luckyPaidTime(command, nowMS).Format("2006-01-02")).Scan(&replayDaySpend); err != nil {
t.Fatalf("query replayed dual spend day: %v", err)
}
if err := db.QueryRow(`SELECT COUNT(*) FROM lucky_draw_records WHERE app_code='lalu' AND command_id LIKE 'dual-spend-batch-command#%'`).Scan(&replayDraws); err != nil {
t.Fatalf("query replayed dual spend draws: %v", err)
}
if err := db.QueryRow(`SELECT balance FROM lucky_pools WHERE app_code='lalu' AND scope_type=? AND scope_id=?`, luckyDynamicPoolScopeType, published.PoolID).Scan(&balanceAfterReplay); err != nil {
t.Fatalf("query replayed dual spend pool: %v", err)
}
if replayed.DrawID != first.DrawID || replayPaidDraws != 3 || replayPendingTokens != 0 || replayDaySpend != 30 || replayDraws != 3 || balanceAfterReplay != balanceBeforeReplay {
t.Fatalf("replay duplicated spend batch: first/replay=%s/%s paid/token/day/draws/pool=%d/%d/%d/%d/%d want pool=%d", first.DrawID, replayed.DrawID, replayPaidDraws, replayPendingTokens, replayDaySpend, replayDraws, balanceAfterReplay, balanceBeforeReplay)
}
}
// assertExternalNewUserSpendBatch proves the external path needs neither recharge facts nor a
// 48-hour-old account: a one-hour-old authenticated user earns at child 2 and spends at child 3.
func assertExternalNewUserSpendBatch(t *testing.T, repo *Repository, db *sql.DB, ctx context.Context, nowMS int64) {
t.Helper()
rule := dynamicMySQLTestRule("dual-spend-external-new-user")
rule.JackpotSpendThresholdCoins = 20
published, err := repo.PublishLuckyGiftRuleConfig(ctx, rule, nowMS-2_000)
if err != nil {
t.Fatalf("publish external new-user spend rule: %v", err)
}
fundDynamicSpendTestPool(t, repo, ctx, published.PoolID, "dual-spend-external-credit", nowMS-1_900)
seedClosedDynamicSpendGlobalWindow(t, db, published, nowMS-1_500)
command := domain.ExternalDrawCommand{
AppCode: "lalu", ExternalUserID: "dual-spend-new-external-user", DeviceID: "dual-spend-new-external-device",
RequestID: "dual-spend-new-external-request", GiftCount: 3, UnitAmount: 10, TotalAmount: 30,
Currency: "COIN", PaidAtMS: nowMS, UserRegisteredAtMS: nowMS - int64(time.Hour/time.Millisecond), PoolID: published.PoolID,
}
first, err := (&Repository{db: db}).ExecuteExternalGiftDraw(ctx, command, nowMS)
if err != nil {
t.Fatalf("execute external new-user spend batch: %v", err)
}
assertSpendTraceSnapshot(t, db, "external_lucky_gift_draw_items", "request_id", command.RequestID, 1, 0, 0, domain.StrategyJackpotMechanismMilestone)
externalUserID := externalLuckyUserID(command.AppCode, command.ExternalUserID)
var paidDraws, pendingTokens, itemCount, aggregateCount int64
if err := db.QueryRow(`SELECT paid_draws, pending_spend_jackpot_tokens FROM lucky_user_states WHERE app_code='lalu' AND user_id=? AND gift_id=?`, externalUserID, published.PoolID).Scan(&paidDraws, &pendingTokens); err != nil {
t.Fatalf("query external new-user spend state: %v", err)
}
if err := db.QueryRow(`SELECT COUNT(*) FROM external_lucky_gift_draw_items WHERE app_code='lalu' AND request_id=?`, command.RequestID).Scan(&itemCount); err != nil {
t.Fatalf("query external new-user spend items: %v", err)
}
if err := db.QueryRow(`SELECT COUNT(*) FROM external_lucky_gift_draws WHERE app_code='lalu' AND request_id=?`, command.RequestID).Scan(&aggregateCount); err != nil {
t.Fatalf("query external new-user spend aggregate: %v", err)
}
if first.RewardAmount <= 0 || paidDraws != 3 || pendingTokens != 0 || itemCount != 3 || aggregateCount != 1 {
t.Fatalf("external new-user spend result reward/paid/token/items/aggregate=%d/%d/%d/%d/%d", first.RewardAmount, paidDraws, pendingTokens, itemCount, aggregateCount)
}
replayed, err := (&Repository{db: db}).ExecuteExternalGiftDraw(ctx, command, nowMS+1)
if err != nil {
t.Fatalf("replay external new-user spend batch: %v", err)
}
var replayPaidDraws, replayItems int64
if err := db.QueryRow(`SELECT paid_draws FROM lucky_user_states WHERE app_code='lalu' AND user_id=? AND gift_id=?`, externalUserID, published.PoolID).Scan(&replayPaidDraws); err != nil {
t.Fatalf("query replayed external new-user state: %v", err)
}
if err := db.QueryRow(`SELECT COUNT(*) FROM external_lucky_gift_draw_items WHERE app_code='lalu' AND request_id=?`, command.RequestID).Scan(&replayItems); err != nil {
t.Fatalf("query replayed external new-user items: %v", err)
}
if replayed.DrawID != first.DrawID || replayPaidDraws != 3 || replayItems != 3 {
t.Fatalf("external retry duplicated spend batch: first/replay=%s/%s paid/items=%d/%d", first.DrawID, replayed.DrawID, replayPaidDraws, replayItems)
}
}
func fundDynamicSpendTestPool(t *testing.T, repo *Repository, ctx context.Context, poolID, adjustmentID string, nowMS int64) {
t.Helper()
result, err := repo.AdjustLuckyGiftPoolBalance(ctx, domain.PoolAdjustmentCommand{
AppCode: "lalu", PoolID: poolID, StrategyVersion: domain.StrategyDynamicV3,
AdjustmentID: adjustmentID, Direction: domain.PoolAdjustmentIn, AmountCoins: 1_000_000,
Reason: "dual spend integration fixture", OperatorAdminID: 1,
}, nowMS)
if err != nil {
t.Fatalf("fund dynamic spend test pool: %v", err)
}
if result.Pool.Balance != 1_000_000 {
t.Fatalf("funded dynamic spend pool balance=%d want=1000000", result.Pool.Balance)
}
}
func seedClosedDynamicSpendGlobalWindow(t *testing.T, db *sql.DB, rule domain.RuleConfig, nowMS int64) {
t.Helper()
runtimeConfig, err := luckyRuntimeConfigFromRuleConfig(rule)
if err != nil {
t.Fatalf("build dynamic spend runtime config: %v", err)
}
scopeID := luckyRuleWindowScopeID(runtimeConfig)
if _, err := db.Exec(`
INSERT INTO lucky_rtp_windows (
app_code,scope_type,scope_id,window_index,target_rtp_ppm,control_window_draws,
paid_draws,wager_coins,target_payout_coins,actual_base_payout_coins,carry_ppm,status,
started_at_ms,last_paid_at_ms,closed_at_ms,created_at_ms,updated_at_ms
) VALUES ('lalu','pool',?,1,980000,1000,100,1000,980,980,0,'closed',?,?,?,?,?)`,
scopeID, nowMS-1_000, nowMS, nowMS, nowMS-1_000, nowMS,
); err != nil {
t.Fatalf("seed closed dynamic spend global window: %v", err)
}
}
func assertSpendTraceSnapshot(t *testing.T, db *sql.DB, table, keyColumn, key string, wantBefore, wantAfter, wantEarned int64, wantMechanism string) {
t.Helper()
jsonColumn := "candidate_tiers_json"
itemPredicate := ""
if table == "external_lucky_gift_draw_items" {
jsonColumn = "candidate_snapshot_json"
itemPredicate = " AND item_index = 3"
}
query := fmt.Sprintf(`
SELECT
CAST(JSON_EXTRACT(%s, '$.dynamic_v3.pending_spend_jackpot_tokens_before') AS SIGNED),
CAST(JSON_EXTRACT(%s, '$.dynamic_v3.pending_spend_jackpot_tokens_after') AS SIGNED),
CAST(JSON_EXTRACT(%s, '$.dynamic_v3.spend_milestone_tokens_earned') AS SIGNED),
COALESCE(JSON_UNQUOTE(JSON_EXTRACT(%s, '$.dynamic_v3.jackpot_mechanism')), '')
FROM %s WHERE app_code='lalu' AND %s=?%s`, jsonColumn, jsonColumn, jsonColumn, jsonColumn, table, keyColumn, itemPredicate)
var before, after, earned int64
var mechanism string
if err := db.QueryRow(query, key).Scan(&before, &after, &earned, &mechanism); err != nil {
t.Fatalf("query %s spend trace for %s: %v", table, key, err)
}
if before != wantBefore || after != wantAfter || earned != wantEarned || mechanism != wantMechanism {
t.Fatalf("%s spend trace for %s before/after/earned/mechanism=%d/%d/%d/%q want=%d/%d/%d/%q", table, key, before, after, earned, mechanism, wantBefore, wantAfter, wantEarned, wantMechanism)
}
}
func assertPendingTokenCount(t *testing.T, db *sql.DB, poolID string, userID, want int64) {
t.Helper()
var got int64
if err := db.QueryRow(`
SELECT pending_jackpot_tokens FROM lucky_user_states
SELECT pending_spend_jackpot_tokens FROM lucky_user_states
WHERE app_code='lalu' AND user_id=? AND gift_id=?`, userID, poolID,
).Scan(&got); err != nil {
t.Fatalf("query pending token: %v", err)
@ -497,3 +738,233 @@ func dynamicMySQLTestRule(poolID string) domain.RuleConfig {
Stages: stages,
}
}
func dualSourceV5ShapeRule(poolID string) domain.RuleConfig {
rule := dynamicMySQLTestRule(poolID)
// 运行参数与测试服 lalu/lucky/v5 对齐;阶段基础表仍沿用轻量测试表,下面只把
// v5 的真实重叠档追加进去,避免这个 repository 单测复制整份 35 档 fixture。
rule.TargetRTPPPM, rule.PoolRatePPM, rule.ProfitRatePPM, rule.AnchorRatePPM = 890_000, 890_000, 10_000, 100_000
rule.GiftPriceReference = 100
rule.JackpotMultiplierPPMs = []int64{100_000_000, 200_000_000, 500_000_000, 1_000_000_000}
rule.JackpotGlobalRTPMaxPPM, rule.JackpotUserRoundRTPMaxPPM, rule.JackpotUser48hRTPMaxPPM = 980_000, 950_000, 950_000
rule.MaxJackpotHitsPerUserDay = 10
rule.MaxSinglePayout, rule.UserHourlyPayoutCap, rule.UserDailyPayoutCap = 4_000_000_000, 4_000_000_000, 4_000_000_000
rule.DeviceDailyPayoutCap, rule.RoomHourlyPayoutCap, rule.AnchorDailyPayoutCap = 4_000_000_000, 4_000_000_000, 4_000_000_000
// 回归测试服 lalu/lucky v5 的真实重叠形状normal 重叠 100/200xadvanced
// 重叠 100/200/500/1000x。这里仅验证运行适配不依赖停用规则的空奖池开奖。
overlaps := map[string][]struct {
multiplier int64
weight int64
}{
domain.StageNormal: {
{multiplier: 100_000_000, weight: 810},
{multiplier: 200_000_000, weight: 405},
},
domain.StageAdvanced: {
{multiplier: 100_000_000, weight: 634},
{multiplier: 200_000_000, weight: 317},
{multiplier: 500_000_000, weight: 127},
{multiplier: 1_000_000_000, weight: 64},
},
}
for stageIndex := range rule.Stages {
stage := &rule.Stages[stageIndex]
var overlapWeight int64
for _, overlap := range overlaps[stage.Stage] {
overlapWeight += overlap.weight
}
for tierIndex := range stage.Tiers {
if stage.Tiers[tierIndex].MultiplierPPM == 1_000_000 {
stage.Tiers[tierIndex].BaseWeightPPM -= overlapWeight
}
}
for _, overlap := range overlaps[stage.Stage] {
stage.Tiers = append(stage.Tiers, domain.RuleTier{
Stage: stage.Stage, TierID: fmt.Sprintf("%s_ordinary_%d", stage.Stage, overlap.multiplier),
MultiplierPPM: overlap.multiplier, BaseWeightPPM: overlap.weight, HighWaterOnly: true, Enabled: true,
})
}
}
return rule
}
func TestLuckyDynamicStrategyConfigSeparatesOrdinaryAndJackpotSources(t *testing.T) {
rule := dualSourceV5ShapeRule("dual-source")
runtimeConfig, strategy, err := BuildLuckyDynamicStrategyForRule(rule)
if err != nil {
t.Fatal(err)
}
if runtimeConfig.TargetRTPPPM != 890_000 || strategy.DailyJackpotLimit != 10 {
t.Fatalf("v5 runtime target/cap=%d/%d want=890000/10", runtimeConfig.TargetRTPPPM, strategy.DailyJackpotLimit)
}
if !strategy.JackpotMechanism2Enabled || strategy.MilestoneSpendCoins != rule.JackpotSpendThresholdCoins {
t.Fatalf("runtime spend strategy enabled/threshold=%v/%d want=true/%d", strategy.JackpotMechanism2Enabled, strategy.MilestoneSpendCoins, rule.JackpotSpendThresholdCoins)
}
ordinary := make(map[int64]*domain.StrategyTier)
jackpot := make(map[int64]*domain.StrategyTier)
for index := range strategy.Tiers {
tier := &strategy.Tiers[index]
if tier.Jackpot {
jackpot[tier.MultiplierPPM] = tier
} else if tier.MultiplierPPM >= 100_000_000 {
ordinary[tier.MultiplierPPM] = tier
}
}
wantWeights := map[int64]map[string]int64{
100_000_000: {domain.StageNormal: 810, domain.StageAdvanced: 634},
200_000_000: {domain.StageNormal: 405, domain.StageAdvanced: 317},
500_000_000: {domain.StageAdvanced: 127},
1_000_000_000: {domain.StageAdvanced: 64},
}
for multiplier, stageWeights := range wantWeights {
ordinaryTier, jackpotTier := ordinary[multiplier], jackpot[multiplier]
if ordinaryTier == nil || ordinaryTier.Jackpot || ordinaryTier.ID != fmt.Sprintf("multiplier_%d", multiplier) {
t.Fatalf("ordinary tier multiplier=%d = %+v", multiplier, ordinaryTier)
}
if jackpotTier == nil || !jackpotTier.Jackpot || jackpotTier.ID != fmt.Sprintf("jackpot_%d", multiplier) || jackpotTier.JackpotWeight != 1 {
t.Fatalf("jackpot tier multiplier=%d = %+v", multiplier, jackpotTier)
}
for _, stageName := range []string{domain.StageNovice, domain.StageNormal, domain.StageAdvanced} {
if ordinaryTier.StageWeightPPM[stageName] != stageWeights[stageName] || jackpotTier.StageWeightPPM[stageName] != 0 {
t.Fatalf("multiplier=%d stage=%s ordinary/jackpot weights=%d/%d want=%d/0", multiplier, stageName, ordinaryTier.StageWeightPPM[stageName], jackpotTier.StageWeightPPM[stageName], stageWeights[stageName])
}
}
}
}
func TestLuckyDynamicDualSourcesSharePoolRTPRiskAndAuditAccounting(t *testing.T) {
rule := dualSourceV5ShapeRule("dual-source-accounting")
runtimeConfig, strategy, err := BuildLuckyDynamicStrategyForRule(rule)
if err != nil {
t.Fatal(err)
}
const (
poolBefore = int64(15_000_000)
paidAtMS = int64(1_784_000_000_000)
)
fullRisk := domain.StrategyRiskCapacity{
Enabled: true, SingleDrawCoins: 4_000_000_000, UserHourCoins: 4_000_000_000, UserDayCoins: 4_000_000_000,
DeviceDayCoins: 4_000_000_000, RoomHourCoins: 4_000_000_000, AnchorDayCoins: 4_000_000_000,
}
input := domain.StrategyInput{
GiftPriceCoins: runtimeConfig.GiftPrice, PoolContributionCoins: 89,
Recharge30DCoins: 1, RiskCapacity: fullRisk,
}
ordinaryState := domain.StrategyState{PoolBalanceCoins: poolBefore}
_, preview, err := domain.PreviewLuckyGiftStrategyWeights(strategy, ordinaryState, input)
if err != nil {
t.Fatal(err)
}
ordinaryIndex, ordinaryBound, ordinaryWeight := int64(0), int64(0), int64(0)
for _, item := range preview {
if item.TierID == "multiplier_100000000" {
ordinaryIndex, ordinaryWeight = ordinaryBound, item.AdjustedWeightPPM
}
ordinaryBound += item.AdjustedWeightPPM
}
if ordinaryWeight <= 0 {
t.Fatal("normal ordinary 100x is not selectable")
}
ordinaryRandomCalls := 0
ordinary, err := domain.DecideLuckyGiftStrategy(strategy, ordinaryState, input, domain.StrategyRandomFunc(func(bound int64) (int64, error) {
ordinaryRandomCalls++
if bound != ordinaryBound {
t.Fatalf("ordinary random bound=%d want=%d", bound, ordinaryBound)
}
return ordinaryIndex, nil
}))
if err != nil {
t.Fatal(err)
}
const closedAtMS = int64(100 * 60 * 60 * 1000)
jackpotState := domain.StrategyState{
PoolBalanceCoins: poolBefore, SettledRoundPending: true, QualificationClosedAtMS: closedAtMS,
UserRegisteredAtMS: closedAtMS - 49*60*60*1000,
GlobalRTP: domain.StrategyRTP{WagerCoins: 10_000, PayoutCoins: 8_900},
UserRoundRTP: domain.StrategyRTP{WagerCoins: 10_000, PayoutCoins: 9_400},
User48HourRTP: domain.StrategyRTP{WagerCoins: 10_000, PayoutCoins: 9_400},
}
jackpotRandomCalls := 0
jackpot, err := domain.DecideLuckyGiftStrategy(strategy, jackpotState, input, domain.StrategyRandomFunc(func(bound int64) (int64, error) {
jackpotRandomCalls++
if bound != 4 {
t.Fatalf("jackpot random bound=%d want=4", bound)
}
return 0, nil
}))
if err != nil {
t.Fatal(err)
}
if ordinaryRandomCalls != 1 || jackpotRandomCalls != 1 || ordinary.SelectedTier.ID != "multiplier_100000000" || ordinary.Jackpot ||
jackpot.SelectedTier.ID != "jackpot_100000000" || !jackpot.Jackpot || ordinary.PayoutCoins != jackpot.PayoutCoins {
t.Fatalf("ordinary/jackpot decisions not source-isolated: ordinary=%+v jackpot=%+v calls=%d/%d", ordinary, jackpot, ordinaryRandomCalls, jackpotRandomCalls)
}
// stageLuckyDynamicDecision is the production transaction accumulator. Running both
// sources through it proves source identity changes only jackpot_hits/audit weight;
// public pool, every RTP numerator and all six risk counters still receive the same payout.
assertStaged := func(label string, before domain.StrategyState, decision domain.StrategyDecision, wantJackpotHits, wantWeight int64) {
t.Helper()
riskCounters := make(map[string]luckyDynamicRiskCounter, 6)
for _, key := range []string{"single", "user_hour", "user_day", "device_day", "room_hour", "anchor_day"} {
riskCounters[key] = luckyDynamicRiskCounter{ScopeType: key}
}
state := luckyDynamicBatchState{
BasePool: luckyPool{Balance: before.PoolBalanceCoins},
GlobalWindow: luckyRTPWindow{TargetRTPPPM: runtimeConfig.TargetRTPPPM, Status: "open"},
GiftWindow: luckyRTPWindow{TargetRTPPPM: runtimeConfig.TargetRTPPPM, Status: "open"},
UserWindow: luckyUserRTPWindow{PoolID: runtimeConfig.PoolID, RuleVersion: runtimeConfig.RuleVersion},
RiskCounters: riskCounters,
Strategy: before,
}
command := domain.DrawCommand{
CommandID: label, PoolID: runtimeConfig.PoolID, GiftID: runtimeConfig.GiftID, GiftCount: 1,
UserID: 1001, TargetUserID: 2001, RoomID: "room-v5", AnchorID: "anchor-v5",
CoinSpent: 100, GiftIncomeCoins: 10, PaidAtMS: paidAtMS,
}
split := domain.StrategyFundSplit{PublicPoolCoins: 89, ProfitPoolCoins: 1, AnchorReturnCoins: 10}
if err := (&Repository{}).stageLuckyDynamicDecision("lalu", runtimeConfig, runtimeConfig.GiftPrice, command, split, decision, paidAtMS, &state); err != nil {
t.Fatalf("%s stage decision: %v", label, err)
}
if state.PublicIn != 89 || state.ProfitIn != 1 || state.AnchorIn != 10 || state.Payout != decision.PayoutCoins ||
state.BasePool.Balance != decision.PoolAfterCoins || state.BasePool.TotalIn != 89 || state.BasePool.TotalOut != decision.PayoutCoins ||
state.GlobalWindow.WagerCoins != 100 || state.GlobalWindow.ActualPayoutCoins != decision.PayoutCoins ||
state.GiftWindow.WagerCoins != 100 || state.GiftWindow.ActualPayoutCoins != decision.PayoutCoins ||
state.UserWindow.WagerCoins != 100 || state.UserWindow.PayoutCoins != decision.PayoutCoins ||
state.DayState.WagerCoins != 100 || state.DayState.PayoutCoins != decision.PayoutCoins || state.DayState.JackpotHits != wantJackpotHits ||
state.HourState.WagerCoins != 100 || state.HourState.PayoutCoins != decision.PayoutCoins {
t.Fatalf("%s shared accounting mismatch: %+v", label, state)
}
for key, counter := range state.RiskCounters {
if counter.Payout != decision.PayoutCoins {
t.Fatalf("%s risk %s payout=%d want=%d", label, key, counter.Payout, decision.PayoutCoins)
}
}
if len(state.Records) != 1 || state.Records[0].Candidate.TierID != decision.SelectedTier.ID || state.Records[0].Candidate.Weight != wantWeight ||
state.Records[0].StrategyDecision == nil || state.Records[0].StrategyDecision.Jackpot != decision.Jackpot {
t.Fatalf("%s audit record mismatch: %+v", label, state.Records)
}
}
assertStaged("ordinary-v5-100x", ordinaryState, ordinary, 0, ordinaryWeight)
assertStaged("jackpot-v5-100x", jackpotState, jackpot, 1, 1)
}
func TestLuckyDynamicSelectedWeightUsesWinningSource(t *testing.T) {
ordinary := domain.StrategyDecision{
SelectedTier: domain.StrategyTier{ID: "multiplier_200000000", MultiplierPPM: 200_000_000},
Trace: domain.DecisionTrace{Weights: []domain.StrategyWeightTrace{{TierID: "multiplier_200000000", AdjustedWeightPPM: 1_000}}},
}
if got := luckyDynamicSelectedWeight(ordinary); got != 1_000 {
t.Fatalf("ordinary audit weight=%d want=1000", got)
}
jackpot := domain.StrategyDecision{
SelectedTier: domain.StrategyTier{ID: "jackpot_200000000", MultiplierPPM: 200_000_000, Jackpot: true, JackpotWeight: 1},
Jackpot: true,
// 普通权重预览会保留 jackpot tier 的零权重事实;大奖审计必须使用独立选择权重 1。
Trace: domain.DecisionTrace{Weights: []domain.StrategyWeightTrace{{TierID: "jackpot_200000000", AdjustedWeightPPM: 0}}},
}
if got := luckyDynamicSelectedWeight(jackpot); got != 1 {
t.Fatalf("jackpot audit weight=%d want=1", got)
}
}

View File

@ -34,6 +34,140 @@ type luckyUserRTPHour struct {
PayoutCoins int64
}
// luckyUserRTPWindow is the user's money aggregate for one platform settlement window.
// qualification_status starts as pending while the platform window is open. It is evaluated lazily
// on that user's next paid draw after the platform window closes, avoiding a full-user scan at reset.
type luckyUserRTPWindow struct {
PoolID string
RuleVersion int64
WindowIndex int64
UserID int64
UserRegisteredAtMS int64
WagerCoins int64
PayoutCoins int64
Rolling48hWagerCoins int64
Rolling48hPayoutCoins int64
QualificationStatus string
AttemptedDrawID string
EvaluatedAtMS int64
}
func (r *Repository) getOrCreateLuckyUserRTPWindow(ctx context.Context, tx *sql.Tx, appCode, poolID string, ruleVersion, windowIndex, userID, userRegisteredAtMS, nowMS int64) (luckyUserRTPWindow, error) {
if _, err := tx.ExecContext(ctx, `
INSERT INTO lucky_user_rtp_windows (
app_code, pool_id, rule_version, window_index, user_id, user_registered_at_ms,
wager_coins, payout_coins, rolling_48h_wager_coins, rolling_48h_payout_coins,
qualification_status, attempted_draw_id, evaluated_at_ms, created_at_ms, updated_at_ms
) VALUES (?, ?, ?, ?, ?, ?, 0, 0, 0, 0, 'pending', '', 0, ?, ?)
ON DUPLICATE KEY UPDATE updated_at_ms = updated_at_ms`,
appCode, poolID, ruleVersion, windowIndex, userID, userRegisteredAtMS, nowMS, nowMS,
); err != nil {
return luckyUserRTPWindow{}, err
}
state := luckyUserRTPWindow{PoolID: poolID, RuleVersion: ruleVersion, WindowIndex: windowIndex, UserID: userID}
err := tx.QueryRowContext(ctx, `
SELECT user_registered_at_ms, wager_coins, payout_coins, rolling_48h_wager_coins, rolling_48h_payout_coins,
qualification_status, attempted_draw_id, evaluated_at_ms
FROM lucky_user_rtp_windows
WHERE app_code = ? AND pool_id = ? AND rule_version = ? AND window_index = ? AND user_id = ?
FOR UPDATE`, appCode, poolID, ruleVersion, windowIndex, userID,
).Scan(&state.UserRegisteredAtMS, &state.WagerCoins, &state.PayoutCoins, &state.Rolling48hWagerCoins, &state.Rolling48hPayoutCoins,
&state.QualificationStatus, &state.AttemptedDrawID, &state.EvaluatedAtMS)
return state, err
}
// getLatestPendingLuckyUserRTPWindow returns at most one closed qualification. A user cannot bank
// multiple compensation attempts: if historical pending rows exist, only the newest is retained and
// older rows are expired in the same transaction before the next paid draw is selected.
func (r *Repository) getLatestPendingLuckyUserRTPWindow(ctx context.Context, tx *sql.Tx, appCode, poolID string, ruleVersion, beforeWindowIndex, userID int64, globalScopeID string, nowMS int64) (luckyUserRTPWindow, luckyRTPWindow, bool, error) {
var userWindow luckyUserRTPWindow
userWindow.PoolID = poolID
userWindow.RuleVersion = ruleVersion
userWindow.UserID = userID
var global luckyRTPWindow
err := tx.QueryRowContext(ctx, `
SELECT u.window_index, u.user_registered_at_ms, u.wager_coins, u.payout_coins,
u.rolling_48h_wager_coins, u.rolling_48h_payout_coins,
u.qualification_status, u.attempted_draw_id, u.evaluated_at_ms,
g.window_index, g.scope_type, g.scope_id, g.target_rtp_ppm, g.control_window_draws,
g.paid_draws, g.wager_coins, g.target_payout_coins, g.actual_base_payout_coins,
g.carry_ppm, g.status, g.started_at_ms, g.last_paid_at_ms, g.closed_at_ms
FROM lucky_user_rtp_windows u
JOIN lucky_rtp_windows g
ON g.app_code = u.app_code AND g.scope_type = 'pool' AND g.scope_id = ?
AND g.window_index = u.window_index AND g.status = 'closed'
WHERE u.app_code = ? AND u.pool_id = ? AND u.rule_version = ? AND u.user_id = ?
AND u.qualification_status = 'pending' AND u.window_index < ?
ORDER BY u.window_index DESC
LIMIT 1
FOR UPDATE`, globalScopeID, appCode, poolID, ruleVersion, userID, beforeWindowIndex,
).Scan(&userWindow.WindowIndex, &userWindow.UserRegisteredAtMS, &userWindow.WagerCoins, &userWindow.PayoutCoins,
&userWindow.Rolling48hWagerCoins, &userWindow.Rolling48hPayoutCoins,
&userWindow.QualificationStatus, &userWindow.AttemptedDrawID, &userWindow.EvaluatedAtMS,
// JOIN 的全局轮次必须显式扫回若只读取用户轮次global 默认 0 会让所有正常闭窗资格
// 在 prepare 对齐校验中失败并回滚下一次送礼。
&global.WindowIndex, &global.ScopeType, &global.ScopeID, &global.TargetRTPPPM, &global.ControlDraws,
&global.PaidDraws, &global.WagerCoins, &global.TargetPayoutCoins, &global.ActualPayoutCoins,
&global.CarryPPM, &global.Status, &global.StartedAtMS, &global.LastPaidAtMS, &global.ClosedAtMS)
if err != nil {
if err == sql.ErrNoRows {
return luckyUserRTPWindow{}, luckyRTPWindow{}, false, nil
}
return luckyUserRTPWindow{}, luckyRTPWindow{}, false, err
}
if _, err := tx.ExecContext(ctx, `
UPDATE lucky_user_rtp_windows
SET qualification_status = 'expired', evaluated_at_ms = ?, updated_at_ms = ?
WHERE app_code = ? AND pool_id = ? AND rule_version = ? AND user_id = ?
AND qualification_status = 'pending' AND window_index < ?`,
nowMS, nowMS, appCode, poolID, ruleVersion, userID, userWindow.WindowIndex,
); err != nil {
return luckyUserRTPWindow{}, luckyRTPWindow{}, false, err
}
return userWindow, global, true, nil
}
// getLatestClosedLuckyDynamicRTPWindow returns the platform sample used only by the durable spend
// strategy. It intentionally does not join a user window: a user may have been inactive throughout
// the latest platform round. Legacy rows without an exact start/close boundary are excluded, because
// treating an administrative migration close as a settled sample could manufacture jackpot access.
func (r *Repository) getLatestClosedLuckyDynamicRTPWindow(ctx context.Context, tx *sql.Tx, appCode, globalScopeID string, beforeWindowIndex int64) (luckyRTPWindow, bool, error) {
var window luckyRTPWindow
err := tx.QueryRowContext(ctx, `
SELECT scope_type, scope_id, window_index, target_rtp_ppm, control_window_draws,
paid_draws, wager_coins, target_payout_coins, actual_base_payout_coins,
carry_ppm, status, started_at_ms, last_paid_at_ms, closed_at_ms
FROM lucky_rtp_windows
WHERE app_code = ? AND scope_type = 'pool' AND scope_id = ? AND status = 'closed'
AND window_index < ? AND wager_coins > 0 AND started_at_ms > 0 AND closed_at_ms > 0
ORDER BY window_index DESC
LIMIT 1`, appCode, globalScopeID, beforeWindowIndex,
).Scan(&window.ScopeType, &window.ScopeID, &window.WindowIndex, &window.TargetRTPPPM, &window.ControlDraws,
&window.PaidDraws, &window.WagerCoins, &window.TargetPayoutCoins, &window.ActualPayoutCoins,
&window.CarryPPM, &window.Status, &window.StartedAtMS, &window.LastPaidAtMS, &window.ClosedAtMS)
if err == sql.ErrNoRows {
return luckyRTPWindow{}, false, nil
}
if err != nil {
return luckyRTPWindow{}, false, err
}
return window, true, nil
}
func (r *Repository) persistLuckyUserRTPWindow(ctx context.Context, tx *sql.Tx, appCode string, state luckyUserRTPWindow, nowMS int64) error {
_, err := tx.ExecContext(ctx, `
UPDATE lucky_user_rtp_windows
SET wager_coins = ?, payout_coins = ?,
rolling_48h_wager_coins = ?, rolling_48h_payout_coins = ?,
qualification_status = ?, attempted_draw_id = ?, evaluated_at_ms = ?, updated_at_ms = ?
WHERE app_code = ? AND pool_id = ? AND rule_version = ? AND window_index = ? AND user_id = ?`,
state.WagerCoins, state.PayoutCoins, state.Rolling48hWagerCoins, state.Rolling48hPayoutCoins,
state.QualificationStatus, state.AttemptedDrawID, state.EvaluatedAtMS, nowMS,
appCode, state.PoolID, state.RuleVersion, state.WindowIndex, state.UserID,
)
return err
}
func luckyPaidTime(cmd domain.DrawCommand, nowMS int64) time.Time {
ms := cmd.PaidAtMS
if ms <= 0 {
@ -174,13 +308,26 @@ func (r *Repository) getOrCreateLuckyUserRTPHour(ctx context.Context, tx *sql.Tx
return state, err
}
func (r *Repository) getLuckyUserRolling48HourRTP(ctx context.Context, tx *sql.Tx, appCode, poolID string, userID int64, endMS int64) (domain.StrategyRTP, error) {
return r.getLuckyUserRollingHourRTP(ctx, tx, appCode, poolID, userID, endMS, 48)
}
func (r *Repository) getLuckyUserRolling24HourRTP(ctx context.Context, tx *sql.Tx, appCode, poolID string, userID int64, endMS int64) (domain.StrategyRTP, error) {
return r.getLuckyUserRollingHourRTP(ctx, tx, appCode, poolID, userID, endMS, 24)
}
// getLuckyUserRolling72HourRTP remains only for old in-package callers during rolling source upgrades.
func (r *Repository) getLuckyUserRolling72HourRTP(ctx context.Context, tx *sql.Tx, appCode, poolID string, userID int64, endMS int64) (domain.StrategyRTP, error) {
// 严格口径是 [end-72h,end),不能把“当前小时+前71整点桶”近似成 72h。
return r.getLuckyUserRollingHourRTP(ctx, tx, appCode, poolID, userID, endMS, 72)
}
func (r *Repository) getLuckyUserRollingHourRTP(ctx context.Context, tx *sql.Tx, appCode, poolID string, userID int64, endMS, hours int64) (domain.StrategyRTP, error) {
// 严格口径是 [end-hours,end),不能把“当前小时+前 N 个整点桶”近似成滚动窗口。
// 完整小时从聚合桶读取;头尾两个不足一小时的片段从专用边界事件读取,避免回扫 lucky_draw_records。
const hourMS = int64(time.Hour / time.Millisecond)
startMS := endMS - 72*hourMS
if startMS < 0 || endMS <= 0 {
return domain.StrategyRTP{}, fmt.Errorf("dynamic lucky gift 72h range is invalid")
startMS := endMS - hours*hourMS
if hours <= 0 || startMS < 0 || endMS <= 0 {
return domain.StrategyRTP{}, fmt.Errorf("dynamic lucky gift rolling RTP range is invalid")
}
fullStartMS := startMS
if remainder := startMS % hourMS; remainder != 0 {
@ -206,7 +353,7 @@ func (r *Repository) getLuckyUserRolling72HourRTP(ctx context.Context, tx *sql.T
var wager, payout int64
if err := tx.QueryRowContext(ctx, `
SELECT COALESCE(SUM(wager_coins), 0), COALESCE(SUM(payout_coins), 0)
FROM lucky_user_rtp_boundary_events
FROM lucky_user_rtp_boundary_events FORCE INDEX (idx_lucky_user_rtp_boundary_range)
WHERE app_code = ? AND pool_id = ? AND user_id = ?
AND occurred_at_ms >= ? AND occurred_at_ms < ?`,
appCode, poolID, userID, boundary[0], boundary[1],
@ -214,7 +361,7 @@ func (r *Repository) getLuckyUserRolling72HourRTP(ctx context.Context, tx *sql.T
return domain.StrategyRTP{}, err
}
if state.WagerCoins > int64(^uint64(0)>>1)-wager || state.PayoutCoins > int64(^uint64(0)>>1)-payout {
return domain.StrategyRTP{}, fmt.Errorf("dynamic lucky gift 72h RTP overflow")
return domain.StrategyRTP{}, fmt.Errorf("dynamic lucky gift rolling RTP overflow")
}
state.WagerCoins += wager
state.PayoutCoins += payout

View File

@ -26,9 +26,23 @@ func luckyUsesDynamicStrategy(config domain.Config) bool {
return config.StrategyVersion == domain.StrategyDynamicV3
}
// BuildLuckyDynamicStrategyForRule exposes the same read-only RuleConfig -> runtime ->
// pure-kernel adapter used by production. Offline simulators must enter through this
// boundary instead of hand-assembling StrategyTier values, otherwise a report can pass
// while silently omitting stage merging, generated source IDs or a newly added rule field.
// The function performs no database I/O and does not enable or publish the supplied rule.
func BuildLuckyDynamicStrategyForRule(rule domain.RuleConfig) (domain.Config, domain.StrategyConfig, error) {
config, err := luckyRuntimeConfigFromRuleConfig(rule)
if err != nil {
return domain.Config{}, domain.StrategyConfig{}, err
}
return config, luckyDynamicStrategyConfig(config), nil
}
// luckyDynamicStrategyConfig 把不可变规则快照转换为纯策略输入。
// 同一倍率在三个充值阶段可能使用不同 tier_id运行内核按倍率合并并保存 stage->weight
// 从而既只有一个 0x 候选,又不会把某阶段不存在的倍率错误继承到另一阶段。
// 同一倍率在三个充值阶段可能使用不同 tier_id普通路径按倍率合并并保存 stage->weight
// 大奖路径则始终生成独立 jackpot tier。倍率只决定金额ordinary/jackpot tier ID 才决定
// 命中来源,这样普通 200x 与补偿 200x 可以共存且不会共享每日大奖计数语义。
func luckyDynamicStrategyConfig(config domain.Config) domain.StrategyConfig {
stageNames := make([]string, 0, len(config.Stages))
rechargeStages := make([]domain.StrategyRechargeStage, 0, len(config.Stages))
@ -44,80 +58,89 @@ func luckyDynamicStrategyConfig(config domain.Config) domain.StrategyConfig {
for _, multiplier := range config.JackpotMultiplierPPMs {
jackpotSet[multiplier] = struct{}{}
}
tiersByMultiplier := make(map[int64]*domain.StrategyTier)
ordinaryByMultiplier := make(map[int64]*domain.StrategyTier)
for _, stage := range config.Stages {
for _, configured := range stage.Tiers {
if !configured.Enabled {
continue
}
tier := tiersByMultiplier[configured.MultiplierPPM]
tier := ordinaryByMultiplier[configured.MultiplierPPM]
if tier == nil {
stageWeights := make(map[string]int64, len(stageNames))
for _, stageName := range stageNames {
stageWeights[stageName] = 0
}
tier = &domain.StrategyTier{
// 保留既有普通奖档审计 ID来源隔离由普通 multiplier_ 与大奖 jackpot_ 前缀完成。
ID: fmt.Sprintf("multiplier_%d", configured.MultiplierPPM),
MultiplierPPM: configured.MultiplierPPM,
StageWeightPPM: stageWeights,
Enabled: true,
}
tiersByMultiplier[configured.MultiplierPPM] = tier
ordinaryByMultiplier[configured.MultiplierPPM] = tier
}
tier.StageWeightPPM[stage.Stage] += configured.BaseWeightPPM
}
}
// 大奖机制从显式倍率集合随机,不要求运营把 200x/500x/1000x 塞进普通概率表。
// 图片没有提供大奖之间的权重,因此统一使用 1这一选择会完整进入 decision trace不能被误认为基础概率。
// 大奖集合即使与普通倍率重合也另建 tier其普通阶段权重固定为 0只能在 RTP
// 补偿条件通过后由 selectPayableJackpot 选中。当前协议没有大奖内部权重,统一记 1。
jackpotByMultiplier := make(map[int64]domain.StrategyTier, len(jackpotSet))
for multiplier := range jackpotSet {
tier := tiersByMultiplier[multiplier]
if tier == nil {
stageWeights := make(map[string]int64, len(stageNames))
for _, stageName := range stageNames {
stageWeights[stageName] = 0
}
tier = &domain.StrategyTier{
ID: fmt.Sprintf("jackpot_%d", multiplier),
MultiplierPPM: multiplier,
StageWeightPPM: stageWeights,
Enabled: true,
}
tiersByMultiplier[multiplier] = tier
stageWeights := make(map[string]int64, len(stageNames))
for _, stageName := range stageNames {
stageWeights[stageName] = 0
}
jackpotByMultiplier[multiplier] = domain.StrategyTier{
ID: fmt.Sprintf("jackpot_%d", multiplier),
MultiplierPPM: multiplier,
StageWeightPPM: stageWeights,
Jackpot: true,
JackpotWeight: 1,
Enabled: true,
}
tier.Jackpot = true
tier.JackpotWeight = 1
}
multipliers := make([]int64, 0, len(tiersByMultiplier))
for multiplier := range tiersByMultiplier {
multipliers := make([]int64, 0, len(ordinaryByMultiplier))
for multiplier := range ordinaryByMultiplier {
multipliers = append(multipliers, multiplier)
}
sort.Slice(multipliers, func(i, j int) bool { return multipliers[i] < multipliers[j] })
tiers := make([]domain.StrategyTier, 0, len(multipliers))
jackpotMultipliers := make([]int64, 0, len(jackpotByMultiplier))
for multiplier := range jackpotByMultiplier {
jackpotMultipliers = append(jackpotMultipliers, multiplier)
}
sort.Slice(jackpotMultipliers, func(i, j int) bool { return jackpotMultipliers[i] < jackpotMultipliers[j] })
tiers := make([]domain.StrategyTier, 0, len(multipliers)+len(jackpotMultipliers))
for _, multiplier := range multipliers {
tiers = append(tiers, *tiersByMultiplier[multiplier])
tiers = append(tiers, *ordinaryByMultiplier[multiplier])
}
for _, multiplier := range jackpotMultipliers {
tiers = append(tiers, jackpotByMultiplier[multiplier])
}
return domain.StrategyConfig{
Tiers: tiers,
PublicPoolRatePPM: config.PoolRatePPM,
ProfitPoolRatePPM: config.ProfitRatePPM,
AnchorReturnRatePPM: config.AnchorRatePPM,
ColdStartPoolCoins: config.InitialPoolCoins,
LowWaterThresholdCoins: config.LowWatermarkCoins,
HighWaterThresholdCoins: config.HighWatermarkCoins,
LowWaterFactorPPM: config.LowWaterNonzeroFactorPPM,
HighWaterFactorPPM: config.HighWaterNonzeroFactorPPM,
RechargeFactorPPM: config.RechargeBoostFactorPPM,
RechargeBoostStartMS: 0,
RechargeBoostEndMS: config.RechargeBoostWindowMS,
MissProtectionZeroDraws: config.LossStreakGuarantee,
DailyJackpotLimit: config.MaxJackpotHitsPerUserDay,
MilestoneSpendCoins: config.JackpotSpendThresholdCoins,
JackpotMechanism1Enabled: true,
JackpotMechanism2Enabled: config.JackpotSpendThresholdCoins > 0,
GlobalRTPMaxPPM: config.JackpotGlobalRTPMaxPPM,
UserDayRTPMaxPPM: config.JackpotUserDayRTPMaxPPM,
User72HourRTPMaxPPM: config.JackpotUser72hRTPMaxPPM,
RechargeStages: rechargeStages,
Tiers: tiers,
PublicPoolRatePPM: config.PoolRatePPM,
ProfitPoolRatePPM: config.ProfitRatePPM,
AnchorReturnRatePPM: config.AnchorRatePPM,
ColdStartPoolCoins: config.InitialPoolCoins,
LowWaterThresholdCoins: config.LowWatermarkCoins,
HighWaterThresholdCoins: config.HighWatermarkCoins,
LowWaterFactorPPM: config.LowWaterNonzeroFactorPPM,
HighWaterFactorPPM: config.HighWaterNonzeroFactorPPM,
RechargeFactorPPM: config.RechargeBoostFactorPPM,
RechargeBoostStartMS: 0,
RechargeBoostEndMS: config.RechargeBoostWindowMS,
User24HourRTPThresholdPPM: config.User24HourRTPThresholdPPM,
User24HourOrdinaryWinFactorPPM: config.User24HourOrdinaryWinFactorPPM,
MissProtectionZeroDraws: config.LossStreakGuarantee,
DailyJackpotLimit: config.MaxJackpotHitsPerUserDay,
MilestoneSpendCoins: config.JackpotSpendThresholdCoins,
JackpotMechanism1Enabled: true,
JackpotMechanism2Enabled: config.JackpotSpendThresholdCoins > 0,
GlobalRTPMaxPPM: config.JackpotGlobalRTPMaxPPM,
UserRoundRTPMaxPPM: config.JackpotUserRoundRTPMaxPPM,
User48HourRTPMaxPPM: config.JackpotUser48hRTPMaxPPM,
SettledRoundRTPEligibility: true,
RechargeStages: rechargeStages,
}
}

View File

@ -19,17 +19,31 @@ const luckyExperimentSelectColumns = `app_code, experiment_id, pool_id, name,
// resolveLuckyGiftRuleConfigForUser 是实验期的配置选择入口:
// 无未完成实验时保持"最新版本生效"的原始语义;有实验时按白名单和哈希分桶钉住组内版本。
// 实验行使用一致性快照读(不加锁):给它加锁会与创建/结束实验的"规则行→实验行"锁序构成 AB-BA。
// 已知取舍:创建实验提交瞬间,快照早于提交而规则行锁读晚于提交的在途抽奖,会按"最新版本=treatment"
// 开奖一次——毫秒级窗口,两组配置都是已校验可运行的版本,无资金风险,仅统计上可忽略的归组误差
// 事务开奖先锁最新规则行,再一致性读取实验:这与创建/结束实验保持"规则行→实验行"顺序,
// 同时确保 REPEATABLE READ 快照建立在前一笔同奖池结算提交之后,滚动 RTP 不会漏掉刚完成的抽奖。
// Check 等只读入口不加锁,仍使用普通一致性快照,避免把低频配置检查串行化
// 实验状态(放量/白名单/暂停)变更会让分组在 Check 与 Draw 之间漂移;创建入口强制两组同策略,漂移无害。
// 钉住的版本行缺失表示不可变事实被外部破坏,必须 fail-close不能静默回落最新版本改变实验语义。
func (r *Repository) resolveLuckyGiftRuleConfigForUser(ctx context.Context, q luckyRuleQueryer, appCode string, poolID string, userKey string, forUpdate bool) (domain.RuleConfig, bool, error) {
var lockedLatest domain.RuleConfig
var lockedLatestExists bool
var err error
if forUpdate {
// 最新规则行是同奖池开奖和实验管理共享的串行化入口。先锁它再读取实验,
// 既固定事务快照边界,也避免 control/treatment 两个版本按用户分组产生反向锁序。
lockedLatest, lockedLatestExists, err = r.getLuckyGiftRuleConfig(ctx, q, appCode, poolID, true)
if err != nil {
return domain.RuleConfig{}, false, err
}
}
experiment, exists, err := r.getActiveLuckyGiftExperiment(ctx, q, appCode, poolID, false)
if err != nil {
return domain.RuleConfig{}, false, err
}
if !exists {
if forUpdate {
return lockedLatest, lockedLatestExists, nil
}
return r.getLuckyGiftRuleConfig(ctx, q, appCode, poolID, forUpdate)
}
override := ""

View File

@ -39,6 +39,11 @@ type luckyRTPWindow struct {
ActualPayoutCoins int64
CarryPPM int64
Status string
// StartedAtMS/LastPaidAtMS/ClosedAtMS are business-time boundaries from the wallet payment fact.
// created_at_ms remains an infrastructure audit timestamp and must not be used for RTP eligibility.
StartedAtMS int64
LastPaidAtMS int64
ClosedAtMS int64
}
func (w luckyRTPWindow) withCurrentWager(spent int64) luckyRTPWindow {
@ -73,9 +78,12 @@ type luckyUserState struct {
CumulativeWagerCoins int64
EquivalentDraws int64
LossStreak int64
// PendingJackpotTokens 是按 UTC 日消费赚取、但允许跨日等待资金的未消费大奖资格。
// 它属于 pool/user 生命周期,不能和“当天消费/当天命中次数”一起在午夜静默清零。
// PendingJackpotTokens is the retired token column. Dynamic V3 reads it only so the next paid
// transaction can clear it; its value must never become a new spend jackpot qualification.
PendingJackpotTokens int64
// PendingSpendJackpotTokens belongs to the app/pool/user lifetime. UTC-day spend creates tokens,
// but a blocked token survives midnight until one spend jackpot is actually paid.
PendingSpendJackpotTokens int64
}
type luckyCandidate struct {
@ -111,8 +119,8 @@ type luckyDrawRecordInput struct {
// 后台以后修改规则版本也不会让历史结果失去可解释性。
StrategyDecision *domain.StrategyDecision
FundSplit domain.StrategyFundSplit
UserDayRTP domain.StrategyRTP
User72HourRTP domain.StrategyRTP
UserRoundRTP domain.StrategyRTP
User48HourRTP domain.StrategyRTP
RewardStatus string
NowMS int64
}
@ -563,6 +571,9 @@ func (r *Repository) updateLuckyPoolNet(ctx context.Context, tx *sql.Tx, appCode
// insertLuckyDrawRecords 批量写入审计明细。
// 仍然保留每一抽一条记录,因为后台统计“抽奖次数”和问题复盘都需要单抽粒度;优化点是批量 INSERT 而不是 N 次 INSERT。
func (r *Repository) insertLuckyDrawRecords(ctx context.Context, tx *sql.Tx, records []luckyDrawRecordInput) error {
if len(records) == 0 {
return nil
}
const chunkSize = 200
for start := 0; start < len(records); start += chunkSize {
end := start + chunkSize
@ -592,7 +603,14 @@ func (r *Repository) insertLuckyDrawRecords(ctx context.Context, tx *sql.Tx, rec
return err
}
}
return nil
// 画像事件与抽奖事实同事务提交;后台页面因此不依赖下一轮历史回灌,同时事件唯一键仍允许回灌安全补旧数据。
facts := make([]luckyUserProfileFact, 0, len(records))
for _, record := range records {
userID := record.Command.UserID
facts = append(facts, luckyUserProfileFactFromRecord(record, luckyUserProfileSourceInternal,
domain.UserProfileIdentityInternal, fmt.Sprintf("%d", userID), "", userID))
}
return r.persistLuckyGiftUserProfileFacts(ctx, tx, facts, records[len(records)-1].NowMS)
}
type luckyDrawPoolStatKey struct {
@ -1039,9 +1057,26 @@ func luckyDrawRecordSnapshots(input luckyDrawRecordInput) (string, string, strin
if input.StrategyDecision != nil {
candidateData["dynamic_v3"] = input.StrategyDecision.Trace
poolData["fund_split"] = input.FundSplit
rtpData["user_day"] = input.UserDayRTP
rtpData["user_72h"] = input.User72HourRTP
rtpData["global"] = input.StrategyDecision.NextState.GlobalRTP
rtpData["user_round"] = input.UserRoundRTP
rtpData["user_48h"] = input.User48HourRTP
for _, condition := range input.StrategyDecision.Trace.Conditions {
if condition.Name != domain.StrategyConditionUser24HourRTPDownweight {
continue
}
// Persist explicit business names rather than forcing incident tooling to reinterpret the
// generic condition numerator/denominator. This is the pre-draw snapshot; current payout is
// added only to StrategyDecision.NextState for the next subdraw.
rtpData["user_24h_downweight"] = map[string]any{
"wager_coins": condition.Denominator, "payout_coins": condition.Numerator,
"rtp_ppm": condition.RatioPPM, "threshold_ppm": condition.LimitPPM,
"ordinary_win_factor_ppm": condition.FactorPPM,
"active": condition.Passed, "reason": condition.Reason,
}
break
}
// Eligibility uses the just-closed platform snapshot; it is intentionally frozen while the
// current open window advances independently on this draw.
rtpData["qualification_global"] = input.StrategyDecision.NextState.GlobalRTP
}
candidateSnapshot, _ := json.Marshal(candidateData)
poolSnapshot, _ := json.Marshal(poolData)
@ -1402,6 +1437,18 @@ func (r *Repository) applyLuckyDraw(ctx context.Context, tx *sql.Tx, input lucky
); err != nil {
return err
}
profileRecord := luckyDrawRecordInput{
AppCode: input.AppCode, DrawID: input.DrawID, Command: input.Command, Config: input.Config,
ExperiencePool: input.ExperiencePool, Candidate: input.Candidate, Limited: input.Limited,
StageFeedback: input.StageFeedback, GlobalWindow: input.GlobalWindow, GiftWindow: input.GiftWindow,
BasePool: input.BasePool, RewardStatus: input.RewardStatus, NowMS: input.NowMS,
}
if err := r.persistLuckyGiftUserProfileFacts(ctx, tx, []luckyUserProfileFact{
luckyUserProfileFactFromRecord(profileRecord, luckyUserProfileSourceInternal,
domain.UserProfileIdentityInternal, fmt.Sprintf("%d", input.Command.UserID), "", input.Command.UserID),
}, input.NowMS); err != nil {
return err
}
if luckyDrawNeedsRewardSettlementOutbox(input.Candidate) {
// 同一 owner outbox 先补偿钱包返奖,再在 granted 收敛后发布中奖事实room/activity 的展示副作用
// 由各自本地 outbox 独立补偿,不能在抽奖事务里直接调用腾讯云 IM。
@ -1846,6 +1893,10 @@ func (r *Repository) markLuckyGiftDraws(ctx context.Context, appCode string, dra
_ = tx.Rollback()
return err
}
if err := r.migrateLuckyGiftUserProfileRewardStatuses(ctx, tx, appCode, chunk, status, onlyPending, nowMS); err != nil {
_ = tx.Rollback()
return err
}
if err := tx.Commit(); err != nil {
return err
}
@ -2300,55 +2351,64 @@ func luckyRuntimeConfigFromRuleConfig(ruleConfig domain.RuleConfig) (domain.Conf
if !hasEnabledBaseTier {
return domain.Config{}, xerr.New(xerr.InvalidArgument, "lucky gift enabled base tiers are required")
}
user24HourOrdinaryWinFactorPPM := ruleConfig.User24HourOrdinaryWinFactorPPM
if ruleConfig.User24HourRTPThresholdPPM == 0 && user24HourOrdinaryWinFactorPPM == 0 {
// Rules published before the field existed deserialize as 0/0. Canonicalize that absence to
// the documented disabled 0/100% sentinel so no legacy rule can become a zero-win table.
user24HourOrdinaryWinFactorPPM = luckyPPMScale
}
// 单池模型把旧 platform/room/gift 三层责任池合并为 app+pool_id 一口基础池;
// 初始水位和安全水位使用旧三池默认值求和,保证新规则上线后高倍奖支付能力不会因模型收敛而突然降低。
config := domain.Config{
AppCode: ruleConfig.AppCode,
GiftID: poolID,
PoolID: poolID,
Enabled: ruleConfig.Enabled,
RuleVersion: ruleConfig.RuleVersion,
StrategyVersion: ruleConfig.StrategyVersion,
GiftPrice: referencePrice,
TargetRTPPPM: ruleConfig.TargetRTPPPM,
PoolRatePPM: ruleConfig.PoolRatePPM,
ProfitRatePPM: ruleConfig.ProfitRatePPM,
AnchorRatePPM: ruleConfig.AnchorRatePPM,
ControlBandPPM: ruleConfig.ControlBandPPM,
SettlementWindowWager: ruleConfig.SettlementWindowWager,
GlobalWindowDraws: windowDraws,
GiftWindowDraws: windowDraws,
NoviceMaxEquivalentDraws: ruleConfig.NoviceMaxEquivalentDraws,
NormalMaxEquivalentDraws: ruleConfig.NormalMaxEquivalentDraws,
HighMultiplier: 10,
HighWaterPoolMultiple: 2,
InitialBasePool: referencePrice * (950 + 500 + 2_375),
BasePoolReserve: referencePrice * (100 + 10 + 100),
InitialPoolCoins: ruleConfig.InitialPoolCoins,
LossStreakGuarantee: ruleConfig.LossStreakGuarantee,
LowWatermarkCoins: ruleConfig.LowWatermarkCoins,
LowWaterNonzeroFactorPPM: ruleConfig.LowWaterNonzeroFactorPPM,
HighWatermarkCoins: ruleConfig.HighWatermarkCoins,
HighWaterNonzeroFactorPPM: ruleConfig.HighWaterNonzeroFactorPPM,
RechargeBoostWindowMS: ruleConfig.RechargeBoostWindowMS,
RechargeBoostFactorPPM: ruleConfig.RechargeBoostFactorPPM,
JackpotMultiplierPPMs: append([]int64(nil), ruleConfig.JackpotMultiplierPPMs...),
JackpotGlobalRTPMaxPPM: ruleConfig.JackpotGlobalRTPMaxPPM,
JackpotUserDayRTPMaxPPM: ruleConfig.JackpotUserDayRTPMaxPPM,
JackpotUser72hRTPMaxPPM: ruleConfig.JackpotUser72hRTPMaxPPM,
JackpotSpendThresholdCoins: ruleConfig.JackpotSpendThresholdCoins,
MaxJackpotHitsPerUserDay: ruleConfig.MaxJackpotHitsPerUserDay,
MaxSinglePayout: ruleConfig.MaxSinglePayout,
UserHourlyPayoutCap: ruleConfig.UserHourlyPayoutCap,
UserDailyPayoutCap: ruleConfig.UserDailyPayoutCap,
DeviceDailyPayoutCap: ruleConfig.DeviceDailyPayoutCap,
RoomHourlyPayoutCap: ruleConfig.RoomHourlyPayoutCap,
AnchorDailyPayoutCap: ruleConfig.AnchorDailyPayoutCap,
Tiers: tiers,
Stages: ruleConfig.Stages,
UpdatedByAdminID: ruleConfig.CreatedByAdminID,
CreatedAtMS: ruleConfig.CreatedAtMS,
UpdatedAtMS: ruleConfig.CreatedAtMS,
AppCode: ruleConfig.AppCode,
GiftID: poolID,
PoolID: poolID,
Enabled: ruleConfig.Enabled,
RuleVersion: ruleConfig.RuleVersion,
StrategyVersion: ruleConfig.StrategyVersion,
GiftPrice: referencePrice,
TargetRTPPPM: ruleConfig.TargetRTPPPM,
PoolRatePPM: ruleConfig.PoolRatePPM,
ProfitRatePPM: ruleConfig.ProfitRatePPM,
AnchorRatePPM: ruleConfig.AnchorRatePPM,
ControlBandPPM: ruleConfig.ControlBandPPM,
SettlementWindowWager: ruleConfig.SettlementWindowWager,
GlobalWindowDraws: windowDraws,
GiftWindowDraws: windowDraws,
NoviceMaxEquivalentDraws: ruleConfig.NoviceMaxEquivalentDraws,
NormalMaxEquivalentDraws: ruleConfig.NormalMaxEquivalentDraws,
HighMultiplier: 10,
HighWaterPoolMultiple: 2,
InitialBasePool: referencePrice * (950 + 500 + 2_375),
BasePoolReserve: referencePrice * (100 + 10 + 100),
InitialPoolCoins: ruleConfig.InitialPoolCoins,
LossStreakGuarantee: ruleConfig.LossStreakGuarantee,
LowWatermarkCoins: ruleConfig.LowWatermarkCoins,
LowWaterNonzeroFactorPPM: ruleConfig.LowWaterNonzeroFactorPPM,
HighWatermarkCoins: ruleConfig.HighWatermarkCoins,
HighWaterNonzeroFactorPPM: ruleConfig.HighWaterNonzeroFactorPPM,
RechargeBoostWindowMS: ruleConfig.RechargeBoostWindowMS,
RechargeBoostFactorPPM: ruleConfig.RechargeBoostFactorPPM,
User24HourRTPThresholdPPM: ruleConfig.User24HourRTPThresholdPPM,
User24HourOrdinaryWinFactorPPM: user24HourOrdinaryWinFactorPPM,
JackpotMultiplierPPMs: append([]int64(nil), ruleConfig.JackpotMultiplierPPMs...),
JackpotGlobalRTPMaxPPM: ruleConfig.JackpotGlobalRTPMaxPPM,
JackpotUserRoundRTPMaxPPM: ruleConfig.JackpotUserRoundRTPMaxPPM,
JackpotUser48hRTPMaxPPM: ruleConfig.JackpotUser48hRTPMaxPPM,
SettledRoundRTPEligibility: true,
JackpotSpendThresholdCoins: ruleConfig.JackpotSpendThresholdCoins,
MaxJackpotHitsPerUserDay: ruleConfig.MaxJackpotHitsPerUserDay,
MaxSinglePayout: ruleConfig.MaxSinglePayout,
UserHourlyPayoutCap: ruleConfig.UserHourlyPayoutCap,
UserDailyPayoutCap: ruleConfig.UserDailyPayoutCap,
DeviceDailyPayoutCap: ruleConfig.DeviceDailyPayoutCap,
RoomHourlyPayoutCap: ruleConfig.RoomHourlyPayoutCap,
AnchorDailyPayoutCap: ruleConfig.AnchorDailyPayoutCap,
Tiers: tiers,
Stages: ruleConfig.Stages,
UpdatedByAdminID: ruleConfig.CreatedByAdminID,
CreatedAtMS: ruleConfig.CreatedAtMS,
UpdatedAtMS: ruleConfig.CreatedAtMS,
}
if config.StrategyVersion == "" {
config.StrategyVersion = domain.StrategyFixedV2
@ -2415,11 +2475,13 @@ func luckyCeilDiv(value, divisor int64) int64 {
func (r *Repository) getLuckyUserState(ctx context.Context, appCode string, userID int64, giftID string) (luckyUserState, error) {
var state luckyUserState
err := r.db.QueryRowContext(ctx, `
SELECT paid_draws, cumulative_wager_coins, equivalent_draws, loss_streak, pending_jackpot_tokens
SELECT paid_draws, cumulative_wager_coins, equivalent_draws, loss_streak,
pending_jackpot_tokens, pending_spend_jackpot_tokens
FROM lucky_user_states
WHERE app_code = ? AND user_id = ? AND gift_id = ?`,
appCode, userID, giftID,
).Scan(&state.PaidDraws, &state.CumulativeWagerCoins, &state.EquivalentDraws, &state.LossStreak, &state.PendingJackpotTokens)
).Scan(&state.PaidDraws, &state.CumulativeWagerCoins, &state.EquivalentDraws, &state.LossStreak,
&state.PendingJackpotTokens, &state.PendingSpendJackpotTokens)
if errors.Is(err, sql.ErrNoRows) {
return luckyUserState{}, nil
}
@ -2432,8 +2494,8 @@ func (r *Repository) getLuckyUserStateForUpdate(ctx context.Context, tx *sql.Tx,
if _, err := tx.ExecContext(ctx, `
INSERT INTO lucky_user_states (
app_code, user_id, gift_id, paid_draws, cumulative_wager_coins, equivalent_draws, loss_streak,
pending_jackpot_tokens, created_at_ms, updated_at_ms
) VALUES (?, ?, ?, 0, 0, 0, 0, 0, ?, ?)
pending_jackpot_tokens, pending_spend_jackpot_tokens, created_at_ms, updated_at_ms
) VALUES (?, ?, ?, 0, 0, 0, 0, 0, 0, ?, ?)
ON DUPLICATE KEY UPDATE updated_at_ms = updated_at_ms`,
appCode, userID, giftID, nowMS, nowMS,
); err != nil {
@ -2441,12 +2503,14 @@ func (r *Repository) getLuckyUserStateForUpdate(ctx context.Context, tx *sql.Tx,
}
var state luckyUserState
err := tx.QueryRowContext(ctx, `
SELECT paid_draws, cumulative_wager_coins, equivalent_draws, loss_streak, pending_jackpot_tokens
SELECT paid_draws, cumulative_wager_coins, equivalent_draws, loss_streak,
pending_jackpot_tokens, pending_spend_jackpot_tokens
FROM lucky_user_states
WHERE app_code = ? AND user_id = ? AND gift_id = ?
FOR UPDATE`,
appCode, userID, giftID,
).Scan(&state.PaidDraws, &state.CumulativeWagerCoins, &state.EquivalentDraws, &state.LossStreak, &state.PendingJackpotTokens)
).Scan(&state.PaidDraws, &state.CumulativeWagerCoins, &state.EquivalentDraws, &state.LossStreak,
&state.PendingJackpotTokens, &state.PendingSpendJackpotTokens)
return state, err
}
@ -2476,31 +2540,38 @@ func (r *Repository) getOpenLuckyRTPWindow(ctx context.Context, tx *sql.Tx, appC
// getOpenLuckyDynamicRTPWindow 只服务 dynamic_v3不可变 rule_version scope 已经固定真实流水阈值,
// 因此窗口是否结束只看 wager_coins。control_window_draws 继续保存 fixed_v2 的兼容观测值,不能参与 V3 滚动判断。
func (r *Repository) getOpenLuckyDynamicRTPWindow(ctx context.Context, tx *sql.Tx, appCode, scopeType, scopeID string, windowWager, legacyWindowDraws, targetPPM, nowMS int64) (luckyRTPWindow, error) {
func (r *Repository) getOpenLuckyDynamicRTPWindow(ctx context.Context, tx *sql.Tx, appCode, scopeType, scopeID string, windowWager, legacyWindowDraws, targetPPM, paidAtMS, nowMS int64) (luckyRTPWindow, error) {
if windowWager <= 0 {
return luckyRTPWindow{}, xerr.New(xerr.InvalidArgument, "dynamic lucky gift settlement window wager must be positive")
}
if paidAtMS <= 0 {
return luckyRTPWindow{}, xerr.New(xerr.InvalidArgument, "dynamic lucky gift paid_at_ms is required for settlement window")
}
window, exists, err := r.getLatestLuckyRTPWindow(ctx, tx, appCode, scopeType, scopeID, true)
if err != nil {
return luckyRTPWindow{}, err
}
if !exists {
return r.createLuckyRTPWindow(ctx, tx, appCode, scopeType, scopeID, 1, 0, legacyWindowDraws, targetPPM, nowMS)
return r.createLuckyRTPWindowAt(ctx, tx, appCode, scopeType, scopeID, 1, 0, legacyWindowDraws, targetPPM, paidAtMS, nowMS)
}
if window.Status == "open" && window.WagerCoins < windowWager {
if window.Status == "open" && window.StartedAtMS > 0 && window.WagerCoins < windowWager {
return window, nil
}
if window.Status == "open" {
// 006 gives pre-upgrade windows started_at_ms=0 because their exact first payment cannot be
// reconstructed safely. Close that legacy partial window without manufacturing user-round
// aggregates; the first post-upgrade draw starts a fully aligned platform/user round.
closedAtMS := maxInt64(window.LastPaidAtMS, paidAtMS)
if _, err := tx.ExecContext(ctx, `
UPDATE lucky_rtp_windows
SET status = 'closed', updated_at_ms = ?
SET status = 'closed', closed_at_ms = ?, updated_at_ms = ?
WHERE app_code = ? AND scope_type = ? AND scope_id = ? AND window_index = ?`,
nowMS, appCode, scopeType, scopeID, window.WindowIndex,
closedAtMS, nowMS, appCode, scopeType, scopeID, window.WindowIndex,
); err != nil {
return luckyRTPWindow{}, err
}
}
return r.createLuckyRTPWindow(ctx, tx, appCode, scopeType, scopeID, window.WindowIndex+1, window.CarryPPM, legacyWindowDraws, targetPPM, nowMS)
return r.createLuckyRTPWindowAt(ctx, tx, appCode, scopeType, scopeID, window.WindowIndex+1, window.CarryPPM, legacyWindowDraws, targetPPM, paidAtMS, nowMS)
}
func (r *Repository) getLatestLuckyRTPWindow(ctx context.Context, tx *sql.Tx, appCode, scopeType, scopeID string, forUpdate bool) (luckyRTPWindow, bool, error) {
@ -2510,7 +2581,8 @@ func (r *Repository) getLatestLuckyRTPWindow(ctx context.Context, tx *sql.Tx, ap
}
row := tx.QueryRowContext(ctx, `
SELECT scope_type, scope_id, window_index, target_rtp_ppm, control_window_draws,
paid_draws, wager_coins, target_payout_coins, actual_base_payout_coins, carry_ppm, status
paid_draws, wager_coins, target_payout_coins, actual_base_payout_coins, carry_ppm, status,
started_at_ms, last_paid_at_ms, closed_at_ms
FROM lucky_rtp_windows
WHERE app_code = ? AND scope_type = ? AND scope_id = ?
ORDER BY window_index DESC
@ -2519,7 +2591,8 @@ func (r *Repository) getLatestLuckyRTPWindow(ctx context.Context, tx *sql.Tx, ap
)
var window luckyRTPWindow
if err := row.Scan(&window.ScopeType, &window.ScopeID, &window.WindowIndex, &window.TargetRTPPPM, &window.ControlDraws,
&window.PaidDraws, &window.WagerCoins, &window.TargetPayoutCoins, &window.ActualPayoutCoins, &window.CarryPPM, &window.Status); err != nil {
&window.PaidDraws, &window.WagerCoins, &window.TargetPayoutCoins, &window.ActualPayoutCoins, &window.CarryPPM, &window.Status,
&window.StartedAtMS, &window.LastPaidAtMS, &window.ClosedAtMS); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return luckyRTPWindow{}, false, nil
}
@ -2529,30 +2602,46 @@ func (r *Repository) getLatestLuckyRTPWindow(ctx context.Context, tx *sql.Tx, ap
}
func (r *Repository) createLuckyRTPWindow(ctx context.Context, tx *sql.Tx, appCode, scopeType, scopeID string, index, carry, windowDraws, targetPPM, nowMS int64) (luckyRTPWindow, error) {
return r.createLuckyRTPWindowAt(ctx, tx, appCode, scopeType, scopeID, index, carry, windowDraws, targetPPM, nowMS, nowMS)
}
func (r *Repository) createLuckyRTPWindowAt(ctx context.Context, tx *sql.Tx, appCode, scopeType, scopeID string, index, carry, windowDraws, targetPPM, startedAtMS, nowMS int64) (luckyRTPWindow, error) {
// 同一 scope 的首次请求可能并发创建同一 window幂等 INSERT 先以唯一键串行化,
// 随后必须重新读取实际行,不能在 duplicate 时返回一个全 0 的本地假快照覆盖已提交进度。
if _, err := tx.ExecContext(ctx, `
INSERT INTO lucky_rtp_windows (
app_code, scope_type, scope_id, window_index, target_rtp_ppm, control_window_draws,
paid_draws, wager_coins, target_payout_coins, actual_base_payout_coins, carry_ppm, status, created_at_ms, updated_at_ms
) VALUES (?, ?, ?, ?, ?, ?, 0, 0, 0, 0, ?, 'open', ?, ?)
paid_draws, wager_coins, target_payout_coins, actual_base_payout_coins, carry_ppm, status,
started_at_ms, last_paid_at_ms, closed_at_ms, created_at_ms, updated_at_ms
) VALUES (?, ?, ?, ?, ?, ?, 0, 0, 0, 0, ?, 'open', ?, 0, 0, ?, ?)
ON DUPLICATE KEY UPDATE updated_at_ms = updated_at_ms`,
appCode, scopeType, scopeID, index, targetPPM, windowDraws, carry, nowMS, nowMS,
appCode, scopeType, scopeID, index, targetPPM, windowDraws, carry, startedAtMS, nowMS, nowMS,
); err != nil {
return luckyRTPWindow{}, err
}
var window luckyRTPWindow
err := tx.QueryRowContext(ctx, `
SELECT scope_type, scope_id, window_index, target_rtp_ppm, control_window_draws,
paid_draws, wager_coins, target_payout_coins, actual_base_payout_coins, carry_ppm, status
paid_draws, wager_coins, target_payout_coins, actual_base_payout_coins, carry_ppm, status,
started_at_ms, last_paid_at_ms, closed_at_ms
FROM lucky_rtp_windows
WHERE app_code = ? AND scope_type = ? AND scope_id = ? AND window_index = ?
FOR UPDATE`, appCode, scopeType, scopeID, index,
).Scan(&window.ScopeType, &window.ScopeID, &window.WindowIndex, &window.TargetRTPPPM, &window.ControlDraws,
&window.PaidDraws, &window.WagerCoins, &window.TargetPayoutCoins, &window.ActualPayoutCoins, &window.CarryPPM, &window.Status)
&window.PaidDraws, &window.WagerCoins, &window.TargetPayoutCoins, &window.ActualPayoutCoins, &window.CarryPPM, &window.Status,
&window.StartedAtMS, &window.LastPaidAtMS, &window.ClosedAtMS)
return window, err
}
func luckyExclusiveEndMS(value int64) int64 {
// RTP storage keeps the owner's actual settlement millisecond for maturity/audit. Range queries are
// [start,end), so only the query boundary is advanced by one millisecond to include that paid fact.
if value >= int64(^uint64(0)>>1) {
return value
}
return value + 1
}
func (r *Repository) getOrCreateLuckyPool(ctx context.Context, tx *sql.Tx, appCode, scopeType, scopeID string, initialBalance, reserveFloor, nowMS int64) (luckyPool, error) {
// 初始注资只能由唯一键胜出的事务写一次;第二个首次请求等待后读取真实余额,不能重复 seed。
if _, err := tx.ExecContext(ctx, `

View File

@ -72,9 +72,25 @@ func (r *Repository) publishLuckyGiftRuleConfigTx(ctx context.Context, tx *sql.T
// 仓储边界再次守住零冷启动,即使内部调用绕过 service 校验,也不能发布一个会暗示自动注资的 V3 快照。
return domain.RuleConfig{}, xerr.New(xerr.InvalidArgument, "dynamic_v3 initial pool coins must be zero")
}
if config.User24HourRTPThresholdPPM == 0 && config.User24HourOrdinaryWinFactorPPM == 0 {
// 旧 proto 客户端不会发送新增标量;仓储兜底成 0/100% 禁用态,确保绕过 service 的内部发布也不改变历史概率。
config.User24HourOrdinaryWinFactorPPM = 1_000_000
}
if config.JackpotMultiplierPPMs == nil {
config.JackpotMultiplierPPMs = []int64{}
}
// 旧的进程内调用方可能仍填写 day/72h 别名;业务口径只认轮次/48h
// 兼容赋值必须在生成不可变版本前完成,随后再把同一值双写到旧影子列供滚动升级和回滚读取。
if config.JackpotUserRoundRTPMaxPPM == 0 {
config.JackpotUserRoundRTPMaxPPM = config.JackpotUserDayRTPMaxPPM
}
if config.JackpotUser48hRTPMaxPPM == 0 {
config.JackpotUser48hRTPMaxPPM = config.JackpotUser72hRTPMaxPPM
}
// 生产双实例需要允许新旧二进制短时共存和安全回滚。兼容窗口内新代码同时写入
// day/round 与 72h/48h 两组列;旧代码只读取影子列,新代码只把新语义暴露到领域层。
config.JackpotUserDayRTPMaxPPM = config.JackpotUserRoundRTPMaxPPM
config.JackpotUser72hRTPMaxPPM = config.JackpotUser48hRTPMaxPPM
jackpotMultiplierPPMsJSON, err := json.Marshal(config.JackpotMultiplierPPMs)
if err != nil {
return domain.RuleConfig{}, fmt.Errorf("marshal lucky gift jackpot multipliers: %w", err)
@ -110,12 +126,15 @@ func (r *Repository) publishLuckyGiftRuleConfigTx(ctx context.Context, tx *sql.T
initial_pool_coins, loss_streak_guarantee,
low_watermark_coins, low_water_nonzero_factor_ppm,
high_watermark_coins, high_water_nonzero_factor_ppm,
recharge_boost_window_ms, recharge_boost_factor_ppm, jackpot_multiplier_ppms,
jackpot_global_rtp_max_ppm, jackpot_user_day_rtp_max_ppm, jackpot_user_72h_rtp_max_ppm,
recharge_boost_window_ms, recharge_boost_factor_ppm,
user_24h_rtp_threshold_ppm, user_24h_ordinary_win_factor_ppm, jackpot_multiplier_ppms,
jackpot_global_rtp_max_ppm,
jackpot_user_day_rtp_max_ppm, jackpot_user_round_rtp_max_ppm,
jackpot_user_72h_rtp_max_ppm, jackpot_user_48h_rtp_max_ppm,
jackpot_spend_threshold_coins, max_jackpot_hits_per_user_day,
max_single_payout, user_hourly_payout_cap, user_daily_payout_cap,
device_daily_payout_cap, room_hourly_payout_cap, anchor_daily_payout_cap
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
config.AppCode, config.PoolID, config.RuleVersion, config.Enabled, config.StrategyVersion, config.TargetRTPPPM, config.PoolRatePPM,
config.ProfitRatePPM, config.AnchorRatePPM,
config.SettlementWindowWager, config.ControlBandPPM, config.GiftPriceReference,
@ -124,8 +143,11 @@ func (r *Repository) publishLuckyGiftRuleConfigTx(ctx context.Context, tx *sql.T
config.InitialPoolCoins, config.LossStreakGuarantee,
config.LowWatermarkCoins, config.LowWaterNonzeroFactorPPM,
config.HighWatermarkCoins, config.HighWaterNonzeroFactorPPM,
config.RechargeBoostWindowMS, config.RechargeBoostFactorPPM, jackpotMultiplierPPMsJSON,
config.JackpotGlobalRTPMaxPPM, config.JackpotUserDayRTPMaxPPM, config.JackpotUser72hRTPMaxPPM,
config.RechargeBoostWindowMS, config.RechargeBoostFactorPPM,
config.User24HourRTPThresholdPPM, config.User24HourOrdinaryWinFactorPPM, jackpotMultiplierPPMsJSON,
config.JackpotGlobalRTPMaxPPM,
config.JackpotUserDayRTPMaxPPM, config.JackpotUserRoundRTPMaxPPM,
config.JackpotUser72hRTPMaxPPM, config.JackpotUser48hRTPMaxPPM,
config.JackpotSpendThresholdCoins, config.MaxJackpotHitsPerUserDay,
config.MaxSinglePayout, config.UserHourlyPayoutCap, config.UserDailyPayoutCap,
config.DeviceDailyPayoutCap, config.RoomHourlyPayoutCap, config.AnchorDailyPayoutCap,
@ -186,8 +208,13 @@ func (r *Repository) ListLuckyGiftRuleConfigs(ctx context.Context, appCode strin
v.initial_pool_coins, v.loss_streak_guarantee,
v.low_watermark_coins, v.low_water_nonzero_factor_ppm,
v.high_watermark_coins, v.high_water_nonzero_factor_ppm,
v.recharge_boost_window_ms, v.recharge_boost_factor_ppm, v.jackpot_multiplier_ppms,
v.jackpot_global_rtp_max_ppm, v.jackpot_user_day_rtp_max_ppm, v.jackpot_user_72h_rtp_max_ppm,
v.recharge_boost_window_ms, v.recharge_boost_factor_ppm,
v.user_24h_rtp_threshold_ppm, v.user_24h_ordinary_win_factor_ppm, v.jackpot_multiplier_ppms,
v.jackpot_global_rtp_max_ppm,
CASE WHEN v.jackpot_user_round_rtp_max_ppm = 0 AND v.jackpot_user_day_rtp_max_ppm <> 0
THEN v.jackpot_user_day_rtp_max_ppm ELSE v.jackpot_user_round_rtp_max_ppm END,
CASE WHEN v.jackpot_user_48h_rtp_max_ppm = 0 AND v.jackpot_user_72h_rtp_max_ppm <> 0
THEN v.jackpot_user_72h_rtp_max_ppm ELSE v.jackpot_user_48h_rtp_max_ppm END,
v.jackpot_spend_threshold_coins, v.max_jackpot_hits_per_user_day,
v.max_single_payout, v.user_hourly_payout_cap, v.user_daily_payout_cap,
v.device_daily_payout_cap, v.room_hourly_payout_cap, v.anchor_daily_payout_cap
@ -230,8 +257,13 @@ const luckyRuleConfigSelectSQL = `
initial_pool_coins, loss_streak_guarantee,
low_watermark_coins, low_water_nonzero_factor_ppm,
high_watermark_coins, high_water_nonzero_factor_ppm,
recharge_boost_window_ms, recharge_boost_factor_ppm, jackpot_multiplier_ppms,
jackpot_global_rtp_max_ppm, jackpot_user_day_rtp_max_ppm, jackpot_user_72h_rtp_max_ppm,
recharge_boost_window_ms, recharge_boost_factor_ppm,
user_24h_rtp_threshold_ppm, user_24h_ordinary_win_factor_ppm, jackpot_multiplier_ppms,
jackpot_global_rtp_max_ppm,
CASE WHEN jackpot_user_round_rtp_max_ppm = 0 AND jackpot_user_day_rtp_max_ppm <> 0
THEN jackpot_user_day_rtp_max_ppm ELSE jackpot_user_round_rtp_max_ppm END,
CASE WHEN jackpot_user_48h_rtp_max_ppm = 0 AND jackpot_user_72h_rtp_max_ppm <> 0
THEN jackpot_user_72h_rtp_max_ppm ELSE jackpot_user_48h_rtp_max_ppm END,
jackpot_spend_threshold_coins, max_jackpot_hits_per_user_day,
max_single_payout, user_hourly_payout_cap, user_daily_payout_cap,
device_daily_payout_cap, room_hourly_payout_cap, anchor_daily_payout_cap
@ -363,10 +395,12 @@ func scanLuckyGiftRuleConfig(scanner luckyScanner) (domain.RuleConfig, error) {
&config.HighWaterNonzeroFactorPPM,
&config.RechargeBoostWindowMS,
&config.RechargeBoostFactorPPM,
&config.User24HourRTPThresholdPPM,
&config.User24HourOrdinaryWinFactorPPM,
&jackpotMultiplierPPMsJSON,
&config.JackpotGlobalRTPMaxPPM,
&config.JackpotUserDayRTPMaxPPM,
&config.JackpotUser72hRTPMaxPPM,
&config.JackpotUserRoundRTPMaxPPM,
&config.JackpotUser48hRTPMaxPPM,
&config.JackpotSpendThresholdCoins,
&config.MaxJackpotHitsPerUserDay,
&config.MaxSinglePayout,
@ -390,5 +424,9 @@ func scanLuckyGiftRuleConfig(scanner luckyScanner) (domain.RuleConfig, error) {
if config.JackpotMultiplierPPMs == nil {
config.JackpotMultiplierPPMs = []int64{}
}
// 数据库兼容期保留旧影子列,但查询已收敛到轮次/48h 两个业务口径;旧别名仅供同进程源码兼容。
config.JackpotUserDayRTPMaxPPM = config.JackpotUserRoundRTPMaxPPM
config.JackpotUser72hRTPMaxPPM = config.JackpotUser48hRTPMaxPPM
config.SettledRoundRTPEligibility = true
return config, nil
}

View File

@ -153,6 +153,14 @@ func TestDynamicStrategyMigrationIsIdempotent(t *testing.T) {
// initdb 已包含最终结构004 连续运行两次仍必须成功,覆盖全新环境与线上重复发布 migration 两条路径。
runLuckyGiftSQLScript(t, schema.DB, migrationPath)
runLuckyGiftSQLScript(t, schema.DB, migrationPath)
// 007 must also work on a pre-dual-strategy schema and remain replay-safe. Dropping only the new
// constant-default column reproduces that shape without scanning or rewriting any user row.
if _, err := schema.DB.Exec(`ALTER TABLE lucky_user_states DROP COLUMN pending_spend_jackpot_tokens, ALGORITHM=INSTANT`); err != nil {
t.Fatalf("prepare pre-007 schema: %v", err)
}
spendTokenMigrationPath := mysqlschema.InitDBPath(t, caller, "..", "..", "..", "deploy", "mysql", "migrations", "007_dual_jackpot_spend_tokens.sql")
runLuckyGiftSQLScript(t, schema.DB, spendTokenMigrationPath)
runLuckyGiftSQLScript(t, schema.DB, spendTokenMigrationPath)
for _, table := range []string{
"lucky_risk_counters", "lucky_user_rtp_hour_buckets", "lucky_user_rtp_boundary_events",
@ -171,6 +179,7 @@ func TestDynamicStrategyMigrationIsIdempotent(t *testing.T) {
minLength int64
}{
{table: "lucky_user_states", name: "pending_jackpot_tokens"},
{table: "lucky_user_states", name: "pending_spend_jackpot_tokens"},
{table: "lucky_rtp_windows", name: "scope_id", minLength: 128},
{table: "lucky_risk_counters", name: "scope_id", minLength: 255},
{table: "lucky_gift_command_locks", name: "command_id", minLength: 128},

View File

@ -87,17 +87,18 @@ func (s *LuckyGiftServer) BatchExecuteLuckyGiftDraw(ctx context.Context, req *lu
func (s *LuckyGiftServer) ExecuteExternalGiftDraw(ctx context.Context, req *luckygiftv1.ExternalGiftDrawRequest) (*luckygiftv1.ExternalGiftDrawResponse, error) {
ctx = appcode.WithContext(ctx, req.GetAppCode())
result, err := s.svc.ExecuteExternalDraw(ctx, domain.ExternalDrawCommand{
AppCode: req.GetAppCode(),
ExternalUserID: req.GetExternalUserId(),
DeviceID: req.GetDeviceId(),
RequestID: req.GetRequestId(),
GiftCount: req.GetGiftCount(),
UnitAmount: req.GetUnitAmount(),
TotalAmount: req.GetTotalAmount(),
Currency: req.GetCurrency(),
PaidAtMS: req.GetPaidAtMs(),
MetadataJSON: req.GetMetadataJson(),
PoolID: req.GetPoolId(),
AppCode: req.GetAppCode(),
ExternalUserID: req.GetExternalUserId(),
DeviceID: req.GetDeviceId(),
RequestID: req.GetRequestId(),
GiftCount: req.GetGiftCount(),
UnitAmount: req.GetUnitAmount(),
TotalAmount: req.GetTotalAmount(),
Currency: req.GetCurrency(),
PaidAtMS: req.GetPaidAtMs(),
MetadataJSON: req.GetMetadataJson(),
PoolID: req.GetPoolId(),
UserRegisteredAtMS: req.GetUserRegisteredAtMs(),
})
if err != nil {
return nil, xerr.ToGRPCError(err)
@ -118,6 +119,7 @@ func luckyDrawCommandFromProto(meta *luckygiftv1.LuckyGiftMeta) domain.DrawComma
GiftCount: meta.GetGiftCount(),
CoinSpent: meta.GetCoinSpent(),
PaidAtMS: meta.GetPaidAtMs(),
UserRegisteredAtMS: meta.GetUserRegisteredAtMs(),
VisibleRegionID: meta.GetVisibleRegionId(),
CountryID: meta.GetCountryId(),
SenderName: meta.GetSenderName(),
@ -222,6 +224,93 @@ func (s *AdminLuckyGiftServer) GetLuckyGiftDrawSummary(ctx context.Context, req
return &luckygiftv1.GetLuckyGiftDrawSummaryResponse{Summary: luckyDrawSummaryToProto(summary)}, nil
}
func (s *AdminLuckyGiftServer) ListLuckyGiftUserProfiles(ctx context.Context, req *luckygiftv1.ListLuckyGiftUserProfilesRequest) (*luckygiftv1.ListLuckyGiftUserProfilesResponse, error) {
appCode := strings.TrimSpace(req.GetMeta().GetAppCode())
ctx = appcode.WithContext(ctx, appCode)
profiles, total, nextCursor, hasMore, snapshotAtMS, err := s.svc.ListUserProfiles(ctx, domain.UserProfileQuery{
AppCode: appCode,
PoolID: req.GetPoolId(),
IdentityType: req.GetIdentityType(),
InternalUserID: req.GetInternalUserId(),
ExternalUserID: req.GetExternalUserId(),
Stage: req.GetStage(),
Status: req.GetStatus(),
SortBy: req.GetSortBy(),
SortDirection: req.GetSortDirection(),
Cursor: req.GetCursor(),
Page: req.GetPage(),
PageSize: req.GetPageSize(),
})
if err != nil {
return nil, xerr.ToGRPCError(err)
}
resp := &luckygiftv1.ListLuckyGiftUserProfilesResponse{
Profiles: make([]*luckygiftv1.LuckyGiftUserProfile, 0, len(profiles)),
Total: total,
SnapshotAtMs: snapshotAtMS,
NextCursor: nextCursor,
HasMore: hasMore,
}
for _, profile := range profiles {
resp.Profiles = append(resp.Profiles, luckyUserProfileToProto(profile))
}
return resp, nil
}
func (s *AdminLuckyGiftServer) GetLuckyGiftUserProfile(ctx context.Context, req *luckygiftv1.GetLuckyGiftUserProfileRequest) (*luckygiftv1.GetLuckyGiftUserProfileResponse, error) {
appCode := strings.TrimSpace(req.GetMeta().GetAppCode())
ctx = appcode.WithContext(ctx, appCode)
detail, snapshotAtMS, err := s.svc.GetUserProfile(ctx, domain.UserProfileQuery{
AppCode: appCode,
PoolID: req.GetPoolId(),
IdentityType: req.GetIdentityType(),
InternalUserID: req.GetInternalUserId(),
ExternalUserID: req.GetExternalUserId(),
JackpotPage: req.GetJackpotPage(),
JackpotPageSize: req.GetJackpotPageSize(),
JackpotCursor: req.GetJackpotCursor(),
})
if err != nil {
return nil, xerr.ToGRPCError(err)
}
resp := &luckygiftv1.GetLuckyGiftUserProfileResponse{
Profile: luckyUserProfileToProto(detail.Profile),
MultiplierDistribution: make([]*luckygiftv1.LuckyGiftUserMultiplierStat, 0, len(detail.MultiplierDistribution)),
JackpotHits: make([]*luckygiftv1.LuckyGiftUserJackpotHit, 0, len(detail.JackpotHits)),
JackpotHitTotal: detail.JackpotHitTotal,
SnapshotAtMs: snapshotAtMS,
JackpotPage: detail.JackpotPage,
JackpotPageSize: detail.JackpotPageSize,
NextJackpotCursor: detail.NextJackpotCursor,
JackpotHasMore: detail.JackpotHasMore,
}
for _, stat := range detail.MultiplierDistribution {
resp.MultiplierDistribution = append(resp.MultiplierDistribution, &luckygiftv1.LuckyGiftUserMultiplierStat{
HitType: stat.HitType, MultiplierPpm: stat.MultiplierPPM, DrawCount: stat.DrawCount,
WagerCoins: stat.WagerCoins, PayoutCoins: stat.PayoutCoins,
})
}
for _, hit := range detail.JackpotHits {
item := &luckygiftv1.LuckyGiftUserJackpotHit{
DrawId: hit.DrawID, RequestId: hit.RequestID, RuleVersion: hit.RuleVersion, Stage: hit.Stage,
EventKey: hit.SourceType + ":" + hit.DrawID,
Mechanism: hit.Mechanism, ReasonCode: hit.ReasonCode, ReasonSummary: hit.ReasonSummary,
TierId: hit.TierID, MultiplierPpm: hit.MultiplierPPM, WagerCoins: hit.WagerCoins,
PayoutCoins: hit.PayoutCoins, OccurredAtMs: hit.OccurredAtMS,
Conditions: make([]*luckygiftv1.LuckyGiftUserJackpotCondition, 0, len(hit.Conditions)),
}
for _, condition := range hit.Conditions {
item.Conditions = append(item.Conditions, &luckygiftv1.LuckyGiftUserJackpotCondition{
Name: condition.Name, Numerator: condition.Numerator, Denominator: condition.Denominator,
RatioPpm: condition.RatioPPM, LimitPpm: condition.LimitPPM, FactorPpm: condition.FactorPPM,
Passed: condition.Passed, Reason: condition.Reason,
})
}
resp.JackpotHits = append(resp.JackpotHits, item)
}
return resp, nil
}
func (s *AdminLuckyGiftServer) ListLuckyGiftPoolBalances(ctx context.Context, req *luckygiftv1.ListLuckyGiftPoolBalancesRequest) (*luckygiftv1.ListLuckyGiftPoolBalancesResponse, error) {
appCode := req.GetMeta().GetAppCode()
if appCode != "" {
@ -443,48 +532,58 @@ func luckyRuleConfigFromProto(config *luckygiftv1.LuckyGiftRuleConfig) domain.Ru
})
}
return domain.RuleConfig{
AppCode: config.GetAppCode(),
PoolID: config.GetPoolId(),
RuleVersion: config.GetRuleVersion(),
Enabled: config.GetEnabled(),
TargetRTPPPM: config.GetTargetRtpPpm(),
PoolRatePPM: config.GetPoolRatePpm(),
SettlementWindowWager: config.GetSettlementWindowWager(),
ControlBandPPM: config.GetControlBandPpm(),
GiftPriceReference: config.GetGiftPriceReference(),
NoviceMaxEquivalentDraws: config.GetNoviceMaxEquivalentDraws(),
NormalMaxEquivalentDraws: config.GetNormalMaxEquivalentDraws(),
EffectiveFromMS: config.GetEffectiveFromMs(),
CreatedByAdminID: config.GetCreatedByAdminId(),
CreatedAtMS: config.GetCreatedAtMs(),
StrategyVersion: config.GetStrategyVersion(),
ProfitRatePPM: config.GetProfitRatePpm(),
AnchorRatePPM: config.GetAnchorRatePpm(),
InitialPoolCoins: config.GetInitialPoolCoins(),
LossStreakGuarantee: config.GetLossStreakGuarantee(),
LowWatermarkCoins: config.GetLowWatermarkCoins(),
LowWaterNonzeroFactorPPM: config.GetLowWaterNonzeroFactorPpm(),
HighWatermarkCoins: config.GetHighWatermarkCoins(),
HighWaterNonzeroFactorPPM: config.GetHighWaterNonzeroFactorPpm(),
RechargeBoostWindowMS: config.GetRechargeBoostWindowMs(),
RechargeBoostFactorPPM: config.GetRechargeBoostFactorPpm(),
JackpotMultiplierPPMs: append([]int64(nil), config.GetJackpotMultiplierPpms()...),
JackpotGlobalRTPMaxPPM: config.GetJackpotGlobalRtpMaxPpm(),
JackpotUserDayRTPMaxPPM: config.GetJackpotUserDayRtpMaxPpm(),
JackpotUser72hRTPMaxPPM: config.GetJackpotUser_72HRtpMaxPpm(),
JackpotSpendThresholdCoins: config.GetJackpotSpendThresholdCoins(),
MaxJackpotHitsPerUserDay: config.GetMaxJackpotHitsPerUserDay(),
MaxSinglePayout: config.GetMaxSinglePayout(),
UserHourlyPayoutCap: config.GetUserHourlyPayoutCap(),
UserDailyPayoutCap: config.GetUserDailyPayoutCap(),
DeviceDailyPayoutCap: config.GetDeviceDailyPayoutCap(),
RoomHourlyPayoutCap: config.GetRoomHourlyPayoutCap(),
AnchorDailyPayoutCap: config.GetAnchorDailyPayoutCap(),
Stages: stages,
AppCode: config.GetAppCode(),
PoolID: config.GetPoolId(),
RuleVersion: config.GetRuleVersion(),
Enabled: config.GetEnabled(),
TargetRTPPPM: config.GetTargetRtpPpm(),
PoolRatePPM: config.GetPoolRatePpm(),
SettlementWindowWager: config.GetSettlementWindowWager(),
ControlBandPPM: config.GetControlBandPpm(),
GiftPriceReference: config.GetGiftPriceReference(),
NoviceMaxEquivalentDraws: config.GetNoviceMaxEquivalentDraws(),
NormalMaxEquivalentDraws: config.GetNormalMaxEquivalentDraws(),
EffectiveFromMS: config.GetEffectiveFromMs(),
CreatedByAdminID: config.GetCreatedByAdminId(),
CreatedAtMS: config.GetCreatedAtMs(),
StrategyVersion: config.GetStrategyVersion(),
ProfitRatePPM: config.GetProfitRatePpm(),
AnchorRatePPM: config.GetAnchorRatePpm(),
InitialPoolCoins: config.GetInitialPoolCoins(),
LossStreakGuarantee: config.GetLossStreakGuarantee(),
LowWatermarkCoins: config.GetLowWatermarkCoins(),
LowWaterNonzeroFactorPPM: config.GetLowWaterNonzeroFactorPpm(),
HighWatermarkCoins: config.GetHighWatermarkCoins(),
HighWaterNonzeroFactorPPM: config.GetHighWaterNonzeroFactorPpm(),
RechargeBoostWindowMS: config.GetRechargeBoostWindowMs(),
RechargeBoostFactorPPM: config.GetRechargeBoostFactorPpm(),
User24HourRTPThresholdPPM: config.GetUser_24HRtpThresholdPpm(),
User24HourOrdinaryWinFactorPPM: config.GetUser_24HOrdinaryWinFactorPpm(),
JackpotMultiplierPPMs: append([]int64(nil), config.GetJackpotMultiplierPpms()...),
JackpotGlobalRTPMaxPPM: config.GetJackpotGlobalRtpMaxPpm(),
JackpotUserRoundRTPMaxPPM: config.GetJackpotUserRoundRtpMaxPpm(),
JackpotUser48hRTPMaxPPM: config.GetJackpotUser_48HRtpMaxPpm(),
JackpotSpendThresholdCoins: config.GetJackpotSpendThresholdCoins(),
MaxJackpotHitsPerUserDay: config.GetMaxJackpotHitsPerUserDay(),
MaxSinglePayout: config.GetMaxSinglePayout(),
UserHourlyPayoutCap: config.GetUserHourlyPayoutCap(),
UserDailyPayoutCap: config.GetUserDailyPayoutCap(),
DeviceDailyPayoutCap: config.GetDeviceDailyPayoutCap(),
RoomHourlyPayoutCap: config.GetRoomHourlyPayoutCap(),
AnchorDailyPayoutCap: config.GetAnchorDailyPayoutCap(),
Stages: stages,
}
}
func luckyRuleConfigToProto(config domain.RuleConfig) *luckygiftv1.LuckyGiftRuleConfig {
roundRTPMaxPPM := config.JackpotUserRoundRTPMaxPPM
if roundRTPMaxPPM == 0 {
roundRTPMaxPPM = config.JackpotUserDayRTPMaxPPM
}
rolling48hRTPMaxPPM := config.JackpotUser48hRTPMaxPPM
if rolling48hRTPMaxPPM == 0 {
rolling48hRTPMaxPPM = config.JackpotUser72hRTPMaxPPM
}
stages := make([]*luckygiftv1.LuckyGiftRuleStage, 0, len(config.Stages))
for _, stage := range config.Stages {
tiers := make([]*luckygiftv1.LuckyGiftRuleTier, 0, len(stage.Tiers))
@ -506,44 +605,46 @@ func luckyRuleConfigToProto(config domain.RuleConfig) *luckygiftv1.LuckyGiftRule
})
}
return &luckygiftv1.LuckyGiftRuleConfig{
AppCode: config.AppCode,
PoolId: config.PoolID,
RuleVersion: config.RuleVersion,
Enabled: config.Enabled,
TargetRtpPpm: config.TargetRTPPPM,
PoolRatePpm: config.PoolRatePPM,
SettlementWindowWager: config.SettlementWindowWager,
ControlBandPpm: config.ControlBandPPM,
GiftPriceReference: config.GiftPriceReference,
NoviceMaxEquivalentDraws: config.NoviceMaxEquivalentDraws,
NormalMaxEquivalentDraws: config.NormalMaxEquivalentDraws,
EffectiveFromMs: config.EffectiveFromMS,
CreatedByAdminId: config.CreatedByAdminID,
CreatedAtMs: config.CreatedAtMS,
StrategyVersion: config.StrategyVersion,
ProfitRatePpm: config.ProfitRatePPM,
AnchorRatePpm: config.AnchorRatePPM,
InitialPoolCoins: config.InitialPoolCoins,
LossStreakGuarantee: config.LossStreakGuarantee,
LowWatermarkCoins: config.LowWatermarkCoins,
LowWaterNonzeroFactorPpm: config.LowWaterNonzeroFactorPPM,
HighWatermarkCoins: config.HighWatermarkCoins,
HighWaterNonzeroFactorPpm: config.HighWaterNonzeroFactorPPM,
RechargeBoostWindowMs: config.RechargeBoostWindowMS,
RechargeBoostFactorPpm: config.RechargeBoostFactorPPM,
JackpotMultiplierPpms: append([]int64(nil), config.JackpotMultiplierPPMs...),
JackpotGlobalRtpMaxPpm: config.JackpotGlobalRTPMaxPPM,
JackpotUserDayRtpMaxPpm: config.JackpotUserDayRTPMaxPPM,
JackpotUser_72HRtpMaxPpm: config.JackpotUser72hRTPMaxPPM,
JackpotSpendThresholdCoins: config.JackpotSpendThresholdCoins,
MaxJackpotHitsPerUserDay: config.MaxJackpotHitsPerUserDay,
MaxSinglePayout: config.MaxSinglePayout,
UserHourlyPayoutCap: config.UserHourlyPayoutCap,
UserDailyPayoutCap: config.UserDailyPayoutCap,
DeviceDailyPayoutCap: config.DeviceDailyPayoutCap,
RoomHourlyPayoutCap: config.RoomHourlyPayoutCap,
AnchorDailyPayoutCap: config.AnchorDailyPayoutCap,
Stages: stages,
AppCode: config.AppCode,
PoolId: config.PoolID,
RuleVersion: config.RuleVersion,
Enabled: config.Enabled,
TargetRtpPpm: config.TargetRTPPPM,
PoolRatePpm: config.PoolRatePPM,
SettlementWindowWager: config.SettlementWindowWager,
ControlBandPpm: config.ControlBandPPM,
GiftPriceReference: config.GiftPriceReference,
NoviceMaxEquivalentDraws: config.NoviceMaxEquivalentDraws,
NormalMaxEquivalentDraws: config.NormalMaxEquivalentDraws,
EffectiveFromMs: config.EffectiveFromMS,
CreatedByAdminId: config.CreatedByAdminID,
CreatedAtMs: config.CreatedAtMS,
StrategyVersion: config.StrategyVersion,
ProfitRatePpm: config.ProfitRatePPM,
AnchorRatePpm: config.AnchorRatePPM,
InitialPoolCoins: config.InitialPoolCoins,
LossStreakGuarantee: config.LossStreakGuarantee,
LowWatermarkCoins: config.LowWatermarkCoins,
LowWaterNonzeroFactorPpm: config.LowWaterNonzeroFactorPPM,
HighWatermarkCoins: config.HighWatermarkCoins,
HighWaterNonzeroFactorPpm: config.HighWaterNonzeroFactorPPM,
RechargeBoostWindowMs: config.RechargeBoostWindowMS,
RechargeBoostFactorPpm: config.RechargeBoostFactorPPM,
User_24HRtpThresholdPpm: config.User24HourRTPThresholdPPM,
User_24HOrdinaryWinFactorPpm: config.User24HourOrdinaryWinFactorPPM,
JackpotMultiplierPpms: append([]int64(nil), config.JackpotMultiplierPPMs...),
JackpotGlobalRtpMaxPpm: config.JackpotGlobalRTPMaxPPM,
JackpotUserRoundRtpMaxPpm: roundRTPMaxPPM,
JackpotUser_48HRtpMaxPpm: rolling48hRTPMaxPPM,
JackpotSpendThresholdCoins: config.JackpotSpendThresholdCoins,
MaxJackpotHitsPerUserDay: config.MaxJackpotHitsPerUserDay,
MaxSinglePayout: config.MaxSinglePayout,
UserHourlyPayoutCap: config.UserHourlyPayoutCap,
UserDailyPayoutCap: config.UserDailyPayoutCap,
DeviceDailyPayoutCap: config.DeviceDailyPayoutCap,
RoomHourlyPayoutCap: config.RoomHourlyPayoutCap,
AnchorDailyPayoutCap: config.AnchorDailyPayoutCap,
Stages: stages,
}
}
@ -632,6 +733,41 @@ func luckyPoolBalanceToProto(pool domain.PoolBalance) *luckygiftv1.LuckyGiftPool
}
}
func luckyUserProfileWindowToProto(window domain.UserProfileWindow) *luckygiftv1.LuckyGiftUserProfileWindow {
return &luckygiftv1.LuckyGiftUserProfileWindow{
Draws: window.Draws, WagerCoins: window.WagerCoins, PayoutCoins: window.PayoutCoins,
NetCoins: window.NetCoins, RtpPpm: window.RTPPPM, HasRtp: window.HasRTP,
}
}
func luckyUserProfileToProto(profile domain.UserProfile) *luckygiftv1.LuckyGiftUserProfile {
// StrategyUserID 可能是 external_user_id 的稳定哈希,只允许仓储层关联 lucky_user_states
// transport 显式逐字段映射且没有该字段,避免未来通过反射或 JSON 误下发。
return &luckygiftv1.LuckyGiftUserProfile{
AppCode: profile.AppCode, PoolId: profile.PoolID, IdentityType: profile.IdentityType,
InternalUserId: profile.InternalUserID, ExternalUserId: profile.ExternalUserID,
RuleVersion: profile.RuleVersion, StrategyVersion: profile.StrategyVersion, Stage: profile.Stage,
PaidDraws: profile.PaidDraws, EquivalentDraws: profile.EquivalentDraws, LossStreak: profile.LossStreak,
PendingSpendJackpotTokens: profile.PendingSpendJackpotTokens,
GuaranteeDrawsRemaining: profile.GuaranteeDrawsRemaining,
DownweightActive: profile.DownweightActive, DownweightFactorPpm: profile.DownweightFactorPPM,
User_24HRtpThresholdPpm: profile.User24HourRTPThresholdPPM,
OneHour: luckyUserProfileWindowToProto(profile.OneHour),
TwentyFourHours: luckyUserProfileWindowToProto(profile.TwentyFourHour),
FortyEightHours: luckyUserProfileWindowToProto(profile.FortyEightHour),
Lifetime: luckyUserProfileWindowToProto(profile.Lifetime),
BaseRewardCoins: profile.BaseRewardCoins, OrdinaryWinCount: profile.OrdinaryWinCount,
HighMultiplierOrdinaryWinCount: profile.HighMultiplierOrdinaryWinCount,
RtpCompensationJackpotCount: profile.RTPCompensationJackpotCount,
CumulativeSpendJackpotCount: profile.CumulativeSpendJackpotCount,
GuaranteeHitCount: profile.GuaranteeHitCount, ZeroDrawCount: profile.ZeroDrawCount,
GrantedDrawCount: profile.GrantedDrawCount, PendingDrawCount: profile.PendingDrawCount,
FailedDrawCount: profile.FailedDrawCount, MaxMultiplierPpm: profile.MaxMultiplierPPM,
MaxRewardCoins: profile.MaxRewardCoins, FirstDrawAtMs: profile.FirstDrawAtMS,
LastDrawAtMs: profile.LastDrawAtMS, AggregatedAtMs: profile.AggregatedAtMS, DataLagMs: profile.DataLagMS,
}
}
func luckyPoolAdjustmentToProto(adjustment domain.PoolAdjustment) *luckygiftv1.LuckyGiftPoolAdjustment {
return &luckygiftv1.LuckyGiftPoolAdjustment{
AdjustmentId: adjustment.AdjustmentID,

View File

@ -461,6 +461,9 @@ type SendGift struct {
SenderRegionID int64 `json:"sender_region_id,omitempty"`
// SenderCountryID 是送礼用户所属国家,由 gateway 从 user-service 注入,统计服务不能用房间区域反推。
SenderCountryID int64 `json:"sender_country_id,omitempty"`
// SenderRegisteredAtMS 是 gateway 从 user-service 固化的账号创建时间。
// room 恢复首次送礼 saga 时必须继续使用该值;旧命令缺失时保留 0让 lucky-gift 对补偿大奖资格 fail-close。
SenderRegisteredAtMS int64 `json:"sender_registered_at_ms,omitempty"`
// TargetIsHost 是 gateway 从 user-service active host profile 注入的工资入账开关。
TargetIsHost bool `json:"target_is_host,omitempty"`
// TargetHostRegionID 是工资政策匹配区域room-service 不自行推导主播身份或区域。
@ -684,6 +687,9 @@ func IdempotencyPayload(payload []byte) ([]byte, error) {
// not conflict merely because the user changed profile between timeout and replay.
delete(values, "sender_region_id")
delete(values, "sender_country_id")
// 注册时间与国家/区域同属 gateway 从 user owner 解析的可信快照。首次请求会完整持久化,
// 但它不是客户端送礼载荷;同一 command_id 重试不能因为用户快照修复或迁移而产生冲突。
delete(values, "sender_registered_at_ms")
// SendGift 的以下字段由 wallet-service 结算后写入 command log不属于客户端请求幂等语义。
delete(values, "billing_receipt_id")
delete(values, "coin_spent")

View File

@ -82,7 +82,10 @@ func newGiftFlow(s *Service, ctx context.Context, req *roomv1.SendGiftRequest, o
BatchSeq: req.GetBatchSeq(),
SenderCountryID: req.GetSenderCountryId(),
SenderRegionID: req.GetSenderRegionId(),
TargetIsHost: req.GetTargetIsHost(),
// 注册时间随 command 一起进入 gift_operation 和最终 command log恢复时使用首次快照
// 不在 room-service 回查 user-service也不让重试请求覆盖已经用于开奖判定的事实。
SenderRegisteredAtMS: req.GetSenderRegisteredAtMs(),
TargetIsHost: req.GetTargetIsHost(),
// 工资区域由 gateway 从 user-service host profile 注入;房间 visible_region_id 只用于房间/礼物可见性。
TargetHostRegionID: req.GetTargetHostRegionId(),
TargetAgencyOwnerUserID: req.GetTargetAgencyOwnerUserId(),

View File

@ -113,6 +113,9 @@ func luckyGiftMetaForTarget(ctx context.Context, meta *roomv1.RequestMeta, cmd c
PoolId: cmd.PoolID,
VisibleRegionId: roomMeta.VisibleRegionID,
CountryId: cmd.SenderCountryID,
// 账号创建时间来自 gateway 的 user-service 快照并随首次命令持久化owner 只用它判断
// 支付时账号是否已满完整历史窗口。0 表示旧命令/缺失事实,补偿大奖资格必须 fail-close。
UserRegisteredAtMs: cmd.SenderRegisteredAtMS,
// 展示资料来自 gateway 本次送礼入口的用户快照,随抽奖事实持久化后,下游不需要在异步消费时反查当前资料。
SenderName: giftDisplayName(cmd.SenderDisplayProfile),
SenderAvatar: strings.TrimSpace(cmd.SenderDisplayProfile.Avatar),

View File

@ -2,6 +2,7 @@ package user
import (
"context"
"strings"
"hyapp/pkg/appcode"
"hyapp/pkg/xerr"
@ -51,6 +52,26 @@ func (s *Service) ResolveDisplayUserID(ctx context.Context, displayUserID string
return s.identityRepository.ResolveDisplayUserID(ctx, displayUserID, s.now().UnixMilli())
}
// ResolveAdminUserIdentifier 供内部后台按默认短号、当前有效展示号或系统长 user_id 精确查人。
// 它与客户端 ResolveDisplayUserID 分离,避免把 held 默认号意外恢复成登录或普通业务可解析身份。
func (s *Service) ResolveAdminUserIdentifier(ctx context.Context, userIdentifier string) (userdomain.Identity, error) {
userIdentifier = strings.TrimSpace(userIdentifier)
if !userdomain.ValidResolvableDisplayUserID(userIdentifier) {
return userdomain.Identity{}, xerr.New(xerr.InvalidArgument, "user_identifier format is invalid")
}
if s.identityRepository == nil {
return userdomain.Identity{}, xerr.New(xerr.Unavailable, "identity repository is not configured")
}
userID, err := s.identityRepository.ResolveAdminUserIdentifier(ctx, userIdentifier, s.now().UnixMilli())
if err != nil {
return userdomain.Identity{}, err
}
// repository 只决定搜索别名归属;最终复用 GetUserIdentity 返回当前 active 投影,
// 因此用 held 默认号搜索到有靓号的用户时,响应仍展示当前有效靓号。
return s.GetUserIdentity(ctx, userID)
}
// ChangeDisplayUserID 修改用户默认短号,并由 repository 在事务内维护当前展示快照和审计。
func (s *Service) ChangeDisplayUserID(ctx context.Context, userID int64, newDisplayUserID string, reason string, operatorUserID int64, requestID string) (userdomain.Identity, error) {
if userID <= 0 {

View File

@ -124,6 +124,8 @@ type IdentityRepository interface {
GetUserIdentity(ctx context.Context, userID int64) (userdomain.Identity, error)
// ResolveDisplayUserID 把短号解析成 user_id并按 nowMs 触发懒过期。
ResolveDisplayUserID(ctx context.Context, displayUserID string, nowMs int64) (userdomain.Identity, error)
// ResolveAdminUserIdentifier 为内部后台精确解析永久默认短号、当前有效展示号或系统长 user_id。
ResolveAdminUserIdentifier(ctx context.Context, userIdentifier string, nowMs int64) (int64, error)
// ChangeDisplayUserID 原子修改默认短号并写变更日志。
ChangeDisplayUserID(ctx context.Context, command userdomain.DisplayUserIDChangeCommand) (userdomain.Identity, error)
// ApplyPrettyDisplayUserID 原子创建靓号租约并覆盖当前展示号。

View File

@ -3,6 +3,8 @@ package identity
import (
"context"
"database/sql"
"strconv"
"strings"
"hyapp/pkg/appcode"
"hyapp/pkg/xerr"
@ -52,6 +54,97 @@ func (r *Repository) ResolveDisplayUserID(ctx context.Context, displayUserID str
return identity, nil
}
// ResolveAdminUserIdentifier 是后台精确查人的 owner 入口。它刻意不复用 ResolveDisplayUserID
// 客户端只能解析当前 active 展示号,而运营搜索还必须能用被有效靓号覆盖的永久默认短号定位同一用户。
func (r *Repository) ResolveAdminUserIdentifier(ctx context.Context, userIdentifier string, nowMs int64) (int64, error) {
userIdentifier = strings.TrimSpace(userIdentifier)
appCode := appcode.FromContext(ctx)
// 输入本身是已过期靓号时先执行既有懒过期流程;这样过期靓号不会继续作为后台搜索别名,
// 同时用户的默认短号会恢复 active后续 GetUserIdentity 返回的始终是当前身份。
if err := r.expirePrettyByDisplayUserID(ctx, userIdentifier, nowMs, "lazy_admin_resolve"); err != nil {
return 0, err
}
var userID int64
// live_display_user_id 是 active/held 事实的生成列并有 app 级唯一索引。active 表示当前有效展示号;
// held 只接受仍等于 users.default_display_user_id 的永久默认号,避免 reserved/blocked 或历史释放号被解析。
err := r.db.QueryRowContext(ctx, `
SELECT du.user_id
FROM user_display_user_ids du FORCE INDEX (uk_user_display_user_ids_live)
INNER JOIN users u
ON u.app_code = du.app_code
AND u.user_id = du.user_id
WHERE du.app_code = ?
AND du.live_display_user_id = ?
AND (
(du.status = ? AND (du.expires_at_ms IS NULL OR du.expires_at_ms = 0 OR du.expires_at_ms > ?))
OR (du.status = ? AND du.display_user_id = u.default_display_user_id)
)
LIMIT 1
`, appCode, userIdentifier, string(userdomain.DisplayUserIDStatusActive), nowMs, string(userdomain.DisplayUserIDStatusHeld)).Scan(&userID)
if err == nil {
return userID, nil
}
if err != sql.ErrNoRows {
return 0, err
}
// 兼容 identity 事实表补齐前的旧用户:两次独立等值查询分别命中 current/default 唯一索引,
// 不使用 OR 扫描 app 用户集合。过期 pretty 快照不能作为当前展示号命中。
err = r.db.QueryRowContext(ctx, `
SELECT user_id
FROM users FORCE INDEX (uk_users_current_display_user_id)
WHERE app_code = ?
AND current_display_user_id = ?
AND (
current_display_user_id_kind <> ?
OR current_display_user_id_expires_at_ms IS NULL
OR current_display_user_id_expires_at_ms = 0
OR current_display_user_id_expires_at_ms > ?
)
LIMIT 1
`, appCode, userIdentifier, string(userdomain.DisplayUserIDKindPretty), nowMs).Scan(&userID)
if err == nil {
return userID, nil
}
if err != sql.ErrNoRows {
return 0, err
}
err = r.db.QueryRowContext(ctx, `
SELECT user_id
FROM users FORCE INDEX (uk_users_default_display_user_id)
WHERE app_code = ? AND default_display_user_id = ?
LIMIT 1
`, appCode, userIdentifier).Scan(&userID)
if err == nil {
return userID, nil
}
if err != sql.ErrNoRows {
return 0, err
}
// 展示号优先于长 user_id保持既有后台搜索的碰撞决策只有所有展示别名都未命中时才查主键。
parsedUserID, parseErr := strconv.ParseInt(userIdentifier, 10, 64)
if parseErr == nil && parsedUserID > 0 {
err = r.db.QueryRowContext(ctx, `
SELECT user_id
FROM users FORCE INDEX (PRIMARY)
WHERE user_id = ? AND app_code = ?
LIMIT 1
`, parsedUserID, appCode).Scan(&userID)
if err == nil {
return userID, nil
}
if err != sql.ErrNoRows {
return 0, err
}
}
return 0, xerr.New(xerr.DisplayUserIDNotFound, "user identifier not found")
}
func (r *Repository) queryActiveIdentity(ctx context.Context, clause string, args ...any) (userdomain.Identity, error) {
// user_display_user_ids 的 active 行是展示号归属事实users.current_display_user_id 只是高频读取快照。
// 解析入口优先读取事实表,可以容忍历史脚本或异常中断导致 users 快照暂时没有同步到最新靓号。

View File

@ -214,6 +214,10 @@ func (r *Repository) ResolveDisplayUserID(ctx context.Context, displayUserID str
return r.Repository.IdentityRepository().ResolveDisplayUserID(ctx, displayUserID, nowMs)
}
func (r *Repository) ResolveAdminUserIdentifier(ctx context.Context, userIdentifier string, nowMs int64) (int64, error) {
return r.Repository.IdentityRepository().ResolveAdminUserIdentifier(ctx, userIdentifier, nowMs)
}
// ChangeDisplayUserID 让测试 wrapper 继续满足短号接口。
func (r *Repository) ChangeDisplayUserID(ctx context.Context, command userdomain.DisplayUserIDChangeCommand) (userdomain.Identity, error) {
return r.Repository.IdentityRepository().ChangeDisplayUserID(ctx, command)

View File

@ -1118,6 +1118,18 @@ func (s *Server) ResolveDisplayUserID(ctx context.Context, req *userv1.ResolveDi
return &userv1.ResolveDisplayUserIDResponse{Identity: toProtoIdentity(identity)}, nil
}
// ResolveAdminUserIdentifier 为内部后台精确解析默认短号、当前有效展示号或系统长 user_id。
func (s *Server) ResolveAdminUserIdentifier(ctx context.Context, req *userv1.ResolveAdminUserIdentifierRequest) (*userv1.ResolveAdminUserIdentifierResponse, error) {
ctx = contextWithApp(ctx, req.GetMeta())
// 后台搜索语义由独立 service 用例承载,禁止扩大客户端 ResolveDisplayUserID 的 active-only 边界。
identity, err := s.userSvc.ResolveAdminUserIdentifier(ctx, req.GetUserIdentifier())
if err != nil {
return nil, xerr.ToGRPCError(err)
}
return &userv1.ResolveAdminUserIdentifierResponse{Identity: toProtoIdentity(identity)}, nil
}
// ChangeDisplayUserID 修改当前 active 短号并返回新绑定。
func (s *Server) ChangeDisplayUserID(ctx context.Context, req *userv1.ChangeDisplayUserIDRequest) (*userv1.ChangeDisplayUserIDResponse, error) {
ctx = contextWithApp(ctx, req.GetMeta())

View File

@ -542,7 +542,7 @@ func (s *demoServer) ensureDynamicRule(ctx context.Context, operatorID int64) (*
HighWatermarkCoins: 20_000_000, HighWaterNonzeroFactorPpm: 1_300_000,
RechargeBoostWindowMs: 300_000, RechargeBoostFactorPpm: 1_100_000,
JackpotMultiplierPpms: []int64{200_000_000, 500_000_000, 1_000_000_000},
JackpotGlobalRtpMaxPpm: 980_000, JackpotUserDayRtpMaxPpm: 960_000, JackpotUser_72HRtpMaxPpm: 960_000,
JackpotGlobalRtpMaxPpm: 980_000, JackpotUserRoundRtpMaxPpm: 960_000, JackpotUser_48HRtpMaxPpm: 960_000,
JackpotSpendThresholdCoins: 5_000, MaxJackpotHitsPerUserDay: 5,
MaxSinglePayout: 1_000_000, UserHourlyPayoutCap: 2_000_000, UserDailyPayoutCap: 5_000_000,
DeviceDailyPayoutCap: 5_000_000, RoomHourlyPayoutCap: 10_000_000, AnchorDailyPayoutCap: 10_000_000,