361a72f300
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>
52 lines
1.7 KiB
TypeScript
52 lines
1.7 KiB
TypeScript
// 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);
|
||
}
|
||
}
|