From 0ad227187f42574f74ef5aec4963194c372c24f6 Mon Sep 17 00:00:00 2001 From: zhx Date: Thu, 4 Jun 2026 21:26:47 +0800 Subject: [PATCH] Enforce room cover and device registration uniqueness --- api/proto/room/v1/room.pb.go | 4 +-- api/proto/room/v1/room.proto | 4 +-- .../创建房间与IM入群Flutter对接.md | 3 +- docs/语音房区域房间列表架构.md | 4 +-- docs/语音房基础闭环实现.md | 2 +- docs/进房与重连开发需求.md | 4 +-- pkg/xerr/catalog.go | 6 ++++ pkg/xerr/errors.go | 2 ++ .../mysql/036_user_register_device_unique.sql | 5 +++ .../internal/transport/http/response_test.go | 17 +++++----- .../transport/http/roomapi/room_handler.go | 4 +-- .../http/roomapi/room_handler_test.go | 23 +++++++++++++ .../internal/room/command/command.go | 2 +- .../internal/room/service/background_test.go | 2 ++ .../internal/room/service/create_room.go | 6 ++-- .../room/service/create_room_real_im_test.go | 1 + .../internal/room/service/create_room_test.go | 31 +++++++++++++++++ .../internal/room/service/follow_test.go | 1 + .../internal/room/service/kick_test.go | 4 +++ .../internal/room/service/lock_test.go | 1 + .../internal/room/service/pin_test.go | 1 + .../room/service/room_treasure_test.go | 1 + .../internal/transport/grpc/server_test.go | 1 + .../deploy/mysql/initdb/001_user_service.sql | 1 + .../internal/service/auth/service.go | 2 ++ .../internal/service/auth/service_test.go | 33 +++++++++++++++++++ .../internal/service/auth/third_party.go | 20 +++++++++++ .../internal/storage/mysql/auth/common.go | 2 ++ .../storage/mysql/auth/third_party.go | 19 +++++++++++ .../internal/testutil/mysqltest/mysqltest.go | 5 +++ 30 files changed, 186 insertions(+), 25 deletions(-) create mode 100644 scripts/mysql/036_user_register_device_unique.sql diff --git a/api/proto/room/v1/room.pb.go b/api/proto/room/v1/room.pb.go index 2fca303c..6c35bdb5 100644 --- a/api/proto/room/v1/room.pb.go +++ b/api/proto/room/v1/room.pb.go @@ -3313,7 +3313,7 @@ func (x *SetRoomBackgroundResponse) GetBackground() *RoomBackgroundImage { } // CreateRoomRequest 创建一个新的房间执行单元。 -// 必填语义:meta.room_id、meta.command_id、meta.actor_user_id、mode 和 room_name。 +// 必填语义:meta.room_id、meta.command_id、meta.actor_user_id、mode、room_name 和 room_avatar。 // 同一个 meta.actor_user_id 只能作为 owner 创建一个房间;重复创建返回 Conflict。 type CreateRoomRequest struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -3326,7 +3326,7 @@ type CreateRoomRequest struct { VisibleRegionId int64 `protobuf:"varint,4,opt,name=visible_region_id,json=visibleRegionId,proto3" json:"visible_region_id,omitempty"` // room_name 是客户端创建房间时填写的展示名称,room-service 会写入 RoomSnapshot.room_ext["title"]。 RoomName string `protobuf:"bytes,5,opt,name=room_name,json=roomName,proto3" json:"room_name,omitempty"` - // room_avatar 是客户端可选上传的展示头像;为空时 room-service 写入默认系统头像。 + // room_avatar 是客户端上传的房间封面;创建时必填,room-service 会写入 RoomSnapshot.room_ext["cover_url"]。 RoomAvatar string `protobuf:"bytes,6,opt,name=room_avatar,json=roomAvatar,proto3" json:"room_avatar,omitempty"` // room_description 是房间简介,只作为房间扩展资料保存,不参与 Room Cell 高频状态决策。 RoomDescription string `protobuf:"bytes,7,opt,name=room_description,json=roomDescription,proto3" json:"room_description,omitempty"` diff --git a/api/proto/room/v1/room.proto b/api/proto/room/v1/room.proto index 216a684f..c5ee3ad0 100644 --- a/api/proto/room/v1/room.proto +++ b/api/proto/room/v1/room.proto @@ -387,7 +387,7 @@ message SetRoomBackgroundResponse { } // CreateRoomRequest 创建一个新的房间执行单元。 -// 必填语义:meta.room_id、meta.command_id、meta.actor_user_id、mode 和 room_name。 +// 必填语义:meta.room_id、meta.command_id、meta.actor_user_id、mode、room_name 和 room_avatar。 // 同一个 meta.actor_user_id 只能作为 owner 创建一个房间;重复创建返回 Conflict。 message CreateRoomRequest { RequestMeta meta = 1; @@ -399,7 +399,7 @@ message CreateRoomRequest { int64 visible_region_id = 4; // room_name 是客户端创建房间时填写的展示名称,room-service 会写入 RoomSnapshot.room_ext["title"]。 string room_name = 5; - // room_avatar 是客户端可选上传的展示头像;为空时 room-service 写入默认系统头像。 + // room_avatar 是客户端上传的房间封面;创建时必填,room-service 会写入 RoomSnapshot.room_ext["cover_url"]。 string room_avatar = 6; // room_description 是房间简介,只作为房间扩展资料保存,不参与 Room Cell 高频状态决策。 string room_description = 7; diff --git a/docs/flutter对接/创建房间与IM入群Flutter对接.md b/docs/flutter对接/创建房间与IM入群Flutter对接.md index 3a3546ab..f19143e7 100644 --- a/docs/flutter对接/创建房间与IM入群Flutter对接.md +++ b/docs/flutter对接/创建房间与IM入群Flutter对接.md @@ -101,7 +101,7 @@ Content-Type: application/json | `seat_count` | 否 | int | 不传或传 `0` 使用后台默认麦位数;正数必须命中后台启用座位数。 | | `mode` | 是 | string | 当前传 `voice`。 | | `room_name` | 是 | string | 房间标题,最长 128 个字符。 | -| `room_avatar` | 否 | string | 房间头像 URL;为空时服务端写默认头像。 | +| `room_avatar` | 是 | string | 房间封面 URL;不传或空字符串会返回 `INVALID_ARGUMENT`。 | | `room_description` | 否 | string | 房间简介,最长 512 个字符。 | 成功响应: @@ -401,4 +401,3 @@ String newCommandId(String action) { | `/rooms/join` 返回 `PERMISSION_DENIED` | 可能被 ban 或锁房密码错误;不调用 `joinGroup`。 | | IM `joinGroup` 群不存在 | 当前不应出现;记录 `request_id`、`room_id`、`group_id` 上报。 | | IM 登录票据过期 | 重新调用 `/api/v1/im/usersig` 并 `login`。 | - diff --git a/docs/语音房区域房间列表架构.md b/docs/语音房区域房间列表架构.md index 56e9b6f8..6ed581a7 100644 --- a/docs/语音房区域房间列表架构.md +++ b/docs/语音房区域房间列表架构.md @@ -130,7 +130,7 @@ sequenceDiagram participant U as user-service participant R as room-service - C->>G: POST /api/v1/rooms/create(room_name, room_avatar?, room_description?) + C->>G: POST /api/v1/rooms/create(room_name, room_avatar, room_description?) G->>U: GetUser(actor_user_id) U-->>G: region_id, display_user_id G->>G: generate room_id = app_code + uuid @@ -146,7 +146,7 @@ sequenceDiagram - 客户端不能提交 `room_id`;gateway 按当前 `app_code` 生成 `_` 作为房间长 ID。 - `room_short_id` 首版直接等于创建者当前 `display_user_id`,用于搜索、分享和展示。 - 同一个登录用户只能作为 owner 创建一个房间;room-service 按 `RequestMeta.actor_user_id` 判定 owner,不能通过更换 `room_id` 创建第二个房间。 -- 客户端必须提交 `room_name`;`room_avatar` 为空时由 room-service 写默认系统头像,`room_description` 只进入 `RoomExt`。 +- 客户端必须提交 `room_name` 和 `room_avatar`;`room_description` 只进入 `RoomExt`。 - `visible_region_id` 来自创建者当前 `users.region_id`。 - 创建者没有区域时写 `0`,进入 `GLOBAL` 列表桶。 - 房间创建成功后,创建者改国家不自动修改房间区域。 diff --git a/docs/语音房基础闭环实现.md b/docs/语音房基础闭环实现.md index 73b640fc..199df601 100644 --- a/docs/语音房基础闭环实现.md +++ b/docs/语音房基础闭环实现.md @@ -83,7 +83,7 @@ sequenceDiagram U-->>G: access token with user_id G-->>C: token envelope - C->>G: POST /api/v1/rooms/create(room_name, room_avatar?, room_description?) + C->>G: POST /api/v1/rooms/create(room_name, room_avatar, room_description?) G->>R: CreateRoom(actor_user_id, room profile) R->>T: create_group(room_id) T-->>R: group ready diff --git a/docs/进房与重连开发需求.md b/docs/进房与重连开发需求.md index c1f32a30..a0d07c6c 100644 --- a/docs/进房与重连开发需求.md +++ b/docs/进房与重连开发需求.md @@ -75,12 +75,12 @@ Authorization: Bearer "command_id": "cmd_create_room_550e8400", "mode": "voice", "room_name": "周末语音房", - "room_avatar": "", + "room_avatar": "https://cdn.example/room.png", "room_description": "本房间用于轻量聊天和上麦互动" } ``` -正式客户端不应该传 `room_id`、`owner_user_id` 和 `host_user_id`。`command_id` 由客户端按创建房间动作生成,同一动作重试必须复用。gateway 必须从 access token 取当前 `user_id` 写入 `RequestMeta.actor_user_id`,并生成内部 `room_id`;room-service 使用该 actor 确定 owner 和默认 host。`seat_count` 可省略,省略或传 `0` 时使用后台默认座位数 `15`;传正数时必须命中后台启用座位数。`room_name` 必填,`room_avatar` 为空时写入默认系统头像,房间展示资料统一落在 `RoomExt`。 +正式客户端不应该传 `room_id`、`owner_user_id` 和 `host_user_id`。`command_id` 由客户端按创建房间动作生成,同一动作重试必须复用。gateway 必须从 access token 取当前 `user_id` 写入 `RequestMeta.actor_user_id`,并生成内部 `room_id`;room-service 使用该 actor 确定 owner 和默认 host。`seat_count` 可省略,省略或传 `0` 时使用后台默认座位数 `15`;传正数时必须命中后台启用座位数。`room_name` 和 `room_avatar` 必填,房间展示资料统一落在 `RoomExt`。 响应必须包含: diff --git a/pkg/xerr/catalog.go b/pkg/xerr/catalog.go index 7ae038f2..b1015397 100644 --- a/pkg/xerr/catalog.go +++ b/pkg/xerr/catalog.go @@ -63,6 +63,12 @@ var catalog = map[Code]Spec{ RegionCountryConflict: spec(codes.FailedPrecondition, httpStatusConflict, RegionCountryConflict, "conflict"), RegionDisabled: spec(codes.FailedPrecondition, httpStatusConflict, RegionDisabled, "conflict"), InvalidInviteCode: spec(codes.InvalidArgument, httpStatusBadRequest, InvalidInviteCode, "invalid argument"), + DeviceAlreadyRegistered: spec( + codes.FailedPrecondition, + httpStatusConflict, + DeviceAlreadyRegistered, + "conflict", + ), InsufficientBalance: spec(codes.FailedPrecondition, httpStatusConflict, InsufficientBalance, "insufficient balance"), DuplicateBillingCommand: spec(codes.AlreadyExists, httpStatusConflict, DuplicateBillingCommand, "conflict"), diff --git a/pkg/xerr/errors.go b/pkg/xerr/errors.go index f8bd5f55..0a2c6613 100644 --- a/pkg/xerr/errors.go +++ b/pkg/xerr/errors.go @@ -71,6 +71,8 @@ const ( RegionDisabled Code = "REGION_DISABLED" // InvalidInviteCode 表示邀请码不存在、停用、跨 App、邀请人不可用或用户尝试自邀。 InvalidInviteCode Code = "INVALID_INVITE_CODE" + // DeviceAlreadyRegistered 表示同一注册设备已经创建过账号,不能再次作为三方注册入口。 + DeviceAlreadyRegistered Code = "DEVICE_ALREADY_REGISTERED" // InsufficientBalance 表示钱包余额不足。 InsufficientBalance Code = "INSUFFICIENT_BALANCE" diff --git a/scripts/mysql/036_user_register_device_unique.sql b/scripts/mysql/036_user_register_device_unique.sql new file mode 100644 index 00000000..1d09ad94 --- /dev/null +++ b/scripts/mysql/036_user_register_device_unique.sql @@ -0,0 +1,5 @@ +USE hyapp_user; + +-- 同一个 App 内,一个非空注册设备号只能创建一个用户;NULL 仍用于没有注册设备快照的历史或测试行。 +ALTER TABLE users + ADD UNIQUE KEY uk_users_register_device_id (app_code, register_device_id); diff --git a/services/gateway-service/internal/transport/http/response_test.go b/services/gateway-service/internal/transport/http/response_test.go index 5744270b..ecbcf971 100644 --- a/services/gateway-service/internal/transport/http/response_test.go +++ b/services/gateway-service/internal/transport/http/response_test.go @@ -5814,7 +5814,7 @@ func TestRoutesWriteInvalidJSONEnvelope(t *testing.T) { func TestCreateRoomIgnoresClientRoomIDAndGeneratesScopedRoomID(t *testing.T) { client := &fakeRoomClient{} router := NewHandlerWithClients(client, nil, nil, &fakeUserProfileClient{regionID: 1001}).Routes(auth.NewVerifier("secret")) - request := httptest.NewRequest(http.MethodPost, "/api/v1/rooms/create", bytes.NewReader([]byte(`{"room_id":"room:1","command_id":"cmd-create-scoped","seat_count":10,"mode":"voice","room_name":"Lobby"}`))) + request := httptest.NewRequest(http.MethodPost, "/api/v1/rooms/create", bytes.NewReader([]byte(`{"room_id":"room:1","command_id":"cmd-create-scoped","seat_count":10,"mode":"voice","room_name":"Lobby","room_avatar":"https://cdn.example.com/room.png"}`))) request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42)) request.Header.Set("X-Request-ID", "req-generated-room-id") recorder := httptest.NewRecorder() @@ -5835,7 +5835,7 @@ func TestCreateRoomIgnoresClientRoomIDAndGeneratesScopedRoomID(t *testing.T) { func TestCreateRoomAllowsMissingSeatCount(t *testing.T) { client := &fakeRoomClient{} router := NewHandlerWithClients(client, nil, nil, &fakeUserProfileClient{regionID: 1001}).Routes(auth.NewVerifier("secret")) - request := httptest.NewRequest(http.MethodPost, "/api/v1/rooms/create", bytes.NewReader([]byte(`{"room_id":"room:1","command_id":"cmd-create-default-seat","mode":"voice","room_name":"Lobby"}`))) + request := httptest.NewRequest(http.MethodPost, "/api/v1/rooms/create", bytes.NewReader([]byte(`{"room_id":"room:1","command_id":"cmd-create-default-seat","mode":"voice","room_name":"Lobby","room_avatar":"https://cdn.example.com/room.png"}`))) request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42)) request.Header.Set("X-Request-ID", "req-create-default-seat") recorder := httptest.NewRecorder() @@ -5972,10 +5972,11 @@ func TestCreateRoomRejectsMissingRequiredFieldsBeforeGRPC(t *testing.T) { name string body string }{ - {name: "command_id", body: `{"room_id":"room-1","seat_count":10,"mode":"voice","room_name":"Lobby"}`}, - {name: "mode", body: `{"room_id":"room-1","command_id":"cmd-create-missing-mode","seat_count":10,"room_name":"Lobby"}`}, - {name: "room_name", body: `{"room_id":"room-1","command_id":"cmd-create-missing-name","seat_count":10,"mode":"voice"}`}, - {name: "negative_seat_count", body: `{"room_id":"room-1","command_id":"cmd-create-negative-seat","seat_count":-1,"mode":"voice","room_name":"Lobby"}`}, + {name: "command_id", body: `{"room_id":"room-1","seat_count":10,"mode":"voice","room_name":"Lobby","room_avatar":"https://cdn.example.com/room.png"}`}, + {name: "mode", body: `{"room_id":"room-1","command_id":"cmd-create-missing-mode","seat_count":10,"room_name":"Lobby","room_avatar":"https://cdn.example.com/room.png"}`}, + {name: "room_name", body: `{"room_id":"room-1","command_id":"cmd-create-missing-name","seat_count":10,"mode":"voice","room_avatar":"https://cdn.example.com/room.png"}`}, + {name: "room_avatar", body: `{"room_id":"room-1","command_id":"cmd-create-missing-avatar","seat_count":10,"mode":"voice","room_name":"Lobby"}`}, + {name: "negative_seat_count", body: `{"room_id":"room-1","command_id":"cmd-create-negative-seat","seat_count":-1,"mode":"voice","room_name":"Lobby","room_avatar":"https://cdn.example.com/room.png"}`}, } for _, test := range tests { @@ -5999,7 +6000,7 @@ func TestCreateRoomRejectsMissingRequiredFieldsBeforeGRPC(t *testing.T) { func TestRoutesWriteUpstreamErrorEnvelope(t *testing.T) { router := NewHandlerWithClients(&fakeRoomClient{createErr: errors.New("room unavailable")}, nil, nil, &fakeUserProfileClient{regionID: 1001}).Routes(auth.NewVerifier("secret")) - body := []byte(`{"room_id":"room-1","command_id":"cmd-create-upstream","seat_count":10,"mode":"voice","room_name":"Lobby"}`) + body := []byte(`{"room_id":"room-1","command_id":"cmd-create-upstream","seat_count":10,"mode":"voice","room_name":"Lobby","room_avatar":"https://cdn.example.com/room.png"}`) request := httptest.NewRequest(http.MethodPost, "/api/v1/rooms/create", bytes.NewReader(body)) request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42)) request.Header.Set("X-Request-ID", "req-upstream") @@ -6015,7 +6016,7 @@ func TestCreateRoomOwnerConflictWritesConflictEnvelope(t *testing.T) { createErr: xerr.ToGRPCError(xerr.New(xerr.Conflict, "owner already has room")), } router := NewHandlerWithClients(roomClient, nil, nil, &fakeUserProfileClient{regionID: 1001}).Routes(auth.NewVerifier("secret")) - body := []byte(`{"room_id":"room-owner-2","command_id":"cmd-create-owner-conflict","seat_count":10,"mode":"voice","room_name":"Lobby"}`) + body := []byte(`{"room_id":"room-owner-2","command_id":"cmd-create-owner-conflict","seat_count":10,"mode":"voice","room_name":"Lobby","room_avatar":"https://cdn.example.com/room.png"}`) request := httptest.NewRequest(http.MethodPost, "/api/v1/rooms/create", bytes.NewReader(body)) request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42)) request.Header.Set("X-Request-ID", "req-owner-conflict") diff --git a/services/gateway-service/internal/transport/http/roomapi/room_handler.go b/services/gateway-service/internal/transport/http/roomapi/room_handler.go index daa925d0..915536c1 100644 --- a/services/gateway-service/internal/transport/http/roomapi/room_handler.go +++ b/services/gateway-service/internal/transport/http/roomapi/room_handler.go @@ -675,8 +675,8 @@ func (h *Handler) createRoom(writer http.ResponseWriter, request *http.Request) body.RoomName = strings.TrimSpace(body.RoomName) body.RoomAvatar = strings.TrimSpace(body.RoomAvatar) body.RoomDescription = strings.TrimSpace(body.RoomDescription) - if body.CommandID == "" || body.SeatCount < 0 || body.Mode == "" || body.RoomName == "" { - // command_id 必须来自客户端用户动作,seat_count 可省略走后台默认值;mode 和 room_name 仍是创建页最小必填信息。 + if body.CommandID == "" || body.SeatCount < 0 || body.Mode == "" || body.RoomName == "" || body.RoomAvatar == "" { + // command_id 必须来自客户端用户动作,seat_count 可省略走后台默认值;mode、room_name 和 room_avatar 是创建页最小必填信息。 httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument") return } diff --git a/services/gateway-service/internal/transport/http/roomapi/room_handler_test.go b/services/gateway-service/internal/transport/http/roomapi/room_handler_test.go index 61fadd94..ac3f4e86 100644 --- a/services/gateway-service/internal/transport/http/roomapi/room_handler_test.go +++ b/services/gateway-service/internal/transport/http/roomapi/room_handler_test.go @@ -1,12 +1,35 @@ package roomapi import ( + "bytes" + "net/http" + "net/http/httptest" "testing" "time" roomv1 "hyapp.local/api/proto/room/v1" ) +func TestCreateRoomRejectsMissingRoomAvatar(t *testing.T) { + handler := New(Config{}) + request := httptest.NewRequest(http.MethodPost, "/api/v1/rooms/create", bytes.NewBufferString(`{ + "command_id": "cmd-create-room", + "seat_count": 10, + "mode": "voice", + "room_name": "No Cover Room" + }`)) + recorder := httptest.NewRecorder() + + handler.createRoom(recorder, request) + + if recorder.Code != http.StatusBadRequest { + t.Fatalf("missing room_avatar must fail before upstream calls: status=%d body=%s", recorder.Code, recorder.Body.String()) + } + if !bytes.Contains(recorder.Body.Bytes(), []byte(`"code":"INVALID_ARGUMENT"`)) { + t.Fatalf("missing room_avatar must return invalid argument envelope: %s", recorder.Body.String()) + } +} + func TestRoomGiftTabsUsesTypeCodeAsKeyAndTabKeyAsLabel(t *testing.T) { tabs := roomGiftTabs( []giftConfigData{ diff --git a/services/room-service/internal/room/command/command.go b/services/room-service/internal/room/command/command.go index 5398b09b..bc22a9a5 100644 --- a/services/room-service/internal/room/command/command.go +++ b/services/room-service/internal/room/command/command.go @@ -66,7 +66,7 @@ type CreateRoom struct { VisibleRegionID int64 `json:"visible_region_id"` // RoomName 是房间展示名称,恢复时回填 RoomExt["title"],不拆成独立状态 owner。 RoomName string `json:"room_name"` - // RoomAvatar 是房间展示头像,恢复时回填 RoomExt["cover_url"]。 + // RoomAvatar 是创建时必填的房间封面,恢复时回填 RoomExt["cover_url"]。 RoomAvatar string `json:"room_avatar"` // RoomDescription 是房间简介,恢复时回填 RoomExt["description"]。 RoomDescription string `json:"room_description"` diff --git a/services/room-service/internal/room/service/background_test.go b/services/room-service/internal/room/service/background_test.go index 86f9c744..3958460d 100644 --- a/services/room-service/internal/room/service/background_test.go +++ b/services/room-service/internal/room/service/background_test.go @@ -32,6 +32,7 @@ func TestRoomBackgroundSaveListSetFlow(t *testing.T) { Mode: "voice", VisibleRegionId: 1001, RoomName: "Background Flow", + RoomAvatar: testRoomCoverURL, RoomShortId: "bg-flow", }); err != nil { t.Fatalf("create room failed: %v", err) @@ -104,6 +105,7 @@ func TestRoomBackgroundOnlyOwnerCanEdit(t *testing.T) { Mode: "voice", VisibleRegionId: 1001, RoomName: "Background Owner", + RoomAvatar: testRoomCoverURL, RoomShortId: "bg-owner", }); err != nil { t.Fatalf("create room failed: %v", err) diff --git a/services/room-service/internal/room/service/create_room.go b/services/room-service/internal/room/service/create_room.go index b9b11cd7..10b85c07 100644 --- a/services/room-service/internal/room/service/create_room.go +++ b/services/room-service/internal/room/service/create_room.go @@ -27,7 +27,7 @@ const ( roomExtShortIDKey = "room_short_id" roomExtBackgroundKey = "background_url" - // defaultRoomAvatar 是房间头像缺省值,语义上代表系统默认资源,不绑定外部 CDN 地址。 + // defaultRoomAvatar 只服务房间资料更新/后台清空封面后的兜底;创建房间必须显式提交封面。 defaultRoomAvatar = "hyapp://room/default-avatar" // RoomExt 的展示字段最终会进入 room_list_entries 和客户端快照,服务端在入口裁剪长度边界。 @@ -304,8 +304,8 @@ func normalizeCreateRoomProfile(req *roomv1.CreateRoomRequest) (roomProfileInput avatar := strings.TrimSpace(req.GetRoomAvatar()) if avatar == "" { - // 默认头像由 room-service 决定,客户端和 gateway 不需要复制同一套兜底策略。 - avatar = defaultRoomAvatar + // 创建房间封面必须来自客户端上传结果;owner service 兜底校验可以防止内部 gRPC 绕过 gateway 创建空封面房。 + return roomProfileInput{}, xerr.New(xerr.InvalidArgument, "room_avatar is required") } if utf8.RuneCountInString(avatar) > maxRoomAvatarRunes { // cover_url 会投影到 room_list_entries.cover_url,超长值必须在写入前拒绝。 diff --git a/services/room-service/internal/room/service/create_room_real_im_test.go b/services/room-service/internal/room/service/create_room_real_im_test.go index e926fb45..05979c54 100644 --- a/services/room-service/internal/room/service/create_room_real_im_test.go +++ b/services/room-service/internal/room/service/create_room_real_im_test.go @@ -53,6 +53,7 @@ func TestRealCreateRoomEnsuresTencentIMGroup(t *testing.T) { Mode: "voice", VisibleRegionId: 1001, RoomName: "Real Tencent IM Room", + RoomAvatar: testRoomCoverURL, RoomShortId: strconv.FormatInt(ownerID, 10), }) if err != nil { diff --git a/services/room-service/internal/room/service/create_room_test.go b/services/room-service/internal/room/service/create_room_test.go index 559f694d..0ca77e33 100644 --- a/services/room-service/internal/room/service/create_room_test.go +++ b/services/room-service/internal/room/service/create_room_test.go @@ -8,12 +8,15 @@ import ( roomv1 "hyapp.local/api/proto/room/v1" "hyapp/pkg/tencentim" + "hyapp/pkg/xerr" "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" ) +const testRoomCoverURL = "https://cdn.example.com/room-cover.png" + type createRoomIMPublisher struct { err error roomID string @@ -32,6 +35,32 @@ func (p *createRoomIMPublisher) PublishRoomEvent(context.Context, tencentim.Room return nil } +func TestCreateRoomRequiresRoomAvatar(t *testing.T) { + ctx := context.Background() + repository := mysqltest.NewRepository(t) + svc := roomservice.New(roomservice.Config{ + NodeID: "node-create-room-avatar-test", + LeaseTTL: 10 * time.Second, + RankLimit: 20, + SnapshotEveryN: 1, + }, router.NewMemoryDirectory(), repository, followTestWallet{}, integration.NewNoopRoomEventPublisher(), integration.NewNoopOutboxPublisher()) + + _, err := svc.CreateRoom(ctx, &roomv1.CreateRoomRequest{ + Meta: roomservice.NewRequestMeta("room-avatar-required", 7000), + SeatCount: 10, + Mode: "voice", + RoomName: "Avatar Required", + RoomShortId: "avatar-required", + }) + if !xerr.IsCode(err, xerr.InvalidArgument) { + // 房间封面为空必须在 Room owner service 拒绝,避免 gateway 以外的内部调用创建空 cover_url 房间。 + t.Fatalf("expected INVALID_ARGUMENT without room avatar, got %v", err) + } + if _, exists, err := repository.GetRoomMeta(ctx, "room-avatar-required"); err != nil || exists { + t.Fatalf("room meta must not persist without avatar: exists=%t err=%v", exists, err) + } +} + func TestCreateRoomSynchronouslyEnsuresIMGroup(t *testing.T) { ctx := context.Background() repository := mysqltest.NewRepository(t) @@ -51,6 +80,7 @@ func TestCreateRoomSynchronouslyEnsuresIMGroup(t *testing.T) { Mode: "voice", VisibleRegionId: 1001, RoomName: "Sync IM Room", + RoomAvatar: testRoomCoverURL, RoomShortId: "sync-im-ok", }) if err != nil { @@ -83,6 +113,7 @@ func TestCreateRoomDoesNotPersistWhenIMGroupEnsureFails(t *testing.T) { Mode: "voice", VisibleRegionId: 1001, RoomName: "Sync IM Failure", + RoomAvatar: testRoomCoverURL, RoomShortId: "sync-im-fail", }); err == nil { t.Fatalf("create room must fail when IM group cannot be created") diff --git a/services/room-service/internal/room/service/follow_test.go b/services/room-service/internal/room/service/follow_test.go index 4665f438..624ca98b 100644 --- a/services/room-service/internal/room/service/follow_test.go +++ b/services/room-service/internal/room/service/follow_test.go @@ -47,6 +47,7 @@ func TestRoomFollowAffectsSnapshotAndFollowedFeed(t *testing.T) { Mode: "voice", VisibleRegionId: 1001, RoomName: "Follow Flow", + RoomAvatar: testRoomCoverURL, RoomShortId: "follow-flow", }); err != nil { t.Fatalf("create room failed: %v", err) diff --git a/services/room-service/internal/room/service/kick_test.go b/services/room-service/internal/room/service/kick_test.go index 7b440947..39f9aa2b 100644 --- a/services/room-service/internal/room/service/kick_test.go +++ b/services/room-service/internal/room/service/kick_test.go @@ -57,6 +57,7 @@ func TestKickUserRemovesPresenceBanAndRTCConnection(t *testing.T) { Mode: "voice", VisibleRegionId: 8101, RoomName: "Kick Flow", + RoomAvatar: testRoomCoverURL, RoomShortId: "kick-flow", }); err != nil { t.Fatalf("create kick room fixture failed: %v", err) @@ -122,6 +123,7 @@ func TestKickUserDurationExpiresAndAllowsJoin(t *testing.T) { Mode: "voice", VisibleRegionId: 8101, RoomName: "Kick Duration", + RoomAvatar: testRoomCoverURL, RoomShortId: "kick-duration", }); err != nil { t.Fatalf("create duration room fixture failed: %v", err) @@ -195,6 +197,7 @@ func TestListRoomBannedUsersReturnsActiveOnlyForManagers(t *testing.T) { Mode: "voice", VisibleRegionId: 8101, RoomName: "Banned Users", + RoomAvatar: testRoomCoverURL, RoomShortId: "banned-users", }); err != nil { t.Fatalf("create banned users room failed: %v", err) @@ -277,6 +280,7 @@ func TestCloseRoomByAdminEvictsCurrentUsersThroughOutboxAndRTC(t *testing.T) { Mode: "voice", VisibleRegionId: 8101, RoomName: "Close Flow", + RoomAvatar: testRoomCoverURL, RoomShortId: "close-flow", }); err != nil { t.Fatalf("create close room fixture failed: %v", err) diff --git a/services/room-service/internal/room/service/lock_test.go b/services/room-service/internal/room/service/lock_test.go index 1b663227..86456703 100644 --- a/services/room-service/internal/room/service/lock_test.go +++ b/services/room-service/internal/room/service/lock_test.go @@ -32,6 +32,7 @@ func TestRoomPasswordControlsJoinAndListLockedFlag(t *testing.T) { Mode: "voice", VisibleRegionId: 6101, RoomName: "Locked Room", + RoomAvatar: testRoomCoverURL, RoomShortId: "lock-flow", }); err != nil { t.Fatalf("create locked room fixture failed: %v", err) diff --git a/services/room-service/internal/room/service/pin_test.go b/services/room-service/internal/room/service/pin_test.go index 37d3f49c..73589e81 100644 --- a/services/room-service/internal/room/service/pin_test.go +++ b/services/room-service/internal/room/service/pin_test.go @@ -73,6 +73,7 @@ func createPinnedListRoom(t *testing.T, ctx context.Context, svc *roomservice.Se Mode: "voice", VisibleRegionId: regionID, RoomName: roomID, + RoomAvatar: testRoomCoverURL, RoomShortId: shortID, }); err != nil { t.Fatalf("create room %s failed: %v", roomID, err) diff --git a/services/room-service/internal/room/service/room_treasure_test.go b/services/room-service/internal/room/service/room_treasure_test.go index 36d9ccfd..a11c3043 100644 --- a/services/room-service/internal/room/service/room_treasure_test.go +++ b/services/room-service/internal/room/service/room_treasure_test.go @@ -549,6 +549,7 @@ func createTreasureRoom(t *testing.T, ctx context.Context, svc *roomservice.Serv Mode: "voice", VisibleRegionId: regionID, RoomName: roomID, + RoomAvatar: testRoomCoverURL, RoomShortId: roomID, }); err != nil { t.Fatalf("create treasure room failed: %v", err) diff --git a/services/room-service/internal/transport/grpc/server_test.go b/services/room-service/internal/transport/grpc/server_test.go index 0e31a1d7..48138a62 100644 --- a/services/room-service/internal/transport/grpc/server_test.go +++ b/services/room-service/internal/transport/grpc/server_test.go @@ -135,6 +135,7 @@ func TestOwnerForwardingUsesRedisRouteAndNodeRegistry(t *testing.T) { SeatCount: 10, Mode: "voice", RoomName: "Forward Room", + RoomAvatar: "https://cdn.example.com/room-cover.png", RoomShortId: "forward-short", }) if err != nil { diff --git a/services/user-service/deploy/mysql/initdb/001_user_service.sql b/services/user-service/deploy/mysql/initdb/001_user_service.sql index b9dece67..257720e5 100644 --- a/services/user-service/deploy/mysql/initdb/001_user_service.sql +++ b/services/user-service/deploy/mysql/initdb/001_user_service.sql @@ -65,6 +65,7 @@ CREATE TABLE IF NOT EXISTS users ( updated_at_ms BIGINT NOT NULL COMMENT '更新时间,UTC epoch ms', UNIQUE KEY uk_users_default_display_user_id (app_code, default_display_user_id), UNIQUE KEY uk_users_current_display_user_id (app_code, current_display_user_id), + UNIQUE KEY uk_users_register_device_id (app_code, register_device_id), KEY idx_users_status (app_code, status), KEY idx_users_region_status (app_code, region_id, status), KEY idx_users_country_region (app_code, country, region_id) diff --git a/services/user-service/internal/service/auth/service.go b/services/user-service/internal/service/auth/service.go index 87632096..674690a0 100644 --- a/services/user-service/internal/service/auth/service.go +++ b/services/user-service/internal/service/auth/service.go @@ -58,6 +58,8 @@ type AuthRepository interface { RevokeSessionWithReason(ctx context.Context, sessionID string, revokedAtMs int64, reason string, requestID string, revokedBy string) (bool, error) // FindThirdPartyIdentity 查找 provider + subject 的绑定。 FindThirdPartyIdentity(ctx context.Context, provider string, providerSubject string) (authdomain.ThirdPartyIdentity, error) + // FindUserIDByRegisterDeviceID 查找当前 App 下已经占用注册设备号的用户。 + FindUserIDByRegisterDeviceID(ctx context.Context, appCode string, deviceID string) (int64, error) // CreateThirdPartyUser 原子创建用户、默认短号、三方身份和首个 session。 CreateThirdPartyUser(ctx context.Context, user userdomain.User, identity userdomain.Identity, thirdParty authdomain.ThirdPartyIdentity, session authdomain.Session, invite invitedomain.BindCommand) error // RecordLoginAudit 记录登录审计,主链路不会因为审计失败而失败。 diff --git a/services/user-service/internal/service/auth/service_test.go b/services/user-service/internal/service/auth/service_test.go index ed697bf4..1e167750 100644 --- a/services/user-service/internal/service/auth/service_test.go +++ b/services/user-service/internal/service/auth/service_test.go @@ -535,6 +535,39 @@ func TestThirdPartyLoginOrRegister(t *testing.T) { } } +func TestThirdPartyRegisterRejectsReusedDeviceID(t *testing.T) { + // 设备号只限制新账号注册;同一个 provider subject 的再次登录仍然可以创建新 session。 + ctx := context.Background() + repository := mysqltest.NewRepository(t) + seedCountry(t, repository, "SG") + now := time.UnixMilli(1000) + svc := newAuthService(repository, &now, []int64{900020, 900021}, []string{"100020", "100021"}) + registration := thirdPartyRegistration("ios") + registration.DeviceID = "device-one-account" + + firstToken, isNewUser, err := svc.LoginThirdParty(ctx, "wechat", "openid-device-first", registration, authservice.Meta{RequestID: "req-device-first"}) + if err != nil { + t.Fatalf("first LoginThirdParty failed: %v", err) + } + if !isNewUser || firstToken.UserID != 900020 { + t.Fatalf("unexpected first third-party result: token=%+v isNew=%v", firstToken, isNewUser) + } + + existingToken, isNewUser, err := svc.LoginThirdParty(ctx, "wechat", "openid-device-first", registration, authservice.Meta{RequestID: "req-device-existing"}) + if err != nil { + t.Fatalf("existing LoginThirdParty should not be blocked by device limit: %v", err) + } + if isNewUser || existingToken.UserID != firstToken.UserID { + t.Fatalf("expected existing third-party login, token=%+v isNew=%v", existingToken, isNewUser) + } + + _, _, err = svc.LoginThirdParty(ctx, "wechat", "openid-device-second", registration, authservice.Meta{RequestID: "req-device-second"}) + if !xerr.IsCode(err, xerr.DeviceAlreadyRegistered) { + // 第二个 provider subject 会创建新账号,必须被注册设备唯一性拦截。 + t.Fatalf("expected DEVICE_ALREADY_REGISTERED for reused device, got %v", err) + } +} + func TestThirdPartyRegisterAllowsMinimalProfileFields(t *testing.T) { // 三方首次登录只需要认证材料、设备和平台;公开资料由后续 onboarding 原子提交。 ctx := context.Background() diff --git a/services/user-service/internal/service/auth/third_party.go b/services/user-service/internal/service/auth/third_party.go index 33a7b0bf..c46949e5 100644 --- a/services/user-service/internal/service/auth/third_party.go +++ b/services/user-service/internal/service/auth/third_party.go @@ -83,6 +83,9 @@ func (s *Service) LoginThirdParty(ctx context.Context, provider string, credenti s.audit(ctx, meta, 0, loginThird, provider, resultFailed, string(xerr.CodeOf(err))) return authdomain.Token{}, false, err } + if err := s.ensureDeviceCanRegisterThirdParty(ctx, registration, meta, provider); err != nil { + return authdomain.Token{}, false, err + } registration, err = s.resolveRegistrationCountry(ctx, registration) if err != nil { return authdomain.Token{}, false, err @@ -91,6 +94,23 @@ func (s *Service) LoginThirdParty(ctx context.Context, provider string, credenti return s.createThirdPartyUser(ctx, provider, profile, registration, meta) } +func (s *Service) ensureDeviceCanRegisterThirdParty(ctx context.Context, registration authdomain.ThirdPartyRegistration, meta Meta, provider string) error { + // 设备号限制只约束“创建新账号”路径;已绑定三方身份登录不经过这里,避免把正常跨设备登录误拦。 + userID, err := s.authRepository.FindUserIDByRegisterDeviceID(ctx, registration.AppCode, registration.DeviceID) + if xerr.IsCode(err, xerr.NotFound) { + return nil + } + if err != nil { + // 设备查重失败不能退化为注册,否则并发和依赖异常都会突破一机一号约束。 + s.audit(ctx, meta, 0, loginThird, provider, resultFailed, string(xerr.CodeOf(err))) + return err + } + + // 已有任意用户占用该注册设备时拒绝创建新账号;不返回 user_id,避免客户端枚举设备归属。 + s.audit(ctx, meta, userID, loginThird, provider, resultFailed, string(xerr.DeviceAlreadyRegistered)) + return xerr.New(xerr.DeviceAlreadyRegistered, "device already registered") +} + func (s *Service) loginExistingThirdParty(ctx context.Context, identity authdomain.ThirdPartyIdentity, registration authdomain.ThirdPartyRegistration, meta Meta) (authdomain.Token, bool, error) { // 已绑定三方用户登录时仍要重新读取用户快照,确保禁用状态和靓号过期生效。 user, err := s.freshUser(ctx, identity.UserID, s.now().UnixMilli(), meta.RequestID) diff --git a/services/user-service/internal/storage/mysql/auth/common.go b/services/user-service/internal/storage/mysql/auth/common.go index f54b8077..495435b4 100644 --- a/services/user-service/internal/storage/mysql/auth/common.go +++ b/services/user-service/internal/storage/mysql/auth/common.go @@ -31,6 +31,8 @@ func mapAuthDuplicateError(err error) error { // 根据约束名或表名把重复键错误映射成稳定领域 reason。 message := mysqlErr.Message switch { + case strings.Contains(message, "uk_users_register_device_id"): + return xerr.New(xerr.DeviceAlreadyRegistered, "device already registered") case strings.Contains(message, "password_accounts"): return xerr.New(xerr.PasswordAlreadySet, "password already set") case strings.Contains(message, "third_party") || strings.Contains(message, "uk_third_party_provider_subject"): diff --git a/services/user-service/internal/storage/mysql/auth/third_party.go b/services/user-service/internal/storage/mysql/auth/third_party.go index 49f744d1..d681cdea 100644 --- a/services/user-service/internal/storage/mysql/auth/third_party.go +++ b/services/user-service/internal/storage/mysql/auth/third_party.go @@ -32,6 +32,25 @@ func (r *Repository) FindThirdPartyIdentity(ctx context.Context, provider string return identity, nil } +func (r *Repository) FindUserIDByRegisterDeviceID(ctx context.Context, appCode string, deviceID string) (int64, error) { + var userID int64 + err := r.db.QueryRowContext(ctx, ` + SELECT user_id + FROM users + WHERE app_code = ? AND register_device_id = ? + LIMIT 1 + `, appcode.Normalize(appCode), deviceID).Scan(&userID) + if err == sql.ErrNoRows { + // 未查到占用者时,service 才允许新三方身份走注册事务。 + return 0, xerr.New(xerr.NotFound, "registered device not found") + } + if err != nil { + return 0, err + } + + return userID, nil +} + // CreateThirdPartyUser 原子创建用户、默认短号、三方身份和首个 session。 func (r *Repository) CreateThirdPartyUser(ctx context.Context, user userdomain.User, identity userdomain.Identity, thirdParty authdomain.ThirdPartyIdentity, session authdomain.Session, inviteBind invitedomain.BindCommand) error { diff --git a/services/user-service/internal/testutil/mysqltest/mysqltest.go b/services/user-service/internal/testutil/mysqltest/mysqltest.go index c4279c4f..544ba51f 100644 --- a/services/user-service/internal/testutil/mysqltest/mysqltest.go +++ b/services/user-service/internal/testutil/mysqltest/mysqltest.go @@ -290,6 +290,11 @@ func (r *Repository) FindThirdPartyIdentity(ctx context.Context, provider string return r.Repository.AuthRepository().FindThirdPartyIdentity(ctx, provider, providerSubject) } +// FindUserIDByRegisterDeviceID 让测试 wrapper 继续满足认证接口。 +func (r *Repository) FindUserIDByRegisterDeviceID(ctx context.Context, appCode string, deviceID string) (int64, error) { + return r.Repository.AuthRepository().FindUserIDByRegisterDeviceID(ctx, appCode, deviceID) +} + // CreateThirdPartyUser 让测试 wrapper 继续满足认证接口。 func (r *Repository) CreateThirdPartyUser(ctx context.Context, user userdomain.User, identity userdomain.Identity, thirdParty authdomain.ThirdPartyIdentity, session authdomain.Session, invite invitedomain.BindCommand) error { return r.Repository.AuthRepository().CreateThirdPartyUser(ctx, user, identity, thirdParty, session, invite)