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