feat(edit): AI套图生成、图片切七牛
This commit is contained in:
@@ -0,0 +1,291 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
createImageSuiteSchema,
|
||||
imageSuiteInputSchema,
|
||||
imageSuitePlanInputSchema,
|
||||
} from '@atlas/contracts';
|
||||
import { ImageSuiteService } from '../src/media/image-suite.service.js';
|
||||
|
||||
const input = () =>
|
||||
imageSuiteInputSchema.parse({
|
||||
variant_id: randomUUID(),
|
||||
reference_asset_ids: [randomUUID()],
|
||||
product_name: '台灯',
|
||||
});
|
||||
const shot = () => ({
|
||||
id: randomUUID(),
|
||||
category: 'white',
|
||||
type: 'white_background',
|
||||
theme: '白底主图',
|
||||
description: '展示商品完整外观',
|
||||
});
|
||||
|
||||
describe('套图共享契约与资源边界', () => {
|
||||
it('规划默认四张,接受 1–20 张预算,生成接口不接收规划预算字段', () => {
|
||||
const valid = input();
|
||||
expect(imageSuitePlanInputSchema.parse(valid).image_count).toBe(4);
|
||||
expect(
|
||||
imageSuitePlanInputSchema.parse({ ...valid, image_count: 6 })
|
||||
.image_count,
|
||||
).toBe(6);
|
||||
for (const image_count of [0, 21, 1.2])
|
||||
expect(
|
||||
imageSuitePlanInputSchema.safeParse({ ...valid, image_count })
|
||||
.success,
|
||||
).toBe(false);
|
||||
expect(
|
||||
createImageSuiteSchema.safeParse({
|
||||
...valid,
|
||||
image_count: 4,
|
||||
shots: [shot()],
|
||||
}).success,
|
||||
).toBe(false);
|
||||
});
|
||||
it('规划保存预算并只创建规划 Outbox,不创建生图子任务', async () => {
|
||||
const request = imageSuitePlanInputSchema.parse({
|
||||
...input(),
|
||||
image_count: 6,
|
||||
});
|
||||
const suiteId = randomUUID(),
|
||||
productId = randomUUID();
|
||||
const query = vi.fn(async (sql: string, values: unknown[] = []) => {
|
||||
if (sql.startsWith('SELECT id FROM atlas.product_variants'))
|
||||
return { rows: [{ id: request.variant_id }] };
|
||||
if (sql.startsWith('SELECT id FROM atlas.media_assets'))
|
||||
return {
|
||||
rows: request.reference_asset_ids.map((id) => ({ id })),
|
||||
};
|
||||
if (sql.startsWith('INSERT INTO atlas.product_image_suites'))
|
||||
return {
|
||||
rows: [
|
||||
{
|
||||
id: suiteId,
|
||||
product_id: productId,
|
||||
variant_id: request.variant_id,
|
||||
purpose: 'plan',
|
||||
status: 'queued',
|
||||
input: JSON.parse(values[3] as string),
|
||||
shots: [],
|
||||
created_at: new Date(),
|
||||
completed_at: null,
|
||||
error_code: null,
|
||||
},
|
||||
],
|
||||
};
|
||||
return { rows: [] };
|
||||
});
|
||||
const service = new ImageSuiteService(
|
||||
{} as never,
|
||||
{
|
||||
run: async (
|
||||
_context: unknown,
|
||||
work: (tx: { query: typeof query }) => Promise<unknown>,
|
||||
) => work({ query }),
|
||||
} as never,
|
||||
{} as never,
|
||||
);
|
||||
const result = await service.create(
|
||||
randomUUID(),
|
||||
randomUUID(),
|
||||
productId,
|
||||
randomUUID(),
|
||||
request,
|
||||
'plan',
|
||||
);
|
||||
expect(result).toMatchObject({
|
||||
status: 202,
|
||||
body: {
|
||||
data: { purpose: 'plan', input: { image_count: 6 }, shots: [] },
|
||||
},
|
||||
});
|
||||
expect(
|
||||
query.mock.calls.some(([sql]) =>
|
||||
sql.includes('INSERT INTO atlas.media_ai_edits'),
|
||||
),
|
||||
).toBe(false);
|
||||
expect(
|
||||
query.mock.calls.find(([sql]) =>
|
||||
sql.includes('INSERT INTO atlas.outbox_events'),
|
||||
)?.[1],
|
||||
).toContain('image_suite.plan.requested');
|
||||
});
|
||||
it('拒绝重复底图、超过 8 张底图、超过分类数量和无实物尺寸的尺寸图', () => {
|
||||
const valid = input();
|
||||
expect(
|
||||
imageSuiteInputSchema.safeParse({
|
||||
...valid,
|
||||
reference_asset_ids: Array.from({ length: 9 }, randomUUID),
|
||||
}).success,
|
||||
).toBe(false);
|
||||
expect(
|
||||
imageSuiteInputSchema.safeParse({
|
||||
...valid,
|
||||
reference_asset_ids: [
|
||||
...valid.reference_asset_ids,
|
||||
...valid.reference_asset_ids,
|
||||
],
|
||||
}).success,
|
||||
).toBe(false);
|
||||
expect(
|
||||
createImageSuiteSchema.safeParse({
|
||||
...valid,
|
||||
shots: Array.from({ length: 6 }, shot),
|
||||
}).success,
|
||||
).toBe(false);
|
||||
expect(
|
||||
createImageSuiteSchema.safeParse({
|
||||
...valid,
|
||||
shots: [{ ...shot(), category: 'other', type: 'dimensions' }],
|
||||
}).success,
|
||||
).toBe(false);
|
||||
});
|
||||
it('逐张底图必须来自已选图库,并保留主体图,类型必须匹配分类', () => {
|
||||
const valid = input();
|
||||
expect(
|
||||
createImageSuiteSchema.safeParse({
|
||||
...valid,
|
||||
shots: [{ ...shot(), reference_asset_ids: [randomUUID()] }],
|
||||
}).success,
|
||||
).toBe(false);
|
||||
expect(
|
||||
createImageSuiteSchema.safeParse({
|
||||
...valid,
|
||||
shots: [{ ...shot(), category: 'scene' }],
|
||||
}).success,
|
||||
).toBe(false);
|
||||
expect(
|
||||
createImageSuiteSchema.safeParse({
|
||||
...valid,
|
||||
shots: [shot()],
|
||||
ratio: '4:3',
|
||||
language: 'zh',
|
||||
}).success,
|
||||
).toBe(true);
|
||||
});
|
||||
it('目标 SKU 不属于当前商品时,在产生任何任务前拒绝请求', async () => {
|
||||
const query = vi.fn(async () => ({ rows: [] }));
|
||||
const run = vi.fn(
|
||||
async (
|
||||
_input: unknown,
|
||||
work: (tx: { query: typeof query }) => Promise<unknown>,
|
||||
) => work({ query }),
|
||||
);
|
||||
const service = new ImageSuiteService(
|
||||
{} as never,
|
||||
{ run } as never,
|
||||
{} as never,
|
||||
);
|
||||
await expect(
|
||||
service.create(
|
||||
randomUUID(),
|
||||
randomUUID(),
|
||||
randomUUID(),
|
||||
randomUUID(),
|
||||
input(),
|
||||
'plan',
|
||||
),
|
||||
).rejects.toMatchObject({ status: 404 });
|
||||
expect(query).toHaveBeenCalledOnce();
|
||||
});
|
||||
it('任一底图不可见时拒绝,不产生套图及 Outbox', async () => {
|
||||
const query = vi.fn(async (sql: string) => ({
|
||||
rows: sql.includes('atlas.product_variants')
|
||||
? [{ id: 'variant' }]
|
||||
: [],
|
||||
}));
|
||||
const run = vi.fn(
|
||||
async (
|
||||
_input: unknown,
|
||||
work: (tx: { query: typeof query }) => Promise<unknown>,
|
||||
) => work({ query }),
|
||||
);
|
||||
const service = new ImageSuiteService(
|
||||
{} as never,
|
||||
{ run } as never,
|
||||
{} as never,
|
||||
);
|
||||
await expect(
|
||||
service.create(
|
||||
randomUUID(),
|
||||
randomUUID(),
|
||||
randomUUID(),
|
||||
randomUUID(),
|
||||
input(),
|
||||
'plan',
|
||||
),
|
||||
).rejects.toMatchObject({ status: 404 });
|
||||
expect(query).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
it('默认逐张用图保留主体,每张最多 3 张,整套轮换覆盖已选 8 张底图', async () => {
|
||||
const request = createImageSuiteSchema.parse({
|
||||
...input(),
|
||||
reference_asset_ids: Array.from({ length: 8 }, randomUUID),
|
||||
shots: Array.from({ length: 8 }, (_, index) => ({
|
||||
...shot(),
|
||||
category: ['white', 'scene', 'selling', 'other'][index % 4],
|
||||
type: ['white_background', 'lifestyle', 'detail', 'custom'][
|
||||
index % 4
|
||||
],
|
||||
})),
|
||||
});
|
||||
const used: string[][] = [];
|
||||
const query = vi.fn(async (sql: string, values: unknown[] = []) => {
|
||||
if (sql.startsWith('SELECT id FROM atlas.product_variants'))
|
||||
return { rows: [{ id: request.variant_id }] };
|
||||
if (sql.startsWith('SELECT id FROM atlas.media_assets'))
|
||||
return {
|
||||
rows: request.reference_asset_ids.map((id) => ({ id })),
|
||||
};
|
||||
if (sql.includes('INSERT INTO atlas.product_image_suites'))
|
||||
return {
|
||||
rows: [
|
||||
{
|
||||
id: randomUUID(),
|
||||
product_id: randomUUID(),
|
||||
variant_id: request.variant_id,
|
||||
purpose: 'generate',
|
||||
status: 'queued',
|
||||
input: request,
|
||||
shots: request.shots,
|
||||
created_at: new Date(),
|
||||
completed_at: null,
|
||||
error_code: null,
|
||||
},
|
||||
],
|
||||
};
|
||||
if (sql.includes('INSERT INTO atlas.media_ai_edits'))
|
||||
used.push([values[5] as string, ...(values[6] as string[])]);
|
||||
return { rows: [] };
|
||||
});
|
||||
const service = new ImageSuiteService(
|
||||
{} as never,
|
||||
{
|
||||
run: async (
|
||||
_request: unknown,
|
||||
work: (tx: { query: typeof query }) => Promise<unknown>,
|
||||
) => work({ query }),
|
||||
} as never,
|
||||
{} as never,
|
||||
);
|
||||
await service.create(
|
||||
randomUUID(),
|
||||
randomUUID(),
|
||||
randomUUID(),
|
||||
randomUUID(),
|
||||
request,
|
||||
'generate',
|
||||
);
|
||||
expect(used).toHaveLength(8);
|
||||
expect(
|
||||
used.every(
|
||||
(refs) =>
|
||||
refs.length <= 3 &&
|
||||
refs[0] === request.reference_asset_ids[0],
|
||||
),
|
||||
).toBe(true);
|
||||
expect(new Set(used.flat())).toEqual(
|
||||
new Set(request.reference_asset_ids),
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user