vip gif图,排行榜分区

This commit is contained in:
zhx 2026-07-21 11:31:21 +08:00
parent b4655c1326
commit 772cb71a46
40 changed files with 1433 additions and 160 deletions

View File

@ -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" +

View File

@ -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_idmeta.command_idmeta.actor_user_idmoderoom_name room_avatar
// meta.room_idmeta.command_idmeta.actor_user_idmoderoom_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 /

View 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` 文案。

View 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。

View File

@ -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` 表示本次实际查询的区域,可用于客户端日志核对。
### 送礼榜

View File

@ -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`。 |
## 佩戴我的资源

View File

@ -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_covercode=VIP_BENEFIT_REQUIRED。
schema:
$ref: "#/definitions/ErrorEnvelope"
"409":
description: 同一 command_id 更换文件code=IDEMPOTENCY_CONFLICT。
schema:
$ref: "#/definitions/ErrorEnvelope"
"413":
description: 文件超过 10MiBcode=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:

View File

@ -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

View File

@ -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 到素材列表 |

View File

@ -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`
- 过期佩戴不会返回。

View File

@ -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))

View File

@ -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))
}

View File

@ -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
}

View File

@ -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) {

View File

@ -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

View File

@ -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,
})
}

View File

@ -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)

View File

@ -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},

View File

@ -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,

View File

@ -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_idroom_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,

View File

@ -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

View File

@ -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,

View File

@ -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 '原文件字节数',

View File

@ -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 '原文件字节数',

View File

@ -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 表示调整麦位总数,值必须来自后台启用配置。

View File

@ -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超长值必须在写入前拒绝。

View File

@ -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(),
},

View File

@ -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 {

View File

@ -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 收敛为不泄漏原值的稳定凭证。相同用户、租户和上传动作
// 永远得到同一 IDgateway 重试不会创建第二条授权记录;跨用户或跨 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

View File

@ -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

View File

@ -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)

View File

@ -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) {

View File

@ -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,

View File

@ -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)

View File

@ -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:

View File

@ -226,6 +226,8 @@ type mutationResult struct {
roomWeeklyContribution *RoomWeeklyContributionIncrement
// roomDecorationAssignment 和 Room Cell 装扮状态同事务更新,供 wallet 事件按索引即时重算。
roomDecorationAssignment *RoomDecorationAssignmentChange
// roomCoverUploadConsumption 让房间资料 URL 与一次性上传授权在同一命令事务内绑定。
roomCoverUploadConsumption *RoomCoverUploadConsumption
}
// New 初始化 room-service 领域服务。

View File

@ -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"

View File

@ -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 {

View File

@ -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"
}
}

View File

@ -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 中的当前或历史显式绑定。