vip和幸运礼物
This commit is contained in:
parent
de9c1e9eef
commit
17f2788dcf
File diff suppressed because it is too large
Load Diff
@ -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
@ -247,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;
|
||||||
@ -444,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);
|
||||||
|
|||||||
@ -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
@ -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_id、meta.command_id、meta.actor_user_id、mode、room_name 和 room_avatar。
|
// 必填语义:meta.room_id、meta.command_id、meta.actor_user_id、mode、room_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);
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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" +
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -9665,6 +9665,8 @@ type EquipUserResourceRequest struct {
|
|||||||
UserId int64 `protobuf:"varint,3,opt,name=user_id,json=userId,proto3" json:"user_id,omitempty"`
|
UserId int64 `protobuf:"varint,3,opt,name=user_id,json=userId,proto3" json:"user_id,omitempty"`
|
||||||
ResourceId int64 `protobuf:"varint,4,opt,name=resource_id,json=resourceId,proto3" json:"resource_id,omitempty"`
|
ResourceId int64 `protobuf:"varint,4,opt,name=resource_id,json=resourceId,proto3" json:"resource_id,omitempty"`
|
||||||
EntitlementId string `protobuf:"bytes,5,opt,name=entitlement_id,json=entitlementId,proto3" json:"entitlement_id,omitempty"`
|
EntitlementId string `protobuf:"bytes,5,opt,name=entitlement_id,json=entitlementId,proto3" json:"entitlement_id,omitempty"`
|
||||||
|
// command_id 是装备状态变更的业务幂等键;request_id 只用于链路追踪。
|
||||||
|
CommandId string `protobuf:"bytes,6,opt,name=command_id,json=commandId,proto3" json:"command_id,omitempty"`
|
||||||
unknownFields protoimpl.UnknownFields
|
unknownFields protoimpl.UnknownFields
|
||||||
sizeCache protoimpl.SizeCache
|
sizeCache protoimpl.SizeCache
|
||||||
}
|
}
|
||||||
@ -9734,6 +9736,13 @@ func (x *EquipUserResourceRequest) GetEntitlementId() string {
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (x *EquipUserResourceRequest) GetCommandId() string {
|
||||||
|
if x != nil {
|
||||||
|
return x.CommandId
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
type EquipUserResourceResponse struct {
|
type EquipUserResourceResponse struct {
|
||||||
state protoimpl.MessageState `protogen:"open.v1"`
|
state protoimpl.MessageState `protogen:"open.v1"`
|
||||||
Resource *UserResourceEntitlement `protobuf:"bytes,1,opt,name=resource,proto3" json:"resource,omitempty"`
|
Resource *UserResourceEntitlement `protobuf:"bytes,1,opt,name=resource,proto3" json:"resource,omitempty"`
|
||||||
@ -17461,8 +17470,11 @@ type VipBenefit struct {
|
|||||||
CreatedAtMs int64 `protobuf:"varint,13,opt,name=created_at_ms,json=createdAtMs,proto3" json:"created_at_ms,omitempty"`
|
CreatedAtMs int64 `protobuf:"varint,13,opt,name=created_at_ms,json=createdAtMs,proto3" json:"created_at_ms,omitempty"`
|
||||||
UpdatedAtMs int64 `protobuf:"varint,14,opt,name=updated_at_ms,json=updatedAtMs,proto3" json:"updated_at_ms,omitempty"`
|
UpdatedAtMs int64 `protobuf:"varint,14,opt,name=updated_at_ms,json=updatedAtMs,proto3" json:"updated_at_ms,omitempty"`
|
||||||
Presentation *VipBenefitPresentation `protobuf:"bytes,15,opt,name=presentation,proto3" json:"presentation,omitempty"`
|
Presentation *VipBenefitPresentation `protobuf:"bytes,15,opt,name=presentation,proto3" json:"presentation,omitempty"`
|
||||||
unknownFields protoimpl.UnknownFields
|
// resource 仅在 resource_id 指向同 App、active 且 resource_type 匹配的真实资源时返回。
|
||||||
sizeCache protoimpl.SizeCache
|
// 运营尚未配置素材的权益保留 resource_id/resource_type 事实,但该对象为空,客户端不得猜 URL。
|
||||||
|
Resource *Resource `protobuf:"bytes,16,opt,name=resource,proto3" json:"resource,omitempty"`
|
||||||
|
unknownFields protoimpl.UnknownFields
|
||||||
|
sizeCache protoimpl.SizeCache
|
||||||
}
|
}
|
||||||
|
|
||||||
func (x *VipBenefit) Reset() {
|
func (x *VipBenefit) Reset() {
|
||||||
@ -17600,6 +17612,13 @@ func (x *VipBenefit) GetPresentation() *VipBenefitPresentation {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (x *VipBenefit) GetResource() *Resource {
|
||||||
|
if x != nil {
|
||||||
|
return x.Resource
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
type VipLevel struct {
|
type VipLevel struct {
|
||||||
state protoimpl.MessageState `protogen:"open.v1"`
|
state protoimpl.MessageState `protogen:"open.v1"`
|
||||||
Level int32 `protobuf:"varint,1,opt,name=level,proto3" json:"level,omitempty"`
|
Level int32 `protobuf:"varint,1,opt,name=level,proto3" json:"level,omitempty"`
|
||||||
@ -20856,8 +20875,12 @@ type CheckVipBenefitResponse struct {
|
|||||||
State *VipState `protobuf:"bytes,3,opt,name=state,proto3" json:"state,omitempty"`
|
State *VipState `protobuf:"bytes,3,opt,name=state,proto3" json:"state,omitempty"`
|
||||||
DenialReason string `protobuf:"bytes,4,opt,name=denial_reason,json=denialReason,proto3" json:"denial_reason,omitempty"`
|
DenialReason string `protobuf:"bytes,4,opt,name=denial_reason,json=denialReason,proto3" json:"denial_reason,omitempty"`
|
||||||
EvaluatedAtMs int64 `protobuf:"varint,5,opt,name=evaluated_at_ms,json=evaluatedAtMs,proto3" json:"evaluated_at_ms,omitempty"`
|
EvaluatedAtMs int64 `protobuf:"varint,5,opt,name=evaluated_at_ms,json=evaluatedAtMs,proto3" json:"evaluated_at_ms,omitempty"`
|
||||||
unknownFields protoimpl.UnknownFields
|
// required_level 是当前 App 所有 active 等级中首次显式配置该权益的等级;0 表示未配置。
|
||||||
sizeCache protoimpl.SizeCache
|
RequiredLevel int32 `protobuf:"varint,6,opt,name=required_level,json=requiredLevel,proto3" json:"required_level,omitempty"`
|
||||||
|
// current_effective_level 来自 wallet 合并付费会员与体验卡后的最终状态。
|
||||||
|
CurrentEffectiveLevel int32 `protobuf:"varint,7,opt,name=current_effective_level,json=currentEffectiveLevel,proto3" json:"current_effective_level,omitempty"`
|
||||||
|
unknownFields protoimpl.UnknownFields
|
||||||
|
sizeCache protoimpl.SizeCache
|
||||||
}
|
}
|
||||||
|
|
||||||
func (x *CheckVipBenefitResponse) Reset() {
|
func (x *CheckVipBenefitResponse) Reset() {
|
||||||
@ -20925,6 +20948,20 @@ func (x *CheckVipBenefitResponse) GetEvaluatedAtMs() int64 {
|
|||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (x *CheckVipBenefitResponse) GetRequiredLevel() int32 {
|
||||||
|
if x != nil {
|
||||||
|
return x.RequiredLevel
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *CheckVipBenefitResponse) GetCurrentEffectiveLevel() int32 {
|
||||||
|
if x != nil {
|
||||||
|
return x.CurrentEffectiveLevel
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
type AdminVipLevelInput struct {
|
type AdminVipLevelInput struct {
|
||||||
state protoimpl.MessageState `protogen:"open.v1"`
|
state protoimpl.MessageState `protogen:"open.v1"`
|
||||||
Level int32 `protobuf:"varint,1,opt,name=level,proto3" json:"level,omitempty"`
|
Level int32 `protobuf:"varint,1,opt,name=level,proto3" json:"level,omitempty"`
|
||||||
@ -27280,7 +27317,7 @@ const file_proto_wallet_v1_wallet_proto_rawDesc = "" +
|
|||||||
"\vactive_only\x18\x05 \x01(\bR\n" +
|
"\vactive_only\x18\x05 \x01(\bR\n" +
|
||||||
"activeOnly\"c\n" +
|
"activeOnly\"c\n" +
|
||||||
"\x19ListUserResourcesResponse\x12F\n" +
|
"\x19ListUserResourcesResponse\x12F\n" +
|
||||||
"\tresources\x18\x01 \x03(\v2(.hyapp.wallet.v1.UserResourceEntitlementR\tresources\"\xb5\x01\n" +
|
"\tresources\x18\x01 \x03(\v2(.hyapp.wallet.v1.UserResourceEntitlementR\tresources\"\xd4\x01\n" +
|
||||||
"\x18EquipUserResourceRequest\x12\x1d\n" +
|
"\x18EquipUserResourceRequest\x12\x1d\n" +
|
||||||
"\n" +
|
"\n" +
|
||||||
"request_id\x18\x01 \x01(\tR\trequestId\x12\x19\n" +
|
"request_id\x18\x01 \x01(\tR\trequestId\x12\x19\n" +
|
||||||
@ -27288,7 +27325,9 @@ const file_proto_wallet_v1_wallet_proto_rawDesc = "" +
|
|||||||
"\auser_id\x18\x03 \x01(\x03R\x06userId\x12\x1f\n" +
|
"\auser_id\x18\x03 \x01(\x03R\x06userId\x12\x1f\n" +
|
||||||
"\vresource_id\x18\x04 \x01(\x03R\n" +
|
"\vresource_id\x18\x04 \x01(\x03R\n" +
|
||||||
"resourceId\x12%\n" +
|
"resourceId\x12%\n" +
|
||||||
"\x0eentitlement_id\x18\x05 \x01(\tR\rentitlementId\"a\n" +
|
"\x0eentitlement_id\x18\x05 \x01(\tR\rentitlementId\x12\x1d\n" +
|
||||||
|
"\n" +
|
||||||
|
"command_id\x18\x06 \x01(\tR\tcommandId\"a\n" +
|
||||||
"\x19EquipUserResourceResponse\x12D\n" +
|
"\x19EquipUserResourceResponse\x12D\n" +
|
||||||
"\bresource\x18\x01 \x01(\v2(.hyapp.wallet.v1.UserResourceEntitlementR\bresource\"\x94\x01\n" +
|
"\bresource\x18\x01 \x01(\v2(.hyapp.wallet.v1.UserResourceEntitlementR\bresource\"\x94\x01\n" +
|
||||||
"\x1aUnequipUserResourceRequest\x12\x1d\n" +
|
"\x1aUnequipUserResourceRequest\x12\x1d\n" +
|
||||||
@ -28055,7 +28094,7 @@ const file_proto_wallet_v1_wallet_proto_rawDesc = "" +
|
|||||||
"\x0econfig_version\x18\v \x01(\x03R\rconfigVersion\x12-\n" +
|
"\x0econfig_version\x18\v \x01(\x03R\rconfigVersion\x12-\n" +
|
||||||
"\x13updated_by_admin_id\x18\f \x01(\x03R\x10updatedByAdminId\x12\"\n" +
|
"\x13updated_by_admin_id\x18\f \x01(\x03R\x10updatedByAdminId\x12\"\n" +
|
||||||
"\rcreated_at_ms\x18\r \x01(\x03R\vcreatedAtMs\x12\"\n" +
|
"\rcreated_at_ms\x18\r \x01(\x03R\vcreatedAtMs\x12\"\n" +
|
||||||
"\rupdated_at_ms\x18\x0e \x01(\x03R\vupdatedAtMs\"\xad\x04\n" +
|
"\rupdated_at_ms\x18\x0e \x01(\x03R\vupdatedAtMs\"\xe4\x04\n" +
|
||||||
"\n" +
|
"\n" +
|
||||||
"VipBenefit\x12!\n" +
|
"VipBenefit\x12!\n" +
|
||||||
"\fbenefit_code\x18\x01 \x01(\tR\vbenefitCode\x12\x12\n" +
|
"\fbenefit_code\x18\x01 \x01(\tR\vbenefitCode\x12\x12\n" +
|
||||||
@ -28076,7 +28115,8 @@ const file_proto_wallet_v1_wallet_proto_rawDesc = "" +
|
|||||||
"\rmetadata_json\x18\f \x01(\tR\fmetadataJson\x12\"\n" +
|
"\rmetadata_json\x18\f \x01(\tR\fmetadataJson\x12\"\n" +
|
||||||
"\rcreated_at_ms\x18\r \x01(\x03R\vcreatedAtMs\x12\"\n" +
|
"\rcreated_at_ms\x18\r \x01(\x03R\vcreatedAtMs\x12\"\n" +
|
||||||
"\rupdated_at_ms\x18\x0e \x01(\x03R\vupdatedAtMs\x12K\n" +
|
"\rupdated_at_ms\x18\x0e \x01(\x03R\vupdatedAtMs\x12K\n" +
|
||||||
"\fpresentation\x18\x0f \x01(\v2'.hyapp.wallet.v1.VipBenefitPresentationR\fpresentation\"\xdc\x05\n" +
|
"\fpresentation\x18\x0f \x01(\v2'.hyapp.wallet.v1.VipBenefitPresentationR\fpresentation\x125\n" +
|
||||||
|
"\bresource\x18\x10 \x01(\v2\x19.hyapp.wallet.v1.ResourceR\bresource\"\xdc\x05\n" +
|
||||||
"\bVipLevel\x12\x14\n" +
|
"\bVipLevel\x12\x14\n" +
|
||||||
"\x05level\x18\x01 \x01(\x05R\x05level\x12\x12\n" +
|
"\x05level\x18\x01 \x01(\x05R\x05level\x12\x12\n" +
|
||||||
"\x04name\x18\x02 \x01(\tR\x04name\x12\x16\n" +
|
"\x04name\x18\x02 \x01(\tR\x04name\x12\x16\n" +
|
||||||
@ -28388,13 +28428,15 @@ const file_proto_wallet_v1_wallet_proto_rawDesc = "" +
|
|||||||
"request_id\x18\x01 \x01(\tR\trequestId\x12\x19\n" +
|
"request_id\x18\x01 \x01(\tR\trequestId\x12\x19\n" +
|
||||||
"\bapp_code\x18\x02 \x01(\tR\aappCode\x12\x17\n" +
|
"\bapp_code\x18\x02 \x01(\tR\aappCode\x12\x17\n" +
|
||||||
"\auser_id\x18\x03 \x01(\x03R\x06userId\x12!\n" +
|
"\auser_id\x18\x03 \x01(\x03R\x06userId\x12!\n" +
|
||||||
"\fbenefit_code\x18\x04 \x01(\tR\vbenefitCode\"\xe8\x01\n" +
|
"\fbenefit_code\x18\x04 \x01(\tR\vbenefitCode\"\xc7\x02\n" +
|
||||||
"\x17CheckVipBenefitResponse\x12\x18\n" +
|
"\x17CheckVipBenefitResponse\x12\x18\n" +
|
||||||
"\aallowed\x18\x01 \x01(\bR\aallowed\x125\n" +
|
"\aallowed\x18\x01 \x01(\bR\aallowed\x125\n" +
|
||||||
"\abenefit\x18\x02 \x01(\v2\x1b.hyapp.wallet.v1.VipBenefitR\abenefit\x12/\n" +
|
"\abenefit\x18\x02 \x01(\v2\x1b.hyapp.wallet.v1.VipBenefitR\abenefit\x12/\n" +
|
||||||
"\x05state\x18\x03 \x01(\v2\x19.hyapp.wallet.v1.VipStateR\x05state\x12#\n" +
|
"\x05state\x18\x03 \x01(\v2\x19.hyapp.wallet.v1.VipStateR\x05state\x12#\n" +
|
||||||
"\rdenial_reason\x18\x04 \x01(\tR\fdenialReason\x12&\n" +
|
"\rdenial_reason\x18\x04 \x01(\tR\fdenialReason\x12&\n" +
|
||||||
"\x0fevaluated_at_ms\x18\x05 \x01(\x03R\revaluatedAtMs\"\xea\x02\n" +
|
"\x0fevaluated_at_ms\x18\x05 \x01(\x03R\revaluatedAtMs\x12%\n" +
|
||||||
|
"\x0erequired_level\x18\x06 \x01(\x05R\rrequiredLevel\x126\n" +
|
||||||
|
"\x17current_effective_level\x18\a \x01(\x05R\x15currentEffectiveLevel\"\xea\x02\n" +
|
||||||
"\x12AdminVipLevelInput\x12\x14\n" +
|
"\x12AdminVipLevelInput\x12\x14\n" +
|
||||||
"\x05level\x18\x01 \x01(\x05R\x05level\x12\x12\n" +
|
"\x05level\x18\x01 \x01(\x05R\x05level\x12\x12\n" +
|
||||||
"\x04name\x18\x02 \x01(\tR\x04name\x12\x16\n" +
|
"\x04name\x18\x02 \x01(\tR\x04name\x12\x16\n" +
|
||||||
@ -29434,343 +29476,344 @@ var file_proto_wallet_v1_wallet_proto_depIdxs = []int32{
|
|||||||
184, // 83: hyapp.wallet.v1.GetDiamondExchangeConfigResponse.rules:type_name -> hyapp.wallet.v1.DiamondExchangeRule
|
184, // 83: hyapp.wallet.v1.GetDiamondExchangeConfigResponse.rules:type_name -> hyapp.wallet.v1.DiamondExchangeRule
|
||||||
187, // 84: hyapp.wallet.v1.ListWalletTransactionsResponse.transactions:type_name -> hyapp.wallet.v1.WalletTransaction
|
187, // 84: hyapp.wallet.v1.ListWalletTransactionsResponse.transactions:type_name -> hyapp.wallet.v1.WalletTransaction
|
||||||
297, // 85: hyapp.wallet.v1.VipBenefit.presentation:type_name -> hyapp.wallet.v1.VipBenefitPresentation
|
297, // 85: hyapp.wallet.v1.VipBenefit.presentation:type_name -> hyapp.wallet.v1.VipBenefitPresentation
|
||||||
190, // 86: hyapp.wallet.v1.VipLevel.reward_items:type_name -> hyapp.wallet.v1.VipRewardItem
|
48, // 86: hyapp.wallet.v1.VipBenefit.resource:type_name -> hyapp.wallet.v1.Resource
|
||||||
192, // 87: hyapp.wallet.v1.VipLevel.benefits:type_name -> hyapp.wallet.v1.VipBenefit
|
190, // 87: hyapp.wallet.v1.VipLevel.reward_items:type_name -> hyapp.wallet.v1.VipRewardItem
|
||||||
194, // 88: hyapp.wallet.v1.VipState.paid_vip:type_name -> hyapp.wallet.v1.UserVip
|
192, // 88: hyapp.wallet.v1.VipLevel.benefits:type_name -> hyapp.wallet.v1.VipBenefit
|
||||||
195, // 89: hyapp.wallet.v1.VipState.equipped_trial_card:type_name -> hyapp.wallet.v1.VipTrialCard
|
194, // 89: hyapp.wallet.v1.VipState.paid_vip:type_name -> hyapp.wallet.v1.UserVip
|
||||||
194, // 90: hyapp.wallet.v1.VipState.effective_vip:type_name -> hyapp.wallet.v1.UserVip
|
195, // 90: hyapp.wallet.v1.VipState.equipped_trial_card:type_name -> hyapp.wallet.v1.VipTrialCard
|
||||||
192, // 91: hyapp.wallet.v1.VipState.effective_benefits:type_name -> hyapp.wallet.v1.VipBenefit
|
194, // 91: hyapp.wallet.v1.VipState.effective_vip:type_name -> hyapp.wallet.v1.UserVip
|
||||||
191, // 92: hyapp.wallet.v1.VipState.program_config:type_name -> hyapp.wallet.v1.VipProgramConfig
|
192, // 92: hyapp.wallet.v1.VipState.effective_benefits:type_name -> hyapp.wallet.v1.VipBenefit
|
||||||
196, // 93: hyapp.wallet.v1.VipState.user_settings:type_name -> hyapp.wallet.v1.VipUserSettings
|
191, // 93: hyapp.wallet.v1.VipState.program_config:type_name -> hyapp.wallet.v1.VipProgramConfig
|
||||||
194, // 94: hyapp.wallet.v1.ListVipPackagesResponse.current_vip:type_name -> hyapp.wallet.v1.UserVip
|
196, // 94: hyapp.wallet.v1.VipState.user_settings:type_name -> hyapp.wallet.v1.VipUserSettings
|
||||||
193, // 95: hyapp.wallet.v1.ListVipPackagesResponse.packages:type_name -> hyapp.wallet.v1.VipLevel
|
194, // 95: hyapp.wallet.v1.ListVipPackagesResponse.current_vip:type_name -> hyapp.wallet.v1.UserVip
|
||||||
197, // 96: hyapp.wallet.v1.ListVipPackagesResponse.state:type_name -> hyapp.wallet.v1.VipState
|
193, // 96: hyapp.wallet.v1.ListVipPackagesResponse.packages:type_name -> hyapp.wallet.v1.VipLevel
|
||||||
191, // 97: hyapp.wallet.v1.ListVipPackagesResponse.program_config:type_name -> hyapp.wallet.v1.VipProgramConfig
|
197, // 97: hyapp.wallet.v1.ListVipPackagesResponse.state:type_name -> hyapp.wallet.v1.VipState
|
||||||
194, // 98: hyapp.wallet.v1.GetMyVipResponse.vip:type_name -> hyapp.wallet.v1.UserVip
|
191, // 98: hyapp.wallet.v1.ListVipPackagesResponse.program_config:type_name -> hyapp.wallet.v1.VipProgramConfig
|
||||||
197, // 99: hyapp.wallet.v1.GetMyVipResponse.state:type_name -> hyapp.wallet.v1.VipState
|
194, // 99: hyapp.wallet.v1.GetMyVipResponse.vip:type_name -> hyapp.wallet.v1.UserVip
|
||||||
194, // 100: hyapp.wallet.v1.PurchaseVipResponse.vip:type_name -> hyapp.wallet.v1.UserVip
|
197, // 100: hyapp.wallet.v1.GetMyVipResponse.state:type_name -> hyapp.wallet.v1.VipState
|
||||||
190, // 101: hyapp.wallet.v1.PurchaseVipResponse.reward_items:type_name -> hyapp.wallet.v1.VipRewardItem
|
194, // 101: hyapp.wallet.v1.PurchaseVipResponse.vip:type_name -> hyapp.wallet.v1.UserVip
|
||||||
197, // 102: hyapp.wallet.v1.PurchaseVipResponse.state:type_name -> hyapp.wallet.v1.VipState
|
190, // 102: hyapp.wallet.v1.PurchaseVipResponse.reward_items:type_name -> hyapp.wallet.v1.VipRewardItem
|
||||||
8, // 103: hyapp.wallet.v1.PurchaseVipResponse.coin_balance:type_name -> hyapp.wallet.v1.AssetBalance
|
197, // 103: hyapp.wallet.v1.PurchaseVipResponse.state:type_name -> hyapp.wallet.v1.VipState
|
||||||
8, // 104: hyapp.wallet.v1.DebitCPBreakupFeeResponse.balance:type_name -> hyapp.wallet.v1.AssetBalance
|
8, // 104: hyapp.wallet.v1.PurchaseVipResponse.coin_balance:type_name -> hyapp.wallet.v1.AssetBalance
|
||||||
8, // 105: hyapp.wallet.v1.DebitWheelDrawResponse.balance:type_name -> hyapp.wallet.v1.AssetBalance
|
8, // 105: hyapp.wallet.v1.DebitCPBreakupFeeResponse.balance:type_name -> hyapp.wallet.v1.AssetBalance
|
||||||
194, // 106: hyapp.wallet.v1.GrantVipResponse.vip:type_name -> hyapp.wallet.v1.UserVip
|
8, // 106: hyapp.wallet.v1.DebitWheelDrawResponse.balance:type_name -> hyapp.wallet.v1.AssetBalance
|
||||||
190, // 107: hyapp.wallet.v1.GrantVipResponse.reward_items:type_name -> hyapp.wallet.v1.VipRewardItem
|
194, // 107: hyapp.wallet.v1.GrantVipResponse.vip:type_name -> hyapp.wallet.v1.UserVip
|
||||||
197, // 108: hyapp.wallet.v1.GrantVipResponse.state:type_name -> hyapp.wallet.v1.VipState
|
190, // 108: hyapp.wallet.v1.GrantVipResponse.reward_items:type_name -> hyapp.wallet.v1.VipRewardItem
|
||||||
191, // 109: hyapp.wallet.v1.GetVipProgramConfigResponse.config:type_name -> hyapp.wallet.v1.VipProgramConfig
|
197, // 109: hyapp.wallet.v1.GrantVipResponse.state:type_name -> hyapp.wallet.v1.VipState
|
||||||
192, // 110: hyapp.wallet.v1.GetVipProgramConfigResponse.benefits:type_name -> hyapp.wallet.v1.VipBenefit
|
191, // 110: hyapp.wallet.v1.GetVipProgramConfigResponse.config:type_name -> hyapp.wallet.v1.VipProgramConfig
|
||||||
191, // 111: hyapp.wallet.v1.UpdateAdminVipProgramConfigRequest.config:type_name -> hyapp.wallet.v1.VipProgramConfig
|
192, // 111: hyapp.wallet.v1.GetVipProgramConfigResponse.benefits:type_name -> hyapp.wallet.v1.VipBenefit
|
||||||
191, // 112: hyapp.wallet.v1.UpdateAdminVipProgramConfigResponse.config:type_name -> hyapp.wallet.v1.VipProgramConfig
|
191, // 112: hyapp.wallet.v1.UpdateAdminVipProgramConfigRequest.config:type_name -> hyapp.wallet.v1.VipProgramConfig
|
||||||
196, // 113: hyapp.wallet.v1.UpdateMyVipSettingsResponse.settings:type_name -> hyapp.wallet.v1.VipUserSettings
|
191, // 113: hyapp.wallet.v1.UpdateAdminVipProgramConfigResponse.config:type_name -> hyapp.wallet.v1.VipProgramConfig
|
||||||
216, // 114: hyapp.wallet.v1.ProcessVipDailyCoinRebateBatchResponse.run:type_name -> hyapp.wallet.v1.VipDailyCoinRebateRun
|
196, // 114: hyapp.wallet.v1.UpdateMyVipSettingsResponse.settings:type_name -> hyapp.wallet.v1.VipUserSettings
|
||||||
217, // 115: hyapp.wallet.v1.GetMyCurrentVipDailyCoinRebateResponse.rebate:type_name -> hyapp.wallet.v1.VipDailyCoinRebate
|
216, // 115: hyapp.wallet.v1.ProcessVipDailyCoinRebateBatchResponse.run:type_name -> hyapp.wallet.v1.VipDailyCoinRebateRun
|
||||||
217, // 116: hyapp.wallet.v1.ListMyVipDailyCoinRebateStatusesResponse.rebates:type_name -> hyapp.wallet.v1.VipDailyCoinRebate
|
217, // 116: hyapp.wallet.v1.GetMyCurrentVipDailyCoinRebateResponse.rebate:type_name -> hyapp.wallet.v1.VipDailyCoinRebate
|
||||||
217, // 117: hyapp.wallet.v1.ClaimVipDailyCoinRebateResponse.rebate:type_name -> hyapp.wallet.v1.VipDailyCoinRebate
|
217, // 117: hyapp.wallet.v1.ListMyVipDailyCoinRebateStatusesResponse.rebates:type_name -> hyapp.wallet.v1.VipDailyCoinRebate
|
||||||
8, // 118: hyapp.wallet.v1.ClaimVipDailyCoinRebateResponse.coin_balance:type_name -> hyapp.wallet.v1.AssetBalance
|
217, // 118: hyapp.wallet.v1.ClaimVipDailyCoinRebateResponse.rebate:type_name -> hyapp.wallet.v1.VipDailyCoinRebate
|
||||||
195, // 119: hyapp.wallet.v1.GrantVipTrialCardResponse.trial_card:type_name -> hyapp.wallet.v1.VipTrialCard
|
8, // 119: hyapp.wallet.v1.ClaimVipDailyCoinRebateResponse.coin_balance:type_name -> hyapp.wallet.v1.AssetBalance
|
||||||
197, // 120: hyapp.wallet.v1.GrantVipTrialCardResponse.state:type_name -> hyapp.wallet.v1.VipState
|
195, // 120: hyapp.wallet.v1.GrantVipTrialCardResponse.trial_card:type_name -> hyapp.wallet.v1.VipTrialCard
|
||||||
195, // 121: hyapp.wallet.v1.EquipVipTrialCardResponse.trial_card:type_name -> hyapp.wallet.v1.VipTrialCard
|
197, // 121: hyapp.wallet.v1.GrantVipTrialCardResponse.state:type_name -> hyapp.wallet.v1.VipState
|
||||||
197, // 122: hyapp.wallet.v1.EquipVipTrialCardResponse.state:type_name -> hyapp.wallet.v1.VipState
|
195, // 122: hyapp.wallet.v1.EquipVipTrialCardResponse.trial_card:type_name -> hyapp.wallet.v1.VipTrialCard
|
||||||
197, // 123: hyapp.wallet.v1.UnequipVipTrialCardResponse.state:type_name -> hyapp.wallet.v1.VipState
|
197, // 123: hyapp.wallet.v1.EquipVipTrialCardResponse.state:type_name -> hyapp.wallet.v1.VipState
|
||||||
192, // 124: hyapp.wallet.v1.CheckVipBenefitResponse.benefit:type_name -> hyapp.wallet.v1.VipBenefit
|
197, // 124: hyapp.wallet.v1.UnequipVipTrialCardResponse.state:type_name -> hyapp.wallet.v1.VipState
|
||||||
197, // 125: hyapp.wallet.v1.CheckVipBenefitResponse.state:type_name -> hyapp.wallet.v1.VipState
|
192, // 125: hyapp.wallet.v1.CheckVipBenefitResponse.benefit:type_name -> hyapp.wallet.v1.VipBenefit
|
||||||
192, // 126: hyapp.wallet.v1.AdminVipLevelInput.benefits:type_name -> hyapp.wallet.v1.VipBenefit
|
197, // 126: hyapp.wallet.v1.CheckVipBenefitResponse.state:type_name -> hyapp.wallet.v1.VipState
|
||||||
193, // 127: hyapp.wallet.v1.ListAdminVipLevelsResponse.levels:type_name -> hyapp.wallet.v1.VipLevel
|
192, // 127: hyapp.wallet.v1.AdminVipLevelInput.benefits:type_name -> hyapp.wallet.v1.VipBenefit
|
||||||
191, // 128: hyapp.wallet.v1.ListAdminVipLevelsResponse.program_config:type_name -> hyapp.wallet.v1.VipProgramConfig
|
193, // 128: hyapp.wallet.v1.ListAdminVipLevelsResponse.levels:type_name -> hyapp.wallet.v1.VipLevel
|
||||||
234, // 129: hyapp.wallet.v1.UpdateAdminVipLevelsRequest.levels:type_name -> hyapp.wallet.v1.AdminVipLevelInput
|
191, // 129: hyapp.wallet.v1.ListAdminVipLevelsResponse.program_config:type_name -> hyapp.wallet.v1.VipProgramConfig
|
||||||
193, // 130: hyapp.wallet.v1.UpdateAdminVipLevelsResponse.levels:type_name -> hyapp.wallet.v1.VipLevel
|
234, // 130: hyapp.wallet.v1.UpdateAdminVipLevelsRequest.levels:type_name -> hyapp.wallet.v1.AdminVipLevelInput
|
||||||
191, // 131: hyapp.wallet.v1.UpdateAdminVipLevelsResponse.program_config:type_name -> hyapp.wallet.v1.VipProgramConfig
|
193, // 131: hyapp.wallet.v1.UpdateAdminVipLevelsResponse.levels:type_name -> hyapp.wallet.v1.VipLevel
|
||||||
8, // 132: hyapp.wallet.v1.CreditTaskRewardResponse.balance:type_name -> hyapp.wallet.v1.AssetBalance
|
191, // 132: hyapp.wallet.v1.UpdateAdminVipLevelsResponse.program_config:type_name -> hyapp.wallet.v1.VipProgramConfig
|
||||||
8, // 133: hyapp.wallet.v1.CreditLuckyGiftRewardResponse.balance:type_name -> hyapp.wallet.v1.AssetBalance
|
8, // 133: hyapp.wallet.v1.CreditTaskRewardResponse.balance:type_name -> hyapp.wallet.v1.AssetBalance
|
||||||
8, // 134: hyapp.wallet.v1.CreditWheelRewardResponse.balance:type_name -> hyapp.wallet.v1.AssetBalance
|
8, // 134: hyapp.wallet.v1.CreditLuckyGiftRewardResponse.balance:type_name -> hyapp.wallet.v1.AssetBalance
|
||||||
8, // 135: hyapp.wallet.v1.CreditRoomTurnoverRewardResponse.balance:type_name -> hyapp.wallet.v1.AssetBalance
|
8, // 135: hyapp.wallet.v1.CreditWheelRewardResponse.balance:type_name -> hyapp.wallet.v1.AssetBalance
|
||||||
8, // 136: hyapp.wallet.v1.CreditInviteActivityRewardResponse.balance:type_name -> hyapp.wallet.v1.AssetBalance
|
8, // 136: hyapp.wallet.v1.CreditRoomTurnoverRewardResponse.balance:type_name -> hyapp.wallet.v1.AssetBalance
|
||||||
8, // 137: hyapp.wallet.v1.CreditAgencyOpeningRewardResponse.balance:type_name -> hyapp.wallet.v1.AssetBalance
|
8, // 137: hyapp.wallet.v1.CreditInviteActivityRewardResponse.balance:type_name -> hyapp.wallet.v1.AssetBalance
|
||||||
254, // 138: hyapp.wallet.v1.RedPacket.claims:type_name -> hyapp.wallet.v1.RedPacketClaim
|
8, // 138: hyapp.wallet.v1.CreditAgencyOpeningRewardResponse.balance:type_name -> hyapp.wallet.v1.AssetBalance
|
||||||
253, // 139: hyapp.wallet.v1.GetRedPacketConfigResponse.config:type_name -> hyapp.wallet.v1.RedPacketConfig
|
254, // 139: hyapp.wallet.v1.RedPacket.claims:type_name -> hyapp.wallet.v1.RedPacketClaim
|
||||||
253, // 140: hyapp.wallet.v1.UpdateRedPacketConfigResponse.config:type_name -> hyapp.wallet.v1.RedPacketConfig
|
253, // 140: hyapp.wallet.v1.GetRedPacketConfigResponse.config:type_name -> hyapp.wallet.v1.RedPacketConfig
|
||||||
255, // 141: hyapp.wallet.v1.CreateRedPacketResponse.packet:type_name -> hyapp.wallet.v1.RedPacket
|
253, // 141: hyapp.wallet.v1.UpdateRedPacketConfigResponse.config:type_name -> hyapp.wallet.v1.RedPacketConfig
|
||||||
254, // 142: hyapp.wallet.v1.ClaimRedPacketResponse.claim:type_name -> hyapp.wallet.v1.RedPacketClaim
|
255, // 142: hyapp.wallet.v1.CreateRedPacketResponse.packet:type_name -> hyapp.wallet.v1.RedPacket
|
||||||
255, // 143: hyapp.wallet.v1.ClaimRedPacketResponse.packet:type_name -> hyapp.wallet.v1.RedPacket
|
254, // 143: hyapp.wallet.v1.ClaimRedPacketResponse.claim:type_name -> hyapp.wallet.v1.RedPacketClaim
|
||||||
255, // 144: hyapp.wallet.v1.ListRedPacketsResponse.packets:type_name -> hyapp.wallet.v1.RedPacket
|
255, // 144: hyapp.wallet.v1.ClaimRedPacketResponse.packet:type_name -> hyapp.wallet.v1.RedPacket
|
||||||
255, // 145: hyapp.wallet.v1.GetRedPacketResponse.packet:type_name -> hyapp.wallet.v1.RedPacket
|
255, // 145: hyapp.wallet.v1.ListRedPacketsResponse.packets:type_name -> hyapp.wallet.v1.RedPacket
|
||||||
255, // 146: hyapp.wallet.v1.RetryRedPacketRefundResponse.packet:type_name -> hyapp.wallet.v1.RedPacket
|
255, // 146: hyapp.wallet.v1.GetRedPacketResponse.packet:type_name -> hyapp.wallet.v1.RedPacket
|
||||||
48, // 147: hyapp.wallet.v1.ResourceShopPurchaseOrder.resource:type_name -> hyapp.wallet.v1.Resource
|
255, // 147: hyapp.wallet.v1.RetryRedPacketRefundResponse.packet:type_name -> hyapp.wallet.v1.RedPacket
|
||||||
274, // 148: hyapp.wallet.v1.ListResourceShopPurchaseOrdersResponse.orders:type_name -> hyapp.wallet.v1.ResourceShopPurchaseOrder
|
48, // 148: hyapp.wallet.v1.ResourceShopPurchaseOrder.resource:type_name -> hyapp.wallet.v1.Resource
|
||||||
279, // 149: hyapp.wallet.v1.GetHostRevenueStatsResponse.stats:type_name -> hyapp.wallet.v1.HostRevenueStats
|
274, // 149: hyapp.wallet.v1.ListResourceShopPurchaseOrdersResponse.orders:type_name -> hyapp.wallet.v1.ResourceShopPurchaseOrder
|
||||||
282, // 150: hyapp.wallet.v1.GetAgencyPointShareStatsResponse.stats:type_name -> hyapp.wallet.v1.AgencyPointShareStats
|
279, // 150: hyapp.wallet.v1.GetHostRevenueStatsResponse.stats:type_name -> hyapp.wallet.v1.HostRevenueStats
|
||||||
285, // 151: hyapp.wallet.v1.GetAgencyHostGiftStatsResponse.stats:type_name -> hyapp.wallet.v1.AgencyHostGiftStats
|
282, // 151: hyapp.wallet.v1.GetAgencyPointShareStatsResponse.stats:type_name -> hyapp.wallet.v1.AgencyPointShareStats
|
||||||
288, // 152: hyapp.wallet.v1.ListPointWithdrawalCoinSellersResponse.sellers:type_name -> hyapp.wallet.v1.PointWithdrawalCoinSellerConfig
|
285, // 152: hyapp.wallet.v1.GetAgencyHostGiftStatsResponse.stats:type_name -> hyapp.wallet.v1.AgencyHostGiftStats
|
||||||
295, // 153: hyapp.wallet.v1.VipBenefitPresentation.preview_items:type_name -> hyapp.wallet.v1.VipBenefitPreviewItem
|
288, // 153: hyapp.wallet.v1.ListPointWithdrawalCoinSellersResponse.sellers:type_name -> hyapp.wallet.v1.PointWithdrawalCoinSellerConfig
|
||||||
296, // 154: hyapp.wallet.v1.VipBenefitPresentation.numeric_reward:type_name -> hyapp.wallet.v1.VipBenefitNumericReward
|
295, // 154: hyapp.wallet.v1.VipBenefitPresentation.preview_items:type_name -> hyapp.wallet.v1.VipBenefitPreviewItem
|
||||||
57, // 155: hyapp.wallet.v1.RevokeUserResourceResponse.resource:type_name -> hyapp.wallet.v1.UserResourceEntitlement
|
296, // 155: hyapp.wallet.v1.VipBenefitPresentation.numeric_reward:type_name -> hyapp.wallet.v1.VipBenefitNumericReward
|
||||||
272, // 156: hyapp.wallet.v1.WalletCronService.ProcessHostSalaryDailySettlementBatch:input_type -> hyapp.wallet.v1.CronBatchRequest
|
57, // 156: hyapp.wallet.v1.RevokeUserResourceResponse.resource:type_name -> hyapp.wallet.v1.UserResourceEntitlement
|
||||||
272, // 157: hyapp.wallet.v1.WalletCronService.ProcessHostSalaryHalfMonthSettlementBatch:input_type -> hyapp.wallet.v1.CronBatchRequest
|
272, // 157: hyapp.wallet.v1.WalletCronService.ProcessHostSalaryDailySettlementBatch:input_type -> hyapp.wallet.v1.CronBatchRequest
|
||||||
272, // 158: hyapp.wallet.v1.WalletCronService.ProcessHostSalaryMonthEndBatch:input_type -> hyapp.wallet.v1.CronBatchRequest
|
272, // 158: hyapp.wallet.v1.WalletCronService.ProcessHostSalaryHalfMonthSettlementBatch:input_type -> hyapp.wallet.v1.CronBatchRequest
|
||||||
218, // 159: hyapp.wallet.v1.WalletCronService.ProcessVipDailyCoinRebateBatch:input_type -> hyapp.wallet.v1.ProcessVipDailyCoinRebateBatchRequest
|
272, // 159: hyapp.wallet.v1.WalletCronService.ProcessHostSalaryMonthEndBatch:input_type -> hyapp.wallet.v1.CronBatchRequest
|
||||||
0, // 160: hyapp.wallet.v1.WalletService.DebitGift:input_type -> hyapp.wallet.v1.DebitGiftRequest
|
218, // 160: hyapp.wallet.v1.WalletCronService.ProcessVipDailyCoinRebateBatch:input_type -> hyapp.wallet.v1.ProcessVipDailyCoinRebateBatchRequest
|
||||||
1, // 161: hyapp.wallet.v1.WalletService.DebitDirectGift:input_type -> hyapp.wallet.v1.DebitDirectGiftRequest
|
0, // 161: hyapp.wallet.v1.WalletService.DebitGift:input_type -> hyapp.wallet.v1.DebitGiftRequest
|
||||||
4, // 162: hyapp.wallet.v1.WalletService.BatchDebitGift:input_type -> hyapp.wallet.v1.BatchDebitGiftRequest
|
1, // 162: hyapp.wallet.v1.WalletService.DebitDirectGift:input_type -> hyapp.wallet.v1.DebitDirectGiftRequest
|
||||||
7, // 163: hyapp.wallet.v1.WalletService.DebitRobotGift:input_type -> hyapp.wallet.v1.DebitRobotGiftRequest
|
4, // 163: hyapp.wallet.v1.WalletService.BatchDebitGift:input_type -> hyapp.wallet.v1.BatchDebitGiftRequest
|
||||||
9, // 164: hyapp.wallet.v1.WalletService.GetBalances:input_type -> hyapp.wallet.v1.GetBalancesRequest
|
7, // 164: hyapp.wallet.v1.WalletService.DebitRobotGift:input_type -> hyapp.wallet.v1.DebitRobotGiftRequest
|
||||||
13, // 165: hyapp.wallet.v1.WalletService.GetActiveHostSalaryPolicy:input_type -> hyapp.wallet.v1.GetActiveHostSalaryPolicyRequest
|
9, // 165: hyapp.wallet.v1.WalletService.GetBalances:input_type -> hyapp.wallet.v1.GetBalancesRequest
|
||||||
16, // 166: hyapp.wallet.v1.WalletService.GetHostSalaryProgress:input_type -> hyapp.wallet.v1.GetHostSalaryProgressRequest
|
13, // 166: hyapp.wallet.v1.WalletService.GetActiveHostSalaryPolicy:input_type -> hyapp.wallet.v1.GetActiveHostSalaryPolicyRequest
|
||||||
280, // 167: hyapp.wallet.v1.WalletService.GetHostRevenueStats:input_type -> hyapp.wallet.v1.GetHostRevenueStatsRequest
|
16, // 167: hyapp.wallet.v1.WalletService.GetHostSalaryProgress:input_type -> hyapp.wallet.v1.GetHostSalaryProgressRequest
|
||||||
283, // 168: hyapp.wallet.v1.WalletService.GetAgencyPointShareStats:input_type -> hyapp.wallet.v1.GetAgencyPointShareStatsRequest
|
280, // 168: hyapp.wallet.v1.WalletService.GetHostRevenueStats:input_type -> hyapp.wallet.v1.GetHostRevenueStatsRequest
|
||||||
286, // 169: hyapp.wallet.v1.WalletService.GetAgencyHostGiftStats:input_type -> hyapp.wallet.v1.GetAgencyHostGiftStatsRequest
|
283, // 169: hyapp.wallet.v1.WalletService.GetAgencyPointShareStats:input_type -> hyapp.wallet.v1.GetAgencyPointShareStatsRequest
|
||||||
19, // 170: hyapp.wallet.v1.WalletService.GetTeamHostSalaryStats:input_type -> hyapp.wallet.v1.GetTeamHostSalaryStatsRequest
|
286, // 170: hyapp.wallet.v1.WalletService.GetAgencyHostGiftStats:input_type -> hyapp.wallet.v1.GetAgencyHostGiftStatsRequest
|
||||||
21, // 171: hyapp.wallet.v1.WalletService.AdminCreditAsset:input_type -> hyapp.wallet.v1.AdminCreditAssetRequest
|
19, // 171: hyapp.wallet.v1.WalletService.GetTeamHostSalaryStats:input_type -> hyapp.wallet.v1.GetTeamHostSalaryStatsRequest
|
||||||
23, // 172: hyapp.wallet.v1.WalletService.AdminCreditCoinSellerStock:input_type -> hyapp.wallet.v1.AdminCreditCoinSellerStockRequest
|
21, // 172: hyapp.wallet.v1.WalletService.AdminCreditAsset:input_type -> hyapp.wallet.v1.AdminCreditAssetRequest
|
||||||
25, // 173: hyapp.wallet.v1.WalletService.TransferCoinFromSeller:input_type -> hyapp.wallet.v1.TransferCoinFromSellerRequest
|
23, // 173: hyapp.wallet.v1.WalletService.AdminCreditCoinSellerStock:input_type -> hyapp.wallet.v1.AdminCreditCoinSellerStockRequest
|
||||||
27, // 174: hyapp.wallet.v1.WalletService.TransferCoinSellerStockToChild:input_type -> hyapp.wallet.v1.TransferCoinSellerStockToChildRequest
|
25, // 174: hyapp.wallet.v1.WalletService.TransferCoinFromSeller:input_type -> hyapp.wallet.v1.TransferCoinFromSellerRequest
|
||||||
30, // 175: hyapp.wallet.v1.WalletService.ListCoinSellerSalaryExchangeRateTiers:input_type -> hyapp.wallet.v1.ListCoinSellerSalaryExchangeRateTiersRequest
|
27, // 175: hyapp.wallet.v1.WalletService.TransferCoinSellerStockToChild:input_type -> hyapp.wallet.v1.TransferCoinSellerStockToChildRequest
|
||||||
32, // 176: hyapp.wallet.v1.WalletService.ExchangeSalaryToCoin:input_type -> hyapp.wallet.v1.ExchangeSalaryToCoinRequest
|
30, // 176: hyapp.wallet.v1.WalletService.ListCoinSellerSalaryExchangeRateTiers:input_type -> hyapp.wallet.v1.ListCoinSellerSalaryExchangeRateTiersRequest
|
||||||
34, // 177: hyapp.wallet.v1.WalletService.TransferSalaryToCoinSeller:input_type -> hyapp.wallet.v1.TransferSalaryToCoinSellerRequest
|
32, // 177: hyapp.wallet.v1.WalletService.ExchangeSalaryToCoin:input_type -> hyapp.wallet.v1.ExchangeSalaryToCoinRequest
|
||||||
289, // 178: hyapp.wallet.v1.WalletService.ListPointWithdrawalCoinSellers:input_type -> hyapp.wallet.v1.ListPointWithdrawalCoinSellersRequest
|
34, // 178: hyapp.wallet.v1.WalletService.TransferSalaryToCoinSeller:input_type -> hyapp.wallet.v1.TransferSalaryToCoinSellerRequest
|
||||||
291, // 179: hyapp.wallet.v1.WalletService.TransferPointToCoinSeller:input_type -> hyapp.wallet.v1.TransferPointToCoinSellerRequest
|
289, // 179: hyapp.wallet.v1.WalletService.ListPointWithdrawalCoinSellers:input_type -> hyapp.wallet.v1.ListPointWithdrawalCoinSellersRequest
|
||||||
293, // 180: hyapp.wallet.v1.WalletService.GetPointWithdrawalConfig:input_type -> hyapp.wallet.v1.GetPointWithdrawalConfigRequest
|
291, // 180: hyapp.wallet.v1.WalletService.TransferPointToCoinSeller:input_type -> hyapp.wallet.v1.TransferPointToCoinSellerRequest
|
||||||
36, // 181: hyapp.wallet.v1.WalletService.FreezeSalaryWithdrawal:input_type -> hyapp.wallet.v1.FreezeSalaryWithdrawalRequest
|
293, // 181: hyapp.wallet.v1.WalletService.GetPointWithdrawalConfig:input_type -> hyapp.wallet.v1.GetPointWithdrawalConfigRequest
|
||||||
38, // 182: hyapp.wallet.v1.WalletService.SettleSalaryWithdrawal:input_type -> hyapp.wallet.v1.SettleSalaryWithdrawalRequest
|
36, // 182: hyapp.wallet.v1.WalletService.FreezeSalaryWithdrawal:input_type -> hyapp.wallet.v1.FreezeSalaryWithdrawalRequest
|
||||||
40, // 183: hyapp.wallet.v1.WalletService.ReleaseSalaryWithdrawal:input_type -> hyapp.wallet.v1.ReleaseSalaryWithdrawalRequest
|
38, // 183: hyapp.wallet.v1.WalletService.SettleSalaryWithdrawal:input_type -> hyapp.wallet.v1.SettleSalaryWithdrawalRequest
|
||||||
42, // 184: hyapp.wallet.v1.WalletService.FreezePointWithdrawal:input_type -> hyapp.wallet.v1.FreezePointWithdrawalRequest
|
40, // 184: hyapp.wallet.v1.WalletService.ReleaseSalaryWithdrawal:input_type -> hyapp.wallet.v1.ReleaseSalaryWithdrawalRequest
|
||||||
44, // 185: hyapp.wallet.v1.WalletService.SettlePointWithdrawal:input_type -> hyapp.wallet.v1.SettlePointWithdrawalRequest
|
42, // 185: hyapp.wallet.v1.WalletService.FreezePointWithdrawal:input_type -> hyapp.wallet.v1.FreezePointWithdrawalRequest
|
||||||
46, // 186: hyapp.wallet.v1.WalletService.ReleasePointWithdrawal:input_type -> hyapp.wallet.v1.ReleasePointWithdrawalRequest
|
44, // 186: hyapp.wallet.v1.WalletService.SettlePointWithdrawal:input_type -> hyapp.wallet.v1.SettlePointWithdrawalRequest
|
||||||
61, // 187: hyapp.wallet.v1.WalletService.ListResources:input_type -> hyapp.wallet.v1.ListResourcesRequest
|
46, // 187: hyapp.wallet.v1.WalletService.ReleasePointWithdrawal:input_type -> hyapp.wallet.v1.ReleasePointWithdrawalRequest
|
||||||
63, // 188: hyapp.wallet.v1.WalletService.GetResource:input_type -> hyapp.wallet.v1.GetResourceRequest
|
61, // 188: hyapp.wallet.v1.WalletService.ListResources:input_type -> hyapp.wallet.v1.ListResourcesRequest
|
||||||
65, // 189: hyapp.wallet.v1.WalletService.CreateResource:input_type -> hyapp.wallet.v1.CreateResourceRequest
|
63, // 189: hyapp.wallet.v1.WalletService.GetResource:input_type -> hyapp.wallet.v1.GetResourceRequest
|
||||||
66, // 190: hyapp.wallet.v1.WalletService.UpdateResource:input_type -> hyapp.wallet.v1.UpdateResourceRequest
|
65, // 190: hyapp.wallet.v1.WalletService.CreateResource:input_type -> hyapp.wallet.v1.CreateResourceRequest
|
||||||
67, // 191: hyapp.wallet.v1.WalletService.SetResourceStatus:input_type -> hyapp.wallet.v1.SetResourceStatusRequest
|
66, // 191: hyapp.wallet.v1.WalletService.UpdateResource:input_type -> hyapp.wallet.v1.UpdateResourceRequest
|
||||||
68, // 192: hyapp.wallet.v1.WalletService.DeleteResource:input_type -> hyapp.wallet.v1.DeleteResourceRequest
|
67, // 192: hyapp.wallet.v1.WalletService.SetResourceStatus:input_type -> hyapp.wallet.v1.SetResourceStatusRequest
|
||||||
69, // 193: hyapp.wallet.v1.WalletService.BatchDeleteResources:input_type -> hyapp.wallet.v1.BatchDeleteResourcesRequest
|
68, // 193: hyapp.wallet.v1.WalletService.DeleteResource:input_type -> hyapp.wallet.v1.DeleteResourceRequest
|
||||||
72, // 194: hyapp.wallet.v1.WalletService.ListResourceGroups:input_type -> hyapp.wallet.v1.ListResourceGroupsRequest
|
69, // 194: hyapp.wallet.v1.WalletService.BatchDeleteResources:input_type -> hyapp.wallet.v1.BatchDeleteResourcesRequest
|
||||||
74, // 195: hyapp.wallet.v1.WalletService.GetResourceGroup:input_type -> hyapp.wallet.v1.GetResourceGroupRequest
|
72, // 195: hyapp.wallet.v1.WalletService.ListResourceGroups:input_type -> hyapp.wallet.v1.ListResourceGroupsRequest
|
||||||
76, // 196: hyapp.wallet.v1.WalletService.CreateResourceGroup:input_type -> hyapp.wallet.v1.CreateResourceGroupRequest
|
74, // 196: hyapp.wallet.v1.WalletService.GetResourceGroup:input_type -> hyapp.wallet.v1.GetResourceGroupRequest
|
||||||
77, // 197: hyapp.wallet.v1.WalletService.UpdateResourceGroup:input_type -> hyapp.wallet.v1.UpdateResourceGroupRequest
|
76, // 197: hyapp.wallet.v1.WalletService.CreateResourceGroup:input_type -> hyapp.wallet.v1.CreateResourceGroupRequest
|
||||||
78, // 198: hyapp.wallet.v1.WalletService.SetResourceGroupStatus:input_type -> hyapp.wallet.v1.SetResourceGroupStatusRequest
|
77, // 198: hyapp.wallet.v1.WalletService.UpdateResourceGroup:input_type -> hyapp.wallet.v1.UpdateResourceGroupRequest
|
||||||
52, // 199: hyapp.wallet.v1.WalletService.PinResourceGroupSnapshot:input_type -> hyapp.wallet.v1.PinResourceGroupSnapshotRequest
|
78, // 199: hyapp.wallet.v1.WalletService.SetResourceGroupStatus:input_type -> hyapp.wallet.v1.SetResourceGroupStatusRequest
|
||||||
80, // 200: hyapp.wallet.v1.WalletService.ListGiftConfigs:input_type -> hyapp.wallet.v1.ListGiftConfigsRequest
|
52, // 200: hyapp.wallet.v1.WalletService.PinResourceGroupSnapshot:input_type -> hyapp.wallet.v1.PinResourceGroupSnapshotRequest
|
||||||
82, // 201: hyapp.wallet.v1.WalletService.ListGiftTypeConfigs:input_type -> hyapp.wallet.v1.ListGiftTypeConfigsRequest
|
80, // 201: hyapp.wallet.v1.WalletService.ListGiftConfigs:input_type -> hyapp.wallet.v1.ListGiftConfigsRequest
|
||||||
277, // 202: hyapp.wallet.v1.WalletService.GetGiftCatalogVersion:input_type -> hyapp.wallet.v1.GetGiftCatalogVersionRequest
|
82, // 202: hyapp.wallet.v1.WalletService.ListGiftTypeConfigs:input_type -> hyapp.wallet.v1.ListGiftTypeConfigsRequest
|
||||||
86, // 203: hyapp.wallet.v1.WalletService.CreateGiftConfig:input_type -> hyapp.wallet.v1.CreateGiftConfigRequest
|
277, // 203: hyapp.wallet.v1.WalletService.GetGiftCatalogVersion:input_type -> hyapp.wallet.v1.GetGiftCatalogVersionRequest
|
||||||
87, // 204: hyapp.wallet.v1.WalletService.BatchCreateGiftConfigs:input_type -> hyapp.wallet.v1.BatchCreateGiftConfigsRequest
|
86, // 204: hyapp.wallet.v1.WalletService.CreateGiftConfig:input_type -> hyapp.wallet.v1.CreateGiftConfigRequest
|
||||||
89, // 205: hyapp.wallet.v1.WalletService.UpdateGiftConfig:input_type -> hyapp.wallet.v1.UpdateGiftConfigRequest
|
87, // 205: hyapp.wallet.v1.WalletService.BatchCreateGiftConfigs:input_type -> hyapp.wallet.v1.BatchCreateGiftConfigsRequest
|
||||||
90, // 206: hyapp.wallet.v1.WalletService.SetGiftConfigStatus:input_type -> hyapp.wallet.v1.SetGiftConfigStatusRequest
|
89, // 206: hyapp.wallet.v1.WalletService.UpdateGiftConfig:input_type -> hyapp.wallet.v1.UpdateGiftConfigRequest
|
||||||
91, // 207: hyapp.wallet.v1.WalletService.DeleteGiftConfig:input_type -> hyapp.wallet.v1.DeleteGiftConfigRequest
|
90, // 207: hyapp.wallet.v1.WalletService.SetGiftConfigStatus:input_type -> hyapp.wallet.v1.SetGiftConfigStatusRequest
|
||||||
84, // 208: hyapp.wallet.v1.WalletService.UpsertGiftTypeConfig:input_type -> hyapp.wallet.v1.UpsertGiftTypeConfigRequest
|
91, // 208: hyapp.wallet.v1.WalletService.DeleteGiftConfig:input_type -> hyapp.wallet.v1.DeleteGiftConfigRequest
|
||||||
93, // 209: hyapp.wallet.v1.WalletService.GrantResource:input_type -> hyapp.wallet.v1.GrantResourceRequest
|
84, // 209: hyapp.wallet.v1.WalletService.UpsertGiftTypeConfig:input_type -> hyapp.wallet.v1.UpsertGiftTypeConfigRequest
|
||||||
94, // 210: hyapp.wallet.v1.WalletService.GrantResourceGroup:input_type -> hyapp.wallet.v1.GrantResourceGroupRequest
|
93, // 210: hyapp.wallet.v1.WalletService.GrantResource:input_type -> hyapp.wallet.v1.GrantResourceRequest
|
||||||
95, // 211: hyapp.wallet.v1.WalletService.GrantPinnedResourceGroup:input_type -> hyapp.wallet.v1.GrantPinnedResourceGroupRequest
|
94, // 211: hyapp.wallet.v1.WalletService.GrantResourceGroup:input_type -> hyapp.wallet.v1.GrantResourceGroupRequest
|
||||||
96, // 212: hyapp.wallet.v1.WalletService.RevokeResourceGrant:input_type -> hyapp.wallet.v1.RevokeResourceGrantRequest
|
95, // 212: hyapp.wallet.v1.WalletService.GrantPinnedResourceGroup:input_type -> hyapp.wallet.v1.GrantPinnedResourceGroupRequest
|
||||||
298, // 213: hyapp.wallet.v1.WalletService.RevokeUserResource:input_type -> hyapp.wallet.v1.RevokeUserResourceRequest
|
96, // 213: hyapp.wallet.v1.WalletService.RevokeResourceGrant:input_type -> hyapp.wallet.v1.RevokeResourceGrantRequest
|
||||||
98, // 214: hyapp.wallet.v1.WalletService.ListUserResources:input_type -> hyapp.wallet.v1.ListUserResourcesRequest
|
298, // 214: hyapp.wallet.v1.WalletService.RevokeUserResource:input_type -> hyapp.wallet.v1.RevokeUserResourceRequest
|
||||||
100, // 215: hyapp.wallet.v1.WalletService.EquipUserResource:input_type -> hyapp.wallet.v1.EquipUserResourceRequest
|
98, // 215: hyapp.wallet.v1.WalletService.ListUserResources:input_type -> hyapp.wallet.v1.ListUserResourcesRequest
|
||||||
102, // 216: hyapp.wallet.v1.WalletService.UnequipUserResource:input_type -> hyapp.wallet.v1.UnequipUserResourceRequest
|
100, // 216: hyapp.wallet.v1.WalletService.EquipUserResource:input_type -> hyapp.wallet.v1.EquipUserResourceRequest
|
||||||
104, // 217: hyapp.wallet.v1.WalletService.BatchGetUserEquippedResources:input_type -> hyapp.wallet.v1.BatchGetUserEquippedResourcesRequest
|
102, // 217: hyapp.wallet.v1.WalletService.UnequipUserResource:input_type -> hyapp.wallet.v1.UnequipUserResourceRequest
|
||||||
107, // 218: hyapp.wallet.v1.WalletService.ListResourceGrants:input_type -> hyapp.wallet.v1.ListResourceGrantsRequest
|
104, // 218: hyapp.wallet.v1.WalletService.BatchGetUserEquippedResources:input_type -> hyapp.wallet.v1.BatchGetUserEquippedResourcesRequest
|
||||||
110, // 219: hyapp.wallet.v1.WalletService.ListResourceShopItems:input_type -> hyapp.wallet.v1.ListResourceShopItemsRequest
|
107, // 219: hyapp.wallet.v1.WalletService.ListResourceGrants:input_type -> hyapp.wallet.v1.ListResourceGrantsRequest
|
||||||
112, // 220: hyapp.wallet.v1.WalletService.UpsertResourceShopItems:input_type -> hyapp.wallet.v1.UpsertResourceShopItemsRequest
|
110, // 220: hyapp.wallet.v1.WalletService.ListResourceShopItems:input_type -> hyapp.wallet.v1.ListResourceShopItemsRequest
|
||||||
114, // 221: hyapp.wallet.v1.WalletService.SetResourceShopItemStatus:input_type -> hyapp.wallet.v1.SetResourceShopItemStatusRequest
|
112, // 221: hyapp.wallet.v1.WalletService.UpsertResourceShopItems:input_type -> hyapp.wallet.v1.UpsertResourceShopItemsRequest
|
||||||
275, // 222: hyapp.wallet.v1.WalletService.ListResourceShopPurchaseOrders:input_type -> hyapp.wallet.v1.ListResourceShopPurchaseOrdersRequest
|
114, // 222: hyapp.wallet.v1.WalletService.SetResourceShopItemStatus:input_type -> hyapp.wallet.v1.SetResourceShopItemStatusRequest
|
||||||
116, // 223: hyapp.wallet.v1.WalletService.PurchaseResourceShopItem:input_type -> hyapp.wallet.v1.PurchaseResourceShopItemRequest
|
275, // 223: hyapp.wallet.v1.WalletService.ListResourceShopPurchaseOrders:input_type -> hyapp.wallet.v1.ListResourceShopPurchaseOrdersRequest
|
||||||
119, // 224: hyapp.wallet.v1.WalletService.ListRechargeBills:input_type -> hyapp.wallet.v1.ListRechargeBillsRequest
|
116, // 224: hyapp.wallet.v1.WalletService.PurchaseResourceShopItem:input_type -> hyapp.wallet.v1.PurchaseResourceShopItemRequest
|
||||||
121, // 225: hyapp.wallet.v1.WalletService.GetRechargeBillSummary:input_type -> hyapp.wallet.v1.GetRechargeBillSummaryRequest
|
119, // 225: hyapp.wallet.v1.WalletService.ListRechargeBills:input_type -> hyapp.wallet.v1.ListRechargeBillsRequest
|
||||||
125, // 226: hyapp.wallet.v1.WalletService.BatchGetUserRechargeStats:input_type -> hyapp.wallet.v1.BatchGetUserRechargeStatsRequest
|
121, // 226: hyapp.wallet.v1.WalletService.GetRechargeBillSummary:input_type -> hyapp.wallet.v1.GetRechargeBillSummaryRequest
|
||||||
127, // 227: hyapp.wallet.v1.WalletService.GetRechargeBillOverview:input_type -> hyapp.wallet.v1.GetRechargeBillOverviewRequest
|
125, // 227: hyapp.wallet.v1.WalletService.BatchGetUserRechargeStats:input_type -> hyapp.wallet.v1.BatchGetUserRechargeStatsRequest
|
||||||
133, // 228: hyapp.wallet.v1.WalletService.RefreshGooglePaymentPrices:input_type -> hyapp.wallet.v1.RefreshGooglePaymentPricesRequest
|
127, // 228: hyapp.wallet.v1.WalletService.GetRechargeBillOverview:input_type -> hyapp.wallet.v1.GetRechargeBillOverviewRequest
|
||||||
137, // 229: hyapp.wallet.v1.WalletService.GetWalletOverview:input_type -> hyapp.wallet.v1.GetWalletOverviewRequest
|
133, // 229: hyapp.wallet.v1.WalletService.RefreshGooglePaymentPrices:input_type -> hyapp.wallet.v1.RefreshGooglePaymentPricesRequest
|
||||||
140, // 230: hyapp.wallet.v1.WalletService.GetWalletValueSummary:input_type -> hyapp.wallet.v1.GetWalletValueSummaryRequest
|
137, // 230: hyapp.wallet.v1.WalletService.GetWalletOverview:input_type -> hyapp.wallet.v1.GetWalletOverviewRequest
|
||||||
143, // 231: hyapp.wallet.v1.WalletService.GetUserGiftWall:input_type -> hyapp.wallet.v1.GetUserGiftWallRequest
|
140, // 231: hyapp.wallet.v1.WalletService.GetWalletValueSummary:input_type -> hyapp.wallet.v1.GetWalletValueSummaryRequest
|
||||||
146, // 232: hyapp.wallet.v1.WalletService.ListRechargeProducts:input_type -> hyapp.wallet.v1.ListRechargeProductsRequest
|
143, // 232: hyapp.wallet.v1.WalletService.GetUserGiftWall:input_type -> hyapp.wallet.v1.GetUserGiftWallRequest
|
||||||
148, // 233: hyapp.wallet.v1.WalletService.ConfirmGooglePayment:input_type -> hyapp.wallet.v1.ConfirmGooglePaymentRequest
|
146, // 233: hyapp.wallet.v1.WalletService.ListRechargeProducts:input_type -> hyapp.wallet.v1.ListRechargeProductsRequest
|
||||||
150, // 234: hyapp.wallet.v1.WalletService.ListAdminRechargeProducts:input_type -> hyapp.wallet.v1.ListAdminRechargeProductsRequest
|
148, // 234: hyapp.wallet.v1.WalletService.ConfirmGooglePayment:input_type -> hyapp.wallet.v1.ConfirmGooglePaymentRequest
|
||||||
152, // 235: hyapp.wallet.v1.WalletService.CreateRechargeProduct:input_type -> hyapp.wallet.v1.CreateRechargeProductRequest
|
150, // 235: hyapp.wallet.v1.WalletService.ListAdminRechargeProducts:input_type -> hyapp.wallet.v1.ListAdminRechargeProductsRequest
|
||||||
153, // 236: hyapp.wallet.v1.WalletService.UpdateRechargeProduct:input_type -> hyapp.wallet.v1.UpdateRechargeProductRequest
|
152, // 236: hyapp.wallet.v1.WalletService.CreateRechargeProduct:input_type -> hyapp.wallet.v1.CreateRechargeProductRequest
|
||||||
154, // 237: hyapp.wallet.v1.WalletService.DeleteRechargeProduct:input_type -> hyapp.wallet.v1.DeleteRechargeProductRequest
|
153, // 237: hyapp.wallet.v1.WalletService.UpdateRechargeProduct:input_type -> hyapp.wallet.v1.UpdateRechargeProductRequest
|
||||||
159, // 238: hyapp.wallet.v1.WalletService.ListThirdPartyPaymentChannels:input_type -> hyapp.wallet.v1.ListThirdPartyPaymentChannelsRequest
|
154, // 238: hyapp.wallet.v1.WalletService.DeleteRechargeProduct:input_type -> hyapp.wallet.v1.DeleteRechargeProductRequest
|
||||||
161, // 239: hyapp.wallet.v1.WalletService.SetThirdPartyPaymentMethodStatus:input_type -> hyapp.wallet.v1.SetThirdPartyPaymentMethodStatusRequest
|
159, // 239: hyapp.wallet.v1.WalletService.ListThirdPartyPaymentChannels:input_type -> hyapp.wallet.v1.ListThirdPartyPaymentChannelsRequest
|
||||||
163, // 240: hyapp.wallet.v1.WalletService.UpdateThirdPartyPaymentRate:input_type -> hyapp.wallet.v1.UpdateThirdPartyPaymentRateRequest
|
161, // 240: hyapp.wallet.v1.WalletService.SetThirdPartyPaymentMethodStatus:input_type -> hyapp.wallet.v1.SetThirdPartyPaymentMethodStatusRequest
|
||||||
165, // 241: hyapp.wallet.v1.WalletService.SyncThirdPartyPaymentMethods:input_type -> hyapp.wallet.v1.SyncThirdPartyPaymentMethodsRequest
|
163, // 241: hyapp.wallet.v1.WalletService.UpdateThirdPartyPaymentRate:input_type -> hyapp.wallet.v1.UpdateThirdPartyPaymentRateRequest
|
||||||
167, // 242: hyapp.wallet.v1.WalletService.ListH5RechargeOptions:input_type -> hyapp.wallet.v1.H5RechargeOptionsRequest
|
165, // 242: hyapp.wallet.v1.WalletService.SyncThirdPartyPaymentMethods:input_type -> hyapp.wallet.v1.SyncThirdPartyPaymentMethodsRequest
|
||||||
170, // 243: hyapp.wallet.v1.WalletService.CreateH5RechargeOrder:input_type -> hyapp.wallet.v1.CreateH5RechargeOrderRequest
|
167, // 243: hyapp.wallet.v1.WalletService.ListH5RechargeOptions:input_type -> hyapp.wallet.v1.H5RechargeOptionsRequest
|
||||||
171, // 244: hyapp.wallet.v1.WalletService.CreateTemporaryRechargeOrder:input_type -> hyapp.wallet.v1.CreateTemporaryRechargeOrderRequest
|
170, // 244: hyapp.wallet.v1.WalletService.CreateH5RechargeOrder:input_type -> hyapp.wallet.v1.CreateH5RechargeOrderRequest
|
||||||
172, // 245: hyapp.wallet.v1.WalletService.GetTemporaryRechargeOrder:input_type -> hyapp.wallet.v1.GetTemporaryRechargeOrderRequest
|
171, // 245: hyapp.wallet.v1.WalletService.CreateTemporaryRechargeOrder:input_type -> hyapp.wallet.v1.CreateTemporaryRechargeOrderRequest
|
||||||
173, // 246: hyapp.wallet.v1.WalletService.ListTemporaryRechargeOrders:input_type -> hyapp.wallet.v1.ListTemporaryRechargeOrdersRequest
|
172, // 246: hyapp.wallet.v1.WalletService.GetTemporaryRechargeOrder:input_type -> hyapp.wallet.v1.GetTemporaryRechargeOrderRequest
|
||||||
175, // 247: hyapp.wallet.v1.WalletService.SubmitH5RechargeTx:input_type -> hyapp.wallet.v1.SubmitH5RechargeTxRequest
|
173, // 247: hyapp.wallet.v1.WalletService.ListTemporaryRechargeOrders:input_type -> hyapp.wallet.v1.ListTemporaryRechargeOrdersRequest
|
||||||
176, // 248: hyapp.wallet.v1.WalletService.GetH5RechargeOrder:input_type -> hyapp.wallet.v1.GetH5RechargeOrderRequest
|
175, // 248: hyapp.wallet.v1.WalletService.SubmitH5RechargeTx:input_type -> hyapp.wallet.v1.SubmitH5RechargeTxRequest
|
||||||
178, // 249: hyapp.wallet.v1.WalletService.VerifyCoinSellerRechargeReceipt:input_type -> hyapp.wallet.v1.VerifyCoinSellerRechargeReceiptRequest
|
176, // 249: hyapp.wallet.v1.WalletService.GetH5RechargeOrder:input_type -> hyapp.wallet.v1.GetH5RechargeOrderRequest
|
||||||
180, // 250: hyapp.wallet.v1.WalletService.HandleMifapayNotify:input_type -> hyapp.wallet.v1.HandleMifapayNotifyRequest
|
178, // 250: hyapp.wallet.v1.WalletService.VerifyCoinSellerRechargeReceipt:input_type -> hyapp.wallet.v1.VerifyCoinSellerRechargeReceiptRequest
|
||||||
182, // 251: hyapp.wallet.v1.WalletService.HandleV5PayNotify:input_type -> hyapp.wallet.v1.HandleV5PayNotifyRequest
|
180, // 251: hyapp.wallet.v1.WalletService.HandleMifapayNotify:input_type -> hyapp.wallet.v1.HandleMifapayNotifyRequest
|
||||||
185, // 252: hyapp.wallet.v1.WalletService.GetDiamondExchangeConfig:input_type -> hyapp.wallet.v1.GetDiamondExchangeConfigRequest
|
182, // 252: hyapp.wallet.v1.WalletService.HandleV5PayNotify:input_type -> hyapp.wallet.v1.HandleV5PayNotifyRequest
|
||||||
188, // 253: hyapp.wallet.v1.WalletService.ListWalletTransactions:input_type -> hyapp.wallet.v1.ListWalletTransactionsRequest
|
185, // 253: hyapp.wallet.v1.WalletService.GetDiamondExchangeConfig:input_type -> hyapp.wallet.v1.GetDiamondExchangeConfigRequest
|
||||||
198, // 254: hyapp.wallet.v1.WalletService.ListVipPackages:input_type -> hyapp.wallet.v1.ListVipPackagesRequest
|
188, // 254: hyapp.wallet.v1.WalletService.ListWalletTransactions:input_type -> hyapp.wallet.v1.ListWalletTransactionsRequest
|
||||||
210, // 255: hyapp.wallet.v1.WalletService.GetVipProgramConfig:input_type -> hyapp.wallet.v1.GetVipProgramConfigRequest
|
198, // 255: hyapp.wallet.v1.WalletService.ListVipPackages:input_type -> hyapp.wallet.v1.ListVipPackagesRequest
|
||||||
200, // 256: hyapp.wallet.v1.WalletService.GetMyVip:input_type -> hyapp.wallet.v1.GetMyVipRequest
|
210, // 256: hyapp.wallet.v1.WalletService.GetVipProgramConfig:input_type -> hyapp.wallet.v1.GetVipProgramConfigRequest
|
||||||
232, // 257: hyapp.wallet.v1.WalletService.CheckVipBenefit:input_type -> hyapp.wallet.v1.CheckVipBenefitRequest
|
200, // 257: hyapp.wallet.v1.WalletService.GetMyVip:input_type -> hyapp.wallet.v1.GetMyVipRequest
|
||||||
202, // 258: hyapp.wallet.v1.WalletService.PurchaseVip:input_type -> hyapp.wallet.v1.PurchaseVipRequest
|
232, // 258: hyapp.wallet.v1.WalletService.CheckVipBenefit:input_type -> hyapp.wallet.v1.CheckVipBenefitRequest
|
||||||
204, // 259: hyapp.wallet.v1.WalletService.DebitCPBreakupFee:input_type -> hyapp.wallet.v1.DebitCPBreakupFeeRequest
|
202, // 259: hyapp.wallet.v1.WalletService.PurchaseVip:input_type -> hyapp.wallet.v1.PurchaseVipRequest
|
||||||
206, // 260: hyapp.wallet.v1.WalletService.DebitWheelDraw:input_type -> hyapp.wallet.v1.DebitWheelDrawRequest
|
204, // 260: hyapp.wallet.v1.WalletService.DebitCPBreakupFee:input_type -> hyapp.wallet.v1.DebitCPBreakupFeeRequest
|
||||||
208, // 261: hyapp.wallet.v1.WalletService.GrantVip:input_type -> hyapp.wallet.v1.GrantVipRequest
|
206, // 261: hyapp.wallet.v1.WalletService.DebitWheelDraw:input_type -> hyapp.wallet.v1.DebitWheelDrawRequest
|
||||||
226, // 262: hyapp.wallet.v1.WalletService.GrantVipTrialCard:input_type -> hyapp.wallet.v1.GrantVipTrialCardRequest
|
208, // 262: hyapp.wallet.v1.WalletService.GrantVip:input_type -> hyapp.wallet.v1.GrantVipRequest
|
||||||
228, // 263: hyapp.wallet.v1.WalletService.EquipVipTrialCard:input_type -> hyapp.wallet.v1.EquipVipTrialCardRequest
|
226, // 263: hyapp.wallet.v1.WalletService.GrantVipTrialCard:input_type -> hyapp.wallet.v1.GrantVipTrialCardRequest
|
||||||
230, // 264: hyapp.wallet.v1.WalletService.UnequipVipTrialCard:input_type -> hyapp.wallet.v1.UnequipVipTrialCardRequest
|
228, // 264: hyapp.wallet.v1.WalletService.EquipVipTrialCard:input_type -> hyapp.wallet.v1.EquipVipTrialCardRequest
|
||||||
235, // 265: hyapp.wallet.v1.WalletService.ListAdminVipLevels:input_type -> hyapp.wallet.v1.ListAdminVipLevelsRequest
|
230, // 265: hyapp.wallet.v1.WalletService.UnequipVipTrialCard:input_type -> hyapp.wallet.v1.UnequipVipTrialCardRequest
|
||||||
237, // 266: hyapp.wallet.v1.WalletService.UpdateAdminVipLevels:input_type -> hyapp.wallet.v1.UpdateAdminVipLevelsRequest
|
235, // 266: hyapp.wallet.v1.WalletService.ListAdminVipLevels:input_type -> hyapp.wallet.v1.ListAdminVipLevelsRequest
|
||||||
212, // 267: hyapp.wallet.v1.WalletService.UpdateAdminVipProgramConfig:input_type -> hyapp.wallet.v1.UpdateAdminVipProgramConfigRequest
|
237, // 267: hyapp.wallet.v1.WalletService.UpdateAdminVipLevels:input_type -> hyapp.wallet.v1.UpdateAdminVipLevelsRequest
|
||||||
214, // 268: hyapp.wallet.v1.WalletService.UpdateMyVipSettings:input_type -> hyapp.wallet.v1.UpdateMyVipSettingsRequest
|
212, // 268: hyapp.wallet.v1.WalletService.UpdateAdminVipProgramConfig:input_type -> hyapp.wallet.v1.UpdateAdminVipProgramConfigRequest
|
||||||
220, // 269: hyapp.wallet.v1.WalletService.GetMyCurrentVipDailyCoinRebate:input_type -> hyapp.wallet.v1.GetMyCurrentVipDailyCoinRebateRequest
|
214, // 269: hyapp.wallet.v1.WalletService.UpdateMyVipSettings:input_type -> hyapp.wallet.v1.UpdateMyVipSettingsRequest
|
||||||
222, // 270: hyapp.wallet.v1.WalletService.ListMyVipDailyCoinRebateStatuses:input_type -> hyapp.wallet.v1.ListMyVipDailyCoinRebateStatusesRequest
|
220, // 270: hyapp.wallet.v1.WalletService.GetMyCurrentVipDailyCoinRebate:input_type -> hyapp.wallet.v1.GetMyCurrentVipDailyCoinRebateRequest
|
||||||
224, // 271: hyapp.wallet.v1.WalletService.ClaimVipDailyCoinRebate:input_type -> hyapp.wallet.v1.ClaimVipDailyCoinRebateRequest
|
222, // 271: hyapp.wallet.v1.WalletService.ListMyVipDailyCoinRebateStatuses:input_type -> hyapp.wallet.v1.ListMyVipDailyCoinRebateStatusesRequest
|
||||||
239, // 272: hyapp.wallet.v1.WalletService.CreditTaskReward:input_type -> hyapp.wallet.v1.CreditTaskRewardRequest
|
224, // 272: hyapp.wallet.v1.WalletService.ClaimVipDailyCoinRebate:input_type -> hyapp.wallet.v1.ClaimVipDailyCoinRebateRequest
|
||||||
241, // 273: hyapp.wallet.v1.WalletService.CreditLuckyGiftReward:input_type -> hyapp.wallet.v1.CreditLuckyGiftRewardRequest
|
239, // 273: hyapp.wallet.v1.WalletService.CreditTaskReward:input_type -> hyapp.wallet.v1.CreditTaskRewardRequest
|
||||||
243, // 274: hyapp.wallet.v1.WalletService.CreditWheelReward:input_type -> hyapp.wallet.v1.CreditWheelRewardRequest
|
241, // 274: hyapp.wallet.v1.WalletService.CreditLuckyGiftReward:input_type -> hyapp.wallet.v1.CreditLuckyGiftRewardRequest
|
||||||
245, // 275: hyapp.wallet.v1.WalletService.CreditRoomTurnoverReward:input_type -> hyapp.wallet.v1.CreditRoomTurnoverRewardRequest
|
243, // 275: hyapp.wallet.v1.WalletService.CreditWheelReward:input_type -> hyapp.wallet.v1.CreditWheelRewardRequest
|
||||||
247, // 276: hyapp.wallet.v1.WalletService.CreditInviteActivityReward:input_type -> hyapp.wallet.v1.CreditInviteActivityRewardRequest
|
245, // 276: hyapp.wallet.v1.WalletService.CreditRoomTurnoverReward:input_type -> hyapp.wallet.v1.CreditRoomTurnoverRewardRequest
|
||||||
249, // 277: hyapp.wallet.v1.WalletService.CreditAgencyOpeningReward:input_type -> hyapp.wallet.v1.CreditAgencyOpeningRewardRequest
|
247, // 277: hyapp.wallet.v1.WalletService.CreditInviteActivityReward:input_type -> hyapp.wallet.v1.CreditInviteActivityRewardRequest
|
||||||
251, // 278: hyapp.wallet.v1.WalletService.ApplyGameCoinChange:input_type -> hyapp.wallet.v1.ApplyGameCoinChangeRequest
|
249, // 278: hyapp.wallet.v1.WalletService.CreditAgencyOpeningReward:input_type -> hyapp.wallet.v1.CreditAgencyOpeningRewardRequest
|
||||||
256, // 279: hyapp.wallet.v1.WalletService.GetRedPacketConfig:input_type -> hyapp.wallet.v1.GetRedPacketConfigRequest
|
251, // 279: hyapp.wallet.v1.WalletService.ApplyGameCoinChange:input_type -> hyapp.wallet.v1.ApplyGameCoinChangeRequest
|
||||||
258, // 280: hyapp.wallet.v1.WalletService.UpdateRedPacketConfig:input_type -> hyapp.wallet.v1.UpdateRedPacketConfigRequest
|
256, // 280: hyapp.wallet.v1.WalletService.GetRedPacketConfig:input_type -> hyapp.wallet.v1.GetRedPacketConfigRequest
|
||||||
260, // 281: hyapp.wallet.v1.WalletService.CreateRedPacket:input_type -> hyapp.wallet.v1.CreateRedPacketRequest
|
258, // 281: hyapp.wallet.v1.WalletService.UpdateRedPacketConfig:input_type -> hyapp.wallet.v1.UpdateRedPacketConfigRequest
|
||||||
262, // 282: hyapp.wallet.v1.WalletService.ClaimRedPacket:input_type -> hyapp.wallet.v1.ClaimRedPacketRequest
|
260, // 282: hyapp.wallet.v1.WalletService.CreateRedPacket:input_type -> hyapp.wallet.v1.CreateRedPacketRequest
|
||||||
264, // 283: hyapp.wallet.v1.WalletService.ListRedPackets:input_type -> hyapp.wallet.v1.ListRedPacketsRequest
|
262, // 283: hyapp.wallet.v1.WalletService.ClaimRedPacket:input_type -> hyapp.wallet.v1.ClaimRedPacketRequest
|
||||||
266, // 284: hyapp.wallet.v1.WalletService.GetRedPacket:input_type -> hyapp.wallet.v1.GetRedPacketRequest
|
264, // 284: hyapp.wallet.v1.WalletService.ListRedPackets:input_type -> hyapp.wallet.v1.ListRedPacketsRequest
|
||||||
268, // 285: hyapp.wallet.v1.WalletService.ExpireRedPackets:input_type -> hyapp.wallet.v1.ExpireRedPacketsRequest
|
266, // 285: hyapp.wallet.v1.WalletService.GetRedPacket:input_type -> hyapp.wallet.v1.GetRedPacketRequest
|
||||||
270, // 286: hyapp.wallet.v1.WalletService.RetryRedPacketRefund:input_type -> hyapp.wallet.v1.RetryRedPacketRefundRequest
|
268, // 286: hyapp.wallet.v1.WalletService.ExpireRedPackets:input_type -> hyapp.wallet.v1.ExpireRedPacketsRequest
|
||||||
273, // 287: hyapp.wallet.v1.WalletCronService.ProcessHostSalaryDailySettlementBatch:output_type -> hyapp.wallet.v1.CronBatchResponse
|
270, // 287: hyapp.wallet.v1.WalletService.RetryRedPacketRefund:input_type -> hyapp.wallet.v1.RetryRedPacketRefundRequest
|
||||||
273, // 288: hyapp.wallet.v1.WalletCronService.ProcessHostSalaryHalfMonthSettlementBatch:output_type -> hyapp.wallet.v1.CronBatchResponse
|
273, // 288: hyapp.wallet.v1.WalletCronService.ProcessHostSalaryDailySettlementBatch:output_type -> hyapp.wallet.v1.CronBatchResponse
|
||||||
273, // 289: hyapp.wallet.v1.WalletCronService.ProcessHostSalaryMonthEndBatch:output_type -> hyapp.wallet.v1.CronBatchResponse
|
273, // 289: hyapp.wallet.v1.WalletCronService.ProcessHostSalaryHalfMonthSettlementBatch:output_type -> hyapp.wallet.v1.CronBatchResponse
|
||||||
219, // 290: hyapp.wallet.v1.WalletCronService.ProcessVipDailyCoinRebateBatch:output_type -> hyapp.wallet.v1.ProcessVipDailyCoinRebateBatchResponse
|
273, // 290: hyapp.wallet.v1.WalletCronService.ProcessHostSalaryMonthEndBatch:output_type -> hyapp.wallet.v1.CronBatchResponse
|
||||||
2, // 291: hyapp.wallet.v1.WalletService.DebitGift:output_type -> hyapp.wallet.v1.DebitGiftResponse
|
219, // 291: hyapp.wallet.v1.WalletCronService.ProcessVipDailyCoinRebateBatch:output_type -> hyapp.wallet.v1.ProcessVipDailyCoinRebateBatchResponse
|
||||||
2, // 292: hyapp.wallet.v1.WalletService.DebitDirectGift:output_type -> hyapp.wallet.v1.DebitGiftResponse
|
2, // 292: hyapp.wallet.v1.WalletService.DebitGift:output_type -> hyapp.wallet.v1.DebitGiftResponse
|
||||||
6, // 293: hyapp.wallet.v1.WalletService.BatchDebitGift:output_type -> hyapp.wallet.v1.BatchDebitGiftResponse
|
2, // 293: hyapp.wallet.v1.WalletService.DebitDirectGift:output_type -> hyapp.wallet.v1.DebitGiftResponse
|
||||||
2, // 294: hyapp.wallet.v1.WalletService.DebitRobotGift:output_type -> hyapp.wallet.v1.DebitGiftResponse
|
6, // 294: hyapp.wallet.v1.WalletService.BatchDebitGift:output_type -> hyapp.wallet.v1.BatchDebitGiftResponse
|
||||||
10, // 295: hyapp.wallet.v1.WalletService.GetBalances:output_type -> hyapp.wallet.v1.GetBalancesResponse
|
2, // 295: hyapp.wallet.v1.WalletService.DebitRobotGift:output_type -> hyapp.wallet.v1.DebitGiftResponse
|
||||||
14, // 296: hyapp.wallet.v1.WalletService.GetActiveHostSalaryPolicy:output_type -> hyapp.wallet.v1.GetActiveHostSalaryPolicyResponse
|
10, // 296: hyapp.wallet.v1.WalletService.GetBalances:output_type -> hyapp.wallet.v1.GetBalancesResponse
|
||||||
17, // 297: hyapp.wallet.v1.WalletService.GetHostSalaryProgress:output_type -> hyapp.wallet.v1.GetHostSalaryProgressResponse
|
14, // 297: hyapp.wallet.v1.WalletService.GetActiveHostSalaryPolicy:output_type -> hyapp.wallet.v1.GetActiveHostSalaryPolicyResponse
|
||||||
281, // 298: hyapp.wallet.v1.WalletService.GetHostRevenueStats:output_type -> hyapp.wallet.v1.GetHostRevenueStatsResponse
|
17, // 298: hyapp.wallet.v1.WalletService.GetHostSalaryProgress:output_type -> hyapp.wallet.v1.GetHostSalaryProgressResponse
|
||||||
284, // 299: hyapp.wallet.v1.WalletService.GetAgencyPointShareStats:output_type -> hyapp.wallet.v1.GetAgencyPointShareStatsResponse
|
281, // 299: hyapp.wallet.v1.WalletService.GetHostRevenueStats:output_type -> hyapp.wallet.v1.GetHostRevenueStatsResponse
|
||||||
287, // 300: hyapp.wallet.v1.WalletService.GetAgencyHostGiftStats:output_type -> hyapp.wallet.v1.GetAgencyHostGiftStatsResponse
|
284, // 300: hyapp.wallet.v1.WalletService.GetAgencyPointShareStats:output_type -> hyapp.wallet.v1.GetAgencyPointShareStatsResponse
|
||||||
20, // 301: hyapp.wallet.v1.WalletService.GetTeamHostSalaryStats:output_type -> hyapp.wallet.v1.GetTeamHostSalaryStatsResponse
|
287, // 301: hyapp.wallet.v1.WalletService.GetAgencyHostGiftStats:output_type -> hyapp.wallet.v1.GetAgencyHostGiftStatsResponse
|
||||||
22, // 302: hyapp.wallet.v1.WalletService.AdminCreditAsset:output_type -> hyapp.wallet.v1.AdminCreditAssetResponse
|
20, // 302: hyapp.wallet.v1.WalletService.GetTeamHostSalaryStats:output_type -> hyapp.wallet.v1.GetTeamHostSalaryStatsResponse
|
||||||
24, // 303: hyapp.wallet.v1.WalletService.AdminCreditCoinSellerStock:output_type -> hyapp.wallet.v1.AdminCreditCoinSellerStockResponse
|
22, // 303: hyapp.wallet.v1.WalletService.AdminCreditAsset:output_type -> hyapp.wallet.v1.AdminCreditAssetResponse
|
||||||
26, // 304: hyapp.wallet.v1.WalletService.TransferCoinFromSeller:output_type -> hyapp.wallet.v1.TransferCoinFromSellerResponse
|
24, // 304: hyapp.wallet.v1.WalletService.AdminCreditCoinSellerStock:output_type -> hyapp.wallet.v1.AdminCreditCoinSellerStockResponse
|
||||||
28, // 305: hyapp.wallet.v1.WalletService.TransferCoinSellerStockToChild:output_type -> hyapp.wallet.v1.TransferCoinSellerStockToChildResponse
|
26, // 305: hyapp.wallet.v1.WalletService.TransferCoinFromSeller:output_type -> hyapp.wallet.v1.TransferCoinFromSellerResponse
|
||||||
31, // 306: hyapp.wallet.v1.WalletService.ListCoinSellerSalaryExchangeRateTiers:output_type -> hyapp.wallet.v1.ListCoinSellerSalaryExchangeRateTiersResponse
|
28, // 306: hyapp.wallet.v1.WalletService.TransferCoinSellerStockToChild:output_type -> hyapp.wallet.v1.TransferCoinSellerStockToChildResponse
|
||||||
33, // 307: hyapp.wallet.v1.WalletService.ExchangeSalaryToCoin:output_type -> hyapp.wallet.v1.ExchangeSalaryToCoinResponse
|
31, // 307: hyapp.wallet.v1.WalletService.ListCoinSellerSalaryExchangeRateTiers:output_type -> hyapp.wallet.v1.ListCoinSellerSalaryExchangeRateTiersResponse
|
||||||
35, // 308: hyapp.wallet.v1.WalletService.TransferSalaryToCoinSeller:output_type -> hyapp.wallet.v1.TransferSalaryToCoinSellerResponse
|
33, // 308: hyapp.wallet.v1.WalletService.ExchangeSalaryToCoin:output_type -> hyapp.wallet.v1.ExchangeSalaryToCoinResponse
|
||||||
290, // 309: hyapp.wallet.v1.WalletService.ListPointWithdrawalCoinSellers:output_type -> hyapp.wallet.v1.ListPointWithdrawalCoinSellersResponse
|
35, // 309: hyapp.wallet.v1.WalletService.TransferSalaryToCoinSeller:output_type -> hyapp.wallet.v1.TransferSalaryToCoinSellerResponse
|
||||||
292, // 310: hyapp.wallet.v1.WalletService.TransferPointToCoinSeller:output_type -> hyapp.wallet.v1.TransferPointToCoinSellerResponse
|
290, // 310: hyapp.wallet.v1.WalletService.ListPointWithdrawalCoinSellers:output_type -> hyapp.wallet.v1.ListPointWithdrawalCoinSellersResponse
|
||||||
294, // 311: hyapp.wallet.v1.WalletService.GetPointWithdrawalConfig:output_type -> hyapp.wallet.v1.GetPointWithdrawalConfigResponse
|
292, // 311: hyapp.wallet.v1.WalletService.TransferPointToCoinSeller:output_type -> hyapp.wallet.v1.TransferPointToCoinSellerResponse
|
||||||
37, // 312: hyapp.wallet.v1.WalletService.FreezeSalaryWithdrawal:output_type -> hyapp.wallet.v1.FreezeSalaryWithdrawalResponse
|
294, // 312: hyapp.wallet.v1.WalletService.GetPointWithdrawalConfig:output_type -> hyapp.wallet.v1.GetPointWithdrawalConfigResponse
|
||||||
39, // 313: hyapp.wallet.v1.WalletService.SettleSalaryWithdrawal:output_type -> hyapp.wallet.v1.SettleSalaryWithdrawalResponse
|
37, // 313: hyapp.wallet.v1.WalletService.FreezeSalaryWithdrawal:output_type -> hyapp.wallet.v1.FreezeSalaryWithdrawalResponse
|
||||||
41, // 314: hyapp.wallet.v1.WalletService.ReleaseSalaryWithdrawal:output_type -> hyapp.wallet.v1.ReleaseSalaryWithdrawalResponse
|
39, // 314: hyapp.wallet.v1.WalletService.SettleSalaryWithdrawal:output_type -> hyapp.wallet.v1.SettleSalaryWithdrawalResponse
|
||||||
43, // 315: hyapp.wallet.v1.WalletService.FreezePointWithdrawal:output_type -> hyapp.wallet.v1.FreezePointWithdrawalResponse
|
41, // 315: hyapp.wallet.v1.WalletService.ReleaseSalaryWithdrawal:output_type -> hyapp.wallet.v1.ReleaseSalaryWithdrawalResponse
|
||||||
45, // 316: hyapp.wallet.v1.WalletService.SettlePointWithdrawal:output_type -> hyapp.wallet.v1.SettlePointWithdrawalResponse
|
43, // 316: hyapp.wallet.v1.WalletService.FreezePointWithdrawal:output_type -> hyapp.wallet.v1.FreezePointWithdrawalResponse
|
||||||
47, // 317: hyapp.wallet.v1.WalletService.ReleasePointWithdrawal:output_type -> hyapp.wallet.v1.ReleasePointWithdrawalResponse
|
45, // 317: hyapp.wallet.v1.WalletService.SettlePointWithdrawal:output_type -> hyapp.wallet.v1.SettlePointWithdrawalResponse
|
||||||
62, // 318: hyapp.wallet.v1.WalletService.ListResources:output_type -> hyapp.wallet.v1.ListResourcesResponse
|
47, // 318: hyapp.wallet.v1.WalletService.ReleasePointWithdrawal:output_type -> hyapp.wallet.v1.ReleasePointWithdrawalResponse
|
||||||
64, // 319: hyapp.wallet.v1.WalletService.GetResource:output_type -> hyapp.wallet.v1.GetResourceResponse
|
62, // 319: hyapp.wallet.v1.WalletService.ListResources:output_type -> hyapp.wallet.v1.ListResourcesResponse
|
||||||
71, // 320: hyapp.wallet.v1.WalletService.CreateResource:output_type -> hyapp.wallet.v1.ResourceResponse
|
64, // 320: hyapp.wallet.v1.WalletService.GetResource:output_type -> hyapp.wallet.v1.GetResourceResponse
|
||||||
71, // 321: hyapp.wallet.v1.WalletService.UpdateResource:output_type -> hyapp.wallet.v1.ResourceResponse
|
71, // 321: hyapp.wallet.v1.WalletService.CreateResource:output_type -> hyapp.wallet.v1.ResourceResponse
|
||||||
71, // 322: hyapp.wallet.v1.WalletService.SetResourceStatus:output_type -> hyapp.wallet.v1.ResourceResponse
|
71, // 322: hyapp.wallet.v1.WalletService.UpdateResource:output_type -> hyapp.wallet.v1.ResourceResponse
|
||||||
71, // 323: hyapp.wallet.v1.WalletService.DeleteResource:output_type -> hyapp.wallet.v1.ResourceResponse
|
71, // 323: hyapp.wallet.v1.WalletService.SetResourceStatus:output_type -> hyapp.wallet.v1.ResourceResponse
|
||||||
70, // 324: hyapp.wallet.v1.WalletService.BatchDeleteResources:output_type -> hyapp.wallet.v1.BatchDeleteResourcesResponse
|
71, // 324: hyapp.wallet.v1.WalletService.DeleteResource:output_type -> hyapp.wallet.v1.ResourceResponse
|
||||||
73, // 325: hyapp.wallet.v1.WalletService.ListResourceGroups:output_type -> hyapp.wallet.v1.ListResourceGroupsResponse
|
70, // 325: hyapp.wallet.v1.WalletService.BatchDeleteResources:output_type -> hyapp.wallet.v1.BatchDeleteResourcesResponse
|
||||||
75, // 326: hyapp.wallet.v1.WalletService.GetResourceGroup:output_type -> hyapp.wallet.v1.GetResourceGroupResponse
|
73, // 326: hyapp.wallet.v1.WalletService.ListResourceGroups:output_type -> hyapp.wallet.v1.ListResourceGroupsResponse
|
||||||
79, // 327: hyapp.wallet.v1.WalletService.CreateResourceGroup:output_type -> hyapp.wallet.v1.ResourceGroupResponse
|
75, // 327: hyapp.wallet.v1.WalletService.GetResourceGroup:output_type -> hyapp.wallet.v1.GetResourceGroupResponse
|
||||||
79, // 328: hyapp.wallet.v1.WalletService.UpdateResourceGroup:output_type -> hyapp.wallet.v1.ResourceGroupResponse
|
79, // 328: hyapp.wallet.v1.WalletService.CreateResourceGroup:output_type -> hyapp.wallet.v1.ResourceGroupResponse
|
||||||
79, // 329: hyapp.wallet.v1.WalletService.SetResourceGroupStatus:output_type -> hyapp.wallet.v1.ResourceGroupResponse
|
79, // 329: hyapp.wallet.v1.WalletService.UpdateResourceGroup:output_type -> hyapp.wallet.v1.ResourceGroupResponse
|
||||||
53, // 330: hyapp.wallet.v1.WalletService.PinResourceGroupSnapshot:output_type -> hyapp.wallet.v1.PinResourceGroupSnapshotResponse
|
79, // 330: hyapp.wallet.v1.WalletService.SetResourceGroupStatus:output_type -> hyapp.wallet.v1.ResourceGroupResponse
|
||||||
81, // 331: hyapp.wallet.v1.WalletService.ListGiftConfigs:output_type -> hyapp.wallet.v1.ListGiftConfigsResponse
|
53, // 331: hyapp.wallet.v1.WalletService.PinResourceGroupSnapshot:output_type -> hyapp.wallet.v1.PinResourceGroupSnapshotResponse
|
||||||
83, // 332: hyapp.wallet.v1.WalletService.ListGiftTypeConfigs:output_type -> hyapp.wallet.v1.ListGiftTypeConfigsResponse
|
81, // 332: hyapp.wallet.v1.WalletService.ListGiftConfigs:output_type -> hyapp.wallet.v1.ListGiftConfigsResponse
|
||||||
278, // 333: hyapp.wallet.v1.WalletService.GetGiftCatalogVersion:output_type -> hyapp.wallet.v1.GetGiftCatalogVersionResponse
|
83, // 333: hyapp.wallet.v1.WalletService.ListGiftTypeConfigs:output_type -> hyapp.wallet.v1.ListGiftTypeConfigsResponse
|
||||||
92, // 334: hyapp.wallet.v1.WalletService.CreateGiftConfig:output_type -> hyapp.wallet.v1.GiftConfigResponse
|
278, // 334: hyapp.wallet.v1.WalletService.GetGiftCatalogVersion:output_type -> hyapp.wallet.v1.GetGiftCatalogVersionResponse
|
||||||
88, // 335: hyapp.wallet.v1.WalletService.BatchCreateGiftConfigs:output_type -> hyapp.wallet.v1.BatchCreateGiftConfigsResponse
|
92, // 335: hyapp.wallet.v1.WalletService.CreateGiftConfig:output_type -> hyapp.wallet.v1.GiftConfigResponse
|
||||||
92, // 336: hyapp.wallet.v1.WalletService.UpdateGiftConfig:output_type -> hyapp.wallet.v1.GiftConfigResponse
|
88, // 336: hyapp.wallet.v1.WalletService.BatchCreateGiftConfigs:output_type -> hyapp.wallet.v1.BatchCreateGiftConfigsResponse
|
||||||
92, // 337: hyapp.wallet.v1.WalletService.SetGiftConfigStatus:output_type -> hyapp.wallet.v1.GiftConfigResponse
|
92, // 337: hyapp.wallet.v1.WalletService.UpdateGiftConfig:output_type -> hyapp.wallet.v1.GiftConfigResponse
|
||||||
92, // 338: hyapp.wallet.v1.WalletService.DeleteGiftConfig:output_type -> hyapp.wallet.v1.GiftConfigResponse
|
92, // 338: hyapp.wallet.v1.WalletService.SetGiftConfigStatus:output_type -> hyapp.wallet.v1.GiftConfigResponse
|
||||||
85, // 339: hyapp.wallet.v1.WalletService.UpsertGiftTypeConfig:output_type -> hyapp.wallet.v1.GiftTypeConfigResponse
|
92, // 339: hyapp.wallet.v1.WalletService.DeleteGiftConfig:output_type -> hyapp.wallet.v1.GiftConfigResponse
|
||||||
97, // 340: hyapp.wallet.v1.WalletService.GrantResource:output_type -> hyapp.wallet.v1.ResourceGrantResponse
|
85, // 340: hyapp.wallet.v1.WalletService.UpsertGiftTypeConfig:output_type -> hyapp.wallet.v1.GiftTypeConfigResponse
|
||||||
97, // 341: hyapp.wallet.v1.WalletService.GrantResourceGroup:output_type -> hyapp.wallet.v1.ResourceGrantResponse
|
97, // 341: hyapp.wallet.v1.WalletService.GrantResource:output_type -> hyapp.wallet.v1.ResourceGrantResponse
|
||||||
97, // 342: hyapp.wallet.v1.WalletService.GrantPinnedResourceGroup:output_type -> hyapp.wallet.v1.ResourceGrantResponse
|
97, // 342: hyapp.wallet.v1.WalletService.GrantResourceGroup:output_type -> hyapp.wallet.v1.ResourceGrantResponse
|
||||||
97, // 343: hyapp.wallet.v1.WalletService.RevokeResourceGrant:output_type -> hyapp.wallet.v1.ResourceGrantResponse
|
97, // 343: hyapp.wallet.v1.WalletService.GrantPinnedResourceGroup:output_type -> hyapp.wallet.v1.ResourceGrantResponse
|
||||||
299, // 344: hyapp.wallet.v1.WalletService.RevokeUserResource:output_type -> hyapp.wallet.v1.RevokeUserResourceResponse
|
97, // 344: hyapp.wallet.v1.WalletService.RevokeResourceGrant:output_type -> hyapp.wallet.v1.ResourceGrantResponse
|
||||||
99, // 345: hyapp.wallet.v1.WalletService.ListUserResources:output_type -> hyapp.wallet.v1.ListUserResourcesResponse
|
299, // 345: hyapp.wallet.v1.WalletService.RevokeUserResource:output_type -> hyapp.wallet.v1.RevokeUserResourceResponse
|
||||||
101, // 346: hyapp.wallet.v1.WalletService.EquipUserResource:output_type -> hyapp.wallet.v1.EquipUserResourceResponse
|
99, // 346: hyapp.wallet.v1.WalletService.ListUserResources:output_type -> hyapp.wallet.v1.ListUserResourcesResponse
|
||||||
103, // 347: hyapp.wallet.v1.WalletService.UnequipUserResource:output_type -> hyapp.wallet.v1.UnequipUserResourceResponse
|
101, // 347: hyapp.wallet.v1.WalletService.EquipUserResource:output_type -> hyapp.wallet.v1.EquipUserResourceResponse
|
||||||
106, // 348: hyapp.wallet.v1.WalletService.BatchGetUserEquippedResources:output_type -> hyapp.wallet.v1.BatchGetUserEquippedResourcesResponse
|
103, // 348: hyapp.wallet.v1.WalletService.UnequipUserResource:output_type -> hyapp.wallet.v1.UnequipUserResourceResponse
|
||||||
108, // 349: hyapp.wallet.v1.WalletService.ListResourceGrants:output_type -> hyapp.wallet.v1.ListResourceGrantsResponse
|
106, // 349: hyapp.wallet.v1.WalletService.BatchGetUserEquippedResources:output_type -> hyapp.wallet.v1.BatchGetUserEquippedResourcesResponse
|
||||||
111, // 350: hyapp.wallet.v1.WalletService.ListResourceShopItems:output_type -> hyapp.wallet.v1.ListResourceShopItemsResponse
|
108, // 350: hyapp.wallet.v1.WalletService.ListResourceGrants:output_type -> hyapp.wallet.v1.ListResourceGrantsResponse
|
||||||
113, // 351: hyapp.wallet.v1.WalletService.UpsertResourceShopItems:output_type -> hyapp.wallet.v1.UpsertResourceShopItemsResponse
|
111, // 351: hyapp.wallet.v1.WalletService.ListResourceShopItems:output_type -> hyapp.wallet.v1.ListResourceShopItemsResponse
|
||||||
115, // 352: hyapp.wallet.v1.WalletService.SetResourceShopItemStatus:output_type -> hyapp.wallet.v1.ResourceShopItemResponse
|
113, // 352: hyapp.wallet.v1.WalletService.UpsertResourceShopItems:output_type -> hyapp.wallet.v1.UpsertResourceShopItemsResponse
|
||||||
276, // 353: hyapp.wallet.v1.WalletService.ListResourceShopPurchaseOrders:output_type -> hyapp.wallet.v1.ListResourceShopPurchaseOrdersResponse
|
115, // 353: hyapp.wallet.v1.WalletService.SetResourceShopItemStatus:output_type -> hyapp.wallet.v1.ResourceShopItemResponse
|
||||||
117, // 354: hyapp.wallet.v1.WalletService.PurchaseResourceShopItem:output_type -> hyapp.wallet.v1.PurchaseResourceShopItemResponse
|
276, // 354: hyapp.wallet.v1.WalletService.ListResourceShopPurchaseOrders:output_type -> hyapp.wallet.v1.ListResourceShopPurchaseOrdersResponse
|
||||||
120, // 355: hyapp.wallet.v1.WalletService.ListRechargeBills:output_type -> hyapp.wallet.v1.ListRechargeBillsResponse
|
117, // 355: hyapp.wallet.v1.WalletService.PurchaseResourceShopItem:output_type -> hyapp.wallet.v1.PurchaseResourceShopItemResponse
|
||||||
123, // 356: hyapp.wallet.v1.WalletService.GetRechargeBillSummary:output_type -> hyapp.wallet.v1.GetRechargeBillSummaryResponse
|
120, // 356: hyapp.wallet.v1.WalletService.ListRechargeBills:output_type -> hyapp.wallet.v1.ListRechargeBillsResponse
|
||||||
126, // 357: hyapp.wallet.v1.WalletService.BatchGetUserRechargeStats:output_type -> hyapp.wallet.v1.BatchGetUserRechargeStatsResponse
|
123, // 357: hyapp.wallet.v1.WalletService.GetRechargeBillSummary:output_type -> hyapp.wallet.v1.GetRechargeBillSummaryResponse
|
||||||
132, // 358: hyapp.wallet.v1.WalletService.GetRechargeBillOverview:output_type -> hyapp.wallet.v1.GetRechargeBillOverviewResponse
|
126, // 358: hyapp.wallet.v1.WalletService.BatchGetUserRechargeStats:output_type -> hyapp.wallet.v1.BatchGetUserRechargeStatsResponse
|
||||||
135, // 359: hyapp.wallet.v1.WalletService.RefreshGooglePaymentPrices:output_type -> hyapp.wallet.v1.RefreshGooglePaymentPricesResponse
|
132, // 359: hyapp.wallet.v1.WalletService.GetRechargeBillOverview:output_type -> hyapp.wallet.v1.GetRechargeBillOverviewResponse
|
||||||
138, // 360: hyapp.wallet.v1.WalletService.GetWalletOverview:output_type -> hyapp.wallet.v1.GetWalletOverviewResponse
|
135, // 360: hyapp.wallet.v1.WalletService.RefreshGooglePaymentPrices:output_type -> hyapp.wallet.v1.RefreshGooglePaymentPricesResponse
|
||||||
141, // 361: hyapp.wallet.v1.WalletService.GetWalletValueSummary:output_type -> hyapp.wallet.v1.GetWalletValueSummaryResponse
|
138, // 361: hyapp.wallet.v1.WalletService.GetWalletOverview:output_type -> hyapp.wallet.v1.GetWalletOverviewResponse
|
||||||
144, // 362: hyapp.wallet.v1.WalletService.GetUserGiftWall:output_type -> hyapp.wallet.v1.GetUserGiftWallResponse
|
141, // 362: hyapp.wallet.v1.WalletService.GetWalletValueSummary:output_type -> hyapp.wallet.v1.GetWalletValueSummaryResponse
|
||||||
147, // 363: hyapp.wallet.v1.WalletService.ListRechargeProducts:output_type -> hyapp.wallet.v1.ListRechargeProductsResponse
|
144, // 363: hyapp.wallet.v1.WalletService.GetUserGiftWall:output_type -> hyapp.wallet.v1.GetUserGiftWallResponse
|
||||||
149, // 364: hyapp.wallet.v1.WalletService.ConfirmGooglePayment:output_type -> hyapp.wallet.v1.ConfirmGooglePaymentResponse
|
147, // 364: hyapp.wallet.v1.WalletService.ListRechargeProducts:output_type -> hyapp.wallet.v1.ListRechargeProductsResponse
|
||||||
151, // 365: hyapp.wallet.v1.WalletService.ListAdminRechargeProducts:output_type -> hyapp.wallet.v1.ListAdminRechargeProductsResponse
|
149, // 365: hyapp.wallet.v1.WalletService.ConfirmGooglePayment:output_type -> hyapp.wallet.v1.ConfirmGooglePaymentResponse
|
||||||
155, // 366: hyapp.wallet.v1.WalletService.CreateRechargeProduct:output_type -> hyapp.wallet.v1.RechargeProductResponse
|
151, // 366: hyapp.wallet.v1.WalletService.ListAdminRechargeProducts:output_type -> hyapp.wallet.v1.ListAdminRechargeProductsResponse
|
||||||
155, // 367: hyapp.wallet.v1.WalletService.UpdateRechargeProduct:output_type -> hyapp.wallet.v1.RechargeProductResponse
|
155, // 367: hyapp.wallet.v1.WalletService.CreateRechargeProduct:output_type -> hyapp.wallet.v1.RechargeProductResponse
|
||||||
156, // 368: hyapp.wallet.v1.WalletService.DeleteRechargeProduct:output_type -> hyapp.wallet.v1.DeleteRechargeProductResponse
|
155, // 368: hyapp.wallet.v1.WalletService.UpdateRechargeProduct:output_type -> hyapp.wallet.v1.RechargeProductResponse
|
||||||
160, // 369: hyapp.wallet.v1.WalletService.ListThirdPartyPaymentChannels:output_type -> hyapp.wallet.v1.ListThirdPartyPaymentChannelsResponse
|
156, // 369: hyapp.wallet.v1.WalletService.DeleteRechargeProduct:output_type -> hyapp.wallet.v1.DeleteRechargeProductResponse
|
||||||
162, // 370: hyapp.wallet.v1.WalletService.SetThirdPartyPaymentMethodStatus:output_type -> hyapp.wallet.v1.ThirdPartyPaymentMethodResponse
|
160, // 370: hyapp.wallet.v1.WalletService.ListThirdPartyPaymentChannels:output_type -> hyapp.wallet.v1.ListThirdPartyPaymentChannelsResponse
|
||||||
162, // 371: hyapp.wallet.v1.WalletService.UpdateThirdPartyPaymentRate:output_type -> hyapp.wallet.v1.ThirdPartyPaymentMethodResponse
|
162, // 371: hyapp.wallet.v1.WalletService.SetThirdPartyPaymentMethodStatus:output_type -> hyapp.wallet.v1.ThirdPartyPaymentMethodResponse
|
||||||
166, // 372: hyapp.wallet.v1.WalletService.SyncThirdPartyPaymentMethods:output_type -> hyapp.wallet.v1.SyncThirdPartyPaymentMethodsResponse
|
162, // 372: hyapp.wallet.v1.WalletService.UpdateThirdPartyPaymentRate:output_type -> hyapp.wallet.v1.ThirdPartyPaymentMethodResponse
|
||||||
168, // 373: hyapp.wallet.v1.WalletService.ListH5RechargeOptions:output_type -> hyapp.wallet.v1.H5RechargeOptionsResponse
|
166, // 373: hyapp.wallet.v1.WalletService.SyncThirdPartyPaymentMethods:output_type -> hyapp.wallet.v1.SyncThirdPartyPaymentMethodsResponse
|
||||||
177, // 374: hyapp.wallet.v1.WalletService.CreateH5RechargeOrder:output_type -> hyapp.wallet.v1.H5RechargeOrderResponse
|
168, // 374: hyapp.wallet.v1.WalletService.ListH5RechargeOptions:output_type -> hyapp.wallet.v1.H5RechargeOptionsResponse
|
||||||
177, // 375: hyapp.wallet.v1.WalletService.CreateTemporaryRechargeOrder:output_type -> hyapp.wallet.v1.H5RechargeOrderResponse
|
177, // 375: hyapp.wallet.v1.WalletService.CreateH5RechargeOrder:output_type -> hyapp.wallet.v1.H5RechargeOrderResponse
|
||||||
177, // 376: hyapp.wallet.v1.WalletService.GetTemporaryRechargeOrder:output_type -> hyapp.wallet.v1.H5RechargeOrderResponse
|
177, // 376: hyapp.wallet.v1.WalletService.CreateTemporaryRechargeOrder:output_type -> hyapp.wallet.v1.H5RechargeOrderResponse
|
||||||
174, // 377: hyapp.wallet.v1.WalletService.ListTemporaryRechargeOrders:output_type -> hyapp.wallet.v1.ListTemporaryRechargeOrdersResponse
|
177, // 377: hyapp.wallet.v1.WalletService.GetTemporaryRechargeOrder:output_type -> hyapp.wallet.v1.H5RechargeOrderResponse
|
||||||
177, // 378: hyapp.wallet.v1.WalletService.SubmitH5RechargeTx:output_type -> hyapp.wallet.v1.H5RechargeOrderResponse
|
174, // 378: hyapp.wallet.v1.WalletService.ListTemporaryRechargeOrders:output_type -> hyapp.wallet.v1.ListTemporaryRechargeOrdersResponse
|
||||||
177, // 379: hyapp.wallet.v1.WalletService.GetH5RechargeOrder:output_type -> hyapp.wallet.v1.H5RechargeOrderResponse
|
177, // 379: hyapp.wallet.v1.WalletService.SubmitH5RechargeTx:output_type -> hyapp.wallet.v1.H5RechargeOrderResponse
|
||||||
179, // 380: hyapp.wallet.v1.WalletService.VerifyCoinSellerRechargeReceipt:output_type -> hyapp.wallet.v1.VerifyCoinSellerRechargeReceiptResponse
|
177, // 380: hyapp.wallet.v1.WalletService.GetH5RechargeOrder:output_type -> hyapp.wallet.v1.H5RechargeOrderResponse
|
||||||
181, // 381: hyapp.wallet.v1.WalletService.HandleMifapayNotify:output_type -> hyapp.wallet.v1.HandleMifapayNotifyResponse
|
179, // 381: hyapp.wallet.v1.WalletService.VerifyCoinSellerRechargeReceipt:output_type -> hyapp.wallet.v1.VerifyCoinSellerRechargeReceiptResponse
|
||||||
183, // 382: hyapp.wallet.v1.WalletService.HandleV5PayNotify:output_type -> hyapp.wallet.v1.HandleV5PayNotifyResponse
|
181, // 382: hyapp.wallet.v1.WalletService.HandleMifapayNotify:output_type -> hyapp.wallet.v1.HandleMifapayNotifyResponse
|
||||||
186, // 383: hyapp.wallet.v1.WalletService.GetDiamondExchangeConfig:output_type -> hyapp.wallet.v1.GetDiamondExchangeConfigResponse
|
183, // 383: hyapp.wallet.v1.WalletService.HandleV5PayNotify:output_type -> hyapp.wallet.v1.HandleV5PayNotifyResponse
|
||||||
189, // 384: hyapp.wallet.v1.WalletService.ListWalletTransactions:output_type -> hyapp.wallet.v1.ListWalletTransactionsResponse
|
186, // 384: hyapp.wallet.v1.WalletService.GetDiamondExchangeConfig:output_type -> hyapp.wallet.v1.GetDiamondExchangeConfigResponse
|
||||||
199, // 385: hyapp.wallet.v1.WalletService.ListVipPackages:output_type -> hyapp.wallet.v1.ListVipPackagesResponse
|
189, // 385: hyapp.wallet.v1.WalletService.ListWalletTransactions:output_type -> hyapp.wallet.v1.ListWalletTransactionsResponse
|
||||||
211, // 386: hyapp.wallet.v1.WalletService.GetVipProgramConfig:output_type -> hyapp.wallet.v1.GetVipProgramConfigResponse
|
199, // 386: hyapp.wallet.v1.WalletService.ListVipPackages:output_type -> hyapp.wallet.v1.ListVipPackagesResponse
|
||||||
201, // 387: hyapp.wallet.v1.WalletService.GetMyVip:output_type -> hyapp.wallet.v1.GetMyVipResponse
|
211, // 387: hyapp.wallet.v1.WalletService.GetVipProgramConfig:output_type -> hyapp.wallet.v1.GetVipProgramConfigResponse
|
||||||
233, // 388: hyapp.wallet.v1.WalletService.CheckVipBenefit:output_type -> hyapp.wallet.v1.CheckVipBenefitResponse
|
201, // 388: hyapp.wallet.v1.WalletService.GetMyVip:output_type -> hyapp.wallet.v1.GetMyVipResponse
|
||||||
203, // 389: hyapp.wallet.v1.WalletService.PurchaseVip:output_type -> hyapp.wallet.v1.PurchaseVipResponse
|
233, // 389: hyapp.wallet.v1.WalletService.CheckVipBenefit:output_type -> hyapp.wallet.v1.CheckVipBenefitResponse
|
||||||
205, // 390: hyapp.wallet.v1.WalletService.DebitCPBreakupFee:output_type -> hyapp.wallet.v1.DebitCPBreakupFeeResponse
|
203, // 390: hyapp.wallet.v1.WalletService.PurchaseVip:output_type -> hyapp.wallet.v1.PurchaseVipResponse
|
||||||
207, // 391: hyapp.wallet.v1.WalletService.DebitWheelDraw:output_type -> hyapp.wallet.v1.DebitWheelDrawResponse
|
205, // 391: hyapp.wallet.v1.WalletService.DebitCPBreakupFee:output_type -> hyapp.wallet.v1.DebitCPBreakupFeeResponse
|
||||||
209, // 392: hyapp.wallet.v1.WalletService.GrantVip:output_type -> hyapp.wallet.v1.GrantVipResponse
|
207, // 392: hyapp.wallet.v1.WalletService.DebitWheelDraw:output_type -> hyapp.wallet.v1.DebitWheelDrawResponse
|
||||||
227, // 393: hyapp.wallet.v1.WalletService.GrantVipTrialCard:output_type -> hyapp.wallet.v1.GrantVipTrialCardResponse
|
209, // 393: hyapp.wallet.v1.WalletService.GrantVip:output_type -> hyapp.wallet.v1.GrantVipResponse
|
||||||
229, // 394: hyapp.wallet.v1.WalletService.EquipVipTrialCard:output_type -> hyapp.wallet.v1.EquipVipTrialCardResponse
|
227, // 394: hyapp.wallet.v1.WalletService.GrantVipTrialCard:output_type -> hyapp.wallet.v1.GrantVipTrialCardResponse
|
||||||
231, // 395: hyapp.wallet.v1.WalletService.UnequipVipTrialCard:output_type -> hyapp.wallet.v1.UnequipVipTrialCardResponse
|
229, // 395: hyapp.wallet.v1.WalletService.EquipVipTrialCard:output_type -> hyapp.wallet.v1.EquipVipTrialCardResponse
|
||||||
236, // 396: hyapp.wallet.v1.WalletService.ListAdminVipLevels:output_type -> hyapp.wallet.v1.ListAdminVipLevelsResponse
|
231, // 396: hyapp.wallet.v1.WalletService.UnequipVipTrialCard:output_type -> hyapp.wallet.v1.UnequipVipTrialCardResponse
|
||||||
238, // 397: hyapp.wallet.v1.WalletService.UpdateAdminVipLevels:output_type -> hyapp.wallet.v1.UpdateAdminVipLevelsResponse
|
236, // 397: hyapp.wallet.v1.WalletService.ListAdminVipLevels:output_type -> hyapp.wallet.v1.ListAdminVipLevelsResponse
|
||||||
213, // 398: hyapp.wallet.v1.WalletService.UpdateAdminVipProgramConfig:output_type -> hyapp.wallet.v1.UpdateAdminVipProgramConfigResponse
|
238, // 398: hyapp.wallet.v1.WalletService.UpdateAdminVipLevels:output_type -> hyapp.wallet.v1.UpdateAdminVipLevelsResponse
|
||||||
215, // 399: hyapp.wallet.v1.WalletService.UpdateMyVipSettings:output_type -> hyapp.wallet.v1.UpdateMyVipSettingsResponse
|
213, // 399: hyapp.wallet.v1.WalletService.UpdateAdminVipProgramConfig:output_type -> hyapp.wallet.v1.UpdateAdminVipProgramConfigResponse
|
||||||
221, // 400: hyapp.wallet.v1.WalletService.GetMyCurrentVipDailyCoinRebate:output_type -> hyapp.wallet.v1.GetMyCurrentVipDailyCoinRebateResponse
|
215, // 400: hyapp.wallet.v1.WalletService.UpdateMyVipSettings:output_type -> hyapp.wallet.v1.UpdateMyVipSettingsResponse
|
||||||
223, // 401: hyapp.wallet.v1.WalletService.ListMyVipDailyCoinRebateStatuses:output_type -> hyapp.wallet.v1.ListMyVipDailyCoinRebateStatusesResponse
|
221, // 401: hyapp.wallet.v1.WalletService.GetMyCurrentVipDailyCoinRebate:output_type -> hyapp.wallet.v1.GetMyCurrentVipDailyCoinRebateResponse
|
||||||
225, // 402: hyapp.wallet.v1.WalletService.ClaimVipDailyCoinRebate:output_type -> hyapp.wallet.v1.ClaimVipDailyCoinRebateResponse
|
223, // 402: hyapp.wallet.v1.WalletService.ListMyVipDailyCoinRebateStatuses:output_type -> hyapp.wallet.v1.ListMyVipDailyCoinRebateStatusesResponse
|
||||||
240, // 403: hyapp.wallet.v1.WalletService.CreditTaskReward:output_type -> hyapp.wallet.v1.CreditTaskRewardResponse
|
225, // 403: hyapp.wallet.v1.WalletService.ClaimVipDailyCoinRebate:output_type -> hyapp.wallet.v1.ClaimVipDailyCoinRebateResponse
|
||||||
242, // 404: hyapp.wallet.v1.WalletService.CreditLuckyGiftReward:output_type -> hyapp.wallet.v1.CreditLuckyGiftRewardResponse
|
240, // 404: hyapp.wallet.v1.WalletService.CreditTaskReward:output_type -> hyapp.wallet.v1.CreditTaskRewardResponse
|
||||||
244, // 405: hyapp.wallet.v1.WalletService.CreditWheelReward:output_type -> hyapp.wallet.v1.CreditWheelRewardResponse
|
242, // 405: hyapp.wallet.v1.WalletService.CreditLuckyGiftReward:output_type -> hyapp.wallet.v1.CreditLuckyGiftRewardResponse
|
||||||
246, // 406: hyapp.wallet.v1.WalletService.CreditRoomTurnoverReward:output_type -> hyapp.wallet.v1.CreditRoomTurnoverRewardResponse
|
244, // 406: hyapp.wallet.v1.WalletService.CreditWheelReward:output_type -> hyapp.wallet.v1.CreditWheelRewardResponse
|
||||||
248, // 407: hyapp.wallet.v1.WalletService.CreditInviteActivityReward:output_type -> hyapp.wallet.v1.CreditInviteActivityRewardResponse
|
246, // 407: hyapp.wallet.v1.WalletService.CreditRoomTurnoverReward:output_type -> hyapp.wallet.v1.CreditRoomTurnoverRewardResponse
|
||||||
250, // 408: hyapp.wallet.v1.WalletService.CreditAgencyOpeningReward:output_type -> hyapp.wallet.v1.CreditAgencyOpeningRewardResponse
|
248, // 408: hyapp.wallet.v1.WalletService.CreditInviteActivityReward:output_type -> hyapp.wallet.v1.CreditInviteActivityRewardResponse
|
||||||
252, // 409: hyapp.wallet.v1.WalletService.ApplyGameCoinChange:output_type -> hyapp.wallet.v1.ApplyGameCoinChangeResponse
|
250, // 409: hyapp.wallet.v1.WalletService.CreditAgencyOpeningReward:output_type -> hyapp.wallet.v1.CreditAgencyOpeningRewardResponse
|
||||||
257, // 410: hyapp.wallet.v1.WalletService.GetRedPacketConfig:output_type -> hyapp.wallet.v1.GetRedPacketConfigResponse
|
252, // 410: hyapp.wallet.v1.WalletService.ApplyGameCoinChange:output_type -> hyapp.wallet.v1.ApplyGameCoinChangeResponse
|
||||||
259, // 411: hyapp.wallet.v1.WalletService.UpdateRedPacketConfig:output_type -> hyapp.wallet.v1.UpdateRedPacketConfigResponse
|
257, // 411: hyapp.wallet.v1.WalletService.GetRedPacketConfig:output_type -> hyapp.wallet.v1.GetRedPacketConfigResponse
|
||||||
261, // 412: hyapp.wallet.v1.WalletService.CreateRedPacket:output_type -> hyapp.wallet.v1.CreateRedPacketResponse
|
259, // 412: hyapp.wallet.v1.WalletService.UpdateRedPacketConfig:output_type -> hyapp.wallet.v1.UpdateRedPacketConfigResponse
|
||||||
263, // 413: hyapp.wallet.v1.WalletService.ClaimRedPacket:output_type -> hyapp.wallet.v1.ClaimRedPacketResponse
|
261, // 413: hyapp.wallet.v1.WalletService.CreateRedPacket:output_type -> hyapp.wallet.v1.CreateRedPacketResponse
|
||||||
265, // 414: hyapp.wallet.v1.WalletService.ListRedPackets:output_type -> hyapp.wallet.v1.ListRedPacketsResponse
|
263, // 414: hyapp.wallet.v1.WalletService.ClaimRedPacket:output_type -> hyapp.wallet.v1.ClaimRedPacketResponse
|
||||||
267, // 415: hyapp.wallet.v1.WalletService.GetRedPacket:output_type -> hyapp.wallet.v1.GetRedPacketResponse
|
265, // 415: hyapp.wallet.v1.WalletService.ListRedPackets:output_type -> hyapp.wallet.v1.ListRedPacketsResponse
|
||||||
269, // 416: hyapp.wallet.v1.WalletService.ExpireRedPackets:output_type -> hyapp.wallet.v1.ExpireRedPacketsResponse
|
267, // 416: hyapp.wallet.v1.WalletService.GetRedPacket:output_type -> hyapp.wallet.v1.GetRedPacketResponse
|
||||||
271, // 417: hyapp.wallet.v1.WalletService.RetryRedPacketRefund:output_type -> hyapp.wallet.v1.RetryRedPacketRefundResponse
|
269, // 417: hyapp.wallet.v1.WalletService.ExpireRedPackets:output_type -> hyapp.wallet.v1.ExpireRedPacketsResponse
|
||||||
287, // [287:418] is the sub-list for method output_type
|
271, // 418: hyapp.wallet.v1.WalletService.RetryRedPacketRefund:output_type -> hyapp.wallet.v1.RetryRedPacketRefundResponse
|
||||||
156, // [156:287] is the sub-list for method input_type
|
288, // [288:419] is the sub-list for method output_type
|
||||||
156, // [156:156] is the sub-list for extension type_name
|
157, // [157:288] is the sub-list for method input_type
|
||||||
156, // [156:156] is the sub-list for extension extendee
|
157, // [157:157] is the sub-list for extension type_name
|
||||||
0, // [0:156] is the sub-list for field type_name
|
157, // [157:157] is the sub-list for extension extendee
|
||||||
|
0, // [0:157] is the sub-list for field type_name
|
||||||
}
|
}
|
||||||
|
|
||||||
func init() { file_proto_wallet_v1_wallet_proto_init() }
|
func init() { file_proto_wallet_v1_wallet_proto_init() }
|
||||||
|
|||||||
@ -1139,6 +1139,8 @@ message EquipUserResourceRequest {
|
|||||||
int64 user_id = 3;
|
int64 user_id = 3;
|
||||||
int64 resource_id = 4;
|
int64 resource_id = 4;
|
||||||
string entitlement_id = 5;
|
string entitlement_id = 5;
|
||||||
|
// command_id 是装备状态变更的业务幂等键;request_id 只用于链路追踪。
|
||||||
|
string command_id = 6;
|
||||||
}
|
}
|
||||||
|
|
||||||
message EquipUserResourceResponse {
|
message EquipUserResourceResponse {
|
||||||
@ -2028,6 +2030,9 @@ message VipBenefit {
|
|||||||
int64 created_at_ms = 13;
|
int64 created_at_ms = 13;
|
||||||
int64 updated_at_ms = 14;
|
int64 updated_at_ms = 14;
|
||||||
VipBenefitPresentation presentation = 15;
|
VipBenefitPresentation presentation = 15;
|
||||||
|
// resource 仅在 resource_id 指向同 App、active 且 resource_type 匹配的真实资源时返回。
|
||||||
|
// 运营尚未配置素材的权益保留 resource_id/resource_type 事实,但该对象为空,客户端不得猜 URL。
|
||||||
|
Resource resource = 16;
|
||||||
}
|
}
|
||||||
|
|
||||||
message VipLevel {
|
message VipLevel {
|
||||||
@ -2404,6 +2409,10 @@ message CheckVipBenefitResponse {
|
|||||||
VipState state = 3;
|
VipState state = 3;
|
||||||
string denial_reason = 4;
|
string denial_reason = 4;
|
||||||
int64 evaluated_at_ms = 5;
|
int64 evaluated_at_ms = 5;
|
||||||
|
// required_level 是当前 App 所有 active 等级中首次显式配置该权益的等级;0 表示未配置。
|
||||||
|
int32 required_level = 6;
|
||||||
|
// current_effective_level 来自 wallet 合并付费会员与体验卡后的最终状态。
|
||||||
|
int32 current_effective_level = 7;
|
||||||
}
|
}
|
||||||
|
|
||||||
message AdminVipLevelInput {
|
message AdminVipLevelInput {
|
||||||
|
|||||||
1485
docs/flutter对接/Fami_VIP五阶段Flutter接口对接.md
Normal file
1485
docs/flutter对接/Fami_VIP五阶段Flutter接口对接.md
Normal file
File diff suppressed because it is too large
Load Diff
163
docs/flutter对接/个人信息页用户上麦状态Flutter对接.md
Normal file
163
docs/flutter对接/个人信息页用户上麦状态Flutter对接.md
Normal 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 | 系统用户 ID;JSON 中按字符串返回 |
|
||||||
|
| `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
@ -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` | 我的页首屏聚合摘要 |
|
||||||
|
|||||||
@ -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()
|
||||||
|
}
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
@ -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.metadata,gateway 再原样写入 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
|
||||||
|
}
|
||||||
|
|||||||
@ -45,8 +45,9 @@ 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),
|
||||||
}
|
}
|
||||||
|
|
||||||
if withDetails, detailErr := st.WithDetails(detail); detailErr == nil {
|
if withDetails, detailErr := st.WithDetails(detail); detailErr == nil {
|
||||||
@ -109,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 {
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
258
scripts/mysql/069_vip_resource_equipment_contract.sql
Normal file
258
scripts/mysql/069_vip_resource_equipment_contract.sql
Normal 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 '当时绑定资源 ID;0 表示未绑定',
|
||||||
|
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;
|
||||||
156
scripts/mysql/071_room_vip_media_and_decorations.sql
Normal file
156
scripts/mysql/071_room_vip_media_and_decorations.sql
Normal file
@ -0,0 +1,156 @@
|
|||||||
|
-- 与 room-service/deploy/mysql/migrations/003_room_vip_media_and_decorations.sql 保持同一可重入结构。
|
||||||
|
-- 性能边界:INSTANT 列仍会短暂申请 metadata lock;image_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 装扮事实的本地幂等表';
|
||||||
@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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, "")
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 响应。
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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(),
|
||||||
|
|||||||
@ -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))
|
||||||
|
}
|
||||||
@ -392,6 +392,7 @@ type fakeRoomQueryClient struct {
|
|||||||
lastFeeds *roomv1.ListRoomFeedsRequest
|
lastFeeds *roomv1.ListRoomFeedsRequest
|
||||||
lastMyRoom *roomv1.GetMyRoomRequest
|
lastMyRoom *roomv1.GetMyRoomRequest
|
||||||
lastCurrent *roomv1.GetCurrentRoomRequest
|
lastCurrent *roomv1.GetCurrentRoomRequest
|
||||||
|
lastVoicePresence *roomv1.BatchGetUserVoiceRoomPresencesRequest
|
||||||
lastSnapshot *roomv1.GetRoomSnapshotRequest
|
lastSnapshot *roomv1.GetRoomSnapshotRequest
|
||||||
lastBackgrounds *roomv1.ListRoomBackgroundsRequest
|
lastBackgrounds *roomv1.ListRoomBackgroundsRequest
|
||||||
lastRocket *roomv1.GetRoomRocketRequest
|
lastRocket *roomv1.GetRoomRocketRequest
|
||||||
@ -403,6 +404,7 @@ type fakeRoomQueryClient struct {
|
|||||||
feedsResp *roomv1.ListRoomsResponse
|
feedsResp *roomv1.ListRoomsResponse
|
||||||
myRoomResp *roomv1.GetMyRoomResponse
|
myRoomResp *roomv1.GetMyRoomResponse
|
||||||
currentResp *roomv1.GetCurrentRoomResponse
|
currentResp *roomv1.GetCurrentRoomResponse
|
||||||
|
voicePresenceResp *roomv1.BatchGetUserVoiceRoomPresencesResponse
|
||||||
snapshotResp *roomv1.GetRoomSnapshotResponse
|
snapshotResp *roomv1.GetRoomSnapshotResponse
|
||||||
backgroundsResp *roomv1.ListRoomBackgroundsResponse
|
backgroundsResp *roomv1.ListRoomBackgroundsResponse
|
||||||
rocketResp *roomv1.GetRoomRocketResponse
|
rocketResp *roomv1.GetRoomRocketResponse
|
||||||
@ -414,6 +416,7 @@ type fakeRoomQueryClient struct {
|
|||||||
feedsErr error
|
feedsErr error
|
||||||
myRoomErr error
|
myRoomErr error
|
||||||
currentErr error
|
currentErr error
|
||||||
|
voicePresenceErr error
|
||||||
snapshotErr error
|
snapshotErr error
|
||||||
backgroundsErr error
|
backgroundsErr error
|
||||||
rocketErr error
|
rocketErr error
|
||||||
@ -1390,6 +1393,17 @@ func (f *fakeRoomQueryClient) GetCurrentRoom(_ context.Context, req *roomv1.GetC
|
|||||||
return &roomv1.GetCurrentRoomResponse{}, nil
|
return &roomv1.GetCurrentRoomResponse{}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (f *fakeRoomQueryClient) BatchGetUserVoiceRoomPresences(_ context.Context, req *roomv1.BatchGetUserVoiceRoomPresencesRequest) (*roomv1.BatchGetUserVoiceRoomPresencesResponse, error) {
|
||||||
|
f.lastVoicePresence = req
|
||||||
|
if f.voicePresenceErr != nil {
|
||||||
|
return nil, f.voicePresenceErr
|
||||||
|
}
|
||||||
|
if f.voicePresenceResp != nil {
|
||||||
|
return f.voicePresenceResp, nil
|
||||||
|
}
|
||||||
|
return &roomv1.BatchGetUserVoiceRoomPresencesResponse{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (f *fakeRoomQueryClient) GetRoomSnapshot(_ context.Context, req *roomv1.GetRoomSnapshotRequest) (*roomv1.GetRoomSnapshotResponse, error) {
|
func (f *fakeRoomQueryClient) GetRoomSnapshot(_ context.Context, req *roomv1.GetRoomSnapshotRequest) (*roomv1.GetRoomSnapshotResponse, error) {
|
||||||
f.lastSnapshot = req
|
f.lastSnapshot = req
|
||||||
if f.snapshotErr != nil {
|
if f.snapshotErr != nil {
|
||||||
@ -4811,6 +4825,87 @@ func TestGetCurrentRoomUsesAuthenticatedUser(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestBatchUserVoiceRoomPresencesUsesAuthenticatedViewerAndStableJSON(t *testing.T) {
|
||||||
|
queryClient := &fakeRoomQueryClient{voicePresenceResp: &roomv1.BatchGetUserVoiceRoomPresencesResponse{
|
||||||
|
Items: []*roomv1.UserVoiceRoomPresence{
|
||||||
|
{
|
||||||
|
UserId: 1002,
|
||||||
|
State: "on_mic",
|
||||||
|
SeatNo: 3,
|
||||||
|
Joinable: true,
|
||||||
|
ObservedAtMs: 1_784_265_600_000,
|
||||||
|
Room: &roomv1.UserVoiceRoomPresenceRoom{
|
||||||
|
RoomId: "room-profile-live",
|
||||||
|
RoomShortId: "886688",
|
||||||
|
OwnerUserId: 1001,
|
||||||
|
HostUserId: 1001,
|
||||||
|
Title: "Live Room",
|
||||||
|
CoverUrl: "https://cdn.example.com/room.png",
|
||||||
|
BackgroundUrl: "https://cdn.example.com/background.png",
|
||||||
|
Mode: "voice",
|
||||||
|
OnlineCount: 56,
|
||||||
|
SeatCount: 10,
|
||||||
|
Locked: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{UserId: 1003, State: "not_on_mic", ObservedAtMs: 1_784_265_600_000},
|
||||||
|
},
|
||||||
|
ServerTimeMs: 1_784_265_600_123,
|
||||||
|
}}
|
||||||
|
handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{})
|
||||||
|
handler.SetRoomQueryClient(queryClient)
|
||||||
|
router := handler.Routes(auth.NewVerifier("secret"))
|
||||||
|
request := httptest.NewRequest(http.MethodGet, "/api/v1/users/voice-room-presence:batch?user_ids=1002,1003,1002", nil)
|
||||||
|
request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
|
||||||
|
request.Header.Set("X-Request-ID", "req-profile-live")
|
||||||
|
recorder := httptest.NewRecorder()
|
||||||
|
|
||||||
|
router.ServeHTTP(recorder, request)
|
||||||
|
|
||||||
|
if recorder.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String())
|
||||||
|
}
|
||||||
|
if queryClient.lastVoicePresence == nil || queryClient.lastVoicePresence.GetViewerUserId() != 42 {
|
||||||
|
t.Fatalf("voice presence query must use authenticated viewer: %+v", queryClient.lastVoicePresence)
|
||||||
|
}
|
||||||
|
if got := queryClient.lastVoicePresence.GetUserIds(); len(got) != 2 || got[0] != 1002 || got[1] != 1003 {
|
||||||
|
t.Fatalf("voice presence query must preserve first-seen deduplicated ids: %v", got)
|
||||||
|
}
|
||||||
|
if queryClient.lastVoicePresence.GetMeta().GetRequestId() != assertGeneratedRequestID(t, recorder, "req-profile-live") {
|
||||||
|
t.Fatalf("voice presence query must propagate generated request id: %+v", queryClient.lastVoicePresence.GetMeta())
|
||||||
|
}
|
||||||
|
|
||||||
|
var response httpkit.ResponseEnvelope
|
||||||
|
if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil {
|
||||||
|
t.Fatalf("decode voice presence response failed: %v", err)
|
||||||
|
}
|
||||||
|
data := response.Data.(map[string]any)
|
||||||
|
items := data["items"].([]any)
|
||||||
|
onMic := items[0].(map[string]any)
|
||||||
|
room := onMic["room"].(map[string]any)
|
||||||
|
if onMic["user_id"] != "1002" || onMic["state"] != "on_mic" || onMic["joinable"] != true || room["room_id"] != "room-profile-live" || room["rtc_room_id"] != "room-profile-live" || room["locked"] != true {
|
||||||
|
t.Fatalf("on-mic response contract mismatch: %+v", onMic)
|
||||||
|
}
|
||||||
|
notOnMic := items[1].(map[string]any)
|
||||||
|
if notOnMic["state"] != "not_on_mic" || notOnMic["joinable"] != false || notOnMic["room"] != nil {
|
||||||
|
t.Fatalf("not-on-mic response must keep false and null fields: %+v", notOnMic)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBatchUserVoiceRoomPresencesRejectsInvalidIDs(t *testing.T) {
|
||||||
|
handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{})
|
||||||
|
handler.SetRoomQueryClient(&fakeRoomQueryClient{})
|
||||||
|
router := handler.Routes(auth.NewVerifier("secret"))
|
||||||
|
request := httptest.NewRequest(http.MethodGet, "/api/v1/users/voice-room-presence:batch?user_ids=1002,,1003", nil)
|
||||||
|
request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
|
||||||
|
request.Header.Set("X-Request-ID", "req-profile-live-invalid")
|
||||||
|
recorder := httptest.NewRecorder()
|
||||||
|
|
||||||
|
router.ServeHTTP(recorder, request)
|
||||||
|
|
||||||
|
assertEnvelope(t, recorder, http.StatusBadRequest, "INVALID_USER_IDS", "req-profile-live-invalid")
|
||||||
|
}
|
||||||
|
|
||||||
func TestGetRoomSnapshotUsesAuthenticatedUser(t *testing.T) {
|
func TestGetRoomSnapshotUsesAuthenticatedUser(t *testing.T) {
|
||||||
queryClient := &fakeRoomQueryClient{snapshotResp: &roomv1.GetRoomSnapshotResponse{
|
queryClient := &fakeRoomQueryClient{snapshotResp: &roomv1.GetRoomSnapshotResponse{
|
||||||
Room: &roomv1.RoomSnapshot{
|
Room: &roomv1.RoomSnapshot{
|
||||||
@ -11029,6 +11124,7 @@ func TestProfileGateRejectsIncompleteUsersForRoomIMRTCPaidCapabilities(t *testin
|
|||||||
{name: "message_delete", method: http.MethodDelete, path: "/api/v1/messages/msg-1"},
|
{name: "message_delete", method: http.MethodDelete, path: "/api/v1/messages/msg-1"},
|
||||||
{name: "direct_gift", method: http.MethodPost, path: "/api/v1/messages/direct-gifts/send", body: `{"command_id":"cmd-direct-gift","target_user_id":900002,"gift_id":"rose","gift_count":1}`},
|
{name: "direct_gift", method: http.MethodPost, path: "/api/v1/messages/direct-gifts/send", body: `{"command_id":"cmd-direct-gift","target_user_id":900002,"gift_id":"rose","gift_count":1}`},
|
||||||
{name: "profiles_batch", method: http.MethodGet, path: "/api/v1/users/profiles:batch?user_ids=42"},
|
{name: "profiles_batch", method: http.MethodGet, path: "/api/v1/users/profiles:batch?user_ids=42"},
|
||||||
|
{name: "voice_room_presence_batch", method: http.MethodGet, path: "/api/v1/users/voice-room-presence:batch?user_ids=43"},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, test := range tests {
|
for _, test := range tests {
|
||||||
|
|||||||
@ -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)
|
||||||
|
}
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
|
}
|
||||||
@ -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()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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(),
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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_ids;viewer_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_mic;RPC 失败代表整批实时事实不可用,客户端必须隐藏 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()}
|
||||||
|
}
|
||||||
@ -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
|
||||||
|
|||||||
@ -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 仍要携带与资源列表相同的显式 renderer;Flutter 不解析 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
|
||||||
|
|||||||
@ -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() {
|
||||||
|
// 匿名记录只暴露随机展示 ID;visitor_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 {
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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(),
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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 仍负责持久 outbox,activity 只做成功落库后的低延迟余额 IM。
|
// WalletClient 是幸运礼物返奖唯一账务依赖;wallet-service 仍负责持久 outbox,activity 只做成功落库后的低延迟余额 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
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -184,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{
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -54,6 +54,8 @@ CREATE TABLE IF NOT EXISTS room_list_entries (
|
|||||||
seat_count INT NOT NULL DEFAULT 0 COMMENT '麦位数量',
|
seat_count INT NOT NULL DEFAULT 0 COMMENT '麦位数量',
|
||||||
occupied_seat_count INT NOT NULL DEFAULT 0 COMMENT '占用麦位数量',
|
occupied_seat_count INT NOT NULL DEFAULT 0 COMMENT '占用麦位数量',
|
||||||
sort_score BIGINT NOT NULL DEFAULT 0 COMMENT '排序分值',
|
sort_score BIGINT NOT NULL DEFAULT 0 COMMENT '排序分值',
|
||||||
|
room_border_json JSON NULL COMMENT '当前未过期房间边框资源快照',
|
||||||
|
room_name_style_json JSON NULL COMMENT '当前未过期彩色房名资源快照',
|
||||||
created_at_ms BIGINT NOT NULL COMMENT '创建时间,UTC epoch ms',
|
created_at_ms BIGINT NOT NULL COMMENT '创建时间,UTC epoch ms',
|
||||||
updated_at_ms BIGINT NOT NULL COMMENT '更新时间,UTC epoch ms',
|
updated_at_ms BIGINT NOT NULL COMMENT '更新时间,UTC epoch ms',
|
||||||
PRIMARY KEY (app_code, room_id),
|
PRIMARY KEY (app_code, room_id),
|
||||||
@ -114,15 +116,82 @@ CREATE TABLE IF NOT EXISTS room_background_images (
|
|||||||
app_code VARCHAR(32) NOT NULL COMMENT '应用编码,用于多租户隔离',
|
app_code VARCHAR(32) NOT NULL COMMENT '应用编码,用于多租户隔离',
|
||||||
background_id BIGINT NOT NULL AUTO_INCREMENT COMMENT '背景图 ID',
|
background_id BIGINT NOT NULL AUTO_INCREMENT COMMENT '背景图 ID',
|
||||||
room_id VARCHAR(64) NOT NULL COMMENT '房间 ID',
|
room_id VARCHAR(64) NOT NULL COMMENT '房间 ID',
|
||||||
image_url VARCHAR(256) NOT NULL COMMENT '背景图 URL',
|
image_url VARCHAR(1024) NOT NULL COMMENT '背景图 URL',
|
||||||
|
command_id VARCHAR(128) NULL COMMENT '新版素材保存幂等键;历史行保持 NULL',
|
||||||
|
object_key VARCHAR(512) NOT NULL DEFAULT '' COMMENT 'COS 对象 key',
|
||||||
|
content_type VARCHAR(64) NOT NULL DEFAULT '' COMMENT '服务端嗅探 MIME',
|
||||||
|
media_format VARCHAR(16) NOT NULL DEFAULT '' COMMENT 'jpeg/png/gif/webp',
|
||||||
|
size_bytes BIGINT NOT NULL DEFAULT 0 COMMENT '文件字节数',
|
||||||
|
width INT NOT NULL DEFAULT 0 COMMENT '像素宽度',
|
||||||
|
height INT NOT NULL DEFAULT 0 COMMENT '像素高度',
|
||||||
|
animated BOOLEAN NOT NULL DEFAULT FALSE COMMENT '是否动图',
|
||||||
|
frame_count INT NOT NULL DEFAULT 0 COMMENT '帧数',
|
||||||
|
duration_ms BIGINT NOT NULL DEFAULT 0 COMMENT '动图总时长',
|
||||||
|
poster_url VARCHAR(1024) NOT NULL DEFAULT '' COMMENT '可选首帧 URL',
|
||||||
|
thumbnail_url VARCHAR(1024) NOT NULL DEFAULT '' COMMENT '可选缩略图 URL',
|
||||||
|
sha256 CHAR(64) NOT NULL DEFAULT '' COMMENT '原文件 SHA-256',
|
||||||
|
media_status VARCHAR(32) NOT NULL DEFAULT '' COMMENT 'active 表示已通过专用上传入口',
|
||||||
created_by_user_id BIGINT NOT NULL COMMENT '保存背景图的用户 ID',
|
created_by_user_id BIGINT NOT NULL COMMENT '保存背景图的用户 ID',
|
||||||
created_at_ms BIGINT NOT NULL COMMENT '创建时间,UTC epoch ms',
|
created_at_ms BIGINT NOT NULL COMMENT '创建时间,UTC epoch ms',
|
||||||
updated_at_ms BIGINT NOT NULL COMMENT '更新时间,UTC epoch ms',
|
updated_at_ms BIGINT NOT NULL COMMENT '更新时间,UTC epoch ms',
|
||||||
PRIMARY KEY (background_id),
|
PRIMARY KEY (background_id),
|
||||||
UNIQUE KEY uk_room_background_url (app_code, room_id, image_url),
|
UNIQUE KEY uk_room_background_url (app_code, room_id, image_url),
|
||||||
|
UNIQUE KEY uk_room_background_command (app_code, room_id, command_id),
|
||||||
KEY idx_room_background_list (app_code, room_id, created_at_ms DESC, background_id DESC)
|
KEY idx_room_background_list (app_code, room_id, created_at_ms DESC, background_id DESC)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='房间背景图素材表';
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='房间背景图素材表';
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS room_media_upload_registrations (
|
||||||
|
app_code VARCHAR(32) NOT NULL COMMENT '应用编码',
|
||||||
|
room_id VARCHAR(64) NOT NULL COMMENT '目标房间 ID',
|
||||||
|
command_id VARCHAR(128) NOT NULL COMMENT '专用上传强幂等键',
|
||||||
|
actor_user_id BIGINT NOT NULL COMMENT '首次授权用户 ID',
|
||||||
|
purpose VARCHAR(32) NOT NULL COMMENT 'room_background/room_image',
|
||||||
|
media_payload_sha256 CHAR(64) NOT NULL COMMENT '首次服务端解析媒体事实哈希',
|
||||||
|
expected_object_key VARCHAR(512) NOT NULL COMMENT 'room owner 确定的 COS key',
|
||||||
|
object_url VARCHAR(1024) NOT NULL DEFAULT '' COMMENT 'PutObject 成功后的访问 URL',
|
||||||
|
status VARCHAR(32) NOT NULL DEFAULT 'authorized' COMMENT 'authorized/active',
|
||||||
|
content_type VARCHAR(64) NOT NULL COMMENT '服务端嗅探 MIME',
|
||||||
|
media_format VARCHAR(16) NOT NULL COMMENT 'jpeg/png/gif/webp',
|
||||||
|
size_bytes BIGINT NOT NULL COMMENT '原文件字节数',
|
||||||
|
width INT NOT NULL COMMENT '像素宽度',
|
||||||
|
height INT NOT NULL COMMENT '像素高度',
|
||||||
|
animated BOOLEAN NOT NULL DEFAULT FALSE COMMENT '是否动图',
|
||||||
|
frame_count INT NOT NULL COMMENT '帧数',
|
||||||
|
duration_ms BIGINT NOT NULL DEFAULT 0 COMMENT '动图总时长',
|
||||||
|
sha256 CHAR(64) NOT NULL COMMENT '原文件 SHA-256',
|
||||||
|
authorized_room_version BIGINT NOT NULL COMMENT '首次授权看到的房间版本',
|
||||||
|
created_at_ms BIGINT NOT NULL COMMENT '创建时间,UTC epoch ms',
|
||||||
|
updated_at_ms BIGINT NOT NULL COMMENT '更新时间,UTC epoch ms',
|
||||||
|
PRIMARY KEY (app_code, room_id, command_id),
|
||||||
|
UNIQUE KEY uk_room_media_upload_object (app_code, expected_object_key),
|
||||||
|
KEY idx_room_media_upload_status_created (app_code, status, created_at_ms)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='房间专用媒体上传授权与完成事实';
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS room_decoration_assignments (
|
||||||
|
app_code VARCHAR(32) NOT NULL,
|
||||||
|
room_id VARCHAR(64) NOT NULL,
|
||||||
|
decoration_type VARCHAR(32) NOT NULL,
|
||||||
|
owner_user_id BIGINT NOT NULL,
|
||||||
|
benefit_code VARCHAR(64) NOT NULL,
|
||||||
|
resource_id BIGINT NOT NULL,
|
||||||
|
entitlement_id VARCHAR(128) NOT NULL DEFAULT '',
|
||||||
|
expires_at_ms BIGINT NOT NULL DEFAULT 0,
|
||||||
|
updated_at_ms BIGINT NOT NULL,
|
||||||
|
PRIMARY KEY (app_code, room_id, decoration_type),
|
||||||
|
KEY idx_room_decoration_resource (app_code, resource_id, room_id, decoration_type),
|
||||||
|
KEY idx_room_decoration_owner (app_code, owner_user_id, room_id, decoration_type),
|
||||||
|
KEY idx_room_decoration_expiry (app_code, expires_at_ms, room_id, decoration_type)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='房间当前 VIP 装扮反向索引';
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS room_wallet_event_consumption (
|
||||||
|
app_code VARCHAR(32) NOT NULL,
|
||||||
|
event_id VARCHAR(128) NOT NULL,
|
||||||
|
event_type VARCHAR(64) NOT NULL,
|
||||||
|
consumed_at_ms BIGINT NOT NULL,
|
||||||
|
PRIMARY KEY (app_code, event_id),
|
||||||
|
KEY idx_room_wallet_event_consumed (app_code, consumed_at_ms)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='room-service 消费 wallet 装扮事实的本地幂等表';
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS room_snapshots (
|
CREATE TABLE IF NOT EXISTS room_snapshots (
|
||||||
app_code VARCHAR(32) NOT NULL COMMENT '应用编码,用于多租户隔离',
|
app_code VARCHAR(32) NOT NULL COMMENT '应用编码,用于多租户隔离',
|
||||||
room_id VARCHAR(64) NOT NULL COMMENT '房间 ID',
|
room_id VARCHAR(64) NOT NULL COMMENT '房间 ID',
|
||||||
|
|||||||
@ -0,0 +1,179 @@
|
|||||||
|
-- 房间新版 VIP 媒体、上传授权与装扮失效读模型。
|
||||||
|
-- 性能边界:
|
||||||
|
-- 1. nullable/default 列使用 MySQL 8 INSTANT,仅短暂持有 metadata lock,不重写历史行;仍建议业务低峰执行。
|
||||||
|
-- 2. image_url 扩容与唯一索引需要在线扫描低频 room_background_images,使用 INPLACE/LOCK=NONE。
|
||||||
|
-- 3. 唯一索引创建前会主动检查非 NULL command_id 重复组;发现脏数据直接 SIGNAL,拒绝创建冲突索引。
|
||||||
|
-- 4. 所有分支先查 information_schema,因此脚本可在发布重试时重复执行。
|
||||||
|
|
||||||
|
SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
USE hyapp_room;
|
||||||
|
|
||||||
|
DELIMITER $$
|
||||||
|
|
||||||
|
DROP PROCEDURE IF EXISTS hyapp_migrate_003_room_vip_media_decorations$$
|
||||||
|
CREATE PROCEDURE hyapp_migrate_003_room_vip_media_decorations()
|
||||||
|
BEGIN
|
||||||
|
DECLARE duplicate_groups BIGINT DEFAULT 0;
|
||||||
|
DECLARE oversized_urls BIGINT DEFAULT 0;
|
||||||
|
|
||||||
|
IF EXISTS (
|
||||||
|
SELECT 1 FROM information_schema.tables
|
||||||
|
WHERE table_schema = DATABASE() AND table_name = 'room_background_images'
|
||||||
|
) THEN
|
||||||
|
IF EXISTS (
|
||||||
|
SELECT 1 FROM information_schema.columns
|
||||||
|
WHERE table_schema = DATABASE() AND table_name = 'room_background_images'
|
||||||
|
AND column_name = 'image_url'
|
||||||
|
AND (data_type <> 'varchar' OR character_maximum_length <> 1024)
|
||||||
|
) THEN
|
||||||
|
SELECT COUNT(*) INTO oversized_urls
|
||||||
|
FROM room_background_images
|
||||||
|
WHERE CHAR_LENGTH(image_url) > 1024;
|
||||||
|
IF oversized_urls > 0 THEN
|
||||||
|
SIGNAL SQLSTATE '45000'
|
||||||
|
SET MESSAGE_TEXT = 'room_background_images.image_url contains values longer than 1024';
|
||||||
|
END IF;
|
||||||
|
ALTER TABLE room_background_images
|
||||||
|
MODIFY COLUMN image_url VARCHAR(1024) NOT NULL,
|
||||||
|
ALGORITHM=INPLACE,
|
||||||
|
LOCK=NONE;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'room_background_images' AND column_name = 'command_id') THEN
|
||||||
|
ALTER TABLE room_background_images ADD COLUMN command_id VARCHAR(128) NULL COMMENT '新版素材保存幂等键;历史行保持 NULL', ALGORITHM=INSTANT;
|
||||||
|
END IF;
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'room_background_images' AND column_name = 'object_key') THEN
|
||||||
|
ALTER TABLE room_background_images ADD COLUMN object_key VARCHAR(512) NOT NULL DEFAULT '' COMMENT 'COS 对象 key', ALGORITHM=INSTANT;
|
||||||
|
END IF;
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'room_background_images' AND column_name = 'content_type') THEN
|
||||||
|
ALTER TABLE room_background_images ADD COLUMN content_type VARCHAR(64) NOT NULL DEFAULT '' COMMENT '服务端嗅探 MIME', ALGORITHM=INSTANT;
|
||||||
|
END IF;
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'room_background_images' AND column_name = 'media_format') THEN
|
||||||
|
ALTER TABLE room_background_images ADD COLUMN media_format VARCHAR(16) NOT NULL DEFAULT '' COMMENT 'jpeg/png/gif/webp', ALGORITHM=INSTANT;
|
||||||
|
END IF;
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'room_background_images' AND column_name = 'size_bytes') THEN
|
||||||
|
ALTER TABLE room_background_images ADD COLUMN size_bytes BIGINT NOT NULL DEFAULT 0 COMMENT '文件字节数', ALGORITHM=INSTANT;
|
||||||
|
END IF;
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'room_background_images' AND column_name = 'width') THEN
|
||||||
|
ALTER TABLE room_background_images ADD COLUMN width INT NOT NULL DEFAULT 0 COMMENT '像素宽度', ALGORITHM=INSTANT;
|
||||||
|
END IF;
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'room_background_images' AND column_name = 'height') THEN
|
||||||
|
ALTER TABLE room_background_images ADD COLUMN height INT NOT NULL DEFAULT 0 COMMENT '像素高度', ALGORITHM=INSTANT;
|
||||||
|
END IF;
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'room_background_images' AND column_name = 'animated') THEN
|
||||||
|
ALTER TABLE room_background_images ADD COLUMN animated BOOLEAN NOT NULL DEFAULT FALSE COMMENT '是否动图', ALGORITHM=INSTANT;
|
||||||
|
END IF;
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'room_background_images' AND column_name = 'frame_count') THEN
|
||||||
|
ALTER TABLE room_background_images ADD COLUMN frame_count INT NOT NULL DEFAULT 0 COMMENT '帧数', ALGORITHM=INSTANT;
|
||||||
|
END IF;
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'room_background_images' AND column_name = 'duration_ms') THEN
|
||||||
|
ALTER TABLE room_background_images ADD COLUMN duration_ms BIGINT NOT NULL DEFAULT 0 COMMENT '动图总时长', ALGORITHM=INSTANT;
|
||||||
|
END IF;
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'room_background_images' AND column_name = 'poster_url') THEN
|
||||||
|
ALTER TABLE room_background_images ADD COLUMN poster_url VARCHAR(1024) NOT NULL DEFAULT '' COMMENT '可选首帧 URL', ALGORITHM=INSTANT;
|
||||||
|
END IF;
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'room_background_images' AND column_name = 'thumbnail_url') THEN
|
||||||
|
ALTER TABLE room_background_images ADD COLUMN thumbnail_url VARCHAR(1024) NOT NULL DEFAULT '' COMMENT '可选缩略图 URL', ALGORITHM=INSTANT;
|
||||||
|
END IF;
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'room_background_images' AND column_name = 'sha256') THEN
|
||||||
|
ALTER TABLE room_background_images ADD COLUMN sha256 CHAR(64) NOT NULL DEFAULT '' COMMENT '原文件 SHA-256', ALGORITHM=INSTANT;
|
||||||
|
END IF;
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'room_background_images' AND column_name = 'media_status') THEN
|
||||||
|
ALTER TABLE room_background_images ADD COLUMN media_status VARCHAR(32) NOT NULL DEFAULT '' COMMENT 'active 表示已通过专用上传入口', ALGORITHM=INSTANT;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1 FROM information_schema.statistics
|
||||||
|
WHERE table_schema = DATABASE() AND table_name = 'room_background_images'
|
||||||
|
AND index_name = 'uk_room_background_command'
|
||||||
|
) THEN
|
||||||
|
SELECT COUNT(*) INTO duplicate_groups
|
||||||
|
FROM (
|
||||||
|
SELECT 1
|
||||||
|
FROM room_background_images
|
||||||
|
WHERE command_id IS NOT NULL
|
||||||
|
GROUP BY app_code, room_id, command_id
|
||||||
|
HAVING COUNT(*) > 1
|
||||||
|
LIMIT 1
|
||||||
|
) AS duplicate_command_groups;
|
||||||
|
IF duplicate_groups > 0 THEN
|
||||||
|
SIGNAL SQLSTATE '45000'
|
||||||
|
SET MESSAGE_TEXT = 'duplicate room background command_id blocks unique index';
|
||||||
|
END IF;
|
||||||
|
ALTER TABLE room_background_images
|
||||||
|
ADD UNIQUE KEY uk_room_background_command (app_code, room_id, command_id),
|
||||||
|
ALGORITHM=INPLACE,
|
||||||
|
LOCK=NONE;
|
||||||
|
END IF;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
IF EXISTS (
|
||||||
|
SELECT 1 FROM information_schema.tables
|
||||||
|
WHERE table_schema = DATABASE() AND table_name = 'room_list_entries'
|
||||||
|
) THEN
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'room_list_entries' AND column_name = 'room_border_json') THEN
|
||||||
|
ALTER TABLE room_list_entries ADD COLUMN room_border_json JSON NULL COMMENT '当前未过期房间边框资源快照', ALGORITHM=INSTANT;
|
||||||
|
END IF;
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'room_list_entries' AND column_name = 'room_name_style_json') THEN
|
||||||
|
ALTER TABLE room_list_entries ADD COLUMN room_name_style_json JSON NULL COMMENT '当前未过期彩色房名资源快照', ALGORITHM=INSTANT;
|
||||||
|
END IF;
|
||||||
|
END IF;
|
||||||
|
END$$
|
||||||
|
|
||||||
|
CALL hyapp_migrate_003_room_vip_media_decorations()$$
|
||||||
|
DROP PROCEDURE IF EXISTS hyapp_migrate_003_room_vip_media_decorations$$
|
||||||
|
|
||||||
|
DELIMITER ;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS room_media_upload_registrations (
|
||||||
|
app_code VARCHAR(32) NOT NULL COMMENT '应用编码',
|
||||||
|
room_id VARCHAR(64) NOT NULL COMMENT '目标房间 ID',
|
||||||
|
command_id VARCHAR(128) NOT NULL COMMENT '专用上传强幂等键',
|
||||||
|
actor_user_id BIGINT NOT NULL COMMENT '首次授权用户 ID',
|
||||||
|
purpose VARCHAR(32) NOT NULL COMMENT 'room_background/room_image',
|
||||||
|
media_payload_sha256 CHAR(64) NOT NULL COMMENT '首次服务端解析媒体事实哈希',
|
||||||
|
expected_object_key VARCHAR(512) NOT NULL COMMENT 'room owner 确定的 COS key',
|
||||||
|
object_url VARCHAR(1024) NOT NULL DEFAULT '' COMMENT 'PutObject 成功后的访问 URL',
|
||||||
|
status VARCHAR(32) NOT NULL DEFAULT 'authorized' COMMENT 'authorized/active',
|
||||||
|
content_type VARCHAR(64) NOT NULL COMMENT '服务端嗅探 MIME',
|
||||||
|
media_format VARCHAR(16) NOT NULL COMMENT 'jpeg/png/gif/webp',
|
||||||
|
size_bytes BIGINT NOT NULL COMMENT '原文件字节数',
|
||||||
|
width INT NOT NULL COMMENT '像素宽度',
|
||||||
|
height INT NOT NULL COMMENT '像素高度',
|
||||||
|
animated BOOLEAN NOT NULL DEFAULT FALSE COMMENT '是否动图',
|
||||||
|
frame_count INT NOT NULL COMMENT '帧数',
|
||||||
|
duration_ms BIGINT NOT NULL DEFAULT 0 COMMENT '动图总时长',
|
||||||
|
sha256 CHAR(64) NOT NULL COMMENT '原文件 SHA-256',
|
||||||
|
authorized_room_version BIGINT NOT NULL COMMENT '首次授权看到的房间版本',
|
||||||
|
created_at_ms BIGINT NOT NULL COMMENT '创建时间,UTC epoch ms',
|
||||||
|
updated_at_ms BIGINT NOT NULL COMMENT '更新时间,UTC epoch ms',
|
||||||
|
PRIMARY KEY (app_code, room_id, command_id),
|
||||||
|
UNIQUE KEY uk_room_media_upload_object (app_code, expected_object_key),
|
||||||
|
KEY idx_room_media_upload_status_created (app_code, status, created_at_ms)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='房间专用媒体上传授权与完成事实';
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS room_decoration_assignments (
|
||||||
|
app_code VARCHAR(32) NOT NULL,
|
||||||
|
room_id VARCHAR(64) NOT NULL,
|
||||||
|
decoration_type VARCHAR(32) NOT NULL,
|
||||||
|
owner_user_id BIGINT NOT NULL,
|
||||||
|
benefit_code VARCHAR(64) NOT NULL,
|
||||||
|
resource_id BIGINT NOT NULL,
|
||||||
|
entitlement_id VARCHAR(128) NOT NULL DEFAULT '',
|
||||||
|
expires_at_ms BIGINT NOT NULL DEFAULT 0,
|
||||||
|
updated_at_ms BIGINT NOT NULL,
|
||||||
|
PRIMARY KEY (app_code, room_id, decoration_type),
|
||||||
|
KEY idx_room_decoration_resource (app_code, resource_id, room_id, decoration_type),
|
||||||
|
KEY idx_room_decoration_owner (app_code, owner_user_id, room_id, decoration_type),
|
||||||
|
KEY idx_room_decoration_expiry (app_code, expires_at_ms, room_id, decoration_type)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='房间当前 VIP 装扮反向索引';
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS room_wallet_event_consumption (
|
||||||
|
app_code VARCHAR(32) NOT NULL,
|
||||||
|
event_id VARCHAR(128) NOT NULL,
|
||||||
|
event_type VARCHAR(64) NOT NULL,
|
||||||
|
consumed_at_ms BIGINT NOT NULL,
|
||||||
|
PRIMARY KEY (app_code, event_id),
|
||||||
|
KEY idx_room_wallet_event_consumed (app_code, consumed_at_ms)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='room-service 消费 wallet 装扮事实的本地幂等表';
|
||||||
@ -25,6 +25,7 @@ import (
|
|||||||
"hyapp/pkg/tencentim"
|
"hyapp/pkg/tencentim"
|
||||||
"hyapp/pkg/tencentrtc"
|
"hyapp/pkg/tencentrtc"
|
||||||
"hyapp/pkg/usermq"
|
"hyapp/pkg/usermq"
|
||||||
|
"hyapp/pkg/walletmq"
|
||||||
"hyapp/services/room-service/internal/config"
|
"hyapp/services/room-service/internal/config"
|
||||||
"hyapp/services/room-service/internal/healthcheck"
|
"hyapp/services/room-service/internal/healthcheck"
|
||||||
"hyapp/services/room-service/internal/integration"
|
"hyapp/services/room-service/internal/integration"
|
||||||
@ -66,7 +67,7 @@ type App struct {
|
|||||||
healthHTTP *healthhttp.Server
|
healthHTTP *healthhttp.Server
|
||||||
// mqProducers 在 outbox worker 启动前打开,承载 fanout 和延迟发射唤醒。
|
// mqProducers 在 outbox worker 启动前打开,承载 fanout 和延迟发射唤醒。
|
||||||
mqProducers []*rocketmqx.Producer
|
mqProducers []*rocketmqx.Producer
|
||||||
// mqConsumers 在 gRPC 服务启动前订阅,承接火箭唤醒、用户区域同步和幸运礼物 owner 事实。
|
// mqConsumers 在 gRPC 服务启动前订阅,承接火箭唤醒、用户区域、wallet 装扮失效和幸运礼物 owner 事实。
|
||||||
mqConsumers []*rocketmqx.Consumer
|
mqConsumers []*rocketmqx.Consumer
|
||||||
// workers 统一控制本节点后台 worker,关闭时必须先停止 worker 再释放 MySQL/Redis。
|
// workers 统一控制本节点后台 worker,关闭时必须先停止 worker 再释放 MySQL/Redis。
|
||||||
workers *serviceapp.BackgroundGroup
|
workers *serviceapp.BackgroundGroup
|
||||||
@ -178,6 +179,13 @@ func New(cfg config.Config) (*App, error) {
|
|||||||
concreteTencentPublisher := integration.NewTencentIMPublisher(tencentClient)
|
concreteTencentPublisher := integration.NewTencentIMPublisher(tencentClient)
|
||||||
tencentPublisher = concreteTencentPublisher
|
tencentPublisher = concreteTencentPublisher
|
||||||
roomPublisher = concreteTencentPublisher
|
roomPublisher = concreteTencentPublisher
|
||||||
|
// 图片消息和 VIP 房间装扮都是用户可见的持久事实,必须由 durable room_outbox 补偿腾讯 IM;
|
||||||
|
// 只过滤这两类事件,避免把现有 direct-IM 展示事件整体接入后制造双消息。
|
||||||
|
outboxPublishers = append(outboxPublishers, integration.NewEventTypeOutboxPublisher(
|
||||||
|
concreteTencentPublisher,
|
||||||
|
"RoomImageMessageSent",
|
||||||
|
"RoomDecorationChanged",
|
||||||
|
))
|
||||||
}
|
}
|
||||||
if cfg.TencentRTC.Enabled {
|
if cfg.TencentRTC.Enabled {
|
||||||
rtcClient, err := tencentrtc.NewRESTClient(cfg.TencentRTC.RESTConfig())
|
rtcClient, err := tencentrtc.NewRESTClient(cfg.TencentRTC.RESTConfig())
|
||||||
@ -192,7 +200,7 @@ func New(cfg config.Config) (*App, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
mqProducers := make([]*rocketmqx.Producer, 0, 2)
|
mqProducers := make([]*rocketmqx.Producer, 0, 2)
|
||||||
mqConsumers := make([]*rocketmqx.Consumer, 0, 4)
|
mqConsumers := make([]*rocketmqx.Consumer, 0, 5)
|
||||||
var rocketLaunchScheduler integration.RoomRocketLaunchScheduler
|
var rocketLaunchScheduler integration.RoomRocketLaunchScheduler
|
||||||
if cfg.OutboxWorker.PublishMode == config.OutboxPublishModeDirect || cfg.OutboxWorker.PublishMode == config.OutboxPublishModeDual {
|
if cfg.OutboxWorker.PublishMode == config.OutboxPublishModeDirect || cfg.OutboxWorker.PublishMode == config.OutboxPublishModeDual {
|
||||||
if activityConn != nil {
|
if activityConn != nil {
|
||||||
@ -297,6 +305,46 @@ func New(cfg config.Config) (*App, error) {
|
|||||||
}
|
}
|
||||||
mqConsumers = append(mqConsumers, userConsumer)
|
mqConsumers = append(mqConsumers, userConsumer)
|
||||||
}
|
}
|
||||||
|
if cfg.RocketMQ.WalletOutbox.Enabled {
|
||||||
|
// 兼容 legacy Tag 与 typed Tag 的滚动窗口;独立 group 只消费会改变当前房间装扮的 owner 事实。
|
||||||
|
walletTagExpression, err := walletmq.LegacyCompatibleTagExpression(
|
||||||
|
"ResourceChanged",
|
||||||
|
"ResourceGrantRevoked",
|
||||||
|
"UserResourceChanged",
|
||||||
|
"VipEffectiveChanged",
|
||||||
|
"VipProgramChanged",
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
_ = repository.Close()
|
||||||
|
closeClientConn(activityConn)
|
||||||
|
_ = walletConn.Close()
|
||||||
|
_ = redisClient.Close()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
walletConsumer, err := rocketmqx.NewConsumer(rocketMQConsumerConfig(cfg.RocketMQ, cfg.RocketMQ.WalletOutbox.ConsumerGroup, cfg.RocketMQ.WalletOutbox.ConsumerMaxReconsumeTimes, 0))
|
||||||
|
if err != nil {
|
||||||
|
_ = repository.Close()
|
||||||
|
closeClientConn(activityConn)
|
||||||
|
_ = walletConn.Close()
|
||||||
|
_ = redisClient.Close()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := walletConsumer.Subscribe(cfg.RocketMQ.WalletOutbox.Topic, walletTagExpression, func(ctx context.Context, message rocketmqx.ConsumedMessage) error {
|
||||||
|
walletMessage, err := walletmq.DecodeWalletOutboxMessage(message.Body)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return svc.HandleWalletDecorationEvent(ctx, walletMessage)
|
||||||
|
}); err != nil {
|
||||||
|
_ = walletConsumer.Shutdown()
|
||||||
|
_ = repository.Close()
|
||||||
|
closeClientConn(activityConn)
|
||||||
|
_ = walletConn.Close()
|
||||||
|
_ = redisClient.Close()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
mqConsumers = append(mqConsumers, walletConsumer)
|
||||||
|
}
|
||||||
if cfg.RocketMQ.LuckyGiftOutbox.Enabled {
|
if cfg.RocketMQ.LuckyGiftOutbox.Enabled {
|
||||||
luckyGiftConsumer, err := rocketmqx.NewConsumer(luckyGiftRocketMQConsumerConfig(cfg.RocketMQ))
|
luckyGiftConsumer, err := rocketmqx.NewConsumer(luckyGiftRocketMQConsumerConfig(cfg.RocketMQ))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@ -156,6 +156,7 @@ type RocketMQConfig struct {
|
|||||||
RoomOutbox RoomOutboxMQConfig `yaml:"room_outbox"`
|
RoomOutbox RoomOutboxMQConfig `yaml:"room_outbox"`
|
||||||
RocketLaunch RocketLaunchMQConfig `yaml:"rocket_launch"`
|
RocketLaunch RocketLaunchMQConfig `yaml:"rocket_launch"`
|
||||||
UserOutbox UserOutboxMQConfig `yaml:"user_outbox"`
|
UserOutbox UserOutboxMQConfig `yaml:"user_outbox"`
|
||||||
|
WalletOutbox WalletOutboxMQConfig `yaml:"wallet_outbox"`
|
||||||
LuckyGiftOutbox LuckyGiftOutboxMQConfig `yaml:"lucky_gift_outbox"`
|
LuckyGiftOutbox LuckyGiftOutboxMQConfig `yaml:"lucky_gift_outbox"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -191,6 +192,15 @@ type UserOutboxMQConfig struct {
|
|||||||
ConsumerMaxReconsumeTimes int32 `yaml:"consumer_max_reconsume_times"`
|
ConsumerMaxReconsumeTimes int32 `yaml:"consumer_max_reconsume_times"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// WalletOutboxMQConfig 控制 wallet owner 的 VIP/资源变化事实消费。
|
||||||
|
// room-service 使用独立 group 更新 Room Cell 装扮,不能复用 notice/statistics 的消费位点。
|
||||||
|
type WalletOutboxMQConfig struct {
|
||||||
|
Enabled bool `yaml:"enabled"`
|
||||||
|
Topic string `yaml:"topic"`
|
||||||
|
ConsumerGroup string `yaml:"consumer_group"`
|
||||||
|
ConsumerMaxReconsumeTimes int32 `yaml:"consumer_max_reconsume_times"`
|
||||||
|
}
|
||||||
|
|
||||||
// LuckyGiftOutboxMQConfig 控制 room-service 消费 lucky-gift-service 已提交的中奖事实。
|
// LuckyGiftOutboxMQConfig 控制 room-service 消费 lucky-gift-service 已提交的中奖事实。
|
||||||
// 该 consumer 只派生房间展示 outbox,不读取幸运礼物库,也不修改 Room Cell 状态。
|
// 该 consumer 只派生房间展示 outbox,不读取幸运礼物库,也不修改 Room Cell 状态。
|
||||||
type LuckyGiftOutboxMQConfig struct {
|
type LuckyGiftOutboxMQConfig struct {
|
||||||
@ -381,6 +391,12 @@ func defaultRocketMQConfig() RocketMQConfig {
|
|||||||
ConsumerGroup: "hyapp-room-user-region-sync",
|
ConsumerGroup: "hyapp-room-user-region-sync",
|
||||||
ConsumerMaxReconsumeTimes: 16,
|
ConsumerMaxReconsumeTimes: 16,
|
||||||
},
|
},
|
||||||
|
WalletOutbox: WalletOutboxMQConfig{
|
||||||
|
Enabled: false,
|
||||||
|
Topic: "hyapp_wallet_outbox",
|
||||||
|
ConsumerGroup: "hyapp-room-wallet-decoration-sync",
|
||||||
|
ConsumerMaxReconsumeTimes: 16,
|
||||||
|
},
|
||||||
LuckyGiftOutbox: LuckyGiftOutboxMQConfig{
|
LuckyGiftOutbox: LuckyGiftOutboxMQConfig{
|
||||||
Enabled: false,
|
Enabled: false,
|
||||||
Topic: "hyapp_lucky_gift_outbox",
|
Topic: "hyapp_lucky_gift_outbox",
|
||||||
@ -629,6 +645,15 @@ func normalizeRocketMQConfig(cfg RocketMQConfig, publishMode string) (RocketMQCo
|
|||||||
if cfg.UserOutbox.ConsumerMaxReconsumeTimes <= 0 {
|
if cfg.UserOutbox.ConsumerMaxReconsumeTimes <= 0 {
|
||||||
cfg.UserOutbox.ConsumerMaxReconsumeTimes = defaults.UserOutbox.ConsumerMaxReconsumeTimes
|
cfg.UserOutbox.ConsumerMaxReconsumeTimes = defaults.UserOutbox.ConsumerMaxReconsumeTimes
|
||||||
}
|
}
|
||||||
|
if cfg.WalletOutbox.Topic = strings.TrimSpace(cfg.WalletOutbox.Topic); cfg.WalletOutbox.Topic == "" {
|
||||||
|
cfg.WalletOutbox.Topic = defaults.WalletOutbox.Topic
|
||||||
|
}
|
||||||
|
if cfg.WalletOutbox.ConsumerGroup = strings.TrimSpace(cfg.WalletOutbox.ConsumerGroup); cfg.WalletOutbox.ConsumerGroup == "" {
|
||||||
|
cfg.WalletOutbox.ConsumerGroup = defaults.WalletOutbox.ConsumerGroup
|
||||||
|
}
|
||||||
|
if cfg.WalletOutbox.ConsumerMaxReconsumeTimes <= 0 {
|
||||||
|
cfg.WalletOutbox.ConsumerMaxReconsumeTimes = defaults.WalletOutbox.ConsumerMaxReconsumeTimes
|
||||||
|
}
|
||||||
if cfg.LuckyGiftOutbox.Topic = strings.TrimSpace(cfg.LuckyGiftOutbox.Topic); cfg.LuckyGiftOutbox.Topic == "" {
|
if cfg.LuckyGiftOutbox.Topic = strings.TrimSpace(cfg.LuckyGiftOutbox.Topic); cfg.LuckyGiftOutbox.Topic == "" {
|
||||||
cfg.LuckyGiftOutbox.Topic = defaults.LuckyGiftOutbox.Topic
|
cfg.LuckyGiftOutbox.Topic = defaults.LuckyGiftOutbox.Topic
|
||||||
}
|
}
|
||||||
@ -638,7 +663,7 @@ func normalizeRocketMQConfig(cfg RocketMQConfig, publishMode string) (RocketMQCo
|
|||||||
if cfg.LuckyGiftOutbox.ConsumerMaxReconsumeTimes <= 0 {
|
if cfg.LuckyGiftOutbox.ConsumerMaxReconsumeTimes <= 0 {
|
||||||
cfg.LuckyGiftOutbox.ConsumerMaxReconsumeTimes = defaults.LuckyGiftOutbox.ConsumerMaxReconsumeTimes
|
cfg.LuckyGiftOutbox.ConsumerMaxReconsumeTimes = defaults.LuckyGiftOutbox.ConsumerMaxReconsumeTimes
|
||||||
}
|
}
|
||||||
if cfg.RoomOutbox.Enabled || cfg.RocketLaunch.Enabled || cfg.UserOutbox.Enabled || cfg.LuckyGiftOutbox.Enabled {
|
if cfg.RoomOutbox.Enabled || cfg.RocketLaunch.Enabled || cfg.UserOutbox.Enabled || cfg.WalletOutbox.Enabled || cfg.LuckyGiftOutbox.Enabled {
|
||||||
cfg.Enabled = true
|
cfg.Enabled = true
|
||||||
}
|
}
|
||||||
if publishMode == OutboxPublishModeMQ || publishMode == OutboxPublishModeDual {
|
if publishMode == OutboxPublishModeMQ || publishMode == OutboxPublishModeDual {
|
||||||
|
|||||||
@ -25,6 +25,18 @@ type WalletClient interface {
|
|||||||
GetMyVip(ctx context.Context, req *walletv1.GetMyVipRequest) (*walletv1.GetMyVipResponse, error)
|
GetMyVip(ctx context.Context, req *walletv1.GetMyVipRequest) (*walletv1.GetMyVipResponse, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// WalletVIPBenefitClient 是新版 VIP 单项权益校验的可选能力。它独立于 WalletClient,
|
||||||
|
// 避免房间测试里的轻量账务 fake 因新增只读 RPC 被迫实现无关方法;生产 gRPC client 必须实现。
|
||||||
|
type WalletVIPBenefitClient interface {
|
||||||
|
CheckVipBenefit(ctx context.Context, req *walletv1.CheckVipBenefitRequest) (*walletv1.CheckVipBenefitResponse, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// WalletUserResourceClient 是房间装扮读取用户有效资源的可选能力。资源归 wallet owner,
|
||||||
|
// room-service 只钉住应用时的展示快照,不能复制 entitlement 有效期和数量算法。
|
||||||
|
type WalletUserResourceClient interface {
|
||||||
|
ListUserResources(ctx context.Context, req *walletv1.ListUserResourcesRequest) (*walletv1.ListUserResourcesResponse, error)
|
||||||
|
}
|
||||||
|
|
||||||
// LuckyGiftClient 抽象 room-service 对 lucky-gift-service 幸运礼物抽奖边界的同步依赖。
|
// LuckyGiftClient 抽象 room-service 对 lucky-gift-service 幸运礼物抽奖边界的同步依赖。
|
||||||
type LuckyGiftClient interface {
|
type LuckyGiftClient interface {
|
||||||
// CheckLuckyGift 在扣费前确认奖池规则可用;失败时不能扣费。
|
// CheckLuckyGift 在扣费前确认奖池规则可用;失败时不能扣费。
|
||||||
|
|||||||
@ -50,6 +50,16 @@ func (c *grpcWalletClient) GetMyVip(ctx context.Context, req *walletv1.GetMyVipR
|
|||||||
return c.client.GetMyVip(ctx, req)
|
return c.client.GetMyVip(ctx, req)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CheckVipBenefit 使用 wallet 已求值的最终权益;room-service 不按 VIP 等级反推。
|
||||||
|
func (c *grpcWalletClient) CheckVipBenefit(ctx context.Context, req *walletv1.CheckVipBenefitRequest) (*walletv1.CheckVipBenefitResponse, error) {
|
||||||
|
return c.client.CheckVipBenefit(ctx, req)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListUserResources 返回 wallet 判定仍有效的 entitlement 和资源目录快照。
|
||||||
|
func (c *grpcWalletClient) ListUserResources(ctx context.Context, req *walletv1.ListUserResourcesRequest) (*walletv1.ListUserResourcesResponse, error) {
|
||||||
|
return c.client.ListUserResources(ctx, req)
|
||||||
|
}
|
||||||
|
|
||||||
type grpcLuckyGiftClient struct {
|
type grpcLuckyGiftClient struct {
|
||||||
client luckygiftv1.LuckyGiftServiceClient
|
client luckygiftv1.LuckyGiftServiceClient
|
||||||
}
|
}
|
||||||
|
|||||||
35
services/room-service/internal/integration/outbox_filter.go
Normal file
35
services/room-service/internal/integration/outbox_filter.go
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
package integration
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
roomeventsv1 "hyapp.local/api/proto/events/room/v1"
|
||||||
|
)
|
||||||
|
|
||||||
|
// eventTypeOutboxPublisher 只把明确需要可靠腾讯 IM 补偿的展示事件转交给下游。
|
||||||
|
// 现有房间事件大多已有 direct-IM lane,不能无差别接入否则会给旧客户端制造双消息。
|
||||||
|
type eventTypeOutboxPublisher struct {
|
||||||
|
inner OutboxPublisher
|
||||||
|
allowedTypes map[string]struct{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewEventTypeOutboxPublisher(inner OutboxPublisher, eventTypes ...string) OutboxPublisher {
|
||||||
|
allowed := make(map[string]struct{}, len(eventTypes))
|
||||||
|
for _, eventType := range eventTypes {
|
||||||
|
if eventType = strings.TrimSpace(eventType); eventType != "" {
|
||||||
|
allowed[eventType] = struct{}{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return &eventTypeOutboxPublisher{inner: inner, allowedTypes: allowed}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *eventTypeOutboxPublisher) PublishOutboxEvent(ctx context.Context, envelope *roomeventsv1.EventEnvelope) error {
|
||||||
|
if p == nil || p.inner == nil || envelope == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if _, allowed := p.allowedTypes[envelope.GetEventType()]; !allowed {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return p.inner.PublishOutboxEvent(ctx, envelope)
|
||||||
|
}
|
||||||
@ -5,6 +5,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"google.golang.org/protobuf/proto"
|
"google.golang.org/protobuf/proto"
|
||||||
roomeventsv1 "hyapp.local/api/proto/events/room/v1"
|
roomeventsv1 "hyapp.local/api/proto/events/room/v1"
|
||||||
@ -243,6 +244,46 @@ func roomEventFromEnvelope(envelope *roomeventsv1.EventEnvelope) (tencentim.Room
|
|||||||
"background_id": fmt.Sprintf("%d", body.GetBackgroundId()),
|
"background_id": fmt.Sprintf("%d", body.GetBackgroundId()),
|
||||||
"room_background_url": body.GetRoomBackgroundUrl(),
|
"room_background_url": body.GetRoomBackgroundUrl(),
|
||||||
}
|
}
|
||||||
|
appendRoomMediaAttributes(base.Attributes, body.GetMedia(), "")
|
||||||
|
return base, true, nil
|
||||||
|
case "RoomDecorationChanged":
|
||||||
|
var body roomeventsv1.RoomDecorationChanged
|
||||||
|
if err := proto.Unmarshal(envelope.GetBody(), &body); err != nil {
|
||||||
|
return tencentim.RoomEvent{}, false, err
|
||||||
|
}
|
||||||
|
if body.GetExpiresAtMs() > 0 && time.Now().UTC().UnixMilli() >= body.GetExpiresAtMs() {
|
||||||
|
// 装扮应用事实继续留在 outbox/MQ,但补偿 IM 在资格到期后不再让客户端恢复失效视觉。
|
||||||
|
return tencentim.RoomEvent{}, false, nil
|
||||||
|
}
|
||||||
|
base.ActorUserID = body.GetActorUserId()
|
||||||
|
colors, err := json.Marshal(body.GetTextColors())
|
||||||
|
if err != nil {
|
||||||
|
return tencentim.RoomEvent{}, false, err
|
||||||
|
}
|
||||||
|
stops, err := json.Marshal(body.GetStops())
|
||||||
|
if err != nil {
|
||||||
|
return tencentim.RoomEvent{}, false, err
|
||||||
|
}
|
||||||
|
base.Attributes = map[string]string{
|
||||||
|
"decoration_type": body.GetDecorationType(), "resource_id": fmt.Sprintf("%d", body.GetResourceId()),
|
||||||
|
"resource_code": body.GetResourceCode(), "resource_type": body.GetResourceType(), "name": body.GetName(),
|
||||||
|
"asset_url": body.GetAssetUrl(), "preview_url": body.GetPreviewUrl(), "animation_url": body.GetAnimationUrl(),
|
||||||
|
"format": body.GetFormat(), "metadata_json": body.GetMetadataJson(), "text_colors": string(colors),
|
||||||
|
"stops": string(stops), "angle_degrees": fmt.Sprintf("%g", body.GetAngleDegrees()),
|
||||||
|
"entitlement_id": body.GetEntitlementId(), "expires_at_ms": fmt.Sprintf("%d", body.GetExpiresAtMs()),
|
||||||
|
}
|
||||||
|
return base, true, nil
|
||||||
|
case "RoomImageMessageSent":
|
||||||
|
var body roomeventsv1.RoomImageMessageSent
|
||||||
|
if err := proto.Unmarshal(envelope.GetBody(), &body); err != nil {
|
||||||
|
return tencentim.RoomEvent{}, false, err
|
||||||
|
}
|
||||||
|
base.ActorUserID = body.GetSenderUserId()
|
||||||
|
base.Attributes = map[string]string{
|
||||||
|
"message_id": body.GetMessageId(), "command_id": body.GetCommandId(),
|
||||||
|
"sender_user_id": fmt.Sprintf("%d", body.GetSenderUserId()),
|
||||||
|
}
|
||||||
|
appendRoomMediaAttributes(base.Attributes, body.GetImage(), "image_")
|
||||||
return base, true, nil
|
return base, true, nil
|
||||||
case "RoomChatEnabledChanged":
|
case "RoomChatEnabledChanged":
|
||||||
// 公屏开关事件提示客户端更新输入态,真正发言仍由 CheckSpeakPermission 拦截。
|
// 公屏开关事件提示客户端更新输入态,真正发言仍由 CheckSpeakPermission 拦截。
|
||||||
@ -626,6 +667,10 @@ func eventTypeForClient(eventType string) string {
|
|||||||
return "room_profile_updated"
|
return "room_profile_updated"
|
||||||
case "RoomBackgroundChanged":
|
case "RoomBackgroundChanged":
|
||||||
return "room_background_changed"
|
return "room_background_changed"
|
||||||
|
case "RoomDecorationChanged":
|
||||||
|
return "room_decoration_changed"
|
||||||
|
case "RoomImageMessageSent":
|
||||||
|
return "room_image_message"
|
||||||
case "RoomChatEnabledChanged":
|
case "RoomChatEnabledChanged":
|
||||||
return "room_chat_enabled_changed"
|
return "room_chat_enabled_changed"
|
||||||
case "RoomPasswordChanged":
|
case "RoomPasswordChanged":
|
||||||
@ -662,3 +707,22 @@ func eventTypeForClient(eventType string) string {
|
|||||||
return eventType
|
return eventType
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func appendRoomMediaAttributes(attributes map[string]string, media *roomeventsv1.RoomMediaSnapshot, prefix string) {
|
||||||
|
if attributes == nil || media == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
attributes[prefix+"url"] = media.GetUrl()
|
||||||
|
attributes[prefix+"object_key"] = media.GetObjectKey()
|
||||||
|
attributes[prefix+"content_type"] = media.GetContentType()
|
||||||
|
attributes[prefix+"format"] = media.GetFormat()
|
||||||
|
attributes[prefix+"size_bytes"] = fmt.Sprintf("%d", media.GetSizeBytes())
|
||||||
|
attributes[prefix+"width"] = fmt.Sprintf("%d", media.GetWidth())
|
||||||
|
attributes[prefix+"height"] = fmt.Sprintf("%d", media.GetHeight())
|
||||||
|
attributes[prefix+"animated"] = fmt.Sprintf("%t", media.GetAnimated())
|
||||||
|
attributes[prefix+"frame_count"] = fmt.Sprintf("%d", media.GetFrameCount())
|
||||||
|
attributes[prefix+"duration_ms"] = fmt.Sprintf("%d", media.GetDurationMs())
|
||||||
|
attributes[prefix+"poster_url"] = media.GetPosterUrl()
|
||||||
|
attributes[prefix+"thumbnail_url"] = media.GetThumbnailUrl()
|
||||||
|
attributes[prefix+"sha256"] = media.GetSha256()
|
||||||
|
}
|
||||||
|
|||||||
@ -4,6 +4,8 @@ package command
|
|||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
|
"hyapp/services/room-service/internal/room/state"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Command 统一表达可写入 command log 的房间命令。
|
// Command 统一表达可写入 command log 的房间命令。
|
||||||
@ -109,10 +111,48 @@ type SetRoomBackground struct {
|
|||||||
BackgroundID int64 `json:"background_id"`
|
BackgroundID int64 `json:"background_id"`
|
||||||
// BackgroundURL 是提交前从房间背景素材表解析出的确定性快照,恢复时不再回查列表表。
|
// BackgroundURL 是提交前从房间背景素材表解析出的确定性快照,恢复时不再回查列表表。
|
||||||
BackgroundURL string `json:"background_url"`
|
BackgroundURL string `json:"background_url"`
|
||||||
|
// Background 保存完整媒体事实;旧 command log 缺字段时恢复继续使用 BackgroundID/BackgroundURL。
|
||||||
|
Background state.RoomBackground `json:"background,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (SetRoomBackground) Type() string { return "set_room_background" }
|
func (SetRoomBackground) Type() string { return "set_room_background" }
|
||||||
|
|
||||||
|
// ApplyRoomDecoration 定义房主把有效 wallet entitlement 应用到指定房间的请求。
|
||||||
|
type ApplyRoomDecoration struct {
|
||||||
|
Base
|
||||||
|
// DecorationType 是客户端动作语义,只接受 room_border/colored_room_name。
|
||||||
|
DecorationType string `json:"decoration_type"`
|
||||||
|
// ResourceID/EntitlementID 是幂等载荷;Resource 是 wallet 校验后固化的恢复快照。
|
||||||
|
ResourceID int64 `json:"resource_id"`
|
||||||
|
EntitlementID string `json:"entitlement_id,omitempty"`
|
||||||
|
Resource state.RoomDecoration `json:"resource,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ApplyRoomDecoration) Type() string { return "apply_room_decoration" }
|
||||||
|
|
||||||
|
// ReconcileRoomDecoration 由 wallet owner 事实触发,用当前 benefit/resource/entitlement 状态更新或清除房间装扮。
|
||||||
|
type ReconcileRoomDecoration struct {
|
||||||
|
Base
|
||||||
|
DecorationType string `json:"decoration_type"`
|
||||||
|
ExpectedResourceID int64 `json:"expected_resource_id"`
|
||||||
|
BenefitCode string `json:"benefit_code"`
|
||||||
|
SourceEventID string `json:"source_event_id"`
|
||||||
|
Active bool `json:"active"`
|
||||||
|
Resource state.RoomDecoration `json:"resource,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ReconcileRoomDecoration) Type() string { return "reconcile_room_decoration" }
|
||||||
|
|
||||||
|
// SendRoomImage 定义由 room-service 权限 owner 接受并写 outbox 的图片消息。
|
||||||
|
// 图片本身已由 gateway 专用上传入口解析,命令仍会复核归属、房态、禁言和 VIP 权益。
|
||||||
|
type SendRoomImage struct {
|
||||||
|
Base
|
||||||
|
MessageID string `json:"message_id"`
|
||||||
|
Image state.RoomMedia `json:"image"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (SendRoomImage) Type() string { return "send_room_image" }
|
||||||
|
|
||||||
// JoinRoom 定义业务进房请求。
|
// JoinRoom 定义业务进房请求。
|
||||||
type EntryVehicleSnapshot struct {
|
type EntryVehicleSnapshot struct {
|
||||||
ResourceID int64 `json:"resource_id,omitempty"`
|
ResourceID int64 `json:"resource_id,omitempty"`
|
||||||
@ -739,6 +779,13 @@ func IdempotencyPayload(payload []byte) ([]byte, error) {
|
|||||||
delete(values, "publish_deadline_ms")
|
delete(values, "publish_deadline_ms")
|
||||||
// ConfirmMicPublishing 的接受时间由 room-service 生成,不能参与客户端请求幂等语义。
|
// ConfirmMicPublishing 的接受时间由 room-service 生成,不能参与客户端请求幂等语义。
|
||||||
delete(values, "accepted_event_time_ms")
|
delete(values, "accepted_event_time_ms")
|
||||||
|
// SetRoomBackground/ApplyRoomDecoration 的完整资源快照来自 owner 表或 wallet,不属于客户端幂等载荷。
|
||||||
|
// 客户端只按 background_id 或 decoration_type/resource_id/entitlement_id 表达动作。
|
||||||
|
delete(values, "background_url")
|
||||||
|
delete(values, "background")
|
||||||
|
delete(values, "resource")
|
||||||
|
// 图片 message_id 由 room_id + command_id 确定性生成;真正的图片属性仍参与冲突判断。
|
||||||
|
delete(values, "message_id")
|
||||||
|
|
||||||
return json.Marshal(values)
|
return json.Marshal(values)
|
||||||
}
|
}
|
||||||
@ -772,6 +819,12 @@ func Deserialize(commandType string, payload []byte) (Command, error) {
|
|||||||
cmd = &UpdateRoomProfile{}
|
cmd = &UpdateRoomProfile{}
|
||||||
case SetRoomBackground{}.Type():
|
case SetRoomBackground{}.Type():
|
||||||
cmd = &SetRoomBackground{}
|
cmd = &SetRoomBackground{}
|
||||||
|
case ApplyRoomDecoration{}.Type():
|
||||||
|
cmd = &ApplyRoomDecoration{}
|
||||||
|
case ReconcileRoomDecoration{}.Type():
|
||||||
|
cmd = &ReconcileRoomDecoration{}
|
||||||
|
case SendRoomImage{}.Type():
|
||||||
|
cmd = &SendRoomImage{}
|
||||||
case JoinRoom{}.Type():
|
case JoinRoom{}.Type():
|
||||||
cmd = &JoinRoom{}
|
cmd = &JoinRoom{}
|
||||||
case RoomHeartbeat{}.Type():
|
case RoomHeartbeat{}.Type():
|
||||||
|
|||||||
@ -2,12 +2,14 @@ package service
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
"unicode/utf8"
|
"unicode/utf8"
|
||||||
|
|
||||||
roomeventsv1 "hyapp.local/api/proto/events/room/v1"
|
roomeventsv1 "hyapp.local/api/proto/events/room/v1"
|
||||||
roomv1 "hyapp.local/api/proto/room/v1"
|
roomv1 "hyapp.local/api/proto/room/v1"
|
||||||
|
"hyapp/pkg/giftlimits"
|
||||||
"hyapp/pkg/roomid"
|
"hyapp/pkg/roomid"
|
||||||
"hyapp/pkg/tencentim"
|
"hyapp/pkg/tencentim"
|
||||||
"hyapp/pkg/xerr"
|
"hyapp/pkg/xerr"
|
||||||
@ -21,28 +23,82 @@ import (
|
|||||||
// 该动作只维护低频素材表,不改变 Room Cell 当前背景;真正生效必须调用 SetRoomBackground。
|
// 该动作只维护低频素材表,不改变 Room Cell 当前背景;真正生效必须调用 SetRoomBackground。
|
||||||
func (s *Service) SaveRoomBackground(ctx context.Context, req *roomv1.SaveRoomBackgroundRequest) (*roomv1.SaveRoomBackgroundResponse, error) {
|
func (s *Service) SaveRoomBackground(ctx context.Context, req *roomv1.SaveRoomBackgroundRequest) (*roomv1.SaveRoomBackgroundResponse, error) {
|
||||||
ctx = contextFromMeta(ctx, req.GetMeta())
|
ctx = contextFromMeta(ctx, req.GetMeta())
|
||||||
roomID, actorUserID, imageURL, err := normalizeSaveRoomBackground(req)
|
roomID, actorUserID, commandID, media, err := normalizeSaveRoomBackground(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
if commandID != "" && !giftlimits.ValidCommandID(commandID) {
|
||||||
|
return nil, xerr.New(xerr.InvalidArgument, "command_id is invalid")
|
||||||
|
}
|
||||||
|
|
||||||
|
imageURL := media.URL
|
||||||
|
replayCommandID := commandID
|
||||||
|
if replayCommandID == "" {
|
||||||
|
// legacy_timed 没有客户端 command_id;这里先算出与首次提交一致的稳定键,确保旧成功也能先于可变权限重放。
|
||||||
|
replayCommandID = legacyRoomBackgroundCommandID(req.GetMeta().GetAppCode(), roomID, actorUserID, imageURL)
|
||||||
|
}
|
||||||
|
if reader, ok := s.repository.(RoomBackgroundCommandReader); ok {
|
||||||
|
existing, exists, readErr := reader.GetRoomBackgroundByCommand(ctx, roomID, replayCommandID)
|
||||||
|
if readErr != nil {
|
||||||
|
return nil, readErr
|
||||||
|
}
|
||||||
|
if exists {
|
||||||
|
if !sameRoomBackgroundSaveRequest(existing, actorUserID, imageURL, media) {
|
||||||
|
return nil, xerr.New(xerr.IdempotencyConflict, "command_id payload conflict")
|
||||||
|
}
|
||||||
|
// 已成功保存是不可变事实。房间随后关闭、owner 元数据变化或 VIP 到期都不能把同一重试改成失败。
|
||||||
|
return &roomv1.SaveRoomBackgroundResponse{
|
||||||
|
Background: roomBackgroundToProto(existing),
|
||||||
|
ServerTimeMs: s.clock.Now().UnixMilli(),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
if err := s.requireRoomOwnerMeta(ctx, roomID, actorUserID); err != nil {
|
if err := s.requireRoomOwnerMeta(ctx, roomID, actorUserID); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if err := s.requireCustomRoomBackgroundBenefit(ctx, req.GetMeta().GetRequestId(), actorUserID); err != nil {
|
access, err := s.loadEffectiveVIPAccess(ctx, req.GetMeta().GetRequestId(), actorUserID)
|
||||||
// 保存素材本身就会形成持久数据,P1 App 必须在写库前校验当前 effective benefit。
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
if access.programType == tieredPrivilegeVIPProgram {
|
||||||
|
if err := s.requireCustomRoomBackgroundBenefit(ctx, req.GetMeta().GetRequestId(), actorUserID, "save_room_background"); err != nil {
|
||||||
|
// Fami P1 的素材保存会形成持久数据,必须在写库前校验当前精确 benefit。
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if !giftlimits.ValidCommandID(commandID) {
|
||||||
|
return nil, xerr.New(xerr.InvalidArgument, "command_id is invalid")
|
||||||
|
}
|
||||||
|
if err := validateRoomMedia(req.GetMeta().GetAppCode(), roomID, actorUserID, roomMediaPurposeBackground, media); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := s.requireActiveRoomMediaUpload(ctx, roomID, actorUserID, roomMediaPurposeBackground, commandID, media); err != nil {
|
||||||
|
// URL/key 前缀只能证明路径归属;这里还要用 active registration 锁定首次嗅探的哈希、尺寸和格式。
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Lalu legacy_timed 保留历史 image_url-only 协议;服务端按 URL 生成稳定幂等键,空 Media 表示未经过新版专用上传。
|
||||||
|
if media.URL == "" || utf8.RuneCountInString(media.URL) > maxRoomBackgroundRunes {
|
||||||
|
return nil, xerr.New(xerr.InvalidArgument, "image_url is invalid")
|
||||||
|
}
|
||||||
|
commandID = replayCommandID
|
||||||
|
media = state.RoomMedia{}
|
||||||
|
}
|
||||||
|
|
||||||
nowMS := s.clock.Now().UnixMilli()
|
nowMS := s.clock.Now().UnixMilli()
|
||||||
background, err := s.repository.SaveRoomBackground(ctx, RoomBackgroundImage{
|
background, err := s.repository.SaveRoomBackground(ctx, RoomBackgroundImage{
|
||||||
AppCode: req.GetMeta().GetAppCode(),
|
AppCode: req.GetMeta().GetAppCode(),
|
||||||
RoomID: roomID,
|
RoomID: roomID,
|
||||||
ImageURL: imageURL,
|
ImageURL: imageURL,
|
||||||
|
CommandID: commandID,
|
||||||
|
Media: media,
|
||||||
CreatedByUserID: actorUserID,
|
CreatedByUserID: actorUserID,
|
||||||
CreatedAtMS: nowMS,
|
CreatedAtMS: nowMS,
|
||||||
UpdatedAtMS: nowMS,
|
UpdatedAtMS: nowMS,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
if errors.Is(err, ErrRoomBackgroundCommandConflict) {
|
||||||
|
return nil, xerr.New(xerr.IdempotencyConflict, "command_id payload conflict")
|
||||||
|
}
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -76,6 +132,7 @@ func (s *Service) ListRoomBackgrounds(ctx context.Context, req *roomv1.ListRoomB
|
|||||||
return &roomv1.ListRoomBackgroundsResponse{
|
return &roomv1.ListRoomBackgroundsResponse{
|
||||||
Backgrounds: roomBackgroundsToProto(backgrounds),
|
Backgrounds: roomBackgroundsToProto(backgrounds),
|
||||||
SelectedBackgroundUrl: snapshot.GetRoomExt()[roomExtBackgroundKey],
|
SelectedBackgroundUrl: snapshot.GetRoomExt()[roomExtBackgroundKey],
|
||||||
|
SelectedBackground: snapshot.GetActiveBackground(),
|
||||||
ServerTimeMs: s.clock.Now().UnixMilli(),
|
ServerTimeMs: s.clock.Now().UnixMilli(),
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
@ -88,21 +145,42 @@ func (s *Service) SetRoomBackground(ctx context.Context, req *roomv1.SetRoomBack
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if err := s.requireRoomOwnerMeta(ctx, roomID, actorUserID); err != nil {
|
cmd := command.SetRoomBackground{
|
||||||
return nil, err
|
Base: baseFromMeta(req.GetMeta()),
|
||||||
|
BackgroundID: backgroundID,
|
||||||
}
|
}
|
||||||
background, exists, err := s.repository.GetRoomBackground(ctx, roomID, backgroundID)
|
_, record, seen, err := s.commandRecordForIdempotency(ctx, cmd)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if !exists {
|
|
||||||
return nil, xerr.New(xerr.NotFound, "room background not found")
|
|
||||||
}
|
|
||||||
|
|
||||||
cmd := command.SetRoomBackground{
|
var background RoomBackgroundImage
|
||||||
Base: baseFromMeta(req.GetMeta()),
|
if seen {
|
||||||
BackgroundID: background.BackgroundID,
|
stored, deserializeErr := command.Deserialize(record.CommandType, record.Payload)
|
||||||
BackgroundURL: background.ImageURL,
|
if deserializeErr != nil {
|
||||||
|
return nil, deserializeErr
|
||||||
|
}
|
||||||
|
storedCommand, ok := stored.(*command.SetRoomBackground)
|
||||||
|
if !ok {
|
||||||
|
return nil, xerr.New(xerr.Internal, "stored room background command is invalid")
|
||||||
|
}
|
||||||
|
cmd = *storedCommand
|
||||||
|
background = roomBackgroundFromSetCommand(cmd)
|
||||||
|
} else {
|
||||||
|
if err := s.requireRoomOwnerMeta(ctx, roomID, actorUserID); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var exists bool
|
||||||
|
background, exists, err = s.repository.GetRoomBackground(ctx, roomID, backgroundID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if !exists {
|
||||||
|
return nil, xerr.New(xerr.NotFound, "room background not found")
|
||||||
|
}
|
||||||
|
cmd.BackgroundID = background.BackgroundID
|
||||||
|
cmd.BackgroundURL = background.ImageURL
|
||||||
|
cmd.Background = roomBackgroundState(background)
|
||||||
}
|
}
|
||||||
result, err := s.mutateRoom(ctx, cmd, true, func(now time.Time, current *state.RoomState, _ *rank.LocalRank) (mutationResult, []outbox.Record, error) {
|
result, err := s.mutateRoom(ctx, cmd, true, func(now time.Time, current *state.RoomState, _ *rank.LocalRank) (mutationResult, []outbox.Record, error) {
|
||||||
if err := requireActiveRoom(current); err != nil {
|
if err := requireActiveRoom(current); err != nil {
|
||||||
@ -111,23 +189,31 @@ func (s *Service) SetRoomBackground(ctx context.Context, req *roomv1.SetRoomBack
|
|||||||
if current.OwnerUserID != cmd.ActorUserID() {
|
if current.OwnerUserID != cmd.ActorUserID() {
|
||||||
return mutationResult{}, nil, xerr.New(xerr.PermissionDenied, "permission denied")
|
return mutationResult{}, nil, xerr.New(xerr.PermissionDenied, "permission denied")
|
||||||
}
|
}
|
||||||
if err := s.requireCustomRoomBackgroundBenefit(ctx, req.GetMeta().GetRequestId(), cmd.ActorUserID()); err != nil {
|
if err := s.requireCustomRoomBackgroundBenefit(ctx, req.GetMeta().GetRequestId(), cmd.ActorUserID(), "set_room_background"); err != nil {
|
||||||
// 放在 mutate 回调内可让同一 command_id 的已提交重试先命中幂等,不会因 VIP 后续到期改写旧命令结果。
|
// 放在 mutate 回调内可让同一 command_id 的已提交重试先命中幂等,不会因 VIP 后续到期改写旧命令结果。
|
||||||
return mutationResult{}, nil, err
|
return mutationResult{}, nil, err
|
||||||
}
|
}
|
||||||
if current.RoomExt == nil {
|
if current.RoomExt == nil {
|
||||||
current.RoomExt = make(map[string]string)
|
current.RoomExt = make(map[string]string)
|
||||||
}
|
}
|
||||||
if current.RoomExt[roomExtBackgroundKey] == cmd.BackgroundURL {
|
if (current.ActiveBackground != nil && current.ActiveBackground.BackgroundID == cmd.BackgroundID) ||
|
||||||
|
(current.ActiveBackground == nil && current.RoomExt[roomExtBackgroundKey] == cmd.BackgroundURL) {
|
||||||
return mutationResult{snapshot: current.ToProto()}, nil, nil
|
return mutationResult{snapshot: current.ToProto()}, nil, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
current.RoomExt[roomExtBackgroundKey] = cmd.BackgroundURL
|
current.RoomExt[roomExtBackgroundKey] = cmd.BackgroundURL
|
||||||
|
if cmd.Background.Media.URL != "" {
|
||||||
|
backgroundSnapshot := cmd.Background
|
||||||
|
current.ActiveBackground = &backgroundSnapshot
|
||||||
|
} else {
|
||||||
|
current.ActiveBackground = nil
|
||||||
|
}
|
||||||
current.Version++
|
current.Version++
|
||||||
backgroundEvent, err := outbox.Build(current.RoomID, "RoomBackgroundChanged", current.Version, now, &roomeventsv1.RoomBackgroundChanged{
|
backgroundEvent, err := outbox.Build(current.RoomID, "RoomBackgroundChanged", current.Version, now, &roomeventsv1.RoomBackgroundChanged{
|
||||||
ActorUserId: cmd.ActorUserID(),
|
ActorUserId: cmd.ActorUserID(),
|
||||||
BackgroundId: cmd.BackgroundID,
|
BackgroundId: cmd.BackgroundID,
|
||||||
RoomBackgroundUrl: current.RoomExt[roomExtBackgroundKey],
|
RoomBackgroundUrl: current.RoomExt[roomExtBackgroundKey],
|
||||||
|
Media: roomMediaEventSnapshot(cmd.Background.Media),
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return mutationResult{}, nil, err
|
return mutationResult{}, nil, err
|
||||||
@ -158,29 +244,66 @@ func (s *Service) SetRoomBackground(ctx context.Context, req *roomv1.SetRoomBack
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func normalizeSaveRoomBackground(req *roomv1.SaveRoomBackgroundRequest) (string, int64, string, error) {
|
func sameRoomBackgroundSaveRequest(existing RoomBackgroundImage, actorUserID int64, imageURL string, media state.RoomMedia) bool {
|
||||||
|
if existing.CreatedByUserID != actorUserID || strings.TrimSpace(existing.ImageURL) != strings.TrimSpace(imageURL) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(media.ObjectKey) != "" {
|
||||||
|
// 新版媒体事实全部由 gateway 解析并由 room-service 校验;任一字段变化都代表 command_id 被复用。
|
||||||
|
return existing.Media == media
|
||||||
|
}
|
||||||
|
legacyCandidate := media
|
||||||
|
legacyCandidate.URL = ""
|
||||||
|
// legacy_timed 只接受 image_url,其余媒体字段必须同时为空,不能借旧成功绕过新版专用上传校验。
|
||||||
|
return existing.Media == (state.RoomMedia{}) && legacyCandidate == (state.RoomMedia{})
|
||||||
|
}
|
||||||
|
|
||||||
|
func roomBackgroundFromSetCommand(cmd command.SetRoomBackground) RoomBackgroundImage {
|
||||||
|
backgroundID := cmd.Background.BackgroundID
|
||||||
|
if backgroundID <= 0 {
|
||||||
|
backgroundID = cmd.BackgroundID
|
||||||
|
}
|
||||||
|
roomID := strings.TrimSpace(cmd.Background.RoomID)
|
||||||
|
if roomID == "" {
|
||||||
|
roomID = cmd.RoomID()
|
||||||
|
}
|
||||||
|
createdByUserID := cmd.Background.CreatedByUserID
|
||||||
|
if createdByUserID <= 0 {
|
||||||
|
createdByUserID = cmd.ActorUserID()
|
||||||
|
}
|
||||||
|
// command log 固化第一次应用时的素材快照;旧记录没有 Background 时继续用 BackgroundURL 返回历史结果。
|
||||||
|
return RoomBackgroundImage{
|
||||||
|
AppCode: cmd.AppCode, BackgroundID: backgroundID, RoomID: roomID,
|
||||||
|
ImageURL: strings.TrimSpace(cmd.BackgroundURL), CommandID: cmd.Background.CommandID, Media: cmd.Background.Media,
|
||||||
|
CreatedByUserID: createdByUserID, CreatedAtMS: cmd.Background.CreatedAtMS, UpdatedAtMS: cmd.Background.UpdatedAtMS,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeSaveRoomBackground(req *roomv1.SaveRoomBackgroundRequest) (string, int64, string, state.RoomMedia, error) {
|
||||||
if req == nil || req.GetMeta() == nil {
|
if req == nil || req.GetMeta() == nil {
|
||||||
return "", 0, "", xerr.New(xerr.InvalidArgument, "request is required")
|
return "", 0, "", state.RoomMedia{}, xerr.New(xerr.InvalidArgument, "request is required")
|
||||||
}
|
}
|
||||||
roomID := strings.TrimSpace(req.GetRoomId())
|
roomID := strings.TrimSpace(req.GetRoomId())
|
||||||
if roomID == "" {
|
if roomID == "" {
|
||||||
roomID = strings.TrimSpace(req.GetMeta().GetRoomId())
|
roomID = strings.TrimSpace(req.GetMeta().GetRoomId())
|
||||||
}
|
}
|
||||||
if !roomid.ValidStringID(roomID) {
|
if !roomid.ValidStringID(roomID) {
|
||||||
return "", 0, "", xerr.New(xerr.InvalidArgument, "room_id is invalid")
|
return "", 0, "", state.RoomMedia{}, xerr.New(xerr.InvalidArgument, "room_id is invalid")
|
||||||
}
|
}
|
||||||
actorUserID := req.GetMeta().GetActorUserId()
|
actorUserID := req.GetMeta().GetActorUserId()
|
||||||
if actorUserID <= 0 {
|
if actorUserID <= 0 {
|
||||||
return "", 0, "", xerr.New(xerr.InvalidArgument, "actor_user_id is required")
|
return "", 0, "", state.RoomMedia{}, xerr.New(xerr.InvalidArgument, "actor_user_id is required")
|
||||||
}
|
}
|
||||||
imageURL := strings.TrimSpace(req.GetImageUrl())
|
commandID := strings.TrimSpace(req.GetMeta().GetCommandId())
|
||||||
if imageURL == "" {
|
media := roomMediaFromProto(req.GetMedia())
|
||||||
return "", 0, "", xerr.New(xerr.InvalidArgument, "image_url is required")
|
requestImageURL := strings.TrimSpace(req.GetImageUrl())
|
||||||
|
if media.URL != "" && requestImageURL != "" && media.URL != requestImageURL {
|
||||||
|
return "", 0, "", state.RoomMedia{}, xerr.New(xerr.InvalidArgument, "image_url does not match media.url")
|
||||||
}
|
}
|
||||||
if utf8.RuneCountInString(imageURL) > maxRoomBackgroundRunes {
|
if media.URL == "" {
|
||||||
return "", 0, "", xerr.New(xerr.InvalidArgument, "image_url is too long")
|
media.URL = requestImageURL
|
||||||
}
|
}
|
||||||
return roomID, actorUserID, imageURL, nil
|
return roomID, actorUserID, commandID, media, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func normalizeListRoomBackgrounds(req *roomv1.ListRoomBackgroundsRequest) (string, int64, error) {
|
func normalizeListRoomBackgrounds(req *roomv1.ListRoomBackgroundsRequest) (string, int64, error) {
|
||||||
@ -235,25 +358,3 @@ func (s *Service) requireRoomOwnerMeta(ctx context.Context, roomID string, actor
|
|||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func roomBackgroundToProto(background RoomBackgroundImage) *roomv1.RoomBackgroundImage {
|
|
||||||
if background.BackgroundID == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return &roomv1.RoomBackgroundImage{
|
|
||||||
BackgroundId: background.BackgroundID,
|
|
||||||
RoomId: background.RoomID,
|
|
||||||
ImageUrl: background.ImageURL,
|
|
||||||
CreatedByUserId: background.CreatedByUserID,
|
|
||||||
CreatedAtMs: background.CreatedAtMS,
|
|
||||||
UpdatedAtMs: background.UpdatedAtMS,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func roomBackgroundsToProto(backgrounds []RoomBackgroundImage) []*roomv1.RoomBackgroundImage {
|
|
||||||
out := make([]*roomv1.RoomBackgroundImage, 0, len(backgrounds))
|
|
||||||
for _, background := range backgrounds {
|
|
||||||
out = append(out, roomBackgroundToProto(background))
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|||||||
290
services/room-service/internal/room/service/decoration.go
Normal file
290
services/room-service/internal/room/service/decoration.go
Normal file
@ -0,0 +1,290 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"math"
|
||||||
|
"reflect"
|
||||||
|
"regexp"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
roomeventsv1 "hyapp.local/api/proto/events/room/v1"
|
||||||
|
roomv1 "hyapp.local/api/proto/room/v1"
|
||||||
|
walletv1 "hyapp.local/api/proto/wallet/v1"
|
||||||
|
"hyapp/pkg/appcode"
|
||||||
|
"hyapp/pkg/xerr"
|
||||||
|
"hyapp/services/room-service/internal/integration"
|
||||||
|
"hyapp/services/room-service/internal/room/command"
|
||||||
|
"hyapp/services/room-service/internal/room/outbox"
|
||||||
|
"hyapp/services/room-service/internal/room/rank"
|
||||||
|
"hyapp/services/room-service/internal/room/state"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
roomDecorationTypeBorder = "room_border"
|
||||||
|
roomDecorationTypeColoredName = "colored_room_name"
|
||||||
|
roomDecorationResourceBorder = "room_border"
|
||||||
|
roomDecorationResourceName = "room_name_style"
|
||||||
|
)
|
||||||
|
|
||||||
|
var roomDecorationColorPattern = regexp.MustCompile(`^#[0-9a-fA-F]{6}([0-9a-fA-F]{2})?$`)
|
||||||
|
|
||||||
|
type roomDecorationMetadata struct {
|
||||||
|
Format string `json:"format"`
|
||||||
|
TextColors []string `json:"text_colors"`
|
||||||
|
TextColorsLegacy []string `json:"textColors"`
|
||||||
|
Stops []float64 `json:"stops"`
|
||||||
|
AngleDegrees *float64 `json:"angle_degrees"`
|
||||||
|
AngleLegacy *float64 `json:"angleDegrees"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ApplyRoomDecoration 把 wallet 当前仍授权的资源快照串行写入 Room Cell。
|
||||||
|
// entitlement 与 VIP benefit 都必须精确绑定同一 resource,防止会员降级后复用历史高等级样式。
|
||||||
|
func (s *Service) ApplyRoomDecoration(ctx context.Context, req *roomv1.ApplyRoomDecorationRequest) (*roomv1.ApplyRoomDecorationResponse, error) {
|
||||||
|
ctx = contextFromMeta(ctx, req.GetMeta())
|
||||||
|
decorationType, resourceType, benefitCode, err := normalizeRoomDecorationRequest(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
cmd := command.ApplyRoomDecoration{
|
||||||
|
Base: baseFromMeta(req.GetMeta()), DecorationType: decorationType,
|
||||||
|
ResourceID: req.GetResourceId(), EntitlementID: strings.TrimSpace(req.GetEntitlementId()),
|
||||||
|
}
|
||||||
|
// 已提交命令必须先于 wallet 当前态重放;否则 VIP/entitlement 后续到期会把合法重试改成失败。
|
||||||
|
if _, record, seen, checkErr := s.commandRecordForIdempotency(ctx, cmd); checkErr != nil {
|
||||||
|
return nil, checkErr
|
||||||
|
} else if seen {
|
||||||
|
stored, deserializeErr := command.Deserialize(record.CommandType, record.Payload)
|
||||||
|
if deserializeErr != nil {
|
||||||
|
return nil, deserializeErr
|
||||||
|
}
|
||||||
|
storedCommand, ok := stored.(*command.ApplyRoomDecoration)
|
||||||
|
if !ok {
|
||||||
|
return nil, xerr.New(xerr.Internal, "stored room decoration command is invalid")
|
||||||
|
}
|
||||||
|
cmd = *storedCommand
|
||||||
|
} else {
|
||||||
|
if err := s.requireRoomOwnerMeta(ctx, cmd.RoomID(), cmd.ActorUserID()); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
resource, resolveErr := s.resolveRoomDecoration(ctx, req.GetMeta().GetRequestId(), cmd.ActorUserID(), cmd.ResourceID, cmd.EntitlementID, resourceType, benefitCode)
|
||||||
|
if resolveErr != nil {
|
||||||
|
return nil, resolveErr
|
||||||
|
}
|
||||||
|
cmd.Resource = resource
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := s.mutateRoom(ctx, cmd, true, func(now time.Time, current *state.RoomState, _ *rank.LocalRank) (mutationResult, []outbox.Record, error) {
|
||||||
|
if err := requireActiveRoom(current); err != nil {
|
||||||
|
return mutationResult{}, nil, err
|
||||||
|
}
|
||||||
|
if current.OwnerUserID != cmd.ActorUserID() {
|
||||||
|
return mutationResult{}, nil, xerr.New(xerr.PermissionDenied, "permission denied")
|
||||||
|
}
|
||||||
|
if cmd.Resource.ResourceID <= 0 || (cmd.Resource.ExpiresAtMS > 0 && now.UnixMilli() >= cmd.Resource.ExpiresAtMS) {
|
||||||
|
return mutationResult{}, nil, xerr.NewWithMetadata(xerr.VIPBenefitRequired, "room decoration entitlement expired", map[string]string{
|
||||||
|
"benefit_code": benefitCode, "required_level": "0", "current_effective_level": "0", "action": "apply_room_decoration",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
var currentDecoration **state.RoomDecoration
|
||||||
|
switch cmd.DecorationType {
|
||||||
|
case roomDecorationTypeBorder:
|
||||||
|
currentDecoration = ¤t.RoomBorder
|
||||||
|
case roomDecorationTypeColoredName:
|
||||||
|
currentDecoration = ¤t.RoomNameStyle
|
||||||
|
default:
|
||||||
|
return mutationResult{}, nil, xerr.New(xerr.InvalidArgument, "decoration_type is invalid")
|
||||||
|
}
|
||||||
|
assignmentChange := roomDecorationAssignmentChange(current, cmd.DecorationType, benefitCode, cmd.Resource, now.UnixMilli())
|
||||||
|
if *currentDecoration != nil && reflect.DeepEqual(**currentDecoration, cmd.Resource) {
|
||||||
|
return mutationResult{snapshot: current.ToProtoAt(now.UnixMilli()), roomDecorationAssignment: assignmentChange}, nil, nil
|
||||||
|
}
|
||||||
|
decoration := cmd.Resource
|
||||||
|
*currentDecoration = &decoration
|
||||||
|
current.Version++
|
||||||
|
event, err := outbox.Build(current.RoomID, "RoomDecorationChanged", current.Version, now, &roomeventsv1.RoomDecorationChanged{
|
||||||
|
ActorUserId: cmd.ActorUserID(), DecorationType: cmd.DecorationType, ResourceId: decoration.ResourceID,
|
||||||
|
ResourceCode: decoration.ResourceCode, ResourceType: decoration.ResourceType, Name: decoration.Name,
|
||||||
|
AssetUrl: decoration.AssetURL, PreviewUrl: decoration.PreviewURL, AnimationUrl: decoration.AnimationURL,
|
||||||
|
Format: decoration.Format, MetadataJson: decoration.MetadataJSON, TextColors: append([]string(nil), decoration.TextColors...),
|
||||||
|
Stops: append([]float64(nil), decoration.Stops...), AngleDegrees: decoration.AngleDegrees,
|
||||||
|
EntitlementId: decoration.EntitlementID, ExpiresAtMs: decoration.ExpiresAtMS,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return mutationResult{}, nil, err
|
||||||
|
}
|
||||||
|
return mutationResult{snapshot: current.ToProtoAt(now.UnixMilli()), roomDecorationAssignment: assignmentChange}, []outbox.Record{event}, nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
resource := roomDecorationToProto(&cmd.Resource, s.clock.Now().UnixMilli())
|
||||||
|
return &roomv1.ApplyRoomDecorationResponse{
|
||||||
|
Result: commandResult(result.applied, result.snapshot.GetVersion(), s.clock.Now()), Room: result.snapshot,
|
||||||
|
DecorationType: decorationType, Resource: resource, ServerTimeMs: s.clock.Now().UnixMilli(),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func roomDecorationAssignmentChange(current *state.RoomState, decorationType string, benefitCode string, resource state.RoomDecoration, nowMS int64) *RoomDecorationAssignmentChange {
|
||||||
|
assignment := &RoomDecorationAssignment{
|
||||||
|
// AppCode 由 SaveMutation 使用当前请求上下文写入,避免把租户字段复制进 RoomState。
|
||||||
|
RoomID: current.RoomID, DecorationType: decorationType, OwnerUserID: current.OwnerUserID,
|
||||||
|
BenefitCode: benefitCode, ResourceID: resource.ResourceID, EntitlementID: resource.EntitlementID,
|
||||||
|
ExpiresAtMS: resource.ExpiresAtMS, UpdatedAtMS: nowMS,
|
||||||
|
}
|
||||||
|
return &RoomDecorationAssignmentChange{DecorationType: decorationType, Assignment: assignment}
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeRoomDecorationRequest(req *roomv1.ApplyRoomDecorationRequest) (string, string, string, error) {
|
||||||
|
if req == nil || req.GetMeta() == nil || strings.TrimSpace(req.GetMeta().GetCommandId()) == "" || req.GetResourceId() <= 0 {
|
||||||
|
return "", "", "", xerr.New(xerr.InvalidArgument, "room decoration request is incomplete")
|
||||||
|
}
|
||||||
|
switch normalizeVIPCode(req.GetDecorationType()) {
|
||||||
|
case roomDecorationTypeBorder:
|
||||||
|
return roomDecorationTypeBorder, roomDecorationResourceBorder, vipBenefitRoomBorder, nil
|
||||||
|
case roomDecorationTypeColoredName:
|
||||||
|
return roomDecorationTypeColoredName, roomDecorationResourceName, vipBenefitColoredRoomName, nil
|
||||||
|
default:
|
||||||
|
return "", "", "", xerr.New(xerr.InvalidArgument, "decoration_type is invalid")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) resolveRoomDecoration(ctx context.Context, requestID string, userID int64, resourceID int64, entitlementID string, resourceType string, benefitCode string) (state.RoomDecoration, error) {
|
||||||
|
checker, ok := s.wallet.(integration.WalletVIPBenefitClient)
|
||||||
|
if !ok {
|
||||||
|
return state.RoomDecoration{}, xerr.New(xerr.Unavailable, "wallet vip benefit client is unavailable")
|
||||||
|
}
|
||||||
|
lookupCtx, cancel := context.WithTimeout(ctx, roomVIPLookupTimeout)
|
||||||
|
defer cancel()
|
||||||
|
decision, err := checker.CheckVipBenefit(lookupCtx, &walletv1.CheckVipBenefitRequest{
|
||||||
|
RequestId: strings.TrimSpace(requestID), AppCode: appcode.FromContext(ctx), UserId: userID, BenefitCode: benefitCode,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return state.RoomDecoration{}, xerr.New(xerr.Unavailable, "check room decoration vip benefit failed")
|
||||||
|
}
|
||||||
|
benefit := decision.GetBenefit()
|
||||||
|
catalogResource := benefit.GetResource()
|
||||||
|
if decision == nil || !decision.GetAllowed() || benefit == nil || benefit.GetResourceId() != resourceID ||
|
||||||
|
normalizeVIPCode(benefit.GetResourceType()) != resourceType || catalogResource == nil ||
|
||||||
|
catalogResource.GetResourceId() != resourceID || normalizeVIPCode(catalogResource.GetResourceType()) != resourceType ||
|
||||||
|
normalizeVIPCode(catalogResource.GetStatus()) != "active" {
|
||||||
|
requiredLevel, currentLevel := int32(0), int32(0)
|
||||||
|
if decision != nil {
|
||||||
|
requiredLevel, currentLevel = decision.GetRequiredLevel(), decision.GetCurrentEffectiveLevel()
|
||||||
|
}
|
||||||
|
return state.RoomDecoration{}, xerr.NewWithMetadata(xerr.VIPBenefitRequired, "room decoration vip benefit is required", map[string]string{
|
||||||
|
"benefit_code": benefitCode, "required_level": strconv.FormatInt(int64(requiredLevel), 10),
|
||||||
|
"current_effective_level": strconv.FormatInt(int64(currentLevel), 10), "action": "apply_room_decoration",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
resources, ok := s.wallet.(integration.WalletUserResourceClient)
|
||||||
|
if !ok {
|
||||||
|
return state.RoomDecoration{}, xerr.New(xerr.Unavailable, "wallet user resource client is unavailable")
|
||||||
|
}
|
||||||
|
resourceCtx, resourceCancel := context.WithTimeout(ctx, roomVIPLookupTimeout)
|
||||||
|
defer resourceCancel()
|
||||||
|
resourceResponse, err := resources.ListUserResources(resourceCtx, &walletv1.ListUserResourcesRequest{
|
||||||
|
RequestId: strings.TrimSpace(requestID), AppCode: appcode.FromContext(ctx), UserId: userID,
|
||||||
|
ResourceType: resourceType, ActiveOnly: true,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return state.RoomDecoration{}, xerr.New(xerr.Unavailable, "list room decoration resources failed")
|
||||||
|
}
|
||||||
|
nowMS := s.clock.Now().UnixMilli()
|
||||||
|
for _, entitlement := range resourceResponse.GetResources() {
|
||||||
|
entitlementResource := entitlement.GetResource()
|
||||||
|
if entitlement.GetResourceId() != resourceID || entitlementResource == nil || entitlementResource.GetResourceId() != resourceID ||
|
||||||
|
normalizeVIPCode(entitlementResource.GetResourceType()) != resourceType {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if entitlementID != "" && strings.TrimSpace(entitlement.GetEntitlementId()) != entitlementID {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if normalizeVIPCode(entitlement.GetStatus()) != "active" || entitlement.GetEffectiveAtMs() > nowMS ||
|
||||||
|
(entitlement.GetExpiresAtMs() > 0 && entitlement.GetExpiresAtMs() <= nowMS) || entitlement.GetRemainingQuantity() <= 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// entitlement 只证明该用户仍有权使用 resource_id;展示内容必须取 CheckVipBenefit 返回的
|
||||||
|
// 当前 active 目录资源。这样后台更新素材后会刷新房间,且不会继续渲染 entitlement 的历史快照。
|
||||||
|
metadata, metadataErr := parseRoomDecorationMetadata(resourceType, catalogResource.GetMetadataJson())
|
||||||
|
if metadataErr != nil {
|
||||||
|
return state.RoomDecoration{}, metadataErr
|
||||||
|
}
|
||||||
|
vipExpiresAtMS := int64(0)
|
||||||
|
if decision.GetState() != nil && decision.GetState().GetEffectiveVip() != nil {
|
||||||
|
vipExpiresAtMS = decision.GetState().GetEffectiveVip().GetExpiresAtMs()
|
||||||
|
}
|
||||||
|
angleDegrees := float64(0)
|
||||||
|
if metadata.AngleDegrees != nil {
|
||||||
|
angleDegrees = *metadata.AngleDegrees
|
||||||
|
}
|
||||||
|
return state.RoomDecoration{
|
||||||
|
ResourceID: catalogResource.ResourceId, ResourceCode: catalogResource.ResourceCode, ResourceType: catalogResource.ResourceType,
|
||||||
|
Name: catalogResource.Name, AssetURL: catalogResource.AssetUrl, PreviewURL: catalogResource.PreviewUrl, AnimationURL: catalogResource.AnimationUrl,
|
||||||
|
Format: metadata.Format, MetadataJSON: catalogResource.MetadataJson, EntitlementID: entitlement.EntitlementId,
|
||||||
|
TextColors: metadata.TextColors, Stops: metadata.Stops, AngleDegrees: angleDegrees,
|
||||||
|
ExpiresAtMS: minNonZeroMS(entitlement.GetExpiresAtMs(), vipExpiresAtMS),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
return state.RoomDecoration{}, xerr.New(xerr.NotFound, "active room decoration entitlement not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseRoomDecorationMetadata(resourceType string, raw string) (roomDecorationMetadata, error) {
|
||||||
|
var metadata roomDecorationMetadata
|
||||||
|
if strings.TrimSpace(raw) != "" && json.Unmarshal([]byte(raw), &metadata) != nil {
|
||||||
|
return roomDecorationMetadata{}, xerr.New(xerr.InvalidArgument, "room decoration metadata_json is invalid")
|
||||||
|
}
|
||||||
|
if len(metadata.TextColors) == 0 {
|
||||||
|
metadata.TextColors = metadata.TextColorsLegacy
|
||||||
|
}
|
||||||
|
if metadata.AngleDegrees == nil {
|
||||||
|
metadata.AngleDegrees = metadata.AngleLegacy
|
||||||
|
}
|
||||||
|
metadata.Format = strings.ToLower(strings.TrimSpace(metadata.Format))
|
||||||
|
if metadata.AngleDegrees != nil && (math.IsNaN(*metadata.AngleDegrees) || math.IsInf(*metadata.AngleDegrees, 0)) {
|
||||||
|
return roomDecorationMetadata{}, xerr.New(xerr.InvalidArgument, "room decoration angle is invalid")
|
||||||
|
}
|
||||||
|
if resourceType == roomDecorationResourceName {
|
||||||
|
if len(metadata.TextColors) == 0 || len(metadata.TextColors) > 8 || (len(metadata.Stops) != 0 && len(metadata.Stops) != len(metadata.TextColors)) {
|
||||||
|
return roomDecorationMetadata{}, xerr.New(xerr.InvalidArgument, "room name style colors are invalid")
|
||||||
|
}
|
||||||
|
for _, color := range metadata.TextColors {
|
||||||
|
if !roomDecorationColorPattern.MatchString(strings.TrimSpace(color)) {
|
||||||
|
return roomDecorationMetadata{}, xerr.New(xerr.InvalidArgument, "room name style color is invalid")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
previous := -1.0
|
||||||
|
for _, stop := range metadata.Stops {
|
||||||
|
if math.IsNaN(stop) || math.IsInf(stop, 0) || stop < 0 || stop > 1 || stop < previous {
|
||||||
|
return roomDecorationMetadata{}, xerr.New(xerr.InvalidArgument, "room name style stops are invalid")
|
||||||
|
}
|
||||||
|
previous = stop
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return metadata, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func minNonZeroMS(left int64, right int64) int64 {
|
||||||
|
if left <= 0 {
|
||||||
|
return right
|
||||||
|
}
|
||||||
|
if right <= 0 || left < right {
|
||||||
|
return left
|
||||||
|
}
|
||||||
|
return right
|
||||||
|
}
|
||||||
|
|
||||||
|
func roomDecorationToProto(resource *state.RoomDecoration, nowMS int64) *roomv1.RoomDecorationResource {
|
||||||
|
if resource == nil || resource.ResourceID <= 0 || (resource.ExpiresAtMS > 0 && nowMS >= resource.ExpiresAtMS) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &roomv1.RoomDecorationResource{
|
||||||
|
ResourceId: resource.ResourceID, ResourceCode: resource.ResourceCode, ResourceType: resource.ResourceType,
|
||||||
|
Name: resource.Name, AssetUrl: resource.AssetURL, PreviewUrl: resource.PreviewURL, AnimationUrl: resource.AnimationURL,
|
||||||
|
Format: resource.Format, MetadataJson: resource.MetadataJSON, EntitlementId: resource.EntitlementID,
|
||||||
|
TextColors: append([]string(nil), resource.TextColors...), Stops: append([]float64(nil), resource.Stops...),
|
||||||
|
AngleDegrees: resource.AngleDegrees, ExpiresAtMs: resource.ExpiresAtMS,
|
||||||
|
}
|
||||||
|
}
|
||||||
332
services/room-service/internal/room/service/image_message.go
Normal file
332
services/room-service/internal/room/service/image_message.go
Normal file
@ -0,0 +1,332 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
roomeventsv1 "hyapp.local/api/proto/events/room/v1"
|
||||||
|
roomv1 "hyapp.local/api/proto/room/v1"
|
||||||
|
"hyapp/pkg/appcode"
|
||||||
|
"hyapp/pkg/giftlimits"
|
||||||
|
"hyapp/pkg/roomid"
|
||||||
|
"hyapp/pkg/xerr"
|
||||||
|
"hyapp/services/room-service/internal/room/command"
|
||||||
|
"hyapp/services/room-service/internal/room/outbox"
|
||||||
|
"hyapp/services/room-service/internal/room/rank"
|
||||||
|
"hyapp/services/room-service/internal/room/state"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AuthorizeRoomMediaUpload 在 COS PutObject 之前执行房间 owner/presence、治理态和 VIP 权益门禁。
|
||||||
|
// 返回的 prefix 是 gateway 生成对象 key 的唯一可信目录,客户端不能自行指定 key。
|
||||||
|
func (s *Service) AuthorizeRoomMediaUpload(ctx context.Context, req *roomv1.AuthorizeRoomMediaUploadRequest) (*roomv1.AuthorizeRoomMediaUploadResponse, error) {
|
||||||
|
if req == nil || req.GetMeta() == nil {
|
||||||
|
return nil, xerr.New(xerr.InvalidArgument, "request is required")
|
||||||
|
}
|
||||||
|
ctx = contextFromMeta(ctx, req.GetMeta())
|
||||||
|
roomID := strings.TrimSpace(req.GetMeta().GetRoomId())
|
||||||
|
actorUserID := req.GetMeta().GetActorUserId()
|
||||||
|
purpose := strings.ToLower(strings.TrimSpace(req.GetPurpose()))
|
||||||
|
if !roomid.ValidStringID(roomID) || actorUserID <= 0 || !giftlimits.ValidCommandID(req.GetMeta().GetCommandId()) ||
|
||||||
|
(purpose != roomMediaPurposeBackground && purpose != roomMediaPurposeImage) {
|
||||||
|
return nil, xerr.New(xerr.InvalidArgument, "room media upload request is invalid")
|
||||||
|
}
|
||||||
|
media := roomMediaFromProto(req.GetMedia())
|
||||||
|
if err := validateRoomMediaProperties(purpose, media); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if media.URL != "" || media.ObjectKey != "" || media.Status != roomMediaStatusActive {
|
||||||
|
return nil, xerr.New(xerr.InvalidArgument, "room media authorization requires pre-upload metadata")
|
||||||
|
}
|
||||||
|
guard, ok := s.repository.(RoomMediaUploadGuardStore)
|
||||||
|
if !ok {
|
||||||
|
return nil, xerr.New(xerr.Unavailable, "room media upload guard is unavailable")
|
||||||
|
}
|
||||||
|
payloadSHA256, err := roomMediaUploadPayloadSHA256(media)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
registration := RoomMediaUploadRegistration{
|
||||||
|
AppCode: appcode.FromContext(ctx), RoomID: roomID, CommandID: strings.TrimSpace(req.GetMeta().GetCommandId()),
|
||||||
|
ActorUserID: actorUserID, Purpose: purpose, MediaPayloadSHA256: payloadSHA256, Media: media,
|
||||||
|
}
|
||||||
|
registration.ExpectedObjectKey = roomMediaExpectedObjectKey(
|
||||||
|
registration.AppCode, actorUserID, roomID, purpose, registration.CommandID, media,
|
||||||
|
)
|
||||||
|
var authorizedReplay *RoomMediaUploadRegistration
|
||||||
|
if existing, exists, readErr := guard.GetRoomMediaUploadRegistration(ctx, roomID, registration.CommandID); readErr != nil {
|
||||||
|
return nil, readErr
|
||||||
|
} else if exists {
|
||||||
|
if !sameRoomMediaUploadIdentity(existing, registration) {
|
||||||
|
return nil, xerr.New(xerr.IdempotencyConflict, "room media upload command payload conflict")
|
||||||
|
}
|
||||||
|
switch existing.Status {
|
||||||
|
case roomMediaStatusActive:
|
||||||
|
// PutObject 已完成的同载荷重试只重放首次结果;后续权限变化不能把已成功请求改成失败。
|
||||||
|
return roomMediaUploadAuthorizationResponse(existing, s.clock.Now().UnixMilli()), nil
|
||||||
|
case "authorized":
|
||||||
|
// authorized 只代表 owner 已占用 command_id,不能代表对象已经上传成功。重试 PutObject
|
||||||
|
// 前必须重新检查当前房主/麦位/VIP,避免权限撤销后继续使用旧授权写 COS。
|
||||||
|
authorizedReplay = &existing
|
||||||
|
default:
|
||||||
|
return nil, xerr.New(xerr.Internal, "room media upload registration status is invalid")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
snapshot, err := s.currentSnapshot(ctx, roomID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if snapshot == nil || snapshot.GetStatus() != state.RoomStatusActive {
|
||||||
|
return nil, xerr.New(xerr.RoomClosed, "room closed")
|
||||||
|
}
|
||||||
|
switch purpose {
|
||||||
|
case roomMediaPurposeBackground:
|
||||||
|
if snapshot.GetOwnerUserId() != actorUserID {
|
||||||
|
return nil, xerr.New(xerr.PermissionDenied, "permission denied")
|
||||||
|
}
|
||||||
|
if err := s.requireVIPBenefit(ctx, req.GetMeta().GetRequestId(), actorUserID, vipBenefitCustomRoomBackground, "upload_room_background"); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
case roomMediaPurposeImage:
|
||||||
|
if err := requireSnapshotSpeakPermission(snapshot, actorUserID, s.clock.Now().UnixMilli()); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := s.requireVIPBenefit(ctx, req.GetMeta().GetRequestId(), actorUserID, vipBenefitRoomImageMessage, "upload_room_image"); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
nowMS := s.clock.Now().UnixMilli()
|
||||||
|
if authorizedReplay != nil {
|
||||||
|
return roomMediaUploadAuthorizationResponse(*authorizedReplay, nowMS), nil
|
||||||
|
}
|
||||||
|
registration.AuthorizedVersion = snapshot.GetVersion()
|
||||||
|
registration.CreatedAtMS, registration.UpdatedAtMS = nowMS, nowMS
|
||||||
|
registered, err := guard.RegisterRoomMediaUpload(ctx, registration)
|
||||||
|
if errors.Is(err, ErrRoomMediaUploadCommandConflict) {
|
||||||
|
return nil, xerr.New(xerr.IdempotencyConflict, "room media upload command payload conflict")
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return roomMediaUploadAuthorizationResponse(registered, nowMS), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CompleteRoomMediaUpload 只接受 gateway 已成功 PutObject 后回传的 URL/key。
|
||||||
|
// owner 以首次注册的完整解析事实为准推进 active,Save/Send 随后只认 active registration。
|
||||||
|
func (s *Service) CompleteRoomMediaUpload(ctx context.Context, req *roomv1.CompleteRoomMediaUploadRequest) (*roomv1.CompleteRoomMediaUploadResponse, error) {
|
||||||
|
if req == nil || req.GetMeta() == nil {
|
||||||
|
return nil, xerr.New(xerr.InvalidArgument, "request is required")
|
||||||
|
}
|
||||||
|
ctx = contextFromMeta(ctx, req.GetMeta())
|
||||||
|
roomID := strings.TrimSpace(req.GetMeta().GetRoomId())
|
||||||
|
commandID := strings.TrimSpace(req.GetMeta().GetCommandId())
|
||||||
|
actorUserID := req.GetMeta().GetActorUserId()
|
||||||
|
purpose := strings.ToLower(strings.TrimSpace(req.GetPurpose()))
|
||||||
|
if !roomid.ValidStringID(roomID) || actorUserID <= 0 || !giftlimits.ValidCommandID(commandID) ||
|
||||||
|
(purpose != roomMediaPurposeBackground && purpose != roomMediaPurposeImage) {
|
||||||
|
return nil, xerr.New(xerr.InvalidArgument, "room media completion request is invalid")
|
||||||
|
}
|
||||||
|
media := roomMediaFromProto(req.GetMedia())
|
||||||
|
if err := validateRoomMedia(appcode.FromContext(ctx), roomID, actorUserID, purpose, media); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
guard, ok := s.repository.(RoomMediaUploadGuardStore)
|
||||||
|
if !ok {
|
||||||
|
return nil, xerr.New(xerr.Unavailable, "room media upload guard is unavailable")
|
||||||
|
}
|
||||||
|
existing, exists, err := guard.GetRoomMediaUploadRegistration(ctx, roomID, commandID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if !exists {
|
||||||
|
return nil, xerr.New(xerr.NotFound, "room media upload authorization not found")
|
||||||
|
}
|
||||||
|
payloadSHA256, err := roomMediaUploadPayloadSHA256(media)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
candidate := RoomMediaUploadRegistration{
|
||||||
|
AppCode: appcode.FromContext(ctx), RoomID: roomID, CommandID: commandID, ActorUserID: actorUserID,
|
||||||
|
Purpose: purpose, MediaPayloadSHA256: payloadSHA256, ExpectedObjectKey: media.ObjectKey, ObjectURL: media.URL, Media: media,
|
||||||
|
}
|
||||||
|
if !sameRoomMediaUploadIdentity(existing, candidate) || !sameRoomMediaProperties(existing.Media, media) {
|
||||||
|
return nil, xerr.New(xerr.IdempotencyConflict, "room media upload command payload conflict")
|
||||||
|
}
|
||||||
|
if existing.Status == roomMediaStatusActive {
|
||||||
|
if strings.TrimSpace(existing.ObjectURL) != strings.TrimSpace(media.URL) {
|
||||||
|
return nil, xerr.New(xerr.IdempotencyConflict, "room media upload completion conflict")
|
||||||
|
}
|
||||||
|
return &roomv1.CompleteRoomMediaUploadResponse{
|
||||||
|
Active: true, Media: roomMediaToProto(existing.Media), ServerTimeMs: s.clock.Now().UnixMilli(),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
existing.ObjectURL, existing.Status, existing.Media = media.URL, roomMediaStatusActive, media
|
||||||
|
existing.UpdatedAtMS = s.clock.Now().UnixMilli()
|
||||||
|
completed, err := guard.CompleteRoomMediaUpload(ctx, existing)
|
||||||
|
if errors.Is(err, ErrRoomMediaUploadCommandConflict) {
|
||||||
|
return nil, xerr.New(xerr.IdempotencyConflict, "room media upload completion conflict")
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &roomv1.CompleteRoomMediaUploadResponse{
|
||||||
|
Active: true, Media: roomMediaToProto(completed.Media), ServerTimeMs: s.clock.Now().UnixMilli(),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func roomMediaUploadAuthorizationResponse(registration RoomMediaUploadRegistration, serverTimeMS int64) *roomv1.AuthorizeRoomMediaUploadResponse {
|
||||||
|
return &roomv1.AuthorizeRoomMediaUploadResponse{
|
||||||
|
Allowed: true, ObjectKeyPrefix: roomMediaObjectPrefix(registration.AppCode, registration.ActorUserID, registration.RoomID, registration.Purpose),
|
||||||
|
ObjectKey: registration.ExpectedObjectKey, RoomVersion: registration.AuthorizedVersion, ServerTimeMs: serverTimeMS,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func sameRoomMediaUploadIdentity(left RoomMediaUploadRegistration, right RoomMediaUploadRegistration) bool {
|
||||||
|
return appcode.Normalize(left.AppCode) == appcode.Normalize(right.AppCode) &&
|
||||||
|
strings.TrimSpace(left.RoomID) == strings.TrimSpace(right.RoomID) &&
|
||||||
|
strings.TrimSpace(left.CommandID) == strings.TrimSpace(right.CommandID) &&
|
||||||
|
left.ActorUserID == right.ActorUserID && strings.TrimSpace(left.Purpose) == strings.TrimSpace(right.Purpose) &&
|
||||||
|
strings.EqualFold(strings.TrimSpace(left.MediaPayloadSHA256), strings.TrimSpace(right.MediaPayloadSHA256)) &&
|
||||||
|
strings.TrimLeft(strings.TrimSpace(left.ExpectedObjectKey), "/") == strings.TrimLeft(strings.TrimSpace(right.ExpectedObjectKey), "/")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) requireActiveRoomMediaUpload(ctx context.Context, roomID string, actorUserID int64, purpose string, commandID string, media state.RoomMedia) error {
|
||||||
|
guard, ok := s.repository.(RoomMediaUploadGuardStore)
|
||||||
|
if !ok {
|
||||||
|
return xerr.New(xerr.Unavailable, "room media upload guard is unavailable")
|
||||||
|
}
|
||||||
|
registration, exists, err := guard.GetActiveRoomMediaUploadByObjectKey(ctx, media.ObjectKey)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !exists {
|
||||||
|
return xerr.New(xerr.NotFound, "active room media upload not found")
|
||||||
|
}
|
||||||
|
payloadSHA256, err := roomMediaUploadPayloadSHA256(media)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
candidate := RoomMediaUploadRegistration{
|
||||||
|
AppCode: appcode.FromContext(ctx), RoomID: roomID, CommandID: strings.TrimSpace(commandID), ActorUserID: actorUserID,
|
||||||
|
Purpose: purpose, MediaPayloadSHA256: payloadSHA256, ExpectedObjectKey: media.ObjectKey, ObjectURL: media.URL, Media: media,
|
||||||
|
}
|
||||||
|
if !sameRoomMediaUploadIdentity(registration, candidate) ||
|
||||||
|
strings.TrimSpace(registration.ObjectURL) != strings.TrimSpace(media.URL) || !sameRoomMediaProperties(registration.Media, media) {
|
||||||
|
return xerr.New(xerr.IdempotencyConflict, "room media does not match completed upload")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SendRoomImage 把服务端认可的图片消息写入 Room Cell command log + durable outbox。
|
||||||
|
// outbox 的 at-least-once 重试使用稳定 message_id,Flutter 不能以到达次数作为消息条数。
|
||||||
|
func (s *Service) SendRoomImage(ctx context.Context, req *roomv1.SendRoomImageRequest) (*roomv1.SendRoomImageResponse, error) {
|
||||||
|
if req == nil || req.GetMeta() == nil {
|
||||||
|
return nil, xerr.New(xerr.InvalidArgument, "request is required")
|
||||||
|
}
|
||||||
|
ctx = contextFromMeta(ctx, req.GetMeta())
|
||||||
|
cmd := command.SendRoomImage{
|
||||||
|
Base: baseFromMeta(req.GetMeta()), MessageID: roomImageMessageID(req.GetMeta()), Image: roomMediaFromProto(req.GetImage()),
|
||||||
|
}
|
||||||
|
if !roomid.ValidStringID(cmd.RoomID()) || cmd.ActorUserID() <= 0 || strings.TrimSpace(cmd.ID()) == "" {
|
||||||
|
return nil, xerr.New(xerr.InvalidArgument, "room image message meta is invalid")
|
||||||
|
}
|
||||||
|
// 与装扮相同,已提交 command_id 必须在当前 VIP 到期/禁言之前直接重放。
|
||||||
|
if _, record, seen, err := s.commandRecordForIdempotency(ctx, cmd); err != nil {
|
||||||
|
return nil, err
|
||||||
|
} else if seen {
|
||||||
|
stored, deserializeErr := command.Deserialize(record.CommandType, record.Payload)
|
||||||
|
if deserializeErr != nil {
|
||||||
|
return nil, deserializeErr
|
||||||
|
}
|
||||||
|
storedCommand, ok := stored.(*command.SendRoomImage)
|
||||||
|
if !ok {
|
||||||
|
return nil, xerr.New(xerr.Internal, "stored room image command is invalid")
|
||||||
|
}
|
||||||
|
// 返回第一次提交固化的 message_id 和媒体事实,不能用当前重试请求拼出看似成功的另一条消息。
|
||||||
|
cmd = *storedCommand
|
||||||
|
} else {
|
||||||
|
if err := validateRoomMedia(appcode.FromContext(ctx), cmd.RoomID(), cmd.ActorUserID(), roomMediaPurposeImage, cmd.Image); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := s.requireActiveRoomMediaUpload(ctx, cmd.RoomID(), cmd.ActorUserID(), roomMediaPurposeImage, cmd.ID(), cmd.Image); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := s.requireVIPBenefit(ctx, req.GetMeta().GetRequestId(), cmd.ActorUserID(), vipBenefitRoomImageMessage, "send_room_image"); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := s.mutateRoom(ctx, cmd, true, func(now time.Time, current *state.RoomState, _ *rank.LocalRank) (mutationResult, []outbox.Record, error) {
|
||||||
|
if err := requireActiveRoom(current); err != nil {
|
||||||
|
return mutationResult{}, nil, err
|
||||||
|
}
|
||||||
|
if err := requireStateSpeakPermission(current, cmd.ActorUserID(), now.UnixMilli()); err != nil {
|
||||||
|
return mutationResult{}, nil, err
|
||||||
|
}
|
||||||
|
// 图片消息不成为房间核心状态字段,但版本推进把 command log、恢复谱系和 outbox 放进同一原子提交。
|
||||||
|
current.Version++
|
||||||
|
event, err := outbox.Build(current.RoomID, "RoomImageMessageSent", current.Version, now, &roomeventsv1.RoomImageMessageSent{
|
||||||
|
MessageId: cmd.MessageID, CommandId: cmd.ID(), SenderUserId: cmd.ActorUserID(), Image: roomMediaEventSnapshot(cmd.Image),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return mutationResult{}, nil, err
|
||||||
|
}
|
||||||
|
return mutationResult{snapshot: current.ToProtoAt(now.UnixMilli())}, []outbox.Record{event}, nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
now := s.clock.Now()
|
||||||
|
return &roomv1.SendRoomImageResponse{
|
||||||
|
Result: commandResult(result.applied, result.snapshot.GetVersion(), now), Accepted: true,
|
||||||
|
MessageId: cmd.MessageID, RoomId: cmd.RoomID(), SenderUserId: cmd.ActorUserID(), ServerTimeMs: now.UnixMilli(),
|
||||||
|
Image: roomMediaToProto(cmd.Image),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func requireSnapshotSpeakPermission(snapshot *roomv1.RoomSnapshot, userID int64, nowMS int64) error {
|
||||||
|
if snapshot == nil || snapshot.GetStatus() != state.RoomStatusActive {
|
||||||
|
return xerr.New(xerr.RoomClosed, "room closed")
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case snapshotUserBanned(snapshot, userID, nowMS):
|
||||||
|
return xerr.New(xerr.PermissionDenied, "user is banned")
|
||||||
|
case containsID(snapshot.GetMuteUserIds(), userID):
|
||||||
|
return xerr.New(xerr.PermissionDenied, "user is muted")
|
||||||
|
case !snapshot.GetChatEnabled():
|
||||||
|
return xerr.New(xerr.Conflict, "room chat is disabled")
|
||||||
|
case findProtoUser(snapshot, userID) == nil:
|
||||||
|
return xerr.New(xerr.PermissionDenied, "user is not in room")
|
||||||
|
default:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func requireStateSpeakPermission(current *state.RoomState, userID int64, nowMS int64) error {
|
||||||
|
if moderation, banned := current.BanUsers[userID]; banned && moderation.ActiveAt(nowMS) {
|
||||||
|
return xerr.New(xerr.PermissionDenied, "user is banned")
|
||||||
|
}
|
||||||
|
if current.MuteUsers[userID] {
|
||||||
|
return xerr.New(xerr.PermissionDenied, "user is muted")
|
||||||
|
}
|
||||||
|
if !current.ChatEnabled {
|
||||||
|
return xerr.New(xerr.Conflict, "room chat is disabled")
|
||||||
|
}
|
||||||
|
if current.OnlineUsers[userID] == nil {
|
||||||
|
return xerr.New(xerr.PermissionDenied, "user is not in room")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func roomImageMessageID(meta *roomv1.RequestMeta) string {
|
||||||
|
if meta == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
sum := sha256.Sum256([]byte(strings.Join([]string{
|
||||||
|
appcode.Normalize(meta.GetAppCode()), strings.TrimSpace(meta.GetRoomId()), strings.TrimSpace(meta.GetCommandId()),
|
||||||
|
}, "\x00")))
|
||||||
|
return "room_msg_" + hex.EncodeToString(sum[:16])
|
||||||
|
}
|
||||||
@ -7,6 +7,7 @@ import (
|
|||||||
"log/slog"
|
"log/slog"
|
||||||
"math"
|
"math"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
roomv1 "hyapp.local/api/proto/room/v1"
|
roomv1 "hyapp.local/api/proto/room/v1"
|
||||||
"hyapp/pkg/appcode"
|
"hyapp/pkg/appcode"
|
||||||
@ -403,6 +404,8 @@ func roomListEntryFromSnapshot(snapshot *roomv1.RoomSnapshot, visibleRegionID in
|
|||||||
SeatCount: seatCount,
|
SeatCount: seatCount,
|
||||||
OccupiedSeatCount: occupiedSeatCount,
|
OccupiedSeatCount: occupiedSeatCount,
|
||||||
SortScore: roomListSortScore(snapshot.GetHeat(), onlineCount, occupiedSeatCount),
|
SortScore: roomListSortScore(snapshot.GetHeat(), onlineCount, occupiedSeatCount),
|
||||||
|
RoomBorderJSON: marshalRoomListDecoration(snapshot.GetRoomBorder()),
|
||||||
|
RoomNameStyleJSON: marshalRoomListDecoration(snapshot.GetRoomNameStyle()),
|
||||||
CreatedAtMS: nowMS,
|
CreatedAtMS: nowMS,
|
||||||
UpdatedAtMS: nowMS,
|
UpdatedAtMS: nowMS,
|
||||||
}
|
}
|
||||||
@ -587,6 +590,7 @@ func encodeRoomListCursor(tab string, query string, countryCode string, viewerCo
|
|||||||
|
|
||||||
// roomListItemToProto 将 repository 投影转换为跨服务响应对象。
|
// roomListItemToProto 将 repository 投影转换为跨服务响应对象。
|
||||||
func roomListItemToProto(entry RoomListEntry) *roomv1.RoomListItem {
|
func roomListItemToProto(entry RoomListEntry) *roomv1.RoomListItem {
|
||||||
|
nowMS := time.Now().UTC().UnixMilli()
|
||||||
return &roomv1.RoomListItem{
|
return &roomv1.RoomListItem{
|
||||||
AppCode: entry.AppCode,
|
AppCode: entry.AppCode,
|
||||||
RoomId: entry.RoomID,
|
RoomId: entry.RoomID,
|
||||||
@ -603,9 +607,34 @@ func roomListItemToProto(entry RoomListEntry) *roomv1.RoomListItem {
|
|||||||
OccupiedSeatCount: entry.OccupiedSeatCount,
|
OccupiedSeatCount: entry.OccupiedSeatCount,
|
||||||
VisibleRegionId: entry.VisibleRegionID,
|
VisibleRegionId: entry.VisibleRegionID,
|
||||||
CountryCode: normalizeRoomCountryCode(entry.OwnerCountryCode),
|
CountryCode: normalizeRoomCountryCode(entry.OwnerCountryCode),
|
||||||
|
RoomBorder: unmarshalRoomListDecoration(entry.RoomBorderJSON, nowMS),
|
||||||
|
RoomNameStyle: unmarshalRoomListDecoration(entry.RoomNameStyleJSON, nowMS),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func marshalRoomListDecoration(resource *roomv1.RoomDecorationResource) string {
|
||||||
|
if resource == nil || resource.GetResourceId() <= 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
payload, err := json.Marshal(resource)
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return string(payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
func unmarshalRoomListDecoration(raw string, nowMS int64) *roomv1.RoomDecorationResource {
|
||||||
|
if strings.TrimSpace(raw) == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var resource roomv1.RoomDecorationResource
|
||||||
|
if err := json.Unmarshal([]byte(raw), &resource); err != nil || resource.GetResourceId() <= 0 ||
|
||||||
|
(resource.GetExpiresAtMs() > 0 && nowMS >= resource.GetExpiresAtMs()) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &resource
|
||||||
|
}
|
||||||
|
|
||||||
func roomListEntryFromMetaAndSnapshot(meta RoomMeta, snapshot *roomv1.RoomSnapshot, nowMS int64) RoomListEntry {
|
func roomListEntryFromMetaAndSnapshot(meta RoomMeta, snapshot *roomv1.RoomSnapshot, nowMS int64) RoomListEntry {
|
||||||
if snapshot == nil || snapshot.GetRoomId() == "" {
|
if snapshot == nil || snapshot.GetRoomId() == "" {
|
||||||
// GetMyRoom 兜底路径:即使快照暂时不可用,也能用 rooms 元数据返回“有房间”的最小卡片。
|
// GetMyRoom 兜底路径:即使快照暂时不可用,也能用 rooms 元数据返回“有房间”的最小卡片。
|
||||||
|
|||||||
248
services/room-service/internal/room/service/media.go
Normal file
248
services/room-service/internal/room/service/media.go
Normal file
@ -0,0 +1,248 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/url"
|
||||||
|
"path"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
roomeventsv1 "hyapp.local/api/proto/events/room/v1"
|
||||||
|
roomv1 "hyapp.local/api/proto/room/v1"
|
||||||
|
"hyapp/pkg/appcode"
|
||||||
|
"hyapp/pkg/xerr"
|
||||||
|
"hyapp/services/room-service/internal/room/state"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
roomMediaPurposeBackground = "room_background"
|
||||||
|
roomMediaPurposeImage = "room_image"
|
||||||
|
roomMediaStatusActive = "active"
|
||||||
|
|
||||||
|
roomBackgroundMaxBytes = int64(12 << 20)
|
||||||
|
roomBackgroundMaxWidth = int32(2160)
|
||||||
|
roomBackgroundMaxHeight = int32(3840)
|
||||||
|
roomBackgroundMaxPixels = int64(8_294_400)
|
||||||
|
roomImageMaxBytes = int64(8 << 20)
|
||||||
|
roomImageMaxDimension = int32(4096)
|
||||||
|
roomImageMaxPixels = int64(16_777_216)
|
||||||
|
roomMediaMaxFrames = int32(120)
|
||||||
|
roomMediaMaxDurationMS = int64(15_000)
|
||||||
|
)
|
||||||
|
|
||||||
|
var roomMediaMIMEByFormat = map[string]string{
|
||||||
|
"jpeg": "image/jpeg",
|
||||||
|
"png": "image/png",
|
||||||
|
"gif": "image/gif",
|
||||||
|
"webp": "image/webp",
|
||||||
|
}
|
||||||
|
|
||||||
|
// validateRoomMedia 校验专用上传入口产生的完整媒体事实。room-service 不下载 URL 重新嗅探,
|
||||||
|
// 但会约束 COS key 归属、https、格式/MIME、尺寸和哈希,防止客户端绕过 gateway 直调 gRPC。
|
||||||
|
func validateRoomMedia(ctxAppCode string, roomID string, actorUserID int64, purpose string, media state.RoomMedia) error {
|
||||||
|
media.URL = strings.TrimSpace(media.URL)
|
||||||
|
media.ObjectKey = strings.TrimLeft(strings.TrimSpace(media.ObjectKey), "/")
|
||||||
|
media.ContentType = strings.ToLower(strings.TrimSpace(media.ContentType))
|
||||||
|
media.Format = strings.ToLower(strings.TrimSpace(media.Format))
|
||||||
|
media.SHA256 = strings.ToLower(strings.TrimSpace(media.SHA256))
|
||||||
|
media.Status = strings.ToLower(strings.TrimSpace(media.Status))
|
||||||
|
if media.URL == "" || media.ObjectKey == "" || media.Status != roomMediaStatusActive {
|
||||||
|
return xerr.New(xerr.InvalidArgument, "room media is incomplete")
|
||||||
|
}
|
||||||
|
if len(media.URL) > 1024 || len(media.ObjectKey) > 512 || len(media.PosterURL) > 1024 || len(media.ThumbnailURL) > 1024 {
|
||||||
|
return xerr.New(xerr.InvalidArgument, "room media url or object_key is too long")
|
||||||
|
}
|
||||||
|
parsed, err := url.Parse(media.URL)
|
||||||
|
if err != nil || parsed.Scheme != "https" || strings.TrimSpace(parsed.Host) == "" {
|
||||||
|
return xerr.New(xerr.InvalidArgument, "room media url must be https")
|
||||||
|
}
|
||||||
|
if expected := roomMediaObjectPrefix(ctxAppCode, actorUserID, roomID, purpose); !strings.HasPrefix(media.ObjectKey, expected) {
|
||||||
|
return xerr.New(xerr.PermissionDenied, "room media object does not belong to actor and room")
|
||||||
|
}
|
||||||
|
if path.Clean(media.ObjectKey) != media.ObjectKey ||
|
||||||
|
!(strings.TrimLeft(parsed.Path, "/") == media.ObjectKey || strings.HasSuffix(strings.TrimRight(parsed.Path, "/"), "/"+media.ObjectKey)) {
|
||||||
|
// gateway 还会按 COS access_url 校验 host/base path;owner service 至少把 URL path 与同一 object_key 绑定,
|
||||||
|
// 防止客户端拿一条合法 key 配合另一张外部图片绕过素材归属。
|
||||||
|
return xerr.New(xerr.PermissionDenied, "room media url does not match object_key")
|
||||||
|
}
|
||||||
|
return validateRoomMediaProperties(purpose, media)
|
||||||
|
}
|
||||||
|
|
||||||
|
// validateRoomMediaProperties 供上传授权阶段在对象尚未写入 COS、因而还没有 URL/key 时复核解析结果。
|
||||||
|
func validateRoomMediaProperties(purpose string, media state.RoomMedia) error {
|
||||||
|
media.ContentType = strings.ToLower(strings.TrimSpace(media.ContentType))
|
||||||
|
media.Format = strings.ToLower(strings.TrimSpace(media.Format))
|
||||||
|
media.SHA256 = strings.ToLower(strings.TrimSpace(media.SHA256))
|
||||||
|
if strings.TrimSpace(media.PosterURL) != "" || strings.TrimSpace(media.ThumbnailURL) != "" {
|
||||||
|
// 当前上传链路没有生成或校验派生图,客户端不能借 poster/thumbnail 字段挂入任意外部 URL。
|
||||||
|
// 等后续服务端真正生成首帧和缩略图后,再由 owner service 填充这两个只读事实字段。
|
||||||
|
return xerr.New(xerr.InvalidArgument, "poster_url and thumbnail_url are not supported")
|
||||||
|
}
|
||||||
|
expectedMIME, supported := roomMediaMIMEByFormat[media.Format]
|
||||||
|
if !supported || expectedMIME != media.ContentType {
|
||||||
|
return xerr.New(xerr.InvalidArgument, "room media format does not match content_type")
|
||||||
|
}
|
||||||
|
if media.Width <= 0 || media.Height <= 0 || media.FrameCount <= 0 || media.SizeBytes <= 0 {
|
||||||
|
return xerr.New(xerr.InvalidArgument, "room media dimensions are invalid")
|
||||||
|
}
|
||||||
|
decodedHash, err := hex.DecodeString(media.SHA256)
|
||||||
|
if err != nil || len(decodedHash) != sha256.Size {
|
||||||
|
return xerr.New(xerr.InvalidArgument, "room media sha256 is invalid")
|
||||||
|
}
|
||||||
|
if media.Animated {
|
||||||
|
if media.Format != "gif" && media.Format != "webp" {
|
||||||
|
return xerr.New(xerr.InvalidArgument, "animated room media must be gif or webp")
|
||||||
|
}
|
||||||
|
if media.FrameCount < 2 || media.FrameCount > roomMediaMaxFrames || media.DurationMS <= 0 || media.DurationMS > roomMediaMaxDurationMS {
|
||||||
|
return xerr.New(xerr.InvalidArgument, "animated room media exceeds frame or duration limit")
|
||||||
|
}
|
||||||
|
} else if media.FrameCount != 1 || media.DurationMS != 0 {
|
||||||
|
return xerr.New(xerr.InvalidArgument, "static room media metadata is invalid")
|
||||||
|
}
|
||||||
|
pixels := int64(media.Width) * int64(media.Height)
|
||||||
|
switch purpose {
|
||||||
|
case roomMediaPurposeBackground:
|
||||||
|
if media.SizeBytes > roomBackgroundMaxBytes || media.Width > roomBackgroundMaxWidth || media.Height > roomBackgroundMaxHeight || pixels > roomBackgroundMaxPixels {
|
||||||
|
return xerr.New(xerr.InvalidArgument, "room background exceeds media limits")
|
||||||
|
}
|
||||||
|
case roomMediaPurposeImage:
|
||||||
|
if media.SizeBytes > roomImageMaxBytes || media.Width > roomImageMaxDimension || media.Height > roomImageMaxDimension || pixels > roomImageMaxPixels {
|
||||||
|
return xerr.New(xerr.InvalidArgument, "room image exceeds media limits")
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return xerr.New(xerr.InvalidArgument, "room media purpose is invalid")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// roomMediaObjectPrefix 是上传与提交共同使用的租户/用户/房间归属边界。
|
||||||
|
// path.Join 同时消除可疑的 ../ 片段,gateway 生成对象 key 时必须使用同一规则。
|
||||||
|
func roomMediaObjectPrefix(rawAppCode string, actorUserID int64, roomID string, purpose string) string {
|
||||||
|
directory := "images"
|
||||||
|
if purpose == roomMediaPurposeBackground {
|
||||||
|
directory = "backgrounds"
|
||||||
|
}
|
||||||
|
return path.Join("room-media", appcode.Normalize(rawAppCode), directory, fmt.Sprintf("%d", actorUserID), strings.TrimSpace(roomID)) + "/"
|
||||||
|
}
|
||||||
|
|
||||||
|
func roomMediaExpectedObjectKey(rawAppCode string, actorUserID int64, roomID string, purpose string, commandID string, media state.RoomMedia) string {
|
||||||
|
commandHash := sha256.Sum256([]byte(strings.TrimSpace(commandID)))
|
||||||
|
extension := media.Format
|
||||||
|
if extension == "jpeg" {
|
||||||
|
extension = "jpg"
|
||||||
|
}
|
||||||
|
return path.Join(
|
||||||
|
roomMediaObjectPrefix(rawAppCode, actorUserID, roomID, purpose),
|
||||||
|
hex.EncodeToString(commandHash[:8])+"-"+strings.ToLower(strings.TrimSpace(media.SHA256))[:16]+"."+extension,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func roomMediaUploadPayloadSHA256(media state.RoomMedia) (string, error) {
|
||||||
|
// URL/object_key/status 是上传生命周期字段,不属于首次解析的文件事实;其余字段使用结构体固定顺序编码。
|
||||||
|
payload, err := json.Marshal(struct {
|
||||||
|
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"`
|
||||||
|
ThumbnailURL string `json:"thumbnail_url"`
|
||||||
|
SHA256 string `json:"sha256"`
|
||||||
|
}{
|
||||||
|
ContentType: media.ContentType, Format: media.Format, SizeBytes: media.SizeBytes,
|
||||||
|
Width: media.Width, Height: media.Height, Animated: media.Animated, FrameCount: media.FrameCount,
|
||||||
|
DurationMS: media.DurationMS, PosterURL: media.PosterURL, ThumbnailURL: media.ThumbnailURL, SHA256: media.SHA256,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
sum := sha256.Sum256(payload)
|
||||||
|
return hex.EncodeToString(sum[:]), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func sameRoomMediaProperties(left state.RoomMedia, right state.RoomMedia) bool {
|
||||||
|
left.URL, left.ObjectKey, left.Status = "", "", ""
|
||||||
|
right.URL, right.ObjectKey, right.Status = "", "", ""
|
||||||
|
return left == right
|
||||||
|
}
|
||||||
|
|
||||||
|
func roomMediaFromProto(media *roomv1.RoomMedia) state.RoomMedia {
|
||||||
|
if media == nil {
|
||||||
|
return state.RoomMedia{}
|
||||||
|
}
|
||||||
|
return state.RoomMedia{
|
||||||
|
URL: strings.TrimSpace(media.GetUrl()),
|
||||||
|
ObjectKey: strings.TrimLeft(strings.TrimSpace(media.GetObjectKey()), "/"),
|
||||||
|
ContentType: strings.ToLower(strings.TrimSpace(media.GetContentType())),
|
||||||
|
Format: strings.ToLower(strings.TrimSpace(media.GetFormat())),
|
||||||
|
SizeBytes: media.GetSizeBytes(),
|
||||||
|
Width: media.GetWidth(),
|
||||||
|
Height: media.GetHeight(),
|
||||||
|
Animated: media.GetAnimated(),
|
||||||
|
FrameCount: media.GetFrameCount(),
|
||||||
|
DurationMS: media.GetDurationMs(),
|
||||||
|
PosterURL: strings.TrimSpace(media.GetPosterUrl()),
|
||||||
|
ThumbnailURL: strings.TrimSpace(media.GetThumbnailUrl()),
|
||||||
|
SHA256: strings.ToLower(strings.TrimSpace(media.GetSha256())),
|
||||||
|
Status: strings.ToLower(strings.TrimSpace(media.GetStatus())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func roomMediaToProto(media state.RoomMedia) *roomv1.RoomMedia {
|
||||||
|
if strings.TrimSpace(media.URL) == "" && strings.TrimSpace(media.ObjectKey) == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &roomv1.RoomMedia{
|
||||||
|
Url: media.URL, ObjectKey: media.ObjectKey, ContentType: media.ContentType, Format: media.Format,
|
||||||
|
SizeBytes: media.SizeBytes, Width: media.Width, Height: media.Height, Animated: media.Animated,
|
||||||
|
FrameCount: media.FrameCount, DurationMs: media.DurationMS, PosterUrl: media.PosterURL,
|
||||||
|
ThumbnailUrl: media.ThumbnailURL, Sha256: media.SHA256, Status: media.Status,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func roomMediaEventSnapshot(media state.RoomMedia) *roomeventsv1.RoomMediaSnapshot {
|
||||||
|
return &roomeventsv1.RoomMediaSnapshot{
|
||||||
|
Url: media.URL, ObjectKey: media.ObjectKey, ContentType: media.ContentType, Format: media.Format,
|
||||||
|
SizeBytes: media.SizeBytes, Width: media.Width, Height: media.Height, Animated: media.Animated,
|
||||||
|
FrameCount: media.FrameCount, DurationMs: media.DurationMS, PosterUrl: media.PosterURL,
|
||||||
|
ThumbnailUrl: media.ThumbnailURL, Sha256: media.SHA256,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func roomBackgroundState(background RoomBackgroundImage) state.RoomBackground {
|
||||||
|
return state.RoomBackground{
|
||||||
|
BackgroundID: background.BackgroundID, RoomID: background.RoomID, CreatedByUserID: background.CreatedByUserID,
|
||||||
|
CreatedAtMS: background.CreatedAtMS, UpdatedAtMS: background.UpdatedAtMS, CommandID: background.CommandID,
|
||||||
|
Media: background.Media,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func roomBackgroundToProto(background RoomBackgroundImage) *roomv1.RoomBackgroundImage {
|
||||||
|
return &roomv1.RoomBackgroundImage{
|
||||||
|
BackgroundId: background.BackgroundID, RoomId: background.RoomID, ImageUrl: background.ImageURL,
|
||||||
|
CreatedByUserId: background.CreatedByUserID, CreatedAtMs: background.CreatedAtMS, UpdatedAtMs: background.UpdatedAtMS,
|
||||||
|
Media: roomMediaToProto(background.Media), CommandId: background.CommandID,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func roomBackgroundsToProto(backgrounds []RoomBackgroundImage) []*roomv1.RoomBackgroundImage {
|
||||||
|
items := make([]*roomv1.RoomBackgroundImage, 0, len(backgrounds))
|
||||||
|
for _, background := range backgrounds {
|
||||||
|
items = append(items, roomBackgroundToProto(background))
|
||||||
|
}
|
||||||
|
return items
|
||||||
|
}
|
||||||
|
|
||||||
|
func legacyRoomBackgroundCommandID(rawAppCode string, roomID string, actorUserID int64, imageURL string) string {
|
||||||
|
// legacy 请求没有 command_id;用租户、房间、房主和 URL 生成稳定短键,重试不会重复建素材,
|
||||||
|
// 同时不把 URL 明文放进 VARCHAR(128) 或日志索引。
|
||||||
|
sum := sha256.Sum256([]byte(strings.Join([]string{
|
||||||
|
appcode.Normalize(rawAppCode), strings.TrimSpace(roomID), fmt.Sprintf("%d", actorUserID), strings.TrimSpace(imageURL),
|
||||||
|
}, "\x00")))
|
||||||
|
return "legacy_bg_" + hex.EncodeToString(sum[:16])
|
||||||
|
}
|
||||||
@ -138,6 +138,55 @@ func TestJoinLeaveRoomUpdatesPresenceIncrementally(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestBatchGetUserVoiceRoomPresencesUsesCurrentMicSeatAndHidesViewer(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
repository := mysqltest.NewRepository(t)
|
||||||
|
now := &fixedRoomRocketClock{now: time.Date(2026, 7, 17, 8, 0, 0, 0, time.UTC)}
|
||||||
|
svc := newRocketTestService(t, repository, &rocketTestWallet{}, now)
|
||||||
|
|
||||||
|
roomID := "room-profile-live"
|
||||||
|
ownerID := int64(1301)
|
||||||
|
targetID := int64(1302)
|
||||||
|
missingID := int64(1303)
|
||||||
|
offMicID := int64(1304)
|
||||||
|
createRocketRoom(t, ctx, svc, roomID, ownerID, 9001)
|
||||||
|
joinRocketRoom(t, ctx, svc, roomID, targetID)
|
||||||
|
joinRocketRoom(t, ctx, svc, roomID, offMicID)
|
||||||
|
if _, err := svc.MicUp(ctx, &roomv1.MicUpRequest{Meta: rocketMeta(roomID, targetID, "profile-live-mic-up"), SeatNo: 3}); err != nil {
|
||||||
|
t.Fatalf("mic up target failed: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := svc.SetRoomPassword(ctx, &roomv1.SetRoomPasswordRequest{Meta: rocketMeta(roomID, ownerID, "profile-live-lock"), Locked: true, Password: "1234"}); err != nil {
|
||||||
|
t.Fatalf("lock room failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := svc.BatchGetUserVoiceRoomPresences(ctx, &roomv1.BatchGetUserVoiceRoomPresencesRequest{
|
||||||
|
Meta: &roomv1.RequestMeta{AppCode: appcode.Default, RequestId: "req-profile-live"},
|
||||||
|
ViewerUserId: ownerID,
|
||||||
|
UserIds: []int64{targetID, missingID, offMicID, ownerID, targetID},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("batch user voice room presence failed: %v", err)
|
||||||
|
}
|
||||||
|
if len(resp.GetItems()) != 4 {
|
||||||
|
t.Fatalf("deduplicated input must return four ordered items: %+v", resp.GetItems())
|
||||||
|
}
|
||||||
|
target := resp.GetItems()[0]
|
||||||
|
if target.GetUserId() != targetID || target.GetState() != "on_mic" || !target.GetJoinable() || target.GetSeatNo() != 3 {
|
||||||
|
t.Fatalf("target mic state mismatch: %+v", target)
|
||||||
|
}
|
||||||
|
if room := target.GetRoom(); room == nil || room.GetRoomId() != roomID || room.GetOwnerUserId() != ownerID || !room.GetLocked() || room.GetSeatCount() != 10 {
|
||||||
|
t.Fatalf("target room entry mismatch: %+v", target.GetRoom())
|
||||||
|
}
|
||||||
|
for _, item := range resp.GetItems()[1:] {
|
||||||
|
if item.GetState() != "not_on_mic" || item.GetJoinable() || item.GetRoom() != nil || item.GetSeatNo() != 0 {
|
||||||
|
t.Fatalf("missing user and viewer must not expose a room: %+v", item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if resp.GetServerTimeMs() != now.now.UnixMilli() || target.GetObservedAtMs() != resp.GetServerTimeMs() {
|
||||||
|
t.Fatalf("presence timestamps must use the service UTC clock: %+v", resp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestMarkRoomUserPresenceLeftDoesNotClearAnotherRoom(t *testing.T) {
|
func TestMarkRoomUserPresenceLeftDoesNotClearAnotherRoom(t *testing.T) {
|
||||||
ctx := appcode.WithContext(context.Background(), appcode.Default)
|
ctx := appcode.WithContext(context.Background(), appcode.Default)
|
||||||
repository := mysqltest.NewRepository(t)
|
repository := mysqltest.NewRepository(t)
|
||||||
|
|||||||
@ -146,6 +146,7 @@ func (s *Service) mutateRoom(ctx context.Context, cmd command.Command, replayabl
|
|||||||
RoomUserContributionPeriodStats: result.roomUserContributionPeriodStats,
|
RoomUserContributionPeriodStats: result.roomUserContributionPeriodStats,
|
||||||
RoomWeeklyContribution: result.roomWeeklyContribution,
|
RoomWeeklyContribution: result.roomWeeklyContribution,
|
||||||
GiftOperation: giftOperationCommitForResult(cmd, resultPayload, now),
|
GiftOperation: giftOperationCommitForResult(cmd, resultPayload, now),
|
||||||
|
RoomDecorationAssignment: result.roomDecorationAssignment,
|
||||||
})
|
})
|
||||||
commitCancel()
|
commitCancel()
|
||||||
if errors.Is(err, ErrCommandAlreadyCommitted) {
|
if errors.Is(err, ErrCommandAlreadyCommitted) {
|
||||||
@ -188,7 +189,7 @@ func (s *Service) mutateRoom(ctx context.Context, cmd command.Command, replayabl
|
|||||||
result.applied = true
|
result.applied = true
|
||||||
} else {
|
} else {
|
||||||
// no-op 命令仍写入 command log,保证相同 command_id 重试可命中幂等,
|
// no-op 命令仍写入 command log,保证相同 command_id 重试可命中幂等,
|
||||||
// 不同 payload 复用 command_id 会被后续入口拒绝为 CONFLICT。
|
// 不同 payload 复用 command_id 会被后续入口拒绝为 IDEMPOTENCY_CONFLICT。
|
||||||
if err := s.ensureLeaseStillOwned(ctx, cmd.RoomID(), lease); err != nil {
|
if err := s.ensureLeaseStillOwned(ctx, cmd.RoomID(), lease); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@ -206,7 +207,9 @@ func (s *Service) mutateRoom(ctx context.Context, cmd command.Command, replayabl
|
|||||||
LeaseToken: lease.LeaseToken,
|
LeaseToken: lease.LeaseToken,
|
||||||
CreatedAtMS: now.UnixMilli(),
|
CreatedAtMS: now.UnixMilli(),
|
||||||
}
|
}
|
||||||
err := s.repository.SaveMutation(commitCtx, MutationCommit{Command: commandRecord})
|
err := s.repository.SaveMutation(commitCtx, MutationCommit{
|
||||||
|
Command: commandRecord, RoomDecorationAssignment: result.roomDecorationAssignment,
|
||||||
|
})
|
||||||
commitCancel()
|
commitCancel()
|
||||||
if err != nil && !s.mutationCommitConfirmed(ctx, commandRecord) {
|
if err != nil && !s.mutationCommitConfirmed(ctx, commandRecord) {
|
||||||
return nil, err
|
return nil, err
|
||||||
@ -313,7 +316,7 @@ func (s *Service) commandRecordForIdempotency(ctx context.Context, cmd command.C
|
|||||||
return nil, record, true, err
|
return nil, record, true, err
|
||||||
}
|
}
|
||||||
if !matched {
|
if !matched {
|
||||||
return nil, record, true, xerr.New(xerr.Conflict, "command_id payload conflict")
|
return nil, record, true, xerr.New(xerr.IdempotencyConflict, "command_id payload conflict")
|
||||||
}
|
}
|
||||||
return payload, record, true, nil
|
return payload, record, true, nil
|
||||||
}
|
}
|
||||||
@ -327,7 +330,7 @@ func (s *Service) commandRecordForIdempotency(ctx context.Context, cmd command.C
|
|||||||
}
|
}
|
||||||
if record.CommandType != cmd.Type() || !bytes.Equal(storedIDPayload, currentIDPayload) {
|
if record.CommandType != cmd.Type() || !bytes.Equal(storedIDPayload, currentIDPayload) {
|
||||||
// 同一个 command_id 携带不同业务载荷必须显式冲突,不能返回旧命令结果掩盖客户端错误。
|
// 同一个 command_id 携带不同业务载荷必须显式冲突,不能返回旧命令结果掩盖客户端错误。
|
||||||
return nil, record, true, xerr.New(xerr.Conflict, "command_id payload conflict")
|
return nil, record, true, xerr.New(xerr.IdempotencyConflict, "command_id payload conflict")
|
||||||
}
|
}
|
||||||
|
|
||||||
return payload, record, true, nil
|
return payload, record, true, nil
|
||||||
|
|||||||
@ -160,7 +160,46 @@ func replay(current *state.RoomState, cmd command.Command, committedAtMS int64)
|
|||||||
if current.RoomExt == nil {
|
if current.RoomExt == nil {
|
||||||
current.RoomExt = make(map[string]string)
|
current.RoomExt = make(map[string]string)
|
||||||
}
|
}
|
||||||
current.RoomExt[roomExtBackgroundKey] = typed.BackgroundURL
|
backgroundURL := typed.BackgroundURL
|
||||||
|
if typed.Background.BackgroundID > 0 && typed.Background.Media.URL != "" {
|
||||||
|
// 新命令完整恢复媒体事实;旧命令没有 Background 时继续只恢复兼容 URL。
|
||||||
|
background := typed.Background
|
||||||
|
current.ActiveBackground = &background
|
||||||
|
backgroundURL = background.Media.URL
|
||||||
|
}
|
||||||
|
current.RoomExt[roomExtBackgroundKey] = backgroundURL
|
||||||
|
current.Version++
|
||||||
|
case *command.ApplyRoomDecoration:
|
||||||
|
decoration := typed.Resource
|
||||||
|
switch typed.DecorationType {
|
||||||
|
case roomDecorationTypeBorder:
|
||||||
|
current.RoomBorder = decoration.Clone()
|
||||||
|
case roomDecorationTypeColoredName:
|
||||||
|
current.RoomNameStyle = decoration.Clone()
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("apply_room_decoration replay unsupported type: %s", typed.DecorationType)
|
||||||
|
}
|
||||||
|
current.Version++
|
||||||
|
case *command.ReconcileRoomDecoration:
|
||||||
|
var currentDecoration **state.RoomDecoration
|
||||||
|
switch typed.DecorationType {
|
||||||
|
case roomDecorationTypeBorder:
|
||||||
|
currentDecoration = ¤t.RoomBorder
|
||||||
|
case roomDecorationTypeColoredName:
|
||||||
|
currentDecoration = ¤t.RoomNameStyle
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("reconcile_room_decoration replay unsupported type: %s", typed.DecorationType)
|
||||||
|
}
|
||||||
|
if typed.Active {
|
||||||
|
decoration := typed.Resource
|
||||||
|
*currentDecoration = decoration.Clone()
|
||||||
|
} else {
|
||||||
|
*currentDecoration = nil
|
||||||
|
}
|
||||||
|
current.Version++
|
||||||
|
case *command.SendRoomImage:
|
||||||
|
// 图片消息不进入房态字段,但它在原始提交中通过版本推进把 command log 与 outbox 原子绑定;
|
||||||
|
// 恢复必须重现版本谱系,绝不能再次发送腾讯 IM。
|
||||||
current.Version++
|
current.Version++
|
||||||
case *command.JoinRoom:
|
case *command.JoinRoom:
|
||||||
if current.Status == state.RoomStatusClosed && typed.ActorUserID() == current.OwnerUserID {
|
if current.Status == state.RoomStatusClosed && typed.ActorUserID() == current.OwnerUserID {
|
||||||
|
|||||||
@ -6,6 +6,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
|
|
||||||
"hyapp/services/room-service/internal/room/outbox"
|
"hyapp/services/room-service/internal/room/outbox"
|
||||||
|
"hyapp/services/room-service/internal/room/state"
|
||||||
)
|
)
|
||||||
|
|
||||||
// RoomMeta 对应 rooms 表中房间的持久元数据。
|
// RoomMeta 对应 rooms 表中房间的持久元数据。
|
||||||
@ -85,6 +86,8 @@ type MutationCommit struct {
|
|||||||
RoomWeeklyContribution *RoomWeeklyContributionIncrement
|
RoomWeeklyContribution *RoomWeeklyContributionIncrement
|
||||||
// GiftOperation 非 nil 时必须在同一事务内把 saga 推进到 committed,并保存与 command log 完全一致的结果。
|
// GiftOperation 非 nil 时必须在同一事务内把 saga 推进到 committed,并保存与 command log 完全一致的结果。
|
||||||
GiftOperation *GiftOperationCommit
|
GiftOperation *GiftOperationCommit
|
||||||
|
// RoomDecorationAssignment 与装扮命令同事务更新,供 wallet 状态事件按索引即时定位并纠正 Room Cell。
|
||||||
|
RoomDecorationAssignment *RoomDecorationAssignmentChange
|
||||||
}
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@ -181,15 +184,73 @@ type RoomCloseInfo struct {
|
|||||||
// RoomBackgroundImage 是房间 owner 保存过的背景图素材。
|
// RoomBackgroundImage 是房间 owner 保存过的背景图素材。
|
||||||
// 当前生效背景仍属于 Room Cell 快照;该表只保存可选素材列表。
|
// 当前生效背景仍属于 Room Cell 快照;该表只保存可选素材列表。
|
||||||
type RoomBackgroundImage struct {
|
type RoomBackgroundImage struct {
|
||||||
AppCode string
|
AppCode string
|
||||||
BackgroundID int64
|
BackgroundID int64
|
||||||
RoomID string
|
RoomID string
|
||||||
ImageURL string
|
ImageURL string
|
||||||
|
// CommandID 让“上传后保存素材”具备业务幂等语义;同 ID 不同媒体必须显式冲突。
|
||||||
|
CommandID string
|
||||||
|
// Media 是 gateway 专用上传入口验证后的完整媒体事实,禁止后续按 URL 后缀猜格式。
|
||||||
|
Media state.RoomMedia
|
||||||
CreatedByUserID int64
|
CreatedByUserID int64
|
||||||
CreatedAtMS int64
|
CreatedAtMS int64
|
||||||
UpdatedAtMS int64
|
UpdatedAtMS int64
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ErrRoomBackgroundCommandConflict 表示 command_id 已用于另一份背景媒体,service 映射为幂等冲突。
|
||||||
|
var ErrRoomBackgroundCommandConflict = errors.New("room background command payload conflict")
|
||||||
|
|
||||||
|
// RoomMediaUploadRegistration 是 COS PutObject 前由 room owner 持久化的上传授权事实。
|
||||||
|
// 同一 app/room/command 只能绑定一个 actor、purpose 和规范化媒体 payload,避免换文件重试产生第二个对象。
|
||||||
|
type RoomMediaUploadRegistration struct {
|
||||||
|
AppCode string
|
||||||
|
RoomID string
|
||||||
|
CommandID string
|
||||||
|
ActorUserID int64
|
||||||
|
Purpose string
|
||||||
|
MediaPayloadSHA256 string
|
||||||
|
ExpectedObjectKey string
|
||||||
|
ObjectURL string
|
||||||
|
Status string
|
||||||
|
Media state.RoomMedia
|
||||||
|
AuthorizedVersion int64
|
||||||
|
CreatedAtMS int64
|
||||||
|
UpdatedAtMS int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// ErrRoomMediaUploadCommandConflict 表示上传 command_id 已绑定另一份文件或用途。
|
||||||
|
var ErrRoomMediaUploadCommandConflict = errors.New("room media upload command payload conflict")
|
||||||
|
|
||||||
|
// RoomDecorationAssignment 是 Room Cell 当前装扮对应的低频反向索引。
|
||||||
|
// wallet 事件只能通过该表按 resource_id/owner_user_id 定位房间,禁止扫描 room_list_entries JSON。
|
||||||
|
type RoomDecorationAssignment struct {
|
||||||
|
AppCode string
|
||||||
|
RoomID string
|
||||||
|
DecorationType string
|
||||||
|
OwnerUserID int64
|
||||||
|
BenefitCode string
|
||||||
|
ResourceID int64
|
||||||
|
EntitlementID string
|
||||||
|
ExpiresAtMS int64
|
||||||
|
UpdatedAtMS int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// RoomDecorationAssignmentChange 与应用/清除 Room Cell 装扮在同一 SaveMutation 事务提交。
|
||||||
|
// Assignment=nil 表示删除该 decoration_type 的当前反向索引。
|
||||||
|
type RoomDecorationAssignmentChange struct {
|
||||||
|
DecorationType string
|
||||||
|
ExpectedResourceID int64
|
||||||
|
Assignment *RoomDecorationAssignment
|
||||||
|
}
|
||||||
|
|
||||||
|
type RoomDecorationAssignmentQuery struct {
|
||||||
|
OwnerUserID int64
|
||||||
|
ResourceID int64
|
||||||
|
AfterRoomID string
|
||||||
|
AfterDecorationType string
|
||||||
|
Limit int
|
||||||
|
}
|
||||||
|
|
||||||
// SnapshotRecord 对应 room_snapshots 中的一条最新快照。
|
// SnapshotRecord 对应 room_snapshots 中的一条最新快照。
|
||||||
type SnapshotRecord struct {
|
type SnapshotRecord struct {
|
||||||
// AppCode 是快照所属 App,恢复时必须和房间 meta 保持一致。
|
// AppCode 是快照所属 App,恢复时必须和房间 meta 保持一致。
|
||||||
@ -238,6 +299,9 @@ type RoomListEntry struct {
|
|||||||
OccupiedSeatCount int32
|
OccupiedSeatCount int32
|
||||||
// SortScore 是热门列表排序分,算法不进入 RoomState。
|
// SortScore 是热门列表排序分,算法不进入 RoomState。
|
||||||
SortScore int64
|
SortScore int64
|
||||||
|
// RoomBorderJSON/RoomNameStyleJSON 是低频装扮快照;读取时必须再次按 expires_at_ms 过滤。
|
||||||
|
RoomBorderJSON string
|
||||||
|
RoomNameStyleJSON string
|
||||||
// CreatedAtMS 是创建房间时的毫秒时间,new tab 使用它排序。
|
// CreatedAtMS 是创建房间时的毫秒时间,new tab 使用它排序。
|
||||||
CreatedAtMS int64
|
CreatedAtMS int64
|
||||||
// UpdatedAtMS 是投影最后更新时间。
|
// UpdatedAtMS 是投影最后更新时间。
|
||||||
@ -823,6 +887,29 @@ type RoomMetaStore interface {
|
|||||||
ListRoomBackgrounds(ctx context.Context, roomID string) ([]RoomBackgroundImage, error)
|
ListRoomBackgrounds(ctx context.Context, roomID string) ([]RoomBackgroundImage, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RoomBackgroundCommandReader 是新版背景保存的可选强幂等读能力。
|
||||||
|
// 它不并入 RoomMetaStore,避免旧测试/内存仓储为纯优化路径扩散接口;生产 MySQL 实现该接口后,
|
||||||
|
// 已成功 command_id 的重试可以在房间关闭、owner 元数据变化或 VIP 到期后仍返回第一次保存结果。
|
||||||
|
type RoomBackgroundCommandReader interface {
|
||||||
|
GetRoomBackgroundByCommand(ctx context.Context, roomID string, commandID string) (RoomBackgroundImage, bool, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RoomMediaUploadGuardStore 是专用上传入口的强幂等持久边界。
|
||||||
|
// Get 必须先于可变房态/VIP 校验,Register 则只在首次授权通过后原子占用 command_id。
|
||||||
|
type RoomMediaUploadGuardStore interface {
|
||||||
|
GetRoomMediaUploadRegistration(ctx context.Context, roomID string, commandID string) (RoomMediaUploadRegistration, bool, error)
|
||||||
|
GetActiveRoomMediaUploadByObjectKey(ctx context.Context, objectKey string) (RoomMediaUploadRegistration, bool, error)
|
||||||
|
RegisterRoomMediaUpload(ctx context.Context, registration RoomMediaUploadRegistration) (RoomMediaUploadRegistration, error)
|
||||||
|
CompleteRoomMediaUpload(ctx context.Context, registration RoomMediaUploadRegistration) (RoomMediaUploadRegistration, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RoomDecorationReconciliationStore 保存装扮反向索引和 wallet consumer 本地幂等事实。
|
||||||
|
type RoomDecorationReconciliationStore interface {
|
||||||
|
ListRoomDecorationAssignments(ctx context.Context, query RoomDecorationAssignmentQuery) ([]RoomDecorationAssignment, error)
|
||||||
|
HasConsumedWalletDecorationEvent(ctx context.Context, eventID string) (bool, error)
|
||||||
|
MarkWalletDecorationEventConsumed(ctx context.Context, eventID string, eventType string, consumedAtMS int64) error
|
||||||
|
}
|
||||||
|
|
||||||
// CommandStore 保存命令幂等记录和一次 Room Cell mutation 的事务提交边界。
|
// CommandStore 保存命令幂等记录和一次 Room Cell mutation 的事务提交边界。
|
||||||
type CommandStore interface {
|
type CommandStore interface {
|
||||||
// GetCommand 读取已提交命令,用于幂等重试和 command_id 冲突判定。
|
// GetCommand 读取已提交命令,用于幂等重试和 command_id 冲突判定。
|
||||||
@ -911,6 +998,8 @@ type PresenceStore interface {
|
|||||||
MarkRoomUserPresenceLeft(ctx context.Context, presence RoomPresence) error
|
MarkRoomUserPresenceLeft(ctx context.Context, presence RoomPresence) error
|
||||||
// GetCurrentRoomPresence 查询用户当前 active 房间 presence,不扫描房间列表或快照。
|
// GetCurrentRoomPresence 查询用户当前 active 房间 presence,不扫描房间列表或快照。
|
||||||
GetCurrentRoomPresence(ctx context.Context, userID int64) (RoomPresence, bool, error)
|
GetCurrentRoomPresence(ctx context.Context, userID int64) (RoomPresence, bool, error)
|
||||||
|
// BatchGetCurrentRoomPresences 用用户主键批量读取候选房间;Room Cell 快照仍是最终上麦事实。
|
||||||
|
BatchGetCurrentRoomPresences(ctx context.Context, userIDs []int64) (map[int64]RoomPresence, error)
|
||||||
// ListRoomOnlineUsers 查询房间 active 在线用户分页,不读取完整 RoomSnapshot。
|
// ListRoomOnlineUsers 查询房间 active 在线用户分页,不读取完整 RoomSnapshot。
|
||||||
ListRoomOnlineUsers(ctx context.Context, query RoomOnlineUserQuery) (RoomOnlineUserPage, error)
|
ListRoomOnlineUsers(ctx context.Context, query RoomOnlineUserQuery) (RoomOnlineUserPage, error)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -224,6 +224,8 @@ type mutationResult struct {
|
|||||||
roomUserGiftStats []RoomUserGiftStatIncrement
|
roomUserGiftStats []RoomUserGiftStatIncrement
|
||||||
// roomWeeklyContribution 是后台房间贡献列的当前 UTC 周增量,和命令日志同事务提交。
|
// roomWeeklyContribution 是后台房间贡献列的当前 UTC 周增量,和命令日志同事务提交。
|
||||||
roomWeeklyContribution *RoomWeeklyContributionIncrement
|
roomWeeklyContribution *RoomWeeklyContributionIncrement
|
||||||
|
// roomDecorationAssignment 和 Room Cell 装扮状态同事务更新,供 wallet 事件按索引即时重算。
|
||||||
|
roomDecorationAssignment *RoomDecorationAssignmentChange
|
||||||
}
|
}
|
||||||
|
|
||||||
// New 初始化 room-service 领域服务。
|
// New 初始化 room-service 领域服务。
|
||||||
|
|||||||
@ -0,0 +1,167 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log/slog"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
roomv1 "hyapp.local/api/proto/room/v1"
|
||||||
|
"hyapp/pkg/appcode"
|
||||||
|
"hyapp/pkg/logx"
|
||||||
|
"hyapp/pkg/xerr"
|
||||||
|
"hyapp/services/room-service/internal/room/state"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
maxUserVoiceRoomPresenceBatchSize = 100
|
||||||
|
userVoiceRoomStateOnMic = "on_mic"
|
||||||
|
userVoiceRoomStateNotOnMic = "not_on_mic"
|
||||||
|
)
|
||||||
|
|
||||||
|
// BatchGetUserVoiceRoomPresences 返回个人资料页可以安全展示的实时上麦入口。
|
||||||
|
// room_user_presence 只负责把 user_id 定位到候选 Room Cell;上麦、房间生命周期和 viewer 权限最终都以最新快照为准。
|
||||||
|
func (s *Service) BatchGetUserVoiceRoomPresences(ctx context.Context, req *roomv1.BatchGetUserVoiceRoomPresencesRequest) (*roomv1.BatchGetUserVoiceRoomPresencesResponse, error) {
|
||||||
|
startedAt := time.Now()
|
||||||
|
if req == nil || req.GetViewerUserId() <= 0 {
|
||||||
|
return nil, xerr.New(xerr.InvalidArgument, "viewer_user_id is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
userIDs, ok := normalizedVoiceRoomPresenceUserIDs(req.GetUserIds())
|
||||||
|
if !ok {
|
||||||
|
return nil, xerr.New(xerr.InvalidArgument, "user_ids must contain 1 to 100 positive ids")
|
||||||
|
}
|
||||||
|
ctx = appcode.WithContext(ctx, req.GetMeta().GetAppCode())
|
||||||
|
|
||||||
|
presences, err := s.repository.BatchGetCurrentRoomPresences(ctx, userIDs)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 一个 batch 可能包含同房间多个麦上用户;每个候选房间只恢复/读取一次快照,避免重复触发 Room Cell 或 MySQL 恢复链路。
|
||||||
|
snapshots := make(map[string]*roomv1.RoomSnapshot)
|
||||||
|
for userID, presence := range presences {
|
||||||
|
if userID == req.GetViewerUserId() || strings.TrimSpace(presence.RoomID) == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, loaded := snapshots[presence.RoomID]; loaded {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
snapshot, err := s.currentSnapshot(ctx, presence.RoomID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
snapshots[presence.RoomID] = snapshot
|
||||||
|
}
|
||||||
|
|
||||||
|
nowMS := s.clock.Now().UnixMilli()
|
||||||
|
resolved := make(map[int64]*roomv1.UserVoiceRoomPresence, len(userIDs))
|
||||||
|
onMicCount := 0
|
||||||
|
hiddenCount := 0
|
||||||
|
orphanCount := 0
|
||||||
|
for _, userID := range userIDs {
|
||||||
|
item := &roomv1.UserVoiceRoomPresence{
|
||||||
|
UserId: userID,
|
||||||
|
State: userVoiceRoomStateNotOnMic,
|
||||||
|
ObservedAtMs: nowMS,
|
||||||
|
}
|
||||||
|
resolved[userID] = item
|
||||||
|
|
||||||
|
// 当前产品只在客态资料页展示 Live;查询本人时按不可见结果收敛,不泄露多余房间入口。
|
||||||
|
if userID == req.GetViewerUserId() {
|
||||||
|
hiddenCount++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
presence, exists := presences[userID]
|
||||||
|
if !exists {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
snapshot := snapshots[presence.RoomID]
|
||||||
|
if snapshot == nil || snapshot.GetStatus() != state.RoomStatusActive || snapshot.GetAppCode() != appcode.FromContext(ctx) {
|
||||||
|
// presence 投影允许短暂滞后;孤儿索引只能降级为 not_on_mic,不能返回旧 room_id。
|
||||||
|
orphanCount++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if snapshotUserBanned(snapshot, req.GetViewerUserId(), nowMS) {
|
||||||
|
// viewer 被房间封禁时正式进房必然失败,因此这里隐藏全部房间定位字段。
|
||||||
|
hiddenCount++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seat := protoSeatByUser(snapshot, userID)
|
||||||
|
if seat == nil {
|
||||||
|
// 仅在房间内但没有占用麦位不是上麦状态。
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if findProtoUser(snapshot, userID) == nil {
|
||||||
|
// 麦位用户不在当前 online_users 说明快照内部事实不完整,按异常数据隐藏并留日志计数。
|
||||||
|
orphanCount++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
ext := snapshot.GetRoomExt()
|
||||||
|
roomShortID := snapshot.GetRoomShortId()
|
||||||
|
if roomShortID == "" {
|
||||||
|
roomShortID = ext["room_short_id"]
|
||||||
|
}
|
||||||
|
onlineCount := snapshot.GetOnlineCount()
|
||||||
|
if onlineCount <= 0 && len(snapshot.GetOnlineUsers()) > 0 {
|
||||||
|
onlineCount = int32(len(snapshot.GetOnlineUsers()))
|
||||||
|
}
|
||||||
|
item.State = userVoiceRoomStateOnMic
|
||||||
|
item.SeatNo = seat.GetSeatNo()
|
||||||
|
item.Joinable = true
|
||||||
|
item.Room = &roomv1.UserVoiceRoomPresenceRoom{
|
||||||
|
RoomId: snapshot.GetRoomId(),
|
||||||
|
RoomShortId: roomShortID,
|
||||||
|
OwnerUserId: snapshot.GetOwnerUserId(),
|
||||||
|
HostUserId: snapshot.GetOwnerUserId(),
|
||||||
|
Title: ext["title"],
|
||||||
|
CoverUrl: ext["cover_url"],
|
||||||
|
BackgroundUrl: ext["background_url"],
|
||||||
|
Mode: snapshot.GetMode(),
|
||||||
|
OnlineCount: onlineCount,
|
||||||
|
SeatCount: int32(len(snapshot.GetMicSeats())),
|
||||||
|
Locked: snapshot.GetLocked(),
|
||||||
|
}
|
||||||
|
onMicCount++
|
||||||
|
}
|
||||||
|
|
||||||
|
items := make([]*roomv1.UserVoiceRoomPresence, 0, len(userIDs))
|
||||||
|
for _, userID := range userIDs {
|
||||||
|
items = append(items, resolved[userID])
|
||||||
|
}
|
||||||
|
logx.Info(ctx, "user_voice_room_presence_batch_queried",
|
||||||
|
slog.String("request_id", req.GetMeta().GetRequestId()),
|
||||||
|
slog.String("app_code", appcode.FromContext(ctx)),
|
||||||
|
slog.Int64("viewer_user_id", req.GetViewerUserId()),
|
||||||
|
slog.Int("user_count", len(userIDs)),
|
||||||
|
slog.Int("active_presence_count", len(presences)),
|
||||||
|
slog.Int("on_mic_count", onMicCount),
|
||||||
|
slog.Int("hidden_count", hiddenCount),
|
||||||
|
slog.Int("orphan_count", orphanCount),
|
||||||
|
slog.Int64("duration_ms", time.Since(startedAt).Milliseconds()),
|
||||||
|
)
|
||||||
|
return &roomv1.BatchGetUserVoiceRoomPresencesResponse{Items: items, ServerTimeMs: nowMS}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizedVoiceRoomPresenceUserIDs(values []int64) ([]int64, bool) {
|
||||||
|
if len(values) == 0 {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
seen := make(map[int64]struct{}, len(values))
|
||||||
|
result := make([]int64, 0, len(values))
|
||||||
|
for _, userID := range values {
|
||||||
|
if userID <= 0 {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
if _, exists := seen[userID]; exists {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[userID] = struct{}{}
|
||||||
|
result = append(result, userID)
|
||||||
|
if len(result) > maxUserVoiceRoomPresenceBatchSize {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result, len(result) > 0
|
||||||
|
}
|
||||||
@ -10,6 +10,7 @@ import (
|
|||||||
walletv1 "hyapp.local/api/proto/wallet/v1"
|
walletv1 "hyapp.local/api/proto/wallet/v1"
|
||||||
"hyapp/pkg/appcode"
|
"hyapp/pkg/appcode"
|
||||||
"hyapp/pkg/xerr"
|
"hyapp/pkg/xerr"
|
||||||
|
"hyapp/services/room-service/internal/integration"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@ -18,6 +19,9 @@ const (
|
|||||||
tieredPrivilegeVIPProgram = "tiered_privilege_v1"
|
tieredPrivilegeVIPProgram = "tiered_privilege_v1"
|
||||||
|
|
||||||
vipBenefitCustomRoomBackground = "custom_room_background"
|
vipBenefitCustomRoomBackground = "custom_room_background"
|
||||||
|
vipBenefitRoomImageMessage = "room_image_message"
|
||||||
|
vipBenefitRoomBorder = "room_border"
|
||||||
|
vipBenefitColoredRoomName = "colored_room_name"
|
||||||
vipBenefitAntiKick = "anti_kick"
|
vipBenefitAntiKick = "anti_kick"
|
||||||
vipBenefitAntiMute = "anti_mute"
|
vipBenefitAntiMute = "anti_mute"
|
||||||
vipBenefitRoomEntryNotice = "room_entry_notice"
|
vipBenefitRoomEntryNotice = "room_entry_notice"
|
||||||
@ -107,9 +111,47 @@ func (s *Service) loadEffectiveVIPAccess(ctx context.Context, requestID string,
|
|||||||
}
|
}
|
||||||
|
|
||||||
// requireCustomRoomBackgroundBenefit 只对配置为 P1 权益制的 App 执行自定义背景门禁。
|
// requireCustomRoomBackgroundBenefit 只对配置为 P1 权益制的 App 执行自定义背景门禁。
|
||||||
|
// action 由调用点传入精确动作,Flutter 才能区分上传、保存和切换失败并保留对应本地状态。
|
||||||
// legacy_timed 保持 Lalu 原行为;新 App 只需在 wallet 切换 program_type,无需再修改 room-service。
|
// legacy_timed 保持 Lalu 原行为;新 App 只需在 wallet 切换 program_type,无需再修改 room-service。
|
||||||
func (s *Service) requireCustomRoomBackgroundBenefit(ctx context.Context, requestID string, ownerUserID int64) error {
|
func (s *Service) requireCustomRoomBackgroundBenefit(ctx context.Context, requestID string, ownerUserID int64, action string) error {
|
||||||
access, err := s.loadEffectiveVIPAccess(ctx, requestID, ownerUserID)
|
return s.requireVIPBenefit(ctx, requestID, ownerUserID, vipBenefitCustomRoomBackground, action)
|
||||||
|
}
|
||||||
|
|
||||||
|
// requireVIPBenefit 优先调用 wallet 的单项权益判定,并把升级页需要的等级事实放进错误 metadata。
|
||||||
|
// legacy_timed App 沿用旧行为;P1 App 在 wallet 不可用或没有权益时都失败关闭,避免故障窗口越权。
|
||||||
|
func (s *Service) requireVIPBenefit(ctx context.Context, requestID string, userID int64, benefitCode string, action string) error {
|
||||||
|
if checker, ok := s.wallet.(integration.WalletVIPBenefitClient); ok {
|
||||||
|
lookupCtx, cancel := context.WithTimeout(ctx, roomVIPLookupTimeout)
|
||||||
|
defer cancel()
|
||||||
|
response, err := checker.CheckVipBenefit(lookupCtx, &walletv1.CheckVipBenefitRequest{
|
||||||
|
RequestId: strings.TrimSpace(requestID),
|
||||||
|
AppCode: appcode.FromContext(ctx),
|
||||||
|
UserId: userID,
|
||||||
|
BenefitCode: normalizeVIPCode(benefitCode),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
// 对外错误只暴露稳定的业务语义,底层 gRPC 状态和地址不能进入 HTTP envelope。
|
||||||
|
return xerr.New(xerr.Unavailable, "check vip benefit failed")
|
||||||
|
}
|
||||||
|
if response == nil || response.GetState() == nil || response.GetState().GetProgramConfig() == nil {
|
||||||
|
return xerr.New(xerr.Unavailable, "wallet vip benefit state is incomplete")
|
||||||
|
}
|
||||||
|
if normalizeVIPCode(response.GetState().GetProgramConfig().GetProgramType()) != tieredPrivilegeVIPProgram {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if response.GetAllowed() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return xerr.NewWithMetadata(xerr.VIPBenefitRequired, "vip benefit is required", map[string]string{
|
||||||
|
"benefit_code": normalizeVIPCode(benefitCode),
|
||||||
|
"required_level": fmt.Sprintf("%d", response.GetRequiredLevel()),
|
||||||
|
"current_effective_level": fmt.Sprintf("%d", response.GetCurrentEffectiveLevel()),
|
||||||
|
"action": strings.TrimSpace(action),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 兼容滚动升级和现有测试 fake:旧 client 仍可从 effective_benefits 得到同一授权事实。
|
||||||
|
access, err := s.loadEffectiveVIPAccess(ctx, requestID, userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// 权限事实不可用时必须失败关闭;放行会让无权益用户在 wallet 故障窗口获得可持久化背景权限。
|
// 权限事实不可用时必须失败关闭;放行会让无权益用户在 wallet 故障窗口获得可持久化背景权限。
|
||||||
return err
|
return err
|
||||||
@ -117,8 +159,13 @@ func (s *Service) requireCustomRoomBackgroundBenefit(ctx context.Context, reques
|
|||||||
if access.programType != tieredPrivilegeVIPProgram {
|
if access.programType != tieredPrivilegeVIPProgram {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
if !access.hasBenefit(vipBenefitCustomRoomBackground) {
|
if !access.hasBenefit(benefitCode) {
|
||||||
return xerr.New(xerr.VIPBenefitRequired, "custom room background vip benefit is required")
|
return xerr.NewWithMetadata(xerr.VIPBenefitRequired, "vip benefit is required", map[string]string{
|
||||||
|
"benefit_code": normalizeVIPCode(benefitCode),
|
||||||
|
"required_level": "0",
|
||||||
|
"current_effective_level": fmt.Sprintf("%d", access.level),
|
||||||
|
"action": strings.TrimSpace(action),
|
||||||
|
})
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,296 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"reflect"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
roomeventsv1 "hyapp.local/api/proto/events/room/v1"
|
||||||
|
"hyapp/pkg/appcode"
|
||||||
|
"hyapp/pkg/walletmq"
|
||||||
|
"hyapp/pkg/xerr"
|
||||||
|
"hyapp/services/room-service/internal/room/command"
|
||||||
|
"hyapp/services/room-service/internal/room/outbox"
|
||||||
|
"hyapp/services/room-service/internal/room/rank"
|
||||||
|
"hyapp/services/room-service/internal/room/state"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
walletEventResourceChanged = "ResourceChanged"
|
||||||
|
walletEventResourceGrantRevoked = "ResourceGrantRevoked"
|
||||||
|
walletEventUserResourceChanged = "UserResourceChanged"
|
||||||
|
walletEventVipEffectiveChanged = "VipEffectiveChanged"
|
||||||
|
walletEventVipProgramChanged = "VipProgramChanged"
|
||||||
|
roomDecorationReconcileBatchSize = 100
|
||||||
|
)
|
||||||
|
|
||||||
|
// HandleWalletDecorationEvent 消费 wallet owner 已提交的权限/资源变化,并通过 Room Cell 命令即时更新装扮。
|
||||||
|
// 专用 assignment 表提供反向索引;每个房间的 command log 和全局 consumption 表共同承担 at-least-once 幂等。
|
||||||
|
func (s *Service) HandleWalletDecorationEvent(ctx context.Context, message walletmq.WalletOutboxMessage) error {
|
||||||
|
queries, handled, err := walletDecorationAssignmentQueries(message)
|
||||||
|
if err != nil || !handled {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
store, ok := s.repository.(RoomDecorationReconciliationStore)
|
||||||
|
if !ok {
|
||||||
|
return xerr.New(xerr.Unavailable, "room decoration reconciliation store is unavailable")
|
||||||
|
}
|
||||||
|
ctx = appcode.WithContext(ctx, message.AppCode)
|
||||||
|
consumed, err := store.HasConsumedWalletDecorationEvent(ctx, message.EventID)
|
||||||
|
if err != nil || consumed {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
assignments, err := listWalletEventDecorationAssignments(ctx, store, queries)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, assignment := range assignments {
|
||||||
|
if err := s.reconcileRoomDecorationAssignment(ctx, message, assignment); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return store.MarkWalletDecorationEventConsumed(ctx, message.EventID, message.EventType, s.clock.Now().UnixMilli())
|
||||||
|
}
|
||||||
|
|
||||||
|
func walletDecorationAssignmentQueries(message walletmq.WalletOutboxMessage) ([]RoomDecorationAssignmentQuery, bool, error) {
|
||||||
|
switch strings.TrimSpace(message.EventType) {
|
||||||
|
case walletEventResourceChanged:
|
||||||
|
resourceID := walletPayloadInt64(message.PayloadJSON, "resource_id", "resourceid")
|
||||||
|
if resourceID <= 0 {
|
||||||
|
return nil, true, fmt.Errorf("ResourceChanged payload has no resource_id")
|
||||||
|
}
|
||||||
|
return []RoomDecorationAssignmentQuery{{ResourceID: resourceID}}, true, nil
|
||||||
|
case walletEventResourceGrantRevoked, walletEventUserResourceChanged, walletEventVipEffectiveChanged:
|
||||||
|
if message.UserID <= 0 {
|
||||||
|
return nil, true, fmt.Errorf("%s has no user_id", message.EventType)
|
||||||
|
}
|
||||||
|
return []RoomDecorationAssignmentQuery{{OwnerUserID: message.UserID}}, true, nil
|
||||||
|
case walletEventVipProgramChanged:
|
||||||
|
resourceIDs := walletPayloadInt64Slice(message.PayloadJSON,
|
||||||
|
"resource_ids", "old_resource_ids", "new_resource_ids", "oldresourceids", "newresourceids")
|
||||||
|
if len(resourceIDs) == 0 {
|
||||||
|
// program status/level_count 变化可能影响所有当前装扮;这里只分页扫描专用 assignment 小表。
|
||||||
|
return []RoomDecorationAssignmentQuery{{}}, true, nil
|
||||||
|
}
|
||||||
|
queries := make([]RoomDecorationAssignmentQuery, 0, len(resourceIDs))
|
||||||
|
for _, resourceID := range resourceIDs {
|
||||||
|
queries = append(queries, RoomDecorationAssignmentQuery{ResourceID: resourceID})
|
||||||
|
}
|
||||||
|
return queries, true, nil
|
||||||
|
default:
|
||||||
|
// legacy Tag 兼容订阅会收到同 topic 的其它钱包事实,非装扮事件必须快速确认且不能写消费表。
|
||||||
|
return nil, false, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func listWalletEventDecorationAssignments(ctx context.Context, store RoomDecorationReconciliationStore, queries []RoomDecorationAssignmentQuery) ([]RoomDecorationAssignment, error) {
|
||||||
|
byKey := make(map[string]RoomDecorationAssignment)
|
||||||
|
for _, query := range queries {
|
||||||
|
query.Limit = roomDecorationReconcileBatchSize
|
||||||
|
for {
|
||||||
|
page, err := store.ListRoomDecorationAssignments(ctx, query)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for _, assignment := range page {
|
||||||
|
byKey[assignment.RoomID+"\x00"+assignment.DecorationType] = assignment
|
||||||
|
}
|
||||||
|
if len(page) < query.Limit {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
last := page[len(page)-1]
|
||||||
|
query.AfterRoomID, query.AfterDecorationType = last.RoomID, last.DecorationType
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assignments := make([]RoomDecorationAssignment, 0, len(byKey))
|
||||||
|
for _, assignment := range byKey {
|
||||||
|
assignments = append(assignments, assignment)
|
||||||
|
}
|
||||||
|
sort.Slice(assignments, func(i, j int) bool {
|
||||||
|
if assignments[i].RoomID != assignments[j].RoomID {
|
||||||
|
return assignments[i].RoomID < assignments[j].RoomID
|
||||||
|
}
|
||||||
|
return assignments[i].DecorationType < assignments[j].DecorationType
|
||||||
|
})
|
||||||
|
return assignments, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) reconcileRoomDecorationAssignment(ctx context.Context, message walletmq.WalletOutboxMessage, assignment RoomDecorationAssignment) error {
|
||||||
|
resourceType, fallbackBenefitCode, err := roomDecorationContract(assignment.DecorationType)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
benefitCode := strings.TrimSpace(assignment.BenefitCode)
|
||||||
|
if benefitCode == "" {
|
||||||
|
benefitCode = fallbackBenefitCode
|
||||||
|
}
|
||||||
|
resource, resolveErr := s.resolveRoomDecoration(
|
||||||
|
ctx, message.EventID, assignment.OwnerUserID, assignment.ResourceID, assignment.EntitlementID, resourceType, benefitCode,
|
||||||
|
)
|
||||||
|
active := resolveErr == nil
|
||||||
|
if resolveErr != nil && !walletDecorationInvalidationError(resolveErr) {
|
||||||
|
// wallet 暂不可用不能被误判成撤销;返回错误让 RocketMQ 保持重试。
|
||||||
|
return resolveErr
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd := command.ReconcileRoomDecoration{
|
||||||
|
Base: command.Base{
|
||||||
|
AppCode: appcode.FromContext(ctx), RequestID: message.EventID,
|
||||||
|
CommandID: walletDecorationCommandID(message.EventID, assignment.RoomID, assignment.DecorationType),
|
||||||
|
ActorID: assignment.OwnerUserID, Room: assignment.RoomID, GatewayNodeID: "wallet_outbox",
|
||||||
|
SentAtMS: message.OccurredAtMS,
|
||||||
|
},
|
||||||
|
DecorationType: assignment.DecorationType, ExpectedResourceID: assignment.ResourceID,
|
||||||
|
BenefitCode: benefitCode, SourceEventID: message.EventID, Active: active, Resource: resource,
|
||||||
|
}
|
||||||
|
_, err = s.mutateRoom(ctx, cmd, true, func(now time.Time, current *state.RoomState, _ *rank.LocalRank) (mutationResult, []outbox.Record, error) {
|
||||||
|
var currentDecoration **state.RoomDecoration
|
||||||
|
switch cmd.DecorationType {
|
||||||
|
case roomDecorationTypeBorder:
|
||||||
|
currentDecoration = ¤t.RoomBorder
|
||||||
|
case roomDecorationTypeColoredName:
|
||||||
|
currentDecoration = ¤t.RoomNameStyle
|
||||||
|
default:
|
||||||
|
return mutationResult{}, nil, xerr.New(xerr.InvalidArgument, "decoration_type is invalid")
|
||||||
|
}
|
||||||
|
if *currentDecoration == nil || (*currentDecoration).ResourceID != cmd.ExpectedResourceID {
|
||||||
|
// assignment 查询后房主可能已切换资源;条件删除旧索引,不触碰当前新装扮。
|
||||||
|
return mutationResult{
|
||||||
|
snapshot: current.ToProtoAt(now.UnixMilli()),
|
||||||
|
roomDecorationAssignment: &RoomDecorationAssignmentChange{
|
||||||
|
DecorationType: cmd.DecorationType, ExpectedResourceID: cmd.ExpectedResourceID,
|
||||||
|
},
|
||||||
|
}, nil, nil
|
||||||
|
}
|
||||||
|
if cmd.Active && reflect.DeepEqual(**currentDecoration, cmd.Resource) {
|
||||||
|
return mutationResult{
|
||||||
|
snapshot: current.ToProtoAt(now.UnixMilli()),
|
||||||
|
roomDecorationAssignment: roomDecorationAssignmentChange(current, cmd.DecorationType, cmd.BenefitCode, cmd.Resource, now.UnixMilli()),
|
||||||
|
}, nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if cmd.Active {
|
||||||
|
decoration := cmd.Resource
|
||||||
|
*currentDecoration = decoration.Clone()
|
||||||
|
} else {
|
||||||
|
*currentDecoration = nil
|
||||||
|
}
|
||||||
|
current.Version++
|
||||||
|
changed := cmd.Resource
|
||||||
|
if !cmd.Active {
|
||||||
|
changed = state.RoomDecoration{}
|
||||||
|
}
|
||||||
|
event, buildErr := outbox.Build(current.RoomID, "RoomDecorationChanged", current.Version, now, &roomeventsv1.RoomDecorationChanged{
|
||||||
|
ActorUserId: cmd.ActorUserID(), DecorationType: cmd.DecorationType, ResourceId: changed.ResourceID,
|
||||||
|
ResourceCode: changed.ResourceCode, ResourceType: changed.ResourceType, Name: changed.Name,
|
||||||
|
AssetUrl: changed.AssetURL, PreviewUrl: changed.PreviewURL, AnimationUrl: changed.AnimationURL,
|
||||||
|
Format: changed.Format, MetadataJson: changed.MetadataJSON, TextColors: append([]string(nil), changed.TextColors...),
|
||||||
|
Stops: append([]float64(nil), changed.Stops...), AngleDegrees: changed.AngleDegrees,
|
||||||
|
EntitlementId: changed.EntitlementID, ExpiresAtMs: changed.ExpiresAtMS,
|
||||||
|
})
|
||||||
|
if buildErr != nil {
|
||||||
|
return mutationResult{}, nil, buildErr
|
||||||
|
}
|
||||||
|
assignmentChange := &RoomDecorationAssignmentChange{
|
||||||
|
DecorationType: cmd.DecorationType, ExpectedResourceID: cmd.ExpectedResourceID,
|
||||||
|
}
|
||||||
|
if cmd.Active {
|
||||||
|
assignmentChange = roomDecorationAssignmentChange(current, cmd.DecorationType, cmd.BenefitCode, cmd.Resource, now.UnixMilli())
|
||||||
|
}
|
||||||
|
return mutationResult{
|
||||||
|
snapshot: current.ToProtoAt(now.UnixMilli()), roomDecorationAssignment: assignmentChange,
|
||||||
|
}, []outbox.Record{event}, nil
|
||||||
|
})
|
||||||
|
if xerr.IsCode(err, xerr.NotFound) {
|
||||||
|
// 房间与 assignment 删除并发时,删除事务已经完成 owner 清理,该 wallet 事件可安全确认。
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func roomDecorationContract(decorationType string) (string, string, error) {
|
||||||
|
switch strings.TrimSpace(decorationType) {
|
||||||
|
case roomDecorationTypeBorder:
|
||||||
|
return roomDecorationResourceBorder, vipBenefitRoomBorder, nil
|
||||||
|
case roomDecorationTypeColoredName:
|
||||||
|
return roomDecorationResourceName, vipBenefitColoredRoomName, nil
|
||||||
|
default:
|
||||||
|
return "", "", xerr.New(xerr.InvalidArgument, "decoration_type is invalid")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func walletDecorationInvalidationError(err error) bool {
|
||||||
|
switch xerr.CodeOf(err) {
|
||||||
|
case xerr.VIPBenefitRequired, xerr.NotFound, xerr.InvalidArgument:
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func walletDecorationCommandID(eventID string, roomID string, decorationType string) string {
|
||||||
|
sum := sha256.Sum256([]byte(strings.Join([]string{eventID, roomID, decorationType}, "\x00")))
|
||||||
|
return "wallet_deco_" + hex.EncodeToString(sum[:16])
|
||||||
|
}
|
||||||
|
|
||||||
|
func walletPayloadInt64(payload string, keys ...string) int64 {
|
||||||
|
values := walletPayloadObject(payload)
|
||||||
|
for _, key := range keys {
|
||||||
|
if raw, exists := values[normalizeWalletPayloadKey(key)]; exists {
|
||||||
|
var value int64
|
||||||
|
if json.Unmarshal(raw, &value) == nil && value > 0 {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func walletPayloadInt64Slice(payload string, keys ...string) []int64 {
|
||||||
|
values := walletPayloadObject(payload)
|
||||||
|
seen := make(map[int64]struct{})
|
||||||
|
for _, key := range keys {
|
||||||
|
raw, exists := values[normalizeWalletPayloadKey(key)]
|
||||||
|
if !exists {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var items []int64
|
||||||
|
if json.Unmarshal(raw, &items) != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, item := range items {
|
||||||
|
if item > 0 {
|
||||||
|
seen[item] = struct{}{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out := make([]int64, 0, len(seen))
|
||||||
|
for item := range seen {
|
||||||
|
out = append(out, item)
|
||||||
|
}
|
||||||
|
sort.Slice(out, func(i, j int) bool { return out[i] < out[j] })
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func walletPayloadObject(payload string) map[string]json.RawMessage {
|
||||||
|
var decoded map[string]json.RawMessage
|
||||||
|
if json.Unmarshal([]byte(payload), &decoded) != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
out := make(map[string]json.RawMessage, len(decoded))
|
||||||
|
for key, value := range decoded {
|
||||||
|
out[normalizeWalletPayloadKey(key)] = value
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeWalletPayloadKey(value string) string {
|
||||||
|
return strings.ReplaceAll(strings.ToLower(strings.TrimSpace(value)), "_", "")
|
||||||
|
}
|
||||||
@ -4,6 +4,7 @@ package state
|
|||||||
import (
|
import (
|
||||||
"maps"
|
"maps"
|
||||||
"slices"
|
"slices"
|
||||||
|
"time"
|
||||||
|
|
||||||
roomv1 "hyapp.local/api/proto/room/v1"
|
roomv1 "hyapp.local/api/proto/room/v1"
|
||||||
)
|
)
|
||||||
@ -116,6 +117,67 @@ type UserModerationState struct {
|
|||||||
ExpiresAtMS int64
|
ExpiresAtMS int64
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RoomMedia 保存已经通过专用上传入口验证的媒体属性。
|
||||||
|
// 这些字段随 Room Cell 快照和 command log 恢复,客户端不需要也不允许按 URL 后缀重新猜类型。
|
||||||
|
type RoomMedia 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"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// RoomBackground 是当前生效背景的完整素材快照。
|
||||||
|
// 背景列表表仍是低频素材目录;一旦应用,恢复只依赖该快照而不反查可能被运营修改的素材行。
|
||||||
|
type RoomBackground struct {
|
||||||
|
BackgroundID int64 `json:"background_id"`
|
||||||
|
RoomID string `json:"room_id"`
|
||||||
|
CreatedByUserID int64 `json:"created_by_user_id"`
|
||||||
|
CreatedAtMS int64 `json:"created_at_ms"`
|
||||||
|
UpdatedAtMS int64 `json:"updated_at_ms"`
|
||||||
|
CommandID string `json:"command_id"`
|
||||||
|
Media RoomMedia `json:"media"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// RoomDecoration 是房主 entitlement 在应用瞬间固化的资源展示快照。
|
||||||
|
// 权限资格仍由 wallet 实时判断;Room Cell 只持有房间当前选择,不复制 entitlement 生命周期算法。
|
||||||
|
type RoomDecoration 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"`
|
||||||
|
AnimationURL string `json:"animation_url"`
|
||||||
|
Format string `json:"format"`
|
||||||
|
MetadataJSON string `json:"metadata_json"`
|
||||||
|
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"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clone 深拷贝装扮内的颜色和渐变停靠点,避免失败命令通过共享切片污染当前房态。
|
||||||
|
func (d *RoomDecoration) Clone() *RoomDecoration {
|
||||||
|
if d == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
cloned := *d
|
||||||
|
cloned.TextColors = append([]string(nil), d.TextColors...)
|
||||||
|
cloned.Stops = append([]float64(nil), d.Stops...)
|
||||||
|
return &cloned
|
||||||
|
}
|
||||||
|
|
||||||
// ActiveAt 判断治理状态在指定 UTC 毫秒是否仍然生效。
|
// ActiveAt 判断治理状态在指定 UTC 毫秒是否仍然生效。
|
||||||
func (m UserModerationState) ActiveAt(nowMS int64) bool {
|
func (m UserModerationState) ActiveAt(nowMS int64) bool {
|
||||||
return m.UserID > 0 && (m.ExpiresAtMS == 0 || m.ExpiresAtMS > nowMS)
|
return m.UserID > 0 && (m.ExpiresAtMS == 0 || m.ExpiresAtMS > nowMS)
|
||||||
@ -257,6 +319,11 @@ type RoomState struct {
|
|||||||
GiftRank []RankItem
|
GiftRank []RankItem
|
||||||
// RoomExt 预留少量房间扩展字段,避免 v1 频繁改结构。
|
// RoomExt 预留少量房间扩展字段,避免 v1 频繁改结构。
|
||||||
RoomExt map[string]string
|
RoomExt map[string]string
|
||||||
|
// ActiveBackground 是当前生效背景的可信媒体快照;RoomExt.background_url 仅兼容旧客户端。
|
||||||
|
ActiveBackground *RoomBackground
|
||||||
|
// RoomBorder/RoomNameStyle 是房间级装扮最终值,必须随 Room Cell 串行提交和恢复。
|
||||||
|
RoomBorder *RoomDecoration
|
||||||
|
RoomNameStyle *RoomDecoration
|
||||||
// Rocket 是语音房火箭状态,送礼燃料和发射必须跟随 Room Cell 串行提交。
|
// Rocket 是语音房火箭状态,送礼燃料和发射必须跟随 Room Cell 串行提交。
|
||||||
Rocket *RocketState
|
Rocket *RocketState
|
||||||
// Version 是房间状态版本,成功变更后递增,快照和 command log 都依赖它。
|
// Version 是房间状态版本,成功变更后递增,快照和 command log 都依赖它。
|
||||||
@ -311,6 +378,9 @@ func (s *RoomState) Clone() *RoomState {
|
|||||||
Heat: s.Heat,
|
Heat: s.Heat,
|
||||||
GiftRank: append([]RankItem(nil), s.GiftRank...),
|
GiftRank: append([]RankItem(nil), s.GiftRank...),
|
||||||
RoomExt: make(map[string]string, len(s.RoomExt)),
|
RoomExt: make(map[string]string, len(s.RoomExt)),
|
||||||
|
ActiveBackground: cloneRoomBackground(s.ActiveBackground),
|
||||||
|
RoomBorder: s.RoomBorder.Clone(),
|
||||||
|
RoomNameStyle: s.RoomNameStyle.Clone(),
|
||||||
Rocket: cloneRocketState(s.Rocket),
|
Rocket: cloneRocketState(s.Rocket),
|
||||||
Version: s.Version,
|
Version: s.Version,
|
||||||
}
|
}
|
||||||
@ -379,6 +449,12 @@ func (s *RoomState) SeatIndex(seatNo int32) int {
|
|||||||
|
|
||||||
// ToProto 把当前内部房间态投影成对外使用的 protobuf 快照。
|
// ToProto 把当前内部房间态投影成对外使用的 protobuf 快照。
|
||||||
func (s *RoomState) ToProto() *roomv1.RoomSnapshot {
|
func (s *RoomState) ToProto() *roomv1.RoomSnapshot {
|
||||||
|
return s.ToProtoAt(time.Now().UTC().UnixMilli())
|
||||||
|
}
|
||||||
|
|
||||||
|
// ToProtoAt 以响应的 server_time_ms 过滤已到期装扮。过期资源仍留在 Room Cell 和 command log
|
||||||
|
// 供审计/恢复,但绝不能继续出现在 room detail、snapshot 或 feed 的客户端投影中。
|
||||||
|
func (s *RoomState) ToProtoAt(nowMS int64) *roomv1.RoomSnapshot {
|
||||||
if s == nil {
|
if s == nil {
|
||||||
// nil 状态没有可投影快照,调用方按房间不存在处理。
|
// nil 状态没有可投影快照,调用方按房间不存在处理。
|
||||||
return nil
|
return nil
|
||||||
@ -446,26 +522,29 @@ func (s *RoomState) ToProto() *roomv1.RoomSnapshot {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return &roomv1.RoomSnapshot{
|
return &roomv1.RoomSnapshot{
|
||||||
RoomId: s.RoomID,
|
RoomId: s.RoomID,
|
||||||
OwnerUserId: s.OwnerUserID,
|
OwnerUserId: s.OwnerUserID,
|
||||||
Mode: s.Mode,
|
Mode: s.Mode,
|
||||||
VisibleRegionId: s.VisibleRegionID,
|
VisibleRegionId: s.VisibleRegionID,
|
||||||
Status: s.Status,
|
Status: s.Status,
|
||||||
ChatEnabled: s.ChatEnabled,
|
ChatEnabled: s.ChatEnabled,
|
||||||
MicSeats: seats,
|
MicSeats: seats,
|
||||||
OnlineUsers: users,
|
OnlineUsers: users,
|
||||||
AdminUserIds: sortedSetIDsExcept(s.AdminUsers, s.OwnerUserID),
|
AdminUserIds: sortedSetIDsExcept(s.AdminUsers, s.OwnerUserID),
|
||||||
BanUserIds: sortedModerationIDs(s.BanUsers),
|
BanUserIds: sortedModerationIDs(s.BanUsers),
|
||||||
MuteUserIds: sortedSetIDs(s.MuteUsers),
|
MuteUserIds: sortedSetIDs(s.MuteUsers),
|
||||||
GiftRank: rankItems,
|
GiftRank: rankItems,
|
||||||
RoomExt: cloneStringMap(s.RoomExt),
|
RoomExt: cloneStringMap(s.RoomExt),
|
||||||
Heat: s.Heat,
|
Heat: s.Heat,
|
||||||
Version: s.Version,
|
Version: s.Version,
|
||||||
RoomShortId: s.RoomExt["room_short_id"],
|
RoomShortId: s.RoomExt["room_short_id"],
|
||||||
Locked: s.RoomPasswordHash != "",
|
Locked: s.RoomPasswordHash != "",
|
||||||
Rocket: rocketStateToProto(s.Rocket),
|
Rocket: rocketStateToProto(s.Rocket),
|
||||||
BanStates: sortedModerationStates(s.BanUsers),
|
BanStates: sortedModerationStates(s.BanUsers),
|
||||||
OnlineCount: int32(len(users)),
|
OnlineCount: int32(len(users)),
|
||||||
|
RoomBorder: roomDecorationToProtoAt(s.RoomBorder, nowMS),
|
||||||
|
RoomNameStyle: roomDecorationToProtoAt(s.RoomNameStyle, nowMS),
|
||||||
|
ActiveBackground: roomBackgroundToProto(s.ActiveBackground),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -478,22 +557,25 @@ func FromProto(snapshot *roomv1.RoomSnapshot) *RoomState {
|
|||||||
|
|
||||||
// 所有集合重新分配,恢复后的状态可以直接交给 Room Cell 独占使用。
|
// 所有集合重新分配,恢复后的状态可以直接交给 Room Cell 独占使用。
|
||||||
restored := &RoomState{
|
restored := &RoomState{
|
||||||
RoomID: snapshot.GetRoomId(),
|
RoomID: snapshot.GetRoomId(),
|
||||||
OwnerUserID: snapshot.GetOwnerUserId(),
|
OwnerUserID: snapshot.GetOwnerUserId(),
|
||||||
Mode: snapshot.GetMode(),
|
Mode: snapshot.GetMode(),
|
||||||
VisibleRegionID: snapshot.GetVisibleRegionId(),
|
VisibleRegionID: snapshot.GetVisibleRegionId(),
|
||||||
Status: snapshot.GetStatus(),
|
Status: snapshot.GetStatus(),
|
||||||
ChatEnabled: snapshot.GetChatEnabled(),
|
ChatEnabled: snapshot.GetChatEnabled(),
|
||||||
MicSeats: make([]MicSeat, 0, len(snapshot.GetMicSeats())),
|
MicSeats: make([]MicSeat, 0, len(snapshot.GetMicSeats())),
|
||||||
OnlineUsers: make(map[int64]*RoomUserState, len(snapshot.GetOnlineUsers())),
|
OnlineUsers: make(map[int64]*RoomUserState, len(snapshot.GetOnlineUsers())),
|
||||||
AdminUsers: make(map[int64]bool, len(snapshot.GetAdminUserIds())),
|
AdminUsers: make(map[int64]bool, len(snapshot.GetAdminUserIds())),
|
||||||
BanUsers: make(map[int64]UserModerationState, len(snapshot.GetBanStates())+len(snapshot.GetBanUserIds())),
|
BanUsers: make(map[int64]UserModerationState, len(snapshot.GetBanStates())+len(snapshot.GetBanUserIds())),
|
||||||
MuteUsers: make(map[int64]bool, len(snapshot.GetMuteUserIds())),
|
MuteUsers: make(map[int64]bool, len(snapshot.GetMuteUserIds())),
|
||||||
GiftRank: make([]RankItem, 0, len(snapshot.GetGiftRank())),
|
GiftRank: make([]RankItem, 0, len(snapshot.GetGiftRank())),
|
||||||
RoomExt: cloneStringMap(snapshot.GetRoomExt()),
|
RoomExt: cloneStringMap(snapshot.GetRoomExt()),
|
||||||
Rocket: rocketStateFromProto(snapshot.GetRocket()),
|
ActiveBackground: roomBackgroundFromProto(snapshot.GetActiveBackground()),
|
||||||
Heat: snapshot.GetHeat(),
|
RoomBorder: roomDecorationFromProto(snapshot.GetRoomBorder()),
|
||||||
Version: snapshot.GetVersion(),
|
RoomNameStyle: roomDecorationFromProto(snapshot.GetRoomNameStyle()),
|
||||||
|
Rocket: rocketStateFromProto(snapshot.GetRocket()),
|
||||||
|
Heat: snapshot.GetHeat(),
|
||||||
|
Version: snapshot.GetVersion(),
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, seat := range snapshot.GetMicSeats() {
|
for _, seat := range snapshot.GetMicSeats() {
|
||||||
@ -570,6 +652,142 @@ func FromProto(snapshot *roomv1.RoomSnapshot) *RoomState {
|
|||||||
return restored
|
return restored
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func cloneRoomBackground(input *RoomBackground) *RoomBackground {
|
||||||
|
if input == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
cloned := *input
|
||||||
|
return &cloned
|
||||||
|
}
|
||||||
|
|
||||||
|
func roomMediaToProto(media RoomMedia) *roomv1.RoomMedia {
|
||||||
|
return &roomv1.RoomMedia{
|
||||||
|
Url: media.URL,
|
||||||
|
ObjectKey: media.ObjectKey,
|
||||||
|
ContentType: media.ContentType,
|
||||||
|
Format: media.Format,
|
||||||
|
SizeBytes: media.SizeBytes,
|
||||||
|
Width: media.Width,
|
||||||
|
Height: media.Height,
|
||||||
|
Animated: media.Animated,
|
||||||
|
FrameCount: media.FrameCount,
|
||||||
|
DurationMs: media.DurationMS,
|
||||||
|
PosterUrl: media.PosterURL,
|
||||||
|
ThumbnailUrl: media.ThumbnailURL,
|
||||||
|
Sha256: media.SHA256,
|
||||||
|
Status: media.Status,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func roomMediaFromProto(media *roomv1.RoomMedia) RoomMedia {
|
||||||
|
if media == nil {
|
||||||
|
return RoomMedia{}
|
||||||
|
}
|
||||||
|
return RoomMedia{
|
||||||
|
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 roomBackgroundToProto(background *RoomBackground) *roomv1.RoomBackgroundImage {
|
||||||
|
if background == nil || background.BackgroundID <= 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &roomv1.RoomBackgroundImage{
|
||||||
|
BackgroundId: background.BackgroundID,
|
||||||
|
RoomId: background.RoomID,
|
||||||
|
ImageUrl: background.Media.URL,
|
||||||
|
CreatedByUserId: background.CreatedByUserID,
|
||||||
|
CreatedAtMs: background.CreatedAtMS,
|
||||||
|
UpdatedAtMs: background.UpdatedAtMS,
|
||||||
|
Media: roomMediaToProto(background.Media),
|
||||||
|
CommandId: background.CommandID,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func roomBackgroundFromProto(background *roomv1.RoomBackgroundImage) *RoomBackground {
|
||||||
|
if background == nil || background.GetBackgroundId() <= 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
media := roomMediaFromProto(background.GetMedia())
|
||||||
|
if media.URL == "" {
|
||||||
|
// 旧快照只有 image_url;恢复时保留兼容 URL,但不会把缺少完整媒体事实的旧素材用于新的保存入口。
|
||||||
|
media.URL = background.GetImageUrl()
|
||||||
|
}
|
||||||
|
return &RoomBackground{
|
||||||
|
BackgroundID: background.GetBackgroundId(),
|
||||||
|
RoomID: background.GetRoomId(),
|
||||||
|
CreatedByUserID: background.GetCreatedByUserId(),
|
||||||
|
CreatedAtMS: background.GetCreatedAtMs(),
|
||||||
|
UpdatedAtMS: background.GetUpdatedAtMs(),
|
||||||
|
CommandID: background.GetCommandId(),
|
||||||
|
Media: media,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func roomDecorationToProto(decoration *RoomDecoration) *roomv1.RoomDecorationResource {
|
||||||
|
return roomDecorationToProtoAt(decoration, time.Now().UTC().UnixMilli())
|
||||||
|
}
|
||||||
|
|
||||||
|
func roomDecorationToProtoAt(decoration *RoomDecoration, nowMS int64) *roomv1.RoomDecorationResource {
|
||||||
|
if decoration == nil || decoration.ResourceID <= 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if decoration.ExpiresAtMS > 0 && nowMS >= decoration.ExpiresAtMS {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &roomv1.RoomDecorationResource{
|
||||||
|
ResourceId: decoration.ResourceID,
|
||||||
|
ResourceCode: decoration.ResourceCode,
|
||||||
|
ResourceType: decoration.ResourceType,
|
||||||
|
Name: decoration.Name,
|
||||||
|
AssetUrl: decoration.AssetURL,
|
||||||
|
PreviewUrl: decoration.PreviewURL,
|
||||||
|
AnimationUrl: decoration.AnimationURL,
|
||||||
|
Format: decoration.Format,
|
||||||
|
MetadataJson: decoration.MetadataJSON,
|
||||||
|
EntitlementId: decoration.EntitlementID,
|
||||||
|
TextColors: append([]string(nil), decoration.TextColors...),
|
||||||
|
Stops: append([]float64(nil), decoration.Stops...),
|
||||||
|
AngleDegrees: decoration.AngleDegrees,
|
||||||
|
ExpiresAtMs: decoration.ExpiresAtMS,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func roomDecorationFromProto(decoration *roomv1.RoomDecorationResource) *RoomDecoration {
|
||||||
|
if decoration == nil || decoration.GetResourceId() <= 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &RoomDecoration{
|
||||||
|
ResourceID: decoration.GetResourceId(),
|
||||||
|
ResourceCode: decoration.GetResourceCode(),
|
||||||
|
ResourceType: decoration.GetResourceType(),
|
||||||
|
Name: decoration.GetName(),
|
||||||
|
AssetURL: decoration.GetAssetUrl(),
|
||||||
|
PreviewURL: decoration.GetPreviewUrl(),
|
||||||
|
AnimationURL: decoration.GetAnimationUrl(),
|
||||||
|
Format: decoration.GetFormat(),
|
||||||
|
MetadataJSON: decoration.GetMetadataJson(),
|
||||||
|
EntitlementID: decoration.GetEntitlementId(),
|
||||||
|
TextColors: append([]string(nil), decoration.GetTextColors()...),
|
||||||
|
Stops: append([]float64(nil), decoration.GetStops()...),
|
||||||
|
AngleDegrees: decoration.GetAngleDegrees(),
|
||||||
|
ExpiresAtMS: decoration.GetExpiresAtMs(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func sortedSetIDs(values map[int64]bool) []int64 {
|
func sortedSetIDs(values map[int64]bool) []int64 {
|
||||||
// map 转数组时只导出 value=true 的成员,false 等价于不存在。
|
// map 转数组时只导出 value=true 的成员,false 等价于不存在。
|
||||||
ids := make([]int64, 0, len(values))
|
ids := make([]int64, 0, len(values))
|
||||||
|
|||||||
@ -133,6 +133,45 @@ func (r *Repository) SaveMutation(ctx context.Context, commit roomservice.Mutati
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if change := commit.RoomDecorationAssignment; change != nil {
|
||||||
|
decorationType := strings.TrimSpace(change.DecorationType)
|
||||||
|
if decorationType == "" {
|
||||||
|
_ = tx.Rollback()
|
||||||
|
return errors.New("room decoration assignment type is required")
|
||||||
|
}
|
||||||
|
if change.Assignment == nil {
|
||||||
|
statement := `DELETE FROM room_decoration_assignments WHERE app_code = ? AND room_id = ? AND decoration_type = ?`
|
||||||
|
args := []any{appCode, commit.Command.RoomID, decorationType}
|
||||||
|
if change.ExpectedResourceID > 0 {
|
||||||
|
// wallet 事件可能和房主切换新资源并发;只允许旧事件删除自己查询到的 assignment。
|
||||||
|
statement += ` AND resource_id = ?`
|
||||||
|
args = append(args, change.ExpectedResourceID)
|
||||||
|
}
|
||||||
|
if _, err := tx.ExecContext(ctx, statement, args...); err != nil {
|
||||||
|
_ = tx.Rollback()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
assignment := change.Assignment
|
||||||
|
if _, err := tx.ExecContext(ctx, `
|
||||||
|
INSERT INTO room_decoration_assignments (
|
||||||
|
app_code, room_id, decoration_type, owner_user_id, benefit_code,
|
||||||
|
resource_id, entitlement_id, expires_at_ms, updated_at_ms
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
ON DUPLICATE KEY UPDATE
|
||||||
|
owner_user_id = VALUES(owner_user_id), benefit_code = VALUES(benefit_code),
|
||||||
|
resource_id = VALUES(resource_id), entitlement_id = VALUES(entitlement_id),
|
||||||
|
expires_at_ms = VALUES(expires_at_ms), updated_at_ms = VALUES(updated_at_ms)`,
|
||||||
|
appCode, commit.Command.RoomID, decorationType, assignment.OwnerUserID,
|
||||||
|
strings.TrimSpace(assignment.BenefitCode), assignment.ResourceID,
|
||||||
|
strings.TrimSpace(assignment.EntitlementID), assignment.ExpiresAtMS, assignment.UpdatedAtMS,
|
||||||
|
); err != nil {
|
||||||
|
_ = tx.Rollback()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if commit.DeleteRoom {
|
if commit.DeleteRoom {
|
||||||
if err := r.deleteRoomRows(ctx, tx, appCode, commit.Command.RoomID); err != nil {
|
if err := r.deleteRoomRows(ctx, tx, appCode, commit.Command.RoomID); err != nil {
|
||||||
_ = tx.Rollback()
|
_ = tx.Rollback()
|
||||||
@ -429,6 +468,8 @@ func (r *Repository) deleteRoomRows(ctx context.Context, tx *sql.Tx, appCode str
|
|||||||
`DELETE FROM room_user_gift_stats WHERE app_code = ? AND room_id = ?`,
|
`DELETE FROM room_user_gift_stats WHERE app_code = ? AND room_id = ?`,
|
||||||
`DELETE FROM room_user_contribution_period_stats WHERE app_code = ? AND room_id = ?`,
|
`DELETE FROM room_user_contribution_period_stats WHERE app_code = ? AND room_id = ?`,
|
||||||
`DELETE FROM room_background_images WHERE app_code = ? AND room_id = ?`,
|
`DELETE FROM room_background_images WHERE app_code = ? AND room_id = ?`,
|
||||||
|
`DELETE FROM room_media_upload_registrations WHERE app_code = ? AND room_id = ?`,
|
||||||
|
`DELETE FROM room_decoration_assignments WHERE app_code = ? AND room_id = ?`,
|
||||||
`DELETE FROM room_snapshots WHERE app_code = ? AND room_id = ?`,
|
`DELETE FROM room_snapshots WHERE app_code = ? AND room_id = ?`,
|
||||||
`DELETE FROM room_gift_operations WHERE app_code = ? AND room_id = ?`,
|
`DELETE FROM room_gift_operations WHERE app_code = ? AND room_id = ?`,
|
||||||
`DELETE FROM room_command_log WHERE app_code = ? AND room_id = ?`,
|
`DELETE FROM room_command_log WHERE app_code = ? AND room_id = ?`,
|
||||||
|
|||||||
@ -168,6 +168,57 @@ func (r *Repository) GetCurrentRoomPresence(ctx context.Context, userID int64) (
|
|||||||
return presence, true, nil
|
return presence, true, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// BatchGetCurrentRoomPresences 按 (app_code, user_id) 主键批量读取用户当前房间索引。
|
||||||
|
// 单批最多由 service 层传入 100 个 ID;IN 条件仍命中主键前缀,不会扫描 active presence 全表。
|
||||||
|
func (r *Repository) BatchGetCurrentRoomPresences(ctx context.Context, userIDs []int64) (map[int64]roomservice.RoomPresence, error) {
|
||||||
|
if len(userIDs) == 0 {
|
||||||
|
return map[int64]roomservice.RoomPresence{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
placeholders := strings.TrimSuffix(strings.Repeat("?,", len(userIDs)), ",")
|
||||||
|
args := make([]any, 0, len(userIDs)+2)
|
||||||
|
args = append(args, appcode.FromContext(ctx), roomPresenceStatusActiveSQL)
|
||||||
|
for _, userID := range userIDs {
|
||||||
|
args = append(args, userID)
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, err := r.db.QueryContext(ctx,
|
||||||
|
`SELECT app_code, user_id, room_id, role, publish_state, mic_session_id, room_version, status, joined_at_ms, last_seen_at_ms, updated_at_ms
|
||||||
|
FROM room_user_presence
|
||||||
|
WHERE app_code = ? AND status = ? AND user_id IN (`+placeholders+`)`,
|
||||||
|
args...,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
result := make(map[int64]roomservice.RoomPresence, len(userIDs))
|
||||||
|
for rows.Next() {
|
||||||
|
var presence roomservice.RoomPresence
|
||||||
|
if err := rows.Scan(
|
||||||
|
&presence.AppCode,
|
||||||
|
&presence.UserID,
|
||||||
|
&presence.RoomID,
|
||||||
|
&presence.Role,
|
||||||
|
&presence.PublishState,
|
||||||
|
&presence.MicSessionID,
|
||||||
|
&presence.RoomVersion,
|
||||||
|
&presence.Status,
|
||||||
|
&presence.JoinedAtMS,
|
||||||
|
&presence.LastSeenAtMS,
|
||||||
|
&presence.UpdatedAtMS,
|
||||||
|
); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
result[presence.UserID] = presence
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
// ListRoomOnlineUsers 分页查询某个房间当前 active presence。
|
// ListRoomOnlineUsers 分页查询某个房间当前 active presence。
|
||||||
func (r *Repository) ListRoomOnlineUsers(ctx context.Context, query roomservice.RoomOnlineUserQuery) (roomservice.RoomOnlineUserPage, error) {
|
func (r *Repository) ListRoomOnlineUsers(ctx context.Context, query roomservice.RoomOnlineUserQuery) (roomservice.RoomOnlineUserPage, error) {
|
||||||
page := query.Page
|
page := query.Page
|
||||||
|
|||||||
@ -0,0 +1,79 @@
|
|||||||
|
package mysql
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"hyapp/pkg/appcode"
|
||||||
|
roomservice "hyapp/services/room-service/internal/room/service"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ListRoomDecorationAssignments 只扫描装扮专用反向索引,并使用稳定 room_id/type 游标分页。
|
||||||
|
// resource/user 事件分别命中对应复合索引;VipProgramChanged 才会按主键遍历该低频小表。
|
||||||
|
func (r *Repository) ListRoomDecorationAssignments(ctx context.Context, query roomservice.RoomDecorationAssignmentQuery) ([]roomservice.RoomDecorationAssignment, error) {
|
||||||
|
limit := query.Limit
|
||||||
|
if limit <= 0 || limit > 500 {
|
||||||
|
limit = 100
|
||||||
|
}
|
||||||
|
args := []any{appcode.FromContext(ctx)}
|
||||||
|
statement := `SELECT app_code, room_id, decoration_type, owner_user_id, benefit_code,
|
||||||
|
resource_id, entitlement_id, expires_at_ms, updated_at_ms
|
||||||
|
FROM room_decoration_assignments WHERE app_code = ?`
|
||||||
|
switch {
|
||||||
|
case query.ResourceID > 0:
|
||||||
|
statement += ` AND resource_id = ?`
|
||||||
|
args = append(args, query.ResourceID)
|
||||||
|
case query.OwnerUserID > 0:
|
||||||
|
statement += ` AND owner_user_id = ?`
|
||||||
|
args = append(args, query.OwnerUserID)
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(query.AfterRoomID) != "" {
|
||||||
|
statement += ` AND (room_id > ? OR (room_id = ? AND decoration_type > ?))`
|
||||||
|
args = append(args, strings.TrimSpace(query.AfterRoomID), strings.TrimSpace(query.AfterRoomID), strings.TrimSpace(query.AfterDecorationType))
|
||||||
|
}
|
||||||
|
statement += ` ORDER BY room_id ASC, decoration_type ASC LIMIT ?`
|
||||||
|
args = append(args, limit)
|
||||||
|
rows, err := r.db.QueryContext(ctx, statement, args...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
assignments := make([]roomservice.RoomDecorationAssignment, 0, limit)
|
||||||
|
for rows.Next() {
|
||||||
|
var assignment roomservice.RoomDecorationAssignment
|
||||||
|
if err := rows.Scan(
|
||||||
|
&assignment.AppCode, &assignment.RoomID, &assignment.DecorationType, &assignment.OwnerUserID,
|
||||||
|
&assignment.BenefitCode, &assignment.ResourceID, &assignment.EntitlementID,
|
||||||
|
&assignment.ExpiresAtMS, &assignment.UpdatedAtMS,
|
||||||
|
); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
assignments = append(assignments, assignment)
|
||||||
|
}
|
||||||
|
return assignments, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Repository) HasConsumedWalletDecorationEvent(ctx context.Context, eventID string) (bool, error) {
|
||||||
|
var value int
|
||||||
|
err := r.db.QueryRowContext(ctx, `
|
||||||
|
SELECT 1 FROM room_wallet_event_consumption
|
||||||
|
WHERE app_code = ? AND event_id = ? LIMIT 1`,
|
||||||
|
appcode.FromContext(ctx), strings.TrimSpace(eventID),
|
||||||
|
).Scan(&value)
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
return err == nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// MarkWalletDecorationEventConsumed 只在该事件所有目标房间均已幂等提交后调用。
|
||||||
|
func (r *Repository) MarkWalletDecorationEventConsumed(ctx context.Context, eventID string, eventType string, consumedAtMS int64) error {
|
||||||
|
_, err := r.db.ExecContext(ctx, `
|
||||||
|
INSERT INTO room_wallet_event_consumption (app_code, event_id, event_type, consumed_at_ms)
|
||||||
|
VALUES (?, ?, ?, ?)
|
||||||
|
ON DUPLICATE KEY UPDATE event_id = room_wallet_event_consumption.event_id`,
|
||||||
|
appcode.FromContext(ctx), strings.TrimSpace(eventID), strings.TrimSpace(eventType), consumedAtMS)
|
||||||
|
return err
|
||||||
|
}
|
||||||
@ -20,8 +20,8 @@ func (r *Repository) UpsertRoomListEntry(ctx context.Context, entry roomservice.
|
|||||||
_, err := r.db.ExecContext(ctx,
|
_, err := r.db.ExecContext(ctx,
|
||||||
`INSERT INTO room_list_entries (
|
`INSERT INTO room_list_entries (
|
||||||
app_code, room_id, room_short_id, visible_region_id, owner_country_code, owner_user_id, title, cover_url, mode, status, locked,
|
app_code, room_id, room_short_id, visible_region_id, owner_country_code, owner_user_id, title, cover_url, mode, status, locked,
|
||||||
heat, online_count, seat_count, occupied_seat_count, sort_score, created_at_ms, updated_at_ms
|
heat, online_count, seat_count, occupied_seat_count, sort_score, room_border_json, room_name_style_json, created_at_ms, updated_at_ms
|
||||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULLIF(?, ''), NULLIF(?, ''), ?, ?)
|
||||||
ON DUPLICATE KEY UPDATE
|
ON DUPLICATE KEY UPDATE
|
||||||
room_short_id = VALUES(room_short_id),
|
room_short_id = VALUES(room_short_id),
|
||||||
visible_region_id = VALUES(visible_region_id),
|
visible_region_id = VALUES(visible_region_id),
|
||||||
@ -37,6 +37,8 @@ func (r *Repository) UpsertRoomListEntry(ctx context.Context, entry roomservice.
|
|||||||
seat_count = VALUES(seat_count),
|
seat_count = VALUES(seat_count),
|
||||||
occupied_seat_count = VALUES(occupied_seat_count),
|
occupied_seat_count = VALUES(occupied_seat_count),
|
||||||
sort_score = VALUES(sort_score),
|
sort_score = VALUES(sort_score),
|
||||||
|
room_border_json = VALUES(room_border_json),
|
||||||
|
room_name_style_json = VALUES(room_name_style_json),
|
||||||
updated_at_ms = VALUES(updated_at_ms)`,
|
updated_at_ms = VALUES(updated_at_ms)`,
|
||||||
appCode,
|
appCode,
|
||||||
entry.RoomID,
|
entry.RoomID,
|
||||||
@ -54,6 +56,8 @@ func (r *Repository) UpsertRoomListEntry(ctx context.Context, entry roomservice.
|
|||||||
entry.SeatCount,
|
entry.SeatCount,
|
||||||
entry.OccupiedSeatCount,
|
entry.OccupiedSeatCount,
|
||||||
entry.SortScore,
|
entry.SortScore,
|
||||||
|
entry.RoomBorderJSON,
|
||||||
|
entry.RoomNameStyleJSON,
|
||||||
entry.CreatedAtMS,
|
entry.CreatedAtMS,
|
||||||
entry.UpdatedAtMS,
|
entry.UpdatedAtMS,
|
||||||
)
|
)
|
||||||
@ -80,32 +84,8 @@ func (r *Repository) ListRoomListEntries(ctx context.Context, query roomservice.
|
|||||||
|
|
||||||
entries := make([]roomservice.RoomListEntry, 0, query.Limit)
|
entries := make([]roomservice.RoomListEntry, 0, query.Limit)
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var entry roomservice.RoomListEntry
|
entry, pinned, err := scanRoomListEntryWithPins(rows)
|
||||||
var pinned int64
|
if err != nil {
|
||||||
if err := rows.Scan(
|
|
||||||
&entry.AppCode,
|
|
||||||
&entry.RoomID,
|
|
||||||
&entry.RoomShortID,
|
|
||||||
&entry.VisibleRegionID,
|
|
||||||
&entry.OwnerCountryCode,
|
|
||||||
&entry.OwnerUserID,
|
|
||||||
&entry.Title,
|
|
||||||
&entry.CoverURL,
|
|
||||||
&entry.Mode,
|
|
||||||
&entry.Status,
|
|
||||||
&entry.Locked,
|
|
||||||
&entry.Heat,
|
|
||||||
&entry.OnlineCount,
|
|
||||||
&entry.SeatCount,
|
|
||||||
&entry.OccupiedSeatCount,
|
|
||||||
&entry.SortScore,
|
|
||||||
&entry.CreatedAtMS,
|
|
||||||
&entry.UpdatedAtMS,
|
|
||||||
&pinned,
|
|
||||||
&entry.PinListRank,
|
|
||||||
&entry.PinWeight,
|
|
||||||
&entry.PinnedUntilMS,
|
|
||||||
); err != nil {
|
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -136,7 +116,8 @@ func (r *Repository) ListRoomListEntriesByIDs(ctx context.Context, query roomser
|
|||||||
}
|
}
|
||||||
rows, err := r.db.QueryContext(ctx, `
|
rows, err := r.db.QueryContext(ctx, `
|
||||||
SELECT app_code, room_id, room_short_id, visible_region_id, owner_country_code, owner_user_id, title, cover_url, mode, status, locked,
|
SELECT app_code, room_id, room_short_id, visible_region_id, owner_country_code, owner_user_id, title, cover_url, mode, status, locked,
|
||||||
heat, online_count, seat_count, occupied_seat_count, sort_score, created_at_ms, updated_at_ms
|
heat, online_count, seat_count, occupied_seat_count, sort_score, created_at_ms, updated_at_ms,
|
||||||
|
COALESCE(CAST(room_border_json AS CHAR), ''), COALESCE(CAST(room_name_style_json AS CHAR), '')
|
||||||
FROM room_list_entries
|
FROM room_list_entries
|
||||||
WHERE app_code = ? AND room_id IN (`+placeholders+`)`, args...)
|
WHERE app_code = ? AND room_id IN (`+placeholders+`)`, args...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -145,27 +126,8 @@ func (r *Repository) ListRoomListEntriesByIDs(ctx context.Context, query roomser
|
|||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
|
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var entry roomservice.RoomListEntry
|
entry, err := scanRoomListEntry(rows)
|
||||||
if err := rows.Scan(
|
if err != nil {
|
||||||
&entry.AppCode,
|
|
||||||
&entry.RoomID,
|
|
||||||
&entry.RoomShortID,
|
|
||||||
&entry.VisibleRegionID,
|
|
||||||
&entry.OwnerCountryCode,
|
|
||||||
&entry.OwnerUserID,
|
|
||||||
&entry.Title,
|
|
||||||
&entry.CoverURL,
|
|
||||||
&entry.Mode,
|
|
||||||
&entry.Status,
|
|
||||||
&entry.Locked,
|
|
||||||
&entry.Heat,
|
|
||||||
&entry.OnlineCount,
|
|
||||||
&entry.SeatCount,
|
|
||||||
&entry.OccupiedSeatCount,
|
|
||||||
&entry.SortScore,
|
|
||||||
&entry.CreatedAtMS,
|
|
||||||
&entry.UpdatedAtMS,
|
|
||||||
); err != nil {
|
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
result[entry.RoomID] = entry
|
result[entry.RoomID] = entry
|
||||||
@ -251,7 +213,7 @@ func (r *Repository) ListRoomListCachePins(ctx context.Context, query roomservic
|
|||||||
|
|
||||||
func scanRoomListEntry(scanner interface{ Scan(dest ...any) error }) (roomservice.RoomListEntry, error) {
|
func scanRoomListEntry(scanner interface{ Scan(dest ...any) error }) (roomservice.RoomListEntry, error) {
|
||||||
var entry roomservice.RoomListEntry
|
var entry roomservice.RoomListEntry
|
||||||
err := scanner.Scan(
|
dest := []any{
|
||||||
&entry.AppCode,
|
&entry.AppCode,
|
||||||
&entry.RoomID,
|
&entry.RoomID,
|
||||||
&entry.RoomShortID,
|
&entry.RoomShortID,
|
||||||
@ -270,10 +232,43 @@ func scanRoomListEntry(scanner interface{ Scan(dest ...any) error }) (roomservic
|
|||||||
&entry.SortScore,
|
&entry.SortScore,
|
||||||
&entry.CreatedAtMS,
|
&entry.CreatedAtMS,
|
||||||
&entry.UpdatedAtMS,
|
&entry.UpdatedAtMS,
|
||||||
)
|
}
|
||||||
|
if scannerColumnCount(scanner) >= len(dest)+2 {
|
||||||
|
dest = append(dest, &entry.RoomBorderJSON, &entry.RoomNameStyleJSON)
|
||||||
|
}
|
||||||
|
err := scanner.Scan(dest...)
|
||||||
return entry, err
|
return entry, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func scanRoomListEntryWithPins(scanner interface{ Scan(dest ...any) error }) (roomservice.RoomListEntry, int64, error) {
|
||||||
|
var entry roomservice.RoomListEntry
|
||||||
|
var pinned int64
|
||||||
|
dest := []any{
|
||||||
|
&entry.AppCode, &entry.RoomID, &entry.RoomShortID, &entry.VisibleRegionID, &entry.OwnerCountryCode,
|
||||||
|
&entry.OwnerUserID, &entry.Title, &entry.CoverURL, &entry.Mode, &entry.Status, &entry.Locked,
|
||||||
|
&entry.Heat, &entry.OnlineCount, &entry.SeatCount, &entry.OccupiedSeatCount, &entry.SortScore,
|
||||||
|
&entry.CreatedAtMS, &entry.UpdatedAtMS,
|
||||||
|
}
|
||||||
|
if scannerColumnCount(scanner) >= len(dest)+2+4 {
|
||||||
|
dest = append(dest, &entry.RoomBorderJSON, &entry.RoomNameStyleJSON)
|
||||||
|
}
|
||||||
|
dest = append(dest, &pinned, &entry.PinListRank, &entry.PinWeight, &entry.PinnedUntilMS)
|
||||||
|
err := scanner.Scan(dest...)
|
||||||
|
return entry, pinned, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func scannerColumnCount(scanner any) int {
|
||||||
|
withColumns, ok := scanner.(interface{ Columns() ([]string, error) })
|
||||||
|
if !ok {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
columns, err := withColumns.Columns()
|
||||||
|
if err != nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return len(columns)
|
||||||
|
}
|
||||||
|
|
||||||
func uniqueRoomListIDs(values []string) []string {
|
func uniqueRoomListIDs(values []string) []string {
|
||||||
seen := make(map[string]struct{}, len(values))
|
seen := make(map[string]struct{}, len(values))
|
||||||
out := make([]string, 0, len(values))
|
out := make([]string, 0, len(values))
|
||||||
@ -326,27 +321,8 @@ func (r *Repository) ListRoomUserFeedEntries(ctx context.Context, query roomserv
|
|||||||
|
|
||||||
entries := make([]roomservice.RoomListEntry, 0, query.Limit)
|
entries := make([]roomservice.RoomListEntry, 0, query.Limit)
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var entry roomservice.RoomListEntry
|
entry, err := scanRoomListEntry(rows)
|
||||||
if err := rows.Scan(
|
if err != nil {
|
||||||
&entry.AppCode,
|
|
||||||
&entry.RoomID,
|
|
||||||
&entry.RoomShortID,
|
|
||||||
&entry.VisibleRegionID,
|
|
||||||
&entry.OwnerCountryCode,
|
|
||||||
&entry.OwnerUserID,
|
|
||||||
&entry.Title,
|
|
||||||
&entry.CoverURL,
|
|
||||||
&entry.Mode,
|
|
||||||
&entry.Status,
|
|
||||||
&entry.Locked,
|
|
||||||
&entry.Heat,
|
|
||||||
&entry.OnlineCount,
|
|
||||||
&entry.SeatCount,
|
|
||||||
&entry.OccupiedSeatCount,
|
|
||||||
&entry.SortScore,
|
|
||||||
&entry.CreatedAtMS,
|
|
||||||
&entry.UpdatedAtMS,
|
|
||||||
); err != nil {
|
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -452,27 +428,8 @@ func (r *Repository) ListRoomFollowEntries(ctx context.Context, query roomservic
|
|||||||
|
|
||||||
entries := make([]roomservice.RoomListEntry, 0, query.Limit)
|
entries := make([]roomservice.RoomListEntry, 0, query.Limit)
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var entry roomservice.RoomListEntry
|
entry, err := scanRoomListEntry(rows)
|
||||||
if err := rows.Scan(
|
if err != nil {
|
||||||
&entry.AppCode,
|
|
||||||
&entry.RoomID,
|
|
||||||
&entry.RoomShortID,
|
|
||||||
&entry.VisibleRegionID,
|
|
||||||
&entry.OwnerCountryCode,
|
|
||||||
&entry.OwnerUserID,
|
|
||||||
&entry.Title,
|
|
||||||
&entry.CoverURL,
|
|
||||||
&entry.Mode,
|
|
||||||
&entry.Status,
|
|
||||||
&entry.Locked,
|
|
||||||
&entry.Heat,
|
|
||||||
&entry.OnlineCount,
|
|
||||||
&entry.SeatCount,
|
|
||||||
&entry.OccupiedSeatCount,
|
|
||||||
&entry.SortScore,
|
|
||||||
&entry.CreatedAtMS,
|
|
||||||
&entry.UpdatedAtMS,
|
|
||||||
); err != nil {
|
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -505,27 +462,8 @@ func (r *Repository) ListRoomRelatedFeedEntries(ctx context.Context, query rooms
|
|||||||
|
|
||||||
entries := make([]roomservice.RoomListEntry, 0, query.Limit)
|
entries := make([]roomservice.RoomListEntry, 0, query.Limit)
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var entry roomservice.RoomListEntry
|
entry, err := scanRoomListEntry(rows)
|
||||||
if err := rows.Scan(
|
if err != nil {
|
||||||
&entry.AppCode,
|
|
||||||
&entry.RoomID,
|
|
||||||
&entry.RoomShortID,
|
|
||||||
&entry.VisibleRegionID,
|
|
||||||
&entry.OwnerCountryCode,
|
|
||||||
&entry.OwnerUserID,
|
|
||||||
&entry.Title,
|
|
||||||
&entry.CoverURL,
|
|
||||||
&entry.Mode,
|
|
||||||
&entry.Status,
|
|
||||||
&entry.Locked,
|
|
||||||
&entry.Heat,
|
|
||||||
&entry.OnlineCount,
|
|
||||||
&entry.SeatCount,
|
|
||||||
&entry.OccupiedSeatCount,
|
|
||||||
&entry.SortScore,
|
|
||||||
&entry.CreatedAtMS,
|
|
||||||
&entry.UpdatedAtMS,
|
|
||||||
); err != nil {
|
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -556,25 +494,29 @@ func (r *Repository) ListRoomRelatedFeedEntries(ctx context.Context, query rooms
|
|||||||
|
|
||||||
const roomListSelectColumns = `
|
const roomListSelectColumns = `
|
||||||
SELECT app_code, room_id, room_short_id, visible_region_id, owner_country_code, owner_user_id, title, cover_url, mode, status, locked,
|
SELECT app_code, room_id, room_short_id, visible_region_id, owner_country_code, owner_user_id, title, cover_url, mode, status, locked,
|
||||||
heat, online_count, seat_count, occupied_seat_count, sort_score, created_at_ms, updated_at_ms
|
heat, online_count, seat_count, occupied_seat_count, sort_score, created_at_ms, updated_at_ms,
|
||||||
|
COALESCE(CAST(room_border_json AS CHAR), ''), COALESCE(CAST(room_name_style_json AS CHAR), '')
|
||||||
FROM room_list_entries`
|
FROM room_list_entries`
|
||||||
|
|
||||||
// owner 定向列表复用公共列表扫描器,但不参与公共发现页置顶排序;这里补零值列保持 scan 契约稳定。
|
// owner 定向列表复用公共列表扫描器,但不参与公共发现页置顶排序;这里补零值列保持 scan 契约稳定。
|
||||||
const roomListOwnerSelectColumns = `
|
const roomListOwnerSelectColumns = `
|
||||||
SELECT app_code, room_id, room_short_id, visible_region_id, owner_country_code, owner_user_id, title, cover_url, mode, status, locked,
|
SELECT app_code, room_id, room_short_id, visible_region_id, owner_country_code, owner_user_id, title, cover_url, mode, status, locked,
|
||||||
heat, online_count, seat_count, occupied_seat_count, sort_score, created_at_ms, updated_at_ms,
|
heat, online_count, seat_count, occupied_seat_count, sort_score, created_at_ms, updated_at_ms,
|
||||||
|
COALESCE(CAST(room_border_json AS CHAR), ''), COALESCE(CAST(room_name_style_json AS CHAR), ''),
|
||||||
0 AS is_pinned, 0 AS pin_list_rank, 0 AS pin_weight, 0 AS pinned_until_ms
|
0 AS is_pinned, 0 AS pin_list_rank, 0 AS pin_weight, 0 AS pinned_until_ms
|
||||||
FROM room_list_entries`
|
FROM room_list_entries`
|
||||||
|
|
||||||
const roomUserFeedSelectColumns = `
|
const roomUserFeedSelectColumns = `
|
||||||
SELECT r.app_code, r.room_id, r.room_short_id, r.visible_region_id, r.owner_country_code, r.owner_user_id, r.title, r.cover_url, r.mode, r.status, r.locked,
|
SELECT r.app_code, r.room_id, r.room_short_id, r.visible_region_id, r.owner_country_code, r.owner_user_id, r.title, r.cover_url, r.mode, r.status, r.locked,
|
||||||
r.heat, r.online_count, r.seat_count, r.occupied_seat_count, r.sort_score, r.created_at_ms, f.updated_at_ms
|
r.heat, r.online_count, r.seat_count, r.occupied_seat_count, r.sort_score, r.created_at_ms, f.updated_at_ms,
|
||||||
|
COALESCE(CAST(r.room_border_json AS CHAR), ''), COALESCE(CAST(r.room_name_style_json AS CHAR), '')
|
||||||
FROM room_user_feed_entries f
|
FROM room_user_feed_entries f
|
||||||
JOIN room_list_entries r ON r.app_code = f.app_code AND r.room_id = f.room_id`
|
JOIN room_list_entries r ON r.app_code = f.app_code AND r.room_id = f.room_id`
|
||||||
|
|
||||||
const roomFollowSelectColumns = `
|
const roomFollowSelectColumns = `
|
||||||
SELECT r.app_code, r.room_id, r.room_short_id, r.visible_region_id, r.owner_country_code, r.owner_user_id, r.title, r.cover_url, r.mode, r.status, r.locked,
|
SELECT r.app_code, r.room_id, r.room_short_id, r.visible_region_id, r.owner_country_code, r.owner_user_id, r.title, r.cover_url, r.mode, r.status, r.locked,
|
||||||
r.heat, r.online_count, r.seat_count, r.occupied_seat_count, r.sort_score, r.created_at_ms, f.followed_at_ms
|
r.heat, r.online_count, r.seat_count, r.occupied_seat_count, r.sort_score, r.created_at_ms, f.followed_at_ms,
|
||||||
|
COALESCE(CAST(r.room_border_json AS CHAR), ''), COALESCE(CAST(r.room_name_style_json AS CHAR), '')
|
||||||
FROM room_follows f
|
FROM room_follows f
|
||||||
JOIN room_list_entries r ON r.app_code = f.app_code AND r.room_id = f.room_id`
|
JOIN room_list_entries r ON r.app_code = f.app_code AND r.room_id = f.room_id`
|
||||||
|
|
||||||
@ -616,6 +558,7 @@ func buildRoomListQuerySQL(query roomservice.RoomListQuery) (string, []any) {
|
|||||||
selectSQL := `
|
selectSQL := `
|
||||||
SELECT r.app_code, r.room_id, r.room_short_id, r.visible_region_id, r.owner_country_code, r.owner_user_id, r.title, r.cover_url, r.mode, r.status, r.locked,
|
SELECT r.app_code, r.room_id, r.room_short_id, r.visible_region_id, r.owner_country_code, r.owner_user_id, r.title, r.cover_url, r.mode, r.status, r.locked,
|
||||||
r.heat, r.online_count, r.seat_count, r.occupied_seat_count, r.sort_score, r.created_at_ms, r.updated_at_ms,
|
r.heat, r.online_count, r.seat_count, r.occupied_seat_count, r.sort_score, r.created_at_ms, r.updated_at_ms,
|
||||||
|
COALESCE(CAST(r.room_border_json AS CHAR), ''), COALESCE(CAST(r.room_name_style_json AS CHAR), ''),
|
||||||
` + pinnedExpr + ` AS is_pinned, ` + pinRankExpr + ` AS pin_list_rank, ` + pinWeightExpr + ` AS pin_weight, ` + pinExpiresExpr + ` AS pinned_until_ms
|
` + pinnedExpr + ` AS is_pinned, ` + pinRankExpr + ` AS pin_list_rank, ` + pinWeightExpr + ` AS pin_weight, ` + pinExpiresExpr + ` AS pinned_until_ms
|
||||||
FROM room_list_entries r
|
FROM room_list_entries r
|
||||||
LEFT JOIN room_region_pins gp
|
LEFT JOIN room_region_pins gp
|
||||||
|
|||||||
@ -0,0 +1,168 @@
|
|||||||
|
package mysql
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"hyapp/pkg/appcode"
|
||||||
|
roomservice "hyapp/services/room-service/internal/room/service"
|
||||||
|
)
|
||||||
|
|
||||||
|
// GetRoomMediaUploadRegistration 按上传 command_id 的主键读取首次授权事实。
|
||||||
|
// 该查询只命中 PRIMARY KEY,不扫描媒体表,可安全放在每次专用上传的权限检查之前。
|
||||||
|
func (r *Repository) GetRoomMediaUploadRegistration(ctx context.Context, roomID string, commandID string) (roomservice.RoomMediaUploadRegistration, bool, error) {
|
||||||
|
row := r.db.QueryRowContext(ctx, `
|
||||||
|
SELECT app_code, room_id, command_id, actor_user_id, purpose, media_payload_sha256,
|
||||||
|
expected_object_key, object_url, status, content_type, media_format, size_bytes,
|
||||||
|
width, height, animated, frame_count, duration_ms, sha256,
|
||||||
|
authorized_room_version, created_at_ms, updated_at_ms
|
||||||
|
FROM room_media_upload_registrations
|
||||||
|
WHERE app_code = ? AND room_id = ? AND command_id = ?
|
||||||
|
LIMIT 1`, appcode.FromContext(ctx), strings.TrimSpace(roomID), strings.TrimSpace(commandID))
|
||||||
|
registration, err := scanRoomMediaUploadRegistration(row)
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return roomservice.RoomMediaUploadRegistration{}, false, nil
|
||||||
|
}
|
||||||
|
return registration, err == nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetActiveRoomMediaUploadByObjectKey 是 Save/Send 接受媒体事实前的权威反查。
|
||||||
|
// expected_object_key 有唯一索引,查询不会按 URL 或 JSON 字段扫描。
|
||||||
|
func (r *Repository) GetActiveRoomMediaUploadByObjectKey(ctx context.Context, objectKey string) (roomservice.RoomMediaUploadRegistration, bool, error) {
|
||||||
|
row := r.db.QueryRowContext(ctx, `
|
||||||
|
SELECT app_code, room_id, command_id, actor_user_id, purpose, media_payload_sha256,
|
||||||
|
expected_object_key, object_url, status, content_type, media_format, size_bytes,
|
||||||
|
width, height, animated, frame_count, duration_ms, sha256,
|
||||||
|
authorized_room_version, created_at_ms, updated_at_ms
|
||||||
|
FROM room_media_upload_registrations
|
||||||
|
WHERE app_code = ? AND expected_object_key = ? AND status = 'active'
|
||||||
|
LIMIT 1`, appcode.FromContext(ctx), strings.TrimLeft(strings.TrimSpace(objectKey), "/"))
|
||||||
|
registration, err := scanRoomMediaUploadRegistration(row)
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return roomservice.RoomMediaUploadRegistration{}, false, nil
|
||||||
|
}
|
||||||
|
return registration, err == nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// RegisterRoomMediaUpload 在首次授权通过后原子占用 command_id。
|
||||||
|
// 并发重复通过唯一主键收敛到首行,再逐字段比较;不同文件、用户或用途不能被 ON DUPLICATE 静默吞掉。
|
||||||
|
func (r *Repository) RegisterRoomMediaUpload(ctx context.Context, registration roomservice.RoomMediaUploadRegistration) (roomservice.RoomMediaUploadRegistration, error) {
|
||||||
|
registration.AppCode = appcode.FromContext(ctx)
|
||||||
|
registration.RoomID = strings.TrimSpace(registration.RoomID)
|
||||||
|
registration.CommandID = strings.TrimSpace(registration.CommandID)
|
||||||
|
registration.Purpose = strings.TrimSpace(registration.Purpose)
|
||||||
|
registration.MediaPayloadSHA256 = strings.ToLower(strings.TrimSpace(registration.MediaPayloadSHA256))
|
||||||
|
registration.ExpectedObjectKey = strings.TrimLeft(strings.TrimSpace(registration.ExpectedObjectKey), "/")
|
||||||
|
registration.ObjectURL = ""
|
||||||
|
registration.Status = "authorized"
|
||||||
|
if registration.CreatedAtMS <= 0 {
|
||||||
|
registration.CreatedAtMS = time.Now().UTC().UnixMilli()
|
||||||
|
}
|
||||||
|
if registration.UpdatedAtMS <= 0 {
|
||||||
|
registration.UpdatedAtMS = registration.CreatedAtMS
|
||||||
|
}
|
||||||
|
_, err := r.db.ExecContext(ctx, `
|
||||||
|
INSERT INTO room_media_upload_registrations (
|
||||||
|
app_code, room_id, command_id, actor_user_id, purpose, media_payload_sha256,
|
||||||
|
expected_object_key, object_url, status, content_type, media_format, size_bytes,
|
||||||
|
width, height, animated, frame_count, duration_ms, sha256,
|
||||||
|
authorized_room_version, created_at_ms, updated_at_ms
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, '', 'authorized', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
ON DUPLICATE KEY UPDATE command_id = room_media_upload_registrations.command_id`,
|
||||||
|
registration.AppCode, registration.RoomID, registration.CommandID, registration.ActorUserID,
|
||||||
|
registration.Purpose, registration.MediaPayloadSHA256, registration.ExpectedObjectKey,
|
||||||
|
registration.Media.ContentType, registration.Media.Format, registration.Media.SizeBytes,
|
||||||
|
registration.Media.Width, registration.Media.Height, registration.Media.Animated,
|
||||||
|
registration.Media.FrameCount, registration.Media.DurationMS, registration.Media.SHA256, registration.AuthorizedVersion,
|
||||||
|
registration.CreatedAtMS, registration.UpdatedAtMS)
|
||||||
|
if err != nil {
|
||||||
|
return roomservice.RoomMediaUploadRegistration{}, err
|
||||||
|
}
|
||||||
|
saved, exists, err := r.GetRoomMediaUploadRegistration(ctx, registration.RoomID, registration.CommandID)
|
||||||
|
if err != nil {
|
||||||
|
return roomservice.RoomMediaUploadRegistration{}, err
|
||||||
|
}
|
||||||
|
if !exists || !sameRoomMediaUploadRegistration(saved, registration) {
|
||||||
|
return roomservice.RoomMediaUploadRegistration{}, roomservice.ErrRoomMediaUploadCommandConflict
|
||||||
|
}
|
||||||
|
return saved, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CompleteRoomMediaUpload 只把首次授权的 expected_object_key 推进为 active。
|
||||||
|
// URL 和完整媒体事实必须先由 service 与授权记录逐字段匹配;并发 complete 仍以首个 URL 为准。
|
||||||
|
func (r *Repository) CompleteRoomMediaUpload(ctx context.Context, registration roomservice.RoomMediaUploadRegistration) (roomservice.RoomMediaUploadRegistration, error) {
|
||||||
|
registration.AppCode = appcode.FromContext(ctx)
|
||||||
|
registration.RoomID = strings.TrimSpace(registration.RoomID)
|
||||||
|
registration.CommandID = strings.TrimSpace(registration.CommandID)
|
||||||
|
registration.ExpectedObjectKey = strings.TrimLeft(strings.TrimSpace(registration.ExpectedObjectKey), "/")
|
||||||
|
registration.ObjectURL = strings.TrimSpace(registration.ObjectURL)
|
||||||
|
registration.Status = "active"
|
||||||
|
if registration.UpdatedAtMS <= 0 {
|
||||||
|
registration.UpdatedAtMS = time.Now().UTC().UnixMilli()
|
||||||
|
}
|
||||||
|
_, err := r.db.ExecContext(ctx, `
|
||||||
|
UPDATE room_media_upload_registrations
|
||||||
|
SET object_url = ?, status = 'active', updated_at_ms = ?
|
||||||
|
WHERE app_code = ? AND room_id = ? AND command_id = ? AND status = 'authorized'`,
|
||||||
|
registration.ObjectURL, registration.UpdatedAtMS, registration.AppCode, registration.RoomID, registration.CommandID)
|
||||||
|
if err != nil {
|
||||||
|
return roomservice.RoomMediaUploadRegistration{}, err
|
||||||
|
}
|
||||||
|
saved, exists, err := r.GetRoomMediaUploadRegistration(ctx, registration.RoomID, registration.CommandID)
|
||||||
|
if err != nil {
|
||||||
|
return roomservice.RoomMediaUploadRegistration{}, err
|
||||||
|
}
|
||||||
|
if !exists || !sameRoomMediaUploadRegistration(saved, registration) ||
|
||||||
|
saved.Status != "active" || strings.TrimSpace(saved.ObjectURL) != registration.ObjectURL {
|
||||||
|
return roomservice.RoomMediaUploadRegistration{}, roomservice.ErrRoomMediaUploadCommandConflict
|
||||||
|
}
|
||||||
|
return saved, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func scanRoomMediaUploadRegistration(scanner interface{ Scan(dest ...any) error }) (roomservice.RoomMediaUploadRegistration, error) {
|
||||||
|
var registration roomservice.RoomMediaUploadRegistration
|
||||||
|
err := scanner.Scan(
|
||||||
|
®istration.AppCode,
|
||||||
|
®istration.RoomID,
|
||||||
|
®istration.CommandID,
|
||||||
|
®istration.ActorUserID,
|
||||||
|
®istration.Purpose,
|
||||||
|
®istration.MediaPayloadSHA256,
|
||||||
|
®istration.ExpectedObjectKey,
|
||||||
|
®istration.ObjectURL,
|
||||||
|
®istration.Status,
|
||||||
|
®istration.Media.ContentType,
|
||||||
|
®istration.Media.Format,
|
||||||
|
®istration.Media.SizeBytes,
|
||||||
|
®istration.Media.Width,
|
||||||
|
®istration.Media.Height,
|
||||||
|
®istration.Media.Animated,
|
||||||
|
®istration.Media.FrameCount,
|
||||||
|
®istration.Media.DurationMS,
|
||||||
|
®istration.Media.SHA256,
|
||||||
|
®istration.AuthorizedVersion,
|
||||||
|
®istration.CreatedAtMS,
|
||||||
|
®istration.UpdatedAtMS,
|
||||||
|
)
|
||||||
|
if err == nil {
|
||||||
|
registration.Media.ObjectKey = registration.ExpectedObjectKey
|
||||||
|
registration.Media.URL = registration.ObjectURL
|
||||||
|
if registration.Status == "active" {
|
||||||
|
registration.Media.Status = "active"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return registration, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func sameRoomMediaUploadRegistration(left roomservice.RoomMediaUploadRegistration, right roomservice.RoomMediaUploadRegistration) bool {
|
||||||
|
return appcode.Normalize(left.AppCode) == appcode.Normalize(right.AppCode) &&
|
||||||
|
strings.TrimSpace(left.RoomID) == strings.TrimSpace(right.RoomID) &&
|
||||||
|
strings.TrimSpace(left.CommandID) == strings.TrimSpace(right.CommandID) &&
|
||||||
|
left.ActorUserID == right.ActorUserID &&
|
||||||
|
strings.TrimSpace(left.Purpose) == strings.TrimSpace(right.Purpose) &&
|
||||||
|
strings.EqualFold(strings.TrimSpace(left.MediaPayloadSHA256), strings.TrimSpace(right.MediaPayloadSHA256)) &&
|
||||||
|
strings.TrimLeft(strings.TrimSpace(left.ExpectedObjectKey), "/") == strings.TrimLeft(strings.TrimSpace(right.ExpectedObjectKey), "/")
|
||||||
|
}
|
||||||
@ -129,7 +129,8 @@ func (r *Repository) GetRoomMetaByOwner(ctx context.Context, ownerUserID int64)
|
|||||||
return meta, true, nil
|
return meta, true, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// SaveRoomBackground 保存房间背景图素材;相同房间相同 URL 按幂等返回原记录。
|
// SaveRoomBackground 保存房间背景图素材。command_id 是素材保存幂等键;URL 唯一键只负责去重,
|
||||||
|
// 不能把相同 command_id 的另一份媒体悄悄折叠成旧记录。
|
||||||
func (r *Repository) SaveRoomBackground(ctx context.Context, background roomservice.RoomBackgroundImage) (roomservice.RoomBackgroundImage, error) {
|
func (r *Repository) SaveRoomBackground(ctx context.Context, background roomservice.RoomBackgroundImage) (roomservice.RoomBackgroundImage, error) {
|
||||||
appCode := normalizedRecordAppCode(ctx, background.AppCode)
|
appCode := normalizedRecordAppCode(ctx, background.AppCode)
|
||||||
roomID := strings.TrimSpace(background.RoomID)
|
roomID := strings.TrimSpace(background.RoomID)
|
||||||
@ -139,29 +140,53 @@ func (r *Repository) SaveRoomBackground(ctx context.Context, background roomserv
|
|||||||
nowMS = time.Now().UTC().UnixMilli()
|
nowMS = time.Now().UTC().UnixMilli()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if existing, exists, err := r.getRoomBackgroundByCommand(ctx, appCode, roomID, background.CommandID); err != nil {
|
||||||
|
return roomservice.RoomBackgroundImage{}, err
|
||||||
|
} else if exists {
|
||||||
|
if !sameRoomBackgroundMedia(existing, background) {
|
||||||
|
return roomservice.RoomBackgroundImage{}, roomservice.ErrRoomBackgroundCommandConflict
|
||||||
|
}
|
||||||
|
return existing, nil
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(background.Media.ObjectKey) == "" {
|
||||||
|
// legacy_timed 以 URL 作为历史幂等键;迁移前的行没有 command_id/media,重复保存必须继续返回原素材。
|
||||||
|
if existing, exists, err := r.getRoomBackgroundByURL(ctx, appCode, roomID, imageURL); err != nil {
|
||||||
|
return roomservice.RoomBackgroundImage{}, err
|
||||||
|
} else if exists {
|
||||||
|
return existing, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
_, err := r.db.ExecContext(ctx, `
|
_, err := r.db.ExecContext(ctx, `
|
||||||
INSERT INTO room_background_images (
|
INSERT INTO room_background_images (
|
||||||
app_code, room_id, image_url, created_by_user_id, created_at_ms, updated_at_ms
|
app_code, room_id, image_url, command_id, object_key, content_type, media_format,
|
||||||
) VALUES (?, ?, ?, ?, ?, ?)
|
size_bytes, width, height, animated, frame_count, duration_ms, poster_url,
|
||||||
ON DUPLICATE KEY UPDATE updated_at_ms = VALUES(updated_at_ms)
|
thumbnail_url, sha256, media_status, created_by_user_id, created_at_ms, updated_at_ms
|
||||||
`, appCode, roomID, imageURL, background.CreatedByUserID, nowMS, nowMS)
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
ON DUPLICATE KEY UPDATE background_id = LAST_INSERT_ID(background_id)
|
||||||
|
`, appCode, roomID, imageURL, strings.TrimSpace(background.CommandID), background.Media.ObjectKey,
|
||||||
|
background.Media.ContentType, background.Media.Format, background.Media.SizeBytes, background.Media.Width,
|
||||||
|
background.Media.Height, background.Media.Animated, background.Media.FrameCount, background.Media.DurationMS,
|
||||||
|
background.Media.PosterURL, background.Media.ThumbnailURL, background.Media.SHA256, background.Media.Status,
|
||||||
|
background.CreatedByUserID, nowMS, nowMS)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return roomservice.RoomBackgroundImage{}, err
|
return roomservice.RoomBackgroundImage{}, err
|
||||||
}
|
}
|
||||||
|
|
||||||
row := r.db.QueryRowContext(ctx, `
|
saved, exists, err := r.getRoomBackgroundByCommand(ctx, appCode, roomID, background.CommandID)
|
||||||
SELECT app_code, background_id, room_id, image_url, created_by_user_id, created_at_ms, updated_at_ms
|
if err != nil {
|
||||||
FROM room_background_images
|
return roomservice.RoomBackgroundImage{}, err
|
||||||
WHERE app_code = ? AND room_id = ? AND image_url = ?
|
}
|
||||||
LIMIT 1
|
if !exists || !sameRoomBackgroundMedia(saved, background) {
|
||||||
`, appCode, roomID, imageURL)
|
return roomservice.RoomBackgroundImage{}, roomservice.ErrRoomBackgroundCommandConflict
|
||||||
return scanRoomBackground(row)
|
}
|
||||||
|
return saved, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetRoomBackground 精确读取一个房间背景图素材。
|
// GetRoomBackground 精确读取一个房间背景图素材。
|
||||||
func (r *Repository) GetRoomBackground(ctx context.Context, roomID string, backgroundID int64) (roomservice.RoomBackgroundImage, bool, error) {
|
func (r *Repository) GetRoomBackground(ctx context.Context, roomID string, backgroundID int64) (roomservice.RoomBackgroundImage, bool, error) {
|
||||||
row := r.db.QueryRowContext(ctx, `
|
row := r.db.QueryRowContext(ctx, `
|
||||||
SELECT app_code, background_id, room_id, image_url, created_by_user_id, created_at_ms, updated_at_ms
|
SELECT `+roomBackgroundSelectColumns+`
|
||||||
FROM room_background_images
|
FROM room_background_images
|
||||||
WHERE app_code = ? AND room_id = ? AND background_id = ?
|
WHERE app_code = ? AND room_id = ? AND background_id = ?
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
@ -177,10 +202,16 @@ func (r *Repository) GetRoomBackground(ctx context.Context, roomID string, backg
|
|||||||
return background, true, nil
|
return background, true, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetRoomBackgroundByCommand 供 service 在任何可变权限/房态检查之前重放已保存素材。
|
||||||
|
// command_id 唯一索引保证最多一行;app_code 继续从已验签 RequestMeta context 读取,避免跨租户重放。
|
||||||
|
func (r *Repository) GetRoomBackgroundByCommand(ctx context.Context, roomID string, commandID string) (roomservice.RoomBackgroundImage, bool, error) {
|
||||||
|
return r.getRoomBackgroundByCommand(ctx, appcode.FromContext(ctx), strings.TrimSpace(roomID), strings.TrimSpace(commandID))
|
||||||
|
}
|
||||||
|
|
||||||
// ListRoomBackgrounds 返回某个房间保存过的背景图素材,按最近保存优先。
|
// ListRoomBackgrounds 返回某个房间保存过的背景图素材,按最近保存优先。
|
||||||
func (r *Repository) ListRoomBackgrounds(ctx context.Context, roomID string) ([]roomservice.RoomBackgroundImage, error) {
|
func (r *Repository) ListRoomBackgrounds(ctx context.Context, roomID string) ([]roomservice.RoomBackgroundImage, error) {
|
||||||
rows, err := r.db.QueryContext(ctx, `
|
rows, err := r.db.QueryContext(ctx, `
|
||||||
SELECT app_code, background_id, room_id, image_url, created_by_user_id, created_at_ms, updated_at_ms
|
SELECT `+roomBackgroundSelectColumns+`
|
||||||
FROM room_background_images
|
FROM room_background_images
|
||||||
WHERE app_code = ? AND room_id = ?
|
WHERE app_code = ? AND room_id = ?
|
||||||
ORDER BY created_at_ms DESC, background_id DESC
|
ORDER BY created_at_ms DESC, background_id DESC
|
||||||
@ -208,9 +239,52 @@ func scanRoomBackground(scanner interface{ Scan(dest ...any) error }) (roomservi
|
|||||||
&background.BackgroundID,
|
&background.BackgroundID,
|
||||||
&background.RoomID,
|
&background.RoomID,
|
||||||
&background.ImageURL,
|
&background.ImageURL,
|
||||||
|
&background.CommandID,
|
||||||
|
&background.Media.ObjectKey,
|
||||||
|
&background.Media.ContentType,
|
||||||
|
&background.Media.Format,
|
||||||
|
&background.Media.SizeBytes,
|
||||||
|
&background.Media.Width,
|
||||||
|
&background.Media.Height,
|
||||||
|
&background.Media.Animated,
|
||||||
|
&background.Media.FrameCount,
|
||||||
|
&background.Media.DurationMS,
|
||||||
|
&background.Media.PosterURL,
|
||||||
|
&background.Media.ThumbnailURL,
|
||||||
|
&background.Media.SHA256,
|
||||||
|
&background.Media.Status,
|
||||||
&background.CreatedByUserID,
|
&background.CreatedByUserID,
|
||||||
&background.CreatedAtMS,
|
&background.CreatedAtMS,
|
||||||
&background.UpdatedAtMS,
|
&background.UpdatedAtMS,
|
||||||
)
|
)
|
||||||
return background, err
|
return background, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const roomBackgroundSelectColumns = `app_code, background_id, room_id, image_url, COALESCE(command_id, ''),
|
||||||
|
object_key, content_type, media_format, size_bytes, width, height, animated, frame_count,
|
||||||
|
duration_ms, poster_url, thumbnail_url, sha256, media_status, created_by_user_id, created_at_ms, updated_at_ms`
|
||||||
|
|
||||||
|
func (r *Repository) getRoomBackgroundByCommand(ctx context.Context, appCode string, roomID string, commandID string) (roomservice.RoomBackgroundImage, bool, error) {
|
||||||
|
row := r.db.QueryRowContext(ctx, `SELECT `+roomBackgroundSelectColumns+`
|
||||||
|
FROM room_background_images WHERE app_code = ? AND room_id = ? AND command_id = ? LIMIT 1`, appCode, roomID, strings.TrimSpace(commandID))
|
||||||
|
background, err := scanRoomBackground(row)
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return roomservice.RoomBackgroundImage{}, false, nil
|
||||||
|
}
|
||||||
|
return background, err == nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Repository) getRoomBackgroundByURL(ctx context.Context, appCode string, roomID string, imageURL string) (roomservice.RoomBackgroundImage, bool, error) {
|
||||||
|
row := r.db.QueryRowContext(ctx, `SELECT `+roomBackgroundSelectColumns+`
|
||||||
|
FROM room_background_images WHERE app_code = ? AND room_id = ? AND image_url = ? LIMIT 1`, appCode, roomID, strings.TrimSpace(imageURL))
|
||||||
|
background, err := scanRoomBackground(row)
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return roomservice.RoomBackgroundImage{}, false, nil
|
||||||
|
}
|
||||||
|
return background, err == nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func sameRoomBackgroundMedia(left roomservice.RoomBackgroundImage, right roomservice.RoomBackgroundImage) bool {
|
||||||
|
return strings.TrimSpace(left.ImageURL) == strings.TrimSpace(right.ImageURL) &&
|
||||||
|
left.CreatedByUserID == right.CreatedByUserID && left.Media == right.Media
|
||||||
|
}
|
||||||
|
|||||||
@ -51,6 +51,8 @@ func (r *Repository) Migrate(ctx context.Context) error {
|
|||||||
seat_count INT NOT NULL DEFAULT 0,
|
seat_count INT NOT NULL DEFAULT 0,
|
||||||
occupied_seat_count INT NOT NULL DEFAULT 0,
|
occupied_seat_count INT NOT NULL DEFAULT 0,
|
||||||
sort_score BIGINT NOT NULL DEFAULT 0,
|
sort_score BIGINT NOT NULL DEFAULT 0,
|
||||||
|
room_border_json JSON NULL,
|
||||||
|
room_name_style_json JSON NULL,
|
||||||
created_at_ms BIGINT NOT NULL,
|
created_at_ms BIGINT NOT NULL,
|
||||||
updated_at_ms BIGINT NOT NULL,
|
updated_at_ms BIGINT NOT NULL,
|
||||||
PRIMARY KEY (app_code, room_id),
|
PRIMARY KEY (app_code, room_id),
|
||||||
@ -107,14 +109,78 @@ func (r *Repository) Migrate(ctx context.Context) error {
|
|||||||
app_code VARCHAR(32) NOT NULL,
|
app_code VARCHAR(32) NOT NULL,
|
||||||
background_id BIGINT NOT NULL AUTO_INCREMENT,
|
background_id BIGINT NOT NULL AUTO_INCREMENT,
|
||||||
room_id VARCHAR(64) NOT NULL,
|
room_id VARCHAR(64) NOT NULL,
|
||||||
image_url VARCHAR(256) NOT NULL,
|
image_url VARCHAR(1024) NOT NULL,
|
||||||
|
command_id VARCHAR(128) NULL,
|
||||||
|
object_key VARCHAR(512) NOT NULL DEFAULT '',
|
||||||
|
content_type VARCHAR(64) NOT NULL DEFAULT '',
|
||||||
|
media_format VARCHAR(16) NOT NULL DEFAULT '',
|
||||||
|
size_bytes BIGINT NOT NULL DEFAULT 0,
|
||||||
|
width INT NOT NULL DEFAULT 0,
|
||||||
|
height INT NOT NULL DEFAULT 0,
|
||||||
|
animated BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
frame_count INT NOT NULL DEFAULT 0,
|
||||||
|
duration_ms BIGINT NOT NULL DEFAULT 0,
|
||||||
|
poster_url VARCHAR(1024) NOT NULL DEFAULT '',
|
||||||
|
thumbnail_url VARCHAR(1024) NOT NULL DEFAULT '',
|
||||||
|
sha256 CHAR(64) NOT NULL DEFAULT '',
|
||||||
|
media_status VARCHAR(32) NOT NULL DEFAULT '',
|
||||||
created_by_user_id BIGINT NOT NULL,
|
created_by_user_id BIGINT NOT NULL,
|
||||||
created_at_ms BIGINT NOT NULL,
|
created_at_ms BIGINT NOT NULL,
|
||||||
updated_at_ms BIGINT NOT NULL,
|
updated_at_ms BIGINT NOT NULL,
|
||||||
PRIMARY KEY (background_id),
|
PRIMARY KEY (background_id),
|
||||||
UNIQUE KEY uk_room_background_url (app_code, room_id, image_url),
|
UNIQUE KEY uk_room_background_url (app_code, room_id, image_url),
|
||||||
|
UNIQUE KEY uk_room_background_command (app_code, room_id, command_id),
|
||||||
KEY idx_room_background_list (app_code, room_id, created_at_ms DESC, background_id DESC)
|
KEY idx_room_background_list (app_code, room_id, created_at_ms DESC, background_id DESC)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`,
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`,
|
||||||
|
`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`,
|
||||||
|
`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`,
|
||||||
|
`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`,
|
||||||
`CREATE TABLE IF NOT EXISTS room_snapshots (
|
`CREATE TABLE IF NOT EXISTS room_snapshots (
|
||||||
app_code VARCHAR(32) NOT NULL,
|
app_code VARCHAR(32) NOT NULL,
|
||||||
room_id VARCHAR(64) NOT NULL,
|
room_id VARCHAR(64) NOT NULL,
|
||||||
@ -404,10 +470,65 @@ func (r *Repository) Migrate(ctx context.Context) error {
|
|||||||
if err := r.ensureGiftOperationSchema(ctx); err != nil {
|
if err := r.ensureGiftOperationSchema(ctx); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if err := r.ensureRoomVIPMediaSchema(ctx); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
return r.ensureOutboxRetrySchema(ctx)
|
return r.ensureOutboxRetrySchema(ctx)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *Repository) ensureRoomVIPMediaSchema(ctx context.Context) error {
|
||||||
|
backgroundColumns := []struct{ name, ddl string }{
|
||||||
|
{"command_id", "ALTER TABLE room_background_images ADD COLUMN command_id VARCHAR(128) NULL"},
|
||||||
|
{"object_key", "ALTER TABLE room_background_images ADD COLUMN object_key VARCHAR(512) NOT NULL DEFAULT ''"},
|
||||||
|
{"content_type", "ALTER TABLE room_background_images ADD COLUMN content_type VARCHAR(64) NOT NULL DEFAULT ''"},
|
||||||
|
{"media_format", "ALTER TABLE room_background_images ADD COLUMN media_format VARCHAR(16) NOT NULL DEFAULT ''"},
|
||||||
|
{"size_bytes", "ALTER TABLE room_background_images ADD COLUMN size_bytes BIGINT NOT NULL DEFAULT 0"},
|
||||||
|
{"width", "ALTER TABLE room_background_images ADD COLUMN width INT NOT NULL DEFAULT 0"},
|
||||||
|
{"height", "ALTER TABLE room_background_images ADD COLUMN height INT NOT NULL DEFAULT 0"},
|
||||||
|
{"animated", "ALTER TABLE room_background_images ADD COLUMN animated BOOLEAN NOT NULL DEFAULT FALSE"},
|
||||||
|
{"frame_count", "ALTER TABLE room_background_images ADD COLUMN frame_count INT NOT NULL DEFAULT 0"},
|
||||||
|
{"duration_ms", "ALTER TABLE room_background_images ADD COLUMN duration_ms BIGINT NOT NULL DEFAULT 0"},
|
||||||
|
{"poster_url", "ALTER TABLE room_background_images ADD COLUMN poster_url VARCHAR(1024) NOT NULL DEFAULT ''"},
|
||||||
|
{"thumbnail_url", "ALTER TABLE room_background_images ADD COLUMN thumbnail_url VARCHAR(1024) NOT NULL DEFAULT ''"},
|
||||||
|
{"sha256", "ALTER TABLE room_background_images ADD COLUMN sha256 CHAR(64) NOT NULL DEFAULT ''"},
|
||||||
|
{"media_status", "ALTER TABLE room_background_images ADD COLUMN media_status VARCHAR(32) NOT NULL DEFAULT ''"},
|
||||||
|
}
|
||||||
|
for _, column := range backgroundColumns {
|
||||||
|
exists, err := r.columnExists(ctx, "room_background_images", column.name)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !exists {
|
||||||
|
if _, err := r.db.ExecContext(ctx, column.ddl); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, column := range []struct{ name, ddl string }{
|
||||||
|
{"room_border_json", "ALTER TABLE room_list_entries ADD COLUMN room_border_json JSON NULL"},
|
||||||
|
{"room_name_style_json", "ALTER TABLE room_list_entries ADD COLUMN room_name_style_json JSON NULL"},
|
||||||
|
} {
|
||||||
|
exists, err := r.columnExists(ctx, "room_list_entries", column.name)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !exists {
|
||||||
|
if _, err := r.db.ExecContext(ctx, column.ddl); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
hasCommandIndex, err := r.indexExists(ctx, "room_background_images", "uk_room_background_command")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !hasCommandIndex {
|
||||||
|
_, err = r.db.ExecContext(ctx, "ALTER TABLE room_background_images ADD UNIQUE KEY uk_room_background_command (app_code, room_id, command_id)")
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
func (r *Repository) ensureGiftOperationSchema(ctx context.Context) error {
|
func (r *Repository) ensureGiftOperationSchema(ctx context.Context) error {
|
||||||
// CREATE TABLE IF NOT EXISTS 不会给历史 room_command_log 补列;auto_migrate 必须显式探测,
|
// CREATE TABLE IF NOT EXISTS 不会给历史 room_command_log 补列;auto_migrate 必须显式探测,
|
||||||
// 否则新二进制启动后第一笔 GetCommand 会因 result_payload 不存在而直接 SQL 失败。
|
// 否则新二进制启动后第一笔 GetCommand 会因 result_payload 不存在而直接 SQL 失败。
|
||||||
|
|||||||
@ -100,6 +100,36 @@ func (s *Server) SetRoomBackground(ctx context.Context, req *roomv1.SetRoomBackg
|
|||||||
return mapServiceResult(s.svc.SetRoomBackground(ctx, req))
|
return mapServiceResult(s.svc.SetRoomBackground(ctx, req))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Server) ApplyRoomDecoration(ctx context.Context, req *roomv1.ApplyRoomDecorationRequest) (*roomv1.ApplyRoomDecorationResponse, error) {
|
||||||
|
ctx = contextWithMetaApp(ctx, req.GetMeta())
|
||||||
|
if resp, forwarded, err := forwardCommand(s, ctx, req.GetMeta(), func(callCtx context.Context, client roomv1.RoomCommandServiceClient) (*roomv1.ApplyRoomDecorationResponse, error) {
|
||||||
|
return client.ApplyRoomDecoration(callCtx, req)
|
||||||
|
}); forwarded {
|
||||||
|
return resp, err
|
||||||
|
}
|
||||||
|
return mapServiceResult(s.svc.ApplyRoomDecoration(ctx, req))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) SendRoomImage(ctx context.Context, req *roomv1.SendRoomImageRequest) (*roomv1.SendRoomImageResponse, error) {
|
||||||
|
ctx = contextWithMetaApp(ctx, req.GetMeta())
|
||||||
|
if resp, forwarded, err := forwardCommand(s, ctx, req.GetMeta(), func(callCtx context.Context, client roomv1.RoomCommandServiceClient) (*roomv1.SendRoomImageResponse, error) {
|
||||||
|
return client.SendRoomImage(callCtx, req)
|
||||||
|
}); forwarded {
|
||||||
|
return resp, err
|
||||||
|
}
|
||||||
|
return mapServiceResult(s.svc.SendRoomImage(ctx, req))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) AuthorizeRoomMediaUpload(ctx context.Context, req *roomv1.AuthorizeRoomMediaUploadRequest) (*roomv1.AuthorizeRoomMediaUploadResponse, error) {
|
||||||
|
ctx = contextWithMetaApp(ctx, req.GetMeta())
|
||||||
|
return mapServiceResult(s.svc.AuthorizeRoomMediaUpload(ctx, req))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) CompleteRoomMediaUpload(ctx context.Context, req *roomv1.CompleteRoomMediaUploadRequest) (*roomv1.CompleteRoomMediaUploadResponse, error) {
|
||||||
|
ctx = contextWithMetaApp(ctx, req.GetMeta())
|
||||||
|
return mapServiceResult(s.svc.CompleteRoomMediaUpload(ctx, req))
|
||||||
|
}
|
||||||
|
|
||||||
// JoinRoom 代理到领域服务。
|
// JoinRoom 代理到领域服务。
|
||||||
func (s *Server) JoinRoom(ctx context.Context, req *roomv1.JoinRoomRequest) (*roomv1.JoinRoomResponse, error) {
|
func (s *Server) JoinRoom(ctx context.Context, req *roomv1.JoinRoomRequest) (*roomv1.JoinRoomResponse, error) {
|
||||||
// JoinRoom 只维护 room-service presence,真实消息连接由腾讯云 IM SDK 处理。
|
// JoinRoom 只维护 room-service presence,真实消息连接由腾讯云 IM SDK 处理。
|
||||||
@ -537,6 +567,13 @@ func (s *Server) GetCurrentRoom(ctx context.Context, req *roomv1.GetCurrentRoomR
|
|||||||
return mapServiceResult(s.svc.GetCurrentRoom(ctx, req))
|
return mapServiceResult(s.svc.GetCurrentRoom(ctx, req))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// BatchGetUserVoiceRoomPresences 代理到个人资料页上麦状态批量查询。
|
||||||
|
func (s *Server) BatchGetUserVoiceRoomPresences(ctx context.Context, req *roomv1.BatchGetUserVoiceRoomPresencesRequest) (*roomv1.BatchGetUserVoiceRoomPresencesResponse, error) {
|
||||||
|
// 查询先用用户级 presence 定位候选房间,再由 Room Cell 快照收敛状态;gRPC 层不复制权限判断。
|
||||||
|
ctx = contextWithMetaApp(ctx, req.GetMeta())
|
||||||
|
return mapServiceResult(s.svc.BatchGetUserVoiceRoomPresences(ctx, req))
|
||||||
|
}
|
||||||
|
|
||||||
// GetRoomSnapshot 代理到房间页完整快照只读查询。
|
// GetRoomSnapshot 代理到房间页完整快照只读查询。
|
||||||
func (s *Server) GetRoomSnapshot(ctx context.Context, req *roomv1.GetRoomSnapshotRequest) (*roomv1.GetRoomSnapshotResponse, error) {
|
func (s *Server) GetRoomSnapshot(ctx context.Context, req *roomv1.GetRoomSnapshotRequest) (*roomv1.GetRoomSnapshotResponse, error) {
|
||||||
// 快照查询不刷新 presence,不替代 heartbeat,也不能让未进房用户读取完整房间态。
|
// 快照查询不刷新 presence,不替代 heartbeat,也不能让未进房用户读取完整房间态。
|
||||||
|
|||||||
@ -117,6 +117,20 @@ CREATE TABLE IF NOT EXISTS user_profile_visits (
|
|||||||
KEY idx_user_profile_visits_target_time (app_code, target_user_id, last_visited_at_ms)
|
KEY idx_user_profile_visits_target_time (app_code, target_user_id, last_visited_at_ms)
|
||||||
) 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 user_profile_anonymous_visits (
|
||||||
|
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码,用于多租户隔离',
|
||||||
|
target_user_id BIGINT NOT NULL COMMENT '目标用户 ID',
|
||||||
|
visitor_user_id BIGINT NOT NULL COMMENT '仅 user-service 内部用于聚合的真实访客 ID,禁止对外返回',
|
||||||
|
anonymous_visit_id VARCHAR(64) NOT NULL COMMENT '首次匿名访问生成的随机展示 ID,不包含真实用户信息',
|
||||||
|
visit_count BIGINT NOT NULL DEFAULT 1 COMMENT '匿名阶段累计访问数量',
|
||||||
|
first_visited_at_ms BIGINT NOT NULL COMMENT '首次匿名访问时间,UTC epoch ms',
|
||||||
|
last_visited_at_ms BIGINT NOT NULL COMMENT '最后匿名访问时间,UTC epoch ms',
|
||||||
|
updated_at_ms BIGINT NOT NULL COMMENT '更新时间,UTC epoch ms',
|
||||||
|
PRIMARY KEY (app_code, target_user_id, visitor_user_id),
|
||||||
|
UNIQUE KEY uk_user_profile_anonymous_visit_id (app_code, target_user_id, anonymous_visit_id),
|
||||||
|
KEY idx_user_profile_anonymous_visits_target_time (app_code, target_user_id, last_visited_at_ms, anonymous_visit_id)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户资料匿名访问 owner 表';
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS user_follows (
|
CREATE TABLE IF NOT EXISTS user_follows (
|
||||||
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码,用于多租户隔离',
|
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码,用于多租户隔离',
|
||||||
follower_user_id BIGINT NOT NULL COMMENT '关注人用户 ID',
|
follower_user_id BIGINT NOT NULL COMMENT '关注人用户 ID',
|
||||||
|
|||||||
@ -0,0 +1,42 @@
|
|||||||
|
SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
USE hyapp_user;
|
||||||
|
|
||||||
|
-- 匿名访问使用独立表,避免修改已有 user_profile_visits 热表主键或回填历史数据:
|
||||||
|
-- 1. CREATE TABLE 不扫描、复制或锁住旧访问表,迁移成本与旧表数据量无关;
|
||||||
|
-- 2. 旧表中的所有历史记录继续作为公开访问返回,不会因用户当前 VIP 状态被倒推改写;
|
||||||
|
-- 3. 主键服务单 target/visitor 的幂等累计,target_time 索引服务目标用户分页,查询不扫全表。
|
||||||
|
CREATE TABLE IF NOT EXISTS user_profile_anonymous_visits (
|
||||||
|
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码,用于多租户隔离',
|
||||||
|
target_user_id BIGINT NOT NULL COMMENT '目标用户 ID',
|
||||||
|
visitor_user_id BIGINT NOT NULL COMMENT '仅 user-service 内部用于聚合的真实访客 ID,禁止对外返回',
|
||||||
|
anonymous_visit_id VARCHAR(64) NOT NULL COMMENT '首次匿名访问生成的随机展示 ID,不包含真实用户信息',
|
||||||
|
visit_count BIGINT NOT NULL DEFAULT 1 COMMENT '匿名阶段累计访问数量',
|
||||||
|
first_visited_at_ms BIGINT NOT NULL COMMENT '首次匿名访问时间,UTC epoch ms',
|
||||||
|
last_visited_at_ms BIGINT NOT NULL COMMENT '最后匿名访问时间,UTC epoch ms',
|
||||||
|
updated_at_ms BIGINT NOT NULL COMMENT '更新时间,UTC epoch ms',
|
||||||
|
PRIMARY KEY (app_code, target_user_id, visitor_user_id),
|
||||||
|
UNIQUE KEY uk_user_profile_anonymous_visit_id (app_code, target_user_id, anonymous_visit_id),
|
||||||
|
KEY idx_user_profile_anonymous_visits_target_time (app_code, target_user_id, last_visited_at_ms, anonymous_visit_id)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户资料匿名访问 owner 表';
|
||||||
|
|
||||||
|
-- CREATE TABLE IF NOT EXISTS 不会为试运行阶段已存在的表补索引;唯一约束和目标时间分页索引都必须
|
||||||
|
-- 独立收敛。两个 DDL 只访问新匿名表,不读取或锁住旧 user_profile_visits 热表;若已有重复随机 ID,
|
||||||
|
-- 唯一键构建会直接失败,由部署方先审计异常数据,不能静默合并两个匿名身份。
|
||||||
|
SET @ddl := IF(
|
||||||
|
(SELECT COUNT(*) FROM information_schema.STATISTICS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'user_profile_anonymous_visits' AND INDEX_NAME = 'uk_user_profile_anonymous_visit_id') = 0,
|
||||||
|
'ALTER TABLE user_profile_anonymous_visits ADD UNIQUE INDEX uk_user_profile_anonymous_visit_id (app_code, target_user_id, anonymous_visit_id), 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 = 'user_profile_anonymous_visits' AND INDEX_NAME = 'idx_user_profile_anonymous_visits_target_time') = 0,
|
||||||
|
'ALTER TABLE user_profile_anonymous_visits ADD INDEX idx_user_profile_anonymous_visits_target_time (app_code, target_user_id, last_visited_at_ms, anonymous_visit_id), ALGORITHM=INPLACE, LOCK=NONE',
|
||||||
|
'SELECT 1'
|
||||||
|
);
|
||||||
|
PREPARE stmt FROM @ddl;
|
||||||
|
EXECUTE stmt;
|
||||||
|
DEALLOCATE PREPARE stmt;
|
||||||
@ -256,6 +256,7 @@ func New(cfg config.Config) (*App, error) {
|
|||||||
}
|
}
|
||||||
var walletConn *grpc.ClientConn
|
var walletConn *grpc.ClientConn
|
||||||
var avatarFrameReader cpservice.AvatarFrameReader
|
var avatarFrameReader cpservice.AvatarFrameReader
|
||||||
|
var privacyBenefitChecker userservice.PrivacyBenefitChecker
|
||||||
if cfg.WalletService.Enabled {
|
if cfg.WalletService.Enabled {
|
||||||
// 头像框佩戴事实归 wallet-service;user-service 刷新榜单前必须能读取该快照,否则榜单会缺少双方头像框。
|
// 头像框佩戴事实归 wallet-service;user-service 刷新榜单前必须能读取该快照,否则榜单会缺少双方头像框。
|
||||||
walletConn, err = grpcclient.Dial(cfg.WalletService.Addr, grpcclient.Config{
|
walletConn, err = grpcclient.Dial(cfg.WalletService.Addr, grpcclient.Config{
|
||||||
@ -278,8 +279,11 @@ func New(cfg config.Config) (*App, error) {
|
|||||||
_ = mysqlRepo.Close()
|
_ = mysqlRepo.Close()
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
// 只把头像框读取能力注入 CP service,避免 CP 领域依赖完整的钱包客户端。
|
// 同一个轻量 gRPC adapter 分别以最小接口注入 CP 头像框读模型和 user 隐私门禁,
|
||||||
avatarFrameReader = walletclient.NewGRPC(walletConn)
|
// 两个领域都不会依赖完整 wallet protobuf client 或复制钱包规则。
|
||||||
|
walletClient := walletclient.NewGRPC(walletConn)
|
||||||
|
avatarFrameReader = walletClient
|
||||||
|
privacyBenefitChecker = walletClient
|
||||||
}
|
}
|
||||||
|
|
||||||
// auth service 负责登录、session 和 token;用户主数据和短号事务仍通过各自领域存储完成。
|
// auth service 负责登录、session 和 token;用户主数据和短号事务仍通过各自领域存储完成。
|
||||||
@ -327,6 +331,7 @@ func New(cfg config.Config) (*App, error) {
|
|||||||
userservice.WithRoleSummaryRepository(hostRepo),
|
userservice.WithRoleSummaryRepository(hostRepo),
|
||||||
userservice.WithDeviceRepository(deviceRepo),
|
userservice.WithDeviceRepository(deviceRepo),
|
||||||
userservice.WithModerationRepository(userRepo),
|
userservice.WithModerationRepository(userRepo),
|
||||||
|
userservice.WithPrivacyBenefitChecker(privacyBenefitChecker),
|
||||||
userservice.WithSessionDenylist(ipDecisionCache, time.Duration(cfg.JWT.AccessTokenTTLSec+60)*time.Second),
|
userservice.WithSessionDenylist(ipDecisionCache, time.Duration(cfg.JWT.AccessTokenTTLSec+60)*time.Second),
|
||||||
userservice.WithIMLoginKicker(imLoginKicker),
|
userservice.WithIMLoginKicker(imLoginKicker),
|
||||||
userservice.WithRoomEvictor(roomEvictor),
|
userservice.WithRoomEvictor(roomEvictor),
|
||||||
|
|||||||
@ -342,11 +342,15 @@ const (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// ProfileVisitRecord 是访问某个用户主页的去重记录。
|
// ProfileVisitRecord 是访问某个用户主页的去重记录。
|
||||||
|
// 公开访问使用 VisitorUserID;VisitorIdentityHidden=true 时只向上游暴露随机 AnonymousVisitID,
|
||||||
|
// 真实访客 ID 仅留在 user-service 的 owner 表中用于同一目标下累计次数,绝不能进入协议转换层。
|
||||||
type ProfileVisitRecord struct {
|
type ProfileVisitRecord struct {
|
||||||
VisitorUserID int64
|
VisitorUserID int64
|
||||||
TargetUserID int64
|
TargetUserID int64
|
||||||
VisitCount int64
|
VisitCount int64
|
||||||
LastVisitedAtMs int64
|
LastVisitedAtMs int64
|
||||||
|
VisitorIdentityHidden bool
|
||||||
|
AnonymousVisitID string
|
||||||
}
|
}
|
||||||
|
|
||||||
// FollowRecord 是单向关注关系。
|
// FollowRecord 是单向关注关系。
|
||||||
|
|||||||
@ -1,22 +1,47 @@
|
|||||||
// Package walletclient 适配 wallet-service 资源 RPC,供 user-service 构建 CP 读模型。
|
// Package walletclient 适配 wallet-service 资源与 VIP 权益 RPC,供 user-service 构建展示读模型并执行隐私门禁。
|
||||||
package walletclient
|
package walletclient
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"google.golang.org/grpc"
|
"google.golang.org/grpc"
|
||||||
walletv1 "hyapp.local/api/proto/wallet/v1"
|
walletv1 "hyapp.local/api/proto/wallet/v1"
|
||||||
|
"hyapp/pkg/appcode"
|
||||||
"hyapp/pkg/idgen"
|
"hyapp/pkg/idgen"
|
||||||
cpdomain "hyapp/services/user-service/internal/domain/cp"
|
cpdomain "hyapp/services/user-service/internal/domain/cp"
|
||||||
)
|
)
|
||||||
|
|
||||||
const avatarFrameResourceType = "avatar_frame"
|
const avatarFrameResourceType = "avatar_frame"
|
||||||
|
|
||||||
// Client 只暴露 user-service 构建 CP 读模型需要的钱包资源读取能力。
|
// Client 只暴露 user-service 构建 CP 读模型和执行资料隐私需要的钱包读取能力。
|
||||||
type Client struct {
|
type Client struct {
|
||||||
client walletv1.WalletServiceClient
|
client walletv1.WalletServiceClient
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CheckVipBenefit 读取 wallet-service 已结合 App 配置、有效 VIP、体验卡排除项及用户开关计算出的最终结果。
|
||||||
|
// user-service 不缓存隐私权益,保证资料统计隐藏和匿名访问每次都按访问发生时的事实固化。
|
||||||
|
func (c *Client) CheckVipBenefit(ctx context.Context, requestID string, userID int64, benefitCode string) (bool, error) {
|
||||||
|
if c == nil || c.client == nil {
|
||||||
|
return false, grpc.ErrClientConnClosing
|
||||||
|
}
|
||||||
|
requestID = strings.TrimSpace(requestID)
|
||||||
|
if requestID == "" {
|
||||||
|
// 非 gateway 的内部调用缺 request_id 时生成兜底链路 ID;HTTP 请求始终复用 gateway 已生成的 request_id。
|
||||||
|
requestID = idgen.New("user_privacy")
|
||||||
|
}
|
||||||
|
resp, err := c.client.CheckVipBenefit(ctx, &walletv1.CheckVipBenefitRequest{
|
||||||
|
RequestId: requestID,
|
||||||
|
AppCode: appcode.FromContext(ctx),
|
||||||
|
UserId: userID,
|
||||||
|
BenefitCode: benefitCode,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
return resp.GetAllowed(), nil
|
||||||
|
}
|
||||||
|
|
||||||
// NewGRPC 基于共享 gRPC 连接创建 wallet-service 资源读取客户端。
|
// NewGRPC 基于共享 gRPC 连接创建 wallet-service 资源读取客户端。
|
||||||
func NewGRPC(conn grpc.ClientConnInterface) *Client {
|
func NewGRPC(conn grpc.ClientConnInterface) *Client {
|
||||||
return &Client{client: walletv1.NewWalletServiceClient(conn)}
|
return &Client{client: walletv1.NewWalletServiceClient(conn)}
|
||||||
|
|||||||
@ -73,6 +73,20 @@ type UserRepository interface {
|
|||||||
ChangeUserCountry(ctx context.Context, command userdomain.CountryChangeCommand) (userdomain.User, int64, []string, error)
|
ChangeUserCountry(ctx context.Context, command userdomain.CountryChangeCommand) (userdomain.User, int64, []string, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AnonymousProfileVisitRepository 是匿名访问的可选持久化能力。
|
||||||
|
// 它与旧的 UserRepository 分离,避免把新增隐私表方法扩散到只覆盖基础用户接口的测试替身;
|
||||||
|
// 生产 MySQL repository 必须实现该接口,匿名写入缺失时 service 会拒绝降级为公开访问。
|
||||||
|
type AnonymousProfileVisitRepository interface {
|
||||||
|
// RecordAnonymousProfileVisit 把本次访问固化为匿名事实;同一 target/visitor 只累计同一条匿名记录。
|
||||||
|
RecordAnonymousProfileVisit(ctx context.Context, visitorUserID int64, targetUserID int64, anonymousVisitID string, nowMs int64) (bool, userdomain.ProfileStats, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// PrivacyBenefitChecker 只暴露 user-service 执行资料隐私所需的单项 VIP 权益判断。
|
||||||
|
// 具体权益、体验卡排除项和用户开关均由 wallet-service 统一计算,user-service 不复制 VIP 规则。
|
||||||
|
type PrivacyBenefitChecker interface {
|
||||||
|
CheckVipBenefit(ctx context.Context, requestID string, userID int64, benefitCode string) (bool, error)
|
||||||
|
}
|
||||||
|
|
||||||
// AppRegistryRepository 负责把客户端包名解析成内部 app_code。
|
// AppRegistryRepository 负责把客户端包名解析成内部 app_code。
|
||||||
type AppRegistryRepository interface {
|
type AppRegistryRepository interface {
|
||||||
ResolveApp(ctx context.Context, appCode string, packageName string, platform string) (userdomain.App, error)
|
ResolveApp(ctx context.Context, appCode string, packageName string, platform string) (userdomain.App, error)
|
||||||
@ -220,6 +234,10 @@ type Service struct {
|
|||||||
appRegistryRepository AppRegistryRepository
|
appRegistryRepository AppRegistryRepository
|
||||||
// userRepository 持有 users 主表视角的读写能力。
|
// userRepository 持有 users 主表视角的读写能力。
|
||||||
userRepository UserRepository
|
userRepository UserRepository
|
||||||
|
// anonymousProfileVisitRepository 把匿名足迹保存在独立 owner 表,避免改写历史公开访问表主键。
|
||||||
|
anonymousProfileVisitRepository AnonymousProfileVisitRepository
|
||||||
|
// privacyBenefitChecker 实时读取 wallet-service 计算后的隐私权益;RPC 失败时业务层按保护隐私处理。
|
||||||
|
privacyBenefitChecker PrivacyBenefitChecker
|
||||||
// identityRepository 持有短号、靓号和变更日志事务能力。
|
// identityRepository 持有短号、靓号和变更日志事务能力。
|
||||||
identityRepository IdentityRepository
|
identityRepository IdentityRepository
|
||||||
// levelProfileReader 读取 activity-service 等级投影,靓号池申请必须依赖它判断等级区间。
|
// levelProfileReader 读取 activity-service 等级投影,靓号池申请必须依赖它判断等级区间。
|
||||||
@ -277,6 +295,10 @@ func New(userRepository UserRepository, options ...Option) *Service {
|
|||||||
// MySQL 用户 repository 同时持有 auth_sessions 表的同库事务能力。
|
// MySQL 用户 repository 同时持有 auth_sessions 表的同库事务能力。
|
||||||
svc.moderationRepository = repository
|
svc.moderationRepository = repository
|
||||||
}
|
}
|
||||||
|
if repository, ok := userRepository.(AnonymousProfileVisitRepository); ok {
|
||||||
|
// MySQL 用户 repository 同时持有公开和匿名足迹表,写入路径在同一 owner 数据库内保持原子。
|
||||||
|
svc.anonymousProfileVisitRepository = repository
|
||||||
|
}
|
||||||
for _, option := range options {
|
for _, option := range options {
|
||||||
// option 只改依赖或策略,不做 IO。
|
// option 只改依赖或策略,不做 IO。
|
||||||
option(svc)
|
option(svc)
|
||||||
@ -285,6 +307,15 @@ func New(userRepository UserRepository, options ...Option) *Service {
|
|||||||
return svc
|
return svc
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// WithPrivacyBenefitChecker 注入 wallet-service 的最终 VIP 权益判断。
|
||||||
|
func WithPrivacyBenefitChecker(checker PrivacyBenefitChecker) Option {
|
||||||
|
return func(s *Service) {
|
||||||
|
if checker != nil {
|
||||||
|
s.privacyBenefitChecker = checker
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// WithModerationRepository 注入用户治理事务 repository。
|
// WithModerationRepository 注入用户治理事务 repository。
|
||||||
func WithModerationRepository(repository ModerationRepository) Option {
|
func WithModerationRepository(repository ModerationRepository) Option {
|
||||||
return func(s *Service) {
|
return func(s *Service) {
|
||||||
|
|||||||
@ -2,6 +2,8 @@ package user
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/hex"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"hyapp/pkg/xerr"
|
"hyapp/pkg/xerr"
|
||||||
@ -11,20 +13,105 @@ import (
|
|||||||
const (
|
const (
|
||||||
defaultSocialPageSize = 20
|
defaultSocialPageSize = 20
|
||||||
maxSocialPageSize = 100
|
maxSocialPageSize = 100
|
||||||
|
|
||||||
|
// 这两个 code 必须与 wallet-service 的可配置 VIP benefit code 保持一致;
|
||||||
|
// user-service 只消费最终 allowed 结果,不复制等级、体验卡和用户开关规则。
|
||||||
|
hideProfileDataBenefitCode = "hide_profile_data"
|
||||||
|
anonymousProfileVisitBenefitCode = "anonymous_profile_visit"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// ProfileVisitResult 是一次资料访问的隐私收敛结果。
|
||||||
|
// TargetStatsHidden=true 时 TargetStats 必须保持零值,transport 也必须省略该对象,形成双层防泄漏。
|
||||||
|
type ProfileVisitResult struct {
|
||||||
|
Recorded bool
|
||||||
|
TargetStats userdomain.ProfileStats
|
||||||
|
TargetStatsHidden bool
|
||||||
|
}
|
||||||
|
|
||||||
// RecordProfileVisit 记录访问关系;自己访问自己不进入访问计数。
|
// RecordProfileVisit 记录访问关系;自己访问自己不进入访问计数。
|
||||||
func (s *Service) RecordProfileVisit(ctx context.Context, visitorUserID int64, targetUserID int64) (bool, userdomain.ProfileStats, error) {
|
func (s *Service) RecordProfileVisit(ctx context.Context, requestID string, visitorUserID int64, targetUserID int64) (ProfileVisitResult, error) {
|
||||||
if visitorUserID <= 0 || targetUserID <= 0 {
|
if visitorUserID <= 0 || targetUserID <= 0 {
|
||||||
return false, userdomain.ProfileStats{}, xerr.New(xerr.InvalidArgument, "user_id is required")
|
return ProfileVisitResult{}, xerr.New(xerr.InvalidArgument, "user_id is required")
|
||||||
}
|
}
|
||||||
if visitorUserID == targetUserID {
|
if visitorUserID == targetUserID {
|
||||||
return false, userdomain.ProfileStats{}, xerr.New(xerr.InvalidArgument, "cannot visit self")
|
return ProfileVisitResult{}, xerr.New(xerr.InvalidArgument, "cannot visit self")
|
||||||
}
|
}
|
||||||
if s.userRepository == nil {
|
if s.userRepository == nil {
|
||||||
return false, userdomain.ProfileStats{}, xerr.New(xerr.Unavailable, "user repository is not configured")
|
return ProfileVisitResult{}, xerr.New(xerr.Unavailable, "user repository is not configured")
|
||||||
}
|
}
|
||||||
return s.userRepository.RecordProfileVisit(ctx, visitorUserID, targetUserID, s.now().UnixMilli())
|
|
||||||
|
// 两项隐私都必须在访问发生时实时判断:目标用户的数据隐藏影响本次响应,访客匿名则决定
|
||||||
|
// 本次事实写入哪张表。目标权益查询失败时仅隐藏 stats;匿名权益查询失败时中止写入,既不泄漏身份,也不越权授予付费匿名能力。
|
||||||
|
anonymousVisit, err := s.anonymousVisitAllowed(ctx, requestID, visitorUserID)
|
||||||
|
if err != nil {
|
||||||
|
return ProfileVisitResult{}, err
|
||||||
|
}
|
||||||
|
// 匿名门禁先成功,才读取目标用户的当前隐藏设置;这样 wallet 整体不可用时不做无意义第二次 RPC,
|
||||||
|
// 正常链路下 target_stats 的判定也更接近最终响应时点。
|
||||||
|
targetStatsHidden := s.targetStatsMustBeHidden(ctx, requestID, targetUserID)
|
||||||
|
nowMs := s.now().UnixMilli()
|
||||||
|
|
||||||
|
var (
|
||||||
|
recorded bool
|
||||||
|
stats userdomain.ProfileStats
|
||||||
|
writeErr error
|
||||||
|
)
|
||||||
|
if anonymousVisit {
|
||||||
|
if s.anonymousProfileVisitRepository == nil {
|
||||||
|
// 匿名 owner 表缺失时必须拒绝写入,绝不能悄悄回退到公开足迹表暴露真实 visitor_user_id。
|
||||||
|
return ProfileVisitResult{}, xerr.New(xerr.Unavailable, "anonymous profile visit repository is not configured")
|
||||||
|
}
|
||||||
|
anonymousVisitID, generateErr := newAnonymousVisitID()
|
||||||
|
if generateErr != nil {
|
||||||
|
return ProfileVisitResult{}, xerr.New(xerr.Unavailable, "anonymous visit id generation failed")
|
||||||
|
}
|
||||||
|
recorded, stats, writeErr = s.anonymousProfileVisitRepository.RecordAnonymousProfileVisit(ctx, visitorUserID, targetUserID, anonymousVisitID, nowMs)
|
||||||
|
} else {
|
||||||
|
// 当前不具备匿名权益时仍写旧公开表;已有匿名历史不会被迁移或反向暴露。
|
||||||
|
recorded, stats, writeErr = s.userRepository.RecordProfileVisit(ctx, visitorUserID, targetUserID, nowMs)
|
||||||
|
}
|
||||||
|
if writeErr != nil {
|
||||||
|
return ProfileVisitResult{}, writeErr
|
||||||
|
}
|
||||||
|
|
||||||
|
result := ProfileVisitResult{Recorded: recorded, TargetStatsHidden: targetStatsHidden}
|
||||||
|
if !targetStatsHidden {
|
||||||
|
result.TargetStats = stats
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// targetStatsMustBeHidden 收敛目标资料统计的可见性。
|
||||||
|
// checker 缺失或 RPC 失败都隐藏 stats:这是只读保护,不会因此授予目标用户任何可执行付费能力。
|
||||||
|
func (s *Service) targetStatsMustBeHidden(ctx context.Context, requestID string, userID int64) bool {
|
||||||
|
if s.privacyBenefitChecker == nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
allowed, err := s.privacyBenefitChecker.CheckVipBenefit(ctx, requestID, userID, hideProfileDataBenefitCode)
|
||||||
|
return err != nil || allowed
|
||||||
|
}
|
||||||
|
|
||||||
|
// anonymousVisitAllowed 判断本次足迹能否匿名。
|
||||||
|
// 依赖失败时不能回退公开写入,也不能把失败当 allowed 授予付费权益,因此明确返回 UNAVAILABLE 且不落任何访问事实。
|
||||||
|
func (s *Service) anonymousVisitAllowed(ctx context.Context, requestID string, userID int64) (bool, error) {
|
||||||
|
if s.privacyBenefitChecker == nil {
|
||||||
|
return false, xerr.New(xerr.Unavailable, "privacy benefit checker is not configured")
|
||||||
|
}
|
||||||
|
allowed, err := s.privacyBenefitChecker.CheckVipBenefit(ctx, requestID, userID, anonymousProfileVisitBenefitCode)
|
||||||
|
if err != nil {
|
||||||
|
return false, xerr.New(xerr.Unavailable, "anonymous profile visit benefit is unavailable")
|
||||||
|
}
|
||||||
|
return allowed, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// newAnonymousVisitID 生成不含 user_id、时间或 target 信息的随机展示 ID。
|
||||||
|
// ID 在首次插入后由数据库保持不变;同一访客访问不同 target 会生成不同 ID,客户端无法跨用户关联。
|
||||||
|
func newAnonymousVisitID() (string, error) {
|
||||||
|
var entropy [16]byte
|
||||||
|
if _, err := rand.Read(entropy[:]); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return "anon_" + hex.EncodeToString(entropy[:]), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) ListProfileVisitors(ctx context.Context, userID int64, page int32, pageSize int32) ([]userdomain.ProfileVisitRecord, int64, error) {
|
func (s *Service) ListProfileVisitors(ctx context.Context, userID int64, page int32, pageSize int32) ([]userdomain.ProfileVisitRecord, int64, error) {
|
||||||
|
|||||||
@ -3,13 +3,21 @@ package user
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
|
"errors"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
mysqldriver "github.com/go-sql-driver/mysql"
|
||||||
"hyapp/pkg/appcode"
|
"hyapp/pkg/appcode"
|
||||||
"hyapp/pkg/xerr"
|
"hyapp/pkg/xerr"
|
||||||
userdomain "hyapp/services/user-service/internal/domain/user"
|
userdomain "hyapp/services/user-service/internal/domain/user"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
profileVisitorPageMax int32 = 100
|
||||||
|
profileVisitorPageSizeMax int32 = 100
|
||||||
|
profileVisitorReadWindowMax int64 = 10_000
|
||||||
|
)
|
||||||
|
|
||||||
// RecordProfileVisit 写访问去重记录。重复访问只刷新时间和次数,不重复增加 visitors_count。
|
// RecordProfileVisit 写访问去重记录。重复访问只刷新时间和次数,不重复增加 visitors_count。
|
||||||
func (r *Repository) RecordProfileVisit(ctx context.Context, visitorUserID int64, targetUserID int64, nowMs int64) (bool, userdomain.ProfileStats, error) {
|
func (r *Repository) RecordProfileVisit(ctx context.Context, visitorUserID int64, targetUserID int64, nowMs int64) (bool, userdomain.ProfileStats, error) {
|
||||||
tx, err := r.db.BeginTx(ctx, nil)
|
tx, err := r.db.BeginTx(ctx, nil)
|
||||||
@ -61,37 +69,222 @@ func (r *Repository) RecordProfileVisit(ctx context.Context, visitorUserID int64
|
|||||||
return inserted, stats, tx.Commit()
|
return inserted, stats, tx.Commit()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *Repository) ListProfileVisitors(ctx context.Context, userID int64, page int32, pageSize int32) ([]userdomain.ProfileVisitRecord, int64, error) {
|
// RecordAnonymousProfileVisit 把匿名足迹固化到独立表。
|
||||||
|
// 真实 visitor_user_id 只作为 owner 内部聚合键;对外随机 ID 首次写入后永不覆盖,因此 VIP 到期也不会反向暴露历史身份。
|
||||||
|
func (r *Repository) RecordAnonymousProfileVisit(ctx context.Context, visitorUserID int64, targetUserID int64, anonymousVisitID string, nowMs int64) (bool, userdomain.ProfileStats, error) {
|
||||||
|
tx, err := r.db.BeginTx(ctx, nil)
|
||||||
|
if err != nil {
|
||||||
|
return false, userdomain.ProfileStats{}, err
|
||||||
|
}
|
||||||
|
defer tx.Rollback()
|
||||||
|
|
||||||
appCode := appcode.FromContext(ctx)
|
appCode := appcode.FromContext(ctx)
|
||||||
total, err := countRows(ctx, r.db, `
|
if err := ensureSocialUser(ctx, tx, appCode, visitorUserID); err != nil {
|
||||||
SELECT COUNT(*) FROM user_profile_visits WHERE app_code = ? AND target_user_id = ?`,
|
return false, userdomain.ProfileStats{}, err
|
||||||
appCode, userID,
|
}
|
||||||
|
if err := ensureSocialUser(ctx, tx, appCode, targetUserID); err != nil {
|
||||||
|
return false, userdomain.ProfileStats{}, err
|
||||||
|
}
|
||||||
|
if err := ensureProfileStatsRow(ctx, tx, appCode, targetUserID, nowMs); err != nil {
|
||||||
|
return false, userdomain.ProfileStats{}, err
|
||||||
|
}
|
||||||
|
_, err = tx.ExecContext(ctx, `
|
||||||
|
INSERT INTO user_profile_anonymous_visits (
|
||||||
|
app_code, target_user_id, visitor_user_id, anonymous_visit_id, visit_count,
|
||||||
|
first_visited_at_ms, last_visited_at_ms, updated_at_ms
|
||||||
|
) VALUES (?, ?, ?, ?, 1, ?, ?, ?)`,
|
||||||
|
appCode, targetUserID, visitorUserID, anonymousVisitID, nowMs, nowMs, nowMs,
|
||||||
)
|
)
|
||||||
|
inserted := err == nil
|
||||||
|
if err != nil {
|
||||||
|
if !anonymousVisitDuplicate(err) {
|
||||||
|
return false, userdomain.ProfileStats{}, err
|
||||||
|
}
|
||||||
|
// 主键冲突表示同一 target/visitor 的历史匿名记录,按主键累计且保留首次随机 ID。
|
||||||
|
// 若冲突仅来自随机 ID 唯一键,则本 visitor 主键不存在、UPDATE affected=0,事务会 fail-closed 回滚。
|
||||||
|
result, updateErr := tx.ExecContext(ctx, `
|
||||||
|
UPDATE user_profile_anonymous_visits
|
||||||
|
SET visit_count = visit_count + 1, last_visited_at_ms = ?, updated_at_ms = ?
|
||||||
|
WHERE app_code = ? AND target_user_id = ? AND visitor_user_id = ?`,
|
||||||
|
nowMs, nowMs, appCode, targetUserID, visitorUserID,
|
||||||
|
)
|
||||||
|
if updateErr != nil {
|
||||||
|
return false, userdomain.ProfileStats{}, updateErr
|
||||||
|
}
|
||||||
|
affected, affectedErr := result.RowsAffected()
|
||||||
|
if affectedErr != nil {
|
||||||
|
return false, userdomain.ProfileStats{}, affectedErr
|
||||||
|
}
|
||||||
|
if affected != 1 {
|
||||||
|
return false, userdomain.ProfileStats{}, xerr.New(xerr.Conflict, "anonymous visit id collision")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if inserted {
|
||||||
|
// 公开与匿名记录是两个独立展示聚合;同一真实用户先公开后匿名会各占一个访客项,
|
||||||
|
// 这样历史公开事实不会被改写,匿名阶段也不会借旧记录泄漏身份。
|
||||||
|
if _, err := tx.ExecContext(ctx, `
|
||||||
|
UPDATE user_profile_stats
|
||||||
|
SET visitors_count = visitors_count + 1, updated_at_ms = ?
|
||||||
|
WHERE app_code = ? AND user_id = ?`,
|
||||||
|
nowMs, appCode, targetUserID,
|
||||||
|
); err != nil {
|
||||||
|
return false, userdomain.ProfileStats{}, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
stats, err := queryProfileStatsTx(ctx, tx, appCode, targetUserID)
|
||||||
|
if err != nil {
|
||||||
|
return false, userdomain.ProfileStats{}, err
|
||||||
|
}
|
||||||
|
return inserted, stats, tx.Commit()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Repository) ListProfileVisitors(ctx context.Context, userID int64, page int32, pageSize int32) ([]userdomain.ProfileVisitRecord, int64, error) {
|
||||||
|
// gRPC 内部调用也必须受同一有界窗口保护,不能只相信 gateway 的 HTTP 参数校验。
|
||||||
|
// page/page_size 双上限固定公开协议;window 上限再防御未来任一上限被单独调整后重新引入无界物化。
|
||||||
|
if page <= 0 || page > profileVisitorPageMax || pageSize <= 0 || pageSize > profileVisitorPageSizeMax {
|
||||||
|
return nil, 0, xerr.New(xerr.InvalidArgument, "profile visitor page is out of range")
|
||||||
|
}
|
||||||
|
offset := socialOffset(page, pageSize)
|
||||||
|
if offset+int64(pageSize) > profileVisitorReadWindowMax {
|
||||||
|
return nil, 0, xerr.New(xerr.InvalidArgument, "profile visitor page window is too large")
|
||||||
|
}
|
||||||
|
appCode := appcode.FromContext(ctx)
|
||||||
|
// total 与两张表的分页读取放在同一个只读快照中,避免并发访问恰好落在查询之间时出现
|
||||||
|
// total 和 records 不属于同一时点。COUNT 都命中 (app_code,target_user_id,...) 前缀,不扫全表。
|
||||||
|
tx, err := r.db.BeginTx(ctx, &sql.TxOptions{ReadOnly: true})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, 0, err
|
return nil, 0, err
|
||||||
}
|
}
|
||||||
rows, err := r.db.QueryContext(ctx, `
|
defer tx.Rollback()
|
||||||
|
var total int64
|
||||||
|
if err := tx.QueryRowContext(ctx, `
|
||||||
|
SELECT
|
||||||
|
(SELECT COUNT(*) FROM user_profile_visits WHERE app_code = ? AND target_user_id = ?) +
|
||||||
|
(SELECT COUNT(*) FROM user_profile_anonymous_visits WHERE app_code = ? AND target_user_id = ?)`,
|
||||||
|
appCode, userID, appCode, userID,
|
||||||
|
).Scan(&total); err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
if total == 0 {
|
||||||
|
return []userdomain.ProfileVisitRecord{}, 0, tx.Commit()
|
||||||
|
}
|
||||||
|
|
||||||
|
if offset >= total {
|
||||||
|
// 越过末页时直接返回快照 total,避免为了一个确定为空的页面从两个分支各读取全部目标记录。
|
||||||
|
return []userdomain.ProfileVisitRecord{}, total, tx.Commit()
|
||||||
|
}
|
||||||
|
fetchLimit := offset + int64(pageSize)
|
||||||
|
if fetchLimit > total {
|
||||||
|
fetchLimit = total
|
||||||
|
}
|
||||||
|
publicRecords, err := queryPublicProfileVisitors(ctx, tx, appCode, userID, fetchLimit)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
anonymousRecords, err := queryAnonymousProfileVisitors(ctx, tx, appCode, userID, fetchLimit)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
records := mergeProfileVisitors(publicRecords, anonymousRecords, offset, pageSize)
|
||||||
|
if err := tx.Commit(); err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
return records, total, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func queryPublicProfileVisitors(ctx context.Context, tx *sql.Tx, appCode string, userID int64, limit int64) ([]userdomain.ProfileVisitRecord, error) {
|
||||||
|
rows, err := tx.QueryContext(ctx, `
|
||||||
SELECT visitor_user_id, target_user_id, visit_count, last_visited_at_ms
|
SELECT visitor_user_id, target_user_id, visit_count, last_visited_at_ms
|
||||||
FROM user_profile_visits
|
FROM user_profile_visits
|
||||||
WHERE app_code = ? AND target_user_id = ?
|
WHERE app_code = ? AND target_user_id = ?
|
||||||
ORDER BY last_visited_at_ms DESC, visitor_user_id DESC
|
ORDER BY last_visited_at_ms DESC, visitor_user_id DESC
|
||||||
LIMIT ? OFFSET ?`,
|
LIMIT ?`,
|
||||||
appCode, userID, pageSize, socialOffset(page, pageSize),
|
appCode, userID, limit,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, 0, err
|
return nil, err
|
||||||
}
|
}
|
||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
|
|
||||||
records := make([]userdomain.ProfileVisitRecord, 0, pageSize)
|
records := make([]userdomain.ProfileVisitRecord, 0)
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var record userdomain.ProfileVisitRecord
|
var record userdomain.ProfileVisitRecord
|
||||||
if err := rows.Scan(&record.VisitorUserID, &record.TargetUserID, &record.VisitCount, &record.LastVisitedAtMs); err != nil {
|
if err := rows.Scan(&record.VisitorUserID, &record.TargetUserID, &record.VisitCount, &record.LastVisitedAtMs); err != nil {
|
||||||
return nil, 0, err
|
return nil, err
|
||||||
}
|
}
|
||||||
records = append(records, record)
|
records = append(records, record)
|
||||||
}
|
}
|
||||||
return records, total, rows.Err()
|
return records, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func queryAnonymousProfileVisitors(ctx context.Context, tx *sql.Tx, appCode string, userID int64, limit int64) ([]userdomain.ProfileVisitRecord, error) {
|
||||||
|
rows, err := tx.QueryContext(ctx, `
|
||||||
|
SELECT anonymous_visit_id, target_user_id, visit_count, last_visited_at_ms
|
||||||
|
FROM user_profile_anonymous_visits
|
||||||
|
WHERE app_code = ? AND target_user_id = ?
|
||||||
|
ORDER BY last_visited_at_ms DESC, anonymous_visit_id DESC
|
||||||
|
LIMIT ?`,
|
||||||
|
appCode, userID, limit,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
records := make([]userdomain.ProfileVisitRecord, 0)
|
||||||
|
for rows.Next() {
|
||||||
|
record := userdomain.ProfileVisitRecord{VisitorIdentityHidden: true}
|
||||||
|
if err := rows.Scan(&record.AnonymousVisitID, &record.TargetUserID, &record.VisitCount, &record.LastVisitedAtMs); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
records = append(records, record)
|
||||||
|
}
|
||||||
|
return records, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// mergeProfileVisitors 合并两个已排序分支,并在内存中应用全局 offset。
|
||||||
|
// 每个 SQL 分支最多读取 offset+page_size 行,避免 UNION ALL 为单个高访问目标物化其全部历史记录。
|
||||||
|
func mergeProfileVisitors(publicRecords []userdomain.ProfileVisitRecord, anonymousRecords []userdomain.ProfileVisitRecord, offset int64, pageSize int32) []userdomain.ProfileVisitRecord {
|
||||||
|
records := make([]userdomain.ProfileVisitRecord, 0, pageSize)
|
||||||
|
publicIndex, anonymousIndex := 0, 0
|
||||||
|
var skipped int64
|
||||||
|
for (publicIndex < len(publicRecords) || anonymousIndex < len(anonymousRecords)) && len(records) < int(pageSize) {
|
||||||
|
var next userdomain.ProfileVisitRecord
|
||||||
|
switch {
|
||||||
|
case anonymousIndex >= len(anonymousRecords):
|
||||||
|
next = publicRecords[publicIndex]
|
||||||
|
publicIndex++
|
||||||
|
case publicIndex >= len(publicRecords):
|
||||||
|
next = anonymousRecords[anonymousIndex]
|
||||||
|
anonymousIndex++
|
||||||
|
case profileVisitBefore(publicRecords[publicIndex], anonymousRecords[anonymousIndex]):
|
||||||
|
next = publicRecords[publicIndex]
|
||||||
|
publicIndex++
|
||||||
|
default:
|
||||||
|
next = anonymousRecords[anonymousIndex]
|
||||||
|
anonymousIndex++
|
||||||
|
}
|
||||||
|
if skipped < offset {
|
||||||
|
skipped++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
records = append(records, next)
|
||||||
|
}
|
||||||
|
return records
|
||||||
|
}
|
||||||
|
|
||||||
|
func profileVisitBefore(left userdomain.ProfileVisitRecord, right userdomain.ProfileVisitRecord) bool {
|
||||||
|
if left.LastVisitedAtMs != right.LastVisitedAtMs {
|
||||||
|
return left.LastVisitedAtMs > right.LastVisitedAtMs
|
||||||
|
}
|
||||||
|
if left.VisitorIdentityHidden != right.VisitorIdentityHidden {
|
||||||
|
// 同毫秒内公开记录固定排在匿名记录前,避免不同查询计划造成跨页漂移。
|
||||||
|
return !left.VisitorIdentityHidden
|
||||||
|
}
|
||||||
|
if left.VisitorIdentityHidden {
|
||||||
|
return left.AnonymousVisitID > right.AnonymousVisitID
|
||||||
|
}
|
||||||
|
return left.VisitorUserID > right.VisitorUserID
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *Repository) FollowUser(ctx context.Context, followerUserID int64, followeeUserID int64, nowMs int64) (userdomain.ProfileStats, error) {
|
func (r *Repository) FollowUser(ctx context.Context, followerUserID int64, followeeUserID int64, nowMs int64) (userdomain.ProfileStats, error) {
|
||||||
@ -601,6 +794,12 @@ func countRows(ctx context.Context, db *sql.DB, query string, args ...any) (int6
|
|||||||
return total, err
|
return total, err
|
||||||
}
|
}
|
||||||
|
|
||||||
func socialOffset(page int32, pageSize int32) int32 {
|
func socialOffset(page int32, pageSize int32) int64 {
|
||||||
return (page - 1) * pageSize
|
// 转为 int64 后再相乘,避免合法 int32 page 在高页码时先于 SQL LIMIT/OFFSET 发生整数溢出。
|
||||||
|
return (int64(page) - 1) * int64(pageSize)
|
||||||
|
}
|
||||||
|
|
||||||
|
func anonymousVisitDuplicate(err error) bool {
|
||||||
|
var mysqlErr *mysqldriver.MySQLError
|
||||||
|
return errors.As(err, &mysqlErr) && mysqlErr.Number == 1062
|
||||||
}
|
}
|
||||||
|
|||||||
@ -246,10 +246,12 @@ func toProtoProfileStats(stats userdomain.ProfileStats) *userv1.UserProfileStats
|
|||||||
|
|
||||||
func toProtoProfileVisitRecord(record userdomain.ProfileVisitRecord) *userv1.ProfileVisitRecord {
|
func toProtoProfileVisitRecord(record userdomain.ProfileVisitRecord) *userv1.ProfileVisitRecord {
|
||||||
return &userv1.ProfileVisitRecord{
|
return &userv1.ProfileVisitRecord{
|
||||||
VisitorUserId: record.VisitorUserID,
|
VisitorUserId: record.VisitorUserID,
|
||||||
TargetUserId: record.TargetUserID,
|
TargetUserId: record.TargetUserID,
|
||||||
VisitCount: record.VisitCount,
|
VisitCount: record.VisitCount,
|
||||||
LastVisitedAtMs: record.LastVisitedAtMs,
|
LastVisitedAtMs: record.LastVisitedAtMs,
|
||||||
|
VisitorIdentityHidden: record.VisitorIdentityHidden,
|
||||||
|
AnonymousVisitId: record.AnonymousVisitID,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -596,11 +596,19 @@ func toProtoSetUserStatusResponse(result userservice.UserStatusResult) *userv1.S
|
|||||||
// RecordProfileVisit 记录访问足迹;重复访问只刷新访问次数和最后访问时间。
|
// RecordProfileVisit 记录访问足迹;重复访问只刷新访问次数和最后访问时间。
|
||||||
func (s *Server) RecordProfileVisit(ctx context.Context, req *userv1.RecordProfileVisitRequest) (*userv1.RecordProfileVisitResponse, error) {
|
func (s *Server) RecordProfileVisit(ctx context.Context, req *userv1.RecordProfileVisitRequest) (*userv1.RecordProfileVisitResponse, error) {
|
||||||
ctx = contextWithApp(ctx, req.GetMeta())
|
ctx = contextWithApp(ctx, req.GetMeta())
|
||||||
recorded, stats, err := s.userSvc.RecordProfileVisit(ctx, req.GetVisitorUserId(), req.GetTargetUserId())
|
result, err := s.userSvc.RecordProfileVisit(ctx, requestIDFromMeta(req.GetMeta()), req.GetVisitorUserId(), req.GetTargetUserId())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, xerr.ToGRPCError(err)
|
return nil, xerr.ToGRPCError(err)
|
||||||
}
|
}
|
||||||
return &userv1.RecordProfileVisitResponse{Recorded: recorded, TargetStats: toProtoProfileStats(stats)}, nil
|
resp := &userv1.RecordProfileVisitResponse{
|
||||||
|
Recorded: result.Recorded,
|
||||||
|
TargetStatsHidden: result.TargetStatsHidden,
|
||||||
|
}
|
||||||
|
if !result.TargetStatsHidden {
|
||||||
|
// service 已在隐藏分支清空 stats;transport 再按显式标志省略 protobuf 对象,避免零值对象被客户端误认为真实数据。
|
||||||
|
resp.TargetStats = toProtoProfileStats(result.TargetStats)
|
||||||
|
}
|
||||||
|
return resp, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ListProfileVisitors 返回访问当前用户主页的人,按最后访问时间倒序。
|
// ListProfileVisitors 返回访问当前用户主页的人,按最后访问时间倒序。
|
||||||
|
|||||||
@ -1457,6 +1457,23 @@ CREATE TABLE IF NOT EXISTS user_resource_equipment (
|
|||||||
KEY idx_user_resource_equipment_user (app_code, user_id, updated_at_ms)
|
KEY idx_user_resource_equipment_user (app_code, user_id, updated_at_ms)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户资源装备表';
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户资源装备表';
|
||||||
|
|
||||||
|
-- 装备命令以 (app_code,command_id) 点查并先于 entitlement/equipment 行加锁;不对装备表做历史扫描。
|
||||||
|
-- 记录首个成功回包快照,确保网络重试不会因资源随后到期或后台下架而改变业务结果。
|
||||||
|
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='用户资源装备命令幂等事实表';
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS wallet_diamond_exchange_rules (
|
CREATE TABLE IF NOT EXISTS wallet_diamond_exchange_rules (
|
||||||
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码,用于多租户隔离',
|
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码,用于多租户隔离',
|
||||||
rule_id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY COMMENT '规则 ID',
|
rule_id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY COMMENT '规则 ID',
|
||||||
@ -1913,9 +1930,31 @@ CREATE TABLE IF NOT EXISTS vip_level_benefits (
|
|||||||
updated_at_ms BIGINT NOT NULL COMMENT '更新时间,UTC epoch ms',
|
updated_at_ms BIGINT NOT NULL COMMENT '更新时间,UTC epoch ms',
|
||||||
PRIMARY KEY (app_code, level, benefit_code),
|
PRIMARY KEY (app_code, level, benefit_code),
|
||||||
KEY idx_vip_benefit_code (app_code, benefit_code, status, level),
|
KEY idx_vip_benefit_code (app_code, benefit_code, status, level),
|
||||||
KEY idx_vip_benefit_level_sort (app_code, level, status, sort_order)
|
KEY idx_vip_benefit_level_sort (app_code, level, status, sort_order),
|
||||||
|
KEY idx_vip_benefit_resource (app_code, resource_id, status, benefit_code, level)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='VIP 每等级完整权益矩阵';
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='VIP 每等级完整权益矩阵';
|
||||||
|
|
||||||
|
-- admin 每次更新都会在删除当前矩阵前后各写一份不可变快照。运行时只按资源索引读取这张
|
||||||
|
-- 小型配置历史,从而让改绑/删除/program disabled 后的旧 entitlement 继续失败关闭。
|
||||||
|
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 '当时绑定资源 ID;0 表示未绑定',
|
||||||
|
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 权益资源绑定不可变历史';
|
||||||
|
|
||||||
-- user_vip_trial_cards 只保存体验卡的 VIP 元数据;卡片库存与当前佩戴仍分别复用
|
-- user_vip_trial_cards 只保存体验卡的 VIP 元数据;卡片库存与当前佩戴仍分别复用
|
||||||
-- user_resource_entitlements 和 user_resource_equipment,切换佩戴不会重置或作废其它卡片。
|
-- user_resource_entitlements 和 user_resource_equipment,切换佩戴不会重置或作废其它卡片。
|
||||||
CREATE TABLE IF NOT EXISTS user_vip_trial_cards (
|
CREATE TABLE IF NOT EXISTS user_vip_trial_cards (
|
||||||
@ -2067,7 +2106,14 @@ SELECT
|
|||||||
'fami', levels.level, benefits.benefit_code, benefits.name, benefits.benefit_type,
|
'fami', levels.level, benefits.benefit_code, benefits.name, benefits.benefit_type,
|
||||||
benefits.unlock_level,
|
benefits.unlock_level,
|
||||||
CASE WHEN benefits.benefit_code = 'daily_coin_rebate' THEN 'disabled' ELSE 'active' END,
|
CASE WHEN benefits.benefit_code = 'daily_coin_rebate' THEN 'disabled' ELSE 'active' END,
|
||||||
benefits.trial_enabled, 0, '', benefits.execution_scope,
|
benefits.trial_enabled, 0,
|
||||||
|
CASE benefits.benefit_code
|
||||||
|
WHEN 'mic_skin' THEN 'mic_seat_animation'
|
||||||
|
WHEN 'room_border' THEN 'room_border'
|
||||||
|
WHEN 'colored_room_name' THEN 'room_name_style'
|
||||||
|
ELSE ''
|
||||||
|
END,
|
||||||
|
benefits.execution_scope,
|
||||||
benefits.auto_equip, benefits.sort_order,
|
benefits.auto_equip, benefits.sort_order,
|
||||||
CASE
|
CASE
|
||||||
WHEN benefits.benefit_code = 'online_global_notice'
|
WHEN benefits.benefit_code = 'online_global_notice'
|
||||||
@ -2097,7 +2143,7 @@ INNER JOIN (
|
|||||||
UNION ALL SELECT 'mic_skin', '麦克风皮肤', 'decoration', 4, TRUE, 'room', TRUE, 150
|
UNION ALL SELECT 'mic_skin', '麦克风皮肤', 'decoration', 4, TRUE, 'room', TRUE, 150
|
||||||
UNION ALL SELECT 'room_border', '房间边框', 'decoration', 4, TRUE, 'room', TRUE, 160
|
UNION ALL SELECT 'room_border', '房间边框', 'decoration', 4, TRUE, 'room', TRUE, 160
|
||||||
UNION ALL SELECT 'vehicle', 'VIP座驾', 'decoration', 4, TRUE, 'room', TRUE, 170
|
UNION ALL SELECT 'vehicle', 'VIP座驾', 'decoration', 4, TRUE, 'room', TRUE, 170
|
||||||
UNION ALL SELECT 'colored_room_name', 'VIP彩色房间名称', 'function', 5, TRUE, 'room', FALSE, 180
|
UNION ALL SELECT 'colored_room_name', 'VIP彩色房间名称', 'decoration', 5, TRUE, 'room', FALSE, 180
|
||||||
UNION ALL SELECT 'colored_nickname', 'VIP彩色昵称', 'function', 5, TRUE, 'user', FALSE, 190
|
UNION ALL SELECT 'colored_nickname', 'VIP彩色昵称', 'function', 5, TRUE, 'user', FALSE, 190
|
||||||
UNION ALL SELECT 'colored_id', 'VIP彩色ID', 'function', 5, TRUE, 'user', FALSE, 200
|
UNION ALL SELECT 'colored_id', 'VIP彩色ID', 'function', 5, TRUE, 'user', FALSE, 200
|
||||||
UNION ALL SELECT 'gift_tray_skin', '送礼托盘皮肤', 'function', 5, TRUE, 'room', FALSE, 210
|
UNION ALL SELECT 'gift_tray_skin', '送礼托盘皮肤', 'function', 5, TRUE, 'room', FALSE, 210
|
||||||
@ -2164,6 +2210,168 @@ INSERT IGNORE INTO resources (
|
|||||||
('fami', 'vip_trial_card_9', 'vip_trial_card', 'VIP9体验卡', 'active', TRUE, TRUE,
|
('fami', 'vip_trial_card_9', 'vip_trial_card', 'VIP9体验卡', 'active', TRUE, TRUE,
|
||||||
'new_entitlement', '', 0, 'free', 0, 0, '{}', '', '', '', '{"vip_level":9}', 90, 0, 0, 0, 0);
|
'new_entitlement', '', 0, 'free', 0, 0, '{}', '', '', '', '{"vip_level":9}', 90, 0, 0, 0, 0);
|
||||||
|
|
||||||
|
SET @vip_resource_contract_now_ms := CAST(UNIX_TIMESTAMP(UTC_TIMESTAMP(3)) * 1000 AS UNSIGNED);
|
||||||
|
|
||||||
|
-- 三项房间视觉权益的 code -> resource_type 是跨服务稳定契约。老环境先收敛类型;
|
||||||
|
-- disabled 行可以没有素材,但不能保留指向下架资源或错误类型资源的脏 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
|
||||||
|
);
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
-- 只按已经存在的 active Fami VIP4..VIP8 资源组唯一匹配麦位动画;HAVING=1 防止多候选误绑。
|
||||||
|
-- VIP9、room_border、room_name_style 没有可核实目录资源时继续保持 resource_id=0,不生成占位素材。
|
||||||
|
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;
|
||||||
|
|
||||||
|
-- 只给 active mic_skin 已绑定的真实麦座动画补 format;优先采用合法 animation_format,
|
||||||
|
-- 否则只从实际 animation/asset URL 的明确后缀推导,jpg 统一写成 jpeg。
|
||||||
|
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)$'
|
||||||
|
);
|
||||||
|
|
||||||
|
-- active 装扮必须能联结到同 App、active、精确类型的真实资源。麦座动画和房间边框还
|
||||||
|
-- 必须具备素材、preview、合法 format,并保持可选 animation_format 与 format 一致。
|
||||||
|
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'))))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- fresh init 在资源映射收敛后建立 baseline;重放 initdb 由唯一键保持幂等。
|
||||||
|
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;
|
||||||
|
|
||||||
INSERT IGNORE INTO gift_type_configs (
|
INSERT IGNORE INTO gift_type_configs (
|
||||||
app_code, type_code, name, tab_key, status, sort_order,
|
app_code, type_code, name, tab_key, status, sort_order,
|
||||||
created_by_user_id, updated_by_user_id, created_at_ms, updated_at_ms
|
created_by_user_id, updated_by_user_id, created_at_ms, updated_at_ms
|
||||||
|
|||||||
@ -0,0 +1,247 @@
|
|||||||
|
SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
USE hyapp_wallet;
|
||||||
|
|
||||||
|
-- 与顶层生产迁移 069 保持同一事实;本服务迁移供本地/独立 wallet 部署使用。
|
||||||
|
-- 幂等重放只走 (app_code,command_id) 主键,不扫描装备或 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 '当时绑定资源 ID;0 表示未绑定',
|
||||||
|
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 权益资源绑定不可变历史';
|
||||||
|
|
||||||
|
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 三项房间视觉权益都属于 decoration,并使用严格的 code -> resource_type 映射。
|
||||||
|
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();
|
||||||
|
|
||||||
|
-- 只绑定资源组中唯一的 active 麦位动画。缺少真实目录资源的 VIP9、房间边框和房名样式保持未配置。
|
||||||
|
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 已绑定的真实麦座动画:合法 animation_format 优先;缺省时仅从
|
||||||
|
-- animation/asset URL 的明确后缀推导 format。权益矩阵很小,资源表始终按主键点查。
|
||||||
|
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();
|
||||||
|
|
||||||
|
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();
|
||||||
|
|
||||||
|
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;下次重放开头的
|
||||||
|
-- INSERT IGNORE 会直接命中唯一键,不会因为版本已提升而多追加一套相同快照。
|
||||||
|
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;
|
||||||
@ -129,6 +129,38 @@ type VipBenefit struct {
|
|||||||
CreatedAtMS int64
|
CreatedAtMS int64
|
||||||
UpdatedAtMS int64
|
UpdatedAtMS int64
|
||||||
Presentation *VipBenefitPresentation
|
Presentation *VipBenefitPresentation
|
||||||
|
// Resource 只承载已通过 App、状态和类型校验的真实目录资源;nil 明确表示当前不可渲染,
|
||||||
|
// 客户端不能再根据 benefit_code 或 VIP 等级拼接资源地址。
|
||||||
|
Resource *VipBenefitResource
|
||||||
|
}
|
||||||
|
|
||||||
|
// VipBenefitResource 是 wallet Resource 的完整只读投影。ledger 不能反向依赖 resource domain
|
||||||
|
// (resource domain 已依赖 ledger 的钱包资产常量),因此在 VIP 聚合内保留无行为的数据结构以避免循环依赖。
|
||||||
|
type VipBenefitResource struct {
|
||||||
|
AppCode string
|
||||||
|
ResourceID int64
|
||||||
|
ResourceCode string
|
||||||
|
ResourceType string
|
||||||
|
Name string
|
||||||
|
Status string
|
||||||
|
Grantable bool
|
||||||
|
ManagerGrantEnabled bool
|
||||||
|
GrantStrategy string
|
||||||
|
WalletAssetType string
|
||||||
|
WalletAssetAmount int64
|
||||||
|
PriceType string
|
||||||
|
CoinPrice int64
|
||||||
|
GiftPointAmount int64
|
||||||
|
UsageScopes []string
|
||||||
|
AssetURL string
|
||||||
|
PreviewURL string
|
||||||
|
AnimationURL string
|
||||||
|
MetadataJSON string
|
||||||
|
SortOrder int32
|
||||||
|
CreatedByUserID int64
|
||||||
|
UpdatedByUserID int64
|
||||||
|
CreatedAtMS int64
|
||||||
|
UpdatedAtMS int64
|
||||||
}
|
}
|
||||||
|
|
||||||
// VipBenefitPresentation 是 App 可直接渲染的权益表现契约。它从 metadata_json.presentation
|
// VipBenefitPresentation 是 App 可直接渲染的权益表现契约。它从 metadata_json.presentation
|
||||||
@ -344,10 +376,14 @@ type GrantVipTrialCardReceipt struct {
|
|||||||
|
|
||||||
// EquipVipTrialCardCommand 以 entitlement 精确选择背包实例,避免同资源多卡时佩戴错对象。
|
// EquipVipTrialCardCommand 以 entitlement 精确选择背包实例,避免同资源多卡时佩戴错对象。
|
||||||
type EquipVipTrialCardCommand struct {
|
type EquipVipTrialCardCommand struct {
|
||||||
AppCode string
|
AppCode string
|
||||||
RequestID string
|
RequestID string
|
||||||
UserID int64
|
// EquipmentCommandID/ResourceID 仅由通用背包装备入口携带,用于把体验卡状态机的
|
||||||
EntitlementID string
|
// 成功结果写入同一份装备幂等事实;专用旧接口保持为空,不改变原有 request_id 语义。
|
||||||
|
EquipmentCommandID string
|
||||||
|
ResourceID int64
|
||||||
|
UserID int64
|
||||||
|
EntitlementID string
|
||||||
}
|
}
|
||||||
|
|
||||||
// UnequipVipTrialCardCommand 只清理 vip_trial_card 类型的装备事实,不回收或暂停任一卡片。
|
// UnequipVipTrialCardCommand 只清理 vip_trial_card 类型的装备事实,不回收或暂停任一卡片。
|
||||||
@ -359,11 +395,13 @@ type UnequipVipTrialCardCommand struct {
|
|||||||
|
|
||||||
// CheckVipBenefitResult 是 owner service 执行单项特权时使用的服务端判断结果。
|
// CheckVipBenefitResult 是 owner service 执行单项特权时使用的服务端判断结果。
|
||||||
type CheckVipBenefitResult struct {
|
type CheckVipBenefitResult struct {
|
||||||
Allowed bool
|
Allowed bool
|
||||||
Benefit VipBenefit
|
Benefit VipBenefit
|
||||||
State VipState
|
State VipState
|
||||||
DenialReason string
|
DenialReason string
|
||||||
EvaluatedAtMS int64
|
EvaluatedAtMS int64
|
||||||
|
RequiredLevel int32
|
||||||
|
CurrentEffectiveLevel int32
|
||||||
}
|
}
|
||||||
|
|
||||||
// AdminVipLevelCommand 是后台保存 VIP 等级配置的完整事实输入。
|
// AdminVipLevelCommand 是后台保存 VIP 等级配置的完整事实输入。
|
||||||
|
|||||||
@ -11,7 +11,11 @@ const (
|
|||||||
TypeGift = "gift"
|
TypeGift = "gift"
|
||||||
TypeMicSeatIcon = "mic_seat_icon"
|
TypeMicSeatIcon = "mic_seat_icon"
|
||||||
TypeMicSeatAnimation = "mic_seat_animation"
|
TypeMicSeatAnimation = "mic_seat_animation"
|
||||||
TypeEmojiPack = "emoji_pack"
|
// TypeRoomBorder 与 TypeRoomNameStyle 是房间级装扮目录类型;应用动作必须进入 Room Cell,
|
||||||
|
// 不能复用 user_resource_equipment 把房间状态错误地挂到用户全局 appearance。
|
||||||
|
TypeRoomBorder = "room_border"
|
||||||
|
TypeRoomNameStyle = "room_name_style"
|
||||||
|
TypeEmojiPack = "emoji_pack"
|
||||||
// TypeVIPTrialCard 是可在背包中独立计时、单选佩戴的 VIP 体验卡。
|
// TypeVIPTrialCard 是可在背包中独立计时、单选佩戴的 VIP 体验卡。
|
||||||
// VIP 等级和有效期事实仍由 user_vip_trial_cards 保存,资源目录只负责素材和通用背包展示。
|
// VIP 等级和有效期事实仍由 user_vip_trial_cards 保存,资源目录只负责素材和通用背包展示。
|
||||||
TypeVIPTrialCard = "vip_trial_card"
|
TypeVIPTrialCard = "vip_trial_card"
|
||||||
@ -36,10 +40,14 @@ const (
|
|||||||
GrantSourceAchievement = "achievement"
|
GrantSourceAchievement = "achievement"
|
||||||
GrantSourceResourceShop = "resource_shop"
|
GrantSourceResourceShop = "resource_shop"
|
||||||
GrantSourceGameRobotInit = "game_robot_init"
|
GrantSourceGameRobotInit = "game_robot_init"
|
||||||
GrantStatusDone = "succeeded"
|
// GrantSourceVIPPaid 与 GrantSourceVIPTrial 把两种 VIP 生效来源的 entitlement
|
||||||
GrantStatusRevoked = "revoked"
|
// 完全隔离;任一来源到期或撤销都不能缩短另一来源仍有效的装扮权益。
|
||||||
ResultWalletCredit = "wallet_credit"
|
GrantSourceVIPPaid = "vip_paid"
|
||||||
ResultEntitlement = "entitlement"
|
GrantSourceVIPTrial = "vip_trial"
|
||||||
|
GrantStatusDone = "succeeded"
|
||||||
|
GrantStatusRevoked = "revoked"
|
||||||
|
ResultWalletCredit = "wallet_credit"
|
||||||
|
ResultEntitlement = "entitlement"
|
||||||
|
|
||||||
GroupItemTypeResource = "resource"
|
GroupItemTypeResource = "resource"
|
||||||
GroupItemTypeWalletAsset = "wallet_asset"
|
GroupItemTypeWalletAsset = "wallet_asset"
|
||||||
|
|||||||
@ -40,15 +40,24 @@ type RevokeUserResourceCommand struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type EquipUserResourceCommand struct {
|
type EquipUserResourceCommand struct {
|
||||||
// RequestID 在通用背包命中 VIP 体验卡时作为专用状态机的 outbox 幂等事件键;
|
// RequestID 只用于链路追踪;不能作为客户端网络重试的业务幂等键。
|
||||||
// 普通装扮仍不把 request_id 当业务幂等键。
|
RequestID string
|
||||||
RequestID string
|
// CommandID 是装备状态变更的持久幂等键。同键不同 payload 必须返回 IDEMPOTENCY_CONFLICT。
|
||||||
|
CommandID string
|
||||||
AppCode string
|
AppCode string
|
||||||
UserID int64
|
UserID int64
|
||||||
ResourceID int64
|
ResourceID int64
|
||||||
EntitlementID string
|
EntitlementID string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// VipBenefitRequirement 描述一个资源由 tiered VIP 权益解锁的最低门槛。
|
||||||
|
// 同一资源若被多个权益引用,装备时满足任一 active effective benefit 即可。
|
||||||
|
type VipBenefitRequirement struct {
|
||||||
|
BenefitCode string
|
||||||
|
RequiredLevel int32
|
||||||
|
ResourceType string
|
||||||
|
}
|
||||||
|
|
||||||
type UnequipUserResourceCommand struct {
|
type UnequipUserResourceCommand struct {
|
||||||
// RequestID 仅在 vip_trial_card 委托专用卸下流程时用于发布完整 VIP 状态变更事件。
|
// RequestID 仅在 vip_trial_card 委托专用卸下流程时用于发布完整 VIP 状态变更事件。
|
||||||
RequestID string
|
RequestID string
|
||||||
|
|||||||
@ -123,7 +123,7 @@ func NormalizeGrantSource(value string) string {
|
|||||||
|
|
||||||
func ValidResourceType(value string) bool {
|
func ValidResourceType(value string) bool {
|
||||||
switch strings.ToLower(strings.TrimSpace(value)) {
|
switch strings.ToLower(strings.TrimSpace(value)) {
|
||||||
case TypeAvatarFrame, TypeProfileCard, TypeCoin, TypeVehicle, TypeChatBubble, TypeBadge, TypeFloatingScreen, TypeGift, TypeMicSeatIcon, TypeMicSeatAnimation, TypeEmojiPack, TypeVIPTrialCard:
|
case TypeAvatarFrame, TypeProfileCard, TypeCoin, TypeVehicle, TypeChatBubble, TypeBadge, TypeFloatingScreen, TypeGift, TypeMicSeatIcon, TypeMicSeatAnimation, TypeRoomBorder, TypeRoomNameStyle, TypeEmojiPack, TypeVIPTrialCard:
|
||||||
return true
|
return true
|
||||||
default:
|
default:
|
||||||
return false
|
return false
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user