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

@@ -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<CreateAssetVersionResponseDto> {
return this.assets.createAssetVersion(publicId, request)
}
@Get(":publicId/variants")
@ApiOperation({
summary: "получить variants asset",

View File

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

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
}