修复机器人创建和转盘

This commit is contained in:
zhx 2026-06-18 17:13:56 +08:00
parent a0c9d055f0
commit db43da8c85
45 changed files with 10107 additions and 3614 deletions

File diff suppressed because it is too large Load Diff

View File

@ -1886,6 +1886,134 @@ message GetLuckyGiftDrawSummaryResponse {
LuckyGiftDrawSummary summary = 1;
}
message WheelPrizeTier {
string tier_id = 1;
string display_name = 2;
string reward_type = 3;
string reward_id = 4;
int64 reward_count = 5;
int64 reward_coins = 6;
int64 rtp_value_coins = 7;
int64 weight_ppm = 8;
bool enabled = 9;
string metadata_json = 10;
}
message WheelRuleConfig {
string app_code = 1;
string wheel_id = 2;
int64 rule_version = 3;
bool enabled = 4;
int64 draw_price_coins = 5;
int64 target_rtp_ppm = 6;
int64 pool_rate_ppm = 7;
int64 settlement_window_draws = 8;
int64 initial_pool_coins = 9;
int64 pool_reserve_coins = 10;
int64 max_single_rtp_payout = 11;
int64 effective_from_ms = 12;
int64 created_by_admin_id = 13;
int64 created_at_ms = 14;
repeated WheelPrizeTier tiers = 15;
}
message WheelDrawMeta {
RequestMeta meta = 1;
string command_id = 2;
string wheel_id = 3;
int64 user_id = 4;
string device_id = 5;
int32 draw_count = 6;
int64 coin_spent = 7;
int64 paid_at_ms = 8;
int64 visible_region_id = 9;
}
message WheelDrawResult {
string draw_id = 1;
repeated string draw_ids = 2;
string command_id = 3;
string wheel_id = 4;
int64 rule_version = 5;
string selected_tier_id = 6;
string reward_type = 7;
string reward_id = 8;
int64 reward_count = 9;
int64 reward_coins = 10;
int64 rtp_value_coins = 11;
string reward_status = 12;
int64 rtp_window_index = 13;
int64 actual_rtp_ppm = 14;
int64 created_at_ms = 15;
string wallet_transaction_id = 16;
int64 coin_balance_after = 17;
string metadata_json = 18;
}
message ExecuteWheelDrawRequest {
WheelDrawMeta wheel = 1;
}
message ExecuteWheelDrawResponse {
WheelDrawResult result = 1;
}
message GetWheelConfigRequest {
RequestMeta meta = 1;
string wheel_id = 2;
}
message GetWheelConfigResponse {
WheelRuleConfig config = 1;
}
message UpsertWheelConfigRequest {
RequestMeta meta = 1;
WheelRuleConfig config = 2;
int64 operator_admin_id = 3;
}
message UpsertWheelConfigResponse {
WheelRuleConfig config = 1;
}
message ListWheelDrawsRequest {
RequestMeta meta = 1;
string wheel_id = 2;
int64 user_id = 3;
string status = 4;
int32 page = 5;
int32 page_size = 6;
}
message ListWheelDrawsResponse {
repeated WheelDrawResult draws = 1;
int64 total = 2;
}
message WheelDrawSummary {
string wheel_id = 1;
int64 total_draws = 2;
int64 unique_users = 3;
int64 total_spent_coins = 4;
int64 total_rtp_value_coins = 5;
int64 actual_rtp_ppm = 6;
int64 pending_draws = 7;
int64 granted_draws = 8;
int64 failed_draws = 9;
}
message GetWheelDrawSummaryRequest {
RequestMeta meta = 1;
string wheel_id = 2;
int64 user_id = 3;
string status = 4;
}
message GetWheelDrawSummaryResponse {
WheelDrawSummary summary = 1;
}
// WeeklyStarGift
message WeeklyStarGift {
string gift_id = 1;
@ -2365,6 +2493,11 @@ service LuckyGiftService {
rpc ExecuteLuckyGiftDraw(ExecuteLuckyGiftDrawRequest) returns (ExecuteLuckyGiftDrawResponse);
}
// WheelService owns wheel draw decisions after wallet debit succeeds.
service WheelService {
rpc ExecuteWheelDraw(ExecuteWheelDrawRequest) returns (ExecuteWheelDrawResponse);
}
// RoomTurnoverRewardService owns App reads for the weekly room turnover reward activity.
service RoomTurnoverRewardService {
rpc GetRoomTurnoverRewardStatus(GetRoomTurnoverRewardStatusRequest) returns (GetRoomTurnoverRewardStatusResponse);
@ -2536,3 +2669,11 @@ service AdminLuckyGiftService {
rpc ListLuckyGiftDraws(ListLuckyGiftDrawsRequest) returns (ListLuckyGiftDrawsResponse);
rpc GetLuckyGiftDrawSummary(GetLuckyGiftDrawSummaryRequest) returns (GetLuckyGiftDrawSummaryResponse);
}
// AdminWheelService is the admin entry for wheel rule versions, draw records, and RTP statistics.
service AdminWheelService {
rpc GetWheelConfig(GetWheelConfigRequest) returns (GetWheelConfigResponse);
rpc UpsertWheelConfig(UpsertWheelConfigRequest) returns (UpsertWheelConfigResponse);
rpc ListWheelDraws(ListWheelDrawsRequest) returns (ListWheelDrawsResponse);
rpc GetWheelDrawSummary(GetWheelDrawSummaryRequest) returns (GetWheelDrawSummaryResponse);
}

View File

@ -1710,6 +1710,112 @@ var LuckyGiftService_ServiceDesc = grpc.ServiceDesc{
Metadata: "proto/activity/v1/activity.proto",
}
const (
WheelService_ExecuteWheelDraw_FullMethodName = "/hyapp.activity.v1.WheelService/ExecuteWheelDraw"
)
// WheelServiceClient is the client API for WheelService service.
//
// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream.
//
// WheelService owns wheel draw decisions after wallet debit succeeds.
type WheelServiceClient interface {
ExecuteWheelDraw(ctx context.Context, in *ExecuteWheelDrawRequest, opts ...grpc.CallOption) (*ExecuteWheelDrawResponse, error)
}
type wheelServiceClient struct {
cc grpc.ClientConnInterface
}
func NewWheelServiceClient(cc grpc.ClientConnInterface) WheelServiceClient {
return &wheelServiceClient{cc}
}
func (c *wheelServiceClient) ExecuteWheelDraw(ctx context.Context, in *ExecuteWheelDrawRequest, opts ...grpc.CallOption) (*ExecuteWheelDrawResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(ExecuteWheelDrawResponse)
err := c.cc.Invoke(ctx, WheelService_ExecuteWheelDraw_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
// WheelServiceServer is the server API for WheelService service.
// All implementations must embed UnimplementedWheelServiceServer
// for forward compatibility.
//
// WheelService owns wheel draw decisions after wallet debit succeeds.
type WheelServiceServer interface {
ExecuteWheelDraw(context.Context, *ExecuteWheelDrawRequest) (*ExecuteWheelDrawResponse, error)
mustEmbedUnimplementedWheelServiceServer()
}
// UnimplementedWheelServiceServer must be embedded to have
// forward compatible implementations.
//
// NOTE: this should be embedded by value instead of pointer to avoid a nil
// pointer dereference when methods are called.
type UnimplementedWheelServiceServer struct{}
func (UnimplementedWheelServiceServer) ExecuteWheelDraw(context.Context, *ExecuteWheelDrawRequest) (*ExecuteWheelDrawResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method ExecuteWheelDraw not implemented")
}
func (UnimplementedWheelServiceServer) mustEmbedUnimplementedWheelServiceServer() {}
func (UnimplementedWheelServiceServer) testEmbeddedByValue() {}
// UnsafeWheelServiceServer may be embedded to opt out of forward compatibility for this service.
// Use of this interface is not recommended, as added methods to WheelServiceServer will
// result in compilation errors.
type UnsafeWheelServiceServer interface {
mustEmbedUnimplementedWheelServiceServer()
}
func RegisterWheelServiceServer(s grpc.ServiceRegistrar, srv WheelServiceServer) {
// If the following call pancis, it indicates UnimplementedWheelServiceServer was
// embedded by pointer and is nil. This will cause panics if an
// unimplemented method is ever invoked, so we test this at initialization
// time to prevent it from happening at runtime later due to I/O.
if t, ok := srv.(interface{ testEmbeddedByValue() }); ok {
t.testEmbeddedByValue()
}
s.RegisterService(&WheelService_ServiceDesc, srv)
}
func _WheelService_ExecuteWheelDraw_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(ExecuteWheelDrawRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(WheelServiceServer).ExecuteWheelDraw(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: WheelService_ExecuteWheelDraw_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(WheelServiceServer).ExecuteWheelDraw(ctx, req.(*ExecuteWheelDrawRequest))
}
return interceptor(ctx, in, info, handler)
}
// WheelService_ServiceDesc is the grpc.ServiceDesc for WheelService service.
// It's only intended for direct use with grpc.RegisterService,
// and not to be introspected or modified (even as a copy)
var WheelService_ServiceDesc = grpc.ServiceDesc{
ServiceName: "hyapp.activity.v1.WheelService",
HandlerType: (*WheelServiceServer)(nil),
Methods: []grpc.MethodDesc{
{
MethodName: "ExecuteWheelDraw",
Handler: _WheelService_ExecuteWheelDraw_Handler,
},
},
Streams: []grpc.StreamDesc{},
Metadata: "proto/activity/v1/activity.proto",
}
const (
RoomTurnoverRewardService_GetRoomTurnoverRewardStatus_FullMethodName = "/hyapp.activity.v1.RoomTurnoverRewardService/GetRoomTurnoverRewardStatus"
)
@ -6247,3 +6353,223 @@ var AdminLuckyGiftService_ServiceDesc = grpc.ServiceDesc{
Streams: []grpc.StreamDesc{},
Metadata: "proto/activity/v1/activity.proto",
}
const (
AdminWheelService_GetWheelConfig_FullMethodName = "/hyapp.activity.v1.AdminWheelService/GetWheelConfig"
AdminWheelService_UpsertWheelConfig_FullMethodName = "/hyapp.activity.v1.AdminWheelService/UpsertWheelConfig"
AdminWheelService_ListWheelDraws_FullMethodName = "/hyapp.activity.v1.AdminWheelService/ListWheelDraws"
AdminWheelService_GetWheelDrawSummary_FullMethodName = "/hyapp.activity.v1.AdminWheelService/GetWheelDrawSummary"
)
// AdminWheelServiceClient is the client API for AdminWheelService service.
//
// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream.
//
// AdminWheelService is the admin entry for wheel rule versions, draw records, and RTP statistics.
type AdminWheelServiceClient interface {
GetWheelConfig(ctx context.Context, in *GetWheelConfigRequest, opts ...grpc.CallOption) (*GetWheelConfigResponse, error)
UpsertWheelConfig(ctx context.Context, in *UpsertWheelConfigRequest, opts ...grpc.CallOption) (*UpsertWheelConfigResponse, error)
ListWheelDraws(ctx context.Context, in *ListWheelDrawsRequest, opts ...grpc.CallOption) (*ListWheelDrawsResponse, error)
GetWheelDrawSummary(ctx context.Context, in *GetWheelDrawSummaryRequest, opts ...grpc.CallOption) (*GetWheelDrawSummaryResponse, error)
}
type adminWheelServiceClient struct {
cc grpc.ClientConnInterface
}
func NewAdminWheelServiceClient(cc grpc.ClientConnInterface) AdminWheelServiceClient {
return &adminWheelServiceClient{cc}
}
func (c *adminWheelServiceClient) GetWheelConfig(ctx context.Context, in *GetWheelConfigRequest, opts ...grpc.CallOption) (*GetWheelConfigResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(GetWheelConfigResponse)
err := c.cc.Invoke(ctx, AdminWheelService_GetWheelConfig_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *adminWheelServiceClient) UpsertWheelConfig(ctx context.Context, in *UpsertWheelConfigRequest, opts ...grpc.CallOption) (*UpsertWheelConfigResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(UpsertWheelConfigResponse)
err := c.cc.Invoke(ctx, AdminWheelService_UpsertWheelConfig_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *adminWheelServiceClient) ListWheelDraws(ctx context.Context, in *ListWheelDrawsRequest, opts ...grpc.CallOption) (*ListWheelDrawsResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(ListWheelDrawsResponse)
err := c.cc.Invoke(ctx, AdminWheelService_ListWheelDraws_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *adminWheelServiceClient) GetWheelDrawSummary(ctx context.Context, in *GetWheelDrawSummaryRequest, opts ...grpc.CallOption) (*GetWheelDrawSummaryResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(GetWheelDrawSummaryResponse)
err := c.cc.Invoke(ctx, AdminWheelService_GetWheelDrawSummary_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
// AdminWheelServiceServer is the server API for AdminWheelService service.
// All implementations must embed UnimplementedAdminWheelServiceServer
// for forward compatibility.
//
// AdminWheelService is the admin entry for wheel rule versions, draw records, and RTP statistics.
type AdminWheelServiceServer interface {
GetWheelConfig(context.Context, *GetWheelConfigRequest) (*GetWheelConfigResponse, error)
UpsertWheelConfig(context.Context, *UpsertWheelConfigRequest) (*UpsertWheelConfigResponse, error)
ListWheelDraws(context.Context, *ListWheelDrawsRequest) (*ListWheelDrawsResponse, error)
GetWheelDrawSummary(context.Context, *GetWheelDrawSummaryRequest) (*GetWheelDrawSummaryResponse, error)
mustEmbedUnimplementedAdminWheelServiceServer()
}
// UnimplementedAdminWheelServiceServer must be embedded to have
// forward compatible implementations.
//
// NOTE: this should be embedded by value instead of pointer to avoid a nil
// pointer dereference when methods are called.
type UnimplementedAdminWheelServiceServer struct{}
func (UnimplementedAdminWheelServiceServer) GetWheelConfig(context.Context, *GetWheelConfigRequest) (*GetWheelConfigResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method GetWheelConfig not implemented")
}
func (UnimplementedAdminWheelServiceServer) UpsertWheelConfig(context.Context, *UpsertWheelConfigRequest) (*UpsertWheelConfigResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method UpsertWheelConfig not implemented")
}
func (UnimplementedAdminWheelServiceServer) ListWheelDraws(context.Context, *ListWheelDrawsRequest) (*ListWheelDrawsResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method ListWheelDraws not implemented")
}
func (UnimplementedAdminWheelServiceServer) GetWheelDrawSummary(context.Context, *GetWheelDrawSummaryRequest) (*GetWheelDrawSummaryResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method GetWheelDrawSummary not implemented")
}
func (UnimplementedAdminWheelServiceServer) mustEmbedUnimplementedAdminWheelServiceServer() {}
func (UnimplementedAdminWheelServiceServer) testEmbeddedByValue() {}
// UnsafeAdminWheelServiceServer may be embedded to opt out of forward compatibility for this service.
// Use of this interface is not recommended, as added methods to AdminWheelServiceServer will
// result in compilation errors.
type UnsafeAdminWheelServiceServer interface {
mustEmbedUnimplementedAdminWheelServiceServer()
}
func RegisterAdminWheelServiceServer(s grpc.ServiceRegistrar, srv AdminWheelServiceServer) {
// If the following call pancis, it indicates UnimplementedAdminWheelServiceServer was
// embedded by pointer and is nil. This will cause panics if an
// unimplemented method is ever invoked, so we test this at initialization
// time to prevent it from happening at runtime later due to I/O.
if t, ok := srv.(interface{ testEmbeddedByValue() }); ok {
t.testEmbeddedByValue()
}
s.RegisterService(&AdminWheelService_ServiceDesc, srv)
}
func _AdminWheelService_GetWheelConfig_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(GetWheelConfigRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(AdminWheelServiceServer).GetWheelConfig(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: AdminWheelService_GetWheelConfig_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(AdminWheelServiceServer).GetWheelConfig(ctx, req.(*GetWheelConfigRequest))
}
return interceptor(ctx, in, info, handler)
}
func _AdminWheelService_UpsertWheelConfig_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(UpsertWheelConfigRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(AdminWheelServiceServer).UpsertWheelConfig(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: AdminWheelService_UpsertWheelConfig_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(AdminWheelServiceServer).UpsertWheelConfig(ctx, req.(*UpsertWheelConfigRequest))
}
return interceptor(ctx, in, info, handler)
}
func _AdminWheelService_ListWheelDraws_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(ListWheelDrawsRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(AdminWheelServiceServer).ListWheelDraws(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: AdminWheelService_ListWheelDraws_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(AdminWheelServiceServer).ListWheelDraws(ctx, req.(*ListWheelDrawsRequest))
}
return interceptor(ctx, in, info, handler)
}
func _AdminWheelService_GetWheelDrawSummary_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(GetWheelDrawSummaryRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(AdminWheelServiceServer).GetWheelDrawSummary(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: AdminWheelService_GetWheelDrawSummary_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(AdminWheelServiceServer).GetWheelDrawSummary(ctx, req.(*GetWheelDrawSummaryRequest))
}
return interceptor(ctx, in, info, handler)
}
// AdminWheelService_ServiceDesc is the grpc.ServiceDesc for AdminWheelService service.
// It's only intended for direct use with grpc.RegisterService,
// and not to be introspected or modified (even as a copy)
var AdminWheelService_ServiceDesc = grpc.ServiceDesc{
ServiceName: "hyapp.activity.v1.AdminWheelService",
HandlerType: (*AdminWheelServiceServer)(nil),
Methods: []grpc.MethodDesc{
{
MethodName: "GetWheelConfig",
Handler: _AdminWheelService_GetWheelConfig_Handler,
},
{
MethodName: "UpsertWheelConfig",
Handler: _AdminWheelService_UpsertWheelConfig_Handler,
},
{
MethodName: "ListWheelDraws",
Handler: _AdminWheelService_ListWheelDraws_Handler,
},
{
MethodName: "GetWheelDrawSummary",
Handler: _AdminWheelService_GetWheelDrawSummary_Handler,
},
},
Streams: []grpc.StreamDesc{},
Metadata: "proto/activity/v1/activity.proto",
}

File diff suppressed because it is too large Load Diff

View File

@ -1471,6 +1471,22 @@ message DebitCPBreakupFeeResponse {
AssetBalance balance = 4;
}
message DebitWheelDrawRequest {
string command_id = 1;
string app_code = 2;
int64 user_id = 3;
string wheel_id = 4;
int32 draw_count = 5;
int64 amount = 6;
}
message DebitWheelDrawResponse {
string transaction_id = 1;
int64 coin_spent = 2;
int64 coin_balance_after = 3;
AssetBalance balance = 4;
}
message GrantVipRequest {
string command_id = 1;
string app_code = 2;
@ -1563,6 +1579,27 @@ message CreditLuckyGiftRewardResponse {
int64 granted_at_ms = 4;
}
// CreditWheelRewardRequest activity-service outbox
message CreditWheelRewardRequest {
string command_id = 1;
string app_code = 2;
int64 target_user_id = 3;
int64 amount = 4;
string draw_id = 5;
string wheel_id = 6;
string selected_tier_id = 7;
string reason = 8;
int64 visible_region_id = 9;
}
// CreditWheelRewardResponse COIN
message CreditWheelRewardResponse {
string transaction_id = 1;
AssetBalance balance = 2;
int64 amount = 3;
int64 granted_at_ms = 4;
}
// CreditRoomTurnoverRewardRequest activity-service
message CreditRoomTurnoverRewardRequest {
string command_id = 1;
@ -1928,11 +1965,13 @@ service WalletService {
rpc GetMyVip(GetMyVipRequest) returns (GetMyVipResponse);
rpc PurchaseVip(PurchaseVipRequest) returns (PurchaseVipResponse);
rpc DebitCPBreakupFee(DebitCPBreakupFeeRequest) returns (DebitCPBreakupFeeResponse);
rpc DebitWheelDraw(DebitWheelDrawRequest) returns (DebitWheelDrawResponse);
rpc GrantVip(GrantVipRequest) returns (GrantVipResponse);
rpc ListAdminVipLevels(ListAdminVipLevelsRequest) returns (ListAdminVipLevelsResponse);
rpc UpdateAdminVipLevels(UpdateAdminVipLevelsRequest) returns (UpdateAdminVipLevelsResponse);
rpc CreditTaskReward(CreditTaskRewardRequest) returns (CreditTaskRewardResponse);
rpc CreditLuckyGiftReward(CreditLuckyGiftRewardRequest) returns (CreditLuckyGiftRewardResponse);
rpc CreditWheelReward(CreditWheelRewardRequest) returns (CreditWheelRewardResponse);
rpc CreditRoomTurnoverReward(CreditRoomTurnoverRewardRequest) returns (CreditRoomTurnoverRewardResponse);
rpc CreditInviteActivityReward(CreditInviteActivityRewardRequest) returns (CreditInviteActivityRewardResponse);
rpc CreditAgencyOpeningReward(CreditAgencyOpeningRewardRequest) returns (CreditAgencyOpeningRewardResponse);

View File

@ -265,11 +265,13 @@ const (
WalletService_GetMyVip_FullMethodName = "/hyapp.wallet.v1.WalletService/GetMyVip"
WalletService_PurchaseVip_FullMethodName = "/hyapp.wallet.v1.WalletService/PurchaseVip"
WalletService_DebitCPBreakupFee_FullMethodName = "/hyapp.wallet.v1.WalletService/DebitCPBreakupFee"
WalletService_DebitWheelDraw_FullMethodName = "/hyapp.wallet.v1.WalletService/DebitWheelDraw"
WalletService_GrantVip_FullMethodName = "/hyapp.wallet.v1.WalletService/GrantVip"
WalletService_ListAdminVipLevels_FullMethodName = "/hyapp.wallet.v1.WalletService/ListAdminVipLevels"
WalletService_UpdateAdminVipLevels_FullMethodName = "/hyapp.wallet.v1.WalletService/UpdateAdminVipLevels"
WalletService_CreditTaskReward_FullMethodName = "/hyapp.wallet.v1.WalletService/CreditTaskReward"
WalletService_CreditLuckyGiftReward_FullMethodName = "/hyapp.wallet.v1.WalletService/CreditLuckyGiftReward"
WalletService_CreditWheelReward_FullMethodName = "/hyapp.wallet.v1.WalletService/CreditWheelReward"
WalletService_CreditRoomTurnoverReward_FullMethodName = "/hyapp.wallet.v1.WalletService/CreditRoomTurnoverReward"
WalletService_CreditInviteActivityReward_FullMethodName = "/hyapp.wallet.v1.WalletService/CreditInviteActivityReward"
WalletService_CreditAgencyOpeningReward_FullMethodName = "/hyapp.wallet.v1.WalletService/CreditAgencyOpeningReward"
@ -354,11 +356,13 @@ type WalletServiceClient interface {
GetMyVip(ctx context.Context, in *GetMyVipRequest, opts ...grpc.CallOption) (*GetMyVipResponse, error)
PurchaseVip(ctx context.Context, in *PurchaseVipRequest, opts ...grpc.CallOption) (*PurchaseVipResponse, error)
DebitCPBreakupFee(ctx context.Context, in *DebitCPBreakupFeeRequest, opts ...grpc.CallOption) (*DebitCPBreakupFeeResponse, error)
DebitWheelDraw(ctx context.Context, in *DebitWheelDrawRequest, opts ...grpc.CallOption) (*DebitWheelDrawResponse, error)
GrantVip(ctx context.Context, in *GrantVipRequest, opts ...grpc.CallOption) (*GrantVipResponse, error)
ListAdminVipLevels(ctx context.Context, in *ListAdminVipLevelsRequest, opts ...grpc.CallOption) (*ListAdminVipLevelsResponse, error)
UpdateAdminVipLevels(ctx context.Context, in *UpdateAdminVipLevelsRequest, opts ...grpc.CallOption) (*UpdateAdminVipLevelsResponse, error)
CreditTaskReward(ctx context.Context, in *CreditTaskRewardRequest, opts ...grpc.CallOption) (*CreditTaskRewardResponse, error)
CreditLuckyGiftReward(ctx context.Context, in *CreditLuckyGiftRewardRequest, opts ...grpc.CallOption) (*CreditLuckyGiftRewardResponse, error)
CreditWheelReward(ctx context.Context, in *CreditWheelRewardRequest, opts ...grpc.CallOption) (*CreditWheelRewardResponse, error)
CreditRoomTurnoverReward(ctx context.Context, in *CreditRoomTurnoverRewardRequest, opts ...grpc.CallOption) (*CreditRoomTurnoverRewardResponse, error)
CreditInviteActivityReward(ctx context.Context, in *CreditInviteActivityRewardRequest, opts ...grpc.CallOption) (*CreditInviteActivityRewardResponse, error)
CreditAgencyOpeningReward(ctx context.Context, in *CreditAgencyOpeningRewardRequest, opts ...grpc.CallOption) (*CreditAgencyOpeningRewardResponse, error)
@ -1021,6 +1025,16 @@ func (c *walletServiceClient) DebitCPBreakupFee(ctx context.Context, in *DebitCP
return out, nil
}
func (c *walletServiceClient) DebitWheelDraw(ctx context.Context, in *DebitWheelDrawRequest, opts ...grpc.CallOption) (*DebitWheelDrawResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(DebitWheelDrawResponse)
err := c.cc.Invoke(ctx, WalletService_DebitWheelDraw_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *walletServiceClient) GrantVip(ctx context.Context, in *GrantVipRequest, opts ...grpc.CallOption) (*GrantVipResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(GrantVipResponse)
@ -1071,6 +1085,16 @@ func (c *walletServiceClient) CreditLuckyGiftReward(ctx context.Context, in *Cre
return out, nil
}
func (c *walletServiceClient) CreditWheelReward(ctx context.Context, in *CreditWheelRewardRequest, opts ...grpc.CallOption) (*CreditWheelRewardResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(CreditWheelRewardResponse)
err := c.cc.Invoke(ctx, WalletService_CreditWheelReward_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *walletServiceClient) CreditRoomTurnoverReward(ctx context.Context, in *CreditRoomTurnoverRewardRequest, opts ...grpc.CallOption) (*CreditRoomTurnoverRewardResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(CreditRoomTurnoverRewardResponse)
@ -1261,11 +1285,13 @@ type WalletServiceServer interface {
GetMyVip(context.Context, *GetMyVipRequest) (*GetMyVipResponse, error)
PurchaseVip(context.Context, *PurchaseVipRequest) (*PurchaseVipResponse, error)
DebitCPBreakupFee(context.Context, *DebitCPBreakupFeeRequest) (*DebitCPBreakupFeeResponse, error)
DebitWheelDraw(context.Context, *DebitWheelDrawRequest) (*DebitWheelDrawResponse, error)
GrantVip(context.Context, *GrantVipRequest) (*GrantVipResponse, error)
ListAdminVipLevels(context.Context, *ListAdminVipLevelsRequest) (*ListAdminVipLevelsResponse, error)
UpdateAdminVipLevels(context.Context, *UpdateAdminVipLevelsRequest) (*UpdateAdminVipLevelsResponse, error)
CreditTaskReward(context.Context, *CreditTaskRewardRequest) (*CreditTaskRewardResponse, error)
CreditLuckyGiftReward(context.Context, *CreditLuckyGiftRewardRequest) (*CreditLuckyGiftRewardResponse, error)
CreditWheelReward(context.Context, *CreditWheelRewardRequest) (*CreditWheelRewardResponse, error)
CreditRoomTurnoverReward(context.Context, *CreditRoomTurnoverRewardRequest) (*CreditRoomTurnoverRewardResponse, error)
CreditInviteActivityReward(context.Context, *CreditInviteActivityRewardRequest) (*CreditInviteActivityRewardResponse, error)
CreditAgencyOpeningReward(context.Context, *CreditAgencyOpeningRewardRequest) (*CreditAgencyOpeningRewardResponse, error)
@ -1480,6 +1506,9 @@ func (UnimplementedWalletServiceServer) PurchaseVip(context.Context, *PurchaseVi
func (UnimplementedWalletServiceServer) DebitCPBreakupFee(context.Context, *DebitCPBreakupFeeRequest) (*DebitCPBreakupFeeResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method DebitCPBreakupFee not implemented")
}
func (UnimplementedWalletServiceServer) DebitWheelDraw(context.Context, *DebitWheelDrawRequest) (*DebitWheelDrawResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method DebitWheelDraw not implemented")
}
func (UnimplementedWalletServiceServer) GrantVip(context.Context, *GrantVipRequest) (*GrantVipResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method GrantVip not implemented")
}
@ -1495,6 +1524,9 @@ func (UnimplementedWalletServiceServer) CreditTaskReward(context.Context, *Credi
func (UnimplementedWalletServiceServer) CreditLuckyGiftReward(context.Context, *CreditLuckyGiftRewardRequest) (*CreditLuckyGiftRewardResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method CreditLuckyGiftReward not implemented")
}
func (UnimplementedWalletServiceServer) CreditWheelReward(context.Context, *CreditWheelRewardRequest) (*CreditWheelRewardResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method CreditWheelReward not implemented")
}
func (UnimplementedWalletServiceServer) CreditRoomTurnoverReward(context.Context, *CreditRoomTurnoverRewardRequest) (*CreditRoomTurnoverRewardResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method CreditRoomTurnoverReward not implemented")
}
@ -2704,6 +2736,24 @@ func _WalletService_DebitCPBreakupFee_Handler(srv interface{}, ctx context.Conte
return interceptor(ctx, in, info, handler)
}
func _WalletService_DebitWheelDraw_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(DebitWheelDrawRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(WalletServiceServer).DebitWheelDraw(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: WalletService_DebitWheelDraw_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(WalletServiceServer).DebitWheelDraw(ctx, req.(*DebitWheelDrawRequest))
}
return interceptor(ctx, in, info, handler)
}
func _WalletService_GrantVip_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(GrantVipRequest)
if err := dec(in); err != nil {
@ -2794,6 +2844,24 @@ func _WalletService_CreditLuckyGiftReward_Handler(srv interface{}, ctx context.C
return interceptor(ctx, in, info, handler)
}
func _WalletService_CreditWheelReward_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(CreditWheelRewardRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(WalletServiceServer).CreditWheelReward(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: WalletService_CreditWheelReward_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(WalletServiceServer).CreditWheelReward(ctx, req.(*CreditWheelRewardRequest))
}
return interceptor(ctx, in, info, handler)
}
func _WalletService_CreditRoomTurnoverReward_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(CreditRoomTurnoverRewardRequest)
if err := dec(in); err != nil {
@ -3273,6 +3341,10 @@ var WalletService_ServiceDesc = grpc.ServiceDesc{
MethodName: "DebitCPBreakupFee",
Handler: _WalletService_DebitCPBreakupFee_Handler,
},
{
MethodName: "DebitWheelDraw",
Handler: _WalletService_DebitWheelDraw_Handler,
},
{
MethodName: "GrantVip",
Handler: _WalletService_GrantVip_Handler,
@ -3293,6 +3365,10 @@ var WalletService_ServiceDesc = grpc.ServiceDesc{
MethodName: "CreditLuckyGiftReward",
Handler: _WalletService_CreditLuckyGiftReward_Handler,
},
{
MethodName: "CreditWheelReward",
Handler: _WalletService_CreditWheelReward_Handler,
},
{
MethodName: "CreditRoomTurnoverReward",
Handler: _WalletService_CreditRoomTurnoverReward_Handler,

View File

@ -0,0 +1,444 @@
# 通用抽奖引擎技术文档
本文档描述当前服务端通用抽奖能力的代码结构、运行口径和转盘落地方式。抽奖通用能力目前由 `activity-service` 持有,钱包入账由 `wallet-service` 持有。幸运礼物已经复用通用权重选择转盘新增独立配置、抽奖记录、RTP 统计、钱包 reason 和后台查询入口。
## 一、边界和结论
| 模块 | Owner | 当前职责 | 不做什么 | 代码位置 |
| --- | --- | --- | --- | --- |
| 通用抽奖引擎 | `activity-service` | 候选归一化、权重随机、奖品类型归一化、RTP 计值归一化 | 不读取数据库、不判断资格、不改奖池、不发奖 | `services/activity-service/internal/domain/lotteryengine/engine.go` |
| 幸运礼物 | `activity-service` | 继续拥有幸运礼物规则、奖池、RTP 窗口、风控、记录和 outbox候选随机改为调用通用引擎 | 不改变原有幸运礼物 RTP 观察和不可支付过滤逻辑 | `services/activity-service/internal/storage/mysql/lucky_gift_repository.go` |
| 转盘 | `activity-service` | 独立规则版本、奖档、奖池、RTP 窗口、抽奖记录、后台统计、金币快路径发奖 | 不复用幸运礼物表;不把道具/装扮价值计入 RTP | `services/activity-service/internal/domain/wheel``internal/service/wheel``internal/storage/mysql/wheel_repository.go` |
| 钱包 reason | `wallet-service` | 新增 `wheel_reward` 入账 reason、交易、分录、钱包 outbox | 不判断转盘中奖资格,不计算 RTP | `services/wallet-service/internal/service/wallet/service.go``internal/storage/mysql/repository.go` |
| Proto/gRPC | `api/proto` + transport | 暴露转盘抽奖和后台配置/记录/统计 RPC | 当前未新增 gateway HTTP 包装 | `api/proto/activity/v1/activity.proto``services/activity-service/internal/transport/grpc/wheel_server.go` |
核心原则:
1. 通用引擎只负责“从已过滤候选里按权重选中一个奖档”。
2. 业务模块必须在调用引擎前完成资格、价格、奖池、风控和预算过滤。
3. RTP 只使用 `rtp_value_coins` 作为分子;转盘道具和装扮无论配置价值多少,运行态都强制为 0。
4. 金币发放必须走钱包独立 reason `wheel_reward`,不能混用 `lucky_gift_reward`
5. 转盘配置、记录和统计使用 `wheel_*` 表,不能写入幸运礼物表。
## 二、代码索引
### 2.1 通用抽奖引擎
| 文件 | 关键符号 | 说明 |
| --- | --- | --- |
| `services/activity-service/internal/domain/lotteryengine/engine.go` | `Candidate` | 引擎唯一输入视图:`ID``Weight``PrizeKind``RTPValueCoins` |
| 同上 | `NormalizeCandidates` | 复制并清洗候选;负权重拒绝;全部权重为 0 时退化为等权随机 |
| 同上 | `SelectWeighted` | 使用 `crypto/rand` 生成随机下标,按权重选择候选 |
| 同上 | `NormalizePrizeKind` | 把 `coin/gift/prop/dress` 及同义值归一化 |
| 同上 | `NormalizeRTPValueCoins` | 金币/礼物保留非负 RTP 价值;`prop/dress` 强制归零 |
| `services/activity-service/internal/domain/lotteryengine/engine_test.go` | 单测 | 覆盖权重选择、全 0 权重兜底、道具/装扮 RTP 归零 |
### 2.2 转盘代码
| 文件 | 关键符号 | 说明 |
| --- | --- | --- |
| `services/activity-service/internal/domain/wheel/wheel.go` | `RuleConfig``Tier``DrawCommand``DrawResult``DrawSummary` | 转盘领域模型 |
| `services/activity-service/internal/service/wheel/config.go` | `NormalizeRuleConfig``ValidateRuleConfig` | 后台配置发布前归一化和硬边界校验 |
| `services/activity-service/internal/service/wheel/service.go` | `Service.Draw``UpsertConfig``GetConfig``ListDraws``GetDrawSummary` | 转盘用例入口 |
| 同上 | `creditCoinRewardFastPath` | 金币奖品同步调用钱包 `CreditWheelReward`,成功后回写 draw、统计和 outbox |
| `services/activity-service/internal/storage/mysql/wheel_repository.go` | `PublishWheelRuleConfig` | 发布不可变规则版本和奖档 |
| 同上 | `ExecuteWheelDraw` | 转盘抽奖主事务幂等、规则锁定、价格校验、RTP 窗口、奖池、记录、统计、outbox |
| 同上 | `executeSingleWheelDraw` | 单次命中奖档、更新 RTP 窗口、写 draw record 和 outbox |
| 同上 | `wheelPayableCandidates` | 按奖池容量和单次 RTP 上限过滤候选;道具/装扮 RTP 价值归零 |
| 同上 | `ListWheelDraws``GetWheelDrawSummary` | 后台抽奖明细和汇总统计读取 |
| 同上 | `MarkWheelDrawsGranted` | 金币发放成功后回写记录、统计和 `activity_outbox` |
| `services/activity-service/internal/storage/mysql/wheel_schema.go` | `ensureWheelTables` | 运行时兜底创建 `wheel_*` 表 |
| `services/activity-service/deploy/mysql/initdb/001_activity_service.sql` | `wheel_*` DDL | 新环境初始化表结构 |
| `services/activity-service/internal/transport/grpc/wheel_server.go` | `WheelServer``AdminWheelServer` | 转盘 App 抽奖和后台配置/统计 gRPC |
| `services/activity-service/internal/app/services.go` | `wheelservice.New` | activity-service 装配转盘 service |
| `services/activity-service/internal/app/grpc_registration.go` | `RegisterWheelServiceServer``RegisterAdminWheelServiceServer` | 注册转盘 gRPC 服务 |
### 2.3 钱包代码
| 文件 | 关键符号 | 说明 |
| --- | --- | --- |
| `api/proto/wallet/v1/wallet.proto` | `CreditWheelRewardRequest``CreditWheelReward` | 钱包转盘金币奖品入账 RPC |
| `services/wallet-service/internal/domain/ledger/ledger.go` | `WheelRewardCommand``WheelRewardReceipt` | 钱包 service/repository 间领域命令 |
| `services/wallet-service/internal/service/wallet/service.go` | `CreditWheelReward` | 校验转盘金币奖品入账命令,固定钱包职责边界 |
| `services/wallet-service/internal/storage/mysql/repository.go` | `bizTypeWheelReward``CreditWheelReward` | 交易、账户余额、分录和钱包 outbox 同事务提交 |
| 同上 | `wheelRewardRequestHash` | 保证同一转盘 draw 幂等入账 |
| `services/wallet-service/internal/transport/grpc/server.go` | `CreditWheelReward` | gRPC 请求转换为钱包领域命令 |
### 2.4 Proto 契约
| 文件 | RPC/Message | 说明 |
| --- | --- | --- |
| `api/proto/activity/v1/activity.proto` | `WheelService.ExecuteWheelDraw` | 扣费后执行转盘抽奖 |
| 同上 | `AdminWheelService.GetWheelConfig` | 后台读取当前转盘配置 |
| 同上 | `AdminWheelService.UpsertWheelConfig` | 后台发布新规则版本 |
| 同上 | `AdminWheelService.ListWheelDraws` | 后台查看抽奖明细 |
| 同上 | `AdminWheelService.GetWheelDrawSummary` | 后台查看转盘统计汇总 |
| 同上 | `WheelRuleConfig``WheelPrizeTier` | 转盘规则和奖档配置 |
| 同上 | `WheelDrawMeta``WheelDrawResult``WheelDrawSummary` | 抽奖入参、结果和统计 |
| `api/proto/wallet/v1/wallet.proto` | `CreditWheelRewardRequest``CreditWheelRewardResponse` | 转盘金币发放钱包契约 |
## 三、通用引擎模型
### 3.1 Candidate
`lotteryengine.Candidate` 是通用引擎唯一关心的数据:
| 字段 | 含义 | 规则 |
| --- | --- | --- |
| `ID` | 业务奖档 ID | 引擎只原样返回,业务侧负责保证唯一 |
| `Weight` | 权重 | 负数拒绝;正数参与随机;全部为 0 时每个候选权重按 1 处理 |
| `PrizeKind` | 奖品类型 | 归一化为 `coin/gift/prop/dress` 等标准值 |
| `RTPValueCoins` | 计入 RTP 的金币价值 | 负数按 0`prop/dress` 强制为 0 |
引擎不直接使用 `RewardCoins``RewardID`、库存、奖池余额、用户信息。这些都属于业务模块。
### 3.2 权重选择
流程:
1. 调用方传入已过滤候选。
2. `NormalizeCandidates` 清洗权重、奖品类型和 RTP 价值。
3. `SelectWeighted` 汇总权重。
4. 用 `crypto/rand.Int` 生成 `[0,totalWeight)` 的随机数。
5. 按权重区间返回命中的 `Candidate`
设计原因:
| 设计 | 原因 |
| --- | --- |
| 使用 `crypto/rand` | 高价值奖品不能被时间种子或进程启动顺序预测 |
| 全 0 权重兜底为等权 | 兼容旧配置或临时配置错误,避免整个活动不可抽 |
| 负权重直接失败 | 负权重没有业务语义,继续运行会让概率不可解释 |
| 不在引擎内做 RTP 修正 | RTP 修正依赖奖池、预算、用户风险和活动策略,必须留在业务层 |
## 四、转盘配置模型
### 4.1 RuleConfig
`domain/wheel.RuleConfig` 是一个不可变版本快照:
| 字段 | 含义 |
| --- | --- |
| `WheelID` | 转盘 ID空值归一为 `default` |
| `RuleVersion` | 不可变规则版本,每次发布 +1 |
| `Enabled` | 是否启用 |
| `DrawPriceCoins` | 单抽价格 |
| `TargetRTPPPM` | RTP 目标ppm |
| `PoolRatePPM` | 每笔消耗进入转盘奖池比例ppm |
| `SettlementWindowDraws` | RTP 观察窗口抽数 |
| `InitialPoolCoins` | 初始奖池水位 |
| `PoolReserveCoins` | 奖池保底水位 |
| `MaxSingleRTPPayout` | 单次可支付的 RTP 价值上限 |
| `Tiers` | 奖档列表 |
### 4.2 Tier
| 字段 | 含义 | 特殊规则 |
| --- | --- | --- |
| `TierID` | 奖档 ID | 同一规则版本内必须唯一 |
| `DisplayName` | 展示名称快照 | 只用于后台和前端展示 |
| `RewardType` | `coin/gift/prop/dress` | 会调用通用引擎归一化 |
| `RewardID` | 礼物、道具、装扮资源 ID | 非金币奖品必填 |
| `RewardCount` | 奖励数量 | 小于等于 0 时归一为 1 |
| `RewardCoins` | 金币奖励金额 | `coin` 使用 |
| `RTPValueCoins` | 计入 RTP 的价值 | `prop/dress` 强制为 0 |
| `WeightPPM` | 随机权重 | 当前作为权重值使用,不要求总和等于 1000000 |
| `Enabled` | 是否启用 | 至少需要一个启用奖档 |
| `MetadataJSON` | 展示和发放扩展快照 | 空值或非法 JSON 归一为 `{}` |
### 4.3 配置校验
入口:`services/activity-service/internal/service/wheel/config.go`
规则:
1. `draw_price_coins` 必须大于 0。
2. `target_rtp_ppm``pool_rate_ppm` 不能为负。
3. `settlement_window_draws` 必须大于 0。
4. 奖档不能为空。
5. `tier_id` 必须唯一。
6. `weight_ppm` 不能为负。
7. `coin` 奖档的 `reward_coins` 不能为负。
8. `gift/prop/dress` 必须有 `reward_id`
9. `prop/dress``rtp_value_coins` 在归一化后必须为 0。
10. 至少一个奖档启用。
## 五、转盘抽奖链路
### 5.1 调用前提
转盘抽奖 RPC 是扣费后的服务端事实入口:
| 阶段 | Owner | 说明 |
| --- | --- | --- |
| 扣费 | 调用方 + wallet-service | 先扣用户金币,拿到真实 `coin_spent``paid_at_ms` |
| 抽奖 | activity-service | 使用 `ExecuteWheelDraw` 固化中奖结果和 RTP |
| 金币发放 | wallet-service | `coin` 奖品通过 `CreditWheelReward` 入账 |
| 礼物/道具/装扮发放 | 对应资产 owner | 当前只固化 pending 记录和 outbox 事实,后续接资产发放接口 |
转盘 repository 会校验:
```text
coin_spent == draw_price_coins * draw_count
```
这样避免客户端或调用方伪造 RTP 分母。
### 5.2 主事务
入口:`Repository.ExecuteWheelDraw`
事务内顺序:
1. 归一化 `wheel_id``draw_count`
2. 按 `command_id` 收集已有 draw完整存在则幂等返回。
3. 如果只存在部分子抽,返回冲突,避免重复发奖。
4. 锁定当前启用的转盘规则版本。
5. 校验 `coin_spent` 与当前单价一致。
6. 获取或创建 RTP 窗口。
7. 获取或创建转盘奖池。
8. 按 `draw_count` 拆分每次消耗。
9. 每一抽调用 `executeSingleWheelDraw`
10. 汇总写回奖池净变化。
11. 更新后台统计。
12. 提交事务。
### 5.3 单抽
入口:`executeSingleWheelDraw`
单抽顺序:
1. `wheelPayableCandidates` 过滤可支付候选。
2. 组装 `lotteryengine.Candidate`
3. 调用 `lotteryengine.SelectWeighted` 命中奖档。
4. 用命中奖档的 `RTPValueCoins` 更新奖池和 RTP 窗口。
5. 写入 `wheel_draw_records`
6. 如果奖品需要发放,写入 `activity_outbox`,事件类型为 `WheelRewardSettlement`
7. 返回 `DrawResult`
候选过滤口径:
| 过滤项 | 说明 |
| --- | --- |
| `tier.Enabled` | 未启用奖档不参与 |
| `RTPValueCoins` | `prop/dress` 强制为 0 |
| 奖池容量 | `pool.balance - reserve_floor` |
| 单次上限 | `max_single_rtp_payout` 大于 0 时限制容量 |
注意:道具/装扮 `RTPValueCoins=0`,所以不会因为展示价值占用奖池,也不会提高 RTP 分子。
### 5.4 批量抽奖
当前转盘支持 `draw_count`
| 功能 | 代码 | 说明 |
| --- | --- | --- |
| 子命令 ID | `wheelSubCommandID` | 单抽使用原 command多抽追加 `#000001` 这类后缀 |
| 金额拆分 | 复用 `luckyDrawUnitSpend` | 总金额按次数拆分,余数给前几抽 |
| 幂等聚合 | `collectWheelDrawsByCommand``aggregateWheelDrawResults` | 完整存在则聚合返回 |
| 发放限制 | `creditCoinRewardFastPath` | 当前只对单抽金币做同步快路径,多抽保留 pending/outbox |
## 六、RTP 口径
### 6.1 分子和分母
| 项 | 字段 | 来源 |
| --- | --- | --- |
| RTP 分母 | `coin_spent` / `wager_coins` | 用户实际扣费金币 |
| RTP 分子 | `rtp_value_coins` / `actual_rtp_value_coins` | 命中奖档计入 RTP 的价值 |
| 目标值 | `target_rtp_ppm` | 后台规则配置 |
| 当前值 | `actual_rtp_ppm` | `actual_rtp_value_coins * 1000000 / wager_coins` |
### 6.2 奖品类型口径
| 奖品类型 | 是否可配置 | 是否发放 | 是否计入 RTP |
| --- | --- | --- | --- |
| `coin` | 是 | 钱包 `wheel_reward` | 是,使用 `rtp_value_coins` |
| `gift` | 是 | 待接礼物/资源 owner | 是,使用 `rtp_value_coins` |
| `prop` | 是 | 待接道具 owner | 否,强制 0 |
| `dress` | 是 | 待接装扮 owner | 否,强制 0 |
强制归零的位置:
| 层 | 代码 |
| --- | --- |
| 通用引擎 | `lotteryengine.NormalizeRTPValueCoins` |
| 转盘配置 | `wheel.NormalizeRuleConfig` |
| 转盘仓储 | `normalizeWheelTiers``wheelPayableCandidates` |
| 转盘测试 | `services/activity-service/internal/service/wheel/config_test.go``domain/lotteryengine/engine_test.go` |
## 七、发奖和 outbox
### 7.1 金币奖品
金币奖品路径:
1. `ExecuteWheelDraw` 写入 `wheel_draw_records`,初始状态为 `pending`
2. `Service.Draw` 在事务提交后进入 `creditCoinRewardFastPath`
3. 调用 wallet-service `CreditWheelReward`
4. wallet-service 以 `biz_type=wheel_reward` 写交易、账户、分录和 wallet outbox。
5. activity-service 调用 `MarkWheelDrawsGranted`
6. `MarkWheelDrawsGranted` 同事务更新:
- `wheel_draw_records.reward_status = granted`
- `wheel_draw_records.reward_transaction_id`
- `wheel_draw_stats.pending_draws/granted_draws`
- `activity_outbox.status = delivered`
钱包 reason
| 项 | 值 |
| --- | --- |
| wallet biz type | `wheel_reward` |
| RPC reason | `wheel_reward` |
| command ID | `wheel_reward:<draw_id>` |
| 钱包事件 | `WalletWheelRewardCredited` |
### 7.2 礼物/道具/装扮奖品
当前状态:
1. 抽奖结果会落 `wheel_draw_records`
2. `reward_status``pending`
3. `activity_outbox` 会写 `WheelRewardSettlement`
4. 具体发放 worker 和资产 owner RPC 还没有接入。
后续接入时必须遵守:
1. 不允许在 activity-service 直接改钱包或资产表。
2. 发放成功后调用转盘仓储状态回写方法,保持 draw record、统计和 outbox 一致。
3. 道具/装扮发放成功也不能把展示价值写入 RTP。
4. 礼物如果要计入 RTP必须使用抽奖时固化的 `rtp_value_coins`,不能按发放时礼物价格重新估值。
## 八、后台记录和统计
### 8.1 明细
入口:`AdminWheelService.ListWheelDraws`
仓储:`Repository.ListWheelDraws`
支持筛选:
| 字段 | 说明 |
| --- | --- |
| `wheel_id` | 转盘 ID |
| `user_id` | 用户 ID |
| `status` | `pending/granted/failed` |
| `page/page_size` | 分页,`page_size` 最大 100 |
返回字段包括:
`draw_id``command_id``wheel_id``rule_version``selected_tier_id``reward_type``reward_id``reward_count``reward_coins``rtp_value_coins``reward_status``wallet_transaction_id``rtp_window_index``metadata_json`
### 8.2 汇总
入口:`AdminWheelService.GetWheelDrawSummary`
仓储:`Repository.GetWheelDrawSummary`
统计字段:
| 字段 | 说明 |
| --- | --- |
| `total_draws` | 抽奖次数 |
| `unique_users` | 去重用户数 |
| `total_spent_coins` | 总消耗金币 |
| `total_rtp_value_coins` | 计入 RTP 的返奖价值 |
| `actual_rtp_ppm` | 实际 RTP |
| `pending_draws` | 待发放 |
| `granted_draws` | 已发放 |
| `failed_draws` | 失败 |
当只按 `wheel_id` 汇总时,优先读 `wheel_draw_stats`;带 `user_id/status` 过滤时直接聚合 `wheel_draw_records`
## 九、数据表
| 表 | 用途 | 代码 |
| --- | --- | --- |
| `wheel_rule_versions` | 转盘不可变规则版本 | `wheel_schema.go``001_activity_service.sql` |
| `wheel_prize_tiers` | 每个规则版本的奖档快照 | 同上 |
| `wheel_rtp_windows` | RTP 观察窗口 | `getOpenWheelRTPWindow``createWheelRTPWindow` |
| `wheel_pools` | 转盘奖池水位 | `getOrCreateWheelPool``persistWheelPoolDelta` |
| `wheel_draw_records` | 抽奖事实明细 | `executeSingleWheelDraw``ListWheelDraws` |
| `wheel_draw_stats` | 后台汇总统计 | `updateWheelStats``GetWheelDrawSummary` |
| `wheel_draw_stat_users` | 统计去重用户 | `updateWheelStats` |
| `activity_outbox` | 转盘发奖补偿事实 | `insertWheelRewardOutbox``MarkWheelDrawsGranted` |
| `wallet_transactions` | 钱包交易 | `wallet-service/internal/storage/mysql.Repository.CreditWheelReward` |
| `wallet_outbox` | 钱包余额和奖励事实 | 同上 |
## 十、和幸运礼物的关系
幸运礼物不是直接迁移成转盘,也不是把转盘塞进幸运礼物:
| 项 | 幸运礼物 | 转盘 |
| --- | --- | --- |
| 规则维度 | `pool_id`,房间送礼场景 | `wheel_id`H5/活动转盘场景 |
| 奖品 | 金币返奖 | 金币、礼物、道具、装扮 |
| RTP 口径 | 原幸运礼物基础返奖口径 | `rtp_value_coins`,道具/装扮强制 0 |
| 记录表 | `lucky_draw_records` | `wheel_draw_records` |
| 钱包 reason | `lucky_gift_reward` | `wheel_reward` |
| 通用引擎使用 | 候选随机复用 `lotteryengine.SelectWeighted` | 候选随机和 RTP 归一化都复用 |
幸运礼物保留自己的奖池、风控、用户阶段和 room IM 逻辑。通用引擎只替换了“已过滤候选里的权重随机”这一小段,避免改动原业务行为。
## 十一、扩展新抽奖活动的步骤
新增一个抽奖活动时,不要复制幸运礼物或转盘整套逻辑。按下面顺序接入:
1. 新建独立 domain定义自己的 `RuleConfig``Tier``DrawCommand``DrawResult`
2. 新建 service负责入参归一化、配置校验和跨服务调用。
3. 新建 storage负责规则版本、奖池、RTP 窗口、记录、统计和 outbox。
4. 业务层先过滤资格、预算、库存、奖池和风控。
5. 把过滤后的候选转换成 `lotteryengine.Candidate`
6. 调用 `lotteryengine.SelectWeighted`
7. 用返回候选固化抽奖结果。
8. 如果有金币奖品,在 wallet-service 增加独立 reason不复用已有 reason。
9. 如果有非金币奖品,接对应 owner service 的发放接口。
10. 补 proto、gRPC、表结构、initdb 和测试。
必须明确回答的问题:
| 问题 | 必须落地的位置 |
| --- | --- |
| 奖品有哪些类型 | domain `Tier.RewardType` 和 proto |
| 哪些奖品计入 RTP | `RTPValueCoins` 归一化 |
| 哪个服务发奖 | service 跨服务依赖 |
| 钱包 reason 是什么 | wallet-service domain/service/storage/proto |
| 如何幂等 | command ID、draw ID、钱包 request hash |
| 后台查什么 | draw records 和 summary |
| 失败如何补偿 | activity outbox 或 owner outbox |
## 十二、验证命令
当前相关验证命令:
```bash
PATH=/Users/hy/go/bin:$PATH make proto
go test ./services/activity-service/internal/domain/lotteryengine
go test ./services/activity-service/internal/service/wheel
go test ./services/activity-service/internal/storage/mysql
go test ./services/activity-service/internal/transport/grpc
go test ./services/activity-service/internal/app
go test ./services/wallet-service/internal/service/wallet ./services/wallet-service/internal/storage/mysql ./services/wallet-service/internal/transport/grpc
go test ./services/activity-service/...
```
已知当前仓库全量 `make test` 仍会失败在既有 MifaPay 配置测试:
```text
services/wallet-service/internal/config.TestLoadMifaPayMerchantConfig
mifapay merchant identity mismatch in ../../configs/config.docker.yaml
```
该失败与通用抽奖和转盘改动无关。
## 十三、当前未完成事项
| 事项 | 原因 | 后续接入位置 |
| --- | --- | --- |
| 礼物奖品真实发放 | 需要确认礼物/资源 owner 的发放接口和背包表现 | activity-service 转盘 outbox worker + 对应 owner RPC |
| 道具/装扮真实发放 | 需要确认装扮资源 owner、有效期和背包协议 | activity-service 转盘 outbox worker + resource/wallet owner |
| Gateway HTTP/H5 接口 | 当前只到 activity gRPCH5 页面还需要 gateway 包装 | `services/gateway-service/internal/transport/http/activityapi` |
| 后台管理页面 | 当前只有 gRPC 后台能力,未接 admin web 模块 | `server/admin` |

View File

@ -70,6 +70,7 @@ import (
userleaderboardmodule "hyapp-admin-server/internal/modules/userleaderboard"
vipconfigmodule "hyapp-admin-server/internal/modules/vipconfig"
weeklystarmodule "hyapp-admin-server/internal/modules/weeklystar"
wheelmodule "hyapp-admin-server/internal/modules/wheel"
"hyapp-admin-server/internal/platform/logging"
"hyapp-admin-server/internal/platform/tencentcos"
"hyapp-admin-server/internal/repository"
@ -303,6 +304,7 @@ func main() {
UserLeaderboard: userleaderboardmodule.New(walletDB, userDB, roomClient),
VIPConfig: vipconfigmodule.New(walletclient.NewGRPC(walletConn), auditHandler),
WeeklyStar: weeklystarmodule.New(activityclient.NewGRPC(activityConn), auditHandler),
Wheel: wheelmodule.New(activityclient.NewGRPC(activityConn), cfg.ActivityService.RequestTimeout, auditHandler),
}
engine := router.New(cfg, auth, handlers)

View File

@ -57,6 +57,10 @@ type Client interface {
ListLuckyGiftConfigs(ctx context.Context, req *activityv1.ListLuckyGiftConfigsRequest) (*activityv1.ListLuckyGiftConfigsResponse, error)
ListLuckyGiftDraws(ctx context.Context, req *activityv1.ListLuckyGiftDrawsRequest) (*activityv1.ListLuckyGiftDrawsResponse, error)
GetLuckyGiftDrawSummary(ctx context.Context, req *activityv1.GetLuckyGiftDrawSummaryRequest) (*activityv1.GetLuckyGiftDrawSummaryResponse, error)
GetWheelConfig(ctx context.Context, req *activityv1.GetWheelConfigRequest) (*activityv1.GetWheelConfigResponse, error)
UpsertWheelConfig(ctx context.Context, req *activityv1.UpsertWheelConfigRequest) (*activityv1.UpsertWheelConfigResponse, error)
ListWheelDraws(ctx context.Context, req *activityv1.ListWheelDrawsRequest) (*activityv1.ListWheelDrawsResponse, error)
GetWheelDrawSummary(ctx context.Context, req *activityv1.GetWheelDrawSummaryRequest) (*activityv1.GetWheelDrawSummaryResponse, error)
RemoveRegionBroadcastMember(ctx context.Context, req *activityv1.RemoveRegionBroadcastMemberRequest) (*activityv1.RemoveRegionBroadcastMemberResponse, error)
ListLevelConfig(ctx context.Context, req *activityv1.ListLevelConfigRequest) (*activityv1.ListLevelConfigResponse, error)
UpsertLevelTrack(ctx context.Context, req *activityv1.UpsertLevelTrackRequest) (*activityv1.UpsertLevelTrackResponse, error)
@ -80,6 +84,7 @@ type GRPCClient struct {
agencyOpeningClient activityv1.AdminAgencyOpeningServiceClient
cpWeeklyRankClient activityv1.AdminCPWeeklyRankServiceClient
luckyGiftClient activityv1.AdminLuckyGiftServiceClient
wheelClient activityv1.AdminWheelServiceClient
broadcastClient activityv1.BroadcastServiceClient
levelClient activityv1.GrowthLevelServiceClient
growthClient activityv1.AdminGrowthLevelServiceClient
@ -100,6 +105,7 @@ func NewGRPC(conn grpc.ClientConnInterface) *GRPCClient {
agencyOpeningClient: activityv1.NewAdminAgencyOpeningServiceClient(conn),
cpWeeklyRankClient: activityv1.NewAdminCPWeeklyRankServiceClient(conn),
luckyGiftClient: activityv1.NewAdminLuckyGiftServiceClient(conn),
wheelClient: activityv1.NewAdminWheelServiceClient(conn),
broadcastClient: activityv1.NewBroadcastServiceClient(conn),
levelClient: activityv1.NewGrowthLevelServiceClient(conn),
growthClient: activityv1.NewAdminGrowthLevelServiceClient(conn),
@ -295,6 +301,22 @@ func (c *GRPCClient) GetLuckyGiftDrawSummary(ctx context.Context, req *activityv
return c.luckyGiftClient.GetLuckyGiftDrawSummary(ctx, req)
}
func (c *GRPCClient) GetWheelConfig(ctx context.Context, req *activityv1.GetWheelConfigRequest) (*activityv1.GetWheelConfigResponse, error) {
return c.wheelClient.GetWheelConfig(ctx, req)
}
func (c *GRPCClient) UpsertWheelConfig(ctx context.Context, req *activityv1.UpsertWheelConfigRequest) (*activityv1.UpsertWheelConfigResponse, error) {
return c.wheelClient.UpsertWheelConfig(ctx, req)
}
func (c *GRPCClient) ListWheelDraws(ctx context.Context, req *activityv1.ListWheelDrawsRequest) (*activityv1.ListWheelDrawsResponse, error) {
return c.wheelClient.ListWheelDraws(ctx, req)
}
func (c *GRPCClient) GetWheelDrawSummary(ctx context.Context, req *activityv1.GetWheelDrawSummaryRequest) (*activityv1.GetWheelDrawSummaryResponse, error) {
return c.wheelClient.GetWheelDrawSummary(ctx, req)
}
func (c *GRPCClient) RemoveRegionBroadcastMember(ctx context.Context, req *activityv1.RemoveRegionBroadcastMemberRequest) (*activityv1.RemoveRegionBroadcastMemberResponse, error) {
return c.broadcastClient.RemoveRegionBroadcastMember(ctx, req)
}

View File

@ -11,6 +11,8 @@ import (
"hyapp-admin-server/internal/modules/shared"
)
const availableRoomRobotPageSize = int32(200)
type RobotRoom struct {
AppCode string `json:"appCode"`
RoomID string `json:"roomId"`
@ -121,15 +123,12 @@ func (s *Service) ListAvailableRoomRobots(ctx context.Context) ([]AvailableRoomR
if s.robotClient == nil || s.roomClient == nil {
return nil, fmt.Errorf("robot or room service client is not configured")
}
result, err := s.robotClient.ListGameRobots(ctx, robotclient.ListGameRobotsRequest{
Status: "active",
PageSize: 200,
})
robots, err := listAllActiveGameRobots(ctx, s.robotClient)
if err != nil {
return nil, err
}
userIDs := make([]int64, 0, len(result.Robots))
for _, item := range result.Robots {
userIDs := make([]int64, 0, len(robots))
for _, item := range robots {
if item.UserID > 0 {
userIDs = append(userIDs, item.UserID)
}
@ -144,7 +143,38 @@ func (s *Service) ListAvailableRoomRobots(ctx context.Context) ([]AvailableRoomR
if err != nil {
return nil, err
}
return availableRoomRobotsFromGamePool(result.Robots, available.AvailableUserIDs, owners), nil
return availableRoomRobotsFromGamePool(robots, available.AvailableUserIDs, owners), nil
}
func listAllActiveGameRobots(ctx context.Context, client robotclient.Client) ([]robotclient.Robot, error) {
if client == nil {
return nil, fmt.Errorf("robot service client is not configured")
}
cursor := ""
seenCursors := make(map[string]bool)
robots := make([]robotclient.Robot, 0, availableRoomRobotPageSize)
for {
// 机器人房间下拉要先拿到完整全站 active 机器人池,再统一交给 room-service 做占用过滤;
// 只取第一页会在测试服机器人数量超过 200 或第一页大多已占用时,把后续地区的新机器人挡在下拉外。
result, err := client.ListGameRobots(ctx, robotclient.ListGameRobotsRequest{
Status: "active",
PageSize: availableRoomRobotPageSize,
Cursor: cursor,
})
if err != nil {
return nil, err
}
robots = append(robots, result.Robots...)
next := strings.TrimSpace(result.NextCursor)
if next == "" {
return robots, nil
}
if seenCursors[next] {
return nil, fmt.Errorf("robot service returned repeated cursor %q", next)
}
seenCursors[next] = true
cursor = next
}
}
func (s *Service) GetHumanRoomRobotConfig(ctx context.Context) (HumanRoomRobotConfig, error) {

View File

@ -1,6 +1,7 @@
package roomadmin
import (
"context"
"encoding/json"
"errors"
"net/http"
@ -214,3 +215,50 @@ func TestAvailableRoomRobotsFromGamePoolKeepsOnlyRoomAvailableUsers(t *testing.T
t.Fatalf("second available robot mismatch: %+v", items[1])
}
}
func TestListAllActiveGameRobotsFollowsCursor(t *testing.T) {
client := &pagedRobotClient{
pages: map[string]robotclient.ListGameRobotsResult{
"": {
Robots: []robotclient.Robot{{UserID: 101}, {UserID: 102}},
NextCursor: "102",
},
"102": {
Robots: []robotclient.Robot{{UserID: 201}},
},
},
}
robots, err := listAllActiveGameRobots(context.Background(), client)
if err != nil {
t.Fatalf("list all active game robots failed: %v", err)
}
if len(robots) != 3 || robots[0].UserID != 101 || robots[1].UserID != 102 || robots[2].UserID != 201 {
t.Fatalf("robots should include every cursor page, got %+v", robots)
}
if len(client.requests) != 2 {
t.Fatalf("expected two page requests, got %+v", client.requests)
}
if client.requests[0].Cursor != "" || client.requests[1].Cursor != "102" {
t.Fatalf("cursor chain mismatch: %+v", client.requests)
}
for _, req := range client.requests {
if req.Status != "active" || req.PageSize != availableRoomRobotPageSize {
t.Fatalf("request should use active status and page size %d, got %+v", availableRoomRobotPageSize, req)
}
}
}
type pagedRobotClient struct {
pages map[string]robotclient.ListGameRobotsResult
requests []robotclient.ListGameRobotsRequest
}
func (c *pagedRobotClient) ListGameRobots(_ context.Context, req robotclient.ListGameRobotsRequest) (robotclient.ListGameRobotsResult, error) {
c.requests = append(c.requests, req)
return c.pages[req.Cursor], nil
}
func (c *pagedRobotClient) ListRoomRobots(context.Context, robotclient.ListRoomRobotsRequest) (robotclient.ListRoomRobotsResult, error) {
return robotclient.ListRoomRobotsResult{}, errors.New("not used")
}

View File

@ -0,0 +1,483 @@
package wheel
import (
"context"
"fmt"
"math"
"strings"
"time"
"hyapp-admin-server/internal/appctx"
"hyapp-admin-server/internal/integration/activityclient"
"hyapp-admin-server/internal/middleware"
"hyapp-admin-server/internal/modules/shared"
"hyapp-admin-server/internal/response"
activityv1 "hyapp.local/api/proto/activity/v1"
"github.com/gin-gonic/gin"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
const defaultWheelID = "default"
var wheelTierCounts = map[string]int{
"classic": 9,
"luxury": 8,
"advanced": 12,
}
type Handler struct {
activity activityclient.Client
requestTimeout time.Duration
audit shared.OperationLogger
}
func New(activity activityclient.Client, requestTimeout time.Duration, audit shared.OperationLogger) *Handler {
if requestTimeout <= 0 {
requestTimeout = 3 * time.Second
}
return &Handler{activity: activity, requestTimeout: requestTimeout, audit: audit}
}
type configRequest struct {
WheelID string `json:"wheel_id"`
Enabled bool `json:"enabled"`
DrawPriceCoins int64 `json:"draw_price_coins"`
TargetRTPPercent float64 `json:"target_rtp_percent"`
PoolRatePercent float64 `json:"pool_rate_percent"`
SettlementWindowDraws int64 `json:"settlement_window_draws"`
InitialPoolCoins int64 `json:"initial_pool_coins"`
PoolReserveCoins int64 `json:"pool_reserve_coins"`
MaxSingleRTPPayout int64 `json:"max_single_rtp_payout"`
EffectiveFromMS int64 `json:"effective_from_ms"`
Tiers []tierDTO `json:"tiers"`
}
type configDTO struct {
configRequest
AppCode string `json:"app_code"`
RuleVersion int64 `json:"rule_version"`
CreatedByAdminID int64 `json:"created_by_admin_id"`
CreatedAtMS int64 `json:"created_at_ms"`
}
type tierDTO struct {
TierID string `json:"tier_id"`
DisplayName string `json:"display_name"`
RewardType string `json:"reward_type"`
RewardID string `json:"reward_id"`
RewardCount int64 `json:"reward_count"`
RewardCoins int64 `json:"reward_coins"`
RTPValueCoins int64 `json:"rtp_value_coins"`
ProbabilityPercent float64 `json:"probability_percent"`
Enabled bool `json:"enabled"`
MetadataJSON string `json:"metadata_json"`
}
type drawDTO struct {
DrawID string `json:"draw_id"`
DrawIDs []string `json:"draw_ids"`
CommandID string `json:"command_id"`
WheelID string `json:"wheel_id"`
RuleVersion int64 `json:"rule_version"`
SelectedTierID string `json:"selected_tier_id"`
RewardType string `json:"reward_type"`
RewardID string `json:"reward_id"`
RewardCount int64 `json:"reward_count"`
RewardCoins int64 `json:"reward_coins"`
RTPValueCoins int64 `json:"rtp_value_coins"`
RewardStatus string `json:"reward_status"`
RTPWindowIndex int64 `json:"rtp_window_index"`
ActualRTPPPM int64 `json:"actual_rtp_ppm"`
CreatedAtMS int64 `json:"created_at_ms"`
WalletTransactionID string `json:"wallet_transaction_id"`
CoinBalanceAfter int64 `json:"coin_balance_after"`
MetadataJSON string `json:"metadata_json"`
}
type drawSummaryDTO struct {
WheelID string `json:"wheel_id"`
TotalDraws int64 `json:"total_draws"`
UniqueUsers int64 `json:"unique_users"`
TotalSpentCoins int64 `json:"total_spent_coins"`
TotalRTPValueCoins int64 `json:"total_rtp_value_coins"`
ActualRTPPPM int64 `json:"actual_rtp_ppm"`
PendingDraws int64 `json:"pending_draws"`
GrantedDraws int64 `json:"granted_draws"`
FailedDraws int64 `json:"failed_draws"`
}
func (h *Handler) GetConfig(c *gin.Context) {
wheelID := normalizeWheelID(firstQuery(c, "wheel_id", "wheelId"))
ctx, cancel := h.activityContext(c)
defer cancel()
resp, err := h.activity.GetWheelConfig(ctx, &activityv1.GetWheelConfigRequest{Meta: h.meta(c), WheelId: wheelID})
if err != nil {
if status.Code(err) == codes.NotFound {
response.OK(c, defaultConfigDTO(wheelID))
return
}
response.ServerError(c, "获取转盘配置失败")
return
}
response.OK(c, configFromProto(resp.GetConfig()))
}
func (h *Handler) UpsertConfig(c *gin.Context) {
var req configRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "转盘配置参数不正确")
return
}
if req.WheelID == "" {
req.WheelID = firstQuery(c, "wheel_id", "wheelId")
}
if err := validateConfigRequest(req); err != nil {
response.BadRequest(c, err.Error())
return
}
ctx, cancel := h.activityContext(c)
defer cancel()
resp, err := h.activity.UpsertWheelConfig(ctx, &activityv1.UpsertWheelConfigRequest{
Meta: h.meta(c),
Config: configToProto(req),
OperatorAdminId: int64(middleware.CurrentUserID(c)),
})
if err != nil {
response.BadRequest(c, err.Error())
return
}
item := configFromProto(resp.GetConfig())
shared.OperationLogWithResourceID(c, h.audit, "upsert-wheel-config", "wheel_rule_versions", item.WheelID, "success", "")
response.OK(c, item)
}
func (h *Handler) ListDraws(c *gin.Context) {
options := shared.ListOptions(c)
ctx, cancel := h.activityContext(c)
defer cancel()
resp, err := h.activity.ListWheelDraws(ctx, &activityv1.ListWheelDrawsRequest{
Meta: h.meta(c),
WheelId: normalizeWheelID(firstQuery(c, "wheel_id", "wheelId")),
UserId: parseOptionalInt64(firstQuery(c, "user_id", "userId")),
Status: strings.TrimSpace(options.Status),
Page: int32(options.Page),
PageSize: int32(options.PageSize),
})
if err != nil {
response.ServerError(c, "获取转盘抽奖记录失败")
return
}
items := make([]drawDTO, 0, len(resp.GetDraws()))
for _, draw := range resp.GetDraws() {
items = append(items, drawFromProto(draw))
}
response.OK(c, response.Page{Items: items, Page: options.Page, PageSize: options.PageSize, Total: resp.GetTotal()})
}
func (h *Handler) GetDrawSummary(c *gin.Context) {
ctx, cancel := h.activityContext(c)
defer cancel()
resp, err := h.activity.GetWheelDrawSummary(ctx, &activityv1.GetWheelDrawSummaryRequest{
Meta: h.meta(c),
WheelId: normalizeWheelID(firstQuery(c, "wheel_id", "wheelId")),
UserId: parseOptionalInt64(firstQuery(c, "user_id", "userId")),
Status: strings.TrimSpace(c.Query("status")),
})
if err != nil {
response.ServerError(c, "获取转盘抽奖汇总失败")
return
}
response.OK(c, drawSummaryFromProto(resp.GetSummary()))
}
func (h *Handler) meta(c *gin.Context) *activityv1.RequestMeta {
return &activityv1.RequestMeta{
RequestId: middleware.CurrentRequestID(c),
Caller: "admin-server",
AppCode: appctx.FromContext(c.Request.Context()),
SentAtMs: time.Now().UTC().UnixMilli(),
}
}
func (h *Handler) activityContext(c *gin.Context) (context.Context, context.CancelFunc) {
return context.WithTimeout(c.Request.Context(), h.requestTimeout)
}
func validateConfigRequest(req configRequest) error {
if normalizeWheelID(req.WheelID) == "" {
return fmt.Errorf("wheel_id 不能为空")
}
expectedCount := expectedWheelTierCount(req.WheelID)
// 转盘前端布局是固定格子数classic=9、luxury=8、advanced=12。这里按池子强校验
// 避免运营导入资源组后少格或多格,导致 H5 动画序列和后端奖品配置无法一一对应。
if len(req.Tiers) != expectedCount {
return fmt.Errorf("%s 转盘必须配置 %d 个奖品档位", normalizeWheelID(req.WheelID), expectedCount)
}
var totalProbability float64
for _, tier := range req.Tiers {
rewardType := normalizeRewardType(tier.RewardType)
if rewardType == "" {
return fmt.Errorf("奖品类型不能为空")
}
totalProbability += tier.ProbabilityPercent
switch rewardType {
case "coin":
if tier.RewardCoins <= 0 {
return fmt.Errorf("金币档 reward_coins 必须大于 0")
}
case "gift":
if strings.TrimSpace(tier.RewardID) == "" {
return fmt.Errorf("礼物档 reward_id 不能为空")
}
case "prop", "dress":
if strings.TrimSpace(tier.RewardID) == "" {
return fmt.Errorf("道具或装扮档 reward_id 不能为空")
}
default:
return fmt.Errorf("奖品类型只支持 coin、gift、prop、dress")
}
// 道具和装扮用于背包展示,不进入 RTP 支出;真实清零在 configToProto 里执行,
// 这里保留运营输入兼容性,防止旧配置带值时直接无法打开保存。
if tier.ProbabilityPercent < 0 {
return fmt.Errorf("奖品概率不能为负数")
}
}
if math.Abs(totalProbability-100) > 0.000001 {
return fmt.Errorf("奖品档位概率合计必须等于 100")
}
return nil
}
func configToProto(req configRequest) *activityv1.WheelRuleConfig {
tiers := make([]*activityv1.WheelPrizeTier, 0, len(req.Tiers))
for index, tier := range req.Tiers {
rewardType := normalizeRewardType(tier.RewardType)
rtpValue := tier.RTPValueCoins
rewardCoins := tier.RewardCoins
if rewardType == "coin" && rtpValue <= 0 {
rtpValue = rewardCoins
}
if rewardType == "prop" || rewardType == "dress" {
rtpValue = 0
}
tiers = append(tiers, &activityv1.WheelPrizeTier{
TierId: generatedTierID(tier.TierID, rewardType, index),
DisplayName: strings.TrimSpace(tier.DisplayName),
RewardType: rewardType,
RewardId: strings.TrimSpace(tier.RewardID),
RewardCount: tier.RewardCount,
RewardCoins: rewardCoins,
RtpValueCoins: rtpValue,
WeightPpm: percentToPPM(tier.ProbabilityPercent),
Enabled: tier.Enabled,
MetadataJson: normalizeMetadataJSON(tier.MetadataJSON),
})
}
return &activityv1.WheelRuleConfig{
WheelId: normalizeWheelID(req.WheelID),
Enabled: req.Enabled,
DrawPriceCoins: req.DrawPriceCoins,
TargetRtpPpm: percentToPPM(req.TargetRTPPercent),
PoolRatePpm: percentToPPM(req.PoolRatePercent),
SettlementWindowDraws: req.SettlementWindowDraws,
InitialPoolCoins: req.InitialPoolCoins,
PoolReserveCoins: req.PoolReserveCoins,
MaxSingleRtpPayout: req.MaxSingleRTPPayout,
EffectiveFromMs: req.EffectiveFromMS,
Tiers: tiers,
}
}
func configFromProto(config *activityv1.WheelRuleConfig) configDTO {
if config == nil {
return configDTO{}
}
tiers := make([]tierDTO, 0, len(config.GetTiers()))
for _, tier := range config.GetTiers() {
tiers = append(tiers, tierDTO{
TierID: tier.GetTierId(),
DisplayName: tier.GetDisplayName(),
RewardType: tier.GetRewardType(),
RewardID: tier.GetRewardId(),
RewardCount: tier.GetRewardCount(),
RewardCoins: tier.GetRewardCoins(),
RTPValueCoins: tier.GetRtpValueCoins(),
ProbabilityPercent: ppmToPercent(tier.GetWeightPpm()),
Enabled: tier.GetEnabled(),
MetadataJSON: tier.GetMetadataJson(),
})
}
return configDTO{
AppCode: config.GetAppCode(),
RuleVersion: config.GetRuleVersion(),
CreatedByAdminID: config.GetCreatedByAdminId(),
CreatedAtMS: config.GetCreatedAtMs(),
configRequest: configRequest{
WheelID: normalizeWheelID(config.GetWheelId()),
Enabled: config.GetEnabled(),
DrawPriceCoins: config.GetDrawPriceCoins(),
TargetRTPPercent: ppmToPercent(config.GetTargetRtpPpm()),
PoolRatePercent: ppmToPercent(config.GetPoolRatePpm()),
SettlementWindowDraws: config.GetSettlementWindowDraws(),
InitialPoolCoins: config.GetInitialPoolCoins(),
PoolReserveCoins: config.GetPoolReserveCoins(),
MaxSingleRTPPayout: config.GetMaxSingleRtpPayout(),
EffectiveFromMS: config.GetEffectiveFromMs(),
Tiers: tiers,
},
}
}
func defaultConfigDTO(wheelID string) configDTO {
wheelID = normalizeWheelID(wheelID)
// 没有保存过配置时只返回基础参数和空档位。奖品档位必须从资源组导入,
// 不能用占位奖品冒充真实配置,否则运营会误以为当前池子已经可发布。
return configDTO{configRequest: configRequest{
WheelID: wheelID,
Enabled: false,
DrawPriceCoins: 10_000,
TargetRTPPercent: 90,
PoolRatePercent: 90,
SettlementWindowDraws: 1000,
InitialPoolCoins: 0,
PoolReserveCoins: 0,
MaxSingleRTPPayout: 0,
Tiers: []tierDTO{},
}}
}
func expectedWheelTierCount(wheelID string) int {
if count, ok := wheelTierCounts[normalizeWheelID(wheelID)]; ok {
return count
}
return wheelTierCounts["classic"]
}
func defaultProbabilityPercent(index int, count int) float64 {
if count <= 0 {
return 0
}
base := math.Floor(10000/float64(count)) / 100
if index == count-1 {
return 100 - base*float64(count-1)
}
return base
}
func drawFromProto(draw *activityv1.WheelDrawResult) drawDTO {
if draw == nil {
return drawDTO{}
}
return drawDTO{
DrawID: draw.GetDrawId(),
DrawIDs: draw.GetDrawIds(),
CommandID: draw.GetCommandId(),
WheelID: draw.GetWheelId(),
RuleVersion: draw.GetRuleVersion(),
SelectedTierID: draw.GetSelectedTierId(),
RewardType: draw.GetRewardType(),
RewardID: draw.GetRewardId(),
RewardCount: draw.GetRewardCount(),
RewardCoins: draw.GetRewardCoins(),
RTPValueCoins: draw.GetRtpValueCoins(),
RewardStatus: draw.GetRewardStatus(),
RTPWindowIndex: draw.GetRtpWindowIndex(),
ActualRTPPPM: draw.GetActualRtpPpm(),
CreatedAtMS: draw.GetCreatedAtMs(),
WalletTransactionID: draw.GetWalletTransactionId(),
CoinBalanceAfter: draw.GetCoinBalanceAfter(),
MetadataJSON: draw.GetMetadataJson(),
}
}
func drawSummaryFromProto(summary *activityv1.WheelDrawSummary) drawSummaryDTO {
if summary == nil {
return drawSummaryDTO{}
}
return drawSummaryDTO{
WheelID: summary.GetWheelId(),
TotalDraws: summary.GetTotalDraws(),
UniqueUsers: summary.GetUniqueUsers(),
TotalSpentCoins: summary.GetTotalSpentCoins(),
TotalRTPValueCoins: summary.GetTotalRtpValueCoins(),
ActualRTPPPM: summary.GetActualRtpPpm(),
PendingDraws: summary.GetPendingDraws(),
GrantedDraws: summary.GetGrantedDraws(),
FailedDraws: summary.GetFailedDraws(),
}
}
func normalizeRewardType(value string) string {
switch strings.ToLower(strings.TrimSpace(value)) {
case "coin":
return "coin"
case "gift":
return "gift"
case "prop", "item", "decoration", "resource":
return "prop"
case "dress", "dress_up", "costume":
return "dress"
default:
return strings.ToLower(strings.TrimSpace(value))
}
}
func normalizeWheelID(value string) string {
value = strings.TrimSpace(value)
if value == "" {
return defaultWheelID
}
return value
}
func generatedTierID(raw string, rewardType string, index int) string {
raw = strings.TrimSpace(raw)
if raw != "" {
return raw
}
// 同一奖池现在允许多个金币、多个礼物或多个装扮;缺省 ID 必须带序号,
// 否则多档同类型会落到同一个 tier_id抽奖结果无法稳定回显到正确格子。
return fmt.Sprintf("%s-%02d", rewardType, index+1)
}
func normalizeMetadataJSON(value string) string {
value = strings.TrimSpace(value)
if value == "" {
return "{}"
}
return value
}
func percentToPPM(value float64) int64 {
return int64(math.Round(value * 10_000))
}
func ppmToPercent(value int64) float64 {
return float64(value) / 10_000
}
func firstQuery(c *gin.Context, names ...string) string {
for _, name := range names {
if value := strings.TrimSpace(c.Query(name)); value != "" {
return value
}
}
return ""
}
func parseOptionalInt64(value string) int64 {
value = strings.TrimSpace(value)
if value == "" {
return 0
}
var result int64
for _, char := range value {
if char < '0' || char > '9' {
return 0
}
result = result*10 + int64(char-'0')
}
return result
}

View File

@ -0,0 +1,114 @@
package wheel
import (
"strings"
"testing"
activityv1 "hyapp.local/api/proto/activity/v1"
)
func TestConfigToProtoSupportsPoolTierCountAndForcesPropRTPZero(t *testing.T) {
tiers := validClassicTiers()
tiers[0] = tierDTO{DisplayName: "金币", RewardType: " coin ", RewardCount: 1, RewardCoins: 1000, ProbabilityPercent: defaultProbabilityPercent(0, 9), Enabled: true}
tiers[1] = tierDTO{DisplayName: "礼物", RewardType: "gift", RewardID: "gift_1", RewardCount: 1, RTPValueCoins: 5000, ProbabilityPercent: defaultProbabilityPercent(1, 9), Enabled: true}
tiers[2] = tierDTO{DisplayName: "装扮", RewardType: "dress_up", RewardID: "dress_1", RewardCount: 1, RTPValueCoins: 999999, ProbabilityPercent: defaultProbabilityPercent(2, 9), Enabled: true}
req := configRequest{
WheelID: " classic ",
Enabled: true,
DrawPriceCoins: 10000,
TargetRTPPercent: 90,
PoolRatePercent: 92,
SettlementWindowDraws: 1000,
Tiers: tiers,
}
if err := validateConfigRequest(req); err != nil {
t.Fatalf("validate config failed: %v", err)
}
config := configToProto(req)
if config.GetWheelId() != "classic" {
t.Fatalf("wheel id = %q, want classic", config.GetWheelId())
}
if config.GetTargetRtpPpm() != 900000 || config.GetPoolRatePpm() != 920000 {
t.Fatalf("ppm mismatch: target=%d pool=%d", config.GetTargetRtpPpm(), config.GetPoolRatePpm())
}
protoTiers := config.GetTiers()
if len(protoTiers) != 9 {
t.Fatalf("tier count = %d, want 9", len(protoTiers))
}
if protoTiers[0].GetTierId() != "coin-01" || protoTiers[0].GetRtpValueCoins() != 1000 {
t.Fatalf("coin tier mismatch: %+v", protoTiers[0])
}
if protoTiers[2].GetRewardType() != "dress" || protoTiers[2].GetRtpValueCoins() != 0 {
t.Fatalf("dress tier must force rtp to zero: %+v", protoTiers[2])
}
}
func TestValidateConfigRequiresPoolTierCountAndProbabilitySum(t *testing.T) {
err := validateConfigRequest(configRequest{
WheelID: "classic",
Tiers: []tierDTO{
{RewardType: "coin", RewardCoins: 100, ProbabilityPercent: 50},
{RewardType: "gift", RewardID: "gift_1", ProbabilityPercent: 30},
{RewardType: "gift", RewardID: "gift_2", ProbabilityPercent: 20},
},
})
if err == nil || !strings.Contains(err.Error(), "9 个奖品档位") {
t.Fatalf("classic tier count should be rejected, got %v", err)
}
tiers := validClassicTiers()
tiers[8].ProbabilityPercent = 1
err = validateConfigRequest(configRequest{
WheelID: "classic",
Tiers: tiers,
})
if err == nil || !strings.Contains(err.Error(), "概率合计") {
t.Fatalf("probability sum should be rejected, got %v", err)
}
}
func TestDefaultConfigDoesNotCreateMockPrizeTiers(t *testing.T) {
config := defaultConfigDTO("classic")
if len(config.Tiers) != 0 {
t.Fatalf("default config must not create mock prize tiers: %+v", config.Tiers)
}
if config.WheelID != "classic" || config.DrawPriceCoins <= 0 {
t.Fatalf("default base config mismatch: %+v", config)
}
}
func TestConfigFromProtoConvertsPPMToPercent(t *testing.T) {
config := configFromProto(&activityv1.WheelRuleConfig{
AppCode: "lalu",
WheelId: "default",
RuleVersion: 3,
Enabled: true,
DrawPriceCoins: 10000,
TargetRtpPpm: 900000,
PoolRatePpm: 920000,
SettlementWindowDraws: 1000,
Tiers: []*activityv1.WheelPrizeTier{
{TierId: "coin", DisplayName: "金币", RewardType: "coin", RewardCoins: 1000, RtpValueCoins: 1000, WeightPpm: 600000, Enabled: true},
},
})
if config.TargetRTPPercent != 90 || config.PoolRatePercent != 92 {
t.Fatalf("percent mismatch: target=%v pool=%v", config.TargetRTPPercent, config.PoolRatePercent)
}
if len(config.Tiers) != 1 || config.Tiers[0].ProbabilityPercent != 60 {
t.Fatalf("tier probability mismatch: %+v", config.Tiers)
}
}
func validClassicTiers() []tierDTO {
tiers := make([]tierDTO, 0, 9)
for index := 0; index < 9; index++ {
tiers = append(tiers, tierDTO{
DisplayName: "金币",
RewardType: "coin",
RewardCount: 1,
RewardCoins: 100,
ProbabilityPercent: defaultProbabilityPercent(index, 9),
Enabled: true,
})
}
return tiers
}

View File

@ -0,0 +1,17 @@
package wheel
import (
"hyapp-admin-server/internal/middleware"
"github.com/gin-gonic/gin"
)
func RegisterRoutes(protected *gin.RouterGroup, h *Handler) {
if h == nil {
return
}
protected.GET("/admin/activity/wheel/config", middleware.RequirePermission("wheel:view"), h.GetConfig)
protected.PUT("/admin/activity/wheel/config", middleware.RequirePermission("wheel:update"), h.UpsertConfig)
protected.GET("/admin/activity/wheel/draws", middleware.RequirePermission("wheel:view"), h.ListDraws)
protected.GET("/admin/activity/wheel/draw-summary", middleware.RequirePermission("wheel:view"), h.GetDrawSummary)
}

View File

@ -130,6 +130,8 @@ var defaultPermissions = []model.Permission{
{Name: "七日签到更新", Code: "seven-day-checkin:update", Kind: "button"},
{Name: "幸运礼物查看", Code: "lucky-gift:view", Kind: "menu"},
{Name: "幸运礼物更新", Code: "lucky-gift:update", Kind: "button"},
{Name: "转盘抽奖查看", Code: "wheel:view", Kind: "menu"},
{Name: "转盘抽奖更新", Code: "wheel:update", Kind: "button"},
{Name: "房间火箭查看", Code: "room-rocket:view", Kind: "menu"},
{Name: "房间火箭更新", Code: "room-rocket:update", Kind: "button"},
{Name: "用户榜单查看", Code: "user-leaderboard:view", Kind: "menu"},
@ -283,9 +285,9 @@ func (s *Store) seedMenus() error {
{ParentID: &operationsID, Title: "币商流水", Code: "operation-coin-seller-ledger", Path: "/operations/coin-seller-ledger", Icon: "receipt", PermissionCode: "coin-seller-ledger:view", Sort: 69, Visible: true},
{ParentID: &operationsID, Title: "金币增减", Code: "operation-coin-adjustment", Path: "/operations/coin-adjustments", Icon: "wallet", PermissionCode: "coin-adjustment:view", Sort: 70, Visible: true},
{ParentID: &operationsID, Title: "幸运礼物", Code: "lucky-gift", Path: "/operations/lucky-gift", Icon: "redeem", PermissionCode: "lucky-gift:view", Sort: 71, Visible: true},
{ParentID: &operationsID, Title: "举报列表", Code: "operation-reports", Path: "/operations/reports", Icon: "flag", PermissionCode: "report:view", Sort: 72, Visible: true},
{ParentID: &operationsID, Title: "礼物钻石", Code: "operation-gift-diamond", Path: "/operations/gift-diamonds", Icon: "diamond", PermissionCode: "gift-diamond:view", Sort: 73, Visible: true},
{ParentID: &operationsID, Title: "全服通知", Code: "operation-full-server-notice", Path: "/operations/full-server-notices", Icon: "campaign", PermissionCode: "full-server-notice:view", Sort: 74, Visible: true},
{ParentID: &operationsID, Title: "举报列表", Code: "operation-reports", Path: "/operations/reports", Icon: "flag", PermissionCode: "report:view", Sort: 73, Visible: true},
{ParentID: &operationsID, Title: "礼物钻石", Code: "operation-gift-diamond", Path: "/operations/gift-diamonds", Icon: "diamond", PermissionCode: "gift-diamond:view", Sort: 74, Visible: true},
{ParentID: &operationsID, Title: "全服通知", Code: "operation-full-server-notice", Path: "/operations/full-server-notices", Icon: "campaign", PermissionCode: "full-server-notice:view", Sort: 75, Visible: true},
{ParentID: &paymentID, Title: "账单列表", Code: "payment-bill-list", Path: "/payment/bills", Icon: "receipt", PermissionCode: "payment-bill:view", Sort: 68, Visible: true},
{ParentID: &paymentID, Title: "三方支付", Code: "payment-third-party", Path: "/payment/third-party", Icon: "wallet", PermissionCode: "payment-third-party:view", Sort: 69, Visible: true},
{ParentID: &paymentID, Title: "支付内购商品", Code: "payment-recharge-products", Path: "/payment/recharge-products", Icon: "wallet", PermissionCode: "payment-product:view", Sort: 70, Visible: true},
@ -300,6 +302,7 @@ func (s *Store) seedMenus() error {
{ParentID: &activityID, Title: "VIP配置", Code: "vip-config", Path: "/activities/vip-config", Icon: "workspace_premium", PermissionCode: "vip-config:view", Sort: 77, Visible: true},
{ParentID: &activityID, Title: "周星配置", Code: "weekly-star", Path: "/activities/weekly-star", Icon: "star", PermissionCode: "weekly-star:view", Sort: 78, Visible: true},
{ParentID: &activityID, Title: "代理开业活动", Code: "agency-opening", Path: "/activities/agency-opening", Icon: "workspace_premium", PermissionCode: "agency-opening:view", Sort: 79, Visible: true},
{ParentID: &activityID, Title: "转盘抽奖", Code: "wheel", Path: "/activities/wheel", Icon: "casino", PermissionCode: "wheel:view", Sort: 80, Visible: true},
{ParentID: &gameID, Title: "游戏列表", Code: "game-list", Path: "/games", Icon: "sports_esports", PermissionCode: "game:view", Sort: 70, Visible: true},
{ParentID: &gameID, Title: "自研游戏", Code: "self-games", Path: "/games/self-games", Icon: "settings", PermissionCode: "game:view", Sort: 80, Visible: true},
{ParentID: &gameID, Title: "全站机器人", Code: "game-robots", Path: "/games/robots", Icon: "team", PermissionCode: "game:view", Sort: 90, Visible: true},
@ -541,7 +544,7 @@ func defaultRolePermissionCodes(code string) []string {
"bd:view", "bd:create", "bd:update",
"coin-seller:view", "coin-seller:create", "coin-seller:update", "coin-seller:stock-credit", "coin-seller:exchange-rate",
"coin-ledger:view", "coin-seller-ledger:view", "coin-adjustment:view", "coin-adjustment:create", "report:view", "gift-diamond:view", "gift-diamond:update", "full-server-notice:view", "full-server-notice:send", "payment-bill:view", "payment-third-party:view", "payment-third-party:update", "payment-product:view", "payment-product:create", "payment-product:update", "payment-product:delete",
"lucky-gift:view", "lucky-gift:update",
"lucky-gift:view", "lucky-gift:update", "wheel:view", "wheel:update",
"game:view", "game:create", "game:update", "game:status", "game:delete",
"daily-task:view", "daily-task:create", "daily-task:update", "daily-task:status",
"achievement:view", "achievement:create", "achievement:update",
@ -557,7 +560,7 @@ func defaultRolePermissionCodes(code string) []string {
"upload:create",
}
case "auditor":
return []string{"overview:view", "log:view", "user:view", "app-user:view", "level-config:view", "pretty-id:view", "region-block:view", "room:view", "room-pin:view", "room-config:view", "room-whitelist:view", "room-robot:view", "app-config:view", "app-version:view", "resource:view", "resource-shop:view", "resource-group:view", "resource-grant:view", "gift:view", "emoji-pack:view", "host-agency-policy:view", "team-salary-policy:view", "host-salary-settlement:view", "coin-ledger:view", "coin-seller-ledger:view", "coin-adjustment:view", "report:view", "gift-diamond:view", "full-server-notice:view", "lucky-gift:view", "payment-bill:view", "payment-third-party:view", "payment-product:view", "game:view", "daily-task:view", "achievement:view", "seven-day-checkin:view", "room-rocket:view", "red-packet:view", "cp-config:view", "vip-config:view", "weekly-star:view", "agency-opening:view", "role:view", "permission:view", "job:view"}
return []string{"overview:view", "log:view", "user:view", "app-user:view", "level-config:view", "pretty-id:view", "region-block:view", "room:view", "room-pin:view", "room-config:view", "room-whitelist:view", "room-robot:view", "app-config:view", "app-version:view", "resource:view", "resource-shop:view", "resource-group:view", "resource-grant:view", "gift:view", "emoji-pack:view", "host-agency-policy:view", "team-salary-policy:view", "host-salary-settlement:view", "coin-ledger:view", "coin-seller-ledger:view", "coin-adjustment:view", "report:view", "gift-diamond:view", "full-server-notice:view", "lucky-gift:view", "wheel:view", "payment-bill:view", "payment-third-party:view", "payment-product:view", "game:view", "daily-task:view", "achievement:view", "seven-day-checkin:view", "room-rocket:view", "red-packet:view", "cp-config:view", "vip-config:view", "weekly-star:view", "agency-opening:view", "role:view", "permission:view", "job:view"}
case "readonly":
return []string{
"overview:view",
@ -595,6 +598,7 @@ func defaultRolePermissionCodes(code string) []string {
"gift-diamond:view",
"full-server-notice:view",
"lucky-gift:view",
"wheel:view",
"payment-bill:view",
"payment-third-party:view",
"payment-product:view",
@ -642,7 +646,7 @@ func defaultRolePermissionMigrationCodes(code string) []string {
"host-agency-policy:view", "host-agency-policy:create", "host-agency-policy:update", "host-agency-policy:delete", "host-agency-policy:publish", "team-salary-policy:view", "team-salary-policy:create", "team-salary-policy:update", "team-salary-policy:delete", "host-salary-settlement:view", "host-salary-settlement:settle",
"coin-seller:view", "coin-seller:create", "coin-seller:update", "coin-seller:stock-credit", "coin-seller:exchange-rate",
"coin-ledger:view", "coin-seller-ledger:view", "coin-adjustment:view", "coin-adjustment:create", "report:view", "gift-diamond:view", "gift-diamond:update", "full-server-notice:view", "full-server-notice:send", "payment-bill:view", "payment-third-party:view", "payment-third-party:update", "payment-product:view", "payment-product:create", "payment-product:update", "payment-product:delete",
"lucky-gift:view", "lucky-gift:update",
"lucky-gift:view", "lucky-gift:update", "wheel:view", "wheel:update",
"game:view", "game:create", "game:update", "game:status", "game:delete",
"daily-task:view", "daily-task:create", "daily-task:update", "daily-task:status",
"achievement:view", "achievement:create", "achievement:update",
@ -655,7 +659,7 @@ func defaultRolePermissionMigrationCodes(code string) []string {
"agency-opening:view", "agency-opening:create", "agency-opening:update",
}
case "auditor", "readonly":
return []string{"level-config:view", "pretty-id:view", "region-block:view", "room:view", "room-pin:view", "room-config:view", "room-whitelist:view", "room-robot:view", "app-config:view", "app-version:view", "resource:view", "resource-shop:view", "resource-group:view", "resource-grant:view", "gift:view", "emoji-pack:view", "host-agency-policy:view", "team-salary-policy:view", "host-salary-settlement:view", "coin-seller:view", "coin-ledger:view", "coin-seller-ledger:view", "coin-adjustment:view", "report:view", "gift-diamond:view", "full-server-notice:view", "lucky-gift:view", "payment-bill:view", "payment-third-party:view", "payment-product:view", "game:view", "daily-task:view", "achievement:view", "seven-day-checkin:view", "room-rocket:view", "red-packet:view", "cp-config:view", "vip-config:view", "weekly-star:view", "agency-opening:view"}
return []string{"level-config:view", "pretty-id:view", "region-block:view", "room:view", "room-pin:view", "room-config:view", "room-whitelist:view", "room-robot:view", "app-config:view", "app-version:view", "resource:view", "resource-shop:view", "resource-group:view", "resource-grant:view", "gift:view", "emoji-pack:view", "host-agency-policy:view", "team-salary-policy:view", "host-salary-settlement:view", "coin-seller:view", "coin-ledger:view", "coin-seller-ledger:view", "coin-adjustment:view", "report:view", "gift-diamond:view", "full-server-notice:view", "lucky-gift:view", "wheel:view", "payment-bill:view", "payment-third-party:view", "payment-product:view", "game:view", "daily-task:view", "achievement:view", "seven-day-checkin:view", "room-rocket:view", "red-packet:view", "cp-config:view", "vip-config:view", "weekly-star:view", "agency-opening:view"}
default:
return nil
}

View File

@ -49,6 +49,7 @@ import (
"hyapp-admin-server/internal/modules/userleaderboard"
"hyapp-admin-server/internal/modules/vipconfig"
"hyapp-admin-server/internal/modules/weeklystar"
"hyapp-admin-server/internal/modules/wheel"
"hyapp-admin-server/internal/platform/logging"
"hyapp-admin-server/internal/service"
@ -102,6 +103,7 @@ type Handlers struct {
UserLeaderboard *userleaderboard.Handler
VIPConfig *vipconfig.Handler
WeeklyStar *weeklystar.Handler
Wheel *wheel.Handler
}
func New(cfg config.Config, auth *service.AuthService, h Handlers) *gin.Engine {
@ -160,6 +162,7 @@ func New(cfg config.Config, auth *service.AuthService, h Handlers) *gin.Engine {
userleaderboard.RegisterRoutes(protected, h.UserLeaderboard)
vipconfig.RegisterRoutes(protected, h.VIPConfig)
weeklystar.RegisterRoutes(protected, h.WeeklyStar)
wheel.RegisterRoutes(protected, h.Wheel)
return engine
}

View File

@ -0,0 +1,59 @@
SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci;
-- 转盘抽奖规则、记录和统计都归 activity-service后台提供活动管理入口和 gRPC 配置适配。
SET @now_ms = CAST(UNIX_TIMESTAMP(UTC_TIMESTAMP(3)) * 1000 AS UNSIGNED);
INSERT INTO admin_permissions (name, code, kind, description, created_at_ms, updated_at_ms) VALUES
('转盘抽奖查看', 'wheel:view', 'menu', '', @now_ms, @now_ms),
('转盘抽奖更新', 'wheel:update', 'button', '', @now_ms, @now_ms)
ON DUPLICATE KEY UPDATE
name = VALUES(name),
kind = VALUES(kind),
description = VALUES(description),
updated_at_ms = @now_ms;
INSERT INTO admin_menus (parent_id, title, code, path, icon, permission_code, sort, visible, created_at_ms, updated_at_ms) VALUES
(NULL, '活动管理', 'activities', '', 'campaign', '', 80, TRUE, @now_ms, @now_ms)
ON DUPLICATE KEY UPDATE
title = VALUES(title),
path = VALUES(path),
icon = VALUES(icon),
permission_code = VALUES(permission_code),
sort = VALUES(sort),
visible = VALUES(visible),
updated_at_ms = @now_ms;
INSERT INTO admin_menus (parent_id, title, code, path, icon, permission_code, sort, visible, created_at_ms, updated_at_ms)
SELECT parent.id, '转盘抽奖', 'wheel', '/activities/wheel', 'casino', 'wheel:view', 80, TRUE, @now_ms, @now_ms
FROM admin_menus parent
WHERE parent.code = 'activities'
ON DUPLICATE KEY UPDATE
parent_id = VALUES(parent_id),
title = VALUES(title),
path = VALUES(path),
icon = VALUES(icon),
permission_code = VALUES(permission_code),
sort = VALUES(sort),
visible = VALUES(visible),
updated_at_ms = @now_ms;
INSERT IGNORE INTO admin_role_permissions (role_id, permission_id)
SELECT admin_role.id, admin_permission.id
FROM admin_roles admin_role
JOIN admin_permissions admin_permission
WHERE admin_role.code = 'platform-admin'
AND admin_permission.code IN ('wheel:view', 'wheel:update');
INSERT IGNORE INTO admin_role_permissions (role_id, permission_id)
SELECT admin_role.id, admin_permission.id
FROM admin_roles admin_role
JOIN admin_permissions admin_permission
WHERE admin_role.code = 'ops-admin'
AND admin_permission.code IN ('wheel:view', 'wheel:update');
INSERT IGNORE INTO admin_role_permissions (role_id, permission_id)
SELECT admin_role.id, admin_permission.id
FROM admin_roles admin_role
JOIN admin_permissions admin_permission
WHERE admin_role.code IN ('auditor', 'readonly')
AND admin_permission.code = 'wheel:view';

View File

@ -0,0 +1,26 @@
SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci;
-- 056 初版曾把转盘抽奖放在运营管理;这里单独迁移到活动管理,保证已执行过 056 的库也能修正菜单位置。
SET @now_ms = CAST(UNIX_TIMESTAMP(UTC_TIMESTAMP(3)) * 1000 AS UNSIGNED);
INSERT INTO admin_menus (parent_id, title, code, path, icon, permission_code, sort, visible, created_at_ms, updated_at_ms) VALUES
(NULL, '活动管理', 'activities', '', 'campaign', '', 80, TRUE, @now_ms, @now_ms)
ON DUPLICATE KEY UPDATE
title = VALUES(title),
path = VALUES(path),
icon = VALUES(icon),
permission_code = VALUES(permission_code),
sort = VALUES(sort),
visible = VALUES(visible),
updated_at_ms = @now_ms;
UPDATE admin_menus wheel
JOIN admin_menus activities ON activities.code = 'activities'
SET wheel.parent_id = activities.id,
wheel.path = '/activities/wheel',
wheel.sort = 80,
wheel.icon = 'casino',
wheel.permission_code = 'wheel:view',
wheel.visible = TRUE,
wheel.updated_at_ms = @now_ms
WHERE wheel.code = 'wheel';

View File

@ -287,6 +287,131 @@ CREATE TABLE IF NOT EXISTS lucky_draw_pool_stat_cursors (
PRIMARY KEY (app_code, cursor_name)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='幸运礼物后台统计快照游标';
CREATE TABLE IF NOT EXISTS wheel_rule_versions (
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码',
wheel_id VARCHAR(96) NOT NULL COMMENT '转盘 ID',
rule_version BIGINT NOT NULL COMMENT '不可变规则版本',
enabled TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否启用',
draw_price_coins BIGINT NOT NULL COMMENT '单抽价格,金币',
target_rtp_ppm BIGINT NOT NULL COMMENT '金币和礼物 RTP 目标ppm',
pool_rate_ppm BIGINT NOT NULL COMMENT '每笔消耗进入转盘基础奖池比例ppm',
settlement_window_draws BIGINT NOT NULL COMMENT 'RTP 观察窗口抽数',
initial_pool_coins BIGINT NOT NULL DEFAULT 0 COMMENT '初始奖池水位',
pool_reserve_coins BIGINT NOT NULL DEFAULT 0 COMMENT '奖池安全水位',
max_single_rtp_payout BIGINT NOT NULL DEFAULT 0 COMMENT '单次计入 RTP 的最大返奖',
effective_from_ms BIGINT NOT NULL COMMENT '生效时间UTC epoch ms',
created_by_admin_id BIGINT NOT NULL DEFAULT 0 COMMENT '发布人',
created_at_ms BIGINT NOT NULL COMMENT '创建时间UTC epoch ms',
PRIMARY KEY (app_code, wheel_id, rule_version),
KEY idx_wheel_rule_versions_latest (app_code, wheel_id, rule_version),
KEY idx_wheel_rule_versions_enabled (app_code, enabled, created_at_ms)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='转盘规则版本表';
CREATE TABLE IF NOT EXISTS wheel_prize_tiers (
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码',
wheel_id VARCHAR(96) NOT NULL COMMENT '转盘 ID',
rule_version BIGINT NOT NULL COMMENT '规则版本',
tier_id VARCHAR(96) NOT NULL COMMENT '奖档 ID',
display_name VARCHAR(128) NOT NULL DEFAULT '' COMMENT '展示名称快照',
reward_type VARCHAR(32) NOT NULL COMMENT 'coin/gift/prop/dress',
reward_id VARCHAR(128) NOT NULL DEFAULT '' COMMENT '礼物或道具资源 ID',
reward_count BIGINT NOT NULL DEFAULT 1 COMMENT '奖励数量',
reward_coins BIGINT NOT NULL DEFAULT 0 COMMENT '金币奖励金额',
rtp_value_coins BIGINT NOT NULL DEFAULT 0 COMMENT '计入 RTP 的奖励价值;道具装扮强制为 0',
weight_ppm BIGINT NOT NULL COMMENT '随机权重ppm 口径',
enabled TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否启用',
metadata_json JSON NOT NULL COMMENT '奖品展示和发放扩展快照',
PRIMARY KEY (app_code, wheel_id, rule_version, tier_id),
KEY idx_wheel_prize_tiers_rule (app_code, wheel_id, rule_version)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='转盘奖档表';
CREATE TABLE IF NOT EXISTS wheel_rtp_windows (
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码',
wheel_id VARCHAR(96) NOT NULL COMMENT '转盘 ID',
window_index BIGINT NOT NULL COMMENT 'RTP 窗口序号',
target_rtp_ppm BIGINT NOT NULL COMMENT '窗口目标 RTP',
control_window_draws BIGINT NOT NULL COMMENT '窗口抽数',
paid_draws BIGINT NOT NULL DEFAULT 0 COMMENT '已抽次数',
wager_coins BIGINT NOT NULL DEFAULT 0 COMMENT '消耗金币',
target_payout_coins BIGINT NOT NULL DEFAULT 0 COMMENT '目标返奖',
actual_rtp_value_coins BIGINT NOT NULL DEFAULT 0 COMMENT '实际计入 RTP 的返奖',
carry_ppm BIGINT NOT NULL DEFAULT 0 COMMENT '整数除法余数',
status VARCHAR(32) NOT NULL DEFAULT 'open' COMMENT 'open/closed',
created_at_ms BIGINT NOT NULL COMMENT '创建时间UTC epoch ms',
updated_at_ms BIGINT NOT NULL COMMENT '更新时间UTC epoch ms',
PRIMARY KEY (app_code, wheel_id, window_index),
KEY idx_wheel_rtp_windows_open (app_code, wheel_id, status, window_index)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='转盘 RTP 观察窗口';
CREATE TABLE IF NOT EXISTS wheel_pools (
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码',
wheel_id VARCHAR(96) NOT NULL COMMENT '转盘 ID',
balance BIGINT NOT NULL DEFAULT 0 COMMENT '当前可用水位',
reserve_floor BIGINT NOT NULL DEFAULT 0 COMMENT '安全水位',
total_in BIGINT NOT NULL DEFAULT 0 COMMENT '累计入池',
total_out BIGINT NOT NULL DEFAULT 0 COMMENT '累计 RTP 价值支出',
created_at_ms BIGINT NOT NULL COMMENT '创建时间UTC epoch ms',
updated_at_ms BIGINT NOT NULL COMMENT '更新时间UTC epoch ms',
PRIMARY KEY (app_code, wheel_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='转盘基础奖池';
CREATE TABLE IF NOT EXISTS wheel_draw_records (
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码',
draw_id VARCHAR(128) NOT NULL COMMENT '抽奖 ID',
command_id VARCHAR(128) NOT NULL COMMENT '幂等命令 ID',
user_id BIGINT NOT NULL COMMENT '用户 ID',
device_id VARCHAR(128) NOT NULL COMMENT '设备 ID',
visible_region_id BIGINT NOT NULL DEFAULT 0 COMMENT '可见区域 ID',
wheel_id VARCHAR(96) NOT NULL COMMENT '转盘 ID',
coin_spent BIGINT NOT NULL COMMENT '消耗金币',
rule_version BIGINT NOT NULL COMMENT '规则版本',
rtp_window_index BIGINT NOT NULL COMMENT 'RTP 窗口',
selected_tier_id VARCHAR(96) NOT NULL COMMENT '命中奖档',
reward_type VARCHAR(32) NOT NULL COMMENT 'coin/gift/prop/dress',
reward_id VARCHAR(128) NOT NULL DEFAULT '' COMMENT '礼物或道具资源 ID',
reward_count BIGINT NOT NULL DEFAULT 0 COMMENT '奖励数量',
reward_coins BIGINT NOT NULL DEFAULT 0 COMMENT '金币奖励金额',
rtp_value_coins BIGINT NOT NULL DEFAULT 0 COMMENT '计入 RTP 的奖励价值',
candidate_tiers_json JSON NOT NULL COMMENT '候选过滤快照',
rtp_snapshot_json JSON NOT NULL COMMENT 'RTP 快照',
prize_metadata_json JSON NOT NULL COMMENT '奖品扩展快照',
reward_status VARCHAR(32) NOT NULL COMMENT 'pending/granted/failed',
reward_transaction_id VARCHAR(128) NOT NULL DEFAULT '' COMMENT '钱包或资产发放交易 ID',
reward_failure_reason VARCHAR(255) NOT NULL DEFAULT '' COMMENT '发放失败原因',
paid_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, draw_id),
UNIQUE KEY uk_wheel_draw_command (app_code, command_id),
KEY idx_wheel_draw_wheel (app_code, wheel_id, created_at_ms),
KEY idx_wheel_draw_user (app_code, user_id, created_at_ms),
KEY idx_wheel_draw_status (app_code, reward_status, updated_at_ms),
KEY idx_wheel_draw_created (app_code, created_at_ms, draw_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='转盘抽奖事实';
CREATE TABLE IF NOT EXISTS wheel_draw_stats (
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码',
wheel_id VARCHAR(96) NOT NULL COMMENT '转盘 ID',
total_draws BIGINT NOT NULL DEFAULT 0 COMMENT '抽奖次数',
unique_users BIGINT NOT NULL DEFAULT 0 COMMENT '参与用户数',
total_spent_coins BIGINT NOT NULL DEFAULT 0 COMMENT '消耗金币',
total_rtp_value_coins BIGINT NOT NULL DEFAULT 0 COMMENT '计入 RTP 的返奖价值',
pending_draws BIGINT NOT NULL DEFAULT 0 COMMENT '待发放数量',
granted_draws BIGINT NOT NULL DEFAULT 0 COMMENT '已发放数量',
failed_draws BIGINT NOT NULL DEFAULT 0 COMMENT '发放失败数量',
created_at_ms BIGINT NOT NULL COMMENT '创建时间UTC epoch ms',
updated_at_ms BIGINT NOT NULL COMMENT '更新时间UTC epoch ms',
PRIMARY KEY (app_code, wheel_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='转盘后台汇总统计';
CREATE TABLE IF NOT EXISTS wheel_draw_stat_users (
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码',
wheel_id VARCHAR(96) NOT NULL COMMENT '转盘 ID',
user_id BIGINT NOT NULL COMMENT '用户 ID',
created_at_ms BIGINT NOT NULL COMMENT '创建时间UTC epoch ms',
PRIMARY KEY (app_code, wheel_id, user_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='转盘统计用户去重';
CREATE TABLE IF NOT EXISTS im_broadcast_outbox (
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码,用于多租户隔离',
event_id VARCHAR(128) NOT NULL COMMENT '事件幂等 ID',

View File

@ -20,6 +20,8 @@ func registerGRPCServers(server *grpc.Server, services *serviceBundle) {
activityv1.RegisterAdminAchievementServiceServer(server, grpcserver.NewAdminAchievementServer(services.achievement))
activityv1.RegisterLuckyGiftServiceServer(server, grpcserver.NewLuckyGiftServer(services.luckyGift))
activityv1.RegisterAdminLuckyGiftServiceServer(server, grpcserver.NewAdminLuckyGiftServer(services.luckyGift))
activityv1.RegisterWheelServiceServer(server, grpcserver.NewWheelServer(services.wheel))
activityv1.RegisterAdminWheelServiceServer(server, grpcserver.NewAdminWheelServer(services.wheel))
activityv1.RegisterRegistrationRewardServiceServer(server, grpcserver.NewRegistrationRewardServer(services.registrationReward))
activityv1.RegisterAdminRegistrationRewardServiceServer(server, grpcserver.NewAdminRegistrationRewardServer(services.registrationReward))
activityv1.RegisterSevenDayCheckInServiceServer(server, grpcserver.NewSevenDayCheckInServer(services.sevenDayCheckIn))

View File

@ -24,6 +24,7 @@ import (
sevendaycheckinservice "hyapp/services/activity-service/internal/service/sevendaycheckin"
taskservice "hyapp/services/activity-service/internal/service/task"
weeklystarservice "hyapp/services/activity-service/internal/service/weeklystar"
wheelservice "hyapp/services/activity-service/internal/service/wheel"
mysqlstorage "hyapp/services/activity-service/internal/storage/mysql"
)
@ -41,6 +42,7 @@ type serviceBundle struct {
roomTurnoverReward *roomturnoverrewardservice.Service
broadcast *broadcastservice.Service
luckyGift *luckygiftservice.Service
wheel *wheelservice.Service
firstRechargeReward *firstrechargeservice.Service
cumulativeRecharge *cumulativerechargeservice.Service
inviteActivityReward *inviteactivityservice.Service
@ -96,6 +98,7 @@ func buildServiceBundle(cfg config.Config, repository *mysqlstorage.Repository,
luckygiftservice.WithRoomPublisher(tencentClient),
luckygiftservice.WithRegionBroadcaster(broadcastSvc),
)
wheelSvc := wheelservice.New(repository, wheelservice.WithWallet(walletClient))
firstRechargeRewardSvc := firstrechargeservice.New(repository, walletClient)
cumulativeRechargeSvc := cumulativerechargeservice.New(repository, walletClient)
inviteActivityRewardSvc := inviteactivityservice.New(repository, walletClient, activityclient.NewGRPCInviteAttributionSource(clients.userConn))
@ -114,6 +117,7 @@ func buildServiceBundle(cfg config.Config, repository *mysqlstorage.Repository,
roomTurnoverReward: roomTurnoverRewardSvc,
broadcast: broadcastSvc,
luckyGift: luckyGiftSvc,
wheel: wheelSvc,
firstRechargeReward: firstRechargeRewardSvc,
cumulativeRecharge: cumulativeRechargeSvc,
inviteActivityReward: inviteActivityRewardSvc,

View File

@ -0,0 +1,130 @@
package lotteryengine
import (
crand "crypto/rand"
"errors"
"math/big"
"strings"
)
const (
PrizeKindCoin = "coin"
PrizeKindGift = "gift"
PrizeKindProp = "prop"
PrizeKindDress = "dress"
)
var (
ErrNoCandidate = errors.New("lottery candidate is empty")
ErrInvalidWeight = errors.New("lottery candidate weight is invalid")
)
// Candidate 是抽奖引擎唯一关心的奖档视图。
// 业务模块负责在进入引擎前完成资格、奖池和风控过滤;引擎只保证权重随机和 RTP 计值口径一致。
type Candidate struct {
ID string
Weight int64
PrizeKind string
RTPValueCoins int64
}
// NormalizeCandidates 复制并清洗候选集,避免调用方的业务快照被引擎内部兜底权重改写。
// 全部候选权重都为 0 时退化成等权随机,是为了兼容旧配置或脏数据;负权重直接拒绝,防止配置错误改变概率方向。
func NormalizeCandidates(candidates []Candidate) ([]Candidate, error) {
if len(candidates) == 0 {
return nil, ErrNoCandidate
}
out := make([]Candidate, 0, len(candidates))
var totalWeight int64
for _, candidate := range candidates {
if candidate.Weight < 0 {
return nil, ErrInvalidWeight
}
candidate.PrizeKind = NormalizePrizeKind(candidate.PrizeKind)
candidate.RTPValueCoins = NormalizeRTPValueCoins(candidate.PrizeKind, candidate.RTPValueCoins)
if candidate.Weight > 0 {
totalWeight += candidate.Weight
out = append(out, candidate)
}
}
if totalWeight > 0 {
return out, nil
}
out = out[:0]
for _, candidate := range candidates {
candidate.PrizeKind = NormalizePrizeKind(candidate.PrizeKind)
candidate.RTPValueCoins = NormalizeRTPValueCoins(candidate.PrizeKind, candidate.RTPValueCoins)
candidate.Weight = 1
out = append(out, candidate)
}
return out, nil
}
// SelectWeighted 从已经归一化的候选中按权重抽一档。
// 随机源使用 crypto/rand避免高价值奖档被时间种子或并发顺序预测。
func SelectWeighted(candidates []Candidate) (Candidate, error) {
normalized, err := NormalizeCandidates(candidates)
if err != nil {
return Candidate{}, err
}
var totalWeight int64
for _, candidate := range normalized {
totalWeight += candidate.Weight
}
if totalWeight <= 0 {
return Candidate{}, ErrNoCandidate
}
index, err := secureIndex(totalWeight)
if err != nil {
return Candidate{}, err
}
for _, candidate := range normalized {
if index < candidate.Weight {
return candidate, nil
}
index -= candidate.Weight
}
return normalized[len(normalized)-1], nil
}
// NormalizePrizeKind 把后台和活动配置里的同义奖品类型收敛到引擎内部口径。
// prop 和 dress 都表示装扮/道具类权益,后续发放可以不同,但 RTP 口径必须一致归零。
func NormalizePrizeKind(kind string) string {
switch strings.ToLower(strings.TrimSpace(kind)) {
case PrizeKindCoin:
return PrizeKindCoin
case PrizeKindGift:
return PrizeKindGift
case PrizeKindProp, "item", "decoration", "resource":
return PrizeKindProp
case PrizeKindDress, "dress_up", "costume":
return PrizeKindDress
default:
return strings.ToLower(strings.TrimSpace(kind))
}
}
// NormalizeRTPValueCoins 是转盘和其它抽奖共用的 RTP 计值入口。
// 装扮/道具无论后台配置了多少展示价值,都不能进入 RTP 返奖分子;金币和礼物保留配置值,负数按 0 处理。
func NormalizeRTPValueCoins(kind string, value int64) int64 {
if value < 0 {
value = 0
}
switch NormalizePrizeKind(kind) {
case PrizeKindProp, PrizeKindDress:
return 0
default:
return value
}
}
func secureIndex(totalWeight int64) (int64, error) {
if totalWeight <= 0 {
return 0, ErrNoCandidate
}
n, err := crand.Int(crand.Reader, big.NewInt(totalWeight))
if err != nil {
return 0, err
}
return n.Int64(), nil
}

View File

@ -0,0 +1,44 @@
package lotteryengine
import "testing"
func TestNormalizeCandidatesKeepsPositiveWeights(t *testing.T) {
candidates, err := NormalizeCandidates([]Candidate{
{ID: "zero", Weight: 0, PrizeKind: PrizeKindCoin, RTPValueCoins: 10},
{ID: "coin", Weight: 7, PrizeKind: PrizeKindCoin, RTPValueCoins: 20},
})
if err != nil {
t.Fatalf("NormalizeCandidates failed: %v", err)
}
if len(candidates) != 1 || candidates[0].ID != "coin" || candidates[0].Weight != 7 || candidates[0].RTPValueCoins != 20 {
t.Fatalf("unexpected normalized candidates: %#v", candidates)
}
}
func TestNormalizeCandidatesFallsBackToEqualWeight(t *testing.T) {
candidates, err := NormalizeCandidates([]Candidate{
{ID: "a", PrizeKind: PrizeKindCoin},
{ID: "b", PrizeKind: PrizeKindGift},
})
if err != nil {
t.Fatalf("NormalizeCandidates failed: %v", err)
}
if len(candidates) != 2 || candidates[0].Weight != 1 || candidates[1].Weight != 1 {
t.Fatalf("zero-weight candidates should become equal-weight candidates: %#v", candidates)
}
}
func TestNormalizeRTPValueCoinsForcesPropsAndDressToZero(t *testing.T) {
tests := []string{PrizeKindProp, PrizeKindDress, "item", "decoration", "resource", "costume"}
for _, kind := range tests {
if got := NormalizeRTPValueCoins(kind, 999_999); got != 0 {
t.Fatalf("kind %s should not count into RTP, got %d", kind, got)
}
}
if got := NormalizeRTPValueCoins(PrizeKindCoin, 500); got != 500 {
t.Fatalf("coin should keep rtp value, got %d", got)
}
if got := NormalizeRTPValueCoins(PrizeKindGift, -1); got != 0 {
t.Fatalf("negative gift rtp value should clamp to zero, got %d", got)
}
}

View File

@ -0,0 +1,102 @@
package wheel
const (
StatusDraft = "draft"
StatusActive = "active"
StatusPending = "pending"
StatusGranted = "granted"
StatusFailed = "failed"
RewardTypeCoin = "coin"
RewardTypeGift = "gift"
RewardTypeProp = "prop"
RewardTypeDress = "dress"
EventTypeWheelRewardSettlement = "WheelRewardSettlement"
)
// RuleConfig 是转盘配置的不可变版本快照。
// 金币和礼物可以配置 RTP 计值,道具/装扮即使有展示价值也必须在运行态归零。
type RuleConfig struct {
AppCode string
WheelID string
RuleVersion int64
Enabled bool
DrawPriceCoins int64
TargetRTPPPM int64
PoolRatePPM int64
SettlementWindowDraws int64
InitialPoolCoins int64
PoolReserveCoins int64
MaxSingleRTPPayout int64
EffectiveFromMS int64
CreatedByAdminID int64
CreatedAtMS int64
Tiers []Tier
}
// Tier 是一个转盘奖档RewardType 决定发放 owner 和 RTP 计值口径。
type Tier struct {
TierID string
DisplayName string
RewardType string
RewardID string
RewardCount int64
RewardCoins int64
RTPValueCoins int64
WeightPPM int64
Enabled bool
MetadataJSON string
}
type DrawCommand struct {
CommandID string
WheelID string
UserID int64
DeviceID string
DrawCount int32
CoinSpent int64
PaidAtMS int64
VisibleRegionID int64
}
type DrawResult struct {
DrawID string
DrawIDs []string
CommandID string
WheelID string
RuleVersion int64
SelectedTierID string
RewardType string
RewardID string
RewardCount int64
RewardCoins int64
RTPValueCoins int64
RewardStatus string
RTPWindowIndex int64
ActualRTPPPM int64
CreatedAtMS int64
WalletTransactionID string
CoinBalanceAfter int64
MetadataJSON string
}
type DrawSummary struct {
WheelID string
TotalDraws int64
UniqueUsers int64
TotalSpentCoins int64
TotalRTPValueCoins int64
ActualRTPPPM int64
PendingDraws int64
GrantedDraws int64
FailedDraws int64
}
type DrawQuery struct {
WheelID string
UserID int64
Status string
Page int32
PageSize int32
}

View File

@ -0,0 +1,106 @@
package wheel
import (
"fmt"
"strings"
"hyapp/pkg/xerr"
lotteryengine "hyapp/services/activity-service/internal/domain/lotteryengine"
domain "hyapp/services/activity-service/internal/domain/wheel"
)
const defaultWheelID = "default"
func NormalizeWheelID(wheelID string) string {
wheelID = strings.TrimSpace(wheelID)
if wheelID == "" {
return defaultWheelID
}
return wheelID
}
// NormalizeRuleConfig 是转盘配置发布前的唯一收口。
// 这里强制道具/装扮的 RTP 价值为 0避免后台展示价值、运营估值或资源原价污染真实返奖率。
func NormalizeRuleConfig(config domain.RuleConfig) domain.RuleConfig {
config.AppCode = strings.TrimSpace(config.AppCode)
config.WheelID = NormalizeWheelID(config.WheelID)
normalized := make([]domain.Tier, 0, len(config.Tiers))
for _, tier := range config.Tiers {
tier.TierID = strings.TrimSpace(tier.TierID)
tier.DisplayName = strings.TrimSpace(tier.DisplayName)
tier.RewardType = lotteryengine.NormalizePrizeKind(tier.RewardType)
tier.RewardID = strings.TrimSpace(tier.RewardID)
tier.MetadataJSON = strings.TrimSpace(tier.MetadataJSON)
if tier.MetadataJSON == "" {
tier.MetadataJSON = "{}"
}
if tier.RewardCount <= 0 {
tier.RewardCount = 1
}
tier.RTPValueCoins = lotteryengine.NormalizeRTPValueCoins(tier.RewardType, tier.RTPValueCoins)
normalized = append(normalized, tier)
}
config.Tiers = normalized
return config
}
// ValidateRuleConfig 只验证运行所需的硬边界;概率是否满足产品预期由后台配置页负责展示和二次确认。
func ValidateRuleConfig(config domain.RuleConfig) error {
config = NormalizeRuleConfig(config)
if config.WheelID == "" {
return xerr.New(xerr.InvalidArgument, "wheel_id is required")
}
if config.DrawPriceCoins <= 0 {
return xerr.New(xerr.InvalidArgument, "draw_price_coins must be positive")
}
if config.TargetRTPPPM < 0 {
return xerr.New(xerr.InvalidArgument, "target_rtp_ppm must be non-negative")
}
if config.PoolRatePPM < 0 {
return xerr.New(xerr.InvalidArgument, "pool_rate_ppm must be non-negative")
}
if config.SettlementWindowDraws <= 0 {
return xerr.New(xerr.InvalidArgument, "settlement_window_draws must be positive")
}
if len(config.Tiers) == 0 {
return xerr.New(xerr.InvalidArgument, "wheel tiers are required")
}
seen := map[string]bool{}
var enabled bool
for _, tier := range config.Tiers {
if tier.TierID == "" {
return xerr.New(xerr.InvalidArgument, "wheel tier_id is required")
}
if seen[tier.TierID] {
return xerr.New(xerr.InvalidArgument, fmt.Sprintf("duplicate wheel tier_id: %s", tier.TierID))
}
seen[tier.TierID] = true
if tier.WeightPPM < 0 {
return xerr.New(xerr.InvalidArgument, fmt.Sprintf("wheel tier %s weight_ppm must be non-negative", tier.TierID))
}
switch tier.RewardType {
case lotteryengine.PrizeKindCoin:
if tier.RewardCoins < 0 {
return xerr.New(xerr.InvalidArgument, fmt.Sprintf("wheel tier %s reward_coins must be non-negative", tier.TierID))
}
case lotteryengine.PrizeKindGift:
if tier.RewardID == "" {
return xerr.New(xerr.InvalidArgument, fmt.Sprintf("wheel tier %s gift reward_id is required", tier.TierID))
}
case lotteryengine.PrizeKindProp, lotteryengine.PrizeKindDress:
if tier.RewardID == "" {
return xerr.New(xerr.InvalidArgument, fmt.Sprintf("wheel tier %s prop reward_id is required", tier.TierID))
}
if tier.RTPValueCoins != 0 {
return xerr.New(xerr.InvalidArgument, fmt.Sprintf("wheel tier %s prop rtp_value_coins must be zero", tier.TierID))
}
default:
return xerr.New(xerr.InvalidArgument, fmt.Sprintf("unsupported wheel reward_type: %s", tier.RewardType))
}
enabled = enabled || tier.Enabled
}
if !enabled {
return xerr.New(xerr.InvalidArgument, "wheel enabled tiers are required")
}
return nil
}

View File

@ -0,0 +1,41 @@
package wheel
import (
"testing"
domain "hyapp/services/activity-service/internal/domain/wheel"
)
func TestNormalizeRuleConfigForcesPropRTPValueToZero(t *testing.T) {
config := NormalizeRuleConfig(domain.RuleConfig{
WheelID: "classic",
DrawPriceCoins: 100,
SettlementWindowDraws: 1000,
Tiers: []domain.Tier{
{TierID: "dress", RewardType: "dress", RewardID: "frame_1", RTPValueCoins: 999999, WeightPPM: 1, Enabled: true},
{TierID: "prop", RewardType: "item", RewardID: "car_1", RTPValueCoins: 888888, WeightPPM: 1, Enabled: true},
{TierID: "coin", RewardType: "coin", RewardCoins: 100, RTPValueCoins: 100, WeightPPM: 1, Enabled: true},
},
})
if config.Tiers[0].RTPValueCoins != 0 || config.Tiers[1].RTPValueCoins != 0 {
t.Fatalf("prop and dress rtp values must be zero: %#v", config.Tiers)
}
if config.Tiers[2].RTPValueCoins != 100 {
t.Fatalf("coin rtp value should be preserved: %#v", config.Tiers[2])
}
}
func TestValidateRuleConfigRejectsPropRTPValueAfterNormalizationByCallerMutation(t *testing.T) {
config := NormalizeRuleConfig(domain.RuleConfig{
WheelID: "classic",
DrawPriceCoins: 100,
SettlementWindowDraws: 1000,
Tiers: []domain.Tier{
{TierID: "prop", RewardType: "prop", RewardID: "car_1", WeightPPM: 1, Enabled: true},
},
})
config.Tiers[0].RTPValueCoins = 100
if err := ValidateRuleConfig(config); err != nil {
t.Fatalf("ValidateRuleConfig should normalize prop rtp value before validation: %v", err)
}
}

View File

@ -0,0 +1,176 @@
package wheel
import (
"context"
"log/slog"
"strings"
"time"
"google.golang.org/grpc"
walletv1 "hyapp.local/api/proto/wallet/v1"
"hyapp/pkg/appcode"
"hyapp/pkg/logx"
"hyapp/pkg/xerr"
domain "hyapp/services/activity-service/internal/domain/wheel"
)
type Repository interface {
PublishWheelRuleConfig(ctx context.Context, config domain.RuleConfig, nowMS int64) (domain.RuleConfig, error)
GetWheelRuleConfig(ctx context.Context, wheelID string) (domain.RuleConfig, bool, error)
ExecuteWheelDraw(ctx context.Context, cmd domain.DrawCommand, nowMS int64) (domain.DrawResult, error)
ListWheelDraws(ctx context.Context, query domain.DrawQuery) ([]domain.DrawResult, int64, error)
GetWheelDrawSummary(ctx context.Context, query domain.DrawQuery) (domain.DrawSummary, error)
MarkWheelDrawsGranted(ctx context.Context, appCode string, drawIDs []string, transactionID string, nowMS int64) error
}
type WalletClient interface {
CreditWheelReward(ctx context.Context, req *walletv1.CreditWheelRewardRequest, opts ...grpc.CallOption) (*walletv1.CreditWheelRewardResponse, error)
}
type Service struct {
repository Repository
wallet WalletClient
now func() time.Time
}
type Option func(*Service)
func WithWallet(client WalletClient) Option {
return func(s *Service) {
s.wallet = client
}
}
func New(repository Repository, options ...Option) *Service {
service := &Service{repository: repository, now: time.Now}
for _, option := range options {
if option != nil {
option(service)
}
}
return service
}
func (s *Service) SetClock(now func() time.Time) {
if now != nil {
s.now = now
}
}
func (s *Service) Draw(ctx context.Context, cmd domain.DrawCommand) (domain.DrawResult, error) {
if err := s.requireRepository(); err != nil {
return domain.DrawResult{}, err
}
cmd.CommandID = strings.TrimSpace(cmd.CommandID)
cmd.WheelID = NormalizeWheelID(cmd.WheelID)
cmd.DeviceID = strings.TrimSpace(cmd.DeviceID)
if cmd.DrawCount <= 0 {
cmd.DrawCount = 1
}
if cmd.CommandID == "" || cmd.WheelID == "" || cmd.UserID <= 0 || cmd.DeviceID == "" || cmd.CoinSpent <= 0 {
return domain.DrawResult{}, xerr.New(xerr.InvalidArgument, "wheel draw command is incomplete")
}
if cmd.PaidAtMS <= 0 {
// paid_at_ms 来自钱包扣费事实;缺失时使用 UTC 服务端时间,保证 RTP 窗口和审计时间不受本地时区影响。
cmd.PaidAtMS = s.now().UTC().UnixMilli()
}
result, err := s.repository.ExecuteWheelDraw(ctx, cmd, s.now().UTC().UnixMilli())
if err != nil {
return domain.DrawResult{}, err
}
return s.creditCoinRewardFastPath(ctx, cmd, result), nil
}
func (s *Service) UpsertConfig(ctx context.Context, config domain.RuleConfig) (domain.RuleConfig, error) {
if err := s.requireRepository(); err != nil {
return domain.RuleConfig{}, err
}
config = NormalizeRuleConfig(config)
if err := ValidateRuleConfig(config); err != nil {
return domain.RuleConfig{}, err
}
return s.repository.PublishWheelRuleConfig(ctx, config, s.now().UTC().UnixMilli())
}
func (s *Service) GetConfig(ctx context.Context, wheelID string) (domain.RuleConfig, error) {
if err := s.requireRepository(); err != nil {
return domain.RuleConfig{}, err
}
config, exists, err := s.repository.GetWheelRuleConfig(ctx, NormalizeWheelID(wheelID))
if err != nil {
return domain.RuleConfig{}, err
}
if !exists {
return domain.RuleConfig{}, xerr.New(xerr.NotFound, "wheel config not found")
}
return config, nil
}
func (s *Service) ListDraws(ctx context.Context, query domain.DrawQuery) ([]domain.DrawResult, int64, error) {
if err := s.requireRepository(); err != nil {
return nil, 0, err
}
return s.repository.ListWheelDraws(ctx, normalizeDrawQuery(query))
}
func (s *Service) GetDrawSummary(ctx context.Context, query domain.DrawQuery) (domain.DrawSummary, error) {
if err := s.requireRepository(); err != nil {
return domain.DrawSummary{}, err
}
return s.repository.GetWheelDrawSummary(ctx, normalizeDrawQuery(query))
}
func (s *Service) creditCoinRewardFastPath(ctx context.Context, cmd domain.DrawCommand, result domain.DrawResult) domain.DrawResult {
if result.RewardType != domain.RewardTypeCoin || result.RewardCoins <= 0 || result.RewardStatus == domain.StatusGranted || len(result.DrawIDs) != 1 {
return result
}
if s.wallet == nil {
logx.Warn(ctx, "wheel_reward_wallet_not_configured", slog.String("draw_id", result.DrawID))
return result
}
resp, err := s.wallet.CreditWheelReward(appcode.WithContext(ctx, appcode.FromContext(ctx)), &walletv1.CreditWheelRewardRequest{
CommandId: "wheel_reward:" + result.DrawID,
AppCode: appcode.FromContext(ctx),
TargetUserId: cmd.UserID,
Amount: result.RewardCoins,
DrawId: result.DrawID,
WheelId: result.WheelID,
SelectedTierId: result.SelectedTierID,
Reason: "wheel_reward",
VisibleRegionId: cmd.VisibleRegionID,
})
if err != nil {
// 钱包不可用不能回滚已经确认的抽奖事实pending 记录和 outbox 保留,后续补偿 worker 可以继续处理。
logx.Error(ctx, "wheel_reward_fast_path_failed", err, slog.String("draw_id", result.DrawID), slog.String("command_id", result.CommandID))
return result
}
result.RewardStatus = domain.StatusGranted
result.WalletTransactionID = resp.GetTransactionId()
result.CoinBalanceAfter = resp.GetBalance().GetAvailableAmount()
if err := s.repository.MarkWheelDrawsGranted(appcode.WithContext(context.WithoutCancel(ctx), appcode.FromContext(ctx)), appcode.FromContext(ctx), result.DrawIDs, result.WalletTransactionID, s.now().UTC().UnixMilli()); err != nil {
logx.Error(ctx, "wheel_reward_fast_path_mark_granted_failed", err, slog.String("draw_id", result.DrawID), slog.String("wallet_transaction_id", result.WalletTransactionID))
}
return result
}
func normalizeDrawQuery(query domain.DrawQuery) domain.DrawQuery {
query.WheelID = NormalizeWheelID(query.WheelID)
query.Status = strings.TrimSpace(query.Status)
if query.Page <= 0 {
query.Page = 1
}
if query.PageSize <= 0 {
query.PageSize = 20
}
if query.PageSize > 100 {
query.PageSize = 100
}
return query
}
func (s *Service) requireRepository() error {
if s == nil || s.repository == nil {
return xerr.New(xerr.Unavailable, "wheel repository is not configured")
}
return nil
}

View File

@ -2,14 +2,12 @@ package mysql
import (
"context"
crand "crypto/rand"
"crypto/sha1"
"database/sql"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"math/big"
"sort"
"strings"
"time"
@ -17,6 +15,7 @@ import (
"hyapp/pkg/appcode"
"hyapp/pkg/idgen"
"hyapp/pkg/xerr"
lotteryengine "hyapp/services/activity-service/internal/domain/lotteryengine"
domain "hyapp/services/activity-service/internal/domain/luckygift"
)
@ -2684,7 +2683,6 @@ func (r *Repository) selectLuckyCandidate(config domain.Config, pool string, pla
limited := map[string]bool{}
baseCandidates := make([]luckyCandidate, 0, len(config.Tiers))
var totalWeight int64
for _, tier := range config.Tiers {
// 体验池决定当前用户可见奖档权重必须保持后台配置RTP 缺口只进入监控口径,不能反向改本次概率。
if tier.Pool != pool || !tier.Enabled {
@ -2715,26 +2713,34 @@ func (r *Repository) selectLuckyCandidate(config domain.Config, pool string, pla
})
}
// 正常运行只使用后台配置概率;奖池和风控只删除“本事务不能支付”的候选,不插值、不补缺口。
candidates := luckyConfiguredWeightCandidates(baseCandidates)
for _, candidate := range candidates {
totalWeight += candidate.Weight
}
if len(candidates) == 0 {
if len(baseCandidates) == 0 {
// 全部正奖档都被奖池或风控剪掉时,本次只能落 0 倍;不能强行透支,也不能生成修正档改变概率。
return luckyCandidate{TierID: "no_payable_tier", Source: domain.SourceBaseRTP, Pool: pool}, limited, nil
}
// 使用 crypto/rand 生成随机落点;候选集合已经全部可支付,随机后不再做业务降级。
index, err := luckySecureIndex(totalWeight)
engineCandidates := make([]lotteryengine.Candidate, 0, len(baseCandidates))
candidateByID := make(map[string]luckyCandidate, len(baseCandidates))
for _, candidate := range baseCandidates {
engineCandidates = append(engineCandidates, lotteryengine.Candidate{
ID: candidate.TierID,
Weight: candidate.Weight,
PrizeKind: lotteryengine.PrizeKindCoin,
RTPValueCoins: candidate.BaseReward,
})
candidateByID[candidate.TierID] = candidate
}
// 使用通用引擎生成随机落点;候选集合已经全部可支付,随机后不再做业务降级。
selected, err := lotteryengine.SelectWeighted(engineCandidates)
if err != nil {
return luckyCandidate{}, limited, err
}
for _, candidate := range candidates {
if index < candidate.Weight {
return candidate, limited, nil
if candidate, ok := candidateByID[selected.ID]; ok {
if candidate.Weight <= 0 {
// 通用引擎会把全 0 权重脏数据退化为等权;这里同步返回权重快照,方便后台审计看到实际兜底口径。
candidate.Weight = selected.Weight
}
index -= candidate.Weight
return candidate, limited, nil
}
return candidates[len(candidates)-1], limited, nil
return luckyCandidate{}, limited, xerr.New(xerr.Internal, "lottery engine selected unknown lucky tier")
}
func (r *Repository) getLuckyDrawByCommand(ctx context.Context, tx *sql.Tx, appCode, commandID string, forUpdate bool) (domain.DrawResult, bool, error) {
@ -2817,23 +2823,26 @@ func luckyEquivalentDraws(cumulativeWagerCoins int64, referencePrice int64) int6
}
func luckyConfiguredWeightCandidates(candidates []luckyCandidate) []luckyCandidate {
out := make([]luckyCandidate, 0, len(candidates))
var totalWeight int64
engineCandidates := make([]lotteryengine.Candidate, 0, len(candidates))
byID := make(map[string]luckyCandidate, len(candidates))
for _, candidate := range candidates {
if candidate.Weight <= 0 {
continue
}
totalWeight += candidate.Weight
out = append(out, candidate)
engineCandidates = append(engineCandidates, lotteryengine.Candidate{
ID: candidate.TierID,
Weight: candidate.Weight,
PrizeKind: lotteryengine.PrizeKindCoin,
RTPValueCoins: candidate.BaseReward,
})
byID[candidate.TierID] = candidate
}
if totalWeight > 0 {
return out
normalized, err := lotteryengine.NormalizeCandidates(engineCandidates)
if err != nil {
return nil
}
// 老数据或脏配置可能没有权重;为了让抽奖继续暴露结果而不是空候选,退化成等权随机。
out = candidates[:0]
for _, candidate := range candidates {
candidate.Weight = 1
out = append(out, candidate)
out := make([]luckyCandidate, 0, len(normalized))
for _, candidate := range normalized {
item := byID[candidate.ID]
item.Weight = candidate.Weight
out = append(out, item)
}
return out
}
@ -2959,17 +2968,6 @@ func luckyRTPPPM(wager, payout int64) int64 {
return payout * luckyPPMScale / wager
}
func luckySecureIndex(totalWeight int64) (int64, error) {
if totalWeight <= 0 {
return 0, xerr.New(xerr.InvalidArgument, "candidate weight is empty")
}
n, err := crand.Int(crand.Reader, big.NewInt(totalWeight))
if err != nil {
return 0, err
}
return n.Int64(), nil
}
func luckySQLPlaceholders(count int) string {
// database/sql 不支持把 slice 直接绑定到 IN (?),这里生成受控占位符而不是拼接业务值。
if count <= 0 {

View File

@ -78,6 +78,9 @@ func (r *Repository) Migrate(ctx context.Context) error {
if err := r.ensureDefaultLuckyGiftRules(ctx); err != nil {
return err
}
if err := r.ensureWheelTables(ctx); err != nil {
return err
}
if err := r.ensureRoomTurnoverRewardTables(ctx); err != nil {
return err
}

View File

@ -0,0 +1,817 @@
package mysql
import (
"context"
"crypto/sha1"
"database/sql"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"strings"
"hyapp/pkg/appcode"
"hyapp/pkg/idgen"
"hyapp/pkg/xerr"
lotteryengine "hyapp/services/activity-service/internal/domain/lotteryengine"
domain "hyapp/services/activity-service/internal/domain/wheel"
)
const wheelPPMScale int64 = 1_000_000
type wheelRTPWindow struct {
WheelID string
WindowIndex int64
TargetRTPPPM int64
ControlDraws int64
PaidDraws int64
WagerCoins int64
TargetPayout int64
ActualRTPValue int64
CarryPPM int64
Status string
}
type wheelPool struct {
WheelID string
Balance int64
ReserveFloor int64
TotalIn int64
TotalOut int64
}
type wheelCandidate struct {
Tier domain.Tier
}
// PublishWheelRuleConfig 新增转盘不可变配置版本;历史 draw record 永远引用旧版本,避免后台改配置后审计口径漂移。
func (r *Repository) PublishWheelRuleConfig(ctx context.Context, config domain.RuleConfig, nowMS int64) (domain.RuleConfig, error) {
if r == nil || r.db == nil {
return domain.RuleConfig{}, xerr.New(xerr.Unavailable, "mysql repository is not configured")
}
appCode := appcode.FromContext(ctx)
config.AppCode = appCode
config.WheelID = normalizeWheelID(config.WheelID)
config.Tiers = normalizeWheelTiers(config.Tiers)
tx, err := r.db.BeginTx(ctx, nil)
if err != nil {
return domain.RuleConfig{}, err
}
defer func() { _ = tx.Rollback() }()
var latest sql.NullInt64
if err := tx.QueryRowContext(ctx, `
SELECT MAX(rule_version)
FROM wheel_rule_versions
WHERE app_code = ? AND wheel_id = ?
FOR UPDATE`,
appCode, config.WheelID,
).Scan(&latest); err != nil {
return domain.RuleConfig{}, err
}
if latest.Valid {
config.RuleVersion = latest.Int64 + 1
} else {
config.RuleVersion = 1
}
if config.EffectiveFromMS <= 0 {
config.EffectiveFromMS = nowMS
}
config.CreatedAtMS = nowMS
if _, err := tx.ExecContext(ctx, `
INSERT INTO wheel_rule_versions (
app_code, wheel_id, rule_version, enabled, draw_price_coins, target_rtp_ppm, pool_rate_ppm,
settlement_window_draws, initial_pool_coins, pool_reserve_coins, max_single_rtp_payout,
effective_from_ms, created_by_admin_id, created_at_ms
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
appCode, config.WheelID, config.RuleVersion, config.Enabled, config.DrawPriceCoins, config.TargetRTPPPM, config.PoolRatePPM,
config.SettlementWindowDraws, config.InitialPoolCoins, config.PoolReserveCoins, config.MaxSingleRTPPayout,
config.EffectiveFromMS, config.CreatedByAdminID, nowMS,
); err != nil {
return domain.RuleConfig{}, err
}
for _, tier := range config.Tiers {
if _, err := tx.ExecContext(ctx, `
INSERT INTO wheel_prize_tiers (
app_code, wheel_id, rule_version, tier_id, display_name, reward_type, reward_id, reward_count,
reward_coins, rtp_value_coins, weight_ppm, enabled, metadata_json
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
appCode, config.WheelID, config.RuleVersion, tier.TierID, tier.DisplayName, tier.RewardType, tier.RewardID, tier.RewardCount,
tier.RewardCoins, tier.RTPValueCoins, tier.WeightPPM, tier.Enabled, wheelMetadataJSON(tier.MetadataJSON),
); err != nil {
return domain.RuleConfig{}, err
}
}
if err := tx.Commit(); err != nil {
return domain.RuleConfig{}, err
}
return config, nil
}
func (r *Repository) GetWheelRuleConfig(ctx context.Context, wheelID string) (domain.RuleConfig, bool, error) {
if r == nil || r.db == nil {
return domain.RuleConfig{}, false, xerr.New(xerr.Unavailable, "mysql repository is not configured")
}
return r.getWheelRuleConfig(ctx, r.db, appcode.FromContext(ctx), normalizeWheelID(wheelID), false)
}
// ExecuteWheelDraw 是转盘线上主事务扣费事实已发生事务内只做命中奖档、RTP 观察、奖池扣减和发放 outbox。
func (r *Repository) ExecuteWheelDraw(ctx context.Context, cmd domain.DrawCommand, nowMS int64) (domain.DrawResult, error) {
if r == nil || r.db == nil {
return domain.DrawResult{}, xerr.New(xerr.Unavailable, "mysql repository is not configured")
}
cmd.WheelID = normalizeWheelID(cmd.WheelID)
if cmd.DrawCount <= 0 {
cmd.DrawCount = 1
}
tx, err := r.db.BeginTx(ctx, nil)
if err != nil {
return domain.DrawResult{}, err
}
defer func() { _ = tx.Rollback() }()
appCode := appcode.FromContext(ctx)
existing, complete, err := r.collectWheelDrawsByCommand(ctx, tx, appCode, cmd.CommandID, cmd.DrawCount)
if err != nil {
return domain.DrawResult{}, err
}
if complete {
return aggregateWheelDrawResults(cmd, existing), nil
}
if len(existing) > 0 {
return domain.DrawResult{}, xerr.New(xerr.Conflict, "partial wheel draw command exists")
}
config, exists, err := r.getWheelRuleConfig(ctx, tx, appCode, cmd.WheelID, true)
if err != nil {
return domain.DrawResult{}, err
}
if !exists || !config.Enabled {
return domain.DrawResult{}, xerr.New(xerr.Conflict, "wheel is disabled")
}
if cmd.CoinSpent <= 0 || cmd.UserID <= 0 || strings.TrimSpace(cmd.CommandID) == "" || strings.TrimSpace(cmd.DeviceID) == "" {
return domain.DrawResult{}, xerr.New(xerr.InvalidArgument, "wheel draw command is incomplete")
}
expectedSpent := config.DrawPriceCoins * int64(cmd.DrawCount)
if cmd.CoinSpent != expectedSpent {
return domain.DrawResult{}, xerr.New(xerr.InvalidArgument, "wheel draw coin_spent does not match current price")
}
if cmd.PaidAtMS <= 0 {
cmd.PaidAtMS = nowMS
}
window, err := r.getOpenWheelRTPWindow(ctx, tx, appCode, config, nowMS)
if err != nil {
return domain.DrawResult{}, err
}
pool, err := r.getOrCreateWheelPool(ctx, tx, appCode, config, nowMS)
if err != nil {
return domain.DrawResult{}, err
}
results := make([]domain.DrawResult, 0, cmd.DrawCount)
var totalPoolIn, totalPoolOut int64
for i := int32(1); i <= cmd.DrawCount; i++ {
unitSpent := luckyDrawUnitSpend(cmd.CoinSpent, cmd.DrawCount, i)
totalPoolIn += unitSpent * config.PoolRatePPM / wheelPPMScale
pool.Balance += unitSpent * config.PoolRatePPM / wheelPPMScale
subCommand := wheelSubCommandID(cmd.CommandID, i, cmd.DrawCount)
result, poolOut, err := r.executeSingleWheelDraw(ctx, tx, appCode, cmd, subCommand, unitSpent, config, &window, &pool, nowMS)
if err != nil {
return domain.DrawResult{}, err
}
totalPoolOut += poolOut
results = append(results, result)
}
if err := r.persistWheelPoolDelta(ctx, tx, appCode, config.WheelID, totalPoolIn, totalPoolOut, nowMS); err != nil {
return domain.DrawResult{}, err
}
if err := r.updateWheelStats(ctx, tx, appCode, config.WheelID, cmd.UserID, cmd.CoinSpent, results, nowMS); err != nil {
return domain.DrawResult{}, err
}
if err := tx.Commit(); err != nil {
return domain.DrawResult{}, err
}
return aggregateWheelDrawResults(cmd, results), nil
}
func (r *Repository) ListWheelDraws(ctx context.Context, query domain.DrawQuery) ([]domain.DrawResult, int64, error) {
if r == nil || r.db == nil {
return nil, 0, xerr.New(xerr.Unavailable, "mysql repository is not configured")
}
whereSQL, args := wheelDrawWhereClause(appcode.FromContext(ctx), query)
var total int64
if err := r.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM wheel_draw_records WHERE `+whereSQL, args...).Scan(&total); err != nil {
return nil, 0, err
}
page, pageSize := normalizeWheelPage(query.Page, query.PageSize)
args = append(args, int(pageSize), int((page-1)*pageSize))
rows, err := r.db.QueryContext(ctx, `
SELECT draw_id, command_id, wheel_id, rule_version, selected_tier_id, reward_type, reward_id,
reward_count, reward_coins, rtp_value_coins, reward_status, reward_transaction_id,
rtp_window_index, created_at_ms, COALESCE(CAST(prize_metadata_json AS CHAR), '{}')
FROM wheel_draw_records
WHERE `+whereSQL+`
ORDER BY created_at_ms DESC, draw_id DESC
LIMIT ? OFFSET ?`, args...)
if err != nil {
return nil, 0, err
}
defer rows.Close()
items := make([]domain.DrawResult, 0, pageSize)
for rows.Next() {
var item domain.DrawResult
if err := rows.Scan(&item.DrawID, &item.CommandID, &item.WheelID, &item.RuleVersion, &item.SelectedTierID, &item.RewardType, &item.RewardID,
&item.RewardCount, &item.RewardCoins, &item.RTPValueCoins, &item.RewardStatus, &item.WalletTransactionID,
&item.RTPWindowIndex, &item.CreatedAtMS, &item.MetadataJSON); err != nil {
return nil, 0, err
}
item.DrawIDs = []string{item.DrawID}
items = append(items, item)
}
return items, total, rows.Err()
}
func (r *Repository) GetWheelDrawSummary(ctx context.Context, query domain.DrawQuery) (domain.DrawSummary, error) {
if r == nil || r.db == nil {
return domain.DrawSummary{}, xerr.New(xerr.Unavailable, "mysql repository is not configured")
}
if query.UserID <= 0 && strings.TrimSpace(query.Status) == "" {
return r.getWheelDrawSummaryFromStats(ctx, query)
}
return r.getWheelDrawSummaryFromRecords(ctx, query)
}
func (r *Repository) getWheelDrawSummaryFromRecords(ctx context.Context, query domain.DrawQuery) (domain.DrawSummary, error) {
whereSQL, args := wheelDrawWhereClause(appcode.FromContext(ctx), query)
var summary domain.DrawSummary
summary.WheelID = normalizeWheelID(query.WheelID)
if err := r.db.QueryRowContext(ctx, `
SELECT
COUNT(*),
COUNT(DISTINCT user_id),
COALESCE(SUM(coin_spent), 0),
COALESCE(SUM(rtp_value_coins), 0),
COALESCE(SUM(CASE WHEN reward_status = 'pending' THEN 1 ELSE 0 END), 0),
COALESCE(SUM(CASE WHEN reward_status = 'granted' THEN 1 ELSE 0 END), 0),
COALESCE(SUM(CASE WHEN reward_status = 'failed' THEN 1 ELSE 0 END), 0)
FROM wheel_draw_records
WHERE `+whereSQL, args...).Scan(
&summary.TotalDraws,
&summary.UniqueUsers,
&summary.TotalSpentCoins,
&summary.TotalRTPValueCoins,
&summary.PendingDraws,
&summary.GrantedDraws,
&summary.FailedDraws,
); err != nil {
return domain.DrawSummary{}, err
}
summary.ActualRTPPPM = wheelRTPPPM(summary.TotalSpentCoins, summary.TotalRTPValueCoins)
return summary, nil
}
func (r *Repository) getWheelDrawSummaryFromStats(ctx context.Context, query domain.DrawQuery) (domain.DrawSummary, error) {
var summary domain.DrawSummary
summary.WheelID = normalizeWheelID(query.WheelID)
err := r.db.QueryRowContext(ctx, `
SELECT total_draws, unique_users, total_spent_coins, total_rtp_value_coins,
pending_draws, granted_draws, failed_draws
FROM wheel_draw_stats
WHERE app_code = ? AND wheel_id = ?`,
appcode.FromContext(ctx), summary.WheelID,
).Scan(
&summary.TotalDraws,
&summary.UniqueUsers,
&summary.TotalSpentCoins,
&summary.TotalRTPValueCoins,
&summary.PendingDraws,
&summary.GrantedDraws,
&summary.FailedDraws,
)
if errors.Is(err, sql.ErrNoRows) {
return summary, nil
}
if err != nil {
return domain.DrawSummary{}, err
}
summary.ActualRTPPPM = wheelRTPPPM(summary.TotalSpentCoins, summary.TotalRTPValueCoins)
return summary, nil
}
func (r *Repository) MarkWheelDrawsGranted(ctx context.Context, appCode string, drawIDs []string, transactionID string, nowMS int64) error {
if r == nil || r.db == nil {
return xerr.New(xerr.Unavailable, "mysql repository is not configured")
}
appCode = appcode.Normalize(appCode)
tx, err := r.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer func() { _ = tx.Rollback() }()
for _, drawID := range drawIDs {
drawID = strings.TrimSpace(drawID)
if drawID == "" {
continue
}
var wheelID, oldStatus string
if err := tx.QueryRowContext(ctx, `
SELECT wheel_id, reward_status
FROM wheel_draw_records
WHERE app_code = ? AND draw_id = ?
FOR UPDATE`,
appCode, drawID,
).Scan(&wheelID, &oldStatus); err != nil {
if errors.Is(err, sql.ErrNoRows) {
continue
}
return err
}
if oldStatus != domain.StatusGranted {
if _, err := tx.ExecContext(ctx, `
UPDATE wheel_draw_records
SET reward_status = 'granted', reward_transaction_id = ?, reward_failure_reason = '', updated_at_ms = ?
WHERE app_code = ? AND draw_id = ? AND reward_status <> 'granted'`,
transactionID, nowMS, appCode, drawID,
); err != nil {
return err
}
// 转盘统计是 draw 事务内即时累加的,后续发奖状态变化必须同步搬移计数,避免后台看到永久 pending。
if _, err := tx.ExecContext(ctx, `
UPDATE wheel_draw_stats
SET pending_draws = CASE WHEN pending_draws > 0 THEN pending_draws - 1 ELSE 0 END,
granted_draws = granted_draws + 1,
updated_at_ms = ?
WHERE app_code = ? AND wheel_id = ?`,
nowMS, appCode, wheelID,
); err != nil {
return err
}
}
if _, err := tx.ExecContext(ctx, `
UPDATE activity_outbox
SET status = 'delivered', locked_by = '', lock_until_ms = 0, last_error = '', updated_at_ms = ?
WHERE app_code = ? AND outbox_id = ?`,
nowMS, appCode, "wheel_reward_"+drawID,
); err != nil {
return err
}
}
return tx.Commit()
}
func (r *Repository) executeSingleWheelDraw(ctx context.Context, tx *sql.Tx, appCode string, cmd domain.DrawCommand, commandID string, unitSpent int64, config domain.RuleConfig, window *wheelRTPWindow, pool *wheelPool, nowMS int64) (domain.DrawResult, int64, error) {
candidates, limited := wheelPayableCandidates(config, pool)
if len(candidates) == 0 {
return domain.DrawResult{}, 0, xerr.New(xerr.Conflict, "wheel has no payable tier")
}
engineCandidates := make([]lotteryengine.Candidate, 0, len(candidates))
byID := make(map[string]domain.Tier, len(candidates))
for _, candidate := range candidates {
engineCandidates = append(engineCandidates, lotteryengine.Candidate{
ID: candidate.Tier.TierID,
Weight: candidate.Tier.WeightPPM,
PrizeKind: candidate.Tier.RewardType,
RTPValueCoins: candidate.Tier.RTPValueCoins,
})
byID[candidate.Tier.TierID] = candidate.Tier
}
selected, err := lotteryengine.SelectWeighted(engineCandidates)
if err != nil {
return domain.DrawResult{}, 0, err
}
tier := byID[selected.ID]
tier.RTPValueCoins = selected.RTPValueCoins
pool.Balance -= tier.RTPValueCoins
pool.TotalOut += tier.RTPValueCoins
window.PaidDraws++
window.WagerCoins += unitSpent
window.TargetPayout = window.WagerCoins * window.TargetRTPPPM / wheelPPMScale
window.ActualRTPValue += tier.RTPValueCoins
if _, err := tx.ExecContext(ctx, `
UPDATE wheel_rtp_windows
SET paid_draws = ?, wager_coins = ?, target_payout_coins = ?, actual_rtp_value_coins = ?,
carry_ppm = ?, updated_at_ms = ?
WHERE app_code = ? AND wheel_id = ? AND window_index = ?`,
window.PaidDraws, window.WagerCoins, window.TargetPayout, window.ActualRTPValue,
window.CarryPPM, nowMS, appCode, config.WheelID, window.WindowIndex,
); err != nil {
return domain.DrawResult{}, 0, err
}
drawID := idgen.New("wheel_draw")
status := domain.StatusGranted
if wheelDrawNeedsFulfillment(tier) {
status = domain.StatusPending
}
candidateJSON, _ := json.Marshal(map[string]any{"selected": tier.TierID, "limited": limited})
rtpJSON, _ := json.Marshal(map[string]any{"window_index": window.WindowIndex, "wager_coins": window.WagerCoins, "actual_rtp_value_coins": window.ActualRTPValue})
if _, err := tx.ExecContext(ctx, `
INSERT INTO wheel_draw_records (
app_code, draw_id, command_id, user_id, device_id, visible_region_id, wheel_id, coin_spent,
rule_version, rtp_window_index, selected_tier_id, reward_type, reward_id, reward_count,
reward_coins, rtp_value_coins, candidate_tiers_json, rtp_snapshot_json, prize_metadata_json,
reward_status, paid_at_ms, created_at_ms, updated_at_ms
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
appCode, drawID, commandID, cmd.UserID, cmd.DeviceID, cmd.VisibleRegionID, config.WheelID, unitSpent,
config.RuleVersion, window.WindowIndex, tier.TierID, tier.RewardType, tier.RewardID, tier.RewardCount,
tier.RewardCoins, tier.RTPValueCoins, string(candidateJSON), string(rtpJSON), wheelMetadataJSON(tier.MetadataJSON),
status, cmd.PaidAtMS, nowMS, nowMS,
); err != nil {
return domain.DrawResult{}, 0, err
}
if status == domain.StatusPending {
if err := r.insertWheelRewardOutbox(ctx, tx, appCode, drawID, commandID, cmd, tier, nowMS); err != nil {
return domain.DrawResult{}, 0, err
}
}
return domain.DrawResult{
DrawID: drawID,
CommandID: commandID,
WheelID: config.WheelID,
RuleVersion: config.RuleVersion,
SelectedTierID: tier.TierID,
RewardType: tier.RewardType,
RewardID: tier.RewardID,
RewardCount: tier.RewardCount,
RewardCoins: tier.RewardCoins,
RTPValueCoins: tier.RTPValueCoins,
RewardStatus: status,
RTPWindowIndex: window.WindowIndex,
ActualRTPPPM: wheelRTPPPM(window.WagerCoins, window.ActualRTPValue),
CreatedAtMS: nowMS,
MetadataJSON: wheelMetadataJSON(tier.MetadataJSON),
}, tier.RTPValueCoins, nil
}
func (r *Repository) getWheelRuleConfig(ctx context.Context, queryer interface {
QueryRowContext(context.Context, string, ...any) *sql.Row
QueryContext(context.Context, string, ...any) (*sql.Rows, error)
}, appCode, wheelID string, forUpdate bool) (domain.RuleConfig, bool, error) {
lockSQL := ""
if forUpdate {
lockSQL = " FOR UPDATE"
}
row := queryer.QueryRowContext(ctx, `
SELECT app_code, wheel_id, rule_version, enabled, draw_price_coins, target_rtp_ppm, pool_rate_ppm,
settlement_window_draws, initial_pool_coins, pool_reserve_coins, max_single_rtp_payout,
effective_from_ms, created_by_admin_id, created_at_ms
FROM wheel_rule_versions
WHERE app_code = ? AND wheel_id = ? AND enabled = 1
ORDER BY rule_version DESC
LIMIT 1`+lockSQL,
appCode, wheelID,
)
var config domain.RuleConfig
if err := row.Scan(&config.AppCode, &config.WheelID, &config.RuleVersion, &config.Enabled, &config.DrawPriceCoins, &config.TargetRTPPPM, &config.PoolRatePPM,
&config.SettlementWindowDraws, &config.InitialPoolCoins, &config.PoolReserveCoins, &config.MaxSingleRTPPayout,
&config.EffectiveFromMS, &config.CreatedByAdminID, &config.CreatedAtMS); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return domain.RuleConfig{}, false, nil
}
return domain.RuleConfig{}, false, err
}
rows, err := queryer.QueryContext(ctx, `
SELECT tier_id, display_name, reward_type, reward_id, reward_count, reward_coins, rtp_value_coins, weight_ppm, enabled,
COALESCE(CAST(metadata_json AS CHAR), '{}')
FROM wheel_prize_tiers
WHERE app_code = ? AND wheel_id = ? AND rule_version = ?
ORDER BY tier_id`,
appCode, config.WheelID, config.RuleVersion,
)
if err != nil {
return domain.RuleConfig{}, false, err
}
defer rows.Close()
for rows.Next() {
var tier domain.Tier
if err := rows.Scan(&tier.TierID, &tier.DisplayName, &tier.RewardType, &tier.RewardID, &tier.RewardCount, &tier.RewardCoins, &tier.RTPValueCoins, &tier.WeightPPM, &tier.Enabled, &tier.MetadataJSON); err != nil {
return domain.RuleConfig{}, false, err
}
config.Tiers = append(config.Tiers, tier)
}
if err := rows.Err(); err != nil {
return domain.RuleConfig{}, false, err
}
return config, true, nil
}
func (r *Repository) getOpenWheelRTPWindow(ctx context.Context, tx *sql.Tx, appCode string, config domain.RuleConfig, nowMS int64) (wheelRTPWindow, error) {
window, exists, err := r.getLatestWheelRTPWindow(ctx, tx, appCode, config.WheelID, true)
if err != nil {
return wheelRTPWindow{}, err
}
if !exists {
return r.createWheelRTPWindow(ctx, tx, appCode, config.WheelID, 1, 0, config.SettlementWindowDraws, config.TargetRTPPPM, nowMS)
}
if window.Status == "open" && window.PaidDraws < window.ControlDraws {
return window, nil
}
if window.Status == "open" {
if _, err := tx.ExecContext(ctx, `UPDATE wheel_rtp_windows SET status = 'closed', updated_at_ms = ? WHERE app_code = ? AND wheel_id = ? AND window_index = ?`, nowMS, appCode, config.WheelID, window.WindowIndex); err != nil {
return wheelRTPWindow{}, err
}
}
return r.createWheelRTPWindow(ctx, tx, appCode, config.WheelID, window.WindowIndex+1, window.CarryPPM, config.SettlementWindowDraws, config.TargetRTPPPM, nowMS)
}
func (r *Repository) getLatestWheelRTPWindow(ctx context.Context, tx *sql.Tx, appCode, wheelID string, forUpdate bool) (wheelRTPWindow, bool, error) {
lockSQL := ""
if forUpdate {
lockSQL = " FOR UPDATE"
}
row := tx.QueryRowContext(ctx, `
SELECT wheel_id, window_index, target_rtp_ppm, control_window_draws, paid_draws, wager_coins,
target_payout_coins, actual_rtp_value_coins, carry_ppm, status
FROM wheel_rtp_windows
WHERE app_code = ? AND wheel_id = ?
ORDER BY window_index DESC LIMIT 1`+lockSQL,
appCode, wheelID,
)
var window wheelRTPWindow
if err := row.Scan(&window.WheelID, &window.WindowIndex, &window.TargetRTPPPM, &window.ControlDraws, &window.PaidDraws, &window.WagerCoins,
&window.TargetPayout, &window.ActualRTPValue, &window.CarryPPM, &window.Status); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return wheelRTPWindow{}, false, nil
}
return wheelRTPWindow{}, false, err
}
return window, true, nil
}
func (r *Repository) createWheelRTPWindow(ctx context.Context, tx *sql.Tx, appCode, wheelID string, index, carry, windowDraws, targetPPM, nowMS int64) (wheelRTPWindow, error) {
if windowDraws <= 0 {
windowDraws = 1
}
window := wheelRTPWindow{WheelID: wheelID, WindowIndex: index, TargetRTPPPM: targetPPM, ControlDraws: windowDraws, CarryPPM: carry, Status: "open"}
if _, err := tx.ExecContext(ctx, `
INSERT INTO wheel_rtp_windows (
app_code, wheel_id, window_index, target_rtp_ppm, control_window_draws,
paid_draws, wager_coins, target_payout_coins, actual_rtp_value_coins, carry_ppm, status, created_at_ms, updated_at_ms
) VALUES (?, ?, ?, ?, ?, 0, 0, 0, 0, ?, 'open', ?, ?)`,
appCode, wheelID, index, targetPPM, windowDraws, carry, nowMS, nowMS,
); err != nil {
return wheelRTPWindow{}, err
}
return window, nil
}
func (r *Repository) getOrCreateWheelPool(ctx context.Context, tx *sql.Tx, appCode string, config domain.RuleConfig, nowMS int64) (wheelPool, error) {
row := tx.QueryRowContext(ctx, `SELECT wheel_id, balance, reserve_floor, total_in, total_out FROM wheel_pools WHERE app_code = ? AND wheel_id = ? FOR UPDATE`, appCode, config.WheelID)
var pool wheelPool
if err := row.Scan(&pool.WheelID, &pool.Balance, &pool.ReserveFloor, &pool.TotalIn, &pool.TotalOut); err != nil {
if !errors.Is(err, sql.ErrNoRows) {
return wheelPool{}, err
}
pool = wheelPool{WheelID: config.WheelID, Balance: config.InitialPoolCoins, ReserveFloor: config.PoolReserveCoins}
if _, err := tx.ExecContext(ctx, `INSERT INTO wheel_pools (app_code, wheel_id, balance, reserve_floor, total_in, total_out, created_at_ms, updated_at_ms) VALUES (?, ?, ?, ?, 0, 0, ?, ?)`,
appCode, pool.WheelID, pool.Balance, pool.ReserveFloor, nowMS, nowMS); err != nil {
return wheelPool{}, err
}
}
return pool, nil
}
func (r *Repository) persistWheelPoolDelta(ctx context.Context, tx *sql.Tx, appCode, wheelID string, in, out, nowMS int64) error {
_, err := tx.ExecContext(ctx, `
UPDATE wheel_pools
SET balance = balance + ? - ?, total_in = total_in + ?, total_out = total_out + ?, updated_at_ms = ?
WHERE app_code = ? AND wheel_id = ?`,
in, out, in, out, nowMS, appCode, wheelID,
)
return err
}
func wheelPayableCandidates(config domain.RuleConfig, pool *wheelPool) ([]wheelCandidate, map[string]bool) {
limited := map[string]bool{}
capacity := pool.Balance - pool.ReserveFloor
if capacity < 0 {
capacity = 0
}
if config.MaxSingleRTPPayout > 0 && config.MaxSingleRTPPayout < capacity {
capacity = config.MaxSingleRTPPayout
}
candidates := make([]wheelCandidate, 0, len(config.Tiers))
for _, tier := range config.Tiers {
if !tier.Enabled {
continue
}
tier.RewardType = lotteryengine.NormalizePrizeKind(tier.RewardType)
tier.RTPValueCoins = lotteryengine.NormalizeRTPValueCoins(tier.RewardType, tier.RTPValueCoins)
if tier.RTPValueCoins > capacity {
limited["pool_cap"] = true
continue
}
candidates = append(candidates, wheelCandidate{Tier: tier})
}
return candidates, limited
}
func (r *Repository) updateWheelStats(ctx context.Context, tx *sql.Tx, appCode, wheelID string, userID int64, totalSpent int64, results []domain.DrawResult, nowMS int64) error {
var spent, rtpValue, pending, granted, failed int64
spent = totalSpent
for _, result := range results {
rtpValue += result.RTPValueCoins
switch result.RewardStatus {
case domain.StatusPending:
pending++
case domain.StatusFailed:
failed++
default:
granted++
}
}
if _, err := tx.ExecContext(ctx, `INSERT IGNORE INTO wheel_draw_stat_users (app_code, wheel_id, user_id, created_at_ms) VALUES (?, ?, ?, ?)`, appCode, wheelID, userID, nowMS); err != nil {
return err
}
var uniqueDelta int64
if err := tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM wheel_draw_stat_users WHERE app_code = ? AND wheel_id = ?`, appCode, wheelID).Scan(&uniqueDelta); err != nil {
return err
}
if _, err := tx.ExecContext(ctx, `
INSERT INTO wheel_draw_stats (
app_code, wheel_id, total_draws, unique_users, total_spent_coins, total_rtp_value_coins,
pending_draws, granted_draws, failed_draws, created_at_ms, updated_at_ms
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
total_draws = total_draws + VALUES(total_draws),
unique_users = ?,
total_spent_coins = total_spent_coins + VALUES(total_spent_coins),
total_rtp_value_coins = total_rtp_value_coins + VALUES(total_rtp_value_coins),
pending_draws = pending_draws + VALUES(pending_draws),
granted_draws = granted_draws + VALUES(granted_draws),
failed_draws = failed_draws + VALUES(failed_draws),
updated_at_ms = VALUES(updated_at_ms)`,
appCode, wheelID, int64(len(results)), uniqueDelta, spent, rtpValue, pending, granted, failed, nowMS, nowMS, uniqueDelta,
); err != nil {
return err
}
return nil
}
func (r *Repository) insertWheelRewardOutbox(ctx context.Context, tx *sql.Tx, appCode, drawID, commandID string, cmd domain.DrawCommand, tier domain.Tier, nowMS int64) error {
payload, _ := json.Marshal(map[string]any{
"app_code": appCode,
"draw_id": drawID,
"command_id": commandID,
"user_id": cmd.UserID,
"wheel_id": cmd.WheelID,
"selected_tier_id": tier.TierID,
"reward_type": tier.RewardType,
"reward_id": tier.RewardID,
"reward_count": tier.RewardCount,
"reward_coins": tier.RewardCoins,
"visible_region_id": cmd.VisibleRegionID,
"created_at_ms": nowMS,
})
return r.insertLuckyDrawOutbox(ctx, tx, appCode, "wheel_reward_"+drawID, domain.EventTypeWheelRewardSettlement, payload, nowMS)
}
func (r *Repository) collectWheelDrawsByCommand(ctx context.Context, tx *sql.Tx, appCode, commandID string, drawCount int32) ([]domain.DrawResult, bool, error) {
if drawCount <= 0 {
drawCount = 1
}
results := make([]domain.DrawResult, 0, drawCount)
for i := int32(1); i <= drawCount; i++ {
sub := wheelSubCommandID(commandID, i, drawCount)
row := tx.QueryRowContext(ctx, `
SELECT draw_id, command_id, wheel_id, rule_version, selected_tier_id, reward_type, reward_id, reward_count,
reward_coins, rtp_value_coins, reward_status, reward_transaction_id, rtp_window_index, created_at_ms,
COALESCE(CAST(prize_metadata_json AS CHAR), '{}')
FROM wheel_draw_records
WHERE app_code = ? AND command_id = ?`,
appCode, sub,
)
var result domain.DrawResult
if err := row.Scan(&result.DrawID, &result.CommandID, &result.WheelID, &result.RuleVersion, &result.SelectedTierID, &result.RewardType, &result.RewardID, &result.RewardCount,
&result.RewardCoins, &result.RTPValueCoins, &result.RewardStatus, &result.WalletTransactionID, &result.RTPWindowIndex, &result.CreatedAtMS, &result.MetadataJSON); err != nil {
if errors.Is(err, sql.ErrNoRows) {
continue
}
return nil, false, err
}
results = append(results, result)
}
return results, int32(len(results)) == drawCount, nil
}
func aggregateWheelDrawResults(cmd domain.DrawCommand, results []domain.DrawResult) domain.DrawResult {
if len(results) == 0 {
return domain.DrawResult{}
}
aggregate := results[0]
aggregate.CommandID = cmd.CommandID
aggregate.DrawIDs = make([]string, 0, len(results))
if len(results) == 1 {
aggregate.DrawIDs = []string{aggregate.DrawID}
return aggregate
}
aggregate.SelectedTierID = "batch"
aggregate.RewardType = "batch"
aggregate.RewardID = ""
aggregate.RewardCount = 0
aggregate.RewardCoins = 0
aggregate.RTPValueCoins = 0
aggregate.RewardStatus = domain.StatusGranted
for _, result := range results {
aggregate.DrawIDs = append(aggregate.DrawIDs, result.DrawID)
aggregate.RewardCoins += result.RewardCoins
aggregate.RTPValueCoins += result.RTPValueCoins
if result.RewardStatus == domain.StatusPending {
aggregate.RewardStatus = domain.StatusPending
} else if aggregate.RewardStatus != domain.StatusPending && result.RewardStatus == domain.StatusFailed {
aggregate.RewardStatus = domain.StatusFailed
}
}
return aggregate
}
func wheelSubCommandID(commandID string, drawIndex int32, drawCount int32) string {
if drawCount <= 1 {
return strings.TrimSpace(commandID)
}
suffix := fmt.Sprintf("#%06d", drawIndex)
const maxCommandIDLength = 128
commandID = strings.TrimSpace(commandID)
if len(commandID)+len(suffix) <= maxCommandIDLength {
return commandID + suffix
}
sum := sha1.Sum([]byte(commandID))
hash := hex.EncodeToString(sum[:])[:10]
hashSuffix := "#" + hash + suffix
headLen := maxCommandIDLength - len(hashSuffix)
if headLen < 0 {
headLen = 0
}
if len(commandID) > headLen {
commandID = commandID[:headLen]
}
return commandID + hashSuffix
}
func normalizeWheelID(wheelID string) string {
wheelID = strings.TrimSpace(wheelID)
if wheelID == "" {
return "default"
}
return wheelID
}
func normalizeWheelTiers(tiers []domain.Tier) []domain.Tier {
out := make([]domain.Tier, 0, len(tiers))
for _, tier := range tiers {
tier.TierID = strings.TrimSpace(tier.TierID)
tier.RewardType = lotteryengine.NormalizePrizeKind(tier.RewardType)
tier.RTPValueCoins = lotteryengine.NormalizeRTPValueCoins(tier.RewardType, tier.RTPValueCoins)
tier.MetadataJSON = wheelMetadataJSON(tier.MetadataJSON)
if tier.RewardCount <= 0 {
tier.RewardCount = 1
}
out = append(out, tier)
}
return out
}
func wheelDrawNeedsFulfillment(tier domain.Tier) bool {
return tier.RewardCoins > 0 || strings.TrimSpace(tier.RewardID) != ""
}
func wheelMetadataJSON(raw string) string {
raw = strings.TrimSpace(raw)
if raw == "" || !json.Valid([]byte(raw)) {
return "{}"
}
return raw
}
func wheelRTPPPM(wager, payout int64) int64 {
if wager <= 0 {
return 0
}
return payout * wheelPPMScale / wager
}
func wheelDrawWhereClause(appCode string, query domain.DrawQuery) (string, []any) {
where := []string{"app_code = ?"}
args := []any{appCode}
if query.WheelID != "" {
where = append(where, "wheel_id = ?")
args = append(args, normalizeWheelID(query.WheelID))
}
if query.UserID > 0 {
where = append(where, "user_id = ?")
args = append(args, query.UserID)
}
if query.Status != "" {
where = append(where, "reward_status = ?")
args = append(args, strings.TrimSpace(query.Status))
}
return strings.Join(where, " AND "), args
}
func normalizeWheelPage(page int32, pageSize int32) (int32, int32) {
if page <= 0 {
page = 1
}
if pageSize <= 0 {
pageSize = 20
}
if pageSize > 100 {
pageSize = 100
}
return page, pageSize
}

View File

@ -0,0 +1,139 @@
package mysql
import (
"context"
"hyapp/pkg/xerr"
)
func (r *Repository) ensureWheelTables(ctx context.Context) error {
if r == nil || r.db == nil {
return xerr.New(xerr.Unavailable, "mysql repository is not configured")
}
statements := []string{
`CREATE TABLE IF NOT EXISTS wheel_rule_versions (
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码',
wheel_id VARCHAR(96) NOT NULL COMMENT '转盘 ID',
rule_version BIGINT NOT NULL COMMENT '不可变规则版本',
enabled TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否启用',
draw_price_coins BIGINT NOT NULL COMMENT '单抽价格金币',
target_rtp_ppm BIGINT NOT NULL COMMENT '金币和礼物 RTP 目标ppm',
pool_rate_ppm BIGINT NOT NULL COMMENT '每笔消耗进入转盘基础奖池比例ppm',
settlement_window_draws BIGINT NOT NULL COMMENT 'RTP 观察窗口抽数',
initial_pool_coins BIGINT NOT NULL DEFAULT 0 COMMENT '初始奖池水位',
pool_reserve_coins BIGINT NOT NULL DEFAULT 0 COMMENT '奖池安全水位',
max_single_rtp_payout BIGINT NOT NULL DEFAULT 0 COMMENT '单次计入 RTP 的最大返奖',
effective_from_ms BIGINT NOT NULL COMMENT '生效时间UTC epoch ms',
created_by_admin_id BIGINT NOT NULL DEFAULT 0 COMMENT '发布人',
created_at_ms BIGINT NOT NULL COMMENT '创建时间UTC epoch ms',
PRIMARY KEY (app_code, wheel_id, rule_version),
KEY idx_wheel_rule_versions_latest (app_code, wheel_id, rule_version),
KEY idx_wheel_rule_versions_enabled (app_code, enabled, created_at_ms)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='转盘规则版本表'`,
`CREATE TABLE IF NOT EXISTS wheel_prize_tiers (
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码',
wheel_id VARCHAR(96) NOT NULL COMMENT '转盘 ID',
rule_version BIGINT NOT NULL COMMENT '规则版本',
tier_id VARCHAR(96) NOT NULL COMMENT '奖档 ID',
display_name VARCHAR(128) NOT NULL DEFAULT '' COMMENT '展示名称快照',
reward_type VARCHAR(32) NOT NULL COMMENT 'coin/gift/prop/dress',
reward_id VARCHAR(128) NOT NULL DEFAULT '' COMMENT '礼物或道具资源 ID',
reward_count BIGINT NOT NULL DEFAULT 1 COMMENT '奖励数量',
reward_coins BIGINT NOT NULL DEFAULT 0 COMMENT '金币奖励金额',
rtp_value_coins BIGINT NOT NULL DEFAULT 0 COMMENT '计入 RTP 的奖励价值道具装扮强制为 0',
weight_ppm BIGINT NOT NULL COMMENT '随机权重ppm 口径',
enabled TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否启用',
metadata_json JSON NOT NULL COMMENT '奖品展示和发放扩展快照',
PRIMARY KEY (app_code, wheel_id, rule_version, tier_id),
KEY idx_wheel_prize_tiers_rule (app_code, wheel_id, rule_version)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='转盘奖档表'`,
`CREATE TABLE IF NOT EXISTS wheel_rtp_windows (
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码',
wheel_id VARCHAR(96) NOT NULL COMMENT '转盘 ID',
window_index BIGINT NOT NULL COMMENT 'RTP 窗口序号',
target_rtp_ppm BIGINT NOT NULL COMMENT '窗口目标 RTP',
control_window_draws BIGINT NOT NULL COMMENT '窗口抽数',
paid_draws BIGINT NOT NULL DEFAULT 0 COMMENT '已抽次数',
wager_coins BIGINT NOT NULL DEFAULT 0 COMMENT '消耗金币',
target_payout_coins BIGINT NOT NULL DEFAULT 0 COMMENT '目标返奖',
actual_rtp_value_coins BIGINT NOT NULL DEFAULT 0 COMMENT '实际计入 RTP 的返奖',
carry_ppm BIGINT NOT NULL DEFAULT 0 COMMENT '整数除法余数',
status VARCHAR(32) NOT NULL DEFAULT 'open' COMMENT 'open/closed',
created_at_ms BIGINT NOT NULL COMMENT '创建时间UTC epoch ms',
updated_at_ms BIGINT NOT NULL COMMENT '更新时间UTC epoch ms',
PRIMARY KEY (app_code, wheel_id, window_index),
KEY idx_wheel_rtp_windows_open (app_code, wheel_id, status, window_index)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='转盘 RTP 观察窗口'`,
`CREATE TABLE IF NOT EXISTS wheel_pools (
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码',
wheel_id VARCHAR(96) NOT NULL COMMENT '转盘 ID',
balance BIGINT NOT NULL DEFAULT 0 COMMENT '当前可用水位',
reserve_floor BIGINT NOT NULL DEFAULT 0 COMMENT '安全水位',
total_in BIGINT NOT NULL DEFAULT 0 COMMENT '累计入池',
total_out BIGINT NOT NULL DEFAULT 0 COMMENT '累计 RTP 价值支出',
created_at_ms BIGINT NOT NULL COMMENT '创建时间UTC epoch ms',
updated_at_ms BIGINT NOT NULL COMMENT '更新时间UTC epoch ms',
PRIMARY KEY (app_code, wheel_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='转盘基础奖池'`,
`CREATE TABLE IF NOT EXISTS wheel_draw_records (
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码',
draw_id VARCHAR(128) NOT NULL COMMENT '抽奖 ID',
command_id VARCHAR(128) NOT NULL COMMENT '幂等命令 ID',
user_id BIGINT NOT NULL COMMENT '用户 ID',
device_id VARCHAR(128) NOT NULL COMMENT '设备 ID',
visible_region_id BIGINT NOT NULL DEFAULT 0 COMMENT '可见区域 ID',
wheel_id VARCHAR(96) NOT NULL COMMENT '转盘 ID',
coin_spent BIGINT NOT NULL COMMENT '消耗金币',
rule_version BIGINT NOT NULL COMMENT '规则版本',
rtp_window_index BIGINT NOT NULL COMMENT 'RTP 窗口',
selected_tier_id VARCHAR(96) NOT NULL COMMENT '命中奖档',
reward_type VARCHAR(32) NOT NULL COMMENT 'coin/gift/prop/dress',
reward_id VARCHAR(128) NOT NULL DEFAULT '' COMMENT '礼物或道具资源 ID',
reward_count BIGINT NOT NULL DEFAULT 0 COMMENT '奖励数量',
reward_coins BIGINT NOT NULL DEFAULT 0 COMMENT '金币奖励金额',
rtp_value_coins BIGINT NOT NULL DEFAULT 0 COMMENT '计入 RTP 的奖励价值',
candidate_tiers_json JSON NOT NULL COMMENT '候选过滤快照',
rtp_snapshot_json JSON NOT NULL COMMENT 'RTP 快照',
prize_metadata_json JSON NOT NULL COMMENT '奖品扩展快照',
reward_status VARCHAR(32) NOT NULL COMMENT 'pending/granted/failed',
reward_transaction_id VARCHAR(128) NOT NULL DEFAULT '' COMMENT '钱包或资产发放交易 ID',
reward_failure_reason VARCHAR(255) NOT NULL DEFAULT '' COMMENT '发放失败原因',
paid_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, draw_id),
UNIQUE KEY uk_wheel_draw_command (app_code, command_id),
KEY idx_wheel_draw_wheel (app_code, wheel_id, created_at_ms),
KEY idx_wheel_draw_user (app_code, user_id, created_at_ms),
KEY idx_wheel_draw_status (app_code, reward_status, updated_at_ms),
KEY idx_wheel_draw_created (app_code, created_at_ms, draw_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='转盘抽奖事实'`,
`CREATE TABLE IF NOT EXISTS wheel_draw_stats (
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码',
wheel_id VARCHAR(96) NOT NULL COMMENT '转盘 ID',
total_draws BIGINT NOT NULL DEFAULT 0 COMMENT '抽奖次数',
unique_users BIGINT NOT NULL DEFAULT 0 COMMENT '参与用户数',
total_spent_coins BIGINT NOT NULL DEFAULT 0 COMMENT '消耗金币',
total_rtp_value_coins BIGINT NOT NULL DEFAULT 0 COMMENT '计入 RTP 的返奖价值',
pending_draws BIGINT NOT NULL DEFAULT 0 COMMENT '待发放数量',
granted_draws BIGINT NOT NULL DEFAULT 0 COMMENT '已发放数量',
failed_draws BIGINT NOT NULL DEFAULT 0 COMMENT '发放失败数量',
created_at_ms BIGINT NOT NULL COMMENT '创建时间UTC epoch ms',
updated_at_ms BIGINT NOT NULL COMMENT '更新时间UTC epoch ms',
PRIMARY KEY (app_code, wheel_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='转盘后台汇总统计'`,
`CREATE TABLE IF NOT EXISTS wheel_draw_stat_users (
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码',
wheel_id VARCHAR(96) NOT NULL COMMENT '转盘 ID',
user_id BIGINT NOT NULL COMMENT '用户 ID',
created_at_ms BIGINT NOT NULL COMMENT '创建时间UTC epoch ms',
PRIMARY KEY (app_code, wheel_id, user_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='转盘统计用户去重'`,
}
for _, statement := range statements {
if _, err := r.db.ExecContext(ctx, statement); err != nil {
return err
}
}
return nil
}

View File

@ -0,0 +1,212 @@
package grpc
import (
"context"
activityv1 "hyapp.local/api/proto/activity/v1"
"hyapp/pkg/appcode"
"hyapp/pkg/xerr"
domain "hyapp/services/activity-service/internal/domain/wheel"
service "hyapp/services/activity-service/internal/service/wheel"
)
type WheelServer struct {
activityv1.UnimplementedWheelServiceServer
svc *service.Service
}
func NewWheelServer(svc *service.Service) *WheelServer {
return &WheelServer{svc: svc}
}
func (s *WheelServer) ExecuteWheelDraw(ctx context.Context, req *activityv1.ExecuteWheelDrawRequest) (*activityv1.ExecuteWheelDrawResponse, error) {
meta := req.GetWheel()
ctx = appcode.WithContext(ctx, meta.GetMeta().GetAppCode())
result, err := s.svc.Draw(ctx, domain.DrawCommand{
CommandID: meta.GetCommandId(),
WheelID: meta.GetWheelId(),
UserID: meta.GetUserId(),
DeviceID: meta.GetDeviceId(),
DrawCount: meta.GetDrawCount(),
CoinSpent: meta.GetCoinSpent(),
PaidAtMS: meta.GetPaidAtMs(),
VisibleRegionID: meta.GetVisibleRegionId(),
})
if err != nil {
return nil, xerr.ToGRPCError(err)
}
return &activityv1.ExecuteWheelDrawResponse{Result: wheelDrawResultToProto(result)}, nil
}
type AdminWheelServer struct {
activityv1.UnimplementedAdminWheelServiceServer
svc *service.Service
}
func NewAdminWheelServer(svc *service.Service) *AdminWheelServer {
return &AdminWheelServer{svc: svc}
}
func (s *AdminWheelServer) GetWheelConfig(ctx context.Context, req *activityv1.GetWheelConfigRequest) (*activityv1.GetWheelConfigResponse, error) {
ctx = appcode.WithContext(ctx, req.GetMeta().GetAppCode())
config, err := s.svc.GetConfig(ctx, req.GetWheelId())
if err != nil {
return nil, xerr.ToGRPCError(err)
}
return &activityv1.GetWheelConfigResponse{Config: wheelRuleConfigToProto(config)}, nil
}
func (s *AdminWheelServer) UpsertWheelConfig(ctx context.Context, req *activityv1.UpsertWheelConfigRequest) (*activityv1.UpsertWheelConfigResponse, error) {
ctx = appcode.WithContext(ctx, req.GetMeta().GetAppCode())
config := wheelRuleConfigFromProto(req.GetConfig())
config.CreatedByAdminID = req.GetOperatorAdminId()
updated, err := s.svc.UpsertConfig(ctx, config)
if err != nil {
return nil, xerr.ToGRPCError(err)
}
return &activityv1.UpsertWheelConfigResponse{Config: wheelRuleConfigToProto(updated)}, nil
}
func (s *AdminWheelServer) ListWheelDraws(ctx context.Context, req *activityv1.ListWheelDrawsRequest) (*activityv1.ListWheelDrawsResponse, error) {
ctx = appcode.WithContext(ctx, req.GetMeta().GetAppCode())
draws, total, err := s.svc.ListDraws(ctx, domain.DrawQuery{
WheelID: req.GetWheelId(),
UserID: req.GetUserId(),
Status: req.GetStatus(),
Page: req.GetPage(),
PageSize: req.GetPageSize(),
})
if err != nil {
return nil, xerr.ToGRPCError(err)
}
resp := &activityv1.ListWheelDrawsResponse{Draws: make([]*activityv1.WheelDrawResult, 0, len(draws)), Total: total}
for _, draw := range draws {
resp.Draws = append(resp.Draws, wheelDrawResultToProto(draw))
}
return resp, nil
}
func (s *AdminWheelServer) GetWheelDrawSummary(ctx context.Context, req *activityv1.GetWheelDrawSummaryRequest) (*activityv1.GetWheelDrawSummaryResponse, error) {
ctx = appcode.WithContext(ctx, req.GetMeta().GetAppCode())
summary, err := s.svc.GetDrawSummary(ctx, domain.DrawQuery{
WheelID: req.GetWheelId(),
UserID: req.GetUserId(),
Status: req.GetStatus(),
})
if err != nil {
return nil, xerr.ToGRPCError(err)
}
return &activityv1.GetWheelDrawSummaryResponse{Summary: wheelDrawSummaryToProto(summary)}, nil
}
func wheelRuleConfigFromProto(config *activityv1.WheelRuleConfig) domain.RuleConfig {
if config == nil {
return domain.RuleConfig{}
}
tiers := make([]domain.Tier, 0, len(config.GetTiers()))
for _, tier := range config.GetTiers() {
tiers = append(tiers, domain.Tier{
TierID: tier.GetTierId(),
DisplayName: tier.GetDisplayName(),
RewardType: tier.GetRewardType(),
RewardID: tier.GetRewardId(),
RewardCount: tier.GetRewardCount(),
RewardCoins: tier.GetRewardCoins(),
RTPValueCoins: tier.GetRtpValueCoins(),
WeightPPM: tier.GetWeightPpm(),
Enabled: tier.GetEnabled(),
MetadataJSON: tier.GetMetadataJson(),
})
}
return domain.RuleConfig{
AppCode: config.GetAppCode(),
WheelID: config.GetWheelId(),
RuleVersion: config.GetRuleVersion(),
Enabled: config.GetEnabled(),
DrawPriceCoins: config.GetDrawPriceCoins(),
TargetRTPPPM: config.GetTargetRtpPpm(),
PoolRatePPM: config.GetPoolRatePpm(),
SettlementWindowDraws: config.GetSettlementWindowDraws(),
InitialPoolCoins: config.GetInitialPoolCoins(),
PoolReserveCoins: config.GetPoolReserveCoins(),
MaxSingleRTPPayout: config.GetMaxSingleRtpPayout(),
EffectiveFromMS: config.GetEffectiveFromMs(),
CreatedByAdminID: config.GetCreatedByAdminId(),
CreatedAtMS: config.GetCreatedAtMs(),
Tiers: tiers,
}
}
func wheelRuleConfigToProto(config domain.RuleConfig) *activityv1.WheelRuleConfig {
tiers := make([]*activityv1.WheelPrizeTier, 0, len(config.Tiers))
for _, tier := range config.Tiers {
tiers = append(tiers, &activityv1.WheelPrizeTier{
TierId: tier.TierID,
DisplayName: tier.DisplayName,
RewardType: tier.RewardType,
RewardId: tier.RewardID,
RewardCount: tier.RewardCount,
RewardCoins: tier.RewardCoins,
RtpValueCoins: tier.RTPValueCoins,
WeightPpm: tier.WeightPPM,
Enabled: tier.Enabled,
MetadataJson: tier.MetadataJSON,
})
}
return &activityv1.WheelRuleConfig{
AppCode: config.AppCode,
WheelId: config.WheelID,
RuleVersion: config.RuleVersion,
Enabled: config.Enabled,
DrawPriceCoins: config.DrawPriceCoins,
TargetRtpPpm: config.TargetRTPPPM,
PoolRatePpm: config.PoolRatePPM,
SettlementWindowDraws: config.SettlementWindowDraws,
InitialPoolCoins: config.InitialPoolCoins,
PoolReserveCoins: config.PoolReserveCoins,
MaxSingleRtpPayout: config.MaxSingleRTPPayout,
EffectiveFromMs: config.EffectiveFromMS,
CreatedByAdminId: config.CreatedByAdminID,
CreatedAtMs: config.CreatedAtMS,
Tiers: tiers,
}
}
func wheelDrawResultToProto(result domain.DrawResult) *activityv1.WheelDrawResult {
return &activityv1.WheelDrawResult{
DrawId: result.DrawID,
DrawIds: result.DrawIDs,
CommandId: result.CommandID,
WheelId: result.WheelID,
RuleVersion: result.RuleVersion,
SelectedTierId: result.SelectedTierID,
RewardType: result.RewardType,
RewardId: result.RewardID,
RewardCount: result.RewardCount,
RewardCoins: result.RewardCoins,
RtpValueCoins: result.RTPValueCoins,
RewardStatus: result.RewardStatus,
RtpWindowIndex: result.RTPWindowIndex,
ActualRtpPpm: result.ActualRTPPPM,
CreatedAtMs: result.CreatedAtMS,
WalletTransactionId: result.WalletTransactionID,
CoinBalanceAfter: result.CoinBalanceAfter,
MetadataJson: result.MetadataJSON,
}
}
func wheelDrawSummaryToProto(summary domain.DrawSummary) *activityv1.WheelDrawSummary {
return &activityv1.WheelDrawSummary{
WheelId: summary.WheelID,
TotalDraws: summary.TotalDraws,
UniqueUsers: summary.UniqueUsers,
TotalSpentCoins: summary.TotalSpentCoins,
TotalRtpValueCoins: summary.TotalRTPValueCoins,
ActualRtpPpm: summary.ActualRTPPPM,
PendingDraws: summary.PendingDraws,
GrantedDraws: summary.GrantedDraws,
FailedDraws: summary.FailedDraws,
}
}

View File

@ -110,6 +110,7 @@ func New(cfg config.Config) (*App, error) {
var weeklyStarClient client.WeeklyStarClient = client.NewGRPCWeeklyStarClient(activityConn)
var cpWeeklyRankClient client.CPWeeklyRankClient = client.NewGRPCCPWeeklyRankClient(activityConn)
var agencyOpeningClient client.AgencyOpeningClient = client.NewGRPCAgencyOpeningClient(activityConn)
var wheelClient client.WheelClient = client.NewGRPCWheelClient(activityConn)
var broadcastClient client.BroadcastClient = client.NewGRPCBroadcastClient(activityConn)
var gameClient client.GameClient = client.NewGRPCGameClient(gameConn)
var statisticsClient client.StatisticsClient = client.NewHTTPStatisticsClient(cfg.Statistics.BaseURL, cfg.Statistics.Timeout)
@ -184,6 +185,7 @@ func New(cfg config.Config) (*App, error) {
handler.SetWeeklyStarClient(weeklyStarClient)
handler.SetCPWeeklyRankClient(cpWeeklyRankClient)
handler.SetAgencyOpeningClient(agencyOpeningClient)
handler.SetWheelClient(wheelClient)
handler.SetBroadcastClient(broadcastClient)
handler.SetGameClient(gameClient)
handler.SetStatisticsClient(statisticsClient)

View File

@ -29,6 +29,7 @@ type WalletClient interface {
GetMyVip(ctx context.Context, req *walletv1.GetMyVipRequest) (*walletv1.GetMyVipResponse, error)
PurchaseVip(ctx context.Context, req *walletv1.PurchaseVipRequest) (*walletv1.PurchaseVipResponse, error)
DebitCPBreakupFee(ctx context.Context, req *walletv1.DebitCPBreakupFeeRequest) (*walletv1.DebitCPBreakupFeeResponse, error)
DebitWheelDraw(ctx context.Context, req *walletv1.DebitWheelDrawRequest) (*walletv1.DebitWheelDrawResponse, error)
GrantVip(ctx context.Context, req *walletv1.GrantVipRequest) (*walletv1.GrantVipResponse, error)
TransferCoinFromSeller(ctx context.Context, req *walletv1.TransferCoinFromSellerRequest) (*walletv1.TransferCoinFromSellerResponse, error)
ListCoinSellerSalaryExchangeRateTiers(ctx context.Context, req *walletv1.ListCoinSellerSalaryExchangeRateTiersRequest) (*walletv1.ListCoinSellerSalaryExchangeRateTiersResponse, error)
@ -138,6 +139,10 @@ func (c *grpcWalletClient) DebitCPBreakupFee(ctx context.Context, req *walletv1.
return c.client.DebitCPBreakupFee(ctx, req)
}
func (c *grpcWalletClient) DebitWheelDraw(ctx context.Context, req *walletv1.DebitWheelDrawRequest) (*walletv1.DebitWheelDrawResponse, error) {
return c.client.DebitWheelDraw(ctx, req)
}
func (c *grpcWalletClient) GrantVip(ctx context.Context, req *walletv1.GrantVipRequest) (*walletv1.GrantVipResponse, error) {
return c.client.GrantVip(ctx, req)
}

View File

@ -0,0 +1,40 @@
package client
import (
"context"
activityv1 "hyapp.local/api/proto/activity/v1"
"google.golang.org/grpc"
)
// WheelClient abstracts gateway access to activity-service wheel config and draw execution.
type WheelClient interface {
ExecuteWheelDraw(ctx context.Context, req *activityv1.ExecuteWheelDrawRequest) (*activityv1.ExecuteWheelDrawResponse, error)
GetWheelConfig(ctx context.Context, req *activityv1.GetWheelConfigRequest) (*activityv1.GetWheelConfigResponse, error)
ListWheelDraws(ctx context.Context, req *activityv1.ListWheelDrawsRequest) (*activityv1.ListWheelDrawsResponse, error)
}
type grpcWheelClient struct {
wheel activityv1.WheelServiceClient
admin activityv1.AdminWheelServiceClient
}
func NewGRPCWheelClient(conn *grpc.ClientConn) WheelClient {
return &grpcWheelClient{
wheel: activityv1.NewWheelServiceClient(conn),
admin: activityv1.NewAdminWheelServiceClient(conn),
}
}
func (c *grpcWheelClient) ExecuteWheelDraw(ctx context.Context, req *activityv1.ExecuteWheelDrawRequest) (*activityv1.ExecuteWheelDrawResponse, error) {
return c.wheel.ExecuteWheelDraw(ctx, req)
}
func (c *grpcWheelClient) GetWheelConfig(ctx context.Context, req *activityv1.GetWheelConfigRequest) (*activityv1.GetWheelConfigResponse, error) {
return c.admin.GetWheelConfig(ctx, req)
}
func (c *grpcWheelClient) ListWheelDraws(ctx context.Context, req *activityv1.ListWheelDrawsRequest) (*activityv1.ListWheelDrawsResponse, error) {
return c.admin.ListWheelDraws(ctx, req)
}

View File

@ -27,6 +27,7 @@ type Handler struct {
weeklyStar client.WeeklyStarClient
cpWeeklyRank client.CPWeeklyRankClient
agencyOpening client.AgencyOpeningClient
wheel client.WheelClient
}
type Config struct {
@ -47,6 +48,7 @@ type Config struct {
WeeklyStar client.WeeklyStarClient
CPWeeklyRank client.CPWeeklyRankClient
AgencyOpening client.AgencyOpeningClient
Wheel client.WheelClient
}
func New(config Config) *Handler {
@ -68,6 +70,7 @@ func New(config Config) *Handler {
weeklyStar: config.WeeklyStar,
cpWeeklyRank: config.CPWeeklyRank,
agencyOpening: config.AgencyOpening,
wheel: config.Wheel,
}
}
@ -93,6 +96,10 @@ func (h *Handler) TaskHandlers() httproutes.TaskHandlers {
GetAgencyOpeningStatus: h.getAgencyOpeningStatus,
ApplyAgencyOpening: h.applyAgencyOpening,
ListAgencyOpeningLeaderboard: h.listAgencyOpeningLeaderboard,
GetWheelConfig: h.getWheelConfig,
DrawWheel: h.drawWheel,
ListWheelHistory: h.listWheelHistory,
ListWheelHints: h.listWheelHints,
}
}

View File

@ -0,0 +1,349 @@
package activityapi
import (
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"strings"
"time"
activityv1 "hyapp.local/api/proto/activity/v1"
walletv1 "hyapp.local/api/proto/wallet/v1"
"hyapp/pkg/appcode"
"hyapp/pkg/idgen"
"hyapp/services/gateway-service/internal/auth"
"hyapp/services/gateway-service/internal/transport/http/httpkit"
)
type wheelDrawBody struct {
Amount int64 `json:"amount"`
CommandID string `json:"command_id"`
RoomID string `json:"roomId"`
Times int32 `json:"times"`
}
type wheelConfigData struct {
WheelID string `json:"wheel_id"`
Enabled bool `json:"enabled"`
DrawPriceCoins int64 `json:"draw_price_coins"`
Rewards []wheelPrizeData `json:"rewards"`
}
type wheelPrizeData struct {
TierID string `json:"tier_id"`
DisplayName string `json:"display_name"`
RewardType string `json:"reward_type"`
RewardID string `json:"reward_id"`
RewardCount int64 `json:"reward_count"`
RewardCoins int64 `json:"reward_coins"`
Enabled bool `json:"enabled"`
MetadataJSON string `json:"metadata_json"`
PreviewURL string `json:"preview_url"`
AnimationURL string `json:"animation_url"`
Value int64 `json:"value"`
}
type wheelDrawData struct {
DrawID string `json:"draw_id"`
DrawIDs []string `json:"draw_ids"`
CommandID string `json:"command_id"`
WheelID string `json:"wheel_id"`
SelectedTierID string `json:"selected_tier_id"`
RewardType string `json:"reward_type"`
RewardID string `json:"reward_id"`
RewardCount int64 `json:"reward_count"`
RewardCoins int64 `json:"reward_coins"`
RewardStatus string `json:"reward_status"`
WalletTransactionID string `json:"wallet_transaction_id"`
CoinBalanceAfter int64 `json:"coin_balance_after"`
MetadataJSON string `json:"metadata_json"`
RewardValue int64 `json:"rewardValue"`
Rewards []wheelPrizeData `json:"rewards"`
}
// getWheelConfig 返回 H5 渲染所需的当前奖池配置;只暴露资源图字段,不暴露后台 RTP 控制窗口。
func (h *Handler) getWheelConfig(writer http.ResponseWriter, request *http.Request) {
if h.wheel == nil {
httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
return
}
resp, err := h.wheel.GetWheelConfig(request.Context(), &activityv1.GetWheelConfigRequest{
Meta: httpkit.ActivityMeta(request),
WheelId: wheelIDFromRequest(request),
})
if err != nil {
httpkit.WriteRPCError(writer, request, err)
return
}
httpkit.WriteOK(writer, request, wheelConfigFromProto(resp.GetConfig()))
}
// drawWheel 先完成钱包扣费,再把扣费事实传给 activity-service 抽奖;任何一步失败都直接返回错误,不伪造中奖。
func (h *Handler) drawWheel(writer http.ResponseWriter, request *http.Request) {
if h.wheel == nil || h.walletClient == nil {
httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
return
}
var body wheelDrawBody
if err := httpkit.DecodeJSON(request.Body, &body); err != nil && !errors.Is(err, io.EOF) {
httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidJSON, "invalid request body")
return
}
wheelID := wheelIDFromRequest(request)
times := body.Times
if times <= 0 {
times = 1
}
configResp, err := h.wheel.GetWheelConfig(request.Context(), &activityv1.GetWheelConfigRequest{
Meta: httpkit.ActivityMeta(request),
WheelId: wheelID,
})
if err != nil {
httpkit.WriteRPCError(writer, request, err)
return
}
config := configResp.GetConfig()
if config == nil || !config.GetEnabled() {
httpkit.WriteError(writer, request, http.StatusConflict, httpkit.CodeInvalidArgument, "wheel disabled")
return
}
expectedAmount := config.GetDrawPriceCoins() * int64(times)
if body.Amount > 0 && body.Amount != expectedAmount {
httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "draw amount mismatch")
return
}
commandID := strings.TrimSpace(body.CommandID)
if commandID == "" {
commandID = idgen.New("wheel")
}
debitResp, err := h.walletClient.DebitWheelDraw(request.Context(), &walletv1.DebitWheelDrawRequest{
CommandId: commandID,
AppCode: appcode.FromContext(request.Context()),
UserId: auth.UserIDFromContext(request.Context()),
WheelId: wheelID,
DrawCount: times,
Amount: expectedAmount,
})
if err != nil {
httpkit.WriteRPCError(writer, request, err)
return
}
drawResp, err := h.wheel.ExecuteWheelDraw(request.Context(), &activityv1.ExecuteWheelDrawRequest{
Wheel: &activityv1.WheelDrawMeta{
Meta: httpkit.ActivityMeta(request),
CommandId: commandID,
WheelId: wheelID,
UserId: auth.UserIDFromContext(request.Context()),
DeviceId: wheelDeviceID(request),
DrawCount: times,
CoinSpent: debitResp.GetCoinSpent(),
PaidAtMs: time.Now().UTC().UnixMilli(),
VisibleRegionId: 0,
},
})
if err != nil {
httpkit.WriteRPCError(writer, request, err)
return
}
data := wheelDrawFromProto(drawResp.GetResult())
data.WalletTransactionID = debitResp.GetTransactionId()
data.CoinBalanceAfter = debitResp.GetCoinBalanceAfter()
httpkit.WriteOK(writer, request, data)
}
// listWheelHistory 返回当前用户的转盘抽奖记录;读侧复用 activity-service 明细,避免 H5 直接拼后台接口。
func (h *Handler) listWheelHistory(writer http.ResponseWriter, request *http.Request) {
if h.wheel == nil {
httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
return
}
resp, err := h.wheel.ListWheelDraws(request.Context(), &activityv1.ListWheelDrawsRequest{
Meta: httpkit.ActivityMeta(request),
WheelId: wheelIDFromRequest(request),
UserId: auth.UserIDFromContext(request.Context()),
Page: int32(queryInt(request, "pageNo", 1)),
PageSize: int32(queryInt(request, "pageSize", 20)),
})
if err != nil {
httpkit.WriteRPCError(writer, request, err)
return
}
records := make([]map[string]any, 0, len(resp.GetDraws()))
for _, item := range resp.GetDraws() {
draw := wheelDrawFromProto(item)
records = append(records, map[string]any{
"createdTime": item.GetCreatedAtMs(),
"drawTimes": len(item.GetDrawIds()),
"rewardValue": draw.RewardValue,
"rewards": draw.Rewards,
})
}
httpkit.WriteOK(writer, request, map[string]any{"records": records, "total": resp.GetTotal()})
}
// listWheelHints 返回最近中奖提示;昵称不在 activity-service 明细里,这里用用户 ID 占位,后续可接 user profile 批量补齐。
func (h *Handler) listWheelHints(writer http.ResponseWriter, request *http.Request) {
if h.wheel == nil {
httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
return
}
resp, err := h.wheel.ListWheelDraws(request.Context(), &activityv1.ListWheelDrawsRequest{
Meta: httpkit.ActivityMeta(request),
WheelId: wheelIDFromRequest(request),
Status: "granted",
Page: 1,
PageSize: 20,
})
if err != nil {
httpkit.WriteRPCError(writer, request, err)
return
}
values := make([]map[string]any, 0, len(resp.GetDraws()))
for _, item := range resp.GetDraws() {
reward := wheelPrizeFromDraw(item)
values = append(values, map[string]any{
"avatar": reward.PreviewURL,
"nickname": fmt.Sprintf("Winner %d", len(values)+1),
"value": reward.DisplayName,
})
}
httpkit.WriteOK(writer, request, map[string]any{"values": values})
}
func wheelIDFromRequest(request *http.Request) string {
value := strings.TrimSpace(request.PathValue("wheel_id"))
if value == "" {
return "classic"
}
return value
}
func wheelDeviceID(request *http.Request) string {
for _, value := range []string{
httpkit.FirstQueryOrHeader(request, "device_id", "X-Device-ID", "X-Hyapp-Device-ID"),
auth.SessionIDFromContext(request.Context()),
} {
if strings.TrimSpace(value) != "" {
return value
}
}
return "h5"
}
func wheelConfigFromProto(config *activityv1.WheelRuleConfig) wheelConfigData {
if config == nil {
return wheelConfigData{Rewards: []wheelPrizeData{}}
}
rewards := make([]wheelPrizeData, 0, len(config.GetTiers()))
for _, tier := range config.GetTiers() {
rewards = append(rewards, wheelPrizeFromTier(tier))
}
return wheelConfigData{
WheelID: config.GetWheelId(),
Enabled: config.GetEnabled(),
DrawPriceCoins: config.GetDrawPriceCoins(),
Rewards: rewards,
}
}
func wheelPrizeFromTier(tier *activityv1.WheelPrizeTier) wheelPrizeData {
if tier == nil {
return wheelPrizeData{}
}
previewURL, animationURL := wheelAssetURLs(tier.GetRewardType(), tier.GetMetadataJson())
return wheelPrizeData{
TierID: tier.GetTierId(),
DisplayName: tier.GetDisplayName(),
RewardType: tier.GetRewardType(),
RewardID: tier.GetRewardId(),
RewardCount: tier.GetRewardCount(),
RewardCoins: tier.GetRewardCoins(),
Enabled: tier.GetEnabled(),
MetadataJSON: tier.GetMetadataJson(),
PreviewURL: previewURL,
AnimationURL: animationURL,
Value: wheelPrizeDisplayValue(tier.GetRewardCoins(), tier.GetRtpValueCoins(), tier.GetRewardCount()),
}
}
func wheelDrawFromProto(item *activityv1.WheelDrawResult) wheelDrawData {
if item == nil {
return wheelDrawData{Rewards: []wheelPrizeData{}}
}
reward := wheelPrizeFromDraw(item)
return wheelDrawData{
DrawID: item.GetDrawId(),
DrawIDs: item.GetDrawIds(),
CommandID: item.GetCommandId(),
WheelID: item.GetWheelId(),
SelectedTierID: item.GetSelectedTierId(),
RewardType: item.GetRewardType(),
RewardID: item.GetRewardId(),
RewardCount: item.GetRewardCount(),
RewardCoins: item.GetRewardCoins(),
RewardStatus: item.GetRewardStatus(),
WalletTransactionID: item.GetWalletTransactionId(),
CoinBalanceAfter: item.GetCoinBalanceAfter(),
MetadataJSON: item.GetMetadataJson(),
RewardValue: reward.Value,
Rewards: []wheelPrizeData{reward},
}
}
func wheelPrizeFromDraw(item *activityv1.WheelDrawResult) wheelPrizeData {
if item == nil {
return wheelPrizeData{}
}
previewURL, animationURL := wheelAssetURLs(item.GetRewardType(), item.GetMetadataJson())
return wheelPrizeData{
TierID: item.GetSelectedTierId(),
DisplayName: item.GetSelectedTierId(),
RewardType: item.GetRewardType(),
RewardID: item.GetRewardId(),
RewardCount: item.GetRewardCount(),
RewardCoins: item.GetRewardCoins(),
MetadataJSON: item.GetMetadataJson(),
PreviewURL: previewURL,
AnimationURL: animationURL,
Value: wheelPrizeDisplayValue(item.GetRewardCoins(), item.GetRtpValueCoins(), item.GetRewardCount()),
}
}
func wheelPrizeDisplayValue(rewardCoins int64, internalValueCoins int64, rewardCount int64) int64 {
// H5 只需要一个展示值,不能把后台 RTP 字段原样暴露出去;金币奖品展示金币数,礼物可展示内部估值。
// 道具和装扮不计入 RTP 时内部估值会是 0这时回退奖励数量避免页面拿到 value=0 后看起来像奖品没有价值。
if rewardCoins > 0 {
return rewardCoins
}
if internalValueCoins > 0 {
return internalValueCoins
}
if rewardCount > 0 {
return rewardCount
}
return 1
}
func wheelAssetURLs(rewardType string, metadataJSON string) (string, string) {
var metadata map[string]any
if err := json.NewDecoder(strings.NewReader(strings.TrimSpace(metadataJSON))).Decode(&metadata); err != nil && err != io.EOF {
return "", ""
}
preview := firstMetadataString(metadata, "preview_url", "previewUrl", "asset_url", "assetUrl")
if strings.EqualFold(strings.TrimSpace(rewardType), "coin") {
return preview, ""
}
animation := firstMetadataString(metadata, "animation_url", "animationUrl")
return preview, animation
}
func firstMetadataString(metadata map[string]any, keys ...string) string {
for _, key := range keys {
if value, ok := metadata[key].(string); ok && strings.TrimSpace(value) != "" {
return value
}
}
return ""
}

View File

@ -51,6 +51,7 @@ type Handler struct {
weeklyStar client.WeeklyStarClient
cpWeeklyRank client.CPWeeklyRankClient
agencyOpening client.AgencyOpeningClient
wheel client.WheelClient
broadcastClient client.BroadcastClient
gameClient client.GameClient
statisticsClient client.StatisticsClient
@ -277,6 +278,11 @@ func (h *Handler) SetAgencyOpeningClient(agencyOpening client.AgencyOpeningClien
h.agencyOpening = agencyOpening
}
// SetWheelClient 注入 activity-service 转盘配置和抽奖 client。
func (h *Handler) SetWheelClient(wheel client.WheelClient) {
h.wheel = wheel
}
// SetBroadcastClient 注入 activity-service 播报群成员关系 client。
func (h *Handler) SetBroadcastClient(broadcastClient client.BroadcastClient) {
h.broadcastClient = broadcastClient

View File

@ -206,6 +206,10 @@ type TaskHandlers struct {
GetAgencyOpeningStatus http.HandlerFunc
ApplyAgencyOpening http.HandlerFunc
ListAgencyOpeningLeaderboard http.HandlerFunc
GetWheelConfig http.HandlerFunc
DrawWheel http.HandlerFunc
ListWheelHistory http.HandlerFunc
ListWheelHints http.HandlerFunc
}
type LevelHandlers struct {
@ -528,6 +532,10 @@ func (r routes) registerTaskRoutes() {
r.profile("/activities/agency-opening-event", http.MethodGet, h.GetAgencyOpeningStatus)
r.profile("/activities/agency-opening-event/apply", http.MethodPost, h.ApplyAgencyOpening)
r.profile("/activities/agency-opening-event/leaderboard", http.MethodGet, h.ListAgencyOpeningLeaderboard)
r.public("/activity/wheel/{wheel_id}/config", http.MethodGet, h.GetWheelConfig)
r.profile("/activity/wheel/{wheel_id}/draw", http.MethodPost, h.DrawWheel)
r.profile("/activity/wheel/{wheel_id}/history", http.MethodPost, h.ListWheelHistory)
r.profile("/activity/wheel/{wheel_id}/hints", http.MethodPost, h.ListWheelHints)
// App 侧接口保持稳定;内部仍按 pool_id 走当前 v2 规则、抽奖和返奖实现。
r.profile("/activities/lucky-gifts/check", http.MethodPost, h.CheckLuckyGift)
r.profile("/tasks/tabs", http.MethodGet, h.ListTaskTabs)

View File

@ -1763,6 +1763,10 @@ func (f *fakeWalletClient) DebitCPBreakupFee(context.Context, *walletv1.DebitCPB
return &walletv1.DebitCPBreakupFeeResponse{}, nil
}
func (f *fakeWalletClient) DebitWheelDraw(context.Context, *walletv1.DebitWheelDrawRequest) (*walletv1.DebitWheelDrawResponse, error) {
return &walletv1.DebitWheelDrawResponse{}, nil
}
func (f *fakeWalletClient) GrantVip(_ context.Context, req *walletv1.GrantVipRequest) (*walletv1.GrantVipResponse, error) {
f.lastGrantVip = req
if f.vipErr != nil {

View File

@ -87,6 +87,7 @@ func (h *Handler) Routes(jwtVerifier *auth.Verifier) http.Handler {
WeeklyStar: h.weeklyStar,
CPWeeklyRank: h.cpWeeklyRank,
AgencyOpening: h.agencyOpening,
Wheel: h.wheel,
})
gameAPI := gameapi.New(gameapi.Config{
GameClient: h.gameClient,

View File

@ -855,6 +855,24 @@ type CPBreakupFeeReceipt struct {
Balance AssetBalance
}
// WheelDrawDebitCommand 是转盘抽奖扣费命令;中奖和 RTP 不在钱包内决策,钱包只保留独立 reason 的 COIN 支出事实。
type WheelDrawDebitCommand struct {
AppCode string
CommandID string
UserID int64
WheelID string
DrawCount int32
Amount int64
}
// WheelDrawDebitReceipt 返回转盘抽奖扣费流水activity-service 会用该流水作为抽奖已付费事实。
type WheelDrawDebitReceipt struct {
TransactionID string
CoinSpent int64
CoinBalanceAfter int64
Balance AssetBalance
}
// GrantVipCommand 是活动或后台发放 VIP 的统一入口命令。
type GrantVipCommand struct {
AppCode string
@ -950,6 +968,27 @@ type LuckyGiftRewardReceipt struct {
GrantedAtMS int64
}
// WheelRewardCommand 是 activity-service 用转盘 draw_id 发起的 COIN 入账命令。
type WheelRewardCommand struct {
AppCode string
CommandID string
TargetUserID int64
Amount int64
DrawID string
WheelID string
SelectedTierID string
VisibleRegionID int64
Reason string
}
// WheelRewardReceipt 是转盘金币奖励入账后的稳定回执。
type WheelRewardReceipt struct {
TransactionID string
Balance AssetBalance
Amount int64
GrantedAtMS int64
}
// RoomTurnoverRewardCommand 是 activity-service 每周房间流水奖励的 COIN 入账命令。
type RoomTurnoverRewardCommand struct {
AppCode string

View File

@ -31,10 +31,12 @@ type Repository interface {
TransferSalaryToCoinSeller(ctx context.Context, command ledger.SalaryTransferToCoinSellerCommand) (ledger.SalaryTransferToCoinSellerReceipt, error)
CreditTaskReward(ctx context.Context, command ledger.TaskRewardCommand) (ledger.TaskRewardReceipt, error)
CreditLuckyGiftReward(ctx context.Context, command ledger.LuckyGiftRewardCommand) (ledger.LuckyGiftRewardReceipt, error)
CreditWheelReward(ctx context.Context, command ledger.WheelRewardCommand) (ledger.WheelRewardReceipt, error)
CreditRoomTurnoverReward(ctx context.Context, command ledger.RoomTurnoverRewardCommand) (ledger.RoomTurnoverRewardReceipt, error)
CreditInviteActivityReward(ctx context.Context, command ledger.InviteActivityRewardCommand) (ledger.InviteActivityRewardReceipt, error)
CreditAgencyOpeningReward(ctx context.Context, command ledger.AgencyOpeningRewardCommand) (ledger.AgencyOpeningRewardReceipt, error)
ApplyGameCoinChange(ctx context.Context, command ledger.GameCoinChangeCommand) (ledger.GameCoinChangeReceipt, error)
DebitWheelDraw(ctx context.Context, command ledger.WheelDrawDebitCommand) (ledger.WheelDrawDebitReceipt, error)
GetRedPacketConfig(ctx context.Context, appCode string) (ledger.RedPacketConfig, error)
UpdateRedPacketConfig(ctx context.Context, config ledger.RedPacketConfig) (ledger.RedPacketConfig, error)
CreateRedPacket(ctx context.Context, command ledger.RedPacketCreateCommand) (ledger.RedPacketCreateReceipt, error)
@ -544,6 +546,27 @@ func (s *Service) CreditLuckyGiftReward(ctx context.Context, command ledger.Luck
return s.repository.CreditLuckyGiftReward(ctx, command)
}
// CreditWheelReward 发放转盘金币奖品抽奖、RTP 和奖品命中归 activity-service钱包只负责独立 reason 的幂等入账。
func (s *Service) CreditWheelReward(ctx context.Context, command ledger.WheelRewardCommand) (ledger.WheelRewardReceipt, error) {
if command.CommandID == "" || command.TargetUserID <= 0 || command.Amount <= 0 || command.DrawID == "" || command.WheelID == "" || command.SelectedTierID == "" {
return ledger.WheelRewardReceipt{}, xerr.New(xerr.InvalidArgument, "wheel reward command is incomplete")
}
command.Reason = strings.TrimSpace(command.Reason)
if command.Reason == "" {
return ledger.WheelRewardReceipt{}, xerr.New(xerr.InvalidArgument, "reason is required")
}
if s.repository == nil {
return ledger.WheelRewardReceipt{}, xerr.New(xerr.Unavailable, "wallet repository is not configured")
}
command.AppCode = appcode.Normalize(command.AppCode)
command.DrawID = strings.TrimSpace(command.DrawID)
command.WheelID = strings.TrimSpace(command.WheelID)
command.SelectedTierID = strings.TrimSpace(command.SelectedTierID)
ctx = appcode.WithContext(ctx, command.AppCode)
return s.repository.CreditWheelReward(ctx, command)
}
// CreditRoomTurnoverReward 发放每周房间流水奖励金币;结算归 activity-service钱包只负责幂等入账。
func (s *Service) CreditRoomTurnoverReward(ctx context.Context, command ledger.RoomTurnoverRewardCommand) (ledger.RoomTurnoverRewardReceipt, error) {
// 钱包只接受 activity-service 已经生成好的结算命令;缺 settlement、room 或正向金额时直接拒绝,避免钱包自己推断活动规则。
@ -1097,6 +1120,29 @@ func (s *Service) DebitCPBreakupFee(ctx context.Context, command ledger.CPBreaku
return s.repository.DebitCPBreakupFee(ctx, command)
}
// DebitWheelDraw 扣除转盘抽奖消耗金币;抽奖命中必须在 activity-service 完成wallet 只提供可幂等复用的扣费事实。
func (s *Service) DebitWheelDraw(ctx context.Context, command ledger.WheelDrawDebitCommand) (ledger.WheelDrawDebitReceipt, error) {
if command.CommandID == "" || command.UserID <= 0 || strings.TrimSpace(command.WheelID) == "" {
return ledger.WheelDrawDebitReceipt{}, xerr.New(xerr.InvalidArgument, "wheel draw debit command is incomplete")
}
if len(command.CommandID) > 128 {
return ledger.WheelDrawDebitReceipt{}, xerr.New(xerr.InvalidArgument, "command_id is too long")
}
if command.DrawCount <= 0 {
return ledger.WheelDrawDebitReceipt{}, xerr.New(xerr.InvalidArgument, "draw_count must be positive")
}
if command.Amount <= 0 {
return ledger.WheelDrawDebitReceipt{}, xerr.New(xerr.InvalidArgument, "wheel draw debit amount must be positive")
}
if s.repository == nil {
return ledger.WheelDrawDebitReceipt{}, xerr.New(xerr.Unavailable, "wallet repository is not configured")
}
command.AppCode = appcode.Normalize(command.AppCode)
command.WheelID = strings.TrimSpace(command.WheelID)
ctx = appcode.WithContext(ctx, command.AppCode)
return s.repository.DebitWheelDraw(ctx, command)
}
// GrantVip 是活动和后台赠送 VIP 的统一入口;购买仍走 PurchaseVip 完成扣费。
func (s *Service) GrantVip(ctx context.Context, command ledger.GrantVipCommand) (ledger.GrantVipReceipt, error) {
if command.CommandID == "" || command.TargetUserID <= 0 || command.Level <= 0 {

View File

@ -28,6 +28,7 @@ const (
bizTypeManualCredit = "manual_credit"
bizTypeTaskReward = "task_reward"
bizTypeLuckyGiftReward = "lucky_gift_reward"
bizTypeWheelReward = "wheel_reward"
bizTypeRoomTurnoverReward = "room_turnover_reward"
bizTypeInviteActivityReward = "invite_activity_reward"
bizTypeAgencyOpeningReward = "agency_opening_reward"
@ -45,6 +46,7 @@ const (
bizTypeGameReverse = "game_reverse"
bizTypeHostSalarySettlement = "host_salary_settlement"
bizTypeCPBreakupFee = "cp_breakup_fee"
bizTypeWheelDrawDebit = "wheel_draw_debit"
outboxStatusPending = "pending"
outboxStatusDelivering = "delivering"
outboxStatusDelivered = "delivered"
@ -1100,6 +1102,98 @@ func (r *Repository) DebitCPBreakupFee(ctx context.Context, command ledger.CPBre
}, nil
}
// DebitWheelDraw 在钱包账本中扣除转盘抽奖金币;中奖和返奖由 activity-service 后续处理,钱包只保证付费事实可审计、可幂等。
func (r *Repository) DebitWheelDraw(ctx context.Context, command ledger.WheelDrawDebitCommand) (ledger.WheelDrawDebitReceipt, error) {
if r == nil || r.db == nil {
return ledger.WheelDrawDebitReceipt{}, xerr.New(xerr.Unavailable, "mysql repository is not configured")
}
ctx = contextWithCommandApp(ctx, command.AppCode)
command.AppCode = appcode.FromContext(ctx)
tx, err := r.db.BeginTx(ctx, nil)
if err != nil {
return ledger.WheelDrawDebitReceipt{}, err
}
defer func() { _ = tx.Rollback() }()
requestHash := wheelDrawDebitRequestHash(command)
if txRow, exists, err := r.lookupTransaction(ctx, tx, command.CommandID, requestHash, bizTypeWheelDrawDebit); err != nil || exists {
if err != nil || !exists {
return ledger.WheelDrawDebitReceipt{}, err
}
balance, balanceErr := r.balanceAfterTransaction(ctx, tx, txRow.TransactionID, command.UserID, ledger.AssetCoin)
if balanceErr != nil {
return ledger.WheelDrawDebitReceipt{}, balanceErr
}
return ledger.WheelDrawDebitReceipt{
TransactionID: txRow.TransactionID,
CoinSpent: command.Amount,
CoinBalanceAfter: balance.AvailableAmount,
Balance: balance,
}, nil
}
nowMs := time.Now().UnixMilli()
account, err := r.lockAccount(ctx, tx, command.UserID, ledger.AssetCoin, false, nowMs)
if err != nil {
return ledger.WheelDrawDebitReceipt{}, err
}
if account.AvailableAmount < command.Amount {
return ledger.WheelDrawDebitReceipt{}, xerr.New(xerr.InsufficientBalance, "insufficient balance")
}
transactionID := transactionID(command.AppCode, command.CommandID)
balanceAfter := account.AvailableAmount - command.Amount
metadata := wheelDrawDebitMetadata{
AppCode: command.AppCode,
UserID: command.UserID,
WheelID: command.WheelID,
DrawCount: command.DrawCount,
Amount: command.Amount,
CoinBalanceAfter: balanceAfter,
}
if err := r.insertTransaction(ctx, tx, transactionID, command.CommandID, bizTypeWheelDrawDebit, requestHash, command.WheelID, metadata, nowMs); err != nil {
return ledger.WheelDrawDebitReceipt{}, err
}
if err := r.applyAccountDelta(ctx, tx, account, -command.Amount, 0, nowMs); err != nil {
return ledger.WheelDrawDebitReceipt{}, err
}
balance := ledger.AssetBalance{
AppCode: command.AppCode,
UserID: command.UserID,
AssetType: ledger.AssetCoin,
AvailableAmount: balanceAfter,
FrozenAmount: account.FrozenAmount,
Version: account.Version + 1,
UpdatedAtMs: nowMs,
}
if err := r.insertEntry(ctx, tx, walletEntry{
TransactionID: transactionID,
UserID: command.UserID,
AssetType: ledger.AssetCoin,
AvailableDelta: -command.Amount,
FrozenDelta: 0,
AvailableAfter: balance.AvailableAmount,
FrozenAfter: balance.FrozenAmount,
CreatedAtMS: nowMs,
}); err != nil {
return ledger.WheelDrawDebitReceipt{}, err
}
if err := r.insertWalletOutbox(ctx, tx, []walletOutboxEvent{
balanceChangedEvent(transactionID, command.CommandID, command.UserID, ledger.AssetCoin, -command.Amount, 0, balance.AvailableAmount, balance.FrozenAmount, balance.Version, metadata, nowMs),
}); err != nil {
return ledger.WheelDrawDebitReceipt{}, err
}
if err := tx.Commit(); err != nil {
return ledger.WheelDrawDebitReceipt{}, err
}
return ledger.WheelDrawDebitReceipt{
TransactionID: transactionID,
CoinSpent: command.Amount,
CoinBalanceAfter: balance.AvailableAmount,
Balance: balance,
}, nil
}
// CreditTaskReward 在一个事务内完成任务奖励 COIN 入账、交易分录和 outbox 事件。
func (r *Repository) CreditTaskReward(ctx context.Context, command ledger.TaskRewardCommand) (ledger.TaskRewardReceipt, error) {
if r == nil || r.db == nil {
@ -1247,6 +1341,79 @@ func (r *Repository) CreditLuckyGiftReward(ctx context.Context, command ledger.L
return receiptFromLuckyGiftRewardMetadata(transactionID, metadata), nil
}
// CreditWheelReward 在同一事务里完成转盘金币奖品入账、交易分录和钱包 outbox。
func (r *Repository) CreditWheelReward(ctx context.Context, command ledger.WheelRewardCommand) (ledger.WheelRewardReceipt, error) {
if r == nil || r.db == nil {
return ledger.WheelRewardReceipt{}, xerr.New(xerr.Unavailable, "mysql repository is not configured")
}
ctx = contextWithCommandApp(ctx, command.AppCode)
command.AppCode = appcode.FromContext(ctx)
tx, err := r.db.BeginTx(ctx, nil)
if err != nil {
return ledger.WheelRewardReceipt{}, err
}
defer func() { _ = tx.Rollback() }()
requestHash := wheelRewardRequestHash(command)
if txRow, exists, err := r.lookupTransaction(ctx, tx, command.CommandID, requestHash, bizTypeWheelReward); err != nil || exists {
if err != nil || !exists {
return ledger.WheelRewardReceipt{}, err
}
return r.receiptForWheelRewardTransaction(ctx, tx, txRow.TransactionID)
}
nowMs := time.Now().UnixMilli()
account, err := r.lockAccount(ctx, tx, command.TargetUserID, ledger.AssetCoin, true, nowMs)
if err != nil {
return ledger.WheelRewardReceipt{}, err
}
transactionID := transactionID(command.AppCode, command.CommandID)
balanceAfter := account.AvailableAmount + command.Amount
metadata := wheelRewardMetadata{
AppCode: command.AppCode,
TargetUserID: command.TargetUserID,
AssetType: ledger.AssetCoin,
Amount: command.Amount,
DrawID: command.DrawID,
WheelID: command.WheelID,
SelectedTierID: command.SelectedTierID,
VisibleRegionID: command.VisibleRegionID,
Reason: command.Reason,
BalanceAfter: balanceAfter,
GrantedAtMS: nowMs,
}
if err := r.insertTransaction(ctx, tx, transactionID, command.CommandID, bizTypeWheelReward, requestHash, command.DrawID, metadata, nowMs); err != nil {
return ledger.WheelRewardReceipt{}, err
}
if err := r.applyAccountDelta(ctx, tx, account, command.Amount, 0, nowMs); err != nil {
return ledger.WheelRewardReceipt{}, err
}
if err := r.insertEntry(ctx, tx, walletEntry{
TransactionID: transactionID,
UserID: command.TargetUserID,
AssetType: ledger.AssetCoin,
AvailableDelta: command.Amount,
FrozenDelta: 0,
AvailableAfter: balanceAfter,
FrozenAfter: account.FrozenAmount,
CreatedAtMS: nowMs,
}); err != nil {
return ledger.WheelRewardReceipt{}, err
}
if err := r.insertWalletOutbox(ctx, tx, []walletOutboxEvent{
balanceChangedEvent(transactionID, command.CommandID, command.TargetUserID, ledger.AssetCoin, command.Amount, 0, balanceAfter, account.FrozenAmount, account.Version+1, metadata, nowMs),
wheelRewardCreditedEvent(transactionID, command.CommandID, metadata, nowMs),
}); err != nil {
return ledger.WheelRewardReceipt{}, err
}
if err := tx.Commit(); err != nil {
return ledger.WheelRewardReceipt{}, err
}
return receiptFromWheelRewardMetadata(transactionID, metadata), nil
}
// CreditRoomTurnoverReward 在同一事务里完成每周房间流水奖励 COIN 入账、交易分录和钱包 outbox。
func (r *Repository) CreditRoomTurnoverReward(ctx context.Context, command ledger.RoomTurnoverRewardCommand) (ledger.RoomTurnoverRewardReceipt, error) {
if r == nil || r.db == nil {
@ -3099,6 +3266,15 @@ type cpBreakupFeeMetadata struct {
CoinBalanceAfter int64 `json:"coin_balance_after"`
}
type wheelDrawDebitMetadata struct {
AppCode string `json:"app_code"`
UserID int64 `json:"user_id"`
WheelID string `json:"wheel_id"`
DrawCount int32 `json:"draw_count"`
Amount int64 `json:"amount"`
CoinBalanceAfter int64 `json:"coin_balance_after"`
}
type taskRewardMetadata struct {
AppCode string `json:"app_code"`
TargetUserID int64 `json:"target_user_id"`
@ -3127,6 +3303,20 @@ type luckyGiftRewardMetadata struct {
GrantedAtMS int64 `json:"granted_at_ms"`
}
type wheelRewardMetadata struct {
AppCode string `json:"app_code"`
TargetUserID int64 `json:"target_user_id"`
AssetType string `json:"asset_type"`
Amount int64 `json:"amount"`
DrawID string `json:"draw_id"`
WheelID string `json:"wheel_id"`
SelectedTierID string `json:"selected_tier_id"`
VisibleRegionID int64 `json:"visible_region_id"`
Reason string `json:"reason"`
BalanceAfter int64 `json:"balance_after"`
GrantedAtMS int64 `json:"granted_at_ms"`
}
type roomTurnoverRewardMetadata struct {
AppCode string `json:"app_code"`
TargetUserID int64 `json:"target_user_id"`
@ -3373,6 +3563,38 @@ func receiptFromLuckyGiftRewardMetadata(transactionID string, metadata luckyGift
}
}
func (r *Repository) receiptForWheelRewardTransaction(ctx context.Context, tx *sql.Tx, transactionID string) (ledger.WheelRewardReceipt, error) {
var metadataJSON string
if err := tx.QueryRowContext(ctx,
`SELECT COALESCE(CAST(metadata_json AS CHAR), '{}')
FROM wallet_transactions
WHERE app_code = ? AND transaction_id = ?`,
appcode.FromContext(ctx),
transactionID,
).Scan(&metadataJSON); err != nil {
return ledger.WheelRewardReceipt{}, err
}
var metadata wheelRewardMetadata
if err := json.Unmarshal([]byte(metadataJSON), &metadata); err != nil {
return ledger.WheelRewardReceipt{}, err
}
return receiptFromWheelRewardMetadata(transactionID, metadata), nil
}
func receiptFromWheelRewardMetadata(transactionID string, metadata wheelRewardMetadata) ledger.WheelRewardReceipt {
return ledger.WheelRewardReceipt{
TransactionID: transactionID,
Balance: ledger.AssetBalance{
AppCode: metadata.AppCode,
UserID: metadata.TargetUserID,
AssetType: metadata.AssetType,
AvailableAmount: metadata.BalanceAfter,
},
Amount: metadata.Amount,
GrantedAtMS: metadata.GrantedAtMS,
}
}
func (r *Repository) receiptForRoomTurnoverRewardTransaction(ctx context.Context, tx *sql.Tx, transactionID string) (ledger.RoomTurnoverRewardReceipt, error) {
var metadataJSON string
if err := tx.QueryRowContext(ctx,
@ -3796,6 +4018,33 @@ func luckyGiftRewardCreditedEvent(transactionID string, commandID string, metada
}
}
func wheelRewardCreditedEvent(transactionID string, commandID string, metadata wheelRewardMetadata, nowMs int64) walletOutboxEvent {
return walletOutboxEvent{
EventID: eventID(transactionID, "WalletWheelRewardCredited", metadata.TargetUserID, ledger.AssetCoin),
EventType: "WalletWheelRewardCredited",
TransactionID: transactionID,
CommandID: commandID,
UserID: metadata.TargetUserID,
AssetType: ledger.AssetCoin,
AvailableDelta: metadata.Amount,
FrozenDelta: 0,
Payload: map[string]any{
"transaction_id": transactionID,
"command_id": commandID,
"user_id": metadata.TargetUserID,
"draw_id": metadata.DrawID,
"wheel_id": metadata.WheelID,
"selected_tier_id": metadata.SelectedTierID,
"visible_region_id": metadata.VisibleRegionID,
"region_id": metadata.VisibleRegionID,
"amount": metadata.Amount,
"reason": metadata.Reason,
"created_at_ms": nowMs,
},
CreatedAtMS: nowMs,
}
}
func roomTurnoverRewardCreditedEvent(transactionID string, commandID string, metadata roomTurnoverRewardMetadata, nowMs int64) walletOutboxEvent {
return walletOutboxEvent{
EventID: eventID(transactionID, "WalletRoomTurnoverRewardCredited", metadata.TargetUserID, ledger.AssetCoin),
@ -4142,6 +4391,17 @@ func cpBreakupFeeRequestHash(command ledger.CPBreakupFeeCommand) string {
))
}
func wheelDrawDebitRequestHash(command ledger.WheelDrawDebitCommand) string {
// 转盘扣费幂等覆盖池子、抽数和金额;同 command_id 如果价格或档位变了,必须按冲突处理而不是复用旧扣费。
return stableHash(fmt.Sprintf("wheel_draw_debit|%s|%d|%s|%d|%d",
appcode.Normalize(command.AppCode),
command.UserID,
strings.TrimSpace(command.WheelID),
command.DrawCount,
command.Amount,
))
}
func taskRewardRequestHash(command ledger.TaskRewardCommand) string {
return stableHash(fmt.Sprintf("task_reward|%s|%d|%d|%s|%s|%s|%s",
appcode.Normalize(command.AppCode),
@ -4167,6 +4427,19 @@ func luckyGiftRewardRequestHash(command ledger.LuckyGiftRewardCommand) string {
))
}
func wheelRewardRequestHash(command ledger.WheelRewardCommand) string {
return stableHash(fmt.Sprintf("wheel_reward|%s|%d|%d|%s|%s|%s|%d|%s",
appcode.Normalize(command.AppCode),
command.TargetUserID,
command.Amount,
strings.TrimSpace(command.DrawID),
strings.TrimSpace(command.WheelID),
strings.TrimSpace(command.SelectedTierID),
command.VisibleRegionID,
strings.TrimSpace(command.Reason),
))
}
func roomTurnoverRewardRequestHash(command ledger.RoomTurnoverRewardCommand) string {
return stableHash(fmt.Sprintf("room_turnover_reward|%s|%d|%d|%s|%s|%d|%d|%d|%d|%s|%s",
appcode.Normalize(command.AppCode),

View File

@ -756,6 +756,28 @@ func (s *Server) DebitCPBreakupFee(ctx context.Context, req *walletv1.DebitCPBre
}, nil
}
// DebitWheelDraw 扣除转盘抽奖金币;中奖结果不在 wallet 内生成,避免钱包反向拥有活动规则。
func (s *Server) DebitWheelDraw(ctx context.Context, req *walletv1.DebitWheelDrawRequest) (*walletv1.DebitWheelDrawResponse, error) {
ctx = appcode.WithContext(ctx, req.GetAppCode())
receipt, err := s.svc.DebitWheelDraw(ctx, ledger.WheelDrawDebitCommand{
AppCode: req.GetAppCode(),
CommandID: req.GetCommandId(),
UserID: req.GetUserId(),
WheelID: req.GetWheelId(),
DrawCount: req.GetDrawCount(),
Amount: req.GetAmount(),
})
if err != nil {
return nil, xerr.ToGRPCError(err)
}
return &walletv1.DebitWheelDrawResponse{
TransactionId: receipt.TransactionID,
CoinSpent: receipt.CoinSpent,
CoinBalanceAfter: receipt.CoinBalanceAfter,
Balance: balanceToProto(receipt.Balance),
}, nil
}
// GrantVip 统一处理活动和后台赠送 VIP。
func (s *Server) GrantVip(ctx context.Context, req *walletv1.GrantVipRequest) (*walletv1.GrantVipResponse, error) {
ctx = appcode.WithContext(ctx, req.GetAppCode())
@ -1040,6 +1062,38 @@ func (s *Server) CreditLuckyGiftReward(ctx context.Context, req *walletv1.Credit
}, nil
}
// CreditWheelReward 处理 activity-service 转盘金币奖品 outbox 的入账。
func (s *Server) CreditWheelReward(ctx context.Context, req *walletv1.CreditWheelRewardRequest) (*walletv1.CreditWheelRewardResponse, error) {
ctx = appcode.WithContext(ctx, req.GetAppCode())
receipt, err := s.svc.CreditWheelReward(ctx, ledger.WheelRewardCommand{
AppCode: req.GetAppCode(),
CommandID: req.GetCommandId(),
TargetUserID: req.GetTargetUserId(),
Amount: req.GetAmount(),
DrawID: req.GetDrawId(),
WheelID: req.GetWheelId(),
SelectedTierID: req.GetSelectedTierId(),
VisibleRegionID: req.GetVisibleRegionId(),
Reason: req.GetReason(),
})
if err != nil {
return nil, xerr.ToGRPCError(err)
}
return &walletv1.CreditWheelRewardResponse{
TransactionId: receipt.TransactionID,
Amount: receipt.Amount,
GrantedAtMs: receipt.GrantedAtMS,
Balance: &walletv1.AssetBalance{
AppCode: receipt.Balance.AppCode,
AssetType: receipt.Balance.AssetType,
AvailableAmount: receipt.Balance.AvailableAmount,
FrozenAmount: receipt.Balance.FrozenAmount,
Version: receipt.Balance.Version,
},
}, nil
}
// CreditRoomTurnoverReward 处理 activity-service 每周房间流水奖励金币入账。
func (s *Server) CreditRoomTurnoverReward(ctx context.Context, req *walletv1.CreditRoomTurnoverRewardRequest) (*walletv1.CreditRoomTurnoverRewardResponse, error) {
// gRPC 层不解释活动规则,只把 activity-service 的结算命令转换为钱包领域命令。