Compare commits

..

8 Commits
test ... main

Author SHA1 Message Date
zhx
d27fba74bc 政策周期修正 2026-07-19 23:21:31 +08:00
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
189 changed files with 18356 additions and 2277 deletions

File diff suppressed because it is too large Load Diff

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -201,6 +201,8 @@ message HostSalaryPolicy {
int64 effective_from_ms = 9; int64 effective_from_ms = 9;
int64 effective_to_ms = 10; int64 effective_to_ms = 10;
repeated HostSalaryPolicyLevel levels = 11; repeated HostSalaryPolicyLevel levels = 11;
string cycle_key = 12;
uint64 policy_version = 13;
} }
message GetActiveHostSalaryPolicyRequest { message GetActiveHostSalaryPolicyRequest {
@ -210,6 +212,8 @@ message GetActiveHostSalaryPolicyRequest {
string settlement_mode = 4; string settlement_mode = 4;
string settlement_trigger_mode = 5; string settlement_trigger_mode = 5;
int64 now_ms = 6; int64 now_ms = 6;
// cycle_key 退 UTC
string cycle_key = 7;
} }
message GetActiveHostSalaryPolicyResponse { message GetActiveHostSalaryPolicyResponse {
@ -1139,6 +1143,8 @@ message EquipUserResourceRequest {
int64 user_id = 3; int64 user_id = 3;
int64 resource_id = 4; int64 resource_id = 4;
string entitlement_id = 5; string entitlement_id = 5;
// command_id request_id
string command_id = 6;
} }
message EquipUserResourceResponse { message EquipUserResourceResponse {
@ -2028,6 +2034,9 @@ message VipBenefit {
int64 created_at_ms = 13; int64 created_at_ms = 13;
int64 updated_at_ms = 14; int64 updated_at_ms = 14;
VipBenefitPresentation presentation = 15; VipBenefitPresentation presentation = 15;
// resource resource_id Appactive resource_type
// resource_id/resource_type URL
Resource resource = 16;
} }
message VipLevel { message VipLevel {
@ -2404,6 +2413,10 @@ message CheckVipBenefitResponse {
VipState state = 3; VipState state = 3;
string denial_reason = 4; string denial_reason = 4;
int64 evaluated_at_ms = 5; int64 evaluated_at_ms = 5;
// required_level App active 0
int32 required_level = 6;
// current_effective_level wallet
int32 current_effective_level = 7;
} }
message AdminVipLevelInput { message AdminVipLevelInput {

View File

@ -0,0 +1,10 @@
brokerClusterName=DefaultCluster
brokerName=broker-a
brokerId=0
listenPort=10911
brokerIP1=127.0.0.1
deleteWhen=04
fileReservedTime=48
brokerRole=ASYNC_MASTER
flushDiskType=ASYNC_FLUSH
autoCreateTopicEnable=true

File diff suppressed because it is too large Load Diff

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -26,6 +26,7 @@ import (
"hyapp-admin-server/internal/integration/statisticsclient" "hyapp-admin-server/internal/integration/statisticsclient"
"hyapp-admin-server/internal/integration/userclient" "hyapp-admin-server/internal/integration/userclient"
"hyapp-admin-server/internal/integration/walletclient" "hyapp-admin-server/internal/integration/walletclient"
"hyapp-admin-server/internal/integration/withdrawalsource"
jobrunner "hyapp-admin-server/internal/job" jobrunner "hyapp-admin-server/internal/job"
"hyapp-admin-server/internal/migration" "hyapp-admin-server/internal/migration"
achievementconfigmodule "hyapp-admin-server/internal/modules/achievementconfig" achievementconfigmodule "hyapp-admin-server/internal/modules/achievementconfig"
@ -88,6 +89,7 @@ import (
vipconfigmodule "hyapp-admin-server/internal/modules/vipconfig" vipconfigmodule "hyapp-admin-server/internal/modules/vipconfig"
weeklystarmodule "hyapp-admin-server/internal/modules/weeklystar" weeklystarmodule "hyapp-admin-server/internal/modules/weeklystar"
wheelmodule "hyapp-admin-server/internal/modules/wheel" wheelmodule "hyapp-admin-server/internal/modules/wheel"
"hyapp-admin-server/internal/platform/dingtalkrobot"
"hyapp-admin-server/internal/platform/logging" "hyapp-admin-server/internal/platform/logging"
"hyapp-admin-server/internal/platform/tencentcos" "hyapp-admin-server/internal/platform/tencentcos"
"hyapp-admin-server/internal/repository" "hyapp-admin-server/internal/repository"
@ -356,6 +358,10 @@ func main() {
) )
luckyGiftHandler := luckygiftmodule.New(luckygiftadmin.NewGRPC(luckyGiftConn), cfg.LuckyGiftService.RequestTimeout, auditHandler). luckyGiftHandler := luckygiftmodule.New(luckygiftadmin.NewGRPC(luckyGiftConn), cfg.LuckyGiftService.RequestTimeout, auditHandler).
BindUserClient(userclient.NewGRPC(userConn)) BindUserClient(userclient.NewGRPC(userConn))
withdrawalReviewNotifier, err := newWithdrawalReviewNotifier(cfg.FinanceNotifications.DingTalk)
if err != nil {
fatalRuntime("create_finance_dingtalk_notifier_failed", err)
}
handlers := router.Handlers{ handlers := router.Handlers{
Audit: auditHandler, Audit: auditHandler,
Auth: authmodule.New(store, auth, auditHandler, cfg), Auth: authmodule.New(store, auth, auditHandler, cfg),
@ -384,7 +390,8 @@ func main() {
financeordermodule.WithLegacyCoinSellerRegionResolver(func(ctx context.Context, appCode string, countryCode string) (int64, bool, error) { financeordermodule.WithLegacyCoinSellerRegionResolver(func(ctx context.Context, appCode string, countryCode string) (int64, bool, error) {
return paymentmodule.RegionIDForCountryCode(ctx, moneyRegionSources, appCode, countryCode) return paymentmodule.RegionIDForCountryCode(ctx, moneyRegionSources, appCode, countryCode)
})), })),
FinanceWithdrawal: financewithdrawalmodule.New(store, walletclient.NewGRPC(walletConn), activityclient.NewGRPC(activityConn), auditHandler), FinanceWithdrawal: financewithdrawalmodule.NewWithSources(store, walletclient.NewGRPC(walletConn), activityclient.NewGRPC(activityConn), auditHandler, withdrawalsource.New(cfg.WithdrawalSources)).
BindReviewPendingNotifier(withdrawalReviewNotifier),
ExternalAdmin: externaladminmodule.New(db, userDB, externaladminmodule.Config{ ExternalAdmin: externaladminmodule.New(db, userDB, externaladminmodule.Config{
SessionTTL: cfg.ExternalAdmin.SessionTTL, SessionTTL: cfg.ExternalAdmin.SessionTTL,
MaxLoginFailures: cfg.ExternalAdmin.MaxLoginFailures, MaxLoginFailures: cfg.ExternalAdmin.MaxLoginFailures,
@ -473,6 +480,23 @@ func main() {
} }
} }
func newWithdrawalReviewNotifier(cfg config.DingTalkRobotConfig) (financewithdrawalmodule.ReviewPendingNotifier, error) {
if !cfg.Enabled || strings.TrimSpace(cfg.WebhookURL) == "" {
return nil, nil
}
client, err := dingtalkrobot.New(dingtalkrobot.Config{
WebhookURL: cfg.WebhookURL,
Secret: cfg.Secret,
AtMobiles: cfg.AtMobiles,
AtAll: cfg.AtAll,
RequestTimeout: cfg.RequestTimeout,
})
if err != nil {
return nil, err
}
return financewithdrawalmodule.NewDingTalkReviewPendingNotifier(client), nil
}
func fatalRuntime(msg string, err error) { func fatalRuntime(msg string, err error) {
slog.Error(msg, "error", err.Error()) slog.Error(msg, "error", err.Error())
os.Exit(1) os.Exit(1)

View File

@ -69,6 +69,15 @@ finance_google_paid_sync:
poll_interval: "15s" poll_interval: "15s"
batch_size: 100 batch_size: 100
request_timeout: "2m" request_timeout: "2m"
# 生产通过 HYAPP_ADMIN_ASLAN_WITHDRAWAL_* 注入密钥、回调地址并开启。
withdrawal_sources:
- enabled: false
app_code: "aslan"
source_system: "likei"
submit_token: ""
callback_url: ""
callback_token: ""
request_timeout: "5s"
mysql_auto_migrate: false mysql_auto_migrate: false
migrations: migrations:
enabled: true enabled: true
@ -128,6 +137,15 @@ statistics_service:
base_url: "http://10.2.1.16:13010" base_url: "http://10.2.1.16:13010"
# 漏斗冷查询允许短时索引页预热;正常查询应远低于该上限,超时只负责隔离异常请求。 # 漏斗冷查询允许短时索引页预热;正常查询应远低于该上限,超时只负责隔离异常请求。
request_timeout: "10s" request_timeout: "10s"
finance_notifications:
dingtalk:
enabled: true
# 生产凭证通过 HYAPP_ADMIN_FINANCE_DINGTALK_* 注入,示例配置不保存机器人 access_token。
webhook_url: ""
secret: ""
at_mobiles: []
at_all: false
request_timeout: "3s"
dashboard_external_sources: dashboard_external_sources:
- enabled: true - enabled: true
type: "mysql" type: "mysql"

View File

@ -67,6 +67,16 @@ finance_google_paid_sync:
poll_interval: "15s" poll_interval: "15s"
batch_size: 100 batch_size: 100
request_timeout: "2m" request_timeout: "2m"
# 外部钱包统一提现双审来源。生产密钥和回调地址只从
# HYAPP_ADMIN_ASLAN_WITHDRAWAL_* 环境变量注入,仓库不保存资金接口凭证。
withdrawal_sources:
- enabled: false
app_code: "aslan"
source_system: "likei"
submit_token: ""
callback_url: ""
callback_token: ""
request_timeout: "5s"
mysql_auto_migrate: true mysql_auto_migrate: true
migrations: migrations:
enabled: true enabled: true
@ -125,6 +135,15 @@ statistics_service:
base_url: "http://127.0.0.1:13010" base_url: "http://127.0.0.1:13010"
# 漏斗冷查询允许短时索引页预热;正常查询应远低于该上限,超时只负责隔离异常请求。 # 漏斗冷查询允许短时索引页预热;正常查询应远低于该上限,超时只负责隔离异常请求。
request_timeout: "10s" request_timeout: "10s"
finance_notifications:
dingtalk:
enabled: true
# 真实 webhook/access_token 和可选签名密钥只通过 HYAPP_ADMIN_FINANCE_DINGTALK_* 注入。
webhook_url: ""
secret: ""
at_mobiles: []
at_all: false
request_timeout: "3s"
dashboard_external_sources: dashboard_external_sources:
- enabled: false - enabled: false
type: "mysql" type: "mysql"

View File

@ -50,8 +50,10 @@ type Config struct {
LuckyGiftService LuckyGiftServiceConfig `yaml:"lucky_gift_service"` LuckyGiftService LuckyGiftServiceConfig `yaml:"lucky_gift_service"`
GameService GameServiceConfig `yaml:"game_service"` GameService GameServiceConfig `yaml:"game_service"`
StatisticsService StatisticsServiceConfig `yaml:"statistics_service"` StatisticsService StatisticsServiceConfig `yaml:"statistics_service"`
FinanceNotifications FinanceNotificationsConfig `yaml:"finance_notifications"`
DashboardExternalSources []DashboardExternalSourceConfig `yaml:"dashboard_external_sources"` DashboardExternalSources []DashboardExternalSourceConfig `yaml:"dashboard_external_sources"`
LegacyCoinSellerRechargeSources []LegacyCoinSellerRechargeSourceConfig `yaml:"legacy_coin_seller_recharge_sources"` LegacyCoinSellerRechargeSources []LegacyCoinSellerRechargeSourceConfig `yaml:"legacy_coin_seller_recharge_sources"`
WithdrawalSources []WithdrawalSourceConfig `yaml:"withdrawal_sources"`
} }
type MigrationConfig struct { type MigrationConfig struct {
@ -115,6 +117,33 @@ type StatisticsServiceConfig struct {
RequestTimeout time.Duration `yaml:"request_timeout"` RequestTimeout time.Duration `yaml:"request_timeout"`
} }
// FinanceNotificationsConfig 只保存审核协作通知配置;提现状态和资金终态仍由 Admin/Wallet 的持久事实决定。
type FinanceNotificationsConfig struct {
DingTalk DingTalkRobotConfig `yaml:"dingtalk"`
}
// DingTalkRobotConfig 的 webhook 和签名密钥属于运行时凭证,可提交配置只能保留空值。
type DingTalkRobotConfig struct {
Enabled bool `yaml:"enabled"`
WebhookURL string `yaml:"webhook_url"`
Secret string `yaml:"secret"`
AtMobiles []string `yaml:"at_mobiles"`
AtAll bool `yaml:"at_all"`
RequestTimeout time.Duration `yaml:"request_timeout"`
}
// WithdrawalSourceConfig 描述接入统一双审工作台的外部钱包系统。
// submit_token 只允许来源 App 创建自己的申请callback_token 用于 admin 终审时回调来源钱包,二者分离避免任一方向泄漏后扩大权限。
type WithdrawalSourceConfig struct {
Enabled bool `yaml:"enabled"`
AppCode string `yaml:"app_code"`
SourceSystem string `yaml:"source_system"`
SubmitToken string `yaml:"submit_token"`
CallbackURL string `yaml:"callback_url"`
CallbackToken string `yaml:"callback_token"`
RequestTimeout time.Duration `yaml:"request_timeout"`
}
type DashboardExternalSourceConfig struct { type DashboardExternalSourceConfig struct {
Enabled bool `yaml:"enabled"` Enabled bool `yaml:"enabled"`
SourceType string `yaml:"type"` SourceType string `yaml:"type"`
@ -348,6 +377,12 @@ func Default() Config {
BaseURL: "http://127.0.0.1:13010", BaseURL: "http://127.0.0.1:13010",
RequestTimeout: 3 * time.Second, RequestTimeout: 3 * time.Second,
}, },
FinanceNotifications: FinanceNotificationsConfig{
DingTalk: DingTalkRobotConfig{
Enabled: true,
RequestTimeout: 3 * time.Second,
},
},
DashboardExternalSources: []DashboardExternalSourceConfig{ DashboardExternalSources: []DashboardExternalSourceConfig{
{ {
Enabled: false, Enabled: false,
@ -385,6 +420,14 @@ func Default() Config {
RequestTimeout: 5 * time.Second, RequestTimeout: 5 * time.Second,
}, },
}, },
WithdrawalSources: []WithdrawalSourceConfig{
{
Enabled: false,
AppCode: "aslan",
SourceSystem: "likei",
RequestTimeout: 5 * time.Second,
},
},
FinanceGooglePaidSync: FinanceGooglePaidSyncConfig{ FinanceGooglePaidSync: FinanceGooglePaidSyncConfig{
Enabled: true, Enabled: true,
PollInterval: 15 * time.Second, PollInterval: 15 * time.Second,
@ -565,6 +608,13 @@ func (cfg *Config) Normalize() {
if cfg.StatisticsService.RequestTimeout <= 0 { if cfg.StatisticsService.RequestTimeout <= 0 {
cfg.StatisticsService.RequestTimeout = 3 * time.Second cfg.StatisticsService.RequestTimeout = 3 * time.Second
} }
cfg.FinanceNotifications.DingTalk.WebhookURL = strings.TrimSpace(cfg.FinanceNotifications.DingTalk.WebhookURL)
cfg.FinanceNotifications.DingTalk.Secret = strings.TrimSpace(cfg.FinanceNotifications.DingTalk.Secret)
cfg.FinanceNotifications.DingTalk.AtMobiles = compactStrings(cfg.FinanceNotifications.DingTalk.AtMobiles)
if cfg.FinanceNotifications.DingTalk.RequestTimeout <= 0 {
cfg.FinanceNotifications.DingTalk.RequestTimeout = 3 * time.Second
}
cfg.applyFinanceDingTalkEnvOverrides()
for index := range cfg.DashboardExternalSources { for index := range cfg.DashboardExternalSources {
source := &cfg.DashboardExternalSources[index] source := &cfg.DashboardExternalSources[index]
// 外接 App 大屏的数据源类型在配置层归一化;查询层只根据类型读取对应的外部聚合事实,不回退到 hyapp statistics-service。 // 外接 App 大屏的数据源类型在配置层归一化;查询层只根据类型读取对应的外部聚合事实,不回退到 hyapp statistics-service。
@ -623,6 +673,18 @@ func (cfg *Config) Normalize() {
} }
} }
cfg.applyLegacyCoinSellerRechargeSourceEnvOverrides() cfg.applyLegacyCoinSellerRechargeSourceEnvOverrides()
for index := range cfg.WithdrawalSources {
source := &cfg.WithdrawalSources[index]
source.AppCode = strings.ToLower(strings.TrimSpace(source.AppCode))
source.SourceSystem = strings.ToLower(strings.TrimSpace(source.SourceSystem))
source.SubmitToken = strings.TrimSpace(source.SubmitToken)
source.CallbackURL = strings.TrimRight(strings.TrimSpace(source.CallbackURL), "/")
source.CallbackToken = strings.TrimSpace(source.CallbackToken)
if source.RequestTimeout <= 0 {
source.RequestTimeout = 5 * time.Second
}
}
cfg.applyWithdrawalSourceEnvOverrides()
cfg.RobotProfileSource.MySQLDSN = strings.TrimSpace(cfg.RobotProfileSource.MySQLDSN) cfg.RobotProfileSource.MySQLDSN = strings.TrimSpace(cfg.RobotProfileSource.MySQLDSN)
if cfg.RobotProfileSource.RequestTimeout <= 0 { if cfg.RobotProfileSource.RequestTimeout <= 0 {
cfg.RobotProfileSource.RequestTimeout = 5 * time.Second cfg.RobotProfileSource.RequestTimeout = 5 * time.Second
@ -904,6 +966,26 @@ func (cfg Config) Validate() error {
return fmt.Errorf("dashboard_external_sources[%d].request_timeout must be greater than 0", index) return fmt.Errorf("dashboard_external_sources[%d].request_timeout must be greater than 0", index)
} }
} }
for index, source := range cfg.WithdrawalSources {
if !source.Enabled {
continue
}
if strings.TrimSpace(source.AppCode) == "" || strings.TrimSpace(source.SourceSystem) == "" {
return fmt.Errorf("withdrawal_sources[%d] app_code and source_system are required when enabled", index)
}
if strings.TrimSpace(source.SubmitToken) == "" || containsConfigPlaceholder(source.SubmitToken) {
return fmt.Errorf("withdrawal_sources[%d].submit_token must be injected when enabled", index)
}
if strings.TrimSpace(source.CallbackToken) == "" || containsConfigPlaceholder(source.CallbackToken) {
return fmt.Errorf("withdrawal_sources[%d].callback_token must be injected when enabled", index)
}
if !strings.HasPrefix(source.CallbackURL, "http://") && !strings.HasPrefix(source.CallbackURL, "https://") {
return fmt.Errorf("withdrawal_sources[%d].callback_url must be an absolute url", index)
}
if source.RequestTimeout <= 0 {
return fmt.Errorf("withdrawal_sources[%d].request_timeout must be greater than 0", index)
}
}
if isLockedEnvironment(cfg.Environment) && strings.TrimSpace(cfg.RobotProfileSource.MySQLDSN) == "" { if isLockedEnvironment(cfg.Environment) && strings.TrimSpace(cfg.RobotProfileSource.MySQLDSN) == "" {
return errors.New("robot_profile_source.mysql_dsn is required outside local/dev") return errors.New("robot_profile_source.mysql_dsn is required outside local/dev")
} }
@ -1114,6 +1196,54 @@ 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 (cfg *Config) applyFinanceDingTalkEnvOverrides() {
dingTalk := &cfg.FinanceNotifications.DingTalk
if value := strings.TrimSpace(os.Getenv("HYAPP_ADMIN_FINANCE_DINGTALK_ENABLED")); value != "" {
dingTalk.Enabled = parseBoolEnv(value)
}
if value := strings.TrimSpace(firstEnv("HYAPP_ADMIN_FINANCE_DINGTALK_WEBHOOK_URL", "FINANCE_DINGTALK_WEBHOOK_URL")); value != "" {
// webhook query 中包含 access_token只允许运行环境覆盖不能复制到 Admin YAML 或日志。
dingTalk.WebhookURL = value
dingTalk.Enabled = true
}
if value := strings.TrimSpace(firstEnv("HYAPP_ADMIN_FINANCE_DINGTALK_SECRET", "FINANCE_DINGTALK_SECRET")); value != "" {
dingTalk.Secret = value
}
if value := strings.TrimSpace(os.Getenv("HYAPP_ADMIN_FINANCE_DINGTALK_AT_MOBILES")); value != "" {
dingTalk.AtMobiles = compactStrings(strings.Split(value, ","))
}
if value := strings.TrimSpace(os.Getenv("HYAPP_ADMIN_FINANCE_DINGTALK_AT_ALL")); value != "" {
dingTalk.AtAll = parseBoolEnv(value)
}
}
func envAppKey(appCode string) string { func envAppKey(appCode string) string {
appCode = strings.ToUpper(strings.TrimSpace(appCode)) appCode = strings.ToUpper(strings.TrimSpace(appCode))
if appCode == "" { if appCode == "" {

View File

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

View File

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

View File

@ -422,11 +422,14 @@ func (PolicyPublishItem) TableName() string {
type HostAgencySalaryPolicy struct { type HostAgencySalaryPolicy struct {
ID uint `gorm:"primaryKey" json:"id"` ID uint `gorm:"primaryKey" json:"id"`
AppCode string `gorm:"size:32;uniqueIndex:uk_admin_host_agency_salary_policy_name;index:idx_admin_host_agency_salary_policy_region,not null;default:lalu" json:"appCode"` AppCode string `gorm:"size:32;uniqueIndex:uk_admin_host_agency_salary_policy_name;uniqueIndex:uk_admin_host_agency_salary_policy_version;index:idx_admin_host_agency_salary_policy_region;index:idx_admin_host_agency_salary_policy_cycle,not null;default:lalu" json:"appCode"`
Name string `gorm:"size:120;uniqueIndex:uk_admin_host_agency_salary_policy_name;not null" json:"name"` Name string `gorm:"size:120;uniqueIndex:uk_admin_host_agency_salary_policy_name;not null" json:"name"`
// RegionID 是第一阶段的政策适用边界;结算时按用户所属区域命中唯一 active 政策。 // RegionID 是第一阶段的政策适用边界;结算时按用户所属区域命中唯一 active 政策。
RegionID int64 `gorm:"uniqueIndex:uk_admin_host_agency_salary_policy_name;index:idx_admin_host_agency_salary_policy_region,not null" json:"regionId"` RegionID int64 `gorm:"uniqueIndex:uk_admin_host_agency_salary_policy_name;uniqueIndex:uk_admin_host_agency_salary_policy_version;index:idx_admin_host_agency_salary_policy_region;index:idx_admin_host_agency_salary_policy_cycle,not null" json:"regionId"`
Status string `gorm:"size:24;index:idx_admin_host_agency_salary_policy_region,not null;default:disabled" json:"status"` // CycleKey 是工资归属月PolicyVersion 使用政策主键形成稳定版本号,发布后两者均不可修改。
CycleKey string `gorm:"column:cycle_key;size:7;uniqueIndex:uk_admin_host_agency_salary_policy_version;index:idx_admin_host_agency_salary_policy_cycle,not null" json:"cycleKey"`
PolicyVersion uint64 `gorm:"column:policy_version;uniqueIndex:uk_admin_host_agency_salary_policy_version;not null;default:0" json:"policyVersion"`
Status string `gorm:"size:24;index:idx_admin_host_agency_salary_policy_region,not null;default:disabled" json:"status"`
// SettlementMode 只配置结算节奏,等级金额仍按累计值保存,实际发放由后续结算任务计算差额。 // SettlementMode 只配置结算节奏,等级金额仍按累计值保存,实际发放由后续结算任务计算差额。
SettlementMode string `gorm:"size:24;not null;default:daily" json:"settlementMode"` SettlementMode string `gorm:"size:24;not null;default:daily" json:"settlementMode"`
// SettlementTriggerMode 区分自动任务与后台人工结算;手动政策发布后仍可被查询,但不会被 cron 自动发薪。 // SettlementTriggerMode 区分自动任务与后台人工结算;手动政策发布后仍可被查询,但不会被 cron 自动发薪。
@ -434,7 +437,7 @@ type HostAgencySalaryPolicy struct {
// 比例字段使用 decimal 字符串承载,避免金币、钻石、美元换算出现 float 精度误差。 // 比例字段使用 decimal 字符串承载,避免金币、钻石、美元换算出现 float 精度误差。
GiftCoinToDiamondRatio string `gorm:"column:gift_coin_to_diamond_ratio;type:decimal(18,6);not null;default:1.000000" json:"giftCoinToDiamondRatio"` GiftCoinToDiamondRatio string `gorm:"column:gift_coin_to_diamond_ratio;type:decimal(18,6);not null;default:1.000000" json:"giftCoinToDiamondRatio"`
ResidualDiamondToUSDRate string `gorm:"column:residual_diamond_to_usd_rate;type:decimal(24,12);not null;default:0.000000000000" json:"residualDiamondToUsdRate"` ResidualDiamondToUSDRate string `gorm:"column:residual_diamond_to_usd_rate;type:decimal(24,12);not null;default:0.000000000000" json:"residualDiamondToUsdRate"`
// 时间范围采用 epoch ms 半开区间EffectiveToMS=0 表示长期有效 // 时间范围保留为运营展示元数据;工资结算只按 CycleKey 绑定版本,不能再用执行时间选政策
EffectiveFromMS int64 `gorm:"column:effective_from_ms;index:idx_admin_host_agency_salary_policy_region,not null;default:0" json:"effectiveFromMs"` EffectiveFromMS int64 `gorm:"column:effective_from_ms;index:idx_admin_host_agency_salary_policy_region,not null;default:0" json:"effectiveFromMs"`
EffectiveToMS int64 `gorm:"column:effective_to_ms;not null;default:0" json:"effectiveToMs"` EffectiveToMS int64 `gorm:"column:effective_to_ms;not null;default:0" json:"effectiveToMs"`
Description string `gorm:"size:255;not null;default:''" json:"description"` Description string `gorm:"size:255;not null;default:''" json:"description"`
@ -728,6 +731,8 @@ func (order CoinSellerRechargeOrder) BusinessTimeMS() int64 {
type UserWithdrawalApplication struct { type UserWithdrawalApplication struct {
ID uint `gorm:"primaryKey;index:idx_admin_withdrawal_app_operations_status_time,priority:4" json:"id"` ID uint `gorm:"primaryKey;index:idx_admin_withdrawal_app_operations_status_time,priority:4" json:"id"`
AppCode string `gorm:"size:32;index:idx_admin_withdrawal_app_status_time;index:idx_admin_withdrawal_app_operations_status_time,priority:1;not null" json:"appCode"` AppCode string `gorm:"size:32;index:idx_admin_withdrawal_app_status_time;index:idx_admin_withdrawal_app_operations_status_time,priority:1;not null" json:"appCode"`
SourceSystem string `gorm:"column:source_system;size:32;not null;default:hyapp_wallet" json:"sourceSystem"`
SourceApplicationID string `gorm:"column:source_application_id;size:128;not null;default:''" json:"sourceApplicationId"`
UserID string `gorm:"size:64;index:idx_admin_withdrawal_user;not null" json:"userId"` UserID string `gorm:"size:64;index:idx_admin_withdrawal_user;not null" json:"userId"`
SalaryAssetType string `gorm:"size:64;not null;default:''" json:"salaryAssetType"` SalaryAssetType string `gorm:"size:64;not null;default:''" json:"salaryAssetType"`
WithdrawAmount string `gorm:"type:decimal(18,2);not null;default:0.00" json:"withdrawAmount"` WithdrawAmount string `gorm:"type:decimal(18,2);not null;default:0.00" json:"withdrawAmount"`

View File

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

View File

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

View File

@ -0,0 +1,100 @@
package financewithdrawal
import (
"context"
"fmt"
"strings"
"time"
"hyapp-admin-server/internal/model"
"hyapp-admin-server/internal/repository"
)
type markdownSender interface {
SendMarkdown(ctx context.Context, title string, text string) error
}
// ReviewPendingNotifier 只通知“当前待谁处理”,不参与提现创建、冻结或审核事务。
type ReviewPendingNotifier interface {
NotifyReviewPending(ctx context.Context, application model.UserWithdrawalApplication, stage repository.WithdrawalApplicationReviewStage) error
}
type dingTalkReviewPendingNotifier struct {
sender markdownSender
}
func NewDingTalkReviewPendingNotifier(sender markdownSender) ReviewPendingNotifier {
if sender == nil {
return nil
}
return &dingTalkReviewPendingNotifier{sender: sender}
}
func (n *dingTalkReviewPendingNotifier) NotifyReviewPending(ctx context.Context, application model.UserWithdrawalApplication, stage repository.WithdrawalApplicationReviewStage) error {
if n == nil || n.sender == nil {
return nil
}
title, text := withdrawalReviewPendingMarkdown(application, stage)
return n.sender.SendMarkdown(ctx, title, text)
}
func withdrawalReviewPendingMarkdown(application model.UserWithdrawalApplication, stage repository.WithdrawalApplicationReviewStage) (string, string) {
stageName := "运营审核阶段"
pageURL := "https://admin-acc.global-interaction.com/finance/?view=withdrawalOperations"
if stage == repository.WithdrawalApplicationReviewStageFinance {
stageName = "财务审核阶段"
pageURL = "https://admin-acc.global-interaction.com/finance/?view=withdrawalFinance"
}
title := fmt.Sprintf("用户提现待处理【%s】", stageName)
lines := []string{
fmt.Sprintf("### 用户提现待处理【%s】", stageName),
"",
fmt.Sprintf("- 当前阶段:**%s**", stageName),
fmt.Sprintf("- 申请ID%d", application.ID),
fmt.Sprintf("- APP%s", withdrawalMarkdownValue(application.AppCode)),
fmt.Sprintf("- 来源系统:%s", withdrawalMarkdownValue(application.SourceSystem)),
fmt.Sprintf("- 来源单号:%s", withdrawalMarkdownValue(application.SourceApplicationID)),
fmt.Sprintf("- 用户ID%s", withdrawalMarkdownValue(application.UserID)),
fmt.Sprintf("- 提现金额:$ %s", withdrawalMarkdownValue(application.WithdrawAmount)),
fmt.Sprintf("- 提现方式:%s", withdrawalMarkdownValue(application.WithdrawMethod)),
fmt.Sprintf("- 提现地址:%s", withdrawalMarkdownValue(application.WithdrawAddress)),
fmt.Sprintf("- 申请时间:%s", formatWithdrawalNotifyMillis(application.CreatedAtMS)),
}
if stage == repository.WithdrawalApplicationReviewStageFinance {
// 财务提醒保留运营审核快照,接收人无需先打开后台就能确认这是一张已完成初审的申请。
lines = append(lines,
fmt.Sprintf("- 运营审核人:%s", withdrawalMarkdownValue(application.OperationsReviewerName)),
fmt.Sprintf("- 运营审核备注:%s", withdrawalMarkdownValue(application.OperationsAuditRemark)),
fmt.Sprintf("- 运营通过时间:%s", formatWithdrawalNotifyMillis(int64Value(application.OperationsReviewedAtMS))),
)
}
lines = append(lines, "- 后台地址:"+pageURL)
return title, strings.Join(lines, "\n")
}
func formatWithdrawalNotifyMillis(value int64) string {
if value <= 0 {
return "-"
}
// 协作消息按运营团队使用的北京时间展示;接口和数据库仍以 Unix 毫秒 UTC 事实为准。
return time.UnixMilli(value).In(time.FixedZone("UTC+8", 8*60*60)).Format("2006-01-02 15:04:05")
}
func int64Value(value *int64) int64 {
if value == nil {
return 0
}
return *value
}
func withdrawalMarkdownValue(value string) string {
value = strings.TrimSpace(value)
if value == "" {
return "-"
}
runes := []rune(value)
if len(runes) > 240 {
value = string(runes[:240]) + "..."
}
return strings.NewReplacer("\r", " ", "\n", " ", "\t", " ").Replace(value)
}

View File

@ -8,6 +8,7 @@ import (
"hyapp-admin-server/internal/appctx" "hyapp-admin-server/internal/appctx"
"hyapp-admin-server/internal/integration/activityclient" "hyapp-admin-server/internal/integration/activityclient"
"hyapp-admin-server/internal/integration/walletclient" "hyapp-admin-server/internal/integration/walletclient"
"hyapp-admin-server/internal/integration/withdrawalsource"
"hyapp-admin-server/internal/middleware" "hyapp-admin-server/internal/middleware"
"hyapp-admin-server/internal/modules/shared" "hyapp-admin-server/internal/modules/shared"
"hyapp-admin-server/internal/repository" "hyapp-admin-server/internal/repository"
@ -20,10 +21,64 @@ import (
type Handler struct { type Handler struct {
service *Service service *Service
audit shared.OperationLogger audit shared.OperationLogger
sources *withdrawalsource.Registry
} }
func New(store *repository.Store, wallet walletclient.Client, activity activityclient.Client, audit shared.OperationLogger) *Handler { func New(store *repository.Store, wallet walletclient.Client, activity activityclient.Client, audit shared.OperationLogger) *Handler {
return &Handler{service: NewService(store, wallet, activity), audit: audit} return NewWithSources(store, wallet, activity, audit, nil)
}
func NewWithSources(store *repository.Store, wallet walletclient.Client, activity activityclient.Client, audit shared.OperationLogger, sources *withdrawalsource.Registry) *Handler {
return &Handler{service: NewServiceWithSources(store, wallet, activity, sources), audit: audit, sources: sources}
}
func (h *Handler) BindReviewPendingNotifier(notifier ReviewPendingNotifier) *Handler {
if h != nil && h.service != nil {
h.service.BindReviewPendingNotifier(notifier)
}
return h
}
// 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(c.Request.Context(), externalWithdrawalApplicationInput{
AppCode: req.AppCode,
SourceSystem: req.SourceSystem,
SourceApplicationID: req.SourceApplicationID,
UserID: req.UserID,
SalaryAssetType: req.SalaryAssetType,
WithdrawAmount: req.WithdrawAmount,
WithdrawAmountMinor: req.WithdrawAmountMinor,
WithdrawMethod: req.WithdrawMethod,
WithdrawAddress: req.WithdrawAddress,
CreatedAtMS: req.CreatedAtMS,
})
if err != nil {
response.BadRequest(c, err.Error())
return
}
response.Created(c, item)
} }
func (h *Handler) ListApplications(c *gin.Context) { func (h *Handler) ListApplications(c *gin.Context) {

View File

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

View File

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

View File

@ -5,6 +5,8 @@ import (
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"log/slog"
"math/big"
"strconv" "strconv"
"strings" "strings"
"time" "time"
@ -12,6 +14,7 @@ import (
"hyapp-admin-server/internal/appctx" "hyapp-admin-server/internal/appctx"
"hyapp-admin-server/internal/integration/activityclient" "hyapp-admin-server/internal/integration/activityclient"
"hyapp-admin-server/internal/integration/walletclient" "hyapp-admin-server/internal/integration/walletclient"
"hyapp-admin-server/internal/integration/withdrawalsource"
"hyapp-admin-server/internal/model" "hyapp-admin-server/internal/model"
"hyapp-admin-server/internal/modules/shared" "hyapp-admin-server/internal/modules/shared"
"hyapp-admin-server/internal/repository" "hyapp-admin-server/internal/repository"
@ -35,9 +38,24 @@ type Service struct {
store *repository.Store store *repository.Store
wallet walletclient.Client wallet walletclient.Client
activity activityclient.Client activity activityclient.Client
sources *withdrawalsource.Registry
notifier ReviewPendingNotifier
now func() time.Time now func() time.Time
} }
type externalWithdrawalApplicationInput struct {
AppCode string
SourceSystem string
SourceApplicationID string
UserID string
SalaryAssetType string
WithdrawAmount string
WithdrawAmountMinor int64
WithdrawMethod string
WithdrawAddress string
CreatedAtMS int64
}
type pointWithdrawalWallet interface { type pointWithdrawalWallet interface {
SettlePointWithdrawal(ctx context.Context, req *walletv1.SettlePointWithdrawalRequest) (*walletv1.SettlePointWithdrawalResponse, error) SettlePointWithdrawal(ctx context.Context, req *walletv1.SettlePointWithdrawalRequest) (*walletv1.SettlePointWithdrawalResponse, error)
ReleasePointWithdrawal(ctx context.Context, req *walletv1.ReleasePointWithdrawalRequest) (*walletv1.ReleasePointWithdrawalResponse, error) ReleasePointWithdrawal(ctx context.Context, req *walletv1.ReleasePointWithdrawalRequest) (*walletv1.ReleasePointWithdrawalResponse, error)
@ -47,6 +65,86 @@ func NewService(store *repository.Store, wallet walletclient.Client, activity ac
return &Service{store: store, wallet: wallet, activity: activity, now: func() time.Time { return time.Now().UTC() }} return &Service{store: store, wallet: wallet, activity: activity, now: func() time.Time { return time.Now().UTC() }}
} }
func NewServiceWithSources(store *repository.Store, wallet walletclient.Client, activity activityclient.Client, sources *withdrawalsource.Registry) *Service {
service := NewService(store, wallet, activity)
service.sources = sources
return service
}
func (s *Service) BindReviewPendingNotifier(notifier ReviewPendingNotifier) *Service {
if s != nil {
s.notifier = notifier
}
return s
}
func (s *Service) CreateExternalApplication(ctx context.Context, 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
}
// 外部 Likei/Aslan 申请直接进入 Admin不经过 Gateway 的创建通知;落库成功后在这里补齐运营阶段提醒。
s.notifyReviewPending(ctx, *application, repository.WithdrawalApplicationReviewStageOperations)
dto := withdrawalApplicationDTOFromModel(*application)
return &dto, nil
}
func validateExternalWithdrawalAmount(amount string, amountMinor int64) error {
parsed, ok := new(big.Rat).SetString(strings.TrimSpace(amount))
if !ok || parsed.Sign() <= 0 || amountMinor <= 0 {
return errors.New("提现金额不正确")
}
minor := new(big.Rat).Mul(parsed, big.NewRat(100, 1))
if !minor.IsInt() || !minor.Num().IsInt64() || minor.Num().Int64() != amountMinor {
return errors.New("提现金额与最小单位金额不一致")
}
return nil
}
func (s *Service) ListApplications(actor shared.Actor, options repository.WithdrawalApplicationListOptions) ([]withdrawalApplicationDTO, int64, error) { func (s *Service) ListApplications(actor shared.Actor, options repository.WithdrawalApplicationListOptions) ([]withdrawalApplicationDTO, int64, error) {
if s == nil || s.store == nil { if s == nil || s.store == nil {
return nil, 0, errors.New("admin store is not configured") return nil, 0, errors.New("admin store is not configured")
@ -156,44 +254,61 @@ func (s *Service) auditApplication(ctx context.Context, actor shared.Actor, id u
AuditImageURL: auditImageURL, AuditImageURL: auditImageURL,
ApprovedAtMS: nowMS, ApprovedAtMS: nowMS,
}, func(item model.UserWithdrawalApplication) (repository.WithdrawalApplicationAuditEffect, error) { }, func(item model.UserWithdrawalApplication) (repository.WithdrawalApplicationAuditEffect, error) {
userID, parseErr := withdrawalWalletUserID(item)
if parseErr != nil {
return repository.WithdrawalApplicationAuditEffect{}, parseErr
}
if stage == repository.WithdrawalApplicationReviewStageOperations && decision == model.WithdrawalApplicationStatusApproved { if stage == repository.WithdrawalApplicationReviewStageOperations && decision == model.WithdrawalApplicationStatusApproved {
// 运营通过只确认业务资料并转交财务;申请金额继续保留在 frozen且不能提前向用户发送最终通过通知。 // 运营通过只确认业务资料并转交财务;申请金额继续保留在 frozen且不能提前向用户发送最终通过通知。
return repository.WithdrawalApplicationAuditEffect{}, nil return repository.WithdrawalApplicationAuditEffect{}, nil
} }
transactionID, walletErr := s.applyWalletDecision(ctx, decision, commandID, appCode, userID, item.SalaryAssetType, item.WithdrawAmountMinor, item.PointFeeAmount, item.PointNetAmount, item.PointsPerUSD, item.PointFeeBPS, actor, strings.TrimSpace(remark), id) transactionID, userID, walletErr := s.applyWithdrawalDecision(ctx, item, decision, commandID, appCode, actor, remark, auditImageURL, id)
if walletErr != nil { if walletErr != nil {
return repository.WithdrawalApplicationAuditEffect{}, walletErr return repository.WithdrawalApplicationAuditEffect{}, walletErr
} }
if noticeErr := s.sendWithdrawalAuditNotice(ctx, auditNoticeInput{ if !isExternalWithdrawal(item) {
ApplicationID: id, noticeErr := s.sendWithdrawalAuditNotice(ctx, auditNoticeInput{
AppCode: appCode, ApplicationID: id,
UserID: userID, AppCode: appCode,
Stage: stage, UserID: userID,
Decision: decision, Stage: stage,
Remark: remark, Decision: decision,
AuditImageURL: auditImageURL, Remark: remark,
AuditTransactionID: transactionID, AuditImageURL: auditImageURL,
WithdrawAmount: item.WithdrawAmount, AuditTransactionID: transactionID,
WithdrawMethod: item.WithdrawMethod, WithdrawAmount: item.WithdrawAmount,
RequestID: requestID, WithdrawMethod: item.WithdrawMethod,
SentAtMS: nowMS, RequestID: requestID,
}); noticeErr != nil { SentAtMS: nowMS,
// 钱包扣冻/释放使用阶段固定 command id通知使用阶段+结果事件 id回调失败会回滚 admin 状态,重试不会重复资金动作或消息。 })
return repository.WithdrawalApplicationAuditEffect{}, noticeErr if noticeErr != nil {
// 钱包扣冻/释放使用阶段固定 command id通知使用阶段+结果事件 id回调失败会回滚 admin 状态,重试不会重复资金动作或消息。
return repository.WithdrawalApplicationAuditEffect{}, noticeErr
}
} }
return repository.WithdrawalApplicationAuditEffect{CommandID: commandID, TransactionID: transactionID}, nil return repository.WithdrawalApplicationAuditEffect{CommandID: commandID, TransactionID: transactionID}, nil
}) })
if err != nil { if err != nil {
return nil, err return nil, err
} }
if stage == repository.WithdrawalApplicationReviewStageOperations && decision == model.WithdrawalApplicationStatusApproved {
// 运营通过已经提交到数据库,随后独立提醒财务;机器人异常不能把已转交的申请回滚到运营队列。
s.notifyReviewPending(ctx, *updated, repository.WithdrawalApplicationReviewStageFinance)
}
dto := withdrawalApplicationDTOFromModel(*updated) dto := withdrawalApplicationDTOFromModel(*updated)
return &dto, nil return &dto, nil
} }
func (s *Service) notifyReviewPending(ctx context.Context, application model.UserWithdrawalApplication, stage repository.WithdrawalApplicationReviewStage) {
if s == nil || s.notifier == nil {
return
}
if err := s.notifier.NotifyReviewPending(ctx, application, stage); err != nil {
slog.Warn("withdrawal_review_dingtalk_notify_failed",
"application_id", application.ID,
"app_code", application.AppCode,
"review_stage", stage,
"error", err,
)
}
}
// executeOperationsRejection 把拒绝拆成 claim -> 外部幂等动作 -> finalize 三段。 // executeOperationsRejection 把拒绝拆成 claim -> 外部幂等动作 -> finalize 三段。
// claim 是独立短事务,因此 wallet 已释放后即使 notice 或最终 admin 提交失败,申请仍停在 rejecting不能被改点通过或进入财务。 // claim 是独立短事务,因此 wallet 已释放后即使 notice 或最终 admin 提交失败,申请仍停在 rejecting不能被改点通过或进入财务。
func (s *Service) executeOperationsRejection(ctx context.Context, actor shared.Actor, id uint, appCode string, remark string, requestID string, commandID string, nowMS int64) (*withdrawalApplicationDTO, error) { func (s *Service) executeOperationsRejection(ctx context.Context, actor shared.Actor, id uint, appCode string, remark string, requestID string, commandID string, nowMS int64) (*withdrawalApplicationDTO, error) {
@ -206,8 +321,7 @@ func (s *Service) executeOperationsRejection(ctx context.Context, actor shared.A
AuditCommandID: commandID, AuditCommandID: commandID,
ApprovedAtMS: nowMS, ApprovedAtMS: nowMS,
}, func(item model.UserWithdrawalApplication) error { }, func(item model.UserWithdrawalApplication) error {
_, err := withdrawalWalletUserID(item) return validateWithdrawalDecisionTarget(item)
return err
}) })
if err != nil { if err != nil {
return nil, err return nil, err
@ -218,17 +332,13 @@ func (s *Service) executeOperationsRejection(ctx context.Context, actor shared.A
return &dto, nil return &dto, nil
} }
userID, err := withdrawalWalletUserID(*claimed)
if err != nil {
return nil, err
}
if claimed.OperationsReviewerUserID == nil || *claimed.OperationsReviewerUserID == 0 || strings.TrimSpace(claimed.OperationsAuditRemark) == "" { if claimed.OperationsReviewerUserID == nil || *claimed.OperationsReviewerUserID == 0 || strings.TrimSpace(claimed.OperationsAuditRemark) == "" {
return nil, errors.New("提现单运营拒绝 claim 不完整") return nil, errors.New("提现单运营拒绝 claim 不完整")
} }
// 重试沿用第一次 claim 的审核人和拒绝原因,不能让资金 metadata、用户通知和后台审计快照随重试人改变。 // 重试沿用第一次 claim 的审核人和拒绝原因,不能让资金 metadata、用户通知和后台审计快照随重试人改变。
claimedActor := shared.Actor{UserID: *claimed.OperationsReviewerUserID, Username: claimed.OperationsReviewerName} claimedActor := shared.Actor{UserID: *claimed.OperationsReviewerUserID, Username: claimed.OperationsReviewerName}
claimedRemark := strings.TrimSpace(claimed.OperationsAuditRemark) claimedRemark := strings.TrimSpace(claimed.OperationsAuditRemark)
transactionID, err := s.applyWalletDecision(ctx, model.WithdrawalApplicationStatusRejected, commandID, appCode, userID, claimed.SalaryAssetType, claimed.WithdrawAmountMinor, claimed.PointFeeAmount, claimed.PointNetAmount, claimed.PointsPerUSD, claimed.PointFeeBPS, claimedActor, claimedRemark, id) transactionID, userID, err := s.applyWithdrawalDecision(ctx, *claimed, model.WithdrawalApplicationStatusRejected, commandID, appCode, claimedActor, claimedRemark, "", id)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -236,21 +346,24 @@ func (s *Service) executeOperationsRejection(ctx context.Context, actor shared.A
if claimed.OperationsReviewedAtMS != nil && *claimed.OperationsReviewedAtMS > 0 { if claimed.OperationsReviewedAtMS != nil && *claimed.OperationsReviewedAtMS > 0 {
sentAtMS = *claimed.OperationsReviewedAtMS sentAtMS = *claimed.OperationsReviewedAtMS
} }
if err := s.sendWithdrawalAuditNotice(ctx, auditNoticeInput{ if !isExternalWithdrawal(*claimed) {
ApplicationID: id, err = s.sendWithdrawalAuditNotice(ctx, auditNoticeInput{
AppCode: appCode, ApplicationID: id,
UserID: userID, AppCode: appCode,
Stage: repository.WithdrawalApplicationReviewStageOperations, UserID: userID,
Decision: model.WithdrawalApplicationStatusRejected, Stage: repository.WithdrawalApplicationReviewStageOperations,
Remark: claimedRemark, Decision: model.WithdrawalApplicationStatusRejected,
AuditTransactionID: transactionID, Remark: claimedRemark,
WithdrawAmount: claimed.WithdrawAmount, AuditTransactionID: transactionID,
WithdrawMethod: claimed.WithdrawMethod, WithdrawAmount: claimed.WithdrawAmount,
RequestID: requestID, WithdrawMethod: claimed.WithdrawMethod,
SentAtMS: sentAtMS, RequestID: requestID,
}); err != nil { SentAtMS: sentAtMS,
// rejecting 已在前一事务提交;返回错误让运营页展示“重试拒绝”,绝不能回退成 pending。 })
return nil, err if err != nil {
// rejecting 已在前一事务提交;返回错误让运营页展示“重试拒绝”,绝不能回退成 pending。
return nil, err
}
} }
updated, err := s.store.FinalizeWithdrawalOperationsRejectionForApp(appCode, id, commandID, transactionID, s.now().UnixMilli()) updated, err := s.store.FinalizeWithdrawalOperationsRejectionForApp(appCode, id, commandID, transactionID, s.now().UnixMilli())
if err != nil { if err != nil {
@ -268,6 +381,52 @@ func withdrawalWalletUserID(item model.UserWithdrawalApplication) (int64, error)
return userID, nil return userID, nil
} }
func validateWithdrawalDecisionTarget(item model.UserWithdrawalApplication) error {
if isExternalWithdrawal(item) {
if strings.TrimSpace(item.SourceApplicationID) == "" || strings.TrimSpace(item.UserID) == "" || item.WithdrawAmountMinor <= 0 {
return errors.New("外部提现单来源信息不完整")
}
return nil
}
_, err := withdrawalWalletUserID(item)
return err
}
func isExternalWithdrawal(item model.UserWithdrawalApplication) bool {
sourceSystem := strings.ToLower(strings.TrimSpace(item.SourceSystem))
return sourceSystem != "" && sourceSystem != "hyapp_wallet"
}
func (s *Service) applyWithdrawalDecision(ctx context.Context, item model.UserWithdrawalApplication, decision string, commandID string, appCode string, actor shared.Actor, remark string, auditImageURL string, applicationID uint) (string, int64, error) {
if isExternalWithdrawal(item) {
if s.sources == nil {
return "", 0, errors.New("withdrawal source callback is not configured")
}
sourceDecision := "NOT_PASS"
credentials := []string(nil)
if decision == model.WithdrawalApplicationStatusApproved {
sourceDecision = "PASS"
credentials = []string{strings.TrimSpace(auditImageURL)}
}
transactionID, err := s.sources.ApplyDecision(ctx, appCode, item.SourceSystem, withdrawalsource.DecisionRequest{
SourceApplicationID: item.SourceApplicationID,
Decision: sourceDecision,
CredentialURLs: credentials,
Remark: strings.TrimSpace(remark),
ReviewerUserID: actor.UserID,
ReviewerName: actor.Username,
CommandID: commandID,
})
return transactionID, 0, err
}
userID, err := withdrawalWalletUserID(item)
if err != nil {
return "", 0, err
}
transactionID, err := s.applyWalletDecision(ctx, decision, commandID, appCode, userID, item.SalaryAssetType, item.WithdrawAmountMinor, item.PointFeeAmount, item.PointNetAmount, item.PointsPerUSD, item.PointFeeBPS, actor, strings.TrimSpace(remark), applicationID)
return transactionID, userID, err
}
func (s *Service) applyWalletDecision(ctx context.Context, decision string, commandID string, appCode string, userID int64, assetType string, amountMinor int64, storedFeePoints int64, storedNetPoints int64, storedPointsPerUSD int64, storedFeeBPS int32, actor shared.Actor, remark string, applicationID uint) (string, error) { func (s *Service) applyWalletDecision(ctx context.Context, decision string, commandID string, appCode string, userID int64, assetType string, amountMinor int64, storedFeePoints int64, storedNetPoints int64, storedPointsPerUSD int64, storedFeeBPS int32, actor shared.Actor, remark string, applicationID uint) (string, error) {
reason := strings.TrimSpace(remark) reason := strings.TrimSpace(remark)
if reason == "" { if reason == "" {

View File

@ -29,6 +29,7 @@ func (h *Handler) ListPolicies(c *gin.Context) {
items, total, err := h.service.List(appctx.FromContext(c.Request.Context()), repository.HostAgencySalaryPolicyListOptions{ items, total, err := h.service.List(appctx.FromContext(c.Request.Context()), repository.HostAgencySalaryPolicyListOptions{
Keyword: options.Keyword, Keyword: options.Keyword,
RegionID: queryInt64(c, "region_id", "regionId"), RegionID: queryInt64(c, "region_id", "regionId"),
CycleKey: firstQuery(c, "cycle_key", "cycleKey"),
Status: options.Status, Status: options.Status,
SettlementMode: firstQuery(c, "settlement_mode", "settlementMode"), SettlementMode: firstQuery(c, "settlement_mode", "settlementMode"),
// trigger mode 可直接筛出“只允许手动结算”的政策,后续工资结算页会复用同一查询口径。 // trigger mode 可直接筛出“只允许手动结算”的政策,后续工资结算页会复用同一查询口径。

View File

@ -3,6 +3,7 @@ package hostagencypolicy
type policyRequest struct { type policyRequest struct {
Name string `json:"name"` Name string `json:"name"`
RegionID int64 `json:"region_id"` RegionID int64 `json:"region_id"`
CycleKey string `json:"cycle_key"`
Status string `json:"status"` Status string `json:"status"`
SettlementMode string `json:"settlement_mode"` SettlementMode string `json:"settlement_mode"`
SettlementTriggerMode string `json:"settlement_trigger_mode"` SettlementTriggerMode string `json:"settlement_trigger_mode"`

View File

@ -11,6 +11,8 @@ type policyDTO struct {
AppCode string `json:"app_code"` AppCode string `json:"app_code"`
Name string `json:"name"` Name string `json:"name"`
RegionID int64 `json:"region_id"` RegionID int64 `json:"region_id"`
CycleKey string `json:"cycle_key"`
PolicyVersion uint64 `json:"policy_version"`
Status string `json:"status"` Status string `json:"status"`
SettlementMode string `json:"settlement_mode"` SettlementMode string `json:"settlement_mode"`
SettlementTriggerMode string `json:"settlement_trigger_mode"` SettlementTriggerMode string `json:"settlement_trigger_mode"`
@ -56,6 +58,8 @@ func policyFromModel(item model.HostAgencySalaryPolicy) policyDTO {
AppCode: item.AppCode, AppCode: item.AppCode,
Name: item.Name, Name: item.Name,
RegionID: item.RegionID, RegionID: item.RegionID,
CycleKey: item.CycleKey,
PolicyVersion: item.PolicyVersion,
Status: item.Status, Status: item.Status,
SettlementMode: item.SettlementMode, SettlementMode: item.SettlementMode,
SettlementTriggerMode: firstNonBlank(item.SettlementTriggerMode, settlementTriggerAutomatic), SettlementTriggerMode: firstNonBlank(item.SettlementTriggerMode, settlementTriggerAutomatic),

View File

@ -69,7 +69,7 @@ func (s *Service) Create(appCode string, actorID uint, req policyRequest) (polic
if err != nil { if err != nil {
return policyDTO{}, err return policyDTO{}, err
} }
// 启用政策必须先做时间段冲突校验,否则同一区域结算时会匹配到两套工资规则。 // 启用政策必须先做周期冲突校验,否则同一区域同一月份会出现两套工资规则。
if err := s.ensureNoActiveOverlap(item, 0); err != nil { if err := s.ensureNoActiveOverlap(item, 0); err != nil {
return policyDTO{}, err return policyDTO{}, err
} }
@ -85,6 +85,10 @@ func (s *Service) Update(appCode string, actorID uint, id uint, req policyReques
if err != nil { if err != nil {
return policyDTO{}, err return policyDTO{}, err
} }
if item.PublishStatus == publishStatusPublished || item.PublishedAtMS > 0 {
// 运行侧已经按周期绑定该版本;编辑会让历史结算口径漂移,只能为新周期新建政策。
return policyDTO{}, errors.New("已发布的周期政策版本不可修改,请为新周期创建政策")
}
// 请求体重新走创建同一套校验,编辑和新增保持完全一致的字段约束。 // 请求体重新走创建同一套校验,编辑和新增保持完全一致的字段约束。
updated, err := policyModelFromRequest(item.AppCode, actorID, req) updated, err := policyModelFromRequest(item.AppCode, actorID, req)
if err != nil { if err != nil {
@ -92,6 +96,7 @@ func (s *Service) Update(appCode string, actorID uint, id uint, req policyReques
} }
item.Name = updated.Name item.Name = updated.Name
item.RegionID = updated.RegionID item.RegionID = updated.RegionID
item.CycleKey = updated.CycleKey
item.Status = updated.Status item.Status = updated.Status
item.SettlementMode = updated.SettlementMode item.SettlementMode = updated.SettlementMode
item.SettlementTriggerMode = updated.SettlementTriggerMode item.SettlementTriggerMode = updated.SettlementTriggerMode
@ -115,14 +120,15 @@ func (s *Service) Update(appCode string, actorID uint, id uint, req policyReques
return policyFromModel(item), nil return policyFromModel(item), nil
} }
func (s *Service) Delete(ctx context.Context, appCode string, id uint) error { func (s *Service) Delete(_ context.Context, appCode string, id uint) error {
// 先按 app_code 查一次,确保删除动作只能落在当前应用上下文内,随后仓储层事务删除主表和等级表。 // 先按 app_code 查一次,确保删除动作只能落在当前应用上下文内,随后仓储层事务删除主表和等级表。
if _, err := s.store.GetHostAgencySalaryPolicy(appctx.Normalize(appCode), id); err != nil { item, err := s.store.GetHostAgencySalaryPolicy(appctx.Normalize(appCode), id)
if err != nil {
return err return err
} }
// 删除后台配置前先清掉 wallet 运行快照,避免 admin 记录消失后结算仍命中过期政策。 if item.PublishStatus == publishStatusPublished || item.PublishedAtMS > 0 {
if err := s.deleteRuntimePolicy(ctx, appctx.Normalize(appCode), id); err != nil { // 已发布版本及其周期绑定是历史结算依据;停用或删除都不能破坏已存在月份的可追溯性。
return err return errors.New("已发布的周期政策版本不可删除")
} }
return s.store.DeleteHostAgencySalaryPolicy(appctx.Normalize(appCode), id) return s.store.DeleteHostAgencySalaryPolicy(appctx.Normalize(appCode), id)
} }
@ -133,6 +139,13 @@ func (s *Service) Publish(ctx context.Context, appCode string, actorID uint, id
if err != nil { if err != nil {
return policyDTO{}, err return policyDTO{}, err
} }
if item.PublishStatus == publishStatusPublished || item.PublishedAtMS > 0 {
// 重复点击发布按幂等成功返回;不再覆盖运行表,也不生成新的周期绑定。
return policyFromModel(item), nil
}
if item.Status != policyStatusActive {
return policyDTO{}, errors.New("工资政策启用后才能发布周期版本")
}
if err := s.ensureNoActiveOverlap(item, id); err != nil { if err := s.ensureNoActiveOverlap(item, id); err != nil {
return policyDTO{}, err return policyDTO{}, err
} }
@ -157,13 +170,12 @@ func (s *Service) ensureNoActiveOverlap(item model.HostAgencySalaryPolicy, exclu
if item.Status != policyStatusActive { if item.Status != policyStatusActive {
return nil return nil
} }
// 时间段判断采用半开区间:[effective_from_ms, effective_to_ms)effective_to_ms=0 表示长期有效。 overlap, err := s.store.HasActiveHostAgencySalaryPolicyForCycle(item.AppCode, item.RegionID, item.CycleKey, excludeID)
overlap, err := s.store.HasOverlappingActiveHostAgencySalaryPolicy(item.AppCode, item.RegionID, item.EffectiveFromMS, item.EffectiveToMS, excludeID)
if err != nil { if err != nil {
return err return err
} }
if overlap { if overlap {
return errors.New("同一区域同一时间只能存在一个启用政策") return errors.New("同一区域同一工资周期只能存在一个启用政策")
} }
return nil return nil
} }
@ -181,26 +193,22 @@ func (s *Service) publishRuntimePolicy(ctx context.Context, item model.HostAgenc
} }
defer func() { _ = tx.Rollback() }() defer func() { _ = tx.Rollback() }()
// wallet-service 运行表以 admin policy_id 为主键;每次发布整包覆盖主表和等级,保证结算读取到一套完整梯度。 policyVersion := item.PolicyVersion
if policyVersion == 0 {
// 仅兼容迁移前创建但尚未发布的草稿;新记录在 admin 仓储创建事务中固定为 policy ID。
policyVersion = uint64(item.ID)
}
// policy 主表和等级表只允许首次插入;任何重复主键或同周期绑定冲突都会回滚整包发布,禁止覆盖历史版本。
if _, err := tx.ExecContext(ctx, ` if _, err := tx.ExecContext(ctx, `
INSERT INTO host_agency_salary_policies ( INSERT INTO host_agency_salary_policies (
app_code, policy_id, name, region_id, status, settlement_mode, settlement_trigger_mode, app_code, policy_id, cycle_key, policy_version, name, region_id, status, settlement_mode, settlement_trigger_mode,
gift_coin_to_diamond_ratio, residual_diamond_to_usd_rate, gift_coin_to_diamond_ratio, residual_diamond_to_usd_rate,
effective_from_ms, effective_to_ms, created_at_ms, updated_at_ms effective_from_ms, effective_to_ms, created_at_ms, updated_at_ms
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
ON DUPLICATE KEY UPDATE
name = VALUES(name),
region_id = VALUES(region_id),
status = VALUES(status),
settlement_mode = VALUES(settlement_mode),
settlement_trigger_mode = VALUES(settlement_trigger_mode),
gift_coin_to_diamond_ratio = VALUES(gift_coin_to_diamond_ratio),
residual_diamond_to_usd_rate = VALUES(residual_diamond_to_usd_rate),
effective_from_ms = VALUES(effective_from_ms),
effective_to_ms = VALUES(effective_to_ms),
updated_at_ms = VALUES(updated_at_ms)`,
item.AppCode, item.AppCode,
item.ID, item.ID,
item.CycleKey,
policyVersion,
item.Name, item.Name,
item.RegionID, item.RegionID,
item.Status, item.Status,
@ -215,9 +223,6 @@ func (s *Service) publishRuntimePolicy(ctx context.Context, item model.HostAgenc
); err != nil { ); err != nil {
return err return err
} }
if _, err := tx.ExecContext(ctx, `DELETE FROM host_agency_salary_policy_levels WHERE app_code = ? AND policy_id = ?`, item.AppCode, item.ID); err != nil {
return err
}
for _, level := range item.Levels { for _, level := range item.Levels {
_, hostSalaryMinor, err := parseFixedDecimal(level.HostSalaryUSD, 2, true, "主播美元工资") _, hostSalaryMinor, err := parseFixedDecimal(level.HostSalaryUSD, 2, true, "主播美元工资")
if err != nil { if err != nil {
@ -248,22 +253,13 @@ func (s *Service) publishRuntimePolicy(ctx context.Context, item model.HostAgenc
return err return err
} }
} }
return tx.Commit() // 周期绑定的复合主键是最终防线:同一区域同一周期只能首次绑定一次,后续月份发布不会影响它。
} if _, err := tx.ExecContext(ctx, `
INSERT INTO host_salary_policy_cycle_bindings (
func (s *Service) deleteRuntimePolicy(ctx context.Context, appCode string, policyID uint) error { app_code, region_id, cycle_key, policy_id, policy_version, created_at_ms
if s == nil || s.walletDB == nil { ) VALUES (?, ?, ?, ?, ?, ?)`,
return errors.New("wallet mysql is not configured") item.AppCode, item.RegionID, item.CycleKey, item.ID, policyVersion, publishedAtMS,
} ); err != nil {
tx, err := s.walletDB.BeginTx(ctx, nil)
if err != nil {
return err
}
defer func() { _ = tx.Rollback() }()
if _, err := tx.ExecContext(ctx, `DELETE FROM host_agency_salary_policy_levels WHERE app_code = ? AND policy_id = ?`, appCode, policyID); err != nil {
return err
}
if _, err := tx.ExecContext(ctx, `DELETE FROM host_agency_salary_policies WHERE app_code = ? AND policy_id = ?`, appCode, policyID); err != nil {
return err return err
} }
return tx.Commit() return tx.Commit()
@ -274,10 +270,14 @@ func policyModelFromRequest(appCode string, actorID uint, req policyRequest) (mo
if name == "" || len([]rune(name)) > 120 { if name == "" || len([]rune(name)) > 120 {
return model.HostAgencySalaryPolicy{}, errors.New("政策名称不正确") return model.HostAgencySalaryPolicy{}, errors.New("政策名称不正确")
} }
// 当前阶段保持单区域政策:一个区域同一时段只能命中一条 active 规则,后续结算查询也以 region_id 做索引 // 当前阶段保持单区域政策:一个区域同一工资周期只能命中一条 active 规则
if req.RegionID <= 0 { if req.RegionID <= 0 {
return model.HostAgencySalaryPolicy{}, errors.New("请选择适用区域") return model.HostAgencySalaryPolicy{}, errors.New("请选择适用区域")
} }
cycleKey, err := normalizePolicyCycleKey(req.CycleKey, req.EffectiveFromMS)
if err != nil {
return model.HostAgencySalaryPolicy{}, err
}
status := normalizeStatus(req.Status) status := normalizeStatus(req.Status)
if status == "" { if status == "" {
// 新建默认停用,避免运营保存草稿时立即影响结算口径。 // 新建默认停用,避免运营保存草稿时立即影响结算口径。
@ -329,6 +329,7 @@ func policyModelFromRequest(appCode string, actorID uint, req policyRequest) (mo
AppCode: appCode, AppCode: appCode,
Name: name, Name: name,
RegionID: req.RegionID, RegionID: req.RegionID,
CycleKey: cycleKey,
Status: status, Status: status,
SettlementMode: settlementMode, SettlementMode: settlementMode,
SettlementTriggerMode: settlementTriggerMode, SettlementTriggerMode: settlementTriggerMode,
@ -525,6 +526,22 @@ func nonZeroMS(value int64, fallback int64) int64 {
return fallback return fallback
} }
func normalizePolicyCycleKey(raw string, effectiveFromMS int64) (string, error) {
cycleKey := strings.TrimSpace(raw)
if cycleKey == "" && effectiveFromMS > 0 {
// 兼容尚未升级的后台页面:旧请求仍可从生效开始时间推导 UTC 归属月,新页面应显式提交 cycle_key。
cycleKey = time.UnixMilli(effectiveFromMS).UTC().Format("2006-01")
}
if len(cycleKey) != len("2006-01") {
return "", errors.New("请选择工资周期")
}
parsed, err := time.Parse("2006-01", cycleKey)
if err != nil || parsed.UTC().Format("2006-01") != cycleKey {
return "", errors.New("工资周期格式必须为 YYYY-MM")
}
return cycleKey, nil
}
func truncateRunes(value string, limit int) string { func truncateRunes(value string, limit int) string {
if limit <= 0 { if limit <= 0 {
return "" return ""

View File

@ -6,6 +6,7 @@ func TestPolicyModelFromRequestNormalizesAndSortsLevels(t *testing.T) {
item, err := policyModelFromRequest("lalu", 7, policyRequest{ item, err := policyModelFromRequest("lalu", 7, policyRequest{
Name: "Lalu 默认政策", Name: "Lalu 默认政策",
RegionID: 101, RegionID: 101,
CycleKey: "2026-06",
Status: "active", Status: "active",
SettlementMode: "half-month", SettlementMode: "half-month",
SettlementTriggerMode: "manual", SettlementTriggerMode: "manual",
@ -19,7 +20,7 @@ func TestPolicyModelFromRequestNormalizesAndSortsLevels(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("policy should be valid: %v", err) t.Fatalf("policy should be valid: %v", err)
} }
if item.AppCode != "lalu" || item.CreatedByAdminID != 7 || item.SettlementMode != settlementModeHalfMonth || item.SettlementTriggerMode != settlementTriggerManual { if item.AppCode != "lalu" || item.CycleKey != "2026-06" || item.CreatedByAdminID != 7 || item.SettlementMode != settlementModeHalfMonth || item.SettlementTriggerMode != settlementTriggerManual {
t.Fatalf("policy fields mismatch: %+v", item) t.Fatalf("policy fields mismatch: %+v", item)
} }
// 工资政策不能单独改送礼入主播周期钻石的比例;该比例只由礼物钻石配置页控制。 // 工资政策不能单独改送礼入主播周期钻石的比例;该比例只由礼物钻石配置页控制。
@ -38,6 +39,7 @@ func TestPolicyModelFromRequestRejectsNonIncreasingLevels(t *testing.T) {
_, err := policyModelFromRequest("lalu", 1, policyRequest{ _, err := policyModelFromRequest("lalu", 1, policyRequest{
Name: "Invalid", Name: "Invalid",
RegionID: 101, RegionID: 101,
CycleKey: "2026-06",
SettlementMode: settlementModeDaily, SettlementMode: settlementModeDaily,
Levels: []levelRequest{ Levels: []levelRequest{
{Level: 1, RequiredDiamonds: 700000, HostSalaryUSD: "3", HostCoinReward: 198000, AgencySalaryUSD: "0.8"}, {Level: 1, RequiredDiamonds: 700000, HostSalaryUSD: "3", HostCoinReward: 198000, AgencySalaryUSD: "0.8"},
@ -53,6 +55,7 @@ func TestPolicyModelFromRequestDefaultsSettlementTriggerMode(t *testing.T) {
item, err := policyModelFromRequest("lalu", 1, policyRequest{ item, err := policyModelFromRequest("lalu", 1, policyRequest{
Name: "Default trigger", Name: "Default trigger",
RegionID: 101, RegionID: 101,
CycleKey: "2026-06",
SettlementMode: settlementModeDaily, SettlementMode: settlementModeDaily,
Levels: []levelRequest{ Levels: []levelRequest{
{Level: 1, RequiredDiamonds: 350000, HostSalaryUSD: "1.5", HostCoinReward: 90000, AgencySalaryUSD: "0.5"}, {Level: 1, RequiredDiamonds: 350000, HostSalaryUSD: "1.5", HostCoinReward: 90000, AgencySalaryUSD: "0.5"},
@ -70,6 +73,7 @@ func TestPolicyModelFromRequestRejectsInvalidSettlementMode(t *testing.T) {
_, err := policyModelFromRequest("lalu", 1, policyRequest{ _, err := policyModelFromRequest("lalu", 1, policyRequest{
Name: "Invalid", Name: "Invalid",
RegionID: 101, RegionID: 101,
CycleKey: "2026-06",
SettlementMode: "monthly", SettlementMode: "monthly",
Levels: []levelRequest{ Levels: []levelRequest{
{Level: 1, RequiredDiamonds: 350000, HostSalaryUSD: "1.5", HostCoinReward: 90000, AgencySalaryUSD: "0.5"}, {Level: 1, RequiredDiamonds: 350000, HostSalaryUSD: "1.5", HostCoinReward: 90000, AgencySalaryUSD: "0.5"},
@ -84,6 +88,7 @@ func TestPolicyModelFromRequestRejectsInvalidSettlementTriggerMode(t *testing.T)
_, err := policyModelFromRequest("lalu", 1, policyRequest{ _, err := policyModelFromRequest("lalu", 1, policyRequest{
Name: "Invalid", Name: "Invalid",
RegionID: 101, RegionID: 101,
CycleKey: "2026-06",
SettlementMode: settlementModeDaily, SettlementMode: settlementModeDaily,
SettlementTriggerMode: "cron", SettlementTriggerMode: "cron",
Levels: []levelRequest{ Levels: []levelRequest{
@ -94,3 +99,34 @@ func TestPolicyModelFromRequestRejectsInvalidSettlementTriggerMode(t *testing.T)
t.Fatal("expected invalid settlement trigger mode to fail") t.Fatal("expected invalid settlement trigger mode to fail")
} }
} }
func TestPolicyModelFromRequestDerivesUTCCycleForLegacyRequest(t *testing.T) {
item, err := policyModelFromRequest("lalu", 1, policyRequest{
Name: "Legacy cycle",
RegionID: 101,
EffectiveFromMS: 1782864000000, // 2026-07-01T00:00:00Z
Levels: []levelRequest{
{Level: 1, RequiredDiamonds: 350000, HostSalaryUSD: "1.5", HostCoinReward: 90000, AgencySalaryUSD: "0.5"},
},
})
if err != nil {
t.Fatalf("legacy request should derive cycle: %v", err)
}
if item.CycleKey != "2026-07" {
t.Fatalf("derived cycle mismatch: %+v", item)
}
}
func TestPolicyModelFromRequestRejectsInvalidCycle(t *testing.T) {
_, err := policyModelFromRequest("lalu", 1, policyRequest{
Name: "Invalid cycle",
RegionID: 101,
CycleKey: "2026-13",
Levels: []levelRequest{
{Level: 1, RequiredDiamonds: 350000, HostSalaryUSD: "1.5", HostCoinReward: 90000, AgencySalaryUSD: "0.5"},
},
})
if err == nil {
t.Fatal("expected invalid cycle to fail")
}
}

View File

@ -61,6 +61,7 @@ type pendingDTO struct {
RegionID int64 `json:"region_id"` RegionID int64 `json:"region_id"`
RegionName string `json:"region_name"` RegionName string `json:"region_name"`
PolicyID uint64 `json:"policy_id"` PolicyID uint64 `json:"policy_id"`
PolicyVersion uint64 `json:"policy_version"`
PolicyName string `json:"policy_name"` PolicyName string `json:"policy_name"`
SettlementType string `json:"settlement_type"` SettlementType string `json:"settlement_type"`
TriggerMode string `json:"trigger_mode"` TriggerMode string `json:"trigger_mode"`
@ -107,6 +108,7 @@ type settlementDTO struct {
AgencyOwner userDTO `json:"agency_owner"` AgencyOwner userDTO `json:"agency_owner"`
CycleKey string `json:"cycle_key"` CycleKey string `json:"cycle_key"`
PolicyID uint64 `json:"policy_id"` PolicyID uint64 `json:"policy_id"`
PolicyVersion uint64 `json:"policy_version"`
LevelNo int32 `json:"level_no"` LevelNo int32 `json:"level_no"`
TotalDiamonds int64 `json:"total_diamonds"` TotalDiamonds int64 `json:"total_diamonds"`
HostSalaryUSDMinorDelta int64 `json:"host_salary_usd_minor_delta"` HostSalaryUSDMinorDelta int64 `json:"host_salary_usd_minor_delta"`

View File

@ -54,6 +54,8 @@ type walletCronClient interface {
type hostPolicy struct { type hostPolicy struct {
PolicyID uint64 PolicyID uint64
CycleKey string
PolicyVersion uint64
Name string Name string
RegionID int64 RegionID int64
SettlementMode string SettlementMode string
@ -108,7 +110,7 @@ func (s *Service) ListPending(ctx context.Context, appCode string, req pendingQu
} }
req = normalizePendingQuery(req) req = normalizePendingQuery(req)
// 待结算列表只做只读重算,不写 progress、不写钱包真正入账必须走 Settle 调 wallet-service。 // 待结算列表只做只读重算,不写 progress、不写钱包真正入账必须走 Settle 调 wallet-service。
candidates, err := s.collectPendingCandidates(ctx, strings.TrimSpace(appCode), req, time.Now().UTC().UnixMilli()) candidates, err := s.collectPendingCandidates(ctx, strings.TrimSpace(appCode), req)
if err != nil { if err != nil {
return nil, 0, err return nil, 0, err
} }
@ -158,7 +160,7 @@ func (s *Service) Settle(ctx context.Context, appCode string, operatorAdminID in
RegionID: req.RegionID, RegionID: req.RegionID,
CountryID: req.CountryID, CountryID: req.CountryID,
CountryCode: req.CountryCode, CountryCode: req.CountryCode,
}, time.Now().UTC().UnixMilli()) })
if err != nil { if err != nil {
return settleResultDTO{}, err return settleResultDTO{}, err
} }
@ -229,7 +231,7 @@ func (s *Service) callWalletSettlement(ctx context.Context, appCode string, req
} }
} }
func (s *Service) collectPendingCandidates(ctx context.Context, appCode string, req pendingQuery, nowMS int64) ([]hostCandidate, error) { func (s *Service) collectPendingCandidates(ctx context.Context, appCode string, req pendingQuery) ([]hostCandidate, error) {
req = normalizePendingQuery(req) req = normalizePendingQuery(req)
regions, err := s.regionFilter(ctx, appCode, req.RegionID, req.CountryID, req.CountryCode) regions, err := s.regionFilter(ctx, appCode, req.RegionID, req.CountryID, req.CountryCode)
if err != nil { if err != nil {
@ -255,7 +257,6 @@ func (s *Service) collectPendingCandidates(ctx context.Context, appCode string,
userClause = "AND a.user_id = ?" userClause = "AND a.user_id = ?"
args = append(args, req.UserID) args = append(args, req.UserID)
} }
args = append(args, nowMS, nowMS)
triggerClause := "" triggerClause := ""
if req.TriggerMode != "" { if req.TriggerMode != "" {
triggerClause = "AND p.settlement_trigger_mode = ?" triggerClause = "AND p.settlement_trigger_mode = ?"
@ -284,12 +285,17 @@ func (s *Service) collectPendingCandidates(ctx context.Context, appCode string,
%s %s
AND EXISTS ( AND EXISTS (
SELECT 1 SELECT 1
FROM host_agency_salary_policies p FROM host_salary_policy_cycle_bindings binding
WHERE p.app_code = a.app_code JOIN host_agency_salary_policies p
AND p.region_id = a.region_id ON p.app_code = binding.app_code
AND p.policy_id = binding.policy_id
AND p.region_id = binding.region_id
AND p.cycle_key = binding.cycle_key
AND p.policy_version = binding.policy_version
WHERE binding.app_code = a.app_code
AND binding.region_id = a.region_id
AND binding.cycle_key = a.cycle_key
AND p.status = 'active' AND p.status = 'active'
AND p.effective_from_ms <= ?
AND (p.effective_to_ms = 0 OR p.effective_to_ms > ?)
%s %s
%s %s
) )
@ -319,10 +325,10 @@ func (s *Service) collectPendingCandidates(ctx context.Context, appCode string,
); err != nil { ); err != nil {
return nil, err return nil, err
} }
cacheKey := fmt.Sprintf("%d:%s:%s", item.RegionID, req.SettlementType, req.TriggerMode) cacheKey := fmt.Sprintf("%d:%s:%s:%s", item.RegionID, item.CycleKey, req.SettlementType, req.TriggerMode)
policy, ok := policyCache[cacheKey] policy, ok := policyCache[cacheKey]
if !ok { if !ok {
policy, ok, err = s.resolvePolicy(ctx, appCode, item.RegionID, req.SettlementType, req.TriggerMode, nowMS) policy, ok, err = s.resolvePolicy(ctx, appCode, item.RegionID, item.CycleKey, req.SettlementType, req.TriggerMode)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -380,7 +386,7 @@ func (s *Service) List(ctx context.Context, appCode string, req query) ([]settle
} }
rows, err := s.walletDB.QueryContext(ctx, ` rows, err := s.walletDB.QueryContext(ctx, `
SELECT settlement_id, command_id, transaction_id, settlement_type, trigger_mode, settlement_role, user_id, agency_owner_user_id, SELECT settlement_id, command_id, transaction_id, settlement_type, trigger_mode, settlement_role, user_id, agency_owner_user_id,
cycle_key, policy_id, level_no, total_diamonds, host_salary_usd_minor_delta, cycle_key, policy_id, policy_version, level_no, total_diamonds, host_salary_usd_minor_delta,
host_coin_reward_delta, agency_salary_usd_minor_delta, residual_usd_minor_delta, host_coin_reward_delta, agency_salary_usd_minor_delta, residual_usd_minor_delta,
status, reason, created_at_ms status, reason, created_at_ms
FROM host_salary_settlement_records FROM host_salary_settlement_records
@ -411,6 +417,7 @@ func (s *Service) List(ctx context.Context, appCode string, req query) ([]settle
&agencyOwnerUserID, &agencyOwnerUserID,
&item.CycleKey, &item.CycleKey,
&item.PolicyID, &item.PolicyID,
&item.PolicyVersion,
&item.LevelNo, &item.LevelNo,
&item.TotalDiamonds, &item.TotalDiamonds,
&item.HostSalaryUSDMinorDelta, &item.HostSalaryUSDMinorDelta,
@ -515,25 +522,31 @@ func settlementWhere(appCode string, req query) (string, []any) {
return "WHERE " + strings.Join(conditions, " AND "), args return "WHERE " + strings.Join(conditions, " AND "), args
} }
func (s *Service) resolvePolicy(ctx context.Context, appCode string, regionID int64, settlementType string, triggerMode string, nowMS int64) (hostPolicy, bool, error) { func (s *Service) resolvePolicy(ctx context.Context, appCode string, regionID int64, cycleKey string, settlementType string, triggerMode string) (hostPolicy, bool, error) {
conditions := []string{"app_code = ?", "region_id = ?", "status = 'active'", "effective_from_ms <= ?", "(effective_to_ms = 0 OR effective_to_ms > ?)"} conditions := []string{"binding.app_code = ?", "binding.region_id = ?", "binding.cycle_key = ?", "p.status = 'active'"}
args := []any{appCode, regionID, nowMS, nowMS} args := []any{appCode, regionID, cycleKey}
if triggerMode != "" { if triggerMode != "" {
conditions = append(conditions, "settlement_trigger_mode = ?") conditions = append(conditions, "p.settlement_trigger_mode = ?")
args = append(args, triggerMode) args = append(args, triggerMode)
} }
if settlementType != settlementTypeMonthEnd { if settlementType != settlementTypeMonthEnd {
conditions = append(conditions, "settlement_mode = ?") conditions = append(conditions, "p.settlement_mode = ?")
args = append(args, settlementType) args = append(args, settlementType)
} }
row := s.walletDB.QueryRowContext(ctx, ` row := s.walletDB.QueryRowContext(ctx, `
SELECT policy_id, name, region_id, settlement_mode, settlement_trigger_mode, CAST(residual_diamond_to_usd_rate AS CHAR) SELECT p.policy_id, p.cycle_key, p.policy_version, p.name, p.region_id,
FROM host_agency_salary_policies p.settlement_mode, p.settlement_trigger_mode, CAST(p.residual_diamond_to_usd_rate AS CHAR)
FROM host_salary_policy_cycle_bindings binding
JOIN host_agency_salary_policies p
ON p.app_code = binding.app_code
AND p.policy_id = binding.policy_id
AND p.region_id = binding.region_id
AND p.cycle_key = binding.cycle_key
AND p.policy_version = binding.policy_version
WHERE `+strings.Join(conditions, " AND ")+` WHERE `+strings.Join(conditions, " AND ")+`
ORDER BY effective_from_ms DESC, policy_id DESC
LIMIT 1`, args...) LIMIT 1`, args...)
var policy hostPolicy var policy hostPolicy
if err := row.Scan(&policy.PolicyID, &policy.Name, &policy.RegionID, &policy.SettlementMode, &policy.TriggerMode, &policy.ResidualDiamondToUSDRate); err != nil { if err := row.Scan(&policy.PolicyID, &policy.CycleKey, &policy.PolicyVersion, &policy.Name, &policy.RegionID, &policy.SettlementMode, &policy.TriggerMode, &policy.ResidualDiamondToUSDRate); err != nil {
if errors.Is(err, sql.ErrNoRows) { if errors.Is(err, sql.ErrNoRows) {
return hostPolicy{}, false, nil return hostPolicy{}, false, nil
} }
@ -867,6 +880,7 @@ func pendingDTOFromCandidate(item hostCandidate, host userProfile, agency userPr
RegionID: item.RegionID, RegionID: item.RegionID,
RegionName: item.RegionName, RegionName: item.RegionName,
PolicyID: item.Policy.PolicyID, PolicyID: item.Policy.PolicyID,
PolicyVersion: item.Policy.PolicyVersion,
PolicyName: item.Policy.Name, PolicyName: item.Policy.Name,
SettlementType: item.SettlementType, SettlementType: item.SettlementType,
TriggerMode: item.Policy.TriggerMode, TriggerMode: item.Policy.TriggerMode,

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,132 @@
package dingtalkrobot
import (
"bytes"
"context"
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
)
type Config struct {
WebhookURL string
Secret string
AtMobiles []string
AtAll bool
RequestTimeout time.Duration
}
// Client 位于 admin 自己的 platform 边界,避免独立 module 反向依赖根模块 hyapp/pkg。
type Client struct {
webhookURL string
secret string
atMobiles []string
atAll bool
httpClient *http.Client
}
func New(config Config) (*Client, error) {
webhookURL := strings.TrimSpace(config.WebhookURL)
if webhookURL == "" {
return nil, errors.New("dingtalk webhook url is required")
}
parsed, err := url.ParseRequestURI(webhookURL)
if err != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") || parsed.Host == "" {
return nil, errors.New("dingtalk webhook url must be an absolute http url")
}
timeout := config.RequestTimeout
if timeout <= 0 {
timeout = 3 * time.Second
}
return &Client{
webhookURL: webhookURL,
secret: strings.TrimSpace(config.Secret),
atMobiles: compactStrings(config.AtMobiles),
atAll: config.AtAll,
httpClient: &http.Client{Timeout: timeout},
}, nil
}
// SendMarkdown 将钉钉 HTTP 状态和业务 errcode 都视为失败;业务层决定通知失败不能回滚审核事实。
func (c *Client) SendMarkdown(ctx context.Context, title string, text string) error {
if c == nil || c.httpClient == nil {
return errors.New("dingtalk client is not configured")
}
title, text = strings.TrimSpace(title), strings.TrimSpace(text)
if title == "" || text == "" {
return errors.New("dingtalk markdown title and text are required")
}
body, err := json.Marshal(map[string]any{
"msgtype": "markdown",
"markdown": map[string]string{"title": title, "text": text},
"at": map[string]any{"atMobiles": c.atMobiles, "isAtAll": c.atAll},
})
if err != nil {
return err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.signedWebhookURL(), bytes.NewReader(body))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json; charset=utf-8")
resp, err := c.httpClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
payload, err := io.ReadAll(io.LimitReader(resp.Body, 4096))
if err != nil {
return err
}
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
return fmt.Errorf("dingtalk returned http %d", resp.StatusCode)
}
var result struct {
ErrCode int `json:"errcode"`
ErrMsg string `json:"errmsg"`
}
if err := json.Unmarshal(payload, &result); err != nil {
return fmt.Errorf("decode dingtalk response: %w", err)
}
if result.ErrCode != 0 {
return fmt.Errorf("dingtalk returned errcode=%d errmsg=%s", result.ErrCode, result.ErrMsg)
}
return nil
}
func (c *Client) signedWebhookURL() string {
if c.secret == "" {
return c.webhookURL
}
parsed, err := url.Parse(c.webhookURL)
if err != nil {
return c.webhookURL
}
timestamp := time.Now().UTC().UnixMilli()
payload := fmt.Sprintf("%d\n%s", timestamp, c.secret)
mac := hmac.New(sha256.New, []byte(c.secret))
_, _ = mac.Write([]byte(payload))
query := parsed.Query()
query.Set("timestamp", fmt.Sprintf("%d", timestamp))
query.Set("sign", base64.StdEncoding.EncodeToString(mac.Sum(nil)))
parsed.RawQuery = query.Encode()
return parsed.String()
}
func compactStrings(values []string) []string {
out := make([]string, 0, len(values))
for _, value := range values {
if value = strings.TrimSpace(value); value != "" {
out = append(out, value)
}
}
return out
}

View File

@ -12,6 +12,7 @@ type HostAgencySalaryPolicyListOptions struct {
AppCode string AppCode string
Keyword string Keyword string
RegionID int64 RegionID int64
CycleKey string
Status string Status string
SettlementMode string SettlementMode string
SettlementTriggerMode string SettlementTriggerMode string
@ -26,6 +27,9 @@ func (s *Store) ListHostAgencySalaryPolicies(options HostAgencySalaryPolicyListO
// 第一阶段仍按单区域保存,区域筛选直接命中主表索引。 // 第一阶段仍按单区域保存,区域筛选直接命中主表索引。
db = db.Where("region_id = ?", options.RegionID) db = db.Where("region_id = ?", options.RegionID)
} }
if cycleKey := strings.TrimSpace(options.CycleKey); cycleKey != "" {
db = db.Where("cycle_key = ?", cycleKey)
}
if status := strings.TrimSpace(options.Status); status != "" { if status := strings.TrimSpace(options.Status); status != "" {
db = db.Where("status = ?", status) db = db.Where("status = ?", status)
} }
@ -50,7 +54,7 @@ func (s *Store) ListHostAgencySalaryPolicies(options HostAgencySalaryPolicyListO
// 等级必须随政策一起预加载并按 level_no 排序,前端展示和后续编辑都依赖稳定顺序。 // 等级必须随政策一起预加载并按 level_no 排序,前端展示和后续编辑都依赖稳定顺序。
err := db.Preload("Levels", func(tx *gorm.DB) *gorm.DB { err := db.Preload("Levels", func(tx *gorm.DB) *gorm.DB {
return tx.Order("level_no ASC") return tx.Order("level_no ASC")
}).Order("region_id ASC, effective_from_ms DESC, id DESC").Offset((page - 1) * pageSize).Limit(pageSize).Find(&items).Error }).Order("region_id ASC, cycle_key DESC, policy_version DESC, id DESC").Offset((page - 1) * pageSize).Limit(pageSize).Find(&items).Error
return items, total, err return items, total, err
} }
@ -71,6 +75,11 @@ func (s *Store) CreateHostAgencySalaryPolicy(item *model.HostAgencySalaryPolicy)
if err := tx.Create(item).Error; err != nil { if err := tx.Create(item).Error; err != nil {
return err return err
} }
// 主键在同一事务内作为不可变版本号;它全局唯一,也让旧数据可以无歧义回填 policy_version=id。
item.PolicyVersion = uint64(item.ID)
if err := tx.Model(item).UpdateColumn("policy_version", item.PolicyVersion).Error; err != nil {
return err
}
for index := range levels { for index := range levels {
levels[index].PolicyID = item.ID levels[index].PolicyID = item.ID
} }
@ -134,15 +143,10 @@ func (s *Store) DeleteHostAgencySalaryPolicy(appCode string, id uint) error {
}) })
} }
func (s *Store) HasOverlappingActiveHostAgencySalaryPolicy(appCode string, regionID int64, effectiveFromMS int64, effectiveToMS int64, excludeID uint) (bool, error) { func (s *Store) HasActiveHostAgencySalaryPolicyForCycle(appCode string, regionID int64, cycleKey string, excludeID uint) (bool, error) {
// 与当前政策开始时间相交:已有长期有效或已有结束时间晚于当前开始 // 工资政策以周期为唯一选择边界;不同月份允许并存,同一区域同一月份只能有一条 active 配置
db := s.db.Model(&model.HostAgencySalaryPolicy{}). db := s.db.Model(&model.HostAgencySalaryPolicy{}).
Where("app_code = ? AND region_id = ? AND status = ?", strings.TrimSpace(appCode), regionID, "active"). Where("app_code = ? AND region_id = ? AND cycle_key = ? AND status = ?", strings.TrimSpace(appCode), regionID, strings.TrimSpace(cycleKey), "active")
Where("(effective_to_ms = 0 OR effective_to_ms > ?)", effectiveFromMS)
if effectiveToMS > 0 {
// 当前政策不是长期有效时,再要求已有政策开始时间早于当前结束,形成标准半开区间相交判断。
db = db.Where("effective_from_ms < ?", effectiveToMS)
}
if excludeID > 0 { if excludeID > 0 {
db = db.Where("id <> ?", excludeID) db = db.Where("id <> ?", excludeID)
} }

View File

@ -381,7 +381,8 @@ func (s *Store) seedMenus() error {
{ParentID: &paymentID, Title: "三方临时支付链接", Code: "payment-temporary-links", Path: "/payment/temporary-links", Icon: "receipt", PermissionCode: "payment-temporary-link:view", Sort: 70, Visible: true}, {ParentID: &paymentID, Title: "三方临时支付链接", Code: "payment-temporary-links", Path: "/payment/temporary-links", Icon: "receipt", PermissionCode: "payment-temporary-link:view", Sort: 70, Visible: true},
{ParentID: &paymentID, Title: "支付内购商品", Code: "payment-recharge-products", Path: "/payment/recharge-products", Icon: "wallet", PermissionCode: "payment-product:view", Sort: 71, Visible: true}, {ParentID: &paymentID, Title: "支付内购商品", Code: "payment-recharge-products", Path: "/payment/recharge-products", Icon: "wallet", PermissionCode: "payment-product:view", Sort: 71, Visible: true},
{ParentID: &paymentID, Title: "币商充值订单", Code: "finance-coin-seller-recharge-orders", Path: "/finance/orders/coin-seller-recharges", Icon: "receipt", PermissionCode: "finance-order:coin-seller-recharge:view", Sort: 72, Visible: true}, {ParentID: &paymentID, Title: "币商充值订单", Code: "finance-coin-seller-recharge-orders", Path: "/finance/orders/coin-seller-recharges", Icon: "receipt", PermissionCode: "finance-order:coin-seller-recharge:view", Sort: 72, Visible: true},
{ParentID: &activityID, Title: "活动模版", Code: "activity-template", Path: "/activities/templates", Icon: "campaign", PermissionCode: "activity-template:view", Sort: 68, Visible: true}, // 活动模版权限继续用于 ops-center 的按钮和接口鉴权;主后台旧二级菜单只保留不可见兼容记录。
{ParentID: &activityID, Title: "活动模版", Code: "activity-template", Path: "/activities/templates", Icon: "campaign", PermissionCode: "activity-template:view", Sort: 68, Visible: false},
{ParentID: &activityID, Title: "每日任务", Code: "daily-task-list", Path: "/activities/daily-tasks", Icon: "task", PermissionCode: "daily-task:view", Sort: 69, Visible: true}, {ParentID: &activityID, Title: "每日任务", Code: "daily-task-list", Path: "/activities/daily-tasks", Icon: "task", PermissionCode: "daily-task:view", Sort: 69, Visible: true},
{ParentID: &activityID, Title: "注册奖励", Code: "registration-reward", Path: "/activities/registration-reward", Icon: "gift", PermissionCode: "registration-reward:view", Sort: 70, Visible: true}, {ParentID: &activityID, Title: "注册奖励", Code: "registration-reward", Path: "/activities/registration-reward", Icon: "gift", PermissionCode: "registration-reward:view", Sort: 70, Visible: true},
{ParentID: &activityID, Title: "成就配置", Code: "achievement-config", Path: "/activities/achievements", Icon: "military_tech", PermissionCode: "achievement:view", Sort: 71, Visible: true}, {ParentID: &activityID, Title: "成就配置", Code: "achievement-config", Path: "/activities/achievements", Icon: "military_tech", PermissionCode: "achievement:view", Sort: 71, Visible: true},

View File

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

View File

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

View File

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

View File

@ -0,0 +1,11 @@
SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci;
-- 活动模版工作台迁入 ops-center权限和旧路径仍保留用于接口鉴权及历史链接兼容。
-- admin_menus.code 是唯一索引,本次更新只命中一行,不扫描菜单表,也不改角色权限关系。
SET @now_ms = CAST(UNIX_TIMESTAMP(UTC_TIMESTAMP(3)) * 1000 AS UNSIGNED);
UPDATE admin_menus
SET visible = FALSE,
updated_at_ms = @now_ms
WHERE code = 'activity-template'
AND visible = TRUE;

View File

@ -0,0 +1,53 @@
SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci;
SET time_zone = '+00:00';
-- 工资政策是小型配置表;迁移只回填政策行并创建周期索引,不扫描用户、工资进度或钱包流水。
SET @ddl := IF(
(SELECT COUNT(*) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'admin_host_agency_salary_policies' AND COLUMN_NAME = 'cycle_key') = 0,
'ALTER TABLE admin_host_agency_salary_policies ADD COLUMN cycle_key VARCHAR(7) NOT NULL DEFAULT '''' COMMENT ''政策归属工资周期UTC yyyy-MM'' AFTER region_id, ALGORITHM=INPLACE, LOCK=NONE',
'SELECT 1'
);
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @ddl := IF(
(SELECT COUNT(*) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'admin_host_agency_salary_policies' AND COLUMN_NAME = 'policy_version') = 0,
'ALTER TABLE admin_host_agency_salary_policies ADD COLUMN policy_version BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT ''不可变政策版本,等于政策 ID'' AFTER cycle_key, ALGORITHM=INPLACE, LOCK=NONE',
'SELECT 1'
);
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
-- 老记录优先按原有效期起点归档;没有起点时依次使用发布时间、创建时间,全部按 UTC 计算。
UPDATE admin_host_agency_salary_policies
SET cycle_key = DATE_FORMAT(FROM_UNIXTIME(
COALESCE(NULLIF(effective_from_ms, 0), NULLIF(published_at_ms, 0), created_at_ms) / 1000
), '%Y-%m'),
policy_version = id
WHERE cycle_key = '' OR policy_version = 0;
SET @ddl := IF(
(SELECT COUNT(*) FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'admin_host_agency_salary_policies'
AND INDEX_NAME = 'uk_admin_host_agency_salary_policy_version') = 0,
'ALTER TABLE admin_host_agency_salary_policies ADD UNIQUE INDEX uk_admin_host_agency_salary_policy_version (app_code, region_id, cycle_key, policy_version), ALGORITHM=INPLACE, LOCK=NONE',
'SELECT 1'
);
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @ddl := IF(
(SELECT COUNT(*) FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'admin_host_agency_salary_policies'
AND INDEX_NAME = 'idx_admin_host_agency_salary_policy_cycle') = 0,
'ALTER TABLE admin_host_agency_salary_policies ADD INDEX idx_admin_host_agency_salary_policy_cycle (app_code, region_id, cycle_key, status), ALGORITHM=INPLACE, LOCK=NONE',
'SELECT 1'
);
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;

View File

@ -647,7 +647,7 @@ func (r *Repository) PrepareActivityTemplateTaskClaim(ctx context.Context, runti
app_code, claim_id, command_id, template_id, template_code, version_no, task_day, task_key, user_id, app_code, claim_id, command_id, template_id, template_code, version_no, task_day, task_key, user_id,
earning_region_id, attribution_at_ms, reward_resource_group_id, reward_snapshot_id, reward_snapshot_json, wallet_command_id, earning_region_id, attribution_at_ms, reward_resource_group_id, reward_snapshot_id, reward_snapshot_json, wallet_command_id,
status, attempt_count, created_at_ms, updated_at_ms status, attempt_count, created_at_ms, updated_at_ms
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CAST(? AS JSON), ?, ?, ?, ?, ?)`, ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CAST(? AS JSON), ?, ?, ?, ?, ?)`,
claim.AppCode, claim.ClaimID, claim.CommandID, claim.TemplateID, claim.TemplateCode, claim.VersionNo, claim.AppCode, claim.ClaimID, claim.CommandID, claim.TemplateID, claim.TemplateCode, claim.VersionNo,
claim.TaskDay, claim.TaskKey, claim.UserID, claim.EarningRegionID, claim.AttributionAtMS, claim.TaskDay, claim.TaskKey, claim.UserID, claim.EarningRegionID, claim.AttributionAtMS,
claim.RewardResourceGroupID, claim.RewardSnapshot.SnapshotID, claim.RewardResourceGroupID, claim.RewardSnapshot.SnapshotID,

View File

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

View File

@ -26,15 +26,16 @@ func (n *dingTalkNotifier) NotifyApplicationCreated(ctx context.Context, applica
return nil return nil
} }
return n.client.SendMarkdown(ctx, dingtalkrobot.MarkdownMessage{ return n.client.SendMarkdown(ctx, dingtalkrobot.MarkdownMessage{
Title: "用户提现运营审核待处理", Title: "用户提现运营审核待处理【运营审核阶段】",
Text: withdrawalApplicationCreatedMarkdown(application), Text: withdrawalApplicationCreatedMarkdown(application),
}) })
} }
func withdrawalApplicationCreatedMarkdown(application Application) string { func withdrawalApplicationCreatedMarkdown(application Application) string {
return strings.Join([]string{ return strings.Join([]string{
"### 用户提现运营审核待处理", "### 用户提现运营审核待处理【运营审核阶段】",
"", "",
"- 当前阶段:**运营审核阶段**",
fmt.Sprintf("- 申请ID%d", application.ID), fmt.Sprintf("- 申请ID%d", application.ID),
fmt.Sprintf("- APP%s", markdownValue(application.AppCode)), fmt.Sprintf("- APP%s", markdownValue(application.AppCode)),
fmt.Sprintf("- 用户ID%d", application.UserID), fmt.Sprintf("- 用户ID%d", application.UserID),

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -392,6 +392,7 @@ type fakeRoomQueryClient struct {
lastFeeds *roomv1.ListRoomFeedsRequest lastFeeds *roomv1.ListRoomFeedsRequest
lastMyRoom *roomv1.GetMyRoomRequest lastMyRoom *roomv1.GetMyRoomRequest
lastCurrent *roomv1.GetCurrentRoomRequest lastCurrent *roomv1.GetCurrentRoomRequest
lastVoicePresence *roomv1.BatchGetUserVoiceRoomPresencesRequest
lastSnapshot *roomv1.GetRoomSnapshotRequest lastSnapshot *roomv1.GetRoomSnapshotRequest
lastBackgrounds *roomv1.ListRoomBackgroundsRequest lastBackgrounds *roomv1.ListRoomBackgroundsRequest
lastRocket *roomv1.GetRoomRocketRequest lastRocket *roomv1.GetRoomRocketRequest
@ -403,6 +404,7 @@ type fakeRoomQueryClient struct {
feedsResp *roomv1.ListRoomsResponse feedsResp *roomv1.ListRoomsResponse
myRoomResp *roomv1.GetMyRoomResponse myRoomResp *roomv1.GetMyRoomResponse
currentResp *roomv1.GetCurrentRoomResponse currentResp *roomv1.GetCurrentRoomResponse
voicePresenceResp *roomv1.BatchGetUserVoiceRoomPresencesResponse
snapshotResp *roomv1.GetRoomSnapshotResponse snapshotResp *roomv1.GetRoomSnapshotResponse
backgroundsResp *roomv1.ListRoomBackgroundsResponse backgroundsResp *roomv1.ListRoomBackgroundsResponse
rocketResp *roomv1.GetRoomRocketResponse rocketResp *roomv1.GetRoomRocketResponse
@ -414,6 +416,7 @@ type fakeRoomQueryClient struct {
feedsErr error feedsErr error
myRoomErr error myRoomErr error
currentErr error currentErr error
voicePresenceErr error
snapshotErr error snapshotErr error
backgroundsErr error backgroundsErr error
rocketErr error rocketErr error
@ -1390,6 +1393,17 @@ func (f *fakeRoomQueryClient) GetCurrentRoom(_ context.Context, req *roomv1.GetC
return &roomv1.GetCurrentRoomResponse{}, nil return &roomv1.GetCurrentRoomResponse{}, nil
} }
func (f *fakeRoomQueryClient) BatchGetUserVoiceRoomPresences(_ context.Context, req *roomv1.BatchGetUserVoiceRoomPresencesRequest) (*roomv1.BatchGetUserVoiceRoomPresencesResponse, error) {
f.lastVoicePresence = req
if f.voicePresenceErr != nil {
return nil, f.voicePresenceErr
}
if f.voicePresenceResp != nil {
return f.voicePresenceResp, nil
}
return &roomv1.BatchGetUserVoiceRoomPresencesResponse{}, nil
}
func (f *fakeRoomQueryClient) GetRoomSnapshot(_ context.Context, req *roomv1.GetRoomSnapshotRequest) (*roomv1.GetRoomSnapshotResponse, error) { func (f *fakeRoomQueryClient) GetRoomSnapshot(_ context.Context, req *roomv1.GetRoomSnapshotRequest) (*roomv1.GetRoomSnapshotResponse, error) {
f.lastSnapshot = req f.lastSnapshot = req
if f.snapshotErr != nil { if f.snapshotErr != nil {
@ -4811,6 +4825,87 @@ func TestGetCurrentRoomUsesAuthenticatedUser(t *testing.T) {
} }
} }
func TestBatchUserVoiceRoomPresencesUsesAuthenticatedViewerAndStableJSON(t *testing.T) {
queryClient := &fakeRoomQueryClient{voicePresenceResp: &roomv1.BatchGetUserVoiceRoomPresencesResponse{
Items: []*roomv1.UserVoiceRoomPresence{
{
UserId: 1002,
State: "on_mic",
SeatNo: 3,
Joinable: true,
ObservedAtMs: 1_784_265_600_000,
Room: &roomv1.UserVoiceRoomPresenceRoom{
RoomId: "room-profile-live",
RoomShortId: "886688",
OwnerUserId: 1001,
HostUserId: 1001,
Title: "Live Room",
CoverUrl: "https://cdn.example.com/room.png",
BackgroundUrl: "https://cdn.example.com/background.png",
Mode: "voice",
OnlineCount: 56,
SeatCount: 10,
Locked: true,
},
},
{UserId: 1003, State: "not_on_mic", ObservedAtMs: 1_784_265_600_000},
},
ServerTimeMs: 1_784_265_600_123,
}}
handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{})
handler.SetRoomQueryClient(queryClient)
router := handler.Routes(auth.NewVerifier("secret"))
request := httptest.NewRequest(http.MethodGet, "/api/v1/users/voice-room-presence:batch?user_ids=1002,1003,1002", nil)
request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
request.Header.Set("X-Request-ID", "req-profile-live")
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK {
t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String())
}
if queryClient.lastVoicePresence == nil || queryClient.lastVoicePresence.GetViewerUserId() != 42 {
t.Fatalf("voice presence query must use authenticated viewer: %+v", queryClient.lastVoicePresence)
}
if got := queryClient.lastVoicePresence.GetUserIds(); len(got) != 2 || got[0] != 1002 || got[1] != 1003 {
t.Fatalf("voice presence query must preserve first-seen deduplicated ids: %v", got)
}
if queryClient.lastVoicePresence.GetMeta().GetRequestId() != assertGeneratedRequestID(t, recorder, "req-profile-live") {
t.Fatalf("voice presence query must propagate generated request id: %+v", queryClient.lastVoicePresence.GetMeta())
}
var response httpkit.ResponseEnvelope
if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil {
t.Fatalf("decode voice presence response failed: %v", err)
}
data := response.Data.(map[string]any)
items := data["items"].([]any)
onMic := items[0].(map[string]any)
room := onMic["room"].(map[string]any)
if onMic["user_id"] != "1002" || onMic["state"] != "on_mic" || onMic["joinable"] != true || room["room_id"] != "room-profile-live" || room["rtc_room_id"] != "room-profile-live" || room["locked"] != true {
t.Fatalf("on-mic response contract mismatch: %+v", onMic)
}
notOnMic := items[1].(map[string]any)
if notOnMic["state"] != "not_on_mic" || notOnMic["joinable"] != false || notOnMic["room"] != nil {
t.Fatalf("not-on-mic response must keep false and null fields: %+v", notOnMic)
}
}
func TestBatchUserVoiceRoomPresencesRejectsInvalidIDs(t *testing.T) {
handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{})
handler.SetRoomQueryClient(&fakeRoomQueryClient{})
router := handler.Routes(auth.NewVerifier("secret"))
request := httptest.NewRequest(http.MethodGet, "/api/v1/users/voice-room-presence:batch?user_ids=1002,,1003", nil)
request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
request.Header.Set("X-Request-ID", "req-profile-live-invalid")
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request)
assertEnvelope(t, recorder, http.StatusBadRequest, "INVALID_USER_IDS", "req-profile-live-invalid")
}
func TestGetRoomSnapshotUsesAuthenticatedUser(t *testing.T) { func TestGetRoomSnapshotUsesAuthenticatedUser(t *testing.T) {
queryClient := &fakeRoomQueryClient{snapshotResp: &roomv1.GetRoomSnapshotResponse{ queryClient := &fakeRoomQueryClient{snapshotResp: &roomv1.GetRoomSnapshotResponse{
Room: &roomv1.RoomSnapshot{ Room: &roomv1.RoomSnapshot{
@ -6667,6 +6762,7 @@ func TestUpdateAgencyProfileForwardsOwnerCommand(t *testing.T) {
} }
func TestHostCenterPlatformPolicyUsesCurrentHostRegion(t *testing.T) { func TestHostCenterPlatformPolicyUsesCurrentHostRegion(t *testing.T) {
expectedCycle := time.Now().UTC().Format("2006-01")
hostClient := &fakeUserHostClient{hostProfile: &userv1.HostProfile{ hostClient := &fakeUserHostClient{hostProfile: &userv1.HostProfile{
UserId: 42, UserId: 42,
Status: "active", Status: "active",
@ -6678,6 +6774,8 @@ func TestHostCenterPlatformPolicyUsesCurrentHostRegion(t *testing.T) {
Found: true, Found: true,
Policy: &walletv1.HostSalaryPolicy{ Policy: &walletv1.HostSalaryPolicy{
PolicyId: 9001, PolicyId: 9001,
CycleKey: expectedCycle,
PolicyVersion: 9001,
Name: "Host Growth Policy", Name: "Host Growth Policy",
RegionId: 30, RegionId: 30,
Status: "active", Status: "active",
@ -6694,7 +6792,7 @@ func TestHostCenterPlatformPolicyUsesCurrentHostRegion(t *testing.T) {
}, },
hostSalaryProgressResp: &walletv1.GetHostSalaryProgressResponse{Progress: &walletv1.HostSalaryProgress{ hostSalaryProgressResp: &walletv1.GetHostSalaryProgressResponse{Progress: &walletv1.HostSalaryProgress{
HostUserId: 42, HostUserId: 42,
CycleKey: "2026-06", CycleKey: expectedCycle,
RegionId: 30, RegionId: 30,
AgencyOwnerUserId: 99, AgencyOwnerUserId: 99,
TotalDiamonds: 1200, TotalDiamonds: 1200,
@ -6718,10 +6816,10 @@ func TestHostCenterPlatformPolicyUsesCurrentHostRegion(t *testing.T) {
if hostClient.lastHost == nil || hostClient.lastHost.GetUserId() != 42 { if hostClient.lastHost == nil || hostClient.lastHost.GetUserId() != 42 {
t.Fatalf("host profile request mismatch: %+v", hostClient.lastHost) t.Fatalf("host profile request mismatch: %+v", hostClient.lastHost)
} }
if walletClient.lastHostSalaryPolicy == nil || walletClient.lastHostSalaryPolicy.GetRegionId() != 30 || walletClient.lastHostSalaryPolicy.GetSettlementTriggerMode() != "" || walletClient.lastHostSalaryPolicy.GetAppCode() == "" || walletClient.lastHostSalaryPolicy.GetRequestId() == "" { if walletClient.lastHostSalaryPolicy == nil || walletClient.lastHostSalaryPolicy.GetRegionId() != 30 || walletClient.lastHostSalaryPolicy.GetCycleKey() != expectedCycle || walletClient.lastHostSalaryPolicy.GetSettlementTriggerMode() != "" || walletClient.lastHostSalaryPolicy.GetAppCode() == "" || walletClient.lastHostSalaryPolicy.GetRequestId() == "" {
t.Fatalf("host salary policy request mismatch: %+v", walletClient.lastHostSalaryPolicy) t.Fatalf("host salary policy request mismatch: %+v", walletClient.lastHostSalaryPolicy)
} }
if walletClient.lastHostSalaryProgress == nil || walletClient.lastHostSalaryProgress.GetHostUserId() != 42 || walletClient.lastHostSalaryProgress.GetAppCode() == "" || walletClient.lastHostSalaryProgress.GetRequestId() == "" { if walletClient.lastHostSalaryProgress == nil || walletClient.lastHostSalaryProgress.GetHostUserId() != 42 || walletClient.lastHostSalaryProgress.GetCycleKey() != expectedCycle || walletClient.lastHostSalaryProgress.GetAppCode() == "" || walletClient.lastHostSalaryProgress.GetRequestId() == "" {
t.Fatalf("host salary progress request mismatch: %+v", walletClient.lastHostSalaryProgress) t.Fatalf("host salary progress request mismatch: %+v", walletClient.lastHostSalaryProgress)
} }
var response httpkit.ResponseEnvelope var response httpkit.ResponseEnvelope
@ -6734,10 +6832,10 @@ func TestHostCenterPlatformPolicyUsesCurrentHostRegion(t *testing.T) {
firstLevel := levels[0].(map[string]any) firstLevel := levels[0].(map[string]any)
progress := data["progress"].(map[string]any) progress := data["progress"].(map[string]any)
levelProgress := data["level_progress"].(map[string]any) levelProgress := data["level_progress"].(map[string]any)
if data["found"] != true || data["host_region_id"] != float64(30) || policy["policy_id"] != "9001" || policy["settlement_mode"] != "half_month" || policy["settlement_trigger_mode"] != "manual" || firstLevel["host_salary_usd"] != 15.0 || firstLevel["agency_salary_usd"] != 3.0 { if data["found"] != true || data["host_region_id"] != float64(30) || policy["policy_id"] != "9001" || policy["cycle_key"] != expectedCycle || policy["policy_version"] != float64(9001) || policy["settlement_mode"] != "half_month" || policy["settlement_trigger_mode"] != "manual" || firstLevel["host_salary_usd"] != 15.0 || firstLevel["agency_salary_usd"] != 3.0 {
t.Fatalf("host policy response mismatch: %+v", data) t.Fatalf("host policy response mismatch: %+v", data)
} }
if progress["cycle_key"] != "2026-06" || progress["total_diamonds"] != float64(1200) { if progress["cycle_key"] != expectedCycle || progress["total_diamonds"] != float64(1200) {
t.Fatalf("host salary progress response mismatch: %+v", progress) t.Fatalf("host salary progress response mismatch: %+v", progress)
} }
if levelProgress["current_level"] != float64(1) || levelProgress["current_value"] != float64(1200) || levelProgress["next_level"] != float64(2) || levelProgress["needed_for_next_level"] != float64(1800) { if levelProgress["current_level"] != float64(1) || levelProgress["current_value"] != float64(1200) || levelProgress["next_level"] != float64(2) || levelProgress["needed_for_next_level"] != float64(1800) {
@ -6832,6 +6930,7 @@ func TestAgencyCenterOverviewAllowsOwnerWithoutManagerProfile(t *testing.T) {
} }
func TestAgencyCenterPlatformPolicyUsesAgencyRegion(t *testing.T) { func TestAgencyCenterPlatformPolicyUsesAgencyRegion(t *testing.T) {
expectedCycle := time.Now().UTC().Format("2006-01")
hostClient := &fakeUserHostClient{ hostClient := &fakeUserHostClient{
roleSummary: &userv1.UserRoleSummary{UserId: 42, IsAgency: true, IsManager: false, AgencyId: 7001}, roleSummary: &userv1.UserRoleSummary{UserId: 42, IsAgency: true, IsManager: false, AgencyId: 7001},
getAgencyResp: &userv1.GetAgencyResponse{Agency: &userv1.Agency{ getAgencyResp: &userv1.GetAgencyResponse{Agency: &userv1.Agency{
@ -6847,6 +6946,8 @@ func TestAgencyCenterPlatformPolicyUsesAgencyRegion(t *testing.T) {
Found: true, Found: true,
Policy: &walletv1.HostSalaryPolicy{ Policy: &walletv1.HostSalaryPolicy{
PolicyId: 9101, PolicyId: 9101,
CycleKey: expectedCycle,
PolicyVersion: 9101,
Name: "Agency Region Policy", Name: "Agency Region Policy",
RegionId: 31, RegionId: 31,
Status: "active", Status: "active",
@ -6878,7 +6979,7 @@ func TestAgencyCenterPlatformPolicyUsesAgencyRegion(t *testing.T) {
if hostClient.lastRoleSummary == nil || hostClient.lastRoleSummary.GetUserId() != 42 || hostClient.lastGetAgency == nil || hostClient.lastGetAgency.GetAgencyId() != 7001 { if hostClient.lastRoleSummary == nil || hostClient.lastRoleSummary.GetUserId() != 42 || hostClient.lastGetAgency == nil || hostClient.lastGetAgency.GetAgencyId() != 7001 {
t.Fatalf("agency owner resolution mismatch: role=%+v agency=%+v", hostClient.lastRoleSummary, hostClient.lastGetAgency) t.Fatalf("agency owner resolution mismatch: role=%+v agency=%+v", hostClient.lastRoleSummary, hostClient.lastGetAgency)
} }
if walletClient.lastHostSalaryPolicy == nil || walletClient.lastHostSalaryPolicy.GetRegionId() != 31 || walletClient.lastHostSalaryPolicy.GetSettlementTriggerMode() != "" || walletClient.lastHostSalaryPolicy.GetAppCode() == "" || walletClient.lastHostSalaryPolicy.GetRequestId() == "" { if walletClient.lastHostSalaryPolicy == nil || walletClient.lastHostSalaryPolicy.GetRegionId() != 31 || walletClient.lastHostSalaryPolicy.GetCycleKey() != expectedCycle || walletClient.lastHostSalaryPolicy.GetSettlementTriggerMode() != "" || walletClient.lastHostSalaryPolicy.GetAppCode() == "" || walletClient.lastHostSalaryPolicy.GetRequestId() == "" {
t.Fatalf("agency policy request mismatch: %+v", walletClient.lastHostSalaryPolicy) t.Fatalf("agency policy request mismatch: %+v", walletClient.lastHostSalaryPolicy)
} }
if walletClient.lastHostSalaryProgress != nil { if walletClient.lastHostSalaryProgress != nil {
@ -6892,7 +6993,7 @@ func TestAgencyCenterPlatformPolicyUsesAgencyRegion(t *testing.T) {
policy := data["policy"].(map[string]any) policy := data["policy"].(map[string]any)
levels := policy["levels"].([]any) levels := policy["levels"].([]any)
secondLevel := levels[1].(map[string]any) secondLevel := levels[1].(map[string]any)
if data["found"] != true || data["agency_region_id"] != float64(31) || policy["policy_id"] != "9101" || len(levels) != 2 || secondLevel["agency_salary_usd"] != 7.2 { if data["found"] != true || data["agency_region_id"] != float64(31) || policy["policy_id"] != "9101" || policy["cycle_key"] != expectedCycle || policy["policy_version"] != float64(9101) || len(levels) != 2 || secondLevel["agency_salary_usd"] != 7.2 {
t.Fatalf("agency policy response mismatch: %+v", data) t.Fatalf("agency policy response mismatch: %+v", data)
} }
} }
@ -11029,6 +11130,7 @@ func TestProfileGateRejectsIncompleteUsersForRoomIMRTCPaidCapabilities(t *testin
{name: "message_delete", method: http.MethodDelete, path: "/api/v1/messages/msg-1"}, {name: "message_delete", method: http.MethodDelete, path: "/api/v1/messages/msg-1"},
{name: "direct_gift", method: http.MethodPost, path: "/api/v1/messages/direct-gifts/send", body: `{"command_id":"cmd-direct-gift","target_user_id":900002,"gift_id":"rose","gift_count":1}`}, {name: "direct_gift", method: http.MethodPost, path: "/api/v1/messages/direct-gifts/send", body: `{"command_id":"cmd-direct-gift","target_user_id":900002,"gift_id":"rose","gift_count":1}`},
{name: "profiles_batch", method: http.MethodGet, path: "/api/v1/users/profiles:batch?user_ids=42"}, {name: "profiles_batch", method: http.MethodGet, path: "/api/v1/users/profiles:batch?user_ids=42"},
{name: "voice_room_presence_batch", method: http.MethodGet, path: "/api/v1/users/voice-room-presence:batch?user_ids=43"},
} }
for _, test := range tests { for _, test := range tests {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -126,6 +126,7 @@ func (h *Handler) getAgencyCenterPlatformPolicy(writer http.ResponseWriter, requ
AppCode: appcode.FromContext(request.Context()), AppCode: appcode.FromContext(request.Context()),
RegionId: agency.GetRegionId(), RegionId: agency.GetRegionId(),
SettlementTriggerMode: "", SettlementTriggerMode: "",
CycleKey: time.Now().UTC().Format("2006-01"),
}) })
if err != nil { if err != nil {
httpkit.WriteRPCError(writer, request, err) httpkit.WriteRPCError(writer, request, err)

View File

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

View File

@ -3,6 +3,7 @@ package userapi
import ( import (
"net/http" "net/http"
"strconv" "strconv"
"time"
userv1 "hyapp.local/api/proto/user/v1" userv1 "hyapp.local/api/proto/user/v1"
walletv1 "hyapp.local/api/proto/wallet/v1" walletv1 "hyapp.local/api/proto/wallet/v1"
@ -15,6 +16,8 @@ const hostProfileStatusActive = "active"
type hostCenterPolicyData struct { type hostCenterPolicyData struct {
PolicyID string `json:"policy_id"` PolicyID string `json:"policy_id"`
CycleKey string `json:"cycle_key"`
PolicyVersion uint64 `json:"policy_version"`
Name string `json:"name"` Name string `json:"name"`
RegionID int64 `json:"region_id"` RegionID int64 `json:"region_id"`
Status string `json:"status"` Status string `json:"status"`
@ -68,11 +71,13 @@ func (h *Handler) getHostCenterPlatformPolicy(writer http.ResponseWriter, reques
return return
} }
// H5 不传 region、policy id 或 triggergateway 用当前主播身份区域读取 active 政策,让 automatic/manual 都能展示。 cycleKey := time.Now().UTC().Format("2006-01")
// 政策和进度共用同一个 UTC 周期锚点,避免月界瞬间两个调用读取到不同月份。
resp, err := h.walletClient.GetActiveHostSalaryPolicy(request.Context(), &walletv1.GetActiveHostSalaryPolicyRequest{ resp, err := h.walletClient.GetActiveHostSalaryPolicy(request.Context(), &walletv1.GetActiveHostSalaryPolicyRequest{
RequestId: httpkit.RequestIDFromContext(request.Context()), RequestId: httpkit.RequestIDFromContext(request.Context()),
AppCode: appcode.FromContext(request.Context()), AppCode: appcode.FromContext(request.Context()),
RegionId: profile.GetRegionId(), RegionId: profile.GetRegionId(),
CycleKey: cycleKey,
}) })
if err != nil { if err != nil {
httpkit.WriteRPCError(writer, request, err) httpkit.WriteRPCError(writer, request, err)
@ -84,6 +89,7 @@ func (h *Handler) getHostCenterPlatformPolicy(writer http.ResponseWriter, reques
RequestId: httpkit.RequestIDFromContext(request.Context()), RequestId: httpkit.RequestIDFromContext(request.Context()),
AppCode: appcode.FromContext(request.Context()), AppCode: appcode.FromContext(request.Context()),
HostUserId: profile.GetUserId(), HostUserId: profile.GetUserId(),
CycleKey: cycleKey,
}) })
if err != nil { if err != nil {
httpkit.WriteRPCError(writer, request, err) httpkit.WriteRPCError(writer, request, err)
@ -132,6 +138,8 @@ func hostCenterPolicyFromProto(policy *walletv1.HostSalaryPolicy) *hostCenterPol
} }
return &hostCenterPolicyData{ return &hostCenterPolicyData{
PolicyID: strconv.FormatUint(policy.GetPolicyId(), 10), PolicyID: strconv.FormatUint(policy.GetPolicyId(), 10),
CycleKey: policy.GetCycleKey(),
PolicyVersion: policy.GetPolicyVersion(),
Name: policy.GetName(), Name: policy.GetName(),
RegionID: policy.GetRegionId(), RegionID: policy.GetRegionId(),
Status: policy.GetStatus(), Status: policy.GetStatus(),

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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