diff --git a/README.md b/README.md index d6f1393..7416c86 100644 --- a/README.md +++ b/README.md @@ -74,6 +74,9 @@ Business API без админки: curl -sS http://localhost:3001/api/presets curl -sS http://localhost:3001/api/assets curl -sS http://localhost:3001/api/assets/asset_demo/variants +curl -sS -X POST http://localhost:3001/api/assets/asset_demo/versions \ + -H 'content-type: application/json' \ + -d '{"sourceUrl":"https://storage.yandexcloud.net/shared1318/img/1.jpg"}' curl -sS -X POST http://localhost:3001/api/assets/asset_demo/variants \ -H 'content-type: application/json' \ -d '{"preset":"card","mode":"family"}' diff --git a/apps/backend/src/assets/assets.controller.ts b/apps/backend/src/assets/assets.controller.ts index a192c4a..7d900c8 100644 --- a/apps/backend/src/assets/assets.controller.ts +++ b/apps/backend/src/assets/assets.controller.ts @@ -13,6 +13,7 @@ import { import { AssetsService } from "./assets.service" import { AssetResponseDto, AssetVariantsResponseDto, AssetsListResponseDto } from "./asset-response.dto" +import { CreateAssetVersionRequestDto, CreateAssetVersionResponseDto } from "./create-asset-version.dto" import { CreateAssetVariantsRequestDto, CreateAssetVariantsResponseDto } from "./create-asset-variants.dto" import { CreateAssetRequestDto, CreateAssetResponseDto } from "./create-asset.dto" @@ -58,6 +59,24 @@ export class AssetsController { return this.assets.getAsset(publicId) } + @Post(":publicId/versions") + @ApiOperation({ + summary: "создать новую версию source image", + description: + "Регистрирует новый source URL для существующего asset, увеличивает currentVersion и тем самым создаёт новый immutable Gateway URL `/v{version}` без purge старых URLs.", + }) + @ApiParam({ description: "Публичный идентификатор asset.", example: "asset_demo", name: "publicId" }) + @ApiCreatedResponse({ description: "Новая версия source image создана и стала текущей.", type: CreateAssetVersionResponseDto }) + @ApiBadRequestResponse({ description: "Некорректный sourceUrl или source host запрещён настройками." }) + @ApiConflictResponse({ description: "Версия asset изменилась конкурентно." }) + @ApiNotFoundResponse({ description: "Asset не найден." }) + createAssetVersion( + @Param("publicId") publicId: string, + @Body() request: CreateAssetVersionRequestDto, + ): Promise { + return this.assets.createAssetVersion(publicId, request) + } + @Get(":publicId/variants") @ApiOperation({ summary: "получить variants asset", diff --git a/apps/backend/src/assets/assets.service.ts b/apps/backend/src/assets/assets.service.ts index 79bc426..05cd717 100644 --- a/apps/backend/src/assets/assets.service.ts +++ b/apps/backend/src/assets/assets.service.ts @@ -18,6 +18,7 @@ import { createHash, randomUUID } from "node:crypto" import { DatabaseService } from "../infra/database.service" import { QueueService } from "../infra/queue.service" import type { AssetResponseDto, AssetVariantResponseDto, AssetVariantsResponseDto, AssetsListResponseDto } from "./asset-response.dto" +import type { CreateAssetVersionRequestDto, CreateAssetVersionResponseDto } from "./create-asset-version.dto" import type { CreateAssetVariantsRequestDto, CreateAssetVariantsResponseDto } from "./create-asset-variants.dto" import type { CreateAssetRequestDto, CreateAssetResponseDto } from "./create-asset.dto" import { normalizeSourceUrl } from "./source-url" @@ -83,7 +84,7 @@ export class AssetsService { await this.assertAllowedHost(source.hostname) const publicId = request.publicId ? normalizePublicId(request.publicId) : generatePublicId() - const sourceHash = createHash("sha256").update(source.sourceUrl).digest("hex") + const sourceHash = createSourceHash(source.sourceUrl) try { const result = await this.database.db.transaction(async (tx) => { @@ -136,6 +137,87 @@ export class AssetsService { return mapAssetResponse(asset) } + async createAssetVersion( + publicId: string, + request: CreateAssetVersionRequestDto, + ): Promise { + const normalizedPublicId = normalizePublicId(publicId) + const source = normalizeSourceUrl(request.sourceUrl) + await this.assertAllowedHost(source.hostname) + + try { + const result = await this.database.db.transaction(async (tx) => { + const [asset] = await tx + .select({ + currentVersion: imageAssets.currentVersion, + id: imageAssets.id, + publicId: imageAssets.publicId, + status: imageAssets.status, + }) + .from(imageAssets) + .where(eq(imageAssets.publicId, normalizedPublicId)) + .limit(1) + + if (!asset || asset.status !== "active") { + throw new NotFoundException("asset not found") + } + + const previousVersion = asset.currentVersion + const nextVersion = previousVersion + 1 + const [version] = await tx + .insert(imageAssetVersions) + .values({ + assetId: asset.id, + sourceHash: createSourceHash(source.sourceUrl), + sourceHost: source.hostname, + sourceUrl: source.sourceUrl, + version: nextVersion, + }) + .returning({ + createdAt: imageAssetVersions.createdAt, + id: imageAssetVersions.id, + sourceHost: imageAssetVersions.sourceHost, + sourceUrl: imageAssetVersions.sourceUrl, + version: imageAssetVersions.version, + }) + + if (!version) { + throw new Error("failed to create image asset version") + } + + const [updatedAsset] = await tx + .update(imageAssets) + .set({ currentVersion: nextVersion, updatedAt: new Date() }) + .where(and(eq(imageAssets.id, asset.id), eq(imageAssets.currentVersion, previousVersion))) + .returning({ currentVersion: imageAssets.currentVersion }) + + if (!updatedAsset) { + throw new ConflictException("asset version changed concurrently") + } + + return { asset, previousVersion, version } + }) + + return { + assetId: result.asset.id, + createdAt: result.version.createdAt.toISOString(), + imageBasePath: `/images/${result.asset.publicId}/v${result.version.version}/card`, + previousVersion: result.previousVersion, + publicId: result.asset.publicId, + sourceHost: result.version.sourceHost, + sourceUrl: result.version.sourceUrl, + version: result.version.version, + versionId: result.version.id, + } + } catch (error) { + if (isUniqueViolation(error)) { + throw new ConflictException("asset version already exists") + } + + throw error + } + } + async listAssetVariants(publicId: string, versionInput?: string): Promise { const asset = await this.loadAssetVersion(publicId, parseOptionalVersion(versionInput)) const conditions = [eq(imageVariants.assetId, asset.assetId)] @@ -520,6 +602,10 @@ function isUniqueViolation(error: unknown) { return typeof error === "object" && error !== null && "code" in error && error.code === "23505" } +function createSourceHash(sourceUrl: string) { + return createHash("sha256").update(sourceUrl).digest("hex") +} + function parsePaginationInteger(value: string | undefined, fallback: number, min: number, max: number) { if (value === undefined) { return fallback diff --git a/apps/backend/src/assets/create-asset-version.dto.ts b/apps/backend/src/assets/create-asset-version.dto.ts new file mode 100644 index 0000000..9e95aad --- /dev/null +++ b/apps/backend/src/assets/create-asset-version.dto.ts @@ -0,0 +1,38 @@ +import { ApiProperty } from "@nestjs/swagger" + +export class CreateAssetVersionRequestDto { + @ApiProperty({ + description: "Постоянная ссылка на новую версию исходного изображения.", + example: "https://storage.yandexcloud.net/shared1318/img/1.jpg", + }) + sourceUrl!: string +} + +export class CreateAssetVersionResponseDto { + @ApiProperty({ description: "Внутренний UUID asset.", example: "59fcf4f6-9891-4df4-8bb7-a4dbe570bb66" }) + assetId!: string + + @ApiProperty({ description: "Внутренний UUID новой версии source image.", example: "3b5da974-bb7f-4d73-b172-d6ad9c244528" }) + versionId!: string + + @ApiProperty({ description: "Публичный идентификатор asset.", example: "asset_demo" }) + publicId!: string + + @ApiProperty({ description: "Предыдущая активная версия source image.", example: 1 }) + previousVersion!: number + + @ApiProperty({ description: "Новая активная версия source image.", example: 2 }) + version!: number + + @ApiProperty({ description: "Нормализованный source URL новой версии.", example: "https://storage.yandexcloud.net/shared1318/img/1.jpg" }) + sourceUrl!: string + + @ApiProperty({ description: "Hostname source URL новой версии.", example: "storage.yandexcloud.net" }) + sourceHost!: string + + @ApiProperty({ description: "Базовый Gateway path для новой версии.", example: "/images/asset_demo/v2/card" }) + imageBasePath!: string + + @ApiProperty({ description: "Дата создания версии.", example: "2026-05-05T12:00:00.000Z" }) + createdAt!: string +} diff --git a/docs/backend-contract-draft.md b/docs/backend-contract-draft.md index d6d0447..d30caac 100644 --- a/docs/backend-contract-draft.md +++ b/docs/backend-contract-draft.md @@ -110,12 +110,13 @@ DELETE /allowed-hosts/:id POST /assets GET /assets GET /assets/:publicId +POST /assets/:publicId/versions GET /assets/:publicId/variants POST /assets/:publicId/variants DELETE /assets/:id ``` -Сейчас реализованы `POST /assets`, `GET /assets`, `GET /assets/:publicId`, `GET /assets/:publicId/variants`, `POST /assets/:publicId/variants`. +Сейчас реализованы `POST /assets`, `GET /assets`, `GET /assets/:publicId`, `POST /assets/:publicId/versions`, `GET /assets/:publicId/variants`, `POST /assets/:publicId/variants`. `POST /assets` request: @@ -167,6 +168,32 @@ Responsibilities: `GET /assets/:publicId/variants` возвращает rows из `image_variants` с public Gateway URL, S3 key и status. +`POST /assets/:publicId/versions` request: + +```json +{ + "sourceUrl": "https://storage.yandexcloud.net/shared1318/img/1.jpg" +} +``` + +Response: + +```json +{ + "assetId": "59fcf4f6-9891-4df4-8bb7-a4dbe570bb66", + "versionId": "3b5da974-bb7f-4d73-b172-d6ad9c244528", + "publicId": "asset_demo", + "previousVersion": 1, + "version": 2, + "sourceUrl": "https://storage.yandexcloud.net/shared1318/img/1.jpg", + "sourceHost": "storage.yandexcloud.net", + "imageBasePath": "/images/asset_demo/v2/card", + "createdAt": "2026-05-05T12:00:00.000Z" +} +``` + +Новая версия становится `currentVersion` asset. Старые Gateway URLs с `/v1/` остаются immutable и не требуют purge. + ## Variants ```text diff --git a/docs/development.md b/docs/development.md index 4bde4af..da48676 100644 --- a/docs/development.md +++ b/docs/development.md @@ -93,6 +93,14 @@ curl -sS http://localhost:3001/api/assets/asset_demo curl -sS http://localhost:3001/api/assets/asset_demo/variants ``` +Создать новую source version для asset: + +```bash +curl -sS -X POST http://localhost:3001/api/assets/asset_demo/versions \ + -H 'content-type: application/json' \ + -d '{"sourceUrl":"https://storage.yandexcloud.net/shared1318/img/1.jpg"}' +``` + Явно поставить jobs на генерацию family variants без Gateway lazy request: ```bash