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
+350
View File
@@ -0,0 +1,350 @@
import assert from 'node:assert/strict';
import { test } from 'node:test';
import sharp from 'sharp';
import { GetObjectCommand, PutObjectCommand } from '@aws-sdk/client-s3';
import { createMediaAiEditRequestSchema } from '@atlas/contracts';
import { classifyAiEditFailure, normalizeAiEditOutput, pinnedLookup, providerImageResult, runMediaAiEdit } from '../src/media-ai-edit.ts';
import { buildMediaAiPrompt } from '../src/media-ai-prompt.ts';
const productId = '733d0d73-3d39-4a98-ac0a-5c0e521d8998';
test('仅扩图在供应商改写中心后仍把原图区域覆盖回结果', async () => {
const source = await sharp({ create: { width: 600, height: 600, channels: 3, background: '#e1252a' } }).png().toBuffer();
const generated = await sharp({ create: { width: 1152, height: 1536, channels: 3, background: '#2055d8' } }).png().toBuffer();
const settings = createMediaAiEditRequestSchema.parse({ product_id: productId, mode: 'outpaint' });
const output = await normalizeAiEditOutput(generated, settings, source);
const { data, info } = await sharp(output).raw().toBuffer({ resolveWithObject: true });
assert.equal(info.width, 1152);
assert.equal(info.height, 1536);
const pixel = (x, y) => [...data.subarray((y * info.width + x) * 3, (y * info.width + x) * 3 + 3)];
assert.ok(pixel(576, 768)[0] > 200, '原图中心仍应为红色');
assert.ok(pixel(576, 20)[2] > 170, '扩充边缘仍应为蓝色');
});
test('提示词明确区分主图、辅助图和尺寸数值', () => {
const settings = createMediaAiEditRequestSchema.parse({
product_id: productId,
reference_asset_ids: ['50ea00b7-6675-4cc7-b7a2-d45f05c07100'],
dimensions: { length: 12, unit: 'cm' }, generate_size_chart: true,
});
const prompt = buildMediaAiPrompt(settings);
assert.match(prompt, /Image 1 is the fixed main product image/);
assert.match(prompt, /Images 2 through 2 are auxiliary references only/);
assert.match(prompt, /length: 12 cm/);
assert.match(prompt, /Do not infer other measurements/);
});
test('Pcore 完成结果的空 b64_json 回退到可下载 URL', () => {
const url = 'https://pcore.ai/v1/images/content/task-example/0';
assert.equal(providerImageResult({ data: [{ b64_json: '', url }] }), url);
assert.equal(providerImageResult({ data: [{ b64_json: ' ', url }] }), url);
assert.deepEqual(providerImageResult({ data: [{ b64_json: '', b64_image: Buffer.from('image').toString('base64'), url }] }),
Buffer.from('image'));
assert.deepEqual(providerImageResult({ data: [{ b64_json: Buffer.from('image').toString('base64'), url }] }),
Buffer.from('image'));
});
test('固定 DNS 回调按 Node 的 all 选项返回地址列表或单个地址', () => {
const lookup = pinnedLookup('8.8.8.8');
lookup('pcore.ai', { all: true }, (error, addresses) => {
assert.equal(error, null);
assert.deepEqual(addresses, [{ address: '8.8.8.8', family: 4 }]);
});
lookup('pcore.ai', { all: false }, (error, address, family) => {
assert.equal(error, null);
assert.equal(address, '8.8.8.8');
assert.equal(family, 4);
});
});
test('异步供应商任务分两次 Outbox 推进,并把结果登记为独立素材', async () => {
const sourceId = '664d0d73-3d39-4a98-ac0a-5c0e521d8998';
const referenceId = '6ebfe347-bc77-4270-8213-b2021271c6b2';
const outputId = '113d0d73-3d39-4a98-ac0a-5c0e521d8998';
const editId = '68db226f-3fcb-4579-ab9e-4932b1d84067';
const source = await sharp({ create: { width: 600, height: 800, channels: 3, background: '#ad3222' } }).jpeg().toBuffer();
const reference = await sharp({ create: { width: 400, height: 400, channels: 4, background: '#2452af80' } }).png().toBuffer();
const generated = await sharp({ create: { width: 1152, height: 1536, channels: 3, background: '#2f72b7' } }).png().toBuffer();
const settings = createMediaAiEditRequestSchema.parse({ product_id: productId,
reference_asset_ids: [referenceId], prompt: '保留商品,使用明亮背景' });
const task = { status: 'queued', source_asset_id: sourceId, reference_asset_ids: [referenceId], settings,
provider_task_id: null, created_at: new Date() };
const outbox = [];
const stored = [];
const database = { workspaceTransaction: async (_workspaceId, work) => work({
query: async (sql, params = []) => {
if (sql.includes('SELECT status, source_asset_id, reference_asset_ids')) return { rows: [{ ...task }] };
if (sql.includes('SELECT id, storage_provider, bucket, object_key')) return { rows: [
{ id: sourceId, storage_provider: 'local_minio', bucket: 'atlas-assets', object_key: 'main.jpg' },
{ id: referenceId, storage_provider: 'local_minio', bucket: 'atlas-assets', object_key: 'reference.jpg' },
] };
if (sql.includes('SELECT status FROM atlas.media_ai_edits')) return { rows: [{ status: task.status }] };
if (sql.includes('SELECT id FROM atlas.media_assets WHERE checksum_sha256')) return { rows: [] };
if (sql.includes('INSERT INTO atlas.media_assets')) return { rows: [{ id: outputId }] };
if (sql.includes('INSERT INTO atlas.outbox_events')) outbox.push(sql);
if (sql.includes("SET status = 'running'")) task.status = 'running';
if (sql.includes('SET provider_task_id =')) task.provider_task_id = params[1];
if (sql.includes("SET status = 'succeeded'")) task.status = 'succeeded';
return { rows: [] };
},
}) };
const storage = { send: async (command) => {
if (command instanceof GetObjectCommand) return { Body: (async function* () {
yield command.input.Key === 'main.jpg' ? source : reference;
})() };
if (command instanceof PutObjectCommand) { stored.push(command.input); return {}; }
throw new Error('unexpected object storage command');
} };
const oldFetch = globalThis.fetch;
const oldBase = process.env.PCORE_BASE_URL;
const oldKey = process.env.PCORE_API_KEY;
process.env.PCORE_BASE_URL = 'https://pcore.ai/v1';
process.env.PCORE_API_KEY = 'test-key';
let requests = 0;
globalThis.fetch = async (url, init) => {
requests += 1;
if (init.method === 'POST') {
assert.equal(url, 'https://pcore.ai/v1/images/edits');
assert.equal(init.headers['Content-Type'], 'application/json');
const body = JSON.parse(init.body);
assert.equal(body.model, 'gpt-image-2');
assert.equal(body.size, '1152x1536');
assert.equal(body.n, 1);
assert.equal(body.async, true);
assert.equal(body.images.length, 2);
assert.deepEqual(body.images, [
{ image_url: `data:image/jpeg;base64,${source.toString('base64')}` },
{ image_url: `data:image/png;base64,${reference.toString('base64')}` },
]);
assert.equal(body.mask, undefined);
return Response.json({ task_id: 'provider-123' });
}
assert.equal(url, 'https://pcore.ai/v1/images/edits/provider-123');
return Response.json({ status: 'completed', data: [{ b64_json: generated.toString('base64') }] });
};
try {
const job = { workspaceId: '30cf54b7-06a3-4e5f-a5fc-d74165115094', payload: { edit_id: editId } };
await runMediaAiEdit(database, storage, job);
assert.equal(task.status, 'running');
assert.equal(task.provider_task_id, 'provider-123');
assert.equal(outbox.length, 1);
await runMediaAiEdit(database, storage, job);
assert.equal(task.status, 'succeeded');
assert.equal(stored.length, 1);
assert.equal(stored[0].ContentType, 'image/jpeg');
assert.equal(requests, 2);
} finally {
globalThis.fetch = oldFetch;
if (oldBase === undefined) delete process.env.PCORE_BASE_URL; else process.env.PCORE_BASE_URL = oldBase;
if (oldKey === undefined) delete process.env.PCORE_API_KEY; else process.env.PCORE_API_KEY = oldKey;
}
});
test('仅扩图以 JSON 图片和蒙版对象提交 Pcore', async () => {
const sourceId = '664d0d73-3d39-4a98-ac0a-5c0e521d8998';
const editId = '68db226f-3fcb-4579-ab9e-4932b1d84067';
const source = await sharp({ create: { width: 600, height: 600, channels: 3, background: '#ad3222' } }).jpeg().toBuffer();
const settings = createMediaAiEditRequestSchema.parse({ product_id: productId, mode: 'outpaint' });
const database = { workspaceTransaction: async (_workspaceId, work) => work({
query: async (sql) => {
if (sql.includes('SELECT status, source_asset_id, reference_asset_ids')) return { rows: [{
status: 'queued', source_asset_id: sourceId, reference_asset_ids: [], settings,
provider_task_id: null, created_at: new Date(),
}] };
if (sql.includes('SELECT id, storage_provider, bucket, object_key')) return { rows: [{
id: sourceId, storage_provider: 'local_minio', bucket: 'atlas-assets', object_key: 'main.jpg',
}] };
return { rows: [] };
},
}) };
const storage = { send: async () => ({ Body: (async function* () { yield source; })() }) };
const oldFetch = globalThis.fetch;
const oldBase = process.env.PCORE_BASE_URL;
const oldKey = process.env.PCORE_API_KEY;
process.env.PCORE_BASE_URL = 'https://pcore.ai';
process.env.PCORE_API_KEY = 'test-key';
globalThis.fetch = async (_url, init) => {
const body = JSON.parse(init.body);
assert.equal(init.headers['Content-Type'], 'application/json');
assert.equal(body.images.length, 1);
assert.match(body.images[0].image_url, /^data:image\/png;base64,/);
assert.match(body.mask.image_url, /^data:image\/png;base64,/);
const image = Buffer.from(body.images[0].image_url.split(',')[1], 'base64');
const mask = Buffer.from(body.mask.image_url.split(',')[1], 'base64');
assert.equal((await sharp(image).metadata()).width, 1152);
assert.equal((await sharp(mask).metadata()).height, 1536);
return Response.json({ task_id: 'provider-outpaint' });
};
try {
await runMediaAiEdit(database, storage, {
workspaceId: '30cf54b7-06a3-4e5f-a5fc-d74165115094', payload: { edit_id: editId },
});
} finally {
globalThis.fetch = oldFetch;
if (oldBase === undefined) delete process.env.PCORE_BASE_URL; else process.env.PCORE_BASE_URL = oldBase;
if (oldKey === undefined) delete process.env.PCORE_API_KEY; else process.env.PCORE_API_KEY = oldKey;
}
});
test('Pcore 终态失败不重复轮询,泛化错误不会误判成内容拒绝', async () => {
const sourceId = '664d0d73-3d39-4a98-ac0a-5c0e521d8998';
const editId = '68db226f-3fcb-4579-ab9e-4932b1d84067';
const settings = createMediaAiEditRequestSchema.parse({ product_id: productId });
let status = 'running';
let failureCode = null;
let providerErrorRaw = null;
let polls = 0;
let providerError = {
code: 'failed', message: '您的请求无法用于生成图像。该请求可能因安全政策被拦截。',
};
const database = { workspaceTransaction: async (_workspaceId, work) => work({
query: async (sql, params = []) => {
if (sql.includes('SELECT status, source_asset_id, reference_asset_ids')) return { rows: [{
status, source_asset_id: sourceId, reference_asset_ids: [], settings,
provider_task_id: 'provider-123', created_at: new Date(),
}] };
if (sql.includes('SELECT id, storage_provider, bucket, object_key')) return { rows: [{
id: sourceId, storage_provider: 'local_minio', bucket: 'atlas-assets', object_key: 'main.jpg',
}] };
if (sql.includes("SET status = 'failed'")) {
status = 'failed';
failureCode = params[1];
if (sql.includes('atlas.operations')) {
providerErrorRaw = params[6];
assert.equal(params[3], 'pcore');
assert.equal(params[4], 'provider_polling');
}
}
return { rows: [] };
},
}) };
const oldFetch = globalThis.fetch;
const oldBase = process.env.PCORE_BASE_URL;
const oldKey = process.env.PCORE_API_KEY;
process.env.PCORE_BASE_URL = 'https://pcore.ai';
process.env.PCORE_API_KEY = 'test-key';
globalThis.fetch = async (url) => {
polls += 1;
assert.equal(url, 'https://pcore.ai/v1/images/edits/provider-123');
return Response.json({ status: 'failed', error: providerError });
};
try {
const job = { workspaceId: '30cf54b7-06a3-4e5f-a5fc-d74165115094', payload: { edit_id: editId } };
await runMediaAiEdit(database, {}, job);
assert.equal(status, 'failed');
assert.equal(failureCode, 'AI_IMAGE_PROVIDER_FAILED');
assert.equal(providerErrorRaw, JSON.stringify(providerError));
assert.equal(polls, 1);
status = 'running';
providerError = { code: 'content_policy_violation', message: 'The image was blocked.' };
await runMediaAiEdit(database, {}, job);
assert.equal(failureCode, 'AI_IMAGE_CONTENT_REJECTED');
assert.equal(providerErrorRaw, JSON.stringify(providerError));
assert.equal(polls, 2);
} finally {
globalThis.fetch = oldFetch;
if (oldBase === undefined) delete process.env.PCORE_BASE_URL; else process.env.PCORE_BASE_URL = oldBase;
if (oldKey === undefined) delete process.env.PCORE_API_KEY; else process.env.PCORE_API_KEY = oldKey;
}
});
test('Pcore 提交时返回错误体会保留原始内容', async () => {
const sourceId = '664d0d73-3d39-4a98-ac0a-5c0e521d8998';
const editId = '68db226f-3fcb-4579-ab9e-4932b1d84067';
const source = await sharp({ create: { width: 600, height: 800, channels: 3, background: '#ad3222' } }).jpeg().toBuffer();
const settings = createMediaAiEditRequestSchema.parse({ product_id: productId });
const database = { workspaceTransaction: async (_workspaceId, work) => work({
query: async (sql) => {
if (sql.includes('SELECT status, source_asset_id, reference_asset_ids')) return { rows: [{
status: 'queued', source_asset_id: sourceId, reference_asset_ids: [], settings,
provider_task_id: null, created_at: new Date(),
}] };
if (sql.includes('SELECT id, storage_provider, bucket, object_key')) return { rows: [{
id: sourceId, storage_provider: 'local_minio', bucket: 'atlas-assets', object_key: 'main.jpg',
}] };
return { rows: [] };
},
}) };
const storage = { send: async () => ({ Body: (async function* () { yield source; })() }) };
const oldFetch = globalThis.fetch;
const oldBase = process.env.PCORE_BASE_URL;
const oldKey = process.env.PCORE_API_KEY;
process.env.PCORE_BASE_URL = 'https://pcore.ai';
process.env.PCORE_API_KEY = 'test-key';
const providerError = { code: 'invalid_request_error', message: '图像文件无效' };
globalThis.fetch = async () => Response.json({ status: 'failed', error: providerError });
try {
const job = { workspaceId: '30cf54b7-06a3-4e5f-a5fc-d74165115094', payload: { edit_id: editId } };
await assert.rejects(runMediaAiEdit(database, storage, job), (error) => {
assert.deepEqual(classifyAiEditFailure(error), {
code: 'AI_IMAGE_GENERATION_FAILED', source: 'pcore', stage: 'provider_submission',
detail: 'Pcore 图片任务请求被拒绝(invalid_request_error 图像文件无效)',
providerErrorRaw: JSON.stringify(providerError),
});
return true;
});
} finally {
globalThis.fetch = oldFetch;
if (oldBase === undefined) delete process.env.PCORE_BASE_URL; else process.env.PCORE_BASE_URL = oldBase;
if (oldKey === undefined) delete process.env.PCORE_API_KEY; else process.env.PCORE_API_KEY = oldKey;
}
});
test('失败诊断区分 Pcore 查询中断、Atlas 图片处理与保存失败', async () => {
const sourceId = '664d0d73-3d39-4a98-ac0a-5c0e521d8998';
const settings = createMediaAiEditRequestSchema.parse({ product_id: productId });
const database = { workspaceTransaction: async (_workspaceId, work) => work({
query: async (sql) => {
if (sql.includes('SELECT status, source_asset_id, reference_asset_ids')) return { rows: [{
status: 'running', source_asset_id: sourceId, reference_asset_ids: [], settings,
provider_task_id: 'provider-123', created_at: new Date(),
}] };
if (sql.includes('SELECT id, storage_provider, bucket, object_key')) return { rows: [{
id: sourceId, storage_provider: 'local_minio', bucket: 'atlas-assets', object_key: 'main.jpg',
}] };
return { rows: [] };
},
}) };
const job = { workspaceId: '30cf54b7-06a3-4e5f-a5fc-d74165115094',
payload: { edit_id: '68db226f-3fcb-4579-ab9e-4932b1d84067' } };
const oldFetch = globalThis.fetch;
const oldBase = process.env.PCORE_BASE_URL;
const oldKey = process.env.PCORE_API_KEY;
process.env.PCORE_BASE_URL = 'https://pcore.ai/v1';
process.env.PCORE_API_KEY = 'test-key';
try {
globalThis.fetch = async () => { throw new TypeError('fetch failed'); };
await assert.rejects(runMediaAiEdit(database, {}, job), (error) => {
assert.deepEqual(classifyAiEditFailure(error), {
code: 'AI_IMAGE_GENERATION_FAILED', source: 'pcore', stage: 'provider_polling',
detail: 'fetch failed', providerErrorRaw: null,
});
return true;
});
globalThis.fetch = async () => Response.json({ status: 'completed',
data: [{ b64_json: Buffer.from('invalid image').toString('base64') }] });
await assert.rejects(runMediaAiEdit(database, {}, job), (error) => {
const failure = classifyAiEditFailure(error);
assert.equal(failure.source, 'atlas');
assert.equal(failure.stage, 'result_processing');
assert.match(failure.detail, /image|format|buffer/i);
return true;
});
const generated = await sharp({ create: { width: 100, height: 100, channels: 3,
background: '#445566' } }).png().toBuffer();
globalThis.fetch = async () => Response.json({ status: 'completed',
data: [{ b64_json: generated.toString('base64') }] });
const storage = { send: async () => { throw new Error('private/object-key.jpg'); } };
await assert.rejects(runMediaAiEdit(database, storage, job), (error) => {
const failure = classifyAiEditFailure(error);
assert.equal(failure.source, 'atlas');
assert.equal(failure.stage, 'result_storage');
assert.equal(failure.detail, 'Error');
return true;
});
} finally {
globalThis.fetch = oldFetch;
if (oldBase === undefined) delete process.env.PCORE_BASE_URL; else process.env.PCORE_BASE_URL = oldBase;
if (oldKey === undefined) delete process.env.PCORE_API_KEY; else process.env.PCORE_API_KEY = oldKey;
}
});
@@ -0,0 +1,186 @@
import assert from 'node:assert/strict';
import { test } from 'node:test';
import sharp from 'sharp';
import { GetObjectCommand, PutObjectCommand } from '@aws-sdk/client-s3';
import { renderThreeFour, runAspectRatioDerivative } from '../src/media-aspect-ratio.ts';
const assertColorNear = (actual, expected) => {
for (let channel = 0; channel < 3; channel++)
assert.ok(Math.abs(actual[channel] - expected[channel]) <= 6, `${actual} 与 ${expected} 色差过大`);
};
const readPixels = async (output) => {
const { data, info } = await sharp(output).raw().toBuffer({ resolveWithObject: true });
assert.equal(info.width, 1200);
assert.equal(info.height, 1600);
assert.equal(info.channels, 3);
return (x, y) => [...data.subarray((y * info.width + x) * 3, (y * info.width + x) * 3 + 3)];
};
test('正方形商品图等比缩放后,上下扩充处使用红色边缘色', async () => {
const source = await sharp({
create: { width: 400, height: 400, channels: 3, background: '#ff0000' },
}).png().toBuffer();
const pixel = await readPixels(await renderThreeFour(source));
assertColorNear(pixel(600, 0), [255, 0, 0]);
assertColorNear(pixel(600, 800), [255, 0, 0]);
assertColorNear(pixel(600, 1599), [255, 0, 0]);
});
test('上下边缘颜色不同,各自填充对应的一侧', async () => {
const source = await sharp({
create: { width: 400, height: 400, channels: 3, background: '#0000ff' },
}).composite([
{ input: { create: { width: 400, height: 48, channels: 3, background: '#ff0000' } }, left: 0, top: 0 },
{ input: { create: { width: 400, height: 48, channels: 3, background: '#808080' } }, left: 0, top: 352 },
]).png().toBuffer();
const pixel = await readPixels(await renderThreeFour(source));
assertColorNear(pixel(600, 0), [255, 0, 0]);
assertColorNear(pixel(600, 800), [0, 0, 255]);
assertColorNear(pixel(600, 1599), [128, 128, 128]);
});
test('同一边缘有多种颜色时,接缝相接且远处不形成竖向条纹', async () => {
const source = await sharp({
create: { width: 400, height: 400, channels: 3, background: '#808080' },
}).composite([
{ input: { create: { width: 200, height: 48, channels: 3, background: '#ff0000' } }, left: 0, top: 0 },
{ input: { create: { width: 200, height: 48, channels: 3, background: '#0000ff' } }, left: 200, top: 0 },
{ input: { create: { width: 200, height: 48, channels: 3, background: '#ffff00' } }, left: 0, top: 352 },
{ input: { create: { width: 200, height: 48, channels: 3, background: '#00ff00' } }, left: 200, top: 352 },
]).png().toBuffer();
const pixel = await readPixels(await renderThreeFour(source));
for (const [x, topColor, bottomColor] of [
[300, [255, 0, 0], [255, 255, 0]],
[900, [0, 0, 255], [0, 255, 0]],
]) {
assertColorNear(pixel(x, 199), topColor);
assertColorNear(pixel(x, 199), pixel(x, 200));
assertColorNear(pixel(x, 1400), pixel(x, 1399));
assertColorNear(pixel(x, 1400), bottomColor);
}
assertColorNear(pixel(300, 0), pixel(900, 0));
assertColorNear(pixel(300, 1599), pixel(900, 1599));
assertColorNear(pixel(300, 0), [128, 0, 128]);
assertColorNear(pixel(300, 1599), [128, 255, 0]);
});
test('竖长图片左右扩充处分别使用左绿右黄的边缘色', async () => {
const source = await sharp({
create: { width: 300, height: 800, channels: 3, background: '#0000ff' },
}).composite([
{ input: { create: { width: 36, height: 800, channels: 3, background: '#00ff00' } }, left: 0, top: 0 },
{ input: { create: { width: 36, height: 800, channels: 3, background: '#ffff00' } }, left: 264, top: 0 },
]).png().toBuffer();
const pixel = await readPixels(await renderThreeFour(source));
assertColorNear(pixel(0, 800), [0, 255, 0]);
assertColorNear(pixel(600, 800), [0, 0, 255]);
assertColorNear(pixel(1199, 800), [255, 255, 0]);
});
test('左右边缘沿高度变色时,接缝相接且远处不形成横向条纹', async () => {
const source = await sharp({
create: { width: 300, height: 800, channels: 3, background: '#808080' },
}).composite([
{ input: { create: { width: 36, height: 400, channels: 3, background: '#00ff00' } }, left: 0, top: 0 },
{ input: { create: { width: 36, height: 400, channels: 3, background: '#0000ff' } }, left: 0, top: 400 },
{ input: { create: { width: 36, height: 400, channels: 3, background: '#ffff00' } }, left: 264, top: 0 },
{ input: { create: { width: 36, height: 400, channels: 3, background: '#ff00ff' } }, left: 264, top: 400 },
]).png().toBuffer();
const pixel = await readPixels(await renderThreeFour(source));
for (const [y, leftColor, rightColor] of [
[400, [0, 255, 0], [255, 255, 0]],
[1200, [0, 0, 255], [255, 0, 255]],
]) {
assertColorNear(pixel(299, y), leftColor);
assertColorNear(pixel(299, y), pixel(300, y));
assertColorNear(pixel(900, y), pixel(899, y));
assertColorNear(pixel(900, y), rightColor);
}
assertColorNear(pixel(0, 400), pixel(0, 1200));
assertColorNear(pixel(1199, 400), pixel(1199, 1200));
assertColorNear(pixel(0, 400), [0, 128, 128]);
assertColorNear(pixel(1199, 400), [255, 128, 128]);
});
test('透明边缘以白底取色', async () => {
const source = await sharp({
create: { width: 400, height: 400, channels: 4, background: '#00000000' },
}).composite([{ input: {
create: { width: 200, height: 200, channels: 3, background: '#ff0000' },
}, left: 100, top: 100 }]).png().toBuffer();
const pixel = await readPixels(await renderThreeFour(source));
assertColorNear(pixel(600, 0), [255, 255, 255]);
assertColorNear(pixel(600, 800), [255, 0, 0]);
assertColorNear(pixel(600, 1599), [255, 255, 255]);
});
test('取色和留白遵循 EXIF 旋转后的显示方向', async () => {
const source = await sharp({
create: { width: 400, height: 200, channels: 3, background: '#0000ff' },
}).composite([
{ input: { create: { width: 400, height: 24, channels: 3, background: '#ff0000' } }, left: 0, top: 0 },
{ input: { create: { width: 400, height: 24, channels: 3, background: '#808080' } }, left: 0, top: 176 },
]).jpeg({ quality: 100 }).withMetadata({ orientation: 6 }).toBuffer();
const pixel = await readPixels(await renderThreeFour(source));
assertColorNear(pixel(0, 800), [128, 128, 128]);
assertColorNear(pixel(1199, 800), [255, 0, 0]);
});
test('拒绝不受支持的来源格式', async () => {
const source = await sharp({
create: { width: 8, height: 8, channels: 3, background: '#ff0000' },
}).gif().toBuffer();
await assert.rejects(renderThreeFour(source), /格式不受支持/);
});
test('任务读取原图、写入独立派生素材,并完成持久化状态', async () => {
const source = await sharp({
create: { width: 400, height: 400, channels: 3, background: '#ff0000' },
}).png().toBuffer();
const writes = [];
const storage = { send: async (command) => {
if (command instanceof GetObjectCommand) return {
Body: (async function* () { yield source; })(),
};
if (command instanceof PutObjectCommand) {
writes.push(command.input);
return {};
}
throw new Error('未知存储操作');
} };
let status = 'queued';
let outputAssetId = null;
const queries = [];
const database = { workspaceTransaction: async (_workspaceId, work) => work({
query: async (sql, args) => {
queries.push({ sql, args });
if (sql.includes('SELECT d.status')) return { rows: [{
status,
storage_provider: 'local_minio',
bucket: 'test-bucket',
object_key: 'original.png',
}] };
if (sql.includes("SET status = 'running'")) status = 'running';
if (sql.includes("SET status = 'succeeded'")) status = 'succeeded';
if (sql.includes('SELECT id FROM atlas.media_assets')) return { rows: [] };
if (sql.includes('INSERT INTO atlas.media_assets')) return { rows: [{ id: 'output-asset' }] };
if (sql.includes("result = jsonb_build_object")) outputAssetId = args[1];
return { rows: [] };
},
}) };
const job = {
workspaceId: 'workspace-id',
payload: { derivative_id: 'derivative-id' },
};
await runAspectRatioDerivative(database, storage, job);
assert.equal(status, 'succeeded');
assert.equal(outputAssetId, 'output-asset');
assert.equal(writes.length, 1);
assert.equal(writes[0].Bucket, 'test-bucket');
assert.match(writes[0].Key, /^derivatives\/workspace-id\/three-four\/[a-f0-9]{64}\.jpg$/);
assert.deepEqual(await sharp(writes[0].Body).metadata().then(({ width, height }) => [width, height]), [1200, 1600]);
await runAspectRatioDerivative(database, storage, job);
assert.equal(writes.length, 1);
assert.ok(queries.some(({ sql }) => sql.includes('UPDATE atlas.operations')));
});
+62
View File
@@ -0,0 +1,62 @@
import assert from 'node:assert/strict';
import { test } from 'node:test';
import sharp from 'sharp';
import { renderWatermark } from '../src/media-watermark.ts';
const template = {
kind: 'image', opacity: '1', image_size_px: 40, border_radius_px: 0,
position_x: 1, position_y: 1, position: 'bottom_right',
};
const pixel = async (buffer, x, y) => {
const { data, info } = await sharp(buffer).raw().toBuffer({ resolveWithObject: true });
return [...data.subarray((y * info.width + x) * info.channels,
(y * info.width + x) * info.channels + 3)];
};
test('图片水印按坐标贴近右下边,并保持源图尺寸', async () => {
const source = await sharp({ create: { width: 120, height: 100, channels: 3, background: '#ffffff' } }).png().toBuffer();
const mark = await sharp({ create: { width: 40, height: 40, channels: 4, background: '#ff0000ff' } }).png().toBuffer();
const output = await renderWatermark(source, template, mark);
assert.equal(output.width, 120);
assert.equal(output.height, 100);
assert.ok((await pixel(output.bytes, 90, 70))[0] > 200);
assert.ok((await pixel(output.bytes, 90, 70))[1] < 60);
assert.ok((await pixel(output.bytes, 15, 15))[1] > 240);
});
test('大图中的水印大小和底边距与 240×320 管理预览等比一致', async () => {
const source = await sharp({ create: { width: 1200, height: 1600, channels: 3, background: '#ffffff' } }).png().toBuffer();
const mark = await sharp({ create: { width: 80, height: 80, channels: 4, background: '#ff0000ff' } }).png().toBuffer();
const output = await renderWatermark(source, { ...template, image_size_px: 80 }, mark);
assert.ok((await pixel(output.bytes, 800, 1200))[1] < 60);
assert.ok((await pixel(output.bytes, 700, 1200))[1] > 240);
assert.ok((await pixel(output.bytes, 1100, 1500))[1] < 60);
assert.ok((await pixel(output.bytes, 1100, 1585))[1] > 240);
});
test('图片水印允许 10px 最小尺寸', async () => {
const source = await sharp({ create: { width: 240, height: 320, channels: 3, background: '#ffffff' } }).png().toBuffer();
const mark = await sharp({ create: { width: 20, height: 20, channels: 4, background: '#ff0000ff' } }).png().toBuffer();
const output = await renderWatermark(source, { ...template, image_size_px: 10 }, mark);
assert.ok((await pixel(output.bytes, 227, 307))[1] < 80);
assert.ok((await pixel(output.bytes, 217, 297))[1] > 220);
});
test('圆角使图片水印角落保持底图颜色', async () => {
const source = await sharp({ create: { width: 120, height: 100, channels: 3, background: '#ffffff' } }).png().toBuffer();
const mark = await sharp({ create: { width: 40, height: 40, channels: 4, background: '#ff0000ff' } }).png().toBuffer();
const output = await renderWatermark(source, { ...template, border_radius_px: 18 }, mark);
assert.ok((await pixel(output.bytes, 76, 56))[1] > 200);
assert.ok((await pixel(output.bytes, 95, 75))[1] < 60);
});
test('文字水印支持 XML 特殊字符,不会把内容当成 SVG', async () => {
const source = await sharp({ create: { width: 200, height: 100, channels: 3, background: '#ffffff' } }).png().toBuffer();
const output = await renderWatermark(source, {
...template, kind: 'text', text_content: '<ATLAS & 1>', font_code: 'NotoSans', font_size_px: 24,
position_x: 0, position_y: 0,
});
const { data } = await sharp(output.bytes).raw().toBuffer({ resolveWithObject: true });
assert.ok(data.some((value) => value < 100));
});
@@ -0,0 +1,63 @@
import assert from 'node:assert/strict';
import { test } from 'node:test';
import {
mapOzonProduct,
parseOzonInfoItems,
parseOzonListPage,
} from '../src/online-product-mapper.ts';
test('列表游标与商品标识按平台响应读取,缺失标识时拒绝继续同步', () => {
const page = parseOzonListPage({
result: {
items: [{ product_id: 123, offer_id: 'offer-1' }],
last_id: 'next-page',
},
});
assert.deepEqual(page, {
items: [{ product_id: '123', offer_id: 'offer-1' }],
lastId: 'next-page',
});
assert.throws(
() => parseOzonListPage({ result: { items: [{ product_id: 123 }] } }),
/缺少商品标识/,
);
assert.throws(() => parseOzonInfoItems({ result: {} }), /响应格式异常/);
});
test('可见性未知、库存零和审核错误保留各自语义', () => {
const product = mapOzonProduct(
{
id: 123,
offer_id: 'offer-1',
name: '商品',
price: '999.00',
old_price: '1110.00',
stocks: { present: 0 },
status: {
moderate_status: 'declined',
validation_state: 'failed',
decline_reasons: [{ message: '图片不符合要求' }],
},
images: [{ file_name: 'https://example.com/product.jpg' }],
sources: [{ sku: 456 }],
},
{ product_id: '123', offer_id: 'offer-1' },
);
assert.equal(product.visible, null);
assert.deepEqual(product.stock_summary, { total: 0, has_data: true });
assert.equal(product.moderation_status, 'declined');
assert.equal(product.remote_sku, '456');
assert.equal(product.image_summary.url, 'https://example.com/product.jpg');
assert.equal(product.error_payload[0].message, '图片不符合要求');
assert.equal(product.selling_price, '999.00');
});
test('不存在库存数据和非 HTTPS 图片不能伪装成有效平台数据', () => {
const product = mapOzonProduct(
{ id: 123, price: 'abc', primary_image: 'http://example.com/a.jpg' },
{ product_id: '123', offer_id: 'offer-1' },
);
assert.deepEqual(product.stock_summary, { total: 0, has_data: false });
assert.equal(product.image_summary.url, null);
assert.equal(product.selling_price, null);
});
@@ -0,0 +1,304 @@
import assert from 'node:assert/strict';
import {
createCipheriv,
createHash,
randomBytes,
randomUUID,
} from 'node:crypto';
import { test } from 'node:test';
import { createApplicationDatabase } from '@atlas/database';
import {
archiveExpiredMissingOnlineProducts,
markOnlineProductSyncFailed,
runOnlineProductSync,
} from '../src/online-product-sync.ts';
const databaseUrl = process.env.DATABASE_URL;
const encryptionKey = process.env.STORE_SECRET_ENCRYPTION_KEY;
test(
'全量同步写入镜像,连续未见归档,再出现恢复且租户隔离',
{ skip: !databaseUrl || !encryptionKey },
async () => {
const database = createApplicationDatabase(databaseUrl);
const workspaceId = randomUUID();
const storeId = randomUUID();
const secretId = randomUUID();
const platform = await database.query(
"SELECT id FROM atlas.platforms WHERE code = 'OZON' LIMIT 1",
);
assert.ok(platform.rows[0]);
const platformId = platform.rows[0].id;
const originalFetch = globalThis.fetch;
let productPresent = true;
let failList = false;
let remoteProductId = 93847265;
let failAfterFirstPage = false;
globalThis.fetch = async (url, init) => {
if (String(url).endsWith('/v3/product/list')) {
if (failList) return new Response('{}', { status: 503 });
const cursor = JSON.parse(init.body).last_id;
if (failAfterFirstPage && cursor)
return new Response('{}', { status: 503 });
return Response.json({
result: {
items: productPresent
? [
{
product_id: remoteProductId,
offer_id: 'sync-test-offer',
},
]
: [],
last_id: failAfterFirstPage ? 'second-page' : '',
},
});
}
if (String(url).endsWith('/v3/product/info/list'))
return Response.json({
result: {
items: [
{
id: remoteProductId,
offer_id: 'sync-test-offer',
name: '同步测试商品',
price: '99.00',
old_price: '109.00',
visible: true,
stocks: { present: 0 },
sources: [{ sku: 987654 }],
status: {
moderate_status: 'approved',
validation_state: 'success',
},
},
],
},
});
throw new Error('Unexpected Ozon path');
};
const key = createHash('sha256').update(encryptionKey).digest();
const nonce = randomBytes(12);
const cipher = createCipheriv('aes-256-gcm', key, nonce);
const ciphertext = Buffer.concat([
cipher.update('test-key', 'utf8'),
cipher.final(),
cipher.getAuthTag(),
]);
try {
await database.query(
'INSERT INTO atlas.workspaces (id, name, slug) VALUES ($1, $2, $3)',
[workspaceId, '在线商品同步测试', `online-sync-${workspaceId}`],
);
await database.workspaceTransaction(workspaceId, async (tx) => {
await tx.query(
`INSERT INTO atlas.stores (id, workspace_id, platform_id, name, platform_client_id, currency_code, is_default, status, credential_status)
VALUES ($1, $2, $3, '测试门店', $4, 'RUB', true, 'active', 'valid')`,
[
storeId,
workspaceId,
platformId,
`online-sync-${storeId}`,
],
);
await tx.query(
`INSERT INTO atlas.store_secrets (id, workspace_id, store_id, kind, secret_ciphertext, secret_nonce, encryption_key_version, fingerprint, status)
VALUES ($1, $2, $3, 'seller_api_key', $4, $5, 'v1', $6, 'valid')`,
[
secretId,
workspaceId,
storeId,
ciphertext,
nonce,
randomUUID(),
],
);
});
const createJob = async () => {
const created = await database.workspaceTransaction(
workspaceId,
(tx) =>
tx.query(
`INSERT INTO atlas.platform_product_sync_runs
(workspace_id, store_id, platform_id, trigger_source) VALUES ($1, $2, $3, 'manual') RETURNING id`,
[workspaceId, storeId, platformId],
),
);
const runId = created.rows[0].id;
const job = {
eventId: randomUUID(),
workspaceId,
eventType: 'platform_product_sync.requested',
payload: {
sync_run_id: runId,
store_id: storeId,
platform_id: platformId,
secret_id: secretId,
},
};
return { runId, job };
};
const sync = async () => {
const { runId, job } = await createJob();
if (failList) {
let failure;
try {
await runOnlineProductSync(database, job);
} catch (error) {
failure = error;
}
assert.ok(failure);
await markOnlineProductSyncFailed(database, job, failure);
await runOnlineProductSync(database, job);
} else await runOnlineProductSync(database, job);
const run = await database.workspaceTransaction(
workspaceId,
(tx) =>
tx.query(
'SELECT status, listed_count, upserted_count FROM atlas.platform_product_sync_runs WHERE id = $1',
[runId],
),
);
assert.equal(
run.rows[0].status,
failList ? 'failed' : 'succeeded',
);
return run.rows[0];
};
const first = await sync();
assert.equal(first.listed_count, 1);
assert.equal(first.upserted_count, 1);
const listing = async () =>
database.workspaceTransaction(workspaceId, (tx) =>
tx.query(
'SELECT id, remote_product_id, name, visible, stock_summary, missing_since, archived_at FROM atlas.platform_listings WHERE store_id = $1',
[storeId],
),
);
let row = (await listing()).rows[0];
assert.equal(row.name, '同步测试商品');
assert.equal(row.visible, true);
assert.deepEqual(row.stock_summary, { total: 0, has_data: true });
const listingId = row.id;
remoteProductId = 93847266;
await sync();
row = (await listing()).rows[0];
assert.equal(row.id, listingId);
assert.equal(row.remote_product_id, String(remoteProductId));
assert.equal((await listing()).rows.length, 1);
failList = true;
await sync();
row = (await listing()).rows[0];
assert.equal(row.missing_since, null);
assert.equal(row.archived_at, null);
failList = false;
productPresent = false;
await sync();
row = (await listing()).rows[0];
assert.ok(row.missing_since);
assert.equal(row.archived_at, null);
await database.workspaceTransaction(workspaceId, (tx) =>
tx.query(
`UPDATE atlas.platform_listings SET missing_since = now() - interval '8 days' WHERE store_id = $1`,
[storeId],
),
);
const activeRun = await database.workspaceTransaction(
workspaceId,
(tx) =>
tx.query(
`INSERT INTO atlas.platform_product_sync_runs (workspace_id, store_id, platform_id, trigger_source)
VALUES ($1, $2, $3, 'manual') RETURNING id`,
[workspaceId, storeId, platformId],
),
);
await archiveExpiredMissingOnlineProducts(database, workspaceId);
assert.equal((await listing()).rows[0].archived_at, null);
await database.workspaceTransaction(workspaceId, (tx) =>
tx.query(
`DELETE FROM atlas.platform_product_sync_runs WHERE id = $1`,
[activeRun.rows[0].id],
),
);
await archiveExpiredMissingOnlineProducts(database, workspaceId);
row = (await listing()).rows[0];
assert.ok(row.archived_at);
productPresent = true;
await sync();
row = (await listing()).rows[0];
assert.equal(row.missing_since, null);
assert.equal(row.archived_at, null);
productPresent = false;
await sync();
row = (await listing()).rows[0];
assert.ok(row.missing_since);
assert.equal(row.archived_at, null);
await sync();
row = (await listing()).rows[0];
assert.ok(row.archived_at);
productPresent = true;
await sync();
row = (await listing()).rows[0];
assert.equal(row.missing_since, null);
assert.equal(row.archived_at, null);
productPresent = false;
await sync();
const missingSinceBeforeRetry = (await listing()).rows[0].missing_since;
assert.ok(missingSinceBeforeRetry);
productPresent = true;
const { job: retryJob } = await createJob();
failAfterFirstPage = true;
await assert.rejects(runOnlineProductSync(database, retryJob));
row = (await listing()).rows[0];
assert.equal(row.missing_since.toISOString(), missingSinceBeforeRetry.toISOString());
assert.equal(row.archived_at, null);
failAfterFirstPage = false;
productPresent = false;
await runOnlineProductSync(database, retryJob);
row = (await listing()).rows[0];
assert.ok(row.missing_since);
assert.ok(row.archived_at);
const otherWorkspace = await database.workspaceTransaction(
randomUUID(),
(tx) =>
tx.query(
'SELECT id FROM atlas.platform_listings WHERE store_id = $1',
[storeId],
),
);
assert.equal(otherWorkspace.rows.length, 0);
} finally {
globalThis.fetch = originalFetch;
await database
.workspaceTransaction(workspaceId, async (tx) => {
await tx.query(
'DELETE FROM atlas.platform_listing_sync_snapshots WHERE workspace_id = $1',
[workspaceId],
);
await tx.query(
'DELETE FROM atlas.platform_listings WHERE workspace_id = $1',
[workspaceId],
);
await tx.query(
'DELETE FROM atlas.platform_product_sync_runs WHERE workspace_id = $1',
[workspaceId],
);
await tx.query(
'DELETE FROM atlas.store_secrets WHERE workspace_id = $1',
[workspaceId],
);
await tx.query(
'DELETE FROM atlas.stores WHERE workspace_id = $1',
[workspaceId],
);
})
.catch(() => {});
await database
.query('DELETE FROM atlas.workspaces WHERE id = $1', [
workspaceId,
])
.catch(() => {});
await database.close();
}
},
);