From 772cb71a463773a98182ae8585d9007bbf71e402 Mon Sep 17 00:00:00 2001 From: zhx Date: Tue, 21 Jul 2026 11:31:21 +0800 Subject: [PATCH] =?UTF-8?q?vip=20gif=E5=9B=BE=EF=BC=8C=E6=8E=92=E8=A1=8C?= =?UTF-8?q?=E6=A6=9C=E5=88=86=E5=8C=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/proto/room/v1/room.pb.go | 118 ++++++--- api/proto/room/v1/room.proto | 17 +- .../Fami_VIP设置开关Flutter接口对接文档.md | 245 ++++++++++++++++++ .../Fami_房间封面上传Flutter接口对接.md | 205 +++++++++++++++ docs/flutter对接/用户排行榜Flutter对接.md | 4 +- docs/flutter对接/背包接口Flutter对接.md | 2 +- docs/openapi/gateway.swagger.yaml | 136 +++++++++- docs/openapi/room.swagger.yaml | 13 +- docs/接口清单.md | 1 + docs/用户装扮Flutter对接文档.md | 1 + pkg/appcode/appcode.go | 12 + pkg/userleaderboard/store.go | 83 ++++-- .../internal/app/wallet_events.go | 17 +- .../internal/service/growth/service.go | 13 + .../internal/storage/mysql/repository.go | 3 +- .../activityapi/user_leaderboard_handler.go | 39 ++- .../transport/http/httproutes/router.go | 2 + .../internal/transport/http/response_test.go | 37 +++ .../transport/http/roomapi/handler.go | 1 + .../transport/http/roomapi/room_handler.go | 24 +- .../http/roomapi/room_media_handler.go | 65 ++++- .../transport/http/roomapi/room_view.go | 13 +- .../deploy/mysql/initdb/001_room_service.sql | 4 +- .../003_room_vip_media_and_decorations.sql | 4 +- .../internal/room/command/command.go | 4 + .../internal/room/service/create_room.go | 67 ++--- .../internal/room/service/gift_flow.go | 1 + .../internal/room/service/image_message.go | 92 +++++-- .../internal/room/service/media.go | 96 +++++++ .../room-service/internal/room/service/mic.go | 4 + .../internal/room/service/mic_test.go | 66 +++++ .../internal/room/service/pipeline.go | 2 + .../internal/room/service/profile.go | 32 ++- .../internal/room/service/repository.go | 12 + .../room/service/room_gift_leaderboard.go | 36 ++- .../internal/room/service/service.go | 2 + .../room-service/internal/room/service/vip.go | 1 + .../internal/storage/mysql/command_log.go | 53 +++- .../storage/mysql/room_media_upload.go | 21 +- .../mysql/resource_entitlement_repository.go | 45 +++- 40 files changed, 1433 insertions(+), 160 deletions(-) create mode 100644 docs/flutter对接/Fami_VIP设置开关Flutter接口对接文档.md create mode 100644 docs/flutter对接/Fami_房间封面上传Flutter接口对接.md diff --git a/api/proto/room/v1/room.pb.go b/api/proto/room/v1/room.pb.go index d78a16a3..6dc5bca7 100644 --- a/api/proto/room/v1/room.pb.go +++ b/api/proto/room/v1/room.pb.go @@ -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" + diff --git a/api/proto/room/v1/room.proto b/api/proto/room/v1/room.proto index e46b8a38..bc6c2241 100644 --- a/api/proto/room/v1/room.proto +++ b/api/proto/room/v1/room.proto @@ -667,7 +667,7 @@ message ApplyRoomDecorationResponse { message AuthorizeRoomMediaUploadRequest { RequestMeta meta = 1; - // purpose 仅接受 room_background/room_image;授权发生在 COS PutObject 之前。 + // purpose 接受 room_background/room_image/room_cover;授权发生在 COS PutObject 之前。 string purpose = 2; RoomMedia media = 3; } @@ -679,6 +679,8 @@ message AuthorizeRoomMediaUploadResponse { int64 server_time_ms = 4; // object_key 由 room owner 按首次 command_id+媒体哈希确定,gateway 不能自行换 key。 string object_key = 5; + // cover_upload_id 仅 room_cover 返回;创建/更新房间必须提交该凭证,不能提交上传 URL。 + string cover_upload_id = 6; } message CompleteRoomMediaUploadRequest { @@ -692,6 +694,8 @@ message CompleteRoomMediaUploadResponse { bool active = 1; RoomMedia media = 2; int64 server_time_ms = 3; + // cover_upload_id 仅 room_cover 返回,并与 Authorize 首次生成的凭证完全一致。 + string cover_upload_id = 4; } message SendRoomImageRequest { @@ -710,7 +714,7 @@ message SendRoomImageResponse { } // CreateRoomRequest 创建一个新的房间执行单元。 -// 必填语义:meta.room_id、meta.command_id、meta.actor_user_id、mode、room_name 和 room_avatar。 +// 必填语义:meta.room_id、meta.command_id、meta.actor_user_id、mode、room_name 和 cover_upload_id。 // 同一个 meta.actor_user_id 只能作为 owner 创建一个房间;重复创建返回 Conflict。 message CreateRoomRequest { RequestMeta meta = 1; @@ -722,7 +726,7 @@ message CreateRoomRequest { int64 visible_region_id = 4; // room_name 是客户端创建房间时填写的展示名称,room-service 会写入 RoomSnapshot.room_ext["title"]。 string room_name = 5; - // room_avatar 是客户端上传的房间封面;创建时必填,room-service 会写入 RoomSnapshot.room_ext["cover_url"]。 + // room_avatar 仅保留 legacy_timed App 的历史协议;权益制 App 禁止客户端直接提交 URL。 string room_avatar = 6; // room_description 是房间简介,只作为房间扩展资料保存,不参与 Room Cell 高频状态决策。 string room_description = 7; @@ -734,6 +738,8 @@ message CreateRoomRequest { bool robot_room = 10; // robot_user_ids 是该机器人房间允许参与机器人送礼和虚拟上麦的用户集合。 repeated int64 robot_user_ids = 11; + // cover_upload_id 来自 POST /api/v1/rooms/covers/upload,消费后不能用于另一间房或另一次资料更新。 + string cover_upload_id = 12; } // CreateRoomResponse 返回新建后的房间快照。 @@ -747,9 +753,12 @@ message CreateRoomResponse { message UpdateRoomProfileRequest { RequestMeta meta = 1; optional string room_name = 2; + // room_avatar 仅保留 legacy_timed App 的历史协议;权益制 App 使用 cover_upload_id。 optional string room_avatar = 3; optional string room_description = 4; optional int32 seat_count = 5; + // cover_upload_id 与 room_avatar 互斥;room-service 在资料命令事务内消费该凭证。 + optional string cover_upload_id = 6; } // UpdateRoomProfileResponse 返回修改后的最新房间快照。 @@ -1400,6 +1409,8 @@ message ListRoomGiftLeaderboardRequest { string period = 2; int32 page = 3; int32 page_size = 4; + // region_id 由 gateway 从登录用户资料注入;Lalu/Fami 必填,客户端 query 不可信。 + int64 region_id = 5; } // RoomFeedRelatedUser 是 Mine 关系房间流的排序输入,不让 room-service 持有好友/关注事实。 diff --git a/docs/flutter对接/Fami_VIP设置开关Flutter接口对接文档.md b/docs/flutter对接/Fami_VIP设置开关Flutter接口对接文档.md new file mode 100644 index 00000000..a386d55d --- /dev/null +++ b/docs/flutter对接/Fami_VIP设置开关Flutter接口对接文档.md @@ -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` 文案。 diff --git a/docs/flutter对接/Fami_房间封面上传Flutter接口对接.md b/docs/flutter对接/Fami_房间封面上传Flutter接口对接.md new file mode 100644 index 00000000..6bc404e5 --- /dev/null +++ b/docs/flutter对接/Fami_房间封面上传Flutter接口对接.md @@ -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 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 uploadRoomCover({ + required Dio dio, + required String filePath, + required String fileName, + required String extension, + required String uploadCommandId, +}) async { + final response = await dio.post>( + '/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); +} +``` + +选择图片时必须保留 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。 diff --git a/docs/flutter对接/用户排行榜Flutter对接.md b/docs/flutter对接/用户排行榜Flutter对接.md index 81e07ac8..85c8484f 100644 --- a/docs/flutter对接/用户排行榜Flutter对接.md +++ b/docs/flutter对接/用户排行榜Flutter对接.md @@ -35,7 +35,7 @@ http://127.0.0.1:13000 | `Authorization` | 是 | `Bearer `。 | | `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` 表示本次实际查询的区域,可用于客户端日志核对。 ### 送礼榜 diff --git a/docs/flutter对接/背包接口Flutter对接.md b/docs/flutter对接/背包接口Flutter对接.md index 6855a0b6..6e6124d6 100644 --- a/docs/flutter对接/背包接口Flutter对接.md +++ b/docs/flutter对接/背包接口Flutter对接.md @@ -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`。 | ## 佩戴我的资源 diff --git a/docs/openapi/gateway.swagger.yaml b/docs/openapi/gateway.swagger.yaml index 54ca480f..81016524 100644 --- a/docs/openapi/gateway.swagger.yaml +++ b/docs/openapi/gateway.swagger.yaml @@ -1537,6 +1537,63 @@ paths: $ref: "#/responses/Unauthorized" "502": $ref: "#/responses/UpstreamError" + /api/v1/rooms/covers/upload: + post: + tags: + - rooms + summary: 上传房间封面并生成一次性绑定凭证 + operationId: uploadRoomCover + description: | + 服务端按文件魔数识别 JPEG/PNG/GIF/WebP,并要求 multipart Content-Type、文件扩展名和实际格式一致。 + 原始字节不裁剪、不转码,COS 后缀和 Content-Type 均由实际格式生成。动态 GIF/WebP 会调用 wallet + `CheckVipBenefit(animated_room_cover)`;Fami 当前权益配置为 VIP4 解锁,但 gateway/room-service 不判断等级数字。 + consumes: + - multipart/form-data + security: + - BearerAuth: [] + parameters: + - name: command_id + in: formData + required: true + type: string + maxLength: 128 + description: 上传动作幂等键;同一文件重试必须复用,更换文件必须生成新值。 + - name: file + in: formData + required: true + type: file + description: 最大 10MiB;宽高不超过 4096,总像素不超过 16777216;动画最多 120 帧且最长 15 秒。 + responses: + "200": + description: 上传完成;url 只用于预览,创建/更新房间必须提交 cover_upload_id。 + schema: + $ref: "#/definitions/RoomCoverUploadEnvelope" + "400": + $ref: "#/responses/BadRequest" + "401": + $ref: "#/responses/Unauthorized" + "403": + description: 动态封面缺少 animated_room_cover,code=VIP_BENEFIT_REQUIRED。 + schema: + $ref: "#/definitions/ErrorEnvelope" + "409": + description: 同一 command_id 更换文件,code=IDEMPOTENCY_CONFLICT。 + schema: + $ref: "#/definitions/ErrorEnvelope" + "413": + description: 文件超过 10MiB,code=FILE_TOO_LARGE。 + schema: + $ref: "#/definitions/ErrorEnvelope" + "415": + description: 魔数、Content-Type、扩展名或媒体结构不一致,code=UNSUPPORTED_MEDIA_TYPE。 + schema: + $ref: "#/definitions/ErrorEnvelope" + "502": + $ref: "#/responses/UpstreamError" + "503": + description: COS 或上传授权依赖不可用。 + schema: + $ref: "#/definitions/ErrorEnvelope" /api/v1/rooms/create: post: tags: @@ -1546,6 +1603,7 @@ paths: description: | gateway 从 access token 取当前 `user_id` 写入 room-service `RequestMeta.actor_user_id`,room-service 由该 actor 确定 owner。 请求体不接收 `room_id` 或 `owner_user_id`;`command_id` 由客户端按创建动作生成,gateway 生成内部 room_id。 + Fami 必须提交未消费且属于当前用户的 `cover_upload_id`;返回的封面 URL 不能代替该凭证。 room-service 会在返回前同步确保腾讯 IM 房间群存在;创建成功后 `data.room.im_group_id` 可直接用于 JoinRoom 成功后的腾讯 IM SDK joinGroup。 同一个登录用户只能作为 owner 创建一个房间;已有房间时返回 409 Conflict。 security: @@ -1582,7 +1640,7 @@ paths: summary: 修改房间资料和座位数 operationId: updateRoomProfile description: | - App 房主在房间内修改房间名、头像、简介和座位数。所有状态变更经过 room-service Room Cell;缩小座位数时,被删除的高编号麦位必须全部空闲且未锁。 + App 房主在房间内修改房间名、封面、简介和座位数。修改封面时提交 `cover_upload_id`,凭证在资料命令事务内消费;不能提交任意动态 URL。缩小座位数时,被删除的高编号麦位必须全部空闲且未锁。 security: - BearerAuth: [] parameters: @@ -4960,7 +5018,7 @@ definitions: format: int64 equipped: type: boolean - description: 当前权益是否是该资源类型的已佩戴项。 + description: 当前权益是否在现有权限下有效佩戴;VIP 失效后历史权益仍可返回,但该字段为 false。 UserResourceListData: type: object properties: @@ -5181,6 +5239,7 @@ definitions: - command_id - mode - room_name + - cover_upload_id properties: command_id: type: string @@ -5199,10 +5258,15 @@ definitions: minLength: 1 maxLength: 128 description: 房间展示名称,必填;room-service 写入 `room_ext.title` 并投影到房间列表。 + cover_upload_id: + type: string + minLength: 1 + maxLength: 128 + description: 必填;来自 `/api/v1/rooms/covers/upload`,必须属于当前 app_code 和登录用户,成功创建后即被消费。 room_avatar: type: string maxLength: 512 - description: 房间头像,可选;为空时 room-service 写入默认系统头像 `hyapp://room/default-avatar`。 + description: 仅 legacy_timed App 的历史字段;Fami 禁止提交,且不能与 cover_upload_id 同时出现。 room_description: type: string maxLength: 512 @@ -5223,10 +5287,15 @@ definitions: minLength: 1 maxLength: 128 description: 可选;传入时更新房间标题。 + cover_upload_id: + type: string + minLength: 1 + maxLength: 128 + description: 修改封面时提交;必须属于当前 app_code 和登录用户,且未被其他房间命令消费。 room_avatar: type: string maxLength: 512 - description: 可选;传空字符串时 room-service 恢复系统默认头像。 + description: 仅 legacy_timed App 的历史字段;不能与 cover_upload_id 同时出现。 room_description: type: string maxLength: 512 @@ -7666,6 +7735,65 @@ definitions: properties: data: $ref: "#/definitions/RoomMediaUploadData" + RoomCoverUploadData: + type: object + required: + - cover_upload_id + - url + - sha256 + - frame_count + - animated + - content_type + - format + - size_bytes + - width + - height + - duration_ms + - server_time_ms + properties: + cover_upload_id: + type: string + description: 一次性封面绑定凭证;只能由同 app_code、同用户的创建或更新命令消费。 + url: + type: string + description: COS 预览地址;禁止作为创建/更新房间的输入。 + sha256: + type: string + pattern: "^[0-9a-f]{64}$" + frame_count: + type: integer + format: int32 + animated: + type: boolean + content_type: + type: string + enum: [image/jpeg, image/png, image/gif, image/webp] + format: + type: string + enum: [jpeg, png, gif, webp] + size_bytes: + type: integer + format: int64 + width: + type: integer + format: int32 + height: + type: integer + format: int32 + duration_ms: + type: integer + format: int64 + description: 静态图固定为 0。 + server_time_ms: + type: integer + format: int64 + RoomCoverUploadEnvelope: + allOf: + - $ref: "#/definitions/GatewayOKEnvelopeBase" + - type: object + properties: + data: + $ref: "#/definitions/RoomCoverUploadData" RoomBackgroundData: type: object required: diff --git a/docs/openapi/room.swagger.yaml b/docs/openapi/room.swagger.yaml index c51983a4..760820e2 100644 --- a/docs/openapi/room.swagger.yaml +++ b/docs/openapi/room.swagger.yaml @@ -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 diff --git a/docs/接口清单.md b/docs/接口清单.md index b32c1a14..d84a68fd 100644 --- a/docs/接口清单.md +++ b/docs/接口清单.md @@ -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 到素材列表 | diff --git a/docs/用户装扮Flutter对接文档.md b/docs/用户装扮Flutter对接文档.md index 5995884e..b583af29 100644 --- a/docs/用户装扮Flutter对接文档.md +++ b/docs/用户装扮Flutter对接文档.md @@ -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`。 - 过期佩戴不会返回。 diff --git a/pkg/appcode/appcode.go b/pkg/appcode/appcode.go index 7b5fb4cc..7e457e19 100644 --- a/pkg/appcode/appcode.go +++ b/pkg/appcode/appcode.go @@ -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)) diff --git a/pkg/userleaderboard/store.go b/pkg/userleaderboard/store.go index 707889e6..93e474c9 100644 --- a/pkg/userleaderboard/store.go +++ b/pkg/userleaderboard/store.go @@ -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)) } diff --git a/services/activity-service/internal/app/wallet_events.go b/services/activity-service/internal/app/wallet_events.go index 1a9009b2..41fc9520 100644 --- a/services/activity-service/internal/app/wallet_events.go +++ b/services/activity-service/internal/app/wallet_events.go @@ -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 } diff --git a/services/activity-service/internal/service/growth/service.go b/services/activity-service/internal/service/growth/service.go index 506cd6e0..d7a88811 100644 --- a/services/activity-service/internal/service/growth/service.go +++ b/services/activity-service/internal/service/growth/service.go @@ -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) { diff --git a/services/game-service/internal/storage/mysql/repository.go b/services/game-service/internal/storage/mysql/repository.go index 890b2b68..054c32c4 100644 --- a/services/game-service/internal/storage/mysql/repository.go +++ b/services/game-service/internal/storage/mysql/repository.go @@ -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 diff --git a/services/gateway-service/internal/transport/http/activityapi/user_leaderboard_handler.go b/services/gateway-service/internal/transport/http/activityapi/user_leaderboard_handler.go index d41cb360..17c1b7ac 100644 --- a/services/gateway-service/internal/transport/http/activityapi/user_leaderboard_handler.go +++ b/services/gateway-service/internal/transport/http/activityapi/user_leaderboard_handler.go @@ -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, }) } diff --git a/services/gateway-service/internal/transport/http/httproutes/router.go b/services/gateway-service/internal/transport/http/httproutes/router.go index 078f3faa..076ad011 100644 --- a/services/gateway-service/internal/transport/http/httproutes/router.go +++ b/services/gateway-service/internal/transport/http/httproutes/router.go @@ -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) diff --git a/services/gateway-service/internal/transport/http/response_test.go b/services/gateway-service/internal/transport/http/response_test.go index de1187c1..1f967c6c 100644 --- a/services/gateway-service/internal/transport/http/response_test.go +++ b/services/gateway-service/internal/transport/http/response_test.go @@ -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}, diff --git a/services/gateway-service/internal/transport/http/roomapi/handler.go b/services/gateway-service/internal/transport/http/roomapi/handler.go index dda6d831..4e25fb36 100644 --- a/services/gateway-service/internal/transport/http/roomapi/handler.go +++ b/services/gateway-service/internal/transport/http/roomapi/handler.go @@ -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, diff --git a/services/gateway-service/internal/transport/http/roomapi/room_handler.go b/services/gateway-service/internal/transport/http/roomapi/room_handler.go index b943b9cb..6d2fddc9 100644 --- a/services/gateway-service/internal/transport/http/roomapi/room_handler.go +++ b/services/gateway-service/internal/transport/http/roomapi/room_handler.go @@ -1145,10 +1145,12 @@ func (h *Handler) handleRoomFollow(writer http.ResponseWriter, request *http.Req // createRoom 把创建房间 HTTP JSON 请求转换为 room-service CreateRoom 命令。 func (h *Handler) createRoom(writer http.ResponseWriter, request *http.Request) { var body struct { - CommandID string `json:"command_id"` - SeatCount int32 `json:"seat_count"` - Mode string `json:"mode"` - RoomName string `json:"room_name"` + CommandID string `json:"command_id"` + SeatCount int32 `json:"seat_count"` + Mode string `json:"mode"` + RoomName string `json:"room_name"` + CoverUploadID string `json:"cover_upload_id"` + // RoomAvatar 仅用于 legacy_timed App 的旧客户端;权益制 App 由 room-service 强制要求上传凭证。 RoomAvatar string `json:"room_avatar"` RoomDescription string `json:"room_description"` } @@ -1159,10 +1161,12 @@ func (h *Handler) createRoom(writer http.ResponseWriter, request *http.Request) body.CommandID = strings.TrimSpace(body.CommandID) body.Mode = strings.TrimSpace(body.Mode) body.RoomName = strings.TrimSpace(body.RoomName) + body.CoverUploadID = strings.TrimSpace(body.CoverUploadID) body.RoomAvatar = strings.TrimSpace(body.RoomAvatar) body.RoomDescription = strings.TrimSpace(body.RoomDescription) - if body.CommandID == "" || body.SeatCount < 0 || body.Mode == "" || body.RoomName == "" || body.RoomAvatar == "" { - // command_id 必须来自客户端用户动作,seat_count 可省略走后台默认值;mode、room_name 和 room_avatar 是创建页最小必填信息。 + if body.CommandID == "" || body.SeatCount < 0 || body.Mode == "" || body.RoomName == "" || + (body.CoverUploadID == "" && body.RoomAvatar == "") || (body.CoverUploadID != "" && body.RoomAvatar != "") { + // 新协议只传 cover_upload_id;room_avatar 分支由 owner service 根据 App VIP program 决定是否仍可兼容。 httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument") return } @@ -1189,6 +1193,7 @@ func (h *Handler) createRoom(writer http.ResponseWriter, request *http.Request) VisibleRegionId: userResp.GetUser().GetRegionId(), OwnerCountryCode: userResp.GetUser().GetCountry(), RoomName: body.RoomName, + CoverUploadId: body.CoverUploadID, RoomAvatar: body.RoomAvatar, RoomDescription: body.RoomDescription, RoomShortId: roomShortID, @@ -1206,6 +1211,7 @@ func (h *Handler) updateRoomProfile(writer http.ResponseWriter, request *http.Re RoomID string `json:"room_id"` CommandID string `json:"command_id"` RoomName *string `json:"room_name"` + CoverUploadID *string `json:"cover_upload_id"` RoomAvatar *string `json:"room_avatar"` RoomDescription *string `json:"room_description"` SeatCount *int32 `json:"seat_count"` @@ -1217,12 +1223,18 @@ func (h *Handler) updateRoomProfile(writer http.ResponseWriter, request *http.Re body.RoomID = strings.TrimSpace(body.RoomID) body.CommandID = strings.TrimSpace(body.CommandID) trimOptionalString(body.RoomName) + trimOptionalString(body.CoverUploadID) trimOptionalString(body.RoomAvatar) trimOptionalString(body.RoomDescription) + if body.CoverUploadID != nil && body.RoomAvatar != nil { + httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "cover_upload_id and room_avatar are mutually exclusive") + return + } resp, err := h.roomClient.UpdateRoomProfile(request.Context(), &roomv1.UpdateRoomProfileRequest{ Meta: httpkit.RoomMeta(request, body.RoomID, body.CommandID), RoomName: body.RoomName, + CoverUploadId: body.CoverUploadID, RoomAvatar: body.RoomAvatar, RoomDescription: body.RoomDescription, SeatCount: body.SeatCount, diff --git a/services/gateway-service/internal/transport/http/roomapi/room_media_handler.go b/services/gateway-service/internal/transport/http/roomapi/room_media_handler.go index 08030d06..65e3cc99 100644 --- a/services/gateway-service/internal/transport/http/roomapi/room_media_handler.go +++ b/services/gateway-service/internal/transport/http/roomapi/room_media_handler.go @@ -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 diff --git a/services/gateway-service/internal/transport/http/roomapi/room_view.go b/services/gateway-service/internal/transport/http/roomapi/room_view.go index c3f7a66a..fce5cf2d 100644 --- a/services/gateway-service/internal/transport/http/roomapi/room_view.go +++ b/services/gateway-service/internal/transport/http/roomapi/room_view.go @@ -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, diff --git a/services/room-service/deploy/mysql/initdb/001_room_service.sql b/services/room-service/deploy/mysql/initdb/001_room_service.sql index 5e6903e5..d802f0c4 100644 --- a/services/room-service/deploy/mysql/initdb/001_room_service.sql +++ b/services/room-service/deploy/mysql/initdb/001_room_service.sql @@ -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 '原文件字节数', diff --git a/services/room-service/deploy/mysql/migrations/003_room_vip_media_and_decorations.sql b/services/room-service/deploy/mysql/migrations/003_room_vip_media_and_decorations.sql index c03d7bf2..46d4127c 100644 --- a/services/room-service/deploy/mysql/migrations/003_room_vip_media_and_decorations.sql +++ b/services/room-service/deploy/mysql/migrations/003_room_vip_media_and_decorations.sql @@ -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 '原文件字节数', diff --git a/services/room-service/internal/room/command/command.go b/services/room-service/internal/room/command/command.go index ee30be6d..63e1cd52 100644 --- a/services/room-service/internal/room/command/command.go +++ b/services/room-service/internal/room/command/command.go @@ -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 表示调整麦位总数,值必须来自后台启用配置。 diff --git a/services/room-service/internal/room/service/create_room.go b/services/room-service/internal/room/service/create_room.go index 369385d7..9d6439bf 100644 --- a/services/room-service/internal/room/service/create_room.go +++ b/services/room-service/internal/room/service/create_room.go @@ -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,超长值必须在写入前拒绝。 diff --git a/services/room-service/internal/room/service/gift_flow.go b/services/room-service/internal/room/service/gift_flow.go index 327ea049..b0f36a7f 100644 --- a/services/room-service/internal/room/service/gift_flow.go +++ b/services/room-service/internal/room/service/gift_flow.go @@ -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(), }, diff --git a/services/room-service/internal/room/service/image_message.go b/services/room-service/internal/room/service/image_message.go index 7d9166d6..f20238a1 100644 --- a/services/room-service/internal/room/service/image_message.go +++ b/services/room-service/internal/room/service/image_message.go @@ -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 { diff --git a/services/room-service/internal/room/service/media.go b/services/room-service/internal/room/service/media.go index b14040d8..3ece500b 100644 --- a/services/room-service/internal/room/service/media.go +++ b/services/room-service/internal/room/service/media.go @@ -1,6 +1,7 @@ package service import ( + "context" "crypto/sha256" "encoding/hex" "encoding/json" @@ -12,6 +13,7 @@ import ( roomeventsv1 "hyapp.local/api/proto/events/room/v1" roomv1 "hyapp.local/api/proto/room/v1" "hyapp/pkg/appcode" + "hyapp/pkg/giftlimits" "hyapp/pkg/xerr" "hyapp/services/room-service/internal/room/state" ) @@ -19,7 +21,9 @@ import ( const ( roomMediaPurposeBackground = "room_background" roomMediaPurposeImage = "room_image" + roomMediaPurposeCover = "room_cover" roomMediaStatusActive = "active" + roomMediaStatusConsumed = "consumed" roomBackgroundMaxBytes = int64(12 << 20) roomBackgroundMaxWidth = int32(2160) @@ -28,6 +32,9 @@ const ( roomImageMaxBytes = int64(8 << 20) roomImageMaxDimension = int32(4096) roomImageMaxPixels = int64(16_777_216) + roomCoverMaxBytes = int64(10 << 20) + roomCoverMaxDimension = int32(4096) + roomCoverMaxPixels = int64(16_777_216) roomMediaMaxFrames = int32(120) roomMediaMaxDurationMS = int64(15_000) ) @@ -111,6 +118,10 @@ func validateRoomMediaProperties(purpose string, media state.RoomMedia) error { if media.SizeBytes > roomImageMaxBytes || media.Width > roomImageMaxDimension || media.Height > roomImageMaxDimension || pixels > roomImageMaxPixels { return xerr.New(xerr.InvalidArgument, "room image exceeds media limits") } + case roomMediaPurposeCover: + if media.SizeBytes > roomCoverMaxBytes || media.Width > roomCoverMaxDimension || media.Height > roomCoverMaxDimension || pixels > roomCoverMaxPixels { + return xerr.New(xerr.InvalidArgument, "room cover exceeds media limits") + } default: return xerr.New(xerr.InvalidArgument, "room media purpose is invalid") } @@ -124,9 +135,94 @@ func roomMediaObjectPrefix(rawAppCode string, actorUserID int64, roomID string, if purpose == roomMediaPurposeBackground { directory = "backgrounds" } + if purpose == roomMediaPurposeCover { + // 封面上传发生在房间创建之前,因此对象归属只能绑定租户和上传用户;真正绑定 room_id + // 由创建/资料命令消费 cover_upload_id 时在 MySQL 事务内完成。 + return path.Join("room-media", appcode.Normalize(rawAppCode), "covers", fmt.Sprintf("%d", actorUserID)) + "/" + } return path.Join("room-media", appcode.Normalize(rawAppCode), directory, fmt.Sprintf("%d", actorUserID), strings.TrimSpace(roomID)) + "/" } +// roomCoverUploadID 把客户端上传 command_id 收敛为不泄漏原值的稳定凭证。相同用户、租户和上传动作 +// 永远得到同一 ID,gateway 重试不会创建第二条授权记录;跨用户或跨 App 则无法碰撞或转用。 +func roomCoverUploadID(rawAppCode string, actorUserID int64, commandID string) string { + sum := sha256.Sum256([]byte(fmt.Sprintf("%s\x00%d\x00%s", appcode.Normalize(rawAppCode), actorUserID, strings.TrimSpace(commandID)))) + return "cover_" + hex.EncodeToString(sum[:16]) +} + +// resolveRoomCoverForMutation 把一次性上传记录解析成可信 URL,并返回必须随房间命令提交的消费条件。 +// active 记录尚未使用;consumed 记录仅允许同房间的原命令重放,后续 SaveMutation 会用 active 条件阻止新命令复用。 +func (s *Service) resolveRoomCoverForMutation(ctx context.Context, requestID string, roomID string, actorUserID int64, coverUploadID string) (string, *RoomCoverUploadConsumption, error) { + coverUploadID = strings.TrimSpace(coverUploadID) + if !strings.HasPrefix(coverUploadID, "cover_") || !giftlimits.ValidCommandID(coverUploadID) { + return "", nil, xerr.New(xerr.InvalidArgument, "cover_upload_id is invalid") + } + guard, ok := s.repository.(RoomMediaUploadGuardStore) + if !ok { + return "", nil, xerr.New(xerr.Unavailable, "room media upload guard is unavailable") + } + registration, exists, err := guard.GetRoomMediaUploadRegistration(ctx, "", coverUploadID) + if err != nil { + return "", nil, err + } + if !exists { + // 成功提交后主键中的 room_id 已被改成目标房间。读取该行只用于相同 command_id 的幂等重放; + // 新命令即使拿到同一 URL,也会在事务消费条件上因 status!=active 失败并整体回滚。 + registration, exists, err = guard.GetRoomMediaUploadRegistration(ctx, roomID, coverUploadID) + if err != nil { + return "", nil, err + } + } + if !exists { + return "", nil, xerr.New(xerr.NotFound, "room cover upload not found") + } + if registration.ActorUserID != actorUserID || registration.Purpose != roomMediaPurposeCover || + (registration.Status != roomMediaStatusActive && !(registration.Status == roomMediaStatusConsumed && registration.RoomID == roomID)) { + return "", nil, xerr.New(xerr.PermissionDenied, "room cover upload does not belong to actor and room") + } + if err := validateRoomMedia(appcode.FromContext(ctx), "", actorUserID, roomMediaPurposeCover, registration.Media); err != nil { + return "", nil, err + } + if registration.Media.Animated && registration.Status == roomMediaStatusActive { + // 上传与绑定都检查精确 benefit,避免 VIP 到期后继续把尚未消费的动态封面写进房间状态。 + if err := s.requireVIPBenefit(ctx, requestID, actorUserID, vipBenefitAnimatedRoomCover, "set_animated_room_cover"); err != nil { + return "", nil, err + } + } + return strings.TrimSpace(registration.ObjectURL), &RoomCoverUploadConsumption{ + CoverUploadID: coverUploadID, + ActorUserID: actorUserID, + }, nil +} + +// resolveSubmittedRoomCover 统一新旧协议边界。权益制 App 的普通客户端只能提交 cover_upload_id; +// legacy_timed 继续接受历史 URL,机器人内部编排也保留已受信任的资源 URL,避免跨 App 发布互相阻塞。 +func (s *Service) resolveSubmittedRoomCover(ctx context.Context, requestID string, roomID string, actorUserID int64, coverUploadID string, legacyURL string, robotRoom bool) (string, *RoomCoverUploadConsumption, error) { + coverUploadID = strings.TrimSpace(coverUploadID) + legacyURL = strings.TrimSpace(legacyURL) + if coverUploadID != "" { + if legacyURL != "" { + return "", nil, xerr.New(xerr.InvalidArgument, "cover_upload_id and room_avatar are mutually exclusive") + } + return s.resolveRoomCoverForMutation(ctx, requestID, roomID, actorUserID, coverUploadID) + } + if legacyURL == "" { + return "", nil, xerr.New(xerr.InvalidArgument, "cover_upload_id is required") + } + if robotRoom || legacyURL == defaultRoomAvatar { + return legacyURL, nil, nil + } + access, err := s.loadEffectiveVIPAccess(ctx, requestID, actorUserID) + if err != nil { + return "", nil, err + } + if access.programType == tieredPrivilegeVIPProgram { + // Fami 当前由 wallet 配置为权益制;这里不识别 app_code,也不读取 VIP 等级数字。 + return "", nil, xerr.New(xerr.InvalidArgument, "cover_upload_id is required") + } + return legacyURL, nil, nil +} + func roomMediaExpectedObjectKey(rawAppCode string, actorUserID int64, roomID string, purpose string, commandID string, media state.RoomMedia) string { commandHash := sha256.Sum256([]byte(strings.TrimSpace(commandID))) extension := media.Format diff --git a/services/room-service/internal/room/service/mic.go b/services/room-service/internal/room/service/mic.go index a287ce0f..7f87c40d 100644 --- a/services/room-service/internal/room/service/mic.go +++ b/services/room-service/internal/room/service/mic.go @@ -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, diff --git a/services/room-service/internal/room/service/mic_test.go b/services/room-service/internal/room/service/mic_test.go index dd5c1fbf..1e8030f3 100644 --- a/services/room-service/internal/room/service/mic_test.go +++ b/services/room-service/internal/room/service/mic_test.go @@ -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) diff --git a/services/room-service/internal/room/service/pipeline.go b/services/room-service/internal/room/service/pipeline.go index 41babfd2..c4a119b2 100644 --- a/services/room-service/internal/room/service/pipeline.go +++ b/services/room-service/internal/room/service/pipeline.go @@ -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) { diff --git a/services/room-service/internal/room/service/profile.go b/services/room-service/internal/room/service/profile.go index 2219c6e6..3130163f 100644 --- a/services/room-service/internal/room/service/profile.go +++ b/services/room-service/internal/room/service/profile.go @@ -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, diff --git a/services/room-service/internal/room/service/repository.go b/services/room-service/internal/room/service/repository.go index 61146912..248094cd 100644 --- a/services/room-service/internal/room/service/repository.go +++ b/services/room-service/internal/room/service/repository.go @@ -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) diff --git a/services/room-service/internal/room/service/room_gift_leaderboard.go b/services/room-service/internal/room/service/room_gift_leaderboard.go index 38c98487..dbcc3729 100644 --- a/services/room-service/internal/room/service/room_gift_leaderboard.go +++ b/services/room-service/internal/room/service/room_gift_leaderboard.go @@ -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: diff --git a/services/room-service/internal/room/service/service.go b/services/room-service/internal/room/service/service.go index 91a0c027..e7a98742 100644 --- a/services/room-service/internal/room/service/service.go +++ b/services/room-service/internal/room/service/service.go @@ -226,6 +226,8 @@ type mutationResult struct { roomWeeklyContribution *RoomWeeklyContributionIncrement // roomDecorationAssignment 和 Room Cell 装扮状态同事务更新,供 wallet 事件按索引即时重算。 roomDecorationAssignment *RoomDecorationAssignmentChange + // roomCoverUploadConsumption 让房间资料 URL 与一次性上传授权在同一命令事务内绑定。 + roomCoverUploadConsumption *RoomCoverUploadConsumption } // New 初始化 room-service 领域服务。 diff --git a/services/room-service/internal/room/service/vip.go b/services/room-service/internal/room/service/vip.go index e62b1977..62215800 100644 --- a/services/room-service/internal/room/service/vip.go +++ b/services/room-service/internal/room/service/vip.go @@ -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" diff --git a/services/room-service/internal/storage/mysql/command_log.go b/services/room-service/internal/storage/mysql/command_log.go index 46bf49b4..34803009 100644 --- a/services/room-service/internal/storage/mysql/command_log.go +++ b/services/room-service/internal/storage/mysql/command_log.go @@ -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 { diff --git a/services/room-service/internal/storage/mysql/room_media_upload.go b/services/room-service/internal/storage/mysql/room_media_upload.go index db48986f..7176e56a 100644 --- a/services/room-service/internal/storage/mysql/room_media_upload.go +++ b/services/room-service/internal/storage/mysql/room_media_upload.go @@ -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" } } diff --git a/services/wallet-service/internal/storage/mysql/resource_entitlement_repository.go b/services/wallet-service/internal/storage/mysql/resource_entitlement_repository.go index eb45b5b0..989d5d38 100644 --- a/services/wallet-service/internal/storage/mysql/resource_entitlement_repository.go +++ b/services/wallet-service/internal/storage/mysql/resource_entitlement_repository.go @@ -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 中的当前或历史显式绑定。