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>
This commit is contained in:
浅念
2026-09-20 19:31:00 +08:00
parent 4937eeecff
commit f4a1ba7a14
9 changed files with 1326 additions and 60 deletions
@@ -0,0 +1,76 @@
// services/api/src/admin/admin-users.controller.ts —— 账号治理端点(P4a
// Task 6)。方法名与契约 operationId 一一对应(updateAdminUser /
// resetAdminUserPassword / activateAdminUser / deactivateAdminUser,tag
// [admin])。控制器级 SessionGuard:全端点需认证;admin 判定在服务层
//(AuthUser.role,403 AUTH_FORBIDDEN)。控制器只做路径参数与 DTO 透传;
// POST 三端点契约响应均为 200(非 201),显式 @HttpCode。
// GET /admin/users(listAdminUsers 双模式)仍在 users-lookup.controller。
import {
Body,
Controller,
HttpCode,
HttpStatus,
Param,
Patch,
Post,
Req,
UseGuards,
} from '@nestjs/common';
import { userOf } from '../albums/albums.controller';
import { AuthenticatedRequest, SessionGuard } from '../auth/session.guard';
import {
AdminPasswordResetDto,
AdminUserUpdateDto,
} from './admin-users.dto';
import {
AdminUsersService,
type AdminPasswordResetResponse,
type AdminUserResponse,
} from './admin-users.service';
@Controller('admin/users')
@UseGuards(SessionGuard)
export class AdminUsersController {
constructor(private readonly adminUsersService: AdminUsersService) {}
/** 更新用户治理属性(role/localLocked/displayName;sourceStatus 只读)。 */
@Patch(':userId')
updateAdminUser(
@Param('userId') userId: string,
@Body() dto: AdminUserUpdateDto,
@Req() req: AuthenticatedRequest,
): Promise<AdminUserResponse> {
return this.adminUsersService.update(userOf(req), userId, dto);
}
/** 管理员重置用户密码(指定或服务端生成;撤销目标全部会话)。 */
@Post(':userId/reset-password')
@HttpCode(HttpStatus.OK)
resetAdminUserPassword(
@Param('userId') userId: string,
@Body() dto: AdminPasswordResetDto,
@Req() req: AuthenticatedRequest,
): Promise<AdminPasswordResetResponse> {
return this.adminUsersService.resetPassword(userOf(req), userId, dto);
}
/** 激活待激活用户(credentialStatus pending→active,幂等)。 */
@Post(':userId/activate')
@HttpCode(HttpStatus.OK)
activateAdminUser(
@Param('userId') userId: string,
@Req() req: AuthenticatedRequest,
): Promise<AdminUserResponse> {
return this.adminUsersService.activate(userOf(req), userId);
}
/** 停用用户(localLocked=true + 撤销全部会话,幂等)。 */
@Post(':userId/deactivate')
@HttpCode(HttpStatus.OK)
deactivateAdminUser(
@Param('userId') userId: string,
@Req() req: AuthenticatedRequest,
): Promise<AdminUserResponse> {
return this.adminUsersService.deactivate(userOf(req), userId);
}
}
+65
View File
@@ -0,0 +1,65 @@
// services/api/src/admin/admin-users.dto.ts —— 账号治理端点入参 DTO(P4a
// Task 6)。契约 AdminUserUpdateRequest / AdminPasswordResetRequest。
// 校验语义要点:
// · 可选字段一律 @ValidateIf((_, value) => value !== undefined) 而非
// @IsOptional:后者连 null 一并跳过,显式 null 会以 null 值渗入服务层
// (Prisma 拒绝、错误形态不契约化);ValidateIf 只放行 undefined(真缺省),
// null 落到类型校验上 → 400 VALIDATION_FAILED,与「提供了非法值」同出口。
// · sourceStatus 只读(技术方案 3.1A 红线:上游状态仅由用户同步任务维护,
// 本地不越权改写):DTO 显式声明该字段 + 只读约束,出现(含 null)即
// 400。全局 ValidationPipe 是 whitelist(剥离而非拒绝)——若不声明,
// sourceStatus 会被静默剥离成「忽略」,与只读拒绝语义不符。
import {
IsBoolean,
IsIn,
IsString,
MaxLength,
MinLength,
Validate,
ValidateIf,
ValidationArguments,
ValidatorConstraint,
ValidatorConstraintInterface,
} from 'class-validator';
/** 只读字段约束:仅 undefined(真缺省)放行,任何显式值(含 null)拒绝。 */
@ValidatorConstraint({ name: 'adminUserReadOnlyField', async: false })
class ReadOnlyFieldConstraint implements ValidatorConstraintInterface {
validate(value: unknown): boolean {
return value === undefined;
}
defaultMessage(args: ValidationArguments): string {
return `${args.property} 为只读字段,不能经本端点修改`;
}
}
export class AdminUserUpdateDto {
@ValidateIf((_, value) => value !== undefined)
@IsIn(['staff', 'admin'])
role?: 'staff' | 'admin';
@ValidateIf((_, value) => value !== undefined)
@IsBoolean()
localLocked?: boolean;
@ValidateIf((_, value) => value !== undefined)
@IsString()
@MinLength(1)
@MaxLength(128)
displayName?: string;
/** sourceStatus 不在可更新集(契约 AdminUserUpdateRequest 无此字段)。 */
@ValidateIf(() => true)
@Validate(ReadOnlyFieldConstraint)
declare sourceStatus?: unknown;
}
export class AdminPasswordResetDto {
/** 契约 bounds:8-128;缺省由服务端生成随机初始密码。 */
@ValidateIf((_, value) => value !== undefined)
@IsString()
@MinLength(8)
@MaxLength(128)
newPassword?: string;
}
@@ -0,0 +1,360 @@
// services/api/src/admin/admin-users.service.ts —— 账号治理服务(P4a Task 6)。
// 契约权威:openapi listAdminUsers 管理列表模式 / updateAdminUser /
// resetAdminUserPassword / activateAdminUser / deactivateAdminUser。全部仅
// admin(AuthUser.role,403 AUTH_FORBIDDEN)。核心语义:
// · 管理列表:全量用户(与前缀模式「仅激活资格」互补——治理列表必须看见
// pending/disabled/locked);createdAt+ID 稳定倒序键集游标(shared/cursor);
// query 过滤 = account 或 displayName 二进制前缀(同前缀模式的 ESCAPE '!'
// 转义 + bin 校对,大小写敏感,不做全文搜索);status 过滤 = 单值归类,
// 优先级 pending > disabled > locked > active(契约 AdminUserStatusFilter,
// SQL CASE 一处表达,多态用户只落一个桶)。
// · PATCH:role/localLocked/displayName 部分更新;sourceStatus 只读(DTO 层
// 拒绝);localLocked 置 true 等价停用(撤销该用户全部会话)、置 false 仅
// 解除本地锁定(上游停用/密码维度不动);自我保护:admin 不许自改角色/
// 自锁(唯一管理员自锁门外、自我降权均为事故面),displayName 自改放行。
// 幂等 no-op(提交值与现值全同)不落审计——同相册域先例。
// · reset-password:指定或服务端生成(16 位 62 字符集,~95 bit 熵,仅响应
// 返回一次);撤销目标全部既有会话;argon2 哈希事务外先算(不占连接,
// 同 auth.changePassword 先例);审计只记 generated 布尔,绝不含密码材料。
// · activate:credentialStatus pending→active(§10:同步创建的待激活账号
// 闭环;激活本身不出凭据,初始密码经 reset-password 出具);对 active
// 幂等返回当前条目,不重复落审计。
// · deactivate:localLocked=true + 撤销该目标全部会话(既有令牌即刻 401);
// 上游 sourceStatus 不动(本地锁定独立,技术方案 3.1A);重复停用幂等。
// 重新启用 = PATCH localLocked=false(activate 只管 credentialStatus 维度)。
import { randomInt } from 'node:crypto';
import { HttpStatus, Injectable } from '@nestjs/common';
import { ErrorCodes, type components } from '@photo/contracts';
import { ApiException } from '../api-exception';
import { hashPassword } from '../auth/password';
import type { AuthUser } from '../auth/session.guard';
import { PrismaService } from '../prisma/prisma.service';
import {
decodeKeysetCursor,
encodeKeysetCursor,
type KeysetCursor,
} from '../shared/cursor';
import { validationFailed } from '../albums/support';
import type { AdminPasswordResetDto, AdminUserUpdateDto } from './admin-users.dto';
import {
escapeLikePrefix,
toAdminUser,
type AdminUserListResponse,
type AdminUserRow,
} from './users-lookup.service';
export type AdminUserResponse = components['schemas']['AdminUser'];
export type AdminPasswordResetResponse =
components['schemas']['AdminPasswordResetResponse'];
export type AdminUserStatusFilter =
components['schemas']['AdminUserStatusFilter'];
/** 契约 AdminUserStatusFilter 取值集(控制器 parseStatusFilter 校验共用)。 */
export const STATUS_FILTER_VALUES: readonly AdminUserStatusFilter[] = [
'active',
'pending',
'disabled',
'locked',
];
/** 服务端生成初始密码:16 位、62 字符集(均匀随机,~95 bit 熵)。 */
const GENERATED_PASSWORD_LENGTH = 16;
const GENERATED_PASSWORD_CHARSET =
'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
function generateInitialPassword(): string {
let out = '';
for (let i = 0; i < GENERATED_PASSWORD_LENGTH; i++) {
out += GENERATED_PASSWORD_CHARSET[
randomInt(GENERATED_PASSWORD_CHARSET.length)
];
}
return out;
}
function requireAdmin(user: AuthUser): void {
if (user.role !== 'admin') {
throw new ApiException(
ErrorCodes.AUTH_FORBIDDEN,
'仅管理员可进行账号治理操作',
HttpStatus.FORBIDDEN,
);
}
}
function userNotFound(): ApiException {
return new ApiException(
ErrorCodes.NOT_FOUND,
'目标用户不存在',
HttpStatus.NOT_FOUND,
);
}
/**
* PATCH 有效变更集(幂等 no-op 检测 + 审计 metadata 双用途)。值收敛为
* string|boolean 联合以兼容 Prisma InputJsonValue 的索引签名要求。
*/
type UserChanges = Record<
string,
{ from: string | boolean; to: string | boolean }
>;
@Injectable()
export class AdminUsersService {
constructor(private readonly prisma: PrismaService) {}
/** 管理列表模式:全量用户 + query/status 过滤 + createdAt+ID 倒序游标分页。 */
async list(
user: AuthUser,
query: string | undefined,
status: AdminUserStatusFilter | undefined,
cursor: string | undefined,
limit: number,
): Promise<AdminUserListResponse> {
requireAdmin(user);
let decoded: KeysetCursor | null = null;
if (cursor !== undefined && cursor !== '') {
decoded = decodeKeysetCursor(cursor);
if (decoded === null) {
throw validationFailed('cursor 不合法');
}
}
// 可选条件全部经参数化门控合入单条静态 SQL(模板绑定,无拼接注入面):
// 缺省过滤以 ${null} IS NULL / ${0} = 0 短路为恒真;query 前缀沿用
// ESCAPE '!' 显式转义(%/_/!/\\ 按字面匹配)。
const hasQuery = query !== undefined;
const pattern = hasQuery ? `${escapeLikePrefix(query)}%` : '';
const statusFilter: AdminUserStatusFilter | null = status ?? null;
const cursorSort = decoded === null ? null : new Date(decoded.sort);
const cursorId = decoded === null ? '' : decoded.id;
const rows = await this.prisma.$queryRaw<AdminUserRow[]>`
SELECT id, account, displayName, role, sourceStatus, localLocked,
credentialStatus, locale, createdAt
FROM users
WHERE (${hasQuery ? 1 : 0} = 0
OR account LIKE ${pattern} ESCAPE '!'
OR displayName LIKE ${pattern} ESCAPE '!')
AND (${statusFilter} IS NULL
OR (CASE
WHEN credentialStatus = 'pending' THEN 'pending'
WHEN sourceStatus = 'disabled' THEN 'disabled'
WHEN localLocked = TRUE THEN 'locked'
ELSE 'active'
END) = ${statusFilter})
AND (${cursorSort} IS NULL
OR createdAt < ${cursorSort}
OR (createdAt = ${cursorSort} AND id < ${cursorId}))
ORDER BY createdAt DESC, id DESC
LIMIT ${limit + 1}
`;
const hasMore = rows.length > limit;
const page = hasMore ? rows.slice(0, limit) : rows;
const last = page.at(-1);
// CursorPageMeta 不变式:hasMore ⟺ nextCursor≠null(同 albums.list 推导)。
const nextCursor =
hasMore && last !== undefined
? encodeKeysetCursor({
sort: last.createdAt.toISOString(),
id: last.id,
})
: null;
return {
items: page.map((row) => toAdminUser(row)),
nextCursor,
hasMore: nextCursor !== null,
};
}
/** 用户治理属性部分更新(updateAdminUser)。 */
async update(
user: AuthUser,
userId: string,
dto: AdminUserUpdateDto,
): Promise<AdminUserResponse> {
requireAdmin(user);
if (
dto.role === undefined &&
dto.localLocked === undefined &&
dto.displayName === undefined
) {
throw validationFailed(
'请求体为空:至少提供 role/localLocked/displayName 之一',
);
}
const target = await this.prisma.users.findUnique({
where: { id: userId },
});
if (target === null) {
throw userNotFound();
}
// 自我保护(Task 6 决策):自改角色(提权/降权语义混乱)与自锁(唯一
// 管理员把自己锁在门外)都是事故面,一律 400;displayName 自改无风险。
if (userId === user.id) {
if (dto.role !== undefined) {
throw validationFailed('不能修改自己的角色');
}
if (dto.localLocked === true) {
throw validationFailed('不能锁定自己(localLocked 自锁被拒绝)');
}
}
// 变化检测:提交值与现值全同 → 幂等返回,不落审计(同相册域 no-op 先例)。
const changes: UserChanges = {};
const data: { role?: string; localLocked?: boolean; displayName?: string } =
{};
if (dto.role !== undefined && dto.role !== target.role) {
changes.role = { from: target.role, to: dto.role };
data.role = dto.role;
}
if (dto.localLocked !== undefined && dto.localLocked !== target.localLocked) {
changes.localLocked = { from: target.localLocked, to: dto.localLocked };
data.localLocked = dto.localLocked;
}
if (
dto.displayName !== undefined &&
dto.displayName !== target.displayName
) {
changes.displayName = {
from: target.displayName,
to: dto.displayName,
};
data.displayName = dto.displayName;
}
if (Object.keys(data).length === 0) {
return toAdminUser(target);
}
const updated = await this.prisma.$transaction(async (tx) => {
const row = await tx.users.update({ where: { id: userId }, data });
// localLocked false→true 等价停用:撤销该用户全部会话(契约语义)。
if (data.localLocked === true) {
await tx.sessions.updateMany({
where: { userId, revokedAt: null },
data: { revokedAt: new Date() },
});
}
await tx.audit_logs.create({
data: {
actorId: user.id,
action: 'user.update',
resourceId: userId,
metadata: { changes },
},
});
return row;
});
return toAdminUser(updated);
}
/** 管理员重置用户密码(resetAdminUserPassword):指定或生成 + 撤全部会话。 */
async resetPassword(
user: AuthUser,
userId: string,
dto: AdminPasswordResetDto,
): Promise<AdminPasswordResetResponse> {
requireAdmin(user);
const target = await this.prisma.users.findUnique({
where: { id: userId },
select: { id: true },
});
if (target === null) {
throw userNotFound();
}
const generated = dto.newPassword === undefined;
const newPassword = dto.newPassword ?? generateInitialPassword();
// argon2 耗时,事务外先算好(同 auth.changePassword 先例,不占连接)。
const passwordHash = await hashPassword(newPassword);
await this.prisma.$transaction([
this.prisma.users.update({
where: { id: userId },
data: { passwordHash },
}),
// 撤销目标的**全部**既有会话(与自助改密保留当前会话不同:管理员重置
// 意味着凭据交接,旧会话一律作废;执行方 admin 自身会话不在目标域内)。
this.prisma.sessions.updateMany({
where: { userId, revokedAt: null },
data: { revokedAt: new Date() },
}),
this.prisma.audit_logs.create({
data: {
actorId: user.id,
action: 'user.password_reset',
resourceId: userId,
// 凭据敏感:审计只记「生成/指定」,绝不含密码明文/哈希/长度等材料。
metadata: { generated },
},
}),
]);
return { newPassword };
}
/** 激活待激活用户(activateAdminUser):pending→active,幂等。 */
async activate(user: AuthUser, userId: string): Promise<AdminUserResponse> {
requireAdmin(user);
const target = await this.prisma.users.findUnique({
where: { id: userId },
});
if (target === null) {
throw userNotFound();
}
if (target.credentialStatus === 'active') {
// 幂等:已是 active 返回当前条目,不重复落审计。
return toAdminUser(target);
}
const updated = await this.prisma.$transaction(async (tx) => {
const row = await tx.users.update({
where: { id: userId },
data: { credentialStatus: 'active' },
});
await tx.audit_logs.create({
data: {
actorId: user.id,
action: 'user.activate',
resourceId: userId,
},
});
return row;
});
return toAdminUser(updated);
}
/** 停用用户(deactivateAdminUser):localLocked=true + 撤全部会话,幂等。 */
async deactivate(user: AuthUser, userId: string): Promise<AdminUserResponse> {
requireAdmin(user);
const target = await this.prisma.users.findUnique({
where: { id: userId },
});
if (target === null) {
throw userNotFound();
}
if (target.localLocked) {
// 幂等:已锁定直接返回(锁定用户无法登录,不存在待撤的新会话)。
return toAdminUser(target);
}
const updated = await this.prisma.$transaction(async (tx) => {
const row = await tx.users.update({
where: { id: userId },
data: { localLocked: true },
});
await tx.sessions.updateMany({
where: { userId, revokedAt: null },
data: { revokedAt: new Date() },
});
await tx.audit_logs.create({
data: {
actorId: user.id,
action: 'user.deactivate',
resourceId: userId,
},
});
return row;
});
return toAdminUser(updated);
}
}
+10 -5
View File
@@ -1,15 +1,20 @@
// services/api/src/admin/admin.module.ts —— 管理辅助域模块(P1b Task 4 起步;
// P4a Task 5 增补用量统计/阈值告警端点)。装配 UsersLookupController/
// UsersLookupService 与 UsageController/UsageService;均仅依赖 @Global 的
// PrismaService(SessionGuard 同样只依赖 Prisma),无需导入其他功能模块。
// P4a Task 5 增补用量统计/阈值告警端点;Task 6 增补账号治理端点)。装配
// UsersLookupController/UsersLookupService(GET /admin/users 双模式)、
// AdminUsersController/AdminUsersService(PATCH/reset-password/activate/
// deactivate)与 UsageController/UsageService;均仅依赖 @Global 的
// PrismaService(SessionGuard 同样只依赖 Prisma;hashPassword 为纯函数),
// 无需导入其他功能模块。
import { Module } from '@nestjs/common';
import { AdminUsersController } from './admin-users.controller';
import { AdminUsersService } from './admin-users.service';
import { UsageController } from './usage.controller';
import { UsageService } from './usage.service';
import { UsersLookupController } from './users-lookup.controller';
import { UsersLookupService } from './users-lookup.service';
@Module({
controllers: [UsersLookupController, UsageController],
providers: [UsersLookupService, UsageService],
controllers: [UsersLookupController, AdminUsersController, UsageController],
providers: [UsersLookupService, AdminUsersService, UsageService],
})
export class AdminModule {}
@@ -1,25 +1,36 @@
// services/api/src/admin/users-lookup.controller.ts —— /admin/users 端点
// (P1b Task 4 建立;P4a Task 1 契约统一为 listAdminUsers 双模式)。
// 方法名与契约 operationId 一一对应(listAdminUsers)。
// 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 判定由 Task 6 补齐)。控制器只做查询参数规整,
// 语义在服务层。
// 过渡期边界(Task 6 收口):契约 P4a 起 accountPrefix 可选(缺省进入管理
// 列表模式:query/status 过滤 + 游标分页 + 仅 admin);当前实现仅前缀模式,
// 缺 accountPrefix 仍 400——管理列表模式随 Task 6 账号管理落地。
// limit 边界已对齐统一契约:缺省 20、上下界 1..100(P1b 时为 10/1..20;
// 邀请搜索调用方显式传 limit=10,行为不变)。
// 头——管理列表模式的仅 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 { SessionGuard } from '../auth/session.guard';
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)。 */
@@ -28,16 +39,38 @@ export const MAX_LIST_LIMIT = 100;
/** accountPrefix 查询参数(前缀模式):2..64(契约 bounds)。 */
export function parseAccountPrefix(raw: string | undefined): string {
if (raw === undefined || raw === '') {
// Task 6 起此分支消失(缺省进入管理列表模式);当前实现仅前缀模式。
throw validationFailed('accountPrefix 必填(2-64 字符)');
}
if (raw.length < 2 || raw.length > 64) {
// 双模式收口后 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 === '') {
@@ -64,17 +97,36 @@ export function parseListLimit(raw: string | undefined): number {
@Controller('admin/users')
@UseGuards(SessionGuard)
export class UsersLookupController {
constructor(private readonly usersLookupService: UsersLookupService) {}
constructor(
private readonly usersLookupService: UsersLookupService,
private readonly adminUsersService: AdminUsersService,
) {}
/** 用户查询/管理列表(统一响应 AdminUserList;当前仅前缀模式,见文件头)。 */
/** 用户查询/管理列表(双模式统一响应 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> {
return this.usersLookupService.lookup(
parseAccountPrefix(accountPrefix),
parseListLimit(limit),
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,
);
}
}
+29 -21
View File
@@ -1,9 +1,8 @@
// services/api/src/admin/users-lookup.service.ts —— 用户查询/管理列表(P1b
// Task 4 前缀查询;P4a Task 1 契约统一为 listAdminUsers 双模式端点)。
// P4a 契约决策:/admin/users 响应统一 AdminUserList(AdminUser 字段超集,
// 管理端 UI 按场景裁剪展示);本文件为**过渡实现**——仅落地前缀模式
// (P1b 语义原样:邀请协作者前置查询),管理列表模式(query/status 过滤 +
// 游标分页 + 仅 admin 访问)由 P4a Task 6 账号管理实现补齐。
// P4a Task 6 收口后本文件承载**前缀模式**(P1b 语义原样:邀请协作者前置查询,
// 任何已认证工作人员可用);管理列表模式(query/status 过滤 + 游标分页 + 仅
// admin)与治理端点(PATCH/reset/activate/deactivate)在 admin-users.service。
// 实现要点:
// · 前缀匹配大小写敏感:库级 collation utf8mb4_0900_bin(schema.prisma 头注
// 约定),LIKE 按二进制逐字节比较——'AL' 不命中 'alice'(契约已注明)。
@@ -14,7 +13,7 @@
// sourceStatus≠disabled 且 !localLocked):被停用/待激活/锁定账号不可被
// 邀请,也就不应出现在候选列表(与 members.add 的目标校验一致)。
// · 前缀模式即取即用(limit 上限内一次取回):nextCursor=null、
// hasMore=false;游标翻页随 Task 6 管理列表模式落地。
// hasMore=false;游标翻页由管理列表模式(admin-users.service)承载。
import { Injectable } from '@nestjs/common';
import type { components } from '@photo/contracts';
import { PrismaService } from '../prisma/prisma.service';
@@ -22,21 +21,41 @@ import { PrismaService } from '../prisma/prisma.service';
export type AdminUserListResponse = components['schemas']['AdminUserList'];
/** AdminUser 契约条目类型(枚举字段的窄化转换目标)。 */
type AdminUserItem = AdminUserListResponse['items'][number];
export type AdminUserItem = AdminUserListResponse['items'][number];
/** $queryRaw 行形状(SELECT 列即 AdminUser 契约字段——不含任何凭据列)。 */
interface AdminUserRow {
/**
* users 行形状(SELECT 列即 AdminUser 契约字段——不含任何凭据列)。
* localLocked:$queryRaw 返回 0/1、Prisma 客户端返回 boolean,两种来源共用
* (toAdminUser 以 Boolean 归一)。
*/
export interface AdminUserRow {
id: string;
account: string;
displayName: string;
role: string;
sourceStatus: string;
localLocked: number; // MySQL BOOLEAN 经 $queryRaw 返回 0/1
localLocked: boolean | number;
credentialStatus: string;
locale: string | null;
createdAt: Date;
}
/** users 行 → AdminUser 契约条目(双模式列表与治理端点回显共用)。 */
export function toAdminUser(row: AdminUserRow): AdminUserItem {
return {
id: row.id,
account: row.account,
displayName: row.displayName,
// 库列为自由字符串(schema 约定取值集),窄化到契约枚举联合。
role: row.role as AdminUserItem['role'],
sourceStatus: row.sourceStatus as AdminUserItem['sourceStatus'],
localLocked: Boolean(row.localLocked),
credentialStatus: row.credentialStatus as AdminUserItem['credentialStatus'],
locale: row.locale,
createdAt: row.createdAt.toISOString(),
};
}
/**
* LIKE 前缀转义(ESCAPE '!'):! 自身、% 与 _(MySQL 通配符)及 \(历史转义
* 字符,防御不同 SQL_MODE 下的语义漂移)前置 ! 后按字面匹配。
@@ -71,18 +90,7 @@ export class UsersLookupService {
LIMIT ${limit}
`;
return {
items: rows.map((row) => ({
id: row.id,
account: row.account,
displayName: row.displayName,
// 库列为自由字符串(schema 约定取值集),窄化到契约枚举联合。
role: row.role as AdminUserItem['role'],
sourceStatus: row.sourceStatus as AdminUserItem['sourceStatus'],
localLocked: row.localLocked !== 0,
credentialStatus: row.credentialStatus as AdminUserItem['credentialStatus'],
locale: row.locale,
createdAt: row.createdAt.toISOString(),
})),
items: rows.map((row) => toAdminUser(row)),
nextCursor: null,
hasMore: false,
};