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,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