feat(ai-image): AI生图开发

This commit is contained in:
2026-09-27 22:40:52 +08:00
parent e670d06ca6
commit a29dd92676
105 changed files with 9943 additions and 361 deletions
+101
View File
@@ -0,0 +1,101 @@
import { describe, expect, it, vi } from 'vitest';
import { createMediaAiEditRequestSchema } from '@atlas/contracts';
import { MediaAiEditService } from '../src/media/media-ai-edit.service.js';
const workspaceId = '30cf54b7-06a3-4e5f-a5fc-d74165115094';
const sourceId = '664d0d73-3d39-4a98-ac0a-5c0e521d8998';
const referenceId = '50ea00b7-6675-4cc7-b7a2-d45f05c07100';
const editId = '68db226f-3fcb-4579-ab9e-4932b1d84067';
const productId = '733d0d73-3d39-4a98-ac0a-5c0e521d8998';
describe('单张 AI 编辑任务', () => {
it('验证主图和辅图都属于当前工作空间后,原子写入任务、进度和 Outbox', async () => {
const statements: string[] = [];
const query = vi.fn(async (sql: string) => {
statements.push(sql);
if (sql.includes('SELECT id FROM atlas.products')) return { rows: [{ id: productId }] };
if (sql.includes('SELECT id FROM atlas.media_assets'))
return { rows: [{ id: sourceId }, { id: referenceId }] };
if (sql.includes('RETURNING id')) return { rows: [{ id: editId }] };
return { rows: [] };
});
const run = vi.fn(async (_input: unknown, work: (tx: { query: typeof query }) => Promise<unknown>) => work({ query }));
const service = new MediaAiEditService(
{} as ConstructorParameters<typeof MediaAiEditService>[0],
{ run } as unknown as ConstructorParameters<typeof MediaAiEditService>[1],
{} as ConstructorParameters<typeof MediaAiEditService>[2],
);
const input = createMediaAiEditRequestSchema.parse({ product_id: productId, reference_asset_ids: [referenceId], background: 'reference' });
const result = await service.create(workspaceId, 'user-id', sourceId, 'key', input);
expect(result).toMatchObject({ status: 202, body: { data: { id: editId, status: 'queued' } } });
expect(run).toHaveBeenCalledOnce();
expect(statements.some((sql) => sql.includes('INSERT INTO atlas.media_ai_edits'))).toBe(true);
expect(statements.some((sql) => sql.includes('INSERT INTO atlas.operations'))).toBe(true);
expect(statements.some((sql) => sql.includes('INSERT INTO atlas.outbox_events'))).toBe(true);
});
it('任一辅助图不在当前工作空间时,不创建任务', async () => {
const query = vi.fn(async (sql: string) => ({ rows: [{ id: sql.includes('atlas.products') ? productId : sourceId }] }));
const run = vi.fn(async (_input: unknown, work: (tx: { query: typeof query }) => Promise<unknown>) => work({ query }));
const service = new MediaAiEditService(
{} as ConstructorParameters<typeof MediaAiEditService>[0],
{ run } as unknown as ConstructorParameters<typeof MediaAiEditService>[1],
{} as ConstructorParameters<typeof MediaAiEditService>[2],
);
const input = createMediaAiEditRequestSchema.parse({ product_id: productId, reference_asset_ids: [referenceId] });
await expect(service.create(workspaceId, 'user-id', sourceId, 'key', input))
.rejects.toMatchObject({ status: 404 });
expect(query).toHaveBeenCalledTimes(2);
});
it('仅扩图不能带辅图或尺寸标注,参考背景必须带辅图', () => {
expect(createMediaAiEditRequestSchema.safeParse({ product_id: productId, mode: 'outpaint', reference_asset_ids: [referenceId] }).success).toBe(false);
expect(createMediaAiEditRequestSchema.safeParse({ product_id: productId, background: 'reference' }).success).toBe(false);
expect(createMediaAiEditRequestSchema.safeParse({ product_id: productId, generate_size_chart: true }).success).toBe(false);
});
it('读取失败任务时返回记录的失败来源、步骤和诊断', async () => {
const query = vi.fn(async (sql: string) => {
expect(sql).toContain('o.workspace_id = e.workspace_id');
return { rows: [{
id: editId, status: 'failed', source_asset_id: sourceId,
output_asset_id: null, error_code: 'AI_IMAGE_GENERATION_FAILED',
failure_source: 'atlas', failure_stage: 'result_processing',
failure_detail: 'Pcore 结果图片无效',
}] };
});
const database = { workspaceTransaction: vi.fn(async (_workspace: string, work: (tx: { query: typeof query }) => Promise<unknown>) => work({ query })) };
const service = new MediaAiEditService(
database as unknown as ConstructorParameters<typeof MediaAiEditService>[0],
{} as ConstructorParameters<typeof MediaAiEditService>[1],
{} as ConstructorParameters<typeof MediaAiEditService>[2],
);
const result = await service.read(workspaceId, editId);
expect(result).toMatchObject({
failure_source: 'atlas', failure_stage: 'result_processing',
failure_detail: 'Pcore 结果图片无效',
});
});
it('AI 生图记录按当前商品筛选并重新签发短时预览地址', async () => {
const query = vi.fn(async (_sql: string, values: unknown[]) => {
expect(values).toEqual([productId]);
return { rows: [{
id: editId, status: 'succeeded', source_asset_id: sourceId,
output_asset_id: referenceId, error_code: null,
content_type: 'image/jpeg', byte_size: '1234', width_px: 1152,
height_px: 1536, asset_created_at: new Date('2026-09-26T00:00:00Z'),
}] };
});
const database = { workspaceTransaction: vi.fn(async (_workspace: string, work: (tx: { query: typeof query }) => Promise<unknown>) => work({ query })) };
const accessUrl = vi.fn(async () => ({ url: 'https://example.com/short-lived', expires_at: '2026-09-26T01:00:00Z' }));
const service = new MediaAiEditService(
database as unknown as ConstructorParameters<typeof MediaAiEditService>[0],
{} as ConstructorParameters<typeof MediaAiEditService>[1],
{ accessUrl } as unknown as ConstructorParameters<typeof MediaAiEditService>[2],
);
const items = await service.list(workspaceId, productId);
expect(items).toMatchObject([{ id: editId, output_asset: { id: referenceId, preview_url: 'https://example.com/short-lived' } }]);
expect(accessUrl).toHaveBeenCalledWith(workspaceId, referenceId);
});
});