diff --git a/apps/admin/src/views/albums/detail/AlbumMembers.vue b/apps/admin/src/views/albums/detail/AlbumMembers.vue index 6842f60..9ee490f 100644 --- a/apps/admin/src/views/albums/detail/AlbumMembers.vue +++ b/apps/admin/src/views/albums/detail/AlbumMembers.vue @@ -175,7 +175,7 @@ async function searchUsers(prefix: string) { query.value = prefix options.value = [] - if (prefix.trim().length < 2) return + if (prefix.trim().length < 1) return searching.value = true try { const result = await fetchLookupUsers(prefix.trim()) diff --git a/apps/admin/src/views/albums/detail/AlbumTransferDialog.vue b/apps/admin/src/views/albums/detail/AlbumTransferDialog.vue index 79b84c1..85059f1 100644 --- a/apps/admin/src/views/albums/detail/AlbumTransferDialog.vue +++ b/apps/admin/src/views/albums/detail/AlbumTransferDialog.vue @@ -112,7 +112,7 @@ async function searchUsers(prefix: string) { query.value = prefix options.value = [] - if (prefix.trim().length < 2) return + if (prefix.trim().length < 1) return searching.value = true try { const result = await fetchLookupUsers(prefix.trim()) diff --git a/apps/mobile/lib/pages/album_members_page.dart b/apps/mobile/lib/pages/album_members_page.dart index be9f084..b3a360f 100644 --- a/apps/mobile/lib/pages/album_members_page.dart +++ b/apps/mobile/lib/pages/album_members_page.dart @@ -410,7 +410,9 @@ class _AlbumInviteSheetState extends ConsumerState { void _onSearchChanged(String text) { _debounce?.cancel(); final query = text.trim(); - if (query.length < 2) { + // 单字符即搜(2026-09-29 与服务端 1..64 bounds 对齐——中文姓氏单字); + // 空串清空结果。 + if (query.isEmpty) { setState(() { _searching = false; _results = null; diff --git a/apps/mobile/test/pages/album_members_page_test.dart b/apps/mobile/test/pages/album_members_page_test.dart index 2440430..e11b926 100644 --- a/apps/mobile/test/pages/album_members_page_test.dart +++ b/apps/mobile/test/pages/album_members_page_test.dart @@ -144,7 +144,7 @@ void main() { expect(find.byIcon(Icons.person_add_outlined), findsOneWidget); }); - testWidgets('邀请搜索防抖:<2 字符不请求;连续输入合并为最后一次(300ms)', + testWidgets('邀请搜索防抖:单字符即请求;连续输入合并为最后一次(300ms)', (tester) async { final adapter = membersAdapter(); await pumpMembers(tester, clientOf(adapter)); @@ -152,15 +152,15 @@ void main() { await openInviteSheet(tester); final field = find.byType(TextField); - // 1 个字符:无请求。 + // 单字符即发请求(2026-09-29 与服务端 1..64 bounds 对齐——中文姓氏单字)。 await tester.enterText(field, 's'); await tester.pump(const Duration(milliseconds: 400)); expect( adapter.requests.where((r) => r.path == '/v1/admin/users'), - isEmpty, + isNotEmpty, ); - // 连续输入两次,防抖合并为最后一次。 + // 连续输入两次,防抖合并为最后一次:'st' 不产生独立请求,只有 'sta'。 await tester.enterText(field, 'st'); await tester.pump(const Duration(milliseconds: 100)); await tester.enterText(field, 'sta'); @@ -169,8 +169,12 @@ void main() { final lookupsRequests = adapter.requests .where((r) => r.path == '/v1/admin/users') .toList(); - expect(lookupsRequests, hasLength(1)); - expect(lookupsRequests.single.uri.queryParameters['accountPrefix'], 'sta'); + // 前段 's' 一条 + 合并后的 'sta' 一条(单字符即搜后基线为 2)。 + expect(lookupsRequests, hasLength(2)); + expect( + lookupsRequests.map((r) => r.uri.queryParameters['accountPrefix']).toList(), + ['s', 'sta'], + ); }); testWidgets('邀请流:搜索命中 → Add → POST userId 载荷 → 已添加态 + 列表刷新', diff --git a/packages/contracts/openapi.yaml b/packages/contracts/openapi.yaml index ccfcfe3..c383bce 100644 --- a/packages/contracts/openapi.yaml +++ b/packages/contracts/openapi.yaml @@ -1330,10 +1330,10 @@ paths: parameters: - name: accountPrefix in: query - description: 账号或姓名前缀(≥2 字符以防过宽匹配;提供即进入前缀模式;大小写敏感——中文姓名无大小写语义)。 + description: 账号或姓名前缀(≥1 字符——中文姓氏单字搜索合理,2026-09-29 放宽;提供即进入前缀模式;大小写敏感——中文姓名无大小写语义)。 schema: type: string - minLength: 2 + minLength: 1 maxLength: 64 - name: query in: query diff --git a/packages/contracts/src/generated/openapi.d.ts b/packages/contracts/src/generated/openapi.d.ts index bf582fc..2ae17e8 100644 --- a/packages/contracts/src/generated/openapi.d.ts +++ b/packages/contracts/src/generated/openapi.d.ts @@ -4442,7 +4442,7 @@ export interface operations { listAdminUsers: { parameters: { query?: { - /** @description 账号或姓名前缀(≥2 字符以防过宽匹配;提供即进入前缀模式;大小写敏感——中文姓名无大小写语义)。 */ + /** @description 账号或姓名前缀(≥1 字符——中文姓氏单字搜索合理,2026-09-29 放宽;提供即进入前缀模式;大小写敏感——中文姓名无大小写语义)。 */ accountPrefix?: string; /** * @description 管理列表过滤:账号或展示名前缀(大小写敏感二进制前缀匹配,同 diff --git a/services/api/src/admin/users-lookup.controller.ts b/services/api/src/admin/users-lookup.controller.ts index 0897671..bc77409 100644 --- a/services/api/src/admin/users-lookup.controller.ts +++ b/services/api/src/admin/users-lookup.controller.ts @@ -37,12 +37,13 @@ import { UsersLookupService, type AdminUserListResponse } from './users-lookup.s export const DEFAULT_LIST_LIMIT = 20; export const MAX_LIST_LIMIT = 100; -/** accountPrefix 查询参数(前缀模式):2..64(契约 bounds)。 */ +/** accountPrefix 查询参数(前缀模式):1..64(契约 bounds;2026-09-29 自 2 + * 放宽——中文姓氏单字搜索合理,用户拍板)。 */ export function parseAccountPrefix(raw: string | undefined): string { // 双模式收口后 undefined 不再到达此函数(分流在 listAdminUsers);防御性 - // 保留同一校验出口。空串/1 字符/超长 → 400。 - if (raw === undefined || raw.length < 2 || raw.length > 64) { - throw validationFailed('accountPrefix 须为 2-64 字符'); + // 保留同一校验出口。空串/超长 → 400;单字符合法。 + if (raw === undefined || raw.length < 1 || raw.length > 64) { + throw validationFailed('accountPrefix 须为 1-64 字符'); } return raw; } diff --git a/services/api/test/users-lookup.e2e-spec.ts b/services/api/test/users-lookup.e2e-spec.ts index c9c944c..7eade02 100644 --- a/services/api/test/users-lookup.e2e-spec.ts +++ b/services/api/test/users-lookup.e2e-spec.ts @@ -258,9 +258,17 @@ describe('账号查询 GET /admin/users (e2e)', () => { expect(empty.status).toBe(400); expect(empty.body.code).toBe(ErrorCodes.VALIDATION_FAILED); - const short = await lookup(staffToken, { accountPrefix: 'a' }); - expect(short.status).toBe(400); - expect(short.body.code).toBe(ErrorCodes.VALIDATION_FAILED); + // 单字符已合法(2026-09-29 自 2 放宽——中文姓氏单字搜索):'a' 命中 + // alice/albert(displayName 前缀并入后 '爱' 亦单字可用)。 + const single = await lookup(staffToken, { accountPrefix: 'a' }); + expect(single.status).toBe(200); + expect(single.body.items.map((i: { account: string }) => i.account)).toEqual([ + 'albert', + 'alice', + ]); + const singleName = await lookup(staffToken, { accountPrefix: '爱' }); + expect(singleName.status).toBe(200); + expect(singleName.body.items).toHaveLength(1); const long = await lookup(staffToken, { accountPrefix: 'a'.repeat(65) }); expect(long.status).toBe(400);