Enforce room cover and device registration uniqueness

This commit is contained in:
zhx 2026-06-04 21:26:47 +08:00
parent 6d4648bf8d
commit 0ad227187f
30 changed files with 186 additions and 25 deletions

View File

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

View File

@ -387,7 +387,7 @@ message SetRoomBackgroundResponse {
}
// CreateRoomRequest
// meta.room_idmeta.command_idmeta.actor_user_idmode room_name
// meta.room_idmeta.command_idmeta.actor_user_idmoderoom_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;

View File

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

View File

@ -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` 生成 `<app_code>_<uuid>` 作为房间长 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` 列表桶。
- 房间创建成功后,创建者改国家不自动修改房间区域。

View File

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

View File

@ -75,12 +75,12 @@ Authorization: Bearer <access_token>
"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`
响应必须包含:

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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 记录登录审计,主链路不会因为审计失败而失败。

View File

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

View File

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

View File

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

View File

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

View File

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