vip gif图,排行榜分区
This commit is contained in:
parent
b4655c1326
commit
772cb71a46
@ -5712,7 +5712,7 @@ func (x *ApplyRoomDecorationResponse) GetServerTimeMs() int64 {
|
||||
type AuthorizeRoomMediaUploadRequest struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
Meta *RequestMeta `protobuf:"bytes,1,opt,name=meta,proto3" json:"meta,omitempty"`
|
||||
// purpose 仅接受 room_background/room_image;授权发生在 COS PutObject 之前。
|
||||
// purpose 接受 room_background/room_image/room_cover;授权发生在 COS PutObject 之前。
|
||||
Purpose string `protobuf:"bytes,2,opt,name=purpose,proto3" json:"purpose,omitempty"`
|
||||
Media *RoomMedia `protobuf:"bytes,3,opt,name=media,proto3" json:"media,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
@ -5777,7 +5777,9 @@ type AuthorizeRoomMediaUploadResponse struct {
|
||||
RoomVersion int64 `protobuf:"varint,3,opt,name=room_version,json=roomVersion,proto3" json:"room_version,omitempty"`
|
||||
ServerTimeMs int64 `protobuf:"varint,4,opt,name=server_time_ms,json=serverTimeMs,proto3" json:"server_time_ms,omitempty"`
|
||||
// object_key 由 room owner 按首次 command_id+媒体哈希确定,gateway 不能自行换 key。
|
||||
ObjectKey string `protobuf:"bytes,5,opt,name=object_key,json=objectKey,proto3" json:"object_key,omitempty"`
|
||||
ObjectKey string `protobuf:"bytes,5,opt,name=object_key,json=objectKey,proto3" json:"object_key,omitempty"`
|
||||
// cover_upload_id 仅 room_cover 返回;创建/更新房间必须提交该凭证,不能提交上传 URL。
|
||||
CoverUploadId string `protobuf:"bytes,6,opt,name=cover_upload_id,json=coverUploadId,proto3" json:"cover_upload_id,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
@ -5847,6 +5849,13 @@ func (x *AuthorizeRoomMediaUploadResponse) GetObjectKey() string {
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *AuthorizeRoomMediaUploadResponse) GetCoverUploadId() string {
|
||||
if x != nil {
|
||||
return x.CoverUploadId
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
type CompleteRoomMediaUploadRequest struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
Meta *RequestMeta `protobuf:"bytes,1,opt,name=meta,proto3" json:"meta,omitempty"`
|
||||
@ -5909,10 +5918,12 @@ func (x *CompleteRoomMediaUploadRequest) GetMedia() *RoomMedia {
|
||||
}
|
||||
|
||||
type CompleteRoomMediaUploadResponse struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
Active bool `protobuf:"varint,1,opt,name=active,proto3" json:"active,omitempty"`
|
||||
Media *RoomMedia `protobuf:"bytes,2,opt,name=media,proto3" json:"media,omitempty"`
|
||||
ServerTimeMs int64 `protobuf:"varint,3,opt,name=server_time_ms,json=serverTimeMs,proto3" json:"server_time_ms,omitempty"`
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
Active bool `protobuf:"varint,1,opt,name=active,proto3" json:"active,omitempty"`
|
||||
Media *RoomMedia `protobuf:"bytes,2,opt,name=media,proto3" json:"media,omitempty"`
|
||||
ServerTimeMs int64 `protobuf:"varint,3,opt,name=server_time_ms,json=serverTimeMs,proto3" json:"server_time_ms,omitempty"`
|
||||
// cover_upload_id 仅 room_cover 返回,并与 Authorize 首次生成的凭证完全一致。
|
||||
CoverUploadId string `protobuf:"bytes,4,opt,name=cover_upload_id,json=coverUploadId,proto3" json:"cover_upload_id,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
@ -5968,6 +5979,13 @@ func (x *CompleteRoomMediaUploadResponse) GetServerTimeMs() int64 {
|
||||
return 0
|
||||
}
|
||||
|
||||
func (x *CompleteRoomMediaUploadResponse) GetCoverUploadId() string {
|
||||
if x != nil {
|
||||
return x.CoverUploadId
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
type SendRoomImageRequest struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
Meta *RequestMeta `protobuf:"bytes,1,opt,name=meta,proto3" json:"meta,omitempty"`
|
||||
@ -6113,7 +6131,7 @@ func (x *SendRoomImageResponse) GetImage() *RoomMedia {
|
||||
}
|
||||
|
||||
// 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 和 cover_upload_id。
|
||||
// 同一个 meta.actor_user_id 只能作为 owner 创建一个房间;重复创建返回 Conflict。
|
||||
type CreateRoomRequest struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
@ -6126,7 +6144,7 @@ type CreateRoomRequest struct {
|
||||
VisibleRegionId int64 `protobuf:"varint,4,opt,name=visible_region_id,json=visibleRegionId,proto3" json:"visible_region_id,omitempty"`
|
||||
// room_name 是客户端创建房间时填写的展示名称,room-service 会写入 RoomSnapshot.room_ext["title"]。
|
||||
RoomName string `protobuf:"bytes,5,opt,name=room_name,json=roomName,proto3" json:"room_name,omitempty"`
|
||||
// room_avatar 是客户端上传的房间封面;创建时必填,room-service 会写入 RoomSnapshot.room_ext["cover_url"]。
|
||||
// room_avatar 仅保留 legacy_timed App 的历史协议;权益制 App 禁止客户端直接提交 URL。
|
||||
RoomAvatar string `protobuf:"bytes,6,opt,name=room_avatar,json=roomAvatar,proto3" json:"room_avatar,omitempty"`
|
||||
// room_description 是房间简介,只作为房间扩展资料保存,不参与 Room Cell 高频状态决策。
|
||||
RoomDescription string `protobuf:"bytes,7,opt,name=room_description,json=roomDescription,proto3" json:"room_description,omitempty"`
|
||||
@ -6137,7 +6155,9 @@ type CreateRoomRequest struct {
|
||||
// robot_room 只供内部机器人房间编排器设置;gateway 不接受客户端提交该字段。
|
||||
RobotRoom bool `protobuf:"varint,10,opt,name=robot_room,json=robotRoom,proto3" json:"robot_room,omitempty"`
|
||||
// robot_user_ids 是该机器人房间允许参与机器人送礼和虚拟上麦的用户集合。
|
||||
RobotUserIds []int64 `protobuf:"varint,11,rep,packed,name=robot_user_ids,json=robotUserIds,proto3" json:"robot_user_ids,omitempty"`
|
||||
RobotUserIds []int64 `protobuf:"varint,11,rep,packed,name=robot_user_ids,json=robotUserIds,proto3" json:"robot_user_ids,omitempty"`
|
||||
// cover_upload_id 来自 POST /api/v1/rooms/covers/upload,消费后不能用于另一间房或另一次资料更新。
|
||||
CoverUploadId string `protobuf:"bytes,12,opt,name=cover_upload_id,json=coverUploadId,proto3" json:"cover_upload_id,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
@ -6249,6 +6269,13 @@ func (x *CreateRoomRequest) GetRobotUserIds() []int64 {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (x *CreateRoomRequest) GetCoverUploadId() string {
|
||||
if x != nil {
|
||||
return x.CoverUploadId
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// CreateRoomResponse 返回新建后的房间快照。
|
||||
type CreateRoomResponse struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
@ -6305,14 +6332,17 @@ func (x *CreateRoomResponse) GetRoom() *RoomSnapshot {
|
||||
// UpdateRoomProfileRequest 修改房间展示资料和麦位数量。
|
||||
// 该命令只允许当前在房间 presence 内的 owner 执行,座位数必须命中后台启用配置。
|
||||
type UpdateRoomProfileRequest struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
Meta *RequestMeta `protobuf:"bytes,1,opt,name=meta,proto3" json:"meta,omitempty"`
|
||||
RoomName *string `protobuf:"bytes,2,opt,name=room_name,json=roomName,proto3,oneof" json:"room_name,omitempty"`
|
||||
RoomAvatar *string `protobuf:"bytes,3,opt,name=room_avatar,json=roomAvatar,proto3,oneof" json:"room_avatar,omitempty"`
|
||||
RoomDescription *string `protobuf:"bytes,4,opt,name=room_description,json=roomDescription,proto3,oneof" json:"room_description,omitempty"`
|
||||
SeatCount *int32 `protobuf:"varint,5,opt,name=seat_count,json=seatCount,proto3,oneof" json:"seat_count,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
Meta *RequestMeta `protobuf:"bytes,1,opt,name=meta,proto3" json:"meta,omitempty"`
|
||||
RoomName *string `protobuf:"bytes,2,opt,name=room_name,json=roomName,proto3,oneof" json:"room_name,omitempty"`
|
||||
// room_avatar 仅保留 legacy_timed App 的历史协议;权益制 App 使用 cover_upload_id。
|
||||
RoomAvatar *string `protobuf:"bytes,3,opt,name=room_avatar,json=roomAvatar,proto3,oneof" json:"room_avatar,omitempty"`
|
||||
RoomDescription *string `protobuf:"bytes,4,opt,name=room_description,json=roomDescription,proto3,oneof" json:"room_description,omitempty"`
|
||||
SeatCount *int32 `protobuf:"varint,5,opt,name=seat_count,json=seatCount,proto3,oneof" json:"seat_count,omitempty"`
|
||||
// cover_upload_id 与 room_avatar 互斥;room-service 在资料命令事务内消费该凭证。
|
||||
CoverUploadId *string `protobuf:"bytes,6,opt,name=cover_upload_id,json=coverUploadId,proto3,oneof" json:"cover_upload_id,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
|
||||
func (x *UpdateRoomProfileRequest) Reset() {
|
||||
@ -6380,6 +6410,13 @@ func (x *UpdateRoomProfileRequest) GetSeatCount() int32 {
|
||||
return 0
|
||||
}
|
||||
|
||||
func (x *UpdateRoomProfileRequest) GetCoverUploadId() string {
|
||||
if x != nil && x.CoverUploadId != nil {
|
||||
return *x.CoverUploadId
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// UpdateRoomProfileResponse 返回修改后的最新房间快照。
|
||||
type UpdateRoomProfileResponse struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
@ -11442,11 +11479,13 @@ func (x *ListRoomFeedsRequest) GetRelatedUsers() []*RoomFeedRelatedUser {
|
||||
|
||||
// ListRoomGiftLeaderboardRequest 查询当前 UTC 周期内的房间金币消耗榜。
|
||||
type ListRoomGiftLeaderboardRequest struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
Meta *RequestMeta `protobuf:"bytes,1,opt,name=meta,proto3" json:"meta,omitempty"`
|
||||
Period string `protobuf:"bytes,2,opt,name=period,proto3" json:"period,omitempty"`
|
||||
Page int32 `protobuf:"varint,3,opt,name=page,proto3" json:"page,omitempty"`
|
||||
PageSize int32 `protobuf:"varint,4,opt,name=page_size,json=pageSize,proto3" json:"page_size,omitempty"`
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
Meta *RequestMeta `protobuf:"bytes,1,opt,name=meta,proto3" json:"meta,omitempty"`
|
||||
Period string `protobuf:"bytes,2,opt,name=period,proto3" json:"period,omitempty"`
|
||||
Page int32 `protobuf:"varint,3,opt,name=page,proto3" json:"page,omitempty"`
|
||||
PageSize int32 `protobuf:"varint,4,opt,name=page_size,json=pageSize,proto3" json:"page_size,omitempty"`
|
||||
// region_id 由 gateway 从登录用户资料注入;Lalu/Fami 必填,客户端 query 不可信。
|
||||
RegionId int64 `protobuf:"varint,5,opt,name=region_id,json=regionId,proto3" json:"region_id,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
@ -11509,6 +11548,13 @@ func (x *ListRoomGiftLeaderboardRequest) GetPageSize() int32 {
|
||||
return 0
|
||||
}
|
||||
|
||||
func (x *ListRoomGiftLeaderboardRequest) GetRegionId() int64 {
|
||||
if x != nil {
|
||||
return x.RegionId
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// RoomFeedRelatedUser 是 Mine 关系房间流的排序输入,不让 room-service 持有好友/关注事实。
|
||||
type RoomFeedRelatedUser struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
@ -14454,22 +14500,24 @@ const file_proto_room_v1_room_proto_rawDesc = "" +
|
||||
"\x1fAuthorizeRoomMediaUploadRequest\x12.\n" +
|
||||
"\x04meta\x18\x01 \x01(\v2\x1a.hyapp.room.v1.RequestMetaR\x04meta\x12\x18\n" +
|
||||
"\apurpose\x18\x02 \x01(\tR\apurpose\x12.\n" +
|
||||
"\x05media\x18\x03 \x01(\v2\x18.hyapp.room.v1.RoomMediaR\x05media\"\xd0\x01\n" +
|
||||
"\x05media\x18\x03 \x01(\v2\x18.hyapp.room.v1.RoomMediaR\x05media\"\xf8\x01\n" +
|
||||
" AuthorizeRoomMediaUploadResponse\x12\x18\n" +
|
||||
"\aallowed\x18\x01 \x01(\bR\aallowed\x12*\n" +
|
||||
"\x11object_key_prefix\x18\x02 \x01(\tR\x0fobjectKeyPrefix\x12!\n" +
|
||||
"\froom_version\x18\x03 \x01(\x03R\vroomVersion\x12$\n" +
|
||||
"\x0eserver_time_ms\x18\x04 \x01(\x03R\fserverTimeMs\x12\x1d\n" +
|
||||
"\n" +
|
||||
"object_key\x18\x05 \x01(\tR\tobjectKey\"\x9a\x01\n" +
|
||||
"object_key\x18\x05 \x01(\tR\tobjectKey\x12&\n" +
|
||||
"\x0fcover_upload_id\x18\x06 \x01(\tR\rcoverUploadId\"\x9a\x01\n" +
|
||||
"\x1eCompleteRoomMediaUploadRequest\x12.\n" +
|
||||
"\x04meta\x18\x01 \x01(\v2\x1a.hyapp.room.v1.RequestMetaR\x04meta\x12\x18\n" +
|
||||
"\apurpose\x18\x02 \x01(\tR\apurpose\x12.\n" +
|
||||
"\x05media\x18\x03 \x01(\v2\x18.hyapp.room.v1.RoomMediaR\x05media\"\x8f\x01\n" +
|
||||
"\x05media\x18\x03 \x01(\v2\x18.hyapp.room.v1.RoomMediaR\x05media\"\xb7\x01\n" +
|
||||
"\x1fCompleteRoomMediaUploadResponse\x12\x16\n" +
|
||||
"\x06active\x18\x01 \x01(\bR\x06active\x12.\n" +
|
||||
"\x05media\x18\x02 \x01(\v2\x18.hyapp.room.v1.RoomMediaR\x05media\x12$\n" +
|
||||
"\x0eserver_time_ms\x18\x03 \x01(\x03R\fserverTimeMs\"v\n" +
|
||||
"\x0eserver_time_ms\x18\x03 \x01(\x03R\fserverTimeMs\x12&\n" +
|
||||
"\x0fcover_upload_id\x18\x04 \x01(\tR\rcoverUploadId\"v\n" +
|
||||
"\x14SendRoomImageRequest\x12.\n" +
|
||||
"\x04meta\x18\x01 \x01(\v2\x1a.hyapp.room.v1.RequestMetaR\x04meta\x12.\n" +
|
||||
"\x05image\x18\x02 \x01(\v2\x18.hyapp.room.v1.RoomMediaR\x05image\"\x9d\x02\n" +
|
||||
@ -14481,7 +14529,7 @@ const file_proto_room_v1_room_proto_rawDesc = "" +
|
||||
"\aroom_id\x18\x04 \x01(\tR\x06roomId\x12$\n" +
|
||||
"\x0esender_user_id\x18\x05 \x01(\x03R\fsenderUserId\x12$\n" +
|
||||
"\x0eserver_time_ms\x18\x06 \x01(\x03R\fserverTimeMs\x12.\n" +
|
||||
"\x05image\x18\a \x01(\v2\x18.hyapp.room.v1.RoomMediaR\x05image\"\xa2\x03\n" +
|
||||
"\x05image\x18\a \x01(\v2\x18.hyapp.room.v1.RoomMediaR\x05image\"\xca\x03\n" +
|
||||
"\x11CreateRoomRequest\x12.\n" +
|
||||
"\x04meta\x18\x01 \x01(\v2\x1a.hyapp.room.v1.RequestMetaR\x04meta\x12\x1d\n" +
|
||||
"\n" +
|
||||
@ -14497,10 +14545,11 @@ const file_proto_room_v1_room_proto_rawDesc = "" +
|
||||
"\n" +
|
||||
"robot_room\x18\n" +
|
||||
" \x01(\bR\trobotRoom\x12$\n" +
|
||||
"\x0erobot_user_ids\x18\v \x03(\x03R\frobotUserIds\"{\n" +
|
||||
"\x0erobot_user_ids\x18\v \x03(\x03R\frobotUserIds\x12&\n" +
|
||||
"\x0fcover_upload_id\x18\f \x01(\tR\rcoverUploadId\"{\n" +
|
||||
"\x12CreateRoomResponse\x124\n" +
|
||||
"\x06result\x18\x01 \x01(\v2\x1c.hyapp.room.v1.CommandResultR\x06result\x12/\n" +
|
||||
"\x04room\x18\x02 \x01(\v2\x1b.hyapp.room.v1.RoomSnapshotR\x04room\"\xa8\x02\n" +
|
||||
"\x04room\x18\x02 \x01(\v2\x1b.hyapp.room.v1.RoomSnapshotR\x04room\"\xe9\x02\n" +
|
||||
"\x18UpdateRoomProfileRequest\x12.\n" +
|
||||
"\x04meta\x18\x01 \x01(\v2\x1a.hyapp.room.v1.RequestMetaR\x04meta\x12 \n" +
|
||||
"\troom_name\x18\x02 \x01(\tH\x00R\broomName\x88\x01\x01\x12$\n" +
|
||||
@ -14508,12 +14557,14 @@ const file_proto_room_v1_room_proto_rawDesc = "" +
|
||||
"roomAvatar\x88\x01\x01\x12.\n" +
|
||||
"\x10room_description\x18\x04 \x01(\tH\x02R\x0froomDescription\x88\x01\x01\x12\"\n" +
|
||||
"\n" +
|
||||
"seat_count\x18\x05 \x01(\x05H\x03R\tseatCount\x88\x01\x01B\f\n" +
|
||||
"seat_count\x18\x05 \x01(\x05H\x03R\tseatCount\x88\x01\x01\x12+\n" +
|
||||
"\x0fcover_upload_id\x18\x06 \x01(\tH\x04R\rcoverUploadId\x88\x01\x01B\f\n" +
|
||||
"\n" +
|
||||
"_room_nameB\x0e\n" +
|
||||
"\f_room_avatarB\x13\n" +
|
||||
"\x11_room_descriptionB\r\n" +
|
||||
"\v_seat_count\"\x82\x01\n" +
|
||||
"\v_seat_countB\x12\n" +
|
||||
"\x10_cover_upload_id\"\x82\x01\n" +
|
||||
"\x19UpdateRoomProfileResponse\x124\n" +
|
||||
"\x06result\x18\x01 \x01(\v2\x1c.hyapp.room.v1.CommandResultR\x06result\x12/\n" +
|
||||
"\x04room\x18\x02 \x01(\v2\x1b.hyapp.room.v1.RoomSnapshotR\x04room\"\xc7\x02\n" +
|
||||
@ -14940,12 +14991,13 @@ const file_proto_room_v1_room_proto_rawDesc = "" +
|
||||
"\x06cursor\x18\x05 \x01(\tR\x06cursor\x12\x14\n" +
|
||||
"\x05limit\x18\x06 \x01(\x05R\x05limit\x12\x14\n" +
|
||||
"\x05query\x18\a \x01(\tR\x05query\x12G\n" +
|
||||
"\rrelated_users\x18\b \x03(\v2\".hyapp.room.v1.RoomFeedRelatedUserR\frelatedUsers\"\x99\x01\n" +
|
||||
"\rrelated_users\x18\b \x03(\v2\".hyapp.room.v1.RoomFeedRelatedUserR\frelatedUsers\"\xb6\x01\n" +
|
||||
"\x1eListRoomGiftLeaderboardRequest\x12.\n" +
|
||||
"\x04meta\x18\x01 \x01(\v2\x1a.hyapp.room.v1.RequestMetaR\x04meta\x12\x16\n" +
|
||||
"\x06period\x18\x02 \x01(\tR\x06period\x12\x12\n" +
|
||||
"\x04page\x18\x03 \x01(\x05R\x04page\x12\x1b\n" +
|
||||
"\tpage_size\x18\x04 \x01(\x05R\bpageSize\"c\n" +
|
||||
"\tpage_size\x18\x04 \x01(\x05R\bpageSize\x12\x1b\n" +
|
||||
"\tregion_id\x18\x05 \x01(\x03R\bregionId\"c\n" +
|
||||
"\x13RoomFeedRelatedUser\x12\x17\n" +
|
||||
"\auser_id\x18\x01 \x01(\x03R\x06userId\x123\n" +
|
||||
"\x16relation_updated_at_ms\x18\x02 \x01(\x03R\x13relationUpdatedAtMs\"\xbd\x05\n" +
|
||||
|
||||
@ -667,7 +667,7 @@ message ApplyRoomDecorationResponse {
|
||||
|
||||
message AuthorizeRoomMediaUploadRequest {
|
||||
RequestMeta meta = 1;
|
||||
// purpose 仅接受 room_background/room_image;授权发生在 COS PutObject 之前。
|
||||
// purpose 接受 room_background/room_image/room_cover;授权发生在 COS PutObject 之前。
|
||||
string purpose = 2;
|
||||
RoomMedia media = 3;
|
||||
}
|
||||
@ -679,6 +679,8 @@ message AuthorizeRoomMediaUploadResponse {
|
||||
int64 server_time_ms = 4;
|
||||
// object_key 由 room owner 按首次 command_id+媒体哈希确定,gateway 不能自行换 key。
|
||||
string object_key = 5;
|
||||
// cover_upload_id 仅 room_cover 返回;创建/更新房间必须提交该凭证,不能提交上传 URL。
|
||||
string cover_upload_id = 6;
|
||||
}
|
||||
|
||||
message CompleteRoomMediaUploadRequest {
|
||||
@ -692,6 +694,8 @@ message CompleteRoomMediaUploadResponse {
|
||||
bool active = 1;
|
||||
RoomMedia media = 2;
|
||||
int64 server_time_ms = 3;
|
||||
// cover_upload_id 仅 room_cover 返回,并与 Authorize 首次生成的凭证完全一致。
|
||||
string cover_upload_id = 4;
|
||||
}
|
||||
|
||||
message SendRoomImageRequest {
|
||||
@ -710,7 +714,7 @@ message SendRoomImageResponse {
|
||||
}
|
||||
|
||||
// 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 和 cover_upload_id。
|
||||
// 同一个 meta.actor_user_id 只能作为 owner 创建一个房间;重复创建返回 Conflict。
|
||||
message CreateRoomRequest {
|
||||
RequestMeta meta = 1;
|
||||
@ -722,7 +726,7 @@ message CreateRoomRequest {
|
||||
int64 visible_region_id = 4;
|
||||
// room_name 是客户端创建房间时填写的展示名称,room-service 会写入 RoomSnapshot.room_ext["title"]。
|
||||
string room_name = 5;
|
||||
// room_avatar 是客户端上传的房间封面;创建时必填,room-service 会写入 RoomSnapshot.room_ext["cover_url"]。
|
||||
// room_avatar 仅保留 legacy_timed App 的历史协议;权益制 App 禁止客户端直接提交 URL。
|
||||
string room_avatar = 6;
|
||||
// room_description 是房间简介,只作为房间扩展资料保存,不参与 Room Cell 高频状态决策。
|
||||
string room_description = 7;
|
||||
@ -734,6 +738,8 @@ message CreateRoomRequest {
|
||||
bool robot_room = 10;
|
||||
// robot_user_ids 是该机器人房间允许参与机器人送礼和虚拟上麦的用户集合。
|
||||
repeated int64 robot_user_ids = 11;
|
||||
// cover_upload_id 来自 POST /api/v1/rooms/covers/upload,消费后不能用于另一间房或另一次资料更新。
|
||||
string cover_upload_id = 12;
|
||||
}
|
||||
|
||||
// CreateRoomResponse 返回新建后的房间快照。
|
||||
@ -747,9 +753,12 @@ message CreateRoomResponse {
|
||||
message UpdateRoomProfileRequest {
|
||||
RequestMeta meta = 1;
|
||||
optional string room_name = 2;
|
||||
// room_avatar 仅保留 legacy_timed App 的历史协议;权益制 App 使用 cover_upload_id。
|
||||
optional string room_avatar = 3;
|
||||
optional string room_description = 4;
|
||||
optional int32 seat_count = 5;
|
||||
// cover_upload_id 与 room_avatar 互斥;room-service 在资料命令事务内消费该凭证。
|
||||
optional string cover_upload_id = 6;
|
||||
}
|
||||
|
||||
// UpdateRoomProfileResponse 返回修改后的最新房间快照。
|
||||
@ -1400,6 +1409,8 @@ message ListRoomGiftLeaderboardRequest {
|
||||
string period = 2;
|
||||
int32 page = 3;
|
||||
int32 page_size = 4;
|
||||
// region_id 由 gateway 从登录用户资料注入;Lalu/Fami 必填,客户端 query 不可信。
|
||||
int64 region_id = 5;
|
||||
}
|
||||
|
||||
// RoomFeedRelatedUser 是 Mine 关系房间流的排序输入,不让 room-service 持有好友/关注事实。
|
||||
|
||||
245
docs/flutter对接/Fami_VIP设置开关Flutter接口对接文档.md
Normal file
245
docs/flutter对接/Fami_VIP设置开关Flutter接口对接文档.md
Normal file
@ -0,0 +1,245 @@
|
||||
# Fami VIP 设置接口对接文档
|
||||
|
||||
## 1. 接口范围
|
||||
|
||||
本文档只描述 Fami VIP 设置相关接口、请求参数和返回字段。
|
||||
|
||||
## 2. 设置字段
|
||||
|
||||
| VIP 等级 | 设置项 | `benefit_code` | 接口字段 | 无记录默认值 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| VIP6 | 隐藏个人数据 | `hide_profile_data` | `hide_profile_data_enabled` | `false` |
|
||||
| VIP6 | 榜单隐身 | `leaderboard_invisible` | `leaderboard_invisible_enabled` | `false` |
|
||||
| VIP7 | 匿名访问主页 | `anonymous_profile_visit` | `anonymous_profile_visit_enabled` | `false` |
|
||||
| VIP8 | 进房全服通知 | `room_entry_notice` | `room_entry_notice_enabled` | `true` |
|
||||
| VIP9 | 上线全服通知 | `online_global_notice` | `online_global_notice_enabled` | `true` |
|
||||
|
||||
`updated_at_ms = 0` 表示用户尚未修改,当前返回的是默认值。
|
||||
|
||||
## 3. 公共请求约定
|
||||
|
||||
### 3.1 请求头
|
||||
|
||||
```http
|
||||
Authorization: Bearer {access_token}
|
||||
X-App-Code: fami
|
||||
Content-Type: application/json
|
||||
```
|
||||
|
||||
### 3.2 统一响应结构
|
||||
|
||||
```json
|
||||
{
|
||||
"code": "OK",
|
||||
"message": "OK",
|
||||
"data": {},
|
||||
"request_id": "req_xxx"
|
||||
}
|
||||
```
|
||||
|
||||
接口成功条件为 `code == "OK"`。
|
||||
|
||||
## 4. 查询当前用户设置
|
||||
|
||||
### 4.1 请求
|
||||
|
||||
```http
|
||||
GET /api/v1/vip/settings
|
||||
```
|
||||
|
||||
请求体:无。
|
||||
|
||||
### 4.2 成功响应
|
||||
|
||||
```json
|
||||
{
|
||||
"code": "OK",
|
||||
"message": "OK",
|
||||
"data": {
|
||||
"settings": {
|
||||
"app_code": "fami",
|
||||
"user_id": 10001,
|
||||
"room_entry_notice_enabled": true,
|
||||
"online_global_notice_enabled": true,
|
||||
"hide_profile_data_enabled": false,
|
||||
"anonymous_profile_visit_enabled": false,
|
||||
"leaderboard_invisible_enabled": false,
|
||||
"updated_at_ms": 0
|
||||
},
|
||||
"evaluated_at_ms": 1784563200000
|
||||
},
|
||||
"request_id": "req_vip_settings_get"
|
||||
}
|
||||
```
|
||||
|
||||
## 5. 修改当前用户设置
|
||||
|
||||
### 5.1 请求
|
||||
|
||||
```http
|
||||
PATCH /api/v1/vip/settings
|
||||
```
|
||||
|
||||
请求体支持以下可选字段:
|
||||
|
||||
```json
|
||||
{
|
||||
"room_entry_notice_enabled": true,
|
||||
"online_global_notice_enabled": true,
|
||||
"hide_profile_data_enabled": false,
|
||||
"anonymous_profile_visit_enabled": false,
|
||||
"leaderboard_invisible_enabled": false
|
||||
}
|
||||
```
|
||||
|
||||
PATCH 规则:
|
||||
|
||||
- 每次请求至少传一个设置字段。
|
||||
- 未传字段保持原值。
|
||||
- 显式传 `false` 表示关闭该设置。
|
||||
- 不支持修改其他用户的设置。
|
||||
|
||||
单字段请求示例:
|
||||
|
||||
```json
|
||||
{
|
||||
"hide_profile_data_enabled": true
|
||||
}
|
||||
```
|
||||
|
||||
### 5.2 成功响应
|
||||
|
||||
修改成功后返回完整设置对象:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": "OK",
|
||||
"message": "OK",
|
||||
"data": {
|
||||
"settings": {
|
||||
"app_code": "fami",
|
||||
"user_id": 10001,
|
||||
"room_entry_notice_enabled": true,
|
||||
"online_global_notice_enabled": true,
|
||||
"hide_profile_data_enabled": true,
|
||||
"anonymous_profile_visit_enabled": false,
|
||||
"leaderboard_invisible_enabled": false,
|
||||
"updated_at_ms": 1784563200123
|
||||
},
|
||||
"server_time_ms": 1784563200150
|
||||
},
|
||||
"request_id": "req_vip_settings_patch"
|
||||
}
|
||||
```
|
||||
|
||||
## 6. VIP 用户信息接口返回设置
|
||||
|
||||
### 6.1 请求
|
||||
|
||||
```http
|
||||
GET /api/v1/vip/me
|
||||
```
|
||||
|
||||
设置字段路径:
|
||||
|
||||
```text
|
||||
data.state.user_settings
|
||||
```
|
||||
|
||||
精简响应示例:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": "OK",
|
||||
"data": {
|
||||
"state": {
|
||||
"effective_vip": {
|
||||
"level": 8,
|
||||
"name": "VIP8",
|
||||
"active": true
|
||||
},
|
||||
"effective_benefits": [
|
||||
{
|
||||
"benefit_code": "room_entry_notice",
|
||||
"status": "active"
|
||||
}
|
||||
],
|
||||
"user_settings": {
|
||||
"app_code": "fami",
|
||||
"user_id": 10001,
|
||||
"room_entry_notice_enabled": true,
|
||||
"online_global_notice_enabled": true,
|
||||
"hide_profile_data_enabled": false,
|
||||
"anonymous_profile_visit_enabled": false,
|
||||
"leaderboard_invisible_enabled": false,
|
||||
"updated_at_ms": 0
|
||||
}
|
||||
}
|
||||
},
|
||||
"request_id": "req_vip_me"
|
||||
}
|
||||
```
|
||||
|
||||
真实响应还包含 `paid_vip`、`equipped_trial_card`、`effective_source`、`evaluated_at_ms` 和 `program_config` 等字段。
|
||||
|
||||
## 7. 当前用户聚合信息返回设置
|
||||
|
||||
### 7.1 请求
|
||||
|
||||
```http
|
||||
GET /api/v1/users/me/overview
|
||||
```
|
||||
|
||||
设置字段路径:
|
||||
|
||||
```text
|
||||
data.vip.user_settings
|
||||
```
|
||||
|
||||
精简响应示例:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": "OK",
|
||||
"data": {
|
||||
"profile": {
|
||||
"user_id": "10001",
|
||||
"username": "Nina"
|
||||
},
|
||||
"vip": {
|
||||
"level": 8,
|
||||
"name": "VIP8",
|
||||
"active": true,
|
||||
"started_at_ms": 1780000000000,
|
||||
"expires_at_ms": 1782592000000,
|
||||
"user_settings": {
|
||||
"app_code": "fami",
|
||||
"user_id": 10001,
|
||||
"room_entry_notice_enabled": true,
|
||||
"online_global_notice_enabled": true,
|
||||
"hide_profile_data_enabled": false,
|
||||
"anonymous_profile_visit_enabled": false,
|
||||
"leaderboard_invisible_enabled": false,
|
||||
"updated_at_ms": 0
|
||||
}
|
||||
}
|
||||
},
|
||||
"request_id": "req_user_overview"
|
||||
}
|
||||
```
|
||||
|
||||
补充约定:
|
||||
|
||||
- `data.vip.unavailable == true` 表示本次 VIP 上游查询降级。
|
||||
- 他人公开资料接口 `GET /api/v1/users/by-id/{user_id}/profile` 不返回 `user_settings`。
|
||||
|
||||
## 8. 错误响应
|
||||
|
||||
| HTTP 状态码 | `code` | 说明 |
|
||||
| --- | --- | --- |
|
||||
| 400 | `INVALID_ARGUMENT` | 请求体为空、字段名错误或字段类型错误。 |
|
||||
| 401 | `UNAUTHORIZED` | Access Token 无效或已过期。 |
|
||||
| 403 | `FORBIDDEN` | 无权跨 App 或修改其他用户设置。 |
|
||||
| 502 | `UPSTREAM_ERROR` | VIP/Wallet 上游服务异常。 |
|
||||
|
||||
错误处理应以稳定的 `code` 为准,不依赖 `message` 文案。
|
||||
205
docs/flutter对接/Fami_房间封面上传Flutter接口对接.md
Normal file
205
docs/flutter对接/Fami_房间封面上传Flutter接口对接.md
Normal file
@ -0,0 +1,205 @@
|
||||
# Fami 房间封面上传 Flutter 接口对接
|
||||
|
||||
## 接入结论
|
||||
|
||||
Fami 创建房间和修改房间封面前,必须先调用 `POST /api/v1/rooms/covers/upload`。上传成功后只把 `cover_upload_id` 提交给创建/更新接口;响应里的 `url` 只用于本地预览,不能继续作为 `room_avatar` 提交。
|
||||
|
||||
服务端根据文件魔数识别实际格式,校验扩展名、multipart Content-Type、尺寸、字节数、帧数和动画时长。文件原始字节直接写 COS,不裁剪、不转码。动态 GIF/WebP 由服务端调用 Wallet `CheckVipBenefit(animated_room_cover)`;Fami 当前配置为 VIP4 解锁,Flutter 不得用本地 `vipLevel >= 4` 代替服务端判定。
|
||||
|
||||
## 1. 上传接口
|
||||
|
||||
```http
|
||||
POST /api/v1/rooms/covers/upload
|
||||
Authorization: Bearer {access_token}
|
||||
Content-Type: multipart/form-data
|
||||
|
||||
command_id={本次选择文件的上传幂等键}
|
||||
file={原始文件字节}
|
||||
```
|
||||
|
||||
约束:
|
||||
|
||||
| 项目 | 规则 |
|
||||
| --- | --- |
|
||||
| 格式 | JPEG、PNG、GIF、WebP;不支持 APNG |
|
||||
| 一致性 | 魔数、文件扩展名、multipart Content-Type 必须一致 |
|
||||
| 大小 | 最大 10 MiB,服务端读取字节数必须与 multipart 文件大小一致 |
|
||||
| 尺寸 | 宽、高均不超过 4096,总像素不超过 16,777,216 |
|
||||
| 动画 | 仅多帧 GIF/WebP;最多 120 帧,总时长 `(0, 15000]ms` |
|
||||
| 静态 | `frame_count=1`、`animated=false`、`duration_ms=0` |
|
||||
|
||||
同一次上传因超时重试时复用原 `command_id` 和同一文件;用户重新选择文件时生成新的 `command_id`。不要统一发送 `application/octet-stream`。
|
||||
|
||||
成功响应:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": "OK",
|
||||
"message": "ok",
|
||||
"request_id": "req_xxx",
|
||||
"data": {
|
||||
"cover_upload_id": "cover_12ab34cd56ef789012ab34cd56ef7890",
|
||||
"url": "https://cdn.example.com/room-media/fami/covers/10001/x.gif",
|
||||
"sha256": "64位小写十六进制",
|
||||
"frame_count": 18,
|
||||
"animated": true,
|
||||
"content_type": "image/gif",
|
||||
"format": "gif",
|
||||
"size_bytes": 1256789,
|
||||
"width": 640,
|
||||
"height": 640,
|
||||
"duration_ms": 3600,
|
||||
"server_time_ms": 1784592000000
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`url`、后缀和 `content_type` 都是服务端按实际格式生成的结果。Flutter 可以立即用 `url` 预览,但持久化房间封面只能使用 `cover_upload_id`。
|
||||
|
||||
## 2. 创建房间
|
||||
|
||||
```http
|
||||
POST /api/v1/rooms/create
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"command_id": "create_room_550e8400-e29b-41d4-a716-446655440000",
|
||||
"seat_count": 10,
|
||||
"mode": "voice",
|
||||
"room_name": "My Room",
|
||||
"cover_upload_id": "cover_12ab34cd56ef789012ab34cd56ef7890",
|
||||
"room_description": ""
|
||||
}
|
||||
```
|
||||
|
||||
Fami 不再提交 `room_avatar`。服务端会校验上传记录属于当前 `app_code + user_id`、状态为可消费,并在创建命令事务内绑定房间和推进 `consumed`。一个 `cover_upload_id` 不能创建两个房间。
|
||||
|
||||
## 3. 修改房间封面
|
||||
|
||||
```http
|
||||
POST /api/v1/rooms/profile/update
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"room_id": "fami_room_xxx",
|
||||
"command_id": "update_cover_550e8400-e29b-41d4-a716-446655440000",
|
||||
"cover_upload_id": "cover_98ab76cd54ef321098ab76cd54ef3210"
|
||||
}
|
||||
```
|
||||
|
||||
`cover_upload_id` 与历史字段 `room_avatar` 互斥。资料修改成功后凭证立即消费;HTTP 超时重试要同时复用原更新 `command_id` 和原 `cover_upload_id`。
|
||||
|
||||
## 4. Dio 示例
|
||||
|
||||
```dart
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:http_parser/http_parser.dart';
|
||||
|
||||
class RoomCoverUploadResult {
|
||||
const RoomCoverUploadResult({
|
||||
required this.coverUploadId,
|
||||
required this.url,
|
||||
required this.sha256,
|
||||
required this.frameCount,
|
||||
required this.animated,
|
||||
});
|
||||
|
||||
final String coverUploadId;
|
||||
final String url;
|
||||
final String sha256;
|
||||
final int frameCount;
|
||||
final bool animated;
|
||||
|
||||
factory RoomCoverUploadResult.fromJson(Map<String, dynamic> json) {
|
||||
return RoomCoverUploadResult(
|
||||
coverUploadId: json['cover_upload_id'] as String,
|
||||
url: json['url'] as String,
|
||||
sha256: json['sha256'] as String,
|
||||
frameCount: json['frame_count'] as int,
|
||||
animated: json['animated'] as bool,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
MediaType roomCoverMediaType(String extension) {
|
||||
switch (extension.toLowerCase()) {
|
||||
case 'jpg':
|
||||
case 'jpeg':
|
||||
return MediaType('image', 'jpeg');
|
||||
case 'png':
|
||||
return MediaType('image', 'png');
|
||||
case 'gif':
|
||||
return MediaType('image', 'gif');
|
||||
case 'webp':
|
||||
return MediaType('image', 'webp');
|
||||
default:
|
||||
throw ArgumentError('unsupported room cover extension');
|
||||
}
|
||||
}
|
||||
|
||||
Future<RoomCoverUploadResult> uploadRoomCover({
|
||||
required Dio dio,
|
||||
required String filePath,
|
||||
required String fileName,
|
||||
required String extension,
|
||||
required String uploadCommandId,
|
||||
}) async {
|
||||
final response = await dio.post<Map<String, dynamic>>(
|
||||
'/api/v1/rooms/covers/upload',
|
||||
data: FormData.fromMap({
|
||||
'command_id': uploadCommandId,
|
||||
'file': await MultipartFile.fromFile(
|
||||
filePath,
|
||||
filename: fileName,
|
||||
contentType: roomCoverMediaType(extension),
|
||||
),
|
||||
}),
|
||||
);
|
||||
final envelope = response.data!;
|
||||
if (envelope['code'] != 'OK') {
|
||||
throw StateError(envelope['code'] as String? ?? 'UPLOAD_FAILED');
|
||||
}
|
||||
return RoomCoverUploadResult.fromJson(envelope['data'] as Map<String, dynamic>);
|
||||
}
|
||||
```
|
||||
|
||||
选择图片时必须保留 GIF/WebP 原始字节。静态图若客户端裁剪后输出 JPEG,文件名应改成 `.jpg/.jpeg`,multipart MIME 应为 `image/jpeg`;不能保留原 `.png/.gif` 名称。
|
||||
|
||||
创建/更新参数示例:
|
||||
|
||||
```dart
|
||||
await dio.post('/api/v1/rooms/create', data: {
|
||||
'command_id': createCommandId,
|
||||
'seat_count': seatCount,
|
||||
'mode': mode,
|
||||
'room_name': roomName,
|
||||
'cover_upload_id': uploaded.coverUploadId,
|
||||
'room_description': description,
|
||||
});
|
||||
|
||||
await dio.post('/api/v1/rooms/profile/update', data: {
|
||||
'room_id': roomId,
|
||||
'command_id': updateCommandId,
|
||||
'cover_upload_id': uploaded.coverUploadId,
|
||||
});
|
||||
```
|
||||
|
||||
## 5. 错误处理
|
||||
|
||||
| HTTP / code | 含义 | Flutter 动作 |
|
||||
| --- | --- | --- |
|
||||
| `403 / VIP_BENEFIT_REQUIRED` | 动态封面缺少 `animated_room_cover` | 读取 `data.benefit_code`、`required_level`,展示升级入口;不要自行写死 VIP4 |
|
||||
| `413 / FILE_TOO_LARGE` | 超过 10 MiB | 保留选择页并提示重新选择 |
|
||||
| `415 / UNSUPPORTED_MEDIA_TYPE` | 魔数、扩展名、MIME 或文件结构不一致 | 不重试;重新选择或修正 multipart MIME |
|
||||
| `400 / INVALID_MEDIA_DIMENSIONS` | 尺寸、总像素、帧数或时长越界 | 不重试;重新选择 |
|
||||
| `409 / IDEMPOTENCY_CONFLICT` | 同一上传 `command_id` 换了文件 | 为新文件生成新 `command_id` |
|
||||
| `404 / NOT_FOUND` | 创建/更新时凭证不存在 | 重新上传,再提交新凭证 |
|
||||
| `403 / PERMISSION_DENIED` | 凭证不属于当前用户/租户,或不是房主 | 丢弃本地凭证并刷新房间权限 |
|
||||
|
||||
服务端错误是最终权限事实。本地 VIP 信息只能用于提前展示锁态,不能跳过上传请求或把动态 URL 直接交给创建/更新接口。
|
||||
|
||||
## 6. 发布与旧数据
|
||||
|
||||
发布顺序为后端接口和消费校验先上线,再发布 Flutter。客户端升级后,所有 Fami 房间封面都使用新接口。
|
||||
|
||||
历史上响应头为 `image/gif`、实际字节为单帧 PNG 的伪 GIF 已经没有动画帧,客户端无法恢复。后端修复上线后必须让用户重新上传原始 GIF/WebP,并按现有 CDN 管理流程刷新旧 URL 缓存;不要只改文件后缀或 Content-Type。
|
||||
@ -35,7 +35,7 @@ http://127.0.0.1:13000
|
||||
| `Authorization` | 是 | `Bearer <access_token>`。 |
|
||||
| `X-App-Code` | 否 | App 编码,默认 `lalu`;登录态接口最终以 token 内 app_code 为准。 |
|
||||
|
||||
所有 `*_ms` 都是 Unix epoch milliseconds。榜单统计窗口按 UTC 计算,不按用户本地时区、设备时区或 IP 推断时区计算。
|
||||
所有 `*_ms` 都是 Unix epoch milliseconds。榜单统计窗口按 UTC 计算,不按用户本地时区、设备时区或 IP 推断时区计算。Lalu/Fami 的财富、魅力、游戏和房间榜由 gateway 使用登录用户在 user-service 的当前 `region_id` 自动分区;客户端不传 `region_id`,也不能切换读取其他区域。
|
||||
|
||||
## 榜单接口
|
||||
|
||||
@ -71,7 +71,7 @@ Query 参数:
|
||||
| `week` | `weekly` | UTC 本周周一 00:00:00 到当前服务端时间。 |
|
||||
| `month` | `monthly` | UTC 本月 1 日 00:00:00 到当前服务端时间。 |
|
||||
|
||||
服务端实际查询范围是 `[start_at_ms, end_at_ms)`,其中 `end_at_ms` 是本次请求的当前服务端时间。用户榜按窗口累计值倒序;`game` 的累计值是 `game_spend_coin`,为兼容既有 JSON 暂时仍通过 `gift_value` 返回。房间榜由 room-service 按同一周期返回。
|
||||
服务端实际查询范围是 `[start_at_ms, end_at_ms)`,其中 `end_at_ms` 是本次请求的当前服务端时间。用户榜按窗口累计值倒序;`game` 的累计值是 `game_spend_coin`,为兼容既有 JSON 暂时仍通过 `gift_value` 返回。房间榜由 room-service 按同一周期返回。Lalu/Fami 响应中的 `region_id` 表示本次实际查询的区域,可用于客户端日志核对。
|
||||
|
||||
### 送礼榜
|
||||
|
||||
|
||||
@ -149,7 +149,7 @@ Query:
|
||||
| `remaining_quantity` | 剩余可用数量;大于 0 才会在背包列表返回。 |
|
||||
| `effective_at_ms` | 生效时间,Unix epoch milliseconds。 |
|
||||
| `expires_at_ms` | 过期时间,Unix epoch milliseconds;`0` 表示永久。 |
|
||||
| `equipped` | 当前权益是否是该资源类型的已佩戴项。每个可佩戴 `resource_type` 同一时间最多一条为 `true`。 |
|
||||
| `equipped` | 当前权益是否在现有权限下有效佩戴。VIP 失效后历史权益仍可返回,但该字段为 `false`,客户端不得继续渲染;每个可佩戴 `resource_type` 同一时间最多一条为 `true`。 |
|
||||
|
||||
## 佩戴我的资源
|
||||
|
||||
|
||||
@ -1537,6 +1537,63 @@ paths:
|
||||
$ref: "#/responses/Unauthorized"
|
||||
"502":
|
||||
$ref: "#/responses/UpstreamError"
|
||||
/api/v1/rooms/covers/upload:
|
||||
post:
|
||||
tags:
|
||||
- rooms
|
||||
summary: 上传房间封面并生成一次性绑定凭证
|
||||
operationId: uploadRoomCover
|
||||
description: |
|
||||
服务端按文件魔数识别 JPEG/PNG/GIF/WebP,并要求 multipart Content-Type、文件扩展名和实际格式一致。
|
||||
原始字节不裁剪、不转码,COS 后缀和 Content-Type 均由实际格式生成。动态 GIF/WebP 会调用 wallet
|
||||
`CheckVipBenefit(animated_room_cover)`;Fami 当前权益配置为 VIP4 解锁,但 gateway/room-service 不判断等级数字。
|
||||
consumes:
|
||||
- multipart/form-data
|
||||
security:
|
||||
- BearerAuth: []
|
||||
parameters:
|
||||
- name: command_id
|
||||
in: formData
|
||||
required: true
|
||||
type: string
|
||||
maxLength: 128
|
||||
description: 上传动作幂等键;同一文件重试必须复用,更换文件必须生成新值。
|
||||
- name: file
|
||||
in: formData
|
||||
required: true
|
||||
type: file
|
||||
description: 最大 10MiB;宽高不超过 4096,总像素不超过 16777216;动画最多 120 帧且最长 15 秒。
|
||||
responses:
|
||||
"200":
|
||||
description: 上传完成;url 只用于预览,创建/更新房间必须提交 cover_upload_id。
|
||||
schema:
|
||||
$ref: "#/definitions/RoomCoverUploadEnvelope"
|
||||
"400":
|
||||
$ref: "#/responses/BadRequest"
|
||||
"401":
|
||||
$ref: "#/responses/Unauthorized"
|
||||
"403":
|
||||
description: 动态封面缺少 animated_room_cover,code=VIP_BENEFIT_REQUIRED。
|
||||
schema:
|
||||
$ref: "#/definitions/ErrorEnvelope"
|
||||
"409":
|
||||
description: 同一 command_id 更换文件,code=IDEMPOTENCY_CONFLICT。
|
||||
schema:
|
||||
$ref: "#/definitions/ErrorEnvelope"
|
||||
"413":
|
||||
description: 文件超过 10MiB,code=FILE_TOO_LARGE。
|
||||
schema:
|
||||
$ref: "#/definitions/ErrorEnvelope"
|
||||
"415":
|
||||
description: 魔数、Content-Type、扩展名或媒体结构不一致,code=UNSUPPORTED_MEDIA_TYPE。
|
||||
schema:
|
||||
$ref: "#/definitions/ErrorEnvelope"
|
||||
"502":
|
||||
$ref: "#/responses/UpstreamError"
|
||||
"503":
|
||||
description: COS 或上传授权依赖不可用。
|
||||
schema:
|
||||
$ref: "#/definitions/ErrorEnvelope"
|
||||
/api/v1/rooms/create:
|
||||
post:
|
||||
tags:
|
||||
@ -1546,6 +1603,7 @@ paths:
|
||||
description: |
|
||||
gateway 从 access token 取当前 `user_id` 写入 room-service `RequestMeta.actor_user_id`,room-service 由该 actor 确定 owner。
|
||||
请求体不接收 `room_id` 或 `owner_user_id`;`command_id` 由客户端按创建动作生成,gateway 生成内部 room_id。
|
||||
Fami 必须提交未消费且属于当前用户的 `cover_upload_id`;返回的封面 URL 不能代替该凭证。
|
||||
room-service 会在返回前同步确保腾讯 IM 房间群存在;创建成功后 `data.room.im_group_id` 可直接用于 JoinRoom 成功后的腾讯 IM SDK joinGroup。
|
||||
同一个登录用户只能作为 owner 创建一个房间;已有房间时返回 409 Conflict。
|
||||
security:
|
||||
@ -1582,7 +1640,7 @@ paths:
|
||||
summary: 修改房间资料和座位数
|
||||
operationId: updateRoomProfile
|
||||
description: |
|
||||
App 房主在房间内修改房间名、头像、简介和座位数。所有状态变更经过 room-service Room Cell;缩小座位数时,被删除的高编号麦位必须全部空闲且未锁。
|
||||
App 房主在房间内修改房间名、封面、简介和座位数。修改封面时提交 `cover_upload_id`,凭证在资料命令事务内消费;不能提交任意动态 URL。缩小座位数时,被删除的高编号麦位必须全部空闲且未锁。
|
||||
security:
|
||||
- BearerAuth: []
|
||||
parameters:
|
||||
@ -4960,7 +5018,7 @@ definitions:
|
||||
format: int64
|
||||
equipped:
|
||||
type: boolean
|
||||
description: 当前权益是否是该资源类型的已佩戴项。
|
||||
description: 当前权益是否在现有权限下有效佩戴;VIP 失效后历史权益仍可返回,但该字段为 false。
|
||||
UserResourceListData:
|
||||
type: object
|
||||
properties:
|
||||
@ -5181,6 +5239,7 @@ definitions:
|
||||
- command_id
|
||||
- mode
|
||||
- room_name
|
||||
- cover_upload_id
|
||||
properties:
|
||||
command_id:
|
||||
type: string
|
||||
@ -5199,10 +5258,15 @@ definitions:
|
||||
minLength: 1
|
||||
maxLength: 128
|
||||
description: 房间展示名称,必填;room-service 写入 `room_ext.title` 并投影到房间列表。
|
||||
cover_upload_id:
|
||||
type: string
|
||||
minLength: 1
|
||||
maxLength: 128
|
||||
description: 必填;来自 `/api/v1/rooms/covers/upload`,必须属于当前 app_code 和登录用户,成功创建后即被消费。
|
||||
room_avatar:
|
||||
type: string
|
||||
maxLength: 512
|
||||
description: 房间头像,可选;为空时 room-service 写入默认系统头像 `hyapp://room/default-avatar`。
|
||||
description: 仅 legacy_timed App 的历史字段;Fami 禁止提交,且不能与 cover_upload_id 同时出现。
|
||||
room_description:
|
||||
type: string
|
||||
maxLength: 512
|
||||
@ -5223,10 +5287,15 @@ definitions:
|
||||
minLength: 1
|
||||
maxLength: 128
|
||||
description: 可选;传入时更新房间标题。
|
||||
cover_upload_id:
|
||||
type: string
|
||||
minLength: 1
|
||||
maxLength: 128
|
||||
description: 修改封面时提交;必须属于当前 app_code 和登录用户,且未被其他房间命令消费。
|
||||
room_avatar:
|
||||
type: string
|
||||
maxLength: 512
|
||||
description: 可选;传空字符串时 room-service 恢复系统默认头像。
|
||||
description: 仅 legacy_timed App 的历史字段;不能与 cover_upload_id 同时出现。
|
||||
room_description:
|
||||
type: string
|
||||
maxLength: 512
|
||||
@ -7666,6 +7735,65 @@ definitions:
|
||||
properties:
|
||||
data:
|
||||
$ref: "#/definitions/RoomMediaUploadData"
|
||||
RoomCoverUploadData:
|
||||
type: object
|
||||
required:
|
||||
- cover_upload_id
|
||||
- url
|
||||
- sha256
|
||||
- frame_count
|
||||
- animated
|
||||
- content_type
|
||||
- format
|
||||
- size_bytes
|
||||
- width
|
||||
- height
|
||||
- duration_ms
|
||||
- server_time_ms
|
||||
properties:
|
||||
cover_upload_id:
|
||||
type: string
|
||||
description: 一次性封面绑定凭证;只能由同 app_code、同用户的创建或更新命令消费。
|
||||
url:
|
||||
type: string
|
||||
description: COS 预览地址;禁止作为创建/更新房间的输入。
|
||||
sha256:
|
||||
type: string
|
||||
pattern: "^[0-9a-f]{64}$"
|
||||
frame_count:
|
||||
type: integer
|
||||
format: int32
|
||||
animated:
|
||||
type: boolean
|
||||
content_type:
|
||||
type: string
|
||||
enum: [image/jpeg, image/png, image/gif, image/webp]
|
||||
format:
|
||||
type: string
|
||||
enum: [jpeg, png, gif, webp]
|
||||
size_bytes:
|
||||
type: integer
|
||||
format: int64
|
||||
width:
|
||||
type: integer
|
||||
format: int32
|
||||
height:
|
||||
type: integer
|
||||
format: int32
|
||||
duration_ms:
|
||||
type: integer
|
||||
format: int64
|
||||
description: 静态图固定为 0。
|
||||
server_time_ms:
|
||||
type: integer
|
||||
format: int64
|
||||
RoomCoverUploadEnvelope:
|
||||
allOf:
|
||||
- $ref: "#/definitions/GatewayOKEnvelopeBase"
|
||||
- type: object
|
||||
properties:
|
||||
data:
|
||||
$ref: "#/definitions/RoomCoverUploadData"
|
||||
RoomBackgroundData:
|
||||
type: object
|
||||
required:
|
||||
|
||||
@ -659,6 +659,7 @@ definitions:
|
||||
- meta
|
||||
- mode
|
||||
- room_name
|
||||
- cover_upload_id
|
||||
properties:
|
||||
meta:
|
||||
$ref: "#/definitions/RequestMeta"
|
||||
@ -676,10 +677,14 @@ definitions:
|
||||
minLength: 1
|
||||
maxLength: 128
|
||||
description: 房间展示名称,room-service 写入 `RoomSnapshot.room_ext["title"]`。
|
||||
cover_upload_id:
|
||||
type: string
|
||||
maxLength: 128
|
||||
description: gateway 专用封面上传返回的一次性凭证;权益制 App 创建房间时必填。
|
||||
room_avatar:
|
||||
type: string
|
||||
maxLength: 512
|
||||
description: 房间头像;为空时写入默认系统头像 `hyapp://room/default-avatar`。
|
||||
description: legacy_timed App 历史字段;权益制 App 禁止用 URL 绕过 cover_upload_id。
|
||||
room_description:
|
||||
type: string
|
||||
maxLength: 512
|
||||
@ -702,10 +707,14 @@ definitions:
|
||||
type: string
|
||||
maxLength: 128
|
||||
description: proto3 optional;传入时更新 RoomExt title。
|
||||
cover_upload_id:
|
||||
type: string
|
||||
maxLength: 128
|
||||
description: proto3 optional;修改封面时提交并在房间资料命令事务内消费。
|
||||
room_avatar:
|
||||
type: string
|
||||
maxLength: 512
|
||||
description: proto3 optional;传空字符串时恢复系统默认头像。
|
||||
description: legacy_timed App 历史字段;与 cover_upload_id 互斥。
|
||||
room_description:
|
||||
type: string
|
||||
maxLength: 512
|
||||
|
||||
@ -70,6 +70,7 @@
|
||||
| GET | `/api/v1/rooms/{room_id}/backgrounds` | rooms | `listRoomBackgrounds` | 房主查询房间已保存背景图列表和当前背景 |
|
||||
| POST | `/api/v1/rooms/{room_id}/follow` | rooms | `followRoom` | 关注房间 |
|
||||
| DELETE | `/api/v1/rooms/{room_id}/follow` | rooms | `unfollowRoom` | 取消关注房间 |
|
||||
| POST | `/api/v1/rooms/covers/upload` | rooms | `uploadRoomCover` | 校验实际格式和动态封面 VIP 权益,原字节上传并返回一次性 `cover_upload_id` |
|
||||
| POST | `/api/v1/rooms/create` | rooms | `createRoom` | 创建房间 |
|
||||
| POST | `/api/v1/rooms/profile/update` | rooms | `updateRoomProfile` | 修改房间资料和座位数 |
|
||||
| POST | `/api/v1/rooms/backgrounds/save` | rooms | `saveRoomBackground` | 房主保存房间背景图 URL 到素材列表 |
|
||||
|
||||
@ -407,6 +407,7 @@ GET /api/v1/users/room-display-profiles:batch?user_ids=42,43
|
||||
- 房间资料卡展示用 `profile_card`,从 `metadata_json.profile_card_layout` 解析 `content_top` 和 `content_height`。
|
||||
- 入场座驾展示用 `vehicle`。
|
||||
- 公屏聊天气泡用 `chat_bubble`;未佩戴时固定返回空对象,普通文本消息按发送人 ID 批量补齐该字段。
|
||||
- VIP 装扮只在当前有效 VIP 权限覆盖对应等级时视为已佩戴;VIP 失效后背包可保留历史权益,但 `equipped=false`,客户端不得用资源列表兜底继续渲染。
|
||||
- 麦位声波展示用 `mic_seat_animation`。
|
||||
- 徽章条展示用 `badges`。
|
||||
- 过期佩戴不会返回。
|
||||
|
||||
@ -26,6 +26,18 @@ func Normalize(value string) string {
|
||||
return value
|
||||
}
|
||||
|
||||
// UsesRegionalLeaderboards 集中声明需要按用户所属区域隔离活动榜的 App。
|
||||
// Lalu/Fami 同时运营中东和欧美区域,榜单 key 必须包含 region_id;其他 App 保持既有全 App 榜,
|
||||
// 避免一次区域修复暗改尚未确认产品口径的租户。
|
||||
func UsesRegionalLeaderboards(value string) bool {
|
||||
switch Normalize(value) {
|
||||
case "lalu", "fami":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// WithContext 把 app_code 写入 context,供 service/repository 在不扩散方法签名的情况下取用。
|
||||
func WithContext(ctx context.Context, value string) context.Context {
|
||||
return context.WithValue(ctx, contextKey{}, Normalize(value))
|
||||
|
||||
@ -34,6 +34,8 @@ const (
|
||||
|
||||
var ErrNotConfigured = errors.New("user leaderboard redis is not configured")
|
||||
|
||||
var ErrRegionRequired = errors.New("user leaderboard region is required")
|
||||
|
||||
// Store keeps user leaderboard buckets in Redis zsets plus per-user hashes.
|
||||
// Gift facts populate sent/received, while successful game debits populate game; gateway only reads this projection.
|
||||
type Store struct {
|
||||
@ -43,14 +45,16 @@ type Store struct {
|
||||
|
||||
// GiftEvent is the minimal WalletGiftDebited fact needed to update the user leaderboard.
|
||||
type GiftEvent struct {
|
||||
AppCode string
|
||||
EventID string
|
||||
SenderUserID int64
|
||||
TargetUserID int64
|
||||
GiftValue int64
|
||||
GiftCount int64
|
||||
OccurredAtMS int64
|
||||
DirectGift bool
|
||||
AppCode string
|
||||
EventID string
|
||||
SenderUserID int64
|
||||
SenderRegionID int64
|
||||
TargetUserID int64
|
||||
TargetRegionID int64
|
||||
GiftValue int64
|
||||
GiftCount int64
|
||||
OccurredAtMS int64
|
||||
DirectGift bool
|
||||
}
|
||||
|
||||
// GameEvent is the successful game debit fact used by the game-spend leaderboard.
|
||||
@ -59,6 +63,7 @@ type GameEvent struct {
|
||||
AppCode string
|
||||
EventID string
|
||||
UserID int64
|
||||
RegionID int64
|
||||
CoinSpent int64
|
||||
OccurredAtMS int64
|
||||
}
|
||||
@ -66,6 +71,7 @@ type GameEvent struct {
|
||||
// Query describes one App leaderboard page. Now must be UTC-compatible; zero means current UTC time.
|
||||
type Query struct {
|
||||
AppCode string
|
||||
RegionID int64
|
||||
BoardType string
|
||||
Period string
|
||||
Page int
|
||||
@ -130,12 +136,21 @@ func (s *Store) ApplyGiftEvent(ctx context.Context, event GiftEvent) (bool, erro
|
||||
}
|
||||
|
||||
occurredAt := time.UnixMilli(event.OccurredAtMS).UTC()
|
||||
updates := make([]leaderboardUpdate, 0, 6)
|
||||
regional := appcode.UsesRegionalLeaderboards(event.AppCode)
|
||||
updates := make([]leaderboardUpdate, 0, 12)
|
||||
for _, period := range []string{PeriodToday, PeriodWeek, PeriodMonth} {
|
||||
// 全 App key 在兼容窗口继续写入,保证 gateway/activity 滚动发布期间旧实例仍能读到连续数据;
|
||||
// Lalu/Fami 的新查询只读取 region key,因此 legacy 全局榜不会再造成跨区混排。
|
||||
updates = append(updates,
|
||||
s.updateFor(event.AppCode, BoardSent, period, event.SenderUserID, occurredAt),
|
||||
s.updateFor(event.AppCode, BoardReceived, period, event.TargetUserID, occurredAt),
|
||||
s.updateFor(event.AppCode, 0, BoardSent, period, event.SenderUserID, occurredAt),
|
||||
s.updateFor(event.AppCode, 0, BoardReceived, period, event.TargetUserID, occurredAt),
|
||||
)
|
||||
if regional && event.SenderRegionID > 0 {
|
||||
updates = append(updates, s.updateFor(event.AppCode, event.SenderRegionID, BoardSent, period, event.SenderUserID, occurredAt))
|
||||
}
|
||||
if regional && event.TargetRegionID > 0 {
|
||||
updates = append(updates, s.updateFor(event.AppCode, event.TargetRegionID, BoardReceived, period, event.TargetUserID, occurredAt))
|
||||
}
|
||||
}
|
||||
return s.applyValueEvent(ctx, event.AppCode, event.EventID, event.GiftValue, event.GiftCount, event.OccurredAtMS, updates)
|
||||
}
|
||||
@ -153,9 +168,13 @@ func (s *Store) ApplyGameEvent(ctx context.Context, event GameEvent) (bool, erro
|
||||
}
|
||||
|
||||
occurredAt := time.UnixMilli(event.OccurredAtMS).UTC()
|
||||
updates := make([]leaderboardUpdate, 0, 3)
|
||||
regional := appcode.UsesRegionalLeaderboards(event.AppCode)
|
||||
updates := make([]leaderboardUpdate, 0, 6)
|
||||
for _, period := range []string{PeriodToday, PeriodWeek, PeriodMonth} {
|
||||
updates = append(updates, s.updateFor(event.AppCode, BoardGame, period, event.UserID, occurredAt))
|
||||
updates = append(updates, s.updateFor(event.AppCode, 0, BoardGame, period, event.UserID, occurredAt))
|
||||
if regional && event.RegionID > 0 {
|
||||
updates = append(updates, s.updateFor(event.AppCode, event.RegionID, BoardGame, period, event.UserID, occurredAt))
|
||||
}
|
||||
}
|
||||
return s.applyValueEvent(ctx, event.AppCode, event.EventID, event.CoinSpent, 0, event.OccurredAtMS, updates)
|
||||
}
|
||||
@ -166,8 +185,11 @@ func (s *Store) List(ctx context.Context, query Query) (Page, error) {
|
||||
return Page{}, ErrNotConfigured
|
||||
}
|
||||
query = normalizeQuery(query)
|
||||
if appcode.UsesRegionalLeaderboards(query.AppCode) && query.RegionID <= 0 {
|
||||
return Page{}, ErrRegionRequired
|
||||
}
|
||||
start, end := QueryWindow(query.Period, query.Now)
|
||||
key := s.scoreKey(query.AppCode, query.BoardType, query.Period, start)
|
||||
key := s.scopedScoreKey(query.AppCode, query.RegionID, query.BoardType, query.Period, start)
|
||||
offset := int64((query.Page - 1) * query.PageSize)
|
||||
stop := offset + int64(query.PageSize) - 1
|
||||
|
||||
@ -186,7 +208,7 @@ func (s *Store) List(ctx context.Context, query Query) (Page, error) {
|
||||
if !ok || strings.TrimSpace(userID) == "" {
|
||||
continue
|
||||
}
|
||||
entry, err := s.entryFromHash(ctx, query.AppCode, query.BoardType, query.Period, start, userID, int64(math.Round(row.Score)))
|
||||
entry, err := s.entryFromHash(ctx, query.AppCode, query.RegionID, query.BoardType, query.Period, start, userID, int64(math.Round(row.Score)))
|
||||
if err != nil {
|
||||
return Page{}, err
|
||||
}
|
||||
@ -213,8 +235,11 @@ func (s *Store) RankForUser(ctx context.Context, query Query, userID int64) (Ent
|
||||
return Entry{}, false, nil
|
||||
}
|
||||
query = normalizeQuery(query)
|
||||
if appcode.UsesRegionalLeaderboards(query.AppCode) && query.RegionID <= 0 {
|
||||
return Entry{}, false, ErrRegionRequired
|
||||
}
|
||||
start, _ := QueryWindow(query.Period, query.Now)
|
||||
key := s.scoreKey(query.AppCode, query.BoardType, query.Period, start)
|
||||
key := s.scopedScoreKey(query.AppCode, query.RegionID, query.BoardType, query.Period, start)
|
||||
member := strconv.FormatInt(userID, 10)
|
||||
rank, err := s.client.ZRevRank(ctx, key, member).Result()
|
||||
if errors.Is(err, redis.Nil) {
|
||||
@ -223,7 +248,7 @@ func (s *Store) RankForUser(ctx context.Context, query Query, userID int64) (Ent
|
||||
if err != nil {
|
||||
return Entry{}, false, err
|
||||
}
|
||||
entry, err := s.entryFromHash(ctx, query.AppCode, query.BoardType, query.Period, start, member, 0)
|
||||
entry, err := s.entryFromHash(ctx, query.AppCode, query.RegionID, query.BoardType, query.Period, start, member, 0)
|
||||
if err != nil {
|
||||
return Entry{}, false, err
|
||||
}
|
||||
@ -273,19 +298,19 @@ type leaderboardUpdate struct {
|
||||
ttl time.Duration
|
||||
}
|
||||
|
||||
func (s *Store) updateFor(app string, board string, period string, userID int64, occurredAt time.Time) leaderboardUpdate {
|
||||
func (s *Store) updateFor(app string, regionID int64, board string, period string, userID int64, occurredAt time.Time) leaderboardUpdate {
|
||||
start, periodEnd := periodBounds(period, occurredAt)
|
||||
member := strconv.FormatInt(userID, 10)
|
||||
return leaderboardUpdate{
|
||||
scoreKey: s.scoreKey(app, board, period, start),
|
||||
itemKey: s.itemKey(app, board, period, start, member),
|
||||
scoreKey: s.scopedScoreKey(app, regionID, board, period, start),
|
||||
itemKey: s.scopedItemKey(app, regionID, board, period, start, member),
|
||||
member: member,
|
||||
ttl: retentionTTL(period, start, periodEnd),
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Store) entryFromHash(ctx context.Context, app string, board string, period string, start time.Time, userID string, scoreFallback int64) (Entry, error) {
|
||||
values, err := s.client.HGetAll(ctx, s.itemKey(app, board, period, start, userID)).Result()
|
||||
func (s *Store) entryFromHash(ctx context.Context, app string, regionID int64, board string, period string, start time.Time, userID string, scoreFallback int64) (Entry, error) {
|
||||
values, err := s.client.HGetAll(ctx, s.scopedItemKey(app, regionID, board, period, start, userID)).Result()
|
||||
if err != nil {
|
||||
return Entry{}, err
|
||||
}
|
||||
@ -310,6 +335,20 @@ func (s *Store) itemKey(app string, board string, period string, start time.Time
|
||||
return fmt.Sprintf("%s:%s:%s:%s:%s:users:%s", s.keyPrefix, appcode.Normalize(app), board, period, bucketID(period, start), userID)
|
||||
}
|
||||
|
||||
func (s *Store) scopedScoreKey(app string, regionID int64, board string, period string, start time.Time) string {
|
||||
if !appcode.UsesRegionalLeaderboards(app) || regionID <= 0 {
|
||||
return s.scoreKey(app, board, period, start)
|
||||
}
|
||||
return fmt.Sprintf("%s:%s:region:%d:%s:%s:%s:scores", s.keyPrefix, appcode.Normalize(app), regionID, board, period, bucketID(period, start))
|
||||
}
|
||||
|
||||
func (s *Store) scopedItemKey(app string, regionID int64, board string, period string, start time.Time, userID string) string {
|
||||
if !appcode.UsesRegionalLeaderboards(app) || regionID <= 0 {
|
||||
return s.itemKey(app, board, period, start, userID)
|
||||
}
|
||||
return fmt.Sprintf("%s:%s:region:%d:%s:%s:%s:users:%s", s.keyPrefix, appcode.Normalize(app), regionID, board, period, bucketID(period, start), userID)
|
||||
}
|
||||
|
||||
func (s *Store) dedupeKey(app string, eventID string) string {
|
||||
return fmt.Sprintf("%s:%s:dedupe:%s", s.keyPrefix, appcode.Normalize(app), strings.TrimSpace(eventID))
|
||||
}
|
||||
|
||||
@ -158,13 +158,16 @@ func userLeaderboardGiftEventFromWalletMessage(body []byte) (userleaderboard.Gif
|
||||
return userleaderboard.GiftEvent{}, false, nil
|
||||
}
|
||||
return userleaderboard.GiftEvent{
|
||||
AppCode: appcode.Normalize(message.AppCode),
|
||||
EventID: message.EventID,
|
||||
SenderUserID: senderUserID,
|
||||
TargetUserID: targetUserID,
|
||||
GiftValue: giftValue,
|
||||
GiftCount: int64FromDecoded(decoded, "gift_count"),
|
||||
OccurredAtMS: message.OccurredAtMS,
|
||||
AppCode: appcode.Normalize(message.AppCode),
|
||||
EventID: message.EventID,
|
||||
SenderUserID: senderUserID,
|
||||
SenderRegionID: int64FromDecoded(decoded, "sender_region_id"),
|
||||
TargetUserID: targetUserID,
|
||||
// 主播身份区域是魅力榜的稳定归属;普通收礼用户没有主播快照时,回退送礼发生房间的可见区域。
|
||||
TargetRegionID: firstNonZeroInt64(int64FromDecoded(decoded, "target_host_region_id"), int64FromDecoded(decoded, "room_region_id")),
|
||||
GiftValue: giftValue,
|
||||
GiftCount: int64FromDecoded(decoded, "gift_count"),
|
||||
OccurredAtMS: message.OccurredAtMS,
|
||||
}, true, nil
|
||||
}
|
||||
|
||||
|
||||
@ -335,6 +335,7 @@ func (s *Service) ConsumeLevelEvent(ctx context.Context, event domain.ValueEvent
|
||||
AppCode: appcode.FromContext(ctx),
|
||||
EventID: event.EventID,
|
||||
UserID: event.UserID,
|
||||
RegionID: gameLeaderboardRegionID(event.DimensionsJSON),
|
||||
CoinSpent: event.ValueDelta,
|
||||
OccurredAtMS: event.OccurredAtMS,
|
||||
}); err != nil {
|
||||
@ -343,6 +344,18 @@ func (s *Service) ConsumeLevelEvent(ctx context.Context, event domain.ValueEvent
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func gameLeaderboardRegionID(dimensionsJSON string) int64 {
|
||||
var dimensions struct {
|
||||
RegionID int64 `json:"region_id"`
|
||||
}
|
||||
// 游戏订单 payload 是 owner service 固化的维度快照;解析失败或旧事件缺字段时返回 0,
|
||||
// 让旧全局榜保持可重放,同时禁止无区域事实进入 Lalu/Fami 的新区域榜。
|
||||
if err := json.Unmarshal([]byte(dimensionsJSON), &dimensions); err != nil || dimensions.RegionID <= 0 {
|
||||
return 0
|
||||
}
|
||||
return dimensions.RegionID
|
||||
}
|
||||
|
||||
// SetUserLevel 是经理中心的直接等级调整入口。
|
||||
// 它不接收累计值,避免调用方伪造进度;service 只校验轨道、等级和操作者,阈值读取与奖励补发在 repository 事务里完成。
|
||||
func (s *Service) SetUserLevel(ctx context.Context, command domain.SetUserLevelCommand) (domain.SetUserLevelResult, error) {
|
||||
|
||||
@ -1140,11 +1140,12 @@ func insertGameLevelEventOutbox(ctx context.Context, tx *sql.Tx, order gamedomai
|
||||
'provider_order_id', ?,
|
||||
'provider_round_id', ?,
|
||||
'room_id', ?,
|
||||
'region_id', ?,
|
||||
'wallet_transaction_id', ?
|
||||
), 'pending', 0, ?, ?, ?)`,
|
||||
order.AppCode, eventID, order.OrderID, order.UserID, order.GameID, order.ProviderOrderID, order.ProviderRoundID,
|
||||
order.CoinAmount, walletTransactionID,
|
||||
order.PlatformCode, order.GameID, order.ProviderGameID, order.ProviderOrderID, order.ProviderRoundID, order.RoomID, walletTransactionID,
|
||||
order.PlatformCode, order.GameID, order.ProviderGameID, order.ProviderOrderID, order.ProviderRoundID, order.RoomID, order.RegionID, walletTransactionID,
|
||||
nowMs, nowMs, nowMs,
|
||||
)
|
||||
return err
|
||||
|
||||
@ -40,6 +40,7 @@ type userLeaderboardResponse struct {
|
||||
StartAtMS int64 `json:"start_at_ms"`
|
||||
EndAtMS int64 `json:"end_at_ms"`
|
||||
ServerTimeMS int64 `json:"server_time_ms"`
|
||||
RegionID int64 `json:"region_id,omitempty"`
|
||||
MyRank *userLeaderboardItem `json:"my_rank,omitempty"`
|
||||
}
|
||||
|
||||
@ -95,8 +96,36 @@ func (h *Handler) listUserLeaderboards(writer http.ResponseWriter, request *http
|
||||
if pageSize > userLeaderboardMaxPageSize {
|
||||
pageSize = userLeaderboardMaxPageSize
|
||||
}
|
||||
app := appcode.FromContext(request.Context())
|
||||
viewerUserID := auth.UserIDFromContext(request.Context())
|
||||
regionID := int64(0)
|
||||
if appcode.UsesRegionalLeaderboards(app) {
|
||||
if h.userProfileClient == nil {
|
||||
httpkit.WriteError(writer, request, http.StatusServiceUnavailable, httpkit.CodeUpstreamError, "upstream service error")
|
||||
return
|
||||
}
|
||||
// 区域只能取 user-service 当前主数据;不接受 query/header 自报 region_id,避免用户跨区读榜。
|
||||
profileResp, err := h.userProfileClient.GetUser(request.Context(), &userv1.GetUserRequest{
|
||||
Meta: httpkit.UserMeta(request, ""),
|
||||
UserId: viewerUserID,
|
||||
})
|
||||
if err != nil {
|
||||
httpkit.WriteRPCError(writer, request, err)
|
||||
return
|
||||
}
|
||||
profile := profileResp.GetUser()
|
||||
if profile == nil || profile.GetRegionId() <= 0 {
|
||||
httpkit.WriteError(writer, request, http.StatusForbidden, httpkit.CodeProfileRequired, "profile required")
|
||||
return
|
||||
}
|
||||
if profile.GetAppCode() != "" && appcode.Normalize(profile.GetAppCode()) != app {
|
||||
httpkit.WriteError(writer, request, http.StatusForbidden, httpkit.CodePermissionDenied, "permission denied")
|
||||
return
|
||||
}
|
||||
regionID = profile.GetRegionId()
|
||||
}
|
||||
if boardType == leaderboardTypeRoom {
|
||||
h.listRoomGiftLeaderboards(writer, request, period, page, pageSize)
|
||||
h.listRoomGiftLeaderboards(writer, request, period, page, pageSize, regionID)
|
||||
return
|
||||
}
|
||||
if h.userLeaderboard == nil {
|
||||
@ -105,9 +134,9 @@ func (h *Handler) listUserLeaderboards(writer http.ResponseWriter, request *http
|
||||
}
|
||||
|
||||
now := time.Now().UTC()
|
||||
app := appcode.FromContext(request.Context())
|
||||
query := userleaderboard.Query{
|
||||
AppCode: app,
|
||||
RegionID: regionID,
|
||||
BoardType: boardType,
|
||||
Period: period,
|
||||
Page: int(page),
|
||||
@ -122,7 +151,6 @@ func (h *Handler) listUserLeaderboards(writer http.ResponseWriter, request *http
|
||||
items := userLeaderboardItemsFromRedisEntries(result.Items)
|
||||
|
||||
var myRank *userLeaderboardItem
|
||||
viewerUserID := auth.UserIDFromContext(request.Context())
|
||||
if viewerUserID > 0 && boardType != leaderboardTypeRoom {
|
||||
entry, found, err := h.userLeaderboard.RankForUser(request.Context(), query, viewerUserID)
|
||||
if err != nil {
|
||||
@ -149,11 +177,12 @@ func (h *Handler) listUserLeaderboards(writer http.ResponseWriter, request *http
|
||||
StartAtMS: result.StartAtMS,
|
||||
EndAtMS: result.EndAtMS,
|
||||
ServerTimeMS: result.ServerTimeMS,
|
||||
RegionID: regionID,
|
||||
MyRank: myRank,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *Handler) listRoomGiftLeaderboards(writer http.ResponseWriter, request *http.Request, period string, page int32, pageSize int32) {
|
||||
func (h *Handler) listRoomGiftLeaderboards(writer http.ResponseWriter, request *http.Request, period string, page int32, pageSize int32, regionID int64) {
|
||||
if h.roomQueryClient == nil {
|
||||
httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
|
||||
return
|
||||
@ -163,6 +192,7 @@ func (h *Handler) listRoomGiftLeaderboards(writer http.ResponseWriter, request *
|
||||
Period: period,
|
||||
Page: page,
|
||||
PageSize: pageSize,
|
||||
RegionId: regionID,
|
||||
})
|
||||
if err != nil {
|
||||
httpkit.WriteRPCError(writer, request, err)
|
||||
@ -178,6 +208,7 @@ func (h *Handler) listRoomGiftLeaderboards(writer http.ResponseWriter, request *
|
||||
StartAtMS: resp.GetStartAtMs(),
|
||||
EndAtMS: resp.GetEndAtMs(),
|
||||
ServerTimeMS: resp.GetServerTimeMs(),
|
||||
RegionID: regionID,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@ -180,6 +180,7 @@ type RoomHandlers struct {
|
||||
SaveRoomBackground http.HandlerFunc
|
||||
ListRoomBackgrounds http.HandlerFunc
|
||||
SetRoomBackground http.HandlerFunc
|
||||
UploadRoomCover http.HandlerFunc
|
||||
UploadRoomBackground http.HandlerFunc
|
||||
UploadRoomImage http.HandlerFunc
|
||||
SaveRoomBorder http.HandlerFunc
|
||||
@ -576,6 +577,7 @@ func (r routes) registerRoomRoutes() {
|
||||
r.profile("/rooms/{room_id}/gift-panel", http.MethodGet, h.GetRoomGiftPanel)
|
||||
r.profile("/rooms/{room_id}/rocket", http.MethodGet, h.GetRoomRocket)
|
||||
r.profile("/rooms/{room_id}/follow", "", h.FollowRoom)
|
||||
r.profile("/rooms/covers/upload", http.MethodPost, h.UploadRoomCover)
|
||||
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)
|
||||
|
||||
@ -3760,6 +3760,43 @@ func TestJoinRoomReturnsInitialDataWithIMGroupRTCTokenAndProfiles(t *testing.T)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetRoomDetailPermissionsExcludeMutedUserFromMicUp(t *testing.T) {
|
||||
queryClient := &fakeRoomQueryClient{snapshotResp: &roomv1.GetRoomSnapshotResponse{
|
||||
Room: &roomv1.RoomSnapshot{
|
||||
RoomId: "room-muted-permissions",
|
||||
OwnerUserId: 101,
|
||||
Status: "active",
|
||||
ChatEnabled: true,
|
||||
MicSeats: []*roomv1.SeatState{{SeatNo: 1}, {SeatNo: 2}},
|
||||
OnlineUsers: []*roomv1.RoomUser{{UserId: 42, Role: "audience"}},
|
||||
MuteUserIds: []int64{42},
|
||||
Version: 3,
|
||||
},
|
||||
ServerTimeMs: 1_700_000_000_123,
|
||||
}}
|
||||
handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{})
|
||||
handler.SetRoomQueryClient(queryClient)
|
||||
router := handler.Routes(auth.NewVerifier("secret"))
|
||||
request := httptest.NewRequest(http.MethodGet, "/api/v1/rooms/room-muted-permissions/detail", nil)
|
||||
request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
|
||||
request.Header.Set("X-Request-ID", "req-muted-permissions")
|
||||
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())
|
||||
}
|
||||
var response httpkit.ResponseEnvelope
|
||||
if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil {
|
||||
t.Fatalf("decode muted room detail response failed: %v", err)
|
||||
}
|
||||
permissions := response.Data.(map[string]any)["permissions"].(map[string]any)
|
||||
if permissions["is_muted"] != true || permissions["can_speak"] != false || permissions["can_mic_up"] != false {
|
||||
t.Fatalf("muted viewer permissions must reject public chat and mic up: %+v", permissions)
|
||||
}
|
||||
}
|
||||
|
||||
func TestJoinRoomLiteReturnsFirstScreenFieldsWithoutDisplayHydration(t *testing.T) {
|
||||
roomClient := &fakeRoomClient{joinResp: &roomv1.JoinRoomResponse{
|
||||
Result: &roomv1.CommandResult{Applied: true, RoomVersion: 8, ServerTimeMs: 1_700_000_010_123},
|
||||
|
||||
@ -154,6 +154,7 @@ func (h *Handler) RoomHandlers() httproutes.RoomHandlers {
|
||||
SaveRoomBackground: h.saveRoomBackground,
|
||||
ListRoomBackgrounds: h.listRoomBackgrounds,
|
||||
SetRoomBackground: h.setRoomBackground,
|
||||
UploadRoomCover: h.uploadRoomCover,
|
||||
UploadRoomBackground: h.uploadRoomBackground,
|
||||
UploadRoomImage: h.uploadRoomImage,
|
||||
SaveRoomBorder: h.saveRoomBorder,
|
||||
|
||||
@ -1145,10 +1145,12 @@ func (h *Handler) handleRoomFollow(writer http.ResponseWriter, request *http.Req
|
||||
// createRoom 把创建房间 HTTP JSON 请求转换为 room-service CreateRoom 命令。
|
||||
func (h *Handler) createRoom(writer http.ResponseWriter, request *http.Request) {
|
||||
var body struct {
|
||||
CommandID string `json:"command_id"`
|
||||
SeatCount int32 `json:"seat_count"`
|
||||
Mode string `json:"mode"`
|
||||
RoomName string `json:"room_name"`
|
||||
CommandID string `json:"command_id"`
|
||||
SeatCount int32 `json:"seat_count"`
|
||||
Mode string `json:"mode"`
|
||||
RoomName string `json:"room_name"`
|
||||
CoverUploadID string `json:"cover_upload_id"`
|
||||
// RoomAvatar 仅用于 legacy_timed App 的旧客户端;权益制 App 由 room-service 强制要求上传凭证。
|
||||
RoomAvatar string `json:"room_avatar"`
|
||||
RoomDescription string `json:"room_description"`
|
||||
}
|
||||
@ -1159,10 +1161,12 @@ func (h *Handler) createRoom(writer http.ResponseWriter, request *http.Request)
|
||||
body.CommandID = strings.TrimSpace(body.CommandID)
|
||||
body.Mode = strings.TrimSpace(body.Mode)
|
||||
body.RoomName = strings.TrimSpace(body.RoomName)
|
||||
body.CoverUploadID = strings.TrimSpace(body.CoverUploadID)
|
||||
body.RoomAvatar = strings.TrimSpace(body.RoomAvatar)
|
||||
body.RoomDescription = strings.TrimSpace(body.RoomDescription)
|
||||
if body.CommandID == "" || body.SeatCount < 0 || body.Mode == "" || body.RoomName == "" || body.RoomAvatar == "" {
|
||||
// command_id 必须来自客户端用户动作,seat_count 可省略走后台默认值;mode、room_name 和 room_avatar 是创建页最小必填信息。
|
||||
if body.CommandID == "" || body.SeatCount < 0 || body.Mode == "" || body.RoomName == "" ||
|
||||
(body.CoverUploadID == "" && body.RoomAvatar == "") || (body.CoverUploadID != "" && body.RoomAvatar != "") {
|
||||
// 新协议只传 cover_upload_id;room_avatar 分支由 owner service 根据 App VIP program 决定是否仍可兼容。
|
||||
httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
|
||||
return
|
||||
}
|
||||
@ -1189,6 +1193,7 @@ func (h *Handler) createRoom(writer http.ResponseWriter, request *http.Request)
|
||||
VisibleRegionId: userResp.GetUser().GetRegionId(),
|
||||
OwnerCountryCode: userResp.GetUser().GetCountry(),
|
||||
RoomName: body.RoomName,
|
||||
CoverUploadId: body.CoverUploadID,
|
||||
RoomAvatar: body.RoomAvatar,
|
||||
RoomDescription: body.RoomDescription,
|
||||
RoomShortId: roomShortID,
|
||||
@ -1206,6 +1211,7 @@ func (h *Handler) updateRoomProfile(writer http.ResponseWriter, request *http.Re
|
||||
RoomID string `json:"room_id"`
|
||||
CommandID string `json:"command_id"`
|
||||
RoomName *string `json:"room_name"`
|
||||
CoverUploadID *string `json:"cover_upload_id"`
|
||||
RoomAvatar *string `json:"room_avatar"`
|
||||
RoomDescription *string `json:"room_description"`
|
||||
SeatCount *int32 `json:"seat_count"`
|
||||
@ -1217,12 +1223,18 @@ func (h *Handler) updateRoomProfile(writer http.ResponseWriter, request *http.Re
|
||||
body.RoomID = strings.TrimSpace(body.RoomID)
|
||||
body.CommandID = strings.TrimSpace(body.CommandID)
|
||||
trimOptionalString(body.RoomName)
|
||||
trimOptionalString(body.CoverUploadID)
|
||||
trimOptionalString(body.RoomAvatar)
|
||||
trimOptionalString(body.RoomDescription)
|
||||
if body.CoverUploadID != nil && body.RoomAvatar != nil {
|
||||
httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "cover_upload_id and room_avatar are mutually exclusive")
|
||||
return
|
||||
}
|
||||
|
||||
resp, err := h.roomClient.UpdateRoomProfile(request.Context(), &roomv1.UpdateRoomProfileRequest{
|
||||
Meta: httpkit.RoomMeta(request, body.RoomID, body.CommandID),
|
||||
RoomName: body.RoomName,
|
||||
CoverUploadId: body.CoverUploadID,
|
||||
RoomAvatar: body.RoomAvatar,
|
||||
RoomDescription: body.RoomDescription,
|
||||
SeatCount: body.SeatCount,
|
||||
|
||||
@ -23,6 +23,7 @@ import (
|
||||
const (
|
||||
roomBackgroundUploadMaxBytes = int64(12 << 20)
|
||||
roomImageUploadMaxBytes = int64(8 << 20)
|
||||
roomCoverUploadMaxBytes = int64(10 << 20)
|
||||
roomUploadEnvelopeOverhead = int64(1 << 20)
|
||||
roomUploadMaxFrames = int32(120)
|
||||
roomUploadMaxDurationMS = int64(15_000)
|
||||
@ -54,6 +55,23 @@ type roomMediaUploadData struct {
|
||||
ServerTimeMS int64 `json:"server_time_ms"`
|
||||
}
|
||||
|
||||
// roomCoverUploadData 只暴露后续房间命令需要的凭证与服务端解析事实。
|
||||
// URL 只用于客户端预览,创建/更新接口拒绝用它替代 cover_upload_id。
|
||||
type roomCoverUploadData struct {
|
||||
CoverUploadID string `json:"cover_upload_id"`
|
||||
URL string `json:"url"`
|
||||
SHA256 string `json:"sha256"`
|
||||
FrameCount int32 `json:"frame_count"`
|
||||
Animated bool `json:"animated"`
|
||||
ContentType string `json:"content_type"`
|
||||
Format string `json:"format"`
|
||||
SizeBytes int64 `json:"size_bytes"`
|
||||
Width int32 `json:"width"`
|
||||
Height int32 `json:"height"`
|
||||
DurationMS int64 `json:"duration_ms"`
|
||||
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), "/"),
|
||||
@ -85,9 +103,13 @@ func (h *Handler) uploadRoomImage(writer http.ResponseWriter, request *http.Requ
|
||||
h.uploadRoomMedia(writer, request, "room_image", roomImageUploadMaxBytes)
|
||||
}
|
||||
|
||||
func (h *Handler) uploadRoomCover(writer http.ResponseWriter, request *http.Request) {
|
||||
h.uploadRoomMedia(writer, request, "room_cover", roomCoverUploadMaxBytes)
|
||||
}
|
||||
|
||||
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) {
|
||||
if purpose != "room_cover" && !roomid.ValidStringID(roomID) {
|
||||
httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "room_id is invalid")
|
||||
return
|
||||
}
|
||||
@ -123,6 +145,12 @@ func (h *Handler) uploadRoomMedia(writer http.ResponseWriter, request *http.Requ
|
||||
httpkit.WriteError(writer, request, http.StatusRequestEntityTooLarge, "FILE_TOO_LARGE", "file exceeds upload limit")
|
||||
return
|
||||
}
|
||||
if header.Size >= 0 && header.Size != int64(len(content)) {
|
||||
// multipart 声明大小与实际读取字节不一致通常表示截断或拼接;后续 SHA/COS ContentLength
|
||||
// 都必须以同一份原始字节为准,因此在媒体解析前直接拒绝。
|
||||
httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "file size does not match multipart metadata")
|
||||
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())
|
||||
@ -145,6 +173,14 @@ func (h *Handler) uploadRoomMedia(writer http.ResponseWriter, request *http.Requ
|
||||
return
|
||||
}
|
||||
objectKey := strings.TrimLeft(strings.TrimSpace(authorization.GetObjectKey()), "/")
|
||||
completeCommandID := commandID
|
||||
if purpose == "room_cover" {
|
||||
completeCommandID = strings.TrimSpace(authorization.GetCoverUploadId())
|
||||
if !giftlimits.ValidCommandID(completeCommandID) {
|
||||
httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "room cover upload authorization is incomplete")
|
||||
return
|
||||
}
|
||||
}
|
||||
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")
|
||||
@ -157,7 +193,7 @@ func (h *Handler) uploadRoomMedia(writer http.ResponseWriter, request *http.Requ
|
||||
}
|
||||
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(),
|
||||
Meta: httpkit.RoomMeta(request, roomID, completeCommandID), Purpose: purpose, Media: media.proto(),
|
||||
})
|
||||
if err != nil {
|
||||
httpkit.WriteRPCError(writer, request, err)
|
||||
@ -173,6 +209,28 @@ func (h *Handler) uploadRoomMedia(writer http.ResponseWriter, request *http.Requ
|
||||
return
|
||||
}
|
||||
media = *completedMedia
|
||||
if purpose == "room_cover" {
|
||||
coverUploadID := strings.TrimSpace(completed.GetCoverUploadId())
|
||||
if coverUploadID == "" || coverUploadID != completeCommandID {
|
||||
httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "room cover upload completion failed")
|
||||
return
|
||||
}
|
||||
httpkit.WriteOK(writer, request, roomCoverUploadData{
|
||||
CoverUploadID: coverUploadID,
|
||||
URL: media.URL,
|
||||
SHA256: media.SHA256,
|
||||
FrameCount: media.FrameCount,
|
||||
Animated: media.Animated,
|
||||
ContentType: media.ContentType,
|
||||
Format: media.Format,
|
||||
SizeBytes: media.SizeBytes,
|
||||
Width: media.Width,
|
||||
Height: media.Height,
|
||||
DurationMS: media.DurationMS,
|
||||
ServerTimeMS: completed.GetServerTimeMs(),
|
||||
})
|
||||
return
|
||||
}
|
||||
httpkit.WriteOK(writer, request, roomMediaUploadData{
|
||||
Purpose: purpose, RoomID: roomID, CommandID: commandID, RoomVersion: authorization.GetRoomVersion(),
|
||||
Media: media, ServerTimeMS: completed.GetServerTimeMs(),
|
||||
@ -251,6 +309,9 @@ func validateRoomUploadDimensions(purpose string, media roomMediaData) error {
|
||||
return nil
|
||||
}
|
||||
if media.Width > 4096 || media.Height > 4096 || pixels > 16_777_216 {
|
||||
if purpose == "room_cover" {
|
||||
return fmt.Errorf("room cover dimensions exceed limit")
|
||||
}
|
||||
return fmt.Errorf("room image dimensions exceed limit")
|
||||
}
|
||||
return nil
|
||||
|
||||
@ -1003,12 +1003,13 @@ func roomPermissionsFromSnapshot(snapshot *roomv1.RoomSnapshot, viewerUserID int
|
||||
isMuted := containsInt64(snapshot.GetMuteUserIds(), viewerUserID)
|
||||
inRoom := protoRoomUserByID(snapshot, viewerUserID) != nil
|
||||
return roomPermissionsData{
|
||||
IsOwner: isOwner,
|
||||
IsAdmin: isAdmin,
|
||||
IsMuted: isMuted,
|
||||
CanSpeak: inRoom && snapshot.GetChatEnabled() && !isMuted,
|
||||
CanSendGift: inRoom && snapshot.GetStatus() == "active",
|
||||
CanMicUp: inRoom && snapshot.GetStatus() == "active" && protoSeatByUserID(snapshot, viewerUserID) == nil,
|
||||
IsOwner: isOwner,
|
||||
IsAdmin: isAdmin,
|
||||
IsMuted: isMuted,
|
||||
CanSpeak: inRoom && snapshot.GetChatEnabled() && !isMuted,
|
||||
CanSendGift: inRoom && snapshot.GetStatus() == "active",
|
||||
// can_mic_up 只表达服务端当前可执行性;禁言用户即使在线且麦位空闲也不能引导进入必然失败的 MicUp。
|
||||
CanMicUp: inRoom && snapshot.GetStatus() == "active" && !isMuted && protoSeatByUserID(snapshot, viewerUserID) == nil,
|
||||
CanManageRoom: inRoom && (isOwner || isAdmin),
|
||||
CanLeaveRoom: inRoom,
|
||||
CanCloseRoom: inRoom,
|
||||
|
||||
@ -149,11 +149,11 @@ CREATE TABLE IF NOT EXISTS room_media_upload_registrations (
|
||||
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',
|
||||
purpose VARCHAR(32) NOT NULL COMMENT 'room_background/room_image/room_cover',
|
||||
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',
|
||||
status VARCHAR(32) NOT NULL DEFAULT 'authorized' COMMENT 'authorized/active/consumed',
|
||||
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 '原文件字节数',
|
||||
|
||||
@ -158,11 +158,11 @@ CREATE TABLE IF NOT EXISTS room_media_upload_registrations (
|
||||
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',
|
||||
purpose VARCHAR(32) NOT NULL COMMENT 'room_background/room_image/room_cover',
|
||||
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',
|
||||
status VARCHAR(32) NOT NULL DEFAULT 'authorized' COMMENT 'authorized/active/consumed',
|
||||
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 '原文件字节数',
|
||||
|
||||
@ -75,6 +75,8 @@ type CreateRoom struct {
|
||||
RoomName string `json:"room_name"`
|
||||
// RoomAvatar 是创建时必填的房间封面,恢复时回填 RoomExt["cover_url"]。
|
||||
RoomAvatar string `json:"room_avatar"`
|
||||
// CoverUploadID 固化封面 URL 的来源凭证,使同 command_id 重试不能换用另一份预上传文件。
|
||||
CoverUploadID string `json:"cover_upload_id,omitempty"`
|
||||
// RoomDescription 是房间简介,恢复时回填 RoomExt["description"]。
|
||||
RoomDescription string `json:"room_description"`
|
||||
// RoomShortID 是房间短 ID,首版等于创建者当前展示短号。
|
||||
@ -95,6 +97,8 @@ type UpdateRoomProfile struct {
|
||||
RoomName *string `json:"room_name,omitempty"`
|
||||
// RoomAvatar 非 nil 表示更新房间头像;空字符串会恢复为系统默认头像。
|
||||
RoomAvatar *string `json:"room_avatar,omitempty"`
|
||||
// CoverUploadID 非空表示 RoomAvatar 来自一次性预上传记录,提交事务必须同步消费。
|
||||
CoverUploadID string `json:"cover_upload_id,omitempty"`
|
||||
// RoomDescription 非 nil 表示更新房间简介,允许清空。
|
||||
RoomDescription *string `json:"room_description,omitempty"`
|
||||
// SeatCount 非 nil 表示调整麦位总数,值必须来自后台启用配置。
|
||||
|
||||
@ -52,6 +52,9 @@ type roomProfileInput struct {
|
||||
|
||||
// CreateRoom 创建一个新的房间执行单元,并把首个快照和事件落盘。
|
||||
func (s *Service) CreateRoom(ctx context.Context, req *roomv1.CreateRoomRequest) (*roomv1.CreateRoomResponse, error) {
|
||||
if req == nil || req.GetMeta() == nil {
|
||||
return nil, xerr.New(xerr.InvalidArgument, "request is required")
|
||||
}
|
||||
ctx = contextFromMeta(ctx, req.GetMeta())
|
||||
startedAt := time.Now()
|
||||
var idempotencyMS int64
|
||||
@ -59,7 +62,6 @@ func (s *Service) CreateRoom(ctx context.Context, req *roomv1.CreateRoomRequest)
|
||||
var saveMutationMS int64
|
||||
var snapshotMS int64
|
||||
var projectionMS int64
|
||||
var saveRoomMetaMS int64
|
||||
var tencentIMMS int64
|
||||
if req.GetMeta() == nil || req.GetMeta().GetRoomId() == "" {
|
||||
// 创建房间必须指定 room_id,后续 lease、meta、snapshot 都依赖它。
|
||||
@ -78,7 +80,19 @@ func (s *Service) CreateRoom(ctx context.Context, req *roomv1.CreateRoomRequest)
|
||||
// mode 是房间状态对客户端和后续策略分发的基础维度,不能创建空模式房。
|
||||
return nil, xerr.New(xerr.InvalidArgument, "mode is required")
|
||||
}
|
||||
profile, err := normalizeCreateRoomProfile(req)
|
||||
actorUserID := req.GetMeta().GetActorUserId()
|
||||
if actorUserID == 0 {
|
||||
// CreateRoom 的 owner 来自已鉴权调用方,不允许匿名内部命令造房间。
|
||||
return nil, xerr.New(xerr.InvalidArgument, "actor_user_id is required")
|
||||
}
|
||||
coverURL, coverConsumption, err := s.resolveSubmittedRoomCover(
|
||||
ctx, req.GetMeta().GetRequestId(), req.GetMeta().GetRoomId(), actorUserID,
|
||||
req.GetCoverUploadId(), req.GetRoomAvatar(), req.GetRobotRoom(),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
profile, err := normalizeCreateRoomProfile(req, coverURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@ -86,11 +100,6 @@ func (s *Service) CreateRoom(ctx context.Context, req *roomv1.CreateRoomRequest)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
actorUserID := req.GetMeta().GetActorUserId()
|
||||
if actorUserID == 0 {
|
||||
// CreateRoom 的 owner 来自已鉴权调用方,不允许匿名内部命令造房间。
|
||||
return nil, xerr.New(xerr.InvalidArgument, "actor_user_id is required")
|
||||
}
|
||||
if s.isDraining() {
|
||||
return nil, xerr.New(xerr.Unavailable, "room service is draining")
|
||||
}
|
||||
@ -105,6 +114,7 @@ func (s *Service) CreateRoom(ctx context.Context, req *roomv1.CreateRoomRequest)
|
||||
OwnerCountryCode: normalizeRoomCountryCode(req.GetOwnerCountryCode()),
|
||||
RoomName: profile.Name,
|
||||
RoomAvatar: profile.Avatar,
|
||||
CoverUploadID: strings.TrimSpace(req.GetCoverUploadId()),
|
||||
RoomDescription: profile.Description,
|
||||
RoomShortID: normalizeRoomShortID(req.GetRoomShortId(), actorUserID),
|
||||
RobotRoom: req.GetRobotRoom(),
|
||||
@ -211,25 +221,19 @@ func (s *Service) CreateRoom(ctx context.Context, req *roomv1.CreateRoomRequest)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 首次创建按 meta -> command log -> outbox -> snapshot 的顺序落盘,保证恢复有基础元数据和首版本命令。
|
||||
saveMetaStartedAt := time.Now()
|
||||
if err := s.repository.SaveRoomMeta(ctx, RoomMeta{
|
||||
AppCode: cmd.AppCode,
|
||||
RoomID: cmd.RoomID(),
|
||||
OwnerUserID: cmd.OwnerUserID,
|
||||
SeatCount: cmd.SeatCount,
|
||||
Mode: cmd.Mode,
|
||||
Status: state.RoomStatusActive,
|
||||
RoomPasswordHash: "",
|
||||
VisibleRegionID: cmd.VisibleRegionID,
|
||||
RoomShortID: cmd.RoomShortID,
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
saveRoomMetaMS = elapsedMS(saveMetaStartedAt)
|
||||
|
||||
saveStartedAt := time.Now()
|
||||
if err := s.repository.SaveMutation(ctx, MutationCommit{
|
||||
CreateRoomMeta: &RoomMeta{
|
||||
AppCode: cmd.AppCode,
|
||||
RoomID: cmd.RoomID(),
|
||||
OwnerUserID: cmd.OwnerUserID,
|
||||
SeatCount: cmd.SeatCount,
|
||||
Mode: cmd.Mode,
|
||||
Status: state.RoomStatusActive,
|
||||
RoomPasswordHash: "",
|
||||
VisibleRegionID: cmd.VisibleRegionID,
|
||||
RoomShortID: cmd.RoomShortID,
|
||||
},
|
||||
Command: CommandRecord{
|
||||
AppCode: cmd.AppCode,
|
||||
RoomID: cmd.RoomID(),
|
||||
@ -242,7 +246,8 @@ func (s *Service) CreateRoom(ctx context.Context, req *roomv1.CreateRoomRequest)
|
||||
LeaseToken: lease.LeaseToken,
|
||||
CreatedAtMS: now.UnixMilli(),
|
||||
},
|
||||
OutboxRecords: []outbox.Record{createdEvent},
|
||||
OutboxRecords: []outbox.Record{createdEvent},
|
||||
RoomCoverUploadConsumption: coverConsumption,
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@ -266,9 +271,7 @@ func (s *Service) CreateRoom(ctx context.Context, req *roomv1.CreateRoomRequest)
|
||||
|
||||
// 持久化成功后再安装内存 Cell,避免内存有房间但恢复来源不完整。
|
||||
s.installCell(ctx, cmd.RoomID(), cell.New(roomState.Clone(), rank.FromState(roomState.GiftRank, s.rankLimit)), lease.LeaseToken)
|
||||
logRoomCommandTiming(ctx, cmd.RoomID(), cmd.ID(), cmd.Type(), true, elapsedMS(startedAt), idempotencyMS, leaseMS, saveMutationMS, snapshotMS, projectionMS, 0, 0, tencentIMMS, true, true,
|
||||
slog.Int64("save_room_meta_ms", saveRoomMetaMS),
|
||||
)
|
||||
logRoomCommandTiming(ctx, cmd.RoomID(), cmd.ID(), cmd.Type(), true, elapsedMS(startedAt), idempotencyMS, leaseMS, saveMutationMS, snapshotMS, projectionMS, 0, 0, tencentIMMS, true, true)
|
||||
|
||||
return &roomv1.CreateRoomResponse{
|
||||
Result: commandResult(true, snapshot.GetVersion(), now),
|
||||
@ -307,7 +310,7 @@ func normalizeRoomShortID(raw string, actorUserID int64) string {
|
||||
}
|
||||
|
||||
// normalizeCreateRoomProfile 固定创建房间展示资料的入口语义,避免 gateway 和恢复路径各自猜默认值。
|
||||
func normalizeCreateRoomProfile(req *roomv1.CreateRoomRequest) (roomProfileInput, error) {
|
||||
func normalizeCreateRoomProfile(req *roomv1.CreateRoomRequest, resolvedAvatar string) (roomProfileInput, error) {
|
||||
name := strings.TrimSpace(req.GetRoomName())
|
||||
if name == "" {
|
||||
// 房间名是创建页必填字段,也是列表卡片的最小可展示信息。
|
||||
@ -318,10 +321,10 @@ func normalizeCreateRoomProfile(req *roomv1.CreateRoomRequest) (roomProfileInput
|
||||
return roomProfileInput{}, xerr.New(xerr.InvalidArgument, "room_name is too long")
|
||||
}
|
||||
|
||||
avatar := strings.TrimSpace(req.GetRoomAvatar())
|
||||
avatar := strings.TrimSpace(resolvedAvatar)
|
||||
if avatar == "" {
|
||||
// 创建房间封面必须来自客户端上传结果;owner service 兜底校验可以防止内部 gRPC 绕过 gateway 创建空封面房。
|
||||
return roomProfileInput{}, xerr.New(xerr.InvalidArgument, "room_avatar is required")
|
||||
// 权益制 App 的封面 URL 必须由已完成的 cover_upload_id 解析;legacy URL 也在进入这里前完成策略判断。
|
||||
return roomProfileInput{}, xerr.New(xerr.InvalidArgument, "room cover is required")
|
||||
}
|
||||
if utf8.RuneCountInString(avatar) > maxRoomAvatarRunes {
|
||||
// cover_url 会投影到 room_list_entries.cover_url,超长值必须在写入前拒绝。
|
||||
|
||||
@ -491,6 +491,7 @@ func (f *giftFlow) toMutationResult(now time.Time, current *state.RoomState) mut
|
||||
roomGiftLeaderboard: &RoomGiftLeaderboardIncrement{
|
||||
AppCode: appcode.FromContext(f.ctx),
|
||||
RoomID: current.RoomID,
|
||||
RegionID: f.roomMeta.VisibleRegionID,
|
||||
CoinSpent: roomGiftLeaderboardValue(f.billing),
|
||||
OccurredAtMS: now.UnixMilli(),
|
||||
},
|
||||
|
||||
@ -30,8 +30,10 @@ func (s *Service) AuthorizeRoomMediaUpload(ctx context.Context, req *roomv1.Auth
|
||||
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) {
|
||||
uploadCommandID := strings.TrimSpace(req.GetMeta().GetCommandId())
|
||||
if actorUserID <= 0 || !giftlimits.ValidCommandID(uploadCommandID) ||
|
||||
(purpose != roomMediaPurposeBackground && purpose != roomMediaPurposeImage && purpose != roomMediaPurposeCover) ||
|
||||
(purpose != roomMediaPurposeCover && !roomid.ValidStringID(roomID)) || (purpose == roomMediaPurposeCover && roomID != "") {
|
||||
return nil, xerr.New(xerr.InvalidArgument, "room media upload request is invalid")
|
||||
}
|
||||
media := roomMediaFromProto(req.GetMedia())
|
||||
@ -49,8 +51,15 @@ func (s *Service) AuthorizeRoomMediaUpload(ctx context.Context, req *roomv1.Auth
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
appCode := appcode.FromContext(ctx)
|
||||
registrationCommandID := uploadCommandID
|
||||
if purpose == roomMediaPurposeCover {
|
||||
// 客户端 command_id 只控制上传幂等;对外消费凭证由 owner service 派生,避免把任意字符串
|
||||
// 当作可绑定封面的授权记录主键。
|
||||
registrationCommandID = roomCoverUploadID(appCode, actorUserID, uploadCommandID)
|
||||
}
|
||||
registration := RoomMediaUploadRegistration{
|
||||
AppCode: appcode.FromContext(ctx), RoomID: roomID, CommandID: strings.TrimSpace(req.GetMeta().GetCommandId()),
|
||||
AppCode: appCode, RoomID: roomID, CommandID: registrationCommandID,
|
||||
ActorUserID: actorUserID, Purpose: purpose, MediaPayloadSHA256: payloadSHA256, Media: media,
|
||||
}
|
||||
registration.ExpectedObjectKey = roomMediaExpectedObjectKey(
|
||||
@ -75,26 +84,46 @@ func (s *Service) AuthorizeRoomMediaUpload(ctx context.Context, req *roomv1.Auth
|
||||
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 purpose == roomMediaPurposeCover {
|
||||
if consumed, exists, readErr := guard.GetRoomMediaUploadByObjectKey(ctx, registration.ExpectedObjectKey); readErr != nil {
|
||||
return nil, readErr
|
||||
} else if exists && consumed.Status == roomMediaStatusConsumed {
|
||||
if !sameConsumedRoomCoverUpload(consumed, registration) {
|
||||
return nil, xerr.New(xerr.IdempotencyConflict, "room media upload command payload conflict")
|
||||
}
|
||||
return nil, xerr.New(xerr.Conflict, "room cover upload is already consumed")
|
||||
}
|
||||
if err := s.requireVIPBenefit(ctx, req.GetMeta().GetRequestId(), actorUserID, vipBenefitCustomRoomBackground, "upload_room_background"); err != nil {
|
||||
}
|
||||
var authorizedVersion int64
|
||||
if purpose != roomMediaPurposeCover {
|
||||
snapshot, err := s.currentSnapshot(ctx, roomID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
case roomMediaPurposeImage:
|
||||
if err := requireSnapshotSpeakPermission(snapshot, actorUserID, s.clock.Now().UnixMilli()); err != nil {
|
||||
return nil, err
|
||||
if snapshot == nil || snapshot.GetStatus() != state.RoomStatusActive {
|
||||
return nil, xerr.New(xerr.RoomClosed, "room closed")
|
||||
}
|
||||
if err := s.requireVIPBenefit(ctx, req.GetMeta().GetRequestId(), actorUserID, vipBenefitRoomImageMessage, "upload_room_image"); err != nil {
|
||||
authorizedVersion = snapshot.GetVersion()
|
||||
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
|
||||
}
|
||||
}
|
||||
} else if media.Animated {
|
||||
// 静态封面属于基础能力;只有服务端实际解析出多帧 GIF/WebP 时才查询精确权益。
|
||||
// 解锁等级完全由 wallet 的 app benefit 配置决定,room-service 不判断 VIP 数字。
|
||||
if err := s.requireVIPBenefit(ctx, req.GetMeta().GetRequestId(), actorUserID, vipBenefitAnimatedRoomCover, "upload_animated_room_cover"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
@ -102,7 +131,7 @@ func (s *Service) AuthorizeRoomMediaUpload(ctx context.Context, req *roomv1.Auth
|
||||
if authorizedReplay != nil {
|
||||
return roomMediaUploadAuthorizationResponse(*authorizedReplay, nowMS), nil
|
||||
}
|
||||
registration.AuthorizedVersion = snapshot.GetVersion()
|
||||
registration.AuthorizedVersion = authorizedVersion
|
||||
registration.CreatedAtMS, registration.UpdatedAtMS = nowMS, nowMS
|
||||
registered, err := guard.RegisterRoomMediaUpload(ctx, registration)
|
||||
if errors.Is(err, ErrRoomMediaUploadCommandConflict) {
|
||||
@ -125,8 +154,9 @@ func (s *Service) CompleteRoomMediaUpload(ctx context.Context, req *roomv1.Compl
|
||||
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) {
|
||||
if actorUserID <= 0 || !giftlimits.ValidCommandID(commandID) ||
|
||||
(purpose != roomMediaPurposeBackground && purpose != roomMediaPurposeImage && purpose != roomMediaPurposeCover) ||
|
||||
(purpose != roomMediaPurposeCover && !roomid.ValidStringID(roomID)) || (purpose == roomMediaPurposeCover && roomID != "") {
|
||||
return nil, xerr.New(xerr.InvalidArgument, "room media completion request is invalid")
|
||||
}
|
||||
media := roomMediaFromProto(req.GetMedia())
|
||||
@ -161,6 +191,7 @@ func (s *Service) CompleteRoomMediaUpload(ctx context.Context, req *roomv1.Compl
|
||||
}
|
||||
return &roomv1.CompleteRoomMediaUploadResponse{
|
||||
Active: true, Media: roomMediaToProto(existing.Media), ServerTimeMs: s.clock.Now().UnixMilli(),
|
||||
CoverUploadId: roomMediaCoverUploadID(existing),
|
||||
}, nil
|
||||
}
|
||||
existing.ObjectURL, existing.Status, existing.Media = media.URL, roomMediaStatusActive, media
|
||||
@ -174,6 +205,7 @@ func (s *Service) CompleteRoomMediaUpload(ctx context.Context, req *roomv1.Compl
|
||||
}
|
||||
return &roomv1.CompleteRoomMediaUploadResponse{
|
||||
Active: true, Media: roomMediaToProto(completed.Media), ServerTimeMs: s.clock.Now().UnixMilli(),
|
||||
CoverUploadId: roomMediaCoverUploadID(completed),
|
||||
}, nil
|
||||
}
|
||||
|
||||
@ -181,9 +213,17 @@ func roomMediaUploadAuthorizationResponse(registration RoomMediaUploadRegistrati
|
||||
return &roomv1.AuthorizeRoomMediaUploadResponse{
|
||||
Allowed: true, ObjectKeyPrefix: roomMediaObjectPrefix(registration.AppCode, registration.ActorUserID, registration.RoomID, registration.Purpose),
|
||||
ObjectKey: registration.ExpectedObjectKey, RoomVersion: registration.AuthorizedVersion, ServerTimeMs: serverTimeMS,
|
||||
CoverUploadId: roomMediaCoverUploadID(registration),
|
||||
}
|
||||
}
|
||||
|
||||
func roomMediaCoverUploadID(registration RoomMediaUploadRegistration) string {
|
||||
if registration.Purpose == roomMediaPurposeCover {
|
||||
return strings.TrimSpace(registration.CommandID)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func sameRoomMediaUploadIdentity(left RoomMediaUploadRegistration, right RoomMediaUploadRegistration) bool {
|
||||
return appcode.Normalize(left.AppCode) == appcode.Normalize(right.AppCode) &&
|
||||
strings.TrimSpace(left.RoomID) == strings.TrimSpace(right.RoomID) &&
|
||||
@ -193,6 +233,14 @@ func sameRoomMediaUploadIdentity(left RoomMediaUploadRegistration, right RoomMed
|
||||
strings.TrimLeft(strings.TrimSpace(left.ExpectedObjectKey), "/") == strings.TrimLeft(strings.TrimSpace(right.ExpectedObjectKey), "/")
|
||||
}
|
||||
|
||||
func sameConsumedRoomCoverUpload(consumed RoomMediaUploadRegistration, candidate RoomMediaUploadRegistration) bool {
|
||||
return appcode.Normalize(consumed.AppCode) == appcode.Normalize(candidate.AppCode) &&
|
||||
strings.TrimSpace(consumed.CommandID) == strings.TrimSpace(candidate.CommandID) &&
|
||||
consumed.ActorUserID == candidate.ActorUserID && consumed.Purpose == roomMediaPurposeCover && candidate.Purpose == roomMediaPurposeCover &&
|
||||
strings.EqualFold(strings.TrimSpace(consumed.MediaPayloadSHA256), strings.TrimSpace(candidate.MediaPayloadSHA256)) &&
|
||||
strings.TrimLeft(strings.TrimSpace(consumed.ExpectedObjectKey), "/") == strings.TrimLeft(strings.TrimSpace(candidate.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 {
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
@ -12,6 +13,7 @@ import (
|
||||
roomeventsv1 "hyapp.local/api/proto/events/room/v1"
|
||||
roomv1 "hyapp.local/api/proto/room/v1"
|
||||
"hyapp/pkg/appcode"
|
||||
"hyapp/pkg/giftlimits"
|
||||
"hyapp/pkg/xerr"
|
||||
"hyapp/services/room-service/internal/room/state"
|
||||
)
|
||||
@ -19,7 +21,9 @@ import (
|
||||
const (
|
||||
roomMediaPurposeBackground = "room_background"
|
||||
roomMediaPurposeImage = "room_image"
|
||||
roomMediaPurposeCover = "room_cover"
|
||||
roomMediaStatusActive = "active"
|
||||
roomMediaStatusConsumed = "consumed"
|
||||
|
||||
roomBackgroundMaxBytes = int64(12 << 20)
|
||||
roomBackgroundMaxWidth = int32(2160)
|
||||
@ -28,6 +32,9 @@ const (
|
||||
roomImageMaxBytes = int64(8 << 20)
|
||||
roomImageMaxDimension = int32(4096)
|
||||
roomImageMaxPixels = int64(16_777_216)
|
||||
roomCoverMaxBytes = int64(10 << 20)
|
||||
roomCoverMaxDimension = int32(4096)
|
||||
roomCoverMaxPixels = int64(16_777_216)
|
||||
roomMediaMaxFrames = int32(120)
|
||||
roomMediaMaxDurationMS = int64(15_000)
|
||||
)
|
||||
@ -111,6 +118,10 @@ func validateRoomMediaProperties(purpose string, media state.RoomMedia) error {
|
||||
if media.SizeBytes > roomImageMaxBytes || media.Width > roomImageMaxDimension || media.Height > roomImageMaxDimension || pixels > roomImageMaxPixels {
|
||||
return xerr.New(xerr.InvalidArgument, "room image exceeds media limits")
|
||||
}
|
||||
case roomMediaPurposeCover:
|
||||
if media.SizeBytes > roomCoverMaxBytes || media.Width > roomCoverMaxDimension || media.Height > roomCoverMaxDimension || pixels > roomCoverMaxPixels {
|
||||
return xerr.New(xerr.InvalidArgument, "room cover exceeds media limits")
|
||||
}
|
||||
default:
|
||||
return xerr.New(xerr.InvalidArgument, "room media purpose is invalid")
|
||||
}
|
||||
@ -124,9 +135,94 @@ func roomMediaObjectPrefix(rawAppCode string, actorUserID int64, roomID string,
|
||||
if purpose == roomMediaPurposeBackground {
|
||||
directory = "backgrounds"
|
||||
}
|
||||
if purpose == roomMediaPurposeCover {
|
||||
// 封面上传发生在房间创建之前,因此对象归属只能绑定租户和上传用户;真正绑定 room_id
|
||||
// 由创建/资料命令消费 cover_upload_id 时在 MySQL 事务内完成。
|
||||
return path.Join("room-media", appcode.Normalize(rawAppCode), "covers", fmt.Sprintf("%d", actorUserID)) + "/"
|
||||
}
|
||||
return path.Join("room-media", appcode.Normalize(rawAppCode), directory, fmt.Sprintf("%d", actorUserID), strings.TrimSpace(roomID)) + "/"
|
||||
}
|
||||
|
||||
// roomCoverUploadID 把客户端上传 command_id 收敛为不泄漏原值的稳定凭证。相同用户、租户和上传动作
|
||||
// 永远得到同一 ID,gateway 重试不会创建第二条授权记录;跨用户或跨 App 则无法碰撞或转用。
|
||||
func roomCoverUploadID(rawAppCode string, actorUserID int64, commandID string) string {
|
||||
sum := sha256.Sum256([]byte(fmt.Sprintf("%s\x00%d\x00%s", appcode.Normalize(rawAppCode), actorUserID, strings.TrimSpace(commandID))))
|
||||
return "cover_" + hex.EncodeToString(sum[:16])
|
||||
}
|
||||
|
||||
// resolveRoomCoverForMutation 把一次性上传记录解析成可信 URL,并返回必须随房间命令提交的消费条件。
|
||||
// active 记录尚未使用;consumed 记录仅允许同房间的原命令重放,后续 SaveMutation 会用 active 条件阻止新命令复用。
|
||||
func (s *Service) resolveRoomCoverForMutation(ctx context.Context, requestID string, roomID string, actorUserID int64, coverUploadID string) (string, *RoomCoverUploadConsumption, error) {
|
||||
coverUploadID = strings.TrimSpace(coverUploadID)
|
||||
if !strings.HasPrefix(coverUploadID, "cover_") || !giftlimits.ValidCommandID(coverUploadID) {
|
||||
return "", nil, xerr.New(xerr.InvalidArgument, "cover_upload_id is invalid")
|
||||
}
|
||||
guard, ok := s.repository.(RoomMediaUploadGuardStore)
|
||||
if !ok {
|
||||
return "", nil, xerr.New(xerr.Unavailable, "room media upload guard is unavailable")
|
||||
}
|
||||
registration, exists, err := guard.GetRoomMediaUploadRegistration(ctx, "", coverUploadID)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
if !exists {
|
||||
// 成功提交后主键中的 room_id 已被改成目标房间。读取该行只用于相同 command_id 的幂等重放;
|
||||
// 新命令即使拿到同一 URL,也会在事务消费条件上因 status!=active 失败并整体回滚。
|
||||
registration, exists, err = guard.GetRoomMediaUploadRegistration(ctx, roomID, coverUploadID)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
}
|
||||
if !exists {
|
||||
return "", nil, xerr.New(xerr.NotFound, "room cover upload not found")
|
||||
}
|
||||
if registration.ActorUserID != actorUserID || registration.Purpose != roomMediaPurposeCover ||
|
||||
(registration.Status != roomMediaStatusActive && !(registration.Status == roomMediaStatusConsumed && registration.RoomID == roomID)) {
|
||||
return "", nil, xerr.New(xerr.PermissionDenied, "room cover upload does not belong to actor and room")
|
||||
}
|
||||
if err := validateRoomMedia(appcode.FromContext(ctx), "", actorUserID, roomMediaPurposeCover, registration.Media); err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
if registration.Media.Animated && registration.Status == roomMediaStatusActive {
|
||||
// 上传与绑定都检查精确 benefit,避免 VIP 到期后继续把尚未消费的动态封面写进房间状态。
|
||||
if err := s.requireVIPBenefit(ctx, requestID, actorUserID, vipBenefitAnimatedRoomCover, "set_animated_room_cover"); err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
}
|
||||
return strings.TrimSpace(registration.ObjectURL), &RoomCoverUploadConsumption{
|
||||
CoverUploadID: coverUploadID,
|
||||
ActorUserID: actorUserID,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// resolveSubmittedRoomCover 统一新旧协议边界。权益制 App 的普通客户端只能提交 cover_upload_id;
|
||||
// legacy_timed 继续接受历史 URL,机器人内部编排也保留已受信任的资源 URL,避免跨 App 发布互相阻塞。
|
||||
func (s *Service) resolveSubmittedRoomCover(ctx context.Context, requestID string, roomID string, actorUserID int64, coverUploadID string, legacyURL string, robotRoom bool) (string, *RoomCoverUploadConsumption, error) {
|
||||
coverUploadID = strings.TrimSpace(coverUploadID)
|
||||
legacyURL = strings.TrimSpace(legacyURL)
|
||||
if coverUploadID != "" {
|
||||
if legacyURL != "" {
|
||||
return "", nil, xerr.New(xerr.InvalidArgument, "cover_upload_id and room_avatar are mutually exclusive")
|
||||
}
|
||||
return s.resolveRoomCoverForMutation(ctx, requestID, roomID, actorUserID, coverUploadID)
|
||||
}
|
||||
if legacyURL == "" {
|
||||
return "", nil, xerr.New(xerr.InvalidArgument, "cover_upload_id is required")
|
||||
}
|
||||
if robotRoom || legacyURL == defaultRoomAvatar {
|
||||
return legacyURL, nil, nil
|
||||
}
|
||||
access, err := s.loadEffectiveVIPAccess(ctx, requestID, actorUserID)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
if access.programType == tieredPrivilegeVIPProgram {
|
||||
// Fami 当前由 wallet 配置为权益制;这里不识别 app_code,也不读取 VIP 等级数字。
|
||||
return "", nil, xerr.New(xerr.InvalidArgument, "cover_upload_id is required")
|
||||
}
|
||||
return legacyURL, nil, nil
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
@ -37,6 +37,10 @@ func (s *Service) MicUp(ctx context.Context, req *roomv1.MicUpRequest) (*roomv1.
|
||||
// 不在 room-service presence 的用户不能上麦。
|
||||
return mutationResult{}, nil, xerr.New(xerr.NotFound, "user not in room")
|
||||
}
|
||||
if current.MuteUsers[cmd.ActorUserID()] {
|
||||
// 禁言同时约束公屏和新上麦;校验必须落在 Room Cell 命令内,不能依赖客户端收到事件后自行拦截。
|
||||
return mutationResult{}, nil, xerr.New(xerr.PermissionDenied, "user is muted")
|
||||
}
|
||||
|
||||
if seat, exists := current.SeatByUser(cmd.ActorUserID()); exists {
|
||||
// MicUp 是客户端“确保自己在麦上”的入口;重复点击或网络重试如果换了 command_id,
|
||||
|
||||
@ -8,10 +8,76 @@ import (
|
||||
|
||||
roomv1 "hyapp.local/api/proto/room/v1"
|
||||
"hyapp/pkg/appcode"
|
||||
"hyapp/pkg/xerr"
|
||||
"hyapp/services/room-service/internal/room/command"
|
||||
"hyapp/services/room-service/internal/testutil/mysqltest"
|
||||
)
|
||||
|
||||
func TestMutedUserCannotSpeakOrMicUpUntilUnmuted(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repository := mysqltest.NewRepository(t)
|
||||
now := &fixedRoomRocketClock{now: time.Date(2026, 7, 21, 8, 0, 0, 0, time.UTC)}
|
||||
svc := newRocketTestService(t, repository, &rocketTestWallet{}, now)
|
||||
|
||||
roomID := "room-muted-user-guard"
|
||||
ownerID := int64(8481)
|
||||
mutedUserID := int64(8482)
|
||||
createRocketRoom(t, ctx, svc, roomID, ownerID, 9101)
|
||||
joinRocketRoom(t, ctx, svc, roomID, mutedUserID)
|
||||
|
||||
if _, err := svc.MuteUser(ctx, &roomv1.MuteUserRequest{
|
||||
Meta: rocketMeta(roomID, ownerID, "mute-user-guard"),
|
||||
TargetUserId: mutedUserID,
|
||||
Muted: true,
|
||||
}); err != nil {
|
||||
t.Fatalf("mute user failed: %v", err)
|
||||
}
|
||||
|
||||
mutedSpeak, err := svc.CheckSpeakPermission(ctx, &roomv1.CheckSpeakPermissionRequest{
|
||||
RoomId: roomID,
|
||||
UserId: mutedUserID,
|
||||
AppCode: appcode.Default,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("check muted speak permission failed: %v", err)
|
||||
}
|
||||
if mutedSpeak.GetAllowed() || mutedSpeak.GetReason() != "user_muted" {
|
||||
t.Fatalf("muted user must be rejected by public chat guard: %+v", mutedSpeak)
|
||||
}
|
||||
if _, err := svc.MicUp(ctx, &roomv1.MicUpRequest{
|
||||
Meta: rocketMeta(roomID, mutedUserID, "muted-mic-up"),
|
||||
SeatNo: 2,
|
||||
}); !xerr.IsCode(err, xerr.PermissionDenied) {
|
||||
t.Fatalf("muted user mic up must return permission denied: %v", err)
|
||||
}
|
||||
|
||||
if _, err := svc.MuteUser(ctx, &roomv1.MuteUserRequest{
|
||||
Meta: rocketMeta(roomID, ownerID, "unmute-user-guard"),
|
||||
TargetUserId: mutedUserID,
|
||||
Muted: false,
|
||||
}); err != nil {
|
||||
t.Fatalf("unmute user failed: %v", err)
|
||||
}
|
||||
|
||||
unmutedSpeak, err := svc.CheckSpeakPermission(ctx, &roomv1.CheckSpeakPermissionRequest{
|
||||
RoomId: roomID,
|
||||
UserId: mutedUserID,
|
||||
AppCode: appcode.Default,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("check unmuted speak permission failed: %v", err)
|
||||
}
|
||||
if !unmutedSpeak.GetAllowed() || unmutedSpeak.GetReason() != "" {
|
||||
t.Fatalf("unmuted user must recover public chat permission: %+v", unmutedSpeak)
|
||||
}
|
||||
if _, err := svc.MicUp(ctx, &roomv1.MicUpRequest{
|
||||
Meta: rocketMeta(roomID, mutedUserID, "unmuted-mic-up"),
|
||||
SeatNo: 2,
|
||||
}); err != nil {
|
||||
t.Fatalf("unmuted user must recover mic up permission: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMicUpReturnsExistingSeatForDuplicateUserCommand(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repository := mysqltest.NewRepository(t)
|
||||
|
||||
@ -147,6 +147,7 @@ func (s *Service) mutateRoom(ctx context.Context, cmd command.Command, replayabl
|
||||
RoomWeeklyContribution: result.roomWeeklyContribution,
|
||||
GiftOperation: giftOperationCommitForResult(cmd, resultPayload, now),
|
||||
RoomDecorationAssignment: result.roomDecorationAssignment,
|
||||
RoomCoverUploadConsumption: result.roomCoverUploadConsumption,
|
||||
})
|
||||
commitCancel()
|
||||
if errors.Is(err, ErrCommandAlreadyCommitted) {
|
||||
@ -209,6 +210,7 @@ func (s *Service) mutateRoom(ctx context.Context, cmd command.Command, replayabl
|
||||
}
|
||||
err := s.repository.SaveMutation(commitCtx, MutationCommit{
|
||||
Command: commandRecord, RoomDecorationAssignment: result.roomDecorationAssignment,
|
||||
RoomCoverUploadConsumption: result.roomCoverUploadConsumption,
|
||||
})
|
||||
commitCancel()
|
||||
if err != nil && !s.mutationCommitConfirmed(ctx, commandRecord) {
|
||||
|
||||
@ -18,15 +18,42 @@ import (
|
||||
|
||||
// UpdateRoomProfile 修改房间展示资料和麦位数量,所有状态变化仍由 Room Cell 串行提交。
|
||||
func (s *Service) UpdateRoomProfile(ctx context.Context, req *roomv1.UpdateRoomProfileRequest) (*roomv1.UpdateRoomProfileResponse, error) {
|
||||
if req == nil || req.GetMeta() == nil {
|
||||
return nil, xerr.New(xerr.InvalidArgument, "request is required")
|
||||
}
|
||||
ctx = contextFromMeta(ctx, req.GetMeta())
|
||||
patch, err := s.normalizeUpdateRoomProfile(ctx, req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var coverConsumption *RoomCoverUploadConsumption
|
||||
if req.CoverUploadId != nil {
|
||||
if req.RoomAvatar != nil {
|
||||
return nil, xerr.New(xerr.InvalidArgument, "cover_upload_id and room_avatar are mutually exclusive")
|
||||
}
|
||||
coverURL, consumption, resolveErr := s.resolveSubmittedRoomCover(
|
||||
ctx, req.GetMeta().GetRequestId(), req.GetMeta().GetRoomId(), req.GetMeta().GetActorUserId(),
|
||||
req.GetCoverUploadId(), "", false,
|
||||
)
|
||||
if resolveErr != nil {
|
||||
return nil, resolveErr
|
||||
}
|
||||
patch.Avatar, coverConsumption = &coverURL, consumption
|
||||
} else if patch.Avatar != nil {
|
||||
coverURL, _, resolveErr := s.resolveSubmittedRoomCover(
|
||||
ctx, req.GetMeta().GetRequestId(), req.GetMeta().GetRoomId(), req.GetMeta().GetActorUserId(),
|
||||
"", *patch.Avatar, false,
|
||||
)
|
||||
if resolveErr != nil {
|
||||
return nil, resolveErr
|
||||
}
|
||||
patch.Avatar = &coverURL
|
||||
}
|
||||
cmd := command.UpdateRoomProfile{
|
||||
Base: baseFromMeta(req.GetMeta()),
|
||||
RoomName: patch.Name,
|
||||
RoomAvatar: patch.Avatar,
|
||||
CoverUploadID: strings.TrimSpace(req.GetCoverUploadId()),
|
||||
RoomDescription: patch.Description,
|
||||
SeatCount: patch.SeatCount,
|
||||
}
|
||||
@ -46,7 +73,7 @@ func (s *Service) UpdateRoomProfile(ctx context.Context, req *roomv1.UpdateRoomP
|
||||
}
|
||||
if !profileChanged && !seatChanged {
|
||||
// 未提交任何字段变化时仍通过 command log 保护 command_id 幂等,但不广播系统消息。
|
||||
return mutationResult{snapshot: current.ToProto()}, nil, nil
|
||||
return mutationResult{snapshot: current.ToProto(), roomCoverUploadConsumption: coverConsumption}, nil, nil
|
||||
}
|
||||
|
||||
current.Version++
|
||||
@ -64,7 +91,8 @@ func (s *Service) UpdateRoomProfile(ctx context.Context, req *roomv1.UpdateRoomP
|
||||
}
|
||||
|
||||
result := mutationResult{
|
||||
snapshot: current.ToProto(),
|
||||
snapshot: current.ToProto(),
|
||||
roomCoverUploadConsumption: coverConsumption,
|
||||
syncEvent: &tencentim.RoomEvent{
|
||||
EventID: profileEvent.EventID,
|
||||
RoomID: current.RoomID,
|
||||
|
||||
@ -59,6 +59,8 @@ type CommandRecord struct {
|
||||
|
||||
// MutationCommit 表达一次 Room Cell 命令需要一起提交的持久化副作用。
|
||||
type MutationCommit struct {
|
||||
// CreateRoomMeta 非 nil 时在命令事务内插入房间元数据;创建封面凭证消费不能与 rooms 行分步提交。
|
||||
CreateRoomMeta *RoomMeta
|
||||
// Command 是本次命令日志,所有成功命令都必须写入。
|
||||
Command CommandRecord
|
||||
// OutboxRecords 是本次命令产生的房间外事件。
|
||||
@ -88,6 +90,8 @@ type MutationCommit struct {
|
||||
GiftOperation *GiftOperationCommit
|
||||
// RoomDecorationAssignment 与装扮命令同事务更新,供 wallet 状态事件按索引即时定位并纠正 Room Cell。
|
||||
RoomDecorationAssignment *RoomDecorationAssignmentChange
|
||||
// RoomCoverUploadConsumption 把上传凭证绑定到当前房间并推进 consumed;必须与资料命令一起成功或回滚。
|
||||
RoomCoverUploadConsumption *RoomCoverUploadConsumption
|
||||
}
|
||||
|
||||
var (
|
||||
@ -221,6 +225,13 @@ type RoomMediaUploadRegistration struct {
|
||||
// ErrRoomMediaUploadCommandConflict 表示上传 command_id 已绑定另一份文件或用途。
|
||||
var ErrRoomMediaUploadCommandConflict = errors.New("room media upload command payload conflict")
|
||||
|
||||
// RoomCoverUploadConsumption 是创建/更新命令对预上传封面的单次消费条件。
|
||||
// SQL 还会用 app_code、空 room_id、purpose 和 active 状态做条件更新,防止跨租户、跨用户和重复使用。
|
||||
type RoomCoverUploadConsumption struct {
|
||||
CoverUploadID string
|
||||
ActorUserID int64
|
||||
}
|
||||
|
||||
// RoomDecorationAssignment 是 Room Cell 当前装扮对应的低频反向索引。
|
||||
// wallet 事件只能通过该表按 resource_id/owner_user_id 定位房间,禁止扫描 room_list_entries JSON。
|
||||
type RoomDecorationAssignment struct {
|
||||
@ -911,6 +922,7 @@ type RoomBackgroundCommandReader interface {
|
||||
// Get 必须先于可变房态/VIP 校验,Register 则只在首次授权通过后原子占用 command_id。
|
||||
type RoomMediaUploadGuardStore interface {
|
||||
GetRoomMediaUploadRegistration(ctx context.Context, roomID string, commandID string) (RoomMediaUploadRegistration, bool, error)
|
||||
GetRoomMediaUploadByObjectKey(ctx context.Context, objectKey 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)
|
||||
|
||||
@ -33,6 +33,8 @@ type RoomGiftLeaderboardStore interface {
|
||||
type RoomGiftLeaderboardIncrement struct {
|
||||
AppCode string
|
||||
RoomID string
|
||||
// RegionID 是送礼发生时房间的 visible_region_id;跨区换房不会改写历史周期归属。
|
||||
RegionID int64
|
||||
// CoinSpent 沿用 proto 字段名,运行值实际是 wallet 折算后的房间贡献值。
|
||||
CoinSpent int64
|
||||
OccurredAtMS int64
|
||||
@ -40,6 +42,7 @@ type RoomGiftLeaderboardIncrement struct {
|
||||
|
||||
type RoomGiftLeaderboardQuery struct {
|
||||
AppCode string
|
||||
RegionID int64
|
||||
Period string
|
||||
Page int
|
||||
PageSize int
|
||||
@ -87,10 +90,16 @@ func (s *RedisRoomGiftLeaderboardStore) IncrementRoomGift(ctx context.Context, i
|
||||
pipe := s.client.Pipeline()
|
||||
for _, period := range []string{roomGiftLeaderboardPeriodToday, roomGiftLeaderboardPeriodWeek, roomGiftLeaderboardPeriodMonth} {
|
||||
start, end := roomGiftLeaderboardWindow(period, occurredAt)
|
||||
key := roomGiftLeaderboardKey(app, period, start)
|
||||
pipe.ZIncrBy(ctx, key, float64(input.CoinSpent), roomID)
|
||||
legacyKey := roomGiftLeaderboardKey(app, period, start)
|
||||
pipe.ZIncrBy(ctx, legacyKey, float64(input.CoinSpent), roomID)
|
||||
// Redis 榜单是可重建读模型,TTL 只需要覆盖当前周期和排查窗口。
|
||||
pipe.Expire(ctx, key, roomGiftLeaderboardTTL(period, start, end))
|
||||
pipe.Expire(ctx, legacyKey, roomGiftLeaderboardTTL(period, start, end))
|
||||
if appcode.UsesRegionalLeaderboards(app) && input.RegionID > 0 {
|
||||
// 兼容 key 与区域 key 双写,避免 room/gateway 滚动发布期间旧实例出现榜单断层。
|
||||
regionalKey := roomGiftLeaderboardScopedKey(app, input.RegionID, period, start)
|
||||
pipe.ZIncrBy(ctx, regionalKey, float64(input.CoinSpent), roomID)
|
||||
pipe.Expire(ctx, regionalKey, roomGiftLeaderboardTTL(period, start, end))
|
||||
}
|
||||
}
|
||||
_, err := pipe.Exec(ctx)
|
||||
return err
|
||||
@ -101,9 +110,12 @@ func (s *RedisRoomGiftLeaderboardStore) ListRoomGiftLeaderboard(ctx context.Cont
|
||||
return RoomGiftLeaderboardPage{}, xerr.New(xerr.Unavailable, "room gift leaderboard store is not configured")
|
||||
}
|
||||
query = normalizeRoomGiftLeaderboardQuery(query)
|
||||
if appcode.UsesRegionalLeaderboards(query.AppCode) && query.RegionID <= 0 {
|
||||
return RoomGiftLeaderboardPage{}, xerr.New(xerr.InvalidArgument, "room gift leaderboard region is required")
|
||||
}
|
||||
now := time.UnixMilli(query.NowMS).UTC()
|
||||
start, end := roomGiftLeaderboardWindow(query.Period, now)
|
||||
key := roomGiftLeaderboardKey(appcode.Normalize(query.AppCode), query.Period, start)
|
||||
key := roomGiftLeaderboardScopedKey(query.AppCode, query.RegionID, query.Period, start)
|
||||
offset := int64((query.Page - 1) * query.PageSize)
|
||||
stop := offset + int64(query.PageSize) - 1
|
||||
|
||||
@ -161,6 +173,7 @@ func (s *Service) ListRoomGiftLeaderboard(ctx context.Context, req *roomv1.ListR
|
||||
|
||||
result, err := s.roomGiftLeaderboard.ListRoomGiftLeaderboard(ctx, RoomGiftLeaderboardQuery{
|
||||
AppCode: appcode.FromContext(ctx),
|
||||
RegionID: req.GetRegionId(),
|
||||
Period: period,
|
||||
Page: page,
|
||||
PageSize: pageSize,
|
||||
@ -283,6 +296,21 @@ func roomGiftLeaderboardKey(app string, period string, start time.Time) string {
|
||||
}
|
||||
}
|
||||
|
||||
func roomGiftLeaderboardScopedKey(app string, regionID int64, period string, start time.Time) string {
|
||||
if !appcode.UsesRegionalLeaderboards(app) || regionID <= 0 {
|
||||
return roomGiftLeaderboardKey(app, period, start)
|
||||
}
|
||||
switch period {
|
||||
case roomGiftLeaderboardPeriodWeek:
|
||||
year, week := start.ISOWeek()
|
||||
return fmt.Sprintf("room:gift_leaderboard:%s:region:%d:week:%04d%02d", appcode.Normalize(app), regionID, year, week)
|
||||
case roomGiftLeaderboardPeriodMonth:
|
||||
return fmt.Sprintf("room:gift_leaderboard:%s:region:%d:month:%04d%02d", appcode.Normalize(app), regionID, start.Year(), int(start.Month()))
|
||||
default:
|
||||
return fmt.Sprintf("room:gift_leaderboard:%s:region:%d:day:%s", appcode.Normalize(app), regionID, start.Format("20060102"))
|
||||
}
|
||||
}
|
||||
|
||||
func roomGiftLeaderboardTTL(period string, start time.Time, end time.Time) time.Duration {
|
||||
switch period {
|
||||
case roomGiftLeaderboardPeriodMonth:
|
||||
|
||||
@ -226,6 +226,8 @@ type mutationResult struct {
|
||||
roomWeeklyContribution *RoomWeeklyContributionIncrement
|
||||
// roomDecorationAssignment 和 Room Cell 装扮状态同事务更新,供 wallet 事件按索引即时重算。
|
||||
roomDecorationAssignment *RoomDecorationAssignmentChange
|
||||
// roomCoverUploadConsumption 让房间资料 URL 与一次性上传授权在同一命令事务内绑定。
|
||||
roomCoverUploadConsumption *RoomCoverUploadConsumption
|
||||
}
|
||||
|
||||
// New 初始化 room-service 领域服务。
|
||||
|
||||
@ -20,6 +20,7 @@ const (
|
||||
|
||||
vipBenefitCustomRoomBackground = "custom_room_background"
|
||||
vipBenefitRoomImageMessage = "room_image_message"
|
||||
vipBenefitAnimatedRoomCover = "animated_room_cover"
|
||||
vipBenefitRoomBorder = "room_border"
|
||||
vipBenefitColoredRoomName = "colored_room_name"
|
||||
vipBenefitAntiKick = "anti_kick"
|
||||
|
||||
@ -73,6 +73,29 @@ func (r *Repository) SaveMutation(ctx context.Context, commit roomservice.Mutati
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if meta := commit.CreateRoomMeta; meta != nil {
|
||||
// 创建元数据、首条 command/outbox 和 cover_upload_id 消费必须共用同一事务;
|
||||
// 任一后续步骤失败时 rooms 唯一约束和预上传凭证都会一起回滚。
|
||||
_, err := tx.ExecContext(ctx,
|
||||
`INSERT INTO rooms (app_code, room_id, room_short_id, owner_user_id, seat_count, mode, status, room_password_hash, visible_region_id, created_at_ms, updated_at_ms)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
appCode,
|
||||
strings.TrimSpace(meta.RoomID),
|
||||
strings.TrimSpace(meta.RoomShortID),
|
||||
meta.OwnerUserID,
|
||||
meta.SeatCount,
|
||||
strings.TrimSpace(meta.Mode),
|
||||
strings.TrimSpace(meta.Status),
|
||||
strings.TrimSpace(meta.RoomPasswordHash),
|
||||
meta.VisibleRegionID,
|
||||
commit.Command.CreatedAtMS,
|
||||
commit.Command.CreatedAtMS,
|
||||
)
|
||||
if err != nil {
|
||||
_ = tx.Rollback()
|
||||
return mapRoomMetaDuplicateError(err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := r.lockRoomMutation(ctx, tx, appCode, commit.Command.RoomID); err != nil {
|
||||
_ = tx.Rollback()
|
||||
@ -133,6 +156,34 @@ func (r *Repository) SaveMutation(ctx context.Context, commit roomservice.Mutati
|
||||
return err
|
||||
}
|
||||
|
||||
if consumption := commit.RoomCoverUploadConsumption; consumption != nil {
|
||||
// PRIMARY KEY(app_code, room_id, command_id) 使该条件直接定位预上传行,不会扫描上传历史。
|
||||
// 把 room_id 从空值改成目标房间同时完成归属绑定;status=active 保证凭证只能成功消费一次。
|
||||
result, err := tx.ExecContext(ctx, `
|
||||
UPDATE room_media_upload_registrations
|
||||
SET room_id = ?, status = 'consumed', updated_at_ms = ?
|
||||
WHERE app_code = ? AND room_id = '' AND command_id = ?
|
||||
AND actor_user_id = ? AND purpose = 'room_cover' AND status = 'active'`,
|
||||
commit.Command.RoomID,
|
||||
commit.Command.CreatedAtMS,
|
||||
appCode,
|
||||
strings.TrimSpace(consumption.CoverUploadID),
|
||||
consumption.ActorUserID,
|
||||
)
|
||||
if err != nil {
|
||||
_ = tx.Rollback()
|
||||
return err
|
||||
}
|
||||
affected, err := result.RowsAffected()
|
||||
if err != nil || affected != 1 {
|
||||
_ = tx.Rollback()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return fmt.Errorf("room cover upload cannot be consumed: room_id=%s cover_upload_id=%s", commit.Command.RoomID, consumption.CoverUploadID)
|
||||
}
|
||||
}
|
||||
|
||||
if change := commit.RoomDecorationAssignment; change != nil {
|
||||
decorationType := strings.TrimSpace(change.DecorationType)
|
||||
if decorationType == "" {
|
||||
@ -434,7 +485,7 @@ func (r *Repository) lockRoomMutation(ctx context.Context, tx *sql.Tx, appCode s
|
||||
roomID,
|
||||
).Scan(&lockedRoomID)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
// CreateRoom 的测试级别 command-log 写入可能没有预置 rooms 行;生产命令在 SaveMutation 前已有 room meta。
|
||||
// 低层 command-log 测试可能不预置 rooms 行;生产 CreateRoom 通过 CreateRoomMeta 在本事务先插入。
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
|
||||
@ -29,6 +29,24 @@ func (r *Repository) GetRoomMediaUploadRegistration(ctx context.Context, roomID
|
||||
return registration, err == nil, err
|
||||
}
|
||||
|
||||
// GetRoomMediaUploadByObjectKey 用唯一对象键读取任意生命周期的上传记录。
|
||||
// 封面消费会修改主键 room_id;上传重试用该索引识别 consumed 事实,避免把唯一键冲突泄漏成数据库错误。
|
||||
func (r *Repository) GetRoomMediaUploadByObjectKey(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 = ?
|
||||
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
|
||||
}
|
||||
|
||||
// GetActiveRoomMediaUploadByObjectKey 是 Save/Send 接受媒体事实前的权威反查。
|
||||
// expected_object_key 有唯一索引,查询不会按 URL 或 JSON 字段扫描。
|
||||
func (r *Repository) GetActiveRoomMediaUploadByObjectKey(ctx context.Context, objectKey string) (roomservice.RoomMediaUploadRegistration, bool, error) {
|
||||
@ -150,7 +168,8 @@ func scanRoomMediaUploadRegistration(scanner interface{ Scan(dest ...any) error
|
||||
if err == nil {
|
||||
registration.Media.ObjectKey = registration.ExpectedObjectKey
|
||||
registration.Media.URL = registration.ObjectURL
|
||||
if registration.Status == "active" {
|
||||
if registration.Status == "active" || registration.Status == "consumed" {
|
||||
// consumed 只改变一次性凭证生命周期;对象本身仍是已完成上传并可展示的 active 媒体。
|
||||
registration.Media.Status = "active"
|
||||
}
|
||||
}
|
||||
|
||||
@ -61,7 +61,50 @@ func (r *Repository) ListUserResources(ctx context.Context, query resourcedomain
|
||||
items = append(items, item)
|
||||
}
|
||||
}
|
||||
return items, rows.Err()
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// 背包要保留用户仍持有的 VIP 历史权益,但 equipped 必须表达“当前允许生效”,不能只反映装备表残留行。
|
||||
// 这里复用房间 appearance 的批量 VIP 门禁;VIP 失效后客户端仍能看到权益记录,但不能再据此渲染装扮。
|
||||
if err := r.filterCurrentVipEquipmentFlags(ctx, query.UserID, items, nowMs); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
// filterCurrentVipEquipmentFlags 把原始 equipment 指针收敛为当前权限下的有效佩戴状态。
|
||||
// 非 VIP 资源原样保留;历史 VIP 绑定资源只有仍匹配当前 paid/trial 等级时才保持 equipped=true。
|
||||
func (r *Repository) filterCurrentVipEquipmentFlags(ctx context.Context, userID int64, items []resourcedomain.UserResourceEntitlement, nowMS int64) error {
|
||||
equipped := make([]resourcedomain.UserResourceEntitlement, 0, len(items))
|
||||
for _, item := range items {
|
||||
if item.Equipped {
|
||||
equipped = append(equipped, item)
|
||||
}
|
||||
}
|
||||
if len(equipped) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
grouped := map[int64][]resourcedomain.UserResourceEntitlement{userID: equipped}
|
||||
if err := r.filterCurrentVipEquipmentBatch(ctx, []int64{userID}, grouped, nowMS); err != nil {
|
||||
return err
|
||||
}
|
||||
allowed := make(map[string]struct{}, len(grouped[userID]))
|
||||
for _, item := range grouped[userID] {
|
||||
allowed[item.EntitlementID] = struct{}{}
|
||||
}
|
||||
for index := range items {
|
||||
if !items[index].Equipped {
|
||||
continue
|
||||
}
|
||||
if _, ok := allowed[items[index].EntitlementID]; !ok {
|
||||
items[index].Equipped = false
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListVipBenefitRequirementsForResource 查询资源在 tiered VIP 中的当前或历史显式绑定。
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user