feat(lucky-gift): add user portrait read model
This commit is contained in:
parent
99b225b78a
commit
a1f88736db
File diff suppressed because it is too large
Load Diff
@ -324,6 +324,8 @@ service AdminLuckyGiftService {
|
|||||||
rpc GetLuckyGiftDrawSummary(GetLuckyGiftDrawSummaryRequest) returns (GetLuckyGiftDrawSummaryResponse);
|
rpc GetLuckyGiftDrawSummary(GetLuckyGiftDrawSummaryRequest) returns (GetLuckyGiftDrawSummaryResponse);
|
||||||
rpc ListLuckyGiftPoolBalances(ListLuckyGiftPoolBalancesRequest) returns (ListLuckyGiftPoolBalancesResponse);
|
rpc ListLuckyGiftPoolBalances(ListLuckyGiftPoolBalancesRequest) returns (ListLuckyGiftPoolBalancesResponse);
|
||||||
rpc AdjustLuckyGiftPoolBalance(AdjustLuckyGiftPoolBalanceRequest) returns (AdjustLuckyGiftPoolBalanceResponse);
|
rpc AdjustLuckyGiftPoolBalance(AdjustLuckyGiftPoolBalanceRequest) returns (AdjustLuckyGiftPoolBalanceResponse);
|
||||||
|
rpc ListLuckyGiftUserProfiles(ListLuckyGiftUserProfilesRequest) returns (ListLuckyGiftUserProfilesResponse);
|
||||||
|
rpc GetLuckyGiftUserProfile(GetLuckyGiftUserProfileRequest) returns (GetLuckyGiftUserProfileResponse);
|
||||||
}
|
}
|
||||||
|
|
||||||
// LuckyGiftHit 只返回批量逐份开奖中实际中奖的精简位置,不复制全部审计明细。
|
// LuckyGiftHit 只返回批量逐份开奖中实际中奖的精简位置,不复制全部审计明细。
|
||||||
@ -368,3 +370,138 @@ message AdjustLuckyGiftPoolBalanceResponse {
|
|||||||
LuckyGiftPoolBalance pool = 2;
|
LuckyGiftPoolBalance pool = 2;
|
||||||
bool idempotent_replay = 3;
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@ -242,6 +242,8 @@ const (
|
|||||||
AdminLuckyGiftService_GetLuckyGiftDrawSummary_FullMethodName = "/hyapp.luckygift.v1.AdminLuckyGiftService/GetLuckyGiftDrawSummary"
|
AdminLuckyGiftService_GetLuckyGiftDrawSummary_FullMethodName = "/hyapp.luckygift.v1.AdminLuckyGiftService/GetLuckyGiftDrawSummary"
|
||||||
AdminLuckyGiftService_ListLuckyGiftPoolBalances_FullMethodName = "/hyapp.luckygift.v1.AdminLuckyGiftService/ListLuckyGiftPoolBalances"
|
AdminLuckyGiftService_ListLuckyGiftPoolBalances_FullMethodName = "/hyapp.luckygift.v1.AdminLuckyGiftService/ListLuckyGiftPoolBalances"
|
||||||
AdminLuckyGiftService_AdjustLuckyGiftPoolBalance_FullMethodName = "/hyapp.luckygift.v1.AdminLuckyGiftService/AdjustLuckyGiftPoolBalance"
|
AdminLuckyGiftService_AdjustLuckyGiftPoolBalance_FullMethodName = "/hyapp.luckygift.v1.AdminLuckyGiftService/AdjustLuckyGiftPoolBalance"
|
||||||
|
AdminLuckyGiftService_ListLuckyGiftUserProfiles_FullMethodName = "/hyapp.luckygift.v1.AdminLuckyGiftService/ListLuckyGiftUserProfiles"
|
||||||
|
AdminLuckyGiftService_GetLuckyGiftUserProfile_FullMethodName = "/hyapp.luckygift.v1.AdminLuckyGiftService/GetLuckyGiftUserProfile"
|
||||||
)
|
)
|
||||||
|
|
||||||
// AdminLuckyGiftServiceClient is the client API for AdminLuckyGiftService service.
|
// AdminLuckyGiftServiceClient is the client API for AdminLuckyGiftService service.
|
||||||
@ -255,6 +257,8 @@ type AdminLuckyGiftServiceClient interface {
|
|||||||
GetLuckyGiftDrawSummary(ctx context.Context, in *GetLuckyGiftDrawSummaryRequest, opts ...grpc.CallOption) (*GetLuckyGiftDrawSummaryResponse, error)
|
GetLuckyGiftDrawSummary(ctx context.Context, in *GetLuckyGiftDrawSummaryRequest, opts ...grpc.CallOption) (*GetLuckyGiftDrawSummaryResponse, error)
|
||||||
ListLuckyGiftPoolBalances(ctx context.Context, in *ListLuckyGiftPoolBalancesRequest, opts ...grpc.CallOption) (*ListLuckyGiftPoolBalancesResponse, error)
|
ListLuckyGiftPoolBalances(ctx context.Context, in *ListLuckyGiftPoolBalancesRequest, opts ...grpc.CallOption) (*ListLuckyGiftPoolBalancesResponse, error)
|
||||||
AdjustLuckyGiftPoolBalance(ctx context.Context, in *AdjustLuckyGiftPoolBalanceRequest, opts ...grpc.CallOption) (*AdjustLuckyGiftPoolBalanceResponse, error)
|
AdjustLuckyGiftPoolBalance(ctx context.Context, in *AdjustLuckyGiftPoolBalanceRequest, opts ...grpc.CallOption) (*AdjustLuckyGiftPoolBalanceResponse, 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 {
|
type adminLuckyGiftServiceClient struct {
|
||||||
@ -335,6 +339,26 @@ func (c *adminLuckyGiftServiceClient) AdjustLuckyGiftPoolBalance(ctx context.Con
|
|||||||
return out, nil
|
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.
|
// AdminLuckyGiftServiceServer is the server API for AdminLuckyGiftService service.
|
||||||
// All implementations must embed UnimplementedAdminLuckyGiftServiceServer
|
// All implementations must embed UnimplementedAdminLuckyGiftServiceServer
|
||||||
// for forward compatibility.
|
// for forward compatibility.
|
||||||
@ -346,6 +370,8 @@ type AdminLuckyGiftServiceServer interface {
|
|||||||
GetLuckyGiftDrawSummary(context.Context, *GetLuckyGiftDrawSummaryRequest) (*GetLuckyGiftDrawSummaryResponse, error)
|
GetLuckyGiftDrawSummary(context.Context, *GetLuckyGiftDrawSummaryRequest) (*GetLuckyGiftDrawSummaryResponse, error)
|
||||||
ListLuckyGiftPoolBalances(context.Context, *ListLuckyGiftPoolBalancesRequest) (*ListLuckyGiftPoolBalancesResponse, error)
|
ListLuckyGiftPoolBalances(context.Context, *ListLuckyGiftPoolBalancesRequest) (*ListLuckyGiftPoolBalancesResponse, error)
|
||||||
AdjustLuckyGiftPoolBalance(context.Context, *AdjustLuckyGiftPoolBalanceRequest) (*AdjustLuckyGiftPoolBalanceResponse, error)
|
AdjustLuckyGiftPoolBalance(context.Context, *AdjustLuckyGiftPoolBalanceRequest) (*AdjustLuckyGiftPoolBalanceResponse, error)
|
||||||
|
ListLuckyGiftUserProfiles(context.Context, *ListLuckyGiftUserProfilesRequest) (*ListLuckyGiftUserProfilesResponse, error)
|
||||||
|
GetLuckyGiftUserProfile(context.Context, *GetLuckyGiftUserProfileRequest) (*GetLuckyGiftUserProfileResponse, error)
|
||||||
mustEmbedUnimplementedAdminLuckyGiftServiceServer()
|
mustEmbedUnimplementedAdminLuckyGiftServiceServer()
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -377,6 +403,12 @@ func (UnimplementedAdminLuckyGiftServiceServer) ListLuckyGiftPoolBalances(contex
|
|||||||
func (UnimplementedAdminLuckyGiftServiceServer) AdjustLuckyGiftPoolBalance(context.Context, *AdjustLuckyGiftPoolBalanceRequest) (*AdjustLuckyGiftPoolBalanceResponse, error) {
|
func (UnimplementedAdminLuckyGiftServiceServer) AdjustLuckyGiftPoolBalance(context.Context, *AdjustLuckyGiftPoolBalanceRequest) (*AdjustLuckyGiftPoolBalanceResponse, error) {
|
||||||
return nil, status.Error(codes.Unimplemented, "method AdjustLuckyGiftPoolBalance not implemented")
|
return nil, status.Error(codes.Unimplemented, "method AdjustLuckyGiftPoolBalance 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) mustEmbedUnimplementedAdminLuckyGiftServiceServer() {}
|
||||||
func (UnimplementedAdminLuckyGiftServiceServer) testEmbeddedByValue() {}
|
func (UnimplementedAdminLuckyGiftServiceServer) testEmbeddedByValue() {}
|
||||||
|
|
||||||
@ -524,6 +556,42 @@ func _AdminLuckyGiftService_AdjustLuckyGiftPoolBalance_Handler(srv interface{},
|
|||||||
return interceptor(ctx, in, info, handler)
|
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.
|
// AdminLuckyGiftService_ServiceDesc is the grpc.ServiceDesc for AdminLuckyGiftService service.
|
||||||
// It's only intended for direct use with grpc.RegisterService,
|
// It's only intended for direct use with grpc.RegisterService,
|
||||||
// and not to be introspected or modified (even as a copy)
|
// and not to be introspected or modified (even as a copy)
|
||||||
@ -559,6 +627,14 @@ var AdminLuckyGiftService_ServiceDesc = grpc.ServiceDesc{
|
|||||||
MethodName: "AdjustLuckyGiftPoolBalance",
|
MethodName: "AdjustLuckyGiftPoolBalance",
|
||||||
Handler: _AdminLuckyGiftService_AdjustLuckyGiftPoolBalance_Handler,
|
Handler: _AdminLuckyGiftService_AdjustLuckyGiftPoolBalance_Handler,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
MethodName: "ListLuckyGiftUserProfiles",
|
||||||
|
Handler: _AdminLuckyGiftService_ListLuckyGiftUserProfiles_Handler,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
MethodName: "GetLuckyGiftUserProfile",
|
||||||
|
Handler: _AdminLuckyGiftService_GetLuckyGiftUserProfile_Handler,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
Streams: []grpc.StreamDesc{},
|
Streams: []grpc.StreamDesc{},
|
||||||
Metadata: "proto/luckygift/v1/luckygift.proto",
|
Metadata: "proto/luckygift/v1/luckygift.proto",
|
||||||
|
|||||||
@ -351,7 +351,8 @@ func main() {
|
|||||||
databimodule.WithLegacyApps(databiLegacyApps(cfg.DashboardExternalSources, cfg.MoneyRegionSources)...),
|
databimodule.WithLegacyApps(databiLegacyApps(cfg.DashboardExternalSources, cfg.MoneyRegionSources)...),
|
||||||
databimodule.WithLegacyRegionCatalogs(databiLegacyRegionCatalogs(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{
|
handlers := router.Handlers{
|
||||||
Audit: auditHandler,
|
Audit: auditHandler,
|
||||||
Auth: authmodule.New(store, auth, auditHandler, cfg),
|
Auth: authmodule.New(store, auth, auditHandler, cfg),
|
||||||
|
|||||||
@ -16,6 +16,8 @@ type Client interface {
|
|||||||
ListLuckyGiftConfigs(ctx context.Context, req *luckygiftv1.ListLuckyGiftConfigsRequest) (*luckygiftv1.ListLuckyGiftConfigsResponse, error)
|
ListLuckyGiftConfigs(ctx context.Context, req *luckygiftv1.ListLuckyGiftConfigsRequest) (*luckygiftv1.ListLuckyGiftConfigsResponse, error)
|
||||||
ListLuckyGiftDraws(ctx context.Context, req *luckygiftv1.ListLuckyGiftDrawsRequest) (*luckygiftv1.ListLuckyGiftDrawsResponse, error)
|
ListLuckyGiftDraws(ctx context.Context, req *luckygiftv1.ListLuckyGiftDrawsRequest) (*luckygiftv1.ListLuckyGiftDrawsResponse, error)
|
||||||
GetLuckyGiftDrawSummary(ctx context.Context, req *luckygiftv1.GetLuckyGiftDrawSummaryRequest) (*luckygiftv1.GetLuckyGiftDrawSummaryResponse, 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)
|
ListLuckyGiftPoolBalances(ctx context.Context, req *luckygiftv1.ListLuckyGiftPoolBalancesRequest) (*luckygiftv1.ListLuckyGiftPoolBalancesResponse, error)
|
||||||
AdjustLuckyGiftPoolBalance(ctx context.Context, req *luckygiftv1.AdjustLuckyGiftPoolBalanceRequest) (*luckygiftv1.AdjustLuckyGiftPoolBalanceResponse, error)
|
AdjustLuckyGiftPoolBalance(ctx context.Context, req *luckygiftv1.AdjustLuckyGiftPoolBalanceRequest) (*luckygiftv1.AdjustLuckyGiftPoolBalanceResponse, error)
|
||||||
}
|
}
|
||||||
@ -48,6 +50,14 @@ func (c *GRPCClient) GetLuckyGiftDrawSummary(ctx context.Context, req *luckygift
|
|||||||
return c.client.GetLuckyGiftDrawSummary(ctx, req)
|
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) {
|
func (c *GRPCClient) ListLuckyGiftPoolBalances(ctx context.Context, req *luckygiftv1.ListLuckyGiftPoolBalancesRequest) (*luckygiftv1.ListLuckyGiftPoolBalancesResponse, error) {
|
||||||
return c.client.ListLuckyGiftPoolBalances(ctx, req)
|
return c.client.ListLuckyGiftPoolBalances(ctx, req)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -9,6 +9,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"hyapp-admin-server/internal/integration/luckygiftadmin"
|
"hyapp-admin-server/internal/integration/luckygiftadmin"
|
||||||
|
"hyapp-admin-server/internal/integration/userclient"
|
||||||
"hyapp-admin-server/internal/middleware"
|
"hyapp-admin-server/internal/middleware"
|
||||||
"hyapp-admin-server/internal/modules/shared"
|
"hyapp-admin-server/internal/modules/shared"
|
||||||
"hyapp-admin-server/internal/response"
|
"hyapp-admin-server/internal/response"
|
||||||
@ -21,6 +22,7 @@ import (
|
|||||||
|
|
||||||
type Handler struct {
|
type Handler struct {
|
||||||
luckyGift luckygiftadmin.Client
|
luckyGift luckygiftadmin.Client
|
||||||
|
users userclient.Client
|
||||||
requestTimeout time.Duration
|
requestTimeout time.Duration
|
||||||
audit shared.OperationLogger
|
audit shared.OperationLogger
|
||||||
}
|
}
|
||||||
@ -32,6 +34,13 @@ func New(luckyGift luckygiftadmin.Client, requestTimeout time.Duration, audit sh
|
|||||||
return &Handler{luckyGift: luckyGift, requestTimeout: requestTimeout, audit: audit}
|
return &Handler{luckyGift: luckyGift, requestTimeout: requestTimeout, audit: audit}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// BindUserClient 注入 user-service 主数据读取能力。幸运礼物 owner 只保存不可变的内部 user_id,
|
||||||
|
// 短号、默认号、靓号和用户资料必须在 admin-server 展示边界实时解析,不能复制进幸运礼物画像表。
|
||||||
|
func (h *Handler) BindUserClient(users userclient.Client) *Handler {
|
||||||
|
h.users = users
|
||||||
|
return h
|
||||||
|
}
|
||||||
|
|
||||||
type configRequest struct {
|
type configRequest struct {
|
||||||
AppCode string `json:"app_code"`
|
AppCode string `json:"app_code"`
|
||||||
PoolID string `json:"pool_id"`
|
PoolID string `json:"pool_id"`
|
||||||
|
|||||||
621
server/admin/internal/modules/luckygift/user_profile.go
Normal file
621
server/admin/internal/modules/luckygift/user_profile.go
Normal file
@ -0,0 +1,621 @@
|
|||||||
|
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.ResolveDisplayUserID(appctx.WithContext(ctx, appCode), userclient.ResolveDisplayUserIDRequest{
|
||||||
|
RequestID: requestID, Caller: "admin-server", DisplayUserID: 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:
|
||||||
|
// 继续尝试把纯数字解释为系统长 user_id。
|
||||||
|
case codes.InvalidArgument:
|
||||||
|
// all 模式下 keyword 也可能是任意 external_user_id;内部展示号格式不合法只代表内部未命中,不能阻断外部精确搜索。
|
||||||
|
return 0, externalUserID, false, nil
|
||||||
|
default:
|
||||||
|
return 0, externalUserID, false, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 纯数字先按展示号解析;只有展示号确实不存在时才回退为 64 位长 user_id,避免短号与长号碰撞时查错人。
|
||||||
|
longUserID, parseErr := strconv.ParseInt(keyword, 10, 64)
|
||||||
|
if parseErr != nil || longUserID <= 0 {
|
||||||
|
return 0, externalUserID, false, nil
|
||||||
|
}
|
||||||
|
user, getErr := h.users.GetUser(appctx.WithContext(ctx, appCode), userclient.GetUserRequest{
|
||||||
|
RequestID: requestID, Caller: "admin-server", UserID: longUserID,
|
||||||
|
})
|
||||||
|
if getErr == nil && user != nil {
|
||||||
|
return user.UserID, externalUserID, true, nil
|
||||||
|
}
|
||||||
|
if getErr != nil && grpcstatus.Code(getErr) != codes.NotFound {
|
||||||
|
return 0, externalUserID, false, getErr
|
||||||
|
}
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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/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/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/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.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/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)
|
group.POST("/lucky-gifts/pools/debit", middleware.RequirePermission("lucky-gift:pool-debit"), h.luckyGift.DebitLuckyGiftPoolBalance)
|
||||||
|
|||||||
@ -403,6 +403,118 @@ CREATE TABLE IF NOT EXISTS lucky_draw_pool_stat_cursors (
|
|||||||
PRIMARY KEY (app_code, cursor_name)
|
PRIMARY KEY (app_code, cursor_name)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='幸运礼物后台统计快照游标';
|
) 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 (
|
CREATE TABLE IF NOT EXISTS external_lucky_gift_request_locks (
|
||||||
app_code VARCHAR(32) NOT NULL COMMENT '外部 App 应用编码',
|
app_code VARCHAR(32) NOT NULL COMMENT '外部 App 应用编码',
|
||||||
request_id VARCHAR(128) NOT NULL COMMENT '外部业务幂等 ID',
|
request_id VARCHAR(128) NOT NULL COMMENT '外部业务幂等 ID',
|
||||||
@ -433,7 +545,8 @@ CREATE TABLE IF NOT EXISTS external_lucky_gift_draws (
|
|||||||
UNIQUE KEY uk_external_lucky_draw_id (app_code, draw_id),
|
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_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_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 幸运礼物抽奖事实表';
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='外部 App 幸运礼物抽奖事实表';
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS external_lucky_gift_draw_items (
|
CREATE TABLE IF NOT EXISTS external_lucky_gift_draw_items (
|
||||||
@ -458,5 +571,6 @@ CREATE TABLE IF NOT EXISTS external_lucky_gift_draw_items (
|
|||||||
created_at_ms BIGINT NOT NULL COMMENT '创建时间,UTC epoch ms',
|
created_at_ms BIGINT NOT NULL COMMENT '创建时间,UTC epoch ms',
|
||||||
PRIMARY KEY (app_code, request_id, item_index),
|
PRIMARY KEY (app_code, request_id, item_index),
|
||||||
UNIQUE KEY uk_external_lucky_item_draw (app_code, draw_id),
|
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顺序子抽审计';
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='外部dynamic_v3顺序子抽审计';
|
||||||
|
|||||||
@ -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;
|
||||||
@ -347,6 +347,138 @@ type DrawQuery struct {
|
|||||||
ExternalOnly bool
|
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 state,transport 映射时必须丢弃。
|
||||||
|
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 “已扣费后请求抽奖”的业务事实。
|
// ExternalDrawCommand 是外部 App “已扣费后请求抽奖”的业务事实。
|
||||||
// HyApp 不接管外部 App 余额,因此这里没有 target_user_id、room_id 或 wallet command_id;幂等边界固定为
|
// HyApp 不接管外部 App 余额,因此这里没有 target_user_id、room_id 或 wallet command_id;幂等边界固定为
|
||||||
// app_code + request_id,任何重试都必须返回同一条抽奖结果。
|
// app_code + request_id,任何重试都必须返回同一条抽奖结果。
|
||||||
|
|||||||
@ -37,6 +37,8 @@ type Repository interface {
|
|||||||
ExecuteExternalGiftDraw(ctx context.Context, cmd domain.ExternalDrawCommand, nowMS int64) (domain.ExternalDrawResult, error)
|
ExecuteExternalGiftDraw(ctx context.Context, cmd domain.ExternalDrawCommand, nowMS int64) (domain.ExternalDrawResult, error)
|
||||||
ListLuckyGiftDraws(ctx context.Context, query domain.DrawQuery) ([]domain.DrawResult, int64, error)
|
ListLuckyGiftDraws(ctx context.Context, query domain.DrawQuery) ([]domain.DrawResult, int64, error)
|
||||||
GetLuckyGiftDrawSummary(ctx context.Context, query domain.DrawQuery) (domain.DrawSummary, 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)
|
ListLuckyGiftPoolBalances(ctx context.Context, query domain.PoolBalanceQuery) ([]domain.PoolBalance, error)
|
||||||
AdjustLuckyGiftPoolBalance(ctx context.Context, command domain.PoolAdjustmentCommand, nowMS int64) (domain.PoolAdjustmentResult, error)
|
AdjustLuckyGiftPoolBalance(ctx context.Context, command domain.PoolAdjustmentCommand, nowMS int64) (domain.PoolAdjustmentResult, error)
|
||||||
GetLuckyGiftDrawRewardState(ctx context.Context, appCode string, drawIDs []string) (domain.DrawRewardState, error)
|
GetLuckyGiftDrawRewardState(ctx context.Context, appCode string, drawIDs []string) (domain.DrawRewardState, error)
|
||||||
@ -54,6 +56,12 @@ type Repository interface {
|
|||||||
MarkLuckyGiftDrawsFailed(ctx context.Context, appCode string, drawIDs []string, failureReason string, nowMS int64) error
|
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 仍负责持久 outbox,activity 只做成功落库后的低延迟余额 IM。
|
// WalletClient 是幸运礼物返奖唯一账务依赖;wallet-service 仍负责持久 outbox,activity 只做成功落库后的低延迟余额 IM。
|
||||||
type WalletClient interface {
|
type WalletClient interface {
|
||||||
CreditLuckyGiftReward(ctx context.Context, req *walletv1.CreditLuckyGiftRewardRequest, opts ...grpc.CallOption) (*walletv1.CreditLuckyGiftRewardResponse, error)
|
CreditLuckyGiftReward(ctx context.Context, req *walletv1.CreditLuckyGiftRewardRequest, opts ...grpc.CallOption) (*walletv1.CreditLuckyGiftRewardResponse, error)
|
||||||
@ -278,6 +286,7 @@ func (s *Service) RunWorker(ctx context.Context, options WorkerOptions) {
|
|||||||
ticker := time.NewTicker(options.PollInterval)
|
ticker := time.NewTicker(options.PollInterval)
|
||||||
defer ticker.Stop()
|
defer ticker.Stop()
|
||||||
nextStatsRefresh := time.Time{}
|
nextStatsRefresh := time.Time{}
|
||||||
|
nextUserProfileRefresh := time.Time{}
|
||||||
for {
|
for {
|
||||||
if _, err := s.ProcessPendingDrawOutbox(ctx, options); err != nil && ctx.Err() == nil {
|
if _, err := s.ProcessPendingDrawOutbox(ctx, options); err != nil && ctx.Err() == nil {
|
||||||
// 批处理失败不删除 outbox;锁过期后下一轮或其他实例会继续补偿。
|
// 批处理失败不删除 outbox;锁过期后下一轮或其他实例会继续补偿。
|
||||||
@ -293,6 +302,24 @@ func (s *Service) RunWorker(ctx context.Context, options WorkerOptions) {
|
|||||||
}
|
}
|
||||||
nextStatsRefresh = now.Add(options.StatsInterval)
|
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 {
|
select {
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
return
|
return
|
||||||
@ -774,6 +801,52 @@ func (s *Service) GetDrawSummary(ctx context.Context, query domain.DrawQuery) (d
|
|||||||
return s.repository.GetLuckyGiftDrawSummary(ctx, normalizeDrawQuery(query))
|
return s.repository.GetLuckyGiftDrawSummary(ctx, normalizeDrawQuery(query))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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) {
|
func (s *Service) ListPoolBalances(ctx context.Context, query domain.PoolBalanceQuery) ([]domain.PoolBalance, error) {
|
||||||
if err := s.requireRepository(); err != nil {
|
if err := s.requireRepository(); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@ -850,6 +923,53 @@ func normalizeDrawQuery(query domain.DrawQuery) domain.DrawQuery {
|
|||||||
return query
|
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 {
|
type luckyGiftDrawnPayload struct {
|
||||||
EventType string `json:"event_type"`
|
EventType string `json:"event_type"`
|
||||||
EventID string `json:"event_id"`
|
EventID string `json:"event_id"`
|
||||||
|
|||||||
@ -154,6 +154,29 @@ func (r *Repository) ExecuteExternalGiftDraw(ctx context.Context, cmd domain.Ext
|
|||||||
); err != nil {
|
); err != nil {
|
||||||
return domain.ExternalDrawResult{}, err
|
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{
|
payload, err := json.Marshal(map[string]any{
|
||||||
"event_type": domain.EventTypeExternalLuckyGiftDrawn,
|
"event_type": domain.EventTypeExternalLuckyGiftDrawn,
|
||||||
"event_id": domain.EventTypeExternalLuckyGiftDrawn + ":" + drawID,
|
"event_id": domain.EventTypeExternalLuckyGiftDrawn + ":" + drawID,
|
||||||
@ -259,7 +282,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 {
|
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{}, 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 内核。
|
// executeExternalDynamicGiftEconomy 让外部 App 复用与房间送礼完全相同的 dynamic_v3 内核。
|
||||||
@ -313,6 +339,9 @@ func (r *Repository) executeExternalDynamicGiftEconomy(ctx context.Context, tx *
|
|||||||
reward += result.EffectiveRewardCoins
|
reward += result.EffectiveRewardCoins
|
||||||
}
|
}
|
||||||
candidate := externalDrawCandidate{TierID: "dynamic_v3_batch"}
|
candidate := externalDrawCandidate{TierID: "dynamic_v3_batch"}
|
||||||
|
if len(results) > 0 {
|
||||||
|
candidate.Stage = results[len(results)-1].ExperiencePool
|
||||||
|
}
|
||||||
if len(results) == 1 {
|
if len(results) == 1 {
|
||||||
candidate.TierID = results[0].SelectedTierID
|
candidate.TierID = results[0].SelectedTierID
|
||||||
candidate.MultiplierPPM = results[0].MultiplierPPM
|
candidate.MultiplierPPM = results[0].MultiplierPPM
|
||||||
@ -396,8 +425,10 @@ func (r *Repository) getExternalGiftDrawForUpdate(ctx context.Context, tx *sql.T
|
|||||||
}
|
}
|
||||||
|
|
||||||
type externalDrawCandidate struct {
|
type externalDrawCandidate struct {
|
||||||
TierID string
|
TierID string
|
||||||
MultiplierPPM int64
|
MultiplierPPM int64
|
||||||
|
Stage string
|
||||||
|
HighMultiplier bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func selectExternalDrawCandidate(rule domain.RuleConfig) (externalDrawCandidate, error) {
|
func selectExternalDrawCandidate(rule domain.RuleConfig) (externalDrawCandidate, error) {
|
||||||
|
|||||||
@ -643,5 +643,11 @@ func (r *Repository) insertExternalLuckyGiftDrawItems(ctx context.Context, tx *s
|
|||||||
return err
|
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)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -569,6 +569,9 @@ func (r *Repository) updateLuckyPoolNet(ctx context.Context, tx *sql.Tx, appCode
|
|||||||
// insertLuckyDrawRecords 批量写入审计明细。
|
// insertLuckyDrawRecords 批量写入审计明细。
|
||||||
// 仍然保留每一抽一条记录,因为后台统计“抽奖次数”和问题复盘都需要单抽粒度;优化点是批量 INSERT 而不是 N 次 INSERT。
|
// 仍然保留每一抽一条记录,因为后台统计“抽奖次数”和问题复盘都需要单抽粒度;优化点是批量 INSERT 而不是 N 次 INSERT。
|
||||||
func (r *Repository) insertLuckyDrawRecords(ctx context.Context, tx *sql.Tx, records []luckyDrawRecordInput) error {
|
func (r *Repository) insertLuckyDrawRecords(ctx context.Context, tx *sql.Tx, records []luckyDrawRecordInput) error {
|
||||||
|
if len(records) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
const chunkSize = 200
|
const chunkSize = 200
|
||||||
for start := 0; start < len(records); start += chunkSize {
|
for start := 0; start < len(records); start += chunkSize {
|
||||||
end := start + chunkSize
|
end := start + chunkSize
|
||||||
@ -598,7 +601,14 @@ func (r *Repository) insertLuckyDrawRecords(ctx context.Context, tx *sql.Tx, rec
|
|||||||
return err
|
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 {
|
type luckyDrawPoolStatKey struct {
|
||||||
@ -1425,6 +1435,18 @@ func (r *Repository) applyLuckyDraw(ctx context.Context, tx *sql.Tx, input lucky
|
|||||||
); err != nil {
|
); err != nil {
|
||||||
return err
|
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) {
|
if luckyDrawNeedsRewardSettlementOutbox(input.Candidate) {
|
||||||
// 同一 owner outbox 先补偿钱包返奖,再在 granted 收敛后发布中奖事实;room/activity 的展示副作用
|
// 同一 owner outbox 先补偿钱包返奖,再在 granted 收敛后发布中奖事实;room/activity 的展示副作用
|
||||||
// 由各自本地 outbox 独立补偿,不能在抽奖事务里直接调用腾讯云 IM。
|
// 由各自本地 outbox 独立补偿,不能在抽奖事务里直接调用腾讯云 IM。
|
||||||
@ -1869,6 +1891,10 @@ func (r *Repository) markLuckyGiftDraws(ctx context.Context, appCode string, dra
|
|||||||
_ = tx.Rollback()
|
_ = tx.Rollback()
|
||||||
return err
|
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 {
|
if err := tx.Commit(); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -224,6 +224,93 @@ func (s *AdminLuckyGiftServer) GetLuckyGiftDrawSummary(ctx context.Context, req
|
|||||||
return &luckygiftv1.GetLuckyGiftDrawSummaryResponse{Summary: luckyDrawSummaryToProto(summary)}, nil
|
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) {
|
func (s *AdminLuckyGiftServer) ListLuckyGiftPoolBalances(ctx context.Context, req *luckygiftv1.ListLuckyGiftPoolBalancesRequest) (*luckygiftv1.ListLuckyGiftPoolBalancesResponse, error) {
|
||||||
appCode := req.GetMeta().GetAppCode()
|
appCode := req.GetMeta().GetAppCode()
|
||||||
if appCode != "" {
|
if appCode != "" {
|
||||||
@ -496,6 +583,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 {
|
func luckyPoolAdjustmentToProto(adjustment domain.PoolAdjustment) *luckygiftv1.LuckyGiftPoolAdjustment {
|
||||||
return &luckygiftv1.LuckyGiftPoolAdjustment{
|
return &luckygiftv1.LuckyGiftPoolAdjustment{
|
||||||
AdjustmentId: adjustment.AdjustmentID,
|
AdjustmentId: adjustment.AdjustmentID,
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user