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 创建一个新的房间执行单元。
|
||||
// 必填语义: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"`
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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`。 |
|
||||
|
||||
|
||||
@ -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` 列表桶。
|
||||
- 房间创建成功后,创建者改国家不自动修改房间区域。
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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`。
|
||||
|
||||
响应必须包含:
|
||||
|
||||
|
||||
@ -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"),
|
||||
|
||||
@ -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"
|
||||
|
||||
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) {
|
||||
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")
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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{
|
||||
|
||||
@ -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"`
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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,超长值必须在写入前拒绝。
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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 记录登录审计,主链路不会因为审计失败而失败。
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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"):
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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)
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user