feat: 完成 SKU 图片选择及素材导入后端

This commit is contained in:
R524809
2026-09-24 17:46:34 +08:00
parent d24ce8f4e0
commit e670d06ca6
26 changed files with 2287 additions and 119 deletions
+100
View File
@@ -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();
});
});
+83
View File
@@ -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 () => {