diff --git a/services/api/src/albums/albums.controller.ts b/services/api/src/albums/albums.controller.ts new file mode 100644 index 0000000..7a3f5fe --- /dev/null +++ b/services/api/src/albums/albums.controller.ts @@ -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 { + return this.albumsService.create(userOf(req).id, dto); + } + + @Get() + listAlbums( + @Query('cursor') cursor: string | undefined, + @Query('limit') limit: string | undefined, + @Req() req: AuthenticatedRequest, + ): Promise { + return this.albumsService.list( + userOf(req), + parseCursor(cursor), + parseLimit(limit), + ); + } + + @Get(':albumId') + getAlbum( + @Param('albumId') albumId: string, + @Req() req: AuthenticatedRequest, + ): Promise { + return this.albumsService.get(userOf(req), albumId); + } +} diff --git a/services/api/src/albums/albums.module.ts b/services/api/src/albums/albums.module.ts index 3058fd9..d8378df 100644 --- a/services/api/src/albums/albums.module.ts +++ b/services/api/src/albums/albums.module.ts @@ -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 {} diff --git a/services/api/src/albums/albums.service.ts b/services/api/src/albums/albums.service.ts new file mode 100644 index 0000000..a4729a8 --- /dev/null +++ b/services/api/src/albums/albums.service.ts @@ -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; +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 { + // 重复 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 { + 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 { + let decoded: ReturnType = 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(); + 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 { + 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) ?? [], + })), + }; + } +} diff --git a/services/api/src/albums/dto.ts b/services/api/src/albums/dto.ts new file mode 100644 index 0000000..0569f1e --- /dev/null +++ b/services/api/src/albums/dto.ts @@ -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; +} diff --git a/services/api/src/shared/cursor.ts b/services/api/src/shared/cursor.ts new file mode 100644 index 0000000..9ea3d16 --- /dev/null +++ b/services/api/src/shared/cursor.ts @@ -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( + 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 } }, + ], + }; +} diff --git a/services/api/test/albums.e2e-spec.ts b/services/api/test/albums.e2e-spec.ts new file mode 100644 index 0000000..3eb581e --- /dev/null +++ b/services/api/test/albums.e2e-spec.ts @@ -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 { + 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 => { + 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) { + 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; + 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>; + 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(); + }); +});