Enforce room cover and device registration uniqueness
This commit is contained in:
parent
6d4648bf8d
commit
0ad227187f
@ -3313,7 +3313,7 @@ func (x *SetRoomBackgroundResponse) GetBackground() *RoomBackgroundImage {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// CreateRoomRequest 创建一个新的房间执行单元。
|
// 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。
|
// 同一个 meta.actor_user_id 只能作为 owner 创建一个房间;重复创建返回 Conflict。
|
||||||
type CreateRoomRequest struct {
|
type CreateRoomRequest struct {
|
||||||
state protoimpl.MessageState `protogen:"open.v1"`
|
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"`
|
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"]。
|
// room_name 是客户端创建房间时填写的展示名称,room-service 会写入 RoomSnapshot.room_ext["title"]。
|
||||||
RoomName string `protobuf:"bytes,5,opt,name=room_name,json=roomName,proto3" json:"room_name,omitempty"`
|
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"`
|
RoomAvatar string `protobuf:"bytes,6,opt,name=room_avatar,json=roomAvatar,proto3" json:"room_avatar,omitempty"`
|
||||||
// room_description 是房间简介,只作为房间扩展资料保存,不参与 Room Cell 高频状态决策。
|
// room_description 是房间简介,只作为房间扩展资料保存,不参与 Room Cell 高频状态决策。
|
||||||
RoomDescription string `protobuf:"bytes,7,opt,name=room_description,json=roomDescription,proto3" json:"room_description,omitempty"`
|
RoomDescription string `protobuf:"bytes,7,opt,name=room_description,json=roomDescription,proto3" json:"room_description,omitempty"`
|
||||||
|
|||||||
@ -387,7 +387,7 @@ message SetRoomBackgroundResponse {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// CreateRoomRequest 创建一个新的房间执行单元。
|
// 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。
|
// 同一个 meta.actor_user_id 只能作为 owner 创建一个房间;重复创建返回 Conflict。
|
||||||
message CreateRoomRequest {
|
message CreateRoomRequest {
|
||||||
RequestMeta meta = 1;
|
RequestMeta meta = 1;
|
||||||
@ -399,7 +399,7 @@ message CreateRoomRequest {
|
|||||||
int64 visible_region_id = 4;
|
int64 visible_region_id = 4;
|
||||||
// room_name 是客户端创建房间时填写的展示名称,room-service 会写入 RoomSnapshot.room_ext["title"]。
|
// room_name 是客户端创建房间时填写的展示名称,room-service 会写入 RoomSnapshot.room_ext["title"]。
|
||||||
string room_name = 5;
|
string room_name = 5;
|
||||||
// room_avatar 是客户端可选上传的展示头像;为空时 room-service 写入默认系统头像。
|
// room_avatar 是客户端上传的房间封面;创建时必填,room-service 会写入 RoomSnapshot.room_ext["cover_url"]。
|
||||||
string room_avatar = 6;
|
string room_avatar = 6;
|
||||||
// room_description 是房间简介,只作为房间扩展资料保存,不参与 Room Cell 高频状态决策。
|
// room_description 是房间简介,只作为房间扩展资料保存,不参与 Room Cell 高频状态决策。
|
||||||
string room_description = 7;
|
string room_description = 7;
|
||||||
|
|||||||
@ -101,7 +101,7 @@ Content-Type: application/json
|
|||||||
| `seat_count` | 否 | int | 不传或传 `0` 使用后台默认麦位数;正数必须命中后台启用座位数。 |
|
| `seat_count` | 否 | int | 不传或传 `0` 使用后台默认麦位数;正数必须命中后台启用座位数。 |
|
||||||
| `mode` | 是 | string | 当前传 `voice`。 |
|
| `mode` | 是 | string | 当前传 `voice`。 |
|
||||||
| `room_name` | 是 | string | 房间标题,最长 128 个字符。 |
|
| `room_name` | 是 | string | 房间标题,最长 128 个字符。 |
|
||||||
| `room_avatar` | 否 | string | 房间头像 URL;为空时服务端写默认头像。 |
|
| `room_avatar` | 是 | string | 房间封面 URL;不传或空字符串会返回 `INVALID_ARGUMENT`。 |
|
||||||
| `room_description` | 否 | string | 房间简介,最长 512 个字符。 |
|
| `room_description` | 否 | string | 房间简介,最长 512 个字符。 |
|
||||||
|
|
||||||
成功响应:
|
成功响应:
|
||||||
@ -401,4 +401,3 @@ String newCommandId(String action) {
|
|||||||
| `/rooms/join` 返回 `PERMISSION_DENIED` | 可能被 ban 或锁房密码错误;不调用 `joinGroup`。 |
|
| `/rooms/join` 返回 `PERMISSION_DENIED` | 可能被 ban 或锁房密码错误;不调用 `joinGroup`。 |
|
||||||
| IM `joinGroup` 群不存在 | 当前不应出现;记录 `request_id`、`room_id`、`group_id` 上报。 |
|
| IM `joinGroup` 群不存在 | 当前不应出现;记录 `request_id`、`room_id`、`group_id` 上报。 |
|
||||||
| IM 登录票据过期 | 重新调用 `/api/v1/im/usersig` 并 `login`。 |
|
| IM 登录票据过期 | 重新调用 `/api/v1/im/usersig` 并 `login`。 |
|
||||||
|
|
||||||
|
|||||||
@ -130,7 +130,7 @@ sequenceDiagram
|
|||||||
participant U as user-service
|
participant U as user-service
|
||||||
participant R as room-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)
|
G->>U: GetUser(actor_user_id)
|
||||||
U-->>G: region_id, display_user_id
|
U-->>G: region_id, display_user_id
|
||||||
G->>G: generate room_id = app_code + uuid
|
G->>G: generate room_id = app_code + uuid
|
||||||
@ -146,7 +146,7 @@ sequenceDiagram
|
|||||||
- 客户端不能提交 `room_id`;gateway 按当前 `app_code` 生成 `<app_code>_<uuid>` 作为房间长 ID。
|
- 客户端不能提交 `room_id`;gateway 按当前 `app_code` 生成 `<app_code>_<uuid>` 作为房间长 ID。
|
||||||
- `room_short_id` 首版直接等于创建者当前 `display_user_id`,用于搜索、分享和展示。
|
- `room_short_id` 首版直接等于创建者当前 `display_user_id`,用于搜索、分享和展示。
|
||||||
- 同一个登录用户只能作为 owner 创建一个房间;room-service 按 `RequestMeta.actor_user_id` 判定 owner,不能通过更换 `room_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`。
|
- `visible_region_id` 来自创建者当前 `users.region_id`。
|
||||||
- 创建者没有区域时写 `0`,进入 `GLOBAL` 列表桶。
|
- 创建者没有区域时写 `0`,进入 `GLOBAL` 列表桶。
|
||||||
- 房间创建成功后,创建者改国家不自动修改房间区域。
|
- 房间创建成功后,创建者改国家不自动修改房间区域。
|
||||||
|
|||||||
@ -83,7 +83,7 @@ sequenceDiagram
|
|||||||
U-->>G: access token with user_id
|
U-->>G: access token with user_id
|
||||||
G-->>C: token envelope
|
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)
|
G->>R: CreateRoom(actor_user_id, room profile)
|
||||||
R->>T: create_group(room_id)
|
R->>T: create_group(room_id)
|
||||||
T-->>R: group ready
|
T-->>R: group ready
|
||||||
|
|||||||
@ -75,12 +75,12 @@ Authorization: Bearer <access_token>
|
|||||||
"command_id": "cmd_create_room_550e8400",
|
"command_id": "cmd_create_room_550e8400",
|
||||||
"mode": "voice",
|
"mode": "voice",
|
||||||
"room_name": "周末语音房",
|
"room_name": "周末语音房",
|
||||||
"room_avatar": "",
|
"room_avatar": "https://cdn.example/room.png",
|
||||||
"room_description": "本房间用于轻量聊天和上麦互动"
|
"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`。
|
||||||
|
|
||||||
响应必须包含:
|
响应必须包含:
|
||||||
|
|
||||||
|
|||||||
@ -63,6 +63,12 @@ var catalog = map[Code]Spec{
|
|||||||
RegionCountryConflict: spec(codes.FailedPrecondition, httpStatusConflict, RegionCountryConflict, "conflict"),
|
RegionCountryConflict: spec(codes.FailedPrecondition, httpStatusConflict, RegionCountryConflict, "conflict"),
|
||||||
RegionDisabled: spec(codes.FailedPrecondition, httpStatusConflict, RegionDisabled, "conflict"),
|
RegionDisabled: spec(codes.FailedPrecondition, httpStatusConflict, RegionDisabled, "conflict"),
|
||||||
InvalidInviteCode: spec(codes.InvalidArgument, httpStatusBadRequest, InvalidInviteCode, "invalid argument"),
|
InvalidInviteCode: spec(codes.InvalidArgument, httpStatusBadRequest, InvalidInviteCode, "invalid argument"),
|
||||||
|
DeviceAlreadyRegistered: spec(
|
||||||
|
codes.FailedPrecondition,
|
||||||
|
httpStatusConflict,
|
||||||
|
DeviceAlreadyRegistered,
|
||||||
|
"conflict",
|
||||||
|
),
|
||||||
|
|
||||||
InsufficientBalance: spec(codes.FailedPrecondition, httpStatusConflict, InsufficientBalance, "insufficient balance"),
|
InsufficientBalance: spec(codes.FailedPrecondition, httpStatusConflict, InsufficientBalance, "insufficient balance"),
|
||||||
DuplicateBillingCommand: spec(codes.AlreadyExists, httpStatusConflict, DuplicateBillingCommand, "conflict"),
|
DuplicateBillingCommand: spec(codes.AlreadyExists, httpStatusConflict, DuplicateBillingCommand, "conflict"),
|
||||||
|
|||||||
@ -71,6 +71,8 @@ const (
|
|||||||
RegionDisabled Code = "REGION_DISABLED"
|
RegionDisabled Code = "REGION_DISABLED"
|
||||||
// InvalidInviteCode 表示邀请码不存在、停用、跨 App、邀请人不可用或用户尝试自邀。
|
// InvalidInviteCode 表示邀请码不存在、停用、跨 App、邀请人不可用或用户尝试自邀。
|
||||||
InvalidInviteCode Code = "INVALID_INVITE_CODE"
|
InvalidInviteCode Code = "INVALID_INVITE_CODE"
|
||||||
|
// DeviceAlreadyRegistered 表示同一注册设备已经创建过账号,不能再次作为三方注册入口。
|
||||||
|
DeviceAlreadyRegistered Code = "DEVICE_ALREADY_REGISTERED"
|
||||||
|
|
||||||
// InsufficientBalance 表示钱包余额不足。
|
// InsufficientBalance 表示钱包余额不足。
|
||||||
InsufficientBalance Code = "INSUFFICIENT_BALANCE"
|
InsufficientBalance Code = "INSUFFICIENT_BALANCE"
|
||||||
|
|||||||
5
scripts/mysql/036_user_register_device_unique.sql
Normal file
5
scripts/mysql/036_user_register_device_unique.sql
Normal 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);
|
||||||
@ -5814,7 +5814,7 @@ func TestRoutesWriteInvalidJSONEnvelope(t *testing.T) {
|
|||||||
func TestCreateRoomIgnoresClientRoomIDAndGeneratesScopedRoomID(t *testing.T) {
|
func TestCreateRoomIgnoresClientRoomIDAndGeneratesScopedRoomID(t *testing.T) {
|
||||||
client := &fakeRoomClient{}
|
client := &fakeRoomClient{}
|
||||||
router := NewHandlerWithClients(client, nil, nil, &fakeUserProfileClient{regionID: 1001}).Routes(auth.NewVerifier("secret"))
|
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("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
|
||||||
request.Header.Set("X-Request-ID", "req-generated-room-id")
|
request.Header.Set("X-Request-ID", "req-generated-room-id")
|
||||||
recorder := httptest.NewRecorder()
|
recorder := httptest.NewRecorder()
|
||||||
@ -5835,7 +5835,7 @@ func TestCreateRoomIgnoresClientRoomIDAndGeneratesScopedRoomID(t *testing.T) {
|
|||||||
func TestCreateRoomAllowsMissingSeatCount(t *testing.T) {
|
func TestCreateRoomAllowsMissingSeatCount(t *testing.T) {
|
||||||
client := &fakeRoomClient{}
|
client := &fakeRoomClient{}
|
||||||
router := NewHandlerWithClients(client, nil, nil, &fakeUserProfileClient{regionID: 1001}).Routes(auth.NewVerifier("secret"))
|
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("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
|
||||||
request.Header.Set("X-Request-ID", "req-create-default-seat")
|
request.Header.Set("X-Request-ID", "req-create-default-seat")
|
||||||
recorder := httptest.NewRecorder()
|
recorder := httptest.NewRecorder()
|
||||||
@ -5972,10 +5972,11 @@ func TestCreateRoomRejectsMissingRequiredFieldsBeforeGRPC(t *testing.T) {
|
|||||||
name string
|
name string
|
||||||
body string
|
body string
|
||||||
}{
|
}{
|
||||||
{name: "command_id", body: `{"room_id":"room-1","seat_count":10,"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"}`},
|
{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"}`},
|
{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: "negative_seat_count", body: `{"room_id":"room-1","command_id":"cmd-create-negative-seat","seat_count":-1,"mode":"voice","room_name":"Lobby"}`},
|
{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 {
|
for _, test := range tests {
|
||||||
@ -5999,7 +6000,7 @@ func TestCreateRoomRejectsMissingRequiredFieldsBeforeGRPC(t *testing.T) {
|
|||||||
|
|
||||||
func TestRoutesWriteUpstreamErrorEnvelope(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"))
|
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 := httptest.NewRequest(http.MethodPost, "/api/v1/rooms/create", bytes.NewReader(body))
|
||||||
request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
|
request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
|
||||||
request.Header.Set("X-Request-ID", "req-upstream")
|
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")),
|
createErr: xerr.ToGRPCError(xerr.New(xerr.Conflict, "owner already has room")),
|
||||||
}
|
}
|
||||||
router := NewHandlerWithClients(roomClient, nil, nil, &fakeUserProfileClient{regionID: 1001}).Routes(auth.NewVerifier("secret"))
|
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 := httptest.NewRequest(http.MethodPost, "/api/v1/rooms/create", bytes.NewReader(body))
|
||||||
request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
|
request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
|
||||||
request.Header.Set("X-Request-ID", "req-owner-conflict")
|
request.Header.Set("X-Request-ID", "req-owner-conflict")
|
||||||
|
|||||||
@ -675,8 +675,8 @@ func (h *Handler) createRoom(writer http.ResponseWriter, request *http.Request)
|
|||||||
body.RoomName = strings.TrimSpace(body.RoomName)
|
body.RoomName = strings.TrimSpace(body.RoomName)
|
||||||
body.RoomAvatar = strings.TrimSpace(body.RoomAvatar)
|
body.RoomAvatar = strings.TrimSpace(body.RoomAvatar)
|
||||||
body.RoomDescription = strings.TrimSpace(body.RoomDescription)
|
body.RoomDescription = strings.TrimSpace(body.RoomDescription)
|
||||||
if body.CommandID == "" || body.SeatCount < 0 || body.Mode == "" || body.RoomName == "" {
|
if body.CommandID == "" || body.SeatCount < 0 || body.Mode == "" || body.RoomName == "" || body.RoomAvatar == "" {
|
||||||
// command_id 必须来自客户端用户动作,seat_count 可省略走后台默认值;mode 和 room_name 仍是创建页最小必填信息。
|
// command_id 必须来自客户端用户动作,seat_count 可省略走后台默认值;mode、room_name 和 room_avatar 是创建页最小必填信息。
|
||||||
httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
|
httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,12 +1,35 @@
|
|||||||
package roomapi
|
package roomapi
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
roomv1 "hyapp.local/api/proto/room/v1"
|
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) {
|
func TestRoomGiftTabsUsesTypeCodeAsKeyAndTabKeyAsLabel(t *testing.T) {
|
||||||
tabs := roomGiftTabs(
|
tabs := roomGiftTabs(
|
||||||
[]giftConfigData{
|
[]giftConfigData{
|
||||||
|
|||||||
@ -66,7 +66,7 @@ type CreateRoom struct {
|
|||||||
VisibleRegionID int64 `json:"visible_region_id"`
|
VisibleRegionID int64 `json:"visible_region_id"`
|
||||||
// RoomName 是房间展示名称,恢复时回填 RoomExt["title"],不拆成独立状态 owner。
|
// RoomName 是房间展示名称,恢复时回填 RoomExt["title"],不拆成独立状态 owner。
|
||||||
RoomName string `json:"room_name"`
|
RoomName string `json:"room_name"`
|
||||||
// RoomAvatar 是房间展示头像,恢复时回填 RoomExt["cover_url"]。
|
// RoomAvatar 是创建时必填的房间封面,恢复时回填 RoomExt["cover_url"]。
|
||||||
RoomAvatar string `json:"room_avatar"`
|
RoomAvatar string `json:"room_avatar"`
|
||||||
// RoomDescription 是房间简介,恢复时回填 RoomExt["description"]。
|
// RoomDescription 是房间简介,恢复时回填 RoomExt["description"]。
|
||||||
RoomDescription string `json:"room_description"`
|
RoomDescription string `json:"room_description"`
|
||||||
|
|||||||
@ -32,6 +32,7 @@ func TestRoomBackgroundSaveListSetFlow(t *testing.T) {
|
|||||||
Mode: "voice",
|
Mode: "voice",
|
||||||
VisibleRegionId: 1001,
|
VisibleRegionId: 1001,
|
||||||
RoomName: "Background Flow",
|
RoomName: "Background Flow",
|
||||||
|
RoomAvatar: testRoomCoverURL,
|
||||||
RoomShortId: "bg-flow",
|
RoomShortId: "bg-flow",
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
t.Fatalf("create room failed: %v", err)
|
t.Fatalf("create room failed: %v", err)
|
||||||
@ -104,6 +105,7 @@ func TestRoomBackgroundOnlyOwnerCanEdit(t *testing.T) {
|
|||||||
Mode: "voice",
|
Mode: "voice",
|
||||||
VisibleRegionId: 1001,
|
VisibleRegionId: 1001,
|
||||||
RoomName: "Background Owner",
|
RoomName: "Background Owner",
|
||||||
|
RoomAvatar: testRoomCoverURL,
|
||||||
RoomShortId: "bg-owner",
|
RoomShortId: "bg-owner",
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
t.Fatalf("create room failed: %v", err)
|
t.Fatalf("create room failed: %v", err)
|
||||||
|
|||||||
@ -27,7 +27,7 @@ const (
|
|||||||
roomExtShortIDKey = "room_short_id"
|
roomExtShortIDKey = "room_short_id"
|
||||||
roomExtBackgroundKey = "background_url"
|
roomExtBackgroundKey = "background_url"
|
||||||
|
|
||||||
// defaultRoomAvatar 是房间头像缺省值,语义上代表系统默认资源,不绑定外部 CDN 地址。
|
// defaultRoomAvatar 只服务房间资料更新/后台清空封面后的兜底;创建房间必须显式提交封面。
|
||||||
defaultRoomAvatar = "hyapp://room/default-avatar"
|
defaultRoomAvatar = "hyapp://room/default-avatar"
|
||||||
|
|
||||||
// RoomExt 的展示字段最终会进入 room_list_entries 和客户端快照,服务端在入口裁剪长度边界。
|
// RoomExt 的展示字段最终会进入 room_list_entries 和客户端快照,服务端在入口裁剪长度边界。
|
||||||
@ -304,8 +304,8 @@ func normalizeCreateRoomProfile(req *roomv1.CreateRoomRequest) (roomProfileInput
|
|||||||
|
|
||||||
avatar := strings.TrimSpace(req.GetRoomAvatar())
|
avatar := strings.TrimSpace(req.GetRoomAvatar())
|
||||||
if avatar == "" {
|
if avatar == "" {
|
||||||
// 默认头像由 room-service 决定,客户端和 gateway 不需要复制同一套兜底策略。
|
// 创建房间封面必须来自客户端上传结果;owner service 兜底校验可以防止内部 gRPC 绕过 gateway 创建空封面房。
|
||||||
avatar = defaultRoomAvatar
|
return roomProfileInput{}, xerr.New(xerr.InvalidArgument, "room_avatar is required")
|
||||||
}
|
}
|
||||||
if utf8.RuneCountInString(avatar) > maxRoomAvatarRunes {
|
if utf8.RuneCountInString(avatar) > maxRoomAvatarRunes {
|
||||||
// cover_url 会投影到 room_list_entries.cover_url,超长值必须在写入前拒绝。
|
// cover_url 会投影到 room_list_entries.cover_url,超长值必须在写入前拒绝。
|
||||||
|
|||||||
@ -53,6 +53,7 @@ func TestRealCreateRoomEnsuresTencentIMGroup(t *testing.T) {
|
|||||||
Mode: "voice",
|
Mode: "voice",
|
||||||
VisibleRegionId: 1001,
|
VisibleRegionId: 1001,
|
||||||
RoomName: "Real Tencent IM Room",
|
RoomName: "Real Tencent IM Room",
|
||||||
|
RoomAvatar: testRoomCoverURL,
|
||||||
RoomShortId: strconv.FormatInt(ownerID, 10),
|
RoomShortId: strconv.FormatInt(ownerID, 10),
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@ -8,12 +8,15 @@ import (
|
|||||||
|
|
||||||
roomv1 "hyapp.local/api/proto/room/v1"
|
roomv1 "hyapp.local/api/proto/room/v1"
|
||||||
"hyapp/pkg/tencentim"
|
"hyapp/pkg/tencentim"
|
||||||
|
"hyapp/pkg/xerr"
|
||||||
"hyapp/services/room-service/internal/integration"
|
"hyapp/services/room-service/internal/integration"
|
||||||
roomservice "hyapp/services/room-service/internal/room/service"
|
roomservice "hyapp/services/room-service/internal/room/service"
|
||||||
"hyapp/services/room-service/internal/router"
|
"hyapp/services/room-service/internal/router"
|
||||||
"hyapp/services/room-service/internal/testutil/mysqltest"
|
"hyapp/services/room-service/internal/testutil/mysqltest"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const testRoomCoverURL = "https://cdn.example.com/room-cover.png"
|
||||||
|
|
||||||
type createRoomIMPublisher struct {
|
type createRoomIMPublisher struct {
|
||||||
err error
|
err error
|
||||||
roomID string
|
roomID string
|
||||||
@ -32,6 +35,32 @@ func (p *createRoomIMPublisher) PublishRoomEvent(context.Context, tencentim.Room
|
|||||||
return nil
|
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) {
|
func TestCreateRoomSynchronouslyEnsuresIMGroup(t *testing.T) {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
repository := mysqltest.NewRepository(t)
|
repository := mysqltest.NewRepository(t)
|
||||||
@ -51,6 +80,7 @@ func TestCreateRoomSynchronouslyEnsuresIMGroup(t *testing.T) {
|
|||||||
Mode: "voice",
|
Mode: "voice",
|
||||||
VisibleRegionId: 1001,
|
VisibleRegionId: 1001,
|
||||||
RoomName: "Sync IM Room",
|
RoomName: "Sync IM Room",
|
||||||
|
RoomAvatar: testRoomCoverURL,
|
||||||
RoomShortId: "sync-im-ok",
|
RoomShortId: "sync-im-ok",
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -83,6 +113,7 @@ func TestCreateRoomDoesNotPersistWhenIMGroupEnsureFails(t *testing.T) {
|
|||||||
Mode: "voice",
|
Mode: "voice",
|
||||||
VisibleRegionId: 1001,
|
VisibleRegionId: 1001,
|
||||||
RoomName: "Sync IM Failure",
|
RoomName: "Sync IM Failure",
|
||||||
|
RoomAvatar: testRoomCoverURL,
|
||||||
RoomShortId: "sync-im-fail",
|
RoomShortId: "sync-im-fail",
|
||||||
}); err == nil {
|
}); err == nil {
|
||||||
t.Fatalf("create room must fail when IM group cannot be created")
|
t.Fatalf("create room must fail when IM group cannot be created")
|
||||||
|
|||||||
@ -47,6 +47,7 @@ func TestRoomFollowAffectsSnapshotAndFollowedFeed(t *testing.T) {
|
|||||||
Mode: "voice",
|
Mode: "voice",
|
||||||
VisibleRegionId: 1001,
|
VisibleRegionId: 1001,
|
||||||
RoomName: "Follow Flow",
|
RoomName: "Follow Flow",
|
||||||
|
RoomAvatar: testRoomCoverURL,
|
||||||
RoomShortId: "follow-flow",
|
RoomShortId: "follow-flow",
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
t.Fatalf("create room failed: %v", err)
|
t.Fatalf("create room failed: %v", err)
|
||||||
|
|||||||
@ -57,6 +57,7 @@ func TestKickUserRemovesPresenceBanAndRTCConnection(t *testing.T) {
|
|||||||
Mode: "voice",
|
Mode: "voice",
|
||||||
VisibleRegionId: 8101,
|
VisibleRegionId: 8101,
|
||||||
RoomName: "Kick Flow",
|
RoomName: "Kick Flow",
|
||||||
|
RoomAvatar: testRoomCoverURL,
|
||||||
RoomShortId: "kick-flow",
|
RoomShortId: "kick-flow",
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
t.Fatalf("create kick room fixture failed: %v", err)
|
t.Fatalf("create kick room fixture failed: %v", err)
|
||||||
@ -122,6 +123,7 @@ func TestKickUserDurationExpiresAndAllowsJoin(t *testing.T) {
|
|||||||
Mode: "voice",
|
Mode: "voice",
|
||||||
VisibleRegionId: 8101,
|
VisibleRegionId: 8101,
|
||||||
RoomName: "Kick Duration",
|
RoomName: "Kick Duration",
|
||||||
|
RoomAvatar: testRoomCoverURL,
|
||||||
RoomShortId: "kick-duration",
|
RoomShortId: "kick-duration",
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
t.Fatalf("create duration room fixture failed: %v", err)
|
t.Fatalf("create duration room fixture failed: %v", err)
|
||||||
@ -195,6 +197,7 @@ func TestListRoomBannedUsersReturnsActiveOnlyForManagers(t *testing.T) {
|
|||||||
Mode: "voice",
|
Mode: "voice",
|
||||||
VisibleRegionId: 8101,
|
VisibleRegionId: 8101,
|
||||||
RoomName: "Banned Users",
|
RoomName: "Banned Users",
|
||||||
|
RoomAvatar: testRoomCoverURL,
|
||||||
RoomShortId: "banned-users",
|
RoomShortId: "banned-users",
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
t.Fatalf("create banned users room failed: %v", err)
|
t.Fatalf("create banned users room failed: %v", err)
|
||||||
@ -277,6 +280,7 @@ func TestCloseRoomByAdminEvictsCurrentUsersThroughOutboxAndRTC(t *testing.T) {
|
|||||||
Mode: "voice",
|
Mode: "voice",
|
||||||
VisibleRegionId: 8101,
|
VisibleRegionId: 8101,
|
||||||
RoomName: "Close Flow",
|
RoomName: "Close Flow",
|
||||||
|
RoomAvatar: testRoomCoverURL,
|
||||||
RoomShortId: "close-flow",
|
RoomShortId: "close-flow",
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
t.Fatalf("create close room fixture failed: %v", err)
|
t.Fatalf("create close room fixture failed: %v", err)
|
||||||
|
|||||||
@ -32,6 +32,7 @@ func TestRoomPasswordControlsJoinAndListLockedFlag(t *testing.T) {
|
|||||||
Mode: "voice",
|
Mode: "voice",
|
||||||
VisibleRegionId: 6101,
|
VisibleRegionId: 6101,
|
||||||
RoomName: "Locked Room",
|
RoomName: "Locked Room",
|
||||||
|
RoomAvatar: testRoomCoverURL,
|
||||||
RoomShortId: "lock-flow",
|
RoomShortId: "lock-flow",
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
t.Fatalf("create locked room fixture failed: %v", err)
|
t.Fatalf("create locked room fixture failed: %v", err)
|
||||||
|
|||||||
@ -73,6 +73,7 @@ func createPinnedListRoom(t *testing.T, ctx context.Context, svc *roomservice.Se
|
|||||||
Mode: "voice",
|
Mode: "voice",
|
||||||
VisibleRegionId: regionID,
|
VisibleRegionId: regionID,
|
||||||
RoomName: roomID,
|
RoomName: roomID,
|
||||||
|
RoomAvatar: testRoomCoverURL,
|
||||||
RoomShortId: shortID,
|
RoomShortId: shortID,
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
t.Fatalf("create room %s failed: %v", roomID, err)
|
t.Fatalf("create room %s failed: %v", roomID, err)
|
||||||
|
|||||||
@ -549,6 +549,7 @@ func createTreasureRoom(t *testing.T, ctx context.Context, svc *roomservice.Serv
|
|||||||
Mode: "voice",
|
Mode: "voice",
|
||||||
VisibleRegionId: regionID,
|
VisibleRegionId: regionID,
|
||||||
RoomName: roomID,
|
RoomName: roomID,
|
||||||
|
RoomAvatar: testRoomCoverURL,
|
||||||
RoomShortId: roomID,
|
RoomShortId: roomID,
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
t.Fatalf("create treasure room failed: %v", err)
|
t.Fatalf("create treasure room failed: %v", err)
|
||||||
|
|||||||
@ -135,6 +135,7 @@ func TestOwnerForwardingUsesRedisRouteAndNodeRegistry(t *testing.T) {
|
|||||||
SeatCount: 10,
|
SeatCount: 10,
|
||||||
Mode: "voice",
|
Mode: "voice",
|
||||||
RoomName: "Forward Room",
|
RoomName: "Forward Room",
|
||||||
|
RoomAvatar: "https://cdn.example.com/room-cover.png",
|
||||||
RoomShortId: "forward-short",
|
RoomShortId: "forward-short",
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@ -65,6 +65,7 @@ CREATE TABLE IF NOT EXISTS users (
|
|||||||
updated_at_ms BIGINT NOT NULL COMMENT '更新时间,UTC epoch ms',
|
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_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_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_status (app_code, status),
|
||||||
KEY idx_users_region_status (app_code, region_id, status),
|
KEY idx_users_region_status (app_code, region_id, status),
|
||||||
KEY idx_users_country_region (app_code, country, region_id)
|
KEY idx_users_country_region (app_code, country, region_id)
|
||||||
|
|||||||
@ -58,6 +58,8 @@ type AuthRepository interface {
|
|||||||
RevokeSessionWithReason(ctx context.Context, sessionID string, revokedAtMs int64, reason string, requestID string, revokedBy string) (bool, error)
|
RevokeSessionWithReason(ctx context.Context, sessionID string, revokedAtMs int64, reason string, requestID string, revokedBy string) (bool, error)
|
||||||
// FindThirdPartyIdentity 查找 provider + subject 的绑定。
|
// FindThirdPartyIdentity 查找 provider + subject 的绑定。
|
||||||
FindThirdPartyIdentity(ctx context.Context, provider string, providerSubject string) (authdomain.ThirdPartyIdentity, error)
|
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 原子创建用户、默认短号、三方身份和首个 session。
|
||||||
CreateThirdPartyUser(ctx context.Context, user userdomain.User, identity userdomain.Identity, thirdParty authdomain.ThirdPartyIdentity, session authdomain.Session, invite invitedomain.BindCommand) error
|
CreateThirdPartyUser(ctx context.Context, user userdomain.User, identity userdomain.Identity, thirdParty authdomain.ThirdPartyIdentity, session authdomain.Session, invite invitedomain.BindCommand) error
|
||||||
// RecordLoginAudit 记录登录审计,主链路不会因为审计失败而失败。
|
// RecordLoginAudit 记录登录审计,主链路不会因为审计失败而失败。
|
||||||
|
|||||||
@ -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) {
|
func TestThirdPartyRegisterAllowsMinimalProfileFields(t *testing.T) {
|
||||||
// 三方首次登录只需要认证材料、设备和平台;公开资料由后续 onboarding 原子提交。
|
// 三方首次登录只需要认证材料、设备和平台;公开资料由后续 onboarding 原子提交。
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|||||||
@ -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)))
|
s.audit(ctx, meta, 0, loginThird, provider, resultFailed, string(xerr.CodeOf(err)))
|
||||||
return authdomain.Token{}, false, 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)
|
registration, err = s.resolveRegistrationCountry(ctx, registration)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return authdomain.Token{}, false, err
|
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)
|
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) {
|
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)
|
user, err := s.freshUser(ctx, identity.UserID, s.now().UnixMilli(), meta.RequestID)
|
||||||
|
|||||||
@ -31,6 +31,8 @@ func mapAuthDuplicateError(err error) error {
|
|||||||
// 根据约束名或表名把重复键错误映射成稳定领域 reason。
|
// 根据约束名或表名把重复键错误映射成稳定领域 reason。
|
||||||
message := mysqlErr.Message
|
message := mysqlErr.Message
|
||||||
switch {
|
switch {
|
||||||
|
case strings.Contains(message, "uk_users_register_device_id"):
|
||||||
|
return xerr.New(xerr.DeviceAlreadyRegistered, "device already registered")
|
||||||
case strings.Contains(message, "password_accounts"):
|
case strings.Contains(message, "password_accounts"):
|
||||||
return xerr.New(xerr.PasswordAlreadySet, "password already set")
|
return xerr.New(xerr.PasswordAlreadySet, "password already set")
|
||||||
case strings.Contains(message, "third_party") || strings.Contains(message, "uk_third_party_provider_subject"):
|
case strings.Contains(message, "third_party") || strings.Contains(message, "uk_third_party_provider_subject"):
|
||||||
|
|||||||
@ -32,6 +32,25 @@ func (r *Repository) FindThirdPartyIdentity(ctx context.Context, provider string
|
|||||||
return identity, nil
|
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。
|
// 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 {
|
func (r *Repository) CreateThirdPartyUser(ctx context.Context, user userdomain.User, identity userdomain.Identity, thirdParty authdomain.ThirdPartyIdentity, session authdomain.Session, inviteBind invitedomain.BindCommand) error {
|
||||||
|
|||||||
@ -290,6 +290,11 @@ func (r *Repository) FindThirdPartyIdentity(ctx context.Context, provider string
|
|||||||
return r.Repository.AuthRepository().FindThirdPartyIdentity(ctx, provider, providerSubject)
|
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 继续满足认证接口。
|
// 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 {
|
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)
|
return r.Repository.AuthRepository().CreateThirdPartyUser(ctx, user, identity, thirdParty, session, invite)
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user