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
@@ -0,0 +1,48 @@
// services/api/src/photos/album-photos.controller.ts —— /albums/{albumId}/photos
// 管理列表端点(P1b Task 4)。方法名与契约 operationId 一一对应
// (listAlbumPhotos,tag [photos]——路由挂在相册子路径下,域仍属照片管理)。
// 控制器级 SessionGuard:端点需认证。控制器只做认证用户透传 + 查询参数规整
// (visibility 本域 dto / categoryId 空串归一 / cursor+limit 走 shared/pagination,
// 同契约 bounds);权限与 HTTP 映射由 PhotosService 抛 ApiException
// (404 ALBUM_NOT_FOUND / 403 AUTH_FORBIDDEN / 400 VALIDATION_FAILED)。
import {
Controller,
Get,
Param,
Query,
Req,
UseGuards,
} from '@nestjs/common';
import { userOf } from '../albums/albums.controller';
import { AuthenticatedRequest, SessionGuard } from '../auth/session.guard';
import { parseCursor, parseLimit } from '../shared/pagination';
import { parseVisibilityFilter } from './dto';
import { PhotosService, type AdminPhotoListResponse } from './photos.service';
@Controller('albums/:albumId/photos')
@UseGuards(SessionGuard)
export class AlbumPhotosController {
constructor(private readonly photosService: PhotosService) {}
/**
* 管理照片列表:全部可见性状态可见(区别于公开列表仅 published),
* (uploadedAt,id) 稳定倒序键集游标;条目内嵌 uploader 脱敏信息。
*/
@Get()
listAlbumPhotos(
@Param('albumId') albumId: string,
@Query('visibility') visibility: string | undefined,
@Query('categoryId') categoryId: string | undefined,
@Query('cursor') cursor: string | undefined,
@Query('limit') limit: string | undefined,
@Req() req: AuthenticatedRequest,
): Promise<AdminPhotoListResponse> {
return this.photosService.listAlbumPhotos(userOf(req), albumId, {
visibility: parseVisibilityFilter(visibility),
// 空串视为缺省(同公开列表 category 规整惯例)。
categoryId: categoryId === '' ? undefined : categoryId,
cursor: parseCursor(cursor),
limit: parseLimit(limit),
});
}
}