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 } },
],
};
}