merge: add lucky gift user portraits

This commit is contained in:
zhx 2026-07-16 18:08:07 +08:00
commit 98d7743d7a
17 changed files with 4701 additions and 125 deletions

File diff suppressed because it is too large Load Diff

View File

@ -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;
}

View File

@ -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",

View File

@ -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),

View File

@ -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)
} }

View File

@ -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"`

View 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)
}
}

View File

@ -24,6 +24,8 @@ func RegisterRoutes(protected *gin.RouterGroup, h *Handler) {
group.GET("/lucky-gifts/configs", middleware.RequirePermission("lucky-gift:view"), h.luckyGift.ListLuckyGiftConfigs) group.GET("/lucky-gifts/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)

View File

@ -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顺序子抽审计';

View File

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

View File

@ -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 statetransport 映射时必须丢弃。
type UserProfile struct {
AppCode string
PoolID string
IdentityType string
IdentityKey string
StrategyUserID int64
InternalUserID int64
ExternalUserID string
RuleVersion int64
StrategyVersion string
Stage string
PaidDraws int64
EquivalentDraws int64
LossStreak int64
PendingSpendJackpotTokens int64
GuaranteeDrawsRemaining int64
DownweightActive bool
DownweightFactorPPM int64
User24HourRTPThresholdPPM int64
OneHour UserProfileWindow
TwentyFourHour UserProfileWindow
FortyEightHour UserProfileWindow
Lifetime UserProfileWindow
BaseRewardCoins int64
OrdinaryWinCount int64
HighMultiplierOrdinaryWinCount int64
RTPCompensationJackpotCount int64
CumulativeSpendJackpotCount int64
GuaranteeHitCount int64
ZeroDrawCount int64
GrantedDrawCount int64
PendingDrawCount int64
FailedDrawCount int64
MaxMultiplierPPM int64
MaxRewardCoins int64
FirstDrawAtMS int64
LastDrawAtMS int64
AggregatedAtMS int64
DataLagMS int64
}
type UserProfileMultiplierStat struct {
HitType string
MultiplierPPM int64
DrawCount int64
WagerCoins int64
PayoutCoins int64
}
type UserProfileJackpotCondition struct {
Name string
Numerator int64
Denominator int64
RatioPPM int64
LimitPPM int64
FactorPPM int64
Passed bool
Reason string
}
type UserProfileJackpotHit struct {
// SourceType 只参与 owner service 的稳定 keyset 游标,不通过 protobuf/HTTP 对外展示。
SourceType string
DrawID string
RequestID string
RuleVersion int64
Stage string
Mechanism string
ReasonCode string
ReasonSummary string
TierID string
MultiplierPPM int64
WagerCoins int64
PayoutCoins int64
OccurredAtMS int64
Conditions []UserProfileJackpotCondition
}
type UserProfileDetail struct {
Profile UserProfile
MultiplierDistribution []UserProfileMultiplierStat
JackpotHits []UserProfileJackpotHit
JackpotHitTotal int64
JackpotPage int32
JackpotPageSize int32
NextJackpotCursor string
JackpotHasMore bool
}
// ExternalDrawCommand 是外部 App “已扣费后请求抽奖”的业务事实。 // 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任何重试都必须返回同一条抽奖结果。

View File

@ -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 仍负责持久 outboxactivity 只做成功落库后的低延迟余额 IM。 // WalletClient 是幸运礼物返奖唯一账务依赖wallet-service 仍负责持久 outboxactivity 只做成功落库后的低延迟余额 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"`

View File

@ -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) {

View File

@ -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)
} }

View File

@ -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
} }

View File

@ -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,