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