Compare commits

...

7 Commits
test ... main

Author SHA1 Message Date
zhx
764caa2193 feat(finance): support cross-app withdrawal review 2026-07-17 18:38:10 +08:00
zhx
a10213b4d8 fix(admin): classify legacy coin seller recharges 2026-07-17 18:22:42 +08:00
zhx
dc79adae36 送礼 2026-07-17 17:39:49 +08:00
zhx
17f2788dcf vip和幸运礼物 2026-07-17 16:55:22 +08:00
zhx
de9c1e9eef fix(room): make robot gifts display-only 2026-07-17 16:46:04 +08:00
zhx
e3bf386acf fix: use wallet host income for lucky gifts 2026-07-17 16:16:33 +08:00
zhx
e705d8cc44 fix: preserve internal gRPC error causes 2026-07-17 12:17:47 +08:00
163 changed files with 17168 additions and 2103 deletions

File diff suppressed because it is too large Load Diff

View File

@ -42,6 +42,53 @@ message RoomBackgroundChanged {
int64 actor_user_id = 1; int64 actor_user_id = 1;
int64 background_id = 2; int64 background_id = 2;
string room_background_url = 3; string room_background_url = 3;
RoomMediaSnapshot media = 4;
}
// RoomMediaSnapshot gateway room-service
message RoomMediaSnapshot {
string url = 1;
string object_key = 2;
string content_type = 3;
string format = 4;
int64 size_bytes = 5;
int32 width = 6;
int32 height = 7;
bool animated = 8;
int32 frame_count = 9;
int64 duration_ms = 10;
string poster_url = 11;
string thumbnail_url = 12;
string sha256 = 13;
}
// RoomDecorationChanged
message RoomDecorationChanged {
int64 actor_user_id = 1;
string decoration_type = 2;
int64 resource_id = 3;
string resource_code = 4;
string resource_type = 5;
string name = 6;
string asset_url = 7;
string preview_url = 8;
string animation_url = 9;
string format = 10;
string metadata_json = 11;
repeated string text_colors = 12;
repeated double stops = 13;
double angle_degrees = 14;
string entitlement_id = 15;
int64 expires_at_ms = 16;
}
// RoomImageMessageSent outbox
// message_id/event_id/command_id Flutter event_id message_id at-least-once
message RoomImageMessageSent {
string message_id = 1;
string command_id = 2;
int64 sender_user_id = 3;
RoomMediaSnapshot image = 4;
} }
// RoomUserJoined // RoomUserJoined

File diff suppressed because it is too large Load Diff

View File

@ -40,7 +40,8 @@ message LuckyGiftMeta {
int64 recharge_7d_coins = 19; int64 recharge_7d_coins = 19;
int64 recharge_30d_coins = 20; int64 recharge_30d_coins = 20;
int64 last_recharged_at_ms = 21; int64 last_recharged_at_ms = 21;
// gift_income_coins wallet dynamic_v3 // gift_income_coins wallet
// host_period_diamond_added owner
int64 gift_income_coins = 22; int64 gift_income_coins = 22;
// user_registered_at_ms user-service // user_registered_at_ms user-service
// dynamic_v3 48 fail-close // dynamic_v3 48 fail-close
@ -246,6 +247,29 @@ message ListLuckyGiftConfigsResponse {
repeated LuckyGiftRuleConfig configs = 1; repeated LuckyGiftRuleConfig configs = 1;
} }
// ListLuckyGiftConfigVersions app+pool
message ListLuckyGiftConfigVersionsRequest {
RequestMeta meta = 1;
string pool_id = 2;
}
message ListLuckyGiftConfigVersionsResponse {
repeated LuckyGiftRuleConfig configs = 1;
}
// RollbackLuckyGiftConfig
message RollbackLuckyGiftConfigRequest {
RequestMeta meta = 1;
string pool_id = 2;
int64 target_rule_version = 3;
int64 operator_admin_id = 4;
}
message RollbackLuckyGiftConfigResponse {
int64 source_rule_version = 1;
LuckyGiftRuleConfig config = 2;
}
message ListLuckyGiftDrawsRequest { message ListLuckyGiftDrawsRequest {
RequestMeta meta = 1; RequestMeta meta = 1;
string gift_id = 2; string gift_id = 2;
@ -443,6 +467,8 @@ service AdminLuckyGiftService {
rpc GetLuckyGiftConfig(GetLuckyGiftConfigRequest) returns (GetLuckyGiftConfigResponse); rpc GetLuckyGiftConfig(GetLuckyGiftConfigRequest) returns (GetLuckyGiftConfigResponse);
rpc UpsertLuckyGiftConfig(UpsertLuckyGiftConfigRequest) returns (UpsertLuckyGiftConfigResponse); rpc UpsertLuckyGiftConfig(UpsertLuckyGiftConfigRequest) returns (UpsertLuckyGiftConfigResponse);
rpc ListLuckyGiftConfigs(ListLuckyGiftConfigsRequest) returns (ListLuckyGiftConfigsResponse); rpc ListLuckyGiftConfigs(ListLuckyGiftConfigsRequest) returns (ListLuckyGiftConfigsResponse);
rpc ListLuckyGiftConfigVersions(ListLuckyGiftConfigVersionsRequest) returns (ListLuckyGiftConfigVersionsResponse);
rpc RollbackLuckyGiftConfig(RollbackLuckyGiftConfigRequest) returns (RollbackLuckyGiftConfigResponse);
rpc ListLuckyGiftDraws(ListLuckyGiftDrawsRequest) returns (ListLuckyGiftDrawsResponse); rpc ListLuckyGiftDraws(ListLuckyGiftDrawsRequest) returns (ListLuckyGiftDrawsResponse);
rpc GetLuckyGiftDrawSummary(GetLuckyGiftDrawSummaryRequest) returns (GetLuckyGiftDrawSummaryResponse); rpc GetLuckyGiftDrawSummary(GetLuckyGiftDrawSummaryRequest) returns (GetLuckyGiftDrawSummaryResponse);
rpc ListLuckyGiftPoolBalances(ListLuckyGiftPoolBalancesRequest) returns (ListLuckyGiftPoolBalancesResponse); rpc ListLuckyGiftPoolBalances(ListLuckyGiftPoolBalancesRequest) returns (ListLuckyGiftPoolBalancesResponse);

View File

@ -238,6 +238,8 @@ const (
AdminLuckyGiftService_GetLuckyGiftConfig_FullMethodName = "/hyapp.luckygift.v1.AdminLuckyGiftService/GetLuckyGiftConfig" AdminLuckyGiftService_GetLuckyGiftConfig_FullMethodName = "/hyapp.luckygift.v1.AdminLuckyGiftService/GetLuckyGiftConfig"
AdminLuckyGiftService_UpsertLuckyGiftConfig_FullMethodName = "/hyapp.luckygift.v1.AdminLuckyGiftService/UpsertLuckyGiftConfig" AdminLuckyGiftService_UpsertLuckyGiftConfig_FullMethodName = "/hyapp.luckygift.v1.AdminLuckyGiftService/UpsertLuckyGiftConfig"
AdminLuckyGiftService_ListLuckyGiftConfigs_FullMethodName = "/hyapp.luckygift.v1.AdminLuckyGiftService/ListLuckyGiftConfigs" AdminLuckyGiftService_ListLuckyGiftConfigs_FullMethodName = "/hyapp.luckygift.v1.AdminLuckyGiftService/ListLuckyGiftConfigs"
AdminLuckyGiftService_ListLuckyGiftConfigVersions_FullMethodName = "/hyapp.luckygift.v1.AdminLuckyGiftService/ListLuckyGiftConfigVersions"
AdminLuckyGiftService_RollbackLuckyGiftConfig_FullMethodName = "/hyapp.luckygift.v1.AdminLuckyGiftService/RollbackLuckyGiftConfig"
AdminLuckyGiftService_ListLuckyGiftDraws_FullMethodName = "/hyapp.luckygift.v1.AdminLuckyGiftService/ListLuckyGiftDraws" AdminLuckyGiftService_ListLuckyGiftDraws_FullMethodName = "/hyapp.luckygift.v1.AdminLuckyGiftService/ListLuckyGiftDraws"
AdminLuckyGiftService_GetLuckyGiftDrawSummary_FullMethodName = "/hyapp.luckygift.v1.AdminLuckyGiftService/GetLuckyGiftDrawSummary" AdminLuckyGiftService_GetLuckyGiftDrawSummary_FullMethodName = "/hyapp.luckygift.v1.AdminLuckyGiftService/GetLuckyGiftDrawSummary"
AdminLuckyGiftService_ListLuckyGiftPoolBalances_FullMethodName = "/hyapp.luckygift.v1.AdminLuckyGiftService/ListLuckyGiftPoolBalances" AdminLuckyGiftService_ListLuckyGiftPoolBalances_FullMethodName = "/hyapp.luckygift.v1.AdminLuckyGiftService/ListLuckyGiftPoolBalances"
@ -259,6 +261,8 @@ type AdminLuckyGiftServiceClient interface {
GetLuckyGiftConfig(ctx context.Context, in *GetLuckyGiftConfigRequest, opts ...grpc.CallOption) (*GetLuckyGiftConfigResponse, error) GetLuckyGiftConfig(ctx context.Context, in *GetLuckyGiftConfigRequest, opts ...grpc.CallOption) (*GetLuckyGiftConfigResponse, error)
UpsertLuckyGiftConfig(ctx context.Context, in *UpsertLuckyGiftConfigRequest, opts ...grpc.CallOption) (*UpsertLuckyGiftConfigResponse, error) UpsertLuckyGiftConfig(ctx context.Context, in *UpsertLuckyGiftConfigRequest, opts ...grpc.CallOption) (*UpsertLuckyGiftConfigResponse, error)
ListLuckyGiftConfigs(ctx context.Context, in *ListLuckyGiftConfigsRequest, opts ...grpc.CallOption) (*ListLuckyGiftConfigsResponse, error) ListLuckyGiftConfigs(ctx context.Context, in *ListLuckyGiftConfigsRequest, opts ...grpc.CallOption) (*ListLuckyGiftConfigsResponse, error)
ListLuckyGiftConfigVersions(ctx context.Context, in *ListLuckyGiftConfigVersionsRequest, opts ...grpc.CallOption) (*ListLuckyGiftConfigVersionsResponse, error)
RollbackLuckyGiftConfig(ctx context.Context, in *RollbackLuckyGiftConfigRequest, opts ...grpc.CallOption) (*RollbackLuckyGiftConfigResponse, error)
ListLuckyGiftDraws(ctx context.Context, in *ListLuckyGiftDrawsRequest, opts ...grpc.CallOption) (*ListLuckyGiftDrawsResponse, error) ListLuckyGiftDraws(ctx context.Context, in *ListLuckyGiftDrawsRequest, opts ...grpc.CallOption) (*ListLuckyGiftDrawsResponse, error)
GetLuckyGiftDrawSummary(ctx context.Context, in *GetLuckyGiftDrawSummaryRequest, opts ...grpc.CallOption) (*GetLuckyGiftDrawSummaryResponse, error) GetLuckyGiftDrawSummary(ctx context.Context, in *GetLuckyGiftDrawSummaryRequest, opts ...grpc.CallOption) (*GetLuckyGiftDrawSummaryResponse, error)
ListLuckyGiftPoolBalances(ctx context.Context, in *ListLuckyGiftPoolBalancesRequest, opts ...grpc.CallOption) (*ListLuckyGiftPoolBalancesResponse, error) ListLuckyGiftPoolBalances(ctx context.Context, in *ListLuckyGiftPoolBalancesRequest, opts ...grpc.CallOption) (*ListLuckyGiftPoolBalancesResponse, error)
@ -311,6 +315,26 @@ func (c *adminLuckyGiftServiceClient) ListLuckyGiftConfigs(ctx context.Context,
return out, nil return out, nil
} }
func (c *adminLuckyGiftServiceClient) ListLuckyGiftConfigVersions(ctx context.Context, in *ListLuckyGiftConfigVersionsRequest, opts ...grpc.CallOption) (*ListLuckyGiftConfigVersionsResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(ListLuckyGiftConfigVersionsResponse)
err := c.cc.Invoke(ctx, AdminLuckyGiftService_ListLuckyGiftConfigVersions_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *adminLuckyGiftServiceClient) RollbackLuckyGiftConfig(ctx context.Context, in *RollbackLuckyGiftConfigRequest, opts ...grpc.CallOption) (*RollbackLuckyGiftConfigResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(RollbackLuckyGiftConfigResponse)
err := c.cc.Invoke(ctx, AdminLuckyGiftService_RollbackLuckyGiftConfig_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *adminLuckyGiftServiceClient) ListLuckyGiftDraws(ctx context.Context, in *ListLuckyGiftDrawsRequest, opts ...grpc.CallOption) (*ListLuckyGiftDrawsResponse, error) { func (c *adminLuckyGiftServiceClient) ListLuckyGiftDraws(ctx context.Context, in *ListLuckyGiftDrawsRequest, opts ...grpc.CallOption) (*ListLuckyGiftDrawsResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(ListLuckyGiftDrawsResponse) out := new(ListLuckyGiftDrawsResponse)
@ -438,6 +462,8 @@ type AdminLuckyGiftServiceServer interface {
GetLuckyGiftConfig(context.Context, *GetLuckyGiftConfigRequest) (*GetLuckyGiftConfigResponse, error) GetLuckyGiftConfig(context.Context, *GetLuckyGiftConfigRequest) (*GetLuckyGiftConfigResponse, error)
UpsertLuckyGiftConfig(context.Context, *UpsertLuckyGiftConfigRequest) (*UpsertLuckyGiftConfigResponse, error) UpsertLuckyGiftConfig(context.Context, *UpsertLuckyGiftConfigRequest) (*UpsertLuckyGiftConfigResponse, error)
ListLuckyGiftConfigs(context.Context, *ListLuckyGiftConfigsRequest) (*ListLuckyGiftConfigsResponse, error) ListLuckyGiftConfigs(context.Context, *ListLuckyGiftConfigsRequest) (*ListLuckyGiftConfigsResponse, error)
ListLuckyGiftConfigVersions(context.Context, *ListLuckyGiftConfigVersionsRequest) (*ListLuckyGiftConfigVersionsResponse, error)
RollbackLuckyGiftConfig(context.Context, *RollbackLuckyGiftConfigRequest) (*RollbackLuckyGiftConfigResponse, error)
ListLuckyGiftDraws(context.Context, *ListLuckyGiftDrawsRequest) (*ListLuckyGiftDrawsResponse, error) ListLuckyGiftDraws(context.Context, *ListLuckyGiftDrawsRequest) (*ListLuckyGiftDrawsResponse, error)
GetLuckyGiftDrawSummary(context.Context, *GetLuckyGiftDrawSummaryRequest) (*GetLuckyGiftDrawSummaryResponse, error) GetLuckyGiftDrawSummary(context.Context, *GetLuckyGiftDrawSummaryRequest) (*GetLuckyGiftDrawSummaryResponse, error)
ListLuckyGiftPoolBalances(context.Context, *ListLuckyGiftPoolBalancesRequest) (*ListLuckyGiftPoolBalancesResponse, error) ListLuckyGiftPoolBalances(context.Context, *ListLuckyGiftPoolBalancesRequest) (*ListLuckyGiftPoolBalancesResponse, error)
@ -469,6 +495,12 @@ func (UnimplementedAdminLuckyGiftServiceServer) UpsertLuckyGiftConfig(context.Co
func (UnimplementedAdminLuckyGiftServiceServer) ListLuckyGiftConfigs(context.Context, *ListLuckyGiftConfigsRequest) (*ListLuckyGiftConfigsResponse, error) { func (UnimplementedAdminLuckyGiftServiceServer) ListLuckyGiftConfigs(context.Context, *ListLuckyGiftConfigsRequest) (*ListLuckyGiftConfigsResponse, error) {
return nil, status.Error(codes.Unimplemented, "method ListLuckyGiftConfigs not implemented") return nil, status.Error(codes.Unimplemented, "method ListLuckyGiftConfigs not implemented")
} }
func (UnimplementedAdminLuckyGiftServiceServer) ListLuckyGiftConfigVersions(context.Context, *ListLuckyGiftConfigVersionsRequest) (*ListLuckyGiftConfigVersionsResponse, error) {
return nil, status.Error(codes.Unimplemented, "method ListLuckyGiftConfigVersions not implemented")
}
func (UnimplementedAdminLuckyGiftServiceServer) RollbackLuckyGiftConfig(context.Context, *RollbackLuckyGiftConfigRequest) (*RollbackLuckyGiftConfigResponse, error) {
return nil, status.Error(codes.Unimplemented, "method RollbackLuckyGiftConfig not implemented")
}
func (UnimplementedAdminLuckyGiftServiceServer) ListLuckyGiftDraws(context.Context, *ListLuckyGiftDrawsRequest) (*ListLuckyGiftDrawsResponse, error) { func (UnimplementedAdminLuckyGiftServiceServer) ListLuckyGiftDraws(context.Context, *ListLuckyGiftDrawsRequest) (*ListLuckyGiftDrawsResponse, error) {
return nil, status.Error(codes.Unimplemented, "method ListLuckyGiftDraws not implemented") return nil, status.Error(codes.Unimplemented, "method ListLuckyGiftDraws not implemented")
} }
@ -580,6 +612,42 @@ func _AdminLuckyGiftService_ListLuckyGiftConfigs_Handler(srv interface{}, ctx co
return interceptor(ctx, in, info, handler) return interceptor(ctx, in, info, handler)
} }
func _AdminLuckyGiftService_ListLuckyGiftConfigVersions_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(ListLuckyGiftConfigVersionsRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(AdminLuckyGiftServiceServer).ListLuckyGiftConfigVersions(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: AdminLuckyGiftService_ListLuckyGiftConfigVersions_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(AdminLuckyGiftServiceServer).ListLuckyGiftConfigVersions(ctx, req.(*ListLuckyGiftConfigVersionsRequest))
}
return interceptor(ctx, in, info, handler)
}
func _AdminLuckyGiftService_RollbackLuckyGiftConfig_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(RollbackLuckyGiftConfigRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(AdminLuckyGiftServiceServer).RollbackLuckyGiftConfig(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: AdminLuckyGiftService_RollbackLuckyGiftConfig_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(AdminLuckyGiftServiceServer).RollbackLuckyGiftConfig(ctx, req.(*RollbackLuckyGiftConfigRequest))
}
return interceptor(ctx, in, info, handler)
}
func _AdminLuckyGiftService_ListLuckyGiftDraws_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { func _AdminLuckyGiftService_ListLuckyGiftDraws_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(ListLuckyGiftDrawsRequest) in := new(ListLuckyGiftDrawsRequest)
if err := dec(in); err != nil { if err := dec(in); err != nil {
@ -815,6 +883,14 @@ var AdminLuckyGiftService_ServiceDesc = grpc.ServiceDesc{
MethodName: "ListLuckyGiftConfigs", MethodName: "ListLuckyGiftConfigs",
Handler: _AdminLuckyGiftService_ListLuckyGiftConfigs_Handler, Handler: _AdminLuckyGiftService_ListLuckyGiftConfigs_Handler,
}, },
{
MethodName: "ListLuckyGiftConfigVersions",
Handler: _AdminLuckyGiftService_ListLuckyGiftConfigVersions_Handler,
},
{
MethodName: "RollbackLuckyGiftConfig",
Handler: _AdminLuckyGiftService_RollbackLuckyGiftConfig_Handler,
},
{ {
MethodName: "ListLuckyGiftDraws", MethodName: "ListLuckyGiftDraws",
Handler: _AdminLuckyGiftService_ListLuckyGiftDraws_Handler, Handler: _AdminLuckyGiftService_ListLuckyGiftDraws_Handler,

File diff suppressed because it is too large Load Diff

View File

@ -552,6 +552,30 @@ message RoomSnapshot {
repeated RoomBanState ban_states = 21; repeated RoomBanState ban_states = 21;
// online_count lite online_users 线 // online_count lite online_users 线
int32 online_count = 22; int32 online_count = 22;
// room_border room_name_style VIP
RoomDecorationResource room_border = 23;
RoomDecorationResource room_name_style = 24;
// active_background room_ext.background_url
RoomBackgroundImage active_background = 25;
}
// RoomMedia 使
// URL gateway room-service object_key
message RoomMedia {
string url = 1;
string object_key = 2;
string content_type = 3;
string format = 4;
int64 size_bytes = 5;
int32 width = 6;
int32 height = 7;
bool animated = 8;
int32 frame_count = 9;
int64 duration_ms = 10;
string poster_url = 11;
string thumbnail_url = 12;
string sha256 = 13;
string status = 14;
} }
// RoomBackgroundImage // RoomBackgroundImage
@ -563,12 +587,16 @@ message RoomBackgroundImage {
int64 created_by_user_id = 4; int64 created_by_user_id = 4;
int64 created_at_ms = 5; int64 created_at_ms = 5;
int64 updated_at_ms = 6; int64 updated_at_ms = 6;
RoomMedia media = 7;
string command_id = 8;
} }
message SaveRoomBackgroundRequest { message SaveRoomBackgroundRequest {
RequestMeta meta = 1; RequestMeta meta = 1;
string room_id = 2; string room_id = 2;
// image_url media URL
string image_url = 3; string image_url = 3;
RoomMedia media = 4;
} }
message SaveRoomBackgroundResponse { message SaveRoomBackgroundResponse {
@ -586,6 +614,7 @@ message ListRoomBackgroundsResponse {
repeated RoomBackgroundImage backgrounds = 1; repeated RoomBackgroundImage backgrounds = 1;
string selected_background_url = 2; string selected_background_url = 2;
int64 server_time_ms = 3; int64 server_time_ms = 3;
RoomBackgroundImage selected_background = 4;
} }
message SetRoomBackgroundRequest { message SetRoomBackgroundRequest {
@ -599,6 +628,85 @@ message SetRoomBackgroundResponse {
RoomBackgroundImage background = 3; RoomBackgroundImage background = 3;
} }
// RoomDecorationResource wallet entitlement
// VIP command log Room Cell
message RoomDecorationResource {
int64 resource_id = 1;
string resource_code = 2;
string resource_type = 3;
string name = 4;
string asset_url = 5;
string preview_url = 6;
string animation_url = 7;
string format = 8;
string metadata_json = 9;
string entitlement_id = 10;
repeated string text_colors = 11;
repeated double stops = 12;
double angle_degrees = 13;
int64 expires_at_ms = 14;
}
message ApplyRoomDecorationRequest {
RequestMeta meta = 1;
// decoration_type room_border/colored_room_name room_border/room_name_style
string decoration_type = 2;
int64 resource_id = 3;
string entitlement_id = 4;
}
message ApplyRoomDecorationResponse {
CommandResult result = 1;
RoomSnapshot room = 2;
string decoration_type = 3;
RoomDecorationResource resource = 4;
int64 server_time_ms = 5;
}
message AuthorizeRoomMediaUploadRequest {
RequestMeta meta = 1;
// purpose room_background/room_image COS PutObject
string purpose = 2;
RoomMedia media = 3;
}
message AuthorizeRoomMediaUploadResponse {
bool allowed = 1;
string object_key_prefix = 2;
int64 room_version = 3;
int64 server_time_ms = 4;
// object_key room owner command_id+gateway key
string object_key = 5;
}
message CompleteRoomMediaUploadRequest {
RequestMeta meta = 1;
string purpose = 2;
// media PutObject url/object_key Authorize
RoomMedia media = 3;
}
message CompleteRoomMediaUploadResponse {
bool active = 1;
RoomMedia media = 2;
int64 server_time_ms = 3;
}
message SendRoomImageRequest {
RequestMeta meta = 1;
RoomMedia image = 2;
}
message SendRoomImageResponse {
CommandResult result = 1;
bool accepted = 2;
string message_id = 3;
string room_id = 4;
int64 sender_user_id = 5;
int64 server_time_ms = 6;
RoomMedia image = 7;
}
// CreateRoomRequest // CreateRoomRequest
// meta.room_idmeta.command_idmeta.actor_user_idmoderoom_name room_avatar // meta.room_idmeta.command_idmeta.actor_user_idmoderoom_name room_avatar
// meta.actor_user_id owner Conflict // meta.actor_user_id owner Conflict
@ -1309,6 +1417,8 @@ message RoomListItem {
string room_short_id = 14; string room_short_id = 14;
bool locked = 15; bool locked = 15;
string country_code = 16; string country_code = 16;
RoomDecorationResource room_border = 17;
RoomDecorationResource room_name_style = 18;
} }
// ListRoomsResponse cursor // ListRoomsResponse cursor
@ -1391,6 +1501,47 @@ message GetCurrentRoomResponse {
int64 server_time_ms = 9; int64 server_time_ms = 9;
} }
// BatchGetUserVoiceRoomPresencesRequest viewer
// user_ids gateway room-service 100
message BatchGetUserVoiceRoomPresencesRequest {
RequestMeta meta = 1;
int64 viewer_user_id = 2;
repeated int64 user_ids = 3;
}
// UserVoiceRoomPresenceRoom Live
// 线 batch RoomSnapshot
message UserVoiceRoomPresenceRoom {
string room_id = 1;
string room_short_id = 2;
int64 owner_user_id = 3;
// host_user_id owner_user_id
int64 host_user_id = 4;
string title = 5;
string cover_url = 6;
string background_url = 7;
string mode = 8;
int32 online_count = 9;
int32 seat_count = 10;
bool locked = 11;
}
// UserVoiceRoomPresence
// not_on_mic
message UserVoiceRoomPresence {
int64 user_id = 1;
string state = 2;
int32 seat_no = 3;
bool joinable = 4;
int64 observed_at_ms = 5;
UserVoiceRoomPresenceRoom room = 6;
}
message BatchGetUserVoiceRoomPresencesResponse {
repeated UserVoiceRoomPresence items = 1;
int64 server_time_ms = 2;
}
// GetRoomSnapshotRequest // GetRoomSnapshotRequest
// gateway viewer_user_id viewer_user_id // gateway viewer_user_id viewer_user_id
message GetRoomSnapshotRequest { message GetRoomSnapshotRequest {
@ -1501,6 +1652,8 @@ service RoomCommandService {
rpc UpdateRoomProfile(UpdateRoomProfileRequest) returns (UpdateRoomProfileResponse); rpc UpdateRoomProfile(UpdateRoomProfileRequest) returns (UpdateRoomProfileResponse);
rpc SaveRoomBackground(SaveRoomBackgroundRequest) returns (SaveRoomBackgroundResponse); rpc SaveRoomBackground(SaveRoomBackgroundRequest) returns (SaveRoomBackgroundResponse);
rpc SetRoomBackground(SetRoomBackgroundRequest) returns (SetRoomBackgroundResponse); rpc SetRoomBackground(SetRoomBackgroundRequest) returns (SetRoomBackgroundResponse);
rpc ApplyRoomDecoration(ApplyRoomDecorationRequest) returns (ApplyRoomDecorationResponse);
rpc SendRoomImage(SendRoomImageRequest) returns (SendRoomImageResponse);
rpc JoinRoom(JoinRoomRequest) returns (JoinRoomResponse); rpc JoinRoom(JoinRoomRequest) returns (JoinRoomResponse);
rpc RoomHeartbeat(RoomHeartbeatRequest) returns (RoomHeartbeatResponse); rpc RoomHeartbeat(RoomHeartbeatRequest) returns (RoomHeartbeatResponse);
rpc LeaveRoom(LeaveRoomRequest) returns (LeaveRoomResponse); rpc LeaveRoom(LeaveRoomRequest) returns (LeaveRoomResponse);
@ -1540,6 +1693,8 @@ service RoomGuardService {
rpc CheckSpeakPermission(CheckSpeakPermissionRequest) returns (CheckSpeakPermissionResponse); rpc CheckSpeakPermission(CheckSpeakPermissionRequest) returns (CheckSpeakPermissionResponse);
rpc VerifyRoomPresence(VerifyRoomPresenceRequest) returns (VerifyRoomPresenceResponse); rpc VerifyRoomPresence(VerifyRoomPresenceRequest) returns (VerifyRoomPresenceResponse);
rpc ResolveRoomAppCode(ResolveRoomAppCodeRequest) returns (ResolveRoomAppCodeResponse); rpc ResolveRoomAppCode(ResolveRoomAppCodeRequest) returns (ResolveRoomAppCodeResponse);
rpc AuthorizeRoomMediaUpload(AuthorizeRoomMediaUploadRequest) returns (AuthorizeRoomMediaUploadResponse);
rpc CompleteRoomMediaUpload(CompleteRoomMediaUploadRequest) returns (CompleteRoomMediaUploadResponse);
} }
// RoomQueryService Room Cell // RoomQueryService Room Cell
@ -1559,6 +1714,7 @@ service RoomQueryService {
rpc ListRoomContributionRank(ListRoomContributionRankRequest) returns (ListRoomContributionRankResponse); rpc ListRoomContributionRank(ListRoomContributionRankRequest) returns (ListRoomContributionRankResponse);
rpc GetMyRoom(GetMyRoomRequest) returns (GetMyRoomResponse); rpc GetMyRoom(GetMyRoomRequest) returns (GetMyRoomResponse);
rpc GetCurrentRoom(GetCurrentRoomRequest) returns (GetCurrentRoomResponse); rpc GetCurrentRoom(GetCurrentRoomRequest) returns (GetCurrentRoomResponse);
rpc BatchGetUserVoiceRoomPresences(BatchGetUserVoiceRoomPresencesRequest) returns (BatchGetUserVoiceRoomPresencesResponse);
rpc GetRoomSnapshot(GetRoomSnapshotRequest) returns (GetRoomSnapshotResponse); rpc GetRoomSnapshot(GetRoomSnapshotRequest) returns (GetRoomSnapshotResponse);
rpc ListRoomBackgrounds(ListRoomBackgroundsRequest) returns (ListRoomBackgroundsResponse); rpc ListRoomBackgrounds(ListRoomBackgroundsRequest) returns (ListRoomBackgroundsResponse);
rpc GetRoomRocket(GetRoomRocketRequest) returns (GetRoomRocketResponse); rpc GetRoomRocket(GetRoomRocketRequest) returns (GetRoomRocketResponse);

View File

@ -23,6 +23,8 @@ const (
RoomCommandService_UpdateRoomProfile_FullMethodName = "/hyapp.room.v1.RoomCommandService/UpdateRoomProfile" RoomCommandService_UpdateRoomProfile_FullMethodName = "/hyapp.room.v1.RoomCommandService/UpdateRoomProfile"
RoomCommandService_SaveRoomBackground_FullMethodName = "/hyapp.room.v1.RoomCommandService/SaveRoomBackground" RoomCommandService_SaveRoomBackground_FullMethodName = "/hyapp.room.v1.RoomCommandService/SaveRoomBackground"
RoomCommandService_SetRoomBackground_FullMethodName = "/hyapp.room.v1.RoomCommandService/SetRoomBackground" RoomCommandService_SetRoomBackground_FullMethodName = "/hyapp.room.v1.RoomCommandService/SetRoomBackground"
RoomCommandService_ApplyRoomDecoration_FullMethodName = "/hyapp.room.v1.RoomCommandService/ApplyRoomDecoration"
RoomCommandService_SendRoomImage_FullMethodName = "/hyapp.room.v1.RoomCommandService/SendRoomImage"
RoomCommandService_JoinRoom_FullMethodName = "/hyapp.room.v1.RoomCommandService/JoinRoom" RoomCommandService_JoinRoom_FullMethodName = "/hyapp.room.v1.RoomCommandService/JoinRoom"
RoomCommandService_RoomHeartbeat_FullMethodName = "/hyapp.room.v1.RoomCommandService/RoomHeartbeat" RoomCommandService_RoomHeartbeat_FullMethodName = "/hyapp.room.v1.RoomCommandService/RoomHeartbeat"
RoomCommandService_LeaveRoom_FullMethodName = "/hyapp.room.v1.RoomCommandService/LeaveRoom" RoomCommandService_LeaveRoom_FullMethodName = "/hyapp.room.v1.RoomCommandService/LeaveRoom"
@ -67,6 +69,8 @@ type RoomCommandServiceClient interface {
UpdateRoomProfile(ctx context.Context, in *UpdateRoomProfileRequest, opts ...grpc.CallOption) (*UpdateRoomProfileResponse, error) UpdateRoomProfile(ctx context.Context, in *UpdateRoomProfileRequest, opts ...grpc.CallOption) (*UpdateRoomProfileResponse, error)
SaveRoomBackground(ctx context.Context, in *SaveRoomBackgroundRequest, opts ...grpc.CallOption) (*SaveRoomBackgroundResponse, error) SaveRoomBackground(ctx context.Context, in *SaveRoomBackgroundRequest, opts ...grpc.CallOption) (*SaveRoomBackgroundResponse, error)
SetRoomBackground(ctx context.Context, in *SetRoomBackgroundRequest, opts ...grpc.CallOption) (*SetRoomBackgroundResponse, error) SetRoomBackground(ctx context.Context, in *SetRoomBackgroundRequest, opts ...grpc.CallOption) (*SetRoomBackgroundResponse, error)
ApplyRoomDecoration(ctx context.Context, in *ApplyRoomDecorationRequest, opts ...grpc.CallOption) (*ApplyRoomDecorationResponse, error)
SendRoomImage(ctx context.Context, in *SendRoomImageRequest, opts ...grpc.CallOption) (*SendRoomImageResponse, error)
JoinRoom(ctx context.Context, in *JoinRoomRequest, opts ...grpc.CallOption) (*JoinRoomResponse, error) JoinRoom(ctx context.Context, in *JoinRoomRequest, opts ...grpc.CallOption) (*JoinRoomResponse, error)
RoomHeartbeat(ctx context.Context, in *RoomHeartbeatRequest, opts ...grpc.CallOption) (*RoomHeartbeatResponse, error) RoomHeartbeat(ctx context.Context, in *RoomHeartbeatRequest, opts ...grpc.CallOption) (*RoomHeartbeatResponse, error)
LeaveRoom(ctx context.Context, in *LeaveRoomRequest, opts ...grpc.CallOption) (*LeaveRoomResponse, error) LeaveRoom(ctx context.Context, in *LeaveRoomRequest, opts ...grpc.CallOption) (*LeaveRoomResponse, error)
@ -149,6 +153,26 @@ func (c *roomCommandServiceClient) SetRoomBackground(ctx context.Context, in *Se
return out, nil return out, nil
} }
func (c *roomCommandServiceClient) ApplyRoomDecoration(ctx context.Context, in *ApplyRoomDecorationRequest, opts ...grpc.CallOption) (*ApplyRoomDecorationResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(ApplyRoomDecorationResponse)
err := c.cc.Invoke(ctx, RoomCommandService_ApplyRoomDecoration_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *roomCommandServiceClient) SendRoomImage(ctx context.Context, in *SendRoomImageRequest, opts ...grpc.CallOption) (*SendRoomImageResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(SendRoomImageResponse)
err := c.cc.Invoke(ctx, RoomCommandService_SendRoomImage_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *roomCommandServiceClient) JoinRoom(ctx context.Context, in *JoinRoomRequest, opts ...grpc.CallOption) (*JoinRoomResponse, error) { func (c *roomCommandServiceClient) JoinRoom(ctx context.Context, in *JoinRoomRequest, opts ...grpc.CallOption) (*JoinRoomResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(JoinRoomResponse) out := new(JoinRoomResponse)
@ -479,6 +503,8 @@ type RoomCommandServiceServer interface {
UpdateRoomProfile(context.Context, *UpdateRoomProfileRequest) (*UpdateRoomProfileResponse, error) UpdateRoomProfile(context.Context, *UpdateRoomProfileRequest) (*UpdateRoomProfileResponse, error)
SaveRoomBackground(context.Context, *SaveRoomBackgroundRequest) (*SaveRoomBackgroundResponse, error) SaveRoomBackground(context.Context, *SaveRoomBackgroundRequest) (*SaveRoomBackgroundResponse, error)
SetRoomBackground(context.Context, *SetRoomBackgroundRequest) (*SetRoomBackgroundResponse, error) SetRoomBackground(context.Context, *SetRoomBackgroundRequest) (*SetRoomBackgroundResponse, error)
ApplyRoomDecoration(context.Context, *ApplyRoomDecorationRequest) (*ApplyRoomDecorationResponse, error)
SendRoomImage(context.Context, *SendRoomImageRequest) (*SendRoomImageResponse, error)
JoinRoom(context.Context, *JoinRoomRequest) (*JoinRoomResponse, error) JoinRoom(context.Context, *JoinRoomRequest) (*JoinRoomResponse, error)
RoomHeartbeat(context.Context, *RoomHeartbeatRequest) (*RoomHeartbeatResponse, error) RoomHeartbeat(context.Context, *RoomHeartbeatRequest) (*RoomHeartbeatResponse, error)
LeaveRoom(context.Context, *LeaveRoomRequest) (*LeaveRoomResponse, error) LeaveRoom(context.Context, *LeaveRoomRequest) (*LeaveRoomResponse, error)
@ -533,6 +559,12 @@ func (UnimplementedRoomCommandServiceServer) SaveRoomBackground(context.Context,
func (UnimplementedRoomCommandServiceServer) SetRoomBackground(context.Context, *SetRoomBackgroundRequest) (*SetRoomBackgroundResponse, error) { func (UnimplementedRoomCommandServiceServer) SetRoomBackground(context.Context, *SetRoomBackgroundRequest) (*SetRoomBackgroundResponse, error) {
return nil, status.Error(codes.Unimplemented, "method SetRoomBackground not implemented") return nil, status.Error(codes.Unimplemented, "method SetRoomBackground not implemented")
} }
func (UnimplementedRoomCommandServiceServer) ApplyRoomDecoration(context.Context, *ApplyRoomDecorationRequest) (*ApplyRoomDecorationResponse, error) {
return nil, status.Error(codes.Unimplemented, "method ApplyRoomDecoration not implemented")
}
func (UnimplementedRoomCommandServiceServer) SendRoomImage(context.Context, *SendRoomImageRequest) (*SendRoomImageResponse, error) {
return nil, status.Error(codes.Unimplemented, "method SendRoomImage not implemented")
}
func (UnimplementedRoomCommandServiceServer) JoinRoom(context.Context, *JoinRoomRequest) (*JoinRoomResponse, error) { func (UnimplementedRoomCommandServiceServer) JoinRoom(context.Context, *JoinRoomRequest) (*JoinRoomResponse, error) {
return nil, status.Error(codes.Unimplemented, "method JoinRoom not implemented") return nil, status.Error(codes.Unimplemented, "method JoinRoom not implemented")
} }
@ -722,6 +754,42 @@ func _RoomCommandService_SetRoomBackground_Handler(srv interface{}, ctx context.
return interceptor(ctx, in, info, handler) return interceptor(ctx, in, info, handler)
} }
func _RoomCommandService_ApplyRoomDecoration_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(ApplyRoomDecorationRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(RoomCommandServiceServer).ApplyRoomDecoration(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: RoomCommandService_ApplyRoomDecoration_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(RoomCommandServiceServer).ApplyRoomDecoration(ctx, req.(*ApplyRoomDecorationRequest))
}
return interceptor(ctx, in, info, handler)
}
func _RoomCommandService_SendRoomImage_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(SendRoomImageRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(RoomCommandServiceServer).SendRoomImage(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: RoomCommandService_SendRoomImage_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(RoomCommandServiceServer).SendRoomImage(ctx, req.(*SendRoomImageRequest))
}
return interceptor(ctx, in, info, handler)
}
func _RoomCommandService_JoinRoom_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { func _RoomCommandService_JoinRoom_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(JoinRoomRequest) in := new(JoinRoomRequest)
if err := dec(in); err != nil { if err := dec(in); err != nil {
@ -1321,6 +1389,14 @@ var RoomCommandService_ServiceDesc = grpc.ServiceDesc{
MethodName: "SetRoomBackground", MethodName: "SetRoomBackground",
Handler: _RoomCommandService_SetRoomBackground_Handler, Handler: _RoomCommandService_SetRoomBackground_Handler,
}, },
{
MethodName: "ApplyRoomDecoration",
Handler: _RoomCommandService_ApplyRoomDecoration_Handler,
},
{
MethodName: "SendRoomImage",
Handler: _RoomCommandService_SendRoomImage_Handler,
},
{ {
MethodName: "JoinRoom", MethodName: "JoinRoom",
Handler: _RoomCommandService_JoinRoom_Handler, Handler: _RoomCommandService_JoinRoom_Handler,
@ -1455,9 +1531,11 @@ var RoomCommandService_ServiceDesc = grpc.ServiceDesc{
} }
const ( const (
RoomGuardService_CheckSpeakPermission_FullMethodName = "/hyapp.room.v1.RoomGuardService/CheckSpeakPermission" RoomGuardService_CheckSpeakPermission_FullMethodName = "/hyapp.room.v1.RoomGuardService/CheckSpeakPermission"
RoomGuardService_VerifyRoomPresence_FullMethodName = "/hyapp.room.v1.RoomGuardService/VerifyRoomPresence" RoomGuardService_VerifyRoomPresence_FullMethodName = "/hyapp.room.v1.RoomGuardService/VerifyRoomPresence"
RoomGuardService_ResolveRoomAppCode_FullMethodName = "/hyapp.room.v1.RoomGuardService/ResolveRoomAppCode" RoomGuardService_ResolveRoomAppCode_FullMethodName = "/hyapp.room.v1.RoomGuardService/ResolveRoomAppCode"
RoomGuardService_AuthorizeRoomMediaUpload_FullMethodName = "/hyapp.room.v1.RoomGuardService/AuthorizeRoomMediaUpload"
RoomGuardService_CompleteRoomMediaUpload_FullMethodName = "/hyapp.room.v1.RoomGuardService/CompleteRoomMediaUpload"
) )
// RoomGuardServiceClient is the client API for RoomGuardService service. // RoomGuardServiceClient is the client API for RoomGuardService service.
@ -1469,6 +1547,8 @@ type RoomGuardServiceClient interface {
CheckSpeakPermission(ctx context.Context, in *CheckSpeakPermissionRequest, opts ...grpc.CallOption) (*CheckSpeakPermissionResponse, error) CheckSpeakPermission(ctx context.Context, in *CheckSpeakPermissionRequest, opts ...grpc.CallOption) (*CheckSpeakPermissionResponse, error)
VerifyRoomPresence(ctx context.Context, in *VerifyRoomPresenceRequest, opts ...grpc.CallOption) (*VerifyRoomPresenceResponse, error) VerifyRoomPresence(ctx context.Context, in *VerifyRoomPresenceRequest, opts ...grpc.CallOption) (*VerifyRoomPresenceResponse, error)
ResolveRoomAppCode(ctx context.Context, in *ResolveRoomAppCodeRequest, opts ...grpc.CallOption) (*ResolveRoomAppCodeResponse, error) ResolveRoomAppCode(ctx context.Context, in *ResolveRoomAppCodeRequest, opts ...grpc.CallOption) (*ResolveRoomAppCodeResponse, error)
AuthorizeRoomMediaUpload(ctx context.Context, in *AuthorizeRoomMediaUploadRequest, opts ...grpc.CallOption) (*AuthorizeRoomMediaUploadResponse, error)
CompleteRoomMediaUpload(ctx context.Context, in *CompleteRoomMediaUploadRequest, opts ...grpc.CallOption) (*CompleteRoomMediaUploadResponse, error)
} }
type roomGuardServiceClient struct { type roomGuardServiceClient struct {
@ -1509,6 +1589,26 @@ func (c *roomGuardServiceClient) ResolveRoomAppCode(ctx context.Context, in *Res
return out, nil return out, nil
} }
func (c *roomGuardServiceClient) AuthorizeRoomMediaUpload(ctx context.Context, in *AuthorizeRoomMediaUploadRequest, opts ...grpc.CallOption) (*AuthorizeRoomMediaUploadResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(AuthorizeRoomMediaUploadResponse)
err := c.cc.Invoke(ctx, RoomGuardService_AuthorizeRoomMediaUpload_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *roomGuardServiceClient) CompleteRoomMediaUpload(ctx context.Context, in *CompleteRoomMediaUploadRequest, opts ...grpc.CallOption) (*CompleteRoomMediaUploadResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(CompleteRoomMediaUploadResponse)
err := c.cc.Invoke(ctx, RoomGuardService_CompleteRoomMediaUpload_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
// RoomGuardServiceServer is the server API for RoomGuardService service. // RoomGuardServiceServer is the server API for RoomGuardService service.
// All implementations must embed UnimplementedRoomGuardServiceServer // All implementations must embed UnimplementedRoomGuardServiceServer
// for forward compatibility. // for forward compatibility.
@ -1518,6 +1618,8 @@ type RoomGuardServiceServer interface {
CheckSpeakPermission(context.Context, *CheckSpeakPermissionRequest) (*CheckSpeakPermissionResponse, error) CheckSpeakPermission(context.Context, *CheckSpeakPermissionRequest) (*CheckSpeakPermissionResponse, error)
VerifyRoomPresence(context.Context, *VerifyRoomPresenceRequest) (*VerifyRoomPresenceResponse, error) VerifyRoomPresence(context.Context, *VerifyRoomPresenceRequest) (*VerifyRoomPresenceResponse, error)
ResolveRoomAppCode(context.Context, *ResolveRoomAppCodeRequest) (*ResolveRoomAppCodeResponse, error) ResolveRoomAppCode(context.Context, *ResolveRoomAppCodeRequest) (*ResolveRoomAppCodeResponse, error)
AuthorizeRoomMediaUpload(context.Context, *AuthorizeRoomMediaUploadRequest) (*AuthorizeRoomMediaUploadResponse, error)
CompleteRoomMediaUpload(context.Context, *CompleteRoomMediaUploadRequest) (*CompleteRoomMediaUploadResponse, error)
mustEmbedUnimplementedRoomGuardServiceServer() mustEmbedUnimplementedRoomGuardServiceServer()
} }
@ -1537,6 +1639,12 @@ func (UnimplementedRoomGuardServiceServer) VerifyRoomPresence(context.Context, *
func (UnimplementedRoomGuardServiceServer) ResolveRoomAppCode(context.Context, *ResolveRoomAppCodeRequest) (*ResolveRoomAppCodeResponse, error) { func (UnimplementedRoomGuardServiceServer) ResolveRoomAppCode(context.Context, *ResolveRoomAppCodeRequest) (*ResolveRoomAppCodeResponse, error) {
return nil, status.Error(codes.Unimplemented, "method ResolveRoomAppCode not implemented") return nil, status.Error(codes.Unimplemented, "method ResolveRoomAppCode not implemented")
} }
func (UnimplementedRoomGuardServiceServer) AuthorizeRoomMediaUpload(context.Context, *AuthorizeRoomMediaUploadRequest) (*AuthorizeRoomMediaUploadResponse, error) {
return nil, status.Error(codes.Unimplemented, "method AuthorizeRoomMediaUpload not implemented")
}
func (UnimplementedRoomGuardServiceServer) CompleteRoomMediaUpload(context.Context, *CompleteRoomMediaUploadRequest) (*CompleteRoomMediaUploadResponse, error) {
return nil, status.Error(codes.Unimplemented, "method CompleteRoomMediaUpload not implemented")
}
func (UnimplementedRoomGuardServiceServer) mustEmbedUnimplementedRoomGuardServiceServer() {} func (UnimplementedRoomGuardServiceServer) mustEmbedUnimplementedRoomGuardServiceServer() {}
func (UnimplementedRoomGuardServiceServer) testEmbeddedByValue() {} func (UnimplementedRoomGuardServiceServer) testEmbeddedByValue() {}
@ -1612,6 +1720,42 @@ func _RoomGuardService_ResolveRoomAppCode_Handler(srv interface{}, ctx context.C
return interceptor(ctx, in, info, handler) return interceptor(ctx, in, info, handler)
} }
func _RoomGuardService_AuthorizeRoomMediaUpload_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(AuthorizeRoomMediaUploadRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(RoomGuardServiceServer).AuthorizeRoomMediaUpload(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: RoomGuardService_AuthorizeRoomMediaUpload_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(RoomGuardServiceServer).AuthorizeRoomMediaUpload(ctx, req.(*AuthorizeRoomMediaUploadRequest))
}
return interceptor(ctx, in, info, handler)
}
func _RoomGuardService_CompleteRoomMediaUpload_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(CompleteRoomMediaUploadRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(RoomGuardServiceServer).CompleteRoomMediaUpload(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: RoomGuardService_CompleteRoomMediaUpload_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(RoomGuardServiceServer).CompleteRoomMediaUpload(ctx, req.(*CompleteRoomMediaUploadRequest))
}
return interceptor(ctx, in, info, handler)
}
// RoomGuardService_ServiceDesc is the grpc.ServiceDesc for RoomGuardService service. // RoomGuardService_ServiceDesc is the grpc.ServiceDesc for RoomGuardService service.
// It's only intended for direct use with grpc.RegisterService, // It's only intended for direct use with grpc.RegisterService,
// and not to be introspected or modified (even as a copy) // and not to be introspected or modified (even as a copy)
@ -1631,6 +1775,14 @@ var RoomGuardService_ServiceDesc = grpc.ServiceDesc{
MethodName: "ResolveRoomAppCode", MethodName: "ResolveRoomAppCode",
Handler: _RoomGuardService_ResolveRoomAppCode_Handler, Handler: _RoomGuardService_ResolveRoomAppCode_Handler,
}, },
{
MethodName: "AuthorizeRoomMediaUpload",
Handler: _RoomGuardService_AuthorizeRoomMediaUpload_Handler,
},
{
MethodName: "CompleteRoomMediaUpload",
Handler: _RoomGuardService_CompleteRoomMediaUpload_Handler,
},
}, },
Streams: []grpc.StreamDesc{}, Streams: []grpc.StreamDesc{},
Metadata: "proto/room/v1/room.proto", Metadata: "proto/room/v1/room.proto",
@ -1652,6 +1804,7 @@ const (
RoomQueryService_ListRoomContributionRank_FullMethodName = "/hyapp.room.v1.RoomQueryService/ListRoomContributionRank" RoomQueryService_ListRoomContributionRank_FullMethodName = "/hyapp.room.v1.RoomQueryService/ListRoomContributionRank"
RoomQueryService_GetMyRoom_FullMethodName = "/hyapp.room.v1.RoomQueryService/GetMyRoom" RoomQueryService_GetMyRoom_FullMethodName = "/hyapp.room.v1.RoomQueryService/GetMyRoom"
RoomQueryService_GetCurrentRoom_FullMethodName = "/hyapp.room.v1.RoomQueryService/GetCurrentRoom" RoomQueryService_GetCurrentRoom_FullMethodName = "/hyapp.room.v1.RoomQueryService/GetCurrentRoom"
RoomQueryService_BatchGetUserVoiceRoomPresences_FullMethodName = "/hyapp.room.v1.RoomQueryService/BatchGetUserVoiceRoomPresences"
RoomQueryService_GetRoomSnapshot_FullMethodName = "/hyapp.room.v1.RoomQueryService/GetRoomSnapshot" RoomQueryService_GetRoomSnapshot_FullMethodName = "/hyapp.room.v1.RoomQueryService/GetRoomSnapshot"
RoomQueryService_ListRoomBackgrounds_FullMethodName = "/hyapp.room.v1.RoomQueryService/ListRoomBackgrounds" RoomQueryService_ListRoomBackgrounds_FullMethodName = "/hyapp.room.v1.RoomQueryService/ListRoomBackgrounds"
RoomQueryService_GetRoomRocket_FullMethodName = "/hyapp.room.v1.RoomQueryService/GetRoomRocket" RoomQueryService_GetRoomRocket_FullMethodName = "/hyapp.room.v1.RoomQueryService/GetRoomRocket"
@ -1680,6 +1833,7 @@ type RoomQueryServiceClient interface {
ListRoomContributionRank(ctx context.Context, in *ListRoomContributionRankRequest, opts ...grpc.CallOption) (*ListRoomContributionRankResponse, error) ListRoomContributionRank(ctx context.Context, in *ListRoomContributionRankRequest, opts ...grpc.CallOption) (*ListRoomContributionRankResponse, error)
GetMyRoom(ctx context.Context, in *GetMyRoomRequest, opts ...grpc.CallOption) (*GetMyRoomResponse, error) GetMyRoom(ctx context.Context, in *GetMyRoomRequest, opts ...grpc.CallOption) (*GetMyRoomResponse, error)
GetCurrentRoom(ctx context.Context, in *GetCurrentRoomRequest, opts ...grpc.CallOption) (*GetCurrentRoomResponse, error) GetCurrentRoom(ctx context.Context, in *GetCurrentRoomRequest, opts ...grpc.CallOption) (*GetCurrentRoomResponse, error)
BatchGetUserVoiceRoomPresences(ctx context.Context, in *BatchGetUserVoiceRoomPresencesRequest, opts ...grpc.CallOption) (*BatchGetUserVoiceRoomPresencesResponse, error)
GetRoomSnapshot(ctx context.Context, in *GetRoomSnapshotRequest, opts ...grpc.CallOption) (*GetRoomSnapshotResponse, error) GetRoomSnapshot(ctx context.Context, in *GetRoomSnapshotRequest, opts ...grpc.CallOption) (*GetRoomSnapshotResponse, error)
ListRoomBackgrounds(ctx context.Context, in *ListRoomBackgroundsRequest, opts ...grpc.CallOption) (*ListRoomBackgroundsResponse, error) ListRoomBackgrounds(ctx context.Context, in *ListRoomBackgroundsRequest, opts ...grpc.CallOption) (*ListRoomBackgroundsResponse, error)
GetRoomRocket(ctx context.Context, in *GetRoomRocketRequest, opts ...grpc.CallOption) (*GetRoomRocketResponse, error) GetRoomRocket(ctx context.Context, in *GetRoomRocketRequest, opts ...grpc.CallOption) (*GetRoomRocketResponse, error)
@ -1845,6 +1999,16 @@ func (c *roomQueryServiceClient) GetCurrentRoom(ctx context.Context, in *GetCurr
return out, nil return out, nil
} }
func (c *roomQueryServiceClient) BatchGetUserVoiceRoomPresences(ctx context.Context, in *BatchGetUserVoiceRoomPresencesRequest, opts ...grpc.CallOption) (*BatchGetUserVoiceRoomPresencesResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(BatchGetUserVoiceRoomPresencesResponse)
err := c.cc.Invoke(ctx, RoomQueryService_BatchGetUserVoiceRoomPresences_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *roomQueryServiceClient) GetRoomSnapshot(ctx context.Context, in *GetRoomSnapshotRequest, opts ...grpc.CallOption) (*GetRoomSnapshotResponse, error) { func (c *roomQueryServiceClient) GetRoomSnapshot(ctx context.Context, in *GetRoomSnapshotRequest, opts ...grpc.CallOption) (*GetRoomSnapshotResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(GetRoomSnapshotResponse) out := new(GetRoomSnapshotResponse)
@ -1916,6 +2080,7 @@ type RoomQueryServiceServer interface {
ListRoomContributionRank(context.Context, *ListRoomContributionRankRequest) (*ListRoomContributionRankResponse, error) ListRoomContributionRank(context.Context, *ListRoomContributionRankRequest) (*ListRoomContributionRankResponse, error)
GetMyRoom(context.Context, *GetMyRoomRequest) (*GetMyRoomResponse, error) GetMyRoom(context.Context, *GetMyRoomRequest) (*GetMyRoomResponse, error)
GetCurrentRoom(context.Context, *GetCurrentRoomRequest) (*GetCurrentRoomResponse, error) GetCurrentRoom(context.Context, *GetCurrentRoomRequest) (*GetCurrentRoomResponse, error)
BatchGetUserVoiceRoomPresences(context.Context, *BatchGetUserVoiceRoomPresencesRequest) (*BatchGetUserVoiceRoomPresencesResponse, error)
GetRoomSnapshot(context.Context, *GetRoomSnapshotRequest) (*GetRoomSnapshotResponse, error) GetRoomSnapshot(context.Context, *GetRoomSnapshotRequest) (*GetRoomSnapshotResponse, error)
ListRoomBackgrounds(context.Context, *ListRoomBackgroundsRequest) (*ListRoomBackgroundsResponse, error) ListRoomBackgrounds(context.Context, *ListRoomBackgroundsRequest) (*ListRoomBackgroundsResponse, error)
GetRoomRocket(context.Context, *GetRoomRocketRequest) (*GetRoomRocketResponse, error) GetRoomRocket(context.Context, *GetRoomRocketRequest) (*GetRoomRocketResponse, error)
@ -1976,6 +2141,9 @@ func (UnimplementedRoomQueryServiceServer) GetMyRoom(context.Context, *GetMyRoom
func (UnimplementedRoomQueryServiceServer) GetCurrentRoom(context.Context, *GetCurrentRoomRequest) (*GetCurrentRoomResponse, error) { func (UnimplementedRoomQueryServiceServer) GetCurrentRoom(context.Context, *GetCurrentRoomRequest) (*GetCurrentRoomResponse, error) {
return nil, status.Error(codes.Unimplemented, "method GetCurrentRoom not implemented") return nil, status.Error(codes.Unimplemented, "method GetCurrentRoom not implemented")
} }
func (UnimplementedRoomQueryServiceServer) BatchGetUserVoiceRoomPresences(context.Context, *BatchGetUserVoiceRoomPresencesRequest) (*BatchGetUserVoiceRoomPresencesResponse, error) {
return nil, status.Error(codes.Unimplemented, "method BatchGetUserVoiceRoomPresences not implemented")
}
func (UnimplementedRoomQueryServiceServer) GetRoomSnapshot(context.Context, *GetRoomSnapshotRequest) (*GetRoomSnapshotResponse, error) { func (UnimplementedRoomQueryServiceServer) GetRoomSnapshot(context.Context, *GetRoomSnapshotRequest) (*GetRoomSnapshotResponse, error) {
return nil, status.Error(codes.Unimplemented, "method GetRoomSnapshot not implemented") return nil, status.Error(codes.Unimplemented, "method GetRoomSnapshot not implemented")
} }
@ -2282,6 +2450,24 @@ func _RoomQueryService_GetCurrentRoom_Handler(srv interface{}, ctx context.Conte
return interceptor(ctx, in, info, handler) return interceptor(ctx, in, info, handler)
} }
func _RoomQueryService_BatchGetUserVoiceRoomPresences_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(BatchGetUserVoiceRoomPresencesRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(RoomQueryServiceServer).BatchGetUserVoiceRoomPresences(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: RoomQueryService_BatchGetUserVoiceRoomPresences_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(RoomQueryServiceServer).BatchGetUserVoiceRoomPresences(ctx, req.(*BatchGetUserVoiceRoomPresencesRequest))
}
return interceptor(ctx, in, info, handler)
}
func _RoomQueryService_GetRoomSnapshot_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { func _RoomQueryService_GetRoomSnapshot_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(GetRoomSnapshotRequest) in := new(GetRoomSnapshotRequest)
if err := dec(in); err != nil { if err := dec(in); err != nil {
@ -2439,6 +2625,10 @@ var RoomQueryService_ServiceDesc = grpc.ServiceDesc{
MethodName: "GetCurrentRoom", MethodName: "GetCurrentRoom",
Handler: _RoomQueryService_GetCurrentRoom_Handler, Handler: _RoomQueryService_GetCurrentRoom_Handler,
}, },
{
MethodName: "BatchGetUserVoiceRoomPresences",
Handler: _RoomQueryService_BatchGetUserVoiceRoomPresences_Handler,
},
{ {
MethodName: "GetRoomSnapshot", MethodName: "GetRoomSnapshot",
Handler: _RoomQueryService_GetRoomSnapshot_Handler, Handler: _RoomQueryService_GetRoomSnapshot_Handler,

View File

@ -2211,11 +2211,14 @@ func (x *RecordProfileVisitRequest) GetTargetUserId() int64 {
} }
type RecordProfileVisitResponse struct { type RecordProfileVisitResponse struct {
state protoimpl.MessageState `protogen:"open.v1"` state protoimpl.MessageState `protogen:"open.v1"`
Recorded bool `protobuf:"varint,1,opt,name=recorded,proto3" json:"recorded,omitempty"` Recorded bool `protobuf:"varint,1,opt,name=recorded,proto3" json:"recorded,omitempty"`
TargetStats *UserProfileStats `protobuf:"bytes,2,opt,name=target_stats,json=targetStats,proto3" json:"target_stats,omitempty"` TargetStats *UserProfileStats `protobuf:"bytes,2,opt,name=target_stats,json=targetStats,proto3" json:"target_stats,omitempty"`
unknownFields protoimpl.UnknownFields // target_stats_hidden=true 时 target_stats 必须为空;该标志由 user-service
sizeCache protoimpl.SizeCache // 基于目标用户当前 hide_profile_data 权益实时判定,客户端不能自行推断。
TargetStatsHidden bool `protobuf:"varint,3,opt,name=target_stats_hidden,json=targetStatsHidden,proto3" json:"target_stats_hidden,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
} }
func (x *RecordProfileVisitResponse) Reset() { func (x *RecordProfileVisitResponse) Reset() {
@ -2262,14 +2265,27 @@ func (x *RecordProfileVisitResponse) GetTargetStats() *UserProfileStats {
return nil return nil
} }
func (x *RecordProfileVisitResponse) GetTargetStatsHidden() bool {
if x != nil {
return x.TargetStatsHidden
}
return false
}
type ProfileVisitRecord struct { type ProfileVisitRecord struct {
state protoimpl.MessageState `protogen:"open.v1"` state protoimpl.MessageState `protogen:"open.v1"`
VisitorUserId int64 `protobuf:"varint,1,opt,name=visitor_user_id,json=visitorUserId,proto3" json:"visitor_user_id,omitempty"` // 隐藏访客身份的记录不返回真实 visitor_user_id字段保持 0客户端必须使用
TargetUserId int64 `protobuf:"varint,2,opt,name=target_user_id,json=targetUserId,proto3" json:"target_user_id,omitempty"` // visitor_identity_hidden/anonymous_visit_id 分支渲染匿名访客,不能把 0 当作用户 ID。
VisitCount int64 `protobuf:"varint,3,opt,name=visit_count,json=visitCount,proto3" json:"visit_count,omitempty"` VisitorUserId int64 `protobuf:"varint,1,opt,name=visitor_user_id,json=visitorUserId,proto3" json:"visitor_user_id,omitempty"`
LastVisitedAtMs int64 `protobuf:"varint,4,opt,name=last_visited_at_ms,json=lastVisitedAtMs,proto3" json:"last_visited_at_ms,omitempty"` TargetUserId int64 `protobuf:"varint,2,opt,name=target_user_id,json=targetUserId,proto3" json:"target_user_id,omitempty"`
unknownFields protoimpl.UnknownFields VisitCount int64 `protobuf:"varint,3,opt,name=visit_count,json=visitCount,proto3" json:"visit_count,omitempty"`
sizeCache protoimpl.SizeCache LastVisitedAtMs int64 `protobuf:"varint,4,opt,name=last_visited_at_ms,json=lastVisitedAtMs,proto3" json:"last_visited_at_ms,omitempty"`
VisitorIdentityHidden bool `protobuf:"varint,5,opt,name=visitor_identity_hidden,json=visitorIdentityHidden,proto3" json:"visitor_identity_hidden,omitempty"`
// anonymous_visit_id 是首次匿名访问时随机生成、仅在同一 target 下稳定的展示 ID
// 不包含真实 user_id也不能用于查询用户资料。
AnonymousVisitId string `protobuf:"bytes,6,opt,name=anonymous_visit_id,json=anonymousVisitId,proto3" json:"anonymous_visit_id,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
} }
func (x *ProfileVisitRecord) Reset() { func (x *ProfileVisitRecord) Reset() {
@ -2330,6 +2346,20 @@ func (x *ProfileVisitRecord) GetLastVisitedAtMs() int64 {
return 0 return 0
} }
func (x *ProfileVisitRecord) GetVisitorIdentityHidden() bool {
if x != nil {
return x.VisitorIdentityHidden
}
return false
}
func (x *ProfileVisitRecord) GetAnonymousVisitId() string {
if x != nil {
return x.AnonymousVisitId
}
return ""
}
type ListProfileVisitorsRequest struct { type ListProfileVisitorsRequest struct {
state protoimpl.MessageState `protogen:"open.v1"` state protoimpl.MessageState `protogen:"open.v1"`
Meta *RequestMeta `protobuf:"bytes,1,opt,name=meta,proto3" json:"meta,omitempty"` Meta *RequestMeta `protobuf:"bytes,1,opt,name=meta,proto3" json:"meta,omitempty"`
@ -13650,16 +13680,19 @@ const file_proto_user_v1_user_proto_rawDesc = "" +
"\x19RecordProfileVisitRequest\x12.\n" + "\x19RecordProfileVisitRequest\x12.\n" +
"\x04meta\x18\x01 \x01(\v2\x1a.hyapp.user.v1.RequestMetaR\x04meta\x12&\n" + "\x04meta\x18\x01 \x01(\v2\x1a.hyapp.user.v1.RequestMetaR\x04meta\x12&\n" +
"\x0fvisitor_user_id\x18\x02 \x01(\x03R\rvisitorUserId\x12$\n" + "\x0fvisitor_user_id\x18\x02 \x01(\x03R\rvisitorUserId\x12$\n" +
"\x0etarget_user_id\x18\x03 \x01(\x03R\ftargetUserId\"|\n" + "\x0etarget_user_id\x18\x03 \x01(\x03R\ftargetUserId\"\xac\x01\n" +
"\x1aRecordProfileVisitResponse\x12\x1a\n" + "\x1aRecordProfileVisitResponse\x12\x1a\n" +
"\brecorded\x18\x01 \x01(\bR\brecorded\x12B\n" + "\brecorded\x18\x01 \x01(\bR\brecorded\x12B\n" +
"\ftarget_stats\x18\x02 \x01(\v2\x1f.hyapp.user.v1.UserProfileStatsR\vtargetStats\"\xb0\x01\n" + "\ftarget_stats\x18\x02 \x01(\v2\x1f.hyapp.user.v1.UserProfileStatsR\vtargetStats\x12.\n" +
"\x13target_stats_hidden\x18\x03 \x01(\bR\x11targetStatsHidden\"\x96\x02\n" +
"\x12ProfileVisitRecord\x12&\n" + "\x12ProfileVisitRecord\x12&\n" +
"\x0fvisitor_user_id\x18\x01 \x01(\x03R\rvisitorUserId\x12$\n" + "\x0fvisitor_user_id\x18\x01 \x01(\x03R\rvisitorUserId\x12$\n" +
"\x0etarget_user_id\x18\x02 \x01(\x03R\ftargetUserId\x12\x1f\n" + "\x0etarget_user_id\x18\x02 \x01(\x03R\ftargetUserId\x12\x1f\n" +
"\vvisit_count\x18\x03 \x01(\x03R\n" + "\vvisit_count\x18\x03 \x01(\x03R\n" +
"visitCount\x12+\n" + "visitCount\x12+\n" +
"\x12last_visited_at_ms\x18\x04 \x01(\x03R\x0flastVisitedAtMs\"\x96\x01\n" + "\x12last_visited_at_ms\x18\x04 \x01(\x03R\x0flastVisitedAtMs\x126\n" +
"\x17visitor_identity_hidden\x18\x05 \x01(\bR\x15visitorIdentityHidden\x12,\n" +
"\x12anonymous_visit_id\x18\x06 \x01(\tR\x10anonymousVisitId\"\x96\x01\n" +
"\x1aListProfileVisitorsRequest\x12.\n" + "\x1aListProfileVisitorsRequest\x12.\n" +
"\x04meta\x18\x01 \x01(\v2\x1a.hyapp.user.v1.RequestMetaR\x04meta\x12\x17\n" + "\x04meta\x18\x01 \x01(\v2\x1a.hyapp.user.v1.RequestMetaR\x04meta\x12\x17\n" +
"\auser_id\x18\x02 \x01(\x03R\x06userId\x12\x12\n" + "\auser_id\x18\x02 \x01(\x03R\x06userId\x12\x12\n" +

View File

@ -262,13 +262,22 @@ message RecordProfileVisitRequest {
message RecordProfileVisitResponse { message RecordProfileVisitResponse {
bool recorded = 1; bool recorded = 1;
UserProfileStats target_stats = 2; UserProfileStats target_stats = 2;
// target_stats_hidden=true target_stats user-service
// hide_profile_data
bool target_stats_hidden = 3;
} }
message ProfileVisitRecord { message ProfileVisitRecord {
// 访 visitor_user_id 0使
// visitor_identity_hidden/anonymous_visit_id 访 0 ID
int64 visitor_user_id = 1; int64 visitor_user_id = 1;
int64 target_user_id = 2; int64 target_user_id = 2;
int64 visit_count = 3; int64 visit_count = 3;
int64 last_visited_at_ms = 4; int64 last_visited_at_ms = 4;
bool visitor_identity_hidden = 5;
// anonymous_visit_id 访 target ID
// user_id
string anonymous_visit_id = 6;
} }
message ListProfileVisitorsRequest { message ListProfileVisitorsRequest {

View File

@ -9665,6 +9665,8 @@ type EquipUserResourceRequest struct {
UserId int64 `protobuf:"varint,3,opt,name=user_id,json=userId,proto3" json:"user_id,omitempty"` UserId int64 `protobuf:"varint,3,opt,name=user_id,json=userId,proto3" json:"user_id,omitempty"`
ResourceId int64 `protobuf:"varint,4,opt,name=resource_id,json=resourceId,proto3" json:"resource_id,omitempty"` ResourceId int64 `protobuf:"varint,4,opt,name=resource_id,json=resourceId,proto3" json:"resource_id,omitempty"`
EntitlementId string `protobuf:"bytes,5,opt,name=entitlement_id,json=entitlementId,proto3" json:"entitlement_id,omitempty"` EntitlementId string `protobuf:"bytes,5,opt,name=entitlement_id,json=entitlementId,proto3" json:"entitlement_id,omitempty"`
// command_id 是装备状态变更的业务幂等键request_id 只用于链路追踪。
CommandId string `protobuf:"bytes,6,opt,name=command_id,json=commandId,proto3" json:"command_id,omitempty"`
unknownFields protoimpl.UnknownFields unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache sizeCache protoimpl.SizeCache
} }
@ -9734,6 +9736,13 @@ func (x *EquipUserResourceRequest) GetEntitlementId() string {
return "" return ""
} }
func (x *EquipUserResourceRequest) GetCommandId() string {
if x != nil {
return x.CommandId
}
return ""
}
type EquipUserResourceResponse struct { type EquipUserResourceResponse struct {
state protoimpl.MessageState `protogen:"open.v1"` state protoimpl.MessageState `protogen:"open.v1"`
Resource *UserResourceEntitlement `protobuf:"bytes,1,opt,name=resource,proto3" json:"resource,omitempty"` Resource *UserResourceEntitlement `protobuf:"bytes,1,opt,name=resource,proto3" json:"resource,omitempty"`
@ -17461,8 +17470,11 @@ type VipBenefit struct {
CreatedAtMs int64 `protobuf:"varint,13,opt,name=created_at_ms,json=createdAtMs,proto3" json:"created_at_ms,omitempty"` CreatedAtMs int64 `protobuf:"varint,13,opt,name=created_at_ms,json=createdAtMs,proto3" json:"created_at_ms,omitempty"`
UpdatedAtMs int64 `protobuf:"varint,14,opt,name=updated_at_ms,json=updatedAtMs,proto3" json:"updated_at_ms,omitempty"` UpdatedAtMs int64 `protobuf:"varint,14,opt,name=updated_at_ms,json=updatedAtMs,proto3" json:"updated_at_ms,omitempty"`
Presentation *VipBenefitPresentation `protobuf:"bytes,15,opt,name=presentation,proto3" json:"presentation,omitempty"` Presentation *VipBenefitPresentation `protobuf:"bytes,15,opt,name=presentation,proto3" json:"presentation,omitempty"`
unknownFields protoimpl.UnknownFields // resource 仅在 resource_id 指向同 App、active 且 resource_type 匹配的真实资源时返回。
sizeCache protoimpl.SizeCache // 运营尚未配置素材的权益保留 resource_id/resource_type 事实,但该对象为空,客户端不得猜 URL。
Resource *Resource `protobuf:"bytes,16,opt,name=resource,proto3" json:"resource,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
} }
func (x *VipBenefit) Reset() { func (x *VipBenefit) Reset() {
@ -17600,6 +17612,13 @@ func (x *VipBenefit) GetPresentation() *VipBenefitPresentation {
return nil return nil
} }
func (x *VipBenefit) GetResource() *Resource {
if x != nil {
return x.Resource
}
return nil
}
type VipLevel struct { type VipLevel struct {
state protoimpl.MessageState `protogen:"open.v1"` state protoimpl.MessageState `protogen:"open.v1"`
Level int32 `protobuf:"varint,1,opt,name=level,proto3" json:"level,omitempty"` Level int32 `protobuf:"varint,1,opt,name=level,proto3" json:"level,omitempty"`
@ -20856,8 +20875,12 @@ type CheckVipBenefitResponse struct {
State *VipState `protobuf:"bytes,3,opt,name=state,proto3" json:"state,omitempty"` State *VipState `protobuf:"bytes,3,opt,name=state,proto3" json:"state,omitempty"`
DenialReason string `protobuf:"bytes,4,opt,name=denial_reason,json=denialReason,proto3" json:"denial_reason,omitempty"` DenialReason string `protobuf:"bytes,4,opt,name=denial_reason,json=denialReason,proto3" json:"denial_reason,omitempty"`
EvaluatedAtMs int64 `protobuf:"varint,5,opt,name=evaluated_at_ms,json=evaluatedAtMs,proto3" json:"evaluated_at_ms,omitempty"` EvaluatedAtMs int64 `protobuf:"varint,5,opt,name=evaluated_at_ms,json=evaluatedAtMs,proto3" json:"evaluated_at_ms,omitempty"`
unknownFields protoimpl.UnknownFields // required_level 是当前 App 所有 active 等级中首次显式配置该权益的等级0 表示未配置。
sizeCache protoimpl.SizeCache RequiredLevel int32 `protobuf:"varint,6,opt,name=required_level,json=requiredLevel,proto3" json:"required_level,omitempty"`
// current_effective_level 来自 wallet 合并付费会员与体验卡后的最终状态。
CurrentEffectiveLevel int32 `protobuf:"varint,7,opt,name=current_effective_level,json=currentEffectiveLevel,proto3" json:"current_effective_level,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
} }
func (x *CheckVipBenefitResponse) Reset() { func (x *CheckVipBenefitResponse) Reset() {
@ -20925,6 +20948,20 @@ func (x *CheckVipBenefitResponse) GetEvaluatedAtMs() int64 {
return 0 return 0
} }
func (x *CheckVipBenefitResponse) GetRequiredLevel() int32 {
if x != nil {
return x.RequiredLevel
}
return 0
}
func (x *CheckVipBenefitResponse) GetCurrentEffectiveLevel() int32 {
if x != nil {
return x.CurrentEffectiveLevel
}
return 0
}
type AdminVipLevelInput struct { type AdminVipLevelInput struct {
state protoimpl.MessageState `protogen:"open.v1"` state protoimpl.MessageState `protogen:"open.v1"`
Level int32 `protobuf:"varint,1,opt,name=level,proto3" json:"level,omitempty"` Level int32 `protobuf:"varint,1,opt,name=level,proto3" json:"level,omitempty"`
@ -27280,7 +27317,7 @@ const file_proto_wallet_v1_wallet_proto_rawDesc = "" +
"\vactive_only\x18\x05 \x01(\bR\n" + "\vactive_only\x18\x05 \x01(\bR\n" +
"activeOnly\"c\n" + "activeOnly\"c\n" +
"\x19ListUserResourcesResponse\x12F\n" + "\x19ListUserResourcesResponse\x12F\n" +
"\tresources\x18\x01 \x03(\v2(.hyapp.wallet.v1.UserResourceEntitlementR\tresources\"\xb5\x01\n" + "\tresources\x18\x01 \x03(\v2(.hyapp.wallet.v1.UserResourceEntitlementR\tresources\"\xd4\x01\n" +
"\x18EquipUserResourceRequest\x12\x1d\n" + "\x18EquipUserResourceRequest\x12\x1d\n" +
"\n" + "\n" +
"request_id\x18\x01 \x01(\tR\trequestId\x12\x19\n" + "request_id\x18\x01 \x01(\tR\trequestId\x12\x19\n" +
@ -27288,7 +27325,9 @@ const file_proto_wallet_v1_wallet_proto_rawDesc = "" +
"\auser_id\x18\x03 \x01(\x03R\x06userId\x12\x1f\n" + "\auser_id\x18\x03 \x01(\x03R\x06userId\x12\x1f\n" +
"\vresource_id\x18\x04 \x01(\x03R\n" + "\vresource_id\x18\x04 \x01(\x03R\n" +
"resourceId\x12%\n" + "resourceId\x12%\n" +
"\x0eentitlement_id\x18\x05 \x01(\tR\rentitlementId\"a\n" + "\x0eentitlement_id\x18\x05 \x01(\tR\rentitlementId\x12\x1d\n" +
"\n" +
"command_id\x18\x06 \x01(\tR\tcommandId\"a\n" +
"\x19EquipUserResourceResponse\x12D\n" + "\x19EquipUserResourceResponse\x12D\n" +
"\bresource\x18\x01 \x01(\v2(.hyapp.wallet.v1.UserResourceEntitlementR\bresource\"\x94\x01\n" + "\bresource\x18\x01 \x01(\v2(.hyapp.wallet.v1.UserResourceEntitlementR\bresource\"\x94\x01\n" +
"\x1aUnequipUserResourceRequest\x12\x1d\n" + "\x1aUnequipUserResourceRequest\x12\x1d\n" +
@ -28055,7 +28094,7 @@ const file_proto_wallet_v1_wallet_proto_rawDesc = "" +
"\x0econfig_version\x18\v \x01(\x03R\rconfigVersion\x12-\n" + "\x0econfig_version\x18\v \x01(\x03R\rconfigVersion\x12-\n" +
"\x13updated_by_admin_id\x18\f \x01(\x03R\x10updatedByAdminId\x12\"\n" + "\x13updated_by_admin_id\x18\f \x01(\x03R\x10updatedByAdminId\x12\"\n" +
"\rcreated_at_ms\x18\r \x01(\x03R\vcreatedAtMs\x12\"\n" + "\rcreated_at_ms\x18\r \x01(\x03R\vcreatedAtMs\x12\"\n" +
"\rupdated_at_ms\x18\x0e \x01(\x03R\vupdatedAtMs\"\xad\x04\n" + "\rupdated_at_ms\x18\x0e \x01(\x03R\vupdatedAtMs\"\xe4\x04\n" +
"\n" + "\n" +
"VipBenefit\x12!\n" + "VipBenefit\x12!\n" +
"\fbenefit_code\x18\x01 \x01(\tR\vbenefitCode\x12\x12\n" + "\fbenefit_code\x18\x01 \x01(\tR\vbenefitCode\x12\x12\n" +
@ -28076,7 +28115,8 @@ const file_proto_wallet_v1_wallet_proto_rawDesc = "" +
"\rmetadata_json\x18\f \x01(\tR\fmetadataJson\x12\"\n" + "\rmetadata_json\x18\f \x01(\tR\fmetadataJson\x12\"\n" +
"\rcreated_at_ms\x18\r \x01(\x03R\vcreatedAtMs\x12\"\n" + "\rcreated_at_ms\x18\r \x01(\x03R\vcreatedAtMs\x12\"\n" +
"\rupdated_at_ms\x18\x0e \x01(\x03R\vupdatedAtMs\x12K\n" + "\rupdated_at_ms\x18\x0e \x01(\x03R\vupdatedAtMs\x12K\n" +
"\fpresentation\x18\x0f \x01(\v2'.hyapp.wallet.v1.VipBenefitPresentationR\fpresentation\"\xdc\x05\n" + "\fpresentation\x18\x0f \x01(\v2'.hyapp.wallet.v1.VipBenefitPresentationR\fpresentation\x125\n" +
"\bresource\x18\x10 \x01(\v2\x19.hyapp.wallet.v1.ResourceR\bresource\"\xdc\x05\n" +
"\bVipLevel\x12\x14\n" + "\bVipLevel\x12\x14\n" +
"\x05level\x18\x01 \x01(\x05R\x05level\x12\x12\n" + "\x05level\x18\x01 \x01(\x05R\x05level\x12\x12\n" +
"\x04name\x18\x02 \x01(\tR\x04name\x12\x16\n" + "\x04name\x18\x02 \x01(\tR\x04name\x12\x16\n" +
@ -28388,13 +28428,15 @@ const file_proto_wallet_v1_wallet_proto_rawDesc = "" +
"request_id\x18\x01 \x01(\tR\trequestId\x12\x19\n" + "request_id\x18\x01 \x01(\tR\trequestId\x12\x19\n" +
"\bapp_code\x18\x02 \x01(\tR\aappCode\x12\x17\n" + "\bapp_code\x18\x02 \x01(\tR\aappCode\x12\x17\n" +
"\auser_id\x18\x03 \x01(\x03R\x06userId\x12!\n" + "\auser_id\x18\x03 \x01(\x03R\x06userId\x12!\n" +
"\fbenefit_code\x18\x04 \x01(\tR\vbenefitCode\"\xe8\x01\n" + "\fbenefit_code\x18\x04 \x01(\tR\vbenefitCode\"\xc7\x02\n" +
"\x17CheckVipBenefitResponse\x12\x18\n" + "\x17CheckVipBenefitResponse\x12\x18\n" +
"\aallowed\x18\x01 \x01(\bR\aallowed\x125\n" + "\aallowed\x18\x01 \x01(\bR\aallowed\x125\n" +
"\abenefit\x18\x02 \x01(\v2\x1b.hyapp.wallet.v1.VipBenefitR\abenefit\x12/\n" + "\abenefit\x18\x02 \x01(\v2\x1b.hyapp.wallet.v1.VipBenefitR\abenefit\x12/\n" +
"\x05state\x18\x03 \x01(\v2\x19.hyapp.wallet.v1.VipStateR\x05state\x12#\n" + "\x05state\x18\x03 \x01(\v2\x19.hyapp.wallet.v1.VipStateR\x05state\x12#\n" +
"\rdenial_reason\x18\x04 \x01(\tR\fdenialReason\x12&\n" + "\rdenial_reason\x18\x04 \x01(\tR\fdenialReason\x12&\n" +
"\x0fevaluated_at_ms\x18\x05 \x01(\x03R\revaluatedAtMs\"\xea\x02\n" + "\x0fevaluated_at_ms\x18\x05 \x01(\x03R\revaluatedAtMs\x12%\n" +
"\x0erequired_level\x18\x06 \x01(\x05R\rrequiredLevel\x126\n" +
"\x17current_effective_level\x18\a \x01(\x05R\x15currentEffectiveLevel\"\xea\x02\n" +
"\x12AdminVipLevelInput\x12\x14\n" + "\x12AdminVipLevelInput\x12\x14\n" +
"\x05level\x18\x01 \x01(\x05R\x05level\x12\x12\n" + "\x05level\x18\x01 \x01(\x05R\x05level\x12\x12\n" +
"\x04name\x18\x02 \x01(\tR\x04name\x12\x16\n" + "\x04name\x18\x02 \x01(\tR\x04name\x12\x16\n" +
@ -29434,343 +29476,344 @@ var file_proto_wallet_v1_wallet_proto_depIdxs = []int32{
184, // 83: hyapp.wallet.v1.GetDiamondExchangeConfigResponse.rules:type_name -> hyapp.wallet.v1.DiamondExchangeRule 184, // 83: hyapp.wallet.v1.GetDiamondExchangeConfigResponse.rules:type_name -> hyapp.wallet.v1.DiamondExchangeRule
187, // 84: hyapp.wallet.v1.ListWalletTransactionsResponse.transactions:type_name -> hyapp.wallet.v1.WalletTransaction 187, // 84: hyapp.wallet.v1.ListWalletTransactionsResponse.transactions:type_name -> hyapp.wallet.v1.WalletTransaction
297, // 85: hyapp.wallet.v1.VipBenefit.presentation:type_name -> hyapp.wallet.v1.VipBenefitPresentation 297, // 85: hyapp.wallet.v1.VipBenefit.presentation:type_name -> hyapp.wallet.v1.VipBenefitPresentation
190, // 86: hyapp.wallet.v1.VipLevel.reward_items:type_name -> hyapp.wallet.v1.VipRewardItem 48, // 86: hyapp.wallet.v1.VipBenefit.resource:type_name -> hyapp.wallet.v1.Resource
192, // 87: hyapp.wallet.v1.VipLevel.benefits:type_name -> hyapp.wallet.v1.VipBenefit 190, // 87: hyapp.wallet.v1.VipLevel.reward_items:type_name -> hyapp.wallet.v1.VipRewardItem
194, // 88: hyapp.wallet.v1.VipState.paid_vip:type_name -> hyapp.wallet.v1.UserVip 192, // 88: hyapp.wallet.v1.VipLevel.benefits:type_name -> hyapp.wallet.v1.VipBenefit
195, // 89: hyapp.wallet.v1.VipState.equipped_trial_card:type_name -> hyapp.wallet.v1.VipTrialCard 194, // 89: hyapp.wallet.v1.VipState.paid_vip:type_name -> hyapp.wallet.v1.UserVip
194, // 90: hyapp.wallet.v1.VipState.effective_vip:type_name -> hyapp.wallet.v1.UserVip 195, // 90: hyapp.wallet.v1.VipState.equipped_trial_card:type_name -> hyapp.wallet.v1.VipTrialCard
192, // 91: hyapp.wallet.v1.VipState.effective_benefits:type_name -> hyapp.wallet.v1.VipBenefit 194, // 91: hyapp.wallet.v1.VipState.effective_vip:type_name -> hyapp.wallet.v1.UserVip
191, // 92: hyapp.wallet.v1.VipState.program_config:type_name -> hyapp.wallet.v1.VipProgramConfig 192, // 92: hyapp.wallet.v1.VipState.effective_benefits:type_name -> hyapp.wallet.v1.VipBenefit
196, // 93: hyapp.wallet.v1.VipState.user_settings:type_name -> hyapp.wallet.v1.VipUserSettings 191, // 93: hyapp.wallet.v1.VipState.program_config:type_name -> hyapp.wallet.v1.VipProgramConfig
194, // 94: hyapp.wallet.v1.ListVipPackagesResponse.current_vip:type_name -> hyapp.wallet.v1.UserVip 196, // 94: hyapp.wallet.v1.VipState.user_settings:type_name -> hyapp.wallet.v1.VipUserSettings
193, // 95: hyapp.wallet.v1.ListVipPackagesResponse.packages:type_name -> hyapp.wallet.v1.VipLevel 194, // 95: hyapp.wallet.v1.ListVipPackagesResponse.current_vip:type_name -> hyapp.wallet.v1.UserVip
197, // 96: hyapp.wallet.v1.ListVipPackagesResponse.state:type_name -> hyapp.wallet.v1.VipState 193, // 96: hyapp.wallet.v1.ListVipPackagesResponse.packages:type_name -> hyapp.wallet.v1.VipLevel
191, // 97: hyapp.wallet.v1.ListVipPackagesResponse.program_config:type_name -> hyapp.wallet.v1.VipProgramConfig 197, // 97: hyapp.wallet.v1.ListVipPackagesResponse.state:type_name -> hyapp.wallet.v1.VipState
194, // 98: hyapp.wallet.v1.GetMyVipResponse.vip:type_name -> hyapp.wallet.v1.UserVip 191, // 98: hyapp.wallet.v1.ListVipPackagesResponse.program_config:type_name -> hyapp.wallet.v1.VipProgramConfig
197, // 99: hyapp.wallet.v1.GetMyVipResponse.state:type_name -> hyapp.wallet.v1.VipState 194, // 99: hyapp.wallet.v1.GetMyVipResponse.vip:type_name -> hyapp.wallet.v1.UserVip
194, // 100: hyapp.wallet.v1.PurchaseVipResponse.vip:type_name -> hyapp.wallet.v1.UserVip 197, // 100: hyapp.wallet.v1.GetMyVipResponse.state:type_name -> hyapp.wallet.v1.VipState
190, // 101: hyapp.wallet.v1.PurchaseVipResponse.reward_items:type_name -> hyapp.wallet.v1.VipRewardItem 194, // 101: hyapp.wallet.v1.PurchaseVipResponse.vip:type_name -> hyapp.wallet.v1.UserVip
197, // 102: hyapp.wallet.v1.PurchaseVipResponse.state:type_name -> hyapp.wallet.v1.VipState 190, // 102: hyapp.wallet.v1.PurchaseVipResponse.reward_items:type_name -> hyapp.wallet.v1.VipRewardItem
8, // 103: hyapp.wallet.v1.PurchaseVipResponse.coin_balance:type_name -> hyapp.wallet.v1.AssetBalance 197, // 103: hyapp.wallet.v1.PurchaseVipResponse.state:type_name -> hyapp.wallet.v1.VipState
8, // 104: hyapp.wallet.v1.DebitCPBreakupFeeResponse.balance:type_name -> hyapp.wallet.v1.AssetBalance 8, // 104: hyapp.wallet.v1.PurchaseVipResponse.coin_balance:type_name -> hyapp.wallet.v1.AssetBalance
8, // 105: hyapp.wallet.v1.DebitWheelDrawResponse.balance:type_name -> hyapp.wallet.v1.AssetBalance 8, // 105: hyapp.wallet.v1.DebitCPBreakupFeeResponse.balance:type_name -> hyapp.wallet.v1.AssetBalance
194, // 106: hyapp.wallet.v1.GrantVipResponse.vip:type_name -> hyapp.wallet.v1.UserVip 8, // 106: hyapp.wallet.v1.DebitWheelDrawResponse.balance:type_name -> hyapp.wallet.v1.AssetBalance
190, // 107: hyapp.wallet.v1.GrantVipResponse.reward_items:type_name -> hyapp.wallet.v1.VipRewardItem 194, // 107: hyapp.wallet.v1.GrantVipResponse.vip:type_name -> hyapp.wallet.v1.UserVip
197, // 108: hyapp.wallet.v1.GrantVipResponse.state:type_name -> hyapp.wallet.v1.VipState 190, // 108: hyapp.wallet.v1.GrantVipResponse.reward_items:type_name -> hyapp.wallet.v1.VipRewardItem
191, // 109: hyapp.wallet.v1.GetVipProgramConfigResponse.config:type_name -> hyapp.wallet.v1.VipProgramConfig 197, // 109: hyapp.wallet.v1.GrantVipResponse.state:type_name -> hyapp.wallet.v1.VipState
192, // 110: hyapp.wallet.v1.GetVipProgramConfigResponse.benefits:type_name -> hyapp.wallet.v1.VipBenefit 191, // 110: hyapp.wallet.v1.GetVipProgramConfigResponse.config:type_name -> hyapp.wallet.v1.VipProgramConfig
191, // 111: hyapp.wallet.v1.UpdateAdminVipProgramConfigRequest.config:type_name -> hyapp.wallet.v1.VipProgramConfig 192, // 111: hyapp.wallet.v1.GetVipProgramConfigResponse.benefits:type_name -> hyapp.wallet.v1.VipBenefit
191, // 112: hyapp.wallet.v1.UpdateAdminVipProgramConfigResponse.config:type_name -> hyapp.wallet.v1.VipProgramConfig 191, // 112: hyapp.wallet.v1.UpdateAdminVipProgramConfigRequest.config:type_name -> hyapp.wallet.v1.VipProgramConfig
196, // 113: hyapp.wallet.v1.UpdateMyVipSettingsResponse.settings:type_name -> hyapp.wallet.v1.VipUserSettings 191, // 113: hyapp.wallet.v1.UpdateAdminVipProgramConfigResponse.config:type_name -> hyapp.wallet.v1.VipProgramConfig
216, // 114: hyapp.wallet.v1.ProcessVipDailyCoinRebateBatchResponse.run:type_name -> hyapp.wallet.v1.VipDailyCoinRebateRun 196, // 114: hyapp.wallet.v1.UpdateMyVipSettingsResponse.settings:type_name -> hyapp.wallet.v1.VipUserSettings
217, // 115: hyapp.wallet.v1.GetMyCurrentVipDailyCoinRebateResponse.rebate:type_name -> hyapp.wallet.v1.VipDailyCoinRebate 216, // 115: hyapp.wallet.v1.ProcessVipDailyCoinRebateBatchResponse.run:type_name -> hyapp.wallet.v1.VipDailyCoinRebateRun
217, // 116: hyapp.wallet.v1.ListMyVipDailyCoinRebateStatusesResponse.rebates:type_name -> hyapp.wallet.v1.VipDailyCoinRebate 217, // 116: hyapp.wallet.v1.GetMyCurrentVipDailyCoinRebateResponse.rebate:type_name -> hyapp.wallet.v1.VipDailyCoinRebate
217, // 117: hyapp.wallet.v1.ClaimVipDailyCoinRebateResponse.rebate:type_name -> hyapp.wallet.v1.VipDailyCoinRebate 217, // 117: hyapp.wallet.v1.ListMyVipDailyCoinRebateStatusesResponse.rebates:type_name -> hyapp.wallet.v1.VipDailyCoinRebate
8, // 118: hyapp.wallet.v1.ClaimVipDailyCoinRebateResponse.coin_balance:type_name -> hyapp.wallet.v1.AssetBalance 217, // 118: hyapp.wallet.v1.ClaimVipDailyCoinRebateResponse.rebate:type_name -> hyapp.wallet.v1.VipDailyCoinRebate
195, // 119: hyapp.wallet.v1.GrantVipTrialCardResponse.trial_card:type_name -> hyapp.wallet.v1.VipTrialCard 8, // 119: hyapp.wallet.v1.ClaimVipDailyCoinRebateResponse.coin_balance:type_name -> hyapp.wallet.v1.AssetBalance
197, // 120: hyapp.wallet.v1.GrantVipTrialCardResponse.state:type_name -> hyapp.wallet.v1.VipState 195, // 120: hyapp.wallet.v1.GrantVipTrialCardResponse.trial_card:type_name -> hyapp.wallet.v1.VipTrialCard
195, // 121: hyapp.wallet.v1.EquipVipTrialCardResponse.trial_card:type_name -> hyapp.wallet.v1.VipTrialCard 197, // 121: hyapp.wallet.v1.GrantVipTrialCardResponse.state:type_name -> hyapp.wallet.v1.VipState
197, // 122: hyapp.wallet.v1.EquipVipTrialCardResponse.state:type_name -> hyapp.wallet.v1.VipState 195, // 122: hyapp.wallet.v1.EquipVipTrialCardResponse.trial_card:type_name -> hyapp.wallet.v1.VipTrialCard
197, // 123: hyapp.wallet.v1.UnequipVipTrialCardResponse.state:type_name -> hyapp.wallet.v1.VipState 197, // 123: hyapp.wallet.v1.EquipVipTrialCardResponse.state:type_name -> hyapp.wallet.v1.VipState
192, // 124: hyapp.wallet.v1.CheckVipBenefitResponse.benefit:type_name -> hyapp.wallet.v1.VipBenefit 197, // 124: hyapp.wallet.v1.UnequipVipTrialCardResponse.state:type_name -> hyapp.wallet.v1.VipState
197, // 125: hyapp.wallet.v1.CheckVipBenefitResponse.state:type_name -> hyapp.wallet.v1.VipState 192, // 125: hyapp.wallet.v1.CheckVipBenefitResponse.benefit:type_name -> hyapp.wallet.v1.VipBenefit
192, // 126: hyapp.wallet.v1.AdminVipLevelInput.benefits:type_name -> hyapp.wallet.v1.VipBenefit 197, // 126: hyapp.wallet.v1.CheckVipBenefitResponse.state:type_name -> hyapp.wallet.v1.VipState
193, // 127: hyapp.wallet.v1.ListAdminVipLevelsResponse.levels:type_name -> hyapp.wallet.v1.VipLevel 192, // 127: hyapp.wallet.v1.AdminVipLevelInput.benefits:type_name -> hyapp.wallet.v1.VipBenefit
191, // 128: hyapp.wallet.v1.ListAdminVipLevelsResponse.program_config:type_name -> hyapp.wallet.v1.VipProgramConfig 193, // 128: hyapp.wallet.v1.ListAdminVipLevelsResponse.levels:type_name -> hyapp.wallet.v1.VipLevel
234, // 129: hyapp.wallet.v1.UpdateAdminVipLevelsRequest.levels:type_name -> hyapp.wallet.v1.AdminVipLevelInput 191, // 129: hyapp.wallet.v1.ListAdminVipLevelsResponse.program_config:type_name -> hyapp.wallet.v1.VipProgramConfig
193, // 130: hyapp.wallet.v1.UpdateAdminVipLevelsResponse.levels:type_name -> hyapp.wallet.v1.VipLevel 234, // 130: hyapp.wallet.v1.UpdateAdminVipLevelsRequest.levels:type_name -> hyapp.wallet.v1.AdminVipLevelInput
191, // 131: hyapp.wallet.v1.UpdateAdminVipLevelsResponse.program_config:type_name -> hyapp.wallet.v1.VipProgramConfig 193, // 131: hyapp.wallet.v1.UpdateAdminVipLevelsResponse.levels:type_name -> hyapp.wallet.v1.VipLevel
8, // 132: hyapp.wallet.v1.CreditTaskRewardResponse.balance:type_name -> hyapp.wallet.v1.AssetBalance 191, // 132: hyapp.wallet.v1.UpdateAdminVipLevelsResponse.program_config:type_name -> hyapp.wallet.v1.VipProgramConfig
8, // 133: hyapp.wallet.v1.CreditLuckyGiftRewardResponse.balance:type_name -> hyapp.wallet.v1.AssetBalance 8, // 133: hyapp.wallet.v1.CreditTaskRewardResponse.balance:type_name -> hyapp.wallet.v1.AssetBalance
8, // 134: hyapp.wallet.v1.CreditWheelRewardResponse.balance:type_name -> hyapp.wallet.v1.AssetBalance 8, // 134: hyapp.wallet.v1.CreditLuckyGiftRewardResponse.balance:type_name -> hyapp.wallet.v1.AssetBalance
8, // 135: hyapp.wallet.v1.CreditRoomTurnoverRewardResponse.balance:type_name -> hyapp.wallet.v1.AssetBalance 8, // 135: hyapp.wallet.v1.CreditWheelRewardResponse.balance:type_name -> hyapp.wallet.v1.AssetBalance
8, // 136: hyapp.wallet.v1.CreditInviteActivityRewardResponse.balance:type_name -> hyapp.wallet.v1.AssetBalance 8, // 136: hyapp.wallet.v1.CreditRoomTurnoverRewardResponse.balance:type_name -> hyapp.wallet.v1.AssetBalance
8, // 137: hyapp.wallet.v1.CreditAgencyOpeningRewardResponse.balance:type_name -> hyapp.wallet.v1.AssetBalance 8, // 137: hyapp.wallet.v1.CreditInviteActivityRewardResponse.balance:type_name -> hyapp.wallet.v1.AssetBalance
254, // 138: hyapp.wallet.v1.RedPacket.claims:type_name -> hyapp.wallet.v1.RedPacketClaim 8, // 138: hyapp.wallet.v1.CreditAgencyOpeningRewardResponse.balance:type_name -> hyapp.wallet.v1.AssetBalance
253, // 139: hyapp.wallet.v1.GetRedPacketConfigResponse.config:type_name -> hyapp.wallet.v1.RedPacketConfig 254, // 139: hyapp.wallet.v1.RedPacket.claims:type_name -> hyapp.wallet.v1.RedPacketClaim
253, // 140: hyapp.wallet.v1.UpdateRedPacketConfigResponse.config:type_name -> hyapp.wallet.v1.RedPacketConfig 253, // 140: hyapp.wallet.v1.GetRedPacketConfigResponse.config:type_name -> hyapp.wallet.v1.RedPacketConfig
255, // 141: hyapp.wallet.v1.CreateRedPacketResponse.packet:type_name -> hyapp.wallet.v1.RedPacket 253, // 141: hyapp.wallet.v1.UpdateRedPacketConfigResponse.config:type_name -> hyapp.wallet.v1.RedPacketConfig
254, // 142: hyapp.wallet.v1.ClaimRedPacketResponse.claim:type_name -> hyapp.wallet.v1.RedPacketClaim 255, // 142: hyapp.wallet.v1.CreateRedPacketResponse.packet:type_name -> hyapp.wallet.v1.RedPacket
255, // 143: hyapp.wallet.v1.ClaimRedPacketResponse.packet:type_name -> hyapp.wallet.v1.RedPacket 254, // 143: hyapp.wallet.v1.ClaimRedPacketResponse.claim:type_name -> hyapp.wallet.v1.RedPacketClaim
255, // 144: hyapp.wallet.v1.ListRedPacketsResponse.packets:type_name -> hyapp.wallet.v1.RedPacket 255, // 144: hyapp.wallet.v1.ClaimRedPacketResponse.packet:type_name -> hyapp.wallet.v1.RedPacket
255, // 145: hyapp.wallet.v1.GetRedPacketResponse.packet:type_name -> hyapp.wallet.v1.RedPacket 255, // 145: hyapp.wallet.v1.ListRedPacketsResponse.packets:type_name -> hyapp.wallet.v1.RedPacket
255, // 146: hyapp.wallet.v1.RetryRedPacketRefundResponse.packet:type_name -> hyapp.wallet.v1.RedPacket 255, // 146: hyapp.wallet.v1.GetRedPacketResponse.packet:type_name -> hyapp.wallet.v1.RedPacket
48, // 147: hyapp.wallet.v1.ResourceShopPurchaseOrder.resource:type_name -> hyapp.wallet.v1.Resource 255, // 147: hyapp.wallet.v1.RetryRedPacketRefundResponse.packet:type_name -> hyapp.wallet.v1.RedPacket
274, // 148: hyapp.wallet.v1.ListResourceShopPurchaseOrdersResponse.orders:type_name -> hyapp.wallet.v1.ResourceShopPurchaseOrder 48, // 148: hyapp.wallet.v1.ResourceShopPurchaseOrder.resource:type_name -> hyapp.wallet.v1.Resource
279, // 149: hyapp.wallet.v1.GetHostRevenueStatsResponse.stats:type_name -> hyapp.wallet.v1.HostRevenueStats 274, // 149: hyapp.wallet.v1.ListResourceShopPurchaseOrdersResponse.orders:type_name -> hyapp.wallet.v1.ResourceShopPurchaseOrder
282, // 150: hyapp.wallet.v1.GetAgencyPointShareStatsResponse.stats:type_name -> hyapp.wallet.v1.AgencyPointShareStats 279, // 150: hyapp.wallet.v1.GetHostRevenueStatsResponse.stats:type_name -> hyapp.wallet.v1.HostRevenueStats
285, // 151: hyapp.wallet.v1.GetAgencyHostGiftStatsResponse.stats:type_name -> hyapp.wallet.v1.AgencyHostGiftStats 282, // 151: hyapp.wallet.v1.GetAgencyPointShareStatsResponse.stats:type_name -> hyapp.wallet.v1.AgencyPointShareStats
288, // 152: hyapp.wallet.v1.ListPointWithdrawalCoinSellersResponse.sellers:type_name -> hyapp.wallet.v1.PointWithdrawalCoinSellerConfig 285, // 152: hyapp.wallet.v1.GetAgencyHostGiftStatsResponse.stats:type_name -> hyapp.wallet.v1.AgencyHostGiftStats
295, // 153: hyapp.wallet.v1.VipBenefitPresentation.preview_items:type_name -> hyapp.wallet.v1.VipBenefitPreviewItem 288, // 153: hyapp.wallet.v1.ListPointWithdrawalCoinSellersResponse.sellers:type_name -> hyapp.wallet.v1.PointWithdrawalCoinSellerConfig
296, // 154: hyapp.wallet.v1.VipBenefitPresentation.numeric_reward:type_name -> hyapp.wallet.v1.VipBenefitNumericReward 295, // 154: hyapp.wallet.v1.VipBenefitPresentation.preview_items:type_name -> hyapp.wallet.v1.VipBenefitPreviewItem
57, // 155: hyapp.wallet.v1.RevokeUserResourceResponse.resource:type_name -> hyapp.wallet.v1.UserResourceEntitlement 296, // 155: hyapp.wallet.v1.VipBenefitPresentation.numeric_reward:type_name -> hyapp.wallet.v1.VipBenefitNumericReward
272, // 156: hyapp.wallet.v1.WalletCronService.ProcessHostSalaryDailySettlementBatch:input_type -> hyapp.wallet.v1.CronBatchRequest 57, // 156: hyapp.wallet.v1.RevokeUserResourceResponse.resource:type_name -> hyapp.wallet.v1.UserResourceEntitlement
272, // 157: hyapp.wallet.v1.WalletCronService.ProcessHostSalaryHalfMonthSettlementBatch:input_type -> hyapp.wallet.v1.CronBatchRequest 272, // 157: hyapp.wallet.v1.WalletCronService.ProcessHostSalaryDailySettlementBatch:input_type -> hyapp.wallet.v1.CronBatchRequest
272, // 158: hyapp.wallet.v1.WalletCronService.ProcessHostSalaryMonthEndBatch:input_type -> hyapp.wallet.v1.CronBatchRequest 272, // 158: hyapp.wallet.v1.WalletCronService.ProcessHostSalaryHalfMonthSettlementBatch:input_type -> hyapp.wallet.v1.CronBatchRequest
218, // 159: hyapp.wallet.v1.WalletCronService.ProcessVipDailyCoinRebateBatch:input_type -> hyapp.wallet.v1.ProcessVipDailyCoinRebateBatchRequest 272, // 159: hyapp.wallet.v1.WalletCronService.ProcessHostSalaryMonthEndBatch:input_type -> hyapp.wallet.v1.CronBatchRequest
0, // 160: hyapp.wallet.v1.WalletService.DebitGift:input_type -> hyapp.wallet.v1.DebitGiftRequest 218, // 160: hyapp.wallet.v1.WalletCronService.ProcessVipDailyCoinRebateBatch:input_type -> hyapp.wallet.v1.ProcessVipDailyCoinRebateBatchRequest
1, // 161: hyapp.wallet.v1.WalletService.DebitDirectGift:input_type -> hyapp.wallet.v1.DebitDirectGiftRequest 0, // 161: hyapp.wallet.v1.WalletService.DebitGift:input_type -> hyapp.wallet.v1.DebitGiftRequest
4, // 162: hyapp.wallet.v1.WalletService.BatchDebitGift:input_type -> hyapp.wallet.v1.BatchDebitGiftRequest 1, // 162: hyapp.wallet.v1.WalletService.DebitDirectGift:input_type -> hyapp.wallet.v1.DebitDirectGiftRequest
7, // 163: hyapp.wallet.v1.WalletService.DebitRobotGift:input_type -> hyapp.wallet.v1.DebitRobotGiftRequest 4, // 163: hyapp.wallet.v1.WalletService.BatchDebitGift:input_type -> hyapp.wallet.v1.BatchDebitGiftRequest
9, // 164: hyapp.wallet.v1.WalletService.GetBalances:input_type -> hyapp.wallet.v1.GetBalancesRequest 7, // 164: hyapp.wallet.v1.WalletService.DebitRobotGift:input_type -> hyapp.wallet.v1.DebitRobotGiftRequest
13, // 165: hyapp.wallet.v1.WalletService.GetActiveHostSalaryPolicy:input_type -> hyapp.wallet.v1.GetActiveHostSalaryPolicyRequest 9, // 165: hyapp.wallet.v1.WalletService.GetBalances:input_type -> hyapp.wallet.v1.GetBalancesRequest
16, // 166: hyapp.wallet.v1.WalletService.GetHostSalaryProgress:input_type -> hyapp.wallet.v1.GetHostSalaryProgressRequest 13, // 166: hyapp.wallet.v1.WalletService.GetActiveHostSalaryPolicy:input_type -> hyapp.wallet.v1.GetActiveHostSalaryPolicyRequest
280, // 167: hyapp.wallet.v1.WalletService.GetHostRevenueStats:input_type -> hyapp.wallet.v1.GetHostRevenueStatsRequest 16, // 167: hyapp.wallet.v1.WalletService.GetHostSalaryProgress:input_type -> hyapp.wallet.v1.GetHostSalaryProgressRequest
283, // 168: hyapp.wallet.v1.WalletService.GetAgencyPointShareStats:input_type -> hyapp.wallet.v1.GetAgencyPointShareStatsRequest 280, // 168: hyapp.wallet.v1.WalletService.GetHostRevenueStats:input_type -> hyapp.wallet.v1.GetHostRevenueStatsRequest
286, // 169: hyapp.wallet.v1.WalletService.GetAgencyHostGiftStats:input_type -> hyapp.wallet.v1.GetAgencyHostGiftStatsRequest 283, // 169: hyapp.wallet.v1.WalletService.GetAgencyPointShareStats:input_type -> hyapp.wallet.v1.GetAgencyPointShareStatsRequest
19, // 170: hyapp.wallet.v1.WalletService.GetTeamHostSalaryStats:input_type -> hyapp.wallet.v1.GetTeamHostSalaryStatsRequest 286, // 170: hyapp.wallet.v1.WalletService.GetAgencyHostGiftStats:input_type -> hyapp.wallet.v1.GetAgencyHostGiftStatsRequest
21, // 171: hyapp.wallet.v1.WalletService.AdminCreditAsset:input_type -> hyapp.wallet.v1.AdminCreditAssetRequest 19, // 171: hyapp.wallet.v1.WalletService.GetTeamHostSalaryStats:input_type -> hyapp.wallet.v1.GetTeamHostSalaryStatsRequest
23, // 172: hyapp.wallet.v1.WalletService.AdminCreditCoinSellerStock:input_type -> hyapp.wallet.v1.AdminCreditCoinSellerStockRequest 21, // 172: hyapp.wallet.v1.WalletService.AdminCreditAsset:input_type -> hyapp.wallet.v1.AdminCreditAssetRequest
25, // 173: hyapp.wallet.v1.WalletService.TransferCoinFromSeller:input_type -> hyapp.wallet.v1.TransferCoinFromSellerRequest 23, // 173: hyapp.wallet.v1.WalletService.AdminCreditCoinSellerStock:input_type -> hyapp.wallet.v1.AdminCreditCoinSellerStockRequest
27, // 174: hyapp.wallet.v1.WalletService.TransferCoinSellerStockToChild:input_type -> hyapp.wallet.v1.TransferCoinSellerStockToChildRequest 25, // 174: hyapp.wallet.v1.WalletService.TransferCoinFromSeller:input_type -> hyapp.wallet.v1.TransferCoinFromSellerRequest
30, // 175: hyapp.wallet.v1.WalletService.ListCoinSellerSalaryExchangeRateTiers:input_type -> hyapp.wallet.v1.ListCoinSellerSalaryExchangeRateTiersRequest 27, // 175: hyapp.wallet.v1.WalletService.TransferCoinSellerStockToChild:input_type -> hyapp.wallet.v1.TransferCoinSellerStockToChildRequest
32, // 176: hyapp.wallet.v1.WalletService.ExchangeSalaryToCoin:input_type -> hyapp.wallet.v1.ExchangeSalaryToCoinRequest 30, // 176: hyapp.wallet.v1.WalletService.ListCoinSellerSalaryExchangeRateTiers:input_type -> hyapp.wallet.v1.ListCoinSellerSalaryExchangeRateTiersRequest
34, // 177: hyapp.wallet.v1.WalletService.TransferSalaryToCoinSeller:input_type -> hyapp.wallet.v1.TransferSalaryToCoinSellerRequest 32, // 177: hyapp.wallet.v1.WalletService.ExchangeSalaryToCoin:input_type -> hyapp.wallet.v1.ExchangeSalaryToCoinRequest
289, // 178: hyapp.wallet.v1.WalletService.ListPointWithdrawalCoinSellers:input_type -> hyapp.wallet.v1.ListPointWithdrawalCoinSellersRequest 34, // 178: hyapp.wallet.v1.WalletService.TransferSalaryToCoinSeller:input_type -> hyapp.wallet.v1.TransferSalaryToCoinSellerRequest
291, // 179: hyapp.wallet.v1.WalletService.TransferPointToCoinSeller:input_type -> hyapp.wallet.v1.TransferPointToCoinSellerRequest 289, // 179: hyapp.wallet.v1.WalletService.ListPointWithdrawalCoinSellers:input_type -> hyapp.wallet.v1.ListPointWithdrawalCoinSellersRequest
293, // 180: hyapp.wallet.v1.WalletService.GetPointWithdrawalConfig:input_type -> hyapp.wallet.v1.GetPointWithdrawalConfigRequest 291, // 180: hyapp.wallet.v1.WalletService.TransferPointToCoinSeller:input_type -> hyapp.wallet.v1.TransferPointToCoinSellerRequest
36, // 181: hyapp.wallet.v1.WalletService.FreezeSalaryWithdrawal:input_type -> hyapp.wallet.v1.FreezeSalaryWithdrawalRequest 293, // 181: hyapp.wallet.v1.WalletService.GetPointWithdrawalConfig:input_type -> hyapp.wallet.v1.GetPointWithdrawalConfigRequest
38, // 182: hyapp.wallet.v1.WalletService.SettleSalaryWithdrawal:input_type -> hyapp.wallet.v1.SettleSalaryWithdrawalRequest 36, // 182: hyapp.wallet.v1.WalletService.FreezeSalaryWithdrawal:input_type -> hyapp.wallet.v1.FreezeSalaryWithdrawalRequest
40, // 183: hyapp.wallet.v1.WalletService.ReleaseSalaryWithdrawal:input_type -> hyapp.wallet.v1.ReleaseSalaryWithdrawalRequest 38, // 183: hyapp.wallet.v1.WalletService.SettleSalaryWithdrawal:input_type -> hyapp.wallet.v1.SettleSalaryWithdrawalRequest
42, // 184: hyapp.wallet.v1.WalletService.FreezePointWithdrawal:input_type -> hyapp.wallet.v1.FreezePointWithdrawalRequest 40, // 184: hyapp.wallet.v1.WalletService.ReleaseSalaryWithdrawal:input_type -> hyapp.wallet.v1.ReleaseSalaryWithdrawalRequest
44, // 185: hyapp.wallet.v1.WalletService.SettlePointWithdrawal:input_type -> hyapp.wallet.v1.SettlePointWithdrawalRequest 42, // 185: hyapp.wallet.v1.WalletService.FreezePointWithdrawal:input_type -> hyapp.wallet.v1.FreezePointWithdrawalRequest
46, // 186: hyapp.wallet.v1.WalletService.ReleasePointWithdrawal:input_type -> hyapp.wallet.v1.ReleasePointWithdrawalRequest 44, // 186: hyapp.wallet.v1.WalletService.SettlePointWithdrawal:input_type -> hyapp.wallet.v1.SettlePointWithdrawalRequest
61, // 187: hyapp.wallet.v1.WalletService.ListResources:input_type -> hyapp.wallet.v1.ListResourcesRequest 46, // 187: hyapp.wallet.v1.WalletService.ReleasePointWithdrawal:input_type -> hyapp.wallet.v1.ReleasePointWithdrawalRequest
63, // 188: hyapp.wallet.v1.WalletService.GetResource:input_type -> hyapp.wallet.v1.GetResourceRequest 61, // 188: hyapp.wallet.v1.WalletService.ListResources:input_type -> hyapp.wallet.v1.ListResourcesRequest
65, // 189: hyapp.wallet.v1.WalletService.CreateResource:input_type -> hyapp.wallet.v1.CreateResourceRequest 63, // 189: hyapp.wallet.v1.WalletService.GetResource:input_type -> hyapp.wallet.v1.GetResourceRequest
66, // 190: hyapp.wallet.v1.WalletService.UpdateResource:input_type -> hyapp.wallet.v1.UpdateResourceRequest 65, // 190: hyapp.wallet.v1.WalletService.CreateResource:input_type -> hyapp.wallet.v1.CreateResourceRequest
67, // 191: hyapp.wallet.v1.WalletService.SetResourceStatus:input_type -> hyapp.wallet.v1.SetResourceStatusRequest 66, // 191: hyapp.wallet.v1.WalletService.UpdateResource:input_type -> hyapp.wallet.v1.UpdateResourceRequest
68, // 192: hyapp.wallet.v1.WalletService.DeleteResource:input_type -> hyapp.wallet.v1.DeleteResourceRequest 67, // 192: hyapp.wallet.v1.WalletService.SetResourceStatus:input_type -> hyapp.wallet.v1.SetResourceStatusRequest
69, // 193: hyapp.wallet.v1.WalletService.BatchDeleteResources:input_type -> hyapp.wallet.v1.BatchDeleteResourcesRequest 68, // 193: hyapp.wallet.v1.WalletService.DeleteResource:input_type -> hyapp.wallet.v1.DeleteResourceRequest
72, // 194: hyapp.wallet.v1.WalletService.ListResourceGroups:input_type -> hyapp.wallet.v1.ListResourceGroupsRequest 69, // 194: hyapp.wallet.v1.WalletService.BatchDeleteResources:input_type -> hyapp.wallet.v1.BatchDeleteResourcesRequest
74, // 195: hyapp.wallet.v1.WalletService.GetResourceGroup:input_type -> hyapp.wallet.v1.GetResourceGroupRequest 72, // 195: hyapp.wallet.v1.WalletService.ListResourceGroups:input_type -> hyapp.wallet.v1.ListResourceGroupsRequest
76, // 196: hyapp.wallet.v1.WalletService.CreateResourceGroup:input_type -> hyapp.wallet.v1.CreateResourceGroupRequest 74, // 196: hyapp.wallet.v1.WalletService.GetResourceGroup:input_type -> hyapp.wallet.v1.GetResourceGroupRequest
77, // 197: hyapp.wallet.v1.WalletService.UpdateResourceGroup:input_type -> hyapp.wallet.v1.UpdateResourceGroupRequest 76, // 197: hyapp.wallet.v1.WalletService.CreateResourceGroup:input_type -> hyapp.wallet.v1.CreateResourceGroupRequest
78, // 198: hyapp.wallet.v1.WalletService.SetResourceGroupStatus:input_type -> hyapp.wallet.v1.SetResourceGroupStatusRequest 77, // 198: hyapp.wallet.v1.WalletService.UpdateResourceGroup:input_type -> hyapp.wallet.v1.UpdateResourceGroupRequest
52, // 199: hyapp.wallet.v1.WalletService.PinResourceGroupSnapshot:input_type -> hyapp.wallet.v1.PinResourceGroupSnapshotRequest 78, // 199: hyapp.wallet.v1.WalletService.SetResourceGroupStatus:input_type -> hyapp.wallet.v1.SetResourceGroupStatusRequest
80, // 200: hyapp.wallet.v1.WalletService.ListGiftConfigs:input_type -> hyapp.wallet.v1.ListGiftConfigsRequest 52, // 200: hyapp.wallet.v1.WalletService.PinResourceGroupSnapshot:input_type -> hyapp.wallet.v1.PinResourceGroupSnapshotRequest
82, // 201: hyapp.wallet.v1.WalletService.ListGiftTypeConfigs:input_type -> hyapp.wallet.v1.ListGiftTypeConfigsRequest 80, // 201: hyapp.wallet.v1.WalletService.ListGiftConfigs:input_type -> hyapp.wallet.v1.ListGiftConfigsRequest
277, // 202: hyapp.wallet.v1.WalletService.GetGiftCatalogVersion:input_type -> hyapp.wallet.v1.GetGiftCatalogVersionRequest 82, // 202: hyapp.wallet.v1.WalletService.ListGiftTypeConfigs:input_type -> hyapp.wallet.v1.ListGiftTypeConfigsRequest
86, // 203: hyapp.wallet.v1.WalletService.CreateGiftConfig:input_type -> hyapp.wallet.v1.CreateGiftConfigRequest 277, // 203: hyapp.wallet.v1.WalletService.GetGiftCatalogVersion:input_type -> hyapp.wallet.v1.GetGiftCatalogVersionRequest
87, // 204: hyapp.wallet.v1.WalletService.BatchCreateGiftConfigs:input_type -> hyapp.wallet.v1.BatchCreateGiftConfigsRequest 86, // 204: hyapp.wallet.v1.WalletService.CreateGiftConfig:input_type -> hyapp.wallet.v1.CreateGiftConfigRequest
89, // 205: hyapp.wallet.v1.WalletService.UpdateGiftConfig:input_type -> hyapp.wallet.v1.UpdateGiftConfigRequest 87, // 205: hyapp.wallet.v1.WalletService.BatchCreateGiftConfigs:input_type -> hyapp.wallet.v1.BatchCreateGiftConfigsRequest
90, // 206: hyapp.wallet.v1.WalletService.SetGiftConfigStatus:input_type -> hyapp.wallet.v1.SetGiftConfigStatusRequest 89, // 206: hyapp.wallet.v1.WalletService.UpdateGiftConfig:input_type -> hyapp.wallet.v1.UpdateGiftConfigRequest
91, // 207: hyapp.wallet.v1.WalletService.DeleteGiftConfig:input_type -> hyapp.wallet.v1.DeleteGiftConfigRequest 90, // 207: hyapp.wallet.v1.WalletService.SetGiftConfigStatus:input_type -> hyapp.wallet.v1.SetGiftConfigStatusRequest
84, // 208: hyapp.wallet.v1.WalletService.UpsertGiftTypeConfig:input_type -> hyapp.wallet.v1.UpsertGiftTypeConfigRequest 91, // 208: hyapp.wallet.v1.WalletService.DeleteGiftConfig:input_type -> hyapp.wallet.v1.DeleteGiftConfigRequest
93, // 209: hyapp.wallet.v1.WalletService.GrantResource:input_type -> hyapp.wallet.v1.GrantResourceRequest 84, // 209: hyapp.wallet.v1.WalletService.UpsertGiftTypeConfig:input_type -> hyapp.wallet.v1.UpsertGiftTypeConfigRequest
94, // 210: hyapp.wallet.v1.WalletService.GrantResourceGroup:input_type -> hyapp.wallet.v1.GrantResourceGroupRequest 93, // 210: hyapp.wallet.v1.WalletService.GrantResource:input_type -> hyapp.wallet.v1.GrantResourceRequest
95, // 211: hyapp.wallet.v1.WalletService.GrantPinnedResourceGroup:input_type -> hyapp.wallet.v1.GrantPinnedResourceGroupRequest 94, // 211: hyapp.wallet.v1.WalletService.GrantResourceGroup:input_type -> hyapp.wallet.v1.GrantResourceGroupRequest
96, // 212: hyapp.wallet.v1.WalletService.RevokeResourceGrant:input_type -> hyapp.wallet.v1.RevokeResourceGrantRequest 95, // 212: hyapp.wallet.v1.WalletService.GrantPinnedResourceGroup:input_type -> hyapp.wallet.v1.GrantPinnedResourceGroupRequest
298, // 213: hyapp.wallet.v1.WalletService.RevokeUserResource:input_type -> hyapp.wallet.v1.RevokeUserResourceRequest 96, // 213: hyapp.wallet.v1.WalletService.RevokeResourceGrant:input_type -> hyapp.wallet.v1.RevokeResourceGrantRequest
98, // 214: hyapp.wallet.v1.WalletService.ListUserResources:input_type -> hyapp.wallet.v1.ListUserResourcesRequest 298, // 214: hyapp.wallet.v1.WalletService.RevokeUserResource:input_type -> hyapp.wallet.v1.RevokeUserResourceRequest
100, // 215: hyapp.wallet.v1.WalletService.EquipUserResource:input_type -> hyapp.wallet.v1.EquipUserResourceRequest 98, // 215: hyapp.wallet.v1.WalletService.ListUserResources:input_type -> hyapp.wallet.v1.ListUserResourcesRequest
102, // 216: hyapp.wallet.v1.WalletService.UnequipUserResource:input_type -> hyapp.wallet.v1.UnequipUserResourceRequest 100, // 216: hyapp.wallet.v1.WalletService.EquipUserResource:input_type -> hyapp.wallet.v1.EquipUserResourceRequest
104, // 217: hyapp.wallet.v1.WalletService.BatchGetUserEquippedResources:input_type -> hyapp.wallet.v1.BatchGetUserEquippedResourcesRequest 102, // 217: hyapp.wallet.v1.WalletService.UnequipUserResource:input_type -> hyapp.wallet.v1.UnequipUserResourceRequest
107, // 218: hyapp.wallet.v1.WalletService.ListResourceGrants:input_type -> hyapp.wallet.v1.ListResourceGrantsRequest 104, // 218: hyapp.wallet.v1.WalletService.BatchGetUserEquippedResources:input_type -> hyapp.wallet.v1.BatchGetUserEquippedResourcesRequest
110, // 219: hyapp.wallet.v1.WalletService.ListResourceShopItems:input_type -> hyapp.wallet.v1.ListResourceShopItemsRequest 107, // 219: hyapp.wallet.v1.WalletService.ListResourceGrants:input_type -> hyapp.wallet.v1.ListResourceGrantsRequest
112, // 220: hyapp.wallet.v1.WalletService.UpsertResourceShopItems:input_type -> hyapp.wallet.v1.UpsertResourceShopItemsRequest 110, // 220: hyapp.wallet.v1.WalletService.ListResourceShopItems:input_type -> hyapp.wallet.v1.ListResourceShopItemsRequest
114, // 221: hyapp.wallet.v1.WalletService.SetResourceShopItemStatus:input_type -> hyapp.wallet.v1.SetResourceShopItemStatusRequest 112, // 221: hyapp.wallet.v1.WalletService.UpsertResourceShopItems:input_type -> hyapp.wallet.v1.UpsertResourceShopItemsRequest
275, // 222: hyapp.wallet.v1.WalletService.ListResourceShopPurchaseOrders:input_type -> hyapp.wallet.v1.ListResourceShopPurchaseOrdersRequest 114, // 222: hyapp.wallet.v1.WalletService.SetResourceShopItemStatus:input_type -> hyapp.wallet.v1.SetResourceShopItemStatusRequest
116, // 223: hyapp.wallet.v1.WalletService.PurchaseResourceShopItem:input_type -> hyapp.wallet.v1.PurchaseResourceShopItemRequest 275, // 223: hyapp.wallet.v1.WalletService.ListResourceShopPurchaseOrders:input_type -> hyapp.wallet.v1.ListResourceShopPurchaseOrdersRequest
119, // 224: hyapp.wallet.v1.WalletService.ListRechargeBills:input_type -> hyapp.wallet.v1.ListRechargeBillsRequest 116, // 224: hyapp.wallet.v1.WalletService.PurchaseResourceShopItem:input_type -> hyapp.wallet.v1.PurchaseResourceShopItemRequest
121, // 225: hyapp.wallet.v1.WalletService.GetRechargeBillSummary:input_type -> hyapp.wallet.v1.GetRechargeBillSummaryRequest 119, // 225: hyapp.wallet.v1.WalletService.ListRechargeBills:input_type -> hyapp.wallet.v1.ListRechargeBillsRequest
125, // 226: hyapp.wallet.v1.WalletService.BatchGetUserRechargeStats:input_type -> hyapp.wallet.v1.BatchGetUserRechargeStatsRequest 121, // 226: hyapp.wallet.v1.WalletService.GetRechargeBillSummary:input_type -> hyapp.wallet.v1.GetRechargeBillSummaryRequest
127, // 227: hyapp.wallet.v1.WalletService.GetRechargeBillOverview:input_type -> hyapp.wallet.v1.GetRechargeBillOverviewRequest 125, // 227: hyapp.wallet.v1.WalletService.BatchGetUserRechargeStats:input_type -> hyapp.wallet.v1.BatchGetUserRechargeStatsRequest
133, // 228: hyapp.wallet.v1.WalletService.RefreshGooglePaymentPrices:input_type -> hyapp.wallet.v1.RefreshGooglePaymentPricesRequest 127, // 228: hyapp.wallet.v1.WalletService.GetRechargeBillOverview:input_type -> hyapp.wallet.v1.GetRechargeBillOverviewRequest
137, // 229: hyapp.wallet.v1.WalletService.GetWalletOverview:input_type -> hyapp.wallet.v1.GetWalletOverviewRequest 133, // 229: hyapp.wallet.v1.WalletService.RefreshGooglePaymentPrices:input_type -> hyapp.wallet.v1.RefreshGooglePaymentPricesRequest
140, // 230: hyapp.wallet.v1.WalletService.GetWalletValueSummary:input_type -> hyapp.wallet.v1.GetWalletValueSummaryRequest 137, // 230: hyapp.wallet.v1.WalletService.GetWalletOverview:input_type -> hyapp.wallet.v1.GetWalletOverviewRequest
143, // 231: hyapp.wallet.v1.WalletService.GetUserGiftWall:input_type -> hyapp.wallet.v1.GetUserGiftWallRequest 140, // 231: hyapp.wallet.v1.WalletService.GetWalletValueSummary:input_type -> hyapp.wallet.v1.GetWalletValueSummaryRequest
146, // 232: hyapp.wallet.v1.WalletService.ListRechargeProducts:input_type -> hyapp.wallet.v1.ListRechargeProductsRequest 143, // 232: hyapp.wallet.v1.WalletService.GetUserGiftWall:input_type -> hyapp.wallet.v1.GetUserGiftWallRequest
148, // 233: hyapp.wallet.v1.WalletService.ConfirmGooglePayment:input_type -> hyapp.wallet.v1.ConfirmGooglePaymentRequest 146, // 233: hyapp.wallet.v1.WalletService.ListRechargeProducts:input_type -> hyapp.wallet.v1.ListRechargeProductsRequest
150, // 234: hyapp.wallet.v1.WalletService.ListAdminRechargeProducts:input_type -> hyapp.wallet.v1.ListAdminRechargeProductsRequest 148, // 234: hyapp.wallet.v1.WalletService.ConfirmGooglePayment:input_type -> hyapp.wallet.v1.ConfirmGooglePaymentRequest
152, // 235: hyapp.wallet.v1.WalletService.CreateRechargeProduct:input_type -> hyapp.wallet.v1.CreateRechargeProductRequest 150, // 235: hyapp.wallet.v1.WalletService.ListAdminRechargeProducts:input_type -> hyapp.wallet.v1.ListAdminRechargeProductsRequest
153, // 236: hyapp.wallet.v1.WalletService.UpdateRechargeProduct:input_type -> hyapp.wallet.v1.UpdateRechargeProductRequest 152, // 236: hyapp.wallet.v1.WalletService.CreateRechargeProduct:input_type -> hyapp.wallet.v1.CreateRechargeProductRequest
154, // 237: hyapp.wallet.v1.WalletService.DeleteRechargeProduct:input_type -> hyapp.wallet.v1.DeleteRechargeProductRequest 153, // 237: hyapp.wallet.v1.WalletService.UpdateRechargeProduct:input_type -> hyapp.wallet.v1.UpdateRechargeProductRequest
159, // 238: hyapp.wallet.v1.WalletService.ListThirdPartyPaymentChannels:input_type -> hyapp.wallet.v1.ListThirdPartyPaymentChannelsRequest 154, // 238: hyapp.wallet.v1.WalletService.DeleteRechargeProduct:input_type -> hyapp.wallet.v1.DeleteRechargeProductRequest
161, // 239: hyapp.wallet.v1.WalletService.SetThirdPartyPaymentMethodStatus:input_type -> hyapp.wallet.v1.SetThirdPartyPaymentMethodStatusRequest 159, // 239: hyapp.wallet.v1.WalletService.ListThirdPartyPaymentChannels:input_type -> hyapp.wallet.v1.ListThirdPartyPaymentChannelsRequest
163, // 240: hyapp.wallet.v1.WalletService.UpdateThirdPartyPaymentRate:input_type -> hyapp.wallet.v1.UpdateThirdPartyPaymentRateRequest 161, // 240: hyapp.wallet.v1.WalletService.SetThirdPartyPaymentMethodStatus:input_type -> hyapp.wallet.v1.SetThirdPartyPaymentMethodStatusRequest
165, // 241: hyapp.wallet.v1.WalletService.SyncThirdPartyPaymentMethods:input_type -> hyapp.wallet.v1.SyncThirdPartyPaymentMethodsRequest 163, // 241: hyapp.wallet.v1.WalletService.UpdateThirdPartyPaymentRate:input_type -> hyapp.wallet.v1.UpdateThirdPartyPaymentRateRequest
167, // 242: hyapp.wallet.v1.WalletService.ListH5RechargeOptions:input_type -> hyapp.wallet.v1.H5RechargeOptionsRequest 165, // 242: hyapp.wallet.v1.WalletService.SyncThirdPartyPaymentMethods:input_type -> hyapp.wallet.v1.SyncThirdPartyPaymentMethodsRequest
170, // 243: hyapp.wallet.v1.WalletService.CreateH5RechargeOrder:input_type -> hyapp.wallet.v1.CreateH5RechargeOrderRequest 167, // 243: hyapp.wallet.v1.WalletService.ListH5RechargeOptions:input_type -> hyapp.wallet.v1.H5RechargeOptionsRequest
171, // 244: hyapp.wallet.v1.WalletService.CreateTemporaryRechargeOrder:input_type -> hyapp.wallet.v1.CreateTemporaryRechargeOrderRequest 170, // 244: hyapp.wallet.v1.WalletService.CreateH5RechargeOrder:input_type -> hyapp.wallet.v1.CreateH5RechargeOrderRequest
172, // 245: hyapp.wallet.v1.WalletService.GetTemporaryRechargeOrder:input_type -> hyapp.wallet.v1.GetTemporaryRechargeOrderRequest 171, // 245: hyapp.wallet.v1.WalletService.CreateTemporaryRechargeOrder:input_type -> hyapp.wallet.v1.CreateTemporaryRechargeOrderRequest
173, // 246: hyapp.wallet.v1.WalletService.ListTemporaryRechargeOrders:input_type -> hyapp.wallet.v1.ListTemporaryRechargeOrdersRequest 172, // 246: hyapp.wallet.v1.WalletService.GetTemporaryRechargeOrder:input_type -> hyapp.wallet.v1.GetTemporaryRechargeOrderRequest
175, // 247: hyapp.wallet.v1.WalletService.SubmitH5RechargeTx:input_type -> hyapp.wallet.v1.SubmitH5RechargeTxRequest 173, // 247: hyapp.wallet.v1.WalletService.ListTemporaryRechargeOrders:input_type -> hyapp.wallet.v1.ListTemporaryRechargeOrdersRequest
176, // 248: hyapp.wallet.v1.WalletService.GetH5RechargeOrder:input_type -> hyapp.wallet.v1.GetH5RechargeOrderRequest 175, // 248: hyapp.wallet.v1.WalletService.SubmitH5RechargeTx:input_type -> hyapp.wallet.v1.SubmitH5RechargeTxRequest
178, // 249: hyapp.wallet.v1.WalletService.VerifyCoinSellerRechargeReceipt:input_type -> hyapp.wallet.v1.VerifyCoinSellerRechargeReceiptRequest 176, // 249: hyapp.wallet.v1.WalletService.GetH5RechargeOrder:input_type -> hyapp.wallet.v1.GetH5RechargeOrderRequest
180, // 250: hyapp.wallet.v1.WalletService.HandleMifapayNotify:input_type -> hyapp.wallet.v1.HandleMifapayNotifyRequest 178, // 250: hyapp.wallet.v1.WalletService.VerifyCoinSellerRechargeReceipt:input_type -> hyapp.wallet.v1.VerifyCoinSellerRechargeReceiptRequest
182, // 251: hyapp.wallet.v1.WalletService.HandleV5PayNotify:input_type -> hyapp.wallet.v1.HandleV5PayNotifyRequest 180, // 251: hyapp.wallet.v1.WalletService.HandleMifapayNotify:input_type -> hyapp.wallet.v1.HandleMifapayNotifyRequest
185, // 252: hyapp.wallet.v1.WalletService.GetDiamondExchangeConfig:input_type -> hyapp.wallet.v1.GetDiamondExchangeConfigRequest 182, // 252: hyapp.wallet.v1.WalletService.HandleV5PayNotify:input_type -> hyapp.wallet.v1.HandleV5PayNotifyRequest
188, // 253: hyapp.wallet.v1.WalletService.ListWalletTransactions:input_type -> hyapp.wallet.v1.ListWalletTransactionsRequest 185, // 253: hyapp.wallet.v1.WalletService.GetDiamondExchangeConfig:input_type -> hyapp.wallet.v1.GetDiamondExchangeConfigRequest
198, // 254: hyapp.wallet.v1.WalletService.ListVipPackages:input_type -> hyapp.wallet.v1.ListVipPackagesRequest 188, // 254: hyapp.wallet.v1.WalletService.ListWalletTransactions:input_type -> hyapp.wallet.v1.ListWalletTransactionsRequest
210, // 255: hyapp.wallet.v1.WalletService.GetVipProgramConfig:input_type -> hyapp.wallet.v1.GetVipProgramConfigRequest 198, // 255: hyapp.wallet.v1.WalletService.ListVipPackages:input_type -> hyapp.wallet.v1.ListVipPackagesRequest
200, // 256: hyapp.wallet.v1.WalletService.GetMyVip:input_type -> hyapp.wallet.v1.GetMyVipRequest 210, // 256: hyapp.wallet.v1.WalletService.GetVipProgramConfig:input_type -> hyapp.wallet.v1.GetVipProgramConfigRequest
232, // 257: hyapp.wallet.v1.WalletService.CheckVipBenefit:input_type -> hyapp.wallet.v1.CheckVipBenefitRequest 200, // 257: hyapp.wallet.v1.WalletService.GetMyVip:input_type -> hyapp.wallet.v1.GetMyVipRequest
202, // 258: hyapp.wallet.v1.WalletService.PurchaseVip:input_type -> hyapp.wallet.v1.PurchaseVipRequest 232, // 258: hyapp.wallet.v1.WalletService.CheckVipBenefit:input_type -> hyapp.wallet.v1.CheckVipBenefitRequest
204, // 259: hyapp.wallet.v1.WalletService.DebitCPBreakupFee:input_type -> hyapp.wallet.v1.DebitCPBreakupFeeRequest 202, // 259: hyapp.wallet.v1.WalletService.PurchaseVip:input_type -> hyapp.wallet.v1.PurchaseVipRequest
206, // 260: hyapp.wallet.v1.WalletService.DebitWheelDraw:input_type -> hyapp.wallet.v1.DebitWheelDrawRequest 204, // 260: hyapp.wallet.v1.WalletService.DebitCPBreakupFee:input_type -> hyapp.wallet.v1.DebitCPBreakupFeeRequest
208, // 261: hyapp.wallet.v1.WalletService.GrantVip:input_type -> hyapp.wallet.v1.GrantVipRequest 206, // 261: hyapp.wallet.v1.WalletService.DebitWheelDraw:input_type -> hyapp.wallet.v1.DebitWheelDrawRequest
226, // 262: hyapp.wallet.v1.WalletService.GrantVipTrialCard:input_type -> hyapp.wallet.v1.GrantVipTrialCardRequest 208, // 262: hyapp.wallet.v1.WalletService.GrantVip:input_type -> hyapp.wallet.v1.GrantVipRequest
228, // 263: hyapp.wallet.v1.WalletService.EquipVipTrialCard:input_type -> hyapp.wallet.v1.EquipVipTrialCardRequest 226, // 263: hyapp.wallet.v1.WalletService.GrantVipTrialCard:input_type -> hyapp.wallet.v1.GrantVipTrialCardRequest
230, // 264: hyapp.wallet.v1.WalletService.UnequipVipTrialCard:input_type -> hyapp.wallet.v1.UnequipVipTrialCardRequest 228, // 264: hyapp.wallet.v1.WalletService.EquipVipTrialCard:input_type -> hyapp.wallet.v1.EquipVipTrialCardRequest
235, // 265: hyapp.wallet.v1.WalletService.ListAdminVipLevels:input_type -> hyapp.wallet.v1.ListAdminVipLevelsRequest 230, // 265: hyapp.wallet.v1.WalletService.UnequipVipTrialCard:input_type -> hyapp.wallet.v1.UnequipVipTrialCardRequest
237, // 266: hyapp.wallet.v1.WalletService.UpdateAdminVipLevels:input_type -> hyapp.wallet.v1.UpdateAdminVipLevelsRequest 235, // 266: hyapp.wallet.v1.WalletService.ListAdminVipLevels:input_type -> hyapp.wallet.v1.ListAdminVipLevelsRequest
212, // 267: hyapp.wallet.v1.WalletService.UpdateAdminVipProgramConfig:input_type -> hyapp.wallet.v1.UpdateAdminVipProgramConfigRequest 237, // 267: hyapp.wallet.v1.WalletService.UpdateAdminVipLevels:input_type -> hyapp.wallet.v1.UpdateAdminVipLevelsRequest
214, // 268: hyapp.wallet.v1.WalletService.UpdateMyVipSettings:input_type -> hyapp.wallet.v1.UpdateMyVipSettingsRequest 212, // 268: hyapp.wallet.v1.WalletService.UpdateAdminVipProgramConfig:input_type -> hyapp.wallet.v1.UpdateAdminVipProgramConfigRequest
220, // 269: hyapp.wallet.v1.WalletService.GetMyCurrentVipDailyCoinRebate:input_type -> hyapp.wallet.v1.GetMyCurrentVipDailyCoinRebateRequest 214, // 269: hyapp.wallet.v1.WalletService.UpdateMyVipSettings:input_type -> hyapp.wallet.v1.UpdateMyVipSettingsRequest
222, // 270: hyapp.wallet.v1.WalletService.ListMyVipDailyCoinRebateStatuses:input_type -> hyapp.wallet.v1.ListMyVipDailyCoinRebateStatusesRequest 220, // 270: hyapp.wallet.v1.WalletService.GetMyCurrentVipDailyCoinRebate:input_type -> hyapp.wallet.v1.GetMyCurrentVipDailyCoinRebateRequest
224, // 271: hyapp.wallet.v1.WalletService.ClaimVipDailyCoinRebate:input_type -> hyapp.wallet.v1.ClaimVipDailyCoinRebateRequest 222, // 271: hyapp.wallet.v1.WalletService.ListMyVipDailyCoinRebateStatuses:input_type -> hyapp.wallet.v1.ListMyVipDailyCoinRebateStatusesRequest
239, // 272: hyapp.wallet.v1.WalletService.CreditTaskReward:input_type -> hyapp.wallet.v1.CreditTaskRewardRequest 224, // 272: hyapp.wallet.v1.WalletService.ClaimVipDailyCoinRebate:input_type -> hyapp.wallet.v1.ClaimVipDailyCoinRebateRequest
241, // 273: hyapp.wallet.v1.WalletService.CreditLuckyGiftReward:input_type -> hyapp.wallet.v1.CreditLuckyGiftRewardRequest 239, // 273: hyapp.wallet.v1.WalletService.CreditTaskReward:input_type -> hyapp.wallet.v1.CreditTaskRewardRequest
243, // 274: hyapp.wallet.v1.WalletService.CreditWheelReward:input_type -> hyapp.wallet.v1.CreditWheelRewardRequest 241, // 274: hyapp.wallet.v1.WalletService.CreditLuckyGiftReward:input_type -> hyapp.wallet.v1.CreditLuckyGiftRewardRequest
245, // 275: hyapp.wallet.v1.WalletService.CreditRoomTurnoverReward:input_type -> hyapp.wallet.v1.CreditRoomTurnoverRewardRequest 243, // 275: hyapp.wallet.v1.WalletService.CreditWheelReward:input_type -> hyapp.wallet.v1.CreditWheelRewardRequest
247, // 276: hyapp.wallet.v1.WalletService.CreditInviteActivityReward:input_type -> hyapp.wallet.v1.CreditInviteActivityRewardRequest 245, // 276: hyapp.wallet.v1.WalletService.CreditRoomTurnoverReward:input_type -> hyapp.wallet.v1.CreditRoomTurnoverRewardRequest
249, // 277: hyapp.wallet.v1.WalletService.CreditAgencyOpeningReward:input_type -> hyapp.wallet.v1.CreditAgencyOpeningRewardRequest 247, // 277: hyapp.wallet.v1.WalletService.CreditInviteActivityReward:input_type -> hyapp.wallet.v1.CreditInviteActivityRewardRequest
251, // 278: hyapp.wallet.v1.WalletService.ApplyGameCoinChange:input_type -> hyapp.wallet.v1.ApplyGameCoinChangeRequest 249, // 278: hyapp.wallet.v1.WalletService.CreditAgencyOpeningReward:input_type -> hyapp.wallet.v1.CreditAgencyOpeningRewardRequest
256, // 279: hyapp.wallet.v1.WalletService.GetRedPacketConfig:input_type -> hyapp.wallet.v1.GetRedPacketConfigRequest 251, // 279: hyapp.wallet.v1.WalletService.ApplyGameCoinChange:input_type -> hyapp.wallet.v1.ApplyGameCoinChangeRequest
258, // 280: hyapp.wallet.v1.WalletService.UpdateRedPacketConfig:input_type -> hyapp.wallet.v1.UpdateRedPacketConfigRequest 256, // 280: hyapp.wallet.v1.WalletService.GetRedPacketConfig:input_type -> hyapp.wallet.v1.GetRedPacketConfigRequest
260, // 281: hyapp.wallet.v1.WalletService.CreateRedPacket:input_type -> hyapp.wallet.v1.CreateRedPacketRequest 258, // 281: hyapp.wallet.v1.WalletService.UpdateRedPacketConfig:input_type -> hyapp.wallet.v1.UpdateRedPacketConfigRequest
262, // 282: hyapp.wallet.v1.WalletService.ClaimRedPacket:input_type -> hyapp.wallet.v1.ClaimRedPacketRequest 260, // 282: hyapp.wallet.v1.WalletService.CreateRedPacket:input_type -> hyapp.wallet.v1.CreateRedPacketRequest
264, // 283: hyapp.wallet.v1.WalletService.ListRedPackets:input_type -> hyapp.wallet.v1.ListRedPacketsRequest 262, // 283: hyapp.wallet.v1.WalletService.ClaimRedPacket:input_type -> hyapp.wallet.v1.ClaimRedPacketRequest
266, // 284: hyapp.wallet.v1.WalletService.GetRedPacket:input_type -> hyapp.wallet.v1.GetRedPacketRequest 264, // 284: hyapp.wallet.v1.WalletService.ListRedPackets:input_type -> hyapp.wallet.v1.ListRedPacketsRequest
268, // 285: hyapp.wallet.v1.WalletService.ExpireRedPackets:input_type -> hyapp.wallet.v1.ExpireRedPacketsRequest 266, // 285: hyapp.wallet.v1.WalletService.GetRedPacket:input_type -> hyapp.wallet.v1.GetRedPacketRequest
270, // 286: hyapp.wallet.v1.WalletService.RetryRedPacketRefund:input_type -> hyapp.wallet.v1.RetryRedPacketRefundRequest 268, // 286: hyapp.wallet.v1.WalletService.ExpireRedPackets:input_type -> hyapp.wallet.v1.ExpireRedPacketsRequest
273, // 287: hyapp.wallet.v1.WalletCronService.ProcessHostSalaryDailySettlementBatch:output_type -> hyapp.wallet.v1.CronBatchResponse 270, // 287: hyapp.wallet.v1.WalletService.RetryRedPacketRefund:input_type -> hyapp.wallet.v1.RetryRedPacketRefundRequest
273, // 288: hyapp.wallet.v1.WalletCronService.ProcessHostSalaryHalfMonthSettlementBatch:output_type -> hyapp.wallet.v1.CronBatchResponse 273, // 288: hyapp.wallet.v1.WalletCronService.ProcessHostSalaryDailySettlementBatch:output_type -> hyapp.wallet.v1.CronBatchResponse
273, // 289: hyapp.wallet.v1.WalletCronService.ProcessHostSalaryMonthEndBatch:output_type -> hyapp.wallet.v1.CronBatchResponse 273, // 289: hyapp.wallet.v1.WalletCronService.ProcessHostSalaryHalfMonthSettlementBatch:output_type -> hyapp.wallet.v1.CronBatchResponse
219, // 290: hyapp.wallet.v1.WalletCronService.ProcessVipDailyCoinRebateBatch:output_type -> hyapp.wallet.v1.ProcessVipDailyCoinRebateBatchResponse 273, // 290: hyapp.wallet.v1.WalletCronService.ProcessHostSalaryMonthEndBatch:output_type -> hyapp.wallet.v1.CronBatchResponse
2, // 291: hyapp.wallet.v1.WalletService.DebitGift:output_type -> hyapp.wallet.v1.DebitGiftResponse 219, // 291: hyapp.wallet.v1.WalletCronService.ProcessVipDailyCoinRebateBatch:output_type -> hyapp.wallet.v1.ProcessVipDailyCoinRebateBatchResponse
2, // 292: hyapp.wallet.v1.WalletService.DebitDirectGift:output_type -> hyapp.wallet.v1.DebitGiftResponse 2, // 292: hyapp.wallet.v1.WalletService.DebitGift:output_type -> hyapp.wallet.v1.DebitGiftResponse
6, // 293: hyapp.wallet.v1.WalletService.BatchDebitGift:output_type -> hyapp.wallet.v1.BatchDebitGiftResponse 2, // 293: hyapp.wallet.v1.WalletService.DebitDirectGift:output_type -> hyapp.wallet.v1.DebitGiftResponse
2, // 294: hyapp.wallet.v1.WalletService.DebitRobotGift:output_type -> hyapp.wallet.v1.DebitGiftResponse 6, // 294: hyapp.wallet.v1.WalletService.BatchDebitGift:output_type -> hyapp.wallet.v1.BatchDebitGiftResponse
10, // 295: hyapp.wallet.v1.WalletService.GetBalances:output_type -> hyapp.wallet.v1.GetBalancesResponse 2, // 295: hyapp.wallet.v1.WalletService.DebitRobotGift:output_type -> hyapp.wallet.v1.DebitGiftResponse
14, // 296: hyapp.wallet.v1.WalletService.GetActiveHostSalaryPolicy:output_type -> hyapp.wallet.v1.GetActiveHostSalaryPolicyResponse 10, // 296: hyapp.wallet.v1.WalletService.GetBalances:output_type -> hyapp.wallet.v1.GetBalancesResponse
17, // 297: hyapp.wallet.v1.WalletService.GetHostSalaryProgress:output_type -> hyapp.wallet.v1.GetHostSalaryProgressResponse 14, // 297: hyapp.wallet.v1.WalletService.GetActiveHostSalaryPolicy:output_type -> hyapp.wallet.v1.GetActiveHostSalaryPolicyResponse
281, // 298: hyapp.wallet.v1.WalletService.GetHostRevenueStats:output_type -> hyapp.wallet.v1.GetHostRevenueStatsResponse 17, // 298: hyapp.wallet.v1.WalletService.GetHostSalaryProgress:output_type -> hyapp.wallet.v1.GetHostSalaryProgressResponse
284, // 299: hyapp.wallet.v1.WalletService.GetAgencyPointShareStats:output_type -> hyapp.wallet.v1.GetAgencyPointShareStatsResponse 281, // 299: hyapp.wallet.v1.WalletService.GetHostRevenueStats:output_type -> hyapp.wallet.v1.GetHostRevenueStatsResponse
287, // 300: hyapp.wallet.v1.WalletService.GetAgencyHostGiftStats:output_type -> hyapp.wallet.v1.GetAgencyHostGiftStatsResponse 284, // 300: hyapp.wallet.v1.WalletService.GetAgencyPointShareStats:output_type -> hyapp.wallet.v1.GetAgencyPointShareStatsResponse
20, // 301: hyapp.wallet.v1.WalletService.GetTeamHostSalaryStats:output_type -> hyapp.wallet.v1.GetTeamHostSalaryStatsResponse 287, // 301: hyapp.wallet.v1.WalletService.GetAgencyHostGiftStats:output_type -> hyapp.wallet.v1.GetAgencyHostGiftStatsResponse
22, // 302: hyapp.wallet.v1.WalletService.AdminCreditAsset:output_type -> hyapp.wallet.v1.AdminCreditAssetResponse 20, // 302: hyapp.wallet.v1.WalletService.GetTeamHostSalaryStats:output_type -> hyapp.wallet.v1.GetTeamHostSalaryStatsResponse
24, // 303: hyapp.wallet.v1.WalletService.AdminCreditCoinSellerStock:output_type -> hyapp.wallet.v1.AdminCreditCoinSellerStockResponse 22, // 303: hyapp.wallet.v1.WalletService.AdminCreditAsset:output_type -> hyapp.wallet.v1.AdminCreditAssetResponse
26, // 304: hyapp.wallet.v1.WalletService.TransferCoinFromSeller:output_type -> hyapp.wallet.v1.TransferCoinFromSellerResponse 24, // 304: hyapp.wallet.v1.WalletService.AdminCreditCoinSellerStock:output_type -> hyapp.wallet.v1.AdminCreditCoinSellerStockResponse
28, // 305: hyapp.wallet.v1.WalletService.TransferCoinSellerStockToChild:output_type -> hyapp.wallet.v1.TransferCoinSellerStockToChildResponse 26, // 305: hyapp.wallet.v1.WalletService.TransferCoinFromSeller:output_type -> hyapp.wallet.v1.TransferCoinFromSellerResponse
31, // 306: hyapp.wallet.v1.WalletService.ListCoinSellerSalaryExchangeRateTiers:output_type -> hyapp.wallet.v1.ListCoinSellerSalaryExchangeRateTiersResponse 28, // 306: hyapp.wallet.v1.WalletService.TransferCoinSellerStockToChild:output_type -> hyapp.wallet.v1.TransferCoinSellerStockToChildResponse
33, // 307: hyapp.wallet.v1.WalletService.ExchangeSalaryToCoin:output_type -> hyapp.wallet.v1.ExchangeSalaryToCoinResponse 31, // 307: hyapp.wallet.v1.WalletService.ListCoinSellerSalaryExchangeRateTiers:output_type -> hyapp.wallet.v1.ListCoinSellerSalaryExchangeRateTiersResponse
35, // 308: hyapp.wallet.v1.WalletService.TransferSalaryToCoinSeller:output_type -> hyapp.wallet.v1.TransferSalaryToCoinSellerResponse 33, // 308: hyapp.wallet.v1.WalletService.ExchangeSalaryToCoin:output_type -> hyapp.wallet.v1.ExchangeSalaryToCoinResponse
290, // 309: hyapp.wallet.v1.WalletService.ListPointWithdrawalCoinSellers:output_type -> hyapp.wallet.v1.ListPointWithdrawalCoinSellersResponse 35, // 309: hyapp.wallet.v1.WalletService.TransferSalaryToCoinSeller:output_type -> hyapp.wallet.v1.TransferSalaryToCoinSellerResponse
292, // 310: hyapp.wallet.v1.WalletService.TransferPointToCoinSeller:output_type -> hyapp.wallet.v1.TransferPointToCoinSellerResponse 290, // 310: hyapp.wallet.v1.WalletService.ListPointWithdrawalCoinSellers:output_type -> hyapp.wallet.v1.ListPointWithdrawalCoinSellersResponse
294, // 311: hyapp.wallet.v1.WalletService.GetPointWithdrawalConfig:output_type -> hyapp.wallet.v1.GetPointWithdrawalConfigResponse 292, // 311: hyapp.wallet.v1.WalletService.TransferPointToCoinSeller:output_type -> hyapp.wallet.v1.TransferPointToCoinSellerResponse
37, // 312: hyapp.wallet.v1.WalletService.FreezeSalaryWithdrawal:output_type -> hyapp.wallet.v1.FreezeSalaryWithdrawalResponse 294, // 312: hyapp.wallet.v1.WalletService.GetPointWithdrawalConfig:output_type -> hyapp.wallet.v1.GetPointWithdrawalConfigResponse
39, // 313: hyapp.wallet.v1.WalletService.SettleSalaryWithdrawal:output_type -> hyapp.wallet.v1.SettleSalaryWithdrawalResponse 37, // 313: hyapp.wallet.v1.WalletService.FreezeSalaryWithdrawal:output_type -> hyapp.wallet.v1.FreezeSalaryWithdrawalResponse
41, // 314: hyapp.wallet.v1.WalletService.ReleaseSalaryWithdrawal:output_type -> hyapp.wallet.v1.ReleaseSalaryWithdrawalResponse 39, // 314: hyapp.wallet.v1.WalletService.SettleSalaryWithdrawal:output_type -> hyapp.wallet.v1.SettleSalaryWithdrawalResponse
43, // 315: hyapp.wallet.v1.WalletService.FreezePointWithdrawal:output_type -> hyapp.wallet.v1.FreezePointWithdrawalResponse 41, // 315: hyapp.wallet.v1.WalletService.ReleaseSalaryWithdrawal:output_type -> hyapp.wallet.v1.ReleaseSalaryWithdrawalResponse
45, // 316: hyapp.wallet.v1.WalletService.SettlePointWithdrawal:output_type -> hyapp.wallet.v1.SettlePointWithdrawalResponse 43, // 316: hyapp.wallet.v1.WalletService.FreezePointWithdrawal:output_type -> hyapp.wallet.v1.FreezePointWithdrawalResponse
47, // 317: hyapp.wallet.v1.WalletService.ReleasePointWithdrawal:output_type -> hyapp.wallet.v1.ReleasePointWithdrawalResponse 45, // 317: hyapp.wallet.v1.WalletService.SettlePointWithdrawal:output_type -> hyapp.wallet.v1.SettlePointWithdrawalResponse
62, // 318: hyapp.wallet.v1.WalletService.ListResources:output_type -> hyapp.wallet.v1.ListResourcesResponse 47, // 318: hyapp.wallet.v1.WalletService.ReleasePointWithdrawal:output_type -> hyapp.wallet.v1.ReleasePointWithdrawalResponse
64, // 319: hyapp.wallet.v1.WalletService.GetResource:output_type -> hyapp.wallet.v1.GetResourceResponse 62, // 319: hyapp.wallet.v1.WalletService.ListResources:output_type -> hyapp.wallet.v1.ListResourcesResponse
71, // 320: hyapp.wallet.v1.WalletService.CreateResource:output_type -> hyapp.wallet.v1.ResourceResponse 64, // 320: hyapp.wallet.v1.WalletService.GetResource:output_type -> hyapp.wallet.v1.GetResourceResponse
71, // 321: hyapp.wallet.v1.WalletService.UpdateResource:output_type -> hyapp.wallet.v1.ResourceResponse 71, // 321: hyapp.wallet.v1.WalletService.CreateResource:output_type -> hyapp.wallet.v1.ResourceResponse
71, // 322: hyapp.wallet.v1.WalletService.SetResourceStatus:output_type -> hyapp.wallet.v1.ResourceResponse 71, // 322: hyapp.wallet.v1.WalletService.UpdateResource:output_type -> hyapp.wallet.v1.ResourceResponse
71, // 323: hyapp.wallet.v1.WalletService.DeleteResource:output_type -> hyapp.wallet.v1.ResourceResponse 71, // 323: hyapp.wallet.v1.WalletService.SetResourceStatus:output_type -> hyapp.wallet.v1.ResourceResponse
70, // 324: hyapp.wallet.v1.WalletService.BatchDeleteResources:output_type -> hyapp.wallet.v1.BatchDeleteResourcesResponse 71, // 324: hyapp.wallet.v1.WalletService.DeleteResource:output_type -> hyapp.wallet.v1.ResourceResponse
73, // 325: hyapp.wallet.v1.WalletService.ListResourceGroups:output_type -> hyapp.wallet.v1.ListResourceGroupsResponse 70, // 325: hyapp.wallet.v1.WalletService.BatchDeleteResources:output_type -> hyapp.wallet.v1.BatchDeleteResourcesResponse
75, // 326: hyapp.wallet.v1.WalletService.GetResourceGroup:output_type -> hyapp.wallet.v1.GetResourceGroupResponse 73, // 326: hyapp.wallet.v1.WalletService.ListResourceGroups:output_type -> hyapp.wallet.v1.ListResourceGroupsResponse
79, // 327: hyapp.wallet.v1.WalletService.CreateResourceGroup:output_type -> hyapp.wallet.v1.ResourceGroupResponse 75, // 327: hyapp.wallet.v1.WalletService.GetResourceGroup:output_type -> hyapp.wallet.v1.GetResourceGroupResponse
79, // 328: hyapp.wallet.v1.WalletService.UpdateResourceGroup:output_type -> hyapp.wallet.v1.ResourceGroupResponse 79, // 328: hyapp.wallet.v1.WalletService.CreateResourceGroup:output_type -> hyapp.wallet.v1.ResourceGroupResponse
79, // 329: hyapp.wallet.v1.WalletService.SetResourceGroupStatus:output_type -> hyapp.wallet.v1.ResourceGroupResponse 79, // 329: hyapp.wallet.v1.WalletService.UpdateResourceGroup:output_type -> hyapp.wallet.v1.ResourceGroupResponse
53, // 330: hyapp.wallet.v1.WalletService.PinResourceGroupSnapshot:output_type -> hyapp.wallet.v1.PinResourceGroupSnapshotResponse 79, // 330: hyapp.wallet.v1.WalletService.SetResourceGroupStatus:output_type -> hyapp.wallet.v1.ResourceGroupResponse
81, // 331: hyapp.wallet.v1.WalletService.ListGiftConfigs:output_type -> hyapp.wallet.v1.ListGiftConfigsResponse 53, // 331: hyapp.wallet.v1.WalletService.PinResourceGroupSnapshot:output_type -> hyapp.wallet.v1.PinResourceGroupSnapshotResponse
83, // 332: hyapp.wallet.v1.WalletService.ListGiftTypeConfigs:output_type -> hyapp.wallet.v1.ListGiftTypeConfigsResponse 81, // 332: hyapp.wallet.v1.WalletService.ListGiftConfigs:output_type -> hyapp.wallet.v1.ListGiftConfigsResponse
278, // 333: hyapp.wallet.v1.WalletService.GetGiftCatalogVersion:output_type -> hyapp.wallet.v1.GetGiftCatalogVersionResponse 83, // 333: hyapp.wallet.v1.WalletService.ListGiftTypeConfigs:output_type -> hyapp.wallet.v1.ListGiftTypeConfigsResponse
92, // 334: hyapp.wallet.v1.WalletService.CreateGiftConfig:output_type -> hyapp.wallet.v1.GiftConfigResponse 278, // 334: hyapp.wallet.v1.WalletService.GetGiftCatalogVersion:output_type -> hyapp.wallet.v1.GetGiftCatalogVersionResponse
88, // 335: hyapp.wallet.v1.WalletService.BatchCreateGiftConfigs:output_type -> hyapp.wallet.v1.BatchCreateGiftConfigsResponse 92, // 335: hyapp.wallet.v1.WalletService.CreateGiftConfig:output_type -> hyapp.wallet.v1.GiftConfigResponse
92, // 336: hyapp.wallet.v1.WalletService.UpdateGiftConfig:output_type -> hyapp.wallet.v1.GiftConfigResponse 88, // 336: hyapp.wallet.v1.WalletService.BatchCreateGiftConfigs:output_type -> hyapp.wallet.v1.BatchCreateGiftConfigsResponse
92, // 337: hyapp.wallet.v1.WalletService.SetGiftConfigStatus:output_type -> hyapp.wallet.v1.GiftConfigResponse 92, // 337: hyapp.wallet.v1.WalletService.UpdateGiftConfig:output_type -> hyapp.wallet.v1.GiftConfigResponse
92, // 338: hyapp.wallet.v1.WalletService.DeleteGiftConfig:output_type -> hyapp.wallet.v1.GiftConfigResponse 92, // 338: hyapp.wallet.v1.WalletService.SetGiftConfigStatus:output_type -> hyapp.wallet.v1.GiftConfigResponse
85, // 339: hyapp.wallet.v1.WalletService.UpsertGiftTypeConfig:output_type -> hyapp.wallet.v1.GiftTypeConfigResponse 92, // 339: hyapp.wallet.v1.WalletService.DeleteGiftConfig:output_type -> hyapp.wallet.v1.GiftConfigResponse
97, // 340: hyapp.wallet.v1.WalletService.GrantResource:output_type -> hyapp.wallet.v1.ResourceGrantResponse 85, // 340: hyapp.wallet.v1.WalletService.UpsertGiftTypeConfig:output_type -> hyapp.wallet.v1.GiftTypeConfigResponse
97, // 341: hyapp.wallet.v1.WalletService.GrantResourceGroup:output_type -> hyapp.wallet.v1.ResourceGrantResponse 97, // 341: hyapp.wallet.v1.WalletService.GrantResource:output_type -> hyapp.wallet.v1.ResourceGrantResponse
97, // 342: hyapp.wallet.v1.WalletService.GrantPinnedResourceGroup:output_type -> hyapp.wallet.v1.ResourceGrantResponse 97, // 342: hyapp.wallet.v1.WalletService.GrantResourceGroup:output_type -> hyapp.wallet.v1.ResourceGrantResponse
97, // 343: hyapp.wallet.v1.WalletService.RevokeResourceGrant:output_type -> hyapp.wallet.v1.ResourceGrantResponse 97, // 343: hyapp.wallet.v1.WalletService.GrantPinnedResourceGroup:output_type -> hyapp.wallet.v1.ResourceGrantResponse
299, // 344: hyapp.wallet.v1.WalletService.RevokeUserResource:output_type -> hyapp.wallet.v1.RevokeUserResourceResponse 97, // 344: hyapp.wallet.v1.WalletService.RevokeResourceGrant:output_type -> hyapp.wallet.v1.ResourceGrantResponse
99, // 345: hyapp.wallet.v1.WalletService.ListUserResources:output_type -> hyapp.wallet.v1.ListUserResourcesResponse 299, // 345: hyapp.wallet.v1.WalletService.RevokeUserResource:output_type -> hyapp.wallet.v1.RevokeUserResourceResponse
101, // 346: hyapp.wallet.v1.WalletService.EquipUserResource:output_type -> hyapp.wallet.v1.EquipUserResourceResponse 99, // 346: hyapp.wallet.v1.WalletService.ListUserResources:output_type -> hyapp.wallet.v1.ListUserResourcesResponse
103, // 347: hyapp.wallet.v1.WalletService.UnequipUserResource:output_type -> hyapp.wallet.v1.UnequipUserResourceResponse 101, // 347: hyapp.wallet.v1.WalletService.EquipUserResource:output_type -> hyapp.wallet.v1.EquipUserResourceResponse
106, // 348: hyapp.wallet.v1.WalletService.BatchGetUserEquippedResources:output_type -> hyapp.wallet.v1.BatchGetUserEquippedResourcesResponse 103, // 348: hyapp.wallet.v1.WalletService.UnequipUserResource:output_type -> hyapp.wallet.v1.UnequipUserResourceResponse
108, // 349: hyapp.wallet.v1.WalletService.ListResourceGrants:output_type -> hyapp.wallet.v1.ListResourceGrantsResponse 106, // 349: hyapp.wallet.v1.WalletService.BatchGetUserEquippedResources:output_type -> hyapp.wallet.v1.BatchGetUserEquippedResourcesResponse
111, // 350: hyapp.wallet.v1.WalletService.ListResourceShopItems:output_type -> hyapp.wallet.v1.ListResourceShopItemsResponse 108, // 350: hyapp.wallet.v1.WalletService.ListResourceGrants:output_type -> hyapp.wallet.v1.ListResourceGrantsResponse
113, // 351: hyapp.wallet.v1.WalletService.UpsertResourceShopItems:output_type -> hyapp.wallet.v1.UpsertResourceShopItemsResponse 111, // 351: hyapp.wallet.v1.WalletService.ListResourceShopItems:output_type -> hyapp.wallet.v1.ListResourceShopItemsResponse
115, // 352: hyapp.wallet.v1.WalletService.SetResourceShopItemStatus:output_type -> hyapp.wallet.v1.ResourceShopItemResponse 113, // 352: hyapp.wallet.v1.WalletService.UpsertResourceShopItems:output_type -> hyapp.wallet.v1.UpsertResourceShopItemsResponse
276, // 353: hyapp.wallet.v1.WalletService.ListResourceShopPurchaseOrders:output_type -> hyapp.wallet.v1.ListResourceShopPurchaseOrdersResponse 115, // 353: hyapp.wallet.v1.WalletService.SetResourceShopItemStatus:output_type -> hyapp.wallet.v1.ResourceShopItemResponse
117, // 354: hyapp.wallet.v1.WalletService.PurchaseResourceShopItem:output_type -> hyapp.wallet.v1.PurchaseResourceShopItemResponse 276, // 354: hyapp.wallet.v1.WalletService.ListResourceShopPurchaseOrders:output_type -> hyapp.wallet.v1.ListResourceShopPurchaseOrdersResponse
120, // 355: hyapp.wallet.v1.WalletService.ListRechargeBills:output_type -> hyapp.wallet.v1.ListRechargeBillsResponse 117, // 355: hyapp.wallet.v1.WalletService.PurchaseResourceShopItem:output_type -> hyapp.wallet.v1.PurchaseResourceShopItemResponse
123, // 356: hyapp.wallet.v1.WalletService.GetRechargeBillSummary:output_type -> hyapp.wallet.v1.GetRechargeBillSummaryResponse 120, // 356: hyapp.wallet.v1.WalletService.ListRechargeBills:output_type -> hyapp.wallet.v1.ListRechargeBillsResponse
126, // 357: hyapp.wallet.v1.WalletService.BatchGetUserRechargeStats:output_type -> hyapp.wallet.v1.BatchGetUserRechargeStatsResponse 123, // 357: hyapp.wallet.v1.WalletService.GetRechargeBillSummary:output_type -> hyapp.wallet.v1.GetRechargeBillSummaryResponse
132, // 358: hyapp.wallet.v1.WalletService.GetRechargeBillOverview:output_type -> hyapp.wallet.v1.GetRechargeBillOverviewResponse 126, // 358: hyapp.wallet.v1.WalletService.BatchGetUserRechargeStats:output_type -> hyapp.wallet.v1.BatchGetUserRechargeStatsResponse
135, // 359: hyapp.wallet.v1.WalletService.RefreshGooglePaymentPrices:output_type -> hyapp.wallet.v1.RefreshGooglePaymentPricesResponse 132, // 359: hyapp.wallet.v1.WalletService.GetRechargeBillOverview:output_type -> hyapp.wallet.v1.GetRechargeBillOverviewResponse
138, // 360: hyapp.wallet.v1.WalletService.GetWalletOverview:output_type -> hyapp.wallet.v1.GetWalletOverviewResponse 135, // 360: hyapp.wallet.v1.WalletService.RefreshGooglePaymentPrices:output_type -> hyapp.wallet.v1.RefreshGooglePaymentPricesResponse
141, // 361: hyapp.wallet.v1.WalletService.GetWalletValueSummary:output_type -> hyapp.wallet.v1.GetWalletValueSummaryResponse 138, // 361: hyapp.wallet.v1.WalletService.GetWalletOverview:output_type -> hyapp.wallet.v1.GetWalletOverviewResponse
144, // 362: hyapp.wallet.v1.WalletService.GetUserGiftWall:output_type -> hyapp.wallet.v1.GetUserGiftWallResponse 141, // 362: hyapp.wallet.v1.WalletService.GetWalletValueSummary:output_type -> hyapp.wallet.v1.GetWalletValueSummaryResponse
147, // 363: hyapp.wallet.v1.WalletService.ListRechargeProducts:output_type -> hyapp.wallet.v1.ListRechargeProductsResponse 144, // 363: hyapp.wallet.v1.WalletService.GetUserGiftWall:output_type -> hyapp.wallet.v1.GetUserGiftWallResponse
149, // 364: hyapp.wallet.v1.WalletService.ConfirmGooglePayment:output_type -> hyapp.wallet.v1.ConfirmGooglePaymentResponse 147, // 364: hyapp.wallet.v1.WalletService.ListRechargeProducts:output_type -> hyapp.wallet.v1.ListRechargeProductsResponse
151, // 365: hyapp.wallet.v1.WalletService.ListAdminRechargeProducts:output_type -> hyapp.wallet.v1.ListAdminRechargeProductsResponse 149, // 365: hyapp.wallet.v1.WalletService.ConfirmGooglePayment:output_type -> hyapp.wallet.v1.ConfirmGooglePaymentResponse
155, // 366: hyapp.wallet.v1.WalletService.CreateRechargeProduct:output_type -> hyapp.wallet.v1.RechargeProductResponse 151, // 366: hyapp.wallet.v1.WalletService.ListAdminRechargeProducts:output_type -> hyapp.wallet.v1.ListAdminRechargeProductsResponse
155, // 367: hyapp.wallet.v1.WalletService.UpdateRechargeProduct:output_type -> hyapp.wallet.v1.RechargeProductResponse 155, // 367: hyapp.wallet.v1.WalletService.CreateRechargeProduct:output_type -> hyapp.wallet.v1.RechargeProductResponse
156, // 368: hyapp.wallet.v1.WalletService.DeleteRechargeProduct:output_type -> hyapp.wallet.v1.DeleteRechargeProductResponse 155, // 368: hyapp.wallet.v1.WalletService.UpdateRechargeProduct:output_type -> hyapp.wallet.v1.RechargeProductResponse
160, // 369: hyapp.wallet.v1.WalletService.ListThirdPartyPaymentChannels:output_type -> hyapp.wallet.v1.ListThirdPartyPaymentChannelsResponse 156, // 369: hyapp.wallet.v1.WalletService.DeleteRechargeProduct:output_type -> hyapp.wallet.v1.DeleteRechargeProductResponse
162, // 370: hyapp.wallet.v1.WalletService.SetThirdPartyPaymentMethodStatus:output_type -> hyapp.wallet.v1.ThirdPartyPaymentMethodResponse 160, // 370: hyapp.wallet.v1.WalletService.ListThirdPartyPaymentChannels:output_type -> hyapp.wallet.v1.ListThirdPartyPaymentChannelsResponse
162, // 371: hyapp.wallet.v1.WalletService.UpdateThirdPartyPaymentRate:output_type -> hyapp.wallet.v1.ThirdPartyPaymentMethodResponse 162, // 371: hyapp.wallet.v1.WalletService.SetThirdPartyPaymentMethodStatus:output_type -> hyapp.wallet.v1.ThirdPartyPaymentMethodResponse
166, // 372: hyapp.wallet.v1.WalletService.SyncThirdPartyPaymentMethods:output_type -> hyapp.wallet.v1.SyncThirdPartyPaymentMethodsResponse 162, // 372: hyapp.wallet.v1.WalletService.UpdateThirdPartyPaymentRate:output_type -> hyapp.wallet.v1.ThirdPartyPaymentMethodResponse
168, // 373: hyapp.wallet.v1.WalletService.ListH5RechargeOptions:output_type -> hyapp.wallet.v1.H5RechargeOptionsResponse 166, // 373: hyapp.wallet.v1.WalletService.SyncThirdPartyPaymentMethods:output_type -> hyapp.wallet.v1.SyncThirdPartyPaymentMethodsResponse
177, // 374: hyapp.wallet.v1.WalletService.CreateH5RechargeOrder:output_type -> hyapp.wallet.v1.H5RechargeOrderResponse 168, // 374: hyapp.wallet.v1.WalletService.ListH5RechargeOptions:output_type -> hyapp.wallet.v1.H5RechargeOptionsResponse
177, // 375: hyapp.wallet.v1.WalletService.CreateTemporaryRechargeOrder:output_type -> hyapp.wallet.v1.H5RechargeOrderResponse 177, // 375: hyapp.wallet.v1.WalletService.CreateH5RechargeOrder:output_type -> hyapp.wallet.v1.H5RechargeOrderResponse
177, // 376: hyapp.wallet.v1.WalletService.GetTemporaryRechargeOrder:output_type -> hyapp.wallet.v1.H5RechargeOrderResponse 177, // 376: hyapp.wallet.v1.WalletService.CreateTemporaryRechargeOrder:output_type -> hyapp.wallet.v1.H5RechargeOrderResponse
174, // 377: hyapp.wallet.v1.WalletService.ListTemporaryRechargeOrders:output_type -> hyapp.wallet.v1.ListTemporaryRechargeOrdersResponse 177, // 377: hyapp.wallet.v1.WalletService.GetTemporaryRechargeOrder:output_type -> hyapp.wallet.v1.H5RechargeOrderResponse
177, // 378: hyapp.wallet.v1.WalletService.SubmitH5RechargeTx:output_type -> hyapp.wallet.v1.H5RechargeOrderResponse 174, // 378: hyapp.wallet.v1.WalletService.ListTemporaryRechargeOrders:output_type -> hyapp.wallet.v1.ListTemporaryRechargeOrdersResponse
177, // 379: hyapp.wallet.v1.WalletService.GetH5RechargeOrder:output_type -> hyapp.wallet.v1.H5RechargeOrderResponse 177, // 379: hyapp.wallet.v1.WalletService.SubmitH5RechargeTx:output_type -> hyapp.wallet.v1.H5RechargeOrderResponse
179, // 380: hyapp.wallet.v1.WalletService.VerifyCoinSellerRechargeReceipt:output_type -> hyapp.wallet.v1.VerifyCoinSellerRechargeReceiptResponse 177, // 380: hyapp.wallet.v1.WalletService.GetH5RechargeOrder:output_type -> hyapp.wallet.v1.H5RechargeOrderResponse
181, // 381: hyapp.wallet.v1.WalletService.HandleMifapayNotify:output_type -> hyapp.wallet.v1.HandleMifapayNotifyResponse 179, // 381: hyapp.wallet.v1.WalletService.VerifyCoinSellerRechargeReceipt:output_type -> hyapp.wallet.v1.VerifyCoinSellerRechargeReceiptResponse
183, // 382: hyapp.wallet.v1.WalletService.HandleV5PayNotify:output_type -> hyapp.wallet.v1.HandleV5PayNotifyResponse 181, // 382: hyapp.wallet.v1.WalletService.HandleMifapayNotify:output_type -> hyapp.wallet.v1.HandleMifapayNotifyResponse
186, // 383: hyapp.wallet.v1.WalletService.GetDiamondExchangeConfig:output_type -> hyapp.wallet.v1.GetDiamondExchangeConfigResponse 183, // 383: hyapp.wallet.v1.WalletService.HandleV5PayNotify:output_type -> hyapp.wallet.v1.HandleV5PayNotifyResponse
189, // 384: hyapp.wallet.v1.WalletService.ListWalletTransactions:output_type -> hyapp.wallet.v1.ListWalletTransactionsResponse 186, // 384: hyapp.wallet.v1.WalletService.GetDiamondExchangeConfig:output_type -> hyapp.wallet.v1.GetDiamondExchangeConfigResponse
199, // 385: hyapp.wallet.v1.WalletService.ListVipPackages:output_type -> hyapp.wallet.v1.ListVipPackagesResponse 189, // 385: hyapp.wallet.v1.WalletService.ListWalletTransactions:output_type -> hyapp.wallet.v1.ListWalletTransactionsResponse
211, // 386: hyapp.wallet.v1.WalletService.GetVipProgramConfig:output_type -> hyapp.wallet.v1.GetVipProgramConfigResponse 199, // 386: hyapp.wallet.v1.WalletService.ListVipPackages:output_type -> hyapp.wallet.v1.ListVipPackagesResponse
201, // 387: hyapp.wallet.v1.WalletService.GetMyVip:output_type -> hyapp.wallet.v1.GetMyVipResponse 211, // 387: hyapp.wallet.v1.WalletService.GetVipProgramConfig:output_type -> hyapp.wallet.v1.GetVipProgramConfigResponse
233, // 388: hyapp.wallet.v1.WalletService.CheckVipBenefit:output_type -> hyapp.wallet.v1.CheckVipBenefitResponse 201, // 388: hyapp.wallet.v1.WalletService.GetMyVip:output_type -> hyapp.wallet.v1.GetMyVipResponse
203, // 389: hyapp.wallet.v1.WalletService.PurchaseVip:output_type -> hyapp.wallet.v1.PurchaseVipResponse 233, // 389: hyapp.wallet.v1.WalletService.CheckVipBenefit:output_type -> hyapp.wallet.v1.CheckVipBenefitResponse
205, // 390: hyapp.wallet.v1.WalletService.DebitCPBreakupFee:output_type -> hyapp.wallet.v1.DebitCPBreakupFeeResponse 203, // 390: hyapp.wallet.v1.WalletService.PurchaseVip:output_type -> hyapp.wallet.v1.PurchaseVipResponse
207, // 391: hyapp.wallet.v1.WalletService.DebitWheelDraw:output_type -> hyapp.wallet.v1.DebitWheelDrawResponse 205, // 391: hyapp.wallet.v1.WalletService.DebitCPBreakupFee:output_type -> hyapp.wallet.v1.DebitCPBreakupFeeResponse
209, // 392: hyapp.wallet.v1.WalletService.GrantVip:output_type -> hyapp.wallet.v1.GrantVipResponse 207, // 392: hyapp.wallet.v1.WalletService.DebitWheelDraw:output_type -> hyapp.wallet.v1.DebitWheelDrawResponse
227, // 393: hyapp.wallet.v1.WalletService.GrantVipTrialCard:output_type -> hyapp.wallet.v1.GrantVipTrialCardResponse 209, // 393: hyapp.wallet.v1.WalletService.GrantVip:output_type -> hyapp.wallet.v1.GrantVipResponse
229, // 394: hyapp.wallet.v1.WalletService.EquipVipTrialCard:output_type -> hyapp.wallet.v1.EquipVipTrialCardResponse 227, // 394: hyapp.wallet.v1.WalletService.GrantVipTrialCard:output_type -> hyapp.wallet.v1.GrantVipTrialCardResponse
231, // 395: hyapp.wallet.v1.WalletService.UnequipVipTrialCard:output_type -> hyapp.wallet.v1.UnequipVipTrialCardResponse 229, // 395: hyapp.wallet.v1.WalletService.EquipVipTrialCard:output_type -> hyapp.wallet.v1.EquipVipTrialCardResponse
236, // 396: hyapp.wallet.v1.WalletService.ListAdminVipLevels:output_type -> hyapp.wallet.v1.ListAdminVipLevelsResponse 231, // 396: hyapp.wallet.v1.WalletService.UnequipVipTrialCard:output_type -> hyapp.wallet.v1.UnequipVipTrialCardResponse
238, // 397: hyapp.wallet.v1.WalletService.UpdateAdminVipLevels:output_type -> hyapp.wallet.v1.UpdateAdminVipLevelsResponse 236, // 397: hyapp.wallet.v1.WalletService.ListAdminVipLevels:output_type -> hyapp.wallet.v1.ListAdminVipLevelsResponse
213, // 398: hyapp.wallet.v1.WalletService.UpdateAdminVipProgramConfig:output_type -> hyapp.wallet.v1.UpdateAdminVipProgramConfigResponse 238, // 398: hyapp.wallet.v1.WalletService.UpdateAdminVipLevels:output_type -> hyapp.wallet.v1.UpdateAdminVipLevelsResponse
215, // 399: hyapp.wallet.v1.WalletService.UpdateMyVipSettings:output_type -> hyapp.wallet.v1.UpdateMyVipSettingsResponse 213, // 399: hyapp.wallet.v1.WalletService.UpdateAdminVipProgramConfig:output_type -> hyapp.wallet.v1.UpdateAdminVipProgramConfigResponse
221, // 400: hyapp.wallet.v1.WalletService.GetMyCurrentVipDailyCoinRebate:output_type -> hyapp.wallet.v1.GetMyCurrentVipDailyCoinRebateResponse 215, // 400: hyapp.wallet.v1.WalletService.UpdateMyVipSettings:output_type -> hyapp.wallet.v1.UpdateMyVipSettingsResponse
223, // 401: hyapp.wallet.v1.WalletService.ListMyVipDailyCoinRebateStatuses:output_type -> hyapp.wallet.v1.ListMyVipDailyCoinRebateStatusesResponse 221, // 401: hyapp.wallet.v1.WalletService.GetMyCurrentVipDailyCoinRebate:output_type -> hyapp.wallet.v1.GetMyCurrentVipDailyCoinRebateResponse
225, // 402: hyapp.wallet.v1.WalletService.ClaimVipDailyCoinRebate:output_type -> hyapp.wallet.v1.ClaimVipDailyCoinRebateResponse 223, // 402: hyapp.wallet.v1.WalletService.ListMyVipDailyCoinRebateStatuses:output_type -> hyapp.wallet.v1.ListMyVipDailyCoinRebateStatusesResponse
240, // 403: hyapp.wallet.v1.WalletService.CreditTaskReward:output_type -> hyapp.wallet.v1.CreditTaskRewardResponse 225, // 403: hyapp.wallet.v1.WalletService.ClaimVipDailyCoinRebate:output_type -> hyapp.wallet.v1.ClaimVipDailyCoinRebateResponse
242, // 404: hyapp.wallet.v1.WalletService.CreditLuckyGiftReward:output_type -> hyapp.wallet.v1.CreditLuckyGiftRewardResponse 240, // 404: hyapp.wallet.v1.WalletService.CreditTaskReward:output_type -> hyapp.wallet.v1.CreditTaskRewardResponse
244, // 405: hyapp.wallet.v1.WalletService.CreditWheelReward:output_type -> hyapp.wallet.v1.CreditWheelRewardResponse 242, // 405: hyapp.wallet.v1.WalletService.CreditLuckyGiftReward:output_type -> hyapp.wallet.v1.CreditLuckyGiftRewardResponse
246, // 406: hyapp.wallet.v1.WalletService.CreditRoomTurnoverReward:output_type -> hyapp.wallet.v1.CreditRoomTurnoverRewardResponse 244, // 406: hyapp.wallet.v1.WalletService.CreditWheelReward:output_type -> hyapp.wallet.v1.CreditWheelRewardResponse
248, // 407: hyapp.wallet.v1.WalletService.CreditInviteActivityReward:output_type -> hyapp.wallet.v1.CreditInviteActivityRewardResponse 246, // 407: hyapp.wallet.v1.WalletService.CreditRoomTurnoverReward:output_type -> hyapp.wallet.v1.CreditRoomTurnoverRewardResponse
250, // 408: hyapp.wallet.v1.WalletService.CreditAgencyOpeningReward:output_type -> hyapp.wallet.v1.CreditAgencyOpeningRewardResponse 248, // 408: hyapp.wallet.v1.WalletService.CreditInviteActivityReward:output_type -> hyapp.wallet.v1.CreditInviteActivityRewardResponse
252, // 409: hyapp.wallet.v1.WalletService.ApplyGameCoinChange:output_type -> hyapp.wallet.v1.ApplyGameCoinChangeResponse 250, // 409: hyapp.wallet.v1.WalletService.CreditAgencyOpeningReward:output_type -> hyapp.wallet.v1.CreditAgencyOpeningRewardResponse
257, // 410: hyapp.wallet.v1.WalletService.GetRedPacketConfig:output_type -> hyapp.wallet.v1.GetRedPacketConfigResponse 252, // 410: hyapp.wallet.v1.WalletService.ApplyGameCoinChange:output_type -> hyapp.wallet.v1.ApplyGameCoinChangeResponse
259, // 411: hyapp.wallet.v1.WalletService.UpdateRedPacketConfig:output_type -> hyapp.wallet.v1.UpdateRedPacketConfigResponse 257, // 411: hyapp.wallet.v1.WalletService.GetRedPacketConfig:output_type -> hyapp.wallet.v1.GetRedPacketConfigResponse
261, // 412: hyapp.wallet.v1.WalletService.CreateRedPacket:output_type -> hyapp.wallet.v1.CreateRedPacketResponse 259, // 412: hyapp.wallet.v1.WalletService.UpdateRedPacketConfig:output_type -> hyapp.wallet.v1.UpdateRedPacketConfigResponse
263, // 413: hyapp.wallet.v1.WalletService.ClaimRedPacket:output_type -> hyapp.wallet.v1.ClaimRedPacketResponse 261, // 413: hyapp.wallet.v1.WalletService.CreateRedPacket:output_type -> hyapp.wallet.v1.CreateRedPacketResponse
265, // 414: hyapp.wallet.v1.WalletService.ListRedPackets:output_type -> hyapp.wallet.v1.ListRedPacketsResponse 263, // 414: hyapp.wallet.v1.WalletService.ClaimRedPacket:output_type -> hyapp.wallet.v1.ClaimRedPacketResponse
267, // 415: hyapp.wallet.v1.WalletService.GetRedPacket:output_type -> hyapp.wallet.v1.GetRedPacketResponse 265, // 415: hyapp.wallet.v1.WalletService.ListRedPackets:output_type -> hyapp.wallet.v1.ListRedPacketsResponse
269, // 416: hyapp.wallet.v1.WalletService.ExpireRedPackets:output_type -> hyapp.wallet.v1.ExpireRedPacketsResponse 267, // 416: hyapp.wallet.v1.WalletService.GetRedPacket:output_type -> hyapp.wallet.v1.GetRedPacketResponse
271, // 417: hyapp.wallet.v1.WalletService.RetryRedPacketRefund:output_type -> hyapp.wallet.v1.RetryRedPacketRefundResponse 269, // 417: hyapp.wallet.v1.WalletService.ExpireRedPackets:output_type -> hyapp.wallet.v1.ExpireRedPacketsResponse
287, // [287:418] is the sub-list for method output_type 271, // 418: hyapp.wallet.v1.WalletService.RetryRedPacketRefund:output_type -> hyapp.wallet.v1.RetryRedPacketRefundResponse
156, // [156:287] is the sub-list for method input_type 288, // [288:419] is the sub-list for method output_type
156, // [156:156] is the sub-list for extension type_name 157, // [157:288] is the sub-list for method input_type
156, // [156:156] is the sub-list for extension extendee 157, // [157:157] is the sub-list for extension type_name
0, // [0:156] is the sub-list for field type_name 157, // [157:157] is the sub-list for extension extendee
0, // [0:157] is the sub-list for field type_name
} }
func init() { file_proto_wallet_v1_wallet_proto_init() } func init() { file_proto_wallet_v1_wallet_proto_init() }

View File

@ -1139,6 +1139,8 @@ message EquipUserResourceRequest {
int64 user_id = 3; int64 user_id = 3;
int64 resource_id = 4; int64 resource_id = 4;
string entitlement_id = 5; string entitlement_id = 5;
// command_id request_id
string command_id = 6;
} }
message EquipUserResourceResponse { message EquipUserResourceResponse {
@ -2028,6 +2030,9 @@ message VipBenefit {
int64 created_at_ms = 13; int64 created_at_ms = 13;
int64 updated_at_ms = 14; int64 updated_at_ms = 14;
VipBenefitPresentation presentation = 15; VipBenefitPresentation presentation = 15;
// resource resource_id Appactive resource_type
// resource_id/resource_type URL
Resource resource = 16;
} }
message VipLevel { message VipLevel {
@ -2404,6 +2409,10 @@ message CheckVipBenefitResponse {
VipState state = 3; VipState state = 3;
string denial_reason = 4; string denial_reason = 4;
int64 evaluated_at_ms = 5; int64 evaluated_at_ms = 5;
// required_level App active 0
int32 required_level = 6;
// current_effective_level wallet
int32 current_effective_level = 7;
} }
message AdminVipLevelInput { message AdminVipLevelInput {

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,163 @@
# 个人信息页用户上麦状态 Flutter 对接
## 1. 接口职责
个人信息页通过本接口判断被查看用户当前是否占用语音房麦位,并取得可直接交给统一进房入口的房间信息。
- 上麦事实只来自 `room-service` 当前 Room Cell 快照不要用用户是否为房主、房间搜索结果、IM 本地状态或历史缓存推断。
- 接口返回的是带时间戳的瞬时快照;点击 Live 后仍以 `POST /api/v1/rooms/join` 的实时校验为准。
- 当前接口仅用于客态个人信息页。请求中包含当前登录用户本人时,该项固定返回 `not_on_mic`
## 2. 请求
```http
GET /api/v1/users/voice-room-presence:batch?user_ids=163020,163021
Authorization: Bearer {access_token}
```
| 参数 | 类型 | 必填 | 说明 |
| --- | --- | --- | --- |
| `user_ids` | string | 是 | 逗号分隔的系统 `user_id`,不接受展示短号 |
约束:
- 去重后最多 100 个 ID响应顺序按请求中第一次出现的顺序返回。
- 空值、非数字、`0`、负数、空分段或超过上限都会整批返回 `INVALID_USER_IDS`
- `viewer_user_id``app_code``request_id` 由 gateway 从登录态和请求上下文注入,客户端不能提交。
- 接口要求有效登录态且用户已完成资料。
## 3. 成功响应
```json
{
"code": "OK",
"message": "ok",
"request_id": "req_xxx",
"data": {
"items": [
{
"user_id": "163020",
"state": "on_mic",
"seat_no": 3,
"joinable": true,
"observed_at_ms": 1784265600000,
"room": {
"room_id": "100086",
"im_group_id": "100086",
"rtc_room_id": "100086",
"room_short_id": "886688",
"title": "Room name",
"owner_user_id": "12001",
"host_user_id": "12001",
"mode": "voice",
"cover_url": "https://cdn.example.com/room-cover.png",
"background_url": "https://cdn.example.com/room-bg.png",
"online_count": 56,
"seat_count": 10,
"locked": true
}
},
{
"user_id": "163021",
"state": "not_on_mic",
"seat_no": 0,
"joinable": false,
"observed_at_ms": 1784265600000,
"room": null
}
],
"server_time_ms": 1784265600123
}
}
```
字段说明:
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `items[].user_id` | string | 系统用户 IDJSON 中按字符串返回 |
| `items[].state` | string | `on_mic``not_on_mic` |
| `items[].seat_no` | int | 当前麦位编号;未上麦为 `0` |
| `items[].joinable` | bool | 当前 viewer 是否允许通过该入口尝试进房 |
| `items[].observed_at_ms` | int64 | 本批状态完成判定时的 UTC epoch ms |
| `items[].room` | object/null | 仅 `on_mic && joinable` 时非空 |
| `server_time_ms` | int64 | 服务端本批响应时间UTC epoch ms |
房间字段可以直接构造现有 `VoiceRoomEntryArgs`
- `room_id` 是业务房间主键。
- `im_group_id` 当前等于 `room_id`
- `rtc_room_id` 当前使用 TRTC string room ID等于 `room_id`
- 当前房间模型没有独立主持人字段,`host_user_id``owner_user_id` 一致。
- `locked=true` 表示密码房;点击后继续复用现有密码输入和进房流程,不能在个人页自行索取或缓存密码。
## 4. Flutter 展示和刷新规则
只有下面四个条件全部成立时显示 Live
```dart
final showLive = item.state == 'on_mic' &&
item.joinable &&
item.room != null &&
item.room!.roomId.isNotEmpty;
```
点击时:
1. 调用现有 `VoiceRoomNavigation.open()`
2. 用 `room` 构造 `VoiceRoomEntryArgs`
3. `entrySource` 使用 `profile_card_live_badge`
4. 正常调用 `POST /api/v1/rooms/join`;如果用户已经下麦但房间仍允许进入,可以正常进入。
5. 进房返回房间关闭、封禁、密码错误等业务错误时,移除当前 Live 状态并按现有错误规则提示。
刷新时机:
- 首次进入客态个人信息页。
- App 从后台回到前台且个人信息页仍可见。
- 从语音房返回个人信息页。
- 用户手动刷新个人信息页。
不要轮询、持久化或使用上一次成功结果兜底。接口失败时只隐藏 Live不影响个人信息页其他资料。
## 5. 后端状态边界
- `on_mic` 表示房间仍为 `active`、目标用户仍在房间在线集合中,并且占用一个当前有效麦位。`MicUp` 后的 `pending_publish` 和 RTC 已确认的 `publishing` 都属于已占麦。
- 房主或管理员只有实际占用麦位时才算上麦;身份本身不算。
- 同一 App 的 `room_user_presence` 使用 `(app_code, user_id)` 主键定位候选房间,最终状态再由 Room Cell 快照确认。
- 跨 `app_code` 房间不会返回。
- viewer 被目标房间封禁、房间已关闭、presence 已滞后或快照事实不完整时,统一返回 `not_on_mic + room:null`,不泄露旧房间 ID。
- 密码房允许展示 Live返回 `locked=true`,正式进房仍校验密码。
- 当前房间产品模型没有“隐藏房/隐身上麦”字段用户关系也没有资料位置黑名单能力本版不做不存在的状态推断。以后增加此类产品能力时必须在后端权限判断中收敛后再返回Flutter 不得自行判断。
## 6. 错误响应
| HTTP | code | 处理方式 |
| --- | --- | --- |
| 400 | `INVALID_USER_IDS` | 修正参数;不要重试同一请求 |
| 401 | `UNAUTHORIZED` / `ACCESS_TOKEN_EXPIRED` | 走现有登录或刷新 token 流程 |
| 403 | `PROFILE_REQUIRED` | 先完成用户资料 |
| 503 | `VOICE_ROOM_PRESENCE_UNAVAILABLE` | 隐藏 Live页面其他内容继续展示 |
示例:
```json
{
"code": "INVALID_USER_IDS",
"message": "invalid user ids",
"request_id": "req_xxx"
}
```
排障日志只记录接口路径、目标 ID 数量、响应业务码和 `request_id`;不要记录 access token 或密码房密码。
## 7. 联调清单
- 未进房、只在房内未上麦:不显示 Live。
- 在自己的房间或其他人的房间实际占麦:显示 Live并返回实际所在房间。
- `pending_publish``publishing`:都显示 Live。
- 下麦、离房、被踢或关房后重新请求:返回 `not_on_mic`
- 密码房:显示 Live点击后进入既有密码流程。
- viewer 被目标房间封禁、跨 App、查询本人不返回房间定位。
- 一批中混合上麦、未上麦和重复用户:每个去重用户一条结果,顺序稳定。
- 查询成功后立即下麦:点击仍以正式进房接口结果为准。

File diff suppressed because it is too large Load Diff

View File

@ -35,6 +35,7 @@
| GET | `/api/v1/users/by-display-user-id/{display_user_id}` | users | `resolveDisplayUserID` | 通过当前展示短号解析用户 | | GET | `/api/v1/users/by-display-user-id/{display_user_id}` | users | `resolveDisplayUserID` | 通过当前展示短号解析用户 |
| GET | `/api/v1/users/profiles:batch` | users | `batchUserProfiles` | 批量查询会话展示资料 | | GET | `/api/v1/users/profiles:batch` | users | `batchUserProfiles` | 批量查询会话展示资料 |
| GET | `/api/v1/users/room-display-profiles:batch` | users | `batchRoomDisplayProfiles` | 批量查询房间和公屏展示资料 | | GET | `/api/v1/users/room-display-profiles:batch` | users | `batchRoomDisplayProfiles` | 批量查询房间和公屏展示资料 |
| GET | `/api/v1/users/voice-room-presence:batch` | users | `batchUserVoiceRoomPresences` | 批量查询个人信息页可展示的用户上麦入口 |
| GET | `/api/v1/users/me/identity` | users | `getMyIdentity` | 查询当前用户展示短号状态 | | 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/host-identity` | users | `getMyHostIdentity` | 查询当前用户 Host/Agency/BD 身份 |
| GET | `/api/v1/users/me/overview` | users | `getMyOverview` | 我的页首屏聚合摘要 | | GET | `/api/v1/users/me/overview` | users | `getMyOverview` | 我的页首屏聚合摘要 |

View File

@ -49,6 +49,9 @@
| `real_room_robot_total_gift_coin` | 真人房机器人总礼物 | 上面三类机器人礼物金币合计。 | | `real_room_robot_total_gift_coin` | 真人房机器人总礼物 | 上面三类机器人礼物金币合计。 |
| `real_room_robot_gift_room_count` | 真人房机器人投放房间数 | 有真人房机器人礼物的房间去重数。 | | `real_room_robot_gift_room_count` | 真人房机器人投放房间数 | 有真人房机器人礼物的房间去重数。 |
| `real_room_robot_avg_room_gift_coin` | 真人房机器人房均投放 | `real_room_robot_total_gift_coin / real_room_robot_gift_room_count`。 | | `real_room_robot_avg_room_gift_coin` | 真人房机器人房均投放 | `real_room_robot_total_gift_coin / real_room_robot_gift_room_count`。 |
> 2026-07-17 起 robot 送礼改为纯腾讯云 IM 展示,不再写 room/wallet 数据库或发布 MQ。`real_room_robot_*` 字段只保留历史兼容;新事件不会再增长,不能继续作为当前机器人投放口径。
| `lucky_gift_turnover` | lucky 礼物流水 | `RoomGiftSent.pool_id` 非空的送礼金币。 | | `lucky_gift_turnover` | lucky 礼物流水 | `RoomGiftSent.pool_id` 非空的送礼金币。 |
| `lucky_gift_payout` | lucky 礼物产出 | `WalletLuckyGiftRewardCredited.amount`UTC 查询会优先用 activity 的 lucky pool 日表覆盖旧统计行。 | | `lucky_gift_payout` | lucky 礼物产出 | `WalletLuckyGiftRewardCredited.amount`UTC 查询会优先用 activity 的 lucky pool 日表覆盖旧统计行。 |
| `lucky_gift_payers` | lucky 礼物付费用户 | 发送 lucky 礼物的用户按日去重。 | | `lucky_gift_payers` | lucky 礼物付费用户 | 发送 lucky 礼物的用户按日去重。 |

View File

@ -37,11 +37,9 @@
### wallet-service ### wallet-service
- 机器人礼物扣费入口:`DebitRobotGift` - 只通过 `ListGiftConfigs` 提供 active、区域可用的礼物展示快照。
- 使用专用资产 `ROBOT_COIN` - `RobotSendGift` 不调用 `DebitRobotGift`,不创建机器人钱包账户、交易、分录或 wallet outbox。
- 自动补足机器人专用金币后扣减。 - 礼物目录在 room-service 按 App/区域短缓存;缓存只保存配置,不保存任何送礼事实。
- 不发出真实 `WalletGiftDebited` 事件。
- 不写主播周期钻石和真实礼物墙。
### activity/statistics ### activity/statistics
@ -101,7 +99,7 @@ room-service gRPC
wallet-service gRPC wallet-service gRPC
- `DebitRobotGift` - `ListGiftConfigs`(只读)
## 创建流程 ## 创建流程
@ -155,24 +153,22 @@ room-service 启动时会扫描所有 active 机器人房间并恢复运行时
room-service 在机器人送礼时强校验: room-service 在机器人送礼时强校验:
- 当前房间必须是机器人房间 - 专属机器人房必须带 `robot_room=true`;真人房机器人使用运行时机器人池校验,不把真人加入允许集合
- sender 必须在该机器人房间 `robot_user_ids_json` 内。 - sender 必须在该机器人房间 `robot_user_ids_json` 内。
- target 必须在同一机器人房间 `robot_user_ids_json` 内。 - target 必须在同一机器人房间 `robot_user_ids_json` 内。
- sender 不能给真人送礼。 - sender 不能给真人送礼。
- 当前机器人礼物只允许单目标调用。 - 当前机器人礼物只允许单目标调用。
## 钱包隔离 ## 纯展示隔离
机器人礼物使用 `DebitRobotGift` `RobotSendGift` 不进入通用 `sendGift/mutateRoom`,只执行
- 资产类型固定为 `ROBOT_COIN` 1. 获得或续租 Redis owner lease并只读当前 Room Cell缺失时从 MySQL 只读恢复),校验 sender、target 和机器人池。
- 如果机器人 `ROBOT_COIN` 不足,同一事务内自动补足。 2. 从 wallet 礼物目录读取并短缓存名称、图标、动画、类型和展示价值。
- 立即扣减本次礼物价格。 3. 在内存构造 `RoomGiftSent`;幸运礼物额外构造 `RoomRobotLuckyGiftDrawn`
- 交易类型为 `robot_gift_debit` 4. 经过 2 秒展示采样后best-effort 直发腾讯云 IM。
- 不写真实 `gift_debit`
- 不发 `WalletGiftDebited` 这条链路不写 `room_gift_operations``room_command_log``room_snapshots``room_outbox`、房间贡献统计、钱包账本或任何 outbox也不调用 RocketMQ producer。内存里的 `outbox.Record` 只复用 protobuf 信封结构,不是持久化 outbox。
- 不写 `user_gift_wall`
- 不写 `host_period_diamond_accounts`
## 幸运礼物隔离 ## 幸运礼物隔离
@ -189,25 +185,14 @@ room-service 只生成展示事件:
## 排行榜与统计隔离 ## 排行榜与统计隔离
`RoomGiftSent` 增加 机器人展示用 `RoomGiftSent` 保留
- `is_robot_gift` - `is_robot_gift`
- `synthetic_lucky_gift` - `synthetic_lucky_gift`
下游服务看到 `is_robot_gift=true` 时跳过: 该事件不会进入 room outbox/MQ所以下游活动、CP、任务、成长和 statistics 根本不会收到机器人送礼事实;`is_robot_gift=true` 只供 IM 展示兼容。
- 活动播报统计 机器人送礼同时不修改房间热度、礼物榜、麦位 `gift_value`、火箭燃料、周贡献或 Redis 跨房贡献榜。真人房机器人也遵守同一边界;历史 `RealRoomHeat` 参数仅用于区分参与者校验范围,不能授权真实贡献。
- 周星
- 房间流水奖励
- 每日任务
- 成长值
- statistics 礼物消费与幸运礼物统计
room-service 本地仍允许机器人房内:
- 房间热度增长
- 麦位热度增长
- 房内展示事件发送
## 本地接口测试 ## 本地接口测试
@ -232,7 +217,7 @@ go run ./tools/robot-room-api-test
5. 创建机器人房间。 5. 创建机器人房间。
6. 查询 active 列表。 6. 查询 active 列表。
7. 停止、启动、再次停止机器人房间。 7. 停止、启动、再次停止机器人房间。
8. 校验数据库中机器人房间配置和真实 `gift_debit` 隔离 8. 校验 robot 展示前后 `room_gift_operations`、command log、snapshot、room outbox、房间统计行数不变`DebitRobotGift` 调用次数为 0
## 运维注意事项 ## 运维注意事项
@ -241,3 +226,4 @@ go run ./tools/robot-room-api-test
- 同一机器人账号不能被多个 active 机器人房间占用。 - 同一机器人账号不能被多个 active 机器人房间占用。
- 停止机器人房间只停止 runtime不删除房间。 - 停止机器人房间只停止 runtime不删除房间。
- 如果需要彻底释放机器人账号,应增加删除/归档能力后再开放。 - 如果需要彻底释放机器人账号,应增加删除/归档能力后再开放。
- 腾讯云 IM 是唯一实时展示通道,失败不补写数据库或 MQ也不重试成业务事实丢失的动画/公屏不恢复,房间快照只保证真实热度、榜单和麦位值不被机器人展示污染。

View File

@ -260,6 +260,10 @@ func UnaryServerInterceptor(service string) grpc.UnaryServerInterceptor {
if err != nil { if err != nil {
// gRPC access 日志是跨服务排障入口reason 只给稳定错误码message 保留服务端校验分支,便于定位 400/409 的真实拒绝原因。 // gRPC access 日志是跨服务排障入口reason 只给稳定错误码message 保留服务端校验分支,便于定位 400/409 的真实拒绝原因。
attrs = append(attrs, slog.String("error_message", grpcErrorMessage(err))) attrs = append(attrs, slog.String("error_message", grpcErrorMessage(err)))
if cause := xerr.CauseFromGRPC(err); cause != nil {
// Internal/Unknown 对客户端固定脱敏成 "internal error"error_cause 是服务端唯一能看到原始错误链的地方。
attrs = append(attrs, slog.String("error_cause", cause.Error()))
}
} }
attrs = appendServiceAttr(attrs, service) attrs = appendServiceAttr(attrs, service)
attrs = append(attrs, commonAttrsFromPayload(request)...) attrs = append(attrs, commonAttrsFromPayload(request)...)

View File

@ -5,13 +5,17 @@ import (
"context" "context"
"encoding/json" "encoding/json"
"errors" "errors"
"fmt"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"strings"
"testing" "testing"
userv1 "hyapp.local/api/proto/user/v1" userv1 "hyapp.local/api/proto/user/v1"
walletv1 "hyapp.local/api/proto/wallet/v1" walletv1 "hyapp.local/api/proto/wallet/v1"
"hyapp/pkg/xerr"
"google.golang.org/grpc" "google.golang.org/grpc"
"google.golang.org/grpc/codes" "google.golang.org/grpc/codes"
"google.golang.org/grpc/status" "google.golang.org/grpc/status"
@ -127,6 +131,39 @@ func TestUnaryServerInterceptorLevelsAndRequestMeta(t *testing.T) {
} }
} }
// TestUnaryServerInterceptorLogsOriginalCauseForInternal 锁定 2026-07-12 排障改进:
// Internal 类错误客户端只见 "internal error",但 grpc_access ERROR 日志必须带脱敏前的原始错误链。
func TestUnaryServerInterceptorLogsOriginalCauseForInternal(t *testing.T) {
var output bytes.Buffer
if err := Init(Config{Service: "room-service", Env: "test", Format: "json", Output: &output}); err != nil {
t.Fatalf("Init failed: %v", err)
}
interceptor := UnaryServerInterceptor("room-service")
request := &userv1.GetUserRequest{Meta: &userv1.RequestMeta{RequestId: "req-grpc-2", AppCode: "lalu"}, UserId: 1001}
_, err := interceptor(context.Background(), request, &grpc.UnaryServerInfo{FullMethod: "/hyapp.room.v1.RoomCommandService/MicHeartbeat"}, func(ctx context.Context, req any) (any, error) {
return nil, xerr.ToGRPCError(fmt.Errorf("refresh mic presence: %w", errors.New("redis: connection refused")))
})
if err == nil {
t.Fatal("interceptor should return handler error")
}
if st, ok := status.FromError(err); !ok || st.Message() != "internal error" {
t.Fatalf("client-facing error must stay sanitized: %v", err)
}
entry := decodeOne(t, output.Bytes())
if entry["level"] != "ERROR" || entry["msg"] != "grpc_access" || entry["request_id"] != "req-grpc-2" {
t.Fatalf("unexpected grpc log entry: %+v", entry)
}
if entry["error_message"] != "internal error" {
t.Fatalf("error_message=%v, want sanitized message", entry["error_message"])
}
cause, _ := entry["error_cause"].(string)
if !strings.Contains(cause, "refresh mic presence") || !strings.Contains(cause, "redis: connection refused") {
t.Fatalf("error_cause=%q, want original error chain", cause)
}
}
func TestUnaryServerInterceptorExtractsTopLevelTraceFieldsWithoutBody(t *testing.T) { func TestUnaryServerInterceptorExtractsTopLevelTraceFieldsWithoutBody(t *testing.T) {
var output bytes.Buffer var output bytes.Buffer
if err := Init(Config{Service: "wallet-service", Env: "test", Format: "json", Output: &output}); err != nil { if err := Init(Config{Service: "wallet-service", Env: "test", Format: "json", Output: &output}); err != nil {

View File

@ -86,3 +86,26 @@ func (u *Uploader) PutObject(ctx context.Context, key string, reader io.Reader,
return publicURL, nil return publicURL, nil
} }
// OwnsObjectURL 证明 URL 正好属于当前配置的 access_url 且对应同一个 object key。
// gateway 在接受客户端回传媒体元数据时必须调用它,不能只相信 https 或 URL 后缀。
func (u *Uploader) OwnsObjectURL(objectURL string, objectKey string) bool {
if u == nil {
return false
}
objectKey = strings.TrimLeft(strings.TrimSpace(objectKey), "/")
if objectKey == "" {
return false
}
expected, err := url.JoinPath(strings.TrimRight(u.accessURL, "/"), objectKey)
if err != nil {
return false
}
actualURL, actualErr := url.Parse(strings.TrimSpace(objectURL))
expectedURL, expectedErr := url.Parse(expected)
if actualErr != nil || expectedErr != nil || actualURL.Scheme != "https" || actualURL.RawQuery != "" || actualURL.Fragment != "" {
return false
}
return strings.EqualFold(actualURL.Scheme, expectedURL.Scheme) && strings.EqualFold(actualURL.Host, expectedURL.Host) &&
actualURL.EscapedPath() == expectedURL.EscapedPath()
}

View File

@ -4,6 +4,7 @@ import (
"bytes" "bytes"
"context" "context"
"crypto/rand" "crypto/rand"
"crypto/sha256"
"encoding/binary" "encoding/binary"
"encoding/json" "encoding/json"
"errors" "errors"
@ -287,8 +288,11 @@ func (c *RESTClient) PublishGroupCustomMessage(ctx context.Context, message Cust
} }
request := sendGroupMsgRequest{ request := sendGroupMsgRequest{
GroupID: message.GroupID, GroupID: message.GroupID,
Random: randomUint32(), // 腾讯云群消息会在短窗口内按 Random + 完整消息内容去重。outbox 的 event_id
// 对同一业务事实稳定,因此从它派生 Random 可收敛“腾讯已成功、本地尚未标记”后的补偿重试;
// 客户端仍应按 payload.event_id 去重,以覆盖超出腾讯去重窗口后的极端重放。
Random: stableMessageRandom(message.EventID),
CloudCustomData: string(payload), CloudCustomData: string(payload),
FromAccount: message.FromAccount, FromAccount: message.FromAccount,
MsgBody: []messageElement{ MsgBody: []messageElement{
@ -312,6 +316,11 @@ func (c *RESTClient) PublishGroupCustomMessage(ctx context.Context, message Cust
return response.err() return response.err()
} }
func stableMessageRandom(eventID string) uint32 {
sum := sha256.Sum256([]byte(strings.TrimSpace(eventID)))
return binary.BigEndian.Uint32(sum[:4])
}
// PublishUserCustomMessage 向单个腾讯云 IM identifier 发送 TIMCustomElem。 // PublishUserCustomMessage 向单个腾讯云 IM identifier 发送 TIMCustomElem。
// 该方法只负责投递协议;业务幂等、隐私字段和重试策略必须由调用方在 outbox 中保证。 // 该方法只负责投递协议;业务幂等、隐私字段和重试策略必须由调用方在 outbox 中保证。
func (c *RESTClient) PublishUserCustomMessage(ctx context.Context, message CustomUserMessage) error { func (c *RESTClient) PublishUserCustomMessage(ctx context.Context, message CustomUserMessage) error {

View File

@ -35,6 +35,7 @@ const (
EventTypeWalletRedPacketClaimed = "WalletRedPacketClaimed" EventTypeWalletRedPacketClaimed = "WalletRedPacketClaimed"
EventTypeWalletRedPacketRefunded = "WalletRedPacketRefunded" EventTypeWalletRedPacketRefunded = "WalletRedPacketRefunded"
EventTypeVIPActivated = "VipActivated" EventTypeVIPActivated = "VipActivated"
EventTypeVIPProgramChanged = "VipProgramChanged"
EventTypeVIPDailyCoinRebateAvailable = "VipDailyCoinRebateAvailable" EventTypeVIPDailyCoinRebateAvailable = "VipDailyCoinRebateAvailable"
EventTypeResourceGranted = "ResourceGranted" EventTypeResourceGranted = "ResourceGranted"
EventTypeResourceGroupGranted = "ResourceGroupGranted" EventTypeResourceGroupGranted = "ResourceGroupGranted"

View File

@ -3,6 +3,7 @@ package xerr
import ( import (
"errors" "errors"
"fmt" "fmt"
"strings"
) )
// Code 是服务内部稳定错误原因。 // Code 是服务内部稳定错误原因。
@ -174,6 +175,10 @@ const (
type Error struct { type Error struct {
Code Code Code Code
Message string Message string
// Metadata 只保存业务层显式声明可跨服务、可返回客户端的短字段。
// 原始错误、SQL、配置和第三方响应不得写入这里ToGRPCError 会把它投影到
// google.rpc.ErrorInfo.metadatagateway 再原样写入 envelope.data。
Metadata map[string]string
} }
// Error 实现标准 error 接口。 // Error 实现标准 error 接口。
@ -195,6 +200,17 @@ func New(code Code, message string) error {
} }
} }
// NewWithMetadata 构造带安全结构化详情的业务错误。
// metadata 是显式的 wire contract不接受空键键和值都会去除首尾空白并复制
// 防止调用方后续修改 map 导致同一个错误在日志与 wire 上出现不同语义。
func NewWithMetadata(code Code, message string, metadata map[string]string) error {
return &Error{
Code: code,
Message: message,
Metadata: normalizeMetadata(metadata),
}
}
// As 提取当前仓库约定的业务错误。 // As 提取当前仓库约定的业务错误。
func As(err error) (*Error, bool) { func As(err error) (*Error, bool) {
if err == nil { if err == nil {
@ -234,3 +250,41 @@ func MessageOf(err error) string {
return "internal error" return "internal error"
} }
// MetadataOf 返回业务错误显式声明的安全 metadata 副本。
// 未识别错误没有可安全暴露的结构化详情,必须返回 nil 而不是猜测原始错误内容。
func MetadataOf(err error) map[string]string {
if typed, ok := As(err); ok {
return cloneMetadata(typed.Metadata)
}
return nil
}
func normalizeMetadata(metadata map[string]string) map[string]string {
if len(metadata) == 0 {
return nil
}
normalized := make(map[string]string, len(metadata))
for key, value := range metadata {
key = strings.TrimSpace(key)
if key == "" {
continue
}
normalized[key] = strings.TrimSpace(value)
}
if len(normalized) == 0 {
return nil
}
return normalized
}
func cloneMetadata(metadata map[string]string) map[string]string {
if len(metadata) == 0 {
return nil
}
cloned := make(map[string]string, len(metadata))
for key, value := range metadata {
cloned[key] = value
}
return cloned
}

View File

@ -45,16 +45,48 @@ func ToGRPCError(err error) error {
st := status.New(GRPCCode(domainErr.Code), domainErr.Message) st := status.New(GRPCCode(domainErr.Code), domainErr.Message)
detail := &errdetails.ErrorInfo{ detail := &errdetails.ErrorInfo{
Reason: string(domainErr.Code), Reason: string(domainErr.Code),
Domain: errorDomain, Domain: errorDomain,
Metadata: cloneMetadata(domainErr.Metadata),
} }
withDetails, detailErr := st.WithDetails(detail) if withDetails, detailErr := st.WithDetails(detail); detailErr == nil {
if detailErr != nil { st = withDetails
return st.Err()
} }
return withDetails.Err() return &grpcError{status: st, cause: err}
}
// grpcError 对 grpc-go 暴露脱敏后的 status同时保留脱敏前的原始错误链。
// 客户端从 GRPCStatus/Error 只能看到 catalog 文案;原始链只有服务端进程内
// 通过 CauseFromGRPC 可达,不会进 wire。
type grpcError struct {
status *status.Status
cause error
}
func (e *grpcError) Error() string {
return e.status.Err().Error()
}
// GRPCStatus 让 status.FromError 和 grpc-go server 识别脱敏 status而不是降级成 Unknown。
func (e *grpcError) GRPCStatus() *status.Status {
return e.status
}
// Unwrap 保留原始错误链,供服务端 errors.Is/As 与 access 日志定位根因。
func (e *grpcError) Unwrap() error {
return e.cause
}
// CauseFromGRPC 取回 ToGRPCError 脱敏前的原始错误链,仅用于服务端日志;
// 对已经是 gRPC status 的透传错误返回 nil。禁止把返回值写回给客户端。
func CauseFromGRPC(err error) error {
var typed *grpcError
if errors.As(err, &typed) {
return typed.cause
}
return nil
} }
// ReasonFromGRPC 供 gateway 或测试从 gRPC error detail 中恢复稳定 reason。 // ReasonFromGRPC 供 gateway 或测试从 gRPC error detail 中恢复稳定 reason。
@ -78,6 +110,25 @@ func ReasonFromGRPC(err error) Code {
return codeFromGRPC(st.Code()) return codeFromGRPC(st.Code())
} }
// MetadataFromGRPC 只读取本项目 ErrorInfo 中由 owner service 显式声明的安全字段。
// 其它 detail/domain 不属于公开 HTTP 契约gateway 不得把它们混入 envelope.data。
func MetadataFromGRPC(err error) map[string]string {
if err == nil {
return nil
}
st, ok := status.FromError(err)
if !ok {
return nil
}
for _, detail := range st.Details() {
info, ok := detail.(*errdetails.ErrorInfo)
if ok && info.GetDomain() == errorDomain && info.GetReason() != "" {
return cloneMetadata(info.GetMetadata())
}
}
return nil
}
// codeFromGRPC 是没有 ErrorInfo detail 时的降级映射。 // codeFromGRPC 是没有 ErrorInfo detail 时的降级映射。
func codeFromGRPC(code codes.Code) Code { func codeFromGRPC(code codes.Code) Code {
switch code { switch code {

View File

@ -2,6 +2,7 @@ package xerr
import ( import (
"context" "context"
"errors"
"fmt" "fmt"
"go/ast" "go/ast"
"go/parser" "go/parser"
@ -54,6 +55,37 @@ func TestToGRPCErrorPreservesContextTermination(t *testing.T) {
} }
} }
// TestToGRPCErrorSanitizesButPreservesCause 锁定 2026-07-12 排障改进:
// 非 xerr 错误对客户端仍固定脱敏成 internal error但服务端能通过 CauseFromGRPC 取回原始错误链。
func TestToGRPCErrorSanitizesButPreservesCause(t *testing.T) {
rootCause := errors.New("redis: connection refused")
err := ToGRPCError(fmt.Errorf("acquire room lease: %w", rootCause))
st, ok := status.FromError(err)
if !ok {
t.Fatalf("expected grpc status")
}
if st.Code() != codes.Internal || st.Message() != "internal error" {
t.Fatalf("client-facing status must stay sanitized: code=%s message=%q", st.Code(), st.Message())
}
if err.Error() != st.Err().Error() {
t.Fatalf("Error() must not leak cause: %q", err.Error())
}
cause := CauseFromGRPC(err)
if cause == nil || !errors.Is(cause, rootCause) {
t.Fatalf("cause chain lost: got %v", cause)
}
// 透传已有 status 的错误没有被脱敏,不应报告 cause。
if got := CauseFromGRPC(status.Error(codes.NotFound, "room not found")); got != nil {
t.Fatalf("passthrough status should have no cause, got %v", got)
}
if got := ToGRPCError(err); got != err {
t.Fatalf("ToGRPCError must be idempotent on wrapped errors")
}
}
func TestCatalogCoversDeclaredCodes(t *testing.T) { func TestCatalogCoversDeclaredCodes(t *testing.T) {
declared := declaredCodesFromSource(t) declared := declaredCodesFromSource(t)

View File

@ -36,7 +36,10 @@ INCREMENTAL_MIGRATION_FILES=(
"services/user-service/deploy/mysql/migrations/012_login_audit_client_version.sql" "services/user-service/deploy/mysql/migrations/012_login_audit_client_version.sql"
"services/user-service/deploy/mysql/migrations/017_cp_application_gift_coin_value.sql" "services/user-service/deploy/mysql/migrations/017_cp_application_gift_coin_value.sql"
"services/user-service/deploy/mysql/migrations/018_auth_refresh_token_rotation.sql" "services/user-service/deploy/mysql/migrations/018_auth_refresh_token_rotation.sql"
"services/user-service/deploy/mysql/migrations/019_user_profile_anonymous_visits.sql"
"services/room-service/deploy/mysql/migrations/003_room_vip_media_and_decorations.sql"
"services/wallet-service/deploy/mysql/migrations/002_resource_shop_price_tiers.sql" "services/wallet-service/deploy/mysql/migrations/002_resource_shop_price_tiers.sql"
"services/wallet-service/deploy/mysql/migrations/003_vip_resource_equipment_contract.sql"
) )
# Keep MySQL as the only required dependency for this script. Business services # Keep MySQL as the only required dependency for this script. Business services

View File

@ -0,0 +1,258 @@
SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci;
USE hyapp_wallet;
-- 装备幂等表是 append-only 小行写入;所有重放只按主键 (app_code,command_id) 点查,
-- 不修改 user_resource_equipment 主键,也不会为上线迁移扫描用户装备或 entitlement 大表。
CREATE TABLE IF NOT EXISTS user_resource_equipment_commands (
app_code VARCHAR(32) NOT NULL COMMENT '应用编码,用于多租户隔离',
command_id VARCHAR(128) NOT NULL COMMENT '装备业务幂等 ID',
request_hash CHAR(64) NOT NULL COMMENT '用户、资源和指定 entitlement 的请求语义哈希',
user_id BIGINT NOT NULL COMMENT '操作用户 ID',
resource_id BIGINT NOT NULL COMMENT '目标资源 ID',
requested_entitlement_id VARCHAR(96) NOT NULL DEFAULT '' COMMENT '客户端指定的 entitlement空表示服务端选择',
result_entitlement_id VARCHAR(96) NOT NULL DEFAULT '' COMMENT '首个成功命中的 entitlement',
resource_type VARCHAR(32) NOT NULL DEFAULT '' COMMENT '首个成功结果的资源类型',
response_json JSON NULL COMMENT '首个成功 UserResourceEntitlement 回包快照',
created_at_ms BIGINT NOT NULL COMMENT '首次命令时间UTC epoch ms',
updated_at_ms BIGINT NOT NULL COMMENT '结果完成时间UTC epoch ms',
PRIMARY KEY (app_code, command_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户资源装备命令幂等事实表';
-- 配置矩阵很小,历史表按 admin config_version 追加;装备和 appearance 只按资源覆盖索引点查。
-- 记录 program_type避免 legacy_timed App 的普通资源被 tiered VIP 门禁误伤。
CREATE TABLE IF NOT EXISTS vip_benefit_resource_binding_history (
app_code VARCHAR(32) NOT NULL COMMENT '应用编码,用于多租户隔离',
history_id CHAR(64) NOT NULL COMMENT '配置版本、阶段、等级和权益的稳定哈希',
config_version BIGINT NOT NULL COMMENT '写入该快照的 VIP 配置版本',
change_phase VARCHAR(16) NOT NULL COMMENT 'baseline/before/after',
program_type VARCHAR(32) NOT NULL COMMENT '快照时的 VIP program 类型',
level INT NOT NULL COMMENT 'VIP 等级',
benefit_code VARCHAR(64) NOT NULL COMMENT '权益编码',
benefit_status VARCHAR(32) NOT NULL COMMENT '权益快照状态',
trial_enabled BOOLEAN NOT NULL DEFAULT FALSE COMMENT '体验卡是否可用快照',
resource_id BIGINT NOT NULL DEFAULT 0 COMMENT '当时绑定资源 ID0 表示未绑定',
resource_type VARCHAR(32) NOT NULL DEFAULT '' COMMENT '当时要求的资源类型',
auto_equip BOOLEAN NOT NULL DEFAULT FALSE COMMENT '当时自动装备策略',
recorded_at_ms BIGINT NOT NULL COMMENT '记录时间UTC epoch ms',
PRIMARY KEY (app_code, history_id),
UNIQUE KEY uk_vip_binding_history_snapshot (app_code, config_version, change_phase, level, benefit_code),
KEY idx_vip_binding_history_resource (app_code, resource_id, program_type, benefit_code, level)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='VIP 权益资源绑定不可变历史';
-- 装备门禁按单 App、单 resource_id 查询 active 绑定。该配置表规模远小于业务流水,
-- 仍显式补覆盖索引并使用在线 DDL避免每次装备扫描完整权益矩阵。
SET @ddl := IF(
(SELECT COUNT(*) FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'vip_level_benefits'
AND INDEX_NAME = 'idx_vip_benefit_resource') = 0,
'ALTER TABLE vip_level_benefits ADD INDEX idx_vip_benefit_resource (app_code, resource_id, status, benefit_code, level), ALGORITHM=INPLACE, LOCK=NONE',
'SELECT 1'
);
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @vip_resource_contract_now_ms := CAST(UNIX_TIMESTAMP(UTC_TIMESTAMP(3)) * 1000 AS UNSIGNED);
-- 在任何修正规则执行前保存旧映射;后续 admin 更新还会为每个新版本分别追加 before/after。
INSERT IGNORE INTO vip_benefit_resource_binding_history (
app_code, history_id, config_version, change_phase, program_type, level,
benefit_code, benefit_status, trial_enabled, resource_id, resource_type,
auto_equip, recorded_at_ms
)
SELECT b.app_code,
SHA2(CONCAT_WS('|', b.app_code, p.config_version, 'baseline', b.level, b.benefit_code), 256),
p.config_version, 'baseline', p.program_type, b.level, b.benefit_code,
b.status, b.trial_enabled, b.resource_id, b.resource_type, b.auto_equip,
@vip_resource_contract_now_ms
FROM vip_level_benefits AS b
JOIN vip_program_configs AS p ON p.app_code = b.app_code;
-- tiered Fami 的房间视觉权益使用稳定映射mic_skin -> mic_seat_animation、
-- room_border -> room_border、colored_room_name -> room_name_style。三项都属于 decoration
-- disabled 可以无素材,但若已有 resource_id 也不能保留错误类型或下架资源。
UPDATE vip_level_benefits
SET benefit_type = 'decoration',
resource_type = CASE benefit_code
WHEN 'mic_skin' THEN 'mic_seat_animation'
WHEN 'room_border' THEN 'room_border'
WHEN 'colored_room_name' THEN 'room_name_style'
END,
updated_at_ms = @vip_resource_contract_now_ms
WHERE app_code = 'fami'
AND benefit_code IN ('mic_skin', 'room_border', 'colored_room_name')
AND (
benefit_type <> 'decoration'
OR resource_type <> CASE benefit_code
WHEN 'mic_skin' THEN 'mic_seat_animation'
WHEN 'room_border' THEN 'room_border'
WHEN 'colored_room_name' THEN 'room_name_style'
END
);
SET @vip_decoration_normalize_count := ROW_COUNT();
UPDATE vip_level_benefits AS benefit
LEFT JOIN resources AS resource
ON resource.app_code = benefit.app_code
AND resource.resource_id = benefit.resource_id
AND resource.status = 'active'
AND resource.resource_type = CASE benefit.benefit_code
WHEN 'mic_skin' THEN 'mic_seat_animation'
WHEN 'room_border' THEN 'room_border'
WHEN 'colored_room_name' THEN 'room_name_style'
END
SET benefit.resource_id = 0,
benefit.updated_at_ms = @vip_resource_contract_now_ms
WHERE benefit.app_code = 'fami'
AND benefit.benefit_code IN ('mic_skin', 'room_border', 'colored_room_name')
AND benefit.resource_id > 0
AND resource.resource_id IS NULL;
SET @vip_invalid_resource_clear_count := ROW_COUNT();
-- 本地现有 Fami VIP4..VIP8 资源组各自只有一个可核实的 active mic_seat_animation。
-- 这里按稳定 group_code + 类型解析 ID并用 HAVING COUNT(DISTINCT)=1 防止运营新增候选后误绑;
-- VIP9、room_border、room_name_style 当前没有真实目录资源,必须保持未配置,不能伪造 ID/URL。
UPDATE vip_level_benefits AS benefit
JOIN (
SELECT resource_group.group_code, MIN(resource.resource_id) AS resource_id
FROM resource_groups AS resource_group
JOIN resource_group_items AS group_item
ON group_item.app_code = resource_group.app_code
AND group_item.group_id = resource_group.group_id
AND group_item.item_type = 'resource'
JOIN resources AS resource
ON resource.app_code = group_item.app_code
AND resource.resource_id = group_item.resource_id
AND resource.resource_type = 'mic_seat_animation'
AND resource.status = 'active'
WHERE resource_group.app_code = 'fami'
AND resource_group.status = 'active'
AND resource_group.group_code IN ('VIP4', 'VIP5', 'VIP6', 'VIP7', 'VIP8')
GROUP BY resource_group.group_code
HAVING COUNT(DISTINCT resource.resource_id) = 1
) AS mapping
ON mapping.group_code = CONCAT('VIP', benefit.level)
SET benefit.resource_id = mapping.resource_id,
benefit.resource_type = 'mic_seat_animation',
benefit.updated_at_ms = @vip_resource_contract_now_ms
WHERE benefit.app_code = 'fami'
AND benefit.level BETWEEN 4 AND 8
AND benefit.benefit_code = 'mic_skin'
AND benefit.status = 'active'
AND benefit.resource_id = 0;
SET @vip_mic_skin_bind_count := ROW_COUNT();
-- 只给“已经被 active mic_skin 绑定”的真实麦座动画补显式 format先信任后台已有的
-- 合法 animation_format否则只从实际 animation/asset URL 的明确后缀推导。这个更新
-- 通过权益配置小表圈定 resource_id再按 resources 主键点查,不会扫描或修改未绑定资源。
UPDATE vip_level_benefits AS benefit
JOIN resources AS resource FORCE INDEX (PRIMARY)
ON resource.resource_id = benefit.resource_id
AND resource.app_code = benefit.app_code
AND resource.resource_type = 'mic_seat_animation'
AND resource.status = 'active'
SET resource.metadata_json = JSON_SET(
COALESCE(resource.metadata_json, JSON_OBJECT()),
'$.format',
CASE
WHEN LOWER(TRIM(COALESCE(JSON_UNQUOTE(JSON_EXTRACT(resource.metadata_json, '$.animation_format')), '')))
IN ('png', 'jpeg', 'webp', 'gif', 'svga', 'pag', 'mp4')
THEN LOWER(TRIM(JSON_UNQUOTE(JSON_EXTRACT(resource.metadata_json, '$.animation_format'))))
WHEN LOWER(SUBSTRING_INDEX(SUBSTRING_INDEX(TRIM(resource.animation_url), '?', 1), '#', 1))
REGEXP '\\.(png|jpeg|jpg|webp|gif|svga|pag|mp4)$'
THEN CASE LOWER(SUBSTRING_INDEX(SUBSTRING_INDEX(SUBSTRING_INDEX(TRIM(resource.animation_url), '?', 1), '#', 1), '.', -1))
WHEN 'jpg' THEN 'jpeg'
ELSE LOWER(SUBSTRING_INDEX(SUBSTRING_INDEX(SUBSTRING_INDEX(TRIM(resource.animation_url), '?', 1), '#', 1), '.', -1))
END
ELSE CASE LOWER(SUBSTRING_INDEX(SUBSTRING_INDEX(SUBSTRING_INDEX(TRIM(resource.asset_url), '?', 1), '#', 1), '.', -1))
WHEN 'jpg' THEN 'jpeg'
ELSE LOWER(SUBSTRING_INDEX(SUBSTRING_INDEX(SUBSTRING_INDEX(TRIM(resource.asset_url), '?', 1), '#', 1), '.', -1))
END
END
),
resource.updated_at_ms = @vip_resource_contract_now_ms
WHERE benefit.app_code = 'fami'
AND benefit.benefit_code = 'mic_skin'
AND benefit.status = 'active'
AND benefit.resource_type = 'mic_seat_animation'
AND benefit.resource_id > 0
AND (TRIM(resource.asset_url) <> '' OR TRIM(resource.animation_url) <> '')
AND TRIM(resource.preview_url) <> ''
AND LOWER(TRIM(COALESCE(JSON_UNQUOTE(JSON_EXTRACT(resource.metadata_json, '$.format')), '')))
NOT IN ('png', 'jpeg', 'webp', 'gif', 'svga', 'pag', 'mp4')
AND (
LOWER(TRIM(COALESCE(JSON_UNQUOTE(JSON_EXTRACT(resource.metadata_json, '$.animation_format')), '')))
IN ('png', 'jpeg', 'webp', 'gif', 'svga', 'pag', 'mp4')
OR LOWER(SUBSTRING_INDEX(SUBSTRING_INDEX(TRIM(resource.animation_url), '?', 1), '#', 1))
REGEXP '\\.(png|jpeg|jpg|webp|gif|svga|pag|mp4)$'
OR LOWER(SUBSTRING_INDEX(SUBSTRING_INDEX(TRIM(resource.asset_url), '?', 1), '#', 1))
REGEXP '\\.(png|jpeg|jpg|webp|gif|svga|pag|mp4)$'
);
SET @vip_mic_format_backfill_count := ROW_COUNT();
-- active 必须能联结到同 App、active、精确类型的真实资源。麦座动画和房间边框还必须
-- 同时具备素材、preview、合法 format且可选 animation_format 一旦配置就必须一致。
-- 缺少真实配置时只禁用权益,不创建占位资源,也不猜测素材 URL。
UPDATE vip_level_benefits AS benefit
LEFT JOIN resources AS resource
ON resource.app_code = benefit.app_code
AND resource.resource_id = benefit.resource_id
AND resource.status = 'active'
AND resource.resource_type = benefit.resource_type
SET benefit.status = 'disabled',
benefit.updated_at_ms = @vip_resource_contract_now_ms
WHERE benefit.app_code = 'fami'
AND benefit.benefit_code IN ('mic_skin', 'room_border', 'colored_room_name')
AND benefit.status = 'active'
AND (
resource.resource_id IS NULL
OR (
benefit.resource_type IN ('mic_seat_animation', 'room_border')
AND (
(TRIM(resource.asset_url) = '' AND TRIM(resource.animation_url) = '')
OR TRIM(resource.preview_url) = ''
OR LOWER(TRIM(COALESCE(JSON_UNQUOTE(JSON_EXTRACT(resource.metadata_json, '$.format')), '')))
NOT IN ('png', 'jpeg', 'webp', 'gif', 'svga', 'pag', 'mp4')
OR (
LOWER(TRIM(COALESCE(JSON_UNQUOTE(JSON_EXTRACT(resource.metadata_json, '$.animation_format')), ''))) <> ''
AND LOWER(TRIM(JSON_UNQUOTE(JSON_EXTRACT(resource.metadata_json, '$.animation_format'))))
<> LOWER(TRIM(JSON_UNQUOTE(JSON_EXTRACT(resource.metadata_json, '$.format'))))
)
)
)
);
SET @vip_decoration_disable_count := ROW_COUNT();
-- config_version 只在首次真实回填时递增;迁移重复执行不会制造无意义版本变化。
UPDATE vip_program_configs
SET config_version = config_version + 1,
updated_at_ms = @vip_resource_contract_now_ms
WHERE app_code = 'fami'
AND (
@vip_decoration_normalize_count
+ @vip_invalid_resource_clear_count
+ @vip_mic_skin_bind_count
+ @vip_mic_format_backfill_count
+ @vip_decoration_disable_count
) > 0;
-- 本次迁移若提升了 config_version立即写入新版本的最终 baseline。这样下一次重放开头的
-- baseline INSERT 会命中唯一键,不会仅因为上次版本递增再追加一轮完全相同的历史快照。
INSERT IGNORE INTO vip_benefit_resource_binding_history (
app_code, history_id, config_version, change_phase, program_type, level,
benefit_code, benefit_status, trial_enabled, resource_id, resource_type,
auto_equip, recorded_at_ms
)
SELECT b.app_code,
SHA2(CONCAT_WS('|', b.app_code, p.config_version, 'baseline', b.level, b.benefit_code), 256),
p.config_version, 'baseline', p.program_type, b.level, b.benefit_code,
b.status, b.trial_enabled, b.resource_id, b.resource_type, b.auto_equip,
@vip_resource_contract_now_ms
FROM vip_level_benefits AS b
JOIN vip_program_configs AS p ON p.app_code = b.app_code;

View File

@ -0,0 +1,156 @@
-- 与 room-service/deploy/mysql/migrations/003_room_vip_media_and_decorations.sql 保持同一可重入结构。
-- 性能边界INSTANT 列仍会短暂申请 metadata lockimage_url 扩容及唯一索引在线扫描低频素材表,建议低峰执行。
SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci;
USE hyapp_room;
DELIMITER $$
DROP PROCEDURE IF EXISTS hyapp_migrate_071_room_vip_media_decorations$$
CREATE PROCEDURE hyapp_migrate_071_room_vip_media_decorations()
BEGIN
DECLARE duplicate_groups BIGINT DEFAULT 0;
DECLARE oversized_urls BIGINT DEFAULT 0;
IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema = DATABASE() AND table_name = 'room_background_images') THEN
IF EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = DATABASE() AND table_name = 'room_background_images' AND column_name = 'image_url'
AND (data_type <> 'varchar' OR character_maximum_length <> 1024)
) THEN
SELECT COUNT(*) INTO oversized_urls FROM room_background_images WHERE CHAR_LENGTH(image_url) > 1024;
IF oversized_urls > 0 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'room_background_images.image_url contains values longer than 1024';
END IF;
ALTER TABLE room_background_images MODIFY COLUMN image_url VARCHAR(1024) NOT NULL, ALGORITHM=INPLACE, LOCK=NONE;
END IF;
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'room_background_images' AND column_name = 'command_id') THEN
ALTER TABLE room_background_images ADD COLUMN command_id VARCHAR(128) NULL COMMENT '新版素材保存幂等键;历史行保持 NULL', ALGORITHM=INSTANT;
END IF;
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'room_background_images' AND column_name = 'object_key') THEN
ALTER TABLE room_background_images ADD COLUMN object_key VARCHAR(512) NOT NULL DEFAULT '' COMMENT 'COS 对象 key', ALGORITHM=INSTANT;
END IF;
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'room_background_images' AND column_name = 'content_type') THEN
ALTER TABLE room_background_images ADD COLUMN content_type VARCHAR(64) NOT NULL DEFAULT '' COMMENT '服务端嗅探 MIME', ALGORITHM=INSTANT;
END IF;
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'room_background_images' AND column_name = 'media_format') THEN
ALTER TABLE room_background_images ADD COLUMN media_format VARCHAR(16) NOT NULL DEFAULT '' COMMENT 'jpeg/png/gif/webp', ALGORITHM=INSTANT;
END IF;
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'room_background_images' AND column_name = 'size_bytes') THEN
ALTER TABLE room_background_images ADD COLUMN size_bytes BIGINT NOT NULL DEFAULT 0 COMMENT '文件字节数', ALGORITHM=INSTANT;
END IF;
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'room_background_images' AND column_name = 'width') THEN
ALTER TABLE room_background_images ADD COLUMN width INT NOT NULL DEFAULT 0 COMMENT '像素宽度', ALGORITHM=INSTANT;
END IF;
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'room_background_images' AND column_name = 'height') THEN
ALTER TABLE room_background_images ADD COLUMN height INT NOT NULL DEFAULT 0 COMMENT '像素高度', ALGORITHM=INSTANT;
END IF;
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'room_background_images' AND column_name = 'animated') THEN
ALTER TABLE room_background_images ADD COLUMN animated BOOLEAN NOT NULL DEFAULT FALSE COMMENT '是否动图', ALGORITHM=INSTANT;
END IF;
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'room_background_images' AND column_name = 'frame_count') THEN
ALTER TABLE room_background_images ADD COLUMN frame_count INT NOT NULL DEFAULT 0 COMMENT '帧数', ALGORITHM=INSTANT;
END IF;
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'room_background_images' AND column_name = 'duration_ms') THEN
ALTER TABLE room_background_images ADD COLUMN duration_ms BIGINT NOT NULL DEFAULT 0 COMMENT '动图总时长', ALGORITHM=INSTANT;
END IF;
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'room_background_images' AND column_name = 'poster_url') THEN
ALTER TABLE room_background_images ADD COLUMN poster_url VARCHAR(1024) NOT NULL DEFAULT '' COMMENT '可选首帧 URL', ALGORITHM=INSTANT;
END IF;
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'room_background_images' AND column_name = 'thumbnail_url') THEN
ALTER TABLE room_background_images ADD COLUMN thumbnail_url VARCHAR(1024) NOT NULL DEFAULT '' COMMENT '可选缩略图 URL', ALGORITHM=INSTANT;
END IF;
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'room_background_images' AND column_name = 'sha256') THEN
ALTER TABLE room_background_images ADD COLUMN sha256 CHAR(64) NOT NULL DEFAULT '' COMMENT '原文件 SHA-256', ALGORITHM=INSTANT;
END IF;
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'room_background_images' AND column_name = 'media_status') THEN
ALTER TABLE room_background_images ADD COLUMN media_status VARCHAR(32) NOT NULL DEFAULT '' COMMENT 'active 表示已通过专用上传入口', ALGORITHM=INSTANT;
END IF;
IF NOT EXISTS (
SELECT 1 FROM information_schema.statistics
WHERE table_schema = DATABASE() AND table_name = 'room_background_images' AND index_name = 'uk_room_background_command'
) THEN
SELECT COUNT(*) INTO duplicate_groups
FROM (
SELECT 1 FROM room_background_images
WHERE command_id IS NOT NULL
GROUP BY app_code, room_id, command_id
HAVING COUNT(*) > 1
LIMIT 1
) AS duplicate_command_groups;
IF duplicate_groups > 0 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'duplicate room background command_id blocks unique index';
END IF;
ALTER TABLE room_background_images ADD UNIQUE KEY uk_room_background_command (app_code, room_id, command_id), ALGORITHM=INPLACE, LOCK=NONE;
END IF;
END IF;
IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema = DATABASE() AND table_name = 'room_list_entries') THEN
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'room_list_entries' AND column_name = 'room_border_json') THEN
ALTER TABLE room_list_entries ADD COLUMN room_border_json JSON NULL COMMENT '当前未过期房间边框资源快照', ALGORITHM=INSTANT;
END IF;
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'room_list_entries' AND column_name = 'room_name_style_json') THEN
ALTER TABLE room_list_entries ADD COLUMN room_name_style_json JSON NULL COMMENT '当前未过期彩色房名资源快照', ALGORITHM=INSTANT;
END IF;
END IF;
END$$
CALL hyapp_migrate_071_room_vip_media_decorations()$$
DROP PROCEDURE IF EXISTS hyapp_migrate_071_room_vip_media_decorations$$
DELIMITER ;
CREATE TABLE IF NOT EXISTS room_media_upload_registrations (
app_code VARCHAR(32) NOT NULL,
room_id VARCHAR(64) NOT NULL,
command_id VARCHAR(128) NOT NULL,
actor_user_id BIGINT NOT NULL,
purpose VARCHAR(32) NOT NULL,
media_payload_sha256 CHAR(64) NOT NULL,
expected_object_key VARCHAR(512) NOT NULL,
object_url VARCHAR(1024) NOT NULL DEFAULT '',
status VARCHAR(32) NOT NULL DEFAULT 'authorized',
content_type VARCHAR(64) NOT NULL,
media_format VARCHAR(16) NOT NULL,
size_bytes BIGINT NOT NULL,
width INT NOT NULL,
height INT NOT NULL,
animated BOOLEAN NOT NULL DEFAULT FALSE,
frame_count INT NOT NULL,
duration_ms BIGINT NOT NULL DEFAULT 0,
sha256 CHAR(64) NOT NULL,
authorized_room_version BIGINT NOT NULL,
created_at_ms BIGINT NOT NULL,
updated_at_ms BIGINT NOT NULL,
PRIMARY KEY (app_code, room_id, command_id),
UNIQUE KEY uk_room_media_upload_object (app_code, expected_object_key),
KEY idx_room_media_upload_status_created (app_code, status, created_at_ms)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='房间专用媒体上传授权与完成事实';
CREATE TABLE IF NOT EXISTS room_decoration_assignments (
app_code VARCHAR(32) NOT NULL,
room_id VARCHAR(64) NOT NULL,
decoration_type VARCHAR(32) NOT NULL,
owner_user_id BIGINT NOT NULL,
benefit_code VARCHAR(64) NOT NULL,
resource_id BIGINT NOT NULL,
entitlement_id VARCHAR(128) NOT NULL DEFAULT '',
expires_at_ms BIGINT NOT NULL DEFAULT 0,
updated_at_ms BIGINT NOT NULL,
PRIMARY KEY (app_code, room_id, decoration_type),
KEY idx_room_decoration_resource (app_code, resource_id, room_id, decoration_type),
KEY idx_room_decoration_owner (app_code, owner_user_id, room_id, decoration_type),
KEY idx_room_decoration_expiry (app_code, expires_at_ms, room_id, decoration_type)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='房间当前 VIP 装扮反向索引';
CREATE TABLE IF NOT EXISTS room_wallet_event_consumption (
app_code VARCHAR(32) NOT NULL,
event_id VARCHAR(128) NOT NULL,
event_type VARCHAR(64) NOT NULL,
consumed_at_ms BIGINT NOT NULL,
PRIMARY KEY (app_code, event_id),
KEY idx_room_wallet_event_consumed (app_code, consumed_at_ms)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='room-service 消费 wallet 装扮事实的本地幂等表';

View File

@ -26,6 +26,7 @@ import (
"hyapp-admin-server/internal/integration/statisticsclient" "hyapp-admin-server/internal/integration/statisticsclient"
"hyapp-admin-server/internal/integration/userclient" "hyapp-admin-server/internal/integration/userclient"
"hyapp-admin-server/internal/integration/walletclient" "hyapp-admin-server/internal/integration/walletclient"
"hyapp-admin-server/internal/integration/withdrawalsource"
jobrunner "hyapp-admin-server/internal/job" jobrunner "hyapp-admin-server/internal/job"
"hyapp-admin-server/internal/migration" "hyapp-admin-server/internal/migration"
achievementconfigmodule "hyapp-admin-server/internal/modules/achievementconfig" achievementconfigmodule "hyapp-admin-server/internal/modules/achievementconfig"
@ -384,7 +385,7 @@ func main() {
financeordermodule.WithLegacyCoinSellerRegionResolver(func(ctx context.Context, appCode string, countryCode string) (int64, bool, error) { financeordermodule.WithLegacyCoinSellerRegionResolver(func(ctx context.Context, appCode string, countryCode string) (int64, bool, error) {
return paymentmodule.RegionIDForCountryCode(ctx, moneyRegionSources, appCode, countryCode) return paymentmodule.RegionIDForCountryCode(ctx, moneyRegionSources, appCode, countryCode)
})), })),
FinanceWithdrawal: financewithdrawalmodule.New(store, walletclient.NewGRPC(walletConn), activityclient.NewGRPC(activityConn), auditHandler), FinanceWithdrawal: financewithdrawalmodule.NewWithSources(store, walletclient.NewGRPC(walletConn), activityclient.NewGRPC(activityConn), auditHandler, withdrawalsource.New(cfg.WithdrawalSources)),
ExternalAdmin: externaladminmodule.New(db, userDB, externaladminmodule.Config{ ExternalAdmin: externaladminmodule.New(db, userDB, externaladminmodule.Config{
SessionTTL: cfg.ExternalAdmin.SessionTTL, SessionTTL: cfg.ExternalAdmin.SessionTTL,
MaxLoginFailures: cfg.ExternalAdmin.MaxLoginFailures, MaxLoginFailures: cfg.ExternalAdmin.MaxLoginFailures,

View File

@ -69,6 +69,15 @@ finance_google_paid_sync:
poll_interval: "15s" poll_interval: "15s"
batch_size: 100 batch_size: 100
request_timeout: "2m" request_timeout: "2m"
# 生产通过 HYAPP_ADMIN_ASLAN_WITHDRAWAL_* 注入密钥、回调地址并开启。
withdrawal_sources:
- enabled: false
app_code: "aslan"
source_system: "likei"
submit_token: ""
callback_url: ""
callback_token: ""
request_timeout: "5s"
mysql_auto_migrate: false mysql_auto_migrate: false
migrations: migrations:
enabled: true enabled: true

View File

@ -67,6 +67,16 @@ finance_google_paid_sync:
poll_interval: "15s" poll_interval: "15s"
batch_size: 100 batch_size: 100
request_timeout: "2m" request_timeout: "2m"
# 外部钱包统一提现双审来源。生产密钥和回调地址只从
# HYAPP_ADMIN_ASLAN_WITHDRAWAL_* 环境变量注入,仓库不保存资金接口凭证。
withdrawal_sources:
- enabled: false
app_code: "aslan"
source_system: "likei"
submit_token: ""
callback_url: ""
callback_token: ""
request_timeout: "5s"
mysql_auto_migrate: true mysql_auto_migrate: true
migrations: migrations:
enabled: true enabled: true

View File

@ -52,6 +52,7 @@ type Config struct {
StatisticsService StatisticsServiceConfig `yaml:"statistics_service"` StatisticsService StatisticsServiceConfig `yaml:"statistics_service"`
DashboardExternalSources []DashboardExternalSourceConfig `yaml:"dashboard_external_sources"` DashboardExternalSources []DashboardExternalSourceConfig `yaml:"dashboard_external_sources"`
LegacyCoinSellerRechargeSources []LegacyCoinSellerRechargeSourceConfig `yaml:"legacy_coin_seller_recharge_sources"` LegacyCoinSellerRechargeSources []LegacyCoinSellerRechargeSourceConfig `yaml:"legacy_coin_seller_recharge_sources"`
WithdrawalSources []WithdrawalSourceConfig `yaml:"withdrawal_sources"`
} }
type MigrationConfig struct { type MigrationConfig struct {
@ -115,6 +116,18 @@ type StatisticsServiceConfig struct {
RequestTimeout time.Duration `yaml:"request_timeout"` RequestTimeout time.Duration `yaml:"request_timeout"`
} }
// WithdrawalSourceConfig 描述接入统一双审工作台的外部钱包系统。
// submit_token 只允许来源 App 创建自己的申请callback_token 用于 admin 终审时回调来源钱包,二者分离避免任一方向泄漏后扩大权限。
type WithdrawalSourceConfig struct {
Enabled bool `yaml:"enabled"`
AppCode string `yaml:"app_code"`
SourceSystem string `yaml:"source_system"`
SubmitToken string `yaml:"submit_token"`
CallbackURL string `yaml:"callback_url"`
CallbackToken string `yaml:"callback_token"`
RequestTimeout time.Duration `yaml:"request_timeout"`
}
type DashboardExternalSourceConfig struct { type DashboardExternalSourceConfig struct {
Enabled bool `yaml:"enabled"` Enabled bool `yaml:"enabled"`
SourceType string `yaml:"type"` SourceType string `yaml:"type"`
@ -385,6 +398,14 @@ func Default() Config {
RequestTimeout: 5 * time.Second, RequestTimeout: 5 * time.Second,
}, },
}, },
WithdrawalSources: []WithdrawalSourceConfig{
{
Enabled: false,
AppCode: "aslan",
SourceSystem: "likei",
RequestTimeout: 5 * time.Second,
},
},
FinanceGooglePaidSync: FinanceGooglePaidSyncConfig{ FinanceGooglePaidSync: FinanceGooglePaidSyncConfig{
Enabled: true, Enabled: true,
PollInterval: 15 * time.Second, PollInterval: 15 * time.Second,
@ -623,6 +644,18 @@ func (cfg *Config) Normalize() {
} }
} }
cfg.applyLegacyCoinSellerRechargeSourceEnvOverrides() cfg.applyLegacyCoinSellerRechargeSourceEnvOverrides()
for index := range cfg.WithdrawalSources {
source := &cfg.WithdrawalSources[index]
source.AppCode = strings.ToLower(strings.TrimSpace(source.AppCode))
source.SourceSystem = strings.ToLower(strings.TrimSpace(source.SourceSystem))
source.SubmitToken = strings.TrimSpace(source.SubmitToken)
source.CallbackURL = strings.TrimRight(strings.TrimSpace(source.CallbackURL), "/")
source.CallbackToken = strings.TrimSpace(source.CallbackToken)
if source.RequestTimeout <= 0 {
source.RequestTimeout = 5 * time.Second
}
}
cfg.applyWithdrawalSourceEnvOverrides()
cfg.RobotProfileSource.MySQLDSN = strings.TrimSpace(cfg.RobotProfileSource.MySQLDSN) cfg.RobotProfileSource.MySQLDSN = strings.TrimSpace(cfg.RobotProfileSource.MySQLDSN)
if cfg.RobotProfileSource.RequestTimeout <= 0 { if cfg.RobotProfileSource.RequestTimeout <= 0 {
cfg.RobotProfileSource.RequestTimeout = 5 * time.Second cfg.RobotProfileSource.RequestTimeout = 5 * time.Second
@ -904,6 +937,26 @@ func (cfg Config) Validate() error {
return fmt.Errorf("dashboard_external_sources[%d].request_timeout must be greater than 0", index) return fmt.Errorf("dashboard_external_sources[%d].request_timeout must be greater than 0", index)
} }
} }
for index, source := range cfg.WithdrawalSources {
if !source.Enabled {
continue
}
if strings.TrimSpace(source.AppCode) == "" || strings.TrimSpace(source.SourceSystem) == "" {
return fmt.Errorf("withdrawal_sources[%d] app_code and source_system are required when enabled", index)
}
if strings.TrimSpace(source.SubmitToken) == "" || containsConfigPlaceholder(source.SubmitToken) {
return fmt.Errorf("withdrawal_sources[%d].submit_token must be injected when enabled", index)
}
if strings.TrimSpace(source.CallbackToken) == "" || containsConfigPlaceholder(source.CallbackToken) {
return fmt.Errorf("withdrawal_sources[%d].callback_token must be injected when enabled", index)
}
if !strings.HasPrefix(source.CallbackURL, "http://") && !strings.HasPrefix(source.CallbackURL, "https://") {
return fmt.Errorf("withdrawal_sources[%d].callback_url must be an absolute url", index)
}
if source.RequestTimeout <= 0 {
return fmt.Errorf("withdrawal_sources[%d].request_timeout must be greater than 0", index)
}
}
if isLockedEnvironment(cfg.Environment) && strings.TrimSpace(cfg.RobotProfileSource.MySQLDSN) == "" { if isLockedEnvironment(cfg.Environment) && strings.TrimSpace(cfg.RobotProfileSource.MySQLDSN) == "" {
return errors.New("robot_profile_source.mysql_dsn is required outside local/dev") return errors.New("robot_profile_source.mysql_dsn is required outside local/dev")
} }
@ -1114,6 +1167,33 @@ func (cfg *Config) applyLegacyCoinSellerRechargeSourceEnvOverrides() {
} }
} }
func (cfg *Config) applyWithdrawalSourceEnvOverrides() {
for index := range cfg.WithdrawalSources {
source := &cfg.WithdrawalSources[index]
appKey := envAppKey(source.AppCode)
if appKey == "" {
continue
}
prefix := "HYAPP_ADMIN_" + appKey + "_WITHDRAWAL_"
if value := strings.TrimSpace(os.Getenv(prefix + "ENABLED")); value != "" {
source.Enabled = parseBoolEnv(value)
}
if value := strings.TrimSpace(os.Getenv(prefix + "SOURCE_SYSTEM")); value != "" {
source.SourceSystem = value
}
if value := strings.TrimSpace(os.Getenv(prefix + "SUBMIT_TOKEN")); value != "" {
// 双向令牌都属于资金系统密钥,只允许通过运行环境注入,不能写进仓库配置。
source.SubmitToken = value
}
if value := strings.TrimSpace(os.Getenv(prefix + "CALLBACK_URL")); value != "" {
source.CallbackURL = value
}
if value := strings.TrimSpace(os.Getenv(prefix + "CALLBACK_TOKEN")); value != "" {
source.CallbackToken = value
}
}
}
func envAppKey(appCode string) string { func envAppKey(appCode string) string {
appCode = strings.ToUpper(strings.TrimSpace(appCode)) appCode = strings.ToUpper(strings.TrimSpace(appCode))
if appCode == "" { if appCode == "" {

View File

@ -14,6 +14,8 @@ type Client interface {
GetLuckyGiftConfig(ctx context.Context, req *luckygiftv1.GetLuckyGiftConfigRequest) (*luckygiftv1.GetLuckyGiftConfigResponse, error) GetLuckyGiftConfig(ctx context.Context, req *luckygiftv1.GetLuckyGiftConfigRequest) (*luckygiftv1.GetLuckyGiftConfigResponse, error)
UpsertLuckyGiftConfig(ctx context.Context, req *luckygiftv1.UpsertLuckyGiftConfigRequest) (*luckygiftv1.UpsertLuckyGiftConfigResponse, error) UpsertLuckyGiftConfig(ctx context.Context, req *luckygiftv1.UpsertLuckyGiftConfigRequest) (*luckygiftv1.UpsertLuckyGiftConfigResponse, error)
ListLuckyGiftConfigs(ctx context.Context, req *luckygiftv1.ListLuckyGiftConfigsRequest) (*luckygiftv1.ListLuckyGiftConfigsResponse, error) ListLuckyGiftConfigs(ctx context.Context, req *luckygiftv1.ListLuckyGiftConfigsRequest) (*luckygiftv1.ListLuckyGiftConfigsResponse, error)
ListLuckyGiftConfigVersions(ctx context.Context, req *luckygiftv1.ListLuckyGiftConfigVersionsRequest) (*luckygiftv1.ListLuckyGiftConfigVersionsResponse, error)
RollbackLuckyGiftConfig(ctx context.Context, req *luckygiftv1.RollbackLuckyGiftConfigRequest) (*luckygiftv1.RollbackLuckyGiftConfigResponse, error)
ListLuckyGiftDraws(ctx context.Context, req *luckygiftv1.ListLuckyGiftDrawsRequest) (*luckygiftv1.ListLuckyGiftDrawsResponse, error) ListLuckyGiftDraws(ctx context.Context, req *luckygiftv1.ListLuckyGiftDrawsRequest) (*luckygiftv1.ListLuckyGiftDrawsResponse, error)
GetLuckyGiftDrawSummary(ctx context.Context, req *luckygiftv1.GetLuckyGiftDrawSummaryRequest) (*luckygiftv1.GetLuckyGiftDrawSummaryResponse, error) GetLuckyGiftDrawSummary(ctx context.Context, req *luckygiftv1.GetLuckyGiftDrawSummaryRequest) (*luckygiftv1.GetLuckyGiftDrawSummaryResponse, error)
ListLuckyGiftUserProfiles(ctx context.Context, req *luckygiftv1.ListLuckyGiftUserProfilesRequest) (*luckygiftv1.ListLuckyGiftUserProfilesResponse, error) ListLuckyGiftUserProfiles(ctx context.Context, req *luckygiftv1.ListLuckyGiftUserProfilesRequest) (*luckygiftv1.ListLuckyGiftUserProfilesResponse, error)
@ -48,6 +50,14 @@ func (c *GRPCClient) ListLuckyGiftConfigs(ctx context.Context, req *luckygiftv1.
return c.client.ListLuckyGiftConfigs(ctx, req) return c.client.ListLuckyGiftConfigs(ctx, req)
} }
func (c *GRPCClient) ListLuckyGiftConfigVersions(ctx context.Context, req *luckygiftv1.ListLuckyGiftConfigVersionsRequest) (*luckygiftv1.ListLuckyGiftConfigVersionsResponse, error) {
return c.client.ListLuckyGiftConfigVersions(ctx, req)
}
func (c *GRPCClient) RollbackLuckyGiftConfig(ctx context.Context, req *luckygiftv1.RollbackLuckyGiftConfigRequest) (*luckygiftv1.RollbackLuckyGiftConfigResponse, error) {
return c.client.RollbackLuckyGiftConfig(ctx, req)
}
func (c *GRPCClient) ListLuckyGiftDraws(ctx context.Context, req *luckygiftv1.ListLuckyGiftDrawsRequest) (*luckygiftv1.ListLuckyGiftDrawsResponse, error) { func (c *GRPCClient) ListLuckyGiftDraws(ctx context.Context, req *luckygiftv1.ListLuckyGiftDrawsRequest) (*luckygiftv1.ListLuckyGiftDrawsResponse, error) {
return c.client.ListLuckyGiftDraws(ctx, req) return c.client.ListLuckyGiftDraws(ctx, req)
} }

View File

@ -0,0 +1,114 @@
package withdrawalsource
import (
"bytes"
"context"
"crypto/subtle"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"strings"
"hyapp-admin-server/internal/config"
)
const maxErrorBodyBytes = 2048
type DecisionRequest struct {
SourceApplicationID string `json:"sourceApplicationId"`
Decision string `json:"decision"`
CredentialURLs []string `json:"credentialUrls"`
Remark string `json:"remark"`
ReviewerUserID uint `json:"reviewerUserId"`
ReviewerName string `json:"reviewerName"`
CommandID string `json:"commandId"`
}
type Registry struct {
sources map[string]source
}
type source struct {
config config.WithdrawalSourceConfig
client *http.Client
}
func New(configs []config.WithdrawalSourceConfig) *Registry {
registry := &Registry{sources: make(map[string]source)}
for _, item := range configs {
if !item.Enabled {
continue
}
key := sourceKey(item.AppCode, item.SourceSystem)
registry.sources[key] = source{
config: item,
client: &http.Client{Timeout: item.RequestTimeout},
}
}
return registry
}
// AuthenticateSubmit 对 app_code + source_system 组合做独立令牌校验。
// 常量时间比较避免从鉴权耗时侧信道逐字符猜测资金接口令牌。
func (r *Registry) AuthenticateSubmit(appCode string, sourceSystem string, authorization string) bool {
item, ok := r.lookup(appCode, sourceSystem)
if !ok {
return false
}
authorization = strings.TrimSpace(authorization)
if !strings.HasPrefix(authorization, "Bearer ") {
return false
}
provided := strings.TrimSpace(strings.TrimPrefix(authorization, "Bearer "))
expected := strings.TrimSpace(item.config.SubmitToken)
return provided != "" && len(provided) == len(expected) && subtle.ConstantTimeCompare([]byte(provided), []byte(expected)) == 1
}
func (r *Registry) HasSource(appCode string, sourceSystem string) bool {
_, ok := r.lookup(appCode, sourceSystem)
return ok
}
// ApplyDecision 只负责把终态回调来源钱包;来源端必须按 commandId 幂等。
// admin 本地状态只有在 2xx 后才会提交,因此任何网络错误都会保留可重试的审核状态。
func (r *Registry) ApplyDecision(ctx context.Context, appCode string, sourceSystem string, request DecisionRequest) (string, error) {
item, ok := r.lookup(appCode, sourceSystem)
if !ok {
return "", errors.New("withdrawal source is not configured")
}
body, err := json.Marshal(request)
if err != nil {
return "", fmt.Errorf("encode withdrawal source decision: %w", err)
}
httpRequest, err := http.NewRequestWithContext(ctx, http.MethodPost, item.config.CallbackURL, bytes.NewReader(body))
if err != nil {
return "", fmt.Errorf("build withdrawal source decision: %w", err)
}
httpRequest.Header.Set("Authorization", "Bearer "+item.config.CallbackToken)
httpRequest.Header.Set("Content-Type", "application/json")
response, err := item.client.Do(httpRequest)
if err != nil {
return "", fmt.Errorf("call withdrawal source decision: %w", err)
}
defer response.Body.Close()
if response.StatusCode < http.StatusOK || response.StatusCode >= http.StatusMultipleChoices {
message, _ := io.ReadAll(io.LimitReader(response.Body, maxErrorBodyBytes))
return "", fmt.Errorf("withdrawal source decision returned %d: %s", response.StatusCode, strings.TrimSpace(string(message)))
}
// 来源申请号已单独持久化;阶段 commandId 作为短事务号即可关联 admin 审核与来源幂等执行,且不会突破字段长度上限。
return "external:" + strings.TrimSpace(request.CommandID), nil
}
func (r *Registry) lookup(appCode string, sourceSystem string) (source, bool) {
if r == nil {
return source{}, false
}
item, ok := r.sources[sourceKey(appCode, sourceSystem)]
return item, ok
}
func sourceKey(appCode string, sourceSystem string) string {
return strings.ToLower(strings.TrimSpace(appCode)) + "\x00" + strings.ToLower(strings.TrimSpace(sourceSystem))
}

View File

@ -728,6 +728,8 @@ func (order CoinSellerRechargeOrder) BusinessTimeMS() int64 {
type UserWithdrawalApplication struct { type UserWithdrawalApplication struct {
ID uint `gorm:"primaryKey;index:idx_admin_withdrawal_app_operations_status_time,priority:4" json:"id"` ID uint `gorm:"primaryKey;index:idx_admin_withdrawal_app_operations_status_time,priority:4" json:"id"`
AppCode string `gorm:"size:32;index:idx_admin_withdrawal_app_status_time;index:idx_admin_withdrawal_app_operations_status_time,priority:1;not null" json:"appCode"` AppCode string `gorm:"size:32;index:idx_admin_withdrawal_app_status_time;index:idx_admin_withdrawal_app_operations_status_time,priority:1;not null" json:"appCode"`
SourceSystem string `gorm:"column:source_system;size:32;not null;default:hyapp_wallet" json:"sourceSystem"`
SourceApplicationID string `gorm:"column:source_application_id;size:128;not null;default:''" json:"sourceApplicationId"`
UserID string `gorm:"size:64;index:idx_admin_withdrawal_user;not null" json:"userId"` UserID string `gorm:"size:64;index:idx_admin_withdrawal_user;not null" json:"userId"`
SalaryAssetType string `gorm:"size:64;not null;default:''" json:"salaryAssetType"` SalaryAssetType string `gorm:"size:64;not null;default:''" json:"salaryAssetType"`
WithdrawAmount string `gorm:"type:decimal(18,2);not null;default:0.00" json:"withdrawAmount"` WithdrawAmount string `gorm:"type:decimal(18,2);not null;default:0.00" json:"withdrawAmount"`

View File

@ -18,6 +18,8 @@ var (
errLegacyCoinSellerOrderDuplicate = errors.New("legacy coin seller recharge order already exists") errLegacyCoinSellerOrderDuplicate = errors.New("legacy coin seller recharge order already exists")
) )
const legacyCoinSellerRechargeType = "USDT-进货"
type coinSellerTarget struct { type coinSellerTarget struct {
UserID int64 UserID int64
DisplayUserID string DisplayUserID string
@ -214,7 +216,9 @@ WHERE id = ?`, input.CoinAmount, input.UserID, input.GrantedAt, balanceID); err
return legacyRechargeGrantResult{}, err return legacyRechargeGrantResult{}, err
} }
waterID := legacySnowflakeLikeID(input.GrantedAt) waterID := legacySnowflakeLikeID(input.GrantedAt)
rechargeType := legacyRechargeType(input.ProviderCode, input.Chain) // likei-services 经理中心按固定 recharge_type=USDT-进货 汇总币商入金;
// V5Pay、Binance 站内转账和链上网络属于支付凭证维度,已完整保存在 admin 订单中,不能写进旧钱包的统计分类字段。
rechargeType := legacyCoinSellerRechargeType
if _, err := tx.ExecContext(queryCtx, ` if _, err := tx.ExecContext(queryCtx, `
INSERT INTO user_freight_balance_running_water INSERT INTO user_freight_balance_running_water
(id, sys_origin, user_id, accept_user_id, seller_id, type, quantity, usd_quantity, balance, amount, recharge_type, origin, remark, create_user, update_user, create_time, update_time) (id, sys_origin, user_id, accept_user_id, seller_id, type, quantity, usd_quantity, balance, amount, recharge_type, origin, remark, create_user, update_user, create_time, update_time)
@ -258,17 +262,6 @@ func decimalBalanceAfter(earnPointsText string, consumptionText string, delta in
return int64(earn) + delta - int64(consumption), nil return int64(earn) + delta - int64(consumption), nil
} }
func legacyRechargeType(providerCode string, chain string) string {
providerCode = strings.ToUpper(strings.TrimSpace(providerCode))
if providerCode == "USDT" {
if chain = strings.ToUpper(strings.TrimSpace(chain)); chain != "" {
return "USDT-" + chain + "-进货"
}
return "USDT-进货"
}
return providerCode + "-进货"
}
func legacySnowflakeLikeID(now time.Time) int64 { func legacySnowflakeLikeID(now time.Time) int64 {
// 旧服务用 MyBatis ASSIGN_ID 写 bigint 主键;后台没有接入旧雪花 worker只需保证单进程内足够唯一且单调。 // 旧服务用 MyBatis ASSIGN_ID 写 bigint 主键;后台没有接入旧雪花 worker只需保证单进程内足够唯一且单调。
return now.UTC().UnixMilli()*1000 + int64(legacyWaterIDCounter.Add(1)%1000) return now.UTC().UnixMilli()*1000 + int64(legacyWaterIDCounter.Add(1)%1000)

View File

@ -32,7 +32,7 @@ func TestLegacyCoinSellerRechargeWriterGrantUpdatesBalanceAndRunningWater(t *tes
WithArgs(int64(1200), int64(10001), grantedAt, int64(77)). WithArgs(int64(1200), int64(10001), grantedAt, int64(77)).
WillReturnResult(sqlmock.NewResult(0, 1)) WillReturnResult(sqlmock.NewResult(0, 1))
walletMock.ExpectExec(`(?s)INSERT INTO user_freight_balance_running_water`). walletMock.ExpectExec(`(?s)INSERT INTO user_freight_balance_running_water`).
WithArgs(sqlmock.AnyArg(), "ATYOU", int64(10001), int64(10001), nil, int64(1200), int64(5200), "12.34", "MIFAPAY-进货", "MIFA-001", int64(10001), int64(10001), grantedAt, grantedAt). WithArgs(sqlmock.AnyArg(), "ATYOU", int64(10001), int64(10001), nil, int64(1200), int64(5200), "12.34", legacyCoinSellerRechargeType, "MIFA-001", int64(10001), int64(10001), grantedAt, grantedAt).
WillReturnResult(sqlmock.NewResult(0, 1)) WillReturnResult(sqlmock.NewResult(0, 1))
walletMock.ExpectCommit() walletMock.ExpectCommit()

View File

@ -8,6 +8,7 @@ import (
"hyapp-admin-server/internal/appctx" "hyapp-admin-server/internal/appctx"
"hyapp-admin-server/internal/integration/activityclient" "hyapp-admin-server/internal/integration/activityclient"
"hyapp-admin-server/internal/integration/walletclient" "hyapp-admin-server/internal/integration/walletclient"
"hyapp-admin-server/internal/integration/withdrawalsource"
"hyapp-admin-server/internal/middleware" "hyapp-admin-server/internal/middleware"
"hyapp-admin-server/internal/modules/shared" "hyapp-admin-server/internal/modules/shared"
"hyapp-admin-server/internal/repository" "hyapp-admin-server/internal/repository"
@ -20,10 +21,57 @@ import (
type Handler struct { type Handler struct {
service *Service service *Service
audit shared.OperationLogger audit shared.OperationLogger
sources *withdrawalsource.Registry
} }
func New(store *repository.Store, wallet walletclient.Client, activity activityclient.Client, audit shared.OperationLogger) *Handler { func New(store *repository.Store, wallet walletclient.Client, activity activityclient.Client, audit shared.OperationLogger) *Handler {
return &Handler{service: NewService(store, wallet, activity), audit: audit} return NewWithSources(store, wallet, activity, audit, nil)
}
func NewWithSources(store *repository.Store, wallet walletclient.Client, activity activityclient.Client, audit shared.OperationLogger, sources *withdrawalsource.Registry) *Handler {
return &Handler{service: NewServiceWithSources(store, wallet, activity, sources), audit: audit, sources: sources}
}
// CreateExternalApplication 是来源钱包的服务间入口,不使用后台 JWT。
// 每个 App 使用独立 submit token且业务唯一键保证来源端持久重试不会生成重复审核单。
func (h *Handler) CreateExternalApplication(c *gin.Context) {
var req struct {
AppCode string `json:"appCode" binding:"required"`
SourceSystem string `json:"sourceSystem" binding:"required"`
SourceApplicationID string `json:"sourceApplicationId" binding:"required"`
UserID string `json:"userId" binding:"required"`
SalaryAssetType string `json:"salaryAssetType" binding:"required"`
WithdrawAmount string `json:"withdrawAmount" binding:"required"`
WithdrawAmountMinor int64 `json:"withdrawAmountMinor" binding:"required"`
WithdrawMethod string `json:"withdrawMethod" binding:"required"`
WithdrawAddress string `json:"withdrawAddress" binding:"required"`
CreatedAtMS int64 `json:"createdAtMs"`
}
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "提现申请参数不正确")
return
}
if h.sources == nil || !h.sources.AuthenticateSubmit(req.AppCode, req.SourceSystem, c.GetHeader("Authorization")) {
response.Unauthorized(c, "提现来源鉴权失败")
return
}
item, err := h.service.CreateExternalApplication(externalWithdrawalApplicationInput{
AppCode: req.AppCode,
SourceSystem: req.SourceSystem,
SourceApplicationID: req.SourceApplicationID,
UserID: req.UserID,
SalaryAssetType: req.SalaryAssetType,
WithdrawAmount: req.WithdrawAmount,
WithdrawAmountMinor: req.WithdrawAmountMinor,
WithdrawMethod: req.WithdrawMethod,
WithdrawAddress: req.WithdrawAddress,
CreatedAtMS: req.CreatedAtMS,
})
if err != nil {
response.BadRequest(c, err.Error())
return
}
response.Created(c, item)
} }
func (h *Handler) ListApplications(c *gin.Context) { func (h *Handler) ListApplications(c *gin.Context) {

View File

@ -6,6 +6,8 @@ type withdrawalApplicationDTO struct {
ID uint `json:"id"` ID uint `json:"id"`
AppCode string `json:"appCode"` AppCode string `json:"appCode"`
AppName string `json:"appName"` AppName string `json:"appName"`
SourceSystem string `json:"sourceSystem"`
SourceApplicationID string `json:"sourceApplicationId"`
UserID string `json:"userId"` UserID string `json:"userId"`
SalaryAssetType string `json:"salaryAssetType"` SalaryAssetType string `json:"salaryAssetType"`
WithdrawAmount string `json:"withdrawAmount"` WithdrawAmount string `json:"withdrawAmount"`
@ -41,6 +43,8 @@ func withdrawalApplicationDTOFromModel(item model.UserWithdrawalApplication) wit
ID: item.ID, ID: item.ID,
AppCode: item.AppCode, AppCode: item.AppCode,
AppName: item.AppCode, AppName: item.AppCode,
SourceSystem: item.SourceSystem,
SourceApplicationID: item.SourceApplicationID,
UserID: item.UserID, UserID: item.UserID,
SalaryAssetType: item.SalaryAssetType, SalaryAssetType: item.SalaryAssetType,
WithdrawAmount: item.WithdrawAmount, WithdrawAmount: item.WithdrawAmount,

View File

@ -17,3 +17,10 @@ func RegisterRoutes(protected *gin.RouterGroup, h *Handler) {
protected.POST("/admin/operations/withdrawal-applications/:application_id/approve", middleware.RequirePermission(permissionAuditOperationsWithdrawals), h.ApproveOperationsApplication) protected.POST("/admin/operations/withdrawal-applications/:application_id/approve", middleware.RequirePermission(permissionAuditOperationsWithdrawals), h.ApproveOperationsApplication)
protected.POST("/admin/operations/withdrawal-applications/:application_id/reject", middleware.RequirePermission(permissionAuditOperationsWithdrawals), h.RejectOperationsApplication) protected.POST("/admin/operations/withdrawal-applications/:application_id/reject", middleware.RequirePermission(permissionAuditOperationsWithdrawals), h.RejectOperationsApplication)
} }
func RegisterIntegrationRoutes(api *gin.RouterGroup, h *Handler) {
if h == nil {
return
}
api.POST("/integrations/withdrawal-applications", h.CreateExternalApplication)
}

View File

@ -5,6 +5,7 @@ import (
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"math/big"
"strconv" "strconv"
"strings" "strings"
"time" "time"
@ -12,6 +13,7 @@ import (
"hyapp-admin-server/internal/appctx" "hyapp-admin-server/internal/appctx"
"hyapp-admin-server/internal/integration/activityclient" "hyapp-admin-server/internal/integration/activityclient"
"hyapp-admin-server/internal/integration/walletclient" "hyapp-admin-server/internal/integration/walletclient"
"hyapp-admin-server/internal/integration/withdrawalsource"
"hyapp-admin-server/internal/model" "hyapp-admin-server/internal/model"
"hyapp-admin-server/internal/modules/shared" "hyapp-admin-server/internal/modules/shared"
"hyapp-admin-server/internal/repository" "hyapp-admin-server/internal/repository"
@ -35,9 +37,23 @@ type Service struct {
store *repository.Store store *repository.Store
wallet walletclient.Client wallet walletclient.Client
activity activityclient.Client activity activityclient.Client
sources *withdrawalsource.Registry
now func() time.Time now func() time.Time
} }
type externalWithdrawalApplicationInput struct {
AppCode string
SourceSystem string
SourceApplicationID string
UserID string
SalaryAssetType string
WithdrawAmount string
WithdrawAmountMinor int64
WithdrawMethod string
WithdrawAddress string
CreatedAtMS int64
}
type pointWithdrawalWallet interface { type pointWithdrawalWallet interface {
SettlePointWithdrawal(ctx context.Context, req *walletv1.SettlePointWithdrawalRequest) (*walletv1.SettlePointWithdrawalResponse, error) SettlePointWithdrawal(ctx context.Context, req *walletv1.SettlePointWithdrawalRequest) (*walletv1.SettlePointWithdrawalResponse, error)
ReleasePointWithdrawal(ctx context.Context, req *walletv1.ReleasePointWithdrawalRequest) (*walletv1.ReleasePointWithdrawalResponse, error) ReleasePointWithdrawal(ctx context.Context, req *walletv1.ReleasePointWithdrawalRequest) (*walletv1.ReleasePointWithdrawalResponse, error)
@ -47,6 +63,77 @@ func NewService(store *repository.Store, wallet walletclient.Client, activity ac
return &Service{store: store, wallet: wallet, activity: activity, now: func() time.Time { return time.Now().UTC() }} return &Service{store: store, wallet: wallet, activity: activity, now: func() time.Time { return time.Now().UTC() }}
} }
func NewServiceWithSources(store *repository.Store, wallet walletclient.Client, activity activityclient.Client, sources *withdrawalsource.Registry) *Service {
service := NewService(store, wallet, activity)
service.sources = sources
return service
}
func (s *Service) CreateExternalApplication(input externalWithdrawalApplicationInput) (*withdrawalApplicationDTO, error) {
if s == nil || s.store == nil || s.sources == nil {
return nil, errors.New("external withdrawal intake is not configured")
}
input.AppCode = appctx.Normalize(input.AppCode)
input.SourceSystem = strings.ToLower(strings.TrimSpace(input.SourceSystem))
input.SourceApplicationID = strings.TrimSpace(input.SourceApplicationID)
input.UserID = strings.TrimSpace(input.UserID)
input.SalaryAssetType = strings.TrimSpace(input.SalaryAssetType)
input.WithdrawAmount = strings.TrimSpace(input.WithdrawAmount)
input.WithdrawMethod = strings.TrimSpace(input.WithdrawMethod)
input.WithdrawAddress = strings.TrimSpace(input.WithdrawAddress)
if !s.sources.HasSource(input.AppCode, input.SourceSystem) {
return nil, errors.New("withdrawal source is not configured")
}
if input.SourceApplicationID == "" || len(input.SourceApplicationID) > 128 || input.UserID == "" || len(input.UserID) > 64 {
return nil, errors.New("来源申请号或用户 ID 不正确")
}
if input.SalaryAssetType == "" || len(input.SalaryAssetType) > 64 || input.WithdrawMethod == "" || len(input.WithdrawMethod) > 64 || input.WithdrawAddress == "" || len(input.WithdrawAddress) > 255 {
return nil, errors.New("提现资产或收款信息不完整")
}
if err := validateExternalWithdrawalAmount(input.WithdrawAmount, input.WithdrawAmountMinor); err != nil {
return nil, err
}
createdAtMS := input.CreatedAtMS
if createdAtMS <= 0 {
createdAtMS = s.now().UnixMilli()
}
application, err := s.store.CreateExternalWithdrawalApplication(model.UserWithdrawalApplication{
AppCode: input.AppCode,
SourceSystem: input.SourceSystem,
SourceApplicationID: input.SourceApplicationID,
UserID: input.UserID,
SalaryAssetType: input.SalaryAssetType,
WithdrawAmount: input.WithdrawAmount,
WithdrawAmountMinor: input.WithdrawAmountMinor,
WithdrawMethod: input.WithdrawMethod,
WithdrawAddress: input.WithdrawAddress,
// 外部来源在提交前已经完成扣款;这两个字段保留来源事实,真正终态由 callback adapter 执行,绝不误调用 HY wallet。
FreezeCommandID: "external:" + input.SourceSystem + ":" + input.SourceApplicationID,
FreezeTransactionID: "external:" + input.SourceApplicationID,
Status: model.WithdrawalApplicationStatusPending,
OperationsStatus: model.WithdrawalOperationsStatusPending,
CreatedAtMS: createdAtMS,
UpdatedAtMS: createdAtMS,
})
if err != nil {
return nil, err
}
dto := withdrawalApplicationDTOFromModel(*application)
return &dto, nil
}
func validateExternalWithdrawalAmount(amount string, amountMinor int64) error {
parsed, ok := new(big.Rat).SetString(strings.TrimSpace(amount))
if !ok || parsed.Sign() <= 0 || amountMinor <= 0 {
return errors.New("提现金额不正确")
}
minor := new(big.Rat).Mul(parsed, big.NewRat(100, 1))
if !minor.IsInt() || !minor.Num().IsInt64() || minor.Num().Int64() != amountMinor {
return errors.New("提现金额与最小单位金额不一致")
}
return nil
}
func (s *Service) ListApplications(actor shared.Actor, options repository.WithdrawalApplicationListOptions) ([]withdrawalApplicationDTO, int64, error) { func (s *Service) ListApplications(actor shared.Actor, options repository.WithdrawalApplicationListOptions) ([]withdrawalApplicationDTO, int64, error) {
if s == nil || s.store == nil { if s == nil || s.store == nil {
return nil, 0, errors.New("admin store is not configured") return nil, 0, errors.New("admin store is not configured")
@ -156,34 +243,33 @@ func (s *Service) auditApplication(ctx context.Context, actor shared.Actor, id u
AuditImageURL: auditImageURL, AuditImageURL: auditImageURL,
ApprovedAtMS: nowMS, ApprovedAtMS: nowMS,
}, func(item model.UserWithdrawalApplication) (repository.WithdrawalApplicationAuditEffect, error) { }, func(item model.UserWithdrawalApplication) (repository.WithdrawalApplicationAuditEffect, error) {
userID, parseErr := withdrawalWalletUserID(item)
if parseErr != nil {
return repository.WithdrawalApplicationAuditEffect{}, parseErr
}
if stage == repository.WithdrawalApplicationReviewStageOperations && decision == model.WithdrawalApplicationStatusApproved { if stage == repository.WithdrawalApplicationReviewStageOperations && decision == model.WithdrawalApplicationStatusApproved {
// 运营通过只确认业务资料并转交财务;申请金额继续保留在 frozen且不能提前向用户发送最终通过通知。 // 运营通过只确认业务资料并转交财务;申请金额继续保留在 frozen且不能提前向用户发送最终通过通知。
return repository.WithdrawalApplicationAuditEffect{}, nil return repository.WithdrawalApplicationAuditEffect{}, nil
} }
transactionID, walletErr := s.applyWalletDecision(ctx, decision, commandID, appCode, userID, item.SalaryAssetType, item.WithdrawAmountMinor, item.PointFeeAmount, item.PointNetAmount, item.PointsPerUSD, item.PointFeeBPS, actor, strings.TrimSpace(remark), id) transactionID, userID, walletErr := s.applyWithdrawalDecision(ctx, item, decision, commandID, appCode, actor, remark, auditImageURL, id)
if walletErr != nil { if walletErr != nil {
return repository.WithdrawalApplicationAuditEffect{}, walletErr return repository.WithdrawalApplicationAuditEffect{}, walletErr
} }
if noticeErr := s.sendWithdrawalAuditNotice(ctx, auditNoticeInput{ if !isExternalWithdrawal(item) {
ApplicationID: id, noticeErr := s.sendWithdrawalAuditNotice(ctx, auditNoticeInput{
AppCode: appCode, ApplicationID: id,
UserID: userID, AppCode: appCode,
Stage: stage, UserID: userID,
Decision: decision, Stage: stage,
Remark: remark, Decision: decision,
AuditImageURL: auditImageURL, Remark: remark,
AuditTransactionID: transactionID, AuditImageURL: auditImageURL,
WithdrawAmount: item.WithdrawAmount, AuditTransactionID: transactionID,
WithdrawMethod: item.WithdrawMethod, WithdrawAmount: item.WithdrawAmount,
RequestID: requestID, WithdrawMethod: item.WithdrawMethod,
SentAtMS: nowMS, RequestID: requestID,
}); noticeErr != nil { SentAtMS: nowMS,
// 钱包扣冻/释放使用阶段固定 command id通知使用阶段+结果事件 id回调失败会回滚 admin 状态,重试不会重复资金动作或消息。 })
return repository.WithdrawalApplicationAuditEffect{}, noticeErr if noticeErr != nil {
// 钱包扣冻/释放使用阶段固定 command id通知使用阶段+结果事件 id回调失败会回滚 admin 状态,重试不会重复资金动作或消息。
return repository.WithdrawalApplicationAuditEffect{}, noticeErr
}
} }
return repository.WithdrawalApplicationAuditEffect{CommandID: commandID, TransactionID: transactionID}, nil return repository.WithdrawalApplicationAuditEffect{CommandID: commandID, TransactionID: transactionID}, nil
}) })
@ -206,8 +292,7 @@ func (s *Service) executeOperationsRejection(ctx context.Context, actor shared.A
AuditCommandID: commandID, AuditCommandID: commandID,
ApprovedAtMS: nowMS, ApprovedAtMS: nowMS,
}, func(item model.UserWithdrawalApplication) error { }, func(item model.UserWithdrawalApplication) error {
_, err := withdrawalWalletUserID(item) return validateWithdrawalDecisionTarget(item)
return err
}) })
if err != nil { if err != nil {
return nil, err return nil, err
@ -218,17 +303,13 @@ func (s *Service) executeOperationsRejection(ctx context.Context, actor shared.A
return &dto, nil return &dto, nil
} }
userID, err := withdrawalWalletUserID(*claimed)
if err != nil {
return nil, err
}
if claimed.OperationsReviewerUserID == nil || *claimed.OperationsReviewerUserID == 0 || strings.TrimSpace(claimed.OperationsAuditRemark) == "" { if claimed.OperationsReviewerUserID == nil || *claimed.OperationsReviewerUserID == 0 || strings.TrimSpace(claimed.OperationsAuditRemark) == "" {
return nil, errors.New("提现单运营拒绝 claim 不完整") return nil, errors.New("提现单运营拒绝 claim 不完整")
} }
// 重试沿用第一次 claim 的审核人和拒绝原因,不能让资金 metadata、用户通知和后台审计快照随重试人改变。 // 重试沿用第一次 claim 的审核人和拒绝原因,不能让资金 metadata、用户通知和后台审计快照随重试人改变。
claimedActor := shared.Actor{UserID: *claimed.OperationsReviewerUserID, Username: claimed.OperationsReviewerName} claimedActor := shared.Actor{UserID: *claimed.OperationsReviewerUserID, Username: claimed.OperationsReviewerName}
claimedRemark := strings.TrimSpace(claimed.OperationsAuditRemark) claimedRemark := strings.TrimSpace(claimed.OperationsAuditRemark)
transactionID, err := s.applyWalletDecision(ctx, model.WithdrawalApplicationStatusRejected, commandID, appCode, userID, claimed.SalaryAssetType, claimed.WithdrawAmountMinor, claimed.PointFeeAmount, claimed.PointNetAmount, claimed.PointsPerUSD, claimed.PointFeeBPS, claimedActor, claimedRemark, id) transactionID, userID, err := s.applyWithdrawalDecision(ctx, *claimed, model.WithdrawalApplicationStatusRejected, commandID, appCode, claimedActor, claimedRemark, "", id)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -236,21 +317,24 @@ func (s *Service) executeOperationsRejection(ctx context.Context, actor shared.A
if claimed.OperationsReviewedAtMS != nil && *claimed.OperationsReviewedAtMS > 0 { if claimed.OperationsReviewedAtMS != nil && *claimed.OperationsReviewedAtMS > 0 {
sentAtMS = *claimed.OperationsReviewedAtMS sentAtMS = *claimed.OperationsReviewedAtMS
} }
if err := s.sendWithdrawalAuditNotice(ctx, auditNoticeInput{ if !isExternalWithdrawal(*claimed) {
ApplicationID: id, err = s.sendWithdrawalAuditNotice(ctx, auditNoticeInput{
AppCode: appCode, ApplicationID: id,
UserID: userID, AppCode: appCode,
Stage: repository.WithdrawalApplicationReviewStageOperations, UserID: userID,
Decision: model.WithdrawalApplicationStatusRejected, Stage: repository.WithdrawalApplicationReviewStageOperations,
Remark: claimedRemark, Decision: model.WithdrawalApplicationStatusRejected,
AuditTransactionID: transactionID, Remark: claimedRemark,
WithdrawAmount: claimed.WithdrawAmount, AuditTransactionID: transactionID,
WithdrawMethod: claimed.WithdrawMethod, WithdrawAmount: claimed.WithdrawAmount,
RequestID: requestID, WithdrawMethod: claimed.WithdrawMethod,
SentAtMS: sentAtMS, RequestID: requestID,
}); err != nil { SentAtMS: sentAtMS,
// rejecting 已在前一事务提交;返回错误让运营页展示“重试拒绝”,绝不能回退成 pending。 })
return nil, err if err != nil {
// rejecting 已在前一事务提交;返回错误让运营页展示“重试拒绝”,绝不能回退成 pending。
return nil, err
}
} }
updated, err := s.store.FinalizeWithdrawalOperationsRejectionForApp(appCode, id, commandID, transactionID, s.now().UnixMilli()) updated, err := s.store.FinalizeWithdrawalOperationsRejectionForApp(appCode, id, commandID, transactionID, s.now().UnixMilli())
if err != nil { if err != nil {
@ -268,6 +352,52 @@ func withdrawalWalletUserID(item model.UserWithdrawalApplication) (int64, error)
return userID, nil return userID, nil
} }
func validateWithdrawalDecisionTarget(item model.UserWithdrawalApplication) error {
if isExternalWithdrawal(item) {
if strings.TrimSpace(item.SourceApplicationID) == "" || strings.TrimSpace(item.UserID) == "" || item.WithdrawAmountMinor <= 0 {
return errors.New("外部提现单来源信息不完整")
}
return nil
}
_, err := withdrawalWalletUserID(item)
return err
}
func isExternalWithdrawal(item model.UserWithdrawalApplication) bool {
sourceSystem := strings.ToLower(strings.TrimSpace(item.SourceSystem))
return sourceSystem != "" && sourceSystem != "hyapp_wallet"
}
func (s *Service) applyWithdrawalDecision(ctx context.Context, item model.UserWithdrawalApplication, decision string, commandID string, appCode string, actor shared.Actor, remark string, auditImageURL string, applicationID uint) (string, int64, error) {
if isExternalWithdrawal(item) {
if s.sources == nil {
return "", 0, errors.New("withdrawal source callback is not configured")
}
sourceDecision := "NOT_PASS"
credentials := []string(nil)
if decision == model.WithdrawalApplicationStatusApproved {
sourceDecision = "PASS"
credentials = []string{strings.TrimSpace(auditImageURL)}
}
transactionID, err := s.sources.ApplyDecision(ctx, appCode, item.SourceSystem, withdrawalsource.DecisionRequest{
SourceApplicationID: item.SourceApplicationID,
Decision: sourceDecision,
CredentialURLs: credentials,
Remark: strings.TrimSpace(remark),
ReviewerUserID: actor.UserID,
ReviewerName: actor.Username,
CommandID: commandID,
})
return transactionID, 0, err
}
userID, err := withdrawalWalletUserID(item)
if err != nil {
return "", 0, err
}
transactionID, err := s.applyWalletDecision(ctx, decision, commandID, appCode, userID, item.SalaryAssetType, item.WithdrawAmountMinor, item.PointFeeAmount, item.PointNetAmount, item.PointsPerUSD, item.PointFeeBPS, actor, strings.TrimSpace(remark), applicationID)
return transactionID, userID, err
}
func (s *Service) applyWalletDecision(ctx context.Context, decision string, commandID string, appCode string, userID int64, assetType string, amountMinor int64, storedFeePoints int64, storedNetPoints int64, storedPointsPerUSD int64, storedFeeBPS int32, actor shared.Actor, remark string, applicationID uint) (string, error) { func (s *Service) applyWalletDecision(ctx context.Context, decision string, commandID string, appCode string, userID int64, assetType string, amountMinor int64, storedFeePoints int64, storedNetPoints int64, storedPointsPerUSD int64, storedFeeBPS int32, actor shared.Actor, remark string, applicationID uint) (string, error) {
reason := strings.TrimSpace(remark) reason := strings.TrimSpace(remark)
if reason == "" { if reason == "" {

View File

@ -95,6 +95,10 @@ type configRequest struct {
Stages []stageDTO `json:"stages"` Stages []stageDTO `json:"stages"`
} }
type configRollbackRequest struct {
TargetRuleVersion int64 `json:"target_rule_version"`
}
// configUpsertRequest 只服务 HTTP 写入口。滚动发布期间旧前端仍可能发送 day/72h 字段, // configUpsertRequest 只服务 HTTP 写入口。滚动发布期间旧前端仍可能发送 day/72h 字段,
// 因此输入 DTO 明确接收两代名字configRequest 继续只暴露 canonical JSON保证 GET 和写入响应不会双写字段。 // 因此输入 DTO 明确接收两代名字configRequest 继续只暴露 canonical JSON保证 GET 和写入响应不会双写字段。
type configUpsertRequest struct { type configUpsertRequest struct {
@ -297,6 +301,67 @@ func (h *Handler) ListLuckyGiftConfigs(c *gin.Context) {
response.OK(c, items) response.OK(c, items)
} }
func (h *Handler) ListLuckyGiftConfigVersions(c *gin.Context) {
appCode := strings.TrimSpace(c.Query("app_code"))
poolID := strings.TrimSpace(c.Query("pool_id"))
if appCode == "" || poolID == "" {
response.BadRequest(c, "app_code and pool_id are required")
return
}
ctx, cancel := h.luckyGiftContext(c)
defer cancel()
resp, err := h.luckyGift.ListLuckyGiftConfigVersions(ctx, &luckygiftv1.ListLuckyGiftConfigVersionsRequest{
Meta: h.metaForApp(c, appCode),
PoolId: poolID,
})
if err != nil {
h.writeConfigRollbackError(c, err, "获取幸运礼物历史版本失败")
return
}
items := make([]configDTO, 0, len(resp.GetConfigs()))
for _, config := range resp.GetConfigs() {
items = append(items, configFromProto(config))
}
response.OK(c, items)
}
func (h *Handler) RollbackLuckyGiftConfig(c *gin.Context) {
appCode := strings.TrimSpace(c.Query("app_code"))
poolID := strings.TrimSpace(c.Query("pool_id"))
if appCode == "" || poolID == "" {
response.BadRequest(c, "app_code and pool_id are required")
return
}
var input configRollbackRequest
decoder := json.NewDecoder(c.Request.Body)
decoder.DisallowUnknownFields()
if err := decoder.Decode(&input); err != nil || input.TargetRuleVersion <= 0 {
response.BadRequest(c, "回滚目标版本不正确")
return
}
ctx, cancel := h.luckyGiftContext(c)
defer cancel()
resp, err := h.luckyGift.RollbackLuckyGiftConfig(ctx, &luckygiftv1.RollbackLuckyGiftConfigRequest{
Meta: h.metaForApp(c, appCode),
PoolId: poolID,
TargetRuleVersion: input.TargetRuleVersion,
OperatorAdminId: int64(middleware.CurrentUserID(c)),
})
if err != nil {
h.writeConfigRollbackError(c, err, "回滚幸运礼物版本失败")
return
}
item := configFromProto(resp.GetConfig())
shared.OperationLogWithResourceID(
c, h.audit, "rollback-lucky-gift-config", "lucky_gift_rule_versions", fmt.Sprintf("%s/%s/v%d", appCode, poolID, item.RuleVersion), "success",
fmt.Sprintf("source_rule_version=v%d new_rule_version=v%d", resp.GetSourceRuleVersion(), item.RuleVersion),
)
response.OK(c, gin.H{
"source_rule_version": resp.GetSourceRuleVersion(),
"config": item,
})
}
func (h *Handler) ListLuckyGiftDraws(c *gin.Context) { func (h *Handler) ListLuckyGiftDraws(c *gin.Context) {
if strings.TrimSpace(c.Query("app_code")) == "" { if strings.TrimSpace(c.Query("app_code")) == "" {
response.BadRequest(c, "app_code is required") response.BadRequest(c, "app_code is required")
@ -444,6 +509,20 @@ func (h *Handler) writePoolAdjustmentError(c *gin.Context, err error) {
} }
} }
func (h *Handler) writeConfigRollbackError(c *gin.Context, err error, fallback string) {
status := grpcstatus.Convert(err)
switch status.Code() {
case codes.InvalidArgument:
response.BadRequest(c, status.Message())
case codes.NotFound:
response.NotFound(c, status.Message())
case codes.AlreadyExists, codes.FailedPrecondition, codes.Aborted:
response.Conflict(c, status.Message())
default:
response.ServerError(c, fallback)
}
}
func (h *Handler) meta(c *gin.Context) *luckygiftv1.RequestMeta { func (h *Handler) meta(c *gin.Context) *luckygiftv1.RequestMeta {
return h.metaForApp(c, "") return h.metaForApp(c, "")
} }

View File

@ -22,6 +22,8 @@ func RegisterRoutes(protected *gin.RouterGroup, h *Handler) {
group.GET("/lucky-gifts/config", middleware.RequirePermission("lucky-gift:view"), h.luckyGift.GetLuckyGiftConfig) group.GET("/lucky-gifts/config", middleware.RequirePermission("lucky-gift:view"), h.luckyGift.GetLuckyGiftConfig)
group.PUT("/lucky-gifts/config", middleware.RequirePermission("lucky-gift:update"), h.luckyGift.UpsertLuckyGiftConfig) group.PUT("/lucky-gifts/config", middleware.RequirePermission("lucky-gift:update"), h.luckyGift.UpsertLuckyGiftConfig)
group.GET("/lucky-gifts/configs", middleware.RequirePermission("lucky-gift:view"), h.luckyGift.ListLuckyGiftConfigs) group.GET("/lucky-gifts/configs", middleware.RequirePermission("lucky-gift:view"), h.luckyGift.ListLuckyGiftConfigs)
group.GET("/lucky-gifts/config/versions", middleware.RequirePermission("lucky-gift:view"), h.luckyGift.ListLuckyGiftConfigVersions)
group.POST("/lucky-gifts/config/rollback", middleware.RequirePermission("lucky-gift:update"), h.luckyGift.RollbackLuckyGiftConfig)
group.GET("/lucky-gifts/draws", middleware.RequirePermission("lucky-gift:view"), h.luckyGift.ListLuckyGiftDraws) group.GET("/lucky-gifts/draws", middleware.RequirePermission("lucky-gift:view"), h.luckyGift.ListLuckyGiftDraws)
group.GET("/lucky-gifts/summary", middleware.RequirePermission("lucky-gift:view"), h.luckyGift.GetLuckyGiftDrawSummary) group.GET("/lucky-gifts/summary", middleware.RequirePermission("lucky-gift:view"), h.luckyGift.GetLuckyGiftDrawSummary)
group.GET("/lucky-gifts/user-profiles", middleware.RequirePermission("lucky-gift:view"), h.luckyGift.ListLuckyGiftUserProfiles) group.GET("/lucky-gifts/user-profiles", middleware.RequirePermission("lucky-gift:view"), h.luckyGift.ListLuckyGiftUserProfiles)

View File

@ -141,6 +141,10 @@ func (h *Handler) CreateResource(c *gin.Context) {
response.BadRequest(c, err.Error()) response.BadRequest(c, err.Error())
return return
} }
if err := validateRoomDecorationResourceMaterial(req); err != nil {
response.BadRequest(c, err.Error())
return
}
if err := validateResourceMetadata(req); err != nil { if err := validateResourceMetadata(req); err != nil {
response.BadRequest(c, err.Error()) response.BadRequest(c, err.Error())
return return
@ -257,6 +261,10 @@ func (h *Handler) UpdateResource(c *gin.Context) {
response.BadRequest(c, err.Error()) response.BadRequest(c, err.Error())
return return
} }
if err := validateRoomDecorationResourceMaterial(req); err != nil {
response.BadRequest(c, err.Error())
return
}
if err := validateResourceMetadata(req); err != nil { if err := validateResourceMetadata(req); err != nil {
response.BadRequest(c, err.Error()) response.BadRequest(c, err.Error())
return return

View File

@ -3,6 +3,8 @@ package resource
import ( import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"math"
"regexp"
"strconv" "strconv"
"strings" "strings"
"time" "time"
@ -22,10 +24,19 @@ const (
resourceTypeAvatarFrame = "avatar_frame" resourceTypeAvatarFrame = "avatar_frame"
resourceTypeBadge = "badge" resourceTypeBadge = "badge"
resourceTypeEmojiPack = "emoji_pack" resourceTypeEmojiPack = "emoji_pack"
resourceTypeMicAnimation = "mic_seat_animation"
resourceTypeProfileCard = "profile_card" resourceTypeProfileCard = "profile_card"
resourceTypeRoomBorder = "room_border"
resourceTypeRoomName = "room_name_style"
resourceTypeVIPTrialCard = "vip_trial_card" resourceTypeVIPTrialCard = "vip_trial_card"
) )
var roomNameStyleColorPattern = regexp.MustCompile(`^#[0-9a-fA-F]{6}([0-9a-fA-F]{2})?$`)
var vipVisualResourceFormats = map[string]struct{}{
"gif": {}, "jpeg": {}, "mp4": {}, "pag": {}, "png": {}, "svga": {}, "webp": {},
}
const ( const (
resourceAnimationFormatMP4 = "mp4" resourceAnimationFormatMP4 = "mp4"
) )
@ -126,9 +137,13 @@ type badgeMetadataPayload struct {
type resourceMetadataPayload struct { type resourceMetadataPayload struct {
AnimationFormat string `json:"animation_format,omitempty"` AnimationFormat string `json:"animation_format,omitempty"`
AvatarFrameKind string `json:"avatar_frame_kind,omitempty"` AvatarFrameKind string `json:"avatar_frame_kind,omitempty"`
Format string `json:"format,omitempty"`
LevelTrack string `json:"level_track,omitempty"` LevelTrack string `json:"level_track,omitempty"`
MP4AlphaLayout *mp4AlphaLayoutMetadataPayload `json:"mp4_alpha_layout,omitempty"` MP4AlphaLayout *mp4AlphaLayoutMetadataPayload `json:"mp4_alpha_layout,omitempty"`
ProfileCardLayout *profileCardLayoutMetadataPayload `json:"profile_card_layout,omitempty"` ProfileCardLayout *profileCardLayoutMetadataPayload `json:"profile_card_layout,omitempty"`
TextColors []string `json:"text_colors,omitempty"`
Stops []float64 `json:"stops,omitempty"`
AngleDegrees *float64 `json:"angle_degrees,omitempty"`
} }
type mp4AlphaLayoutMetadataPayload struct { type mp4AlphaLayoutMetadataPayload struct {
@ -644,8 +659,25 @@ func validateResourceMetadata(req resourceRequest) error {
return fmt.Errorf("资源元数据格式不正确") return fmt.Errorf("资源元数据格式不正确")
} }
if payload == nil { if payload == nil {
if resourceType == resourceTypeRoomName {
return fmt.Errorf("请配置房间名称渐变样式")
}
return nil return nil
} }
if resourceType == resourceTypeRoomName {
if err := validateRoomNameStyleMetadata(payload); err != nil {
return err
}
}
if resourceRequiresExplicitVisualFormat(resourceType) {
format := strings.ToLower(strings.TrimSpace(payload.Format))
if _, allowed := vipVisualResourceFormats[format]; !allowed {
return fmt.Errorf("视觉资源素材格式不支持")
}
if animationFormat := strings.ToLower(strings.TrimSpace(payload.AnimationFormat)); animationFormat != "" && animationFormat != format {
return fmt.Errorf("视觉资源素材格式与动效格式不一致")
}
}
if rawFormat, ok := fields["animation_format"]; ok && len(rawFormat) > 0 { if rawFormat, ok := fields["animation_format"]; ok && len(rawFormat) > 0 {
animationFormat := strings.ToLower(strings.TrimSpace(payload.AnimationFormat)) animationFormat := strings.ToLower(strings.TrimSpace(payload.AnimationFormat))
if animationFormat != "" && animationFormat != resourceAnimationFormatMP4 { if animationFormat != "" && animationFormat != resourceAnimationFormatMP4 {
@ -683,6 +715,39 @@ func resourceGrantStrategy(resourceType string) string {
} }
} }
func validateRoomDecorationResourceMaterial(req resourceRequest) error {
resourceType := normalizeResourceType(req.ResourceType)
if !resourceRequiresExplicitVisualFormat(resourceType) {
return nil
}
// 麦位动效和房间边框都是实际视觉资源,不能创建只有编码的占位行;
// 样式型 room_name_style 则由 metadata 独立渲染。
if strings.TrimSpace(req.AssetURL) == "" && strings.TrimSpace(req.AnimationURL) == "" {
return fmt.Errorf("请上传视觉资源素材")
}
if strings.TrimSpace(req.PreviewURL) == "" {
return fmt.Errorf("请上传视觉资源预览图")
}
payload, _, err := parseResourceMetadataPayload(req.MetadataJSON)
if err != nil || payload == nil {
return fmt.Errorf("请配置视觉资源素材格式")
}
format := strings.ToLower(strings.TrimSpace(payload.Format))
if _, allowed := vipVisualResourceFormats[format]; !allowed {
return fmt.Errorf("视觉资源素材格式不支持")
}
return nil
}
func resourceRequiresExplicitVisualFormat(resourceType string) bool {
switch normalizeResourceType(resourceType) {
case resourceTypeMicAnimation, resourceTypeRoomBorder:
return true
default:
return false
}
}
func resourceMetadataJSON(resourceType string, badgeForm string, metadataJSON string, badgeKind string, levelTrack string, avatarFrameKind string) string { func resourceMetadataJSON(resourceType string, badgeForm string, metadataJSON string, badgeKind string, levelTrack string, avatarFrameKind string) string {
if resourceType == resourceTypeBadge { if resourceType == resourceTypeBadge {
return badgeMetadataJSON(badgeForm, badgeKind, levelTrack) return badgeMetadataJSON(badgeForm, badgeKind, levelTrack)
@ -707,6 +772,14 @@ func resourceMetadataJSON(resourceType string, badgeForm string, metadataJSON st
payload.LevelTrack = normalizeLevelTrack(levelTrack) payload.LevelTrack = normalizeLevelTrack(levelTrack)
} }
} }
if resourceType == resourceTypeRoomName || resourceRequiresExplicitVisualFormat(resourceType) {
payload.Format = strings.ToLower(strings.TrimSpace(rawPayload.Format))
}
if resourceType == resourceTypeRoomName {
payload.TextColors = append([]string(nil), rawPayload.TextColors...)
payload.Stops = append([]float64(nil), rawPayload.Stops...)
payload.AngleDegrees = rawPayload.AngleDegrees
}
if resourceAllowsAnimationMetadata(resourceType) { if resourceAllowsAnimationMetadata(resourceType) {
if layout, err := sanitizeMP4AlphaLayoutMetadata(rawPayload.MP4AlphaLayout); err == nil && layout != nil && layout.Confirmed { if layout, err := sanitizeMP4AlphaLayoutMetadata(rawPayload.MP4AlphaLayout); err == nil && layout != nil && layout.Confirmed {
payload.AnimationFormat = resourceAnimationFormatMP4 payload.AnimationFormat = resourceAnimationFormatMP4
@ -777,7 +850,7 @@ func parseResourceMetadataPayload(metadataJSON string) (*resourceMetadataPayload
} }
func marshalResourceMetadataPayload(payload resourceMetadataPayload) string { func marshalResourceMetadataPayload(payload resourceMetadataPayload) string {
if payload.AnimationFormat == "" && payload.AvatarFrameKind == "" && payload.LevelTrack == "" && payload.MP4AlphaLayout == nil && payload.ProfileCardLayout == nil { if payload.AnimationFormat == "" && payload.AvatarFrameKind == "" && payload.Format == "" && payload.LevelTrack == "" && payload.MP4AlphaLayout == nil && payload.ProfileCardLayout == nil && len(payload.TextColors) == 0 && len(payload.Stops) == 0 && payload.AngleDegrees == nil {
return "{}" return "{}"
} }
body, err := json.Marshal(payload) body, err := json.Marshal(payload)
@ -875,13 +948,40 @@ func normalizeMP4AlphaLayout(value string) string {
func resourceAllowsAnimationMetadata(resourceType string) bool { func resourceAllowsAnimationMetadata(resourceType string) bool {
switch resourceType { switch resourceType {
case "gift", "vehicle", "avatar_frame", "chat_bubble", "floating_screen", resourceTypeProfileCard: case "gift", "vehicle", "avatar_frame", "chat_bubble", "floating_screen", resourceTypeProfileCard, resourceTypeRoomBorder:
return true return true
default: default:
return strings.HasPrefix(resourceType, "mic_seat_") return strings.HasPrefix(resourceType, "mic_seat_")
} }
} }
func validateRoomNameStyleMetadata(payload *resourceMetadataPayload) error {
if payload == nil || len(payload.TextColors) == 0 || len(payload.TextColors) > 8 {
return fmt.Errorf("房名渐变颜色必须配置 1 至 8 个")
}
for index, color := range payload.TextColors {
color = strings.TrimSpace(color)
if !roomNameStyleColorPattern.MatchString(color) {
return fmt.Errorf("房名渐变颜色必须使用 #RRGGBB 或 #RRGGBBAA")
}
payload.TextColors[index] = color
}
if len(payload.Stops) != 0 && len(payload.Stops) != len(payload.TextColors) {
return fmt.Errorf("房名渐变位置数量必须与颜色一致")
}
previous := -1.0
for _, stop := range payload.Stops {
if math.IsNaN(stop) || math.IsInf(stop, 0) || stop < 0 || stop > 1 || stop < previous {
return fmt.Errorf("房名渐变位置必须按 0 至 1 递增")
}
previous = stop
}
if payload.AngleDegrees != nil && (math.IsNaN(*payload.AngleDegrees) || math.IsInf(*payload.AngleDegrees, 0)) {
return fmt.Errorf("房名渐变角度不正确")
}
return nil
}
func sanitizeProfileCardLayoutMetadata(layout *profileCardLayoutMetadataPayload) *profileCardLayoutMetadataPayload { func sanitizeProfileCardLayoutMetadata(layout *profileCardLayoutMetadataPayload) *profileCardLayoutMetadataPayload {
if layout == nil { if layout == nil {
return nil return nil

View File

@ -57,6 +57,64 @@ type WithdrawalApplicationAuditAction func(application model.UserWithdrawalAppli
// 校验必须留在持行锁的短事务内,避免坏数据先进入 rejecting 后再永久卡住人工流程。 // 校验必须留在持行锁的短事务内,避免坏数据先进入 rejecting 后再永久卡住人工流程。
type WithdrawalApplicationClaimValidator func(application model.UserWithdrawalApplication) error type WithdrawalApplicationClaimValidator func(application model.UserWithdrawalApplication) error
// CreateExternalWithdrawalApplication 以 App、来源系统、来源申请号作为业务幂等键。
// 来源钱包采用持久重试投递;重复提交必须返回同一 admin 申请,不能制造两张可独立审核的资金单。
func (s *Store) CreateExternalWithdrawalApplication(application model.UserWithdrawalApplication) (*model.UserWithdrawalApplication, error) {
application.AppCode = strings.TrimSpace(application.AppCode)
application.SourceSystem = strings.TrimSpace(application.SourceSystem)
application.SourceApplicationID = strings.TrimSpace(application.SourceApplicationID)
if application.AppCode == "" || application.SourceSystem == "" || application.SourceApplicationID == "" {
return nil, errors.New("external withdrawal source identity is incomplete")
}
var existing model.UserWithdrawalApplication
lookup := s.db.Where(
"app_code = ? AND source_system = ? AND source_application_id = ?",
application.AppCode,
application.SourceSystem,
application.SourceApplicationID,
).First(&existing)
if lookup.Error == nil {
if err := validateExternalWithdrawalReplay(existing, application); err != nil {
return nil, err
}
return &existing, nil
}
if !errors.Is(lookup.Error, gorm.ErrRecordNotFound) {
return nil, lookup.Error
}
result := s.db.Clauses(clause.OnConflict{DoNothing: true}).Create(&application)
if result.Error != nil {
return nil, result.Error
}
// MySQL 对 ON DUPLICATE KEY 的 RowsAffected 会受连接参数影响;无论本次创建还是并发重放,都按业务键回读唯一事实。
if err := s.db.Where(
"app_code = ? AND source_system = ? AND source_application_id = ?",
application.AppCode,
application.SourceSystem,
application.SourceApplicationID,
).First(&existing).Error; err != nil {
return nil, err
}
if err := validateExternalWithdrawalReplay(existing, application); err != nil {
return nil, err
}
return &existing, nil
}
func validateExternalWithdrawalReplay(existing model.UserWithdrawalApplication, replay model.UserWithdrawalApplication) error {
if existing.UserID != replay.UserID ||
existing.SalaryAssetType != replay.SalaryAssetType ||
existing.WithdrawAmountMinor != replay.WithdrawAmountMinor ||
existing.WithdrawMethod != replay.WithdrawMethod ||
existing.WithdrawAddress != replay.WithdrawAddress {
// 相同来源单号只能重放完全一致的资金事实,不能用幂等接口覆盖已进入人工审核的金额或收款地址。
return errors.New("external withdrawal replay payload conflicts with existing application")
}
return nil
}
func (s *Store) GetWithdrawalApplication(id uint) (*model.UserWithdrawalApplication, error) { func (s *Store) GetWithdrawalApplication(id uint) (*model.UserWithdrawalApplication, error) {
var application model.UserWithdrawalApplication var application model.UserWithdrawalApplication
if err := s.db.First(&application, id).Error; err != nil { if err := s.db.First(&application, id).Error; err != nil {

View File

@ -154,6 +154,7 @@ func New(cfg config.Config, auth *service.AuthService, store *repository.Store,
appProtected.Use(middleware.RequireAppScope(store)) appProtected.Use(middleware.RequireAppScope(store))
authroutes.RegisterRoutes(api, protected, h.Auth) authroutes.RegisterRoutes(api, protected, h.Auth)
financewithdrawal.RegisterIntegrationRoutes(api, h.FinanceWithdrawal)
externaladmin.RegisterExternalRoutes(api, h.ExternalAdmin, externaladmin.BusinessHandlers{ externaladmin.RegisterExternalRoutes(api, h.ExternalAdmin, externaladmin.BusinessHandlers{
AppUser: h.AppUser, HostOrg: h.HostOrg, RoomAdmin: h.RoomAdmin, Resource: h.Resource, AppUser: h.AppUser, HostOrg: h.HostOrg, RoomAdmin: h.RoomAdmin, Resource: h.Resource,
PrettyID: h.PrettyID, AppConfig: h.AppConfig, VIPConfig: h.VIPConfig, Upload: h.Upload, PrettyID: h.PrettyID, AppConfig: h.AppConfig, VIPConfig: h.VIPConfig, Upload: h.Upload,

View File

@ -0,0 +1,63 @@
SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci;
-- 两列都是短 VARCHAR线上使用 INPLACE/LOCK=NONE避免跨 App 接入时阻塞现有 Lalu 提现写入。
SET @source_system_exists = (
SELECT COUNT(*)
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'admin_user_withdrawal_applications'
AND COLUMN_NAME = 'source_system'
);
SET @source_system_ddl = IF(
@source_system_exists = 0,
'ALTER TABLE admin_user_withdrawal_applications ADD COLUMN source_system VARCHAR(32) NOT NULL DEFAULT ''hyapp_wallet'' AFTER app_code, ALGORITHM=INPLACE, LOCK=NONE',
'SELECT 1'
);
PREPARE stmt FROM @source_system_ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @source_application_id_exists = (
SELECT COUNT(*)
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'admin_user_withdrawal_applications'
AND COLUMN_NAME = 'source_application_id'
);
SET @source_application_id_ddl = IF(
@source_application_id_exists = 0,
'ALTER TABLE admin_user_withdrawal_applications ADD COLUMN source_application_id VARCHAR(128) NOT NULL DEFAULT '''' AFTER source_system, ALGORITHM=INPLACE, LOCK=NONE',
'SELECT 1'
);
PREPARE stmt FROM @source_application_id_ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
-- 存量 Lalu 单使用已经唯一的 freeze_command_id 作为来源单号,确保新增唯一索引不会发生空值碰撞。
UPDATE admin_user_withdrawal_applications
SET source_system = 'hyapp_wallet',
source_application_id = freeze_command_id
WHERE source_application_id = '';
SET @source_index_exists = (
SELECT COUNT(*)
FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'admin_user_withdrawal_applications'
AND INDEX_NAME = 'uk_admin_withdrawal_source_application'
);
-- 唯一索引同时承担来源重试的幂等查找;列选择性高,不增加列表查询扫描成本。
SET @source_index_ddl = IF(
@source_index_exists = 0,
'ALTER TABLE admin_user_withdrawal_applications ADD UNIQUE KEY uk_admin_withdrawal_source_application (app_code, source_system, source_application_id), ALGORITHM=INPLACE, LOCK=NONE',
'SELECT 1'
);
PREPARE stmt FROM @source_index_ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;

View File

@ -43,6 +43,18 @@ type RoomGuardClient interface {
ResolveRoomAppCode(ctx context.Context, req *roomv1.ResolveRoomAppCodeRequest) (*roomv1.ResolveRoomAppCodeResponse, error) ResolveRoomAppCode(ctx context.Context, req *roomv1.ResolveRoomAppCodeRequest) (*roomv1.ResolveRoomAppCodeResponse, error)
} }
// RoomVIPFeatureClient 是新版房间 VIP 展示能力的窄接口,避免扩大旧 RoomClient fake。
type RoomVIPFeatureClient interface {
ApplyRoomDecoration(ctx context.Context, req *roomv1.ApplyRoomDecorationRequest) (*roomv1.ApplyRoomDecorationResponse, error)
SendRoomImage(ctx context.Context, req *roomv1.SendRoomImageRequest) (*roomv1.SendRoomImageResponse, error)
}
// RoomMediaGuardClient 在 COS 写入前向 room owner 校验上传权限。
type RoomMediaGuardClient interface {
AuthorizeRoomMediaUpload(ctx context.Context, req *roomv1.AuthorizeRoomMediaUploadRequest) (*roomv1.AuthorizeRoomMediaUploadResponse, error)
CompleteRoomMediaUpload(ctx context.Context, req *roomv1.CompleteRoomMediaUploadRequest) (*roomv1.CompleteRoomMediaUploadResponse, error)
}
// RoomQueryClient 抽象 gateway 对 room-service 读模型查询的依赖。 // RoomQueryClient 抽象 gateway 对 room-service 读模型查询的依赖。
type RoomQueryClient interface { type RoomQueryClient interface {
ListRooms(ctx context.Context, req *roomv1.ListRoomsRequest) (*roomv1.ListRoomsResponse, error) ListRooms(ctx context.Context, req *roomv1.ListRoomsRequest) (*roomv1.ListRoomsResponse, error)
@ -108,6 +120,14 @@ func (c *grpcRoomClient) SetRoomBackground(ctx context.Context, req *roomv1.SetR
return c.client.SetRoomBackground(ctx, req) return c.client.SetRoomBackground(ctx, req)
} }
func (c *grpcRoomClient) ApplyRoomDecoration(ctx context.Context, req *roomv1.ApplyRoomDecorationRequest) (*roomv1.ApplyRoomDecorationResponse, error) {
return c.client.ApplyRoomDecoration(ctx, req)
}
func (c *grpcRoomClient) SendRoomImage(ctx context.Context, req *roomv1.SendRoomImageRequest) (*roomv1.SendRoomImageResponse, error) {
return c.client.SendRoomImage(ctx, req)
}
func (c *grpcRoomClient) JoinRoom(ctx context.Context, req *roomv1.JoinRoomRequest) (*roomv1.JoinRoomResponse, error) { func (c *grpcRoomClient) JoinRoom(ctx context.Context, req *roomv1.JoinRoomRequest) (*roomv1.JoinRoomResponse, error) {
return c.client.JoinRoom(ctx, req) return c.client.JoinRoom(ctx, req)
} }
@ -200,6 +220,14 @@ func (c *grpcRoomGuardClient) ResolveRoomAppCode(ctx context.Context, req *roomv
return c.client.ResolveRoomAppCode(ctx, req) return c.client.ResolveRoomAppCode(ctx, req)
} }
func (c *grpcRoomGuardClient) AuthorizeRoomMediaUpload(ctx context.Context, req *roomv1.AuthorizeRoomMediaUploadRequest) (*roomv1.AuthorizeRoomMediaUploadResponse, error) {
return c.client.AuthorizeRoomMediaUpload(ctx, req)
}
func (c *grpcRoomGuardClient) CompleteRoomMediaUpload(ctx context.Context, req *roomv1.CompleteRoomMediaUploadRequest) (*roomv1.CompleteRoomMediaUploadResponse, error) {
return c.client.CompleteRoomMediaUpload(ctx, req)
}
func (c *grpcRoomQueryClient) ListRooms(ctx context.Context, req *roomv1.ListRoomsRequest) (*roomv1.ListRoomsResponse, error) { func (c *grpcRoomQueryClient) ListRooms(ctx context.Context, req *roomv1.ListRoomsRequest) (*roomv1.ListRoomsResponse, error) {
return c.client.ListRooms(ctx, req) return c.client.ListRooms(ctx, req)
} }
@ -228,6 +256,11 @@ func (c *grpcRoomQueryClient) GetCurrentRoom(ctx context.Context, req *roomv1.Ge
return c.client.GetCurrentRoom(ctx, req) return c.client.GetCurrentRoom(ctx, req)
} }
// BatchGetUserVoiceRoomPresences 保持为具体 client 能力;只有个人资料页入口依赖它,避免扩大所有房间查询 mock 的接口面。
func (c *grpcRoomQueryClient) BatchGetUserVoiceRoomPresences(ctx context.Context, req *roomv1.BatchGetUserVoiceRoomPresencesRequest) (*roomv1.BatchGetUserVoiceRoomPresencesResponse, error) {
return c.client.BatchGetUserVoiceRoomPresences(ctx, req)
}
func (c *grpcRoomQueryClient) GetRoomSnapshot(ctx context.Context, req *roomv1.GetRoomSnapshotRequest) (*roomv1.GetRoomSnapshotResponse, error) { func (c *grpcRoomQueryClient) GetRoomSnapshot(ctx context.Context, req *roomv1.GetRoomSnapshotRequest) (*roomv1.GetRoomSnapshotResponse, error) {
return c.client.GetRoomSnapshot(ctx, req) return c.client.GetRoomSnapshot(ctx, req)
} }

View File

@ -119,12 +119,14 @@ func (w *MySQLWriter) CreateApplication(ctx context.Context, command CreateAppli
result, err := w.db.ExecContext(ctx, ` result, err := w.db.ExecContext(ctx, `
INSERT INTO admin_user_withdrawal_applications INSERT INTO admin_user_withdrawal_applications
(app_code, user_id, salary_asset_type, withdraw_amount, withdraw_amount_minor, (app_code, source_system, source_application_id, user_id, salary_asset_type, withdraw_amount, withdraw_amount_minor,
point_fee_amount, point_net_amount, points_per_usd, point_fee_bps, point_policy_instance_code, point_fee_amount, point_net_amount, points_per_usd, point_fee_bps, point_policy_instance_code,
withdraw_method, withdraw_address, freeze_command_id, freeze_transaction_id, status, operations_status, created_at_ms, updated_at_ms) withdraw_method, withdraw_address, freeze_command_id, freeze_transaction_id, status, operations_status, created_at_ms, updated_at_ms)
VALUES VALUES
(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
command.AppCode, command.AppCode,
"hyapp_wallet",
command.FreezeCommandID,
formatUserID(command.UserID), formatUserID(command.UserID),
command.SalaryAssetType, command.SalaryAssetType,
command.WithdrawAmount, command.WithdrawAmount,

View File

@ -93,6 +93,8 @@ func TestCreateApplicationInsertsAfterAppScopedMiss(t *testing.T) {
mock.ExpectExec(`(?s)INSERT INTO admin_user_withdrawal_applications`). mock.ExpectExec(`(?s)INSERT INTO admin_user_withdrawal_applications`).
WithArgs( WithArgs(
"huwaa", "huwaa",
"hyapp_wallet",
"cmd-point-freeze",
"42001", "42001",
"POINT", "POINT",
"9.50", "9.50",

View File

@ -101,10 +101,17 @@ func WriteOK(writer http.ResponseWriter, request *http.Request, data any) {
// WriteError 写失败 envelope。 // WriteError 写失败 envelope。
// 错误 message 保持稳定、短小,排障依赖 request_id 追日志,而不是把内部错误直接暴露给客户端。 // 错误 message 保持稳定、短小,排障依赖 request_id 追日志,而不是把内部错误直接暴露给客户端。
func WriteError(writer http.ResponseWriter, request *http.Request, statusCode int, code string, message string) { func WriteError(writer http.ResponseWriter, request *http.Request, statusCode int, code string, message string) {
WriteErrorData(writer, request, statusCode, code, message, nil)
}
// WriteErrorData 写入客户端可执行的结构化错误详情。
// data 只能来自业务错误的显式安全 metadata 或 handler 已判定的公开状态,不能放原始 err。
func WriteErrorData(writer http.ResponseWriter, request *http.Request, statusCode int, code string, message string, data any) {
WriteEnvelope(writer, statusCode, ResponseEnvelope{ WriteEnvelope(writer, statusCode, ResponseEnvelope{
Code: code, Code: code,
Message: message, Message: message,
RequestID: ResponseRequestID(request), RequestID: ResponseRequestID(request),
Data: data,
}) })
} }
@ -127,7 +134,30 @@ func WriteRPCError(writer http.ResponseWriter, request *http.Request, err error)
message = detail message = detail
} }
} }
WriteError(writer, request, statusCode, code, message) WriteErrorData(writer, request, statusCode, code, message, rpcErrorData(err))
}
// rpcErrorData 将 ErrorInfo.metadata 转成 HTTP JSON data并只对已登记的等级字段做数值化。
// google.rpc 的 metadata wire 类型固定为 string这里显式 ParseInt 后Flutter 才能按整数
// 比较 required_level/current_effective_level其余字段继续保持稳定字符串语义。
func rpcErrorData(err error) map[string]any {
metadata := xerr.MetadataFromGRPC(err)
if len(metadata) == 0 {
return nil
}
data := make(map[string]any, len(metadata))
for key, value := range metadata {
switch key {
case "required_level", "current_effective_level":
level, parseErr := strconv.ParseInt(value, 10, 32)
if parseErr == nil {
data[key] = level
continue
}
}
data[key] = value
}
return data
} }
// Write 统一把内部 gRPC 调用结果转换为 HTTP JSON 响应。 // Write 统一把内部 gRPC 调用结果转换为 HTTP JSON 响应。

View File

@ -75,6 +75,7 @@ type UserHandlers struct {
ResolveDisplayUserID http.HandlerFunc ResolveDisplayUserID http.HandlerFunc
BatchUserProfiles http.HandlerFunc BatchUserProfiles http.HandlerFunc
BatchRoomDisplayProfiles http.HandlerFunc BatchRoomDisplayProfiles http.HandlerFunc
BatchUserVoiceRoomPresences http.HandlerFunc
GetMyOverview http.HandlerFunc GetMyOverview http.HandlerFunc
GetMyInviteOverview http.HandlerFunc GetMyInviteOverview http.HandlerFunc
GetMyInviteReferrer http.HandlerFunc GetMyInviteReferrer http.HandlerFunc
@ -179,6 +180,10 @@ type RoomHandlers struct {
SaveRoomBackground http.HandlerFunc SaveRoomBackground http.HandlerFunc
ListRoomBackgrounds http.HandlerFunc ListRoomBackgrounds http.HandlerFunc
SetRoomBackground http.HandlerFunc SetRoomBackground http.HandlerFunc
UploadRoomBackground http.HandlerFunc
UploadRoomImage http.HandlerFunc
ApplyRoomDecoration http.HandlerFunc
SendRoomImage http.HandlerFunc
JoinRoom http.HandlerFunc JoinRoom http.HandlerFunc
RoomHeartbeat http.HandlerFunc RoomHeartbeat http.HandlerFunc
LeaveRoom http.HandlerFunc LeaveRoom http.HandlerFunc
@ -469,6 +474,7 @@ func (r routes) registerUserRoutes() {
r.public("/users/by-id/{user_id}/appearance", http.MethodGet, h.GetUserAppearance) r.public("/users/by-id/{user_id}/appearance", http.MethodGet, h.GetUserAppearance)
r.profile("/users/profiles:batch", "", h.BatchUserProfiles) r.profile("/users/profiles:batch", "", h.BatchUserProfiles)
r.profile("/users/room-display-profiles:batch", http.MethodGet, h.BatchRoomDisplayProfiles) r.profile("/users/room-display-profiles:batch", http.MethodGet, h.BatchRoomDisplayProfiles)
r.profile("/users/voice-room-presence:batch", http.MethodGet, h.BatchUserVoiceRoomPresences)
r.profile("/users/me/overview", "", h.GetMyOverview) r.profile("/users/me/overview", "", h.GetMyOverview)
r.profile("/users/me/invite-overview", http.MethodGet, h.GetMyInviteOverview) r.profile("/users/me/invite-overview", http.MethodGet, h.GetMyInviteOverview)
r.profile("/users/me/invite-referrer", http.MethodGet, h.GetMyInviteReferrer) r.profile("/users/me/invite-referrer", http.MethodGet, h.GetMyInviteReferrer)
@ -568,6 +574,10 @@ func (r routes) registerRoomRoutes() {
r.profile("/rooms/{room_id}/rocket", http.MethodGet, h.GetRoomRocket) r.profile("/rooms/{room_id}/rocket", http.MethodGet, h.GetRoomRocket)
r.profile("/rooms/{room_id}/follow", "", h.FollowRoom) r.profile("/rooms/{room_id}/follow", "", h.FollowRoom)
r.profile("/rooms/{room_id}/backgrounds", http.MethodGet, h.ListRoomBackgrounds) r.profile("/rooms/{room_id}/backgrounds", http.MethodGet, h.ListRoomBackgrounds)
r.profile("/rooms/{room_id}/backgrounds/upload", http.MethodPost, h.UploadRoomBackground)
r.profile("/rooms/{room_id}/messages/images/upload", http.MethodPost, h.UploadRoomImage)
r.profile("/rooms/{room_id}/messages/image", http.MethodPost, h.SendRoomImage)
r.profile("/rooms/{room_id}/decorations/apply", http.MethodPost, h.ApplyRoomDecoration)
r.profile("/rooms/create", http.MethodPost, h.CreateRoom) r.profile("/rooms/create", http.MethodPost, h.CreateRoom)
r.profile("/rooms/profile/update", http.MethodPost, h.UpdateRoomProfile) r.profile("/rooms/profile/update", http.MethodPost, h.UpdateRoomProfile)
r.profile("/rooms/backgrounds/save", http.MethodPost, h.SaveRoomBackground) r.profile("/rooms/backgrounds/save", http.MethodPost, h.SaveRoomBackground)

View File

@ -12,6 +12,7 @@ import (
walletv1 "hyapp.local/api/proto/wallet/v1" walletv1 "hyapp.local/api/proto/wallet/v1"
"hyapp/pkg/appcode" "hyapp/pkg/appcode"
"hyapp/services/gateway-service/internal/auth" "hyapp/services/gateway-service/internal/auth"
"hyapp/services/gateway-service/internal/transport/http/resourceview"
) )
const resourceTypeEmojiPack = "emoji_pack" const resourceTypeEmojiPack = "emoji_pack"
@ -39,6 +40,7 @@ type resourceData struct {
AssetURL string `json:"asset_url"` AssetURL string `json:"asset_url"`
PreviewURL string `json:"preview_url"` PreviewURL string `json:"preview_url"`
AnimationURL string `json:"animation_url"` AnimationURL string `json:"animation_url"`
Format string `json:"format,omitempty"`
MetadataJSON string `json:"metadata_json"` MetadataJSON string `json:"metadata_json"`
SortOrder int32 `json:"sort_order"` SortOrder int32 `json:"sort_order"`
CreatedAtMS int64 `json:"created_at_ms"` CreatedAtMS int64 `json:"created_at_ms"`
@ -88,6 +90,12 @@ type resourceShopPurchaseRequestBody struct {
CommandIDAlt string `json:"commandId"` CommandIDAlt string `json:"commandId"`
} }
type resourceEquipRequestBody struct {
EntitlementID string `json:"entitlement_id"`
CommandID string `json:"command_id"`
CommandIDAlt string `json:"commandId"`
}
type resourceShopPurchaseData struct { type resourceShopPurchaseData struct {
OrderID string `json:"order_id"` OrderID string `json:"order_id"`
TransactionID string `json:"transaction_id"` TransactionID string `json:"transaction_id"`
@ -475,18 +483,31 @@ func (h *Handler) equipMyResource(writer http.ResponseWriter, request *http.Requ
httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument") httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
return return
} }
var body struct { var body resourceEquipRequestBody
EntitlementID string `json:"entitlement_id"`
}
if request.Body != nil && request.Body != http.NoBody { if request.Body != nil && request.Body != http.NoBody {
if err := httpkit.DecodeJSON(request.Body, &body); err != nil && !errors.Is(err, io.EOF) { if err := httpkit.DecodeJSON(request.Body, &body); err != nil && !errors.Is(err, io.EOF) {
httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidJSON, "invalid request body") httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidJSON, "invalid request body")
return return
} }
} }
commandID := strings.TrimSpace(body.CommandID)
if commandID == "" {
commandID = strings.TrimSpace(body.CommandIDAlt)
}
appCode := appcode.FromContext(request.Context())
if commandID == "" {
if appCode == "fami" {
// Fami tiered VIP 是本次新契约,不能静默用每次都会变化的 request_id 伪装业务幂等。
httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
return
}
// Lalu 等旧客户端没有业务 command_id用本次 request_id 保持原有可调用性,但不承诺跨请求重放。
commandID = "legacy_equip:" + httpkit.RequestIDFromContext(request.Context())
}
resp, err := h.walletClient.EquipUserResource(request.Context(), &walletv1.EquipUserResourceRequest{ resp, err := h.walletClient.EquipUserResource(request.Context(), &walletv1.EquipUserResourceRequest{
RequestId: httpkit.RequestIDFromContext(request.Context()), RequestId: httpkit.RequestIDFromContext(request.Context()),
AppCode: appcode.FromContext(request.Context()), CommandId: commandID,
AppCode: appCode,
UserId: auth.UserIDFromContext(request.Context()), UserId: auth.UserIDFromContext(request.Context()),
ResourceId: resourceID, ResourceId: resourceID,
EntitlementId: strings.TrimSpace(body.EntitlementID), EntitlementId: strings.TrimSpace(body.EntitlementID),
@ -550,6 +571,7 @@ func resourceFromProto(item *walletv1.Resource) resourceData {
AssetURL: item.GetAssetUrl(), AssetURL: item.GetAssetUrl(),
PreviewURL: item.GetPreviewUrl(), PreviewURL: item.GetPreviewUrl(),
AnimationURL: item.GetAnimationUrl(), AnimationURL: item.GetAnimationUrl(),
Format: resourceview.FormatFromMetadata(item.GetMetadataJson()),
MetadataJSON: item.GetMetadataJson(), MetadataJSON: item.GetMetadataJson(),
SortOrder: item.GetSortOrder(), SortOrder: item.GetSortOrder(),
CreatedAtMS: item.GetCreatedAtMs(), CreatedAtMS: item.GetCreatedAtMs(),

View File

@ -0,0 +1,24 @@
// Package resourceview contains the stable client-facing projection rules for wallet resources.
package resourceview
import (
"encoding/json"
"strings"
)
// FormatFromMetadata returns only an explicitly configured renderer identifier. format is the
// current contract and animation_format remains a legacy fallback; URL suffixes are intentionally
// never inspected because signed/CDN URLs are not a trustworthy media-format fact.
func FormatFromMetadata(raw string) string {
var metadata struct {
Format string `json:"format"`
AnimationFormat string `json:"animation_format"`
}
if json.Unmarshal([]byte(strings.TrimSpace(raw)), &metadata) != nil {
return ""
}
if format := strings.ToLower(strings.TrimSpace(metadata.Format)); format != "" {
return format
}
return strings.ToLower(strings.TrimSpace(metadata.AnimationFormat))
}

View File

@ -392,6 +392,7 @@ type fakeRoomQueryClient struct {
lastFeeds *roomv1.ListRoomFeedsRequest lastFeeds *roomv1.ListRoomFeedsRequest
lastMyRoom *roomv1.GetMyRoomRequest lastMyRoom *roomv1.GetMyRoomRequest
lastCurrent *roomv1.GetCurrentRoomRequest lastCurrent *roomv1.GetCurrentRoomRequest
lastVoicePresence *roomv1.BatchGetUserVoiceRoomPresencesRequest
lastSnapshot *roomv1.GetRoomSnapshotRequest lastSnapshot *roomv1.GetRoomSnapshotRequest
lastBackgrounds *roomv1.ListRoomBackgroundsRequest lastBackgrounds *roomv1.ListRoomBackgroundsRequest
lastRocket *roomv1.GetRoomRocketRequest lastRocket *roomv1.GetRoomRocketRequest
@ -403,6 +404,7 @@ type fakeRoomQueryClient struct {
feedsResp *roomv1.ListRoomsResponse feedsResp *roomv1.ListRoomsResponse
myRoomResp *roomv1.GetMyRoomResponse myRoomResp *roomv1.GetMyRoomResponse
currentResp *roomv1.GetCurrentRoomResponse currentResp *roomv1.GetCurrentRoomResponse
voicePresenceResp *roomv1.BatchGetUserVoiceRoomPresencesResponse
snapshotResp *roomv1.GetRoomSnapshotResponse snapshotResp *roomv1.GetRoomSnapshotResponse
backgroundsResp *roomv1.ListRoomBackgroundsResponse backgroundsResp *roomv1.ListRoomBackgroundsResponse
rocketResp *roomv1.GetRoomRocketResponse rocketResp *roomv1.GetRoomRocketResponse
@ -414,6 +416,7 @@ type fakeRoomQueryClient struct {
feedsErr error feedsErr error
myRoomErr error myRoomErr error
currentErr error currentErr error
voicePresenceErr error
snapshotErr error snapshotErr error
backgroundsErr error backgroundsErr error
rocketErr error rocketErr error
@ -1390,6 +1393,17 @@ func (f *fakeRoomQueryClient) GetCurrentRoom(_ context.Context, req *roomv1.GetC
return &roomv1.GetCurrentRoomResponse{}, nil return &roomv1.GetCurrentRoomResponse{}, nil
} }
func (f *fakeRoomQueryClient) BatchGetUserVoiceRoomPresences(_ context.Context, req *roomv1.BatchGetUserVoiceRoomPresencesRequest) (*roomv1.BatchGetUserVoiceRoomPresencesResponse, error) {
f.lastVoicePresence = req
if f.voicePresenceErr != nil {
return nil, f.voicePresenceErr
}
if f.voicePresenceResp != nil {
return f.voicePresenceResp, nil
}
return &roomv1.BatchGetUserVoiceRoomPresencesResponse{}, nil
}
func (f *fakeRoomQueryClient) GetRoomSnapshot(_ context.Context, req *roomv1.GetRoomSnapshotRequest) (*roomv1.GetRoomSnapshotResponse, error) { func (f *fakeRoomQueryClient) GetRoomSnapshot(_ context.Context, req *roomv1.GetRoomSnapshotRequest) (*roomv1.GetRoomSnapshotResponse, error) {
f.lastSnapshot = req f.lastSnapshot = req
if f.snapshotErr != nil { if f.snapshotErr != nil {
@ -4811,6 +4825,87 @@ func TestGetCurrentRoomUsesAuthenticatedUser(t *testing.T) {
} }
} }
func TestBatchUserVoiceRoomPresencesUsesAuthenticatedViewerAndStableJSON(t *testing.T) {
queryClient := &fakeRoomQueryClient{voicePresenceResp: &roomv1.BatchGetUserVoiceRoomPresencesResponse{
Items: []*roomv1.UserVoiceRoomPresence{
{
UserId: 1002,
State: "on_mic",
SeatNo: 3,
Joinable: true,
ObservedAtMs: 1_784_265_600_000,
Room: &roomv1.UserVoiceRoomPresenceRoom{
RoomId: "room-profile-live",
RoomShortId: "886688",
OwnerUserId: 1001,
HostUserId: 1001,
Title: "Live Room",
CoverUrl: "https://cdn.example.com/room.png",
BackgroundUrl: "https://cdn.example.com/background.png",
Mode: "voice",
OnlineCount: 56,
SeatCount: 10,
Locked: true,
},
},
{UserId: 1003, State: "not_on_mic", ObservedAtMs: 1_784_265_600_000},
},
ServerTimeMs: 1_784_265_600_123,
}}
handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{})
handler.SetRoomQueryClient(queryClient)
router := handler.Routes(auth.NewVerifier("secret"))
request := httptest.NewRequest(http.MethodGet, "/api/v1/users/voice-room-presence:batch?user_ids=1002,1003,1002", nil)
request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
request.Header.Set("X-Request-ID", "req-profile-live")
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 queryClient.lastVoicePresence == nil || queryClient.lastVoicePresence.GetViewerUserId() != 42 {
t.Fatalf("voice presence query must use authenticated viewer: %+v", queryClient.lastVoicePresence)
}
if got := queryClient.lastVoicePresence.GetUserIds(); len(got) != 2 || got[0] != 1002 || got[1] != 1003 {
t.Fatalf("voice presence query must preserve first-seen deduplicated ids: %v", got)
}
if queryClient.lastVoicePresence.GetMeta().GetRequestId() != assertGeneratedRequestID(t, recorder, "req-profile-live") {
t.Fatalf("voice presence query must propagate generated request id: %+v", queryClient.lastVoicePresence.GetMeta())
}
var response httpkit.ResponseEnvelope
if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil {
t.Fatalf("decode voice presence response failed: %v", err)
}
data := response.Data.(map[string]any)
items := data["items"].([]any)
onMic := items[0].(map[string]any)
room := onMic["room"].(map[string]any)
if onMic["user_id"] != "1002" || onMic["state"] != "on_mic" || onMic["joinable"] != true || room["room_id"] != "room-profile-live" || room["rtc_room_id"] != "room-profile-live" || room["locked"] != true {
t.Fatalf("on-mic response contract mismatch: %+v", onMic)
}
notOnMic := items[1].(map[string]any)
if notOnMic["state"] != "not_on_mic" || notOnMic["joinable"] != false || notOnMic["room"] != nil {
t.Fatalf("not-on-mic response must keep false and null fields: %+v", notOnMic)
}
}
func TestBatchUserVoiceRoomPresencesRejectsInvalidIDs(t *testing.T) {
handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{})
handler.SetRoomQueryClient(&fakeRoomQueryClient{})
router := handler.Routes(auth.NewVerifier("secret"))
request := httptest.NewRequest(http.MethodGet, "/api/v1/users/voice-room-presence:batch?user_ids=1002,,1003", nil)
request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
request.Header.Set("X-Request-ID", "req-profile-live-invalid")
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request)
assertEnvelope(t, recorder, http.StatusBadRequest, "INVALID_USER_IDS", "req-profile-live-invalid")
}
func TestGetRoomSnapshotUsesAuthenticatedUser(t *testing.T) { func TestGetRoomSnapshotUsesAuthenticatedUser(t *testing.T) {
queryClient := &fakeRoomQueryClient{snapshotResp: &roomv1.GetRoomSnapshotResponse{ queryClient := &fakeRoomQueryClient{snapshotResp: &roomv1.GetRoomSnapshotResponse{
Room: &roomv1.RoomSnapshot{ Room: &roomv1.RoomSnapshot{
@ -11029,6 +11124,7 @@ func TestProfileGateRejectsIncompleteUsersForRoomIMRTCPaidCapabilities(t *testin
{name: "message_delete", method: http.MethodDelete, path: "/api/v1/messages/msg-1"}, {name: "message_delete", method: http.MethodDelete, path: "/api/v1/messages/msg-1"},
{name: "direct_gift", method: http.MethodPost, path: "/api/v1/messages/direct-gifts/send", body: `{"command_id":"cmd-direct-gift","target_user_id":900002,"gift_id":"rose","gift_count":1}`}, {name: "direct_gift", method: http.MethodPost, path: "/api/v1/messages/direct-gifts/send", body: `{"command_id":"cmd-direct-gift","target_user_id":900002,"gift_id":"rose","gift_count":1}`},
{name: "profiles_batch", method: http.MethodGet, path: "/api/v1/users/profiles:batch?user_ids=42"}, {name: "profiles_batch", method: http.MethodGet, path: "/api/v1/users/profiles:batch?user_ids=42"},
{name: "voice_room_presence_batch", method: http.MethodGet, path: "/api/v1/users/voice-room-presence:batch?user_ids=43"},
} }
for _, test := range tests { for _, test := range tests {

View File

@ -1,6 +1,8 @@
package roomapi package roomapi
import ( import (
"context"
"io"
"net/http" "net/http"
"time" "time"
@ -11,12 +13,25 @@ import (
"hyapp/services/gateway-service/internal/transport/http/httproutes" "hyapp/services/gateway-service/internal/transport/http/httproutes"
) )
// ObjectUploader 是房间专用媒体上传所需的最小 COS 能力。
type ObjectUploader interface {
PutObject(ctx context.Context, key string, reader io.Reader, sizeBytes int64, contentType string) (string, error)
}
type objectURLVerifier interface {
OwnsObjectURL(objectURL string, objectKey string) bool
}
// Handler owns room-facing HTTP endpoints in gateway. // Handler owns room-facing HTTP endpoints in gateway.
// It keeps gateway as protocol adapter only: room state still belongs to room-service. // It keeps gateway as protocol adapter only: room state still belongs to room-service.
type Handler struct { type Handler struct {
roomClient client.RoomClient roomClient client.RoomClient
roomGuardClient client.RoomGuardClient roomGuardClient client.RoomGuardClient
roomQueryClient client.RoomQueryClient roomQueryClient client.RoomQueryClient
roomVIPClient client.RoomVIPFeatureClient
roomMediaGuard client.RoomMediaGuardClient
objectUploader ObjectUploader
objectURLVerifier objectURLVerifier
userProfileClient client.UserProfileClient userProfileClient client.UserProfileClient
userCountryClient client.UserCountryQueryClient userCountryClient client.UserCountryQueryClient
userRegionClient client.UserRegionClient userRegionClient client.UserRegionClient
@ -62,6 +77,7 @@ type Config struct {
GiftMaxCount int GiftMaxCount int
GiftMaxUnits int GiftMaxUnits int
RTCTokenConfig tencentrtc.TokenConfig RTCTokenConfig tencentrtc.TokenConfig
ObjectUploader ObjectUploader
} }
func New(config Config) *Handler { func New(config Config) *Handler {
@ -82,10 +98,17 @@ func New(config Config) *Handler {
giftMaxUnits = 5_000 giftMaxUnits = 5_000
} }
comboReader, _ := any(config.AppConfigReader).(giftComboConfigReader) comboReader, _ := any(config.AppConfigReader).(giftComboConfigReader)
roomVIPClient, _ := any(config.RoomClient).(client.RoomVIPFeatureClient)
roomMediaGuard, _ := any(config.RoomGuardClient).(client.RoomMediaGuardClient)
urlVerifier, _ := any(config.ObjectUploader).(objectURLVerifier)
return &Handler{ return &Handler{
roomClient: config.RoomClient, roomClient: config.RoomClient,
roomGuardClient: config.RoomGuardClient, roomGuardClient: config.RoomGuardClient,
roomQueryClient: config.RoomQueryClient, roomQueryClient: config.RoomQueryClient,
roomVIPClient: roomVIPClient,
roomMediaGuard: roomMediaGuard,
objectUploader: config.ObjectUploader,
objectURLVerifier: urlVerifier,
userProfileClient: config.UserProfileClient, userProfileClient: config.UserProfileClient,
userCountryClient: config.UserCountryClient, userCountryClient: config.UserCountryClient,
userRegionClient: config.UserRegionClient, userRegionClient: config.UserRegionClient,
@ -131,6 +154,10 @@ func (h *Handler) RoomHandlers() httproutes.RoomHandlers {
SaveRoomBackground: h.saveRoomBackground, SaveRoomBackground: h.saveRoomBackground,
ListRoomBackgrounds: h.listRoomBackgrounds, ListRoomBackgrounds: h.listRoomBackgrounds,
SetRoomBackground: h.setRoomBackground, SetRoomBackground: h.setRoomBackground,
UploadRoomBackground: h.uploadRoomBackground,
UploadRoomImage: h.uploadRoomImage,
ApplyRoomDecoration: h.applyRoomDecoration,
SendRoomImage: h.sendRoomImage,
JoinRoom: h.joinRoom, JoinRoom: h.joinRoom,
RoomHeartbeat: h.roomHeartbeat, RoomHeartbeat: h.roomHeartbeat,
LeaveRoom: h.leaveRoom, LeaveRoom: h.leaveRoom,
@ -161,3 +188,7 @@ func (h *Handler) IssueTencentRTCToken(writer http.ResponseWriter, request *http
func (h *Handler) BatchRoomDisplayProfiles(writer http.ResponseWriter, request *http.Request) { func (h *Handler) BatchRoomDisplayProfiles(writer http.ResponseWriter, request *http.Request) {
h.batchRoomDisplayProfiles(writer, request) h.batchRoomDisplayProfiles(writer, request)
} }
func (h *Handler) BatchUserVoiceRoomPresences(writer http.ResponseWriter, request *http.Request) {
h.batchUserVoiceRoomPresences(writer, request)
}

View File

@ -17,6 +17,7 @@ import (
"hyapp/pkg/roomid" "hyapp/pkg/roomid"
"hyapp/pkg/xerr" "hyapp/pkg/xerr"
"hyapp/services/gateway-service/internal/auth" "hyapp/services/gateway-service/internal/auth"
"hyapp/services/gateway-service/internal/transport/http/resourceview"
"google.golang.org/grpc/codes" "google.golang.org/grpc/codes"
"google.golang.org/grpc/status" "google.golang.org/grpc/status"
@ -1233,24 +1234,44 @@ func (h *Handler) updateRoomProfile(writer http.ResponseWriter, request *http.Re
// saveRoomBackground 保存房主上传后的背景图 URL素材归 room-service 按房间维度管理。 // saveRoomBackground 保存房主上传后的背景图 URL素材归 room-service 按房间维度管理。
func (h *Handler) saveRoomBackground(writer http.ResponseWriter, request *http.Request) { func (h *Handler) saveRoomBackground(writer http.ResponseWriter, request *http.Request) {
var body struct { var body struct {
RoomID string `json:"room_id"` RoomID string `json:"room_id"`
ImageURL string `json:"image_url"` CommandID string `json:"command_id"`
ImageURL string `json:"image_url"`
Media *roomMediaData `json:"media"`
} }
if !httpkit.Decode(writer, request, &body) { if !httpkit.Decode(writer, request, &body) {
return return
} }
body.RoomID = strings.TrimSpace(body.RoomID) body.RoomID = strings.TrimSpace(body.RoomID)
body.CommandID = strings.TrimSpace(body.CommandID)
body.ImageURL = strings.TrimSpace(body.ImageURL) body.ImageURL = strings.TrimSpace(body.ImageURL)
if body.Media != nil && body.ImageURL == "" {
body.ImageURL = strings.TrimSpace(body.Media.URL)
}
if body.Media != nil && body.ImageURL != strings.TrimSpace(body.Media.URL) {
// 新版请求的 image_url 只是 media.url 的兼容镜像;两者不一致时拒绝,避免调用方误以为保存了另一张图。
httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "image_url does not match media.url")
return
}
if !roomid.ValidStringID(body.RoomID) || body.ImageURL == "" { if !roomid.ValidStringID(body.RoomID) || body.ImageURL == "" {
httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument") httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
return return
} }
var media *roomv1.RoomMedia
if body.Media != nil {
if h.objectURLVerifier == nil || !h.objectURLVerifier.OwnsObjectURL(body.Media.URL, body.Media.ObjectKey) {
httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid room background media")
return
}
media = body.Media.proto()
}
resp, err := h.roomClient.SaveRoomBackground(request.Context(), &roomv1.SaveRoomBackgroundRequest{ resp, err := h.roomClient.SaveRoomBackground(request.Context(), &roomv1.SaveRoomBackgroundRequest{
Meta: httpkit.RoomMeta(request, body.RoomID, ""), Meta: httpkit.RoomMeta(request, body.RoomID, body.CommandID),
RoomId: body.RoomID, RoomId: body.RoomID,
ImageUrl: body.ImageURL, ImageUrl: body.ImageURL,
Media: media,
}) })
httpkit.Write(writer, request, saveRoomBackgroundDataFromProto(resp), err) httpkit.Write(writer, request, saveRoomBackgroundDataFromProto(resp), err)
} }
@ -2824,6 +2845,11 @@ func roomAppearanceResourceFromProto(item *walletv1.Resource, entitlementID stri
"entitlement_id": entitlementID, "entitlement_id": entitlementID,
"expires_at_ms": expiresAtMS, "expires_at_ms": expiresAtMS,
} }
if format := resourceview.FormatFromMetadata(item.GetMetadataJson()); format != "" {
// 房间首屏/profile 是其他用户渲染麦位动效的权威快照,因此必须投影显式格式;
// 缺省时不猜 animation_url 后缀,避免 CDN 地址变化导致客户端选择错误 renderer。
resource["format"] = format
}
if item.GetResourceType() == "badge" { if item.GetResourceType() == "badge" {
badgeForm, badgeKind, levelTrack := roomBadgeMetadataFields(item.GetMetadataJson()) badgeForm, badgeKind, levelTrack := roomBadgeMetadataFields(item.GetMetadataJson())
resource["badge_form"] = badgeForm resource["badge_form"] = badgeForm

View File

@ -0,0 +1,564 @@
package roomapi
import (
"bytes"
"crypto/sha256"
"encoding/binary"
"encoding/hex"
"fmt"
"image/jpeg"
"image/png"
"io"
"mime"
"net/http"
"path/filepath"
"strings"
roomv1 "hyapp.local/api/proto/room/v1"
"hyapp/pkg/giftlimits"
"hyapp/pkg/roomid"
"hyapp/services/gateway-service/internal/transport/http/httpkit"
)
const (
roomBackgroundUploadMaxBytes = int64(12 << 20)
roomImageUploadMaxBytes = int64(8 << 20)
roomUploadEnvelopeOverhead = int64(1 << 20)
roomUploadMaxFrames = int32(120)
roomUploadMaxDurationMS = int64(15_000)
)
type roomMediaData struct {
URL string `json:"url"`
ObjectKey string `json:"object_key"`
ContentType string `json:"content_type"`
Format string `json:"format"`
SizeBytes int64 `json:"size_bytes"`
Width int32 `json:"width"`
Height int32 `json:"height"`
Animated bool `json:"animated"`
FrameCount int32 `json:"frame_count"`
DurationMS int64 `json:"duration_ms"`
PosterURL string `json:"poster_url,omitempty"`
ThumbnailURL string `json:"thumbnail_url,omitempty"`
SHA256 string `json:"sha256"`
Status string `json:"status"`
}
type roomMediaUploadData struct {
Purpose string `json:"purpose"`
RoomID string `json:"room_id"`
CommandID string `json:"command_id"`
RoomVersion int64 `json:"room_version"`
Media roomMediaData `json:"media"`
ServerTimeMS int64 `json:"server_time_ms"`
}
func (data roomMediaData) proto() *roomv1.RoomMedia {
return &roomv1.RoomMedia{
Url: strings.TrimSpace(data.URL), ObjectKey: strings.TrimLeft(strings.TrimSpace(data.ObjectKey), "/"),
ContentType: strings.ToLower(strings.TrimSpace(data.ContentType)), Format: strings.ToLower(strings.TrimSpace(data.Format)),
SizeBytes: data.SizeBytes, Width: data.Width, Height: data.Height, Animated: data.Animated,
FrameCount: data.FrameCount, DurationMs: data.DurationMS, PosterUrl: strings.TrimSpace(data.PosterURL),
ThumbnailUrl: strings.TrimSpace(data.ThumbnailURL), Sha256: strings.ToLower(strings.TrimSpace(data.SHA256)),
Status: strings.ToLower(strings.TrimSpace(data.Status)),
}
}
func roomMediaDataFromProto(media *roomv1.RoomMedia) *roomMediaData {
if media == nil || (strings.TrimSpace(media.GetUrl()) == "" && strings.TrimSpace(media.GetObjectKey()) == "") {
return nil
}
return &roomMediaData{
URL: media.GetUrl(), ObjectKey: media.GetObjectKey(), ContentType: media.GetContentType(), Format: media.GetFormat(),
SizeBytes: media.GetSizeBytes(), Width: media.GetWidth(), Height: media.GetHeight(), Animated: media.GetAnimated(),
FrameCount: media.GetFrameCount(), DurationMS: media.GetDurationMs(), PosterURL: media.GetPosterUrl(),
ThumbnailURL: media.GetThumbnailUrl(), SHA256: media.GetSha256(), Status: media.GetStatus(),
}
}
func (h *Handler) uploadRoomBackground(writer http.ResponseWriter, request *http.Request) {
h.uploadRoomMedia(writer, request, "room_background", roomBackgroundUploadMaxBytes)
}
func (h *Handler) uploadRoomImage(writer http.ResponseWriter, request *http.Request) {
h.uploadRoomMedia(writer, request, "room_image", roomImageUploadMaxBytes)
}
func (h *Handler) uploadRoomMedia(writer http.ResponseWriter, request *http.Request, purpose string, maxBytes int64) {
roomID := strings.TrimSpace(request.PathValue("room_id"))
if !roomid.ValidStringID(roomID) {
httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "room_id is invalid")
return
}
if h.objectUploader == nil || h.objectURLVerifier == nil || h.roomMediaGuard == nil {
httpkit.WriteError(writer, request, http.StatusServiceUnavailable, httpkit.CodeUpstreamError, "room media upload is unavailable")
return
}
request.Body = http.MaxBytesReader(writer, request.Body, maxBytes+roomUploadEnvelopeOverhead)
if err := request.ParseMultipartForm(maxBytes + roomUploadEnvelopeOverhead); err != nil {
httpkit.WriteError(writer, request, http.StatusRequestEntityTooLarge, "FILE_TOO_LARGE", "file exceeds upload limit")
return
}
if request.MultipartForm != nil {
defer request.MultipartForm.RemoveAll()
}
commandID := strings.TrimSpace(request.FormValue("command_id"))
if !giftlimits.ValidCommandID(commandID) {
httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "command_id is invalid")
return
}
file, header, err := request.FormFile("file")
if err != nil {
httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "file is required")
return
}
defer file.Close()
content, err := io.ReadAll(io.LimitReader(file, maxBytes+1))
if err != nil || int64(len(content)) == 0 {
httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "file is invalid")
return
}
if int64(len(content)) > maxBytes {
httpkit.WriteError(writer, request, http.StatusRequestEntityTooLarge, "FILE_TOO_LARGE", "file exceeds upload limit")
return
}
media, _, err := inspectRoomImage(content, header.Filename, header.Header.Get("Content-Type"))
if err != nil {
httpkit.WriteError(writer, request, http.StatusUnsupportedMediaType, "UNSUPPORTED_MEDIA_TYPE", err.Error())
return
}
if err := validateRoomUploadDimensions(purpose, media); err != nil {
httpkit.WriteError(writer, request, http.StatusBadRequest, "INVALID_MEDIA_DIMENSIONS", err.Error())
return
}
authorization, err := h.roomMediaGuard.AuthorizeRoomMediaUpload(request.Context(), &roomv1.AuthorizeRoomMediaUploadRequest{
Meta: httpkit.RoomMeta(request, roomID, commandID), Purpose: purpose, Media: media.proto(),
})
if err != nil {
httpkit.WriteRPCError(writer, request, err)
return
}
if authorization == nil || !authorization.GetAllowed() || strings.TrimSpace(authorization.GetObjectKeyPrefix()) == "" ||
strings.TrimSpace(authorization.GetObjectKey()) == "" {
httpkit.WriteError(writer, request, http.StatusForbidden, httpkit.CodePermissionDenied, "room media upload denied")
return
}
objectKey := strings.TrimLeft(strings.TrimSpace(authorization.GetObjectKey()), "/")
objectURL, err := h.objectUploader.PutObject(request.Context(), objectKey, bytes.NewReader(content), int64(len(content)), media.ContentType)
if err != nil {
httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "object upload failed")
return
}
if !h.objectURLVerifier.OwnsObjectURL(objectURL, objectKey) {
// 上传成功但返回地址不属于配置 access_url 时拒绝下发,避免把不可信 URL 固化进房间状态。
httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "object upload url verification failed")
return
}
media.URL, media.ObjectKey, media.Status = objectURL, objectKey, "active"
completed, err := h.roomMediaGuard.CompleteRoomMediaUpload(request.Context(), &roomv1.CompleteRoomMediaUploadRequest{
Meta: httpkit.RoomMeta(request, roomID, commandID), Purpose: purpose, Media: media.proto(),
})
if err != nil {
httpkit.WriteRPCError(writer, request, err)
return
}
if completed == nil || !completed.GetActive() {
httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "room media upload completion failed")
return
}
completedMedia := roomMediaDataFromProto(completed.GetMedia())
if completedMedia == nil {
httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "room media upload completion failed")
return
}
media = *completedMedia
httpkit.WriteOK(writer, request, roomMediaUploadData{
Purpose: purpose, RoomID: roomID, CommandID: commandID, RoomVersion: authorization.GetRoomVersion(),
Media: media, ServerTimeMS: completed.GetServerTimeMs(),
})
}
func inspectRoomImage(content []byte, filename string, partContentType string) (roomMediaData, string, error) {
var data roomMediaData
var extension string
switch {
case len(content) >= 3 && content[0] == 0xff && content[1] == 0xd8 && content[2] == 0xff:
cfg, err := jpeg.DecodeConfig(bytes.NewReader(content))
if err != nil {
return data, "", fmt.Errorf("invalid jpeg")
}
data.Format, data.ContentType, data.Width, data.Height, data.FrameCount = "jpeg", "image/jpeg", int32(cfg.Width), int32(cfg.Height), 1
extension = "jpg"
case len(content) >= 8 && bytes.Equal(content[:8], []byte{137, 80, 78, 71, 13, 10, 26, 10}):
if pngHasAnimationChunk(content) {
return data, "", fmt.Errorf("animated png is not supported")
}
cfg, err := png.DecodeConfig(bytes.NewReader(content))
if err != nil {
return data, "", fmt.Errorf("invalid png")
}
data.Format, data.ContentType, data.Width, data.Height, data.FrameCount = "png", "image/png", int32(cfg.Width), int32(cfg.Height), 1
extension = "png"
case len(content) >= 6 && (string(content[:6]) == "GIF87a" || string(content[:6]) == "GIF89a"):
width, height, frames, durationMS, err := inspectGIF(content)
if err != nil {
return data, "", err
}
data.Format, data.ContentType, data.Width, data.Height = "gif", "image/gif", width, height
data.FrameCount, data.Animated, data.DurationMS = frames, frames > 1, durationMS
if !data.Animated {
data.DurationMS = 0
}
extension = "gif"
case len(content) >= 12 && string(content[:4]) == "RIFF" && string(content[8:12]) == "WEBP":
width, height, frames, durationMS, animated, err := inspectWebP(content)
if err != nil {
return data, "", err
}
data.Format, data.ContentType, data.Width, data.Height = "webp", "image/webp", width, height
data.FrameCount, data.DurationMS, data.Animated = frames, durationMS, animated
extension = "webp"
default:
return data, "", fmt.Errorf("only jpeg, png, gif and webp are supported")
}
declared, _, _ := mime.ParseMediaType(strings.TrimSpace(partContentType))
if !strings.EqualFold(declared, data.ContentType) {
return roomMediaData{}, "", fmt.Errorf("file content_type does not match signature")
}
fileExtension := strings.ToLower(strings.TrimPrefix(filepath.Ext(filename), "."))
validExtension := fileExtension == extension || (data.Format == "jpeg" && fileExtension == "jpeg")
if !validExtension {
return roomMediaData{}, "", fmt.Errorf("filename extension does not match signature")
}
sum := sha256.Sum256(content)
data.SizeBytes, data.SHA256, data.Status = int64(len(content)), hex.EncodeToString(sum[:]), "active"
return data, extension, nil
}
func validateRoomUploadDimensions(purpose string, media roomMediaData) error {
if media.Width <= 0 || media.Height <= 0 || media.FrameCount <= 0 {
return fmt.Errorf("image dimensions are invalid")
}
if media.Animated && (media.FrameCount > roomUploadMaxFrames || media.DurationMS <= 0 || media.DurationMS > roomUploadMaxDurationMS) {
return fmt.Errorf("animation exceeds frame or duration limit")
}
pixels := int64(media.Width) * int64(media.Height)
if purpose == "room_background" {
if media.Width > 2160 || media.Height > 3840 || pixels > 8_294_400 {
return fmt.Errorf("room background dimensions exceed limit")
}
return nil
}
if media.Width > 4096 || media.Height > 4096 || pixels > 16_777_216 {
return fmt.Errorf("room image dimensions exceed limit")
}
return nil
}
func pngHasAnimationChunk(content []byte) bool {
for offset := 8; offset+12 <= len(content); {
size := int(binary.BigEndian.Uint32(content[offset : offset+4]))
if size < 0 || offset+12+size > len(content) {
return false
}
if string(content[offset+4:offset+8]) == "acTL" {
return true
}
offset += 12 + size
}
return false
}
func inspectGIF(content []byte) (int32, int32, int32, int64, error) {
if len(content) < 13 || (string(content[:6]) != "GIF87a" && string(content[:6]) != "GIF89a") {
return 0, 0, 0, 0, fmt.Errorf("invalid gif")
}
width := int32(binary.LittleEndian.Uint16(content[6:8]))
height := int32(binary.LittleEndian.Uint16(content[8:10]))
if width <= 0 || height <= 0 || width > 4096 || height > 4096 || int64(width)*int64(height) > 16_777_216 {
// 先检查逻辑画布,后续只扫描块边界,不按攻击者声明的尺寸分配像素缓冲区。
return 0, 0, 0, 0, fmt.Errorf("gif dimensions exceed limit")
}
offset := 13
if content[10]&0x80 != 0 {
colorTableBytes := 3 * (1 << (int(content[10]&0x07) + 1))
if offset+colorTableBytes > len(content) {
return 0, 0, 0, 0, fmt.Errorf("invalid gif color table")
}
offset += colorTableBytes
}
var frames int32
var durationMS int64
var pendingDelayMS int64
for offset < len(content) {
marker := content[offset]
offset++
switch marker {
case 0x3b: // trailer
if frames <= 0 {
return 0, 0, 0, 0, fmt.Errorf("gif has no image frame")
}
if frames == 1 {
durationMS = 0
}
return width, height, frames, durationMS, nil
case 0x21: // extension
if offset >= len(content) {
return 0, 0, 0, 0, fmt.Errorf("invalid gif extension")
}
label := content[offset]
offset++
if label == 0xf9 {
// Graphic Control Extension 固定为 4 字节数据和 0 终止符delay 单位是 1/100 秒。
if offset+6 > len(content) || content[offset] != 4 || content[offset+5] != 0 {
return 0, 0, 0, 0, fmt.Errorf("invalid gif graphic control extension")
}
pendingDelayMS = int64(binary.LittleEndian.Uint16(content[offset+2:offset+4])) * 10
offset += 6
continue
}
var skipErr error
offset, _, skipErr = skipGIFSubBlocks(content, offset)
if skipErr != nil {
return 0, 0, 0, 0, skipErr
}
case 0x2c: // image descriptor
if offset+9 > len(content) {
return 0, 0, 0, 0, fmt.Errorf("invalid gif image descriptor")
}
left := int(binary.LittleEndian.Uint16(content[offset : offset+2]))
top := int(binary.LittleEndian.Uint16(content[offset+2 : offset+4]))
frameWidth := int(binary.LittleEndian.Uint16(content[offset+4 : offset+6]))
frameHeight := int(binary.LittleEndian.Uint16(content[offset+6 : offset+8]))
packed := content[offset+8]
if frameWidth <= 0 || frameHeight <= 0 || left+frameWidth > int(width) || top+frameHeight > int(height) {
return 0, 0, 0, 0, fmt.Errorf("invalid gif frame dimensions")
}
offset += 9
if packed&0x80 != 0 {
colorTableBytes := 3 * (1 << (int(packed&0x07) + 1))
if offset+colorTableBytes > len(content) {
return 0, 0, 0, 0, fmt.Errorf("invalid gif local color table")
}
offset += colorTableBytes
}
if offset >= len(content) || content[offset] < 2 || content[offset] > 8 {
return 0, 0, 0, 0, fmt.Errorf("invalid gif lzw code size")
}
offset++
var hasImageData bool
var skipErr error
offset, hasImageData, skipErr = skipGIFSubBlocks(content, offset)
if skipErr != nil || !hasImageData {
return 0, 0, 0, 0, fmt.Errorf("invalid gif image data")
}
frames++
durationMS += pendingDelayMS
pendingDelayMS = 0
if frames > roomUploadMaxFrames || durationMS > roomUploadMaxDurationMS {
return 0, 0, 0, 0, fmt.Errorf("gif animation exceeds frame or duration limit")
}
default:
return 0, 0, 0, 0, fmt.Errorf("invalid gif block marker")
}
}
return 0, 0, 0, 0, fmt.Errorf("gif trailer is missing")
}
func skipGIFSubBlocks(content []byte, offset int) (int, bool, error) {
hasData := false
for {
if offset >= len(content) {
return 0, false, fmt.Errorf("invalid gif sub-block")
}
blockSize := int(content[offset])
offset++
if blockSize == 0 {
return offset, hasData, nil
}
if offset+blockSize > len(content) {
return 0, false, fmt.Errorf("invalid gif sub-block")
}
hasData = true
offset += blockSize
}
}
func inspectWebP(content []byte) (int32, int32, int32, int64, bool, error) {
if len(content) < 20 || string(content[:4]) != "RIFF" || string(content[8:12]) != "WEBP" {
return 0, 0, 0, 0, false, fmt.Errorf("invalid webp header")
}
declaredSize := uint64(binary.LittleEndian.Uint32(content[4:8])) + 8
if declaredSize != uint64(len(content)) {
// RIFF size 不匹配通常表示截断或尾部拼接;禁止只扫到某个看似合法的前缀就接受整个对象。
return 0, 0, 0, 0, false, fmt.Errorf("invalid webp riff size")
}
var width, height int32
var frames int32
var durationMS int64
var animatedFlag, hasVP8X, hasANIM, hasStaticImage bool
for offset := 12; offset < len(content); {
if offset+8 > len(content) {
return 0, 0, 0, 0, false, fmt.Errorf("invalid webp trailing chunk")
}
chunkType := string(content[offset : offset+4])
size := uint64(binary.LittleEndian.Uint32(content[offset+4 : offset+8]))
start := uint64(offset + 8)
end := start + size
paddedEnd := end + size%2
if end < start || paddedEnd > uint64(len(content)) {
return 0, 0, 0, 0, false, fmt.Errorf("invalid webp chunk")
}
if size%2 == 1 && content[end] != 0 {
return 0, 0, 0, 0, false, fmt.Errorf("invalid webp chunk padding")
}
chunk := content[int(start):int(end)]
switch chunkType {
case "VP8X":
if hasVP8X || offset != 12 || len(chunk) != 10 || chunk[0]&0xc1 != 0 || chunk[1] != 0 || chunk[2] != 0 || chunk[3] != 0 {
return 0, 0, 0, 0, false, fmt.Errorf("invalid webp header")
}
hasVP8X = true
animatedFlag = chunk[0]&0x02 != 0
width = int32(readUint24LE(chunk[4:7])) + 1
height = int32(readUint24LE(chunk[7:10])) + 1
case "VP8 ", "VP8L":
if hasStaticImage {
return 0, 0, 0, 0, false, fmt.Errorf("webp has multiple top-level image payloads")
}
if !hasVP8X && offset != 12 {
return 0, 0, 0, 0, false, fmt.Errorf("invalid simple webp chunk order")
}
imageWidth, imageHeight, err := inspectWebPImagePayload(chunkType, chunk)
if err != nil {
return 0, 0, 0, 0, false, err
}
hasStaticImage = true
if width == 0 {
width, height = imageWidth, imageHeight
} else if imageWidth > width || imageHeight > height {
return 0, 0, 0, 0, false, fmt.Errorf("webp image exceeds canvas")
}
case "ANIM":
if !hasVP8X || !animatedFlag || hasANIM || len(chunk) != 6 {
return 0, 0, 0, 0, false, fmt.Errorf("invalid webp animation header")
}
hasANIM = true
case "ANMF":
if !hasVP8X || !animatedFlag || !hasANIM || width <= 0 || height <= 0 || len(chunk) < 16 || chunk[15]&0xfc != 0 {
return 0, 0, 0, 0, false, fmt.Errorf("invalid webp animation frame")
}
frameX := int64(readUint24LE(chunk[0:3])) * 2
frameY := int64(readUint24LE(chunk[3:6])) * 2
frameWidth := int32(readUint24LE(chunk[6:9])) + 1
frameHeight := int32(readUint24LE(chunk[9:12])) + 1
if frameWidth <= 0 || frameHeight <= 0 || frameX+int64(frameWidth) > int64(width) || frameY+int64(frameHeight) > int64(height) {
return 0, 0, 0, 0, false, fmt.Errorf("invalid webp animation frame geometry")
}
if err := inspectWebPFramePayload(chunk[16:], frameWidth, frameHeight); err != nil {
return 0, 0, 0, 0, false, err
}
frames++
durationMS += int64(readUint24LE(chunk[12:15]))
if frames > roomUploadMaxFrames || durationMS > roomUploadMaxDurationMS {
return 0, 0, 0, 0, false, fmt.Errorf("webp animation exceeds frame or duration limit")
}
}
offset = int(paddedEnd)
}
if width <= 0 || height <= 0 {
return 0, 0, 0, 0, false, fmt.Errorf("invalid webp dimensions")
}
if animatedFlag {
if !hasANIM || hasStaticImage || frames < 2 || durationMS <= 0 {
return 0, 0, 0, 0, false, fmt.Errorf("invalid animated webp")
}
return width, height, frames, durationMS, true, nil
}
if hasANIM || frames != 0 || !hasStaticImage {
return 0, 0, 0, 0, false, fmt.Errorf("invalid static webp")
}
return width, height, 1, 0, false, nil
}
func inspectWebPImagePayload(chunkType string, chunk []byte) (int32, int32, error) {
switch chunkType {
case "VP8 ":
// 10 字节只覆盖 lossy key-frame header至少再有一个压缩 payload 字节,防止伪造尺寸头通过。
if len(chunk) <= 10 || chunk[0]&0x01 != 0 || !bytes.Equal(chunk[3:6], []byte{0x9d, 0x01, 0x2a}) {
return 0, 0, fmt.Errorf("invalid webp vp8 payload")
}
width := int32(binary.LittleEndian.Uint16(chunk[6:8]) & 0x3fff)
height := int32(binary.LittleEndian.Uint16(chunk[8:10]) & 0x3fff)
if width <= 0 || height <= 0 {
return 0, 0, fmt.Errorf("invalid webp vp8 dimensions")
}
return width, height, nil
case "VP8L":
// VP8L signature+尺寸头为 5 字节version 位必须为 0并要求存在实际压缩 payload。
if len(chunk) <= 5 || chunk[0] != 0x2f {
return 0, 0, fmt.Errorf("invalid webp vp8l payload")
}
bits := binary.LittleEndian.Uint32(chunk[1:5])
if bits>>29 != 0 {
return 0, 0, fmt.Errorf("invalid webp vp8l version")
}
return int32(bits&0x3fff) + 1, int32((bits>>14)&0x3fff) + 1, nil
default:
return 0, 0, fmt.Errorf("unsupported webp image payload")
}
}
func inspectWebPFramePayload(content []byte, expectedWidth int32, expectedHeight int32) error {
if len(content) < 8 {
return fmt.Errorf("webp animation frame has no image payload")
}
foundImage := false
for offset := 0; offset < len(content); {
if offset+8 > len(content) {
return fmt.Errorf("invalid webp animation frame chunk")
}
chunkType := string(content[offset : offset+4])
size := uint64(binary.LittleEndian.Uint32(content[offset+4 : offset+8]))
start := uint64(offset + 8)
end := start + size
paddedEnd := end + size%2
if end < start || paddedEnd > uint64(len(content)) {
return fmt.Errorf("invalid webp animation frame chunk")
}
if size%2 == 1 && content[end] != 0 {
return fmt.Errorf("invalid webp animation frame padding")
}
chunk := content[int(start):int(end)]
switch chunkType {
case "ALPH":
if foundImage || len(chunk) == 0 {
return fmt.Errorf("invalid webp animation alpha payload")
}
case "VP8 ", "VP8L":
if foundImage {
return fmt.Errorf("webp animation frame has multiple image payloads")
}
width, height, err := inspectWebPImagePayload(chunkType, chunk)
if err != nil || width != expectedWidth || height != expectedHeight {
return fmt.Errorf("webp animation image dimensions mismatch")
}
foundImage = true
default:
return fmt.Errorf("unsupported webp animation frame chunk")
}
offset = int(paddedEnd)
}
if !foundImage {
return fmt.Errorf("webp animation frame has no image payload")
}
return nil
}
func readUint24LE(value []byte) uint32 {
return uint32(value[0]) | uint32(value[1])<<8 | uint32(value[2])<<16
}

View File

@ -35,23 +35,25 @@ type myRoomData struct {
} }
type roomListItemData struct { type roomListItemData struct {
RoomID string `json:"room_id"` RoomID string `json:"room_id"`
IMGroupID string `json:"im_group_id"` IMGroupID string `json:"im_group_id"`
OwnerUserID string `json:"owner_user_id,omitempty"` OwnerUserID string `json:"owner_user_id,omitempty"`
Title string `json:"title,omitempty"` Title string `json:"title,omitempty"`
CoverURL string `json:"cover_url,omitempty"` CoverURL string `json:"cover_url,omitempty"`
Mode string `json:"mode,omitempty"` Mode string `json:"mode,omitempty"`
Status string `json:"status,omitempty"` Status string `json:"status,omitempty"`
Locked bool `json:"locked"` Locked bool `json:"locked"`
Heat int64 `json:"heat"` Heat int64 `json:"heat"`
OnlineCount int32 `json:"online_count"` OnlineCount int32 `json:"online_count"`
SeatCount int32 `json:"seat_count"` SeatCount int32 `json:"seat_count"`
OccupiedSeatCount int32 `json:"occupied_seat_count"` OccupiedSeatCount int32 `json:"occupied_seat_count"`
VisibleRegionID int64 `json:"visible_region_id,omitempty"` VisibleRegionID int64 `json:"visible_region_id,omitempty"`
AppCode string `json:"app_code,omitempty"` AppCode string `json:"app_code,omitempty"`
RoomShortID string `json:"room_short_id,omitempty"` RoomShortID string `json:"room_short_id,omitempty"`
CountryCode string `json:"country_code,omitempty"` CountryCode string `json:"country_code,omitempty"`
CountryFlag string `json:"country_flag,omitempty"` CountryFlag string `json:"country_flag,omitempty"`
RoomBorder *roomDecorationData `json:"room_border,omitempty"`
RoomNameStyle *roomDecorationData `json:"room_name_style,omitempty"`
} }
type createRoomData struct { type createRoomData struct {
@ -378,12 +380,14 @@ type updateRoomProfileData struct {
} }
type roomBackgroundData struct { type roomBackgroundData struct {
BackgroundID int64 `json:"background_id"` BackgroundID int64 `json:"background_id"`
RoomID string `json:"room_id"` RoomID string `json:"room_id"`
ImageURL string `json:"image_url"` ImageURL string `json:"image_url"`
CreatedByUserID string `json:"created_by_user_id,omitempty"` CreatedByUserID string `json:"created_by_user_id,omitempty"`
CreatedAtMS int64 `json:"created_at_ms"` CreatedAtMS int64 `json:"created_at_ms"`
UpdatedAtMS int64 `json:"updated_at_ms"` UpdatedAtMS int64 `json:"updated_at_ms"`
CommandID string `json:"command_id,omitempty"`
Media *roomMediaData `json:"media,omitempty"`
} }
type saveRoomBackgroundData struct { type saveRoomBackgroundData struct {
@ -394,6 +398,7 @@ type saveRoomBackgroundData struct {
type listRoomBackgroundsData struct { type listRoomBackgroundsData struct {
Backgrounds []roomBackgroundData `json:"backgrounds"` Backgrounds []roomBackgroundData `json:"backgrounds"`
SelectedBackgroundURL string `json:"selected_background_url,omitempty"` SelectedBackgroundURL string `json:"selected_background_url,omitempty"`
SelectedBackground *roomBackgroundData `json:"selected_background,omitempty"`
ServerTimeMS int64 `json:"server_time_ms"` ServerTimeMS int64 `json:"server_time_ms"`
} }
@ -426,24 +431,27 @@ type roomCommandResultData struct {
} }
type roomInitialData struct { type roomInitialData struct {
RoomID string `json:"room_id"` RoomID string `json:"room_id"`
IMGroupID string `json:"im_group_id"` IMGroupID string `json:"im_group_id"`
RoomShortID string `json:"room_short_id,omitempty"` RoomShortID string `json:"room_short_id,omitempty"`
Title string `json:"title,omitempty"` Title string `json:"title,omitempty"`
CoverURL string `json:"cover_url,omitempty"` CoverURL string `json:"cover_url,omitempty"`
BackgroundURL string `json:"background_url,omitempty"` BackgroundURL string `json:"background_url,omitempty"`
Description string `json:"description,omitempty"` Description string `json:"description,omitempty"`
OwnerUserID string `json:"owner_user_id,omitempty"` OwnerUserID string `json:"owner_user_id,omitempty"`
Mode string `json:"mode,omitempty"` Mode string `json:"mode,omitempty"`
Status string `json:"status,omitempty"` Status string `json:"status,omitempty"`
ChatEnabled bool `json:"chat_enabled"` ChatEnabled bool `json:"chat_enabled"`
Locked bool `json:"locked"` Locked bool `json:"locked"`
Heat int64 `json:"heat"` Heat int64 `json:"heat"`
OnlineCount int32 `json:"online_count"` OnlineCount int32 `json:"online_count"`
SeatCount int32 `json:"seat_count"` SeatCount int32 `json:"seat_count"`
OccupiedSeatCount int32 `json:"occupied_seat_count"` OccupiedSeatCount int32 `json:"occupied_seat_count"`
VisibleRegionID int64 `json:"visible_region_id,omitempty"` VisibleRegionID int64 `json:"visible_region_id,omitempty"`
Version int64 `json:"version"` Version int64 `json:"version"`
ActiveBackground *roomBackgroundData `json:"active_background,omitempty"`
RoomBorder *roomDecorationData `json:"room_border,omitempty"`
RoomNameStyle *roomDecorationData `json:"room_name_style,omitempty"`
} }
type roomViewerData struct { type roomViewerData struct {
@ -744,6 +752,8 @@ func roomListItemDataFromProto(room *roomv1.RoomListItem) roomListItemData {
RoomShortID: room.GetRoomShortId(), RoomShortID: room.GetRoomShortId(),
CountryCode: room.GetCountryCode(), CountryCode: room.GetCountryCode(),
CountryFlag: roomProfileCountryFlag(room.GetCountryCode()), CountryFlag: roomProfileCountryFlag(room.GetCountryCode()),
RoomBorder: roomDecorationDataFromProto(room.GetRoomBorder()),
RoomNameStyle: roomDecorationDataFromProto(room.GetRoomNameStyle()),
} }
} }
@ -792,6 +802,7 @@ func listRoomBackgroundsDataFromProto(resp *roomv1.ListRoomBackgroundsResponse)
return listRoomBackgroundsData{ return listRoomBackgroundsData{
Backgrounds: backgrounds, Backgrounds: backgrounds,
SelectedBackgroundURL: resp.GetSelectedBackgroundUrl(), SelectedBackgroundURL: resp.GetSelectedBackgroundUrl(),
SelectedBackground: roomBackgroundDataPointerFromProto(resp.GetSelectedBackground()),
ServerTimeMS: resp.GetServerTimeMs(), ServerTimeMS: resp.GetServerTimeMs(),
} }
} }
@ -820,9 +831,19 @@ func roomBackgroundDataFromProto(background *roomv1.RoomBackgroundImage) roomBac
CreatedByUserID: formatOptionalUserID(background.GetCreatedByUserId()), CreatedByUserID: formatOptionalUserID(background.GetCreatedByUserId()),
CreatedAtMS: background.GetCreatedAtMs(), CreatedAtMS: background.GetCreatedAtMs(),
UpdatedAtMS: background.GetUpdatedAtMs(), UpdatedAtMS: background.GetUpdatedAtMs(),
CommandID: background.GetCommandId(),
Media: roomMediaDataFromProto(background.GetMedia()),
} }
} }
func roomBackgroundDataPointerFromProto(background *roomv1.RoomBackgroundImage) *roomBackgroundData {
if background == nil || background.GetBackgroundId() <= 0 {
return nil
}
data := roomBackgroundDataFromProto(background)
return &data
}
func roomSnapshotDataFromProto(resp *roomv1.GetRoomSnapshotResponse) roomSnapshotData { func roomSnapshotDataFromProto(resp *roomv1.GetRoomSnapshotResponse) roomSnapshotData {
if resp == nil { if resp == nil {
return roomSnapshotData{} return roomSnapshotData{}
@ -874,6 +895,9 @@ func roomInitialRoomDataFromSnapshot(snapshot *roomv1.RoomSnapshot, roomID strin
OccupiedSeatCount: occupiedSeatCount, OccupiedSeatCount: occupiedSeatCount,
VisibleRegionID: snapshot.GetVisibleRegionId(), VisibleRegionID: snapshot.GetVisibleRegionId(),
Version: snapshot.GetVersion(), Version: snapshot.GetVersion(),
ActiveBackground: roomBackgroundDataPointerFromProto(snapshot.GetActiveBackground()),
RoomBorder: roomDecorationDataFromProto(snapshot.GetRoomBorder()),
RoomNameStyle: roomDecorationDataFromProto(snapshot.GetRoomNameStyle()),
} }
} }

View File

@ -0,0 +1,134 @@
package roomapi
import (
"net/http"
"strings"
roomv1 "hyapp.local/api/proto/room/v1"
"hyapp/pkg/giftlimits"
"hyapp/pkg/roomid"
"hyapp/services/gateway-service/internal/transport/http/httpkit"
)
type roomDecorationData struct {
ResourceID int64 `json:"resource_id"`
ResourceCode string `json:"resource_code"`
ResourceType string `json:"resource_type"`
Name string `json:"name"`
AssetURL string `json:"asset_url"`
PreviewURL string `json:"preview_url,omitempty"`
AnimationURL string `json:"animation_url,omitempty"`
Format string `json:"format,omitempty"`
MetadataJSON string `json:"metadata_json,omitempty"`
EntitlementID string `json:"entitlement_id"`
TextColors []string `json:"text_colors,omitempty"`
Stops []float64 `json:"stops,omitempty"`
AngleDegrees float64 `json:"angle_degrees,omitempty"`
ExpiresAtMS int64 `json:"expires_at_ms,omitempty"`
}
type applyRoomDecorationData struct {
Result roomCommandResultData `json:"result"`
Room roomInitialData `json:"room"`
DecorationType string `json:"decoration_type"`
Resource *roomDecorationData `json:"resource,omitempty"`
ServerTimeMS int64 `json:"server_time_ms"`
}
type sendRoomImageData struct {
Result roomCommandResultData `json:"result"`
Accepted bool `json:"accepted"`
MessageID string `json:"message_id"`
RoomID string `json:"room_id"`
SenderUserID string `json:"sender_user_id"`
ServerTimeMS int64 `json:"server_time_ms"`
Image *roomMediaData `json:"image,omitempty"`
}
func (h *Handler) applyRoomDecoration(writer http.ResponseWriter, request *http.Request) {
if h.roomVIPClient == nil {
httpkit.WriteError(writer, request, http.StatusServiceUnavailable, httpkit.CodeUpstreamError, "room vip feature is unavailable")
return
}
roomID := strings.TrimSpace(request.PathValue("room_id"))
var body struct {
CommandID string `json:"command_id"`
DecorationType string `json:"decoration_type"`
ResourceID flexibleInt64 `json:"resource_id"`
EntitlementID string `json:"entitlement_id"`
}
if !httpkit.Decode(writer, request, &body) {
return
}
body.CommandID = strings.TrimSpace(body.CommandID)
body.DecorationType = strings.ToLower(strings.TrimSpace(body.DecorationType))
body.EntitlementID = strings.TrimSpace(body.EntitlementID)
if !roomid.ValidStringID(roomID) || !giftlimits.ValidCommandID(body.CommandID) || int64(body.ResourceID) <= 0 {
httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
return
}
resp, err := h.roomVIPClient.ApplyRoomDecoration(request.Context(), &roomv1.ApplyRoomDecorationRequest{
Meta: httpkit.RoomMeta(request, roomID, body.CommandID), DecorationType: body.DecorationType,
ResourceId: int64(body.ResourceID), EntitlementId: body.EntitlementID,
})
httpkit.Write(writer, request, applyRoomDecorationDataFromProto(resp), err)
}
func (h *Handler) sendRoomImage(writer http.ResponseWriter, request *http.Request) {
if h.roomVIPClient == nil || h.objectURLVerifier == nil {
httpkit.WriteError(writer, request, http.StatusServiceUnavailable, httpkit.CodeUpstreamError, "room image message is unavailable")
return
}
roomID := strings.TrimSpace(request.PathValue("room_id"))
var body struct {
CommandID string `json:"command_id"`
Image roomMediaData `json:"image"`
}
if !httpkit.Decode(writer, request, &body) {
return
}
body.CommandID = strings.TrimSpace(body.CommandID)
if !roomid.ValidStringID(roomID) || !giftlimits.ValidCommandID(body.CommandID) ||
!h.objectURLVerifier.OwnsObjectURL(body.Image.URL, body.Image.ObjectKey) {
httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid room image")
return
}
resp, err := h.roomVIPClient.SendRoomImage(request.Context(), &roomv1.SendRoomImageRequest{
Meta: httpkit.RoomMeta(request, roomID, body.CommandID), Image: body.Image.proto(),
})
httpkit.Write(writer, request, sendRoomImageDataFromProto(resp), err)
}
func applyRoomDecorationDataFromProto(resp *roomv1.ApplyRoomDecorationResponse) applyRoomDecorationData {
if resp == nil {
return applyRoomDecorationData{}
}
return applyRoomDecorationData{
Result: commandResultDataFromProto(resp.GetResult()), Room: roomInitialRoomDataFromSnapshot(resp.GetRoom(), resp.GetRoom().GetRoomId()),
DecorationType: resp.GetDecorationType(), Resource: roomDecorationDataFromProto(resp.GetResource()), ServerTimeMS: resp.GetServerTimeMs(),
}
}
func sendRoomImageDataFromProto(resp *roomv1.SendRoomImageResponse) sendRoomImageData {
if resp == nil {
return sendRoomImageData{}
}
return sendRoomImageData{
Result: commandResultDataFromProto(resp.GetResult()), Accepted: resp.GetAccepted(), MessageID: resp.GetMessageId(),
RoomID: resp.GetRoomId(), SenderUserID: formatOptionalUserID(resp.GetSenderUserId()),
ServerTimeMS: resp.GetServerTimeMs(), Image: roomMediaDataFromProto(resp.GetImage()),
}
}
func roomDecorationDataFromProto(resource *roomv1.RoomDecorationResource) *roomDecorationData {
if resource == nil || resource.GetResourceId() <= 0 {
return nil
}
return &roomDecorationData{
ResourceID: resource.GetResourceId(), ResourceCode: resource.GetResourceCode(), ResourceType: resource.GetResourceType(),
Name: resource.GetName(), AssetURL: resource.GetAssetUrl(), PreviewURL: resource.GetPreviewUrl(), AnimationURL: resource.GetAnimationUrl(),
Format: resource.GetFormat(), MetadataJSON: resource.GetMetadataJson(), EntitlementID: resource.GetEntitlementId(),
TextColors: append([]string(nil), resource.GetTextColors()...), Stops: append([]float64(nil), resource.GetStops()...),
AngleDegrees: resource.GetAngleDegrees(), ExpiresAtMS: resource.GetExpiresAtMs(),
}
}

View File

@ -0,0 +1,146 @@
package roomapi
import (
"context"
"net/http"
"strconv"
"strings"
roomv1 "hyapp.local/api/proto/room/v1"
"hyapp/services/gateway-service/internal/auth"
"hyapp/services/gateway-service/internal/transport/http/httpkit"
)
const (
maxUserVoiceRoomPresenceBatchSize = 100
codeInvalidUserIDs = "INVALID_USER_IDS"
codeVoiceRoomPresenceUnavailable = "VOICE_ROOM_PRESENCE_UNAVAILABLE"
)
// userVoiceRoomPresenceQueryClient 是个人资料页独占的窄查询能力。
// 生产 grpcRoomQueryClient 实现该接口;其他只使用房间列表的调用方无需为新 RPC 扩大 mock 面。
type userVoiceRoomPresenceQueryClient interface {
BatchGetUserVoiceRoomPresences(context.Context, *roomv1.BatchGetUserVoiceRoomPresencesRequest) (*roomv1.BatchGetUserVoiceRoomPresencesResponse, error)
}
type userVoiceRoomPresenceBatchData struct {
Items []userVoiceRoomPresenceData `json:"items"`
ServerTimeMS int64 `json:"server_time_ms"`
}
type userVoiceRoomPresenceData struct {
UserID string `json:"user_id"`
State string `json:"state"`
SeatNo int32 `json:"seat_no"`
Joinable bool `json:"joinable"`
ObservedAtMS int64 `json:"observed_at_ms"`
Room *userVoiceRoomPresenceRoomData `json:"room"`
}
type userVoiceRoomPresenceRoomData struct {
RoomID string `json:"room_id"`
IMGroupID string `json:"im_group_id"`
RTCRoomID string `json:"rtc_room_id"`
RoomShortID string `json:"room_short_id"`
Title string `json:"title"`
OwnerUserID string `json:"owner_user_id"`
HostUserID string `json:"host_user_id"`
Mode string `json:"mode"`
CoverURL string `json:"cover_url"`
BackgroundURL string `json:"background_url"`
OnlineCount int32 `json:"online_count"`
SeatCount int32 `json:"seat_count"`
Locked bool `json:"locked"`
}
// batchUserVoiceRoomPresences 为个人资料页返回当前登录用户可见的 Live 入口。
// 客户端只提交目标 user_idsviewer_user_id 和 app_code 必须来自已验签请求上下文。
func (h *Handler) batchUserVoiceRoomPresences(writer http.ResponseWriter, request *http.Request) {
queryClient, ok := h.roomQueryClient.(userVoiceRoomPresenceQueryClient)
if !ok || queryClient == nil {
httpkit.WriteError(writer, request, http.StatusServiceUnavailable, codeVoiceRoomPresenceUnavailable, "voice room presence unavailable")
return
}
userIDs, ok := parseUserVoiceRoomPresenceUserIDs(request)
if !ok {
httpkit.WriteError(writer, request, http.StatusBadRequest, codeInvalidUserIDs, "invalid user ids")
return
}
resp, err := queryClient.BatchGetUserVoiceRoomPresences(request.Context(), &roomv1.BatchGetUserVoiceRoomPresencesRequest{
Meta: httpkit.RoomMeta(request, "", ""),
ViewerUserId: auth.UserIDFromContext(request.Context()),
UserIds: userIDs,
})
if err != nil {
// 单个未上麦或不可见用户由 room-service 返回 not_on_micRPC 失败代表整批实时事实不可用,客户端必须隐藏 Live。
httpkit.WriteError(writer, request, http.StatusServiceUnavailable, codeVoiceRoomPresenceUnavailable, "voice room presence unavailable")
return
}
httpkit.WriteOK(writer, request, userVoiceRoomPresenceBatchDataFromProto(resp))
}
func parseUserVoiceRoomPresenceUserIDs(request *http.Request) ([]int64, bool) {
rawValues, exists := request.URL.Query()["user_ids"]
if !exists || len(rawValues) == 0 {
return nil, false
}
seen := make(map[int64]struct{})
result := make([]int64, 0, len(rawValues))
for _, raw := range rawValues {
if strings.TrimSpace(raw) == "" {
return nil, false
}
for piece := range strings.SplitSeq(raw, ",") {
piece = strings.TrimSpace(piece)
userID, err := strconv.ParseInt(piece, 10, 64)
if piece == "" || err != nil || userID <= 0 {
return nil, false
}
if _, duplicate := seen[userID]; duplicate {
continue
}
seen[userID] = struct{}{}
result = append(result, userID)
if len(result) > maxUserVoiceRoomPresenceBatchSize {
return nil, false
}
}
}
return result, len(result) > 0
}
func userVoiceRoomPresenceBatchDataFromProto(resp *roomv1.BatchGetUserVoiceRoomPresencesResponse) userVoiceRoomPresenceBatchData {
if resp == nil {
return userVoiceRoomPresenceBatchData{Items: []userVoiceRoomPresenceData{}}
}
items := make([]userVoiceRoomPresenceData, 0, len(resp.GetItems()))
for _, item := range resp.GetItems() {
view := userVoiceRoomPresenceData{
UserID: httpkit.UserIDString(item.GetUserId()),
State: item.GetState(),
SeatNo: item.GetSeatNo(),
Joinable: item.GetJoinable(),
ObservedAtMS: item.GetObservedAtMs(),
}
if room := item.GetRoom(); room != nil {
view.Room = &userVoiceRoomPresenceRoomData{
RoomID: room.GetRoomId(),
IMGroupID: roomIMGroupID(room.GetRoomId()),
RTCRoomID: room.GetRoomId(),
RoomShortID: room.GetRoomShortId(),
Title: room.GetTitle(),
OwnerUserID: httpkit.UserIDString(room.GetOwnerUserId()),
HostUserID: httpkit.UserIDString(room.GetHostUserId()),
Mode: room.GetMode(),
CoverURL: room.GetCoverUrl(),
BackgroundURL: room.GetBackgroundUrl(),
OnlineCount: room.GetOnlineCount(),
SeatCount: room.GetSeatCount(),
Locked: room.GetLocked(),
}
}
items = append(items, view)
}
return userVoiceRoomPresenceBatchData{Items: items, ServerTimeMS: resp.GetServerTimeMs()}
}

View File

@ -41,6 +41,7 @@ func (h *Handler) Routes(jwtVerifier *auth.Verifier) http.Handler {
GiftMaxTargets: h.giftMaxTargets, GiftMaxTargets: h.giftMaxTargets,
GiftMaxCount: h.giftMaxCount, GiftMaxCount: h.giftMaxCount,
GiftMaxUnits: h.giftMaxUnits, GiftMaxUnits: h.giftMaxUnits,
ObjectUploader: h.objectUploader,
RTCTokenConfig: tencentrtc.TokenConfig{ RTCTokenConfig: tencentrtc.TokenConfig{
Enabled: h.tencentRTC.Enabled, Enabled: h.tencentRTC.Enabled,
SDKAppID: h.tencentRTC.SDKAppID, SDKAppID: h.tencentRTC.SDKAppID,
@ -153,6 +154,7 @@ func (h *Handler) Routes(jwtVerifier *auth.Verifier) http.Handler {
userHandlers := userAPI.UserHandlers() userHandlers := userAPI.UserHandlers()
userHandlers.BatchUserProfiles = messageAPI.BatchUserProfiles userHandlers.BatchUserProfiles = messageAPI.BatchUserProfiles
userHandlers.BatchRoomDisplayProfiles = roomAPI.BatchRoomDisplayProfiles userHandlers.BatchRoomDisplayProfiles = roomAPI.BatchRoomDisplayProfiles
userHandlers.BatchUserVoiceRoomPresences = roomAPI.BatchUserVoiceRoomPresences
userHandlers.GetMyGiftWall = walletAPI.GetMyGiftWall userHandlers.GetMyGiftWall = walletAPI.GetMyGiftWall
userHandlers.ListMyResources = resourceAPI.ListMyResources userHandlers.ListMyResources = resourceAPI.ListMyResources
userHandlers.EquipMyResource = resourceAPI.EquipMyResource userHandlers.EquipMyResource = resourceAPI.EquipMyResource

View File

@ -13,6 +13,7 @@ import (
"hyapp/pkg/appcode" "hyapp/pkg/appcode"
"hyapp/services/gateway-service/internal/auth" "hyapp/services/gateway-service/internal/auth"
"hyapp/services/gateway-service/internal/transport/http/httpkit" "hyapp/services/gateway-service/internal/transport/http/httpkit"
"hyapp/services/gateway-service/internal/transport/http/resourceview"
) )
const ( const (
@ -383,6 +384,11 @@ func appearanceResourceFromProto(item *walletv1.Resource, entitlementID string,
"entitlement_id": entitlementID, "entitlement_id": entitlementID,
"expires_at_ms": expiresAtMS, "expires_at_ms": expiresAtMS,
} }
if format := resourceview.FormatFromMetadata(item.GetMetadataJson()); format != "" {
// 佩戴后的 appearance 仍要携带与资源列表相同的显式 rendererFlutter 不解析 URL 后缀,
// 缺少后台格式配置时保持字段省略,让客户端明确走“不支持”分支。
resource["format"] = format
}
if item.GetResourceType() == appearanceResourceBadge { if item.GetResourceType() == appearanceResourceBadge {
badgeForm, badgeKind, levelTrack := appearanceBadgeMetadataFields(item.GetMetadataJson()) badgeForm, badgeKind, levelTrack := appearanceBadgeMetadataFields(item.GetMetadataJson())
resource["badge_form"] = badgeForm resource["badge_form"] = badgeForm

View File

@ -1,19 +1,26 @@
package userapi package userapi
import ( import (
"hyapp/services/gateway-service/internal/transport/http/httpkit"
"net/http" "net/http"
"strings" "strings"
userv1 "hyapp.local/api/proto/user/v1" userv1 "hyapp.local/api/proto/user/v1"
"hyapp/services/gateway-service/internal/auth" "hyapp/services/gateway-service/internal/auth"
"hyapp/services/gateway-service/internal/transport/http/httpkit"
)
const (
profileVisitorPageMax int32 = 100
profileVisitorPageSizeMax int32 = 100
) )
type profileVisitRecordData struct { type profileVisitRecordData struct {
VisitorUserID string `json:"visitor_user_id"` VisitorUserID string `json:"visitor_user_id,omitempty"`
TargetUserID string `json:"target_user_id"` AnonymousVisitID string `json:"anonymous_visit_id,omitempty"`
VisitCount int64 `json:"visit_count"` VisitorIdentityHidden bool `json:"visitor_identity_hidden"`
LastVisitedAtMS int64 `json:"last_visited_at_ms"` TargetUserID string `json:"target_user_id"`
VisitCount int64 `json:"visit_count"`
LastVisitedAtMS int64 `json:"last_visited_at_ms"`
} }
type followRecordData struct { type followRecordData struct {
@ -47,6 +54,18 @@ func (h *Handler) listMyProfileVisitors(writer http.ResponseWriter, request *htt
httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument") httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
return return
} }
if page > profileVisitorPageMax {
// 当前 page 协议需要从公开、匿名两个有序分支读取到目标 offset 后再合并;限制页码可把
// 单次读取窗口硬限制在 10,000 条,避免客户端构造高页把 user-service 内存变成无界缓存。
httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "page must be less than or equal to 100")
return
}
if pageSize > profileVisitorPageSizeMax {
// 访客列表的页宽是对外契约;网关必须在进入 gRPC 前拒绝超限请求,
// 不依赖 user-service 静默截断,否则 Flutter 无法区分参数错误与服务端分页。
httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "page_size must be less than or equal to 100")
return
}
resp, err := h.userSocialClient.ListProfileVisitors(request.Context(), &userv1.ListProfileVisitorsRequest{ resp, err := h.userSocialClient.ListProfileVisitors(request.Context(), &userv1.ListProfileVisitorsRequest{
Meta: httpkit.UserMeta(request, ""), Meta: httpkit.UserMeta(request, ""),
UserId: auth.UserIDFromContext(request.Context()), UserId: auth.UserIDFromContext(request.Context()),
@ -192,7 +211,14 @@ func (h *Handler) recordProfileVisit(writer http.ResponseWriter, request *http.R
httpkit.WriteRPCError(writer, request, err) httpkit.WriteRPCError(writer, request, err)
return return
} }
data := map[string]any{"recorded": resp.GetRecorded(), "target_stats": overviewStatsFromProto(resp.GetTargetStats())} data := map[string]any{
"recorded": resp.GetRecorded(),
"target_stats_hidden": resp.GetTargetStatsHidden(),
}
if !resp.GetTargetStatsHidden() {
// 隐藏标志为 true 时不写 target_stats key不能把 nil 转成零值 DTO否则客户端会把伪造的 0 当成真实统计。
data["target_stats"] = overviewStatsFromProto(resp.GetTargetStats())
}
if h.userProfileClient != nil { if h.userProfileClient != nil {
profileResp, err := h.userProfileClient.GetUser(request.Context(), &userv1.GetUserRequest{ profileResp, err := h.userProfileClient.GetUser(request.Context(), &userv1.GetUserRequest{
Meta: httpkit.UserMeta(request, ""), Meta: httpkit.UserMeta(request, ""),
@ -303,12 +329,19 @@ func profileVisitData(record *userv1.ProfileVisitRecord) profileVisitRecordData
if record == nil { if record == nil {
return profileVisitRecordData{} return profileVisitRecordData{}
} }
return profileVisitRecordData{ data := profileVisitRecordData{
VisitorUserID: userIDString(record.GetVisitorUserId()), VisitorIdentityHidden: record.GetVisitorIdentityHidden(),
TargetUserID: userIDString(record.GetTargetUserId()), TargetUserID: userIDString(record.GetTargetUserId()),
VisitCount: record.GetVisitCount(), VisitCount: record.GetVisitCount(),
LastVisitedAtMS: record.GetLastVisitedAtMs(), LastVisitedAtMS: record.GetLastVisitedAtMs(),
} }
if record.GetVisitorIdentityHidden() {
// 匿名记录只暴露随机展示 IDvisitor_user_id 使用 omitempty 完全省略,也不存在可继续拉取的 profile。
data.AnonymousVisitID = record.GetAnonymousVisitId()
} else {
data.VisitorUserID = userIDString(record.GetVisitorUserId())
}
return data
} }
func followData(record *userv1.FollowRecord) followRecordData { func followData(record *userv1.FollowRecord) followRecordData {

View File

@ -230,6 +230,8 @@ type vipBenefitData struct {
CreatedAtMS int64 `json:"created_at_ms"` CreatedAtMS int64 `json:"created_at_ms"`
UpdatedAtMS int64 `json:"updated_at_ms"` UpdatedAtMS int64 `json:"updated_at_ms"`
Presentation *vipBenefitPresentationData `json:"presentation"` Presentation *vipBenefitPresentationData `json:"presentation"`
// Resource 只有在 wallet 已确认同 App、active 且类型匹配时才非空。
Resource *resourceData `json:"resource,omitempty"`
} }
type vipBenefitPresentationData struct { type vipBenefitPresentationData struct {

View File

@ -1,6 +1,9 @@
package walletapi package walletapi
import walletv1 "hyapp.local/api/proto/wallet/v1" import (
walletv1 "hyapp.local/api/proto/wallet/v1"
"hyapp/services/gateway-service/internal/transport/http/resourceview"
)
type overviewVIPData struct { type overviewVIPData struct {
Level int32 `json:"level"` Level int32 `json:"level"`
@ -30,6 +33,7 @@ type resourceData struct {
AssetURL string `json:"asset_url"` AssetURL string `json:"asset_url"`
PreviewURL string `json:"preview_url"` PreviewURL string `json:"preview_url"`
AnimationURL string `json:"animation_url"` AnimationURL string `json:"animation_url"`
Format string `json:"format,omitempty"`
MetadataJSON string `json:"metadata_json"` MetadataJSON string `json:"metadata_json"`
SortOrder int32 `json:"sort_order"` SortOrder int32 `json:"sort_order"`
CreatedAtMS int64 `json:"created_at_ms"` CreatedAtMS int64 `json:"created_at_ms"`
@ -65,6 +69,7 @@ func resourceFromProto(item *walletv1.Resource) resourceData {
AssetURL: item.GetAssetUrl(), AssetURL: item.GetAssetUrl(),
PreviewURL: item.GetPreviewUrl(), PreviewURL: item.GetPreviewUrl(),
AnimationURL: item.GetAnimationUrl(), AnimationURL: item.GetAnimationUrl(),
Format: resourceview.FormatFromMetadata(item.GetMetadataJson()),
MetadataJSON: item.GetMetadataJson(), MetadataJSON: item.GetMetadataJson(),
SortOrder: item.GetSortOrder(), SortOrder: item.GetSortOrder(),
CreatedAtMS: item.GetCreatedAtMs(), CreatedAtMS: item.GetCreatedAtMs(),

View File

@ -233,7 +233,12 @@ func (h *Handler) triggerVIPOnlineNotice(writer http.ResponseWriter, request *ht
// 客户端展示态可能滞后,执行前必须重新查 effective benefit体验卡排除、过期和后台停用 // 客户端展示态可能滞后,执行前必须重新查 effective benefit体验卡排除、过期和后台停用
// 都以 wallet-service 此刻的判定为准,不能仅比较 VIP9 等级。 // 都以 wallet-service 此刻的判定为准,不能仅比较 VIP9 等级。
statusCode, code, message := httpkit.MapReasonToHTTP(xerr.VIPBenefitRequired) statusCode, code, message := httpkit.MapReasonToHTTP(xerr.VIPBenefitRequired)
httpkit.WriteError(writer, request, statusCode, code, message) httpkit.WriteErrorData(writer, request, statusCode, code, message, map[string]any{
"benefit_code": vipOnlineNoticeBenefitCode,
"required_level": benefitResp.GetRequiredLevel(),
"current_effective_level": benefitResp.GetCurrentEffectiveLevel(),
"action": "send_online_global_notice",
})
return return
} }
equippedResp, err := h.walletClient.BatchGetUserEquippedResources(ctx, &walletv1.BatchGetUserEquippedResourcesRequest{ equippedResp, err := h.walletClient.BatchGetUserEquippedResources(ctx, &walletv1.BatchGetUserEquippedResourcesRequest{
@ -438,6 +443,7 @@ func vipBenefitsFromProto(items []*walletv1.VipBenefit) []vipBenefitData {
CreatedAtMS: item.GetCreatedAtMs(), CreatedAtMS: item.GetCreatedAtMs(),
UpdatedAtMS: item.GetUpdatedAtMs(), UpdatedAtMS: item.GetUpdatedAtMs(),
Presentation: vipBenefitPresentationFromProto(item.GetPresentation()), Presentation: vipBenefitPresentationFromProto(item.GetPresentation()),
Resource: resourcePointerFromProto(item.GetResource()),
}) })
} }
return result return result

View File

@ -211,7 +211,8 @@ type DrawCommand struct {
Recharge7DCoins int64 Recharge7DCoins int64
Recharge30DCoins int64 Recharge30DCoins int64
LastRechargedAtMS int64 LastRechargedAtMS int64
GiftIncomeCoins int64 // HostPeriodDiamondAdded 是 wallet 按后台礼物比例配置实际入账的主播钻石,资金拆分必须以该 owner 事实为准。
HostPeriodDiamondAdded int64
// Sender* 是扣费时刻的公开展示快照owner outbox 必须携带快照,避免下游为一条飘屏同步反查 user-service。 // Sender* 是扣费时刻的公开展示快照owner outbox 必须携带快照,避免下游为一条飘屏同步反查 user-service。
SenderName string SenderName string
SenderAvatar string SenderAvatar string

View File

@ -68,6 +68,13 @@ type luckyGiftUserProfileRefresher interface {
RefreshLuckyGiftUserProfiles(ctx context.Context, nowMS int64, batchSize int) (int, error) RefreshLuckyGiftUserProfiles(ctx context.Context, nowMS int64, batchSize int) (int, error)
} }
// luckyGiftRuleVersionRepository 把历史版本能力保持为生产 MySQL 仓储的可选扩展,避免为只服务
// 单元测试的最小 Repository 伪实现扩散后台运维方法。
type luckyGiftRuleVersionRepository interface {
ListLuckyGiftRuleConfigVersions(ctx context.Context, poolID string) ([]domain.RuleConfig, error)
RollbackLuckyGiftRuleConfig(ctx context.Context, poolID string, targetRuleVersion int64, operatorAdminID int64, nowMS int64) (domain.RuleConfig, error)
}
// WalletClient 是幸运礼物返奖唯一账务依赖wallet-service 仍负责持久 outboxactivity 只做成功落库后的低延迟余额 IM。 // WalletClient 是幸运礼物返奖唯一账务依赖wallet-service 仍负责持久 outboxactivity 只做成功落库后的低延迟余额 IM。
type WalletClient interface { type WalletClient interface {
CreditLuckyGiftReward(ctx context.Context, req *walletv1.CreditLuckyGiftRewardRequest, opts ...grpc.CallOption) (*walletv1.CreditLuckyGiftRewardResponse, error) CreditLuckyGiftReward(ctx context.Context, req *walletv1.CreditLuckyGiftRewardRequest, opts ...grpc.CallOption) (*walletv1.CreditLuckyGiftRewardResponse, error)
@ -787,6 +794,42 @@ func (s *Service) ListConfigs(ctx context.Context, appCode string) ([]domain.Rul
return s.repository.ListLuckyGiftRuleConfigs(ctx, strings.TrimSpace(appCode)) return s.repository.ListLuckyGiftRuleConfigs(ctx, strings.TrimSpace(appCode))
} }
func (s *Service) ListConfigVersions(ctx context.Context, poolID string) ([]domain.RuleConfig, error) {
if err := s.requireRepository(); err != nil {
return nil, err
}
versionRepository, ok := s.repository.(luckyGiftRuleVersionRepository)
if !ok {
return nil, xerr.New(xerr.Unavailable, "lucky gift rule version repository is unavailable")
}
poolID = normalizePoolID(poolID)
if poolID == "" {
return nil, xerr.New(xerr.InvalidArgument, "lucky gift pool id is required")
}
return versionRepository.ListLuckyGiftRuleConfigVersions(ctx, poolID)
}
func (s *Service) RollbackConfig(ctx context.Context, poolID string, targetRuleVersion int64, operatorAdminID int64) (domain.RuleConfig, error) {
if err := s.requireRepository(); err != nil {
return domain.RuleConfig{}, err
}
versionRepository, ok := s.repository.(luckyGiftRuleVersionRepository)
if !ok {
return domain.RuleConfig{}, xerr.New(xerr.Unavailable, "lucky gift rule version repository is unavailable")
}
poolID = normalizePoolID(poolID)
if poolID == "" || targetRuleVersion <= 0 || operatorAdminID <= 0 {
return domain.RuleConfig{}, xerr.New(xerr.InvalidArgument, "lucky gift rollback request is incomplete")
}
return versionRepository.RollbackLuckyGiftRuleConfig(
ctx,
poolID,
targetRuleVersion,
operatorAdminID,
s.now().UTC().UnixMilli(),
)
}
func (s *Service) ListDraws(ctx context.Context, query domain.DrawQuery) ([]domain.DrawResult, int64, error) { func (s *Service) ListDraws(ctx context.Context, query domain.DrawQuery) ([]domain.DrawResult, int64, error) {
if err := s.requireRepository(); err != nil { if err := s.requireRepository(); err != nil {
return nil, 0, err return nil, 0, err

View File

@ -332,7 +332,8 @@ func (r *Repository) executeExternalDynamicGiftEconomy(ctx context.Context, tx *
CoinSpent: cmd.TotalAmount, CoinSpent: cmd.TotalAmount,
PaidAtMS: cmd.PaidAtMS, PaidAtMS: cmd.PaidAtMS,
UserRegisteredAtMS: cmd.UserRegisteredAtMS, UserRegisteredAtMS: cmd.UserRegisteredAtMS,
GiftIncomeCoins: split.AnchorReturnCoins, // 外部 App 没有 HyApp wallet 回执,只能继续使用已签名外部金额按本规则拆出的主播份额。
HostPeriodDiamondAdded: split.AnchorReturnCoins,
} }
// 当前外部协议没有可验证的充值 owner 快照;保持 0/0 会落 novice 且关闭五分钟加成, // 当前外部协议没有可验证的充值 owner 快照;保持 0/0 会落 novice 且关闭五分钟加成,
// 比从 metadata 或客户端自报字段猜测更安全。后续若扩协议,必须把签名覆盖的充值事实显式入契约。 // 比从 metadata 或客户端自报字段猜测更安全。后续若扩协议,必须把签名覆盖的充值事实显式入契约。

View File

@ -147,7 +147,7 @@ func (r *Repository) executeDynamicLuckyGiftDrawBatch(ctx context.Context, tx *s
if err != nil { if err != nil {
return nil, err return nil, err
} }
fundSplit, err := luckyDynamicFundSplit(cmd.CoinSpent, cmd.GiftIncomeCoins, strategyConfig) fundSplit, err := luckyDynamicFundSplit(cmd.CoinSpent, cmd.HostPeriodDiamondAdded, strategyConfig)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -204,7 +204,7 @@ func (r *Repository) executeDynamicLuckyGiftDrawBatch(ctx context.Context, tx *s
return nil, err return nil, err
} }
unitSplit := unitSplits[index-1] unitSplit := unitSplits[index-1]
unitCommand.GiftIncomeCoins = unitSplit.AnchorReturnCoins unitCommand.HostPeriodDiamondAdded = unitSplit.AnchorReturnCoins
if unitCommand.CoinSpent <= 0 { if unitCommand.CoinSpent <= 0 {
return nil, xerr.New(xerr.InvalidArgument, "dynamic lucky gift unit spend must be positive") return nil, xerr.New(xerr.InvalidArgument, "dynamic lucky gift unit spend must be positive")
} }

View File

@ -48,7 +48,7 @@ func TestDynamicLuckyGiftMySQLBatchAndExternalPaths(t *testing.T) {
command := domain.DrawCommand{ command := domain.DrawCommand{
CommandID: "dynamic-99", PoolID: internalRule.PoolID, UserID: 1001, TargetUserID: 2001, CommandID: "dynamic-99", PoolID: internalRule.PoolID, UserID: 1001, TargetUserID: 2001,
DeviceID: "device-1001", RoomID: "room-1", AnchorID: "anchor-1", GiftID: "super-lucky", DeviceID: "device-1001", RoomID: "room-1", AnchorID: "anchor-1", GiftID: "super-lucky",
GiftCount: 99, CoinSpent: 100, GiftIncomeCoins: 1, PaidAtMS: now, GiftCount: 99, CoinSpent: 100, HostPeriodDiamondAdded: 1, PaidAtMS: now,
} }
missingWalletPaidAt := command missingWalletPaidAt := command
missingWalletPaidAt.CommandID = "dynamic-missing-wallet-paid-at" missingWalletPaidAt.CommandID = "dynamic-missing-wallet-paid-at"
@ -330,7 +330,7 @@ func assertDynamicTokenSurvivesUTCDayBoundary(t *testing.T, repo *Repository, db
earn := domain.DrawCommand{ earn := domain.DrawCommand{
CommandID: "token-earn", PoolID: rule.PoolID, UserID: userID, TargetUserID: 1, CommandID: "token-earn", PoolID: rule.PoolID, UserID: userID, TargetUserID: 1,
DeviceID: "device-token", RoomID: "room-token", AnchorID: "anchor-token", GiftID: "super-lucky", DeviceID: "device-token", RoomID: "room-token", AnchorID: "anchor-token", GiftID: "super-lucky",
GiftCount: 1, CoinSpent: 5_000, GiftIncomeCoins: 50, PaidAtMS: dayOneMS, GiftCount: 1, CoinSpent: 5_000, HostPeriodDiamondAdded: 50, PaidAtMS: dayOneMS,
} }
if _, err := repo.ExecuteLuckyGiftDraw(ctx, earn, dayOneMS); err != nil { if _, err := repo.ExecuteLuckyGiftDraw(ctx, earn, dayOneMS); err != nil {
t.Fatalf("earn milestone token: %v", err) t.Fatalf("earn milestone token: %v", err)
@ -350,7 +350,7 @@ func assertDynamicTokenSurvivesUTCDayBoundary(t *testing.T, repo *Repository, db
retain := earn retain := earn
retain.CommandID = "token-retain-next-day" retain.CommandID = "token-retain-next-day"
retain.CoinSpent = 1 retain.CoinSpent = 1
retain.GiftIncomeCoins = 0 retain.HostPeriodDiamondAdded = 0
retain.PaidAtMS = dayTwoMS retain.PaidAtMS = dayTwoMS
if _, err := repo.ExecuteLuckyGiftDraw(ctx, retain, dayTwoMS); err != nil { if _, err := repo.ExecuteLuckyGiftDraw(ctx, retain, dayTwoMS); err != nil {
t.Fatalf("retain token across UTC day: %v", err) t.Fatalf("retain token across UTC day: %v", err)
@ -408,7 +408,7 @@ func assertDynamicSpendBatchRollbackAndRestart(t *testing.T, repo *Repository, d
command := domain.DrawCommand{ command := domain.DrawCommand{
CommandID: "dual-spend-batch-command", PoolID: published.PoolID, UserID: 4_040, TargetUserID: 1, CommandID: "dual-spend-batch-command", PoolID: published.PoolID, UserID: 4_040, TargetUserID: 1,
DeviceID: "dual-spend-batch-device", RoomID: "dual-spend-batch-room", AnchorID: "dual-spend-batch-anchor", GiftID: "super-lucky", DeviceID: "dual-spend-batch-device", RoomID: "dual-spend-batch-room", AnchorID: "dual-spend-batch-anchor", GiftID: "super-lucky",
GiftCount: 3, CoinSpent: 30, GiftIncomeCoins: 0, PaidAtMS: nowMS, GiftCount: 3, CoinSpent: 30, HostPeriodDiamondAdded: 0, PaidAtMS: nowMS,
} }
const rollbackTrigger = "trg_lucky_dual_spend_rollback" const rollbackTrigger = "trg_lucky_dual_spend_rollback"
if _, err := db.Exec("DROP TRIGGER IF EXISTS " + rollbackTrigger); err != nil { if _, err := db.Exec("DROP TRIGGER IF EXISTS " + rollbackTrigger); err != nil {
@ -938,7 +938,7 @@ func TestLuckyDynamicDualSourcesSharePoolRTPRiskAndAuditAccounting(t *testing.T)
command := domain.DrawCommand{ command := domain.DrawCommand{
CommandID: label, PoolID: runtimeConfig.PoolID, GiftID: runtimeConfig.GiftID, GiftCount: 1, CommandID: label, PoolID: runtimeConfig.PoolID, GiftID: runtimeConfig.GiftID, GiftCount: 1,
UserID: 1001, TargetUserID: 2001, RoomID: "room-v5", AnchorID: "anchor-v5", UserID: 1001, TargetUserID: 2001, RoomID: "room-v5", AnchorID: "anchor-v5",
CoinSpent: 100, GiftIncomeCoins: 10, PaidAtMS: paidAtMS, CoinSpent: 100, HostPeriodDiamondAdded: 10, PaidAtMS: paidAtMS,
} }
split := domain.StrategyFundSplit{PublicPoolCoins: 89, ProfitPoolCoins: 1, AnchorReturnCoins: 10} split := domain.StrategyFundSplit{PublicPoolCoins: 89, ProfitPoolCoins: 1, AnchorReturnCoins: 10}
if err := (&Repository{}).stageLuckyDynamicDecision("lalu", runtimeConfig, runtimeConfig.GiftPrice, command, split, decision, paidAtMS, &state); err != nil { if err := (&Repository{}).stageLuckyDynamicDecision("lalu", runtimeConfig, runtimeConfig.GiftPrice, command, split, decision, paidAtMS, &state); err != nil {

View File

@ -144,22 +144,22 @@ func luckyDynamicStrategyConfig(config domain.Config) domain.StrategyConfig {
} }
} }
// luckyDynamicFundSplit 以不可变规则做唯一拆账,并校验 wallet 已经发给收礼人的 COIN 与规则主播份额完全一致 // luckyDynamicFundSplit 以 wallet 已按后台礼物比例配置实际入账的主播钻石为唯一主播份额
// dynamic_v3 不能把旧 room 缺字段解释成 0也不能把 App 钱包配置的其他返币比例静默塞进盈利池; // 幸运礼物规则只决定公共奖池比例;主播份额变化后的剩余金额自然进入平台利润,不能再用规则里的
// 启用新规则前必须先完成 wallet/room 契约滚动升级并让礼物返币配置与本规则一致 // anchor_rate_ppm 覆盖 wallet owner 事实,也不能把“返还金币比例”误当成主播收益
func luckyDynamicFundSplit(totalSpent, actualAnchorIncome int64, config domain.StrategyConfig) (domain.StrategyFundSplit, error) { func luckyDynamicFundSplit(totalSpent, actualAnchorIncome int64, config domain.StrategyConfig) (domain.StrategyFundSplit, error) {
configured, err := domain.SplitLuckyGiftStrategyFunds(totalSpent, config) configured, err := domain.SplitLuckyGiftStrategyFunds(totalSpent, config)
if err != nil { if err != nil {
return domain.StrategyFundSplit{}, err return domain.StrategyFundSplit{}, err
} }
if actualAnchorIncome != configured.AnchorReturnCoins { if actualAnchorIncome < 0 || actualAnchorIncome > totalSpent || configured.PublicPoolCoins > totalSpent-actualAnchorIncome {
return domain.StrategyFundSplit{}, fmt.Errorf( return domain.StrategyFundSplit{}, fmt.Errorf("dynamic lucky gift wallet anchor income is invalid: spent=%d anchor=%d", totalSpent, actualAnchorIncome)
"dynamic lucky gift anchor income mismatch: wallet=%d configured=%d",
actualAnchorIncome,
configured.AnchorReturnCoins,
)
} }
return configured, nil return domain.StrategyFundSplit{
PublicPoolCoins: configured.PublicPoolCoins,
ProfitPoolCoins: totalSpent - configured.PublicPoolCoins - actualAnchorIncome,
AnchorReturnCoins: actualAnchorIncome,
}, nil
} }
// luckyDynamicUnitFundSplits 联合分配整批的三个资金桶:每一抽都先满足 // luckyDynamicUnitFundSplits 联合分配整批的三个资金桶:每一抽都先满足

View File

@ -102,15 +102,18 @@ func TestLuckyDynamicUnitFundSplitsConserveEveryDrawAndBatchBucket(t *testing.T)
} }
} }
func TestLuckyDynamicFundSplitRejectsWalletAnchorMismatch(t *testing.T) { func TestLuckyDynamicFundSplitUsesWalletAnchorIncome(t *testing.T) {
config := domain.DefaultLuckyGiftStrategyConfig() config := domain.DefaultLuckyGiftStrategyConfig()
if split, err := luckyDynamicFundSplit(100, 1, config); err != nil || split != (domain.StrategyFundSplit{PublicPoolCoins: 98, ProfitPoolCoins: 1, AnchorReturnCoins: 1}) { if split, err := luckyDynamicFundSplit(100, 1, config); err != nil || split != (domain.StrategyFundSplit{PublicPoolCoins: 98, ProfitPoolCoins: 1, AnchorReturnCoins: 1}) {
t.Fatalf("matching split=%+v err=%v", split, err) t.Fatalf("matching split=%+v err=%v", split, err)
} }
if _, err := luckyDynamicFundSplit(100, 0, config); err == nil { if split, err := luckyDynamicFundSplit(100, 0, config); err != nil || split != (domain.StrategyFundSplit{PublicPoolCoins: 98, ProfitPoolCoins: 2, AnchorReturnCoins: 0}) {
t.Fatal("missing wallet anchor snapshot was accepted") t.Fatalf("zero wallet anchor split=%+v err=%v", split, err)
} }
if _, err := luckyDynamicFundSplit(100, 10, config); err == nil { if split, err := luckyDynamicFundSplit(100, 2, config); err != nil || split != (domain.StrategyFundSplit{PublicPoolCoins: 98, ProfitPoolCoins: 0, AnchorReturnCoins: 2}) {
t.Fatal("wallet anchor ratio different from rule was accepted") t.Fatalf("wallet-authoritative split=%+v err=%v", split, err)
}
if _, err := luckyDynamicFundSplit(100, 3, config); err == nil {
t.Fatal("wallet anchor that exceeds the conserved remainder was accepted")
} }
} }

View File

@ -251,6 +251,95 @@ func (r *Repository) ListLuckyGiftRuleConfigs(ctx context.Context, appCode strin
return configs, rows.Err() return configs, rows.Err()
} }
// ListLuckyGiftRuleConfigVersions 返回同一奖池的完整不可变版本链。主键前缀
// (app_code,pool_id,rule_version) 可直接反向范围扫描,不会扫描其他应用或奖池的规则行。
func (r *Repository) ListLuckyGiftRuleConfigVersions(ctx context.Context, poolID string) ([]domain.RuleConfig, error) {
if r == nil || r.db == nil {
return nil, xerr.New(xerr.Unavailable, "mysql repository is not configured")
}
appCode := appcode.FromContext(ctx)
poolID = luckyPoolID(poolID)
if appCode == "" || poolID == "" {
return nil, xerr.New(xerr.InvalidArgument, "lucky gift app and pool are required")
}
rows, err := r.db.QueryContext(ctx, luckyRuleConfigSelectSQL+`
ORDER BY rule_version DESC`, appCode, poolID)
if err != nil {
return nil, err
}
defer rows.Close()
configs := make([]domain.RuleConfig, 0)
for rows.Next() {
config, err := scanLuckyGiftRuleConfig(rows)
if err != nil {
return nil, err
}
stages, err := r.getLuckyGiftRuleStages(ctx, r.db, appCode, poolID, config.RuleVersion)
if err != nil {
return nil, err
}
config.Stages = stages
configs = append(configs, config)
}
return configs, rows.Err()
}
// RollbackLuckyGiftRuleConfig 复制目标快照并追加新版本;旧版本和当前版本都保持不可变。
// 首先锁最新规则行,再锁实验行,严格沿用常规发布/实验入口的加锁顺序,避免并发发布与回滚互相覆盖。
func (r *Repository) RollbackLuckyGiftRuleConfig(ctx context.Context, poolID string, targetRuleVersion int64, operatorAdminID int64, nowMS int64) (domain.RuleConfig, error) {
if r == nil || r.db == nil {
return domain.RuleConfig{}, xerr.New(xerr.Unavailable, "mysql repository is not configured")
}
appCode := appcode.FromContext(ctx)
poolID = luckyPoolID(poolID)
if appCode == "" || poolID == "" || targetRuleVersion <= 0 || operatorAdminID <= 0 {
return domain.RuleConfig{}, xerr.New(xerr.InvalidArgument, "lucky gift rollback identity is incomplete")
}
tx, err := r.db.BeginTx(ctx, nil)
if err != nil {
return domain.RuleConfig{}, err
}
defer func() { _ = tx.Rollback() }()
current, exists, err := r.getLuckyGiftRuleConfig(ctx, tx, appCode, poolID, true)
if err != nil {
return domain.RuleConfig{}, err
}
if !exists {
return domain.RuleConfig{}, xerr.New(xerr.NotFound, "lucky gift pool config not found")
}
if targetRuleVersion >= current.RuleVersion {
return domain.RuleConfig{}, xerr.New(xerr.InvalidArgument, "rollback target must be older than the current rule version")
}
if _, active, err := r.getActiveLuckyGiftExperiment(ctx, tx, appCode, poolID, true); err != nil {
return domain.RuleConfig{}, err
} else if active {
return domain.RuleConfig{}, xerr.New(xerr.Conflict, "lucky gift pool has an active experiment; end the experiment before rollback")
}
target, exists, err := r.getLuckyGiftRuleConfigVersion(ctx, tx, appCode, poolID, targetRuleVersion, false)
if err != nil {
return domain.RuleConfig{}, err
}
if !exists {
return domain.RuleConfig{}, xerr.New(xerr.NotFound, "lucky gift rollback target version not found")
}
target.CreatedByAdminID = operatorAdminID
// 回滚从当前操作时刻生效;不能沿用历史 effective_from_ms否则审计会把新版本误显示成旧发布时间。
target.EffectiveFromMS = 0
if target.StrategyVersion == domain.StrategyDynamicV3 {
// V3 资金只保留在独立水位账本中。历史快照即使残留 seed回滚也不能重新注资。
target.InitialPoolCoins = 0
}
published, err := r.publishLuckyGiftRuleConfigTx(ctx, tx, target, nowMS)
if err != nil {
return domain.RuleConfig{}, err
}
if err := tx.Commit(); err != nil {
return domain.RuleConfig{}, err
}
return published, nil
}
const luckyRuleConfigSelectSQL = ` const luckyRuleConfigSelectSQL = `
SELECT app_code, pool_id, rule_version, enabled, strategy_version, target_rtp_ppm, pool_rate_ppm, SELECT app_code, pool_id, rule_version, enabled, strategy_version, target_rtp_ppm, pool_rate_ppm,
profit_rate_ppm, anchor_rate_ppm, profit_rate_ppm, anchor_rate_ppm,

View File

@ -132,7 +132,8 @@ func luckyDrawCommandFromProto(meta *luckygiftv1.LuckyGiftMeta) domain.DrawComma
Recharge7DCoins: meta.GetRecharge_7DCoins(), Recharge7DCoins: meta.GetRecharge_7DCoins(),
Recharge30DCoins: meta.GetRecharge_30DCoins(), Recharge30DCoins: meta.GetRecharge_30DCoins(),
LastRechargedAtMS: meta.GetLastRechargedAtMs(), LastRechargedAtMS: meta.GetLastRechargedAtMs(),
GiftIncomeCoins: meta.GetGiftIncomeCoins(), // protobuf 为滚动发布保留旧字段号和 getter 名domain 内只暴露真实的 wallet owner 语义。
HostPeriodDiamondAdded: meta.GetGiftIncomeCoins(),
} }
} }
@ -183,6 +184,31 @@ func (s *AdminLuckyGiftServer) ListLuckyGiftConfigs(ctx context.Context, req *lu
return resp, nil return resp, nil
} }
func (s *AdminLuckyGiftServer) ListLuckyGiftConfigVersions(ctx context.Context, req *luckygiftv1.ListLuckyGiftConfigVersionsRequest) (*luckygiftv1.ListLuckyGiftConfigVersionsResponse, error) {
ctx = appcode.WithContext(ctx, req.GetMeta().GetAppCode())
configs, err := s.svc.ListConfigVersions(ctx, req.GetPoolId())
if err != nil {
return nil, xerr.ToGRPCError(err)
}
resp := &luckygiftv1.ListLuckyGiftConfigVersionsResponse{Configs: make([]*luckygiftv1.LuckyGiftRuleConfig, 0, len(configs))}
for _, config := range configs {
resp.Configs = append(resp.Configs, luckyRuleConfigToProto(config))
}
return resp, nil
}
func (s *AdminLuckyGiftServer) RollbackLuckyGiftConfig(ctx context.Context, req *luckygiftv1.RollbackLuckyGiftConfigRequest) (*luckygiftv1.RollbackLuckyGiftConfigResponse, error) {
ctx = appcode.WithContext(ctx, req.GetMeta().GetAppCode())
config, err := s.svc.RollbackConfig(ctx, req.GetPoolId(), req.GetTargetRuleVersion(), req.GetOperatorAdminId())
if err != nil {
return nil, xerr.ToGRPCError(err)
}
return &luckygiftv1.RollbackLuckyGiftConfigResponse{
SourceRuleVersion: req.GetTargetRuleVersion(),
Config: luckyRuleConfigToProto(config),
}, nil
}
func (s *AdminLuckyGiftServer) ListLuckyGiftDraws(ctx context.Context, req *luckygiftv1.ListLuckyGiftDrawsRequest) (*luckygiftv1.ListLuckyGiftDrawsResponse, error) { func (s *AdminLuckyGiftServer) ListLuckyGiftDraws(ctx context.Context, req *luckygiftv1.ListLuckyGiftDrawsRequest) (*luckygiftv1.ListLuckyGiftDrawsResponse, error) {
ctx = appcode.WithContext(ctx, req.GetMeta().GetAppCode()) ctx = appcode.WithContext(ctx, req.GetMeta().GetAppCode())
draws, total, err := s.svc.ListDraws(ctx, domain.DrawQuery{ draws, total, err := s.svc.ListDraws(ctx, domain.DrawQuery{

View File

@ -81,6 +81,12 @@ rocketmq:
topic: "hyapp_user_outbox" topic: "hyapp_user_outbox"
consumer_group: "hyapp-room-user-region-sync" consumer_group: "hyapp-room-user-region-sync"
consumer_max_reconsume_times: 16 consumer_max_reconsume_times: 16
# 独立消费 wallet owner 事实Room Cell 装扮失效和资源素材刷新都走持久命令链路。
wallet_outbox:
enabled: true
topic: "hyapp_wallet_outbox"
consumer_group: "hyapp-room-wallet-decoration-sync"
consumer_max_reconsume_times: 16
# 房间中奖 IM 由 lucky-gift owner 事实可靠派生;独立 group 保证不和其他下游共享消费位点。 # 房间中奖 IM 由 lucky-gift owner 事实可靠派生;独立 group 保证不和其他下游共享消费位点。
lucky_gift_outbox: lucky_gift_outbox:
enabled: true enabled: true

View File

@ -91,6 +91,12 @@ rocketmq:
topic: "hyapp_user_outbox" topic: "hyapp_user_outbox"
consumer_group: "hyapp-room-user-region-sync" consumer_group: "hyapp-room-user-region-sync"
consumer_max_reconsume_times: 16 consumer_max_reconsume_times: 16
# 线上必须使用 room-service 专属 group不要复用 notice/statistics 的 wallet 消费位点。
wallet_outbox:
enabled: true
topic: "hyapp_wallet_outbox"
consumer_group: "hyapp-room-wallet-decoration-sync"
consumer_max_reconsume_times: 16
# 线上必须使用 room-service 自己的消费组owner outbox 只通过 MQ 进入房间展示链路。 # 线上必须使用 room-service 自己的消费组owner outbox 只通过 MQ 进入房间展示链路。
lucky_gift_outbox: lucky_gift_outbox:
enabled: true enabled: true

View File

@ -84,6 +84,12 @@ rocketmq:
topic: "hyapp_user_outbox" topic: "hyapp_user_outbox"
consumer_group: "hyapp-room-user-region-sync" consumer_group: "hyapp-room-user-region-sync"
consumer_max_reconsume_times: 16 consumer_max_reconsume_times: 16
# VIP/资源变更用 room-service 独立消费位点重算当前房间装扮,解绑或禁用后无需等客户端重进房。
wallet_outbox:
enabled: true
topic: "hyapp_wallet_outbox"
consumer_group: "hyapp-room-wallet-decoration-sync"
consumer_max_reconsume_times: 16
# 幸运礼物 owner 事实由独立消费组派生为房间展示 outbox不能复用 activity 消费位点或直连幸运礼物库。 # 幸运礼物 owner 事实由独立消费组派生为房间展示 outbox不能复用 activity 消费位点或直连幸运礼物库。
lucky_gift_outbox: lucky_gift_outbox:
enabled: true enabled: true

View File

@ -54,6 +54,8 @@ CREATE TABLE IF NOT EXISTS room_list_entries (
seat_count INT NOT NULL DEFAULT 0 COMMENT '麦位数量', seat_count INT NOT NULL DEFAULT 0 COMMENT '麦位数量',
occupied_seat_count INT NOT NULL DEFAULT 0 COMMENT '占用麦位数量', occupied_seat_count INT NOT NULL DEFAULT 0 COMMENT '占用麦位数量',
sort_score BIGINT NOT NULL DEFAULT 0 COMMENT '排序分值', sort_score BIGINT NOT NULL DEFAULT 0 COMMENT '排序分值',
room_border_json JSON NULL COMMENT '当前未过期房间边框资源快照',
room_name_style_json JSON NULL COMMENT '当前未过期彩色房名资源快照',
created_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', updated_at_ms BIGINT NOT NULL COMMENT '更新时间UTC epoch ms',
PRIMARY KEY (app_code, room_id), PRIMARY KEY (app_code, room_id),
@ -114,15 +116,82 @@ CREATE TABLE IF NOT EXISTS room_background_images (
app_code VARCHAR(32) NOT NULL COMMENT '应用编码,用于多租户隔离', app_code VARCHAR(32) NOT NULL COMMENT '应用编码,用于多租户隔离',
background_id BIGINT NOT NULL AUTO_INCREMENT COMMENT '背景图 ID', background_id BIGINT NOT NULL AUTO_INCREMENT COMMENT '背景图 ID',
room_id VARCHAR(64) NOT NULL COMMENT '房间 ID', room_id VARCHAR(64) NOT NULL COMMENT '房间 ID',
image_url VARCHAR(256) NOT NULL COMMENT '背景图 URL', image_url VARCHAR(1024) NOT NULL COMMENT '背景图 URL',
command_id VARCHAR(128) NULL COMMENT '新版素材保存幂等键;历史行保持 NULL',
object_key VARCHAR(512) NOT NULL DEFAULT '' COMMENT 'COS 对象 key',
content_type VARCHAR(64) NOT NULL DEFAULT '' COMMENT '服务端嗅探 MIME',
media_format VARCHAR(16) NOT NULL DEFAULT '' COMMENT 'jpeg/png/gif/webp',
size_bytes BIGINT NOT NULL DEFAULT 0 COMMENT '文件字节数',
width INT NOT NULL DEFAULT 0 COMMENT '像素宽度',
height INT NOT NULL DEFAULT 0 COMMENT '像素高度',
animated BOOLEAN NOT NULL DEFAULT FALSE COMMENT '是否动图',
frame_count INT NOT NULL DEFAULT 0 COMMENT '帧数',
duration_ms BIGINT NOT NULL DEFAULT 0 COMMENT '动图总时长',
poster_url VARCHAR(1024) NOT NULL DEFAULT '' COMMENT '可选首帧 URL',
thumbnail_url VARCHAR(1024) NOT NULL DEFAULT '' COMMENT '可选缩略图 URL',
sha256 CHAR(64) NOT NULL DEFAULT '' COMMENT '原文件 SHA-256',
media_status VARCHAR(32) NOT NULL DEFAULT '' COMMENT 'active 表示已通过专用上传入口',
created_by_user_id BIGINT NOT NULL COMMENT '保存背景图的用户 ID', created_by_user_id BIGINT NOT NULL COMMENT '保存背景图的用户 ID',
created_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', updated_at_ms BIGINT NOT NULL COMMENT '更新时间UTC epoch ms',
PRIMARY KEY (background_id), PRIMARY KEY (background_id),
UNIQUE KEY uk_room_background_url (app_code, room_id, image_url), UNIQUE KEY uk_room_background_url (app_code, room_id, image_url),
UNIQUE KEY uk_room_background_command (app_code, room_id, command_id),
KEY idx_room_background_list (app_code, room_id, created_at_ms DESC, background_id DESC) KEY idx_room_background_list (app_code, room_id, created_at_ms DESC, background_id DESC)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='房间背景图素材表'; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='房间背景图素材表';
CREATE TABLE IF NOT EXISTS room_media_upload_registrations (
app_code VARCHAR(32) NOT NULL COMMENT '应用编码',
room_id VARCHAR(64) NOT NULL COMMENT '目标房间 ID',
command_id VARCHAR(128) NOT NULL COMMENT '专用上传强幂等键',
actor_user_id BIGINT NOT NULL COMMENT '首次授权用户 ID',
purpose VARCHAR(32) NOT NULL COMMENT 'room_background/room_image',
media_payload_sha256 CHAR(64) NOT NULL COMMENT '首次服务端解析媒体事实哈希',
expected_object_key VARCHAR(512) NOT NULL COMMENT 'room owner 确定的 COS key',
object_url VARCHAR(1024) NOT NULL DEFAULT '' COMMENT 'PutObject 成功后的访问 URL',
status VARCHAR(32) NOT NULL DEFAULT 'authorized' COMMENT 'authorized/active',
content_type VARCHAR(64) NOT NULL COMMENT '服务端嗅探 MIME',
media_format VARCHAR(16) NOT NULL COMMENT 'jpeg/png/gif/webp',
size_bytes BIGINT NOT NULL COMMENT '原文件字节数',
width INT NOT NULL COMMENT '像素宽度',
height INT NOT NULL COMMENT '像素高度',
animated BOOLEAN NOT NULL DEFAULT FALSE COMMENT '是否动图',
frame_count INT NOT NULL COMMENT '帧数',
duration_ms BIGINT NOT NULL DEFAULT 0 COMMENT '动图总时长',
sha256 CHAR(64) NOT NULL COMMENT '原文件 SHA-256',
authorized_room_version BIGINT NOT 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, room_id, command_id),
UNIQUE KEY uk_room_media_upload_object (app_code, expected_object_key),
KEY idx_room_media_upload_status_created (app_code, status, created_at_ms)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='房间专用媒体上传授权与完成事实';
CREATE TABLE IF NOT EXISTS room_decoration_assignments (
app_code VARCHAR(32) NOT NULL,
room_id VARCHAR(64) NOT NULL,
decoration_type VARCHAR(32) NOT NULL,
owner_user_id BIGINT NOT NULL,
benefit_code VARCHAR(64) NOT NULL,
resource_id BIGINT NOT NULL,
entitlement_id VARCHAR(128) NOT NULL DEFAULT '',
expires_at_ms BIGINT NOT NULL DEFAULT 0,
updated_at_ms BIGINT NOT NULL,
PRIMARY KEY (app_code, room_id, decoration_type),
KEY idx_room_decoration_resource (app_code, resource_id, room_id, decoration_type),
KEY idx_room_decoration_owner (app_code, owner_user_id, room_id, decoration_type),
KEY idx_room_decoration_expiry (app_code, expires_at_ms, room_id, decoration_type)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='房间当前 VIP 装扮反向索引';
CREATE TABLE IF NOT EXISTS room_wallet_event_consumption (
app_code VARCHAR(32) NOT NULL,
event_id VARCHAR(128) NOT NULL,
event_type VARCHAR(64) NOT NULL,
consumed_at_ms BIGINT NOT NULL,
PRIMARY KEY (app_code, event_id),
KEY idx_room_wallet_event_consumed (app_code, consumed_at_ms)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='room-service 消费 wallet 装扮事实的本地幂等表';
CREATE TABLE IF NOT EXISTS room_snapshots ( CREATE TABLE IF NOT EXISTS room_snapshots (
app_code VARCHAR(32) NOT NULL COMMENT '应用编码,用于多租户隔离', app_code VARCHAR(32) NOT NULL COMMENT '应用编码,用于多租户隔离',
room_id VARCHAR(64) NOT NULL COMMENT '房间 ID', room_id VARCHAR(64) NOT NULL COMMENT '房间 ID',

View File

@ -0,0 +1,179 @@
-- 房间新版 VIP 媒体、上传授权与装扮失效读模型。
-- 性能边界:
-- 1. nullable/default 列使用 MySQL 8 INSTANT仅短暂持有 metadata lock不重写历史行仍建议业务低峰执行。
-- 2. image_url 扩容与唯一索引需要在线扫描低频 room_background_images使用 INPLACE/LOCK=NONE。
-- 3. 唯一索引创建前会主动检查非 NULL command_id 重复组;发现脏数据直接 SIGNAL拒绝创建冲突索引。
-- 4. 所有分支先查 information_schema因此脚本可在发布重试时重复执行。
SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci;
USE hyapp_room;
DELIMITER $$
DROP PROCEDURE IF EXISTS hyapp_migrate_003_room_vip_media_decorations$$
CREATE PROCEDURE hyapp_migrate_003_room_vip_media_decorations()
BEGIN
DECLARE duplicate_groups BIGINT DEFAULT 0;
DECLARE oversized_urls BIGINT DEFAULT 0;
IF EXISTS (
SELECT 1 FROM information_schema.tables
WHERE table_schema = DATABASE() AND table_name = 'room_background_images'
) THEN
IF EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = DATABASE() AND table_name = 'room_background_images'
AND column_name = 'image_url'
AND (data_type <> 'varchar' OR character_maximum_length <> 1024)
) THEN
SELECT COUNT(*) INTO oversized_urls
FROM room_background_images
WHERE CHAR_LENGTH(image_url) > 1024;
IF oversized_urls > 0 THEN
SIGNAL SQLSTATE '45000'
SET MESSAGE_TEXT = 'room_background_images.image_url contains values longer than 1024';
END IF;
ALTER TABLE room_background_images
MODIFY COLUMN image_url VARCHAR(1024) NOT NULL,
ALGORITHM=INPLACE,
LOCK=NONE;
END IF;
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'room_background_images' AND column_name = 'command_id') THEN
ALTER TABLE room_background_images ADD COLUMN command_id VARCHAR(128) NULL COMMENT '新版素材保存幂等键;历史行保持 NULL', ALGORITHM=INSTANT;
END IF;
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'room_background_images' AND column_name = 'object_key') THEN
ALTER TABLE room_background_images ADD COLUMN object_key VARCHAR(512) NOT NULL DEFAULT '' COMMENT 'COS 对象 key', ALGORITHM=INSTANT;
END IF;
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'room_background_images' AND column_name = 'content_type') THEN
ALTER TABLE room_background_images ADD COLUMN content_type VARCHAR(64) NOT NULL DEFAULT '' COMMENT '服务端嗅探 MIME', ALGORITHM=INSTANT;
END IF;
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'room_background_images' AND column_name = 'media_format') THEN
ALTER TABLE room_background_images ADD COLUMN media_format VARCHAR(16) NOT NULL DEFAULT '' COMMENT 'jpeg/png/gif/webp', ALGORITHM=INSTANT;
END IF;
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'room_background_images' AND column_name = 'size_bytes') THEN
ALTER TABLE room_background_images ADD COLUMN size_bytes BIGINT NOT NULL DEFAULT 0 COMMENT '文件字节数', ALGORITHM=INSTANT;
END IF;
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'room_background_images' AND column_name = 'width') THEN
ALTER TABLE room_background_images ADD COLUMN width INT NOT NULL DEFAULT 0 COMMENT '像素宽度', ALGORITHM=INSTANT;
END IF;
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'room_background_images' AND column_name = 'height') THEN
ALTER TABLE room_background_images ADD COLUMN height INT NOT NULL DEFAULT 0 COMMENT '像素高度', ALGORITHM=INSTANT;
END IF;
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'room_background_images' AND column_name = 'animated') THEN
ALTER TABLE room_background_images ADD COLUMN animated BOOLEAN NOT NULL DEFAULT FALSE COMMENT '是否动图', ALGORITHM=INSTANT;
END IF;
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'room_background_images' AND column_name = 'frame_count') THEN
ALTER TABLE room_background_images ADD COLUMN frame_count INT NOT NULL DEFAULT 0 COMMENT '帧数', ALGORITHM=INSTANT;
END IF;
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'room_background_images' AND column_name = 'duration_ms') THEN
ALTER TABLE room_background_images ADD COLUMN duration_ms BIGINT NOT NULL DEFAULT 0 COMMENT '动图总时长', ALGORITHM=INSTANT;
END IF;
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'room_background_images' AND column_name = 'poster_url') THEN
ALTER TABLE room_background_images ADD COLUMN poster_url VARCHAR(1024) NOT NULL DEFAULT '' COMMENT '可选首帧 URL', ALGORITHM=INSTANT;
END IF;
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'room_background_images' AND column_name = 'thumbnail_url') THEN
ALTER TABLE room_background_images ADD COLUMN thumbnail_url VARCHAR(1024) NOT NULL DEFAULT '' COMMENT '可选缩略图 URL', ALGORITHM=INSTANT;
END IF;
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'room_background_images' AND column_name = 'sha256') THEN
ALTER TABLE room_background_images ADD COLUMN sha256 CHAR(64) NOT NULL DEFAULT '' COMMENT '原文件 SHA-256', ALGORITHM=INSTANT;
END IF;
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'room_background_images' AND column_name = 'media_status') THEN
ALTER TABLE room_background_images ADD COLUMN media_status VARCHAR(32) NOT NULL DEFAULT '' COMMENT 'active 表示已通过专用上传入口', ALGORITHM=INSTANT;
END IF;
IF NOT EXISTS (
SELECT 1 FROM information_schema.statistics
WHERE table_schema = DATABASE() AND table_name = 'room_background_images'
AND index_name = 'uk_room_background_command'
) THEN
SELECT COUNT(*) INTO duplicate_groups
FROM (
SELECT 1
FROM room_background_images
WHERE command_id IS NOT NULL
GROUP BY app_code, room_id, command_id
HAVING COUNT(*) > 1
LIMIT 1
) AS duplicate_command_groups;
IF duplicate_groups > 0 THEN
SIGNAL SQLSTATE '45000'
SET MESSAGE_TEXT = 'duplicate room background command_id blocks unique index';
END IF;
ALTER TABLE room_background_images
ADD UNIQUE KEY uk_room_background_command (app_code, room_id, command_id),
ALGORITHM=INPLACE,
LOCK=NONE;
END IF;
END IF;
IF EXISTS (
SELECT 1 FROM information_schema.tables
WHERE table_schema = DATABASE() AND table_name = 'room_list_entries'
) THEN
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'room_list_entries' AND column_name = 'room_border_json') THEN
ALTER TABLE room_list_entries ADD COLUMN room_border_json JSON NULL COMMENT '当前未过期房间边框资源快照', ALGORITHM=INSTANT;
END IF;
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'room_list_entries' AND column_name = 'room_name_style_json') THEN
ALTER TABLE room_list_entries ADD COLUMN room_name_style_json JSON NULL COMMENT '当前未过期彩色房名资源快照', ALGORITHM=INSTANT;
END IF;
END IF;
END$$
CALL hyapp_migrate_003_room_vip_media_decorations()$$
DROP PROCEDURE IF EXISTS hyapp_migrate_003_room_vip_media_decorations$$
DELIMITER ;
CREATE TABLE IF NOT EXISTS room_media_upload_registrations (
app_code VARCHAR(32) NOT NULL COMMENT '应用编码',
room_id VARCHAR(64) NOT NULL COMMENT '目标房间 ID',
command_id VARCHAR(128) NOT NULL COMMENT '专用上传强幂等键',
actor_user_id BIGINT NOT NULL COMMENT '首次授权用户 ID',
purpose VARCHAR(32) NOT NULL COMMENT 'room_background/room_image',
media_payload_sha256 CHAR(64) NOT NULL COMMENT '首次服务端解析媒体事实哈希',
expected_object_key VARCHAR(512) NOT NULL COMMENT 'room owner 确定的 COS key',
object_url VARCHAR(1024) NOT NULL DEFAULT '' COMMENT 'PutObject 成功后的访问 URL',
status VARCHAR(32) NOT NULL DEFAULT 'authorized' COMMENT 'authorized/active',
content_type VARCHAR(64) NOT NULL COMMENT '服务端嗅探 MIME',
media_format VARCHAR(16) NOT NULL COMMENT 'jpeg/png/gif/webp',
size_bytes BIGINT NOT NULL COMMENT '原文件字节数',
width INT NOT NULL COMMENT '像素宽度',
height INT NOT NULL COMMENT '像素高度',
animated BOOLEAN NOT NULL DEFAULT FALSE COMMENT '是否动图',
frame_count INT NOT NULL COMMENT '帧数',
duration_ms BIGINT NOT NULL DEFAULT 0 COMMENT '动图总时长',
sha256 CHAR(64) NOT NULL COMMENT '原文件 SHA-256',
authorized_room_version BIGINT NOT 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, room_id, command_id),
UNIQUE KEY uk_room_media_upload_object (app_code, expected_object_key),
KEY idx_room_media_upload_status_created (app_code, status, created_at_ms)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='房间专用媒体上传授权与完成事实';
CREATE TABLE IF NOT EXISTS room_decoration_assignments (
app_code VARCHAR(32) NOT NULL,
room_id VARCHAR(64) NOT NULL,
decoration_type VARCHAR(32) NOT NULL,
owner_user_id BIGINT NOT NULL,
benefit_code VARCHAR(64) NOT NULL,
resource_id BIGINT NOT NULL,
entitlement_id VARCHAR(128) NOT NULL DEFAULT '',
expires_at_ms BIGINT NOT NULL DEFAULT 0,
updated_at_ms BIGINT NOT NULL,
PRIMARY KEY (app_code, room_id, decoration_type),
KEY idx_room_decoration_resource (app_code, resource_id, room_id, decoration_type),
KEY idx_room_decoration_owner (app_code, owner_user_id, room_id, decoration_type),
KEY idx_room_decoration_expiry (app_code, expires_at_ms, room_id, decoration_type)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='房间当前 VIP 装扮反向索引';
CREATE TABLE IF NOT EXISTS room_wallet_event_consumption (
app_code VARCHAR(32) NOT NULL,
event_id VARCHAR(128) NOT NULL,
event_type VARCHAR(64) NOT NULL,
consumed_at_ms BIGINT NOT NULL,
PRIMARY KEY (app_code, event_id),
KEY idx_room_wallet_event_consumed (app_code, consumed_at_ms)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='room-service 消费 wallet 装扮事实的本地幂等表';

View File

@ -25,6 +25,7 @@ import (
"hyapp/pkg/tencentim" "hyapp/pkg/tencentim"
"hyapp/pkg/tencentrtc" "hyapp/pkg/tencentrtc"
"hyapp/pkg/usermq" "hyapp/pkg/usermq"
"hyapp/pkg/walletmq"
"hyapp/services/room-service/internal/config" "hyapp/services/room-service/internal/config"
"hyapp/services/room-service/internal/healthcheck" "hyapp/services/room-service/internal/healthcheck"
"hyapp/services/room-service/internal/integration" "hyapp/services/room-service/internal/integration"
@ -66,7 +67,7 @@ type App struct {
healthHTTP *healthhttp.Server healthHTTP *healthhttp.Server
// mqProducers 在 outbox worker 启动前打开,承载 fanout 和延迟发射唤醒。 // mqProducers 在 outbox worker 启动前打开,承载 fanout 和延迟发射唤醒。
mqProducers []*rocketmqx.Producer mqProducers []*rocketmqx.Producer
// mqConsumers 在 gRPC 服务启动前订阅,承接火箭唤醒、用户区域同步和幸运礼物 owner 事实。 // mqConsumers 在 gRPC 服务启动前订阅,承接火箭唤醒、用户区域、wallet 装扮失效和幸运礼物 owner 事实。
mqConsumers []*rocketmqx.Consumer mqConsumers []*rocketmqx.Consumer
// workers 统一控制本节点后台 worker关闭时必须先停止 worker 再释放 MySQL/Redis。 // workers 统一控制本节点后台 worker关闭时必须先停止 worker 再释放 MySQL/Redis。
workers *serviceapp.BackgroundGroup workers *serviceapp.BackgroundGroup
@ -164,7 +165,7 @@ func New(cfg config.Config) (*App, error) {
roomPublisher := integration.NewNoopRoomEventPublisher() roomPublisher := integration.NewNoopRoomEventPublisher()
outboxPublishers := make([]integration.OutboxPublisher, 0, 3) outboxPublishers := make([]integration.OutboxPublisher, 0, 3)
var rtcUserRemover integration.RTCUserRemover var rtcUserRemover integration.RTCUserRemover
var tencentPublisher *integration.TencentIMPublisher var tencentPublisher integration.OutboxPublisher
if cfg.TencentIM.Enabled { if cfg.TencentIM.Enabled {
// 腾讯云 IM 替代自研 IMroom-service 只负责服务端 REST 群消息桥接。 // 腾讯云 IM 替代自研 IMroom-service 只负责服务端 REST 群消息桥接。
tencentClient, err := tencentim.NewRESTClient(cfg.TencentIM.RESTConfig()) tencentClient, err := tencentim.NewRESTClient(cfg.TencentIM.RESTConfig())
@ -175,8 +176,16 @@ func New(cfg config.Config) (*App, error) {
_ = redisClient.Close() _ = redisClient.Close()
return nil, err return nil, err
} }
tencentPublisher = integration.NewTencentIMPublisher(tencentClient) concreteTencentPublisher := integration.NewTencentIMPublisher(tencentClient)
roomPublisher = tencentPublisher tencentPublisher = concreteTencentPublisher
roomPublisher = concreteTencentPublisher
// 图片消息和 VIP 房间装扮都是用户可见的持久事实,必须由 durable room_outbox 补偿腾讯 IM
// 只过滤这两类事件,避免把现有 direct-IM 展示事件整体接入后制造双消息。
outboxPublishers = append(outboxPublishers, integration.NewEventTypeOutboxPublisher(
concreteTencentPublisher,
"RoomImageMessageSent",
"RoomDecorationChanged",
))
} }
if cfg.TencentRTC.Enabled { if cfg.TencentRTC.Enabled {
rtcClient, err := tencentrtc.NewRESTClient(cfg.TencentRTC.RESTConfig()) rtcClient, err := tencentrtc.NewRESTClient(cfg.TencentRTC.RESTConfig())
@ -191,7 +200,7 @@ func New(cfg config.Config) (*App, error) {
} }
mqProducers := make([]*rocketmqx.Producer, 0, 2) mqProducers := make([]*rocketmqx.Producer, 0, 2)
mqConsumers := make([]*rocketmqx.Consumer, 0, 4) mqConsumers := make([]*rocketmqx.Consumer, 0, 5)
var rocketLaunchScheduler integration.RoomRocketLaunchScheduler var rocketLaunchScheduler integration.RoomRocketLaunchScheduler
if cfg.OutboxWorker.PublishMode == config.OutboxPublishModeDirect || cfg.OutboxWorker.PublishMode == config.OutboxPublishModeDual { if cfg.OutboxWorker.PublishMode == config.OutboxPublishModeDirect || cfg.OutboxWorker.PublishMode == config.OutboxPublishModeDual {
if activityConn != nil { if activityConn != nil {
@ -296,6 +305,46 @@ func New(cfg config.Config) (*App, error) {
} }
mqConsumers = append(mqConsumers, userConsumer) mqConsumers = append(mqConsumers, userConsumer)
} }
if cfg.RocketMQ.WalletOutbox.Enabled {
// 兼容 legacy Tag 与 typed Tag 的滚动窗口;独立 group 只消费会改变当前房间装扮的 owner 事实。
walletTagExpression, err := walletmq.LegacyCompatibleTagExpression(
"ResourceChanged",
"ResourceGrantRevoked",
"UserResourceChanged",
"VipEffectiveChanged",
"VipProgramChanged",
)
if err != nil {
_ = repository.Close()
closeClientConn(activityConn)
_ = walletConn.Close()
_ = redisClient.Close()
return nil, err
}
walletConsumer, err := rocketmqx.NewConsumer(rocketMQConsumerConfig(cfg.RocketMQ, cfg.RocketMQ.WalletOutbox.ConsumerGroup, cfg.RocketMQ.WalletOutbox.ConsumerMaxReconsumeTimes, 0))
if err != nil {
_ = repository.Close()
closeClientConn(activityConn)
_ = walletConn.Close()
_ = redisClient.Close()
return nil, err
}
if err := walletConsumer.Subscribe(cfg.RocketMQ.WalletOutbox.Topic, walletTagExpression, func(ctx context.Context, message rocketmqx.ConsumedMessage) error {
walletMessage, err := walletmq.DecodeWalletOutboxMessage(message.Body)
if err != nil {
return err
}
return svc.HandleWalletDecorationEvent(ctx, walletMessage)
}); err != nil {
_ = walletConsumer.Shutdown()
_ = repository.Close()
closeClientConn(activityConn)
_ = walletConn.Close()
_ = redisClient.Close()
return nil, err
}
mqConsumers = append(mqConsumers, walletConsumer)
}
if cfg.RocketMQ.LuckyGiftOutbox.Enabled { if cfg.RocketMQ.LuckyGiftOutbox.Enabled {
luckyGiftConsumer, err := rocketmqx.NewConsumer(luckyGiftRocketMQConsumerConfig(cfg.RocketMQ)) luckyGiftConsumer, err := rocketmqx.NewConsumer(luckyGiftRocketMQConsumerConfig(cfg.RocketMQ))
if err != nil { if err != nil {

View File

@ -156,6 +156,7 @@ type RocketMQConfig struct {
RoomOutbox RoomOutboxMQConfig `yaml:"room_outbox"` RoomOutbox RoomOutboxMQConfig `yaml:"room_outbox"`
RocketLaunch RocketLaunchMQConfig `yaml:"rocket_launch"` RocketLaunch RocketLaunchMQConfig `yaml:"rocket_launch"`
UserOutbox UserOutboxMQConfig `yaml:"user_outbox"` UserOutbox UserOutboxMQConfig `yaml:"user_outbox"`
WalletOutbox WalletOutboxMQConfig `yaml:"wallet_outbox"`
LuckyGiftOutbox LuckyGiftOutboxMQConfig `yaml:"lucky_gift_outbox"` LuckyGiftOutbox LuckyGiftOutboxMQConfig `yaml:"lucky_gift_outbox"`
} }
@ -191,6 +192,15 @@ type UserOutboxMQConfig struct {
ConsumerMaxReconsumeTimes int32 `yaml:"consumer_max_reconsume_times"` ConsumerMaxReconsumeTimes int32 `yaml:"consumer_max_reconsume_times"`
} }
// WalletOutboxMQConfig 控制 wallet owner 的 VIP/资源变化事实消费。
// room-service 使用独立 group 更新 Room Cell 装扮,不能复用 notice/statistics 的消费位点。
type WalletOutboxMQConfig struct {
Enabled bool `yaml:"enabled"`
Topic string `yaml:"topic"`
ConsumerGroup string `yaml:"consumer_group"`
ConsumerMaxReconsumeTimes int32 `yaml:"consumer_max_reconsume_times"`
}
// LuckyGiftOutboxMQConfig 控制 room-service 消费 lucky-gift-service 已提交的中奖事实。 // LuckyGiftOutboxMQConfig 控制 room-service 消费 lucky-gift-service 已提交的中奖事实。
// 该 consumer 只派生房间展示 outbox不读取幸运礼物库也不修改 Room Cell 状态。 // 该 consumer 只派生房间展示 outbox不读取幸运礼物库也不修改 Room Cell 状态。
type LuckyGiftOutboxMQConfig struct { type LuckyGiftOutboxMQConfig struct {
@ -381,6 +391,12 @@ func defaultRocketMQConfig() RocketMQConfig {
ConsumerGroup: "hyapp-room-user-region-sync", ConsumerGroup: "hyapp-room-user-region-sync",
ConsumerMaxReconsumeTimes: 16, ConsumerMaxReconsumeTimes: 16,
}, },
WalletOutbox: WalletOutboxMQConfig{
Enabled: false,
Topic: "hyapp_wallet_outbox",
ConsumerGroup: "hyapp-room-wallet-decoration-sync",
ConsumerMaxReconsumeTimes: 16,
},
LuckyGiftOutbox: LuckyGiftOutboxMQConfig{ LuckyGiftOutbox: LuckyGiftOutboxMQConfig{
Enabled: false, Enabled: false,
Topic: "hyapp_lucky_gift_outbox", Topic: "hyapp_lucky_gift_outbox",
@ -629,6 +645,15 @@ func normalizeRocketMQConfig(cfg RocketMQConfig, publishMode string) (RocketMQCo
if cfg.UserOutbox.ConsumerMaxReconsumeTimes <= 0 { if cfg.UserOutbox.ConsumerMaxReconsumeTimes <= 0 {
cfg.UserOutbox.ConsumerMaxReconsumeTimes = defaults.UserOutbox.ConsumerMaxReconsumeTimes cfg.UserOutbox.ConsumerMaxReconsumeTimes = defaults.UserOutbox.ConsumerMaxReconsumeTimes
} }
if cfg.WalletOutbox.Topic = strings.TrimSpace(cfg.WalletOutbox.Topic); cfg.WalletOutbox.Topic == "" {
cfg.WalletOutbox.Topic = defaults.WalletOutbox.Topic
}
if cfg.WalletOutbox.ConsumerGroup = strings.TrimSpace(cfg.WalletOutbox.ConsumerGroup); cfg.WalletOutbox.ConsumerGroup == "" {
cfg.WalletOutbox.ConsumerGroup = defaults.WalletOutbox.ConsumerGroup
}
if cfg.WalletOutbox.ConsumerMaxReconsumeTimes <= 0 {
cfg.WalletOutbox.ConsumerMaxReconsumeTimes = defaults.WalletOutbox.ConsumerMaxReconsumeTimes
}
if cfg.LuckyGiftOutbox.Topic = strings.TrimSpace(cfg.LuckyGiftOutbox.Topic); cfg.LuckyGiftOutbox.Topic == "" { if cfg.LuckyGiftOutbox.Topic = strings.TrimSpace(cfg.LuckyGiftOutbox.Topic); cfg.LuckyGiftOutbox.Topic == "" {
cfg.LuckyGiftOutbox.Topic = defaults.LuckyGiftOutbox.Topic cfg.LuckyGiftOutbox.Topic = defaults.LuckyGiftOutbox.Topic
} }
@ -638,7 +663,7 @@ func normalizeRocketMQConfig(cfg RocketMQConfig, publishMode string) (RocketMQCo
if cfg.LuckyGiftOutbox.ConsumerMaxReconsumeTimes <= 0 { if cfg.LuckyGiftOutbox.ConsumerMaxReconsumeTimes <= 0 {
cfg.LuckyGiftOutbox.ConsumerMaxReconsumeTimes = defaults.LuckyGiftOutbox.ConsumerMaxReconsumeTimes cfg.LuckyGiftOutbox.ConsumerMaxReconsumeTimes = defaults.LuckyGiftOutbox.ConsumerMaxReconsumeTimes
} }
if cfg.RoomOutbox.Enabled || cfg.RocketLaunch.Enabled || cfg.UserOutbox.Enabled || cfg.LuckyGiftOutbox.Enabled { if cfg.RoomOutbox.Enabled || cfg.RocketLaunch.Enabled || cfg.UserOutbox.Enabled || cfg.WalletOutbox.Enabled || cfg.LuckyGiftOutbox.Enabled {
cfg.Enabled = true cfg.Enabled = true
} }
if publishMode == OutboxPublishModeMQ || publishMode == OutboxPublishModeDual { if publishMode == OutboxPublishModeMQ || publishMode == OutboxPublishModeDual {

View File

@ -25,6 +25,18 @@ type WalletClient interface {
GetMyVip(ctx context.Context, req *walletv1.GetMyVipRequest) (*walletv1.GetMyVipResponse, error) GetMyVip(ctx context.Context, req *walletv1.GetMyVipRequest) (*walletv1.GetMyVipResponse, error)
} }
// WalletVIPBenefitClient 是新版 VIP 单项权益校验的可选能力。它独立于 WalletClient
// 避免房间测试里的轻量账务 fake 因新增只读 RPC 被迫实现无关方法;生产 gRPC client 必须实现。
type WalletVIPBenefitClient interface {
CheckVipBenefit(ctx context.Context, req *walletv1.CheckVipBenefitRequest) (*walletv1.CheckVipBenefitResponse, error)
}
// WalletUserResourceClient 是房间装扮读取用户有效资源的可选能力。资源归 wallet owner
// room-service 只钉住应用时的展示快照,不能复制 entitlement 有效期和数量算法。
type WalletUserResourceClient interface {
ListUserResources(ctx context.Context, req *walletv1.ListUserResourcesRequest) (*walletv1.ListUserResourcesResponse, error)
}
// LuckyGiftClient 抽象 room-service 对 lucky-gift-service 幸运礼物抽奖边界的同步依赖。 // LuckyGiftClient 抽象 room-service 对 lucky-gift-service 幸运礼物抽奖边界的同步依赖。
type LuckyGiftClient interface { type LuckyGiftClient interface {
// CheckLuckyGift 在扣费前确认奖池规则可用;失败时不能扣费。 // CheckLuckyGift 在扣费前确认奖池规则可用;失败时不能扣费。

View File

@ -50,6 +50,16 @@ func (c *grpcWalletClient) GetMyVip(ctx context.Context, req *walletv1.GetMyVipR
return c.client.GetMyVip(ctx, req) return c.client.GetMyVip(ctx, req)
} }
// CheckVipBenefit 使用 wallet 已求值的最终权益room-service 不按 VIP 等级反推。
func (c *grpcWalletClient) CheckVipBenefit(ctx context.Context, req *walletv1.CheckVipBenefitRequest) (*walletv1.CheckVipBenefitResponse, error) {
return c.client.CheckVipBenefit(ctx, req)
}
// ListUserResources 返回 wallet 判定仍有效的 entitlement 和资源目录快照。
func (c *grpcWalletClient) ListUserResources(ctx context.Context, req *walletv1.ListUserResourcesRequest) (*walletv1.ListUserResourcesResponse, error) {
return c.client.ListUserResources(ctx, req)
}
type grpcLuckyGiftClient struct { type grpcLuckyGiftClient struct {
client luckygiftv1.LuckyGiftServiceClient client luckygiftv1.LuckyGiftServiceClient
} }

View File

@ -0,0 +1,35 @@
package integration
import (
"context"
"strings"
roomeventsv1 "hyapp.local/api/proto/events/room/v1"
)
// eventTypeOutboxPublisher 只把明确需要可靠腾讯 IM 补偿的展示事件转交给下游。
// 现有房间事件大多已有 direct-IM lane不能无差别接入否则会给旧客户端制造双消息。
type eventTypeOutboxPublisher struct {
inner OutboxPublisher
allowedTypes map[string]struct{}
}
func NewEventTypeOutboxPublisher(inner OutboxPublisher, eventTypes ...string) OutboxPublisher {
allowed := make(map[string]struct{}, len(eventTypes))
for _, eventType := range eventTypes {
if eventType = strings.TrimSpace(eventType); eventType != "" {
allowed[eventType] = struct{}{}
}
}
return &eventTypeOutboxPublisher{inner: inner, allowedTypes: allowed}
}
func (p *eventTypeOutboxPublisher) PublishOutboxEvent(ctx context.Context, envelope *roomeventsv1.EventEnvelope) error {
if p == nil || p.inner == nil || envelope == nil {
return nil
}
if _, allowed := p.allowedTypes[envelope.GetEventType()]; !allowed {
return nil
}
return p.inner.PublishOutboxEvent(ctx, envelope)
}

View File

@ -0,0 +1,18 @@
package integration
import (
"context"
walletv1 "hyapp.local/api/proto/wallet/v1"
)
// WalletGiftCatalogClient 是机器人展示链路读取礼物快照的可选能力。
// RobotSendGift 只能调用这个只读目录接口,不能再借 DebitRobotGift 产生钱包账户、交易、分录或 outbox。
type WalletGiftCatalogClient interface {
ListGiftConfigs(ctx context.Context, req *walletv1.ListGiftConfigsRequest) (*walletv1.ListGiftConfigsResponse, error)
}
// ListGiftConfigs 只读取 wallet owner 的礼物目录;机器人展示使用该快照,绝不调用账务扣费接口。
func (c *grpcWalletClient) ListGiftConfigs(ctx context.Context, req *walletv1.ListGiftConfigsRequest) (*walletv1.ListGiftConfigsResponse, error) {
return c.client.ListGiftConfigs(ctx, req)
}

View File

@ -5,6 +5,7 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"strings" "strings"
"time"
"google.golang.org/protobuf/proto" "google.golang.org/protobuf/proto"
roomeventsv1 "hyapp.local/api/proto/events/room/v1" roomeventsv1 "hyapp.local/api/proto/events/room/v1"
@ -243,6 +244,46 @@ func roomEventFromEnvelope(envelope *roomeventsv1.EventEnvelope) (tencentim.Room
"background_id": fmt.Sprintf("%d", body.GetBackgroundId()), "background_id": fmt.Sprintf("%d", body.GetBackgroundId()),
"room_background_url": body.GetRoomBackgroundUrl(), "room_background_url": body.GetRoomBackgroundUrl(),
} }
appendRoomMediaAttributes(base.Attributes, body.GetMedia(), "")
return base, true, nil
case "RoomDecorationChanged":
var body roomeventsv1.RoomDecorationChanged
if err := proto.Unmarshal(envelope.GetBody(), &body); err != nil {
return tencentim.RoomEvent{}, false, err
}
if body.GetExpiresAtMs() > 0 && time.Now().UTC().UnixMilli() >= body.GetExpiresAtMs() {
// 装扮应用事实继续留在 outbox/MQ但补偿 IM 在资格到期后不再让客户端恢复失效视觉。
return tencentim.RoomEvent{}, false, nil
}
base.ActorUserID = body.GetActorUserId()
colors, err := json.Marshal(body.GetTextColors())
if err != nil {
return tencentim.RoomEvent{}, false, err
}
stops, err := json.Marshal(body.GetStops())
if err != nil {
return tencentim.RoomEvent{}, false, err
}
base.Attributes = map[string]string{
"decoration_type": body.GetDecorationType(), "resource_id": fmt.Sprintf("%d", body.GetResourceId()),
"resource_code": body.GetResourceCode(), "resource_type": body.GetResourceType(), "name": body.GetName(),
"asset_url": body.GetAssetUrl(), "preview_url": body.GetPreviewUrl(), "animation_url": body.GetAnimationUrl(),
"format": body.GetFormat(), "metadata_json": body.GetMetadataJson(), "text_colors": string(colors),
"stops": string(stops), "angle_degrees": fmt.Sprintf("%g", body.GetAngleDegrees()),
"entitlement_id": body.GetEntitlementId(), "expires_at_ms": fmt.Sprintf("%d", body.GetExpiresAtMs()),
}
return base, true, nil
case "RoomImageMessageSent":
var body roomeventsv1.RoomImageMessageSent
if err := proto.Unmarshal(envelope.GetBody(), &body); err != nil {
return tencentim.RoomEvent{}, false, err
}
base.ActorUserID = body.GetSenderUserId()
base.Attributes = map[string]string{
"message_id": body.GetMessageId(), "command_id": body.GetCommandId(),
"sender_user_id": fmt.Sprintf("%d", body.GetSenderUserId()),
}
appendRoomMediaAttributes(base.Attributes, body.GetImage(), "image_")
return base, true, nil return base, true, nil
case "RoomChatEnabledChanged": case "RoomChatEnabledChanged":
// 公屏开关事件提示客户端更新输入态,真正发言仍由 CheckSpeakPermission 拦截。 // 公屏开关事件提示客户端更新输入态,真正发言仍由 CheckSpeakPermission 拦截。
@ -321,7 +362,6 @@ func roomEventFromEnvelope(envelope *roomeventsv1.EventEnvelope) (tencentim.Room
"gift_count": fmt.Sprintf("%d", body.GetGiftCount()), "gift_count": fmt.Sprintf("%d", body.GetGiftCount()),
"coin_spent": fmt.Sprintf("%d", body.GetCoinSpent()), "coin_spent": fmt.Sprintf("%d", body.GetCoinSpent()),
"billing_receipt_id": body.GetBillingReceiptId(), "billing_receipt_id": body.GetBillingReceiptId(),
"target_gift_value": fmt.Sprintf("%d", body.GetTargetGiftValue()),
"gift_type_code": body.GetGiftTypeCode(), "gift_type_code": body.GetGiftTypeCode(),
"cp_relation_type": body.GetCpRelationType(), "cp_relation_type": body.GetCpRelationType(),
"gift_name": body.GetGiftName(), "gift_name": body.GetGiftName(),
@ -337,6 +377,14 @@ func roomEventFromEnvelope(envelope *roomeventsv1.EventEnvelope) (tencentim.Room
"receiver_display_user_id": body.GetReceiverDisplayUserId(), "receiver_display_user_id": body.GetReceiverDisplayUserId(),
"receiver_pretty_display_user_id": body.GetReceiverPrettyDisplayUserId(), "receiver_pretty_display_user_id": body.GetReceiverPrettyDisplayUserId(),
} }
if body.GetIsRobotGift() {
// 机器人礼物只用于动画和公屏展示。Flutter 会把顶层 gift_value 累加到成员贡献,
// 也会用 target_gift_value 覆盖麦位值;两者都必须从机器人协议中移除,避免假贡献和乱序回退。
base.GiftValue = 0
base.Attributes["is_robot_gift"] = "true"
} else {
base.Attributes["target_gift_value"] = fmt.Sprintf("%d", body.GetTargetGiftValue())
}
return base, true, nil return base, true, nil
case "RoomGiftBatchSent": case "RoomGiftBatchSent":
// 批量送礼展示事件只面向新客户端;逐目标 RoomGiftSent 仍由 outbox 给统计、CP 和礼物墙消费。 // 批量送礼展示事件只面向新客户端;逐目标 RoomGiftSent 仍由 outbox 给统计、CP 和礼物墙消费。
@ -448,9 +496,13 @@ func roomEventFromEnvelope(envelope *roomeventsv1.EventEnvelope) (tencentim.Room
} }
base.ActorUserID = body.GetSenderUserId() base.ActorUserID = body.GetSenderUserId()
base.TargetUserID = body.GetTargetUserId() base.TargetUserID = body.GetTargetUserId()
base.GiftValue = body.GetEffectiveRewardCoins() // 中奖金额保留在 effective_reward_coins 供动画展示;顶层 gift_value 必须为 0
// 否则 Flutter 会把纯展示中奖额累计进机器人发送者的成员贡献。
base.GiftValue = 0
base.Attributes = map[string]string{ base.Attributes = map[string]string{
"event_type": "lucky_gift_drawn", "event_type": "lucky_gift_drawn",
// RoomEvent.GiftValue=0 会被 omitempty 省略;显式扁平 gift_value=0防止 Flutter 回退读取中奖额并累计成员贡献。
"gift_value": "0",
"draw_id": body.GetDrawId(), "draw_id": body.GetDrawId(),
"command_id": body.GetCommandId(), "command_id": body.GetCommandId(),
"sender_user_id": fmt.Sprintf("%d", body.GetSenderUserId()), "sender_user_id": fmt.Sprintf("%d", body.GetSenderUserId()),
@ -615,6 +667,10 @@ func eventTypeForClient(eventType string) string {
return "room_profile_updated" return "room_profile_updated"
case "RoomBackgroundChanged": case "RoomBackgroundChanged":
return "room_background_changed" return "room_background_changed"
case "RoomDecorationChanged":
return "room_decoration_changed"
case "RoomImageMessageSent":
return "room_image_message"
case "RoomChatEnabledChanged": case "RoomChatEnabledChanged":
return "room_chat_enabled_changed" return "room_chat_enabled_changed"
case "RoomPasswordChanged": case "RoomPasswordChanged":
@ -651,3 +707,22 @@ func eventTypeForClient(eventType string) string {
return eventType return eventType
} }
} }
func appendRoomMediaAttributes(attributes map[string]string, media *roomeventsv1.RoomMediaSnapshot, prefix string) {
if attributes == nil || media == nil {
return
}
attributes[prefix+"url"] = media.GetUrl()
attributes[prefix+"object_key"] = media.GetObjectKey()
attributes[prefix+"content_type"] = media.GetContentType()
attributes[prefix+"format"] = media.GetFormat()
attributes[prefix+"size_bytes"] = fmt.Sprintf("%d", media.GetSizeBytes())
attributes[prefix+"width"] = fmt.Sprintf("%d", media.GetWidth())
attributes[prefix+"height"] = fmt.Sprintf("%d", media.GetHeight())
attributes[prefix+"animated"] = fmt.Sprintf("%t", media.GetAnimated())
attributes[prefix+"frame_count"] = fmt.Sprintf("%d", media.GetFrameCount())
attributes[prefix+"duration_ms"] = fmt.Sprintf("%d", media.GetDurationMs())
attributes[prefix+"poster_url"] = media.GetPosterUrl()
attributes[prefix+"thumbnail_url"] = media.GetThumbnailUrl()
attributes[prefix+"sha256"] = media.GetSha256()
}

View File

@ -137,6 +137,30 @@ func TestRoomGiftSentCarriesTargetGiftValueInIMAttributes(t *testing.T) {
if event.Attributes["receiver_nickname"] != "Robot Receiver" || event.Attributes["receiver_avatar"] != "https://cdn.example/receiver.png" || event.Attributes["receiver_display_user_id"] != "100002" { if event.Attributes["receiver_nickname"] != "Robot Receiver" || event.Attributes["receiver_avatar"] != "https://cdn.example/receiver.png" || event.Attributes["receiver_display_user_id"] != "100002" {
t.Fatalf("gift IM event receiver display fields mismatch: %+v", event.Attributes) t.Fatalf("gift IM event receiver display fields mismatch: %+v", event.Attributes)
} }
robotRecord, err := outbox.Build("room-robot-gift-value", "RoomGiftSent", 8, time.Now(), &roomeventsv1.RoomGiftSent{
SenderUserId: 2001,
TargetUserId: 2002,
GiftId: "gift_robot_rose",
GiftCount: 1,
GiftValue: 999,
TargetGiftValue: 888,
CoinSpent: 100,
IsRobotGift: true,
})
if err != nil {
t.Fatalf("Build robot RoomGiftSent envelope failed: %v", err)
}
robotEvent, publish, err := roomEventFromEnvelope(robotRecord.Envelope)
if err != nil || !publish {
t.Fatalf("robot RoomGiftSent should publish: publish=%t err=%v", publish, err)
}
if robotEvent.GiftValue != 0 || robotEvent.Attributes["is_robot_gift"] != "true" {
t.Fatalf("robot gift IM must expose zero business contribution: %+v", robotEvent)
}
if _, exists := robotEvent.Attributes["target_gift_value"]; exists {
t.Fatalf("robot gift IM must not overwrite authoritative target value: %+v", robotEvent.Attributes)
}
} }
func TestRoomGiftSentBatchDisplayFactSkipsSingleTargetIM(t *testing.T) { func TestRoomGiftSentBatchDisplayFactSkipsSingleTargetIM(t *testing.T) {
@ -291,16 +315,28 @@ func TestRoomRobotLuckyGiftDrawnPublishesLuckyGiftIMEvent(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("RoomRobotLuckyGiftDrawn should decode: %v", err) t.Fatalf("RoomRobotLuckyGiftDrawn should decode: %v", err)
} }
if !publish || event.EventType != "lucky_gift_drawn" || event.ActorUserID != 1001 || event.TargetUserID != 1002 { if !publish || event.EventType != "lucky_gift_drawn" || event.ActorUserID != 1001 || event.TargetUserID != 1002 || event.GiftValue != 0 {
t.Fatalf("robot lucky gift IM event mismatch: publish=%t event=%+v", publish, event) t.Fatalf("robot lucky gift IM event mismatch: publish=%t event=%+v", publish, event)
} }
if event.Attributes["draw_id"] != "robot_lucky_draw_1" || if event.Attributes["draw_id"] != "robot_lucky_draw_1" ||
event.Attributes["gift_id"] != "gift_lucky_1" || event.Attributes["gift_id"] != "gift_lucky_1" ||
event.Attributes["pool_id"] != "robot_lucky_display" || event.Attributes["pool_id"] != "robot_lucky_display" ||
event.Attributes["gift_value"] != "0" ||
event.Attributes["effective_reward_coins"] != "500" || event.Attributes["effective_reward_coins"] != "500" ||
event.Attributes["reward_status"] != "granted" { event.Attributes["reward_status"] != "granted" {
t.Fatalf("robot lucky gift IM attributes mismatch: %+v", event.Attributes) t.Fatalf("robot lucky gift IM attributes mismatch: %+v", event.Attributes)
} }
payload, err := json.Marshal(event)
if err != nil {
t.Fatalf("marshal robot lucky gift IM event failed: %v", err)
}
var flattened map[string]any
if err := json.Unmarshal(payload, &flattened); err != nil {
t.Fatalf("decode flattened robot lucky gift IM event failed: %v", err)
}
if flattened["gift_value"] != "0" || flattened["effective_reward_coins"] != "500" {
t.Fatalf("robot lucky gift JSON must keep reward display but zero contribution: %s", payload)
}
} }
func TestRoomLuckyGiftDrawnPublishesGrantedOwnerFactAsLuckyGiftIMEvent(t *testing.T) { func TestRoomLuckyGiftDrawnPublishesGrantedOwnerFactAsLuckyGiftIMEvent(t *testing.T) {

View File

@ -4,6 +4,8 @@ package command
import ( import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"hyapp/services/room-service/internal/room/state"
) )
// Command 统一表达可写入 command log 的房间命令。 // Command 统一表达可写入 command log 的房间命令。
@ -109,10 +111,48 @@ type SetRoomBackground struct {
BackgroundID int64 `json:"background_id"` BackgroundID int64 `json:"background_id"`
// BackgroundURL 是提交前从房间背景素材表解析出的确定性快照,恢复时不再回查列表表。 // BackgroundURL 是提交前从房间背景素材表解析出的确定性快照,恢复时不再回查列表表。
BackgroundURL string `json:"background_url"` BackgroundURL string `json:"background_url"`
// Background 保存完整媒体事实;旧 command log 缺字段时恢复继续使用 BackgroundID/BackgroundURL。
Background state.RoomBackground `json:"background,omitempty"`
} }
func (SetRoomBackground) Type() string { return "set_room_background" } func (SetRoomBackground) Type() string { return "set_room_background" }
// ApplyRoomDecoration 定义房主把有效 wallet entitlement 应用到指定房间的请求。
type ApplyRoomDecoration struct {
Base
// DecorationType 是客户端动作语义,只接受 room_border/colored_room_name。
DecorationType string `json:"decoration_type"`
// ResourceID/EntitlementID 是幂等载荷Resource 是 wallet 校验后固化的恢复快照。
ResourceID int64 `json:"resource_id"`
EntitlementID string `json:"entitlement_id,omitempty"`
Resource state.RoomDecoration `json:"resource,omitempty"`
}
func (ApplyRoomDecoration) Type() string { return "apply_room_decoration" }
// ReconcileRoomDecoration 由 wallet owner 事实触发,用当前 benefit/resource/entitlement 状态更新或清除房间装扮。
type ReconcileRoomDecoration struct {
Base
DecorationType string `json:"decoration_type"`
ExpectedResourceID int64 `json:"expected_resource_id"`
BenefitCode string `json:"benefit_code"`
SourceEventID string `json:"source_event_id"`
Active bool `json:"active"`
Resource state.RoomDecoration `json:"resource,omitempty"`
}
func (ReconcileRoomDecoration) Type() string { return "reconcile_room_decoration" }
// SendRoomImage 定义由 room-service 权限 owner 接受并写 outbox 的图片消息。
// 图片本身已由 gateway 专用上传入口解析,命令仍会复核归属、房态、禁言和 VIP 权益。
type SendRoomImage struct {
Base
MessageID string `json:"message_id"`
Image state.RoomMedia `json:"image"`
}
func (SendRoomImage) Type() string { return "send_room_image" }
// JoinRoom 定义业务进房请求。 // JoinRoom 定义业务进房请求。
type EntryVehicleSnapshot struct { type EntryVehicleSnapshot struct {
ResourceID int64 `json:"resource_id,omitempty"` ResourceID int64 `json:"resource_id,omitempty"`
@ -739,6 +779,13 @@ func IdempotencyPayload(payload []byte) ([]byte, error) {
delete(values, "publish_deadline_ms") delete(values, "publish_deadline_ms")
// ConfirmMicPublishing 的接受时间由 room-service 生成,不能参与客户端请求幂等语义。 // ConfirmMicPublishing 的接受时间由 room-service 生成,不能参与客户端请求幂等语义。
delete(values, "accepted_event_time_ms") delete(values, "accepted_event_time_ms")
// SetRoomBackground/ApplyRoomDecoration 的完整资源快照来自 owner 表或 wallet不属于客户端幂等载荷。
// 客户端只按 background_id 或 decoration_type/resource_id/entitlement_id 表达动作。
delete(values, "background_url")
delete(values, "background")
delete(values, "resource")
// 图片 message_id 由 room_id + command_id 确定性生成;真正的图片属性仍参与冲突判断。
delete(values, "message_id")
return json.Marshal(values) return json.Marshal(values)
} }
@ -772,6 +819,12 @@ func Deserialize(commandType string, payload []byte) (Command, error) {
cmd = &UpdateRoomProfile{} cmd = &UpdateRoomProfile{}
case SetRoomBackground{}.Type(): case SetRoomBackground{}.Type():
cmd = &SetRoomBackground{} cmd = &SetRoomBackground{}
case ApplyRoomDecoration{}.Type():
cmd = &ApplyRoomDecoration{}
case ReconcileRoomDecoration{}.Type():
cmd = &ReconcileRoomDecoration{}
case SendRoomImage{}.Type():
cmd = &SendRoomImage{}
case JoinRoom{}.Type(): case JoinRoom{}.Type():
cmd = &JoinRoom{} cmd = &JoinRoom{}
case RoomHeartbeat{}.Type(): case RoomHeartbeat{}.Type():

View File

@ -2,12 +2,14 @@ package service
import ( import (
"context" "context"
"errors"
"strings" "strings"
"time" "time"
"unicode/utf8" "unicode/utf8"
roomeventsv1 "hyapp.local/api/proto/events/room/v1" roomeventsv1 "hyapp.local/api/proto/events/room/v1"
roomv1 "hyapp.local/api/proto/room/v1" roomv1 "hyapp.local/api/proto/room/v1"
"hyapp/pkg/giftlimits"
"hyapp/pkg/roomid" "hyapp/pkg/roomid"
"hyapp/pkg/tencentim" "hyapp/pkg/tencentim"
"hyapp/pkg/xerr" "hyapp/pkg/xerr"
@ -21,28 +23,82 @@ import (
// 该动作只维护低频素材表,不改变 Room Cell 当前背景;真正生效必须调用 SetRoomBackground。 // 该动作只维护低频素材表,不改变 Room Cell 当前背景;真正生效必须调用 SetRoomBackground。
func (s *Service) SaveRoomBackground(ctx context.Context, req *roomv1.SaveRoomBackgroundRequest) (*roomv1.SaveRoomBackgroundResponse, error) { func (s *Service) SaveRoomBackground(ctx context.Context, req *roomv1.SaveRoomBackgroundRequest) (*roomv1.SaveRoomBackgroundResponse, error) {
ctx = contextFromMeta(ctx, req.GetMeta()) ctx = contextFromMeta(ctx, req.GetMeta())
roomID, actorUserID, imageURL, err := normalizeSaveRoomBackground(req) roomID, actorUserID, commandID, media, err := normalizeSaveRoomBackground(req)
if err != nil { if err != nil {
return nil, err return nil, err
} }
if commandID != "" && !giftlimits.ValidCommandID(commandID) {
return nil, xerr.New(xerr.InvalidArgument, "command_id is invalid")
}
imageURL := media.URL
replayCommandID := commandID
if replayCommandID == "" {
// legacy_timed 没有客户端 command_id这里先算出与首次提交一致的稳定键确保旧成功也能先于可变权限重放。
replayCommandID = legacyRoomBackgroundCommandID(req.GetMeta().GetAppCode(), roomID, actorUserID, imageURL)
}
if reader, ok := s.repository.(RoomBackgroundCommandReader); ok {
existing, exists, readErr := reader.GetRoomBackgroundByCommand(ctx, roomID, replayCommandID)
if readErr != nil {
return nil, readErr
}
if exists {
if !sameRoomBackgroundSaveRequest(existing, actorUserID, imageURL, media) {
return nil, xerr.New(xerr.IdempotencyConflict, "command_id payload conflict")
}
// 已成功保存是不可变事实。房间随后关闭、owner 元数据变化或 VIP 到期都不能把同一重试改成失败。
return &roomv1.SaveRoomBackgroundResponse{
Background: roomBackgroundToProto(existing),
ServerTimeMs: s.clock.Now().UnixMilli(),
}, nil
}
}
if err := s.requireRoomOwnerMeta(ctx, roomID, actorUserID); err != nil { if err := s.requireRoomOwnerMeta(ctx, roomID, actorUserID); err != nil {
return nil, err return nil, err
} }
if err := s.requireCustomRoomBackgroundBenefit(ctx, req.GetMeta().GetRequestId(), actorUserID); err != nil { access, err := s.loadEffectiveVIPAccess(ctx, req.GetMeta().GetRequestId(), actorUserID)
// 保存素材本身就会形成持久数据P1 App 必须在写库前校验当前 effective benefit。 if err != nil {
return nil, err return nil, err
} }
if access.programType == tieredPrivilegeVIPProgram {
if err := s.requireCustomRoomBackgroundBenefit(ctx, req.GetMeta().GetRequestId(), actorUserID, "save_room_background"); err != nil {
// Fami P1 的素材保存会形成持久数据,必须在写库前校验当前精确 benefit。
return nil, err
}
if !giftlimits.ValidCommandID(commandID) {
return nil, xerr.New(xerr.InvalidArgument, "command_id is invalid")
}
if err := validateRoomMedia(req.GetMeta().GetAppCode(), roomID, actorUserID, roomMediaPurposeBackground, media); err != nil {
return nil, err
}
if err := s.requireActiveRoomMediaUpload(ctx, roomID, actorUserID, roomMediaPurposeBackground, commandID, media); err != nil {
// URL/key 前缀只能证明路径归属;这里还要用 active registration 锁定首次嗅探的哈希、尺寸和格式。
return nil, err
}
} else {
// Lalu legacy_timed 保留历史 image_url-only 协议;服务端按 URL 生成稳定幂等键,空 Media 表示未经过新版专用上传。
if media.URL == "" || utf8.RuneCountInString(media.URL) > maxRoomBackgroundRunes {
return nil, xerr.New(xerr.InvalidArgument, "image_url is invalid")
}
commandID = replayCommandID
media = state.RoomMedia{}
}
nowMS := s.clock.Now().UnixMilli() nowMS := s.clock.Now().UnixMilli()
background, err := s.repository.SaveRoomBackground(ctx, RoomBackgroundImage{ background, err := s.repository.SaveRoomBackground(ctx, RoomBackgroundImage{
AppCode: req.GetMeta().GetAppCode(), AppCode: req.GetMeta().GetAppCode(),
RoomID: roomID, RoomID: roomID,
ImageURL: imageURL, ImageURL: imageURL,
CommandID: commandID,
Media: media,
CreatedByUserID: actorUserID, CreatedByUserID: actorUserID,
CreatedAtMS: nowMS, CreatedAtMS: nowMS,
UpdatedAtMS: nowMS, UpdatedAtMS: nowMS,
}) })
if err != nil { if err != nil {
if errors.Is(err, ErrRoomBackgroundCommandConflict) {
return nil, xerr.New(xerr.IdempotencyConflict, "command_id payload conflict")
}
return nil, err return nil, err
} }
@ -76,6 +132,7 @@ func (s *Service) ListRoomBackgrounds(ctx context.Context, req *roomv1.ListRoomB
return &roomv1.ListRoomBackgroundsResponse{ return &roomv1.ListRoomBackgroundsResponse{
Backgrounds: roomBackgroundsToProto(backgrounds), Backgrounds: roomBackgroundsToProto(backgrounds),
SelectedBackgroundUrl: snapshot.GetRoomExt()[roomExtBackgroundKey], SelectedBackgroundUrl: snapshot.GetRoomExt()[roomExtBackgroundKey],
SelectedBackground: snapshot.GetActiveBackground(),
ServerTimeMs: s.clock.Now().UnixMilli(), ServerTimeMs: s.clock.Now().UnixMilli(),
}, nil }, nil
} }
@ -88,21 +145,42 @@ func (s *Service) SetRoomBackground(ctx context.Context, req *roomv1.SetRoomBack
if err != nil { if err != nil {
return nil, err return nil, err
} }
if err := s.requireRoomOwnerMeta(ctx, roomID, actorUserID); err != nil { cmd := command.SetRoomBackground{
return nil, err Base: baseFromMeta(req.GetMeta()),
BackgroundID: backgroundID,
} }
background, exists, err := s.repository.GetRoomBackground(ctx, roomID, backgroundID) _, record, seen, err := s.commandRecordForIdempotency(ctx, cmd)
if err != nil { if err != nil {
return nil, err return nil, err
} }
if !exists {
return nil, xerr.New(xerr.NotFound, "room background not found")
}
cmd := command.SetRoomBackground{ var background RoomBackgroundImage
Base: baseFromMeta(req.GetMeta()), if seen {
BackgroundID: background.BackgroundID, stored, deserializeErr := command.Deserialize(record.CommandType, record.Payload)
BackgroundURL: background.ImageURL, if deserializeErr != nil {
return nil, deserializeErr
}
storedCommand, ok := stored.(*command.SetRoomBackground)
if !ok {
return nil, xerr.New(xerr.Internal, "stored room background command is invalid")
}
cmd = *storedCommand
background = roomBackgroundFromSetCommand(cmd)
} else {
if err := s.requireRoomOwnerMeta(ctx, roomID, actorUserID); err != nil {
return nil, err
}
var exists bool
background, exists, err = s.repository.GetRoomBackground(ctx, roomID, backgroundID)
if err != nil {
return nil, err
}
if !exists {
return nil, xerr.New(xerr.NotFound, "room background not found")
}
cmd.BackgroundID = background.BackgroundID
cmd.BackgroundURL = background.ImageURL
cmd.Background = roomBackgroundState(background)
} }
result, err := s.mutateRoom(ctx, cmd, true, func(now time.Time, current *state.RoomState, _ *rank.LocalRank) (mutationResult, []outbox.Record, error) { result, err := s.mutateRoom(ctx, cmd, true, func(now time.Time, current *state.RoomState, _ *rank.LocalRank) (mutationResult, []outbox.Record, error) {
if err := requireActiveRoom(current); err != nil { if err := requireActiveRoom(current); err != nil {
@ -111,23 +189,31 @@ func (s *Service) SetRoomBackground(ctx context.Context, req *roomv1.SetRoomBack
if current.OwnerUserID != cmd.ActorUserID() { if current.OwnerUserID != cmd.ActorUserID() {
return mutationResult{}, nil, xerr.New(xerr.PermissionDenied, "permission denied") return mutationResult{}, nil, xerr.New(xerr.PermissionDenied, "permission denied")
} }
if err := s.requireCustomRoomBackgroundBenefit(ctx, req.GetMeta().GetRequestId(), cmd.ActorUserID()); err != nil { if err := s.requireCustomRoomBackgroundBenefit(ctx, req.GetMeta().GetRequestId(), cmd.ActorUserID(), "set_room_background"); err != nil {
// 放在 mutate 回调内可让同一 command_id 的已提交重试先命中幂等,不会因 VIP 后续到期改写旧命令结果。 // 放在 mutate 回调内可让同一 command_id 的已提交重试先命中幂等,不会因 VIP 后续到期改写旧命令结果。
return mutationResult{}, nil, err return mutationResult{}, nil, err
} }
if current.RoomExt == nil { if current.RoomExt == nil {
current.RoomExt = make(map[string]string) current.RoomExt = make(map[string]string)
} }
if current.RoomExt[roomExtBackgroundKey] == cmd.BackgroundURL { if (current.ActiveBackground != nil && current.ActiveBackground.BackgroundID == cmd.BackgroundID) ||
(current.ActiveBackground == nil && current.RoomExt[roomExtBackgroundKey] == cmd.BackgroundURL) {
return mutationResult{snapshot: current.ToProto()}, nil, nil return mutationResult{snapshot: current.ToProto()}, nil, nil
} }
current.RoomExt[roomExtBackgroundKey] = cmd.BackgroundURL current.RoomExt[roomExtBackgroundKey] = cmd.BackgroundURL
if cmd.Background.Media.URL != "" {
backgroundSnapshot := cmd.Background
current.ActiveBackground = &backgroundSnapshot
} else {
current.ActiveBackground = nil
}
current.Version++ current.Version++
backgroundEvent, err := outbox.Build(current.RoomID, "RoomBackgroundChanged", current.Version, now, &roomeventsv1.RoomBackgroundChanged{ backgroundEvent, err := outbox.Build(current.RoomID, "RoomBackgroundChanged", current.Version, now, &roomeventsv1.RoomBackgroundChanged{
ActorUserId: cmd.ActorUserID(), ActorUserId: cmd.ActorUserID(),
BackgroundId: cmd.BackgroundID, BackgroundId: cmd.BackgroundID,
RoomBackgroundUrl: current.RoomExt[roomExtBackgroundKey], RoomBackgroundUrl: current.RoomExt[roomExtBackgroundKey],
Media: roomMediaEventSnapshot(cmd.Background.Media),
}) })
if err != nil { if err != nil {
return mutationResult{}, nil, err return mutationResult{}, nil, err
@ -158,29 +244,66 @@ func (s *Service) SetRoomBackground(ctx context.Context, req *roomv1.SetRoomBack
}, nil }, nil
} }
func normalizeSaveRoomBackground(req *roomv1.SaveRoomBackgroundRequest) (string, int64, string, error) { func sameRoomBackgroundSaveRequest(existing RoomBackgroundImage, actorUserID int64, imageURL string, media state.RoomMedia) bool {
if existing.CreatedByUserID != actorUserID || strings.TrimSpace(existing.ImageURL) != strings.TrimSpace(imageURL) {
return false
}
if strings.TrimSpace(media.ObjectKey) != "" {
// 新版媒体事实全部由 gateway 解析并由 room-service 校验;任一字段变化都代表 command_id 被复用。
return existing.Media == media
}
legacyCandidate := media
legacyCandidate.URL = ""
// legacy_timed 只接受 image_url其余媒体字段必须同时为空不能借旧成功绕过新版专用上传校验。
return existing.Media == (state.RoomMedia{}) && legacyCandidate == (state.RoomMedia{})
}
func roomBackgroundFromSetCommand(cmd command.SetRoomBackground) RoomBackgroundImage {
backgroundID := cmd.Background.BackgroundID
if backgroundID <= 0 {
backgroundID = cmd.BackgroundID
}
roomID := strings.TrimSpace(cmd.Background.RoomID)
if roomID == "" {
roomID = cmd.RoomID()
}
createdByUserID := cmd.Background.CreatedByUserID
if createdByUserID <= 0 {
createdByUserID = cmd.ActorUserID()
}
// command log 固化第一次应用时的素材快照;旧记录没有 Background 时继续用 BackgroundURL 返回历史结果。
return RoomBackgroundImage{
AppCode: cmd.AppCode, BackgroundID: backgroundID, RoomID: roomID,
ImageURL: strings.TrimSpace(cmd.BackgroundURL), CommandID: cmd.Background.CommandID, Media: cmd.Background.Media,
CreatedByUserID: createdByUserID, CreatedAtMS: cmd.Background.CreatedAtMS, UpdatedAtMS: cmd.Background.UpdatedAtMS,
}
}
func normalizeSaveRoomBackground(req *roomv1.SaveRoomBackgroundRequest) (string, int64, string, state.RoomMedia, error) {
if req == nil || req.GetMeta() == nil { if req == nil || req.GetMeta() == nil {
return "", 0, "", xerr.New(xerr.InvalidArgument, "request is required") return "", 0, "", state.RoomMedia{}, xerr.New(xerr.InvalidArgument, "request is required")
} }
roomID := strings.TrimSpace(req.GetRoomId()) roomID := strings.TrimSpace(req.GetRoomId())
if roomID == "" { if roomID == "" {
roomID = strings.TrimSpace(req.GetMeta().GetRoomId()) roomID = strings.TrimSpace(req.GetMeta().GetRoomId())
} }
if !roomid.ValidStringID(roomID) { if !roomid.ValidStringID(roomID) {
return "", 0, "", xerr.New(xerr.InvalidArgument, "room_id is invalid") return "", 0, "", state.RoomMedia{}, xerr.New(xerr.InvalidArgument, "room_id is invalid")
} }
actorUserID := req.GetMeta().GetActorUserId() actorUserID := req.GetMeta().GetActorUserId()
if actorUserID <= 0 { if actorUserID <= 0 {
return "", 0, "", xerr.New(xerr.InvalidArgument, "actor_user_id is required") return "", 0, "", state.RoomMedia{}, xerr.New(xerr.InvalidArgument, "actor_user_id is required")
} }
imageURL := strings.TrimSpace(req.GetImageUrl()) commandID := strings.TrimSpace(req.GetMeta().GetCommandId())
if imageURL == "" { media := roomMediaFromProto(req.GetMedia())
return "", 0, "", xerr.New(xerr.InvalidArgument, "image_url is required") requestImageURL := strings.TrimSpace(req.GetImageUrl())
if media.URL != "" && requestImageURL != "" && media.URL != requestImageURL {
return "", 0, "", state.RoomMedia{}, xerr.New(xerr.InvalidArgument, "image_url does not match media.url")
} }
if utf8.RuneCountInString(imageURL) > maxRoomBackgroundRunes { if media.URL == "" {
return "", 0, "", xerr.New(xerr.InvalidArgument, "image_url is too long") media.URL = requestImageURL
} }
return roomID, actorUserID, imageURL, nil return roomID, actorUserID, commandID, media, nil
} }
func normalizeListRoomBackgrounds(req *roomv1.ListRoomBackgroundsRequest) (string, int64, error) { func normalizeListRoomBackgrounds(req *roomv1.ListRoomBackgroundsRequest) (string, int64, error) {
@ -235,25 +358,3 @@ func (s *Service) requireRoomOwnerMeta(ctx context.Context, roomID string, actor
} }
return nil return nil
} }
func roomBackgroundToProto(background RoomBackgroundImage) *roomv1.RoomBackgroundImage {
if background.BackgroundID == 0 {
return nil
}
return &roomv1.RoomBackgroundImage{
BackgroundId: background.BackgroundID,
RoomId: background.RoomID,
ImageUrl: background.ImageURL,
CreatedByUserId: background.CreatedByUserID,
CreatedAtMs: background.CreatedAtMS,
UpdatedAtMs: background.UpdatedAtMS,
}
}
func roomBackgroundsToProto(backgrounds []RoomBackgroundImage) []*roomv1.RoomBackgroundImage {
out := make([]*roomv1.RoomBackgroundImage, 0, len(backgrounds))
for _, background := range backgrounds {
out = append(out, roomBackgroundToProto(background))
}
return out
}

View File

@ -0,0 +1,290 @@
package service
import (
"context"
"encoding/json"
"math"
"reflect"
"regexp"
"strconv"
"strings"
"time"
roomeventsv1 "hyapp.local/api/proto/events/room/v1"
roomv1 "hyapp.local/api/proto/room/v1"
walletv1 "hyapp.local/api/proto/wallet/v1"
"hyapp/pkg/appcode"
"hyapp/pkg/xerr"
"hyapp/services/room-service/internal/integration"
"hyapp/services/room-service/internal/room/command"
"hyapp/services/room-service/internal/room/outbox"
"hyapp/services/room-service/internal/room/rank"
"hyapp/services/room-service/internal/room/state"
)
const (
roomDecorationTypeBorder = "room_border"
roomDecorationTypeColoredName = "colored_room_name"
roomDecorationResourceBorder = "room_border"
roomDecorationResourceName = "room_name_style"
)
var roomDecorationColorPattern = regexp.MustCompile(`^#[0-9a-fA-F]{6}([0-9a-fA-F]{2})?$`)
type roomDecorationMetadata struct {
Format string `json:"format"`
TextColors []string `json:"text_colors"`
TextColorsLegacy []string `json:"textColors"`
Stops []float64 `json:"stops"`
AngleDegrees *float64 `json:"angle_degrees"`
AngleLegacy *float64 `json:"angleDegrees"`
}
// ApplyRoomDecoration 把 wallet 当前仍授权的资源快照串行写入 Room Cell。
// entitlement 与 VIP benefit 都必须精确绑定同一 resource防止会员降级后复用历史高等级样式。
func (s *Service) ApplyRoomDecoration(ctx context.Context, req *roomv1.ApplyRoomDecorationRequest) (*roomv1.ApplyRoomDecorationResponse, error) {
ctx = contextFromMeta(ctx, req.GetMeta())
decorationType, resourceType, benefitCode, err := normalizeRoomDecorationRequest(req)
if err != nil {
return nil, err
}
cmd := command.ApplyRoomDecoration{
Base: baseFromMeta(req.GetMeta()), DecorationType: decorationType,
ResourceID: req.GetResourceId(), EntitlementID: strings.TrimSpace(req.GetEntitlementId()),
}
// 已提交命令必须先于 wallet 当前态重放;否则 VIP/entitlement 后续到期会把合法重试改成失败。
if _, record, seen, checkErr := s.commandRecordForIdempotency(ctx, cmd); checkErr != nil {
return nil, checkErr
} else if seen {
stored, deserializeErr := command.Deserialize(record.CommandType, record.Payload)
if deserializeErr != nil {
return nil, deserializeErr
}
storedCommand, ok := stored.(*command.ApplyRoomDecoration)
if !ok {
return nil, xerr.New(xerr.Internal, "stored room decoration command is invalid")
}
cmd = *storedCommand
} else {
if err := s.requireRoomOwnerMeta(ctx, cmd.RoomID(), cmd.ActorUserID()); err != nil {
return nil, err
}
resource, resolveErr := s.resolveRoomDecoration(ctx, req.GetMeta().GetRequestId(), cmd.ActorUserID(), cmd.ResourceID, cmd.EntitlementID, resourceType, benefitCode)
if resolveErr != nil {
return nil, resolveErr
}
cmd.Resource = resource
}
result, err := s.mutateRoom(ctx, cmd, true, func(now time.Time, current *state.RoomState, _ *rank.LocalRank) (mutationResult, []outbox.Record, error) {
if err := requireActiveRoom(current); err != nil {
return mutationResult{}, nil, err
}
if current.OwnerUserID != cmd.ActorUserID() {
return mutationResult{}, nil, xerr.New(xerr.PermissionDenied, "permission denied")
}
if cmd.Resource.ResourceID <= 0 || (cmd.Resource.ExpiresAtMS > 0 && now.UnixMilli() >= cmd.Resource.ExpiresAtMS) {
return mutationResult{}, nil, xerr.NewWithMetadata(xerr.VIPBenefitRequired, "room decoration entitlement expired", map[string]string{
"benefit_code": benefitCode, "required_level": "0", "current_effective_level": "0", "action": "apply_room_decoration",
})
}
var currentDecoration **state.RoomDecoration
switch cmd.DecorationType {
case roomDecorationTypeBorder:
currentDecoration = &current.RoomBorder
case roomDecorationTypeColoredName:
currentDecoration = &current.RoomNameStyle
default:
return mutationResult{}, nil, xerr.New(xerr.InvalidArgument, "decoration_type is invalid")
}
assignmentChange := roomDecorationAssignmentChange(current, cmd.DecorationType, benefitCode, cmd.Resource, now.UnixMilli())
if *currentDecoration != nil && reflect.DeepEqual(**currentDecoration, cmd.Resource) {
return mutationResult{snapshot: current.ToProtoAt(now.UnixMilli()), roomDecorationAssignment: assignmentChange}, nil, nil
}
decoration := cmd.Resource
*currentDecoration = &decoration
current.Version++
event, err := outbox.Build(current.RoomID, "RoomDecorationChanged", current.Version, now, &roomeventsv1.RoomDecorationChanged{
ActorUserId: cmd.ActorUserID(), DecorationType: cmd.DecorationType, ResourceId: decoration.ResourceID,
ResourceCode: decoration.ResourceCode, ResourceType: decoration.ResourceType, Name: decoration.Name,
AssetUrl: decoration.AssetURL, PreviewUrl: decoration.PreviewURL, AnimationUrl: decoration.AnimationURL,
Format: decoration.Format, MetadataJson: decoration.MetadataJSON, TextColors: append([]string(nil), decoration.TextColors...),
Stops: append([]float64(nil), decoration.Stops...), AngleDegrees: decoration.AngleDegrees,
EntitlementId: decoration.EntitlementID, ExpiresAtMs: decoration.ExpiresAtMS,
})
if err != nil {
return mutationResult{}, nil, err
}
return mutationResult{snapshot: current.ToProtoAt(now.UnixMilli()), roomDecorationAssignment: assignmentChange}, []outbox.Record{event}, nil
})
if err != nil {
return nil, err
}
resource := roomDecorationToProto(&cmd.Resource, s.clock.Now().UnixMilli())
return &roomv1.ApplyRoomDecorationResponse{
Result: commandResult(result.applied, result.snapshot.GetVersion(), s.clock.Now()), Room: result.snapshot,
DecorationType: decorationType, Resource: resource, ServerTimeMs: s.clock.Now().UnixMilli(),
}, nil
}
func roomDecorationAssignmentChange(current *state.RoomState, decorationType string, benefitCode string, resource state.RoomDecoration, nowMS int64) *RoomDecorationAssignmentChange {
assignment := &RoomDecorationAssignment{
// AppCode 由 SaveMutation 使用当前请求上下文写入,避免把租户字段复制进 RoomState。
RoomID: current.RoomID, DecorationType: decorationType, OwnerUserID: current.OwnerUserID,
BenefitCode: benefitCode, ResourceID: resource.ResourceID, EntitlementID: resource.EntitlementID,
ExpiresAtMS: resource.ExpiresAtMS, UpdatedAtMS: nowMS,
}
return &RoomDecorationAssignmentChange{DecorationType: decorationType, Assignment: assignment}
}
func normalizeRoomDecorationRequest(req *roomv1.ApplyRoomDecorationRequest) (string, string, string, error) {
if req == nil || req.GetMeta() == nil || strings.TrimSpace(req.GetMeta().GetCommandId()) == "" || req.GetResourceId() <= 0 {
return "", "", "", xerr.New(xerr.InvalidArgument, "room decoration request is incomplete")
}
switch normalizeVIPCode(req.GetDecorationType()) {
case roomDecorationTypeBorder:
return roomDecorationTypeBorder, roomDecorationResourceBorder, vipBenefitRoomBorder, nil
case roomDecorationTypeColoredName:
return roomDecorationTypeColoredName, roomDecorationResourceName, vipBenefitColoredRoomName, nil
default:
return "", "", "", xerr.New(xerr.InvalidArgument, "decoration_type is invalid")
}
}
func (s *Service) resolveRoomDecoration(ctx context.Context, requestID string, userID int64, resourceID int64, entitlementID string, resourceType string, benefitCode string) (state.RoomDecoration, error) {
checker, ok := s.wallet.(integration.WalletVIPBenefitClient)
if !ok {
return state.RoomDecoration{}, xerr.New(xerr.Unavailable, "wallet vip benefit client is unavailable")
}
lookupCtx, cancel := context.WithTimeout(ctx, roomVIPLookupTimeout)
defer cancel()
decision, err := checker.CheckVipBenefit(lookupCtx, &walletv1.CheckVipBenefitRequest{
RequestId: strings.TrimSpace(requestID), AppCode: appcode.FromContext(ctx), UserId: userID, BenefitCode: benefitCode,
})
if err != nil {
return state.RoomDecoration{}, xerr.New(xerr.Unavailable, "check room decoration vip benefit failed")
}
benefit := decision.GetBenefit()
catalogResource := benefit.GetResource()
if decision == nil || !decision.GetAllowed() || benefit == nil || benefit.GetResourceId() != resourceID ||
normalizeVIPCode(benefit.GetResourceType()) != resourceType || catalogResource == nil ||
catalogResource.GetResourceId() != resourceID || normalizeVIPCode(catalogResource.GetResourceType()) != resourceType ||
normalizeVIPCode(catalogResource.GetStatus()) != "active" {
requiredLevel, currentLevel := int32(0), int32(0)
if decision != nil {
requiredLevel, currentLevel = decision.GetRequiredLevel(), decision.GetCurrentEffectiveLevel()
}
return state.RoomDecoration{}, xerr.NewWithMetadata(xerr.VIPBenefitRequired, "room decoration vip benefit is required", map[string]string{
"benefit_code": benefitCode, "required_level": strconv.FormatInt(int64(requiredLevel), 10),
"current_effective_level": strconv.FormatInt(int64(currentLevel), 10), "action": "apply_room_decoration",
})
}
resources, ok := s.wallet.(integration.WalletUserResourceClient)
if !ok {
return state.RoomDecoration{}, xerr.New(xerr.Unavailable, "wallet user resource client is unavailable")
}
resourceCtx, resourceCancel := context.WithTimeout(ctx, roomVIPLookupTimeout)
defer resourceCancel()
resourceResponse, err := resources.ListUserResources(resourceCtx, &walletv1.ListUserResourcesRequest{
RequestId: strings.TrimSpace(requestID), AppCode: appcode.FromContext(ctx), UserId: userID,
ResourceType: resourceType, ActiveOnly: true,
})
if err != nil {
return state.RoomDecoration{}, xerr.New(xerr.Unavailable, "list room decoration resources failed")
}
nowMS := s.clock.Now().UnixMilli()
for _, entitlement := range resourceResponse.GetResources() {
entitlementResource := entitlement.GetResource()
if entitlement.GetResourceId() != resourceID || entitlementResource == nil || entitlementResource.GetResourceId() != resourceID ||
normalizeVIPCode(entitlementResource.GetResourceType()) != resourceType {
continue
}
if entitlementID != "" && strings.TrimSpace(entitlement.GetEntitlementId()) != entitlementID {
continue
}
if normalizeVIPCode(entitlement.GetStatus()) != "active" || entitlement.GetEffectiveAtMs() > nowMS ||
(entitlement.GetExpiresAtMs() > 0 && entitlement.GetExpiresAtMs() <= nowMS) || entitlement.GetRemainingQuantity() <= 0 {
continue
}
// entitlement 只证明该用户仍有权使用 resource_id展示内容必须取 CheckVipBenefit 返回的
// 当前 active 目录资源。这样后台更新素材后会刷新房间,且不会继续渲染 entitlement 的历史快照。
metadata, metadataErr := parseRoomDecorationMetadata(resourceType, catalogResource.GetMetadataJson())
if metadataErr != nil {
return state.RoomDecoration{}, metadataErr
}
vipExpiresAtMS := int64(0)
if decision.GetState() != nil && decision.GetState().GetEffectiveVip() != nil {
vipExpiresAtMS = decision.GetState().GetEffectiveVip().GetExpiresAtMs()
}
angleDegrees := float64(0)
if metadata.AngleDegrees != nil {
angleDegrees = *metadata.AngleDegrees
}
return state.RoomDecoration{
ResourceID: catalogResource.ResourceId, ResourceCode: catalogResource.ResourceCode, ResourceType: catalogResource.ResourceType,
Name: catalogResource.Name, AssetURL: catalogResource.AssetUrl, PreviewURL: catalogResource.PreviewUrl, AnimationURL: catalogResource.AnimationUrl,
Format: metadata.Format, MetadataJSON: catalogResource.MetadataJson, EntitlementID: entitlement.EntitlementId,
TextColors: metadata.TextColors, Stops: metadata.Stops, AngleDegrees: angleDegrees,
ExpiresAtMS: minNonZeroMS(entitlement.GetExpiresAtMs(), vipExpiresAtMS),
}, nil
}
return state.RoomDecoration{}, xerr.New(xerr.NotFound, "active room decoration entitlement not found")
}
func parseRoomDecorationMetadata(resourceType string, raw string) (roomDecorationMetadata, error) {
var metadata roomDecorationMetadata
if strings.TrimSpace(raw) != "" && json.Unmarshal([]byte(raw), &metadata) != nil {
return roomDecorationMetadata{}, xerr.New(xerr.InvalidArgument, "room decoration metadata_json is invalid")
}
if len(metadata.TextColors) == 0 {
metadata.TextColors = metadata.TextColorsLegacy
}
if metadata.AngleDegrees == nil {
metadata.AngleDegrees = metadata.AngleLegacy
}
metadata.Format = strings.ToLower(strings.TrimSpace(metadata.Format))
if metadata.AngleDegrees != nil && (math.IsNaN(*metadata.AngleDegrees) || math.IsInf(*metadata.AngleDegrees, 0)) {
return roomDecorationMetadata{}, xerr.New(xerr.InvalidArgument, "room decoration angle is invalid")
}
if resourceType == roomDecorationResourceName {
if len(metadata.TextColors) == 0 || len(metadata.TextColors) > 8 || (len(metadata.Stops) != 0 && len(metadata.Stops) != len(metadata.TextColors)) {
return roomDecorationMetadata{}, xerr.New(xerr.InvalidArgument, "room name style colors are invalid")
}
for _, color := range metadata.TextColors {
if !roomDecorationColorPattern.MatchString(strings.TrimSpace(color)) {
return roomDecorationMetadata{}, xerr.New(xerr.InvalidArgument, "room name style color is invalid")
}
}
previous := -1.0
for _, stop := range metadata.Stops {
if math.IsNaN(stop) || math.IsInf(stop, 0) || stop < 0 || stop > 1 || stop < previous {
return roomDecorationMetadata{}, xerr.New(xerr.InvalidArgument, "room name style stops are invalid")
}
previous = stop
}
}
return metadata, nil
}
func minNonZeroMS(left int64, right int64) int64 {
if left <= 0 {
return right
}
if right <= 0 || left < right {
return left
}
return right
}
func roomDecorationToProto(resource *state.RoomDecoration, nowMS int64) *roomv1.RoomDecorationResource {
if resource == nil || resource.ResourceID <= 0 || (resource.ExpiresAtMS > 0 && nowMS >= resource.ExpiresAtMS) {
return nil
}
return &roomv1.RoomDecorationResource{
ResourceId: resource.ResourceID, ResourceCode: resource.ResourceCode, ResourceType: resource.ResourceType,
Name: resource.Name, AssetUrl: resource.AssetURL, PreviewUrl: resource.PreviewURL, AnimationUrl: resource.AnimationURL,
Format: resource.Format, MetadataJson: resource.MetadataJSON, EntitlementId: resource.EntitlementID,
TextColors: append([]string(nil), resource.TextColors...), Stops: append([]float64(nil), resource.Stops...),
AngleDegrees: resource.AngleDegrees, ExpiresAtMs: resource.ExpiresAtMS,
}
}

View File

@ -4,6 +4,7 @@ import (
"context" "context"
"time" "time"
"hyapp/pkg/xerr"
"hyapp/services/room-service/internal/room/outbox" "hyapp/services/room-service/internal/room/outbox"
"hyapp/services/room-service/internal/room/rank" "hyapp/services/room-service/internal/room/rank"
"hyapp/services/room-service/internal/room/state" "hyapp/services/room-service/internal/room/state"
@ -41,6 +42,11 @@ func (s *Service) sendGift(ctx context.Context, req *roomv1.SendGiftRequest, opt
} }
func (s *Service) sendGiftWithRecovery(ctx context.Context, req *roomv1.SendGiftRequest, options giftSendOptions, recovery GiftOperation) (*roomv1.SendGiftResponse, error) { func (s *Service) sendGiftWithRecovery(ctx context.Context, req *roomv1.SendGiftRequest, options giftSendOptions, recovery GiftOperation) (*roomv1.SendGiftResponse, error) {
if options.Robot.Enabled && recovery.CommandID == "" {
// 新机器人礼物只能走 RobotSendGift 的纯展示通道。这里 fail-close防止后续调用方误把机器人重新接回
// room_gift_operations、Room Cell 持久化、钱包账本或 MQ已有历史 saga 恢复不依赖 options.Robot。
return nil, xerr.New(xerr.InvalidArgument, "robot gift must use display-only path")
}
ctx = contextFromMeta(ctx, req.GetMeta()) ctx = contextFromMeta(ctx, req.GetMeta())
flow := newGiftFlow(s, ctx, req, options) flow := newGiftFlow(s, ctx, req, options)
if recovery.CommandID != "" { if recovery.CommandID != "" {

View File

@ -87,16 +87,17 @@ func newGiftFlow(s *Service, ctx context.Context, req *roomv1.SendGiftRequest, o
SenderRegisteredAtMS: req.GetSenderRegisteredAtMs(), SenderRegisteredAtMS: req.GetSenderRegisteredAtMs(),
TargetIsHost: req.GetTargetIsHost(), TargetIsHost: req.GetTargetIsHost(),
// 工资区域由 gateway 从 user-service host profile 注入;房间 visible_region_id 只用于房间/礼物可见性。 // 工资区域由 gateway 从 user-service host profile 注入;房间 visible_region_id 只用于房间/礼物可见性。
TargetHostRegionID: req.GetTargetHostRegionId(), TargetHostRegionID: req.GetTargetHostRegionId(),
TargetAgencyOwnerUserID: req.GetTargetAgencyOwnerUserId(), TargetAgencyOwnerUserID: req.GetTargetAgencyOwnerUserId(),
TargetHostScopes: giftTargetHostScopesFromProto(req.GetTargetHostScopes()), TargetHostScopes: giftTargetHostScopesFromProto(req.GetTargetHostScopes()),
SenderDisplayProfile: giftDisplayProfileFromProto(req.GetSenderDisplayProfile()), SenderDisplayProfile: giftDisplayProfileFromProto(req.GetSenderDisplayProfile()),
TargetDisplayProfiles: giftDisplayProfilesFromProto(req.GetTargetDisplayProfiles()), TargetDisplayProfiles: giftDisplayProfilesFromProto(req.GetTargetDisplayProfiles()),
RobotGift: options.Robot.Enabled && !options.Robot.RealRoomHeat, RobotGift: options.Robot.Enabled && !options.Robot.RealRoomHeat,
RobotWalletGift: options.Robot.Enabled, RobotWalletGift: options.Robot.Enabled,
SyntheticLuckyGift: options.Robot.Enabled && strings.TrimSpace(req.GetPoolId()) != "", SyntheticLuckyGift: options.Robot.Enabled && strings.TrimSpace(req.GetPoolId()) != "",
SyntheticLuckyRewardCoins: firstPositiveInt64(options.Robot.SyntheticRewardCoins, 0), SyntheticLuckyRewardCoins: firstPositiveInt64(options.Robot.SyntheticRewardCoins, 0),
SyntheticLuckyMultiplierPPM: firstPositiveInt64(options.Robot.SyntheticMultiplierPPM, 1000000), // 0 留给 syntheticLuckyGiftResult 统一选择 5x 默认值;这里提前写成 1x 会让倍数和展示中奖额互相矛盾。
SyntheticLuckyMultiplierPPM: firstPositiveInt64(options.Robot.SyntheticMultiplierPPM, 0),
} }
if cmd.TargetType == "" { if cmd.TargetType == "" {
cmd.TargetType = "user" cmd.TargetType = "user"

View File

@ -3,6 +3,8 @@ package service
import ( import (
"context" "context"
"fmt" "fmt"
"math"
"math/bits"
"strings" "strings"
"time" "time"
@ -125,7 +127,9 @@ func luckyGiftMetaForTarget(ctx context.Context, meta *roomv1.RequestMeta, cmd c
Recharge_7DCoins: targetBilling.Billing.GetRechargeSevenDayCoins(), Recharge_7DCoins: targetBilling.Billing.GetRechargeSevenDayCoins(),
Recharge_30DCoins: targetBilling.Billing.GetRechargeThirtyDayCoins(), Recharge_30DCoins: targetBilling.Billing.GetRechargeThirtyDayCoins(),
LastRechargedAtMs: targetBilling.Billing.GetLastRechargedAtMs(), LastRechargedAtMs: targetBilling.Billing.GetLastRechargedAtMs(),
GiftIncomeCoins: targetBilling.Billing.GetGiftIncomeCoinAmount(), // LuckyGiftMeta.gift_income_coins 是滚动兼容保留的旧字段名。这里必须传 wallet 已按后台
// “主播钻石比例”实际入账的钻石,不能误传独立的“返还金币比例”结果,否则 0% 返币会阻断开奖。
GiftIncomeCoins: targetBilling.Billing.GetHostPeriodDiamondAdded(),
}, nil }, nil
} }
@ -276,8 +280,11 @@ func syntheticLuckyGiftResult(cmd command.SendGift, targetBillings []giftTargetB
if len(targetBillings) == 0 || targetBillings[0].Billing == nil { if len(targetBillings) == 0 || targetBillings[0].Billing == nil {
return nil return nil
} }
rewardCoins := firstPositiveInt64(cmd.SyntheticLuckyRewardCoins, targetBillings[0].Billing.GetCoinSpent()*5) coinSpent := targetBillings[0].Billing.GetCoinSpent()
multiplier := firstPositiveInt64(cmd.SyntheticLuckyMultiplierPPM, 5000000) multiplier := firstPositiveInt64(cmd.SyntheticLuckyMultiplierPPM, 5000000)
// 未显式指定中奖额时必须按同一 multiplier 推导,避免 20x 文案却展示 5x 金额;计算饱和防止异常配置溢出。
defaultRewardCoins := scaledRobotRewardCoins(coinSpent, multiplier)
rewardCoins := firstPositiveInt64(cmd.SyntheticLuckyRewardCoins, defaultRewardCoins)
drawID := fmt.Sprintf("robot_lucky_%s_%d", cmd.ID(), now.UnixMilli()) drawID := fmt.Sprintf("robot_lucky_%s_%d", cmd.ID(), now.UnixMilli())
return &roomv1.LuckyGiftDrawResult{ return &roomv1.LuckyGiftDrawResult{
Enabled: true, Enabled: true,
@ -299,6 +306,22 @@ func syntheticLuckyGiftResult(cmd command.SendGift, targetBillings []giftTargetB
} }
} }
func scaledRobotRewardCoins(coinSpent int64, multiplierPPM int64) int64 {
if coinSpent <= 0 || multiplierPPM <= 0 {
return 0
}
// bits.Mul64 + Div64 完成无符号 128 位乘除;高 64 位已不小于除数时,商超出 uint64直接饱和。
high, low := bits.Mul64(uint64(coinSpent), uint64(multiplierPPM))
if high >= 1_000_000 {
return math.MaxInt64
}
reward, _ := bits.Div64(high, low, 1_000_000)
if reward > math.MaxInt64 {
return math.MaxInt64
}
return int64(reward)
}
func giftFinalCoinBalanceAfter(billing *walletv1.DebitGiftResponse) int64 { func giftFinalCoinBalanceAfter(billing *walletv1.DebitGiftResponse) int64 {
if billing == nil { if billing == nil {
return 0 return 0

View File

@ -2,13 +2,46 @@ package service
import ( import (
"context" "context"
"math"
"strings" "strings"
"time"
roomeventsv1 "hyapp.local/api/proto/events/room/v1"
roomv1 "hyapp.local/api/proto/room/v1" roomv1 "hyapp.local/api/proto/room/v1"
walletv1 "hyapp.local/api/proto/wallet/v1"
"hyapp/pkg/appcode"
"hyapp/pkg/giftlimits"
"hyapp/pkg/xerr" "hyapp/pkg/xerr"
"hyapp/services/room-service/internal/integration"
"hyapp/services/room-service/internal/room/outbox"
"hyapp/services/room-service/internal/room/state" "hyapp/services/room-service/internal/room/state"
) )
const (
// 机器人送礼量远高于礼物配置变更频率;短缓存把每次展示的 wallet/MySQL 只读查询收敛为每 App/区域每分钟一次。
robotGiftCatalogTTL = time.Minute
// wallet 故障只短暂负缓存,既让同批机器人共享失败,也能在服务恢复后迅速重试。
robotGiftCatalogErrorTTL = time.Second
robotGiftCatalogQueryTimeout = 3 * time.Second
robotGiftCatalogPageSize = int32(500)
robotGiftCatalogMaxPages = int32(20)
)
type robotGiftCatalogKey struct {
appCode string
regionID int64
}
type robotGiftCatalogEntry struct {
expiresAtMS int64
gifts map[string]*walletv1.GiftConfig
loadErr error
}
type robotGiftCatalogLoad struct {
done chan struct{}
}
type RobotSendGiftInput struct { type RobotSendGiftInput struct {
Meta *roomv1.RequestMeta Meta *roomv1.RequestMeta
TargetUserID int64 TargetUserID int64
@ -18,7 +51,8 @@ type RobotSendGiftInput struct {
RobotUserIDs []int64 RobotUserIDs []int64
SyntheticRewardCoins int64 SyntheticRewardCoins int64
SyntheticMultiplierPPM int64 SyntheticMultiplierPPM int64
RealRoomHeat bool // RealRoomHeat 是历史字段名;当前只表示“真人房机器人”校验范围,绝不再产生真实热度或任何持久化贡献。
RealRoomHeat bool
} }
type robotGiftOptions struct { type robotGiftOptions struct {
@ -26,7 +60,8 @@ type robotGiftOptions struct {
RobotUserIDs []int64 RobotUserIDs []int64
SyntheticRewardCoins int64 SyntheticRewardCoins int64
SyntheticMultiplierPPM int64 SyntheticMultiplierPPM int64
RealRoomHeat bool // RealRoomHeat 仅供 validateBeforeDebit 区分专属机器人房和真人房机器人池,不能进入状态/账务结算。
RealRoomHeat bool
} }
func (s *Service) RobotSendGift(ctx context.Context, input RobotSendGiftInput) (*roomv1.SendGiftResponse, error) { func (s *Service) RobotSendGift(ctx context.Context, input RobotSendGiftInput) (*roomv1.SendGiftResponse, error) {
@ -34,12 +69,13 @@ func (s *Service) RobotSendGift(ctx context.Context, input RobotSendGiftInput) (
Meta: input.Meta, Meta: input.Meta,
TargetUserId: input.TargetUserID, TargetUserId: input.TargetUserID,
TargetUserIds: []int64{input.TargetUserID}, TargetUserIds: []int64{input.TargetUserID},
GiftId: input.GiftID, GiftId: strings.TrimSpace(input.GiftID),
GiftCount: input.GiftCount, GiftCount: input.GiftCount,
TargetType: "user", TargetType: "user",
PoolId: input.PoolID, PoolId: input.PoolID,
} }
return s.sendGift(ctx, req, giftSendOptions{ ctx = contextFromMeta(ctx, req.GetMeta())
flow := newGiftFlow(s, ctx, req, giftSendOptions{
Robot: robotGiftOptions{ Robot: robotGiftOptions{
Enabled: true, Enabled: true,
RobotUserIDs: input.RobotUserIDs, RobotUserIDs: input.RobotUserIDs,
@ -48,6 +84,300 @@ func (s *Service) RobotSendGift(ctx context.Context, input RobotSendGiftInput) (
RealRoomHeat: input.RealRoomHeat, RealRoomHeat: input.RealRoomHeat,
}, },
}) })
// RobotGift 永远是纯展示语义。RealRoomHeat 只兼容真人房机器人参与者校验,不能再授权热度、榜单、火箭或统计落库。
flow.cmd.RobotGift = true
flow.cmd.RobotWalletGift = true
if flow.cmd.RoomID() == "" || flow.cmd.ActorUserID() <= 0 || !giftlimits.ValidCommandID(flow.cmd.ID()) {
return nil, xerr.New(xerr.InvalidArgument, "command meta is incomplete")
}
if s.isDraining() {
return nil, xerr.New(xerr.Unavailable, "room service is draining")
}
// 只读取当前 Room Cell 做 presence 与机器人池校验ensureCell 仅续租 Redis owner lease
// 不进入 mutateRoom因此不会写 room_gift_operations、command log、snapshot、账本或房间统计表。
snapshot, leaseToken, err := s.robotGiftOwnerSnapshot(ctx, flow.cmd.RoomID())
if err != nil {
return nil, err
}
current := state.FromProto(snapshot)
if err := flow.validateBeforeDebit(current); err != nil {
return nil, err
}
// 礼物目录调用是只读快照查询,替代历史 DebitRobotGift返回值仅用于 IM 图标、动画、类型和展示价值。
giftConfig, err := s.robotGiftConfig(ctx, flow.cmd.GiftID, snapshot.GetVisibleRegionId())
if err != nil {
return nil, err
}
billing, err := robotGiftDisplayBilling(giftConfig, flow.cmd.GiftCount)
if err != nil {
return nil, err
}
targetBillings := []giftTargetBilling{{
TargetUserID: flow.cmd.TargetUserID,
CommandID: flow.cmd.ID(),
Billing: billing,
}}
targetGiftValues := map[int64]int64{}
if target := current.OnlineUsers[flow.cmd.TargetUserID]; target != nil {
// 纯展示礼物不能改变麦位/在线用户累计值;事件携带当前值,客户端不会把机器人价值误并入真实状态。
targetGiftValues[flow.cmd.TargetUserID] = target.GiftValue
}
now := s.clock.Now()
records, err := buildRoomGiftSentRecords(flow.cmd.RoomID(), snapshot.GetVersion(), now, flow.cmd, RoomMeta{
RoomID: flow.cmd.RoomID(),
VisibleRegionID: snapshot.GetVisibleRegionId(),
}, targetBillings, targetGiftValues)
if err != nil {
return nil, err
}
var luckyGift *roomv1.LuckyGiftDrawResult
var luckyGifts []*roomv1.LuckyGiftDrawResult
if flow.cmd.SyntheticLuckyGift {
luckyGift = syntheticLuckyGiftResult(flow.cmd, targetBillings, now)
if luckyGift != nil {
luckyGifts = []*roomv1.LuckyGiftDrawResult{luckyGift}
drawRecord, buildErr := outbox.Build(flow.cmd.RoomID(), "RoomRobotLuckyGiftDrawn", snapshot.GetVersion(), now, &roomeventsv1.RoomRobotLuckyGiftDrawn{
DrawId: luckyGift.GetDrawId(),
CommandId: flow.cmd.ID(),
SenderUserId: flow.cmd.ActorUserID(),
TargetUserId: flow.cmd.TargetUserID,
GiftId: flow.cmd.GiftID,
GiftCount: flow.cmd.GiftCount,
PoolId: flow.cmd.PoolID,
MultiplierPpm: luckyGift.GetMultiplierPpm(),
BaseRewardCoins: luckyGift.GetBaseRewardCoins(),
EffectiveRewardCoins: luckyGift.GetEffectiveRewardCoins(),
CreatedAtMs: luckyGift.GetCreatedAtMs(),
VisibleRegionId: snapshot.GetVisibleRegionId(),
IsRobot: true,
Synthetic: true,
})
if buildErr != nil {
return nil, buildErr
}
records = append(records, drawRecord)
}
}
// outbox.Record 在这里仅是复用 protobuf 信封的内存载体;不调用 repository/outboxPublisher
// 只经过采样器后直接交给生产环境的 TencentIMPublisher因此不会生成任何 RocketMQ 消息。
records = s.sampleRobotDisplayRecords(ctx, records)
if len(records) > 0 {
// wallet 目录查询最多持续 3 秒;发布前用同一 token 再做 fencing防止查询期间房间已被另一节点接管。
if err := s.verifyRobotGiftOwner(ctx, flow.cmd.RoomID(), leaseToken); err != nil {
return nil, err
}
}
s.publishRobotDisplayRecordsBestEffort(ctx, records)
return &roomv1.SendGiftResponse{
Result: commandResult(true, snapshot.GetVersion(), now),
RoomHeat: snapshot.GetHeat(),
GiftRank: snapshot.GetGiftRank(),
Room: snapshot,
Rocket: snapshot.GetRocket(),
LuckyGift: luckyGift,
LuckyGifts: luckyGifts,
}, nil
}
func (s *Service) robotGiftOwnerSnapshot(ctx context.Context, roomID string) (*roomv1.RoomSnapshot, string, error) {
if s.directory == nil {
// 极简内部测试允许没有 lease 目录;生产 HealthCheck 会拒绝这种装配。
snapshot, err := s.currentSnapshot(ctx, roomID)
if err != nil {
return nil, "", err
}
if snapshot == nil {
return nil, "", xerr.New(xerr.NotFound, "room not found")
}
return snapshot, "", nil
}
roomCell, lease, err := s.ensureCell(ctx, roomID)
if err != nil {
return nil, "", err
}
current, _, err := roomCell.Snapshot(ctx)
if err != nil {
return nil, "", err
}
return snapshotWithApp(ctx, current.ToProto()), lease.LeaseToken, nil
}
func (s *Service) verifyRobotGiftOwner(ctx context.Context, roomID string, leaseToken string) error {
if s.directory == nil {
return nil
}
owned, err := s.directory.VerifyOwner(ctx, runtimeRoomKeyFromContext(ctx, roomID), s.nodeID, leaseToken, s.clock.Now())
if err != nil {
return err
}
if !owned {
return xerr.New(xerr.Conflict, "room owner lease changed before robot display")
}
return nil
}
func (s *Service) robotGiftConfig(ctx context.Context, giftID string, regionID int64) (*walletv1.GiftConfig, error) {
key := robotGiftCatalogKey{appCode: appcode.FromContext(ctx), regionID: regionID}
for {
nowMS := s.clock.Now().UnixMilli()
s.robotGiftCatalogMu.Lock()
if cached, exists := s.robotGiftCatalog[key]; exists && cached.expiresAtMS > nowMS {
s.robotGiftCatalogMu.Unlock()
if cached.loadErr != nil {
return nil, cached.loadErr
}
return robotGiftConfigOrNotFound(cached.gifts[giftID])
}
if loading := s.robotGiftCatalogLoads[key]; loading != nil {
done := loading.done
s.robotGiftCatalogMu.Unlock()
select {
case <-done:
// loader 已把成功或短负缓存结果写入 catalog循环一次统一读取。
continue
case <-ctx.Done():
return nil, ctx.Err()
}
}
if s.robotGiftCatalogLoads == nil {
s.robotGiftCatalogLoads = make(map[robotGiftCatalogKey]*robotGiftCatalogLoad)
}
loading := &robotGiftCatalogLoad{done: make(chan struct{})}
s.robotGiftCatalogLoads[key] = loading
s.robotGiftCatalogMu.Unlock()
// loader 脱离首个 caller 的取消信号但仍受 3 秒 RPC timeout所有调用者都按自己的 ctx 等待同一 done。
go s.completeRobotGiftCatalogLoad(context.WithoutCancel(ctx), key, loading)
select {
case <-loading.done:
continue
case <-ctx.Done():
return nil, ctx.Err()
}
}
}
func (s *Service) completeRobotGiftCatalogLoad(ctx context.Context, key robotGiftCatalogKey, loading *robotGiftCatalogLoad) {
// 网络查询在全局缓存锁外执行;同 key 等待 done不同 key 可并行请求 wallet。
gifts, loadErr := s.loadRobotGiftCatalog(ctx, key)
now := s.clock.Now()
expiresAt := now.Add(robotGiftCatalogTTL)
if loadErr != nil {
expiresAt = now.Add(robotGiftCatalogErrorTTL)
}
s.robotGiftCatalogMu.Lock()
if s.robotGiftCatalog == nil {
s.robotGiftCatalog = make(map[robotGiftCatalogKey]robotGiftCatalogEntry)
}
s.robotGiftCatalog[key] = robotGiftCatalogEntry{expiresAtMS: expiresAt.UnixMilli(), gifts: gifts, loadErr: loadErr}
delete(s.robotGiftCatalogLoads, key)
close(loading.done)
s.pruneRobotGiftCatalogLocked(now.UnixMilli())
s.robotGiftCatalogMu.Unlock()
}
func (s *Service) pruneRobotGiftCatalogLocked(nowMS int64) {
if len(s.robotGiftCatalog) <= 1024 {
return
}
for key, entry := range s.robotGiftCatalog {
if entry.expiresAtMS <= nowMS && s.robotGiftCatalogLoads[key] == nil {
delete(s.robotGiftCatalog, key)
}
}
}
func robotGiftConfigOrNotFound(gift *walletv1.GiftConfig) (*walletv1.GiftConfig, error) {
if gift == nil {
return nil, xerr.New(xerr.NotFound, "robot gift config is not active in room region")
}
return gift, nil
}
func (s *Service) loadRobotGiftCatalog(ctx context.Context, key robotGiftCatalogKey) (map[string]*walletv1.GiftConfig, error) {
client, ok := s.wallet.(integration.WalletGiftCatalogClient)
if !ok || client == nil {
return nil, xerr.New(xerr.Unavailable, "wallet gift catalog is not configured")
}
queryCtx, cancel := context.WithTimeout(ctx, robotGiftCatalogQueryTimeout)
defer cancel()
gifts := make(map[string]*walletv1.GiftConfig)
var loaded int64
for page := int32(1); page <= robotGiftCatalogMaxPages; page++ {
resp, err := client.ListGiftConfigs(queryCtx, &walletv1.ListGiftConfigsRequest{
AppCode: key.appCode,
Page: page,
PageSize: robotGiftCatalogPageSize,
ActiveOnly: true,
RegionId: key.regionID,
FilterRegion: true,
})
if err != nil {
return nil, err
}
for _, gift := range resp.GetGifts() {
if id := strings.TrimSpace(gift.GetGiftId()); id != "" {
gifts[id] = gift
}
}
loaded += int64(len(resp.GetGifts()))
if loaded >= resp.GetTotal() || len(resp.GetGifts()) < int(robotGiftCatalogPageSize) {
return gifts, nil
}
}
return nil, xerr.New(xerr.Unavailable, "wallet gift catalog exceeds robot display limit")
}
func robotGiftDisplayBilling(gift *walletv1.GiftConfig, giftCount int32) (*walletv1.DebitGiftResponse, error) {
if gift == nil || giftCount <= 0 {
return nil, xerr.New(xerr.InvalidArgument, "robot gift display config is invalid")
}
coinSpent, err := checkedRobotGiftDisplayValue(gift.GetCoinPrice(), giftCount)
if err != nil {
return nil, err
}
resource := gift.GetResource()
iconURL := ""
animationURL := ""
name := strings.TrimSpace(gift.GetName())
if resource != nil {
iconURL = strings.TrimSpace(resource.GetPreviewUrl())
if iconURL == "" {
iconURL = strings.TrimSpace(resource.GetAssetUrl())
}
animationURL = strings.TrimSpace(resource.GetAnimationUrl())
if name == "" {
name = strings.TrimSpace(resource.GetName())
}
}
// 该返回对象只是复用 RoomGiftSent 构造器的展示快照,不包含 receipt/transaction/balance也没有任何账务含义。
// HeatValue 必须保持 0Flutter 会把 gift_value 增加到成员和麦位的本地值,纯展示事件不能制造短暂的假贡献。
return &walletv1.DebitGiftResponse{
CoinSpent: coinSpent,
ChargeAmount: coinSpent,
HeatValue: 0,
GiftTypeCode: gift.GetGiftTypeCode(),
CpRelationType: gift.GetCpRelationType(),
GiftName: name,
GiftIconUrl: iconURL,
GiftAnimationUrl: animationURL,
GiftEffectTypes: append([]string(nil), gift.GetEffectTypes()...),
PriceVersion: gift.GetPriceVersion(),
}, nil
}
func checkedRobotGiftDisplayValue(unitValue int64, giftCount int32) (int64, error) {
if unitValue < 0 || giftCount <= 0 || unitValue > math.MaxInt64/int64(giftCount) {
return 0, xerr.New(xerr.InvalidArgument, "robot gift display value is invalid")
}
return unitValue * int64(giftCount), nil
} }
func requireRobotGiftParticipants(current *state.RoomState, senderUserID int64, targetUserIDs []int64, runtimeRobotUserIDs []int64, requireRobotRoom bool) error { func requireRobotGiftParticipants(current *state.RoomState, senderUserID int64, targetUserIDs []int64, runtimeRobotUserIDs []int64, requireRobotRoom bool) error {

View File

@ -0,0 +1,332 @@
package service
import (
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"strings"
"time"
roomeventsv1 "hyapp.local/api/proto/events/room/v1"
roomv1 "hyapp.local/api/proto/room/v1"
"hyapp/pkg/appcode"
"hyapp/pkg/giftlimits"
"hyapp/pkg/roomid"
"hyapp/pkg/xerr"
"hyapp/services/room-service/internal/room/command"
"hyapp/services/room-service/internal/room/outbox"
"hyapp/services/room-service/internal/room/rank"
"hyapp/services/room-service/internal/room/state"
)
// AuthorizeRoomMediaUpload 在 COS PutObject 之前执行房间 owner/presence、治理态和 VIP 权益门禁。
// 返回的 prefix 是 gateway 生成对象 key 的唯一可信目录,客户端不能自行指定 key。
func (s *Service) AuthorizeRoomMediaUpload(ctx context.Context, req *roomv1.AuthorizeRoomMediaUploadRequest) (*roomv1.AuthorizeRoomMediaUploadResponse, error) {
if req == nil || req.GetMeta() == nil {
return nil, xerr.New(xerr.InvalidArgument, "request is required")
}
ctx = contextFromMeta(ctx, req.GetMeta())
roomID := strings.TrimSpace(req.GetMeta().GetRoomId())
actorUserID := req.GetMeta().GetActorUserId()
purpose := strings.ToLower(strings.TrimSpace(req.GetPurpose()))
if !roomid.ValidStringID(roomID) || actorUserID <= 0 || !giftlimits.ValidCommandID(req.GetMeta().GetCommandId()) ||
(purpose != roomMediaPurposeBackground && purpose != roomMediaPurposeImage) {
return nil, xerr.New(xerr.InvalidArgument, "room media upload request is invalid")
}
media := roomMediaFromProto(req.GetMedia())
if err := validateRoomMediaProperties(purpose, media); err != nil {
return nil, err
}
if media.URL != "" || media.ObjectKey != "" || media.Status != roomMediaStatusActive {
return nil, xerr.New(xerr.InvalidArgument, "room media authorization requires pre-upload metadata")
}
guard, ok := s.repository.(RoomMediaUploadGuardStore)
if !ok {
return nil, xerr.New(xerr.Unavailable, "room media upload guard is unavailable")
}
payloadSHA256, err := roomMediaUploadPayloadSHA256(media)
if err != nil {
return nil, err
}
registration := RoomMediaUploadRegistration{
AppCode: appcode.FromContext(ctx), RoomID: roomID, CommandID: strings.TrimSpace(req.GetMeta().GetCommandId()),
ActorUserID: actorUserID, Purpose: purpose, MediaPayloadSHA256: payloadSHA256, Media: media,
}
registration.ExpectedObjectKey = roomMediaExpectedObjectKey(
registration.AppCode, actorUserID, roomID, purpose, registration.CommandID, media,
)
var authorizedReplay *RoomMediaUploadRegistration
if existing, exists, readErr := guard.GetRoomMediaUploadRegistration(ctx, roomID, registration.CommandID); readErr != nil {
return nil, readErr
} else if exists {
if !sameRoomMediaUploadIdentity(existing, registration) {
return nil, xerr.New(xerr.IdempotencyConflict, "room media upload command payload conflict")
}
switch existing.Status {
case roomMediaStatusActive:
// PutObject 已完成的同载荷重试只重放首次结果;后续权限变化不能把已成功请求改成失败。
return roomMediaUploadAuthorizationResponse(existing, s.clock.Now().UnixMilli()), nil
case "authorized":
// authorized 只代表 owner 已占用 command_id不能代表对象已经上传成功。重试 PutObject
// 前必须重新检查当前房主/麦位/VIP避免权限撤销后继续使用旧授权写 COS。
authorizedReplay = &existing
default:
return nil, xerr.New(xerr.Internal, "room media upload registration status is invalid")
}
}
snapshot, err := s.currentSnapshot(ctx, roomID)
if err != nil {
return nil, err
}
if snapshot == nil || snapshot.GetStatus() != state.RoomStatusActive {
return nil, xerr.New(xerr.RoomClosed, "room closed")
}
switch purpose {
case roomMediaPurposeBackground:
if snapshot.GetOwnerUserId() != actorUserID {
return nil, xerr.New(xerr.PermissionDenied, "permission denied")
}
if err := s.requireVIPBenefit(ctx, req.GetMeta().GetRequestId(), actorUserID, vipBenefitCustomRoomBackground, "upload_room_background"); err != nil {
return nil, err
}
case roomMediaPurposeImage:
if err := requireSnapshotSpeakPermission(snapshot, actorUserID, s.clock.Now().UnixMilli()); err != nil {
return nil, err
}
if err := s.requireVIPBenefit(ctx, req.GetMeta().GetRequestId(), actorUserID, vipBenefitRoomImageMessage, "upload_room_image"); err != nil {
return nil, err
}
}
nowMS := s.clock.Now().UnixMilli()
if authorizedReplay != nil {
return roomMediaUploadAuthorizationResponse(*authorizedReplay, nowMS), nil
}
registration.AuthorizedVersion = snapshot.GetVersion()
registration.CreatedAtMS, registration.UpdatedAtMS = nowMS, nowMS
registered, err := guard.RegisterRoomMediaUpload(ctx, registration)
if errors.Is(err, ErrRoomMediaUploadCommandConflict) {
return nil, xerr.New(xerr.IdempotencyConflict, "room media upload command payload conflict")
}
if err != nil {
return nil, err
}
return roomMediaUploadAuthorizationResponse(registered, nowMS), nil
}
// CompleteRoomMediaUpload 只接受 gateway 已成功 PutObject 后回传的 URL/key。
// owner 以首次注册的完整解析事实为准推进 activeSave/Send 随后只认 active registration。
func (s *Service) CompleteRoomMediaUpload(ctx context.Context, req *roomv1.CompleteRoomMediaUploadRequest) (*roomv1.CompleteRoomMediaUploadResponse, error) {
if req == nil || req.GetMeta() == nil {
return nil, xerr.New(xerr.InvalidArgument, "request is required")
}
ctx = contextFromMeta(ctx, req.GetMeta())
roomID := strings.TrimSpace(req.GetMeta().GetRoomId())
commandID := strings.TrimSpace(req.GetMeta().GetCommandId())
actorUserID := req.GetMeta().GetActorUserId()
purpose := strings.ToLower(strings.TrimSpace(req.GetPurpose()))
if !roomid.ValidStringID(roomID) || actorUserID <= 0 || !giftlimits.ValidCommandID(commandID) ||
(purpose != roomMediaPurposeBackground && purpose != roomMediaPurposeImage) {
return nil, xerr.New(xerr.InvalidArgument, "room media completion request is invalid")
}
media := roomMediaFromProto(req.GetMedia())
if err := validateRoomMedia(appcode.FromContext(ctx), roomID, actorUserID, purpose, media); err != nil {
return nil, err
}
guard, ok := s.repository.(RoomMediaUploadGuardStore)
if !ok {
return nil, xerr.New(xerr.Unavailable, "room media upload guard is unavailable")
}
existing, exists, err := guard.GetRoomMediaUploadRegistration(ctx, roomID, commandID)
if err != nil {
return nil, err
}
if !exists {
return nil, xerr.New(xerr.NotFound, "room media upload authorization not found")
}
payloadSHA256, err := roomMediaUploadPayloadSHA256(media)
if err != nil {
return nil, err
}
candidate := RoomMediaUploadRegistration{
AppCode: appcode.FromContext(ctx), RoomID: roomID, CommandID: commandID, ActorUserID: actorUserID,
Purpose: purpose, MediaPayloadSHA256: payloadSHA256, ExpectedObjectKey: media.ObjectKey, ObjectURL: media.URL, Media: media,
}
if !sameRoomMediaUploadIdentity(existing, candidate) || !sameRoomMediaProperties(existing.Media, media) {
return nil, xerr.New(xerr.IdempotencyConflict, "room media upload command payload conflict")
}
if existing.Status == roomMediaStatusActive {
if strings.TrimSpace(existing.ObjectURL) != strings.TrimSpace(media.URL) {
return nil, xerr.New(xerr.IdempotencyConflict, "room media upload completion conflict")
}
return &roomv1.CompleteRoomMediaUploadResponse{
Active: true, Media: roomMediaToProto(existing.Media), ServerTimeMs: s.clock.Now().UnixMilli(),
}, nil
}
existing.ObjectURL, existing.Status, existing.Media = media.URL, roomMediaStatusActive, media
existing.UpdatedAtMS = s.clock.Now().UnixMilli()
completed, err := guard.CompleteRoomMediaUpload(ctx, existing)
if errors.Is(err, ErrRoomMediaUploadCommandConflict) {
return nil, xerr.New(xerr.IdempotencyConflict, "room media upload completion conflict")
}
if err != nil {
return nil, err
}
return &roomv1.CompleteRoomMediaUploadResponse{
Active: true, Media: roomMediaToProto(completed.Media), ServerTimeMs: s.clock.Now().UnixMilli(),
}, nil
}
func roomMediaUploadAuthorizationResponse(registration RoomMediaUploadRegistration, serverTimeMS int64) *roomv1.AuthorizeRoomMediaUploadResponse {
return &roomv1.AuthorizeRoomMediaUploadResponse{
Allowed: true, ObjectKeyPrefix: roomMediaObjectPrefix(registration.AppCode, registration.ActorUserID, registration.RoomID, registration.Purpose),
ObjectKey: registration.ExpectedObjectKey, RoomVersion: registration.AuthorizedVersion, ServerTimeMs: serverTimeMS,
}
}
func sameRoomMediaUploadIdentity(left RoomMediaUploadRegistration, right RoomMediaUploadRegistration) bool {
return appcode.Normalize(left.AppCode) == appcode.Normalize(right.AppCode) &&
strings.TrimSpace(left.RoomID) == strings.TrimSpace(right.RoomID) &&
strings.TrimSpace(left.CommandID) == strings.TrimSpace(right.CommandID) &&
left.ActorUserID == right.ActorUserID && strings.TrimSpace(left.Purpose) == strings.TrimSpace(right.Purpose) &&
strings.EqualFold(strings.TrimSpace(left.MediaPayloadSHA256), strings.TrimSpace(right.MediaPayloadSHA256)) &&
strings.TrimLeft(strings.TrimSpace(left.ExpectedObjectKey), "/") == strings.TrimLeft(strings.TrimSpace(right.ExpectedObjectKey), "/")
}
func (s *Service) requireActiveRoomMediaUpload(ctx context.Context, roomID string, actorUserID int64, purpose string, commandID string, media state.RoomMedia) error {
guard, ok := s.repository.(RoomMediaUploadGuardStore)
if !ok {
return xerr.New(xerr.Unavailable, "room media upload guard is unavailable")
}
registration, exists, err := guard.GetActiveRoomMediaUploadByObjectKey(ctx, media.ObjectKey)
if err != nil {
return err
}
if !exists {
return xerr.New(xerr.NotFound, "active room media upload not found")
}
payloadSHA256, err := roomMediaUploadPayloadSHA256(media)
if err != nil {
return err
}
candidate := RoomMediaUploadRegistration{
AppCode: appcode.FromContext(ctx), RoomID: roomID, CommandID: strings.TrimSpace(commandID), ActorUserID: actorUserID,
Purpose: purpose, MediaPayloadSHA256: payloadSHA256, ExpectedObjectKey: media.ObjectKey, ObjectURL: media.URL, Media: media,
}
if !sameRoomMediaUploadIdentity(registration, candidate) ||
strings.TrimSpace(registration.ObjectURL) != strings.TrimSpace(media.URL) || !sameRoomMediaProperties(registration.Media, media) {
return xerr.New(xerr.IdempotencyConflict, "room media does not match completed upload")
}
return nil
}
// SendRoomImage 把服务端认可的图片消息写入 Room Cell command log + durable outbox。
// outbox 的 at-least-once 重试使用稳定 message_idFlutter 不能以到达次数作为消息条数。
func (s *Service) SendRoomImage(ctx context.Context, req *roomv1.SendRoomImageRequest) (*roomv1.SendRoomImageResponse, error) {
if req == nil || req.GetMeta() == nil {
return nil, xerr.New(xerr.InvalidArgument, "request is required")
}
ctx = contextFromMeta(ctx, req.GetMeta())
cmd := command.SendRoomImage{
Base: baseFromMeta(req.GetMeta()), MessageID: roomImageMessageID(req.GetMeta()), Image: roomMediaFromProto(req.GetImage()),
}
if !roomid.ValidStringID(cmd.RoomID()) || cmd.ActorUserID() <= 0 || strings.TrimSpace(cmd.ID()) == "" {
return nil, xerr.New(xerr.InvalidArgument, "room image message meta is invalid")
}
// 与装扮相同,已提交 command_id 必须在当前 VIP 到期/禁言之前直接重放。
if _, record, seen, err := s.commandRecordForIdempotency(ctx, cmd); err != nil {
return nil, err
} else if seen {
stored, deserializeErr := command.Deserialize(record.CommandType, record.Payload)
if deserializeErr != nil {
return nil, deserializeErr
}
storedCommand, ok := stored.(*command.SendRoomImage)
if !ok {
return nil, xerr.New(xerr.Internal, "stored room image command is invalid")
}
// 返回第一次提交固化的 message_id 和媒体事实,不能用当前重试请求拼出看似成功的另一条消息。
cmd = *storedCommand
} else {
if err := validateRoomMedia(appcode.FromContext(ctx), cmd.RoomID(), cmd.ActorUserID(), roomMediaPurposeImage, cmd.Image); err != nil {
return nil, err
}
if err := s.requireActiveRoomMediaUpload(ctx, cmd.RoomID(), cmd.ActorUserID(), roomMediaPurposeImage, cmd.ID(), cmd.Image); err != nil {
return nil, err
}
if err := s.requireVIPBenefit(ctx, req.GetMeta().GetRequestId(), cmd.ActorUserID(), vipBenefitRoomImageMessage, "send_room_image"); err != nil {
return nil, err
}
}
result, err := s.mutateRoom(ctx, cmd, true, func(now time.Time, current *state.RoomState, _ *rank.LocalRank) (mutationResult, []outbox.Record, error) {
if err := requireActiveRoom(current); err != nil {
return mutationResult{}, nil, err
}
if err := requireStateSpeakPermission(current, cmd.ActorUserID(), now.UnixMilli()); err != nil {
return mutationResult{}, nil, err
}
// 图片消息不成为房间核心状态字段,但版本推进把 command log、恢复谱系和 outbox 放进同一原子提交。
current.Version++
event, err := outbox.Build(current.RoomID, "RoomImageMessageSent", current.Version, now, &roomeventsv1.RoomImageMessageSent{
MessageId: cmd.MessageID, CommandId: cmd.ID(), SenderUserId: cmd.ActorUserID(), Image: roomMediaEventSnapshot(cmd.Image),
})
if err != nil {
return mutationResult{}, nil, err
}
return mutationResult{snapshot: current.ToProtoAt(now.UnixMilli())}, []outbox.Record{event}, nil
})
if err != nil {
return nil, err
}
now := s.clock.Now()
return &roomv1.SendRoomImageResponse{
Result: commandResult(result.applied, result.snapshot.GetVersion(), now), Accepted: true,
MessageId: cmd.MessageID, RoomId: cmd.RoomID(), SenderUserId: cmd.ActorUserID(), ServerTimeMs: now.UnixMilli(),
Image: roomMediaToProto(cmd.Image),
}, nil
}
func requireSnapshotSpeakPermission(snapshot *roomv1.RoomSnapshot, userID int64, nowMS int64) error {
if snapshot == nil || snapshot.GetStatus() != state.RoomStatusActive {
return xerr.New(xerr.RoomClosed, "room closed")
}
switch {
case snapshotUserBanned(snapshot, userID, nowMS):
return xerr.New(xerr.PermissionDenied, "user is banned")
case containsID(snapshot.GetMuteUserIds(), userID):
return xerr.New(xerr.PermissionDenied, "user is muted")
case !snapshot.GetChatEnabled():
return xerr.New(xerr.Conflict, "room chat is disabled")
case findProtoUser(snapshot, userID) == nil:
return xerr.New(xerr.PermissionDenied, "user is not in room")
default:
return nil
}
}
func requireStateSpeakPermission(current *state.RoomState, userID int64, nowMS int64) error {
if moderation, banned := current.BanUsers[userID]; banned && moderation.ActiveAt(nowMS) {
return xerr.New(xerr.PermissionDenied, "user is banned")
}
if current.MuteUsers[userID] {
return xerr.New(xerr.PermissionDenied, "user is muted")
}
if !current.ChatEnabled {
return xerr.New(xerr.Conflict, "room chat is disabled")
}
if current.OnlineUsers[userID] == nil {
return xerr.New(xerr.PermissionDenied, "user is not in room")
}
return nil
}
func roomImageMessageID(meta *roomv1.RequestMeta) string {
if meta == nil {
return ""
}
sum := sha256.Sum256([]byte(strings.Join([]string{
appcode.Normalize(meta.GetAppCode()), strings.TrimSpace(meta.GetRoomId()), strings.TrimSpace(meta.GetCommandId()),
}, "\x00")))
return "room_msg_" + hex.EncodeToString(sum[:16])
}

View File

@ -7,6 +7,7 @@ import (
"log/slog" "log/slog"
"math" "math"
"strings" "strings"
"time"
roomv1 "hyapp.local/api/proto/room/v1" roomv1 "hyapp.local/api/proto/room/v1"
"hyapp/pkg/appcode" "hyapp/pkg/appcode"
@ -403,6 +404,8 @@ func roomListEntryFromSnapshot(snapshot *roomv1.RoomSnapshot, visibleRegionID in
SeatCount: seatCount, SeatCount: seatCount,
OccupiedSeatCount: occupiedSeatCount, OccupiedSeatCount: occupiedSeatCount,
SortScore: roomListSortScore(snapshot.GetHeat(), onlineCount, occupiedSeatCount), SortScore: roomListSortScore(snapshot.GetHeat(), onlineCount, occupiedSeatCount),
RoomBorderJSON: marshalRoomListDecoration(snapshot.GetRoomBorder()),
RoomNameStyleJSON: marshalRoomListDecoration(snapshot.GetRoomNameStyle()),
CreatedAtMS: nowMS, CreatedAtMS: nowMS,
UpdatedAtMS: nowMS, UpdatedAtMS: nowMS,
} }
@ -587,6 +590,7 @@ func encodeRoomListCursor(tab string, query string, countryCode string, viewerCo
// roomListItemToProto 将 repository 投影转换为跨服务响应对象。 // roomListItemToProto 将 repository 投影转换为跨服务响应对象。
func roomListItemToProto(entry RoomListEntry) *roomv1.RoomListItem { func roomListItemToProto(entry RoomListEntry) *roomv1.RoomListItem {
nowMS := time.Now().UTC().UnixMilli()
return &roomv1.RoomListItem{ return &roomv1.RoomListItem{
AppCode: entry.AppCode, AppCode: entry.AppCode,
RoomId: entry.RoomID, RoomId: entry.RoomID,
@ -603,9 +607,34 @@ func roomListItemToProto(entry RoomListEntry) *roomv1.RoomListItem {
OccupiedSeatCount: entry.OccupiedSeatCount, OccupiedSeatCount: entry.OccupiedSeatCount,
VisibleRegionId: entry.VisibleRegionID, VisibleRegionId: entry.VisibleRegionID,
CountryCode: normalizeRoomCountryCode(entry.OwnerCountryCode), CountryCode: normalizeRoomCountryCode(entry.OwnerCountryCode),
RoomBorder: unmarshalRoomListDecoration(entry.RoomBorderJSON, nowMS),
RoomNameStyle: unmarshalRoomListDecoration(entry.RoomNameStyleJSON, nowMS),
} }
} }
func marshalRoomListDecoration(resource *roomv1.RoomDecorationResource) string {
if resource == nil || resource.GetResourceId() <= 0 {
return ""
}
payload, err := json.Marshal(resource)
if err != nil {
return ""
}
return string(payload)
}
func unmarshalRoomListDecoration(raw string, nowMS int64) *roomv1.RoomDecorationResource {
if strings.TrimSpace(raw) == "" {
return nil
}
var resource roomv1.RoomDecorationResource
if err := json.Unmarshal([]byte(raw), &resource); err != nil || resource.GetResourceId() <= 0 ||
(resource.GetExpiresAtMs() > 0 && nowMS >= resource.GetExpiresAtMs()) {
return nil
}
return &resource
}
func roomListEntryFromMetaAndSnapshot(meta RoomMeta, snapshot *roomv1.RoomSnapshot, nowMS int64) RoomListEntry { func roomListEntryFromMetaAndSnapshot(meta RoomMeta, snapshot *roomv1.RoomSnapshot, nowMS int64) RoomListEntry {
if snapshot == nil || snapshot.GetRoomId() == "" { if snapshot == nil || snapshot.GetRoomId() == "" {
// GetMyRoom 兜底路径:即使快照暂时不可用,也能用 rooms 元数据返回“有房间”的最小卡片。 // GetMyRoom 兜底路径:即使快照暂时不可用,也能用 rooms 元数据返回“有房间”的最小卡片。

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