feat(api): 用户前缀查找并入 displayName——成员添加可按姓名搜人(方案 A)
线上根因:V1.1 账号=手机号,运营按姓名搜索恒空(accountPrefix 仅匹配 account)。扩展为 account OR displayName 前缀(括号分组保资格过滤不被 短路),大小写敏感/激活过滤/account 升序语义不变;与管理列表 query 模式 对齐。契约描述同步。附 members-categories e2e 冷启动超时对齐 120s。 Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -1313,9 +1313,11 @@ paths:
|
|||||||
双模式统一端点(P4a 决策:与 P1b 账号前缀查询合一,响应统一
|
双模式统一端点(P4a 决策:与 P1b 账号前缀查询合一,响应统一
|
||||||
AdminUserList——条目为 AdminUser 字段超集,管理端 UI 按场景裁剪展示):
|
AdminUserList——条目为 AdminUser 字段超集,管理端 UI 按场景裁剪展示):
|
||||||
· 前缀模式(提供 accountPrefix):邀请协作者前置查询,沿用 P1b 语义
|
· 前缀模式(提供 accountPrefix):邀请协作者前置查询,沿用 P1b 语义
|
||||||
——大小写敏感前缀匹配(服务端按二进制校对执行 LIKE prefix%)、
|
——大小写敏感前缀匹配(服务端按二进制校对执行 LIKE prefix%,匹配
|
||||||
仅「激活资格」用户(credentialStatus=active 且 sourceStatus≠disabled
|
account **或 displayName** 前缀——2026-09-29 扩展:V1.1 账号=手机号,
|
||||||
且 !localLocked,与成员添加的目标校验一致)、account 升序;
|
运营按姓名搜人是自然预期)、仅「激活资格」用户(credentialStatus=
|
||||||
|
active 且 sourceStatus≠disabled 且 !localLocked,与成员添加的目标
|
||||||
|
校验一致)、account 升序;
|
||||||
访问策略沿用 P1b 决策:任何已认证工作人员可用(邀请发起方不限于
|
访问策略沿用 P1b 决策:任何已认证工作人员可用(邀请发起方不限于
|
||||||
创建者/管理员,泄露面为已认证内部人员,风险可接受)。
|
创建者/管理员,泄露面为已认证内部人员,风险可接受)。
|
||||||
· 管理列表模式(未提供 accountPrefix):全量用户治理列表(游标分页,
|
· 管理列表模式(未提供 accountPrefix):全量用户治理列表(游标分页,
|
||||||
@@ -1328,7 +1330,7 @@ paths:
|
|||||||
parameters:
|
parameters:
|
||||||
- name: accountPrefix
|
- name: accountPrefix
|
||||||
in: query
|
in: query
|
||||||
description: 账号前缀(≥2 字符以防过宽匹配;提供即进入前缀模式)。
|
description: 账号或姓名前缀(≥2 字符以防过宽匹配;提供即进入前缀模式;大小写敏感——中文姓名无大小写语义)。
|
||||||
schema:
|
schema:
|
||||||
type: string
|
type: string
|
||||||
minLength: 2
|
minLength: 2
|
||||||
|
|||||||
+6
-4
@@ -557,9 +557,11 @@ export interface paths {
|
|||||||
* @description 双模式统一端点(P4a 决策:与 P1b 账号前缀查询合一,响应统一
|
* @description 双模式统一端点(P4a 决策:与 P1b 账号前缀查询合一,响应统一
|
||||||
* AdminUserList——条目为 AdminUser 字段超集,管理端 UI 按场景裁剪展示):
|
* AdminUserList——条目为 AdminUser 字段超集,管理端 UI 按场景裁剪展示):
|
||||||
* · 前缀模式(提供 accountPrefix):邀请协作者前置查询,沿用 P1b 语义
|
* · 前缀模式(提供 accountPrefix):邀请协作者前置查询,沿用 P1b 语义
|
||||||
* ——大小写敏感前缀匹配(服务端按二进制校对执行 LIKE prefix%)、
|
* ——大小写敏感前缀匹配(服务端按二进制校对执行 LIKE prefix%,匹配
|
||||||
* 仅「激活资格」用户(credentialStatus=active 且 sourceStatus≠disabled
|
* account **或 displayName** 前缀——2026-09-29 扩展:V1.1 账号=手机号,
|
||||||
* 且 !localLocked,与成员添加的目标校验一致)、account 升序;
|
* 运营按姓名搜人是自然预期)、仅「激活资格」用户(credentialStatus=
|
||||||
|
* active 且 sourceStatus≠disabled 且 !localLocked,与成员添加的目标
|
||||||
|
* 校验一致)、account 升序;
|
||||||
* 访问策略沿用 P1b 决策:任何已认证工作人员可用(邀请发起方不限于
|
* 访问策略沿用 P1b 决策:任何已认证工作人员可用(邀请发起方不限于
|
||||||
* 创建者/管理员,泄露面为已认证内部人员,风险可接受)。
|
* 创建者/管理员,泄露面为已认证内部人员,风险可接受)。
|
||||||
* · 管理列表模式(未提供 accountPrefix):全量用户治理列表(游标分页,
|
* · 管理列表模式(未提供 accountPrefix):全量用户治理列表(游标分页,
|
||||||
@@ -4440,7 +4442,7 @@ export interface operations {
|
|||||||
listAdminUsers: {
|
listAdminUsers: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: {
|
query?: {
|
||||||
/** @description 账号前缀(≥2 字符以防过宽匹配;提供即进入前缀模式)。 */
|
/** @description 账号或姓名前缀(≥2 字符以防过宽匹配;提供即进入前缀模式;大小写敏感——中文姓名无大小写语义)。 */
|
||||||
accountPrefix?: string;
|
accountPrefix?: string;
|
||||||
/**
|
/**
|
||||||
* @description 管理列表过滤:账号或展示名前缀(大小写敏感二进制前缀匹配,同
|
* @description 管理列表过滤:账号或展示名前缀(大小写敏感二进制前缀匹配,同
|
||||||
|
|||||||
@@ -71,6 +71,10 @@ export class UsersLookupService {
|
|||||||
/**
|
/**
|
||||||
* 前缀查询(契约 listAdminUsers 前缀模式):激活资格用户、account 升序、
|
* 前缀查询(契约 listAdminUsers 前缀模式):激活资格用户、account 升序、
|
||||||
* limit 截断;响应为统一 AdminUserList 形状(P4a)。
|
* limit 截断;响应为统一 AdminUserList 形状(P4a)。
|
||||||
|
* 2026-09-29 方案 A:前缀匹配扩展为 **account OR displayName**——V1.1 同步
|
||||||
|
* 用户账号=手机号,运营按姓名搜人是自然预期(此前仅 account,姓名搜索恒空,
|
||||||
|
* 线上成员添加「关联不到用户」根因);与管理列表 query 模式的 OR displayName
|
||||||
|
* 对齐。大小写敏感(bin 校对)与资格过滤对两列同样生效。
|
||||||
*/
|
*/
|
||||||
async lookup(
|
async lookup(
|
||||||
accountPrefix: string,
|
accountPrefix: string,
|
||||||
@@ -78,11 +82,13 @@ export class UsersLookupService {
|
|||||||
): Promise<AdminUserListResponse> {
|
): Promise<AdminUserListResponse> {
|
||||||
const pattern = `${escapeLikePrefix(accountPrefix)}%`;
|
const pattern = `${escapeLikePrefix(accountPrefix)}%`;
|
||||||
// 参数经 $queryRaw 模板绑定(无拼接注入面);LIMIT ? 为 MySQL 合法占位。
|
// 参数经 $queryRaw 模板绑定(无拼接注入面);LIMIT ? 为 MySQL 合法占位。
|
||||||
|
// OR 两列必须括号分组——否则资格过滤会被短路。
|
||||||
const rows = await this.prisma.$queryRaw<AdminUserRow[]>`
|
const rows = await this.prisma.$queryRaw<AdminUserRow[]>`
|
||||||
SELECT id, account, displayName, role, sourceStatus, localLocked,
|
SELECT id, account, displayName, role, sourceStatus, localLocked,
|
||||||
credentialStatus, locale, createdAt
|
credentialStatus, locale, createdAt
|
||||||
FROM users
|
FROM users
|
||||||
WHERE account LIKE ${pattern} ESCAPE '!'
|
WHERE (account LIKE ${pattern} ESCAPE '!'
|
||||||
|
OR displayName LIKE ${pattern} ESCAPE '!')
|
||||||
AND credentialStatus = 'active'
|
AND credentialStatus = 'active'
|
||||||
AND sourceStatus <> 'disabled'
|
AND sourceStatus <> 'disabled'
|
||||||
AND localLocked = false
|
AND localLocked = false
|
||||||
|
|||||||
@@ -14,6 +14,10 @@ import { randomBytes } from 'node:crypto';
|
|||||||
import { type INestApplication } from '@nestjs/common';
|
import { type INestApplication } from '@nestjs/common';
|
||||||
import { Test } from '@nestjs/testing';
|
import { Test } from '@nestjs/testing';
|
||||||
import request from 'supertest';
|
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 { PrismaClient } from '@photo/db-cn';
|
||||||
import { ErrorCodes } from '@photo/contracts';
|
import { ErrorCodes } from '@photo/contracts';
|
||||||
import { AppCnModule } from '../src/app-cn.module';
|
import { AppCnModule } from '../src/app-cn.module';
|
||||||
|
|||||||
@@ -197,6 +197,38 @@ describe('账号查询 GET /admin/users (e2e)', () => {
|
|||||||
expect(percent.body.items).toEqual([]);
|
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 () => {
|
it('accountPrefix 边界:缺失 → 双模式分流(staff 403 / admin 200 管理列表);1/65 字符 → 400;64 → 200 空', async () => {
|
||||||
// Task 6 起缺失 accountPrefix 进入管理列表模式(仅 admin):staff 无前缀
|
// Task 6 起缺失 accountPrefix 进入管理列表模式(仅 admin):staff 无前缀
|
||||||
// → 403 AUTH_FORBIDDEN;admin → 200 管理列表(全量用户,含非激活态——
|
// → 403 AUTH_FORBIDDEN;admin → 200 管理列表(全量用户,含非激活态——
|
||||||
|
|||||||
Reference in New Issue
Block a user