Merge branch 'test' of gitea.haiyihy.com:hy/hyapp-server

# Conflicts:
#	scripts/mysql/038_split_bd_leader_profiles.sql
#	server/admin/internal/modules/hostorg/reader.go
#	server/admin/internal/modules/hostorg/service_test.go
#	server/admin/internal/modules/teamsalarysettlement/service.go
#	server/admin/internal/modules/teamsalarysettlement/service_integration_test.go
#	services/gateway-service/internal/transport/http/gameapi/game_handler.go
#	services/gateway-service/internal/transport/http/gameapi/handler.go
#	services/gateway-service/internal/transport/http/httproutes/router.go
#	services/user-service/deploy/mysql/initdb/001_user_service.sql
#	services/user-service/internal/storage/mysql/host/admin.go
#	services/user-service/internal/storage/mysql/host/common.go
#	services/user-service/internal/testutil/mysqltest/mysqltest.go
This commit is contained in:
zhx 2026-06-11 19:45:31 +08:00
commit ff0e42e3d3
334 changed files with 54251 additions and 5159 deletions

View File

@ -92,8 +92,9 @@
## Coding Rules
- 默认使用 Go 标准库和仓库已有小包;不要为了轻量逻辑引入重框架。
- 手写代码保持高密度有效注释,注释解释工程语义和失败分支,不复述语法。
- 手写代码保持高密度有效注释,所有业务逻辑、状态流转、权限判断、支付链路、异步流程都要解释工程语义和失败分支,不复述语法。
- 新增配置必须同时考虑本地 `config.yaml`、Docker `config.docker.yaml`、以及线上配置示例。
- 所有逻辑功能配置默认开启;密钥、地址、三方账号等必须由运行环境提供的敏感配置只能使用占位符或环境配置,不能写入真实值,也不能伪造可用凭证。
- 新增服务依赖必须在 `docker-compose.yml` 中体现本地可运行形态。
- 数据库表结构变更必须同步更新对应 owner service 的 `deploy/mysql/initdb`
- 不要把线上密钥、云数据库密码、Redis 密码写进真实配置。只能写占位示例。

File diff suppressed because it is too large Load Diff

View File

@ -215,6 +215,13 @@ message TaskItem {
int64 next_refresh_at_ms = 15;
int32 sort_order = 16;
int64 version = 17;
string audience_type = 18;
string icon_key = 19;
string icon_url = 20;
string action_type = 21;
string action_param = 22;
string action_payload_json = 23;
string dimension_filter_json = 24;
}
// TaskSection App daily/exclusive
@ -271,6 +278,7 @@ message ConsumeTaskEventRequest {
string metric_type = 6;
int64 value = 7;
int64 occurred_at_ms = 8;
string dimensions_json = 9;
}
// ConsumeTaskEventResponse matched_task_count=0
@ -369,6 +377,13 @@ message TaskDefinition {
int64 updated_by_admin_id = 16;
int64 created_at_ms = 17;
int64 updated_at_ms = 18;
string audience_type = 19;
string icon_key = 20;
string icon_url = 21;
string action_type = 22;
string action_param = 23;
string action_payload_json = 24;
string dimension_filter_json = 25;
}
// ListTaskDefinitionsRequest
@ -404,6 +419,13 @@ message UpsertTaskDefinitionRequest {
int64 effective_from_ms = 13;
int64 effective_to_ms = 14;
int64 operator_admin_id = 15;
string audience_type = 16;
string icon_key = 17;
string icon_url = 18;
string action_type = 19;
string action_param = 20;
string action_payload_json = 21;
string dimension_filter_json = 22;
}
message UpsertTaskDefinitionResponse {
@ -534,7 +556,7 @@ message ListRegistrationRewardClaimsResponse {
int64 today_claimed_count = 3;
}
// FirstRechargeRewardTier max_coin_amount=0
// FirstRechargeRewardTier Google
message FirstRechargeRewardTier {
int64 tier_id = 1;
string tier_code = 2;
@ -546,6 +568,9 @@ message FirstRechargeRewardTier {
int32 sort_order = 8;
int64 created_at_ms = 9;
int64 updated_at_ms = 10;
int64 usd_minor_amount = 11;
string google_product_id = 12;
int32 discount_percent = 13;
}
// FirstRechargeRewardConfig App activity-service
@ -558,7 +583,7 @@ message FirstRechargeRewardConfig {
int64 updated_at_ms = 6;
}
// FirstRechargeRewardClaim user_id
// FirstRechargeRewardClaim
message FirstRechargeRewardClaim {
string claim_id = 1;
string event_id = 2;
@ -579,6 +604,9 @@ message FirstRechargeRewardClaim {
int64 granted_at_ms = 17;
int64 created_at_ms = 18;
int64 updated_at_ms = 19;
int64 tier_usd_minor_amount = 20;
string google_product_id = 21;
int64 recharge_usd_minor = 22;
}
// FirstRechargeRewardStatus App
@ -588,6 +616,7 @@ message FirstRechargeRewardStatus {
bool rewarded = 3;
FirstRechargeRewardClaim claim = 4;
int64 server_time_ms = 5;
repeated FirstRechargeRewardClaim claims = 6;
}
message GetFirstRechargeRewardStatusRequest {
@ -599,7 +628,7 @@ message GetFirstRechargeRewardStatusResponse {
FirstRechargeRewardStatus status = 1;
}
// ConsumeFirstRechargeRewardRequest wallet recharge_sequence=1
// ConsumeFirstRechargeRewardRequest wallet Google ID
message ConsumeFirstRechargeRewardRequest {
RequestMeta meta = 1;
string event_id = 2;
@ -610,6 +639,8 @@ message ConsumeFirstRechargeRewardRequest {
int64 recharge_sequence = 7;
string recharge_type = 8;
int64 occurred_at_ms = 9;
int64 recharge_usd_minor = 10;
string google_product_id = 11;
}
message ConsumeFirstRechargeRewardResponse {
@ -785,6 +816,159 @@ message ListCumulativeRechargeRewardGrantsResponse {
int64 total = 2;
}
// InviteActivityRewardTier recharge 使valid_invite 使
message InviteActivityRewardTier {
int64 tier_id = 1;
string reward_type = 2;
string tier_code = 3;
string tier_name = 4;
int64 threshold_coin_amount = 5;
int64 threshold_valid_invite_count = 6;
int64 reward_coin_amount = 7;
string status = 8;
int32 sort_order = 9;
int64 created_at_ms = 10;
int64 updated_at_ms = 11;
}
// InviteActivityRewardConfig App App
message InviteActivityRewardConfig {
string app_code = 1;
bool enabled = 2;
repeated InviteActivityRewardTier tiers = 3;
int64 updated_by_admin_id = 4;
int64 created_at_ms = 5;
int64 updated_at_ms = 6;
int64 per_invite_inviter_reward_coin_amount = 7;
int64 per_invite_invitee_reward_coin_amount = 8;
}
// InviteActivityRewardProgress UTC
message InviteActivityRewardProgress {
string app_code = 1;
string cycle_key = 2;
int64 user_id = 3;
int64 total_recharge_coin_amount = 4;
int64 valid_invite_count = 5;
int64 claimed_reward_coin_amount = 6;
int64 updated_at_ms = 7;
}
// InviteActivityRewardClaim
message InviteActivityRewardClaim {
string claim_id = 1;
string app_code = 2;
string cycle_key = 3;
int64 user_id = 4;
string reward_type = 5;
string command_id = 6;
int64 tier_id = 7;
string tier_code = 8;
string tier_name = 9;
int64 threshold_coin_amount = 10;
int64 threshold_valid_invite_count = 11;
int64 reached_value = 12;
int64 reward_coin_amount = 13;
string status = 14;
string wallet_command_id = 15;
string wallet_transaction_id = 16;
string failure_reason = 17;
int64 granted_at_ms = 18;
int64 created_at_ms = 19;
int64 updated_at_ms = 20;
}
// InviteActivityRewardStatus App
message InviteActivityRewardStatus {
InviteActivityRewardConfig config = 1;
InviteActivityRewardProgress progress = 2;
repeated InviteActivityRewardClaim claims = 3;
string cycle_key = 4;
int64 period_start_ms = 5;
int64 period_end_ms = 6;
int64 server_time_ms = 7;
}
message GetInviteActivityRewardStatusRequest {
RequestMeta meta = 1;
int64 user_id = 2;
}
message GetInviteActivityRewardStatusResponse {
InviteActivityRewardStatus status = 1;
}
message ClaimInviteActivityRewardRequest {
RequestMeta meta = 1;
int64 user_id = 2;
string reward_type = 3;
int64 tier_id = 4;
string command_id = 5;
}
message ClaimInviteActivityRewardResponse {
InviteActivityRewardClaim claim = 1;
InviteActivityRewardStatus status = 2;
}
// InviteActivityLeaderboardEntry gateway
message InviteActivityLeaderboardEntry {
int32 rank_no = 1;
int64 user_id = 2;
int64 valid_invite_count = 3;
int64 total_recharge_coin_amount = 4;
}
message ListInviteActivityLeaderboardRequest {
RequestMeta meta = 1;
string cycle_key = 2;
int32 page = 3;
int32 page_size = 4;
}
message ListInviteActivityLeaderboardResponse {
repeated InviteActivityLeaderboardEntry entries = 1;
int64 total = 2;
string cycle_key = 3;
int64 server_time_ms = 4;
}
message GetInviteActivityRewardConfigRequest {
RequestMeta meta = 1;
}
message GetInviteActivityRewardConfigResponse {
InviteActivityRewardConfig config = 1;
}
message UpdateInviteActivityRewardConfigRequest {
RequestMeta meta = 1;
bool enabled = 2;
repeated InviteActivityRewardTier tiers = 3;
int64 operator_admin_id = 4;
int64 per_invite_inviter_reward_coin_amount = 5;
int64 per_invite_invitee_reward_coin_amount = 6;
}
message UpdateInviteActivityRewardConfigResponse {
InviteActivityRewardConfig config = 1;
}
message ListInviteActivityRewardClaimsRequest {
RequestMeta meta = 1;
string status = 2;
string reward_type = 3;
int64 user_id = 4;
string cycle_key = 5;
int32 page = 6;
int32 page_size = 7;
}
message ListInviteActivityRewardClaimsResponse {
repeated InviteActivityRewardClaim claims = 1;
int64 total = 2;
}
// RoomTurnoverRewardTier threshold_coin_spent
message RoomTurnoverRewardTier {
int64 tier_id = 1;
@ -1948,6 +2132,13 @@ service CumulativeRechargeRewardService {
rpc ConsumeCumulativeRechargeReward(ConsumeCumulativeRechargeRewardRequest) returns (ConsumeCumulativeRechargeRewardResponse);
}
// InviteActivityRewardService App
service InviteActivityRewardService {
rpc GetInviteActivityRewardStatus(GetInviteActivityRewardStatusRequest) returns (GetInviteActivityRewardStatusResponse);
rpc ClaimInviteActivityReward(ClaimInviteActivityRewardRequest) returns (ClaimInviteActivityRewardResponse);
rpc ListInviteActivityLeaderboard(ListInviteActivityLeaderboardRequest) returns (ListInviteActivityLeaderboardResponse);
}
// AdminCumulativeRechargeRewardService 访
service AdminCumulativeRechargeRewardService {
rpc GetCumulativeRechargeRewardConfig(GetCumulativeRechargeRewardConfigRequest) returns (GetCumulativeRechargeRewardConfigResponse);
@ -1955,6 +2146,13 @@ service AdminCumulativeRechargeRewardService {
rpc ListCumulativeRechargeRewardGrants(ListCumulativeRechargeRewardGrantsRequest) returns (ListCumulativeRechargeRewardGrantsResponse);
}
// AdminInviteActivityRewardService 访
service AdminInviteActivityRewardService {
rpc GetInviteActivityRewardConfig(GetInviteActivityRewardConfigRequest) returns (GetInviteActivityRewardConfigResponse);
rpc UpdateInviteActivityRewardConfig(UpdateInviteActivityRewardConfigRequest) returns (UpdateInviteActivityRewardConfigResponse);
rpc ListInviteActivityRewardClaims(ListInviteActivityRewardClaimsRequest) returns (ListInviteActivityRewardClaimsResponse);
}
// AdminRoomTurnoverRewardService 访
service AdminRoomTurnoverRewardService {
rpc GetRoomTurnoverRewardConfig(GetRoomTurnoverRewardConfigRequest) returns (GetRoomTurnoverRewardConfigResponse);

View File

@ -3271,6 +3271,189 @@ var CumulativeRechargeRewardService_ServiceDesc = grpc.ServiceDesc{
Metadata: "proto/activity/v1/activity.proto",
}
const (
InviteActivityRewardService_GetInviteActivityRewardStatus_FullMethodName = "/hyapp.activity.v1.InviteActivityRewardService/GetInviteActivityRewardStatus"
InviteActivityRewardService_ClaimInviteActivityReward_FullMethodName = "/hyapp.activity.v1.InviteActivityRewardService/ClaimInviteActivityReward"
InviteActivityRewardService_ListInviteActivityLeaderboard_FullMethodName = "/hyapp.activity.v1.InviteActivityRewardService/ListInviteActivityLeaderboard"
)
// InviteActivityRewardServiceClient is the client API for InviteActivityRewardService 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.
//
// InviteActivityRewardService 拥有 App 邀请活动状态、领奖和排行榜入口。
type InviteActivityRewardServiceClient interface {
GetInviteActivityRewardStatus(ctx context.Context, in *GetInviteActivityRewardStatusRequest, opts ...grpc.CallOption) (*GetInviteActivityRewardStatusResponse, error)
ClaimInviteActivityReward(ctx context.Context, in *ClaimInviteActivityRewardRequest, opts ...grpc.CallOption) (*ClaimInviteActivityRewardResponse, error)
ListInviteActivityLeaderboard(ctx context.Context, in *ListInviteActivityLeaderboardRequest, opts ...grpc.CallOption) (*ListInviteActivityLeaderboardResponse, error)
}
type inviteActivityRewardServiceClient struct {
cc grpc.ClientConnInterface
}
func NewInviteActivityRewardServiceClient(cc grpc.ClientConnInterface) InviteActivityRewardServiceClient {
return &inviteActivityRewardServiceClient{cc}
}
func (c *inviteActivityRewardServiceClient) GetInviteActivityRewardStatus(ctx context.Context, in *GetInviteActivityRewardStatusRequest, opts ...grpc.CallOption) (*GetInviteActivityRewardStatusResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(GetInviteActivityRewardStatusResponse)
err := c.cc.Invoke(ctx, InviteActivityRewardService_GetInviteActivityRewardStatus_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *inviteActivityRewardServiceClient) ClaimInviteActivityReward(ctx context.Context, in *ClaimInviteActivityRewardRequest, opts ...grpc.CallOption) (*ClaimInviteActivityRewardResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(ClaimInviteActivityRewardResponse)
err := c.cc.Invoke(ctx, InviteActivityRewardService_ClaimInviteActivityReward_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *inviteActivityRewardServiceClient) ListInviteActivityLeaderboard(ctx context.Context, in *ListInviteActivityLeaderboardRequest, opts ...grpc.CallOption) (*ListInviteActivityLeaderboardResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(ListInviteActivityLeaderboardResponse)
err := c.cc.Invoke(ctx, InviteActivityRewardService_ListInviteActivityLeaderboard_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
// InviteActivityRewardServiceServer is the server API for InviteActivityRewardService service.
// All implementations must embed UnimplementedInviteActivityRewardServiceServer
// for forward compatibility.
//
// InviteActivityRewardService 拥有 App 邀请活动状态、领奖和排行榜入口。
type InviteActivityRewardServiceServer interface {
GetInviteActivityRewardStatus(context.Context, *GetInviteActivityRewardStatusRequest) (*GetInviteActivityRewardStatusResponse, error)
ClaimInviteActivityReward(context.Context, *ClaimInviteActivityRewardRequest) (*ClaimInviteActivityRewardResponse, error)
ListInviteActivityLeaderboard(context.Context, *ListInviteActivityLeaderboardRequest) (*ListInviteActivityLeaderboardResponse, error)
mustEmbedUnimplementedInviteActivityRewardServiceServer()
}
// UnimplementedInviteActivityRewardServiceServer 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 UnimplementedInviteActivityRewardServiceServer struct{}
func (UnimplementedInviteActivityRewardServiceServer) GetInviteActivityRewardStatus(context.Context, *GetInviteActivityRewardStatusRequest) (*GetInviteActivityRewardStatusResponse, error) {
return nil, status.Error(codes.Unimplemented, "method GetInviteActivityRewardStatus not implemented")
}
func (UnimplementedInviteActivityRewardServiceServer) ClaimInviteActivityReward(context.Context, *ClaimInviteActivityRewardRequest) (*ClaimInviteActivityRewardResponse, error) {
return nil, status.Error(codes.Unimplemented, "method ClaimInviteActivityReward not implemented")
}
func (UnimplementedInviteActivityRewardServiceServer) ListInviteActivityLeaderboard(context.Context, *ListInviteActivityLeaderboardRequest) (*ListInviteActivityLeaderboardResponse, error) {
return nil, status.Error(codes.Unimplemented, "method ListInviteActivityLeaderboard not implemented")
}
func (UnimplementedInviteActivityRewardServiceServer) mustEmbedUnimplementedInviteActivityRewardServiceServer() {
}
func (UnimplementedInviteActivityRewardServiceServer) testEmbeddedByValue() {}
// UnsafeInviteActivityRewardServiceServer may be embedded to opt out of forward compatibility for this service.
// Use of this interface is not recommended, as added methods to InviteActivityRewardServiceServer will
// result in compilation errors.
type UnsafeInviteActivityRewardServiceServer interface {
mustEmbedUnimplementedInviteActivityRewardServiceServer()
}
func RegisterInviteActivityRewardServiceServer(s grpc.ServiceRegistrar, srv InviteActivityRewardServiceServer) {
// If the following call panics, it indicates UnimplementedInviteActivityRewardServiceServer 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(&InviteActivityRewardService_ServiceDesc, srv)
}
func _InviteActivityRewardService_GetInviteActivityRewardStatus_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(GetInviteActivityRewardStatusRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(InviteActivityRewardServiceServer).GetInviteActivityRewardStatus(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: InviteActivityRewardService_GetInviteActivityRewardStatus_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(InviteActivityRewardServiceServer).GetInviteActivityRewardStatus(ctx, req.(*GetInviteActivityRewardStatusRequest))
}
return interceptor(ctx, in, info, handler)
}
func _InviteActivityRewardService_ClaimInviteActivityReward_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(ClaimInviteActivityRewardRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(InviteActivityRewardServiceServer).ClaimInviteActivityReward(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: InviteActivityRewardService_ClaimInviteActivityReward_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(InviteActivityRewardServiceServer).ClaimInviteActivityReward(ctx, req.(*ClaimInviteActivityRewardRequest))
}
return interceptor(ctx, in, info, handler)
}
func _InviteActivityRewardService_ListInviteActivityLeaderboard_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(ListInviteActivityLeaderboardRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(InviteActivityRewardServiceServer).ListInviteActivityLeaderboard(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: InviteActivityRewardService_ListInviteActivityLeaderboard_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(InviteActivityRewardServiceServer).ListInviteActivityLeaderboard(ctx, req.(*ListInviteActivityLeaderboardRequest))
}
return interceptor(ctx, in, info, handler)
}
// InviteActivityRewardService_ServiceDesc is the grpc.ServiceDesc for InviteActivityRewardService service.
// It's only intended for direct use with grpc.RegisterService,
// and not to be introspected or modified (even as a copy)
var InviteActivityRewardService_ServiceDesc = grpc.ServiceDesc{
ServiceName: "hyapp.activity.v1.InviteActivityRewardService",
HandlerType: (*InviteActivityRewardServiceServer)(nil),
Methods: []grpc.MethodDesc{
{
MethodName: "GetInviteActivityRewardStatus",
Handler: _InviteActivityRewardService_GetInviteActivityRewardStatus_Handler,
},
{
MethodName: "ClaimInviteActivityReward",
Handler: _InviteActivityRewardService_ClaimInviteActivityReward_Handler,
},
{
MethodName: "ListInviteActivityLeaderboard",
Handler: _InviteActivityRewardService_ListInviteActivityLeaderboard_Handler,
},
},
Streams: []grpc.StreamDesc{},
Metadata: "proto/activity/v1/activity.proto",
}
const (
AdminCumulativeRechargeRewardService_GetCumulativeRechargeRewardConfig_FullMethodName = "/hyapp.activity.v1.AdminCumulativeRechargeRewardService/GetCumulativeRechargeRewardConfig"
AdminCumulativeRechargeRewardService_UpdateCumulativeRechargeRewardConfig_FullMethodName = "/hyapp.activity.v1.AdminCumulativeRechargeRewardService/UpdateCumulativeRechargeRewardConfig"
@ -3454,6 +3637,189 @@ var AdminCumulativeRechargeRewardService_ServiceDesc = grpc.ServiceDesc{
Metadata: "proto/activity/v1/activity.proto",
}
const (
AdminInviteActivityRewardService_GetInviteActivityRewardConfig_FullMethodName = "/hyapp.activity.v1.AdminInviteActivityRewardService/GetInviteActivityRewardConfig"
AdminInviteActivityRewardService_UpdateInviteActivityRewardConfig_FullMethodName = "/hyapp.activity.v1.AdminInviteActivityRewardService/UpdateInviteActivityRewardConfig"
AdminInviteActivityRewardService_ListInviteActivityRewardClaims_FullMethodName = "/hyapp.activity.v1.AdminInviteActivityRewardService/ListInviteActivityRewardClaims"
)
// AdminInviteActivityRewardServiceClient is the client API for AdminInviteActivityRewardService 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.
//
// AdminInviteActivityRewardService 是后台活动管理访问邀请活动配置和领取记录的唯一入口。
type AdminInviteActivityRewardServiceClient interface {
GetInviteActivityRewardConfig(ctx context.Context, in *GetInviteActivityRewardConfigRequest, opts ...grpc.CallOption) (*GetInviteActivityRewardConfigResponse, error)
UpdateInviteActivityRewardConfig(ctx context.Context, in *UpdateInviteActivityRewardConfigRequest, opts ...grpc.CallOption) (*UpdateInviteActivityRewardConfigResponse, error)
ListInviteActivityRewardClaims(ctx context.Context, in *ListInviteActivityRewardClaimsRequest, opts ...grpc.CallOption) (*ListInviteActivityRewardClaimsResponse, error)
}
type adminInviteActivityRewardServiceClient struct {
cc grpc.ClientConnInterface
}
func NewAdminInviteActivityRewardServiceClient(cc grpc.ClientConnInterface) AdminInviteActivityRewardServiceClient {
return &adminInviteActivityRewardServiceClient{cc}
}
func (c *adminInviteActivityRewardServiceClient) GetInviteActivityRewardConfig(ctx context.Context, in *GetInviteActivityRewardConfigRequest, opts ...grpc.CallOption) (*GetInviteActivityRewardConfigResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(GetInviteActivityRewardConfigResponse)
err := c.cc.Invoke(ctx, AdminInviteActivityRewardService_GetInviteActivityRewardConfig_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *adminInviteActivityRewardServiceClient) UpdateInviteActivityRewardConfig(ctx context.Context, in *UpdateInviteActivityRewardConfigRequest, opts ...grpc.CallOption) (*UpdateInviteActivityRewardConfigResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(UpdateInviteActivityRewardConfigResponse)
err := c.cc.Invoke(ctx, AdminInviteActivityRewardService_UpdateInviteActivityRewardConfig_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *adminInviteActivityRewardServiceClient) ListInviteActivityRewardClaims(ctx context.Context, in *ListInviteActivityRewardClaimsRequest, opts ...grpc.CallOption) (*ListInviteActivityRewardClaimsResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(ListInviteActivityRewardClaimsResponse)
err := c.cc.Invoke(ctx, AdminInviteActivityRewardService_ListInviteActivityRewardClaims_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
// AdminInviteActivityRewardServiceServer is the server API for AdminInviteActivityRewardService service.
// All implementations must embed UnimplementedAdminInviteActivityRewardServiceServer
// for forward compatibility.
//
// AdminInviteActivityRewardService 是后台活动管理访问邀请活动配置和领取记录的唯一入口。
type AdminInviteActivityRewardServiceServer interface {
GetInviteActivityRewardConfig(context.Context, *GetInviteActivityRewardConfigRequest) (*GetInviteActivityRewardConfigResponse, error)
UpdateInviteActivityRewardConfig(context.Context, *UpdateInviteActivityRewardConfigRequest) (*UpdateInviteActivityRewardConfigResponse, error)
ListInviteActivityRewardClaims(context.Context, *ListInviteActivityRewardClaimsRequest) (*ListInviteActivityRewardClaimsResponse, error)
mustEmbedUnimplementedAdminInviteActivityRewardServiceServer()
}
// UnimplementedAdminInviteActivityRewardServiceServer 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 UnimplementedAdminInviteActivityRewardServiceServer struct{}
func (UnimplementedAdminInviteActivityRewardServiceServer) GetInviteActivityRewardConfig(context.Context, *GetInviteActivityRewardConfigRequest) (*GetInviteActivityRewardConfigResponse, error) {
return nil, status.Error(codes.Unimplemented, "method GetInviteActivityRewardConfig not implemented")
}
func (UnimplementedAdminInviteActivityRewardServiceServer) UpdateInviteActivityRewardConfig(context.Context, *UpdateInviteActivityRewardConfigRequest) (*UpdateInviteActivityRewardConfigResponse, error) {
return nil, status.Error(codes.Unimplemented, "method UpdateInviteActivityRewardConfig not implemented")
}
func (UnimplementedAdminInviteActivityRewardServiceServer) ListInviteActivityRewardClaims(context.Context, *ListInviteActivityRewardClaimsRequest) (*ListInviteActivityRewardClaimsResponse, error) {
return nil, status.Error(codes.Unimplemented, "method ListInviteActivityRewardClaims not implemented")
}
func (UnimplementedAdminInviteActivityRewardServiceServer) mustEmbedUnimplementedAdminInviteActivityRewardServiceServer() {
}
func (UnimplementedAdminInviteActivityRewardServiceServer) testEmbeddedByValue() {}
// UnsafeAdminInviteActivityRewardServiceServer may be embedded to opt out of forward compatibility for this service.
// Use of this interface is not recommended, as added methods to AdminInviteActivityRewardServiceServer will
// result in compilation errors.
type UnsafeAdminInviteActivityRewardServiceServer interface {
mustEmbedUnimplementedAdminInviteActivityRewardServiceServer()
}
func RegisterAdminInviteActivityRewardServiceServer(s grpc.ServiceRegistrar, srv AdminInviteActivityRewardServiceServer) {
// If the following call panics, it indicates UnimplementedAdminInviteActivityRewardServiceServer 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(&AdminInviteActivityRewardService_ServiceDesc, srv)
}
func _AdminInviteActivityRewardService_GetInviteActivityRewardConfig_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(GetInviteActivityRewardConfigRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(AdminInviteActivityRewardServiceServer).GetInviteActivityRewardConfig(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: AdminInviteActivityRewardService_GetInviteActivityRewardConfig_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(AdminInviteActivityRewardServiceServer).GetInviteActivityRewardConfig(ctx, req.(*GetInviteActivityRewardConfigRequest))
}
return interceptor(ctx, in, info, handler)
}
func _AdminInviteActivityRewardService_UpdateInviteActivityRewardConfig_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(UpdateInviteActivityRewardConfigRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(AdminInviteActivityRewardServiceServer).UpdateInviteActivityRewardConfig(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: AdminInviteActivityRewardService_UpdateInviteActivityRewardConfig_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(AdminInviteActivityRewardServiceServer).UpdateInviteActivityRewardConfig(ctx, req.(*UpdateInviteActivityRewardConfigRequest))
}
return interceptor(ctx, in, info, handler)
}
func _AdminInviteActivityRewardService_ListInviteActivityRewardClaims_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(ListInviteActivityRewardClaimsRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(AdminInviteActivityRewardServiceServer).ListInviteActivityRewardClaims(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: AdminInviteActivityRewardService_ListInviteActivityRewardClaims_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(AdminInviteActivityRewardServiceServer).ListInviteActivityRewardClaims(ctx, req.(*ListInviteActivityRewardClaimsRequest))
}
return interceptor(ctx, in, info, handler)
}
// AdminInviteActivityRewardService_ServiceDesc is the grpc.ServiceDesc for AdminInviteActivityRewardService service.
// It's only intended for direct use with grpc.RegisterService,
// and not to be introspected or modified (even as a copy)
var AdminInviteActivityRewardService_ServiceDesc = grpc.ServiceDesc{
ServiceName: "hyapp.activity.v1.AdminInviteActivityRewardService",
HandlerType: (*AdminInviteActivityRewardServiceServer)(nil),
Methods: []grpc.MethodDesc{
{
MethodName: "GetInviteActivityRewardConfig",
Handler: _AdminInviteActivityRewardService_GetInviteActivityRewardConfig_Handler,
},
{
MethodName: "UpdateInviteActivityRewardConfig",
Handler: _AdminInviteActivityRewardService_UpdateInviteActivityRewardConfig_Handler,
},
{
MethodName: "ListInviteActivityRewardClaims",
Handler: _AdminInviteActivityRewardService_ListInviteActivityRewardClaims_Handler,
},
},
Streams: []grpc.StreamDesc{},
Metadata: "proto/activity/v1/activity.proto",
}
const (
AdminRoomTurnoverRewardService_GetRoomTurnoverRewardConfig_FullMethodName = "/hyapp.activity.v1.AdminRoomTurnoverRewardService/GetRoomTurnoverRewardConfig"
AdminRoomTurnoverRewardService_UpdateRoomTurnoverRewardConfig_FullMethodName = "/hyapp.activity.v1.AdminRoomTurnoverRewardService/UpdateRoomTurnoverRewardConfig"

View File

@ -1208,6 +1208,7 @@ type RoomGiftSent struct {
GiftIconUrl string `protobuf:"bytes,16,opt,name=gift_icon_url,json=giftIconUrl,proto3" json:"gift_icon_url,omitempty"`
GiftAnimationUrl string `protobuf:"bytes,17,opt,name=gift_animation_url,json=giftAnimationUrl,proto3" json:"gift_animation_url,omitempty"`
TargetGiftValue int64 `protobuf:"varint,18,opt,name=target_gift_value,json=targetGiftValue,proto3" json:"target_gift_value,omitempty"`
GiftEffectTypes []string `protobuf:"bytes,19,rep,name=gift_effect_types,json=giftEffectTypes,proto3" json:"gift_effect_types,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
@ -1368,6 +1369,13 @@ func (x *RoomGiftSent) GetTargetGiftValue() int64 {
return 0
}
func (x *RoomGiftSent) GetGiftEffectTypes() []string {
if x != nil {
return x.GiftEffectTypes
}
return nil
}
// RoomHeatChanged 表达热度变化结果。
type RoomHeatChanged struct {
state protoimpl.MessageState `protogen:"open.v1"`
@ -2152,7 +2160,7 @@ const file_proto_events_room_v1_events_proto_rawDesc = "" +
"\x0etarget_user_id\x18\x02 \x01(\x03R\ftargetUserId\"\\\n" +
"\x10RoomUserUnbanned\x12\"\n" +
"\ractor_user_id\x18\x01 \x01(\x03R\vactorUserId\x12$\n" +
"\x0etarget_user_id\x18\x02 \x01(\x03R\ftargetUserId\"\x89\x05\n" +
"\x0etarget_user_id\x18\x02 \x01(\x03R\ftargetUserId\"\xb5\x05\n" +
"\fRoomGiftSent\x12$\n" +
"\x0esender_user_id\x18\x01 \x01(\x03R\fsenderUserId\x12$\n" +
"\x0etarget_user_id\x18\x02 \x01(\x03R\ftargetUserId\x12\x17\n" +
@ -2177,7 +2185,8 @@ const file_proto_events_room_v1_events_proto_rawDesc = "" +
"\tgift_name\x18\x0f \x01(\tR\bgiftName\x12\"\n" +
"\rgift_icon_url\x18\x10 \x01(\tR\vgiftIconUrl\x12,\n" +
"\x12gift_animation_url\x18\x11 \x01(\tR\x10giftAnimationUrl\x12*\n" +
"\x11target_gift_value\x18\x12 \x01(\x03R\x0ftargetGiftValue\"J\n" +
"\x11target_gift_value\x18\x12 \x01(\x03R\x0ftargetGiftValue\x12*\n" +
"\x11gift_effect_types\x18\x13 \x03(\tR\x0fgiftEffectTypes\"J\n" +
"\x0fRoomHeatChanged\x12\x14\n" +
"\x05delta\x18\x01 \x01(\x03R\x05delta\x12!\n" +
"\fcurrent_heat\x18\x02 \x01(\x03R\vcurrentHeat\"_\n" +

View File

@ -165,6 +165,7 @@ message RoomGiftSent {
string gift_icon_url = 16;
string gift_animation_url = 17;
int64 target_gift_value = 18;
repeated string gift_effect_types = 19;
}
// RoomHeatChanged

File diff suppressed because it is too large Load Diff

View File

@ -94,6 +94,27 @@ message ListRecentGamesRequest {
int32 page_size = 8;
}
message ListExploreWinnersRequest {
RequestMeta meta = 1;
int32 page_size = 2;
}
message ExploreWinner {
string win_id = 1;
string game_code = 2;
string game_id = 3;
int64 user_id = 4;
string display_name = 5;
string avatar_url = 6;
int64 coin_amount = 7;
int64 won_at_ms = 8;
}
message ListExploreWinnersResponse {
repeated ExploreWinner winners = 1;
int64 server_time_ms = 2;
}
message GetBridgeScriptRequest {
RequestMeta meta = 1;
}
@ -130,6 +151,260 @@ message LaunchGameResponse {
int32 safe_height = 6;
}
message DiceParticipant {
int64 user_id = 1;
int32 seat_no = 2;
string status = 3;
int64 stake_coin = 4;
repeated int32 dice_points = 5;
string result = 6;
int64 payout_coin = 7;
int64 balance_after = 8;
int64 joined_at_ms = 9;
int64 updated_at_ms = 10;
string participant_type = 11;
bool is_robot = 12;
string rps_gesture = 13;
}
message DiceMatch {
string app_code = 1;
string match_id = 2;
string game_id = 3;
string room_id = 4;
int64 region_id = 5;
int32 min_players = 6;
int32 max_players = 7;
int32 current_players = 8;
int64 stake_coin = 9;
int32 round_no = 10;
string status = 11;
string result = 12;
repeated DiceParticipant participants = 13;
int64 join_deadline_ms = 14;
int64 created_at_ms = 15;
int64 updated_at_ms = 16;
int64 settled_at_ms = 17;
string phase = 18;
int64 phase_deadline_ms = 19;
int32 fee_bps = 20;
int32 pool_bps = 21;
string match_mode = 22;
string forced_result = 23;
int64 ready_at_ms = 24;
int64 canceled_at_ms = 25;
int64 pool_delta_coin = 26;
}
message DiceStakeOption {
int64 stake_coin = 1;
bool enabled = 2;
int32 sort_order = 3;
}
message DiceConfig {
string app_code = 1;
string game_id = 2;
string status = 3;
repeated DiceStakeOption stake_options = 4;
int32 fee_bps = 5;
int32 pool_bps = 6;
int32 min_players = 7;
int32 max_players = 8;
bool robot_enabled = 9;
int64 robot_match_wait_ms = 10;
int64 pool_balance_coin = 11;
int64 created_at_ms = 12;
int64 updated_at_ms = 13;
}
message GetDiceConfigRequest {
RequestMeta meta = 1;
string game_id = 2;
}
message DiceConfigResponse {
DiceConfig config = 1;
int64 server_time_ms = 2;
}
message RoomRPSStakeGift {
int64 gift_id = 1;
string gift_name = 2;
string gift_icon_url = 3;
int64 gift_price_coin = 4;
bool enabled = 5;
int32 sort_order = 6;
}
message RoomRPSConfig {
string app_code = 1;
string game_id = 2;
string status = 3;
int64 challenge_timeout_ms = 4;
int64 reveal_countdown_ms = 5;
repeated RoomRPSStakeGift stake_gifts = 6;
int64 created_at_ms = 7;
int64 updated_at_ms = 8;
}
message RoomRPSPlayer {
int64 user_id = 1;
string gesture = 2;
string result = 3;
int64 balance_after = 4;
int64 joined_at_ms = 5;
}
message RoomRPSChallenge {
string app_code = 1;
string challenge_id = 2;
string room_id = 3;
int64 region_id = 4;
string status = 5;
int64 stake_gift_id = 6;
int64 stake_coin = 7;
RoomRPSPlayer initiator = 8;
RoomRPSPlayer challenger = 9;
int64 winner_user_id = 10;
string settlement_status = 11;
string failure_reason = 12;
int64 timeout_at_ms = 13;
int64 reveal_at_ms = 14;
int64 created_at_ms = 15;
int64 matched_at_ms = 16;
int64 settled_at_ms = 17;
int64 updated_at_ms = 18;
}
message GetRoomRPSConfigRequest {
RequestMeta meta = 1;
}
message RoomRPSConfigResponse {
RoomRPSConfig config = 1;
int64 server_time_ms = 2;
}
message CreateRoomRPSChallengeRequest {
RequestMeta meta = 1;
int64 user_id = 2;
string room_id = 3;
int64 region_id = 4;
int64 gift_id = 5;
string gesture = 6;
}
message AcceptRoomRPSChallengeRequest {
RequestMeta meta = 1;
int64 user_id = 2;
string challenge_id = 3;
string gesture = 4;
}
message GetRoomRPSChallengeRequest {
RequestMeta meta = 1;
int64 user_id = 2;
string challenge_id = 3;
}
message ListRoomRPSChallengesRequest {
RequestMeta meta = 1;
int64 user_id = 2;
string room_id = 3;
string status = 4;
int64 initiator_user_id = 5;
int64 challenger_user_id = 6;
int64 start_time_ms = 7;
int64 end_time_ms = 8;
int32 page_size = 9;
string cursor = 10;
}
message RoomRPSChallengeResponse {
RoomRPSChallenge challenge = 1;
int64 server_time_ms = 2;
}
message ListRoomRPSChallengesResponse {
repeated RoomRPSChallenge challenges = 1;
string next_cursor = 2;
int32 page_size = 3;
int64 server_time_ms = 4;
}
message UpdateRoomRPSConfigRequest {
RequestMeta meta = 1;
RoomRPSConfig config = 2;
}
message RetryRoomRPSSettlementRequest {
RequestMeta meta = 1;
string challenge_id = 2;
}
message ExpireRoomRPSChallengeRequest {
RequestMeta meta = 1;
string challenge_id = 2;
}
message CreateDiceMatchRequest {
RequestMeta meta = 1;
int64 user_id = 2;
string game_id = 3;
string room_id = 4;
int64 region_id = 5;
int64 stake_coin = 6;
int32 min_players = 7;
int32 max_players = 8;
string rps_gesture = 9;
}
message JoinDiceMatchRequest {
RequestMeta meta = 1;
int64 user_id = 2;
string match_id = 3;
string game_id = 4;
string rps_gesture = 5;
}
message GetDiceMatchRequest {
RequestMeta meta = 1;
int64 user_id = 2;
string match_id = 3;
string game_id = 4;
}
message RollDiceMatchRequest {
RequestMeta meta = 1;
int64 user_id = 2;
string match_id = 3;
string game_id = 4;
string rps_gesture = 5;
}
message MatchDiceRequest {
RequestMeta meta = 1;
int64 user_id = 2;
string game_id = 3;
string room_id = 4;
int64 region_id = 5;
int64 stake_coin = 6;
string rps_gesture = 7;
}
message CancelDiceMatchRequest {
RequestMeta meta = 1;
int64 user_id = 2;
string match_id = 3;
string game_id = 4;
}
message DiceMatchResponse {
DiceMatch match = 1;
int64 server_time_ms = 2;
}
message CallbackRequest {
RequestMeta meta = 1;
string platform_code = 2;
@ -220,11 +495,120 @@ message DeleteCatalogResponse {
int64 server_time_ms = 1;
}
message ListSelfGamesRequest {
RequestMeta meta = 1;
}
message ListSelfGamesResponse {
repeated DiceConfig games = 1;
int64 server_time_ms = 2;
}
message UpdateDiceConfigRequest {
RequestMeta meta = 1;
DiceConfig config = 2;
}
message DicePoolAdjustment {
string adjustment_id = 1;
string game_id = 2;
int64 amount_coin = 3;
string direction = 4;
string reason = 5;
int64 balance_after = 6;
int64 created_at_ms = 7;
}
message AdjustDicePoolRequest {
RequestMeta meta = 1;
string game_id = 2;
int64 amount_coin = 3;
string direction = 4;
string reason = 5;
}
message AdjustDicePoolResponse {
DicePoolAdjustment adjustment = 1;
DiceConfig config = 2;
int64 server_time_ms = 3;
}
message DiceRobot {
string app_code = 1;
string game_id = 2;
int64 user_id = 3;
string status = 4;
int64 created_by_admin_id = 5;
int64 created_at_ms = 6;
int64 updated_at_ms = 7;
}
message ListDiceRobotsRequest {
RequestMeta meta = 1;
string game_id = 2;
string status = 3;
int32 page_size = 4;
string cursor = 5;
}
message ListDiceRobotsResponse {
repeated DiceRobot robots = 1;
string next_cursor = 2;
int64 server_time_ms = 3;
}
message RegisterDiceRobotsRequest {
RequestMeta meta = 1;
string game_id = 2;
repeated int64 user_ids = 3;
}
message RegisterDiceRobotsResponse {
repeated DiceRobot robots = 1;
int64 server_time_ms = 2;
}
message SetDiceRobotStatusRequest {
RequestMeta meta = 1;
string game_id = 2;
int64 user_id = 3;
string status = 4;
}
message DeleteDiceRobotRequest {
RequestMeta meta = 1;
string game_id = 2;
int64 user_id = 3;
}
message DeleteDiceRobotResponse {
bool deleted = 1;
int64 server_time_ms = 2;
}
message DiceRobotResponse {
DiceRobot robot = 1;
int64 server_time_ms = 2;
}
service GameAppService {
rpc ListGames(ListGamesRequest) returns (ListGamesResponse);
rpc ListRecentGames(ListRecentGamesRequest) returns (ListGamesResponse);
rpc ListExploreWinners(ListExploreWinnersRequest) returns (ListExploreWinnersResponse);
rpc GetBridgeScript(GetBridgeScriptRequest) returns (GetBridgeScriptResponse);
rpc LaunchGame(LaunchGameRequest) returns (LaunchGameResponse);
rpc GetDiceConfig(GetDiceConfigRequest) returns (DiceConfigResponse);
rpc MatchDice(MatchDiceRequest) returns (DiceMatchResponse);
rpc CreateDiceMatch(CreateDiceMatchRequest) returns (DiceMatchResponse);
rpc JoinDiceMatch(JoinDiceMatchRequest) returns (DiceMatchResponse);
rpc GetDiceMatch(GetDiceMatchRequest) returns (DiceMatchResponse);
rpc RollDiceMatch(RollDiceMatchRequest) returns (DiceMatchResponse);
rpc CancelDiceMatch(CancelDiceMatchRequest) returns (DiceMatchResponse);
rpc GetRoomRPSConfig(GetRoomRPSConfigRequest) returns (RoomRPSConfigResponse);
rpc ListRoomRPSChallenges(ListRoomRPSChallengesRequest) returns (ListRoomRPSChallengesResponse);
rpc CreateRoomRPSChallenge(CreateRoomRPSChallengeRequest) returns (RoomRPSChallengeResponse);
rpc AcceptRoomRPSChallenge(AcceptRoomRPSChallengeRequest) returns (RoomRPSChallengeResponse);
rpc GetRoomRPSChallenge(GetRoomRPSChallengeRequest) returns (RoomRPSChallengeResponse);
}
service GameCallbackService {
@ -243,4 +627,17 @@ service GameAdminService {
rpc UpsertCatalog(UpsertCatalogRequest) returns (CatalogResponse);
rpc SetGameStatus(SetGameStatusRequest) returns (CatalogResponse);
rpc DeleteCatalog(DeleteCatalogRequest) returns (DeleteCatalogResponse);
rpc ListSelfGames(ListSelfGamesRequest) returns (ListSelfGamesResponse);
rpc UpdateDiceConfig(UpdateDiceConfigRequest) returns (DiceConfigResponse);
rpc AdjustDicePool(AdjustDicePoolRequest) returns (AdjustDicePoolResponse);
rpc ListDiceRobots(ListDiceRobotsRequest) returns (ListDiceRobotsResponse);
rpc RegisterDiceRobots(RegisterDiceRobotsRequest) returns (RegisterDiceRobotsResponse);
rpc SetDiceRobotStatus(SetDiceRobotStatusRequest) returns (DiceRobotResponse);
rpc DeleteDiceRobot(DeleteDiceRobotRequest) returns (DeleteDiceRobotResponse);
rpc GetRoomRPSConfig(GetRoomRPSConfigRequest) returns (RoomRPSConfigResponse);
rpc UpdateRoomRPSConfig(UpdateRoomRPSConfigRequest) returns (RoomRPSConfigResponse);
rpc ListRoomRPSChallenges(ListRoomRPSChallengesRequest) returns (ListRoomRPSChallengesResponse);
rpc GetRoomRPSChallenge(GetRoomRPSChallengeRequest) returns (RoomRPSChallengeResponse);
rpc RetryRoomRPSSettlement(RetryRoomRPSSettlementRequest) returns (RoomRPSChallengeResponse);
rpc ExpireRoomRPSChallenge(ExpireRoomRPSChallengeRequest) returns (RoomRPSChallengeResponse);
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -112,6 +112,8 @@ message RoomRocketRewardItem {
int64 weight = 3;
string display_name = 4;
string icon_url = 5;
// quantity 0 1
int64 quantity = 6;
}
// RoomRocketLevel
@ -139,6 +141,13 @@ message RoomRocketRewardGrant {
string status = 8;
}
// RoomRocketContribution
message RoomRocketContribution {
int64 user_id = 1;
int64 fuel = 2;
int64 updated_at_ms = 3;
}
// RoomRocketPendingLaunch
message RoomRocketPendingLaunch {
string rocket_id = 1;
@ -169,6 +178,7 @@ message RoomRocketState {
int64 config_version = 12;
repeated RoomRocketRewardGrant last_rewards = 13;
repeated RoomRocketPendingLaunch pending_launches = 14;
repeated RoomRocketContribution current_contributions = 15;
}
// RoomRocketInfo App UI

View File

@ -454,7 +454,7 @@ func (x *SetPasswordResponse) GetPasswordSet() bool {
return false
}
// QuickCreateAccountRequest 为测试和三方联调快速创建一个可密码登录的完整账号
// QuickCreateAccountRequest 为后台快速创建完整资料账号source=game_robot 时账号不可登录
type QuickCreateAccountRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
Meta *RequestMeta `protobuf:"bytes,1,opt,name=meta,proto3" json:"meta,omitempty"`
@ -643,7 +643,7 @@ func (x *QuickCreateAccountRequest) GetTimezone() string {
return ""
}
// QuickCreateAccountResponse 返回新账号登录态display_user_id 即后续账号密码登录用账号
// QuickCreateAccountResponse 返回新账号身份普通账号含登录态source=game_robot 时 token 只含 user_id/display_user_id
type QuickCreateAccountResponse struct {
state protoimpl.MessageState `protogen:"open.v1"`
Token *AuthToken `protobuf:"bytes,1,opt,name=token,proto3" json:"token,omitempty"`
@ -1091,12 +1091,13 @@ func (x *AppHeartbeatRequest) GetSessionId() string {
return ""
}
// AppHeartbeatResponse 返回本次会话心跳写入时间
// AppHeartbeatResponse 返回本次会话心跳写入时间,并在当前会话仍有效时顺手重签 access token
type AppHeartbeatResponse struct {
state protoimpl.MessageState `protogen:"open.v1"`
Accepted bool `protobuf:"varint,1,opt,name=accepted,proto3" json:"accepted,omitempty"`
HeartbeatAtMs int64 `protobuf:"varint,2,opt,name=heartbeat_at_ms,json=heartbeatAtMs,proto3" json:"heartbeat_at_ms,omitempty"`
ServerTimeMs int64 `protobuf:"varint,3,opt,name=server_time_ms,json=serverTimeMs,proto3" json:"server_time_ms,omitempty"`
Token *AuthToken `protobuf:"bytes,4,opt,name=token,proto3" json:"token,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
@ -1152,6 +1153,13 @@ func (x *AppHeartbeatResponse) GetServerTimeMs() int64 {
return 0
}
func (x *AppHeartbeatResponse) GetToken() *AuthToken {
if x != nil {
return x.Token
}
return nil
}
var File_proto_user_v1_auth_proto protoreflect.FileDescriptor
const file_proto_user_v1_auth_proto_rawDesc = "" +
@ -1251,11 +1259,12 @@ const file_proto_user_v1_auth_proto_rawDesc = "" +
"\x04meta\x18\x01 \x01(\v2\x1a.hyapp.user.v1.RequestMetaR\x04meta\x12\x17\n" +
"\auser_id\x18\x02 \x01(\x03R\x06userId\x12\x1d\n" +
"\n" +
"session_id\x18\x03 \x01(\tR\tsessionId\"\x80\x01\n" +
"session_id\x18\x03 \x01(\tR\tsessionId\"\xb0\x01\n" +
"\x14AppHeartbeatResponse\x12\x1a\n" +
"\baccepted\x18\x01 \x01(\bR\baccepted\x12&\n" +
"\x0fheartbeat_at_ms\x18\x02 \x01(\x03R\rheartbeatAtMs\x12$\n" +
"\x0eserver_time_ms\x18\x03 \x01(\x03R\fserverTimeMs2\xdc\x05\n" +
"\x0eserver_time_ms\x18\x03 \x01(\x03R\fserverTimeMs\x12.\n" +
"\x05token\x18\x04 \x01(\v2\x18.hyapp.user.v1.AuthTokenR\x05token2\xdc\x05\n" +
"\vAuthService\x12Q\n" +
"\rLoginPassword\x12#.hyapp.user.v1.LoginPasswordRequest\x1a\x1b.hyapp.user.v1.AuthResponse\x12U\n" +
"\x0fLoginThirdParty\x12%.hyapp.user.v1.LoginThirdPartyRequest\x1a\x1b.hyapp.user.v1.AuthResponse\x12T\n" +
@ -1310,27 +1319,28 @@ var file_proto_user_v1_auth_proto_depIdxs = []int32{
15, // 8: hyapp.user.v1.LogoutRequest.meta:type_name -> hyapp.user.v1.RequestMeta
15, // 9: hyapp.user.v1.RecordLoginBlockedRequest.meta:type_name -> hyapp.user.v1.RequestMeta
15, // 10: hyapp.user.v1.AppHeartbeatRequest.meta:type_name -> hyapp.user.v1.RequestMeta
0, // 11: hyapp.user.v1.AuthService.LoginPassword:input_type -> hyapp.user.v1.LoginPasswordRequest
1, // 12: hyapp.user.v1.AuthService.LoginThirdParty:input_type -> hyapp.user.v1.LoginThirdPartyRequest
3, // 13: hyapp.user.v1.AuthService.SetPassword:input_type -> hyapp.user.v1.SetPasswordRequest
5, // 14: hyapp.user.v1.AuthService.QuickCreateAccount:input_type -> hyapp.user.v1.QuickCreateAccountRequest
7, // 15: hyapp.user.v1.AuthService.RefreshToken:input_type -> hyapp.user.v1.RefreshTokenRequest
9, // 16: hyapp.user.v1.AuthService.Logout:input_type -> hyapp.user.v1.LogoutRequest
11, // 17: hyapp.user.v1.AuthService.RecordLoginBlocked:input_type -> hyapp.user.v1.RecordLoginBlockedRequest
13, // 18: hyapp.user.v1.AuthService.AppHeartbeat:input_type -> hyapp.user.v1.AppHeartbeatRequest
2, // 19: hyapp.user.v1.AuthService.LoginPassword:output_type -> hyapp.user.v1.AuthResponse
2, // 20: hyapp.user.v1.AuthService.LoginThirdParty:output_type -> hyapp.user.v1.AuthResponse
4, // 21: hyapp.user.v1.AuthService.SetPassword:output_type -> hyapp.user.v1.SetPasswordResponse
6, // 22: hyapp.user.v1.AuthService.QuickCreateAccount:output_type -> hyapp.user.v1.QuickCreateAccountResponse
8, // 23: hyapp.user.v1.AuthService.RefreshToken:output_type -> hyapp.user.v1.RefreshTokenResponse
10, // 24: hyapp.user.v1.AuthService.Logout:output_type -> hyapp.user.v1.LogoutResponse
12, // 25: hyapp.user.v1.AuthService.RecordLoginBlocked:output_type -> hyapp.user.v1.RecordLoginBlockedResponse
14, // 26: hyapp.user.v1.AuthService.AppHeartbeat:output_type -> hyapp.user.v1.AppHeartbeatResponse
19, // [19:27] is the sub-list for method output_type
11, // [11:19] is the sub-list for method input_type
11, // [11:11] is the sub-list for extension type_name
11, // [11:11] is the sub-list for extension extendee
0, // [0:11] is the sub-list for field type_name
16, // 11: hyapp.user.v1.AppHeartbeatResponse.token:type_name -> hyapp.user.v1.AuthToken
0, // 12: hyapp.user.v1.AuthService.LoginPassword:input_type -> hyapp.user.v1.LoginPasswordRequest
1, // 13: hyapp.user.v1.AuthService.LoginThirdParty:input_type -> hyapp.user.v1.LoginThirdPartyRequest
3, // 14: hyapp.user.v1.AuthService.SetPassword:input_type -> hyapp.user.v1.SetPasswordRequest
5, // 15: hyapp.user.v1.AuthService.QuickCreateAccount:input_type -> hyapp.user.v1.QuickCreateAccountRequest
7, // 16: hyapp.user.v1.AuthService.RefreshToken:input_type -> hyapp.user.v1.RefreshTokenRequest
9, // 17: hyapp.user.v1.AuthService.Logout:input_type -> hyapp.user.v1.LogoutRequest
11, // 18: hyapp.user.v1.AuthService.RecordLoginBlocked:input_type -> hyapp.user.v1.RecordLoginBlockedRequest
13, // 19: hyapp.user.v1.AuthService.AppHeartbeat:input_type -> hyapp.user.v1.AppHeartbeatRequest
2, // 20: hyapp.user.v1.AuthService.LoginPassword:output_type -> hyapp.user.v1.AuthResponse
2, // 21: hyapp.user.v1.AuthService.LoginThirdParty:output_type -> hyapp.user.v1.AuthResponse
4, // 22: hyapp.user.v1.AuthService.SetPassword:output_type -> hyapp.user.v1.SetPasswordResponse
6, // 23: hyapp.user.v1.AuthService.QuickCreateAccount:output_type -> hyapp.user.v1.QuickCreateAccountResponse
8, // 24: hyapp.user.v1.AuthService.RefreshToken:output_type -> hyapp.user.v1.RefreshTokenResponse
10, // 25: hyapp.user.v1.AuthService.Logout:output_type -> hyapp.user.v1.LogoutResponse
12, // 26: hyapp.user.v1.AuthService.RecordLoginBlocked:output_type -> hyapp.user.v1.RecordLoginBlockedResponse
14, // 27: hyapp.user.v1.AuthService.AppHeartbeat:output_type -> hyapp.user.v1.AppHeartbeatResponse
20, // [20:28] is the sub-list for method output_type
12, // [12:20] is the sub-list for method input_type
12, // [12:12] is the sub-list for extension type_name
12, // [12:12] is the sub-list for extension extendee
0, // [0:12] is the sub-list for field type_name
}
func init() { file_proto_user_v1_auth_proto_init() }

View File

@ -57,7 +57,7 @@ message SetPasswordResponse {
bool password_set = 1;
}
// QuickCreateAccountRequest
// QuickCreateAccountRequest source=game_robot
message QuickCreateAccountRequest {
RequestMeta meta = 1;
string password = 2;
@ -80,7 +80,7 @@ message QuickCreateAccountRequest {
string timezone = 19;
}
// QuickCreateAccountResponse display_user_id
// QuickCreateAccountResponse source=game_robot token user_id/display_user_id
message QuickCreateAccountResponse {
AuthToken token = 1;
bool password_set = 2;
@ -131,11 +131,12 @@ message AppHeartbeatRequest {
string session_id = 3;
}
// AppHeartbeatResponse
// AppHeartbeatResponse access token
message AppHeartbeatResponse {
bool accepted = 1;
int64 heartbeat_at_ms = 2;
int64 server_time_ms = 3;
AuthToken token = 4;
}
// AuthService token

File diff suppressed because it is too large Load Diff

View File

@ -83,6 +83,8 @@ message User {
bool country_enabled = 26;
string app_code = 27;
InviteOverview invite = 28;
string pretty_id = 29;
string pretty_display_user_id = 30;
}
// InviteOverview read model
@ -101,6 +103,29 @@ message InviteBinding {
int64 inviter_user_id = 3;
}
// InviteAttribution
message InviteAttribution {
bool found = 1;
string app_code = 2;
int64 invited_user_id = 3;
int64 inviter_user_id = 4;
string invite_code = 5;
int64 bound_at_ms = 6;
string validity_status = 7;
int64 accumulated_recharge_coin_amount = 8;
int64 required_recharge_coin_amount = 9;
int64 valid_at_ms = 10;
}
message GetInviteAttributionRequest {
RequestMeta meta = 1;
int64 invited_user_id = 2;
}
message GetInviteAttributionResponse {
InviteAttribution attribution = 1;
}
// UserMicLifetimeStats /
message UserMicLifetimeStats {
int64 user_id = 1;
@ -418,6 +443,39 @@ message CPRelationship {
int64 ended_at_ms = 17;
}
// CPAvatarFrameSnapshot
message CPAvatarFrameSnapshot {
int64 resource_id = 1;
string resource_code = 2;
string name = 3;
string asset_url = 4;
string preview_url = 5;
string animation_url = 6;
string metadata_json = 7;
}
// CPIntimacyLeaderboardUser CP
message CPIntimacyLeaderboardUser {
int64 user_id = 1;
string display_user_id = 2;
string username = 3;
string avatar = 4;
CPAvatarFrameSnapshot avatar_frame = 5;
}
// CPIntimacyLeaderboardItem active
message CPIntimacyLeaderboardItem {
int64 rank = 1;
string relationship_id = 2;
string relation_type = 3;
int64 intimacy_value = 4;
int32 level = 5;
CPIntimacyLeaderboardUser user_a = 6;
CPIntimacyLeaderboardUser user_b = 7;
int64 formed_at_ms = 8;
int64 updated_at_ms = 9;
}
message ListCPApplicationsRequest {
RequestMeta meta = 1;
int64 user_id = 2;
@ -470,6 +528,21 @@ message ListCPRelationshipsResponse {
reserved "max_count_by_relation_type";
}
message ListCPIntimacyLeaderboardRequest {
RequestMeta meta = 1;
string relation_type = 2;
int32 page = 3;
int32 page_size = 4;
}
message ListCPIntimacyLeaderboardResponse {
repeated CPIntimacyLeaderboardItem items = 1;
int64 total = 2;
int32 page = 3;
int32 page_size = 4;
int64 server_time_ms = 5;
}
message PrepareBreakCPRelationshipRequest {
RequestMeta meta = 1;
int64 user_id = 2;
@ -903,6 +976,8 @@ message UserIdentity {
string display_user_id_kind = 5;
int64 display_user_id_expires_at_ms = 6;
string app_code = 7;
string pretty_id = 8;
string pretty_display_user_id = 9;
}
// GetUserIdentityRequest user_id
@ -968,9 +1043,188 @@ message ExpirePrettyDisplayUserIDResponse {
UserIdentity identity = 1;
}
// PrettyDisplayIDPool
message PrettyDisplayIDPool {
string app_code = 1;
string pool_id = 2;
string name = 3;
string level_track = 4;
int32 min_level = 5;
int32 max_level = 6;
string rule_type = 7;
string rule_config_json = 8;
string status = 9;
int32 sort_order = 10;
int64 created_by_admin_id = 11;
int64 updated_by_admin_id = 12;
int64 created_at_ms = 13;
int64 updated_at_ms = 14;
}
// PrettyDisplayID
message PrettyDisplayID {
string app_code = 1;
string pretty_id = 2;
string pool_id = 3;
string source = 4;
string display_user_id = 5;
string status = 6;
int64 assigned_user_id = 7;
string assigned_lease_id = 8;
int64 assigned_at_ms = 9;
int64 released_at_ms = 10;
string release_reason = 11;
string generated_batch_id = 12;
int64 created_by_admin_id = 13;
int64 created_at_ms = 14;
int64 updated_at_ms = 15;
PrettyDisplayIDPool pool = 16;
}
message PrettyDisplayIDGenerationBatch {
string app_code = 1;
string batch_id = 2;
string pool_id = 3;
string rule_type = 4;
string rule_config_json = 5;
int32 requested_count = 6;
int32 generated_count = 7;
int32 skipped_conflict_count = 8;
string status = 9;
int64 operator_admin_id = 10;
string request_id = 11;
int64 created_at_ms = 12;
int64 updated_at_ms = 13;
}
message ListAvailablePrettyDisplayIDsRequest {
RequestMeta meta = 1;
int64 user_id = 2;
int32 page = 3;
int32 page_size = 4;
}
message ListAvailablePrettyDisplayIDsResponse {
repeated PrettyDisplayID items = 1;
int64 total = 2;
}
message ApplyPrettyDisplayIDFromPoolRequest {
RequestMeta meta = 1;
int64 user_id = 2;
string pretty_id = 3;
}
message ApplyPrettyDisplayIDFromPoolResponse {
UserIdentity identity = 1;
string pretty_id = 2;
string lease_id = 3;
}
message ListPrettyDisplayIDPoolsRequest {
RequestMeta meta = 1;
string status = 2;
string level_track = 3;
int32 page = 4;
int32 page_size = 5;
}
message ListPrettyDisplayIDPoolsResponse {
repeated PrettyDisplayIDPool items = 1;
int64 total = 2;
}
message CreatePrettyDisplayIDPoolRequest {
RequestMeta meta = 1;
string name = 2;
string level_track = 3;
int32 min_level = 4;
int32 max_level = 5;
string rule_type = 6;
string rule_config_json = 7;
string status = 8;
int32 sort_order = 9;
int64 operator_admin_id = 10;
}
message UpdatePrettyDisplayIDPoolRequest {
RequestMeta meta = 1;
string pool_id = 2;
string name = 3;
string level_track = 4;
int32 min_level = 5;
int32 max_level = 6;
string rule_type = 7;
string rule_config_json = 8;
string status = 9;
int32 sort_order = 10;
int64 operator_admin_id = 11;
}
message PrettyDisplayIDPoolResponse {
PrettyDisplayIDPool pool = 1;
}
message GeneratePrettyDisplayIDsRequest {
RequestMeta meta = 1;
string pool_id = 2;
string rule_type = 3;
string rule_config_json = 4;
int32 count = 5;
int64 operator_admin_id = 6;
}
message GeneratePrettyDisplayIDsResponse {
PrettyDisplayIDGenerationBatch batch = 1;
}
message ListPrettyDisplayIDsRequest {
RequestMeta meta = 1;
string pool_id = 2;
string source = 3;
string status = 4;
string keyword = 5;
int64 assigned_user_id = 6;
int32 page = 7;
int32 page_size = 8;
}
message ListPrettyDisplayIDsResponse {
repeated PrettyDisplayID items = 1;
int64 total = 2;
}
message SetPrettyDisplayIDStatusRequest {
RequestMeta meta = 1;
string pretty_id = 2;
string status = 3;
int64 operator_admin_id = 4;
string reason = 5;
}
message PrettyDisplayIDResponse {
PrettyDisplayID item = 1;
}
message AdminGrantPrettyDisplayIDRequest {
RequestMeta meta = 1;
int64 target_user_id = 2;
string display_user_id = 3;
int64 duration_ms = 4;
string reason = 5;
int64 operator_admin_id = 6;
}
message AdminGrantPrettyDisplayIDResponse {
UserIdentity identity = 1;
string pretty_id = 2;
string lease_id = 3;
}
// UserService
service UserService {
rpc GetUser(GetUserRequest) returns (GetUserResponse);
rpc GetInviteAttribution(GetInviteAttributionRequest) returns (GetInviteAttributionResponse);
rpc BusinessUserLookup(BusinessUserLookupRequest) returns (BusinessUserLookupResponse);
rpc GetMyProfileStats(GetMyProfileStatsRequest) returns (GetMyProfileStatsResponse);
rpc BatchGetUsers(BatchGetUsersRequest) returns (BatchGetUsersResponse);
@ -1007,6 +1261,7 @@ service UserCPService {
rpc AcceptCPApplication(AcceptCPApplicationRequest) returns (AcceptCPApplicationResponse);
rpc RejectCPApplication(RejectCPApplicationRequest) returns (RejectCPApplicationResponse);
rpc ListCPRelationships(ListCPRelationshipsRequest) returns (ListCPRelationshipsResponse);
rpc ListCPIntimacyLeaderboard(ListCPIntimacyLeaderboardRequest) returns (ListCPIntimacyLeaderboardResponse);
rpc PrepareBreakCPRelationship(PrepareBreakCPRelationshipRequest) returns (PrepareBreakCPRelationshipResponse);
rpc ConfirmBreakCPRelationship(ConfirmBreakCPRelationshipRequest) returns (ConfirmBreakCPRelationshipResponse);
rpc CancelBreakCPRelationship(CancelBreakCPRelationshipRequest) returns (CancelBreakCPRelationshipResponse);
@ -1023,6 +1278,7 @@ service UserCronService {
rpc ProcessRegionRebuildBatch(CronBatchRequest) returns (CronBatchResponse);
rpc CompensateMicOpenSessions(CronBatchRequest) returns (CronBatchResponse);
rpc ExpireManagerUserBlocks(CronBatchRequest) returns (CronBatchResponse);
rpc RefreshCPIntimacyLeaderboard(CronBatchRequest) returns (CronBatchResponse);
}
// UserDeviceService App token
@ -1064,5 +1320,18 @@ service UserIdentityService {
rpc ResolveDisplayUserID(ResolveDisplayUserIDRequest) returns (ResolveDisplayUserIDResponse);
rpc ChangeDisplayUserID(ChangeDisplayUserIDRequest) returns (ChangeDisplayUserIDResponse);
rpc ApplyPrettyDisplayUserID(ApplyPrettyDisplayUserIDRequest) returns (ApplyPrettyDisplayUserIDResponse);
rpc ListAvailablePrettyDisplayIDs(ListAvailablePrettyDisplayIDsRequest) returns (ListAvailablePrettyDisplayIDsResponse);
rpc ApplyPrettyDisplayIDFromPool(ApplyPrettyDisplayIDFromPoolRequest) returns (ApplyPrettyDisplayIDFromPoolResponse);
rpc ExpirePrettyDisplayUserID(ExpirePrettyDisplayUserIDRequest) returns (ExpirePrettyDisplayUserIDResponse);
}
// UserPrettyDisplayIDAdminService
service UserPrettyDisplayIDAdminService {
rpc ListPrettyDisplayIDPools(ListPrettyDisplayIDPoolsRequest) returns (ListPrettyDisplayIDPoolsResponse);
rpc CreatePrettyDisplayIDPool(CreatePrettyDisplayIDPoolRequest) returns (PrettyDisplayIDPoolResponse);
rpc UpdatePrettyDisplayIDPool(UpdatePrettyDisplayIDPoolRequest) returns (PrettyDisplayIDPoolResponse);
rpc GeneratePrettyDisplayIDs(GeneratePrettyDisplayIDsRequest) returns (GeneratePrettyDisplayIDsResponse);
rpc ListPrettyDisplayIDs(ListPrettyDisplayIDsRequest) returns (ListPrettyDisplayIDsResponse);
rpc SetPrettyDisplayIDStatus(SetPrettyDisplayIDStatusRequest) returns (PrettyDisplayIDResponse);
rpc AdminGrantPrettyDisplayID(AdminGrantPrettyDisplayIDRequest) returns (AdminGrantPrettyDisplayIDResponse);
}

View File

@ -20,6 +20,7 @@ const _ = grpc.SupportPackageIsVersion9
const (
UserService_GetUser_FullMethodName = "/hyapp.user.v1.UserService/GetUser"
UserService_GetInviteAttribution_FullMethodName = "/hyapp.user.v1.UserService/GetInviteAttribution"
UserService_BusinessUserLookup_FullMethodName = "/hyapp.user.v1.UserService/BusinessUserLookup"
UserService_GetMyProfileStats_FullMethodName = "/hyapp.user.v1.UserService/GetMyProfileStats"
UserService_BatchGetUsers_FullMethodName = "/hyapp.user.v1.UserService/BatchGetUsers"
@ -42,6 +43,7 @@ const (
// UserService 提供用户主状态、基础资料和注册资料读写接口。
type UserServiceClient interface {
GetUser(ctx context.Context, in *GetUserRequest, opts ...grpc.CallOption) (*GetUserResponse, error)
GetInviteAttribution(ctx context.Context, in *GetInviteAttributionRequest, opts ...grpc.CallOption) (*GetInviteAttributionResponse, error)
BusinessUserLookup(ctx context.Context, in *BusinessUserLookupRequest, opts ...grpc.CallOption) (*BusinessUserLookupResponse, error)
GetMyProfileStats(ctx context.Context, in *GetMyProfileStatsRequest, opts ...grpc.CallOption) (*GetMyProfileStatsResponse, error)
BatchGetUsers(ctx context.Context, in *BatchGetUsersRequest, opts ...grpc.CallOption) (*BatchGetUsersResponse, error)
@ -75,6 +77,16 @@ func (c *userServiceClient) GetUser(ctx context.Context, in *GetUserRequest, opt
return out, nil
}
func (c *userServiceClient) GetInviteAttribution(ctx context.Context, in *GetInviteAttributionRequest, opts ...grpc.CallOption) (*GetInviteAttributionResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(GetInviteAttributionResponse)
err := c.cc.Invoke(ctx, UserService_GetInviteAttribution_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *userServiceClient) BusinessUserLookup(ctx context.Context, in *BusinessUserLookupRequest, opts ...grpc.CallOption) (*BusinessUserLookupResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(BusinessUserLookupResponse)
@ -212,6 +224,7 @@ func (c *userServiceClient) CompleteOnboarding(ctx context.Context, in *Complete
// UserService 提供用户主状态、基础资料和注册资料读写接口。
type UserServiceServer interface {
GetUser(context.Context, *GetUserRequest) (*GetUserResponse, error)
GetInviteAttribution(context.Context, *GetInviteAttributionRequest) (*GetInviteAttributionResponse, error)
BusinessUserLookup(context.Context, *BusinessUserLookupRequest) (*BusinessUserLookupResponse, error)
GetMyProfileStats(context.Context, *GetMyProfileStatsRequest) (*GetMyProfileStatsResponse, error)
BatchGetUsers(context.Context, *BatchGetUsersRequest) (*BatchGetUsersResponse, error)
@ -238,6 +251,9 @@ type UnimplementedUserServiceServer struct{}
func (UnimplementedUserServiceServer) GetUser(context.Context, *GetUserRequest) (*GetUserResponse, error) {
return nil, status.Error(codes.Unimplemented, "method GetUser not implemented")
}
func (UnimplementedUserServiceServer) GetInviteAttribution(context.Context, *GetInviteAttributionRequest) (*GetInviteAttributionResponse, error) {
return nil, status.Error(codes.Unimplemented, "method GetInviteAttribution not implemented")
}
func (UnimplementedUserServiceServer) BusinessUserLookup(context.Context, *BusinessUserLookupRequest) (*BusinessUserLookupResponse, error) {
return nil, status.Error(codes.Unimplemented, "method BusinessUserLookup not implemented")
}
@ -316,6 +332,24 @@ func _UserService_GetUser_Handler(srv interface{}, ctx context.Context, dec func
return interceptor(ctx, in, info, handler)
}
func _UserService_GetInviteAttribution_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(GetInviteAttributionRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(UserServiceServer).GetInviteAttribution(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: UserService_GetInviteAttribution_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(UserServiceServer).GetInviteAttribution(ctx, req.(*GetInviteAttributionRequest))
}
return interceptor(ctx, in, info, handler)
}
func _UserService_BusinessUserLookup_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(BusinessUserLookupRequest)
if err := dec(in); err != nil {
@ -561,6 +595,10 @@ var UserService_ServiceDesc = grpc.ServiceDesc{
MethodName: "GetUser",
Handler: _UserService_GetUser_Handler,
},
{
MethodName: "GetInviteAttribution",
Handler: _UserService_GetInviteAttribution_Handler,
},
{
MethodName: "BusinessUserLookup",
Handler: _UserService_BusinessUserLookup_Handler,
@ -1109,6 +1147,7 @@ const (
UserCPService_AcceptCPApplication_FullMethodName = "/hyapp.user.v1.UserCPService/AcceptCPApplication"
UserCPService_RejectCPApplication_FullMethodName = "/hyapp.user.v1.UserCPService/RejectCPApplication"
UserCPService_ListCPRelationships_FullMethodName = "/hyapp.user.v1.UserCPService/ListCPRelationships"
UserCPService_ListCPIntimacyLeaderboard_FullMethodName = "/hyapp.user.v1.UserCPService/ListCPIntimacyLeaderboard"
UserCPService_PrepareBreakCPRelationship_FullMethodName = "/hyapp.user.v1.UserCPService/PrepareBreakCPRelationship"
UserCPService_ConfirmBreakCPRelationship_FullMethodName = "/hyapp.user.v1.UserCPService/ConfirmBreakCPRelationship"
UserCPService_CancelBreakCPRelationship_FullMethodName = "/hyapp.user.v1.UserCPService/CancelBreakCPRelationship"
@ -1124,6 +1163,7 @@ type UserCPServiceClient interface {
AcceptCPApplication(ctx context.Context, in *AcceptCPApplicationRequest, opts ...grpc.CallOption) (*AcceptCPApplicationResponse, error)
RejectCPApplication(ctx context.Context, in *RejectCPApplicationRequest, opts ...grpc.CallOption) (*RejectCPApplicationResponse, error)
ListCPRelationships(ctx context.Context, in *ListCPRelationshipsRequest, opts ...grpc.CallOption) (*ListCPRelationshipsResponse, error)
ListCPIntimacyLeaderboard(ctx context.Context, in *ListCPIntimacyLeaderboardRequest, opts ...grpc.CallOption) (*ListCPIntimacyLeaderboardResponse, error)
PrepareBreakCPRelationship(ctx context.Context, in *PrepareBreakCPRelationshipRequest, opts ...grpc.CallOption) (*PrepareBreakCPRelationshipResponse, error)
ConfirmBreakCPRelationship(ctx context.Context, in *ConfirmBreakCPRelationshipRequest, opts ...grpc.CallOption) (*ConfirmBreakCPRelationshipResponse, error)
CancelBreakCPRelationship(ctx context.Context, in *CancelBreakCPRelationshipRequest, opts ...grpc.CallOption) (*CancelBreakCPRelationshipResponse, error)
@ -1177,6 +1217,16 @@ func (c *userCPServiceClient) ListCPRelationships(ctx context.Context, in *ListC
return out, nil
}
func (c *userCPServiceClient) ListCPIntimacyLeaderboard(ctx context.Context, in *ListCPIntimacyLeaderboardRequest, opts ...grpc.CallOption) (*ListCPIntimacyLeaderboardResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(ListCPIntimacyLeaderboardResponse)
err := c.cc.Invoke(ctx, UserCPService_ListCPIntimacyLeaderboard_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *userCPServiceClient) PrepareBreakCPRelationship(ctx context.Context, in *PrepareBreakCPRelationshipRequest, opts ...grpc.CallOption) (*PrepareBreakCPRelationshipResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(PrepareBreakCPRelationshipResponse)
@ -1217,6 +1267,7 @@ type UserCPServiceServer interface {
AcceptCPApplication(context.Context, *AcceptCPApplicationRequest) (*AcceptCPApplicationResponse, error)
RejectCPApplication(context.Context, *RejectCPApplicationRequest) (*RejectCPApplicationResponse, error)
ListCPRelationships(context.Context, *ListCPRelationshipsRequest) (*ListCPRelationshipsResponse, error)
ListCPIntimacyLeaderboard(context.Context, *ListCPIntimacyLeaderboardRequest) (*ListCPIntimacyLeaderboardResponse, error)
PrepareBreakCPRelationship(context.Context, *PrepareBreakCPRelationshipRequest) (*PrepareBreakCPRelationshipResponse, error)
ConfirmBreakCPRelationship(context.Context, *ConfirmBreakCPRelationshipRequest) (*ConfirmBreakCPRelationshipResponse, error)
CancelBreakCPRelationship(context.Context, *CancelBreakCPRelationshipRequest) (*CancelBreakCPRelationshipResponse, error)
@ -1242,6 +1293,9 @@ func (UnimplementedUserCPServiceServer) RejectCPApplication(context.Context, *Re
func (UnimplementedUserCPServiceServer) ListCPRelationships(context.Context, *ListCPRelationshipsRequest) (*ListCPRelationshipsResponse, error) {
return nil, status.Error(codes.Unimplemented, "method ListCPRelationships not implemented")
}
func (UnimplementedUserCPServiceServer) ListCPIntimacyLeaderboard(context.Context, *ListCPIntimacyLeaderboardRequest) (*ListCPIntimacyLeaderboardResponse, error) {
return nil, status.Error(codes.Unimplemented, "method ListCPIntimacyLeaderboard not implemented")
}
func (UnimplementedUserCPServiceServer) PrepareBreakCPRelationship(context.Context, *PrepareBreakCPRelationshipRequest) (*PrepareBreakCPRelationshipResponse, error) {
return nil, status.Error(codes.Unimplemented, "method PrepareBreakCPRelationship not implemented")
}
@ -1344,6 +1398,24 @@ func _UserCPService_ListCPRelationships_Handler(srv interface{}, ctx context.Con
return interceptor(ctx, in, info, handler)
}
func _UserCPService_ListCPIntimacyLeaderboard_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(ListCPIntimacyLeaderboardRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(UserCPServiceServer).ListCPIntimacyLeaderboard(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: UserCPService_ListCPIntimacyLeaderboard_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(UserCPServiceServer).ListCPIntimacyLeaderboard(ctx, req.(*ListCPIntimacyLeaderboardRequest))
}
return interceptor(ctx, in, info, handler)
}
func _UserCPService_PrepareBreakCPRelationship_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(PrepareBreakCPRelationshipRequest)
if err := dec(in); err != nil {
@ -1421,6 +1493,10 @@ var UserCPService_ServiceDesc = grpc.ServiceDesc{
MethodName: "ListCPRelationships",
Handler: _UserCPService_ListCPRelationships_Handler,
},
{
MethodName: "ListCPIntimacyLeaderboard",
Handler: _UserCPService_ListCPIntimacyLeaderboard_Handler,
},
{
MethodName: "PrepareBreakCPRelationship",
Handler: _UserCPService_PrepareBreakCPRelationship_Handler,
@ -1545,10 +1621,11 @@ var UserCPInternalService_ServiceDesc = grpc.ServiceDesc{
}
const (
UserCronService_ProcessLoginIPRiskBatch_FullMethodName = "/hyapp.user.v1.UserCronService/ProcessLoginIPRiskBatch"
UserCronService_ProcessRegionRebuildBatch_FullMethodName = "/hyapp.user.v1.UserCronService/ProcessRegionRebuildBatch"
UserCronService_CompensateMicOpenSessions_FullMethodName = "/hyapp.user.v1.UserCronService/CompensateMicOpenSessions"
UserCronService_ExpireManagerUserBlocks_FullMethodName = "/hyapp.user.v1.UserCronService/ExpireManagerUserBlocks"
UserCronService_ProcessLoginIPRiskBatch_FullMethodName = "/hyapp.user.v1.UserCronService/ProcessLoginIPRiskBatch"
UserCronService_ProcessRegionRebuildBatch_FullMethodName = "/hyapp.user.v1.UserCronService/ProcessRegionRebuildBatch"
UserCronService_CompensateMicOpenSessions_FullMethodName = "/hyapp.user.v1.UserCronService/CompensateMicOpenSessions"
UserCronService_ExpireManagerUserBlocks_FullMethodName = "/hyapp.user.v1.UserCronService/ExpireManagerUserBlocks"
UserCronService_RefreshCPIntimacyLeaderboard_FullMethodName = "/hyapp.user.v1.UserCronService/RefreshCPIntimacyLeaderboard"
)
// UserCronServiceClient is the client API for UserCronService service.
@ -1561,6 +1638,7 @@ type UserCronServiceClient interface {
ProcessRegionRebuildBatch(ctx context.Context, in *CronBatchRequest, opts ...grpc.CallOption) (*CronBatchResponse, error)
CompensateMicOpenSessions(ctx context.Context, in *CronBatchRequest, opts ...grpc.CallOption) (*CronBatchResponse, error)
ExpireManagerUserBlocks(ctx context.Context, in *CronBatchRequest, opts ...grpc.CallOption) (*CronBatchResponse, error)
RefreshCPIntimacyLeaderboard(ctx context.Context, in *CronBatchRequest, opts ...grpc.CallOption) (*CronBatchResponse, error)
}
type userCronServiceClient struct {
@ -1611,6 +1689,16 @@ func (c *userCronServiceClient) ExpireManagerUserBlocks(ctx context.Context, in
return out, nil
}
func (c *userCronServiceClient) RefreshCPIntimacyLeaderboard(ctx context.Context, in *CronBatchRequest, opts ...grpc.CallOption) (*CronBatchResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(CronBatchResponse)
err := c.cc.Invoke(ctx, UserCronService_RefreshCPIntimacyLeaderboard_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
// UserCronServiceServer is the server API for UserCronService service.
// All implementations must embed UnimplementedUserCronServiceServer
// for forward compatibility.
@ -1621,6 +1709,7 @@ type UserCronServiceServer interface {
ProcessRegionRebuildBatch(context.Context, *CronBatchRequest) (*CronBatchResponse, error)
CompensateMicOpenSessions(context.Context, *CronBatchRequest) (*CronBatchResponse, error)
ExpireManagerUserBlocks(context.Context, *CronBatchRequest) (*CronBatchResponse, error)
RefreshCPIntimacyLeaderboard(context.Context, *CronBatchRequest) (*CronBatchResponse, error)
mustEmbedUnimplementedUserCronServiceServer()
}
@ -1643,6 +1732,9 @@ func (UnimplementedUserCronServiceServer) CompensateMicOpenSessions(context.Cont
func (UnimplementedUserCronServiceServer) ExpireManagerUserBlocks(context.Context, *CronBatchRequest) (*CronBatchResponse, error) {
return nil, status.Error(codes.Unimplemented, "method ExpireManagerUserBlocks not implemented")
}
func (UnimplementedUserCronServiceServer) RefreshCPIntimacyLeaderboard(context.Context, *CronBatchRequest) (*CronBatchResponse, error) {
return nil, status.Error(codes.Unimplemented, "method RefreshCPIntimacyLeaderboard not implemented")
}
func (UnimplementedUserCronServiceServer) mustEmbedUnimplementedUserCronServiceServer() {}
func (UnimplementedUserCronServiceServer) testEmbeddedByValue() {}
@ -1736,6 +1828,24 @@ func _UserCronService_ExpireManagerUserBlocks_Handler(srv interface{}, ctx conte
return interceptor(ctx, in, info, handler)
}
func _UserCronService_RefreshCPIntimacyLeaderboard_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(CronBatchRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(UserCronServiceServer).RefreshCPIntimacyLeaderboard(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: UserCronService_RefreshCPIntimacyLeaderboard_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(UserCronServiceServer).RefreshCPIntimacyLeaderboard(ctx, req.(*CronBatchRequest))
}
return interceptor(ctx, in, info, handler)
}
// UserCronService_ServiceDesc is the grpc.ServiceDesc for UserCronService service.
// It's only intended for direct use with grpc.RegisterService,
// and not to be introspected or modified (even as a copy)
@ -1759,6 +1869,10 @@ var UserCronService_ServiceDesc = grpc.ServiceDesc{
MethodName: "ExpireManagerUserBlocks",
Handler: _UserCronService_ExpireManagerUserBlocks_Handler,
},
{
MethodName: "RefreshCPIntimacyLeaderboard",
Handler: _UserCronService_RefreshCPIntimacyLeaderboard_Handler,
},
},
Streams: []grpc.StreamDesc{},
Metadata: "proto/user/v1/user.proto",
@ -2527,11 +2641,13 @@ var RegionAdminService_ServiceDesc = grpc.ServiceDesc{
}
const (
UserIdentityService_GetUserIdentity_FullMethodName = "/hyapp.user.v1.UserIdentityService/GetUserIdentity"
UserIdentityService_ResolveDisplayUserID_FullMethodName = "/hyapp.user.v1.UserIdentityService/ResolveDisplayUserID"
UserIdentityService_ChangeDisplayUserID_FullMethodName = "/hyapp.user.v1.UserIdentityService/ChangeDisplayUserID"
UserIdentityService_ApplyPrettyDisplayUserID_FullMethodName = "/hyapp.user.v1.UserIdentityService/ApplyPrettyDisplayUserID"
UserIdentityService_ExpirePrettyDisplayUserID_FullMethodName = "/hyapp.user.v1.UserIdentityService/ExpirePrettyDisplayUserID"
UserIdentityService_GetUserIdentity_FullMethodName = "/hyapp.user.v1.UserIdentityService/GetUserIdentity"
UserIdentityService_ResolveDisplayUserID_FullMethodName = "/hyapp.user.v1.UserIdentityService/ResolveDisplayUserID"
UserIdentityService_ChangeDisplayUserID_FullMethodName = "/hyapp.user.v1.UserIdentityService/ChangeDisplayUserID"
UserIdentityService_ApplyPrettyDisplayUserID_FullMethodName = "/hyapp.user.v1.UserIdentityService/ApplyPrettyDisplayUserID"
UserIdentityService_ListAvailablePrettyDisplayIDs_FullMethodName = "/hyapp.user.v1.UserIdentityService/ListAvailablePrettyDisplayIDs"
UserIdentityService_ApplyPrettyDisplayIDFromPool_FullMethodName = "/hyapp.user.v1.UserIdentityService/ApplyPrettyDisplayIDFromPool"
UserIdentityService_ExpirePrettyDisplayUserID_FullMethodName = "/hyapp.user.v1.UserIdentityService/ExpirePrettyDisplayUserID"
)
// UserIdentityServiceClient is the client API for UserIdentityService service.
@ -2544,6 +2660,8 @@ type UserIdentityServiceClient interface {
ResolveDisplayUserID(ctx context.Context, in *ResolveDisplayUserIDRequest, opts ...grpc.CallOption) (*ResolveDisplayUserIDResponse, error)
ChangeDisplayUserID(ctx context.Context, in *ChangeDisplayUserIDRequest, opts ...grpc.CallOption) (*ChangeDisplayUserIDResponse, error)
ApplyPrettyDisplayUserID(ctx context.Context, in *ApplyPrettyDisplayUserIDRequest, opts ...grpc.CallOption) (*ApplyPrettyDisplayUserIDResponse, error)
ListAvailablePrettyDisplayIDs(ctx context.Context, in *ListAvailablePrettyDisplayIDsRequest, opts ...grpc.CallOption) (*ListAvailablePrettyDisplayIDsResponse, error)
ApplyPrettyDisplayIDFromPool(ctx context.Context, in *ApplyPrettyDisplayIDFromPoolRequest, opts ...grpc.CallOption) (*ApplyPrettyDisplayIDFromPoolResponse, error)
ExpirePrettyDisplayUserID(ctx context.Context, in *ExpirePrettyDisplayUserIDRequest, opts ...grpc.CallOption) (*ExpirePrettyDisplayUserIDResponse, error)
}
@ -2595,6 +2713,26 @@ func (c *userIdentityServiceClient) ApplyPrettyDisplayUserID(ctx context.Context
return out, nil
}
func (c *userIdentityServiceClient) ListAvailablePrettyDisplayIDs(ctx context.Context, in *ListAvailablePrettyDisplayIDsRequest, opts ...grpc.CallOption) (*ListAvailablePrettyDisplayIDsResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(ListAvailablePrettyDisplayIDsResponse)
err := c.cc.Invoke(ctx, UserIdentityService_ListAvailablePrettyDisplayIDs_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *userIdentityServiceClient) ApplyPrettyDisplayIDFromPool(ctx context.Context, in *ApplyPrettyDisplayIDFromPoolRequest, opts ...grpc.CallOption) (*ApplyPrettyDisplayIDFromPoolResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(ApplyPrettyDisplayIDFromPoolResponse)
err := c.cc.Invoke(ctx, UserIdentityService_ApplyPrettyDisplayIDFromPool_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *userIdentityServiceClient) ExpirePrettyDisplayUserID(ctx context.Context, in *ExpirePrettyDisplayUserIDRequest, opts ...grpc.CallOption) (*ExpirePrettyDisplayUserIDResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(ExpirePrettyDisplayUserIDResponse)
@ -2615,6 +2753,8 @@ type UserIdentityServiceServer interface {
ResolveDisplayUserID(context.Context, *ResolveDisplayUserIDRequest) (*ResolveDisplayUserIDResponse, error)
ChangeDisplayUserID(context.Context, *ChangeDisplayUserIDRequest) (*ChangeDisplayUserIDResponse, error)
ApplyPrettyDisplayUserID(context.Context, *ApplyPrettyDisplayUserIDRequest) (*ApplyPrettyDisplayUserIDResponse, error)
ListAvailablePrettyDisplayIDs(context.Context, *ListAvailablePrettyDisplayIDsRequest) (*ListAvailablePrettyDisplayIDsResponse, error)
ApplyPrettyDisplayIDFromPool(context.Context, *ApplyPrettyDisplayIDFromPoolRequest) (*ApplyPrettyDisplayIDFromPoolResponse, error)
ExpirePrettyDisplayUserID(context.Context, *ExpirePrettyDisplayUserIDRequest) (*ExpirePrettyDisplayUserIDResponse, error)
mustEmbedUnimplementedUserIdentityServiceServer()
}
@ -2638,6 +2778,12 @@ func (UnimplementedUserIdentityServiceServer) ChangeDisplayUserID(context.Contex
func (UnimplementedUserIdentityServiceServer) ApplyPrettyDisplayUserID(context.Context, *ApplyPrettyDisplayUserIDRequest) (*ApplyPrettyDisplayUserIDResponse, error) {
return nil, status.Error(codes.Unimplemented, "method ApplyPrettyDisplayUserID not implemented")
}
func (UnimplementedUserIdentityServiceServer) ListAvailablePrettyDisplayIDs(context.Context, *ListAvailablePrettyDisplayIDsRequest) (*ListAvailablePrettyDisplayIDsResponse, error) {
return nil, status.Error(codes.Unimplemented, "method ListAvailablePrettyDisplayIDs not implemented")
}
func (UnimplementedUserIdentityServiceServer) ApplyPrettyDisplayIDFromPool(context.Context, *ApplyPrettyDisplayIDFromPoolRequest) (*ApplyPrettyDisplayIDFromPoolResponse, error) {
return nil, status.Error(codes.Unimplemented, "method ApplyPrettyDisplayIDFromPool not implemented")
}
func (UnimplementedUserIdentityServiceServer) ExpirePrettyDisplayUserID(context.Context, *ExpirePrettyDisplayUserIDRequest) (*ExpirePrettyDisplayUserIDResponse, error) {
return nil, status.Error(codes.Unimplemented, "method ExpirePrettyDisplayUserID not implemented")
}
@ -2734,6 +2880,42 @@ func _UserIdentityService_ApplyPrettyDisplayUserID_Handler(srv interface{}, ctx
return interceptor(ctx, in, info, handler)
}
func _UserIdentityService_ListAvailablePrettyDisplayIDs_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(ListAvailablePrettyDisplayIDsRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(UserIdentityServiceServer).ListAvailablePrettyDisplayIDs(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: UserIdentityService_ListAvailablePrettyDisplayIDs_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(UserIdentityServiceServer).ListAvailablePrettyDisplayIDs(ctx, req.(*ListAvailablePrettyDisplayIDsRequest))
}
return interceptor(ctx, in, info, handler)
}
func _UserIdentityService_ApplyPrettyDisplayIDFromPool_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(ApplyPrettyDisplayIDFromPoolRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(UserIdentityServiceServer).ApplyPrettyDisplayIDFromPool(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: UserIdentityService_ApplyPrettyDisplayIDFromPool_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(UserIdentityServiceServer).ApplyPrettyDisplayIDFromPool(ctx, req.(*ApplyPrettyDisplayIDFromPoolRequest))
}
return interceptor(ctx, in, info, handler)
}
func _UserIdentityService_ExpirePrettyDisplayUserID_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(ExpirePrettyDisplayUserIDRequest)
if err := dec(in); err != nil {
@ -2775,6 +2957,14 @@ var UserIdentityService_ServiceDesc = grpc.ServiceDesc{
MethodName: "ApplyPrettyDisplayUserID",
Handler: _UserIdentityService_ApplyPrettyDisplayUserID_Handler,
},
{
MethodName: "ListAvailablePrettyDisplayIDs",
Handler: _UserIdentityService_ListAvailablePrettyDisplayIDs_Handler,
},
{
MethodName: "ApplyPrettyDisplayIDFromPool",
Handler: _UserIdentityService_ApplyPrettyDisplayIDFromPool_Handler,
},
{
MethodName: "ExpirePrettyDisplayUserID",
Handler: _UserIdentityService_ExpirePrettyDisplayUserID_Handler,
@ -2783,3 +2973,338 @@ var UserIdentityService_ServiceDesc = grpc.ServiceDesc{
Streams: []grpc.StreamDesc{},
Metadata: "proto/user/v1/user.proto",
}
const (
UserPrettyDisplayIDAdminService_ListPrettyDisplayIDPools_FullMethodName = "/hyapp.user.v1.UserPrettyDisplayIDAdminService/ListPrettyDisplayIDPools"
UserPrettyDisplayIDAdminService_CreatePrettyDisplayIDPool_FullMethodName = "/hyapp.user.v1.UserPrettyDisplayIDAdminService/CreatePrettyDisplayIDPool"
UserPrettyDisplayIDAdminService_UpdatePrettyDisplayIDPool_FullMethodName = "/hyapp.user.v1.UserPrettyDisplayIDAdminService/UpdatePrettyDisplayIDPool"
UserPrettyDisplayIDAdminService_GeneratePrettyDisplayIDs_FullMethodName = "/hyapp.user.v1.UserPrettyDisplayIDAdminService/GeneratePrettyDisplayIDs"
UserPrettyDisplayIDAdminService_ListPrettyDisplayIDs_FullMethodName = "/hyapp.user.v1.UserPrettyDisplayIDAdminService/ListPrettyDisplayIDs"
UserPrettyDisplayIDAdminService_SetPrettyDisplayIDStatus_FullMethodName = "/hyapp.user.v1.UserPrettyDisplayIDAdminService/SetPrettyDisplayIDStatus"
UserPrettyDisplayIDAdminService_AdminGrantPrettyDisplayID_FullMethodName = "/hyapp.user.v1.UserPrettyDisplayIDAdminService/AdminGrantPrettyDisplayID"
)
// UserPrettyDisplayIDAdminServiceClient is the client API for UserPrettyDisplayIDAdminService 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.
//
// UserPrettyDisplayIDAdminService 承载后台靓号池和后台发放能力。
type UserPrettyDisplayIDAdminServiceClient interface {
ListPrettyDisplayIDPools(ctx context.Context, in *ListPrettyDisplayIDPoolsRequest, opts ...grpc.CallOption) (*ListPrettyDisplayIDPoolsResponse, error)
CreatePrettyDisplayIDPool(ctx context.Context, in *CreatePrettyDisplayIDPoolRequest, opts ...grpc.CallOption) (*PrettyDisplayIDPoolResponse, error)
UpdatePrettyDisplayIDPool(ctx context.Context, in *UpdatePrettyDisplayIDPoolRequest, opts ...grpc.CallOption) (*PrettyDisplayIDPoolResponse, error)
GeneratePrettyDisplayIDs(ctx context.Context, in *GeneratePrettyDisplayIDsRequest, opts ...grpc.CallOption) (*GeneratePrettyDisplayIDsResponse, error)
ListPrettyDisplayIDs(ctx context.Context, in *ListPrettyDisplayIDsRequest, opts ...grpc.CallOption) (*ListPrettyDisplayIDsResponse, error)
SetPrettyDisplayIDStatus(ctx context.Context, in *SetPrettyDisplayIDStatusRequest, opts ...grpc.CallOption) (*PrettyDisplayIDResponse, error)
AdminGrantPrettyDisplayID(ctx context.Context, in *AdminGrantPrettyDisplayIDRequest, opts ...grpc.CallOption) (*AdminGrantPrettyDisplayIDResponse, error)
}
type userPrettyDisplayIDAdminServiceClient struct {
cc grpc.ClientConnInterface
}
func NewUserPrettyDisplayIDAdminServiceClient(cc grpc.ClientConnInterface) UserPrettyDisplayIDAdminServiceClient {
return &userPrettyDisplayIDAdminServiceClient{cc}
}
func (c *userPrettyDisplayIDAdminServiceClient) ListPrettyDisplayIDPools(ctx context.Context, in *ListPrettyDisplayIDPoolsRequest, opts ...grpc.CallOption) (*ListPrettyDisplayIDPoolsResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(ListPrettyDisplayIDPoolsResponse)
err := c.cc.Invoke(ctx, UserPrettyDisplayIDAdminService_ListPrettyDisplayIDPools_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *userPrettyDisplayIDAdminServiceClient) CreatePrettyDisplayIDPool(ctx context.Context, in *CreatePrettyDisplayIDPoolRequest, opts ...grpc.CallOption) (*PrettyDisplayIDPoolResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(PrettyDisplayIDPoolResponse)
err := c.cc.Invoke(ctx, UserPrettyDisplayIDAdminService_CreatePrettyDisplayIDPool_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *userPrettyDisplayIDAdminServiceClient) UpdatePrettyDisplayIDPool(ctx context.Context, in *UpdatePrettyDisplayIDPoolRequest, opts ...grpc.CallOption) (*PrettyDisplayIDPoolResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(PrettyDisplayIDPoolResponse)
err := c.cc.Invoke(ctx, UserPrettyDisplayIDAdminService_UpdatePrettyDisplayIDPool_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *userPrettyDisplayIDAdminServiceClient) GeneratePrettyDisplayIDs(ctx context.Context, in *GeneratePrettyDisplayIDsRequest, opts ...grpc.CallOption) (*GeneratePrettyDisplayIDsResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(GeneratePrettyDisplayIDsResponse)
err := c.cc.Invoke(ctx, UserPrettyDisplayIDAdminService_GeneratePrettyDisplayIDs_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *userPrettyDisplayIDAdminServiceClient) ListPrettyDisplayIDs(ctx context.Context, in *ListPrettyDisplayIDsRequest, opts ...grpc.CallOption) (*ListPrettyDisplayIDsResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(ListPrettyDisplayIDsResponse)
err := c.cc.Invoke(ctx, UserPrettyDisplayIDAdminService_ListPrettyDisplayIDs_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *userPrettyDisplayIDAdminServiceClient) SetPrettyDisplayIDStatus(ctx context.Context, in *SetPrettyDisplayIDStatusRequest, opts ...grpc.CallOption) (*PrettyDisplayIDResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(PrettyDisplayIDResponse)
err := c.cc.Invoke(ctx, UserPrettyDisplayIDAdminService_SetPrettyDisplayIDStatus_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *userPrettyDisplayIDAdminServiceClient) AdminGrantPrettyDisplayID(ctx context.Context, in *AdminGrantPrettyDisplayIDRequest, opts ...grpc.CallOption) (*AdminGrantPrettyDisplayIDResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(AdminGrantPrettyDisplayIDResponse)
err := c.cc.Invoke(ctx, UserPrettyDisplayIDAdminService_AdminGrantPrettyDisplayID_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
// UserPrettyDisplayIDAdminServiceServer is the server API for UserPrettyDisplayIDAdminService service.
// All implementations must embed UnimplementedUserPrettyDisplayIDAdminServiceServer
// for forward compatibility.
//
// UserPrettyDisplayIDAdminService 承载后台靓号池和后台发放能力。
type UserPrettyDisplayIDAdminServiceServer interface {
ListPrettyDisplayIDPools(context.Context, *ListPrettyDisplayIDPoolsRequest) (*ListPrettyDisplayIDPoolsResponse, error)
CreatePrettyDisplayIDPool(context.Context, *CreatePrettyDisplayIDPoolRequest) (*PrettyDisplayIDPoolResponse, error)
UpdatePrettyDisplayIDPool(context.Context, *UpdatePrettyDisplayIDPoolRequest) (*PrettyDisplayIDPoolResponse, error)
GeneratePrettyDisplayIDs(context.Context, *GeneratePrettyDisplayIDsRequest) (*GeneratePrettyDisplayIDsResponse, error)
ListPrettyDisplayIDs(context.Context, *ListPrettyDisplayIDsRequest) (*ListPrettyDisplayIDsResponse, error)
SetPrettyDisplayIDStatus(context.Context, *SetPrettyDisplayIDStatusRequest) (*PrettyDisplayIDResponse, error)
AdminGrantPrettyDisplayID(context.Context, *AdminGrantPrettyDisplayIDRequest) (*AdminGrantPrettyDisplayIDResponse, error)
mustEmbedUnimplementedUserPrettyDisplayIDAdminServiceServer()
}
// UnimplementedUserPrettyDisplayIDAdminServiceServer 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 UnimplementedUserPrettyDisplayIDAdminServiceServer struct{}
func (UnimplementedUserPrettyDisplayIDAdminServiceServer) ListPrettyDisplayIDPools(context.Context, *ListPrettyDisplayIDPoolsRequest) (*ListPrettyDisplayIDPoolsResponse, error) {
return nil, status.Error(codes.Unimplemented, "method ListPrettyDisplayIDPools not implemented")
}
func (UnimplementedUserPrettyDisplayIDAdminServiceServer) CreatePrettyDisplayIDPool(context.Context, *CreatePrettyDisplayIDPoolRequest) (*PrettyDisplayIDPoolResponse, error) {
return nil, status.Error(codes.Unimplemented, "method CreatePrettyDisplayIDPool not implemented")
}
func (UnimplementedUserPrettyDisplayIDAdminServiceServer) UpdatePrettyDisplayIDPool(context.Context, *UpdatePrettyDisplayIDPoolRequest) (*PrettyDisplayIDPoolResponse, error) {
return nil, status.Error(codes.Unimplemented, "method UpdatePrettyDisplayIDPool not implemented")
}
func (UnimplementedUserPrettyDisplayIDAdminServiceServer) GeneratePrettyDisplayIDs(context.Context, *GeneratePrettyDisplayIDsRequest) (*GeneratePrettyDisplayIDsResponse, error) {
return nil, status.Error(codes.Unimplemented, "method GeneratePrettyDisplayIDs not implemented")
}
func (UnimplementedUserPrettyDisplayIDAdminServiceServer) ListPrettyDisplayIDs(context.Context, *ListPrettyDisplayIDsRequest) (*ListPrettyDisplayIDsResponse, error) {
return nil, status.Error(codes.Unimplemented, "method ListPrettyDisplayIDs not implemented")
}
func (UnimplementedUserPrettyDisplayIDAdminServiceServer) SetPrettyDisplayIDStatus(context.Context, *SetPrettyDisplayIDStatusRequest) (*PrettyDisplayIDResponse, error) {
return nil, status.Error(codes.Unimplemented, "method SetPrettyDisplayIDStatus not implemented")
}
func (UnimplementedUserPrettyDisplayIDAdminServiceServer) AdminGrantPrettyDisplayID(context.Context, *AdminGrantPrettyDisplayIDRequest) (*AdminGrantPrettyDisplayIDResponse, error) {
return nil, status.Error(codes.Unimplemented, "method AdminGrantPrettyDisplayID not implemented")
}
func (UnimplementedUserPrettyDisplayIDAdminServiceServer) mustEmbedUnimplementedUserPrettyDisplayIDAdminServiceServer() {
}
func (UnimplementedUserPrettyDisplayIDAdminServiceServer) testEmbeddedByValue() {}
// UnsafeUserPrettyDisplayIDAdminServiceServer may be embedded to opt out of forward compatibility for this service.
// Use of this interface is not recommended, as added methods to UserPrettyDisplayIDAdminServiceServer will
// result in compilation errors.
type UnsafeUserPrettyDisplayIDAdminServiceServer interface {
mustEmbedUnimplementedUserPrettyDisplayIDAdminServiceServer()
}
func RegisterUserPrettyDisplayIDAdminServiceServer(s grpc.ServiceRegistrar, srv UserPrettyDisplayIDAdminServiceServer) {
// If the following call panics, it indicates UnimplementedUserPrettyDisplayIDAdminServiceServer 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(&UserPrettyDisplayIDAdminService_ServiceDesc, srv)
}
func _UserPrettyDisplayIDAdminService_ListPrettyDisplayIDPools_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(ListPrettyDisplayIDPoolsRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(UserPrettyDisplayIDAdminServiceServer).ListPrettyDisplayIDPools(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: UserPrettyDisplayIDAdminService_ListPrettyDisplayIDPools_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(UserPrettyDisplayIDAdminServiceServer).ListPrettyDisplayIDPools(ctx, req.(*ListPrettyDisplayIDPoolsRequest))
}
return interceptor(ctx, in, info, handler)
}
func _UserPrettyDisplayIDAdminService_CreatePrettyDisplayIDPool_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(CreatePrettyDisplayIDPoolRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(UserPrettyDisplayIDAdminServiceServer).CreatePrettyDisplayIDPool(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: UserPrettyDisplayIDAdminService_CreatePrettyDisplayIDPool_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(UserPrettyDisplayIDAdminServiceServer).CreatePrettyDisplayIDPool(ctx, req.(*CreatePrettyDisplayIDPoolRequest))
}
return interceptor(ctx, in, info, handler)
}
func _UserPrettyDisplayIDAdminService_UpdatePrettyDisplayIDPool_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(UpdatePrettyDisplayIDPoolRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(UserPrettyDisplayIDAdminServiceServer).UpdatePrettyDisplayIDPool(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: UserPrettyDisplayIDAdminService_UpdatePrettyDisplayIDPool_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(UserPrettyDisplayIDAdminServiceServer).UpdatePrettyDisplayIDPool(ctx, req.(*UpdatePrettyDisplayIDPoolRequest))
}
return interceptor(ctx, in, info, handler)
}
func _UserPrettyDisplayIDAdminService_GeneratePrettyDisplayIDs_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(GeneratePrettyDisplayIDsRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(UserPrettyDisplayIDAdminServiceServer).GeneratePrettyDisplayIDs(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: UserPrettyDisplayIDAdminService_GeneratePrettyDisplayIDs_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(UserPrettyDisplayIDAdminServiceServer).GeneratePrettyDisplayIDs(ctx, req.(*GeneratePrettyDisplayIDsRequest))
}
return interceptor(ctx, in, info, handler)
}
func _UserPrettyDisplayIDAdminService_ListPrettyDisplayIDs_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(ListPrettyDisplayIDsRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(UserPrettyDisplayIDAdminServiceServer).ListPrettyDisplayIDs(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: UserPrettyDisplayIDAdminService_ListPrettyDisplayIDs_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(UserPrettyDisplayIDAdminServiceServer).ListPrettyDisplayIDs(ctx, req.(*ListPrettyDisplayIDsRequest))
}
return interceptor(ctx, in, info, handler)
}
func _UserPrettyDisplayIDAdminService_SetPrettyDisplayIDStatus_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(SetPrettyDisplayIDStatusRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(UserPrettyDisplayIDAdminServiceServer).SetPrettyDisplayIDStatus(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: UserPrettyDisplayIDAdminService_SetPrettyDisplayIDStatus_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(UserPrettyDisplayIDAdminServiceServer).SetPrettyDisplayIDStatus(ctx, req.(*SetPrettyDisplayIDStatusRequest))
}
return interceptor(ctx, in, info, handler)
}
func _UserPrettyDisplayIDAdminService_AdminGrantPrettyDisplayID_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(AdminGrantPrettyDisplayIDRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(UserPrettyDisplayIDAdminServiceServer).AdminGrantPrettyDisplayID(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: UserPrettyDisplayIDAdminService_AdminGrantPrettyDisplayID_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(UserPrettyDisplayIDAdminServiceServer).AdminGrantPrettyDisplayID(ctx, req.(*AdminGrantPrettyDisplayIDRequest))
}
return interceptor(ctx, in, info, handler)
}
// UserPrettyDisplayIDAdminService_ServiceDesc is the grpc.ServiceDesc for UserPrettyDisplayIDAdminService service.
// It's only intended for direct use with grpc.RegisterService,
// and not to be introspected or modified (even as a copy)
var UserPrettyDisplayIDAdminService_ServiceDesc = grpc.ServiceDesc{
ServiceName: "hyapp.user.v1.UserPrettyDisplayIDAdminService",
HandlerType: (*UserPrettyDisplayIDAdminServiceServer)(nil),
Methods: []grpc.MethodDesc{
{
MethodName: "ListPrettyDisplayIDPools",
Handler: _UserPrettyDisplayIDAdminService_ListPrettyDisplayIDPools_Handler,
},
{
MethodName: "CreatePrettyDisplayIDPool",
Handler: _UserPrettyDisplayIDAdminService_CreatePrettyDisplayIDPool_Handler,
},
{
MethodName: "UpdatePrettyDisplayIDPool",
Handler: _UserPrettyDisplayIDAdminService_UpdatePrettyDisplayIDPool_Handler,
},
{
MethodName: "GeneratePrettyDisplayIDs",
Handler: _UserPrettyDisplayIDAdminService_GeneratePrettyDisplayIDs_Handler,
},
{
MethodName: "ListPrettyDisplayIDs",
Handler: _UserPrettyDisplayIDAdminService_ListPrettyDisplayIDs_Handler,
},
{
MethodName: "SetPrettyDisplayIDStatus",
Handler: _UserPrettyDisplayIDAdminService_SetPrettyDisplayIDStatus_Handler,
},
{
MethodName: "AdminGrantPrettyDisplayID",
Handler: _UserPrettyDisplayIDAdminService_AdminGrantPrettyDisplayID_Handler,
},
},
Streams: []grpc.StreamDesc{},
Metadata: "proto/user/v1/user.proto",
}

File diff suppressed because it is too large Load Diff

View File

@ -50,6 +50,7 @@ message DebitGiftResponse {
string gift_name = 14;
string gift_icon_url = 15;
string gift_animation_url = 16;
repeated string gift_effect_types = 17;
}
// DebitGiftTarget
@ -736,6 +737,13 @@ message SetGiftConfigStatusRequest {
int64 operator_user_id = 5;
}
message DeleteGiftConfigRequest {
string request_id = 1;
string app_code = 2;
string gift_id = 3;
int64 operator_user_id = 4;
}
message GiftConfigResponse {
GiftConfig gift = 1;
}
@ -1037,6 +1045,8 @@ message RechargeProduct {
int64 updated_at_ms = 19;
string app_code = 20;
string status = 21;
// audience_type H5 App normal
string audience_type = 22;
}
message ListRechargeProductsRequest {
@ -1046,6 +1056,8 @@ message ListRechargeProductsRequest {
int64 region_id = 4;
// platform gateway App
string platform = 5;
// audience_type H5 App normal
string audience_type = 6;
}
message ListRechargeProductsResponse {
@ -1089,6 +1101,7 @@ message ListAdminRechargeProductsRequest {
string keyword = 6;
int32 page = 7;
int32 page_size = 8;
string audience_type = 9;
}
message ListAdminRechargeProductsResponse {
@ -1108,6 +1121,7 @@ message CreateRechargeProductRequest {
repeated int64 region_ids = 8;
bool enabled = 9;
int64 operator_user_id = 10;
string audience_type = 11;
}
message UpdateRechargeProductRequest {
@ -1122,6 +1136,7 @@ message UpdateRechargeProductRequest {
repeated int64 region_ids = 9;
bool enabled = 10;
int64 operator_user_id = 11;
string audience_type = 12;
}
message DeleteRechargeProductRequest {
@ -1139,6 +1154,163 @@ message DeleteRechargeProductResponse {
bool deleted = 1;
}
// ThirdPartyPaymentMethod H5
message ThirdPartyPaymentMethod {
int64 method_id = 1;
string app_code = 2;
string provider_code = 3;
string provider_name = 4;
string country_code = 5;
string country_name = 6;
string currency_code = 7;
string pay_way = 8;
string pay_type = 9;
string method_name = 10;
string logo_url = 11;
string status = 12;
string usd_to_currency_rate = 13;
int32 sort_order = 14;
int64 created_at_ms = 15;
int64 updated_at_ms = 16;
}
message ThirdPartyPaymentChannel {
string app_code = 1;
string provider_code = 2;
string provider_name = 3;
string status = 4;
int32 sort_order = 5;
repeated ThirdPartyPaymentMethod methods = 6;
}
message ListThirdPartyPaymentChannelsRequest {
string request_id = 1;
string app_code = 2;
string provider_code = 3;
string status = 4;
bool include_disabled_methods = 5;
}
message ListThirdPartyPaymentChannelsResponse {
repeated ThirdPartyPaymentChannel channels = 1;
}
message SetThirdPartyPaymentMethodStatusRequest {
string request_id = 1;
string app_code = 2;
int64 method_id = 3;
bool enabled = 4;
int64 operator_user_id = 5;
}
message ThirdPartyPaymentMethodResponse {
ThirdPartyPaymentMethod method = 1;
}
message UpdateThirdPartyPaymentRateRequest {
string request_id = 1;
string app_code = 2;
int64 method_id = 3;
string usd_to_currency_rate = 4;
int64 operator_user_id = 5;
}
message H5RechargeOptionsRequest {
string request_id = 1;
string app_code = 2;
int64 target_user_id = 3;
int64 target_region_id = 4;
string target_country_code = 5;
string audience_type = 6;
}
message H5RechargeOptionsResponse {
repeated RechargeProduct products = 1;
repeated ThirdPartyPaymentMethod payment_methods = 2;
bool usdt_trc20_enabled = 3;
string usdt_trc20_address = 4;
}
message ExternalRechargeOrder {
string order_id = 1;
string app_code = 2;
int64 target_user_id = 3;
int64 target_region_id = 4;
string target_country_code = 5;
string audience_type = 6;
int64 product_id = 7;
string product_name = 8;
int64 coin_amount = 9;
int64 usd_minor_amount = 10;
string provider_code = 11;
int64 payment_method_id = 12;
string country_code = 13;
string currency_code = 14;
int64 provider_amount_minor = 15;
string pay_way = 16;
string pay_type = 17;
string pay_url = 18;
string provider_order_id = 19;
string tx_hash = 20;
string receive_address = 21;
string status = 22;
string failure_reason = 23;
string transaction_id = 24;
int64 created_at_ms = 25;
int64 updated_at_ms = 26;
bool idempotent_replay = 27;
}
message CreateH5RechargeOrderRequest {
string request_id = 1;
string app_code = 2;
string command_id = 3;
int64 target_user_id = 4;
int64 target_region_id = 5;
string target_country_code = 6;
string audience_type = 7;
int64 product_id = 8;
string provider_code = 9;
int64 payment_method_id = 10;
string return_url = 11;
string notify_url = 12;
string client_ip = 13;
string language = 14;
}
message SubmitH5RechargeTxRequest {
string request_id = 1;
string app_code = 2;
string order_id = 3;
int64 target_user_id = 4;
string tx_hash = 5;
}
message GetH5RechargeOrderRequest {
string request_id = 1;
string app_code = 2;
string order_id = 3;
int64 target_user_id = 4;
}
message H5RechargeOrderResponse {
ExternalRechargeOrder order = 1;
}
message HandleMifapayNotifyRequest {
string request_id = 1;
string app_code = 2;
string mer_account = 3;
string data = 4;
string sign = 5;
}
message HandleMifapayNotifyResponse {
bool accepted = 1;
string response_text = 2;
string order_id = 3;
}
message DiamondExchangeRule {
string exchange_type = 1;
string from_asset_type = 2;
@ -1396,6 +1568,29 @@ message CreditRoomTurnoverRewardResponse {
int64 granted_at_ms = 4;
}
// CreditInviteActivityRewardRequest activity-service COIN
message CreditInviteActivityRewardRequest {
string command_id = 1;
string app_code = 2;
int64 target_user_id = 3;
int64 amount = 4;
string claim_id = 5;
string reward_type = 6;
int64 tier_id = 7;
string tier_code = 8;
string cycle_key = 9;
int64 reached_value = 10;
string reason = 11;
}
// CreditInviteActivityRewardResponse COIN
message CreditInviteActivityRewardResponse {
string transaction_id = 1;
AssetBalance balance = 2;
int64 amount = 3;
int64 granted_at_ms = 4;
}
// ApplyGameCoinChangeRequest game-service
message ApplyGameCoinChangeRequest {
string request_id = 1;
@ -1646,6 +1841,7 @@ service WalletService {
rpc CreateGiftConfig(CreateGiftConfigRequest) returns (GiftConfigResponse);
rpc UpdateGiftConfig(UpdateGiftConfigRequest) returns (GiftConfigResponse);
rpc SetGiftConfigStatus(SetGiftConfigStatusRequest) returns (GiftConfigResponse);
rpc DeleteGiftConfig(DeleteGiftConfigRequest) returns (GiftConfigResponse);
rpc UpsertGiftTypeConfig(UpsertGiftTypeConfigRequest) returns (GiftTypeConfigResponse);
rpc GrantResource(GrantResourceRequest) returns (ResourceGrantResponse);
rpc GrantResourceGroup(GrantResourceGroupRequest) returns (ResourceGrantResponse);
@ -1668,6 +1864,14 @@ service WalletService {
rpc CreateRechargeProduct(CreateRechargeProductRequest) returns (RechargeProductResponse);
rpc UpdateRechargeProduct(UpdateRechargeProductRequest) returns (RechargeProductResponse);
rpc DeleteRechargeProduct(DeleteRechargeProductRequest) returns (DeleteRechargeProductResponse);
rpc ListThirdPartyPaymentChannels(ListThirdPartyPaymentChannelsRequest) returns (ListThirdPartyPaymentChannelsResponse);
rpc SetThirdPartyPaymentMethodStatus(SetThirdPartyPaymentMethodStatusRequest) returns (ThirdPartyPaymentMethodResponse);
rpc UpdateThirdPartyPaymentRate(UpdateThirdPartyPaymentRateRequest) returns (ThirdPartyPaymentMethodResponse);
rpc ListH5RechargeOptions(H5RechargeOptionsRequest) returns (H5RechargeOptionsResponse);
rpc CreateH5RechargeOrder(CreateH5RechargeOrderRequest) returns (H5RechargeOrderResponse);
rpc SubmitH5RechargeTx(SubmitH5RechargeTxRequest) returns (H5RechargeOrderResponse);
rpc GetH5RechargeOrder(GetH5RechargeOrderRequest) returns (H5RechargeOrderResponse);
rpc HandleMifapayNotify(HandleMifapayNotifyRequest) returns (HandleMifapayNotifyResponse);
rpc GetDiamondExchangeConfig(GetDiamondExchangeConfigRequest) returns (GetDiamondExchangeConfigResponse);
rpc ListWalletTransactions(ListWalletTransactionsRequest) returns (ListWalletTransactionsResponse);
rpc ListVipPackages(ListVipPackagesRequest) returns (ListVipPackagesResponse);
@ -1680,6 +1884,7 @@ service WalletService {
rpc CreditTaskReward(CreditTaskRewardRequest) returns (CreditTaskRewardResponse);
rpc CreditLuckyGiftReward(CreditLuckyGiftRewardRequest) returns (CreditLuckyGiftRewardResponse);
rpc CreditRoomTurnoverReward(CreditRoomTurnoverRewardRequest) returns (CreditRoomTurnoverRewardResponse);
rpc CreditInviteActivityReward(CreditInviteActivityRewardRequest) returns (CreditInviteActivityRewardResponse);
rpc ApplyGameCoinChange(ApplyGameCoinChangeRequest) returns (ApplyGameCoinChangeResponse);
rpc GetRedPacketConfig(GetRedPacketConfigRequest) returns (GetRedPacketConfigResponse);
rpc UpdateRedPacketConfig(UpdateRedPacketConfigRequest) returns (UpdateRedPacketConfigResponse);

View File

@ -227,6 +227,7 @@ const (
WalletService_CreateGiftConfig_FullMethodName = "/hyapp.wallet.v1.WalletService/CreateGiftConfig"
WalletService_UpdateGiftConfig_FullMethodName = "/hyapp.wallet.v1.WalletService/UpdateGiftConfig"
WalletService_SetGiftConfigStatus_FullMethodName = "/hyapp.wallet.v1.WalletService/SetGiftConfigStatus"
WalletService_DeleteGiftConfig_FullMethodName = "/hyapp.wallet.v1.WalletService/DeleteGiftConfig"
WalletService_UpsertGiftTypeConfig_FullMethodName = "/hyapp.wallet.v1.WalletService/UpsertGiftTypeConfig"
WalletService_GrantResource_FullMethodName = "/hyapp.wallet.v1.WalletService/GrantResource"
WalletService_GrantResourceGroup_FullMethodName = "/hyapp.wallet.v1.WalletService/GrantResourceGroup"
@ -249,6 +250,14 @@ const (
WalletService_CreateRechargeProduct_FullMethodName = "/hyapp.wallet.v1.WalletService/CreateRechargeProduct"
WalletService_UpdateRechargeProduct_FullMethodName = "/hyapp.wallet.v1.WalletService/UpdateRechargeProduct"
WalletService_DeleteRechargeProduct_FullMethodName = "/hyapp.wallet.v1.WalletService/DeleteRechargeProduct"
WalletService_ListThirdPartyPaymentChannels_FullMethodName = "/hyapp.wallet.v1.WalletService/ListThirdPartyPaymentChannels"
WalletService_SetThirdPartyPaymentMethodStatus_FullMethodName = "/hyapp.wallet.v1.WalletService/SetThirdPartyPaymentMethodStatus"
WalletService_UpdateThirdPartyPaymentRate_FullMethodName = "/hyapp.wallet.v1.WalletService/UpdateThirdPartyPaymentRate"
WalletService_ListH5RechargeOptions_FullMethodName = "/hyapp.wallet.v1.WalletService/ListH5RechargeOptions"
WalletService_CreateH5RechargeOrder_FullMethodName = "/hyapp.wallet.v1.WalletService/CreateH5RechargeOrder"
WalletService_SubmitH5RechargeTx_FullMethodName = "/hyapp.wallet.v1.WalletService/SubmitH5RechargeTx"
WalletService_GetH5RechargeOrder_FullMethodName = "/hyapp.wallet.v1.WalletService/GetH5RechargeOrder"
WalletService_HandleMifapayNotify_FullMethodName = "/hyapp.wallet.v1.WalletService/HandleMifapayNotify"
WalletService_GetDiamondExchangeConfig_FullMethodName = "/hyapp.wallet.v1.WalletService/GetDiamondExchangeConfig"
WalletService_ListWalletTransactions_FullMethodName = "/hyapp.wallet.v1.WalletService/ListWalletTransactions"
WalletService_ListVipPackages_FullMethodName = "/hyapp.wallet.v1.WalletService/ListVipPackages"
@ -261,6 +270,7 @@ const (
WalletService_CreditTaskReward_FullMethodName = "/hyapp.wallet.v1.WalletService/CreditTaskReward"
WalletService_CreditLuckyGiftReward_FullMethodName = "/hyapp.wallet.v1.WalletService/CreditLuckyGiftReward"
WalletService_CreditRoomTurnoverReward_FullMethodName = "/hyapp.wallet.v1.WalletService/CreditRoomTurnoverReward"
WalletService_CreditInviteActivityReward_FullMethodName = "/hyapp.wallet.v1.WalletService/CreditInviteActivityReward"
WalletService_ApplyGameCoinChange_FullMethodName = "/hyapp.wallet.v1.WalletService/ApplyGameCoinChange"
WalletService_GetRedPacketConfig_FullMethodName = "/hyapp.wallet.v1.WalletService/GetRedPacketConfig"
WalletService_UpdateRedPacketConfig_FullMethodName = "/hyapp.wallet.v1.WalletService/UpdateRedPacketConfig"
@ -304,6 +314,7 @@ type WalletServiceClient interface {
CreateGiftConfig(ctx context.Context, in *CreateGiftConfigRequest, opts ...grpc.CallOption) (*GiftConfigResponse, error)
UpdateGiftConfig(ctx context.Context, in *UpdateGiftConfigRequest, opts ...grpc.CallOption) (*GiftConfigResponse, error)
SetGiftConfigStatus(ctx context.Context, in *SetGiftConfigStatusRequest, opts ...grpc.CallOption) (*GiftConfigResponse, error)
DeleteGiftConfig(ctx context.Context, in *DeleteGiftConfigRequest, opts ...grpc.CallOption) (*GiftConfigResponse, error)
UpsertGiftTypeConfig(ctx context.Context, in *UpsertGiftTypeConfigRequest, opts ...grpc.CallOption) (*GiftTypeConfigResponse, error)
GrantResource(ctx context.Context, in *GrantResourceRequest, opts ...grpc.CallOption) (*ResourceGrantResponse, error)
GrantResourceGroup(ctx context.Context, in *GrantResourceGroupRequest, opts ...grpc.CallOption) (*ResourceGrantResponse, error)
@ -326,6 +337,14 @@ type WalletServiceClient interface {
CreateRechargeProduct(ctx context.Context, in *CreateRechargeProductRequest, opts ...grpc.CallOption) (*RechargeProductResponse, error)
UpdateRechargeProduct(ctx context.Context, in *UpdateRechargeProductRequest, opts ...grpc.CallOption) (*RechargeProductResponse, error)
DeleteRechargeProduct(ctx context.Context, in *DeleteRechargeProductRequest, opts ...grpc.CallOption) (*DeleteRechargeProductResponse, error)
ListThirdPartyPaymentChannels(ctx context.Context, in *ListThirdPartyPaymentChannelsRequest, opts ...grpc.CallOption) (*ListThirdPartyPaymentChannelsResponse, error)
SetThirdPartyPaymentMethodStatus(ctx context.Context, in *SetThirdPartyPaymentMethodStatusRequest, opts ...grpc.CallOption) (*ThirdPartyPaymentMethodResponse, error)
UpdateThirdPartyPaymentRate(ctx context.Context, in *UpdateThirdPartyPaymentRateRequest, opts ...grpc.CallOption) (*ThirdPartyPaymentMethodResponse, error)
ListH5RechargeOptions(ctx context.Context, in *H5RechargeOptionsRequest, opts ...grpc.CallOption) (*H5RechargeOptionsResponse, error)
CreateH5RechargeOrder(ctx context.Context, in *CreateH5RechargeOrderRequest, opts ...grpc.CallOption) (*H5RechargeOrderResponse, error)
SubmitH5RechargeTx(ctx context.Context, in *SubmitH5RechargeTxRequest, opts ...grpc.CallOption) (*H5RechargeOrderResponse, error)
GetH5RechargeOrder(ctx context.Context, in *GetH5RechargeOrderRequest, opts ...grpc.CallOption) (*H5RechargeOrderResponse, error)
HandleMifapayNotify(ctx context.Context, in *HandleMifapayNotifyRequest, opts ...grpc.CallOption) (*HandleMifapayNotifyResponse, error)
GetDiamondExchangeConfig(ctx context.Context, in *GetDiamondExchangeConfigRequest, opts ...grpc.CallOption) (*GetDiamondExchangeConfigResponse, error)
ListWalletTransactions(ctx context.Context, in *ListWalletTransactionsRequest, opts ...grpc.CallOption) (*ListWalletTransactionsResponse, error)
ListVipPackages(ctx context.Context, in *ListVipPackagesRequest, opts ...grpc.CallOption) (*ListVipPackagesResponse, error)
@ -338,6 +357,7 @@ type WalletServiceClient interface {
CreditTaskReward(ctx context.Context, in *CreditTaskRewardRequest, opts ...grpc.CallOption) (*CreditTaskRewardResponse, error)
CreditLuckyGiftReward(ctx context.Context, in *CreditLuckyGiftRewardRequest, opts ...grpc.CallOption) (*CreditLuckyGiftRewardResponse, error)
CreditRoomTurnoverReward(ctx context.Context, in *CreditRoomTurnoverRewardRequest, opts ...grpc.CallOption) (*CreditRoomTurnoverRewardResponse, error)
CreditInviteActivityReward(ctx context.Context, in *CreditInviteActivityRewardRequest, opts ...grpc.CallOption) (*CreditInviteActivityRewardResponse, error)
ApplyGameCoinChange(ctx context.Context, in *ApplyGameCoinChangeRequest, opts ...grpc.CallOption) (*ApplyGameCoinChangeResponse, error)
GetRedPacketConfig(ctx context.Context, in *GetRedPacketConfigRequest, opts ...grpc.CallOption) (*GetRedPacketConfigResponse, error)
UpdateRedPacketConfig(ctx context.Context, in *UpdateRedPacketConfigRequest, opts ...grpc.CallOption) (*UpdateRedPacketConfigResponse, error)
@ -617,6 +637,16 @@ func (c *walletServiceClient) SetGiftConfigStatus(ctx context.Context, in *SetGi
return out, nil
}
func (c *walletServiceClient) DeleteGiftConfig(ctx context.Context, in *DeleteGiftConfigRequest, opts ...grpc.CallOption) (*GiftConfigResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(GiftConfigResponse)
err := c.cc.Invoke(ctx, WalletService_DeleteGiftConfig_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *walletServiceClient) UpsertGiftTypeConfig(ctx context.Context, in *UpsertGiftTypeConfigRequest, opts ...grpc.CallOption) (*GiftTypeConfigResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(GiftTypeConfigResponse)
@ -837,6 +867,86 @@ func (c *walletServiceClient) DeleteRechargeProduct(ctx context.Context, in *Del
return out, nil
}
func (c *walletServiceClient) ListThirdPartyPaymentChannels(ctx context.Context, in *ListThirdPartyPaymentChannelsRequest, opts ...grpc.CallOption) (*ListThirdPartyPaymentChannelsResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(ListThirdPartyPaymentChannelsResponse)
err := c.cc.Invoke(ctx, WalletService_ListThirdPartyPaymentChannels_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *walletServiceClient) SetThirdPartyPaymentMethodStatus(ctx context.Context, in *SetThirdPartyPaymentMethodStatusRequest, opts ...grpc.CallOption) (*ThirdPartyPaymentMethodResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(ThirdPartyPaymentMethodResponse)
err := c.cc.Invoke(ctx, WalletService_SetThirdPartyPaymentMethodStatus_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *walletServiceClient) UpdateThirdPartyPaymentRate(ctx context.Context, in *UpdateThirdPartyPaymentRateRequest, opts ...grpc.CallOption) (*ThirdPartyPaymentMethodResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(ThirdPartyPaymentMethodResponse)
err := c.cc.Invoke(ctx, WalletService_UpdateThirdPartyPaymentRate_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *walletServiceClient) ListH5RechargeOptions(ctx context.Context, in *H5RechargeOptionsRequest, opts ...grpc.CallOption) (*H5RechargeOptionsResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(H5RechargeOptionsResponse)
err := c.cc.Invoke(ctx, WalletService_ListH5RechargeOptions_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *walletServiceClient) CreateH5RechargeOrder(ctx context.Context, in *CreateH5RechargeOrderRequest, opts ...grpc.CallOption) (*H5RechargeOrderResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(H5RechargeOrderResponse)
err := c.cc.Invoke(ctx, WalletService_CreateH5RechargeOrder_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *walletServiceClient) SubmitH5RechargeTx(ctx context.Context, in *SubmitH5RechargeTxRequest, opts ...grpc.CallOption) (*H5RechargeOrderResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(H5RechargeOrderResponse)
err := c.cc.Invoke(ctx, WalletService_SubmitH5RechargeTx_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *walletServiceClient) GetH5RechargeOrder(ctx context.Context, in *GetH5RechargeOrderRequest, opts ...grpc.CallOption) (*H5RechargeOrderResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(H5RechargeOrderResponse)
err := c.cc.Invoke(ctx, WalletService_GetH5RechargeOrder_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *walletServiceClient) HandleMifapayNotify(ctx context.Context, in *HandleMifapayNotifyRequest, opts ...grpc.CallOption) (*HandleMifapayNotifyResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(HandleMifapayNotifyResponse)
err := c.cc.Invoke(ctx, WalletService_HandleMifapayNotify_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *walletServiceClient) GetDiamondExchangeConfig(ctx context.Context, in *GetDiamondExchangeConfigRequest, opts ...grpc.CallOption) (*GetDiamondExchangeConfigResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(GetDiamondExchangeConfigResponse)
@ -957,6 +1067,16 @@ func (c *walletServiceClient) CreditRoomTurnoverReward(ctx context.Context, in *
return out, nil
}
func (c *walletServiceClient) CreditInviteActivityReward(ctx context.Context, in *CreditInviteActivityRewardRequest, opts ...grpc.CallOption) (*CreditInviteActivityRewardResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(CreditInviteActivityRewardResponse)
err := c.cc.Invoke(ctx, WalletService_CreditInviteActivityReward_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *walletServiceClient) ApplyGameCoinChange(ctx context.Context, in *ApplyGameCoinChangeRequest, opts ...grpc.CallOption) (*ApplyGameCoinChangeResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(ApplyGameCoinChangeResponse)
@ -1079,6 +1199,7 @@ type WalletServiceServer interface {
CreateGiftConfig(context.Context, *CreateGiftConfigRequest) (*GiftConfigResponse, error)
UpdateGiftConfig(context.Context, *UpdateGiftConfigRequest) (*GiftConfigResponse, error)
SetGiftConfigStatus(context.Context, *SetGiftConfigStatusRequest) (*GiftConfigResponse, error)
DeleteGiftConfig(context.Context, *DeleteGiftConfigRequest) (*GiftConfigResponse, error)
UpsertGiftTypeConfig(context.Context, *UpsertGiftTypeConfigRequest) (*GiftTypeConfigResponse, error)
GrantResource(context.Context, *GrantResourceRequest) (*ResourceGrantResponse, error)
GrantResourceGroup(context.Context, *GrantResourceGroupRequest) (*ResourceGrantResponse, error)
@ -1101,6 +1222,14 @@ type WalletServiceServer interface {
CreateRechargeProduct(context.Context, *CreateRechargeProductRequest) (*RechargeProductResponse, error)
UpdateRechargeProduct(context.Context, *UpdateRechargeProductRequest) (*RechargeProductResponse, error)
DeleteRechargeProduct(context.Context, *DeleteRechargeProductRequest) (*DeleteRechargeProductResponse, error)
ListThirdPartyPaymentChannels(context.Context, *ListThirdPartyPaymentChannelsRequest) (*ListThirdPartyPaymentChannelsResponse, error)
SetThirdPartyPaymentMethodStatus(context.Context, *SetThirdPartyPaymentMethodStatusRequest) (*ThirdPartyPaymentMethodResponse, error)
UpdateThirdPartyPaymentRate(context.Context, *UpdateThirdPartyPaymentRateRequest) (*ThirdPartyPaymentMethodResponse, error)
ListH5RechargeOptions(context.Context, *H5RechargeOptionsRequest) (*H5RechargeOptionsResponse, error)
CreateH5RechargeOrder(context.Context, *CreateH5RechargeOrderRequest) (*H5RechargeOrderResponse, error)
SubmitH5RechargeTx(context.Context, *SubmitH5RechargeTxRequest) (*H5RechargeOrderResponse, error)
GetH5RechargeOrder(context.Context, *GetH5RechargeOrderRequest) (*H5RechargeOrderResponse, error)
HandleMifapayNotify(context.Context, *HandleMifapayNotifyRequest) (*HandleMifapayNotifyResponse, error)
GetDiamondExchangeConfig(context.Context, *GetDiamondExchangeConfigRequest) (*GetDiamondExchangeConfigResponse, error)
ListWalletTransactions(context.Context, *ListWalletTransactionsRequest) (*ListWalletTransactionsResponse, error)
ListVipPackages(context.Context, *ListVipPackagesRequest) (*ListVipPackagesResponse, error)
@ -1113,6 +1242,7 @@ type WalletServiceServer interface {
CreditTaskReward(context.Context, *CreditTaskRewardRequest) (*CreditTaskRewardResponse, error)
CreditLuckyGiftReward(context.Context, *CreditLuckyGiftRewardRequest) (*CreditLuckyGiftRewardResponse, error)
CreditRoomTurnoverReward(context.Context, *CreditRoomTurnoverRewardRequest) (*CreditRoomTurnoverRewardResponse, error)
CreditInviteActivityReward(context.Context, *CreditInviteActivityRewardRequest) (*CreditInviteActivityRewardResponse, error)
ApplyGameCoinChange(context.Context, *ApplyGameCoinChangeRequest) (*ApplyGameCoinChangeResponse, error)
GetRedPacketConfig(context.Context, *GetRedPacketConfigRequest) (*GetRedPacketConfigResponse, error)
UpdateRedPacketConfig(context.Context, *UpdateRedPacketConfigRequest) (*UpdateRedPacketConfigResponse, error)
@ -1210,6 +1340,9 @@ func (UnimplementedWalletServiceServer) UpdateGiftConfig(context.Context, *Updat
func (UnimplementedWalletServiceServer) SetGiftConfigStatus(context.Context, *SetGiftConfigStatusRequest) (*GiftConfigResponse, error) {
return nil, status.Error(codes.Unimplemented, "method SetGiftConfigStatus not implemented")
}
func (UnimplementedWalletServiceServer) DeleteGiftConfig(context.Context, *DeleteGiftConfigRequest) (*GiftConfigResponse, error) {
return nil, status.Error(codes.Unimplemented, "method DeleteGiftConfig not implemented")
}
func (UnimplementedWalletServiceServer) UpsertGiftTypeConfig(context.Context, *UpsertGiftTypeConfigRequest) (*GiftTypeConfigResponse, error) {
return nil, status.Error(codes.Unimplemented, "method UpsertGiftTypeConfig not implemented")
}
@ -1276,6 +1409,30 @@ func (UnimplementedWalletServiceServer) UpdateRechargeProduct(context.Context, *
func (UnimplementedWalletServiceServer) DeleteRechargeProduct(context.Context, *DeleteRechargeProductRequest) (*DeleteRechargeProductResponse, error) {
return nil, status.Error(codes.Unimplemented, "method DeleteRechargeProduct not implemented")
}
func (UnimplementedWalletServiceServer) ListThirdPartyPaymentChannels(context.Context, *ListThirdPartyPaymentChannelsRequest) (*ListThirdPartyPaymentChannelsResponse, error) {
return nil, status.Error(codes.Unimplemented, "method ListThirdPartyPaymentChannels not implemented")
}
func (UnimplementedWalletServiceServer) SetThirdPartyPaymentMethodStatus(context.Context, *SetThirdPartyPaymentMethodStatusRequest) (*ThirdPartyPaymentMethodResponse, error) {
return nil, status.Error(codes.Unimplemented, "method SetThirdPartyPaymentMethodStatus not implemented")
}
func (UnimplementedWalletServiceServer) UpdateThirdPartyPaymentRate(context.Context, *UpdateThirdPartyPaymentRateRequest) (*ThirdPartyPaymentMethodResponse, error) {
return nil, status.Error(codes.Unimplemented, "method UpdateThirdPartyPaymentRate not implemented")
}
func (UnimplementedWalletServiceServer) ListH5RechargeOptions(context.Context, *H5RechargeOptionsRequest) (*H5RechargeOptionsResponse, error) {
return nil, status.Error(codes.Unimplemented, "method ListH5RechargeOptions not implemented")
}
func (UnimplementedWalletServiceServer) CreateH5RechargeOrder(context.Context, *CreateH5RechargeOrderRequest) (*H5RechargeOrderResponse, error) {
return nil, status.Error(codes.Unimplemented, "method CreateH5RechargeOrder not implemented")
}
func (UnimplementedWalletServiceServer) SubmitH5RechargeTx(context.Context, *SubmitH5RechargeTxRequest) (*H5RechargeOrderResponse, error) {
return nil, status.Error(codes.Unimplemented, "method SubmitH5RechargeTx not implemented")
}
func (UnimplementedWalletServiceServer) GetH5RechargeOrder(context.Context, *GetH5RechargeOrderRequest) (*H5RechargeOrderResponse, error) {
return nil, status.Error(codes.Unimplemented, "method GetH5RechargeOrder not implemented")
}
func (UnimplementedWalletServiceServer) HandleMifapayNotify(context.Context, *HandleMifapayNotifyRequest) (*HandleMifapayNotifyResponse, error) {
return nil, status.Error(codes.Unimplemented, "method HandleMifapayNotify not implemented")
}
func (UnimplementedWalletServiceServer) GetDiamondExchangeConfig(context.Context, *GetDiamondExchangeConfigRequest) (*GetDiamondExchangeConfigResponse, error) {
return nil, status.Error(codes.Unimplemented, "method GetDiamondExchangeConfig not implemented")
}
@ -1312,6 +1469,9 @@ func (UnimplementedWalletServiceServer) CreditLuckyGiftReward(context.Context, *
func (UnimplementedWalletServiceServer) CreditRoomTurnoverReward(context.Context, *CreditRoomTurnoverRewardRequest) (*CreditRoomTurnoverRewardResponse, error) {
return nil, status.Error(codes.Unimplemented, "method CreditRoomTurnoverReward not implemented")
}
func (UnimplementedWalletServiceServer) CreditInviteActivityReward(context.Context, *CreditInviteActivityRewardRequest) (*CreditInviteActivityRewardResponse, error) {
return nil, status.Error(codes.Unimplemented, "method CreditInviteActivityReward not implemented")
}
func (UnimplementedWalletServiceServer) ApplyGameCoinChange(context.Context, *ApplyGameCoinChangeRequest) (*ApplyGameCoinChangeResponse, error) {
return nil, status.Error(codes.Unimplemented, "method ApplyGameCoinChange not implemented")
}
@ -1828,6 +1988,24 @@ func _WalletService_SetGiftConfigStatus_Handler(srv interface{}, ctx context.Con
return interceptor(ctx, in, info, handler)
}
func _WalletService_DeleteGiftConfig_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(DeleteGiftConfigRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(WalletServiceServer).DeleteGiftConfig(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: WalletService_DeleteGiftConfig_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(WalletServiceServer).DeleteGiftConfig(ctx, req.(*DeleteGiftConfigRequest))
}
return interceptor(ctx, in, info, handler)
}
func _WalletService_UpsertGiftTypeConfig_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(UpsertGiftTypeConfigRequest)
if err := dec(in); err != nil {
@ -2224,6 +2402,150 @@ func _WalletService_DeleteRechargeProduct_Handler(srv interface{}, ctx context.C
return interceptor(ctx, in, info, handler)
}
func _WalletService_ListThirdPartyPaymentChannels_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(ListThirdPartyPaymentChannelsRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(WalletServiceServer).ListThirdPartyPaymentChannels(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: WalletService_ListThirdPartyPaymentChannels_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(WalletServiceServer).ListThirdPartyPaymentChannels(ctx, req.(*ListThirdPartyPaymentChannelsRequest))
}
return interceptor(ctx, in, info, handler)
}
func _WalletService_SetThirdPartyPaymentMethodStatus_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(SetThirdPartyPaymentMethodStatusRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(WalletServiceServer).SetThirdPartyPaymentMethodStatus(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: WalletService_SetThirdPartyPaymentMethodStatus_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(WalletServiceServer).SetThirdPartyPaymentMethodStatus(ctx, req.(*SetThirdPartyPaymentMethodStatusRequest))
}
return interceptor(ctx, in, info, handler)
}
func _WalletService_UpdateThirdPartyPaymentRate_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(UpdateThirdPartyPaymentRateRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(WalletServiceServer).UpdateThirdPartyPaymentRate(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: WalletService_UpdateThirdPartyPaymentRate_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(WalletServiceServer).UpdateThirdPartyPaymentRate(ctx, req.(*UpdateThirdPartyPaymentRateRequest))
}
return interceptor(ctx, in, info, handler)
}
func _WalletService_ListH5RechargeOptions_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(H5RechargeOptionsRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(WalletServiceServer).ListH5RechargeOptions(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: WalletService_ListH5RechargeOptions_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(WalletServiceServer).ListH5RechargeOptions(ctx, req.(*H5RechargeOptionsRequest))
}
return interceptor(ctx, in, info, handler)
}
func _WalletService_CreateH5RechargeOrder_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(CreateH5RechargeOrderRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(WalletServiceServer).CreateH5RechargeOrder(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: WalletService_CreateH5RechargeOrder_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(WalletServiceServer).CreateH5RechargeOrder(ctx, req.(*CreateH5RechargeOrderRequest))
}
return interceptor(ctx, in, info, handler)
}
func _WalletService_SubmitH5RechargeTx_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(SubmitH5RechargeTxRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(WalletServiceServer).SubmitH5RechargeTx(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: WalletService_SubmitH5RechargeTx_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(WalletServiceServer).SubmitH5RechargeTx(ctx, req.(*SubmitH5RechargeTxRequest))
}
return interceptor(ctx, in, info, handler)
}
func _WalletService_GetH5RechargeOrder_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(GetH5RechargeOrderRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(WalletServiceServer).GetH5RechargeOrder(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: WalletService_GetH5RechargeOrder_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(WalletServiceServer).GetH5RechargeOrder(ctx, req.(*GetH5RechargeOrderRequest))
}
return interceptor(ctx, in, info, handler)
}
func _WalletService_HandleMifapayNotify_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(HandleMifapayNotifyRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(WalletServiceServer).HandleMifapayNotify(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: WalletService_HandleMifapayNotify_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(WalletServiceServer).HandleMifapayNotify(ctx, req.(*HandleMifapayNotifyRequest))
}
return interceptor(ctx, in, info, handler)
}
func _WalletService_GetDiamondExchangeConfig_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(GetDiamondExchangeConfigRequest)
if err := dec(in); err != nil {
@ -2440,6 +2762,24 @@ func _WalletService_CreditRoomTurnoverReward_Handler(srv interface{}, ctx contex
return interceptor(ctx, in, info, handler)
}
func _WalletService_CreditInviteActivityReward_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(CreditInviteActivityRewardRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(WalletServiceServer).CreditInviteActivityReward(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: WalletService_CreditInviteActivityReward_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(WalletServiceServer).CreditInviteActivityReward(ctx, req.(*CreditInviteActivityRewardRequest))
}
return interceptor(ctx, in, info, handler)
}
func _WalletService_ApplyGameCoinChange_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(ApplyGameCoinChangeRequest)
if err := dec(in); err != nil {
@ -2713,6 +3053,10 @@ var WalletService_ServiceDesc = grpc.ServiceDesc{
MethodName: "SetGiftConfigStatus",
Handler: _WalletService_SetGiftConfigStatus_Handler,
},
{
MethodName: "DeleteGiftConfig",
Handler: _WalletService_DeleteGiftConfig_Handler,
},
{
MethodName: "UpsertGiftTypeConfig",
Handler: _WalletService_UpsertGiftTypeConfig_Handler,
@ -2801,6 +3145,38 @@ var WalletService_ServiceDesc = grpc.ServiceDesc{
MethodName: "DeleteRechargeProduct",
Handler: _WalletService_DeleteRechargeProduct_Handler,
},
{
MethodName: "ListThirdPartyPaymentChannels",
Handler: _WalletService_ListThirdPartyPaymentChannels_Handler,
},
{
MethodName: "SetThirdPartyPaymentMethodStatus",
Handler: _WalletService_SetThirdPartyPaymentMethodStatus_Handler,
},
{
MethodName: "UpdateThirdPartyPaymentRate",
Handler: _WalletService_UpdateThirdPartyPaymentRate_Handler,
},
{
MethodName: "ListH5RechargeOptions",
Handler: _WalletService_ListH5RechargeOptions_Handler,
},
{
MethodName: "CreateH5RechargeOrder",
Handler: _WalletService_CreateH5RechargeOrder_Handler,
},
{
MethodName: "SubmitH5RechargeTx",
Handler: _WalletService_SubmitH5RechargeTx_Handler,
},
{
MethodName: "GetH5RechargeOrder",
Handler: _WalletService_GetH5RechargeOrder_Handler,
},
{
MethodName: "HandleMifapayNotify",
Handler: _WalletService_HandleMifapayNotify_Handler,
},
{
MethodName: "GetDiamondExchangeConfig",
Handler: _WalletService_GetDiamondExchangeConfig_Handler,
@ -2849,6 +3225,10 @@ var WalletService_ServiceDesc = grpc.ServiceDesc{
MethodName: "CreditRoomTurnoverReward",
Handler: _WalletService_CreditRoomTurnoverReward_Handler,
},
{
MethodName: "CreditInviteActivityReward",
Handler: _WalletService_CreditInviteActivityReward_Handler,
},
{
MethodName: "ApplyGameCoinChange",
Handler: _WalletService_ApplyGameCoinChange_Handler,

View File

@ -287,9 +287,9 @@ services:
- ${DEPLOY_PLATFORM_ROOT:-../deploy-platform}/deploy/mysql/initdb/005_utf8mb4_chinese_support.sql:/docker-entrypoint-initdb.d/005_utf8mb4_chinese_support.sql:ro
- ${DEPLOY_PLATFORM_ROOT:-../deploy-platform}/deploy/mysql/initdb/006_admin_database.sql:/docker-entrypoint-initdb.d/006_admin_database.sql:ro
- ${DEPLOY_PLATFORM_ROOT:-../deploy-platform}/deploy/mysql/initdb/007_resource_group_wallet_asset_items.sql:/docker-entrypoint-initdb.d/007_resource_group_wallet_asset_items.sql:ro
- ${DEPLOY_PLATFORM_ROOT:-../deploy-platform}/deploy/mysql/initdb/008_remove_taiwan_country.sql:/docker-entrypoint-initdb.d/008_remove_taiwan_country.sql:ro
- ./services/cron-service/deploy/mysql/initdb/001_cron_service.sql:/docker-entrypoint-initdb.d/009_cron_service.sql:ro
- ./services/game-service/deploy/mysql/initdb/001_game_service.sql:/docker-entrypoint-initdb.d/010_game_service.sql:ro
- ./services/game-service/deploy/mysql/initdata/001_self_games.sql:/docker-entrypoint-initdb.d/010_game_service_data.sql:ro
- ./services/notice-service/deploy/mysql/initdb/001_notice_service.sql:/docker-entrypoint-initdb.d/011_notice_service.sql:ro
- ./services/statistics-service/deploy/mysql/initdb/001_statistics_service.sql:/docker-entrypoint-initdb.d/012_statistics_service.sql:ro
- ${DEPLOY_PLATFORM_ROOT:-../deploy-platform}/deploy/mysql/initdb/999_local_grants.sql:/docker-entrypoint-initdb.d/999_local_grants.sql:ro

View File

@ -0,0 +1,339 @@
# H5 自研猜拳接入接口
本文只记录独立自研猜拳 `rock/rps` 的 H5 接口。所有地址都有 `/api/v1` 前缀,所有接口都需要 `Authorization: Bearer <access_token>`
成功响应统一格式:
```json
{
"code": "OK",
"message": "ok",
"request_id": "req_xxx",
"data": {}
}
```
时间字段都是 Unix epoch milliseconds。用户 ID 建议 H5 按字符串字段使用。
## 1. 获取游戏列表
地址:
```http
GET /api/v1/games
```
参数:
| 字段 | 位置 | 必填 | 说明 |
| --- | --- | --- | --- |
| `scene` | query | 否 | 场景,语音房传 `voice_room`。 |
| `room_id` | query | 否 | 房间 ID。 |
返回值:
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `games` | array | 游戏列表。 |
| `games[].game_id` | string | 猜拳是 `rock`。 |
| `games[].name` | string | 游戏名称。 |
| `games[].launch_mode` | string | H5 打开方式。 |
| `games[].enabled` | bool | 是否可进入。 |
| `games[].maintenance` | bool | 是否维护中。 |
| `server_time_ms` | number | 服务端时间。 |
相关 IM无。
## 2. 创建 H5 启动会话
地址:
```http
POST /api/v1/games/rock/launch
Content-Type: application/json
```
参数:
| 字段 | 类型 | 必填 | 说明 |
| --- | --- | --- | --- |
| `scene` | string | 否 | 场景,语音房传 `voice_room`。 |
| `room_id` | string | 否 | 房间 ID。 |
返回值:
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `session_id` | string | H5 启动会话 ID。 |
| `launch_url` | string | H5 页面地址。真实地址由后台配置,不在客户端拼。 |
| `expires_at_ms` | number | 启动会话过期时间。 |
| `orientation` | string | 页面方向。 |
| `safe_height` | number | 安全高度。 |
| `safeHeight` | number | 同 `safe_height`。 |
| `server_time_ms` | number | 服务端时间。 |
相关 IM无。
## 3. 查询猜拳配置
地址:
```http
GET /api/v1/games/rps/config
```
参数:
| 字段 | 位置 | 必填 | 说明 |
| --- | --- | --- | --- |
| `game_id` | query | 否 | 固定是 `rock`,不传也按 `rock` 处理。 |
返回值:
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `config.game_id` | string | 固定返回 `rock`。 |
| `config.status` | string | `active` 才能玩。 |
| `config.stake_options` | array | 可下注金币档位。 |
| `config.fee_bps` | number | 平台抽水,万分比。 |
| `config.pool_bps` | number | 奖池入池,万分比。 |
| `config.min_players` | number | 最少人数。 |
| `config.max_players` | number | 最多人数。 |
| `config.robot_enabled` | bool | 是否启用机器人补位。 |
| `config.robot_match_wait_ms` | number | 等待机器人补位时间。 |
| `config.pool_balance_coin` | number | 当前奖池余额。 |
| `server_time_ms` | number | 服务端时间。 |
`stake_options` 字段:
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `stake_coin` | number | 下注金币。 |
| `enabled` | bool | 是否可选。 |
| `sort_order` | number | 排序。 |
相关 IM无。
## 4. 快速匹配
地址:
```http
POST /api/v1/games/rps/match
Content-Type: application/json
```
参数:
| 字段 | 类型 | 必填 | 说明 |
| --- | --- | --- | --- |
| `stake_coin` | number | 是 | 下注金币,必须是配置里的启用档位。 |
| `gesture` | string | 是 | 本人提前选择的手势:`rock``paper``scissors`。 |
| `rps_gesture` | string | 否 | 同 `gesture`,两个字段传一个即可。 |
| `game_id` | string | 否 | 固定是 `rock`。 |
| `room_id` | string | 否 | 房间 ID。 |
返回值:
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `match` | object | 当前局详情,字段见“局详情字段”。 |
| `server_time_ms` | number | 服务端时间。 |
相关 IM无。H5 可以轮询详情,也可以等后续接入 IM 后按 IM 刷新。
## 5. 创建等待局
地址:
```http
POST /api/v1/games/rps/matches
Content-Type: application/json
```
参数:
| 字段 | 类型 | 必填 | 说明 |
| --- | --- | --- | --- |
| `stake_coin` | number | 是 | 下注金币。 |
| `gesture` | string | 是 | 本人提前选择的手势。 |
| `rps_gesture` | string | 否 | 同 `gesture`。 |
| `game_id` | string | 否 | 固定是 `rock`。 |
| `room_id` | string | 否 | 房间 ID。 |
| `min_players` | number | 否 | 最少人数,不传用配置。 |
| `max_players` | number | 否 | 最多人数,不传用配置。 |
返回值:
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `match` | object | 当前局详情,字段见“局详情字段”。 |
| `server_time_ms` | number | 服务端时间。 |
相关 IM无。
## 6. 加入等待局
地址:
```http
POST /api/v1/games/rps/matches/{match_id}/join
Content-Type: application/json
```
参数:
| 字段 | 位置 | 必填 | 说明 |
| --- | --- | --- | --- |
| `match_id` | path | 是 | 局 ID。 |
| `gesture` | body | 是 | 本人提前选择的手势。 |
| `rps_gesture` | body | 否 | 同 `gesture`。 |
返回值:
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `match` | object | 当前局详情,字段见“局详情字段”。 |
| `server_time_ms` | number | 服务端时间。 |
相关 IM无。
## 7. 查询局详情
地址:
```http
GET /api/v1/games/rps/matches/{match_id}
```
参数:
| 字段 | 位置 | 必填 | 说明 |
| --- | --- | --- | --- |
| `match_id` | path | 是 | 局 ID。 |
返回值:
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `match` | object | 当前局详情,字段见“局详情字段”。 |
| `server_time_ms` | number | 服务端时间。 |
相关 IM无。
## 8. 揭晓并结算
地址:
```http
POST /api/v1/games/rps/matches/{match_id}/roll
Content-Type: application/json
```
参数:
| 字段 | 位置 | 必填 | 说明 |
| --- | --- | --- | --- |
| `match_id` | path | 是 | 局 ID。 |
| `gesture` | body | 否 | 可以不传;如果传,必须等于创建或加入时的预选手势。 |
| `rps_gesture` | body | 否 | 同 `gesture`。 |
返回值:
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `match` | object | 已结算局详情。 |
| `server_time_ms` | number | 服务端时间。 |
说明:猜拳不会随机出拳。服务端用双方提前选择的 `gesture` 比输赢,然后扣款、派奖和更新余额。
相关 IM无。
## 9. 取消等待局
地址:
```http
POST /api/v1/games/rps/matches/{match_id}/cancel
```
参数:
| 字段 | 位置 | 必填 | 说明 |
| --- | --- | --- | --- |
| `match_id` | path | 是 | 局 ID。 |
返回值:
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `match` | object | 取消后的局详情。 |
| `server_time_ms` | number | 服务端时间。 |
相关 IM无。
## 10. 局详情字段
`match` 字段:
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `match_id` | string | 局 ID。 |
| `game_id` | string | 固定是 `rock`。 |
| `room_id` | string | 房间 ID。 |
| `region_id` | number | 用户区域 ID。 |
| `min_players` | number | 最少人数。 |
| `max_players` | number | 最多人数。 |
| `current_players` | number | 当前人数。 |
| `stake_coin` | number | 本局下注金币。 |
| `round_no` | number | 当前轮次。 |
| `status` | string | `created``ready``settled``canceled`。 |
| `phase` | string | `waiting``countdown``settled``canceled`。 |
| `phase_deadline_ms` | number | 当前阶段截止时间。 |
| `result` | string | 当前用户视角结果:`win``lose``draw`。 |
| `participants` | array | 参与者列表。 |
| `join_deadline_ms` | number | 等待加入截止时间。 |
| `ready_at_ms` | number | 匹配完成时间。 |
| `settled_at_ms` | number | 结算时间。 |
| `canceled_at_ms` | number | 取消时间。 |
| `fee_bps` | number | 平台抽水。 |
| `pool_bps` | number | 奖池入池。 |
| `match_mode` | string | `human``robot`。 |
| `forced_result` | string | 机器人奖池不足时的强制结果。 |
| `pool_delta_coin` | number | 本局奖池变化。 |
`participants` 字段:
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `user_id` | string | 用户 IDH5 优先用这个字段。 |
| `user_id_number` | number | 用户 ID 数字值。 |
| `seat_no` | number | 座位号。 |
| `status` | string | `joined``settled``canceled`。 |
| `stake_coin` | number | 下注金币。 |
| `gesture` | string | 预选手势。 |
| `rps_gesture` | string | 同 `gesture`。 |
| `result` | string | `win``lose``draw`。 |
| `payout_coin` | number | 派彩金币。 |
| `balance_after` | number | 结算后余额。 |
| `joined_at_ms` | number | 加入时间。 |
| `updated_at_ms` | number | 更新时间。 |
| `participant_type` | string | `user``robot`。 |
| `is_robot` | bool | 是否机器人。 |
| `user.display_user_id` | string | 展示 ID。 |
| `user.nickname` | string | 昵称。 |
| `user.avatar` | string | 头像。 |
| `avatar_frame` | object | 头像框资源。 |
## 11. H5 推荐流程
1. App 调 `GET /api/v1/games`,看到 `game_id=rock` 后展示入口。
2. App 调 `POST /api/v1/games/rock/launch` 打开 H5。
3. H5 调 `GET /api/v1/games/rps/config` 展示下注档位。
4. 用户先选择 `rock``paper``scissors`
5. H5 调 `POST /api/v1/games/rps/match` 快速匹配。
6. 返回 `phase=countdown`H5 用 `phase_deadline_ms` 做倒计时。
7. 倒计时结束后H5 调 `POST /api/v1/games/rps/matches/{match_id}/roll` 揭晓并结算。
8. H5 读返回的 `participants[].gesture/result/payout_coin/balance_after` 展示结果。
相关 IM当前独立猜拳没有 IM。H5 用 HTTP 返回和详情轮询刷新。

View File

@ -0,0 +1,118 @@
# 任务系统 Flutter 对接
## 获取任务页
地址:`GET /api/v1/tasks/tabs`
参数:无。需要登录态。
返回值:
```json
{
"sections": [
{
"section": "daily",
"server_time_ms": 1781097600000,
"next_refresh_at_ms": 1781136000000,
"items": [
{
"task_id": "task_xxx",
"task_type": "daily",
"category": "gift",
"audience_type": "all",
"metric_type": "gift_spend_coin",
"title": "普通礼物消耗金币",
"description": "",
"icon_key": "gift",
"icon_url": "",
"action_type": "gift_panel",
"action_param": "",
"action_payload": {},
"action_payload_json": "{}",
"dimension_filter_json": "{}",
"target_value": 10000,
"target_unit": "coin",
"progress_value": 1200,
"reward_coin_amount": 200,
"status": "in_progress",
"claimable": false,
"task_day": "2026-06-11",
"server_time_ms": 1781097600000,
"next_refresh_at_ms": 1781136000000,
"sort_order": 10,
"version": 1
}
]
},
{
"section": "exclusive",
"server_time_ms": 1781097600000,
"next_refresh_at_ms": 1781136000000,
"items": []
}
],
"server_time_ms": 1781097600000,
"next_refresh_at_ms": 1781136000000
}
```
说明:
- `section=daily` 是每日任务,按服务端 UTC 00:00 刷新。
- `section=exclusive` 是专属/新手任务,`task_day` 固定为 `lifetime`
- `claimable=true` 显示领取按钮。
- `status=claimed` 显示已领取。
- 其他状态显示去完成按钮,按 `action_type` 跳转。
## 领取任务奖励
地址:`POST /api/v1/tasks/claim`
参数:
```json
{
"task_id": "task_xxx",
"task_type": "daily",
"task_day": "2026-06-11",
"command_id": "app-generated-uuid"
}
```
返回值:
```json
{
"claim_id": "tclaim_xxx",
"task_id": "task_xxx",
"task_type": "daily",
"task_day": "2026-06-11",
"reward_coin_amount": 200,
"status": "granted",
"wallet_transaction_id": "wtx_xxx",
"granted_at_ms": 1781097600000,
"claimed": true
}
```
说明:
- `command_id` 由 App 每次点击领取时生成;同一次重试继续使用同一个 `command_id`
- 每日任务只能领取当天 `task_day`
- 专属任务领取时传列表里的 `task_day`,通常是 `lifetime`
## 跳转类型
| action_type | action_param |
| --- | --- |
| `none` | 不跳转 |
| `room_random` | 随机进入一个有人的房间;没有有人的房间时进入随机房间 |
| `room_random_game` | `game_id`,进入房间后打开指定游戏 |
| `wallet` | 打开钱包 |
| `gift_panel` | 进入房间后打开礼物面板 |
| `gift_panel_specific` | `gift_id`,进入房间后打开礼物面板并定位指定礼物 |
## 相关 IM
无。任务进度来自服务端事实事件,领取结果以接口返回和 `wallet_transaction_id` 为准。

View File

@ -0,0 +1,212 @@
# 公共跳转参数
## 地址
无。
这不是一个接口是后台运营配置、H5 bridge、IM custom data 共用的一套 App 跳转参数。
## 用在哪里
- Admin Banner`type``APP``param` 填下面的 JSON。
- Admin 开屏配置:`type``APP``param` 填下面的 JSON。
- Admin 弹窗配置:`跳转类型``APP`,使用公共跳转组件生成下面的 JSON。
- H5 bridge通过 `taskAction` 或其它业务 action 传下面的 JSON。
- 全服飘窗 IM`jump_type``jump_param``jump_payload`
## 参数
### 钱包
打开钱包页。
```json
{"type":"wallet"}
```
### 指定房间
进入指定房间。
```json
{"type":"room","room_id":"ROOM_ID"}
```
### 随机房间
进入一个随机公开房间。
```json
{"type":"room_random"}
```
### 指定房内拉起窗口
进入指定房间后打开房内窗口。
```json
{"type":"room_window","room_id":"ROOM_ID","window":"gift_panel"}
```
### 随机房内拉起窗口
进入随机公开房间后打开房内窗口。
```json
{"type":"room_random_window","window":"gift_panel"}
```
### 指定房内拉起游戏
进入指定房间后打开指定游戏。
```json
{"type":"room_game","room_id":"ROOM_ID","game_id":"GAME_ID"}
```
### 随机房内拉起游戏
进入随机公开房间后打开指定游戏。
```json
{"type":"room_random_game","game_id":"GAME_ID"}
```
### Explore 游戏 H5
App 先切到 Explore tab再打开对应的 H5。
```json
{"type":"explore_game","game_code":"game_dice"}
```
`game_code` 可填:
- `game_dice`:打开 Explore 的 Dice Battle H5对应 Flutter H5 key `game_dice`
- `game_rock`:打开 Explore 的 Rock Paper Scissors H5对应 Flutter H5 key `game_rock`
## window 可填值
### 游戏窗口
打开房内游戏窗口。带 `game_id` 时直接打开这个游戏;不带 `game_id` 时打开游戏菜单。
```json
{"type":"room_window","room_id":"ROOM_ID","window":"game_window","game_id":"GAME_ID"}
```
### 礼物面板
打开礼物面板。带 `gift_id` 时预选这个礼物。
```json
{"type":"room_window","room_id":"ROOM_ID","window":"gift_panel","gift_id":"GIFT_ID"}
```
### 领红包弹窗
打开领红包弹窗。带 `red_packet_no` 时打开指定红包。
```json
{"type":"room_window","room_id":"ROOM_ID","window":"red_packet_claim","red_packet_no":"RED_PACKET_NO"}
```
### 转盘窗口
打开房内转盘窗口。
```json
{"type":"room_window","room_id":"ROOM_ID","window":"wheel"}
```
## Admin 配置方式
后台公共跳转组件里:
- `跳转类型``APP`
- `APP 目标` 选需要的目标。
- 目标是 `Explore 游戏 H5` 时,继续选择 `Game Code`
- 保存后后台会生成 `param` JSON。
Explore 游戏 H5 的后台示例:
```json
{"type":"explore_game","game_code":"game_rock"}
```
## 返回值
无。
这是 App 内部跳转参数,没有服务端返回值。
## 相关 IM
全服飘窗使用 IM custom message。App 会解析 `jump_type``jump_param``jump_payload`
### 指定 jump_payload
```json
{
"broadcast_type": "game_win",
"room_id": "ROOM_ID",
"game_id": "GAME_ID",
"coins": 1000,
"jump_type": "room_window",
"jump_payload": {
"room_id": "ROOM_ID",
"window": "gift_panel",
"gift_id": "GIFT_ID"
}
}
```
### 跳钱包
```json
{
"broadcast_type": "game_win",
"coins": 1000,
"jump_type": "wallet",
"jump_payload": {}
}
```
### 随机房拉起游戏
```json
{
"broadcast_type": "game_win",
"coins": 1000,
"jump_type": "room_random_game",
"jump_payload": {
"game_id": "GAME_ID"
}
}
```
### 跳 Explore 游戏 H5
```json
{
"broadcast_type": "game_win",
"coins": 1000,
"jump_type": "explore_game",
"jump_payload": {
"game_code": "game_dice"
}
}
```
也可以把 `game_code` 放在 `jump_param`
```json
{
"broadcast_type": "game_win",
"coins": 1000,
"jump_type": "explore_game",
"jump_param": "game_rock"
}
```
没有 `jump_type`App 使用 IM 里的 `room_id``game_id` 作为默认跳转。

View File

@ -0,0 +1,69 @@
# 开屏配置 Flutter 对接
## 地址
`GET /api/v1/app/splash-screens`
公开接口,不需要登录。返回后台 `APP配置 -> 开屏配置` 中当前有效的开屏列表。
## 参数
| 参数 | 位置 | 必填 | 说明 |
| --- | --- | --- | --- |
| `X-App-Code` | Header | 否 | App 编码,默认 `lalu`。 |
| `X-App-Package` | Header | 否 | 包名或 Bundle ID用于服务端解析 App。 |
| `platform` | Query | 否 | `android``ios`。不传时读取 `X-App-Platform` / `X-Platform`。 |
| `region_id` | Query | 否 | 当前用户区域 ID。 |
| `country` | Query | 否 | 当前用户国家码。不传时读取 `X-Country-Code` / `X-App-Country`。 |
示例:
```http
GET /api/v1/app/splash-screens?platform=android&region_id=1&country=CN
X-App-Code: lalu
X-App-Platform: android
```
## 返回值
```json
{
"code": "OK",
"message": "ok",
"request_id": "req_abc",
"data": {
"items": [
{
"id": 12,
"cover_url": "https://cdn.example.com/splash.png",
"type": "h5",
"param": "https://h5.example.com/splash",
"platform": "android",
"sort_order": 1,
"region_id": 1,
"country_code": "CN",
"description": "活动开屏",
"starts_at_ms": 1700000000000,
"ends_at_ms": 1800000000000,
"updated_at_ms": 1700000003000
}
],
"total": 1
}
}
```
| 字段 | 说明 |
| --- | --- |
| `items` | 当前可展示的开屏配置列表,已按 `sort_order ASC, id DESC` 排序。 |
| `cover_url` | 开屏图片地址。 |
| `type` | `h5``app`。 |
| `param` | `type=h5` 时是 H5 链接;`type=app` 时由客户端解释。 |
| `platform` | `android``ios`。 |
| `region_id` | 配置限制的区域 ID为空或 0 表示全部区域。 |
| `country_code` | 配置限制的国家码;为空表示全部国家。 |
| `starts_at_ms` / `ends_at_ms` | 投放时间。0 表示不限。 |
## 相关 IM
无。

View File

@ -0,0 +1,72 @@
# 弹窗配置 Flutter 对接
## 地址
`GET /api/v1/app/popups`
公开接口,不需要登录。返回后台 `APP配置 -> 弹窗配置` 中当前有效的弹窗列表。
## 参数
| 参数 | 位置 | 必填 | 说明 |
| --- | --- | --- | --- |
| `X-App-Code` | Header | 否 | App 编码,默认 `lalu`。 |
| `X-App-Package` | Header | 否 | 包名或 Bundle ID用于服务端解析 App。 |
示例:
```http
GET /api/v1/app/popups
X-App-Code: lalu
```
## 返回值
```json
{
"code": "OK",
"message": "ok",
"request_id": "req_abc",
"data": {
"items": [
{
"id": 21,
"code": "new-user-popup",
"name": "新人活动",
"image_url": "https://cdn.example.com/popup.png",
"jump_type": "h5",
"jump_url": "https://h5.example.com/new-user",
"display_period_days": 1,
"sort_order": 1,
"starts_at_ms": 1700000000000,
"ends_at_ms": 1800000000000,
"updated_at_ms": 1700000004000
}
],
"total": 1
}
}
```
| 字段 | 说明 |
| --- | --- |
| `items` | 当前可展示的弹窗配置列表,已按 `sort_order ASC, id DESC` 排序。 |
| `code` | 弹窗编码,同一个 App 下唯一,用于 Flutter 本地频控和埋点识别。 |
| `name` | 弹窗名字。 |
| `image_url` | 弹窗图片地址,可能为空。 |
| `jump_type` | `h5``app`。 |
| `jump_url` | `jump_type=h5` 时是 H5 链接;`jump_type=app` 时由 Flutter 解释。 |
| `display_period_days` | 展示周期。`0` 表示每次打开 App 都展示;`1` 表示 1 天展示一次;`2``3``4` 同理。 |
| `starts_at_ms` / `ends_at_ms` | 弹窗生效时间。`0` 表示不限。 |
Flutter 处理规则:
1. 调接口后按 `items` 顺序取第一个满足本地频控的弹窗。
2. 用 `popup_last_show:{code}:{updated_at_ms}` 记录本地最后展示时间。
3. `display_period_days=0` 不读本地间隔,每次打开都可以展示。
4. `display_period_days>0` 时,距离上次展示满对应天数后再展示。
5. 用户点击弹窗时,`jump_type=h5` 打开 H5`jump_type=app` 走 App 内路由。
## 相关 IM
无。

View File

@ -0,0 +1,322 @@
# 房内猜拳 Flutter 对接
本文只记录房内猜拳 App 端 HTTP 接口和腾讯 IM 自定义消息字段。独立猜拳本阶段不接入。
## 1. 基本约定
- 所有 HTTP 地址都有 `/api/v1` 前缀。
- 所有接口都需要 `Authorization: Bearer <access_token>`
- 成功响应统一是 gateway envelope
```json
{
"code": "OK",
"message": "ok",
"request_id": "req_xxx",
"data": {}
}
```
- 所有时间字段都是 Unix epoch milliseconds。
- Flutter 模型里用户 ID、挑战 ID、礼物 ID 建议统一按 `String` 保存。
- HTTP 返回的发起人和应战人资料在 `challenge.initiator.user``challenge.challenger.user`
- IM 里的发起人和应战人也必须带 `nickname``avatar`;客户端不要为了基础展示再查用户资料。
## 2. 通用挑战单字段
`challenge` 字段在列表、详情、发起、应战接口中结构一致。
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `challenge_id` | string | 挑战单 ID。 |
| `room_id` | string | 房间 ID。 |
| `region_id` | number | 用户语区 ID。 |
| `status` | string | `pending``matched``revealing``finished``timeout``settlement_failed``cancelled`。 |
| `stake_gift_id` | string | 下注礼物 IDFlutter 优先用这个字段。 |
| `stake_gift_id_number` | number | 下注礼物 ID 数字值。 |
| `stake_coin` | number | 本局礼物对应金币价值。 |
| `initiator` | object | 发起人。 |
| `challenger` | object | 应战人;`pending` 时可能为空用户。 |
| `winner_user_id` | string | 胜者用户 ID平局或未结算为空或 `0`。 |
| `winner_user_id_number` | number | 胜者用户 ID 数字值。 |
| `settlement_status` | string | 结算状态,例如 `pending``settled``failed`。 |
| `failure_reason` | string | 结算失败原因。 |
| `timeout_at_ms` | number | 无人应战自动过期时间。 |
| `reveal_at_ms` | number | 揭晓时间。 |
| `created_at_ms` | number | 创建时间。 |
| `matched_at_ms` | number | 应战成功时间。 |
| `settled_at_ms` | number | 结算时间。 |
| `updated_at_ms` | number | 更新时间。 |
`initiator``challenger` 字段:
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `user_id` | string | 用户 IDFlutter 优先用这个字段。 |
| `user_id_number` | number | 用户 ID 数字值。 |
| `gesture` | string | `rock``paper``scissors`。 |
| `result` | string | `win``lose``draw`;未结算时为空。 |
| `balance_after` | number | 结算后的金币余额。 |
| `joined_at_ms` | number | 参与时间。 |
| `user.user_id` | string | 展示用户 ID。 |
| `user.display_user_id` | string | 短号或展示号。 |
| `user.nickname` | string | 昵称;发起人和应战人都要有。 |
| `user.avatar` | string | 头像;发起人和应战人都要有。 |
## 3. 查询配置
地址:
```http
GET /api/v1/games/room-rps/config
```
参数:无。
返回值:
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `config.status` | string | `active``disabled`。 |
| `config.challenge_timeout_ms` | number | 发起后无人应战的超时时间,默认 10 分钟。 |
| `config.reveal_countdown_ms` | number | 双方出拳后的揭晓倒计时,默认 3 秒。 |
| `config.stake_gifts` | array | 礼物下注档位,固定 4 档。 |
| `server_time_ms` | number | 服务端时间。 |
`stake_gifts` 字段:
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `gift_id` | string | 礼物 IDFlutter 优先用这个字段提交。 |
| `gift_id_number` | number | 礼物 ID 数字值。 |
| `gift_name` | string | 礼物名称。 |
| `gift_icon_url` | string | 礼物图标。 |
| `gift_price_coin` | number | 礼物价格金币。 |
| `enabled` | bool | 是否启用。 |
| `sort_order` | number | 排序。 |
相关 IM无。
## 4. 查询房间挑战单
地址:
```http
GET /api/v1/games/room-rps/challenges
```
参数:
| 字段 | 位置 | 必填 | 说明 |
| --- | --- | --- | --- |
| `room_id` | query | 是 | 房间 ID。 |
| `status` | query | 否 | 挑战状态。 |
| `page_size` | query | 否 | 页大小,默认 20。 |
| `cursor` | query | 否 | 下一页游标。 |
返回值:
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `challenges` | array | 挑战单列表,结构见通用挑战单字段。 |
| `next_cursor` | string | 下一页游标。 |
| `page_size` | number | 本次页大小。 |
| `server_time_ms` | number | 服务端时间。 |
相关 IM无。客户端漏掉 IM 后,用这个接口补拉当前房间挑战单。
## 5. 发起挑战
地址:
```http
POST /api/v1/games/room-rps/challenges/create
Content-Type: application/json
```
参数:
| 字段 | 类型 | 必填 | 说明 |
| --- | --- | --- | --- |
| `room_id` | string | 是 | 房间 ID。 |
| `gift_id` | number | 是 | 后台配置的礼物档位 ID。 |
| `gesture` | string | 是 | `rock``paper``scissors`。 |
请求示例:
```json
{
"room_id": "room_1001",
"gift_id": 10001,
"gesture": "rock"
}
```
返回值:
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `challenge` | object | 挑战单,结构见通用挑战单字段。 |
| `server_time_ms` | number | 服务端时间。 |
相关 IM`room_rps_challenge_created`
## 6. 应战
地址:
```http
POST /api/v1/games/room-rps/challenges/{challenge_id}/accept
Content-Type: application/json
```
参数:
| 字段 | 位置 | 必填 | 说明 |
| --- | --- | --- | --- |
| `challenge_id` | path | 是 | 挑战单 ID。 |
| `gesture` | body | 是 | `rock``paper``scissors`。 |
请求示例:
```json
{
"gesture": "paper"
}
```
返回值:
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `challenge` | object | 应战后的挑战单,结构见通用挑战单字段。 |
| `server_time_ms` | number | 服务端时间。 |
相关 IM
- `room_rps_challenge_accepted`
- `room_rps_reveal_countdown`
- `room_rps_finished`
## 7. 查询挑战详情
地址:
```http
GET /api/v1/games/room-rps/challenges/{challenge_id}
```
参数:
| 字段 | 位置 | 必填 | 说明 |
| --- | --- | --- | --- |
| `challenge_id` | path | 是 | 挑战单 ID。 |
返回值:
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `challenge` | object | 挑战单,结构见通用挑战单字段。 |
| `server_time_ms` | number | 服务端时间。 |
相关 IM无。客户端收到 IM 后,可以用这个接口刷新挑战事实。
## 8. 腾讯 IM 字段
房间群消息使用腾讯 IM `TIMCustomElem``data` 是 JSON 字符串。Flutter 只需要解析下面字段。
通用字段:
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `event_id` | string | IM 事件 ID客户端用它去重。 |
| `event_type` | string | 事件类型。 |
| `room_id` | string | 房间 ID。 |
| `challenge_id` | string | 挑战单 ID。 |
| `server_time_ms` | number | 服务端时间。 |
| `challenge` | object | 挑战单快照,结构见通用挑战单字段。 |
`challenge.initiator.user` 字段:
| 字段 | 类型 | 必填 | 说明 |
| --- | --- | --- | --- |
| `user_id` | string | 是 | 发起人用户 ID。 |
| `display_user_id` | string | 否 | 发起人展示号。 |
| `nickname` | string | 是 | 发起人昵称。 |
| `avatar` | string | 是 | 发起人头像。 |
`challenge.challenger.user` 字段:
| 字段 | 类型 | 必填 | 说明 |
| --- | --- | --- | --- |
| `user_id` | string | 应战后是 | 应战人用户 ID。 |
| `display_user_id` | string | 否 | 应战人展示号。 |
| `nickname` | string | 应战后是 | 应战人昵称。 |
| `avatar` | string | 应战后是 | 应战人头像。 |
IM 事件:
| `event_type` | 触发时机 | Flutter 处理 |
| --- | --- | --- |
| `room_rps_challenge_created` | 用户发起挑战后 | 展示 `[PK]` 入口、发起人头像昵称、礼物和倒计时。此时 `challenger` 可以为空。 |
| `room_rps_challenge_accepted` | 有用户应战后 | 锁定挑战入口,展示双方头像昵称。 |
| `room_rps_reveal_countdown` | 双方出拳后 | 播放 3 秒揭晓动画,展示双方头像昵称。 |
| `room_rps_finished` | 结算完成或平局退款后 | 展示胜负、双方头像昵称、余额或失败提示。 |
IM 示例:
```json
{
"event_id": "room_rps_finished:rps_10001",
"event_type": "room_rps_finished",
"room_id": "room_1001",
"challenge_id": "rps_10001",
"server_time_ms": 1780000003000,
"challenge": {
"challenge_id": "rps_10001",
"room_id": "room_1001",
"status": "finished",
"stake_gift_id": "10001",
"stake_gift_id_number": 10001,
"stake_coin": 100,
"initiator": {
"user_id": "10001",
"user_id_number": 10001,
"gesture": "rock",
"result": "lose",
"balance_after": 900,
"joined_at_ms": 1780000000000,
"user": {
"user_id": "10001",
"display_user_id": "880001",
"nickname": "Alice",
"avatar": "https://cdn.example/alice.png"
}
},
"challenger": {
"user_id": "10002",
"user_id_number": 10002,
"gesture": "paper",
"result": "win",
"balance_after": 1200,
"joined_at_ms": 1780000001000,
"user": {
"user_id": "10002",
"display_user_id": "880002",
"nickname": "Bob",
"avatar": "https://cdn.example/bob.png"
}
},
"winner_user_id": "10002",
"winner_user_id_number": 10002,
"settlement_status": "settled",
"timeout_at_ms": 1780000600000,
"reveal_at_ms": 1780000003000,
"created_at_ms": 1780000000000,
"matched_at_ms": 1780000001000,
"settled_at_ms": 1780000003000,
"updated_at_ms": 1780000003000
}
}
```

View File

@ -1,12 +1,17 @@
# Flutter 礼物墙对接
本文描述 Flutter App 查询当前登录用户礼物墙的 HTTP 接口。礼物墙事实由 `wallet-service` 在送礼扣费成功后通过异步投影聚合,客户端只展示 gateway 返回的当前投影,不从房间消息或本地缓存推算收礼数量。
本文描述 Flutter App 查询用户礼物墙的 HTTP 接口。礼物墙事实由 `wallet-service` 在送礼扣费成功后通过异步投影聚合,客户端只展示 gateway 返回的当前投影,不从房间消息或本地缓存推算收礼数量。
## 接口
`GET /api/v1/users/me/gift-wall`
登录接口,需要 `Authorization: Bearer <access_token>`。接口固定查询当前登录用户,不接受客户端传 `user_id`
`GET /api/v1/users/{user_id}/gift-wall`
登录接口,需要 `Authorization: Bearer <access_token>`
- `GET /api/v1/users/me/gift-wall` 查询当前登录用户。
- `GET /api/v1/users/{user_id}/gift-wall` 查询资料卡目标用户,`user_id` 是路径参数,必须是正整数。
一致性边界:礼物墙是最终一致读模型,送礼成功后允许延迟数秒到几十秒才出现在接口结果中。客户端不要用该接口判断刚刚送礼是否成功;送礼成功状态仍以送礼接口响应和房间 IM 消息为准。
@ -25,6 +30,12 @@ Authorization: Bearer <access_token>
X-App-Code: lalu
```
```http
GET /api/v1/users/10002/gift-wall
Authorization: Bearer <access_token>
X-App-Code: lalu
```
成功响应:
```json

View File

@ -274,7 +274,7 @@
| `rewards[].grant_id` | 发奖回执 |
| `rewards[].status` | 发奖状态 |
奖励 IM 用于通知本次哪些人中奖。需要渲染金币数量、奖励天数、聊天气泡或麦位特效素材时,收到 IM 后调用 `GET /api/v1/rooms/{room_id}/rocket`,读取 `state.last_rewards[].resource_group`
奖励 IM 用于通知本次哪些人中奖。top1 和点火人命中后会发完整配置池,同一用户可能同时收到多条 `top1``igniter` 和 1 条在房随机奖励。需要渲染金币数量、奖励天数、聊天气泡或麦位特效素材时,收到 IM 后调用 `GET /api/v1/rooms/{room_id}/rocket`,读取 `state.last_rewards[].resource_group`
## 客户端处理

View File

@ -0,0 +1,474 @@
# game-service 游戏结构设计
本文定义 `game-service` 后续承载游戏的结构边界。当前仓库已经有第三方 H5 游戏接入能力:游戏列表、启动会话、厂商回调、钱包改账、统计 outbox 和等级事件 outbox 都在 `game-service` 内。本文补的是自研小游戏的结构,避免把骰子、猜拳这类玩法塞进第三方平台 adapter。
## 结论
- `game-service` 是游戏事实 owner。游戏列表、启动会话、匹配、局内状态、结算、补偿和游戏事件都归它管理。
- `gateway-service` 只做 App HTTP 入口、鉴权、参数转换和 gRPC 转发,不保存游戏状态。
- `wallet-service` 仍然是金币 owner。`game-service` 只能通过钱包 gRPC 扣款、派奖、退款,不能直接改余额。
- `room-service` 不拥有游戏状态。房间只提供入口、房间上下文和必要的展示事件。
- 第三方 H5 游戏继续走现有 `platform/catalog/session/callback/order` 结构。
- 自研小游戏单独按 `game_code` 建领域模块、服务模块和表,不提前抽一个复杂通用引擎。
- 只有当两个以上自研游戏出现相同匹配、计时、结算、IM 投递逻辑时,再把公共能力抽到 `internal/domain/minigame``internal/service/minigame`
## 当前事实
现有入口:
- App 游戏列表:`GET /api/v1/games`
- 最近游戏:`GET /api/v1/games/recent`
- 桥接脚本:`GET /api/v1/games/bridge-script`
- 启动游戏:`POST /api/v1/games/{game_id}/launch`
- 厂商回调:`POST /api/v1/game-callbacks/{platform_code}/{operation}`
现有内部 RPC
- `GameAppService.ListGames`
- `GameAppService.ListRecentGames`
- `GameAppService.GetBridgeScript`
- `GameAppService.LaunchGame`
- `GameCallbackService.HandleCallback`
- `GameAdminService` 管理平台和目录
- `GameCronService.ProcessLevelEventOutboxBatch`
现有核心表:
- `game_platforms`:第三方平台配置
- `game_catalog`App 看到的游戏目录
- `game_display_rules`:按场景、区域、语言和客户端平台控制展示
- `game_launch_sessions`H5 启动会话
- `game_orders`:第三方或游戏结算引起的钱包订单
- `game_callback_logs`:第三方回调审计
- `game_level_event_outbox`:游戏消耗投递 activity-service
- `game_outbox`:游戏订单事实投递 RocketMQ给 statistics-service 等下游消费
## 分层结构
```text
gateway-service
internal/transport/http/gameapi
只接 App HTTP拿 user_id、request_id、app_code 后转 game-service gRPC
api/proto/game/v1
game.proto
所有跨服务契约,新增 App 接口或后台接口先改这里
services/game-service
internal/domain/game
已有通用游戏目录、平台、会话、订单、回调、outbox 领域对象
internal/service/game
已有第三方 H5 平台接入和游戏列表、启动、回调主流程
internal/domain/<game_code>
自研游戏纯规则不能依赖数据库、gRPC、HTTP、钱包 client
internal/service/<game_code>
自研游戏用例层,负责匹配、状态推进、调用钱包、写 outbox
internal/storage/mysql/<game_code>_repository.go
自研游戏持久化,行锁、幂等、状态转移都在这里收口
internal/transport/grpc
把 game.proto 的 RPC 转成 service 调用,不写业务规则
```
目录原则:
- `internal/service/game` 保留给已有平台接入,不继续堆自研玩法。
- 每个自研玩法用独立包,例如 `internal/domain/dice``internal/service/dice`
- 自研玩法可以复用 `game_catalog``game_display_rules` 做列表展示。
- 自研玩法不走第三方 `PlatformAdapter`,也不走 `HandleCallback`
- 自研玩法的 HTTP 入口仍在 gateway跨服务契约仍在 `api/proto/game/v1/game.proto`
## 游戏类型
| 类型 | 例子 | 列表和启动 | 状态 owner | 结算方式 |
| ---------------- | ---------------------- | ------------------------------------------------- | --------------------------------------- | --------------------------------------------- |
| 第三方 H5 | 百顺、VivaGames、Reyou | 现有 `/games``/launch` | 第三方平台game-service 保存会话和订单 | 厂商回调到 game-service再调 wallet-service |
| 自研 H5 多人结算 | 骰子首版 | 复用 `/games``/launch`,局内操作走新增 HTTP | game-service | game-service 直接调 wallet-service |
| 自研实时对战 | 猜拳、后续双人骰子 | 复用 `/games``/launch`,匹配和出招走新增 HTTP | game-service | game-service 调 wallet-serviceIM 只下发事件 |
## 自研游戏统一链路
```mermaid
sequenceDiagram
participant App
participant Gateway as gateway-service
participant Game as game-service
participant Wallet as wallet-service
participant Notice as notice-service/Tencent IM
participant DB as hyapp_game
App->>Gateway: POST /api/v1/games/{game_id}/launch
Gateway->>Game: LaunchGame
Game->>DB: create game_launch_sessions
Game-->>Gateway: launch_url/session_id
Gateway-->>App: H5 url
App->>Gateway: POST /api/v1/games/dice/matches
Gateway->>Game: Create self-game match
Game->>DB: create match + first participant
App->>Gateway: POST /api/v1/games/dice/matches/{match_id}/join
Gateway->>Game: JoinDiceMatch
Game->>DB: lock match, insert participant
App->>Gateway: POST /api/v1/games/dice/matches/{match_id}/roll
Gateway->>Game: RollDiceMatch
Game->>DB: claim match for settling
Game->>Wallet: ApplyGameCoinChange debit/credit/refund
Wallet-->>Game: wallet_transaction_id/balance_after
Game->>DB: save dice points, orders, result and outbox
Game-->>Gateway: match/result state
Gateway-->>App: business response
Game-->>Notice: optional realtime outbox
Notice-->>App: optional IM custom event
```
关键约束:
- 用户身份只信任 gateway 从 JWT 得到的 `user_id`
- `request_id` 只做链路追踪,不做业务幂等键。
- 业务幂等键使用 `command_id``match_id``round_id``provider_order_id` 或唯一索引。
- 所有时间字段使用 UTC epoch milliseconds字段名统一 `*_ms`
- 金币金额必须是正数,扣款、派奖、退款用 `op_type` 表达方向。
- 同一局的投注、派奖、退款必须能通过 `match_id/round_id/order_id` 追踪。
## 自研游戏表结构原则
不要用一个大表兼容所有玩法。每个玩法先建自己的事实表,公共 outbox 可以复用。
建议公共表:
### game_realtime_outbox
用于自研游戏的 IM 实时事件。不要复用 `game_outbox`,因为 `game_outbox` 是订单和统计事实,字段强依赖 `order_id/op_type/coin_amount`
关键字段:
- `app_code`
- `event_id`
- `event_type`
- `game_code`
- `match_id`
- `target_user_id`
- `payload_json`
- `status`
- `worker_id`
- `lock_until_ms`
- `retry_count`
- `next_retry_at_ms`
- `last_error`
- `created_at_ms`
- `updated_at_ms`
推荐索引:
- `PRIMARY KEY(app_code, event_id)`
- `idx_game_realtime_claim(app_code, status, next_retry_at_ms, created_at_ms, event_id)`
- `idx_game_realtime_target(app_code, target_user_id, created_at_ms)`
## Redis 和 MySQL 边界
局信息可以同时出现在 MySQL 和 Redis但不能是“双重事实”。
- MySQL 是唯一事实。参与人、扣款、派奖、结果、状态流转和补偿都必须能只靠 MySQL 恢复。
- Redis 只保存热状态。可以存等待队列、房间当前局快照、倒计时、座位临时锁和防重复点击标记。
- Redis 数据必须可丢。Redis 重启、Key 过期或缓存击穿时,服务必须从 MySQL 查回当前局。
- 金币相关状态只以 MySQL 和 wallet-service 返回为准,不以 Redis 为准。
- 关键状态推进用 MySQL 事务、唯一索引和行锁收口Redis 锁只能减少并发压力,不能作为最终防线。
推荐 Key
- `game:dice:room:{app_code}:{room_id}:active_match`房间当前局快照TTL 按局时长加缓冲。
- `game:dice:match:{app_code}:{match_id}`局详情快照TTL 按局时长加缓冲。
- `game:dice:join_lock:{app_code}:{match_id}`:短 TTL 加入锁,用来减少并发抢座。
- `game:dice:action:{app_code}:{match_id}:{user_id}:{action}`:短 TTL 动作防抖,最终幂等仍看 MySQL。
写入顺序:
1. 先在 MySQL 事务里锁定 `game_dice_matches`
2. 创建/加入只写 `game_dice_participants` 和局状态。
3. 开奖阶段用钱包幂等键写 `game_orders`,再保存点数、派奖和局结果。
4. MySQL 事实提交后刷新 Redis 快照。
5. Redis 刷新失败只记录日志和重试,不回滚已提交的 MySQL 事实。
## 骰子游戏首版结构
首版按多人可扩展结构设计。当前实现要求至少 2 人,默认最多 2 人,硬上限 6 人3 人局只增加参与者行不改表。H5 只负责展示和发起操作,随机、扣款、派奖和结果都由 `game-service` 完成。
当前骰子规则是单局胜场制。每个参与者每轮只扔 1 颗骰子,服务端把这个点数写入 `dice_points[0]`点数高的一方获胜。同点不是终局不退本金、不派奖H5 展示点数 2 秒后继续调用 `roll`,同一个 `match_id` 会一直重投到分出胜负。
### 模块
```text
services/game-service/internal/domain/dice
rule.go 赔率、可选押注、结果计算
match.go match 状态、参与者、局次、结果
services/game-service/internal/service/dice
service.go 创建局、加入局、扣款、开奖、派奖、查询
services/game-service/internal/storage/mysql/dice_repository.go
创建局、参与人行锁读取、状态推进、订单关联、查询
```
### 状态机
```mermaid
stateDiagram-v2
[*] --> created
created --> joining
joining --> settling
created --> settling
settling --> payout_applying
payout_applying --> settled
settling --> failed
payout_applying --> failed
```
状态说明:
- `created`:收到创建请求,已生成 `match_id`
- `joining`:等待参与者加入,人数达到 `min_players` 后可以开奖。
- `settling`:已抢占结算权,正在扣押注、生成骰子结果。
- `payout_applying`:骰子点数已经固定,正在派奖或退款;重试必须复用旧点数。
- `settled`:局已结束,结果固定。
- `failed`:扣款、派奖或退款失败,后续由补偿任务继续处理。
### 表
#### game_dice_matches
关键字段:
- `app_code`
- `match_id`
- `game_id`
- `platform_code`
- `provider_game_id`
- `room_id`
- `region_id`
- `min_players`
- `max_players`
- `current_players`
- `stake_coin`
- `round_no`
- `result`
- `status`
- `join_deadline_ms`
- `created_at_ms`
- `updated_at_ms`
- `settled_at_ms`
索引:
- `PRIMARY KEY(app_code, match_id)`
- `idx_game_dice_room_active(app_code, room_id, status, created_at_ms)`
- `idx_game_dice_status_time(app_code, status, updated_at_ms, match_id)`
#### game_dice_participants
一局一个参与者一行。2 人、3 人或 6 人只改变参与者数量,不改主表结构。
关键字段:
- `app_code`
- `match_id`
- `user_id`
- `seat_no`
- `status`
- `stake_coin`
- `dice_points_json`
- `result`
- `payout_coin`
- `debit_order_id`
- `payout_order_id`
- `refund_order_id`
- `balance_after`
- `joined_at_ms`
- `updated_at_ms`
索引:
- `PRIMARY KEY(app_code, match_id, user_id)`
- `UNIQUE KEY uk_game_dice_seat(app_code, match_id, seat_no)`
- `idx_game_dice_participant_user(app_code, user_id, updated_at_ms)`
- `idx_game_dice_participant_status(app_code, match_id, status, seat_no)`
#### game_dice_round_logs
如果首版只有一局,可以先不建。若后续做多轮、连胜、多人奖池或房间内广播,再补小局日志表。
关键字段:
- `app_code`
- `match_id`
- `round_no`
- `result_json`
- `created_at_ms`
## 骰子接口草案
接口文档只保留地址、参数、返回值和相关 IM。
### 1. 创建骰子局
地址:`POST /api/v1/games/dice/matches`
参数:
- `game_id`
- `room_id`
- `stake_coin`
- `min_players`
- `max_players`
返回值:
- `match_id`
- `status`
- `min_players`
- `max_players`
- `current_players`
- `stake_coin`
- `participants`
- `server_time_ms`
相关 IM
- 首版无
### 2. 加入骰子局
地址:`POST /api/v1/games/dice/matches/{match_id}/join`
参数:
- `match_id`
返回值:
- `match_id`
- `status`
- `min_players`
- `max_players`
- `current_players`
- `stake_coin`
- `participants`
- `server_time_ms`
相关 IM
- 首版无
### 3. 查询骰子局
地址:`GET /api/v1/games/dice/matches/{match_id}`
参数:
- `match_id`
返回值:
- `match_id`
- `status`
- `min_players`
- `max_players`
- `current_players`
- `stake_coin`
- `result`
- `participants`
- `server_time_ms`
相关 IM
- 首版无
### 4. 开奖
地址:`POST /api/v1/games/dice/matches/{match_id}/roll`
参数:
- `match_id`
返回值:
- `match_id`
- `status`
- `stake_coin`
- `result`
- `participants`
- `server_time_ms`
相关 IM
- 首版无
- 如果后续做房间公屏展示,再增加 `dice_match_settled`
## 钱包和订单
自研游戏也要落 `game_orders`,不能直接只写玩法表。
骰子推荐订单:
- 押注扣款:`op_type=debit``provider_order_id=dice:{match_id}:{user_id}:debit`
- 派奖加款:`op_type=credit``provider_order_id=dice:{match_id}:{user_id}:credit`
- 异常退款:`op_type=refund``provider_order_id=dice:{match_id}:{user_id}:refund`
`wallet-service``CommandId`
- `game:dice:{match_id}:{user_id}:debit`
- `game:dice:{match_id}:{user_id}:credit`
- `game:dice:{match_id}:{user_id}:refund`
扣款成功后:
- 写 `game_orders`
- 写 `game_outbox`,供统计使用
- 写 `game_level_event_outbox`,供 activity-service 游戏等级使用
派奖和退款是否进等级轨道由产品规则决定,默认只有 `debit` 进游戏等级。
## IM 事件边界
首版骰子不强依赖 IM。客户端发起 HTTPHTTP 返回结果。
需要 IM 的场景:
- 双人匹配成功
- 倒计时开始
- 双方状态变化
- 结果揭晓
- 对方离开或超时
IM 规则:
- 客户端不能通过 IM 提交游戏动作。
- 客户端动作必须走 HTTP 到 gateway再转 game-service。
- IM 只承载服务端事实事件。
- IM payload 必须带 `event_id``game_code``match_id``server_time_ms`
- 客户端按 `event_id` 去重。
## 上线顺序
1. 先补 `api/proto/game/v1/game.proto` 的骰子 RPC。
2. 生成 proto`make proto`
3. 在 `gateway-service/internal/transport/http/gameapi` 加 HTTP handler。
4. 在 `game-service``domain/dice``service/dice``storage/mysql/dice_repository.go`
5. 更新 `services/game-service/deploy/mysql/initdb/001_game_service.sql`
6. 更新 runtime `Migrate`,保证线上库自动补表和索引。
7. 后台把骰子写入 `game_catalog` 和默认 `game_display_rules`
8. H5 `game/touzi` 通过启动页拿 `game_id/session_id`,局内操作走新增骰子接口。
9. 跑测试:`make proto``go test ./services/game-service/... ./services/gateway-service/...`
10. 本地拉起:`docker compose config`,必要时 `docker compose up -d game-service gateway-service` 验证。
## 不做的事
- 不把骰子局状态放进 `room-service`
- 不让 H5 直接调 `wallet-service`
- 不把骰子动作放进第三方 `HandleCallback`
- 不把所有玩法硬塞进一个通用 `game_matches` 大表。
- 不用 Redis 做唯一事实。Redis 后续只能做短期匹配队列、限流或缓存MySQL 仍是恢复来源。

View File

@ -393,6 +393,62 @@ paths:
$ref: "#/definitions/H5LinkListEnvelope"
"502":
$ref: "#/responses/UpstreamError"
/api/v1/app/splash-screens:
get:
tags:
- app-config
summary: 获取 App 开屏配置
operationId: listSplashScreens
description: 公开读接口,返回后台 APP配置/开屏配置 中当前有效的开屏列表,客户端按列表顺序取最高优先级配置展示。
parameters:
- name: platform
in: query
required: false
type: string
enum: [android, ios]
description: 可选平台覆盖值;不传时读取 `X-App-Platform` / `X-Platform`。
- name: region_id
in: query
required: false
type: integer
format: int64
description: 当前用户区域 ID0 或不传只命中全区域配置。
- name: country
in: query
required: false
type: string
description: 当前用户国家码;不传时读取 `X-Country-Code` / `X-App-Country`。
- name: X-App-Platform
in: header
required: false
type: string
enum: [android, ios]
- name: X-Country-Code
in: header
required: false
type: string
responses:
"200":
description: 查询成功,`data.items` 返回当前可展示的开屏配置。
schema:
$ref: "#/definitions/SplashScreenListEnvelope"
"502":
$ref: "#/responses/UpstreamError"
/api/v1/app/popups:
get:
tags:
- app-config
summary: 获取 App 弹窗配置
operationId: listAppPopups
description: 公开读接口,返回后台 APP配置/弹窗配置 中当前有效的弹窗列表。客户端按列表顺序选择弹窗,并按 display_period_days 做本地展示频控。
parameters:
responses:
"200":
description: 查询成功,`data.items` 返回当前可展示的弹窗配置。
schema:
$ref: "#/definitions/PopupListEnvelope"
"502":
$ref: "#/responses/UpstreamError"
/api/v1/im/usersig:
get:
tags:
@ -751,6 +807,29 @@ paths:
$ref: "#/responses/Internal"
"502":
$ref: "#/responses/UpstreamError"
/api/v1/users/me/invite-overview:
get:
tags:
- users
summary: 查询当前用户邀请概览
operationId: getMyInviteOverview
description: 返回当前登录用户自己的邀请码和邀请计数;该接口只服务私有 H5 分享页,不并入我的页首屏聚合摘要。
security:
- BearerAuth: []
parameters:
responses:
"200":
description: 查询成功,`data` 返回当前用户邀请概览。
schema:
$ref: "#/definitions/MyInviteOverviewEnvelope"
"401":
$ref: "#/responses/Unauthorized"
"403":
$ref: "#/responses/Forbidden"
"500":
$ref: "#/responses/Internal"
"502":
$ref: "#/responses/UpstreamError"
/api/v1/users/me/onboarding/complete:
post:
tags:
@ -2975,6 +3054,28 @@ definitions:
server_time_ms:
type: integer
format: int64
MyInviteOverviewData:
type: object
required:
- my_invite_code
- invite_enabled
- invite_count
- valid_invite_count
- valid_invite_threshold_coin
properties:
my_invite_code:
type: string
invite_enabled:
type: boolean
invite_count:
type: integer
format: int64
valid_invite_count:
type: integer
format: int64
valid_invite_threshold_coin:
type: integer
format: int64
UserProfileData:
type: object
properties:
@ -3252,13 +3353,7 @@ definitions:
properties:
key:
type: string
enum:
- host-center
- manager-center
- bd-center
- bd-leader-center
- agency-center
- invite-user
description: 后台 H5 配置 keyApp 只对已接入的 key 做入口展示,例如 rank、explore_game_rank、game_rock、game_dice、vip、level、task。
label:
type: string
url:
@ -3278,6 +3373,100 @@ definitions:
total:
type: integer
format: int32
SplashScreenData:
type: object
properties:
id:
type: integer
format: uint64
cover_url:
type: string
description: 开屏图片 URL。
type:
type: string
enum: [h5, app]
description: h5 表示 param 是 H5 链接app 表示 param 由客户端解释。
param:
type: string
platform:
type: string
enum: [android, ios]
sort_order:
type: integer
format: int32
region_id:
type: integer
format: int64
country_code:
type: string
description:
type: string
starts_at_ms:
type: integer
format: int64
ends_at_ms:
type: integer
format: int64
updated_at_ms:
type: integer
format: int64
SplashScreenListData:
type: object
properties:
items:
type: array
items:
$ref: "#/definitions/SplashScreenData"
total:
type: integer
format: int32
PopupData:
type: object
properties:
id:
type: integer
format: uint64
code:
type: string
description: 弹窗编码,同一个 App 下唯一,用于客户端本地频控和埋点识别。
name:
type: string
description: 弹窗名字。
image_url:
type: string
description: 弹窗图片 URL为空表示无图片。
jump_type:
type: string
enum: [h5, app]
description: h5 表示 jump_url 是 H5 链接app 表示 jump_url 由客户端解释。
jump_url:
type: string
display_period_days:
type: integer
format: int32
description: 展示周期天数0 表示每次打开都展示。
sort_order:
type: integer
format: int32
starts_at_ms:
type: integer
format: int64
ends_at_ms:
type: integer
format: int64
updated_at_ms:
type: integer
format: int64
PopupListData:
type: object
properties:
items:
type: array
items:
$ref: "#/definitions/PopupData"
total:
type: integer
format: int32
AppBootstrapTab:
type: object
properties:
@ -5037,6 +5226,13 @@ definitions:
properties:
data:
$ref: "#/definitions/MyOverviewData"
MyInviteOverviewEnvelope:
allOf:
- $ref: "#/definitions/GatewayOKEnvelopeBase"
- type: object
properties:
data:
$ref: "#/definitions/MyInviteOverviewData"
UserProfileEnvelope:
allOf:
- $ref: "#/definitions/GatewayOKEnvelopeBase"
@ -5121,6 +5317,20 @@ definitions:
properties:
data:
$ref: "#/definitions/H5LinkListData"
SplashScreenListEnvelope:
allOf:
- $ref: "#/definitions/GatewayOKEnvelopeBase"
- type: object
properties:
data:
$ref: "#/definitions/SplashScreenListData"
PopupListEnvelope:
allOf:
- $ref: "#/definitions/GatewayOKEnvelopeBase"
- type: object
properties:
data:
$ref: "#/definitions/PopupListData"
ResourceListEnvelope:
allOf:
- $ref: "#/definitions/GatewayOKEnvelopeBase"

View File

@ -0,0 +1,80 @@
# 全站机器人产品文档
## 目标
全站机器人用于自研游戏自动补位。后台批量创建机器人资料号后,游戏服务可以在玩家等待超时后从机器人池选择一个可用机器人参与对局。
## 后台入口
- 菜单位置:游戏管理 / 全站机器人
- 菜单编码:`game-robots`
- 页面地址:`/games/robots`
- 权限:查看使用 `game:view`,创建使用 `game:create`,启停使用 `game:update`,删除使用 `game:delete``game:update`
## 机器人规则
- 机器人以 App 用户资料形式保存,有 `user_id`、短 ID、昵称、头像、国家、语言等基础资料。
- 机器人注册来源固定为 `game_robot`
- 机器人不是自然新增用户,不进入新增用户统计、注册 cohort、留存统计。
- 机器人不能通过 App 登录接口登录。
- 机器人创建时不生成 access token、refresh token也不写登录成功审计。
- 机器人不导入腾讯 IM 登录账号;它只用于游戏资料展示和游戏服务补位。
- 删除机器人只会从全站机器人池移除,用户资料和历史对局记录保留。
## 创建流程
1. 运营在后台选择创建数量和昵称语言。
2. admin-server 下载并转存头像。
3. admin-server 调 user-service 创建 `source=game_robot` 的资料号。
4. user-service 写入用户资料、短 ID 和密码身份,但不创建登录 session。
5. admin-server 调 game-service 把这些 user_id 注册到机器人池。
6. game-service 后续只从启用状态的机器人中选择补位。
## 统计口径
- 实时统计:`source=game_robot` 不写 `UserRegistered` outbox。
- 补数统计:统计补数按 `users.created_at_ms` 扫描时排除 `source=game_robot`
- 后台创建 100 个全站机器人,不会增加“新增用户数”,也不会进入留存 cohort。
## 登录限制
- 密码登录:即使知道短 ID 和密码,也会被 user-service 拦截。
- 三方登录:机器人没有三方身份绑定,不能通过三方接口登录成机器人。
- refresh token新机器人不会生成 refresh token历史上如果存在机器人 session续期时也会被用户登录判断拦截。
- access token 重签:会复用同一登录判断,机器人不能续签。
## 后台接口
### 列表
- 地址:`GET /admin/game/robots`
- 参数:`gameId``status``pageSize``cursor`
- 返回值:机器人列表、下一页游标、服务器时间
- 相关 IM
### 批量创建
- 地址:`POST /admin/game/robots/generate`
- 参数:`count``nicknameLanguage``gender``country``avatarUrls`
- 返回值:创建数量、机器人列表
- 相关 IM机器人不导入 IM 登录账号
### 启停
- 地址:`PATCH /admin/game/robots/{user_id}/status`
- 参数:`status`,可传 `active``disabled`;可选 `gameId`
- 返回值:机器人当前状态
- 相关 IM
### 删除
- 地址:`DELETE /admin/game/robots/{user_id}`
- 参数:可选 `gameId`
- 返回值:`deleted`
- 相关 IM
## 游戏补位规则
- 自研游戏配置里开启机器人补位后玩家等待超过配置时间game-service 会选择启用机器人加入对局。
- 机器人对局资金由游戏奖池结算,不从机器人钱包扣款。
- 机器人禁用后不会再被新对局选中。

View File

@ -0,0 +1,83 @@
# 房内猜拳接口文档
本文只记录房内猜拳 App 端接口。独立猜拳本阶段不接入。Flutter 详细字段看 [房内猜拳 Flutter 对接](flutter对接/房内猜拳Flutter对接.md)。
## 查询配置
- 地址:`GET /api/v1/games/room-rps/config`
- 参数:无
- 返回值:
- `config.status``active` / `disabled`
- `config.challenge_timeout_ms`:发起后无人应战的超时时间,默认 10 分钟
- `config.reveal_countdown_ms`:双方出拳后的揭晓倒计时,默认 3 秒
- `config.stake_gifts`:礼物下注档位
- 相关 IM
## 查询房间挑战单
- 地址:`GET /api/v1/games/room-rps/challenges`
- 参数:
- `room_id`:房间 ID必填
- `status`:状态,可选
- `page_size`:页大小,默认 20
- `cursor`:下一页游标
- 返回值:
- `challenges`:挑战单列表
- `challenges[].initiator.user.nickname`:发起人昵称
- `challenges[].initiator.user.avatar`:发起人头像
- `challenges[].challenger.user.nickname`:应战人昵称;未应战时为空
- `challenges[].challenger.user.avatar`:应战人头像;未应战时为空
- `next_cursor`:下一页游标
- `page_size`:本次页大小
- 相关 IM无。客户端漏掉 IM 后可以用这个接口补拉当前房间挑战单。
## 发起挑战
- 地址:`POST /api/v1/games/room-rps/challenges/create`
- 参数:
- `room_id`:房间 ID
- `gift_id`:后台配置的礼物档位 ID
- `gesture``rock` / `paper` / `scissors`
- 返回值:
- `challenge`:挑战单
- 相关 IM
- `room_rps_challenge_created`:服务端向房间群发 `[PK]` 消息,带 `challenge_id`、发起人头像昵称、礼物档位和超时时间。
## 应战
- 地址:`POST /api/v1/games/room-rps/challenges/:challenge_id/accept`
- 参数:
- `challenge_id`:挑战单 ID
- `gesture``rock` / `paper` / `scissors`
- 返回值:
- `challenge`:应战后的挑战单
- 相关 IM
- `room_rps_challenge_accepted`:服务端通知房间挑战已被锁定,带发起人和应战人头像昵称。
- `room_rps_reveal_countdown`:服务端通知双方展示 3 秒倒计时,带发起人和应战人头像昵称。
- `room_rps_finished`:服务端通知胜负、退款或礼物结算结果,带发起人和应战人头像昵称。
## 查询挑战详情
- 地址:`GET /api/v1/games/room-rps/challenges/:challenge_id`
- 参数:
- `challenge_id`:挑战单 ID
- 返回值:
- `challenge.status``pending` / `matched` / `revealing` / `finished` / `timeout` / `settlement_failed` / `cancelled`
- `challenge.initiator`:发起人
- `challenge.initiator.user.nickname`:发起人昵称
- `challenge.initiator.user.avatar`:发起人头像
- `challenge.challenger`:应战人
- `challenge.challenger.user.nickname`:应战人昵称;未应战时为空
- `challenge.challenger.user.avatar`:应战人头像;未应战时为空
- `challenge.winner_user_id`:胜者,平局为空或 0
- `challenge.settlement_status`:结算状态
- 相关 IM无。客户端收到 IM 后可以用这个接口刷新挑战事实。
## 相关 IM 字段
- 所有房内猜拳 IM 都是腾讯 IM `TIMCustomElem``data` 是 JSON 字符串。
- 通用字段:`event_id``event_type``room_id``challenge_id``server_time_ms``challenge`
- `challenge.initiator.user.nickname`:发起人昵称,必填。
- `challenge.initiator.user.avatar`:发起人头像,必填。
- `challenge.challenger.user.nickname`:应战人昵称;`room_rps_challenge_accepted``room_rps_reveal_countdown``room_rps_finished` 必填。
- `challenge.challenger.user.avatar`:应战人头像;`room_rps_challenge_accepted``room_rps_reveal_countdown``room_rps_finished` 必填。

View File

@ -0,0 +1,47 @@
# 房内猜拳本地真实 IM 测试
测试文件:`tests/smoke/room_rps_real_im_flow_test.go`
## 作用
- 用本地 gateway 真实 HTTP 接口跑房内猜拳流程。
- 通过 `/api/v1/auth/account/quick-create` 自动创建发起人和应战人。
- 用真实腾讯 IM REST 确保房间群、导入两个账号、发送一条 `TIMCustomElem` 探针消息。
- 不使用 fake client不伪造 access token不伪造 IM 成功。
## 前置
- 本地 gateway 地址默认:`http://127.0.0.1:13000/api/v1`
- 本地 gateway、game-service、user-service 已启动。
- game-service 启动时需要带 `TENCENT_IM_SDK_APP_ID``TENCENT_IM_SECRET_KEY``TENCENT_IM_ADMIN_IDENTIFIER`,这样创建和应战接口会真实投递 IM。
- 不传 `ROOM_RPS_ROOM_ID` 时,测试会自动创建 `room-rps-real-im-时间戳` 临时群。
- 不传 `ROOM_RPS_GIFT_ID` 时,使用默认已开启礼物 `10001`
- 腾讯 IM 配置必须和本地 gateway 使用同一个 SDKAppID。
## 命令
```bash
ROOM_RPS_REAL_IM_FLOW_TEST=1 \
ROOM_RPS_BASE_URL=http://127.0.0.1:13000/api/v1 \
ROOM_RPS_APP_CODE=lalu \
TENCENT_IM_SDK_APP_ID=1400000000 \
TENCENT_IM_SECRET_KEY='<secret_key>' \
TENCENT_IM_ADMIN_IDENTIFIER=administrator \
TENCENT_IM_ENDPOINT=adminapisgp.im.qcloud.com \
go test ./tests/smoke -run TestRoomRPSLocalHTTPFlowWithRealTencentIM -count=1 -v
```
## 返回判断
- 通过:本地 HTTP 流程完成,真实腾讯 IM 探针消息发送成功game-service 创建和应战 IM 投递成功,发起人和应战人返回头像昵称。
- 失败并提示腾讯 IM 错误:先检查 SDKAppID、SecretKey、管理员账号、endpoint 和 GroupID。
## 清理
默认会清理自动创建的 `room-rps-real-im-` 临时群。
如果传入真实房间群,不要开启清理;可以显式加:
```bash
ROOM_RPS_CLEANUP_IM_GROUP=0
```

View File

@ -18,6 +18,8 @@
| GET | `/api/v1/countries` | countries | `listRegistrationCountries` | 获取 App 注册页可选国家 |
| GET | `/api/v1/app/bootstrap` | app-config | `getAppBootstrap` | 获取 App 启动配置摘要 |
| GET | `/api/v1/app/h5-links` | app-config | `listAppH5Links` | 获取 App H5 入口地址 |
| GET | `/api/v1/app/splash-screens` | app-config | `listSplashScreens` | 获取 App 开屏配置 |
| GET | `/api/v1/app/popups` | app-config | `listAppPopups` | 获取 App 弹窗配置 |
| POST | `/api/v1/app/heartbeat` | app | `appHeartbeat` | 刷新当前 App 登录会话心跳 |
| GET | `/api/v1/im/usersig` | tencent-im | `issueTencentIMUserSig` | 获取腾讯云 IM UserSig |
| POST | `/api/v1/rtc/token` | tencent-rtc | `issueTencentRTCToken` | 获取腾讯 RTC 进房 UserSig |
@ -36,7 +38,9 @@
| GET | `/api/v1/users/me/identity` | users | `getMyIdentity` | 查询当前用户展示短号状态 |
| GET | `/api/v1/users/me/host-identity` | users | `getMyHostIdentity` | 查询当前用户 Host/Agency/BD 身份 |
| GET | `/api/v1/users/me/overview` | users | `getMyOverview` | 我的页首屏聚合摘要 |
| GET | `/api/v1/users/me/invite-overview` | users | `getMyInviteOverview` | 查询当前用户邀请码和邀请计数 |
| GET | `/api/v1/users/me/gift-wall` | users | `getMyGiftWall` | 查询当前用户收到的礼物墙聚合 |
| GET | `/api/v1/users/{user_id}/gift-wall` | users | `getUserGiftWall` | 查询资料卡用户收到的礼物墙聚合 |
| GET | `/api/v1/users/me/role-summary` | users | `getMyRoleSummary` | 查询当前用户角色摘要 |
| POST | `/api/v1/users/me/onboarding/complete` | users | `completeMyOnboarding` | 完成注册页必填资料 |
| POST | `/api/v1/users/me/profile/update` | users | `updateMyProfile` | 修改当前用户基础资料 |
@ -122,6 +126,10 @@
| POST | `/api/v1/admin/app-config/banners` | admin | `createBanner` | createBanner |
| DELETE | `/api/v1/admin/app-config/banners/{banner_id}` | admin | `deleteBanner` | deleteBanner |
| PUT | `/api/v1/admin/app-config/banners/{banner_id}` | admin | `updateBanner` | updateBanner |
| GET | `/api/v1/admin/app-config/splash-screens` | admin | `listSplashScreens` | listSplashScreens |
| POST | `/api/v1/admin/app-config/splash-screens` | admin | `createSplashScreen` | createSplashScreen |
| DELETE | `/api/v1/admin/app-config/splash-screens/{splash_id}` | admin | `deleteSplashScreen` | deleteSplashScreen |
| PUT | `/api/v1/admin/app-config/splash-screens/{splash_id}` | admin | `updateSplashScreen` | updateSplashScreen |
| GET | `/api/v1/admin/app-config/h5-links` | admin | `listH5Links` | listH5Links |
| PUT | `/api/v1/admin/app-config/h5-links` | admin | `updateH5Links` | updateH5Links |
| GET | `/api/v1/admin/apps` | admin | `listApps` | listApps |

View File

@ -74,12 +74,15 @@
| 贡献第一奖励 | `pending_launches[].top1_user_id` |
| 在房奖励 | 发射命令执行时 Room Cell `OnlineUsers` |
`reward_stack_policy`
发放规则
| 值 | 说明 |
| 奖励 | 规则 |
| --- | --- |
| `allow_stack` | 同一用户可同时拿在房、top1、点火人奖励 |
| `priority_only` | 先点火人,再 top1再在房同一用户只拿一份 |
| 在房奖励 | 每个在线用户从 `inRoomRewards` 按权重随机抽 1 个 |
| 贡献第一奖励 | top1 用户获得 `top1Rewards` 配置池内全部奖励 |
| 点火人奖励 | 点火用户获得 `igniterRewards` 配置池内全部奖励 |
同一用户同时是 top1 和点火人时top1 池和点火人池继续叠加;如果该用户发射瞬间仍在房间,也会再拿 1 个在房随机奖励。
## IM
@ -119,7 +122,7 @@
| `launch_delay_ms` | 点火到发射延迟 |
| `broadcast_enabled` | 是否发飘屏 |
| `broadcast_scope` | `region``global``none` |
| `reward_stack_policy` | 奖励叠加策略 |
| `reward_stack_policy` | 历史兼容字段;当前特殊身份奖励按上方规则叠加 |
| `levels_json` | 5 级阈值、物料和奖励池 |
| `gift_fuel_rules_json` | 礼物燃料规则 |

View File

@ -11,6 +11,14 @@ const (
MessageTypeGameOutboxEvent = "game_outbox_event"
TagGameOutboxEvent = "game_outbox_event"
EventTypeGameOrderSettled = "GameOrderSettled"
EventTypeSelfGameMatchCreated = "SelfGameMatchCreated"
EventTypeSelfGameMatchReady = "SelfGameMatchReady"
EventTypeSelfGameMatchSettled = "SelfGameMatchSettled"
EventTypeSelfGameMatchCanceled = "SelfGameMatchCanceled"
EventTypeSelfGameMatchFailed = "SelfGameMatchFailed"
EventTypeSelfGamePoolAdjusted = "SelfGamePoolAdjusted"
)
// GameOutboxMessage is the MQ representation of one committed game_outbox fact.
@ -35,14 +43,16 @@ func EncodeGameOutboxMessage(message GameOutboxMessage) ([]byte, error) {
if strings.TrimSpace(message.AppCode) == "" ||
strings.TrimSpace(message.EventID) == "" ||
strings.TrimSpace(message.EventType) == "" ||
strings.TrimSpace(message.OrderID) == "" ||
message.UserID <= 0 ||
strings.TrimSpace(message.GameID) == "" ||
strings.TrimSpace(message.OpType) == "" ||
message.CoinAmount <= 0 ||
message.OccurredAtMS <= 0 {
return nil, errors.New("game outbox message is incomplete")
}
if message.EventType == EventTypeGameOrderSettled && (strings.TrimSpace(message.OrderID) == "" ||
message.UserID <= 0 ||
strings.TrimSpace(message.OpType) == "" ||
message.CoinAmount <= 0) {
return nil, errors.New("game order outbox message is incomplete")
}
if strings.TrimSpace(message.PayloadJSON) == "" {
message.PayloadJSON = "{}"
}
@ -61,14 +71,16 @@ func DecodeGameOutboxMessage(body []byte) (GameOutboxMessage, error) {
if strings.TrimSpace(message.AppCode) == "" ||
strings.TrimSpace(message.EventID) == "" ||
strings.TrimSpace(message.EventType) == "" ||
strings.TrimSpace(message.OrderID) == "" ||
message.UserID <= 0 ||
strings.TrimSpace(message.GameID) == "" ||
strings.TrimSpace(message.OpType) == "" ||
message.CoinAmount <= 0 ||
message.OccurredAtMS <= 0 {
return GameOutboxMessage{}, errors.New("game outbox message is incomplete")
}
if message.EventType == EventTypeGameOrderSettled && (strings.TrimSpace(message.OrderID) == "" ||
message.UserID <= 0 ||
strings.TrimSpace(message.OpType) == "" ||
message.CoinAmount <= 0) {
return GameOutboxMessage{}, errors.New("game order outbox message is incomplete")
}
if strings.TrimSpace(message.PayloadJSON) == "" {
message.PayloadJSON = "{}"
}

View File

@ -19,7 +19,6 @@ SQL_FILES=(
"${DEPLOY_PLATFORM_ROOT}/deploy/mysql/initdb/005_utf8mb4_chinese_support.sql"
"${DEPLOY_PLATFORM_ROOT}/deploy/mysql/initdb/006_admin_database.sql"
"${DEPLOY_PLATFORM_ROOT}/deploy/mysql/initdb/007_resource_group_wallet_asset_items.sql"
"${DEPLOY_PLATFORM_ROOT}/deploy/mysql/initdb/008_remove_taiwan_country.sql"
"services/cron-service/deploy/mysql/initdb/001_cron_service.sql"
"services/game-service/deploy/mysql/initdb/001_game_service.sql"
"services/notice-service/deploy/mysql/initdb/001_notice_service.sql"

View File

@ -0,0 +1,6 @@
UPDATE user_invite_validity_policies
SET eligible_recharge_types = JSON_ARRAY('coin_seller_transfer', 'google_play', 'google', 'mifapay', 'usdt_trc20'),
updated_at_ms = UNIX_TIMESTAMP(CURRENT_TIMESTAMP(3)) * 1000
WHERE app_code = 'lalu'
AND region_id = 0
AND policy_version = 'invite-valid-default-v1';

View File

@ -1,22 +1,19 @@
CREATE TABLE IF NOT EXISTS bd_leader_profiles (
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码,用于多租户隔离',
user_id BIGINT NOT NULL PRIMARY KEY COMMENT '用户 ID',
region_id BIGINT NOT NULL COMMENT '区域 ID',
status VARCHAR(32) NOT NULL COMMENT '业务状态',
created_by_user_id BIGINT NOT NULL COMMENT '创建人用户 ID',
created_at_ms BIGINT NOT NULL COMMENT '创建时间UTC epoch ms',
updated_at_ms BIGINT NOT NULL COMMENT '更新时间UTC epoch ms',
KEY idx_bd_leader_profiles_region_status (app_code, region_id, status)
updated_at_ms BIGINT NOT NULL COMMENT '更新时间UTC epoch ms'
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='BD Leader资料表';
INSERT INTO bd_leader_profiles (
app_code, user_id, region_id, status, created_by_user_id, created_at_ms, updated_at_ms
app_code, user_id, status, created_by_user_id, created_at_ms, updated_at_ms
)
SELECT app_code, user_id, region_id, status, created_by_user_id, created_at_ms, updated_at_ms
SELECT app_code, user_id, status, created_by_user_id, created_at_ms, updated_at_ms
FROM bd_profiles
WHERE role = 'bd_leader'
ON DUPLICATE KEY UPDATE
region_id = VALUES(region_id),
status = VALUES(status),
created_by_user_id = VALUES(created_by_user_id),
created_at_ms = VALUES(created_at_ms),

View File

@ -0,0 +1,92 @@
SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci;
USE hyapp_user;
-- 靓号管理把展示号从纯数字短号扩展为可发放的展示字符串,默认短号生成规则仍由业务代码保持纯数字。
ALTER TABLE users
MODIFY COLUMN default_display_user_id VARCHAR(64) NOT NULL COMMENT '默认展示用户 ID',
MODIFY COLUMN current_display_user_id VARCHAR(64) NOT NULL COMMENT '当前展示用户 ID';
ALTER TABLE user_display_user_ids
MODIFY COLUMN display_user_id VARCHAR(64) NOT NULL COMMENT '展示用户 ID',
MODIFY COLUMN live_display_user_id VARCHAR(64)
GENERATED ALWAYS AS (
CASE
WHEN status IN ('active', 'held', 'reserved', 'blocked') THEN display_user_id
ELSE NULL
END
) STORED COMMENT '实时展示用户 ID';
ALTER TABLE pretty_display_user_id_leases
MODIFY COLUMN display_user_id VARCHAR(64) NOT NULL COMMENT '展示用户 ID',
MODIFY COLUMN active_display_user_id VARCHAR(64)
GENERATED ALWAYS AS (
CASE WHEN status = 'active' THEN display_user_id ELSE NULL END
) STORED COMMENT '启用展示用户 ID',
ADD COLUMN released_at_ms BIGINT NOT NULL DEFAULT 0 COMMENT '释放时间UTC epoch ms' AFTER expires_at_ms,
ADD COLUMN release_reason VARCHAR(64) NOT NULL DEFAULT '' COMMENT '释放原因' AFTER released_at_ms;
ALTER TABLE display_user_id_change_logs
MODIFY COLUMN old_display_user_id VARCHAR(64) NOT NULL COMMENT '原展示用户 ID',
MODIFY COLUMN new_display_user_id VARCHAR(64) NOT NULL COMMENT '新展示用户 ID';
CREATE TABLE IF NOT EXISTS pretty_display_id_pools (
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码,用于多租户隔离',
pool_id VARCHAR(64) NOT NULL PRIMARY KEY COMMENT '靓号池 ID',
name VARCHAR(128) NOT NULL COMMENT '靓号池名称',
level_track VARCHAR(32) NOT NULL COMMENT '等级轨道wealth/game/charm',
min_level INT NOT NULL COMMENT '最小等级,包含',
max_level INT NOT NULL COMMENT '最大等级,包含',
rule_type VARCHAR(32) NOT NULL COMMENT '生成规则',
rule_config_json JSON NULL COMMENT '生成规则配置',
status VARCHAR(32) NOT NULL COMMENT '业务状态active/disabled',
sort_order INT NOT NULL DEFAULT 0 COMMENT '排序权重',
created_by_admin_id BIGINT NOT NULL DEFAULT 0 COMMENT '创建管理员 ID',
updated_by_admin_id BIGINT NOT NULL DEFAULT 0 COMMENT '更新管理员 ID',
created_at_ms BIGINT NOT NULL COMMENT '创建时间UTC epoch ms',
updated_at_ms BIGINT NOT NULL COMMENT '更新时间UTC epoch ms',
UNIQUE KEY uk_pretty_pool_range_rule (app_code, level_track, min_level, max_level, name),
KEY idx_pretty_pool_status (app_code, status, sort_order)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='靓号池配置表';
CREATE TABLE IF NOT EXISTS pretty_display_ids (
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码,用于多租户隔离',
pretty_id VARCHAR(64) NOT NULL PRIMARY KEY COMMENT '靓号记录 ID',
pool_id VARCHAR(64) NOT NULL DEFAULT '' COMMENT '所属靓号池 ID后台发放为空',
source VARCHAR(32) NOT NULL DEFAULT 'pool' COMMENT '来源pool/admin_grant',
display_user_id VARCHAR(64) NOT NULL COMMENT '靓号展示内容',
status VARCHAR(32) NOT NULL COMMENT '业务状态',
assigned_user_id BIGINT NOT NULL DEFAULT 0 COMMENT '当前占用用户 ID',
assigned_lease_id VARCHAR(64) NOT NULL DEFAULT '' COMMENT '当前占用租约 ID',
assigned_at_ms BIGINT NOT NULL DEFAULT 0 COMMENT '占用时间UTC epoch ms',
released_at_ms BIGINT NOT NULL DEFAULT 0 COMMENT '释放时间UTC epoch ms',
release_reason VARCHAR(64) NOT NULL DEFAULT '' COMMENT '释放原因',
generated_batch_id VARCHAR(64) NOT NULL DEFAULT '' COMMENT '生成批次 ID',
created_by_admin_id BIGINT NOT NULL DEFAULT 0 COMMENT '创建管理员 ID',
created_at_ms BIGINT NOT NULL COMMENT '创建时间UTC epoch ms',
updated_at_ms BIGINT NOT NULL COMMENT '更新时间UTC epoch ms',
live_display_user_id VARCHAR(64)
GENERATED ALWAYS AS (
CASE WHEN status IN ('available', 'assigned', 'disabled', 'reserved') THEN display_user_id ELSE NULL END
) STORED COMMENT '未删除且未释放的靓号内容',
UNIQUE KEY uk_pretty_display_ids_live (app_code, live_display_user_id),
KEY idx_pretty_display_ids_pool_status (app_code, pool_id, status),
KEY idx_pretty_display_ids_assigned_user (app_code, assigned_user_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='靓号池号码表';
CREATE TABLE IF NOT EXISTS pretty_display_id_generation_batches (
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码,用于多租户隔离',
batch_id VARCHAR(64) NOT NULL PRIMARY KEY COMMENT '生成批次 ID',
pool_id VARCHAR(64) NOT NULL COMMENT '靓号池 ID',
rule_type VARCHAR(32) NOT NULL COMMENT '生成规则',
rule_config_json JSON NULL COMMENT '生成规则配置',
requested_count INT NOT NULL COMMENT '请求生成数量',
generated_count INT NOT NULL COMMENT '实际生成数量',
skipped_conflict_count INT NOT NULL COMMENT '冲突跳过数量',
status VARCHAR(32) NOT NULL COMMENT '批次状态',
operator_admin_id BIGINT NOT NULL COMMENT '操作管理员 ID',
request_id VARCHAR(64) NOT NULL DEFAULT '' COMMENT '链路请求 ID',
created_at_ms BIGINT NOT NULL COMMENT '创建时间UTC epoch ms',
updated_at_ms BIGINT NOT NULL COMMENT '更新时间UTC epoch ms',
KEY idx_pretty_batch_pool (app_code, pool_id, created_at_ms)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='靓号批量生成记录表';

View File

@ -0,0 +1,129 @@
SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci;
-- H5 外部充值:商品区分普通用户/币商,三方支付配置默认启用,外部订单独立于 Google payment_orders。
SET @now_ms = CAST(UNIX_TIMESTAMP(UTC_TIMESTAMP(3)) * 1000 AS UNSIGNED);
SET @ddl := IF(
(SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'wallet_recharge_products' AND COLUMN_NAME = 'audience_type') = 0,
'ALTER TABLE wallet_recharge_products ADD COLUMN audience_type VARCHAR(32) NOT NULL DEFAULT ''normal'' COMMENT ''购买对象normal 普通用户coin_seller 币商'' AFTER description',
'SELECT 1'
);
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
CREATE TABLE IF NOT EXISTS third_party_payment_channels (
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码,用于多租户隔离',
provider_code VARCHAR(32) NOT NULL COMMENT '三方支付渠道编码',
provider_name VARCHAR(64) NOT NULL COMMENT '三方支付渠道名称',
status VARCHAR(32) NOT NULL DEFAULT 'active' COMMENT '渠道状态',
sort_order INT 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, provider_code),
KEY idx_third_party_payment_channels_status (app_code, status, sort_order)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='三方支付渠道表';
CREATE TABLE IF NOT EXISTS third_party_payment_methods (
method_id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY COMMENT '支付方式 ID',
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码,用于多租户隔离',
provider_code VARCHAR(32) NOT NULL COMMENT '三方支付渠道编码',
country_code VARCHAR(8) NOT NULL COMMENT '国家码GLOBAL 表示全球方式',
country_name VARCHAR(64) NOT NULL DEFAULT '' COMMENT '国家名称',
currency_code VARCHAR(8) NOT NULL COMMENT '下单币种',
pay_way VARCHAR(64) NOT NULL COMMENT 'MiFaPay payWay',
pay_type VARCHAR(64) NOT NULL COMMENT 'MiFaPay payType',
method_name VARCHAR(128) NOT NULL COMMENT '支付方式名称',
logo_url VARCHAR(1024) NOT NULL DEFAULT '' COMMENT '支付方式图标',
status VARCHAR(32) NOT NULL DEFAULT 'active' COMMENT '方式状态',
usd_to_currency_rate DECIMAL(20,8) NOT NULL DEFAULT 1 COMMENT 'USD 到支付币种汇率',
sort_order INT 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',
UNIQUE KEY uk_third_party_payment_methods_scope (app_code, provider_code, country_code, pay_way, pay_type),
KEY idx_third_party_payment_methods_h5 (app_code, provider_code, country_code, status, sort_order),
CONSTRAINT fk_third_party_payment_methods_channel FOREIGN KEY (app_code, provider_code) REFERENCES third_party_payment_channels(app_code, provider_code)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='三方支付国家方式配置表';
CREATE TABLE IF NOT EXISTS external_recharge_orders (
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码,用于多租户隔离',
order_id VARCHAR(96) NOT NULL COMMENT '外部充值订单号',
command_id VARCHAR(128) NOT NULL COMMENT 'H5 幂等命令 ID',
target_user_id BIGINT NOT NULL COMMENT '目标用户 ID',
target_region_id BIGINT NOT NULL COMMENT '目标区域 ID',
target_country_code VARCHAR(8) NOT NULL DEFAULT '' COMMENT '目标国家码',
audience_type VARCHAR(32) NOT NULL COMMENT '购买对象',
product_id BIGINT NOT NULL COMMENT '商品 ID',
product_code VARCHAR(128) NOT NULL COMMENT '商品编码快照',
product_name VARCHAR(128) NOT NULL COMMENT '商品名称快照',
coin_amount BIGINT NOT NULL COMMENT '到账金币数量',
usd_minor_amount BIGINT NOT NULL COMMENT 'USD cent 金额快照',
provider_code VARCHAR(32) NOT NULL COMMENT '支付渠道',
payment_method_id BIGINT NOT NULL DEFAULT 0 COMMENT '三方支付方式 ID',
country_code VARCHAR(8) NOT NULL DEFAULT '' COMMENT '支付方式国家码',
currency_code VARCHAR(8) NOT NULL DEFAULT '' COMMENT '支付币种',
provider_amount_minor BIGINT NOT NULL DEFAULT 0 COMMENT '三方支付币种最小单位金额',
pay_way VARCHAR(64) NOT NULL DEFAULT '' COMMENT 'MiFaPay payWay',
pay_type VARCHAR(64) NOT NULL DEFAULT '' COMMENT 'MiFaPay payType',
pay_url VARCHAR(2048) NOT NULL DEFAULT '' COMMENT '三方支付跳转 URL',
provider_order_id VARCHAR(128) NOT NULL DEFAULT '' COMMENT '三方支付订单号',
tx_hash VARCHAR(128) NOT NULL DEFAULT '' COMMENT 'USDT tx hash',
receive_address VARCHAR(128) NOT NULL DEFAULT '' COMMENT 'USDT 收款地址',
status VARCHAR(32) NOT NULL COMMENT '订单状态',
failure_reason VARCHAR(512) NOT NULL DEFAULT '' COMMENT '失败原因',
wallet_transaction_id VARCHAR(96) NOT NULL DEFAULT '' COMMENT '钱包交易 ID',
provider_payload JSON NULL 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, order_id),
UNIQUE KEY uk_external_recharge_orders_command (app_code, command_id),
KEY idx_external_recharge_orders_user_time (app_code, target_user_id, created_at_ms),
KEY idx_external_recharge_orders_provider_order (app_code, provider_code, provider_order_id),
KEY idx_external_recharge_orders_tx (app_code, provider_code, tx_hash)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='H5 外部充值订单表';
INSERT IGNORE INTO third_party_payment_channels (app_code, provider_code, provider_name, status, sort_order, created_at_ms, updated_at_ms)
VALUES ('lalu', 'mifapay', 'MiFaPay', 'active', 10, @now_ms, @now_ms);
-- MiFaPay 是 H5 统一三方支付入口;真实商户号、商户标识、商户私钥、平台公钥只放运行环境配置。
-- 国家和支付方式默认打开,运营可在后台逐项关闭;默认汇率为 1避免新环境缺配置时 H5 完全不可见。
INSERT IGNORE INTO third_party_payment_methods (
app_code, provider_code, country_code, country_name, currency_code, pay_way, pay_type,
method_name, logo_url, status, usd_to_currency_rate, sort_order, created_at_ms, updated_at_ms
) VALUES
('lalu', 'mifapay', 'ID', 'Indonesia', 'IDR', 'BankTransfer', 'BCA', 'BCA Bank', '', 'active', 1, 110, @now_ms, @now_ms),
('lalu', 'mifapay', 'ID', 'Indonesia', 'IDR', 'BankTransfer', 'BNI', 'BNI Bank', '', 'active', 1, 120, @now_ms, @now_ms),
('lalu', 'mifapay', 'ID', 'Indonesia', 'IDR', 'BankTransfer', 'BRI', 'BRI Bank', '', 'active', 1, 130, @now_ms, @now_ms),
('lalu', 'mifapay', 'ID', 'Indonesia', 'IDR', 'BankTransfer', 'Mandiri', 'Mandiri Bank', '', 'active', 1, 140, @now_ms, @now_ms),
('lalu', 'mifapay', 'ID', 'Indonesia', 'IDR', 'BankTransfer', 'Permata', 'Permata Bank', '', 'active', 1, 150, @now_ms, @now_ms),
('lalu', 'mifapay', 'ID', 'Indonesia', 'IDR', 'Ewallet', 'DANA', 'DANA', '', 'active', 1, 160, @now_ms, @now_ms),
('lalu', 'mifapay', 'ID', 'Indonesia', 'IDR', 'Ewallet', 'LinkAja', 'LinkAja', '', 'active', 1, 170, @now_ms, @now_ms),
('lalu', 'mifapay', 'ID', 'Indonesia', 'IDR', 'Ewallet', 'OVO', 'OVO', '', 'active', 1, 180, @now_ms, @now_ms),
('lalu', 'mifapay', 'ID', 'Indonesia', 'IDR', 'Ewallet', 'ShopeePay', 'ShopeePay', '', 'active', 1, 190, @now_ms, @now_ms),
('lalu', 'mifapay', 'ID', 'Indonesia', 'IDR', 'QR', 'QRIS', 'QRIS', '', 'active', 1, 200, @now_ms, @now_ms),
('lalu', 'mifapay', 'VN', 'Vietnam', 'VND', 'BankTransfer', 'VN_BankTransfer', 'Vietnam Bank Transfer', '', 'active', 1, 210, @now_ms, @now_ms),
('lalu', 'mifapay', 'VN', 'Vietnam', 'VND', 'QR', 'VietQR', 'VietQR', '', 'active', 1, 220, @now_ms, @now_ms),
('lalu', 'mifapay', 'BH', 'Bahrain', 'BHD', 'Card', 'CreditCard', 'Bahrain Credit Card', '', 'active', 1, 310, @now_ms, @now_ms),
('lalu', 'mifapay', 'QA', 'Qatar', 'QAR', 'Card', 'CreditCard', 'Qatar Credit Card', '', 'active', 1, 410, @now_ms, @now_ms),
('lalu', 'mifapay', 'OM', 'Oman', 'OMR', 'Card', 'CreditCard', 'Oman Credit Card', '', 'active', 1, 510, @now_ms, @now_ms),
('lalu', 'mifapay', 'AE', 'United Arab Emirates', 'AED', 'Card', 'CreditCard', 'UAE Credit Card', '', 'active', 1, 610, @now_ms, @now_ms),
('lalu', 'mifapay', 'KW', 'Kuwait', 'KWD', 'Card', 'Knet', 'KNET', '', 'active', 1, 710, @now_ms, @now_ms),
('lalu', 'mifapay', 'SA', 'Saudi Arabia', 'SAR', 'Card', 'MADA', 'MADA', '', 'active', 1, 810, @now_ms, @now_ms),
('lalu', 'mifapay', 'SA', 'Saudi Arabia', 'SAR', 'Ewallet', 'ApplePay', 'ApplePay', '', 'active', 1, 820, @now_ms, @now_ms),
('lalu', 'mifapay', 'SA', 'Saudi Arabia', 'SAR', 'Ewallet', 'STCPay', 'STCPay', '', 'active', 1, 830, @now_ms, @now_ms),
('lalu', 'mifapay', 'IN', 'India', 'INR', 'Ewallet', 'BHIM', 'BHIM', '', 'active', 1, 910, @now_ms, @now_ms),
('lalu', 'mifapay', 'IN', 'India', 'INR', 'Ewallet', 'GooglePay', 'GooglePay', '', 'active', 1, 920, @now_ms, @now_ms),
('lalu', 'mifapay', 'IN', 'India', 'INR', 'Ewallet', 'Mobikwik', 'Mobikwik', '', 'active', 1, 930, @now_ms, @now_ms),
('lalu', 'mifapay', 'IN', 'India', 'INR', 'Ewallet', 'Paytm', 'Paytm', '', 'active', 1, 940, @now_ms, @now_ms),
('lalu', 'mifapay', 'IN', 'India', 'INR', 'Ewallet', 'PhonePe', 'PhonePe', '', 'active', 1, 950, @now_ms, @now_ms),
('lalu', 'mifapay', 'IN', 'India', 'INR', 'Ewallet', 'UPI', 'UPI', '', 'active', 1, 960, @now_ms, @now_ms),
('lalu', 'mifapay', 'BD', 'Bangladesh', 'BDT', 'Ewallet', 'bKash', 'bKash', '', 'active', 1, 1010, @now_ms, @now_ms),
('lalu', 'mifapay', 'PK', 'Pakistan', 'PKR', 'Ewallet', 'Easypaisa', 'Easypaisa', '', 'active', 1, 1110, @now_ms, @now_ms),
('lalu', 'mifapay', 'PK', 'Pakistan', 'PKR', 'Ewallet', 'JazzCash', 'JazzCash', '', 'active', 1, 1120, @now_ms, @now_ms),
('lalu', 'mifapay', 'EG', 'Egypt', 'EGP', 'Ewallet', 'FawryPay', 'FawryPay', '', 'active', 1, 1210, @now_ms, @now_ms),
('lalu', 'mifapay', 'EG', 'Egypt', 'EGP', 'Ewallet', 'Meeza', 'Meeza', '', 'active', 1, 1220, @now_ms, @now_ms),
('lalu', 'mifapay', 'EG', 'Egypt', 'EGP', 'Ewallet', 'Orange', 'Orange', '', 'active', 1, 1230, @now_ms, @now_ms),
('lalu', 'mifapay', 'EG', 'Egypt', 'EGP', 'Ewallet', 'Vodafone', 'Vodafone', '', 'active', 1, 1240, @now_ms, @now_ms),
('lalu', 'mifapay', 'PH', 'Philippines', 'PHP', 'Ewallet', 'Gcash', 'GCash', '', 'active', 1, 1310, @now_ms, @now_ms),
('lalu', 'mifapay', 'PH', 'Philippines', 'PHP', 'Ewallet', 'PayMaya', 'PayMaya', '', 'active', 1, 1320, @now_ms, @now_ms),
('lalu', 'mifapay', 'PH', 'Philippines', 'PHP', 'QR', 'QRPH', 'QRPH', '', 'active', 1, 1330, @now_ms, @now_ms);

View File

@ -0,0 +1,93 @@
SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci;
-- 首充奖励从“首笔金币区间”改为“Google 商品档位”:一个用户每个商品档位只能领取一次。
SET @ddl := IF(
(SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'first_recharge_reward_tiers' AND COLUMN_NAME = 'usd_minor_amount') = 0,
'ALTER TABLE first_recharge_reward_tiers ADD COLUMN usd_minor_amount BIGINT NOT NULL DEFAULT 0 COMMENT ''Google 商品美元美分档位'' AFTER max_coin_amount',
'SELECT 1'
);
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @ddl := IF(
(SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'first_recharge_reward_tiers' AND COLUMN_NAME = 'google_product_id') = 0,
'ALTER TABLE first_recharge_reward_tiers ADD COLUMN google_product_id VARCHAR(128) NOT NULL DEFAULT '''' COMMENT ''Google Play 商品 ID'' AFTER usd_minor_amount',
'SELECT 1'
);
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @ddl := IF(
(SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'first_recharge_reward_tiers' AND COLUMN_NAME = 'discount_percent') = 0,
'ALTER TABLE first_recharge_reward_tiers ADD COLUMN discount_percent INT NOT NULL DEFAULT 0 COMMENT ''展示优惠百分比,不参与结算'' AFTER google_product_id',
'SELECT 1'
);
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
UPDATE first_recharge_reward_tiers
SET google_product_id = CONCAT('legacy_', tier_id)
WHERE google_product_id = '';
SET @ddl := IF(
(SELECT COUNT(*) FROM information_schema.STATISTICS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'first_recharge_reward_tiers' AND INDEX_NAME = 'uk_first_recharge_reward_google_product') = 0,
'ALTER TABLE first_recharge_reward_tiers ADD UNIQUE KEY uk_first_recharge_reward_google_product (app_code, google_product_id)',
'SELECT 1'
);
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @ddl := IF(
(SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'first_recharge_reward_claims' AND COLUMN_NAME = 'tier_usd_minor_amount') = 0,
'ALTER TABLE first_recharge_reward_claims ADD COLUMN tier_usd_minor_amount BIGINT NOT NULL DEFAULT 0 COMMENT ''命中档位美元美分'' AFTER resource_group_id',
'SELECT 1'
);
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @ddl := IF(
(SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'first_recharge_reward_claims' AND COLUMN_NAME = 'google_product_id') = 0,
'ALTER TABLE first_recharge_reward_claims ADD COLUMN google_product_id VARCHAR(128) NOT NULL DEFAULT '''' COMMENT ''命中 Google Play 商品 ID'' AFTER tier_usd_minor_amount',
'SELECT 1'
);
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @ddl := IF(
(SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'first_recharge_reward_claims' AND COLUMN_NAME = 'recharge_usd_minor') = 0,
'ALTER TABLE first_recharge_reward_claims ADD COLUMN recharge_usd_minor BIGINT NOT NULL DEFAULT 0 COMMENT ''本次充值美元美分'' AFTER recharge_coin_amount',
'SELECT 1'
);
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @ddl := IF(
(SELECT COUNT(*) FROM information_schema.STATISTICS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'first_recharge_reward_claims' AND INDEX_NAME = 'uk_first_recharge_reward_user_once') > 0,
'ALTER TABLE first_recharge_reward_claims DROP INDEX uk_first_recharge_reward_user_once',
'SELECT 1'
);
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @ddl := IF(
(SELECT COUNT(*) FROM information_schema.STATISTICS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'first_recharge_reward_claims' AND INDEX_NAME = 'uk_first_recharge_reward_user_tier') = 0,
'ALTER TABLE first_recharge_reward_claims ADD UNIQUE KEY uk_first_recharge_reward_user_tier (app_code, user_id, tier_id)',
'SELECT 1'
);
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SELECT
COUNT(*) AS first_recharge_google_tier_count
FROM first_recharge_reward_tiers
WHERE google_product_id <> '';

View File

@ -0,0 +1,107 @@
SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci;
-- MiFaPay 是 H5 统一三方支付入口;迁移只写国家/支付方式清单,真实商户号、商户标识、商户私钥、平台公钥必须放运行环境配置。
-- 所有截图内方式默认打开;已有正数汇率继续保留,避免迁移覆盖运营已经维护过的 USD->本币汇率。
SET @now_ms = CAST(UNIX_TIMESTAMP(UTC_TIMESTAMP(3)) * 1000 AS UNSIGNED);
INSERT INTO third_party_payment_channels (
app_code, provider_code, provider_name, status, sort_order, created_at_ms, updated_at_ms
) VALUES ('lalu', 'mifapay', 'MiFaPay', 'active', 10, @now_ms, @now_ms)
ON DUPLICATE KEY UPDATE
provider_name = VALUES(provider_name),
status = VALUES(status),
sort_order = VALUES(sort_order),
updated_at_ms = VALUES(updated_at_ms);
-- 存量库可能已经写入过早期测试方式;先关闭不在 MiFaPay 当前支持清单内的方式H5 才不会展示未开通入口。
UPDATE third_party_payment_methods
SET status = 'disabled', updated_at_ms = @now_ms
WHERE app_code = 'lalu'
AND provider_code = 'mifapay'
AND NOT (
(country_code = 'ID' AND pay_way = 'BankTransfer' AND pay_type = 'BCA') OR
(country_code = 'ID' AND pay_way = 'BankTransfer' AND pay_type = 'BNI') OR
(country_code = 'ID' AND pay_way = 'BankTransfer' AND pay_type = 'BRI') OR
(country_code = 'ID' AND pay_way = 'BankTransfer' AND pay_type = 'Mandiri') OR
(country_code = 'ID' AND pay_way = 'BankTransfer' AND pay_type = 'Permata') OR
(country_code = 'ID' AND pay_way = 'Ewallet' AND pay_type = 'DANA') OR
(country_code = 'ID' AND pay_way = 'Ewallet' AND pay_type = 'LinkAja') OR
(country_code = 'ID' AND pay_way = 'Ewallet' AND pay_type = 'OVO') OR
(country_code = 'ID' AND pay_way = 'Ewallet' AND pay_type = 'ShopeePay') OR
(country_code = 'ID' AND pay_way = 'QR' AND pay_type = 'QRIS') OR
(country_code = 'VN' AND pay_way = 'BankTransfer' AND pay_type = 'VN_BankTransfer') OR
(country_code = 'VN' AND pay_way = 'QR' AND pay_type = 'VietQR') OR
(country_code = 'BH' AND pay_way = 'Card' AND pay_type = 'CreditCard') OR
(country_code = 'QA' AND pay_way = 'Card' AND pay_type = 'CreditCard') OR
(country_code = 'OM' AND pay_way = 'Card' AND pay_type = 'CreditCard') OR
(country_code = 'AE' AND pay_way = 'Card' AND pay_type = 'CreditCard') OR
(country_code = 'KW' AND pay_way = 'Card' AND pay_type = 'Knet') OR
(country_code = 'SA' AND pay_way = 'Card' AND pay_type = 'MADA') OR
(country_code = 'SA' AND pay_way = 'Ewallet' AND pay_type = 'ApplePay') OR
(country_code = 'SA' AND pay_way = 'Ewallet' AND pay_type = 'STCPay') OR
(country_code = 'IN' AND pay_way = 'Ewallet' AND pay_type = 'BHIM') OR
(country_code = 'IN' AND pay_way = 'Ewallet' AND pay_type = 'GooglePay') OR
(country_code = 'IN' AND pay_way = 'Ewallet' AND pay_type = 'Mobikwik') OR
(country_code = 'IN' AND pay_way = 'Ewallet' AND pay_type = 'Paytm') OR
(country_code = 'IN' AND pay_way = 'Ewallet' AND pay_type = 'PhonePe') OR
(country_code = 'IN' AND pay_way = 'Ewallet' AND pay_type = 'UPI') OR
(country_code = 'BD' AND pay_way = 'Ewallet' AND pay_type = 'bKash') OR
(country_code = 'PK' AND pay_way = 'Ewallet' AND pay_type = 'Easypaisa') OR
(country_code = 'PK' AND pay_way = 'Ewallet' AND pay_type = 'JazzCash') OR
(country_code = 'EG' AND pay_way = 'Ewallet' AND pay_type = 'FawryPay') OR
(country_code = 'EG' AND pay_way = 'Ewallet' AND pay_type = 'Meeza') OR
(country_code = 'EG' AND pay_way = 'Ewallet' AND pay_type = 'Orange') OR
(country_code = 'EG' AND pay_way = 'Ewallet' AND pay_type = 'Vodafone') OR
(country_code = 'PH' AND pay_way = 'Ewallet' AND pay_type = 'Gcash') OR
(country_code = 'PH' AND pay_way = 'Ewallet' AND pay_type = 'PayMaya') OR
(country_code = 'PH' AND pay_way = 'QR' AND pay_type = 'QRPH')
);
INSERT INTO third_party_payment_methods (
app_code, provider_code, country_code, country_name, currency_code, pay_way, pay_type,
method_name, logo_url, status, usd_to_currency_rate, sort_order, created_at_ms, updated_at_ms
) VALUES
('lalu', 'mifapay', 'ID', 'Indonesia', 'IDR', 'BankTransfer', 'BCA', 'BCA Bank', '', 'active', 1, 110, @now_ms, @now_ms),
('lalu', 'mifapay', 'ID', 'Indonesia', 'IDR', 'BankTransfer', 'BNI', 'BNI Bank', '', 'active', 1, 120, @now_ms, @now_ms),
('lalu', 'mifapay', 'ID', 'Indonesia', 'IDR', 'BankTransfer', 'BRI', 'BRI Bank', '', 'active', 1, 130, @now_ms, @now_ms),
('lalu', 'mifapay', 'ID', 'Indonesia', 'IDR', 'BankTransfer', 'Mandiri', 'Mandiri Bank', '', 'active', 1, 140, @now_ms, @now_ms),
('lalu', 'mifapay', 'ID', 'Indonesia', 'IDR', 'BankTransfer', 'Permata', 'Permata Bank', '', 'active', 1, 150, @now_ms, @now_ms),
('lalu', 'mifapay', 'ID', 'Indonesia', 'IDR', 'Ewallet', 'DANA', 'DANA', '', 'active', 1, 160, @now_ms, @now_ms),
('lalu', 'mifapay', 'ID', 'Indonesia', 'IDR', 'Ewallet', 'LinkAja', 'LinkAja', '', 'active', 1, 170, @now_ms, @now_ms),
('lalu', 'mifapay', 'ID', 'Indonesia', 'IDR', 'Ewallet', 'OVO', 'OVO', '', 'active', 1, 180, @now_ms, @now_ms),
('lalu', 'mifapay', 'ID', 'Indonesia', 'IDR', 'Ewallet', 'ShopeePay', 'ShopeePay', '', 'active', 1, 190, @now_ms, @now_ms),
('lalu', 'mifapay', 'ID', 'Indonesia', 'IDR', 'QR', 'QRIS', 'QRIS', '', 'active', 1, 200, @now_ms, @now_ms),
('lalu', 'mifapay', 'VN', 'Vietnam', 'VND', 'BankTransfer', 'VN_BankTransfer', 'Vietnam Bank Transfer', '', 'active', 1, 210, @now_ms, @now_ms),
('lalu', 'mifapay', 'VN', 'Vietnam', 'VND', 'QR', 'VietQR', 'VietQR', '', 'active', 1, 220, @now_ms, @now_ms),
('lalu', 'mifapay', 'BH', 'Bahrain', 'BHD', 'Card', 'CreditCard', 'Bahrain Credit Card', '', 'active', 1, 310, @now_ms, @now_ms),
('lalu', 'mifapay', 'QA', 'Qatar', 'QAR', 'Card', 'CreditCard', 'Qatar Credit Card', '', 'active', 1, 410, @now_ms, @now_ms),
('lalu', 'mifapay', 'OM', 'Oman', 'OMR', 'Card', 'CreditCard', 'Oman Credit Card', '', 'active', 1, 510, @now_ms, @now_ms),
('lalu', 'mifapay', 'AE', 'United Arab Emirates', 'AED', 'Card', 'CreditCard', 'UAE Credit Card', '', 'active', 1, 610, @now_ms, @now_ms),
('lalu', 'mifapay', 'KW', 'Kuwait', 'KWD', 'Card', 'Knet', 'KNET', '', 'active', 1, 710, @now_ms, @now_ms),
('lalu', 'mifapay', 'SA', 'Saudi Arabia', 'SAR', 'Card', 'MADA', 'MADA', '', 'active', 1, 810, @now_ms, @now_ms),
('lalu', 'mifapay', 'SA', 'Saudi Arabia', 'SAR', 'Ewallet', 'ApplePay', 'ApplePay', '', 'active', 1, 820, @now_ms, @now_ms),
('lalu', 'mifapay', 'SA', 'Saudi Arabia', 'SAR', 'Ewallet', 'STCPay', 'STCPay', '', 'active', 1, 830, @now_ms, @now_ms),
('lalu', 'mifapay', 'IN', 'India', 'INR', 'Ewallet', 'BHIM', 'BHIM', '', 'active', 1, 910, @now_ms, @now_ms),
('lalu', 'mifapay', 'IN', 'India', 'INR', 'Ewallet', 'GooglePay', 'GooglePay', '', 'active', 1, 920, @now_ms, @now_ms),
('lalu', 'mifapay', 'IN', 'India', 'INR', 'Ewallet', 'Mobikwik', 'Mobikwik', '', 'active', 1, 930, @now_ms, @now_ms),
('lalu', 'mifapay', 'IN', 'India', 'INR', 'Ewallet', 'Paytm', 'Paytm', '', 'active', 1, 940, @now_ms, @now_ms),
('lalu', 'mifapay', 'IN', 'India', 'INR', 'Ewallet', 'PhonePe', 'PhonePe', '', 'active', 1, 950, @now_ms, @now_ms),
('lalu', 'mifapay', 'IN', 'India', 'INR', 'Ewallet', 'UPI', 'UPI', '', 'active', 1, 960, @now_ms, @now_ms),
('lalu', 'mifapay', 'BD', 'Bangladesh', 'BDT', 'Ewallet', 'bKash', 'bKash', '', 'active', 1, 1010, @now_ms, @now_ms),
('lalu', 'mifapay', 'PK', 'Pakistan', 'PKR', 'Ewallet', 'Easypaisa', 'Easypaisa', '', 'active', 1, 1110, @now_ms, @now_ms),
('lalu', 'mifapay', 'PK', 'Pakistan', 'PKR', 'Ewallet', 'JazzCash', 'JazzCash', '', 'active', 1, 1120, @now_ms, @now_ms),
('lalu', 'mifapay', 'EG', 'Egypt', 'EGP', 'Ewallet', 'FawryPay', 'FawryPay', '', 'active', 1, 1210, @now_ms, @now_ms),
('lalu', 'mifapay', 'EG', 'Egypt', 'EGP', 'Ewallet', 'Meeza', 'Meeza', '', 'active', 1, 1220, @now_ms, @now_ms),
('lalu', 'mifapay', 'EG', 'Egypt', 'EGP', 'Ewallet', 'Orange', 'Orange', '', 'active', 1, 1230, @now_ms, @now_ms),
('lalu', 'mifapay', 'EG', 'Egypt', 'EGP', 'Ewallet', 'Vodafone', 'Vodafone', '', 'active', 1, 1240, @now_ms, @now_ms),
('lalu', 'mifapay', 'PH', 'Philippines', 'PHP', 'Ewallet', 'Gcash', 'GCash', '', 'active', 1, 1310, @now_ms, @now_ms),
('lalu', 'mifapay', 'PH', 'Philippines', 'PHP', 'Ewallet', 'PayMaya', 'PayMaya', '', 'active', 1, 1320, @now_ms, @now_ms),
('lalu', 'mifapay', 'PH', 'Philippines', 'PHP', 'QR', 'QRPH', 'QRPH', '', 'active', 1, 1330, @now_ms, @now_ms)
ON DUPLICATE KEY UPDATE
country_name = VALUES(country_name),
currency_code = VALUES(currency_code),
method_name = VALUES(method_name),
status = VALUES(status),
usd_to_currency_rate = IF(usd_to_currency_rate > 0, usd_to_currency_rate, VALUES(usd_to_currency_rate)),
sort_order = VALUES(sort_order),
updated_at_ms = VALUES(updated_at_ms);

View File

@ -0,0 +1,104 @@
SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci;
USE hyapp_user;
-- BD/Agency/Host 的区域统一由 users 当前区域或用户国家映射区域派生。
-- 这些身份表里的 region_id 是旧快照,先删除依赖索引,再删除列,避免后续继续写入或误读。
SET @ddl := IF(
(SELECT COUNT(*) FROM information_schema.STATISTICS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'host_profiles' AND INDEX_NAME = 'idx_host_profiles_region_status') > 0,
'ALTER TABLE host_profiles DROP INDEX idx_host_profiles_region_status',
'SELECT 1'
);
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @ddl := IF(
(SELECT COUNT(*) FROM information_schema.STATISTICS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'bd_profiles' AND INDEX_NAME = 'idx_bd_profiles_region_role') > 0,
'ALTER TABLE bd_profiles DROP INDEX idx_bd_profiles_region_role',
'SELECT 1'
);
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @ddl := IF(
(SELECT COUNT(*) FROM information_schema.STATISTICS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'bd_leader_profiles' AND INDEX_NAME = 'idx_bd_leader_profiles_region_status') > 0,
'ALTER TABLE bd_leader_profiles DROP INDEX idx_bd_leader_profiles_region_status',
'SELECT 1'
);
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @ddl := IF(
(SELECT COUNT(*) FROM information_schema.STATISTICS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'agencies' AND INDEX_NAME = 'idx_agencies_region_status') > 0,
'ALTER TABLE agencies DROP INDEX idx_agencies_region_status',
'SELECT 1'
);
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @ddl := IF(
(SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'host_profiles' AND COLUMN_NAME = 'region_id') > 0,
'ALTER TABLE host_profiles DROP COLUMN region_id',
'SELECT 1'
);
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @ddl := IF(
(SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'bd_profiles' AND COLUMN_NAME = 'region_id') > 0,
'ALTER TABLE bd_profiles DROP COLUMN region_id',
'SELECT 1'
);
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @ddl := IF(
(SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'bd_leader_profiles' AND COLUMN_NAME = 'region_id') > 0,
'ALTER TABLE bd_leader_profiles DROP COLUMN region_id',
'SELECT 1'
);
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @ddl := IF(
(SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'agencies' AND COLUMN_NAME = 'region_id') > 0,
'ALTER TABLE agencies DROP COLUMN region_id',
'SELECT 1'
);
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @ddl := IF(
(SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'agency_memberships' AND COLUMN_NAME = 'region_id') > 0,
'ALTER TABLE agency_memberships DROP COLUMN region_id',
'SELECT 1'
);
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @ddl := IF(
(SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'agency_applications' AND COLUMN_NAME = 'region_id') > 0,
'ALTER TABLE agency_applications DROP COLUMN region_id',
'SELECT 1'
);
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @ddl := IF(
(SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'role_invitations' AND COLUMN_NAME = 'region_id') > 0,
'ALTER TABLE role_invitations DROP COLUMN region_id',
'SELECT 1'
);
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;

View File

@ -0,0 +1,16 @@
CREATE TABLE IF NOT EXISTS game_explore_winners (
app_code VARCHAR(32) NOT NULL COMMENT '应用编码,用于多租户隔离',
win_id VARCHAR(96) NOT NULL COMMENT 'Explore Square 播报 ID',
game_code VARCHAR(64) NOT NULL COMMENT 'App H5 入口 key例如 game_dice/game_rock',
game_id VARCHAR(96) NOT NULL COMMENT '来源游戏 ID',
user_id BIGINT NOT NULL COMMENT '获胜用户 ID',
display_name VARCHAR(128) NOT NULL DEFAULT '' COMMENT '展示昵称快照',
avatar_url VARCHAR(512) NOT NULL DEFAULT '' COMMENT '展示头像快照',
coin_amount BIGINT NOT NULL COMMENT '获胜金币数',
won_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, win_id),
KEY idx_game_explore_winners_time(app_code, won_at_ms, win_id),
KEY idx_game_explore_winners_game(app_code, game_code, won_at_ms)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='Explore Square 游戏获胜播报表';

View File

@ -85,7 +85,7 @@ go run ./cmd/server -config configs/config.yaml -bootstrap
5. 新增任务类型时需要补齐创建接口、runner handler、重试/失败结果和产物策略。
6. 区域创建和区域启用/禁用是后台管理能力,实现在 `hyapp-admin-server`,不要为此新增 user-service RPC 或把后台开关逻辑下沉到 app 后端服务。
7. 国家创建和国家启用/禁用是后台管理后端能力,实现在 `hyapp-admin-server`,不要为这些后台动作新增 user-service RPC 或 user-service 业务逻辑。
8. 后台创建 BD Leader 必须传后台选择的 `regionId`admin-server 负责权限和审计user-service 在关系创建事务里更新目标用户 `users.region_id`,不要在 admin-server 里用 GetUser 做“用户原区域必须一致”的拒绝
8. 后台创建 BD Leader 按目标用户当前 `users.region_id` 创建admin-server 只负责权限和审计,不传 `regionId`user-service 在关系创建事务里读取目标用户区域并写入 `bd_leader_profiles.region_id`,不要在创建 BD Leader 时顺手修改用户区域
9. `hyapp.local/api` 是唯一允许直接依赖的 app 后端契约 module不要 import `hyapp/services/*``hyapp/internal/*``hyapp/pkg/*`
## 验证

View File

@ -43,11 +43,13 @@ import (
hostagencypolicymodule "hyapp-admin-server/internal/modules/hostagencypolicy"
hostorgmodule "hyapp-admin-server/internal/modules/hostorg"
hostsalarysettlementmodule "hyapp-admin-server/internal/modules/hostsalarysettlement"
inviteactivityrewardmodule "hyapp-admin-server/internal/modules/inviteactivityreward"
jobmodule "hyapp-admin-server/internal/modules/job"
levelconfigmodule "hyapp-admin-server/internal/modules/levelconfig"
luckygiftmodule "hyapp-admin-server/internal/modules/luckygift"
menumodule "hyapp-admin-server/internal/modules/menu"
paymentmodule "hyapp-admin-server/internal/modules/payment"
prettyidmodule "hyapp-admin-server/internal/modules/prettyid"
rbacmodule "hyapp-admin-server/internal/modules/rbac"
redpacketmodule "hyapp-admin-server/internal/modules/redpacket"
regionblockmodule "hyapp-admin-server/internal/modules/regionblock"
@ -222,31 +224,38 @@ func main() {
objectUploader = uploader
}
handlers := router.Handlers{
Audit: auditHandler,
Auth: authmodule.New(store, auth, auditHandler, cfg),
AdminUser: adminusermodule.New(store, cfg, auditHandler),
AchievementConfig: achievementconfigmodule.New(activityclient.NewGRPC(activityConn), auditHandler),
AppConfig: appconfigmodule.New(store, auditHandler),
AppRegistry: appregistrymodule.New(userDB),
AppUser: appusermodule.New(userclient.NewGRPC(userConn), activityclient.NewGRPC(activityConn), sqlDB, userDB, walletDB, cfg, auditHandler),
CoinLedger: coinledgermodule.New(userDB, walletDB, sqlDB, walletclient.NewGRPC(walletConn), auditHandler),
CountryRegion: countryregionmodule.New(userclient.NewGRPC(userConn), userDB, cfg, auditHandler),
CPRelation: cprelationmodule.New(userDB, auditHandler),
CumulativeRecharge: cumulativerechargerewardmodule.New(activityclient.NewGRPC(activityConn), userDB, auditHandler),
DailyTask: dailytaskmodule.New(activityclient.NewGRPC(activityConn), auditHandler),
Dashboard: dashboardmodule.New(store, cfg),
FirstRechargeReward: firstrechargerewardmodule.New(activityclient.NewGRPC(activityConn), userDB, auditHandler),
Game: gamemanagementmodule.New(gameclient.NewGRPC(gameConn), auditHandler),
Audit: auditHandler,
Auth: authmodule.New(store, auth, auditHandler, cfg),
AdminUser: adminusermodule.New(store, cfg, auditHandler),
AchievementConfig: achievementconfigmodule.New(activityclient.NewGRPC(activityConn), auditHandler),
AppConfig: appconfigmodule.New(store, auditHandler),
AppRegistry: appregistrymodule.New(userDB),
AppUser: appusermodule.New(userclient.NewGRPC(userConn), activityclient.NewGRPC(activityConn), sqlDB, userDB, walletDB, cfg, auditHandler),
CoinLedger: coinledgermodule.New(userDB, walletDB, sqlDB, walletclient.NewGRPC(walletConn), auditHandler),
CountryRegion: countryregionmodule.New(userclient.NewGRPC(userConn), userDB, cfg, auditHandler),
CPRelation: cprelationmodule.New(userDB, auditHandler),
CumulativeRecharge: cumulativerechargerewardmodule.New(activityclient.NewGRPC(activityConn), userDB, auditHandler),
DailyTask: dailytaskmodule.New(activityclient.NewGRPC(activityConn), auditHandler),
Dashboard: dashboardmodule.New(store, cfg),
FirstRechargeReward: firstrechargerewardmodule.New(activityclient.NewGRPC(activityConn), userDB, auditHandler),
Game: gamemanagementmodule.New(
gameclient.NewGRPC(gameConn),
userclient.NewGRPC(userConn),
auditHandler,
gamemanagementmodule.WithRobotAvatarUploader(objectUploader, cfg.TencentCOS.ObjectPrefix),
),
GiftDiamond: giftdiamondmodule.New(walletDB, auditHandler),
Health: healthmodule.New(store, redisClient, jobStatus),
HostAgencyPolicy: hostagencypolicymodule.New(store, walletDB, auditHandler),
HostOrg: hostorgmodule.New(userclient.NewGRPC(userConn), walletclient.NewGRPC(walletConn), userDB, walletDB, sqlDB, auditHandler),
HostSalarySettlement: hostsalarysettlementmodule.New(walletDB, userDB),
InviteActivityReward: inviteactivityrewardmodule.New(activityclient.NewGRPC(activityConn), userDB, auditHandler),
Job: jobmodule.New(store, cfg, auditHandler),
LevelConfig: levelconfigmodule.New(activityclient.NewGRPC(activityConn), walletclient.NewGRPC(walletConn), auditHandler),
LuckyGift: luckygiftmodule.New(activityclient.NewGRPC(activityConn), cfg.ActivityService.RequestTimeout, auditHandler),
Menu: menumodule.New(store, auditHandler),
Payment: paymentmodule.New(walletclient.NewGRPC(walletConn), userDB, auditHandler),
PrettyID: prettyidmodule.New(userclient.NewGRPC(userConn), auditHandler),
RBAC: rbacmodule.New(store, auditHandler),
RedPacket: redpacketmodule.New(walletclient.NewGRPC(walletConn), auditHandler),
Report: reportmodule.New(userDB, roomClient),

View File

@ -292,6 +292,7 @@
| `gift:create` | `button` | 从资源库选择资源新增礼物 |
| `gift:update` | `button` | 编辑礼物、资源绑定和价格 |
| `gift:status` | `button` | 上架、下架 |
| `gift:delete` | `button` | 删除礼物列表配置 |
| `activity:view` | `menu` | 活动列表 |
| `activity:create` | `button` | 新增活动 |
| `activity:update` | `button` | 编辑活动 |

View File

@ -0,0 +1,87 @@
# 猜拳后台管理技术文档
本文只记录本阶段已接入的房内猜拳后台接口。独立猜拳本阶段不接入后台。
## 房内猜拳配置
### 查询配置
- 地址:`GET /admin/game/room-rps/config`
- 权限:`game:view`
- 参数:无
- 返回值:
- `config.status``active` / `disabled`
- `config.challengeTimeoutMs`:发起后无人应战的超时时间,毫秒
- `config.revealCountdownMs`:双方出拳后的揭晓倒计时,毫秒
- `config.stakeGifts`4 个礼物下注档位
- 相关 IM
### 更新配置
- 地址:`PATCH /admin/game/room-rps/config`
- 权限:`game:update`
- 参数:
- `status``active` / `disabled`
- `challengeTimeoutMs`:默认 `600000`
- `revealCountdownMs`:默认 `3000`
- `stakeGifts`:必须正好 4 档
- `stakeGifts[].giftId`:礼物 ID
- `stakeGifts[].enabled`:是否启用
- `stakeGifts[].sortOrder`:排序
- 返回值:更新后的 `config`
- 相关 IM
## 房内猜拳订单
### 订单列表
- 地址:`GET /admin/game/room-rps/challenges`
- 权限:`game:view`
- 参数:
- `roomId` / `room_id`:房间 ID可选
- `status`:订单状态,可选
- `initiatorUserId` / `initiator_user_id`:发起人,可选
- `challengerUserId` / `challenger_user_id`:应战人,可选
- `startTimeMs` / `start_time_ms`:开始时间,可选
- `endTimeMs` / `end_time_ms`:结束时间,可选
- `pageSize` / `page_size`:默认 50
- `cursor`:下一页游标
- 返回值:
- `items`:订单列表
- `nextCursor`:下一页游标
- `pageSize`:本次页大小
- 相关 IM
### 订单详情
- 地址:`GET /admin/game/room-rps/challenges/:challenge_id`
- 权限:`game:view`
- 参数:
- `challenge_id`:挑战单 ID
- 返回值:
- `challenge.status``pending` / `matched` / `revealing` / `finished` / `timeout` / `settlement_failed` / `cancelled`
- `challenge.initiator`:发起人手势和结果
- `challenge.challenger`:应战人手势和结果
- `challenge.settlementStatus`:结算状态
- `challenge.failureReason`:结算失败原因
- 相关 IM
### 重试结算
- 地址:`POST /admin/game/room-rps/challenges/:challenge_id/retry-settlement`
- 权限:`game:update`
- 参数:
- `challenge_id`:挑战单 ID
- 返回值:重试后的 `challenge`
- 限制:只允许 `settlement_failed`
- 相关 IM成功后由 game-service 按订单状态决定是否补发房间 IM
### 手动过期
- 地址:`POST /admin/game/room-rps/challenges/:challenge_id/expire`
- 权限:`game:update`
- 参数:
- `challenge_id`:挑战单 ID
- 返回值:过期后的 `challenge`
- 限制:只允许未被应战的 `pending`
- 相关 IM成功后由 game-service 按订单状态决定是否补发房间 IM

View File

@ -0,0 +1,637 @@
# 靓号管理服务端技术文档
## 当前结论
靓号管理不能只做成后台页面配置。当前仓库里 `user-service` 已经是 `user_id``display_user_id` 和临时靓号租约的 owner`server/admin` 只能做后台 HTTP 入口、权限、审计和管理适配App 入口继续由 `gateway-service` 暴露。
本需求新增的是“靓号池申请 + 后台发放靓号 + 资料返回靓号字段”,要在现有 `display_user_id` 体系上扩展,不重新引入一套用户 ID。
## 已有事实
- `users.user_id` 是内部不可变用户主键。
- `users.current_display_user_id` 是当前对外展示短号。
- `users.current_display_user_id_kind` 当前支持 `default` / `pretty`
- `users.current_display_user_id_expires_at_ms``0` 或空时表示当前展示号不自动过期。
- `user_display_user_ids` 已保存默认短号和靓号的实时归属。
- `pretty_display_user_id_leases` 已保存靓号覆盖关系。
- 当前默认短号和旧自助靓号校验是 5 到 11 位、不能 0 开头的纯数字。
- 本需求的靓号池批量生成默认仍可使用数字规则;后台发放靓号不能复用纯数字限制,需要支持阿拉伯语、英文和数字组合。
- 当前 App 端已有 `/api/v1/users/me/display-id/pretty/apply`,但参数是 `pretty_display_user_id + lease_duration_ms + payment_receipt_id`,不是靓号池申请要的 `pretty_id`
## 目标
- 后台在“用户管理”下新增二级菜单“靓号管理”。
- 后台可以配置靓号池的等级轨道、等级区间、生成规则和生成数量。
- 后台批量生成靓号时,不能和当前库里的 `users.user_id`、默认短号、当前短号、历史有效短号、已有靓号池号码冲突。
- 每个靓号池号码都有一个稳定 `pretty_id`App 靓号池申请时只传 `pretty_id`
- 靓号池申请默认长期持有;用户已有靓号时,新靓号直接覆盖旧靓号,旧靓号释放。
- 后台发放靓号可以直接输入靓号内容,可以填写有效期;不填有效期时默认长期持有。
- 后台发放靓号内容不限定纯数字,需要支持阿拉伯语、英文和数字组合。
- App 用户申请靓号池号码时,服务端根据靓号池配置校验用户是否在对应等级区间内,在区间内才允许占用。
- 用户拥有靓号后,个人信息、个人资料卡、房间个人资料卡、房间详情等资料返回都带当前靓号字段。
## 非目标
- 不把靓号作为内部主键使用,所有业务关系仍然使用 `user_id`
- 不让 `room-service` 保存靓号状态。房间只保存房间状态和用户 `user_id`,展示资料由 gateway 汇总用户资料。
- 不让 `server/admin` 直接 import app 后端 `services/*``pkg/*`
- 首版不做等级下降后的自动回收。用户申请时校验等级区间;后续如果需要降级回收,再新增独立回收规则和定时任务。
- 后台发放不需要等级校验,管理员只受后台权限和审计约束。
- 首版不对外推送 IM。靓号申请成功后客户端以接口返回和下次资料刷新为准。
## 服务边界
| 模块 | 责任 |
| --- | --- |
| `user-service` | 靓号池、靓号项、后台发放、替换释放事务、短号唯一性、当前展示号覆盖、资料字段 |
| `activity-service` | 提供用户等级展示投影,至少能按 `user_id` 返回 `wealth/game/charm` 当前等级 |
| `gateway-service` | App HTTP 入口,按 token 用户申请靓号,返回统一 envelope |
| `server/admin` | 后台 HTTP API、菜单、权限、审计调用 `hyapp.local/api` protobuf client |
| `hyapp-admin-platform` | 用户管理下新增“靓号管理”页面,消费后台 API |
## 等级区间规则
当前等级体系有三条轨道:
| track | 含义 |
| --- | --- |
| `wealth` | 财富等级 |
| `game` | 游戏等级 |
| `charm` | 魅力等级 |
靓号池配置必须保存 `level_track`,不能在服务端猜默认轨道。后台页面可以默认选中 `wealth`,但请求和数据库必须明确保存轨道。
靓号池申请时:
1. 根据 `pretty_id` 锁定靓号项和所属靓号池。
2. 读取用户当前等级,使用池子的 `level_track`
3. 判断 `min_level <= current_level <= max_level`
4. 通过后进入靓号占用事务。
## 数据库设计
新增表归属 `hyapp_user`,因为靓号和短号唯一性属于 `user-service`
### 现有短号表扩展
后台发放的靓号不一定是数字,现有短号相关字段需要从数字短号能力扩展成“展示号字符串”能力:
```sql
ALTER TABLE users
MODIFY COLUMN current_display_user_id VARCHAR(64) NOT NULL,
MODIFY COLUMN default_display_user_id VARCHAR(64) NOT NULL;
ALTER TABLE user_display_user_ids
MODIFY COLUMN display_user_id VARCHAR(64) NOT NULL,
MODIFY COLUMN live_display_user_id VARCHAR(64)
GENERATED ALWAYS AS (
CASE
WHEN status IN ('active', 'held', 'reserved', 'blocked') THEN display_user_id
ELSE NULL
END
) STORED;
ALTER TABLE pretty_display_user_id_leases
MODIFY COLUMN display_user_id VARCHAR(64) NOT NULL,
MODIFY COLUMN active_display_user_id VARCHAR(64)
GENERATED ALWAYS AS (
CASE WHEN status = 'active' THEN display_user_id ELSE NULL END
) STORED,
ADD COLUMN released_at_ms BIGINT NOT NULL DEFAULT 0 AFTER expires_at_ms,
ADD COLUMN release_reason VARCHAR(64) NOT NULL DEFAULT '' AFTER released_at_ms;
ALTER TABLE display_user_id_change_logs
MODIFY COLUMN old_display_user_id VARCHAR(64) NOT NULL,
MODIFY COLUMN new_display_user_id VARCHAR(64) NOT NULL;
```
规则:
- 默认短号仍然按现有数字规则生成。
- 靓号池批量生成默认仍然按数字规则生成。
- 后台发放靓号允许 1 到 64 个字符。
- 后台发放靓号允许 Unicode 字母、Unicode 数字和组合标记,用于支持阿拉伯语、英文和数字。
- 后台发放靓号不允许空白字符、控制字符、emoji 和标点符号。
- 唯一性仍按 `app_code + display_user_id` 判断;如果产品要求大小写敏感,相关唯一列需要改成 `utf8mb4_bin` 或在写入前做明确大小写归一。
### `pretty_display_id_pools`
保存后台配置的等级区间和默认生成规则。
```sql
CREATE TABLE pretty_display_id_pools (
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu',
pool_id VARCHAR(64) NOT NULL PRIMARY KEY,
name VARCHAR(128) NOT NULL,
level_track VARCHAR(32) NOT NULL,
min_level INT NOT NULL,
max_level INT NOT NULL,
rule_type VARCHAR(32) NOT NULL,
rule_config_json JSON NULL,
status VARCHAR(32) NOT NULL,
sort_order INT NOT NULL DEFAULT 0,
created_by_admin_id BIGINT NOT NULL DEFAULT 0,
updated_by_admin_id BIGINT NOT NULL DEFAULT 0,
created_at_ms BIGINT NOT NULL,
updated_at_ms BIGINT NOT NULL,
UNIQUE KEY uk_pretty_pool_range_rule (app_code, level_track, min_level, max_level, name),
KEY idx_pretty_pool_status (app_code, status, sort_order)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='靓号池配置表';
```
字段说明:
| 字段 | 说明 |
| --- | --- |
| `pool_id` | 靓号池 ID |
| `level_track` | `wealth` / `game` / `charm` |
| `min_level` | 可申请最小等级,包含 |
| `max_level` | 可申请最大等级,包含 |
| `rule_type` | 生成规则,例如 `aabbcc` |
| `rule_config_json` | 长度、号段、排除数字等扩展配置 |
| `status` | `active` / `disabled` |
约束:
- `min_level` 不能大于 `max_level`
- `level_track` 必须是已有等级轨道。
- `active` 池才允许 App 用户申请。
### `pretty_display_ids`
保存每一个可申请靓号。每个靓号都有自己的 `pretty_id`
```sql
CREATE TABLE pretty_display_ids (
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu',
pretty_id VARCHAR(64) NOT NULL PRIMARY KEY,
pool_id VARCHAR(64) NOT NULL DEFAULT '',
source VARCHAR(32) NOT NULL DEFAULT 'pool',
display_user_id VARCHAR(64) NOT NULL,
status VARCHAR(32) NOT NULL,
assigned_user_id BIGINT NOT NULL DEFAULT 0,
assigned_lease_id VARCHAR(64) NOT NULL DEFAULT '',
assigned_at_ms BIGINT NOT NULL DEFAULT 0,
released_at_ms BIGINT NOT NULL DEFAULT 0,
release_reason VARCHAR(64) NOT NULL DEFAULT '',
generated_batch_id VARCHAR(64) NOT NULL DEFAULT '',
created_by_admin_id BIGINT NOT NULL DEFAULT 0,
created_at_ms BIGINT NOT NULL,
updated_at_ms BIGINT NOT NULL,
live_display_user_id VARCHAR(64)
GENERATED ALWAYS AS (
CASE WHEN status IN ('available', 'assigned', 'disabled', 'reserved') THEN display_user_id ELSE NULL END
) STORED,
UNIQUE KEY uk_pretty_display_ids_live (app_code, live_display_user_id),
KEY idx_pretty_display_ids_pool_status (app_code, pool_id, status),
KEY idx_pretty_display_ids_assigned_user (app_code, assigned_user_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='靓号池号码表';
```
状态说明:
| status | 说明 |
| --- | --- |
| `available` | 可申请 |
| `assigned` | 已被用户占用 |
| `disabled` | 后台下架,不允许申请 |
| `reserved` | 系统预留,不允许普通用户申请 |
| `released` | 曾经发放或占用,已释放,不参与可申请列表 |
| `deleted` | 软删除,只保留审计 |
`source` 说明:
| source | 说明 |
| --- | --- |
| `pool` | 靓号池生成,释放后可以回到 `available` |
| `admin_grant` | 后台直接发放,释放后默认进入 `released` |
### `pretty_display_id_generation_batches`
保存批量生成记录,方便后台展示和排查。
```sql
CREATE TABLE pretty_display_id_generation_batches (
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu',
batch_id VARCHAR(64) NOT NULL PRIMARY KEY,
pool_id VARCHAR(64) NOT NULL,
rule_type VARCHAR(32) NOT NULL,
rule_config_json JSON NULL,
requested_count INT NOT NULL,
generated_count INT NOT NULL,
skipped_conflict_count INT NOT NULL,
status VARCHAR(32) NOT NULL,
operator_admin_id BIGINT NOT NULL,
request_id VARCHAR(64) NOT NULL DEFAULT '',
created_at_ms BIGINT NOT NULL,
updated_at_ms BIGINT NOT NULL,
KEY idx_pretty_batch_pool (app_code, pool_id, created_at_ms)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='靓号批量生成记录表';
```
## 靓号池生成规则
靓号池批量生成支持数字规则、日期规则、寓意号和自定义规则。数字规则允许前导 0号码以字符串保存。
| rule_type | 说明 |
| --- | --- |
| `aa``aaa``aaaa``aaaaa``aaaaaa``aaaaaaa` | 2A 到 7A 连号 |
| `aabb` | 两组对子 |
| `aabbcc` | 三组重复数字 |
| `aabbccdd` | 四组对子 |
| `abab``ababab` | 交替号 |
| `abba``abcba``abccba` | 回文号 |
| `aaab``aaaab``aaaaab` | 拖一号 |
| `aaabbb``aaaabbbb` | 双组号 |
| `abc``abcd``abcde``abcdef` | 顺子号 |
| `cba``dcba``edcba``fedcba` | 倒顺号 |
| `abcabc` | 顺子重复号 |
| `date_yyyymmdd``date_yymmdd` | 日期号 |
| `love` | 520、521、1314 等寓意号 |
| `custom_values` | 使用 `rule_config_json.values` 固定候选 |
| `custom_pattern` | 使用 `rule_config_json.pattern` 自定义模式 |
`rule_config_json` 可选字段:
| 字段 | 说明 |
| --- | --- |
| `digits` | 限制可用数字,例如 `["6","8","9"]` |
| `exclude_digits` | 排除数字,例如 `["4"]` |
| `prefix``suffix` | 给生成候选加前缀或后缀 |
| `values` | `custom_values` 的候选数组 |
| `pattern` | `custom_pattern` 的模式,例如 `AAAABBBB` |
| `year_from``year_to` | 日期号年份范围 |
| `start_date``end_date` | 日期号精确范围,格式 `yyyy-mm-dd` |
后台直接发放靓号不受本节数字生成规则限制,按“现有短号表扩展”里的后台发放字符规则校验。
## 批量生成事务
批量生成时由 `user-service` 完成冲突检查和入库。
冲突范围:
- `CAST(users.user_id AS CHAR)`
- `users.default_display_user_id`
- `users.current_display_user_id`
- `user_display_user_ids``status IN ('active','held','reserved','blocked')` 的号码。
- `pretty_display_ids``status IN ('available','assigned','disabled','reserved')` 的号码。
流程:
1. 后台提交 `pool_id + rule_type + rule_config + count`
2. `user-service` 生成候选号码。
3. 每个候选号码先做数字短号格式校验。
4. 批量查询冲突号码,过滤已冲突项。
5. 插入 `pretty_display_ids`,唯一键兜底处理并发冲突。
6. 写 `pretty_display_id_generation_batches`
7. 返回本次生成数量和跳过冲突数量。
为了避免生成器死循环,服务端需要设置最大尝试次数,例如 `count * 20`,仍不足时返回实际生成数量和 `skipped_conflict_count`
## 通用替换释放事务
靓号池申请和后台发放都复用同一个“替换当前靓号”事务。
如果用户没有 active pretty 展示号:
1. 默认短号从 `active` 改为 `held`
2. 新靓号写入 `pretty_display_user_id_leases`
3. 新靓号写入 `user_display_user_ids``display_user_id_kind='pretty'`
4. 更新 `users.current_display_user_id``current_display_user_id_kind``current_display_user_id_expires_at_ms`
5. 写 `display_user_id_change_logs`
如果用户已有 active pretty 展示号:
1. 锁定当前用户行和旧 active lease。
2. 旧 `pretty_display_user_id_leases.status` 改为 `cancelled``release_reason='replaced_by_new_pretty'`
3. 旧 `user_display_user_ids.status` 改为 `released`,记录 `released_at_ms`
4. 如果旧靓号来自靓号池,旧 `pretty_display_ids.status` 改回 `available`,清空 `assigned_user_id``assigned_lease_id``assigned_at_ms`
5. 如果旧靓号来自后台发放,旧 `pretty_display_ids.status` 改为 `released`,保留历史。
6. 写旧靓号释放日志。
7. 再按新靓号写入当前 active 展示号。
这个规则意味着:用户申请或被后台发放新靓号时,不返回“已有靓号”冲突,而是直接覆盖旧靓号。
## 靓号池申请事务
App 靓号池申请只接受 `pretty_id`,不接受客户端自报号码、等级、用户 ID 或租期。
事务流程:
1. 从 access token 读取当前 `user_id`
2. `SELECT ... FOR UPDATE` 锁定 `pretty_display_ids`
3. 校验靓号项为 `available``source='pool'`
4. 锁定所属 `pretty_display_id_pools`,校验为 `active`
5. 调用 `activity-service` 读取当前用户在 `level_track` 下的等级。
6. 校验用户等级在 `[min_level, max_level]`
7. 进入通用替换释放事务。
8. 写新 `pretty_display_user_id_leases``source='level_pool'``expires_at_ms=0`
9. 更新新 `pretty_display_ids.status='assigned'`,写入 `assigned_user_id``assigned_lease_id`
10. 返回当前身份投影。
靓号池申请默认长期持有:
- `pretty_display_user_id_leases.expires_at_ms = 0`
- `users.current_display_user_id_expires_at_ms = 0`
- 不由定时任务自动过期。
## 后台发放事务
后台发放不依赖靓号池,也不校验用户等级。
输入:
- `target_user_id`:目标 App 用户。
- `display_user_id`:管理员输入的靓号内容。
- `duration_ms`:可选;大于 0 表示限时靓号,不传或传 0 表示长期。
- `reason`:发放原因,写入审计和 change log。
事务流程:
1. 校验管理员有 `pretty-id:grant` 权限。
2. 校验 `display_user_id` 符合后台发放字符规则。
3. 检查 `display_user_id` 不与当前有效 `users.user_id` 字符串、默认短号、当前短号、历史有效短号、已有靓号冲突。
4. 锁定目标用户行。
5. 进入通用替换释放事务。
6. 生成新的 `pretty_id``lease_id`
7. 写 `pretty_display_ids``source='admin_grant'``status='assigned'`
8. 写 `pretty_display_user_id_leases``source='admin_grant'`
9. 如果 `duration_ms > 0``expires_at_ms = now_ms + duration_ms`;否则 `expires_at_ms = 0`
10. 更新 `users.current_display_user_id_expires_at_ms` 为同一个过期时间。
11. 返回当前身份投影。
限时后台靓号:
- `expires_at_ms > 0` 时继续复用现有靓号过期恢复逻辑。
- 到期后恢复默认短号,不回到靓号池。
- 如果到期前又发放或申请了新靓号,旧限时靓号按通用替换释放事务取消。
## Proto 变更
`api/proto/user/v1/user.proto` 中扩展或新增 identity 管理 RPC。
新增 App 申请 RPC
```proto
message ApplyPrettyDisplayIDFromPoolRequest {
RequestMeta meta = 1;
int64 user_id = 2;
string pretty_id = 3;
}
message ApplyPrettyDisplayIDFromPoolResponse {
UserIdentity identity = 1;
string pretty_id = 2;
string lease_id = 3;
}
```
新增后台管理 RPC
```proto
message AdminGrantPrettyDisplayIDRequest {
RequestMeta meta = 1;
int64 target_user_id = 2;
string display_user_id = 3;
int64 duration_ms = 4;
string reason = 5;
int64 operator_admin_id = 6;
}
message AdminGrantPrettyDisplayIDResponse {
UserIdentity identity = 1;
string pretty_id = 2;
string lease_id = 3;
}
service UserPrettyDisplayIDAdminService {
rpc ListPrettyDisplayIDPools(ListPrettyDisplayIDPoolsRequest) returns (ListPrettyDisplayIDPoolsResponse);
rpc CreatePrettyDisplayIDPool(CreatePrettyDisplayIDPoolRequest) returns (PrettyDisplayIDPoolResponse);
rpc UpdatePrettyDisplayIDPool(UpdatePrettyDisplayIDPoolRequest) returns (PrettyDisplayIDPoolResponse);
rpc GeneratePrettyDisplayIDs(GeneratePrettyDisplayIDsRequest) returns (GeneratePrettyDisplayIDsResponse);
rpc ListPrettyDisplayIDs(ListPrettyDisplayIDsRequest) returns (ListPrettyDisplayIDsResponse);
rpc SetPrettyDisplayIDStatus(SetPrettyDisplayIDStatusRequest) returns (PrettyDisplayIDResponse);
rpc AdminGrantPrettyDisplayID(AdminGrantPrettyDisplayIDRequest) returns (AdminGrantPrettyDisplayIDResponse);
}
```
资料投影建议增加字段:
```proto
message User {
// existing fields...
string pretty_id = 24;
string pretty_display_user_id = 25;
}
message UserIdentity {
// existing fields...
string pretty_id = 8;
string pretty_display_user_id = 9;
}
```
字段规则:
| 字段 | 说明 |
| --- | --- |
| `display_user_id` | 当前对外展示号;有靓号时等于靓号 |
| `display_user_id_kind` | `default` / `pretty` |
| `display_user_id_expires_at_ms` | 长期持有为 `0` |
| `pretty_id` | 当前占用的靓号池记录 ID没有则为空 |
| `pretty_display_user_id` | 当前占用的靓号号码,没有则为空 |
## 后台 HTTP 接口
后台接口归属 `server/admin/internal/modules/prettyid`,路径放在用户管理域。
### 靓号池列表
| 项 | 内容 |
| --- | --- |
| 地址 | `GET /api/v1/admin/users/pretty-id-pools` |
| 参数 | `status``level_track``page``page_size` |
| 返回值 | `items[]``total`,每项包含 `pool_id``name``level_track``min_level``max_level``rule_type``status` |
| 相关 IM | 无 |
### 创建靓号池
| 项 | 内容 |
| --- | --- |
| 地址 | `POST /api/v1/admin/users/pretty-id-pools` |
| 参数 | `name``level_track``min_level``max_level``rule_type``rule_config_json``status` |
| 返回值 | 靓号池详情 |
| 相关 IM | 无 |
### 更新靓号池
| 项 | 内容 |
| --- | --- |
| 地址 | `PUT /api/v1/admin/users/pretty-id-pools/{pool_id}` |
| 参数 | `name``level_track``min_level``max_level``rule_type``rule_config_json``status` |
| 返回值 | 靓号池详情 |
| 相关 IM | 无 |
### 批量生成靓号
| 项 | 内容 |
| --- | --- |
| 地址 | `POST /api/v1/admin/users/pretty-id-pools/{pool_id}/generate` |
| 参数 | `rule_type``rule_config_json``count` |
| 返回值 | `batch_id``requested_count``generated_count``skipped_conflict_count` |
| 相关 IM | 无 |
### 靓号列表
| 项 | 内容 |
| --- | --- |
| 地址 | `GET /api/v1/admin/users/pretty-ids` |
| 参数 | `pool_id``source``status``keyword``assigned_user_id``page``page_size` |
| 返回值 | `items[]``total`,每项包含 `pretty_id``source``display_user_id``pool``status``assigned_user_id``assigned_lease_id` |
| 相关 IM | 无 |
### 后台发放靓号
| 项 | 内容 |
| --- | --- |
| 地址 | `POST /api/v1/admin/users/pretty-ids/grant` |
| 参数 | `target_user_id``display_user_id``duration_ms``reason``duration_ms` 不传或为 `0` 表示长期 |
| 返回值 | `user_id``display_user_id``display_user_id_kind``display_user_id_expires_at_ms``pretty_id``pretty_display_user_id``lease_id` |
| 相关 IM | 无 |
### 靓号状态修改
| 项 | 内容 |
| --- | --- |
| 地址 | `POST /api/v1/admin/users/pretty-ids/{pretty_id}/status` |
| 参数 | `status`,只允许未占用的靓号在 `available``disabled``reserved` 之间切换 |
| 返回值 | 靓号详情 |
| 相关 IM | 无 |
## App HTTP 接口
### 可申请靓号列表
| 项 | 内容 |
| --- | --- |
| 地址 | `GET /api/v1/users/pretty-ids/available` |
| 参数 | `page``page_size` |
| 返回值 | `items[]``total`,每项包含 `pretty_id``display_user_id``level_track``min_level``max_level` |
| 相关 IM | 无 |
返回只包含当前用户等级满足条件的 `available` 靓号。
### 靓号池申请
| 项 | 内容 |
| --- | --- |
| 地址 | `POST /api/v1/users/me/display-id/pretty/apply` |
| 参数 | `pretty_id` |
| 返回值 | `user_id``display_user_id``display_user_id_kind``display_user_id_expires_at_ms``pretty_id``pretty_display_user_id``lease_id` |
| 相关 IM | 无 |
说明:
- 保留当前地址,避免前端路由重复;请求体改为优先读取 `pretty_id`
- 靓号池申请默认长期持有,`display_user_id_expires_at_ms=0`
- 用户已有靓号时,新靓号直接覆盖旧靓号,旧靓号释放。
- 如果要兼容旧付费参数,服务端可以临时保留 `pretty_display_user_id + lease_duration_ms + payment_receipt_id` 分支,但新 App 只使用 `pretty_id`
## 资料返回改造
所有资料返回统一使用同一套字段。
| 场景 | 当前链路 | 改造点 |
| --- | --- | --- |
| 个人信息 | `GET /api/v1/users/me` -> `user-service.GetUser` | `User` proto 增加 `pretty_id``pretty_display_user_id` |
| 个人资料卡 | 用户资料批量接口或资料详情 | 复用 `User` 投影字段 |
| 房间个人资料卡 | `GET /api/v1/users/room-display-profiles:batch` | `roomDisplayProfileData` 增加靓号字段 |
| 房间详情 | `room detail` 中的 `profiles[]` | 来自同一批 `roomDisplayProfileData` |
| 登录 / 刷新 token | `AuthToken` 已有 display 字段 | 如客户端需要启动后立即知道靓号,补 `pretty_id``pretty_display_user_id` |
响应规则:
- 用户没有靓号:`display_user_id` 返回默认短号,`display_user_id_kind='default'``pretty_id=''``pretty_display_user_id=''`
- 用户有靓号:`display_user_id` 返回靓号,`display_user_id_kind='pretty'``pretty_id` 返回池记录 ID`pretty_display_user_id` 返回靓号号码。
- 长期持有靓号:`display_user_id_expires_at_ms=0`
- 后台限时发放靓号:`display_user_id_expires_at_ms` 返回实际过期时间。
## 后台菜单和权限
新增权限:
| 权限码 | kind | 说明 |
| --- | --- | --- |
| `pretty-id:view` | `menu` | 查看靓号管理 |
| `pretty-id:update` | `button` | 新增、编辑、上下架靓号池和靓号 |
| `pretty-id:generate` | `button` | 批量生成靓号 |
| `pretty-id:grant` | `button` | 后台直接发放靓号 |
新增菜单:
| 字段 | 值 |
| --- | --- |
| parent | `app-users` |
| title | `靓号管理` |
| code | `app-user-pretty-ids` |
| path | `/app/users/pretty-ids` |
| icon | `badge` |
| permission_code | `pretty-id:view` |
| sort | `65` |
需要同步:
- `server/admin/migrations/044_pretty_id_management.sql`
- `server/admin/internal/repository/seed.go` 默认权限和菜单。
- `platform-admin` 默认绑定全部新增权限。
- 只读角色只绑定 `pretty-id:view`
## 代码落点
| 位置 | 改动 |
| --- | --- |
| `api/proto/user/v1/user.proto` | 增加靓号池 RPC、后台发放 RPC、资料字段 |
| `services/user-service/internal/domain/user` | 增加 pool、pretty item、申请命令、后台发放命令和非数字靓号校验 |
| `services/user-service/internal/storage/mysql/identity` | 增加靓号池、生成、申请、后台发放、替换释放事务 |
| `services/user-service/deploy/mysql/initdb/001_user_service.sql` | 增加新表并扩展短号字段长度 |
| `services/user-service/migrations` | 增加线上迁移 |
| `services/gateway-service/internal/transport/http/userapi` | 改造靓号池申请接口、可申请列表、资料字段 |
| `services/gateway-service/internal/transport/http/roomapi` | 房间资料结构补靓号字段 |
| `server/admin/internal/integration/userclient` | 增加后台靓号管理 client |
| `server/admin/internal/modules/prettyid` | 新增后台模块 |
| `server/admin/internal/router/router.go` | 注册后台模块 |
| `server/admin/migrations` | 菜单和权限迁移 |
## 错误码建议
| code | 场景 |
| --- | --- |
| `PRETTY_ID_NOT_FOUND` | `pretty_id` 不存在 |
| `PRETTY_ID_NOT_AVAILABLE` | 靓号不可申请或已被占用 |
| `PRETTY_ID_LEVEL_NOT_MATCH` | 用户等级不在池区间内 |
| `PRETTY_DISPLAY_ID_INVALID` | 后台发放的靓号内容不符合字符规则 |
| `DISPLAY_USER_ID_CONFLICT` | 生成或申请时发现短号冲突 |
## 验证项
必须验证:
- `make proto` 成功,生成 `.pb.go``_grpc.pb.go`
- `go test ./...``hyapp-server` 根目录通过。
- `go test ./...``hyapp-server/server/admin` 通过。
- 批量生成 1000 个 `aabbcc` 靓号时,不插入与 `users.user_id`、默认短号、当前短号、已有靓号冲突的号码。
- 用户等级低于池区间时,申请返回 `PRETTY_ID_LEVEL_NOT_MATCH`
- 用户等级在池区间内时,申请成功并更新:
- `pretty_display_ids.status='assigned'`
- `pretty_display_user_id_leases.source='pool'`
- `user_display_user_ids.display_user_id_kind='pretty'`
- `users.current_display_user_id_kind='pretty'`
- 用户已有靓号时,再申请另一个靓号会覆盖旧靓号,旧靓号释放;旧池靓号回到 `available`,旧后台发放靓号进入 `released`
- 后台发放 `VIP2026``ملك123` 这类英文数字或阿拉伯语数字组合可以成功。
- 后台发放不传 `duration_ms` 时,返回 `display_user_id_expires_at_ms=0`
- 后台发放传 `duration_ms` 时,返回实际 `display_user_id_expires_at_ms`,到期后恢复默认短号。
- `GET /api/v1/users/me` 返回靓号字段。
- `GET /api/v1/users/room-display-profiles:batch` 返回靓号字段。
- 房间详情 `profiles[]` 返回靓号字段。
- 后台 `/api/v1/navigation/menus` 能看到“用户管理 > 靓号管理”。

View File

@ -24,6 +24,9 @@ type Client interface {
GetCumulativeRechargeRewardConfig(ctx context.Context, req *activityv1.GetCumulativeRechargeRewardConfigRequest) (*activityv1.GetCumulativeRechargeRewardConfigResponse, error)
UpdateCumulativeRechargeRewardConfig(ctx context.Context, req *activityv1.UpdateCumulativeRechargeRewardConfigRequest) (*activityv1.UpdateCumulativeRechargeRewardConfigResponse, error)
ListCumulativeRechargeRewardGrants(ctx context.Context, req *activityv1.ListCumulativeRechargeRewardGrantsRequest) (*activityv1.ListCumulativeRechargeRewardGrantsResponse, error)
GetInviteActivityRewardConfig(ctx context.Context, req *activityv1.GetInviteActivityRewardConfigRequest) (*activityv1.GetInviteActivityRewardConfigResponse, error)
UpdateInviteActivityRewardConfig(ctx context.Context, req *activityv1.UpdateInviteActivityRewardConfigRequest) (*activityv1.UpdateInviteActivityRewardConfigResponse, error)
ListInviteActivityRewardClaims(ctx context.Context, req *activityv1.ListInviteActivityRewardClaimsRequest) (*activityv1.ListInviteActivityRewardClaimsResponse, error)
GetSevenDayCheckInConfig(ctx context.Context, req *activityv1.GetSevenDayCheckInConfigRequest) (*activityv1.GetSevenDayCheckInConfigResponse, error)
UpdateSevenDayCheckInConfig(ctx context.Context, req *activityv1.UpdateSevenDayCheckInConfigRequest) (*activityv1.UpdateSevenDayCheckInConfigResponse, error)
ListSevenDayCheckInClaims(ctx context.Context, req *activityv1.ListSevenDayCheckInClaimsRequest) (*activityv1.ListSevenDayCheckInClaimsResponse, error)
@ -56,6 +59,7 @@ type GRPCClient struct {
registrationRewardClient activityv1.AdminRegistrationRewardServiceClient
firstRechargeRewardClient activityv1.AdminFirstRechargeRewardServiceClient
cumulativeRechargeClient activityv1.AdminCumulativeRechargeRewardServiceClient
inviteActivityClient activityv1.AdminInviteActivityRewardServiceClient
sevenDayCheckInClient activityv1.AdminSevenDayCheckInServiceClient
roomTurnoverRewardClient activityv1.AdminRoomTurnoverRewardServiceClient
weeklyStarClient activityv1.AdminWeeklyStarServiceClient
@ -71,6 +75,7 @@ func NewGRPC(conn grpc.ClientConnInterface) *GRPCClient {
registrationRewardClient: activityv1.NewAdminRegistrationRewardServiceClient(conn),
firstRechargeRewardClient: activityv1.NewAdminFirstRechargeRewardServiceClient(conn),
cumulativeRechargeClient: activityv1.NewAdminCumulativeRechargeRewardServiceClient(conn),
inviteActivityClient: activityv1.NewAdminInviteActivityRewardServiceClient(conn),
sevenDayCheckInClient: activityv1.NewAdminSevenDayCheckInServiceClient(conn),
roomTurnoverRewardClient: activityv1.NewAdminRoomTurnoverRewardServiceClient(conn),
weeklyStarClient: activityv1.NewAdminWeeklyStarServiceClient(conn),
@ -136,6 +141,18 @@ func (c *GRPCClient) ListCumulativeRechargeRewardGrants(ctx context.Context, req
return c.cumulativeRechargeClient.ListCumulativeRechargeRewardGrants(ctx, req)
}
func (c *GRPCClient) GetInviteActivityRewardConfig(ctx context.Context, req *activityv1.GetInviteActivityRewardConfigRequest) (*activityv1.GetInviteActivityRewardConfigResponse, error) {
return c.inviteActivityClient.GetInviteActivityRewardConfig(ctx, req)
}
func (c *GRPCClient) UpdateInviteActivityRewardConfig(ctx context.Context, req *activityv1.UpdateInviteActivityRewardConfigRequest) (*activityv1.UpdateInviteActivityRewardConfigResponse, error) {
return c.inviteActivityClient.UpdateInviteActivityRewardConfig(ctx, req)
}
func (c *GRPCClient) ListInviteActivityRewardClaims(ctx context.Context, req *activityv1.ListInviteActivityRewardClaimsRequest) (*activityv1.ListInviteActivityRewardClaimsResponse, error) {
return c.inviteActivityClient.ListInviteActivityRewardClaims(ctx, req)
}
func (c *GRPCClient) GetSevenDayCheckInConfig(ctx context.Context, req *activityv1.GetSevenDayCheckInConfigRequest) (*activityv1.GetSevenDayCheckInConfigResponse, error) {
return c.sevenDayCheckInClient.GetSevenDayCheckInConfig(ctx, req)
}

View File

@ -16,6 +16,19 @@ type Client interface {
UpsertCatalog(ctx context.Context, req *gamev1.UpsertCatalogRequest) (*gamev1.CatalogResponse, error)
SetGameStatus(ctx context.Context, req *gamev1.SetGameStatusRequest) (*gamev1.CatalogResponse, error)
DeleteCatalog(ctx context.Context, req *gamev1.DeleteCatalogRequest) (*gamev1.DeleteCatalogResponse, error)
ListSelfGames(ctx context.Context, req *gamev1.ListSelfGamesRequest) (*gamev1.ListSelfGamesResponse, error)
UpdateDiceConfig(ctx context.Context, req *gamev1.UpdateDiceConfigRequest) (*gamev1.DiceConfigResponse, error)
AdjustDicePool(ctx context.Context, req *gamev1.AdjustDicePoolRequest) (*gamev1.AdjustDicePoolResponse, error)
ListDiceRobots(ctx context.Context, req *gamev1.ListDiceRobotsRequest) (*gamev1.ListDiceRobotsResponse, error)
RegisterDiceRobots(ctx context.Context, req *gamev1.RegisterDiceRobotsRequest) (*gamev1.RegisterDiceRobotsResponse, error)
SetDiceRobotStatus(ctx context.Context, req *gamev1.SetDiceRobotStatusRequest) (*gamev1.DiceRobotResponse, error)
DeleteDiceRobot(ctx context.Context, req *gamev1.DeleteDiceRobotRequest) (*gamev1.DeleteDiceRobotResponse, error)
GetRoomRPSConfig(ctx context.Context, req *gamev1.GetRoomRPSConfigRequest) (*gamev1.RoomRPSConfigResponse, error)
UpdateRoomRPSConfig(ctx context.Context, req *gamev1.UpdateRoomRPSConfigRequest) (*gamev1.RoomRPSConfigResponse, error)
ListRoomRPSChallenges(ctx context.Context, req *gamev1.ListRoomRPSChallengesRequest) (*gamev1.ListRoomRPSChallengesResponse, error)
GetRoomRPSChallenge(ctx context.Context, req *gamev1.GetRoomRPSChallengeRequest) (*gamev1.RoomRPSChallengeResponse, error)
RetryRoomRPSSettlement(ctx context.Context, req *gamev1.RetryRoomRPSSettlementRequest) (*gamev1.RoomRPSChallengeResponse, error)
ExpireRoomRPSChallenge(ctx context.Context, req *gamev1.ExpireRoomRPSChallengeRequest) (*gamev1.RoomRPSChallengeResponse, error)
}
type GRPCClient struct {
@ -49,3 +62,62 @@ func (c *GRPCClient) SetGameStatus(ctx context.Context, req *gamev1.SetGameStatu
func (c *GRPCClient) DeleteCatalog(ctx context.Context, req *gamev1.DeleteCatalogRequest) (*gamev1.DeleteCatalogResponse, error) {
return c.client.DeleteCatalog(ctx, req)
}
func (c *GRPCClient) ListSelfGames(ctx context.Context, req *gamev1.ListSelfGamesRequest) (*gamev1.ListSelfGamesResponse, error) {
return c.client.ListSelfGames(ctx, req)
}
func (c *GRPCClient) UpdateDiceConfig(ctx context.Context, req *gamev1.UpdateDiceConfigRequest) (*gamev1.DiceConfigResponse, error) {
return c.client.UpdateDiceConfig(ctx, req)
}
func (c *GRPCClient) AdjustDicePool(ctx context.Context, req *gamev1.AdjustDicePoolRequest) (*gamev1.AdjustDicePoolResponse, error) {
return c.client.AdjustDicePool(ctx, req)
}
func (c *GRPCClient) ListDiceRobots(ctx context.Context, req *gamev1.ListDiceRobotsRequest) (*gamev1.ListDiceRobotsResponse, error) {
return c.client.ListDiceRobots(ctx, req)
}
func (c *GRPCClient) RegisterDiceRobots(ctx context.Context, req *gamev1.RegisterDiceRobotsRequest) (*gamev1.RegisterDiceRobotsResponse, error) {
return c.client.RegisterDiceRobots(ctx, req)
}
func (c *GRPCClient) SetDiceRobotStatus(ctx context.Context, req *gamev1.SetDiceRobotStatusRequest) (*gamev1.DiceRobotResponse, error) {
return c.client.SetDiceRobotStatus(ctx, req)
}
// DeleteDiceRobot 透传后台删除机器人登记 RPCadmin-server 不直接操作 game-service 的机器人表。
func (c *GRPCClient) DeleteDiceRobot(ctx context.Context, req *gamev1.DeleteDiceRobotRequest) (*gamev1.DeleteDiceRobotResponse, error) {
return c.client.DeleteDiceRobot(ctx, req)
}
func (c *GRPCClient) GetRoomRPSConfig(ctx context.Context, req *gamev1.GetRoomRPSConfigRequest) (*gamev1.RoomRPSConfigResponse, error) {
// admin-server 不持有房内猜拳配置缓存;每次查询都透传到 game-service保证后台看到的是运行时同一份配置事实。
return c.client.GetRoomRPSConfig(ctx, req)
}
func (c *GRPCClient) UpdateRoomRPSConfig(ctx context.Context, req *gamev1.UpdateRoomRPSConfigRequest) (*gamev1.RoomRPSConfigResponse, error) {
// 配置更新只通过 game-service 管理 RPC 落地admin-server 不直接写游戏表,避免与运行时读取路径产生双写分叉。
return c.client.UpdateRoomRPSConfig(ctx, req)
}
func (c *GRPCClient) ListRoomRPSChallenges(ctx context.Context, req *gamev1.ListRoomRPSChallengesRequest) (*gamev1.ListRoomRPSChallengesResponse, error) {
// 订单列表筛选条件原样交给 game-service后台不拼 SQL也不自行解释 challenge 状态机。
return c.client.ListRoomRPSChallenges(ctx, req)
}
func (c *GRPCClient) GetRoomRPSChallenge(ctx context.Context, req *gamev1.GetRoomRPSChallengeRequest) (*gamev1.RoomRPSChallengeResponse, error) {
// 订单详情以 game-service 的单据快照为准,避免 admin-server 用列表缓存展示过期结算状态。
return c.client.GetRoomRPSChallenge(ctx, req)
}
func (c *GRPCClient) RetryRoomRPSSettlement(ctx context.Context, req *gamev1.RetryRoomRPSSettlementRequest) (*gamev1.RoomRPSChallengeResponse, error) {
// 重试结算是运维命令,不是后台本地状态修改;是否可重试必须由 game-service 在钱包幂等语义下判断。
return c.client.RetryRoomRPSSettlement(ctx, req)
}
func (c *GRPCClient) ExpireRoomRPSChallenge(ctx context.Context, req *gamev1.ExpireRoomRPSChallengeRequest) (*gamev1.RoomRPSChallengeResponse, error) {
// 手动过期同样只发命令给 game-servicepending 校验和并发应战保护必须在订单事务内完成。
return c.client.ExpireRoomRPSChallenge(ctx, req)
}

View File

@ -127,6 +127,7 @@ type RoomRocketRewardItem struct {
RewardItemID string
ResourceGroupID int64
Weight int64
Quantity int64
DisplayName string
IconURL string
}
@ -623,6 +624,7 @@ func roomRocketRewardsFromProto(input []*roomv1.RoomRocketRewardItem) []RoomRock
RewardItemID: reward.GetRewardItemId(),
ResourceGroupID: reward.GetResourceGroupId(),
Weight: reward.GetWeight(),
Quantity: reward.GetQuantity(),
DisplayName: reward.GetDisplayName(),
IconURL: reward.GetIconUrl(),
})
@ -637,6 +639,7 @@ func roomRocketRewardsToProto(input []RoomRocketRewardItem) []*roomv1.RoomRocket
RewardItemId: reward.RewardItemID,
ResourceGroupId: reward.ResourceGroupID,
Weight: reward.Weight,
Quantity: reward.Quantity,
DisplayName: reward.DisplayName,
IconUrl: reward.IconURL,
})

View File

@ -12,9 +12,17 @@ import (
type Client interface {
GetUser(ctx context.Context, req GetUserRequest) (*User, error)
BatchGetUsers(ctx context.Context, req BatchGetUsersRequest) (map[int64]*User, error)
AdminChangeUserCountry(ctx context.Context, req AdminChangeUserCountryRequest) (*ChangeUserCountryResult, error)
SetUserStatus(ctx context.Context, req SetUserStatusRequest) (*SetUserStatusResult, error)
ResolveDisplayUserID(ctx context.Context, req ResolveDisplayUserIDRequest) (*UserIdentity, error)
ListPrettyDisplayIDPools(ctx context.Context, req ListPrettyDisplayIDPoolsRequest) ([]*PrettyDisplayIDPool, int64, error)
CreatePrettyDisplayIDPool(ctx context.Context, req PrettyDisplayIDPoolRequest) (*PrettyDisplayIDPool, error)
UpdatePrettyDisplayIDPool(ctx context.Context, req PrettyDisplayIDPoolRequest) (*PrettyDisplayIDPool, error)
GeneratePrettyDisplayIDs(ctx context.Context, req GeneratePrettyDisplayIDsRequest) (*PrettyDisplayIDGenerationBatch, error)
ListPrettyDisplayIDs(ctx context.Context, req ListPrettyDisplayIDsRequest) ([]*PrettyDisplayID, int64, error)
SetPrettyDisplayIDStatus(ctx context.Context, req SetPrettyDisplayIDStatusRequest) (*PrettyDisplayID, error)
AdminGrantPrettyDisplayID(ctx context.Context, req AdminGrantPrettyDisplayIDRequest) (*AdminGrantPrettyDisplayIDResult, error)
ListCountries(ctx context.Context, req ListCountriesRequest) ([]*Country, error)
UpdateCountry(ctx context.Context, req UpdateCountryRequest) (*Country, error)
ListRegions(ctx context.Context, req ListRegionsRequest) ([]*Region, error)
@ -31,6 +39,7 @@ type Client interface {
CloseAgency(ctx context.Context, req CloseAgencyRequest) (*Agency, error)
DeleteAgency(ctx context.Context, req DeleteAgencyRequest) (*Agency, error)
SetAgencyJoinEnabled(ctx context.Context, req SetAgencyJoinEnabledRequest) (*Agency, error)
QuickCreateAccount(ctx context.Context, req QuickCreateAccountRequest) (*QuickCreateAccountResult, error)
}
type GetUserRequest struct {
@ -39,6 +48,35 @@ type GetUserRequest struct {
UserID int64
}
// BatchGetUsersRequest 只承载后台展示需要的一组真实 user_id不在 admin-server 里回查用户库。
type BatchGetUsersRequest struct {
RequestID string
Caller string
UserIDs []int64
}
type QuickCreateAccountRequest struct {
RequestID string
Caller string
Password string
Username string
Avatar string
Gender string
Country string
DeviceID string
Source string
InstallChannel string
Platform string
Language string
Timezone string
}
type QuickCreateAccountResult struct {
UserID int64
DisplayUserID string
AccessToken string
}
type SetUserStatusRequest struct {
RequestID string
Caller string
@ -92,6 +130,8 @@ type UserIdentity struct {
DefaultDisplayUserID string `json:"defaultDisplayUserId"`
DisplayUserIDKind string `json:"displayUserIdKind"`
DisplayUserIDExpiresAtMs int64 `json:"displayUserIdExpiresAtMs"`
PrettyID string `json:"prettyId,omitempty"`
PrettyDisplayUserID string `json:"prettyDisplayUserId,omitempty"`
AppCode string `json:"appCode"`
}
@ -104,6 +144,8 @@ type User struct {
DefaultDisplayUserID string `json:"defaultDisplayUserId"`
DisplayUserIDKind string `json:"displayUserIdKind"`
DisplayUserIDExpiresAtMs int64 `json:"displayUserIdExpiresAtMs"`
PrettyID string `json:"prettyId,omitempty"`
PrettyDisplayUserID string `json:"prettyDisplayUserId,omitempty"`
Username string `json:"username"`
Gender string `json:"gender"`
Country string `json:"country"`
@ -126,9 +168,11 @@ type User struct {
type GRPCClient struct {
client userv1.UserServiceClient
authClient userv1.AuthServiceClient
identityClient userv1.UserIdentityServiceClient
countryClient userv1.CountryAdminServiceClient
regionClient userv1.RegionAdminServiceClient
prettyClient userv1.UserPrettyDisplayIDAdminServiceClient
hostClient userv1.UserHostServiceClient
hostAdminClient userv1.UserHostAdminServiceClient
}
@ -136,14 +180,50 @@ type GRPCClient struct {
func NewGRPC(conn grpc.ClientConnInterface) *GRPCClient {
return &GRPCClient{
client: userv1.NewUserServiceClient(conn),
authClient: userv1.NewAuthServiceClient(conn),
identityClient: userv1.NewUserIdentityServiceClient(conn),
countryClient: userv1.NewCountryAdminServiceClient(conn),
regionClient: userv1.NewRegionAdminServiceClient(conn),
prettyClient: userv1.NewUserPrettyDisplayIDAdminServiceClient(conn),
hostClient: userv1.NewUserHostServiceClient(conn),
hostAdminClient: userv1.NewUserHostAdminServiceClient(conn),
}
}
func (c *GRPCClient) QuickCreateAccount(ctx context.Context, req QuickCreateAccountRequest) (*QuickCreateAccountResult, error) {
resp, err := c.authClient.QuickCreateAccount(ctx, &userv1.QuickCreateAccountRequest{
Meta: &userv1.RequestMeta{
RequestId: req.RequestID,
Caller: req.Caller,
SentAtMs: time.Now().UnixMilli(),
AppCode: appctx.FromContext(ctx),
Platform: req.Platform,
Language: req.Language,
Timezone: req.Timezone,
},
Password: req.Password,
Username: req.Username,
Avatar: req.Avatar,
Gender: req.Gender,
Country: req.Country,
DeviceId: req.DeviceID,
Source: req.Source,
InstallChannel: req.InstallChannel,
Platform: req.Platform,
Language: req.Language,
Timezone: req.Timezone,
})
if err != nil {
return nil, err
}
token := resp.GetToken()
return &QuickCreateAccountResult{
UserID: token.GetUserId(),
DisplayUserID: token.GetDisplayUserId(),
AccessToken: token.GetAccessToken(),
}, nil
}
func (c *GRPCClient) GetUser(ctx context.Context, req GetUserRequest) (*User, error) {
resp, err := c.client.GetUser(ctx, &userv1.GetUserRequest{
Meta: &userv1.RequestMeta{
@ -160,6 +240,22 @@ func (c *GRPCClient) GetUser(ctx context.Context, req GetUserRequest) (*User, er
return fromProtoUser(resp.GetUser()), nil
}
// BatchGetUsers 复用 user-service 主数据 RPC让后台列表只做展示聚合不复制用户资料到游戏库。
func (c *GRPCClient) BatchGetUsers(ctx context.Context, req BatchGetUsersRequest) (map[int64]*User, error) {
resp, err := c.client.BatchGetUsers(ctx, &userv1.BatchGetUsersRequest{
Meta: requestMeta(ctx, req.RequestID, req.Caller),
UserIds: req.UserIDs,
})
if err != nil {
return nil, err
}
users := make(map[int64]*User, len(resp.GetUsers()))
for userID, user := range resp.GetUsers() {
users[userID] = fromProtoUser(user)
}
return users, nil
}
func (c *GRPCClient) AdminChangeUserCountry(ctx context.Context, req AdminChangeUserCountryRequest) (*ChangeUserCountryResult, error) {
resp, err := c.client.AdminChangeUserCountry(ctx, &userv1.AdminChangeUserCountryRequest{
Meta: requestMeta(ctx, req.RequestID, req.Caller),
@ -243,6 +339,8 @@ func fromProtoUser(user *userv1.User) *User {
DefaultDisplayUserID: user.GetDefaultDisplayUserId(),
DisplayUserIDKind: user.GetDisplayUserIdKind(),
DisplayUserIDExpiresAtMs: user.GetDisplayUserIdExpiresAtMs(),
PrettyID: user.GetPrettyId(),
PrettyDisplayUserID: user.GetPrettyDisplayUserId(),
Username: user.GetUsername(),
Gender: user.GetGender(),
Country: user.GetCountry(),
@ -288,6 +386,8 @@ func fromProtoUserIdentity(identity *userv1.UserIdentity) *UserIdentity {
DefaultDisplayUserID: identity.GetDefaultDisplayUserId(),
DisplayUserIDKind: identity.GetDisplayUserIdKind(),
DisplayUserIDExpiresAtMs: identity.GetDisplayUserIdExpiresAtMs(),
PrettyID: identity.GetPrettyId(),
PrettyDisplayUserID: identity.GetPrettyDisplayUserId(),
AppCode: identity.GetAppCode(),
}
}

View File

@ -0,0 +1,328 @@
package userclient
import (
"context"
userv1 "hyapp.local/api/proto/user/v1"
)
type PrettyDisplayIDPool struct {
AppCode string `json:"app_code"`
PoolID string `json:"pool_id"`
Name string `json:"name"`
LevelTrack string `json:"level_track"`
MinLevel int32 `json:"min_level"`
MaxLevel int32 `json:"max_level"`
RuleType string `json:"rule_type"`
RuleConfigJSON string `json:"rule_config_json"`
Status string `json:"status"`
SortOrder int32 `json:"sort_order"`
CreatedByAdminID int64 `json:"created_by_admin_id,string"`
UpdatedByAdminID int64 `json:"updated_by_admin_id,string"`
CreatedAtMs int64 `json:"created_at_ms"`
UpdatedAtMs int64 `json:"updated_at_ms"`
}
type PrettyDisplayID struct {
AppCode string `json:"app_code"`
PrettyID string `json:"pretty_id"`
PoolID string `json:"pool_id"`
Source string `json:"source"`
DisplayUserID string `json:"display_user_id"`
Status string `json:"status"`
AssignedUserID int64 `json:"assigned_user_id,string"`
AssignedLeaseID string `json:"assigned_lease_id"`
AssignedAtMs int64 `json:"assigned_at_ms"`
ReleasedAtMs int64 `json:"released_at_ms"`
ReleaseReason string `json:"release_reason"`
GeneratedBatchID string `json:"generated_batch_id"`
CreatedByAdminID int64 `json:"created_by_admin_id,string"`
CreatedAtMs int64 `json:"created_at_ms"`
UpdatedAtMs int64 `json:"updated_at_ms"`
Pool *PrettyDisplayIDPool `json:"pool,omitempty"`
}
type PrettyDisplayIDGenerationBatch struct {
AppCode string `json:"app_code"`
BatchID string `json:"batch_id"`
PoolID string `json:"pool_id"`
RuleType string `json:"rule_type"`
RuleConfigJSON string `json:"rule_config_json"`
RequestedCount int32 `json:"requested_count"`
GeneratedCount int32 `json:"generated_count"`
SkippedConflictCount int32 `json:"skipped_conflict_count"`
Status string `json:"status"`
OperatorAdminID int64 `json:"operator_admin_id,string"`
RequestID string `json:"request_id"`
CreatedAtMs int64 `json:"created_at_ms"`
UpdatedAtMs int64 `json:"updated_at_ms"`
}
type ListPrettyDisplayIDPoolsRequest struct {
RequestID string
Caller string
Status string
LevelTrack string
Page int32
PageSize int32
}
type PrettyDisplayIDPoolRequest struct {
RequestID string
Caller string
PoolID string
Name string
LevelTrack string
MinLevel int32
MaxLevel int32
RuleType string
RuleConfigJSON string
Status string
SortOrder int32
OperatorAdminID int64
}
type GeneratePrettyDisplayIDsRequest struct {
RequestID string
Caller string
PoolID string
RuleType string
RuleConfigJSON string
Count int32
OperatorAdminID int64
}
type ListPrettyDisplayIDsRequest struct {
RequestID string
Caller string
PoolID string
Source string
Status string
Keyword string
AssignedUserID int64
Page int32
PageSize int32
}
type SetPrettyDisplayIDStatusRequest struct {
RequestID string
Caller string
PrettyID string
Status string
Reason string
OperatorAdminID int64
}
type AdminGrantPrettyDisplayIDRequest struct {
RequestID string
Caller string
TargetUserID int64
DisplayUserID string
DurationMs int64
Reason string
OperatorAdminID int64
}
type AdminGrantPrettyDisplayIDResult struct {
Identity *UserIdentity `json:"identity"`
PrettyID string `json:"pretty_id"`
LeaseID string `json:"lease_id"`
}
// 以下方法只做 admin-server 到 user-service 的传输适配HTTP 层 DTO 保持 snake_casegRPC 层字段按 proto 命名传递。
func (c *GRPCClient) ListPrettyDisplayIDPools(ctx context.Context, req ListPrettyDisplayIDPoolsRequest) ([]*PrettyDisplayIDPool, int64, error) {
resp, err := c.prettyClient.ListPrettyDisplayIDPools(ctx, &userv1.ListPrettyDisplayIDPoolsRequest{
Meta: requestMeta(ctx, req.RequestID, req.Caller),
Status: req.Status,
LevelTrack: req.LevelTrack,
Page: req.Page,
PageSize: req.PageSize,
})
if err != nil {
return nil, 0, err
}
items := make([]*PrettyDisplayIDPool, 0, len(resp.GetItems()))
for _, item := range resp.GetItems() {
items = append(items, fromProtoPrettyDisplayIDPool(item))
}
return items, resp.GetTotal(), nil
}
func (c *GRPCClient) CreatePrettyDisplayIDPool(ctx context.Context, req PrettyDisplayIDPoolRequest) (*PrettyDisplayIDPool, error) {
resp, err := c.prettyClient.CreatePrettyDisplayIDPool(ctx, &userv1.CreatePrettyDisplayIDPoolRequest{
Meta: requestMeta(ctx, req.RequestID, req.Caller),
Name: req.Name,
LevelTrack: req.LevelTrack,
MinLevel: req.MinLevel,
MaxLevel: req.MaxLevel,
RuleType: req.RuleType,
RuleConfigJson: req.RuleConfigJSON,
Status: req.Status,
SortOrder: req.SortOrder,
OperatorAdminId: req.OperatorAdminID,
})
if err != nil {
return nil, err
}
return fromProtoPrettyDisplayIDPool(resp.GetPool()), nil
}
func (c *GRPCClient) UpdatePrettyDisplayIDPool(ctx context.Context, req PrettyDisplayIDPoolRequest) (*PrettyDisplayIDPool, error) {
resp, err := c.prettyClient.UpdatePrettyDisplayIDPool(ctx, &userv1.UpdatePrettyDisplayIDPoolRequest{
Meta: requestMeta(ctx, req.RequestID, req.Caller),
PoolId: req.PoolID,
Name: req.Name,
LevelTrack: req.LevelTrack,
MinLevel: req.MinLevel,
MaxLevel: req.MaxLevel,
RuleType: req.RuleType,
RuleConfigJson: req.RuleConfigJSON,
Status: req.Status,
SortOrder: req.SortOrder,
OperatorAdminId: req.OperatorAdminID,
})
if err != nil {
return nil, err
}
return fromProtoPrettyDisplayIDPool(resp.GetPool()), nil
}
func (c *GRPCClient) GeneratePrettyDisplayIDs(ctx context.Context, req GeneratePrettyDisplayIDsRequest) (*PrettyDisplayIDGenerationBatch, error) {
// 批量生成的冲突过滤、规则默认值和批次落库都在 user-service这里不复制业务判断。
resp, err := c.prettyClient.GeneratePrettyDisplayIDs(ctx, &userv1.GeneratePrettyDisplayIDsRequest{
Meta: requestMeta(ctx, req.RequestID, req.Caller),
PoolId: req.PoolID,
RuleType: req.RuleType,
RuleConfigJson: req.RuleConfigJSON,
Count: req.Count,
OperatorAdminId: req.OperatorAdminID,
})
if err != nil {
return nil, err
}
return fromProtoPrettyDisplayIDGenerationBatch(resp.GetBatch()), nil
}
func (c *GRPCClient) ListPrettyDisplayIDs(ctx context.Context, req ListPrettyDisplayIDsRequest) ([]*PrettyDisplayID, int64, error) {
resp, err := c.prettyClient.ListPrettyDisplayIDs(ctx, &userv1.ListPrettyDisplayIDsRequest{
Meta: requestMeta(ctx, req.RequestID, req.Caller),
PoolId: req.PoolID,
Source: req.Source,
Status: req.Status,
Keyword: req.Keyword,
AssignedUserId: req.AssignedUserID,
Page: req.Page,
PageSize: req.PageSize,
})
if err != nil {
return nil, 0, err
}
items := make([]*PrettyDisplayID, 0, len(resp.GetItems()))
for _, item := range resp.GetItems() {
items = append(items, fromProtoPrettyDisplayID(item))
}
return items, resp.GetTotal(), nil
}
func (c *GRPCClient) SetPrettyDisplayIDStatus(ctx context.Context, req SetPrettyDisplayIDStatusRequest) (*PrettyDisplayID, error) {
resp, err := c.prettyClient.SetPrettyDisplayIDStatus(ctx, &userv1.SetPrettyDisplayIDStatusRequest{
Meta: requestMeta(ctx, req.RequestID, req.Caller),
PrettyId: req.PrettyID,
Status: req.Status,
OperatorAdminId: req.OperatorAdminID,
Reason: req.Reason,
})
if err != nil {
return nil, err
}
return fromProtoPrettyDisplayID(resp.GetItem()), nil
}
func (c *GRPCClient) AdminGrantPrettyDisplayID(ctx context.Context, req AdminGrantPrettyDisplayIDRequest) (*AdminGrantPrettyDisplayIDResult, error) {
// 后台发放返回当前用户身份快照admin-server 原样转给前端,方便页面确认最终展示号。
resp, err := c.prettyClient.AdminGrantPrettyDisplayID(ctx, &userv1.AdminGrantPrettyDisplayIDRequest{
Meta: requestMeta(ctx, req.RequestID, req.Caller),
TargetUserId: req.TargetUserID,
DisplayUserId: req.DisplayUserID,
DurationMs: req.DurationMs,
Reason: req.Reason,
OperatorAdminId: req.OperatorAdminID,
})
if err != nil {
return nil, err
}
return &AdminGrantPrettyDisplayIDResult{
Identity: fromProtoUserIdentity(resp.GetIdentity()),
PrettyID: resp.GetPrettyId(),
LeaseID: resp.GetLeaseId(),
}, nil
}
func fromProtoPrettyDisplayIDPool(pool *userv1.PrettyDisplayIDPool) *PrettyDisplayIDPool {
if pool == nil {
return nil
}
// 转换层保留 int64 的 string JSON tag避免后台前端在大 ID 上丢精度。
return &PrettyDisplayIDPool{
AppCode: pool.GetAppCode(),
PoolID: pool.GetPoolId(),
Name: pool.GetName(),
LevelTrack: pool.GetLevelTrack(),
MinLevel: pool.GetMinLevel(),
MaxLevel: pool.GetMaxLevel(),
RuleType: pool.GetRuleType(),
RuleConfigJSON: pool.GetRuleConfigJson(),
Status: pool.GetStatus(),
SortOrder: pool.GetSortOrder(),
CreatedByAdminID: pool.GetCreatedByAdminId(),
UpdatedByAdminID: pool.GetUpdatedByAdminId(),
CreatedAtMs: pool.GetCreatedAtMs(),
UpdatedAtMs: pool.GetUpdatedAtMs(),
}
}
func fromProtoPrettyDisplayID(item *userv1.PrettyDisplayID) *PrettyDisplayID {
if item == nil {
return nil
}
// pool 可能为空:后台发放的自由靓号没有池归属,列表展示时仍要返回靓号本体。
return &PrettyDisplayID{
AppCode: item.GetAppCode(),
PrettyID: item.GetPrettyId(),
PoolID: item.GetPoolId(),
Source: item.GetSource(),
DisplayUserID: item.GetDisplayUserId(),
Status: item.GetStatus(),
AssignedUserID: item.GetAssignedUserId(),
AssignedLeaseID: item.GetAssignedLeaseId(),
AssignedAtMs: item.GetAssignedAtMs(),
ReleasedAtMs: item.GetReleasedAtMs(),
ReleaseReason: item.GetReleaseReason(),
GeneratedBatchID: item.GetGeneratedBatchId(),
CreatedByAdminID: item.GetCreatedByAdminId(),
CreatedAtMs: item.GetCreatedAtMs(),
UpdatedAtMs: item.GetUpdatedAtMs(),
Pool: fromProtoPrettyDisplayIDPool(item.GetPool()),
}
}
func fromProtoPrettyDisplayIDGenerationBatch(batch *userv1.PrettyDisplayIDGenerationBatch) *PrettyDisplayIDGenerationBatch {
if batch == nil {
return nil
}
return &PrettyDisplayIDGenerationBatch{
AppCode: batch.GetAppCode(),
BatchID: batch.GetBatchId(),
PoolID: batch.GetPoolId(),
RuleType: batch.GetRuleType(),
RuleConfigJSON: batch.GetRuleConfigJson(),
RequestedCount: batch.GetRequestedCount(),
GeneratedCount: batch.GetGeneratedCount(),
SkippedConflictCount: batch.GetSkippedConflictCount(),
Status: batch.GetStatus(),
OperatorAdminID: batch.GetOperatorAdminId(),
RequestID: batch.GetRequestId(),
CreatedAtMs: batch.GetCreatedAtMs(),
UpdatedAtMs: batch.GetUpdatedAtMs(),
}
}

View File

@ -25,6 +25,7 @@ type Client interface {
CreateGiftConfig(ctx context.Context, req *walletv1.CreateGiftConfigRequest) (*walletv1.GiftConfigResponse, error)
UpdateGiftConfig(ctx context.Context, req *walletv1.UpdateGiftConfigRequest) (*walletv1.GiftConfigResponse, error)
SetGiftConfigStatus(ctx context.Context, req *walletv1.SetGiftConfigStatusRequest) (*walletv1.GiftConfigResponse, error)
DeleteGiftConfig(ctx context.Context, req *walletv1.DeleteGiftConfigRequest) (*walletv1.GiftConfigResponse, error)
UpsertGiftTypeConfig(ctx context.Context, req *walletv1.UpsertGiftTypeConfigRequest) (*walletv1.GiftTypeConfigResponse, error)
GrantResource(ctx context.Context, req *walletv1.GrantResourceRequest) (*walletv1.ResourceGrantResponse, error)
GrantResourceGroup(ctx context.Context, req *walletv1.GrantResourceGroupRequest) (*walletv1.ResourceGrantResponse, error)
@ -35,6 +36,9 @@ type Client interface {
AdminCreditAsset(ctx context.Context, req *walletv1.AdminCreditAssetRequest) (*walletv1.AdminCreditAssetResponse, error)
AdminCreditCoinSellerStock(ctx context.Context, req *walletv1.AdminCreditCoinSellerStockRequest) (*walletv1.AdminCreditCoinSellerStockResponse, error)
ListRechargeBills(ctx context.Context, req *walletv1.ListRechargeBillsRequest) (*walletv1.ListRechargeBillsResponse, error)
ListThirdPartyPaymentChannels(ctx context.Context, req *walletv1.ListThirdPartyPaymentChannelsRequest) (*walletv1.ListThirdPartyPaymentChannelsResponse, error)
SetThirdPartyPaymentMethodStatus(ctx context.Context, req *walletv1.SetThirdPartyPaymentMethodStatusRequest) (*walletv1.ThirdPartyPaymentMethodResponse, error)
UpdateThirdPartyPaymentRate(ctx context.Context, req *walletv1.UpdateThirdPartyPaymentRateRequest) (*walletv1.ThirdPartyPaymentMethodResponse, error)
ListAdminRechargeProducts(ctx context.Context, req *walletv1.ListAdminRechargeProductsRequest) (*walletv1.ListAdminRechargeProductsResponse, error)
CreateRechargeProduct(ctx context.Context, req *walletv1.CreateRechargeProductRequest) (*walletv1.RechargeProductResponse, error)
UpdateRechargeProduct(ctx context.Context, req *walletv1.UpdateRechargeProductRequest) (*walletv1.RechargeProductResponse, error)
@ -117,6 +121,10 @@ func (c *GRPCClient) SetGiftConfigStatus(ctx context.Context, req *walletv1.SetG
return c.client.SetGiftConfigStatus(ctx, req)
}
func (c *GRPCClient) DeleteGiftConfig(ctx context.Context, req *walletv1.DeleteGiftConfigRequest) (*walletv1.GiftConfigResponse, error) {
return c.client.DeleteGiftConfig(ctx, req)
}
func (c *GRPCClient) UpsertGiftTypeConfig(ctx context.Context, req *walletv1.UpsertGiftTypeConfigRequest) (*walletv1.GiftTypeConfigResponse, error) {
return c.client.UpsertGiftTypeConfig(ctx, req)
}
@ -157,6 +165,18 @@ func (c *GRPCClient) ListRechargeBills(ctx context.Context, req *walletv1.ListRe
return c.client.ListRechargeBills(ctx, req)
}
func (c *GRPCClient) ListThirdPartyPaymentChannels(ctx context.Context, req *walletv1.ListThirdPartyPaymentChannelsRequest) (*walletv1.ListThirdPartyPaymentChannelsResponse, error) {
return c.client.ListThirdPartyPaymentChannels(ctx, req)
}
func (c *GRPCClient) SetThirdPartyPaymentMethodStatus(ctx context.Context, req *walletv1.SetThirdPartyPaymentMethodStatusRequest) (*walletv1.ThirdPartyPaymentMethodResponse, error) {
return c.client.SetThirdPartyPaymentMethodStatus(ctx, req)
}
func (c *GRPCClient) UpdateThirdPartyPaymentRate(ctx context.Context, req *walletv1.UpdateThirdPartyPaymentRateRequest) (*walletv1.ThirdPartyPaymentMethodResponse, error) {
return c.client.UpdateThirdPartyPaymentRate(ctx, req)
}
func (c *GRPCClient) ListAdminRechargeProducts(ctx context.Context, req *walletv1.ListAdminRechargeProductsRequest) (*walletv1.ListAdminRechargeProductsResponse, error) {
return c.client.ListAdminRechargeProducts(ctx, req)
}

View File

@ -115,6 +115,50 @@ func (AppBanner) TableName() string {
return "admin_app_banners"
}
type AppSplashScreen struct {
ID uint `gorm:"primaryKey" json:"id"`
AppCode string `gorm:"size:32;index:idx_admin_app_splash_screens_app_sort,not null;default:lalu" json:"appCode"`
CoverURL string `gorm:"size:1024;not null" json:"coverUrl"`
SplashType string `gorm:"size:16;not null" json:"splashType"`
Param string `gorm:"size:2048" json:"param"`
Status string `gorm:"size:24;index:idx_admin_app_splash_screens_app_sort,not null;default:active" json:"status"`
Platform string `gorm:"size:24;index:idx_admin_app_splash_screens_scope,not null" json:"platform"`
SortOrder int `gorm:"index:idx_admin_app_splash_screens_app_sort,not null;default:0" json:"sortOrder"`
RegionID int64 `gorm:"index:idx_admin_app_splash_screens_scope,not null;default:0" json:"regionId"`
CountryCode string `gorm:"size:8;index:idx_admin_app_splash_screens_scope" json:"countryCode"`
Description string `gorm:"size:255" json:"description"`
DisplayDurationMS int `gorm:"column:display_duration_ms;not null;default:3000;comment:开屏展示时长毫秒0 表示客户端默认值" json:"displayDurationMs"`
StartsAtMS int64 `gorm:"column:starts_at_ms;not null;default:0;comment:投放开始时间UTC epoch ms" json:"startsAtMs"`
EndsAtMS int64 `gorm:"column:ends_at_ms;not null;default:0;comment:投放结束时间UTC epoch ms" json:"endsAtMs"`
CreatedAtMS int64 `gorm:"column:created_at_ms;autoCreateTime:milli" json:"createdAtMs"`
UpdatedAtMS int64 `gorm:"column:updated_at_ms;autoUpdateTime:milli" json:"updatedAtMs"`
}
func (AppSplashScreen) TableName() string {
return "admin_app_splash_screens"
}
type AppPopup struct {
ID uint `gorm:"primaryKey" json:"id"`
AppCode string `gorm:"size:32;not null;default:lalu;index:idx_admin_app_popups_app_sort;uniqueIndex:uk_admin_app_popups_code" json:"appCode"`
Code string `gorm:"size:80;not null;uniqueIndex:uk_admin_app_popups_code;comment:弹窗编码" json:"code"`
Name string `gorm:"size:80;not null;comment:弹窗名称" json:"name"`
ImageURL string `gorm:"column:image_url;size:1024;not null;default:'';comment:弹窗图片 URL可为空" json:"imageUrl"`
JumpType string `gorm:"size:16;not null;comment:跳转类型h5 或 app" json:"jumpType"`
JumpURL string `gorm:"column:jump_url;size:2048;not null;default:'';comment:跳转地址" json:"jumpUrl"`
DisplayPeriodDays int `gorm:"column:display_period_days;not null;default:0;comment:展示周期天数0 表示每次打开都展示" json:"displayPeriodDays"`
Status string `gorm:"size:24;index:idx_admin_app_popups_app_sort,not null;default:active" json:"status"`
SortOrder int `gorm:"index:idx_admin_app_popups_app_sort,not null;default:0" json:"sortOrder"`
StartsAtMS int64 `gorm:"column:starts_at_ms;not null;default:0;comment:生效开始时间UTC epoch ms" json:"startsAtMs"`
EndsAtMS int64 `gorm:"column:ends_at_ms;not null;default:0;comment:生效结束时间UTC epoch ms" json:"endsAtMs"`
CreatedAtMS int64 `gorm:"column:created_at_ms;autoCreateTime:milli" json:"createdAtMs"`
UpdatedAtMS int64 `gorm:"column:updated_at_ms;autoUpdateTime:milli" json:"updatedAtMs"`
}
func (AppPopup) TableName() string {
return "admin_app_popups"
}
type AppVersion struct {
ID uint `gorm:"primaryKey" json:"id"`
AppCode string `gorm:"size:32;uniqueIndex:uk_admin_app_versions_build,not null;default:lalu" json:"appCode"`

View File

@ -160,6 +160,129 @@ func (h *Handler) DeleteBanner(c *gin.Context) {
response.OK(c, gin.H{"deleted": true})
}
func (h *Handler) ListSplashScreens(c *gin.Context) {
options := shared.ListOptions(c)
items, err := h.service.ListSplashScreens(appctx.FromContext(c.Request.Context()), repository.AppSplashScreenListOptions{
Keyword: options.Keyword,
Status: options.Status,
Platform: strings.TrimSpace(c.Query("platform")),
RegionID: parseOptionalInt64(c.Query("regionId"), c.Query("region_id")),
Country: firstQuery(c, "countryCode", "country_code", "country"),
})
if err != nil {
response.ServerError(c, "获取开屏配置失败")
return
}
response.OK(c, gin.H{"items": items, "total": len(items)})
}
func (h *Handler) CreateSplashScreen(c *gin.Context) {
var request splashScreenRequest
if err := c.ShouldBindJSON(&request); err != nil {
response.BadRequest(c, "参数不正确")
return
}
item, err := h.service.CreateSplashScreen(appctx.FromContext(c.Request.Context()), request)
if err != nil {
response.BadRequest(c, err.Error())
return
}
shared.OperationLog(c, h.audit, "create-app-splash-screen", "admin_app_splash_screens", "success", fmt.Sprintf("splash_id=%d", item.ID))
response.Created(c, item)
}
func (h *Handler) UpdateSplashScreen(c *gin.Context) {
id, ok := splashScreenID(c)
if !ok {
return
}
var request splashScreenRequest
if err := c.ShouldBindJSON(&request); err != nil {
response.BadRequest(c, "参数不正确")
return
}
item, err := h.service.UpdateSplashScreen(appctx.FromContext(c.Request.Context()), id, request)
if err != nil {
response.BadRequest(c, err.Error())
return
}
shared.OperationLog(c, h.audit, "update-app-splash-screen", "admin_app_splash_screens", "success", fmt.Sprintf("splash_id=%d", item.ID))
response.OK(c, item)
}
func (h *Handler) DeleteSplashScreen(c *gin.Context) {
id, ok := splashScreenID(c)
if !ok {
return
}
if err := h.service.DeleteSplashScreen(appctx.FromContext(c.Request.Context()), id); err != nil {
response.BadRequest(c, err.Error())
return
}
shared.OperationLog(c, h.audit, "delete-app-splash-screen", "admin_app_splash_screens", "success", fmt.Sprintf("splash_id=%d", id))
response.OK(c, gin.H{"deleted": true})
}
func (h *Handler) ListPopups(c *gin.Context) {
options := shared.ListOptions(c)
items, err := h.service.ListPopups(appctx.FromContext(c.Request.Context()), repository.AppPopupListOptions{
Keyword: options.Keyword,
Status: options.Status,
})
if err != nil {
response.ServerError(c, "获取弹窗配置失败")
return
}
response.OK(c, gin.H{"items": items, "total": len(items)})
}
func (h *Handler) CreatePopup(c *gin.Context) {
var request popupRequest
if err := c.ShouldBindJSON(&request); err != nil {
response.BadRequest(c, "参数不正确")
return
}
item, err := h.service.CreatePopup(appctx.FromContext(c.Request.Context()), request)
if err != nil {
response.BadRequest(c, err.Error())
return
}
shared.OperationLog(c, h.audit, "create-app-popup", "admin_app_popups", "success", fmt.Sprintf("popup_id=%d", item.ID))
response.Created(c, item)
}
func (h *Handler) UpdatePopup(c *gin.Context) {
id, ok := popupID(c)
if !ok {
return
}
var request popupRequest
if err := c.ShouldBindJSON(&request); err != nil {
response.BadRequest(c, "参数不正确")
return
}
item, err := h.service.UpdatePopup(appctx.FromContext(c.Request.Context()), id, request)
if err != nil {
response.BadRequest(c, err.Error())
return
}
shared.OperationLog(c, h.audit, "update-app-popup", "admin_app_popups", "success", fmt.Sprintf("popup_id=%d", item.ID))
response.OK(c, item)
}
func (h *Handler) DeletePopup(c *gin.Context) {
id, ok := popupID(c)
if !ok {
return
}
if err := h.service.DeletePopup(appctx.FromContext(c.Request.Context()), id); err != nil {
response.BadRequest(c, err.Error())
return
}
shared.OperationLog(c, h.audit, "delete-app-popup", "admin_app_popups", "success", fmt.Sprintf("popup_id=%d", id))
response.OK(c, gin.H{"deleted": true})
}
func (h *Handler) ListAppVersions(c *gin.Context) {
items, err := h.service.ListAppVersions(appctx.FromContext(c.Request.Context()), repository.AppVersionListOptions{
Keyword: strings.TrimSpace(c.Query("keyword")),
@ -297,6 +420,24 @@ func bannerID(c *gin.Context) (uint, bool) {
return uint(value), true
}
func splashScreenID(c *gin.Context) (uint, bool) {
value, err := strconv.ParseUint(c.Param("splash_id"), 10, 64)
if err != nil || value == 0 {
response.BadRequest(c, "splash screen id is invalid")
return 0, false
}
return uint(value), true
}
func popupID(c *gin.Context) (uint, bool) {
value, err := strconv.ParseUint(c.Param("popup_id"), 10, 64)
if err != nil || value == 0 {
response.BadRequest(c, "popup id is invalid")
return 0, false
}
return uint(value), true
}
func appVersionID(c *gin.Context) (uint, bool) {
value, err := strconv.ParseUint(c.Param("version_id"), 10, 64)
if err != nil || value == 0 {

View File

@ -27,6 +27,34 @@ type bannerRequest struct {
EndsAtMs int64 `json:"endsAtMs"`
}
type splashScreenRequest struct {
CoverURL string `json:"coverUrl" binding:"required"`
SplashType string `json:"splashType" binding:"required"`
Param string `json:"param"`
Status string `json:"status" binding:"required"`
Platform string `json:"platform" binding:"required"`
SortOrder int `json:"sortOrder"`
RegionID int64 `json:"regionId"`
CountryCode string `json:"countryCode"`
Description string `json:"description"`
DisplayDurationMs int `json:"displayDurationMs"`
StartsAtMs int64 `json:"startsAtMs"`
EndsAtMs int64 `json:"endsAtMs"`
}
type popupRequest struct {
Code string `json:"code" binding:"required"`
Name string `json:"name" binding:"required"`
ImageURL string `json:"imageUrl"`
JumpType string `json:"jumpType" binding:"required"`
JumpURL string `json:"jumpUrl" binding:"required"`
DisplayPeriodDays int `json:"displayPeriodDays"`
Status string `json:"status" binding:"required"`
SortOrder int `json:"sortOrder"`
StartsAtMs int64 `json:"startsAtMs"`
EndsAtMs int64 `json:"endsAtMs"`
}
type appVersionRequest struct {
Platform string `json:"platform" binding:"required"`
Version string `json:"version" binding:"required"`

View File

@ -27,6 +27,16 @@ func RegisterRoutes(protected *gin.RouterGroup, h *Handler) {
protected.PUT("/admin/app-config/banners/:banner_id", middleware.RequirePermission("app-config:update"), h.UpdateBanner)
protected.DELETE("/admin/app-config/banners/:banner_id", middleware.RequirePermission("app-config:update"), h.DeleteBanner)
protected.GET("/admin/app-config/splash-screens", middleware.RequirePermission("app-config:view"), h.ListSplashScreens)
protected.POST("/admin/app-config/splash-screens", middleware.RequirePermission("app-config:update"), h.CreateSplashScreen)
protected.PUT("/admin/app-config/splash-screens/:splash_id", middleware.RequirePermission("app-config:update"), h.UpdateSplashScreen)
protected.DELETE("/admin/app-config/splash-screens/:splash_id", middleware.RequirePermission("app-config:update"), h.DeleteSplashScreen)
protected.GET("/admin/app-config/popups", middleware.RequirePermission("app-config:view"), h.ListPopups)
protected.POST("/admin/app-config/popups", middleware.RequirePermission("app-config:update"), h.CreatePopup)
protected.PUT("/admin/app-config/popups/:popup_id", middleware.RequirePermission("app-config:update"), h.UpdatePopup)
protected.DELETE("/admin/app-config/popups/:popup_id", middleware.RequirePermission("app-config:update"), h.DeletePopup)
protected.GET("/admin/app-config/versions", middleware.RequirePermission("app-version:view"), h.ListAppVersions)
protected.POST("/admin/app-config/versions", middleware.RequirePermission("app-version:create"), h.CreateAppVersion)
protected.PUT("/admin/app-config/versions/:version_id", middleware.RequirePermission("app-version:update"), h.UpdateAppVersion)

View File

@ -23,6 +23,8 @@ const (
bannerStatusActive = "active"
bannerStatusDisabled = "disabled"
bannerStatusExpired = "expired"
splashDefaultDisplayDurationMS = 3000
)
var bannerDisplayScopeOrder = []string{bannerDisplayScopeHome, bannerDisplayScopeRoom, bannerDisplayScopeRecharge}
@ -59,6 +61,42 @@ type AppBanner struct {
UpdatedAtMs int64 `json:"updatedAtMs"`
}
type AppSplashScreen struct {
ID uint `json:"id"`
AppCode string `json:"appCode"`
CoverURL string `json:"coverUrl"`
SplashType string `json:"splashType"`
Param string `json:"param"`
Status string `json:"status"`
Platform string `json:"platform"`
SortOrder int `json:"sortOrder"`
RegionID int64 `json:"regionId"`
CountryCode string `json:"countryCode"`
Description string `json:"description"`
DisplayDurationMs int `json:"displayDurationMs"`
StartsAtMs int64 `json:"startsAtMs"`
EndsAtMs int64 `json:"endsAtMs"`
CreatedAtMs int64 `json:"createdAtMs"`
UpdatedAtMs int64 `json:"updatedAtMs"`
}
type AppPopup struct {
ID uint `json:"id"`
AppCode string `json:"appCode"`
Code string `json:"code"`
Name string `json:"name"`
ImageURL string `json:"imageUrl"`
JumpType string `json:"jumpType"`
JumpURL string `json:"jumpUrl"`
DisplayPeriodDays int `json:"displayPeriodDays"`
Status string `json:"status"`
SortOrder int `json:"sortOrder"`
StartsAtMs int64 `json:"startsAtMs"`
EndsAtMs int64 `json:"endsAtMs"`
CreatedAtMs int64 `json:"createdAtMs"`
UpdatedAtMs int64 `json:"updatedAtMs"`
}
type AppVersion struct {
ID uint `json:"id"`
AppCode string `json:"appCode"`
@ -225,6 +263,145 @@ func (s *AppConfigService) DeleteBanner(appCode string, id uint) error {
return s.store.DeleteAppBanner(appctx.Normalize(appCode), id)
}
func (s *AppConfigService) ListSplashScreens(appCode string, options repository.AppSplashScreenListOptions) ([]AppSplashScreen, error) {
// 列表入口先把管理后台传入的筛选条件统一成数据库稳定值,再执行过期回收,避免已过期 active 数据继续展示。
options.AppCode = appctx.Normalize(appCode)
options.Status = normalizeBannerStatus(options.Status)
options.Platform = normalizeBannerPlatform(options.Platform)
options.Country = normalizeCountryCode(options.Country)
options.Keyword = strings.TrimSpace(options.Keyword)
if err := s.store.ExpireAppSplashScreens(options.AppCode, time.Now().UTC().UnixMilli()); err != nil {
return nil, err
}
items, err := s.store.ListAppSplashScreens(options)
if err != nil {
return nil, err
}
out := make([]AppSplashScreen, 0, len(items))
for _, item := range items {
out = append(out, appSplashScreenFromModel(item))
}
return out, nil
}
func (s *AppConfigService) CreateSplashScreen(appCode string, req splashScreenRequest) (AppSplashScreen, error) {
// 创建时只接受 service 归一化后的模型,保证 status、平台、国家码和时间窗口在入库前已经被校验。
item, err := splashScreenModelFromRequest(appctx.Normalize(appCode), req)
if err != nil {
return AppSplashScreen{}, err
}
if err := s.store.CreateAppSplashScreen(&item); err != nil {
return AppSplashScreen{}, err
}
return appSplashScreenFromModel(item), nil
}
func (s *AppConfigService) UpdateSplashScreen(appCode string, id uint, req splashScreenRequest) (AppSplashScreen, error) {
// 更新先按 app_code 和 id 锁定原记录,再只覆盖业务字段,避免请求体伪造 app_code 或创建时间。
item, err := s.store.GetAppSplashScreen(appctx.Normalize(appCode), id)
if err != nil {
return AppSplashScreen{}, err
}
updated, err := splashScreenModelFromRequest(item.AppCode, req)
if err != nil {
return AppSplashScreen{}, err
}
item.CoverURL = updated.CoverURL
item.SplashType = updated.SplashType
item.Param = updated.Param
item.Status = updated.Status
item.Platform = updated.Platform
item.SortOrder = updated.SortOrder
item.RegionID = updated.RegionID
item.CountryCode = updated.CountryCode
item.Description = updated.Description
item.DisplayDurationMS = updated.DisplayDurationMS
item.StartsAtMS = updated.StartsAtMS
item.EndsAtMS = updated.EndsAtMS
if err := s.store.UpdateAppSplashScreen(&item); err != nil {
return AppSplashScreen{}, err
}
return appSplashScreenFromModel(item), nil
}
func (s *AppConfigService) DeleteSplashScreen(appCode string, id uint) error {
return s.store.DeleteAppSplashScreen(appctx.Normalize(appCode), id)
}
func (s *AppConfigService) ListPopups(appCode string, options repository.AppPopupListOptions) ([]AppPopup, error) {
// 列表先清理已过期 active 记录,再返回后台可见数据,保证运营看到的状态和 App 实际可展示状态一致。
options.AppCode = appctx.Normalize(appCode)
options.Status = normalizeBannerStatus(options.Status)
options.Keyword = strings.TrimSpace(options.Keyword)
if err := s.store.ExpireAppPopups(options.AppCode, time.Now().UTC().UnixMilli()); err != nil {
return nil, err
}
items, err := s.store.ListAppPopups(options)
if err != nil {
return nil, err
}
out := make([]AppPopup, 0, len(items))
for _, item := range items {
out = append(out, appPopupFromModel(item))
}
return out, nil
}
func (s *AppConfigService) CreatePopup(appCode string, req popupRequest) (AppPopup, error) {
item, err := popupModelFromRequest(appctx.Normalize(appCode), req)
if err != nil {
return AppPopup{}, err
}
exists, err := s.store.HasAppPopupCode(item.AppCode, item.Code, 0)
if err != nil {
return AppPopup{}, err
}
if exists {
return AppPopup{}, errors.New("popup code already exists")
}
if err := s.store.CreateAppPopup(&item); err != nil {
return AppPopup{}, err
}
return appPopupFromModel(item), nil
}
func (s *AppConfigService) UpdatePopup(appCode string, id uint, req popupRequest) (AppPopup, error) {
// 更新时先按 app_code 锁定原记录,再覆盖可编辑字段,避免请求体伪造租户和创建时间。
item, err := s.store.GetAppPopup(appctx.Normalize(appCode), id)
if err != nil {
return AppPopup{}, err
}
updated, err := popupModelFromRequest(item.AppCode, req)
if err != nil {
return AppPopup{}, err
}
exists, err := s.store.HasAppPopupCode(item.AppCode, updated.Code, item.ID)
if err != nil {
return AppPopup{}, err
}
if exists {
return AppPopup{}, errors.New("popup code already exists")
}
item.Code = updated.Code
item.Name = updated.Name
item.ImageURL = updated.ImageURL
item.JumpType = updated.JumpType
item.JumpURL = updated.JumpURL
item.DisplayPeriodDays = updated.DisplayPeriodDays
item.Status = updated.Status
item.SortOrder = updated.SortOrder
item.StartsAtMS = updated.StartsAtMS
item.EndsAtMS = updated.EndsAtMS
if err := s.store.UpdateAppPopup(&item); err != nil {
return AppPopup{}, err
}
return appPopupFromModel(item), nil
}
func (s *AppConfigService) DeletePopup(appCode string, id uint) error {
return s.store.DeleteAppPopup(appctx.Normalize(appCode), id)
}
func (s *AppConfigService) ListAppVersions(appCode string, options repository.AppVersionListOptions) ([]AppVersion, error) {
options.AppCode = appctx.Normalize(appCode)
options.Platform = normalizeAppPlatform(options.Platform)
@ -495,6 +672,177 @@ func appBannerFromModel(item model.AppBanner) AppBanner {
}
}
func splashScreenModelFromRequest(appCode string, req splashScreenRequest) (model.AppSplashScreen, error) {
item := model.AppSplashScreen{
AppCode: appctx.Normalize(appCode),
CoverURL: strings.TrimSpace(req.CoverURL),
SplashType: normalizeSplashType(req.SplashType),
Param: strings.TrimSpace(req.Param),
Status: normalizeBannerStatus(req.Status),
Platform: normalizeBannerPlatform(req.Platform),
SortOrder: req.SortOrder,
RegionID: req.RegionID,
CountryCode: normalizeCountryCode(req.CountryCode),
Description: strings.TrimSpace(req.Description),
DisplayDurationMS: normalizeSplashDisplayDurationMS(req.DisplayDurationMs),
StartsAtMS: req.StartsAtMs,
EndsAtMS: req.EndsAtMs,
}
if item.CoverURL == "" || len(item.CoverURL) > 1024 {
return model.AppSplashScreen{}, errors.New("splash cover is invalid")
}
if containsWhitespace(item.CoverURL) {
return model.AppSplashScreen{}, errors.New("splash cover cannot contain whitespace")
}
if item.SplashType != "h5" && item.SplashType != "app" {
return model.AppSplashScreen{}, errors.New("splash type is invalid")
}
if item.SplashType == "h5" {
// H5 开屏必须带可直接打开的链接APP 类型参数由客户端解释,所以只做长度限制。
if item.Param == "" {
return model.AppSplashScreen{}, errors.New("h5 link is required")
}
if err := validateH5URL(item.Param); err != nil {
return model.AppSplashScreen{}, err
}
}
if len(item.Param) > 2048 {
return model.AppSplashScreen{}, errors.New("splash param is too long")
}
if item.Status != bannerStatusActive && item.Status != bannerStatusDisabled && item.Status != bannerStatusExpired {
return model.AppSplashScreen{}, errors.New("splash status is invalid")
}
if item.StartsAtMS < 0 || item.EndsAtMS < 0 {
return model.AppSplashScreen{}, errors.New("splash delivery time is invalid")
}
if item.StartsAtMS > 0 && item.EndsAtMS > 0 && item.StartsAtMS >= item.EndsAtMS {
return model.AppSplashScreen{}, errors.New("splash delivery time range is invalid")
}
if item.Status == bannerStatusActive && item.EndsAtMS > 0 && item.EndsAtMS <= time.Now().UTC().UnixMilli() {
// 管理员保存已经结束的 active 配置时直接落为 expired列表和 App 侧都不会误判为可投放。
item.Status = bannerStatusExpired
}
if item.Platform != "android" && item.Platform != "ios" {
return model.AppSplashScreen{}, errors.New("splash platform is invalid")
}
if item.RegionID < 0 {
return model.AppSplashScreen{}, errors.New("splash region is invalid")
}
if item.CountryCode != "" && !validCountryCode(item.CountryCode) {
return model.AppSplashScreen{}, errors.New("splash country is invalid")
}
if utf8.RuneCountInString(item.Description) > 255 {
return model.AppSplashScreen{}, errors.New("splash description is too long")
}
if req.DisplayDurationMs < 0 {
return model.AppSplashScreen{}, errors.New("splash display duration is invalid")
}
return item, nil
}
func appSplashScreenFromModel(item model.AppSplashScreen) AppSplashScreen {
// DTO 只返回后台页面需要的字段不暴露数据库列名gateway 另有 snake_case 的 App 输出结构。
return AppSplashScreen{
ID: item.ID,
AppCode: item.AppCode,
CoverURL: item.CoverURL,
SplashType: item.SplashType,
Param: item.Param,
Status: item.Status,
Platform: item.Platform,
SortOrder: item.SortOrder,
RegionID: item.RegionID,
CountryCode: item.CountryCode,
Description: item.Description,
DisplayDurationMs: item.DisplayDurationMS,
StartsAtMs: item.StartsAtMS,
EndsAtMs: item.EndsAtMS,
CreatedAtMs: item.CreatedAtMS,
UpdatedAtMs: item.UpdatedAtMS,
}
}
func popupModelFromRequest(appCode string, req popupRequest) (model.AppPopup, error) {
item := model.AppPopup{
AppCode: appctx.Normalize(appCode),
Code: strings.TrimSpace(req.Code),
Name: strings.TrimSpace(req.Name),
ImageURL: strings.TrimSpace(req.ImageURL),
JumpType: normalizeJumpType(req.JumpType),
JumpURL: strings.TrimSpace(req.JumpURL),
DisplayPeriodDays: req.DisplayPeriodDays,
Status: normalizeBannerStatus(req.Status),
SortOrder: req.SortOrder,
StartsAtMS: req.StartsAtMs,
EndsAtMS: req.EndsAtMs,
}
if err := validateH5Key(item.Code); err != nil {
return model.AppPopup{}, errors.New("popup code is invalid")
}
if item.Name == "" || utf8.RuneCountInString(item.Name) > 80 {
return model.AppPopup{}, errors.New("popup name is invalid")
}
if len(item.ImageURL) > 1024 {
return model.AppPopup{}, errors.New("popup image is too long")
}
if item.ImageURL != "" && containsWhitespace(item.ImageURL) {
return model.AppPopup{}, errors.New("popup image cannot contain whitespace")
}
if item.JumpType != "h5" && item.JumpType != "app" {
return model.AppPopup{}, errors.New("popup jump type is invalid")
}
if item.JumpURL == "" {
return model.AppPopup{}, errors.New("popup jump url is required")
}
if len(item.JumpURL) > 2048 {
return model.AppPopup{}, errors.New("popup jump url is too long")
}
if item.JumpType == "h5" {
// H5 弹窗必须是无空白的可打开地址APP 类型由客户端解释路径,但同样不允许空白字符破坏路由解析。
if err := validateH5URL(item.JumpURL); err != nil {
return model.AppPopup{}, err
}
} else if containsWhitespace(item.JumpURL) {
return model.AppPopup{}, errors.New("popup jump url cannot contain whitespace")
}
if item.DisplayPeriodDays < 0 {
return model.AppPopup{}, errors.New("popup display period is invalid")
}
if item.Status != bannerStatusActive && item.Status != bannerStatusDisabled && item.Status != bannerStatusExpired {
return model.AppPopup{}, errors.New("popup status is invalid")
}
if item.StartsAtMS < 0 || item.EndsAtMS < 0 {
return model.AppPopup{}, errors.New("popup effective time is invalid")
}
if item.StartsAtMS > 0 && item.EndsAtMS > 0 && item.StartsAtMS >= item.EndsAtMS {
return model.AppPopup{}, errors.New("popup effective time range is invalid")
}
if item.Status == bannerStatusActive && item.EndsAtMS > 0 && item.EndsAtMS <= time.Now().UTC().UnixMilli() {
// 已经结束的 active 弹窗保存为 expiredApp 接口不会再下发,后台也能直接看到过期状态。
item.Status = bannerStatusExpired
}
return item, nil
}
func appPopupFromModel(item model.AppPopup) AppPopup {
return AppPopup{
ID: item.ID,
AppCode: item.AppCode,
Code: item.Code,
Name: item.Name,
ImageURL: item.ImageURL,
JumpType: item.JumpType,
JumpURL: item.JumpURL,
DisplayPeriodDays: item.DisplayPeriodDays,
Status: item.Status,
SortOrder: item.SortOrder,
StartsAtMs: item.StartsAtMS,
EndsAtMs: item.EndsAtMS,
CreatedAtMs: item.CreatedAtMS,
UpdatedAtMs: item.UpdatedAtMS,
}
}
func appVersionModelFromRequest(appCode string, req appVersionRequest) (model.AppVersion, error) {
item := model.AppVersion{
AppCode: appctx.Normalize(appCode),
@ -580,6 +928,22 @@ func normalizeBannerType(value string) string {
return strings.ToLower(strings.TrimSpace(value))
}
func normalizeSplashType(value string) string {
return strings.ToLower(strings.TrimSpace(value))
}
func normalizeJumpType(value string) string {
return strings.ToLower(strings.TrimSpace(value))
}
func normalizeSplashDisplayDurationMS(value int) int {
// 管理端允许 0 表示“客户端默认 3 秒”,空请求和历史数据都统一补成 3000ms避免 App 侧拿到缺省字段后出现 0 秒闪退式开屏。
if value == 0 {
return splashDefaultDisplayDurationMS
}
return value
}
func normalizeBannerStatus(value string) string {
value = strings.ToLower(strings.TrimSpace(value))
if value == "" {

View File

@ -104,6 +104,201 @@ func TestAppBannerFromModelReturnsDisplayScopes(t *testing.T) {
}
}
func TestSplashScreenModelFromRequestValidatesH5Config(t *testing.T) {
startsAtMs := time.Now().UTC().Add(time.Hour).UnixMilli()
endsAtMs := startsAtMs + int64(time.Hour/time.Millisecond)
item, err := splashScreenModelFromRequest("lalu", splashScreenRequest{
CoverURL: "https://cdn.example.com/splash.png",
SplashType: "h5",
Param: "https://h5.example.com/splash",
Status: bannerStatusActive,
Platform: "android",
SortOrder: 7,
RegionID: 1001,
CountryCode: "us",
Description: "launch campaign",
DisplayDurationMs: 4200,
StartsAtMs: startsAtMs,
EndsAtMs: endsAtMs,
})
if err != nil {
t.Fatalf("splash screen model should be valid: %v", err)
}
if item.AppCode != "lalu" || item.SplashType != "h5" || item.CountryCode != "US" || item.DisplayDurationMS != 4200 || item.StartsAtMS != startsAtMs || item.EndsAtMS != endsAtMs {
t.Fatalf("splash screen model mismatch: %+v", item)
}
}
func TestSplashScreenModelFromRequestDefaultsDisplayDuration(t *testing.T) {
item, err := splashScreenModelFromRequest("lalu", splashScreenRequest{
CoverURL: "https://cdn.example.com/splash.png",
SplashType: "app",
Status: bannerStatusActive,
Platform: "android",
})
if err != nil {
t.Fatalf("splash screen model should accept missing display duration: %v", err)
}
if item.DisplayDurationMS != splashDefaultDisplayDurationMS {
t.Fatalf("display duration should default to %d, got %+v", splashDefaultDisplayDurationMS, item)
}
}
func TestSplashScreenModelFromRequestRejectsNegativeDisplayDuration(t *testing.T) {
_, err := splashScreenModelFromRequest("lalu", splashScreenRequest{
CoverURL: "https://cdn.example.com/splash.png",
SplashType: "app",
Status: bannerStatusActive,
Platform: "android",
DisplayDurationMs: -1,
})
if err == nil {
t.Fatal("expected negative display duration to fail")
}
}
func TestSplashScreenModelFromRequestRequiresH5Link(t *testing.T) {
_, err := splashScreenModelFromRequest("lalu", splashScreenRequest{
CoverURL: "https://cdn.example.com/splash.png",
SplashType: "h5",
Status: bannerStatusActive,
Platform: "android",
})
if err == nil {
t.Fatal("expected h5 splash screen without link to fail")
}
}
func TestSplashScreenModelFromRequestExpiresEndedActiveConfig(t *testing.T) {
item, err := splashScreenModelFromRequest("lalu", splashScreenRequest{
CoverURL: "https://cdn.example.com/splash.png",
SplashType: "app",
Status: bannerStatusActive,
Platform: "ios",
EndsAtMs: 1,
})
if err != nil {
t.Fatalf("ended app splash screen should be accepted: %v", err)
}
if item.Status != bannerStatusExpired {
t.Fatalf("ended active splash screen must become expired: %+v", item)
}
}
func TestAppSplashScreenFromModelMapsDTOFields(t *testing.T) {
item := appSplashScreenFromModel(model.AppSplashScreen{
ID: 9,
AppCode: "lalu",
CoverURL: "https://cdn.example.com/splash.png",
SplashType: "app",
Param: "profile",
Status: bannerStatusDisabled,
Platform: "ios",
SortOrder: 3,
RegionID: 2002,
CountryCode: "BR",
Description: "disabled campaign",
DisplayDurationMS: 5000,
StartsAtMS: 1700000000000,
EndsAtMS: 1800000000000,
CreatedAtMS: 1690000000000,
UpdatedAtMS: 1700000002000,
})
if item.ID != 9 || item.SplashType != "app" || item.Param != "profile" || item.RegionID != 2002 || item.DisplayDurationMs != 5000 || item.UpdatedAtMs != 1700000002000 {
t.Fatalf("splash screen dto mismatch: %+v", item)
}
}
func TestPopupModelFromRequestValidatesOptionalImageAndPeriod(t *testing.T) {
startsAtMs := time.Now().UTC().Add(time.Hour).UnixMilli()
endsAtMs := startsAtMs + int64(time.Hour/time.Millisecond)
item, err := popupModelFromRequest("lalu", popupRequest{
Code: "new-user-popup",
Name: "新人活动",
ImageURL: "",
JumpType: "h5",
JumpURL: "https://h5.example.com/new-user",
DisplayPeriodDays: 2,
Status: bannerStatusActive,
SortOrder: 4,
StartsAtMs: startsAtMs,
EndsAtMs: endsAtMs,
})
if err != nil {
t.Fatalf("popup model should be valid: %v", err)
}
if item.AppCode != "lalu" || item.Code != "new-user-popup" || item.Name != "新人活动" || item.ImageURL != "" || item.JumpType != "h5" || item.DisplayPeriodDays != 2 || item.StartsAtMS != startsAtMs || item.EndsAtMS != endsAtMs {
t.Fatalf("popup model mismatch: %+v", item)
}
}
func TestPopupModelFromRequestRejectsInvalidJumpURL(t *testing.T) {
_, err := popupModelFromRequest("lalu", popupRequest{
Code: "activity-popup",
Name: "活动弹窗",
JumpType: "h5",
JumpURL: "https://h5.example.com/new user",
Status: bannerStatusActive,
})
if err == nil {
t.Fatal("expected popup h5 jump url with whitespace to fail")
}
}
func TestPopupModelFromRequestRejectsInvalidCode(t *testing.T) {
_, err := popupModelFromRequest("lalu", popupRequest{
Code: "activity popup",
Name: "活动弹窗",
JumpType: "app",
JumpURL: "activity/detail",
Status: bannerStatusActive,
})
if err == nil {
t.Fatal("expected popup code with whitespace to fail")
}
}
func TestPopupModelFromRequestExpiresEndedActivePopup(t *testing.T) {
item, err := popupModelFromRequest("lalu", popupRequest{
Code: "history-popup",
Name: "历史活动",
JumpType: "app",
JumpURL: "activity/detail",
Status: bannerStatusActive,
EndsAtMs: 1,
})
if err != nil {
t.Fatalf("ended popup should be accepted: %v", err)
}
if item.Status != bannerStatusExpired {
t.Fatalf("ended active popup must become expired: %+v", item)
}
}
func TestAppPopupFromModelMapsDTOFields(t *testing.T) {
item := appPopupFromModel(model.AppPopup{
ID: 10,
AppCode: "lalu",
Code: "recharge-popup",
Name: "充值活动",
ImageURL: "https://cdn.example.com/popup.png",
JumpType: "app",
JumpURL: "recharge",
DisplayPeriodDays: 0,
Status: bannerStatusDisabled,
SortOrder: 8,
StartsAtMS: 1700000000000,
EndsAtMS: 1800000000000,
CreatedAtMS: 1690000000000,
UpdatedAtMS: 1700000002000,
})
if item.ID != 10 || item.Code != "recharge-popup" || item.Name != "充值活动" || item.JumpType != "app" || item.JumpURL != "recharge" || item.DisplayPeriodDays != 0 || item.UpdatedAtMs != 1700000002000 {
t.Fatalf("popup dto mismatch: %+v", item)
}
}
func bannerModel(displayScope string) model.AppBanner {
return model.AppBanner{
ID: 1,

View File

@ -32,21 +32,24 @@ type Service struct {
}
type AppUser struct {
Avatar string `json:"avatar"`
Coin int64 `json:"coin"`
Country string `json:"country"`
CountryDisplayName string `json:"countryDisplayName"`
CountryName string `json:"countryName"`
CreatedAtMs int64 `json:"createdAtMs"`
DisplayUserID string `json:"displayUserId"`
Gender string `json:"gender"`
LastActiveAtMs int64 `json:"lastActiveAtMs"`
RegionID int64 `json:"regionId"`
RegionName string `json:"regionName"`
Status string `json:"status"`
UpdatedAtMs int64 `json:"updatedAtMs"`
UserID string `json:"userId"`
Username string `json:"username"`
Avatar string `json:"avatar"`
Coin int64 `json:"coin"`
Country string `json:"country"`
CountryDisplayName string `json:"countryDisplayName"`
CountryName string `json:"countryName"`
CreatedAtMs int64 `json:"createdAtMs"`
DefaultDisplayUserID string `json:"defaultDisplayUserId"`
DisplayUserID string `json:"displayUserId"`
Gender string `json:"gender"`
LastActiveAtMs int64 `json:"lastActiveAtMs"`
PrettyDisplayUserID string `json:"prettyDisplayUserId"`
PrettyID string `json:"prettyId"`
RegionID int64 `json:"regionId"`
RegionName string `json:"regionName"`
Status string `json:"status"`
UpdatedAtMs int64 `json:"updatedAtMs"`
UserID string `json:"userId"`
Username string `json:"username"`
}
type SetUserStatusResult struct {
@ -84,9 +87,11 @@ func (s *Service) ListUsers(ctx context.Context, query listQuery) ([]AppUser, in
items, err := s.listUsersSortedByCoin(ctx, query, whereSQL, args)
return items, total, err
}
nowMs := nowMillis()
rows, err := s.userDB.QueryContext(ctx, fmt.Sprintf(`
SELECT u.user_id,
u.current_display_user_id,
u.default_display_user_id,
COALESCE(u.username, ''),
COALESCE(u.avatar, ''),
COALESCE(u.gender, ''),
@ -123,10 +128,11 @@ func (s *Service) ListUsers(ctx context.Context, query listQuery) ([]AppUser, in
COALESCE((SELECT MAX(s.updated_at_ms) FROM auth_sessions s WHERE s.app_code = u.app_code AND s.user_id = u.user_id), 0),
COALESCE((SELECT MAX(l.created_at_ms) FROM login_audit l WHERE l.app_code = u.app_code AND l.user_id = u.user_id), 0)
)
%s
%s
%s
LIMIT ? OFFSET ?
`, whereSQL, appUserOrderSQL(query)), append(args, query.PageSize, offset(query.Page, query.PageSize))...)
`, appUserPrettySelectColumns(), whereSQL, appUserOrderSQL(query)), append(appUserSelectArgs(nowMs, args), query.PageSize, offset(query.Page, query.PageSize))...)
if err != nil {
return nil, 0, err
}
@ -143,9 +149,11 @@ func (s *Service) ListUsers(ctx context.Context, query listQuery) ([]AppUser, in
}
func (s *Service) listUsersSortedByCoin(ctx context.Context, query listQuery, whereSQL string, args []any) ([]AppUser, error) {
nowMs := nowMillis()
rows, err := s.userDB.QueryContext(ctx, fmt.Sprintf(`
SELECT u.user_id,
u.current_display_user_id,
u.default_display_user_id,
COALESCE(u.username, ''),
COALESCE(u.avatar, ''),
COALESCE(u.gender, ''),
@ -182,8 +190,9 @@ func (s *Service) listUsersSortedByCoin(ctx context.Context, query listQuery, wh
COALESCE((SELECT MAX(s.updated_at_ms) FROM auth_sessions s WHERE s.app_code = u.app_code AND s.user_id = u.user_id), 0),
COALESCE((SELECT MAX(l.created_at_ms) FROM login_audit l WHERE l.app_code = u.app_code AND l.user_id = u.user_id), 0)
)
%s
%s
`, whereSQL), args...)
`, appUserPrettySelectColumns(), whereSQL), appUserSelectArgs(nowMs, args)...)
if err != nil {
return nil, err
}
@ -209,6 +218,7 @@ func scanAppUserRows(rows *sql.Rows, capacity int) ([]AppUser, []int64, error) {
if err := rows.Scan(
&userID,
&item.DisplayUserID,
&item.DefaultDisplayUserID,
&item.Username,
&item.Avatar,
&item.Gender,
@ -221,6 +231,8 @@ func scanAppUserRows(rows *sql.Rows, capacity int) ([]AppUser, []int64, error) {
&item.CreatedAtMs,
&item.UpdatedAtMs,
&item.LastActiveAtMs,
&item.PrettyDisplayUserID,
&item.PrettyID,
); err != nil {
return nil, nil, err
}
@ -241,9 +253,11 @@ func (s *Service) GetUser(ctx context.Context, userID int64) (AppUser, error) {
var item AppUser
var scannedUserID int64
appCode := appctx.FromContext(ctx)
nowMs := nowMillis()
err := s.userDB.QueryRowContext(ctx, `
SELECT u.user_id,
u.current_display_user_id,
u.default_display_user_id,
COALESCE(u.username, ''),
COALESCE(u.avatar, ''),
COALESCE(u.gender, ''),
@ -280,11 +294,13 @@ func (s *Service) GetUser(ctx context.Context, userID int64) (AppUser, error) {
COALESCE((SELECT MAX(s.updated_at_ms) FROM auth_sessions s WHERE s.app_code = u.app_code AND s.user_id = u.user_id), 0),
COALESCE((SELECT MAX(l.created_at_ms) FROM login_audit l WHERE l.app_code = u.app_code AND l.user_id = u.user_id), 0)
)
`+appUserPrettySelectColumns()+`
FROM users u
WHERE u.app_code = ? AND u.user_id = ?
`, appCode, userID).Scan(
`, nowMs, nowMs, appCode, userID).Scan(
&scannedUserID,
&item.DisplayUserID,
&item.DefaultDisplayUserID,
&item.Username,
&item.Avatar,
&item.Gender,
@ -297,6 +313,8 @@ func (s *Service) GetUser(ctx context.Context, userID int64) (AppUser, error) {
&item.CreatedAtMs,
&item.UpdatedAtMs,
&item.LastActiveAtMs,
&item.PrettyDisplayUserID,
&item.PrettyID,
)
if errors.Is(err, sql.ErrNoRows) {
return AppUser{}, ErrNotFound
@ -619,6 +637,43 @@ func appUserValidRegionExistsSQL(userAlias string) string {
)`, userAlias)
}
func appUserPrettySelectColumns() string {
return `,
COALESCE((
SELECT lease.display_user_id
FROM pretty_display_user_id_leases lease
WHERE lease.app_code = u.app_code
AND lease.user_id = u.user_id
AND lease.status = 'active'
AND (COALESCE(lease.expires_at_ms, 0) = 0 OR lease.expires_at_ms > ?)
ORDER BY lease.created_at_ms DESC, lease.lease_id DESC
LIMIT 1
), ''),
COALESCE((
SELECT pdi.pretty_id
FROM pretty_display_user_id_leases lease
JOIN pretty_display_ids pdi
ON pdi.app_code = lease.app_code
AND pdi.assigned_lease_id = lease.lease_id
AND pdi.status = 'assigned'
WHERE lease.app_code = u.app_code
AND lease.user_id = u.user_id
AND lease.status = 'active'
AND (COALESCE(lease.expires_at_ms, 0) = 0 OR lease.expires_at_ms > ?)
ORDER BY lease.created_at_ms DESC, lease.lease_id DESC
LIMIT 1
), '')`
}
func appUserSelectArgs(nowMs int64, whereArgs []any) []any {
// 用户资料页展示“原始短号 + 靓号”时,靓号以租约表为准读取,不能只依赖 users.current_display_user_id。
// 本地 initdb 或历史修复脚本可能只重放 users 快照active lease 和 pretty_display_ids 才是当前靓号归属的审计来源。
args := make([]any, 0, len(whereArgs)+2)
args = append(args, nowMs, nowMs)
args = append(args, whereArgs...)
return args
}
func normalizeAppUserSortBy(value string) string {
switch strings.ToLower(strings.TrimSpace(value)) {
case "coin", "coins":

View File

@ -24,18 +24,25 @@ func New(activity activityclient.Client, audit shared.OperationLogger) *Handler
}
type taskRequest struct {
TaskType string `json:"task_type"`
Category string `json:"category"`
MetricType string `json:"metric_type"`
Title string `json:"title"`
Description string `json:"description"`
TargetValue int64 `json:"target_value"`
TargetUnit string `json:"target_unit"`
RewardCoinAmount int64 `json:"reward_coin_amount"`
Status string `json:"status"`
SortOrder int32 `json:"sort_order"`
EffectiveFromMS int64 `json:"effective_from_ms"`
EffectiveToMS int64 `json:"effective_to_ms"`
TaskType string `json:"task_type"`
Category string `json:"category"`
MetricType string `json:"metric_type"`
Title string `json:"title"`
Description string `json:"description"`
AudienceType string `json:"audience_type"`
IconKey string `json:"icon_key"`
IconURL string `json:"icon_url"`
ActionType string `json:"action_type"`
ActionParam string `json:"action_param"`
ActionPayloadJSON string `json:"action_payload_json"`
DimensionFilterJSON string `json:"dimension_filter_json"`
TargetValue int64 `json:"target_value"`
TargetUnit string `json:"target_unit"`
RewardCoinAmount int64 `json:"reward_coin_amount"`
Status string `json:"status"`
SortOrder int32 `json:"sort_order"`
EffectiveFromMS int64 `json:"effective_from_ms"`
EffectiveToMS int64 `json:"effective_to_ms"`
}
type statusRequest struct {
@ -43,24 +50,31 @@ type statusRequest struct {
}
type taskDTO struct {
TaskID string `json:"task_id"`
TaskType string `json:"task_type"`
Category string `json:"category"`
MetricType string `json:"metric_type"`
Title string `json:"title"`
Description string `json:"description"`
TargetValue int64 `json:"target_value"`
TargetUnit string `json:"target_unit"`
RewardCoinAmount int64 `json:"reward_coin_amount"`
Status string `json:"status"`
SortOrder int32 `json:"sort_order"`
Version int64 `json:"version"`
EffectiveFromMS int64 `json:"effective_from_ms"`
EffectiveToMS int64 `json:"effective_to_ms"`
CreatedByAdminID int64 `json:"created_by_admin_id"`
UpdatedByAdminID int64 `json:"updated_by_admin_id"`
CreatedAtMS int64 `json:"created_at_ms"`
UpdatedAtMS int64 `json:"updated_at_ms"`
TaskID string `json:"task_id"`
TaskType string `json:"task_type"`
Category string `json:"category"`
MetricType string `json:"metric_type"`
Title string `json:"title"`
Description string `json:"description"`
AudienceType string `json:"audience_type"`
IconKey string `json:"icon_key"`
IconURL string `json:"icon_url"`
ActionType string `json:"action_type"`
ActionParam string `json:"action_param"`
ActionPayloadJSON string `json:"action_payload_json"`
DimensionFilterJSON string `json:"dimension_filter_json"`
TargetValue int64 `json:"target_value"`
TargetUnit string `json:"target_unit"`
RewardCoinAmount int64 `json:"reward_coin_amount"`
Status string `json:"status"`
SortOrder int32 `json:"sort_order"`
Version int64 `json:"version"`
EffectiveFromMS int64 `json:"effective_from_ms"`
EffectiveToMS int64 `json:"effective_to_ms"`
CreatedByAdminID int64 `json:"created_by_admin_id"`
UpdatedByAdminID int64 `json:"updated_by_admin_id"`
CreatedAtMS int64 `json:"created_at_ms"`
UpdatedAtMS int64 `json:"updated_at_ms"`
}
// ListTaskDefinitions 返回活动管理 > 每日任务页的 daily/exclusive 配置列表。
@ -153,22 +167,31 @@ func (h *Handler) SetTaskDefinitionStatus(c *gin.Context) {
}
func (r taskRequest) toProto(c *gin.Context, taskID string, meta *activityv1.RequestMeta) *activityv1.UpsertTaskDefinitionRequest {
// admin-server 只做字段裁剪和身份注入任务口径校验、JSON 对象校验和版本生成统一放在 activity-service。
// 图标和跳转属于展示字段直接透传给任务定义App 任务页会按 icon_key/icon_url/action_* 渲染。
return &activityv1.UpsertTaskDefinitionRequest{
Meta: meta,
TaskId: taskID,
TaskType: strings.TrimSpace(r.TaskType),
Category: strings.TrimSpace(r.Category),
MetricType: strings.TrimSpace(r.MetricType),
Title: strings.TrimSpace(r.Title),
Description: strings.TrimSpace(r.Description),
TargetValue: r.TargetValue,
TargetUnit: strings.TrimSpace(r.TargetUnit),
RewardCoinAmount: r.RewardCoinAmount,
Status: strings.TrimSpace(r.Status),
SortOrder: r.SortOrder,
EffectiveFromMs: r.EffectiveFromMS,
EffectiveToMs: r.EffectiveToMS,
OperatorAdminId: int64(middleware.CurrentUserID(c)),
Meta: meta,
TaskId: taskID,
TaskType: strings.TrimSpace(r.TaskType),
Category: strings.TrimSpace(r.Category),
MetricType: strings.TrimSpace(r.MetricType),
Title: strings.TrimSpace(r.Title),
Description: strings.TrimSpace(r.Description),
AudienceType: strings.TrimSpace(r.AudienceType),
IconKey: strings.TrimSpace(r.IconKey),
IconUrl: strings.TrimSpace(r.IconURL),
ActionType: strings.TrimSpace(r.ActionType),
ActionParam: strings.TrimSpace(r.ActionParam),
ActionPayloadJson: strings.TrimSpace(r.ActionPayloadJSON),
DimensionFilterJson: strings.TrimSpace(r.DimensionFilterJSON),
TargetValue: r.TargetValue,
TargetUnit: strings.TrimSpace(r.TargetUnit),
RewardCoinAmount: r.RewardCoinAmount,
Status: strings.TrimSpace(r.Status),
SortOrder: r.SortOrder,
EffectiveFromMs: r.EffectiveFromMS,
EffectiveToMs: r.EffectiveToMS,
OperatorAdminId: int64(middleware.CurrentUserID(c)),
}
}
@ -192,25 +215,33 @@ func taskFromProto(item *activityv1.TaskDefinition) taskDTO {
if item == nil {
return taskDTO{}
}
// 后台列表返回 activity-service 的完整任务定义,保证编辑弹窗能原样带回维度过滤、图标和跳转配置。
return taskDTO{
TaskID: item.GetTaskId(),
TaskType: item.GetTaskType(),
Category: item.GetCategory(),
MetricType: item.GetMetricType(),
Title: item.GetTitle(),
Description: item.GetDescription(),
TargetValue: item.GetTargetValue(),
TargetUnit: item.GetTargetUnit(),
RewardCoinAmount: item.GetRewardCoinAmount(),
Status: item.GetStatus(),
SortOrder: item.GetSortOrder(),
Version: item.GetVersion(),
EffectiveFromMS: item.GetEffectiveFromMs(),
EffectiveToMS: item.GetEffectiveToMs(),
CreatedByAdminID: item.GetCreatedByAdminId(),
UpdatedByAdminID: item.GetUpdatedByAdminId(),
CreatedAtMS: item.GetCreatedAtMs(),
UpdatedAtMS: item.GetUpdatedAtMs(),
TaskID: item.GetTaskId(),
TaskType: item.GetTaskType(),
Category: item.GetCategory(),
MetricType: item.GetMetricType(),
Title: item.GetTitle(),
Description: item.GetDescription(),
AudienceType: item.GetAudienceType(),
IconKey: item.GetIconKey(),
IconURL: item.GetIconUrl(),
ActionType: item.GetActionType(),
ActionParam: item.GetActionParam(),
ActionPayloadJSON: item.GetActionPayloadJson(),
DimensionFilterJSON: item.GetDimensionFilterJson(),
TargetValue: item.GetTargetValue(),
TargetUnit: item.GetTargetUnit(),
RewardCoinAmount: item.GetRewardCoinAmount(),
Status: item.GetStatus(),
SortOrder: item.GetSortOrder(),
Version: item.GetVersion(),
EffectiveFromMS: item.GetEffectiveFromMs(),
EffectiveToMS: item.GetEffectiveToMs(),
CreatedByAdminID: item.GetCreatedByAdminId(),
UpdatedByAdminID: item.GetUpdatedByAdminId(),
CreatedAtMS: item.GetCreatedAtMs(),
UpdatedAtMS: item.GetUpdatedAtMs(),
}
}

View File

@ -51,6 +51,9 @@ type tierDTO struct {
SortOrder int32 `json:"sort_order"`
CreatedAtMS int64 `json:"created_at_ms"`
UpdatedAtMS int64 `json:"updated_at_ms"`
USDMinorAmount int64 `json:"usd_minor_amount"`
GoogleProductID string `json:"google_product_id"`
DiscountPercent int32 `json:"discount_percent"`
}
type claimDTO struct {
@ -66,6 +69,9 @@ type claimDTO struct {
RechargeCoinAmount int64 `json:"recharge_coin_amount"`
RechargeSequence int64 `json:"recharge_sequence"`
RechargeType string `json:"recharge_type"`
TierUSDMinorAmount int64 `json:"tier_usd_minor_amount"`
GoogleProductID string `json:"google_product_id"`
RechargeUSDMinor int64 `json:"recharge_usd_minor"`
Status string `json:"status"`
WalletCommandID string `json:"wallet_command_id"`
WalletGrantID string `json:"wallet_grant_id"`
@ -86,7 +92,7 @@ type userDTO struct {
func (h *Handler) GetConfig(c *gin.Context) {
resp, err := h.activity.GetFirstRechargeRewardConfig(c.Request.Context(), &activityv1.GetFirstRechargeRewardConfigRequest{Meta: h.meta(c)})
if err != nil {
response.ServerError(c, "获取首冲奖励配置失败")
response.ServerError(c, "获取首充礼包配置失败")
return
}
response.OK(c, configFromProto(resp.GetConfig()))
@ -95,7 +101,7 @@ func (h *Handler) GetConfig(c *gin.Context) {
func (h *Handler) UpdateConfig(c *gin.Context) {
var req configRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "首冲奖励配置参数不正确")
response.BadRequest(c, "首充礼包配置参数不正确")
return
}
tiers := make([]*activityv1.FirstRechargeRewardTier, 0, len(req.Tiers))
@ -109,6 +115,9 @@ func (h *Handler) UpdateConfig(c *gin.Context) {
ResourceGroupId: tier.ResourceGroupID,
Status: strings.TrimSpace(tier.Status),
SortOrder: tier.SortOrder,
UsdMinorAmount: tier.USDMinorAmount,
GoogleProductId: strings.TrimSpace(tier.GoogleProductID),
DiscountPercent: tier.DiscountPercent,
})
}
resp, err := h.activity.UpdateFirstRechargeRewardConfig(c.Request.Context(), &activityv1.UpdateFirstRechargeRewardConfigRequest{
@ -145,7 +154,7 @@ func (h *Handler) ListClaims(c *gin.Context) {
PageSize: int32(options.PageSize),
})
if err != nil {
response.ServerError(c, "获取首冲奖励发放记录失败")
response.ServerError(c, "获取首充礼包发放记录失败")
return
}
items := make([]claimDTO, 0, len(resp.GetClaims()))
@ -282,6 +291,9 @@ func tierFromProto(tier *activityv1.FirstRechargeRewardTier) tierDTO {
SortOrder: tier.GetSortOrder(),
CreatedAtMS: tier.GetCreatedAtMs(),
UpdatedAtMS: tier.GetUpdatedAtMs(),
USDMinorAmount: tier.GetUsdMinorAmount(),
GoogleProductID: tier.GetGoogleProductId(),
DiscountPercent: tier.GetDiscountPercent(),
}
}
@ -301,6 +313,9 @@ func claimFromProto(claim *activityv1.FirstRechargeRewardClaim) claimDTO {
RechargeCoinAmount: claim.GetRechargeCoinAmount(),
RechargeSequence: claim.GetRechargeSequence(),
RechargeType: claim.GetRechargeType(),
TierUSDMinorAmount: claim.GetTierUsdMinorAmount(),
GoogleProductID: claim.GetGoogleProductId(),
RechargeUSDMinor: claim.GetRechargeUsdMinor(),
Status: claim.GetStatus(),
WalletCommandID: claim.GetWalletCommandId(),
WalletGrantID: claim.GetWalletGrantId(),

View File

@ -1,6 +1,10 @@
package gamemanagement
import gamev1 "hyapp.local/api/proto/game/v1"
import (
"strconv"
gamev1 "hyapp.local/api/proto/game/v1"
)
type platformDTO struct {
AppCode string `json:"appCode"`
@ -43,6 +47,105 @@ type catalogDTO struct {
UpdatedAtMS int64 `json:"updatedAtMs"`
}
type diceConfigDTO struct {
AppCode string `json:"appCode"`
GameID string `json:"gameId"`
Status string `json:"status"`
StakeOptions []diceStakeDTO `json:"stakeOptions"`
FeeBPS int32 `json:"feeBps"`
PoolBPS int32 `json:"poolBps"`
MinPlayers int32 `json:"minPlayers"`
MaxPlayers int32 `json:"maxPlayers"`
RobotEnabled bool `json:"robotEnabled"`
RobotMatchWaitMS int64 `json:"robotMatchWaitMs"`
PoolBalanceCoin int64 `json:"poolBalanceCoin"`
CreatedAtMS int64 `json:"createdAtMs"`
UpdatedAtMS int64 `json:"updatedAtMs"`
}
type diceStakeDTO struct {
StakeCoin int64 `json:"stakeCoin"`
Enabled bool `json:"enabled"`
SortOrder int32 `json:"sortOrder"`
}
type dicePoolAdjustmentDTO struct {
AdjustmentID string `json:"adjustmentId"`
GameID string `json:"gameId"`
AmountCoin int64 `json:"amountCoin"`
Direction string `json:"direction"`
Reason string `json:"reason"`
BalanceAfter int64 `json:"balanceAfter"`
CreatedAtMS int64 `json:"createdAtMs"`
}
type diceRobotDTO struct {
AppCode string `json:"appCode"`
GameID string `json:"gameId"`
UserID string `json:"userId"`
UserIDNumber int64 `json:"userIdNumber"`
ShortID string `json:"shortId"`
Nickname string `json:"nickname"`
Avatar string `json:"avatar"`
Status string `json:"status"`
CreatedByAdminID int64 `json:"createdByAdminId"`
CreatedAtMS int64 `json:"createdAtMs"`
UpdatedAtMS int64 `json:"updatedAtMs"`
}
type roomRPSConfigDTO struct {
AppCode string `json:"appCode"`
GameID string `json:"gameId"`
Status string `json:"status"`
ChallengeTimeoutMS int64 `json:"challengeTimeoutMs"`
RevealCountdownMS int64 `json:"revealCountdownMs"`
StakeGifts []roomRPSStakeGiftDTO `json:"stakeGifts"`
CreatedAtMS int64 `json:"createdAtMs"`
UpdatedAtMS int64 `json:"updatedAtMs"`
}
type roomRPSStakeGiftDTO struct {
GiftID string `json:"giftId"`
GiftIDNumber int64 `json:"giftIdNumber"`
GiftName string `json:"giftName"`
GiftIconURL string `json:"giftIconUrl"`
GiftPriceCoin int64 `json:"giftPriceCoin"`
Enabled bool `json:"enabled"`
SortOrder int32 `json:"sortOrder"`
}
type roomRPSChallengeDTO struct {
AppCode string `json:"appCode"`
ChallengeID string `json:"challengeId"`
RoomID string `json:"roomId"`
RegionID int64 `json:"regionId"`
Status string `json:"status"`
StakeGiftID string `json:"stakeGiftId"`
StakeGiftIDNumber int64 `json:"stakeGiftIdNumber"`
StakeCoin int64 `json:"stakeCoin"`
Initiator roomRPSPlayerDTO `json:"initiator"`
Challenger roomRPSPlayerDTO `json:"challenger"`
WinnerUserID string `json:"winnerUserId"`
WinnerUserIDNumber int64 `json:"winnerUserIdNumber"`
SettlementStatus string `json:"settlementStatus"`
FailureReason string `json:"failureReason"`
TimeoutAtMS int64 `json:"timeoutAtMs"`
RevealAtMS int64 `json:"revealAtMs"`
CreatedAtMS int64 `json:"createdAtMs"`
MatchedAtMS int64 `json:"matchedAtMs"`
SettledAtMS int64 `json:"settledAtMs"`
UpdatedAtMS int64 `json:"updatedAtMs"`
}
type roomRPSPlayerDTO struct {
UserID string `json:"userId"`
UserIDNumber int64 `json:"userIdNumber"`
Gesture string `json:"gesture"`
Result string `json:"result"`
BalanceAfter int64 `json:"balanceAfter"`
JoinedAtMS int64 `json:"joinedAtMs"`
}
func platformFromProto(item *gamev1.GamePlatform) platformDTO {
if item == nil {
return platformDTO{}
@ -88,3 +191,138 @@ func catalogFromProto(item *gamev1.GameCatalogItem) catalogDTO {
UpdatedAtMS: item.GetUpdatedAtMs(),
}
}
func diceConfigFromProto(item *gamev1.DiceConfig) diceConfigDTO {
if item == nil {
return diceConfigDTO{}
}
options := make([]diceStakeDTO, 0, len(item.GetStakeOptions()))
for _, option := range item.GetStakeOptions() {
options = append(options, diceStakeDTO{
StakeCoin: option.GetStakeCoin(),
Enabled: option.GetEnabled(),
SortOrder: option.GetSortOrder(),
})
}
return diceConfigDTO{
AppCode: item.GetAppCode(),
GameID: item.GetGameId(),
Status: item.GetStatus(),
StakeOptions: options,
FeeBPS: item.GetFeeBps(),
PoolBPS: item.GetPoolBps(),
MinPlayers: item.GetMinPlayers(),
MaxPlayers: item.GetMaxPlayers(),
RobotEnabled: item.GetRobotEnabled(),
RobotMatchWaitMS: item.GetRobotMatchWaitMs(),
PoolBalanceCoin: item.GetPoolBalanceCoin(),
CreatedAtMS: item.GetCreatedAtMs(),
UpdatedAtMS: item.GetUpdatedAtMs(),
}
}
func dicePoolAdjustmentFromProto(item *gamev1.DicePoolAdjustment) dicePoolAdjustmentDTO {
if item == nil {
return dicePoolAdjustmentDTO{}
}
return dicePoolAdjustmentDTO{
AdjustmentID: item.GetAdjustmentId(),
GameID: item.GetGameId(),
AmountCoin: item.GetAmountCoin(),
Direction: item.GetDirection(),
Reason: item.GetReason(),
BalanceAfter: item.GetBalanceAfter(),
CreatedAtMS: item.GetCreatedAtMs(),
}
}
func diceRobotFromProto(item *gamev1.DiceRobot) diceRobotDTO {
if item == nil {
return diceRobotDTO{}
}
return diceRobotDTO{
AppCode: item.GetAppCode(),
GameID: item.GetGameId(),
UserID: strconv.FormatInt(item.GetUserId(), 10),
UserIDNumber: item.GetUserId(),
Status: item.GetStatus(),
CreatedByAdminID: item.GetCreatedByAdminId(),
CreatedAtMS: item.GetCreatedAtMs(),
UpdatedAtMS: item.GetUpdatedAtMs(),
}
}
func roomRPSConfigFromProto(item *gamev1.RoomRPSConfig) roomRPSConfigDTO {
if item == nil {
return roomRPSConfigDTO{}
}
gifts := make([]roomRPSStakeGiftDTO, 0, len(item.GetStakeGifts()))
for _, gift := range item.GetStakeGifts() {
// 礼物详情由 game-service 结合礼物配置补齐admin-server 只保持展示字段和 ID 双写,不重新查礼物表。
gifts = append(gifts, roomRPSStakeGiftDTO{
GiftID: strconv.FormatInt(gift.GetGiftId(), 10),
GiftIDNumber: gift.GetGiftId(),
GiftName: gift.GetGiftName(),
GiftIconURL: gift.GetGiftIconUrl(),
GiftPriceCoin: gift.GetGiftPriceCoin(),
Enabled: gift.GetEnabled(),
SortOrder: gift.GetSortOrder(),
})
}
// DTO 只做 proto 到后台 JSON 的字段命名转换;配置默认值和合法性已经在 game-service/admin request 层处理。
return roomRPSConfigDTO{
AppCode: item.GetAppCode(),
GameID: item.GetGameId(),
Status: item.GetStatus(),
ChallengeTimeoutMS: item.GetChallengeTimeoutMs(),
RevealCountdownMS: item.GetRevealCountdownMs(),
StakeGifts: gifts,
CreatedAtMS: item.GetCreatedAtMs(),
UpdatedAtMS: item.GetUpdatedAtMs(),
}
}
func roomRPSChallengeFromProto(item *gamev1.RoomRPSChallenge) roomRPSChallengeDTO {
if item == nil {
return roomRPSChallengeDTO{}
}
// 后台订单页要同时服务浏览器展示和问题排查ID 用字符串保护前端精度,也保留 number 字段便于排序、复制和接口调试。
// 胜负、结算状态、失败原因全部按 game-service 返回值展示admin-server 不根据手势重新计算,避免和真实结算事实分叉。
return roomRPSChallengeDTO{
AppCode: item.GetAppCode(),
ChallengeID: item.GetChallengeId(),
RoomID: item.GetRoomId(),
RegionID: item.GetRegionId(),
Status: item.GetStatus(),
StakeGiftID: strconv.FormatInt(item.GetStakeGiftId(), 10),
StakeGiftIDNumber: item.GetStakeGiftId(),
StakeCoin: item.GetStakeCoin(),
Initiator: roomRPSPlayerFromProto(item.GetInitiator()),
Challenger: roomRPSPlayerFromProto(item.GetChallenger()),
WinnerUserID: strconv.FormatInt(item.GetWinnerUserId(), 10),
WinnerUserIDNumber: item.GetWinnerUserId(),
SettlementStatus: item.GetSettlementStatus(),
FailureReason: item.GetFailureReason(),
TimeoutAtMS: item.GetTimeoutAtMs(),
RevealAtMS: item.GetRevealAtMs(),
CreatedAtMS: item.GetCreatedAtMs(),
MatchedAtMS: item.GetMatchedAtMs(),
SettledAtMS: item.GetSettledAtMs(),
UpdatedAtMS: item.GetUpdatedAtMs(),
}
}
func roomRPSPlayerFromProto(item *gamev1.RoomRPSPlayer) roomRPSPlayerDTO {
if item == nil {
return roomRPSPlayerDTO{}
}
// 管理后台玩家 DTO 只展示订单内快照字段;头像昵称属于 App/IM 展示增强,后台如需用户资料应走独立用户查询。
return roomRPSPlayerDTO{
UserID: strconv.FormatInt(item.GetUserId(), 10),
UserIDNumber: item.GetUserId(),
Gesture: item.GetGesture(),
Result: item.GetResult(),
BalanceAfter: item.GetBalanceAfter(),
JoinedAtMS: item.GetJoinedAtMs(),
}
}

View File

@ -22,3 +22,53 @@ func TestPlatformFromProtoReturnsCallbackSecret(t *testing.T) {
t.Fatal("CallbackSecretSet = false, want true")
}
}
func TestRoomRPSConfigRequestRequiresFourGiftTiers(t *testing.T) {
_, err := (roomRPSConfigRequest{
Status: "active",
ChallengeTimeoutMS: 600000,
RevealCountdownMS: 3000,
StakeGifts: []roomRPSStakeGiftReq{
{GiftID: 1001, Enabled: true, SortOrder: 1},
{GiftID: 1002, Enabled: true, SortOrder: 2},
{GiftID: 1003, Enabled: true, SortOrder: 3},
},
}).toProto()
if err == nil {
t.Fatal("toProto must reject room rps config without exactly four gift tiers")
}
}
func TestRoomRPSConfigRequestDefaultsOpenConfig(t *testing.T) {
got, err := (roomRPSConfigRequest{
StakeGifts: []roomRPSStakeGiftReq{
{GiftID: 1001, Enabled: true},
{GiftID: 1002, Enabled: true},
{GiftID: 1003, Enabled: true},
{GiftID: 1004, Enabled: true},
},
}).toProto()
if err != nil {
t.Fatalf("toProto failed: %v", err)
}
if got.GetStatus() != "active" || got.GetChallengeTimeoutMs() != 600000 || got.GetRevealCountdownMs() != 3000 {
t.Fatalf("room rps default config mismatch: %+v", got)
}
if got.GetStakeGifts()[3].GetSortOrder() != 4 {
t.Fatalf("default sort order mismatch: %+v", got.GetStakeGifts())
}
}
func TestRoomRPSChallengeFromProtoKeepsLargeIDsAsStrings(t *testing.T) {
got := roomRPSChallengeFromProto(&gamev1.RoomRPSChallenge{
ChallengeId: "rps-1",
StakeGiftId: 9007199254740993,
Initiator: &gamev1.RoomRPSPlayer{UserId: 9007199254740995, Gesture: "rock"},
WinnerUserId: 9007199254740995,
})
if got.StakeGiftID != "9007199254740993" || got.Initiator.UserID != "9007199254740995" || got.WinnerUserID != "9007199254740995" {
t.Fatalf("large IDs must stay string-safe in admin JSON: %+v", got)
}
}

View File

@ -5,6 +5,7 @@ import (
"strings"
"hyapp-admin-server/internal/integration/gameclient"
"hyapp-admin-server/internal/integration/userclient"
"hyapp-admin-server/internal/modules/shared"
"hyapp-admin-server/internal/response"
gamev1 "hyapp.local/api/proto/game/v1"
@ -13,12 +14,23 @@ import (
)
type Handler struct {
game gameclient.Client
audit shared.OperationLogger
game gameclient.Client
user userclient.Client
audit shared.OperationLogger
robotAvatar robotAvatarConfig
}
func New(game gameclient.Client, audit shared.OperationLogger) *Handler {
return &Handler{game: game, audit: audit}
// Option 只开放模块初始化时的基础设施注入点,避免 handler 方法里散落环境配置读取。
type Option func(*Handler)
func New(game gameclient.Client, user userclient.Client, audit shared.OperationLogger, options ...Option) *Handler {
h := &Handler{game: game, user: user, audit: audit, robotAvatar: defaultRobotAvatarConfig()}
for _, option := range options {
if option != nil {
option(h)
}
}
return h
}
// ListPlatforms 返回后台游戏平台筛选项和平台配置列表。

View File

@ -1,6 +1,7 @@
package gamemanagement
import (
"fmt"
"strings"
"time"
@ -47,6 +48,55 @@ type statusRequest struct {
Status string `json:"status"`
}
type diceConfigRequest struct {
Status string `json:"status"`
StakeOptions []diceStakeReq `json:"stakeOptions"`
FeeBPS int32 `json:"feeBps"`
PoolBPS int32 `json:"poolBps"`
MinPlayers int32 `json:"minPlayers"`
MaxPlayers int32 `json:"maxPlayers"`
RobotEnabled bool `json:"robotEnabled"`
RobotMatchWaitMS int64 `json:"robotMatchWaitMs"`
}
type diceStakeReq struct {
StakeCoin int64 `json:"stakeCoin"`
Enabled bool `json:"enabled"`
SortOrder int32 `json:"sortOrder"`
}
type dicePoolAdjustRequest struct {
AmountCoin int64 `json:"amountCoin"`
Direction string `json:"direction"`
Reason string `json:"reason"`
}
type diceGenerateRobotsRequest struct {
Count int32 `json:"count"`
NicknamePrefix string `json:"nicknamePrefix"`
NicknameLanguage string `json:"nicknameLanguage"`
AvatarURLs []string `json:"avatarUrls"`
Country string `json:"country"`
Gender string `json:"gender"`
}
type diceRobotStatusRequest struct {
Status string `json:"status"`
}
type roomRPSConfigRequest struct {
Status string `json:"status"`
ChallengeTimeoutMS int64 `json:"challengeTimeoutMs"`
RevealCountdownMS int64 `json:"revealCountdownMs"`
StakeGifts []roomRPSStakeGiftReq `json:"stakeGifts"`
}
type roomRPSStakeGiftReq struct {
GiftID int64 `json:"giftId"`
Enabled bool `json:"enabled"`
SortOrder int32 `json:"sortOrder"`
}
func (r platformRequest) toProto(platformCode string) *gamev1.GamePlatform {
if platformCode == "" {
platformCode = r.PlatformCode
@ -86,6 +136,81 @@ func (r catalogRequest) toProto(gameID string) *gamev1.GameCatalogItem {
}
}
func (r diceConfigRequest) toProto(gameID string) *gamev1.DiceConfig {
options := make([]*gamev1.DiceStakeOption, 0, len(r.StakeOptions))
for _, option := range r.StakeOptions {
options = append(options, &gamev1.DiceStakeOption{
StakeCoin: option.StakeCoin,
Enabled: option.Enabled,
SortOrder: option.SortOrder,
})
}
return &gamev1.DiceConfig{
GameId: strings.TrimSpace(gameID),
Status: strings.TrimSpace(r.Status),
StakeOptions: options,
FeeBps: r.FeeBPS,
PoolBps: r.PoolBPS,
MinPlayers: r.MinPlayers,
MaxPlayers: r.MaxPlayers,
RobotEnabled: r.RobotEnabled,
RobotMatchWaitMs: r.RobotMatchWaitMS,
}
}
func (r roomRPSConfigRequest) toProto() (*gamev1.RoomRPSConfig, error) {
status := strings.TrimSpace(r.Status)
if status == "" {
// 业务功能配置默认打开;后台没传 status 时不能默认为关闭,否则新环境上线后 App 入口会被静默隐藏。
status = "active"
}
timeoutMS := r.ChallengeTimeoutMS
if timeoutMS <= 0 {
// 无人应战超时使用产品默认 10 分钟0 或负数按“未填写”处理,不允许保存成立刻过期。
timeoutMS = 10 * 60 * 1000
}
revealMS := r.RevealCountdownMS
if revealMS <= 0 {
// 揭晓倒计时使用产品默认 3 秒;客户端动画和 IM 倒计时都依赖这个稳定默认值。
revealMS = 3 * 1000
}
if len(r.StakeGifts) != 4 {
// 房内猜拳礼物档位是产品固定四档,少一档会导致 Flutter 面板缺位,多一档会导致后台和客户端展示不一致。
return nil, fmt.Errorf("房内猜拳礼物档位必须配置 4 档")
}
gifts := make([]*gamev1.RoomRPSStakeGift, 0, len(r.StakeGifts))
seen := make(map[int64]struct{}, len(r.StakeGifts))
for index, gift := range r.StakeGifts {
if gift.GiftID <= 0 {
// gift_id 必须是后台真实礼物 ID0 不能表示“空档位”,空档位应该通过 enabled=false 表达。
return nil, fmt.Errorf("第 %d 档礼物 ID 不正确", index+1)
}
if _, ok := seen[gift.GiftID]; ok {
// 同一个礼物不能重复配置成多档,否则结算金额和排序展示会变成同一个 gift_id 的多份事实。
return nil, fmt.Errorf("第 %d 档礼物 ID 重复", index+1)
}
seen[gift.GiftID] = struct{}{}
sortOrder := gift.SortOrder
if sortOrder <= 0 {
// 未显式给排序时按提交顺序落库,保证前端拖拽顺序和运行时展示顺序一致。
sortOrder = int32(index + 1)
}
gifts = append(gifts, &gamev1.RoomRPSStakeGift{
GiftId: gift.GiftID,
Enabled: gift.Enabled,
SortOrder: sortOrder,
})
}
return &gamev1.RoomRPSConfig{
// 房内猜拳和独立猜拳必须用不同 game_id这里固定 room_rps防止后台误把配置写到独立猜拳。
GameId: "room_rps",
Status: status,
ChallengeTimeoutMs: timeoutMS,
RevealCountdownMs: revealMS,
StakeGifts: gifts,
}, nil
}
func compactTags(tags []string) []string {
out := make([]string, 0, len(tags))
seen := make(map[string]struct{}, len(tags))

View File

@ -0,0 +1,167 @@
package gamemanagement
import (
"bytes"
"context"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
"hyapp-admin-server/internal/platform/idgen"
)
const (
robotAvatarDownloadTimeout = 10 * time.Second
robotAvatarMaxBytes = 5 << 20
robotAvatarSniffBytes = 512
)
// ObjectUploader 是游戏管理模块需要的对象存储最小能力,实际生产实现由后台 COS 平台层提供。
type ObjectUploader interface {
PutObject(ctx context.Context, key string, reader io.Reader, sizeBytes int64, contentType string) (string, error)
}
type robotAvatarConfig struct {
uploader ObjectUploader
objectPrefix string
httpClient *http.Client
}
func defaultRobotAvatarConfig() robotAvatarConfig {
return robotAvatarConfig{
objectPrefix: "admin",
httpClient: &http.Client{Timeout: robotAvatarDownloadTimeout},
}
}
// WithRobotAvatarUploader 注入后台已装配的 COS 上传器,机器人创建只拿这个薄接口,不直接依赖 COS SDK。
func WithRobotAvatarUploader(uploader ObjectUploader, objectPrefix string) Option {
return func(h *Handler) {
h.robotAvatar.uploader = uploader
h.robotAvatar.objectPrefix = cleanRobotAvatarObjectPrefix(objectPrefix)
}
}
// WithRobotAvatarHTTPClient 只给测试替换下载客户端;生产默认使用带超时的标准 HTTP client。
func WithRobotAvatarHTTPClient(client *http.Client) Option {
return func(h *Handler) {
if client != nil {
h.robotAvatar.httpClient = client
}
}
}
func (h *Handler) uploadRobotAvatarFromSource(ctx context.Context, actorID uint, sourceURL string) (string, error) {
sourceURL = strings.TrimSpace(sourceURL)
if sourceURL == "" {
return "", fmt.Errorf("robot avatar source url is empty")
}
if h.robotAvatar.uploader == nil {
return "", fmt.Errorf("robot avatar cos uploader is not configured")
}
data, contentType, err := h.downloadRobotAvatar(ctx, sourceURL)
if err != nil {
return "", err
}
// 机器人头像先进入后台 COS 前缀再写入用户资料App 端只依赖自有 CDN URL不直接依赖第三方头像站。
key := buildRobotAvatarObjectKey(h.robotAvatar.objectPrefix, actorID, contentType, time.Now().UTC())
return h.robotAvatar.uploader.PutObject(ctx, key, bytes.NewReader(data), int64(len(data)), contentType)
}
func (h *Handler) downloadRobotAvatar(ctx context.Context, sourceURL string) ([]byte, string, error) {
if !validRobotAvatarSourceURL(sourceURL) {
return nil, "", fmt.Errorf("robot avatar source url is invalid")
}
client := h.robotAvatar.httpClient
if client == nil {
client = &http.Client{Timeout: robotAvatarDownloadTimeout}
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, sourceURL, nil)
if err != nil {
return nil, "", fmt.Errorf("build robot avatar request: %w", err)
}
req.Header.Set("User-Agent", "hyapp-admin-server/robot-avatar")
resp, err := client.Do(req)
if err != nil {
return nil, "", fmt.Errorf("download robot avatar: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
return nil, "", fmt.Errorf("download robot avatar returned status %d", resp.StatusCode)
}
if resp.ContentLength > robotAvatarMaxBytes {
return nil, "", fmt.Errorf("robot avatar is too large")
}
data, err := io.ReadAll(io.LimitReader(resp.Body, robotAvatarMaxBytes+1))
if err != nil {
return nil, "", fmt.Errorf("read robot avatar: %w", err)
}
if len(data) == 0 || len(data) > robotAvatarMaxBytes {
return nil, "", fmt.Errorf("robot avatar size is invalid")
}
sniffSize := len(data)
if sniffSize > robotAvatarSniffBytes {
sniffSize = robotAvatarSniffBytes
}
contentType, ok := normalizeRobotAvatarContentType(http.DetectContentType(data[:sniffSize]), resp.Header.Get("Content-Type"))
if !ok {
return nil, "", fmt.Errorf("robot avatar content type is unsupported")
}
return data, contentType, nil
}
func validRobotAvatarSourceURL(raw string) bool {
u, err := url.ParseRequestURI(strings.TrimSpace(raw))
if err != nil {
return false
}
return (u.Scheme == "https" || u.Scheme == "http") && strings.TrimSpace(u.Host) != ""
}
func normalizeRobotAvatarContentType(values ...string) (string, bool) {
for _, value := range values {
contentType := strings.ToLower(strings.TrimSpace(strings.Split(value, ";")[0]))
switch contentType {
case "image/jpeg", "image/jpg":
return "image/jpeg", true
case "image/png":
return "image/png", true
case "image/webp":
return "image/webp", true
}
}
return "", false
}
func buildRobotAvatarObjectKey(objectPrefix string, actorID uint, contentType string, now time.Time) string {
extension := ".jpg"
switch contentType {
case "image/png":
extension = ".png"
case "image/webp":
extension = ".webp"
}
return fmt.Sprintf(
"%s/game-robots/avatars/%d/%s/%s%s",
cleanRobotAvatarObjectPrefix(objectPrefix),
actorID,
now.UTC().Format("20060102"),
idgen.New("avatar"),
extension,
)
}
func cleanRobotAvatarObjectPrefix(value string) string {
value = strings.Trim(strings.TrimSpace(value), "/")
if value == "" {
return "admin"
}
return value
}

View File

@ -0,0 +1,133 @@
package gamemanagement
import (
"context"
"encoding/base64"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"unicode"
)
type fakeRobotAvatarUploader struct {
key string
contentType string
data []byte
}
func (u *fakeRobotAvatarUploader) PutObject(_ context.Context, key string, reader io.Reader, _ int64, contentType string) (string, error) {
data, err := io.ReadAll(reader)
if err != nil {
return "", err
}
u.key = key
u.contentType = contentType
u.data = data
return "https://media.example.com/" + key, nil
}
func TestUploadRobotAvatarFromSourceUploadsDownloadedImageToCOS(t *testing.T) {
imageBytes, err := base64.StdEncoding.DecodeString("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII=")
if err != nil {
t.Fatalf("decode png: %v", err)
}
avatarServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "image/png")
_, _ = w.Write(imageBytes)
}))
defer avatarServer.Close()
uploader := &fakeRobotAvatarUploader{}
handler := New(nil, nil, nil, WithRobotAvatarUploader(uploader, "admin"), WithRobotAvatarHTTPClient(avatarServer.Client()))
got, err := handler.uploadRobotAvatarFromSource(t.Context(), 42, avatarServer.URL+"/avatar.png")
if err != nil {
t.Fatalf("uploadRobotAvatarFromSource failed: %v", err)
}
if !strings.HasPrefix(got, "https://media.example.com/admin/game-robots/avatars/42/") {
t.Fatalf("uploaded url = %q", got)
}
if uploader.contentType != "image/png" {
t.Fatalf("contentType = %q, want image/png", uploader.contentType)
}
if string(uploader.data) != string(imageBytes) {
t.Fatalf("uploaded bytes mismatch")
}
}
func TestRobotNicknameUsesSingleLanguagePresetWithoutDigits(t *testing.T) {
// 预设池是批量创建机器人的基础数据,数量变化会直接影响前端一次创建 200 个机器人时的去重效果。
if len(defaultArabicRobotNicknames) != 100 {
t.Fatalf("arabic nickname preset count = %d, want 100", len(defaultArabicRobotNicknames))
}
if len(defaultEnglishRobotNicknames) != 100 {
t.Fatalf("english nickname preset count = %d, want 100", len(defaultEnglishRobotNicknames))
}
if len(defaultRobotAvatarSources) != 200 {
t.Fatalf("avatar preset count = %d, want 200", len(defaultRobotAvatarSources))
}
if uniqueCount(defaultRobotAvatarSources) != 200 {
t.Fatalf("avatar preset must keep 200 unique source urls")
}
englishNames := make([]string, 0, 200)
arabicNames := make([]string, 0, 200)
for index := 0; index < 200; index++ {
english := robotNickname(index, "english")
if !isPureEnglishNickname(english) {
t.Fatalf("english nickname[%d] = %q, want letters only", index, english)
}
englishNames = append(englishNames, english)
arabic := robotNickname(index, "arabic")
if !isPureArabicNickname(arabic) {
t.Fatalf("arabic nickname[%d] = %q, want arabic letters only", index, arabic)
}
arabicNames = append(arabicNames, arabic)
}
if uniqueCount(englishNames) != len(englishNames) {
t.Fatalf("english nickname presets and overflow combinations must keep 200 unique names")
}
if uniqueCount(arabicNames) != len(arabicNames) {
t.Fatalf("arabic nickname presets and overflow combinations must keep 200 unique names")
}
if got := robotNickname(100, "english"); got != "AlexJordan" {
t.Fatalf("english overflow nickname = %q, want pure english combined name", got)
}
if got := robotNickname(100, "arabic"); got != "أميرسيف" {
t.Fatalf("arabic overflow nickname = %q, want pure arabic combined name", got)
}
}
func isPureEnglishNickname(value string) bool {
if value == "" {
return false
}
for _, item := range value {
if (item < 'A' || item > 'Z') && (item < 'a' || item > 'z') {
return false
}
}
return true
}
func isPureArabicNickname(value string) bool {
if value == "" {
return false
}
for _, item := range value {
if !unicode.Is(unicode.Arabic, item) {
return false
}
}
return true
}
func uniqueCount(values []string) int {
seen := make(map[string]struct{}, len(values))
for _, value := range values {
seen[value] = struct{}{}
}
return len(seen)
}

View File

@ -0,0 +1,513 @@
package gamemanagement
import (
"strings"
"unicode"
)
const (
robotNicknameLanguageArabic = "arabic"
robotNicknameLanguageEnglish = "english"
)
var defaultRobotAvatarSources = []string{
"https://randomuser.me/api/portraits/men/0.jpg",
"https://randomuser.me/api/portraits/men/1.jpg",
"https://randomuser.me/api/portraits/men/2.jpg",
"https://randomuser.me/api/portraits/men/3.jpg",
"https://randomuser.me/api/portraits/men/4.jpg",
"https://randomuser.me/api/portraits/men/5.jpg",
"https://randomuser.me/api/portraits/men/6.jpg",
"https://randomuser.me/api/portraits/men/7.jpg",
"https://randomuser.me/api/portraits/men/8.jpg",
"https://randomuser.me/api/portraits/men/9.jpg",
"https://randomuser.me/api/portraits/men/10.jpg",
"https://randomuser.me/api/portraits/men/11.jpg",
"https://randomuser.me/api/portraits/men/12.jpg",
"https://randomuser.me/api/portraits/men/13.jpg",
"https://randomuser.me/api/portraits/men/14.jpg",
"https://randomuser.me/api/portraits/men/15.jpg",
"https://randomuser.me/api/portraits/men/16.jpg",
"https://randomuser.me/api/portraits/men/17.jpg",
"https://randomuser.me/api/portraits/men/18.jpg",
"https://randomuser.me/api/portraits/men/19.jpg",
"https://randomuser.me/api/portraits/men/20.jpg",
"https://randomuser.me/api/portraits/men/21.jpg",
"https://randomuser.me/api/portraits/men/22.jpg",
"https://randomuser.me/api/portraits/men/23.jpg",
"https://randomuser.me/api/portraits/men/24.jpg",
"https://randomuser.me/api/portraits/men/25.jpg",
"https://randomuser.me/api/portraits/men/26.jpg",
"https://randomuser.me/api/portraits/men/27.jpg",
"https://randomuser.me/api/portraits/men/28.jpg",
"https://randomuser.me/api/portraits/men/29.jpg",
"https://randomuser.me/api/portraits/men/30.jpg",
"https://randomuser.me/api/portraits/men/31.jpg",
"https://randomuser.me/api/portraits/men/32.jpg",
"https://randomuser.me/api/portraits/men/33.jpg",
"https://randomuser.me/api/portraits/men/34.jpg",
"https://randomuser.me/api/portraits/men/35.jpg",
"https://randomuser.me/api/portraits/men/36.jpg",
"https://randomuser.me/api/portraits/men/37.jpg",
"https://randomuser.me/api/portraits/men/38.jpg",
"https://randomuser.me/api/portraits/men/39.jpg",
"https://randomuser.me/api/portraits/men/40.jpg",
"https://randomuser.me/api/portraits/men/41.jpg",
"https://randomuser.me/api/portraits/men/42.jpg",
"https://randomuser.me/api/portraits/men/43.jpg",
"https://randomuser.me/api/portraits/men/44.jpg",
"https://randomuser.me/api/portraits/men/45.jpg",
"https://randomuser.me/api/portraits/men/46.jpg",
"https://randomuser.me/api/portraits/men/47.jpg",
"https://randomuser.me/api/portraits/men/48.jpg",
"https://randomuser.me/api/portraits/men/49.jpg",
"https://randomuser.me/api/portraits/men/50.jpg",
"https://randomuser.me/api/portraits/men/51.jpg",
"https://randomuser.me/api/portraits/men/52.jpg",
"https://randomuser.me/api/portraits/men/53.jpg",
"https://randomuser.me/api/portraits/men/54.jpg",
"https://randomuser.me/api/portraits/men/55.jpg",
"https://randomuser.me/api/portraits/men/56.jpg",
"https://randomuser.me/api/portraits/men/57.jpg",
"https://randomuser.me/api/portraits/men/58.jpg",
"https://randomuser.me/api/portraits/men/59.jpg",
"https://randomuser.me/api/portraits/men/60.jpg",
"https://randomuser.me/api/portraits/men/61.jpg",
"https://randomuser.me/api/portraits/men/62.jpg",
"https://randomuser.me/api/portraits/men/63.jpg",
"https://randomuser.me/api/portraits/men/64.jpg",
"https://randomuser.me/api/portraits/men/65.jpg",
"https://randomuser.me/api/portraits/men/66.jpg",
"https://randomuser.me/api/portraits/men/67.jpg",
"https://randomuser.me/api/portraits/men/68.jpg",
"https://randomuser.me/api/portraits/men/69.jpg",
"https://randomuser.me/api/portraits/men/70.jpg",
"https://randomuser.me/api/portraits/men/71.jpg",
"https://randomuser.me/api/portraits/men/72.jpg",
"https://randomuser.me/api/portraits/men/73.jpg",
"https://randomuser.me/api/portraits/men/74.jpg",
"https://randomuser.me/api/portraits/men/75.jpg",
"https://randomuser.me/api/portraits/men/76.jpg",
"https://randomuser.me/api/portraits/men/77.jpg",
"https://randomuser.me/api/portraits/men/78.jpg",
"https://randomuser.me/api/portraits/men/79.jpg",
"https://randomuser.me/api/portraits/men/80.jpg",
"https://randomuser.me/api/portraits/men/81.jpg",
"https://randomuser.me/api/portraits/men/82.jpg",
"https://randomuser.me/api/portraits/men/83.jpg",
"https://randomuser.me/api/portraits/men/84.jpg",
"https://randomuser.me/api/portraits/men/85.jpg",
"https://randomuser.me/api/portraits/men/86.jpg",
"https://randomuser.me/api/portraits/men/87.jpg",
"https://randomuser.me/api/portraits/men/88.jpg",
"https://randomuser.me/api/portraits/men/89.jpg",
"https://randomuser.me/api/portraits/men/90.jpg",
"https://randomuser.me/api/portraits/men/91.jpg",
"https://randomuser.me/api/portraits/men/92.jpg",
"https://randomuser.me/api/portraits/men/93.jpg",
"https://randomuser.me/api/portraits/men/94.jpg",
"https://randomuser.me/api/portraits/men/95.jpg",
"https://randomuser.me/api/portraits/men/96.jpg",
"https://randomuser.me/api/portraits/men/97.jpg",
"https://randomuser.me/api/portraits/men/98.jpg",
"https://randomuser.me/api/portraits/men/99.jpg",
"https://randomuser.me/api/portraits/women/0.jpg",
"https://randomuser.me/api/portraits/women/1.jpg",
"https://randomuser.me/api/portraits/women/2.jpg",
"https://randomuser.me/api/portraits/women/3.jpg",
"https://randomuser.me/api/portraits/women/4.jpg",
"https://randomuser.me/api/portraits/women/5.jpg",
"https://randomuser.me/api/portraits/women/6.jpg",
"https://randomuser.me/api/portraits/women/7.jpg",
"https://randomuser.me/api/portraits/women/8.jpg",
"https://randomuser.me/api/portraits/women/9.jpg",
"https://randomuser.me/api/portraits/women/10.jpg",
"https://randomuser.me/api/portraits/women/11.jpg",
"https://randomuser.me/api/portraits/women/12.jpg",
"https://randomuser.me/api/portraits/women/13.jpg",
"https://randomuser.me/api/portraits/women/14.jpg",
"https://randomuser.me/api/portraits/women/15.jpg",
"https://randomuser.me/api/portraits/women/16.jpg",
"https://randomuser.me/api/portraits/women/17.jpg",
"https://randomuser.me/api/portraits/women/18.jpg",
"https://randomuser.me/api/portraits/women/19.jpg",
"https://randomuser.me/api/portraits/women/20.jpg",
"https://randomuser.me/api/portraits/women/21.jpg",
"https://randomuser.me/api/portraits/women/22.jpg",
"https://randomuser.me/api/portraits/women/23.jpg",
"https://randomuser.me/api/portraits/women/24.jpg",
"https://randomuser.me/api/portraits/women/25.jpg",
"https://randomuser.me/api/portraits/women/26.jpg",
"https://randomuser.me/api/portraits/women/27.jpg",
"https://randomuser.me/api/portraits/women/28.jpg",
"https://randomuser.me/api/portraits/women/29.jpg",
"https://randomuser.me/api/portraits/women/30.jpg",
"https://randomuser.me/api/portraits/women/31.jpg",
"https://randomuser.me/api/portraits/women/32.jpg",
"https://randomuser.me/api/portraits/women/33.jpg",
"https://randomuser.me/api/portraits/women/34.jpg",
"https://randomuser.me/api/portraits/women/35.jpg",
"https://randomuser.me/api/portraits/women/36.jpg",
"https://randomuser.me/api/portraits/women/37.jpg",
"https://randomuser.me/api/portraits/women/38.jpg",
"https://randomuser.me/api/portraits/women/39.jpg",
"https://randomuser.me/api/portraits/women/40.jpg",
"https://randomuser.me/api/portraits/women/41.jpg",
"https://randomuser.me/api/portraits/women/42.jpg",
"https://randomuser.me/api/portraits/women/43.jpg",
"https://randomuser.me/api/portraits/women/44.jpg",
"https://randomuser.me/api/portraits/women/45.jpg",
"https://randomuser.me/api/portraits/women/46.jpg",
"https://randomuser.me/api/portraits/women/47.jpg",
"https://randomuser.me/api/portraits/women/48.jpg",
"https://randomuser.me/api/portraits/women/49.jpg",
"https://randomuser.me/api/portraits/women/50.jpg",
"https://randomuser.me/api/portraits/women/51.jpg",
"https://randomuser.me/api/portraits/women/52.jpg",
"https://randomuser.me/api/portraits/women/53.jpg",
"https://randomuser.me/api/portraits/women/54.jpg",
"https://randomuser.me/api/portraits/women/55.jpg",
"https://randomuser.me/api/portraits/women/56.jpg",
"https://randomuser.me/api/portraits/women/57.jpg",
"https://randomuser.me/api/portraits/women/58.jpg",
"https://randomuser.me/api/portraits/women/59.jpg",
"https://randomuser.me/api/portraits/women/60.jpg",
"https://randomuser.me/api/portraits/women/61.jpg",
"https://randomuser.me/api/portraits/women/62.jpg",
"https://randomuser.me/api/portraits/women/63.jpg",
"https://randomuser.me/api/portraits/women/64.jpg",
"https://randomuser.me/api/portraits/women/65.jpg",
"https://randomuser.me/api/portraits/women/66.jpg",
"https://randomuser.me/api/portraits/women/67.jpg",
"https://randomuser.me/api/portraits/women/68.jpg",
"https://randomuser.me/api/portraits/women/69.jpg",
"https://randomuser.me/api/portraits/women/70.jpg",
"https://randomuser.me/api/portraits/women/71.jpg",
"https://randomuser.me/api/portraits/women/72.jpg",
"https://randomuser.me/api/portraits/women/73.jpg",
"https://randomuser.me/api/portraits/women/74.jpg",
"https://randomuser.me/api/portraits/women/75.jpg",
"https://randomuser.me/api/portraits/women/76.jpg",
"https://randomuser.me/api/portraits/women/77.jpg",
"https://randomuser.me/api/portraits/women/78.jpg",
"https://randomuser.me/api/portraits/women/79.jpg",
"https://randomuser.me/api/portraits/women/80.jpg",
"https://randomuser.me/api/portraits/women/81.jpg",
"https://randomuser.me/api/portraits/women/82.jpg",
"https://randomuser.me/api/portraits/women/83.jpg",
"https://randomuser.me/api/portraits/women/84.jpg",
"https://randomuser.me/api/portraits/women/85.jpg",
"https://randomuser.me/api/portraits/women/86.jpg",
"https://randomuser.me/api/portraits/women/87.jpg",
"https://randomuser.me/api/portraits/women/88.jpg",
"https://randomuser.me/api/portraits/women/89.jpg",
"https://randomuser.me/api/portraits/women/90.jpg",
"https://randomuser.me/api/portraits/women/91.jpg",
"https://randomuser.me/api/portraits/women/92.jpg",
"https://randomuser.me/api/portraits/women/93.jpg",
"https://randomuser.me/api/portraits/women/94.jpg",
"https://randomuser.me/api/portraits/women/95.jpg",
"https://randomuser.me/api/portraits/women/96.jpg",
"https://randomuser.me/api/portraits/women/97.jpg",
"https://randomuser.me/api/portraits/women/98.jpg",
"https://randomuser.me/api/portraits/women/99.jpg",
}
var defaultArabicRobotNicknames = []string{
"أمير",
"سيف",
"مالك",
"فارس",
"ليث",
"بدر",
"ماهر",
"رامي",
"كريم",
"سامي",
"عمر",
"يوسف",
"آدم",
"ريان",
"زيد",
"نور",
"ياسين",
"أنس",
"خالد",
"طارق",
"حازم",
"نادر",
"وسيم",
"جواد",
"مروان",
"إياد",
"نبيل",
"هلال",
"تميم",
"سلمان",
"راشد",
"جاسم",
"ماجد",
"سلطان",
"عادل",
"هاني",
"بلال",
"يزن",
"غيث",
"قصي",
"سليم",
"ريان_الليل",
"نجم",
"ملك_اللعبة",
"صقر",
"ذيب",
"أسد",
"الزعيم",
"القناص",
"الأسطورة",
"ظل",
"برق",
"رعد",
"موج",
"نمر",
"شهاب",
"فهد",
"نايف",
"نوف",
"ليان",
"ريم",
"سارة",
"مريم",
"لينا",
"هنا",
"جود",
"تالا",
"دانا",
"لارا",
"ياسمين",
"نوران",
"رؤى",
"سما",
"ملك",
"جنى",
"آية",
"ندى",
"فرح",
"أمل",
"هدى",
"سلمى",
"ليلى",
"رنا",
"نورا",
"ديما",
"مايا",
"ميس",
"رهف",
"شهد",
"غلا",
"لولو",
"وردة",
"قمر",
"نجمة",
"لؤلؤة",
"الملكة",
"أميرة",
"روح",
"حلم",
"بسمة",
}
var defaultEnglishRobotNicknames = []string{
"Alex",
"Jordan",
"Taylor",
"Morgan",
"Riley",
"Nova",
"Phoenix",
"Hunter",
"Luna",
"Mia",
"Noah",
"Liam",
"Ava",
"Emma",
"Oliver",
"Elijah",
"James",
"William",
"Benjamin",
"Lucas",
"Mason",
"Ethan",
"Logan",
"Jackson",
"Levi",
"Sebastian",
"Mateo",
"Jack",
"Owen",
"Theodore",
"Aiden",
"Samuel",
"Joseph",
"John",
"David",
"Wyatt",
"Matthew",
"Luke",
"Asher",
"Carter",
"Julian",
"Grayson",
"Leo",
"Jayden",
"Gabriel",
"Isaac",
"Lincoln",
"Anthony",
"Hudson",
"Dylan",
"Ezra",
"Thomas",
"Charles",
"Christopher",
"Jaxon",
"Maverick",
"Josiah",
"Isaiah",
"Andrew",
"Elias",
"Joshua",
"Nathan",
"Caleb",
"Ryan",
"Adrian",
"Miles",
"Eli",
"Nolan",
"Christian",
"Aaron",
"Cameron",
"Ezekiel",
"Colton",
"Luca",
"Landon",
"Hannah",
"Sofia",
"Isabella",
"Amelia",
"Olivia",
"Charlotte",
"Harper",
"Evelyn",
"Abigail",
"Ella",
"Scarlett",
"Grace",
"Chloe",
"Victoria",
"Aria",
"Penelope",
"Layla",
"Zoey",
"Nora",
"Lily",
"Ellie",
"Violet",
"Aurora",
"Stella",
"Hazel",
}
func normalizeRobotNicknameLanguage(value string) string {
// 后台只开放英语和阿拉伯语两类昵称池;未知值回退英语,避免历史请求继续落到英阿混合池。
switch strings.ToLower(strings.TrimSpace(value)) {
case "ar", "arabic", "arab":
return robotNicknameLanguageArabic
case "en", "english":
return robotNicknameLanguageEnglish
default:
return robotNicknameLanguageEnglish
}
}
func robotAccountLanguage(nicknameLanguage string) string {
// 用户资料里的 language 跟昵称池保持一致,后续客户端或运营筛选机器人时不用再反推昵称字符集。
if normalizeRobotNicknameLanguage(nicknameLanguage) == robotNicknameLanguageArabic {
return "ar"
}
return "en"
}
func robotNickname(index int, nicknameLanguage string) string {
// 机器人昵称只从单一语言池取值,不能再拼随机 hex否则会出现数字、下划线和英阿混排。
language := normalizeRobotNicknameLanguage(nicknameLanguage)
if language == robotNicknameLanguageArabic {
return robotNicknameFromPool(index, defaultArabicRobotNicknames, "لاعب", language)
}
return robotNicknameFromPool(index, defaultEnglishRobotNicknames, "Player", language)
}
func robotNicknameFromPool(index int, pool []string, fallback string, language string) string {
// 前 100 个直接使用预设名;超过预设数量时用同语言两个预设名拼接,保证批量 200 个仍然不带数字后缀。
if index < 0 {
index = 0
}
if len(pool) == 0 {
return fallback
}
base := sanitizeRobotNickname(pool[index%len(pool)], language)
if base == "" {
base = fallback
}
if index < len(pool) {
return base
}
next := sanitizeRobotNickname(pool[(index%len(pool)+1)%len(pool)], language)
if next == "" || next == base {
next = sanitizeRobotNickname(pool[(index+index/len(pool)+2)%len(pool)], language)
}
if next == "" || next == base {
return base
}
return base + next
}
func sanitizeRobotNickname(value string, language string) string {
// 预设里可能有旧的分隔符或非目标语言字符;写库前统一剔除,保证“英语纯英语、阿拉伯语纯阿拉伯语”。
var builder strings.Builder
for _, item := range strings.TrimSpace(value) {
switch language {
case robotNicknameLanguageArabic:
if unicode.Is(unicode.Arabic, item) {
builder.WriteRune(item)
}
default:
if (item >= 'A' && item <= 'Z') || (item >= 'a' && item <= 'z') {
builder.WriteRune(item)
}
}
}
return builder.String()
}
func robotGender(index int, requested string) string {
requested = strings.TrimSpace(requested)
if requested != "" {
return requested
}
if index%2 == 0 {
return "male"
}
return "female"
}
func robotAvatarSource(index int, requested []string) string {
if source := pickString(requested, index); source != "" {
return source
}
if len(defaultRobotAvatarSources) == 0 {
return ""
}
return defaultRobotAvatarSources[index%len(defaultRobotAvatarSources)]
}

View File

@ -0,0 +1,42 @@
package gamemanagement
import (
"context"
"hyapp-admin-server/internal/integration/userclient"
)
func (h *Handler) enrichDiceRobotProfiles(ctx context.Context, requestID string, items []diceRobotDTO) error {
if h.user == nil || len(items) == 0 {
return nil
}
userIDs := make([]int64, 0, len(items))
for _, item := range items {
if item.UserIDNumber > 0 {
userIDs = append(userIDs, item.UserIDNumber)
}
}
if len(userIDs) == 0 {
return nil
}
// 机器人登记表只拥有游戏侧启停事实;头像、昵称和短 ID 仍以 user-service 用户主数据为准。
users, err := h.user.BatchGetUsers(ctx, userclient.BatchGetUsersRequest{
RequestID: requestID,
Caller: "admin-server",
UserIDs: userIDs,
})
if err != nil {
return err
}
for index := range items {
user := users[items[index].UserIDNumber]
if user == nil {
continue
}
items[index].ShortID = user.DisplayUserID
items[index].Nickname = user.Username
items[index].Avatar = user.Avatar
}
return nil
}

View File

@ -0,0 +1,173 @@
package gamemanagement
import (
"fmt"
"strings"
"hyapp-admin-server/internal/response"
gamev1 "hyapp.local/api/proto/game/v1"
"github.com/gin-gonic/gin"
)
func (h *Handler) GetRoomRPSConfig(c *gin.Context) {
// 后台查询不读本地缓存,也不直接查 game 表;所有配置事实统一从 game-service 管理 RPC 返回,避免 admin-server 和运行时出现双写。
resp, err := h.game.GetRoomRPSConfig(c.Request.Context(), &gamev1.GetRoomRPSConfigRequest{Meta: requestMeta(c)})
if err != nil {
response.ServerError(c, "获取房内猜拳配置失败")
return
}
response.OK(c, gin.H{"config": roomRPSConfigFromProto(resp.GetConfig()), "serverTimeMs": resp.GetServerTimeMs()})
}
func (h *Handler) UpdateRoomRPSConfig(c *gin.Context) {
var req roomRPSConfigRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "房内猜拳配置参数不正确")
return
}
// JSON 绑定只负责类型检查,业务边界统一放到 toProto默认启用、默认倒计时、固定四档礼物和礼物 ID 去重。
config, err := req.toProto()
if err != nil {
response.BadRequest(c, err.Error())
return
}
// admin-server 只校验后台可见的配置边界:开关、超时和 4 个礼物档位。
// 礼物名称、价格、图标、扣款、托管和结算幂等由 game-service/wallet-service 统一处理,避免后台绕过运行时校验写入脏配置。
resp, err := h.game.UpdateRoomRPSConfig(c.Request.Context(), &gamev1.UpdateRoomRPSConfigRequest{
Meta: requestMeta(c),
Config: config,
})
if err != nil {
response.BadRequest(c, err.Error())
return
}
item := roomRPSConfigFromProto(resp.GetConfig())
// 配置修改会影响所有房间入口和下注档位,必须写审计;审计目标使用 game_id便于后续按玩法追踪变更。
h.auditLog(c, "update-room-rps-config", "game_room_rps_configs", item.GameID, item.Status)
response.OK(c, gin.H{"config": item, "serverTimeMs": resp.GetServerTimeMs()})
}
func (h *Handler) ListRoomRPSChallenges(c *gin.Context) {
// 管理后台默认页大小比 App 大,但仍只允许正数;真正的上限和游标稳定性由 game-service 控制,防止 admin-server 复制分页规则。
pageSize := int32(parsePositiveInt(firstQuery(c, "pageSize", "page_size"), 50))
// 所有 ID 查询都兼容 camelCase 和 snake_case前端生成代码和手工调试都能命中同一套过滤逻辑。
initiatorUserID, ok := optionalInt64Query(c, "initiatorUserId", "initiator_user_id")
if !ok {
response.BadRequest(c, "发起人用户 ID 参数不正确")
return
}
challengerUserID, ok := optionalInt64Query(c, "challengerUserId", "challenger_user_id")
if !ok {
response.BadRequest(c, "应战人用户 ID 参数不正确")
return
}
startTimeMS, ok := optionalInt64Query(c, "startTimeMs", "start_time_ms")
if !ok {
response.BadRequest(c, "开始时间参数不正确")
return
}
endTimeMS, ok := optionalInt64Query(c, "endTimeMs", "end_time_ms")
if !ok {
response.BadRequest(c, "结束时间参数不正确")
return
}
// 列表接口只把后台筛选条件透传给 game-service订单可见性、游标语义、状态枚举和结算状态仍以 game-service 查询结果为准。
// admin-server 不做本地状态拼装,避免后续 game-service 增加 timeout/settlement_failed 补偿状态时后台漏同步。
resp, err := h.game.ListRoomRPSChallenges(c.Request.Context(), &gamev1.ListRoomRPSChallengesRequest{
Meta: requestMeta(c),
RoomId: strings.TrimSpace(firstQuery(c, "roomId", "room_id")),
Status: strings.TrimSpace(c.Query("status")),
InitiatorUserId: initiatorUserID,
ChallengerUserId: challengerUserID,
StartTimeMs: startTimeMS,
EndTimeMs: endTimeMS,
PageSize: pageSize,
Cursor: strings.TrimSpace(c.Query("cursor")),
})
if err != nil {
response.ServerError(c, "获取房内猜拳订单失败")
return
}
items := make([]roomRPSChallengeDTO, 0, len(resp.GetChallenges()))
for _, item := range resp.GetChallenges() {
// DTO 转换只做字段命名和大整数保护,不在后台列表里重新计算胜负或结算结果。
items = append(items, roomRPSChallengeFromProto(item))
}
response.OK(c, gin.H{"items": items, "nextCursor": resp.GetNextCursor(), "pageSize": pageSize, "serverTimeMs": resp.GetServerTimeMs()})
}
func (h *Handler) GetRoomRPSChallenge(c *gin.Context) {
challengeID := strings.TrimSpace(c.Param("challenge_id"))
if challengeID == "" {
response.BadRequest(c, "挑战单 ID 参数不正确")
return
}
// 详情接口不接收 room_id 或用户 ID 作为辅助条件challenge_id 是唯一事实入口,避免后台参数互相矛盾时误展示错误订单。
resp, err := h.game.GetRoomRPSChallenge(c.Request.Context(), &gamev1.GetRoomRPSChallengeRequest{
Meta: requestMeta(c),
ChallengeId: challengeID,
})
if err != nil {
response.BadRequest(c, err.Error())
return
}
response.OK(c, gin.H{"challenge": roomRPSChallengeFromProto(resp.GetChallenge()), "serverTimeMs": resp.GetServerTimeMs()})
}
func (h *Handler) RetryRoomRPSSettlement(c *gin.Context) {
challengeID := strings.TrimSpace(c.Param("challenge_id"))
if challengeID == "" {
response.BadRequest(c, "挑战单 ID 参数不正确")
return
}
// 重试入口只允许后台触发一次明确的运维命令。
// 是否处于 settlement_failed、是否已经结算过、钱包补偿是否可重放都由 game-service 按订单幂等事实判断admin-server 不能预读后再决定。
resp, err := h.game.RetryRoomRPSSettlement(c.Request.Context(), &gamev1.RetryRoomRPSSettlementRequest{
Meta: requestMeta(c),
ChallengeId: challengeID,
})
if err != nil {
response.BadRequest(c, err.Error())
return
}
item := roomRPSChallengeFromProto(resp.GetChallenge())
// 审计记录写回 game-service 返回的新结算状态,方便排查“点击重试但实际已被其它 worker 修复”的幂等场景。
h.auditLog(c, "retry-room-rps-settlement", "game_room_rps_challenges", item.ChallengeID, item.SettlementStatus)
response.OK(c, gin.H{"challenge": item, "serverTimeMs": resp.GetServerTimeMs()})
}
func (h *Handler) ExpireRoomRPSChallenge(c *gin.Context) {
challengeID := strings.TrimSpace(c.Param("challenge_id"))
if challengeID == "" {
response.BadRequest(c, "挑战单 ID 参数不正确")
return
}
// 手动过期只是运维兜底命令。
// 只有 pending 且未被应战的挑战能过期;这个判断必须在 game-service 事务内完成,避免后台读到 pending 后并发被应战。
resp, err := h.game.ExpireRoomRPSChallenge(c.Request.Context(), &gamev1.ExpireRoomRPSChallengeRequest{
Meta: requestMeta(c),
ChallengeId: challengeID,
})
if err != nil {
response.BadRequest(c, err.Error())
return
}
item := roomRPSChallengeFromProto(resp.GetChallenge())
// 审计记录写最终状态,而不是写“用户点击了过期”,这样能看出命令是否真的把订单推进到 timeout。
h.auditLog(c, "expire-room-rps-challenge", "game_room_rps_challenges", item.ChallengeID, item.Status)
response.OK(c, gin.H{"challenge": item, "serverTimeMs": resp.GetServerTimeMs()})
}
func optionalInt64Query(c *gin.Context, keys ...string) (int64, bool) {
raw := strings.TrimSpace(firstQuery(c, keys...))
if raw == "" {
// 空值表示不筛选,不是筛选用户 0game-service 看到 0 时按“无该条件”处理。
return 0, true
}
var value int64
if _, err := fmt.Sscan(raw, &value); err != nil || value < 0 {
return 0, false
}
return value, true
}

View File

@ -20,4 +20,19 @@ func RegisterRoutes(protected *gin.RouterGroup, h *Handler) {
protected.PATCH("/admin/game/games/:game_id", middleware.RequirePermission("game:update"), h.UpdateCatalog)
protected.PATCH("/admin/game/games/:game_id/status", middleware.RequirePermission("game:status"), h.SetGameStatus)
protected.DELETE("/admin/game/games/:game_id", middleware.RequireAnyPermission("game:delete", "game:update"), h.DeleteCatalog)
protected.GET("/admin/game/self-games", middleware.RequirePermission("game:view"), h.ListSelfGames)
protected.PATCH("/admin/game/self-games/:game_id/config", middleware.RequirePermission("game:update"), h.UpdateDiceConfig)
protected.POST("/admin/game/self-games/:game_id/pool/adjust", middleware.RequirePermission("game:update"), h.AdjustDicePool)
protected.GET("/admin/game/robots", middleware.RequirePermission("game:view"), h.ListDiceRobots)
protected.POST("/admin/game/robots/generate", middleware.RequirePermission("game:create"), h.GenerateDiceRobots)
protected.PATCH("/admin/game/robots/:user_id/status", middleware.RequirePermission("game:update"), h.SetDiceRobotStatus)
protected.DELETE("/admin/game/robots/:user_id", middleware.RequireAnyPermission("game:delete", "game:update"), h.DeleteDiceRobot)
// 房内猜拳后台沿用游戏管理权限但路由、DTO 和 game-service RPC 都独立于骰子和独立猜拳,避免配置互相污染。
protected.GET("/admin/game/room-rps/config", middleware.RequirePermission("game:view"), h.GetRoomRPSConfig)
protected.PATCH("/admin/game/room-rps/config", middleware.RequirePermission("game:update"), h.UpdateRoomRPSConfig)
protected.GET("/admin/game/room-rps/challenges", middleware.RequirePermission("game:view"), h.ListRoomRPSChallenges)
protected.GET("/admin/game/room-rps/challenges/:challenge_id", middleware.RequirePermission("game:view"), h.GetRoomRPSChallenge)
// 结算重试和手动过期都是会推进订单状态的运维动作,必须使用 game:update并由 game-service 再做状态机保护。
protected.POST("/admin/game/room-rps/challenges/:challenge_id/retry-settlement", middleware.RequirePermission("game:update"), h.RetryRoomRPSSettlement)
protected.POST("/admin/game/room-rps/challenges/:challenge_id/expire", middleware.RequirePermission("game:update"), h.ExpireRoomRPSChallenge)
}

View File

@ -0,0 +1,262 @@
package gamemanagement
import (
"crypto/rand"
"encoding/hex"
"fmt"
"strings"
"hyapp-admin-server/internal/integration/userclient"
"hyapp-admin-server/internal/middleware"
"hyapp-admin-server/internal/response"
gamev1 "hyapp.local/api/proto/game/v1"
"github.com/gin-gonic/gin"
)
func (h *Handler) ListSelfGames(c *gin.Context) {
resp, err := h.game.ListSelfGames(c.Request.Context(), &gamev1.ListSelfGamesRequest{Meta: requestMeta(c)})
if err != nil {
response.ServerError(c, "获取自研游戏失败")
return
}
items := make([]diceConfigDTO, 0, len(resp.GetGames()))
for _, item := range resp.GetGames() {
items = append(items, diceConfigFromProto(item))
}
response.OK(c, gin.H{"items": items, "serverTimeMs": resp.GetServerTimeMs()})
}
func (h *Handler) UpdateDiceConfig(c *gin.Context) {
gameID := strings.TrimSpace(c.Param("game_id"))
if gameID == "" {
response.BadRequest(c, "游戏 ID 参数不正确")
return
}
var req diceConfigRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "骰子配置参数不正确")
return
}
resp, err := h.game.UpdateDiceConfig(c.Request.Context(), &gamev1.UpdateDiceConfigRequest{
Meta: requestMeta(c),
Config: req.toProto(gameID),
})
if err != nil {
response.BadRequest(c, err.Error())
return
}
item := diceConfigFromProto(resp.GetConfig())
h.auditLog(c, "update-dice-config", "game_self_game_configs", item.GameID, item.Status)
response.OK(c, item)
}
func (h *Handler) AdjustDicePool(c *gin.Context) {
gameID := strings.TrimSpace(c.Param("game_id"))
if gameID == "" {
response.BadRequest(c, "游戏 ID 参数不正确")
return
}
var req dicePoolAdjustRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "奖池调整参数不正确")
return
}
resp, err := h.game.AdjustDicePool(c.Request.Context(), &gamev1.AdjustDicePoolRequest{
Meta: requestMeta(c),
GameId: gameID,
AmountCoin: req.AmountCoin,
Direction: strings.TrimSpace(req.Direction),
Reason: strings.TrimSpace(req.Reason),
})
if err != nil {
response.BadRequest(c, err.Error())
return
}
h.auditLog(c, "adjust-dice-pool", "game_self_game_pools", gameID, fmt.Sprintf("%s:%d", req.Direction, req.AmountCoin))
response.OK(c, gin.H{"adjustment": dicePoolAdjustmentFromProto(resp.GetAdjustment()), "config": diceConfigFromProto(resp.GetConfig())})
}
func (h *Handler) ListDiceRobots(c *gin.Context) {
pageSize := int32(parsePositiveInt(firstQuery(c, "pageSize", "page_size"), 50))
requestID := middleware.CurrentRequestID(c)
resp, err := h.game.ListDiceRobots(c.Request.Context(), &gamev1.ListDiceRobotsRequest{
Meta: requestMeta(c),
GameId: strings.TrimSpace(firstQuery(c, "gameId", "game_id")),
Status: strings.TrimSpace(c.Query("status")),
PageSize: pageSize,
Cursor: strings.TrimSpace(c.Query("cursor")),
})
if err != nil {
response.ServerError(c, "获取全站机器人失败")
return
}
items := make([]diceRobotDTO, 0, len(resp.GetRobots()))
for _, item := range resp.GetRobots() {
items = append(items, diceRobotFromProto(item))
}
if err := h.enrichDiceRobotProfiles(c.Request.Context(), requestID, items); err != nil {
response.ServerError(c, "获取机器人用户资料失败")
return
}
response.OK(c, gin.H{"items": items, "nextCursor": resp.GetNextCursor(), "pageSize": pageSize, "serverTimeMs": resp.GetServerTimeMs()})
}
func (h *Handler) GenerateDiceRobots(c *gin.Context) {
if h.user == nil {
response.ServerError(c, "用户服务不可用")
return
}
var req diceGenerateRobotsRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "机器人生成参数不正确")
return
}
if req.Count <= 0 {
req.Count = 20
}
if req.Count > 200 {
req.Count = 200
}
nicknameLanguage := normalizeRobotNicknameLanguage(req.NicknameLanguage)
country := strings.TrimSpace(req.Country)
if country == "" {
country = "SA"
}
userIDs := make([]int64, 0, req.Count)
requestID := middleware.CurrentRequestID(c)
actorID := middleware.CurrentUserID(c)
avatarCache := make(map[string]string)
for index := int32(0); index < req.Count; index++ {
// 批量机器人必须一次性写齐完整资料;同一请求内相同头像源只转存一次,避免 200 个账号重复下载上传同一张图。
avatarSource := robotAvatarSource(int(index), req.AvatarURLs)
avatarURL := avatarCache[avatarSource]
if avatarURL == "" {
var err error
avatarURL, err = h.uploadRobotAvatarFromSource(c.Request.Context(), actorID, avatarSource)
if err != nil {
response.BadRequest(c, "上传机器人头像失败: "+err.Error())
return
}
avatarCache[avatarSource] = avatarURL
}
created, err := h.user.QuickCreateAccount(c.Request.Context(), userclient.QuickCreateAccountRequest{
RequestID: requestID,
Caller: "admin-server",
Password: randomRobotPassword(),
Username: robotNickname(int(index), nicknameLanguage),
Avatar: avatarURL,
Gender: robotGender(int(index), req.Gender),
Country: country,
DeviceID: "game-robot-" + randomRobotHex(8),
Source: "game_robot",
InstallChannel: "admin",
Platform: "android",
Language: robotAccountLanguage(nicknameLanguage),
Timezone: "UTC",
})
if err != nil {
response.BadRequest(c, "创建机器人用户失败: "+err.Error())
return
}
userIDs = append(userIDs, created.UserID)
}
resp, err := h.game.RegisterDiceRobots(c.Request.Context(), &gamev1.RegisterDiceRobotsRequest{
Meta: requestMeta(c),
GameId: strings.TrimSpace(firstQuery(c, "gameId", "game_id")),
UserIds: userIDs,
})
if err != nil {
response.BadRequest(c, err.Error())
return
}
items := make([]diceRobotDTO, 0, len(resp.GetRobots()))
for _, item := range resp.GetRobots() {
items = append(items, diceRobotFromProto(item))
}
if err := h.enrichDiceRobotProfiles(c.Request.Context(), requestID, items); err != nil {
response.ServerError(c, "获取机器人用户资料失败")
return
}
h.auditLog(c, "generate-dice-robots", "game_self_game_robots", "dice", fmt.Sprintf("count=%d", len(userIDs)))
response.OK(c, gin.H{"created": len(userIDs), "items": items})
}
func (h *Handler) SetDiceRobotStatus(c *gin.Context) {
gameID := strings.TrimSpace(firstQuery(c, "gameId", "game_id"))
if gameID == "" {
gameID = "dice"
}
userID, err := parseInt64Param(c.Param("user_id"))
if err != nil || userID <= 0 {
response.BadRequest(c, "机器人用户 ID 参数不正确")
return
}
var req diceRobotStatusRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "机器人状态参数不正确")
return
}
resp, err := h.game.SetDiceRobotStatus(c.Request.Context(), &gamev1.SetDiceRobotStatusRequest{
Meta: requestMeta(c),
GameId: gameID,
UserId: userID,
Status: strings.TrimSpace(req.Status),
})
if err != nil {
response.BadRequest(c, err.Error())
return
}
item := diceRobotFromProto(resp.GetRobot())
h.auditLog(c, "set-dice-robot-status", "game_self_game_robots", item.UserID, item.Status)
response.OK(c, item)
}
// DeleteDiceRobot 删除当前自研游戏的机器人登记,后台只移除可匹配机器人池,不触碰真实用户资料。
func (h *Handler) DeleteDiceRobot(c *gin.Context) {
gameID := strings.TrimSpace(firstQuery(c, "gameId", "game_id"))
if gameID == "" {
gameID = "dice"
}
userID, err := parseInt64Param(c.Param("user_id"))
if err != nil || userID <= 0 {
response.BadRequest(c, "机器人用户 ID 参数不正确")
return
}
// 后台删除只撤销 game-service 的机器人登记;真实用户仍保留在 user-service历史对局和钱包流水可以继续按 user_id 追溯。
if _, err := h.game.DeleteDiceRobot(c.Request.Context(), &gamev1.DeleteDiceRobotRequest{
Meta: requestMeta(c),
GameId: gameID,
UserId: userID,
}); err != nil {
response.BadRequest(c, err.Error())
return
}
h.auditLog(c, "delete-dice-robot", "game_self_game_robots", fmt.Sprintf("%d", userID), gameID)
response.OK(c, gin.H{"deleted": true})
}
func randomRobotPassword() string {
return "Robot#" + randomRobotHex(12)
}
func randomRobotHex(size int) string {
buf := make([]byte, size)
if _, err := rand.Read(buf); err != nil {
return "fallback"
}
return hex.EncodeToString(buf)
}
func pickString(values []string, index int) string {
if len(values) == 0 {
return ""
}
return strings.TrimSpace(values[index%len(values)])
}
func parseInt64Param(raw string) (int64, error) {
var value int64
_, err := fmt.Sscan(strings.TrimSpace(raw), &value)
return value, err
}

View File

@ -28,6 +28,7 @@ const (
adapterZeeOneV1 = "zeeone_v1"
adapterVivaGamesV1 = "vivagames_v1"
adapterReyouV1 = "reyou_v1"
adapterYomiV4 = "yomi_v4"
defaultGameStatus = "disabled"
defaultGameCategory = "casino"
defaultLaunchMode = "full_screen"
@ -137,6 +138,8 @@ func fetchProviderGameSyncPlan(ctx context.Context, platform *gamev1.GamePlatfor
return fetchVivaGamesGameSyncPlan(platform, req)
case adapterReyouV1:
return fetchReyouGameSyncPlan(platform, req)
case adapterYomiV4:
return fetchYomiGameSyncPlan(platform, req)
default:
// 不在配置文件写死游戏:每家厂商新增“列表 API”时只加一个 adapter fetcher后台入口保持不变。
return providerGameSyncPlan{}, fmt.Errorf("当前适配器暂未接入厂商游戏列表 API: %s", platform.GetAdapterType())
@ -199,6 +202,14 @@ type reyouSyncConfig struct {
GameCovers map[string]string `json:"game_covers"`
}
type yomiSyncConfig struct {
// Yomi V4 文档只提供启动鉴权地址,没有游戏列表 API后台通过 game_names 维护截图里的 gameUid 清单。
GameNames map[string]string `json:"game_names"`
GameURLs map[string]string `json:"game_urls"`
GameIcons map[string]string `json:"game_icons"`
GameCovers map[string]string `json:"game_covers"`
}
func fetchZeeOneGameSyncPlan(platform *gamev1.GamePlatform, req syncGamesRequest) (providerGameSyncPlan, error) {
config, err := decodeZeeOneSyncConfig(platform.GetAdapterConfigJson())
if err != nil {
@ -355,6 +366,21 @@ func fetchReyouGameSyncPlan(platform *gamev1.GamePlatform, req syncGamesRequest)
}, nil
}
func fetchYomiGameSyncPlan(platform *gamev1.GamePlatform, req syncGamesRequest) (providerGameSyncPlan, error) {
config, err := decodeYomiSyncConfig(platform.GetAdapterConfigJson())
if err != nil {
return providerGameSyncPlan{}, err
}
games, gameURLs := yomiCatalogItems(platform.GetPlatformCode(), config, req)
if len(games) == 0 {
return providerGameSyncPlan{}, fmt.Errorf("小游游戏列表为空:请在 adapterConfigJson.game_names 配置游戏 ID 到名称的映射")
}
return providerGameSyncPlan{
Games: games,
GameURLs: gameURLs,
}, nil
}
func decodeReyouSyncConfig(raw string) (reyouSyncConfig, error) {
if strings.TrimSpace(raw) == "" {
return reyouSyncConfig{}, nil
@ -366,6 +392,17 @@ func decodeReyouSyncConfig(raw string) (reyouSyncConfig, error) {
return config, nil
}
func decodeYomiSyncConfig(raw string) (yomiSyncConfig, error) {
if strings.TrimSpace(raw) == "" {
return yomiSyncConfig{}, nil
}
var config yomiSyncConfig
if err := json.Unmarshal([]byte(raw), &config); err != nil {
return yomiSyncConfig{}, fmt.Errorf("小游适配器配置 JSON 不合法")
}
return config, nil
}
func reyouCatalogItems(platformCode string, config reyouSyncConfig, req syncGamesRequest) ([]catalogRequest, map[string]string) {
keys := make([]string, 0, len(config.GameURLs))
for providerGameID, launchURL := range config.GameURLs {
@ -401,6 +438,59 @@ func reyouCatalogItems(platformCode string, config reyouSyncConfig, req syncGame
return items, gameURLs
}
func yomiCatalogItems(platformCode string, config yomiSyncConfig, req syncGamesRequest) ([]catalogRequest, map[string]string) {
keys := make([]string, 0, len(config.GameNames)+len(config.GameURLs))
seen := make(map[string]struct{}, len(config.GameNames)+len(config.GameURLs))
// 小游没有列表 APIgame_names 是本地候选游戏的事实来源game_urls 只用于后台预览,不参与启动拼参。
for providerGameID := range config.GameNames {
key := strings.TrimSpace(providerGameID)
if key == "" {
continue
}
seen[key] = struct{}{}
keys = append(keys, key)
}
for providerGameID, launchURL := range config.GameURLs {
key := strings.TrimSpace(providerGameID)
if key == "" || strings.TrimSpace(launchURL) == "" {
continue
}
if _, ok := seen[key]; ok {
continue
}
seen[key] = struct{}{}
keys = append(keys, key)
}
sort.Strings(keys)
items := make([]catalogRequest, 0, len(keys))
gameURLs := make(map[string]string, len(keys))
for index, providerGameID := range keys {
launchURL := strings.TrimSpace(config.GameURLs[providerGameID])
if launchURL != "" {
gameURLs[providerGameID] = launchURL
}
name := firstNonEmpty(config.GameNames[providerGameID], zeeoneGameNameFromURL(launchURL), providerGameID)
iconURL := strings.TrimSpace(config.GameIcons[providerGameID])
coverURL := firstNonEmpty(config.GameCovers[providerGameID], iconURL)
items = append(items, catalogRequest{
GameID: stableGameID(platformCode, providerGameID),
PlatformCode: strings.TrimSpace(platformCode),
ProviderGameID: providerGameID,
GameName: name,
Category: defaulted(req.Category, defaultGameCategory),
IconURL: iconURL,
CoverURL: coverURL,
LaunchMode: defaulted(req.LaunchMode, defaultLaunchMode),
Orientation: defaultOrientation,
MinCoin: req.MinCoin,
Status: defaulted(req.Status, defaultGameStatus),
SortOrder: int32((index + 1) * 10),
Tags: compactTags(append([]string{strings.TrimSpace(platformCode), adapterYomiV4}, req.Tags...)),
})
}
return items, gameURLs
}
func titleGameName(value string) string {
tokens := splitGameNameTokens(value)
if len(tokens) == 0 {

View File

@ -167,6 +167,42 @@ func TestFetchReyouGameSyncPlanReadsConfiguredGameURLs(t *testing.T) {
}
}
func TestFetchYomiGameSyncPlanReadsConfiguredGameNames(t *testing.T) {
plan, err := fetchProviderGameSyncPlan(t.Context(), &gamev1.GamePlatform{
PlatformCode: "yomi",
AdapterType: adapterYomiV4,
AdapterConfigJson: `{
"game_names": {
"481": "MahjongWays",
"480": "ChickenPlinko"
},
"game_urls": {
"481": "https://api-testv2.yomigame.games/gate/gateway/auth"
}
}`,
}, syncGamesRequest{Status: "active"})
if err != nil {
t.Fatalf("fetchProviderGameSyncPlan failed: %v", err)
}
if len(plan.Games) != 2 {
t.Fatalf("games len = %d, want 2", len(plan.Games))
}
first := plan.Games[0]
if first.GameID != "yomi_480" || first.ProviderGameID != "480" || first.GameName != "ChickenPlinko" {
t.Fatalf("first yomi game mismatch: %+v", first)
}
second := plan.Games[1]
if second.GameID != "yomi_481" || second.ProviderGameID != "481" || second.GameName != "MahjongWays" {
t.Fatalf("second yomi game mismatch: %+v", second)
}
if plan.GameURLs["481"] != "https://api-testv2.yomigame.games/gate/gateway/auth" {
t.Fatalf("game url mismatch: %+v", plan.GameURLs)
}
if first.Status != "active" || first.LaunchMode != "full_screen" || first.Orientation != "portrait" {
t.Fatalf("default fields mismatch: %+v", first)
}
}
func TestSelectProviderGamesKeepsOnlySelectedIDs(t *testing.T) {
games := []catalogRequest{
{ProviderGameID: "1001", GameName: "one"},

View File

@ -53,6 +53,33 @@ type CoinSellerSalaryRateTier struct {
UpdatedAtMs int64 `json:"updatedAtMs"`
}
func userCountryRegionJoin(userAlias, countryRegionAlias, regionAlias string) string {
// 组织列表里的区域只用于后台展示、搜索和筛选,必须以用户当前国家归属的 active 区域为准。
// 身份表不再保存区域;这里始终按用户国家映射 active 区域,避免用户国家调整后列表继续显示旧值。
return fmt.Sprintf(`
LEFT JOIN region_countries %s ON %s.app_code = %s.app_code
AND %s.country_code = %s.country
AND %s.status = 'active'
LEFT JOIN regions %s ON %s.app_code = %s.app_code
AND %s.region_id = %s.region_id
AND %s.status = 'active'`,
countryRegionAlias, countryRegionAlias, userAlias,
countryRegionAlias, userAlias,
countryRegionAlias,
regionAlias, regionAlias, countryRegionAlias,
regionAlias, countryRegionAlias,
regionAlias,
)
}
func userCountryRegionIDSQL(regionAlias string) string {
return fmt.Sprintf("COALESCE(%s.region_id, 0)", regionAlias)
}
func userCountryRegionNameSQL(regionAlias string) string {
return fmt.Sprintf("COALESCE(%s.name, '')", regionAlias)
}
// ListBDProfiles 只读 user-service 的 BD/BD Leader 事实表,后台列表按角色选择物理表,避免把两种身份重新混在一起。
func (r *Reader) ListBDProfiles(ctx context.Context, query listQuery, role string) ([]*userclient.BDProfile, int64, error) {
if r == nil || r.db == nil {
@ -65,9 +92,9 @@ func (r *Reader) ListBDProfiles(ctx context.Context, query listQuery, role strin
whereSQL := `
FROM bd_profiles bp
LEFT JOIN users u ON u.app_code = bp.app_code AND u.user_id = bp.user_id
` + userCountryRegionJoin("u", "user_country_region", "user_region") + `
LEFT JOIN users parent_leader ON parent_leader.app_code = bp.app_code AND parent_leader.user_id = bp.parent_leader_user_id
LEFT JOIN users creator ON creator.app_code = bp.app_code AND creator.user_id = bp.created_by_user_id
LEFT JOIN regions r ON r.app_code = bp.app_code AND r.region_id = bp.region_id
WHERE bp.app_code = ? AND bp.role = ?`
args := []any{appCode, role}
if query.Status != "" {
@ -75,7 +102,7 @@ func (r *Reader) ListBDProfiles(ctx context.Context, query listQuery, role strin
args = append(args, query.Status)
}
if query.RegionID > 0 {
whereSQL += " AND bp.region_id = ?"
whereSQL += " AND user_region.region_id = ?"
args = append(args, query.RegionID)
}
if query.ParentLeaderUserID > 0 {
@ -84,7 +111,7 @@ func (r *Reader) ListBDProfiles(ctx context.Context, query listQuery, role strin
}
if keyword := strings.TrimSpace(query.Keyword); keyword != "" {
like := "%" + keyword + "%"
whereSQL += " AND (CAST(bp.user_id AS CHAR) LIKE ? OR u.current_display_user_id LIKE ? OR u.username LIKE ? OR r.name LIKE ?)"
whereSQL += " AND (CAST(bp.user_id AS CHAR) LIKE ? OR u.current_display_user_id LIKE ? OR u.username LIKE ? OR user_region.name LIKE ?)"
args = append(args, like, like, like, like)
}
@ -93,10 +120,10 @@ func (r *Reader) ListBDProfiles(ctx context.Context, query listQuery, role strin
return nil, 0, err
}
rows, err := r.db.QueryContext(ctx, fmt.Sprintf(`
SELECT bp.user_id, bp.role, bp.region_id, COALESCE(bp.parent_leader_user_id, 0),
SELECT bp.user_id, bp.role, `+userCountryRegionIDSQL("user_region")+`, COALESCE(bp.parent_leader_user_id, 0),
bp.status, bp.created_by_user_id, bp.created_at_ms, bp.updated_at_ms,
COALESCE(u.current_display_user_id, ''), COALESCE(u.username, ''),
COALESCE(u.avatar, ''), COALESCE(r.name, ''),
COALESCE(u.avatar, ''), `+userCountryRegionNameSQL("user_region")+`,
COALESCE(parent_leader.current_display_user_id, ''), COALESCE(parent_leader.username, ''),
COALESCE(parent_leader.avatar, ''),
COALESCE(creator.current_display_user_id, ''), COALESCE(creator.username, ''),
@ -168,8 +195,8 @@ func (r *Reader) listBDLeaderProfiles(ctx context.Context, query listQuery) ([]*
whereSQL := `
FROM bd_leader_profiles bp
LEFT JOIN users u ON u.app_code = bp.app_code AND u.user_id = bp.user_id
` + userCountryRegionJoin("u", "user_country_region", "user_region") + `
LEFT JOIN users creator ON creator.app_code = bp.app_code AND creator.user_id = bp.created_by_user_id
LEFT JOIN regions r ON r.app_code = bp.app_code AND r.region_id = bp.region_id
WHERE bp.app_code = ?`
args := []any{appCode}
if query.Status != "" {
@ -177,12 +204,12 @@ func (r *Reader) listBDLeaderProfiles(ctx context.Context, query listQuery) ([]*
args = append(args, query.Status)
}
if query.RegionID > 0 {
whereSQL += " AND bp.region_id = ?"
whereSQL += " AND user_region.region_id = ?"
args = append(args, query.RegionID)
}
if keyword := strings.TrimSpace(query.Keyword); keyword != "" {
like := "%" + keyword + "%"
whereSQL += " AND (CAST(bp.user_id AS CHAR) LIKE ? OR u.current_display_user_id LIKE ? OR u.username LIKE ? OR r.name LIKE ?)"
whereSQL += " AND (CAST(bp.user_id AS CHAR) LIKE ? OR u.current_display_user_id LIKE ? OR u.username LIKE ? OR user_region.name LIKE ?)"
args = append(args, like, like, like, like)
}
@ -191,10 +218,10 @@ func (r *Reader) listBDLeaderProfiles(ctx context.Context, query listQuery) ([]*
return nil, 0, err
}
rows, err := r.db.QueryContext(ctx, fmt.Sprintf(`
SELECT bp.user_id, 'bd_leader', bp.region_id, 0,
SELECT bp.user_id, 'bd_leader', `+userCountryRegionIDSQL("user_region")+`, 0,
bp.status, bp.created_by_user_id, bp.created_at_ms, bp.updated_at_ms,
COALESCE(u.current_display_user_id, ''), COALESCE(u.username, ''),
COALESCE(u.avatar, ''), COALESCE(r.name, ''),
COALESCE(u.avatar, ''), `+userCountryRegionNameSQL("user_region")+`,
'', '', '',
COALESCE(creator.current_display_user_id, ''), COALESCE(creator.username, ''),
COALESCE(creator.avatar, ''),
@ -261,10 +288,10 @@ func (r *Reader) GetBDLeader(ctx context.Context, userID int64) (*userclient.BDP
appCode := appctx.FromContext(ctx)
item := &userclient.BDProfile{}
err := r.db.QueryRowContext(ctx, `
SELECT bp.user_id, 'bd_leader', bp.region_id, 0,
SELECT bp.user_id, 'bd_leader', `+userCountryRegionIDSQL("user_region")+`, 0,
bp.status, bp.created_by_user_id, bp.created_at_ms, bp.updated_at_ms,
COALESCE(u.current_display_user_id, ''), COALESCE(u.username, ''),
COALESCE(u.avatar, ''), COALESCE(r.name, ''),
COALESCE(u.avatar, ''), `+userCountryRegionNameSQL("user_region")+`,
COALESCE(creator.current_display_user_id, ''), COALESCE(creator.username, ''),
COALESCE(creator.avatar, ''),
(
@ -276,8 +303,8 @@ func (r *Reader) GetBDLeader(ctx context.Context, userID int64) (*userclient.BDP
)
FROM bd_leader_profiles bp
LEFT JOIN users u ON u.app_code = bp.app_code AND u.user_id = bp.user_id
`+userCountryRegionJoin("u", "user_country_region", "user_region")+`
LEFT JOIN users creator ON creator.app_code = bp.app_code AND creator.user_id = bp.created_by_user_id
LEFT JOIN regions r ON r.app_code = bp.app_code AND r.region_id = bp.region_id
WHERE bp.app_code = ? AND bp.user_id = ?
`, appCode, userID).Scan(
&item.UserID,
@ -448,12 +475,14 @@ func (r *Reader) DeleteBDLeader(ctx context.Context, userID int64) (*userclient.
}
appCode := appctx.FromContext(ctx)
item := &userclient.BDProfile{}
err := r.db.QueryRowContext(ctx, `
SELECT bp.user_id, 'bd_leader', bp.region_id, 0,
err := r.db.QueryRowContext(ctx, fmt.Sprintf(`
SELECT bp.user_id, 'bd_leader', %s, 0,
bp.status, bp.created_by_user_id, bp.created_at_ms, bp.updated_at_ms
FROM bd_leader_profiles bp
LEFT JOIN users u ON u.app_code = bp.app_code AND u.user_id = bp.user_id
%s
WHERE bp.app_code = ? AND bp.user_id = ?
`, appCode, userID).Scan(
`, userCountryRegionIDSQL("user_region"), userCountryRegionJoin("u", "user_country_region", "user_region")), appCode, userID).Scan(
&item.UserID,
&item.Role,
&item.RegionID,
@ -501,8 +530,8 @@ func (r *Reader) ListAgencies(ctx context.Context, query listQuery) ([]*userclie
whereSQL := `
FROM agencies a
LEFT JOIN users owner ON owner.app_code = a.app_code AND owner.user_id = a.owner_user_id
` + userCountryRegionJoin("owner", "owner_country_region", "owner_region") + `
LEFT JOIN users parent_bd ON parent_bd.app_code = a.app_code AND parent_bd.user_id = a.parent_bd_user_id
LEFT JOIN regions r ON r.app_code = a.app_code AND r.region_id = a.region_id
WHERE a.app_code = ?`
args := []any{appCode}
if query.Status != "" {
@ -513,7 +542,7 @@ func (r *Reader) ListAgencies(ctx context.Context, query listQuery) ([]*userclie
args = append(args, "deleted")
}
if query.RegionID > 0 {
whereSQL += " AND a.region_id = ?"
whereSQL += " AND owner_region.region_id = ?"
args = append(args, query.RegionID)
}
if query.ParentBDUserID > 0 {
@ -522,7 +551,7 @@ func (r *Reader) ListAgencies(ctx context.Context, query listQuery) ([]*userclie
}
if keyword := strings.TrimSpace(query.Keyword); keyword != "" {
like := "%" + keyword + "%"
whereSQL += " AND (a.name LIKE ? OR CAST(a.agency_id AS CHAR) LIKE ? OR CAST(a.owner_user_id AS CHAR) LIKE ? OR owner.current_display_user_id LIKE ? OR owner.username LIKE ? OR CAST(a.parent_bd_user_id AS CHAR) LIKE ? OR parent_bd.current_display_user_id LIKE ? OR parent_bd.username LIKE ? OR r.name LIKE ?)"
whereSQL += " AND (a.name LIKE ? OR CAST(a.agency_id AS CHAR) LIKE ? OR CAST(a.owner_user_id AS CHAR) LIKE ? OR owner.current_display_user_id LIKE ? OR owner.username LIKE ? OR CAST(a.parent_bd_user_id AS CHAR) LIKE ? OR parent_bd.current_display_user_id LIKE ? OR parent_bd.username LIKE ? OR owner_region.name LIKE ?)"
args = append(args, like, like, like, like, like, like, like, like, like)
}
@ -531,13 +560,13 @@ func (r *Reader) ListAgencies(ctx context.Context, query listQuery) ([]*userclie
return nil, 0, err
}
rows, err := r.db.QueryContext(ctx, fmt.Sprintf(`
SELECT a.agency_id, a.owner_user_id, a.region_id, a.parent_bd_user_id,
SELECT a.agency_id, a.owner_user_id, `+userCountryRegionIDSQL("owner_region")+`, a.parent_bd_user_id,
a.name, a.status, a.join_enabled, a.max_hosts,
a.created_by_user_id, a.created_at_ms, a.updated_at_ms,
COALESCE(owner.current_display_user_id, ''), COALESCE(owner.username, ''),
COALESCE(owner.avatar, ''),
COALESCE(parent_bd.current_display_user_id, ''), COALESCE(parent_bd.username, ''),
COALESCE(parent_bd.avatar, ''), COALESCE(r.name, '')
COALESCE(parent_bd.avatar, ''), `+userCountryRegionNameSQL("owner_region")+`
%s
ORDER BY a.created_at_ms DESC, a.agency_id DESC
LIMIT ? OFFSET ?
@ -586,9 +615,9 @@ func (r *Reader) ListHostProfiles(ctx context.Context, query listQuery) ([]*user
whereSQL := `
FROM host_profiles hp
LEFT JOIN users u ON u.app_code = hp.app_code AND u.user_id = hp.user_id
` + userCountryRegionJoin("u", "user_country_region", "user_region") + `
LEFT JOIN agencies a ON a.app_code = hp.app_code AND a.agency_id = hp.current_agency_id
LEFT JOIN users agency_owner ON agency_owner.app_code = a.app_code AND agency_owner.user_id = a.owner_user_id
LEFT JOIN regions r ON r.app_code = hp.app_code AND r.region_id = hp.region_id
WHERE hp.app_code = ?`
args := []any{appCode}
if query.Status != "" {
@ -596,7 +625,7 @@ func (r *Reader) ListHostProfiles(ctx context.Context, query listQuery) ([]*user
args = append(args, query.Status)
}
if query.RegionID > 0 {
whereSQL += " AND hp.region_id = ?"
whereSQL += " AND user_region.region_id = ?"
args = append(args, query.RegionID)
}
if query.AgencyID > 0 {
@ -605,7 +634,7 @@ func (r *Reader) ListHostProfiles(ctx context.Context, query listQuery) ([]*user
}
if keyword := strings.TrimSpace(query.Keyword); keyword != "" {
like := "%" + keyword + "%"
whereSQL += " AND (CAST(hp.user_id AS CHAR) LIKE ? OR u.current_display_user_id LIKE ? OR u.username LIKE ? OR a.name LIKE ? OR agency_owner.current_display_user_id LIKE ? OR agency_owner.username LIKE ? OR r.name LIKE ?)"
whereSQL += " AND (CAST(hp.user_id AS CHAR) LIKE ? OR u.current_display_user_id LIKE ? OR u.username LIKE ? OR a.name LIKE ? OR agency_owner.current_display_user_id LIKE ? OR agency_owner.username LIKE ? OR user_region.name LIKE ?)"
args = append(args, like, like, like, like, like, like, like)
}
@ -620,11 +649,11 @@ func (r *Reader) ListHostProfiles(ctx context.Context, query listQuery) ([]*user
queryArgs = args
}
rows, err := r.db.QueryContext(ctx, fmt.Sprintf(`
SELECT hp.user_id, hp.status, hp.region_id, COALESCE(hp.current_agency_id, 0),
SELECT hp.user_id, hp.status, `+userCountryRegionIDSQL("user_region")+`, COALESCE(hp.current_agency_id, 0),
COALESCE(hp.current_membership_id, 0), hp.source,
hp.first_became_host_at_ms, hp.created_at_ms, hp.updated_at_ms,
COALESCE(u.current_display_user_id, ''), COALESCE(u.username, ''),
COALESCE(u.avatar, ''), COALESCE(r.name, ''), COALESCE(a.name, ''),
COALESCE(u.avatar, ''), `+userCountryRegionNameSQL("user_region")+`, COALESCE(a.name, ''),
COALESCE(a.owner_user_id, 0), COALESCE(agency_owner.current_display_user_id, ''),
COALESCE(agency_owner.username, ''), COALESCE(agency_owner.avatar, '')
%s

View File

@ -1,6 +1,9 @@
package hostorg
import "testing"
import (
"strings"
"testing"
)
// TestNormalizeListQueryAllowsCoinSellerComputedSorts 锁定币商余额和累充 USDT 排序字段会被后台列表查询接受。
func TestNormalizeListQueryAllowsCoinSellerComputedSorts(t *testing.T) {
@ -25,6 +28,32 @@ func TestBDRoleFromStatusPathUsesLeaderTableWithAPIPrefix(t *testing.T) {
}
}
// TestUserCountryRegionSQLUsesCountryMapping 锁定组织列表区域展示口径:从用户国家映射 active 区域,不从身份表区域快照取值。
func TestUserCountryRegionSQLUsesCountryMapping(t *testing.T) {
joinSQL := userCountryRegionJoin("owner", "owner_country_region", "owner_region")
for _, want := range []string{
"LEFT JOIN region_countries owner_country_region",
"owner_country_region.country_code = owner.country",
"owner_country_region.status = 'active'",
"LEFT JOIN regions owner_region",
"owner_region.region_id = owner_country_region.region_id",
"owner_region.status = 'active'",
} {
if !strings.Contains(joinSQL, want) {
t.Fatalf("country region join missing %q in %s", want, joinSQL)
}
}
if strings.Contains(joinSQL, "owner.region_id") {
t.Fatalf("country region join must not read identity or user region snapshots: %s", joinSQL)
}
if got := userCountryRegionIDSQL("owner_region"); got != "COALESCE(owner_region.region_id, 0)" {
t.Fatalf("region id projection mismatch: %s", got)
}
if got := userCountryRegionNameSQL("owner_region"); got != "COALESCE(owner_region.name, '')" {
t.Fatalf("region name projection mismatch: %s", got)
}
}
// TestSortCoinSellerListItemsComputedFields 验证 wallet 聚合字段排序只改变展示顺序,不改动币商身份数据。
func TestSortCoinSellerListItemsComputedFields(t *testing.T) {
items := []*CoinSellerListItem{

View File

@ -0,0 +1,364 @@
package inviteactivityreward
import (
"context"
"database/sql"
"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"
)
type Handler struct {
activity activityclient.Client
userDB *sql.DB
audit shared.OperationLogger
}
func New(activity activityclient.Client, userDB *sql.DB, audit shared.OperationLogger) *Handler {
return &Handler{activity: activity, userDB: userDB, audit: audit}
}
type configRequest struct {
Enabled bool `json:"enabled"`
PerInviteInviterRewardCoinAmount int64 `json:"per_invite_inviter_reward_coin_amount"`
PerInviteInviteeRewardCoinAmount int64 `json:"per_invite_invitee_reward_coin_amount"`
Tiers []tierDTO `json:"tiers"`
}
type configDTO struct {
AppCode string `json:"app_code"`
Enabled bool `json:"enabled"`
PerInviteInviterRewardCoinAmount int64 `json:"per_invite_inviter_reward_coin_amount"`
PerInviteInviteeRewardCoinAmount int64 `json:"per_invite_invitee_reward_coin_amount"`
Tiers []tierDTO `json:"tiers"`
UpdatedByAdminID int64 `json:"updated_by_admin_id"`
CreatedAtMS int64 `json:"created_at_ms"`
UpdatedAtMS int64 `json:"updated_at_ms"`
}
type tierDTO struct {
TierID int64 `json:"tier_id"`
RewardType string `json:"reward_type"`
TierCode string `json:"tier_code"`
TierName string `json:"tier_name"`
ThresholdCoinAmount int64 `json:"threshold_coin_amount"`
ThresholdValidInviteCount int64 `json:"threshold_valid_invite_count"`
RewardCoinAmount int64 `json:"reward_coin_amount"`
Status string `json:"status"`
SortOrder int32 `json:"sort_order"`
CreatedAtMS int64 `json:"created_at_ms"`
UpdatedAtMS int64 `json:"updated_at_ms"`
}
type claimDTO struct {
ClaimID string `json:"claim_id"`
AppCode string `json:"app_code"`
CycleKey string `json:"cycle_key"`
UserID int64 `json:"user_id"`
User *userDTO `json:"user,omitempty"`
RewardType string `json:"reward_type"`
CommandID string `json:"command_id"`
TierID int64 `json:"tier_id"`
TierCode string `json:"tier_code"`
TierName string `json:"tier_name"`
ThresholdCoinAmount int64 `json:"threshold_coin_amount"`
ThresholdValidInviteCount int64 `json:"threshold_valid_invite_count"`
ReachedValue int64 `json:"reached_value"`
RewardCoinAmount int64 `json:"reward_coin_amount"`
Status string `json:"status"`
WalletCommandID string `json:"wallet_command_id"`
WalletTransactionID string `json:"wallet_transaction_id"`
FailureReason string `json:"failure_reason"`
GrantedAtMS int64 `json:"granted_at_ms"`
CreatedAtMS int64 `json:"created_at_ms"`
UpdatedAtMS int64 `json:"updated_at_ms"`
}
type userDTO struct {
UserID int64 `json:"user_id"`
DisplayUserID string `json:"display_user_id"`
Username string `json:"username"`
Avatar string `json:"avatar"`
}
func (h *Handler) GetConfig(c *gin.Context) {
resp, err := h.activity.GetInviteActivityRewardConfig(c.Request.Context(), &activityv1.GetInviteActivityRewardConfigRequest{Meta: h.meta(c)})
if err != nil {
response.ServerError(c, "获取邀请活动配置失败")
return
}
response.OK(c, configFromProto(resp.GetConfig()))
}
func (h *Handler) UpdateConfig(c *gin.Context) {
var req configRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "邀请活动配置参数不正确")
return
}
tiers := make([]*activityv1.InviteActivityRewardTier, 0, len(req.Tiers))
for _, tier := range req.Tiers {
// admin-server 只裁剪字符串和注入操作人;档位门槛、奖励金额、最高档领取语义都由 activity-service 统一校验。
tiers = append(tiers, &activityv1.InviteActivityRewardTier{
TierId: tier.TierID,
RewardType: strings.TrimSpace(tier.RewardType),
TierCode: strings.TrimSpace(tier.TierCode),
TierName: strings.TrimSpace(tier.TierName),
ThresholdCoinAmount: tier.ThresholdCoinAmount,
ThresholdValidInviteCount: tier.ThresholdValidInviteCount,
RewardCoinAmount: tier.RewardCoinAmount,
Status: strings.TrimSpace(tier.Status),
SortOrder: tier.SortOrder,
})
}
resp, err := h.activity.UpdateInviteActivityRewardConfig(c.Request.Context(), &activityv1.UpdateInviteActivityRewardConfigRequest{
Meta: h.meta(c),
Enabled: req.Enabled,
PerInviteInviterRewardCoinAmount: req.PerInviteInviterRewardCoinAmount,
PerInviteInviteeRewardCoinAmount: req.PerInviteInviteeRewardCoinAmount,
Tiers: tiers,
OperatorAdminId: int64(middleware.CurrentUserID(c)),
})
if err != nil {
response.BadRequest(c, err.Error())
return
}
item := configFromProto(resp.GetConfig())
shared.OperationLogWithResourceID(c, h.audit, "update-invite-activity-reward", "invite_activity_reward_configs", item.AppCode, "success", "")
response.OK(c, item)
}
func (h *Handler) ListClaims(c *gin.Context) {
options := shared.ListOptions(c)
userID, matched, ok := h.resolveUserID(c.Request.Context(), strings.TrimSpace(options.Keyword))
if !ok {
response.ServerError(c, "查询用户信息失败")
return
}
if options.Keyword != "" && !matched {
response.OK(c, response.Page{Items: []claimDTO{}, Page: options.Page, PageSize: options.PageSize, Total: 0})
return
}
resp, err := h.activity.ListInviteActivityRewardClaims(c.Request.Context(), &activityv1.ListInviteActivityRewardClaimsRequest{
Meta: h.meta(c),
Status: strings.TrimSpace(options.Status),
RewardType: strings.TrimSpace(c.Query("reward_type")),
UserId: userID,
CycleKey: strings.TrimSpace(c.Query("cycle_key")),
Page: int32(options.Page),
PageSize: int32(options.PageSize),
})
if err != nil {
response.ServerError(c, "获取邀请活动领取记录失败")
return
}
items := make([]claimDTO, 0, len(resp.GetClaims()))
for _, claim := range resp.GetClaims() {
items = append(items, claimFromProto(claim))
}
if err := h.fillUsers(c.Request.Context(), items); err != nil {
response.ServerError(c, "补全用户信息失败")
return
}
response.OK(c, response.Page{Items: items, Page: options.Page, PageSize: options.PageSize, Total: resp.GetTotal()})
}
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().UnixMilli(),
}
}
func (h *Handler) resolveUserID(ctx context.Context, keyword string) (int64, bool, bool) {
if keyword == "" {
return 0, false, true
}
if h.userDB == nil {
return 0, false, true
}
appCode := appctx.FromContext(ctx)
rows, err := h.userDB.QueryContext(ctx, `
SELECT user_id
FROM users
WHERE app_code = ?
AND (
CAST(user_id AS CHAR) = ?
OR current_display_user_id = ?
OR username LIKE ?
)
ORDER BY
CASE
WHEN CAST(user_id AS CHAR) = ? THEN 0
WHEN current_display_user_id = ? THEN 1
ELSE 2
END,
user_id DESC
LIMIT 1`,
appCode, keyword, keyword, "%"+keyword+"%", keyword, keyword,
)
if err != nil {
return 0, false, false
}
defer rows.Close()
if !rows.Next() {
return 0, false, rows.Err() == nil
}
var userID int64
if err := rows.Scan(&userID); err != nil {
return 0, false, false
}
return userID, true, rows.Err() == nil
}
func (h *Handler) fillUsers(ctx context.Context, claims []claimDTO) error {
if h.userDB == nil || len(claims) == 0 {
return nil
}
ids := collectUserIDs(claims)
if len(ids) == 0 {
return nil
}
args := append([]any{appctx.FromContext(ctx)}, int64Args(ids)...)
rows, err := h.userDB.QueryContext(ctx, `
SELECT user_id, current_display_user_id, COALESCE(username, ''), COALESCE(avatar, '')
FROM users
WHERE app_code = ? AND user_id IN (`+placeholders(len(ids))+`)`,
args...,
)
if err != nil {
return err
}
defer rows.Close()
users := make(map[int64]userDTO, len(ids))
for rows.Next() {
var user userDTO
if err := rows.Scan(&user.UserID, &user.DisplayUserID, &user.Username, &user.Avatar); err != nil {
return err
}
users[user.UserID] = user
}
if err := rows.Err(); err != nil {
return err
}
for index := range claims {
if user, ok := users[claims[index].UserID]; ok {
claims[index].User = &user
continue
}
claims[index].User = &userDTO{UserID: claims[index].UserID}
}
return nil
}
func configFromProto(config *activityv1.InviteActivityRewardConfig) configDTO {
if config == nil {
return configDTO{Tiers: []tierDTO{}}
}
tiers := make([]tierDTO, 0, len(config.GetTiers()))
for _, tier := range config.GetTiers() {
tiers = append(tiers, tierFromProto(tier))
}
return configDTO{
AppCode: config.GetAppCode(),
Enabled: config.GetEnabled(),
PerInviteInviterRewardCoinAmount: config.GetPerInviteInviterRewardCoinAmount(),
PerInviteInviteeRewardCoinAmount: config.GetPerInviteInviteeRewardCoinAmount(),
Tiers: tiers,
UpdatedByAdminID: config.GetUpdatedByAdminId(),
CreatedAtMS: config.GetCreatedAtMs(),
UpdatedAtMS: config.GetUpdatedAtMs(),
}
}
func tierFromProto(tier *activityv1.InviteActivityRewardTier) tierDTO {
if tier == nil {
return tierDTO{}
}
return tierDTO{
TierID: tier.GetTierId(),
RewardType: tier.GetRewardType(),
TierCode: tier.GetTierCode(),
TierName: tier.GetTierName(),
ThresholdCoinAmount: tier.GetThresholdCoinAmount(),
ThresholdValidInviteCount: tier.GetThresholdValidInviteCount(),
RewardCoinAmount: tier.GetRewardCoinAmount(),
Status: tier.GetStatus(),
SortOrder: tier.GetSortOrder(),
CreatedAtMS: tier.GetCreatedAtMs(),
UpdatedAtMS: tier.GetUpdatedAtMs(),
}
}
func claimFromProto(claim *activityv1.InviteActivityRewardClaim) claimDTO {
if claim == nil {
return claimDTO{}
}
return claimDTO{
ClaimID: claim.GetClaimId(),
AppCode: claim.GetAppCode(),
CycleKey: claim.GetCycleKey(),
UserID: claim.GetUserId(),
RewardType: claim.GetRewardType(),
CommandID: claim.GetCommandId(),
TierID: claim.GetTierId(),
TierCode: claim.GetTierCode(),
TierName: claim.GetTierName(),
ThresholdCoinAmount: claim.GetThresholdCoinAmount(),
ThresholdValidInviteCount: claim.GetThresholdValidInviteCount(),
ReachedValue: claim.GetReachedValue(),
RewardCoinAmount: claim.GetRewardCoinAmount(),
Status: claim.GetStatus(),
WalletCommandID: claim.GetWalletCommandId(),
WalletTransactionID: claim.GetWalletTransactionId(),
FailureReason: claim.GetFailureReason(),
GrantedAtMS: claim.GetGrantedAtMs(),
CreatedAtMS: claim.GetCreatedAtMs(),
UpdatedAtMS: claim.GetUpdatedAtMs(),
}
}
func collectUserIDs(claims []claimDTO) []int64 {
seen := make(map[int64]struct{}, len(claims))
ids := make([]int64, 0, len(claims))
for _, claim := range claims {
if claim.UserID <= 0 {
continue
}
if _, ok := seen[claim.UserID]; ok {
continue
}
seen[claim.UserID] = struct{}{}
ids = append(ids, claim.UserID)
}
return ids
}
func int64Args(ids []int64) []any {
args := make([]any, 0, len(ids))
for _, id := range ids {
args = append(args, id)
}
return args
}
func placeholders(count int) string {
if count <= 0 {
return ""
}
parts := make([]string, count)
for i := range parts {
parts[i] = "?"
}
return strings.Join(parts, ",")
}

View File

@ -0,0 +1,16 @@
package inviteactivityreward
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/invite-reward/config", middleware.RequirePermission("invite-activity-reward:view"), h.GetConfig)
protected.PUT("/admin/activity/invite-reward/config", middleware.RequirePermission("invite-activity-reward:update"), h.UpdateConfig)
protected.GET("/admin/activity/invite-reward/claims", middleware.RequirePermission("invite-activity-reward:view"), h.ListClaims)
}

View File

@ -47,6 +47,7 @@ type rechargeProductDTO struct {
ProductCode string `json:"productCode"`
ProductName string `json:"productName"`
Description string `json:"description"`
AudienceType string `json:"audienceType"`
Platform string `json:"platform"`
Channel string `json:"channel"`
CurrencyCode string `json:"currencyCode"`
@ -67,6 +68,34 @@ type rechargeProductDTO struct {
UpdatedAtMS int64 `json:"updatedAtMs"`
}
type thirdPartyPaymentChannelDTO struct {
AppCode string `json:"appCode"`
ProviderCode string `json:"providerCode"`
ProviderName string `json:"providerName"`
Status string `json:"status"`
SortOrder int32 `json:"sortOrder"`
Methods []thirdPartyPaymentMethodDTO `json:"methods"`
}
type thirdPartyPaymentMethodDTO struct {
MethodID int64 `json:"methodId"`
AppCode string `json:"appCode"`
ProviderCode string `json:"providerCode"`
ProviderName string `json:"providerName"`
CountryCode string `json:"countryCode"`
CountryName string `json:"countryName"`
CurrencyCode string `json:"currencyCode"`
PayWay string `json:"payWay"`
PayType string `json:"payType"`
MethodName string `json:"methodName"`
LogoURL string `json:"logoUrl"`
Status string `json:"status"`
USDToCurrencyRate string `json:"usdToCurrencyRate"`
SortOrder int32 `json:"sortOrder"`
CreatedAtMS int64 `json:"createdAtMs"`
UpdatedAtMS int64 `json:"updatedAtMs"`
}
func rechargeBillFromProto(item *walletv1.RechargeBill) rechargeBillDTO {
if item == nil {
return rechargeBillDTO{}
@ -154,6 +183,7 @@ func rechargeProductFromProto(item *walletv1.RechargeProduct) rechargeProductDTO
ProductCode: item.GetProductCode(),
ProductName: item.GetProductName(),
Description: item.GetDescription(),
AudienceType: item.GetAudienceType(),
Platform: item.GetPlatform(),
Channel: item.GetChannel(),
CurrencyCode: item.GetCurrencyCode(),
@ -175,6 +205,48 @@ func rechargeProductFromProto(item *walletv1.RechargeProduct) rechargeProductDTO
}
}
func thirdPartyPaymentChannelFromProto(item *walletv1.ThirdPartyPaymentChannel) thirdPartyPaymentChannelDTO {
if item == nil {
return thirdPartyPaymentChannelDTO{}
}
dto := thirdPartyPaymentChannelDTO{
AppCode: item.GetAppCode(),
ProviderCode: item.GetProviderCode(),
ProviderName: item.GetProviderName(),
Status: item.GetStatus(),
SortOrder: item.GetSortOrder(),
Methods: make([]thirdPartyPaymentMethodDTO, 0, len(item.GetMethods())),
}
for _, method := range item.GetMethods() {
dto.Methods = append(dto.Methods, thirdPartyPaymentMethodFromProto(method))
}
return dto
}
func thirdPartyPaymentMethodFromProto(item *walletv1.ThirdPartyPaymentMethod) thirdPartyPaymentMethodDTO {
if item == nil {
return thirdPartyPaymentMethodDTO{}
}
return thirdPartyPaymentMethodDTO{
MethodID: item.GetMethodId(),
AppCode: item.GetAppCode(),
ProviderCode: item.GetProviderCode(),
ProviderName: item.GetProviderName(),
CountryCode: item.GetCountryCode(),
CountryName: item.GetCountryName(),
CurrencyCode: item.GetCurrencyCode(),
PayWay: item.GetPayWay(),
PayType: item.GetPayType(),
MethodName: item.GetMethodName(),
LogoURL: item.GetLogoUrl(),
Status: item.GetStatus(),
USDToCurrencyRate: item.GetUsdToCurrencyRate(),
SortOrder: item.GetSortOrder(),
CreatedAtMS: item.GetCreatedAtMs(),
UpdatedAtMS: item.GetUpdatedAtMs(),
}
}
func formatUSDTMicro(amount int64) string {
if amount <= 0 {
return "0"

View File

@ -130,14 +130,15 @@ func (h *Handler) loadRechargeBillUsers(ctx context.Context, appCode string, use
func (h *Handler) ListRechargeProducts(c *gin.Context) {
options := shared.ListOptions(c)
resp, err := h.wallet.ListAdminRechargeProducts(c.Request.Context(), &walletv1.ListAdminRechargeProductsRequest{
RequestId: middleware.CurrentRequestID(c),
AppCode: appctx.FromContext(c.Request.Context()),
Status: strings.TrimSpace(firstQuery(c, "status")),
Platform: strings.TrimSpace(firstQuery(c, "platform")),
RegionId: queryInt64(c, "region_id", "regionId"),
Keyword: options.Keyword,
Page: int32(options.Page),
PageSize: int32(options.PageSize),
RequestId: middleware.CurrentRequestID(c),
AppCode: appctx.FromContext(c.Request.Context()),
Status: strings.TrimSpace(firstQuery(c, "status")),
Platform: strings.TrimSpace(firstQuery(c, "platform")),
RegionId: queryInt64(c, "region_id", "regionId"),
AudienceType: strings.TrimSpace(firstQuery(c, "audience_type", "audienceType")),
Keyword: options.Keyword,
Page: int32(options.Page),
PageSize: int32(options.PageSize),
})
if err != nil {
writeWalletError(c, err, "获取内购配置失败")
@ -169,6 +170,7 @@ func (h *Handler) CreateRechargeProduct(c *gin.Context) {
ProductName: strings.TrimSpace(request.ProductName),
Description: strings.TrimSpace(request.Description),
Platform: strings.TrimSpace(request.Platform),
AudienceType: strings.TrimSpace(request.AudienceType),
RegionIds: request.RegionIDs,
Enabled: request.Enabled,
OperatorUserId: actorID(c),
@ -206,6 +208,7 @@ func (h *Handler) UpdateRechargeProduct(c *gin.Context) {
ProductName: strings.TrimSpace(request.ProductName),
Description: strings.TrimSpace(request.Description),
Platform: strings.TrimSpace(request.Platform),
AudienceType: strings.TrimSpace(request.AudienceType),
RegionIds: request.RegionIDs,
Enabled: request.Enabled,
OperatorUserId: actorID(c),
@ -238,17 +241,99 @@ func (h *Handler) DeleteRechargeProduct(c *gin.Context) {
response.OK(c, gin.H{"deleted": true})
}
func (h *Handler) ListThirdPartyPaymentChannels(c *gin.Context) {
resp, err := h.wallet.ListThirdPartyPaymentChannels(c.Request.Context(), &walletv1.ListThirdPartyPaymentChannelsRequest{
RequestId: middleware.CurrentRequestID(c),
AppCode: appctx.FromContext(c.Request.Context()),
ProviderCode: strings.TrimSpace(firstQuery(c, "provider_code", "providerCode")),
Status: strings.TrimSpace(firstQuery(c, "status")),
IncludeDisabledMethods: true,
})
if err != nil {
writeWalletError(c, err, "获取三方支付渠道失败")
return
}
items := make([]thirdPartyPaymentChannelDTO, 0, len(resp.GetChannels()))
for _, item := range resp.GetChannels() {
items = append(items, thirdPartyPaymentChannelFromProto(item))
}
response.OK(c, gin.H{"items": items, "total": len(items)})
}
func (h *Handler) SetThirdPartyPaymentMethodStatus(c *gin.Context) {
methodID := queryPathInt64(c, "method_id")
if methodID <= 0 {
response.BadRequest(c, "支付方式ID不正确")
return
}
var request thirdPartyPaymentMethodStatusRequest
if err := c.ShouldBindJSON(&request); err != nil {
response.BadRequest(c, "支付方式状态参数不正确")
return
}
resp, err := h.wallet.SetThirdPartyPaymentMethodStatus(c.Request.Context(), &walletv1.SetThirdPartyPaymentMethodStatusRequest{
RequestId: middleware.CurrentRequestID(c),
AppCode: appctx.FromContext(c.Request.Context()),
MethodId: methodID,
Enabled: request.Enabled,
OperatorUserId: actorID(c),
})
if err != nil {
writeWalletError(c, err, "修改支付方式状态失败")
return
}
item := thirdPartyPaymentMethodFromProto(resp.GetMethod())
shared.OperationLogWithResourceID(c, h.audit, "update-third-party-payment-method-status", "third_party_payment_methods", strconv.FormatInt(methodID, 10), "success", item.Status)
response.OK(c, item)
}
func (h *Handler) UpdateThirdPartyPaymentRate(c *gin.Context) {
methodID := queryPathInt64(c, "method_id")
if methodID <= 0 {
response.BadRequest(c, "支付方式ID不正确")
return
}
var request thirdPartyPaymentRateRequest
if err := c.ShouldBindJSON(&request); err != nil || strings.TrimSpace(request.USDToCurrencyRate) == "" {
response.BadRequest(c, "支付汇率参数不正确")
return
}
resp, err := h.wallet.UpdateThirdPartyPaymentRate(c.Request.Context(), &walletv1.UpdateThirdPartyPaymentRateRequest{
RequestId: middleware.CurrentRequestID(c),
AppCode: appctx.FromContext(c.Request.Context()),
MethodId: methodID,
UsdToCurrencyRate: strings.TrimSpace(request.USDToCurrencyRate),
OperatorUserId: actorID(c),
})
if err != nil {
writeWalletError(c, err, "修改支付方式汇率失败")
return
}
item := thirdPartyPaymentMethodFromProto(resp.GetMethod())
shared.OperationLogWithResourceID(c, h.audit, "update-third-party-payment-rate", "third_party_payment_methods", strconv.FormatInt(methodID, 10), "success", item.USDToCurrencyRate)
response.OK(c, item)
}
type rechargeProductRequest struct {
AmountUSDT string `json:"amountUsdt"`
AmountUSDTMicro int64 `json:"amountUsdtMicro"`
CoinAmount int64 `json:"coinAmount"`
ProductName string `json:"productName"`
Description string `json:"description"`
AudienceType string `json:"audienceType"`
Platform string `json:"platform"`
RegionIDs []int64 `json:"regionIds"`
Enabled bool `json:"enabled"`
}
type thirdPartyPaymentMethodStatusRequest struct {
Enabled bool `json:"enabled"`
}
type thirdPartyPaymentRateRequest struct {
USDToCurrencyRate string `json:"usdToCurrencyRate"`
}
func queryInt64(c *gin.Context, keys ...string) int64 {
value := strings.TrimSpace(firstQuery(c, keys...))
if value == "" {
@ -270,6 +355,15 @@ func firstQuery(c *gin.Context, keys ...string) string {
return ""
}
func queryPathInt64(c *gin.Context, name string) int64 {
value := strings.TrimSpace(c.Param(name))
parsed, err := strconv.ParseInt(value, 10, 64)
if err != nil || parsed <= 0 {
return 0
}
return parsed
}
func productID(c *gin.Context) (int64, bool) {
value := strings.TrimSpace(c.Param("product_id"))
parsed, err := strconv.ParseInt(value, 10, 64)

View File

@ -0,0 +1,227 @@
package payment
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"hyapp-admin-server/internal/appctx"
"hyapp-admin-server/internal/integration/walletclient"
"hyapp-admin-server/internal/middleware"
walletv1 "hyapp.local/api/proto/wallet/v1"
"github.com/gin-gonic/gin"
)
func TestListThirdPartyPaymentChannelsForwardsIncludeDisabledMethods(t *testing.T) {
wallet := &mockPaymentWallet{thirdPartyChannelsResp: &walletv1.ListThirdPartyPaymentChannelsResponse{
Channels: []*walletv1.ThirdPartyPaymentChannel{{
AppCode: "lalu",
ProviderCode: "mifapay",
ProviderName: "MiFaPay",
Status: "active",
SortOrder: 10,
Methods: []*walletv1.ThirdPartyPaymentMethod{{
MethodId: 810,
AppCode: "lalu",
ProviderCode: "mifapay",
ProviderName: "MiFaPay",
CountryCode: "SA",
CountryName: "Saudi Arabia",
CurrencyCode: "SAR",
PayWay: "Card",
PayType: "MADA",
MethodName: "MADA",
Status: "active",
UsdToCurrencyRate: "1.00000000",
}},
}},
}}
router := newPaymentHandlerTestRouter(New(wallet, nil, nil))
request := httptest.NewRequest(http.MethodGet, "/admin/payment/third-party-channels?provider_code=mifapay&status=active", nil)
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK {
t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String())
}
if wallet.lastThirdPartyChannels == nil ||
wallet.lastThirdPartyChannels.GetAppCode() != "lalu" ||
wallet.lastThirdPartyChannels.GetProviderCode() != "mifapay" ||
wallet.lastThirdPartyChannels.GetStatus() != "active" ||
!wallet.lastThirdPartyChannels.GetIncludeDisabledMethods() {
t.Fatalf("third party channels request mismatch: %+v", wallet.lastThirdPartyChannels)
}
var response adminPaymentTestResponse
if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil {
t.Fatalf("decode response failed: %v", err)
}
items := response.Data["items"].([]any)
channel := items[0].(map[string]any)
methods := channel["methods"].([]any)
if response.Code != 0 || channel["providerCode"] != "mifapay" || len(methods) != 1 || methods[0].(map[string]any)["payType"] != "MADA" {
t.Fatalf("third party channels response mismatch: %+v", response)
}
}
func TestSetThirdPartyPaymentMethodStatusForwardsOperator(t *testing.T) {
wallet := &mockPaymentWallet{}
router := newPaymentHandlerTestRouter(New(wallet, nil, nil))
request := httptest.NewRequest(http.MethodPatch, "/admin/payment/third-party-methods/810/status", bytes.NewBufferString(`{"enabled":false}`))
request.Header.Set("Content-Type", "application/json")
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK {
t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String())
}
if wallet.lastSetMethodStatus == nil ||
wallet.lastSetMethodStatus.GetMethodId() != 810 ||
wallet.lastSetMethodStatus.GetEnabled() ||
wallet.lastSetMethodStatus.GetOperatorUserId() != 7 {
t.Fatalf("method status request mismatch: %+v", wallet.lastSetMethodStatus)
}
}
func TestUpdateThirdPartyPaymentRateTrimsRateAndForwardsOperator(t *testing.T) {
wallet := &mockPaymentWallet{}
router := newPaymentHandlerTestRouter(New(wallet, nil, nil))
request := httptest.NewRequest(http.MethodPatch, "/admin/payment/third-party-rates/810", bytes.NewBufferString(`{"usdToCurrencyRate":" 3.75000000 "}`))
request.Header.Set("Content-Type", "application/json")
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK {
t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String())
}
if wallet.lastUpdateRate == nil ||
wallet.lastUpdateRate.GetMethodId() != 810 ||
wallet.lastUpdateRate.GetUsdToCurrencyRate() != "3.75000000" ||
wallet.lastUpdateRate.GetOperatorUserId() != 7 {
t.Fatalf("payment rate request mismatch: %+v", wallet.lastUpdateRate)
}
}
func TestCreateRechargeProductForwardsWebCoinSellerAudience(t *testing.T) {
wallet := &mockPaymentWallet{}
router := newPaymentHandlerTestRouter(New(wallet, nil, nil))
body := `{"amountUsdt":"10.000000","coinAmount":880000,"productName":"Seller 10 USD","description":"coin seller tier","audienceType":"coin_seller","platform":"web","regionIds":[7100],"enabled":true}`
request := httptest.NewRequest(http.MethodPost, "/admin/payment/recharge-products", bytes.NewBufferString(body))
request.Header.Set("Content-Type", "application/json")
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request)
if recorder.Code != http.StatusCreated {
t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String())
}
if wallet.lastCreateProduct == nil ||
wallet.lastCreateProduct.GetAmountMicro() != 10_000_000 ||
wallet.lastCreateProduct.GetCoinAmount() != 880000 ||
wallet.lastCreateProduct.GetAudienceType() != "coin_seller" ||
wallet.lastCreateProduct.GetPlatform() != "web" ||
len(wallet.lastCreateProduct.GetRegionIds()) != 1 ||
wallet.lastCreateProduct.GetRegionIds()[0] != 7100 ||
!wallet.lastCreateProduct.GetEnabled() ||
wallet.lastCreateProduct.GetOperatorUserId() != 7 {
t.Fatalf("create recharge product request mismatch: %+v", wallet.lastCreateProduct)
}
}
func newPaymentHandlerTestRouter(handler *Handler) *gin.Engine {
gin.SetMode(gin.TestMode)
router := gin.New()
router.Use(func(c *gin.Context) {
// 后台 handler 只依赖 app_code、request_id 和操作者;测试在这里模拟鉴权中间件已完成后的上下文。
c.Request = c.Request.WithContext(appctx.WithContext(c.Request.Context(), "lalu"))
c.Set(middleware.ContextRequestID, "payment-handler-test")
c.Set(middleware.ContextUserID, uint(7))
c.Set(middleware.ContextUsername, "tester")
c.Next()
})
router.GET("/admin/payment/third-party-channels", handler.ListThirdPartyPaymentChannels)
router.PATCH("/admin/payment/third-party-methods/:method_id/status", handler.SetThirdPartyPaymentMethodStatus)
router.PATCH("/admin/payment/third-party-rates/:method_id", handler.UpdateThirdPartyPaymentRate)
router.POST("/admin/payment/recharge-products", handler.CreateRechargeProduct)
return router
}
type adminPaymentTestResponse struct {
Code int `json:"code"`
Data map[string]any `json:"data"`
}
type mockPaymentWallet struct {
walletclient.Client
lastThirdPartyChannels *walletv1.ListThirdPartyPaymentChannelsRequest
thirdPartyChannelsResp *walletv1.ListThirdPartyPaymentChannelsResponse
lastSetMethodStatus *walletv1.SetThirdPartyPaymentMethodStatusRequest
lastUpdateRate *walletv1.UpdateThirdPartyPaymentRateRequest
lastCreateProduct *walletv1.CreateRechargeProductRequest
}
func (m *mockPaymentWallet) ListThirdPartyPaymentChannels(_ context.Context, req *walletv1.ListThirdPartyPaymentChannelsRequest) (*walletv1.ListThirdPartyPaymentChannelsResponse, error) {
m.lastThirdPartyChannels = req
if m.thirdPartyChannelsResp != nil {
return m.thirdPartyChannelsResp, nil
}
return &walletv1.ListThirdPartyPaymentChannelsResponse{}, nil
}
func (m *mockPaymentWallet) SetThirdPartyPaymentMethodStatus(_ context.Context, req *walletv1.SetThirdPartyPaymentMethodStatusRequest) (*walletv1.ThirdPartyPaymentMethodResponse, error) {
m.lastSetMethodStatus = req
status := "disabled"
if req.GetEnabled() {
status = "active"
}
return &walletv1.ThirdPartyPaymentMethodResponse{Method: &walletv1.ThirdPartyPaymentMethod{
MethodId: req.GetMethodId(),
AppCode: req.GetAppCode(),
ProviderCode: "mifapay",
ProviderName: "MiFaPay",
CountryCode: "SA",
PayWay: "Card",
PayType: "MADA",
Status: status,
}}, nil
}
func (m *mockPaymentWallet) UpdateThirdPartyPaymentRate(_ context.Context, req *walletv1.UpdateThirdPartyPaymentRateRequest) (*walletv1.ThirdPartyPaymentMethodResponse, error) {
m.lastUpdateRate = req
return &walletv1.ThirdPartyPaymentMethodResponse{Method: &walletv1.ThirdPartyPaymentMethod{
MethodId: req.GetMethodId(),
AppCode: req.GetAppCode(),
ProviderCode: "mifapay",
ProviderName: "MiFaPay",
CountryCode: "SA",
PayWay: "Card",
PayType: "MADA",
Status: "active",
UsdToCurrencyRate: req.GetUsdToCurrencyRate(),
}}, nil
}
func (m *mockPaymentWallet) CreateRechargeProduct(_ context.Context, req *walletv1.CreateRechargeProductRequest) (*walletv1.RechargeProductResponse, error) {
m.lastCreateProduct = req
return &walletv1.RechargeProductResponse{Product: &walletv1.RechargeProduct{
AppCode: req.GetAppCode(),
ProductId: 9001,
ProductCode: "h5_seller_10",
ProductName: req.GetProductName(),
Description: req.GetDescription(),
AudienceType: req.GetAudienceType(),
Platform: req.GetPlatform(),
AmountMicro: req.GetAmountMicro(),
CoinAmount: req.GetCoinAmount(),
RegionIds: append([]int64(nil), req.GetRegionIds()...),
Enabled: req.GetEnabled(),
Status: "active",
}}, nil
}

View File

@ -12,6 +12,9 @@ func RegisterRoutes(protected *gin.RouterGroup, h *Handler) {
}
protected.GET("/admin/payment/recharge-bills", middleware.RequirePermission("payment-bill:view"), h.ListRechargeBills)
protected.GET("/admin/payment/third-party-channels", middleware.RequirePermission("payment-third-party:view"), h.ListThirdPartyPaymentChannels)
protected.PATCH("/admin/payment/third-party-methods/:method_id/status", middleware.RequirePermission("payment-third-party:update"), h.SetThirdPartyPaymentMethodStatus)
protected.PATCH("/admin/payment/third-party-rates/:method_id", middleware.RequirePermission("payment-third-party:update"), h.UpdateThirdPartyPaymentRate)
protected.GET("/admin/payment/recharge-products", middleware.RequirePermission("payment-product:view"), h.ListRechargeProducts)
protected.POST("/admin/payment/recharge-products", middleware.RequirePermission("payment-product:create"), h.CreateRechargeProduct)
protected.PUT("/admin/payment/recharge-products/:product_id", middleware.RequirePermission("payment-product:update"), h.UpdateRechargeProduct)

View File

@ -0,0 +1,289 @@
package prettyid
import (
"strconv"
"strings"
"hyapp-admin-server/internal/integration/userclient"
"hyapp-admin-server/internal/middleware"
"hyapp-admin-server/internal/modules/shared"
"hyapp-admin-server/internal/response"
"github.com/gin-gonic/gin"
)
type Handler struct {
user userclient.Client
audit shared.OperationLogger
}
func New(user userclient.Client, audit shared.OperationLogger) *Handler {
return &Handler{user: user, audit: audit}
}
type poolRequest struct {
Name string `json:"name"`
LevelTrack string `json:"level_track"`
MinLevel int32 `json:"min_level"`
MaxLevel int32 `json:"max_level"`
RuleType string `json:"rule_type"`
RuleConfigJSON string `json:"rule_config_json"`
Status string `json:"status"`
SortOrder int32 `json:"sort_order"`
}
type generateRequest struct {
RuleType string `json:"rule_type"`
RuleConfigJSON string `json:"rule_config_json"`
Count int32 `json:"count"`
}
type grantRequest struct {
TargetUserID any `json:"target_user_id"`
DisplayUserID string `json:"display_user_id"`
DurationMs int64 `json:"duration_ms"`
Reason string `json:"reason"`
}
type statusRequest struct {
Status string `json:"status"`
Reason string `json:"reason"`
}
func (h *Handler) ListPools(c *gin.Context) {
page, pageSize, ok := pageQuery(c)
if !ok {
response.BadRequest(c, "分页参数不正确")
return
}
// admin-server 只解析 HTTP 查询参数和当前管理员身份,池状态、等级轨道合法性由 user-service 统一校验。
items, total, err := h.user.ListPrettyDisplayIDPools(c.Request.Context(), userclient.ListPrettyDisplayIDPoolsRequest{
RequestID: middleware.CurrentRequestID(c),
Caller: "admin-server",
Status: strings.TrimSpace(c.Query("status")),
LevelTrack: strings.TrimSpace(c.Query("level_track")),
Page: page,
PageSize: pageSize,
})
if err != nil {
response.BadRequest(c, err.Error())
return
}
response.OK(c, map[string]any{"items": items, "total": total, "page": page, "page_size": pageSize})
}
func (h *Handler) CreatePool(c *gin.Context) {
var req poolRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "靓号池参数不正确")
return
}
// 创建池时操作者从后台登录态读取,不能信任请求体里的管理员 ID。
pool, err := h.user.CreatePrettyDisplayIDPool(c.Request.Context(), userclient.PrettyDisplayIDPoolRequest{
RequestID: middleware.CurrentRequestID(c),
Caller: "admin-server",
Name: req.Name,
LevelTrack: req.LevelTrack,
MinLevel: req.MinLevel,
MaxLevel: req.MaxLevel,
RuleType: req.RuleType,
RuleConfigJSON: req.RuleConfigJSON,
Status: req.Status,
SortOrder: req.SortOrder,
OperatorAdminID: int64(middleware.CurrentUserID(c)),
})
if err != nil {
response.BadRequest(c, err.Error())
return
}
shared.OperationLogWithResourceID(c, h.audit, "create-pretty-id-pool", "pretty_display_id_pools", pool.PoolID, "success", "name="+pool.Name)
response.Created(c, pool)
}
func (h *Handler) UpdatePool(c *gin.Context) {
var req poolRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "靓号池参数不正确")
return
}
// pool_id 来自路径body 只承载可编辑字段,避免前端误传 ID 导致更新目标不一致。
poolID := strings.TrimSpace(c.Param("pool_id"))
pool, err := h.user.UpdatePrettyDisplayIDPool(c.Request.Context(), userclient.PrettyDisplayIDPoolRequest{
RequestID: middleware.CurrentRequestID(c),
Caller: "admin-server",
PoolID: poolID,
Name: req.Name,
LevelTrack: req.LevelTrack,
MinLevel: req.MinLevel,
MaxLevel: req.MaxLevel,
RuleType: req.RuleType,
RuleConfigJSON: req.RuleConfigJSON,
Status: req.Status,
SortOrder: req.SortOrder,
OperatorAdminID: int64(middleware.CurrentUserID(c)),
})
if err != nil {
response.BadRequest(c, err.Error())
return
}
shared.OperationLogWithResourceID(c, h.audit, "update-pretty-id-pool", "pretty_display_id_pools", pool.PoolID, "success", "status="+pool.Status)
response.OK(c, pool)
}
func (h *Handler) Generate(c *gin.Context) {
var req generateRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "生成参数不正确")
return
}
// 生成只从路径读取 pool_id请求体里的规则和数量会继续交给 user-service 校验并写批次审计。
poolID := strings.TrimSpace(c.Param("pool_id"))
batch, err := h.user.GeneratePrettyDisplayIDs(c.Request.Context(), userclient.GeneratePrettyDisplayIDsRequest{
RequestID: middleware.CurrentRequestID(c),
Caller: "admin-server",
PoolID: poolID,
RuleType: req.RuleType,
RuleConfigJSON: req.RuleConfigJSON,
Count: req.Count,
OperatorAdminID: int64(middleware.CurrentUserID(c)),
})
if err != nil {
response.BadRequest(c, err.Error())
return
}
shared.OperationLogWithResourceID(c, h.audit, "generate-pretty-ids", "pretty_display_id_generation_batches", batch.BatchID, "success", "pool_id="+poolID)
response.Created(c, batch)
}
func (h *Handler) ListIDs(c *gin.Context) {
page, pageSize, ok := pageQuery(c)
if !ok {
response.BadRequest(c, "分页参数不正确")
return
}
assignedUserID, ok := optionalInt64Query(c, "assigned_user_id")
if !ok {
response.BadRequest(c, "assigned_user_id 参数不正确")
return
}
// 列表筛选保持轻量透传空字符串表示不过滤assigned_user_id 只在正整数时作为精确条件。
items, total, err := h.user.ListPrettyDisplayIDs(c.Request.Context(), userclient.ListPrettyDisplayIDsRequest{
RequestID: middleware.CurrentRequestID(c),
Caller: "admin-server",
PoolID: strings.TrimSpace(c.Query("pool_id")),
Source: strings.TrimSpace(c.Query("source")),
Status: strings.TrimSpace(c.Query("status")),
Keyword: strings.TrimSpace(c.Query("keyword")),
AssignedUserID: assignedUserID,
Page: page,
PageSize: pageSize,
})
if err != nil {
response.BadRequest(c, err.Error())
return
}
response.OK(c, map[string]any{"items": items, "total": total, "page": page, "page_size": pageSize})
}
func (h *Handler) Grant(c *gin.Context) {
var req grantRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "发放参数不正确")
return
}
targetUserID, ok := parseFlexibleInt64(req.TargetUserID)
if !ok {
response.BadRequest(c, "target_user_id 参数不正确")
return
}
// 后台发放允许 target_user_id 由 JS 以数字或字符串提交,这里转成 int64 后把业务校验交给 user-service。
result, err := h.user.AdminGrantPrettyDisplayID(c.Request.Context(), userclient.AdminGrantPrettyDisplayIDRequest{
RequestID: middleware.CurrentRequestID(c),
Caller: "admin-server",
TargetUserID: targetUserID,
DisplayUserID: req.DisplayUserID,
DurationMs: req.DurationMs,
Reason: req.Reason,
OperatorAdminID: int64(middleware.CurrentUserID(c)),
})
if err != nil {
response.BadRequest(c, err.Error())
return
}
shared.OperationLogWithResourceID(c, h.audit, "grant-pretty-id", "pretty_display_ids", result.PrettyID, "success", "target_user_id="+strconv.FormatInt(targetUserID, 10))
response.Created(c, result)
}
func (h *Handler) SetStatus(c *gin.Context) {
var req statusRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "状态参数不正确")
return
}
// 状态修改以路径 pretty_id 为准,请求体只允许目标状态和原因,防止改错号码。
prettyID := strings.TrimSpace(c.Param("pretty_id"))
item, err := h.user.SetPrettyDisplayIDStatus(c.Request.Context(), userclient.SetPrettyDisplayIDStatusRequest{
RequestID: middleware.CurrentRequestID(c),
Caller: "admin-server",
PrettyID: prettyID,
Status: req.Status,
Reason: req.Reason,
OperatorAdminID: int64(middleware.CurrentUserID(c)),
})
if err != nil {
response.BadRequest(c, err.Error())
return
}
shared.OperationLogWithResourceID(c, h.audit, "set-pretty-id-status", "pretty_display_ids", prettyID, "success", "status="+item.Status)
response.OK(c, item)
}
func pageQuery(c *gin.Context) (int32, int32, bool) {
// 分页参数在 admin-server 先收敛到 1..100,避免后台页面传错值造成 user-service 大分页查询。
page := int32(1)
pageSize := int32(20)
if raw := strings.TrimSpace(c.Query("page")); raw != "" {
value, err := strconv.ParseInt(raw, 10, 32)
if err != nil || value <= 0 {
return 0, 0, false
}
page = int32(value)
}
if raw := strings.TrimSpace(c.Query("page_size")); raw != "" {
value, err := strconv.ParseInt(raw, 10, 32)
if err != nil || value <= 0 {
return 0, 0, false
}
pageSize = int32(value)
}
if pageSize > 100 {
pageSize = 100
}
return page, pageSize, true
}
func optionalInt64Query(c *gin.Context, name string) (int64, bool) {
// 空查询表示不筛选,非空必须是正整数,避免把 0 当成真实用户 ID 传给列表接口。
raw := strings.TrimSpace(c.Query(name))
if raw == "" {
return 0, true
}
value, err := strconv.ParseInt(raw, 10, 64)
return value, err == nil && value > 0
}
func parseFlexibleInt64(raw any) (int64, bool) {
// JSON 大整数在前端经常以字符串提交,数字提交时也必须是精确整数,不能接受小数或负数。
switch value := raw.(type) {
case float64:
if value <= 0 || value != float64(int64(value)) {
return 0, false
}
return int64(value), true
case string:
parsed, err := strconv.ParseInt(strings.TrimSpace(value), 10, 64)
return parsed, err == nil && parsed > 0
default:
return 0, false
}
}

View File

@ -0,0 +1,21 @@
package prettyid
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/users/pretty-id-pools", middleware.RequirePermission("pretty-id:view"), h.ListPools)
protected.POST("/admin/users/pretty-id-pools", middleware.RequirePermission("pretty-id:update"), h.CreatePool)
protected.PUT("/admin/users/pretty-id-pools/:pool_id", middleware.RequirePermission("pretty-id:update"), h.UpdatePool)
protected.POST("/admin/users/pretty-id-pools/:pool_id/generate", middleware.RequirePermission("pretty-id:generate"), h.Generate)
protected.GET("/admin/users/pretty-ids", middleware.RequirePermission("pretty-id:view"), h.ListIDs)
protected.POST("/admin/users/pretty-ids/grant", middleware.RequirePermission("pretty-id:grant"), h.Grant)
protected.POST("/admin/users/pretty-ids/:pretty_id/status", middleware.RequirePermission("pretty-id:update"), h.SetStatus)
}

View File

@ -95,6 +95,10 @@ func (h *Handler) CreateResource(c *gin.Context) {
response.BadRequest(c, err.Error())
return
}
if err := validateResourceMetadata(req); err != nil {
response.BadRequest(c, err.Error())
return
}
resp, err := h.wallet.CreateResource(c.Request.Context(), req.createProto(c))
if err != nil {
response.BadRequest(c, err.Error())
@ -203,6 +207,10 @@ func (h *Handler) UpdateResource(c *gin.Context) {
response.BadRequest(c, err.Error())
return
}
if err := validateResourceMetadata(req); err != nil {
response.BadRequest(c, err.Error())
return
}
protoReq := req.updateProto(c, resourceID)
resp, err := h.wallet.UpdateResource(c.Request.Context(), protoReq)
if err != nil {
@ -214,6 +222,71 @@ func (h *Handler) UpdateResource(c *gin.Context) {
response.OK(c, resource)
}
func (h *Handler) UpdateMP4ResourceLayouts(c *gin.Context) {
var req resourceMP4LayoutBatchRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "MP4 布局参数不正确")
return
}
items, err := normalizeMP4LayoutBatchItems(req.Items)
if err != nil {
response.BadRequest(c, err.Error())
return
}
ctx, cancel := h.walletRequestContext(c)
defer cancel()
type preparedMP4LayoutUpdate struct {
resource *walletv1.Resource
metadataJSON string
}
prepared := make([]preparedMP4LayoutUpdate, 0, len(items))
for _, item := range items {
resp, err := h.wallet.GetResource(ctx, &walletv1.GetResourceRequest{
RequestId: middleware.CurrentRequestID(c),
AppCode: appctx.FromContext(c.Request.Context()),
ResourceId: item.ResourceID,
})
if err != nil {
response.BadRequest(c, err.Error())
return
}
current := resp.GetResource()
if current.GetResourceId() == 0 {
response.BadRequest(c, "资源不存在")
return
}
// 批量接口只更新真实 MP4 动效资源;非 MP4 行按产品要求自动跳过,
// 这样前端全选资源后提交到后端也不会把图片、SVGA、PAG 的 metadata 写坏。
if !resourceAnimationSourceIsMP4(current) {
continue
}
metadataJSON, err := mp4LayoutMetadataForResource(current, item.MetadataJSON)
if err != nil {
response.BadRequest(c, err.Error())
return
}
prepared = append(prepared, preparedMP4LayoutUpdate{resource: current, metadataJSON: metadataJSON})
}
updated := make([]resourceDTO, 0, len(prepared))
updatedIDs := make([]string, 0, len(prepared))
for _, item := range prepared {
// wallet-service 只有全量资源更新 RPC所以这里用当前资源还原所有业务字段
// 只替换 metadata_json避免批量重算 MP4 时顺手覆盖价格、状态、资产或经理赠送配置。
resp, err := h.wallet.UpdateResource(ctx, updateResourceMP4MetadataProto(c, item.resource, item.metadataJSON))
if err != nil {
response.BadRequest(c, err.Error())
return
}
resource := resourceFromProto(resp.GetResource())
updated = append(updated, resource)
updatedIDs = append(updatedIDs, fmt.Sprintf("%d", resource.ResourceID))
}
h.auditLog(c, "update-resource-mp4-layouts", "resources", "batch", "success", strings.Join(updatedIDs, ","))
response.OK(c, updated)
}
func (h *Handler) EnableResource(c *gin.Context) {
h.setResourceStatus(c, "active")
}
@ -439,6 +512,27 @@ func (h *Handler) UpdateGift(c *gin.Context) {
response.OK(c, gift)
}
func (h *Handler) DeleteGift(c *gin.Context) {
giftID := strings.TrimSpace(c.Param("gift_id"))
if giftID == "" {
response.BadRequest(c, "ID 参数不正确")
return
}
resp, err := h.wallet.DeleteGiftConfig(c.Request.Context(), &walletv1.DeleteGiftConfigRequest{
RequestId: middleware.CurrentRequestID(c),
AppCode: appctx.FromContext(c.Request.Context()),
GiftId: giftID,
OperatorUserId: actorID(c),
})
if err != nil {
response.BadRequest(c, err.Error())
return
}
gift := giftFromProto(resp.GetGift())
h.auditLog(c, "delete-gift", "gift_configs", gift.GiftID, "success", gift.Name)
response.OK(c, gift)
}
func (h *Handler) EnableGift(c *gin.Context) {
h.setGiftStatus(c, "active")
}
@ -724,6 +818,119 @@ func actorID(c *gin.Context) int64 {
return int64(shared.ActorFromContext(c).UserID)
}
func normalizeMP4LayoutBatchItems(items []resourceMP4LayoutUpdateRequest) ([]resourceMP4LayoutUpdateRequest, error) {
const maxMP4LayoutBatchSize = 50
if len(items) == 0 {
return nil, fmt.Errorf("请选择要更新的 MP4 资源")
}
if len(items) > maxMP4LayoutBatchSize {
return nil, fmt.Errorf("单次最多更新 %d 个 MP4 资源", maxMP4LayoutBatchSize)
}
seen := map[int64]struct{}{}
normalized := make([]resourceMP4LayoutUpdateRequest, 0, len(items))
for _, item := range items {
if item.ResourceID <= 0 {
return nil, fmt.Errorf("资源 ID 不正确")
}
if _, ok := seen[item.ResourceID]; ok {
continue
}
seen[item.ResourceID] = struct{}{}
normalized = append(normalized, resourceMP4LayoutUpdateRequest{
ResourceID: item.ResourceID,
MetadataJSON: strings.TrimSpace(item.MetadataJSON),
})
}
if len(normalized) == 0 {
return nil, fmt.Errorf("请选择要更新的 MP4 资源")
}
return normalized, nil
}
func resourceAnimationSourceIsMP4(item *walletv1.Resource) bool {
if item == nil {
return false
}
return resourceSourceIsMP4(item.GetAnimationUrl()) || resourceSourceIsMP4(item.GetAssetUrl())
}
func resourceSourceIsMP4(value string) bool {
source := strings.TrimSpace(value)
if source == "" {
return false
}
if before, _, ok := strings.Cut(source, "?"); ok {
source = before
}
if before, _, ok := strings.Cut(source, "#"); ok {
source = before
}
return strings.HasSuffix(strings.ToLower(source), ".mp4")
}
func mp4LayoutMetadataForResource(current *walletv1.Resource, metadataJSON string) (string, error) {
incoming, fields, err := parseResourceMetadataPayload(metadataJSON)
if err != nil {
return "", fmt.Errorf("资源元数据格式不正确")
}
if incoming == nil {
return "", fmt.Errorf("MP4 透明布局参数不完整")
}
if _, ok := fields["mp4_alpha_layout"]; !ok {
return "", fmt.Errorf("MP4 透明布局参数不完整")
}
if err := validateResourceMetadata(resourceRequest{
ResourceType: current.GetResourceType(),
MetadataJSON: metadataJSON,
}); err != nil {
return "", err
}
layout, err := sanitizeMP4AlphaLayoutMetadata(incoming.MP4AlphaLayout)
if err != nil {
return "", err
}
// 这个接口是“只更新 MP4 布局”的窄接口,所以其它 metadata 不信任前端请求;
// 资料卡布局从当前资源保留MP4 布局使用本次重算结果,最终再走统一 marshal 过滤空字段。
merged := resourceMetadataPayload{
AnimationFormat: resourceAnimationFormatMP4,
MP4AlphaLayout: layout,
}
if current.GetResourceType() == resourceTypeProfileCard {
currentPayload, _, _ := parseResourceMetadataPayload(current.GetMetadataJson())
if currentPayload != nil {
merged.ProfileCardLayout = sanitizeProfileCardLayoutMetadata(currentPayload.ProfileCardLayout)
}
}
return marshalResourceMetadataPayload(merged), nil
}
func updateResourceMP4MetadataProto(c *gin.Context, current *walletv1.Resource, metadataJSON string) *walletv1.UpdateResourceRequest {
managerGrantEnabled := current.GetManagerGrantEnabled()
return &walletv1.UpdateResourceRequest{
RequestId: middleware.CurrentRequestID(c),
AppCode: appctx.FromContext(c.Request.Context()),
ResourceId: current.GetResourceId(),
ResourceCode: current.GetResourceCode(),
ResourceType: current.GetResourceType(),
Name: current.GetName(),
Status: current.GetStatus(),
Grantable: current.GetGrantable(),
GrantStrategy: current.GetGrantStrategy(),
WalletAssetType: current.GetWalletAssetType(),
WalletAssetAmount: current.GetWalletAssetAmount(),
UsageScopes: append([]string(nil), current.GetUsageScopes()...),
AssetUrl: current.GetAssetUrl(),
PreviewUrl: current.GetPreviewUrl(),
AnimationUrl: current.GetAnimationUrl(),
MetadataJson: metadataJSON,
SortOrder: current.GetSortOrder(),
OperatorUserId: actorID(c),
ManagerGrantEnabled: &managerGrantEnabled,
PriceType: current.GetPriceType(),
CoinPrice: current.GetCoinPrice(),
}
}
func parseID(c *gin.Context, name string) (int64, bool) {
raw := strings.TrimSpace(c.Param(name))
value, err := strconv.ParseInt(raw, 10, 64)

View File

@ -0,0 +1,202 @@
package resource
import (
"context"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"hyapp-admin-server/internal/appctx"
"hyapp-admin-server/internal/integration/walletclient"
"hyapp-admin-server/internal/middleware"
walletv1 "hyapp.local/api/proto/wallet/v1"
"github.com/gin-gonic/gin"
)
func TestUpdateMP4ResourceLayoutsPreservesResourceFields(t *testing.T) {
wallet := &mockResourceWallet{resources: map[int64]*walletv1.Resource{
11: {
AppCode: "lalu",
ResourceId: 11,
ResourceCode: "profile_card_star",
ResourceType: resourceTypeProfileCard,
Name: "星光资料卡",
Status: "active",
Grantable: true,
GrantStrategy: "extend_expiry",
WalletAssetType: "RESOURCE",
WalletAssetAmount: 1,
UsageScopes: []string{resourceTypeProfileCard},
AssetUrl: "https://cdn.example.test/profile_card.png",
PreviewUrl: "https://cdn.example.test/profile_card_cover.png",
AnimationUrl: "https://cdn.example.test/profile_card.mp4?token=1",
MetadataJson: `{"profile_card_layout":{"source_width":1136,"source_height":1680,"color_content_width":750,"content_top":117,"content_bottom":1666,"content_height":1550,"detect_version":1}}`,
SortOrder: 9,
ManagerGrantEnabled: true,
PriceType: resourcePriceTypeCoin,
CoinPrice: 100,
},
}}
router := newResourceHandlerTestRouter(New(wallet, nil, nil, time.Second, nil))
metadataJSON := `{
"animation_format":"mp4",
"mp4_alpha_layout":{
"alpha_layout":"alpha_left_rgb_right",
"video_w":1500,
"video_h":1334,
"rgb_frame":[750,0,750,1334],
"alpha_frame":[0,0,750,1334],
"confirmed":true,
"detect_version":1
},
"debug":true
}`
body := fmt.Sprintf(`{"items":[{"resourceId":11,"metadataJson":%q}]}`, metadataJSON)
recorder := httptest.NewRecorder()
request := httptest.NewRequest(http.MethodPut, "/admin/resources/mp4-layouts/batch", strings.NewReader(body))
request.Header.Set("Content-Type", "application/json")
router.ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK {
t.Fatalf("batch mp4 layout update status mismatch: %d %s", recorder.Code, recorder.Body.String())
}
if len(wallet.updates) != 1 {
t.Fatalf("expected one wallet update, got %d", len(wallet.updates))
}
update := wallet.updates[0]
if update.GetResourceId() != 11 || update.GetName() != "星光资料卡" || update.GetStatus() != "active" || update.GetCoinPrice() != 100 {
t.Fatalf("resource fields should be preserved: %+v", update)
}
if update.ManagerGrantEnabled == nil || !update.GetManagerGrantEnabled() || update.GetOperatorUserId() != 7 {
t.Fatalf("operator and manager grant should be preserved: %+v", update)
}
if !strings.Contains(update.GetMetadataJson(), `"profile_card_layout"`) || !strings.Contains(update.GetMetadataJson(), `"mp4_alpha_layout"`) {
t.Fatalf("profile card and mp4 metadata should both be kept: %s", update.GetMetadataJson())
}
if strings.Contains(update.GetMetadataJson(), "debug") {
t.Fatalf("debug metadata should be dropped: %s", update.GetMetadataJson())
}
}
func TestUpdateMP4ResourceLayoutsIgnoresNonMP4Resource(t *testing.T) {
wallet := &mockResourceWallet{resources: map[int64]*walletv1.Resource{
12: {
AppCode: "lalu",
ResourceId: 12,
ResourceCode: "gift_rose",
ResourceType: "gift",
Name: "玫瑰",
Status: "active",
AnimationUrl: "https://cdn.example.test/gift.svga",
MetadataJson: "{}",
GrantStrategy: "increase_quantity",
PriceType: resourcePriceTypeCoin,
CoinPrice: 10,
},
}}
router := newResourceHandlerTestRouter(New(wallet, nil, nil, time.Second, nil))
body := fmt.Sprintf(`{"items":[{"resourceId":12,"metadataJson":%q}]}`, mp4AlphaLayoutMetadataJSON("alpha_left_rgb_right", true))
recorder := httptest.NewRecorder()
request := httptest.NewRequest(http.MethodPut, "/admin/resources/mp4-layouts/batch", strings.NewReader(body))
request.Header.Set("Content-Type", "application/json")
router.ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK {
t.Fatalf("non-mp4 resource should be ignored, got %d %s", recorder.Code, recorder.Body.String())
}
if len(wallet.updates) != 0 {
t.Fatalf("non-mp4 resource should not update wallet")
}
}
func TestDeleteGiftOnlyCallsGiftConfigDelete(t *testing.T) {
wallet := &mockResourceWallet{}
router := newResourceHandlerTestRouter(New(wallet, nil, nil, time.Second, nil))
recorder := httptest.NewRecorder()
request := httptest.NewRequest(http.MethodDelete, "/admin/gifts/rose", nil)
router.ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK {
t.Fatalf("delete gift status mismatch: %d %s", recorder.Code, recorder.Body.String())
}
if len(wallet.deletedGifts) != 1 {
t.Fatalf("expected one gift delete request, got %d", len(wallet.deletedGifts))
}
deleteReq := wallet.deletedGifts[0]
if deleteReq.GetAppCode() != "lalu" || deleteReq.GetGiftId() != "rose" || deleteReq.GetOperatorUserId() != 7 {
t.Fatalf("delete gift request mismatch: %+v", deleteReq)
}
}
func newResourceHandlerTestRouter(handler *Handler) *gin.Engine {
gin.SetMode(gin.TestMode)
router := gin.New()
router.Use(func(c *gin.Context) {
c.Request = c.Request.WithContext(appctx.WithContext(c.Request.Context(), "lalu"))
c.Set(middleware.ContextRequestID, "resource-handler-test")
c.Set(middleware.ContextUserID, uint(7))
c.Set(middleware.ContextUsername, "tester")
c.Next()
})
router.PUT("/admin/resources/mp4-layouts/batch", handler.UpdateMP4ResourceLayouts)
router.DELETE("/admin/gifts/:gift_id", handler.DeleteGift)
return router
}
type mockResourceWallet struct {
walletclient.Client
resources map[int64]*walletv1.Resource
updates []*walletv1.UpdateResourceRequest
deletedGifts []*walletv1.DeleteGiftConfigRequest
}
func (m *mockResourceWallet) GetResource(ctx context.Context, req *walletv1.GetResourceRequest) (*walletv1.GetResourceResponse, error) {
resource := m.resources[req.GetResourceId()]
if resource == nil {
return nil, fmt.Errorf("resource not found")
}
return &walletv1.GetResourceResponse{Resource: resource}, nil
}
func (m *mockResourceWallet) UpdateResource(ctx context.Context, req *walletv1.UpdateResourceRequest) (*walletv1.ResourceResponse, error) {
m.updates = append(m.updates, req)
return &walletv1.ResourceResponse{Resource: &walletv1.Resource{
AppCode: req.GetAppCode(),
ResourceId: req.GetResourceId(),
ResourceCode: req.GetResourceCode(),
ResourceType: req.GetResourceType(),
Name: req.GetName(),
Status: req.GetStatus(),
Grantable: req.GetGrantable(),
GrantStrategy: req.GetGrantStrategy(),
WalletAssetType: req.GetWalletAssetType(),
WalletAssetAmount: req.GetWalletAssetAmount(),
UsageScopes: req.GetUsageScopes(),
AssetUrl: req.GetAssetUrl(),
PreviewUrl: req.GetPreviewUrl(),
AnimationUrl: req.GetAnimationUrl(),
MetadataJson: req.GetMetadataJson(),
SortOrder: req.GetSortOrder(),
ManagerGrantEnabled: req.GetManagerGrantEnabled(),
PriceType: req.GetPriceType(),
CoinPrice: req.GetCoinPrice(),
}}, nil
}
func (m *mockResourceWallet) DeleteGiftConfig(ctx context.Context, req *walletv1.DeleteGiftConfigRequest) (*walletv1.GiftConfigResponse, error) {
m.deletedGifts = append(m.deletedGifts, req)
return &walletv1.GiftConfigResponse{Gift: &walletv1.GiftConfig{
AppCode: req.GetAppCode(),
GiftId: req.GetGiftId(),
ResourceId: 11,
Status: "active",
Name: "Rose",
}}, nil
}

View File

@ -2,6 +2,7 @@ package resource
import (
"encoding/json"
"strconv"
"strings"
"testing"
)
@ -150,6 +151,131 @@ func TestProfileCardResourceMetadataKeepsLayoutOnly(t *testing.T) {
}
}
func TestResourceMetadataKeepsMP4AlphaLayoutForAnimatedResources(t *testing.T) {
for _, resourceType := range []string{"gift", "vehicle", "avatar_frame", "chat_bubble", "floating_screen", "mic_seat_animation"} {
metadata := resourceMetadataJSON(resourceType, "", mp4AlphaLayoutMetadataJSON("alpha_left_rgb_right", true), "", "")
payload := resourceMetadataPayload{}
if err := json.Unmarshal([]byte(metadata), &payload); err != nil {
t.Fatalf("%s mp4 metadata json mismatch: %v", resourceType, err)
}
if payload.AnimationFormat != resourceAnimationFormatMP4 || payload.MP4AlphaLayout == nil {
t.Fatalf("%s mp4 metadata should be kept: %s", resourceType, metadata)
}
if payload.MP4AlphaLayout.AlphaLayout != mp4AlphaLayoutAlphaLeftRGBRight || payload.MP4AlphaLayout.RGBFrame[0] != 750 {
t.Fatalf("%s mp4 layout mismatch: %+v", resourceType, payload.MP4AlphaLayout)
}
}
}
func TestResourceMetadataKeepsNormalMP4Layout(t *testing.T) {
metadata := resourceMetadataJSON("gift", "", `{
"animation_format": "mp4",
"mp4_alpha_layout": {
"alpha_layout": "normal",
"video_w": 750,
"video_h": 1334,
"rgb_frame": [1, 2, 3, 4],
"alpha_frame": null,
"confirmed": true,
"detect_version": 1
}
}`, "", "")
payload := resourceMetadataPayload{}
if err := json.Unmarshal([]byte(metadata), &payload); err != nil {
t.Fatalf("normal mp4 metadata json mismatch: %v", err)
}
if payload.MP4AlphaLayout == nil || payload.MP4AlphaLayout.AlphaLayout != mp4AlphaLayoutNormal {
t.Fatalf("normal mp4 metadata should be kept: %s", metadata)
}
if payload.MP4AlphaLayout.AlphaFrame != nil || payload.MP4AlphaLayout.RGBFrame[2] != 750 || payload.MP4AlphaLayout.RGBFrame[3] != 1334 {
t.Fatalf("normal mp4 layout should be normalized to full frame: %+v", payload.MP4AlphaLayout)
}
}
func TestProfileCardResourceMetadataKeepsLayoutAndMP4AlphaLayout(t *testing.T) {
metadata := resourceMetadataJSON(resourceTypeProfileCard, "", `{
"profile_card_layout": {
"source_width": 1136,
"source_height": 1680,
"color_content_width": 750,
"content_top": 117,
"content_bottom": 1666,
"content_height": 1550,
"content_top_ratio": 0,
"content_height_ratio": 0,
"detect_version": 99
},
"mp4_alpha_layout": {
"alpha_layout": "rgb_left_alpha_right",
"video_w": 1472,
"video_h": 1328,
"rgb_frame": [0, 0, 736, 1328],
"alpha_frame": [736, 0, 736, 1328],
"confirmed": true,
"detect_version": 1
}
}`, "", "")
payload := resourceMetadataPayload{}
if err := json.Unmarshal([]byte(metadata), &payload); err != nil {
t.Fatalf("profile card mp4 metadata json mismatch: %v", err)
}
if payload.ProfileCardLayout == nil || payload.ProfileCardLayout.ContentTop != 117 {
t.Fatalf("profile card layout should be kept: %+v", payload.ProfileCardLayout)
}
if payload.MP4AlphaLayout == nil || payload.MP4AlphaLayout.AlphaLayout != mp4AlphaLayoutRGBLeftAlphaRight {
t.Fatalf("profile card mp4 layout should be kept: %+v", payload.MP4AlphaLayout)
}
}
func TestValidateResourceMetadataRejectsInvalidMP4AlphaLayout(t *testing.T) {
cases := []struct {
name string
req resourceRequest
}{
{
name: "unconfirmed",
req: resourceRequest{
ResourceType: "gift",
MetadataJSON: mp4AlphaLayoutMetadataJSON("alpha_left_rgb_right", false),
},
},
{
name: "bad rect",
req: resourceRequest{
ResourceType: "gift",
MetadataJSON: `{
"mp4_alpha_layout": {
"alpha_layout": "alpha_left_rgb_right",
"video_w": 1500,
"video_h": 1334,
"rgb_frame": [750, 0, 800, 1334],
"alpha_frame": [0, 0, 750, 1334],
"confirmed": true,
"detect_version": 1
}
}`,
},
},
{
name: "unsupported type",
req: resourceRequest{
ResourceType: resourceTypeBadge,
MetadataJSON: mp4AlphaLayoutMetadataJSON("alpha_left_rgb_right", true),
},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if err := validateResourceMetadata(tc.req); err == nil {
t.Fatalf("invalid mp4 metadata should be rejected")
}
})
}
if err := validateResourceMetadata(resourceRequest{ResourceType: "gift", MetadataJSON: mp4AlphaLayoutMetadataJSON("alpha_left_rgb_right", true)}); err != nil {
t.Fatalf("valid mp4 metadata rejected: %v", err)
}
}
func TestProfileCardResourceMetadataRejectsInvalidLayout(t *testing.T) {
metadata := resourceMetadataJSON(resourceTypeProfileCard, "", `{
"profile_card_layout": {
@ -182,3 +308,19 @@ func TestValidateBadgeResourceRequiresBadgeForm(t *testing.T) {
t.Fatalf("non-badge resource should not require badge form: %v", err)
}
}
func mp4AlphaLayoutMetadataJSON(alphaLayout string, confirmed bool) string {
return `{
"animation_format": "mp4",
"mp4_alpha_layout": {
"alpha_layout": "` + alphaLayout + `",
"video_w": 1500,
"video_h": 1334,
"rgb_frame": [750, 0, 750, 1334],
"alpha_frame": [0, 0, 750, 1334],
"confirmed": ` + strconv.FormatBool(confirmed) + `,
"detect_version": 1
},
"debug": true
}`
}

View File

@ -24,6 +24,20 @@ const (
resourceTypeProfileCard = "profile_card"
)
const (
resourceAnimationFormatMP4 = "mp4"
)
const (
mp4AlphaLayoutNormal = "normal"
mp4AlphaLayoutAlphaLeftRGBRight = "alpha_left_rgb_right"
mp4AlphaLayoutRGBLeftAlphaRight = "rgb_left_alpha_right"
mp4AlphaLayoutAlphaTopRGBBottom = "alpha_top_rgb_bottom"
mp4AlphaLayoutRGBTopAlphaBottom = "rgb_top_alpha_bottom"
mp4AlphaLayoutCustom = "custom"
mp4AlphaLayoutDetectVersion int64 = 1
)
const (
resourcePriceTypeCoin = "coin"
resourcePriceTypeFree = "free"
@ -64,6 +78,15 @@ type resourceRequest struct {
SortOrder int32 `json:"sortOrder"`
}
type resourceMP4LayoutBatchRequest struct {
Items []resourceMP4LayoutUpdateRequest `json:"items"`
}
type resourceMP4LayoutUpdateRequest struct {
ResourceID int64 `json:"resourceId"`
MetadataJSON string `json:"metadataJson"`
}
type emojiPackRequest struct {
Name string `json:"name"`
Category string `json:"category"`
@ -88,6 +111,23 @@ type badgeMetadataPayload struct {
DefaultSlot string `json:"default_slot,omitempty"`
}
type resourceMetadataPayload struct {
AnimationFormat string `json:"animation_format,omitempty"`
MP4AlphaLayout *mp4AlphaLayoutMetadataPayload `json:"mp4_alpha_layout,omitempty"`
ProfileCardLayout *profileCardLayoutMetadataPayload `json:"profile_card_layout,omitempty"`
}
type mp4AlphaLayoutMetadataPayload struct {
AlphaLayout string `json:"alpha_layout"`
VideoWidth int64 `json:"video_w"`
VideoHeight int64 `json:"video_h"`
RGBFrame []int64 `json:"rgb_frame"`
AlphaFrame []int64 `json:"alpha_frame"`
Confirmed bool `json:"confirmed"`
Confidence float64 `json:"confidence,omitempty"`
DetectVersion int64 `json:"detect_version"`
}
type profileCardMetadataPayload struct {
ProfileCardLayout *profileCardLayoutMetadataPayload `json:"profile_card_layout,omitempty"`
}
@ -559,6 +599,39 @@ func validateResourceBadgeForm(req resourceRequest) error {
return nil
}
func validateResourceMetadata(req resourceRequest) error {
resourceType := normalizeResourceType(req.ResourceType)
payload, fields, err := parseResourceMetadataPayload(req.MetadataJSON)
if err != nil {
return fmt.Errorf("资源元数据格式不正确")
}
if payload == nil {
return nil
}
if rawFormat, ok := fields["animation_format"]; ok && len(rawFormat) > 0 {
animationFormat := strings.ToLower(strings.TrimSpace(payload.AnimationFormat))
if animationFormat != "" && animationFormat != resourceAnimationFormatMP4 {
return fmt.Errorf("资源动效格式不支持")
}
}
if _, ok := fields["mp4_alpha_layout"]; !ok {
return nil
}
if !resourceAllowsAnimationMetadata(resourceType) {
return fmt.Errorf("当前资源类型不支持 MP4 透明布局")
}
layout, err := sanitizeMP4AlphaLayoutMetadata(payload.MP4AlphaLayout)
if err != nil {
return err
}
// MP4 透明布局是客户端合成协议,后台只能保存人工确认后的显式矩形,
// 否则 Flutter 会重新落回不稳定的自动识别路径,黑场或弱 mask 素材仍会展示错误。
if !layout.Confirmed {
return fmt.Errorf("请确认 MP4 透明布局")
}
return nil
}
func resourceGrantStrategy(resourceType string) string {
switch resourceType {
case resourceTypeCoin:
@ -573,14 +646,25 @@ func resourceGrantStrategy(resourceType string) string {
}
func resourceMetadataJSON(resourceType string, badgeForm string, metadataJSON string, badgeKind string, levelTrack string) string {
switch resourceType {
case resourceTypeBadge:
if resourceType == resourceTypeBadge {
return badgeMetadataJSON(badgeForm, badgeKind, levelTrack)
case resourceTypeProfileCard:
return profileCardMetadataJSON(metadataJSON)
default:
}
rawPayload, _, err := parseResourceMetadataPayload(metadataJSON)
if err != nil || rawPayload == nil {
return "{}"
}
payload := resourceMetadataPayload{}
if resourceType == resourceTypeProfileCard {
payload.ProfileCardLayout = sanitizeProfileCardLayoutMetadata(rawPayload.ProfileCardLayout)
}
if resourceAllowsAnimationMetadata(resourceType) {
if layout, err := sanitizeMP4AlphaLayoutMetadata(rawPayload.MP4AlphaLayout); err == nil && layout != nil && layout.Confirmed {
payload.AnimationFormat = resourceAnimationFormatMP4
payload.MP4AlphaLayout = layout
}
}
return marshalResourceMetadataPayload(payload)
}
func badgeMetadataJSON(badgeForm string, badgeKind string, levelTrack string) string {
@ -611,7 +695,7 @@ func profileCardMetadataJSON(metadataJSON string) string {
if raw == "" {
return "{}"
}
payload := profileCardMetadataPayload{}
payload := resourceMetadataPayload{}
if err := json.Unmarshal([]byte(raw), &payload); err != nil {
return "{}"
}
@ -627,6 +711,128 @@ func profileCardMetadataJSON(metadataJSON string) string {
return string(body)
}
func parseResourceMetadataPayload(metadataJSON string) (*resourceMetadataPayload, map[string]json.RawMessage, error) {
raw := strings.TrimSpace(metadataJSON)
if raw == "" {
return nil, nil, nil
}
fields := map[string]json.RawMessage{}
if err := json.Unmarshal([]byte(raw), &fields); err != nil {
return nil, nil, err
}
payload := resourceMetadataPayload{}
if err := json.Unmarshal([]byte(raw), &payload); err != nil {
return nil, nil, err
}
return &payload, fields, nil
}
func marshalResourceMetadataPayload(payload resourceMetadataPayload) string {
if payload.AnimationFormat == "" && payload.MP4AlphaLayout == nil && payload.ProfileCardLayout == nil {
return "{}"
}
body, err := json.Marshal(payload)
if err != nil {
return "{}"
}
return string(body)
}
func sanitizeMP4AlphaLayoutMetadata(layout *mp4AlphaLayoutMetadataPayload) (*mp4AlphaLayoutMetadataPayload, error) {
if layout == nil {
return nil, fmt.Errorf("MP4 透明布局参数不完整")
}
videoWidth := layout.VideoWidth
videoHeight := layout.VideoHeight
if videoWidth <= 0 || videoHeight <= 0 {
return nil, fmt.Errorf("MP4 视频宽高不正确")
}
alphaLayout := normalizeMP4AlphaLayout(layout.AlphaLayout)
if alphaLayout == "" {
return nil, fmt.Errorf("MP4 透明布局类型不支持")
}
detectVersion := layout.DetectVersion
if detectVersion <= 0 {
detectVersion = mp4AlphaLayoutDetectVersion
}
confidence := layout.Confidence
if confidence < 0 {
confidence = 0
}
if confidence > 1 {
confidence = 1
}
if alphaLayout == mp4AlphaLayoutNormal {
// normal 也是显式布局RGB 使用完整视频帧alpha_frame 固定为 null
// 这样 native 层能明确跳过 split-alpha 猜测,普通 MP4 不再被误判。
return &mp4AlphaLayoutMetadataPayload{
AlphaLayout: mp4AlphaLayoutNormal,
VideoWidth: videoWidth,
VideoHeight: videoHeight,
RGBFrame: []int64{0, 0, videoWidth, videoHeight},
AlphaFrame: nil,
Confirmed: layout.Confirmed,
Confidence: confidence,
DetectVersion: detectVersion,
}, nil
}
if !validMP4Frame(layout.RGBFrame, videoWidth, videoHeight) || !validMP4Frame(layout.AlphaFrame, videoWidth, videoHeight) {
return nil, fmt.Errorf("MP4 透明布局矩形不正确")
}
// 透明 MP4 的 RGB 与 alpha 矩形直接影响客户端 shader 取样,后端只做边界归一化,
// 不把字段重算成左右或上下默认值,避免覆盖前端人工确认过的 custom 细节。
return &mp4AlphaLayoutMetadataPayload{
AlphaLayout: alphaLayout,
VideoWidth: videoWidth,
VideoHeight: videoHeight,
RGBFrame: append([]int64(nil), layout.RGBFrame[:4]...),
AlphaFrame: append([]int64(nil), layout.AlphaFrame[:4]...),
Confirmed: layout.Confirmed,
Confidence: confidence,
DetectVersion: detectVersion,
}, nil
}
func validMP4Frame(frame []int64, videoWidth int64, videoHeight int64) bool {
if len(frame) < 4 {
return false
}
left, top, width, height := frame[0], frame[1], frame[2], frame[3]
if left < 0 || top < 0 || width <= 0 || height <= 0 {
return false
}
return left+width <= videoWidth && top+height <= videoHeight
}
func normalizeMP4AlphaLayout(value string) string {
switch strings.ToLower(strings.TrimSpace(value)) {
case mp4AlphaLayoutNormal:
return mp4AlphaLayoutNormal
case mp4AlphaLayoutAlphaLeftRGBRight:
return mp4AlphaLayoutAlphaLeftRGBRight
case mp4AlphaLayoutRGBLeftAlphaRight:
return mp4AlphaLayoutRGBLeftAlphaRight
case mp4AlphaLayoutAlphaTopRGBBottom:
return mp4AlphaLayoutAlphaTopRGBBottom
case mp4AlphaLayoutRGBTopAlphaBottom:
return mp4AlphaLayoutRGBTopAlphaBottom
case mp4AlphaLayoutCustom:
return mp4AlphaLayoutCustom
default:
return ""
}
}
func resourceAllowsAnimationMetadata(resourceType string) bool {
switch resourceType {
case "gift", "vehicle", "avatar_frame", "chat_bubble", "floating_screen", resourceTypeProfileCard:
return true
default:
return strings.HasPrefix(resourceType, "mic_seat_")
}
}
func sanitizeProfileCardLayoutMetadata(layout *profileCardLayoutMetadataPayload) *profileCardLayoutMetadataPayload {
if layout == nil {
return nil

View File

@ -13,6 +13,7 @@ func RegisterRoutes(protected *gin.RouterGroup, h *Handler) {
protected.GET("/admin/resources", middleware.RequirePermission("resource:view"), h.ListResources)
protected.POST("/admin/resources", middleware.RequirePermission("resource:create"), h.CreateResource)
protected.PUT("/admin/resources/mp4-layouts/batch", middleware.RequirePermission("resource:update"), h.UpdateMP4ResourceLayouts)
protected.GET("/admin/resources/:resource_id", middleware.RequirePermission("resource:view"), h.GetResource)
protected.PUT("/admin/resources/:resource_id", middleware.RequirePermission("resource:update"), h.UpdateResource)
protected.POST("/admin/resources/:resource_id/enable", middleware.RequirePermission("resource:update"), h.EnableResource)
@ -36,6 +37,7 @@ func RegisterRoutes(protected *gin.RouterGroup, h *Handler) {
protected.PUT("/admin/gift-types/:type_code", middleware.RequirePermission("gift:update"), h.UpdateGiftType)
protected.POST("/admin/gifts", middleware.RequirePermission("gift:create"), h.CreateGift)
protected.PUT("/admin/gifts/:gift_id", middleware.RequirePermission("gift:update"), h.UpdateGift)
protected.DELETE("/admin/gifts/:gift_id", middleware.RequirePermission("gift:delete"), h.DeleteGift)
protected.POST("/admin/gifts/:gift_id/enable", middleware.RequirePermission("gift:status"), h.EnableGift)
protected.POST("/admin/gifts/:gift_id/disable", middleware.RequirePermission("gift:status"), h.DisableGift)

View File

@ -63,6 +63,7 @@ type RoomRocketRewardItem struct {
RewardItemID string `json:"rewardItemId"`
ResourceGroupID int64 `json:"resourceGroupId"`
Weight int64 `json:"weight"`
Quantity int64 `json:"quantity"`
DisplayName string `json:"displayName"`
IconURL string `json:"iconUrl"`
}
@ -246,7 +247,7 @@ func normalizeRewardPool(level int32, role string, rewards []RoomRocketRewardIte
reward.RewardItemID = strings.TrimSpace(reward.RewardItemID)
reward.DisplayName = strings.TrimSpace(reward.DisplayName)
reward.IconURL = strings.TrimSpace(reward.IconURL)
if reward.RewardItemID == "" && reward.ResourceGroupID == 0 && reward.Weight == 0 && reward.DisplayName == "" && reward.IconURL == "" {
if reward.RewardItemID == "" && reward.ResourceGroupID == 0 && reward.Weight == 0 && reward.Quantity == 0 && reward.DisplayName == "" && reward.IconURL == "" {
continue
}
if reward.ResourceGroupID <= 0 {
@ -255,6 +256,10 @@ func normalizeRewardPool(level int32, role string, rewards []RoomRocketRewardIte
if reward.Weight <= 0 {
return nil, fmt.Errorf("%w: 第 %d 级奖励权重必须大于 0", ErrInvalidArgument, level)
}
if reward.Quantity <= 0 {
// 历史配置没有 quantity 字段;后台读写都按 1 份兼容,避免旧配置打开页面即失败。
reward.Quantity = 1
}
if reward.RewardItemID == "" {
reward.RewardItemID = fmt.Sprintf("level_%d_%s_%d", level, role, index+1)
}
@ -382,6 +387,7 @@ func rewardsFromClient(input []roomclient.RoomRocketRewardItem) []RoomRocketRewa
RewardItemID: reward.RewardItemID,
ResourceGroupID: reward.ResourceGroupID,
Weight: reward.Weight,
Quantity: reward.Quantity,
DisplayName: reward.DisplayName,
IconURL: reward.IconURL,
})
@ -396,6 +402,7 @@ func rewardsToClient(input []RoomRocketRewardItem) []roomclient.RoomRocketReward
RewardItemID: reward.RewardItemID,
ResourceGroupID: reward.ResourceGroupID,
Weight: reward.Weight,
Quantity: reward.Quantity,
DisplayName: reward.DisplayName,
IconURL: reward.IconURL,
})

View File

@ -42,6 +42,22 @@ func TestNormalizeConfigDefaultsAndSortsLevels(t *testing.T) {
}
}
func TestNormalizeConfigKeepsRewardQuantityAndDefaultsOldItems(t *testing.T) {
config := defaultConfig("lalu")
config.Levels[0].InRoomRewards = []RoomRocketRewardItem{
{RewardItemID: "coin", ResourceGroupID: 1001, Weight: 100, Quantity: 2},
{RewardItemID: "mic-wave", ResourceGroupID: 1002, Weight: 100},
}
normalized, err := normalizeConfig(config)
if err != nil {
t.Fatalf("normalizeConfig failed: %v", err)
}
rewards := normalized.Levels[0].InRoomRewards
if rewards[0].Quantity != 2 || rewards[1].Quantity != 1 {
t.Fatalf("reward quantity must be preserved and defaulted: %+v", rewards)
}
}
func TestDefaultConfigKeepsOperationalDefaults(t *testing.T) {
config, err := normalizeConfig(defaultConfig("lalu"))
if err != nil {

View File

@ -354,7 +354,7 @@ func (s *Service) collectBDCandidates(ctx context.Context, appCode string, trigg
if !ok || agency.ParentBDUserID <= 0 || income <= 0 {
continue
}
// 区域来自 Agency 配置,不从主播记录推导,保证 BD 政策和组织管理后台保持同一口径
// 区域来自 Agency 拥有者 users 当前区域,不从主播记录或 Agency 历史快照推导
if len(regions) > 0 {
if _, ok := regions[agency.RegionID]; !ok {
continue
@ -399,7 +399,7 @@ func (s *Service) collectAdminCandidates(ctx context.Context, appCode string, tr
if !ok {
continue
}
// Admin 的适用区域以 bd_leader profile 为准,避免一个 BD 跨区域时把收入错配到错误政策
// Admin 的适用区域以 BD Leader 用户当前区域为准,不再读取 bd_leader_profiles 的历史 region_id
if len(regions) > 0 {
if _, ok := regions[leader.RegionID]; !ok {
continue
@ -614,7 +614,7 @@ type agencyInfo struct {
RegionID int64
}
// activeAgenciesByOwner 只使用 active agency避免历史或停用 Agency 继续给 BD 产生工资。
// activeAgenciesByOwner 只使用 active agency并从 owner users 行读取当前区域,避免历史快照继续影响 BD 工资。
func (s *Service) activeAgenciesByOwner(ctx context.Context, appCode string, ownerIDs []int64) (map[int64]agencyInfo, error) {
ownerIDs = uniquePositiveUserIDs(ownerIDs)
if len(ownerIDs) == 0 {
@ -627,9 +627,11 @@ func (s *Service) activeAgenciesByOwner(ctx context.Context, appCode string, own
args = append(args, id)
}
rows, err := s.userDB.QueryContext(ctx, `
SELECT owner_user_id, parent_bd_user_id, region_id
FROM agencies
WHERE app_code = ? AND status = 'active' AND owner_user_id IN (`+placeholders+`)`, args...)
SELECT a.owner_user_id, a.parent_bd_user_id, COALESCE(u.region_id, 0)
FROM agencies a
INNER JOIN users u
ON u.app_code = a.app_code AND u.user_id = a.owner_user_id
WHERE a.app_code = ? AND a.status = 'active' AND a.owner_user_id IN (`+placeholders+`)`, args...)
if err != nil {
return nil, err
}
@ -653,7 +655,7 @@ type bdProfile struct {
ParentLeaderUserID int64
}
// bdProfilesByUser 按角色取 active profile普通 BD 和 BD Leader 已拆成两张物理表,调用方必须显式传 role。
// bdProfilesByUser 按角色取 active profileRegionID 始终来自用户当前区域,调用方必须显式传 role。
func (s *Service) bdProfilesByUser(ctx context.Context, appCode string, userIDs []int64, role string) (map[int64]bdProfile, error) {
userIDs = uniquePositiveUserIDs(userIDs)
if len(userIDs) == 0 {
@ -666,14 +668,18 @@ func (s *Service) bdProfilesByUser(ctx context.Context, appCode string, userIDs
args = append(args, id)
}
query := `
SELECT user_id, role, region_id, COALESCE(parent_leader_user_id, 0)
FROM bd_profiles
WHERE app_code = ? AND role = 'bd' AND status = 'active' AND user_id IN (` + placeholders + `)`
SELECT bp.user_id, bp.role, COALESCE(u.region_id, 0), COALESCE(bp.parent_leader_user_id, 0)
FROM bd_profiles bp
INNER JOIN users u
ON u.app_code = bp.app_code AND u.user_id = bp.user_id
WHERE bp.app_code = ? AND bp.role = 'bd' AND bp.status = 'active' AND bp.user_id IN (` + placeholders + `)`
if role == "bd_leader" {
query = `
SELECT user_id, 'bd_leader', region_id, 0
FROM bd_leader_profiles
WHERE app_code = ? AND status = 'active' AND user_id IN (` + placeholders + `)`
SELECT bl.user_id, 'bd_leader', COALESCE(u.region_id, 0), 0
FROM bd_leader_profiles bl
INNER JOIN users u
ON u.app_code = bl.app_code AND u.user_id = bl.user_id
WHERE bl.app_code = ? AND bl.status = 'active' AND bl.user_id IN (` + placeholders + `)`
}
rows, err := s.userDB.QueryContext(ctx, `
`+query, args...)

View File

@ -148,18 +148,18 @@ func seedTeamSalaryFlow(t *testing.T, adminDB *sql.DB, userDB *sql.DB, walletDB
`INSERT INTO regions (app_code, region_id, name) VALUES ('lalu', 686, 'Middle East')`,
`INSERT INTO countries (app_code, country_id, country_code, name) VALUES ('lalu', 971, 'AE', 'United Arab Emirates')`,
`INSERT INTO region_countries (app_code, region_id, country_code, status) VALUES ('lalu', 686, 'AE', 'active')`,
`INSERT INTO users (app_code, user_id, current_display_user_id, username, avatar) VALUES
('lalu', 3001, 'AG3001', 'Agency One', ''),
('lalu', 3002, 'AG3002', 'Agency Two', ''),
('lalu', 4001, 'BD4001', 'BD One', ''),
('lalu', 5001, 'AD5001', 'Admin One', '')`,
`INSERT INTO agencies (app_code, owner_user_id, parent_bd_user_id, region_id, status) VALUES
('lalu', 3001, 4001, 686, 'active'),
('lalu', 3002, 4001, 686, 'active')`,
`INSERT INTO bd_profiles (app_code, user_id, role, region_id, parent_leader_user_id, status) VALUES
('lalu', 4001, 'bd', 686, 5001, 'active')`,
`INSERT INTO bd_leader_profiles (app_code, user_id, region_id, status) VALUES
('lalu', 5001, 686, 'active')`,
`INSERT INTO users (app_code, user_id, current_display_user_id, username, avatar, region_id) VALUES
('lalu', 3001, 'AG3001', 'Agency One', '', 686),
('lalu', 3002, 'AG3002', 'Agency Two', '', 686),
('lalu', 4001, 'BD4001', 'BD One', '', 686),
('lalu', 5001, 'AD5001', 'Admin One', '', 686)`,
`INSERT INTO agencies (app_code, owner_user_id, parent_bd_user_id, status) VALUES
('lalu', 3001, 4001, 'active'),
('lalu', 3002, 4001, 'active')`,
`INSERT INTO bd_profiles (app_code, user_id, role, parent_leader_user_id, status) VALUES
('lalu', 4001, 'bd', 5001, 'active')`,
`INSERT INTO bd_leader_profiles (app_code, user_id, status) VALUES
('lalu', 5001, 'active')`,
)
execAll(t, walletDB, fmt.Sprintf(`INSERT INTO host_salary_settlement_records
(app_code, settlement_id, command_id, transaction_id, settlement_type, user_id, agency_owner_user_id,
@ -217,6 +217,7 @@ func teamSalaryUserDDL() []string {
current_display_user_id VARCHAR(64) NOT NULL DEFAULT '',
username VARCHAR(128) NULL,
avatar VARCHAR(512) NULL,
region_id BIGINT NOT NULL DEFAULT 0,
PRIMARY KEY (app_code, user_id)
)`,
`CREATE TABLE regions (
@ -243,7 +244,6 @@ func teamSalaryUserDDL() []string {
app_code VARCHAR(32) NOT NULL,
owner_user_id BIGINT NOT NULL,
parent_bd_user_id BIGINT NOT NULL DEFAULT 0,
region_id BIGINT NOT NULL,
status VARCHAR(24) NOT NULL,
PRIMARY KEY (app_code, owner_user_id)
)`,
@ -251,7 +251,6 @@ func teamSalaryUserDDL() []string {
app_code VARCHAR(32) NOT NULL,
user_id BIGINT NOT NULL,
role VARCHAR(24) NOT NULL,
region_id BIGINT NOT NULL,
parent_leader_user_id BIGINT NOT NULL DEFAULT 0,
status VARCHAR(24) NOT NULL,
PRIMARY KEY (app_code, user_id, role)
@ -259,7 +258,6 @@ func teamSalaryUserDDL() []string {
`CREATE TABLE bd_leader_profiles (
app_code VARCHAR(32) NOT NULL,
user_id BIGINT NOT NULL,
region_id BIGINT NOT NULL,
status VARCHAR(24) NOT NULL,
PRIMARY KEY (app_code, user_id)
)`,

View File

@ -0,0 +1,76 @@
package repository
import (
"strings"
"hyapp-admin-server/internal/model"
)
type AppPopupListOptions struct {
AppCode string
Keyword string
Status string
}
func (s *Store) ListAppPopups(options AppPopupListOptions) ([]model.AppPopup, error) {
var items []model.AppPopup
// 管理后台列表先用 app_code 做租户隔离,再叠加状态和关键字;时间窗口只改变展示资格,不影响后台可见性。
query := s.db.Where("app_code = ?", strings.TrimSpace(options.AppCode))
if options.Status != "" {
query = query.Where("status = ?", strings.TrimSpace(options.Status))
}
if options.Keyword != "" {
like := "%" + strings.TrimSpace(options.Keyword) + "%"
query = query.Where("(code LIKE ? OR name LIKE ? OR jump_url LIKE ? OR image_url LIKE ?)", like, like, like, like)
}
err := query.Order("sort_order ASC, id DESC").Find(&items).Error
return items, err
}
func (s *Store) HasAppPopupCode(appCode string, code string, excludeID uint) (bool, error) {
var count int64
// code 是客户端频控和埋点使用的稳定标识,同一个 App 下必须唯一;更新时排除当前记录。
query := s.db.Model(&model.AppPopup{}).
Where("app_code = ? AND code = ?", strings.TrimSpace(appCode), strings.TrimSpace(code))
if excludeID > 0 {
query = query.Where("id <> ?", excludeID)
}
if err := query.Count(&count).Error; err != nil {
return false, err
}
return count > 0, nil
}
func (s *Store) ExpireAppPopups(appCode string, nowMs int64) error {
appCode = strings.TrimSpace(appCode)
if appCode == "" || nowMs <= 0 {
return nil
}
// 只把已经结束的 active 弹窗转成 expired手动关闭的配置不自动覆盖避免运营状态被时间任务误改。
return s.db.Model(&model.AppPopup{}).
Where("app_code = ? AND status = ? AND ends_at_ms > 0 AND ends_at_ms <= ?", appCode, "active", nowMs).
Updates(map[string]any{
"status": "expired",
"updated_at_ms": nowMs,
}).Error
}
func (s *Store) GetAppPopup(appCode string, id uint) (model.AppPopup, error) {
var item model.AppPopup
// app_code 和 id 同时限定,防止多 App 后台误编辑其他应用的弹窗。
err := s.db.Where("app_code = ? AND id = ?", strings.TrimSpace(appCode), id).First(&item).Error
return item, err
}
func (s *Store) CreateAppPopup(item *model.AppPopup) error {
return s.db.Create(item).Error
}
func (s *Store) UpdateAppPopup(item *model.AppPopup) error {
return s.db.Save(item).Error
}
func (s *Store) DeleteAppPopup(appCode string, id uint) error {
// 弹窗配置和 Banner/开屏保持一致,后台删除使用硬删除;审计在 handler 层写操作日志。
return s.db.Where("app_code = ? AND id = ?", strings.TrimSpace(appCode), id).Delete(&model.AppPopup{}).Error
}

View File

@ -0,0 +1,75 @@
package repository
import (
"strings"
"hyapp-admin-server/internal/model"
)
type AppSplashScreenListOptions struct {
AppCode string
Keyword string
Status string
Platform string
RegionID int64
Country string
}
func (s *Store) ListAppSplashScreens(options AppSplashScreenListOptions) ([]model.AppSplashScreen, error) {
var items []model.AppSplashScreen
// 后台列表始终先按 app_code 隔离,再叠加可选筛选;区域 0 和国家空值在管理端表示全量配置,不需要额外匹配。
query := s.db.Where("app_code = ?", strings.TrimSpace(options.AppCode))
if options.Status != "" {
query = query.Where("status = ?", strings.TrimSpace(options.Status))
}
if options.Platform != "" {
query = query.Where("platform = ?", strings.TrimSpace(options.Platform))
}
if options.RegionID > 0 {
query = query.Where("region_id = ?", options.RegionID)
}
if options.Country != "" {
query = query.Where("country_code = ?", strings.TrimSpace(options.Country))
}
if options.Keyword != "" {
like := "%" + strings.TrimSpace(options.Keyword) + "%"
// 关键字只查人工可识别的字段,避免把封面 URL 这类长字段带进模糊查询拖慢列表。
query = query.Where("(description LIKE ? OR param LIKE ? OR country_code LIKE ?)", like, like, like)
}
err := query.Order("sort_order ASC, id DESC").Find(&items).Error
return items, err
}
func (s *Store) ExpireAppSplashScreens(appCode string, nowMs int64) error {
appCode = strings.TrimSpace(appCode)
if appCode == "" || nowMs <= 0 {
return nil
}
// 过期回收只处理 active 且已到结束时间的记录,手动关闭和历史 expired 状态保持原样,方便后台审计。
return s.db.Model(&model.AppSplashScreen{}).
Where("app_code = ? AND status = ? AND ends_at_ms > 0 AND ends_at_ms <= ?", appCode, "active", nowMs).
Updates(map[string]any{
"status": "expired",
"updated_at_ms": nowMs,
}).Error
}
func (s *Store) GetAppSplashScreen(appCode string, id uint) (model.AppSplashScreen, error) {
var item model.AppSplashScreen
// app_code 和 id 共同限定记录,避免多 App 后台误编辑其他租户配置。
err := s.db.Where("app_code = ? AND id = ?", strings.TrimSpace(appCode), id).First(&item).Error
return item, err
}
func (s *Store) CreateAppSplashScreen(item *model.AppSplashScreen) error {
return s.db.Create(item).Error
}
func (s *Store) UpdateAppSplashScreen(item *model.AppSplashScreen) error {
return s.db.Save(item).Error
}
func (s *Store) DeleteAppSplashScreen(appCode string, id uint) error {
// 删除沿用硬删除语义,和 Banner 配置保持一致;操作日志在 handler 层记录。
return s.db.Where("app_code = ? AND id = ?", strings.TrimSpace(appCode), id).Delete(&model.AppSplashScreen{}).Error
}

View File

@ -64,6 +64,8 @@ func (s *Store) AutoMigrate() error {
&model.Menu{},
&model.AppConfig{},
&model.AppBanner{},
&model.AppSplashScreen{},
&model.AppPopup{},
&model.AppVersion{},
&model.AppExploreTab{},
&model.HostAgencySalaryPolicy{},

Some files were not shown because too many files have changed in this diff Show More