feat(ai-image): AI生图开发
This commit is contained in:
@@ -0,0 +1,166 @@
|
||||
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 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
|
||||
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 ? 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
|
||||
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 } } };
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user