feat(api): album creation with translations and owner membership

Task 15: POST/GET /v1/albums, GET /v1/albums/{id}.
- create: single transaction writes albums + album_translations +
  album_members(owner) + audit_logs(album.create); publicToken 24-char
  [0-9a-z] with one P2002 retry; duplicate-locale input rejected as 400.
- dto: class-validator per contract AlbumCreateRequest, incl. cross-field
  rules (defaultLocale must be covered by translations; endAt>startAt when
  present; timezone string-only per P1a scope).
- get: PermissionsService.canViewAlbum gate -> 404 ALBUM_NOT_FOUND for
  non-member/nonexistent alike (anti-enumeration); admin bypass; joins
  categories (empty until Task 17).
- list: admin sees all, members see joined albums; stable createdAt DESC,
  id DESC keyset pagination via new shared/cursor.ts (encode/decode +
  typed where-builder, designed for Task 23 photo list reuse).
- e2e: services/api/test/albums.e2e-spec.ts (12 cases, seeded sessions).

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-09-17 21:51:50 +08:00
parent 2d9b9f9fad
commit 1c48a4108b
6 changed files with 1130 additions and 5 deletions
@@ -0,0 +1,116 @@
// services/api/src/albums/albums.controller.ts —— /albums 端点(Task 15)。
// 方法名与契约 operationId 一一对应(createAlbum/listAlbums/getAlbum;
// updateAlbum 属 Task 16)。控制器级 SessionGuard:三端点全需认证
// (契约均有 bearerAuth、无 security: [] 例外)。职责仅剩:
// · 透传认证用户(req.user,守卫挂载);
// · 查询参数规整(limit 1..100 整数、cursor 长度),非法 → 400
// VALIDATION_FAILED,与 body 校验同一出口;
// · 权限/可见性→HTTP 映射交由服务层抛 ApiException(404 ALBUM_NOT_FOUND)。
import {
Body,
Controller,
Get,
HttpStatus,
Param,
Post,
Query,
Req,
UseGuards,
} from '@nestjs/common';
import { ErrorCodes } from '@photo/contracts';
import { ApiException } from '../api-exception';
import type { AuthUser } from '../auth/session.guard';
import { AuthenticatedRequest, SessionGuard } from '../auth/session.guard';
import {
DEFAULT_ALBUM_PAGE_SIZE,
MAX_ALBUM_PAGE_SIZE,
type AlbumListResponse,
type AlbumResponse,
AlbumsService,
} from './albums.service';
import { AlbumCreateRequestDto } from './dto';
import { MAX_CURSOR_LENGTH } from '../shared/cursor';
/** 守卫放行后提取 req.user;防御性兜底(守卫未挂载属装配错误,按未认证拒绝)。 */
function userOf(req: AuthenticatedRequest): AuthUser {
if (req.user === undefined) {
throw new ApiException(
ErrorCodes.AUTH_TOKEN_EXPIRED,
'Session token is missing, invalid, or expired',
HttpStatus.UNAUTHORIZED,
);
}
return req.user;
}
/** limit 查询参数:缺省 20,须为 1..100 的十进制整数(契约 bounds)。 */
function parseLimit(raw: string | undefined): number {
if (raw === undefined || raw === '') {
return DEFAULT_ALBUM_PAGE_SIZE;
}
if (!/^\d+$/.test(raw)) {
throw new ApiException(
ErrorCodes.VALIDATION_FAILED,
'limit 必须是 1-100 的整数',
HttpStatus.BAD_REQUEST,
);
}
const limit = Number.parseInt(raw, 10);
if (limit < 1 || limit > MAX_ALBUM_PAGE_SIZE) {
throw new ApiException(
ErrorCodes.VALIDATION_FAILED,
'limit 必须是 1-100 的整数',
HttpStatus.BAD_REQUEST,
);
}
return limit;
}
function parseCursor(raw: string | undefined): string | undefined {
if (raw === undefined || raw === '') {
return undefined;
}
if (raw.length > MAX_CURSOR_LENGTH) {
throw new ApiException(
ErrorCodes.VALIDATION_FAILED,
'cursor 不合法',
HttpStatus.BAD_REQUEST,
);
}
return raw;
}
@Controller('albums')
@UseGuards(SessionGuard)
export class AlbumsController {
constructor(private readonly albumsService: AlbumsService) {}
@Post()
createAlbum(
@Body() dto: AlbumCreateRequestDto,
@Req() req: AuthenticatedRequest,
): Promise<AlbumResponse> {
return this.albumsService.create(userOf(req).id, dto);
}
@Get()
listAlbums(
@Query('cursor') cursor: string | undefined,
@Query('limit') limit: string | undefined,
@Req() req: AuthenticatedRequest,
): Promise<AlbumListResponse> {
return this.albumsService.list(
userOf(req),
parseCursor(cursor),
parseLimit(limit),
);
}
@Get(':albumId')
getAlbum(
@Param('albumId') albumId: string,
@Req() req: AuthenticatedRequest,
): Promise<AlbumResponse> {
return this.albumsService.get(userOf(req), albumId);
}
}