feat: добавить версионирование source image

- добавлен endpoint создания новой версии asset
- реализовано переключение currentVersion через image_asset_versions
- обновлена документация business API и dev smoke flow
This commit is contained in:
2026-05-05 13:31:45 +03:00
parent 1c0e8277a3
commit 3ec1e51bea
6 changed files with 183 additions and 2 deletions

View File

@@ -74,6 +74,9 @@ Business API без админки:
curl -sS http://localhost:3001/api/presets curl -sS http://localhost:3001/api/presets
curl -sS http://localhost:3001/api/assets curl -sS http://localhost:3001/api/assets
curl -sS http://localhost:3001/api/assets/asset_demo/variants 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 \ curl -sS -X POST http://localhost:3001/api/assets/asset_demo/variants \
-H 'content-type: application/json' \ -H 'content-type: application/json' \
-d '{"preset":"card","mode":"family"}' -d '{"preset":"card","mode":"family"}'

View File

@@ -13,6 +13,7 @@ import {
import { AssetsService } from "./assets.service" import { AssetsService } from "./assets.service"
import { AssetResponseDto, AssetVariantsResponseDto, AssetsListResponseDto } from "./asset-response.dto" 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 { CreateAssetVariantsRequestDto, CreateAssetVariantsResponseDto } from "./create-asset-variants.dto"
import { CreateAssetRequestDto, CreateAssetResponseDto } from "./create-asset.dto" import { CreateAssetRequestDto, CreateAssetResponseDto } from "./create-asset.dto"
@@ -58,6 +59,24 @@ export class AssetsController {
return this.assets.getAsset(publicId) 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<CreateAssetVersionResponseDto> {
return this.assets.createAssetVersion(publicId, request)
}
@Get(":publicId/variants") @Get(":publicId/variants")
@ApiOperation({ @ApiOperation({
summary: "получить variants asset", summary: "получить variants asset",

View File

@@ -18,6 +18,7 @@ import { createHash, randomUUID } from "node:crypto"
import { DatabaseService } from "../infra/database.service" import { DatabaseService } from "../infra/database.service"
import { QueueService } from "../infra/queue.service" import { QueueService } from "../infra/queue.service"
import type { AssetResponseDto, AssetVariantResponseDto, AssetVariantsResponseDto, AssetsListResponseDto } from "./asset-response.dto" 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 { CreateAssetVariantsRequestDto, CreateAssetVariantsResponseDto } from "./create-asset-variants.dto"
import type { CreateAssetRequestDto, CreateAssetResponseDto } from "./create-asset.dto" import type { CreateAssetRequestDto, CreateAssetResponseDto } from "./create-asset.dto"
import { normalizeSourceUrl } from "./source-url" import { normalizeSourceUrl } from "./source-url"
@@ -83,7 +84,7 @@ export class AssetsService {
await this.assertAllowedHost(source.hostname) await this.assertAllowedHost(source.hostname)
const publicId = request.publicId ? normalizePublicId(request.publicId) : generatePublicId() const publicId = request.publicId ? normalizePublicId(request.publicId) : generatePublicId()
const sourceHash = createHash("sha256").update(source.sourceUrl).digest("hex") const sourceHash = createSourceHash(source.sourceUrl)
try { try {
const result = await this.database.db.transaction(async (tx) => { const result = await this.database.db.transaction(async (tx) => {
@@ -136,6 +137,87 @@ export class AssetsService {
return mapAssetResponse(asset) return mapAssetResponse(asset)
} }
async createAssetVersion(
publicId: string,
request: CreateAssetVersionRequestDto,
): Promise<CreateAssetVersionResponseDto> {
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<AssetVariantsResponseDto> { async listAssetVariants(publicId: string, versionInput?: string): Promise<AssetVariantsResponseDto> {
const asset = await this.loadAssetVersion(publicId, parseOptionalVersion(versionInput)) const asset = await this.loadAssetVersion(publicId, parseOptionalVersion(versionInput))
const conditions = [eq(imageVariants.assetId, asset.assetId)] 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" 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) { function parsePaginationInteger(value: string | undefined, fallback: number, min: number, max: number) {
if (value === undefined) { if (value === undefined) {
return fallback return fallback

View File

@@ -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
}

View File

@@ -110,12 +110,13 @@ DELETE /allowed-hosts/:id
POST /assets POST /assets
GET /assets GET /assets
GET /assets/:publicId GET /assets/:publicId
POST /assets/:publicId/versions
GET /assets/:publicId/variants GET /assets/:publicId/variants
POST /assets/:publicId/variants POST /assets/:publicId/variants
DELETE /assets/:id 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: `POST /assets` request:
@@ -167,6 +168,32 @@ Responsibilities:
`GET /assets/:publicId/variants` возвращает rows из `image_variants` с public Gateway URL, S3 key и status. `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 ## Variants
```text ```text

View File

@@ -93,6 +93,14 @@ curl -sS http://localhost:3001/api/assets/asset_demo
curl -sS http://localhost:3001/api/assets/asset_demo/variants 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: Явно поставить jobs на генерацию family variants без Gateway lazy request:
```bash ```bash