feat(api): management photo list and account lookup

P1b Task 4: listAlbumPhotos (GET /albums/{albumId}/photos, tag
[photos]) + lookupUsersByAccount (GET /admin/users, tag [admin]).

- management list: all visibility states visible (unpublished/
  published/hidden/deleted) for owner/admin; collaborator 403,
  outsider/ghost album 404 (canView -> canManage ordering, same
  anti-enumeration as members). Items = PhotoRecord + uploader
  {userId, account, displayName} (flat-FK second query, redacted).
  Keyset (uploadedAt,id) DESC via shared/cursor; visibility filter
  validated against PhotoVisibility enum (400 otherwise), categoryId
  must belong to the album (ghost/cross-album same-code 400).
  pending_upload rows (uploadedAt NULL) excluded: the sort key is
  both the contract-required field and the keyset predicate - NULL
  can neither be shaped as PhotoRecord nor compared; in-flight
  uploads are covered by the uploads history endpoint.
- account lookup (new src/admin module): prefix search over active
  users only (credentialStatus=active, sourceStatus!=disabled,
  !localLocked - same eligibility as SessionGuard/members.add),
  account ASC, no cursor (small cap). $queryRaw LIKE prefix% with
  explicit ESCAPE '!': !/%/_/\ escaped to literal matching (Prisma
  startsWith escaping is not contractual); utf8mb4_0900_bin makes
  the prefix match case-sensitive ('AL' misses 'alice'), documented
  in contract + tests. Access policy per contract: any authenticated
  staff (deviation from plan's admin/owner), unauthenticated 401.
- e2e: photos-admin (6) + users-lookup (6) cases; api e2e 136 -> 148.

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-09-18 08:54:44 +08:00
parent 361a72f300
commit 8ba98ace63
10 changed files with 1124 additions and 12 deletions
+12
View File
@@ -0,0 +1,12 @@
// services/api/src/admin/admin.module.ts —— 管理辅助域模块(P1b Task 4)。
// 装配 UsersLookupController/UsersLookupService;仅依赖 @Global 的 PrismaService
// (SessionGuard 同样只依赖 Prisma),无需导入其他功能模块。
import { Module } from '@nestjs/common';
import { UsersLookupController } from './users-lookup.controller';
import { UsersLookupService } from './users-lookup.service';
@Module({
controllers: [UsersLookupController],
providers: [UsersLookupService],
})
export class AdminModule {}
@@ -0,0 +1,78 @@
// services/api/src/admin/users-lookup.controller.ts —— /admin/users 端点
// (P1b Task 4)。方法名与契约 operationId 一一对应(lookupUsersByAccount)。
// 控制器级 SessionGuard:访问策略=任何已认证工作人员(契约描述的 P1b 决策,
// 见服务文件头),认证通过即可,无角色判定。控制器只做查询参数规整
// (accountPrefix 必填 2-64 字符;limit 缺省 10、1..20——本端点独立 bounds,
// 同公开照片列表在 public.controller 本地维护 parsePublicPhotoLimit 的先例,
// 勿混用 shared/pagination 的 1..100);语义在服务层。
import {
Controller,
Get,
HttpStatus,
Query,
UseGuards,
} from '@nestjs/common';
import { ErrorCodes } from '@photo/contracts';
import { ApiException } from '../api-exception';
import { SessionGuard } from '../auth/session.guard';
import { validationFailed } from '../albums/support';
import {
UsersLookupService,
type AdminUserLookupListResponse,
} from './users-lookup.service';
/** 契约 lookupUsersByAccount.limit:缺省 10,上下界 1..20。 */
export const DEFAULT_LOOKUP_LIMIT = 10;
export const MAX_LOOKUP_LIMIT = 20;
/** accountPrefix 查询参数:必填(缺失/空串 400),长度 2..64(契约 bounds)。 */
export function parseAccountPrefix(raw: string | undefined): string {
if (raw === undefined || raw === '') {
throw validationFailed('accountPrefix 必填(2-64 字符)');
}
if (raw.length < 2 || raw.length > 64) {
throw validationFailed('accountPrefix 须为 2-64 字符');
}
return raw;
}
/** limit 查询参数:缺省 10,须为 1..20 的十进制整数(契约 bounds)。 */
export function parseLookupLimit(raw: string | undefined): number {
if (raw === undefined || raw === '') {
return DEFAULT_LOOKUP_LIMIT;
}
if (!/^\d+$/.test(raw)) {
throw new ApiException(
ErrorCodes.VALIDATION_FAILED,
'limit 必须是 1-20 的整数',
HttpStatus.BAD_REQUEST,
);
}
const limit = Number.parseInt(raw, 10);
if (limit < 1 || limit > MAX_LOOKUP_LIMIT) {
throw new ApiException(
ErrorCodes.VALIDATION_FAILED,
'limit 必须是 1-20 的整数',
HttpStatus.BAD_REQUEST,
);
}
return limit;
}
@Controller('admin/users')
@UseGuards(SessionGuard)
export class UsersLookupController {
constructor(private readonly usersLookupService: UsersLookupService) {}
/** 账号前缀查询:激活用户脱敏列表(account 升序,无游标)。 */
@Get()
lookupUsersByAccount(
@Query('accountPrefix') accountPrefix: string | undefined,
@Query('limit') limit: string | undefined,
): Promise<AdminUserLookupListResponse> {
return this.usersLookupService.lookup(
parseAccountPrefix(accountPrefix),
parseLookupLimit(limit),
);
}
}
@@ -0,0 +1,70 @@
// services/api/src/admin/users-lookup.service.ts —— 账号前缀查询(P1b Task 4,
// 契约 lookupUsersByAccount,tag [admin])。邀请协作者的前置查询:按 account
// 前缀搜索激活用户,返回 {id,account,displayName} 脱敏列表(不含邮箱/手机/
// 状态/凭据字段)。
// 访问策略(P1b 决策,契约描述):任何已认证工作人员可用——邀请发起方不限于
// 创建者/管理员,泄露面仅为激活用户的最小展示字段、接收方为已认证内部人员;
// 未认证一律 401(控制器级 SessionGuard 承担,服务层不再重复角色判定)。
// 实现要点:
// · 前缀匹配大小写敏感:库级 collation utf8mb4_0900_bin(schema.prisma 头注
// 约定),LIKE 按二进制逐字节比较——'AL' 不命中 'alice'(契约已注明)。
// · LIKE 通配符显式转义:Prisma startsWith 对 %/_ 的转义行为无契约保证
// (引擎版本相关),此处 $queryRaw 显式 `ESCAPE '!'`,前缀中 !/%/_/\ 四
// 字符前置 ! 后按字面匹配——'esc_' 只命中字面下划线账号,不作单字符通配。
// · 资格过滤与 SessionGuard 同一谓词(credentialStatus=active 且
// sourceStatus≠disabled 且 !localLocked):被停用/待激活/锁定账号不可被
// 邀请,也就不应出现在候选列表(与 members.add 的目标校验一致)。
// · 无游标:前缀搜索 + 小上限(1..20 缺省 10)即取即用(契约决策),按
// account 升序返回确定性输出。
import { Injectable } from '@nestjs/common';
import type { components } from '@photo/contracts';
import { PrismaService } from '../prisma/prisma.service';
export type AdminUserLookupListResponse =
components['schemas']['AdminUserLookupList'];
/** $queryRaw 行形状(SELECT 列即脱敏字段全集——不选取任何凭据/状态列)。 */
interface LookupRow {
id: string;
account: string;
displayName: string;
}
/**
* LIKE 前缀转义(ESCAPE '!'):! 自身、% 与 _(MySQL 通配符)及 \(历史转义
* 字符,防御不同 SQL_MODE 下的语义漂移)前置 ! 后按字面匹配。
*/
export function escapeLikePrefix(prefix: string): string {
return prefix.replace(/[!%_\\]/g, (ch) => `!${ch}`);
}
@Injectable()
export class UsersLookupService {
constructor(private readonly prisma: PrismaService) {}
/** 前缀查询(契约 lookupUsersByAccount):激活用户、account 升序、limit 截断。 */
async lookup(
accountPrefix: string,
limit: number,
): Promise<AdminUserLookupListResponse> {
const pattern = `${escapeLikePrefix(accountPrefix)}%`;
// 参数经 $queryRaw 模板绑定(无拼接注入面);LIMIT ? 为 MySQL 合法占位。
const rows = await this.prisma.$queryRaw<LookupRow[]>`
SELECT id, account, displayName
FROM users
WHERE account LIKE ${pattern} ESCAPE '!'
AND credentialStatus = 'active'
AND sourceStatus <> 'disabled'
AND localLocked = false
ORDER BY account ASC
LIMIT ${limit}
`;
return {
items: rows.map((row) => ({
id: row.id,
account: row.account,
displayName: row.displayName,
})),
};
}
}