Files
photo-live/services/api/src/admin/users-lookup.controller.ts
T
浅念 f4a1ba7a14 feat(api): admin account governance endpoints
P4a Task 6: GET /admin/users 双模式收口(无 accountPrefix 进入管理列表
模式:全量用户、query/status 过滤、createdAt+ID 倒序键集游标、仅 admin;
前缀模式 P1b 语义保留)+ PATCH / reset-password / activate / deactivate
治理端点(sourceStatus 只读拒绝、自我保护、会话撤销、审计、生成初始
密码)。附带修复 full-chain e2e 的 job-done 断言竞态(轮询条件补齐
photo_jobs 终态)。api e2e 214 → 227 全绿。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-20 19:31:00 +08:00

133 lines
5.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// services/api/src/admin/users-lookup.controller.ts —— GET /admin/users 端点
// (P1b Task 4 建立;P4a Task 1 契约统一为 listAdminUsers 双模式;Task 6
// 收口双模式分流)。方法名与契约 operationId 一一对应(listAdminUsers)。
// 控制器级 SessionGuard:访问策略=任何已认证工作人员(P1b 决策,见服务文件
// 头——管理列表模式的仅 admin 判定在 AdminUsersService.list)。
// 双模式分流(P4a 契约):
// · 提供 accountPrefix → 前缀模式(P1b 语义保留:邀请协作者前置查询,
// 仅激活资格用户、account 升序、任何已认证工作人员可用);
// · 未提供 → 管理列表模式(全量用户、query/status 过滤、createdAt+ID 倒序
// 游标分页、仅 admin)。
// 控制器只做查询参数规整(limit/cursor 经 shared/pagination 同一 bounds),
// 语义在服务层。limit 边界已对齐统一契约:缺省 20、上下界 1..100。
// 显式空串 accountPrefix=''/query='' 是「提供了非法值」而非「未提供」→ 400
//(前者长度 <2,后者长度 <1,均契约 minLength 违例)。
import {
Controller,
Get,
HttpStatus,
Query,
Req,
UseGuards,
} from '@nestjs/common';
import { ErrorCodes } from '@photo/contracts';
import { ApiException } from '../api-exception';
import { AuthenticatedRequest, SessionGuard } from '../auth/session.guard';
import { userOf } from '../albums/albums.controller';
import { parseCursor } from '../shared/pagination';
import { validationFailed } from '../albums/support';
import {
AdminUsersService,
STATUS_FILTER_VALUES,
type AdminUserStatusFilter,
} from './admin-users.service';
import { UsersLookupService, type AdminUserListResponse } from './users-lookup.service';
/** 契约 listAdminUsers.limit:缺省 20,上下界 1..100(P4a 统一分页 bounds)。 */
export const DEFAULT_LIST_LIMIT = 20;
export const MAX_LIST_LIMIT = 100;
/** accountPrefix 查询参数(前缀模式):2..64(契约 bounds)。 */
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 字符');
}
return raw;
}
/** query 查询参数(管理列表模式):1..64(契约 bounds);未提供 → undefined。 */
export function parseAdminQuery(raw: string | undefined): string | undefined {
if (raw === undefined) {
return undefined;
}
if (raw.length < 1 || raw.length > 64) {
throw validationFailed('query 须为 1-64 字符');
}
return raw;
}
/** status 查询参数(管理列表模式):契约枚举单值;缺省/空串 → 不过滤。 */
export function parseStatusFilter(
raw: string | undefined,
): AdminUserStatusFilter | undefined {
if (raw === undefined || raw === '') {
return undefined;
}
if (!STATUS_FILTER_VALUES.includes(raw as AdminUserStatusFilter)) {
throw validationFailed('status 须为 active/pending/disabled/locked 之一');
}
return raw as AdminUserStatusFilter;
}
/** limit 查询参数:缺省 20,须为 1..100 的十进制整数(契约 bounds)。 */
export function parseListLimit(raw: string | undefined): number {
if (raw === undefined || raw === '') {
return DEFAULT_LIST_LIMIT;
}
if (!/^\d+$/.test(raw)) {
throw new ApiException(
ErrorCodes.VALIDATION_FAILED,
'limit 必须是 1-100 的整数',
HttpStatus.BAD_REQUEST,
);
}
const limit = Number.parseInt(raw, 10);
if (limit < 1 || limit > MAX_LIST_LIMIT) {
throw new ApiException(
ErrorCodes.VALIDATION_FAILED,
'limit 必须是 1-100 的整数',
HttpStatus.BAD_REQUEST,
);
}
return limit;
}
@Controller('admin/users')
@UseGuards(SessionGuard)
export class UsersLookupController {
constructor(
private readonly usersLookupService: UsersLookupService,
private readonly adminUsersService: AdminUsersService,
) {}
/** 用户查询/管理列表(双模式统一响应 AdminUserList,分流见文件头)。 */
@Get()
listAdminUsers(
@Req() req: AuthenticatedRequest,
@Query('accountPrefix') accountPrefix: string | undefined,
@Query('query') query: string | undefined,
@Query('status') status: string | undefined,
@Query('cursor') cursor: string | undefined,
@Query('limit') limit: string | undefined,
): Promise<AdminUserListResponse> {
const parsedLimit = parseListLimit(limit);
if (accountPrefix !== undefined) {
// 前缀模式(P1b 语义保留):任何已认证工作人员。
return this.usersLookupService.lookup(
parseAccountPrefix(accountPrefix),
parsedLimit,
);
}
// 管理列表模式(P4a Task 6):仅 admin(服务层 403)。
return this.adminUsersService.list(
userOf(req),
parseAdminQuery(query),
parseStatusFilter(status),
parseCursor(cursor),
parsedLimit,
);
}
}