feat(api): album asset upload chain

P1b Task 3: initAlbumAssetUpload / completeAlbumAssetUpload (tags
[assets]) over album_assets with in-row one-shot sessions.

- migration asset_state: album_assets + state (pending|effective),
  expectedBytes, contentType (utf8mb4_0900_bin); pre-existing rows
  backfilled to effective. Complete head-verifies bytes AND
  contentType vs the init declaration (P1a Task 11 server-side
  fallback), then conditionally flips pending->effective with the
  winner writing the album.asset.add audit (resourceId=albumId,
  metadata assetId/kind/locale). Effective rows re-complete
  idempotently (re-signed URL, no re-verify, survives object
  cleanup).
- object key is the final key (albums/{albumId}/assets/{assetId}.{ext});
  the pending row is the logical staging - no copy step, keys embed
  assetId so re-init can never overwrite another row's object.
- permissions: owner/admin only (collaborator 403, outsider 404);
  uploadEnabled does NOT gate assets (switch governs photo uploads
  per 4.9 - both endpoints carry no ALBUM_UPLOAD_DISABLED).
- public banner now locale-aware (req 4.7): effective rows resolved
  as display-locale row -> global (locale NULL) row -> null, latest
  wins per tier (same-kind re-upload replaces, old rows retained).
- e2e: test/assets.e2e-spec.ts (14 cases, real jpegs via sharp,
  MinIO round-trips); public spec seed marked effective.

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-09-18 08:40:32 +08:00
parent c2a9cc51f3
commit 361a72f300
10 changed files with 974 additions and 24 deletions
+17 -8
View File
@@ -9,8 +9,10 @@
// 决策),与缺省同样走回退;resolvedLocale 向客户端指示实际返回语言。
// · 分类名称同语言解析:resolved locale → defaultLocale → 任一既有条目
// (确定性:locale 升序取首);分类创建必附翻译,末级分支纯防御。
// · banner:album_assets(kind=banner) 多行时取最新一条(createdAt DESC,
// id DESC 决胜);P1a 无按语言选封面概念(locale 列为 P1b 上传链路预留)。
// · banner(P1b Task 3 起对接上传链路):album_assets(kind=banner,
// state='effective') 中按语言偏好解析(需求 4.7)——resolved locale 行 →
// 全局行(locale NULL)→ null;同级取最新(createdAt,id DESC 决胜,同
// (kind, locale) 再上传即替换语义)。
// · 签名 URL 一律现签(SIGNED_URL_TTL_SECONDS 惰性读取,SigV4 本地计算无
// 网络副作用);单图定位端点是契约规定的过期刷新入口。
// · viewCount:P1a 占位 null(§7.1 /visits 端点后续提供)。
@@ -100,15 +102,22 @@ export class PublicService {
bucket.set(row.locale, row.name);
}
// banner:最新 banner 资产行现签(无行 → null)。
const bannerAsset = await this.prisma.album_assets.findFirst({
where: { albumId: album.id, kind: 'banner' },
// banner(P1b Task 3 起对接上传链路):仅 state='effective' 行参与;语言
// 偏好解析(需求 4.7)——resolved locale 行 → 全局行(locale NULL)→ null。
// 以「实际展示语言」(translation 解析结果)为偏好驱动:banner 跟随观众
// 看到的文案语言,而非原始请求值(请求 fr 回退 zh 时配 zh banner)。
// 同级多行取最新(createdAt,id DESC 决胜):同 (kind, locale) 再上传即替换
// 语义——新行生效、旧行保留,此处取最新 effective 行。
const bannerAssets = await this.prisma.album_assets.findMany({
where: { albumId: album.id, kind: 'banner', state: 'effective' },
orderBy: [{ createdAt: 'desc' }, { id: 'desc' }],
});
const bannerRow =
bannerAssets.find((row) => row.locale === resolved.locale) ??
bannerAssets.find((row) => row.locale === null) ??
null;
const banner =
bannerAsset === null
? null
: await this.presign(bannerAsset.objectKey);
bannerRow === null ? null : await this.presign(bannerRow.objectKey);
return {
publicToken: album.publicToken,