From b0a3515bf22ed4b6b76853e0f4504107bac27da0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B5=85=E5=BF=B5?= Date: Tue, 29 Sep 2026 15:20:09 +0800 Subject: [PATCH] =?UTF-8?q?feat(api):=20=E7=94=A8=E6=88=B7=E5=89=8D?= =?UTF-8?q?=E7=BC=80=E6=9F=A5=E6=89=BE=E5=B9=B6=E5=85=A5=20displayName?= =?UTF-8?q?=E2=80=94=E2=80=94=E6=88=90=E5=91=98=E6=B7=BB=E5=8A=A0=E5=8F=AF?= =?UTF-8?q?=E6=8C=89=E5=A7=93=E5=90=8D=E6=90=9C=E4=BA=BA=EF=BC=88=E6=96=B9?= =?UTF-8?q?=E6=A1=88=20A=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 线上根因:V1.1 账号=手机号,运营按姓名搜索恒空(accountPrefix 仅匹配 account)。扩展为 account OR displayName 前缀(括号分组保资格过滤不被 短路),大小写敏感/激活过滤/account 升序语义不变;与管理列表 query 模式 对齐。契约描述同步。附 members-categories e2e 冷启动超时对齐 120s。 Co-Authored-By: Claude Code --- packages/contracts/openapi.yaml | 10 +++--- packages/contracts/src/generated/openapi.d.ts | 10 +++--- .../api/src/admin/users-lookup.service.ts | 8 ++++- .../api/test/members-categories.e2e-spec.ts | 4 +++ services/api/test/users-lookup.e2e-spec.ts | 32 +++++++++++++++++++ 5 files changed, 55 insertions(+), 9 deletions(-) diff --git a/packages/contracts/openapi.yaml b/packages/contracts/openapi.yaml index 8ef830b..ccfcfe3 100644 --- a/packages/contracts/openapi.yaml +++ b/packages/contracts/openapi.yaml @@ -1313,9 +1313,11 @@ paths: 双模式统一端点(P4a 决策:与 P1b 账号前缀查询合一,响应统一 AdminUserList——条目为 AdminUser 字段超集,管理端 UI 按场景裁剪展示): · 前缀模式(提供 accountPrefix):邀请协作者前置查询,沿用 P1b 语义 - ——大小写敏感前缀匹配(服务端按二进制校对执行 LIKE prefix%)、 - 仅「激活资格」用户(credentialStatus=active 且 sourceStatus≠disabled - 且 !localLocked,与成员添加的目标校验一致)、account 升序; + ——大小写敏感前缀匹配(服务端按二进制校对执行 LIKE prefix%,匹配 + account **或 displayName** 前缀——2026-09-29 扩展:V1.1 账号=手机号, + 运营按姓名搜人是自然预期)、仅「激活资格」用户(credentialStatus= + active 且 sourceStatus≠disabled 且 !localLocked,与成员添加的目标 + 校验一致)、account 升序; 访问策略沿用 P1b 决策:任何已认证工作人员可用(邀请发起方不限于 创建者/管理员,泄露面为已认证内部人员,风险可接受)。 · 管理列表模式(未提供 accountPrefix):全量用户治理列表(游标分页, @@ -1328,7 +1330,7 @@ paths: parameters: - name: accountPrefix in: query - description: 账号前缀(≥2 字符以防过宽匹配;提供即进入前缀模式)。 + description: 账号或姓名前缀(≥2 字符以防过宽匹配;提供即进入前缀模式;大小写敏感——中文姓名无大小写语义)。 schema: type: string minLength: 2 diff --git a/packages/contracts/src/generated/openapi.d.ts b/packages/contracts/src/generated/openapi.d.ts index 0b01405..bf582fc 100644 --- a/packages/contracts/src/generated/openapi.d.ts +++ b/packages/contracts/src/generated/openapi.d.ts @@ -557,9 +557,11 @@ export interface paths { * @description 双模式统一端点(P4a 决策:与 P1b 账号前缀查询合一,响应统一 * AdminUserList——条目为 AdminUser 字段超集,管理端 UI 按场景裁剪展示): * · 前缀模式(提供 accountPrefix):邀请协作者前置查询,沿用 P1b 语义 - * ——大小写敏感前缀匹配(服务端按二进制校对执行 LIKE prefix%)、 - * 仅「激活资格」用户(credentialStatus=active 且 sourceStatus≠disabled - * 且 !localLocked,与成员添加的目标校验一致)、account 升序; + * ——大小写敏感前缀匹配(服务端按二进制校对执行 LIKE prefix%,匹配 + * account **或 displayName** 前缀——2026-09-29 扩展:V1.1 账号=手机号, + * 运营按姓名搜人是自然预期)、仅「激活资格」用户(credentialStatus= + * active 且 sourceStatus≠disabled 且 !localLocked,与成员添加的目标 + * 校验一致)、account 升序; * 访问策略沿用 P1b 决策:任何已认证工作人员可用(邀请发起方不限于 * 创建者/管理员,泄露面为已认证内部人员,风险可接受)。 * · 管理列表模式(未提供 accountPrefix):全量用户治理列表(游标分页, @@ -4440,7 +4442,7 @@ export interface operations { listAdminUsers: { parameters: { query?: { - /** @description 账号前缀(≥2 字符以防过宽匹配;提供即进入前缀模式)。 */ + /** @description 账号或姓名前缀(≥2 字符以防过宽匹配;提供即进入前缀模式;大小写敏感——中文姓名无大小写语义)。 */ accountPrefix?: string; /** * @description 管理列表过滤:账号或展示名前缀(大小写敏感二进制前缀匹配,同 diff --git a/services/api/src/admin/users-lookup.service.ts b/services/api/src/admin/users-lookup.service.ts index dee4c28..921582d 100644 --- a/services/api/src/admin/users-lookup.service.ts +++ b/services/api/src/admin/users-lookup.service.ts @@ -71,6 +71,10 @@ export class UsersLookupService { /** * 前缀查询(契约 listAdminUsers 前缀模式):激活资格用户、account 升序、 * limit 截断;响应为统一 AdminUserList 形状(P4a)。 + * 2026-09-29 方案 A:前缀匹配扩展为 **account OR displayName**——V1.1 同步 + * 用户账号=手机号,运营按姓名搜人是自然预期(此前仅 account,姓名搜索恒空, + * 线上成员添加「关联不到用户」根因);与管理列表 query 模式的 OR displayName + * 对齐。大小写敏感(bin 校对)与资格过滤对两列同样生效。 */ async lookup( accountPrefix: string, @@ -78,11 +82,13 @@ export class UsersLookupService { ): Promise { const pattern = `${escapeLikePrefix(accountPrefix)}%`; // 参数经 $queryRaw 模板绑定(无拼接注入面);LIMIT ? 为 MySQL 合法占位。 + // OR 两列必须括号分组——否则资格过滤会被短路。 const rows = await this.prisma.$queryRaw` SELECT id, account, displayName, role, sourceStatus, localLocked, credentialStatus, locale, createdAt FROM users - WHERE account LIKE ${pattern} ESCAPE '!' + WHERE (account LIKE ${pattern} ESCAPE '!' + OR displayName LIKE ${pattern} ESCAPE '!') AND credentialStatus = 'active' AND sourceStatus <> 'disabled' AND localLocked = false diff --git a/services/api/test/members-categories.e2e-spec.ts b/services/api/test/members-categories.e2e-spec.ts index efaf53e..9a45ea8 100644 --- a/services/api/test/members-categories.e2e-spec.ts +++ b/services/api/test/members-categories.e2e-spec.ts @@ -14,6 +14,10 @@ import { randomBytes } from 'node:crypto'; import { type INestApplication } from '@nestjs/common'; import { Test } from '@nestjs/testing'; import request from 'supertest'; + +// 冷 infra(Docker 重启后)下 beforeAll 引导可超 jest 缺省 5s——对齐 +// 同目录其它 e2e 的 120s(2026-09-29 两次冷启动误报教训)。 +jest.setTimeout(120_000); import { PrismaClient } from '@photo/db-cn'; import { ErrorCodes } from '@photo/contracts'; import { AppCnModule } from '../src/app-cn.module'; diff --git a/services/api/test/users-lookup.e2e-spec.ts b/services/api/test/users-lookup.e2e-spec.ts index ee77127..c9c944c 100644 --- a/services/api/test/users-lookup.e2e-spec.ts +++ b/services/api/test/users-lookup.e2e-spec.ts @@ -197,6 +197,38 @@ describe('账号查询 GET /admin/users (e2e)', () => { expect(percent.body.items).toEqual([]); }); + // displayName 前缀并入(2026-09-29 方案 A:运营按姓名搜人——账号=手机号下 + // 姓名是运营唯一记得的标识;管理列表 query 模式此前已 OR displayName, + // 前缀模式对齐)。语义:account 前缀 OR displayName 前缀,资格过滤/ + // account 升序/大小写敏感(bin 校对对两列同样生效)不变。 + it('displayName 前缀命中(account 不匹配也可查);两列命中并集按 account 升序', async () => { + const byName = await lookup(staffToken, { accountPrefix: '爱丽' }); + expect(byName.status).toBe(200); + expect(byName.body.items).toEqual([ + expect.objectContaining({ id: aliceId, account: 'alice', displayName: '爱丽' }), + ]); + + // 前缀同时命中两列的多个用户:'批量' 命中 12 个 zz-look-* 的姓名, + // account 升序 + limit 截断(limit:5 → 01..05)。 + const paged = await lookup(staffToken, { accountPrefix: '批量', limit: 5 }); + expect(paged.status).toBe(200); + expect(paged.body.items.map((i: { account: string }) => i.account)).toEqual([ + 'zz-look-01', 'zz-look-02', 'zz-look-03', 'zz-look-04', 'zz-look-05', + ]); + }); + + it('displayName 命中同样受激活资格过滤(停用/待激活/锁定经姓名也查不到)', async () => { + const disabled = await lookup(staffToken, { accountPrefix: '停用' }); + expect(disabled.status).toBe(200); + expect(disabled.body.items).toEqual([]); + const pending = await lookup(staffToken, { accountPrefix: '待激活' }); + expect(pending.status).toBe(200); + expect(pending.body.items).toEqual([]); + const locked = await lookup(staffToken, { accountPrefix: '锁定' }); + expect(locked.status).toBe(200); + expect(locked.body.items).toEqual([]); + }); + it('accountPrefix 边界:缺失 → 双模式分流(staff 403 / admin 200 管理列表);1/65 字符 → 400;64 → 200 空', async () => { // Task 6 起缺失 accountPrefix 进入管理列表模式(仅 admin):staff 无前缀 // → 403 AUTH_FORBIDDEN;admin → 200 管理列表(全量用户,含非激活态——