This commit is contained in:
2026-05-12 07:54:32 +03:00
parent 0faa8b9d2d
commit d49449c30c
187 changed files with 4826 additions and 5884 deletions

View File

@@ -87,6 +87,52 @@ export class AssetVariantsResponseDto {
variants!: AssetVariantResponseDto[]
}
export class AssetVersionResponseDto {
@ApiProperty({ description: "Внутренний UUID версии source image.", example: "3b5da974-bb7f-4d73-b172-d6ad9c244528" })
id!: string
@ApiProperty({ description: "Номер версии source image.", example: 2 })
version!: number
@ApiProperty({ description: "Является ли версия текущей для asset.", example: true })
isCurrent!: boolean
@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
@ApiPropertyOptional({ description: "Ширина оригинального изображения, если уже определена Worker.", example: 1200, nullable: true, type: Number })
width!: number | null
@ApiPropertyOptional({ description: "Высота оригинального изображения, если уже определена Worker.", example: 800, nullable: true, type: Number })
height!: number | null
@ApiPropertyOptional({ description: "Content-Type оригинального изображения, если уже определён Worker.", example: "image/jpeg", nullable: true, type: String })
contentType!: string | null
@ApiPropertyOptional({ description: "Размер оригинального изображения в bytes, если уже определён Worker.", example: 245760, nullable: true, type: Number })
sizeBytes!: number | null
@ApiProperty({ description: "Дата создания версии.", example: "2026-05-05T12:00:00.000Z" })
createdAt!: string
}
export class AssetVersionsResponseDto {
@ApiProperty({ description: "Публичный идентификатор asset.", example: "asset_demo" })
publicId!: string
@ApiProperty({ description: "Текущая версия source image.", example: 2 })
currentVersion!: number
@ApiProperty({ description: "История версий source image.", type: [AssetVersionResponseDto] })
versions!: AssetVersionResponseDto[]
}
export class AssetsListResponseDto {
@ApiProperty({ description: "Список assets.", type: [AssetResponseDto] })
assets!: AssetResponseDto[]

View File

@@ -13,7 +13,7 @@ import {
import { AssetsService } from "./assets.service"
import { AssetPictureResponseDto } from "./asset-picture-response.dto"
import { AssetResponseDto, AssetVariantsResponseDto, AssetsListResponseDto } from "./asset-response.dto"
import { AssetResponseDto, AssetVariantsResponseDto, AssetVersionsResponseDto, 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"
@@ -60,6 +60,19 @@ export class AssetsController {
return this.assets.getAsset(publicId)
}
@Get(":publicId/versions")
@ApiOperation({
summary: "получить историю версий source image",
description:
"Возвращает все зарегистрированные source versions asset с current marker и versioned Gateway base path для каждой версии.",
})
@ApiParam({ description: "Публичный идентификатор asset.", example: "asset_demo", name: "publicId" })
@ApiOkResponse({ description: "История версий source image возвращена.", type: AssetVersionsResponseDto })
@ApiNotFoundResponse({ description: "Asset не найден." })
listAssetVersions(@Param("publicId") publicId: string): Promise<AssetVersionsResponseDto> {
return this.assets.listAssetVersions(publicId)
}
@Post(":publicId/versions")
@ApiOperation({
summary: "создать новую версию source image",

View File

@@ -1,5 +1,5 @@
import { BadRequestException, ConflictException, Injectable, NotFoundException } from "@nestjs/common"
import { imageAssets, imageAssetVersions, imageVariants } from "@image-platform/database"
import { imageAssets, imageAssetVersions, imageProjects, imageVariants } from "@image-platform/database"
import {
CUSTOM_PRESET_NAME,
ImageTransformConfigError,
@@ -17,8 +17,15 @@ import { createHash, randomUUID } from "node:crypto"
import { DatabaseService } from "../infra/database.service"
import { QueueService } from "../infra/queue.service"
import { normalizeProjectSlug } from "../projects/project-slug"
import type { AssetPictureResponseDto } from "./asset-picture-response.dto"
import type { AssetResponseDto, AssetVariantResponseDto, AssetVariantsResponseDto, AssetsListResponseDto } from "./asset-response.dto"
import type {
AssetResponseDto,
AssetVariantResponseDto,
AssetVariantsResponseDto,
AssetVersionsResponseDto,
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"
@@ -61,10 +68,39 @@ export class AssetsService {
private readonly queue: QueueService,
) {}
async listAssets(input: { limit?: string; offset?: string }): Promise<AssetsListResponseDto> {
async listAssets(input: { limit?: string; offset?: string; projectSlug?: string }): Promise<AssetsListResponseDto> {
const limit = parsePaginationInteger(input.limit, 50, 1, 100)
const offset = parsePaginationInteger(input.offset, 0, 0, 10_000)
if (input.projectSlug) {
const project = await this.loadProject(input.projectSlug)
const rows = await this.database.db
.select({
createdAt: imageAssets.createdAt,
currentVersion: imageAssets.currentVersion,
id: imageAssets.id,
publicId: imageAssets.publicId,
sourceHost: imageAssetVersions.sourceHost,
sourceUrl: imageAssetVersions.sourceUrl,
status: imageAssets.status,
updatedAt: imageAssets.updatedAt,
})
.from(imageAssets)
.innerJoin(
imageAssetVersions,
and(eq(imageAssetVersions.assetId, imageAssets.id), eq(imageAssetVersions.version, imageAssets.currentVersion)),
)
.where(eq(imageAssets.projectId, project.id))
.orderBy(desc(imageAssets.createdAt))
.limit(limit)
.offset(offset)
return {
assets: rows.map(mapAssetResponse),
}
}
const rows = await this.database.db
.select({
createdAt: imageAssets.createdAt,
@@ -90,18 +126,19 @@ export class AssetsService {
}
}
async createAsset(request: CreateAssetRequestDto): Promise<CreateAssetResponseDto> {
async createAsset(request: CreateAssetRequestDto, projectSlug?: string): Promise<CreateAssetResponseDto> {
const source = normalizeSourceUrl(request.sourceUrl)
await this.assertAllowedHost(source.hostname)
const publicId = request.publicId ? normalizePublicId(request.publicId) : generatePublicId()
const sourceHash = createSourceHash(source.sourceUrl)
const project = projectSlug ? await this.loadProject(projectSlug) : null
try {
const result = await this.database.db.transaction(async (tx) => {
const [asset] = await tx
.insert(imageAssets)
.values({ publicId })
.values(project ? { projectId: project.id, publicId } : { publicId })
.returning({ id: imageAssets.id, publicId: imageAssets.publicId })
if (!asset) {
@@ -148,6 +185,58 @@ export class AssetsService {
return mapAssetResponse(asset)
}
async listAssetVersions(publicId: string): Promise<AssetVersionsResponseDto> {
const normalizedPublicId = normalizePublicId(publicId)
const [asset] = await this.database.db
.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 versions = await this.database.db
.select({
contentType: imageAssetVersions.contentType,
createdAt: imageAssetVersions.createdAt,
height: imageAssetVersions.height,
id: imageAssetVersions.id,
sizeBytes: imageAssetVersions.sizeBytes,
sourceHost: imageAssetVersions.sourceHost,
sourceUrl: imageAssetVersions.sourceUrl,
version: imageAssetVersions.version,
width: imageAssetVersions.width,
})
.from(imageAssetVersions)
.where(eq(imageAssetVersions.assetId, asset.id))
.orderBy(desc(imageAssetVersions.version))
return {
currentVersion: asset.currentVersion,
publicId: asset.publicId,
versions: versions.map((version) => ({
contentType: version.contentType,
createdAt: version.createdAt.toISOString(),
height: version.height,
id: version.id,
imageBasePath: `/images/${asset.publicId}/v${version.version}/card`,
isCurrent: version.version === asset.currentVersion,
sizeBytes: version.sizeBytes,
sourceHost: version.sourceHost,
sourceUrl: version.sourceUrl,
version: version.version,
width: version.width,
})),
}
}
async createAssetVersion(
publicId: string,
request: CreateAssetVersionRequestDto,
@@ -381,6 +470,21 @@ export class AssetsService {
return asset
}
private async loadProject(projectSlug: string) {
const normalizedSlug = normalizeProjectSlug(projectSlug)
const [project] = await this.database.db
.select({ id: imageProjects.id, status: imageProjects.status })
.from(imageProjects)
.where(eq(imageProjects.slug, normalizedSlug))
.limit(1)
if (!project || project.status !== "active") {
throw new NotFoundException("project not found")
}
return project
}
private async loadAssetVersion(publicId: string, versionInput: number | null | undefined): Promise<AssetVersionRow> {
const normalizedPublicId = normalizePublicId(publicId)
const [asset] = await this.database.db