167 lines
9.3 KiB
TypeScript
167 lines
9.3 KiB
TypeScript
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<MediaAiEdit> {
|
|
const row = await this.database.workspaceTransaction(workspaceId, async (tx) => {
|
|
const result = await tx.query<EditRow>(
|
|
`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<MediaAiEdit[]> {
|
|
const rows = await this.database.workspaceTransaction(workspaceId, async (tx) => {
|
|
const result = await tx.query<EditRow>(
|
|
`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 } } };
|
|
});
|
|
}
|
|
}
|