import { Inject, Injectable } from '@nestjs/common'; import type { MediaAiEdit, MediaAiEditSettings } 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 EditRow = { id: string; status: MediaAiEdit['status']; source_asset_id: string; output_asset_id: string | null; error_code: string | null; failure_source?: MediaAiEdit['failure_source']; failure_stage?: MediaAiEdit['failure_stage']; failure_detail?: 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 MediaAiEditService { public constructor( @Inject(DATABASE) private readonly database: Database, @Inject(IdempotencyService) private readonly idempotency: IdempotencyService, @Inject(MediaService) private readonly media: MediaService, ) {} /** 创建任务前确认主图和辅图均为本工作空间已校验的正式素材。 */ public async create(workspaceId: string, userId: string, sourceId: string, key: string, settings: MediaAiEditSettings) { return this.idempotency.run({ workspaceId, userId, method: 'POST', path: `/api/media-assets/${sourceId}/ai-edits`, key, request: settings, }, async (tx) => { const allIds = [sourceId, ...settings.reference_asset_ids]; const product = await tx.query<{ id: string }>( 'SELECT id FROM atlas.products WHERE id = $1', [settings.product_id], ); if (!product.rows[0]) throw new ApiError(404, 'RESOURCE_NOT_FOUND', '商品不存在'); if (new Set(allIds).size !== allIds.length) throw new ApiError(422, 'INVALID_REFERENCE_IMAGE', '主图和辅助图不能重复'); const assets = await tx.query<{ id: string }>( `SELECT id FROM atlas.media_assets WHERE id = ANY($1::uuid[]) AND deleted_at IS NULL 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`, [allIds], ); if (assets.rows.length !== allIds.length) throw new ApiError(404, 'RESOURCE_NOT_FOUND', '图片素材不存在或不可处理'); const created = await tx.query<{ id: string }>( `INSERT INTO atlas.media_ai_edits (workspace_id, product_id, source_asset_id, reference_asset_ids, settings, created_by_user_id) VALUES (current_setting('app.workspace_id')::uuid, $1, $2, $3::uuid[], $4::jsonb, $5) RETURNING id`, [settings.product_id, sourceId, settings.reference_asset_ids, JSON.stringify(settings), 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_ai_edit', 'media_ai_edit', $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_ai_edit', $1, 'media.ai_edit.requested', jsonb_build_object('edit_id', $1::uuid), $1::text)`, [id], ); return { status: 202, body: { data: { id, status: 'queued', source_asset_id: sourceId, output_asset: null, error_code: null, failure_source: null, failure_stage: null, failure_detail: null, } satisfies MediaAiEdit } }; }); } public async read(workspaceId: string, id: string): Promise { const row = await this.database.workspaceTransaction(workspaceId, async (tx) => { const result = await tx.query( `SELECT e.id, e.status, e.source_asset_id, e.output_asset_id, e.error_code, o.result ->> 'failure_source' AS failure_source, o.result ->> 'failure_stage' AS failure_stage, o.result ->> 'failure_detail' AS failure_detail, a.content_type, a.byte_size::text, a.width_px, a.height_px, a.created_at AS asset_created_at FROM atlas.media_ai_edits e LEFT JOIN atlas.operations o ON o.id = e.id AND o.workspace_id = e.workspace_id LEFT JOIN atlas.media_assets a ON a.id = e.output_asset_id AND a.deleted_at IS NULL WHERE e.id = $1`, [id], ); return result.rows[0] ?? null; }); if (!row) throw new ApiError(404, 'RESOURCE_NOT_FOUND', 'AI 编辑任务不存在'); const access = row.output_asset_id && row.content_type ? 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, failure_source: row.status === 'failed' ? row.failure_source ?? null : null, failure_stage: row.status === 'failed' ? row.failure_stage ?? null : null, failure_detail: row.status === 'failed' ? row.failure_detail ?? null : null, 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, }; } /** 右侧素材库展示最近完成的生成图,预览链接每次读取时重新签发。 */ public async list(workspaceId: string, productId: string): Promise { const rows = await this.database.workspaceTransaction(workspaceId, async (tx) => { const result = await tx.query( `SELECT e.id, e.status, e.source_asset_id, e.output_asset_id, e.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_ai_edits e JOIN atlas.media_assets a ON a.id = e.output_asset_id AND a.deleted_at IS NULL WHERE e.status = 'succeeded' AND e.product_id = $1 ORDER BY e.completed_at DESC LIMIT 100`, [productId], ); return result.rows; }); return Promise.all(rows.map(async (row) => { const access = await this.media.accessUrl(workspaceId, row.output_asset_id!); return { id: row.id, status: 'succeeded' as const, source_asset_id: row.source_asset_id, error_code: null, failure_source: null, failure_stage: null, failure_detail: null, output_asset: { 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, }, }; })); } /** 取消是工作空间内的幂等状态变更;已完成结果不回滚。 */ public async cancel(workspaceId: string, userId: string, id: string, key: string) { return this.idempotency.run({ workspaceId, userId, method: 'POST', path: `/api/media-ai-edits/${id}/cancel`, key, request: {}, }, async (tx) => { const existing = await tx.query<{ status: MediaAiEdit['status'] }>( 'SELECT status FROM atlas.media_ai_edits WHERE id = $1 FOR UPDATE', [id], ); if (!existing.rows[0]) throw new ApiError(404, 'RESOURCE_NOT_FOUND', 'AI 编辑任务不存在'); if (existing.rows[0].status === 'queued' || existing.rows[0].status === 'running') { await tx.query( `UPDATE atlas.media_ai_edits SET status = 'cancelled', completed_at = now() WHERE id = $1`, [id], ); await tx.query( `UPDATE atlas.operations SET status = 'cancelled', completed_at = now() WHERE id = $1 AND status IN ('queued', 'running')`, [id], ); } return { status: 200, body: { data: { id, status: existing.rows[0].status === 'queued' || existing.rows[0].status === 'running' ? 'cancelled' : existing.rows[0].status } } }; }); } }