修复机器人创建和转盘
This commit is contained in:
parent
a0c9d055f0
commit
db43da8c85
File diff suppressed because it is too large
Load Diff
@ -1886,6 +1886,134 @@ message GetLuckyGiftDrawSummaryResponse {
|
|||||||
LuckyGiftDrawSummary summary = 1;
|
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 是周星周期内参与积分的指定礼物。
|
// WeeklyStarGift 是周星周期内参与积分的指定礼物。
|
||||||
message WeeklyStarGift {
|
message WeeklyStarGift {
|
||||||
string gift_id = 1;
|
string gift_id = 1;
|
||||||
@ -2365,6 +2493,11 @@ service LuckyGiftService {
|
|||||||
rpc ExecuteLuckyGiftDraw(ExecuteLuckyGiftDrawRequest) returns (ExecuteLuckyGiftDrawResponse);
|
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.
|
// RoomTurnoverRewardService owns App reads for the weekly room turnover reward activity.
|
||||||
service RoomTurnoverRewardService {
|
service RoomTurnoverRewardService {
|
||||||
rpc GetRoomTurnoverRewardStatus(GetRoomTurnoverRewardStatusRequest) returns (GetRoomTurnoverRewardStatusResponse);
|
rpc GetRoomTurnoverRewardStatus(GetRoomTurnoverRewardStatusRequest) returns (GetRoomTurnoverRewardStatusResponse);
|
||||||
@ -2536,3 +2669,11 @@ service AdminLuckyGiftService {
|
|||||||
rpc ListLuckyGiftDraws(ListLuckyGiftDrawsRequest) returns (ListLuckyGiftDrawsResponse);
|
rpc ListLuckyGiftDraws(ListLuckyGiftDrawsRequest) returns (ListLuckyGiftDrawsResponse);
|
||||||
rpc GetLuckyGiftDrawSummary(GetLuckyGiftDrawSummaryRequest) returns (GetLuckyGiftDrawSummaryResponse);
|
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);
|
||||||
|
}
|
||||||
|
|||||||
@ -1710,6 +1710,112 @@ var LuckyGiftService_ServiceDesc = grpc.ServiceDesc{
|
|||||||
Metadata: "proto/activity/v1/activity.proto",
|
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 (
|
const (
|
||||||
RoomTurnoverRewardService_GetRoomTurnoverRewardStatus_FullMethodName = "/hyapp.activity.v1.RoomTurnoverRewardService/GetRoomTurnoverRewardStatus"
|
RoomTurnoverRewardService_GetRoomTurnoverRewardStatus_FullMethodName = "/hyapp.activity.v1.RoomTurnoverRewardService/GetRoomTurnoverRewardStatus"
|
||||||
)
|
)
|
||||||
@ -6247,3 +6353,223 @@ var AdminLuckyGiftService_ServiceDesc = grpc.ServiceDesc{
|
|||||||
Streams: []grpc.StreamDesc{},
|
Streams: []grpc.StreamDesc{},
|
||||||
Metadata: "proto/activity/v1/activity.proto",
|
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
@ -1471,6 +1471,22 @@ message DebitCPBreakupFeeResponse {
|
|||||||
AssetBalance balance = 4;
|
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 {
|
message GrantVipRequest {
|
||||||
string command_id = 1;
|
string command_id = 1;
|
||||||
string app_code = 2;
|
string app_code = 2;
|
||||||
@ -1563,6 +1579,27 @@ message CreditLuckyGiftRewardResponse {
|
|||||||
int64 granted_at_ms = 4;
|
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 每周房间流水奖励结算后的金币入账命令。
|
// CreditRoomTurnoverRewardRequest 是 activity-service 每周房间流水奖励结算后的金币入账命令。
|
||||||
message CreditRoomTurnoverRewardRequest {
|
message CreditRoomTurnoverRewardRequest {
|
||||||
string command_id = 1;
|
string command_id = 1;
|
||||||
@ -1928,11 +1965,13 @@ service WalletService {
|
|||||||
rpc GetMyVip(GetMyVipRequest) returns (GetMyVipResponse);
|
rpc GetMyVip(GetMyVipRequest) returns (GetMyVipResponse);
|
||||||
rpc PurchaseVip(PurchaseVipRequest) returns (PurchaseVipResponse);
|
rpc PurchaseVip(PurchaseVipRequest) returns (PurchaseVipResponse);
|
||||||
rpc DebitCPBreakupFee(DebitCPBreakupFeeRequest) returns (DebitCPBreakupFeeResponse);
|
rpc DebitCPBreakupFee(DebitCPBreakupFeeRequest) returns (DebitCPBreakupFeeResponse);
|
||||||
|
rpc DebitWheelDraw(DebitWheelDrawRequest) returns (DebitWheelDrawResponse);
|
||||||
rpc GrantVip(GrantVipRequest) returns (GrantVipResponse);
|
rpc GrantVip(GrantVipRequest) returns (GrantVipResponse);
|
||||||
rpc ListAdminVipLevels(ListAdminVipLevelsRequest) returns (ListAdminVipLevelsResponse);
|
rpc ListAdminVipLevels(ListAdminVipLevelsRequest) returns (ListAdminVipLevelsResponse);
|
||||||
rpc UpdateAdminVipLevels(UpdateAdminVipLevelsRequest) returns (UpdateAdminVipLevelsResponse);
|
rpc UpdateAdminVipLevels(UpdateAdminVipLevelsRequest) returns (UpdateAdminVipLevelsResponse);
|
||||||
rpc CreditTaskReward(CreditTaskRewardRequest) returns (CreditTaskRewardResponse);
|
rpc CreditTaskReward(CreditTaskRewardRequest) returns (CreditTaskRewardResponse);
|
||||||
rpc CreditLuckyGiftReward(CreditLuckyGiftRewardRequest) returns (CreditLuckyGiftRewardResponse);
|
rpc CreditLuckyGiftReward(CreditLuckyGiftRewardRequest) returns (CreditLuckyGiftRewardResponse);
|
||||||
|
rpc CreditWheelReward(CreditWheelRewardRequest) returns (CreditWheelRewardResponse);
|
||||||
rpc CreditRoomTurnoverReward(CreditRoomTurnoverRewardRequest) returns (CreditRoomTurnoverRewardResponse);
|
rpc CreditRoomTurnoverReward(CreditRoomTurnoverRewardRequest) returns (CreditRoomTurnoverRewardResponse);
|
||||||
rpc CreditInviteActivityReward(CreditInviteActivityRewardRequest) returns (CreditInviteActivityRewardResponse);
|
rpc CreditInviteActivityReward(CreditInviteActivityRewardRequest) returns (CreditInviteActivityRewardResponse);
|
||||||
rpc CreditAgencyOpeningReward(CreditAgencyOpeningRewardRequest) returns (CreditAgencyOpeningRewardResponse);
|
rpc CreditAgencyOpeningReward(CreditAgencyOpeningRewardRequest) returns (CreditAgencyOpeningRewardResponse);
|
||||||
|
|||||||
@ -265,11 +265,13 @@ const (
|
|||||||
WalletService_GetMyVip_FullMethodName = "/hyapp.wallet.v1.WalletService/GetMyVip"
|
WalletService_GetMyVip_FullMethodName = "/hyapp.wallet.v1.WalletService/GetMyVip"
|
||||||
WalletService_PurchaseVip_FullMethodName = "/hyapp.wallet.v1.WalletService/PurchaseVip"
|
WalletService_PurchaseVip_FullMethodName = "/hyapp.wallet.v1.WalletService/PurchaseVip"
|
||||||
WalletService_DebitCPBreakupFee_FullMethodName = "/hyapp.wallet.v1.WalletService/DebitCPBreakupFee"
|
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_GrantVip_FullMethodName = "/hyapp.wallet.v1.WalletService/GrantVip"
|
||||||
WalletService_ListAdminVipLevels_FullMethodName = "/hyapp.wallet.v1.WalletService/ListAdminVipLevels"
|
WalletService_ListAdminVipLevels_FullMethodName = "/hyapp.wallet.v1.WalletService/ListAdminVipLevels"
|
||||||
WalletService_UpdateAdminVipLevels_FullMethodName = "/hyapp.wallet.v1.WalletService/UpdateAdminVipLevels"
|
WalletService_UpdateAdminVipLevels_FullMethodName = "/hyapp.wallet.v1.WalletService/UpdateAdminVipLevels"
|
||||||
WalletService_CreditTaskReward_FullMethodName = "/hyapp.wallet.v1.WalletService/CreditTaskReward"
|
WalletService_CreditTaskReward_FullMethodName = "/hyapp.wallet.v1.WalletService/CreditTaskReward"
|
||||||
WalletService_CreditLuckyGiftReward_FullMethodName = "/hyapp.wallet.v1.WalletService/CreditLuckyGiftReward"
|
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_CreditRoomTurnoverReward_FullMethodName = "/hyapp.wallet.v1.WalletService/CreditRoomTurnoverReward"
|
||||||
WalletService_CreditInviteActivityReward_FullMethodName = "/hyapp.wallet.v1.WalletService/CreditInviteActivityReward"
|
WalletService_CreditInviteActivityReward_FullMethodName = "/hyapp.wallet.v1.WalletService/CreditInviteActivityReward"
|
||||||
WalletService_CreditAgencyOpeningReward_FullMethodName = "/hyapp.wallet.v1.WalletService/CreditAgencyOpeningReward"
|
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)
|
GetMyVip(ctx context.Context, in *GetMyVipRequest, opts ...grpc.CallOption) (*GetMyVipResponse, error)
|
||||||
PurchaseVip(ctx context.Context, in *PurchaseVipRequest, opts ...grpc.CallOption) (*PurchaseVipResponse, error)
|
PurchaseVip(ctx context.Context, in *PurchaseVipRequest, opts ...grpc.CallOption) (*PurchaseVipResponse, error)
|
||||||
DebitCPBreakupFee(ctx context.Context, in *DebitCPBreakupFeeRequest, opts ...grpc.CallOption) (*DebitCPBreakupFeeResponse, 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)
|
GrantVip(ctx context.Context, in *GrantVipRequest, opts ...grpc.CallOption) (*GrantVipResponse, error)
|
||||||
ListAdminVipLevels(ctx context.Context, in *ListAdminVipLevelsRequest, opts ...grpc.CallOption) (*ListAdminVipLevelsResponse, error)
|
ListAdminVipLevels(ctx context.Context, in *ListAdminVipLevelsRequest, opts ...grpc.CallOption) (*ListAdminVipLevelsResponse, error)
|
||||||
UpdateAdminVipLevels(ctx context.Context, in *UpdateAdminVipLevelsRequest, opts ...grpc.CallOption) (*UpdateAdminVipLevelsResponse, error)
|
UpdateAdminVipLevels(ctx context.Context, in *UpdateAdminVipLevelsRequest, opts ...grpc.CallOption) (*UpdateAdminVipLevelsResponse, error)
|
||||||
CreditTaskReward(ctx context.Context, in *CreditTaskRewardRequest, opts ...grpc.CallOption) (*CreditTaskRewardResponse, error)
|
CreditTaskReward(ctx context.Context, in *CreditTaskRewardRequest, opts ...grpc.CallOption) (*CreditTaskRewardResponse, error)
|
||||||
CreditLuckyGiftReward(ctx context.Context, in *CreditLuckyGiftRewardRequest, opts ...grpc.CallOption) (*CreditLuckyGiftRewardResponse, 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)
|
CreditRoomTurnoverReward(ctx context.Context, in *CreditRoomTurnoverRewardRequest, opts ...grpc.CallOption) (*CreditRoomTurnoverRewardResponse, error)
|
||||||
CreditInviteActivityReward(ctx context.Context, in *CreditInviteActivityRewardRequest, opts ...grpc.CallOption) (*CreditInviteActivityRewardResponse, error)
|
CreditInviteActivityReward(ctx context.Context, in *CreditInviteActivityRewardRequest, opts ...grpc.CallOption) (*CreditInviteActivityRewardResponse, error)
|
||||||
CreditAgencyOpeningReward(ctx context.Context, in *CreditAgencyOpeningRewardRequest, opts ...grpc.CallOption) (*CreditAgencyOpeningRewardResponse, 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
|
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) {
|
func (c *walletServiceClient) GrantVip(ctx context.Context, in *GrantVipRequest, opts ...grpc.CallOption) (*GrantVipResponse, error) {
|
||||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||||
out := new(GrantVipResponse)
|
out := new(GrantVipResponse)
|
||||||
@ -1071,6 +1085,16 @@ func (c *walletServiceClient) CreditLuckyGiftReward(ctx context.Context, in *Cre
|
|||||||
return out, nil
|
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) {
|
func (c *walletServiceClient) CreditRoomTurnoverReward(ctx context.Context, in *CreditRoomTurnoverRewardRequest, opts ...grpc.CallOption) (*CreditRoomTurnoverRewardResponse, error) {
|
||||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||||
out := new(CreditRoomTurnoverRewardResponse)
|
out := new(CreditRoomTurnoverRewardResponse)
|
||||||
@ -1261,11 +1285,13 @@ type WalletServiceServer interface {
|
|||||||
GetMyVip(context.Context, *GetMyVipRequest) (*GetMyVipResponse, error)
|
GetMyVip(context.Context, *GetMyVipRequest) (*GetMyVipResponse, error)
|
||||||
PurchaseVip(context.Context, *PurchaseVipRequest) (*PurchaseVipResponse, error)
|
PurchaseVip(context.Context, *PurchaseVipRequest) (*PurchaseVipResponse, error)
|
||||||
DebitCPBreakupFee(context.Context, *DebitCPBreakupFeeRequest) (*DebitCPBreakupFeeResponse, error)
|
DebitCPBreakupFee(context.Context, *DebitCPBreakupFeeRequest) (*DebitCPBreakupFeeResponse, error)
|
||||||
|
DebitWheelDraw(context.Context, *DebitWheelDrawRequest) (*DebitWheelDrawResponse, error)
|
||||||
GrantVip(context.Context, *GrantVipRequest) (*GrantVipResponse, error)
|
GrantVip(context.Context, *GrantVipRequest) (*GrantVipResponse, error)
|
||||||
ListAdminVipLevels(context.Context, *ListAdminVipLevelsRequest) (*ListAdminVipLevelsResponse, error)
|
ListAdminVipLevels(context.Context, *ListAdminVipLevelsRequest) (*ListAdminVipLevelsResponse, error)
|
||||||
UpdateAdminVipLevels(context.Context, *UpdateAdminVipLevelsRequest) (*UpdateAdminVipLevelsResponse, error)
|
UpdateAdminVipLevels(context.Context, *UpdateAdminVipLevelsRequest) (*UpdateAdminVipLevelsResponse, error)
|
||||||
CreditTaskReward(context.Context, *CreditTaskRewardRequest) (*CreditTaskRewardResponse, error)
|
CreditTaskReward(context.Context, *CreditTaskRewardRequest) (*CreditTaskRewardResponse, error)
|
||||||
CreditLuckyGiftReward(context.Context, *CreditLuckyGiftRewardRequest) (*CreditLuckyGiftRewardResponse, error)
|
CreditLuckyGiftReward(context.Context, *CreditLuckyGiftRewardRequest) (*CreditLuckyGiftRewardResponse, error)
|
||||||
|
CreditWheelReward(context.Context, *CreditWheelRewardRequest) (*CreditWheelRewardResponse, error)
|
||||||
CreditRoomTurnoverReward(context.Context, *CreditRoomTurnoverRewardRequest) (*CreditRoomTurnoverRewardResponse, error)
|
CreditRoomTurnoverReward(context.Context, *CreditRoomTurnoverRewardRequest) (*CreditRoomTurnoverRewardResponse, error)
|
||||||
CreditInviteActivityReward(context.Context, *CreditInviteActivityRewardRequest) (*CreditInviteActivityRewardResponse, error)
|
CreditInviteActivityReward(context.Context, *CreditInviteActivityRewardRequest) (*CreditInviteActivityRewardResponse, error)
|
||||||
CreditAgencyOpeningReward(context.Context, *CreditAgencyOpeningRewardRequest) (*CreditAgencyOpeningRewardResponse, 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) {
|
func (UnimplementedWalletServiceServer) DebitCPBreakupFee(context.Context, *DebitCPBreakupFeeRequest) (*DebitCPBreakupFeeResponse, error) {
|
||||||
return nil, status.Errorf(codes.Unimplemented, "method DebitCPBreakupFee not implemented")
|
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) {
|
func (UnimplementedWalletServiceServer) GrantVip(context.Context, *GrantVipRequest) (*GrantVipResponse, error) {
|
||||||
return nil, status.Errorf(codes.Unimplemented, "method GrantVip not implemented")
|
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) {
|
func (UnimplementedWalletServiceServer) CreditLuckyGiftReward(context.Context, *CreditLuckyGiftRewardRequest) (*CreditLuckyGiftRewardResponse, error) {
|
||||||
return nil, status.Errorf(codes.Unimplemented, "method CreditLuckyGiftReward not implemented")
|
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) {
|
func (UnimplementedWalletServiceServer) CreditRoomTurnoverReward(context.Context, *CreditRoomTurnoverRewardRequest) (*CreditRoomTurnoverRewardResponse, error) {
|
||||||
return nil, status.Errorf(codes.Unimplemented, "method CreditRoomTurnoverReward not implemented")
|
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)
|
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) {
|
func _WalletService_GrantVip_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||||
in := new(GrantVipRequest)
|
in := new(GrantVipRequest)
|
||||||
if err := dec(in); err != nil {
|
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)
|
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) {
|
func _WalletService_CreditRoomTurnoverReward_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||||
in := new(CreditRoomTurnoverRewardRequest)
|
in := new(CreditRoomTurnoverRewardRequest)
|
||||||
if err := dec(in); err != nil {
|
if err := dec(in); err != nil {
|
||||||
@ -3273,6 +3341,10 @@ var WalletService_ServiceDesc = grpc.ServiceDesc{
|
|||||||
MethodName: "DebitCPBreakupFee",
|
MethodName: "DebitCPBreakupFee",
|
||||||
Handler: _WalletService_DebitCPBreakupFee_Handler,
|
Handler: _WalletService_DebitCPBreakupFee_Handler,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
MethodName: "DebitWheelDraw",
|
||||||
|
Handler: _WalletService_DebitWheelDraw_Handler,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
MethodName: "GrantVip",
|
MethodName: "GrantVip",
|
||||||
Handler: _WalletService_GrantVip_Handler,
|
Handler: _WalletService_GrantVip_Handler,
|
||||||
@ -3293,6 +3365,10 @@ var WalletService_ServiceDesc = grpc.ServiceDesc{
|
|||||||
MethodName: "CreditLuckyGiftReward",
|
MethodName: "CreditLuckyGiftReward",
|
||||||
Handler: _WalletService_CreditLuckyGiftReward_Handler,
|
Handler: _WalletService_CreditLuckyGiftReward_Handler,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
MethodName: "CreditWheelReward",
|
||||||
|
Handler: _WalletService_CreditWheelReward_Handler,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
MethodName: "CreditRoomTurnoverReward",
|
MethodName: "CreditRoomTurnoverReward",
|
||||||
Handler: _WalletService_CreditRoomTurnoverReward_Handler,
|
Handler: _WalletService_CreditRoomTurnoverReward_Handler,
|
||||||
|
|||||||
444
docs/通用抽奖引擎技术文档.md
Normal file
444
docs/通用抽奖引擎技术文档.md
Normal 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 gRPC;H5 页面还需要 gateway 包装 | `services/gateway-service/internal/transport/http/activityapi` |
|
||||||
|
| 后台管理页面 | 当前只有 gRPC 后台能力,未接 admin web 模块 | `server/admin` |
|
||||||
|
|
||||||
@ -70,6 +70,7 @@ import (
|
|||||||
userleaderboardmodule "hyapp-admin-server/internal/modules/userleaderboard"
|
userleaderboardmodule "hyapp-admin-server/internal/modules/userleaderboard"
|
||||||
vipconfigmodule "hyapp-admin-server/internal/modules/vipconfig"
|
vipconfigmodule "hyapp-admin-server/internal/modules/vipconfig"
|
||||||
weeklystarmodule "hyapp-admin-server/internal/modules/weeklystar"
|
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/logging"
|
||||||
"hyapp-admin-server/internal/platform/tencentcos"
|
"hyapp-admin-server/internal/platform/tencentcos"
|
||||||
"hyapp-admin-server/internal/repository"
|
"hyapp-admin-server/internal/repository"
|
||||||
@ -303,6 +304,7 @@ func main() {
|
|||||||
UserLeaderboard: userleaderboardmodule.New(walletDB, userDB, roomClient),
|
UserLeaderboard: userleaderboardmodule.New(walletDB, userDB, roomClient),
|
||||||
VIPConfig: vipconfigmodule.New(walletclient.NewGRPC(walletConn), auditHandler),
|
VIPConfig: vipconfigmodule.New(walletclient.NewGRPC(walletConn), auditHandler),
|
||||||
WeeklyStar: weeklystarmodule.New(activityclient.NewGRPC(activityConn), auditHandler),
|
WeeklyStar: weeklystarmodule.New(activityclient.NewGRPC(activityConn), auditHandler),
|
||||||
|
Wheel: wheelmodule.New(activityclient.NewGRPC(activityConn), cfg.ActivityService.RequestTimeout, auditHandler),
|
||||||
}
|
}
|
||||||
engine := router.New(cfg, auth, handlers)
|
engine := router.New(cfg, auth, handlers)
|
||||||
|
|
||||||
|
|||||||
@ -57,6 +57,10 @@ type Client interface {
|
|||||||
ListLuckyGiftConfigs(ctx context.Context, req *activityv1.ListLuckyGiftConfigsRequest) (*activityv1.ListLuckyGiftConfigsResponse, error)
|
ListLuckyGiftConfigs(ctx context.Context, req *activityv1.ListLuckyGiftConfigsRequest) (*activityv1.ListLuckyGiftConfigsResponse, error)
|
||||||
ListLuckyGiftDraws(ctx context.Context, req *activityv1.ListLuckyGiftDrawsRequest) (*activityv1.ListLuckyGiftDrawsResponse, error)
|
ListLuckyGiftDraws(ctx context.Context, req *activityv1.ListLuckyGiftDrawsRequest) (*activityv1.ListLuckyGiftDrawsResponse, error)
|
||||||
GetLuckyGiftDrawSummary(ctx context.Context, req *activityv1.GetLuckyGiftDrawSummaryRequest) (*activityv1.GetLuckyGiftDrawSummaryResponse, 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)
|
RemoveRegionBroadcastMember(ctx context.Context, req *activityv1.RemoveRegionBroadcastMemberRequest) (*activityv1.RemoveRegionBroadcastMemberResponse, error)
|
||||||
ListLevelConfig(ctx context.Context, req *activityv1.ListLevelConfigRequest) (*activityv1.ListLevelConfigResponse, error)
|
ListLevelConfig(ctx context.Context, req *activityv1.ListLevelConfigRequest) (*activityv1.ListLevelConfigResponse, error)
|
||||||
UpsertLevelTrack(ctx context.Context, req *activityv1.UpsertLevelTrackRequest) (*activityv1.UpsertLevelTrackResponse, error)
|
UpsertLevelTrack(ctx context.Context, req *activityv1.UpsertLevelTrackRequest) (*activityv1.UpsertLevelTrackResponse, error)
|
||||||
@ -80,6 +84,7 @@ type GRPCClient struct {
|
|||||||
agencyOpeningClient activityv1.AdminAgencyOpeningServiceClient
|
agencyOpeningClient activityv1.AdminAgencyOpeningServiceClient
|
||||||
cpWeeklyRankClient activityv1.AdminCPWeeklyRankServiceClient
|
cpWeeklyRankClient activityv1.AdminCPWeeklyRankServiceClient
|
||||||
luckyGiftClient activityv1.AdminLuckyGiftServiceClient
|
luckyGiftClient activityv1.AdminLuckyGiftServiceClient
|
||||||
|
wheelClient activityv1.AdminWheelServiceClient
|
||||||
broadcastClient activityv1.BroadcastServiceClient
|
broadcastClient activityv1.BroadcastServiceClient
|
||||||
levelClient activityv1.GrowthLevelServiceClient
|
levelClient activityv1.GrowthLevelServiceClient
|
||||||
growthClient activityv1.AdminGrowthLevelServiceClient
|
growthClient activityv1.AdminGrowthLevelServiceClient
|
||||||
@ -100,6 +105,7 @@ func NewGRPC(conn grpc.ClientConnInterface) *GRPCClient {
|
|||||||
agencyOpeningClient: activityv1.NewAdminAgencyOpeningServiceClient(conn),
|
agencyOpeningClient: activityv1.NewAdminAgencyOpeningServiceClient(conn),
|
||||||
cpWeeklyRankClient: activityv1.NewAdminCPWeeklyRankServiceClient(conn),
|
cpWeeklyRankClient: activityv1.NewAdminCPWeeklyRankServiceClient(conn),
|
||||||
luckyGiftClient: activityv1.NewAdminLuckyGiftServiceClient(conn),
|
luckyGiftClient: activityv1.NewAdminLuckyGiftServiceClient(conn),
|
||||||
|
wheelClient: activityv1.NewAdminWheelServiceClient(conn),
|
||||||
broadcastClient: activityv1.NewBroadcastServiceClient(conn),
|
broadcastClient: activityv1.NewBroadcastServiceClient(conn),
|
||||||
levelClient: activityv1.NewGrowthLevelServiceClient(conn),
|
levelClient: activityv1.NewGrowthLevelServiceClient(conn),
|
||||||
growthClient: activityv1.NewAdminGrowthLevelServiceClient(conn),
|
growthClient: activityv1.NewAdminGrowthLevelServiceClient(conn),
|
||||||
@ -295,6 +301,22 @@ func (c *GRPCClient) GetLuckyGiftDrawSummary(ctx context.Context, req *activityv
|
|||||||
return c.luckyGiftClient.GetLuckyGiftDrawSummary(ctx, req)
|
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) {
|
func (c *GRPCClient) RemoveRegionBroadcastMember(ctx context.Context, req *activityv1.RemoveRegionBroadcastMemberRequest) (*activityv1.RemoveRegionBroadcastMemberResponse, error) {
|
||||||
return c.broadcastClient.RemoveRegionBroadcastMember(ctx, req)
|
return c.broadcastClient.RemoveRegionBroadcastMember(ctx, req)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -11,6 +11,8 @@ import (
|
|||||||
"hyapp-admin-server/internal/modules/shared"
|
"hyapp-admin-server/internal/modules/shared"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const availableRoomRobotPageSize = int32(200)
|
||||||
|
|
||||||
type RobotRoom struct {
|
type RobotRoom struct {
|
||||||
AppCode string `json:"appCode"`
|
AppCode string `json:"appCode"`
|
||||||
RoomID string `json:"roomId"`
|
RoomID string `json:"roomId"`
|
||||||
@ -121,15 +123,12 @@ func (s *Service) ListAvailableRoomRobots(ctx context.Context) ([]AvailableRoomR
|
|||||||
if s.robotClient == nil || s.roomClient == nil {
|
if s.robotClient == nil || s.roomClient == nil {
|
||||||
return nil, fmt.Errorf("robot or room service client is not configured")
|
return nil, fmt.Errorf("robot or room service client is not configured")
|
||||||
}
|
}
|
||||||
result, err := s.robotClient.ListGameRobots(ctx, robotclient.ListGameRobotsRequest{
|
robots, err := listAllActiveGameRobots(ctx, s.robotClient)
|
||||||
Status: "active",
|
|
||||||
PageSize: 200,
|
|
||||||
})
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
userIDs := make([]int64, 0, len(result.Robots))
|
userIDs := make([]int64, 0, len(robots))
|
||||||
for _, item := range result.Robots {
|
for _, item := range robots {
|
||||||
if item.UserID > 0 {
|
if item.UserID > 0 {
|
||||||
userIDs = append(userIDs, item.UserID)
|
userIDs = append(userIDs, item.UserID)
|
||||||
}
|
}
|
||||||
@ -144,7 +143,38 @@ func (s *Service) ListAvailableRoomRobots(ctx context.Context) ([]AvailableRoomR
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
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) {
|
func (s *Service) GetHumanRoomRobotConfig(ctx context.Context) (HumanRoomRobotConfig, error) {
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
package roomadmin
|
package roomadmin
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
@ -214,3 +215,50 @@ func TestAvailableRoomRobotsFromGamePoolKeepsOnlyRoomAvailableUsers(t *testing.T
|
|||||||
t.Fatalf("second available robot mismatch: %+v", items[1])
|
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")
|
||||||
|
}
|
||||||
|
|||||||
483
server/admin/internal/modules/wheel/handler.go
Normal file
483
server/admin/internal/modules/wheel/handler.go
Normal 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
|
||||||
|
}
|
||||||
114
server/admin/internal/modules/wheel/handler_test.go
Normal file
114
server/admin/internal/modules/wheel/handler_test.go
Normal 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
|
||||||
|
}
|
||||||
17
server/admin/internal/modules/wheel/routes.go
Normal file
17
server/admin/internal/modules/wheel/routes.go
Normal 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)
|
||||||
|
}
|
||||||
@ -130,6 +130,8 @@ var defaultPermissions = []model.Permission{
|
|||||||
{Name: "七日签到更新", Code: "seven-day-checkin:update", Kind: "button"},
|
{Name: "七日签到更新", Code: "seven-day-checkin:update", Kind: "button"},
|
||||||
{Name: "幸运礼物查看", Code: "lucky-gift:view", Kind: "menu"},
|
{Name: "幸运礼物查看", Code: "lucky-gift:view", Kind: "menu"},
|
||||||
{Name: "幸运礼物更新", Code: "lucky-gift:update", Kind: "button"},
|
{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:view", Kind: "menu"},
|
||||||
{Name: "房间火箭更新", Code: "room-rocket:update", Kind: "button"},
|
{Name: "房间火箭更新", Code: "room-rocket:update", Kind: "button"},
|
||||||
{Name: "用户榜单查看", Code: "user-leaderboard:view", Kind: "menu"},
|
{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-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: "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: "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-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: 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: 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-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-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},
|
{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: "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: "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: "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: "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: "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},
|
{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",
|
"bd:view", "bd:create", "bd:update",
|
||||||
"coin-seller:view", "coin-seller:create", "coin-seller:update", "coin-seller:stock-credit", "coin-seller:exchange-rate",
|
"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",
|
"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",
|
"game:view", "game:create", "game:update", "game:status", "game:delete",
|
||||||
"daily-task:view", "daily-task:create", "daily-task:update", "daily-task:status",
|
"daily-task:view", "daily-task:create", "daily-task:update", "daily-task:status",
|
||||||
"achievement:view", "achievement:create", "achievement:update",
|
"achievement:view", "achievement:create", "achievement:update",
|
||||||
@ -557,7 +560,7 @@ func defaultRolePermissionCodes(code string) []string {
|
|||||||
"upload:create",
|
"upload:create",
|
||||||
}
|
}
|
||||||
case "auditor":
|
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":
|
case "readonly":
|
||||||
return []string{
|
return []string{
|
||||||
"overview:view",
|
"overview:view",
|
||||||
@ -595,6 +598,7 @@ func defaultRolePermissionCodes(code string) []string {
|
|||||||
"gift-diamond:view",
|
"gift-diamond:view",
|
||||||
"full-server-notice:view",
|
"full-server-notice:view",
|
||||||
"lucky-gift:view",
|
"lucky-gift:view",
|
||||||
|
"wheel:view",
|
||||||
"payment-bill:view",
|
"payment-bill:view",
|
||||||
"payment-third-party:view",
|
"payment-third-party:view",
|
||||||
"payment-product: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",
|
"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-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",
|
"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",
|
"game:view", "game:create", "game:update", "game:status", "game:delete",
|
||||||
"daily-task:view", "daily-task:create", "daily-task:update", "daily-task:status",
|
"daily-task:view", "daily-task:create", "daily-task:update", "daily-task:status",
|
||||||
"achievement:view", "achievement:create", "achievement:update",
|
"achievement:view", "achievement:create", "achievement:update",
|
||||||
@ -655,7 +659,7 @@ func defaultRolePermissionMigrationCodes(code string) []string {
|
|||||||
"agency-opening:view", "agency-opening:create", "agency-opening:update",
|
"agency-opening:view", "agency-opening:create", "agency-opening:update",
|
||||||
}
|
}
|
||||||
case "auditor", "readonly":
|
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:
|
default:
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@ -49,6 +49,7 @@ import (
|
|||||||
"hyapp-admin-server/internal/modules/userleaderboard"
|
"hyapp-admin-server/internal/modules/userleaderboard"
|
||||||
"hyapp-admin-server/internal/modules/vipconfig"
|
"hyapp-admin-server/internal/modules/vipconfig"
|
||||||
"hyapp-admin-server/internal/modules/weeklystar"
|
"hyapp-admin-server/internal/modules/weeklystar"
|
||||||
|
"hyapp-admin-server/internal/modules/wheel"
|
||||||
"hyapp-admin-server/internal/platform/logging"
|
"hyapp-admin-server/internal/platform/logging"
|
||||||
"hyapp-admin-server/internal/service"
|
"hyapp-admin-server/internal/service"
|
||||||
|
|
||||||
@ -102,6 +103,7 @@ type Handlers struct {
|
|||||||
UserLeaderboard *userleaderboard.Handler
|
UserLeaderboard *userleaderboard.Handler
|
||||||
VIPConfig *vipconfig.Handler
|
VIPConfig *vipconfig.Handler
|
||||||
WeeklyStar *weeklystar.Handler
|
WeeklyStar *weeklystar.Handler
|
||||||
|
Wheel *wheel.Handler
|
||||||
}
|
}
|
||||||
|
|
||||||
func New(cfg config.Config, auth *service.AuthService, h Handlers) *gin.Engine {
|
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)
|
userleaderboard.RegisterRoutes(protected, h.UserLeaderboard)
|
||||||
vipconfig.RegisterRoutes(protected, h.VIPConfig)
|
vipconfig.RegisterRoutes(protected, h.VIPConfig)
|
||||||
weeklystar.RegisterRoutes(protected, h.WeeklyStar)
|
weeklystar.RegisterRoutes(protected, h.WeeklyStar)
|
||||||
|
wheel.RegisterRoutes(protected, h.Wheel)
|
||||||
|
|
||||||
return engine
|
return engine
|
||||||
}
|
}
|
||||||
|
|||||||
59
server/admin/migrations/056_wheel_navigation.sql
Normal file
59
server/admin/migrations/056_wheel_navigation.sql
Normal 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';
|
||||||
26
server/admin/migrations/057_move_wheel_to_activities.sql
Normal file
26
server/admin/migrations/057_move_wheel_to_activities.sql
Normal 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';
|
||||||
@ -287,6 +287,131 @@ CREATE TABLE IF NOT EXISTS lucky_draw_pool_stat_cursors (
|
|||||||
PRIMARY KEY (app_code, cursor_name)
|
PRIMARY KEY (app_code, cursor_name)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='幸运礼物后台统计快照游标';
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='幸运礼物后台统计快照游标';
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS 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 (
|
CREATE TABLE IF NOT EXISTS im_broadcast_outbox (
|
||||||
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码,用于多租户隔离',
|
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码,用于多租户隔离',
|
||||||
event_id VARCHAR(128) NOT NULL COMMENT '事件幂等 ID',
|
event_id VARCHAR(128) NOT NULL COMMENT '事件幂等 ID',
|
||||||
|
|||||||
@ -20,6 +20,8 @@ func registerGRPCServers(server *grpc.Server, services *serviceBundle) {
|
|||||||
activityv1.RegisterAdminAchievementServiceServer(server, grpcserver.NewAdminAchievementServer(services.achievement))
|
activityv1.RegisterAdminAchievementServiceServer(server, grpcserver.NewAdminAchievementServer(services.achievement))
|
||||||
activityv1.RegisterLuckyGiftServiceServer(server, grpcserver.NewLuckyGiftServer(services.luckyGift))
|
activityv1.RegisterLuckyGiftServiceServer(server, grpcserver.NewLuckyGiftServer(services.luckyGift))
|
||||||
activityv1.RegisterAdminLuckyGiftServiceServer(server, grpcserver.NewAdminLuckyGiftServer(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.RegisterRegistrationRewardServiceServer(server, grpcserver.NewRegistrationRewardServer(services.registrationReward))
|
||||||
activityv1.RegisterAdminRegistrationRewardServiceServer(server, grpcserver.NewAdminRegistrationRewardServer(services.registrationReward))
|
activityv1.RegisterAdminRegistrationRewardServiceServer(server, grpcserver.NewAdminRegistrationRewardServer(services.registrationReward))
|
||||||
activityv1.RegisterSevenDayCheckInServiceServer(server, grpcserver.NewSevenDayCheckInServer(services.sevenDayCheckIn))
|
activityv1.RegisterSevenDayCheckInServiceServer(server, grpcserver.NewSevenDayCheckInServer(services.sevenDayCheckIn))
|
||||||
|
|||||||
@ -24,6 +24,7 @@ import (
|
|||||||
sevendaycheckinservice "hyapp/services/activity-service/internal/service/sevendaycheckin"
|
sevendaycheckinservice "hyapp/services/activity-service/internal/service/sevendaycheckin"
|
||||||
taskservice "hyapp/services/activity-service/internal/service/task"
|
taskservice "hyapp/services/activity-service/internal/service/task"
|
||||||
weeklystarservice "hyapp/services/activity-service/internal/service/weeklystar"
|
weeklystarservice "hyapp/services/activity-service/internal/service/weeklystar"
|
||||||
|
wheelservice "hyapp/services/activity-service/internal/service/wheel"
|
||||||
mysqlstorage "hyapp/services/activity-service/internal/storage/mysql"
|
mysqlstorage "hyapp/services/activity-service/internal/storage/mysql"
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -41,6 +42,7 @@ type serviceBundle struct {
|
|||||||
roomTurnoverReward *roomturnoverrewardservice.Service
|
roomTurnoverReward *roomturnoverrewardservice.Service
|
||||||
broadcast *broadcastservice.Service
|
broadcast *broadcastservice.Service
|
||||||
luckyGift *luckygiftservice.Service
|
luckyGift *luckygiftservice.Service
|
||||||
|
wheel *wheelservice.Service
|
||||||
firstRechargeReward *firstrechargeservice.Service
|
firstRechargeReward *firstrechargeservice.Service
|
||||||
cumulativeRecharge *cumulativerechargeservice.Service
|
cumulativeRecharge *cumulativerechargeservice.Service
|
||||||
inviteActivityReward *inviteactivityservice.Service
|
inviteActivityReward *inviteactivityservice.Service
|
||||||
@ -96,6 +98,7 @@ func buildServiceBundle(cfg config.Config, repository *mysqlstorage.Repository,
|
|||||||
luckygiftservice.WithRoomPublisher(tencentClient),
|
luckygiftservice.WithRoomPublisher(tencentClient),
|
||||||
luckygiftservice.WithRegionBroadcaster(broadcastSvc),
|
luckygiftservice.WithRegionBroadcaster(broadcastSvc),
|
||||||
)
|
)
|
||||||
|
wheelSvc := wheelservice.New(repository, wheelservice.WithWallet(walletClient))
|
||||||
firstRechargeRewardSvc := firstrechargeservice.New(repository, walletClient)
|
firstRechargeRewardSvc := firstrechargeservice.New(repository, walletClient)
|
||||||
cumulativeRechargeSvc := cumulativerechargeservice.New(repository, walletClient)
|
cumulativeRechargeSvc := cumulativerechargeservice.New(repository, walletClient)
|
||||||
inviteActivityRewardSvc := inviteactivityservice.New(repository, walletClient, activityclient.NewGRPCInviteAttributionSource(clients.userConn))
|
inviteActivityRewardSvc := inviteactivityservice.New(repository, walletClient, activityclient.NewGRPCInviteAttributionSource(clients.userConn))
|
||||||
@ -114,6 +117,7 @@ func buildServiceBundle(cfg config.Config, repository *mysqlstorage.Repository,
|
|||||||
roomTurnoverReward: roomTurnoverRewardSvc,
|
roomTurnoverReward: roomTurnoverRewardSvc,
|
||||||
broadcast: broadcastSvc,
|
broadcast: broadcastSvc,
|
||||||
luckyGift: luckyGiftSvc,
|
luckyGift: luckyGiftSvc,
|
||||||
|
wheel: wheelSvc,
|
||||||
firstRechargeReward: firstRechargeRewardSvc,
|
firstRechargeReward: firstRechargeRewardSvc,
|
||||||
cumulativeRecharge: cumulativeRechargeSvc,
|
cumulativeRecharge: cumulativeRechargeSvc,
|
||||||
inviteActivityReward: inviteActivityRewardSvc,
|
inviteActivityReward: inviteActivityRewardSvc,
|
||||||
|
|||||||
@ -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
|
||||||
|
}
|
||||||
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
102
services/activity-service/internal/domain/wheel/wheel.go
Normal file
102
services/activity-service/internal/domain/wheel/wheel.go
Normal 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
|
||||||
|
}
|
||||||
106
services/activity-service/internal/service/wheel/config.go
Normal file
106
services/activity-service/internal/service/wheel/config.go
Normal 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
|
||||||
|
}
|
||||||
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
176
services/activity-service/internal/service/wheel/service.go
Normal file
176
services/activity-service/internal/service/wheel/service.go
Normal 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
|
||||||
|
}
|
||||||
@ -2,14 +2,12 @@ package mysql
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
crand "crypto/rand"
|
|
||||||
"crypto/sha1"
|
"crypto/sha1"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"math/big"
|
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@ -17,6 +15,7 @@ import (
|
|||||||
"hyapp/pkg/appcode"
|
"hyapp/pkg/appcode"
|
||||||
"hyapp/pkg/idgen"
|
"hyapp/pkg/idgen"
|
||||||
"hyapp/pkg/xerr"
|
"hyapp/pkg/xerr"
|
||||||
|
lotteryengine "hyapp/services/activity-service/internal/domain/lotteryengine"
|
||||||
domain "hyapp/services/activity-service/internal/domain/luckygift"
|
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{}
|
limited := map[string]bool{}
|
||||||
|
|
||||||
baseCandidates := make([]luckyCandidate, 0, len(config.Tiers))
|
baseCandidates := make([]luckyCandidate, 0, len(config.Tiers))
|
||||||
var totalWeight int64
|
|
||||||
for _, tier := range config.Tiers {
|
for _, tier := range config.Tiers {
|
||||||
// 体验池决定当前用户可见奖档;权重必须保持后台配置,RTP 缺口只进入监控口径,不能反向改本次概率。
|
// 体验池决定当前用户可见奖档;权重必须保持后台配置,RTP 缺口只进入监控口径,不能反向改本次概率。
|
||||||
if tier.Pool != pool || !tier.Enabled {
|
if tier.Pool != pool || !tier.Enabled {
|
||||||
@ -2715,26 +2713,34 @@ func (r *Repository) selectLuckyCandidate(config domain.Config, pool string, pla
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
// 正常运行只使用后台配置概率;奖池和风控只删除“本事务不能支付”的候选,不插值、不补缺口。
|
// 正常运行只使用后台配置概率;奖池和风控只删除“本事务不能支付”的候选,不插值、不补缺口。
|
||||||
candidates := luckyConfiguredWeightCandidates(baseCandidates)
|
if len(baseCandidates) == 0 {
|
||||||
for _, candidate := range candidates {
|
|
||||||
totalWeight += candidate.Weight
|
|
||||||
}
|
|
||||||
if len(candidates) == 0 {
|
|
||||||
// 全部正奖档都被奖池或风控剪掉时,本次只能落 0 倍;不能强行透支,也不能生成修正档改变概率。
|
// 全部正奖档都被奖池或风控剪掉时,本次只能落 0 倍;不能强行透支,也不能生成修正档改变概率。
|
||||||
return luckyCandidate{TierID: "no_payable_tier", Source: domain.SourceBaseRTP, Pool: pool}, limited, nil
|
return luckyCandidate{TierID: "no_payable_tier", Source: domain.SourceBaseRTP, Pool: pool}, limited, nil
|
||||||
}
|
}
|
||||||
// 使用 crypto/rand 生成随机落点;候选集合已经全部可支付,随机后不再做业务降级。
|
engineCandidates := make([]lotteryengine.Candidate, 0, len(baseCandidates))
|
||||||
index, err := luckySecureIndex(totalWeight)
|
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 {
|
if err != nil {
|
||||||
return luckyCandidate{}, limited, err
|
return luckyCandidate{}, limited, err
|
||||||
}
|
}
|
||||||
for _, candidate := range candidates {
|
if candidate, ok := candidateByID[selected.ID]; ok {
|
||||||
if index < candidate.Weight {
|
if candidate.Weight <= 0 {
|
||||||
return candidate, limited, nil
|
// 通用引擎会把全 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) {
|
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 {
|
func luckyConfiguredWeightCandidates(candidates []luckyCandidate) []luckyCandidate {
|
||||||
out := make([]luckyCandidate, 0, len(candidates))
|
engineCandidates := make([]lotteryengine.Candidate, 0, len(candidates))
|
||||||
var totalWeight int64
|
byID := make(map[string]luckyCandidate, len(candidates))
|
||||||
for _, candidate := range candidates {
|
for _, candidate := range candidates {
|
||||||
if candidate.Weight <= 0 {
|
engineCandidates = append(engineCandidates, lotteryengine.Candidate{
|
||||||
continue
|
ID: candidate.TierID,
|
||||||
}
|
Weight: candidate.Weight,
|
||||||
totalWeight += candidate.Weight
|
PrizeKind: lotteryengine.PrizeKindCoin,
|
||||||
out = append(out, candidate)
|
RTPValueCoins: candidate.BaseReward,
|
||||||
|
})
|
||||||
|
byID[candidate.TierID] = candidate
|
||||||
}
|
}
|
||||||
if totalWeight > 0 {
|
normalized, err := lotteryengine.NormalizeCandidates(engineCandidates)
|
||||||
return out
|
if err != nil {
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
// 老数据或脏配置可能没有权重;为了让抽奖继续暴露结果而不是空候选,退化成等权随机。
|
out := make([]luckyCandidate, 0, len(normalized))
|
||||||
out = candidates[:0]
|
for _, candidate := range normalized {
|
||||||
for _, candidate := range candidates {
|
item := byID[candidate.ID]
|
||||||
candidate.Weight = 1
|
item.Weight = candidate.Weight
|
||||||
out = append(out, candidate)
|
out = append(out, item)
|
||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
@ -2959,17 +2968,6 @@ func luckyRTPPPM(wager, payout int64) int64 {
|
|||||||
return payout * luckyPPMScale / wager
|
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 {
|
func luckySQLPlaceholders(count int) string {
|
||||||
// database/sql 不支持把 slice 直接绑定到 IN (?),这里生成受控占位符而不是拼接业务值。
|
// database/sql 不支持把 slice 直接绑定到 IN (?),这里生成受控占位符而不是拼接业务值。
|
||||||
if count <= 0 {
|
if count <= 0 {
|
||||||
|
|||||||
@ -78,6 +78,9 @@ func (r *Repository) Migrate(ctx context.Context) error {
|
|||||||
if err := r.ensureDefaultLuckyGiftRules(ctx); err != nil {
|
if err := r.ensureDefaultLuckyGiftRules(ctx); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if err := r.ensureWheelTables(ctx); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
if err := r.ensureRoomTurnoverRewardTables(ctx); err != nil {
|
if err := r.ensureRoomTurnoverRewardTables(ctx); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
|
||||||
|
}
|
||||||
139
services/activity-service/internal/storage/mysql/wheel_schema.go
Normal file
139
services/activity-service/internal/storage/mysql/wheel_schema.go
Normal 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
|
||||||
|
}
|
||||||
@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -110,6 +110,7 @@ func New(cfg config.Config) (*App, error) {
|
|||||||
var weeklyStarClient client.WeeklyStarClient = client.NewGRPCWeeklyStarClient(activityConn)
|
var weeklyStarClient client.WeeklyStarClient = client.NewGRPCWeeklyStarClient(activityConn)
|
||||||
var cpWeeklyRankClient client.CPWeeklyRankClient = client.NewGRPCCPWeeklyRankClient(activityConn)
|
var cpWeeklyRankClient client.CPWeeklyRankClient = client.NewGRPCCPWeeklyRankClient(activityConn)
|
||||||
var agencyOpeningClient client.AgencyOpeningClient = client.NewGRPCAgencyOpeningClient(activityConn)
|
var agencyOpeningClient client.AgencyOpeningClient = client.NewGRPCAgencyOpeningClient(activityConn)
|
||||||
|
var wheelClient client.WheelClient = client.NewGRPCWheelClient(activityConn)
|
||||||
var broadcastClient client.BroadcastClient = client.NewGRPCBroadcastClient(activityConn)
|
var broadcastClient client.BroadcastClient = client.NewGRPCBroadcastClient(activityConn)
|
||||||
var gameClient client.GameClient = client.NewGRPCGameClient(gameConn)
|
var gameClient client.GameClient = client.NewGRPCGameClient(gameConn)
|
||||||
var statisticsClient client.StatisticsClient = client.NewHTTPStatisticsClient(cfg.Statistics.BaseURL, cfg.Statistics.Timeout)
|
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.SetWeeklyStarClient(weeklyStarClient)
|
||||||
handler.SetCPWeeklyRankClient(cpWeeklyRankClient)
|
handler.SetCPWeeklyRankClient(cpWeeklyRankClient)
|
||||||
handler.SetAgencyOpeningClient(agencyOpeningClient)
|
handler.SetAgencyOpeningClient(agencyOpeningClient)
|
||||||
|
handler.SetWheelClient(wheelClient)
|
||||||
handler.SetBroadcastClient(broadcastClient)
|
handler.SetBroadcastClient(broadcastClient)
|
||||||
handler.SetGameClient(gameClient)
|
handler.SetGameClient(gameClient)
|
||||||
handler.SetStatisticsClient(statisticsClient)
|
handler.SetStatisticsClient(statisticsClient)
|
||||||
|
|||||||
@ -29,6 +29,7 @@ type WalletClient interface {
|
|||||||
GetMyVip(ctx context.Context, req *walletv1.GetMyVipRequest) (*walletv1.GetMyVipResponse, error)
|
GetMyVip(ctx context.Context, req *walletv1.GetMyVipRequest) (*walletv1.GetMyVipResponse, error)
|
||||||
PurchaseVip(ctx context.Context, req *walletv1.PurchaseVipRequest) (*walletv1.PurchaseVipResponse, error)
|
PurchaseVip(ctx context.Context, req *walletv1.PurchaseVipRequest) (*walletv1.PurchaseVipResponse, error)
|
||||||
DebitCPBreakupFee(ctx context.Context, req *walletv1.DebitCPBreakupFeeRequest) (*walletv1.DebitCPBreakupFeeResponse, 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)
|
GrantVip(ctx context.Context, req *walletv1.GrantVipRequest) (*walletv1.GrantVipResponse, error)
|
||||||
TransferCoinFromSeller(ctx context.Context, req *walletv1.TransferCoinFromSellerRequest) (*walletv1.TransferCoinFromSellerResponse, error)
|
TransferCoinFromSeller(ctx context.Context, req *walletv1.TransferCoinFromSellerRequest) (*walletv1.TransferCoinFromSellerResponse, error)
|
||||||
ListCoinSellerSalaryExchangeRateTiers(ctx context.Context, req *walletv1.ListCoinSellerSalaryExchangeRateTiersRequest) (*walletv1.ListCoinSellerSalaryExchangeRateTiersResponse, 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)
|
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) {
|
func (c *grpcWalletClient) GrantVip(ctx context.Context, req *walletv1.GrantVipRequest) (*walletv1.GrantVipResponse, error) {
|
||||||
return c.client.GrantVip(ctx, req)
|
return c.client.GrantVip(ctx, req)
|
||||||
}
|
}
|
||||||
|
|||||||
40
services/gateway-service/internal/client/wheel_client.go
Normal file
40
services/gateway-service/internal/client/wheel_client.go
Normal 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)
|
||||||
|
}
|
||||||
@ -27,6 +27,7 @@ type Handler struct {
|
|||||||
weeklyStar client.WeeklyStarClient
|
weeklyStar client.WeeklyStarClient
|
||||||
cpWeeklyRank client.CPWeeklyRankClient
|
cpWeeklyRank client.CPWeeklyRankClient
|
||||||
agencyOpening client.AgencyOpeningClient
|
agencyOpening client.AgencyOpeningClient
|
||||||
|
wheel client.WheelClient
|
||||||
}
|
}
|
||||||
|
|
||||||
type Config struct {
|
type Config struct {
|
||||||
@ -47,6 +48,7 @@ type Config struct {
|
|||||||
WeeklyStar client.WeeklyStarClient
|
WeeklyStar client.WeeklyStarClient
|
||||||
CPWeeklyRank client.CPWeeklyRankClient
|
CPWeeklyRank client.CPWeeklyRankClient
|
||||||
AgencyOpening client.AgencyOpeningClient
|
AgencyOpening client.AgencyOpeningClient
|
||||||
|
Wheel client.WheelClient
|
||||||
}
|
}
|
||||||
|
|
||||||
func New(config Config) *Handler {
|
func New(config Config) *Handler {
|
||||||
@ -68,6 +70,7 @@ func New(config Config) *Handler {
|
|||||||
weeklyStar: config.WeeklyStar,
|
weeklyStar: config.WeeklyStar,
|
||||||
cpWeeklyRank: config.CPWeeklyRank,
|
cpWeeklyRank: config.CPWeeklyRank,
|
||||||
agencyOpening: config.AgencyOpening,
|
agencyOpening: config.AgencyOpening,
|
||||||
|
wheel: config.Wheel,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -93,6 +96,10 @@ func (h *Handler) TaskHandlers() httproutes.TaskHandlers {
|
|||||||
GetAgencyOpeningStatus: h.getAgencyOpeningStatus,
|
GetAgencyOpeningStatus: h.getAgencyOpeningStatus,
|
||||||
ApplyAgencyOpening: h.applyAgencyOpening,
|
ApplyAgencyOpening: h.applyAgencyOpening,
|
||||||
ListAgencyOpeningLeaderboard: h.listAgencyOpeningLeaderboard,
|
ListAgencyOpeningLeaderboard: h.listAgencyOpeningLeaderboard,
|
||||||
|
GetWheelConfig: h.getWheelConfig,
|
||||||
|
DrawWheel: h.drawWheel,
|
||||||
|
ListWheelHistory: h.listWheelHistory,
|
||||||
|
ListWheelHints: h.listWheelHints,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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 ""
|
||||||
|
}
|
||||||
@ -51,6 +51,7 @@ type Handler struct {
|
|||||||
weeklyStar client.WeeklyStarClient
|
weeklyStar client.WeeklyStarClient
|
||||||
cpWeeklyRank client.CPWeeklyRankClient
|
cpWeeklyRank client.CPWeeklyRankClient
|
||||||
agencyOpening client.AgencyOpeningClient
|
agencyOpening client.AgencyOpeningClient
|
||||||
|
wheel client.WheelClient
|
||||||
broadcastClient client.BroadcastClient
|
broadcastClient client.BroadcastClient
|
||||||
gameClient client.GameClient
|
gameClient client.GameClient
|
||||||
statisticsClient client.StatisticsClient
|
statisticsClient client.StatisticsClient
|
||||||
@ -277,6 +278,11 @@ func (h *Handler) SetAgencyOpeningClient(agencyOpening client.AgencyOpeningClien
|
|||||||
h.agencyOpening = agencyOpening
|
h.agencyOpening = agencyOpening
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetWheelClient 注入 activity-service 转盘配置和抽奖 client。
|
||||||
|
func (h *Handler) SetWheelClient(wheel client.WheelClient) {
|
||||||
|
h.wheel = wheel
|
||||||
|
}
|
||||||
|
|
||||||
// SetBroadcastClient 注入 activity-service 播报群成员关系 client。
|
// SetBroadcastClient 注入 activity-service 播报群成员关系 client。
|
||||||
func (h *Handler) SetBroadcastClient(broadcastClient client.BroadcastClient) {
|
func (h *Handler) SetBroadcastClient(broadcastClient client.BroadcastClient) {
|
||||||
h.broadcastClient = broadcastClient
|
h.broadcastClient = broadcastClient
|
||||||
|
|||||||
@ -206,6 +206,10 @@ type TaskHandlers struct {
|
|||||||
GetAgencyOpeningStatus http.HandlerFunc
|
GetAgencyOpeningStatus http.HandlerFunc
|
||||||
ApplyAgencyOpening http.HandlerFunc
|
ApplyAgencyOpening http.HandlerFunc
|
||||||
ListAgencyOpeningLeaderboard http.HandlerFunc
|
ListAgencyOpeningLeaderboard http.HandlerFunc
|
||||||
|
GetWheelConfig http.HandlerFunc
|
||||||
|
DrawWheel http.HandlerFunc
|
||||||
|
ListWheelHistory http.HandlerFunc
|
||||||
|
ListWheelHints http.HandlerFunc
|
||||||
}
|
}
|
||||||
|
|
||||||
type LevelHandlers struct {
|
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", http.MethodGet, h.GetAgencyOpeningStatus)
|
||||||
r.profile("/activities/agency-opening-event/apply", http.MethodPost, h.ApplyAgencyOpening)
|
r.profile("/activities/agency-opening-event/apply", http.MethodPost, h.ApplyAgencyOpening)
|
||||||
r.profile("/activities/agency-opening-event/leaderboard", http.MethodGet, h.ListAgencyOpeningLeaderboard)
|
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 规则、抽奖和返奖实现。
|
// App 侧接口保持稳定;内部仍按 pool_id 走当前 v2 规则、抽奖和返奖实现。
|
||||||
r.profile("/activities/lucky-gifts/check", http.MethodPost, h.CheckLuckyGift)
|
r.profile("/activities/lucky-gifts/check", http.MethodPost, h.CheckLuckyGift)
|
||||||
r.profile("/tasks/tabs", http.MethodGet, h.ListTaskTabs)
|
r.profile("/tasks/tabs", http.MethodGet, h.ListTaskTabs)
|
||||||
|
|||||||
@ -1763,6 +1763,10 @@ func (f *fakeWalletClient) DebitCPBreakupFee(context.Context, *walletv1.DebitCPB
|
|||||||
return &walletv1.DebitCPBreakupFeeResponse{}, nil
|
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) {
|
func (f *fakeWalletClient) GrantVip(_ context.Context, req *walletv1.GrantVipRequest) (*walletv1.GrantVipResponse, error) {
|
||||||
f.lastGrantVip = req
|
f.lastGrantVip = req
|
||||||
if f.vipErr != nil {
|
if f.vipErr != nil {
|
||||||
|
|||||||
@ -87,6 +87,7 @@ func (h *Handler) Routes(jwtVerifier *auth.Verifier) http.Handler {
|
|||||||
WeeklyStar: h.weeklyStar,
|
WeeklyStar: h.weeklyStar,
|
||||||
CPWeeklyRank: h.cpWeeklyRank,
|
CPWeeklyRank: h.cpWeeklyRank,
|
||||||
AgencyOpening: h.agencyOpening,
|
AgencyOpening: h.agencyOpening,
|
||||||
|
Wheel: h.wheel,
|
||||||
})
|
})
|
||||||
gameAPI := gameapi.New(gameapi.Config{
|
gameAPI := gameapi.New(gameapi.Config{
|
||||||
GameClient: h.gameClient,
|
GameClient: h.gameClient,
|
||||||
|
|||||||
@ -855,6 +855,24 @@ type CPBreakupFeeReceipt struct {
|
|||||||
Balance AssetBalance
|
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 的统一入口命令。
|
// GrantVipCommand 是活动或后台发放 VIP 的统一入口命令。
|
||||||
type GrantVipCommand struct {
|
type GrantVipCommand struct {
|
||||||
AppCode string
|
AppCode string
|
||||||
@ -950,6 +968,27 @@ type LuckyGiftRewardReceipt struct {
|
|||||||
GrantedAtMS int64
|
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 入账命令。
|
// RoomTurnoverRewardCommand 是 activity-service 每周房间流水奖励的 COIN 入账命令。
|
||||||
type RoomTurnoverRewardCommand struct {
|
type RoomTurnoverRewardCommand struct {
|
||||||
AppCode string
|
AppCode string
|
||||||
|
|||||||
@ -31,10 +31,12 @@ type Repository interface {
|
|||||||
TransferSalaryToCoinSeller(ctx context.Context, command ledger.SalaryTransferToCoinSellerCommand) (ledger.SalaryTransferToCoinSellerReceipt, error)
|
TransferSalaryToCoinSeller(ctx context.Context, command ledger.SalaryTransferToCoinSellerCommand) (ledger.SalaryTransferToCoinSellerReceipt, error)
|
||||||
CreditTaskReward(ctx context.Context, command ledger.TaskRewardCommand) (ledger.TaskRewardReceipt, error)
|
CreditTaskReward(ctx context.Context, command ledger.TaskRewardCommand) (ledger.TaskRewardReceipt, error)
|
||||||
CreditLuckyGiftReward(ctx context.Context, command ledger.LuckyGiftRewardCommand) (ledger.LuckyGiftRewardReceipt, 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)
|
CreditRoomTurnoverReward(ctx context.Context, command ledger.RoomTurnoverRewardCommand) (ledger.RoomTurnoverRewardReceipt, error)
|
||||||
CreditInviteActivityReward(ctx context.Context, command ledger.InviteActivityRewardCommand) (ledger.InviteActivityRewardReceipt, error)
|
CreditInviteActivityReward(ctx context.Context, command ledger.InviteActivityRewardCommand) (ledger.InviteActivityRewardReceipt, error)
|
||||||
CreditAgencyOpeningReward(ctx context.Context, command ledger.AgencyOpeningRewardCommand) (ledger.AgencyOpeningRewardReceipt, error)
|
CreditAgencyOpeningReward(ctx context.Context, command ledger.AgencyOpeningRewardCommand) (ledger.AgencyOpeningRewardReceipt, error)
|
||||||
ApplyGameCoinChange(ctx context.Context, command ledger.GameCoinChangeCommand) (ledger.GameCoinChangeReceipt, 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)
|
GetRedPacketConfig(ctx context.Context, appCode string) (ledger.RedPacketConfig, error)
|
||||||
UpdateRedPacketConfig(ctx context.Context, config ledger.RedPacketConfig) (ledger.RedPacketConfig, error)
|
UpdateRedPacketConfig(ctx context.Context, config ledger.RedPacketConfig) (ledger.RedPacketConfig, error)
|
||||||
CreateRedPacket(ctx context.Context, command ledger.RedPacketCreateCommand) (ledger.RedPacketCreateReceipt, 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)
|
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,钱包只负责幂等入账。
|
// CreditRoomTurnoverReward 发放每周房间流水奖励金币;结算归 activity-service,钱包只负责幂等入账。
|
||||||
func (s *Service) CreditRoomTurnoverReward(ctx context.Context, command ledger.RoomTurnoverRewardCommand) (ledger.RoomTurnoverRewardReceipt, error) {
|
func (s *Service) CreditRoomTurnoverReward(ctx context.Context, command ledger.RoomTurnoverRewardCommand) (ledger.RoomTurnoverRewardReceipt, error) {
|
||||||
// 钱包只接受 activity-service 已经生成好的结算命令;缺 settlement、room 或正向金额时直接拒绝,避免钱包自己推断活动规则。
|
// 钱包只接受 activity-service 已经生成好的结算命令;缺 settlement、room 或正向金额时直接拒绝,避免钱包自己推断活动规则。
|
||||||
@ -1097,6 +1120,29 @@ func (s *Service) DebitCPBreakupFee(ctx context.Context, command ledger.CPBreaku
|
|||||||
return s.repository.DebitCPBreakupFee(ctx, command)
|
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 完成扣费。
|
// GrantVip 是活动和后台赠送 VIP 的统一入口;购买仍走 PurchaseVip 完成扣费。
|
||||||
func (s *Service) GrantVip(ctx context.Context, command ledger.GrantVipCommand) (ledger.GrantVipReceipt, error) {
|
func (s *Service) GrantVip(ctx context.Context, command ledger.GrantVipCommand) (ledger.GrantVipReceipt, error) {
|
||||||
if command.CommandID == "" || command.TargetUserID <= 0 || command.Level <= 0 {
|
if command.CommandID == "" || command.TargetUserID <= 0 || command.Level <= 0 {
|
||||||
|
|||||||
@ -28,6 +28,7 @@ const (
|
|||||||
bizTypeManualCredit = "manual_credit"
|
bizTypeManualCredit = "manual_credit"
|
||||||
bizTypeTaskReward = "task_reward"
|
bizTypeTaskReward = "task_reward"
|
||||||
bizTypeLuckyGiftReward = "lucky_gift_reward"
|
bizTypeLuckyGiftReward = "lucky_gift_reward"
|
||||||
|
bizTypeWheelReward = "wheel_reward"
|
||||||
bizTypeRoomTurnoverReward = "room_turnover_reward"
|
bizTypeRoomTurnoverReward = "room_turnover_reward"
|
||||||
bizTypeInviteActivityReward = "invite_activity_reward"
|
bizTypeInviteActivityReward = "invite_activity_reward"
|
||||||
bizTypeAgencyOpeningReward = "agency_opening_reward"
|
bizTypeAgencyOpeningReward = "agency_opening_reward"
|
||||||
@ -45,6 +46,7 @@ const (
|
|||||||
bizTypeGameReverse = "game_reverse"
|
bizTypeGameReverse = "game_reverse"
|
||||||
bizTypeHostSalarySettlement = "host_salary_settlement"
|
bizTypeHostSalarySettlement = "host_salary_settlement"
|
||||||
bizTypeCPBreakupFee = "cp_breakup_fee"
|
bizTypeCPBreakupFee = "cp_breakup_fee"
|
||||||
|
bizTypeWheelDrawDebit = "wheel_draw_debit"
|
||||||
outboxStatusPending = "pending"
|
outboxStatusPending = "pending"
|
||||||
outboxStatusDelivering = "delivering"
|
outboxStatusDelivering = "delivering"
|
||||||
outboxStatusDelivered = "delivered"
|
outboxStatusDelivered = "delivered"
|
||||||
@ -1100,6 +1102,98 @@ func (r *Repository) DebitCPBreakupFee(ctx context.Context, command ledger.CPBre
|
|||||||
}, nil
|
}, 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 事件。
|
// CreditTaskReward 在一个事务内完成任务奖励 COIN 入账、交易分录和 outbox 事件。
|
||||||
func (r *Repository) CreditTaskReward(ctx context.Context, command ledger.TaskRewardCommand) (ledger.TaskRewardReceipt, error) {
|
func (r *Repository) CreditTaskReward(ctx context.Context, command ledger.TaskRewardCommand) (ledger.TaskRewardReceipt, error) {
|
||||||
if r == nil || r.db == nil {
|
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
|
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。
|
// CreditRoomTurnoverReward 在同一事务里完成每周房间流水奖励 COIN 入账、交易分录和钱包 outbox。
|
||||||
func (r *Repository) CreditRoomTurnoverReward(ctx context.Context, command ledger.RoomTurnoverRewardCommand) (ledger.RoomTurnoverRewardReceipt, error) {
|
func (r *Repository) CreditRoomTurnoverReward(ctx context.Context, command ledger.RoomTurnoverRewardCommand) (ledger.RoomTurnoverRewardReceipt, error) {
|
||||||
if r == nil || r.db == nil {
|
if r == nil || r.db == nil {
|
||||||
@ -3099,6 +3266,15 @@ type cpBreakupFeeMetadata struct {
|
|||||||
CoinBalanceAfter int64 `json:"coin_balance_after"`
|
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 {
|
type taskRewardMetadata struct {
|
||||||
AppCode string `json:"app_code"`
|
AppCode string `json:"app_code"`
|
||||||
TargetUserID int64 `json:"target_user_id"`
|
TargetUserID int64 `json:"target_user_id"`
|
||||||
@ -3127,6 +3303,20 @@ type luckyGiftRewardMetadata struct {
|
|||||||
GrantedAtMS int64 `json:"granted_at_ms"`
|
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 {
|
type roomTurnoverRewardMetadata struct {
|
||||||
AppCode string `json:"app_code"`
|
AppCode string `json:"app_code"`
|
||||||
TargetUserID int64 `json:"target_user_id"`
|
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) {
|
func (r *Repository) receiptForRoomTurnoverRewardTransaction(ctx context.Context, tx *sql.Tx, transactionID string) (ledger.RoomTurnoverRewardReceipt, error) {
|
||||||
var metadataJSON string
|
var metadataJSON string
|
||||||
if err := tx.QueryRowContext(ctx,
|
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 {
|
func roomTurnoverRewardCreditedEvent(transactionID string, commandID string, metadata roomTurnoverRewardMetadata, nowMs int64) walletOutboxEvent {
|
||||||
return walletOutboxEvent{
|
return walletOutboxEvent{
|
||||||
EventID: eventID(transactionID, "WalletRoomTurnoverRewardCredited", metadata.TargetUserID, ledger.AssetCoin),
|
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 {
|
func taskRewardRequestHash(command ledger.TaskRewardCommand) string {
|
||||||
return stableHash(fmt.Sprintf("task_reward|%s|%d|%d|%s|%s|%s|%s",
|
return stableHash(fmt.Sprintf("task_reward|%s|%d|%d|%s|%s|%s|%s",
|
||||||
appcode.Normalize(command.AppCode),
|
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 {
|
func roomTurnoverRewardRequestHash(command ledger.RoomTurnoverRewardCommand) string {
|
||||||
return stableHash(fmt.Sprintf("room_turnover_reward|%s|%d|%d|%s|%s|%d|%d|%d|%d|%s|%s",
|
return stableHash(fmt.Sprintf("room_turnover_reward|%s|%d|%d|%s|%s|%d|%d|%d|%d|%s|%s",
|
||||||
appcode.Normalize(command.AppCode),
|
appcode.Normalize(command.AppCode),
|
||||||
|
|||||||
@ -756,6 +756,28 @@ func (s *Server) DebitCPBreakupFee(ctx context.Context, req *walletv1.DebitCPBre
|
|||||||
}, nil
|
}, 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。
|
// GrantVip 统一处理活动和后台赠送 VIP。
|
||||||
func (s *Server) GrantVip(ctx context.Context, req *walletv1.GrantVipRequest) (*walletv1.GrantVipResponse, error) {
|
func (s *Server) GrantVip(ctx context.Context, req *walletv1.GrantVipRequest) (*walletv1.GrantVipResponse, error) {
|
||||||
ctx = appcode.WithContext(ctx, req.GetAppCode())
|
ctx = appcode.WithContext(ctx, req.GetAppCode())
|
||||||
@ -1040,6 +1062,38 @@ func (s *Server) CreditLuckyGiftReward(ctx context.Context, req *walletv1.Credit
|
|||||||
}, nil
|
}, 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 每周房间流水奖励金币入账。
|
// CreditRoomTurnoverReward 处理 activity-service 每周房间流水奖励金币入账。
|
||||||
func (s *Server) CreditRoomTurnoverReward(ctx context.Context, req *walletv1.CreditRoomTurnoverRewardRequest) (*walletv1.CreditRoomTurnoverRewardResponse, error) {
|
func (s *Server) CreditRoomTurnoverReward(ctx context.Context, req *walletv1.CreditRoomTurnoverRewardRequest) (*walletv1.CreditRoomTurnoverRewardResponse, error) {
|
||||||
// gRPC 层不解释活动规则,只把 activity-service 的结算命令转换为钱包领域命令。
|
// gRPC 层不解释活动规则,只把 activity-service 的结算命令转换为钱包领域命令。
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user