feat: добавить версионирование source image
- добавлен endpoint создания новой версии asset - реализовано переключение currentVersion через image_asset_versions - обновлена документация business API и dev smoke flow
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
38
apps/backend/src/assets/create-asset-version.dto.ts
Normal file
38
apps/backend/src/assets/create-asset-version.dto.ts
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user