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:
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user