feat: 完成 SKU 图片选择及素材导入后端
This commit is contained in:
@@ -0,0 +1,100 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { MediaService } from '../src/media/media.service.js';
|
||||
|
||||
vi.mock('../src/media/product-image-download.js', async (importOriginal) => {
|
||||
const original =
|
||||
await importOriginal<
|
||||
typeof import('../src/media/product-image-download.js')
|
||||
>();
|
||||
return {
|
||||
...original,
|
||||
downloadProductImage: vi.fn(async () => ({
|
||||
bytes: Buffer.from('validated-image'),
|
||||
contentType: 'image/png',
|
||||
width: 800,
|
||||
height: 800,
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
describe('网络商品图片导入', () => {
|
||||
it('复用同工作空间素材并返回正式 ID 与短时预览地址', async () => {
|
||||
const assetId = '7c910684-7070-4f36-8a7b-b10bd91acd79';
|
||||
const bytes = Buffer.from('validated-image');
|
||||
const checksum = createHash('sha256').update(bytes).digest('hex');
|
||||
const query = vi.fn(async (sql: string, args?: unknown[]) => {
|
||||
if (sql.includes('WHERE checksum_sha256 = $1')) {
|
||||
expect(args).toEqual([checksum]);
|
||||
return { rows: [{ id: assetId }] };
|
||||
}
|
||||
if (sql.includes('INSERT INTO atlas.product_image_imports')) {
|
||||
expect(args?.[0]).toBe('https://example.com/photo.png');
|
||||
expect(args?.[1]).toBe(assetId);
|
||||
return { rows: [] };
|
||||
}
|
||||
if (sql.includes('SELECT id, content_type')) {
|
||||
return {
|
||||
rows: [
|
||||
{
|
||||
id: assetId,
|
||||
content_type: 'image/png',
|
||||
byte_size: '15',
|
||||
width_px: 800,
|
||||
height_px: 800,
|
||||
created_at: new Date('2026-09-24T00:00:00Z'),
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
if (sql.includes('SELECT bucket, object_key')) {
|
||||
return {
|
||||
rows: [
|
||||
{
|
||||
bucket: 'test',
|
||||
object_key: 'product-imports/existing.png',
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
throw new Error(`未知 SQL: ${sql}`);
|
||||
});
|
||||
const run = vi.fn(
|
||||
async (
|
||||
request: { path: string; key: string },
|
||||
callback: (tx: { query: typeof query }) => Promise<unknown>,
|
||||
) => {
|
||||
expect(request.path).toBe('/api/media-assets/product-imports');
|
||||
expect(request.key).toBe('idempotency-key');
|
||||
return callback({ query });
|
||||
},
|
||||
);
|
||||
const service = new MediaService(
|
||||
{ run } as unknown as ConstructorParameters<typeof MediaService>[0],
|
||||
{} as ConstructorParameters<typeof MediaService>[1],
|
||||
{
|
||||
OBJECT_STORAGE_ENDPOINT: 'http://127.0.0.1:9000',
|
||||
OBJECT_STORAGE_BUCKET: 'test',
|
||||
objectStorageForcePathStyle: true,
|
||||
objectStorageAccessKey: 'test',
|
||||
objectStorageSecretKey: 'test',
|
||||
} as ConstructorParameters<typeof MediaService>[2],
|
||||
);
|
||||
const result = await service.importProductImage(
|
||||
'workspace-id',
|
||||
'user-id',
|
||||
'idempotency-key',
|
||||
{ url: 'https://example.com/photo.png' },
|
||||
);
|
||||
const body = result.body as {
|
||||
data: { id: string; source_url: string; preview_url: string };
|
||||
};
|
||||
expect(result.status).toBe(201);
|
||||
expect(body.data).toMatchObject({
|
||||
id: assetId,
|
||||
source_url: 'https://example.com/photo.png',
|
||||
});
|
||||
expect(body.data.preview_url).toContain('product-imports/existing.png');
|
||||
expect(query).toHaveBeenCalledTimes(4);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,228 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import { GetObjectCommand, HeadObjectCommand } from '@aws-sdk/client-s3';
|
||||
import {
|
||||
createMediaUploadIntentRequestSchema,
|
||||
importProductImageRequestSchema,
|
||||
} from '@atlas/contracts';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { MediaService } from '../src/media/media.service.js';
|
||||
import {
|
||||
downloadProductImage,
|
||||
publicIpv4,
|
||||
} from '../src/media/product-image-download.js';
|
||||
|
||||
function serviceWithObject(bytes: Buffer) {
|
||||
const service = new MediaService(
|
||||
{} as ConstructorParameters<typeof MediaService>[0],
|
||||
{} as ConstructorParameters<typeof MediaService>[1],
|
||||
{
|
||||
OBJECT_STORAGE_ENDPOINT: 'http://127.0.0.1:9000',
|
||||
OBJECT_STORAGE_BUCKET: 'test',
|
||||
objectStorageForcePathStyle: true,
|
||||
objectStorageAccessKey: 'test',
|
||||
objectStorageSecretKey: 'test',
|
||||
} as ConstructorParameters<typeof MediaService>[2],
|
||||
);
|
||||
const send = vi.fn(async (command: unknown) => {
|
||||
if (command instanceof HeadObjectCommand) {
|
||||
return { ContentLength: bytes.length, ContentType: 'image/png' };
|
||||
}
|
||||
if (command instanceof GetObjectCommand) {
|
||||
return {
|
||||
Body: (async function* () {
|
||||
yield bytes;
|
||||
})(),
|
||||
};
|
||||
}
|
||||
throw new Error('未知 S3 命令');
|
||||
});
|
||||
Object.assign(service, { s3: { send } });
|
||||
return service as unknown as {
|
||||
inspectObject: (intent: Record<string, unknown>) => Promise<{
|
||||
width: number;
|
||||
height: number;
|
||||
checksum: string;
|
||||
}>;
|
||||
};
|
||||
}
|
||||
|
||||
function png(width: number, height: number) {
|
||||
const bytes = Buffer.alloc(24);
|
||||
Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]).copy(bytes);
|
||||
bytes.writeUInt32BE(width, 16);
|
||||
bytes.writeUInt32BE(height, 20);
|
||||
return bytes;
|
||||
}
|
||||
|
||||
describe('商品图片上传策略', () => {
|
||||
it('创建商品图片上传意图时持久化用途并签发独立对象路径', async () => {
|
||||
const query = vi.fn(async (_sql: string, _params?: unknown[]) => ({
|
||||
rows: [],
|
||||
}));
|
||||
const run = vi.fn(
|
||||
async (
|
||||
_request: unknown,
|
||||
callback: (tx: { query: typeof query }) => Promise<unknown>,
|
||||
) => callback({ query }),
|
||||
);
|
||||
const service = new MediaService(
|
||||
{ run } as unknown as ConstructorParameters<typeof MediaService>[0],
|
||||
{} as ConstructorParameters<typeof MediaService>[1],
|
||||
{
|
||||
OBJECT_STORAGE_ENDPOINT: 'http://127.0.0.1:9000',
|
||||
OBJECT_STORAGE_BUCKET: 'test',
|
||||
objectStorageForcePathStyle: true,
|
||||
objectStorageAccessKey: 'test',
|
||||
objectStorageSecretKey: 'test',
|
||||
} as ConstructorParameters<typeof MediaService>[2],
|
||||
);
|
||||
Object.assign(service, { ensureBucket: async () => undefined });
|
||||
const result = await service.createIntent(
|
||||
'workspace-id',
|
||||
'user-id',
|
||||
'key',
|
||||
{
|
||||
purpose: 'product_image',
|
||||
filename: 'sku.png',
|
||||
content_type: 'image/png',
|
||||
byte_size: 1024,
|
||||
checksum_sha256: 'a'.repeat(64),
|
||||
},
|
||||
);
|
||||
const args = query.mock.calls[0]?.[1] as unknown[];
|
||||
expect(args[1]).toBe('product_image');
|
||||
expect(args[3]).toContain('/product-images/');
|
||||
const body = result.body as { data: { upload: { url: string } } };
|
||||
expect(body.data.upload.url).toContain('product-images');
|
||||
});
|
||||
|
||||
it('商品图允许 20 MiB,水印图仍限制 5 MiB', () => {
|
||||
const request = {
|
||||
filename: 'sku.png',
|
||||
content_type: 'image/png',
|
||||
byte_size: 6 * 1024 * 1024,
|
||||
checksum_sha256: 'a'.repeat(64),
|
||||
};
|
||||
expect(
|
||||
createMediaUploadIntentRequestSchema.safeParse({
|
||||
...request,
|
||||
purpose: 'product_image',
|
||||
}).success,
|
||||
).toBe(true);
|
||||
expect(
|
||||
createMediaUploadIntentRequestSchema.safeParse({
|
||||
...request,
|
||||
purpose: 'watermark_image',
|
||||
}).success,
|
||||
).toBe(false);
|
||||
expect(
|
||||
createMediaUploadIntentRequestSchema.safeParse({
|
||||
...request,
|
||||
purpose: 'product_image',
|
||||
byte_size: 21 * 1024 * 1024,
|
||||
}).success,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('商品图校验接受 9000px,水印图仍拒绝超过 8192px', async () => {
|
||||
const bytes = png(9000, 9000);
|
||||
const service = serviceWithObject(bytes);
|
||||
const intent = {
|
||||
bucket: 'test',
|
||||
object_key: 'product.png',
|
||||
expected_content_type: 'image/png',
|
||||
purpose: 'product_image',
|
||||
};
|
||||
await expect(service.inspectObject(intent)).resolves.toMatchObject({
|
||||
width: 9000,
|
||||
height: 9000,
|
||||
checksum: createHash('sha256').update(bytes).digest('hex'),
|
||||
});
|
||||
await expect(
|
||||
service.inspectObject({ ...intent, purpose: 'watermark_image' }),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('上传图片列表仅查询当前工作空间已完成的商品素材', async () => {
|
||||
const query = vi.fn(async (sql: string) => {
|
||||
expect(sql).toContain("i.purpose = 'product_image'");
|
||||
expect(sql).toContain("i.status = 'ready'");
|
||||
return {
|
||||
rows: [
|
||||
{
|
||||
id: '7c910684-7070-4f36-8a7b-b10bd91acd79',
|
||||
content_type: 'image/png',
|
||||
byte_size: '24',
|
||||
width_px: 800,
|
||||
height_px: 800,
|
||||
created_at: new Date('2026-09-24T00:00:00Z'),
|
||||
bucket: 'test',
|
||||
object_key: 'uploads/test.png',
|
||||
},
|
||||
],
|
||||
};
|
||||
});
|
||||
const workspaceTransaction = vi.fn(
|
||||
async (
|
||||
workspaceId: string,
|
||||
callback: (tx: { query: typeof query }) => Promise<unknown>,
|
||||
) => {
|
||||
expect(workspaceId).toBe('workspace-id');
|
||||
return callback({ query });
|
||||
},
|
||||
);
|
||||
const service = new MediaService(
|
||||
{} as ConstructorParameters<typeof MediaService>[0],
|
||||
{ workspaceTransaction } as unknown as ConstructorParameters<
|
||||
typeof MediaService
|
||||
>[1],
|
||||
{
|
||||
OBJECT_STORAGE_ENDPOINT: 'http://127.0.0.1:9000',
|
||||
OBJECT_STORAGE_BUCKET: 'test',
|
||||
objectStorageForcePathStyle: true,
|
||||
objectStorageAccessKey: 'test',
|
||||
objectStorageSecretKey: 'test',
|
||||
} as ConstructorParameters<typeof MediaService>[2],
|
||||
);
|
||||
const uploads = await service.productUploads('workspace-id');
|
||||
expect(uploads).toHaveLength(1);
|
||||
expect(uploads[0]).toMatchObject({
|
||||
id: '7c910684-7070-4f36-8a7b-b10bd91acd79',
|
||||
byte_size: 24,
|
||||
});
|
||||
expect(uploads[0].preview_url).toContain('uploads/test.png');
|
||||
});
|
||||
|
||||
it('网络导入只允许 HTTPS 图片链接,拒绝内网地址', async () => {
|
||||
expect(
|
||||
importProductImageRequestSchema.safeParse({
|
||||
url: 'https://example.com/image.jpg',
|
||||
}).success,
|
||||
).toBe(true);
|
||||
expect(
|
||||
importProductImageRequestSchema.safeParse({
|
||||
url: 'http://example.com/image.jpg',
|
||||
}).success,
|
||||
).toBe(false);
|
||||
expect(
|
||||
importProductImageRequestSchema.safeParse({
|
||||
url: 'https://example.com/image.svg',
|
||||
}).success,
|
||||
).toBe(false);
|
||||
expect(publicIpv4('8.8.8.8')).toBe(true);
|
||||
for (const address of [
|
||||
'127.0.0.1',
|
||||
'10.1.2.3',
|
||||
'172.16.0.1',
|
||||
'192.168.1.1',
|
||||
'169.254.1.1',
|
||||
'100.64.0.1',
|
||||
'224.0.0.1',
|
||||
]) {
|
||||
expect(publicIpv4(address)).toBe(false);
|
||||
}
|
||||
await expect(
|
||||
downloadProductImage('https://127.0.0.1/image.jpg'),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -132,6 +132,14 @@ describe('商品编辑 M3.0–M3.3 HTTP 集成测试', () => {
|
||||
'DELETE FROM atlas.collection_asset_transfers WHERE workspace_id = $1',
|
||||
[workspaceId],
|
||||
);
|
||||
await tx.query(
|
||||
'DELETE FROM atlas.product_image_imports WHERE workspace_id = $1',
|
||||
[workspaceId],
|
||||
);
|
||||
await tx.query(
|
||||
'DELETE FROM atlas.media_upload_intents WHERE workspace_id = $1',
|
||||
[workspaceId],
|
||||
);
|
||||
await tx.query(
|
||||
'DELETE FROM atlas.product_variants WHERE workspace_id = $1',
|
||||
[workspaceId],
|
||||
@@ -1900,6 +1908,81 @@ describe('商品编辑 M3.0–M3.3 HTTP 集成测试', () => {
|
||||
after.body.data.variants[0].media.map((item: any) => item.asset_id),
|
||||
[asset],
|
||||
);
|
||||
const additionalAssets = await database.workspaceTransaction(workspaceId, async (tx) => {
|
||||
const ids: string[] = [];
|
||||
for (let index = 0; index < 29; index += 1) {
|
||||
const checksum = randomUUID().replaceAll('-', '').repeat(2);
|
||||
const objectKey = `m36/${randomUUID()}.png`;
|
||||
const created = await tx.query<{ id: string }>(
|
||||
`INSERT INTO atlas.media_assets
|
||||
(workspace_id, storage_provider, bucket, object_key, content_type,
|
||||
byte_size, checksum_sha256, width_px, height_px)
|
||||
VALUES (current_setting('app.workspace_id')::uuid, 'test', 'test', $1,
|
||||
'image/png', 1024, $2, 1200, 900) RETURNING id`,
|
||||
[objectKey, checksum],
|
||||
);
|
||||
const assetId = created.rows[0].id;
|
||||
ids.push(assetId);
|
||||
if (index === 0) {
|
||||
await tx.query(
|
||||
`INSERT INTO atlas.media_upload_intents
|
||||
(workspace_id, purpose, storage_provider, bucket, object_key,
|
||||
expected_content_type, expected_byte_size, expected_checksum_sha256,
|
||||
status, asset_id, expires_at, completed_at)
|
||||
VALUES (current_setting('app.workspace_id')::uuid, 'product_image',
|
||||
'test', 'test', $1, 'image/png', 1024, $2,
|
||||
'ready', $3, now() + interval '1 hour', now())`,
|
||||
[objectKey, checksum, assetId],
|
||||
);
|
||||
} else {
|
||||
await tx.query(
|
||||
`INSERT INTO atlas.product_image_imports
|
||||
(workspace_id, source_url, asset_id)
|
||||
VALUES (current_setting('app.workspace_id')::uuid, $1, $2)`,
|
||||
[`https://example.test/${randomUUID()}.png`, assetId],
|
||||
);
|
||||
}
|
||||
}
|
||||
return ids;
|
||||
});
|
||||
const thirtyImages = {
|
||||
...payload,
|
||||
variants: payload.variants.map((item: any) => ({
|
||||
...item,
|
||||
media_asset_ids: item.id === variant.id ? [asset, ...additionalAssets] : [],
|
||||
})),
|
||||
};
|
||||
const expanded = await request(`/api/products/${first.productId}/editor-draft`, {
|
||||
method: 'PUT',
|
||||
headers: headers({
|
||||
'X-Atlas-Platform-Id': platformId,
|
||||
'Idempotency-Key': randomUUID(),
|
||||
'If-Match': `"${saved.body.data.product_revision}:${saved.body.data.profile_revision}"`,
|
||||
}),
|
||||
body: JSON.stringify(thirtyImages),
|
||||
});
|
||||
assert.equal(expanded.response.status, 200);
|
||||
const expandedSnapshot = await request(`/api/products/${first.productId}/editor`, {
|
||||
headers: headers({ 'X-Atlas-Platform-Id': platformId }),
|
||||
});
|
||||
assert.equal(expandedSnapshot.body.data.variants[0].media.length, 30);
|
||||
const uploaded = await request('/api/media-assets/product-uploads', {
|
||||
headers: headers(),
|
||||
});
|
||||
assert.equal(uploaded.response.status, 200);
|
||||
assert.ok(uploaded.body.data.some((item: any) => item.id === additionalAssets[0]));
|
||||
const imported = await request('/api/media-assets/product-imports', {
|
||||
headers: headers(),
|
||||
});
|
||||
assert.equal(imported.response.status, 200);
|
||||
assert.ok(imported.body.data.some((item: any) => item.id === additionalAssets[1]));
|
||||
const privateImport = await request('/api/media-assets/product-imports', {
|
||||
method: 'POST',
|
||||
headers: headers({ 'Idempotency-Key': randomUUID() }),
|
||||
body: JSON.stringify({ url: 'https://127.0.0.1/private.png' }),
|
||||
});
|
||||
assert.equal(privateImport.response.status, 422);
|
||||
assert.equal(privateImport.body.error.code, 'PRODUCT_IMAGE_IMPORT_INVALID');
|
||||
});
|
||||
|
||||
it('独立保存采集描述会持久化覆盖值并保持幂等', async () => {
|
||||
|
||||
Reference in New Issue
Block a user