feat: 完成 SKU 图片选择及素材导入后端
This commit is contained in:
@@ -0,0 +1,87 @@
|
||||
export function detectImage(
|
||||
bytes: Buffer,
|
||||
maxDimension = 8192,
|
||||
): {
|
||||
contentType: 'image/png' | 'image/jpeg' | 'image/webp';
|
||||
width: number;
|
||||
height: number;
|
||||
} | null {
|
||||
if (
|
||||
bytes.length >= 24 &&
|
||||
bytes
|
||||
.subarray(0, 8)
|
||||
.equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]))
|
||||
) {
|
||||
return dimensions(
|
||||
'image/png',
|
||||
bytes.readUInt32BE(16),
|
||||
bytes.readUInt32BE(20),
|
||||
maxDimension,
|
||||
);
|
||||
}
|
||||
if (
|
||||
bytes.length >= 12 &&
|
||||
bytes.subarray(0, 4).toString() === 'RIFF' &&
|
||||
bytes.subarray(8, 12).toString() === 'WEBP'
|
||||
) {
|
||||
const type = bytes.subarray(12, 16).toString();
|
||||
if (type === 'VP8X' && bytes.length >= 30) {
|
||||
return dimensions(
|
||||
'image/webp',
|
||||
1 + bytes.readUIntLE(24, 3),
|
||||
1 + bytes.readUIntLE(27, 3),
|
||||
maxDimension,
|
||||
);
|
||||
}
|
||||
if (
|
||||
type === 'VP8 ' &&
|
||||
bytes.length >= 30 &&
|
||||
bytes[23] === 0x9d &&
|
||||
bytes[24] === 0x01 &&
|
||||
bytes[25] === 0x2a
|
||||
) {
|
||||
return dimensions(
|
||||
'image/webp',
|
||||
bytes.readUInt16LE(26) & 0x3fff,
|
||||
bytes.readUInt16LE(28) & 0x3fff,
|
||||
maxDimension,
|
||||
);
|
||||
}
|
||||
}
|
||||
if (bytes.length > 4 && bytes[0] === 0xff && bytes[1] === 0xd8) {
|
||||
for (let offset = 2; offset + 9 < bytes.length;) {
|
||||
if (bytes[offset] !== 0xff) return null;
|
||||
const marker = bytes[offset + 1];
|
||||
const length = bytes.readUInt16BE(offset + 2);
|
||||
if (length < 2 || offset + 2 + length > bytes.length) return null;
|
||||
if (
|
||||
[
|
||||
0xc0, 0xc1, 0xc2, 0xc3, 0xc5, 0xc6, 0xc7, 0xc9, 0xca, 0xcb,
|
||||
0xcd, 0xce, 0xcf,
|
||||
].includes(marker)
|
||||
) {
|
||||
return dimensions(
|
||||
'image/jpeg',
|
||||
bytes.readUInt16BE(offset + 7),
|
||||
bytes.readUInt16BE(offset + 5),
|
||||
maxDimension,
|
||||
);
|
||||
}
|
||||
offset += 2 + length;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
function dimensions(
|
||||
contentType: 'image/png' | 'image/jpeg' | 'image/webp',
|
||||
width: number,
|
||||
height: number,
|
||||
maxDimension: number,
|
||||
) {
|
||||
return width >= 1 &&
|
||||
height >= 1 &&
|
||||
width <= maxDimension &&
|
||||
height <= maxDimension
|
||||
? { contentType, width, height }
|
||||
: null;
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
Controller,
|
||||
Headers,
|
||||
Inject,
|
||||
Get,
|
||||
Param,
|
||||
Post,
|
||||
Req,
|
||||
@@ -17,6 +18,7 @@ import {
|
||||
completeMediaUploadIntentRequestSchema,
|
||||
createMediaAccessUrlRequestSchema,
|
||||
createMediaUploadIntentRequestSchema,
|
||||
importProductImageRequestSchema,
|
||||
type Workspace,
|
||||
} from '@atlas/contracts';
|
||||
import { z } from 'zod';
|
||||
@@ -33,6 +35,34 @@ export class MediaController {
|
||||
@Inject(MediaService) private readonly media: MediaService,
|
||||
) {}
|
||||
|
||||
@Get('media-assets/product-uploads')
|
||||
@ApiOkResponse({ description: '最近上传的商品图片及短时预览地址' })
|
||||
public async productUploads(@CurrentWorkspace() workspace: Workspace) {
|
||||
return { data: await this.media.productUploads(workspace.id) };
|
||||
}
|
||||
|
||||
@Get('media-assets/product-imports')
|
||||
@ApiOkResponse({ description: '最近导入的网络商品图片及短时预览地址' })
|
||||
public async productImports(@CurrentWorkspace() workspace: Workspace) {
|
||||
return { data: await this.media.productImports(workspace.id) };
|
||||
}
|
||||
|
||||
@Post('media-assets/product-imports')
|
||||
@ApiCreatedResponse({ description: '已下载并校验网络图片,创建正式素材' })
|
||||
public async importProductImage(
|
||||
@Body() body: unknown,
|
||||
@Headers('idempotency-key') key: string | undefined,
|
||||
@Req() request: AuthenticatedRequest,
|
||||
@CurrentWorkspace() workspace: Workspace,
|
||||
) {
|
||||
return (await this.media.importProductImage(
|
||||
workspace.id,
|
||||
request.auth.userId,
|
||||
idempotencyKey(key),
|
||||
parse(importProductImageRequestSchema, body),
|
||||
)).body;
|
||||
}
|
||||
|
||||
@Post('media/upload-intents')
|
||||
@ApiCreatedResponse({ description: '已创建一次性预签名上传意图' })
|
||||
public async createIntent(
|
||||
|
||||
@@ -12,15 +12,19 @@ import { Inject, Injectable } from '@nestjs/common';
|
||||
import type {
|
||||
CompleteMediaUploadIntentRequest,
|
||||
CreateMediaUploadIntentRequest,
|
||||
ImportProductImageRequest,
|
||||
} from '@atlas/contracts';
|
||||
import type { Database, DatabaseTransaction } from '@atlas/database';
|
||||
import { ApiError } from '../common/api-error.js';
|
||||
import { IdempotencyService } from '../common/idempotency.service.js';
|
||||
import { APP_CONFIG, type AppConfig } from '../config/app-config.module.js';
|
||||
import { DATABASE } from '../database/database.module.js';
|
||||
import { detectImage } from './media-image-validation.js';
|
||||
import { downloadProductImage } from './product-image-download.js';
|
||||
|
||||
type IntentRow = {
|
||||
id: string;
|
||||
purpose: 'watermark_image' | 'product_image';
|
||||
storage_provider: string;
|
||||
bucket: string;
|
||||
object_key: string;
|
||||
@@ -80,16 +84,17 @@ export class MediaService {
|
||||
async (tx) => {
|
||||
await this.ensureBucket();
|
||||
const id = randomUUID();
|
||||
const objectKey = `uploads/${workspaceId}/watermark-images/${id}`;
|
||||
const objectKey = `uploads/${workspaceId}/${input.purpose === 'product_image' ? 'product-images' : 'watermark-images'}/${id}`;
|
||||
const expiresAt = new Date(Date.now() + 10 * 60_000);
|
||||
await tx.query(
|
||||
`INSERT INTO atlas.media_upload_intents
|
||||
(id, workspace_id, purpose, storage_provider, bucket, object_key, expected_content_type,
|
||||
expected_byte_size, expected_checksum_sha256, expires_at, created_by_user_id)
|
||||
VALUES ($1, current_setting('app.workspace_id')::uuid, 'watermark_image', 'local_minio',
|
||||
$2, $3, $4, $5, $6, $7, $8)`,
|
||||
VALUES ($1, current_setting('app.workspace_id')::uuid, $2, 'local_minio',
|
||||
$3, $4, $5, $6, $7, $8, $9)`,
|
||||
[
|
||||
id,
|
||||
input.purpose,
|
||||
this.config.OBJECT_STORAGE_BUCKET,
|
||||
objectKey,
|
||||
input.content_type,
|
||||
@@ -282,12 +287,150 @@ export class MediaService {
|
||||
});
|
||||
}
|
||||
|
||||
/** 返回工作空间最近上传的商品素材,预览 URL 仅在本次响应中签发。 */
|
||||
public async productUploads(workspaceId: string) {
|
||||
return this.database.workspaceTransaction(workspaceId, async (tx) => {
|
||||
const uploads = await tx.query<AssetRow & { bucket: string; object_key: string }>(
|
||||
`SELECT a.id, a.content_type, a.byte_size::text, a.width_px, a.height_px,
|
||||
a.created_at, a.bucket, a.object_key, max(i.completed_at) AS uploaded_at
|
||||
FROM atlas.media_assets a
|
||||
JOIN atlas.media_upload_intents i ON i.asset_id = a.id
|
||||
WHERE i.purpose = 'product_image' AND i.status = 'ready'
|
||||
GROUP BY a.id
|
||||
ORDER BY uploaded_at DESC, a.id DESC
|
||||
LIMIT 100`,
|
||||
);
|
||||
const expiresAt = new Date(Date.now() + 10 * 60_000).toISOString();
|
||||
return Promise.all(uploads.rows.map(async (asset) => ({
|
||||
id: asset.id,
|
||||
content_type: asset.content_type,
|
||||
byte_size: Number(asset.byte_size),
|
||||
width_px: asset.width_px,
|
||||
height_px: asset.height_px,
|
||||
created_at: asset.created_at instanceof Date
|
||||
? asset.created_at.toISOString()
|
||||
: asset.created_at,
|
||||
preview_url: await getSignedUrl(
|
||||
this.s3,
|
||||
new GetObjectCommand({ Bucket: asset.bucket, Key: asset.object_key }),
|
||||
{ expiresIn: 600 },
|
||||
),
|
||||
preview_url_expires_at: expiresAt,
|
||||
})));
|
||||
});
|
||||
}
|
||||
|
||||
/** 网络图片先做公网下载与内容校验,再登记为工作空间正式素材。 */
|
||||
public async importProductImage(
|
||||
workspaceId: string,
|
||||
userId: string,
|
||||
key: string,
|
||||
input: ImportProductImageRequest,
|
||||
) {
|
||||
return this.idempotency.run(
|
||||
{ workspaceId, userId, method: 'POST', path: '/api/media-assets/product-imports', key, request: input },
|
||||
async (tx) => {
|
||||
const image = await downloadProductImage(input.url);
|
||||
const checksum = createHash('sha256').update(image.bytes).digest('hex');
|
||||
const existing = await tx.query<{ id: string }>(
|
||||
'SELECT id FROM atlas.media_assets WHERE checksum_sha256 = $1',
|
||||
[checksum],
|
||||
);
|
||||
let assetId = existing.rows[0]?.id ?? randomUUID();
|
||||
if (!existing.rows[0]) {
|
||||
await this.ensureBucket();
|
||||
const objectKey = `product-imports/${workspaceId}/${checksum}`;
|
||||
await this.s3.send(new PutObjectCommand({
|
||||
Bucket: this.config.OBJECT_STORAGE_BUCKET,
|
||||
Key: objectKey,
|
||||
Body: image.bytes,
|
||||
ContentType: image.contentType,
|
||||
}));
|
||||
const inserted = await tx.query<{ id: string }>(
|
||||
`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, current_setting('app.workspace_id')::uuid, 'local_minio',
|
||||
$2, $3, $4, $5, $6, $7, $8)
|
||||
ON CONFLICT (workspace_id, checksum_sha256) DO NOTHING
|
||||
RETURNING id`,
|
||||
[assetId, this.config.OBJECT_STORAGE_BUCKET, objectKey, image.contentType,
|
||||
image.bytes.length, checksum, image.width, image.height],
|
||||
);
|
||||
assetId = inserted.rows[0]?.id ?? (await tx.query<{ id: string }>(
|
||||
'SELECT id FROM atlas.media_assets WHERE checksum_sha256 = $1',
|
||||
[checksum],
|
||||
)).rows[0].id;
|
||||
}
|
||||
await tx.query(
|
||||
`INSERT INTO atlas.product_image_imports
|
||||
(workspace_id, source_url, asset_id, created_by_user_id)
|
||||
VALUES (current_setting('app.workspace_id')::uuid, $1, $2, $3)
|
||||
ON CONFLICT (workspace_id, source_url)
|
||||
DO UPDATE SET asset_id = EXCLUDED.asset_id, updated_at = now()`,
|
||||
[input.url, assetId, userId],
|
||||
);
|
||||
const asset = await this.requireAsset(tx, assetId);
|
||||
const stored = await tx.query<{ bucket: string; object_key: string }>(
|
||||
'SELECT bucket, object_key FROM atlas.media_assets WHERE id = $1',
|
||||
[assetId],
|
||||
);
|
||||
const expiresAt = new Date(Date.now() + 10 * 60_000);
|
||||
const previewUrl = await getSignedUrl(this.s3, new GetObjectCommand({
|
||||
Bucket: stored.rows[0].bucket,
|
||||
Key: stored.rows[0].object_key,
|
||||
}), { expiresIn: 600 });
|
||||
return {
|
||||
status: 201,
|
||||
body: { data: { ...asset, source_url: input.url,
|
||||
preview_url: previewUrl, preview_url_expires_at: expiresAt.toISOString() } },
|
||||
};
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
public async productImports(workspaceId: string) {
|
||||
return this.database.workspaceTransaction(workspaceId, async (tx) => {
|
||||
const imports = await tx.query<AssetRow & {
|
||||
bucket: string;
|
||||
object_key: string;
|
||||
source_url: string;
|
||||
}>(
|
||||
`SELECT a.id, a.content_type, a.byte_size::text, a.width_px, a.height_px,
|
||||
a.created_at, a.bucket, a.object_key, i.source_url
|
||||
FROM (
|
||||
SELECT DISTINCT ON (asset_id) asset_id, source_url, updated_at, id
|
||||
FROM atlas.product_image_imports
|
||||
ORDER BY asset_id, updated_at DESC, id DESC
|
||||
) i
|
||||
JOIN atlas.media_assets a ON a.id = i.asset_id
|
||||
ORDER BY i.updated_at DESC, i.id DESC
|
||||
LIMIT 100`,
|
||||
);
|
||||
const expiresAt = new Date(Date.now() + 10 * 60_000).toISOString();
|
||||
return Promise.all(imports.rows.map(async (asset) => ({
|
||||
id: asset.id,
|
||||
content_type: asset.content_type,
|
||||
byte_size: Number(asset.byte_size),
|
||||
width_px: asset.width_px,
|
||||
height_px: asset.height_px,
|
||||
created_at: asset.created_at instanceof Date
|
||||
? asset.created_at.toISOString() : asset.created_at,
|
||||
source_url: asset.source_url,
|
||||
preview_url: await getSignedUrl(this.s3, new GetObjectCommand({
|
||||
Bucket: asset.bucket, Key: asset.object_key,
|
||||
}), { expiresIn: 600 }),
|
||||
preview_url_expires_at: expiresAt,
|
||||
})));
|
||||
});
|
||||
}
|
||||
|
||||
private async lockIntent(
|
||||
tx: DatabaseTransaction,
|
||||
intentId: string,
|
||||
): Promise<IntentRow> {
|
||||
const rows = await tx.query<IntentRow>(
|
||||
`SELECT id, storage_provider, bucket, object_key, expected_content_type, expected_byte_size,
|
||||
`SELECT id, purpose, storage_provider, bucket, object_key, expected_content_type, expected_byte_size,
|
||||
expected_checksum_sha256, status, expires_at, asset_id
|
||||
FROM atlas.media_upload_intents WHERE id = $1 FOR UPDATE`,
|
||||
[intentId],
|
||||
@@ -335,7 +478,8 @@ export class MediaService {
|
||||
'尚未找到已上传文件',
|
||||
);
|
||||
}
|
||||
if (!head.ContentLength || head.ContentLength > 5 * 1024 * 1024) {
|
||||
const byteLimit = intent.purpose === 'product_image' ? 20 * 1024 * 1024 : 5 * 1024 * 1024;
|
||||
if (!head.ContentLength || head.ContentLength > byteLimit) {
|
||||
throw new ApiError(
|
||||
422,
|
||||
'UPLOAD_VALIDATION_FAILED',
|
||||
@@ -370,7 +514,7 @@ export class MediaService {
|
||||
for await (const chunk of object.Body as AsyncIterable<Uint8Array>) {
|
||||
byteSize += chunk.byteLength;
|
||||
// HeadObject 和 GetObject 之间对象可能被替换;读取时也必须有硬上限。
|
||||
if (byteSize > 5 * 1024 * 1024) {
|
||||
if (byteSize > byteLimit) {
|
||||
throw new ApiError(
|
||||
422,
|
||||
'UPLOAD_VALIDATION_FAILED',
|
||||
@@ -380,7 +524,7 @@ export class MediaService {
|
||||
chunks.push(Buffer.from(chunk));
|
||||
}
|
||||
const bytes = Buffer.concat(chunks, byteSize);
|
||||
const detected = detectImage(bytes);
|
||||
const detected = detectImage(bytes, intent.purpose === 'product_image' ? 10_000 : 8192);
|
||||
if (
|
||||
!detected ||
|
||||
detected.contentType !== intent.expected_content_type
|
||||
@@ -458,80 +602,3 @@ function apiErrorCode(error: unknown): string | undefined {
|
||||
? apiError.code
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function detectImage(bytes: Buffer): {
|
||||
contentType: 'image/png' | 'image/jpeg' | 'image/webp';
|
||||
width: number;
|
||||
height: number;
|
||||
} | null {
|
||||
if (
|
||||
bytes.length >= 24 &&
|
||||
bytes
|
||||
.subarray(0, 8)
|
||||
.equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]))
|
||||
) {
|
||||
return dimensions(
|
||||
'image/png',
|
||||
bytes.readUInt32BE(16),
|
||||
bytes.readUInt32BE(20),
|
||||
);
|
||||
}
|
||||
if (
|
||||
bytes.length >= 12 &&
|
||||
bytes.subarray(0, 4).toString() === 'RIFF' &&
|
||||
bytes.subarray(8, 12).toString() === 'WEBP'
|
||||
) {
|
||||
const type = bytes.subarray(12, 16).toString();
|
||||
if (type === 'VP8X' && bytes.length >= 30) {
|
||||
return dimensions(
|
||||
'image/webp',
|
||||
1 + bytes.readUIntLE(24, 3),
|
||||
1 + bytes.readUIntLE(27, 3),
|
||||
);
|
||||
}
|
||||
if (
|
||||
type === 'VP8 ' &&
|
||||
bytes.length >= 30 &&
|
||||
bytes[23] === 0x9d &&
|
||||
bytes[24] === 0x01 &&
|
||||
bytes[25] === 0x2a
|
||||
) {
|
||||
return dimensions(
|
||||
'image/webp',
|
||||
bytes.readUInt16LE(26) & 0x3fff,
|
||||
bytes.readUInt16LE(28) & 0x3fff,
|
||||
);
|
||||
}
|
||||
}
|
||||
if (bytes.length > 4 && bytes[0] === 0xff && bytes[1] === 0xd8) {
|
||||
for (let offset = 2; offset + 9 < bytes.length;) {
|
||||
if (bytes[offset] !== 0xff) return null;
|
||||
const marker = bytes[offset + 1];
|
||||
const length = bytes.readUInt16BE(offset + 2);
|
||||
if (length < 2 || offset + 2 + length > bytes.length) return null;
|
||||
if (
|
||||
[
|
||||
0xc0, 0xc1, 0xc2, 0xc3, 0xc5, 0xc6, 0xc7, 0xc9, 0xca, 0xcb,
|
||||
0xcd, 0xce, 0xcf,
|
||||
].includes(marker)
|
||||
) {
|
||||
return dimensions(
|
||||
'image/jpeg',
|
||||
bytes.readUInt16BE(offset + 7),
|
||||
bytes.readUInt16BE(offset + 5),
|
||||
);
|
||||
}
|
||||
offset += 2 + length;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
function dimensions(
|
||||
contentType: 'image/png' | 'image/jpeg' | 'image/webp',
|
||||
width: number,
|
||||
height: number,
|
||||
) {
|
||||
return width >= 1 && height >= 1 && width <= 8192 && height <= 8192
|
||||
? { contentType, width, height }
|
||||
: null;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
import { lookup } from 'node:dns/promises';
|
||||
import { request as httpsRequest } from 'node:https';
|
||||
import type { IncomingMessage } from 'node:http';
|
||||
import { isIP } from 'node:net';
|
||||
import { ApiError } from '../common/api-error.js';
|
||||
import { detectImage } from './media-image-validation.js';
|
||||
|
||||
const MAX_BYTES = 5 * 1024 * 1024;
|
||||
|
||||
function invalidImage(): never {
|
||||
throw new ApiError(
|
||||
422,
|
||||
'PRODUCT_IMAGE_IMPORT_INVALID',
|
||||
'网络图片链接无效或图片不符合要求',
|
||||
);
|
||||
}
|
||||
|
||||
export function publicIpv4(address: string) {
|
||||
if (isIP(address) !== 4) return false;
|
||||
const [a, b, c] = address.split('.').map(Number);
|
||||
return !(
|
||||
a === 0 ||
|
||||
a === 10 ||
|
||||
a === 127 ||
|
||||
a >= 224 ||
|
||||
(a === 100 && b >= 64 && b <= 127) ||
|
||||
(a === 169 && b === 254) ||
|
||||
(a === 172 && b >= 16 && b <= 31) ||
|
||||
(a === 192 &&
|
||||
(b === 168 || (b === 0 && c === 0) || (b === 0 && c === 2))) ||
|
||||
(a === 198 && (b === 18 || b === 19 || (b === 51 && c === 100))) ||
|
||||
(a === 203 && b === 0 && c === 113)
|
||||
);
|
||||
}
|
||||
|
||||
/** 每次重定向重新解析域名,并将 HTTPS 连接固定到已检查的公网 IPv4。 */
|
||||
async function requestPublicImage(url: URL): Promise<IncomingMessage> {
|
||||
if (
|
||||
url.protocol !== 'https:' ||
|
||||
url.username ||
|
||||
url.password ||
|
||||
url.port ||
|
||||
isIP(url.hostname)
|
||||
)
|
||||
invalidImage();
|
||||
const addresses = await lookup(url.hostname, {
|
||||
all: true,
|
||||
family: 4,
|
||||
}).catch(() => []);
|
||||
if (
|
||||
!addresses.length ||
|
||||
addresses.some((item) => !publicIpv4(item.address))
|
||||
)
|
||||
invalidImage();
|
||||
const address = addresses[0].address;
|
||||
return new Promise<IncomingMessage>((resolve, reject) => {
|
||||
const request = httpsRequest(
|
||||
url,
|
||||
{
|
||||
lookup: (_hostname, _options, callback) =>
|
||||
callback(null, address, 4),
|
||||
headers: {
|
||||
Accept: 'image/jpeg,image/png',
|
||||
'User-Agent': 'Atlas-Image-Importer/0.1',
|
||||
},
|
||||
timeout: 15_000,
|
||||
},
|
||||
resolve,
|
||||
);
|
||||
request.on('error', reject);
|
||||
request.on('timeout', () =>
|
||||
request.destroy(new Error('网络图片下载超时')),
|
||||
);
|
||||
request.end();
|
||||
});
|
||||
}
|
||||
|
||||
/** 仅导入公开 HTTPS 的 JPG/PNG,硬限制 5 MiB 并验证文件内容。 */
|
||||
export async function downloadProductImage(source: string) {
|
||||
let url = new URL(source);
|
||||
for (let redirect = 0; redirect < 4; redirect += 1) {
|
||||
let response: IncomingMessage;
|
||||
try {
|
||||
response = await requestPublicImage(url);
|
||||
} catch (error) {
|
||||
if (error instanceof ApiError) throw error;
|
||||
invalidImage();
|
||||
}
|
||||
if ([301, 302, 303, 307, 308].includes(response.statusCode ?? 0)) {
|
||||
const location = response.headers.location;
|
||||
response.resume();
|
||||
if (!location) invalidImage();
|
||||
url = new URL(location, url);
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
response.statusCode !== 200 ||
|
||||
Number(response.headers['content-length'] ?? 0) > MAX_BYTES
|
||||
) {
|
||||
response.resume();
|
||||
invalidImage();
|
||||
}
|
||||
const chunks: Buffer[] = [];
|
||||
let total = 0;
|
||||
try {
|
||||
for await (const chunk of response) {
|
||||
total += chunk.length;
|
||||
if (total > MAX_BYTES) {
|
||||
response.destroy();
|
||||
invalidImage();
|
||||
}
|
||||
chunks.push(Buffer.from(chunk));
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof ApiError) throw error;
|
||||
invalidImage();
|
||||
}
|
||||
const bytes = Buffer.concat(chunks, total);
|
||||
const detected = detectImage(bytes, 10_000);
|
||||
if (
|
||||
!detected ||
|
||||
!['image/jpeg', 'image/png'].includes(detected.contentType)
|
||||
)
|
||||
invalidImage();
|
||||
const declared = response.headers['content-type']
|
||||
?.split(';', 1)[0]
|
||||
?.toLowerCase();
|
||||
if (declared && declared !== detected.contentType) invalidImage();
|
||||
return {
|
||||
bytes,
|
||||
contentType: detected.contentType,
|
||||
width: detected.width,
|
||||
height: detected.height,
|
||||
};
|
||||
}
|
||||
invalidImage();
|
||||
}
|
||||
@@ -1378,6 +1378,23 @@ export class ProductEditorService {
|
||||
],
|
||||
)
|
||||
: { rows: [] };
|
||||
const uploaded = assetIds.length
|
||||
? await transaction.query<{ asset_id: string }>(
|
||||
`SELECT DISTINCT asset_id FROM atlas.media_upload_intents
|
||||
WHERE purpose = 'product_image' AND status = 'ready'
|
||||
AND asset_id = ANY($1::uuid[])`,
|
||||
[assetIds],
|
||||
)
|
||||
: { rows: [] };
|
||||
const uploadedIds = new Set(uploaded.rows.map((item) => item.asset_id));
|
||||
const imported = assetIds.length
|
||||
? await transaction.query<{ asset_id: string }>(
|
||||
`SELECT DISTINCT asset_id FROM atlas.product_image_imports
|
||||
WHERE asset_id = ANY($1::uuid[])`,
|
||||
[assetIds],
|
||||
)
|
||||
: { rows: [] };
|
||||
const importedIds = new Set(imported.rows.map((item) => item.asset_id));
|
||||
const candidateVariants = new Map<string, Set<string | null>>();
|
||||
for (const candidate of candidates.rows) {
|
||||
const variants =
|
||||
@@ -1416,6 +1433,8 @@ export class ProductEditorService {
|
||||
);
|
||||
if (existingByVariant.get(variant.variantId)?.has(assetId))
|
||||
continue;
|
||||
if (uploadedIds.has(assetId)) continue;
|
||||
if (importedIds.has(assetId)) continue;
|
||||
const allowedFor = candidateVariants.get(assetId);
|
||||
if (
|
||||
!allowedFor?.has(null) &&
|
||||
@@ -1424,7 +1443,7 @@ export class ProductEditorService {
|
||||
throw new ApiError(
|
||||
422,
|
||||
'LISTING_MEDIA_NOT_SELECTABLE',
|
||||
'只能选择当前采集快照中该 SKU 或商品通用候选已转存的图片',
|
||||
'只能选择已上传、已导入或当前采集快照中该 SKU 可用的已转存图片',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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