Merge branch 'main' of gitea.haiyihy.com:hy/hyapp-server

This commit is contained in:
zhx 2026-05-14 09:57:03 +08:00
commit 4a4e40bc7d
122 changed files with 14858 additions and 3008 deletions

View File

@ -14,7 +14,7 @@ HY Voice Room 是语音房后端 monorepo。当前架构不再自研 IM 长连
- `user-service` 当前承接登录、三方注册、密码、refresh session、用户主数据、默认短号和临时靓号基础能力。
- `activity-service` 当前承接每日任务、活动/系统消息基础能力和房间事件 consumer 边界;`room_outbox` 到 activity/audit 的实际消费链路按独立消费位点接入。
- `cron-service` 当前负责后台调度、任务运行记录,以及通过内部 gRPC 触发 owner service 批处理;它不直接拥有房间、账务、用户或活动业务状态。
- `notice-service` 当前消费 `wallet_outbox` 的余额变更事件,异步投递腾讯云 IM 单聊私有通知;后续 push、站内 inbox 和其它 owner outbox 消费按模块继续扩展
- `notice-service` 当前消费 `wallet_outbox` 的余额变更事件`room_outbox` 的踢人事件,异步投递腾讯云 IM 单聊私有通知;房间群消息和群成员控制仍由 `room-service` 的房间 IM bridge 处理
- 腾讯云 IM 承担客户端长连接、群消息、公屏、单聊、离线/漫游和全球接入能力;本仓库不再部署 `im-service`
- 多 App 共用同一套后端服务,入口由 `gateway-service` 通过包名或 `app_code` 解析租户;所有业务库表按 `app_code` 隔离,设计细节见 `docs/多App租户架构.md`
@ -107,7 +107,7 @@ Authorization: Bearer <access_token>
- `X-Request-ID`/`request_id` 由后端生成,只做链路追踪;写操作的 `command_id` 由前端按用户动作生成,用于服务端幂等。
- `room-service``CreateRoom` 成功后写 `room_outbox`,由 outbox worker 异步创建或补偿同名腾讯云 IM 群组。
- `room-service` 在上下麦、禁言、踢人、送礼等命令提交后只写房间状态、command log 和 outbox腾讯云 IM 房间系统自定义消息由 outbox worker 异步投递。
- `notice-service` 消费钱包余额变更 outbox,向单个用户发送 `wallet_notice` 私有自定义消息;钱包不会直接调用腾讯云 IM
- `notice-service` 消费钱包余额变更 outbox 和房间踢人 outbox向单个用户发送 `wallet_notice``room_notice` 私有自定义消息owner service 不直接调用腾讯云 IM 单聊
- `gateway-service` 已提供 `/api/v1/tencent-im/callback`,支持腾讯云 IM 入群前和发言前回调,并分别回查 `VerifyRoomPresence``CheckSpeakPermission`
腾讯云 IM 配置分别在:
@ -197,7 +197,7 @@ services/notice-service/ async user notice delivery workers
- 腾讯云 IM 回调入口已经落在 gateway线上仍必须在腾讯云 IM 控制台启用 `Group.CallbackBeforeApplyJoinGroup``Group.CallbackBeforeSendMsg`,并配置公网 callback URL 与鉴权 token。
- 前端对同一次用户写动作的 HTTP 重试必须复用同一个 `command_id``X-Request-ID` 不需要也不应该由前端传入。
- `wallet-service` App 运行路径使用 MySQL 扣费事务;本地需要先通过 compose initdb 建好 `hyapp_wallet`
- `notice-service` 本地默认不启用腾讯云 IM worker线上启用 `wallet_notice_worker.enabled=true` 时必须同时启用 `tencent_im.enabled=true`
- `notice-service` 本地默认不启用腾讯云 IM worker线上启用 `wallet_notice_worker.enabled=true` `room_notice_worker.enabled=true` 时必须同时启用 `tencent_im.enabled=true`
- `activity-service` 已承接每日任务和消息/活动基础能力;`room_outbox` 到 activity/audit 的实际消费链路仍需按独立消费位点补齐。
- `room-service` App 已启动 `room_outbox` 补偿 worker该状态只表示腾讯云 IM 系统消息补偿结果activity/audit 后续必须使用独立消费位点。
- JWT、腾讯云 IM secret 等本地配置只能用开发值,线上必须走安全配置源。

File diff suppressed because it is too large Load Diff

View File

@ -1,7 +1,7 @@
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
// versions:
// - protoc-gen-go-grpc v1.5.1
// - protoc v5.29.3
// - protoc v5.29.2
// source: proto/activity/v1/activity.proto
package activityv1

View File

@ -1,7 +1,7 @@
// Code generated by protoc-gen-go. DO NOT EDIT.
// versions:
// protoc-gen-go v1.35.1
// protoc v5.29.3
// protoc-gen-go v1.34.2
// protoc v5.29.2
// source: proto/events/room/v1/events.proto
package roomeventsv1
@ -38,9 +38,11 @@ type EventEnvelope struct {
func (x *EventEnvelope) Reset() {
*x = EventEnvelope{}
mi := &file_proto_events_room_v1_events_proto_msgTypes[0]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
if protoimpl.UnsafeEnabled {
mi := &file_proto_events_room_v1_events_proto_msgTypes[0]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
}
func (x *EventEnvelope) String() string {
@ -51,7 +53,7 @@ func (*EventEnvelope) ProtoMessage() {}
func (x *EventEnvelope) ProtoReflect() protoreflect.Message {
mi := &file_proto_events_room_v1_events_proto_msgTypes[0]
if x != nil {
if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
@ -132,9 +134,11 @@ type RoomCreated struct {
func (x *RoomCreated) Reset() {
*x = RoomCreated{}
mi := &file_proto_events_room_v1_events_proto_msgTypes[1]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
if protoimpl.UnsafeEnabled {
mi := &file_proto_events_room_v1_events_proto_msgTypes[1]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
}
func (x *RoomCreated) String() string {
@ -145,7 +149,7 @@ func (*RoomCreated) ProtoMessage() {}
func (x *RoomCreated) ProtoReflect() protoreflect.Message {
mi := &file_proto_events_room_v1_events_proto_msgTypes[1]
if x != nil {
if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
@ -217,9 +221,11 @@ type RoomProfileUpdated struct {
func (x *RoomProfileUpdated) Reset() {
*x = RoomProfileUpdated{}
mi := &file_proto_events_room_v1_events_proto_msgTypes[2]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
if protoimpl.UnsafeEnabled {
mi := &file_proto_events_room_v1_events_proto_msgTypes[2]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
}
func (x *RoomProfileUpdated) String() string {
@ -230,7 +236,7 @@ func (*RoomProfileUpdated) ProtoMessage() {}
func (x *RoomProfileUpdated) ProtoReflect() protoreflect.Message {
mi := &file_proto_events_room_v1_events_proto_msgTypes[2]
if x != nil {
if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
@ -292,9 +298,11 @@ type RoomUserJoined struct {
func (x *RoomUserJoined) Reset() {
*x = RoomUserJoined{}
mi := &file_proto_events_room_v1_events_proto_msgTypes[3]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
if protoimpl.UnsafeEnabled {
mi := &file_proto_events_room_v1_events_proto_msgTypes[3]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
}
func (x *RoomUserJoined) String() string {
@ -305,7 +313,7 @@ func (*RoomUserJoined) ProtoMessage() {}
func (x *RoomUserJoined) ProtoReflect() protoreflect.Message {
mi := &file_proto_events_room_v1_events_proto_msgTypes[3]
if x != nil {
if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
@ -345,9 +353,11 @@ type RoomUserLeft struct {
func (x *RoomUserLeft) Reset() {
*x = RoomUserLeft{}
mi := &file_proto_events_room_v1_events_proto_msgTypes[4]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
if protoimpl.UnsafeEnabled {
mi := &file_proto_events_room_v1_events_proto_msgTypes[4]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
}
func (x *RoomUserLeft) String() string {
@ -358,7 +368,7 @@ func (*RoomUserLeft) ProtoMessage() {}
func (x *RoomUserLeft) ProtoReflect() protoreflect.Message {
mi := &file_proto_events_room_v1_events_proto_msgTypes[4]
if x != nil {
if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
@ -392,9 +402,11 @@ type RoomClosed struct {
func (x *RoomClosed) Reset() {
*x = RoomClosed{}
mi := &file_proto_events_room_v1_events_proto_msgTypes[5]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
if protoimpl.UnsafeEnabled {
mi := &file_proto_events_room_v1_events_proto_msgTypes[5]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
}
func (x *RoomClosed) String() string {
@ -405,7 +417,7 @@ func (*RoomClosed) ProtoMessage() {}
func (x *RoomClosed) ProtoReflect() protoreflect.Message {
mi := &file_proto_events_room_v1_events_proto_msgTypes[5]
if x != nil {
if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
@ -458,9 +470,11 @@ type RoomMicChanged struct {
func (x *RoomMicChanged) Reset() {
*x = RoomMicChanged{}
mi := &file_proto_events_room_v1_events_proto_msgTypes[6]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
if protoimpl.UnsafeEnabled {
mi := &file_proto_events_room_v1_events_proto_msgTypes[6]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
}
func (x *RoomMicChanged) String() string {
@ -471,7 +485,7 @@ func (*RoomMicChanged) ProtoMessage() {}
func (x *RoomMicChanged) ProtoReflect() protoreflect.Message {
mi := &file_proto_events_room_v1_events_proto_msgTypes[6]
if x != nil {
if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
@ -576,9 +590,11 @@ type RoomMicSeatLocked struct {
func (x *RoomMicSeatLocked) Reset() {
*x = RoomMicSeatLocked{}
mi := &file_proto_events_room_v1_events_proto_msgTypes[7]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
if protoimpl.UnsafeEnabled {
mi := &file_proto_events_room_v1_events_proto_msgTypes[7]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
}
func (x *RoomMicSeatLocked) String() string {
@ -589,7 +605,7 @@ func (*RoomMicSeatLocked) ProtoMessage() {}
func (x *RoomMicSeatLocked) ProtoReflect() protoreflect.Message {
mi := &file_proto_events_room_v1_events_proto_msgTypes[7]
if x != nil {
if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
@ -637,9 +653,11 @@ type RoomChatEnabledChanged struct {
func (x *RoomChatEnabledChanged) Reset() {
*x = RoomChatEnabledChanged{}
mi := &file_proto_events_room_v1_events_proto_msgTypes[8]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
if protoimpl.UnsafeEnabled {
mi := &file_proto_events_room_v1_events_proto_msgTypes[8]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
}
func (x *RoomChatEnabledChanged) String() string {
@ -650,7 +668,7 @@ func (*RoomChatEnabledChanged) ProtoMessage() {}
func (x *RoomChatEnabledChanged) ProtoReflect() protoreflect.Message {
mi := &file_proto_events_room_v1_events_proto_msgTypes[8]
if x != nil {
if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
@ -679,6 +697,62 @@ func (x *RoomChatEnabledChanged) GetEnabled() bool {
return false
}
// RoomPasswordChanged 表达房间入房密码状态变更,不携带明文或哈希。
type RoomPasswordChanged struct {
state protoimpl.MessageState
sizeCache protoimpl.SizeCache
unknownFields protoimpl.UnknownFields
ActorUserId int64 `protobuf:"varint,1,opt,name=actor_user_id,json=actorUserId,proto3" json:"actor_user_id,omitempty"`
Locked bool `protobuf:"varint,2,opt,name=locked,proto3" json:"locked,omitempty"`
}
func (x *RoomPasswordChanged) Reset() {
*x = RoomPasswordChanged{}
if protoimpl.UnsafeEnabled {
mi := &file_proto_events_room_v1_events_proto_msgTypes[9]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
}
func (x *RoomPasswordChanged) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*RoomPasswordChanged) ProtoMessage() {}
func (x *RoomPasswordChanged) ProtoReflect() protoreflect.Message {
mi := &file_proto_events_room_v1_events_proto_msgTypes[9]
if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use RoomPasswordChanged.ProtoReflect.Descriptor instead.
func (*RoomPasswordChanged) Descriptor() ([]byte, []int) {
return file_proto_events_room_v1_events_proto_rawDescGZIP(), []int{9}
}
func (x *RoomPasswordChanged) GetActorUserId() int64 {
if x != nil {
return x.ActorUserId
}
return 0
}
func (x *RoomPasswordChanged) GetLocked() bool {
if x != nil {
return x.Locked
}
return false
}
// RoomAdminChanged 表达房间管理员集合变更。
type RoomAdminChanged struct {
state protoimpl.MessageState
@ -692,9 +766,11 @@ type RoomAdminChanged struct {
func (x *RoomAdminChanged) Reset() {
*x = RoomAdminChanged{}
mi := &file_proto_events_room_v1_events_proto_msgTypes[9]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
if protoimpl.UnsafeEnabled {
mi := &file_proto_events_room_v1_events_proto_msgTypes[10]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
}
func (x *RoomAdminChanged) String() string {
@ -704,8 +780,8 @@ func (x *RoomAdminChanged) String() string {
func (*RoomAdminChanged) ProtoMessage() {}
func (x *RoomAdminChanged) ProtoReflect() protoreflect.Message {
mi := &file_proto_events_room_v1_events_proto_msgTypes[9]
if x != nil {
mi := &file_proto_events_room_v1_events_proto_msgTypes[10]
if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
@ -717,7 +793,7 @@ func (x *RoomAdminChanged) ProtoReflect() protoreflect.Message {
// Deprecated: Use RoomAdminChanged.ProtoReflect.Descriptor instead.
func (*RoomAdminChanged) Descriptor() ([]byte, []int) {
return file_proto_events_room_v1_events_proto_rawDescGZIP(), []int{9}
return file_proto_events_room_v1_events_proto_rawDescGZIP(), []int{10}
}
func (x *RoomAdminChanged) GetActorUserId() int64 {
@ -754,9 +830,11 @@ type RoomUserMuted struct {
func (x *RoomUserMuted) Reset() {
*x = RoomUserMuted{}
mi := &file_proto_events_room_v1_events_proto_msgTypes[10]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
if protoimpl.UnsafeEnabled {
mi := &file_proto_events_room_v1_events_proto_msgTypes[11]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
}
func (x *RoomUserMuted) String() string {
@ -766,8 +844,8 @@ func (x *RoomUserMuted) String() string {
func (*RoomUserMuted) ProtoMessage() {}
func (x *RoomUserMuted) ProtoReflect() protoreflect.Message {
mi := &file_proto_events_room_v1_events_proto_msgTypes[10]
if x != nil {
mi := &file_proto_events_room_v1_events_proto_msgTypes[11]
if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
@ -779,7 +857,7 @@ func (x *RoomUserMuted) ProtoReflect() protoreflect.Message {
// Deprecated: Use RoomUserMuted.ProtoReflect.Descriptor instead.
func (*RoomUserMuted) Descriptor() ([]byte, []int) {
return file_proto_events_room_v1_events_proto_rawDescGZIP(), []int{10}
return file_proto_events_room_v1_events_proto_rawDescGZIP(), []int{11}
}
func (x *RoomUserMuted) GetActorUserId() int64 {
@ -815,9 +893,11 @@ type RoomUserKicked struct {
func (x *RoomUserKicked) Reset() {
*x = RoomUserKicked{}
mi := &file_proto_events_room_v1_events_proto_msgTypes[11]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
if protoimpl.UnsafeEnabled {
mi := &file_proto_events_room_v1_events_proto_msgTypes[12]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
}
func (x *RoomUserKicked) String() string {
@ -827,8 +907,8 @@ func (x *RoomUserKicked) String() string {
func (*RoomUserKicked) ProtoMessage() {}
func (x *RoomUserKicked) ProtoReflect() protoreflect.Message {
mi := &file_proto_events_room_v1_events_proto_msgTypes[11]
if x != nil {
mi := &file_proto_events_room_v1_events_proto_msgTypes[12]
if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
@ -840,7 +920,7 @@ func (x *RoomUserKicked) ProtoReflect() protoreflect.Message {
// Deprecated: Use RoomUserKicked.ProtoReflect.Descriptor instead.
func (*RoomUserKicked) Descriptor() ([]byte, []int) {
return file_proto_events_room_v1_events_proto_rawDescGZIP(), []int{11}
return file_proto_events_room_v1_events_proto_rawDescGZIP(), []int{12}
}
func (x *RoomUserKicked) GetActorUserId() int64 {
@ -869,9 +949,11 @@ type RoomUserUnbanned struct {
func (x *RoomUserUnbanned) Reset() {
*x = RoomUserUnbanned{}
mi := &file_proto_events_room_v1_events_proto_msgTypes[12]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
if protoimpl.UnsafeEnabled {
mi := &file_proto_events_room_v1_events_proto_msgTypes[13]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
}
func (x *RoomUserUnbanned) String() string {
@ -881,8 +963,8 @@ func (x *RoomUserUnbanned) String() string {
func (*RoomUserUnbanned) ProtoMessage() {}
func (x *RoomUserUnbanned) ProtoReflect() protoreflect.Message {
mi := &file_proto_events_room_v1_events_proto_msgTypes[12]
if x != nil {
mi := &file_proto_events_room_v1_events_proto_msgTypes[13]
if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
@ -894,7 +976,7 @@ func (x *RoomUserUnbanned) ProtoReflect() protoreflect.Message {
// Deprecated: Use RoomUserUnbanned.ProtoReflect.Descriptor instead.
func (*RoomUserUnbanned) Descriptor() ([]byte, []int) {
return file_proto_events_room_v1_events_proto_rawDescGZIP(), []int{12}
return file_proto_events_room_v1_events_proto_rawDescGZIP(), []int{13}
}
func (x *RoomUserUnbanned) GetActorUserId() int64 {
@ -929,9 +1011,11 @@ type RoomGiftSent struct {
func (x *RoomGiftSent) Reset() {
*x = RoomGiftSent{}
mi := &file_proto_events_room_v1_events_proto_msgTypes[13]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
if protoimpl.UnsafeEnabled {
mi := &file_proto_events_room_v1_events_proto_msgTypes[14]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
}
func (x *RoomGiftSent) String() string {
@ -941,8 +1025,8 @@ func (x *RoomGiftSent) String() string {
func (*RoomGiftSent) ProtoMessage() {}
func (x *RoomGiftSent) ProtoReflect() protoreflect.Message {
mi := &file_proto_events_room_v1_events_proto_msgTypes[13]
if x != nil {
mi := &file_proto_events_room_v1_events_proto_msgTypes[14]
if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
@ -954,7 +1038,7 @@ func (x *RoomGiftSent) ProtoReflect() protoreflect.Message {
// Deprecated: Use RoomGiftSent.ProtoReflect.Descriptor instead.
func (*RoomGiftSent) Descriptor() ([]byte, []int) {
return file_proto_events_room_v1_events_proto_rawDescGZIP(), []int{13}
return file_proto_events_room_v1_events_proto_rawDescGZIP(), []int{14}
}
func (x *RoomGiftSent) GetSenderUserId() int64 {
@ -1025,9 +1109,11 @@ type RoomHeatChanged struct {
func (x *RoomHeatChanged) Reset() {
*x = RoomHeatChanged{}
mi := &file_proto_events_room_v1_events_proto_msgTypes[14]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
if protoimpl.UnsafeEnabled {
mi := &file_proto_events_room_v1_events_proto_msgTypes[15]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
}
func (x *RoomHeatChanged) String() string {
@ -1037,8 +1123,8 @@ func (x *RoomHeatChanged) String() string {
func (*RoomHeatChanged) ProtoMessage() {}
func (x *RoomHeatChanged) ProtoReflect() protoreflect.Message {
mi := &file_proto_events_room_v1_events_proto_msgTypes[14]
if x != nil {
mi := &file_proto_events_room_v1_events_proto_msgTypes[15]
if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
@ -1050,7 +1136,7 @@ func (x *RoomHeatChanged) ProtoReflect() protoreflect.Message {
// Deprecated: Use RoomHeatChanged.ProtoReflect.Descriptor instead.
func (*RoomHeatChanged) Descriptor() ([]byte, []int) {
return file_proto_events_room_v1_events_proto_rawDescGZIP(), []int{14}
return file_proto_events_room_v1_events_proto_rawDescGZIP(), []int{15}
}
func (x *RoomHeatChanged) GetDelta() int64 {
@ -1080,9 +1166,11 @@ type RoomRankChanged struct {
func (x *RoomRankChanged) Reset() {
*x = RoomRankChanged{}
mi := &file_proto_events_room_v1_events_proto_msgTypes[15]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
if protoimpl.UnsafeEnabled {
mi := &file_proto_events_room_v1_events_proto_msgTypes[16]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
}
func (x *RoomRankChanged) String() string {
@ -1092,8 +1180,8 @@ func (x *RoomRankChanged) String() string {
func (*RoomRankChanged) ProtoMessage() {}
func (x *RoomRankChanged) ProtoReflect() protoreflect.Message {
mi := &file_proto_events_room_v1_events_proto_msgTypes[15]
if x != nil {
mi := &file_proto_events_room_v1_events_proto_msgTypes[16]
if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
@ -1105,7 +1193,7 @@ func (x *RoomRankChanged) ProtoReflect() protoreflect.Message {
// Deprecated: Use RoomRankChanged.ProtoReflect.Descriptor instead.
func (*RoomRankChanged) Descriptor() ([]byte, []int) {
return file_proto_events_room_v1_events_proto_rawDescGZIP(), []int{15}
return file_proto_events_room_v1_events_proto_rawDescGZIP(), []int{16}
}
func (x *RoomRankChanged) GetUserId() int64 {
@ -1222,67 +1310,72 @@ var file_proto_events_room_v1_events_proto_rawDesc = []byte{
0x0d, 0x61, 0x63, 0x74, 0x6f, 0x72, 0x5f, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x01,
0x20, 0x01, 0x28, 0x03, 0x52, 0x0b, 0x61, 0x63, 0x74, 0x6f, 0x72, 0x55, 0x73, 0x65, 0x72, 0x49,
0x64, 0x12, 0x18, 0x0a, 0x07, 0x65, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x18, 0x02, 0x20, 0x01,
0x28, 0x08, 0x52, 0x07, 0x65, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x22, 0x76, 0x0a, 0x10, 0x52,
0x6f, 0x6f, 0x6d, 0x41, 0x64, 0x6d, 0x69, 0x6e, 0x43, 0x68, 0x61, 0x6e, 0x67, 0x65, 0x64, 0x12,
0x22, 0x0a, 0x0d, 0x61, 0x63, 0x74, 0x6f, 0x72, 0x5f, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64,
0x18, 0x01, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0b, 0x61, 0x63, 0x74, 0x6f, 0x72, 0x55, 0x73, 0x65,
0x72, 0x49, 0x64, 0x12, 0x24, 0x0a, 0x0e, 0x74, 0x61, 0x72, 0x67, 0x65, 0x74, 0x5f, 0x75, 0x73,
0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0c, 0x74, 0x61, 0x72,
0x67, 0x65, 0x74, 0x55, 0x73, 0x65, 0x72, 0x49, 0x64, 0x12, 0x18, 0x0a, 0x07, 0x65, 0x6e, 0x61,
0x62, 0x6c, 0x65, 0x64, 0x18, 0x03, 0x20, 0x01, 0x28, 0x08, 0x52, 0x07, 0x65, 0x6e, 0x61, 0x62,
0x6c, 0x65, 0x64, 0x22, 0x6f, 0x0a, 0x0d, 0x52, 0x6f, 0x6f, 0x6d, 0x55, 0x73, 0x65, 0x72, 0x4d,
0x75, 0x74, 0x65, 0x64, 0x12, 0x22, 0x0a, 0x0d, 0x61, 0x63, 0x74, 0x6f, 0x72, 0x5f, 0x75, 0x73,
0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0b, 0x61, 0x63, 0x74,
0x6f, 0x72, 0x55, 0x73, 0x65, 0x72, 0x49, 0x64, 0x12, 0x24, 0x0a, 0x0e, 0x74, 0x61, 0x72, 0x67,
0x28, 0x08, 0x52, 0x07, 0x65, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x22, 0x51, 0x0a, 0x13, 0x52,
0x6f, 0x6f, 0x6d, 0x50, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x43, 0x68, 0x61, 0x6e, 0x67,
0x65, 0x64, 0x12, 0x22, 0x0a, 0x0d, 0x61, 0x63, 0x74, 0x6f, 0x72, 0x5f, 0x75, 0x73, 0x65, 0x72,
0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0b, 0x61, 0x63, 0x74, 0x6f, 0x72,
0x55, 0x73, 0x65, 0x72, 0x49, 0x64, 0x12, 0x16, 0x0a, 0x06, 0x6c, 0x6f, 0x63, 0x6b, 0x65, 0x64,
0x18, 0x02, 0x20, 0x01, 0x28, 0x08, 0x52, 0x06, 0x6c, 0x6f, 0x63, 0x6b, 0x65, 0x64, 0x22, 0x76,
0x0a, 0x10, 0x52, 0x6f, 0x6f, 0x6d, 0x41, 0x64, 0x6d, 0x69, 0x6e, 0x43, 0x68, 0x61, 0x6e, 0x67,
0x65, 0x64, 0x12, 0x22, 0x0a, 0x0d, 0x61, 0x63, 0x74, 0x6f, 0x72, 0x5f, 0x75, 0x73, 0x65, 0x72,
0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0b, 0x61, 0x63, 0x74, 0x6f, 0x72,
0x55, 0x73, 0x65, 0x72, 0x49, 0x64, 0x12, 0x24, 0x0a, 0x0e, 0x74, 0x61, 0x72, 0x67, 0x65, 0x74,
0x5f, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0c,
0x74, 0x61, 0x72, 0x67, 0x65, 0x74, 0x55, 0x73, 0x65, 0x72, 0x49, 0x64, 0x12, 0x18, 0x0a, 0x07,
0x65, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x18, 0x03, 0x20, 0x01, 0x28, 0x08, 0x52, 0x07, 0x65,
0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x22, 0x6f, 0x0a, 0x0d, 0x52, 0x6f, 0x6f, 0x6d, 0x55, 0x73,
0x65, 0x72, 0x4d, 0x75, 0x74, 0x65, 0x64, 0x12, 0x22, 0x0a, 0x0d, 0x61, 0x63, 0x74, 0x6f, 0x72,
0x5f, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0b,
0x61, 0x63, 0x74, 0x6f, 0x72, 0x55, 0x73, 0x65, 0x72, 0x49, 0x64, 0x12, 0x24, 0x0a, 0x0e, 0x74,
0x61, 0x72, 0x67, 0x65, 0x74, 0x5f, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20,
0x01, 0x28, 0x03, 0x52, 0x0c, 0x74, 0x61, 0x72, 0x67, 0x65, 0x74, 0x55, 0x73, 0x65, 0x72, 0x49,
0x64, 0x12, 0x14, 0x0a, 0x05, 0x6d, 0x75, 0x74, 0x65, 0x64, 0x18, 0x03, 0x20, 0x01, 0x28, 0x08,
0x52, 0x05, 0x6d, 0x75, 0x74, 0x65, 0x64, 0x22, 0x5a, 0x0a, 0x0e, 0x52, 0x6f, 0x6f, 0x6d, 0x55,
0x73, 0x65, 0x72, 0x4b, 0x69, 0x63, 0x6b, 0x65, 0x64, 0x12, 0x22, 0x0a, 0x0d, 0x61, 0x63, 0x74,
0x6f, 0x72, 0x5f, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x03,
0x52, 0x0b, 0x61, 0x63, 0x74, 0x6f, 0x72, 0x55, 0x73, 0x65, 0x72, 0x49, 0x64, 0x12, 0x24, 0x0a,
0x0e, 0x74, 0x61, 0x72, 0x67, 0x65, 0x74, 0x5f, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18,
0x02, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0c, 0x74, 0x61, 0x72, 0x67, 0x65, 0x74, 0x55, 0x73, 0x65,
0x72, 0x49, 0x64, 0x22, 0x5c, 0x0a, 0x10, 0x52, 0x6f, 0x6f, 0x6d, 0x55, 0x73, 0x65, 0x72, 0x55,
0x6e, 0x62, 0x61, 0x6e, 0x6e, 0x65, 0x64, 0x12, 0x22, 0x0a, 0x0d, 0x61, 0x63, 0x74, 0x6f, 0x72,
0x5f, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0b,
0x61, 0x63, 0x74, 0x6f, 0x72, 0x55, 0x73, 0x65, 0x72, 0x49, 0x64, 0x12, 0x24, 0x0a, 0x0e, 0x74,
0x61, 0x72, 0x67, 0x65, 0x74, 0x5f, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20,
0x01, 0x28, 0x03, 0x52, 0x0c, 0x74, 0x61, 0x72, 0x67, 0x65, 0x74, 0x55, 0x73, 0x65, 0x72, 0x49,
0x64, 0x22, 0xaa, 0x02, 0x0a, 0x0c, 0x52, 0x6f, 0x6f, 0x6d, 0x47, 0x69, 0x66, 0x74, 0x53, 0x65,
0x6e, 0x74, 0x12, 0x24, 0x0a, 0x0e, 0x73, 0x65, 0x6e, 0x64, 0x65, 0x72, 0x5f, 0x75, 0x73, 0x65,
0x72, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0c, 0x73, 0x65, 0x6e, 0x64,
0x65, 0x72, 0x55, 0x73, 0x65, 0x72, 0x49, 0x64, 0x12, 0x24, 0x0a, 0x0e, 0x74, 0x61, 0x72, 0x67,
0x65, 0x74, 0x5f, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x03,
0x52, 0x0c, 0x74, 0x61, 0x72, 0x67, 0x65, 0x74, 0x55, 0x73, 0x65, 0x72, 0x49, 0x64, 0x12, 0x14,
0x0a, 0x05, 0x6d, 0x75, 0x74, 0x65, 0x64, 0x18, 0x03, 0x20, 0x01, 0x28, 0x08, 0x52, 0x05, 0x6d,
0x75, 0x74, 0x65, 0x64, 0x22, 0x5a, 0x0a, 0x0e, 0x52, 0x6f, 0x6f, 0x6d, 0x55, 0x73, 0x65, 0x72,
0x4b, 0x69, 0x63, 0x6b, 0x65, 0x64, 0x12, 0x22, 0x0a, 0x0d, 0x61, 0x63, 0x74, 0x6f, 0x72, 0x5f,
0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0b, 0x61,
0x63, 0x74, 0x6f, 0x72, 0x55, 0x73, 0x65, 0x72, 0x49, 0x64, 0x12, 0x24, 0x0a, 0x0e, 0x74, 0x61,
0x72, 0x67, 0x65, 0x74, 0x5f, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01,
0x28, 0x03, 0x52, 0x0c, 0x74, 0x61, 0x72, 0x67, 0x65, 0x74, 0x55, 0x73, 0x65, 0x72, 0x49, 0x64,
0x22, 0x5c, 0x0a, 0x10, 0x52, 0x6f, 0x6f, 0x6d, 0x55, 0x73, 0x65, 0x72, 0x55, 0x6e, 0x62, 0x61,
0x6e, 0x6e, 0x65, 0x64, 0x12, 0x22, 0x0a, 0x0d, 0x61, 0x63, 0x74, 0x6f, 0x72, 0x5f, 0x75, 0x73,
0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0b, 0x61, 0x63, 0x74,
0x6f, 0x72, 0x55, 0x73, 0x65, 0x72, 0x49, 0x64, 0x12, 0x24, 0x0a, 0x0e, 0x74, 0x61, 0x72, 0x67,
0x65, 0x74, 0x5f, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x03,
0x52, 0x0c, 0x74, 0x61, 0x72, 0x67, 0x65, 0x74, 0x55, 0x73, 0x65, 0x72, 0x49, 0x64, 0x22, 0xaa,
0x02, 0x0a, 0x0c, 0x52, 0x6f, 0x6f, 0x6d, 0x47, 0x69, 0x66, 0x74, 0x53, 0x65, 0x6e, 0x74, 0x12,
0x24, 0x0a, 0x0e, 0x73, 0x65, 0x6e, 0x64, 0x65, 0x72, 0x5f, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69,
0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0c, 0x73, 0x65, 0x6e, 0x64, 0x65, 0x72, 0x55,
0x73, 0x65, 0x72, 0x49, 0x64, 0x12, 0x24, 0x0a, 0x0e, 0x74, 0x61, 0x72, 0x67, 0x65, 0x74, 0x5f,
0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0c, 0x74,
0x61, 0x72, 0x67, 0x65, 0x74, 0x55, 0x73, 0x65, 0x72, 0x49, 0x64, 0x12, 0x17, 0x0a, 0x07, 0x67,
0x69, 0x66, 0x74, 0x5f, 0x69, 0x64, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x67, 0x69,
0x66, 0x74, 0x49, 0x64, 0x12, 0x1d, 0x0a, 0x0a, 0x67, 0x69, 0x66, 0x74, 0x5f, 0x63, 0x6f, 0x75,
0x6e, 0x74, 0x18, 0x04, 0x20, 0x01, 0x28, 0x05, 0x52, 0x09, 0x67, 0x69, 0x66, 0x74, 0x43, 0x6f,
0x75, 0x6e, 0x74, 0x12, 0x1d, 0x0a, 0x0a, 0x67, 0x69, 0x66, 0x74, 0x5f, 0x76, 0x61, 0x6c, 0x75,
0x65, 0x18, 0x05, 0x20, 0x01, 0x28, 0x03, 0x52, 0x09, 0x67, 0x69, 0x66, 0x74, 0x56, 0x61, 0x6c,
0x75, 0x65, 0x12, 0x2c, 0x0a, 0x12, 0x62, 0x69, 0x6c, 0x6c, 0x69, 0x6e, 0x67, 0x5f, 0x72, 0x65,
0x63, 0x65, 0x69, 0x70, 0x74, 0x5f, 0x69, 0x64, 0x18, 0x06, 0x20, 0x01, 0x28, 0x09, 0x52, 0x10,
0x62, 0x69, 0x6c, 0x6c, 0x69, 0x6e, 0x67, 0x52, 0x65, 0x63, 0x65, 0x69, 0x70, 0x74, 0x49, 0x64,
0x12, 0x2a, 0x0a, 0x11, 0x76, 0x69, 0x73, 0x69, 0x62, 0x6c, 0x65, 0x5f, 0x72, 0x65, 0x67, 0x69,
0x6f, 0x6e, 0x5f, 0x69, 0x64, 0x18, 0x07, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0f, 0x76, 0x69, 0x73,
0x69, 0x62, 0x6c, 0x65, 0x52, 0x65, 0x67, 0x69, 0x6f, 0x6e, 0x49, 0x64, 0x12, 0x1d, 0x0a, 0x0a,
0x63, 0x6f, 0x6d, 0x6d, 0x61, 0x6e, 0x64, 0x5f, 0x69, 0x64, 0x18, 0x08, 0x20, 0x01, 0x28, 0x09,
0x52, 0x09, 0x63, 0x6f, 0x6d, 0x6d, 0x61, 0x6e, 0x64, 0x49, 0x64, 0x22, 0x4a, 0x0a, 0x0f, 0x52,
0x6f, 0x6f, 0x6d, 0x48, 0x65, 0x61, 0x74, 0x43, 0x68, 0x61, 0x6e, 0x67, 0x65, 0x64, 0x12, 0x14,
0x0a, 0x05, 0x64, 0x65, 0x6c, 0x74, 0x61, 0x18, 0x01, 0x20, 0x01, 0x28, 0x03, 0x52, 0x05, 0x64,
0x65, 0x6c, 0x74, 0x61, 0x12, 0x21, 0x0a, 0x0c, 0x63, 0x75, 0x72, 0x72, 0x65, 0x6e, 0x74, 0x5f,
0x68, 0x65, 0x61, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0b, 0x63, 0x75, 0x72, 0x72,
0x65, 0x6e, 0x74, 0x48, 0x65, 0x61, 0x74, 0x22, 0x5f, 0x0a, 0x0f, 0x52, 0x6f, 0x6f, 0x6d, 0x52,
0x61, 0x6e, 0x6b, 0x43, 0x68, 0x61, 0x6e, 0x67, 0x65, 0x64, 0x12, 0x17, 0x0a, 0x07, 0x75, 0x73,
0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x03, 0x52, 0x06, 0x75, 0x73, 0x65,
0x72, 0x49, 0x64, 0x12, 0x14, 0x0a, 0x05, 0x73, 0x63, 0x6f, 0x72, 0x65, 0x18, 0x02, 0x20, 0x01,
0x28, 0x03, 0x52, 0x05, 0x73, 0x63, 0x6f, 0x72, 0x65, 0x12, 0x1d, 0x0a, 0x0a, 0x67, 0x69, 0x66,
0x74, 0x5f, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x03, 0x52, 0x09, 0x67,
0x69, 0x66, 0x74, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x42, 0x33, 0x5a, 0x31, 0x68, 0x79, 0x61, 0x70,
0x70, 0x2e, 0x6c, 0x6f, 0x63, 0x61, 0x6c, 0x2f, 0x61, 0x70, 0x69, 0x2f, 0x70, 0x72, 0x6f, 0x74,
0x6f, 0x2f, 0x65, 0x76, 0x65, 0x6e, 0x74, 0x73, 0x2f, 0x72, 0x6f, 0x6f, 0x6d, 0x2f, 0x76, 0x31,
0x3b, 0x72, 0x6f, 0x6f, 0x6d, 0x65, 0x76, 0x65, 0x6e, 0x74, 0x73, 0x76, 0x31, 0x62, 0x06, 0x70,
0x72, 0x6f, 0x74, 0x6f, 0x33,
0x52, 0x0c, 0x74, 0x61, 0x72, 0x67, 0x65, 0x74, 0x55, 0x73, 0x65, 0x72, 0x49, 0x64, 0x12, 0x17,
0x0a, 0x07, 0x67, 0x69, 0x66, 0x74, 0x5f, 0x69, 0x64, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52,
0x06, 0x67, 0x69, 0x66, 0x74, 0x49, 0x64, 0x12, 0x1d, 0x0a, 0x0a, 0x67, 0x69, 0x66, 0x74, 0x5f,
0x63, 0x6f, 0x75, 0x6e, 0x74, 0x18, 0x04, 0x20, 0x01, 0x28, 0x05, 0x52, 0x09, 0x67, 0x69, 0x66,
0x74, 0x43, 0x6f, 0x75, 0x6e, 0x74, 0x12, 0x1d, 0x0a, 0x0a, 0x67, 0x69, 0x66, 0x74, 0x5f, 0x76,
0x61, 0x6c, 0x75, 0x65, 0x18, 0x05, 0x20, 0x01, 0x28, 0x03, 0x52, 0x09, 0x67, 0x69, 0x66, 0x74,
0x56, 0x61, 0x6c, 0x75, 0x65, 0x12, 0x2c, 0x0a, 0x12, 0x62, 0x69, 0x6c, 0x6c, 0x69, 0x6e, 0x67,
0x5f, 0x72, 0x65, 0x63, 0x65, 0x69, 0x70, 0x74, 0x5f, 0x69, 0x64, 0x18, 0x06, 0x20, 0x01, 0x28,
0x09, 0x52, 0x10, 0x62, 0x69, 0x6c, 0x6c, 0x69, 0x6e, 0x67, 0x52, 0x65, 0x63, 0x65, 0x69, 0x70,
0x74, 0x49, 0x64, 0x12, 0x2a, 0x0a, 0x11, 0x76, 0x69, 0x73, 0x69, 0x62, 0x6c, 0x65, 0x5f, 0x72,
0x65, 0x67, 0x69, 0x6f, 0x6e, 0x5f, 0x69, 0x64, 0x18, 0x07, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0f,
0x76, 0x69, 0x73, 0x69, 0x62, 0x6c, 0x65, 0x52, 0x65, 0x67, 0x69, 0x6f, 0x6e, 0x49, 0x64, 0x12,
0x1d, 0x0a, 0x0a, 0x63, 0x6f, 0x6d, 0x6d, 0x61, 0x6e, 0x64, 0x5f, 0x69, 0x64, 0x18, 0x08, 0x20,
0x01, 0x28, 0x09, 0x52, 0x09, 0x63, 0x6f, 0x6d, 0x6d, 0x61, 0x6e, 0x64, 0x49, 0x64, 0x22, 0x4a,
0x0a, 0x0f, 0x52, 0x6f, 0x6f, 0x6d, 0x48, 0x65, 0x61, 0x74, 0x43, 0x68, 0x61, 0x6e, 0x67, 0x65,
0x64, 0x12, 0x14, 0x0a, 0x05, 0x64, 0x65, 0x6c, 0x74, 0x61, 0x18, 0x01, 0x20, 0x01, 0x28, 0x03,
0x52, 0x05, 0x64, 0x65, 0x6c, 0x74, 0x61, 0x12, 0x21, 0x0a, 0x0c, 0x63, 0x75, 0x72, 0x72, 0x65,
0x6e, 0x74, 0x5f, 0x68, 0x65, 0x61, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0b, 0x63,
0x75, 0x72, 0x72, 0x65, 0x6e, 0x74, 0x48, 0x65, 0x61, 0x74, 0x22, 0x5f, 0x0a, 0x0f, 0x52, 0x6f,
0x6f, 0x6d, 0x52, 0x61, 0x6e, 0x6b, 0x43, 0x68, 0x61, 0x6e, 0x67, 0x65, 0x64, 0x12, 0x17, 0x0a,
0x07, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x03, 0x52, 0x06,
0x75, 0x73, 0x65, 0x72, 0x49, 0x64, 0x12, 0x14, 0x0a, 0x05, 0x73, 0x63, 0x6f, 0x72, 0x65, 0x18,
0x02, 0x20, 0x01, 0x28, 0x03, 0x52, 0x05, 0x73, 0x63, 0x6f, 0x72, 0x65, 0x12, 0x1d, 0x0a, 0x0a,
0x67, 0x69, 0x66, 0x74, 0x5f, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x03,
0x52, 0x09, 0x67, 0x69, 0x66, 0x74, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x42, 0x33, 0x5a, 0x31, 0x68,
0x79, 0x61, 0x70, 0x70, 0x2e, 0x6c, 0x6f, 0x63, 0x61, 0x6c, 0x2f, 0x61, 0x70, 0x69, 0x2f, 0x70,
0x72, 0x6f, 0x74, 0x6f, 0x2f, 0x65, 0x76, 0x65, 0x6e, 0x74, 0x73, 0x2f, 0x72, 0x6f, 0x6f, 0x6d,
0x2f, 0x76, 0x31, 0x3b, 0x72, 0x6f, 0x6f, 0x6d, 0x65, 0x76, 0x65, 0x6e, 0x74, 0x73, 0x76, 0x31,
0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33,
}
var (
@ -1297,7 +1390,7 @@ func file_proto_events_room_v1_events_proto_rawDescGZIP() []byte {
return file_proto_events_room_v1_events_proto_rawDescData
}
var file_proto_events_room_v1_events_proto_msgTypes = make([]protoimpl.MessageInfo, 16)
var file_proto_events_room_v1_events_proto_msgTypes = make([]protoimpl.MessageInfo, 17)
var file_proto_events_room_v1_events_proto_goTypes = []any{
(*EventEnvelope)(nil), // 0: hyapp.events.room.v1.EventEnvelope
(*RoomCreated)(nil), // 1: hyapp.events.room.v1.RoomCreated
@ -1308,13 +1401,14 @@ var file_proto_events_room_v1_events_proto_goTypes = []any{
(*RoomMicChanged)(nil), // 6: hyapp.events.room.v1.RoomMicChanged
(*RoomMicSeatLocked)(nil), // 7: hyapp.events.room.v1.RoomMicSeatLocked
(*RoomChatEnabledChanged)(nil), // 8: hyapp.events.room.v1.RoomChatEnabledChanged
(*RoomAdminChanged)(nil), // 9: hyapp.events.room.v1.RoomAdminChanged
(*RoomUserMuted)(nil), // 10: hyapp.events.room.v1.RoomUserMuted
(*RoomUserKicked)(nil), // 11: hyapp.events.room.v1.RoomUserKicked
(*RoomUserUnbanned)(nil), // 12: hyapp.events.room.v1.RoomUserUnbanned
(*RoomGiftSent)(nil), // 13: hyapp.events.room.v1.RoomGiftSent
(*RoomHeatChanged)(nil), // 14: hyapp.events.room.v1.RoomHeatChanged
(*RoomRankChanged)(nil), // 15: hyapp.events.room.v1.RoomRankChanged
(*RoomPasswordChanged)(nil), // 9: hyapp.events.room.v1.RoomPasswordChanged
(*RoomAdminChanged)(nil), // 10: hyapp.events.room.v1.RoomAdminChanged
(*RoomUserMuted)(nil), // 11: hyapp.events.room.v1.RoomUserMuted
(*RoomUserKicked)(nil), // 12: hyapp.events.room.v1.RoomUserKicked
(*RoomUserUnbanned)(nil), // 13: hyapp.events.room.v1.RoomUserUnbanned
(*RoomGiftSent)(nil), // 14: hyapp.events.room.v1.RoomGiftSent
(*RoomHeatChanged)(nil), // 15: hyapp.events.room.v1.RoomHeatChanged
(*RoomRankChanged)(nil), // 16: hyapp.events.room.v1.RoomRankChanged
}
var file_proto_events_room_v1_events_proto_depIdxs = []int32{
0, // [0:0] is the sub-list for method output_type
@ -1329,13 +1423,219 @@ func file_proto_events_room_v1_events_proto_init() {
if File_proto_events_room_v1_events_proto != nil {
return
}
if !protoimpl.UnsafeEnabled {
file_proto_events_room_v1_events_proto_msgTypes[0].Exporter = func(v any, i int) any {
switch v := v.(*EventEnvelope); i {
case 0:
return &v.state
case 1:
return &v.sizeCache
case 2:
return &v.unknownFields
default:
return nil
}
}
file_proto_events_room_v1_events_proto_msgTypes[1].Exporter = func(v any, i int) any {
switch v := v.(*RoomCreated); i {
case 0:
return &v.state
case 1:
return &v.sizeCache
case 2:
return &v.unknownFields
default:
return nil
}
}
file_proto_events_room_v1_events_proto_msgTypes[2].Exporter = func(v any, i int) any {
switch v := v.(*RoomProfileUpdated); i {
case 0:
return &v.state
case 1:
return &v.sizeCache
case 2:
return &v.unknownFields
default:
return nil
}
}
file_proto_events_room_v1_events_proto_msgTypes[3].Exporter = func(v any, i int) any {
switch v := v.(*RoomUserJoined); i {
case 0:
return &v.state
case 1:
return &v.sizeCache
case 2:
return &v.unknownFields
default:
return nil
}
}
file_proto_events_room_v1_events_proto_msgTypes[4].Exporter = func(v any, i int) any {
switch v := v.(*RoomUserLeft); i {
case 0:
return &v.state
case 1:
return &v.sizeCache
case 2:
return &v.unknownFields
default:
return nil
}
}
file_proto_events_room_v1_events_proto_msgTypes[5].Exporter = func(v any, i int) any {
switch v := v.(*RoomClosed); i {
case 0:
return &v.state
case 1:
return &v.sizeCache
case 2:
return &v.unknownFields
default:
return nil
}
}
file_proto_events_room_v1_events_proto_msgTypes[6].Exporter = func(v any, i int) any {
switch v := v.(*RoomMicChanged); i {
case 0:
return &v.state
case 1:
return &v.sizeCache
case 2:
return &v.unknownFields
default:
return nil
}
}
file_proto_events_room_v1_events_proto_msgTypes[7].Exporter = func(v any, i int) any {
switch v := v.(*RoomMicSeatLocked); i {
case 0:
return &v.state
case 1:
return &v.sizeCache
case 2:
return &v.unknownFields
default:
return nil
}
}
file_proto_events_room_v1_events_proto_msgTypes[8].Exporter = func(v any, i int) any {
switch v := v.(*RoomChatEnabledChanged); i {
case 0:
return &v.state
case 1:
return &v.sizeCache
case 2:
return &v.unknownFields
default:
return nil
}
}
file_proto_events_room_v1_events_proto_msgTypes[9].Exporter = func(v any, i int) any {
switch v := v.(*RoomPasswordChanged); i {
case 0:
return &v.state
case 1:
return &v.sizeCache
case 2:
return &v.unknownFields
default:
return nil
}
}
file_proto_events_room_v1_events_proto_msgTypes[10].Exporter = func(v any, i int) any {
switch v := v.(*RoomAdminChanged); i {
case 0:
return &v.state
case 1:
return &v.sizeCache
case 2:
return &v.unknownFields
default:
return nil
}
}
file_proto_events_room_v1_events_proto_msgTypes[11].Exporter = func(v any, i int) any {
switch v := v.(*RoomUserMuted); i {
case 0:
return &v.state
case 1:
return &v.sizeCache
case 2:
return &v.unknownFields
default:
return nil
}
}
file_proto_events_room_v1_events_proto_msgTypes[12].Exporter = func(v any, i int) any {
switch v := v.(*RoomUserKicked); i {
case 0:
return &v.state
case 1:
return &v.sizeCache
case 2:
return &v.unknownFields
default:
return nil
}
}
file_proto_events_room_v1_events_proto_msgTypes[13].Exporter = func(v any, i int) any {
switch v := v.(*RoomUserUnbanned); i {
case 0:
return &v.state
case 1:
return &v.sizeCache
case 2:
return &v.unknownFields
default:
return nil
}
}
file_proto_events_room_v1_events_proto_msgTypes[14].Exporter = func(v any, i int) any {
switch v := v.(*RoomGiftSent); i {
case 0:
return &v.state
case 1:
return &v.sizeCache
case 2:
return &v.unknownFields
default:
return nil
}
}
file_proto_events_room_v1_events_proto_msgTypes[15].Exporter = func(v any, i int) any {
switch v := v.(*RoomHeatChanged); i {
case 0:
return &v.state
case 1:
return &v.sizeCache
case 2:
return &v.unknownFields
default:
return nil
}
}
file_proto_events_room_v1_events_proto_msgTypes[16].Exporter = func(v any, i int) any {
switch v := v.(*RoomRankChanged); i {
case 0:
return &v.state
case 1:
return &v.sizeCache
case 2:
return &v.unknownFields
default:
return nil
}
}
}
type x struct{}
out := protoimpl.TypeBuilder{
File: protoimpl.DescBuilder{
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
RawDescriptor: file_proto_events_room_v1_events_proto_rawDesc,
NumEnums: 0,
NumMessages: 16,
NumMessages: 17,
NumExtensions: 0,
NumServices: 0,
},

View File

@ -84,6 +84,12 @@ message RoomChatEnabledChanged {
bool enabled = 2;
}
// RoomPasswordChanged
message RoomPasswordChanged {
int64 actor_user_id = 1;
bool locked = 2;
}
// RoomAdminChanged
message RoomAdminChanged {
int64 actor_user_id = 1;

View File

@ -1,7 +1,7 @@
// Code generated by protoc-gen-go. DO NOT EDIT.
// versions:
// protoc-gen-go v1.35.1
// protoc v5.29.3
// protoc-gen-go v1.34.2
// protoc v5.29.2
// source: proto/game/v1/game.proto
package gamev1
@ -36,9 +36,11 @@ type RequestMeta struct {
func (x *RequestMeta) Reset() {
*x = RequestMeta{}
mi := &file_proto_game_v1_game_proto_msgTypes[0]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
if protoimpl.UnsafeEnabled {
mi := &file_proto_game_v1_game_proto_msgTypes[0]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
}
func (x *RequestMeta) String() string {
@ -49,7 +51,7 @@ func (*RequestMeta) ProtoMessage() {}
func (x *RequestMeta) ProtoReflect() protoreflect.Message {
mi := &file_proto_game_v1_game_proto_msgTypes[0]
if x != nil {
if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
@ -123,9 +125,11 @@ type GamePlatform struct {
func (x *GamePlatform) Reset() {
*x = GamePlatform{}
mi := &file_proto_game_v1_game_proto_msgTypes[1]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
if protoimpl.UnsafeEnabled {
mi := &file_proto_game_v1_game_proto_msgTypes[1]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
}
func (x *GamePlatform) String() string {
@ -136,7 +140,7 @@ func (*GamePlatform) ProtoMessage() {}
func (x *GamePlatform) ProtoReflect() protoreflect.Message {
mi := &file_proto_game_v1_game_proto_msgTypes[1]
if x != nil {
if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
@ -232,9 +236,11 @@ type GameCatalogItem struct {
func (x *GameCatalogItem) Reset() {
*x = GameCatalogItem{}
mi := &file_proto_game_v1_game_proto_msgTypes[2]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
if protoimpl.UnsafeEnabled {
mi := &file_proto_game_v1_game_proto_msgTypes[2]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
}
func (x *GameCatalogItem) String() string {
@ -245,7 +251,7 @@ func (*GameCatalogItem) ProtoMessage() {}
func (x *GameCatalogItem) ProtoReflect() protoreflect.Message {
mi := &file_proto_game_v1_game_proto_msgTypes[2]
if x != nil {
if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
@ -394,9 +400,11 @@ type AppGame struct {
func (x *AppGame) Reset() {
*x = AppGame{}
mi := &file_proto_game_v1_game_proto_msgTypes[3]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
if protoimpl.UnsafeEnabled {
mi := &file_proto_game_v1_game_proto_msgTypes[3]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
}
func (x *AppGame) String() string {
@ -407,7 +415,7 @@ func (*AppGame) ProtoMessage() {}
func (x *AppGame) ProtoReflect() protoreflect.Message {
mi := &file_proto_game_v1_game_proto_msgTypes[3]
if x != nil {
if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
@ -529,9 +537,11 @@ type ListGamesRequest struct {
func (x *ListGamesRequest) Reset() {
*x = ListGamesRequest{}
mi := &file_proto_game_v1_game_proto_msgTypes[4]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
if protoimpl.UnsafeEnabled {
mi := &file_proto_game_v1_game_proto_msgTypes[4]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
}
func (x *ListGamesRequest) String() string {
@ -542,7 +552,7 @@ func (*ListGamesRequest) ProtoMessage() {}
func (x *ListGamesRequest) ProtoReflect() protoreflect.Message {
mi := &file_proto_game_v1_game_proto_msgTypes[4]
if x != nil {
if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
@ -617,9 +627,11 @@ type ListGamesResponse struct {
func (x *ListGamesResponse) Reset() {
*x = ListGamesResponse{}
mi := &file_proto_game_v1_game_proto_msgTypes[5]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
if protoimpl.UnsafeEnabled {
mi := &file_proto_game_v1_game_proto_msgTypes[5]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
}
func (x *ListGamesResponse) String() string {
@ -630,7 +642,7 @@ func (*ListGamesResponse) ProtoMessage() {}
func (x *ListGamesResponse) ProtoReflect() protoreflect.Message {
mi := &file_proto_game_v1_game_proto_msgTypes[5]
if x != nil {
if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
@ -674,9 +686,11 @@ type LaunchGameRequest struct {
func (x *LaunchGameRequest) Reset() {
*x = LaunchGameRequest{}
mi := &file_proto_game_v1_game_proto_msgTypes[6]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
if protoimpl.UnsafeEnabled {
mi := &file_proto_game_v1_game_proto_msgTypes[6]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
}
func (x *LaunchGameRequest) String() string {
@ -687,7 +701,7 @@ func (*LaunchGameRequest) ProtoMessage() {}
func (x *LaunchGameRequest) ProtoReflect() protoreflect.Message {
mi := &file_proto_game_v1_game_proto_msgTypes[6]
if x != nil {
if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
@ -758,9 +772,11 @@ type LaunchGameResponse struct {
func (x *LaunchGameResponse) Reset() {
*x = LaunchGameResponse{}
mi := &file_proto_game_v1_game_proto_msgTypes[7]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
if protoimpl.UnsafeEnabled {
mi := &file_proto_game_v1_game_proto_msgTypes[7]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
}
func (x *LaunchGameResponse) String() string {
@ -771,7 +787,7 @@ func (*LaunchGameResponse) ProtoMessage() {}
func (x *LaunchGameResponse) ProtoReflect() protoreflect.Message {
mi := &file_proto_game_v1_game_proto_msgTypes[7]
if x != nil {
if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
@ -837,9 +853,11 @@ type CallbackRequest struct {
func (x *CallbackRequest) Reset() {
*x = CallbackRequest{}
mi := &file_proto_game_v1_game_proto_msgTypes[8]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
if protoimpl.UnsafeEnabled {
mi := &file_proto_game_v1_game_proto_msgTypes[8]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
}
func (x *CallbackRequest) String() string {
@ -850,7 +868,7 @@ func (*CallbackRequest) ProtoMessage() {}
func (x *CallbackRequest) ProtoReflect() protoreflect.Message {
mi := &file_proto_game_v1_game_proto_msgTypes[8]
if x != nil {
if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
@ -925,9 +943,11 @@ type CallbackResponse struct {
func (x *CallbackResponse) Reset() {
*x = CallbackResponse{}
mi := &file_proto_game_v1_game_proto_msgTypes[9]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
if protoimpl.UnsafeEnabled {
mi := &file_proto_game_v1_game_proto_msgTypes[9]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
}
func (x *CallbackResponse) String() string {
@ -938,7 +958,7 @@ func (*CallbackResponse) ProtoMessage() {}
func (x *CallbackResponse) ProtoReflect() protoreflect.Message {
mi := &file_proto_game_v1_game_proto_msgTypes[9]
if x != nil {
if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
@ -978,9 +998,11 @@ type ListPlatformsRequest struct {
func (x *ListPlatformsRequest) Reset() {
*x = ListPlatformsRequest{}
mi := &file_proto_game_v1_game_proto_msgTypes[10]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
if protoimpl.UnsafeEnabled {
mi := &file_proto_game_v1_game_proto_msgTypes[10]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
}
func (x *ListPlatformsRequest) String() string {
@ -991,7 +1013,7 @@ func (*ListPlatformsRequest) ProtoMessage() {}
func (x *ListPlatformsRequest) ProtoReflect() protoreflect.Message {
mi := &file_proto_game_v1_game_proto_msgTypes[10]
if x != nil {
if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
@ -1031,9 +1053,11 @@ type ListPlatformsResponse struct {
func (x *ListPlatformsResponse) Reset() {
*x = ListPlatformsResponse{}
mi := &file_proto_game_v1_game_proto_msgTypes[11]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
if protoimpl.UnsafeEnabled {
mi := &file_proto_game_v1_game_proto_msgTypes[11]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
}
func (x *ListPlatformsResponse) String() string {
@ -1044,7 +1068,7 @@ func (*ListPlatformsResponse) ProtoMessage() {}
func (x *ListPlatformsResponse) ProtoReflect() protoreflect.Message {
mi := &file_proto_game_v1_game_proto_msgTypes[11]
if x != nil {
if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
@ -1084,9 +1108,11 @@ type UpsertPlatformRequest struct {
func (x *UpsertPlatformRequest) Reset() {
*x = UpsertPlatformRequest{}
mi := &file_proto_game_v1_game_proto_msgTypes[12]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
if protoimpl.UnsafeEnabled {
mi := &file_proto_game_v1_game_proto_msgTypes[12]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
}
func (x *UpsertPlatformRequest) String() string {
@ -1097,7 +1123,7 @@ func (*UpsertPlatformRequest) ProtoMessage() {}
func (x *UpsertPlatformRequest) ProtoReflect() protoreflect.Message {
mi := &file_proto_game_v1_game_proto_msgTypes[12]
if x != nil {
if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
@ -1137,9 +1163,11 @@ type PlatformResponse struct {
func (x *PlatformResponse) Reset() {
*x = PlatformResponse{}
mi := &file_proto_game_v1_game_proto_msgTypes[13]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
if protoimpl.UnsafeEnabled {
mi := &file_proto_game_v1_game_proto_msgTypes[13]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
}
func (x *PlatformResponse) String() string {
@ -1150,7 +1178,7 @@ func (*PlatformResponse) ProtoMessage() {}
func (x *PlatformResponse) ProtoReflect() protoreflect.Message {
mi := &file_proto_game_v1_game_proto_msgTypes[13]
if x != nil {
if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
@ -1193,9 +1221,11 @@ type ListCatalogRequest struct {
func (x *ListCatalogRequest) Reset() {
*x = ListCatalogRequest{}
mi := &file_proto_game_v1_game_proto_msgTypes[14]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
if protoimpl.UnsafeEnabled {
mi := &file_proto_game_v1_game_proto_msgTypes[14]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
}
func (x *ListCatalogRequest) String() string {
@ -1206,7 +1236,7 @@ func (*ListCatalogRequest) ProtoMessage() {}
func (x *ListCatalogRequest) ProtoReflect() protoreflect.Message {
mi := &file_proto_game_v1_game_proto_msgTypes[14]
if x != nil {
if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
@ -1268,9 +1298,11 @@ type ListCatalogResponse struct {
func (x *ListCatalogResponse) Reset() {
*x = ListCatalogResponse{}
mi := &file_proto_game_v1_game_proto_msgTypes[15]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
if protoimpl.UnsafeEnabled {
mi := &file_proto_game_v1_game_proto_msgTypes[15]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
}
func (x *ListCatalogResponse) String() string {
@ -1281,7 +1313,7 @@ func (*ListCatalogResponse) ProtoMessage() {}
func (x *ListCatalogResponse) ProtoReflect() protoreflect.Message {
mi := &file_proto_game_v1_game_proto_msgTypes[15]
if x != nil {
if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
@ -1328,9 +1360,11 @@ type UpsertCatalogRequest struct {
func (x *UpsertCatalogRequest) Reset() {
*x = UpsertCatalogRequest{}
mi := &file_proto_game_v1_game_proto_msgTypes[16]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
if protoimpl.UnsafeEnabled {
mi := &file_proto_game_v1_game_proto_msgTypes[16]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
}
func (x *UpsertCatalogRequest) String() string {
@ -1341,7 +1375,7 @@ func (*UpsertCatalogRequest) ProtoMessage() {}
func (x *UpsertCatalogRequest) ProtoReflect() protoreflect.Message {
mi := &file_proto_game_v1_game_proto_msgTypes[16]
if x != nil {
if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
@ -1381,9 +1415,11 @@ type CatalogResponse struct {
func (x *CatalogResponse) Reset() {
*x = CatalogResponse{}
mi := &file_proto_game_v1_game_proto_msgTypes[17]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
if protoimpl.UnsafeEnabled {
mi := &file_proto_game_v1_game_proto_msgTypes[17]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
}
func (x *CatalogResponse) String() string {
@ -1394,7 +1430,7 @@ func (*CatalogResponse) ProtoMessage() {}
func (x *CatalogResponse) ProtoReflect() protoreflect.Message {
mi := &file_proto_game_v1_game_proto_msgTypes[17]
if x != nil {
if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
@ -1435,9 +1471,11 @@ type SetGameStatusRequest struct {
func (x *SetGameStatusRequest) Reset() {
*x = SetGameStatusRequest{}
mi := &file_proto_game_v1_game_proto_msgTypes[18]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
if protoimpl.UnsafeEnabled {
mi := &file_proto_game_v1_game_proto_msgTypes[18]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
}
func (x *SetGameStatusRequest) String() string {
@ -1448,7 +1486,7 @@ func (*SetGameStatusRequest) ProtoMessage() {}
func (x *SetGameStatusRequest) ProtoReflect() protoreflect.Message {
mi := &file_proto_game_v1_game_proto_msgTypes[18]
if x != nil {
if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
@ -1862,6 +1900,236 @@ func file_proto_game_v1_game_proto_init() {
if File_proto_game_v1_game_proto != nil {
return
}
if !protoimpl.UnsafeEnabled {
file_proto_game_v1_game_proto_msgTypes[0].Exporter = func(v any, i int) any {
switch v := v.(*RequestMeta); i {
case 0:
return &v.state
case 1:
return &v.sizeCache
case 2:
return &v.unknownFields
default:
return nil
}
}
file_proto_game_v1_game_proto_msgTypes[1].Exporter = func(v any, i int) any {
switch v := v.(*GamePlatform); i {
case 0:
return &v.state
case 1:
return &v.sizeCache
case 2:
return &v.unknownFields
default:
return nil
}
}
file_proto_game_v1_game_proto_msgTypes[2].Exporter = func(v any, i int) any {
switch v := v.(*GameCatalogItem); i {
case 0:
return &v.state
case 1:
return &v.sizeCache
case 2:
return &v.unknownFields
default:
return nil
}
}
file_proto_game_v1_game_proto_msgTypes[3].Exporter = func(v any, i int) any {
switch v := v.(*AppGame); i {
case 0:
return &v.state
case 1:
return &v.sizeCache
case 2:
return &v.unknownFields
default:
return nil
}
}
file_proto_game_v1_game_proto_msgTypes[4].Exporter = func(v any, i int) any {
switch v := v.(*ListGamesRequest); i {
case 0:
return &v.state
case 1:
return &v.sizeCache
case 2:
return &v.unknownFields
default:
return nil
}
}
file_proto_game_v1_game_proto_msgTypes[5].Exporter = func(v any, i int) any {
switch v := v.(*ListGamesResponse); i {
case 0:
return &v.state
case 1:
return &v.sizeCache
case 2:
return &v.unknownFields
default:
return nil
}
}
file_proto_game_v1_game_proto_msgTypes[6].Exporter = func(v any, i int) any {
switch v := v.(*LaunchGameRequest); i {
case 0:
return &v.state
case 1:
return &v.sizeCache
case 2:
return &v.unknownFields
default:
return nil
}
}
file_proto_game_v1_game_proto_msgTypes[7].Exporter = func(v any, i int) any {
switch v := v.(*LaunchGameResponse); i {
case 0:
return &v.state
case 1:
return &v.sizeCache
case 2:
return &v.unknownFields
default:
return nil
}
}
file_proto_game_v1_game_proto_msgTypes[8].Exporter = func(v any, i int) any {
switch v := v.(*CallbackRequest); i {
case 0:
return &v.state
case 1:
return &v.sizeCache
case 2:
return &v.unknownFields
default:
return nil
}
}
file_proto_game_v1_game_proto_msgTypes[9].Exporter = func(v any, i int) any {
switch v := v.(*CallbackResponse); i {
case 0:
return &v.state
case 1:
return &v.sizeCache
case 2:
return &v.unknownFields
default:
return nil
}
}
file_proto_game_v1_game_proto_msgTypes[10].Exporter = func(v any, i int) any {
switch v := v.(*ListPlatformsRequest); i {
case 0:
return &v.state
case 1:
return &v.sizeCache
case 2:
return &v.unknownFields
default:
return nil
}
}
file_proto_game_v1_game_proto_msgTypes[11].Exporter = func(v any, i int) any {
switch v := v.(*ListPlatformsResponse); i {
case 0:
return &v.state
case 1:
return &v.sizeCache
case 2:
return &v.unknownFields
default:
return nil
}
}
file_proto_game_v1_game_proto_msgTypes[12].Exporter = func(v any, i int) any {
switch v := v.(*UpsertPlatformRequest); i {
case 0:
return &v.state
case 1:
return &v.sizeCache
case 2:
return &v.unknownFields
default:
return nil
}
}
file_proto_game_v1_game_proto_msgTypes[13].Exporter = func(v any, i int) any {
switch v := v.(*PlatformResponse); i {
case 0:
return &v.state
case 1:
return &v.sizeCache
case 2:
return &v.unknownFields
default:
return nil
}
}
file_proto_game_v1_game_proto_msgTypes[14].Exporter = func(v any, i int) any {
switch v := v.(*ListCatalogRequest); i {
case 0:
return &v.state
case 1:
return &v.sizeCache
case 2:
return &v.unknownFields
default:
return nil
}
}
file_proto_game_v1_game_proto_msgTypes[15].Exporter = func(v any, i int) any {
switch v := v.(*ListCatalogResponse); i {
case 0:
return &v.state
case 1:
return &v.sizeCache
case 2:
return &v.unknownFields
default:
return nil
}
}
file_proto_game_v1_game_proto_msgTypes[16].Exporter = func(v any, i int) any {
switch v := v.(*UpsertCatalogRequest); i {
case 0:
return &v.state
case 1:
return &v.sizeCache
case 2:
return &v.unknownFields
default:
return nil
}
}
file_proto_game_v1_game_proto_msgTypes[17].Exporter = func(v any, i int) any {
switch v := v.(*CatalogResponse); i {
case 0:
return &v.state
case 1:
return &v.sizeCache
case 2:
return &v.unknownFields
default:
return nil
}
}
file_proto_game_v1_game_proto_msgTypes[18].Exporter = func(v any, i int) any {
switch v := v.(*SetGameStatusRequest); i {
case 0:
return &v.state
case 1:
return &v.sizeCache
case 2:
return &v.unknownFields
default:
return nil
}
}
}
type x struct{}
out := protoimpl.TypeBuilder{
File: protoimpl.DescBuilder{

View File

@ -1,7 +1,7 @@
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
// versions:
// - protoc-gen-go-grpc v1.5.1
// - protoc v5.29.3
// - protoc v5.29.2
// source: proto/game/v1/game.proto
package gamev1

File diff suppressed because it is too large Load Diff

View File

@ -80,6 +80,8 @@ message RoomSnapshot {
string app_code = 16;
string room_short_id = 17;
int64 visible_region_id = 18;
// locked HTTP JSON
bool locked = 19;
}
// CreateRoomRequest
@ -129,6 +131,8 @@ message UpdateRoomProfileResponse {
message JoinRoomRequest {
RequestMeta meta = 1;
string role = 2;
// password room-service command log
string password = 3;
}
// JoinRoomResponse
@ -295,6 +299,20 @@ message SetChatEnabledResponse {
RoomSnapshot room = 2;
}
// SetRoomPasswordRequest
// locked=true password locked=false
message SetRoomPasswordRequest {
RequestMeta meta = 1;
bool locked = 2;
string password = 3;
}
// SetRoomPasswordResponse
message SetRoomPasswordResponse {
CommandResult result = 1;
RoomSnapshot room = 2;
}
// SetRoomAdminRequest
message SetRoomAdminRequest {
RequestMeta meta = 1;
@ -331,6 +349,10 @@ message KickUserRequest {
message KickUserResponse {
CommandResult result = 1;
RoomSnapshot room = 2;
// rtc_kicked room-service TRTC
bool rtc_kicked = 3;
// rtc_kick_error
string rtc_kick_error = 4;
}
// UnbanUserRequest ban JoinRoom
@ -429,7 +451,7 @@ message ListRoomsRequest {
string query = 7;
}
// ListRoomFeedsRequest Mine visited/friend/following
// ListRoomFeedsRequest Mine visited/friend/following/followed
//
message ListRoomFeedsRequest {
RequestMeta meta = 1;
@ -464,6 +486,7 @@ message RoomListItem {
int64 visible_region_id = 12;
string app_code = 13;
string room_short_id = 14;
bool locked = 15;
}
// ListRoomsResponse cursor
@ -520,6 +543,8 @@ message GetRoomSnapshotRequest {
message GetRoomSnapshotResponse {
RoomSnapshot room = 1;
int64 server_time_ms = 2;
// is_followed viewer room_follows Room Cell
bool is_followed = 3;
}
// ListRoomOnlineUsersRequest 线 RoomSnapshot
@ -539,6 +564,34 @@ message ListRoomOnlineUsersResponse {
int64 server_time_ms = 5;
}
// FollowRoomRequest
// room-service - Room Cell command log
message FollowRoomRequest {
RequestMeta meta = 1;
string room_id = 2;
int64 user_id = 3;
}
message FollowRoomResponse {
string room_id = 1;
bool followed = 2;
int64 followed_at_ms = 3;
int64 server_time_ms = 4;
}
// UnfollowRoomRequest
message UnfollowRoomRequest {
RequestMeta meta = 1;
string room_id = 2;
int64 user_id = 3;
}
message UnfollowRoomResponse {
string room_id = 1;
bool followed = 2;
int64 server_time_ms = 3;
}
// RoomCommandService
service RoomCommandService {
rpc CreateRoom(CreateRoomRequest) returns (CreateRoomResponse);
@ -555,12 +608,15 @@ service RoomCommandService {
rpc ApplyRTCEvent(ApplyRTCEventRequest) returns (ApplyRTCEventResponse);
rpc SetMicSeatLock(SetMicSeatLockRequest) returns (SetMicSeatLockResponse);
rpc SetChatEnabled(SetChatEnabledRequest) returns (SetChatEnabledResponse);
rpc SetRoomPassword(SetRoomPasswordRequest) returns (SetRoomPasswordResponse);
rpc SetRoomAdmin(SetRoomAdminRequest) returns (SetRoomAdminResponse);
rpc MuteUser(MuteUserRequest) returns (MuteUserResponse);
rpc KickUser(KickUserRequest) returns (KickUserResponse);
rpc UnbanUser(UnbanUserRequest) returns (UnbanUserResponse);
rpc SystemEvictUser(SystemEvictUserRequest) returns (SystemEvictUserResponse);
rpc SendGift(SendGiftRequest) returns (SendGiftResponse);
rpc FollowRoom(FollowRoomRequest) returns (FollowRoomResponse);
rpc UnfollowRoom(UnfollowRoomRequest) returns (UnfollowRoomResponse);
}
// RoomGuardService IM gateway

View File

@ -1,7 +1,7 @@
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
// versions:
// - protoc-gen-go-grpc v1.5.1
// - protoc v5.29.3
// - protoc v5.29.2
// source: proto/room/v1/room.proto
package roomv1
@ -33,12 +33,15 @@ const (
RoomCommandService_ApplyRTCEvent_FullMethodName = "/hyapp.room.v1.RoomCommandService/ApplyRTCEvent"
RoomCommandService_SetMicSeatLock_FullMethodName = "/hyapp.room.v1.RoomCommandService/SetMicSeatLock"
RoomCommandService_SetChatEnabled_FullMethodName = "/hyapp.room.v1.RoomCommandService/SetChatEnabled"
RoomCommandService_SetRoomPassword_FullMethodName = "/hyapp.room.v1.RoomCommandService/SetRoomPassword"
RoomCommandService_SetRoomAdmin_FullMethodName = "/hyapp.room.v1.RoomCommandService/SetRoomAdmin"
RoomCommandService_MuteUser_FullMethodName = "/hyapp.room.v1.RoomCommandService/MuteUser"
RoomCommandService_KickUser_FullMethodName = "/hyapp.room.v1.RoomCommandService/KickUser"
RoomCommandService_UnbanUser_FullMethodName = "/hyapp.room.v1.RoomCommandService/UnbanUser"
RoomCommandService_SystemEvictUser_FullMethodName = "/hyapp.room.v1.RoomCommandService/SystemEvictUser"
RoomCommandService_SendGift_FullMethodName = "/hyapp.room.v1.RoomCommandService/SendGift"
RoomCommandService_FollowRoom_FullMethodName = "/hyapp.room.v1.RoomCommandService/FollowRoom"
RoomCommandService_UnfollowRoom_FullMethodName = "/hyapp.room.v1.RoomCommandService/UnfollowRoom"
)
// RoomCommandServiceClient is the client API for RoomCommandService service.
@ -61,12 +64,15 @@ type RoomCommandServiceClient interface {
ApplyRTCEvent(ctx context.Context, in *ApplyRTCEventRequest, opts ...grpc.CallOption) (*ApplyRTCEventResponse, error)
SetMicSeatLock(ctx context.Context, in *SetMicSeatLockRequest, opts ...grpc.CallOption) (*SetMicSeatLockResponse, error)
SetChatEnabled(ctx context.Context, in *SetChatEnabledRequest, opts ...grpc.CallOption) (*SetChatEnabledResponse, error)
SetRoomPassword(ctx context.Context, in *SetRoomPasswordRequest, opts ...grpc.CallOption) (*SetRoomPasswordResponse, error)
SetRoomAdmin(ctx context.Context, in *SetRoomAdminRequest, opts ...grpc.CallOption) (*SetRoomAdminResponse, error)
MuteUser(ctx context.Context, in *MuteUserRequest, opts ...grpc.CallOption) (*MuteUserResponse, error)
KickUser(ctx context.Context, in *KickUserRequest, opts ...grpc.CallOption) (*KickUserResponse, error)
UnbanUser(ctx context.Context, in *UnbanUserRequest, opts ...grpc.CallOption) (*UnbanUserResponse, error)
SystemEvictUser(ctx context.Context, in *SystemEvictUserRequest, opts ...grpc.CallOption) (*SystemEvictUserResponse, error)
SendGift(ctx context.Context, in *SendGiftRequest, opts ...grpc.CallOption) (*SendGiftResponse, error)
FollowRoom(ctx context.Context, in *FollowRoomRequest, opts ...grpc.CallOption) (*FollowRoomResponse, error)
UnfollowRoom(ctx context.Context, in *UnfollowRoomRequest, opts ...grpc.CallOption) (*UnfollowRoomResponse, error)
}
type roomCommandServiceClient struct {
@ -217,6 +223,16 @@ func (c *roomCommandServiceClient) SetChatEnabled(ctx context.Context, in *SetCh
return out, nil
}
func (c *roomCommandServiceClient) SetRoomPassword(ctx context.Context, in *SetRoomPasswordRequest, opts ...grpc.CallOption) (*SetRoomPasswordResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(SetRoomPasswordResponse)
err := c.cc.Invoke(ctx, RoomCommandService_SetRoomPassword_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *roomCommandServiceClient) SetRoomAdmin(ctx context.Context, in *SetRoomAdminRequest, opts ...grpc.CallOption) (*SetRoomAdminResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(SetRoomAdminResponse)
@ -277,6 +293,26 @@ func (c *roomCommandServiceClient) SendGift(ctx context.Context, in *SendGiftReq
return out, nil
}
func (c *roomCommandServiceClient) FollowRoom(ctx context.Context, in *FollowRoomRequest, opts ...grpc.CallOption) (*FollowRoomResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(FollowRoomResponse)
err := c.cc.Invoke(ctx, RoomCommandService_FollowRoom_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *roomCommandServiceClient) UnfollowRoom(ctx context.Context, in *UnfollowRoomRequest, opts ...grpc.CallOption) (*UnfollowRoomResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(UnfollowRoomResponse)
err := c.cc.Invoke(ctx, RoomCommandService_UnfollowRoom_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
// RoomCommandServiceServer is the server API for RoomCommandService service.
// All implementations must embed UnimplementedRoomCommandServiceServer
// for forward compatibility.
@ -297,12 +333,15 @@ type RoomCommandServiceServer interface {
ApplyRTCEvent(context.Context, *ApplyRTCEventRequest) (*ApplyRTCEventResponse, error)
SetMicSeatLock(context.Context, *SetMicSeatLockRequest) (*SetMicSeatLockResponse, error)
SetChatEnabled(context.Context, *SetChatEnabledRequest) (*SetChatEnabledResponse, error)
SetRoomPassword(context.Context, *SetRoomPasswordRequest) (*SetRoomPasswordResponse, error)
SetRoomAdmin(context.Context, *SetRoomAdminRequest) (*SetRoomAdminResponse, error)
MuteUser(context.Context, *MuteUserRequest) (*MuteUserResponse, error)
KickUser(context.Context, *KickUserRequest) (*KickUserResponse, error)
UnbanUser(context.Context, *UnbanUserRequest) (*UnbanUserResponse, error)
SystemEvictUser(context.Context, *SystemEvictUserRequest) (*SystemEvictUserResponse, error)
SendGift(context.Context, *SendGiftRequest) (*SendGiftResponse, error)
FollowRoom(context.Context, *FollowRoomRequest) (*FollowRoomResponse, error)
UnfollowRoom(context.Context, *UnfollowRoomRequest) (*UnfollowRoomResponse, error)
mustEmbedUnimplementedRoomCommandServiceServer()
}
@ -355,6 +394,9 @@ func (UnimplementedRoomCommandServiceServer) SetMicSeatLock(context.Context, *Se
func (UnimplementedRoomCommandServiceServer) SetChatEnabled(context.Context, *SetChatEnabledRequest) (*SetChatEnabledResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method SetChatEnabled not implemented")
}
func (UnimplementedRoomCommandServiceServer) SetRoomPassword(context.Context, *SetRoomPasswordRequest) (*SetRoomPasswordResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method SetRoomPassword not implemented")
}
func (UnimplementedRoomCommandServiceServer) SetRoomAdmin(context.Context, *SetRoomAdminRequest) (*SetRoomAdminResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method SetRoomAdmin not implemented")
}
@ -373,6 +415,12 @@ func (UnimplementedRoomCommandServiceServer) SystemEvictUser(context.Context, *S
func (UnimplementedRoomCommandServiceServer) SendGift(context.Context, *SendGiftRequest) (*SendGiftResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method SendGift not implemented")
}
func (UnimplementedRoomCommandServiceServer) FollowRoom(context.Context, *FollowRoomRequest) (*FollowRoomResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method FollowRoom not implemented")
}
func (UnimplementedRoomCommandServiceServer) UnfollowRoom(context.Context, *UnfollowRoomRequest) (*UnfollowRoomResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method UnfollowRoom not implemented")
}
func (UnimplementedRoomCommandServiceServer) mustEmbedUnimplementedRoomCommandServiceServer() {}
func (UnimplementedRoomCommandServiceServer) testEmbeddedByValue() {}
@ -646,6 +694,24 @@ func _RoomCommandService_SetChatEnabled_Handler(srv interface{}, ctx context.Con
return interceptor(ctx, in, info, handler)
}
func _RoomCommandService_SetRoomPassword_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(SetRoomPasswordRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(RoomCommandServiceServer).SetRoomPassword(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: RoomCommandService_SetRoomPassword_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(RoomCommandServiceServer).SetRoomPassword(ctx, req.(*SetRoomPasswordRequest))
}
return interceptor(ctx, in, info, handler)
}
func _RoomCommandService_SetRoomAdmin_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(SetRoomAdminRequest)
if err := dec(in); err != nil {
@ -754,6 +820,42 @@ func _RoomCommandService_SendGift_Handler(srv interface{}, ctx context.Context,
return interceptor(ctx, in, info, handler)
}
func _RoomCommandService_FollowRoom_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(FollowRoomRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(RoomCommandServiceServer).FollowRoom(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: RoomCommandService_FollowRoom_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(RoomCommandServiceServer).FollowRoom(ctx, req.(*FollowRoomRequest))
}
return interceptor(ctx, in, info, handler)
}
func _RoomCommandService_UnfollowRoom_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(UnfollowRoomRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(RoomCommandServiceServer).UnfollowRoom(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: RoomCommandService_UnfollowRoom_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(RoomCommandServiceServer).UnfollowRoom(ctx, req.(*UnfollowRoomRequest))
}
return interceptor(ctx, in, info, handler)
}
// RoomCommandService_ServiceDesc is the grpc.ServiceDesc for RoomCommandService service.
// It's only intended for direct use with grpc.RegisterService,
// and not to be introspected or modified (even as a copy)
@ -817,6 +919,10 @@ var RoomCommandService_ServiceDesc = grpc.ServiceDesc{
MethodName: "SetChatEnabled",
Handler: _RoomCommandService_SetChatEnabled_Handler,
},
{
MethodName: "SetRoomPassword",
Handler: _RoomCommandService_SetRoomPassword_Handler,
},
{
MethodName: "SetRoomAdmin",
Handler: _RoomCommandService_SetRoomAdmin_Handler,
@ -841,6 +947,14 @@ var RoomCommandService_ServiceDesc = grpc.ServiceDesc{
MethodName: "SendGift",
Handler: _RoomCommandService_SendGift_Handler,
},
{
MethodName: "FollowRoom",
Handler: _RoomCommandService_FollowRoom_Handler,
},
{
MethodName: "UnfollowRoom",
Handler: _RoomCommandService_UnfollowRoom_Handler,
},
},
Streams: []grpc.StreamDesc{},
Metadata: "proto/room/v1/room.proto",

View File

@ -1,7 +1,7 @@
// Code generated by protoc-gen-go. DO NOT EDIT.
// versions:
// protoc-gen-go v1.35.1
// protoc v5.29.3
// protoc-gen-go v1.34.2
// protoc v5.29.2
// source: proto/user/v1/auth.proto
package userv1
@ -33,9 +33,11 @@ type LoginPasswordRequest struct {
func (x *LoginPasswordRequest) Reset() {
*x = LoginPasswordRequest{}
mi := &file_proto_user_v1_auth_proto_msgTypes[0]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
if protoimpl.UnsafeEnabled {
mi := &file_proto_user_v1_auth_proto_msgTypes[0]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
}
func (x *LoginPasswordRequest) String() string {
@ -46,7 +48,7 @@ func (*LoginPasswordRequest) ProtoMessage() {}
func (x *LoginPasswordRequest) ProtoReflect() protoreflect.Message {
mi := &file_proto_user_v1_auth_proto_msgTypes[0]
if x != nil {
if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
@ -112,9 +114,11 @@ type LoginThirdPartyRequest struct {
func (x *LoginThirdPartyRequest) Reset() {
*x = LoginThirdPartyRequest{}
mi := &file_proto_user_v1_auth_proto_msgTypes[1]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
if protoimpl.UnsafeEnabled {
mi := &file_proto_user_v1_auth_proto_msgTypes[1]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
}
func (x *LoginThirdPartyRequest) String() string {
@ -125,7 +129,7 @@ func (*LoginThirdPartyRequest) ProtoMessage() {}
func (x *LoginThirdPartyRequest) ProtoReflect() protoreflect.Message {
mi := &file_proto_user_v1_auth_proto_msgTypes[1]
if x != nil {
if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
@ -294,9 +298,11 @@ type AuthResponse struct {
func (x *AuthResponse) Reset() {
*x = AuthResponse{}
mi := &file_proto_user_v1_auth_proto_msgTypes[2]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
if protoimpl.UnsafeEnabled {
mi := &file_proto_user_v1_auth_proto_msgTypes[2]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
}
func (x *AuthResponse) String() string {
@ -307,7 +313,7 @@ func (*AuthResponse) ProtoMessage() {}
func (x *AuthResponse) ProtoReflect() protoreflect.Message {
mi := &file_proto_user_v1_auth_proto_msgTypes[2]
if x != nil {
if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
@ -363,9 +369,11 @@ type SetPasswordRequest struct {
func (x *SetPasswordRequest) Reset() {
*x = SetPasswordRequest{}
mi := &file_proto_user_v1_auth_proto_msgTypes[3]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
if protoimpl.UnsafeEnabled {
mi := &file_proto_user_v1_auth_proto_msgTypes[3]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
}
func (x *SetPasswordRequest) String() string {
@ -376,7 +384,7 @@ func (*SetPasswordRequest) ProtoMessage() {}
func (x *SetPasswordRequest) ProtoReflect() protoreflect.Message {
mi := &file_proto_user_v1_auth_proto_msgTypes[3]
if x != nil {
if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
@ -423,9 +431,11 @@ type SetPasswordResponse struct {
func (x *SetPasswordResponse) Reset() {
*x = SetPasswordResponse{}
mi := &file_proto_user_v1_auth_proto_msgTypes[4]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
if protoimpl.UnsafeEnabled {
mi := &file_proto_user_v1_auth_proto_msgTypes[4]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
}
func (x *SetPasswordResponse) String() string {
@ -436,7 +446,7 @@ func (*SetPasswordResponse) ProtoMessage() {}
func (x *SetPasswordResponse) ProtoReflect() protoreflect.Message {
mi := &file_proto_user_v1_auth_proto_msgTypes[4]
if x != nil {
if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
@ -470,9 +480,11 @@ type RefreshTokenRequest struct {
func (x *RefreshTokenRequest) Reset() {
*x = RefreshTokenRequest{}
mi := &file_proto_user_v1_auth_proto_msgTypes[5]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
if protoimpl.UnsafeEnabled {
mi := &file_proto_user_v1_auth_proto_msgTypes[5]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
}
func (x *RefreshTokenRequest) String() string {
@ -483,7 +495,7 @@ func (*RefreshTokenRequest) ProtoMessage() {}
func (x *RefreshTokenRequest) ProtoReflect() protoreflect.Message {
mi := &file_proto_user_v1_auth_proto_msgTypes[5]
if x != nil {
if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
@ -523,9 +535,11 @@ type RefreshTokenResponse struct {
func (x *RefreshTokenResponse) Reset() {
*x = RefreshTokenResponse{}
mi := &file_proto_user_v1_auth_proto_msgTypes[6]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
if protoimpl.UnsafeEnabled {
mi := &file_proto_user_v1_auth_proto_msgTypes[6]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
}
func (x *RefreshTokenResponse) String() string {
@ -536,7 +550,7 @@ func (*RefreshTokenResponse) ProtoMessage() {}
func (x *RefreshTokenResponse) ProtoReflect() protoreflect.Message {
mi := &file_proto_user_v1_auth_proto_msgTypes[6]
if x != nil {
if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
@ -571,9 +585,11 @@ type LogoutRequest struct {
func (x *LogoutRequest) Reset() {
*x = LogoutRequest{}
mi := &file_proto_user_v1_auth_proto_msgTypes[7]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
if protoimpl.UnsafeEnabled {
mi := &file_proto_user_v1_auth_proto_msgTypes[7]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
}
func (x *LogoutRequest) String() string {
@ -584,7 +600,7 @@ func (*LogoutRequest) ProtoMessage() {}
func (x *LogoutRequest) ProtoReflect() protoreflect.Message {
mi := &file_proto_user_v1_auth_proto_msgTypes[7]
if x != nil {
if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
@ -631,9 +647,11 @@ type LogoutResponse struct {
func (x *LogoutResponse) Reset() {
*x = LogoutResponse{}
mi := &file_proto_user_v1_auth_proto_msgTypes[8]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
if protoimpl.UnsafeEnabled {
mi := &file_proto_user_v1_auth_proto_msgTypes[8]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
}
func (x *LogoutResponse) String() string {
@ -644,7 +662,7 @@ func (*LogoutResponse) ProtoMessage() {}
func (x *LogoutResponse) ProtoReflect() protoreflect.Message {
mi := &file_proto_user_v1_auth_proto_msgTypes[8]
if x != nil {
if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
@ -682,9 +700,11 @@ type RecordLoginBlockedRequest struct {
func (x *RecordLoginBlockedRequest) Reset() {
*x = RecordLoginBlockedRequest{}
mi := &file_proto_user_v1_auth_proto_msgTypes[9]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
if protoimpl.UnsafeEnabled {
mi := &file_proto_user_v1_auth_proto_msgTypes[9]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
}
func (x *RecordLoginBlockedRequest) String() string {
@ -695,7 +715,7 @@ func (*RecordLoginBlockedRequest) ProtoMessage() {}
func (x *RecordLoginBlockedRequest) ProtoReflect() protoreflect.Message {
mi := &file_proto_user_v1_auth_proto_msgTypes[9]
if x != nil {
if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
@ -763,9 +783,11 @@ type RecordLoginBlockedResponse struct {
func (x *RecordLoginBlockedResponse) Reset() {
*x = RecordLoginBlockedResponse{}
mi := &file_proto_user_v1_auth_proto_msgTypes[10]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
if protoimpl.UnsafeEnabled {
mi := &file_proto_user_v1_auth_proto_msgTypes[10]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
}
func (x *RecordLoginBlockedResponse) String() string {
@ -776,7 +798,7 @@ func (*RecordLoginBlockedResponse) ProtoMessage() {}
func (x *RecordLoginBlockedResponse) ProtoReflect() protoreflect.Message {
mi := &file_proto_user_v1_auth_proto_msgTypes[10]
if x != nil {
if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
@ -1017,6 +1039,140 @@ func file_proto_user_v1_auth_proto_init() {
return
}
file_proto_user_v1_user_proto_init()
if !protoimpl.UnsafeEnabled {
file_proto_user_v1_auth_proto_msgTypes[0].Exporter = func(v any, i int) any {
switch v := v.(*LoginPasswordRequest); i {
case 0:
return &v.state
case 1:
return &v.sizeCache
case 2:
return &v.unknownFields
default:
return nil
}
}
file_proto_user_v1_auth_proto_msgTypes[1].Exporter = func(v any, i int) any {
switch v := v.(*LoginThirdPartyRequest); i {
case 0:
return &v.state
case 1:
return &v.sizeCache
case 2:
return &v.unknownFields
default:
return nil
}
}
file_proto_user_v1_auth_proto_msgTypes[2].Exporter = func(v any, i int) any {
switch v := v.(*AuthResponse); i {
case 0:
return &v.state
case 1:
return &v.sizeCache
case 2:
return &v.unknownFields
default:
return nil
}
}
file_proto_user_v1_auth_proto_msgTypes[3].Exporter = func(v any, i int) any {
switch v := v.(*SetPasswordRequest); i {
case 0:
return &v.state
case 1:
return &v.sizeCache
case 2:
return &v.unknownFields
default:
return nil
}
}
file_proto_user_v1_auth_proto_msgTypes[4].Exporter = func(v any, i int) any {
switch v := v.(*SetPasswordResponse); i {
case 0:
return &v.state
case 1:
return &v.sizeCache
case 2:
return &v.unknownFields
default:
return nil
}
}
file_proto_user_v1_auth_proto_msgTypes[5].Exporter = func(v any, i int) any {
switch v := v.(*RefreshTokenRequest); i {
case 0:
return &v.state
case 1:
return &v.sizeCache
case 2:
return &v.unknownFields
default:
return nil
}
}
file_proto_user_v1_auth_proto_msgTypes[6].Exporter = func(v any, i int) any {
switch v := v.(*RefreshTokenResponse); i {
case 0:
return &v.state
case 1:
return &v.sizeCache
case 2:
return &v.unknownFields
default:
return nil
}
}
file_proto_user_v1_auth_proto_msgTypes[7].Exporter = func(v any, i int) any {
switch v := v.(*LogoutRequest); i {
case 0:
return &v.state
case 1:
return &v.sizeCache
case 2:
return &v.unknownFields
default:
return nil
}
}
file_proto_user_v1_auth_proto_msgTypes[8].Exporter = func(v any, i int) any {
switch v := v.(*LogoutResponse); i {
case 0:
return &v.state
case 1:
return &v.sizeCache
case 2:
return &v.unknownFields
default:
return nil
}
}
file_proto_user_v1_auth_proto_msgTypes[9].Exporter = func(v any, i int) any {
switch v := v.(*RecordLoginBlockedRequest); i {
case 0:
return &v.state
case 1:
return &v.sizeCache
case 2:
return &v.unknownFields
default:
return nil
}
}
file_proto_user_v1_auth_proto_msgTypes[10].Exporter = func(v any, i int) any {
switch v := v.(*RecordLoginBlockedResponse); i {
case 0:
return &v.state
case 1:
return &v.sizeCache
case 2:
return &v.unknownFields
default:
return nil
}
}
}
type x struct{}
out := protoimpl.TypeBuilder{
File: protoimpl.DescBuilder{

View File

@ -1,7 +1,7 @@
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
// versions:
// - protoc-gen-go-grpc v1.5.1
// - protoc v5.29.3
// - protoc v5.29.2
// source: proto/user/v1/auth.proto
package userv1

File diff suppressed because it is too large Load Diff

View File

@ -1,7 +1,7 @@
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
// versions:
// - protoc-gen-go-grpc v1.5.1
// - protoc v5.29.3
// - protoc v5.29.2
// source: proto/user/v1/host.proto
package userv1

File diff suppressed because it is too large Load Diff

View File

@ -1,7 +1,7 @@
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
// versions:
// - protoc-gen-go-grpc v1.5.1
// - protoc v5.29.3
// - protoc v5.29.2
// source: proto/user/v1/user.proto
package userv1

File diff suppressed because it is too large Load Diff

View File

@ -1,7 +1,7 @@
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
// versions:
// - protoc-gen-go-grpc v1.5.1
// - protoc v5.29.3
// - protoc v5.29.2
// source: proto/wallet/v1/wallet.proto
package walletv1

View File

@ -0,0 +1,125 @@
# 房间关注接口 Flutter 对接
## 接口地址
`POST /api/v1/rooms/{room_id}/follow`
`DELETE /api/v1/rooms/{room_id}/follow`
本地开发地址:
```text
http://127.0.0.1:13000
```
## 请求方法
| 方法 | 说明 |
| --- | --- |
| `POST` | 关注房间 |
| `DELETE` | 取消关注房间 |
## 请求头
| Header | 必填 | 模拟值 | 说明 |
| --- | --- | --- | --- |
| `Authorization` | 是 | `Bearer <access_token>` | 登录 access token服务端从 token 读取当前用户 ID。 |
| `X-App-Code` | 否 | `lalu` | App 编码;登录态接口最终以 token 内的 `app_code` 为准。 |
## 参数
Path 参数:
| 参数 | 必填 | 类型 | 模拟值 | 说明 |
| --- | --- | --- | --- | --- |
| `room_id` | 是 | string | `room_10001` | 房间 ID只允许字母、数字、下划线、短横线最长 48 位。 |
Query 参数:无。
请求体:无。不要传 `user_id`,服务端只使用 token 中的当前用户。
关注请求示例:
```http
POST /api/v1/rooms/room_10001/follow
Authorization: Bearer <access_token>
X-App-Code: lalu
```
取消关注请求示例:
```http
DELETE /api/v1/rooms/room_10001/follow
Authorization: Bearer <access_token>
X-App-Code: lalu
```
## 返回体模拟数据
关注成功:
```json
{
"code": "OK",
"message": "ok",
"request_id": "req_abc",
"data": {
"room_id": "room_10001",
"is_followed": true,
"followed_at_ms": 1710000000123,
"server_time_ms": 1710000000456
}
}
```
取消关注成功:
```json
{
"code": "OK",
"message": "ok",
"request_id": "req_abc",
"data": {
"room_id": "room_10001",
"is_followed": false,
"server_time_ms": 1710000000789
}
}
```
字段说明:
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `room_id` | string | 本次操作的房间 ID。 |
| `is_followed` | bool | 当前登录用户是否已关注该房间。 |
| `followed_at_ms` | int64 | 关注成功时间Unix epoch milliseconds取消关注返回体不带该字段。 |
| `server_time_ms` | int64 | 服务端当前时间Unix epoch milliseconds。 |
## 错误处理
失败返回仍使用统一 envelope
```json
{
"code": "INVALID_ARGUMENT",
"message": "invalid argument",
"request_id": "req_abc"
}
```
| HTTP 状态码 | `code` | 处理方式 |
| --- | --- | --- |
| `400` | `INVALID_ARGUMENT` | `room_id` 为空或格式非法,客户端不要重试,检查传参。 |
| `401` | `UNAUTHORIZED` | token 缺失、无效、过期或会话失效,重新登录。 |
| `403` | `PROFILE_REQUIRED` | 用户资料未完成,跳转资料补全流程。 |
| `404` | `NOT_FOUND` | 关注时房间不存在,刷新房间状态或退出当前房间页。 |
| `409` | `ROOM_CLOSED` | 关注时房间已关闭,刷新房间状态或退出当前房间页。 |
| `502` | `UPSTREAM_ERROR` | 内部服务暂不可用,可提示稍后重试。 |
幂等规则:
| 场景 | 返回 |
| --- | --- |
| 重复关注已关注房间 | `200 OK``is_followed=true`,不刷新原 `followed_at_ms`。 |
| 取消未关注房间 | `200 OK``is_followed=false`。 |

View File

@ -0,0 +1,322 @@
# 房间踢人接口 Flutter 对接
本文描述 Flutter App 对接房间踢人能力的 HTTP 接口,以及踢人后腾讯 IM 和腾讯 RTC 的客户端处理方式。踢人事实由 `room-service` Room Cell 提交;客户端不能只靠本地 UI 状态判断用户是否仍在房间。
## 接口地址
`POST /api/v1/rooms/user/kick`
`POST /api/v1/rooms/user/unban`
本地开发地址:
```text
http://127.0.0.1:13000
```
## 请求方法
| 方法 | 接口 | 说明 |
| --- | --- | --- |
| `POST` | `/api/v1/rooms/user/kick` | 踢出房间用户,并写入房间 ban 集合。 |
| `POST` | `/api/v1/rooms/user/unban` | 解除房间 ban允许用户后续重新 JoinRoom。 |
## 请求头
| Header | 必填 | 模拟值 | 说明 |
| --- | --- | --- | --- |
| `Authorization` | 是 | `Bearer <access_token>` | 登录 access token服务端从 token 读取当前用户 ID。 |
| `X-App-Code` | 否 | `lalu` | App 编码;登录态接口最终以 token 内的 `app_code` 为准。 |
## 踢出房间用户
房主可以踢出房间内用户。当前后端权限矩阵也允许房管管理普通用户;端上如果只开放房主入口,后端仍兼容。
请求体:
| 字段 | 必填 | 类型 | 模拟值 | 说明 |
| --- | --- | --- | --- | --- |
| `room_id` | 是 | string | `room_10001` | 房间 ID。 |
| `command_id` | 是 | string | `cmd_kick_room_10001_20001_1710000000` | 房间命令幂等键;同一次踢人重试必须复用。 |
| `target_user_id` | 是 | int64 | `20001` | 被踢用户长 ID。 |
请求示例:
```http
POST /api/v1/rooms/user/kick
Authorization: Bearer <access_token>
X-App-Code: lalu
Content-Type: application/json
{
"room_id": "room_10001",
"command_id": "cmd_kick_room_10001_20001_1710000000",
"target_user_id": 20001
}
```
成功响应:
```json
{
"code": "OK",
"message": "ok",
"request_id": "req_abc",
"data": {
"result": {
"applied": true,
"room_version": 18,
"server_time_ms": 1710000000456
},
"room": {
"room_id": "room_10001",
"status": "active",
"ban_user_ids": [20001],
"online_users": [],
"mic_seats": [],
"version": 18
},
"rtc_kicked": true,
"rtc_kick_error": ""
}
}
```
字段说明:
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `data.result.applied` | bool | 本次命令是否产生状态变更;重复踢已 ban 且无残留状态的用户可能为 `false`。 |
| `data.room.ban_user_ids` | int64[] | 当前房间 ban 用户集合;被踢用户会进入该集合。 |
| `data.room.online_users` | array | 当前业务 presence被踢用户会被移除。 |
| `data.room.mic_seats` | array | 如果被踢用户在麦上,对应麦位会被释放。 |
| `data.rtc_kicked` | bool | 服务端是否已调用 TRTC `RemoveUserByStrRoomId` 移除目标用户。 |
| `data.rtc_kick_error` | string | TRTC 服务端踢人失败原因;踢人事实已生效,客户端仍按被踢处理。 |
## 解除房间 ban
解除 ban 只恢复用户后续重新进房资格,不恢复历史 presence、麦位、管理员身份或 RTC 连接。
请求示例:
```http
POST /api/v1/rooms/user/unban
Authorization: Bearer <access_token>
X-App-Code: lalu
Content-Type: application/json
{
"room_id": "room_10001",
"command_id": "cmd_unban_room_10001_20001_1710000100",
"target_user_id": 20001
}
```
成功响应字段和踢人一致,`room.ban_user_ids` 中不再包含目标用户。
## IM 和 RTC 交互
踢人成功后有三层效果,客户端都要按幂等方式处理:
| 层 | 服务端行为 | Flutter 处理 |
| --- | --- | --- |
| room-service | 移除目标 presence、释放麦位、写入 ban后续 JoinRoom、RTC token、IM 进群和发言守卫都会拒绝目标用户。 | 被踢用户所有房间接口如果返回 `PERMISSION_DENIED`,立即退出房间页并清理房间态。 |
| 腾讯 IM 群 | room-service outbox worker 先调用 IM REST 把目标用户移出房间群,再发布 `room_user_kicked` 系统消息给房间内其他用户。 | 普通成员收到 `room_user_kicked` 后刷新在线列表和麦位;被踢用户要监听 IM 群被移除或退群回调作为主要退出信号。 |
| 腾讯 IM 单聊 | notice-service 监听 `room_outbox.RoomUserKicked`,给被踢用户发送 C2C `room_notice` 私有通知。 | 被踢用户收到 `room_notice` 时执行同一套本地退出逻辑;按 `event_id` 去重,避免和群消息或接口 403 重复弹窗。 |
| 腾讯 RTC | room-service 在踢人事实提交后尽力调用 TRTC `RemoveUserByStrRoomId`。 | 被踢用户收到 RTC 退房或连接断开回调时调用同一套本地退出逻辑;如果先收到 IM 回调,也要主动 `exitRoom`。 |
客户端退出房间的本地动作必须可重复调用:
1. 停止本地音频上行。
2. 调用 TRTC `exitRoom`
3. 退出或清理当前房间 IM 群会话。
4. 停止房间 heartbeat。
5. 清空当前房间页状态,回到房间列表或 Mine 页面。
6. 弹出被踢提示。
被踢用户不一定能收到房间群里的 `room_user_kicked` 自定义消息,因为服务端会优先把他移出 IM 群。端上必须同时处理 notice-service 的 C2C `room_notice`、IM SDK 的群移除回调、RTC 退房回调和后续接口 403。
## IM 自定义消息
房间内其他用户会收到群 `TIMCustomElem`,核心字段如下:
```json
{
"event_id": "evt_abc",
"room_id": "room_10001",
"event_type": "room_user_kicked",
"actor_user_id": 10001,
"target_user_id": 20001,
"room_version": 18,
"attributes": {}
}
```
处理规则:
| 字段 | 说明 |
| --- | --- |
| `event_type` | 固定 `room_user_kicked`。 |
| `actor_user_id` | 操作者用户 ID系统驱逐时可能为 `0`。 |
| `target_user_id` | 被踢用户 ID。 |
| `room_version` | 房间版本;本地已有更高版本时丢弃旧消息。 |
如果 `target_user_id` 是当前登录用户,直接执行本地退出房间动作;如果不是当前用户,只刷新在线列表、麦位和管理面 UI。
被踢用户会收到 notice-service 发送的 C2C `TIMCustomElem``Ext=room_notice`,核心字段如下:
```json
{
"event_id": "evt_abc",
"event_type": "room_user_kicked",
"source_event_type": "RoomUserKicked",
"app_code": "lalu",
"room_id": "room_10001",
"room_version": 18,
"actor_user_id": 10001,
"target_user_id": 20001,
"occurred_at_ms": 1710000000456,
"source_created_at_ms": 1710000000456
}
```
C2C 通知和房间群消息使用同一个 `event_id`。Flutter 侧把 `room_user_kicked`、IM 群移除、RTC 退房和接口 403 都归一到同一个 `handleKickedFromRoom(roomId, eventId)` 流程;同一个 `event_id` 或同一个房间的短时间重复退出信号只处理一次。
## 错误处理
失败返回仍使用统一 envelope
```json
{
"code": "PERMISSION_DENIED",
"message": "permission denied",
"request_id": "req_abc"
}
```
| HTTP 状态码 | `code` | 典型场景 | 处理方式 |
| --- | --- | --- | --- |
| `400` | `INVALID_ARGUMENT` | `room_id``command_id``target_user_id` 缺失;操作者踢自己。 | 客户端修正参数,不自动重试。 |
| `401` | `UNAUTHORIZED` | token 缺失、无效、过期或会话失效。 | 重新登录。 |
| `403` | `PROFILE_REQUIRED` | 用户资料未完成。 | 跳转资料补全流程。 |
| `403` | `PERMISSION_DENIED` | 非管理角色踢人;房管踢房主;操作者不在房间。 | 隐藏入口或提示无权限。 |
| `404` | `NOT_FOUND` | 房间不存在。 | 刷新列表或退出房间页。 |
| `409` | `ROOM_CLOSED` | 房间已关闭。 | 刷新列表或退出房间页。 |
| `409` | `CONFLICT` | 同一个 `command_id` 被不同请求体复用。 | 为新的用户动作生成新的 `command_id`。 |
| `502` | `UPSTREAM_ERROR` | 内部服务暂不可用。 | 可提示稍后重试。 |
幂等规则:
| 场景 | 返回 |
| --- | --- |
| 同一个 `command_id` 重试同一次踢人 | 返回同一命令结果,不重复变更房间。 |
| 同一个 `command_id` 换另一个 `target_user_id` | `409 CONFLICT`。 |
| 重复踢已 ban 且无残留状态的用户 | `200 OK``applied=false`。 |
| 解封未 ban 用户 | `200 OK``applied=false`。 |
## Flutter 解析示例
```dart
class RoomCommandResult {
RoomCommandResult({
required this.applied,
required this.roomVersion,
required this.serverTimeMs,
});
final bool applied;
final int roomVersion;
final int serverTimeMs;
factory RoomCommandResult.fromJson(Map<String, dynamic> json) {
return RoomCommandResult(
applied: json['applied'] as bool? ?? false,
roomVersion: (json['room_version'] as num?)?.toInt() ?? 0,
serverTimeMs: (json['server_time_ms'] as num?)?.toInt() ?? 0,
);
}
}
class KickUserResult {
KickUserResult({
required this.result,
required this.rtcKicked,
required this.rtcKickError,
required this.banUserIds,
});
final RoomCommandResult result;
final bool rtcKicked;
final String rtcKickError;
final List<int> banUserIds;
factory KickUserResult.fromJson(Map<String, dynamic> json) {
final room = json['room'] as Map<String, dynamic>? ?? const {};
return KickUserResult(
result: RoomCommandResult.fromJson(
json['result'] as Map<String, dynamic>? ?? const {},
),
rtcKicked: json['rtc_kicked'] as bool? ?? false,
rtcKickError: json['rtc_kick_error'] as String? ?? '',
banUserIds: (room['ban_user_ids'] as List<dynamic>? ?? const [])
.map((value) => (value as num).toInt())
.toList(),
);
}
}
class RoomKickedEvent {
RoomKickedEvent({
required this.roomId,
required this.actorUserId,
required this.targetUserId,
required this.roomVersion,
});
final String roomId;
final int actorUserId;
final int targetUserId;
final int roomVersion;
bool isSelf(int currentUserId) => targetUserId == currentUserId;
factory RoomKickedEvent.fromJson(Map<String, dynamic> json) {
return RoomKickedEvent(
roomId: json['room_id'] as String? ?? '',
actorUserId: (json['actor_user_id'] as num?)?.toInt() ?? 0,
targetUserId: (json['target_user_id'] as num?)?.toInt() ?? 0,
roomVersion: (json['room_version'] as num?)?.toInt() ?? 0,
);
}
}
class RoomKickNotice {
RoomKickNotice({
required this.eventId,
required this.roomId,
required this.actorUserId,
required this.targetUserId,
required this.roomVersion,
});
final String eventId;
final String roomId;
final int actorUserId;
final int targetUserId;
final int roomVersion;
factory RoomKickNotice.fromJson(Map<String, dynamic> json) {
return RoomKickNotice(
eventId: json['event_id'] as String? ?? '',
roomId: json['room_id'] as String? ?? '',
actorUserId: (json['actor_user_id'] as num?)?.toInt() ?? 0,
targetUserId: (json['target_user_id'] as num?)?.toInt() ?? 0,
roomVersion: (json['room_version'] as num?)?.toInt() ?? 0,
);
}
}
```
调用 HTTP 接口时先解析外层 envelope只有 `code == "OK"` 时读取 `data`;其他 `code` 按登录态、参数错误、权限错误或服务错误处理,并把 `request_id` 写入客户端日志。
客户端生成 `command_id` 时按一次用户动作生成一次,同一次 HTTP 重试复用原 `command_id`;用户再次点击踢人时生成新的 `command_id`

View File

@ -0,0 +1,383 @@
# 房间锁房接口 Flutter 对接
本文描述 Flutter App 对接房间锁房能力的 HTTP 接口。房间密码由 `room-service` 只保存 bcrypt 哈希;客户端只提交明文用于本次设置或进房校验,任何列表、搜索、详情、房间消息和响应体都不会返回明文或哈希。
## 接口地址
`POST /api/v1/rooms/password`
`POST /api/v1/rooms/join`
`GET /api/v1/rooms`
`GET /api/v1/rooms/feeds`
本地开发地址:
```text
http://127.0.0.1:13000
```
## 请求方法
| 方法 | 接口 | 说明 |
| --- | --- | --- |
| `POST` | `/api/v1/rooms/password` | 房主设置或清空房间入房密码。 |
| `POST` | `/api/v1/rooms/join` | 进入房间;带锁房间需要传 `password`。 |
| `GET` | `/api/v1/rooms` | 公共房间列表和搜索;房间卡片返回 `locked`。 |
| `GET` | `/api/v1/rooms/feeds` | Mine 页房间流;房间卡片返回 `locked`。 |
## 请求头
| Header | 必填 | 模拟值 | 说明 |
| --- | --- | --- | --- |
| `Authorization` | 是 | `Bearer <access_token>` | 登录 access token服务端从 token 读取当前用户 ID。 |
| `X-App-Code` | 否 | `lalu` | App 编码;登录态接口最终以 token 内的 `app_code` 为准。 |
## 设置或清空房间密码
只有房主可以调用。房管、主持人和普通观众调用会返回 `PERMISSION_DENIED`
请求体:
| 字段 | 必填 | 类型 | 模拟值 | 说明 |
| --- | --- | --- | --- | --- |
| `room_id` | 是 | string | `room_10001` | 房间 ID。 |
| `command_id` | 是 | string | `cmd_room_password_room_10001_1710000000` | 房间命令幂等键;同一次点击重试必须复用。 |
| `locked` | 是 | bool | `true` | `true` 表示设置密码,`false` 表示清空密码。 |
| `password` | `locked=true` 时必填 | string | `1234` | 入房密码;服务端 trim 后最长 64 个字符。`locked=false` 时忽略。 |
设置密码请求示例:
```http
POST /api/v1/rooms/password
Authorization: Bearer <access_token>
X-App-Code: lalu
Content-Type: application/json
{
"room_id": "room_10001",
"command_id": "cmd_room_password_room_10001_1710000000",
"locked": true,
"password": "1234"
}
```
清空密码请求示例:
```http
POST /api/v1/rooms/password
Authorization: Bearer <access_token>
X-App-Code: lalu
Content-Type: application/json
{
"room_id": "room_10001",
"command_id": "cmd_room_password_room_10001_1710000100",
"locked": false
}
```
成功响应:
```json
{
"code": "OK",
"message": "ok",
"request_id": "req_abc",
"data": {
"result": {
"applied": true,
"room_version": 12,
"server_time_ms": 1710000000456
},
"room": {
"room_id": "room_10001",
"im_group_id": "room_10001",
"room_short_id": "100001",
"title": "Live Room",
"cover_url": "https://cdn.example/room.png",
"owner_user_id": "10001",
"mode": "voice",
"status": "active",
"chat_enabled": true,
"locked": true,
"heat": 1000,
"online_count": 12,
"seat_count": 15,
"occupied_seat_count": 2,
"version": 12
},
"seats": [],
"server_time_ms": 1710000000456
}
}
```
字段说明:
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `data.result.applied` | bool | 本次命令是否产生状态变更;重复提交相同命令可能为 `false`。 |
| `data.result.room_version` | int64 | 命令完成后房间版本。 |
| `data.room.locked` | bool | 最新锁房状态;`true` 表示后续新用户进房需要密码。 |
| `data.room.version` | int64 | 最新房间快照版本;客户端本地房间状态以更大版本覆盖旧状态。 |
| `data.seats` | array | 当前座位列表;锁房接口成功后可直接复用该房间快照刷新页面。 |
## 带密码进入房间
客户端进入房间前先看房间卡片或详情里的 `locked`
| `locked` | 客户端行为 |
| --- | --- |
| `false` | 直接调用 JoinRoom`password` 可不传或传空字符串。 |
| `true` | 先弹出密码输入,再调用 JoinRoom 并传 `password`。 |
请求体:
| 字段 | 必填 | 类型 | 模拟值 | 说明 |
| --- | --- | --- | --- | --- |
| `room_id` | 是 | string | `room_10001` | 房间 ID。 |
| `command_id` | 是 | string | `cmd_join_room_10001_20001_1710000000` | 进房命令幂等键;同一次进房重试必须复用。 |
| `role` | 否 | string | `audience` | 当前固定传 `audience`。 |
| `password` | 锁房时必填 | string | `1234` | 只用于本次进房校验,服务端不保存明文。 |
请求示例:
```http
POST /api/v1/rooms/join
Authorization: Bearer <access_token>
X-App-Code: lalu
Content-Type: application/json
{
"room_id": "room_10001",
"command_id": "cmd_join_room_10001_20001_1710000000",
"role": "audience",
"password": "1234"
}
```
成功响应只展示锁房相关字段:
```json
{
"code": "OK",
"message": "ok",
"request_id": "req_abc",
"data": {
"result": {
"applied": true,
"room_version": 13,
"server_time_ms": 1710000001456
},
"room": {
"room_id": "room_10001",
"im_group_id": "room_10001",
"locked": true,
"version": 13
},
"viewer": {
"user_id": "20001",
"role": "audience",
"joined_at_ms": 1710000001456,
"last_seen_at_ms": 1710000001456
},
"im": {
"group_id": "room_10001",
"need_join_group": true
},
"rtc": {
"need_token": true,
"available": true
},
"server_time_ms": 1710000001456
}
}
```
进房规则:
| 场景 | 结果 |
| --- | --- |
| 房间未锁 | 不校验 `password`。 |
| 房间已锁且密码正确 | JoinRoom 成功,客户端再调用腾讯 IM `joinGroup` 和 RTC `enterRoom`。 |
| 房间已锁且密码错误或缺失 | 返回 `403 PERMISSION_DENIED`,不会创建 presence不会返回 RTC token。 |
| 房主进入自己的房间 | 不需要密码。 |
| 用户已经在房间内 | 再次 JoinRoom 按幂等恢复当前房间态,不会因为后续锁房被踢出。 |
## 房间列表和搜索 locked 字段
公共列表和搜索使用同一个接口:
```http
GET /api/v1/rooms?tab=hot&query=100001
Authorization: Bearer <access_token>
X-App-Code: lalu
```
Mine 页房间流:
```http
GET /api/v1/rooms/feeds?tab=followed
Authorization: Bearer <access_token>
X-App-Code: lalu
```
列表响应示例:
```json
{
"code": "OK",
"message": "ok",
"request_id": "req_abc",
"data": {
"rooms": [
{
"room_id": "room_10001",
"im_group_id": "room_10001",
"owner_user_id": "10001",
"title": "Live Room",
"cover_url": "https://cdn.example/room.png",
"mode": "voice",
"status": "active",
"locked": true,
"heat": 1000,
"online_count": 12,
"seat_count": 15,
"occupied_seat_count": 2,
"visible_region_id": 1,
"app_code": "lalu",
"room_short_id": "100001"
}
],
"next_cursor": ""
}
}
```
字段说明:
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `rooms[].locked` | bool | 房间是否需要入房密码;搜索结果和普通列表一致。 |
| `rooms[].im_group_id` | string | 腾讯 IM 群 ID只用于 JoinRoom 成功后的 `joinGroup`,不能绕过 JoinRoom。 |
| `next_cursor` | string | 下一页游标;为空表示没有下一页。 |
## 错误处理
失败返回仍使用统一 envelope
```json
{
"code": "PERMISSION_DENIED",
"message": "permission denied",
"request_id": "req_abc"
}
```
| HTTP 状态码 | `code` | 典型场景 | 处理方式 |
| --- | --- | --- | --- |
| `400` | `INVALID_ARGUMENT` | `room_id``command_id` 缺失;设置锁房时 `password` 为空或超过 64 字符。 | 客户端修正参数,不自动重试。 |
| `401` | `UNAUTHORIZED` | token 缺失、无效、过期或会话失效。 | 重新登录。 |
| `403` | `PROFILE_REQUIRED` | 用户资料未完成。 | 跳转资料补全流程。 |
| `403` | `PERMISSION_DENIED` | 非房主设置密码;进锁房密码错误;用户被 ban。 | 设置密码时隐藏入口或提示无权限;进房时提示密码错误。 |
| `404` | `NOT_FOUND` | 房间不存在。 | 刷新列表或退出房间页。 |
| `409` | `ROOM_CLOSED` | 房间已关闭。 | 刷新列表或退出房间页。 |
| `409` | `CONFLICT` | 同一个 `command_id` 被不同请求体复用。 | 为新的用户动作生成新的 `command_id`。 |
| `502` | `UPSTREAM_ERROR` | 内部服务暂不可用。 | 可提示稍后重试。 |
幂等规则:
| 场景 | 返回 |
| --- | --- |
| 同一个 `command_id` 重试同一次设置密码 | 返回同一命令结果,不重复变更房间。 |
| 同一个 `command_id` 换另一个密码 | `409 CONFLICT`。 |
| 重复清空已解锁房间 | `200 OK``data.room.locked=false`。 |
| 同一个用户重复 JoinRoom | 返回当前房间首屏数据,不重复创建 presence。 |
## Flutter 解析示例
```dart
class RoomCommandResult {
RoomCommandResult({
required this.applied,
required this.roomVersion,
required this.serverTimeMs,
});
final bool applied;
final int roomVersion;
final int serverTimeMs;
factory RoomCommandResult.fromJson(Map<String, dynamic> json) {
return RoomCommandResult(
applied: json['applied'] as bool? ?? false,
roomVersion: (json['room_version'] as num?)?.toInt() ?? 0,
serverTimeMs: (json['server_time_ms'] as num?)?.toInt() ?? 0,
);
}
}
class RoomCard {
RoomCard({
required this.roomId,
required this.imGroupId,
required this.title,
required this.locked,
required this.onlineCount,
});
final String roomId;
final String imGroupId;
final String title;
final bool locked;
final int onlineCount;
factory RoomCard.fromJson(Map<String, dynamic> json) {
return RoomCard(
roomId: json['room_id'] as String? ?? '',
imGroupId: json['im_group_id'] as String? ?? '',
title: json['title'] as String? ?? '',
locked: json['locked'] as bool? ?? false,
onlineCount: (json['online_count'] as num?)?.toInt() ?? 0,
);
}
}
class RoomLockResult {
RoomLockResult({
required this.result,
required this.room,
required this.serverTimeMs,
});
final RoomCommandResult result;
final RoomCard room;
final int serverTimeMs;
factory RoomLockResult.fromJson(Map<String, dynamic> json) {
return RoomLockResult(
result: RoomCommandResult.fromJson(
json['result'] as Map<String, dynamic>? ?? const {},
),
room: RoomCard.fromJson(
json['room'] as Map<String, dynamic>? ?? const {},
),
serverTimeMs: (json['server_time_ms'] as num?)?.toInt() ?? 0,
);
}
}
```
调用时先解析外层 envelope只有 `code == "OK"` 时读取 `data`;其他 `code` 按登录态、参数错误、权限错误或服务错误处理,并把 `request_id` 写入客户端日志。
客户端生成 `command_id` 时按一次用户动作生成一次,例如:
```dart
String newRoomCommandId(String action, String roomId, String userId) {
return 'cmd_${action}_${roomId}_${userId}_${DateTime.now().millisecondsSinceEpoch}';
}
```
同一次 HTTP 重试复用原 `command_id`;用户重新输入密码或再次点击设置按钮时生成新的 `command_id`

View File

@ -4,12 +4,13 @@
`notice-service` 是外部通知投递服务,不是新的业务状态 owner。它从各 owner service 的 outbox 或只读事件源读取已经提交的事实,再投递到用户私有实时通道、后续 push 或站内 inbox。
当前已实现的第一条链路是:
当前已实现的链路是:
- `wallet-service` 在同一账务事务里写 `wallet_outbox.WalletBalanceChanged`
- `notice-service` 抢占该事件,写自己的 `notice_delivery_events` 投递位点。
- `notice-service` 通过腾讯云 IM C2C `TIMCustomElem` 给用户发送 `wallet_notice`
- 客户端收到通知后按 `asset_type + balance_version` 更新本地余额;乱序或重复事件直接丢弃。
- `room-service``room_outbox.RoomUserKicked` 后,`notice-service` 只给被踢用户发送 `room_notice` C2C 私有通知;房间群系统消息和 IM 群成员移除仍由 room-service 的房间 IM bridge 负责。
## 服务边界
@ -17,12 +18,17 @@
| --- | --- |
| `wallet-service` | 余额、流水、交易幂等、`wallet_outbox` 事实源;不调用 IM、不关心客户端在线状态 |
| `notice-service` | 通知投递位点、抢占锁、退避重试、死信、腾讯云 IM 单聊投递 |
| `room-service` | 房间状态和房间群系统消息 outbox不发送钱包私有余额 |
| `room-service` | 房间状态、房间群系统消息 outbox、腾讯 IM 群组/成员桥接;不直接发送用户私有通知 |
| `activity-service` | 活动、任务、系统消息业务事实;后续可新增 activity notice 模块 |
| 腾讯云 IM | 客户端长连接、单聊/群消息、离线/漫游投递 |
`notice-service` 不直接修改钱包、房间、用户或活动业务表。它只读取 owner outbox并写自己的投递状态表。
房间 IM 被拆成两种通道:
- 房间群通道:`RoomCreated` 建群、`RoomUserKicked` 移除群成员、`room_user_kicked` 群系统消息由 room-service outbox worker 投递。它依赖房间群生命周期和群成员控制,不进入 notice-service。
- 用户私有通道:被踢本人可能已经先被移出 IM 群,不能保证收到群消息;因此 notice-service 监听同一条 `RoomUserKicked` 事实,发送 C2C `room_notice``target_user_id`
## 当前目录结构
```text
@ -32,6 +38,7 @@ services/notice-service/
deploy/mysql/initdb/ notice-service 自己的库表
internal/app/ 进程装配、health、worker 生命周期
internal/config/ 配置解析和默认值
internal/modules/roomnotice/ 房间私有通知模块
internal/modules/walletnotice/ 钱包余额通知模块
internal/platform/mysql/ notice-service MySQL 连接池
```
@ -76,6 +83,34 @@ sequenceDiagram
主请求链路只等待钱包扣费和房间状态提交,不等待腾讯云 IM 私有通知。因此送礼请求不会因为 IM 抖动变慢。IM 投递失败时用户仍可以通过钱包接口拉取最终余额notice worker 会按退避策略补偿。
## 房间踢人私有通知链路
```mermaid
sequenceDiagram
participant Owner as Room Owner App
participant Gateway as gateway-service
participant Room as room-service
participant RoomDB as hyapp_room
participant Notice as notice-service
participant NoticeDB as hyapp_notice
participant TencentIM as Tencent IM
participant Target as Kicked User App
Owner->>Gateway: POST /api/v1/rooms/user/kick
Gateway->>Room: KickUser(command_id, target_user_id)
Room->>Room: remove presence, release mic, write ban
Room->>RoomDB: command log + snapshot + room_outbox.RoomUserKicked
Room-->>Gateway: success
Room-->>TencentIM: room outbox worker deletes group member and sends group event
Notice->>RoomDB: claim RoomUserKicked
Notice->>NoticeDB: notice_delivery_events=delivering
Notice->>TencentIM: C2C TIMCustomElem(room_notice)
TencentIM-->>Target: private kicked notice
Notice->>NoticeDB: delivered / retryable / failed
```
房间群消息和 C2C 私有通知使用同一个 `event_id`,客户端按 `event_id` 去重即可。notice-service 不修改 `room_outbox.status`,它只用 `notice_delivery_events` 记录自己的 C2C 投递状态,避免和 room-service 群消息 worker 互相覆盖。
## Payload 约定
`WalletBalanceChanged` 发送给客户端的核心字段:
@ -126,16 +161,25 @@ sequenceDiagram
本地默认关闭 worker避免误发真实腾讯云 IM
```yaml
wallet_database: "hyapp_wallet"
room_database: "hyapp_room"
tencent_im:
enabled: false
wallet_notice_worker:
enabled: false
room_notice_worker:
enabled: false
```
线上启用钱包余额通知必须同时开启:
线上启用私有通知必须同时开启腾讯云 IM并配置对应 owner outbox 所在库名
```yaml
wallet_database: "hyapp_wallet"
room_database: "hyapp_room"
tencent_im:
enabled: true
sdk_app_id: 1400000000
@ -148,6 +192,13 @@ wallet_notice_worker:
batch_size: 100
lock_ttl: 30s
max_retry_count: 10
room_notice_worker:
enabled: true
poll_interval: 500ms
batch_size: 100
lock_ttl: 30s
max_retry_count: 10
```
`notice-service` 使用 `13009` 作为 gRPC health 端口,`13109` 作为 HTTP health 端口。
@ -155,10 +206,11 @@ wallet_notice_worker:
## 后续扩展顺序
1. 钱包余额通知:已按 `walletnotice` 模块落地。
2. 幸运礼物中奖:仍由 `wallet-service` 写余额 outboxnotice 不需要新增特殊账务逻辑,只要客户端按 `balance_version` 更新。
3. 活动/系统私有通知:新增 `activitynotice` 模块,读取 activity outbox复用 `notice_delivery_events` 或新增同语义位点表。
4. 站内 inbox新增 inbox 模块,写 notice-service 自己的 inbox 表,再按客户端分页接口读取。
5. Push新增 push 模块,和 IM 投递使用不同 `channel`,独立重试和死信。
2. 房间踢人私有通知:已按 `roomnotice` 模块落地,只消费 `RoomUserKicked` 的 C2C 通知。
3. 幸运礼物中奖:仍由 `wallet-service` 写余额 outboxnotice 不需要新增特殊账务逻辑,只要客户端按 `balance_version` 更新。
4. 活动/系统私有通知:新增 `activitynotice` 模块,读取 activity outbox复用 `notice_delivery_events` 或新增同语义位点表。
5. 站内 inbox新增 inbox 模块,写 notice-service 自己的 inbox 表,再按客户端分页接口读取。
6. Push新增 push 模块,和 IM 投递使用不同 `channel`,独立重试和死信。
## 真实库验证

View File

@ -959,7 +959,7 @@ paths:
- rooms
summary: Mine 房间关系流
operationId: listRoomFeeds
description: 查询 Mine 页下方 Visited/Friend/Following 房间流;`visited` 读取 JoinRoom 写入的房间访问索引,`friend/following` 由 gateway 先读取 user-service 关系事实,再让 room-service 返回这些关系用户的 active 房间卡片和 `im_group_id`,不作为公共房间发现列表。
description: 查询 Mine 页下方 Visited/Friend/Following/Followed 房间流;`visited` 读取 JoinRoom 写入的房间访问索引,`friend/following` 由 gateway 先读取 user-service 关系事实,`followed` 读取 room-service 房间关注关系,再让 room-service 返回 active 房间卡片和 `im_group_id`,不作为公共房间发现列表。
security:
- BearerAuth: []
parameters:
@ -971,6 +971,7 @@ paths:
- visited
- friend
- following
- followed
- name: cursor
in: query
required: false
@ -982,7 +983,7 @@ paths:
format: int32
responses:
"200":
description: 查询成功;`visited` 由 JoinRoom 成功写入,`friend/following` 同步使用 user-service 好友/关注关系,不依赖 Redis 缓存命中。
description: 查询成功;`visited` 由 JoinRoom 成功写入,`friend/following` 同步使用 user-service 好友/关注关系,`followed` 同步使用 room-service 房间关注关系,不依赖 Redis 缓存命中。
schema:
$ref: "#/definitions/RoomListEnvelope"
"400":
@ -1068,6 +1069,63 @@ paths:
$ref: "#/responses/Conflict"
"502":
$ref: "#/responses/UpstreamError"
/api/v1/rooms/{room_id}/follow:
post:
tags:
- rooms
summary: 关注房间
operationId: followRoom
description: 使用 access token user_id 建立当前用户对房间的关注关系;重复关注幂等,`followed_at_ms` 不因重复点击刷新。
security:
- BearerAuth: []
parameters:
- name: room_id
in: path
required: true
type: string
pattern: "^[A-Za-z0-9_-]{1,48}$"
maxLength: 48
responses:
"200":
description: 关注成功,`data.is_followed=true`。
schema:
$ref: "#/definitions/RoomFollowEnvelope"
"400":
$ref: "#/responses/BadRequest"
"401":
$ref: "#/responses/Unauthorized"
"404":
$ref: "#/responses/NotFound"
"409":
$ref: "#/responses/Conflict"
"502":
$ref: "#/responses/UpstreamError"
delete:
tags:
- rooms
summary: 取消关注房间
operationId: unfollowRoom
description: 使用 access token user_id 取消当前用户对房间的关注关系;未关注时按幂等成功返回 `is_followed=false`。
security:
- BearerAuth: []
parameters:
- name: room_id
in: path
required: true
type: string
pattern: "^[A-Za-z0-9_-]{1,48}$"
maxLength: 48
responses:
"200":
description: 取消成功,`data.is_followed=false`。
schema:
$ref: "#/definitions/RoomFollowEnvelope"
"400":
$ref: "#/responses/BadRequest"
"401":
$ref: "#/responses/Unauthorized"
"502":
$ref: "#/responses/UpstreamError"
/api/v1/rooms/create:
post:
tags:
@ -1435,6 +1493,40 @@ paths:
$ref: "#/responses/Internal"
"502":
$ref: "#/responses/UpstreamError"
/api/v1/rooms/password:
post:
tags:
- rooms
summary: 设置或清空房间密码
operationId: setRoomPassword
description: 仅房主可调用。`locked=true` 时 `password` 必须非空;`locked=false` 清空密码并解除锁房。
security:
- BearerAuth: []
parameters:
- name: body
in: body
required: true
schema:
$ref: "#/definitions/SetRoomPasswordRequest"
responses:
"200":
description: 密码状态修改成功,`data.room.locked` 返回最新锁房标识。
schema:
$ref: "#/definitions/SetRoomPasswordEnvelope"
"400":
$ref: "#/responses/BadRequest"
"401":
$ref: "#/responses/Unauthorized"
"403":
$ref: "#/responses/Forbidden"
"404":
$ref: "#/responses/NotFound"
"409":
$ref: "#/responses/Conflict"
"500":
$ref: "#/responses/Internal"
"502":
$ref: "#/responses/UpstreamError"
/api/v1/rooms/admin/set:
post:
tags:
@ -3771,6 +3863,13 @@ definitions:
description: 房间命令幂等键;客户端按用户动作生成,同一动作的 HTTP 重试必须复用。
role:
type: string
enum:
- audience
description: 首版固定 audience上麦成功后客户端再切换发布角色。
password:
type: string
maxLength: 64
description: 锁房时必填;只用于本次 JoinRoom 校验,服务端不保存明文。
RoomHeartbeatRequest:
type: object
required:
@ -3879,6 +3978,19 @@ definitions:
description: 房间命令幂等键;客户端按用户动作生成,同一动作的 HTTP 重试必须复用。
enabled:
type: boolean
SetRoomPasswordRequest:
type: object
properties:
room_id:
type: string
command_id:
type: string
description: 房间命令幂等键;客户端按用户动作生成,同一动作的 HTTP 重试必须复用。
locked:
type: boolean
password:
type: string
description: "`locked=true` 时必填;`locked=false` 时忽略。"
SetRoomAdminRequest:
type: object
properties:
@ -4030,6 +4142,8 @@ definitions:
type: string
chat_enabled:
type: boolean
locked:
type: boolean
mic_seats:
type: array
items:
@ -4089,6 +4203,9 @@ definitions:
type: string
status:
type: string
locked:
type: boolean
description: true 表示该房间需要入房密码;列表和搜索只返回标识,不返回密码。
heat:
type: integer
format: int64
@ -4160,6 +4277,9 @@ definitions:
type: string
chat_enabled:
type: boolean
locked:
type: boolean
description: true 表示该房间需要入房密码。
heat:
type: integer
format: int64
@ -4303,6 +4423,27 @@ definitions:
properties:
room:
$ref: "#/definitions/RoomSnapshot"
is_followed:
type: boolean
description: 当前 token 用户是否已关注该房间;来自 room-service 房间关注关系,不属于 Room Cell 核心状态。
server_time_ms:
type: integer
format: int64
RoomFollowData:
type: object
required:
- room_id
- is_followed
- server_time_ms
properties:
room_id:
type: string
is_followed:
type: boolean
followed_at_ms:
type: integer
format: int64
description: POST 关注成功时返回DELETE 取消关注时省略。
server_time_ms:
type: integer
format: int64
@ -4465,6 +4606,12 @@ definitions:
$ref: "#/definitions/CommandResult"
room:
$ref: "#/definitions/RoomSnapshot"
rtc_kicked:
type: boolean
description: room-service 是否已通过 TRTC 服务端接口移除目标用户;失败不回滚踢人事实。
rtc_kick_error:
type: string
description: TRTC 服务端踢人失败原因,仅用于排障和告警。
UnbanUserResponse:
type: object
properties:
@ -4747,6 +4894,13 @@ definitions:
properties:
data:
$ref: "#/definitions/RoomSnapshotData"
RoomFollowEnvelope:
allOf:
- $ref: "#/definitions/GatewayOKEnvelopeBase"
- type: object
properties:
data:
$ref: "#/definitions/RoomFollowData"
JoinRoomEnvelope:
allOf:
- $ref: "#/definitions/GatewayOKEnvelopeBase"
@ -4810,6 +4964,13 @@ definitions:
properties:
data:
$ref: "#/definitions/SetChatEnabledResponse"
SetRoomPasswordEnvelope:
allOf:
- $ref: "#/definitions/GatewayOKEnvelopeBase"
- type: object
properties:
data:
$ref: "#/definitions/UpdateRoomProfileResponse"
SetRoomAdminEnvelope:
allOf:
- $ref: "#/definitions/GatewayOKEnvelopeBase"

View File

@ -0,0 +1,46 @@
# 房间区域置顶
房间置顶是公共房间发现列表的运营排序能力。房间列表按 `visible_region_id` 隔离,所以置顶也按区域生效:后台选择一个房间后,后端使用该房间当前 `visible_region_id` 建立 `room_region_pins` 记录。
## 边界
- `room-service` 仍然是房间状态 owner置顶不进入 Room Cell、snapshot 或 command log。
- 置顶只影响 `GET /api/v1/rooms?tab=hot/new` 的排序,不影响进房权限。
- 有效置顶条件为 `status=active``expires_at_ms > now_ms`
- 房间改区域后,旧区域置顶不会自动迁移到新区域;后台需要取消旧置顶并在新区域重新置顶。
## 排序
同一区域公共列表排序:
1. 有效置顶房间排在普通房间之前。
2. 置顶房间按 `weight DESC, expires_at_ms DESC, room_id ASC`
3. 非置顶房间保留原排序:`hot``sort_score DESC``new``created_at_ms DESC`
列表 cursor 包含置顶分区、权重、过期时间和原列表排序键,跨页时不会重复返回置顶房间。
## 后台接口
后台接口使用后台登录 token 和 `/api/v1` 前缀。
| 方法 | 路径 | 权限 | 说明 |
| --- | --- | --- | --- |
| `GET` | `/api/v1/admin/rooms/pins` | `room-pin:view` | 查询房间置顶列表,默认只返回有效置顶 |
| `POST` | `/api/v1/admin/rooms/pins` | `room-pin:create` | 新增或恢复房间置顶 |
| `DELETE` | `/api/v1/admin/rooms/pins/{pin_id}` | `room-pin:cancel` | 取消房间置顶 |
创建请求:
```json
{
"roomId": "room_123",
"weight": 100,
"durationDays": 30
}
```
- `roomId` 可以是长房间 ID 或短房间 ID最终落库使用长房间 ID。
- `durationDays` 不传时默认 30 天。
- 只能置顶当前 `active` 房间。
列表返回的 `room` 包含房间长短 ID、昵称、头像和区域`user` 包含房主头像、长短 ID 和昵称;`remainingMs` 是当前 UTC 业务时间下的剩余毫秒数。

View File

@ -54,12 +54,14 @@
| DELETE | `/api/v1/users/{user_id}/friend` | users | `deleteFriend` | 双向删除好友关系 |
| GET | `/api/v1/rooms` | rooms | `listRooms` | 房间发现列表,支持 `tab=hot/new`,卡片返回 `im_group_id` |
| GET | `/api/v1/rooms/me` | rooms | `getMyRoom` | 查询 Mine 顶部我的房间卡片 |
| GET | `/api/v1/rooms/feeds` | rooms | `listRoomFeeds` | Mine 页 Visited/Friend/Following 房间流 |
| GET | `/api/v1/rooms/feeds` | rooms | `listRoomFeeds` | Mine 页 Visited/Friend/Following/Followed 房间流 |
| GET | `/api/v1/rooms/current` | rooms | `getCurrentRoom` | 查询当前可恢复房间 |
| GET | `/api/v1/rooms/snapshot` | rooms | `getRoomSnapshot` | 查询房间完整快照 |
| GET | `/api/v1/rooms/{room_id}/detail` | rooms | `getRoomDetail` | 查询房间详情页首屏聚合包 |
| GET | `/api/v1/rooms/{room_id}/online-users` | rooms | `listRoomOnlineUsers` | 分页查询房间在线用户 |
| GET | `/api/v1/rooms/{room_id}/gift-panel` | rooms | `getRoomGiftPanel` | 查询房间礼物面板初始化数据 |
| POST | `/api/v1/rooms/{room_id}/follow` | rooms | `followRoom` | 关注房间 |
| DELETE | `/api/v1/rooms/{room_id}/follow` | rooms | `unfollowRoom` | 取消关注房间 |
| POST | `/api/v1/rooms/create` | rooms | `createRoom` | 创建房间 |
| POST | `/api/v1/rooms/profile/update` | rooms | `updateRoomProfile` | 修改房间资料和座位数 |
| POST | `/api/v1/rooms/join` | rooms | `joinRoom` | 加入房间并返回首屏数据和 RTC token |
@ -73,6 +75,7 @@
| POST | `/api/v1/rooms/mic/mute` | rooms | `setMicMute` | 设置服务端可见麦克风静音状态 |
| POST | `/api/v1/rooms/mic/lock` | rooms | `setMicSeatLock` | 锁定或解锁麦位 |
| POST | `/api/v1/rooms/chat/enabled` | rooms | `setChatEnabled` | 开启或关闭房间公屏 |
| POST | `/api/v1/rooms/password` | rooms | `setRoomPassword` | 房主设置或清空房间入房密码 |
| POST | `/api/v1/rooms/admin/set` | rooms | `setRoomAdmin` | 添加或移除房间管理员 |
| POST | `/api/v1/rooms/user/mute` | rooms | `muteUser` | 设置房间内禁言 |
| POST | `/api/v1/rooms/user/kick` | rooms | `kickUser` | 踢出房间用户 |
@ -177,6 +180,9 @@
| POST | `/api/v1/admin/resources/{resource_id}/disable` | admin | `disableResource` | disableResource |
| POST | `/api/v1/admin/resources/{resource_id}/enable` | admin | `enableResource` | enableResource |
| GET | `/api/v1/admin/rooms` | admin | `listRooms` | listRooms |
| GET | `/api/v1/admin/rooms/pins` | admin | `listRoomPins` | 查询房间区域置顶列表 |
| POST | `/api/v1/admin/rooms/pins` | admin | `createRoomPin` | 新增或恢复房间区域置顶 |
| DELETE | `/api/v1/admin/rooms/pins/{pin_id}` | admin | `cancelRoomPin` | 取消房间区域置顶 |
| DELETE | `/api/v1/admin/rooms/{room_id}` | admin | `deleteRoom` | deleteRoom |
| PATCH | `/api/v1/admin/rooms/{room_id}` | admin | `updateRoom` | updateRoom |
| GET | `/api/v1/app/users` | admin | `appListUsers` | appListUsers |
@ -292,6 +298,8 @@
| POST | `/hyapp.room.v1.RoomCommandService/KickUser` | room-command | `roomKickUser` | 踢出房间用户 |
| POST | `/hyapp.room.v1.RoomCommandService/UnbanUser` | room-command | `roomUnbanUser` | 解除房间 ban |
| POST | `/hyapp.room.v1.RoomCommandService/SendGift` | room-command | `roomSendGift` | 发送礼物 |
| POST | `/hyapp.room.v1.RoomCommandService/FollowRoom` | room-command | `roomFollowRoom` | 建立用户对房间的关注关系 |
| POST | `/hyapp.room.v1.RoomCommandService/UnfollowRoom` | room-command | `roomUnfollowRoom` | 取消用户对房间的关注关系 |
| POST | `/hyapp.room.v1.RoomGuardService/CheckSpeakPermission` | room-guard | `roomCheckSpeakPermission` | 发言前守卫 |
| POST | `/hyapp.room.v1.RoomGuardService/VerifyRoomPresence` | room-guard | `roomVerifyRoomPresence` | 入群前 presence 守卫 |
| POST | `/hyapp.room.v1.RoomQueryService/GetCurrentRoom` | room-query | `roomGetCurrentRoom` | 查询用户当前可恢复房间 |

View File

@ -110,7 +110,8 @@ sequenceDiagram
- `GET /api/v1/rooms/feeds?tab=visited` 是房间访问历史,只由 JoinRoom 成功后写入 room-service 的房间 feed 索引;不要使用 `/users/{user_id}/visit` 用户主页访问记录填充。
- `GET /api/v1/rooms/feeds?tab=following` 由 gateway 调用 `ListFollowing` 读取关注用户,再把关系用户和 `followed_at_ms` 传给 room-service 查询 active 房间卡片。
- `GET /api/v1/rooms/feeds?tab=friend` 由 gateway 调用 `ListFriends` 读取好友用户,再把关系用户和 `friended_at_ms` 传给 room-service 查询 active 房间卡片。
- 好友/关注关系事实仍归 user-serviceroom-service 只拥有房间状态和房间列表卡片,不保存好友/关注关系作为事实。
- `GET /api/v1/rooms/feeds?tab=followed` 是房间关注列表,只读取 room-service 的 `room_follows` 用户-房间关系,再 join active 房间卡片。
- 好友/用户关注关系事实仍归 user-service房间关注关系事实归 room-service不能用 user-service 的用户关注表替代。
## App Notes

View File

@ -93,7 +93,7 @@ sequenceDiagram
G->>U: GetUser(user_id)
U-->>G: region_id
G->>R: ListRooms(region_id, tab, cursor, limit)
R->>S: Read room_list_entries or Redis ZSET
R->>S: Read room_list_entries + active room_region_pins
S-->>R: RoomListItem[]
R-->>G: ListRoomsResponse
G-->>C: {code,message,request_id,data}
@ -107,6 +107,14 @@ gateway -> room-service.JoinRoom -> Tencent IM join group callback guard
因此列表允许秒级最终一致;真正能否进入房间由 `JoinRoom` 和 IM 守卫决定。
区域置顶只影响当前 `visible_region_id` 的公共发现列表排序。`room_region_pins` 保存 `app_code + visible_region_id + room_id` 的运营置顶关系;查询时只 join `status=active``expires_at_ms > now_ms` 的记录,排序优先级为:
1. 有效区域置顶在普通房间之前。
2. 置顶房间按 `weight DESC, expires_at_ms DESC, room_id ASC`
3. 普通房间继续按 `hot``sort_score DESC``new``created_at_ms DESC`
置顶不进入 Room Cell、command log 或 snapshot它是房间列表读模型的运营控制面。房间改区域后旧区域置顶不会自动跨区域生效后台需要在新区域重新建立置顶。
## Room Region Ownership
### CreateRoom
@ -194,6 +202,7 @@ CREATE TABLE IF NOT EXISTS room_list_entries (
cover_url VARCHAR(512) NOT NULL DEFAULT '',
mode VARCHAR(64) NOT NULL,
status VARCHAR(32) NOT NULL,
locked BOOLEAN NOT NULL DEFAULT FALSE,
heat BIGINT NOT NULL DEFAULT 0,
online_count INT NOT NULL DEFAULT 0,
seat_count INT NOT NULL DEFAULT 0,
@ -222,6 +231,7 @@ CREATE TABLE IF NOT EXISTS room_list_entries (
| `cover_url` | `RoomState.RoomExt["cover_url"]` | 创建房间 |
| `mode` | RoomMeta/RoomState | 创建房间或模式变更 |
| `status` | RoomState | 创建、关闭、封禁、恢复 |
| `locked` | `RoomState.RoomPasswordHash != ""` | SetRoomPassword |
| `heat` | RoomState | SendGift、热度刷新 |
| `online_count` | RoomState presence | JoinRoom、LeaveRoom、stale cleanup |
| `occupied_seat_count` | RoomState mic seats | MicUp、MicDown、ChangeMicSeat |
@ -348,6 +358,7 @@ message RoomListItem {
int64 visible_region_id = 12;
string app_code = 13;
string room_short_id = 14;
bool locked = 15;
}
message ListRoomsResponse {
@ -384,6 +395,7 @@ message ListRoomsResponse {
- 命令成功提交后调用 `RoomListProjector` 更新 MySQL 投影。
- 投影更新失败时写 `room_outbox` 或独立 `room_projection_tasks` 等待补偿。
- 列表查询只读 `room_list_entries`,不直接读取 Room Cell 内存。
- 公共列表和带 `query` 的搜索列表都从同一投影读取 `locked`;它只表达是否需要入房密码,不携带密码明文或哈希。
## Consistency Model
@ -453,7 +465,7 @@ JoinRoom(room_id, user_id)
1. 新增管理端房间区域迁移命令。
2. 新增区域迁移审计表。
3. 支持按区域隐藏、封禁或置顶房间。
3. 支持按区域隐藏、封禁或置顶房间;置顶由 `room_region_pins` 按区域独立维护
## Tests
@ -468,6 +480,7 @@ JoinRoom(room_id, user_id)
| 创建房间 | 写 `rooms.visible_region_id``room_list_entries` |
| 房主改国家 | 已创建房间列表区域不变 |
| Join/Leave | 列表在线人数最终更新 |
| 区域置顶 | 置顶房间排在同区域普通房间前,翻页不重复 |
| SendGift | 热度和排序分更新 |
| Redis miss | MySQL 回源成功 |
| 列表房间已关闭 | `JoinRoom` 拒绝,客户端刷新 |

View File

@ -251,7 +251,7 @@ return snapshot
## Tencent IM Message Contract
room-service 发给腾讯云 IM 的房间系统消息使用 `TIMCustomElem`
room-service 发给腾讯云 IM 的房间系统消息使用 `TIMCustomElem`给单个用户的私有通知不从这个 bridge 发,统一由 notice-service 读取 owner outbox 后走腾讯云 IM C2C。
客户端只依赖以下稳定字段:
@ -290,6 +290,8 @@ room-service 发给腾讯云 IM 的房间系统消息使用 `TIMCustomElem`。
| `room_user_kicked` | `RoomUserKicked` |
| `room_gift_sent` | `RoomGiftSent` |
`room_user_kicked` 还会由 notice-service 读取同一条 `RoomUserKicked` 事实,给被踢用户发送 C2C `room_notice`。room-service 仍负责把目标用户移出腾讯 IM 群notice-service 不处理建群、退群或群成员权限。
## Recovery Rules
节点重启或接管时:

View File

@ -7,7 +7,7 @@
| Area | Owner | Rule |
| --- | --- | --- |
| HTTP 入口 | `gateway-service` | 鉴权、profile gate、协议转换、腾讯 IM/RTC token 签发 |
| 房间业务状态 | `room-service` | Room Cell、presence、麦位、房管、snapshot、outbox |
| 房间业务状态 | `room-service` | Room Cell、presence、麦位、房管、snapshot、outbox、房间关注关系 |
| 房间长连接和公屏 | 腾讯云 IM SDK | 客户端登录、房间群、公屏、系统消息接收 |
| 真实音频频道 | 腾讯云 RTC SDK | 进 RTC 房、切换 audience/anchor、发音频 |
| 用户主数据 | `user-service` | 登录、资料完成状态、用户区域、push token |
@ -40,7 +40,7 @@ sequenceDiagram
R-->>G: owner room card from rooms + snapshot
G-->>C: has_room + room card + im_group_id
C->>G: GET /api/v1/rooms/feeds?tab=visited|friend|following
C->>G: GET /api/v1/rooms/feeds?tab=visited|friend|following|followed
opt tab=friend|following
G->>U: ListFriends/ListFollowing(cursor)
U-->>G: related user ids + relation times
@ -64,7 +64,14 @@ sequenceDiagram
G-->>C: optional active-room resync
C->>G: GET /api/v1/rooms/{room_id}/detail
G-->>C: render-ready room detail package
G-->>C: render-ready room detail package + is_followed
opt user toggles room follow
C->>G: POST|DELETE /api/v1/rooms/{room_id}/follow
G->>R: FollowRoom|UnfollowRoom(user_id from token)
R-->>G: current follow state
G-->>C: is_followed
end
loop while room page is active
C->>G: POST /api/v1/rooms/heartbeat
@ -78,32 +85,48 @@ sequenceDiagram
| 1 | `GET /api/v1/im/usersig` | yes | 用户级 IM 登录票据,可在 App 启动后预取并按 TTL 复用 |
| 2 | Tencent IM SDK `login` | yes | 客户端先建立 IM 身份,房间群仍等 JoinRoom 成功后再进 |
| 3 | `GET /api/v1/rooms/me` | yes for Mine top card | 直接读取 owner 房间权威元数据,不依赖发现页投影 |
| 4 | `GET /api/v1/rooms/feeds?tab=visited\|friend\|following` | yes for Mine lists | Mine 列表 tab`visited` 由 JoinRoom 成功写入,`friend/following` 由 gateway 先查 user-service 关系,再让 room-service 返回这些关系用户的 active 房间卡片 |
| 4 | `GET /api/v1/rooms/feeds?tab=visited\|friend\|following\|followed` | yes for Mine lists | Mine 列表 tab`visited` 由 JoinRoom 成功写入,`friend/following` 由 gateway 先查 user-service 关系,`followed` 直接读取 room-service 的房间关注关系 |
| 5 | `POST /api/v1/rooms/join` | yes | 建立业务 presence并返回首屏房间数据和内嵌 RTC token |
| 6 | Tencent IM SDK `joinGroup(im_group_id)` | yes | 必须在 JoinRoom 成功后执行IM 回调守卫会校验 room-service presence |
| 7 | Tencent RTC SDK `enterRoom` | yes for voice room audio | 使用 JoinRoom 响应里的 `rtc.token` 进入真实音频频道旁听 |
| 8 | `POST /api/v1/rooms/heartbeat` | yes | 刷新业务 presence避免 stale timeout |
| 9 | `GET /api/v1/rooms/{room_id}/detail` | optional after join/restore | 详情页聚合包;用于 JoinRoom 后补偿刷新、回前台、设计稿首屏重绘 |
| 10 | `GET /api/v1/rooms/{room_id}/gift-panel` | lazy on gift button | 打开礼物面板时再拉,不放进房间首屏 |
| 11 | `GET /api/v1/rooms/{room_id}/online-users` | lazy on online count | 点击右上角在线人数时分页拉取 |
| 9 | `GET /api/v1/rooms/{room_id}/detail` | optional after join/restore | 详情页聚合包;返回 `is_followed`,用于关注按钮初始状态 |
| 10 | `POST /api/v1/rooms/{room_id}/follow` / `DELETE /api/v1/rooms/{room_id}/follow` | user action | 建立或取消当前用户对房间的关注关系gateway 只使用 token user_id |
| 11 | `GET /api/v1/rooms/{room_id}/gift-panel` | lazy on gift button | 打开礼物面板时再拉,不放进房间首屏 |
| 12 | `GET /api/v1/rooms/{room_id}/online-users` | lazy on online count | 点击右上角在线人数时分页拉取 |
如果 JoinRoom 响应里的 `rtc.available=false`,客户端可以先展示房间页并重试 `POST /api/v1/rtc/token`RTC 失败不能回滚已经成功的业务 presence。
### Mine Feed Cache Boundary
当前 `friend/following` 不强制加 Redis。原因是关注、取关、同意好友和删除好友都要求列表立即反映关系事实首版直接读 user-service MySQL 关系表,再查 room-service active 房间卡片,正确性更直接。
当前 `friend/following/followed` 不强制加 Redis。原因是关注、取关、同意好友和删除好友都要求列表立即反映关系事实`friend/following` 直接读 user-service MySQL 关系表,`followed` 直接读 room-service 的 `room_follows`,再查 active 房间卡片,正确性更直接。
后续可以加 Redis 的位置:
- `hot/new` 公共列表room-service 用 Redis ZSET 缓存排序 room_idmiss 后回源 `room_list_entries`
- 房间卡片短 TTL 缓存key 可按 `app_code + region_id + room_id`,但 MySQL `room_list_entries` 仍是事实来源。
- `friend/following` 关系 ID 短 TTL 缓存:只能放在 gateway 或 user-service 读优化层,`follow/unfollow/accept/delete` 成功后必须主动失效。
- `followed` 房间 ID 短 TTL 缓存:只能放在 room-service 读优化层,`FollowRoom/UnfollowRoom` 成功后必须主动失效。
- `visited` room_id 列表缓存:可以由 JoinRoom 成功后刷新Redis 写失败不能影响 JoinRoom。
不建议首版缓存 `friend/following` 完整房间卡片列表。这个列表同时依赖 user-service 关系事实、room-service 房间 active 状态、区域过滤和搜索条件,失效面较大;先保持同步查询,等 QPS 或延迟数据证明需要缓存再加。
不建议首版缓存 `friend/following/followed` 完整房间卡片列表。这个列表同时依赖关系事实、room-service 房间 active 状态、区域过滤和搜索条件,失效面较大;先保持同步查询,等 QPS 或延迟数据证明需要缓存再加。
## Core Interfaces
### Room List Cards
```http
GET /api/v1/rooms?tab=hot&query=100001
GET /api/v1/rooms/feeds?tab=followed
Authorization: Bearer <access_token>
```
Rules:
- 公共发现列表、房间搜索和 Mine 页房间流都返回同一套房间卡片字段。
- `locked=true` 表示 JoinRoom 需要 `password`;列表和搜索只返回锁房标识,不返回明文或哈希。
- `im_group_id` 只代表腾讯 IM 目标群,不能绕过 JoinRoom 的锁房、ban 和 presence 校验。
### Join Room
```http
@ -118,7 +141,8 @@ Request:
{
"room_id": "lalu_xxx",
"command_id": "cmd_join_<room_id>_<user_id>_<nonce>",
"role": "audience"
"role": "audience",
"password": "1234"
}
```
@ -143,6 +167,7 @@ Response `data`:
"mode": "voice",
"status": "active",
"chat_enabled": true,
"locked": true,
"heat": 1000,
"online_count": 26,
"seat_count": 15,
@ -209,6 +234,8 @@ Rules:
- `room_id` 必须来自房间列表、分享或恢复接口,不能由客户端拼业务含义。
- `command_id` 由客户端按用户动作生成,用于房间命令幂等;重试同一次进房必须复用同一个值。
- `role` 只作为展示默认值,不能通过自报 `admin/host/owner` 获得权限。
- 带锁房间的新进入用户必须传正确 `password`;密码错误返回 403不创建 presence不签发 RTC token。
- 房主不需要密码进入自己的房间;已在房内的用户不会因为房主重新设置密码被挤出。
- `im.group_id` 当前等于 `room_id`,但客户端必须使用服务端返回值,不能硬编码映射规则。
- JoinRoom 成功后才能调用腾讯 IM SDK `joinGroup`;列表里的 `im_group_id` 只用于准备,不代表入群授权。
- JoinRoom 会尝试内嵌签发 RTC token签发失败时返回 `rtc.available=false`,客户端可以继续展示房间并单独重试 `/api/v1/rtc/token`
@ -229,6 +256,7 @@ Response `data`:
"seats": [],
"rank_top": [],
"online_count": 30,
"is_followed": true,
"permissions": {},
"profiles": [],
"im": {},
@ -240,9 +268,35 @@ Rules:
- `detail` 只读 Room Cell/snapshot不刷新 heartbeat不隐式进房。
- viewer 必须仍在 room-service presence 内;未进房、被踢或被 ban 返回 `PERMISSION_DENIED`
- `is_followed` 来自 room-service 的 `room_follows` 关系表,只表达当前 token 用户是否关注该房间,不写入 Room Cell 快照。
- `permissions.can_close_room` 表示设计稿右上角电源按钮可用,但当前语义是“退出房间”,不是关闭房间生命周期。
- `profiles` 只返回首屏需要的 owner、host、viewer、麦位用户和榜单 top 用户。完整在线用户资料走在线用户分页或 `users/room-display-profiles:batch`
### Follow Room
```http
POST /api/v1/rooms/{room_id}/follow
DELETE /api/v1/rooms/{room_id}/follow
Authorization: Bearer <access_token>
```
Response `data`:
```json
{
"room_id": "lalu_xxx",
"is_followed": true,
"followed_at_ms": 1700000000123,
"server_time_ms": 1700000000123
}
```
Rules:
- POST 重复关注保持幂等;已 active 的关注不会刷新 `followed_at_ms`,避免列表排序被重复点击打乱。
- DELETE 未关注或历史关系已取消时按幂等成功返回 `is_followed=false`
- 关注列表走 `GET /api/v1/rooms/feeds?tab=followed`,只返回当前仍 active 且命中 viewer 区域桶的房间卡片。
### Online Users
```http
@ -638,6 +692,7 @@ Rules:
| `POST /api/v1/rooms/mic/lock` | `room_id, command_id, seat_no, locked` | 锁/解锁麦位 |
| `POST /api/v1/rooms/mic/mute` | `room_id, command_id, target_user_id, muted` | 修改麦克风静音状态;该状态会同步给房间其他用户 |
| `POST /api/v1/rooms/chat/enabled` | `room_id, command_id, enabled` | 开/关公屏,影响发言守卫 |
| `POST /api/v1/rooms/password` | `room_id, command_id, locked, password` | 房主设置或清空入房密码;成功后房间快照、列表和搜索的 `locked` 同步刷新 |
| `POST /api/v1/rooms/admin/set` | `room_id, command_id, target_user_id, is_admin` | 设置或取消房管 |
| `POST /api/v1/rooms/host/transfer` | `room_id, command_id, target_user_id` | 转移主持人 |
| `POST /api/v1/rooms/user/mute` | `room_id, command_id, target_user_id, muted, reason` | 禁言或解禁 |
@ -648,7 +703,10 @@ Rules:
Management event handling:
- 管理动作成功后room-service 写 command log、snapshot 和 outbox。
- 客户端应通过腾讯云 IM 房间系统消息更新 UI。
- 踢人成功后 room-service 会移除业务 presence、释放麦位、写入房间 ban并通过 room-service 房间 IM bridge 补偿移出腾讯 IM 房间群。
- 被踢本人私有通知由 notice-service 监听 `room_outbox.RoomUserKicked` 后发送 C2C `room_notice`notice-service 不负责建群、踢群成员或房间群系统消息。
- 踢人成功后 room-service 会尽力调用 TRTC `RemoveUserByStrRoomId` 移出 RTC 房间;失败不回滚踢人事实,客户端仍必须按 IM/RTC 回调和后续 403 兜底清理。
- 客户端应通过腾讯云 IM 房间系统消息、notice-service C2C 私有通知、IM 群成员移除回调和 RTC 退房回调更新 UI。
- 如果本地 UI 状态和系统消息冲突,以最新 room snapshot 和 `room_version` 为准。
## Gift And Interaction

View File

@ -244,6 +244,9 @@
| `room:view` | `menu` | 房间列表、详情、房间状态 |
| `room:update` | `button` | 后台修改房间展示资料、模式、状态、区域 |
| `room:delete` | `button` | 后台删除房间 |
| `room-pin:view` | `menu` | 房间置顶菜单、置顶列表 |
| `room-pin:create` | `button` | 新增或恢复区域房间置顶 |
| `room-pin:cancel` | `button` | 取消区域房间置顶 |
| `room-config:view` | `menu` | 房间配置页面、读取座位数配置 |
| `room-config:update` | `button` | 保存启用座位数和默认座位数 |
| `room:create` | `button` | 后台创建房间 |

View File

@ -34,6 +34,48 @@ func (h *Handler) ListRooms(c *gin.Context) {
response.OK(c, response.Page{Items: items, Page: query.Page, PageSize: query.PageSize, Total: total})
}
func (h *Handler) ListRoomPins(c *gin.Context) {
query, ok := parseRoomPinListQuery(c)
if !ok {
return
}
items, total, err := h.service.ListRoomPins(c.Request.Context(), query)
if err != nil {
response.ServerError(c, "获取房间置顶列表失败")
return
}
response.OK(c, response.Page{Items: items, Page: query.Page, PageSize: query.PageSize, Total: total})
}
func (h *Handler) CreateRoomPin(c *gin.Context) {
var req createRoomPinRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "房间置顶参数不正确")
return
}
pin, err := h.service.CreateRoomPin(c.Request.Context(), req, shared.ActorFromContext(c).UserID)
if err != nil {
writeMutationError(c, err, "新增房间置顶失败")
return
}
writeRoomAuditLog(c, h.audit, "create-room-pin", "room_region_pins", pin.Room.RoomID, "success", "create regional room pin")
response.Created(c, pin)
}
func (h *Handler) CancelRoomPin(c *gin.Context) {
pinID, ok := parseRoomPinID(c)
if !ok {
return
}
pin, err := h.service.CancelRoomPin(c.Request.Context(), pinID, shared.ActorFromContext(c).UserID)
if err != nil {
writeMutationError(c, err, "取消房间置顶失败")
return
}
writeRoomAuditLog(c, h.audit, "cancel-room-pin", "room_region_pins", strconv.FormatInt(pinID, 10), "success", "cancel regional room pin")
response.OK(c, pin)
}
func (h *Handler) GetRoomConfig(c *gin.Context) {
config, err := h.service.GetRoomConfig(c.Request.Context())
if err != nil {
@ -108,6 +150,21 @@ func parseListQuery(c *gin.Context) (listQuery, bool) {
return normalizeListQuery(query), true
}
func parseRoomPinListQuery(c *gin.Context) (roomPinListQuery, bool) {
options := shared.ListOptions(c)
regionID, ok := parseOptionalInt64Query(c, "regionId", "region_id")
if !ok {
return roomPinListQuery{}, false
}
return normalizeRoomPinListQuery(roomPinListQuery{
Keyword: options.Keyword,
Page: options.Page,
PageSize: options.PageSize,
RegionID: regionID,
Status: options.Status,
}), true
}
func firstQueryValue(c *gin.Context, names ...string) string {
for _, name := range names {
if value := strings.TrimSpace(c.Query(name)); value != "" {
@ -142,6 +199,16 @@ func parseRoomID(c *gin.Context) (string, bool) {
return roomID, true
}
func parseRoomPinID(c *gin.Context) (int64, bool) {
raw := strings.TrimSpace(c.Param("pin_id"))
id, err := strconv.ParseInt(raw, 10, 64)
if err != nil || id <= 0 {
response.BadRequest(c, "置顶 ID 参数不正确")
return 0, false
}
return id, true
}
func writeMutationError(c *gin.Context, err error, fallback string) {
if errors.Is(err, ErrNotFound) {
response.NotFound(c, "房间不存在")

View File

@ -0,0 +1,409 @@
package roomadmin
import (
"context"
"database/sql"
"errors"
"fmt"
"strconv"
"strings"
"time"
"hyapp-admin-server/internal/appctx"
)
const (
roomPinStatusActive = "active"
roomPinStatusCancelled = "cancelled"
roomPinStatusExpired = "expired"
roomPinStatusAll = "all"
defaultRoomPinDays = int64(30)
maxRoomPinDays = int64(3650)
roomPinDayMillis = int64(24 * 60 * 60 * 1000)
)
type RoomPinRoom struct {
CoverURL string `json:"coverUrl"`
RegionName string `json:"regionName"`
RoomID string `json:"roomId"`
RoomShortID string `json:"roomShortId"`
Status string `json:"status"`
Title string `json:"title"`
VisibleRegionID int64 `json:"visibleRegionId"`
}
type RoomPin struct {
CancelledAtMS int64 `json:"cancelledAtMs"`
CancelledByAdminID uint `json:"cancelledByAdminId"`
CreatedAtMS int64 `json:"createdAtMs"`
CreatedByAdminID uint `json:"createdByAdminId"`
ExpiresAtMS int64 `json:"expiresAtMs"`
ID int64 `json:"id"`
PinnedAtMS int64 `json:"pinnedAtMs"`
RemainingMS int64 `json:"remainingMs"`
Room RoomPinRoom `json:"room"`
Status string `json:"status"`
UpdatedAtMS int64 `json:"updatedAtMs"`
User RoomOwner `json:"user"`
Weight int64 `json:"weight"`
}
func (s *Service) ListRoomPins(ctx context.Context, query roomPinListQuery) ([]RoomPin, int64, error) {
if s.db == nil {
return nil, 0, fmt.Errorf("room mysql is not configured")
}
query = normalizeRoomPinListQuery(query)
whereSQL, args := roomPinListWhere(ctx, query, time.Now().UTC().UnixMilli())
total, err := countRows(ctx, s.db, whereSQL, args...)
if err != nil {
return nil, 0, err
}
rows, err := s.db.QueryContext(ctx, `
SELECT p.id,
p.visible_region_id,
p.room_id,
p.weight,
p.status,
p.pinned_at_ms,
p.expires_at_ms,
p.cancelled_at_ms,
p.created_by_admin_id,
p.cancelled_by_admin_id,
p.created_at_ms,
p.updated_at_ms,
r.room_short_id,
r.title,
r.cover_url,
r.owner_user_id,
r.status
`+whereSQL+`
ORDER BY p.weight DESC, p.expires_at_ms DESC, p.id DESC
LIMIT ? OFFSET ?
`, append(args, query.PageSize, offset(query.Page, query.PageSize))...)
if err != nil {
return nil, 0, err
}
defer rows.Close()
items := make([]RoomPin, 0, query.PageSize)
for rows.Next() {
item, err := scanRoomPin(rows)
if err != nil {
return nil, 0, err
}
items = append(items, item)
}
if err := rows.Err(); err != nil {
return nil, 0, err
}
if err := s.fillRoomPinDetails(ctx, items); err != nil {
return nil, 0, err
}
return items, total, nil
}
func (s *Service) CreateRoomPin(ctx context.Context, req createRoomPinRequest, actorID uint) (RoomPin, error) {
if s.db == nil {
return RoomPin{}, fmt.Errorf("room mysql is not configured")
}
input, err := normalizeCreateRoomPinRequest(req)
if err != nil {
return RoomPin{}, err
}
room, _, err := s.getRoomForPin(ctx, input.RoomID)
if err != nil {
return RoomPin{}, err
}
if room.Status != "active" {
return RoomPin{}, fmt.Errorf("%w: 只能置顶正常状态房间", ErrInvalidArgument)
}
nowMS := time.Now().UTC().UnixMilli()
expiresAtMS := nowMS + input.DurationDays*roomPinDayMillis
appCode := appctx.FromContext(ctx)
if _, err := s.db.ExecContext(ctx, `
INSERT INTO room_region_pins (
app_code, visible_region_id, room_id, weight, status, pinned_at_ms, expires_at_ms,
cancelled_at_ms, created_by_admin_id, cancelled_by_admin_id, created_at_ms, updated_at_ms
) VALUES (?, ?, ?, ?, ?, ?, ?, 0, ?, 0, ?, ?)
ON DUPLICATE KEY UPDATE
weight = VALUES(weight),
status = VALUES(status),
pinned_at_ms = VALUES(pinned_at_ms),
expires_at_ms = VALUES(expires_at_ms),
cancelled_at_ms = 0,
created_by_admin_id = VALUES(created_by_admin_id),
cancelled_by_admin_id = 0,
created_at_ms = VALUES(created_at_ms),
updated_at_ms = VALUES(updated_at_ms)
`, appCode, room.VisibleRegionID, room.RoomID, input.Weight, roomPinStatusActive, nowMS, expiresAtMS, actorID, nowMS, nowMS); err != nil {
return RoomPin{}, err
}
pin, err := s.getRoomPinByRoom(ctx, room.VisibleRegionID, room.RoomID)
if err != nil {
return RoomPin{}, err
}
return pin, nil
}
func (s *Service) CancelRoomPin(ctx context.Context, pinID int64, actorID uint) (RoomPin, error) {
if s.db == nil {
return RoomPin{}, fmt.Errorf("room mysql is not configured")
}
if pinID <= 0 {
return RoomPin{}, fmt.Errorf("%w: 置顶 ID 不正确", ErrInvalidArgument)
}
nowMS := time.Now().UTC().UnixMilli()
appCode := appctx.FromContext(ctx)
result, err := s.db.ExecContext(ctx, `
UPDATE room_region_pins
SET status = ?, cancelled_at_ms = ?, cancelled_by_admin_id = ?, updated_at_ms = ?
WHERE app_code = ? AND id = ? AND status <> ?
`, roomPinStatusCancelled, nowMS, actorID, nowMS, appCode, pinID, roomPinStatusCancelled)
if err != nil {
return RoomPin{}, err
}
if affected, err := result.RowsAffected(); err == nil && affected == 0 {
if _, err := s.getRoomPinByID(ctx, pinID); errors.Is(err, ErrNotFound) {
return RoomPin{}, ErrNotFound
}
}
return s.getRoomPinByID(ctx, pinID)
}
func (s *Service) getRoomForPin(ctx context.Context, roomID string) (RoomPinRoom, int64, error) {
appCode := appctx.FromContext(ctx)
row := s.db.QueryRowContext(ctx, `
SELECT room_id,
room_short_id,
visible_region_id,
owner_user_id,
title,
cover_url,
status
FROM room_list_entries
WHERE app_code = ? AND (room_id = ? OR room_short_id = ?)
LIMIT 1
`, appCode, roomID, roomID)
var room RoomPinRoom
var ownerUserID int64
if err := row.Scan(&room.RoomID, &room.RoomShortID, &room.VisibleRegionID, &ownerUserID, &room.Title, &room.CoverURL, &room.Status); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return RoomPinRoom{}, 0, ErrNotFound
}
return RoomPinRoom{}, 0, err
}
if room.Status != "active" {
room.Status = "closed"
}
return room, ownerUserID, nil
}
func (s *Service) getRoomPinByRoom(ctx context.Context, regionID int64, roomID string) (RoomPin, error) {
return s.getRoomPin(ctx, `
WHERE p.app_code = ? AND p.visible_region_id = ? AND p.room_id = ?
`, appctx.FromContext(ctx), regionID, roomID)
}
func (s *Service) getRoomPinByID(ctx context.Context, pinID int64) (RoomPin, error) {
return s.getRoomPin(ctx, `
WHERE p.app_code = ? AND p.id = ?
`, appctx.FromContext(ctx), pinID)
}
func (s *Service) getRoomPin(ctx context.Context, whereSQL string, args ...any) (RoomPin, error) {
row := s.db.QueryRowContext(ctx, `
SELECT p.id,
p.visible_region_id,
p.room_id,
p.weight,
p.status,
p.pinned_at_ms,
p.expires_at_ms,
p.cancelled_at_ms,
p.created_by_admin_id,
p.cancelled_by_admin_id,
p.created_at_ms,
p.updated_at_ms,
r.room_short_id,
r.title,
r.cover_url,
r.owner_user_id,
r.status
FROM room_region_pins p
JOIN room_list_entries r ON r.app_code = p.app_code AND r.room_id = p.room_id
`+whereSQL+`
LIMIT 1
`, args...)
item, err := scanRoomPin(row)
if errors.Is(err, sql.ErrNoRows) {
return RoomPin{}, ErrNotFound
}
if err != nil {
return RoomPin{}, err
}
items := []RoomPin{item}
if err := s.fillRoomPinDetails(ctx, items); err != nil {
return RoomPin{}, err
}
return items[0], nil
}
func roomPinListWhere(ctx context.Context, query roomPinListQuery, nowMS int64) (string, []any) {
where := []string{"p.app_code = ?"}
args := []any{appctx.FromContext(ctx)}
switch query.Status {
case roomPinStatusActive:
where = append(where, "p.status = ?", "p.expires_at_ms > ?")
args = append(args, roomPinStatusActive, nowMS)
case roomPinStatusExpired:
where = append(where, "p.status = ?", "p.expires_at_ms <= ?")
args = append(args, roomPinStatusActive, nowMS)
case roomPinStatusCancelled:
where = append(where, "p.status = ?")
args = append(args, roomPinStatusCancelled)
}
if query.RegionID > 0 {
where = append(where, "p.visible_region_id = ?")
args = append(args, query.RegionID)
}
if query.Keyword != "" {
like := "%" + query.Keyword + "%"
where = append(where, "(p.room_id LIKE ? OR r.room_short_id LIKE ? OR r.title LIKE ? OR CAST(r.owner_user_id AS CHAR) LIKE ?)")
args = append(args, like, like, like, like)
}
return `
FROM room_region_pins p
JOIN room_list_entries r ON r.app_code = p.app_code AND r.room_id = p.room_id
WHERE ` + strings.Join(where, " AND "), args
}
func normalizeRoomPinListQuery(query roomPinListQuery) roomPinListQuery {
if query.Page < 1 {
query.Page = 1
}
if query.PageSize < 1 {
query.PageSize = 20
}
if query.PageSize > 100 {
query.PageSize = 100
}
query.Keyword = strings.TrimSpace(query.Keyword)
query.Status = normalizeRoomPinStatus(query.Status)
if query.RegionID < 0 {
query.RegionID = 0
}
return query
}
func normalizeRoomPinStatus(status string) string {
switch strings.ToLower(strings.TrimSpace(status)) {
case "", roomPinStatusActive:
return roomPinStatusActive
case roomPinStatusExpired:
return roomPinStatusExpired
case roomPinStatusCancelled:
return roomPinStatusCancelled
case roomPinStatusAll:
return roomPinStatusAll
default:
return roomPinStatusActive
}
}
func normalizeCreateRoomPinRequest(req createRoomPinRequest) (createRoomPinRequest, error) {
req.RoomID = strings.TrimSpace(req.RoomID)
if req.RoomID == "" || len(req.RoomID) > 64 {
return req, fmt.Errorf("%w: 请选择房间", ErrInvalidArgument)
}
if req.Weight < 0 {
return req, fmt.Errorf("%w: 权重不能小于 0", ErrInvalidArgument)
}
if req.DurationDays == 0 {
req.DurationDays = defaultRoomPinDays
}
if req.DurationDays < 0 || req.DurationDays > maxRoomPinDays {
return req, fmt.Errorf("%w: 置顶时间必须在 1 到 3650 天之间", ErrInvalidArgument)
}
return req, nil
}
type roomPinScanner interface {
Scan(dest ...any) error
}
func scanRoomPin(row roomPinScanner) (RoomPin, error) {
var item RoomPin
var ownerUserID int64
err := row.Scan(
&item.ID,
&item.Room.VisibleRegionID,
&item.Room.RoomID,
&item.Weight,
&item.Status,
&item.PinnedAtMS,
&item.ExpiresAtMS,
&item.CancelledAtMS,
&item.CreatedByAdminID,
&item.CancelledByAdminID,
&item.CreatedAtMS,
&item.UpdatedAtMS,
&item.Room.RoomShortID,
&item.Room.Title,
&item.Room.CoverURL,
&ownerUserID,
&item.Room.Status,
)
item.User = RoomOwner{UserID: strconv.FormatInt(ownerUserID, 10)}
item.RemainingMS = item.ExpiresAtMS - time.Now().UTC().UnixMilli()
if item.RemainingMS < 0 {
item.RemainingMS = 0
}
if item.Room.Status != "active" {
item.Room.Status = "closed"
}
return item, err
}
func (s *Service) fillRoomPinDetails(ctx context.Context, items []RoomPin) error {
if s.userDB == nil || len(items) == 0 {
return nil
}
ownerIDs := make([]int64, 0, len(items))
regionIDs := make([]int64, 0, len(items))
for _, item := range items {
if ownerID, err := strconv.ParseInt(item.User.UserID, 10, 64); err == nil && ownerID > 0 {
ownerIDs = append(ownerIDs, ownerID)
}
if item.Room.VisibleRegionID > 0 {
regionIDs = append(regionIDs, item.Room.VisibleRegionID)
}
}
owners, err := s.queryRoomOwners(ctx, uniqueInt64s(ownerIDs))
if err != nil {
return err
}
regions, err := s.queryRegionNames(ctx, uniqueInt64s(regionIDs))
if err != nil {
return err
}
for index := range items {
if ownerID, err := strconv.ParseInt(items[index].User.UserID, 10, 64); err == nil {
if owner, ok := owners[ownerID]; ok {
items[index].User = owner
}
}
if name, ok := regions[items[index].Room.VisibleRegionID]; ok {
items[index].Room.RegionName = name
}
}
return nil
}

View File

@ -0,0 +1,46 @@
package roomadmin
import (
"errors"
"testing"
)
func TestNormalizeRoomPinListQueryDefaultsToActive(t *testing.T) {
query := normalizeRoomPinListQuery(roomPinListQuery{Page: -1, PageSize: 200, Status: "unknown", RegionID: -10, Keyword: " 123 "})
if query.Page != 1 || query.PageSize != 100 {
t.Fatalf("pagination normalization mismatch: %+v", query)
}
if query.Status != roomPinStatusActive || query.RegionID != 0 || query.Keyword != "123" {
t.Fatalf("filter normalization mismatch: %+v", query)
}
}
func TestNormalizeCreateRoomPinRequestDefaultsDuration(t *testing.T) {
req, err := normalizeCreateRoomPinRequest(createRoomPinRequest{RoomID: " room-1 ", Weight: 10})
if err != nil {
t.Fatalf("normalize create pin failed: %v", err)
}
if req.RoomID != "room-1" || req.DurationDays != defaultRoomPinDays || req.Weight != 10 {
t.Fatalf("create pin normalization mismatch: %+v", req)
}
}
func TestNormalizeCreateRoomPinRequestRejectsInvalidInput(t *testing.T) {
tests := []struct {
name string
req createRoomPinRequest
}{
{name: "empty_room", req: createRoomPinRequest{Weight: 1, DurationDays: 30}},
{name: "negative_weight", req: createRoomPinRequest{RoomID: "room-1", Weight: -1, DurationDays: 30}},
{name: "duration_too_large", req: createRoomPinRequest{RoomID: "room-1", Weight: 1, DurationDays: maxRoomPinDays + 1}},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
_, err := normalizeCreateRoomPinRequest(test.req)
if !errors.Is(err, ErrInvalidArgument) {
t.Fatalf("expected ErrInvalidArgument, got %v", err)
}
})
}
}

View File

@ -10,6 +10,20 @@ type listQuery struct {
SortDirection string
}
type roomPinListQuery struct {
Keyword string
Page int
PageSize int
RegionID int64
Status string
}
type createRoomPinRequest struct {
DurationDays int64 `json:"durationDays"`
RoomID string `json:"roomId"`
Weight int64 `json:"weight"`
}
type updateRoomRequest struct {
CoverURL *string `json:"coverUrl"`
Description *string `json:"description"`

View File

@ -12,6 +12,9 @@ func RegisterRoutes(protected *gin.RouterGroup, h *Handler) {
}
protected.GET("/admin/rooms", middleware.RequirePermission("room:view"), h.ListRooms)
protected.GET("/admin/rooms/pins", middleware.RequirePermission("room-pin:view"), h.ListRoomPins)
protected.POST("/admin/rooms/pins", middleware.RequirePermission("room-pin:create"), h.CreateRoomPin)
protected.DELETE("/admin/rooms/pins/:pin_id", middleware.RequirePermission("room-pin:cancel"), h.CancelRoomPin)
protected.GET("/admin/rooms/config", middleware.RequirePermission("room-config:view"), h.GetRoomConfig)
protected.PUT("/admin/rooms/config", middleware.RequirePermission("room-config:update"), h.UpdateRoomConfig)
protected.PATCH("/admin/rooms/:room_id", middleware.RequirePermission("room:update"), h.UpdateRoom)

View File

@ -42,6 +42,7 @@ type Room struct {
OwnerUserID string `json:"ownerUserId"`
RegionName string `json:"regionName"`
RoomID string `json:"roomId"`
RoomShortID string `json:"roomShortId"`
RoomContribution int64 `json:"roomContribution"`
SeatCount int32 `json:"seatCount"`
SortScore int64 `json:"sortScore"`
@ -76,8 +77,8 @@ func (s *Service) ListRooms(ctx context.Context, query listQuery) ([]Room, int64
}
if query.Keyword != "" {
like := "%" + query.Keyword + "%"
whereSQL += " AND (rle.room_id LIKE ? OR rle.title LIKE ? OR CAST(rle.owner_user_id AS CHAR) LIKE ?)"
args = append(args, like, like, like)
whereSQL += " AND (rle.room_id LIKE ? OR rle.room_short_id LIKE ? OR rle.title LIKE ? OR CAST(rle.owner_user_id AS CHAR) LIKE ?)"
args = append(args, like, like, like, like)
}
total, err := countRows(ctx, s.db, whereSQL, args...)
if err != nil {
@ -85,6 +86,7 @@ func (s *Service) ListRooms(ctx context.Context, query listQuery) ([]Room, int64
}
rows, err := s.db.QueryContext(ctx, fmt.Sprintf(`
SELECT rle.room_id,
rle.room_short_id,
rle.visible_region_id,
rle.owner_user_id,
rle.title,
@ -131,6 +133,7 @@ func (s *Service) GetRoom(ctx context.Context, roomID string) (Room, error) {
appCode := appctx.FromContext(ctx)
row := s.db.QueryRowContext(ctx, `
SELECT room_id,
room_short_id,
visible_region_id,
owner_user_id,
title,
@ -389,6 +392,7 @@ func scanRoom(row roomScanner) (Room, error) {
var ownerUserID int64
err := row.Scan(
&item.RoomID,
&item.RoomShortID,
&item.VisibleRegionID,
&ownerUserID,
&item.Title,

View File

@ -23,6 +23,9 @@ var defaultPermissions = []model.Permission{
{Name: "房间查看", Code: "room:view", Kind: "menu"},
{Name: "房间更新", Code: "room:update", Kind: "button"},
{Name: "房间删除", Code: "room:delete", Kind: "button"},
{Name: "房间置顶查看", Code: "room-pin:view", Kind: "menu"},
{Name: "房间置顶创建", Code: "room-pin:create", Kind: "button"},
{Name: "房间置顶取消", Code: "room-pin:cancel", Kind: "button"},
{Name: "房间配置查看", Code: "room-config:view", Kind: "menu"},
{Name: "房间配置更新", Code: "room-config:update", Kind: "button"},
{Name: "APP 配置查看", Code: "app-config:view", Kind: "menu"},
@ -192,7 +195,8 @@ func (s *Store) seedMenus() error {
{ParentID: &appUsersID, Title: "封禁列表", Code: "app-user-bans", Path: "/app/users/bans", Icon: "block", PermissionCode: "app-user:view", Sort: 61, Visible: true},
{ParentID: &appUsersID, Title: "登录日志", Code: "app-user-login-logs", Path: "/app/users/login-logs", Icon: "login", PermissionCode: "app-user:view", Sort: 62, Visible: true},
{ParentID: &roomsID, Title: "房间列表", Code: "room-list", Path: "/rooms", Icon: "room", PermissionCode: "room:view", Sort: 65, Visible: true},
{ParentID: &roomsID, Title: "房间配置", Code: "room-config", Path: "/rooms/config", Icon: "settings", PermissionCode: "room-config:view", Sort: 66, Visible: true},
{ParentID: &roomsID, Title: "房间置顶", Code: "room-pins", Path: "/rooms/pins", Icon: "push_pin", PermissionCode: "room-pin:view", Sort: 66, Visible: true},
{ParentID: &roomsID, Title: "房间配置", Code: "room-config", Path: "/rooms/config", Icon: "settings", PermissionCode: "room-config:view", Sort: 67, Visible: true},
{ParentID: &appConfigID, Title: "H5配置", Code: "app-config-h5", Path: "/app-config/h5", Icon: "settings", PermissionCode: "app-config:view", Sort: 66, Visible: true},
{ParentID: &appConfigID, Title: "BANNER配置", Code: "app-config-banners", Path: "/app-config/banners", Icon: "image", PermissionCode: "app-config:view", Sort: 67, Visible: true},
{ParentID: &appConfigID, Title: "内购配置", Code: "payment-recharge-products", Path: "/app-config/recharge-products", Icon: "wallet", PermissionCode: "payment-product:view", Sort: 68, Visible: true},
@ -423,7 +427,7 @@ func defaultRolePermissionCodes(code string) []string {
"overview:view",
"user:view", "user:status",
"app-user:view", "app-user:update", "app-user:status", "app-user:password",
"room:view", "room:update", "room:delete", "room-config:view", "room-config:update",
"room:view", "room:update", "room:delete", "room-pin:view", "room-pin:create", "room-pin:cancel", "room-config:view", "room-config:update",
"app-config:view", "app-config:update",
"app-version:view", "app-version:create", "app-version:update", "app-version:delete",
"resource:view", "resource:create", "resource:update",
@ -444,13 +448,14 @@ func defaultRolePermissionCodes(code string) []string {
"upload:create",
}
case "auditor":
return []string{"overview:view", "log:view", "user:view", "app-user:view", "room:view", "room-config:view", "app-config:view", "app-version:view", "resource:view", "resource-group:view", "resource-grant:view", "gift:view", "coin-ledger:view", "payment-bill:view", "payment-product:view", "game:view", "daily-task:view", "role:view", "permission:view", "job:view"}
return []string{"overview:view", "log:view", "user:view", "app-user:view", "room:view", "room-pin:view", "room-config:view", "app-config:view", "app-version:view", "resource:view", "resource-group:view", "resource-grant:view", "gift:view", "coin-ledger:view", "payment-bill:view", "payment-product:view", "game:view", "daily-task:view", "role:view", "permission:view", "job:view"}
case "readonly":
return []string{
"overview:view",
"user:view",
"app-user:view",
"room:view",
"room-pin:view",
"room-config:view",
"app-config:view",
"app-version:view",
@ -485,7 +490,7 @@ func defaultRolePermissionMigrationCodes(code string) []string {
case "ops-admin":
return []string{
"app-user:update", "app-user:status", "app-user:password",
"room:view", "room:update", "room:delete", "room-config:view", "room-config:update",
"room:view", "room:update", "room:delete", "room-pin:view", "room-pin:create", "room-pin:cancel", "room-config:view", "room-config:update",
"app-config:view", "app-config:update",
"app-version:view", "app-version:create", "app-version:update", "app-version:delete",
"resource:view", "resource:create", "resource:update",
@ -501,7 +506,7 @@ func defaultRolePermissionMigrationCodes(code string) []string {
"daily-task:view", "daily-task:create", "daily-task:update", "daily-task:status",
}
case "auditor", "readonly":
return []string{"room:view", "room-config:view", "app-config:view", "app-version:view", "resource:view", "resource-group:view", "resource-grant:view", "gift:view", "coin-seller:view", "coin-ledger:view", "payment-bill:view", "payment-product:view", "game:view", "daily-task:view"}
return []string{"room:view", "room-pin:view", "room-config:view", "app-config:view", "app-version:view", "resource:view", "resource-group:view", "resource-grant:view", "gift:view", "coin-seller:view", "coin-ledger:view", "payment-bill:view", "payment-product:view", "game:view", "daily-task:view"}
default:
return nil
}

View File

@ -22,11 +22,14 @@ type RoomClient interface {
ApplyRTCEvent(ctx context.Context, req *roomv1.ApplyRTCEventRequest) (*roomv1.ApplyRTCEventResponse, error)
SetMicSeatLock(ctx context.Context, req *roomv1.SetMicSeatLockRequest) (*roomv1.SetMicSeatLockResponse, error)
SetChatEnabled(ctx context.Context, req *roomv1.SetChatEnabledRequest) (*roomv1.SetChatEnabledResponse, error)
SetRoomPassword(ctx context.Context, req *roomv1.SetRoomPasswordRequest) (*roomv1.SetRoomPasswordResponse, error)
SetRoomAdmin(ctx context.Context, req *roomv1.SetRoomAdminRequest) (*roomv1.SetRoomAdminResponse, error)
MuteUser(ctx context.Context, req *roomv1.MuteUserRequest) (*roomv1.MuteUserResponse, error)
KickUser(ctx context.Context, req *roomv1.KickUserRequest) (*roomv1.KickUserResponse, error)
UnbanUser(ctx context.Context, req *roomv1.UnbanUserRequest) (*roomv1.UnbanUserResponse, error)
SendGift(ctx context.Context, req *roomv1.SendGiftRequest) (*roomv1.SendGiftResponse, error)
FollowRoom(ctx context.Context, req *roomv1.FollowRoomRequest) (*roomv1.FollowRoomResponse, error)
UnfollowRoom(ctx context.Context, req *roomv1.UnfollowRoomRequest) (*roomv1.UnfollowRoomResponse, error)
}
// RoomGuardClient 抽象 gateway 对 room-service 实时守卫 RPC 的依赖。
@ -131,6 +134,10 @@ func (c *grpcRoomClient) SetChatEnabled(ctx context.Context, req *roomv1.SetChat
return c.client.SetChatEnabled(ctx, req)
}
func (c *grpcRoomClient) SetRoomPassword(ctx context.Context, req *roomv1.SetRoomPasswordRequest) (*roomv1.SetRoomPasswordResponse, error) {
return c.client.SetRoomPassword(ctx, req)
}
func (c *grpcRoomClient) SetRoomAdmin(ctx context.Context, req *roomv1.SetRoomAdminRequest) (*roomv1.SetRoomAdminResponse, error) {
return c.client.SetRoomAdmin(ctx, req)
}
@ -151,6 +158,14 @@ func (c *grpcRoomClient) SendGift(ctx context.Context, req *roomv1.SendGiftReque
return c.client.SendGift(ctx, req)
}
func (c *grpcRoomClient) FollowRoom(ctx context.Context, req *roomv1.FollowRoomRequest) (*roomv1.FollowRoomResponse, error) {
return c.client.FollowRoom(ctx, req)
}
func (c *grpcRoomClient) UnfollowRoom(ctx context.Context, req *roomv1.UnfollowRoomRequest) (*roomv1.UnfollowRoomResponse, error) {
return c.client.UnfollowRoom(ctx, req)
}
func (c *grpcRoomGuardClient) CheckSpeakPermission(ctx context.Context, req *roomv1.CheckSpeakPermissionRequest) (*roomv1.CheckSpeakPermissionResponse, error) {
return c.client.CheckSpeakPermission(ctx, req)
}

View File

@ -0,0 +1,33 @@
package activityapi
import (
"hyapp/services/gateway-service/internal/client"
"hyapp/services/gateway-service/internal/transport/http/httproutes"
)
// Handler owns activity-facing HTTP endpoints.
// It keeps task and registration reward reads/writes behind activity-service, with gateway only adding auth context.
type Handler struct {
taskClient client.TaskClient
registrationReward client.RegistrationRewardClient
}
type Config struct {
TaskClient client.TaskClient
RegistrationReward client.RegistrationRewardClient
}
func New(config Config) *Handler {
return &Handler{
taskClient: config.TaskClient,
registrationReward: config.RegistrationReward,
}
}
func (h *Handler) TaskHandlers() httproutes.TaskHandlers {
return httproutes.TaskHandlers{
ListTaskTabs: h.listTaskTabs,
ClaimTaskReward: h.claimTaskReward,
GetRegistrationRewardEligibility: h.getRegistrationRewardEligibility,
}
}

View File

@ -1,4 +1,4 @@
package http
package activityapi
import (
"net/http"
@ -29,7 +29,7 @@ func (h *Handler) getRegistrationRewardEligibility(writer http.ResponseWriter, r
return
}
resp, err := h.registrationReward.GetRegistrationRewardEligibility(request.Context(), &activityv1.GetRegistrationRewardEligibilityRequest{
Meta: activityMeta(request),
Meta: httpkit.ActivityMeta(request),
UserId: auth.UserIDFromContext(request.Context()),
})
if err != nil {

View File

@ -1,4 +1,4 @@
package http
package activityapi
import (
"hyapp/services/gateway-service/internal/transport/http/httpkit"
@ -94,7 +94,7 @@ func (h *Handler) listTaskTabs(writer http.ResponseWriter, request *http.Request
return
}
resp, err := h.taskClient.ListUserTasks(request.Context(), &activityv1.ListUserTasksRequest{
Meta: activityMeta(request),
Meta: httpkit.ActivityMeta(request),
UserId: auth.UserIDFromContext(request.Context()),
})
if err != nil {
@ -128,19 +128,20 @@ func (h *Handler) claimTaskReward(writer http.ResponseWriter, request *http.Requ
return
}
var body taskClaimRequestBody
if !decode(writer, request, &body) {
if !httpkit.Decode(writer, request, &body) {
return
}
taskID := body.normalizedTaskID()
taskType := body.normalizedTaskType()
taskDay := body.normalizedTaskDay()
commandID := normalizeCommandID(body.normalizedCommandID())
// command_id 是客户端动作的幂等锚点gateway 只裁剪空白,不替客户端补值。
commandID := strings.TrimSpace(body.normalizedCommandID())
if commandID == "" || taskID == "" || taskType == "" || taskDay == "" {
httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
return
}
resp, err := h.taskClient.ClaimTaskReward(request.Context(), &activityv1.ClaimTaskRewardRequest{
Meta: activityMeta(request),
Meta: httpkit.ActivityMeta(request),
UserId: auth.UserIDFromContext(request.Context()),
TaskId: taskID,
TaskType: taskType,

View File

@ -1,7 +1,6 @@
package http
package appapi
import (
"context"
"net/http"
"strconv"
"strings"
@ -12,13 +11,6 @@ import (
"hyapp/services/gateway-service/internal/transport/http/httpkit"
)
// appConfigReader 是 gateway 对后台 App 配置的只读依赖。
type appConfigReader interface {
ListH5Links(ctx context.Context) ([]appconfig.H5Link, error)
ListBanners(ctx context.Context, query appconfig.BannerQuery) ([]appconfig.Banner, error)
LatestVersion(ctx context.Context, query appconfig.VersionQuery) (appconfig.Version, error)
}
type appBootstrapBottomTab struct {
Key string `json:"key"`
Title string `json:"title"`
@ -111,9 +103,9 @@ func (h *Handler) listAppBanners(writer http.ResponseWriter, request *http.Reque
items, err := h.appConfigReader.ListBanners(request.Context(), appconfig.BannerQuery{
AppCode: appcode.FromContext(request.Context()),
Platform: firstQueryOrHeader(request, "platform", "X-App-Platform", "X-Platform"),
Platform: httpkit.FirstQueryOrHeader(request, "platform", "X-App-Platform", "X-Platform"),
RegionID: optionalInt64Query(request, "region_id"),
Country: firstQueryOrHeader(request, "country", "X-Country-Code", "X-App-Country"),
Country: httpkit.FirstQueryOrHeader(request, "country", "X-Country-Code", "X-App-Country"),
})
if err != nil {
httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
@ -129,7 +121,7 @@ func (h *Handler) getAppVersion(writer http.ResponseWriter, request *http.Reques
httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
return
}
platform := normalizeAppPlatform(firstQueryOrHeader(request, "platform", "X-App-Platform", "X-Platform"))
platform := normalizeAppPlatform(httpkit.FirstQueryOrHeader(request, "platform", "X-App-Platform", "X-Platform"))
if platform != "android" && platform != "ios" {
httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "platform is invalid")
return
@ -160,15 +152,8 @@ func (h *Handler) getAppVersion(writer http.ResponseWriter, request *http.Reques
})
}
func firstQueryOrHeader(request *http.Request, queryKey string, headerNames ...string) string {
if value := strings.TrimSpace(request.URL.Query().Get(queryKey)); value != "" {
return value
}
return firstHeader(request, headerNames...)
}
func optionalInt64QueryOrHeader(request *http.Request, queryKey string, headerNames ...string) int64 {
value := firstQueryOrHeader(request, queryKey, headerNames...)
value := httpkit.FirstQueryOrHeader(request, queryKey, headerNames...)
if value == "" {
return 0
}

View File

@ -1,4 +1,4 @@
package http
package appapi
import (
"hyapp/services/gateway-service/internal/transport/http/httpkit"
@ -37,14 +37,14 @@ func (h *Handler) bindPushToken(writer http.ResponseWriter, request *http.Reques
Language string `json:"language"`
Timezone string `json:"timezone"`
}
if !decode(writer, request, &body) {
if !httpkit.Decode(writer, request, &body) {
return
}
body.DeviceID = pushDeviceID(request, body.DeviceID)
body.Platform = pushPlatform(request, body.Platform)
resp, err := h.userDeviceClient.BindPushToken(request.Context(), &userv1.BindPushTokenRequest{
Meta: authRequestMeta(request, body.DeviceID),
Meta: httpkit.UserMeta(request, body.DeviceID),
UserId: auth.UserIDFromContext(request.Context()),
DeviceId: body.DeviceID,
PushToken: strings.TrimSpace(body.PushToken),
@ -71,13 +71,13 @@ func (h *Handler) deletePushToken(writer http.ResponseWriter, request *http.Requ
DeviceID string `json:"device_id"`
PushToken string `json:"push_token"`
}
if !decode(writer, request, &body) {
if !httpkit.Decode(writer, request, &body) {
return
}
body.DeviceID = pushDeviceID(request, body.DeviceID)
resp, err := h.userDeviceClient.DeletePushToken(request.Context(), &userv1.DeletePushTokenRequest{
Meta: authRequestMeta(request, body.DeviceID),
Meta: httpkit.UserMeta(request, body.DeviceID),
UserId: auth.UserIDFromContext(request.Context()),
DeviceId: body.DeviceID,
PushToken: strings.TrimSpace(body.PushToken),
@ -120,12 +120,12 @@ func pushDeviceID(request *http.Request, bodyValue string) string {
if value := strings.TrimSpace(bodyValue); value != "" {
return value
}
return firstHeader(request, "X-Device-ID", "X-HY-Device-ID")
return httpkit.FirstHeader(request, "X-Device-ID", "X-HY-Device-ID")
}
func pushPlatform(request *http.Request, bodyValue string) string {
if value := strings.TrimSpace(bodyValue); value != "" {
return value
}
return firstHeader(request, "X-App-Platform", "X-Platform")
return httpkit.FirstHeader(request, "X-App-Platform", "X-Platform")
}

View File

@ -0,0 +1,72 @@
package appapi
import (
"context"
"io"
"net/http"
"hyapp/services/gateway-service/internal/appconfig"
"hyapp/services/gateway-service/internal/client"
)
// ConfigReader 是 gateway 对后台 App 配置的只读依赖。
type ConfigReader interface {
ListH5Links(ctx context.Context) ([]appconfig.H5Link, error)
ListBanners(ctx context.Context, query appconfig.BannerQuery) ([]appconfig.Banner, error)
LatestVersion(ctx context.Context, query appconfig.VersionQuery) (appconfig.Version, error)
}
// ObjectUploader 是 gateway 上传接口依赖的对象存储最小能力。
type ObjectUploader interface {
PutObject(ctx context.Context, key string, reader io.Reader, sizeBytes int64, contentType string) (string, error)
}
// Handler owns app platform endpoints such as bootstrap, config, upload and device token binding.
// It does not own user identity or resource semantics; those remain in their owner services/packages.
type Handler struct {
appConfigReader ConfigReader
userDeviceClient client.UserDeviceClient
objectUploader ObjectUploader
}
type Config struct {
AppConfigReader ConfigReader
UserDeviceClient client.UserDeviceClient
ObjectUploader ObjectUploader
}
func New(config Config) *Handler {
return &Handler{
appConfigReader: config.AppConfigReader,
userDeviceClient: config.UserDeviceClient,
objectUploader: config.ObjectUploader,
}
}
func (h *Handler) GetAppBootstrap(writer http.ResponseWriter, request *http.Request) {
h.getAppBootstrap(writer, request)
}
func (h *Handler) ListH5Links(writer http.ResponseWriter, request *http.Request) {
h.listH5Links(writer, request)
}
func (h *Handler) ListAppBanners(writer http.ResponseWriter, request *http.Request) {
h.listAppBanners(writer, request)
}
func (h *Handler) GetAppVersion(writer http.ResponseWriter, request *http.Request) {
h.getAppVersion(writer, request)
}
func (h *Handler) UploadFile(writer http.ResponseWriter, request *http.Request) {
h.uploadFile(writer, request)
}
func (h *Handler) UploadAvatar(writer http.ResponseWriter, request *http.Request) {
h.uploadAvatar(writer, request)
}
func (h *Handler) HandlePushToken(writer http.ResponseWriter, request *http.Request) {
h.handlePushToken(writer, request)
}

View File

@ -1,4 +1,4 @@
package http
package appapi
import (
"bytes"
@ -106,7 +106,7 @@ func (h *Handler) uploadObject(writer http.ResponseWriter, request *http.Request
return
}
objectKey := buildUploadObjectKey(policy, auth.UserIDFromContext(request.Context()), header.Filename, contentType, timeNow())
objectKey := buildUploadObjectKey(policy, auth.UserIDFromContext(request.Context()), header.Filename, contentType, time.Now().UTC())
objectURL, err := h.objectUploader.PutObject(request.Context(), objectKey, io.MultiReader(bytes.NewReader(head), file), header.Size, contentType)
if err != nil {
httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")

View File

@ -276,7 +276,7 @@ func authData(token *userv1.AuthToken, isNewUser *bool) authTokenData {
}
return authTokenData{
UserID: userIDString(token.GetUserId()),
UserID: httpkit.UserIDString(token.GetUserId()),
AppCode: token.GetAppCode(),
DisplayUserID: token.GetDisplayUserId(),
DefaultDisplayUserID: token.GetDefaultDisplayUserId(),

View File

@ -0,0 +1,58 @@
package callbackapi
import (
"net/http"
"time"
"hyapp/services/gateway-service/internal/client"
)
// TencentIMConfig 是腾讯云 IM 服务端回调校验需要的最小配置。
type TencentIMConfig struct {
SDKAppID int64
AdminIdentifier string
CallbackAuthToken string
}
// TencentRTCConfig 是腾讯云 RTC 服务端回调校验需要的最小配置。
type TencentRTCConfig struct {
SDKAppID int64
CallbackSignKey string
UserSigTTL time.Duration
}
// Handler owns external Tencent callback endpoints.
// Tencent callback responses intentionally bypass gateway envelope because Tencent only accepts provider-native shapes.
type Handler struct {
roomClient client.RoomClient
roomGuardClient client.RoomGuardClient
userProfileClient client.UserProfileClient
tencentIM TencentIMConfig
tencentRTC TencentRTCConfig
}
type Config struct {
RoomClient client.RoomClient
RoomGuardClient client.RoomGuardClient
UserProfileClient client.UserProfileClient
TencentIM TencentIMConfig
TencentRTC TencentRTCConfig
}
func New(config Config) *Handler {
return &Handler{
roomClient: config.RoomClient,
roomGuardClient: config.RoomGuardClient,
userProfileClient: config.UserProfileClient,
tencentIM: config.TencentIM,
tencentRTC: config.TencentRTC,
}
}
func (h *Handler) HandleTencentIMCallback(writer http.ResponseWriter, request *http.Request) {
h.handleTencentIMCallback(writer, request)
}
func (h *Handler) HandleTencentRTCCallback(writer http.ResponseWriter, request *http.Request) {
h.handleTencentRTCCallback(writer, request)
}

View File

@ -1,4 +1,4 @@
package http
package gameapi
import (
"io"
@ -21,7 +21,7 @@ func (h *Handler) listGames(writer http.ResponseWriter, request *http.Request) {
}
userID := auth.UserIDFromContext(request.Context())
userResp, err := h.userProfileClient.GetUser(request.Context(), &userv1.GetUserRequest{
Meta: authRequestMeta(request, ""),
Meta: httpkit.UserMeta(request, ""),
UserId: userID,
})
if err != nil {
@ -35,9 +35,9 @@ func (h *Handler) listGames(writer http.ResponseWriter, request *http.Request) {
RoomId: strings.TrimSpace(request.URL.Query().Get("room_id")),
RegionId: userResp.GetUser().GetRegionId(),
Language: requestLanguage(request),
ClientPlatform: firstHeader(request, "X-App-Platform", "X-Platform"),
ClientPlatform: httpkit.FirstHeader(request, "X-App-Platform", "X-Platform"),
})
write(writer, request, gameListDataFromProto(resp), err)
httpkit.Write(writer, request, gameListDataFromProto(resp), err)
}
func (h *Handler) launchGame(writer http.ResponseWriter, request *http.Request) {
@ -54,12 +54,12 @@ func (h *Handler) launchGame(writer http.ResponseWriter, request *http.Request)
Scene string `json:"scene"`
RoomID string `json:"room_id"`
}
if !decode(writer, request, &body) {
if !httpkit.Decode(writer, request, &body) {
return
}
userID := auth.UserIDFromContext(request.Context())
userResp, err := h.userProfileClient.GetUser(request.Context(), &userv1.GetUserRequest{
Meta: authRequestMeta(request, ""),
Meta: httpkit.UserMeta(request, ""),
UserId: userID,
})
if err != nil {
@ -74,7 +74,7 @@ func (h *Handler) launchGame(writer http.ResponseWriter, request *http.Request)
Scene: body.Scene,
RoomId: body.RoomID,
})
write(writer, request, gameLaunchDataFromProto(resp), err)
httpkit.Write(writer, request, gameLaunchDataFromProto(resp), err)
}
func (h *Handler) handleGameCallback(writer http.ResponseWriter, request *http.Request) {
@ -122,7 +122,7 @@ func gameMeta(request *http.Request) *gamev1.RequestMeta {
}
func requestLanguage(request *http.Request) string {
value := firstHeader(request, "X-App-Language", "X-Language")
value := httpkit.FirstHeader(request, "X-App-Language", "X-Language")
if value == "" {
value = request.Header.Get("Accept-Language")
}

View File

@ -1,4 +1,4 @@
package http
package gameapi
import gamev1 "hyapp.local/api/proto/game/v1"

View File

@ -0,0 +1,47 @@
package gameapi
import (
"net/http"
"hyapp/services/gateway-service/internal/client"
"hyapp/services/gateway-service/internal/transport/http/httproutes"
)
// Handler owns game-facing HTTP endpoints.
// gateway only translates app/user context into game-service RPCs; game session state stays in game-service.
type Handler struct {
gameClient client.GameClient
userProfileClient client.UserProfileClient
}
type Config struct {
GameClient client.GameClient
UserProfileClient client.UserProfileClient
}
func New(config Config) *Handler {
return &Handler{
gameClient: config.GameClient,
userProfileClient: config.UserProfileClient,
}
}
func (h *Handler) Handlers() httproutes.GameHandlers {
return httproutes.GameHandlers{
ListGames: h.listGames,
LaunchGame: h.launchGame,
HandleCallback: h.handleGameCallback,
}
}
func (h *Handler) ListGames(writer http.ResponseWriter, request *http.Request) {
h.listGames(writer, request)
}
func (h *Handler) LaunchGame(writer http.ResponseWriter, request *http.Request) {
h.launchGame(writer, request)
}
func (h *Handler) HandleCallback(writer http.ResponseWriter, request *http.Request) {
h.handleGameCallback(writer, request)
}

View File

@ -9,13 +9,12 @@ import (
"time"
"hyapp/pkg/appcode"
"hyapp/pkg/idgen"
"hyapp/pkg/imgroup"
"hyapp/pkg/tencentim"
"hyapp/services/gateway-service/internal/auth"
"hyapp/services/gateway-service/internal/client"
"hyapp/services/gateway-service/internal/transport/http/appapi"
roomv1 "hyapp.local/api/proto/room/v1"
userv1 "hyapp.local/api/proto/user/v1"
)
@ -39,7 +38,7 @@ type Handler struct {
registrationReward client.RegistrationRewardClient
broadcastClient client.BroadcastClient
gameClient client.GameClient
appConfigReader appConfigReader
appConfigReader appapi.ConfigReader
tencentIM TencentIMConfig
tencentRTC TencentRTCConfig
objectUploader ObjectUploader
@ -190,25 +189,10 @@ func (h *Handler) SetGameClient(gameClient client.GameClient) {
}
// SetAppConfigReader 注入后台 App 配置读取依赖。
func (h *Handler) SetAppConfigReader(reader appConfigReader) {
func (h *Handler) SetAppConfigReader(reader appapi.ConfigReader) {
h.appConfigReader = reader
}
// meta 把外部 HTTP 请求上下文转换成 room-service 命令元信息。
// request_id 只做链路追踪command_id 才是 room-service command log 使用的幂等键。
func meta(request *http.Request, roomID string, commandID string) *roomv1.RequestMeta {
return &roomv1.RequestMeta{
RequestId: httpkit.RequestIDFromContext(request.Context()),
CommandId: normalizeCommandID(commandID),
ActorUserId: auth.UserIDFromContext(request.Context()),
RoomId: roomID,
AppCode: appcode.FromContext(request.Context()),
GatewayNodeId: "gateway-local",
SessionId: idgen.New("sess"),
SentAtMs: time.Now().UnixMilli(),
}
}
func normalizeCommandID(commandID string) string {
// command_id 是客户端按用户动作生成的写命令幂等键gateway 只裁剪空白,不自动补值。
return strings.TrimSpace(commandID)

View File

@ -4,14 +4,22 @@ import (
"context"
"encoding/json"
"io"
"net"
"net/http"
"net/netip"
"strconv"
"strings"
"time"
"google.golang.org/grpc/status"
"hyapp/pkg/appcode"
"hyapp/pkg/idgen"
"hyapp/pkg/xerr"
"hyapp/services/gateway-service/internal/auth"
activityv1 "hyapp.local/api/proto/activity/v1"
roomv1 "hyapp.local/api/proto/room/v1"
userv1 "hyapp.local/api/proto/user/v1"
)
type requestIDContextKey struct{}
@ -67,6 +75,17 @@ func DecodeJSON(reader io.Reader, out any) error {
return json.NewDecoder(reader).Decode(out)
}
// Decode 统一处理 HTTP JSON 入参解析失败分支。
// gateway 在这里拒绝非法 JSON不把脏请求继续透传到内部 gRPC。
func Decode(writer http.ResponseWriter, request *http.Request, out any) bool {
if err := DecodeJSON(request.Body, out); err != nil {
WriteError(writer, request, http.StatusBadRequest, CodeInvalidJSON, "invalid request body")
return false
}
return true
}
// WriteOK 写成功 envelope。
func WriteOK(writer http.ResponseWriter, request *http.Request, data any) {
WriteEnvelope(writer, http.StatusOK, ResponseEnvelope{
@ -102,6 +121,16 @@ func WriteRPCError(writer http.ResponseWriter, request *http.Request, err error)
WriteError(writer, request, statusCode, code, message)
}
// Write 统一把内部 gRPC 调用结果转换为 HTTP JSON 响应。
func Write(writer http.ResponseWriter, request *http.Request, body any, err error) {
if err != nil {
WriteRPCError(writer, request, err)
return
}
WriteOK(writer, request, body)
}
func MapReasonToHTTP(reason xerr.Code) (int, string, string) {
spec := xerr.SpecOf(reason)
return spec.HTTPStatus, spec.PublicCode, spec.PublicMessage
@ -129,6 +158,41 @@ func PositiveInt64PathValue(request *http.Request, name string) (int64, bool) {
return value, err == nil && value > 0
}
func UserIDString(userID int64) string {
if userID > 0 {
return strconv.FormatInt(userID, 10)
}
return ""
}
func PositiveInt32Query(request *http.Request, name string, fallback int32) (int32, bool) {
raw := strings.TrimSpace(request.URL.Query().Get(name))
if raw == "" {
return fallback, true
}
value, err := strconv.ParseInt(raw, 10, 32)
if err != nil || value <= 0 {
return 0, false
}
return int32(value), true
}
func FirstQueryOrHeader(request *http.Request, queryKey string, headerNames ...string) string {
if value := strings.TrimSpace(request.URL.Query().Get(queryKey)); value != "" {
return value
}
return FirstHeader(request, headerNames...)
}
func FirstHeader(request *http.Request, headerNames ...string) string {
for _, name := range headerNames {
if value := strings.TrimSpace(request.Header.Get(name)); value != "" {
return value
}
}
return ""
}
// RequireMethod 在业务 handler 之前固定 HTTP method避免 path-only mux 让错误 method 进入命令链路。
func RequireMethod(method string, next http.HandlerFunc) http.HandlerFunc {
return func(writer http.ResponseWriter, request *http.Request) {
@ -153,3 +217,121 @@ func RequireCompletedProfile(next http.HandlerFunc) http.HandlerFunc {
next.ServeHTTP(writer, request)
}
}
// RoomMeta 把外部 HTTP 请求上下文转换成 room-service 命令元信息。
// request_id 只做链路追踪command_id 必须来自客户端用户动作gateway 只裁剪空白。
func RoomMeta(request *http.Request, roomID string, commandID string) *roomv1.RequestMeta {
return &roomv1.RequestMeta{
RequestId: RequestIDFromContext(request.Context()),
CommandId: strings.TrimSpace(commandID),
ActorUserId: auth.UserIDFromContext(request.Context()),
RoomId: roomID,
AppCode: appcode.FromContext(request.Context()),
GatewayNodeId: "gateway-local",
SessionId: idgen.New("sess"),
SentAtMs: time.Now().UnixMilli(),
}
}
// UserMeta 生成 user-service RPC 的统一追踪和审计元信息。
func UserMeta(request *http.Request, deviceID string) *userv1.RequestMeta {
return UserMetaWithLoginContext(request, deviceID, "", "", "")
}
func UserMetaWithLoginContext(request *http.Request, deviceID string, platform string, language string, timezone string) *userv1.RequestMeta {
return &userv1.RequestMeta{
RequestId: RequestIDFromContext(request.Context()),
Caller: "gateway-service",
GatewayNodeId: "gateway-local",
SentAtMs: time.Now().UnixMilli(),
DeviceId: deviceID,
ClientIp: ClientIP(request),
UserAgent: request.UserAgent(),
CountryByIp: CountryByIP(request),
SessionId: auth.SessionIDFromContext(request.Context()),
AppCode: appcode.FromContext(request.Context()),
Platform: strings.ToLower(strings.TrimSpace(platform)),
Language: strings.TrimSpace(language),
Timezone: strings.TrimSpace(timezone),
}
}
// ActivityMeta 生成 activity-service RPC 的统一追踪元信息。
// 业务 actor 仍由各请求显式传 user_idmeta 只承载链路和租户上下文。
func ActivityMeta(request *http.Request) *activityv1.RequestMeta {
return &activityv1.RequestMeta{
RequestId: RequestIDFromContext(request.Context()),
Caller: "gateway-service",
GatewayNodeId: "gateway-local",
SentAtMs: time.Now().UnixMilli(),
AppCode: appcode.FromContext(request.Context()),
}
}
// ClientIP 提取 gateway 看到的客户端地址。
func ClientIP(request *http.Request) string {
for _, header := range []string{"X-Trusted-Client-IP", "X-Real-IP"} {
if ip := NormalizeIP(request.Header.Get(header)); ip != "" {
return ip
}
}
forwarded := strings.TrimSpace(request.Header.Get("X-Forwarded-For"))
if forwarded != "" {
for part := range strings.SplitSeq(forwarded, ",") {
ip := NormalizeIP(part)
if ip == "" {
continue
}
if addr, err := netip.ParseAddr(ip); err == nil && isPublicClientAddr(addr) {
return ip
}
}
}
host, _, err := net.SplitHostPort(request.RemoteAddr)
if err != nil {
return NormalizeIP(request.RemoteAddr)
}
return host
}
func NormalizeIP(value string) string {
value = strings.TrimSpace(value)
if value == "" {
return ""
}
if host, _, err := net.SplitHostPort(value); err == nil {
value = host
}
if addr, err := netip.ParseAddr(value); err == nil {
return addr.String()
}
return value
}
func isPublicClientAddr(addr netip.Addr) bool {
return addr.IsValid() &&
addr.IsGlobalUnicast() &&
!addr.IsPrivate() &&
!addr.IsLoopback() &&
!addr.IsLinkLocalUnicast() &&
!addr.IsLinkLocalMulticast() &&
!addr.IsMulticast() &&
!addr.IsUnspecified()
}
func CountryByIP(request *http.Request) string {
// country_by_ip 不接受 JSON body只采信 gateway/边缘层写入的地域头。
for _, header := range []string{"CF-IPCountry", "CloudFront-Viewer-Country", "X-Vercel-IP-Country", "X-AppEngine-Country", "X-Country-Code"} {
country := strings.ToUpper(strings.TrimSpace(request.Header.Get(header)))
if country == "" || country == "XX" || country == "UNKNOWN" {
continue
}
if len(country) >= 2 && len(country) <= 3 {
return country
}
}
return ""
}

View File

@ -91,6 +91,7 @@ type RoomHandlers struct {
GetRoomDetail http.HandlerFunc
ListRoomOnlineUsers http.HandlerFunc
GetRoomGiftPanel http.HandlerFunc
FollowRoom http.HandlerFunc
CreateRoom http.HandlerFunc
UpdateRoomProfile http.HandlerFunc
JoinRoom http.HandlerFunc
@ -104,6 +105,7 @@ type RoomHandlers struct {
SetMicMute http.HandlerFunc
SetMicSeatLock http.HandlerFunc
SetChatEnabled http.HandlerFunc
SetRoomPassword http.HandlerFunc
SetRoomAdmin http.HandlerFunc
MuteUser http.HandlerFunc
KickUser http.HandlerFunc
@ -263,6 +265,7 @@ func (r routes) registerRoomRoutes() {
r.profile("/rooms/{room_id}/detail", http.MethodGet, h.GetRoomDetail)
r.profile("/rooms/{room_id}/online-users", http.MethodGet, h.ListRoomOnlineUsers)
r.profile("/rooms/{room_id}/gift-panel", http.MethodGet, h.GetRoomGiftPanel)
r.profile("/rooms/{room_id}/follow", "", h.FollowRoom)
r.profile("/rooms/create", http.MethodPost, h.CreateRoom)
r.profile("/rooms/profile/update", http.MethodPost, h.UpdateRoomProfile)
r.profile("/rooms/join", http.MethodPost, h.JoinRoom)
@ -276,6 +279,7 @@ func (r routes) registerRoomRoutes() {
r.profile("/rooms/mic/mute", http.MethodPost, h.SetMicMute)
r.profile("/rooms/mic/lock", http.MethodPost, h.SetMicSeatLock)
r.profile("/rooms/chat/enabled", http.MethodPost, h.SetChatEnabled)
r.profile("/rooms/password", http.MethodPost, h.SetRoomPassword)
r.profile("/rooms/admin/set", http.MethodPost, h.SetRoomAdmin)
r.profile("/rooms/user/mute", http.MethodPost, h.MuteUser)
r.profile("/rooms/user/kick", http.MethodPost, h.KickUser)

View File

@ -50,7 +50,7 @@ func TestListManagerGrantResourcesChecksCapabilityAndFiltersWalletRequest(t *tes
if recorder.Code != http.StatusOK {
t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String())
}
if hostClient.lastCapability == nil || hostClient.lastCapability.GetActorUserId() != 42 || hostClient.lastCapability.GetCapability() != managerResourceGrantCapability {
if hostClient.lastCapability == nil || hostClient.lastCapability.GetActorUserId() != 42 || hostClient.lastCapability.GetCapability() != "manager_resource_grant" {
t.Fatalf("manager capability request mismatch: %+v", hostClient.lastCapability)
}
if walletClient.lastListResources == nil {
@ -65,10 +65,13 @@ func TestListManagerGrantResourcesChecksCapabilityAndFiltersWalletRequest(t *tes
Code string `json:"code"`
RequestID string `json:"request_id"`
Data struct {
Items []managerGrantResourceData `json:"items"`
Total int64 `json:"total"`
Page int32 `json:"page"`
PageSize int32 `json:"page_size"`
Items []struct {
ResourceID string `json:"resource_id"`
Name string `json:"name"`
} `json:"items"`
Total int64 `json:"total"`
Page int32 `json:"page"`
PageSize int32 `json:"page_size"`
} `json:"data"`
}
if err := json.NewDecoder(recorder.Body).Decode(&envelope); err != nil {
@ -124,8 +127,11 @@ func TestBusinessUserLookupForwardsSceneAndActor(t *testing.T) {
var envelope struct {
Code string `json:"code"`
Data struct {
Items []businessUserLookupData `json:"items"`
Total int `json:"total"`
Items []struct {
UserID string `json:"user_id"`
Status string `json:"status"`
} `json:"items"`
Total int `json:"total"`
} `json:"data"`
}
if err := json.NewDecoder(recorder.Body).Decode(&envelope); err != nil {

View File

@ -0,0 +1,36 @@
package managerapi
import (
"hyapp/services/gateway-service/internal/client"
"hyapp/services/gateway-service/internal/transport/http/httproutes"
)
// Handler owns manager-center HTTP endpoints that are still part of the app gateway.
// Capability checks stay in user-service, and resource grants stay in wallet-service.
type Handler struct {
walletClient client.WalletClient
userHostClient client.UserHostClient
userProfileClient client.UserProfileClient
}
type Config struct {
WalletClient client.WalletClient
UserHostClient client.UserHostClient
UserProfileClient client.UserProfileClient
}
func New(config Config) *Handler {
return &Handler{
walletClient: config.WalletClient,
userHostClient: config.UserHostClient,
userProfileClient: config.UserProfileClient,
}
}
func (h *Handler) ManagerHandlers() httproutes.ManagerHandlers {
return httproutes.ManagerHandlers{
ListManagerGrantResources: h.listManagerGrantResources,
GrantManagerResource: h.grantManagerResource,
LookupBusinessUsers: h.lookupBusinessUsers,
}
}

View File

@ -1,4 +1,4 @@
package http
package managerapi
import (
"encoding/json"
@ -88,7 +88,7 @@ func (h *Handler) lookupBusinessUsers(writer http.ResponseWriter, request *http.
httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
return
}
pageSize, ok := parsePositiveInt32Query(request, "page_size", 20)
pageSize, ok := httpkit.PositiveInt32Query(request, "page_size", 20)
if !ok {
httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
return
@ -97,7 +97,7 @@ func (h *Handler) lookupBusinessUsers(writer http.ResponseWriter, request *http.
pageSize = 20
}
resp, err := h.userProfileClient.BusinessUserLookup(request.Context(), &userv1.BusinessUserLookupRequest{
Meta: authRequestMeta(request, ""),
Meta: httpkit.UserMeta(request, ""),
ActorUserId: auth.UserIDFromContext(request.Context()),
Scene: strings.TrimSpace(request.URL.Query().Get("scene")),
Keyword: strings.TrimSpace(request.URL.Query().Get("keyword")),
@ -131,7 +131,7 @@ func (h *Handler) grantManagerResource(writer http.ResponseWriter, request *http
DurationMS *int64 `json:"duration_ms"`
DurationMSAlt *int64 `json:"durationMs"`
}
if !decode(writer, request, &body) {
if !httpkit.Decode(writer, request, &body) {
return
}
if body.Quantity != nil || body.DurationMS != nil || body.DurationMSAlt != nil {
@ -152,7 +152,7 @@ func (h *Handler) grantManagerResource(writer http.ResponseWriter, request *http
return
}
targetResp, err := h.userProfileClient.GetUser(request.Context(), &userv1.GetUserRequest{
Meta: authRequestMeta(request, ""),
Meta: httpkit.UserMeta(request, ""),
UserId: targetUserID,
})
if err != nil {
@ -201,7 +201,7 @@ func (h *Handler) grantManagerResource(writer http.ResponseWriter, request *http
func (h *Handler) requireManagerResourceGrantCapability(writer http.ResponseWriter, request *http.Request) bool {
resp, err := h.userHostClient.CheckBusinessCapability(request.Context(), &userv1.CheckBusinessCapabilityRequest{
Meta: authRequestMeta(request, ""),
Meta: httpkit.UserMeta(request, ""),
ActorUserId: auth.UserIDFromContext(request.Context()),
Capability: managerResourceGrantCapability,
})
@ -221,7 +221,7 @@ func managerGrantResourceFromProto(resource *walletv1.Resource) managerGrantReso
return managerGrantResourceData{}
}
return managerGrantResourceData{
ResourceID: userIDString(resource.GetResourceId()),
ResourceID: httpkit.UserIDString(resource.GetResourceId()),
ResourceType: resource.GetResourceType(),
Name: resource.GetName(),
AssetURL: resource.GetAssetUrl(),
@ -237,7 +237,7 @@ func businessUserLookupFromProto(user *userv1.BusinessUserLookupItem) businessUs
return businessUserLookupData{}
}
return businessUserLookupData{
UserID: userIDString(user.GetUserId()),
UserID: httpkit.UserIDString(user.GetUserId()),
DisplayUserID: user.GetDisplayUserId(),
Username: user.GetUsername(),
Avatar: user.GetAvatar(),
@ -254,25 +254,36 @@ func managerResourceGrantFromProto(grant *walletv1.ResourceGrant) managerResourc
}
resourceID := grant.GetGrantSubjectId()
if len(grant.GetItems()) > 0 && grant.GetItems()[0].GetResourceId() > 0 {
resourceID = userIDString(grant.GetItems()[0].GetResourceId())
resourceID = httpkit.UserIDString(grant.GetItems()[0].GetResourceId())
}
return managerResourceGrantData{
GrantID: grant.GetGrantId(),
CommandID: grant.GetCommandId(),
TargetUserID: userIDString(grant.GetTargetUserId()),
TargetUserID: httpkit.UserIDString(grant.GetTargetUserId()),
ResourceID: resourceID,
Status: grant.GetStatus(),
GrantSource: grant.GetGrantSource(),
OperatorUserID: userIDString(grant.GetOperatorUserId()),
OperatorUserID: httpkit.UserIDString(grant.GetOperatorUserId()),
CreatedAtMS: grant.GetCreatedAtMs(),
}
}
func managerPage(request *http.Request) (int32, int32, bool) {
page, pageSize, ok := resourcePage(request)
page, ok := httpkit.PositiveInt32Query(request, "page", 1)
if !ok {
return 0, 0, false
}
pageSize, ok := httpkit.PositiveInt32Query(request, "page_size", 20)
if !ok {
return 0, 0, false
}
if raw := strings.TrimSpace(request.URL.Query().Get("pageSize")); raw != "" {
parsed, err := strconv.Atoi(raw)
if err != nil || parsed <= 0 {
return 0, 0, false
}
pageSize = int32(parsed)
}
if pageSize > 20 {
pageSize = 20
}

View File

@ -0,0 +1,41 @@
package messageapi
import (
"net/http"
"hyapp/services/gateway-service/internal/client"
"hyapp/services/gateway-service/internal/transport/http/httproutes"
)
// Handler owns message inbox endpoints and safe profile batches used by Tencent IM conversation UI.
// Private chat delivery remains Tencent IM owned; these handlers only expose backend-owned system inbox facts.
type Handler struct {
messageClient client.MessageInboxClient
userProfileClient client.UserProfileClient
}
type Config struct {
MessageClient client.MessageInboxClient
UserProfileClient client.UserProfileClient
}
func New(config Config) *Handler {
return &Handler{
messageClient: config.MessageClient,
userProfileClient: config.UserProfileClient,
}
}
func (h *Handler) MessageHandlers() httproutes.MessageHandlers {
return httproutes.MessageHandlers{
ListMessageTabs: h.listMessageTabs,
MarkInboxSectionRead: h.markInboxSectionRead,
MarkInboxMessageReadPath: h.markInboxMessageReadByPath,
DeleteInboxMessagePath: h.deleteInboxMessageByPath,
ListInboxMessages: h.listInboxMessages,
}
}
func (h *Handler) BatchUserProfiles(writer http.ResponseWriter, request *http.Request) {
h.batchUserProfiles(writer, request)
}

View File

@ -1,16 +1,14 @@
package http
package messageapi
import (
"hyapp/services/gateway-service/internal/transport/http/httpkit"
"net/http"
"strconv"
"strings"
"time"
activityv1 "hyapp.local/api/proto/activity/v1"
userv1 "hyapp.local/api/proto/user/v1"
"hyapp/pkg/appcode"
"hyapp/services/gateway-service/internal/auth"
"hyapp/services/gateway-service/internal/transport/http/httpkit"
)
type messageTabSectionData struct {
@ -51,7 +49,7 @@ func (h *Handler) listMessageTabs(writer http.ResponseWriter, request *http.Requ
return
}
resp, err := h.messageClient.ListMessageTabs(request.Context(), &activityv1.ListMessageTabsRequest{
Meta: activityMeta(request),
Meta: httpkit.ActivityMeta(request),
UserId: auth.UserIDFromContext(request.Context()),
})
if err != nil {
@ -83,7 +81,7 @@ func (h *Handler) listInboxMessages(writer http.ResponseWriter, request *http.Re
}
section := strings.TrimSpace(request.URL.Query().Get("section"))
resp, err := h.messageClient.ListInboxMessages(request.Context(), &activityv1.ListInboxMessagesRequest{
Meta: activityMeta(request),
Meta: httpkit.ActivityMeta(request),
UserId: auth.UserIDFromContext(request.Context()),
Section: section,
PageSize: pageSize,
@ -120,11 +118,11 @@ func (h *Handler) markInboxSectionRead(writer http.ResponseWriter, request *http
var body struct {
Section string `json:"section"`
}
if !decode(writer, request, &body) {
if !httpkit.Decode(writer, request, &body) {
return
}
resp, err := h.messageClient.MarkInboxSectionRead(request.Context(), &activityv1.MarkInboxSectionReadRequest{
Meta: activityMeta(request),
Meta: httpkit.ActivityMeta(request),
UserId: auth.UserIDFromContext(request.Context()),
Section: body.Section,
})
@ -173,7 +171,7 @@ func (h *Handler) markInboxMessageRead(writer http.ResponseWriter, request *http
return
}
resp, err := h.messageClient.MarkInboxMessageRead(request.Context(), &activityv1.MarkInboxMessageReadRequest{
Meta: activityMeta(request),
Meta: httpkit.ActivityMeta(request),
UserId: auth.UserIDFromContext(request.Context()),
MessageId: messageID,
})
@ -186,7 +184,7 @@ func (h *Handler) markInboxMessageRead(writer http.ResponseWriter, request *http
func (h *Handler) deleteInboxMessage(writer http.ResponseWriter, request *http.Request, messageID string) {
resp, err := h.messageClient.DeleteInboxMessage(request.Context(), &activityv1.DeleteInboxMessageRequest{
Meta: activityMeta(request),
Meta: httpkit.ActivityMeta(request),
UserId: auth.UserIDFromContext(request.Context()),
MessageId: messageID,
})
@ -209,7 +207,7 @@ func (h *Handler) batchUserProfiles(writer http.ResponseWriter, request *http.Re
return
}
resp, err := h.userProfileClient.BatchGetUsers(request.Context(), &userv1.BatchGetUsersRequest{
Meta: authRequestMeta(request, ""),
Meta: httpkit.UserMeta(request, ""),
UserIds: userIDs,
})
if err != nil {
@ -234,16 +232,6 @@ func (h *Handler) batchUserProfiles(writer http.ResponseWriter, request *http.Re
httpkit.WriteOK(writer, request, map[string]any{"profiles": items})
}
func activityMeta(request *http.Request) *activityv1.RequestMeta {
return &activityv1.RequestMeta{
RequestId: httpkit.RequestIDFromContext(request.Context()),
Caller: "gateway-service",
GatewayNodeId: "gateway-local",
SentAtMs: time.Now().UnixMilli(),
AppCode: appcode.FromContext(request.Context()),
}
}
func parseMessagePageSize(raw string) (int32, bool) {
raw = strings.TrimSpace(raw)
if raw == "" {

View File

@ -0,0 +1,41 @@
package resourceapi
import (
"net/http"
"hyapp/services/gateway-service/internal/client"
)
// Handler owns public resource catalog and current-user resource inventory endpoints.
// Wallet-service remains the owner for entitlement and grant semantics.
type Handler struct {
walletClient client.WalletClient
}
type Config struct {
WalletClient client.WalletClient
}
func New(config Config) *Handler {
return &Handler{walletClient: config.WalletClient}
}
func (h *Handler) ListResources(writer http.ResponseWriter, request *http.Request) {
h.listResources(writer, request)
}
func (h *Handler) GetResourceGroup(writer http.ResponseWriter, request *http.Request) {
h.getResourceGroup(writer, request)
}
func (h *Handler) ListGifts(writer http.ResponseWriter, request *http.Request) {
h.listGifts(writer, request)
}
func (h *Handler) ListMyResources(writer http.ResponseWriter, request *http.Request) {
h.listMyResources(writer, request)
}
func (h *Handler) EquipMyResource(writer http.ResponseWriter, request *http.Request) {
h.equipMyResource(writer, request)
}

View File

@ -1,4 +1,4 @@
package http
package resourceapi
import (
"errors"
@ -357,11 +357,11 @@ func userResourceFromProto(item *walletv1.UserResourceEntitlement) userResourceD
}
func resourcePage(request *http.Request) (int32, int32, bool) {
page, ok := parsePositiveInt32Query(request, "page", 1)
page, ok := httpkit.PositiveInt32Query(request, "page", 1)
if !ok {
return 0, 0, false
}
pageSize, ok := parsePositiveInt32Query(request, "page_size", 20)
pageSize, ok := httpkit.PositiveInt32Query(request, "page_size", 20)
if !ok {
return 0, 0, false
}
@ -375,18 +375,6 @@ func resourcePage(request *http.Request) (int32, int32, bool) {
return page, pageSize, true
}
func parsePositiveInt32Query(request *http.Request, name string, fallback int32) (int32, bool) {
raw := strings.TrimSpace(request.URL.Query().Get(name))
if raw == "" {
return fallback, true
}
value, err := strconv.Atoi(raw)
if err != nil || value <= 0 {
return 0, false
}
return int32(value), true
}
func optionalNonNegativeInt64Query(request *http.Request, name string) (int64, bool, bool) {
raw := strings.TrimSpace(request.URL.Query().Get(name))
if raw == "" {

View File

@ -9,6 +9,7 @@ import (
"encoding/json"
"errors"
"hyapp/services/gateway-service/internal/transport/http/httpkit"
"hyapp/services/gateway-service/internal/transport/http/roomapi"
"io"
"mime/multipart"
"net/http"
@ -45,11 +46,14 @@ type fakeRoomClient struct {
lastRTCEvent *roomv1.ApplyRTCEventRequest
lastMicSeatLock *roomv1.SetMicSeatLockRequest
lastChatEnabled *roomv1.SetChatEnabledRequest
lastRoomPassword *roomv1.SetRoomPasswordRequest
lastSetAdmin *roomv1.SetRoomAdminRequest
lastMute *roomv1.MuteUserRequest
lastKick *roomv1.KickUserRequest
lastUnban *roomv1.UnbanUserRequest
lastGift *roomv1.SendGiftRequest
lastFollowRoom *roomv1.FollowRoomRequest
lastUnfollowRoom *roomv1.UnfollowRoomRequest
createErr error
updateProfileErr error
joinErr error
@ -175,6 +179,24 @@ func (f *fakeRoomClient) SetChatEnabled(_ context.Context, req *roomv1.SetChatEn
return &roomv1.SetChatEnabledResponse{Result: &roomv1.CommandResult{Applied: true, RoomVersion: 8}}, nil
}
func (f *fakeRoomClient) SetRoomPassword(_ context.Context, req *roomv1.SetRoomPasswordRequest) (*roomv1.SetRoomPasswordResponse, error) {
f.lastRoomPassword = req
return &roomv1.SetRoomPasswordResponse{
Result: &roomv1.CommandResult{Applied: true, RoomVersion: 9, ServerTimeMs: 1_700_000_000_009},
Room: &roomv1.RoomSnapshot{
RoomId: req.GetMeta().GetRoomId(),
OwnerUserId: req.GetMeta().GetActorUserId(),
Mode: "voice",
Status: "active",
ChatEnabled: true,
Locked: req.GetLocked(),
MicSeats: []*roomv1.SeatState{{SeatNo: 1}, {SeatNo: 2}},
RoomExt: map[string]string{"title": "Room"},
Version: 9,
},
}, nil
}
func (f *fakeRoomClient) SetRoomAdmin(_ context.Context, req *roomv1.SetRoomAdminRequest) (*roomv1.SetRoomAdminResponse, error) {
f.lastSetAdmin = req
return &roomv1.SetRoomAdminResponse{Result: &roomv1.CommandResult{Applied: true, RoomVersion: 9}}, nil
@ -200,6 +222,16 @@ func (f *fakeRoomClient) SendGift(_ context.Context, req *roomv1.SendGiftRequest
return &roomv1.SendGiftResponse{Result: &roomv1.CommandResult{Applied: true, RoomVersion: 14}}, nil
}
func (f *fakeRoomClient) FollowRoom(_ context.Context, req *roomv1.FollowRoomRequest) (*roomv1.FollowRoomResponse, error) {
f.lastFollowRoom = req
return &roomv1.FollowRoomResponse{RoomId: req.GetRoomId(), Followed: true, FollowedAtMs: 1_700_000_000_123, ServerTimeMs: 1_700_000_000_123}, nil
}
func (f *fakeRoomClient) UnfollowRoom(_ context.Context, req *roomv1.UnfollowRoomRequest) (*roomv1.UnfollowRoomResponse, error) {
f.lastUnfollowRoom = req
return &roomv1.UnfollowRoomResponse{RoomId: req.GetRoomId(), Followed: false, ServerTimeMs: 1_700_000_000_124}, nil
}
type fakeUserAuthClient struct {
lastLoginThirdParty *userv1.LoginThirdPartyRequest
lastLoginPassword *userv1.LoginPasswordRequest
@ -1242,6 +1274,15 @@ func TestRoomCommandsPropagateClientCommandID(t *testing.T) {
return client.lastChatEnabled.GetMeta()
},
},
{
name: "room_password",
path: "/api/v1/rooms/password",
body: `{"room_id":"room-1","command_id":"cmd-room-password-1","locked":true,"password":"1234"}`,
commandID: "cmd-room-password-1",
extractMeta: func(client *fakeRoomClient) *roomv1.RequestMeta {
return client.lastRoomPassword.GetMeta()
},
},
{
name: "set_admin",
path: "/api/v1/rooms/admin/set",
@ -1323,9 +1364,9 @@ func TestRoomCommandsPropagateClientCommandID(t *testing.T) {
}
func TestJoinRoomReturnsInitialDataWithIMGroupRTCTokenAndProfiles(t *testing.T) {
previousNow := timeNow
timeNow = func() time.Time { return time.Unix(1_700_000_000, 0) }
defer func() { timeNow = previousNow }()
previousNow := roomapi.TimeNow
roomapi.TimeNow = func() time.Time { return time.Unix(1_700_000_000, 0) }
defer func() { roomapi.TimeNow = previousNow }()
roomClient := &fakeRoomClient{joinResp: &roomv1.JoinRoomResponse{
Result: &roomv1.CommandResult{Applied: true, RoomVersion: 7, ServerTimeMs: 1_700_000_000_123},
@ -1336,6 +1377,7 @@ func TestJoinRoomReturnsInitialDataWithIMGroupRTCTokenAndProfiles(t *testing.T)
Mode: "voice",
Status: "active",
ChatEnabled: true,
Locked: true,
MicSeats: []*roomv1.SeatState{{SeatNo: 1, UserId: 102, PublishState: "publishing", MicSessionId: "mic-102"}, {SeatNo: 2, Locked: true}},
OnlineUsers: []*roomv1.RoomUser{{UserId: 42, Role: "audience"}, {UserId: 102, Role: "admin"}},
GiftRank: []*roomv1.RankItem{{UserId: 301, Score: 900, GiftValue: 900}},
@ -1356,7 +1398,7 @@ func TestJoinRoomReturnsInitialDataWithIMGroupRTCTokenAndProfiles(t *testing.T)
AppScene: "voice_chat_room",
})
router := handler.Routes(auth.NewVerifier("secret"))
request := httptest.NewRequest(http.MethodPost, "/api/v1/rooms/join", bytes.NewReader([]byte(`{"room_id":"room_1001","command_id":"cmd-join","role":"audience"}`)))
request := httptest.NewRequest(http.MethodPost, "/api/v1/rooms/join", bytes.NewReader([]byte(`{"room_id":"room_1001","command_id":"cmd-join","role":"audience","password":"1234"}`)))
request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
request.Header.Set("X-Request-ID", "req-join-initial")
recorder := httptest.NewRecorder()
@ -1372,9 +1414,12 @@ func TestJoinRoomReturnsInitialDataWithIMGroupRTCTokenAndProfiles(t *testing.T)
}
data := response.Data.(map[string]any)
room := data["room"].(map[string]any)
if room["room_id"] != "room_1001" || room["im_group_id"] != "room_1001" || room["title"] != "Live Room" || room["online_count"].(float64) != 2 {
if room["room_id"] != "room_1001" || room["im_group_id"] != "room_1001" || room["title"] != "Live Room" || room["online_count"].(float64) != 2 || room["locked"] != true {
t.Fatalf("unexpected initial room data: %+v", room)
}
if roomClient.lastJoin == nil || roomClient.lastJoin.GetPassword() != "1234" {
t.Fatalf("join must forward room password to room-service: %+v", roomClient.lastJoin)
}
seats := data["seats"].([]any)
if len(seats) != 2 || seats[0].(map[string]any)["user_id"] != "102" {
t.Fatalf("join response must include first-screen seats: %+v", seats)
@ -1401,7 +1446,7 @@ func TestListRoomsUsesUserRegionFromUserService(t *testing.T) {
profileClient := &fakeUserProfileClient{regionID: 1001}
queryClient := &fakeRoomQueryClient{resp: &roomv1.ListRoomsResponse{
Rooms: []*roomv1.RoomListItem{
{RoomId: "room-1", VisibleRegionId: 1001, Heat: 90},
{RoomId: "room-1", VisibleRegionId: 1001, Heat: 90, Locked: true},
},
NextCursor: "cursor-2",
}}
@ -1437,13 +1482,43 @@ func TestListRoomsUsesUserRegionFromUserService(t *testing.T) {
data := response.Data.(map[string]any)
rooms := data["rooms"].([]any)
first := rooms[0].(map[string]any)
if first["im_group_id"] != "room-1" {
if first["im_group_id"] != "room-1" || first["locked"] != true {
t.Fatalf("room list must expose room IM group id: %+v", first)
}
}
func TestSetRoomPasswordForwardsPasswordAndReturnsLockedState(t *testing.T) {
client := &fakeRoomClient{}
router := NewHandlerWithClients(client, nil, nil, &fakeUserProfileClient{}).Routes(auth.NewVerifier("secret"))
request := httptest.NewRequest(http.MethodPost, "/api/v1/rooms/password", bytes.NewReader([]byte(`{"room_id":"room-1","command_id":"cmd-password","locked":true,"password":" 1234 "}`)))
request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
request.Header.Set("X-Request-ID", "req-password")
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK {
t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String())
}
if client.lastRoomPassword == nil || !client.lastRoomPassword.GetLocked() || client.lastRoomPassword.GetPassword() != "1234" {
t.Fatalf("room password request was not normalized and forwarded: %+v", client.lastRoomPassword)
}
if strings.Contains(recorder.Body.String(), "password_hash") || strings.Contains(recorder.Body.String(), "room_password_hash") {
t.Fatalf("set password response must not expose password hash: %s", recorder.Body.String())
}
var response httpkit.ResponseEnvelope
if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil {
t.Fatalf("decode room password response failed: %v", err)
}
data := response.Data.(map[string]any)
room := data["room"].(map[string]any)
if room["locked"] != true {
t.Fatalf("set password response must expose locked state: %+v", room)
}
}
func TestListRoomsRejectsMineFeedTabs(t *testing.T) {
for _, tab := range []string{"visited", "friend", "following", "me"} {
for _, tab := range []string{"visited", "friend", "following", "followed", "me"} {
t.Run(tab, func(t *testing.T) {
queryClient := &fakeRoomQueryClient{}
handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{regionID: 1001})
@ -1466,7 +1541,7 @@ func TestListRoomsRejectsMineFeedTabs(t *testing.T) {
}
func TestListRoomFeedsSupportsMineFeedTabs(t *testing.T) {
for _, tab := range []string{"visited", "friend", "following"} {
for _, tab := range []string{"visited", "friend", "following", "followed"} {
t.Run(tab, func(t *testing.T) {
profileClient := &fakeUserProfileClient{regionID: 1001}
socialClient := &fakeUserSocialClient{}
@ -1475,7 +1550,7 @@ func TestListRoomFeedsSupportsMineFeedTabs(t *testing.T) {
}}
handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, profileClient)
handler.SetRoomQueryClient(queryClient)
if tab != "visited" {
if tab == "friend" || tab == "following" {
handler.SetUserSocialClient(socialClient)
}
router := handler.Routes(auth.NewVerifier("secret"))
@ -1493,9 +1568,9 @@ func TestListRoomFeedsSupportsMineFeedTabs(t *testing.T) {
t.Fatalf("feed tab was not forwarded with authenticated user and region: %+v", queryClient.lastFeeds)
}
switch tab {
case "visited":
case "visited", "followed":
if len(queryClient.lastFeeds.GetRelatedUsers()) != 0 || socialClient.lastFollowing != nil || socialClient.lastFriends != nil {
t.Fatalf("visited feed must not load social relations: feeds=%+v social=%+v/%+v", queryClient.lastFeeds, socialClient.lastFollowing, socialClient.lastFriends)
t.Fatalf("%s feed must not load social relations: feeds=%+v social=%+v/%+v", tab, queryClient.lastFeeds, socialClient.lastFollowing, socialClient.lastFriends)
}
case "following":
if socialClient.lastFollowing == nil || socialClient.lastFollowing.GetUserId() != 42 {
@ -1524,12 +1599,12 @@ func TestListRoomFeedsRelationCursorContinuesUserSocialScan(t *testing.T) {
handler.SetRoomQueryClient(queryClient)
handler.SetUserSocialClient(socialClient)
router := handler.Routes(auth.NewVerifier("secret"))
cursorPayload, _ := json.Marshal(roomFeedRelationCursor{
Tab: "following",
Query: "music",
UpdatedAtMS: 8000,
SubjectUserID: 10002,
RoomID: "room-feed",
cursorPayload, _ := json.Marshal(map[string]any{
"tab": "following",
"query": "music",
"updated_at_ms": int64(8000),
"subject_user_id": int64(10002),
"room_id": "room-feed",
})
cursor := base64.RawURLEncoding.EncodeToString(cursorPayload)
request := httptest.NewRequest(http.MethodGet, "/api/v1/rooms/feeds?tab=following&q=music&cursor="+cursor, nil)
@ -1546,6 +1621,52 @@ func TestListRoomFeedsRelationCursorContinuesUserSocialScan(t *testing.T) {
}
}
func TestRoomFollowActionsUseAuthenticatedUser(t *testing.T) {
for _, test := range []struct {
name string
method string
}{
{name: "follow", method: http.MethodPost},
{name: "unfollow", method: http.MethodDelete},
} {
t.Run(test.name, func(t *testing.T) {
roomClient := &fakeRoomClient{}
router := NewHandler(roomClient).Routes(auth.NewVerifier("secret"))
request := httptest.NewRequest(test.method, "/api/v1/rooms/room-follow/follow", nil)
request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
request.Header.Set("X-Request-ID", "req-room-"+test.name)
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 room follow response failed: %v", err)
}
data := response.Data.(map[string]any)
switch test.method {
case http.MethodPost:
if roomClient.lastFollowRoom == nil || roomClient.lastFollowRoom.GetUserId() != 42 || roomClient.lastFollowRoom.GetRoomId() != "room-follow" {
t.Fatalf("follow room request must use authenticated user and path room: %+v", roomClient.lastFollowRoom)
}
if data["is_followed"] != true || data["room_id"] != "room-follow" {
t.Fatalf("follow response mismatch: %+v", data)
}
case http.MethodDelete:
if roomClient.lastUnfollowRoom == nil || roomClient.lastUnfollowRoom.GetUserId() != 42 || roomClient.lastUnfollowRoom.GetRoomId() != "room-follow" {
t.Fatalf("unfollow room request must use authenticated user and path room: %+v", roomClient.lastUnfollowRoom)
}
if data["is_followed"] != false || data["room_id"] != "room-follow" {
t.Fatalf("unfollow response mismatch: %+v", data)
}
}
})
}
}
func TestGetMyRoomUsesAuthenticatedOwnerAndReturnsIMGroup(t *testing.T) {
queryClient := &fakeRoomQueryClient{myRoomResp: &roomv1.GetMyRoomResponse{
HasRoom: true,
@ -1755,6 +1876,52 @@ func TestGetRoomSnapshotUsesAuthenticatedUser(t *testing.T) {
if !ok || len(seats) != 2 {
t.Fatalf("snapshot DTO must include seat list: %+v", data["seats"])
}
if data["is_followed"] != false {
t.Fatalf("snapshot DTO must expose is_followed=false explicitly: %+v", data)
}
}
func TestGetRoomDetailIncludesFollowState(t *testing.T) {
queryClient := &fakeRoomQueryClient{snapshotResp: &roomv1.GetRoomSnapshotResponse{
Room: &roomv1.RoomSnapshot{
RoomId: "room-detail",
OwnerUserId: 1001,
Status: "active",
ChatEnabled: true,
Version: 9,
MicSeats: []*roomv1.SeatState{{SeatNo: 1, UserId: 42}},
OnlineUsers: []*roomv1.RoomUser{
{UserId: 42, Role: "audience", JoinedAtMs: 1000, LastSeenAtMs: 2000},
},
RoomExt: map[string]string{"title": "Detail Room"},
},
ServerTimeMs: 123456,
IsFollowed: true,
}}
handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{})
handler.SetRoomQueryClient(queryClient)
router := handler.Routes(auth.NewVerifier("secret"))
request := httptest.NewRequest(http.MethodGet, "/api/v1/rooms/room-detail/detail", nil)
request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
request.Header.Set("X-Request-ID", "req-room-detail")
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK {
t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String())
}
if queryClient.lastSnapshot == nil || queryClient.lastSnapshot.GetViewerUserId() != 42 || queryClient.lastSnapshot.GetRoomId() != "room-detail" {
t.Fatalf("detail must read snapshot with authenticated viewer: %+v", queryClient.lastSnapshot)
}
var response httpkit.ResponseEnvelope
if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil {
t.Fatalf("decode detail response failed: %v", err)
}
data := response.Data.(map[string]any)
if data["is_followed"] != true {
t.Fatalf("detail response must expose room follow state: %+v", data)
}
}
func TestGetRoomSnapshotRejectsInvalidRoomIDBeforeGRPC(t *testing.T) {
@ -3481,9 +3648,9 @@ func TestTencentIMUserSigUsesAuthenticatedUserIDAndFailsClosed(t *testing.T) {
}
func TestTencentRTCTokenUsesPresenceGuardAndInternalUserID(t *testing.T) {
previousNow := timeNow
timeNow = func() time.Time { return time.Unix(1_700_000_000, 0) }
defer func() { timeNow = previousNow }()
previousNow := roomapi.TimeNow
roomapi.TimeNow = func() time.Time { return time.Unix(1_700_000_000, 0) }
defer func() { roomapi.TimeNow = previousNow }()
guard := &fakeRoomGuardClient{presenceResp: &roomv1.VerifyRoomPresenceResponse{Present: true, RoomVersion: 9}}
handler := NewHandlerWithConfig(&fakeRoomClient{}, guard, nil, nil, TencentIMConfig{})
@ -3856,7 +4023,11 @@ func assertTencentCallback(t *testing.T, recorder *httptest.ResponseRecorder, st
t.Fatalf("status mismatch: got %d want %d body=%s", recorder.Code, statusCode, recorder.Body.String())
}
var response tencentIMCallbackResponse
var response struct {
ActionStatus string `json:"ActionStatus"`
ErrorInfo string `json:"ErrorInfo"`
ErrorCode int `json:"ErrorCode"`
}
if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil {
t.Fatalf("decode tencent callback response failed: %v", err)
}
@ -3872,7 +4043,10 @@ func assertTencentRTCCallback(t *testing.T, recorder *httptest.ResponseRecorder,
t.Fatalf("status mismatch: got %d want %d body=%s", recorder.Code, statusCode, recorder.Body.String())
}
var response tencentRTCCallbackResponse
var response struct {
Code int `json:"code"`
Message string `json:"message,omitempty"`
}
if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil {
t.Fatalf("decode tencent rtc callback response failed: %v", err)
}

View File

@ -0,0 +1,97 @@
package roomapi
import walletv1 "hyapp.local/api/proto/wallet/v1"
type resourceData struct {
ResourceID int64 `json:"resource_id"`
ResourceCode string `json:"resource_code"`
ResourceType string `json:"resource_type"`
Name string `json:"name"`
Status string `json:"status"`
Grantable bool `json:"grantable"`
GrantStrategy string `json:"grant_strategy"`
WalletAssetType string `json:"wallet_asset_type,omitempty"`
WalletAssetAmount int64 `json:"wallet_asset_amount,omitempty"`
UsageScopes []string `json:"usage_scopes"`
AssetURL string `json:"asset_url"`
PreviewURL string `json:"preview_url"`
AnimationURL string `json:"animation_url"`
MetadataJSON string `json:"metadata_json"`
SortOrder int32 `json:"sort_order"`
CreatedAtMS int64 `json:"created_at_ms"`
UpdatedAtMS int64 `json:"updated_at_ms"`
}
type giftConfigData struct {
GiftID string `json:"gift_id"`
ResourceID int64 `json:"resource_id"`
Resource resourceData `json:"resource"`
Status string `json:"status"`
Name string `json:"name"`
SortOrder int32 `json:"sort_order"`
PresentationJSON string `json:"presentation_json"`
PriceVersion string `json:"price_version"`
GiftTypeCode string `json:"gift_type_code"`
ChargeAssetType string `json:"charge_asset_type"`
CoinPrice int64 `json:"coin_price"`
GiftPointAmount int64 `json:"gift_point_amount"`
HeatValue int64 `json:"heat_value"`
EffectiveFromMS int64 `json:"effective_from_ms"`
EffectiveToMS int64 `json:"effective_to_ms"`
EffectTypes []string `json:"effect_types"`
CreatedAtMS int64 `json:"created_at_ms"`
UpdatedAtMS int64 `json:"updated_at_ms"`
RegionIDs []int64 `json:"region_ids"`
}
func giftFromProto(gift *walletv1.GiftConfig) giftConfigData {
if gift == nil {
return giftConfigData{}
}
return giftConfigData{
GiftID: gift.GetGiftId(),
ResourceID: gift.GetResourceId(),
Resource: resourceFromProto(gift.GetResource()),
Status: gift.GetStatus(),
Name: gift.GetName(),
SortOrder: gift.GetSortOrder(),
PresentationJSON: gift.GetPresentationJson(),
PriceVersion: gift.GetPriceVersion(),
GiftTypeCode: gift.GetGiftTypeCode(),
ChargeAssetType: gift.GetChargeAssetType(),
CoinPrice: gift.GetCoinPrice(),
GiftPointAmount: gift.GetGiftPointAmount(),
HeatValue: gift.GetHeatValue(),
EffectiveFromMS: gift.GetEffectiveFromMs(),
EffectiveToMS: gift.GetEffectiveToMs(),
EffectTypes: gift.GetEffectTypes(),
CreatedAtMS: gift.GetCreatedAtMs(),
UpdatedAtMS: gift.GetUpdatedAtMs(),
RegionIDs: gift.GetRegionIds(),
}
}
func resourceFromProto(item *walletv1.Resource) resourceData {
if item == nil {
return resourceData{}
}
return resourceData{
ResourceID: item.GetResourceId(),
ResourceCode: item.GetResourceCode(),
ResourceType: item.GetResourceType(),
Name: item.GetName(),
Status: item.GetStatus(),
Grantable: item.GetGrantable(),
GrantStrategy: item.GetGrantStrategy(),
WalletAssetType: item.GetWalletAssetType(),
WalletAssetAmount: item.GetWalletAssetAmount(),
UsageScopes: item.GetUsageScopes(),
AssetURL: item.GetAssetUrl(),
PreviewURL: item.GetPreviewUrl(),
AnimationURL: item.GetAnimationUrl(),
MetadataJSON: item.GetMetadataJson(),
SortOrder: item.GetSortOrder(),
CreatedAtMS: item.GetCreatedAtMs(),
UpdatedAtMS: item.GetUpdatedAtMs(),
}
}

View File

@ -0,0 +1,84 @@
package roomapi
import (
"net/http"
"hyapp/pkg/tencentrtc"
"hyapp/services/gateway-service/internal/client"
"hyapp/services/gateway-service/internal/transport/http/httproutes"
)
// Handler owns room-facing HTTP endpoints in gateway.
// It keeps gateway as protocol adapter only: room state still belongs to room-service.
type Handler struct {
roomClient client.RoomClient
roomGuardClient client.RoomGuardClient
roomQueryClient client.RoomQueryClient
userProfileClient client.UserProfileClient
userSocialClient client.UserSocialClient
walletClient client.WalletClient
rtcTokenConfig tencentrtc.TokenConfig
}
type Config struct {
RoomClient client.RoomClient
RoomGuardClient client.RoomGuardClient
RoomQueryClient client.RoomQueryClient
UserProfileClient client.UserProfileClient
UserSocialClient client.UserSocialClient
WalletClient client.WalletClient
RTCTokenConfig tencentrtc.TokenConfig
}
func New(config Config) *Handler {
return &Handler{
roomClient: config.RoomClient,
roomGuardClient: config.RoomGuardClient,
roomQueryClient: config.RoomQueryClient,
userProfileClient: config.UserProfileClient,
userSocialClient: config.UserSocialClient,
walletClient: config.WalletClient,
rtcTokenConfig: config.RTCTokenConfig,
}
}
func (h *Handler) RoomHandlers() httproutes.RoomHandlers {
return httproutes.RoomHandlers{
GetMyRoom: h.getMyRoom,
ListRoomFeeds: h.listRoomFeeds,
ListRooms: h.listRooms,
GetCurrentRoom: h.getCurrentRoom,
GetRoomSnapshot: h.getRoomSnapshot,
GetRoomDetail: h.getRoomDetail,
ListRoomOnlineUsers: h.listRoomOnlineUsers,
GetRoomGiftPanel: h.getRoomGiftPanel,
FollowRoom: h.handleRoomFollow,
CreateRoom: h.createRoom,
UpdateRoomProfile: h.updateRoomProfile,
JoinRoom: h.joinRoom,
RoomHeartbeat: h.roomHeartbeat,
LeaveRoom: h.leaveRoom,
CloseRoom: h.closeRoom,
MicUp: h.micUp,
MicDown: h.micDown,
ChangeMicSeat: h.changeMicSeat,
ConfirmMicPublishing: h.confirmMicPublishing,
SetMicMute: h.setMicMute,
SetMicSeatLock: h.setMicSeatLock,
SetChatEnabled: h.setChatEnabled,
SetRoomPassword: h.setRoomPassword,
SetRoomAdmin: h.setRoomAdmin,
MuteUser: h.muteUser,
KickUser: h.kickUser,
UnbanUser: h.unbanUser,
SendGift: h.sendGift,
}
}
func (h *Handler) IssueTencentRTCToken(writer http.ResponseWriter, request *http.Request) {
h.issueTencentRTCToken(writer, request)
}
func (h *Handler) BatchRoomDisplayProfiles(writer http.ResponseWriter, request *http.Request) {
h.batchRoomDisplayProfiles(writer, request)
}

View File

@ -1,4 +1,4 @@
package http
package roomapi
import (
"encoding/base64"
@ -8,13 +8,14 @@ import (
"strconv"
"strings"
roomv1 "hyapp.local/api/proto/room/v1"
userv1 "hyapp.local/api/proto/user/v1"
walletv1 "hyapp.local/api/proto/wallet/v1"
"hyapp/pkg/appcode"
"hyapp/pkg/roomid"
"hyapp/pkg/xerr"
"hyapp/services/gateway-service/internal/auth"
roomv1 "hyapp.local/api/proto/room/v1"
userv1 "hyapp.local/api/proto/user/v1"
walletv1 "hyapp.local/api/proto/wallet/v1"
)
const (
@ -51,7 +52,7 @@ func (h *Handler) listRooms(writer http.ResponseWriter, request *http.Request) {
viewerUserID := auth.UserIDFromContext(request.Context())
userResp, err := h.userProfileClient.GetUser(request.Context(), &userv1.GetUserRequest{
Meta: authRequestMeta(request, ""),
Meta: httpkit.UserMeta(request, ""),
UserId: viewerUserID,
})
if err != nil {
@ -61,7 +62,7 @@ func (h *Handler) listRooms(writer http.ResponseWriter, request *http.Request) {
}
resp, err := h.roomQueryClient.ListRooms(request.Context(), &roomv1.ListRoomsRequest{
Meta: meta(request, "", ""),
Meta: httpkit.RoomMeta(request, "", ""),
ViewerUserId: viewerUserID,
VisibleRegionId: userResp.GetUser().GetRegionId(),
Tab: tab,
@ -76,7 +77,7 @@ func (h *Handler) listRooms(writer http.ResponseWriter, request *http.Request) {
httpkit.WriteOK(writer, request, roomListDataFromProto(resp))
}
// listRoomFeeds 查询 Mine 页 visited/friend/following 用户房间流。
// listRoomFeeds 查询 Mine 页 visited/friend/following/followed 房间流。
func (h *Handler) listRoomFeeds(writer http.ResponseWriter, request *http.Request) {
if h.userProfileClient == nil || h.roomQueryClient == nil {
// feed 同样需要 user-service 区域归属,避免跨区域露出用户关系流房间。
@ -97,7 +98,7 @@ func (h *Handler) listRoomFeeds(writer http.ResponseWriter, request *http.Reques
viewerUserID := auth.UserIDFromContext(request.Context())
userResp, err := h.userProfileClient.GetUser(request.Context(), &userv1.GetUserRequest{
Meta: authRequestMeta(request, ""),
Meta: httpkit.UserMeta(request, ""),
UserId: viewerUserID,
})
if err != nil {
@ -116,7 +117,7 @@ func (h *Handler) listRoomFeeds(writer http.ResponseWriter, request *http.Reques
}
resp, err := h.roomQueryClient.ListRoomFeeds(request.Context(), &roomv1.ListRoomFeedsRequest{
Meta: meta(request, "", ""),
Meta: httpkit.RoomMeta(request, "", ""),
ViewerUserId: viewerUserID,
VisibleRegionId: userResp.GetUser().GetRegionId(),
Tab: tab,
@ -133,7 +134,7 @@ func (h *Handler) listRoomFeeds(writer http.ResponseWriter, request *http.Reques
}
func (h *Handler) roomFeedRelatedUsers(request *http.Request, tab string, query string, rawCursor string) ([]*roomv1.RoomFeedRelatedUser, bool, error) {
if tab == "visited" {
if tab == "visited" || tab == "followed" {
return nil, true, nil
}
if h.userSocialClient == nil {
@ -165,7 +166,7 @@ func (h *Handler) roomFeedRelatedUserBatch(request *http.Request, tab string, vi
switch tab {
case "following":
resp, err := h.userSocialClient.ListFollowing(request.Context(), &userv1.ListFollowingRequest{
Meta: authRequestMeta(request, ""),
Meta: httpkit.UserMeta(request, ""),
UserId: viewerUserID,
Page: 1,
PageSize: defaultRoomFeedRelationPageSize,
@ -187,7 +188,7 @@ func (h *Handler) roomFeedRelatedUserBatch(request *http.Request, tab string, vi
return relatedUsers, cursor, nil
case "friend":
resp, err := h.userSocialClient.ListFriends(request.Context(), &userv1.ListFriendsRequest{
Meta: authRequestMeta(request, ""),
Meta: httpkit.UserMeta(request, ""),
UserId: viewerUserID,
Page: 1,
PageSize: defaultRoomFeedRelationPageSize,
@ -250,6 +251,8 @@ func parseRoomFeedTab(raw string) (string, bool) {
return "friend", true
case "following":
return "following", true
case "followed":
return "followed", true
default:
return "", false
}
@ -288,7 +291,7 @@ func (h *Handler) getMyRoom(writer http.ResponseWriter, request *http.Request) {
}
resp, err := h.roomQueryClient.GetMyRoom(request.Context(), &roomv1.GetMyRoomRequest{
Meta: meta(request, "", ""),
Meta: httpkit.RoomMeta(request, "", ""),
OwnerUserId: auth.UserIDFromContext(request.Context()),
})
if err != nil {
@ -307,7 +310,7 @@ func (h *Handler) getCurrentRoom(writer http.ResponseWriter, request *http.Reque
}
resp, err := h.roomQueryClient.GetCurrentRoom(request.Context(), &roomv1.GetCurrentRoomRequest{
Meta: meta(request, "", ""),
Meta: httpkit.RoomMeta(request, "", ""),
UserId: auth.UserIDFromContext(request.Context()),
})
if err != nil {
@ -363,11 +366,11 @@ func (h *Handler) getRoomSnapshot(writer http.ResponseWriter, request *http.Requ
}
resp, err := h.roomQueryClient.GetRoomSnapshot(request.Context(), &roomv1.GetRoomSnapshotRequest{
Meta: meta(request, roomID, ""),
Meta: httpkit.RoomMeta(request, roomID, ""),
RoomId: roomID,
ViewerUserId: auth.UserIDFromContext(request.Context()),
})
write(writer, request, roomSnapshotDataFromProto(resp), err)
httpkit.Write(writer, request, roomSnapshotDataFromProto(resp), err)
}
// getRoomDetail 返回语音房详情页首屏可直接渲染的数据。
@ -385,7 +388,7 @@ func (h *Handler) getRoomDetail(writer http.ResponseWriter, request *http.Reques
viewerUserID := auth.UserIDFromContext(request.Context())
resp, err := h.roomQueryClient.GetRoomSnapshot(request.Context(), &roomv1.GetRoomSnapshotRequest{
Meta: meta(request, roomID, ""),
Meta: httpkit.RoomMeta(request, roomID, ""),
RoomId: roomID,
ViewerUserId: viewerUserID,
})
@ -408,19 +411,19 @@ func (h *Handler) listRoomOnlineUsers(writer http.ResponseWriter, request *http.
httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
return
}
page, ok := parsePositiveInt32Query(request, "page", 1)
page, ok := httpkit.PositiveInt32Query(request, "page", 1)
if !ok {
httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
return
}
pageSize, ok := parsePositiveInt32Query(request, "page_size", 50)
pageSize, ok := httpkit.PositiveInt32Query(request, "page_size", 50)
if !ok {
httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
return
}
resp, err := h.roomQueryClient.ListRoomOnlineUsers(request.Context(), &roomv1.ListRoomOnlineUsersRequest{
Meta: meta(request, roomID, ""),
Meta: httpkit.RoomMeta(request, roomID, ""),
RoomId: roomID,
ViewerUserId: auth.UserIDFromContext(request.Context()),
Page: page,
@ -451,7 +454,7 @@ func (h *Handler) getRoomGiftPanel(writer http.ResponseWriter, request *http.Req
viewerUserID := auth.UserIDFromContext(request.Context())
snapshotResp, err := h.roomQueryClient.GetRoomSnapshot(request.Context(), &roomv1.GetRoomSnapshotRequest{
Meta: meta(request, roomID, ""),
Meta: httpkit.RoomMeta(request, roomID, ""),
RoomId: roomID,
ViewerUserId: viewerUserID,
})
@ -497,6 +500,48 @@ func (h *Handler) getRoomGiftPanel(writer http.ResponseWriter, request *http.Req
})
}
// handleRoomFollow 建立或取消当前用户对房间的关注关系。
// 关注关系由 room-service 保存gateway 只负责鉴权用户和 path room_id 的协议转换。
func (h *Handler) handleRoomFollow(writer http.ResponseWriter, request *http.Request) {
if h.roomClient == nil {
httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
return
}
roomID := strings.TrimSpace(request.PathValue("room_id"))
if !roomid.ValidStringID(roomID) {
httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
return
}
viewerUserID := auth.UserIDFromContext(request.Context())
switch request.Method {
case http.MethodPost:
resp, err := h.roomClient.FollowRoom(request.Context(), &roomv1.FollowRoomRequest{
Meta: httpkit.RoomMeta(request, roomID, ""),
RoomId: roomID,
UserId: viewerUserID,
})
if err != nil {
httpkit.WriteRPCError(writer, request, err)
return
}
httpkit.WriteOK(writer, request, roomFollowDataFromProto(resp))
case http.MethodDelete:
resp, err := h.roomClient.UnfollowRoom(request.Context(), &roomv1.UnfollowRoomRequest{
Meta: httpkit.RoomMeta(request, roomID, ""),
RoomId: roomID,
UserId: viewerUserID,
})
if err != nil {
httpkit.WriteRPCError(writer, request, err)
return
}
httpkit.WriteOK(writer, request, roomUnfollowDataFromProto(resp))
default:
httpkit.WriteError(writer, request, http.StatusMethodNotAllowed, httpkit.CodeInvalidArgument, "invalid argument")
}
}
// createRoom 把创建房间 HTTP JSON 请求转换为 room-service CreateRoom 命令。
func (h *Handler) createRoom(writer http.ResponseWriter, request *http.Request) {
var body struct {
@ -508,7 +553,7 @@ func (h *Handler) createRoom(writer http.ResponseWriter, request *http.Request)
RoomDescription string `json:"room_description"`
}
if !decode(writer, request, &body) {
if !httpkit.Decode(writer, request, &body) {
return
}
body.CommandID = strings.TrimSpace(body.CommandID)
@ -527,7 +572,7 @@ func (h *Handler) createRoom(writer http.ResponseWriter, request *http.Request)
return
}
userResp, err := h.userProfileClient.GetUser(request.Context(), &userv1.GetUserRequest{
Meta: authRequestMeta(request, ""),
Meta: httpkit.UserMeta(request, ""),
UserId: auth.UserIDFromContext(request.Context()),
})
if err != nil {
@ -538,7 +583,7 @@ func (h *Handler) createRoom(writer http.ResponseWriter, request *http.Request)
roomID := appcode.NewScopedID(appcode.FromContext(request.Context()))
roomShortID := userResp.GetUser().GetDisplayUserId()
resp, err := h.roomClient.CreateRoom(request.Context(), &roomv1.CreateRoomRequest{
Meta: meta(request, roomID, body.CommandID),
Meta: httpkit.RoomMeta(request, roomID, body.CommandID),
SeatCount: body.SeatCount,
Mode: body.Mode,
VisibleRegionId: userResp.GetUser().GetRegionId(),
@ -547,7 +592,7 @@ func (h *Handler) createRoom(writer http.ResponseWriter, request *http.Request)
RoomDescription: body.RoomDescription,
RoomShortId: roomShortID,
})
write(writer, request, resp, err)
httpkit.Write(writer, request, resp, err)
}
// updateRoomProfile 把 App 房间资料编辑请求转换为 room-service UpdateRoomProfile 命令。
@ -561,7 +606,7 @@ func (h *Handler) updateRoomProfile(writer http.ResponseWriter, request *http.Re
SeatCount *int32 `json:"seat_count"`
}
if !decode(writer, request, &body) {
if !httpkit.Decode(writer, request, &body) {
return
}
body.RoomID = strings.TrimSpace(body.RoomID)
@ -571,7 +616,7 @@ func (h *Handler) updateRoomProfile(writer http.ResponseWriter, request *http.Re
trimOptionalString(body.RoomDescription)
resp, err := h.roomClient.UpdateRoomProfile(request.Context(), &roomv1.UpdateRoomProfileRequest{
Meta: meta(request, body.RoomID, body.CommandID),
Meta: httpkit.RoomMeta(request, body.RoomID, body.CommandID),
RoomName: body.RoomName,
RoomAvatar: body.RoomAvatar,
RoomDescription: body.RoomDescription,
@ -597,15 +642,17 @@ func (h *Handler) joinRoom(writer http.ResponseWriter, request *http.Request) {
RoomID string `json:"room_id"`
CommandID string `json:"command_id"`
Role string `json:"role"`
Password string `json:"password"`
}
if !decode(writer, request, &body) {
if !httpkit.Decode(writer, request, &body) {
return
}
resp, err := h.roomClient.JoinRoom(request.Context(), &roomv1.JoinRoomRequest{
Meta: meta(request, body.RoomID, body.CommandID),
Role: body.Role,
Meta: httpkit.RoomMeta(request, body.RoomID, body.CommandID),
Role: body.Role,
Password: body.Password,
})
if err != nil {
httpkit.WriteRPCError(writer, request, err)
@ -662,7 +709,7 @@ func (h *Handler) roomInitialProfiles(request *http.Request, snapshot *roomv1.Ro
return nil
}
resp, err := h.userProfileClient.BatchGetUsers(request.Context(), &userv1.BatchGetUsersRequest{
Meta: authRequestMeta(request, ""),
Meta: httpkit.UserMeta(request, ""),
UserIds: userIDs,
})
if err != nil {
@ -693,14 +740,14 @@ func (h *Handler) roomHeartbeat(writer http.ResponseWriter, request *http.Reques
CommandID string `json:"command_id"`
}
if !decode(writer, request, &body) {
if !httpkit.Decode(writer, request, &body) {
return
}
resp, err := h.roomClient.RoomHeartbeat(request.Context(), &roomv1.RoomHeartbeatRequest{
Meta: meta(request, body.RoomID, body.CommandID),
Meta: httpkit.RoomMeta(request, body.RoomID, body.CommandID),
})
write(writer, request, resp, err)
httpkit.Write(writer, request, resp, err)
}
// leaveRoom 把退房 HTTP JSON 请求转换为 room-service LeaveRoom 命令。
@ -710,14 +757,14 @@ func (h *Handler) leaveRoom(writer http.ResponseWriter, request *http.Request) {
CommandID string `json:"command_id"`
}
if !decode(writer, request, &body) {
if !httpkit.Decode(writer, request, &body) {
return
}
resp, err := h.roomClient.LeaveRoom(request.Context(), &roomv1.LeaveRoomRequest{
Meta: meta(request, body.RoomID, body.CommandID),
Meta: httpkit.RoomMeta(request, body.RoomID, body.CommandID),
})
write(writer, request, resp, err)
httpkit.Write(writer, request, resp, err)
}
// closeRoom 是设计稿右上角电源按钮入口:这里只退出当前用户,不关闭房间生命周期。
@ -733,15 +780,15 @@ func (h *Handler) micUp(writer http.ResponseWriter, request *http.Request) {
SeatNo int32 `json:"seat_no"`
}
if !decode(writer, request, &body) {
if !httpkit.Decode(writer, request, &body) {
return
}
resp, err := h.roomClient.MicUp(request.Context(), &roomv1.MicUpRequest{
Meta: meta(request, body.RoomID, body.CommandID),
Meta: httpkit.RoomMeta(request, body.RoomID, body.CommandID),
SeatNo: body.SeatNo,
})
write(writer, request, resp, err)
httpkit.Write(writer, request, resp, err)
}
// micDown 把下麦 HTTP JSON 请求转换为 room-service MicDown 命令。
@ -753,16 +800,16 @@ func (h *Handler) micDown(writer http.ResponseWriter, request *http.Request) {
Reason string `json:"reason"`
}
if !decode(writer, request, &body) {
if !httpkit.Decode(writer, request, &body) {
return
}
resp, err := h.roomClient.MicDown(request.Context(), &roomv1.MicDownRequest{
Meta: meta(request, body.RoomID, body.CommandID),
Meta: httpkit.RoomMeta(request, body.RoomID, body.CommandID),
TargetUserId: body.TargetUserID,
Reason: body.Reason,
})
write(writer, request, resp, err)
httpkit.Write(writer, request, resp, err)
}
// changeMicSeat 把换麦位 HTTP JSON 请求转换为 room-service ChangeMicSeat 命令。
@ -774,16 +821,16 @@ func (h *Handler) changeMicSeat(writer http.ResponseWriter, request *http.Reques
SeatNo int32 `json:"seat_no"`
}
if !decode(writer, request, &body) {
if !httpkit.Decode(writer, request, &body) {
return
}
resp, err := h.roomClient.ChangeMicSeat(request.Context(), &roomv1.ChangeMicSeatRequest{
Meta: meta(request, body.RoomID, body.CommandID),
Meta: httpkit.RoomMeta(request, body.RoomID, body.CommandID),
TargetUserId: body.TargetUserID,
SeatNo: body.SeatNo,
})
write(writer, request, resp, err)
httpkit.Write(writer, request, resp, err)
}
// confirmMicPublishing 把客户端 SDK 发流成功回调转换为 room-service 发流确认命令。
@ -798,19 +845,19 @@ func (h *Handler) confirmMicPublishing(writer http.ResponseWriter, request *http
Source string `json:"source"`
}
if !decode(writer, request, &body) {
if !httpkit.Decode(writer, request, &body) {
return
}
resp, err := h.roomClient.ConfirmMicPublishing(request.Context(), &roomv1.ConfirmMicPublishingRequest{
Meta: meta(request, body.RoomID, body.CommandID),
Meta: httpkit.RoomMeta(request, body.RoomID, body.CommandID),
TargetUserId: body.TargetUserID,
MicSessionId: body.MicSessionID,
RoomVersion: body.RoomVersion,
EventTimeMs: body.EventTimeMS,
Source: body.Source,
})
write(writer, request, resp, err)
httpkit.Write(writer, request, resp, err)
}
// setMicMute 把麦克风静音 HTTP 请求转换为 room-service SetMicMute 命令。
@ -822,16 +869,16 @@ func (h *Handler) setMicMute(writer http.ResponseWriter, request *http.Request)
Muted bool `json:"muted"`
}
if !decode(writer, request, &body) {
if !httpkit.Decode(writer, request, &body) {
return
}
resp, err := h.roomClient.SetMicMute(request.Context(), &roomv1.SetMicMuteRequest{
Meta: meta(request, body.RoomID, body.CommandID),
Meta: httpkit.RoomMeta(request, body.RoomID, body.CommandID),
TargetUserId: body.TargetUserID,
Muted: body.Muted,
})
write(writer, request, resp, err)
httpkit.Write(writer, request, resp, err)
}
// setMicSeatLock 把锁麦 HTTP JSON 请求转换为 room-service SetMicSeatLock 命令。
@ -843,16 +890,16 @@ func (h *Handler) setMicSeatLock(writer http.ResponseWriter, request *http.Reque
Locked bool `json:"locked"`
}
if !decode(writer, request, &body) {
if !httpkit.Decode(writer, request, &body) {
return
}
resp, err := h.roomClient.SetMicSeatLock(request.Context(), &roomv1.SetMicSeatLockRequest{
Meta: meta(request, body.RoomID, body.CommandID),
Meta: httpkit.RoomMeta(request, body.RoomID, body.CommandID),
SeatNo: body.SeatNo,
Locked: body.Locked,
})
write(writer, request, resp, err)
httpkit.Write(writer, request, resp, err)
}
// setChatEnabled 把公屏开关 HTTP JSON 请求转换为 room-service SetChatEnabled 命令。
@ -863,15 +910,46 @@ func (h *Handler) setChatEnabled(writer http.ResponseWriter, request *http.Reque
Enabled bool `json:"enabled"`
}
if !decode(writer, request, &body) {
if !httpkit.Decode(writer, request, &body) {
return
}
resp, err := h.roomClient.SetChatEnabled(request.Context(), &roomv1.SetChatEnabledRequest{
Meta: meta(request, body.RoomID, body.CommandID),
Meta: httpkit.RoomMeta(request, body.RoomID, body.CommandID),
Enabled: body.Enabled,
})
write(writer, request, resp, err)
httpkit.Write(writer, request, resp, err)
}
// setRoomPassword 把房主锁房 HTTP JSON 请求转换为 room-service SetRoomPassword 命令。
func (h *Handler) setRoomPassword(writer http.ResponseWriter, request *http.Request) {
var body struct {
RoomID string `json:"room_id"`
CommandID string `json:"command_id"`
Locked bool `json:"locked"`
Password string `json:"password"`
}
if !httpkit.Decode(writer, request, &body) {
return
}
body.RoomID = strings.TrimSpace(body.RoomID)
body.CommandID = strings.TrimSpace(body.CommandID)
body.Password = strings.TrimSpace(body.Password)
resp, err := h.roomClient.SetRoomPassword(request.Context(), &roomv1.SetRoomPasswordRequest{
Meta: httpkit.RoomMeta(request, body.RoomID, body.CommandID),
Locked: body.Locked,
Password: body.Password,
})
if err != nil {
httpkit.WriteRPCError(writer, request, err)
return
}
httpkit.WriteOK(writer, request, updateRoomProfileDataFromProto(&roomv1.UpdateRoomProfileResponse{
Result: resp.GetResult(),
Room: resp.GetRoom(),
}))
}
// setRoomAdmin 把管理员增删 HTTP JSON 请求转换为 room-service SetRoomAdmin 命令。
@ -883,16 +961,16 @@ func (h *Handler) setRoomAdmin(writer http.ResponseWriter, request *http.Request
Enabled bool `json:"enabled"`
}
if !decode(writer, request, &body) {
if !httpkit.Decode(writer, request, &body) {
return
}
resp, err := h.roomClient.SetRoomAdmin(request.Context(), &roomv1.SetRoomAdminRequest{
Meta: meta(request, body.RoomID, body.CommandID),
Meta: httpkit.RoomMeta(request, body.RoomID, body.CommandID),
TargetUserId: body.TargetUserID,
Enabled: body.Enabled,
})
write(writer, request, resp, err)
httpkit.Write(writer, request, resp, err)
}
// muteUser 把禁言 HTTP JSON 请求转换为 room-service MuteUser 命令。
@ -904,16 +982,16 @@ func (h *Handler) muteUser(writer http.ResponseWriter, request *http.Request) {
Muted bool `json:"muted"`
}
if !decode(writer, request, &body) {
if !httpkit.Decode(writer, request, &body) {
return
}
resp, err := h.roomClient.MuteUser(request.Context(), &roomv1.MuteUserRequest{
Meta: meta(request, body.RoomID, body.CommandID),
Meta: httpkit.RoomMeta(request, body.RoomID, body.CommandID),
TargetUserId: body.TargetUserID,
Muted: body.Muted,
})
write(writer, request, resp, err)
httpkit.Write(writer, request, resp, err)
}
// kickUser 把踢人 HTTP JSON 请求转换为 room-service KickUser 命令。
@ -924,15 +1002,15 @@ func (h *Handler) kickUser(writer http.ResponseWriter, request *http.Request) {
TargetUserID int64 `json:"target_user_id"`
}
if !decode(writer, request, &body) {
if !httpkit.Decode(writer, request, &body) {
return
}
resp, err := h.roomClient.KickUser(request.Context(), &roomv1.KickUserRequest{
Meta: meta(request, body.RoomID, body.CommandID),
Meta: httpkit.RoomMeta(request, body.RoomID, body.CommandID),
TargetUserId: body.TargetUserID,
})
write(writer, request, resp, err)
httpkit.Write(writer, request, resp, err)
}
// unbanUser 把解封 HTTP JSON 请求转换为 room-service UnbanUser 命令。
@ -943,15 +1021,15 @@ func (h *Handler) unbanUser(writer http.ResponseWriter, request *http.Request) {
TargetUserID int64 `json:"target_user_id"`
}
if !decode(writer, request, &body) {
if !httpkit.Decode(writer, request, &body) {
return
}
resp, err := h.roomClient.UnbanUser(request.Context(), &roomv1.UnbanUserRequest{
Meta: meta(request, body.RoomID, body.CommandID),
Meta: httpkit.RoomMeta(request, body.RoomID, body.CommandID),
TargetUserId: body.TargetUserID,
})
write(writer, request, resp, err)
httpkit.Write(writer, request, resp, err)
}
// sendGift 把送礼 HTTP JSON 请求转换为 room-service SendGift 命令。
@ -967,7 +1045,7 @@ func (h *Handler) sendGift(writer http.ResponseWriter, request *http.Request) {
GiftCount int32 `json:"gift_count"`
}
if !decode(writer, request, &body) {
if !httpkit.Decode(writer, request, &body) {
return
}
targetType := strings.TrimSpace(body.TargetType)
@ -981,14 +1059,14 @@ func (h *Handler) sendGift(writer http.ResponseWriter, request *http.Request) {
}
resp, err := h.roomClient.SendGift(request.Context(), &roomv1.SendGiftRequest{
Meta: meta(request, body.RoomID, body.CommandID),
Meta: httpkit.RoomMeta(request, body.RoomID, body.CommandID),
TargetType: targetType,
TargetUserIds: targetUserIDs,
TargetUserId: firstUserID(targetUserIDs),
GiftId: body.GiftID,
GiftCount: body.GiftCount,
})
write(writer, request, resp, err)
httpkit.Write(writer, request, resp, err)
}
// batchRoomDisplayProfiles 返回房间和公屏专用展示资料。
@ -1005,6 +1083,31 @@ func (h *Handler) batchRoomDisplayProfiles(writer http.ResponseWriter, request *
httpkit.WriteOK(writer, request, map[string]any{"profiles": h.roomDisplayProfiles(request, userIDs)})
}
func parseBatchUserIDs(request *http.Request) ([]int64, bool) {
rawValues := append([]string(nil), request.URL.Query()["user_ids"]...)
rawValues = append(rawValues, request.URL.Query()["user_id"]...)
seen := make(map[int64]bool)
result := make([]int64, 0, len(rawValues))
for _, raw := range rawValues {
for piece := range strings.SplitSeq(raw, ",") {
piece = strings.TrimSpace(piece)
if piece == "" {
continue
}
userID, err := strconv.ParseInt(piece, 10, 64)
if err != nil || userID <= 0 {
return nil, false
}
if seen[userID] {
continue
}
seen[userID] = true
result = append(result, userID)
}
}
return result, len(result) > 0
}
func (h *Handler) roomDisplayProfiles(request *http.Request, userIDs []int64) []roomDisplayProfileData {
profileMap := h.roomDisplayProfileMap(request, userIDs)
profiles := make([]roomDisplayProfileData, 0, len(userIDs))
@ -1025,7 +1128,7 @@ func (h *Handler) roomDisplayProfileMap(request *http.Request, userIDs []int64)
return nil
}
resp, err := h.userProfileClient.BatchGetUsers(request.Context(), &userv1.BatchGetUsersRequest{
Meta: authRequestMeta(request, ""),
Meta: httpkit.UserMeta(request, ""),
UserIds: userIDs,
})
if err != nil {

View File

@ -1,4 +1,4 @@
package http
package roomapi
import (
"strconv"
@ -29,6 +29,7 @@ type roomListItemData struct {
CoverURL string `json:"cover_url,omitempty"`
Mode string `json:"mode,omitempty"`
Status string `json:"status,omitempty"`
Locked bool `json:"locked"`
Heat int64 `json:"heat"`
OnlineCount int32 `json:"online_count"`
SeatCount int32 `json:"seat_count"`
@ -53,6 +54,7 @@ type joinRoomData struct {
type roomDetailData struct {
Room roomInitialData `json:"room"`
Viewer roomViewerData `json:"viewer"`
IsFollowed bool `json:"is_followed"`
Seats []roomSeatData `json:"seats"`
RankTop []roomRankItemData `json:"rank_top"`
OnlineCount int32 `json:"online_count"`
@ -137,9 +139,17 @@ type roomSnapshotData struct {
Room roomInitialData `json:"room"`
Seats []roomSeatData `json:"seats"`
ContributionRank []roomRankItemData `json:"contribution_rank"`
IsFollowed bool `json:"is_followed"`
ServerTimeMS int64 `json:"server_time_ms"`
}
type roomFollowData struct {
RoomID string `json:"room_id"`
IsFollowed bool `json:"is_followed"`
FollowedAtMS int64 `json:"followed_at_ms,omitempty"`
ServerTimeMS int64 `json:"server_time_ms"`
}
type roomCommandResultData struct {
Applied bool `json:"applied"`
RoomVersion int64 `json:"room_version"`
@ -157,6 +167,7 @@ type roomInitialData struct {
Mode string `json:"mode,omitempty"`
Status string `json:"status,omitempty"`
ChatEnabled bool `json:"chat_enabled"`
Locked bool `json:"locked"`
Heat int64 `json:"heat"`
OnlineCount int32 `json:"online_count"`
SeatCount int32 `json:"seat_count"`
@ -244,6 +255,7 @@ func roomListItemDataFromProto(room *roomv1.RoomListItem) roomListItemData {
CoverURL: room.GetCoverUrl(),
Mode: room.GetMode(),
Status: room.GetStatus(),
Locked: room.GetLocked(),
Heat: room.GetHeat(),
OnlineCount: room.GetOnlineCount(),
SeatCount: room.GetSeatCount(),
@ -287,6 +299,7 @@ func roomSnapshotDataFromProto(resp *roomv1.GetRoomSnapshotResponse) roomSnapsho
Room: roomInitialRoomDataFromSnapshot(snapshot, snapshot.GetRoomId()),
Seats: roomSeatDataFromSnapshot(snapshot),
ContributionRank: roomRankDataFromSnapshot(snapshot, 0),
IsFollowed: resp.GetIsFollowed(),
ServerTimeMS: resp.GetServerTimeMs(),
}
}
@ -315,6 +328,7 @@ func roomInitialRoomDataFromSnapshot(snapshot *roomv1.RoomSnapshot, roomID strin
Mode: snapshot.GetMode(),
Status: snapshot.GetStatus(),
ChatEnabled: snapshot.GetChatEnabled(),
Locked: snapshot.GetLocked(),
Heat: snapshot.GetHeat(),
OnlineCount: int32(len(snapshot.GetOnlineUsers())),
SeatCount: seatCount,
@ -364,6 +378,7 @@ func roomDetailDataFromSnapshot(resp *roomv1.GetRoomSnapshotResponse, viewerUser
return roomDetailData{
Room: roomInitialRoomDataFromSnapshot(snapshot, roomID),
Viewer: roomViewerDataFromProto(viewer, viewerUserID),
IsFollowed: resp.GetIsFollowed(),
Seats: roomSeatDataFromSnapshot(snapshot),
RankTop: roomRankDataFromSnapshot(snapshot, joinRoomContributionRankLimit),
OnlineCount: int32(len(snapshot.GetOnlineUsers())),
@ -375,6 +390,29 @@ func roomDetailDataFromSnapshot(resp *roomv1.GetRoomSnapshotResponse, viewerUser
}
}
func roomFollowDataFromProto(resp *roomv1.FollowRoomResponse) roomFollowData {
if resp == nil {
return roomFollowData{}
}
return roomFollowData{
RoomID: resp.GetRoomId(),
IsFollowed: resp.GetFollowed(),
FollowedAtMS: resp.GetFollowedAtMs(),
ServerTimeMS: resp.GetServerTimeMs(),
}
}
func roomUnfollowDataFromProto(resp *roomv1.UnfollowRoomResponse) roomFollowData {
if resp == nil {
return roomFollowData{}
}
return roomFollowData{
RoomID: resp.GetRoomId(),
IsFollowed: resp.GetFollowed(),
ServerTimeMS: resp.GetServerTimeMs(),
}
}
func roomPermissionsFromSnapshot(snapshot *roomv1.RoomSnapshot, viewerUserID int64) roomPermissionsData {
if snapshot == nil || viewerUserID <= 0 {
return roomPermissionsData{}

View File

@ -1,4 +1,4 @@
package http
package roomapi
import (
"hyapp/services/gateway-service/internal/transport/http/httpkit"
@ -21,7 +21,7 @@ func (h *Handler) issueTencentRTCToken(writer http.ResponseWriter, request *http
RoomID string `json:"room_id"`
}
if !decode(writer, request, &body) {
if !httpkit.Decode(writer, request, &body) {
return
}
if !roomid.ValidStringID(body.RoomID) {
@ -105,24 +105,17 @@ func (h *Handler) writeRTCPresenceDenied(writer http.ResponseWriter, request *ht
httpkit.WriteError(writer, request, http.StatusForbidden, httpkit.CodePermissionDenied, "permission denied")
}
var timeNow = defaultTimeNow
var TimeNow = defaultTimeNow
func defaultTimeNow() time.Time {
return time.Now().UTC()
}
func (h *Handler) tencentRTCTokenConfig() tencentrtc.TokenConfig {
return tencentrtc.TokenConfig{
Enabled: h.tencentRTC.Enabled,
SDKAppID: h.tencentRTC.SDKAppID,
SecretKey: h.tencentRTC.SecretKey,
TTL: h.tencentRTC.UserSigTTL,
RoomIDType: h.tencentRTC.RoomIDType,
AppScene: h.tencentRTC.AppScene,
}
return h.rtcTokenConfig
}
func (h *Handler) generateTencentRTCToken(userID int64, roomID string) (tencentrtc.TokenResult, error) {
// JoinRoom 内嵌签发和独立 /rtc/token 使用同一套签名策略,避免两条入口出现 RTC 身份漂移。
return tencentrtc.GenerateToken(h.tencentRTCTokenConfig(), userID, roomID, timeNow())
return tencentrtc.GenerateToken(h.tencentRTCTokenConfig(), userID, roomID, TimeNow())
}

View File

@ -3,13 +3,102 @@ package http
import (
"net/http"
"hyapp/pkg/tencentrtc"
"hyapp/services/gateway-service/internal/auth"
"hyapp/services/gateway-service/internal/transport/http/activityapi"
"hyapp/services/gateway-service/internal/transport/http/appapi"
"hyapp/services/gateway-service/internal/transport/http/callbackapi"
"hyapp/services/gateway-service/internal/transport/http/gameapi"
"hyapp/services/gateway-service/internal/transport/http/httproutes"
"hyapp/services/gateway-service/internal/transport/http/managerapi"
"hyapp/services/gateway-service/internal/transport/http/messageapi"
"hyapp/services/gateway-service/internal/transport/http/resourceapi"
"hyapp/services/gateway-service/internal/transport/http/roomapi"
"hyapp/services/gateway-service/internal/transport/http/userapi"
"hyapp/services/gateway-service/internal/transport/http/walletapi"
)
// Routes 把 Handler 的私有方法适配成独立路由包需要的显式 handler 表。
// 路径注册集中在 httproutesgateway 依赖装配和鉴权 wrapper 仍留在 transport 入口层。
func (h *Handler) Routes(jwtVerifier *auth.Verifier) http.Handler {
roomAPI := roomapi.New(roomapi.Config{
RoomClient: h.roomClient,
RoomGuardClient: h.roomGuardClient,
RoomQueryClient: h.roomQueryClient,
UserProfileClient: h.userProfileClient,
UserSocialClient: h.userSocialClient,
WalletClient: h.walletClient,
RTCTokenConfig: tencentrtc.TokenConfig{
Enabled: h.tencentRTC.Enabled,
SDKAppID: h.tencentRTC.SDKAppID,
SecretKey: h.tencentRTC.SecretKey,
TTL: h.tencentRTC.UserSigTTL,
RoomIDType: h.tencentRTC.RoomIDType,
AppScene: h.tencentRTC.AppScene,
},
})
walletAPI := walletapi.New(walletapi.Config{
WalletClient: h.walletClient,
UserIdentityClient: h.userIdentityClient,
UserProfileClient: h.userProfileClient,
UserHostClient: h.userHostClient,
})
userAPI := userapi.New(userapi.Config{
UserIdentityClient: h.userIdentityClient,
UserProfileClient: h.userProfileClient,
UserSocialClient: h.userSocialClient,
UserCountryClient: h.userCountryClient,
UserHostClient: h.userHostClient,
WalletClient: h.walletClient,
BroadcastClient: h.broadcastClient,
})
messageAPI := messageapi.New(messageapi.Config{
MessageClient: h.messageClient,
UserProfileClient: h.userProfileClient,
})
activityAPI := activityapi.New(activityapi.Config{
TaskClient: h.taskClient,
RegistrationReward: h.registrationReward,
})
gameAPI := gameapi.New(gameapi.Config{
GameClient: h.gameClient,
UserProfileClient: h.userProfileClient,
})
resourceAPI := resourceapi.New(resourceapi.Config{
WalletClient: h.walletClient,
})
managerAPI := managerapi.New(managerapi.Config{
WalletClient: h.walletClient,
UserHostClient: h.userHostClient,
UserProfileClient: h.userProfileClient,
})
appAPI := appapi.New(appapi.Config{
AppConfigReader: h.appConfigReader,
UserDeviceClient: h.userDeviceClient,
ObjectUploader: h.objectUploader,
})
callbackAPI := callbackapi.New(callbackapi.Config{
RoomClient: h.roomClient,
RoomGuardClient: h.roomGuardClient,
UserProfileClient: h.userProfileClient,
TencentIM: callbackapi.TencentIMConfig{
SDKAppID: h.tencentIM.SDKAppID,
AdminIdentifier: h.tencentIM.AdminIdentifier,
CallbackAuthToken: h.tencentIM.CallbackAuthToken,
},
TencentRTC: callbackapi.TencentRTCConfig{
SDKAppID: h.tencentRTC.SDKAppID,
CallbackSignKey: h.tencentRTC.CallbackSignKey,
UserSigTTL: h.tencentRTC.UserSigTTL,
},
})
userHandlers := userAPI.UserHandlers()
userHandlers.BatchUserProfiles = messageAPI.BatchUserProfiles
userHandlers.BatchRoomDisplayProfiles = roomAPI.BatchRoomDisplayProfiles
userHandlers.GetMyGiftWall = walletAPI.GetMyGiftWall
userHandlers.ListMyResources = resourceAPI.ListMyResources
userHandlers.EquipMyResource = resourceAPI.EquipMyResource
return httproutes.New(httproutes.Config{
PublicWrap: h.publicAPIHandler,
AuthWrap: func(handler http.HandlerFunc) http.Handler {
@ -26,109 +115,29 @@ func (h *Handler) Routes(jwtVerifier *auth.Verifier) http.Handler {
Logout: h.logout,
},
App: httproutes.AppHandlers{
ListRegistrationCountries: h.listRegistrationCountries,
GetAppBootstrap: h.getAppBootstrap,
ListH5Links: h.listH5Links,
ListAppBanners: h.listAppBanners,
GetAppVersion: h.getAppVersion,
ListResources: h.listResources,
GetResourceGroup: h.getResourceGroup,
ListGifts: h.listGifts,
ListRegistrationCountries: userAPI.ListRegistrationCountries,
GetAppBootstrap: appAPI.GetAppBootstrap,
ListH5Links: appAPI.ListH5Links,
ListAppBanners: appAPI.ListAppBanners,
GetAppVersion: appAPI.GetAppVersion,
ListResources: resourceAPI.ListResources,
GetResourceGroup: resourceAPI.GetResourceGroup,
ListGifts: resourceAPI.ListGifts,
IssueTencentIMUserSig: h.issueTencentIMUserSig,
IssueTencentRTCToken: h.issueTencentRTCToken,
HandleTencentIMCallback: h.handleTencentIMCallback,
HandleTencentRTCCallback: h.handleTencentRTCCallback,
UploadFile: h.uploadFile,
UploadAvatar: h.uploadAvatar,
HandlePushToken: h.handlePushToken,
},
User: httproutes.UserHandlers{
ResolveDisplayUserID: h.resolveDisplayUserID,
BatchUserProfiles: h.batchUserProfiles,
BatchRoomDisplayProfiles: h.batchRoomDisplayProfiles,
GetMyOverview: h.getMyOverview,
GetMyGiftWall: h.getMyGiftWall,
GetMyIdentity: h.getMyIdentity,
GetMyHostIdentity: h.getMyHostIdentity,
GetMyRoleSummary: h.getMyRoleSummary,
CompleteMyOnboarding: h.completeMyOnboarding,
UpdateMyProfile: h.updateMyProfile,
ChangeMyCountry: h.changeMyCountry,
ChangeMyDisplayUserID: h.changeMyDisplayUserID,
ApplyMyPrettyDisplayUserID: h.applyMyPrettyDisplayUserID,
ListMyProfileVisitors: h.listMyProfileVisitors,
ListMyFollowing: h.listMyFollowing,
ListMyFriends: h.listMyFriends,
ListMyFriendApplications: h.listMyFriendApplications,
GetMyProfile: h.getMyProfile,
ListMyResources: h.listMyResources,
EquipMyResource: h.equipMyResource,
UserSocialAction: h.userSocialAction,
},
Manager: httproutes.ManagerHandlers{
ListManagerGrantResources: h.listManagerGrantResources,
GrantManagerResource: h.grantManagerResource,
LookupBusinessUsers: h.lookupBusinessUsers,
},
Room: httproutes.RoomHandlers{
GetMyRoom: h.getMyRoom,
ListRoomFeeds: h.listRoomFeeds,
ListRooms: h.listRooms,
GetCurrentRoom: h.getCurrentRoom,
GetRoomSnapshot: h.getRoomSnapshot,
GetRoomDetail: h.getRoomDetail,
ListRoomOnlineUsers: h.listRoomOnlineUsers,
GetRoomGiftPanel: h.getRoomGiftPanel,
CreateRoom: h.createRoom,
UpdateRoomProfile: h.updateRoomProfile,
JoinRoom: h.joinRoom,
RoomHeartbeat: h.roomHeartbeat,
LeaveRoom: h.leaveRoom,
CloseRoom: h.closeRoom,
MicUp: h.micUp,
MicDown: h.micDown,
ChangeMicSeat: h.changeMicSeat,
ConfirmMicPublishing: h.confirmMicPublishing,
SetMicMute: h.setMicMute,
SetMicSeatLock: h.setMicSeatLock,
SetChatEnabled: h.setChatEnabled,
SetRoomAdmin: h.setRoomAdmin,
MuteUser: h.muteUser,
KickUser: h.kickUser,
UnbanUser: h.unbanUser,
SendGift: h.sendGift,
},
Message: httproutes.MessageHandlers{
ListMessageTabs: h.listMessageTabs,
MarkInboxSectionRead: h.markInboxSectionRead,
MarkInboxMessageReadPath: h.markInboxMessageReadByPath,
DeleteInboxMessagePath: h.deleteInboxMessageByPath,
ListInboxMessages: h.listInboxMessages,
},
Task: httproutes.TaskHandlers{
ListTaskTabs: h.listTaskTabs,
ClaimTaskReward: h.claimTaskReward,
GetRegistrationRewardEligibility: h.getRegistrationRewardEligibility,
},
Wallet: httproutes.WalletHandlers{
GetWalletOverview: h.getWalletOverview,
GetMyBalances: h.getMyBalances,
ListRechargeProducts: h.listRechargeProducts,
GetDiamondExchangeConfig: h.getDiamondExchangeConfig,
ApplyWithdrawal: h.applyWithdrawal,
ListCoinTransactions: h.listCoinTransactions,
ListWalletTransactions: h.listWalletTransactions,
TransferCoinFromSeller: h.transferCoinFromSeller,
},
VIP: httproutes.VIPHandlers{
GetMyVIP: h.getMyVIP,
ListVIPPackages: h.listVIPPackages,
PurchaseVIP: h.purchaseVIP,
},
Game: httproutes.GameHandlers{
ListGames: h.listGames,
LaunchGame: h.launchGame,
HandleCallback: h.handleGameCallback,
IssueTencentRTCToken: roomAPI.IssueTencentRTCToken,
HandleTencentIMCallback: callbackAPI.HandleTencentIMCallback,
HandleTencentRTCCallback: callbackAPI.HandleTencentRTCCallback,
UploadFile: appAPI.UploadFile,
UploadAvatar: appAPI.UploadAvatar,
HandlePushToken: appAPI.HandlePushToken,
},
User: userHandlers,
Manager: managerAPI.ManagerHandlers(),
Room: roomAPI.RoomHandlers(),
Message: messageAPI.MessageHandlers(),
Task: activityAPI.TaskHandlers(),
Wallet: walletAPI.WalletHandlers(),
VIP: walletAPI.VIPHandlers(),
Game: gameAPI.Handlers(),
})
}

View File

@ -0,0 +1,45 @@
package userapi
import userv1 "hyapp.local/api/proto/user/v1"
// authTokenData 是完整资料补齐后返回给客户端替换 access token 的扁平格式。
// 字段保持和登录接口一致,避免客户端在 onboarding 后解析两套 token 结构。
type authTokenData struct {
AppCode string `json:"app_code"`
UserID string `json:"user_id,omitempty"`
DisplayUserID string `json:"display_user_id,omitempty"`
DefaultDisplayUserID string `json:"default_display_user_id,omitempty"`
DisplayUserIDKind string `json:"display_user_id_kind,omitempty"`
DisplayUserIDExpiresAtMs int64 `json:"display_user_id_expires_at_ms"`
SessionID string `json:"session_id"`
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token,omitempty"`
ExpiresInSec int64 `json:"expires_in_sec"`
TokenType string `json:"token_type"`
IsNewUser *bool `json:"is_new_user,omitempty"`
ProfileCompleted bool `json:"profile_completed"`
OnboardingStatus string `json:"onboarding_status,omitempty"`
}
func authData(token *userv1.AuthToken, isNewUser *bool) authTokenData {
if token == nil {
return authTokenData{IsNewUser: isNewUser}
}
return authTokenData{
UserID: userIDString(token.GetUserId()),
AppCode: token.GetAppCode(),
DisplayUserID: token.GetDisplayUserId(),
DefaultDisplayUserID: token.GetDefaultDisplayUserId(),
DisplayUserIDKind: token.GetDisplayUserIdKind(),
DisplayUserIDExpiresAtMs: token.GetDisplayUserIdExpiresAtMs(),
SessionID: token.GetSessionId(),
AccessToken: token.GetAccessToken(),
RefreshToken: token.GetRefreshToken(),
ExpiresInSec: token.GetExpiresInSec(),
TokenType: token.GetTokenType(),
IsNewUser: isNewUser,
ProfileCompleted: token.GetProfileCompleted(),
OnboardingStatus: token.GetOnboardingStatus(),
}
}

View File

@ -0,0 +1,67 @@
package userapi
import (
"net/http"
"hyapp/services/gateway-service/internal/client"
"hyapp/services/gateway-service/internal/transport/http/httproutes"
)
// Handler owns user-facing HTTP endpoints and relationship endpoints.
// It delegates user facts to user-service and only translates HTTP protocol shape.
type Handler struct {
userIdentityClient client.UserIdentityClient
userProfileClient client.UserProfileClient
userSocialClient client.UserSocialClient
userCountryClient client.UserCountryQueryClient
userHostClient client.UserHostClient
walletClient client.WalletClient
broadcastClient client.BroadcastClient
}
type Config struct {
UserIdentityClient client.UserIdentityClient
UserProfileClient client.UserProfileClient
UserSocialClient client.UserSocialClient
UserCountryClient client.UserCountryQueryClient
UserHostClient client.UserHostClient
WalletClient client.WalletClient
BroadcastClient client.BroadcastClient
}
func New(config Config) *Handler {
return &Handler{
userIdentityClient: config.UserIdentityClient,
userProfileClient: config.UserProfileClient,
userSocialClient: config.UserSocialClient,
userCountryClient: config.UserCountryClient,
userHostClient: config.UserHostClient,
walletClient: config.WalletClient,
broadcastClient: config.BroadcastClient,
}
}
func (h *Handler) UserHandlers() httproutes.UserHandlers {
return httproutes.UserHandlers{
ResolveDisplayUserID: h.resolveDisplayUserID,
GetMyOverview: h.getMyOverview,
GetMyIdentity: h.getMyIdentity,
GetMyHostIdentity: h.getMyHostIdentity,
GetMyRoleSummary: h.getMyRoleSummary,
CompleteMyOnboarding: h.completeMyOnboarding,
UpdateMyProfile: h.updateMyProfile,
ChangeMyCountry: h.changeMyCountry,
ChangeMyDisplayUserID: h.changeMyDisplayUserID,
ApplyMyPrettyDisplayUserID: h.applyMyPrettyDisplayUserID,
ListMyProfileVisitors: h.listMyProfileVisitors,
ListMyFollowing: h.listMyFollowing,
ListMyFriends: h.listMyFriends,
ListMyFriendApplications: h.listMyFriendApplications,
GetMyProfile: h.getMyProfile,
UserSocialAction: h.userSocialAction,
}
}
func (h *Handler) ListRegistrationCountries(writer http.ResponseWriter, request *http.Request) {
h.listRegistrationCountries(writer, request)
}

View File

@ -1,4 +1,4 @@
package http
package userapi
import (
"hyapp/services/gateway-service/internal/transport/http/httpkit"
@ -37,7 +37,7 @@ func (h *Handler) getMyHostIdentity(writer http.ResponseWriter, request *http.Re
identity := userHostIdentityData{}
hostResp, err := h.userHostClient.GetHostProfile(request.Context(), &userv1.GetHostProfileRequest{
Meta: authRequestMeta(request, ""),
Meta: httpkit.UserMeta(request, ""),
UserId: userID,
})
if err != nil {
@ -50,7 +50,7 @@ func (h *Handler) getMyHostIdentity(writer http.ResponseWriter, request *http.Re
}
bdResp, err := h.userHostClient.GetBDProfile(request.Context(), &userv1.GetBDProfileRequest{
Meta: authRequestMeta(request, ""),
Meta: httpkit.UserMeta(request, ""),
UserId: userID,
})
if err != nil {
@ -63,7 +63,7 @@ func (h *Handler) getMyHostIdentity(writer http.ResponseWriter, request *http.Re
}
coinSellerResp, err := h.userHostClient.GetCoinSellerProfile(request.Context(), &userv1.GetCoinSellerProfileRequest{
Meta: authRequestMeta(request, ""),
Meta: httpkit.UserMeta(request, ""),
UserId: userID,
})
if err != nil {
@ -85,7 +85,7 @@ func (h *Handler) getMyRoleSummary(writer http.ResponseWriter, request *http.Req
return
}
resp, err := h.userHostClient.GetUserRoleSummary(request.Context(), &userv1.GetUserRoleSummaryRequest{
Meta: authRequestMeta(request, ""),
Meta: httpkit.UserMeta(request, ""),
UserId: auth.UserIDFromContext(request.Context()),
})
if err != nil {

View File

@ -1,4 +1,4 @@
package http
package userapi
import (
"context"
@ -91,7 +91,7 @@ func (h *Handler) getMyOverview(writer http.ResponseWriter, request *http.Reques
var profileErr error
wg.Go(func() {
profileResp, profileErr = h.userProfileClient.GetUser(ctx, &userv1.GetUserRequest{
Meta: authRequestMeta(request.WithContext(ctx), ""),
Meta: httpkit.UserMeta(request.WithContext(ctx), ""),
UserId: userID,
})
})
@ -134,7 +134,7 @@ func (h *Handler) getMyOverview(writer http.ResponseWriter, request *http.Reques
var statsErr error
wg.Go(func() {
statsResp, statsErr = h.userProfileClient.GetMyProfileStats(ctx, &userv1.GetMyProfileStatsRequest{
Meta: authRequestMeta(request.WithContext(ctx), ""),
Meta: httpkit.UserMeta(request.WithContext(ctx), ""),
UserId: userID,
})
})
@ -173,7 +173,7 @@ func (h *Handler) getOverviewRoles(ctx context.Context, request *http.Request, u
}
resp, err := h.userHostClient.GetUserRoleSummary(ctx, &userv1.GetUserRoleSummaryRequest{
Meta: authRequestMeta(request.WithContext(ctx), ""),
Meta: httpkit.UserMeta(request.WithContext(ctx), ""),
UserId: userID,
})
if err != nil {

View File

@ -1,4 +1,4 @@
package http
package userapi
import (
"hyapp/services/gateway-service/internal/transport/http/httpkit"
@ -48,7 +48,7 @@ func (h *Handler) listMyProfileVisitors(writer http.ResponseWriter, request *htt
return
}
resp, err := h.userSocialClient.ListProfileVisitors(request.Context(), &userv1.ListProfileVisitorsRequest{
Meta: authRequestMeta(request, ""),
Meta: httpkit.UserMeta(request, ""),
UserId: auth.UserIDFromContext(request.Context()),
Page: page,
PageSize: pageSize,
@ -76,7 +76,7 @@ func (h *Handler) listMyFollowing(writer http.ResponseWriter, request *http.Requ
return
}
resp, err := h.userSocialClient.ListFollowing(request.Context(), &userv1.ListFollowingRequest{
Meta: authRequestMeta(request, ""),
Meta: httpkit.UserMeta(request, ""),
UserId: auth.UserIDFromContext(request.Context()),
Page: page,
PageSize: pageSize,
@ -104,7 +104,7 @@ func (h *Handler) listMyFriends(writer http.ResponseWriter, request *http.Reques
return
}
resp, err := h.userSocialClient.ListFriends(request.Context(), &userv1.ListFriendsRequest{
Meta: authRequestMeta(request, ""),
Meta: httpkit.UserMeta(request, ""),
UserId: auth.UserIDFromContext(request.Context()),
Page: page,
PageSize: pageSize,
@ -132,7 +132,7 @@ func (h *Handler) listMyFriendApplications(writer http.ResponseWriter, request *
return
}
resp, err := h.userSocialClient.ListFriendApplications(request.Context(), &userv1.ListFriendApplicationsRequest{
Meta: authRequestMeta(request, ""),
Meta: httpkit.UserMeta(request, ""),
UserId: auth.UserIDFromContext(request.Context()),
Direction: strings.TrimSpace(request.URL.Query().Get("direction")),
Page: page,
@ -184,7 +184,7 @@ func (h *Handler) recordProfileVisit(writer http.ResponseWriter, request *http.R
return
}
resp, err := h.userSocialClient.RecordProfileVisit(request.Context(), &userv1.RecordProfileVisitRequest{
Meta: authRequestMeta(request, ""),
Meta: httpkit.UserMeta(request, ""),
VisitorUserId: auth.UserIDFromContext(request.Context()),
TargetUserId: targetUserID,
})
@ -200,7 +200,7 @@ func (h *Handler) handleFollowAction(writer http.ResponseWriter, request *http.R
switch request.Method {
case http.MethodPost:
resp, err := h.userSocialClient.FollowUser(request.Context(), &userv1.FollowUserRequest{
Meta: authRequestMeta(request, ""),
Meta: httpkit.UserMeta(request, ""),
FollowerUserId: userID,
FolloweeUserId: followeeUserID,
})
@ -211,7 +211,7 @@ func (h *Handler) handleFollowAction(writer http.ResponseWriter, request *http.R
httpkit.WriteOK(writer, request, map[string]any{"following": resp.GetFollowing(), "follower_stats": overviewStatsFromProto(resp.GetFollowerStats())})
case http.MethodDelete:
resp, err := h.userSocialClient.UnfollowUser(request.Context(), &userv1.UnfollowUserRequest{
Meta: authRequestMeta(request, ""),
Meta: httpkit.UserMeta(request, ""),
FollowerUserId: userID,
FolloweeUserId: followeeUserID,
})
@ -231,7 +231,7 @@ func (h *Handler) applyFriend(writer http.ResponseWriter, request *http.Request,
return
}
resp, err := h.userSocialClient.ApplyFriend(request.Context(), &userv1.ApplyFriendRequest{
Meta: authRequestMeta(request, ""),
Meta: httpkit.UserMeta(request, ""),
RequesterUserId: auth.UserIDFromContext(request.Context()),
TargetUserId: targetUserID,
})
@ -248,7 +248,7 @@ func (h *Handler) acceptFriendApplication(writer http.ResponseWriter, request *h
return
}
resp, err := h.userSocialClient.AcceptFriendApplication(request.Context(), &userv1.AcceptFriendApplicationRequest{
Meta: authRequestMeta(request, ""),
Meta: httpkit.UserMeta(request, ""),
AccepterUserId: auth.UserIDFromContext(request.Context()),
RequesterUserId: requesterUserID,
})
@ -265,7 +265,7 @@ func (h *Handler) deleteFriend(writer http.ResponseWriter, request *http.Request
return
}
resp, err := h.userSocialClient.DeleteFriend(request.Context(), &userv1.DeleteFriendRequest{
Meta: authRequestMeta(request, ""),
Meta: httpkit.UserMeta(request, ""),
UserId: auth.UserIDFromContext(request.Context()),
FriendUserId: friendUserID,
})
@ -277,11 +277,11 @@ func (h *Handler) deleteFriend(writer http.ResponseWriter, request *http.Request
}
func socialPage(request *http.Request) (int32, int32, bool) {
page, ok := parsePositiveInt32Query(request, "page", 1)
page, ok := httpkit.PositiveInt32Query(request, "page", 1)
if !ok {
return 0, 0, false
}
pageSize, ok := parsePositiveInt32Query(request, "page_size", 20)
pageSize, ok := httpkit.PositiveInt32Query(request, "page_size", 20)
if !ok {
return 0, 0, false
}

View File

@ -1,7 +1,6 @@
package http
package userapi
import (
"hyapp/services/gateway-service/internal/transport/http/httpkit"
"log/slog"
"net/http"
"strconv"
@ -11,6 +10,7 @@ import (
userv1 "hyapp.local/api/proto/user/v1"
"hyapp/pkg/logx"
"hyapp/services/gateway-service/internal/auth"
"hyapp/services/gateway-service/internal/transport/http/httpkit"
)
// userIdentityData 是 gateway 对外返回的当前短号投影。
@ -93,7 +93,7 @@ func (h *Handler) listRegistrationCountries(writer http.ResponseWriter, request
}
resp, err := h.userCountryClient.ListRegistrationCountries(request.Context(), &userv1.ListRegistrationCountriesRequest{
Meta: authRequestMeta(request, ""),
Meta: httpkit.UserMeta(request, ""),
})
if err != nil {
httpkit.WriteRPCError(writer, request, err)
@ -115,7 +115,7 @@ func (h *Handler) getMyIdentity(writer http.ResponseWriter, request *http.Reques
}
resp, err := h.userIdentityClient.GetUserIdentity(request.Context(), &userv1.GetUserIdentityRequest{
Meta: authRequestMeta(request, ""),
Meta: httpkit.UserMeta(request, ""),
UserId: auth.UserIDFromContext(request.Context()),
})
if err != nil {
@ -139,7 +139,7 @@ func (h *Handler) resolveDisplayUserID(writer http.ResponseWriter, request *http
return
}
resp, err := h.userIdentityClient.ResolveDisplayUserID(request.Context(), &userv1.ResolveDisplayUserIDRequest{
Meta: authRequestMeta(request, ""),
Meta: httpkit.UserMeta(request, ""),
DisplayUserId: displayUserID,
})
if err != nil {
@ -160,7 +160,7 @@ func (h *Handler) completeMyOnboarding(writer http.ResponseWriter, request *http
Country string `json:"country"`
InviteCode string `json:"invite_code"`
}
if !decode(writer, request, &body) {
if !httpkit.Decode(writer, request, &body) {
return
}
if h.userProfileClient == nil {
@ -169,7 +169,7 @@ func (h *Handler) completeMyOnboarding(writer http.ResponseWriter, request *http
}
resp, err := h.userProfileClient.CompleteOnboarding(request.Context(), &userv1.CompleteOnboardingRequest{
Meta: authRequestMeta(request, ""),
Meta: httpkit.UserMeta(request, ""),
UserId: auth.UserIDFromContext(request.Context()),
Username: body.Username,
Avatar: body.Avatar,
@ -197,7 +197,7 @@ func (h *Handler) getMyProfile(writer http.ResponseWriter, request *http.Request
}
resp, err := h.userProfileClient.GetUser(request.Context(), &userv1.GetUserRequest{
Meta: authRequestMeta(request, ""),
Meta: httpkit.UserMeta(request, ""),
UserId: auth.UserIDFromContext(request.Context()),
})
if err != nil {
@ -216,7 +216,7 @@ func (h *Handler) updateMyProfile(writer http.ResponseWriter, request *http.Requ
Gender *string `json:"gender"`
Birth *string `json:"birth"`
}
if !decode(writer, request, &body) {
if !httpkit.Decode(writer, request, &body) {
return
}
if h.userProfileClient == nil {
@ -225,7 +225,7 @@ func (h *Handler) updateMyProfile(writer http.ResponseWriter, request *http.Requ
}
resp, err := h.userProfileClient.UpdateUserProfile(request.Context(), &userv1.UpdateUserProfileRequest{
Meta: authRequestMeta(request, ""),
Meta: httpkit.UserMeta(request, ""),
UserId: auth.UserIDFromContext(request.Context()),
Username: body.Username,
Avatar: body.Avatar,
@ -245,7 +245,7 @@ func (h *Handler) changeMyCountry(writer http.ResponseWriter, request *http.Requ
var body struct {
Country string `json:"country"`
}
if !decode(writer, request, &body) {
if !httpkit.Decode(writer, request, &body) {
return
}
if h.userProfileClient == nil {
@ -254,7 +254,7 @@ func (h *Handler) changeMyCountry(writer http.ResponseWriter, request *http.Requ
}
resp, err := h.userProfileClient.ChangeUserCountry(request.Context(), &userv1.ChangeUserCountryRequest{
Meta: authRequestMeta(request, ""),
Meta: httpkit.UserMeta(request, ""),
UserId: auth.UserIDFromContext(request.Context()),
Country: body.Country,
})
@ -272,7 +272,7 @@ func (h *Handler) removeOldRegionBroadcastMemberBestEffort(request *http.Request
return
}
resp, err := h.broadcastClient.RemoveRegionBroadcastMember(request.Context(), &activityv1.RemoveRegionBroadcastMemberRequest{
Meta: activityMeta(request),
Meta: httpkit.ActivityMeta(request),
UserId: userID,
RegionId: oldRegionID,
Reason: reason,
@ -303,7 +303,7 @@ func (h *Handler) changeMyDisplayUserID(writer http.ResponseWriter, request *htt
NewDisplayUserID string `json:"new_display_user_id"`
Reason string `json:"reason"`
}
if !decode(writer, request, &body) {
if !httpkit.Decode(writer, request, &body) {
return
}
if h.userIdentityClient == nil {
@ -312,7 +312,7 @@ func (h *Handler) changeMyDisplayUserID(writer http.ResponseWriter, request *htt
}
resp, err := h.userIdentityClient.ChangeDisplayUserID(request.Context(), &userv1.ChangeDisplayUserIDRequest{
Meta: authRequestMeta(request, ""),
Meta: httpkit.UserMeta(request, ""),
UserId: auth.UserIDFromContext(request.Context()),
NewDisplayUserId: body.NewDisplayUserID,
Reason: body.Reason,
@ -333,7 +333,7 @@ func (h *Handler) applyMyPrettyDisplayUserID(writer http.ResponseWriter, request
LeaseDurationMs int64 `json:"lease_duration_ms"`
PaymentReceiptID string `json:"payment_receipt_id"`
}
if !decode(writer, request, &body) {
if !httpkit.Decode(writer, request, &body) {
return
}
if h.userIdentityClient == nil {
@ -342,7 +342,7 @@ func (h *Handler) applyMyPrettyDisplayUserID(writer http.ResponseWriter, request
}
resp, err := h.userIdentityClient.ApplyPrettyDisplayUserID(request.Context(), &userv1.ApplyPrettyDisplayUserIDRequest{
Meta: authRequestMeta(request, ""),
Meta: httpkit.UserMeta(request, ""),
UserId: auth.UserIDFromContext(request.Context()),
PrettyDisplayUserId: body.PrettyDisplayUserID,
LeaseDurationMs: body.LeaseDurationMs,

View File

@ -1,4 +1,4 @@
package http
package walletapi
import (
"hyapp/services/gateway-service/internal/transport/http/httpkit"
@ -170,7 +170,7 @@ func (h *Handler) listRechargeProducts(writer http.ResponseWriter, request *http
}
userID := auth.UserIDFromContext(request.Context())
profileResp, err := h.userProfileClient.GetUser(request.Context(), &userv1.GetUserRequest{
Meta: authRequestMeta(request, ""),
Meta: httpkit.UserMeta(request, ""),
UserId: userID,
})
if err != nil {
@ -182,7 +182,7 @@ func (h *Handler) listRechargeProducts(writer http.ResponseWriter, request *http
AppCode: appcode.FromContext(request.Context()),
UserId: userID,
RegionId: profileResp.GetUser().GetRegionId(),
Platform: firstQueryOrHeader(request, "platform", "X-App-Platform", "X-Platform"),
Platform: httpkit.FirstQueryOrHeader(request, "platform", "X-App-Platform", "X-Platform"),
})
if err != nil {
httpkit.WriteRPCError(writer, request, err)
@ -250,12 +250,12 @@ func (h *Handler) listWalletTransactionsByAsset(writer http.ResponseWriter, requ
httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
return
}
page, ok := parsePositiveInt32Query(request, "page", 1)
page, ok := httpkit.PositiveInt32Query(request, "page", 1)
if !ok {
httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
return
}
pageSize, ok := parsePositiveInt32Query(request, "page_size", defaultPageSize)
pageSize, ok := httpkit.PositiveInt32Query(request, "page_size", defaultPageSize)
if !ok {
httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
return
@ -286,7 +286,7 @@ func (h *Handler) applyWithdrawal(writer http.ResponseWriter, request *http.Requ
return
}
var body withdrawalApplyRequestBody
if !decode(writer, request, &body) {
if !httpkit.Decode(writer, request, &body) {
return
}
commandID := strings.TrimSpace(body.CommandID)

View File

@ -0,0 +1,58 @@
package walletapi
import (
"net/http"
"hyapp/services/gateway-service/internal/client"
"hyapp/services/gateway-service/internal/transport/http/httproutes"
)
// Handler owns wallet-facing HTTP endpoints.
// It keeps wallet semantics behind wallet-service and only handles HTTP protocol conversion.
type Handler struct {
walletClient client.WalletClient
userIdentityClient client.UserIdentityClient
userProfileClient client.UserProfileClient
userHostClient client.UserHostClient
}
type Config struct {
WalletClient client.WalletClient
UserIdentityClient client.UserIdentityClient
UserProfileClient client.UserProfileClient
UserHostClient client.UserHostClient
}
func New(config Config) *Handler {
return &Handler{
walletClient: config.WalletClient,
userIdentityClient: config.UserIdentityClient,
userProfileClient: config.UserProfileClient,
userHostClient: config.UserHostClient,
}
}
func (h *Handler) WalletHandlers() httproutes.WalletHandlers {
return httproutes.WalletHandlers{
GetWalletOverview: h.getWalletOverview,
GetMyBalances: h.getMyBalances,
ListRechargeProducts: h.listRechargeProducts,
GetDiamondExchangeConfig: h.getDiamondExchangeConfig,
ApplyWithdrawal: h.applyWithdrawal,
ListCoinTransactions: h.listCoinTransactions,
ListWalletTransactions: h.listWalletTransactions,
TransferCoinFromSeller: h.transferCoinFromSeller,
}
}
func (h *Handler) VIPHandlers() httproutes.VIPHandlers {
return httproutes.VIPHandlers{
GetMyVIP: h.getMyVIP,
ListVIPPackages: h.listVIPPackages,
PurchaseVIP: h.purchaseVIP,
}
}
func (h *Handler) GetMyGiftWall(writer http.ResponseWriter, request *http.Request) {
h.getMyGiftWall(writer, request)
}

View File

@ -0,0 +1,65 @@
package walletapi
import walletv1 "hyapp.local/api/proto/wallet/v1"
type overviewVIPData struct {
Level int32 `json:"level"`
Name string `json:"name"`
Active bool `json:"active"`
StartedAtMS int64 `json:"started_at_ms"`
ExpiresAtMS int64 `json:"expires_at_ms"`
Unavailable bool `json:"unavailable,omitempty"`
}
type resourceData struct {
ResourceID int64 `json:"resource_id"`
ResourceCode string `json:"resource_code"`
ResourceType string `json:"resource_type"`
Name string `json:"name"`
Status string `json:"status"`
Grantable bool `json:"grantable"`
GrantStrategy string `json:"grant_strategy"`
WalletAssetType string `json:"wallet_asset_type,omitempty"`
WalletAssetAmount int64 `json:"wallet_asset_amount,omitempty"`
UsageScopes []string `json:"usage_scopes"`
AssetURL string `json:"asset_url"`
PreviewURL string `json:"preview_url"`
AnimationURL string `json:"animation_url"`
MetadataJSON string `json:"metadata_json"`
SortOrder int32 `json:"sort_order"`
CreatedAtMS int64 `json:"created_at_ms"`
UpdatedAtMS int64 `json:"updated_at_ms"`
}
func resourcePointerFromProto(item *walletv1.Resource) *resourceData {
if item == nil {
return nil
}
data := resourceFromProto(item)
return &data
}
func resourceFromProto(item *walletv1.Resource) resourceData {
if item == nil {
return resourceData{}
}
return resourceData{
ResourceID: item.GetResourceId(),
ResourceCode: item.GetResourceCode(),
ResourceType: item.GetResourceType(),
Name: item.GetName(),
Status: item.GetStatus(),
Grantable: item.GetGrantable(),
GrantStrategy: item.GetGrantStrategy(),
WalletAssetType: item.GetWalletAssetType(),
WalletAssetAmount: item.GetWalletAssetAmount(),
UsageScopes: item.GetUsageScopes(),
AssetURL: item.GetAssetUrl(),
PreviewURL: item.GetPreviewUrl(),
AnimationURL: item.GetAnimationUrl(),
MetadataJSON: item.GetMetadataJson(),
SortOrder: item.GetSortOrder(),
CreatedAtMS: item.GetCreatedAtMs(),
UpdatedAtMS: item.GetUpdatedAtMs(),
}
}

View File

@ -1,4 +1,4 @@
package http
package walletapi
import (
"hyapp/services/gateway-service/internal/transport/http/httpkit"
@ -57,7 +57,7 @@ func (h *Handler) purchaseVIP(writer http.ResponseWriter, request *http.Request)
return
}
var body vipPurchaseRequestBody
if !decode(writer, request, &body) {
if !httpkit.Decode(writer, request, &body) {
return
}
commandID := strings.TrimSpace(body.CommandID)

View File

@ -1,4 +1,4 @@
package http
package walletapi
import (
"hyapp/services/gateway-service/internal/transport/http/httpkit"
@ -104,7 +104,7 @@ func (h *Handler) transferCoinFromSeller(writer http.ResponseWriter, request *ht
return
}
var body coinSellerTransferRequestBody
if !decode(writer, request, &body) {
if !httpkit.Decode(writer, request, &body) {
return
}
commandID := body.normalizedCommandID()
@ -117,7 +117,7 @@ func (h *Handler) transferCoinFromSeller(writer http.ResponseWriter, request *ht
sellerUserID := auth.UserIDFromContext(request.Context())
profileResp, err := h.userHostClient.GetCoinSellerProfile(request.Context(), &userv1.GetCoinSellerProfileRequest{
Meta: authRequestMeta(request, ""),
Meta: httpkit.UserMeta(request, ""),
UserId: sellerUserID,
})
if err != nil {
@ -168,7 +168,7 @@ func (h *Handler) transferCoinFromSeller(writer http.ResponseWriter, request *ht
func (h *Handler) resolveCoinSellerTransferTargetUserID(writer http.ResponseWriter, request *http.Request, targetDisplayUserID string) (int64, bool) {
resp, err := h.userIdentityClient.ResolveDisplayUserID(request.Context(), &userv1.ResolveDisplayUserIDRequest{
Meta: authRequestMeta(request, ""),
Meta: httpkit.UserMeta(request, ""),
DisplayUserId: targetDisplayUserID,
})
if err != nil {
@ -186,7 +186,7 @@ func (h *Handler) resolveCoinSellerTransferTargetUserID(writer http.ResponseWrit
func (h *Handler) coinSellerTransferRegions(writer http.ResponseWriter, request *http.Request, sellerUserID int64, targetUserID int64) (int64, int64, bool) {
sellerResp, err := h.userProfileClient.GetUser(request.Context(), &userv1.GetUserRequest{
Meta: authRequestMeta(request, ""),
Meta: httpkit.UserMeta(request, ""),
UserId: sellerUserID,
})
if err != nil {
@ -194,7 +194,7 @@ func (h *Handler) coinSellerTransferRegions(writer http.ResponseWriter, request
return 0, 0, false
}
targetResp, err := h.userProfileClient.GetUser(request.Context(), &userv1.GetUserRequest{
Meta: authRequestMeta(request, ""),
Meta: httpkit.UserMeta(request, ""),
UserId: targetUserID,
})
if err != nil {

View File

@ -5,6 +5,7 @@ grpc_addr: ":13009"
health_http_addr: ":13109"
mysql_dsn: "hyapp:hyapp@tcp(mysql:3306)/hyapp_notice?parseTime=true&charset=utf8mb4&loc=UTC"
wallet_database: "hyapp_wallet"
room_database: "hyapp_room"
mysql_auto_migrate: false
tencent_im:
@ -26,6 +27,16 @@ wallet_notice_worker:
initial_backoff: 5s
max_backoff: 5m
room_notice_worker:
enabled: false
poll_interval: 1s
batch_size: 100
lock_ttl: 30s
publish_timeout: 3s
max_retry_count: 10
initial_backoff: 5s
max_backoff: 5m
log:
level: info
format: json

View File

@ -5,6 +5,7 @@ grpc_addr: ":13009"
health_http_addr: ":13109"
mysql_dsn: "hyapp:${MYSQL_PASSWORD}@tcp(${MYSQL_HOST}:3306)/hyapp_notice?parseTime=true&charset=utf8mb4&loc=UTC"
wallet_database: "hyapp_wallet"
room_database: "hyapp_room"
mysql_auto_migrate: false
tencent_im:
@ -26,6 +27,16 @@ wallet_notice_worker:
initial_backoff: 5s
max_backoff: 5m
room_notice_worker:
enabled: true
poll_interval: 500ms
batch_size: 100
lock_ttl: 30s
publish_timeout: 3s
max_retry_count: 10
initial_backoff: 5s
max_backoff: 5m
log:
level: info
format: json

View File

@ -5,6 +5,7 @@ grpc_addr: ":13009"
health_http_addr: ":13109"
mysql_dsn: "hyapp:hyapp@tcp(127.0.0.1:23306)/hyapp_notice?parseTime=true&charset=utf8mb4&loc=UTC"
wallet_database: "hyapp_wallet"
room_database: "hyapp_room"
mysql_auto_migrate: false
tencent_im:
@ -26,6 +27,16 @@ wallet_notice_worker:
initial_backoff: 5s
max_backoff: 5m
room_notice_worker:
enabled: false
poll_interval: 1s
batch_size: 100
lock_ttl: 30s
publish_timeout: 3s
max_retry_count: 10
initial_backoff: 5s
max_backoff: 5m
log:
level: info
format: json

View File

@ -16,6 +16,7 @@ import (
"hyapp/pkg/logx"
"hyapp/pkg/tencentim"
"hyapp/services/notice-service/internal/config"
"hyapp/services/notice-service/internal/modules/roomnotice"
"hyapp/services/notice-service/internal/modules/walletnotice"
mysqlplatform "hyapp/services/notice-service/internal/platform/mysql"
)
@ -30,6 +31,9 @@ type App struct {
walletNoticeService *walletnotice.Service
walletWorkerOptions walletnotice.WalletNoticeWorkerOptions
walletWorkerEnabled bool
roomNoticeService *roomnotice.Service
roomWorkerOptions roomnotice.RoomNoticeWorkerOptions
roomWorkerEnabled bool
workerCtx context.Context
workerCancel context.CancelFunc
workerWG sync.WaitGroup
@ -57,6 +61,12 @@ func New(cfg config.Config) (*App, error) {
_ = store.Close()
return nil, err
}
roomRepo, err := roomnotice.NewMySQLRepository(store.DB, cfg.RoomDatabase)
if err != nil {
_ = listener.Close()
_ = store.Close()
return nil, err
}
var publisher walletnotice.UserMessagePublisher
if cfg.TencentIM.Enabled {
tencentClient, err := tencentim.NewRESTClient(cfg.TencentIM.RESTConfig())
@ -72,6 +82,11 @@ func New(cfg config.Config) (*App, error) {
_ = store.Close()
return nil, errors.New("wallet notice worker requires tencent_im.enabled")
}
if cfg.RoomNoticeWorker.Enabled && publisher == nil {
_ = listener.Close()
_ = store.Close()
return nil, errors.New("room notice worker requires tencent_im.enabled")
}
server := grpc.NewServer(grpc.UnaryInterceptor(logx.UnaryServerInterceptor("notice-service")))
health := grpchealth.NewServingChecker("notice-service", grpchealth.Dependency{
@ -96,6 +111,9 @@ func New(cfg config.Config) (*App, error) {
walletNoticeService: walletnotice.New(walletnotice.Config{NodeID: cfg.NodeID}, walletRepo, publisher),
walletWorkerOptions: walletNoticeWorkerOptions(cfg.WalletNoticeWorker),
walletWorkerEnabled: cfg.WalletNoticeWorker.Enabled,
roomNoticeService: roomnotice.New(roomnotice.Config{NodeID: cfg.NodeID}, roomRepo, publisher),
roomWorkerOptions: roomNoticeWorkerOptions(cfg.RoomNoticeWorker),
roomWorkerEnabled: cfg.RoomNoticeWorker.Enabled,
workerCtx: workerCtx,
workerCancel: workerCancel,
}, nil
@ -117,6 +135,11 @@ func (a *App) Run() error {
a.walletNoticeService.RunWalletBalanceWorker(a.workerCtx, a.walletWorkerOptions)
})
}
if a.roomWorkerEnabled && a.roomNoticeService != nil {
a.workerWG.Go(func() {
a.roomNoticeService.RunRoomKickWorker(a.workerCtx, a.roomWorkerOptions)
})
}
err := a.server.Serve(a.listener)
if errors.Is(err, grpc.ErrServerStopped) {
return nil
@ -171,3 +194,15 @@ func walletNoticeWorkerOptions(cfg config.WalletNoticeWorkerConfig) walletnotice
MaxBackoff: cfg.MaxBackoff,
}
}
func roomNoticeWorkerOptions(cfg config.WalletNoticeWorkerConfig) roomnotice.RoomNoticeWorkerOptions {
return roomnotice.RoomNoticeWorkerOptions{
PollInterval: cfg.PollInterval,
BatchSize: cfg.BatchSize,
LockTTL: cfg.LockTTL,
PublishTimeout: cfg.PublishTimeout,
MaxRetryCount: cfg.MaxRetryCount,
InitialBackoff: cfg.InitialBackoff,
MaxBackoff: cfg.MaxBackoff,
}
}

View File

@ -22,12 +22,16 @@ type Config struct {
MySQLDSN string `yaml:"mysql_dsn"`
// WalletDatabase 是 wallet_outbox 所在库名notice 只把它当 append-only 事实源读取。
WalletDatabase string `yaml:"wallet_database"`
// RoomDatabase 是 room_outbox 所在库名notice 只读取需要私有通知的房间事实。
RoomDatabase string `yaml:"room_database"`
// TencentIM 是私有 IM 投递通道配置;本地默认关闭,避免误发真实消息。
TencentIM TencentIMConfig `yaml:"tencent_im"`
// WalletNoticeWorker 消费 wallet_outbox 中的 WalletBalanceChanged 事件。
WalletNoticeWorker WalletNoticeWorkerConfig `yaml:"wallet_notice_worker"`
MySQLAutoMigrate bool `yaml:"mysql_auto_migrate"`
Log logx.Config `yaml:"log"`
// RoomNoticeWorker 消费 room_outbox 中需要给单个用户发送的房间通知。
RoomNoticeWorker WalletNoticeWorkerConfig `yaml:"room_notice_worker"`
MySQLAutoMigrate bool `yaml:"mysql_auto_migrate"`
Log logx.Config `yaml:"log"`
}
// TencentIMConfig 描述服务端调用腾讯云 IM REST API 的配置。
@ -79,6 +83,7 @@ func Default() Config {
HealthHTTPAddr: ":13109",
MySQLDSN: "hyapp:hyapp@tcp(127.0.0.1:23306)/hyapp_notice?parseTime=true&charset=utf8mb4&loc=UTC",
WalletDatabase: "hyapp_wallet",
RoomDatabase: "hyapp_room",
MySQLAutoMigrate: false,
TencentIM: TencentIMConfig{
Enabled: false,
@ -97,6 +102,16 @@ func Default() Config {
InitialBackoff: 5 * time.Second,
MaxBackoff: 5 * time.Minute,
},
RoomNoticeWorker: WalletNoticeWorkerConfig{
Enabled: false,
PollInterval: time.Second,
BatchSize: 100,
LockTTL: 30 * time.Second,
PublishTimeout: 3 * time.Second,
MaxRetryCount: 10,
InitialBackoff: 5 * time.Second,
MaxBackoff: 5 * time.Minute,
},
Log: logx.Config{
Level: "info",
Format: "json",
@ -138,7 +153,12 @@ func Load(path string) (Config, error) {
if cfg.WalletDatabase == "" {
cfg.WalletDatabase = "hyapp_wallet"
}
cfg.RoomDatabase = strings.TrimSpace(cfg.RoomDatabase)
if cfg.RoomDatabase == "" {
cfg.RoomDatabase = "hyapp_room"
}
cfg.WalletNoticeWorker = normalizeWalletNoticeWorker(cfg.WalletNoticeWorker)
cfg.RoomNoticeWorker = normalizeWalletNoticeWorker(cfg.RoomNoticeWorker)
if strings.TrimSpace(cfg.TencentIM.AdminIdentifier) == "" {
cfg.TencentIM.AdminIdentifier = "administrator"
}

View File

@ -0,0 +1,174 @@
package roomnotice
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"os"
"strings"
"testing"
"time"
"google.golang.org/protobuf/proto"
roomeventsv1 "hyapp.local/api/proto/events/room/v1"
_ "github.com/go-sql-driver/mysql"
)
func TestMySQLRepositoryProcessesRealRoomKickOutbox(t *testing.T) {
dsn := strings.TrimSpace(os.Getenv("NOTICE_REAL_MYSQL_DSN"))
if dsn == "" {
t.Skip("set NOTICE_REAL_MYSQL_DSN to run real MySQL notice validation")
}
db, err := sql.Open("mysql", dsn)
if err != nil {
t.Fatalf("open mysql: %v", err)
}
defer db.Close()
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := db.PingContext(ctx); err != nil {
t.Fatalf("ping mysql: %v", err)
}
suffix := fmt.Sprintf("%d", time.Now().UnixNano())
roomDatabase := strings.TrimSpace(os.Getenv("NOTICE_REAL_ROOM_DATABASE"))
if roomDatabase == "" {
roomDatabase = "hyapp_room_notice_test_" + suffix
if _, err := db.ExecContext(ctx, fmt.Sprintf(`CREATE DATABASE %s DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci`, quoteDB(roomDatabase))); err != nil {
t.Fatalf("create room test database: %v", err)
}
t.Cleanup(func() {
_, _ = db.ExecContext(context.Background(), fmt.Sprintf(`DROP DATABASE %s`, quoteDB(roomDatabase)))
})
}
if err := ensureRoomOutboxTable(ctx, db, roomDatabase); err != nil {
t.Fatalf("ensure room_outbox table: %v", err)
}
repository, err := NewMySQLRepository(db, roomDatabase)
if err != nil {
t.Fatalf("NewMySQLRepository: %v", err)
}
appCode := "notice_real"
roomID := "notice_real_room_" + suffix
eventID := "notice_real_room_evt_" + suffix
actorUserID := int64(990000001)
targetUserID := int64(990000002)
nowMS := time.Now().UnixMilli()
body, err := proto.Marshal(&roomeventsv1.RoomUserKicked{
ActorUserId: actorUserID,
TargetUserId: targetUserID,
})
if err != nil {
t.Fatalf("marshal body: %v", err)
}
envelope, err := proto.Marshal(&roomeventsv1.EventEnvelope{
EventId: eventID,
RoomId: roomID,
EventType: "RoomUserKicked",
RoomVersion: 12,
OccurredAtMs: nowMS,
AppCode: appCode,
Body: body,
})
if err != nil {
t.Fatalf("marshal envelope: %v", err)
}
if _, err := db.ExecContext(ctx, fmt.Sprintf(`
INSERT INTO %s.room_outbox (
app_code, event_id, event_type, room_id, status, worker_id, lock_until_ms,
envelope, created_at_ms, retry_count, next_retry_at_ms, last_error, updated_at_ms
) VALUES (?, ?, 'RoomUserKicked', ?, 'delivered', '', 0, ?, ?, 0, 0, '', ?)`,
quoteDB(roomDatabase),
), appCode, eventID, roomID, envelope, nowMS, nowMS); err != nil {
t.Fatalf("insert room_outbox: %v", err)
}
if !keepRealRows() {
t.Cleanup(func() {
_, _ = db.ExecContext(context.Background(), `DELETE FROM notice_delivery_events WHERE app_code = ? AND source_event_id = ?`, appCode, eventID)
_, _ = db.ExecContext(context.Background(), fmt.Sprintf(`DELETE FROM %s.room_outbox WHERE app_code = ? AND event_id = ?`, quoteDB(roomDatabase)), appCode, eventID)
})
}
publisher := &fakePublisher{}
service := New(Config{NodeID: "notice-real-test"}, repository, publisher)
processed, err := service.ProcessRoomKickNotices(ctx, RoomNoticeWorkerOptions{
WorkerID: "notice-real-room-test-worker",
BatchSize: 1,
LockTTL: 30 * time.Second,
PublishTimeout: time.Second,
MaxRetryCount: 3,
})
if err != nil {
t.Fatalf("ProcessRoomKickNotices: %v", err)
}
if processed != 1 || len(publisher.messages) != 1 {
t.Fatalf("unexpected process result: processed=%d messages=%d", processed, len(publisher.messages))
}
if publisher.messages[0].ToAccount != "990000002" || publisher.messages[0].Ext != "room_notice" {
t.Fatalf("unexpected private message: %+v", publisher.messages[0])
}
var noticeStatus string
var deliveredAtMS int64
var storedPayload string
if err := db.QueryRowContext(ctx, `
SELECT status, delivered_at_ms, COALESCE(CAST(payload_json AS CHAR), '{}')
FROM notice_delivery_events
WHERE source_name = 'room_outbox' AND app_code = ? AND source_event_id = ? AND channel = 'tencent_im_c2c'`,
appCode, eventID,
).Scan(&noticeStatus, &deliveredAtMS, &storedPayload); err != nil {
t.Fatalf("query notice_delivery_events: %v", err)
}
if noticeStatus != "delivered" || deliveredAtMS <= 0 {
t.Fatalf("unexpected delivery marker: status=%s delivered_at_ms=%d payload=%s", noticeStatus, deliveredAtMS, storedPayload)
}
var noticePayload map[string]any
if err := json.Unmarshal([]byte(storedPayload), &noticePayload); err != nil {
t.Fatalf("decode stored notice payload: %v", err)
}
if noticePayload["event_id"] != eventID || noticePayload["room_id"] != roomID || noticePayload["event_type"] != "room_user_kicked" {
t.Fatalf("unexpected stored notice payload: %+v", noticePayload)
}
if noticePayload["target_user_id"].(float64) != float64(targetUserID) || noticePayload["actor_user_id"].(float64) != float64(actorUserID) {
t.Fatalf("unexpected stored user payload: %+v", noticePayload)
}
t.Logf("real room notice validation event_id=%s status=%s delivered_at_ms=%d", eventID, noticeStatus, deliveredAtMS)
}
func keepRealRows() bool {
value := strings.ToLower(strings.TrimSpace(os.Getenv("NOTICE_REAL_MYSQL_KEEP_ROWS")))
return value == "1" || value == "true" || value == "yes"
}
func quoteDB(value string) string {
return "`" + strings.ReplaceAll(value, "`", "``") + "`"
}
func ensureRoomOutboxTable(ctx context.Context, db *sql.DB, roomDatabase string) error {
_, err := db.ExecContext(ctx, fmt.Sprintf(`
CREATE TABLE IF NOT EXISTS %s.room_outbox (
app_code VARCHAR(32) NOT NULL,
event_id VARCHAR(128) NOT NULL,
event_type VARCHAR(64) NOT NULL,
room_id VARCHAR(64) NOT NULL,
status VARCHAR(32) NOT NULL,
worker_id VARCHAR(128) NOT NULL DEFAULT '',
lock_until_ms BIGINT NULL,
envelope LONGBLOB NOT NULL,
created_at_ms BIGINT NOT NULL,
retry_count INT NOT NULL DEFAULT 0,
next_retry_at_ms BIGINT NOT NULL DEFAULT 0,
last_error TEXT NULL,
updated_at_ms BIGINT NOT NULL,
PRIMARY KEY (app_code, event_id),
KEY idx_room_outbox_notice (app_code, event_type, created_at_ms)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`,
quoteDB(roomDatabase),
))
return err
}

View File

@ -0,0 +1,382 @@
package roomnotice
import (
"context"
"database/sql"
"errors"
"fmt"
"strings"
"time"
"unicode"
"google.golang.org/protobuf/proto"
roomeventsv1 "hyapp.local/api/proto/events/room/v1"
"hyapp/pkg/appcode"
"hyapp/pkg/xerr"
)
const (
sourceRoomOutbox = "room_outbox"
channelTencentIMC2C = "tencent_im_c2c"
eventRoomUserKicked = "RoomUserKicked"
deliveryStatusDelivering = "delivering"
deliveryStatusDelivered = "delivered"
deliveryStatusRetryable = "retryable"
deliveryStatusFailed = "failed"
)
// MySQLRepository 只读取 room_outbox 事实,并把 notice 自己的消费位点写入 notice_delivery_events。
type MySQLRepository struct {
db *sql.DB
roomOutboxTable string
}
// RoomKickEvent 是 notice-service 从 room_outbox 认领的一条踢人私有通知事实。
type RoomKickEvent struct {
AppCode string
EventID string
EventType string
RoomID string
RoomVersion int64
ActorUserID int64
TargetUserID int64
OccurredAtMS int64
CreatedAtMS int64
RetryCount int
}
// NewMySQLRepository 创建房间私有通知仓储room_outbox 被视作 append-only 事实源。
func NewMySQLRepository(db *sql.DB, roomDatabase string) (*MySQLRepository, error) {
if db == nil {
return nil, xerr.New(xerr.Unavailable, "mysql db is not configured")
}
roomDatabase = normalizeDatabaseName(roomDatabase)
if roomDatabase == "" {
return nil, xerr.New(xerr.InvalidArgument, "room_database is invalid")
}
return &MySQLRepository{
db: db,
roomOutboxTable: fmt.Sprintf("`%s`.`room_outbox`", roomDatabase),
}, nil
}
// ClaimRoomKickEvents 抢占待投递给被踢用户的私有 C2C 通知。
// room_outbox 的 pending/delivered 状态属于房间群消息 bridge这里不能修改它避免多消费者互相覆盖。
func (r *MySQLRepository) ClaimRoomKickEvents(ctx context.Context, workerID string, limit int, lockTTL time.Duration) ([]RoomKickEvent, error) {
if r == nil || r.db == nil {
return nil, xerr.New(xerr.Unavailable, "notice repository is not configured")
}
workerID = strings.TrimSpace(workerID)
if workerID == "" {
return nil, xerr.New(xerr.InvalidArgument, "worker_id is required")
}
if limit <= 0 {
limit = 100
}
if limit > 500 {
limit = 500
}
if lockTTL <= 0 {
lockTTL = 30 * time.Second
}
candidates, err := r.listRoomKickCandidates(ctx, limit, time.Now().UnixMilli())
if err != nil {
return nil, err
}
claimed := make([]RoomKickEvent, 0, len(candidates))
for _, candidate := range candidates {
event, ok, err := r.claimRoomKickCandidate(ctx, candidate, workerID, lockTTL)
if err != nil {
return claimed, err
}
if ok {
claimed = append(claimed, event)
}
}
return claimed, nil
}
type roomKickCandidate struct {
AppCode string
EventID string
}
func (r *MySQLRepository) listRoomKickCandidates(ctx context.Context, limit int, nowMs int64) ([]roomKickCandidate, error) {
query := fmt.Sprintf(`
SELECT ro.app_code, ro.event_id
FROM %s ro
LEFT JOIN notice_delivery_events nde
ON nde.source_name = ? AND nde.app_code = ro.app_code AND nde.source_event_id = ro.event_id AND nde.channel = ?
WHERE ro.event_type = ?
AND (
nde.source_event_id IS NULL
OR (nde.status = ? AND nde.next_retry_at_ms <= ?)
OR (nde.status = ? AND nde.lock_until_ms <= ?)
)
ORDER BY ro.created_at_ms ASC, ro.event_id ASC
LIMIT ?`, r.roomOutboxTable)
rows, err := r.db.QueryContext(ctx, query,
sourceRoomOutbox,
channelTencentIMC2C,
eventRoomUserKicked,
deliveryStatusRetryable,
nowMs,
deliveryStatusDelivering,
nowMs,
limit,
)
if err != nil {
return nil, err
}
defer rows.Close()
candidates := make([]roomKickCandidate, 0, limit)
for rows.Next() {
var candidate roomKickCandidate
if err := rows.Scan(&candidate.AppCode, &candidate.EventID); err != nil {
return nil, err
}
candidates = append(candidates, candidate)
}
return candidates, rows.Err()
}
func (r *MySQLRepository) claimRoomKickCandidate(ctx context.Context, candidate roomKickCandidate, workerID string, lockTTL time.Duration) (RoomKickEvent, bool, error) {
nowMs := time.Now().UnixMilli()
lockUntilMS := time.Now().Add(lockTTL).UnixMilli()
ctx = appcode.WithContext(ctx, candidate.AppCode)
tx, err := r.db.BeginTx(ctx, nil)
if err != nil {
return RoomKickEvent{}, false, err
}
defer func() { _ = tx.Rollback() }()
event, err := r.lockRoomKickEvent(ctx, tx, candidate)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
// 候选扫描和事务锁定之间可能被清理或事件类型变化,跳过该行即可。
return RoomKickEvent{}, false, nil
}
return RoomKickEvent{}, false, err
}
retryCount, claimed, err := r.claimDeliveryEvent(ctx, tx, event, workerID, lockUntilMS, nowMs)
if err != nil || !claimed {
return RoomKickEvent{}, false, err
}
event.RetryCount = retryCount
if err := tx.Commit(); err != nil {
return RoomKickEvent{}, false, err
}
return event, true, nil
}
func (r *MySQLRepository) lockRoomKickEvent(ctx context.Context, tx *sql.Tx, candidate roomKickCandidate) (RoomKickEvent, error) {
var (
event RoomKickEvent
envelopeBytes []byte
)
query := fmt.Sprintf(`
SELECT app_code, event_id, event_type, room_id, envelope, created_at_ms
FROM %s
WHERE app_code = ? AND event_id = ? AND event_type = ?
FOR UPDATE`, r.roomOutboxTable)
err := tx.QueryRowContext(ctx, query,
appcode.Normalize(candidate.AppCode),
candidate.EventID,
eventRoomUserKicked,
).Scan(
&event.AppCode,
&event.EventID,
&event.EventType,
&event.RoomID,
&envelopeBytes,
&event.CreatedAtMS,
)
if err != nil {
return RoomKickEvent{}, err
}
var envelope roomeventsv1.EventEnvelope
if err := proto.Unmarshal(envelopeBytes, &envelope); err != nil {
return RoomKickEvent{}, err
}
if envelope.GetEventType() != eventRoomUserKicked {
return RoomKickEvent{}, fmt.Errorf("unexpected room event type %q", envelope.GetEventType())
}
var body roomeventsv1.RoomUserKicked
if err := proto.Unmarshal(envelope.GetBody(), &body); err != nil {
return RoomKickEvent{}, err
}
event.RoomVersion = envelope.GetRoomVersion()
event.OccurredAtMS = envelope.GetOccurredAtMs()
event.ActorUserID = body.GetActorUserId()
event.TargetUserID = body.GetTargetUserId()
if event.TargetUserID <= 0 {
return RoomKickEvent{}, fmt.Errorf("room kick event target_user_id is invalid")
}
return event, nil
}
func (r *MySQLRepository) claimDeliveryEvent(ctx context.Context, tx *sql.Tx, event RoomKickEvent, workerID string, lockUntilMS int64, nowMs int64) (int, bool, error) {
payload, err := roomKickNoticePayload(event)
if err != nil {
return 0, false, err
}
_, err = tx.ExecContext(ctx, `
INSERT INTO notice_delivery_events (
source_name, app_code, source_event_id, channel, notice_type, target_user_id,
status, retry_count, locked_by, lock_until_ms, next_retry_at_ms,
payload_json, last_error, delivered_at_ms, created_at_ms, updated_at_ms
) VALUES (?, ?, ?, ?, ?, ?, ?, 0, ?, ?, 0, ?, '', 0, ?, ?)`,
sourceRoomOutbox,
event.AppCode,
event.EventID,
channelTencentIMC2C,
event.EventType,
event.TargetUserID,
deliveryStatusDelivering,
workerID,
lockUntilMS,
string(payload),
nowMs,
nowMs,
)
if err == nil {
return 0, true, nil
}
if !isDuplicateKey(err) {
return 0, false, err
}
result, err := tx.ExecContext(ctx, `
UPDATE notice_delivery_events
SET status = ?, locked_by = ?, lock_until_ms = ?, payload_json = ?, updated_at_ms = ?
WHERE source_name = ? AND app_code = ? AND source_event_id = ? AND channel = ?
AND (
(status = ? AND next_retry_at_ms <= ?)
OR (status = ? AND lock_until_ms <= ?)
)`,
deliveryStatusDelivering,
workerID,
lockUntilMS,
string(payload),
nowMs,
sourceRoomOutbox,
event.AppCode,
event.EventID,
channelTencentIMC2C,
deliveryStatusRetryable,
nowMs,
deliveryStatusDelivering,
nowMs,
)
if err != nil {
return 0, false, err
}
affected, err := result.RowsAffected()
if err != nil || affected == 0 {
return 0, false, err
}
var retryCount int
err = tx.QueryRowContext(ctx, `
SELECT retry_count
FROM notice_delivery_events
WHERE source_name = ? AND app_code = ? AND source_event_id = ? AND channel = ?
FOR UPDATE`,
sourceRoomOutbox,
event.AppCode,
event.EventID,
channelTencentIMC2C,
).Scan(&retryCount)
return retryCount, err == nil, err
}
// MarkRoomKickDelivered 标记被踢私有通知已送达腾讯云 IM C2C 通道。
func (r *MySQLRepository) MarkRoomKickDelivered(ctx context.Context, event RoomKickEvent, deliveredPayload []byte, nowMs int64) error {
_, err := r.db.ExecContext(ctx, `
UPDATE notice_delivery_events
SET status = ?, locked_by = '', lock_until_ms = 0, next_retry_at_ms = 0,
payload_json = ?, last_error = '', delivered_at_ms = ?, updated_at_ms = ?
WHERE source_name = ? AND app_code = ? AND source_event_id = ? AND channel = ?`,
deliveryStatusDelivered,
string(deliveredPayload),
nowMs,
nowMs,
sourceRoomOutbox,
event.AppCode,
event.EventID,
channelTencentIMC2C,
)
return err
}
// MarkRoomKickRetryable 释放当前锁并进入退避重试;源 room_outbox 不受影响。
func (r *MySQLRepository) MarkRoomKickRetryable(ctx context.Context, event RoomKickEvent, retryCount int, nextRetryAtMS int64, lastErr string, nowMs int64) error {
_, err := r.db.ExecContext(ctx, `
UPDATE notice_delivery_events
SET status = ?, retry_count = ?, locked_by = '', lock_until_ms = 0,
next_retry_at_ms = ?, last_error = ?, updated_at_ms = ?
WHERE source_name = ? AND app_code = ? AND source_event_id = ? AND channel = ?`,
deliveryStatusRetryable,
retryCount,
nextRetryAtMS,
truncateError(lastErr),
nowMs,
sourceRoomOutbox,
event.AppCode,
event.EventID,
channelTencentIMC2C,
)
return err
}
// MarkRoomKickFailed 把无法投递的私有通知移入 notice 死信,等待后台人工重放。
func (r *MySQLRepository) MarkRoomKickFailed(ctx context.Context, event RoomKickEvent, retryCount int, lastErr string, nowMs int64) error {
_, err := r.db.ExecContext(ctx, `
UPDATE notice_delivery_events
SET status = ?, retry_count = ?, locked_by = '', lock_until_ms = 0,
next_retry_at_ms = 0, last_error = ?, updated_at_ms = ?
WHERE source_name = ? AND app_code = ? AND source_event_id = ? AND channel = ?`,
deliveryStatusFailed,
retryCount,
truncateError(lastErr),
nowMs,
sourceRoomOutbox,
event.AppCode,
event.EventID,
channelTencentIMC2C,
)
return err
}
func normalizeDatabaseName(value string) string {
value = strings.TrimSpace(value)
if value == "" {
return ""
}
for _, r := range value {
if r == '_' || unicode.IsDigit(r) || unicode.IsLetter(r) {
continue
}
return ""
}
return value
}
func isDuplicateKey(err error) bool {
if err == nil {
return false
}
return strings.Contains(err.Error(), "Error 1062") || strings.Contains(strings.ToLower(err.Error()), "duplicate")
}
func truncateError(value string) string {
value = strings.TrimSpace(value)
if len(value) <= 1000 {
return value
}
return value[:1000]
}

View File

@ -0,0 +1,142 @@
package roomnotice
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"time"
"hyapp/pkg/logx"
"hyapp/pkg/tencentim"
)
// Repository 是 room notice 模块需要的持久化边界。
type Repository interface {
ClaimRoomKickEvents(ctx context.Context, workerID string, limit int, lockTTL time.Duration) ([]RoomKickEvent, error)
MarkRoomKickDelivered(ctx context.Context, event RoomKickEvent, deliveredPayload []byte, nowMs int64) error
MarkRoomKickRetryable(ctx context.Context, event RoomKickEvent, retryCount int, nextRetryAtMS int64, lastErr string, nowMs int64) error
MarkRoomKickFailed(ctx context.Context, event RoomKickEvent, retryCount int, lastErr string, nowMs int64) error
}
// UserMessagePublisher 把房间私有 notice 投递到用户个人实时通道。
type UserMessagePublisher interface {
PublishUserCustomMessage(ctx context.Context, message tencentim.CustomUserMessage) error
}
// Config 保存 room notice 的进程级配置。
type Config struct {
NodeID string
}
// Service 消费 room_outbox 中需要私有通知的房间事实,不拥有房间状态。
type Service struct {
cfg Config
repository Repository
publisher UserMessagePublisher
}
// New 创建 room notice 服务。
func New(cfg Config, repository Repository, publisher UserMessagePublisher) *Service {
return &Service{cfg: cfg, repository: repository, publisher: publisher}
}
// ProcessRoomKickNotices 处理一批“被踢用户本人”的私有通知。
func (s *Service) ProcessRoomKickNotices(ctx context.Context, options RoomNoticeWorkerOptions) (int, error) {
options = normalizeRoomNoticeWorkerOptions(options, s.cfg.NodeID)
if s.repository == nil {
return 0, fmt.Errorf("notice repository is not configured")
}
if s.publisher == nil {
return 0, fmt.Errorf("notice publisher is not configured")
}
events, err := s.repository.ClaimRoomKickEvents(ctx, options.WorkerID, options.BatchSize, options.LockTTL)
if err != nil {
return 0, err
}
processed := 0
for _, event := range events {
if err := s.publishRoomKickEvent(ctx, event, options); err != nil {
return processed, err
}
processed++
}
return processed, nil
}
func (s *Service) publishRoomKickEvent(ctx context.Context, event RoomKickEvent, options RoomNoticeWorkerOptions) error {
payload, err := roomKickNoticePayload(event)
if err != nil {
return s.markFailed(ctx, event, options, err)
}
publishCtx, cancel := context.WithTimeout(ctx, options.PublishTimeout)
defer cancel()
err = s.publisher.PublishUserCustomMessage(publishCtx, tencentim.CustomUserMessage{
ToAccount: tencentim.FormatUserID(event.TargetUserID),
EventID: event.EventID,
Desc: "room_user_kicked",
Ext: "room_notice",
PayloadJSON: payload,
})
nowMs := time.Now().UnixMilli()
if err == nil {
if markErr := s.repository.MarkRoomKickDelivered(ctx, event, payload, nowMs); markErr != nil {
return markErr
}
logx.Info(ctx, "notice_room_kick_delivered",
slog.String("event_id", event.EventID),
slog.String("app_code", event.AppCode),
slog.String("room_id", event.RoomID),
slog.Int64("target_user_id", event.TargetUserID),
)
return nil
}
return s.markFailed(ctx, event, options, err)
}
func (s *Service) markFailed(ctx context.Context, event RoomKickEvent, options RoomNoticeWorkerOptions, cause error) error {
nowMs := time.Now().UnixMilli()
nextRetryCount := event.RetryCount + 1
if nextRetryCount >= options.MaxRetryCount {
if err := s.repository.MarkRoomKickFailed(ctx, event, nextRetryCount, cause.Error(), nowMs); err != nil {
return err
}
logx.Error(ctx, "notice_room_kick_dead_letter", cause,
slog.String("event_id", event.EventID),
slog.String("app_code", event.AppCode),
slog.String("room_id", event.RoomID),
slog.Int64("target_user_id", event.TargetUserID),
slog.Int("retry_count", nextRetryCount),
)
return nil
}
nextRetryAtMS := nowMs + roomNoticeBackoff(nextRetryCount, options).Milliseconds()
if err := s.repository.MarkRoomKickRetryable(ctx, event, nextRetryCount, nextRetryAtMS, cause.Error(), nowMs); err != nil {
return err
}
logx.Error(ctx, "notice_room_kick_retryable", cause,
slog.String("event_id", event.EventID),
slog.String("app_code", event.AppCode),
slog.String("room_id", event.RoomID),
slog.Int64("target_user_id", event.TargetUserID),
slog.Int("retry_count", nextRetryCount),
slog.Int64("next_retry_at_ms", nextRetryAtMS),
)
return nil
}
func roomKickNoticePayload(event RoomKickEvent) (json.RawMessage, error) {
payload := map[string]any{
"event_id": event.EventID,
"event_type": "room_user_kicked",
"source_event_type": event.EventType,
"app_code": event.AppCode,
"room_id": event.RoomID,
"room_version": event.RoomVersion,
"actor_user_id": event.ActorUserID,
"target_user_id": event.TargetUserID,
"occurred_at_ms": event.OccurredAtMS,
"source_created_at_ms": event.CreatedAtMS,
}
return json.Marshal(payload)
}

View File

@ -0,0 +1,145 @@
package roomnotice
import (
"context"
"encoding/json"
"errors"
"testing"
"time"
"hyapp/pkg/tencentim"
)
func TestProcessRoomKickNoticesPublishesPrivateIM(t *testing.T) {
repo := &fakeRepository{
events: []RoomKickEvent{{
AppCode: "lalu",
EventID: "evt-room-kick-1",
EventType: "RoomUserKicked",
RoomID: "room_10001",
RoomVersion: 18,
ActorUserID: 10001,
TargetUserID: 20001,
OccurredAtMS: 1710000000123,
CreatedAtMS: 1710000000124,
}},
}
publisher := &fakePublisher{}
service := New(Config{NodeID: "notice-test"}, repo, publisher)
processed, err := service.ProcessRoomKickNotices(context.Background(), RoomNoticeWorkerOptions{
WorkerID: "room-notice-test",
BatchSize: 10,
LockTTL: time.Second,
PublishTimeout: time.Second,
MaxRetryCount: 3,
InitialBackoff: time.Second,
MaxBackoff: time.Second,
})
if err != nil {
t.Fatalf("ProcessRoomKickNotices failed: %v", err)
}
if processed != 1 {
t.Fatalf("processed=%d, want 1", processed)
}
if len(publisher.messages) != 1 {
t.Fatalf("published messages=%d, want 1", len(publisher.messages))
}
message := publisher.messages[0]
if message.ToAccount != "20001" || message.EventID != "evt-room-kick-1" || message.Ext != "room_notice" || message.Desc != "room_user_kicked" {
t.Fatalf("unexpected message: %+v", message)
}
if repo.deliveredEventID != "evt-room-kick-1" {
t.Fatalf("delivered event id=%q", repo.deliveredEventID)
}
var payload map[string]any
if err := json.Unmarshal(message.PayloadJSON, &payload); err != nil {
t.Fatalf("decode payload: %v", err)
}
if payload["event_type"] != "room_user_kicked" || payload["room_id"] != "room_10001" {
t.Fatalf("unexpected payload: %+v", payload)
}
if payload["target_user_id"].(float64) != 20001 || payload["actor_user_id"].(float64) != 10001 {
t.Fatalf("unexpected user payload: %+v", payload)
}
}
func TestProcessRoomKickNoticesMarksRetryableOnPublishError(t *testing.T) {
repo := &fakeRepository{
events: []RoomKickEvent{{
AppCode: "lalu",
EventID: "evt-room-kick-2",
EventType: "RoomUserKicked",
RoomID: "room_10001",
ActorUserID: 10001,
TargetUserID: 20001,
RetryCount: 1,
}},
}
service := New(Config{NodeID: "notice-test"}, repo, &fakePublisher{err: errors.New("im timeout")})
processed, err := service.ProcessRoomKickNotices(context.Background(), RoomNoticeWorkerOptions{
WorkerID: "room-notice-test",
BatchSize: 10,
LockTTL: time.Second,
PublishTimeout: time.Second,
MaxRetryCount: 3,
InitialBackoff: time.Second,
MaxBackoff: time.Second,
})
if err != nil {
t.Fatalf("ProcessRoomKickNotices failed: %v", err)
}
if processed != 1 {
t.Fatalf("processed=%d, want 1", processed)
}
if repo.retryEventID != "evt-room-kick-2" || repo.retryCount != 2 {
t.Fatalf("retry marker event=%q count=%d", repo.retryEventID, repo.retryCount)
}
if repo.failedEventID != "" {
t.Fatalf("event should be retryable, got failed=%q", repo.failedEventID)
}
}
type fakeRepository struct {
events []RoomKickEvent
deliveredEventID string
retryEventID string
failedEventID string
retryCount int
}
func (r *fakeRepository) ClaimRoomKickEvents(context.Context, string, int, time.Duration) ([]RoomKickEvent, error) {
return r.events, nil
}
func (r *fakeRepository) MarkRoomKickDelivered(_ context.Context, event RoomKickEvent, _ []byte, _ int64) error {
r.deliveredEventID = event.EventID
return nil
}
func (r *fakeRepository) MarkRoomKickRetryable(_ context.Context, event RoomKickEvent, retryCount int, _ int64, _ string, _ int64) error {
r.retryEventID = event.EventID
r.retryCount = retryCount
return nil
}
func (r *fakeRepository) MarkRoomKickFailed(_ context.Context, event RoomKickEvent, retryCount int, _ string, _ int64) error {
r.failedEventID = event.EventID
r.retryCount = retryCount
return nil
}
type fakePublisher struct {
messages []tencentim.CustomUserMessage
err error
}
func (p *fakePublisher) PublishUserCustomMessage(_ context.Context, message tencentim.CustomUserMessage) error {
if p.err != nil {
return p.err
}
p.messages = append(p.messages, message)
return nil
}

View File

@ -0,0 +1,92 @@
package roomnotice
import (
"context"
"log/slog"
"strings"
"time"
"hyapp/pkg/logx"
)
// RoomNoticeWorkerOptions 控制房间私有通知 worker 的运行参数。
type RoomNoticeWorkerOptions struct {
WorkerID string
PollInterval time.Duration
BatchSize int
LockTTL time.Duration
PublishTimeout time.Duration
MaxRetryCount int
InitialBackoff time.Duration
MaxBackoff time.Duration
}
// RunRoomKickWorker 持续消费 room_outbox 中的 RoomUserKicked 事件。
func (s *Service) RunRoomKickWorker(ctx context.Context, options RoomNoticeWorkerOptions) {
options = normalizeRoomNoticeWorkerOptions(options, s.cfg.NodeID)
ticker := time.NewTicker(options.PollInterval)
defer ticker.Stop()
for {
processed, err := s.ProcessRoomKickNotices(ctx, options)
if err != nil && ctx.Err() == nil {
logx.Error(ctx, "notice_room_kick_batch_failed", err, slog.String("worker_id", options.WorkerID))
}
if processed >= options.BatchSize {
// 批次打满说明有积压,立即继续 drain降低被踢用户退出提示延迟。
continue
}
select {
case <-ctx.Done():
return
case <-ticker.C:
}
}
}
func normalizeRoomNoticeWorkerOptions(options RoomNoticeWorkerOptions, nodeID string) RoomNoticeWorkerOptions {
if strings.TrimSpace(options.WorkerID) == "" {
if strings.TrimSpace(nodeID) == "" {
nodeID = "notice"
}
options.WorkerID = "room-notice-" + nodeID
}
if options.PollInterval <= 0 {
options.PollInterval = time.Second
}
if options.BatchSize <= 0 {
options.BatchSize = 100
}
if options.LockTTL <= 0 {
options.LockTTL = 30 * time.Second
}
if options.PublishTimeout <= 0 {
options.PublishTimeout = 3 * time.Second
}
if options.MaxRetryCount <= 0 {
options.MaxRetryCount = 10
}
if options.InitialBackoff <= 0 {
options.InitialBackoff = 5 * time.Second
}
if options.MaxBackoff <= 0 {
options.MaxBackoff = 5 * time.Minute
}
return options
}
func roomNoticeBackoff(retryCount int, options RoomNoticeWorkerOptions) time.Duration {
if retryCount <= 0 {
return options.InitialBackoff
}
backoff := options.InitialBackoff
for i := 1; i < retryCount; i++ {
if backoff >= options.MaxBackoff/2 {
return options.MaxBackoff
}
backoff *= 2
}
if backoff > options.MaxBackoff {
return options.MaxBackoff
}
return backoff
}

View File

@ -14,6 +14,7 @@ CREATE TABLE IF NOT EXISTS rooms (
seat_count INT NOT NULL,
mode VARCHAR(64) NOT NULL,
status VARCHAR(32) NOT NULL,
room_password_hash VARCHAR(128) NOT NULL DEFAULT '',
visible_region_id BIGINT NOT NULL DEFAULT 0,
created_at_ms BIGINT NOT NULL,
updated_at_ms BIGINT NOT NULL,
@ -33,6 +34,7 @@ CREATE TABLE IF NOT EXISTS room_list_entries (
cover_url VARCHAR(512) NOT NULL DEFAULT '',
mode VARCHAR(64) NOT NULL,
status VARCHAR(32) NOT NULL,
locked BOOLEAN NOT NULL DEFAULT FALSE,
heat BIGINT NOT NULL DEFAULT 0,
online_count INT NOT NULL DEFAULT 0,
seat_count INT NOT NULL DEFAULT 0,
@ -121,6 +123,38 @@ CREATE TABLE IF NOT EXISTS room_user_feed_entries (
KEY idx_room_user_feed_list (app_code, user_id, feed_type, updated_at_ms DESC, room_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS room_follows (
app_code VARCHAR(32) NOT NULL,
user_id BIGINT NOT NULL,
room_id VARCHAR(64) NOT NULL,
status VARCHAR(32) NOT NULL,
followed_at_ms BIGINT NOT NULL,
updated_at_ms BIGINT NOT NULL,
PRIMARY KEY (app_code, user_id, room_id),
KEY idx_room_follows_user_list (app_code, user_id, status, followed_at_ms DESC, room_id),
KEY idx_room_follows_room (app_code, room_id, status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS room_region_pins (
id BIGINT NOT NULL AUTO_INCREMENT,
app_code VARCHAR(32) NOT NULL,
visible_region_id BIGINT NOT NULL,
room_id VARCHAR(64) NOT NULL,
weight BIGINT NOT NULL DEFAULT 0,
status VARCHAR(32) NOT NULL,
pinned_at_ms BIGINT NOT NULL,
expires_at_ms BIGINT NOT NULL,
cancelled_at_ms BIGINT NOT NULL DEFAULT 0,
created_by_admin_id BIGINT UNSIGNED NOT NULL DEFAULT 0,
cancelled_by_admin_id BIGINT UNSIGNED NOT NULL DEFAULT 0,
created_at_ms BIGINT NOT NULL,
updated_at_ms BIGINT NOT NULL,
PRIMARY KEY (id),
UNIQUE KEY uk_room_region_pins_room (app_code, visible_region_id, room_id),
KEY idx_room_region_pins_active (app_code, visible_region_id, status, expires_at_ms, weight DESC, room_id),
KEY idx_room_region_pins_room (app_code, room_id, status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS room_seat_configs (
app_code VARCHAR(32) NOT NULL,
allowed_seat_counts VARCHAR(128) NOT NULL,

View File

@ -185,6 +185,15 @@ func roomEventFromEnvelope(envelope *roomeventsv1.EventEnvelope) (tencentim.Room
base.ActorUserID = body.GetActorUserId()
base.Attributes = map[string]string{"enabled": fmt.Sprintf("%t", body.GetEnabled())}
return base, true, nil
case "RoomPasswordChanged":
// 锁房事件只广播布尔状态,密码明文和哈希都不会进入 IM 自定义消息。
var body roomeventsv1.RoomPasswordChanged
if err := proto.Unmarshal(envelope.GetBody(), &body); err != nil {
return tencentim.RoomEvent{}, false, err
}
base.ActorUserID = body.GetActorUserId()
base.Attributes = map[string]string{"locked": fmt.Sprintf("%t", body.GetLocked())}
return base, true, nil
case "RoomAdminChanged":
// 管理员变更进入系统消息,客户端收到后仍应以最新 snapshot 修正本地权限 UI。
var body roomeventsv1.RoomAdminChanged
@ -259,6 +268,8 @@ func eventTypeForClient(eventType string) string {
return "room_profile_updated"
case "RoomChatEnabledChanged":
return "room_chat_enabled_changed"
case "RoomPasswordChanged":
return "room_password_changed"
case "RoomAdminChanged":
return "room_admin_changed"
case "RoomUserMuted":

View File

@ -241,6 +241,20 @@ type SetChatEnabled struct {
// Type 返回命令类型。
func (SetChatEnabled) Type() string { return "set_chat_enabled" }
// SetRoomPassword 定义房主设置或清空入房密码的请求。
type SetRoomPassword struct {
Base
// Locked 为 false 表示清空密码true 表示开启入房密码校验。
Locked bool `json:"locked"`
// Password 只在当前进程内参与 command_id 冲突判断,序列化时必须省略,避免明文进入 command log。
Password string `json:"-"`
// PasswordHash 是服务端生成的密码哈希command log 不能保存客户端明文。
PasswordHash string `json:"password_hash,omitempty"`
}
// Type 返回命令类型。
func (SetRoomPassword) Type() string { return "set_room_password" }
// SetRoomAdmin 定义添加或移除管理员请求。
type SetRoomAdmin struct {
Base
@ -360,6 +374,8 @@ func IdempotencyPayload(payload []byte) ([]byte, error) {
delete(values, "gift_point_added")
delete(values, "heat_value")
delete(values, "price_version")
// SetRoomPassword 的哈希带随机盐;通用 payload 比较先去掉哈希service 层再用 transient 明文和已存 bcrypt 哈希确认是否同一密码。
delete(values, "password_hash")
// MicUp 的发流确认 deadline 由 room-service 生成,客户端重试同一 command_id 时不能因此冲突。
delete(values, "publish_deadline_ms")
@ -400,6 +416,8 @@ func Deserialize(commandType string, payload []byte) (Command, error) {
cmd = &SetMicSeatLock{}
case SetChatEnabled{}.Type():
cmd = &SetChatEnabled{}
case SetRoomPassword{}.Type():
cmd = &SetRoomPassword{}
case SetRoomAdmin{}.Type():
cmd = &SetRoomAdmin{}
case MuteUser{}.Type():

View File

@ -188,14 +188,15 @@ func (s *Service) CreateRoom(ctx context.Context, req *roomv1.CreateRoomRequest)
// 首次创建按 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,
VisibleRegionID: cmd.VisibleRegionID,
RoomShortID: cmd.RoomShortID,
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
}

View File

@ -32,9 +32,11 @@ func (s *Service) GetCurrentRoom(ctx context.Context, req *roomv1.GetCurrentRoom
return nil, err
}
if !exists {
// 没有用户当前房间投影时直接返回空结果,不扫描房间列表或所有 Room Cell。
return emptyCurrentRoomResponse(serverTimeMS), nil
}
// presence 只是恢复入口的索引,必须用 Room Cell 快照确认房间仍 active 且用户仍在房间内。
snapshot, err := s.currentSnapshot(ctx, presence.RoomID)
if err != nil {
return nil, err
@ -54,6 +56,7 @@ func (s *Service) GetCurrentRoom(ctx context.Context, req *roomv1.GetCurrentRoom
micSessionID := ""
needRTCToken := false
if seat != nil {
// 上麦用户回前台需要 RTC token 恢复媒体链路;普通房间用户只需要重新加 IM 群。
publishState = seat.GetPublishState()
micSessionID = seat.GetMicSessionId()
needRTCToken = publishState == state.MicPublishPending || publishState == state.MicPublishPublishing
@ -72,15 +75,19 @@ func (s *Service) GetCurrentRoom(ctx context.Context, req *roomv1.GetCurrentRoom
}, nil
}
// emptyCurrentRoomResponse 保留 server_time_ms方便客户端用同一套时钟校正逻辑处理空结果。
func emptyCurrentRoomResponse(serverTimeMS int64) *roomv1.GetCurrentRoomResponse {
return &roomv1.GetCurrentRoomResponse{ServerTimeMs: serverTimeMS}
}
// currentRoomSnapshotRestorable 判定读模型指向的房间是否仍可被客户端恢复。
func currentRoomSnapshotRestorable(snapshot *roomv1.RoomSnapshot, userID int64) bool {
if snapshot == nil || snapshot.GetRoomId() == "" || snapshot.GetStatus() != state.RoomStatusActive {
// 非 active 房间不能恢复,即使用户级 presence 投影还没被清理。
return false
}
if containsUserID(snapshot.GetBanUserIds(), userID) {
// ban 优先于旧 presence避免被踢用户通过本地恢复入口重新收消息。
return false
}
return findProtoUser(snapshot, userID) != nil
@ -102,11 +109,14 @@ func (s *Service) projectRoomPresenceBestEffort(ctx context.Context, snapshot *r
}
}
// roomPresenceProjectionFromSnapshot 把 Room Cell 快照压缩成用户当前房间读模型。
// 读模型只保存恢复入口需要的字段,不复制完整 RoomState也不作为权限权威来源。
func roomPresenceProjectionFromSnapshot(snapshot *roomv1.RoomSnapshot, updatedAtMS int64) RoomPresenceSnapshot {
if snapshot == nil || snapshot.GetRoomId() == "" {
return RoomPresenceSnapshot{}
}
// 先按 user_id 建立麦位索引,投影用户时能补齐发流状态而不做双层循环。
seatsByUser := make(map[int64]*roomv1.SeatState, len(snapshot.GetMicSeats()))
for _, seat := range snapshot.GetMicSeats() {
if seat.GetUserId() > 0 {
@ -116,6 +126,7 @@ func roomPresenceProjectionFromSnapshot(snapshot *roomv1.RoomSnapshot, updatedAt
users := make([]RoomPresence, 0, len(snapshot.GetOnlineUsers()))
if snapshot.GetStatus() == state.RoomStatusActive {
// 只有 active 房间才投影 active 用户;关闭房间通过空 users 让 repository 收敛旧 presence。
for _, user := range snapshot.GetOnlineUsers() {
if user.GetUserId() <= 0 {
continue
@ -131,6 +142,7 @@ func roomPresenceProjectionFromSnapshot(snapshot *roomv1.RoomSnapshot, updatedAt
UpdatedAtMS: updatedAtMS,
}
if seat := seatsByUser[user.GetUserId()]; seat != nil {
// 发流状态只对当前上麦用户有意义,非上麦用户保持空字段。
entry.PublishState = seat.GetPublishState()
entry.MicSessionID = seat.GetMicSessionId()
}
@ -148,6 +160,7 @@ func roomPresenceProjectionFromSnapshot(snapshot *roomv1.RoomSnapshot, updatedAt
}
}
// containsUserID 是当前房间恢复入口的本地集合查询,独立保留便于后续替换为二分查找。
func containsUserID(values []int64, target int64) bool {
return slices.Contains(values, target)
}

View File

@ -0,0 +1,85 @@
package service
import (
"context"
"strings"
roomv1 "hyapp.local/api/proto/room/v1"
"hyapp/pkg/roomid"
"hyapp/pkg/xerr"
)
// FollowRoom 建立当前用户对房间的关注关系。
// 关注关系是用户个性化读模型事实,不改变 Room Cell 核心状态,也不需要 Redis lease 串行化。
func (s *Service) FollowRoom(ctx context.Context, req *roomv1.FollowRoomRequest) (*roomv1.FollowRoomResponse, error) {
ctx = contextFromMeta(ctx, req.GetMeta())
roomID, userID, err := normalizeRoomFollowInput(req.GetMeta(), req.GetRoomId(), req.GetUserId())
if err != nil {
return nil, err
}
meta, exists, err := s.repository.GetRoomMeta(ctx, roomID)
if err != nil {
return nil, err
}
if !exists {
return nil, xerr.New(xerr.NotFound, "room not found")
}
if isClosedRoomStatus(meta.Status) {
// 已关闭房间不再建立新的关注关系;历史关注仍可通过 UnfollowRoom 幂等取消。
return nil, xerr.New(xerr.RoomClosed, "room closed")
}
nowMS := s.clock.Now().UnixMilli()
record, err := s.repository.FollowRoom(ctx, userID, roomID, nowMS)
if err != nil {
return nil, err
}
return &roomv1.FollowRoomResponse{
RoomId: roomID,
Followed: true,
FollowedAtMs: record.FollowedAtMS,
ServerTimeMs: nowMS,
}, nil
}
// UnfollowRoom 取消当前用户对房间的关注关系。
// 未关注或历史房间已关闭都按幂等成功处理,方便客户端在本地状态不确定时直接收敛。
func (s *Service) UnfollowRoom(ctx context.Context, req *roomv1.UnfollowRoomRequest) (*roomv1.UnfollowRoomResponse, error) {
ctx = contextFromMeta(ctx, req.GetMeta())
roomID, userID, err := normalizeRoomFollowInput(req.GetMeta(), req.GetRoomId(), req.GetUserId())
if err != nil {
return nil, err
}
nowMS := s.clock.Now().UnixMilli()
if err := s.repository.UnfollowRoom(ctx, userID, roomID, nowMS); err != nil {
return nil, err
}
return &roomv1.UnfollowRoomResponse{
RoomId: roomID,
Followed: false,
ServerTimeMs: nowMS,
}, nil
}
func normalizeRoomFollowInput(meta *roomv1.RequestMeta, rawRoomID string, userID int64) (string, int64, error) {
roomID := strings.TrimSpace(rawRoomID)
metaRoomID := strings.TrimSpace(meta.GetRoomId())
if roomID == "" {
roomID = metaRoomID
}
if metaRoomID != "" && roomID != metaRoomID {
// meta.room_id 是链路和审计字段;与 body/path room_id 不一致时必须拒绝,避免写错关系。
return "", 0, xerr.New(xerr.InvalidArgument, "room_id is invalid")
}
if !roomid.ValidStringID(roomID) {
return "", 0, xerr.New(xerr.InvalidArgument, "room_id is invalid")
}
if userID <= 0 {
return "", 0, xerr.New(xerr.InvalidArgument, "user_id is required")
}
return roomID, userID, nil
}

View File

@ -0,0 +1,128 @@
package service_test
import (
"context"
"testing"
"time"
roomv1 "hyapp.local/api/proto/room/v1"
walletv1 "hyapp.local/api/proto/wallet/v1"
"hyapp/pkg/appcode"
"hyapp/services/room-service/internal/integration"
roomservice "hyapp/services/room-service/internal/room/service"
"hyapp/services/room-service/internal/router"
"hyapp/services/room-service/internal/testutil/mysqltest"
)
type followTestWallet struct{}
func (followTestWallet) DebitGift(context.Context, *walletv1.DebitGiftRequest) (*walletv1.DebitGiftResponse, error) {
return &walletv1.DebitGiftResponse{BillingReceiptId: "receipt-follow-test"}, nil
}
func TestRoomFollowAffectsSnapshotAndFollowedFeed(t *testing.T) {
ctx := context.Background()
repository := mysqltest.NewRepository(t)
svc := roomservice.New(roomservice.Config{
NodeID: "node-follow-test",
LeaseTTL: 10 * time.Second,
RankLimit: 20,
SnapshotEveryN: 1,
}, router.NewMemoryDirectory(), repository, followTestWallet{}, integration.NewNoopRoomEventPublisher(), integration.NewNoopOutboxPublisher())
roomID := "room-follow-flow"
ownerID := int64(1001)
viewerID := int64(2002)
if _, err := svc.CreateRoom(ctx, &roomv1.CreateRoomRequest{
Meta: roomservice.NewRequestMeta(roomID, ownerID),
SeatCount: 10,
Mode: "voice",
VisibleRegionId: 1001,
RoomName: "Follow Flow",
RoomShortId: "follow-flow",
}); err != nil {
t.Fatalf("create room failed: %v", err)
}
if _, err := svc.JoinRoom(ctx, &roomv1.JoinRoomRequest{
Meta: roomservice.NewRequestMeta(roomID, viewerID),
Role: "audience",
}); err != nil {
t.Fatalf("join room failed: %v", err)
}
beforeFollow, err := svc.GetRoomSnapshot(ctx, &roomv1.GetRoomSnapshotRequest{
Meta: &roomv1.RequestMeta{AppCode: appcode.Default, RoomId: roomID},
RoomId: roomID,
ViewerUserId: viewerID,
})
if err != nil {
t.Fatalf("snapshot before follow failed: %v", err)
}
if beforeFollow.GetIsFollowed() {
t.Fatalf("new viewer must not be followed before FollowRoom")
}
followResp, err := svc.FollowRoom(ctx, &roomv1.FollowRoomRequest{
Meta: &roomv1.RequestMeta{AppCode: appcode.Default, RoomId: roomID},
RoomId: roomID,
UserId: viewerID,
})
if err != nil {
t.Fatalf("follow room failed: %v", err)
}
if !followResp.GetFollowed() || followResp.GetFollowedAtMs() <= 0 {
t.Fatalf("follow response mismatch: %+v", followResp)
}
afterFollow, err := svc.GetRoomSnapshot(ctx, &roomv1.GetRoomSnapshotRequest{
Meta: &roomv1.RequestMeta{AppCode: appcode.Default, RoomId: roomID},
RoomId: roomID,
ViewerUserId: viewerID,
})
if err != nil {
t.Fatalf("snapshot after follow failed: %v", err)
}
if !afterFollow.GetIsFollowed() {
t.Fatalf("snapshot must expose is_followed=true after FollowRoom")
}
feedResp, err := svc.ListRoomFeeds(ctx, &roomv1.ListRoomFeedsRequest{
Meta: &roomv1.RequestMeta{AppCode: appcode.Default},
ViewerUserId: viewerID,
VisibleRegionId: 1001,
Tab: "followed",
Limit: 20,
})
if err != nil {
t.Fatalf("followed feed failed: %v", err)
}
if len(feedResp.GetRooms()) != 1 || feedResp.GetRooms()[0].GetRoomId() != roomID {
t.Fatalf("followed feed must return followed room: %+v", feedResp.GetRooms())
}
unfollowResp, err := svc.UnfollowRoom(ctx, &roomv1.UnfollowRoomRequest{
Meta: &roomv1.RequestMeta{AppCode: appcode.Default, RoomId: roomID},
RoomId: roomID,
UserId: viewerID,
})
if err != nil {
t.Fatalf("unfollow room failed: %v", err)
}
if unfollowResp.GetFollowed() {
t.Fatalf("unfollow response mismatch: %+v", unfollowResp)
}
emptyFeedResp, err := svc.ListRoomFeeds(ctx, &roomv1.ListRoomFeedsRequest{
Meta: &roomv1.RequestMeta{AppCode: appcode.Default},
ViewerUserId: viewerID,
VisibleRegionId: 1001,
Tab: "followed",
Limit: 20,
})
if err != nil {
t.Fatalf("followed feed after unfollow failed: %v", err)
}
if len(emptyFeedResp.GetRooms()) != 0 {
t.Fatalf("followed feed must be empty after UnfollowRoom: %+v", emptyFeedResp.GetRooms())
}
}

View File

@ -15,8 +15,10 @@ import (
)
type loadedRoomRef struct {
// AppCode 是运行态 Cell 所属租户,后台 worker 必须用它恢复 ctx。
AppCode string
RoomID string
// RoomID 是已装载 Room Cell 的业务房间 ID。
RoomID string
}
func (s *Service) installCell(ctx context.Context, roomID string, roomCell *cell.RoomCell) {
@ -50,6 +52,7 @@ func (s *Service) loadedRoomRefs() []loadedRoomRef {
func runtimeRoomKey(appCode string, roomID string) string {
appCode = appcode.Normalize(appCode)
// 使用 NUL 分隔 app_code 和 room_id避免普通字符串拼接出现歧义。
return appCode + "\x00" + roomID
}
@ -60,10 +63,12 @@ func RuntimeRoomKey(appCode string, roomID string) string {
}
func runtimeRoomKeyFromContext(ctx context.Context, roomID string) string {
// 命令入口统一从 ctx 读取 app_code避免 service 方法忘记把租户键传给 Redis lease。
return runtimeRoomKey(appcode.FromContext(ctx), roomID)
}
func (s *Service) loadedRoomLease(ctx context.Context, roomRef loadedRoomRef, now time.Time) (router.Lease, bool, error) {
// 后台 worker 通过 EnsureOwner 续租当前 Cell不能在没有 lease 的情况下读取旧内存态。
lease, err := s.directory.EnsureOwner(ctx, runtimeRoomKey(roomRef.AppCode, roomRef.RoomID), s.nodeID, now, s.leaseTTL)
if err != nil {
return router.Lease{}, false, err
@ -78,10 +83,12 @@ func (s *Service) loadedRoomLease(ctx context.Context, roomRef loadedRoomRef, no
}
func (s *Service) verifyLoadedRoomLease(ctx context.Context, roomRef loadedRoomRef, lease router.Lease, now time.Time) (bool, error) {
// 使用同一个 lease token 做 fencing 校验,防止 worker 批处理期间 owner 已经切走。
return s.directory.VerifyOwner(ctx, runtimeRoomKey(roomRef.AppCode, roomRef.RoomID), s.nodeID, lease.LeaseToken, now)
}
func runtimeRoomRef(key string) loadedRoomRef {
// runtime key 是内部格式;解析失败时保留 room_id便于旧测试数据仍能被安全跳过或排查。
for index := 0; index < len(key); index++ {
if key[index] == 0 {
return loadedRoomRef{AppCode: appcode.Normalize(key[:index]), RoomID: key[index+1:]}

View File

@ -0,0 +1,108 @@
package service_test
import (
"context"
"testing"
"time"
roomv1 "hyapp.local/api/proto/room/v1"
"hyapp/pkg/appcode"
"hyapp/services/room-service/internal/integration"
roomservice "hyapp/services/room-service/internal/room/service"
"hyapp/services/room-service/internal/router"
"hyapp/services/room-service/internal/testutil/mysqltest"
)
type fakeRTCUserRemover struct {
roomID string
userID int64
}
func (r *fakeRTCUserRemover) RemoveUserByStrRoomID(_ context.Context, roomID string, userID int64) error {
r.roomID = roomID
r.userID = userID
return nil
}
func TestKickUserRemovesPresenceBanAndRTCConnection(t *testing.T) {
ctx := context.Background()
repository := mysqltest.NewRepository(t)
rtc := &fakeRTCUserRemover{}
svc := roomservice.New(roomservice.Config{
NodeID: "node-kick-test",
LeaseTTL: 10 * time.Second,
RankLimit: 20,
SnapshotEveryN: 1,
RTCUserRemover: rtc,
}, router.NewMemoryDirectory(), repository, followTestWallet{}, integration.NewNoopRoomEventPublisher(), integration.NewNoopOutboxPublisher())
roomID := "room-kick-flow"
ownerID := int64(7101)
viewerID := int64(7201)
if _, err := svc.CreateRoom(ctx, &roomv1.CreateRoomRequest{
Meta: roomservice.NewRequestMeta(roomID, ownerID),
SeatCount: 10,
Mode: "voice",
VisibleRegionId: 8101,
RoomName: "Kick Flow",
RoomShortId: "kick-flow",
}); err != nil {
t.Fatalf("create kick room fixture failed: %v", err)
}
if _, err := svc.JoinRoom(ctx, &roomv1.JoinRoomRequest{
Meta: roomservice.NewRequestMeta(roomID, viewerID),
Role: "audience",
}); err != nil {
t.Fatalf("join kick target failed: %v", err)
}
kickResp, err := svc.KickUser(ctx, &roomv1.KickUserRequest{
Meta: roomservice.NewRequestMeta(roomID, ownerID),
TargetUserId: viewerID,
})
if err != nil {
t.Fatalf("kick user failed: %v", err)
}
if !kickResp.GetRtcKicked() || kickResp.GetRtcKickError() != "" || rtc.roomID != roomID || rtc.userID != viewerID {
t.Fatalf("kick must remove RTC user after Room Cell commit: resp=%+v rtc=%+v", kickResp, rtc)
}
if !containsInt64(kickResp.GetRoom().GetBanUserIds(), viewerID) || findRoomUser(kickResp.GetRoom(), viewerID) != nil {
t.Fatalf("kick response must ban and remove target presence: %+v", kickResp.GetRoom())
}
presenceResp, err := svc.VerifyRoomPresence(ctx, &roomv1.VerifyRoomPresenceRequest{
AppCode: appcode.Default,
RoomId: roomID,
UserId: viewerID,
})
if err != nil {
t.Fatalf("verify room presence after kick failed: %v", err)
}
if presenceResp.GetPresent() || presenceResp.GetReason() != "user_banned" {
t.Fatalf("kicked user must not pass IM presence guard: %+v", presenceResp)
}
if _, err := svc.JoinRoom(ctx, &roomv1.JoinRoomRequest{
Meta: roomservice.NewRequestMeta(roomID, viewerID),
Role: "audience",
}); err == nil {
t.Fatalf("kicked user must not join before unban")
}
}
func findRoomUser(snapshot *roomv1.RoomSnapshot, userID int64) *roomv1.RoomUser {
for _, user := range snapshot.GetOnlineUsers() {
if user.GetUserId() == userID {
return user
}
}
return nil
}
func containsInt64(values []int64, target int64) bool {
for _, value := range values {
if value == target {
return true
}
}
return false
}

View File

@ -20,10 +20,12 @@ func (s *Service) requireRoomOpenForEntry(ctx context.Context, roomID string) er
if err != nil || !closed {
return err
}
// 进房入口使用专门错误码gateway 可以稳定映射成客户端房间关闭态。
return xerr.New(xerr.RoomClosed, "room closed")
}
func (s *Service) isRoomClosed(ctx context.Context, roomID string) (bool, error) {
// 关闭状态先读 rooms 元数据,避免为了拒绝进房恢复完整 Room Cell。
roomMeta, exists, err := s.repository.GetRoomMeta(ctx, roomID)
if err != nil || !exists {
return false, err
@ -33,6 +35,7 @@ func (s *Service) isRoomClosed(ctx context.Context, roomID string) (bool, error)
func isClosedRoomStatus(status string) bool {
value := strings.TrimSpace(status)
// 空状态兼容测试构造;非 active 一律按不可进入处理closing 也不接收新用户。
return value != "" && !strings.EqualFold(value, state.RoomStatusActive)
}
@ -52,9 +55,11 @@ func (s *Service) CloseRoom(ctx context.Context, req *roomv1.CloseRoomRequest) (
return mutationResult{}, nil, xerr.New(xerr.NotFound, "room not found")
}
if current.Status == state.RoomStatusClosed {
// 关房命令幂等,重复请求只返回当前关闭快照,不重复写 RoomClosed 事件。
return mutationResult{snapshot: current.ToProto()}, nil, nil
}
if current.Status != state.RoomStatusActive && current.Status != state.RoomStatusClosing {
// creating 等非开放状态不允许用 CloseRoom 直接跳过创建收尾语义。
return mutationResult{}, nil, xerr.New(xerr.Conflict, "room is not active")
}
if err := requireOwnerPresent(current, cmd.ActorUserID()); err != nil {
@ -69,6 +74,7 @@ func (s *Service) CloseRoom(ctx context.Context, req *roomv1.CloseRoomRequest) (
occupiedSeats := make([]occupiedMicSeat, 0, len(current.MicSeats))
for _, seat := range current.MicSeats {
if seat.UserID != 0 && seat.MicSessionID != "" {
// 清场前先保存占麦会话,后续 ClearMicSession 会抹掉 mic_session_id。
occupiedSeats = append(occupiedSeats, occupiedMicSeat{
userID: seat.UserID,
seatNo: seat.SeatNo,
@ -77,6 +83,7 @@ func (s *Service) CloseRoom(ctx context.Context, req *roomv1.CloseRoomRequest) (
}
}
// 关房是 Room Cell 的终态变更presence 和麦位都在同一版本下清空。
current.Status = state.RoomStatusClosed
current.OnlineUsers = map[int64]*state.RoomUserState{}
for index := range current.MicSeats {

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