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
@@ -0,0 +1,51 @@
// services/api/src/albums/assets.controller.ts —— /albums/{albumId}/assets
// 端点(P1b Task 3)。方法名与契约 operationId 一一对应(initAlbumAssetUpload/
// completeAlbumAssetUpload)。控制器级 SessionGuard:两端点全需认证。控制器只做
// 认证用户与路径参数透传;权限与 HTTP 映射由 AssetsService 抛 ApiException
// (404 ALBUM_NOT_FOUND / 403 AUTH_FORBIDDEN / 400 VALIDATION_FAILED 或
// UPLOAD_VERIFY_FAILED)。
import {
Body,
Controller,
HttpCode,
HttpStatus,
Param,
Post,
Req,
UseGuards,
} from '@nestjs/common';
import { AuthenticatedRequest, SessionGuard } from '../auth/session.guard';
import { userOf } from './albums.controller';
import { AssetInitRequestDto } from './dto';
import {
AssetsService,
type AssetCompleteResponse,
type AssetInitResponse,
} from './assets.service';
@Controller('albums/:albumId/assets')
@UseGuards(SessionGuard)
export class AssetsController {
constructor(private readonly assetsService: AssetsService) {}
/** 契约 201:会话行已建(每次 init 新行新键,无幂等键)。 */
@Post()
initAlbumAssetUpload(
@Param('albumId') albumId: string,
@Body() dto: AssetInitRequestDto,
@Req() req: AuthenticatedRequest,
): Promise<AssetInitResponse> {
return this.assetsService.init(userOf(req), albumId, dto);
}
/** 完成核验:幂等(已生效行现签新 URL 返回,即签名刷新入口)。 */
@Post(':assetId/complete')
@HttpCode(HttpStatus.OK)
completeAlbumAssetUpload(
@Param('albumId') albumId: string,
@Param('assetId') assetId: string,
@Req() req: AuthenticatedRequest,
): Promise<AssetCompleteResponse> {
return this.assetsService.complete(userOf(req), albumId, assetId);
}
}