feat(edit): AI套图生成、图片切七牛

This commit is contained in:
2026-10-04 20:47:47 +08:00
parent 696112e74d
commit e20cd7547f
60 changed files with 6241 additions and 312 deletions
+328
View File
@@ -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();
}
});
},
);
+291
View File
@@ -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),
);
});
});
+3 -1
View File
@@ -47,10 +47,11 @@ describe('网络商品图片导入', () => {
],
};
}
if (sql.includes('SELECT bucket, object_key')) {
if (sql.includes('SELECT storage_provider, bucket, object_key')) {
return {
rows: [
{
storage_provider: 'local_minio',
bucket: 'test',
object_key: 'product-imports/existing.png',
},
@@ -74,6 +75,7 @@ describe('网络商品图片导入', () => {
{} as ConstructorParameters<typeof MediaService>[1],
{
OBJECT_STORAGE_ENDPOINT: 'http://127.0.0.1:9000',
OBJECT_STORAGE_REGION: 'us-east-1',
OBJECT_STORAGE_BUCKET: 'test',
objectStorageForcePathStyle: true,
objectStorageAccessKey: 'test',
@@ -18,6 +18,7 @@ function serviceWithObject(bytes: Buffer) {
{} as ConstructorParameters<typeof MediaService>[1],
{
OBJECT_STORAGE_ENDPOINT: 'http://127.0.0.1:9000',
OBJECT_STORAGE_REGION: 'us-east-1',
OBJECT_STORAGE_BUCKET: 'test',
objectStorageForcePathStyle: true,
objectStorageAccessKey: 'test',
@@ -70,6 +71,7 @@ describe('商品图片上传策略', () => {
{ workspaceTransaction } as unknown as ConstructorParameters<typeof MediaService>[1],
{
OBJECT_STORAGE_ENDPOINT: 'http://127.0.0.1:9000',
OBJECT_STORAGE_REGION: 'cn-east-1',
OBJECT_STORAGE_BUCKET: 'test',
objectStorageForcePathStyle: true,
objectStorageAccessKey: 'test',
@@ -80,11 +82,88 @@ describe('商品图片上传策略', () => {
const preview = await service.accessUrl('workspace-id', assetId);
const download = await service.accessUrl('workspace-id', assetId, 'download');
expect(new URL(preview.url).searchParams.has('response-content-disposition')).toBe(false);
expect(new URL(preview.url).searchParams.get('X-Amz-Credential')).toContain('/cn-east-1/');
expect(new URL(download.url).searchParams.get('response-content-disposition'))
.toBe(`attachment; filename="atlas-image-${assetId}.png"`);
expect(workspaceTransaction).toHaveBeenCalledTimes(2);
});
it('开发环境为旧 MinIO 素材签发本地预览地址', async () => {
const query = vi.fn(async () => ({
rows: [{
storage_provider: 'local_minio',
bucket: 'atlas-assets',
object_key: 'collections/test/image',
content_type: 'image/jpeg',
}],
}));
const database = {
workspaceTransaction: async (_workspaceId: string, work: (tx: { query: typeof query }) => Promise<unknown>) => work({ query }),
};
const service = new MediaService(
{} as ConstructorParameters<typeof MediaService>[0],
database as unknown as ConstructorParameters<typeof MediaService>[1],
{
NODE_ENV: 'development',
OBJECT_STORAGE_PROVIDER: 'qiniu_kodo',
OBJECT_STORAGE_ENDPOINT: 'https://s3.cn-east-1.qiniucs.com',
OBJECT_STORAGE_REGION: 'cn-east-1',
OBJECT_STORAGE_BUCKET: 'atlas-dev-assets',
objectStorageForcePathStyle: true,
objectStorageAccessKey: 'test',
objectStorageSecretKey: 'test',
LEGACY_MINIO_ENDPOINT: 'http://localhost:9000',
MINIO_ROOT_USER: 'local-test',
MINIO_ROOT_PASSWORD: 'local-secret',
} as ConstructorParameters<typeof MediaService>[2],
);
const preview = await service.accessUrl('workspace-id', 'asset-id');
expect(new URL(preview.url).host).toBe('localhost:9000');
expect(new URL(preview.url).pathname).toBe('/atlas-assets/collections/test/image');
});
it('七牛素材使用绑定域名签发预览和附件下载链接', async () => {
const assetId = '7c910684-7070-4f36-8a7b-b10bd91acd79';
const query = vi.fn(async () => ({
rows: [{
storage_provider: 'qiniu_kodo',
bucket: 'atlas-dev-assets',
object_key: 'collections/workspace/image',
content_type: 'image/jpeg',
}],
}));
const database = {
workspaceTransaction: async (_workspaceId: string, work: (tx: { query: typeof query }) => Promise<unknown>) => work({ query }),
};
const service = new MediaService(
{} as ConstructorParameters<typeof MediaService>[0],
database as unknown as ConstructorParameters<typeof MediaService>[1],
{
OBJECT_STORAGE_ENDPOINT: 'https://s3.cn-east-1.qiniucs.com',
OBJECT_STORAGE_REGION: 'cn-east-1',
OBJECT_STORAGE_BUCKET: 'atlas-dev-assets',
objectStorageForcePathStyle: true,
objectStorageAccessKey: 's3-key',
objectStorageSecretKey: 's3-secret',
qiniuDownloadOrigin: 'https://img-dev.atlas.joeyone.cn',
qiniuDownloadAccessKey: 'download-key',
qiniuDownloadSecretKey: 'download-secret',
} as ConstructorParameters<typeof MediaService>[2],
);
const preview = await service.accessUrl('workspace-id', assetId);
const download = await service.accessUrl('workspace-id', assetId, 'download');
const previewUrl = new URL(preview.url);
const downloadUrl = new URL(download.url);
expect(previewUrl.origin).toBe('https://img-dev.atlas.joeyone.cn');
expect(previewUrl.pathname).toBe('/collections/workspace/image');
expect(previewUrl.searchParams.has('X-Amz-Signature')).toBe(false);
expect(previewUrl.searchParams.has('attname')).toBe(false);
expect(previewUrl.searchParams.get('token')).toMatch(/^download-key:/);
expect(preview.url.split('&').at(-1)).toMatch(/^token=/);
expect(downloadUrl.searchParams.get('attname')).toBe(`atlas-image-${assetId}.jpg`);
expect(download.url.split('&').at(-1)).toMatch(/^token=/);
});
it('创建商品图片上传意图时持久化用途并签发独立对象路径', async () => {
const query = vi.fn(async (_sql: string, _params?: unknown[]) => ({
rows: [],
@@ -100,6 +179,7 @@ describe('商品图片上传策略', () => {
{} as ConstructorParameters<typeof MediaService>[1],
{
OBJECT_STORAGE_ENDPOINT: 'http://127.0.0.1:9000',
OBJECT_STORAGE_REGION: 'us-east-1',
OBJECT_STORAGE_BUCKET: 'test',
objectStorageForcePathStyle: true,
objectStorageAccessKey: 'test',
@@ -208,6 +288,7 @@ describe('商品图片上传策略', () => {
>[1],
{
OBJECT_STORAGE_ENDPOINT: 'http://127.0.0.1:9000',
OBJECT_STORAGE_REGION: 'us-east-1',
OBJECT_STORAGE_BUCKET: 'test',
objectStorageForcePathStyle: true,
objectStorageAccessKey: 'test',
@@ -72,6 +72,15 @@ const commission: CommissionRule = {
version: 'test',
};
describe('十进制定价引擎', () => {
it('默认方案折扣率为 50% 时,划线价是实际售价的两倍', () => {
const i = input();
i.pricing.list_price_discount_rate = '0.5';
for (const currency of ['CNY', 'RUB']) {
const option = calculatePricing(i, currency, '12.3', [rule], [commission])
.options[0];
expect(Number(option.list_price)).toBe(Number(option.selling_price) * 2);
}
});
it('目标加价与明细一致,CNY / RUB 都按最终币种舍入', () => {
const c = calculatePricing(input(), 'CNY', '12.3', [rule], [commission])
.options[0];
+220 -3
View File
@@ -4,6 +4,7 @@ import type { AddressInfo } from 'node:net';
import type { Database } from '@atlas/database';
import type { NestExpressApplication } from '@nestjs/platform-express';
import type { ProductPublishJob } from '@atlas/contracts';
import { ozonHashtagProblem } from '@atlas/contracts';
import type { S3Client } from '@aws-sdk/client-s3';
import { beforeAll, afterAll, describe, it } from 'vitest';
import { DATABASE } from '../src/database/database.module.js';
@@ -204,7 +205,7 @@ describe('首次发布 HTTP / 数据库 / Worker 闭环', () => {
)
).rows[0].id;
await tx.query(
`INSERT INTO atlas.platform_product_category_attributes(platform_id,platform_product_category_id,platform_attribute_id,name,value_type,is_required,is_aspect,max_value_count) VALUES($1,$2,'9048','型号','text',true,false,1)`,
`INSERT INTO atlas.platform_product_category_attributes(platform_id,platform_product_category_id,platform_attribute_id,name,value_type,is_required,is_aspect,max_value_count) VALUES($1,$2,'9048','型号','text',true,false,1),($1,$2,'23171','#主题标签','text',false,false,1)`,
[platform, category],
);
await tx.query(
@@ -217,6 +218,167 @@ describe('首次发布 HTTP / 数据库 / Worker 闭环', () => {
await app?.close();
});
it('多个主题标签合并为一个 Ozon 属性值,SKU 覆盖与继承均保留', async () => {
const f = await fixture();
await db.workspaceTransaction(workspace, async (tx) => {
await tx.query(
'UPDATE atlas.product_platform_profiles SET tags=$2::jsonb WHERE id=$1',
[f.profile, JSON.stringify(['#boho', '#loft'])],
);
await tx.query(
'UPDATE atlas.product_variant_platform_listings SET tags=$3::jsonb WHERE profile_id=$1 AND product_variant_id=$2',
[f.profile, f.ids[0], JSON.stringify(['#kpop', '#oldmoney'])],
);
});
const created = await publish(f);
assert.equal(created.status, 202, JSON.stringify(created));
const job = created.body.data.publish_job as ProductPublishJob;
await run(job, async (_client, _key, path, body) => {
if (path === '/v3/product/info/list') return { items: [] };
assert.equal(path, '/v3/product/import');
const items = body.items as Array<{
attributes: Array<{
id: number;
values: Array<{ value: string }>;
}>;
}>;
assert.deepEqual(
items.map(
(item) =>
item.attributes.find(
(attribute) => attribute.id === 23171,
)?.values,
),
[[{ value: '#kpop #oldmoney' }], [{ value: '#boho #loft' }]],
);
return { result: { task_id: 23171 } };
});
});
it('主题标签支持 30 个标签,空标签不提交属性', async () => {
for (const tags of [
[],
Array.from({ length: 30 }, (_, index) => `#tag${index}`),
]) {
const f = await fixture(1);
await db.workspaceTransaction(workspace, (tx) =>
tx.query(
'UPDATE atlas.product_platform_profiles SET tags=$2::jsonb WHERE id=$1',
[f.profile, JSON.stringify(tags)],
),
);
const created = await publish(f);
assert.equal(created.status, 202, JSON.stringify(created));
const result = await db.workspaceTransaction(workspace, (tx) =>
tx.query<{
snapshot: {
attributes: Array<{
id: number;
values: Array<{ value: string }>;
}>;
};
}>(
'SELECT snapshot FROM atlas.product_publish_job_items WHERE job_id=$1',
[created.body.data.publish_job.id],
),
);
const attribute = result.rows[0].snapshot.attributes.find(
(item) => item.id === 23171,
);
assert.deepEqual(
attribute?.values,
tags.length ? [{ value: tags.join(' ') }] : undefined,
);
}
});
it('主题标签只接受单个井号、字母数字下划线及 30 字符边界', () => {
assert.equal(ozonHashtagProblem(['#стиль_2026', '#boho']), null);
assert.equal(ozonHashtagProblem(['#' + 'я'.repeat(29)]), null);
for (const tag of [
'стиль',
'#стиль мода',
'#стиль-мода',
'#стиль/мода',
'#стиль#мода',
'#стиль😀',
'#',
'#' + 'я'.repeat(30),
])
assert.ok(ozonHashtagProblem([tag]), tag);
assert.ok(
ozonHashtagProblem(Array.from({ length: 31 }, () => '#стиль')),
);
});
it('历史不合规主题标签在预检与创建发布时拦截,不影响合法 SKU 覆盖', async () => {
const f = await fixture();
await db.workspaceTransaction(workspace, async (tx) => {
await tx.query(
'UPDATE atlas.product_platform_profiles SET tags=$2::jsonb WHERE id=$1',
[f.profile, JSON.stringify(['спортивный стиль'])],
);
await tx.query(
'UPDATE atlas.product_variant_platform_listings SET tags=$3::jsonb WHERE profile_id=$1 AND product_variant_id=$2',
[f.profile, f.ids[0], JSON.stringify(['#спортивный_стиль'])],
);
});
const preflight = await request(
`/product-platform-profiles/${f.profile}/publish-preflights`,
'POST',
{ variant_ids: f.ids },
);
assert.equal(preflight.status, 200, JSON.stringify(preflight));
assert.equal(preflight.body.data.status, 'blocked');
const validation = preflight.body.data.validation;
assert.equal(
validation.variants.find(
(v: { variant_id: string }) => v.variant_id === f.ids[0],
).ready,
true,
);
assert.ok(
validation.variants
.find((v: { variant_id: string }) => v.variant_id === f.ids[1])
.issues.some(
(issue: { code: string; field: string }) =>
issue.code === 'OZON_HASHTAG_INVALID' &&
issue.field === 'profile.tags',
),
);
const rejected = await publish(f);
assert.equal(rejected.status, 422);
assert.ok(
rejected.body.error.details.some(
(issue: { code: string }) =>
issue.code === 'OZON_HASHTAG_INVALID',
),
);
const selected = await publish(f, [f.ids[0]]);
assert.equal(selected.status, 202, JSON.stringify(selected));
const invalidSku = await fixture(1);
await db.workspaceTransaction(workspace, (tx) =>
tx.query(
'UPDATE atlas.product_variant_platform_listings SET tags=$3::jsonb WHERE profile_id=$1 AND product_variant_id=$2',
[
invalidSku.profile,
invalidSku.ids[0],
JSON.stringify(['#стиль-мода']),
],
),
);
const response = await publish(invalidSku);
assert.equal(response.status, 422);
assert.ok(
response.body.error.details.some(
(issue: { code: string; field: string }) =>
issue.code === 'OZON_HASHTAG_INVALID' &&
issue.field === `variants.${invalidSku.ids[0]}.tags`,
),
);
});
it('预检、并发幂等、精度快照、Outbox 与跨租户隔离', async () => {
const f = await fixture();
const key = randomUUID();
@@ -241,12 +403,21 @@ describe('首次发布 HTTP / 数据库 / Worker 闭环', () => {
[job.id],
)
).rows,
listings: (
await tx.query(
'SELECT selling_price::text, list_price::text FROM atlas.product_variant_platform_listings WHERE profile_id=$1',
[f.profile],
)
).rows,
}));
assert.equal(facts.events.length, 1);
assert.deepEqual(facts.events[0].event_payload, {
publish_job_id: job.id,
});
assert.equal(facts.items[0].snapshot.price, '1999.1234');
assert.equal(facts.items[0].snapshot.price, '2999.1234');
assert.equal(facts.listings[0].selling_price, '1999.1234');
assert.equal(facts.listings[0].list_price, '2999.1234');
assert.ok(!('old_price' in facts.items[0].snapshot));
assert.equal(facts.items[0].snapshot.depth, 240);
assert.ok(!('vat' in facts.items[0].snapshot));
assert.ok(!('images' in facts.items[0].snapshot));
@@ -297,6 +468,51 @@ describe('首次发布 HTTP / 数据库 / Worker 闭环', () => {
'VERSION_CONFLICT',
);
});
it('首次价格使用划线价;零折扣仍可提交,缺失或零划线价阻断预检和创建', async () => {
const equal = await fixture(1);
await db.workspaceTransaction(workspace, (tx) =>
tx.query(
'UPDATE atlas.product_variant_platform_listings SET list_price=selling_price WHERE profile_id=$1',
[equal.profile],
),
);
const created = await publish(equal);
assert.equal(created.status, 202, JSON.stringify(created));
await run(created.body.data.publish_job, async (_client, _key, path, body) => {
if (path === '/v3/product/info/list') return { items: [] };
assert.equal(path, '/v3/product/import');
const item = (body.items as any[])[0];
assert.equal(item.price, '1999.1234');
assert.ok(!('old_price' in item));
return { result: { task_id: 987654 } };
});
for (const listPrice of [null, '0']) {
const f = await fixture(1);
await db.workspaceTransaction(workspace, (tx) =>
tx.query(
'UPDATE atlas.product_variant_platform_listings SET list_price=$2, selling_price=CASE WHEN $2::numeric=0 THEN 0 ELSE selling_price END WHERE profile_id=$1',
[f.profile, listPrice],
),
);
const preflight = await request(
`/product-platform-profiles/${f.profile}/publish-preflights`,
'POST',
{ variant_ids: f.ids },
{ 'Idempotency-Key': randomUUID(), 'If-Match': '"1:1"' },
);
assert.equal(preflight.status, 200, JSON.stringify(preflight));
assert.equal(preflight.body.data.validation.variants[0].ready, false);
assert.ok(preflight.body.data.validation.variants[0].issues.some(
(issue: { code: string; field: string }) =>
issue.code === 'LIST_PRICE_REQUIRED' && issue.field === `variants.${f.ids[0]}.list_price`,
));
const rejected = await publish(f);
assert.equal(rejected.status, 422);
assert.ok(rejected.body.error.details.some(
(issue: { code: string }) => issue.code === 'LIST_PRICE_REQUIRED',
));
}
});
it('只校验所选 SKU;不就绪的未选 SKU 不阻断部分上架', async () => {
const f = await fixture();
await db.workspaceTransaction(workspace, (tx) =>
@@ -322,7 +538,8 @@ describe('首次发布 HTTP / 数据库 / Worker 闭环', () => {
imports++;
const items = body.items as any[];
assert.equal(items.length, 2);
assert.equal(items[0].price, '1999.1234');
assert.equal(items[0].price, '2999.1234');
assert.ok(!('old_price' in items[0]));
assert.ok(!('vat' in items[0]));
assert.ok(!('barcode' in items[0]));
assert.ok(!('description' in items[0]));