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