import { Inject, Injectable } from '@nestjs/common'; import type { CreateWatermarkDerivativeRequest, WatermarkDerivative } 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: WatermarkDerivative['status']; source_asset_id: string; watermark_template_id: string; watermark_template_version: number; 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 WatermarkDerivativeService { 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: CreateWatermarkDerivativeRequest, ) { return this.idempotency.run({ workspaceId, userId, method: 'POST', path: `/api/media-assets/${assetId}/watermark-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 template = await tx.query<{ id: string; current_version: number }>( `SELECT id, current_version FROM atlas.watermark_templates WHERE id = $1 AND status = 'active' AND deleted_at IS NULL`, [input.watermark_template_id], ); if (!template.rows[0]) throw new ApiError(404, 'RESOURCE_NOT_FOUND', '水印模板不存在'); const version = template.rows[0].current_version; const created = await tx.query<{ id: string }>( `INSERT INTO atlas.media_watermark_derivatives (workspace_id, product_id, source_asset_id, watermark_template_id, watermark_template_version, created_by_user_id) VALUES (current_setting('app.workspace_id')::uuid, $1, $2, $3, $4, $5) RETURNING id`, [input.product_id ?? null, assetId, input.watermark_template_id, version, 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_watermark_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.watermark_derivative.requested', jsonb_build_object('derivative_id', $1::uuid), $1::text)`, [id], ); return { status: 202, body: { data: { id, status: 'queued', source_asset_id: assetId, watermark_template_id: input.watermark_template_id, watermark_template_version: version, output_asset: null, error_code: null, } satisfies WatermarkDerivative } }; }); } /** 成功结果读取时才签发短时预览地址。 */ 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.watermark_template_id, d.watermark_template_version, 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_watermark_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, watermark_template_id: row.watermark_template_id, watermark_template_version: row.watermark_template_version, 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, }; } }