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);
}
}
+8 -5
View File
@@ -1,12 +1,15 @@
// services/api/src/albums/albums.module.ts —— 相册模块壳(Task 14)。
// 本任务只落 PermissionsService(A02 权限基石,依赖 @Global 的 PrismaService),
// 控制器与相册 CRUD 自 Task 15 起加入本模块。export 供 uploads/photos 等后续
// 模块经模块间 DI 复用同一份判定逻辑。
// services/api/src/albums/albums.module.ts —— 相册模块(Task 14 起步,Task 15 补全)。
// PermissionsService(A02 权限基石)+ AlbumsService/AlbumsController(创建/详情/
// 列表;Task 16 增补 PATCH)。export PermissionsService 供 uploads/photos 等后续
// 模块经模块间 DI 复用同一份判定逻辑;AlbumsService 暂无外部消费者不 export。
import { Module } from '@nestjs/common';
import { AlbumsController } from './albums.controller';
import { AlbumsService } from './albums.service';
import { PermissionsService } from './permissions.service';
@Module({
providers: [PermissionsService],
controllers: [AlbumsController],
providers: [PermissionsService, AlbumsService],
exports: [PermissionsService],
})
export class AlbumsModule {}
+358
View File
@@ -0,0 +1,358 @@
// services/api/src/albums/albums.service.ts —— 相册创建/详情/列表核心逻辑(Task 15)。
// 契约权威:packages/contracts/openapi.yaml tags [albums](createAlbum/getAlbum/
// listAlbums;updateAlbum 属 Task 16)。要点:
// · 创建走单事务:albums + album_translations(批量)+ album_members(owner)
// + audit_logs(album.create)——四表要么全成要么全无,杜绝「无主相册」
// (权限全部源自成员行,Task 14 结论:漏写 owner 行会让创建者自己不可见)。
// · publicToken:24 字符 [0-9a-z](randomBytes 均匀映射 36 字符表),
// 契约 pattern ^[0-9a-z]{20,32}$;24×log2(36)≈124bit 熵,碰撞概率可忽略,
// 仍在 P2002 时整事务重试一次兜底。
// · 相册域为扁平字符串外键(无 Prisma relation):翻译/分类/成员一律
// findMany 二次查询后在应用层组装,不做 include。
// · 列表:admin 全量;否则本账号有效成员行的相册(album_members 无 relation
// 映射,P1a 规模下先查成员 albumId 集再 id IN ——成员相册数为个位到十位级,
// 不构成规模问题;若 P5 需要,可换 $queryRaw 子查询,接口不变)。
// 排序 createdAt DESC, id DESC(稳定决胜),游标经 shared/cursor 键集推进。
// 响应映射严格契约 camelCase(DB snake 列名 → AlbumSummary/Album 形状)。
import { randomBytes } from 'node:crypto';
import { HttpStatus, Injectable } from '@nestjs/common';
import { ErrorCodes } from '@photo/contracts';
import type { components } from '@photo/contracts';
import type { Prisma } from '@photo/db-cn';
import { ApiException } from '../api-exception';
import type { AuthUser } from '../auth/session.guard';
import { PrismaService } from '../prisma/prisma.service';
import {
decodeKeysetCursor,
encodeKeysetCursor,
keysetWhereDesc,
} from '../shared/cursor';
import type { AlbumCreateRequestDto } from './dto';
import { PermissionsService } from './permissions.service';
export type AlbumTranslationResponse =
components['schemas']['AlbumTranslation'];
export type AlbumSummaryResponse = components['schemas']['AlbumSummary'];
export type AlbumResponse = components['schemas']['Album'];
export type AlbumListResponse = components['schemas']['AlbumList'];
/** 契约 listAlbums:limit 缺省 20,上下界 1..100。 */
export const DEFAULT_ALBUM_PAGE_SIZE = 20;
export const MAX_ALBUM_PAGE_SIZE = 100;
const PUBLIC_TOKEN_LENGTH = 24;
const PUBLIC_TOKEN_ALPHABET = '0123456789abcdefghijklmnopqrstuvwxyz';
function generatePublicToken(): string {
const bytes = randomBytes(PUBLIC_TOKEN_LENGTH);
let token = '';
for (let i = 0; i < PUBLIC_TOKEN_LENGTH; i++) {
// %36 的模偏差对 124bit 令牌熵无实际影响(非安全边界场景)。
token += PUBLIC_TOKEN_ALPHABET[bytes[i] % PUBLIC_TOKEN_ALPHABET.length];
}
return token;
}
function albumNotFound(): ApiException {
return new ApiException(
ErrorCodes.ALBUM_NOT_FOUND,
'相册不存在或不可见',
HttpStatus.NOT_FOUND,
);
}
/** P2002 是否命中指定唯一索引(meta.target 形如 ['publicToken'])。 */
function isUniqueViolationOn(err: unknown, field: string): boolean {
if (typeof err !== 'object' || err === null) {
return false;
}
const { code, meta } = err as { code?: unknown; meta?: { target?: unknown } };
if (code !== 'P2002') {
return false;
}
const target = meta?.target;
const fields = Array.isArray(target)
? target
: typeof target === 'string'
? [target]
: [];
return fields.includes(field);
}
type AlbumRow = Prisma.albumsGetPayload<Prisma.albumsDefaultArgs>;
type AlbumTranslationRow = Prisma.album_translationsGetPayload<
Prisma.album_translationsDefaultArgs
>;
function toTranslationResponse(
row: AlbumTranslationRow,
): AlbumTranslationResponse {
return {
// DB 存自由字符串;取值全部来自入参枚举校验,收窄为契约 Locale。
locale: row.locale as AlbumTranslationResponse['locale'],
title: row.title,
description: row.description,
venue: row.venue,
address: row.address,
};
}
function toSummaryResponse(
album: AlbumRow,
translations: AlbumTranslationRow[],
): AlbumSummaryResponse {
return {
id: album.id,
publicToken: album.publicToken,
ownerId: album.ownerId,
status: album.status as AlbumSummaryResponse['status'],
defaultLocale: album.defaultLocale as AlbumSummaryResponse['defaultLocale'],
timezone: album.timezone,
startAt: album.startAt.toISOString(),
endAt: album.endAt === null ? null : album.endAt.toISOString(),
uploadEnabled: album.uploadEnabled,
publicEnabled: album.publicEnabled,
downloadEnabled: album.downloadEnabled,
originalDownloadEnabled: album.originalDownloadEnabled,
watermarkVersion: album.watermarkVersion,
translations: translations.map(toTranslationResponse),
createdAt: album.createdAt.toISOString(),
updatedAt: album.updatedAt.toISOString(),
};
}
@Injectable()
export class AlbumsService {
constructor(
private readonly prisma: PrismaService,
private readonly permissions: PermissionsService,
) {}
/**
* 创建相册:事务内四写(albums/translations/members/audit),返回完整
* Album 形状(categories 恒 []——分类自 Task 17 起才有写入入口)。
*/
async create(
userId: string,
dto: AlbumCreateRequestDto,
): Promise<AlbumResponse> {
// 重复 locale 会让事务内 createMany 撞 @@unique([albumId,locale]) 变 P2002
// ——那是 500 而非 400,必须在入事务前以校验错误拒绝。
const locales = dto.translations.map((t) => t.locale);
if (new Set(locales).size !== locales.length) {
throw new ApiException(
ErrorCodes.VALIDATION_FAILED,
'translations 存在重复 locale',
HttpStatus.BAD_REQUEST,
);
}
let albumId: string;
for (let attempt = 0; ; attempt++) {
try {
albumId = await this.prisma.$transaction(async (tx) => {
const album = await tx.albums.create({
data: {
publicToken: generatePublicToken(),
ownerId: userId,
startAt: new Date(dto.startAt),
endAt:
dto.endAt === undefined || dto.endAt === null
? null
: new Date(dto.endAt),
timezone: dto.timezone ?? 'Asia/Shanghai',
defaultLocale: dto.defaultLocale,
downloadEnabled: dto.downloadEnabled ?? true,
originalDownloadEnabled: dto.originalDownloadEnabled ?? false,
// status/uploadEnabled/publicEnabled/watermarkVersion 走 schema 默认。
},
select: { id: true },
});
await tx.album_translations.createMany({
data: dto.translations.map((t) => ({
albumId: album.id,
locale: t.locale,
title: t.title,
description: t.description ?? null,
venue: t.venue ?? null,
address: t.address ?? null,
})),
});
await tx.album_members.create({
data: {
albumId: album.id,
userId,
role: 'owner',
status: 'active',
},
});
await tx.audit_logs.create({
data: {
actorId: userId,
action: 'album.create',
resourceId: album.id,
},
});
return album.id;
});
break;
} catch (err) {
// publicToken 撞唯一索引:换令牌整事务重来一次(一次已足够——
// 124bit 熵下连撞两回可视为宇宙射线问题,交由 5xx 兜底)。
if (attempt === 0 && isUniqueViolationOn(err, 'publicToken')) {
continue;
}
throw err;
}
}
const album = await this.loadAlbum(albumId);
if (album === null) {
// 刚提交就读不到只可能是并发异常路径,按 5xx 兜底处理。
throw new ApiException(
ErrorCodes.INTERNAL_ERROR,
'Internal server error',
HttpStatus.INTERNAL_SERVER_ERROR,
);
}
return album;
}
/** 相册详情:不可见(含不存在)统一 404 ALBUM_NOT_FOUND,防 id 枚举。 */
async get(user: AuthUser, albumId: string): Promise<AlbumResponse> {
const visible = await this.permissions.canViewAlbum(user, albumId);
if (!visible) {
throw albumNotFound();
}
const album = await this.loadAlbum(albumId);
if (album === null) {
// admin 对不存在的 id 权限恒真,须由这里的空值兜底成同码 404。
throw albumNotFound();
}
return album;
}
/** 相册列表:admin 全量;否则成员行(status=active)对应的相册。 */
async list(
user: AuthUser,
cursor: string | undefined,
limit: number,
): Promise<AlbumListResponse> {
let decoded: ReturnType<typeof decodeKeysetCursor> = null;
if (cursor !== undefined && cursor !== '') {
decoded = decodeKeysetCursor(cursor);
if (decoded === null) {
throw new ApiException(
ErrorCodes.VALIDATION_FAILED,
'cursor 不合法',
HttpStatus.BAD_REQUEST,
);
}
}
const where: Prisma.albumsWhereInput = {};
if (user.role !== 'admin') {
const memberships = await this.prisma.album_members.findMany({
where: { userId: user.id, status: 'active' },
select: { albumId: true },
});
where.id = { in: memberships.map((m) => m.albumId) };
}
if (decoded !== null) {
Object.assign(where, keysetWhereDesc('createdAt', 'id', decoded));
}
const rows = await this.prisma.albums.findMany({
where,
orderBy: [{ createdAt: 'desc' }, { id: 'desc' }],
take: limit + 1, // 多取一条探测 hasMore,避免额外 count 查询
});
const hasMore = rows.length > limit;
const page = hasMore ? rows.slice(0, limit) : rows;
const translations = await this.prisma.album_translations.findMany({
where: { albumId: { in: page.map((a) => a.id) } },
orderBy: { locale: 'asc' },
});
const byAlbumId = new Map<string, AlbumTranslationRow[]>();
for (const row of translations) {
const bucket = byAlbumId.get(row.albumId);
if (bucket === undefined) {
byAlbumId.set(row.albumId, [row]);
} else {
bucket.push(row);
}
}
const last = page.at(-1);
// 契约 CursorPageMeta 不变式:hasMore=true 当且仅当 nextCursor≠null——
// 以 nextCursor 派生 hasMore,结构上保证两者成对(防御未来调用方绕过
// 控制器的 limit 规整直呼本方法)。
const nextCursor =
hasMore && last !== undefined
? encodeKeysetCursor({
sort: last.createdAt.toISOString(),
id: last.id,
})
: null;
return {
items: page.map((a) => toSummaryResponse(a, byAlbumId.get(a.id) ?? [])),
nextCursor,
hasMore: nextCursor !== null,
};
}
/**
* 组装完整 Album 形状:主行 + 翻译(locale 升序,确定性输出)+ 分类
* (sortOrder 升序,Task 17 前无写入入口故实际为空数组——join 逻辑先就位,
* 分类上线即生效)。相册域无 Prisma relation,全部二次查询应用层组装。
*/
private async loadAlbum(albumId: string): Promise<AlbumResponse | null> {
const album = await this.prisma.albums.findUnique({
where: { id: albumId },
});
if (album === null) {
return null;
}
const translations = await this.prisma.album_translations.findMany({
where: { albumId },
orderBy: { locale: 'asc' },
});
const categories = await this.prisma.categories.findMany({
where: { albumId },
orderBy: [{ sortOrder: 'asc' }, { id: 'asc' }],
});
const categoryTranslations =
categories.length === 0
? []
: await this.prisma.category_translations.findMany({
where: { categoryId: { in: categories.map((c) => c.id) } },
orderBy: { locale: 'asc' },
});
const namesByCategory = new Map<
string,
components['schemas']['CategoryTranslation'][]
>();
for (const row of categoryTranslations) {
// 同 toTranslationResponse:locale 取值来自入参枚举校验,收窄为契约 Locale。
const entry = {
locale: row.locale as components['schemas']['Locale'],
name: row.name,
};
const bucket = namesByCategory.get(row.categoryId);
if (bucket === undefined) {
namesByCategory.set(row.categoryId, [entry]);
} else {
bucket.push(entry);
}
}
return {
...toSummaryResponse(album, translations),
categories: categories.map((c) => ({
id: c.id,
albumId: c.albumId,
sortOrder: c.sortOrder,
translations: namesByCategory.get(c.id) ?? [],
})),
};
}
}
+140
View File
@@ -0,0 +1,140 @@
// services/api/src/albums/dto.ts —— 相册端点入参 DTO(Task 15)。
// 校验约束对齐契约 AlbumCreateRequest / AlbumTranslation:
// · translations 至少 1 条(minItems: 1),locale 限 P1a 双语枚举;
// · defaultLocale 必须有对应翻译条目(跨字段校验,契约 required 语义);
// · endAt 可空(契约 [string,'null']),给值时须晚于 startAt(跨字段校验);
// · timezone 只做字符串/长度校验——IANA 合法性校验为 P4a 事项(计划 P1a
// 明确不实现,非法时区最多影响展示侧回显,不构成存储安全问题);
// · 两个下载开关 @IsBoolean/@IsOptional,缺省值由服务层落库(true/false)。
// 跨字段约束用 ValidatorConstraint 而非服务层 if:失败与其余 DTO 错误一样经
// 全局 ValidationPipe → 400 VALIDATION_FAILED,单一出口。
import { Type } from 'class-transformer';
import {
ArrayMinSize,
IsBoolean,
IsIn,
IsISO8601,
IsNotEmpty,
IsOptional,
IsString,
MaxLength,
Validate,
ValidateNested,
ValidatorConstraint,
type ValidatorConstraintInterface,
type ValidationArguments,
} from 'class-validator';
/** 契约 Locale 枚举(P1a 双语)。 */
export const ALBUM_LOCALES = ['zh-CN', 'en'] as const;
export type AlbumLocale = (typeof ALBUM_LOCALES)[number];
/** 列宽对齐 db-cn schema:title/venue/address VarChar(255),timezone VarChar(64)。 */
const TITLE_MAX_LENGTH = 255;
const VENUE_ADDRESS_MAX_LENGTH = 255;
const TIMEZONE_MAX_LENGTH = 64;
export class AlbumTranslationDto {
@IsIn(ALBUM_LOCALES)
locale!: AlbumLocale;
@IsString()
@IsNotEmpty()
@MaxLength(TITLE_MAX_LENGTH)
title!: string;
/** 契约 [string,'null']:可不填或显式 null,落库为 null。 */
@IsOptional()
@IsString()
@MaxLength(65_535) // TEXT 列宽
description?: string | null;
@IsOptional()
@IsString()
@MaxLength(VENUE_ADDRESS_MAX_LENGTH)
venue?: string | null;
@IsOptional()
@IsString()
@MaxLength(VENUE_ADDRESS_MAX_LENGTH)
address?: string | null;
}
/** defaultLocale 必须能在 translations 里找到同 locale 条目(契约必含语义)。 */
@ValidatorConstraint({ name: 'albumDefaultLocaleCovered', async: false })
class DefaultLocaleCoveredConstraint implements ValidatorConstraintInterface {
validate(value: unknown, args: ValidationArguments): boolean {
const { translations } = args.object as { translations?: unknown };
// translations 本身缺失/非数组/为空 → 交给 @ArrayMinSize 报告,这里不重复报。
if (!Array.isArray(translations) || translations.length === 0) {
return true;
}
return translations.some(
(entry) =>
entry !== null &&
typeof entry === 'object' &&
(entry as { locale?: unknown }).locale === value,
);
}
defaultMessage(): string {
return 'translations 必须包含 defaultLocale 对应语言的条目';
}
}
/** endAt(给值时)必须晚于 startAt。 */
@ValidatorConstraint({ name: 'albumEndAfterStart', async: false })
class EndAfterStartConstraint implements ValidatorConstraintInterface {
validate(value: unknown, args: ValidationArguments): boolean {
// 值缺失/类型不符交给字段级装饰器报告;此处只裁决先后关系。
if (typeof value !== 'string' || value === '') {
return true;
}
const { startAt } = args.object as { startAt?: unknown };
if (typeof startAt !== 'string') {
return true;
}
const startMs = Date.parse(startAt);
const endMs = Date.parse(value);
if (Number.isNaN(startMs) || Number.isNaN(endMs)) {
return true; // 非法日期串交给 @IsISO8601 报告
}
return endMs > startMs;
}
defaultMessage(): string {
return 'endAt 必须晚于 startAt';
}
}
export class AlbumCreateRequestDto {
@ArrayMinSize(1)
@ValidateNested({ each: true })
@Type(() => AlbumTranslationDto)
translations!: AlbumTranslationDto[];
@IsIn(ALBUM_LOCALES)
@Validate(DefaultLocaleCoveredConstraint)
defaultLocale!: AlbumLocale;
@IsISO8601({ strict: true })
startAt!: string;
@IsOptional()
@IsISO8601({ strict: true })
@Validate(EndAfterStartConstraint)
endAt?: string | null;
@IsOptional()
@IsString()
@MaxLength(TIMEZONE_MAX_LENGTH)
timezone?: string;
@IsOptional()
@IsBoolean()
downloadEnabled?: boolean;
@IsOptional()
@IsBoolean()
originalDownloadEnabled?: boolean;
}
+79
View File
@@ -0,0 +1,79 @@
// services/api/src/shared/cursor.ts —— 键集(keyset)游标分页共用工具(Task 15)。
// 背景:契约列表端点统一 (sortCol, id) 双列稳定排序 + CursorPageMeta(nextCursor/
// hasMore 成对不变式)。offset 分页在并发写入下会重漏行,这里按「排序列相等时
// 以 id 决胜」的键集谓词推进:
// WHERE sort < :cursorSort OR (sort = :cursorSort AND id < :cursorId)
// 配套 ORDER BY sort DESC, id DESC(升序场景 P1a 暂无,出现时再对称扩展)。
// 游标载体 = JSON [sortISO, id] 的 base64url(URL 查询参数安全;契约 cursor
// maxLength 512)。Task 23 照片列表(publishedAt,id)复用本模块,勿内联重写。
/** 契约 CursorQuery.maxLength。 */
export const MAX_CURSOR_LENGTH = 512;
/** 已解码的键集游标:排序列取值(ISO 8601 字符串)+ 同值决胜 id。 */
export interface KeysetCursor {
readonly sort: string;
readonly id: string;
}
/** 编码为可回传客户端的 opaque 游标字符串。 */
export function encodeKeysetCursor(cursor: KeysetCursor): string {
return Buffer.from(JSON.stringify([cursor.sort, cursor.id]), 'utf8').toString(
'base64url',
);
}
/**
* 解码游标;任何不合规形状(非 base64url JSON、非 [string,string] 二元组、
* sort 非可解析时间、超长)一律返回 null——调用方据此决定 400(显式给了
* cursor 却解析失败是客户端错误),本模块不抛异常、保持纯函数。
*/
export function decodeKeysetCursor(raw: string): KeysetCursor | null {
if (raw === '' || raw.length > MAX_CURSOR_LENGTH) {
return null;
}
let parsed: unknown;
try {
parsed = JSON.parse(Buffer.from(raw, 'base64url').toString('utf8'));
} catch {
return null;
}
if (!Array.isArray(parsed) || parsed.length !== 2) {
return null;
}
const [sort, id] = parsed as [unknown, unknown];
if (typeof sort !== 'string' || typeof id !== 'string' || id === '') {
return null;
}
if (Number.isNaN(Date.parse(sort))) {
return null;
}
return { sort, id };
}
/**
* 降序键集谓词(Prisma where 片段),与 ORDER BY sortKey DESC, idKey DESC 配套:
* 排序列严格小于游标值,或相等且 id 小于游标 id(同一排序列值内的决胜)。
* 泛型键名约束到具体模型的列,展开进 where 时由 Prisma 输入类型检查兜底。
*/
export function keysetWhereDesc<TSortKey extends PropertyKey, TIdKey extends PropertyKey>(
sortKey: TSortKey,
idKey: TIdKey,
cursor: KeysetCursor,
): {
OR: [
{ [K in TSortKey]: { lt: Date } },
{ [K in TSortKey]: Date } & { [K in TIdKey]: { lt: string } },
];
} {
const sortValue = new Date(cursor.sort);
return {
OR: [
{ [sortKey]: { lt: sortValue } } as { [K in TSortKey]: { lt: Date } },
{
[sortKey]: sortValue,
[idKey]: { lt: cursor.id },
} as { [K in TSortKey]: Date } & { [K in TIdKey]: { lt: string } },
],
};
}
+429
View File
@@ -0,0 +1,429 @@
// services/api/test/albums.e2e-spec.ts —— Task 15 相册创建/详情/列表 e2e(先写后实现)。
// 真实 app + MySQL(reset.ts 隔离)。认证不走登录接口:直接经 Prisma 种 sessions
// 行(tokenHash=sha256(已知明文))拿 Bearer 令牌——比走 /auth/password 少一层
//无关耦合(登录语义已有 auth.e2e-spec 覆盖),albums 断言聚焦相册域本身。
// 覆盖:
// 创建:201 契约 Album 形状(publicToken ^[0-9a-z]{20,32}$、四开关默认值、
// watermarkVersion=0、translations 回读、categories=[])+ DB 行/翻译/
// owner 成员行(album_members role=owner status=active)+ audit album.create;
// 校验:translations 条目缺 title / defaultLocale 无对应条目 / endAt<startAt /
// 未认证 → 400/400/400/401;
// 详情:owner 200 全量;非成员 staff 404 ALBUM_NOT_FOUND(防枚举);admin 200;
// 不存在的 id 404;
// 列表:仅本成员相册(他人相册不可见);admin 全量;(createdAt,id) 游标分页
// created-desc、无重复、hasMore/nextCursor 成对;limit 非法/游标非法 400。
// 分页确定性:三个相册创建后经 Prisma 显式回写互不相同的 createdAt(排序键
// 与真实写入时序解耦),游标语义按回写时间断言。
import { randomBytes } from 'node:crypto';
import { type INestApplication } from '@nestjs/common';
import { Test } from '@nestjs/testing';
import request from 'supertest';
import { PrismaClient } from '@photo/db-cn';
import { ErrorCodes } from '@photo/contracts';
import { AppCnModule } from '../src/app-cn.module';
import { hashSessionToken } from '../src/auth/session.guard';
import { API_PREFIX } from '../src/shared/constants';
import { resetDb } from './reset';
/** 契约 AlbumSummary 的必返字段全集(列表条目形状精确断言用)。 */
const ALBUM_SUMMARY_KEYS = [
'createdAt',
'defaultLocale',
'downloadEnabled',
'endAt',
'id',
'originalDownloadEnabled',
'ownerId',
'publicEnabled',
'publicToken',
'startAt',
'status',
'timezone',
'translations',
'updatedAt',
'uploadEnabled',
'watermarkVersion',
].sort();
const PAGINATION_BASE = '2026-09-10T';
describe('albums (e2e)', () => {
let app: INestApplication;
let prisma: PrismaClient;
let ownerId: string;
let adminId: string;
let outsiderId: string;
let pagerId: string;
let ownerToken: string;
let adminToken: string;
let outsiderToken: string;
let pagerToken: string;
/** 首个用例创建的相册(后续详情/审计用例复用)。 */
let primaryAlbumId: string;
const validCreateBody = {
translations: [
{
locale: 'zh-CN',
title: '2026 公司年会',
description: '年度盛会',
venue: '大宴会厅',
address: '上海市浦东新区世纪大道 100 号',
},
{ locale: 'en', title: 'Annual Party 2026' },
],
defaultLocale: 'zh-CN',
startAt: '2026-10-01T02:00:00.000Z',
};
beforeAll(async () => {
await resetDb();
const moduleRef = await Test.createTestingModule({
imports: [AppCnModule],
}).compile();
app = moduleRef.createNestApplication();
app.setGlobalPrefix(API_PREFIX);
await app.init();
prisma = new PrismaClient();
const mkUser = (account: string, role = 'staff') =>
prisma.users.create({
data: { account, role, passwordHash: 'x', displayName: account },
select: { id: true },
});
ownerId = (await mkUser('album-owner')).id;
adminId = (await mkUser('album-admin', 'admin')).id;
outsiderId = (await mkUser('album-outsider')).id;
pagerId = (await mkUser('album-pager')).id;
// 直种会话行换 Bearer 令牌:守卫只认 tokenHash,无需走登录接口。
const mkToken = async (userId: string): Promise<string> => {
const token = randomBytes(32).toString('hex');
await prisma.sessions.create({
data: {
userId,
tokenHash: hashSessionToken(token),
expiresAt: new Date(Date.now() + 3_600_000),
},
});
return token;
};
ownerToken = await mkToken(ownerId);
adminToken = await mkToken(adminId);
outsiderToken = await mkToken(outsiderId);
pagerToken = await mkToken(pagerId);
});
afterAll(async () => {
await app.close();
await prisma.$disconnect();
});
function postAlbum(token: string, body: object) {
return request(app.getHttpServer())
.post(`/${API_PREFIX}/albums`)
.set('Authorization', `Bearer ${token}`)
.send(body);
}
function getAlbum(token: string, albumId: string) {
return request(app.getHttpServer())
.get(`/${API_PREFIX}/albums/${albumId}`)
.set('Authorization', `Bearer ${token}`);
}
function listAlbums(token: string, query?: Record<string, string | number>) {
return request(app.getHttpServer())
.get(`/${API_PREFIX}/albums`)
.set('Authorization', `Bearer ${token}`)
.query(query ?? {});
}
it('创建相册(zh-CN+en 翻译)→ 201 契约 Album 形状;DB 落 albums+2 翻译+owner 成员行', async () => {
const res = await postAlbum(ownerToken, validCreateBody);
expect(res.status).toBe(201);
const body = res.body as Record<string, unknown>;
expect(typeof body.id).toBe('string');
expect(body.publicToken).toMatch(/^[0-9a-z]{20,32}$/);
expect(body.ownerId).toBe(ownerId);
expect(body.status).toBe('active');
expect(body.defaultLocale).toBe('zh-CN');
expect(body.timezone).toBe('Asia/Shanghai'); // 契约缺省
expect(body.startAt).toBe('2026-10-01T02:00:00.000Z');
expect(body.endAt).toBeNull();
expect(body.uploadEnabled).toBe(true);
expect(body.publicEnabled).toBe(true);
expect(body.downloadEnabled).toBe(true);
expect(body.originalDownloadEnabled).toBe(false);
expect(body.watermarkVersion).toBe(0);
expect(body.categories).toEqual([]);
expect(Number.isNaN(Date.parse(String(body.createdAt)))).toBe(false);
expect(Number.isNaN(Date.parse(String(body.updatedAt)))).toBe(false);
primaryAlbumId = body.id as string;
// 翻译回读:未填可选字段为 null(契约「未填的可选字段存储为 null」)。
expect(body.translations).toHaveLength(2);
const translations = body.translations as Array<Record<string, unknown>>;
const zh = translations.find((t) => t.locale === 'zh-CN');
const en = translations.find((t) => t.locale === 'en');
expect(zh).toMatchObject(validCreateBody.translations[0]);
expect(en).toMatchObject({
title: 'Annual Party 2026',
description: null,
venue: null,
address: null,
});
// DB:albums 行默认值全部落库。
const row = await prisma.albums.findUnique({
where: { id: primaryAlbumId },
});
expect(row).not.toBeNull();
expect(row).toMatchObject({
publicToken: body.publicToken,
ownerId: ownerId,
status: 'active',
defaultLocale: 'zh-CN',
timezone: 'Asia/Shanghai',
uploadEnabled: true,
publicEnabled: true,
downloadEnabled: true,
originalDownloadEnabled: false,
watermarkVersion: 0,
});
expect(row?.startAt.getTime()).toBe(Date.parse(validCreateBody.startAt));
expect(row?.endAt).toBeNull();
// DB:两条翻译行。
const translationRows = await prisma.album_translations.findMany({
where: { albumId: primaryAlbumId },
});
expect(translationRows.map((t) => t.locale).sort()).toEqual([
'en',
'zh-CN',
]);
// DB:创建者自动成为 owner 成员(权限全部源自成员行——Task 14 结论)。
const member = await prisma.album_members.findUnique({
where: {
albumId_userId: { albumId: primaryAlbumId, userId: ownerId },
},
});
expect(member).toMatchObject({ role: 'owner', status: 'active' });
// DB:审计。
const audit = await prisma.audit_logs.findFirst({
where: {
action: 'album.create',
actorId: ownerId,
resourceId: primaryAlbumId,
},
});
expect(audit).not.toBeNull();
});
it('translations 条目缺 title → 400 VALIDATION_FAILED', async () => {
const res = await postAlbum(ownerToken, {
...validCreateBody,
translations: [
{ locale: 'zh-CN' },
{ locale: 'en', title: 'No Title Entry' },
],
});
expect(res.status).toBe(400);
expect(res.body.code).toBe(ErrorCodes.VALIDATION_FAILED);
expect(typeof res.body.requestId).toBe('string');
});
it('defaultLocale 无对应翻译条目 → 400 VALIDATION_FAILED', async () => {
const res = await postAlbum(ownerToken, {
...validCreateBody,
defaultLocale: 'en',
translations: [{ locale: 'zh-CN', title: '只有中文' }],
});
expect(res.status).toBe(400);
expect(res.body.code).toBe(ErrorCodes.VALIDATION_FAILED);
});
it('endAt 早于 startAt → 400 VALIDATION_FAILED', async () => {
const res = await postAlbum(ownerToken, {
...validCreateBody,
endAt: '2026-09-30T00:00:00.000Z',
});
expect(res.status).toBe(400);
expect(res.body.code).toBe(ErrorCodes.VALIDATION_FAILED);
});
it('未认证创建/查询 → 401 AUTH_TOKEN_EXPIRED', async () => {
const post = await request(app.getHttpServer())
.post(`/${API_PREFIX}/albums`)
.send(validCreateBody);
expect(post.status).toBe(401);
expect(post.body.code).toBe(ErrorCodes.AUTH_TOKEN_EXPIRED);
const list = await request(app.getHttpServer()).get(
`/${API_PREFIX}/albums`,
);
expect(list.status).toBe(401);
const detail = await request(app.getHttpServer()).get(
`/${API_PREFIX}/albums/${primaryAlbumId}`,
);
expect(detail.status).toBe(401);
expect(detail.body.code).toBe(ErrorCodes.AUTH_TOKEN_EXPIRED);
});
it('相册详情:owner 200 全量;非成员 staff 404 ALBUM_NOT_FOUND(防枚举);admin 200', async () => {
const ownerRes = await getAlbum(ownerToken, primaryAlbumId);
expect(ownerRes.status).toBe(200);
expect(Object.keys(ownerRes.body).sort()).toEqual(
[...ALBUM_SUMMARY_KEYS, 'categories'].sort(),
);
expect(ownerRes.body.id).toBe(primaryAlbumId);
expect(ownerRes.body.translations).toHaveLength(2);
expect(ownerRes.body.categories).toEqual([]);
const outsiderRes = await getAlbum(outsiderToken, primaryAlbumId);
expect(outsiderRes.status).toBe(404);
expect(outsiderRes.body.code).toBe(ErrorCodes.ALBUM_NOT_FOUND);
// admin 无任何成员行仍可见(公司管理员全量)。
const adminRes = await getAlbum(adminToken, primaryAlbumId);
expect(adminRes.status).toBe(200);
expect(adminRes.body.id).toBe(primaryAlbumId);
// 不存在的相册 id:owner 视角同样 404(与不可见同码防枚举)。
const ghostRes = await getAlbum(ownerToken, 'no-such-album');
expect(ghostRes.status).toBe(404);
expect(ghostRes.body.code).toBe(ErrorCodes.ALBUM_NOT_FOUND);
});
it('列表:成员只见自己参与相册;外人员工空列表;admin 可见全部', async () => {
const ownerList = await listAlbums(ownerToken);
expect(ownerList.status).toBe(200);
expect(ownerList.body.items.map((a: { id: string }) => a.id)).toEqual([
primaryAlbumId,
]);
// 列表条目形状精确等于契约 AlbumSummary(无 categories)。
expect(Object.keys(ownerList.body.items[0]).sort()).toEqual(
ALBUM_SUMMARY_KEYS,
);
expect(ownerList.body.items[0].publicToken).toMatch(/^[0-9a-z]{20,32}$/);
const outsiderList = await listAlbums(outsiderToken);
expect(outsiderList.status).toBe(200);
expect(outsiderList.body.items).toEqual([]);
expect(outsiderList.body.nextCursor).toBeNull();
expect(outsiderList.body.hasMore).toBe(false);
const adminList = await listAlbums(adminToken, { limit: 100 });
expect(adminList.status).toBe(200);
const adminIds = adminList.body.items.map((a: { id: string }) => a.id);
expect(adminIds).toContain(primaryAlbumId);
});
describe('列表游标分页(createdAt DESC, id DESC 稳定序)', () => {
const CREATED_AT = [
`${PAGINATION_BASE}08:00:00.000Z`,
`${PAGINATION_BASE}10:00:00.000Z`,
`${PAGINATION_BASE}12:00:00.000Z`,
];
let pagerAlbumIds: string[];
beforeAll(async () => {
// pager 用户独占三个相册(endAt 显式给值顺带验证晚于 startAt 的落库回读)。
const created: string[] = [];
for (let i = 0; i < 3; i++) {
const res = await postAlbum(pagerToken, {
...validCreateBody,
startAt: '2026-10-01T02:00:00.000Z',
endAt: '2026-10-01T12:00:00.000Z',
});
expect(res.status).toBe(201);
created.push(res.body.id as string);
}
// 显式回写互不相同的 createdAt:排序断言与真实写入时序解耦(jest 串行下
// 三个请求的 DateTime(6) 可能同毫秒,靠 id 次序断言会依赖 cuid 生成序)。
for (let i = 0; i < 3; i++) {
await prisma.albums.update({
where: { id: created[i] },
data: { createdAt: new Date(CREATED_AT[i]) },
});
}
pagerAlbumIds = created;
});
it('limit=2 首页 → 新两条 + nextCursor/hasMore;次页 → 最旧一条,无重复;created 降序', async () => {
const page1 = await listAlbums(pagerToken, { limit: 2 });
expect(page1.status).toBe(200);
expect(page1.body.items).toHaveLength(2);
expect(page1.body.hasMore).toBe(true);
expect(typeof page1.body.nextCursor).toBe('string');
// created 降序:12:00 → 10:00。
expect(page1.body.items.map((a: { id: string }) => a.id)).toEqual([
pagerAlbumIds[2],
pagerAlbumIds[1],
]);
expect(page1.body.items[0].endAt).toBe('2026-10-01T12:00:00.000Z');
const page2 = await listAlbums(pagerToken, {
limit: 2,
cursor: page1.body.nextCursor,
});
expect(page2.status).toBe(200);
expect(page2.body.items.map((a: { id: string }) => a.id)).toEqual([
pagerAlbumIds[0],
]);
expect(page2.body.hasMore).toBe(false);
expect(page2.body.nextCursor).toBeNull();
// 两页并集恰为 pager 的三个相册(无重复、无丢失,也不含 owner 的相册)。
const union = [
...page1.body.items.map((a: { id: string }) => a.id),
...page2.body.items.map((a: { id: string }) => a.id),
];
expect(new Set(union).size).toBe(3);
expect(union.sort()).toEqual([...pagerAlbumIds].sort());
});
it('admin 列表包含多位用户的相册(全量视角)', async () => {
const res = await listAlbums(adminToken, { limit: 100 });
expect(res.status).toBe(200);
const ids = res.body.items.map((a: { id: string }) => a.id);
expect(ids).toContain(primaryAlbumId);
for (const id of pagerAlbumIds) {
expect(ids).toContain(id);
}
});
it('limit 越界(0/101/非整数)→ 400 VALIDATION_FAILED', async () => {
for (const limit of [0, 101, 'abc']) {
const res = await listAlbums(pagerToken, { limit });
expect(res.status).toBe(400);
expect(res.body.code).toBe(ErrorCodes.VALIDATION_FAILED);
}
});
it('非法 cursor → 400 VALIDATION_FAILED', async () => {
const res = await listAlbums(pagerToken, { cursor: 'not-a-cursor' });
expect(res.status).toBe(400);
expect(res.body.code).toBe(ErrorCodes.VALIDATION_FAILED);
});
});
it('audit_logs:album.create 以创建者为 actor、相册 id 为 resourceId', async () => {
const audit = await prisma.audit_logs.findFirst({
where: {
action: 'album.create',
actorId: ownerId,
resourceId: primaryAlbumId,
},
});
expect(audit).not.toBeNull();
});
});