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 background_id = 2;
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

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_30d_coins = 20;
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;
// user_registered_at_ms user-service
// dynamic_v3 48 fail-close
@ -246,6 +247,29 @@ message ListLuckyGiftConfigsResponse {
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 {
RequestMeta meta = 1;
string gift_id = 2;
@ -443,6 +467,8 @@ service AdminLuckyGiftService {
rpc GetLuckyGiftConfig(GetLuckyGiftConfigRequest) returns (GetLuckyGiftConfigResponse);
rpc UpsertLuckyGiftConfig(UpsertLuckyGiftConfigRequest) returns (UpsertLuckyGiftConfigResponse);
rpc ListLuckyGiftConfigs(ListLuckyGiftConfigsRequest) returns (ListLuckyGiftConfigsResponse);
rpc ListLuckyGiftConfigVersions(ListLuckyGiftConfigVersionsRequest) returns (ListLuckyGiftConfigVersionsResponse);
rpc RollbackLuckyGiftConfig(RollbackLuckyGiftConfigRequest) returns (RollbackLuckyGiftConfigResponse);
rpc ListLuckyGiftDraws(ListLuckyGiftDrawsRequest) returns (ListLuckyGiftDrawsResponse);
rpc GetLuckyGiftDrawSummary(GetLuckyGiftDrawSummaryRequest) returns (GetLuckyGiftDrawSummaryResponse);
rpc ListLuckyGiftPoolBalances(ListLuckyGiftPoolBalancesRequest) returns (ListLuckyGiftPoolBalancesResponse);

View File

@ -238,6 +238,8 @@ const (
AdminLuckyGiftService_GetLuckyGiftConfig_FullMethodName = "/hyapp.luckygift.v1.AdminLuckyGiftService/GetLuckyGiftConfig"
AdminLuckyGiftService_UpsertLuckyGiftConfig_FullMethodName = "/hyapp.luckygift.v1.AdminLuckyGiftService/UpsertLuckyGiftConfig"
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_GetLuckyGiftDrawSummary_FullMethodName = "/hyapp.luckygift.v1.AdminLuckyGiftService/GetLuckyGiftDrawSummary"
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)
UpsertLuckyGiftConfig(ctx context.Context, in *UpsertLuckyGiftConfigRequest, opts ...grpc.CallOption) (*UpsertLuckyGiftConfigResponse, 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)
GetLuckyGiftDrawSummary(ctx context.Context, in *GetLuckyGiftDrawSummaryRequest, opts ...grpc.CallOption) (*GetLuckyGiftDrawSummaryResponse, 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
}
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) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(ListLuckyGiftDrawsResponse)
@ -438,6 +462,8 @@ type AdminLuckyGiftServiceServer interface {
GetLuckyGiftConfig(context.Context, *GetLuckyGiftConfigRequest) (*GetLuckyGiftConfigResponse, error)
UpsertLuckyGiftConfig(context.Context, *UpsertLuckyGiftConfigRequest) (*UpsertLuckyGiftConfigResponse, 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)
GetLuckyGiftDrawSummary(context.Context, *GetLuckyGiftDrawSummaryRequest) (*GetLuckyGiftDrawSummaryResponse, error)
ListLuckyGiftPoolBalances(context.Context, *ListLuckyGiftPoolBalancesRequest) (*ListLuckyGiftPoolBalancesResponse, error)
@ -469,6 +495,12 @@ func (UnimplementedAdminLuckyGiftServiceServer) UpsertLuckyGiftConfig(context.Co
func (UnimplementedAdminLuckyGiftServiceServer) ListLuckyGiftConfigs(context.Context, *ListLuckyGiftConfigsRequest) (*ListLuckyGiftConfigsResponse, error) {
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) {
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)
}
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) {
in := new(ListLuckyGiftDrawsRequest)
if err := dec(in); err != nil {
@ -815,6 +883,14 @@ var AdminLuckyGiftService_ServiceDesc = grpc.ServiceDesc{
MethodName: "ListLuckyGiftConfigs",
Handler: _AdminLuckyGiftService_ListLuckyGiftConfigs_Handler,
},
{
MethodName: "ListLuckyGiftConfigVersions",
Handler: _AdminLuckyGiftService_ListLuckyGiftConfigVersions_Handler,
},
{
MethodName: "RollbackLuckyGiftConfig",
Handler: _AdminLuckyGiftService_RollbackLuckyGiftConfig_Handler,
},
{
MethodName: "ListLuckyGiftDraws",
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;
// online_count lite online_users 线
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
@ -563,12 +587,16 @@ message RoomBackgroundImage {
int64 created_by_user_id = 4;
int64 created_at_ms = 5;
int64 updated_at_ms = 6;
RoomMedia media = 7;
string command_id = 8;
}
message SaveRoomBackgroundRequest {
RequestMeta meta = 1;
string room_id = 2;
// image_url media URL
string image_url = 3;
RoomMedia media = 4;
}
message SaveRoomBackgroundResponse {
@ -586,6 +614,7 @@ message ListRoomBackgroundsResponse {
repeated RoomBackgroundImage backgrounds = 1;
string selected_background_url = 2;
int64 server_time_ms = 3;
RoomBackgroundImage selected_background = 4;
}
message SetRoomBackgroundRequest {
@ -599,6 +628,85 @@ message SetRoomBackgroundResponse {
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
// meta.room_idmeta.command_idmeta.actor_user_idmoderoom_name room_avatar
// meta.actor_user_id owner Conflict
@ -1309,6 +1417,8 @@ message RoomListItem {
string room_short_id = 14;
bool locked = 15;
string country_code = 16;
RoomDecorationResource room_border = 17;
RoomDecorationResource room_name_style = 18;
}
// ListRoomsResponse cursor
@ -1391,6 +1501,47 @@ message GetCurrentRoomResponse {
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
// gateway viewer_user_id viewer_user_id
message GetRoomSnapshotRequest {
@ -1501,6 +1652,8 @@ service RoomCommandService {
rpc UpdateRoomProfile(UpdateRoomProfileRequest) returns (UpdateRoomProfileResponse);
rpc SaveRoomBackground(SaveRoomBackgroundRequest) returns (SaveRoomBackgroundResponse);
rpc SetRoomBackground(SetRoomBackgroundRequest) returns (SetRoomBackgroundResponse);
rpc ApplyRoomDecoration(ApplyRoomDecorationRequest) returns (ApplyRoomDecorationResponse);
rpc SendRoomImage(SendRoomImageRequest) returns (SendRoomImageResponse);
rpc JoinRoom(JoinRoomRequest) returns (JoinRoomResponse);
rpc RoomHeartbeat(RoomHeartbeatRequest) returns (RoomHeartbeatResponse);
rpc LeaveRoom(LeaveRoomRequest) returns (LeaveRoomResponse);
@ -1540,6 +1693,8 @@ service RoomGuardService {
rpc CheckSpeakPermission(CheckSpeakPermissionRequest) returns (CheckSpeakPermissionResponse);
rpc VerifyRoomPresence(VerifyRoomPresenceRequest) returns (VerifyRoomPresenceResponse);
rpc ResolveRoomAppCode(ResolveRoomAppCodeRequest) returns (ResolveRoomAppCodeResponse);
rpc AuthorizeRoomMediaUpload(AuthorizeRoomMediaUploadRequest) returns (AuthorizeRoomMediaUploadResponse);
rpc CompleteRoomMediaUpload(CompleteRoomMediaUploadRequest) returns (CompleteRoomMediaUploadResponse);
}
// RoomQueryService Room Cell
@ -1559,6 +1714,7 @@ service RoomQueryService {
rpc ListRoomContributionRank(ListRoomContributionRankRequest) returns (ListRoomContributionRankResponse);
rpc GetMyRoom(GetMyRoomRequest) returns (GetMyRoomResponse);
rpc GetCurrentRoom(GetCurrentRoomRequest) returns (GetCurrentRoomResponse);
rpc BatchGetUserVoiceRoomPresences(BatchGetUserVoiceRoomPresencesRequest) returns (BatchGetUserVoiceRoomPresencesResponse);
rpc GetRoomSnapshot(GetRoomSnapshotRequest) returns (GetRoomSnapshotResponse);
rpc ListRoomBackgrounds(ListRoomBackgroundsRequest) returns (ListRoomBackgroundsResponse);
rpc GetRoomRocket(GetRoomRocketRequest) returns (GetRoomRocketResponse);

View File

@ -23,6 +23,8 @@ const (
RoomCommandService_UpdateRoomProfile_FullMethodName = "/hyapp.room.v1.RoomCommandService/UpdateRoomProfile"
RoomCommandService_SaveRoomBackground_FullMethodName = "/hyapp.room.v1.RoomCommandService/SaveRoomBackground"
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_RoomHeartbeat_FullMethodName = "/hyapp.room.v1.RoomCommandService/RoomHeartbeat"
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)
SaveRoomBackground(ctx context.Context, in *SaveRoomBackgroundRequest, opts ...grpc.CallOption) (*SaveRoomBackgroundResponse, 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)
RoomHeartbeat(ctx context.Context, in *RoomHeartbeatRequest, opts ...grpc.CallOption) (*RoomHeartbeatResponse, 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
}
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) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(JoinRoomResponse)
@ -479,6 +503,8 @@ type RoomCommandServiceServer interface {
UpdateRoomProfile(context.Context, *UpdateRoomProfileRequest) (*UpdateRoomProfileResponse, error)
SaveRoomBackground(context.Context, *SaveRoomBackgroundRequest) (*SaveRoomBackgroundResponse, 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)
RoomHeartbeat(context.Context, *RoomHeartbeatRequest) (*RoomHeartbeatResponse, error)
LeaveRoom(context.Context, *LeaveRoomRequest) (*LeaveRoomResponse, error)
@ -533,6 +559,12 @@ func (UnimplementedRoomCommandServiceServer) SaveRoomBackground(context.Context,
func (UnimplementedRoomCommandServiceServer) SetRoomBackground(context.Context, *SetRoomBackgroundRequest) (*SetRoomBackgroundResponse, error) {
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) {
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)
}
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) {
in := new(JoinRoomRequest)
if err := dec(in); err != nil {
@ -1321,6 +1389,14 @@ var RoomCommandService_ServiceDesc = grpc.ServiceDesc{
MethodName: "SetRoomBackground",
Handler: _RoomCommandService_SetRoomBackground_Handler,
},
{
MethodName: "ApplyRoomDecoration",
Handler: _RoomCommandService_ApplyRoomDecoration_Handler,
},
{
MethodName: "SendRoomImage",
Handler: _RoomCommandService_SendRoomImage_Handler,
},
{
MethodName: "JoinRoom",
Handler: _RoomCommandService_JoinRoom_Handler,
@ -1455,9 +1531,11 @@ var RoomCommandService_ServiceDesc = grpc.ServiceDesc{
}
const (
RoomGuardService_CheckSpeakPermission_FullMethodName = "/hyapp.room.v1.RoomGuardService/CheckSpeakPermission"
RoomGuardService_VerifyRoomPresence_FullMethodName = "/hyapp.room.v1.RoomGuardService/VerifyRoomPresence"
RoomGuardService_ResolveRoomAppCode_FullMethodName = "/hyapp.room.v1.RoomGuardService/ResolveRoomAppCode"
RoomGuardService_CheckSpeakPermission_FullMethodName = "/hyapp.room.v1.RoomGuardService/CheckSpeakPermission"
RoomGuardService_VerifyRoomPresence_FullMethodName = "/hyapp.room.v1.RoomGuardService/VerifyRoomPresence"
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.
@ -1469,6 +1547,8 @@ type RoomGuardServiceClient interface {
CheckSpeakPermission(ctx context.Context, in *CheckSpeakPermissionRequest, opts ...grpc.CallOption) (*CheckSpeakPermissionResponse, error)
VerifyRoomPresence(ctx context.Context, in *VerifyRoomPresenceRequest, opts ...grpc.CallOption) (*VerifyRoomPresenceResponse, 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 {
@ -1509,6 +1589,26 @@ func (c *roomGuardServiceClient) ResolveRoomAppCode(ctx context.Context, in *Res
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.
// All implementations must embed UnimplementedRoomGuardServiceServer
// for forward compatibility.
@ -1518,6 +1618,8 @@ type RoomGuardServiceServer interface {
CheckSpeakPermission(context.Context, *CheckSpeakPermissionRequest) (*CheckSpeakPermissionResponse, error)
VerifyRoomPresence(context.Context, *VerifyRoomPresenceRequest) (*VerifyRoomPresenceResponse, error)
ResolveRoomAppCode(context.Context, *ResolveRoomAppCodeRequest) (*ResolveRoomAppCodeResponse, error)
AuthorizeRoomMediaUpload(context.Context, *AuthorizeRoomMediaUploadRequest) (*AuthorizeRoomMediaUploadResponse, error)
CompleteRoomMediaUpload(context.Context, *CompleteRoomMediaUploadRequest) (*CompleteRoomMediaUploadResponse, error)
mustEmbedUnimplementedRoomGuardServiceServer()
}
@ -1537,6 +1639,12 @@ func (UnimplementedRoomGuardServiceServer) VerifyRoomPresence(context.Context, *
func (UnimplementedRoomGuardServiceServer) ResolveRoomAppCode(context.Context, *ResolveRoomAppCodeRequest) (*ResolveRoomAppCodeResponse, error) {
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) testEmbeddedByValue() {}
@ -1612,6 +1720,42 @@ func _RoomGuardService_ResolveRoomAppCode_Handler(srv interface{}, ctx context.C
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.
// It's only intended for direct use with grpc.RegisterService,
// and not to be introspected or modified (even as a copy)
@ -1631,6 +1775,14 @@ var RoomGuardService_ServiceDesc = grpc.ServiceDesc{
MethodName: "ResolveRoomAppCode",
Handler: _RoomGuardService_ResolveRoomAppCode_Handler,
},
{
MethodName: "AuthorizeRoomMediaUpload",
Handler: _RoomGuardService_AuthorizeRoomMediaUpload_Handler,
},
{
MethodName: "CompleteRoomMediaUpload",
Handler: _RoomGuardService_CompleteRoomMediaUpload_Handler,
},
},
Streams: []grpc.StreamDesc{},
Metadata: "proto/room/v1/room.proto",
@ -1652,6 +1804,7 @@ const (
RoomQueryService_ListRoomContributionRank_FullMethodName = "/hyapp.room.v1.RoomQueryService/ListRoomContributionRank"
RoomQueryService_GetMyRoom_FullMethodName = "/hyapp.room.v1.RoomQueryService/GetMyRoom"
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_ListRoomBackgrounds_FullMethodName = "/hyapp.room.v1.RoomQueryService/ListRoomBackgrounds"
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)
GetMyRoom(ctx context.Context, in *GetMyRoomRequest, opts ...grpc.CallOption) (*GetMyRoomResponse, 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)
ListRoomBackgrounds(ctx context.Context, in *ListRoomBackgroundsRequest, opts ...grpc.CallOption) (*ListRoomBackgroundsResponse, 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
}
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) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(GetRoomSnapshotResponse)
@ -1916,6 +2080,7 @@ type RoomQueryServiceServer interface {
ListRoomContributionRank(context.Context, *ListRoomContributionRankRequest) (*ListRoomContributionRankResponse, error)
GetMyRoom(context.Context, *GetMyRoomRequest) (*GetMyRoomResponse, error)
GetCurrentRoom(context.Context, *GetCurrentRoomRequest) (*GetCurrentRoomResponse, error)
BatchGetUserVoiceRoomPresences(context.Context, *BatchGetUserVoiceRoomPresencesRequest) (*BatchGetUserVoiceRoomPresencesResponse, error)
GetRoomSnapshot(context.Context, *GetRoomSnapshotRequest) (*GetRoomSnapshotResponse, error)
ListRoomBackgrounds(context.Context, *ListRoomBackgroundsRequest) (*ListRoomBackgroundsResponse, 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) {
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) {
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)
}
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) {
in := new(GetRoomSnapshotRequest)
if err := dec(in); err != nil {
@ -2439,6 +2625,10 @@ var RoomQueryService_ServiceDesc = grpc.ServiceDesc{
MethodName: "GetCurrentRoom",
Handler: _RoomQueryService_GetCurrentRoom_Handler,
},
{
MethodName: "BatchGetUserVoiceRoomPresences",
Handler: _RoomQueryService_BatchGetUserVoiceRoomPresences_Handler,
},
{
MethodName: "GetRoomSnapshot",
Handler: _RoomQueryService_GetRoomSnapshot_Handler,

View File

@ -2211,11 +2211,14 @@ func (x *RecordProfileVisitRequest) GetTargetUserId() int64 {
}
type RecordProfileVisitResponse struct {
state protoimpl.MessageState `protogen:"open.v1"`
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"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
state protoimpl.MessageState `protogen:"open.v1"`
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"`
// target_stats_hidden=true 时 target_stats 必须为空;该标志由 user-service
// 基于目标用户当前 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() {
@ -2262,14 +2265,27 @@ func (x *RecordProfileVisitResponse) GetTargetStats() *UserProfileStats {
return nil
}
func (x *RecordProfileVisitResponse) GetTargetStatsHidden() bool {
if x != nil {
return x.TargetStatsHidden
}
return false
}
type ProfileVisitRecord struct {
state protoimpl.MessageState `protogen:"open.v1"`
VisitorUserId int64 `protobuf:"varint,1,opt,name=visitor_user_id,json=visitorUserId,proto3" json:"visitor_user_id,omitempty"`
TargetUserId int64 `protobuf:"varint,2,opt,name=target_user_id,json=targetUserId,proto3" json:"target_user_id,omitempty"`
VisitCount int64 `protobuf:"varint,3,opt,name=visit_count,json=visitCount,proto3" json:"visit_count,omitempty"`
LastVisitedAtMs int64 `protobuf:"varint,4,opt,name=last_visited_at_ms,json=lastVisitedAtMs,proto3" json:"last_visited_at_ms,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
state protoimpl.MessageState `protogen:"open.v1"`
// 隐藏访客身份的记录不返回真实 visitor_user_id字段保持 0客户端必须使用
// visitor_identity_hidden/anonymous_visit_id 分支渲染匿名访客,不能把 0 当作用户 ID。
VisitorUserId int64 `protobuf:"varint,1,opt,name=visitor_user_id,json=visitorUserId,proto3" json:"visitor_user_id,omitempty"`
TargetUserId int64 `protobuf:"varint,2,opt,name=target_user_id,json=targetUserId,proto3" json:"target_user_id,omitempty"`
VisitCount int64 `protobuf:"varint,3,opt,name=visit_count,json=visitCount,proto3" json:"visit_count,omitempty"`
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() {
@ -2330,6 +2346,20 @@ func (x *ProfileVisitRecord) GetLastVisitedAtMs() int64 {
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 {
state protoimpl.MessageState `protogen:"open.v1"`
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" +
"\x04meta\x18\x01 \x01(\v2\x1a.hyapp.user.v1.RequestMetaR\x04meta\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" +
"\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" +
"\x0fvisitor_user_id\x18\x01 \x01(\x03R\rvisitorUserId\x12$\n" +
"\x0etarget_user_id\x18\x02 \x01(\x03R\ftargetUserId\x12\x1f\n" +
"\vvisit_count\x18\x03 \x01(\x03R\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" +
"\x04meta\x18\x01 \x01(\v2\x1a.hyapp.user.v1.RequestMetaR\x04meta\x12\x17\n" +
"\auser_id\x18\x02 \x01(\x03R\x06userId\x12\x12\n" +

View File

@ -262,13 +262,22 @@ message RecordProfileVisitRequest {
message RecordProfileVisitResponse {
bool recorded = 1;
UserProfileStats target_stats = 2;
// target_stats_hidden=true target_stats user-service
// hide_profile_data
bool target_stats_hidden = 3;
}
message ProfileVisitRecord {
// 访 visitor_user_id 0使
// visitor_identity_hidden/anonymous_visit_id 访 0 ID
int64 visitor_user_id = 1;
int64 target_user_id = 2;
int64 visit_count = 3;
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 {

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

View File

@ -1139,6 +1139,8 @@ message EquipUserResourceRequest {
int64 user_id = 3;
int64 resource_id = 4;
string entitlement_id = 5;
// command_id request_id
string command_id = 6;
}
message EquipUserResourceResponse {
@ -2028,6 +2030,9 @@ message VipBenefit {
int64 created_at_ms = 13;
int64 updated_at_ms = 14;
VipBenefitPresentation presentation = 15;
// resource resource_id Appactive resource_type
// resource_id/resource_type URL
Resource resource = 16;
}
message VipLevel {
@ -2404,6 +2409,10 @@ message CheckVipBenefitResponse {
VipState state = 3;
string denial_reason = 4;
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 {

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/profiles:batch` | users | `batchUserProfiles` | 批量查询会话展示资料 |
| 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/host-identity` | users | `getMyHostIdentity` | 查询当前用户 Host/Agency/BD 身份 |
| GET | `/api/v1/users/me/overview` | users | `getMyOverview` | 我的页首屏聚合摘要 |

View File

@ -49,6 +49,9 @@
| `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_payout` | lucky 礼物产出 | `WalletLuckyGiftRewardCredited.amount`UTC 查询会优先用 activity 的 lucky pool 日表覆盖旧统计行。 |
| `lucky_gift_payers` | lucky 礼物付费用户 | 发送 lucky 礼物的用户按日去重。 |

View File

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

View File

@ -260,6 +260,10 @@ func UnaryServerInterceptor(service string) grpc.UnaryServerInterceptor {
if err != nil {
// gRPC access 日志是跨服务排障入口reason 只给稳定错误码message 保留服务端校验分支,便于定位 400/409 的真实拒绝原因。
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 = append(attrs, commonAttrsFromPayload(request)...)

View File

@ -5,13 +5,17 @@ import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
userv1 "hyapp.local/api/proto/user/v1"
walletv1 "hyapp.local/api/proto/wallet/v1"
"hyapp/pkg/xerr"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"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) {
var output bytes.Buffer
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
}
// 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"
"context"
"crypto/rand"
"crypto/sha256"
"encoding/binary"
"encoding/json"
"errors"
@ -287,8 +288,11 @@ func (c *RESTClient) PublishGroupCustomMessage(ctx context.Context, message Cust
}
request := sendGroupMsgRequest{
GroupID: message.GroupID,
Random: randomUint32(),
GroupID: message.GroupID,
// 腾讯云群消息会在短窗口内按 Random + 完整消息内容去重。outbox 的 event_id
// 对同一业务事实稳定,因此从它派生 Random 可收敛“腾讯已成功、本地尚未标记”后的补偿重试;
// 客户端仍应按 payload.event_id 去重,以覆盖超出腾讯去重窗口后的极端重放。
Random: stableMessageRandom(message.EventID),
CloudCustomData: string(payload),
FromAccount: message.FromAccount,
MsgBody: []messageElement{
@ -312,6 +316,11 @@ func (c *RESTClient) PublishGroupCustomMessage(ctx context.Context, message Cust
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。
// 该方法只负责投递协议;业务幂等、隐私字段和重试策略必须由调用方在 outbox 中保证。
func (c *RESTClient) PublishUserCustomMessage(ctx context.Context, message CustomUserMessage) error {

View File

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

View File

@ -3,6 +3,7 @@ package xerr
import (
"errors"
"fmt"
"strings"
)
// Code 是服务内部稳定错误原因。
@ -174,6 +175,10 @@ const (
type Error struct {
Code Code
Message string
// Metadata 只保存业务层显式声明可跨服务、可返回客户端的短字段。
// 原始错误、SQL、配置和第三方响应不得写入这里ToGRPCError 会把它投影到
// google.rpc.ErrorInfo.metadatagateway 再原样写入 envelope.data。
Metadata map[string]string
}
// 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 提取当前仓库约定的业务错误。
func As(err error) (*Error, bool) {
if err == nil {
@ -234,3 +250,41 @@ func MessageOf(err error) string {
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)
detail := &errdetails.ErrorInfo{
Reason: string(domainErr.Code),
Domain: errorDomain,
Reason: string(domainErr.Code),
Domain: errorDomain,
Metadata: cloneMetadata(domainErr.Metadata),
}
withDetails, detailErr := st.WithDetails(detail)
if detailErr != nil {
return st.Err()
if withDetails, detailErr := st.WithDetails(detail); detailErr == nil {
st = withDetails
}
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。
@ -78,6 +110,25 @@ func ReasonFromGRPC(err error) 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 时的降级映射。
func codeFromGRPC(code codes.Code) Code {
switch code {

View File

@ -2,6 +2,7 @@ package xerr
import (
"context"
"errors"
"fmt"
"go/ast"
"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) {
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/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/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/003_vip_resource_equipment_contract.sql"
)
# 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/userclient"
"hyapp-admin-server/internal/integration/walletclient"
"hyapp-admin-server/internal/integration/withdrawalsource"
jobrunner "hyapp-admin-server/internal/job"
"hyapp-admin-server/internal/migration"
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) {
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{
SessionTTL: cfg.ExternalAdmin.SessionTTL,
MaxLoginFailures: cfg.ExternalAdmin.MaxLoginFailures,

View File

@ -69,6 +69,15 @@ finance_google_paid_sync:
poll_interval: "15s"
batch_size: 100
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
migrations:
enabled: true

View File

@ -67,6 +67,16 @@ finance_google_paid_sync:
poll_interval: "15s"
batch_size: 100
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
migrations:
enabled: true

View File

@ -52,6 +52,7 @@ type Config struct {
StatisticsService StatisticsServiceConfig `yaml:"statistics_service"`
DashboardExternalSources []DashboardExternalSourceConfig `yaml:"dashboard_external_sources"`
LegacyCoinSellerRechargeSources []LegacyCoinSellerRechargeSourceConfig `yaml:"legacy_coin_seller_recharge_sources"`
WithdrawalSources []WithdrawalSourceConfig `yaml:"withdrawal_sources"`
}
type MigrationConfig struct {
@ -115,6 +116,18 @@ type StatisticsServiceConfig struct {
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 {
Enabled bool `yaml:"enabled"`
SourceType string `yaml:"type"`
@ -385,6 +398,14 @@ func Default() Config {
RequestTimeout: 5 * time.Second,
},
},
WithdrawalSources: []WithdrawalSourceConfig{
{
Enabled: false,
AppCode: "aslan",
SourceSystem: "likei",
RequestTimeout: 5 * time.Second,
},
},
FinanceGooglePaidSync: FinanceGooglePaidSyncConfig{
Enabled: true,
PollInterval: 15 * time.Second,
@ -623,6 +644,18 @@ func (cfg *Config) Normalize() {
}
}
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)
if cfg.RobotProfileSource.RequestTimeout <= 0 {
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)
}
}
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) == "" {
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 {
appCode = strings.ToUpper(strings.TrimSpace(appCode))
if appCode == "" {

View File

@ -14,6 +14,8 @@ type Client interface {
GetLuckyGiftConfig(ctx context.Context, req *luckygiftv1.GetLuckyGiftConfigRequest) (*luckygiftv1.GetLuckyGiftConfigResponse, error)
UpsertLuckyGiftConfig(ctx context.Context, req *luckygiftv1.UpsertLuckyGiftConfigRequest) (*luckygiftv1.UpsertLuckyGiftConfigResponse, 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)
GetLuckyGiftDrawSummary(ctx context.Context, req *luckygiftv1.GetLuckyGiftDrawSummaryRequest) (*luckygiftv1.GetLuckyGiftDrawSummaryResponse, 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)
}
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) {
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 {
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"`
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"`
SalaryAssetType string `gorm:"size:64;not null;default:''" json:"salaryAssetType"`
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")
)
const legacyCoinSellerRechargeType = "USDT-进货"
type coinSellerTarget struct {
UserID int64
DisplayUserID string
@ -214,7 +216,9 @@ WHERE id = ?`, input.CoinAmount, input.UserID, input.GrantedAt, balanceID); err
return legacyRechargeGrantResult{}, err
}
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, `
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)
@ -258,17 +262,6 @@ func decimalBalanceAfter(earnPointsText string, consumptionText string, delta in
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 {
// 旧服务用 MyBatis ASSIGN_ID 写 bigint 主键;后台没有接入旧雪花 worker只需保证单进程内足够唯一且单调。
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)).
WillReturnResult(sqlmock.NewResult(0, 1))
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))
walletMock.ExpectCommit()

View File

@ -8,6 +8,7 @@ import (
"hyapp-admin-server/internal/appctx"
"hyapp-admin-server/internal/integration/activityclient"
"hyapp-admin-server/internal/integration/walletclient"
"hyapp-admin-server/internal/integration/withdrawalsource"
"hyapp-admin-server/internal/middleware"
"hyapp-admin-server/internal/modules/shared"
"hyapp-admin-server/internal/repository"
@ -20,10 +21,57 @@ import (
type Handler struct {
service *Service
audit shared.OperationLogger
sources *withdrawalsource.Registry
}
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) {

View File

@ -6,6 +6,8 @@ type withdrawalApplicationDTO struct {
ID uint `json:"id"`
AppCode string `json:"appCode"`
AppName string `json:"appName"`
SourceSystem string `json:"sourceSystem"`
SourceApplicationID string `json:"sourceApplicationId"`
UserID string `json:"userId"`
SalaryAssetType string `json:"salaryAssetType"`
WithdrawAmount string `json:"withdrawAmount"`
@ -41,6 +43,8 @@ func withdrawalApplicationDTOFromModel(item model.UserWithdrawalApplication) wit
ID: item.ID,
AppCode: item.AppCode,
AppName: item.AppCode,
SourceSystem: item.SourceSystem,
SourceApplicationID: item.SourceApplicationID,
UserID: item.UserID,
SalaryAssetType: item.SalaryAssetType,
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/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"
"errors"
"fmt"
"math/big"
"strconv"
"strings"
"time"
@ -12,6 +13,7 @@ import (
"hyapp-admin-server/internal/appctx"
"hyapp-admin-server/internal/integration/activityclient"
"hyapp-admin-server/internal/integration/walletclient"
"hyapp-admin-server/internal/integration/withdrawalsource"
"hyapp-admin-server/internal/model"
"hyapp-admin-server/internal/modules/shared"
"hyapp-admin-server/internal/repository"
@ -35,9 +37,23 @@ type Service struct {
store *repository.Store
wallet walletclient.Client
activity activityclient.Client
sources *withdrawalsource.Registry
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 {
SettlePointWithdrawal(ctx context.Context, req *walletv1.SettlePointWithdrawalRequest) (*walletv1.SettlePointWithdrawalResponse, 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() }}
}
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) {
if s == nil || s.store == nil {
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,
ApprovedAtMS: nowMS,
}, 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 {
// 运营通过只确认业务资料并转交财务;申请金额继续保留在 frozen且不能提前向用户发送最终通过通知。
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 {
return repository.WithdrawalApplicationAuditEffect{}, walletErr
}
if noticeErr := s.sendWithdrawalAuditNotice(ctx, auditNoticeInput{
ApplicationID: id,
AppCode: appCode,
UserID: userID,
Stage: stage,
Decision: decision,
Remark: remark,
AuditImageURL: auditImageURL,
AuditTransactionID: transactionID,
WithdrawAmount: item.WithdrawAmount,
WithdrawMethod: item.WithdrawMethod,
RequestID: requestID,
SentAtMS: nowMS,
}); noticeErr != nil {
// 钱包扣冻/释放使用阶段固定 command id通知使用阶段+结果事件 id回调失败会回滚 admin 状态,重试不会重复资金动作或消息。
return repository.WithdrawalApplicationAuditEffect{}, noticeErr
if !isExternalWithdrawal(item) {
noticeErr := s.sendWithdrawalAuditNotice(ctx, auditNoticeInput{
ApplicationID: id,
AppCode: appCode,
UserID: userID,
Stage: stage,
Decision: decision,
Remark: remark,
AuditImageURL: auditImageURL,
AuditTransactionID: transactionID,
WithdrawAmount: item.WithdrawAmount,
WithdrawMethod: item.WithdrawMethod,
RequestID: requestID,
SentAtMS: nowMS,
})
if noticeErr != nil {
// 钱包扣冻/释放使用阶段固定 command id通知使用阶段+结果事件 id回调失败会回滚 admin 状态,重试不会重复资金动作或消息。
return repository.WithdrawalApplicationAuditEffect{}, noticeErr
}
}
return repository.WithdrawalApplicationAuditEffect{CommandID: commandID, TransactionID: transactionID}, nil
})
@ -206,8 +292,7 @@ func (s *Service) executeOperationsRejection(ctx context.Context, actor shared.A
AuditCommandID: commandID,
ApprovedAtMS: nowMS,
}, func(item model.UserWithdrawalApplication) error {
_, err := withdrawalWalletUserID(item)
return err
return validateWithdrawalDecisionTarget(item)
})
if err != nil {
return nil, err
@ -218,17 +303,13 @@ func (s *Service) executeOperationsRejection(ctx context.Context, actor shared.A
return &dto, nil
}
userID, err := withdrawalWalletUserID(*claimed)
if err != nil {
return nil, err
}
if claimed.OperationsReviewerUserID == nil || *claimed.OperationsReviewerUserID == 0 || strings.TrimSpace(claimed.OperationsAuditRemark) == "" {
return nil, errors.New("提现单运营拒绝 claim 不完整")
}
// 重试沿用第一次 claim 的审核人和拒绝原因,不能让资金 metadata、用户通知和后台审计快照随重试人改变。
claimedActor := shared.Actor{UserID: *claimed.OperationsReviewerUserID, Username: claimed.OperationsReviewerName}
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 {
return nil, err
}
@ -236,21 +317,24 @@ func (s *Service) executeOperationsRejection(ctx context.Context, actor shared.A
if claimed.OperationsReviewedAtMS != nil && *claimed.OperationsReviewedAtMS > 0 {
sentAtMS = *claimed.OperationsReviewedAtMS
}
if err := s.sendWithdrawalAuditNotice(ctx, auditNoticeInput{
ApplicationID: id,
AppCode: appCode,
UserID: userID,
Stage: repository.WithdrawalApplicationReviewStageOperations,
Decision: model.WithdrawalApplicationStatusRejected,
Remark: claimedRemark,
AuditTransactionID: transactionID,
WithdrawAmount: claimed.WithdrawAmount,
WithdrawMethod: claimed.WithdrawMethod,
RequestID: requestID,
SentAtMS: sentAtMS,
}); err != nil {
// rejecting 已在前一事务提交;返回错误让运营页展示“重试拒绝”,绝不能回退成 pending。
return nil, err
if !isExternalWithdrawal(*claimed) {
err = s.sendWithdrawalAuditNotice(ctx, auditNoticeInput{
ApplicationID: id,
AppCode: appCode,
UserID: userID,
Stage: repository.WithdrawalApplicationReviewStageOperations,
Decision: model.WithdrawalApplicationStatusRejected,
Remark: claimedRemark,
AuditTransactionID: transactionID,
WithdrawAmount: claimed.WithdrawAmount,
WithdrawMethod: claimed.WithdrawMethod,
RequestID: requestID,
SentAtMS: sentAtMS,
})
if err != nil {
// rejecting 已在前一事务提交;返回错误让运营页展示“重试拒绝”,绝不能回退成 pending。
return nil, err
}
}
updated, err := s.store.FinalizeWithdrawalOperationsRejectionForApp(appCode, id, commandID, transactionID, s.now().UnixMilli())
if err != nil {
@ -268,6 +352,52 @@ func withdrawalWalletUserID(item model.UserWithdrawalApplication) (int64, error)
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) {
reason := strings.TrimSpace(remark)
if reason == "" {

View File

@ -95,6 +95,10 @@ type configRequest struct {
Stages []stageDTO `json:"stages"`
}
type configRollbackRequest struct {
TargetRuleVersion int64 `json:"target_rule_version"`
}
// configUpsertRequest 只服务 HTTP 写入口。滚动发布期间旧前端仍可能发送 day/72h 字段,
// 因此输入 DTO 明确接收两代名字configRequest 继续只暴露 canonical JSON保证 GET 和写入响应不会双写字段。
type configUpsertRequest struct {
@ -297,6 +301,67 @@ func (h *Handler) ListLuckyGiftConfigs(c *gin.Context) {
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) {
if strings.TrimSpace(c.Query("app_code")) == "" {
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 {
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.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/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/summary", middleware.RequirePermission("lucky-gift:view"), h.luckyGift.GetLuckyGiftDrawSummary)
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())
return
}
if err := validateRoomDecorationResourceMaterial(req); err != nil {
response.BadRequest(c, err.Error())
return
}
if err := validateResourceMetadata(req); err != nil {
response.BadRequest(c, err.Error())
return
@ -257,6 +261,10 @@ func (h *Handler) UpdateResource(c *gin.Context) {
response.BadRequest(c, err.Error())
return
}
if err := validateRoomDecorationResourceMaterial(req); err != nil {
response.BadRequest(c, err.Error())
return
}
if err := validateResourceMetadata(req); err != nil {
response.BadRequest(c, err.Error())
return

View File

@ -3,6 +3,8 @@ package resource
import (
"encoding/json"
"fmt"
"math"
"regexp"
"strconv"
"strings"
"time"
@ -22,10 +24,19 @@ const (
resourceTypeAvatarFrame = "avatar_frame"
resourceTypeBadge = "badge"
resourceTypeEmojiPack = "emoji_pack"
resourceTypeMicAnimation = "mic_seat_animation"
resourceTypeProfileCard = "profile_card"
resourceTypeRoomBorder = "room_border"
resourceTypeRoomName = "room_name_style"
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 (
resourceAnimationFormatMP4 = "mp4"
)
@ -126,9 +137,13 @@ type badgeMetadataPayload struct {
type resourceMetadataPayload struct {
AnimationFormat string `json:"animation_format,omitempty"`
AvatarFrameKind string `json:"avatar_frame_kind,omitempty"`
Format string `json:"format,omitempty"`
LevelTrack string `json:"level_track,omitempty"`
MP4AlphaLayout *mp4AlphaLayoutMetadataPayload `json:"mp4_alpha_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 {
@ -644,8 +659,25 @@ func validateResourceMetadata(req resourceRequest) error {
return fmt.Errorf("资源元数据格式不正确")
}
if payload == nil {
if resourceType == resourceTypeRoomName {
return fmt.Errorf("请配置房间名称渐变样式")
}
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 {
animationFormat := strings.ToLower(strings.TrimSpace(payload.AnimationFormat))
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 {
if resourceType == resourceTypeBadge {
return badgeMetadataJSON(badgeForm, badgeKind, levelTrack)
@ -707,6 +772,14 @@ func resourceMetadataJSON(resourceType string, badgeForm string, metadataJSON st
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 layout, err := sanitizeMP4AlphaLayoutMetadata(rawPayload.MP4AlphaLayout); err == nil && layout != nil && layout.Confirmed {
payload.AnimationFormat = resourceAnimationFormatMP4
@ -777,7 +850,7 @@ func parseResourceMetadataPayload(metadataJSON string) (*resourceMetadataPayload
}
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 "{}"
}
body, err := json.Marshal(payload)
@ -875,13 +948,40 @@ func normalizeMP4AlphaLayout(value string) string {
func resourceAllowsAnimationMetadata(resourceType string) bool {
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
default:
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 {
if layout == nil {
return nil

View File

@ -57,6 +57,64 @@ type WithdrawalApplicationAuditAction func(application model.UserWithdrawalAppli
// 校验必须留在持行锁的短事务内,避免坏数据先进入 rejecting 后再永久卡住人工流程。
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) {
var application model.UserWithdrawalApplication
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))
authroutes.RegisterRoutes(api, protected, h.Auth)
financewithdrawal.RegisterIntegrationRoutes(api, h.FinanceWithdrawal)
externaladmin.RegisterExternalRoutes(api, h.ExternalAdmin, externaladmin.BusinessHandlers{
AppUser: h.AppUser, HostOrg: h.HostOrg, RoomAdmin: h.RoomAdmin, Resource: h.Resource,
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)
}
// 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 读模型查询的依赖。
type RoomQueryClient interface {
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)
}
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) {
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)
}
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) {
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)
}
// 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) {
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, `
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,
withdraw_method, withdraw_address, freeze_command_id, freeze_transaction_id, status, operations_status, created_at_ms, updated_at_ms)
VALUES
(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
command.AppCode,
"hyapp_wallet",
command.FreezeCommandID,
formatUserID(command.UserID),
command.SalaryAssetType,
command.WithdrawAmount,

View File

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

View File

@ -101,10 +101,17 @@ func WriteOK(writer http.ResponseWriter, request *http.Request, data any) {
// WriteError 写失败 envelope。
// 错误 message 保持稳定、短小,排障依赖 request_id 追日志,而不是把内部错误直接暴露给客户端。
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{
Code: code,
Message: message,
RequestID: ResponseRequestID(request),
Data: data,
})
}
@ -127,7 +134,30 @@ func WriteRPCError(writer http.ResponseWriter, request *http.Request, err error)
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 响应。

View File

@ -75,6 +75,7 @@ type UserHandlers struct {
ResolveDisplayUserID http.HandlerFunc
BatchUserProfiles http.HandlerFunc
BatchRoomDisplayProfiles http.HandlerFunc
BatchUserVoiceRoomPresences http.HandlerFunc
GetMyOverview http.HandlerFunc
GetMyInviteOverview http.HandlerFunc
GetMyInviteReferrer http.HandlerFunc
@ -179,6 +180,10 @@ type RoomHandlers struct {
SaveRoomBackground http.HandlerFunc
ListRoomBackgrounds http.HandlerFunc
SetRoomBackground http.HandlerFunc
UploadRoomBackground http.HandlerFunc
UploadRoomImage http.HandlerFunc
ApplyRoomDecoration http.HandlerFunc
SendRoomImage http.HandlerFunc
JoinRoom http.HandlerFunc
RoomHeartbeat 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.profile("/users/profiles:batch", "", h.BatchUserProfiles)
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/invite-overview", http.MethodGet, h.GetMyInviteOverview)
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}/follow", "", h.FollowRoom)
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/profile/update", http.MethodPost, h.UpdateRoomProfile)
r.profile("/rooms/backgrounds/save", http.MethodPost, h.SaveRoomBackground)

View File

@ -12,6 +12,7 @@ import (
walletv1 "hyapp.local/api/proto/wallet/v1"
"hyapp/pkg/appcode"
"hyapp/services/gateway-service/internal/auth"
"hyapp/services/gateway-service/internal/transport/http/resourceview"
)
const resourceTypeEmojiPack = "emoji_pack"
@ -39,6 +40,7 @@ type resourceData struct {
AssetURL string `json:"asset_url"`
PreviewURL string `json:"preview_url"`
AnimationURL string `json:"animation_url"`
Format string `json:"format,omitempty"`
MetadataJSON string `json:"metadata_json"`
SortOrder int32 `json:"sort_order"`
CreatedAtMS int64 `json:"created_at_ms"`
@ -88,6 +90,12 @@ type resourceShopPurchaseRequestBody struct {
CommandIDAlt string `json:"commandId"`
}
type resourceEquipRequestBody struct {
EntitlementID string `json:"entitlement_id"`
CommandID string `json:"command_id"`
CommandIDAlt string `json:"commandId"`
}
type resourceShopPurchaseData struct {
OrderID string `json:"order_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")
return
}
var body struct {
EntitlementID string `json:"entitlement_id"`
}
var body resourceEquipRequestBody
if request.Body != nil && request.Body != http.NoBody {
if err := httpkit.DecodeJSON(request.Body, &body); err != nil && !errors.Is(err, io.EOF) {
httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidJSON, "invalid request body")
return
}
}
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{
RequestId: httpkit.RequestIDFromContext(request.Context()),
AppCode: appcode.FromContext(request.Context()),
CommandId: commandID,
AppCode: appCode,
UserId: auth.UserIDFromContext(request.Context()),
ResourceId: resourceID,
EntitlementId: strings.TrimSpace(body.EntitlementID),
@ -550,6 +571,7 @@ func resourceFromProto(item *walletv1.Resource) resourceData {
AssetURL: item.GetAssetUrl(),
PreviewURL: item.GetPreviewUrl(),
AnimationURL: item.GetAnimationUrl(),
Format: resourceview.FormatFromMetadata(item.GetMetadataJson()),
MetadataJSON: item.GetMetadataJson(),
SortOrder: item.GetSortOrder(),
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
lastMyRoom *roomv1.GetMyRoomRequest
lastCurrent *roomv1.GetCurrentRoomRequest
lastVoicePresence *roomv1.BatchGetUserVoiceRoomPresencesRequest
lastSnapshot *roomv1.GetRoomSnapshotRequest
lastBackgrounds *roomv1.ListRoomBackgroundsRequest
lastRocket *roomv1.GetRoomRocketRequest
@ -403,6 +404,7 @@ type fakeRoomQueryClient struct {
feedsResp *roomv1.ListRoomsResponse
myRoomResp *roomv1.GetMyRoomResponse
currentResp *roomv1.GetCurrentRoomResponse
voicePresenceResp *roomv1.BatchGetUserVoiceRoomPresencesResponse
snapshotResp *roomv1.GetRoomSnapshotResponse
backgroundsResp *roomv1.ListRoomBackgroundsResponse
rocketResp *roomv1.GetRoomRocketResponse
@ -414,6 +416,7 @@ type fakeRoomQueryClient struct {
feedsErr error
myRoomErr error
currentErr error
voicePresenceErr error
snapshotErr error
backgroundsErr error
rocketErr error
@ -1390,6 +1393,17 @@ func (f *fakeRoomQueryClient) GetCurrentRoom(_ context.Context, req *roomv1.GetC
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) {
f.lastSnapshot = req
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) {
queryClient := &fakeRoomQueryClient{snapshotResp: &roomv1.GetRoomSnapshotResponse{
Room: &roomv1.RoomSnapshot{
@ -11029,6 +11124,7 @@ func TestProfileGateRejectsIncompleteUsersForRoomIMRTCPaidCapabilities(t *testin
{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: "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 {

View File

@ -1,6 +1,8 @@
package roomapi
import (
"context"
"io"
"net/http"
"time"
@ -11,12 +13,25 @@ import (
"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.
// It keeps gateway as protocol adapter only: room state still belongs to room-service.
type Handler struct {
roomClient client.RoomClient
roomGuardClient client.RoomGuardClient
roomQueryClient client.RoomQueryClient
roomVIPClient client.RoomVIPFeatureClient
roomMediaGuard client.RoomMediaGuardClient
objectUploader ObjectUploader
objectURLVerifier objectURLVerifier
userProfileClient client.UserProfileClient
userCountryClient client.UserCountryQueryClient
userRegionClient client.UserRegionClient
@ -62,6 +77,7 @@ type Config struct {
GiftMaxCount int
GiftMaxUnits int
RTCTokenConfig tencentrtc.TokenConfig
ObjectUploader ObjectUploader
}
func New(config Config) *Handler {
@ -82,10 +98,17 @@ func New(config Config) *Handler {
giftMaxUnits = 5_000
}
comboReader, _ := any(config.AppConfigReader).(giftComboConfigReader)
roomVIPClient, _ := any(config.RoomClient).(client.RoomVIPFeatureClient)
roomMediaGuard, _ := any(config.RoomGuardClient).(client.RoomMediaGuardClient)
urlVerifier, _ := any(config.ObjectUploader).(objectURLVerifier)
return &Handler{
roomClient: config.RoomClient,
roomGuardClient: config.RoomGuardClient,
roomQueryClient: config.RoomQueryClient,
roomVIPClient: roomVIPClient,
roomMediaGuard: roomMediaGuard,
objectUploader: config.ObjectUploader,
objectURLVerifier: urlVerifier,
userProfileClient: config.UserProfileClient,
userCountryClient: config.UserCountryClient,
userRegionClient: config.UserRegionClient,
@ -131,6 +154,10 @@ func (h *Handler) RoomHandlers() httproutes.RoomHandlers {
SaveRoomBackground: h.saveRoomBackground,
ListRoomBackgrounds: h.listRoomBackgrounds,
SetRoomBackground: h.setRoomBackground,
UploadRoomBackground: h.uploadRoomBackground,
UploadRoomImage: h.uploadRoomImage,
ApplyRoomDecoration: h.applyRoomDecoration,
SendRoomImage: h.sendRoomImage,
JoinRoom: h.joinRoom,
RoomHeartbeat: h.roomHeartbeat,
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) {
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/xerr"
"hyapp/services/gateway-service/internal/auth"
"hyapp/services/gateway-service/internal/transport/http/resourceview"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
@ -1233,24 +1234,44 @@ func (h *Handler) updateRoomProfile(writer http.ResponseWriter, request *http.Re
// saveRoomBackground 保存房主上传后的背景图 URL素材归 room-service 按房间维度管理。
func (h *Handler) saveRoomBackground(writer http.ResponseWriter, request *http.Request) {
var body struct {
RoomID string `json:"room_id"`
ImageURL string `json:"image_url"`
RoomID string `json:"room_id"`
CommandID string `json:"command_id"`
ImageURL string `json:"image_url"`
Media *roomMediaData `json:"media"`
}
if !httpkit.Decode(writer, request, &body) {
return
}
body.RoomID = strings.TrimSpace(body.RoomID)
body.CommandID = strings.TrimSpace(body.CommandID)
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 == "" {
httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
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{
Meta: httpkit.RoomMeta(request, body.RoomID, ""),
Meta: httpkit.RoomMeta(request, body.RoomID, body.CommandID),
RoomId: body.RoomID,
ImageUrl: body.ImageURL,
Media: media,
})
httpkit.Write(writer, request, saveRoomBackgroundDataFromProto(resp), err)
}
@ -2824,6 +2845,11 @@ func roomAppearanceResourceFromProto(item *walletv1.Resource, entitlementID stri
"entitlement_id": entitlementID,
"expires_at_ms": expiresAtMS,
}
if format := resourceview.FormatFromMetadata(item.GetMetadataJson()); format != "" {
// 房间首屏/profile 是其他用户渲染麦位动效的权威快照,因此必须投影显式格式;
// 缺省时不猜 animation_url 后缀,避免 CDN 地址变化导致客户端选择错误 renderer。
resource["format"] = format
}
if item.GetResourceType() == "badge" {
badgeForm, badgeKind, levelTrack := roomBadgeMetadataFields(item.GetMetadataJson())
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 {
RoomID string `json:"room_id"`
IMGroupID string `json:"im_group_id"`
OwnerUserID string `json:"owner_user_id,omitempty"`
Title string `json:"title,omitempty"`
CoverURL string `json:"cover_url,omitempty"`
Mode string `json:"mode,omitempty"`
Status string `json:"status,omitempty"`
Locked bool `json:"locked"`
Heat int64 `json:"heat"`
OnlineCount int32 `json:"online_count"`
SeatCount int32 `json:"seat_count"`
OccupiedSeatCount int32 `json:"occupied_seat_count"`
VisibleRegionID int64 `json:"visible_region_id,omitempty"`
AppCode string `json:"app_code,omitempty"`
RoomShortID string `json:"room_short_id,omitempty"`
CountryCode string `json:"country_code,omitempty"`
CountryFlag string `json:"country_flag,omitempty"`
RoomID string `json:"room_id"`
IMGroupID string `json:"im_group_id"`
OwnerUserID string `json:"owner_user_id,omitempty"`
Title string `json:"title,omitempty"`
CoverURL string `json:"cover_url,omitempty"`
Mode string `json:"mode,omitempty"`
Status string `json:"status,omitempty"`
Locked bool `json:"locked"`
Heat int64 `json:"heat"`
OnlineCount int32 `json:"online_count"`
SeatCount int32 `json:"seat_count"`
OccupiedSeatCount int32 `json:"occupied_seat_count"`
VisibleRegionID int64 `json:"visible_region_id,omitempty"`
AppCode string `json:"app_code,omitempty"`
RoomShortID string `json:"room_short_id,omitempty"`
CountryCode string `json:"country_code,omitempty"`
CountryFlag string `json:"country_flag,omitempty"`
RoomBorder *roomDecorationData `json:"room_border,omitempty"`
RoomNameStyle *roomDecorationData `json:"room_name_style,omitempty"`
}
type createRoomData struct {
@ -378,12 +380,14 @@ type updateRoomProfileData struct {
}
type roomBackgroundData struct {
BackgroundID int64 `json:"background_id"`
RoomID string `json:"room_id"`
ImageURL string `json:"image_url"`
CreatedByUserID string `json:"created_by_user_id,omitempty"`
CreatedAtMS int64 `json:"created_at_ms"`
UpdatedAtMS int64 `json:"updated_at_ms"`
BackgroundID int64 `json:"background_id"`
RoomID string `json:"room_id"`
ImageURL string `json:"image_url"`
CreatedByUserID string `json:"created_by_user_id,omitempty"`
CreatedAtMS int64 `json:"created_at_ms"`
UpdatedAtMS int64 `json:"updated_at_ms"`
CommandID string `json:"command_id,omitempty"`
Media *roomMediaData `json:"media,omitempty"`
}
type saveRoomBackgroundData struct {
@ -394,6 +398,7 @@ type saveRoomBackgroundData struct {
type listRoomBackgroundsData struct {
Backgrounds []roomBackgroundData `json:"backgrounds"`
SelectedBackgroundURL string `json:"selected_background_url,omitempty"`
SelectedBackground *roomBackgroundData `json:"selected_background,omitempty"`
ServerTimeMS int64 `json:"server_time_ms"`
}
@ -426,24 +431,27 @@ type roomCommandResultData struct {
}
type roomInitialData struct {
RoomID string `json:"room_id"`
IMGroupID string `json:"im_group_id"`
RoomShortID string `json:"room_short_id,omitempty"`
Title string `json:"title,omitempty"`
CoverURL string `json:"cover_url,omitempty"`
BackgroundURL string `json:"background_url,omitempty"`
Description string `json:"description,omitempty"`
OwnerUserID string `json:"owner_user_id,omitempty"`
Mode string `json:"mode,omitempty"`
Status string `json:"status,omitempty"`
ChatEnabled bool `json:"chat_enabled"`
Locked bool `json:"locked"`
Heat int64 `json:"heat"`
OnlineCount int32 `json:"online_count"`
SeatCount int32 `json:"seat_count"`
OccupiedSeatCount int32 `json:"occupied_seat_count"`
VisibleRegionID int64 `json:"visible_region_id,omitempty"`
Version int64 `json:"version"`
RoomID string `json:"room_id"`
IMGroupID string `json:"im_group_id"`
RoomShortID string `json:"room_short_id,omitempty"`
Title string `json:"title,omitempty"`
CoverURL string `json:"cover_url,omitempty"`
BackgroundURL string `json:"background_url,omitempty"`
Description string `json:"description,omitempty"`
OwnerUserID string `json:"owner_user_id,omitempty"`
Mode string `json:"mode,omitempty"`
Status string `json:"status,omitempty"`
ChatEnabled bool `json:"chat_enabled"`
Locked bool `json:"locked"`
Heat int64 `json:"heat"`
OnlineCount int32 `json:"online_count"`
SeatCount int32 `json:"seat_count"`
OccupiedSeatCount int32 `json:"occupied_seat_count"`
VisibleRegionID int64 `json:"visible_region_id,omitempty"`
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 {
@ -744,6 +752,8 @@ func roomListItemDataFromProto(room *roomv1.RoomListItem) roomListItemData {
RoomShortID: room.GetRoomShortId(),
CountryCode: 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{
Backgrounds: backgrounds,
SelectedBackgroundURL: resp.GetSelectedBackgroundUrl(),
SelectedBackground: roomBackgroundDataPointerFromProto(resp.GetSelectedBackground()),
ServerTimeMS: resp.GetServerTimeMs(),
}
}
@ -820,9 +831,19 @@ func roomBackgroundDataFromProto(background *roomv1.RoomBackgroundImage) roomBac
CreatedByUserID: formatOptionalUserID(background.GetCreatedByUserId()),
CreatedAtMS: background.GetCreatedAtMs(),
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 {
if resp == nil {
return roomSnapshotData{}
@ -874,6 +895,9 @@ func roomInitialRoomDataFromSnapshot(snapshot *roomv1.RoomSnapshot, roomID strin
OccupiedSeatCount: occupiedSeatCount,
VisibleRegionID: snapshot.GetVisibleRegionId(),
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,
GiftMaxCount: h.giftMaxCount,
GiftMaxUnits: h.giftMaxUnits,
ObjectUploader: h.objectUploader,
RTCTokenConfig: tencentrtc.TokenConfig{
Enabled: h.tencentRTC.Enabled,
SDKAppID: h.tencentRTC.SDKAppID,
@ -153,6 +154,7 @@ func (h *Handler) Routes(jwtVerifier *auth.Verifier) http.Handler {
userHandlers := userAPI.UserHandlers()
userHandlers.BatchUserProfiles = messageAPI.BatchUserProfiles
userHandlers.BatchRoomDisplayProfiles = roomAPI.BatchRoomDisplayProfiles
userHandlers.BatchUserVoiceRoomPresences = roomAPI.BatchUserVoiceRoomPresences
userHandlers.GetMyGiftWall = walletAPI.GetMyGiftWall
userHandlers.ListMyResources = resourceAPI.ListMyResources
userHandlers.EquipMyResource = resourceAPI.EquipMyResource

View File

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

View File

@ -1,19 +1,26 @@
package userapi
import (
"hyapp/services/gateway-service/internal/transport/http/httpkit"
"net/http"
"strings"
userv1 "hyapp.local/api/proto/user/v1"
"hyapp/services/gateway-service/internal/auth"
"hyapp/services/gateway-service/internal/transport/http/httpkit"
)
const (
profileVisitorPageMax int32 = 100
profileVisitorPageSizeMax int32 = 100
)
type profileVisitRecordData struct {
VisitorUserID string `json:"visitor_user_id"`
TargetUserID string `json:"target_user_id"`
VisitCount int64 `json:"visit_count"`
LastVisitedAtMS int64 `json:"last_visited_at_ms"`
VisitorUserID string `json:"visitor_user_id,omitempty"`
AnonymousVisitID string `json:"anonymous_visit_id,omitempty"`
VisitorIdentityHidden bool `json:"visitor_identity_hidden"`
TargetUserID string `json:"target_user_id"`
VisitCount int64 `json:"visit_count"`
LastVisitedAtMS int64 `json:"last_visited_at_ms"`
}
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")
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{
Meta: httpkit.UserMeta(request, ""),
UserId: auth.UserIDFromContext(request.Context()),
@ -192,7 +211,14 @@ func (h *Handler) recordProfileVisit(writer http.ResponseWriter, request *http.R
httpkit.WriteRPCError(writer, request, err)
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 {
profileResp, err := h.userProfileClient.GetUser(request.Context(), &userv1.GetUserRequest{
Meta: httpkit.UserMeta(request, ""),
@ -303,12 +329,19 @@ func profileVisitData(record *userv1.ProfileVisitRecord) profileVisitRecordData
if record == nil {
return profileVisitRecordData{}
}
return profileVisitRecordData{
VisitorUserID: userIDString(record.GetVisitorUserId()),
TargetUserID: userIDString(record.GetTargetUserId()),
VisitCount: record.GetVisitCount(),
LastVisitedAtMS: record.GetLastVisitedAtMs(),
data := profileVisitRecordData{
VisitorIdentityHidden: record.GetVisitorIdentityHidden(),
TargetUserID: userIDString(record.GetTargetUserId()),
VisitCount: record.GetVisitCount(),
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 {

View File

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

View File

@ -1,6 +1,9 @@
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 {
Level int32 `json:"level"`
@ -30,6 +33,7 @@ type resourceData struct {
AssetURL string `json:"asset_url"`
PreviewURL string `json:"preview_url"`
AnimationURL string `json:"animation_url"`
Format string `json:"format,omitempty"`
MetadataJSON string `json:"metadata_json"`
SortOrder int32 `json:"sort_order"`
CreatedAtMS int64 `json:"created_at_ms"`
@ -65,6 +69,7 @@ func resourceFromProto(item *walletv1.Resource) resourceData {
AssetURL: item.GetAssetUrl(),
PreviewURL: item.GetPreviewUrl(),
AnimationURL: item.GetAnimationUrl(),
Format: resourceview.FormatFromMetadata(item.GetMetadataJson()),
MetadataJSON: item.GetMetadataJson(),
SortOrder: item.GetSortOrder(),
CreatedAtMS: item.GetCreatedAtMs(),

View File

@ -233,7 +233,12 @@ func (h *Handler) triggerVIPOnlineNotice(writer http.ResponseWriter, request *ht
// 客户端展示态可能滞后,执行前必须重新查 effective benefit体验卡排除、过期和后台停用
// 都以 wallet-service 此刻的判定为准,不能仅比较 VIP9 等级。
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
}
equippedResp, err := h.walletClient.BatchGetUserEquippedResources(ctx, &walletv1.BatchGetUserEquippedResourcesRequest{
@ -438,6 +443,7 @@ func vipBenefitsFromProto(items []*walletv1.VipBenefit) []vipBenefitData {
CreatedAtMS: item.GetCreatedAtMs(),
UpdatedAtMS: item.GetUpdatedAtMs(),
Presentation: vipBenefitPresentationFromProto(item.GetPresentation()),
Resource: resourcePointerFromProto(item.GetResource()),
})
}
return result

View File

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

View File

@ -68,6 +68,13 @@ type luckyGiftUserProfileRefresher interface {
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。
type WalletClient interface {
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))
}
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) {
if err := s.requireRepository(); err != nil {
return nil, 0, err

View File

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

View File

@ -147,7 +147,7 @@ func (r *Repository) executeDynamicLuckyGiftDrawBatch(ctx context.Context, tx *s
if err != nil {
return nil, err
}
fundSplit, err := luckyDynamicFundSplit(cmd.CoinSpent, cmd.GiftIncomeCoins, strategyConfig)
fundSplit, err := luckyDynamicFundSplit(cmd.CoinSpent, cmd.HostPeriodDiamondAdded, strategyConfig)
if err != nil {
return nil, err
}
@ -204,7 +204,7 @@ func (r *Repository) executeDynamicLuckyGiftDrawBatch(ctx context.Context, tx *s
return nil, err
}
unitSplit := unitSplits[index-1]
unitCommand.GiftIncomeCoins = unitSplit.AnchorReturnCoins
unitCommand.HostPeriodDiamondAdded = unitSplit.AnchorReturnCoins
if unitCommand.CoinSpent <= 0 {
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{
CommandID: "dynamic-99", PoolID: internalRule.PoolID, UserID: 1001, TargetUserID: 2001,
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.CommandID = "dynamic-missing-wallet-paid-at"
@ -330,7 +330,7 @@ func assertDynamicTokenSurvivesUTCDayBoundary(t *testing.T, repo *Repository, db
earn := domain.DrawCommand{
CommandID: "token-earn", PoolID: rule.PoolID, UserID: userID, TargetUserID: 1,
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 {
t.Fatalf("earn milestone token: %v", err)
@ -350,7 +350,7 @@ func assertDynamicTokenSurvivesUTCDayBoundary(t *testing.T, repo *Repository, db
retain := earn
retain.CommandID = "token-retain-next-day"
retain.CoinSpent = 1
retain.GiftIncomeCoins = 0
retain.HostPeriodDiamondAdded = 0
retain.PaidAtMS = dayTwoMS
if _, err := repo.ExecuteLuckyGiftDraw(ctx, retain, dayTwoMS); err != nil {
t.Fatalf("retain token across UTC day: %v", err)
@ -408,7 +408,7 @@ func assertDynamicSpendBatchRollbackAndRestart(t *testing.T, repo *Repository, d
command := domain.DrawCommand{
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",
GiftCount: 3, CoinSpent: 30, GiftIncomeCoins: 0, PaidAtMS: nowMS,
GiftCount: 3, CoinSpent: 30, HostPeriodDiamondAdded: 0, PaidAtMS: nowMS,
}
const rollbackTrigger = "trg_lucky_dual_spend_rollback"
if _, err := db.Exec("DROP TRIGGER IF EXISTS " + rollbackTrigger); err != nil {
@ -938,7 +938,7 @@ func TestLuckyDynamicDualSourcesSharePoolRTPRiskAndAuditAccounting(t *testing.T)
command := domain.DrawCommand{
CommandID: label, PoolID: runtimeConfig.PoolID, GiftID: runtimeConfig.GiftID, GiftCount: 1,
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}
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 与规则主播份额完全一致
// dynamic_v3 不能把旧 room 缺字段解释成 0也不能把 App 钱包配置的其他返币比例静默塞进盈利池;
// 启用新规则前必须先完成 wallet/room 契约滚动升级并让礼物返币配置与本规则一致
// luckyDynamicFundSplit 以 wallet 已按后台礼物比例配置实际入账的主播钻石为唯一主播份额
// 幸运礼物规则只决定公共奖池比例;主播份额变化后的剩余金额自然进入平台利润,不能再用规则里的
// anchor_rate_ppm 覆盖 wallet owner 事实,也不能把“返还金币比例”误当成主播收益
func luckyDynamicFundSplit(totalSpent, actualAnchorIncome int64, config domain.StrategyConfig) (domain.StrategyFundSplit, error) {
configured, err := domain.SplitLuckyGiftStrategyFunds(totalSpent, config)
if err != nil {
return domain.StrategyFundSplit{}, err
}
if actualAnchorIncome != configured.AnchorReturnCoins {
return domain.StrategyFundSplit{}, fmt.Errorf(
"dynamic lucky gift anchor income mismatch: wallet=%d configured=%d",
actualAnchorIncome,
configured.AnchorReturnCoins,
)
if actualAnchorIncome < 0 || actualAnchorIncome > totalSpent || configured.PublicPoolCoins > totalSpent-actualAnchorIncome {
return domain.StrategyFundSplit{}, fmt.Errorf("dynamic lucky gift wallet anchor income is invalid: spent=%d anchor=%d", totalSpent, actualAnchorIncome)
}
return configured, nil
return domain.StrategyFundSplit{
PublicPoolCoins: configured.PublicPoolCoins,
ProfitPoolCoins: totalSpent - configured.PublicPoolCoins - actualAnchorIncome,
AnchorReturnCoins: actualAnchorIncome,
}, nil
}
// 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()
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)
}
if _, err := luckyDynamicFundSplit(100, 0, config); err == nil {
t.Fatal("missing wallet anchor snapshot was accepted")
if split, err := luckyDynamicFundSplit(100, 0, config); err != nil || split != (domain.StrategyFundSplit{PublicPoolCoins: 98, ProfitPoolCoins: 2, AnchorReturnCoins: 0}) {
t.Fatalf("zero wallet anchor split=%+v err=%v", split, err)
}
if _, err := luckyDynamicFundSplit(100, 10, config); err == nil {
t.Fatal("wallet anchor ratio different from rule was accepted")
if split, err := luckyDynamicFundSplit(100, 2, config); err != nil || split != (domain.StrategyFundSplit{PublicPoolCoins: 98, ProfitPoolCoins: 0, AnchorReturnCoins: 2}) {
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()
}
// 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 = `
SELECT app_code, pool_id, rule_version, enabled, strategy_version, target_rtp_ppm, pool_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(),
Recharge30DCoins: meta.GetRecharge_30DCoins(),
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
}
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) {
ctx = appcode.WithContext(ctx, req.GetMeta().GetAppCode())
draws, total, err := s.svc.ListDraws(ctx, domain.DrawQuery{

View File

@ -81,6 +81,12 @@ rocketmq:
topic: "hyapp_user_outbox"
consumer_group: "hyapp-room-user-region-sync"
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 保证不和其他下游共享消费位点。
lucky_gift_outbox:
enabled: true

View File

@ -91,6 +91,12 @@ rocketmq:
topic: "hyapp_user_outbox"
consumer_group: "hyapp-room-user-region-sync"
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 进入房间展示链路。
lucky_gift_outbox:
enabled: true

View File

@ -84,6 +84,12 @@ rocketmq:
topic: "hyapp_user_outbox"
consumer_group: "hyapp-room-user-region-sync"
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 消费位点或直连幸运礼物库。
lucky_gift_outbox:
enabled: true

View File

@ -54,6 +54,8 @@ CREATE TABLE IF NOT EXISTS room_list_entries (
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 '排序分值',
room_border_json JSON NULL COMMENT '当前未过期房间边框资源快照',
room_name_style_json JSON NULL COMMENT '当前未过期彩色房名资源快照',
created_at_ms BIGINT NOT NULL COMMENT '创建时间UTC epoch ms',
updated_at_ms BIGINT NOT NULL COMMENT '更新时间UTC epoch ms',
PRIMARY KEY (app_code, room_id),
@ -114,15 +116,82 @@ CREATE TABLE IF NOT EXISTS room_background_images (
app_code VARCHAR(32) NOT NULL COMMENT '应用编码,用于多租户隔离',
background_id BIGINT NOT NULL AUTO_INCREMENT 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_at_ms BIGINT NOT NULL COMMENT '创建时间UTC epoch ms',
updated_at_ms BIGINT NOT NULL COMMENT '更新时间UTC epoch ms',
PRIMARY KEY (background_id),
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)
) 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 (
app_code VARCHAR(32) NOT NULL COMMENT '应用编码,用于多租户隔离',
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/tencentrtc"
"hyapp/pkg/usermq"
"hyapp/pkg/walletmq"
"hyapp/services/room-service/internal/config"
"hyapp/services/room-service/internal/healthcheck"
"hyapp/services/room-service/internal/integration"
@ -66,7 +67,7 @@ type App struct {
healthHTTP *healthhttp.Server
// mqProducers 在 outbox worker 启动前打开,承载 fanout 和延迟发射唤醒。
mqProducers []*rocketmqx.Producer
// mqConsumers 在 gRPC 服务启动前订阅,承接火箭唤醒、用户区域同步和幸运礼物 owner 事实。
// mqConsumers 在 gRPC 服务启动前订阅,承接火箭唤醒、用户区域、wallet 装扮失效和幸运礼物 owner 事实。
mqConsumers []*rocketmqx.Consumer
// workers 统一控制本节点后台 worker关闭时必须先停止 worker 再释放 MySQL/Redis。
workers *serviceapp.BackgroundGroup
@ -164,7 +165,7 @@ func New(cfg config.Config) (*App, error) {
roomPublisher := integration.NewNoopRoomEventPublisher()
outboxPublishers := make([]integration.OutboxPublisher, 0, 3)
var rtcUserRemover integration.RTCUserRemover
var tencentPublisher *integration.TencentIMPublisher
var tencentPublisher integration.OutboxPublisher
if cfg.TencentIM.Enabled {
// 腾讯云 IM 替代自研 IMroom-service 只负责服务端 REST 群消息桥接。
tencentClient, err := tencentim.NewRESTClient(cfg.TencentIM.RESTConfig())
@ -175,8 +176,16 @@ func New(cfg config.Config) (*App, error) {
_ = redisClient.Close()
return nil, err
}
tencentPublisher = integration.NewTencentIMPublisher(tencentClient)
roomPublisher = tencentPublisher
concreteTencentPublisher := integration.NewTencentIMPublisher(tencentClient)
tencentPublisher = concreteTencentPublisher
roomPublisher = concreteTencentPublisher
// 图片消息和 VIP 房间装扮都是用户可见的持久事实,必须由 durable room_outbox 补偿腾讯 IM
// 只过滤这两类事件,避免把现有 direct-IM 展示事件整体接入后制造双消息。
outboxPublishers = append(outboxPublishers, integration.NewEventTypeOutboxPublisher(
concreteTencentPublisher,
"RoomImageMessageSent",
"RoomDecorationChanged",
))
}
if cfg.TencentRTC.Enabled {
rtcClient, err := tencentrtc.NewRESTClient(cfg.TencentRTC.RESTConfig())
@ -191,7 +200,7 @@ func New(cfg config.Config) (*App, error) {
}
mqProducers := make([]*rocketmqx.Producer, 0, 2)
mqConsumers := make([]*rocketmqx.Consumer, 0, 4)
mqConsumers := make([]*rocketmqx.Consumer, 0, 5)
var rocketLaunchScheduler integration.RoomRocketLaunchScheduler
if cfg.OutboxWorker.PublishMode == config.OutboxPublishModeDirect || cfg.OutboxWorker.PublishMode == config.OutboxPublishModeDual {
if activityConn != nil {
@ -296,6 +305,46 @@ func New(cfg config.Config) (*App, error) {
}
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 {
luckyGiftConsumer, err := rocketmqx.NewConsumer(luckyGiftRocketMQConsumerConfig(cfg.RocketMQ))
if err != nil {

View File

@ -156,6 +156,7 @@ type RocketMQConfig struct {
RoomOutbox RoomOutboxMQConfig `yaml:"room_outbox"`
RocketLaunch RocketLaunchMQConfig `yaml:"rocket_launch"`
UserOutbox UserOutboxMQConfig `yaml:"user_outbox"`
WalletOutbox WalletOutboxMQConfig `yaml:"wallet_outbox"`
LuckyGiftOutbox LuckyGiftOutboxMQConfig `yaml:"lucky_gift_outbox"`
}
@ -191,6 +192,15 @@ type UserOutboxMQConfig struct {
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 已提交的中奖事实。
// 该 consumer 只派生房间展示 outbox不读取幸运礼物库也不修改 Room Cell 状态。
type LuckyGiftOutboxMQConfig struct {
@ -381,6 +391,12 @@ func defaultRocketMQConfig() RocketMQConfig {
ConsumerGroup: "hyapp-room-user-region-sync",
ConsumerMaxReconsumeTimes: 16,
},
WalletOutbox: WalletOutboxMQConfig{
Enabled: false,
Topic: "hyapp_wallet_outbox",
ConsumerGroup: "hyapp-room-wallet-decoration-sync",
ConsumerMaxReconsumeTimes: 16,
},
LuckyGiftOutbox: LuckyGiftOutboxMQConfig{
Enabled: false,
Topic: "hyapp_lucky_gift_outbox",
@ -629,6 +645,15 @@ func normalizeRocketMQConfig(cfg RocketMQConfig, publishMode string) (RocketMQCo
if cfg.UserOutbox.ConsumerMaxReconsumeTimes <= 0 {
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 == "" {
cfg.LuckyGiftOutbox.Topic = defaults.LuckyGiftOutbox.Topic
}
@ -638,7 +663,7 @@ func normalizeRocketMQConfig(cfg RocketMQConfig, publishMode string) (RocketMQCo
if cfg.LuckyGiftOutbox.ConsumerMaxReconsumeTimes <= 0 {
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
}
if publishMode == OutboxPublishModeMQ || publishMode == OutboxPublishModeDual {

View File

@ -25,6 +25,18 @@ type WalletClient interface {
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 幸运礼物抽奖边界的同步依赖。
type LuckyGiftClient interface {
// CheckLuckyGift 在扣费前确认奖池规则可用;失败时不能扣费。

View File

@ -50,6 +50,16 @@ func (c *grpcWalletClient) GetMyVip(ctx context.Context, req *walletv1.GetMyVipR
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 {
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"
"fmt"
"strings"
"time"
"google.golang.org/protobuf/proto"
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()),
"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
case "RoomChatEnabledChanged":
// 公屏开关事件提示客户端更新输入态,真正发言仍由 CheckSpeakPermission 拦截。
@ -321,7 +362,6 @@ func roomEventFromEnvelope(envelope *roomeventsv1.EventEnvelope) (tencentim.Room
"gift_count": fmt.Sprintf("%d", body.GetGiftCount()),
"coin_spent": fmt.Sprintf("%d", body.GetCoinSpent()),
"billing_receipt_id": body.GetBillingReceiptId(),
"target_gift_value": fmt.Sprintf("%d", body.GetTargetGiftValue()),
"gift_type_code": body.GetGiftTypeCode(),
"cp_relation_type": body.GetCpRelationType(),
"gift_name": body.GetGiftName(),
@ -337,6 +377,14 @@ func roomEventFromEnvelope(envelope *roomeventsv1.EventEnvelope) (tencentim.Room
"receiver_display_user_id": body.GetReceiverDisplayUserId(),
"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
case "RoomGiftBatchSent":
// 批量送礼展示事件只面向新客户端;逐目标 RoomGiftSent 仍由 outbox 给统计、CP 和礼物墙消费。
@ -448,9 +496,13 @@ func roomEventFromEnvelope(envelope *roomeventsv1.EventEnvelope) (tencentim.Room
}
base.ActorUserID = body.GetSenderUserId()
base.TargetUserID = body.GetTargetUserId()
base.GiftValue = body.GetEffectiveRewardCoins()
// 中奖金额保留在 effective_reward_coins 供动画展示;顶层 gift_value 必须为 0
// 否则 Flutter 会把纯展示中奖额累计进机器人发送者的成员贡献。
base.GiftValue = 0
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(),
"command_id": body.GetCommandId(),
"sender_user_id": fmt.Sprintf("%d", body.GetSenderUserId()),
@ -615,6 +667,10 @@ func eventTypeForClient(eventType string) string {
return "room_profile_updated"
case "RoomBackgroundChanged":
return "room_background_changed"
case "RoomDecorationChanged":
return "room_decoration_changed"
case "RoomImageMessageSent":
return "room_image_message"
case "RoomChatEnabledChanged":
return "room_chat_enabled_changed"
case "RoomPasswordChanged":
@ -651,3 +707,22 @@ func eventTypeForClient(eventType string) string {
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" {
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) {
@ -291,16 +315,28 @@ func TestRoomRobotLuckyGiftDrawnPublishesLuckyGiftIMEvent(t *testing.T) {
if err != nil {
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)
}
if event.Attributes["draw_id"] != "robot_lucky_draw_1" ||
event.Attributes["gift_id"] != "gift_lucky_1" ||
event.Attributes["pool_id"] != "robot_lucky_display" ||
event.Attributes["gift_value"] != "0" ||
event.Attributes["effective_reward_coins"] != "500" ||
event.Attributes["reward_status"] != "granted" {
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) {

View File

@ -4,6 +4,8 @@ package command
import (
"encoding/json"
"fmt"
"hyapp/services/room-service/internal/room/state"
)
// Command 统一表达可写入 command log 的房间命令。
@ -109,10 +111,48 @@ type SetRoomBackground struct {
BackgroundID int64 `json:"background_id"`
// BackgroundURL 是提交前从房间背景素材表解析出的确定性快照,恢复时不再回查列表表。
BackgroundURL string `json:"background_url"`
// Background 保存完整媒体事实;旧 command log 缺字段时恢复继续使用 BackgroundID/BackgroundURL。
Background state.RoomBackground `json:"background,omitempty"`
}
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 定义业务进房请求。
type EntryVehicleSnapshot struct {
ResourceID int64 `json:"resource_id,omitempty"`
@ -739,6 +779,13 @@ func IdempotencyPayload(payload []byte) ([]byte, error) {
delete(values, "publish_deadline_ms")
// ConfirmMicPublishing 的接受时间由 room-service 生成,不能参与客户端请求幂等语义。
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)
}
@ -772,6 +819,12 @@ func Deserialize(commandType string, payload []byte) (Command, error) {
cmd = &UpdateRoomProfile{}
case SetRoomBackground{}.Type():
cmd = &SetRoomBackground{}
case ApplyRoomDecoration{}.Type():
cmd = &ApplyRoomDecoration{}
case ReconcileRoomDecoration{}.Type():
cmd = &ReconcileRoomDecoration{}
case SendRoomImage{}.Type():
cmd = &SendRoomImage{}
case JoinRoom{}.Type():
cmd = &JoinRoom{}
case RoomHeartbeat{}.Type():

View File

@ -2,12 +2,14 @@ package service
import (
"context"
"errors"
"strings"
"time"
"unicode/utf8"
roomeventsv1 "hyapp.local/api/proto/events/room/v1"
roomv1 "hyapp.local/api/proto/room/v1"
"hyapp/pkg/giftlimits"
"hyapp/pkg/roomid"
"hyapp/pkg/tencentim"
"hyapp/pkg/xerr"
@ -21,28 +23,82 @@ import (
// 该动作只维护低频素材表,不改变 Room Cell 当前背景;真正生效必须调用 SetRoomBackground。
func (s *Service) SaveRoomBackground(ctx context.Context, req *roomv1.SaveRoomBackgroundRequest) (*roomv1.SaveRoomBackgroundResponse, error) {
ctx = contextFromMeta(ctx, req.GetMeta())
roomID, actorUserID, imageURL, err := normalizeSaveRoomBackground(req)
roomID, actorUserID, commandID, media, err := normalizeSaveRoomBackground(req)
if err != nil {
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 {
return nil, err
}
if err := s.requireCustomRoomBackgroundBenefit(ctx, req.GetMeta().GetRequestId(), actorUserID); err != nil {
// 保存素材本身就会形成持久数据P1 App 必须在写库前校验当前 effective benefit。
access, err := s.loadEffectiveVIPAccess(ctx, req.GetMeta().GetRequestId(), actorUserID)
if err != nil {
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()
background, err := s.repository.SaveRoomBackground(ctx, RoomBackgroundImage{
AppCode: req.GetMeta().GetAppCode(),
RoomID: roomID,
ImageURL: imageURL,
CommandID: commandID,
Media: media,
CreatedByUserID: actorUserID,
CreatedAtMS: nowMS,
UpdatedAtMS: nowMS,
})
if err != nil {
if errors.Is(err, ErrRoomBackgroundCommandConflict) {
return nil, xerr.New(xerr.IdempotencyConflict, "command_id payload conflict")
}
return nil, err
}
@ -76,6 +132,7 @@ func (s *Service) ListRoomBackgrounds(ctx context.Context, req *roomv1.ListRoomB
return &roomv1.ListRoomBackgroundsResponse{
Backgrounds: roomBackgroundsToProto(backgrounds),
SelectedBackgroundUrl: snapshot.GetRoomExt()[roomExtBackgroundKey],
SelectedBackground: snapshot.GetActiveBackground(),
ServerTimeMs: s.clock.Now().UnixMilli(),
}, nil
}
@ -88,21 +145,42 @@ func (s *Service) SetRoomBackground(ctx context.Context, req *roomv1.SetRoomBack
if err != nil {
return nil, err
}
if err := s.requireRoomOwnerMeta(ctx, roomID, actorUserID); err != nil {
return nil, err
cmd := command.SetRoomBackground{
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 {
return nil, err
}
if !exists {
return nil, xerr.New(xerr.NotFound, "room background not found")
}
cmd := command.SetRoomBackground{
Base: baseFromMeta(req.GetMeta()),
BackgroundID: background.BackgroundID,
BackgroundURL: background.ImageURL,
var background RoomBackgroundImage
if seen {
stored, deserializeErr := command.Deserialize(record.CommandType, record.Payload)
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) {
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() {
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 后续到期改写旧命令结果。
return mutationResult{}, nil, err
}
if current.RoomExt == nil {
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
}
current.RoomExt[roomExtBackgroundKey] = cmd.BackgroundURL
if cmd.Background.Media.URL != "" {
backgroundSnapshot := cmd.Background
current.ActiveBackground = &backgroundSnapshot
} else {
current.ActiveBackground = nil
}
current.Version++
backgroundEvent, err := outbox.Build(current.RoomID, "RoomBackgroundChanged", current.Version, now, &roomeventsv1.RoomBackgroundChanged{
ActorUserId: cmd.ActorUserID(),
BackgroundId: cmd.BackgroundID,
RoomBackgroundUrl: current.RoomExt[roomExtBackgroundKey],
Media: roomMediaEventSnapshot(cmd.Background.Media),
})
if err != nil {
return mutationResult{}, nil, err
@ -158,29 +244,66 @@ func (s *Service) SetRoomBackground(ctx context.Context, req *roomv1.SetRoomBack
}, 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 {
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())
if roomID == "" {
roomID = strings.TrimSpace(req.GetMeta().GetRoomId())
}
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()
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())
if imageURL == "" {
return "", 0, "", xerr.New(xerr.InvalidArgument, "image_url is required")
commandID := strings.TrimSpace(req.GetMeta().GetCommandId())
media := roomMediaFromProto(req.GetMedia())
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 {
return "", 0, "", xerr.New(xerr.InvalidArgument, "image_url is too long")
if media.URL == "" {
media.URL = requestImageURL
}
return roomID, actorUserID, imageURL, nil
return roomID, actorUserID, commandID, media, nil
}
func normalizeListRoomBackgrounds(req *roomv1.ListRoomBackgroundsRequest) (string, int64, error) {
@ -235,25 +358,3 @@ func (s *Service) requireRoomOwnerMeta(ctx context.Context, roomID string, actor
}
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"
"time"
"hyapp/pkg/xerr"
"hyapp/services/room-service/internal/room/outbox"
"hyapp/services/room-service/internal/room/rank"
"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) {
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())
flow := newGiftFlow(s, ctx, req, options)
if recovery.CommandID != "" {

View File

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

View File

@ -3,6 +3,8 @@ package service
import (
"context"
"fmt"
"math"
"math/bits"
"strings"
"time"
@ -125,7 +127,9 @@ func luckyGiftMetaForTarget(ctx context.Context, meta *roomv1.RequestMeta, cmd c
Recharge_7DCoins: targetBilling.Billing.GetRechargeSevenDayCoins(),
Recharge_30DCoins: targetBilling.Billing.GetRechargeThirtyDayCoins(),
LastRechargedAtMs: targetBilling.Billing.GetLastRechargedAtMs(),
GiftIncomeCoins: targetBilling.Billing.GetGiftIncomeCoinAmount(),
// LuckyGiftMeta.gift_income_coins 是滚动兼容保留的旧字段名。这里必须传 wallet 已按后台
// “主播钻石比例”实际入账的钻石,不能误传独立的“返还金币比例”结果,否则 0% 返币会阻断开奖。
GiftIncomeCoins: targetBilling.Billing.GetHostPeriodDiamondAdded(),
}, nil
}
@ -276,8 +280,11 @@ func syntheticLuckyGiftResult(cmd command.SendGift, targetBillings []giftTargetB
if len(targetBillings) == 0 || targetBillings[0].Billing == nil {
return nil
}
rewardCoins := firstPositiveInt64(cmd.SyntheticLuckyRewardCoins, targetBillings[0].Billing.GetCoinSpent()*5)
coinSpent := targetBillings[0].Billing.GetCoinSpent()
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())
return &roomv1.LuckyGiftDrawResult{
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 {
if billing == nil {
return 0

View File

@ -2,13 +2,46 @@ package service
import (
"context"
"math"
"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/giftlimits"
"hyapp/pkg/xerr"
"hyapp/services/room-service/internal/integration"
"hyapp/services/room-service/internal/room/outbox"
"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 {
Meta *roomv1.RequestMeta
TargetUserID int64
@ -18,7 +51,8 @@ type RobotSendGiftInput struct {
RobotUserIDs []int64
SyntheticRewardCoins int64
SyntheticMultiplierPPM int64
RealRoomHeat bool
// RealRoomHeat 是历史字段名;当前只表示“真人房机器人”校验范围,绝不再产生真实热度或任何持久化贡献。
RealRoomHeat bool
}
type robotGiftOptions struct {
@ -26,7 +60,8 @@ type robotGiftOptions struct {
RobotUserIDs []int64
SyntheticRewardCoins int64
SyntheticMultiplierPPM int64
RealRoomHeat bool
// RealRoomHeat 仅供 validateBeforeDebit 区分专属机器人房和真人房机器人池,不能进入状态/账务结算。
RealRoomHeat bool
}
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,
TargetUserId: input.TargetUserID,
TargetUserIds: []int64{input.TargetUserID},
GiftId: input.GiftID,
GiftId: strings.TrimSpace(input.GiftID),
GiftCount: input.GiftCount,
TargetType: "user",
PoolId: input.PoolID,
}
return s.sendGift(ctx, req, giftSendOptions{
ctx = contextFromMeta(ctx, req.GetMeta())
flow := newGiftFlow(s, ctx, req, giftSendOptions{
Robot: robotGiftOptions{
Enabled: true,
RobotUserIDs: input.RobotUserIDs,
@ -48,6 +84,300 @@ func (s *Service) RobotSendGift(ctx context.Context, input RobotSendGiftInput) (
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 {

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"
"math"
"strings"
"time"
roomv1 "hyapp.local/api/proto/room/v1"
"hyapp/pkg/appcode"
@ -403,6 +404,8 @@ func roomListEntryFromSnapshot(snapshot *roomv1.RoomSnapshot, visibleRegionID in
SeatCount: seatCount,
OccupiedSeatCount: occupiedSeatCount,
SortScore: roomListSortScore(snapshot.GetHeat(), onlineCount, occupiedSeatCount),
RoomBorderJSON: marshalRoomListDecoration(snapshot.GetRoomBorder()),
RoomNameStyleJSON: marshalRoomListDecoration(snapshot.GetRoomNameStyle()),
CreatedAtMS: nowMS,
UpdatedAtMS: nowMS,
}
@ -587,6 +590,7 @@ func encodeRoomListCursor(tab string, query string, countryCode string, viewerCo
// roomListItemToProto 将 repository 投影转换为跨服务响应对象。
func roomListItemToProto(entry RoomListEntry) *roomv1.RoomListItem {
nowMS := time.Now().UTC().UnixMilli()
return &roomv1.RoomListItem{
AppCode: entry.AppCode,
RoomId: entry.RoomID,
@ -603,9 +607,34 @@ func roomListItemToProto(entry RoomListEntry) *roomv1.RoomListItem {
OccupiedSeatCount: entry.OccupiedSeatCount,
VisibleRegionId: entry.VisibleRegionID,
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 {
if snapshot == nil || snapshot.GetRoomId() == "" {
// GetMyRoom 兜底路径:即使快照暂时不可用,也能用 rooms 元数据返回“有房间”的最小卡片。

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