diff --git a/apps/api/src/media/media-image-validation.ts b/apps/api/src/media/media-image-validation.ts new file mode 100644 index 0000000..3a49b5c --- /dev/null +++ b/apps/api/src/media/media-image-validation.ts @@ -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; +} diff --git a/apps/api/src/media/media.controller.ts b/apps/api/src/media/media.controller.ts index 0866459..28ed153 100644 --- a/apps/api/src/media/media.controller.ts +++ b/apps/api/src/media/media.controller.ts @@ -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( diff --git a/apps/api/src/media/media.service.ts b/apps/api/src/media/media.service.ts index 792bf7c..457b8af 100644 --- a/apps/api/src/media/media.service.ts +++ b/apps/api/src/media/media.service.ts @@ -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( + `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( + `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 { const rows = await tx.query( - `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) { 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; -} diff --git a/apps/api/src/media/product-image-download.ts b/apps/api/src/media/product-image-download.ts new file mode 100644 index 0000000..1ba5047 --- /dev/null +++ b/apps/api/src/media/product-image-download.ts @@ -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 { + 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((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(); +} diff --git a/apps/api/src/product-editor/product-editor.service.ts b/apps/api/src/product-editor/product-editor.service.ts index 35c4411..0ac1a92 100644 --- a/apps/api/src/product-editor/product-editor.service.ts +++ b/apps/api/src/product-editor/product-editor.service.ts @@ -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>(); 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 可用的已转存图片', ); } } diff --git a/apps/api/test/media-import.unit.test.ts b/apps/api/test/media-import.unit.test.ts new file mode 100644 index 0000000..38a4a2a --- /dev/null +++ b/apps/api/test/media-import.unit.test.ts @@ -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, + ) => { + 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[0], + {} as ConstructorParameters[1], + { + OBJECT_STORAGE_ENDPOINT: 'http://127.0.0.1:9000', + OBJECT_STORAGE_BUCKET: 'test', + objectStorageForcePathStyle: true, + objectStorageAccessKey: 'test', + objectStorageSecretKey: 'test', + } as ConstructorParameters[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); + }); +}); diff --git a/apps/api/test/media-product-image.unit.test.ts b/apps/api/test/media-product-image.unit.test.ts new file mode 100644 index 0000000..e1c556b --- /dev/null +++ b/apps/api/test/media-product-image.unit.test.ts @@ -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[0], + {} as ConstructorParameters[1], + { + OBJECT_STORAGE_ENDPOINT: 'http://127.0.0.1:9000', + OBJECT_STORAGE_BUCKET: 'test', + objectStorageForcePathStyle: true, + objectStorageAccessKey: 'test', + objectStorageSecretKey: 'test', + } as ConstructorParameters[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) => 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, + ) => callback({ query }), + ); + const service = new MediaService( + { run } as unknown as ConstructorParameters[0], + {} as ConstructorParameters[1], + { + OBJECT_STORAGE_ENDPOINT: 'http://127.0.0.1:9000', + OBJECT_STORAGE_BUCKET: 'test', + objectStorageForcePathStyle: true, + objectStorageAccessKey: 'test', + objectStorageSecretKey: 'test', + } as ConstructorParameters[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, + ) => { + expect(workspaceId).toBe('workspace-id'); + return callback({ query }); + }, + ); + const service = new MediaService( + {} as ConstructorParameters[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[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(); + }); +}); diff --git a/apps/api/test/product-editor.e2e.test.ts b/apps/api/test/product-editor.e2e.test.ts index 4dbcd7b..7e0325e 100644 --- a/apps/api/test/product-editor.e2e.test.ts +++ b/apps/api/test/product-editor.e2e.test.ts @@ -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 () => { diff --git a/apps/web/src/pages/products/ProductEditPage.tsx b/apps/web/src/pages/products/ProductEditPage.tsx index 5b0a970..5798b47 100644 --- a/apps/web/src/pages/products/ProductEditPage.tsx +++ b/apps/web/src/pages/products/ProductEditPage.tsx @@ -1062,7 +1062,7 @@ const ProductEditPage = () => { modal.info({ title: `${name}将在下一阶段接入`, content: - '本轮只实现前端工作台。生成结果会以候选形式展示,必须由用户确认才会写入草稿,不会自动覆盖当前内容。', + '当前已支持 SKU 图片上传、网络导入和采集图转存。AI 生图与图片编辑将在后续接入。', okText: '知道了', }); const validSupplyUrl = @@ -1419,7 +1419,7 @@ const ProductEditPage = () => { onCompareCopy={(field) => setComparison({ variantId: null, field })} /> - { skuCopyTaskId.current = null; }} onCompareCopy={(variantId, field) => setComparison({ variantId, field })} + onImagesChanged={() => setDirty(true)} /> void; onCompareCopy: (variantId: string, field: 'title' | 'tags' | 'description') => void; + onImagesChanged: () => void; }; /** @@ -157,11 +161,43 @@ export function ProductVariantSection({ usesVariantTitle, onAiCopy, onCompareCopy, + onImagesChanged, }: Props) { // 实时监听变体草稿,驱动已选图片和主图回显。 const draftVariants = (Form.useWatch('variants', form) ?? []) as VariantValues[]; // 展开状态按 Form.List 的 field.key(而非下标)记录,增删/复制 SKU 后不会错位。 const [expandedVariantKeys, setExpandedVariantKeys] = useState>(() => new Set()); + // 页面内保存已选预览,正式选图顺序同步到 Form 的 media_asset_ids。 + const [localImagesByVariant, setLocalImagesByVariant] = useState>({}); + const [assetPreviewUrls, setAssetPreviewUrls] = useState>({}); + useEffect(() => setLocalImagesByVariant({}), [snapshot]); + const [imageEditorTarget, setImageEditorTarget] = useState<{ + key: string; + name: string; + images: SkuEditorImage[]; + candidates: CollectionItemInspection['asset_transfers']; + } | null>(null); + const localObjectUrls = useRef([]); + useEffect(() => () => localObjectUrls.current.forEach((url) => URL.revokeObjectURL(url)), []); + useEffect(() => { + let active = true; + const refresh = async () => { + const ids = [...new Set(snapshot.variants.flatMap((variant) => + variant.media.filter((media) => !media.source_url).map((media) => media.asset_id) + ))]; + const previews = await Promise.allSettled(ids.map((id) => mediaApi.createPreviewUrl(id))); + if (!active) return; + setAssetPreviewUrls(Object.fromEntries(previews.flatMap((result, index) => + result.status === 'fulfilled' ? [[ids[index], result.value.url]] : [] + ))); + }; + void refresh(); + const interval = window.setInterval(() => void refresh(), 8 * 60_000); + return () => { + active = false; + window.clearInterval(interval); + }; + }, [snapshot]); // SKU 变体属性统一放入展开后的“SKU 信息”,必填状态仍由 schema 决定。 const skuInfoAspectDefinitions = activeAttributeSchema .filter( @@ -175,6 +211,7 @@ export function ProductVariantSection({ definition.is_aspect && !definition.editor_field && isColorNameExpression(definition) ); return ( + <> ({ - assetId, - url: - imageCandidates.find( - (candidate) => candidate.asset_id === assetId - )?.source_url ?? - original?.media.find( - (media) => media.asset_id === assetId - )?.source_url, - })) - .filter((image): image is { assetId: string; url: string } => - Boolean(image.url) - ); + const variantKey = sourceId ?? variantValue?.client_key ?? String(field.key); + const savedImages = selectedAssetIds + .map((assetId): SkuEditorImage | null => { + const candidate = imageCandidates.find( + (item) => item.asset_id === assetId + ); + const url = candidate?.source_url ?? original?.media.find( + (media) => media.asset_id === assetId + )?.source_url ?? assetPreviewUrls[assetId]; + return url ? { + id: candidate ? `collection:${candidate.id}` : `asset:${assetId}`, + url, + label: '已选 SKU 图片', + origin: 'saved', + assetId, + } : null; + }) + .filter((image): image is SkuEditorImage => image !== null); + const selectedImages = localImagesByVariant[variantKey] ?? savedImages; const copyVariant = () => add({ ...variantValue, @@ -425,11 +467,16 @@ export function ProductVariantSection({ SKU 图片 + {Object.hasOwn(localImagesByVariant, variantKey) && ( + + 当前图片仅在本页预览,刷新后恢复 + + )}
{selectedImages.map((image, imageIndex) => (
{imageIndex === 0 && ( @@ -439,10 +486,15 @@ export function ProductVariantSection({ 已选 SKU 图片
))} - {/* 图片选择交互将在后续接入。 */} @@ -703,5 +755,43 @@ export function ProductVariantSection({ }} + setImageEditorTarget(null)} + onConfirm={async (images) => { + if (!imageEditorTarget) return; + const assetIds = await resolveSkuImageSelection(images, collectionRecord?.id ?? null); + const variantIndex = (form.getFieldValue('variants') as VariantValues[]) + .findIndex((variant) => (variant.id ?? variant.client_key) === imageEditorTarget.key); + if (variantIndex < 0) throw new Error('SKU 已变化,请重新打开图片选择'); + form.setFieldValue(['variants', variantIndex, 'media_asset_ids'], assetIds); + setLocalImagesByVariant((current) => ({ + ...current, + [imageEditorTarget.key]: images.map((image, index) => ({ + ...image, + assetId: assetIds[index], + })), + })); + onImagesChanged(); + setImageEditorTarget(null); + }} + onUploadFile={async (file) => { + const asset = await uploadProductImage(file); + const url = URL.createObjectURL(file); + localObjectUrls.current.push(url); + return { + id: `asset:${asset.id}`, + url, + label: file.name, + origin: 'upload', + assetId: asset.id, + }; + }} + /> + ); } diff --git a/apps/web/src/pages/products/components/SkuImageCard.tsx b/apps/web/src/pages/products/components/SkuImageCard.tsx new file mode 100644 index 0000000..a22e71c --- /dev/null +++ b/apps/web/src/pages/products/components/SkuImageCard.tsx @@ -0,0 +1,112 @@ +import type { DragEvent, KeyboardEvent, MouseEvent } from 'react'; +import { Button, Dropdown, Tooltip } from 'antd'; +import type { MenuProps } from 'antd'; +import { + CheckCircleOutlined, + DownOutlined, + EyeOutlined, + MinusCircleOutlined, +} from '@ant-design/icons'; + +export type SkuEditorImage = { + id: string; + url: string; + label: string; + origin: 'saved' | 'collection' | 'upload' | 'network'; + assetId?: string; + transferId?: string; + sourceUrl?: string; +}; + +type Props = { + image: SkuEditorImage; + mode: 'selected' | 'source'; + selected: boolean; + cover?: boolean; + menu: MenuProps; + onToggle: () => void; + onPreview: () => void; + onRemove?: () => void; + onDragStart?: () => void; + onDragOver?: (event: DragEvent) => void; + onDrop?: (event: DragEvent) => void; +}; + +/** 左右两列复用图片卡片,选中、预览和编辑操作保持一致。 */ +export function SkuImageCard({ + image, + mode, + selected, + cover, + menu, + onToggle, + onPreview, + onRemove, + onDragStart, + onDragOver, + onDrop, +}: Props) { + const isSelectedColumn = mode === 'selected'; + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + onToggle(); + } + }; + const stop = (event: MouseEvent) => event.stopPropagation(); + return ( +
+ {image.label} + {cover && 主图} + {!isSelectedColumn && selected && ( + + + + )} + {isSelectedColumn && ( + + + +
+
+ ); +} diff --git a/apps/web/src/pages/products/components/SkuImageEditorModal.css b/apps/web/src/pages/products/components/SkuImageEditorModal.css new file mode 100644 index 0000000..f2872a7 --- /dev/null +++ b/apps/web/src/pages/products/components/SkuImageEditorModal.css @@ -0,0 +1,309 @@ +.sku-image-editor-modal .ant-modal-container { + display: flex; + height: min(800px, calc(100dvh - 48px)); + flex-direction: column; + padding: 0; + overflow: hidden; + border-radius: 14px; +} +.sku-image-editor-modal .ant-modal-header { + flex: 0 0 auto; + margin: 0; + padding: 18px 26px; + border-bottom: 1px solid #ebe9f1; +} +.sku-image-editor-modal .ant-modal-title > div { + display: flex; + align-items: baseline; + gap: 14px; +} +.sku-image-editor-modal .ant-modal-title strong { + color: #22212b; + font-size: 20px; +} +.sku-image-editor-modal .ant-modal-title span { + overflow: hidden; + color: #888396; + font-size: 13px; + font-weight: 400; + text-overflow: ellipsis; + white-space: nowrap; +} +.sku-image-editor-modal .ant-modal-close { + top: 17px; + right: 18px; +} +.sku-image-editor-modal .ant-modal-body { + display: flex; + min-height: 0; + flex: 1 1 auto; + flex-direction: column; + padding: 0 24px 18px; +} +.sku-image-editor-modal .ant-modal-footer { + flex: 0 0 auto; + margin: 0; + padding: 14px 24px; + border-top: 1px solid #ebe9f1; +} +.sku-image-editor-note { + flex: 0 0 auto; + margin: 16px 0 12px; + padding: 9px 13px; + border: 1px solid #e9e2ff; + border-radius: 8px; + background: #f8f5ff; + color: #695d86; + font-size: 12px; +} +.sku-image-editor-columns { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); + gap: 24px; + min-height: 0; + flex: 1 1 auto; +} +.sku-image-editor-left, +.sku-image-editor-right { + display: flex; + min-width: 0; + min-height: 0; + flex-direction: column; +} +.sku-image-editor-left { + padding-right: 24px; + border-right: 1px solid #ece9f1; +} +.sku-image-editor-toolbar { + display: flex; + align-items: center; + gap: 10px; + min-height: 42px; +} +.sku-image-editor-toolbar .ant-btn:first-of-type { + margin-right: auto; +} +.sku-image-editor-summary { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px 16px; + margin: 20px 0 14px; + flex: 0 0 auto; +} +.sku-image-editor-summary strong { + color: #252332; + font-size: 16px; +} +.sku-image-editor-summary span { + color: #8d879e; + font-size: 12px; +} +.sku-image-editor-scroll { + min-height: 0; + flex: 1 1 auto; + overflow-y: auto; + scrollbar-color: #d1ccd9 transparent; + scrollbar-width: thin; +} +.sku-image-editor-right .ant-tabs { + display: flex; + min-height: 0; + flex: 1 1 auto; + flex-direction: column; +} +.sku-image-editor-right .ant-tabs-nav { + flex: 0 0 auto; +} +.sku-image-editor-right .ant-tabs-body-holder, +.sku-image-editor-right .ant-tabs-body { + min-height: 0; + flex: 1 1 auto; +} +.sku-image-editor-right .ant-tabs-body-holder { + overflow: hidden; +} +.sku-image-editor-right .ant-tabs-body { + height: 100%; +} +.sku-image-editor-right .ant-tabs-content { + height: 100%; + overflow-y: auto; + scrollbar-color: #d1ccd9 transparent; + scrollbar-width: thin; +} +.sku-image-selected-grid, +.sku-image-source-grid { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + grid-auto-flow: row; + gap: 12px; + padding: 3px; +} +.sku-image-selected-card, +.sku-image-source-card { + position: relative; + min-width: 0; + aspect-ratio: 3 / 4; + overflow: hidden; + border: 2px solid #e8e5ed; + border-radius: 9px; + background: #f6f5f8; + cursor: pointer; +} +.sku-image-selected-card.is-cover { + grid-column: span 2; + grid-row: span 2; +} +.sku-image-selected-card.is-marked, +.sku-image-source-card.is-selected { + border-color: #7062e9; + box-shadow: 0 0 0 2px rgb(112 98 233 / 12%); +} +.sku-image-selected-card > img, +.sku-image-source-card > img { + display: block; + width: 100%; + height: 100%; + object-fit: contain; +} +.sku-image-cover-badge { + position: absolute; + top: 8px; + left: 8px; + padding: 2px 7px; + border-radius: 5px; + background: #7062e9; + color: #fff; + font-size: 11px; +} +.sku-image-source-selected { + position: absolute; + top: 6px; + left: 6px; + z-index: 2; + display: flex; + width: 24px; + height: 24px; + align-items: center; + justify-content: center; + padding: 0; + border-radius: 5px; + background: rgb(255 255 255 / 94%); + color: #7062e9; + font-size: 16px; +} +.sku-image-remove.ant-btn { + position: absolute; + top: 6px; + right: 6px; + z-index: 2; + width: 24px; + height: 24px; + min-width: 24px; + padding: 0; + border-radius: 5px; + background: rgb(255 255 255 / 94%); + color: #26232d; +} +.sku-image-remove.ant-btn:hover { + background: rgb(255 255 255 / 94%) !important; + color: #c34456 !important; +} +.sku-image-card-actions { + position: absolute; + right: 0; + bottom: 0; + left: 0; + display: flex; + justify-content: space-between; + align-items: center; + min-height: 34px; + padding: 0 4px; + background: rgb(36 34 44 / 68%); +} +.sku-image-card-actions .ant-btn { + color: #fff; +} +.sku-image-card-actions .ant-btn:hover { + color: #fff !important; + background: rgb(255 255 255 / 14%) !important; +} +.sku-image-selected-card.is-cover .sku-image-card-actions { + min-height: 42px; +} +.sku-image-editor-right .ant-tabs-nav { + margin: 0 0 16px; +} +.sku-image-editor-right .ant-tabs-tab { + padding: 9px 0; +} +.sku-image-collection-collapse { + margin: 0 2px 12px 0; +} +.sku-image-collection-collapse .ant-collapse-content-box { + padding: 12px !important; +} +.sku-image-right-tab { + display: flex; + height: 100%; + min-height: 0; + flex-direction: column; +} +.sku-image-network-controls, +.sku-image-upload-controls { + display: flex; + flex: 0 0 auto; + align-items: flex-start; + gap: 10px; + margin: 0 2px 16px 0; +} +.sku-image-network-controls { + flex-direction: column; +} +.sku-image-network-textarea.ant-input { + height: 164px; + line-height: 22px; + resize: none; +} +.sku-image-upload-controls { + align-items: center; + flex-wrap: wrap; +} +.sku-image-upload-controls > span { + color: #8d879e; + font-size: 12px; +} +.sku-image-right-gallery { + min-height: 0; + flex: 1 1 auto; + overflow-y: auto; + scrollbar-color: #d1ccd9 transparent; + scrollbar-width: thin; +} +@media (max-width: 860px) { + .sku-image-editor-columns { + grid-template-columns: 1fr; + grid-template-rows: minmax(0, 1fr) minmax(0, 1fr); + gap: 22px; + } + .sku-image-editor-left { + padding-right: 0; + padding-bottom: 22px; + border-right: 0; + border-bottom: 1px solid #ece9f1; + } +} +@media (max-width: 500px) { + .sku-image-editor-modal .ant-modal-body { + padding: 0 14px 14px; + } + .sku-image-selected-grid, + .sku-image-source-grid { + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 8px; + } + .sku-image-editor-toolbar { + flex-wrap: wrap; + } +} diff --git a/apps/web/src/pages/products/components/SkuImageEditorModal.tsx b/apps/web/src/pages/products/components/SkuImageEditorModal.tsx new file mode 100644 index 0000000..23283af --- /dev/null +++ b/apps/web/src/pages/products/components/SkuImageEditorModal.tsx @@ -0,0 +1,385 @@ +import { useEffect, useRef, useState } from 'react'; +import { App as AntdApp, Button, Dropdown, Empty, Image, Modal } from 'antd'; +import type { MenuProps } from 'antd'; +import { CheckOutlined, DownOutlined, UploadOutlined } from '@ant-design/icons'; +import type { CollectionItemInspection } from '@atlas/contracts'; +import { apiErrorMessage } from '@/services/api'; +import { SkuImageCard, type SkuEditorImage } from './SkuImageCard'; +import { SkuImageSourceTabs } from './SkuImageSourceTabs'; +import { uploadFileError, useSkuImageLibrary } from './useSkuImageLibrary'; +import './SkuImageEditorModal.css'; + +type CollectionImage = CollectionItemInspection['asset_transfers'][number]; + +export type { SkuEditorImage } from './SkuImageCard'; + +type Props = { + open: boolean; + skuKey: string; + skuName: string; + initialImages: SkuEditorImage[]; + collectionImages: CollectionImage[]; + onCancel: () => void; + onConfirm: (images: SkuEditorImage[]) => Promise; + onUploadFile: (file: File) => Promise; +}; + +const MAX_IMAGES = 30; + +function downloadImage(image: SkuEditorImage) { + const link = document.createElement('a'); + link.href = image.url; + link.download = image.label || 'sku-image'; + link.target = '_blank'; + link.rel = 'noopener noreferrer'; + document.body.appendChild(link); + link.click(); + link.remove(); +} + +function sameImage(left: SkuEditorImage, right: SkuEditorImage) { + return left.id === right.id || !!(left.assetId && left.assetId === right.assetId); +} + +/** 图片选择弹窗管理交互状态;素材上传与草稿写入由外层服务完成。 */ +export function SkuImageEditorModal({ + open, + skuKey, + skuName, + initialImages, + collectionImages, + onCancel, + onConfirm, + onUploadFile, +}: Props) { + const { message } = AntdApp.useApp(); + const [images, setImages] = useState(initialImages); + const [markedIds, setMarkedIds] = useState([]); + const [activeTab, setActiveTab] = useState('collection'); + const [confirming, setConfirming] = useState(false); + const { + networkUrls, + setNetworkUrls, + networkAdding, + networkImages, + uploadedImages, + uploading, + uploadFile, + uploadToRight, + addNetworkImages, + } = useSkuImageLibrary(open, skuKey, images, onUploadFile); + const [preview, setPreview] = useState<{ images: SkuEditorImage[]; index: number } | null>( + null + ); + const fileInput = useRef(null); + const dragIndex = useRef(null); + useEffect(() => { + if (!open) return; + setImages(initialImages); + setMarkedIds([]); + setActiveTab('collection'); + setPreview(null); + }, [open, skuKey]); + const addImage = (image: SkuEditorImage) => { + const existing = images.find((item) => sameImage(item, image)); + if (existing) { + setImages((current) => current.filter((item) => item.id !== existing.id)); + setMarkedIds((current) => current.filter((id) => id !== existing.id)); + return; + } + if (images.length >= MAX_IMAGES) { + message.warning(`每个 SKU 最多选择 ${MAX_IMAGES} 张图片`); + return; + } + setImages((current) => [...current, image]); + }; + const removeImages = (ids: string[]) => { + setImages((current) => current.filter((image) => !ids.includes(image.id))); + setMarkedIds((current) => current.filter((id) => !ids.includes(id))); + }; + const setCover = (image: SkuEditorImage) => { + if (!images.some((item) => sameImage(item, image)) && images.length >= MAX_IMAGES) { + message.warning(`每个 SKU 最多选择 ${MAX_IMAGES} 张图片`); + return; + } + setImages((current) => { + const selected = current.find((item) => sameImage(item, image)) ?? image; + return [selected, ...current.filter((item) => !sameImage(item, image))]; + }); + }; + const markImage = (id: string) => + setMarkedIds((current) => + current.includes(id) ? current.filter((item) => item !== id) : [...current, id] + ); + const pendingAction = (label: string) => message.info(`${label}将在后续接入图片处理服务`); + const onBatchAction: MenuProps['onClick'] = ({ key }) => { + if (!markedIds.length) { + message.info('请先点击左侧图片进行选择'); + return; + } + if (key === 'delete') removeImages(markedIds); + else if (key === 'download') + images.filter((image) => markedIds.includes(image.id)).forEach(downloadImage); + else pendingAction(String(key)); + }; + const batchMenu: MenuProps = { + onClick: onBatchAction, + items: [ + { key: '添加水印', label: '添加水印' }, + { key: '一键 3:4', label: '一键 3:4' }, + { key: '批量翻译', label: '批量翻译' }, + { key: 'download', label: '下载' }, + { type: 'divider' }, + { key: 'delete', label: '移出已选', danger: true }, + ], + }; + const imageMenu = (image: SkuEditorImage): MenuProps => ({ + onClick: ({ key, domEvent }) => { + domEvent.stopPropagation(); + if (key === 'cover') setCover(image); + else if (key === 'download') downloadImage(image); + else pendingAction(String(key)); + }, + items: [ + { key: 'AI 编辑', label: 'AI 编辑' }, + { key: '添加水印', label: '添加水印' }, + { key: '一键 3:4', label: '一键 3:4' }, + { key: 'cover', label: '设为封图' }, + { key: 'download', label: '下载' }, + ], + }); + const uploadFiles = async (files: FileList | null) => { + if (!files) return; + const available = MAX_IMAGES - images.length; + const accepted = Array.from(files).filter((file) => { + const error = uploadFileError(file); + if (error) message.warning(error); + return !error; + }); + if (accepted.length > available) message.warning(`每个 SKU 最多选择 ${MAX_IMAGES} 张图片`); + for (const file of accepted.slice(0, available)) { + try { + const image = await uploadFile(file); + setImages((current) => + current.some((item) => item.assetId === image.assetId) + ? current + : [...current, image] + ); + } catch (error) { + message.error(`${file.name} 上传失败:${apiErrorMessage(error)}`); + } + } + if (fileInput.current) fileInput.current.value = ''; + }; + const sourceImages = (role: string): SkuEditorImage[] => + collectionImages + .filter((image) => image.role === role) + .map((image) => ({ + id: `collection:${image.id}`, + url: image.source_url, + label: + image.role === 'source_main' + ? '采集主图' + : image.role === 'source_sku' + ? '采集 SKU 图' + : '采集详情图', + origin: 'collection' as const, + assetId: image.asset_id ?? undefined, + transferId: image.id, + })); + const showPreview = (items: SkuEditorImage[], index: number) => + setPreview({ images: items, index }); + const renderSourceImages = (items: SkuEditorImage[]) => + items.length ? ( +
+ {items.map((image, index) => { + const selected = images.some((item) => sameImage(item, image)); + return ( + addImage(image)} + onPreview={() => showPreview(items, index)} + /> + ); + })} +
+ ) : ( + + ); + + const confirmSelection = async () => { + setConfirming(true); + try { + await onConfirm(images); + } catch (error) { + message.error(apiErrorMessage(error)); + } finally { + setConfirming(false); + } + }; + + return ( + <> + + SKU 图片选择 + {skuName} + + } + width="min(1480px, 96vw)" + onCancel={() => { + if (!confirming) onCancel(); + }} + closable={!confirming} + maskClosable={!confirming} + footer={[ + , + , + ]} + centered + > +
+ 第一张为主图。确认后将选图写入当前编辑草稿,点击页面“保存”后持久化。 +
+
+
+
+ void uploadFiles(event.target.files)} + /> + + + + + +
+
+ + 已选图片({images.length} / {MAX_IMAGES}) + + 第一张为主图,拖动可调整顺序 +
+
+ {images.length ? ( +
+ {images.map((image, index) => ( + markImage(image.id)} + onPreview={() => showPreview(images, index)} + onRemove={() => removeImages([image.id])} + onDragStart={() => { + dragIndex.current = index; + }} + onDragOver={(event) => event.preventDefault()} + onDrop={(event) => { + event.preventDefault(); + const from = dragIndex.current; + if (from === null || from === index) return; + setImages((current) => { + const next = [...current]; + const [moved] = next.splice(from, 1); + next.splice(index, 0, moved); + return next; + }); + dragIndex.current = null; + }} + /> + ))} +
+ ) : ( + + )} +
+
+
+ void addNetworkImages()} + uploadedImages={uploadedImages} + uploading={uploading} + onUploadFile={uploadToRight} + /> +
+
+
+ image.url) ?? []} + preview={{ + open: !!preview, + current: preview?.index ?? 0, + onOpenChange: (visible) => { + if (!visible) setPreview(null); + }, + onChange: (current) => + setPreview((previous) => + previous ? { ...previous, index: current } : null + ), + }} + /> + + ); +} diff --git a/apps/web/src/pages/products/components/SkuImageSourceTabs.tsx b/apps/web/src/pages/products/components/SkuImageSourceTabs.tsx new file mode 100644 index 0000000..5986222 --- /dev/null +++ b/apps/web/src/pages/products/components/SkuImageSourceTabs.tsx @@ -0,0 +1,146 @@ +import type { ReactNode } from 'react'; +import { Button, Collapse, Empty, Input, Tabs, Upload } from 'antd'; +import { UploadOutlined } from '@ant-design/icons'; +import type { SkuEditorImage } from './SkuImageCard'; + +const NETWORK_PLACEHOLDER = `请输入图片链接,每行一个(仅支持 JPG、PNG 格式): +https://example.com/image1.jpg +https://example.com/image2.png + +• 请确保图片链接可以正常访问 +• 建议图片大小不超过 5MB +• 自动过滤重复和无效链接`; + +type Props = { + activeTab: string; + onTabChange: (tab: string) => void; + sourceImages: (role: string) => SkuEditorImage[]; + renderImages: (images: SkuEditorImage[]) => ReactNode; + networkImages: SkuEditorImage[]; + networkUrls: string; + onNetworkUrlsChange: (value: string) => void; + networkAdding: boolean; + onAddNetworkImages: () => void; + uploadedImages: SkuEditorImage[]; + uploading: boolean; + onUploadFile: (file: File) => Promise; +}; + +/** 图片来源分区只负责展示和输入,选中及素材状态由弹窗控制。 */ +export function SkuImageSourceTabs({ + activeTab, + onTabChange, + sourceImages, + renderImages, + networkImages, + networkUrls, + onNetworkUrlsChange, + networkAdding, + onAddNetworkImages, + uploadedImages, + uploading, + onUploadFile, +}: Props) { + return ( + + ), + }, + { + key: 'collection', + label: '采集图片', + children: ( + + ), + }, + { + key: 'network', + label: '网络图片', + children: ( +
+
+ onNetworkUrlsChange(event.target.value)} + aria-label="网络图片链接,每行一个" + /> + +
+
+ {renderImages(networkImages)} +
+
+ ), + }, + { + key: 'upload', + label: '上传图片', + children: ( +
+
+ + + + 图片先放在右侧,点击图片后加入左侧已选区 +
+
+ {renderImages(uploadedImages)} +
+
+ ), + }, + ]} + /> + ); +} diff --git a/apps/web/src/pages/products/components/skuImageSelection.ts b/apps/web/src/pages/products/components/skuImageSelection.ts new file mode 100644 index 0000000..0d2af14 --- /dev/null +++ b/apps/web/src/pages/products/components/skuImageSelection.ts @@ -0,0 +1,58 @@ +import type { CollectionItemInspection } from '@atlas/contracts'; +import { collectionApi } from '@/services/collection'; +import type { SkuEditorImage } from './SkuImageCard'; + +const wait = (milliseconds: number) => + new Promise((resolve) => window.setTimeout(resolve, milliseconds)); + +/** 确认时转存已选采集图;草稿只接受已校验的正式素材 ID。 */ +export async function resolveSkuImageSelection( + images: SkuEditorImage[], + collectionItemId: string | null +): Promise { + const transferIds = [ + ...new Set( + images + .filter((image) => !image.assetId && image.transferId) + .map((image) => image.transferId!) + ), + ]; + const resolved = new Map(); + if (transferIds.length) { + if (!collectionItemId) throw new Error('缺少采集记录,无法转存所选图片'); + const current = await collectionApi.inspection(collectionItemId); + const pending = transferIds.filter( + (id) => + current.asset_transfers.find((item) => item.id === id)?.status === + 'pending_selection' + ); + if (pending.length) { + await collectionApi.materializeAssets(collectionItemId, { transfer_ids: pending }); + } + for (let attempt = 0; attempt < 45; attempt += 1) { + const inspection = await collectionApi.inspection(collectionItemId); + for (const id of transferIds) { + const transfer = inspection.asset_transfers.find((item) => item.id === id); + if (transfer?.status === 'failed') { + throw new Error('采集图片转存失败,请稍后重试'); + } + if (transfer?.status === 'succeeded' && transfer.asset_id) { + resolved.set(id, transfer.asset_id); + } + } + if (resolved.size === transferIds.length) break; + await wait(1_000); + } + if (resolved.size !== transferIds.length) { + throw new Error('采集图片仍在转存,请稍后重新确认'); + } + } + const assetIds = images.map( + (image) => image.assetId ?? (image.transferId ? resolved.get(image.transferId) : undefined) + ); + if (assetIds.some((id) => !id)) throw new Error('有图片尚未转存,无法写入草稿'); + if (new Set(assetIds).size !== assetIds.length) { + throw new Error('所选图片包含重复素材,请移除重复项'); + } + return assetIds as string[]; +} diff --git a/apps/web/src/pages/products/components/useSkuImageLibrary.ts b/apps/web/src/pages/products/components/useSkuImageLibrary.ts new file mode 100644 index 0000000..acbcf12 --- /dev/null +++ b/apps/web/src/pages/products/components/useSkuImageLibrary.ts @@ -0,0 +1,215 @@ +import { useEffect, useRef, useState } from 'react'; +import { App as AntdApp, Upload } from 'antd'; +import { apiErrorMessage } from '@/services/api'; +import { mediaApi } from '@/services/media'; +import type { SkuEditorImage } from './SkuImageCard'; + +export function uploadFileError(file: File) { + if (!['image/jpeg', 'image/png', 'image/webp'].includes(file.type)) { + return `${file.name} 不是 JPG、PNG 或 WebP 图片`; + } + if (file.size > 20 * 1024 * 1024) return `${file.name} 超过 20 MB`; + return null; +} + +function networkImageUrl(value: string) { + try { + const url = new URL(value); + if (url.protocol !== 'https:' || url.username || url.password || url.port) return null; + if (!/\.(?:jpe?g|png)$/i.test(url.pathname)) return null; + url.hash = ''; + return url.toString(); + } catch { + return null; + } +} + +/** 工作空间候选图库:网络导入、上传、重开弹窗后的短时预览刷新。 */ +export function useSkuImageLibrary( + open: boolean, + skuKey: string, + selectedImages: SkuEditorImage[], + onUploadFile: (file: File) => Promise +) { + const { message } = AntdApp.useApp(); + const [networkUrls, setNetworkUrls] = useState(''); + const [networkAdding, setNetworkAdding] = useState(false); + const [networkImages, setNetworkImages] = useState([]); + const [uploadedImages, setUploadedImages] = useState([]); + const [uploadCount, setUploadCount] = useState(0); + const fingerprints = useRef(new Set()); + const networkRequest = useRef(0); + + useEffect(() => { + networkRequest.current += 1; + setNetworkUrls(''); + setNetworkAdding(false); + }, [open, skuKey]); + useEffect(() => { + if (!open) return; + let active = true; + const refresh = () => + void mediaApi + .listProductUploads() + .then((uploads) => { + if (!active) return; + const loaded = uploads.map((asset) => ({ + id: `asset:${asset.id}`, + url: asset.preview_url, + label: '上传图片', + origin: 'upload' as const, + assetId: asset.id, + })); + setUploadedImages((current) => [ + ...current.filter( + (image) => !loaded.some((item) => item.assetId === image.assetId) + ), + ...loaded, + ]); + }) + .catch((error) => { + if (active) message.warning(`上传图片列表加载失败:${apiErrorMessage(error)}`); + }); + refresh(); + const interval = window.setInterval(refresh, 8 * 60_000); + return () => { + active = false; + window.clearInterval(interval); + }; + }, [open, message]); + useEffect(() => { + if (!open) return; + let active = true; + const refresh = () => + void mediaApi + .listProductImports() + .then((imports) => { + if (!active) return; + const loaded = imports.map((asset) => ({ + id: `asset:${asset.id}`, + url: asset.preview_url, + label: new URL(asset.source_url).pathname.split('/').pop() || '网络图片', + origin: 'network' as const, + assetId: asset.id, + sourceUrl: asset.source_url, + })); + setNetworkImages((current) => [ + ...current.filter( + (image) => !loaded.some((item) => item.assetId === image.assetId) + ), + ...loaded, + ]); + }) + .catch((error) => { + if (active) message.warning(`网络图片列表加载失败:${apiErrorMessage(error)}`); + }); + refresh(); + const interval = window.setInterval(refresh, 8 * 60_000); + return () => { + active = false; + window.clearInterval(interval); + }; + }, [open, message]); + + const uploadFile = async (file: File) => { + setUploadCount((count) => count + 1); + try { + return await onUploadFile(file); + } finally { + setUploadCount((count) => count - 1); + } + }; + const uploadToRight = async (file: File) => { + const error = uploadFileError(file); + if (error) { + message.warning(error); + return Upload.LIST_IGNORE; + } + const fingerprint = `${file.name}:${file.size}:${file.lastModified}`; + if (fingerprints.current.has(fingerprint)) { + message.info(`${file.name} 已在上传图片列表中`); + return Upload.LIST_IGNORE; + } + fingerprints.current.add(fingerprint); + try { + const image = await uploadFile(file); + setUploadedImages((current) => + current.some((item) => item.assetId === image.assetId) + ? current + : [image, ...current] + ); + } catch (uploadError) { + fingerprints.current.delete(fingerprint); + message.error(`${file.name} 上传失败:${apiErrorMessage(uploadError)}`); + } + return Upload.LIST_IGNORE; + }; + const addNetworkImages = async () => { + if (!networkUrls.trim() || networkAdding) return; + const request = ++networkRequest.current; + const seen = new Set([ + ...networkImages.map((image) => image.sourceUrl ?? image.url), + ...selectedImages.map((image) => image.sourceUrl ?? image.url), + ]); + const candidates: string[] = []; + let skipped = 0; + for (const line of networkUrls + .split(/\r?\n/) + .map((item) => item.trim()) + .filter(Boolean)) { + const url = networkImageUrl(line); + if (!url || seen.has(url)) { + skipped += 1; + continue; + } + seen.add(url); + candidates.push(url); + } + if (!candidates.length) { + message.warning('没有可添加的 JPG 或 PNG 图片链接'); + return; + } + setNetworkAdding(true); + const results = await Promise.allSettled( + candidates.map((url) => mediaApi.importProductImage({ url })) + ); + if (request !== networkRequest.current) return; + const valid = results.flatMap((result) => + result.status === 'fulfilled' ? [result.value] : [] + ); + skipped += results.length - valid.length; + setNetworkImages((current) => { + const added = valid.map((asset) => ({ + id: `asset:${asset.id}`, + url: asset.preview_url, + label: new URL(asset.source_url).pathname.split('/').pop() || '网络图片', + origin: 'network' as const, + assetId: asset.id, + sourceUrl: asset.source_url, + })); + return [ + ...added, + ...current.filter((image) => !added.some((item) => item.id === image.id)), + ]; + }); + setNetworkUrls(''); + setNetworkAdding(false); + if (valid.length) { + message.success( + `已导入 ${valid.length} 张网络图片${skipped ? `,过滤 ${skipped} 条重复或无效链接` : ''}` + ); + } else message.warning(`未找到可访问的图片链接,已过滤 ${skipped} 条`); + }; + + return { + networkUrls, + setNetworkUrls, + networkAdding, + networkImages, + uploadedImages, + uploading: uploadCount > 0, + uploadFile, + uploadToRight, + addNetworkImages, + }; +} diff --git a/apps/web/src/services/media.ts b/apps/web/src/services/media.ts index 6fbc1ad..3dac447 100644 --- a/apps/web/src/services/media.ts +++ b/apps/web/src/services/media.ts @@ -3,6 +3,9 @@ import type { MediaAccessUrl, MediaAsset, MediaUploadIntent, + ProductUploadImage, + ProductImportedImage, + ImportProductImageRequest, } from '@atlas/contracts'; import { api } from '@/services/api'; @@ -12,6 +15,12 @@ export const WATERMARK_IMAGE_MAX_BYTES = 5 * 1024 * 1024; /** 异步操作查询,契约见 docs/api/conventions.md §5(GET /api/operations/{operation_id}) */ export const mediaApi = { + listProductUploads: () => api.get('/media-assets/product-uploads'), + listProductImports: () => api.get('/media-assets/product-imports'), + importProductImage: (payload: ImportProductImageRequest) => + api.post('/media-assets/product-imports', payload, { + headers: { 'Idempotency-Key': crypto.randomUUID() }, + }), createUploadIntent: (payload: CreateMediaUploadIntentRequest) => api.post('/media/upload-intents', payload, { headers: { 'Idempotency-Key': crypto.randomUUID() }, @@ -47,9 +56,21 @@ export async function sha256Hex(file: File): Promise { * 返回已通过校验的素材,可直接用于图片水印模板的 image_asset_id。 */ export async function uploadWatermarkImage(file: File): Promise { + return uploadImage(file, 'watermark_image'); +} + +/** 商品图片沿用素材直传协议,完成服务端校验后才可写入 SKU 草稿。 */ +export async function uploadProductImage(file: File): Promise { + return uploadImage(file, 'product_image'); +} + +async function uploadImage( + file: File, + purpose: CreateMediaUploadIntentRequest['purpose'] +): Promise { const checksumSha256 = await sha256Hex(file); const intent = await mediaApi.createUploadIntent({ - purpose: 'watermark_image', + purpose, filename: file.name, content_type: file.type as CreateMediaUploadIntentRequest['content_type'], byte_size: file.size, diff --git a/docs/api/product-editor-and-publishing.md b/docs/api/product-editor-and-publishing.md index c01498c..9fd579c 100644 --- a/docs/api/product-editor-and-publishing.md +++ b/docs/api/product-editor-and-publishing.md @@ -127,7 +127,7 @@ Headers:`If-Match: "{product_revision}:{profile_revision}"`、`Idempotency-Key - `null` 的 SKU 规格字段表示继承父商品;包装物流规格的 `status` 为 `missing`/`ambiguous` 时,不允许定价确认或首次上架,但仍允许保存草稿。发布前服务端必须对每个待发布 SKU 解析 `effective_package_spec`,确认长、宽、高、重量均存在且大于 0 后才可生成 Ozon item。实物规格不用于物流;是否向 SPU 或 SKU 展示由其对应的类目 schema 属性决定,尺寸图仍可使用已确认的有效实物规格。 - `target_store_id` 只能是当前工作空间、当前平台的活动店铺。变更目标店铺只更新**尚未导入** listing 的将来发布目标;`imported` listing 永远保留其首次导入时的店铺快照。 - 类目变化后不匹配的旧属性作为未映射草稿返回 warnings,不能自动进入发布 payload。属性由平台 schema 校验 `type`、必填、字典、多值/复杂属性和 `is_aspect`;客户端不能用任意 JSON 绕过。 -- `media_asset_ids` 是本 SKU 当前平台的最终选图顺序;首期最多 15 张。格式只允许 JPG/PNG/WebP,单文件最多 20 MB,边长超过 10,000 px 拒绝,短边小于 700 px 不得作为首图并给出警告。第一张是主图。 +- `media_asset_ids` 是本 SKU 当前平台的最终选图顺序;最多 30 张。格式只允许 JPG/PNG/WebP,单文件最多 20 MB,边长超过 10,000 px 拒绝,短边小于 700 px 不得作为首图并给出警告。第一张是主图。已完成的 `product_image` 上传素材和网络导入素材可供同工作空间的 SKU 选择;采集图片仍须先转存,且来源变体须匹配。 - `common.model` 是用户手填或主动 AI 填报的商品型号;采集时允许为空,但每次首次上架前必须非空。`offer_id` 是用户手填或主动 AI 填报的目标平台卖家货号;采集入箱时不填值,生成时不直接复用 `source_sku`。草稿可为空,但每个待首次上架 SKU 的 `offer_id` 必须非空且在同一店铺/平台唯一。AI 填报接口和回填规则见[方案](../product/ai-autofill-plan.md)。 - `common.source_title` 在编辑页显示为“中文商品名”;`profile.title` 显示为“俄文发布标题”,页面保存时必填。为允许先保存采集草稿再使用 AI 填报,API 草稿契约仍接受空标题;发布就绪校验在 `profile.title` 为空时返回 `PUBLISH_TITLE_REQUIRED`。SKU 发布标题 `variants[].title` 为 `null` 时继承 `profile.title`,不回退到 `display_name`;现有 SKU `tags=[]`、`description=null` 保持继承语义。标题、标签、描述分别独立覆盖。中俄对照按 SPU/SKU 与字段随 `editor-draft` 原子保存;SKU 继承项直接引用 SPU 对照,手工俄文覆盖但无匹配中文时标为对照待更新。发布适配器使用 SKU 有效文案仍是后续工作。 diff --git a/docs/api/watermarks-and-assets.md b/docs/api/watermarks-and-assets.md index 10d757d..fd59696 100644 --- a/docs/api/watermarks-and-assets.md +++ b/docs/api/watermarks-and-assets.md @@ -22,7 +22,7 @@ Headers:`Idempotency-Key` 必填。 } ``` -首期允许 `image/png`、`image/jpeg`、`image/webp`,最大 5 MiB,图片宽高须为 1–8192 px。服务端按用途校验大小/尺寸策略,生成随机 `object_key` 和 10 分钟有效的预签名上传地址。返回 `201 Created`: +允许 `image/png`、`image/jpeg`、`image/webp`。`watermark_image` 最大 5 MiB、宽高 1–8192 px;`product_image` 最大 20 MiB、宽高 1–10000 px。服务端按用途校验大小/尺寸策略,生成随机 `object_key` 和 10 分钟有效的预签名上传地址。返回 `201 Created`: ```json { @@ -79,6 +79,14 @@ Headers:`Idempotency-Key` 必填。 返回 10 分钟有效的短时 `url` 与 `expires_at`。仅当前工作空间的素材可签发;前端在 URL 过期后重新请求,不能把 URL 写回业务表或长期缓存。 +`GET /api/media-assets/product-uploads` 返回当前工作空间最近 100 个已经完成校验的 `product_image` 素材。每项包含 `MediaAsset` 字段及 10 分钟有效的 `preview_url`、`preview_url_expires_at`,供编辑页“上传图片”候选区展示;该地址不得写入草稿。 + +### 1.4 网络商品图片导入 + +`POST /api/media-assets/product-imports`,需 `Idempotency-Key`,请求 `{ "url": "https://example.com/image.jpg" }`。只接受 HTTPS、无用户信息和自定义端口、路径以 `.jpg`、`.jpeg` 或 `.png` 结尾的链接。服务端在每次重定向时解析域名并把连接固定到已检查的公网 IPv4;最多跟随 3 次重定向,下载上限 5 MiB,校验文件内容、MIME 与最大边长 10,000 px。成功返回 `201`,`data` 为 `ProductImportedImage`:`MediaAsset` 字段、原始 `source_url`、10 分钟有效的 `preview_url` 和 `preview_url_expires_at`。重复 URL 更新当前候选素材;原有 SKU 选图不被自动替换。 + +`GET /api/media-assets/product-imports` 返回当前工作空间最近 100 条已导入的网络图片,响应项同上。图片已生成正式素材 ID 后才可进入 SKU 草稿;网络来源地址和预览地址均不得写入 `media_asset_ids`。 + ## 2. 可用字体 `GET /api/watermark-fonts` diff --git a/docs/product/product-ai-editor.md b/docs/product/product-ai-editor.md index 6b48349..12adfb2 100644 --- a/docs/product/product-ai-editor.md +++ b/docs/product/product-ai-editor.md @@ -61,7 +61,7 @@ AI 输入由两部分组成:一是用户在右侧“采集的商品信息” 点击任何 AI 动作前须先保存草稿,服务端再从当前 revision 组装输入;客户端不得把未保存的尺寸或自行解析的来源 JSON 直接提交给 AI。父商品规格与 SKU 有效规格均以后端读模型为准,SKU 的空覆盖字段按继承规则读取左侧父商品规格。 -SKU 图片编辑弹窗左侧是该 SKU 的已选图片(排序/移除),右侧分组展示 AI 生图记录、采集主图、采集 SKU 图和采集详情图。商品级素材可被多个 SKU 选择;明确有稳定来源关联的 SKU 图只展示给对应 SKU。Ozon 首期每个 SKU 最多选择 15 张发布图片,第一张为主图,可拖动排序;平台适配器以自身限制驱动该上限。 +SKU 图片选择弹窗左侧是该 SKU 的已选图片(排序/移除),右侧分组展示 AI 生图记录、采集主图、采集 SKU 图、采集详情图、网络图片和上传图片。商品级素材可被多个 SKU 选择;明确有稳定来源关联的 SKU 图只展示给对应 SKU。Ozon 首期每个 SKU 最多选择 30 张发布图片,第一张为主图,可拖动排序;平台适配器以自身限制驱动该上限。 已选图片悬浮时显示“编辑”菜单,至少包含“添加水印”。点击后在弹窗中选择一个工作空间水印模板,后台创建带水印的派生素材;原图和其他 SKU 的选择不被改写。用户确认后,以派生图替换该 SKU 当前图片位置,并可在同一弹窗撤销为原图。首期不在发布时自动加水印,也不因门店默认水印自动处理图片;后续再支持默认模板的预选、批量应用或自动应用。 diff --git a/docs/project/HANDOFF.md b/docs/project/HANDOFF.md index e3a4163..40071df 100644 --- a/docs/project/HANDOFF.md +++ b/docs/project/HANDOFF.md @@ -1,6 +1,6 @@ # Atlas Seller Kit 交接说明 -> 更新:2026-09-23。此文是新 AI 对话、架构讨论与开发工作的首读入口。开始开发前,必须先阅读并遵守 [`development-collaboration-contract.md`](./development-collaboration-contract.md) 和仓库 `AGENTS.md`。下方按日期记录的旧方案仅供追溯;当前状态以本节及 [1.39](#139-开发进度与交接2026-09-23商品-ai-填报与编辑页收口) 为准。 +> 更新:2026-09-24。此文是新 AI 对话、架构讨论与开发工作的首读入口。开始开发前,必须先阅读并遵守 [`development-collaboration-contract.md`](./development-collaboration-contract.md) 和仓库 `AGENTS.md`。下方按日期记录的旧方案仅供追溯;当前状态以本节及最新的 1.40 节为准。 ## 1. 项目定位 @@ -12,13 +12,13 @@ pnpm monorepo 包含 Web、NestJS API、BullMQ Worker、WXT 浏览器扩展、 ## 0. 交接结论(先看这里) -已完成“采集 → 采集箱 → 商品编辑草稿”的主要数据闭环、平台级类目/属性/字典目录与发布预检。编辑页已接入异步「AI 填报」:基于来源资料与 Atlas 类目树生成类目建议、型号/SKU 货号、证据充分的包装建议及 SPU 中俄文案;单 SKU 文案由用户单独生成并确认应用。AI 结果只回填未保存表单,保存草稿仍是持久化边界。Ozon 动态类目属性可按已选类目加载并手工填写,**当前不由 AI 自动填报**。 +已完成“采集 → 采集箱 → 商品编辑草稿”的主要数据闭环、平台级类目/属性/字典目录与发布预检。编辑页已接入异步「AI 填报」:基于来源资料与 Atlas 类目树生成类目建议、型号/SKU 货号、证据充分的包装建议及 SPU 中俄文案;单 SKU 文案由用户单独生成并确认应用。AI 结果只回填未保存表单,保存草稿仍是持久化边界。Ozon 动态类目属性可按已选类目加载并手工填写,**当前不由 AI 自动填报**。SKU 图片选择现支持采集候选图、工作空间上传图和网络导入图;在弹窗确认后回填草稿,页面保存后持久化。 -尚未完成可直接发布的闭环:定价确认、真正的 Ozon 发布 job/import 及结果轮询、在线商品同步仍待开发。AI 类目推荐和文案已能运行,但不能等同于跨来源样本验收;尤其应继续用 1688/淘宝/天猫样本核验 Atlas 类目候选质量,以及俄文文案、包装证据和 SKU 继承行为。AI 生图仍是占位入口。 +尚未完成可直接发布的闭环:定价确认、真正的 Ozon 发布 job/import 及结果轮询、在线商品同步仍待开发。AI 类目推荐和文案已能运行,但不能等同于跨来源样本验收;尤其应继续用 1688/淘宝/天猫样本核验 Atlas 类目候选质量,以及俄文文案、包装证据和 SKU 继承行为。AI 生图仍是占位入口;图片「编辑」菜单中的 AI 编辑、水印、一键 3:4 等处理动作本轮未接入。 -当前优先事项:用真实来源样本回归 AI 类目推荐、文案与候选素材;完成定价确认;让首次发布 job 复用既有发布预检,再实现 Ozon import 与结果轮询。平台参考目录目前由平台级参考源和同步任务维护,不能把 9 月 10 日的门店级缓存描述当作现行架构。 +当前优先事项:用真实来源样本回归 AI 类目推荐、文案与图片选择,补做真实图片上传/网络导入联调;完成定价确认;让首次发布 job 复用既有发布预检,再实现 Ozon import 与结果轮询。平台参考目录目前由平台级参考源和同步任务维护,不能把 9 月 10 日的门店级缓存描述当作现行架构。 -接手前请先阅读 `git status --short`;截至本次更新,AI 填报涉及 API、Worker、Web、contracts、迁移和文档的改动仍在工作区,其中若干文件未跟踪,不能当作已提交。不要用 `git reset --hard` 或批量清理未跟踪文件。共享 contracts 类型需先构建,再执行 Web/API/Worker 检查。开发验证需要时可按 `AGENTS.md` 先确认进程与端口,再启动或重启 API/Worker;下方旧章节中的“不可重启”说法已失效。 +接手前请先阅读 `git status --short`,确认当前工作区状态;本轮图片选择的 Web、API、contracts、迁移、测试和文档随本报告一起提交。不要用 `git reset --hard` 或批量清理未跟踪文件。共享 contracts 类型需先构建,再执行 Web/API/Worker 检查。开发验证需要时可按 `AGENTS.md` 先确认进程与端口,再启动或重启 API/Worker;下方旧章节中的“不可重启”说法已失效。 ### 1.1 开发进度(2026-09-02) @@ -446,6 +446,20 @@ BullMQ Worker(最多 5 次,5 秒起指数退避) 详细字段规则与任务接口见 [`ai-autofill-plan.md`](../product/ai-autofill-plan.md) 和 [`product-editor-and-publishing.md`](../api/product-editor-and-publishing.md)。 +### 1.40 开发进度与交接(2026-09-24):SKU 图片选择 + +**已完成的链路** + +- 新采集商品的 SKU 默认没有已选图片,编辑页显示「选择图片」入口。弹窗标题为「SKU 图片选择」;左侧已选图片最多 30 张,第一张为主图,可移出、调整顺序、批量选择和预览;右侧按 AI 生图记录、采集图片、网络图片、上传图片展示候选图,采集主图/SKU 图/详情图使用默认展开的折叠面板。左右图片区各自滚动;图片卡片与预览复用 Ant Design 组件。弹窗确认只更新当前编辑表单,页面「保存」才持久化选图。 +- 图片交互拆为 `SkuImageCard`、`SkuImageSourceTabs`、`useSkuImageLibrary`、`skuImageSelection` 与弹窗组件;`ProductVariantSection` 负责 SKU 表单集成。左侧上传完成后进入已选区;右侧上传只进入工作空间候选图库,由用户决定是否加入。网络图片按行导入,过滤重复或无效链接;已选图与候选图通过边框及角标标识状态。 +- API 扩展现有两阶段素材上传,新增 `product_image` 用途、20 MiB/最大边长 10,000 px 校验,并提供最近 100 张工作空间上传图及短时预览地址。网络图通过 `POST /api/media-assets/product-imports` 导入为正式素材,支持最近 100 条候选记录查询;仅接收 HTTPS JPG/PNG、最多 5 MiB,逐次校验重定向目标并固定到已检查的公网 IPv4,校验实际文件内容与尺寸。来源 URL 保存在独立的工作空间隔离记录中,预览 URL 不写入商品草稿。 +- 已选素材通过 `media_asset_ids` 顺序随草稿保存;采集候选素材按原有按需转存链路物化,服务端新增对已上传/已导入素材归属及状态的验证。共享契约、服务端校验与数据库顺序约束均调整为每个 SKU 最多 30 张;迁移为 `031_sku_image_limit.sql` 和 `032_product_image_imports.sql`。接口与产品说明见 [`product-editor-and-publishing.md`](../api/product-editor-and-publishing.md)、[`watermarks-and-assets.md`](../api/watermarks-and-assets.md) 和 [`product-ai-editor.md`](../product/product-ai-editor.md)。 + +**验证与后续范围** + +- 本地 `031`、`032` 迁移已执行。contracts、database、API、Web TypeScript 检查及 API/Web 生产构建通过;API 媒体单元测试共 6 项、商品编辑集成测试共 11 项通过。集成测试覆盖 30 张选图保存、已上传和网络导入素材的草稿引用、非法内网网络图片链接拒绝。 +- 尚未用真实外部图片 URL、浏览器直传 MinIO 和完整编辑页操作做端到端回归;上线前需联调短时预览地址刷新、上传失败重试、跨 SKU 选图与重新打开弹窗。AI 生图和图片「编辑」菜单中的 AI 编辑、添加水印、一键 3:4、批量翻译由后续迭代接入;当前菜单仅提供入口提示,设为封图、下载和移出已选可用。 + ## 2. 已确认产品决策 | 主题 | 决策 | @@ -509,9 +523,9 @@ User → Workspace → Membership / Role > 注意:下面的清单不能覆盖 2026-09-18 的 [Ozon 参考目录决策](../architecture/platform-product-reference-catalog.md)。平台目录、后台、同步基础链路和商品编辑器迁移已经实现;旧门店目录只作为兼容/回滚数据保留。 -当前执行顺序(2026-09-23): +当前执行顺序(2026-09-24): -1. 核对 AI 任务迁移、运行进程版本和四类来源的真实样本;回归类目选择、中俄文案(俄文字段不含汉字)、包装证据、SKU 文案继承及候选媒体按需物化。 +1. 核对 AI 任务迁移、运行进程版本和四类来源的真实样本;回归类目选择、中俄文案(俄文字段不含汉字)、包装证据、SKU 文案继承及候选媒体按需物化,并联调 SKU 图片上传、网络导入与保存。 2. 完成定价确认、平台目录大字典续传与管理员浏览器回归;旧门店目录继续保留,暂不清理。 3. 将真正的 `publish-jobs` 创建事务接入已完成的发布预检,固化发布快照后实现 Ozon `/v3/product/import` 与结果轮询,再推进在线商品同步;不得重新实现另一套目录判断。 diff --git a/packages/contracts/src/media.ts b/packages/contracts/src/media.ts index d332a0e..0785980 100644 --- a/packages/contracts/src/media.ts +++ b/packages/contracts/src/media.ts @@ -5,17 +5,21 @@ const checksum = z .regex(/^[a-f0-9]{64}$/, '必须是 SHA-256 十六进制摘要'); export const createMediaUploadIntentRequestSchema = z .object({ - purpose: z.literal('watermark_image'), + purpose: z.enum(['watermark_image', 'product_image']), filename: z.string().trim().min(1).max(255), content_type: z.enum(['image/png', 'image/jpeg', 'image/webp']), byte_size: z .number() .int() .min(1) - .max(5 * 1024 * 1024), + .max(20 * 1024 * 1024), checksum_sha256: checksum, }) - .strict(); + .strict() + .refine( + (value) => value.purpose !== 'watermark_image' || value.byte_size <= 5 * 1024 * 1024, + { path: ['byte_size'], message: '水印图片不能超过 5 MiB' }, + ); export const completeMediaUploadIntentRequestSchema = z .object({ checksum_sha256: checksum, @@ -26,6 +30,18 @@ export const createMediaAccessUrlRequestSchema = z purpose: z.literal('preview'), }) .strict(); +export const importProductImageRequestSchema = z.object({ + url: z.url().max(2048).refine((value) => { + try { + const url = new URL(value); + return url.protocol === 'https:' && !url.username && !url.password && !url.port + && /\.(?:jpe?g|png)$/i.test(url.pathname); + } catch { + return false; + } + }, '仅支持 HTTPS 的 JPG 或 PNG 图片链接'), +}).strict(); +export type ImportProductImageRequest = z.infer; export type CreateMediaUploadIntentRequest = z.infer< typeof createMediaUploadIntentRequestSchema @@ -60,6 +76,13 @@ export type MediaAsset = { created_at: string; }; +/** 工作空间已上传的商品图片,预览地址过期后需重新读取。 */ +export type ProductUploadImage = MediaAsset & { + preview_url: string; + preview_url_expires_at: string; +}; +export type ProductImportedImage = ProductUploadImage & { source_url: string }; + /** POST /api/media-assets/{asset_id}/access-urls 响应(短时预览 URL) */ export type MediaAccessUrl = { url: string; diff --git a/packages/contracts/src/product-editor.ts b/packages/contracts/src/product-editor.ts index 2c00d88..6f5b50e 100644 --- a/packages/contracts/src/product-editor.ts +++ b/packages/contracts/src/product-editor.ts @@ -552,7 +552,7 @@ export const saveProductEditorDraftRequestSchema = z offer_id: nullableText(500), media_asset_ids: z .array(uuid) - .max(15) + .max(30) .refine( (ids) => new Set(ids).size === ids.length, 'SKU 图片不能重复', diff --git a/packages/database/migrations/031_sku_image_limit.sql b/packages/database/migrations/031_sku_image_limit.sql new file mode 100644 index 0000000..c107664 --- /dev/null +++ b/packages/database/migrations/031_sku_image_limit.sql @@ -0,0 +1,8 @@ +SET search_path TO atlas, public; + +-- SKU 图片上限扩展到 30 张,保持与编辑草稿契约一致。 +ALTER TABLE product_variant_listing_media + DROP CONSTRAINT product_variant_listing_media_sort_order_check; +ALTER TABLE product_variant_listing_media + ADD CONSTRAINT product_variant_listing_media_sort_order_check + CHECK (sort_order >= 0 AND sort_order < 30); diff --git a/packages/database/migrations/032_product_image_imports.sql b/packages/database/migrations/032_product_image_imports.sql new file mode 100644 index 0000000..8ac7418 --- /dev/null +++ b/packages/database/migrations/032_product_image_imports.sql @@ -0,0 +1,21 @@ +SET search_path TO atlas, public; + +-- 网络图片导入记录与正式素材分开,保留来源 URL 供候选区回显。 +CREATE TABLE product_image_imports ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + workspace_id UUID NOT NULL REFERENCES workspaces(id) ON DELETE RESTRICT, + source_url TEXT NOT NULL, + asset_id UUID NOT NULL, + created_by_user_id UUID REFERENCES users(id) ON DELETE SET NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + FOREIGN KEY (workspace_id, asset_id) REFERENCES media_assets(workspace_id, id) ON DELETE RESTRICT, + UNIQUE (workspace_id, source_url) +); +CREATE INDEX ix_product_image_imports_recent + ON product_image_imports (workspace_id, updated_at DESC, id DESC); +ALTER TABLE product_image_imports ENABLE ROW LEVEL SECURITY; +ALTER TABLE product_image_imports FORCE ROW LEVEL SECURITY; +CREATE POLICY workspace_isolation ON product_image_imports + USING (workspace_id = app_current_workspace_id()) + WITH CHECK (workspace_id = app_current_workspace_id());