feat(edit): AI套图生成、图片切七牛
This commit is contained in:
@@ -0,0 +1,328 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import {
|
||||
createApplicationDatabase,
|
||||
type Database,
|
||||
type DatabaseTransaction,
|
||||
} from '@atlas/database';
|
||||
import {
|
||||
createImageSuiteSchema,
|
||||
imageSuiteInputSchema,
|
||||
type ImageSuite,
|
||||
} from '@atlas/contracts';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { IdempotencyService } from '../src/common/idempotency.service.js';
|
||||
import { ImageSuiteService } from '../src/media/image-suite.service.js';
|
||||
import { MediaAiEditService } from '../src/media/media-ai-edit.service.js';
|
||||
import { ProductImageTaskService } from '../src/media/product-image-task.service.js';
|
||||
|
||||
/** 所有实体和 Outbox 都只存在于最终回滚的事务,验证不调用实际收费生图。 */
|
||||
describe.skipIf(!process.env.DATABASE_URL)(
|
||||
'套图数据库隔离、幂等与状态汇总',
|
||||
() => {
|
||||
it('创建/部分失败/取消/重试保留成功图片,FK 与 RLS 拒绝越权引用', async () => {
|
||||
const db = createApplicationDatabase(process.env.DATABASE_URL!);
|
||||
const rollback = Symbol('rollback');
|
||||
try {
|
||||
await db.transaction(async (tx) => {
|
||||
const workspaceId = randomUUID(),
|
||||
otherWorkspace = randomUUID(),
|
||||
userId = randomUUID();
|
||||
await tx.query(
|
||||
"INSERT INTO atlas.users (id, email, password_hash) VALUES ($1, $2, 'test-only')",
|
||||
[userId, `${userId}@example.test`],
|
||||
);
|
||||
for (const id of [workspaceId, otherWorkspace])
|
||||
await tx.query(
|
||||
"INSERT INTO atlas.workspaces (id, name, slug) VALUES ($1::uuid, '套图测试', $1::text)",
|
||||
[id],
|
||||
);
|
||||
const inWorkspace = async (id: string) =>
|
||||
tx.query(
|
||||
"SELECT set_config('app.workspace_id', $1, true)",
|
||||
[id],
|
||||
);
|
||||
await inWorkspace(workspaceId);
|
||||
const productId = randomUUID(),
|
||||
variantId = randomUUID(),
|
||||
itemId = randomUUID(),
|
||||
assetId = randomUUID(),
|
||||
outputId = randomUUID();
|
||||
await tx.query(
|
||||
"INSERT INTO atlas.collection_items (id, workspace_id, source_kind, source_item_key) VALUES ($1::uuid, $2, 'manual', $1::text)",
|
||||
[itemId, workspaceId],
|
||||
);
|
||||
await tx.query(
|
||||
'INSERT INTO atlas.products (id, workspace_id, collection_item_id) VALUES ($1, $2, $3)',
|
||||
[productId, workspaceId, itemId],
|
||||
);
|
||||
await tx.query(
|
||||
'INSERT INTO atlas.product_variants (id, workspace_id, product_id) VALUES ($1, $2, $3)',
|
||||
[variantId, workspaceId, productId],
|
||||
);
|
||||
const asset = async (id: string, ws: string) =>
|
||||
tx.query(
|
||||
`INSERT INTO atlas.media_assets
|
||||
(id, workspace_id, storage_provider, bucket, object_key, content_type, byte_size, checksum_sha256, width_px, height_px)
|
||||
VALUES ($1::uuid, $2, 'local_minio', 'test', $1::text, 'image/jpeg', 100, $1::text, 100, 100)`,
|
||||
[id, ws],
|
||||
);
|
||||
await asset(assetId, workspaceId);
|
||||
await asset(outputId, workspaceId);
|
||||
await inWorkspace(otherWorkspace);
|
||||
const foreignAsset = randomUUID();
|
||||
await asset(foreignAsset, otherWorkspace);
|
||||
await inWorkspace(workspaceId);
|
||||
let queryQueue: Promise<void> = Promise.resolve();
|
||||
const scoped = {
|
||||
workspaceTransaction: <T>(
|
||||
ws: string,
|
||||
work: (
|
||||
transaction: DatabaseTransaction,
|
||||
) => Promise<T>,
|
||||
) => {
|
||||
const result = queryQueue.then(async () => {
|
||||
await inWorkspace(ws);
|
||||
return work(tx);
|
||||
});
|
||||
queryQueue = result.then(
|
||||
() => undefined,
|
||||
() => undefined,
|
||||
);
|
||||
return result;
|
||||
},
|
||||
} as Database;
|
||||
const idempotency = new IdempotencyService(scoped, {
|
||||
IDEMPOTENCY_KEY_SECRET: 'suite-test-secret',
|
||||
} as never);
|
||||
const edits = new MediaAiEditService(scoped, idempotency, {
|
||||
accessUrl: async () => ({
|
||||
url: 'https://example.test/preview',
|
||||
expires_at: '2099-01-01T00:00:00Z',
|
||||
}),
|
||||
} as never);
|
||||
const suites = new ImageSuiteService(
|
||||
scoped,
|
||||
idempotency,
|
||||
edits,
|
||||
);
|
||||
const input = imageSuiteInputSchema.parse({
|
||||
variant_id: variantId,
|
||||
reference_asset_ids: [assetId],
|
||||
product_name: '台灯',
|
||||
});
|
||||
const shots = ['white', 'scene', 'selling'].map(
|
||||
(category, i) => ({
|
||||
id: randomUUID(),
|
||||
category,
|
||||
type: ['white_background', 'lifestyle', 'detail'][
|
||||
i
|
||||
],
|
||||
theme: `方案${i}`,
|
||||
description: '依据真实底图展示商品',
|
||||
}),
|
||||
);
|
||||
const request = createImageSuiteSchema.parse({
|
||||
...input,
|
||||
shots,
|
||||
});
|
||||
const key = randomUUID();
|
||||
const created = await suites.create(
|
||||
workspaceId,
|
||||
userId,
|
||||
productId,
|
||||
key,
|
||||
request,
|
||||
'generate',
|
||||
);
|
||||
const id = (created.body as { data: ImageSuite }).data.id;
|
||||
expect(
|
||||
(
|
||||
await suites.create(
|
||||
workspaceId,
|
||||
userId,
|
||||
productId,
|
||||
key,
|
||||
request,
|
||||
'generate',
|
||||
)
|
||||
).body,
|
||||
).toEqual(created.body);
|
||||
await expect(
|
||||
suites.create(
|
||||
workspaceId,
|
||||
userId,
|
||||
productId,
|
||||
key,
|
||||
{ ...request, product_name: '不同请求' },
|
||||
'generate',
|
||||
),
|
||||
).rejects.toMatchObject({
|
||||
status: 409,
|
||||
response: { error: { code: 'IDEMPOTENCY_KEY_REUSED' } },
|
||||
});
|
||||
const children = (
|
||||
await tx.query<{ id: string }>(
|
||||
'SELECT id FROM atlas.media_ai_edits WHERE suite_id = $1 ORDER BY suite_position',
|
||||
[id],
|
||||
)
|
||||
).rows;
|
||||
expect(children).toHaveLength(3);
|
||||
expect(
|
||||
(
|
||||
await tx.query(
|
||||
'SELECT id FROM atlas.outbox_events WHERE aggregate_id = ANY($1::uuid[])',
|
||||
[children.map((child) => child.id)],
|
||||
)
|
||||
).rows,
|
||||
).toHaveLength(3);
|
||||
await tx.query(
|
||||
"UPDATE atlas.media_ai_edits SET status = 'succeeded', output_asset_id = $2 WHERE id = $1",
|
||||
[children[0].id, outputId],
|
||||
);
|
||||
await tx.query(
|
||||
"UPDATE atlas.media_ai_edits SET status = 'failed', error_code = 'TEST_FAILURE' WHERE id = $1",
|
||||
[children[1].id],
|
||||
);
|
||||
expect(
|
||||
(await suites.read(workspaceId, productId, id)).status,
|
||||
).toBe('running');
|
||||
await suites.action(
|
||||
workspaceId,
|
||||
userId,
|
||||
productId,
|
||||
id,
|
||||
randomUUID(),
|
||||
'cancel',
|
||||
);
|
||||
expect(
|
||||
(await suites.read(workspaceId, productId, id)).status,
|
||||
).toBe('cancelled');
|
||||
await suites.action(
|
||||
workspaceId,
|
||||
userId,
|
||||
productId,
|
||||
id,
|
||||
randomUUID(),
|
||||
'retry',
|
||||
);
|
||||
const retried = await suites.read(
|
||||
workspaceId,
|
||||
productId,
|
||||
id,
|
||||
);
|
||||
expect(retried.items.map((item) => item.status)).toEqual([
|
||||
'succeeded',
|
||||
'queued',
|
||||
'queued',
|
||||
]);
|
||||
expect(retried.items[0].output_asset?.id).toBe(outputId);
|
||||
const attempts = (
|
||||
await tx.query<{ generation_attempt: number }>(
|
||||
'SELECT generation_attempt FROM atlas.media_ai_edits WHERE suite_id = $1 ORDER BY suite_position',
|
||||
[id],
|
||||
)
|
||||
).rows;
|
||||
expect(
|
||||
attempts.map((item) => item.generation_attempt),
|
||||
).toEqual([1, 2, 2]);
|
||||
await tx.query(
|
||||
"UPDATE atlas.media_ai_edits SET status = 'failed', error_code = 'TEST_FAILURE' WHERE id = ANY($1::uuid[])",
|
||||
[children.slice(1).map((child) => child.id)],
|
||||
);
|
||||
expect(
|
||||
(await suites.read(workspaceId, productId, id)).status,
|
||||
).toBe('partial');
|
||||
const tasks = await new ProductImageTaskService(
|
||||
scoped,
|
||||
).list(workspaceId, productId, { limit: 20 });
|
||||
expect(tasks.data).toMatchObject([
|
||||
{
|
||||
id,
|
||||
kind: 'ai_suite',
|
||||
status: 'partial',
|
||||
total: 3,
|
||||
completed: 1,
|
||||
output_asset_ids: [outputId],
|
||||
},
|
||||
]);
|
||||
expect(tasks.data).toHaveLength(1);
|
||||
await expect(
|
||||
suites.read(otherWorkspace, productId, id),
|
||||
).rejects.toMatchObject({ status: 404 });
|
||||
await expect(
|
||||
suites.create(
|
||||
workspaceId,
|
||||
userId,
|
||||
productId,
|
||||
randomUUID(),
|
||||
{ ...input, reference_asset_ids: [foreignAsset] },
|
||||
'plan',
|
||||
),
|
||||
).rejects.toMatchObject({ status: 404 });
|
||||
const forbidden = async (
|
||||
sql: string,
|
||||
params: unknown[],
|
||||
code: string,
|
||||
) => {
|
||||
await tx.query('SAVEPOINT forbidden_write');
|
||||
try {
|
||||
await tx.query(sql, params);
|
||||
throw new Error('本应拒绝的写入成功');
|
||||
} catch (error) {
|
||||
expect((error as { code: string }).code).toBe(code);
|
||||
} finally {
|
||||
await tx.query(
|
||||
'ROLLBACK TO SAVEPOINT forbidden_write',
|
||||
);
|
||||
}
|
||||
};
|
||||
await forbidden(
|
||||
'UPDATE atlas.product_image_suites SET input = input || \'{"color":"篡改"}\'::jsonb WHERE id = $1',
|
||||
[id],
|
||||
'23514',
|
||||
);
|
||||
await forbidden(
|
||||
'UPDATE atlas.media_ai_edits SET suite_position = NULL WHERE id = $1',
|
||||
[children[0].id],
|
||||
'23514',
|
||||
);
|
||||
await forbidden(
|
||||
'UPDATE atlas.media_ai_edits SET source_asset_id = $2 WHERE id = $1',
|
||||
[children[0].id, foreignAsset],
|
||||
'23514',
|
||||
);
|
||||
const foreignReferenceParent = await suites.create(
|
||||
workspaceId,
|
||||
userId,
|
||||
productId,
|
||||
randomUUID(),
|
||||
input,
|
||||
'plan',
|
||||
);
|
||||
const foreignParentId = (
|
||||
foreignReferenceParent.body as { data: ImageSuite }
|
||||
).data.id;
|
||||
await forbidden(
|
||||
'UPDATE atlas.product_image_suite_references SET asset_id = $2 WHERE suite_id = $1',
|
||||
[foreignParentId, foreignAsset],
|
||||
'23514',
|
||||
);
|
||||
await inWorkspace(otherWorkspace);
|
||||
expect(
|
||||
(
|
||||
await tx.query(
|
||||
'SELECT id FROM atlas.product_image_suites WHERE id = $1',
|
||||
[id],
|
||||
)
|
||||
).rows,
|
||||
).toHaveLength(0);
|
||||
throw rollback;
|
||||
});
|
||||
} catch (error) {
|
||||
if (error !== rollback) throw error;
|
||||
} finally {
|
||||
await db.close();
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
Reference in New Issue
Block a user