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