import { Inject, Injectable } from '@nestjs/common'; import type { AspectRatioDerivative, CreateAspectRatioDerivativeRequest } from '@atlas/contracts'; import type { Database } from '@atlas/database'; import { ApiError } from '../common/api-error.js'; import { IdempotencyService } from '../common/idempotency.service.js'; import { DATABASE } from '../database/database.module.js'; import { MediaService } from './media.service.js'; type DerivativeRow = { id: string; status: AspectRatioDerivative['status']; source_asset_id: string; output_asset_id: string | null; error_code: string | null; content_type: string | null; byte_size: string | null; width_px: number | null; height_px: number | null; asset_created_at: Date | string | null; }; @Injectable() export class AspectRatioDerivativeService { public constructor( @Inject(DATABASE) private readonly database: Database, @Inject(IdempotencyService) private readonly idempotency: IdempotencyService, @Inject(MediaService) private readonly media: MediaService, ) {} /** 任务、用户可见进度和 Outbox 必须在同一工作空间事务中创建。 */ public async create( workspaceId: string, userId: string, assetId: string, key: string, input: CreateAspectRatioDerivativeRequest, ) { return this.idempotency.run( { workspaceId, userId, method: 'POST', path: `/api/media-assets/${assetId}/aspect-ratio-derivatives`, key, request: input, }, async (tx) => { const source = await tx.query<{ id: string }>( `SELECT id FROM atlas.media_assets WHERE id = $1 AND content_type IN ('image/jpeg', 'image/png', 'image/webp') AND byte_size <= 20971520 AND width_px BETWEEN 1 AND 10000 AND height_px BETWEEN 1 AND 10000`, [assetId], ); if (!source.rows[0]) throw new ApiError(404, 'RESOURCE_NOT_FOUND', '图片素材不存在或不可处理'); if (input.product_id) { const product = await tx.query<{ id: string }>( 'SELECT id FROM atlas.products WHERE id = $1', [input.product_id], ); if (!product.rows[0]) throw new ApiError(404, 'RESOURCE_NOT_FOUND', '商品不存在'); } const created = await tx.query<{ id: string }>( `INSERT INTO atlas.media_aspect_ratio_derivatives (workspace_id, product_id, source_asset_id, created_by_user_id) VALUES (current_setting('app.workspace_id')::uuid, $1, $2, $3) RETURNING id`, [input.product_id ?? null, assetId, userId], ); const id = created.rows[0].id; await tx.query( `INSERT INTO atlas.operations (id, workspace_id, kind, resource_type, resource_id, created_by_user_id) VALUES ($1, current_setting('app.workspace_id')::uuid, 'media_aspect_ratio_derivative', 'media_derivative', $1, $2)`, [id, userId], ); await tx.query( `INSERT INTO atlas.outbox_events (workspace_id, aggregate_type, aggregate_id, event_type, event_payload, deduplication_key) VALUES (current_setting('app.workspace_id')::uuid, 'media_derivative', $1, 'media.aspect_ratio_derivative.requested', jsonb_build_object('derivative_id', $1::uuid), $1::text)`, [id], ); return { status: 202, body: { data: { id, status: 'queued', source_asset_id: assetId, output_asset: null, error_code: null, } satisfies AspectRatioDerivative }, }; }, ); } /** 短时预览地址仅在读取成功结果时签发,不写入任务或草稿。 */ public async read(workspaceId: string, derivativeId: string): Promise { const row = await this.database.workspaceTransaction(workspaceId, async (tx) => { const result = await tx.query( `SELECT d.id, d.status, d.source_asset_id, d.output_asset_id, d.error_code, a.content_type, a.byte_size::text, a.width_px, a.height_px, a.created_at AS asset_created_at FROM atlas.media_aspect_ratio_derivatives d LEFT JOIN atlas.media_assets a ON a.id = d.output_asset_id WHERE d.id = $1`, [derivativeId], ); return result.rows[0] ?? null; }); if (!row) throw new ApiError(404, 'RESOURCE_NOT_FOUND', '图片处理任务不存在'); const access = row.output_asset_id ? await this.media.accessUrl(workspaceId, row.output_asset_id) : null; return { id: row.id, status: row.status, source_asset_id: row.source_asset_id, error_code: row.error_code, output_asset: row.output_asset_id && access ? { id: row.output_asset_id, content_type: row.content_type!, byte_size: Number(row.byte_size), width_px: row.width_px, height_px: row.height_px, created_at: row.asset_created_at instanceof Date ? row.asset_created_at.toISOString() : String(row.asset_created_at), preview_url: access.url, preview_url_expires_at: access.expires_at, } : null, }; } }