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
+41 -2
View File
@@ -1,7 +1,8 @@
// services/api/src/albums/dto.ts —— 相册端点入参 DTO(Task 15;Task 16 增补更新版;
// Task 17 增补成员/分类)。校验约束对齐契约
// Task 17 增补成员/分类;P1b Task 3 增补资产)。校验约束对齐契约
// AlbumCreateRequest / AlbumUpdateRequest / MemberAddRequest /
// CategoryCreateRequest / CategoryUpdateRequest / AlbumTranslation:
// CategoryCreateRequest / CategoryUpdateRequest / AlbumTranslation /
// AssetInitRequest:
// · translations 至少 1 条(minItems: 1),locale 限 P1a 双语枚举;
// · defaultLocale 必须有对应翻译条目(跨字段校验,契约 required 语义);
// · endAt 可空(契约 [string,'null']),给值时须晚于 startAt(跨字段校验);
@@ -29,6 +30,10 @@ import {
type ValidatorConstraintInterface,
type ValidationArguments,
} from 'class-validator';
import {
UPLOAD_CONTENT_TYPES,
type UploadContentType,
} from '../uploads/support';
/** 契约 Locale 枚举(P1a 双语)。 */
export const ALBUM_LOCALES = ['zh-CN', 'en'] as const;
@@ -294,3 +299,37 @@ export class CategoryUpdateRequestDto {
@Type(() => CategoryTranslationDto)
translations?: CategoryTranslationDto[];
}
// ── 资产(P1b Task 3)──
/** 契约 AssetKind:banner(观众页宣传图,PublicAlbum.banner 消费方);cover/logo 建链就绪,展示消费随后续阶段。 */
export const ASSET_KINDS = ['banner', 'cover', 'logo'] as const;
export type AssetKind = (typeof ASSET_KINDS)[number];
/**
* 契约 AssetInitRequest。大小限额 UPLOAD_MAX_BYTES 与 filename 扩展名白名单是
* 环境相关/派生校验,在服务层做(同 UploadInitRequestDto 约定);contentType
* 与照片直传共用白名单(image/heic 暂定,G2 真机验证)。
*/
export class AssetInitRequestDto {
@IsIn(ASSET_KINDS)
kind!: AssetKind;
/** 可选语言维度:同 (kind, locale) 再上传即替换(新行生效);语言无关资产(如 logo)不传。 */
@IsOptional()
@IsIn(ALBUM_LOCALES)
locale?: AlbumLocale;
/** 原始文件名,仅元数据;扩展名白名单校验在服务层(对象键后缀派生)。 */
@IsString()
@IsNotEmpty()
@MaxLength(255)
filename!: string;
@IsInt()
@Min(1)
bytes!: number;
@IsIn(UPLOAD_CONTENT_TYPES)
contentType!: UploadContentType;
}