From 79b310efe7c140b0ea2cd18ace5a4d0fb0091fa5 Mon Sep 17 00:00:00 2001 From: joey-home Date: Tue, 6 Oct 2026 18:30:05 +0800 Subject: [PATCH] =?UTF-8?q?feat(edit):=20=E7=BC=96=E8=BE=91=E4=B8=8A?= =?UTF-8?q?=E6=8A=A5=E7=BB=86=E8=8A=82=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/api/src/collection/collection.service.ts | 6 +- apps/api/src/extension/extension.service.ts | 21 ++- .../media/aspect-ratio-derivative.service.ts | 6 +- apps/api/src/media/image-suite.service.ts | 2 +- apps/api/src/media/media-ai-edit.service.ts | 8 +- apps/api/src/media/media.controller.ts | 38 ++++- apps/api/src/media/media.module.ts | 3 +- apps/api/src/media/media.service.ts | 42 +++-- .../media/product-image-deletion.service.ts | 125 ++++++++++++++ ...roduct-image-derivative-history.service.ts | 2 +- .../src/media/product-image-task.service.ts | 6 +- .../src/media/watermark-derivative.service.ts | 6 +- apps/api/src/pricing/pricing.service.ts | 44 +++-- .../product-editor/product-editor.models.ts | 1 + .../product-editor/product-editor.publish.ts | 6 +- .../product-editor/product-editor.service.ts | 27 +-- apps/api/test/extension.e2e.test.ts | 160 +++++++++++++++++- apps/api/test/media-import.unit.test.ts | 11 +- .../api/test/media-product-image.unit.test.ts | 21 ++- apps/api/test/pricing.e2e.test.ts | 68 ++++++++ apps/api/test/product-ai.unit.test.ts | 54 ++++++ ...roduct-editor-processed-media.unit.test.ts | 15 +- apps/api/test/product-editor.e2e.test.ts | 36 +++- .../product-image-deletion.database.test.ts | 83 +++++++++ .../product-image-library.database.test.ts | 101 +++++++++++ apps/api/test/product-publish.e2e.test.ts | 119 +++++++++++++ apps/extension/entrypoints/background.ts | 15 +- .../entrypoints/collector.content.ts | 40 +++-- apps/extension/lib/api.ts | 18 +- .../lib/collection-payload-validation.ts | 11 ++ apps/extension/lib/collector-panel.ts | 58 +++++++ apps/extension/lib/product-media.ts | 57 ++++++- apps/extension/lib/request-errors.ts | 40 +++++ apps/extension/lib/source-parsers.ts | 62 ++++--- apps/extension/test/1688-gallery.test.mts | 65 +++++++ apps/extension/test/1688-sku.test.mts | 54 ++++++ .../fixtures/1688-gallery-background.html | 21 +++ apps/extension/test/request-errors.test.mts | 56 ++++++ apps/extension/wxt.config.ts | 9 + apps/web/HANDOFF.md | 89 +++++++++- apps/web/src/components/CopyTextButton.tsx | 19 +++ .../components/pricing/PricingCalculator.tsx | 2 +- .../web/src/components/pricing/SkuPricing.tsx | 122 +++++++------ .../src/pages/products/ProductEditPage.css | 4 + .../src/pages/products/ProductEditPage.tsx | 116 +++++++++++-- .../components/DynamicAttributeFields.tsx | 47 +++-- .../components/ImageSuiteReferencePicker.tsx | 6 +- .../components/ProductAttributeSection.tsx | 2 + .../components/ProductCopyAiDialogs.css | 59 +++++++ .../components/ProductCopyAiDialogs.tsx | 111 ++++++++---- .../components/ProductOtherSection.tsx | 3 +- .../components/ProductPublishActions.tsx | 45 ++++- .../components/ProductVariantSection.css | 25 +++ .../components/ProductVariantSection.tsx | 12 +- .../components/SkuImageEditorModal.tsx | 122 ++++++++++--- .../products/components/SkuInfoPanel.tsx | 37 ++-- .../products/components/SkuOfferIdInput.tsx | 27 +++ .../products/components/useSkuImageLibrary.ts | 61 +++++-- apps/web/src/pages/products/skuOfferId.ts | 38 +++++ apps/web/src/services/media.ts | 21 ++- apps/web/src/services/productEditor.ts | 22 ++- apps/worker/src/collection-assets.ts | 6 +- apps/worker/src/config.ts | 3 + apps/worker/src/main.ts | 17 +- apps/worker/src/media-ai-edit.ts | 54 +++++- apps/worker/src/media-aspect-ratio.ts | 4 +- apps/worker/src/media-asset-delete.ts | 25 +++ apps/worker/src/media-watermark.ts | 4 +- apps/worker/src/ozon-description.ts | 9 + apps/worker/src/product-ai.ts | 10 +- apps/worker/src/product-publish.ts | 5 +- apps/worker/test/media-ai-edit.test.mjs | 73 +++++++- apps/worker/test/media-aspect-ratio.test.mjs | 2 +- apps/worker/test/product-ai.test.mjs | 47 +++++ docs/api/collection-and-extension.md | 8 +- docs/api/pricing.md | 8 +- docs/api/product-editor-and-publishing.md | 24 ++- docs/api/watermarks-and-assets.md | 21 ++- docs/product/ai-autofill-plan.md | 3 + docs/project/HANDOFF.md | 125 ++++++++++++++ .../project/m2.2-m2.3-extension-collection.md | 18 +- .../m2.4-source-parsers-and-inspection.md | 2 +- .../implementation-readiness-open-items.md | 1 + packages/contracts/src/collection.ts | 3 +- packages/contracts/src/index.ts | 1 + packages/contracts/src/media.ts | 24 ++- packages/contracts/src/product-ai.ts | 6 +- packages/contracts/src/product-editor.ts | 16 +- packages/contracts/src/product-limits.ts | 2 + .../060_product_image_library_scope.sql | 20 +++ .../migrations/061_product_image_deletion.sql | 63 +++++++ ...2_media_asset_deletion_reference_guard.sql | 21 +++ 92 files changed, 2764 insertions(+), 368 deletions(-) create mode 100644 apps/api/src/media/product-image-deletion.service.ts create mode 100644 apps/api/test/product-ai.unit.test.ts create mode 100644 apps/api/test/product-image-deletion.database.test.ts create mode 100644 apps/api/test/product-image-library.database.test.ts create mode 100644 apps/extension/lib/collection-payload-validation.ts create mode 100644 apps/extension/lib/collector-panel.ts create mode 100644 apps/extension/lib/request-errors.ts create mode 100644 apps/extension/test/1688-gallery.test.mts create mode 100644 apps/extension/test/fixtures/1688-gallery-background.html create mode 100644 apps/extension/test/request-errors.test.mts create mode 100644 apps/web/src/components/CopyTextButton.tsx create mode 100644 apps/web/src/pages/products/components/SkuOfferIdInput.tsx create mode 100644 apps/web/src/pages/products/skuOfferId.ts create mode 100644 apps/worker/src/media-asset-delete.ts create mode 100644 apps/worker/src/ozon-description.ts create mode 100644 packages/contracts/src/product-limits.ts create mode 100644 packages/database/migrations/060_product_image_library_scope.sql create mode 100644 packages/database/migrations/061_product_image_deletion.sql create mode 100644 packages/database/migrations/062_media_asset_deletion_reference_guard.sql diff --git a/apps/api/src/collection/collection.service.ts b/apps/api/src/collection/collection.service.ts index 4daf0c2..bc58148 100644 --- a/apps/api/src/collection/collection.service.ts +++ b/apps/api/src/collection/collection.service.ts @@ -167,7 +167,7 @@ export class CollectionService { created_at: Date | string; completed_at: Date | string | null; }>( - `SELECT id, source_variant_id, media_type::text AS media_type, poster_url, role::text AS role, source_url, status::text AS status, asset_id, error_payload, created_at, completed_at FROM atlas.collection_asset_transfers WHERE collection_item_id = $1 AND source_snapshot_revision = $2 ORDER BY coalesce((error_payload->>'sort_order')::integer, 0), created_at, id`, + `SELECT id, source_variant_id, media_type::text AS media_type, poster_url, role::text AS role, source_url, status::text AS status, asset_id, error_payload, created_at, completed_at FROM atlas.collection_asset_transfers WHERE collection_item_id = $1 AND source_snapshot_revision = $2 AND deleted_at IS NULL ORDER BY coalesce((error_payload->>'sort_order')::integer, 0), created_at, id`, [id, source.rows[0]?.source_snapshot_revision ?? 1], ); return { @@ -280,7 +280,7 @@ export class CollectionService { const result = await tx.query<{ source_snapshot_revision: string | number; }>( - `UPDATE atlas.collection_asset_transfers t SET status = 'queued', error_payload = NULL, updated_at = now() FROM atlas.collection_items c WHERE t.collection_item_id = c.id AND t.collection_item_id = $1 AND t.id = ANY($2::uuid[]) AND t.source_snapshot_revision = c.source_snapshot_revision AND t.status IN ('failed', 'cancelled') RETURNING c.source_snapshot_revision`, + `UPDATE atlas.collection_asset_transfers t SET status = 'queued', error_payload = NULL, updated_at = now() FROM atlas.collection_items c WHERE t.collection_item_id = c.id AND t.collection_item_id = $1 AND t.id = ANY($2::uuid[]) AND t.source_snapshot_revision = c.source_snapshot_revision AND t.deleted_at IS NULL AND t.status IN ('failed', 'cancelled') RETURNING c.source_snapshot_revision`, [id, input.transfer_ids], ); if (result.rowCount !== input.transfer_ids.length) @@ -328,7 +328,7 @@ export class CollectionService { const result = await tx.query<{ source_snapshot_revision: string | number; }>( - `UPDATE atlas.collection_asset_transfers t SET status = 'queued', error_payload = NULL, completed_at = NULL, updated_at = now() FROM atlas.collection_items c WHERE t.collection_item_id = c.id AND t.collection_item_id = $1 AND t.id = ANY($2::uuid[]) AND t.source_snapshot_revision = c.source_snapshot_revision AND t.status = 'pending_selection' RETURNING c.source_snapshot_revision`, + `UPDATE atlas.collection_asset_transfers t SET status = 'queued', error_payload = NULL, completed_at = NULL, updated_at = now() FROM atlas.collection_items c WHERE t.collection_item_id = c.id AND t.collection_item_id = $1 AND t.id = ANY($2::uuid[]) AND t.source_snapshot_revision = c.source_snapshot_revision AND t.deleted_at IS NULL AND t.status = 'pending_selection' RETURNING c.source_snapshot_revision`, [id, input.transfer_ids], ); if (result.rowCount !== input.transfer_ids.length) diff --git a/apps/api/src/extension/extension.service.ts b/apps/api/src/extension/extension.service.ts index f79c259..56f1acb 100644 --- a/apps/api/src/extension/extension.service.ts +++ b/apps/api/src/extension/extension.service.ts @@ -87,9 +87,13 @@ export class ExtensionService { ], ); const product = await tx.query<{ id: string }>( - `INSERT INTO atlas.products (workspace_id, collection_item_id, source_title) - VALUES (current_setting('app.workspace_id')::uuid, $1, $2) RETURNING id`, - [item.rows[0].id, payload.common.title?.value ?? null], + `INSERT INTO atlas.products (workspace_id, collection_item_id, source_title, purchase_url) + VALUES (current_setting('app.workspace_id')::uuid, $1, $2, $3) RETURNING id`, + [ + item.rows[0].id, + payload.common.title?.value ?? null, + payload.source.canonicalUrl, + ], ); await this.writeSourceSnapshotRows( tx, @@ -449,21 +453,24 @@ export class ExtensionService { const status = await tx.query<{ status: string; source_title: string | null; + variant_count: string; }>( - 'SELECT status, source_title FROM atlas.collection_items WHERE id = $1', - [collectionItemId], + `SELECT status, source_title, + (SELECT count(*)::text FROM atlas.product_variants WHERE product_id=$2 AND deleted_at IS NULL) AS variant_count + FROM atlas.collection_items WHERE id=$1`, + [collectionItemId, productId], ); return { collection_item: { id: collectionItemId, status: status.rows[0]?.status ?? 'received', source_title: status.rows[0]?.source_title ?? null, - variant_count: 0, + variant_count: Number(status.rows[0]?.variant_count ?? 0), }, product_id: productId, reused, refreshed, - editor_entry_path: `/collection-test/${collectionItemId}`, + editor_entry_path: `/products/${productId}/editor?collection_item_id=${collectionItemId}`, materialization: { total: Number(count.rows[0]?.total ?? 0), pending_selection: Number( diff --git a/apps/api/src/media/aspect-ratio-derivative.service.ts b/apps/api/src/media/aspect-ratio-derivative.service.ts index f962593..81943e8 100644 --- a/apps/api/src/media/aspect-ratio-derivative.service.ts +++ b/apps/api/src/media/aspect-ratio-derivative.service.ts @@ -47,7 +47,7 @@ export class AspectRatioDerivativeService { async (tx) => { const source = await tx.query<{ id: string }>( `SELECT id FROM atlas.media_assets - WHERE id = $1 AND content_type IN ('image/jpeg', 'image/png', 'image/webp') + WHERE id = $1 AND deleted_at IS NULL AND content_type IN ('image/jpeg', 'image/png', 'image/webp') AND byte_size <= 20971520 AND width_px BETWEEN 1 AND 10000 AND height_px BETWEEN 1 AND 10000`, [assetId], @@ -107,14 +107,14 @@ export class AspectRatioDerivativeService { a.content_type, a.byte_size::text, a.width_px, a.height_px, a.created_at AS asset_created_at FROM atlas.media_aspect_ratio_derivatives d - LEFT JOIN atlas.media_assets a ON a.id = d.output_asset_id + LEFT JOIN atlas.media_assets a ON a.id = d.output_asset_id AND a.deleted_at IS NULL WHERE d.id = $1`, [derivativeId], ); return result.rows[0] ?? null; }); if (!row) throw new ApiError(404, 'RESOURCE_NOT_FOUND', '图片处理任务不存在'); - const access = row.output_asset_id + const access = row.output_asset_id && row.content_type ? await this.media.accessUrl(workspaceId, row.output_asset_id) : null; return { diff --git a/apps/api/src/media/image-suite.service.ts b/apps/api/src/media/image-suite.service.ts index 7cf5920..8f51739 100644 --- a/apps/api/src/media/image-suite.service.ts +++ b/apps/api/src/media/image-suite.service.ts @@ -57,7 +57,7 @@ export class ImageSuiteService { '目标 SKU 不存在或不属于此商品,请先保存新 SKU', ); const assets = await tx.query<{ id: string }>( - `SELECT id FROM atlas.media_assets WHERE id = ANY($1::uuid[]) + `SELECT id FROM atlas.media_assets WHERE id = ANY($1::uuid[]) AND deleted_at IS NULL AND content_type IN ('image/jpeg', 'image/png', 'image/webp') AND byte_size <= 20971520 AND width_px BETWEEN 1 AND 10000 AND height_px BETWEEN 1 AND 10000`, [request.reference_asset_ids], diff --git a/apps/api/src/media/media-ai-edit.service.ts b/apps/api/src/media/media-ai-edit.service.ts index c93d5da..16ce410 100644 --- a/apps/api/src/media/media-ai-edit.service.ts +++ b/apps/api/src/media/media-ai-edit.service.ts @@ -44,7 +44,7 @@ export class MediaAiEditService { if (new Set(allIds).size !== allIds.length) throw new ApiError(422, 'INVALID_REFERENCE_IMAGE', '主图和辅助图不能重复'); const assets = await tx.query<{ id: string }>( - `SELECT id FROM atlas.media_assets WHERE id = ANY($1::uuid[]) + `SELECT id FROM atlas.media_assets WHERE id = ANY($1::uuid[]) AND deleted_at IS NULL AND content_type IN ('image/jpeg', 'image/png', 'image/webp') AND byte_size <= 20971520 AND width_px BETWEEN 1 AND 10000 AND height_px BETWEEN 1 AND 10000`, [allIds], @@ -88,13 +88,13 @@ export class MediaAiEditService { a.created_at AS asset_created_at FROM atlas.media_ai_edits e LEFT JOIN atlas.operations o ON o.id = e.id AND o.workspace_id = e.workspace_id - LEFT JOIN atlas.media_assets a ON a.id = e.output_asset_id + LEFT JOIN atlas.media_assets a ON a.id = e.output_asset_id AND a.deleted_at IS NULL WHERE e.id = $1`, [id], ); return result.rows[0] ?? null; }); if (!row) throw new ApiError(404, 'RESOURCE_NOT_FOUND', 'AI 编辑任务不存在'); - const access = row.output_asset_id ? await this.media.accessUrl(workspaceId, row.output_asset_id) : null; + const access = row.output_asset_id && row.content_type ? await this.media.accessUrl(workspaceId, row.output_asset_id) : null; return { id: row.id, status: row.status, source_asset_id: row.source_asset_id, error_code: row.error_code, failure_source: row.status === 'failed' ? row.failure_source ?? null : null, @@ -118,7 +118,7 @@ export class MediaAiEditService { a.content_type, a.byte_size::text, a.width_px, a.height_px, a.created_at AS asset_created_at FROM atlas.media_ai_edits e - JOIN atlas.media_assets a ON a.id = e.output_asset_id + JOIN atlas.media_assets a ON a.id = e.output_asset_id AND a.deleted_at IS NULL WHERE e.status = 'succeeded' AND e.product_id = $1 ORDER BY e.completed_at DESC LIMIT 100`, [productId], ); diff --git a/apps/api/src/media/media.controller.ts b/apps/api/src/media/media.controller.ts index e17738e..7d6d1db 100644 --- a/apps/api/src/media/media.controller.ts +++ b/apps/api/src/media/media.controller.ts @@ -1,3 +1,4 @@ +import { ProductImageDeletionService } from './product-image-deletion.service.js'; import { Body, Controller, @@ -17,6 +18,7 @@ import { ApiTags, } from '@nestjs/swagger'; import { + deleteProductImageRequestSchema, completeMediaUploadIntentRequestSchema, createMediaAccessUrlRequestSchema, createMediaUploadIntentRequestSchema, @@ -47,6 +49,7 @@ import { ImageSuiteService } from './image-suite.service.js'; @Controller() export class MediaController { public constructor( + @Inject(ProductImageDeletionService) private readonly imageDeletions: ProductImageDeletionService, @Inject(MediaService) private readonly media: MediaService, @Inject(AspectRatioDerivativeService) private readonly derivatives: AspectRatioDerivativeService, @Inject(WatermarkDerivativeService) private readonly watermarkDerivatives: WatermarkDerivativeService, @@ -56,6 +59,29 @@ export class MediaController { @Inject(ImageSuiteService) private readonly suites: ImageSuiteService, ) {} + @Post('products/:productId/image-deletions') + public async deleteProductImage(@Param('productId') productId: string, @Body() body: unknown, + @Headers('idempotency-key') key: string | undefined, @Req() request: AuthenticatedRequest, + @CurrentWorkspace() workspace: Workspace, @Res({ passthrough: true }) response: { status: (code: number) => void }) { + const result = await this.imageDeletions.create(workspace.id, request.auth.userId, uuid('product_id', productId), idempotencyKey(key), parse(deleteProductImageRequestSchema, body)); + response.status(result.status); return result.body; + } + @Get('products/:productId/image-deletions') + public async pendingImageDeletions(@Param('productId') productId: string, @CurrentWorkspace() workspace: Workspace) { + return { data: await this.imageDeletions.pending(workspace.id, uuid('product_id', productId)) }; + } + @Get('products/:productId/image-deletions/:deletionId') + public async readImageDeletion(@Param('productId') productId: string, @Param('deletionId') id: string, @CurrentWorkspace() workspace: Workspace) { + return { data: await this.imageDeletions.read(workspace.id, uuid('product_id', productId), uuid('deletion_id', id)) }; + } + @Post('products/:productId/image-deletions/:deletionId/retry') + public async retryImageDeletion(@Param('productId') productId: string, @Param('deletionId') id: string, + @Headers('idempotency-key') key: string | undefined, @Req() request: AuthenticatedRequest, @CurrentWorkspace() workspace: Workspace, + @Res({ passthrough: true }) response: { status: (code: number) => void }) { + const result = await this.imageDeletions.retry(workspace.id, request.auth.userId, uuid('product_id', productId), uuid('deletion_id', id), idempotencyKey(key)); + response.status(result.status); return result.body; + } + @Post('products/:productId/ai/suite-plans') public async planSuite(@Param('productId') productId: string, @Body() body: unknown, @Headers('idempotency-key') key: string | undefined, @Req() request: AuthenticatedRequest, @@ -214,15 +240,15 @@ export class MediaController { } @Get('media-assets/product-uploads') - @ApiOkResponse({ description: '最近上传的商品图片及短时预览地址' }) - public async productUploads(@CurrentWorkspace() workspace: Workspace) { - return { data: await this.media.productUploads(workspace.id) }; + @ApiOkResponse({ description: '当前商品最近上传的图片及短时预览地址' }) + public async productUploads(@CurrentWorkspace() workspace: Workspace, @Query('product_id') productId: string) { + return { data: await this.media.productUploads(workspace.id, uuid('product_id', productId)) }; } @Get('media-assets/product-imports') - @ApiOkResponse({ description: '最近导入的网络商品图片及短时预览地址' }) - public async productImports(@CurrentWorkspace() workspace: Workspace) { - return { data: await this.media.productImports(workspace.id) }; + @ApiOkResponse({ description: '当前商品最近导入的网络图片及短时预览地址' }) + public async productImports(@CurrentWorkspace() workspace: Workspace, @Query('product_id') productId: string) { + return { data: await this.media.productImports(workspace.id, uuid('product_id', productId)) }; } @Post('media-assets/product-imports') diff --git a/apps/api/src/media/media.module.ts b/apps/api/src/media/media.module.ts index d90f0d7..6ff2a6a 100644 --- a/apps/api/src/media/media.module.ts +++ b/apps/api/src/media/media.module.ts @@ -1,3 +1,4 @@ +import { ProductImageDeletionService } from './product-image-deletion.service.js'; import { Module } from '@nestjs/common'; import { IdempotencyService } from '../common/idempotency.service.js'; import { MediaController } from './media.controller.js'; @@ -11,7 +12,7 @@ import { ImageSuiteService } from './image-suite.service.js'; @Module({ controllers: [MediaController], - providers: [IdempotencyService, MediaService, AspectRatioDerivativeService, WatermarkDerivativeService, MediaAiEditService, ProductImageTaskService, ProductImageDerivativeHistoryService, ImageSuiteService], + providers: [ProductImageDeletionService, IdempotencyService, MediaService, AspectRatioDerivativeService, WatermarkDerivativeService, MediaAiEditService, ProductImageTaskService, ProductImageDerivativeHistoryService, ImageSuiteService], exports: [MediaService], }) export class MediaModule {} diff --git a/apps/api/src/media/media.service.ts b/apps/api/src/media/media.service.ts index df43ef5..9ae3ad8 100644 --- a/apps/api/src/media/media.service.ts +++ b/apps/api/src/media/media.service.ts @@ -100,6 +100,10 @@ export class MediaService { request: input, }, async (tx) => { + if (input.purpose === 'product_image') { + if (!input.product_id) throw new ApiError(422, 'VALIDATION_FAILED', '商品图片必须绑定商品'); + await this.requireProduct(tx, input.product_id); + } await this.ensureBucket(); const id = randomUUID(); const objectKey = `uploads/${workspaceId}/${input.purpose === 'product_image' ? 'product-images' : 'watermark-images'}/${id}`; @@ -107,9 +111,9 @@ export class MediaService { 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) + expected_byte_size, expected_checksum_sha256, expires_at, created_by_user_id, product_id) VALUES ($1, current_setting('app.workspace_id')::uuid, $2, $10, - $3, $4, $5, $6, $7, $8, $9)`, + $3, $4, $5, $6, $7, $8, $9, $11)`, [ id, input.purpose, @@ -121,6 +125,7 @@ export class MediaService { expiresAt, userId, this.config.OBJECT_STORAGE_PROVIDER, + input.product_id ?? null, ], ); const url = await getSignedUrl( @@ -295,7 +300,7 @@ export class MediaService { object_key: string; content_type: string; }>( - `SELECT storage_provider, bucket, object_key, content_type FROM atlas.media_assets WHERE id = $1`, + `SELECT storage_provider, bucket, object_key, content_type FROM atlas.media_assets WHERE id = $1 AND deleted_at IS NULL`, [assetId], ); if (!asset.rows[0]) @@ -315,18 +320,19 @@ export class MediaService { }); } - /** 返回工作空间最近上传的商品素材,预览 URL 仅在本次响应中签发。 */ - public async productUploads(workspaceId: string) { + /** 返回当前商品最近上传的素材,预览 URL 仅在本次响应中签发。 */ + public async productUploads(workspaceId: string, productId: string) { return this.database.workspaceTransaction(workspaceId, async (tx) => { const uploads = await tx.query( `SELECT a.id, a.storage_provider, 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' + WHERE i.purpose = 'product_image' AND i.status = 'ready' AND i.product_id = $1 AND a.deleted_at IS NULL GROUP BY a.id ORDER BY uploaded_at DESC, a.id DESC LIMIT 100`, + [productId], ); const expiresAt = new Date(Date.now() + 10 * 60_000).toISOString(); return Promise.all(uploads.rows.map(async (asset) => ({ @@ -354,6 +360,7 @@ export class MediaService { return this.idempotency.run( { workspaceId, userId, method: 'POST', path: '/api/media-assets/product-imports', key, request: input }, async (tx) => { + await this.requireProduct(tx, input.product_id); const image = await downloadProductImage(input.url); const checksum = createHash('sha256').update(image.bytes).digest('hex'); const existing = await tx.query<{ id: string }>( @@ -363,7 +370,7 @@ export class MediaService { let assetId = existing.rows[0]?.id ?? randomUUID(); if (!existing.rows[0]) { await this.ensureBucket(); - const objectKey = `product-imports/${workspaceId}/${checksum}`; + const objectKey = `product-imports/${workspaceId}/${checksum}/${randomUUID()}`; await this.s3.send(new PutObjectCommand({ Bucket: this.config.OBJECT_STORAGE_BUCKET, Key: objectKey, @@ -388,11 +395,11 @@ export class MediaService { } 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) + (workspace_id, source_url, asset_id, created_by_user_id, product_id) + VALUES (current_setting('app.workspace_id')::uuid, $1, $2, $3, $4) + ON CONFLICT (workspace_id, product_id, source_url) DO UPDATE SET asset_id = EXCLUDED.asset_id, updated_at = now()`, - [input.url, assetId, userId], + [input.url, assetId, userId, input.product_id], ); const asset = await this.requireAsset(tx, assetId); const stored = await tx.query<{ storage_provider: string; bucket: string; object_key: string }>( @@ -410,7 +417,7 @@ export class MediaService { ); } - public async productImports(workspaceId: string) { + public async productImports(workspaceId: string, productId: string) { return this.database.workspaceTransaction(workspaceId, async (tx) => { const imports = await tx.query ({ @@ -445,6 +454,11 @@ export class MediaService { }); } + private async requireProduct(tx: DatabaseTransaction, productId: string) { + const result = await tx.query('SELECT id FROM atlas.products WHERE id = $1', [productId]); + if (!result.rows.length) throw new ApiError(404, 'RESOURCE_NOT_FOUND', '商品不存在'); + } + private async lockIntent( tx: DatabaseTransaction, intentId: string, @@ -463,7 +477,7 @@ export class MediaService { private async requireAsset(tx: DatabaseTransaction, assetId: string) { const rows = await tx.query( `SELECT id, content_type, byte_size, width_px, height_px, created_at - FROM atlas.media_assets WHERE id = $1`, + FROM atlas.media_assets WHERE id = $1 AND deleted_at IS NULL`, [assetId], ); if (!rows.rows[0]) diff --git a/apps/api/src/media/product-image-deletion.service.ts b/apps/api/src/media/product-image-deletion.service.ts new file mode 100644 index 0000000..4c0685e --- /dev/null +++ b/apps/api/src/media/product-image-deletion.service.ts @@ -0,0 +1,125 @@ +import { randomUUID } from 'node:crypto'; +import { Inject, Injectable } from '@nestjs/common'; +import type { DeleteProductImageRequest, ProductImageDeletion, Operation } 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 { DATABASE } from '../database/database.module.js'; + +type StoredAsset = { id: string; deleted_at: string | null; storage_provider: string; bucket: string; object_key: string }; +@Injectable() +export class ProductImageDeletionService { + constructor(@Inject(DATABASE) private readonly database: Database, + @Inject(IdempotencyService) private readonly idempotency: IdempotencyService) {} + + /** 先锁素材和校验全部引用,再在同一事务禁用候选并登记可靠清理任务。 */ + async create(workspaceId: string, userId: string, productId: string, key: string, input: DeleteProductImageRequest) { + return this.idempotency.run({ workspaceId, userId, method: 'POST', path: `/api/products/${productId}/image-deletions`, key, request: input }, async tx => { + const product = await tx.query('SELECT id FROM atlas.products WHERE id = $1', [productId]); + if (!product.rows.length) throw new ApiError(404, 'RESOURCE_NOT_FOUND', '商品不存在'); + let assetId = input.asset_id ?? null; + if (input.collection_image_id) { + const source = await tx.query<{ asset_id: string | null; status: string }>( + 'SELECT asset_id, status FROM atlas.collection_asset_transfers WHERE id = $1 AND product_id = $2 FOR UPDATE', [input.collection_image_id, productId]); + if (!source.rows.length) throw new ApiError(404, 'RESOURCE_NOT_FOUND', '采集图片不存在'); + if (source.rows[0].status === 'running') throw new ApiError(409, 'MEDIA_ASSET_IN_USE', '图片正在转存,请完成后再删除'); + assetId = source.rows[0].asset_id; + } + const existing = await tx.query( + `SELECT id, status, asset_id, collection_image_id, error_code FROM atlas.product_image_deletions + WHERE product_id = $1 AND (asset_id = $2 OR collection_image_id = $3)`, [productId, assetId, input.collection_image_id ?? null]); + if (existing.rows.length) return { status: 202, body: { data: await this.withOperation(tx, existing.rows[0]) } }; + let objects: Array<{ storage_provider: string; bucket: string; object_key: string }> = []; + if (assetId) { + const result = await tx.query('SELECT id, deleted_at, storage_provider, bucket, object_key FROM atlas.media_assets WHERE id = $1 FOR UPDATE', [assetId]); + const asset = result.rows[0]; + if (!asset || asset.deleted_at) throw new ApiError(404, 'RESOURCE_NOT_FOUND', '图片不存在或已删除'); + const owned = await tx.query(`SELECT 1 FROM atlas.media_upload_intents WHERE asset_id = $1 AND product_id = $2 AND purpose = 'product_image' AND status = 'ready' + UNION ALL SELECT 1 FROM atlas.product_image_imports WHERE asset_id = $1 AND product_id = $2 + UNION ALL SELECT 1 FROM atlas.collection_asset_transfers WHERE asset_id = $1 AND product_id = $2 + UNION ALL SELECT 1 FROM atlas.media_ai_edits WHERE output_asset_id = $1 AND product_id = $2 + UNION ALL SELECT 1 FROM atlas.media_aspect_ratio_derivatives WHERE output_asset_id = $1 AND product_id = $2 + UNION ALL SELECT 1 FROM atlas.media_watermark_derivatives WHERE output_asset_id = $1 AND product_id = $2 LIMIT 1`, [assetId, productId]); + if (!owned.rows.length) throw new ApiError(404, 'RESOURCE_NOT_FOUND', '图片不属于当前商品'); + await this.requireUnused(tx, assetId, productId); + const uploads = await tx.query<{ storage_provider: string; bucket: string; object_key: string }>( + "SELECT storage_provider, bucket, object_key FROM atlas.media_upload_intents WHERE asset_id = $1 AND status = 'ready'", [assetId]); + objects = [asset, ...uploads.rows].map(({storage_provider, bucket, object_key}) => ({storage_provider, bucket, object_key})); + objects = objects.filter((v, i) => objects.findIndex(o => o.storage_provider === v.storage_provider && o.bucket === v.bucket && o.object_key === v.object_key) === i); + await tx.query('UPDATE atlas.media_assets SET deleted_at = now(), deleted_checksum_sha256 = checksum_sha256, checksum_sha256 = NULL WHERE id = $1', [assetId]); + await tx.query('UPDATE atlas.collection_asset_transfers SET deleted_at = now() WHERE asset_id = $1 AND product_id = $2', [assetId, productId]); + } + if (input.collection_image_id) await tx.query( + "UPDATE atlas.collection_asset_transfers SET deleted_at = now(), status = CASE WHEN status IN ('queued', 'pending_selection') THEN 'cancelled'::atlas.processing_status ELSE status END WHERE id = $1", [input.collection_image_id]); + const id = randomUUID(); + await tx.query(`INSERT INTO atlas.operations (id, workspace_id, kind, resource_type, resource_id, created_by_user_id, status, completed_at) + VALUES ($1, current_setting('app.workspace_id')::uuid, 'product_image_deletion', 'product_image_deletion', $1, $2, + $3::atlas.operation_status, CASE WHEN $3 = 'succeeded' THEN now() ELSE NULL END)`, [id, userId, objects.length ? 'queued' : 'succeeded']); + const deletion = await tx.query(`INSERT INTO atlas.product_image_deletions + (id, workspace_id, product_id, asset_id, collection_image_id, objects, status, completed_at) + VALUES ($1, current_setting('app.workspace_id')::uuid, $2, $3, $4, $5::jsonb, $6, + CASE WHEN $6 = 'succeeded' THEN now() ELSE NULL END) + RETURNING id, status, asset_id, collection_image_id, error_code`, + [id, productId, assetId, input.collection_image_id ?? null, JSON.stringify(objects), objects.length ? 'queued' : 'succeeded']); + if (objects.length) await this.enqueue(tx, id, randomUUID()); + return { status: 202, body: { data: await this.withOperation(tx, deletion.rows[0]) } }; + }); + } + async pending(workspaceId: string, productId: string) { + return this.database.workspaceTransaction(workspaceId, async tx => { + const rows = await tx.query("SELECT id, status, asset_id, collection_image_id, error_code FROM atlas.product_image_deletions WHERE product_id = $1 AND status <> 'succeeded' ORDER BY created_at DESC LIMIT 100", [productId]); + return Promise.all(rows.rows.map(row => this.withOperation(tx, row))); + }); + } + async read(workspaceId: string, productId: string, id: string) { + return this.database.workspaceTransaction(workspaceId, async tx => { + const result = await tx.query('SELECT id, status, asset_id, collection_image_id, error_code FROM atlas.product_image_deletions WHERE id = $1 AND product_id = $2', [id, productId]); + if (!result.rows.length) throw new ApiError(404, 'RESOURCE_NOT_FOUND', '图片删除记录不存在'); + return this.withOperation(tx, result.rows[0]); + }); + } + async retry(workspaceId: string, userId: string, productId: string, id: string, key: string) { + return this.idempotency.run({workspaceId, userId, method: 'POST', path: `/api/products/${productId}/image-deletions/${id}/retry`, key, request: {}}, async tx => { + const row = await tx.query('SELECT id, status, asset_id, collection_image_id, error_code FROM atlas.product_image_deletions WHERE id = $1 AND product_id = $2 FOR UPDATE', [id, productId]); + if (!row.rows.length) throw new ApiError(404, 'RESOURCE_NOT_FOUND', '图片删除记录不存在'); + if (row.rows[0].status === 'failed') { + await tx.query("UPDATE atlas.product_image_deletions SET status = 'queued', error_code = NULL, completed_at = NULL WHERE id = $1", [id]); + await tx.query("UPDATE atlas.operations SET status = 'queued', error_code = NULL, error_message = NULL, completed_at = NULL WHERE id = $1", [id]); + await this.enqueue(tx, id, randomUUID()); + row.rows[0].status = 'queued'; row.rows[0].error_code = null; + } + return {status: 202, body: {data: await this.withOperation(tx, row.rows[0])}}; + }); + } + private async withOperation(tx: DatabaseTransaction, row: ProductImageDeletion): Promise { + const result = await tx.query(`SELECT id, kind, status, created_at::text, completed_at::text, + CASE WHEN error_code IS NULL THEN NULL ELSE jsonb_build_object('code', error_code, 'message', error_message) END AS error + FROM atlas.operations WHERE id = $1`, [row.id]); + const operation = result.rows[0]; + return { ...row, operation: { ...operation, + created_at: new Date(operation.created_at).toISOString(), + completed_at: operation.completed_at ? new Date(operation.completed_at).toISOString() : null, + } }; + } + private async enqueue(tx: DatabaseTransaction, id: string, attempt: string) { + await tx.query(`INSERT INTO atlas.outbox_events (workspace_id, aggregate_type, aggregate_id, event_type, event_payload, deduplication_key) + VALUES (current_setting('app.workspace_id')::uuid, 'product_image_deletion', $1, 'media.asset_delete.requested', jsonb_build_object('deletion_id', $1::uuid), $2)`, [id, attempt]); + } + private async requireUnused(tx: DatabaseTransaction, assetId: string, productId: string) { + const refs = await tx.query(`SELECT 1 FROM atlas.product_variant_listing_media WHERE asset_id = $1 + UNION ALL SELECT 1 FROM atlas.watermark_template_versions WHERE image_asset_id = $1 + UNION ALL SELECT 1 FROM atlas.users WHERE avatar_asset_id = $1 + UNION ALL SELECT 1 FROM atlas.media_upload_intents WHERE asset_id = $1 AND product_id IS DISTINCT FROM $2::uuid + UNION ALL SELECT 1 FROM atlas.product_image_imports WHERE asset_id = $1 AND product_id IS DISTINCT FROM $2::uuid + UNION ALL SELECT 1 FROM atlas.collection_asset_transfers WHERE asset_id = $1 AND product_id <> $2 + UNION ALL SELECT 1 FROM atlas.media_ai_edits WHERE (output_asset_id = $1 AND product_id IS DISTINCT FROM $2::uuid) + OR (status IN ('queued','running') AND (source_asset_id = $1 OR $1 = ANY(reference_asset_ids))) + UNION ALL SELECT 1 FROM atlas.media_aspect_ratio_derivatives WHERE (output_asset_id = $1 AND product_id IS DISTINCT FROM $2::uuid) + OR (status IN ('queued','running') AND source_asset_id = $1) + UNION ALL SELECT 1 FROM atlas.media_watermark_derivatives WHERE (output_asset_id = $1 AND product_id IS DISTINCT FROM $2::uuid) + OR (status IN ('queued','running') AND source_asset_id = $1) + UNION ALL SELECT 1 FROM atlas.product_image_suite_references r JOIN atlas.product_image_suites s ON s.id = r.suite_id + WHERE r.asset_id = $1 AND s.status IN ('queued','running') LIMIT 1`, [assetId, productId]); + if (refs.rows.length) throw new ApiError(409, 'MEDIA_ASSET_IN_USE', '图片已被 SKU、其他商品或图片任务使用,请先移除引用并保存后再删除'); + } +} diff --git a/apps/api/src/media/product-image-derivative-history.service.ts b/apps/api/src/media/product-image-derivative-history.service.ts index ccc151c..dd02edd 100644 --- a/apps/api/src/media/product-image-derivative-history.service.ts +++ b/apps/api/src/media/product-image-derivative-history.service.ts @@ -47,7 +47,7 @@ export class ProductImageDerivativeHistoryService { FROM atlas.media_watermark_derivatives WHERE product_id = $1 AND status = 'succeeded' ) d - JOIN atlas.media_assets a ON a.id = d.output_asset_id + JOIN atlas.media_assets a ON a.id = d.output_asset_id AND a.deleted_at IS NULL ORDER BY d.completed_at DESC, d.id DESC LIMIT 100`, [productId], diff --git a/apps/api/src/media/product-image-task.service.ts b/apps/api/src/media/product-image-task.service.ts index 3791fec..5a99bf2 100644 --- a/apps/api/src/media/product-image-task.service.ts +++ b/apps/api/src/media/product-image-task.service.ts @@ -20,6 +20,7 @@ type AiEditTaskRow = { total?: number; completed?: number; output_asset_ids?: string[]; + active_output_asset_ids?: string[]; }; @Injectable() @@ -44,7 +45,8 @@ export class ProductImageTaskService { } params.push(query.limit + 1); const edits = await tx.query( - `SELECT e.* FROM ( + `SELECT e.*, ARRAY(SELECT a.id FROM unnest(e.output_asset_ids) WITH ORDINALITY refs(id, position) + JOIN atlas.media_assets a ON a.id = refs.id AND a.deleted_at IS NULL ORDER BY refs.position) AS active_output_asset_ids FROM ( SELECT e.id, e.product_id, e.status, e.provider_task_id, e.output_asset_id, e.error_code, o.result ->> 'provider_error_raw' AS provider_error_raw, o.result ->> 'failure_source' AS failure_source, @@ -85,7 +87,7 @@ export class ProductImageTaskService { phase: row.status === 'running' && row.provider_task_id ? 'waiting_provider' as const : null, total: row.total ?? 1, completed: row.completed ?? (row.status === 'succeeded' ? 1 : 0), - output_asset_ids: row.output_asset_ids ?? (row.output_asset_id ? [row.output_asset_id] : []), + output_asset_ids: row.active_output_asset_ids ?? row.output_asset_ids ?? (row.output_asset_id ? [row.output_asset_id] : []), error_code: row.error_code, failure_source: ['failed', 'partial'].includes(row.status) ? row.failure_source ?? null : null, failure_stage: ['failed', 'partial'].includes(row.status) ? row.failure_stage ?? null : null, diff --git a/apps/api/src/media/watermark-derivative.service.ts b/apps/api/src/media/watermark-derivative.service.ts index b9d67d0..9457727 100644 --- a/apps/api/src/media/watermark-derivative.service.ts +++ b/apps/api/src/media/watermark-derivative.service.ts @@ -47,7 +47,7 @@ export class WatermarkDerivativeService { }, async (tx) => { const source = await tx.query<{ id: string }>( `SELECT id FROM atlas.media_assets - WHERE id = $1 AND content_type IN ('image/jpeg', 'image/png', 'image/webp') + WHERE id = $1 AND deleted_at IS NULL AND content_type IN ('image/jpeg', 'image/png', 'image/webp') AND byte_size <= 20971520 AND width_px BETWEEN 1 AND 10000 AND height_px BETWEEN 1 AND 10000`, [assetId], @@ -112,14 +112,14 @@ export class WatermarkDerivativeService { a.content_type, a.byte_size::text, a.width_px, a.height_px, a.created_at AS asset_created_at FROM atlas.media_watermark_derivatives d - LEFT JOIN atlas.media_assets a ON a.id = d.output_asset_id + LEFT JOIN atlas.media_assets a ON a.id = d.output_asset_id AND a.deleted_at IS NULL WHERE d.id = $1`, [derivativeId], ); return result.rows[0] ?? null; }); if (!row) throw new ApiError(404, 'RESOURCE_NOT_FOUND', '水印处理任务不存在'); - const access = row.output_asset_id + const access = row.output_asset_id && row.content_type ? await this.media.accessUrl(workspaceId, row.output_asset_id) : null; return { diff --git a/apps/api/src/pricing/pricing.service.ts b/apps/api/src/pricing/pricing.service.ts index 74a9c99..be26cf8 100644 --- a/apps/api/src/pricing/pricing.service.ts +++ b/apps/api/src/pricing/pricing.service.ts @@ -327,20 +327,26 @@ export class PricingService { 'PROFILE_NOT_FOUND', '商品发布配置不存在', ); - if ( - binding.variant_id && - !( - await tx.query( - `SELECT l.id FROM atlas.product_variant_platform_listings l JOIN atlas.product_variants v ON v.id=l.product_variant_id WHERE l.profile_id=$1 AND l.product_variant_id=$2 AND l.status<>'imported' AND v.deleted_at IS NULL`, + if (binding.variant_id) { + const listing = ( + await tx.query<{ id: string; status: string; store_id: string }>( + `SELECT l.id,l.status,l.store_id FROM atlas.product_variant_platform_listings l JOIN atlas.product_variants v ON v.id=l.product_variant_id WHERE l.profile_id=$1 AND l.product_variant_id=$2 AND v.deleted_at IS NULL`, [binding.profile_id, binding.variant_id], ) - ).rowCount - ) - throw new ApiError( - 422, - 'PRICING_VARIANT_UNAVAILABLE', - 'SKU 不存在或已经上架', - ); + ).rows[0]; + if (!listing) + throw new ApiError( + 422, + 'PRICING_VARIANT_UNAVAILABLE', + 'SKU 不存在或已删除', + ); + if (listing.status === 'imported' && listing.store_id !== store.id) + throw new ApiError( + 422, + 'PRICING_VARIANT_STORE_MISMATCH', + '已上架 SKU 请按首次导入店铺试算', + ); + } } const rules = await this.rules(tx, input, store), exchange = await this.exchange(tx); @@ -633,14 +639,16 @@ export class PricingService { await tx.query<{ id: string; status: string; + store_id: string; pricing_draft: PricingParameters | null; pricing_quote_id: string | null; }>( - `SELECT id,status,pricing_draft,pricing_quote_id FROM atlas.product_variant_platform_listings WHERE profile_id=$1 AND product_variant_id=$2 FOR UPDATE`, + `SELECT id,status,store_id,pricing_draft,pricing_quote_id FROM atlas.product_variant_platform_listings WHERE profile_id=$1 AND product_variant_id=$2 FOR UPDATE`, [profileId, variantId], ) ).rows[0]; - if (listing.status === 'imported') continue; + // 已上架 SKU 只有显式应用新报价时更新本地记录,普通草稿保存保留历史报价。 + if (listing.status === 'imported' && !v.pricing_selection) continue; const state = ( await tx.query<{ product_id: string; @@ -652,8 +660,12 @@ export class PricingService { purchase_cost_cny: string; package: PricingPreviewRequest['package']; }>( - `SELECT v.product_id,p.platform_id,p.target_store_id AS store_id,s.currency_code,p.platform_product_category_id AS category_id,p.fulfillment,v.purchase_cost_cny::text,jsonb_build_object('shipping_weight_g',coalesce(v.shipping_weight_g,g.default_shipping_weight_g)::text,'length_mm',coalesce(v.package_length_mm,g.default_package_length_mm)::text,'width_mm',coalesce(v.package_width_mm,g.default_package_width_mm)::text,'height_mm',coalesce(v.package_height_mm,g.default_package_height_mm)::text,'status',coalesce(v.package_spec_status,g.package_spec_status)) AS package FROM atlas.product_variants v JOIN atlas.products g ON g.id=v.product_id JOIN atlas.product_platform_profiles p ON p.id=$1 JOIN atlas.stores s ON s.id=p.target_store_id WHERE v.id=$2`, - [profileId, variantId], + `SELECT v.product_id,p.platform_id,s.id AS store_id,s.currency_code,p.platform_product_category_id AS category_id,p.fulfillment,v.purchase_cost_cny::text,jsonb_build_object('shipping_weight_g',coalesce(v.shipping_weight_g,g.default_shipping_weight_g)::text,'length_mm',coalesce(v.package_length_mm,g.default_package_length_mm)::text,'width_mm',coalesce(v.package_width_mm,g.default_package_width_mm)::text,'height_mm',coalesce(v.package_height_mm,g.default_package_height_mm)::text,'status',coalesce(v.package_spec_status,g.package_spec_status)) AS package FROM atlas.product_variants v JOIN atlas.products g ON g.id=v.product_id JOIN atlas.product_platform_profiles p ON p.id=$1 JOIN atlas.stores s ON s.id=coalesce($3::uuid,p.target_store_id) WHERE v.id=$2`, + [ + profileId, + variantId, + listing.status === 'imported' ? listing.store_id : null, + ], ) ).rows[0]; const params = diff --git a/apps/api/src/product-editor/product-editor.models.ts b/apps/api/src/product-editor/product-editor.models.ts index cfdd5c8..d738fd2 100644 --- a/apps/api/src/product-editor/product-editor.models.ts +++ b/apps/api/src/product-editor/product-editor.models.ts @@ -8,6 +8,7 @@ export type ProductRow = { model: string | null; barcode: string | null; purchase_url: string | null; + source_url: string | null; supply_note: string | null; default_physical_length_mm: string | null; default_physical_width_mm: string | null; diff --git a/apps/api/src/product-editor/product-editor.publish.ts b/apps/api/src/product-editor/product-editor.publish.ts index 028988f..70728d7 100644 --- a/apps/api/src/product-editor/product-editor.publish.ts +++ b/apps/api/src/product-editor/product-editor.publish.ts @@ -1,4 +1,5 @@ import { randomUUID } from 'node:crypto'; +import { productAttributeValueRules } from '@atlas/contracts'; import type { OzonPublishItemSnapshot, ProductAttributeSchema, @@ -325,11 +326,12 @@ export class ProductEditorPublishOperations { // Ozon 主题标签用空格分隔后放在同一个属性值中,单值限制不代表只允许一个标签。 if (definition.editor_field === 'tags' && values.length) values = [values.join(' ')]; - if (values.length > definition.max_value_count) + const { maxValueCount } = productAttributeValueRules(definition); + if (values.length > maxValueCount) throw new ApiError( 422, 'PUBLISH_VALIDATION_FAILED', - `${definition.name}最多 ${definition.max_value_count} 个值`, + `${definition.name}最多 ${maxValueCount} 个值`, [ { field: `variants.${listing.variant_id}.attributes.${definition.platform_attribute_id}`, diff --git a/apps/api/src/product-editor/product-editor.service.ts b/apps/api/src/product-editor/product-editor.service.ts index 765edd1..4f5bb58 100644 --- a/apps/api/src/product-editor/product-editor.service.ts +++ b/apps/api/src/product-editor/product-editor.service.ts @@ -1,5 +1,6 @@ import { PricingService } from '../pricing/pricing.service.js'; import type { PricingParameters, PricingOption } from '@atlas/contracts'; +import { productAttributeValueRules } from '@atlas/contracts'; import { Inject, Injectable } from '@nestjs/common'; import type { ProductEditorContext, @@ -229,6 +230,7 @@ export class ProductEditorService { sort_order: number; purchase_cost_cny: string | null; listing_id: string; + listing_store_id: string; offer_id: string | null; title: string | null; description: string | null; @@ -256,7 +258,7 @@ export class ProductEditorService { }>( `SELECT v.id, v.source_variant_key, v.source_sku, v.display_name, v.sort_order, v.purchase_cost_cny::numeric(18, 2)::text AS purchase_cost_cny, l.id AS listing_id, l.offer_id, - l.title, l.description, l.tags, l.status, l.selling_price::text, + l.store_id AS listing_store_id, l.title, l.description, l.tags, l.status, l.selling_price::text, l.list_price::text, l.currency_code, l.pricing_draft, l.pricing_valid, (SELECT calculation_snapshot FROM atlas.pricing_quote_options o JOIN atlas.pricing_quotes q ON q.id=o.quote_id AND q.selected_option_id=o.id WHERE q.id=l.pricing_quote_id) AS pricing_result, v.specification_values, v.physical_length_mm::text, v.physical_width_mm::text, v.physical_height_mm::text, @@ -417,6 +419,7 @@ export class ProductEditorService { ), listing: { id: variant.listing_id, + store_id: variant.listing_store_id, offer_id: variant.offer_id, status: variant.status, pricing_draft: variant.pricing_draft, @@ -438,7 +441,8 @@ export class ProductEditorService { brand: product.brand, model: product.model, barcode: product.barcode, - purchase_url: product.purchase_url, + // 历史草稿未填写货源链接时沿用采集页面,手工链接始终优先。 + purchase_url: product.purchase_url || product.source_url, supply_note: product.supply_note, }, default_physical_spec: defaultPhysicalSpec, @@ -1377,7 +1381,7 @@ export class ProductEditorService { height_px: number | null; }>( `SELECT id, content_type, byte_size::text, width_px, height_px - FROM atlas.media_assets WHERE id = ANY($1::uuid[])`, + FROM atlas.media_assets WHERE id = ANY($1::uuid[]) AND deleted_at IS NULL`, [assetIds], ) : { rows: [] }; @@ -1424,16 +1428,16 @@ export class ProductEditorService { ? 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], + AND product_id = $1 AND asset_id = ANY($2::uuid[])`, + [input.product.id, 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], + WHERE product_id = $1 AND asset_id = ANY($2::uuid[])`, + [input.product.id, assetIds], ) : { rows: [] }; const importedIds = new Set(imported.rows.map((item) => item.asset_id)); @@ -1810,10 +1814,7 @@ export class ProductEditorService { values: string[], unchanged: boolean, ) { - // 商品颜色允许多选;该用户输入规则优先于部分 Ozon 类目 schema 的单值标记。 - const isProductColor = definition.name.trim() === '商品颜色'; - const allowsMultipleValues = definition.is_collection || isProductColor; - const maxValueCount = isProductColor ? 100 : definition.max_value_count; + const { allowsMultipleValues, maxValueCount } = productAttributeValueRules(definition); if ( values.length > maxValueCount || (!allowsMultipleValues && values.length !== 1) @@ -2401,7 +2402,7 @@ export class ProductEditorService { p.physical_spec_status, p.package_spec_status, p.package_weight_mode, p.package_dimensions_mode, p.ai_source_material_overrides, p.ai_source_material_base_revision, - c.normalized_payload, c.source_snapshot_revision + c.source_url, c.normalized_payload, c.source_snapshot_revision FROM atlas.products p JOIN atlas.collection_items c ON c.id = p.collection_item_id WHERE c.id = $1 AND c.deleted_at IS NULL`, [collectionItemId], @@ -2427,7 +2428,7 @@ export class ProductEditorService { p.physical_spec_status, p.package_spec_status, p.package_weight_mode, p.package_dimensions_mode, p.ai_source_material_overrides, p.ai_source_material_base_revision, - c.normalized_payload, c.source_snapshot_revision + c.source_url, c.normalized_payload, c.source_snapshot_revision FROM atlas.products p JOIN atlas.collection_items c ON c.id = p.collection_item_id WHERE p.id = $1 AND c.deleted_at IS NULL${lockSourceSnapshot ? ' FOR UPDATE OF c' : ''}`, [productId], diff --git a/apps/api/test/extension.e2e.test.ts b/apps/api/test/extension.e2e.test.ts index 1e6e3eb..ccb40cb 100644 --- a/apps/api/test/extension.e2e.test.ts +++ b/apps/api/test/extension.e2e.test.ts @@ -6,12 +6,14 @@ import type { NestExpressApplication } from '@nestjs/platform-express'; import { afterAll, beforeAll, describe, it } from 'vitest'; import { DATABASE } from '../src/database/database.module.js'; import { createApplication } from '../src/main.js'; +import { PRODUCT_VARIANT_LIMIT } from '@atlas/contracts'; let app: NestExpressApplication; let database: Database; let baseUrl = ''; let workspaceId = ''; let userId = ''; +let webAccessToken = ''; async function request(path: string, init: RequestInit = {}) { const response = await fetch(`${baseUrl}${path}`, { redirect: 'manual', @@ -124,7 +126,7 @@ describe('扩展授权与采集 HTTP 集成测试', () => { assert.equal(registration.response.status, 201); userId = registration.body.data.user.id; workspaceId = registration.body.data.workspace.id; - const webAccessToken = registration.body.data.access_token as string; + webAccessToken = registration.body.data.access_token as string; const session = await request('/api/extension/session', { headers: { Authorization: `Bearer ${webAccessToken}` }, }); @@ -166,11 +168,12 @@ describe('扩展授权与采集 HTTP 集成测试', () => { ], evidence: [{ ref: 'page', kind: 'page', path: 'test' }], }; + const firstKey = randomUUID(); const first = await request('/api/extension/materials', { method: 'POST', headers: { Authorization: `Bearer ${webAccessToken}`, - 'Idempotency-Key': randomUUID(), + 'Idempotency-Key': firstKey, }, body: JSON.stringify(payload), }); @@ -179,6 +182,22 @@ describe('扩展授权与采集 HTTP 集成测试', () => { assert.equal(first.body.data.collection_item.status, 'ready_for_edit'); assert.equal(first.body.data.materialization.pending_selection, 1); assert.equal(first.body.data.materialization.queued, 0); + const editorEntry = new URL(first.body.data.editor_entry_path, 'http://atlas.example.test'); + assert.equal(editorEntry.pathname, `/products/${first.body.data.product_id}/editor`); + assert.equal(editorEntry.searchParams.get('collection_item_id'), first.body.data.collection_item.id); + const replay = await request('/api/extension/materials', { + method: 'POST', + headers: { + Authorization: `Bearer ${webAccessToken}`, + 'Idempotency-Key': firstKey, + }, + body: JSON.stringify(payload), + }); + assert.deepEqual(replay.body, first.body); + const profilesBeforeEntry = await database.workspaceTransaction(workspaceId, (tx) => + tx.query('SELECT id FROM atlas.product_platform_profiles WHERE product_id=$1', [first.body.data.product_id]), + ); + assert.equal(profilesBeforeEntry.rowCount, 0); const source1688 = { source: { platform: '1688', itemKey: '1688-price-image-test', @@ -260,7 +279,7 @@ describe('扩展授权与采集 HTTP 集成测试', () => { ); }); const editorContext = await request( - `/api/collection-items/${first.body.data.collection_item.id}/editor-contexts`, + `/api/collection-items/${editorEntry.searchParams.get('collection_item_id')}/editor-contexts`, { method: 'POST', headers: { @@ -271,6 +290,39 @@ describe('扩展授权与采集 HTTP 集成测试', () => { }, ); assert.equal(editorContext.response.status, 201); + assert.equal(editorContext.body.data.product_id, first.body.data.product_id); + assert.equal(editorContext.body.data.editor_path, editorEntry.pathname); + const readEditor = (productId: string) => request( + `/api/products/${productId}/editor`, + { headers: { + Authorization: `Bearer ${webAccessToken}`, + 'X-Atlas-Platform-Id': targetPlatform.id, + } }, + ); + const initialEditor = await readEditor(first.body.data.product_id); + assert.equal(initialEditor.response.status, 200); + assert.equal(initialEditor.body.data.product.common.purchase_url, payload.source.canonicalUrl); + const sourceLink = await database.workspaceTransaction(workspaceId, (tx) => tx.query( + 'SELECT purchase_url FROM atlas.products WHERE id=$1', + [captured1688.body.data.product_id], + )); + assert.equal(sourceLink.rows[0].purchase_url, source1688.source.canonicalUrl); + await request(`/api/collection-items/${captured1688.body.data.collection_item.id}/editor-contexts`, { + method: 'POST', + headers: { + Authorization: `Bearer ${webAccessToken}`, + 'X-Atlas-Platform-Id': targetPlatform.id, + 'Idempotency-Key': randomUUID(), + }, + }); + // 模拟历史采集商品:原来只有采集记录保存了页面地址,商品货源字段为空。 + await database.workspaceTransaction(workspaceId, (tx) => tx.query( + 'UPDATE atlas.products SET purchase_url=NULL WHERE id=$1', + [captured1688.body.data.product_id], + )); + const historicalEditor = await readEditor(captured1688.body.data.product_id); + assert.equal(historicalEditor.response.status, 200); + assert.equal(historicalEditor.body.data.product.common.purchase_url, source1688.source.canonicalUrl); // Simulate real draft work before the source page is collected again. // Re-collection must neither delete this SKU nor overwrite its Atlas // fields / platform mapping just because a newer source snapshot arrives. @@ -281,7 +333,7 @@ describe('扩展授权与采集 HTTP 集成测试', () => { [first.body.data.product_id], ); await tx.query( - `UPDATE atlas.products SET source_title = '人工确认商品名' + `UPDATE atlas.products SET source_title = '人工确认商品名', purchase_url = 'https://detail.1688.com/offer/888888888.html' WHERE id = $1`, [first.body.data.product_id], ); @@ -366,6 +418,8 @@ describe('扩展授权与采集 HTTP 集成测试', () => { assert.equal(second.response.status, 200); assert.equal(second.body.data.reused, true); assert.equal(second.body.data.refreshed, true); + assert.equal(second.body.data.product_id, first.body.data.product_id); + assert.equal(second.body.data.editor_entry_path, first.body.data.editor_entry_path); assert.equal( second.body.data.collection_item.id, first.body.data.collection_item.id, @@ -382,6 +436,10 @@ describe('扩展授权与采集 HTTP 集成测试', () => { }, ); assert.equal(editorAfterRefresh.response.status, 200); + assert.equal( + editorAfterRefresh.body.data.product.common.purchase_url, + 'https://detail.1688.com/offer/888888888.html', + ); assert.equal( editorAfterRefresh.body.data.product.common.source_title, '人工确认商品名', @@ -494,4 +552,98 @@ describe('扩展授权与采集 HTTP 集成测试', () => { ); assert.equal(transferWorkspace.rows[0]?.workspace_id, workspaceId); }); + + it('完整采集并保存 1000 个 SKU,复采保持身份,超限请求原子拒绝', async () => { + const variants = Array.from({ length: PRODUCT_VARIANT_LIMIT }, (_, index) => ({ + sourceVariantKey: String(8_000_000_000_000 + index), + sourceSku: String(8_000_000_000_000 + index), + displayName: `来源 SKU ${index}`, + selections: [], confidence: 'verified', evidenceRefs: [], + sourcePrice: { amount: '20.00', currencyCode: 'CNY' }, + })); + const payload = { + source: { + platform: '1688', itemKey: `large-${randomUUID()}`, + canonicalUrl: 'https://detail.1688.com/offer/123456789.html', + collectedAt: new Date().toISOString(), parserVersion: 'large-sku-test', + }, + common: { title: { value: '多 SKU 来源商品', confidence: 'verified' }, attributes: [], categories: [] }, + variantAxes: [], variants, assets: [], evidence: [], + }; + const capture = (body: unknown) => request('/api/extension/materials', { + method: 'POST', + headers: { Authorization: `Bearer ${webAccessToken}`, 'Idempotency-Key': randomUUID() }, + body: JSON.stringify(body), + }); + const captured = await capture(payload); + assert.equal(captured.response.status, 201, JSON.stringify(captured.body)); + assert.equal(captured.body.data.collection_item.variant_count, PRODUCT_VARIANT_LIMIT); + const collectionId = captured.body.data.collection_item.id; + const productId = captured.body.data.product_id; + const platforms = await request('/api/platforms', { headers: { Authorization: `Bearer ${webAccessToken}` } }); + const platformId = platforms.body.data.find((platform: { code: string }) => platform.code === 'OZON').id; + const context = await request(`/api/collection-items/${collectionId}/editor-contexts`, { + method: 'POST', + headers: { + Authorization: `Bearer ${webAccessToken}`, 'X-Atlas-Platform-Id': platformId, + 'Idempotency-Key': randomUUID(), + }, + }); + assert.equal(context.response.status, 201, JSON.stringify(context.body)); + const read = () => request(`/api/products/${productId}/editor`, { + headers: { Authorization: `Bearer ${webAccessToken}`, 'X-Atlas-Platform-Id': platformId }, + }); + const before = await read(); + assert.equal(before.response.status, 200); + const snapshot = before.body.data; + assert.equal(snapshot.variants.length, PRODUCT_VARIANT_LIMIT); + assert.equal(snapshot.variants.at(-1).source_sku, variants.at(-1)!.sourceSku); + const draft = { + ai_source_material: snapshot.source_material, + ai_source_overridden_fields: [], + common: { + ...snapshot.product.common, + default_physical_spec: snapshot.product.default_physical_spec, + default_package_spec: snapshot.product.default_package_spec, + }, + profile: { + target_store_id: snapshot.profile.target_store.id, + fulfillment: snapshot.profile.fulfillment, title: null, description: null, tags: [], + platform_product_category_id: null, + }, + variants: snapshot.variants.map((variant: any, index: number) => ({ + id: variant.id, client_key: null, + display_name: index === PRODUCT_VARIANT_LIMIT - 1 ? '末尾 SKU 已编辑' : variant.display_name, + source_sku: variant.source_sku, specification_values: variant.specification_values, + purchase_cost_cny: variant.purchase_cost_cny, physical_spec: variant.physical_spec, + package_spec: variant.package_spec, tags: [], description: null, + offer_id: null, media_asset_ids: [], + })), + }; + const saved = await request(`/api/products/${productId}/editor-draft`, { + method: 'PUT', headers: { + Authorization: `Bearer ${webAccessToken}`, 'X-Atlas-Platform-Id': platformId, + 'Idempotency-Key': randomUUID(), + 'If-Match': `"${snapshot.product.revision}:${snapshot.profile.revision}"`, + }, body: JSON.stringify(draft), + }); + assert.equal(saved.response.status, 200, JSON.stringify(saved.body)); + const after = await read(); + assert.equal(after.body.data.variants.length, PRODUCT_VARIANT_LIMIT); + assert.equal(after.body.data.variants.at(-1).display_name, '末尾 SKU 已编辑'); + const refreshed = await capture(payload); + assert.equal(refreshed.response.status, 200); + assert.equal(refreshed.body.data.product_id, productId); + assert.equal(refreshed.body.data.collection_item.variant_count, PRODUCT_VARIANT_LIMIT); + const reread = await read(); + assert.deepEqual(reread.body.data.variants.map((v: any) => v.id), snapshot.variants.map((v: any) => v.id)); + assert.equal(reread.body.data.variants.at(-1).display_name, '末尾 SKU 已编辑'); + const revision = reread.body.data.source_material.source_snapshot_revision; + const rejected = await capture({ ...payload, variants: [...variants, { ...variants[0], sourceVariantKey: 'extra-sku' }] }); + assert.equal(rejected.response.status, 422); + assert.equal(rejected.body.error.details[0].field, 'variants'); + const unchanged = await read(); + assert.equal(unchanged.body.data.source_material.source_snapshot_revision, revision); + assert.equal(unchanged.body.data.variants.length, PRODUCT_VARIANT_LIMIT); + }, 60_000); }); diff --git a/apps/api/test/media-import.unit.test.ts b/apps/api/test/media-import.unit.test.ts index 2755ab9..28698cb 100644 --- a/apps/api/test/media-import.unit.test.ts +++ b/apps/api/test/media-import.unit.test.ts @@ -20,10 +20,15 @@ vi.mock('../src/media/product-image-download.js', async (importOriginal) => { describe('网络商品图片导入', () => { it('复用同工作空间素材并返回正式 ID 与短时预览地址', async () => { + const productId = '733d0d73-3d39-4a98-ac0a-5c0e521d8998'; 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('FROM atlas.products')) { + expect(args).toEqual([productId]); + return { rows: [{ id: productId }] }; + } if (sql.includes('WHERE checksum_sha256 = $1')) { expect(args).toEqual([checksum]); return { rows: [{ id: assetId }] }; @@ -31,6 +36,8 @@ describe('网络商品图片导入', () => { if (sql.includes('INSERT INTO atlas.product_image_imports')) { expect(args?.[0]).toBe('https://example.com/photo.png'); expect(args?.[1]).toBe(assetId); + expect(args?.[3]).toBe(productId); + expect(sql).toContain('ON CONFLICT (workspace_id, product_id, source_url)'); return { rows: [] }; } if (sql.includes('SELECT id, content_type')) { @@ -86,7 +93,7 @@ describe('网络商品图片导入', () => { 'workspace-id', 'user-id', 'idempotency-key', - { url: 'https://example.com/photo.png' }, + { url: 'https://example.com/photo.png', product_id: productId }, ); const body = result.body as { data: { id: string; source_url: string; preview_url: string }; @@ -97,6 +104,6 @@ describe('网络商品图片导入', () => { source_url: 'https://example.com/photo.png', }); expect(body.data.preview_url).toContain('product-imports/existing.png'); - expect(query).toHaveBeenCalledTimes(4); + expect(query).toHaveBeenCalledTimes(5); }); }); diff --git a/apps/api/test/media-product-image.unit.test.ts b/apps/api/test/media-product-image.unit.test.ts index d98d75a..1c45a1a 100644 --- a/apps/api/test/media-product-image.unit.test.ts +++ b/apps/api/test/media-product-image.unit.test.ts @@ -1,3 +1,4 @@ +const productId = '733d0d73-3d39-4a98-ac0a-5c0e521d8998'; import { createHash } from 'node:crypto'; import { GetObjectCommand, HeadObjectCommand } from '@aws-sdk/client-s3'; import { @@ -166,7 +167,7 @@ describe('商品图片上传策略', () => { it('创建商品图片上传意图时持久化用途并签发独立对象路径', async () => { const query = vi.fn(async (_sql: string, _params?: unknown[]) => ({ - rows: [], + rows: [{ id: productId }], })); const run = vi.fn( async ( @@ -193,13 +194,15 @@ describe('商品图片上传策略', () => { 'key', { purpose: 'product_image', + product_id: productId, filename: 'sku.png', content_type: 'image/png', byte_size: 1024, checksum_sha256: 'a'.repeat(64), }, ); - const args = query.mock.calls[0]?.[1] as unknown[]; + const args = query.mock.calls.find(([sql]) => sql.includes('INSERT INTO atlas.media_upload_intents'))?.[1] as unknown[]; + expect(args[10]).toBe(productId); expect(args[1]).toBe('product_image'); expect(args[3]).toContain('/product-images/'); const body = result.body as { data: { upload: { url: string } } }; @@ -217,6 +220,7 @@ describe('商品图片上传策略', () => { createMediaUploadIntentRequestSchema.safeParse({ ...request, purpose: 'product_image', + product_id: productId, }).success, ).toBe(true); expect( @@ -229,11 +233,20 @@ describe('商品图片上传策略', () => { createMediaUploadIntentRequestSchema.safeParse({ ...request, purpose: 'product_image', + product_id: productId, byte_size: 21 * 1024 * 1024, }).success, ).toBe(false); }); + it('商品上传和网络导入必须绑定商品,水印仍无需商品', () => { + const request = { filename: 'image.png', content_type: 'image/png', byte_size: 1024, checksum_sha256: 'a'.repeat(64) }; + expect(createMediaUploadIntentRequestSchema.safeParse({ ...request, purpose: 'product_image' }).success).toBe(false); + expect(createMediaUploadIntentRequestSchema.safeParse({ ...request, purpose: 'watermark_image' }).success).toBe(true); + expect(createMediaUploadIntentRequestSchema.safeParse({ ...request, purpose: 'watermark_image', product_id: productId }).success).toBe(false); + expect(importProductImageRequestSchema.safeParse({ url: 'https://example.test/image.png' }).success).toBe(false); + }); + it('商品图校验接受 9000px,水印图仍拒绝超过 8192px', async () => { const bytes = png(9000, 9000); const service = serviceWithObject(bytes); @@ -257,6 +270,7 @@ describe('商品图片上传策略', () => { const query = vi.fn(async (sql: string) => { expect(sql).toContain("i.purpose = 'product_image'"); expect(sql).toContain("i.status = 'ready'"); + expect(sql).toContain('i.product_id = $1'); return { rows: [ { @@ -295,7 +309,7 @@ describe('商品图片上传策略', () => { objectStorageSecretKey: 'test', } as ConstructorParameters[2], ); - const uploads = await service.productUploads('workspace-id'); + const uploads = await service.productUploads('workspace-id', productId); expect(uploads).toHaveLength(1); expect(uploads[0]).toMatchObject({ id: '7c910684-7070-4f36-8a7b-b10bd91acd79', @@ -308,6 +322,7 @@ describe('商品图片上传策略', () => { expect( importProductImageRequestSchema.safeParse({ url: 'https://example.com/image.jpg', + product_id: productId, }).success, ).toBe(true); expect( diff --git a/apps/api/test/pricing.e2e.test.ts b/apps/api/test/pricing.e2e.test.ts index ecc9696..ae64fd5 100644 --- a/apps/api/test/pricing.e2e.test.ts +++ b/apps/api/test/pricing.e2e.test.ts @@ -654,4 +654,72 @@ describe('SKU 试算与原子确认 HTTP / PostgreSQL', () => { assert.equal(rateCard.rows[0].status, 'draft'); assert.equal(rateCard.rows[0].reviewed_at, null); }); + it('已上架 SKU 可确认本地新价格,保留首次导入身份且不触发平台任务', async () => { + const otherStore = randomUUID(); + await db.workspaceTransaction(workspace, async (tx) => { + await tx.query('UPDATE atlas.products SET model=$2 WHERE id=$1', [product, 'LOCAL-RECORD-MODEL']); + await tx.query( + `UPDATE atlas.product_variant_platform_listings SET status='imported',offer_id='LOCAL-RECORD-SKU',platform_product_id='123456',first_imported_at=now() WHERE profile_id=$1 AND product_variant_id=$2`, + [profile, variant], + ); + await tx.query( + `INSERT INTO atlas.stores(id,workspace_id,platform_id,name,platform_client_id,currency_code,status,credential_status) VALUES($1,$2,$3,'未来目标店铺',$4,'RUB','active','valid')`, + [otherStore, workspace, platform, randomUUID()], + ); + await tx.query('UPDATE atlas.product_platform_profiles SET target_store_id=$2 WHERE id=$1', [profile, otherStore]); + }); + const facts = () => db.workspaceTransaction(workspace, async (tx) => ({ + listing: (await tx.query( + 'SELECT status,store_id,offer_id,platform_product_id,first_imported_at,selling_price::text,list_price::text,pricing_quote_id FROM atlas.product_variant_platform_listings WHERE profile_id=$1 AND product_variant_id=$2', + [profile, variant], + )).rows[0], + publishJobs: (await tx.query('SELECT id FROM atlas.product_publish_jobs WHERE product_id=$1', [product])).rows.length, + events: (await tx.query('SELECT id FROM atlas.outbox_events')).rows.length, + })); + const before = await facts(); + const state = await read(); + assert.equal(state.variants.find((v) => v.id === variant)?.listing.store_id, store); + const input = quote(); + input.pricing.mode = 'fixed_sale'; + input.pricing.selling_price = '100'; + input.pricing.list_price_discount_rate = '0.5'; + const wrongStore = await request('/pricing/quote-previews', 'POST', { ...input, store_id: otherStore }); + assert.equal(wrongStore.status, 422); + assert.equal(wrongStore.error.code, 'PRICING_VARIANT_STORE_MISMATCH'); + const preview = await request('/pricing/quote-previews', 'POST', input); + assert.equal(preview.status, 201, JSON.stringify(preview)); + const payload = draft(state); + payload.profile.target_store_id = otherStore; + const changed = payload.variants.find((v) => v.id === variant)!; + changed.pricing_draft = input.pricing; + changed.pricing_selection = { + preview_id: preview.data.preview_id, + option_id: preview.data.recommended_option_id, + input_hash: preview.data.input_hash, + }; + changed.purchase_cost_cny = '29.00'; + assert.equal((await save(state, payload)).status, 409); + assert.deepEqual(await facts(), before); + changed.purchase_cost_cny = '28.00'; + const key = randomUUID(); + const saved = await save(state, payload, key); + assert.equal(saved.status, 200, JSON.stringify(saved)); + assert.deepEqual((await save(state, payload, key)).data, saved.data); + const after = await facts(); + assert.equal(after.listing.selling_price, '100.0000'); + assert.equal(after.listing.list_price, '200.0000'); + assert.notEqual(after.listing.pricing_quote_id, before.listing.pricing_quote_id); + for (const field of ['status', 'store_id', 'offer_id', 'platform_product_id', 'first_imported_at']) + assert.deepEqual(after.listing[field], before.listing[field]); + assert.equal(after.publishJobs, before.publishJobs); + assert.equal(after.events, before.events); + const reread = await read(); + const listing = reread.variants.find((v) => v.id === variant)!.listing; + assert.equal(listing.status, 'imported'); + assert.equal(listing.pricing_result?.selling_price, '100.00'); + const ordinary = draft(reread); + ordinary.profile.target_store_id = otherStore; + assert.equal((await save(reread, ordinary)).status, 200); + assert.deepEqual((await facts()).listing, after.listing); + }); }); diff --git a/apps/api/test/product-ai.unit.test.ts b/apps/api/test/product-ai.unit.test.ts new file mode 100644 index 0000000..f9a5df3 --- /dev/null +++ b/apps/api/test/product-ai.unit.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it, vi } from 'vitest'; +import { PRODUCT_AI_HINT_MAX_LENGTH, requestProductAiTaskSchema } from '@atlas/contracts'; +import type { ProductEditorSnapshot } from '@atlas/contracts'; +import { ProductAiService } from '../src/product-editor/product-ai.service.js'; + +describe('SKU 文案用户要求', () => { + it('将用户要求与 SKU 输入原子固化,并纳入幂等请求,不改写商品字段', async () => { + const hint = requestProductAiTaskSchema.parse({ hint: ' 改为红色款,25cm;标题不要写包邮。 ' }).hint; + let stored: Record | undefined; + let outbox: unknown; + const statements: string[] = []; + const query = vi.fn(async (sql: string, values: unknown[] = []) => { + statements.push(sql); + if (sql.includes('SELECT product_id')) return { rows: [{ product_id: 'product' }] }; + if (sql.includes('SELECT product.revision')) return { rows: [{ product_revision: 3, profile_revision: 4, source_snapshot_revision: 5 }] }; + if (sql.includes('INSERT INTO atlas.product_ai_tasks')) { + stored = JSON.parse(values[4] as string); + return { rows: [{ id: 'task' }] }; + } + if (sql.includes('INSERT INTO atlas.outbox_events')) outbox = JSON.parse(values[1] as string); + return { rows: [] }; + }); + const transaction = { query }; + const workspaceTransaction = vi.fn(async (_workspace: string, work: (tx: typeof transaction) => Promise) => work(transaction)); + const run = vi.fn(async (_input: unknown, work: (tx: typeof transaction) => Promise) => work(transaction)); + const snapshot = { + product: { id: 'product', revision: 3, common: { model: 'model' } }, + profile: { id: 'profile', revision: 4, platform_id: 'platform', target_store: { id: 'store' }, category: { id: null }, title: null, tags: [], description: null }, + source_material: { source_snapshot_revision: 5, title: '采集蓝色 20cm', description: null }, + variants: [{ id: 'sku', display_name: '蓝色款', specification_values: [], listing: { status: 'not_listed', offer_id: 'model-blue' }, tags: [] }], + } as unknown as ProductEditorSnapshot; + const service = new ProductAiService( + { workspaceTransaction } as unknown as ConstructorParameters[0], + { run } as unknown as ConstructorParameters[1], + { read: vi.fn(async () => snapshot) } as unknown as ConstructorParameters[2], + ); + const result = await service.create('workspace', 'user', 'profile', 'platform', 'sku', 'key', hint); + expect(result).toMatchObject({ status: 202, body: { data: { ai_task_id: 'task', status: 'queued' } } }); + expect(stored).toMatchObject({ hint, variant_id: 'sku', revision: { product: 3, profile: 4, source_snapshot: 5 } }); + expect(stored?.source_material).toEqual(snapshot.source_material); + expect(run.mock.calls[0][0]).toMatchObject({ request: { hint, platform_id: 'platform' } }); + expect(outbox).toEqual({ task_id: 'task' }); + expect(statements.some((sql) => /UPDATE atlas\.(products|product_variants|collection_items)/.test(sql))).toBe(false); + expect(workspaceTransaction.mock.calls.every(([workspace]) => workspace === 'workspace')).toBe(true); + }); + + it('兼容不传要求,接受 1000 字符并拒绝超限和未知字段', () => { + expect(requestProductAiTaskSchema.parse({})).toEqual({}); + expect(requestProductAiTaskSchema.parse({ hint: ' ' })).toEqual({ hint: '' }); + expect(requestProductAiTaskSchema.safeParse({ hint: '要'.repeat(PRODUCT_AI_HINT_MAX_LENGTH) }).success).toBe(true); + expect(requestProductAiTaskSchema.safeParse({ hint: '要'.repeat(PRODUCT_AI_HINT_MAX_LENGTH + 1) }).success).toBe(false); + expect(requestProductAiTaskSchema.safeParse({ hint: '红色款', source_override: {} }).success).toBe(false); + }); +}); diff --git a/apps/api/test/product-editor-processed-media.unit.test.ts b/apps/api/test/product-editor-processed-media.unit.test.ts index ea0eafb..5865765 100644 --- a/apps/api/test/product-editor-processed-media.unit.test.ts +++ b/apps/api/test/product-editor-processed-media.unit.test.ts @@ -5,12 +5,18 @@ const productId = '733d0d73-3d39-4a98-ac0a-5c0e521d8998'; const variantId = '50ea00b7-6675-4cc7-b7a2-d45f05c07100'; const assetId = '664d0d73-3d39-4a98-ac0a-5c0e521d8998'; -function mediaTransaction(processed: boolean) { +function mediaTransaction(processed: boolean, library?: 'upload' | 'import', owner = productId) { const query = vi.fn(async (sql: string, values?: unknown[]) => { if (sql.includes('FROM atlas.product_variant_platform_listings')) return { rows: [{ id: 'listing-id', product_variant_id: variantId }] }; if (sql.includes('FROM atlas.media_assets')) return { rows: [{ id: assetId, content_type: 'image/jpeg', byte_size: '1200', width_px: 1200, height_px: 1600 }] }; + if (sql.includes('FROM atlas.media_upload_intents') || sql.includes('FROM atlas.product_image_imports')) { + expect(values).toEqual([productId, [assetId]]); + expect(sql).toContain('product_id = $1'); + const matches = library === 'upload' ? sql.includes('media_upload_intents') : library === 'import' && sql.includes('product_image_imports'); + return { rows: matches && owner === productId ? [{ asset_id: assetId }] : [] }; + } if (sql.includes('FROM atlas.media_aspect_ratio_derivatives')) { expect(values).toEqual([productId, [assetId]]); return { rows: processed ? [{ asset_id: assetId }] : [] }; @@ -34,6 +40,13 @@ describe('SKU 图片处理结果的保存资格', () => { persistListingMedia: (tx: ReturnType, value: typeof input) => Promise; }; + it.each(['upload', 'import'] as const)('%s 图片仅允许所属商品保存', async (library) => { + await service.persistListingMedia(mediaTransaction(false, library), input); + await expect(service.persistListingMedia(mediaTransaction(false, library, '其他商品'), input)).rejects.toMatchObject({ + status: 422, response: { error: { code: 'LISTING_MEDIA_NOT_SELECTABLE' } }, + }); + }); + it('当前商品的成功处理结果可加入 SKU 并保存', async () => { const tx = mediaTransaction(true); await service.persistListingMedia(tx, input); diff --git a/apps/api/test/product-editor.e2e.test.ts b/apps/api/test/product-editor.e2e.test.ts index 7b332ad..e96e444 100644 --- a/apps/api/test/product-editor.e2e.test.ts +++ b/apps/api/test/product-editor.e2e.test.ts @@ -1933,18 +1933,18 @@ describe('商品编辑 M3.0–M3.3 HTTP 集成测试', () => { `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) + status, asset_id, expires_at, completed_at, product_id) 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], + 'ready', $3, now() + interval '1 hour', now(), $4)`, + [objectKey, checksum, assetId, first.productId], ); } 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], + (workspace_id, source_url, asset_id, product_id) + VALUES (current_setting('app.workspace_id')::uuid, $1, $2, $3)`, + [`https://example.test/${randomUUID()}.png`, assetId, first.productId], ); } } @@ -1971,20 +1971,38 @@ describe('商品编辑 M3.0–M3.3 HTTP 集成测试', () => { 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', { + const uploaded = await request(`/api/media-assets/product-uploads?product_id=${first.productId}`, { 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', { + const usedDeletion = await request(`/api/products/${first.productId}/image-deletions`, { + method: 'POST', headers: headers({ 'Idempotency-Key': randomUUID() }), + body: JSON.stringify({ asset_id: additionalAssets[0] }), + }); + assert.equal(usedDeletion.response.status, 409); + assert.equal(usedDeletion.body.error.code, 'MEDIA_ASSET_IN_USE'); + const invalidDeletion = await request(`/api/products/${first.productId}/image-deletions`, { + method: 'POST', headers: headers({ 'Idempotency-Key': randomUUID() }), body: '{}', + }); + assert.equal(invalidDeletion.response.status, 422); + + const imported = await request(`/api/media-assets/product-imports?product_id=${first.productId}`, { headers: headers(), }); assert.equal(imported.response.status, 200); assert.ok(imported.body.data.some((item: any) => item.id === additionalAssets[1])); + const unscoped = await request('/api/media-assets/product-uploads', { headers: headers() }); + assert.equal(unscoped.response.status, 422); + for (const kind of ['product-uploads', 'product-imports']) { + const unrelated = await request(`/api/media-assets/${kind}?product_id=${randomUUID()}`, { headers: headers() }); + assert.equal(unrelated.response.status, 200); + assert.deepEqual(unrelated.body.data, []); + } 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' }), + body: JSON.stringify({ product_id: first.productId, url: 'https://127.0.0.1/private.png' }), }); assert.equal(privateImport.response.status, 422); assert.equal(privateImport.body.error.code, 'PRODUCT_IMAGE_IMPORT_INVALID'); diff --git a/apps/api/test/product-image-deletion.database.test.ts b/apps/api/test/product-image-deletion.database.test.ts new file mode 100644 index 0000000..501c160 --- /dev/null +++ b/apps/api/test/product-image-deletion.database.test.ts @@ -0,0 +1,83 @@ +import { randomUUID } from 'node:crypto'; +import { createApplicationDatabase, type Database } from '@atlas/database'; +import { describe, expect, it, vi } from 'vitest'; +import { ProductImageDeletionService } from '../src/media/product-image-deletion.service.js'; +import { IdempotencyService } from '../src/common/idempotency.service.js'; +import { runMediaAssetDeletion, markMediaAssetDeletionFailed } from '../../worker/src/media-asset-delete.js'; + +/** 全部业务实体在结束时回滚,清理对象请求使用替身。 */ +describe.skipIf(!process.env.DATABASE_URL)('商品图片永久删除数据库闭环', () => { + it('校验归属和引用,可靠清理可重试且幂等,删除后不能新增引用', async () => { + const db = createApplicationDatabase(process.env.DATABASE_URL!); + const rollback = Symbol('rollback'); + try { + await db.transaction(async tx => { + const ws = randomUUID(), foreignWs = randomUUID(), user = randomUUID(); + await tx.query("INSERT INTO atlas.users (id, email, password_hash) VALUES ($1, $2, 'test')", [user, `${user}@example.test`]); + for (const id of [ws, foreignWs]) await tx.query("INSERT INTO atlas.workspaces (id,name,slug) VALUES ($1::uuid,'删除测试',$1::text)", [id]); + const setWorkspace = (id: string) => tx.query("SELECT set_config('app.workspace_id', $1, true)", [id]); + await setWorkspace(ws); + const item = randomUUID(), product = randomUUID(), otherProduct = randomUUID(), asset = randomUUID(); + await tx.query("INSERT INTO atlas.collection_items (id,workspace_id,source_kind,source_item_key) VALUES ($1::uuid,$2,'manual',$1::text)", [item, ws]); + await tx.query('INSERT INTO atlas.products (id,workspace_id,collection_item_id) VALUES ($1,$2,$3)', [product, ws, item]); + const otherItem = randomUUID(); + await tx.query("INSERT INTO atlas.collection_items (id,workspace_id,source_kind,source_item_key) VALUES ($1::uuid,$2,'manual',$1::text)", [otherItem, ws]); + await tx.query('INSERT INTO atlas.products (id,workspace_id,collection_item_id) VALUES ($1,$2,$3)', [otherProduct, ws, otherItem]); + const checksum = 'a'.repeat(64); + await tx.query(`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,$2,'local_minio','test','test/canonical','image/png',100,$3,800,800)`, [asset, ws, checksum]); + await tx.query(`INSERT INTO atlas.media_upload_intents (workspace_id,product_id,purpose,storage_provider,bucket,object_key,expected_content_type,expected_byte_size, + expected_checksum_sha256,status,asset_id,expires_at,completed_at) + VALUES ($1,$2,'product_image','local_minio','test','test/duplicate','image/png',100,$3,'ready',$4,now()+interval '1 hour',now())`, [ws,product,checksum,asset]); + const scoped = { workspaceTransaction: async (id: string, work: (transaction: typeof tx) => Promise) => { await setWorkspace(id); return work(tx); } } as Database; + const service = new ProductImageDeletionService(scoped, new IdempotencyService(scoped, { IDEMPOTENCY_KEY_SECRET: 'delete-test' } as never)); + await expect(service.create(ws,user,otherProduct,randomUUID(),{asset_id:asset})).rejects.toMatchObject({status:404}); + await tx.query('UPDATE atlas.users SET avatar_asset_id = $1 WHERE id = $2', [asset,user]); + await expect(service.create(ws,user,product,randomUUID(),{asset_id:asset})).rejects.toMatchObject({status:409}); + await tx.query('SAVEPOINT in_use_delete'); + await expect(tx.query('UPDATE atlas.media_assets SET deleted_at = now(), checksum_sha256 = NULL WHERE id = $1', [asset])).rejects.toMatchObject({code:'23514'}); + await tx.query('ROLLBACK TO SAVEPOINT in_use_delete'); + await tx.query('UPDATE atlas.users SET avatar_asset_id = NULL WHERE id = $1', [user]); + await tx.query("INSERT INTO atlas.product_image_imports (workspace_id,product_id,source_url,asset_id) VALUES ($1,$2,'https://example.test/shared.png',$3)", [ws,otherProduct,asset]); + await expect(service.create(ws,user,product,randomUUID(),{asset_id:asset})).rejects.toMatchObject({status:409}); + await tx.query('DELETE FROM atlas.product_image_imports WHERE product_id = $1', [otherProduct]); + const key = randomUUID(); + const result = await service.create(ws,user,product,key,{asset_id:asset}); + const deletion = (result.body as {data:{id:string;status:string}}).data; + expect(deletion.status).toBe('queued'); + expect((await service.create(ws,user,product,key,{asset_id:asset})).body).toEqual(result.body); + const removed = (await tx.query('SELECT deleted_at,checksum_sha256,deleted_checksum_sha256 FROM atlas.media_assets WHERE id = $1',[asset])).rows[0]; + expect(removed.deleted_at).toBeTruthy(); expect(removed.checksum_sha256).toBeNull(); expect(removed.deleted_checksum_sha256).toBe(checksum); + expect((await tx.query("SELECT event_payload FROM atlas.outbox_events WHERE event_type = 'media.asset_delete.requested'")).rows).toHaveLength(1); + await tx.query('SAVEPOINT deleted_reference'); + await expect(tx.query("INSERT INTO atlas.product_image_imports (workspace_id,product_id,source_url,asset_id) VALUES ($1,$2,'https://example.test/forbidden.png',$3)",[ws,product,asset])).rejects.toMatchObject({code:'23514'}); + await tx.query('ROLLBACK TO SAVEPOINT deleted_reference'); + const send = vi.fn().mockRejectedValueOnce(new Error('存储暂不可用')).mockResolvedValue({}); + const storage = () => ({ send }) as never; + const job = {eventId:randomUUID(),workspaceId:ws,eventType:'media.asset_delete.requested',payload:{deletion_id:deletion.id}}; + await expect(runMediaAssetDeletion(scoped,storage,job)).rejects.toThrow('存储暂不可用'); + await markMediaAssetDeletionFailed(scoped,job); + const failed = await service.read(ws,product,deletion.id); + expect(failed.status).toBe('failed'); expect(failed.operation.status).toBe('failed'); + await service.retry(ws,user,product,deletion.id,randomUUID()); + await runMediaAssetDeletion(scoped,storage,job); + const succeeded = await service.read(ws,product,deletion.id); + expect(succeeded.status).toBe('succeeded'); expect(succeeded.operation.status).toBe('succeeded'); + expect(send).toHaveBeenCalledTimes(3); + await runMediaAssetDeletion(scoped,storage,job); + expect(send).toHaveBeenCalledTimes(3); + await tx.query(`INSERT INTO atlas.media_assets (workspace_id,storage_provider,bucket,object_key,content_type,byte_size,checksum_sha256) + VALUES ($1,'local_minio','test','test/new-object','image/png',100,$2)`,[ws,checksum]); + const transfer = randomUUID(); + await tx.query(`INSERT INTO atlas.collection_asset_transfers (id,workspace_id,collection_item_id,product_id,role,source_url) + VALUES ($1,$2,$3,$4,'source_main','https://example.test/source.png')`,[transfer,ws,item,product]); + const sourceDelete = await service.create(ws,user,product,randomUUID(),{collection_image_id:transfer}); + expect((sourceDelete.body as {data:{status:string}}).data.status).toBe('succeeded'); + expect((await tx.query('SELECT status,deleted_at FROM atlas.collection_asset_transfers WHERE id = $1',[transfer])).rows[0]).toMatchObject({status:'cancelled'}); + await expect(service.read(foreignWs,product,deletion.id)).rejects.toMatchObject({status:404}); + throw rollback; + }); + } catch (error) { if (error !== rollback) throw error; } + finally { await db.close(); } + }); +}); diff --git a/apps/api/test/product-image-library.database.test.ts b/apps/api/test/product-image-library.database.test.ts new file mode 100644 index 0000000..17f5446 --- /dev/null +++ b/apps/api/test/product-image-library.database.test.ts @@ -0,0 +1,101 @@ +import { createHash, randomUUID } from 'node:crypto'; +import { createApplicationDatabase, type Database, type DatabaseTransaction } from '@atlas/database'; +import { describe, expect, it, vi } from 'vitest'; +import { IdempotencyService } from '../src/common/idempotency.service.js'; +import { MediaService } from '../src/media/media.service.js'; + +const bytes = Buffer.from('validated-product-image'); +vi.mock('../src/media/product-image-download.js', () => ({ + downloadProductImage: vi.fn(async () => ({ bytes, contentType: 'image/png', width: 800, height: 800 })), +})); + +/** 归属验证只在回滚事务中创建数据,存储和公网下载由测试替身承担。 */ +describe.skipIf(!process.env.DATABASE_URL)('商品上传与网络图片数据库隔离', () => { + it('候选只属于当前商品,重复素材可独立绑定,FK 与 RLS 拒绝越权', async () => { + const db = createApplicationDatabase(process.env.DATABASE_URL!); + const rollback = Symbol('rollback'); + try { + await db.transaction(async (tx) => { + const workspaceId = randomUUID(), otherWorkspace = randomUUID(), userId = randomUUID(); + await tx.query("INSERT INTO atlas.users (id, email, password_hash) VALUES ($1, $2, 'test-only')", [userId, `${userId}@example.test`]); + for (const id of [workspaceId, otherWorkspace]) + await tx.query("INSERT INTO atlas.workspaces (id, name, slug) VALUES ($1::uuid, '图片归属测试', $1::text)", [id]); + const setWorkspace = (id: string) => tx.query("SELECT set_config('app.workspace_id', $1, true)", [id]); + const product = async (ws: string) => { + await setWorkspace(ws); + const id = randomUUID(), itemId = randomUUID(); + await tx.query("INSERT INTO atlas.collection_items (id, workspace_id, source_kind, source_item_key) VALUES ($1::uuid, $2, 'manual', $1::text)", [itemId, ws]); + await tx.query('INSERT INTO atlas.products (id, workspace_id, collection_item_id) VALUES ($1, $2, $3)', [id, ws, itemId]); + return id; + }; + const productA = await product(workspaceId), productB = await product(workspaceId), foreignProduct = await product(otherWorkspace); + await setWorkspace(workspaceId); + const assetA = randomUUID(), assetB = randomUUID(), legacyAsset = randomUUID(); + const checksum = createHash('sha256').update(bytes).digest('hex'); + for (const id of [assetA, assetB, legacyAsset]) + await tx.query(`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::uuid, $2, 'local_minio', 'test', $1::text, 'image/png', $3, $4, 800, 800)`, + [id, workspaceId, bytes.length, id === assetA ? checksum : id]); + const upload = async (productId: string | null, assetId: string) => 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, product_id) + VALUES ($1, 'product_image', 'local_minio', 'test', $2, 'image/png', $3, $4, 'ready', $5, now() + interval '1 hour', now(), $6)`, + [workspaceId, randomUUID(), bytes.length, checksum, assetId, productId]); + const imported = async (productId: string | null, assetId: string) => tx.query(`INSERT INTO atlas.product_image_imports + (workspace_id, product_id, source_url, asset_id) VALUES ($1, $2, 'https://example.test/shared.png', $3)`, + [workspaceId, productId, assetId]); + await upload(productA, assetA); + await upload(productB, assetB); + await upload(null, legacyAsset); + await imported(productA, assetA); + await imported(productB, assetB); + await imported(null, legacyAsset); + const scoped = { workspaceTransaction: async (ws: string, work: (tx: DatabaseTransaction) => Promise) => { + await setWorkspace(ws); + return work(tx); + } } as Database; + const media = new MediaService(new IdempotencyService(scoped, { IDEMPOTENCY_KEY_SECRET: 'image-test-secret' } as never), scoped, { + OBJECT_STORAGE_ENDPOINT: 'http://127.0.0.1:9000', OBJECT_STORAGE_REGION: 'us-east-1', OBJECT_STORAGE_BUCKET: 'test', + OBJECT_STORAGE_PROVIDER: 'local_minio', objectStorageForcePathStyle: true, objectStorageAccessKey: 'test', objectStorageSecretKey: 'test', + } as never); + Object.assign(media, { ensureBucket: async () => undefined, inspectObject: async () => ({ + byteSize: bytes.length, checksum, contentType: 'image/png', width: 800, height: 800, + }) }); + const ids = (rows: Array<{ id: string }>) => rows.map((row) => row.id).sort(); + expect(ids(await media.productUploads(workspaceId, productA))).toEqual([assetA]); + expect(ids(await media.productUploads(workspaceId, productB))).toEqual([assetB]); + expect(ids(await media.productImports(workspaceId, productA))).toEqual([assetA]); + expect(ids(await media.productImports(workspaceId, productB))).toEqual([assetB]); + const input = { purpose: 'product_image' as const, product_id: productB, filename: 'shared.png', + content_type: 'image/png' as const, byte_size: bytes.length, checksum_sha256: checksum }; + const key = randomUUID(); + const intent = await media.createIntent(workspaceId, userId, key, input); + const uploadId = (intent.body as { data: { upload_id: string } }).data.upload_id; + const repeated = await media.createIntent(workspaceId, userId, key, input); + expect((repeated.body as { data: { upload_id: string } }).data.upload_id).toBe(uploadId); + expect((await tx.query('SELECT product_id FROM atlas.media_upload_intents WHERE id = $1', [uploadId])).rows[0].product_id).toBe(productB); + await media.completeIntent(workspaceId, userId, uploadId, randomUUID(), { checksum_sha256: checksum }); + expect(ids(await media.productUploads(workspaceId, productB))).toEqual([assetA, assetB].sort()); + expect(ids(await media.productUploads(workspaceId, productA))).toEqual([assetA]); + await media.importProductImage(workspaceId, userId, randomUUID(), { product_id: productB, url: 'https://example.test/shared.png' }); + expect(ids(await media.productImports(workspaceId, productB))).toEqual([assetA]); + expect(ids(await media.productImports(workspaceId, productA))).toEqual([assetA]); + const forbidden = async (work: () => Promise) => { + await tx.query('SAVEPOINT forbidden_image'); + try { await expect(work()).rejects.toMatchObject({ code: '23503' }); } + finally { await tx.query('ROLLBACK TO SAVEPOINT forbidden_image'); } + }; + await forbidden(() => upload(foreignProduct, assetA)); + await forbidden(() => imported(foreignProduct, assetA)); + await tx.query('SAVEPOINT unknown_product'); + await expect(media.createIntent(workspaceId, userId, randomUUID(), { ...input, product_id: foreignProduct })).rejects.toMatchObject({ status: 404 }); + await tx.query('ROLLBACK TO SAVEPOINT unknown_product'); + expect(await media.productUploads(otherWorkspace, productA)).toEqual([]); + expect(await media.productImports(otherWorkspace, productA)).toEqual([]); + throw rollback; + }); + } catch (error) { if (error !== rollback) throw error; } + finally { await db.close(); } + }); +}); diff --git a/apps/api/test/product-publish.e2e.test.ts b/apps/api/test/product-publish.e2e.test.ts index 35cfb3c..b904a63 100644 --- a/apps/api/test/product-publish.e2e.test.ts +++ b/apps/api/test/product-publish.e2e.test.ts @@ -218,6 +218,125 @@ describe('首次发布 HTTP / 数据库 / Worker 闭环', () => { await app?.close(); }); + it('商品颜色按属性 ID 支持多选保存、重读和发布,其他单值属性仍限制数量', async () => { + const f = await fixture(1); + const dictionaryId = `color-${randomUUID()}`; + await db.workspaceTransaction(workspace, async tx => { + const ownCategory = (await tx.query<{ id: string }>( + `INSERT INTO atlas.platform_product_categories(platform_id,external_category_id,external_type_id,name,path) + VALUES($1,$2,'1002','颜色多选测试','颜色多选测试') RETURNING id`, + [platform, String(Date.now()) + '96'], + )).rows[0].id; + const color = (await tx.query<{ id: string }>( + `INSERT INTO atlas.platform_product_category_attributes + (platform_id,platform_product_category_id,platform_attribute_id,name,value_type,is_required,is_aspect,is_collection,max_value_count,dictionary_id) + VALUES($1,$2,'10096','Цвет товара','dictionary',true,true,false,1,$3) RETURNING id`, + [platform, ownCategory, dictionaryId], + )).rows[0].id; + await tx.query(`INSERT INTO atlas.platform_product_category_attributes + (platform_id,platform_product_category_id,platform_attribute_id,name,value_type,is_aspect,is_collection,max_value_count) + VALUES($1,$2,'200001','单值测试','text',true,false,1)`, [platform, ownCategory]); + await tx.query(`INSERT INTO atlas.platform_catalog_dictionary_values + (platform_id,platform_product_category_id,attribute_id,dictionary_id,external_value_id,value) + VALUES($1,$2,$3,$4,'61579','红色'),($1,$2,$3,$4,'61581','蓝色')`, + [platform, ownCategory, color, dictionaryId]); + await tx.query(`INSERT INTO atlas.platform_catalog_resource_states + (platform_id,scope_key,scope,category_id,attribute_id,last_success_at,is_complete) + VALUES($1,'attributes:' || $2::text,'attributes',$2::uuid,NULL,now(),true), + ($1,'dictionary:' || $3::text,'dictionary',$2::uuid,$3::uuid,now(),true)`, + [platform, ownCategory, color]); + await tx.query('UPDATE atlas.product_platform_profiles SET platform_product_category_id=$2 WHERE id=$1', [f.profile, ownCategory]); + await tx.query('UPDATE atlas.product_variant_platform_listings SET pricing_quote_id=NULL WHERE profile_id=$1', [f.profile]); + await tx.query(`UPDATE atlas.media_assets SET width_px=800,height_px=800 + WHERE id IN (SELECT asset_id FROM atlas.product_variant_listing_media WHERE profile_id=$1)`, [f.profile]); + }); + const before = (await request(`/products/${f.product}/editor`)).body.data; + const colorDraft = { + scope: 'variant', variant_id: f.ids[0], platform_attribute_id: '10096', complex_id: '0', + values: ['61579', '61581'], language_code: null, source: 'manual', confirmation_status: 'confirmed', + }; + const draft = { + ai_source_material: before.source_material, + ai_source_overridden_fields: before.source_material_state.overridden_fields, + common: { ...before.product.common, default_physical_spec: before.product.default_physical_spec, + default_package_spec: before.product.default_package_spec }, + profile: { target_store_id: store, fulfillment: before.profile.fulfillment, title: before.profile.title, + description: before.profile.description, tags: before.profile.tags, + platform_product_category_id: before.profile.category.id, category_source: 'manual' }, + attributes: [colorDraft], + variants: before.variants.map((v: any) => ({ + id: v.id, client_key: null, display_name: v.display_name, source_sku: v.source_sku, + specification_values: v.specification_values, purchase_cost_cny: v.purchase_cost_cny, + title: v.title, tags: v.tags, description: v.description, physical_spec: v.physical_spec, + package_spec: v.package_spec, offer_id: v.listing.offer_id, + media_asset_ids: v.media.map((m: any) => m.asset_id), + })), + }; + const save = (attributes = draft.attributes) => request(`/products/${f.product}/editor-draft`, 'PUT', + { ...draft, attributes }, { 'Idempotency-Key': randomUUID(), 'If-Match': '"1:1"' }); + const invalidSingle = await save([{ ...colorDraft, platform_attribute_id: '200001', values: ['one', 'two'] }]); + assert.equal(invalidSingle.status, 422); + assert.equal(invalidSingle.body.error.code, 'ATTRIBUTE_VALUE_COUNT_INVALID'); + const invalidDictionary = await save([{ ...colorDraft, values: ['61579', '99999999'] }]); + assert.equal(invalidDictionary.status, 422); + const saved = await save(); + assert.equal(saved.status, 200, JSON.stringify(saved)); + const after = (await request(`/products/${f.product}/editor`)).body.data; + assert.deepEqual(after.attributes.find((a: any) => a.platform_attribute_id === '10096').values, ['61579', '61581']); + // fixture 使用预先确认的模拟报价;草稿保存后恢复其有效标记,仅验证颜色发布映射。 + await db.workspaceTransaction(workspace, tx => tx.query( + `UPDATE atlas.product_variant_platform_listings listing SET pricing_valid=true, + pricing_quote_id=(SELECT quote.id FROM atlas.pricing_quotes quote + WHERE quote.profile_id=listing.profile_id AND quote.product_variant_id=listing.product_variant_id LIMIT 1) + WHERE listing.profile_id=$1`, [f.profile])); + const job = await request(`/product-platform-profiles/${f.profile}/publish-jobs`, 'POST', + { variant_ids: f.ids }, { 'Idempotency-Key': randomUUID(), 'If-Match': `"${after.product.revision}:${after.profile.revision}"` }); + assert.equal(job.status, 202, JSON.stringify(job)); + const snapshot = await db.workspaceTransaction(workspace, async tx => + (await tx.query<{ snapshot: { attributes: Array<{ id: number; values: unknown[] }> } }>( + 'SELECT snapshot FROM atlas.product_publish_job_items WHERE job_id=$1', [job.body.data.publish_job.id])).rows[0].snapshot); + assert.deepEqual(snapshot.attributes.find(a => a.id === 10096)?.values, [ + { dictionary_value_id: 61579, value: '红色' }, + { dictionary_value_id: 61581, value: '蓝色' }, + ]); + }); + + it('简介保留 SKU 覆盖和 SPU 继承的换行,Ozon 请求转为 br 且不修改快照', async () => { + const f = await fixture(); + const common = 'Общее описание\r\n\r\nХарактеристики:\r\n• Один & два'; + const own = 'Описание SKU\n\nРазмер < 10\n• Красный'; + await db.workspaceTransaction(workspace, async tx => { + await tx.query(`INSERT INTO atlas.platform_product_category_attributes + (platform_id,platform_product_category_id,platform_attribute_id,name,value_type,is_required,is_aspect,max_value_count) + VALUES($1,$2,'4191','简介','text',false,false,1) ON CONFLICT DO NOTHING`, [platform, category]); + await tx.query('UPDATE atlas.product_platform_profiles SET description=$2 WHERE id=$1', [f.profile, common]); + await tx.query('UPDATE atlas.product_variant_platform_listings SET description=$3 WHERE profile_id=$1 AND product_variant_id=$2', [f.profile,f.ids[0],own]); + }); + const created = await publish(f); + assert.equal(created.status, 202, JSON.stringify(created)); + const job = created.body.data.publish_job as ProductPublishJob; + const snapshots = () => db.workspaceTransaction(workspace, async tx => (await tx.query<{ + variant_id: string; snapshot: { attributes: Array<{ id: number; values: Array<{ value: string }> }> }; + }>('SELECT variant_id,snapshot FROM atlas.product_publish_job_items WHERE job_id=$1', [job.id])).rows); + const before = await snapshots(); + for (const row of before) assert.equal(row.snapshot.attributes.find(a=>a.id===4191)?.values[0].value, + row.variant_id === f.ids[0] ? own : common); + let submitted = false; + await run(job, async (_client, _key, path, body) => { + if (path === '/v3/product/info/list') return { items: [] }; + assert.equal(path, '/v3/product/import'); + submitted = true; + const items = (body as { items: Array<{ attributes: Array<{ id: number; values: Array<{ value: string }> }> }> }).items; + assert.deepEqual(new Set(items.map(item=>item.attributes.find(a=>a.id===4191)?.values[0].value)), new Set([ + 'Описание SKU

Размер < 10
• Красный', + 'Общее описание

Характеристики:
• Один & два', + ])); + return { result: { task_id: 778899 } }; + }); + assert.equal(submitted,true); + assert.deepEqual(await snapshots(), before); + }); + it('多个主题标签合并为一个 Ozon 属性值,SKU 覆盖与继承均保留', async () => { const f = await fixture(); await db.workspaceTransaction(workspace, async (tx) => { diff --git a/apps/extension/entrypoints/background.ts b/apps/extension/entrypoints/background.ts index 1c495f1..1493b45 100644 --- a/apps/extension/entrypoints/background.ts +++ b/apps/extension/entrypoints/background.ts @@ -1,10 +1,15 @@ import { getSession, login, openErp, submit } from '../lib/api.js'; +import { backgroundError } from '../lib/request-errors.js'; export default defineBackground(() => { browser.runtime.onMessage.addListener(async (message: { type?: string; payload?: unknown; path?: string }) => { - if (message?.type === 'atlas.session') return { session: await getSession() }; - if (message?.type === 'atlas.login') return { session: await login() }; - if (message?.type === 'atlas.collect') return { result: await submit(message.payload as import('@atlas/contracts').CollectedProductPayload) }; - if (message?.type === 'atlas.open') { await openErp(message.path); return { ok: true }; } - return undefined; + try { + if (message?.type === 'atlas.session') return { session: await getSession() }; + if (message?.type === 'atlas.login') return { session: await login() }; + if (message?.type === 'atlas.collect') return { result: await submit(message.payload as import('@atlas/contracts').CollectedProductPayload) }; + if (message?.type === 'atlas.open') { await openErp(message.path); return { ok: true }; } + return undefined; + } catch (error) { + return backgroundError(error); + } }); }); diff --git a/apps/extension/entrypoints/collector.content.ts b/apps/extension/entrypoints/collector.content.ts index 2cb3fd4..e041e33 100644 --- a/apps/extension/entrypoints/collector.content.ts +++ b/apps/extension/entrypoints/collector.content.ts @@ -4,6 +4,8 @@ import { } from '../lib/source-parsers.js'; import type { OzonPageJsonFetcher } from '../lib/ozon-parser.js'; import type { MarketplacePageStateFetcher } from '../lib/source-parsers.js'; +import { renderCollectorPanel } from '../lib/collector-panel.js'; +import { unwrapBackgroundResponse } from '../lib/request-errors.js'; export default defineContentScript({ matches: [ @@ -19,6 +21,7 @@ export default defineContentScript({ let mount: HTMLDivElement | null = null; let shadow: ShadowRoot | null = null; let observedUrl = location.href; + let minimized = false; const render = async () => { if (!supportedProductPlatform()) { @@ -35,21 +38,17 @@ export default defineContentScript({ shadow = mount.attachShadow({ mode: 'open' }); } const root = shadow!; - const session = await browser.runtime - .sendMessage({ type: 'atlas.session' }) + const session = await sendBackgroundMessage<{ session?: unknown }>({ type: 'atlas.session' }) .then((value: { session?: unknown }) => value.session) .catch(() => null); - root.innerHTML = `
Atlas ERP
${session ? '' : ''}
`; - root.querySelector('.dot')?.addEventListener('click', () => { - mount?.remove(); - mount = null; - shadow = null; - }); - root.querySelectorAll('[data-action]').forEach( - (button) => - button.addEventListener('click', () => - void action(button.dataset.action ?? '', root), - ), + renderCollectorPanel( + root, + Boolean(session), + minimized, + (next) => { + minimized = next; + }, + (kind) => void action(kind, root), ); }; @@ -58,12 +57,12 @@ export default defineContentScript({ try { if (kind === 'login') { hint.textContent = '正在登录…'; - await browser.runtime.sendMessage({ type: 'atlas.login' }); + await sendBackgroundMessage({ type: 'atlas.login' }); await render(); return; } if (kind === 'erp') { - await browser.runtime.sendMessage({ type: 'atlas.open', path: '/' }); + await sendBackgroundMessage({ type: 'atlas.open', path: '/' }); return; } hint.textContent = '正在采集多 SKU…'; @@ -77,18 +76,19 @@ export default defineContentScript({ ? requestMarketplacePageState : undefined, ); - const response = await browser.runtime.sendMessage({ + hint.textContent = `已解析 ${payload.variants.length || 1} 个 SKU,正在入库…`; + const response = await sendBackgroundMessage<{ result: { data: { editor_entry_path: string } } }>({ type: 'atlas.collect', payload, }); if (kind === 'edit') { - await browser.runtime.sendMessage({ + await sendBackgroundMessage({ type: 'atlas.open', path: response.result.data.editor_entry_path, }); } hint.textContent = - kind === 'edit' ? '已打开采集数据测试页' : '已进入采集箱'; + `${kind === 'edit' ? '已打开商品编辑页' : '已进入采集箱'}(${payload.variants.length || 1} 个 SKU)`; } catch (error) { hint.textContent = error instanceof Error ? error.message : '采集失败,请重试'; @@ -105,6 +105,10 @@ export default defineContentScript({ }, }); +async function sendBackgroundMessage(message: { type: string; payload?: unknown; path?: string }) { + return unwrapBackgroundResponse(await browser.runtime.sendMessage(message)); +} + const requestOzonPageJson: OzonPageJsonFetcher = ({ kind, sku }) => new Promise((resolve, reject) => { const requestId = crypto.randomUUID(); diff --git a/apps/extension/lib/api.ts b/apps/extension/lib/api.ts index 4a280e9..982342b 100644 --- a/apps/extension/lib/api.ts +++ b/apps/extension/lib/api.ts @@ -1,4 +1,6 @@ import type { CollectedProductPayload } from '@atlas/contracts'; +import { requestError } from './request-errors.js'; +import { validateCollectedPayload } from './collection-payload-validation.js'; const apiOrigin = (import.meta.env.ATLAS_API_ORIGIN as string | undefined) ?? 'http://localhost:8200'; const webOrigin = (import.meta.env.ATLAS_WEB_ORIGIN as string | undefined) ?? 'http://localhost:8100'; @@ -10,7 +12,10 @@ async function saveToken(token: string) { await browser.storage.local.set({ [sto async function clearTokens() { await browser.storage.local.remove(storageKey); } async function request(path: string, options: RequestInit = {}, token?: string) { const response = await fetch(`${apiOrigin}/api${path}`, { ...options, headers: { Accept: 'application/json', ...(options.headers ?? {}), ...(token ? { Authorization: `Bearer ${token}` } : {}) } }); - if (!response.ok) { const body = await response.json().catch(() => null) as { error?: { message?: string } } | null; throw Object.assign(new Error(body?.error?.message ?? `请求失败(HTTP ${response.status})`), { status: response.status }); } + if (!response.ok) { + const body: unknown = await response.json().catch(() => null); + throw requestError(body, response.status); + } return response.status === 204 ? undefined : response.json() as Promise<{ data: unknown }>; } async function activeAccessToken() { @@ -43,7 +48,16 @@ export async function login() { } throw new Error('登录超时,请在 Atlas ERP 网页完成登录后重试'); } -export async function submit(payload: CollectedProductPayload) { const token = await activeAccessToken(); if (!token) throw new Error('请先登录 Atlas ERP'); return request('/extension/materials', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Idempotency-Key': crypto.randomUUID() }, body: JSON.stringify(payload) }, token) as Promise<{ data: { editor_entry_path: string; product_id: string; collection_item: { id: string }; reused: boolean } }>; } +export async function submit(payload: CollectedProductPayload) { + const validated = validateCollectedPayload(payload); + const token = await activeAccessToken(); + if (!token) throw new Error('请先登录 Atlas ERP'); + return request('/extension/materials', { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'Idempotency-Key': crypto.randomUUID() }, + body: JSON.stringify(validated), + }, token) as Promise<{ data: { editor_entry_path: string; product_id: string; collection_item: { id: string }; reused: boolean } }>; +} export async function openErp(path = '/') { await browser.tabs.create({ url: new URL(path, webOrigin).toString() }); } async function readWebAccessToken(tabId: number) { try { diff --git a/apps/extension/lib/collection-payload-validation.ts b/apps/extension/lib/collection-payload-validation.ts new file mode 100644 index 0000000..f302dac --- /dev/null +++ b/apps/extension/lib/collection-payload-validation.ts @@ -0,0 +1,11 @@ +import { collectedProductPayloadSchema } from '@atlas/contracts'; + +/** 先按共享契约校验采集载荷,失败时保留字段路径,不丢弃或截断真实 SKU。 */ +export function validateCollectedPayload(payload: unknown) { + const result = collectedProductPayloadSchema.safeParse(payload); + if (result.success) return result.data; + const details = result.error.issues.slice(0, 3).map((issue) => + `${issue.path.join('.') || '采集数据'}:${issue.message}`, + ); + throw new Error(`采集数据不符合要求(${details.join(';')})`); +} diff --git a/apps/extension/lib/collector-panel.ts b/apps/extension/lib/collector-panel.ts new file mode 100644 index 0000000..e2be52d --- /dev/null +++ b/apps/extension/lib/collector-panel.ts @@ -0,0 +1,58 @@ +/** 采集浮窗只负责展示与折叠,登录、采集和页面跳转交由内容脚本处理。 */ +export function renderCollectorPanel( + root: ShadowRoot, + authenticated: boolean, + minimized: boolean, + onToggle: (minimized: boolean) => void, + onAction: (kind: string) => void, +) { + root.innerHTML = ` + +
+
+
+ Atlas ERP + +
+ ${authenticated + ? '' + : ''} +
+
+ +
`; + const panel = root.querySelector('.collector')!; + const minimize = root.querySelector('.minimize')!; + const launcher = root.querySelector('.launcher')!; + const toggle = (next: boolean) => { + panel.classList.toggle('is-minimized', next); + launcher.setAttribute('aria-expanded', String(!next)); + onToggle(next); + (next ? launcher : minimize).focus(); + }; + minimize.addEventListener('click', () => toggle(true)); + launcher.addEventListener('click', () => toggle(false)); + root.querySelectorAll('[data-action]').forEach((button) => + button.addEventListener('click', () => onAction(button.dataset.action ?? '')), + ); +} diff --git a/apps/extension/lib/product-media.ts b/apps/extension/lib/product-media.ts index b209e1a..d311671 100644 --- a/apps/extension/lib/product-media.ts +++ b/apps/extension/lib/product-media.ts @@ -29,6 +29,14 @@ type ImageGroup = { */ const DOM_IMAGE_PROFILES: Record = { '1688': [ + { + role: 'source_main', + relation: 'inherited_product_default', + // 画廊缩略项虽小,但背景地址指向商品原图;位置与控件类型比尺寸更可靠。 + selectors: ['.od-picture-gallery-list > .v-image-cover'], + sourceProps: ['backgroundImage'], + excludeWithin: ['.video-image-cover', '.v-image-cover:has(.cpv-icon, .cpv-tag-name)'], + }, { role: 'source_main', relation: 'inherited_product_default', @@ -43,6 +51,7 @@ const DOM_IMAGE_PROFILES: Record = { '.detail-gallery-turn img.detail-gallery-img', '.img-list-wrapper img.od-gallery-img', ], + excludeWithin: ['.video-image-cover', '.v-image-cover:has(.cpv-icon, .cpv-tag-name)'], minWidth: 200, minHeight: 200, }, @@ -189,15 +198,58 @@ export function collectPlatformDomAssets( } } } + if (platform === '1688') add1688GalleryVideos(root, pageUrl, assets); return assets; } +/** 只读取商品画廊内播放器的公开直链,不扫描全页视频或根据封面猜测地址。 */ +function add1688GalleryVideos(root: Document, pageUrl: string, assets: Asset[]) { + const selectors = [ + '[data-module="od_picture_gallery"] video', + '.module-od-picture-gallery video', + '.od-picture-gallery video', + '.od-picture-gallery-list video', + '.detail-gallery-turn video', + '.od-gallery-turn-item-wrapper video', + ]; + const seen = new Set(); + for (const selector of selectors) { + for (const video of Array.from(root.querySelectorAll(selector))) { + const candidates = [video.currentSrc, video.getAttribute('src'), + ...Array.from(video.querySelectorAll('source')).map((source) => source.getAttribute('src'))]; + const url = candidates.map((candidate) => normaliseVideoUrl(candidate ?? '', pageUrl)).find(Boolean); + if (!url || seen.has(url) || assets.length >= 500) continue; + seen.add(url); + const cover = root.querySelector('.od-picture-gallery-list > .video-image-cover'); + const poster = video.poster || video.getAttribute('poster'); + const posterUrl = (poster ? normaliseImageUrl(poster, pageUrl, '1688') : undefined) ?? + (cover ? imageUrlFromElement(cover, ['backgroundImage'], pageUrl, '1688') : undefined); + assets.push({ + url, mediaType: 'video', role: 'source_main', relation: 'product_candidate', + ...(posterUrl?.startsWith('https://') ? { posterUrl } : {}), + sortOrder: assets.length, evidenceRef: '1688:dom:source_video', + }); + } + } +} + +export function normaliseVideoUrl(raw: string, pageUrl: string): string | undefined { + if (!raw.trim()) return undefined; + try { + const url = new URL(raw, pageUrl); + return url.protocol === 'https:' && /\.(mp4|webm)$/i.test(url.pathname) ? url.href : undefined; + } catch { + return undefined; + } +} + /** Restores the original CDN URL before payload storage and de-duplication. */ export function normaliseImageUrl( raw: string, pageUrl: string, platform: SupportedMediaPlatform, ): string | undefined { + if (!raw.trim() || /^(?:undefined|null|none)$/i.test(raw.trim())) return undefined; let url: URL; try { url = new URL(raw, pageUrl); @@ -266,9 +318,10 @@ function imageUrlFromElement( else if (prop === 'currentSrc') raw = (target as HTMLImageElement).currentSrc ?? ''; else if (prop === 'backgroundImage') { + // 优先使用内联地址,避免占位值 undefined 被计算样式解析成一个页面相对 URL。 const style = - target.ownerDocument.defaultView?.getComputedStyle(target) - .backgroundImage ?? target.style.backgroundImage; + target.style.backgroundImage || + target.ownerDocument.defaultView?.getComputedStyle(target).backgroundImage || ''; raw = style.match(/url\(["']?(.*?)["']?\)/i)?.[1] ?? ''; // Some 1688 SKU selectors directly match an despite the // group being background-image-first. diff --git a/apps/extension/lib/request-errors.ts b/apps/extension/lib/request-errors.ts new file mode 100644 index 0000000..facea14 --- /dev/null +++ b/apps/extension/lib/request-errors.ts @@ -0,0 +1,40 @@ +type FieldIssue = { field: string; message: string }; + +function describeIssues(message: string, issues: FieldIssue[]) { + const details = issues.slice(0, 3).map((issue) => `${issue.field}:${issue.message}`); + return details.length ? `${message}(${details.join(';')})` : message; +} + +/** 只展示服务端错误信封的业务说明,避免把请求或授权信息带入错误提示。 */ +export function requestError(body: unknown, status: number) { + const error = body && typeof body === 'object' && 'error' in body + ? body.error + : null; + const info = error && typeof error === 'object' + ? error as { message?: unknown; details?: unknown } + : null; + const message = typeof info?.message === 'string' + ? info.message + : `请求失败(HTTP ${status})`; + const issues = Array.isArray(info?.details) + ? info.details.filter((item): item is FieldIssue => + !!item && typeof item === 'object' && + typeof item.field === 'string' && typeof item.message === 'string') + : []; + return Object.assign(new Error(describeIssues(message, issues)), { status }); +} + +/** Chrome 消息只返回可序列化错误,交给内容脚本显示,避免后台未处理异常。 */ +export function backgroundError(error: unknown) { + return { error: { message: error instanceof Error ? error.message : '操作失败,请重试' } }; +} + +export function unwrapBackgroundResponse(response: unknown): T { + if (!response || typeof response !== 'object') + throw new Error('插件后台未响应,请重载扩展并刷新商品页'); + if ('error' in response && response.error && typeof response.error === 'object') { + const error = response.error as { message?: unknown }; + throw new Error(typeof error.message === 'string' ? error.message : '操作失败,请重试'); + } + return response as T; +} diff --git a/apps/extension/lib/source-parsers.ts b/apps/extension/lib/source-parsers.ts index ca3de34..58a28a6 100644 --- a/apps/extension/lib/source-parsers.ts +++ b/apps/extension/lib/source-parsers.ts @@ -4,7 +4,7 @@ import { isOzonProductPage, type OzonPageJsonFetcher, } from './ozon-parser.js'; -import { collectPlatformDomAssets, normaliseImageUrl } from './product-media.js'; +import { collectPlatformDomAssets, normaliseImageUrl, normaliseVideoUrl } from './product-media.js'; import { formatCollectedDescription } from './description-format.js'; type SourcePlatform = '1688' | 'taobao' | 'tmall' | 'ozon'; @@ -49,11 +49,12 @@ export async function collectCurrentProduct( function collect1688(root: Document, pageUrl: string, bridgedState?: unknown): CollectedProductPayload { const state = [bridgedState, ...pageState(root)]; - const skuInfoMap = findFirst(state, 'skuInfoMap'); + const skuModel = findFirst(state, 'skuModel'); + const skuInfoMap = isRecord(skuModel) ? skuModel.skuInfoMap : findFirst(state, 'skuInfoMap'); const skuMap = isRecord(skuInfoMap) && Object.keys(skuInfoMap).length ? skuInfoMap : findFirst(state, 'skuMapOriginal'); - const skuProps = findFirst(state, 'skuProps'); + const skuProps = isRecord(skuModel) ? skuModel.skuProps : findFirst(state, 'skuProps'); const axes = parse1688Axes(skuProps); const evidence: CollectedProductPayload['evidence'] = [ { @@ -78,9 +79,14 @@ function collect1688(root: Document, pageUrl: string, bridgedState?: unknown): C }); for (const [combination, row] of skuRows) { if (!isRecord(row)) continue; - const sourceSku = stringAt(row, ['skuId', 'sku_id', 'skuCode']); + const sku = stringAt(row, ['skuId', 'sku_id', 'skuCode'])?.trim(); + const sourceSku = sku && !/^(0|null|undefined)$/.test(sku) ? sku : undefined; const specAttrs = stringAt(row, ['specAttrs', 'propPath']) ?? combination; const selections = selectionsFor1688Combination(specAttrs, axes); + // 元数据对象不是 SKU;无来源 ID 时须同时具备完整规格匹配和可售记录证据。 + const hasSaleEvidence = moneyAt(row, ['price']) !== undefined || + (typeof row.canBookCount === 'number' && Number.isFinite(row.canBookCount) && row.canBookCount >= 0); + if (!sourceSku && !(axes.length > 0 && selections.length === axes.length && hasSaleEvidence)) continue; // 来源记录本身证明可售组合,不能用规格轴的笛卡尔积扩充 SKU。 const sourceVariantKey = sourceSku ?? `1688:${combination}`; if (!combination || combination === 'undefined') continue; @@ -239,7 +245,7 @@ function basePayload( ref: `${platform}:dom:image-profile`, kind: 'dom', path: 'platform-scoped gallery / sku / detail selectors', - raw_value: `采集 ${assets.length} 张受限商品区域图片`, + raw_value: `采集 ${assets.filter((asset) => asset.mediaType === 'image').length} 张受限商品区域图片、${assets.filter((asset) => asset.mediaType === 'video').length} 个画廊视频`, }); const videoCount = addMarketplaceVideos(platform, states, assets, canonical); if (videoCount) evidence.push({ @@ -280,8 +286,7 @@ function basePayload( }; } -/** The old collector's proven video sources are structured product state. Do - * not broaden this to page video elements: they also contain feeds and reviews. */ +/** 结构化画廊数据补充公开视频地址,与受限画廊 DOM 去重,不读取推荐或评价视频。 */ function addMarketplaceVideos( platform: Exclude, states: unknown[], @@ -306,16 +311,21 @@ function addMarketplaceVideos( for (const entry of raw) { if (assets.length >= 500) break; const record = isRecord(entry) ? entry : undefined; - const url = directVideoUrl( - typeof entry === 'string' ? entry : stringAt(record ?? {}, ['url', 'videoUrl', 'src']), - pageUrl, - ); - if (!url || seen.has(`video:${url}`)) continue; - seen.add(`video:${url}`); + const candidates = typeof entry === 'string' ? [entry] + : ['url', 'videoUrl', 'videoURL', 'videoAddress', 'address', 'src', 'playUrl'] + .map((key) => stringAt(record ?? {}, [key])); + const url = candidates.map((candidate) => directVideoUrl(candidate, pageUrl)).find(Boolean); + if (!url) continue; const posterUrl = imageUrl( stringAt(record ?? {}, ['videoThumbnailURL', 'thumbnailUrl', 'coverUrl', 'coverImage', 'poster']), pageUrl, ); + if (seen.has(`video:${url}`)) { + const existing = assets.find((asset) => asset.mediaType === 'video' && asset.url === url); + if (existing && !existing.posterUrl && posterUrl) existing.posterUrl = posterUrl; + continue; + } + seen.add(`video:${url}`); assets.push({ url, mediaType: 'video', @@ -331,14 +341,7 @@ function addMarketplaceVideos( } function directVideoUrl(value: string | undefined, pageUrl: string) { - if (!value) return undefined; - try { - const url = new URL(value, pageUrl); - if (url.protocol !== 'https:' || /\.m3u8(?:$|[?#])/i.test(url.pathname)) return undefined; - return /\.(mp4|webm)(?:$|[?#])/i.test(url.pathname) ? url.href : undefined; - } catch { - return undefined; - } + return value ? normaliseVideoUrl(value, pageUrl) : undefined; } function imageUrl(value: string | undefined, pageUrl: string) { @@ -431,6 +434,8 @@ function pageState(root: Document) { } } for (const marker of [ + 'gallery', + 'skuModel', 'skuInfoMap', 'skuMapOriginal', 'skuBase', @@ -444,11 +449,16 @@ function pageState(root: Document) { return all; } function objectAfterMarker(text: string, marker: string): unknown | null { - const position = text.indexOf(marker); - if (position < 0) return null; - const start = text.slice(position).search(/[\[{]/); - if (start < 0) return null; - const first = position + start; + // 只读取对象属性的直接字面值,避免从字段名字符串或 null 跳到后续无关对象。 + const property = new RegExp(`(?:^|[,{])\\s*(?:"${marker}"|'${marker}'|${marker})\\s*:\\s*(?=[\\[{])`, 'g'); + for (const match of text.matchAll(property)) { + const first = match.index! + match[0].length; + const parsed = objectLiteralAt(text, first); + if (parsed !== null) return parsed; + } + return null; +} +function objectLiteralAt(text: string, first: number): unknown | null { const open = text[first]; const close = open === '{' ? '}' : ']'; let depth = 0; diff --git a/apps/extension/test/1688-gallery.test.mts b/apps/extension/test/1688-gallery.test.mts new file mode 100644 index 0000000..8cdab33 --- /dev/null +++ b/apps/extension/test/1688-gallery.test.mts @@ -0,0 +1,65 @@ +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import { createRequire } from 'node:module'; +import test from 'node:test'; +import { collectedProductPayloadSchema } from '@atlas/contracts'; +import { collectPlatformDomAssets, normaliseImageUrl } from '../lib/product-media.ts'; +import { collectCurrentProduct } from '../lib/source-parsers.ts'; + +// 使用 WXT 已有的 HTML 解析依赖构建离线 DOM,不加载样本里的远程媒体。 +const require = createRequire(import.meta.url); +const wxtRequire = createRequire(require.resolve('wxt')); +const { parseHTML } = wxtRequire('linkedom'); +const pageUrl = 'https://detail.1688.com/offer/1012823100668.html'; +const videoUrl = 'https://cloud.video.taobao.com/qa-product.mp4'; +const poster = 'https://cbu01.alicdn.com/img/ibank/O1CN01lCwIgP1UjKGylKwAN_!!2563422553-0-cib.jpg'; + +async function fixture(extra = ''): Promise { + const html = await readFile(new URL('./fixtures/1688-gallery-background.html', import.meta.url), 'utf8'); + const { document, window } = parseHTML(`${html}${extra}`); + window.getComputedStyle = (element: HTMLElement) => ({ backgroundImage: element.style.backgroundImage }); + Object.defineProperty(document, 'scripts', { get: () => document.querySelectorAll('script') }); + for (const element of document.querySelectorAll('.v-image-cover')) { + Object.defineProperty(element, 'offsetWidth', { value: 60 }); + Object.defineProperty(element, 'offsetHeight', { value: 60 }); + } + return document; +} + +test('用户画廊样本的 15 张背景缩略图完整采集、还原原图,排除参数与视频控件', async () => { + const assets = collectPlatformDomAssets('1688', await fixture(), pageUrl); + assert.equal(assets.length, 15); + assert.ok(assets.every((asset) => asset.mediaType === 'image' && asset.role === 'source_main')); + assert.equal(assets[0].url, poster); + assert.ok(assets.every((asset) => asset.url.endsWith('.jpg') && !asset.url.includes('_b.jpg'))); + assert.ok(assets.every((asset) => !/undefined|\.svg/.test(asset.url))); + assert.equal(normaliseImageUrl('undefined', pageUrl, '1688'), undefined); +}); + +test('画廊播放器可采集真实视频及封面,忽略推荐视频、blob 和 HLS', async () => { + const root = await fixture(``); + const assets = collectPlatformDomAssets('1688', root, pageUrl); + const videos = assets.filter((asset) => asset.mediaType === 'video'); + assert.equal(videos.length, 1); + assert.equal(videos[0].url, videoUrl); + assert.equal(videos[0].posterUrl, poster); + assert.equal(assets.filter((asset) => asset.mediaType === 'image').length, 15); +}); + +test('脚本画廊数据与 DOM 视频去重,完整采集载荷符合 API 契约', async () => { + const gallery = { fields: { video: { url: videoUrl, videoThumbnailURL: poster } } }; + const root = await fixture(``); + const payload = await collectCurrentProduct(root, pageUrl); + assert.equal(payload.assets.filter((asset) => asset.mediaType === 'video').length, 1); + assert.equal(payload.assets.filter((asset) => asset.mediaType === 'image').length, 15); + assert.equal(collectedProductPayloadSchema.safeParse(payload).success, true); +}); + +test('尚未挂载播放器时仍可从明确的 gallery 字面值读取视频直链', async () => { + const gallery = { fields: { video: { url: '', videoAddress: videoUrl, videoThumbnailURL: poster } } }; + const payload = await collectCurrentProduct(await fixture(``), pageUrl); + const videos = payload.assets.filter((asset) => asset.mediaType === 'video'); + assert.equal(videos.length, 1); + assert.equal(videos[0].url, videoUrl); + assert.equal(videos[0].posterUrl, poster); +}); diff --git a/apps/extension/test/1688-sku.test.mts b/apps/extension/test/1688-sku.test.mts index b5f8f04..1f2d5b1 100644 --- a/apps/extension/test/1688-sku.test.mts +++ b/apps/extension/test/1688-sku.test.mts @@ -2,6 +2,7 @@ import assert from 'node:assert/strict'; import { readFile } from 'node:fs/promises'; import test from 'node:test'; import { collectCurrentProduct } from '../lib/source-parsers.ts'; +import { collectedProductPayloadSchema } from '@atlas/contracts'; function fixtureSkuModel(html: string) { const marker = '"skuModel": '; @@ -39,6 +40,7 @@ test('1688 真实样本按 skuInfoMap 的 price 和规格图片生成逐 SKU 数 async () => ({ skuModel }), ); assert.equal(payload.variants.length, Object.keys(skuModel.skuInfoMap).length); + assert.equal(collectedProductPayloadSchema.safeParse(payload).success, true); for (const [name, row] of Object.entries(skuModel.skuInfoMap)) { const variant = payload.variants.find((item) => item.sourceSku === String(row.skuId)); assert.ok(variant, `缺少 ${name}`); @@ -60,3 +62,55 @@ test('1688 真实样本按 skuInfoMap 的 price 和规格图片生成逐 SKU 数 assert.equal(withoutBridge.variants.length, payload.variants.length); assert.equal(withoutBridge.assets.filter((asset) => asset.variantSourceKey).length, payload.variants.length); }); + +test('1688 超过 100 条真实可售记录完整解析,不按旧上限裁剪', async () => { + const root = { scripts: [], querySelector: () => null, querySelectorAll: () => [] } as unknown as Document; + const skuInfoMap = Object.fromEntries(Array.from({ length: 200 }, (_, index) => [ + `组合 ${index}`, { skuId: String(8_000_000_000_000 + index), price: '20.00' }, + ])); + const payload = await collectCurrentProduct(root, 'https://detail.1688.com/offer/123456789.html', undefined, + async () => ({ skuModel: { skuInfoMap } })); + assert.equal(payload.variants.length, 200); + assert.equal(payload.variants.at(-1)?.sourceSku, String(8_000_000_000_199)); + assert.equal(collectedProductPayloadSchema.safeParse(payload).success, true); +}); + +test('字段名字符串与后续 150 个无关对象不会被误采为 SKU', async () => { + const unrelated = Object.fromEntries(Array.from({ length: 150 }, (_, index) => [ + `metadata${index}`, { label: '推荐数据' }, + ])); + const model = { + skuInfoMap: { 白色: { skuId: '1001', price: '20.00' }, 蓝色: { skuId: '1002', price: '20.00' } }, + skuProps: [{ prop: 'color', name: '颜色', value: [{ valueId: 'white', name: '白色' }, { valueId: 'blue', name: '蓝色' }] }], + }; + const script = `const key="skuInfoMap";const unrelated=${JSON.stringify(unrelated)};window.context=${JSON.stringify({ skuModel: model })};`; + const root = { scripts: [{ type: 'text/javascript', textContent: script }], querySelector: () => null, querySelectorAll: () => [] } as unknown as Document; + const payload = await collectCurrentProduct(root, 'https://detail.1688.com/offer/123456789.html'); + assert.deepEqual(payload.variants.map((variant) => variant.sourceSku), ['1001', '1002']); + assert.deepEqual(payload.variants.map((variant) => variant.displayName), ['白色', '蓝色']); +}); + +test('skuInfoMap 为 null 时不跳读后续对象,仍读取明确的原始可售表', async () => { + const script = `window.context={"skuInfoMap":null,"unrelated":{"bad":{"title":"不是 SKU"}},"skuMapOriginal":[{"skuId":"1001","specAttrs":"白色","price":"20.00"}]};`; + const root = { scripts: [{ type: 'text/javascript', textContent: script }], querySelector: () => null, querySelectorAll: () => [] } as unknown as Document; + const payload = await collectCurrentProduct(root, 'https://detail.1688.com/offer/123456789.html'); + assert.equal(payload.variants.length, 1); + assert.equal(payload.variants[0].sourceSku, '1001'); +}); + +test('真实规格表内的元数据不生成 SKU,无 ID 的完整可售组合仍保留', async () => { + const root = { scripts: [], querySelector: () => null, querySelectorAll: () => [] } as unknown as Document; + const model = { + skuInfoMap: { + metadata: { label: '元数据' }, + 未匹配颜色: { price: '30.00' }, + 白色: { price: '20.00', canBookCount: 0 }, + }, + skuProps: [{ prop: 'color', name: '颜色', value: [{ valueId: 'white', name: '白色' }] }], + }; + const payload = await collectCurrentProduct(root, 'https://detail.1688.com/offer/123456789.html', undefined, + async () => ({ skuModel: model })); + assert.equal(payload.variants.length, 1); + assert.equal(payload.variants[0].sourceVariantKey, '1688:白色'); + assert.equal(payload.variants[0].selections[0].valueKey, 'white'); +}); diff --git a/apps/extension/test/fixtures/1688-gallery-background.html b/apps/extension/test/fixtures/1688-gallery-background.html new file mode 100644 index 0000000..53fee7b --- /dev/null +++ b/apps/extension/test/fixtures/1688-gallery-background.html @@ -0,0 +1,21 @@ + diff --git a/apps/extension/test/request-errors.test.mts b/apps/extension/test/request-errors.test.mts new file mode 100644 index 0000000..a6f788c --- /dev/null +++ b/apps/extension/test/request-errors.test.mts @@ -0,0 +1,56 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + backgroundError, + requestError, + unwrapBackgroundResponse, +} from '../lib/request-errors.ts'; +import { validateCollectedPayload } from '../lib/collection-payload-validation.ts'; +import { PRODUCT_VARIANT_LIMIT, productPublishPreflightRequestSchema } from '@atlas/contracts'; + +const payload = { + source: { + platform: '1688', itemKey: '123456789', + canonicalUrl: 'https://detail.1688.com/offer/123456789.html', + collectedAt: '2026-10-05T10:00:00.000Z', parserVersion: 'test', + }, + common: { attributes: [], categories: [] }, + variantAxes: [], variants: [], assets: [], evidence: [], +}; + +test('采集支持超过 100 个 SKU,上限与草稿一致,超限时不静默截断', () => { + const variants = Array.from({ length: PRODUCT_VARIANT_LIMIT + 1 }, (_, index) => ({ + sourceVariantKey: String(index), displayName: `SKU ${index}`, + selections: [], confidence: 'verified', evidenceRefs: [], + })); + assert.throws(() => validateCollectedPayload({ ...payload, variants }), /采集数据不符合要求.*variants/); + assert.equal(variants.length, PRODUCT_VARIANT_LIMIT + 1); + assert.equal(validateCollectedPayload({ ...payload, variants: variants.slice(0, 101) }).variants.length, 101); + assert.equal(validateCollectedPayload({ ...payload, variants: variants.slice(0, PRODUCT_VARIANT_LIMIT) }).variants.length, PRODUCT_VARIANT_LIMIT); + assert.equal(validateCollectedPayload(payload).source.itemKey, '123456789'); +}); + +test('提高采集容量不放宽每批 50 个 SKU 的首次上架约束', () => { + const ids = Array.from({ length: 51 }, () => crypto.randomUUID()); + assert.equal(productPublishPreflightRequestSchema.safeParse({ variant_ids: ids.slice(0, 50) }).success, true); + assert.equal(productPublishPreflightRequestSchema.safeParse({ variant_ids: ids }).success, false); +}); + +test('采集字段路径错误可经 Chrome 可序列化响应返回浮窗', () => { + const error = requestError({ error: { + message: '请求参数不符合要求', + details: [{ field: 'variantAxes.0.values.0.name', message: 'Too small: expected string to have >=1 characters' }], + } }, 422); + assert.equal(error.status, 422); + const response = JSON.parse(JSON.stringify(backgroundError(error))); + assert.throws(() => unwrapBackgroundResponse(response), /variantAxes\.0\.values\.0\.name/); + assert.throws(() => unwrapBackgroundResponse(response), /请求参数不符合要求/); +}); + +test('通用 HTTP 错误及空后台响应仍可操作,成功响应保持原有结构', () => { + assert.equal(requestError(null, 503).message, '请求失败(HTTP 503)'); + assert.equal(requestError({ error: { message: '登录过期', details: [null, { token: '不应展示' }] } }, 401).message, '登录过期'); + assert.throws(() => unwrapBackgroundResponse(undefined), /请重载扩展并刷新商品页/); + const success = { result: { data: { editor_entry_path: '/products/test/editor' } } }; + assert.strictEqual(unwrapBackgroundResponse(success), success); +}); diff --git a/apps/extension/wxt.config.ts b/apps/extension/wxt.config.ts index 18d345e..4b5d133 100644 --- a/apps/extension/wxt.config.ts +++ b/apps/extension/wxt.config.ts @@ -1,9 +1,18 @@ import { defineConfig } from 'wxt'; +import { fileURLToPath } from 'node:url'; const atlasWebOrigin = new URL(process.env.ATLAS_WEB_ORIGIN ?? 'http://localhost:8100').origin; export default defineConfig({ modules: ['@wxt-dev/module-react'], + vite: () => ({ + resolve: { + // 开发和正式构建均读取共享源码,防止开发包持续缓存旧的校验上限。 + alias: { + '@atlas/contracts': fileURLToPath(new URL('../../packages/contracts/src/index.ts', import.meta.url)), + }, + }, + }), // 扩展 dev server 固定 8110:默认取 3000–3010 第一个空闲端口,会漂移且易与常见服务冲突 dev: { server: { diff --git a/apps/web/HANDOFF.md b/apps/web/HANDOFF.md index 3e9abb3..bf81dc2 100644 --- a/apps/web/HANDOFF.md +++ b/apps/web/HANDOFF.md @@ -1,6 +1,60 @@ # Web 前端交接:商品编辑器与 AI 填报 -更新日期:2026-10-04 +更新日期:2026-10-06 + +## 2026-10-06 SKU 文案用户要求与弹窗重设计 + +SKU 文案弹窗以「用户描述 / 生成要求」为主要输入(1000 字符,计数、生成时禁用),标明优先采用;当前 SKU 名称独立展示,采集标题/描述/规格移入默认折叠的辅助参考,右侧按标题/标签/描述展示俄文与中文对照,窄屏单列且内容可滚动。用户要求通过既有 `hint` 提交,按 SKU 暂存于页面内;不写采集信息或商品草稿。要求修改会清空旧预览并禁用应用。生成/重新生成/应用状态明确,仍需用户应用并保存。 + +Web 请求方法接受共享 `RequestProductAiTask`,复用共享 1000 字符常量;API 字段和校验规则不变。Worker 将用户要求作为独立高优先级输入,明确用户补充或修正优先于采集资料、旧 SKU 和既有文案,辅助 SKU 摘要仅包含当前 SKU,输出格式/俄文/标签约束继续生效。前端通过请求序号隔离关闭或切换 SKU 后完成的旧任务,并核对结果 SKU 与采集/产品/profile 版本。应用当前标题需切换逐 SKU 模式时,保留其他 SKU 原先继承的标题和中文对照,修复旧行为清空其他 SKU 标题的问题。 + +Contracts/API(含测试)/Worker/Web 类型检查及构建通过;API 单元测试 2 项、Worker 文案测试 12 项通过,均模拟数据库或供应商,不创建真实任务或收费请求。ego-browser TaskSpace 9 的真实 ProductEditPage + 模拟 API 验证 hint 请求、修改要求使旧结果失效、按 SKU 保留要求、关闭/切换后旧结果不回填、加载禁用输入、当前 SKU 应用/模拟保存、中俄对照、其他 SKU 标题保留、空要求兼容、1000 字符边界及 1280/390px 布局。弹窗在 390px 下宽度/scrollWidth 均 374px;底层编辑页有原有 478px 横向宽度,本轮未扩展修改该布局。临时页面与浏览器已清理,未读写真实商品、调用 AI 供应商或 Ozon,未手动启停维护者服务。Web 原有大包体警告保留。 + +## 2026-10-06 SKU 货号以型号为自动前缀 + +本轮仅修改 Web 商品编辑器、SKU 信息区及局部样式,新增 `skuOfferId.ts` 与 `SkuOfferIdInput.tsx`,并同步 API/AI 方案与决策说明;HTTP 字段、服务端、数据库、Worker 和扩展无改动。货号控件占整行,型号加连接符为只读前缀,用户只输入 SKU 后缀,表单与保存请求继续持有完整 `offer_id`。手工修改型号同步全部未导入 SKU,包括未挂载的收起项;使用完整表单值,避免丢失采购价、选图等资料。清空型号保留后缀,清空后缀保持空货号。已导入/进行中货号只读并保留历史事实,已有型号锁定规则继续生效。 + +加载时匹配当前型号的旧货号拆出后缀;未匹配的旧值完整保留为后缀,补前缀只进入待保存草稿并标记未保存,不做后台批量写入。AI 结果的生成型号前缀先拆出,再按表单型号组合,用户非空后缀不被覆盖。完整货号限制 50 字符;输入长度扣除前缀占用,型号变长不截断已有后缀,超限保存给予明确提示。 + +Web 类型检查与生产构建通过,原有大包体警告保留。ego-browser TaskSpace 8 的隔离真实 SkuInfoPanel/Form 验证前缀及后缀编辑、反复修改/清空型号、收起字段联动、旧值完整保留、空后缀、采购价/图片保留、已上架只读、50 字符输入上限和 390/1280px 布局;长型号导致整串 57 字符时,38 字符旧后缀完整保留。临时 QA 页面和浏览器已清理,没有读取/保存真实商品或触发发布;未手动启停维护者现有服务。 + +## 2026-10-06 采集与编辑保存支持超过 100 个 SKU + +用户补充的明确字段错误为 `variants: Too big: expected array to have <=100 items`,关闭上一轮待定位事项:根因是插件与 API 共享的采集数组上限为 100,且编辑完整草稿也有独立的 100 项上限,真实 1688 组合超过此值即失败。当前集成会话修改共享契约、API 采集响应统计、扩展/API 回归与 API/交接文档。新增 `PRODUCT_VARIANT_LIMIT=1000`,采集和商品完整保存统一使用;超限中文提示,不静默截断 SKU。采集响应的 `variant_count` 改为实际未删除 SKU 数。首次发布每批 50 个、媒体数量、其他字段约束、工作空间采集商品配额及 Ozon 公开页有界补全规则均保持原边界,无数据库迁移或 Worker/Web 业务逻辑修改。 + +扩展回归 15 项通过,验证 1688 200 条明确可售记录完整解析、101/1000 条接受、1001 条拒绝和上架 50/51 条边界。API 采集/商品编辑 16 项通过,新增完整 1000 SKU 的采集入库、编辑初始化、读取末尾 SKU、完整草稿保存、复采保留 ID 与人工修改、超限请求不改变来源版本或 SKU 数;测试数据隔离,无真实采集或 Ozon 发布。Contracts/API(含测试)/Web/Worker/Extension 类型检查和构建通过;Web 原有包体警告保留,差异检查通过。维护者现有 API 开发进程随源码自动重载,未手动启停服务;插件静态产物已重建,需重载扩展并刷新来源页后再次采集。 + +## 2026-10-05 采集插件编辑上架进入商品编辑器 + +修改扩展 `collector.content.ts`、API `extension.service.ts`、Web 编辑页及 `productEditor` 服务和 API/交接文档。根因是采集响应仍提供测试页路径,且新商品尚未初始化平台编辑配置。采集成功响应改为 `/products/{product_id}/editor?collection_item_id={collection_item_id}`,扩展继续使用响应入口并提示「已打开商品编辑页」。Web 携带采集 ID 调用现有 `editor-contexts`,核对商品 ID 后读取快照;当前页头平台和默认店铺规则沿用既有契约。重复采集进入原商品,既有上下文保留资料和上架状态;「采集商品」仍不打开编辑页,没有自动上架、媒体下载或平台绑定写入采集事务。测试页和旧手工商品入口保留,无新增 HTTP 字段或数据库迁移。 + +API 采集/商品编辑集成回归 15 项通过,覆盖新入口实际商品 ID、幂等重放、复采路径稳定以及首次上下文初始化。API 源码/测试类型检查与构建、Web 类型检查/构建、扩展 WXT prepare/类型检查/Chrome MV3 构建通过;Web 原有包体大小警告保留。ego-browser 隔离真实前端服务验证首次入口先初始化后读快照,普通既有入口直接读取,商品不匹配及无默认店铺阻断读取,并沿用当前平台。临时页面和任务浏览器已清理,没有采集/保存真实商品或调用 Ozon,未手动启停维护者服务。扩展新产物位于 `apps/extension/.output/chrome-mv3`,已加载静态扩展的浏览器需重载扩展并刷新来源页以更新成功提示;既有插件已使用响应入口,API 更新即可更改下一次跳转地址。 + +## 2026-10-05 已上架 SKU 本地价格重新试算 + +本轮跨 Web `SkuPricing`、定价 API、商品编辑读模型、共享契约与接口/交接文档。已上架 SKU 开放「重新试算」和「试算详情」,应用价格前确认:「该 SKU 已上架。本次价格调整仅保存为本地记录,不会同步至 Ozon;如需修改线上价格,请前往 Ozon 后台。」取消不修改价格,确认后更新草稿,仍须保存商品。批量计算和自动试算继续跳过已导入项。 + +编辑快照新增 `variants[].listing.store_id`。已上架 SKU 固定读取首次导入店铺的定价目录和币种,即使 profile 的将来目标店铺已变更。API 允许此 SKU 生成预览,跨店铺试算返回 `PRICING_VARIANT_STORE_MISMATCH`;显式 `pricing_selection` 按已有有效期、输入、规则和幂等边界确认新本地报价,普通草稿保存保留原报价。保持首次导入状态、店铺、货号、平台商品 ID 和导入时间,不创建发布或价格同步任务,无数据库迁移、Worker 或扩展业务改动。 + +定价保存 13 项、首次发布 11 项、商品编辑 13 项和采集 2 项集成测试通过,覆盖已上架新报价、错误店铺拦截、失效报价原子回滚、幂等重放、普通保存保留报价及零平台任务。Contracts/API/Web/Worker 类型检查与构建通过;Web 存在原有大包体警告。ego-browser 隔离真实组件验证两个入口均可用,取消不改变草稿,确认后实际售价 120 / 划线价 240,始终使用首次导入店铺,批量不自动改价。临时页面与代理浏览器已清理;未保存真实商品或操作 Ozon,没有手动启停维护者服务。 + +## 2026-10-05 采集页面地址回填货源链接 + +当前集成会话修改 API `extension.service.ts` 新采集商品写入、`product-editor.service.ts`/models 编辑读模型、extension HTTP 集成回归以及 API/交接文档。插件既有解析器已上报 `source.canonicalUrl`,问题是地址只保存于采集记录,未用于商品货源字段。新建商品同时初始化 `purchase_url`;编辑读模型优先使用手工非空链接,空值回退关联采集记录 `source_url`,历史商品无需复采,手动无来源商品仍为空。清空表示恢复来源地址,复采不覆盖手工非空链接。不修改插件、HTTP 字段或数据库,无历史草稿批量写入。 + +采集授权/素材 HTTP 2 项与商品编辑 13 项通过,覆盖新 1688 地址初始化、历史空字段回填、Ozon 地址、复采保留手工链接和既有商品编辑边界;API 源码/测试类型检查与构建、Web 类型检查/构建及差异检查通过。在用户当前商品的独立浏览器验证页确认 1688 来源地址已显示、打开按钮启用,输入框与按钮均 35px 且对齐。原用户页面未重载或保存,避免丢失编辑;API 开发进程自动重载,无手动启停。未向真实平台发布,未重新采集或写入真实商品。 + +## 2026-10-05 货源链接按钮与输入框等高 + +仅修改 Web `ProductOtherSection` 和编辑页局部样式,货源链接 Compact 组增加专用类,按钮按输入框所在 flex 行伸展高度,避免 Input 的 35px 主题高度与 Button 的默认 32px 不一致;添加按钮可访问名称。其他按钮样式和打开链接行为不变,无接口变更。 + +Web 类型检查、构建及差异检查通过;ego-browser 隔离真实组件在 620/390/1280px 宽度检查启用与禁用按钮,均与输入框同为 35px 且顶部对齐,打开回调正常。临时页面已清理,未修改真实商品,未启停原有服务。 + +## 2026-10-05 部分上架按钮显示进行中状态 + +本轮仅修改 Web `ProductPublishActions.tsx` 与交接文档,复用现有任务读取、提交和轮询接口,无 API、Worker、数据库或契约变更。「部分上架」按钮在任务未结束时显示排队中、提交中、等待 Ozon、等待对账或处理中,配合时钟/加载/提醒图标;running 任务按 SKU 状态细分阶段。点击进行中按钮直接打开任务详情,保持可查看,避免同一入口再次创建任务;任务成功、部分成功或失败后恢复正常部分上架选择入口。页面重载沿用现有最近任务恢复逻辑。 + +Web 类型检查、生产构建与差异检查通过。ego-browser 隔离真实组件验证页面恢复排队任务、轮询切换提交/等待平台/对账、进行中点击详情不保存或创建任务、失败与成功后恢复入口,以及新任务提交后立即显示排队状态;模拟提交仅一次。临时页面已清理,未向真实 Ozon 发布,未保存真实商品,未手动启停维护者服务。 ## 2026-10-04 SKU 主动重算使用最新默认方案 @@ -140,3 +194,36 @@ git diff --check - 当前任务可按仓库 `AGENTS.md` 修改相关项目代码;跨领域改动需遵守 [`../../docs/project/development-collaboration-contract.md`](../../docs/project/development-collaboration-contract.md) 并说明影响范围。 - 验证需要时可在确认目标进程和端口后启动、停止或重启 API/Worker;完成后说明实际操作与状态。上方 2026-09-11 的验证记录不能替代新改动的检查。 - 不在浏览器持久化、日志或页面状态中保存 Ozon API Key。 + + +## 2026-10-06 SKU 上传/网络图片按商品隔离 + +`useSkuImageLibrary` 必须接收 productId,两个候选 GET 携带 product_id,网络导入和商品上传意图绑定当前商品。变体图片弹窗与套图参考图均已接入;水印协议不变。同商品 SKU 可共用候选,切换商品立即隐藏旧候选并重置指纹和计数,延迟请求使用商品世代隔离。直接上传到 SKU 使用编辑会话序号隔离,关闭/切换后不追加旧结果。历史无归属候选不再全局展示,已有 SKU 选图保留。需先应用数据库迁移 060,API 与 Web 一起更新。浏览器真实 Hook 回归和 Web 类型检查/构建通过;临时 QA 文件与 TaskSpace 10 已清理。 + + +## 2026-10-06 SKU 名称与货号恢复同行 + +修复型号前缀控件引入时将货号设置为整行的布局回归。Web `SkuInfoPanel` 将名称与货号放入独立两列基础资料行,`ProductVariantSection.css` 调整行布局和长型号前缀截断宽度;即使其余发布字段在窄容器中切为单列,基础资料仍保持同行。型号前缀、后缀编辑、完整货号与锁定规则不变,无 API/共享契约/数据库变更。Web 类型检查、生产构建和差异检查通过;ego-browser TaskSpace 11 验证 1280/620/390px 名称和货号输入同行、不溢出,并确认后缀修改仍组成完整货号。临时 QA 文件和浏览器已清理,未手动启停服务或修改真实商品,保留既有包体警告。 + + +## 2026-10-06 SKU 候选图片永久删除 + +`SkuImageEditorModal` 右侧候选图片的「编辑」菜单最底部新增红色「删除」,二次确认明确同步永久删除七牛文件。左侧「移出已选」仅取消选图。接口确认接受后清理当前商品候选、所有未保存 SKU 选图及本地处理/预览缓存;删除状态轮询成功后才显示存储清理完成,失败可在确认框或弹窗「重试清理」入口恢复,重新打开可读取待处理任务。已保存引用的图片被服务端拒绝,提示先移除引用并保存。 + +依赖共享 `ProductImageDeletion` 和 `mediaApi` 商品删除/列表/读取/重试接口,API/Worker 与迁移 061、062 需同步更新,契约见 `docs/api/watermarks-and-assets.md`。Web 类型检查、生产构建通过;ego-browser TaskSpace 13 实际组件验证菜单、二次确认、取消、引用拒绝、失败提示和重试恢复,临时文件与浏览器已清理。后端另以真实七牛对象验证删除后 HEAD 404;没有删除真实用户图片,未手动启停服务,保留原有包体大小警告。 + +## 2026-10-06 定价试算费用显示精度 + +`PricingCalculator` 的费用明细统一四舍五入显示两位小数(包括零费用),不修改接口原始金额、计算精度或应用到 SKU 的价格数据。本轮仅修改前端显示。Web 类型检查、生产构建和差异检查通过,保留既有包体大小警告;未启停维护者服务。 + + +## 2026-10-06 SKU 颜色多选与已选文本 + +`DynamicAttributeFields` 使用共享 `isOzonProductColorAttribute` / `productAttributeValueRules`,颜色属性 10096 在 schema 单值时仍支持多选,保存和发布也采用同一 100 项限制。控件初始化加载颜色字典,按属性 UUID 保留已选文本,搜索、候选分页替换和草稿回填不再让首项退回编号;加载前使用既有俄文颜色名兜底,未知值提示待加载。字典 ID 继续作为提交值,新类目不沿用旧名称缓存。 + +共享契约和 API 须一起更新;无迁移。Contracts/API/Web 类型检查及构建、25 项后端编辑/发布回归通过。ego-browser TaskSpace 14 验证真实 Form.List 控件的回填、多选、搜索、重新展开和类目隔离;临时文件与浏览器已清理,未修改真实商品或手动启停服务,保留既有包体警告。 + + +## 2026-10-06 产品描述复制保留换行 + +CopyTextButton 直接读取原始文本写入剪贴板,保留内部换行和空行。通用描述、SKU 描述、中俄对照与 SKU AI 描述预览均增加明确复制按钮;SKU 空白覆盖值按有效文案复制 SPU 描述,未复制 HTML。上报由 Worker 仅对简介属性 4191 转成 Ozon 支持的 br;表单、草稿与快照仍是纯文本。已导入商品不会自动更新。Web 类型检查/构建及 ego-browser TaskSpace 16 真实组件模拟剪贴板验证通过;临时文件和浏览器已清理,未修改真实商品或手动启停服务,保留既有包体警告。 diff --git a/apps/web/src/components/CopyTextButton.tsx b/apps/web/src/components/CopyTextButton.tsx new file mode 100644 index 0000000..628acd1 --- /dev/null +++ b/apps/web/src/components/CopyTextButton.tsx @@ -0,0 +1,19 @@ +import { App, Button } from 'antd'; +import { CopyOutlined } from '@ant-design/icons'; + +/** 直接复制表单或预览原文,保留换行和空行,不从页面排版提取文本。 */ +export function CopyTextButton({ getText, label }: { + getText: () => string | null | undefined; + label: string; +}) { + const { message } = App.useApp(); + return - )} - {!imported && ( - - )} + +
@@ -502,7 +525,7 @@ export function SkuPricing({ }} onPressEnter={(event) => { event.preventDefault(); - void calculate(true); + confirmPriceChange(() => calculate(true)); }} /> @@ -562,6 +585,7 @@ export function SkuPricing({ {feedback}
+ {imported && {localPriceNotice}} {policyName} {option ? ` · ${option.transport_name}` : ''} @@ -631,10 +655,12 @@ export function SkuPricing({ previous={result?.option ?? original?.listing.pricing_result} catalog={catalog} onCancel={() => setModal(null)} - onApply={(input, p, id) => { - apply(input, p, id, true); - setModal(null); - }} + onApply={(input, p, id) => + confirmPriceChange(() => { + apply(input, p, id, true); + setModal(null); + }) + } /> )} diff --git a/apps/web/src/pages/products/ProductEditPage.css b/apps/web/src/pages/products/ProductEditPage.css index 0283c9d..286b6d2 100644 --- a/apps/web/src/pages/products/ProductEditPage.css +++ b/apps/web/src/pages/products/ProductEditPage.css @@ -231,6 +231,10 @@ .editor-short-input.ant-input { width: 200px; } +.editor-supply-url .ant-btn { + height: auto; + align-self: stretch; +} /* 「采集信息变化」弹窗内容(页面 modal 渲染) */ .source-diff-list { diff --git a/apps/web/src/pages/products/ProductEditPage.tsx b/apps/web/src/pages/products/ProductEditPage.tsx index e4082ed..c588a86 100644 --- a/apps/web/src/pages/products/ProductEditPage.tsx +++ b/apps/web/src/pages/products/ProductEditPage.tsx @@ -1,5 +1,5 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { useBlocker, useNavigate, useParams } from 'react-router-dom'; +import { useBlocker, useNavigate, useParams, useSearchParams } from 'react-router-dom'; import { Alert, App as AntdApp, Button, Form, Modal, Radio, Space, Spin, Tag, Typography } from 'antd'; import { ArrowLeftOutlined, @@ -41,6 +41,7 @@ import { ProductImageTaskCenter } from './components/ProductImageTaskCenter'; import { ProductImageSuiteDrawer } from './components/ProductImageSuiteDrawer'; import type { SkuEditorImage } from './components/SkuImageCard'; import { displayBrandName, OZON_NO_BRAND_VALUE } from './brandDisplay'; +import { composeSkuOfferId, lockedSkuOfferIds, skuOfferSuffix, syncSkuOfferIds } from './skuOfferId'; import { ProductCopyComparisonModal, SkuCopyAiModal, @@ -455,10 +456,13 @@ function detectedSpecs(attributes: CollectedAttribute[]) { */ const ProductEditPage = () => { const { id: productId } = useParams<{ id: string }>(); + const [searchParams] = useSearchParams(); + const collectionItemId = searchParams.get('collection_item_id'); const { currentPlatform } = usePlatform(); const { message, modal } = AntdApp.useApp(); const navigate = useNavigate(); const [form] = Form.useForm(); + const offerModelRef = useRef(null); // 关键表单值的实时监听:驱动类目/属性数据联动与货源链接打开按钮。 const supplyUrl = Form.useWatch(['common', 'purchase_url'], form); const selectedCategoryId = Form.useWatch(['profile', 'platform_product_category_id'], form); @@ -520,6 +524,9 @@ const ProductEditPage = () => { const [skuCopyLoading, setSkuCopyLoading] = useState(false); const [skuCopyResult, setSkuCopyResult] = useState(null); const [skuCopyError, setSkuCopyError] = useState(null); + const [skuCopyRequirements, setSkuCopyRequirements] = useState>({}); + const skuCopyTaskId = useRef(null); + const skuCopyRequestId = useRef(0); const blocker = useBlocker(dirty); const sourceSpecsApplied = useRef(false); const categories = useMemo(() => categoryTree(categoryOptions), [categoryOptions]); @@ -555,26 +562,31 @@ const ProductEditPage = () => { if (!productId || !currentPlatform) return; setLoading(true); try { - const next = await productEditorApi.read(productId, currentPlatform.id); + const next = await productEditorApi.read(productId, currentPlatform.id, collectionItemId); setSnapshot(next); setCollectionRecord(null); setSourceDraft(next.source_material); setSourceOverriddenFields(next.source_material_state.overridden_fields); sourceSpecsApplied.current = false; - form.setFieldsValue(formValues(next, currentPlatform.code)); + const values = formValues(next, currentPlatform.code); + const variants = syncSkuOfferIds( + values.variants, values.common.model, values.common.model, lockedSkuOfferIds(next) + ); + offerModelRef.current = values.common.model; + form.setFieldsValue({ ...values, variants }); setUsesVariantPackageWeight(next.product.package_weight_mode === 'variant'); setUsesVariantPackageDimensions(next.product.package_dimensions_mode === 'variant'); setUsesVariantTags(next.variants.some((variant) => variant.tags.length > 0)); setUsesVariantDescription(next.variants.some((variant) => !!variant.description)); setUsesVariantTitle(next.profile.title_mode === 'variant'); setCopyTranslations(next.copy_translations); - setDirty(false); + setDirty(variants.some((variant, index) => variant.offer_id !== values.variants[index].offer_id)); } catch (error) { message.error(apiErrorMessage(error)); } finally { setLoading(false); } - }, [currentPlatform, form, message, productId]); + }, [collectionItemId, currentPlatform, form, message, productId]); useEffect(() => { void load(); }, [load]); @@ -906,6 +918,10 @@ const ProductEditPage = () => { message.error(invalidPackage); return false; } + if (draft.variants.some((variant) => (variant.offer_id?.length ?? 0) > 50)) { + message.error('完整货号(含型号前缀)最多 50 字符,请缩短型号或 SKU 后缀'); + return false; + } setSaving(true); try { await productEditorApi.save( @@ -1029,7 +1045,8 @@ const ProductEditPage = () => { const values = form.getFieldsValue(true) as EditorFormValues; const profile = { ...values.profile }; const common = { ...values.common }; - const variants = [...values.variants]; + let variants = [...values.variants]; + const lockedOffers = lockedSkuOfferIds(snapshot); if (result.category && !profile.platform_product_category_id) { profile.platform_product_category_id = result.category.category_id; profile.category_source = 'ai_suggestion'; @@ -1060,9 +1077,16 @@ const ProductEditPage = () => { } for (const offer of result.offers ?? []) { const index = variants.findIndex((variant) => variant.id === offer.variant_id); - if (index >= 0 && !variants[index].offer_id) - variants[index] = { ...variants[index], offer_id: offer.offer_id }; + if (index >= 0 && !variants[index].offer_id && !lockedOffers.has(offer.variant_id)) + variants[index] = { + ...variants[index], + offer_id: composeSkuOfferId( + values.common.model, skuOfferSuffix(offer.offer_id, result.model ?? common.model) + ), + }; } + variants = syncSkuOfferIds(variants, values.common.model, common.model, lockedOffers); + offerModelRef.current = common.model; const copy = result.spu_copy; if (copy?.title && profile.title_mode !== 'variant' && !profile.title) { profile.title = copy.title.ru; @@ -1118,36 +1142,49 @@ const ProductEditPage = () => { }; const generateSkuCopy = async () => { if (!snapshot || !currentPlatform || !skuCopyVariantId || skuCopyLoading) return; + const variantId = skuCopyVariantId; + const hint = (skuCopyRequirements[variantId] ?? '').trim(); + const requestId = ++skuCopyRequestId.current; setSkuCopyLoading(true); setSkuCopyError(null); setSkuCopyResult(null); + skuCopyTaskId.current = null; try { if (dirty && !(await save(form.getFieldsValue(true) as EditorFormValues))) return; + if (requestId !== skuCopyRequestId.current) return; // 保存重载后补齐的空属性控件不属于人工修改,按实际保存内容比较。 const formAtStart = JSON.stringify( normalizedValues(form.getFieldsValue(true) as EditorFormValues) ); const task = await productEditorApi.aiSkuCopy( snapshot.profile.id, - skuCopyVariantId, - currentPlatform.id + variantId, + currentPlatform.id, + hint ? { hint } : {} ); + if (requestId !== skuCopyRequestId.current) return; const completed = await waitForAiTask(task.ai_task_id); + if (requestId !== skuCopyRequestId.current) return; if (completed.status === 'failed' || !completed.result) throw new Error(completed.error_message ?? 'SKU 文案生成失败'); + if ((completed.result as ProductAiSkuCopyResult).variant_id !== variantId) + throw new Error('生成结果与当前 SKU 不匹配,请重新生成'); const latest = await productEditorApi.read(snapshot.product.id, currentPlatform.id); + if (requestId !== skuCopyRequestId.current) return; if ( latest.product.revision !== completed.input_revision.product || latest.profile.revision !== completed.input_revision.profile || + latest.source_material.source_snapshot_revision !== completed.input_revision.source_snapshot || JSON.stringify(normalizedValues(form.getFieldsValue(true) as EditorFormValues)) !== formAtStart ) throw new Error('商品已被其他操作更新,请重新生成'); skuCopyTaskId.current = completed.id; setSkuCopyResult((completed.result as ProductAiSkuCopyResult).copy); } catch (error) { - setSkuCopyError(error instanceof Error ? error.message : apiErrorMessage(error)); + if (requestId === skuCopyRequestId.current) + setSkuCopyError(error instanceof Error ? error.message : apiErrorMessage(error)); } finally { - setSkuCopyLoading(false); + if (requestId === skuCopyRequestId.current) setSkuCopyLoading(false); } }; const applySkuCopy = () => { @@ -1156,12 +1193,29 @@ const ProductEditPage = () => { const index = variants.findIndex((variant) => variant.id === skuCopyVariantId); if (index < 0) return; const copy = skuCopyResult; - if (copy.title && !usesVariantTitle) changeTitleMode('variant'); + const switchesTitleMode = !!copy.title && !usesVariantTitle; + const inheritedTitle = switchesTitleMode + ? form.getFieldValue(['profile', 'title']) as string | null + : null; + if (switchesTitleMode) { + const inheritedTranslation = copyTranslations.find((item) => + item.variant_id === null && item.field === 'title' && item.russian_value === inheritedTitle + ); + changeTitleMode('variant'); + // 只替换当前 SKU;其他 SKU 原先继承的标题与对照在模式切换后继续保留。 + if (inheritedTranslation) { + const inheritedVariants = variants.filter((variant, current) => current !== index && variant.id && !variant.title); + setCopyTranslations((previous) => [ + ...previous.filter((item) => item.field !== 'title' || !inheritedVariants.some((variant) => variant.id === item.variant_id)), + ...inheritedVariants.map((variant) => ({ ...inheritedTranslation, variant_id: variant.id })), + ]); + } + } form.setFieldValue( 'variants', variants.map((variant, current) => current !== index - ? variant + ? inheritedTitle && !variant.title ? { ...variant, title: inheritedTitle } : variant : { ...variant, title: copy.title?.ru ?? variant.title, @@ -1179,7 +1233,15 @@ const ProductEditPage = () => { setSkuCopyVariantId(null); message.success('已应用到当前 SKU,保存草稿后生效'); }; - const skuCopyTaskId = useRef(null); + const closeSkuCopy = () => { + // 关闭后仍在运行的任务可以完成,但不能写入后来打开的另一个 SKU 弹窗。 + skuCopyRequestId.current += 1; + skuCopyTaskId.current = null; + setSkuCopyVariantId(null); + setSkuCopyLoading(false); + setSkuCopyResult(null); + setSkuCopyError(null); + }; const validSupplyUrl = typeof supplyUrl === 'string' && /^https?:\/\//i.test(supplyUrl) ? supplyUrl : null; const canOpenSupplyUrl = !!validSupplyUrl; @@ -1448,7 +1510,17 @@ const ProductEditPage = () => { wrapperCol={{ flex: '0 1 680px' }} onFinish={save} requiredMark - onValuesChange={() => setDirty(true)} + onValuesChange={(changed: Partial) => { + if (changed.common && 'model' in changed.common) { + // 收起的 SKU 没有挂载字段,必须从完整表单读取,避免同步时丢失其资料。 + const values = form.getFieldsValue(true) as EditorFormValues; + form.setFieldValue('variants', syncSkuOfferIds( + values.variants, offerModelRef.current, values.common.model, lockedSkuOfferIds(snapshot) + )); + offerModelRef.current = values.common.model; + } + setDirty(true); + }} >
{labels[row.field]} {row.stale && 对照待更新}
{row.chinese || '暂无中文对照'}
+ {row.field === 'description' && row.chinese} />}
))} @@ -71,10 +75,12 @@ export function SkuCopyAiModal({ sourceTitle, sourceDescription, specifications, + requirements, loading, result, error, onGenerate, + onRequirementsChange, onApply, onClose, }: { @@ -83,46 +89,81 @@ export function SkuCopyAiModal({ sourceTitle: string; sourceDescription: string; specifications: string; + requirements: string; loading: boolean; result: AiCopyOutput | null; error: string | null; onGenerate: () => void; + onRequirementsChange: (value: string) => void; onApply: () => void; onClose: () => void; }) { return ( - - - - +
+ 仅应用到当前 SKU,保存草稿后生效。 + + + + + +
} > +
+ 当前 SKU + {variantName} +
{error && ( )}
- 中文来源与 SKU 信息 - SKU 名称 -

{variantName}

- 来源标题 -

{sourceTitle || '未采集'}

- 来源描述 -

{sourceDescription || '未采集'}

- SKU 规格 -

{specifications || '未采集'}

+
+
+ + 优先采用 +
+

你的描述与要求将优先采用,采集资料仅用于补充。

+ onRequirementsChange(event.target.value)} + /> +

可补充或修正商品信息,指定卖点、写作风格和禁用词。留空则依据当前 SKU 与采集资料生成。

+
+ + 来源标题 +

{sourceTitle || '未采集'}

+ 来源描述 +

{sourceDescription || '未采集'}

+ SKU 规格 +

{specifications || '未采集'}

+ , + }]} + />
当前 SKU 的生成结果 @@ -133,22 +174,26 @@ export function SkuCopyAiModal({ )} {!loading && !result && ( - 点击下方「AI 生成」后在此预览。 + 填写描述或要求后点击「AI 生成」,在此查看俄文文案与中文对照。 )} {result && (
- 标题 -

{result.title?.ru || '未生成'}

-

{result.title?.zh || ''}

- 主题标签 -

{result.tags?.map((tag) => tag.ru).join('、') || '未生成'}

-

- {result.tags?.map((tag) => tag.zh).join('、') || ''} -

- 产品描述 -

{result.description?.ru || '未生成'}

-

{result.description?.zh || ''}

+ {[ + { field: 'title', ru: result.title?.ru, zh: result.title?.zh }, + { field: 'tags', ru: result.tags?.map((tag) => tag.ru).join(' '), zh: result.tags?.map((tag) => tag.zh).join('、') }, + { field: 'description', ru: result.description?.ru, zh: result.description?.zh }, + ].map((row) => ( +
+ {labels[row.field as ProductCopyField]} +

俄文

+

{row.ru || '未生成'}

+ {row.field === 'description' && row.ru} />} +

中文对照

+

{row.zh || '未生成'}

+ {row.field === 'description' && row.zh} />} +
+ ))}
)}
diff --git a/apps/web/src/pages/products/components/ProductOtherSection.tsx b/apps/web/src/pages/products/components/ProductOtherSection.tsx index 6b51d9a..b9e7fbd 100644 --- a/apps/web/src/pages/products/components/ProductOtherSection.tsx +++ b/apps/web/src/pages/products/components/ProductOtherSection.tsx @@ -21,7 +21,7 @@ export function ProductOtherSection({ canOpenSupplyUrl, platformName, onOpenSupp
- + { + const variants = form.getFieldValue('variants') as VariantValues[]; + if (image.assetId) variants.forEach((variant, index) => { + form.setFieldValue(['variants', index, 'media_asset_ids'], (variant.media_asset_ids ?? []).filter(id => id !== image.assetId)); + }); + setLocalImagesByVariant(current => Object.fromEntries(Object.entries(current).map(([key, items]) => + [key, items.filter(item => item.id !== image.id && (!image.assetId || item.assetId !== image.assetId))]))); + onImagesChanged(); + }} onUploadFile={async (file) => { - const asset = await uploadProductImage(file); + const asset = await uploadProductImage(file, snapshot.product.id); const url = URL.createObjectURL(file); localObjectUrls.current.push(url); return { diff --git a/apps/web/src/pages/products/components/SkuImageEditorModal.tsx b/apps/web/src/pages/products/components/SkuImageEditorModal.tsx index 83f36e8..6022376 100644 --- a/apps/web/src/pages/products/components/SkuImageEditorModal.tsx +++ b/apps/web/src/pages/products/components/SkuImageEditorModal.tsx @@ -1,8 +1,8 @@ import { useCallback, useEffect, useRef, useState } from 'react'; -import { App as AntdApp, Button, Dropdown, Empty, Image, Modal, Spin } from 'antd'; +import { App as AntdApp, Alert, Button, Dropdown, Empty, Image, Modal, Spin } from 'antd'; import type { MenuProps } from 'antd'; import { CheckOutlined, DownOutlined, UploadOutlined } from '@ant-design/icons'; -import type { CollectionItemInspection, ProductImageDerivativeRecord, WatermarkTemplate } from '@atlas/contracts'; +import type { CollectionItemInspection, ProductImageDeletion, ProductImageDerivativeRecord, WatermarkTemplate } from '@atlas/contracts'; import { apiErrorMessage } from '@/services/api'; import { mediaApi } from '@/services/media'; import { watermarksApi } from '@/services/watermarks'; @@ -31,6 +31,7 @@ type Props = { onMakeThreeFour: (image: SkuEditorImage) => Promise; onApplyWatermark: (image: SkuEditorImage, template: WatermarkTemplate) => Promise; onResolveAsset: (image: SkuEditorImage) => Promise; + onImageDeleted?: (image: SkuEditorImage) => void; onDownloadImage: (image: SkuEditorImage) => Promise; }; @@ -56,11 +57,27 @@ export function SkuImageEditorModal({ onApplyWatermark, onResolveAsset, onDownloadImage, + onImageDeleted, }: Props) { - const { message } = AntdApp.useApp(); + const { message, modal } = AntdApp.useApp(); const [images, setImages] = useState(initialImages); const [markedIds, setMarkedIds] = useState([]); const [activeTab, setActiveTab] = useState('collection'); + const [deletionJobs, setDeletionJobs] = useState([]); + useEffect(() => { + if (!open) return; + let active = true; + setDeletionJobs([]); + const refresh = () => void mediaApi.listPendingImageDeletions(productId) + .then(jobs => { if (active) setDeletionJobs(jobs); }).catch(() => undefined); + refresh(); + const interval = window.setInterval(refresh, 4000); + return () => { active = false; window.clearInterval(interval); }; + }, [open, productId]); + const [deleting, setDeleting] = useState(false); + const [removedImages, setRemovedImages] = useState([]); + const deleteDialog = useRef<{ destroy: () => void } | null>(null); + useEffect(() => { setRemovedImages([]); }, [productId]); const [confirming, setConfirming] = useState(false); const [processingThreeFour, setProcessingThreeFour] = useState(false); const [processingWatermark, setProcessingWatermark] = useState(false); @@ -92,6 +109,7 @@ export function SkuImageEditorModal({ const editorSession = useRef(0); const downloadLock = useRef(false); const { + removeAsset, networkUrls, setNetworkUrls, networkAdding, @@ -101,7 +119,7 @@ export function SkuImageEditorModal({ uploadFile, uploadToRight, addNetworkImages, - } = useSkuImageLibrary(open, skuKey, images, onUploadFile); + } = useSkuImageLibrary(open, productId, skuKey, images, onUploadFile); const [preview, setPreview] = useState<{ images: SkuEditorImage[]; index: number } | null>( null ); @@ -122,10 +140,12 @@ export function SkuImageEditorModal({ return () => { if (editorSession.current === session) { editorSession.current += 1; + deleteDialog.current?.destroy(); + deleteDialog.current = null; defaultWatermarkRequest.current = null; } }; - }, [open, skuKey]); + }, [open, productId, skuKey]); useEffect(() => { if (!open) return; let active = true; @@ -394,7 +414,54 @@ export function SkuImageEditorModal({ { key: 'delete', label: '移出已选', danger: true }, ], }; - const imageMenu = (image: SkuEditorImage): MenuProps => ({ + const requestImageDelete = (image: SkuEditorImage) => { + const session = editorSession.current; + let deletionId: string | null = null; + deleteDialog.current = modal.confirm({ + title: '删除这张图片?', + content: '删除后将从当前商品图片库和已选区域移除,七牛中已存储的图片文件也会永久删除。此操作无法撤销。', + okText: '删除', cancelText: '取消', okButtonProps: { danger: true }, + onOk: async () => { + if (session !== editorSession.current) return; + processingLock.current = true; setDeleting(true); + try { + let result = deletionId + ? await mediaApi.retryImageDeletion(productId, deletionId) + : await mediaApi.deleteProductImage(productId, image.assetId ? { asset_id: image.assetId } : { collection_image_id: image.transferId }); + deletionId = result.id; + if (session === editorSession.current) { + setRemovedImages(current => [...current, image]); + setImages(current => current.filter(item => !sameImage(item, image))); + setMarkedIds(current => current.filter(id => id !== image.id)); + if (image.assetId) { + removeAsset(image.assetId); + setAiImages(current => current.filter(item => item.assetId !== image.assetId)); + setProcessedRecords(current => current.filter(item => item.output_asset.id !== image.assetId)); + } + setPreview(null); + setWatermarkUndo(current => Object.fromEntries(Object.entries(current).filter(([id, value]) => + id !== image.id && (!image.assetId || value?.assetId !== image.assetId)))); + onImageDeleted?.(image); + } + for (let attempt = 0; result.status === 'queued' && attempt < 60; attempt += 1) { + await new Promise(resolve => window.setTimeout(resolve, 1000)); + result = await mediaApi.readImageDeletion(productId, result.id); + } + if (result.status === 'failed') throw new Error('七牛图片清理失败,请点击删除按钮重试'); + if (session === editorSession.current) { + if (result.status === 'succeeded') message.success('图片已删除,存储文件已清理'); + else message.info('图片删除已提交,存储文件仍在清理中'); + } + } catch (error) { + if (session === editorSession.current) message.error(apiErrorMessage(error)); + throw error; + } finally { + if (session === editorSession.current) { processingLock.current = false; setDeleting(false); } + } + }, + }); + }; + const imageMenu = (image: SkuEditorImage, canDelete = false): MenuProps => ({ onClick: ({ key, domEvent }) => { domEvent.stopPropagation(); if (processingLock.current) return; @@ -404,6 +471,7 @@ export function SkuImageEditorModal({ else if (key === 'watermark') void addWatermark([image]); else if (key === 'AI 编辑') setAiTarget(image); else if (key === 'undo_watermark') undoWatermark(image); + else if (key === 'delete_image') requestImageDelete(image); else pendingAction(String(key)); }, items: [ @@ -413,10 +481,12 @@ export function SkuImageEditorModal({ { key: 'three_four', label: '一键 3:4' }, { key: 'cover', label: '设为封图' }, { key: 'download', label: '下载' }, + ...(canDelete ? [{ type: 'divider' as const }, { key: 'delete_image', label: '删除', danger: true }] : []), ], }); const uploadFiles = async (files: FileList | null) => { if (!files || processingLock.current) return; + const session = editorSession.current; const available = MAX_IMAGES - images.length; const accepted = Array.from(files).filter((file) => { const error = uploadFileError(file); @@ -425,14 +495,17 @@ export function SkuImageEditorModal({ }); if (accepted.length > available) message.warning(`每个 SKU 最多选择 ${MAX_IMAGES} 张图片`); for (const file of accepted.slice(0, available)) { + if (session !== editorSession.current) return; try { const image = await uploadFile(file); + if (session !== editorSession.current) return; setImages((current) => current.some((item) => item.assetId === image.assetId) ? current : [...current, image] ); } catch (error) { + if (session !== editorSession.current) return; message.error(`${file.name} 上传失败:${apiErrorMessage(error)}`); } } @@ -440,7 +513,7 @@ export function SkuImageEditorModal({ }; const sourceImages = (role: string): SkuEditorImage[] => collectionImages - .filter((image) => image.role === role) + .filter((image) => image.role === role && !removedImages.some(removed => removed.transferId === image.id || (removed.assetId && removed.assetId === image.asset_id))) .map((image) => ({ id: `collection:${image.id}`, url: (image.asset_id && previewUrls[image.asset_id]) ?? image.source_url, @@ -456,8 +529,9 @@ export function SkuImageEditorModal({ })); const showPreview = (items: SkuEditorImage[], index: number) => setPreview({ images: items, index }); - const renderSourceImages = (items: SkuEditorImage[]) => - items.length ? ( + const renderSourceImages = (candidates: SkuEditorImage[]) => { + const items = candidates.filter(image => !removedImages.some(removed => sameImage(removed, image))); + return items.length ? (
{items.map((image, index) => { const selected = images.some((item) => sameImage(item, image)); @@ -467,7 +541,7 @@ export function SkuImageEditorModal({ image={image} mode="source" selected={selected} - menu={imageMenu(image)} + menu={imageMenu(image, true)} onToggle={() => addImage(image)} onPreview={() => showPreview(items, index)} /> @@ -477,7 +551,9 @@ export function SkuImageEditorModal({ ) : ( ); - const processedImages = processedRecords.map((record): SkuEditorImage => ({ + }; + const visibleProcessedRecords = processedRecords.filter(record => !removedImages.some(image => image.assetId === record.output_asset.id)); + const processedImages = visibleProcessedRecords.map((record): SkuEditorImage => ({ id: `asset:${record.output_asset.id}`, url: record.output_asset.preview_url, label: record.kind === 'watermark' ? '水印处理图' : '3:4 处理图', @@ -489,16 +565,16 @@ export function SkuImageEditorModal({ if (processedError) return ; - if (!processedRecords.length) return ; + if (!visibleProcessedRecords.length) return ; return
- {processedRecords.map((record, index) => { + {visibleProcessedRecords.map((record, index) => { const image = processedImages[index]; return
sameImage(item, image))} - menu={imageMenu(image)} + menu={imageMenu(image, true)} onToggle={() => addImage(image)} onPreview={() => showPreview(processedImages, index)} /> @@ -535,19 +611,19 @@ export function SkuImageEditorModal({ } width="min(1480px, 96vw)" onCancel={() => { - if (!confirming && !processingThreeFour && !processingWatermark) onCancel(); + if (!confirming && !deleting && !processingThreeFour && !processingWatermark) onCancel(); }} - closable={!confirming && !processingThreeFour && !processingWatermark} - maskClosable={!confirming && !processingThreeFour && !processingWatermark} + closable={!confirming && !deleting && !processingThreeFour && !processingWatermark} + maskClosable={!confirming && !deleting && !processingThreeFour && !processingWatermark} footer={[ - , )}} style={{ marginBottom: 12 }} />}
diff --git a/apps/web/src/pages/products/components/SkuInfoPanel.tsx b/apps/web/src/pages/products/components/SkuInfoPanel.tsx index e07dcd7..4449520 100644 --- a/apps/web/src/pages/products/components/SkuInfoPanel.tsx +++ b/apps/web/src/pages/products/components/SkuInfoPanel.tsx @@ -9,6 +9,8 @@ import type { } from '@atlas/contracts'; import { PackageIntegerInput, packageIntegerRule } from '@/components/PackageIntegerInput'; import { DynamicAttributeFields } from './DynamicAttributeFields'; +import { CopyTextButton } from '@/components/CopyTextButton'; +import { SkuOfferIdInput } from './SkuOfferIdInput'; type EditorFormValues = SaveProductEditorDraftRequest; type NamePath = Array; @@ -16,6 +18,7 @@ type Props = { form: FormInstance; fieldName: number; sourceId: string | null; + offerIdLocked: boolean; usesVariantPackageWeight: boolean; usesVariantPackageDimensions: boolean; onUseCommonPackageWeight: () => void; @@ -62,6 +65,7 @@ export function SkuInfoPanel({ form, fieldName, sourceId, + offerIdLocked, usesVariantPackageWeight, usesVariantPackageDimensions, onUseCommonPackageWeight, @@ -86,6 +90,7 @@ export function SkuInfoPanel({ const profile = Form.useWatch('profile', { form, preserve: true }) as EditorFormValues['profile'] | undefined; const ownTags = Form.useWatch(['variants', fieldName, 'tags'], { form, preserve: true }) ?? []; + const model = Form.useWatch(['common', 'model'], { form, preserve: true }); const tagsProblem = ozonHashtagProblem( usesVariantTags && ownTags.length ? ownTags : profile?.tags ?? [] ); @@ -95,17 +100,21 @@ export function SkuInfoPanel({
规格与发布信息
SKU 基础资料
- - - - - - +
+ + + + + + +
{(usesVariantPackageWeight || usesVariantPackageDimensions) && ( <>
包装规格
@@ -271,6 +280,12 @@ export function SkuInfoPanel({ } /> + { + const own = usesVariantDescription + ? form.getFieldValue(['variants', fieldName, 'description']) as string | null | undefined + : null; + return own?.trim() ? own : form.getFieldValue(['profile', 'description']); + }} />