From a29dd92676d2527901b402fb0dc88f4464ad9b94 Mon Sep 17 00:00:00 2001 From: joey-home Date: Sun, 27 Sep 2026 22:40:52 +0800 Subject: [PATCH] =?UTF-8?q?feat(ai-image):=20AI=E7=94=9F=E5=9B=BE=E5=BC=80?= =?UTF-8?q?=E5=8F=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 5 +- apps/api/src/app.module.ts | 2 + apps/api/src/extension/extension.service.ts | 19 +- .../media/aspect-ratio-derivative.service.ts | 139 ++++++ apps/api/src/media/media-ai-edit.service.ts | 166 +++++++ apps/api/src/media/media.controller.ts | 141 +++++- apps/api/src/media/media.module.ts | 7 +- apps/api/src/media/media.service.ts | 18 +- ...roduct-image-derivative-history.service.ts | 81 ++++ .../src/media/product-image-task.service.ts | 106 ++++ .../src/media/watermark-derivative.service.ts | 145 ++++++ .../online-products.controller.ts | 191 ++++++++ .../online-products/online-products.module.ts | 18 + .../online-products.service.ts | 415 ++++++++++++++++ .../product-editor/product-editor.service.ts | 20 +- .../src/watermarks/watermarks.controller.ts | 15 + apps/api/src/watermarks/watermarks.service.ts | 183 ++++++- apps/api/test/extension.e2e.test.ts | 65 ++- apps/api/test/media-ai-edit.unit.test.ts | 101 ++++ apps/api/test/media-aspect-ratio.unit.test.ts | 81 ++++ .../api/test/media-product-image.unit.test.ts | 30 ++ apps/api/test/media-watermark.e2e.test.ts | 170 +++++++ apps/api/test/media-watermark.unit.test.ts | 68 +++ apps/api/test/online-products.e2e.test.ts | 258 ++++++++++ ...roduct-editor-processed-media.unit.test.ts | 48 ++ apps/api/test/product-editor.e2e.test.ts | 77 +++ ...duct-image-derivative-history.unit.test.ts | 56 +++ .../api/test/product-image-tasks.unit.test.ts | 98 ++++ apps/api/test/watermark-default.unit.test.ts | 94 ++++ .../test/watermark-image-size.unit.test.ts | 153 ++++++ apps/api/test/watermark-position.unit.test.ts | 109 +++++ apps/extension/lib/source-parsers.ts | 68 ++- apps/extension/test/1688-sku.test.mts | 62 +++ apps/web/src/App.css | 73 +-- apps/web/src/components/WatermarkPreview.tsx | 198 ++++++-- .../src/pages/collection/CollectionPage.css | 15 + .../src/pages/collection/CollectionPage.tsx | 7 +- .../online-products/OnlineProductsPage.css | 123 +++++ .../online-products/OnlineProductsPage.tsx | 433 ++++++++++++++++- .../components/OnlineProductDetailDrawer.tsx | 121 +++++ .../components/OnlineProductFilters.tsx | 70 +++ .../components/OnlineProductTable.tsx | 145 ++++++ .../components/OnlineSyncStatus.tsx | 146 ++++++ .../src/pages/products/ProductEditPage.css | 5 + .../src/pages/products/ProductEditPage.tsx | 9 +- .../components/CollectedProductInfoPanel.css | 17 +- .../components/CollectedProductInfoPanel.tsx | 184 ++++++- .../components/ProductImageTaskCenter.css | 83 ++++ .../components/ProductImageTaskCenter.tsx | 171 +++++++ .../components/ProductVariantSection.css | 31 +- .../components/ProductVariantSection.tsx | 83 +++- .../products/components/SkuImageCard.tsx | 2 +- .../components/SkuImageEditorModal.css | 31 +- .../components/SkuImageEditorModal.tsx | 420 ++++++++++++++-- .../components/SkuImageSourceTabs.tsx | 11 +- .../components/SkuSingleAiEditDrawer.css | 39 ++ .../components/SkuSingleAiEditDrawer.tsx | 237 +++++++++ .../components/SkuWatermarkPickerModal.css | 137 ++++++ .../components/SkuWatermarkPickerModal.tsx | 165 +++++++ .../src/pages/watermarks/WatermarksPage.tsx | 288 ++++++----- apps/web/src/services/media.ts | 105 ++++ apps/web/src/services/onlineProducts.ts | 59 +++ apps/web/src/services/watermarks.ts | 7 + apps/worker/package.json | 3 +- apps/worker/src/main.ts | 33 ++ apps/worker/src/media-ai-edit.ts | 456 ++++++++++++++++++ apps/worker/src/media-ai-prompt.ts | 47 ++ apps/worker/src/media-aspect-ratio.ts | 221 +++++++++ apps/worker/src/media-watermark.ts | 218 +++++++++ apps/worker/src/online-product-mapper.ts | 155 ++++++ apps/worker/src/online-product-sync.ts | 346 +++++++++++++ apps/worker/src/ozon-client.ts | 2 +- apps/worker/test/media-ai-edit.test.mjs | 350 ++++++++++++++ apps/worker/test/media-aspect-ratio.test.mjs | 186 +++++++ apps/worker/test/media-watermark.test.mjs | 62 +++ .../test/online-product-mapper.test.mjs | 63 +++ .../online-product-sync.integration.test.mjs | 304 ++++++++++++ apps/worker/tsconfig.tsbuildinfo | 1 + docs/api/README.md | 2 +- docs/api/collection-and-extension.md | 2 + docs/api/online-listings.md | 2 + docs/api/product-editor-and-publishing.md | 23 +- docs/api/watermarks-and-assets.md | 54 ++- docs/product/product-ai-editor.md | 16 +- docs/project/HANDOFF.md | 62 ++- docs/requirements/account-store-watermark.md | 12 +- .../collection-box-and-online-products.md | 2 +- .../implementation-readiness-open-items.md | 4 +- packages/contracts/src/index.ts | 1 + packages/contracts/src/media.ts | 100 +++- packages/contracts/src/online-products.ts | 104 ++++ packages/contracts/src/product-editor.ts | 2 +- packages/contracts/src/watermarks.ts | 39 +- .../033_media_aspect_ratio_derivatives.sql | 28 ++ .../034_watermark_free_position.sql | 44 ++ .../035_watermark_image_size_and_radius.sql | 48 ++ .../036_media_watermark_derivatives.sql | 32 ++ .../037_watermark_image_size_min_10.sql | 9 + .../migrations/038_watermark_default.sql | 8 + .../migrations/039_media_ai_edits.sql | 32 ++ .../040_media_ai_edit_product_scope.sql | 10 + .../migrations/041_online_product_sync.sql | 89 ++++ .../042_product_image_derivative_history.sql | 18 + packages/reference/1688.html | 169 +++++++ pnpm-lock.yaml | 321 ++++++++++++ 105 files changed, 9943 insertions(+), 361 deletions(-) create mode 100644 apps/api/src/media/aspect-ratio-derivative.service.ts create mode 100644 apps/api/src/media/media-ai-edit.service.ts create mode 100644 apps/api/src/media/product-image-derivative-history.service.ts create mode 100644 apps/api/src/media/product-image-task.service.ts create mode 100644 apps/api/src/media/watermark-derivative.service.ts create mode 100644 apps/api/src/online-products/online-products.controller.ts create mode 100644 apps/api/src/online-products/online-products.module.ts create mode 100644 apps/api/src/online-products/online-products.service.ts create mode 100644 apps/api/test/media-ai-edit.unit.test.ts create mode 100644 apps/api/test/media-aspect-ratio.unit.test.ts create mode 100644 apps/api/test/media-watermark.e2e.test.ts create mode 100644 apps/api/test/media-watermark.unit.test.ts create mode 100644 apps/api/test/online-products.e2e.test.ts create mode 100644 apps/api/test/product-editor-processed-media.unit.test.ts create mode 100644 apps/api/test/product-image-derivative-history.unit.test.ts create mode 100644 apps/api/test/product-image-tasks.unit.test.ts create mode 100644 apps/api/test/watermark-default.unit.test.ts create mode 100644 apps/api/test/watermark-image-size.unit.test.ts create mode 100644 apps/api/test/watermark-position.unit.test.ts create mode 100644 apps/extension/test/1688-sku.test.mts create mode 100644 apps/web/src/pages/collection/CollectionPage.css create mode 100644 apps/web/src/pages/online-products/OnlineProductsPage.css create mode 100644 apps/web/src/pages/online-products/components/OnlineProductDetailDrawer.tsx create mode 100644 apps/web/src/pages/online-products/components/OnlineProductFilters.tsx create mode 100644 apps/web/src/pages/online-products/components/OnlineProductTable.tsx create mode 100644 apps/web/src/pages/online-products/components/OnlineSyncStatus.tsx create mode 100644 apps/web/src/pages/products/components/ProductImageTaskCenter.css create mode 100644 apps/web/src/pages/products/components/ProductImageTaskCenter.tsx create mode 100644 apps/web/src/pages/products/components/SkuSingleAiEditDrawer.css create mode 100644 apps/web/src/pages/products/components/SkuSingleAiEditDrawer.tsx create mode 100644 apps/web/src/pages/products/components/SkuWatermarkPickerModal.css create mode 100644 apps/web/src/pages/products/components/SkuWatermarkPickerModal.tsx create mode 100644 apps/web/src/services/onlineProducts.ts create mode 100644 apps/worker/src/media-ai-edit.ts create mode 100644 apps/worker/src/media-ai-prompt.ts create mode 100644 apps/worker/src/media-aspect-ratio.ts create mode 100644 apps/worker/src/media-watermark.ts create mode 100644 apps/worker/src/online-product-mapper.ts create mode 100644 apps/worker/src/online-product-sync.ts create mode 100644 apps/worker/test/media-ai-edit.test.mjs create mode 100644 apps/worker/test/media-aspect-ratio.test.mjs create mode 100644 apps/worker/test/media-watermark.test.mjs create mode 100644 apps/worker/test/online-product-mapper.test.mjs create mode 100644 apps/worker/test/online-product-sync.integration.test.mjs create mode 100644 apps/worker/tsconfig.tsbuildinfo create mode 100644 packages/contracts/src/online-products.ts create mode 100644 packages/database/migrations/033_media_aspect_ratio_derivatives.sql create mode 100644 packages/database/migrations/034_watermark_free_position.sql create mode 100644 packages/database/migrations/035_watermark_image_size_and_radius.sql create mode 100644 packages/database/migrations/036_media_watermark_derivatives.sql create mode 100644 packages/database/migrations/037_watermark_image_size_min_10.sql create mode 100644 packages/database/migrations/038_watermark_default.sql create mode 100644 packages/database/migrations/039_media_ai_edits.sql create mode 100644 packages/database/migrations/040_media_ai_edit_product_scope.sql create mode 100644 packages/database/migrations/041_online_product_sync.sql create mode 100644 packages/database/migrations/042_product_image_derivative_history.sql create mode 100644 packages/reference/1688.html diff --git a/.env.example b/.env.example index ddf5a0a..022fe9f 100644 --- a/.env.example +++ b/.env.example @@ -87,5 +87,8 @@ EXTENSION_PORT=8110 # DeepSeek API Key DEEPSEEK_API_KEY= +# 单张 AI 图片编辑使用 Pcore 的 gpt-image-2 异步图片接口;根地址和 /v1 地址均可,仅 Worker 读取。 +PCORE_BASE_URL=https://pcore.ai/v1 +PCORE_API_KEY= # DeepSeek Model Name -DEEPSEEK_MODEL_NAME=deepseek-flash \ No newline at end of file +DEEPSEEK_MODEL_NAME=deepseek-flash diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index 15ed03f..899b02f 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -13,6 +13,7 @@ import { CollectionModule } from './collection/collection.module.js'; import { ExtensionModule } from './extension/extension.module.js'; import { ProductEditorModule } from './product-editor/product-editor.module.js'; import { PlatformCatalogAdminModule } from './platform-catalog-admin/platform-catalog-admin.module.js'; +import { OnlineProductsModule } from './online-products/online-products.module.js'; @Controller() class HealthController { @@ -53,6 +54,7 @@ class HealthController { ExtensionModule, ProductEditorModule, PlatformCatalogAdminModule, + OnlineProductsModule, ], controllers: [HealthController], }) diff --git a/apps/api/src/extension/extension.service.ts b/apps/api/src/extension/extension.service.ts index 4df45cc..f79c259 100644 --- a/apps/api/src/extension/extension.service.ts +++ b/apps/api/src/extension/extension.service.ts @@ -321,12 +321,14 @@ export class ExtensionService { await tx.query( `UPDATE atlas.product_variants SET source_sku = $1, display_name = $2, sort_order = $3, + purchase_cost_cny = COALESCE(ROUND($4::numeric, 2), purchase_cost_cny), deleted_at = NULL, updated_at = now() - WHERE id = $4`, + WHERE id = $5`, [ variant.sourceSku ?? null, variant.displayName, index, + sourcePurchaseCost(payload.source.platform, variant), variantId, ], ); @@ -334,8 +336,8 @@ export class ExtensionService { } else { const inserted = await tx.query<{ id: string }>( `INSERT INTO atlas.product_variants - (workspace_id, product_id, source_variant_key, source_sku, display_name, sort_order) - VALUES ($1, $2, $3, $4, $5, $6) RETURNING id`, + (workspace_id, product_id, source_variant_key, source_sku, display_name, sort_order, purchase_cost_cny) + VALUES ($1, $2, $3, $4, $5, $6, ROUND($7::numeric, 2)) RETURNING id`, [ workspaceId, productId, @@ -343,6 +345,7 @@ export class ExtensionService { variant.sourceSku ?? null, variant.displayName, index, + sourcePurchaseCost(payload.source.platform, variant), ], ); variantId = inserted.rows[0].id; @@ -486,3 +489,13 @@ function sourceState(payload: CollectedProductPayload) { sourcePrice: payload.common.sourcePrice, }; } + +/** 仅将 1688 明确标为人民币的逐 SKU 来源价写入采购价。 */ +function sourcePurchaseCost( + platform: CollectedProductPayload['source']['platform'], + variant: CollectedProductPayload['variants'][number] | { sourceVariantKey: null }, +) { + return platform === '1688' && 'sourcePrice' in variant && variant.sourcePrice?.currencyCode === 'CNY' + ? variant.sourcePrice.amount + : null; +} diff --git a/apps/api/src/media/aspect-ratio-derivative.service.ts b/apps/api/src/media/aspect-ratio-derivative.service.ts new file mode 100644 index 0000000..f962593 --- /dev/null +++ b/apps/api/src/media/aspect-ratio-derivative.service.ts @@ -0,0 +1,139 @@ +import { Inject, Injectable } from '@nestjs/common'; +import type { AspectRatioDerivative, CreateAspectRatioDerivativeRequest } from '@atlas/contracts'; +import type { Database } from '@atlas/database'; +import { ApiError } from '../common/api-error.js'; +import { IdempotencyService } from '../common/idempotency.service.js'; +import { DATABASE } from '../database/database.module.js'; +import { MediaService } from './media.service.js'; + +type DerivativeRow = { + id: string; + status: AspectRatioDerivative['status']; + source_asset_id: string; + output_asset_id: string | null; + error_code: string | null; + content_type: string | null; + byte_size: string | null; + width_px: number | null; + height_px: number | null; + asset_created_at: Date | string | null; +}; + +@Injectable() +export class AspectRatioDerivativeService { + public constructor( + @Inject(DATABASE) private readonly database: Database, + @Inject(IdempotencyService) private readonly idempotency: IdempotencyService, + @Inject(MediaService) private readonly media: MediaService, + ) {} + + /** 任务、用户可见进度和 Outbox 必须在同一工作空间事务中创建。 */ + public async create( + workspaceId: string, + userId: string, + assetId: string, + key: string, + input: CreateAspectRatioDerivativeRequest, + ) { + return this.idempotency.run( + { + workspaceId, + userId, + method: 'POST', + path: `/api/media-assets/${assetId}/aspect-ratio-derivatives`, + key, + request: input, + }, + 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') + AND byte_size <= 20971520 AND width_px BETWEEN 1 AND 10000 + AND height_px BETWEEN 1 AND 10000`, + [assetId], + ); + if (!source.rows[0]) + throw new ApiError(404, 'RESOURCE_NOT_FOUND', '图片素材不存在或不可处理'); + if (input.product_id) { + const product = await tx.query<{ id: string }>( + 'SELECT id FROM atlas.products WHERE id = $1', + [input.product_id], + ); + if (!product.rows[0]) + throw new ApiError(404, 'RESOURCE_NOT_FOUND', '商品不存在'); + } + const created = await tx.query<{ id: string }>( + `INSERT INTO atlas.media_aspect_ratio_derivatives + (workspace_id, product_id, source_asset_id, created_by_user_id) + VALUES (current_setting('app.workspace_id')::uuid, $1, $2, $3) + RETURNING id`, + [input.product_id ?? null, assetId, userId], + ); + const id = created.rows[0].id; + await tx.query( + `INSERT INTO atlas.operations + (id, workspace_id, kind, resource_type, resource_id, created_by_user_id) + VALUES ($1, current_setting('app.workspace_id')::uuid, + 'media_aspect_ratio_derivative', 'media_derivative', $1, $2)`, + [id, userId], + ); + 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, 'media_derivative', $1, + 'media.aspect_ratio_derivative.requested', + jsonb_build_object('derivative_id', $1::uuid), $1::text)`, + [id], + ); + return { + status: 202, + body: { data: { + id, + status: 'queued', + source_asset_id: assetId, + output_asset: null, + error_code: null, + } satisfies AspectRatioDerivative }, + }; + }, + ); + } + + /** 短时预览地址仅在读取成功结果时签发,不写入任务或草稿。 */ + public async read(workspaceId: string, derivativeId: string): Promise { + const row = await this.database.workspaceTransaction(workspaceId, async (tx) => { + const result = await tx.query( + `SELECT d.id, d.status, d.source_asset_id, d.output_asset_id, d.error_code, + 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 + 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 + ? 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, + output_asset: row.output_asset_id && access ? { + id: row.output_asset_id, + content_type: row.content_type!, + byte_size: Number(row.byte_size), + width_px: row.width_px, + height_px: row.height_px, + created_at: row.asset_created_at instanceof Date + ? row.asset_created_at.toISOString() + : String(row.asset_created_at), + preview_url: access.url, + preview_url_expires_at: access.expires_at, + } : null, + }; + } +} diff --git a/apps/api/src/media/media-ai-edit.service.ts b/apps/api/src/media/media-ai-edit.service.ts new file mode 100644 index 0000000..c93d5da --- /dev/null +++ b/apps/api/src/media/media-ai-edit.service.ts @@ -0,0 +1,166 @@ +import { Inject, Injectable } from '@nestjs/common'; +import type { MediaAiEdit, MediaAiEditSettings } from '@atlas/contracts'; +import type { Database } from '@atlas/database'; +import { ApiError } from '../common/api-error.js'; +import { IdempotencyService } from '../common/idempotency.service.js'; +import { DATABASE } from '../database/database.module.js'; +import { MediaService } from './media.service.js'; + +type EditRow = { + id: string; + status: MediaAiEdit['status']; + source_asset_id: string; + output_asset_id: string | null; + error_code: string | null; + failure_source?: MediaAiEdit['failure_source']; + failure_stage?: MediaAiEdit['failure_stage']; + failure_detail?: string | null; + content_type: string | null; + byte_size: string | null; + width_px: number | null; + height_px: number | null; + asset_created_at: Date | string | null; +}; + +@Injectable() +export class MediaAiEditService { + public constructor( + @Inject(DATABASE) private readonly database: Database, + @Inject(IdempotencyService) private readonly idempotency: IdempotencyService, + @Inject(MediaService) private readonly media: MediaService, + ) {} + + /** 创建任务前确认主图和辅图均为本工作空间已校验的正式素材。 */ + public async create(workspaceId: string, userId: string, sourceId: string, key: string, settings: MediaAiEditSettings) { + return this.idempotency.run({ + workspaceId, userId, method: 'POST', path: `/api/media-assets/${sourceId}/ai-edits`, + key, request: settings, + }, async (tx) => { + const allIds = [sourceId, ...settings.reference_asset_ids]; + const product = await tx.query<{ id: string }>( + 'SELECT id FROM atlas.products WHERE id = $1', [settings.product_id], + ); + if (!product.rows[0]) throw new ApiError(404, 'RESOURCE_NOT_FOUND', '商品不存在'); + 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[]) + 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], + ); + if (assets.rows.length !== allIds.length) + throw new ApiError(404, 'RESOURCE_NOT_FOUND', '图片素材不存在或不可处理'); + const created = await tx.query<{ id: string }>( + `INSERT INTO atlas.media_ai_edits + (workspace_id, product_id, source_asset_id, reference_asset_ids, settings, created_by_user_id) + VALUES (current_setting('app.workspace_id')::uuid, $1, $2, $3::uuid[], $4::jsonb, $5) + RETURNING id`, + [settings.product_id, sourceId, settings.reference_asset_ids, JSON.stringify(settings), userId], + ); + const id = created.rows[0].id; + await tx.query( + `INSERT INTO atlas.operations (id, workspace_id, kind, resource_type, resource_id, created_by_user_id) + VALUES ($1, current_setting('app.workspace_id')::uuid, 'media_ai_edit', 'media_ai_edit', $1, $2)`, + [id, userId], + ); + 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, 'media_ai_edit', $1, + 'media.ai_edit.requested', jsonb_build_object('edit_id', $1::uuid), $1::text)`, [id], + ); + return { status: 202, body: { data: { + id, status: 'queued', source_asset_id: sourceId, output_asset: null, error_code: null, + failure_source: null, failure_stage: null, failure_detail: null, + } satisfies MediaAiEdit } }; + }); + } + + public async read(workspaceId: string, id: string): Promise { + const row = await this.database.workspaceTransaction(workspaceId, async (tx) => { + const result = await tx.query( + `SELECT e.id, e.status, e.source_asset_id, e.output_asset_id, e.error_code, + o.result ->> 'failure_source' AS failure_source, + o.result ->> 'failure_stage' AS failure_stage, + o.result ->> 'failure_detail' AS failure_detail, + 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 + 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 + 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; + 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, + failure_stage: row.status === 'failed' ? row.failure_stage ?? null : null, + failure_detail: row.status === 'failed' ? row.failure_detail ?? null : null, + output_asset: row.output_asset_id && access ? { + id: row.output_asset_id, content_type: row.content_type!, byte_size: Number(row.byte_size), + width_px: row.width_px, height_px: row.height_px, + created_at: row.asset_created_at instanceof Date + ? row.asset_created_at.toISOString() : String(row.asset_created_at), + preview_url: access.url, preview_url_expires_at: access.expires_at, + } : null, + }; + } + + /** 右侧素材库展示最近完成的生成图,预览链接每次读取时重新签发。 */ + public async list(workspaceId: string, productId: string): Promise { + const rows = await this.database.workspaceTransaction(workspaceId, async (tx) => { + const result = await tx.query( + `SELECT e.id, e.status, e.source_asset_id, e.output_asset_id, e.error_code, + 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 + WHERE e.status = 'succeeded' AND e.product_id = $1 + ORDER BY e.completed_at DESC LIMIT 100`, [productId], + ); + return result.rows; + }); + return Promise.all(rows.map(async (row) => { + const access = await this.media.accessUrl(workspaceId, row.output_asset_id!); + return { + id: row.id, status: 'succeeded' as const, source_asset_id: row.source_asset_id, + error_code: null, + failure_source: null, failure_stage: null, failure_detail: null, + output_asset: { + id: row.output_asset_id!, content_type: row.content_type!, + byte_size: Number(row.byte_size), width_px: row.width_px, height_px: row.height_px, + created_at: row.asset_created_at instanceof Date + ? row.asset_created_at.toISOString() : String(row.asset_created_at), + preview_url: access.url, preview_url_expires_at: access.expires_at, + }, + }; + })); + } + + /** 取消是工作空间内的幂等状态变更;已完成结果不回滚。 */ + public async cancel(workspaceId: string, userId: string, id: string, key: string) { + return this.idempotency.run({ + workspaceId, userId, method: 'POST', path: `/api/media-ai-edits/${id}/cancel`, key, request: {}, + }, async (tx) => { + const existing = await tx.query<{ status: MediaAiEdit['status'] }>( + 'SELECT status FROM atlas.media_ai_edits WHERE id = $1 FOR UPDATE', [id], + ); + if (!existing.rows[0]) throw new ApiError(404, 'RESOURCE_NOT_FOUND', 'AI 编辑任务不存在'); + if (existing.rows[0].status === 'queued' || existing.rows[0].status === 'running') { + await tx.query( + `UPDATE atlas.media_ai_edits SET status = 'cancelled', completed_at = now() WHERE id = $1`, [id], + ); + await tx.query( + `UPDATE atlas.operations SET status = 'cancelled', completed_at = now() + WHERE id = $1 AND status IN ('queued', 'running')`, [id], + ); + } + return { status: 200, body: { data: { id, status: existing.rows[0].status === 'queued' + || existing.rows[0].status === 'running' ? 'cancelled' : existing.rows[0].status } } }; + }); + } +} diff --git a/apps/api/src/media/media.controller.ts b/apps/api/src/media/media.controller.ts index 28ed153..dc78840 100644 --- a/apps/api/src/media/media.controller.ts +++ b/apps/api/src/media/media.controller.ts @@ -6,7 +6,9 @@ import { Get, Param, Post, + Query, Req, + Res, } from '@nestjs/common'; import { ApiBearerAuth, @@ -19,6 +21,11 @@ import { createMediaAccessUrlRequestSchema, createMediaUploadIntentRequestSchema, importProductImageRequestSchema, + createAspectRatioDerivativeRequestSchema, + createWatermarkDerivativeRequestSchema, + createMediaAiEditRequestSchema, + listProductImageTasksQuerySchema, + type CreateMediaAccessUrlRequest, type Workspace, } from '@atlas/contracts'; import { z } from 'zod'; @@ -26,6 +33,11 @@ import { ApiError } from '../common/api-error.js'; import type { AuthenticatedRequest } from '../auth/auth.guard.js'; import { CurrentWorkspace } from '../workspace/current-workspace.decorator.js'; import { MediaService } from './media.service.js'; +import { AspectRatioDerivativeService } from './aspect-ratio-derivative.service.js'; +import { WatermarkDerivativeService } from './watermark-derivative.service.js'; +import { MediaAiEditService } from './media-ai-edit.service.js'; +import { ProductImageTaskService } from './product-image-task.service.js'; +import { ProductImageDerivativeHistoryService } from './product-image-derivative-history.service.js'; @ApiTags('Media') @ApiBearerAuth('access-token') @@ -33,8 +45,132 @@ import { MediaService } from './media.service.js'; export class MediaController { public constructor( @Inject(MediaService) private readonly media: MediaService, + @Inject(AspectRatioDerivativeService) private readonly derivatives: AspectRatioDerivativeService, + @Inject(WatermarkDerivativeService) private readonly watermarkDerivatives: WatermarkDerivativeService, + @Inject(MediaAiEditService) private readonly aiEdits: MediaAiEditService, + @Inject(ProductImageTaskService) private readonly productImageTasks: ProductImageTaskService, + @Inject(ProductImageDerivativeHistoryService) private readonly derivativeHistory: ProductImageDerivativeHistoryService, ) {} + @Get('products/:productId/image-derivatives') + public async listProductImageDerivatives( + @Param('productId') productId: string, + @CurrentWorkspace() workspace: Workspace, + ) { + return { data: await this.derivativeHistory.list(workspace.id, uuid('product_id', productId)) }; + } + + @Get('products/:productId/image-tasks') + public async listProductImageTasks( + @Param('productId') productId: string, + @CurrentWorkspace() workspace: Workspace, + @Query() query: unknown, + ) { + return this.productImageTasks.list(workspace.id, uuid('product_id', productId), + parse(listProductImageTasksQuerySchema, query)); + } + + @Post('media-assets/:assetId/ai-edits') + public async createAiEdit( + @Param('assetId') assetId: 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.aiEdits.create( + workspace.id, request.auth.userId, uuid('asset_id', assetId), + idempotencyKey(key), parse(createMediaAiEditRequestSchema, body), + ); + response.status(result.status); + return result.body; + } + + @Get('media-ai-edits/:editId') + public async aiEdit( + @Param('editId') editId: string, + @CurrentWorkspace() workspace: Workspace, + ) { + return { data: await this.aiEdits.read(workspace.id, uuid('edit_id', editId)) }; + } + + @Get('media-ai-edits') + public async listAiEdits( + @Query('product_id') productId: string, + @CurrentWorkspace() workspace: Workspace, + ) { + return { data: await this.aiEdits.list(workspace.id, uuid('product_id', productId)) }; + } + + @Post('media-ai-edits/:editId/cancel') + public async cancelAiEdit( + @Param('editId') editId: string, + @Headers('idempotency-key') key: string | undefined, + @Req() request: AuthenticatedRequest, + @CurrentWorkspace() workspace: Workspace, + ) { + return (await this.aiEdits.cancel( + workspace.id, request.auth.userId, uuid('edit_id', editId), idempotencyKey(key), + )).body; + } + + @Post('media-assets/:assetId/watermark-derivatives') + public async createWatermarkDerivative( + @Param('assetId') assetId: 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.watermarkDerivatives.create( + workspace.id, + request.auth.userId, + uuid('asset_id', assetId), + idempotencyKey(key), + parse(createWatermarkDerivativeRequestSchema, body), + ); + response.status(result.status); + return result.body; + } + + @Get('media-watermark-derivatives/:derivativeId') + public async watermarkDerivative( + @Param('derivativeId') derivativeId: string, + @CurrentWorkspace() workspace: Workspace, + ) { + return { data: await this.watermarkDerivatives.read(workspace.id, uuid('derivative_id', derivativeId)) }; + } + + @Post('media-assets/:assetId/aspect-ratio-derivatives') + public async createAspectRatioDerivative( + @Param('assetId') assetId: 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.derivatives.create( + workspace.id, + request.auth.userId, + uuid('asset_id', assetId), + idempotencyKey(key), + parse(createAspectRatioDerivativeRequestSchema, body), + ); + response.status(result.status); + return result.body; + } + + @Get('media-derivatives/:derivativeId') + public async aspectRatioDerivative( + @Param('derivativeId') derivativeId: string, + @CurrentWorkspace() workspace: Workspace, + ) { + return { data: await this.derivatives.read(workspace.id, uuid('derivative_id', derivativeId)) }; + } + @Get('media-assets/product-uploads') @ApiOkResponse({ description: '最近上传的商品图片及短时预览地址' }) public async productUploads(@CurrentWorkspace() workspace: Workspace) { @@ -102,17 +238,18 @@ export class MediaController { } @Post('media-assets/:assetId/access-urls') - @ApiOkResponse({ description: '已签发短时预览 URL' }) + @ApiOkResponse({ description: '已签发短时预览或下载 URL' }) public async accessUrl( @Param('assetId') assetId: string, @Body() body: unknown, @CurrentWorkspace() workspace: Workspace, ) { - parse(createMediaAccessUrlRequestSchema, body); + const input = parse(createMediaAccessUrlRequestSchema, body) as CreateMediaAccessUrlRequest; return { data: await this.media.accessUrl( workspace.id, uuid('asset_id', assetId), + input.purpose, ), }; } diff --git a/apps/api/src/media/media.module.ts b/apps/api/src/media/media.module.ts index 3964bfc..2c303fb 100644 --- a/apps/api/src/media/media.module.ts +++ b/apps/api/src/media/media.module.ts @@ -2,10 +2,15 @@ import { Module } from '@nestjs/common'; import { IdempotencyService } from '../common/idempotency.service.js'; import { MediaController } from './media.controller.js'; import { MediaService } from './media.service.js'; +import { AspectRatioDerivativeService } from './aspect-ratio-derivative.service.js'; +import { WatermarkDerivativeService } from './watermark-derivative.service.js'; +import { MediaAiEditService } from './media-ai-edit.service.js'; +import { ProductImageTaskService } from './product-image-task.service.js'; +import { ProductImageDerivativeHistoryService } from './product-image-derivative-history.service.js'; @Module({ controllers: [MediaController], - providers: [IdempotencyService, MediaService], + providers: [IdempotencyService, MediaService, AspectRatioDerivativeService, WatermarkDerivativeService, MediaAiEditService, ProductImageTaskService, ProductImageDerivativeHistoryService], exports: [MediaService], }) export class MediaModule {} diff --git a/apps/api/src/media/media.service.ts b/apps/api/src/media/media.service.ts index 457b8af..acf9acd 100644 --- a/apps/api/src/media/media.service.ts +++ b/apps/api/src/media/media.service.ts @@ -11,6 +11,7 @@ import { getSignedUrl } from '@aws-sdk/s3-request-presigner'; import { Inject, Injectable } from '@nestjs/common'; import type { CompleteMediaUploadIntentRequest, + CreateMediaAccessUrlRequest, CreateMediaUploadIntentRequest, ImportProductImageRequest, } from '@atlas/contracts'; @@ -263,23 +264,36 @@ export class MediaService { } } - public async accessUrl(workspaceId: string, assetId: string) { + public async accessUrl( + workspaceId: string, + assetId: string, + purpose: CreateMediaAccessUrlRequest['purpose'] = 'preview', + ) { return this.database.workspaceTransaction(workspaceId, async (tx) => { const asset = await tx.query<{ bucket: string; object_key: string; + content_type: string; }>( - `SELECT bucket, object_key FROM atlas.media_assets WHERE id = $1`, + `SELECT bucket, object_key, content_type FROM atlas.media_assets WHERE id = $1`, [assetId], ); if (!asset.rows[0]) throw new ApiError(404, 'RESOURCE_NOT_FOUND', '素材不存在'); + const extension = ({ + 'image/jpeg': 'jpg', + 'image/png': 'png', + 'image/webp': 'webp', + } as Record)[asset.rows[0].content_type] ?? 'bin'; const expiresAt = new Date(Date.now() + 10 * 60_000); const url = await getSignedUrl( this.s3, new GetObjectCommand({ Bucket: asset.rows[0].bucket, Key: asset.rows[0].object_key, + ...(purpose === 'download' ? { + ResponseContentDisposition: `attachment; filename="atlas-image-${assetId}.${extension}"`, + } : {}), }), { expiresIn: 600 }, ); diff --git a/apps/api/src/media/product-image-derivative-history.service.ts b/apps/api/src/media/product-image-derivative-history.service.ts new file mode 100644 index 0000000..ccc151c --- /dev/null +++ b/apps/api/src/media/product-image-derivative-history.service.ts @@ -0,0 +1,81 @@ +import { Inject, Injectable } from '@nestjs/common'; +import type { ProductImageDerivativeRecord } from '@atlas/contracts'; +import type { Database } from '@atlas/database'; +import { ApiError } from '../common/api-error.js'; +import { DATABASE } from '../database/database.module.js'; +import { MediaService } from './media.service.js'; + +type HistoryRow = { + id: string; + kind: ProductImageDerivativeRecord['kind']; + source_asset_id: string; + output_asset_id: string; + created_at: Date | string; + content_type: string; + byte_size: string; + width_px: number | null; + height_px: number | null; + asset_created_at: Date | string; +}; + +@Injectable() +export class ProductImageDerivativeHistoryService { + public constructor( + @Inject(DATABASE) private readonly database: Database, + @Inject(MediaService) private readonly media: MediaService, + ) {} + + /** 只列当前商品成功的派生图;预览地址每次读取时重新签发。 */ + public async list(workspaceId: string, productId: string): Promise { + const rows = await this.database.workspaceTransaction(workspaceId, async (tx) => { + const product = await tx.query<{ id: string }>( + 'SELECT id FROM atlas.products WHERE id = $1', [productId], + ); + if (!product.rows[0]) throw new ApiError(404, 'RESOURCE_NOT_FOUND', '商品不存在'); + const result = await tx.query( + `SELECT d.id, d.kind, d.source_asset_id, d.output_asset_id, d.created_at, + a.content_type, a.byte_size::text, a.width_px, a.height_px, + a.created_at AS asset_created_at + FROM ( + SELECT id, 'aspect_ratio_3_4'::text AS kind, source_asset_id, + output_asset_id, created_at, completed_at + FROM atlas.media_aspect_ratio_derivatives + WHERE product_id = $1 AND status = 'succeeded' + UNION ALL + SELECT id, 'watermark'::text AS kind, source_asset_id, + output_asset_id, created_at, completed_at + 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 + ORDER BY d.completed_at DESC, d.id DESC + LIMIT 100`, + [productId], + ); + return result.rows; + }); + return Promise.all(rows.map(async (row) => { + const access = await this.media.accessUrl(workspaceId, row.output_asset_id); + return { + id: row.id, + kind: row.kind, + source_asset_id: row.source_asset_id, + created_at: isoTime(row.created_at), + output_asset: { + id: row.output_asset_id, + content_type: row.content_type, + byte_size: Number(row.byte_size), + width_px: row.width_px, + height_px: row.height_px, + created_at: isoTime(row.asset_created_at), + preview_url: access.url, + preview_url_expires_at: access.expires_at, + }, + }; + })); + } +} + +function isoTime(value: Date | string) { + return value instanceof Date ? value.toISOString() : String(value); +} diff --git a/apps/api/src/media/product-image-task.service.ts b/apps/api/src/media/product-image-task.service.ts new file mode 100644 index 0000000..a14bba8 --- /dev/null +++ b/apps/api/src/media/product-image-task.service.ts @@ -0,0 +1,106 @@ +import { Inject, Injectable } from '@nestjs/common'; +import type { ListProductImageTasksQuery, ProductImageTask } from '@atlas/contracts'; +import type { Database } from '@atlas/database'; +import { ApiError } from '../common/api-error.js'; +import { DATABASE } from '../database/database.module.js'; + +type AiEditTaskRow = { + id: string; + status: ProductImageTask['status']; + provider_task_id: string | null; + output_asset_id: string | null; + error_code: string | null; + provider_error_raw: string | null; + failure_source: ProductImageTask['failure_source']; + failure_stage: ProductImageTask['failure_stage']; + failure_detail: string | null; + created_at: Date | string; + completed_at: Date | string | null; +}; + +@Injectable() +export class ProductImageTaskService { + public constructor(@Inject(DATABASE) private readonly database: Database) {} + + /** 聚合当前商品的图片任务;新增套图任务时在此追加来源,接口结构保持稳定。 */ + public async list(workspaceId: string, productId: string, query: ListProductImageTasksQuery) { + return this.database.workspaceTransaction(workspaceId, async (tx) => { + const product = await tx.query<{ id: string }>( + 'SELECT id FROM atlas.products WHERE id = $1', [productId], + ); + if (!product.rows[0]) throw new ApiError(404, 'RESOURCE_NOT_FOUND', '商品不存在'); + + const params: unknown[] = [productId]; + let cursorFilter = ''; + if (query.cursor) { + const cursor = decodeCursor(query.cursor); + params.push(cursor.active, cursor.at, cursor.id); + cursorFilter = `AND ((e.status IN ('queued', 'running'))::int, e.created_at, e.id) + < ($2::int, $3::timestamptz, $4::uuid)`; + } + params.push(query.limit + 1); + const edits = await tx.query( + `SELECT e.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, + o.result ->> 'failure_stage' AS failure_stage, + o.result ->> 'failure_detail' AS failure_detail, + e.created_at, e.completed_at + FROM atlas.media_ai_edits e + LEFT JOIN atlas.operations o ON o.id = e.id AND o.workspace_id = e.workspace_id + WHERE e.product_id = $1 ${cursorFilter} + ORDER BY (e.status IN ('queued', 'running')) DESC, e.created_at DESC, e.id DESC + LIMIT $${params.length}`, params, + ); + const hasMore = edits.rows.length > query.limit; + const rows = edits.rows.slice(0, query.limit); + const last = rows.at(-1); + const data: ProductImageTask[] = rows.map((row) => ({ + id: row.id, + kind: 'single_ai_edit' as const, + status: row.status, + phase: row.status === 'running' && row.provider_task_id ? 'waiting_provider' as const : null, + total: 1, + completed: row.status === 'succeeded' ? 1 : 0, + output_asset_ids: row.output_asset_id ? [row.output_asset_id] : [], + error_code: row.error_code, + failure_source: row.status === 'failed' ? row.failure_source ?? null : null, + failure_stage: row.status === 'failed' ? row.failure_stage ?? null : null, + failure_detail: row.status === 'failed' ? row.failure_detail ?? null : null, + provider_error_raw: row.status === 'failed' ? row.provider_error_raw ?? null : null, + created_at: iso(row.created_at), + completed_at: row.completed_at ? iso(row.completed_at) : null, + })); + return { + data, + meta: { + has_more: hasMore, + next_cursor: hasMore && last ? encodeCursor(last) : null, + }, + }; + }); + } +} + +function iso(value: Date | string): string { + return value instanceof Date ? value.toISOString() : value; +} + +function encodeCursor(row: AiEditTaskRow): string { + return Buffer.from(JSON.stringify({ + active: row.status === 'queued' || row.status === 'running' ? 1 : 0, + at: iso(row.created_at), id: row.id, + })).toString('base64url'); +} + +function decodeCursor(value: string): { active: number; at: string; id: string } { + try { + const decoded = JSON.parse(Buffer.from(value, 'base64url').toString('utf8')) as Record; + if ((decoded.active === 0 || decoded.active === 1) + && typeof decoded.at === 'string' && !Number.isNaN(Date.parse(decoded.at)) + && typeof decoded.id === 'string' && /^[0-9a-f-]{36}$/i.test(decoded.id)) { + return { active: decoded.active, at: decoded.at, id: decoded.id }; + } + } catch { /* 非法游标按字段错误返回。 */ } + throw new ApiError(422, 'VALIDATION_FAILED', '分页游标无效'); +} diff --git a/apps/api/src/media/watermark-derivative.service.ts b/apps/api/src/media/watermark-derivative.service.ts new file mode 100644 index 0000000..b9d67d0 --- /dev/null +++ b/apps/api/src/media/watermark-derivative.service.ts @@ -0,0 +1,145 @@ +import { Inject, Injectable } from '@nestjs/common'; +import type { CreateWatermarkDerivativeRequest, WatermarkDerivative } from '@atlas/contracts'; +import type { Database } from '@atlas/database'; +import { ApiError } from '../common/api-error.js'; +import { IdempotencyService } from '../common/idempotency.service.js'; +import { DATABASE } from '../database/database.module.js'; +import { MediaService } from './media.service.js'; + +type DerivativeRow = { + id: string; + status: WatermarkDerivative['status']; + source_asset_id: string; + watermark_template_id: string; + watermark_template_version: number; + output_asset_id: string | null; + error_code: string | null; + content_type: string | null; + byte_size: string | null; + width_px: number | null; + height_px: number | null; + asset_created_at: Date | string | null; +}; + +@Injectable() +export class WatermarkDerivativeService { + public constructor( + @Inject(DATABASE) private readonly database: Database, + @Inject(IdempotencyService) private readonly idempotency: IdempotencyService, + @Inject(MediaService) private readonly media: MediaService, + ) {} + + /** 创建任务时固定模板版本,并与进度和 Outbox 一起提交。 */ + public async create( + workspaceId: string, + userId: string, + assetId: string, + key: string, + input: CreateWatermarkDerivativeRequest, + ) { + return this.idempotency.run({ + workspaceId, + userId, + method: 'POST', + path: `/api/media-assets/${assetId}/watermark-derivatives`, + key, + request: input, + }, 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') + AND byte_size <= 20971520 AND width_px BETWEEN 1 AND 10000 + AND height_px BETWEEN 1 AND 10000`, + [assetId], + ); + if (!source.rows[0]) throw new ApiError(404, 'RESOURCE_NOT_FOUND', '图片素材不存在或不可处理'); + if (input.product_id) { + const product = await tx.query<{ id: string }>( + 'SELECT id FROM atlas.products WHERE id = $1', + [input.product_id], + ); + if (!product.rows[0]) throw new ApiError(404, 'RESOURCE_NOT_FOUND', '商品不存在'); + } + const template = await tx.query<{ id: string; current_version: number }>( + `SELECT id, current_version FROM atlas.watermark_templates + WHERE id = $1 AND status = 'active' AND deleted_at IS NULL`, + [input.watermark_template_id], + ); + if (!template.rows[0]) throw new ApiError(404, 'RESOURCE_NOT_FOUND', '水印模板不存在'); + const version = template.rows[0].current_version; + const created = await tx.query<{ id: string }>( + `INSERT INTO atlas.media_watermark_derivatives + (workspace_id, product_id, source_asset_id, watermark_template_id, + watermark_template_version, created_by_user_id) + VALUES (current_setting('app.workspace_id')::uuid, $1, $2, $3, $4, $5) + RETURNING id`, + [input.product_id ?? null, assetId, input.watermark_template_id, version, userId], + ); + const id = created.rows[0].id; + await tx.query( + `INSERT INTO atlas.operations + (id, workspace_id, kind, resource_type, resource_id, created_by_user_id) + VALUES ($1, current_setting('app.workspace_id')::uuid, + 'media_watermark_derivative', 'media_derivative', $1, $2)`, + [id, userId], + ); + 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, 'media_derivative', $1, + 'media.watermark_derivative.requested', + jsonb_build_object('derivative_id', $1::uuid), $1::text)`, + [id], + ); + return { status: 202, body: { data: { + id, + status: 'queued', + source_asset_id: assetId, + watermark_template_id: input.watermark_template_id, + watermark_template_version: version, + output_asset: null, + error_code: null, + } satisfies WatermarkDerivative } }; + }); + } + + /** 成功结果读取时才签发短时预览地址。 */ + public async read(workspaceId: string, derivativeId: string): Promise { + const row = await this.database.workspaceTransaction(workspaceId, async (tx) => { + const result = await tx.query( + `SELECT d.id, d.status, d.source_asset_id, d.watermark_template_id, + d.watermark_template_version, d.output_asset_id, d.error_code, + 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 + 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 + ? await this.media.accessUrl(workspaceId, row.output_asset_id) + : null; + return { + id: row.id, + status: row.status, + source_asset_id: row.source_asset_id, + watermark_template_id: row.watermark_template_id, + watermark_template_version: row.watermark_template_version, + error_code: row.error_code, + output_asset: row.output_asset_id && access ? { + id: row.output_asset_id, + content_type: row.content_type!, + byte_size: Number(row.byte_size), + width_px: row.width_px, + height_px: row.height_px, + created_at: row.asset_created_at instanceof Date + ? row.asset_created_at.toISOString() : String(row.asset_created_at), + preview_url: access.url, + preview_url_expires_at: access.expires_at, + } : null, + }; + } +} diff --git a/apps/api/src/online-products/online-products.controller.ts b/apps/api/src/online-products/online-products.controller.ts new file mode 100644 index 0000000..a480b31 --- /dev/null +++ b/apps/api/src/online-products/online-products.controller.ts @@ -0,0 +1,191 @@ +import { + Body, + Controller, + Get, + Headers, + Inject, + Param, + Post, + Query, + Req, + Res, +} from '@nestjs/common'; +import { + ApiAcceptedResponse, + ApiBearerAuth, + ApiOkResponse, + ApiOperation, + ApiTags, +} from '@nestjs/swagger'; +import { z } from 'zod'; +import { + listOnlineProductsQuerySchema, + listOnlineProductSyncRunsQuerySchema, + onlineProductSyncRequestSchema, + type Workspace, +} from '@atlas/contracts'; +import type { AuthenticatedRequest } from '../auth/auth.guard.js'; +import { ApiError } from '../common/api-error.js'; +import { CurrentWorkspace } from '../workspace/current-workspace.decorator.js'; +import { OnlineProductsService } from './online-products.service.js'; + +type HttpResponse = { status: (status: number) => HttpResponse }; + +@ApiTags('Online products') +@ApiBearerAuth('access-token') +@Controller('platform-listings') +export class OnlineProductsController { + public constructor( + @Inject(OnlineProductsService) + private readonly service: OnlineProductsService, + ) {} + + @Get() + @ApiOperation({ summary: '按店铺和 SKU 查询 Ozon 商品镜像' }) + @ApiOkResponse({ description: '商品列表及最近同步状态' }) + public list( + @Headers('x-atlas-platform-id') header: string | undefined, + @CurrentWorkspace() workspace: Workspace, + @Query() query: unknown, + ) { + return this.service.list( + workspace.id, + platformId(header), + parse(listOnlineProductsQuerySchema, query), + ); + } + + @Get(':listingId') + @ApiOperation({ summary: '读取一个在线 SKU 的业务摘要' }) + public async get( + @Headers('x-atlas-platform-id') header: string | undefined, + @CurrentWorkspace() workspace: Workspace, + @Param('listingId') listingId: string, + ) { + return { + data: await this.service.get( + workspace.id, + platformId(header), + uuid('listing_id', listingId), + ), + }; + } +} + +@ApiTags('Online product sync') +@ApiBearerAuth('access-token') +@Controller('stores/:storeId/platform-product-sync-runs') +export class StoreOnlineProductSyncController { + public constructor( + @Inject(OnlineProductsService) + private readonly service: OnlineProductsService, + ) {} + + @Post() + @ApiOperation({ summary: '手动发起该店铺的全量 Ozon 商品同步' }) + @ApiAcceptedResponse({ description: '已创建异步同步任务' }) + public async create( + @Headers('x-atlas-platform-id') header: string | undefined, + @Headers('idempotency-key') key: string | undefined, + @Param('storeId') storeId: string, + @Body() body: unknown, + @Req() request: AuthenticatedRequest, + @CurrentWorkspace() workspace: Workspace, + @Res({ passthrough: true }) response: HttpResponse, + ) { + parse(onlineProductSyncRequestSchema, body ?? {}); + const result = await this.service.createSync( + workspace.id, + request.auth.userId, + platformId(header), + uuid('store_id', storeId), + requireKey(key), + ); + response.status(result.status); + return result.body; + } + + @Get() + @ApiOperation({ summary: '查询店铺同步历史' }) + public list( + @Headers('x-atlas-platform-id') header: string | undefined, + @CurrentWorkspace() workspace: Workspace, + @Param('storeId') storeId: string, + @Query() query: unknown, + ) { + return this.service.listRuns( + workspace.id, + platformId(header), + uuid('store_id', storeId), + parse(listOnlineProductSyncRunsQuerySchema, query), + ); + } +} + +@ApiTags('Online product sync') +@ApiBearerAuth('access-token') +@Controller('platform-product-sync-runs') +export class OnlineProductSyncRunsController { + public constructor( + @Inject(OnlineProductsService) + private readonly service: OnlineProductsService, + ) {} + + @Get(':runId') + @ApiOperation({ summary: '查询一次在线商品同步的进度和结果' }) + public async get( + @Headers('x-atlas-platform-id') header: string | undefined, + @CurrentWorkspace() workspace: Workspace, + @Param('runId') runId: string, + ) { + return { + data: await this.service.getRun( + workspace.id, + platformId(header), + uuid('sync_run_id', runId), + ), + }; + } +} + +function platformId(value: string | undefined): string { + if (!value) + throw new ApiError( + 400, + 'PLATFORM_CONTEXT_REQUIRED', + '请求缺少 X-Atlas-Platform-Id', + ); + return uuid('X-Atlas-Platform-Id', value); +} + +function requireKey(value: string | undefined): string { + if (!value) + throw new ApiError( + 400, + 'IDEMPOTENCY_KEY_REQUIRED', + '请求缺少 Idempotency-Key', + ); + return uuid('Idempotency-Key', value); +} + +function uuid(field: string, value: string): string { + if (z.uuid().safeParse(value).success) return value; + throw new ApiError(422, 'VALIDATION_FAILED', '请求参数不符合要求', [ + { field, code: 'INVALID_FIELD', message: `${field} 必须是 UUID` }, + ]); +} + +function parse(schema: z.ZodType, value: unknown): T { + const result = schema.safeParse(value); + if (result.success) return result.data; + throw new ApiError( + 422, + 'VALIDATION_FAILED', + '请求参数不符合要求', + result.error.issues.map((issue) => ({ + field: issue.path.join('.'), + code: 'INVALID_FIELD', + message: issue.message, + })), + ); +} diff --git a/apps/api/src/online-products/online-products.module.ts b/apps/api/src/online-products/online-products.module.ts new file mode 100644 index 0000000..a3444b3 --- /dev/null +++ b/apps/api/src/online-products/online-products.module.ts @@ -0,0 +1,18 @@ +import { Module } from '@nestjs/common'; +import { IdempotencyService } from '../common/idempotency.service.js'; +import { + OnlineProductsController, + OnlineProductSyncRunsController, + StoreOnlineProductSyncController, +} from './online-products.controller.js'; +import { OnlineProductsService } from './online-products.service.js'; + +@Module({ + controllers: [ + OnlineProductsController, + StoreOnlineProductSyncController, + OnlineProductSyncRunsController, + ], + providers: [OnlineProductsService, IdempotencyService], +}) +export class OnlineProductsModule {} diff --git a/apps/api/src/online-products/online-products.service.ts b/apps/api/src/online-products/online-products.service.ts new file mode 100644 index 0000000..aa16a15 --- /dev/null +++ b/apps/api/src/online-products/online-products.service.ts @@ -0,0 +1,415 @@ +import { Inject, Injectable } from '@nestjs/common'; +import type { Database, DatabaseTransaction } from '@atlas/database'; +import type { + ListOnlineProductsQuery, + ListOnlineProductSyncRunsQuery, + OnlineProduct, + OnlineProductSyncRun, + OnlineProductsMeta, +} from '@atlas/contracts'; +import { ApiError } from '../common/api-error.js'; +import { IdempotencyService } from '../common/idempotency.service.js'; +import { DATABASE } from '../database/database.module.js'; + +type StoreRow = { + id: string; + name: string; + platform_id: string; + status: string; + credential_status: string; +}; +type ListingRow = Omit< + OnlineProduct, + | 'image' + | 'stock_summary' + | 'last_synced_at' + | 'missing_since' + | 'archived_at' +> & { + image_summary: OnlineProduct['image']; + stock_summary: OnlineProduct['stock_summary']; + last_synced_at: Date | string; + missing_since: Date | string | null; + archived_at: Date | string | null; + updated_at: Date | string; +}; +type RunRow = Omit< + OnlineProductSyncRun, + 'created_at' | 'started_at' | 'completed_at' | 'error' +> & { + created_at: Date | string; + started_at: Date | string | null; + completed_at: Date | string | null; + error_payload: { code?: string; message?: string } | null; +}; + +const listingSelect = `SELECT l.id, l.store_id, l.platform_id, l.remote_product_id, l.offer_id, + l.remote_sku, l.name, l.currency_code, round(l.selling_price, 2)::numeric(18,2)::text AS selling_price, + round(l.list_price, 2)::numeric(18,2)::text AS list_price, l.visible, l.remote_state, l.moderation_status, + l.validation_status, l.stock_summary, l.image_summary, l.error_payload AS errors, + l.visibility_details, l.source, l.last_synced_at, l.missing_since, l.archived_at, l.updated_at, + CASE WHEN origin.id IS NULL THEN NULL ELSE jsonb_build_object( + 'collection_item_id', product.collection_item_id, 'product_id', origin.product_id, + 'variant_id', origin.product_variant_id) END AS atlas_origin + FROM atlas.platform_listings l + LEFT JOIN atlas.product_variant_platform_listings origin ON origin.id = l.atlas_variant_listing_id + LEFT JOIN atlas.products product ON product.id = origin.product_id`; + +const runSelect = `SELECT id, store_id, status, trigger_source, cursor_started, cursor_finished, + listed_count, upserted_count, failed_count, created_at, started_at, completed_at, error_payload + FROM atlas.platform_product_sync_runs`; + +@Injectable() +export class OnlineProductsService { + public constructor( + @Inject(DATABASE) private readonly database: Database, + @Inject(IdempotencyService) + private readonly idempotency: IdempotencyService, + ) {} + + public list( + workspaceId: string, + platformId: string, + query: ListOnlineProductsQuery, + ) { + return this.database.workspaceTransaction(workspaceId, async (tx) => { + const store = await this.resolveStore( + tx, + platformId, + query.store_id, + ); + const params: unknown[] = [store.id, platformId]; + const filters = ['l.store_id = $1', 'l.platform_id = $2']; + if (!query.include_archived) filters.push('l.archived_at IS NULL'); + if (query.q) { + params.push(`%${query.q.replace(/[\\%_]/g, '\\$&')}%`); + filters.push( + `(l.name ILIKE $${params.length} ESCAPE '\\' OR l.offer_id ILIKE $${params.length} ESCAPE '\\' OR l.remote_product_id ILIKE $${params.length} ESCAPE '\\' OR l.remote_sku ILIKE $${params.length} ESCAPE '\\')`, + ); + } + if (query.visibility !== 'all') { + filters.push( + query.visibility === 'unknown' + ? 'l.visible IS NULL' + : `l.visible = ${query.visibility === 'visible' ? 'true' : 'false'}`, + ); + } + if (query.source) { + params.push(query.source); + filters.push(`l.source = $${params.length}`); + } + if (query.moderation_status) { + params.push(query.moderation_status); + filters.push(`l.moderation_status = $${params.length}`); + } + if (query.cursor) { + const cursor = decodeCursor(query.cursor); + params.push(cursor.at, cursor.id); + filters.push( + `(l.updated_at, l.id) < ($${params.length - 1}::timestamptz, $${params.length}::uuid)`, + ); + } + params.push(query.limit + 1); + const result = await tx.query( + `${listingSelect} WHERE ${filters.join(' AND ')} + ORDER BY l.updated_at DESC, l.id DESC LIMIT $${params.length}`, + params, + ); + const hasMore = result.rows.length > query.limit; + const rows = result.rows.slice(0, query.limit); + const last = rows.at(-1); + const sync = await tx.query<{ + last_successful_sync_at: Date | string | null; + last_sync_status: OnlineProductsMeta['last_sync_status']; + }>( + `SELECT (SELECT completed_at FROM atlas.platform_product_sync_runs + WHERE store_id = $1 AND status = 'succeeded' ORDER BY completed_at DESC LIMIT 1) AS last_successful_sync_at, + (SELECT status FROM atlas.platform_product_sync_runs + WHERE store_id = $1 ORDER BY created_at DESC, id DESC LIMIT 1) AS last_sync_status`, + [store.id], + ); + return { + data: rows.map(toListing), + meta: { + next_cursor: + hasMore && last + ? encodeCursor(last.updated_at, last.id) + : null, + has_more: hasMore, + store: { id: store.id, name: store.name }, + last_successful_sync_at: iso( + sync.rows[0]?.last_successful_sync_at ?? null, + ), + last_sync_status: sync.rows[0]?.last_sync_status ?? null, + } satisfies OnlineProductsMeta, + }; + }); + } + + public get(workspaceId: string, platformId: string, listingId: string) { + return this.database.workspaceTransaction(workspaceId, async (tx) => { + await this.assertPlatform(tx, platformId); + const result = await tx.query( + `${listingSelect} WHERE l.id = $1 AND l.platform_id = $2`, + [listingId, platformId], + ); + if (!result.rows[0]) + throw new ApiError(404, 'LISTING_NOT_FOUND', '在线商品不存在'); + return toListing(result.rows[0]); + }); + } + + public async createSync( + workspaceId: string, + userId: string, + platformId: string, + storeId: string, + key: string, + ) { + try { + return await this.idempotency.run( + { + workspaceId, + userId, + method: 'POST', + path: `/api/stores/${storeId}/platform-product-sync-runs`, + key, + request: { + reason: 'manual_refresh', + platform_id: platformId, + }, + }, + async (tx) => { + const store = await this.resolveStore( + tx, + platformId, + storeId, + ); + const platform = await tx.query<{ code: string }>( + `SELECT code FROM atlas.platforms WHERE id = $1`, + [platformId], + ); + if (platform.rows[0]?.code.toUpperCase() !== 'OZON') + throw new ApiError( + 422, + 'PLATFORM_UNAVAILABLE', + '当前平台暂不支持商品同步', + ); + if ( + store.status !== 'active' || + store.credential_status !== 'valid' + ) + throw new ApiError( + 422, + 'STORE_CREDENTIAL_UNAVAILABLE', + '店铺凭据暂不可用于同步', + ); + const secret = await tx.query<{ id: string }>( + `SELECT id FROM atlas.store_secrets WHERE store_id = $1 AND kind = 'seller_api_key' AND revoked_at IS NULL`, + [store.id], + ); + if (!secret.rows[0]) + throw new ApiError( + 422, + 'STORE_CREDENTIAL_UNAVAILABLE', + '店铺凭据暂不可用于同步', + ); + const active = await tx.query<{ id: string }>( + `SELECT id FROM atlas.platform_product_sync_runs WHERE store_id = $1 AND status IN ('queued', 'running') FOR UPDATE`, + [store.id], + ); + if (active.rows[0]) + throw new ApiError( + 409, + 'SYNC_ALREADY_RUNNING', + '该店铺已有正在进行的商品同步', + [{ sync_run_id: active.rows[0].id }], + ); + const created = await tx.query( + `INSERT INTO atlas.platform_product_sync_runs (workspace_id, store_id, platform_id, trigger_source) + VALUES (current_setting('app.workspace_id')::uuid, $1, $2, 'manual') RETURNING id, store_id, + status, trigger_source, cursor_started, cursor_finished, listed_count, upserted_count, + failed_count, created_at, started_at, completed_at, error_payload`, + [store.id, platformId], + ); + const run = toRun(created.rows[0]); + 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, 'platform_product_sync_run', $1, + 'platform_product_sync.requested', $2, $1::uuid::text)`, + [ + run.id, + { + sync_run_id: run.id, + store_id: store.id, + platform_id: platformId, + secret_id: secret.rows[0].id, + }, + ], + ); + return { status: 202, body: { data: { sync_run: run } } }; + }, + ); + } catch (error) { + if ( + typeof error === 'object' && + error !== null && + 'constraint' in error && + error.constraint === 'ux_platform_product_sync_runs_active' + ) { + const active = await this.database.workspaceTransaction( + workspaceId, + (tx) => + tx.query<{ id: string }>( + `SELECT id FROM atlas.platform_product_sync_runs WHERE store_id = $1 AND status IN ('queued', 'running') ORDER BY created_at DESC LIMIT 1`, + [storeId], + ), + ); + throw new ApiError( + 409, + 'SYNC_ALREADY_RUNNING', + '该店铺已有正在进行的商品同步', + active.rows[0] ? [{ sync_run_id: active.rows[0].id }] : [], + ); + } + throw error; + } + } + + public getRun(workspaceId: string, platformId: string, runId: string) { + return this.database.workspaceTransaction(workspaceId, async (tx) => { + await this.assertPlatform(tx, platformId); + const result = await tx.query( + `${runSelect} WHERE id = $1 AND platform_id = $2`, + [runId, platformId], + ); + if (!result.rows[0]) + throw new ApiError(404, 'SYNC_RUN_NOT_FOUND', '同步记录不存在'); + return toRun(result.rows[0]); + }); + } + + public listRuns( + workspaceId: string, + platformId: string, + storeId: string, + query: ListOnlineProductSyncRunsQuery, + ) { + return this.database.workspaceTransaction(workspaceId, async (tx) => { + await this.resolveStore(tx, platformId, storeId); + const cursor = query.cursor ? decodeCursor(query.cursor) : null; + const result = await tx.query( + `${runSelect} WHERE store_id = $1 AND platform_id = $2 + AND ($3::timestamptz IS NULL OR (created_at, id) < ($3::timestamptz, $4::uuid)) + ORDER BY created_at DESC, id DESC LIMIT $5`, + [ + storeId, + platformId, + cursor?.at ?? null, + cursor?.id ?? null, + query.limit + 1, + ], + ); + const hasMore = result.rows.length > query.limit; + const rows = result.rows.slice(0, query.limit); + const last = rows.at(-1); + return { + data: rows.map(toRun), + meta: { + next_cursor: + hasMore && last + ? encodeCursor(last.created_at, last.id) + : null, + has_more: hasMore, + }, + }; + }); + } + + private async assertPlatform(tx: DatabaseTransaction, platformId: string) { + const platform = await tx.query<{ id: string }>( + `SELECT id FROM atlas.platforms WHERE id = $1 AND status = 'active'`, + [platformId], + ); + if (!platform.rows[0]) + throw new ApiError(422, 'PLATFORM_UNAVAILABLE', '当前平台不可用'); + } + + private async resolveStore( + tx: DatabaseTransaction, + platformId: string, + storeId?: string, + ): Promise { + await this.assertPlatform(tx, platformId); + const result = await tx.query( + `SELECT id, name, platform_id, status, credential_status FROM atlas.stores + WHERE platform_id = $1 AND deleted_at IS NULL AND ${storeId ? 'id = $2' : 'is_default = true'}`, + storeId ? [platformId, storeId] : [platformId], + ); + if (!result.rows[0]) + throw new ApiError( + storeId ? 404 : 422, + storeId ? 'STORE_NOT_FOUND' : 'DEFAULT_STORE_REQUIRED', + storeId ? '店铺不存在' : '请先设置当前平台的默认店铺', + ); + return result.rows[0]; + } +} + +function toListing(row: ListingRow): OnlineProduct { + const { image_summary, updated_at: _updatedAt, ...rest } = row; + return { + ...rest, + stock_summary: row.stock_summary ?? { total: 0, has_data: false }, + image: image_summary ?? { url: null, width_px: null, height_px: null }, + errors: row.errors ?? [], + visibility_details: row.visibility_details ?? {}, + last_synced_at: iso(row.last_synced_at)!, + missing_since: iso(row.missing_since), + archived_at: iso(row.archived_at), + }; +} + +function toRun(row: RunRow): OnlineProductSyncRun { + const { error_payload, ...rest } = row; + return { + ...rest, + created_at: iso(row.created_at)!, + started_at: iso(row.started_at), + completed_at: iso(row.completed_at), + error: error_payload + ? { + code: error_payload.code ?? 'SYNC_FAILED', + message: error_payload.message ?? '商品同步失败', + } + : null, + }; +} + +function iso(value: Date | string | null): string | null { + return value ? new Date(value).toISOString() : null; +} + +function encodeCursor(at: Date | string, id: string): string { + return Buffer.from(JSON.stringify({ at: iso(at), id })).toString( + 'base64url', + ); +} + +function decodeCursor(value: string): { at: string; id: string } { + try { + const decoded = JSON.parse( + Buffer.from(value, 'base64url').toString('utf8'), + ) as { at?: unknown; id?: unknown }; + if ( + typeof decoded.at === 'string' && + !Number.isNaN(Date.parse(decoded.at)) && + typeof decoded.id === 'string' && + /^[0-9a-f-]{36}$/i.test(decoded.id) + ) + return { at: decoded.at, id: decoded.id }; + } catch { + /* 非法游标统一转成字段校验错误。 */ + } + throw new ApiError(422, 'VALIDATION_FAILED', '分页游标无效'); +} diff --git a/apps/api/src/product-editor/product-editor.service.ts b/apps/api/src/product-editor/product-editor.service.ts index 0ac1a92..bb3827a 100644 --- a/apps/api/src/product-editor/product-editor.service.ts +++ b/apps/api/src/product-editor/product-editor.service.ts @@ -237,7 +237,7 @@ export class ProductEditorService { 'missing' | 'ambiguous' | 'confirmed' | null; }>( `SELECT v.id, v.source_variant_key, v.source_sku, v.display_name, v.sort_order, - v.purchase_cost_cny::text AS purchase_cost_cny, l.id AS listing_id, l.offer_id, + 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.list_price::text, l.currency_code, v.specification_values, v.physical_length_mm::text, v.physical_width_mm::text, v.physical_height_mm::text, @@ -1395,6 +1395,21 @@ export class ProductEditorService { ) : { rows: [] }; const importedIds = new Set(imported.rows.map((item) => item.asset_id)); + const processed = assetIds.length + ? await transaction.query<{ asset_id: string }>( + `SELECT output_asset_id AS asset_id + FROM atlas.media_aspect_ratio_derivatives + WHERE product_id = $1 AND status = 'succeeded' + AND output_asset_id = ANY($2::uuid[]) + UNION + SELECT output_asset_id AS asset_id + FROM atlas.media_watermark_derivatives + WHERE product_id = $1 AND status = 'succeeded' + AND output_asset_id = ANY($2::uuid[])`, + [input.product.id, assetIds], + ) + : { rows: [] }; + const processedIds = new Set(processed.rows.map((item) => item.asset_id)); const candidateVariants = new Map>(); for (const candidate of candidates.rows) { const variants = @@ -1435,6 +1450,7 @@ export class ProductEditorService { continue; if (uploadedIds.has(assetId)) continue; if (importedIds.has(assetId)) continue; + if (processedIds.has(assetId)) continue; const allowedFor = candidateVariants.get(assetId); if ( !allowedFor?.has(null) && @@ -1443,7 +1459,7 @@ export class ProductEditorService { throw new ApiError( 422, 'LISTING_MEDIA_NOT_SELECTABLE', - '只能选择已上传、已导入或当前采集快照中该 SKU 可用的已转存图片', + '只能选择当前商品可用的采集、上传、导入或图片处理结果', ); } } diff --git a/apps/api/src/watermarks/watermarks.controller.ts b/apps/api/src/watermarks/watermarks.controller.ts index 9d295d9..958149a 100644 --- a/apps/api/src/watermarks/watermarks.controller.ts +++ b/apps/api/src/watermarks/watermarks.controller.ts @@ -28,6 +28,12 @@ export class WatermarksController { return this.watermarks.list(workspace.id, parse(listWatermarkTemplatesQuerySchema, query)); } + @Get('watermark-templates/default') + @ApiOperation({ summary: '读取当前工作空间的默认水印模板' }) + public async getDefault(@CurrentWorkspace() workspace: Workspace) { + return { data: await this.watermarks.getDefault(workspace.id) }; + } + @Get('watermark-templates/:templateId') @ApiOperation({ summary: '读取水印模板当前版本' }) public async get(@Param('templateId') templateId: string, @CurrentWorkspace() workspace: Workspace) { @@ -51,6 +57,15 @@ export class WatermarksController { idempotencyKey(key), revision(ifMatch), parse(updateWatermarkTemplateRequestSchema, body))).body; } + @Put('watermark-templates/:templateId/default') + @ApiOkResponse({ description: '已设为当前工作空间的默认水印' }) + public async setDefault(@Param('templateId') templateId: string, + @Headers('idempotency-key') key: string | undefined, + @Req() request: AuthenticatedRequest, @CurrentWorkspace() workspace: Workspace) { + return (await this.watermarks.setDefault(workspace.id, request.auth.userId, + uuid('template_id', templateId), idempotencyKey(key))).body; + } + @Delete('watermark-templates/:templateId') @HttpCode(204) @ApiNoContentResponse({ description: '已软删除模板并解除当前店铺绑定' }) diff --git a/apps/api/src/watermarks/watermarks.service.ts b/apps/api/src/watermarks/watermarks.service.ts index 5038563..0ab2aaa 100644 --- a/apps/api/src/watermarks/watermarks.service.ts +++ b/apps/api/src/watermarks/watermarks.service.ts @@ -1,4 +1,9 @@ import { Inject, Injectable } from '@nestjs/common'; +import { + WATERMARK_BORDER_RADIUS_MAX_PX, + WATERMARK_IMAGE_SIZE_MAX_PX, + WATERMARK_IMAGE_SIZE_MIN_PX, +} from '@atlas/contracts'; import type { CreateWatermarkTemplateRequest, ListWatermarkTemplatesQuery, @@ -13,6 +18,7 @@ type TemplateRow = { id: string; name: string; kind: 'text' | 'image'; + is_default: boolean; current_version: number; created_at: Date | string; updated_at: Date | string; @@ -23,7 +29,11 @@ type TemplateRow = { font_size_px: number | null; opacity: string; scale_ratio: string | null; + image_size_px: number | null; + border_radius_px: number; position: string; + position_x: string; + position_y: string; }; type TemplateConfig = Record; @@ -81,6 +91,59 @@ export class WatermarksService { ); } + public async getDefault(workspaceId: string) { + return this.database.workspaceTransaction(workspaceId, async (tx) => { + const rows = await tx.query( + `${templateSelect} WHERE template.is_default = true + AND template.status = 'active' AND template.deleted_at IS NULL`, + ); + return rows.rows[0] ? toTemplate(rows.rows[0]) : null; + }); + } + + public async setDefault( + workspaceId: string, + userId: string, + templateId: string, + idempotencyKey: string, + ) { + return this.idempotency.run( + { + workspaceId, + userId, + method: 'PUT', + path: `/api/watermark-templates/${templateId}/default`, + key: idempotencyKey, + request: {}, + }, + async (tx) => { + // 同一工作空间的切换操作串行化,避免两个请求同时争用唯一默认索引。 + await tx.query( + `SELECT id FROM atlas.workspaces + WHERE id = current_setting('app.workspace_id')::uuid FOR UPDATE`, + ); + const current = await this.lockTemplate(tx, templateId); + if (!current.is_default) { + await tx.query( + `UPDATE atlas.watermark_templates + SET is_default = false + WHERE is_default = true AND status = 'active' AND deleted_at IS NULL`, + ); + await tx.query( + `UPDATE atlas.watermark_templates + SET is_default = true + WHERE id = $1`, + [templateId], + ); + } + return { + status: 200, + body: { data: await this.requireTemplate(tx, templateId) }, + }; + }, + ); + } + public async create( workspaceId: string, userId: string, @@ -97,7 +160,8 @@ export class WatermarksService { request: input, }, async (tx) => { - await this.validateConfig(tx, input.kind, input.config); + const config = normalizeImageConfig(input.kind, normalizePlacement(input.config), input.config); + await this.validateConfig(tx, input.kind, config); const inserted = await tx.query<{ id: string }>( `INSERT INTO atlas.watermark_templates (workspace_id, name, kind) VALUES (current_setting('app.workspace_id')::uuid, $1, $2) RETURNING id`, @@ -108,7 +172,7 @@ export class WatermarksService { inserted.rows[0].id, 1, input.kind, - input.config, + config, userId, ); return { @@ -158,10 +222,10 @@ export class WatermarksService { ); } // 同类型编辑允许只提交变化字段;切换类型时旧类型字段不会通过新类型校验。 - const nextConfig = normalizeConfig(nextKind, { + const nextConfig = normalizeConfig(nextKind, normalizeImageConfig(nextKind, normalizePlacement({ ...configFromRow(current), ...(input.config ?? {}), - }); + }, input.config), input.config)); await this.validateConfig(tx, nextKind, nextConfig); const renderingChanged = input.config !== undefined || nextKind !== current.kind; @@ -217,7 +281,7 @@ export class WatermarksService { [templateId], ); await tx.query( - `UPDATE atlas.watermark_templates SET status = 'archived', deleted_at = now(), + `UPDATE atlas.watermark_templates SET status = 'archived', deleted_at = now(), is_default = false, revision = revision + 1, updated_at = now() WHERE id = $1`, [templateId], ); @@ -312,7 +376,9 @@ export class WatermarksService { ) { if ( typeof config.opacity !== 'string' || - typeof config.position !== 'string' + typeof config.position !== 'string' || + !validCoordinate(config.position_x) || + !validCoordinate(config.position_y) ) { throw new ApiError(422, 'VALIDATION_FAILED', '水印配置不完整'); } @@ -344,7 +410,9 @@ export class WatermarksService { const assetId = config.image_asset_id; if ( typeof assetId !== 'string' || - typeof config.scale_ratio !== 'string' + typeof config.scale_ratio !== 'string' || + !validImageSize(config.image_size_px) || + !validBorderRadius(config.border_radius_px) ) { throw new ApiError(422, 'VALIDATION_FAILED', '图片水印配置不完整'); } @@ -371,8 +439,8 @@ export class WatermarksService { ) { await tx.query( `INSERT INTO atlas.watermark_template_versions - (workspace_id, template_id, version, kind, text_content, image_asset_id, font_code, font_size_px, opacity, scale_ratio, position, created_by_user_id) - VALUES (current_setting('app.workspace_id')::uuid, $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)`, + (workspace_id, template_id, version, kind, text_content, image_asset_id, font_code, font_size_px, opacity, scale_ratio, position_x, position_y, position, created_by_user_id, image_size_px, border_radius_px) + VALUES (current_setting('app.workspace_id')::uuid, $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15)`, [ templateId, version, @@ -383,17 +451,23 @@ export class WatermarksService { config.font_size_px ?? null, config.opacity, config.scale_ratio ?? null, + config.position_x, + config.position_y, config.position, userId, + config.image_size_px ?? null, + config.border_radius_px ?? 0, ], ); } } -const templateSelect = `SELECT template.id, template.name, template.kind, template.current_version, +const templateSelect = `SELECT template.id, template.name, template.kind, template.is_default, template.current_version, template.created_at, template.updated_at, template.revision, version.text_content, version.image_asset_id, version.font_code, version.font_size_px, version.opacity::text AS opacity, version.scale_ratio::text AS scale_ratio, - version.position FROM atlas.watermark_templates template JOIN atlas.watermark_template_versions version + version.image_size_px, version.border_radius_px, + version.position, version.position_x::text AS position_x, version.position_y::text AS position_y + FROM atlas.watermark_templates template JOIN atlas.watermark_template_versions version ON version.template_id = template.id AND version.version = template.current_version`; function toTemplate(row: TemplateRow) { @@ -401,6 +475,7 @@ function toTemplate(row: TemplateRow) { id: row.id, name: row.name, kind: row.kind, + is_default: row.is_default, current_version: row.current_version, config: configFromRow(row), created_at: iso(row.created_at), @@ -416,12 +491,18 @@ function configFromRow(row: TemplateRow): TemplateConfig { font_size_px: row.font_size_px, opacity: row.opacity, position: row.position, + position_x: Number(row.position_x), + position_y: Number(row.position_y), } : { image_asset_id: row.image_asset_id, scale_ratio: row.scale_ratio, + image_size_px: row.image_size_px, + border_radius_px: row.border_radius_px, opacity: row.opacity, position: row.position, + position_x: Number(row.position_x), + position_y: Number(row.position_y), }; } function normalizeConfig( @@ -435,14 +516,94 @@ function normalizeConfig( font_size_px: config.font_size_px, opacity: config.opacity, position: config.position, + position_x: config.position_x, + position_y: config.position_y, } : { image_asset_id: config.image_asset_id, scale_ratio: config.scale_ratio, + image_size_px: config.image_size_px, + border_radius_px: config.border_radius_px, opacity: config.opacity, position: config.position, + position_x: config.position_x, + position_y: config.position_y, }; } + +const legacyPositions = [ + 'top_left', 'top_center', 'top_right', + 'middle_left', 'center', 'middle_right', + 'bottom_left', 'bottom_center', 'bottom_right', +] as const; + +function validCoordinate(value: unknown): value is number { + return typeof value === 'number' && Number.isFinite(value) && value >= 0 && value <= 1; +} + +function validImageSize(value: unknown): value is number { + return Number.isInteger(value) && Number(value) >= WATERMARK_IMAGE_SIZE_MIN_PX + && Number(value) <= WATERMARK_IMAGE_SIZE_MAX_PX; +} + +function validBorderRadius(value: unknown): value is number { + return Number.isInteger(value) && Number(value) >= 0 + && Number(value) <= WATERMARK_BORDER_RADIUS_MAX_PX; +} + +function legacyImageSize(value: unknown): number | undefined { + const ratio = Number(value); + if (!Number.isFinite(ratio)) return undefined; + return Math.min(WATERMARK_IMAGE_SIZE_MAX_PX, + Math.max(WATERMARK_IMAGE_SIZE_MIN_PX, Math.round(ratio * 144))); +} + +function normalizeImageConfig( + kind: 'text' | 'image', config: TemplateConfig, patch?: Record, +): TemplateConfig { + if (kind !== 'image') return config; + const legacyOnly = patch?.scale_ratio !== undefined && patch.image_size_px === undefined; + const size = legacyOnly + ? legacyImageSize(patch?.scale_ratio) + : config.image_size_px ?? legacyImageSize(config.scale_ratio); + const scaleRatio = legacyOnly || !validImageSize(size) + ? config.scale_ratio + : String(Number(Math.max(0.1, size / 144).toFixed(4))); + return { + ...config, + image_size_px: size, + border_radius_px: config.border_radius_px ?? 0, + // 旧比例字段只供旧客户端读取;像素尺寸决定新预览与后续渲染。 + scale_ratio: scaleRatio, + }; +} + +function legacyCoordinates(position: unknown) { + const index = legacyPositions.indexOf(position as typeof legacyPositions[number]); + if (index < 0) return null; + return { x: (index % 3) / 2, y: Math.floor(index / 3) / 2 }; +} + +function normalizePlacement(config: TemplateConfig, patch?: Record): TemplateConfig { + const legacyOnly = patch?.position !== undefined + && patch.position_x === undefined && patch.position_y === undefined; + const legacy = legacyCoordinates(config.position); + const x = legacyOnly ? legacy?.x : config.position_x ?? legacy?.x; + const y = legacyOnly ? legacy?.y : config.position_y ?? legacy?.y; + if (!validCoordinate(x) || !validCoordinate(y)) return { ...config, position_x: x, position_y: y }; + const positionX = Math.round(x * 10_000) / 10_000; + const positionY = Math.round(y * 10_000) / 10_000; + const col = Math.min(2, Math.floor(positionX * 3)); + const row = Math.min(2, Math.floor(positionY * 3)); + return { + ...config, + position_x: positionX, + position_y: positionY, + // 旧字段仅供兼容展示;精确坐标始终决定实际水印位置。 + position: legacyPositions[row * 3 + col], + }; +} + function iso(value: Date | string) { return value instanceof Date ? value.toISOString() : value; } diff --git a/apps/api/test/extension.e2e.test.ts b/apps/api/test/extension.e2e.test.ts index e148009..1e6e3eb 100644 --- a/apps/api/test/extension.e2e.test.ts +++ b/apps/api/test/extension.e2e.test.ts @@ -179,6 +179,69 @@ 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 source1688 = { + source: { + platform: '1688', itemKey: '1688-price-image-test', + canonicalUrl: 'https://detail.1688.com/offer/123456789.html', + collectedAt: new Date().toISOString(), parserVersion: '1688-test', + }, + common: { attributes: [], categories: [] }, + variantAxes: [], + variants: [{ + sourceVariantKey: '6100454176961', sourceSku: '6100454176961', + displayName: '六代目卡卡西25cm', selections: [], + sourcePrice: { amount: '31.335', currencyCode: 'CNY' }, + confidence: 'verified', evidenceRefs: ['1688:sku-map'], + }], + assets: [{ + url: 'https://cbu01.alicdn.com/sku.jpg', role: 'source_sku', + relation: 'axis_value', variantSourceKey: '6100454176961', + sortOrder: 0, evidenceRef: '1688:sku-map', + }], + evidence: [{ ref: '1688:sku-map', kind: 'state', path: 'skuModel.skuInfoMap' }], + }; + const captured1688 = await request('/api/extension/materials', { + method: 'POST', + headers: { + Authorization: `Bearer ${webAccessToken}`, + 'Idempotency-Key': randomUUID(), + }, + body: JSON.stringify(source1688), + }); + assert.equal(captured1688.response.status, 201); + const associated1688 = await database.workspaceTransaction(workspaceId, async (tx) => + tx.query<{ purchase_cost_cny: string; source_variant_id: string; variant_id: string }>( + `SELECT v.purchase_cost_cny::text, t.source_variant_id, v.id AS variant_id + FROM atlas.product_variants v + JOIN atlas.collection_asset_transfers t ON t.product_id = v.product_id + WHERE v.product_id = $1 AND t.role = 'source_sku'`, + [captured1688.body.data.product_id], + ), + ); + assert.equal(associated1688.rows[0]?.purchase_cost_cny, '31.3400'); + assert.equal(associated1688.rows[0]?.source_variant_id, associated1688.rows[0]?.variant_id); + const refreshed1688 = await request('/api/extension/materials', { + method: 'POST', + headers: { + Authorization: `Bearer ${webAccessToken}`, + 'Idempotency-Key': randomUUID(), + }, + body: JSON.stringify({ + ...source1688, + variants: [{ + ...source1688.variants[0], + sourcePrice: { amount: '32.525', currencyCode: 'CNY' }, + }], + }), + }); + assert.equal(refreshed1688.response.status, 200); + const updatedCost = await database.workspaceTransaction(workspaceId, async (tx) => + tx.query<{ purchase_cost_cny: string }>( + `SELECT purchase_cost_cny::text FROM atlas.product_variants WHERE product_id = $1`, + [captured1688.body.data.product_id], + ), + ); + assert.equal(updatedCost.rows[0]?.purchase_cost_cny, '32.5300'); const platforms = await request('/api/platforms', { headers: { Authorization: `Bearer ${webAccessToken}` }, }); @@ -328,7 +391,7 @@ describe('扩展授权与采集 HTTP 集成测试', () => { variant.source_variant_key === '123456789', ); assert.equal(originalVariant.display_name, '人工确认 SKU'); - assert.equal(originalVariant.purchase_cost_cny, '88.8000'); + assert.equal(originalVariant.purchase_cost_cny, '88.80'); assert.equal(originalVariant.listing.offer_id, 'manual-offer-001'); const discoveredVariant = editorAfterRefresh.body.data.variants.find( (variant: { source_variant_key: string }) => diff --git a/apps/api/test/media-ai-edit.unit.test.ts b/apps/api/test/media-ai-edit.unit.test.ts new file mode 100644 index 0000000..06e2e93 --- /dev/null +++ b/apps/api/test/media-ai-edit.unit.test.ts @@ -0,0 +1,101 @@ +import { describe, expect, it, vi } from 'vitest'; +import { createMediaAiEditRequestSchema } from '@atlas/contracts'; +import { MediaAiEditService } from '../src/media/media-ai-edit.service.js'; + +const workspaceId = '30cf54b7-06a3-4e5f-a5fc-d74165115094'; +const sourceId = '664d0d73-3d39-4a98-ac0a-5c0e521d8998'; +const referenceId = '50ea00b7-6675-4cc7-b7a2-d45f05c07100'; +const editId = '68db226f-3fcb-4579-ab9e-4932b1d84067'; +const productId = '733d0d73-3d39-4a98-ac0a-5c0e521d8998'; + +describe('单张 AI 编辑任务', () => { + it('验证主图和辅图都属于当前工作空间后,原子写入任务、进度和 Outbox', async () => { + const statements: string[] = []; + const query = vi.fn(async (sql: string) => { + statements.push(sql); + if (sql.includes('SELECT id FROM atlas.products')) return { rows: [{ id: productId }] }; + if (sql.includes('SELECT id FROM atlas.media_assets')) + return { rows: [{ id: sourceId }, { id: referenceId }] }; + if (sql.includes('RETURNING id')) return { rows: [{ id: editId }] }; + return { rows: [] }; + }); + const run = vi.fn(async (_input: unknown, work: (tx: { query: typeof query }) => Promise) => work({ query })); + const service = new MediaAiEditService( + {} as ConstructorParameters[0], + { run } as unknown as ConstructorParameters[1], + {} as ConstructorParameters[2], + ); + const input = createMediaAiEditRequestSchema.parse({ product_id: productId, reference_asset_ids: [referenceId], background: 'reference' }); + const result = await service.create(workspaceId, 'user-id', sourceId, 'key', input); + expect(result).toMatchObject({ status: 202, body: { data: { id: editId, status: 'queued' } } }); + expect(run).toHaveBeenCalledOnce(); + expect(statements.some((sql) => sql.includes('INSERT INTO atlas.media_ai_edits'))).toBe(true); + expect(statements.some((sql) => sql.includes('INSERT INTO atlas.operations'))).toBe(true); + expect(statements.some((sql) => sql.includes('INSERT INTO atlas.outbox_events'))).toBe(true); + }); + + it('任一辅助图不在当前工作空间时,不创建任务', async () => { + const query = vi.fn(async (sql: string) => ({ rows: [{ id: sql.includes('atlas.products') ? productId : sourceId }] })); + const run = vi.fn(async (_input: unknown, work: (tx: { query: typeof query }) => Promise) => work({ query })); + const service = new MediaAiEditService( + {} as ConstructorParameters[0], + { run } as unknown as ConstructorParameters[1], + {} as ConstructorParameters[2], + ); + const input = createMediaAiEditRequestSchema.parse({ product_id: productId, reference_asset_ids: [referenceId] }); + await expect(service.create(workspaceId, 'user-id', sourceId, 'key', input)) + .rejects.toMatchObject({ status: 404 }); + expect(query).toHaveBeenCalledTimes(2); + }); + + it('仅扩图不能带辅图或尺寸标注,参考背景必须带辅图', () => { + expect(createMediaAiEditRequestSchema.safeParse({ product_id: productId, mode: 'outpaint', reference_asset_ids: [referenceId] }).success).toBe(false); + expect(createMediaAiEditRequestSchema.safeParse({ product_id: productId, background: 'reference' }).success).toBe(false); + expect(createMediaAiEditRequestSchema.safeParse({ product_id: productId, generate_size_chart: true }).success).toBe(false); + }); + + it('读取失败任务时返回记录的失败来源、步骤和诊断', async () => { + const query = vi.fn(async (sql: string) => { + expect(sql).toContain('o.workspace_id = e.workspace_id'); + return { rows: [{ + id: editId, status: 'failed', source_asset_id: sourceId, + output_asset_id: null, error_code: 'AI_IMAGE_GENERATION_FAILED', + failure_source: 'atlas', failure_stage: 'result_processing', + failure_detail: 'Pcore 结果图片无效', + }] }; + }); + const database = { workspaceTransaction: vi.fn(async (_workspace: string, work: (tx: { query: typeof query }) => Promise) => work({ query })) }; + const service = new MediaAiEditService( + database as unknown as ConstructorParameters[0], + {} as ConstructorParameters[1], + {} as ConstructorParameters[2], + ); + const result = await service.read(workspaceId, editId); + expect(result).toMatchObject({ + failure_source: 'atlas', failure_stage: 'result_processing', + failure_detail: 'Pcore 结果图片无效', + }); + }); + + it('AI 生图记录按当前商品筛选并重新签发短时预览地址', async () => { + const query = vi.fn(async (_sql: string, values: unknown[]) => { + expect(values).toEqual([productId]); + return { rows: [{ + id: editId, status: 'succeeded', source_asset_id: sourceId, + output_asset_id: referenceId, error_code: null, + content_type: 'image/jpeg', byte_size: '1234', width_px: 1152, + height_px: 1536, asset_created_at: new Date('2026-09-26T00:00:00Z'), + }] }; + }); + const database = { workspaceTransaction: vi.fn(async (_workspace: string, work: (tx: { query: typeof query }) => Promise) => work({ query })) }; + const accessUrl = vi.fn(async () => ({ url: 'https://example.com/short-lived', expires_at: '2026-09-26T01:00:00Z' })); + const service = new MediaAiEditService( + database as unknown as ConstructorParameters[0], + {} as ConstructorParameters[1], + { accessUrl } as unknown as ConstructorParameters[2], + ); + const items = await service.list(workspaceId, productId); + expect(items).toMatchObject([{ id: editId, output_asset: { id: referenceId, preview_url: 'https://example.com/short-lived' } }]); + expect(accessUrl).toHaveBeenCalledWith(workspaceId, referenceId); + }); +}); diff --git a/apps/api/test/media-aspect-ratio.unit.test.ts b/apps/api/test/media-aspect-ratio.unit.test.ts new file mode 100644 index 0000000..fe74729 --- /dev/null +++ b/apps/api/test/media-aspect-ratio.unit.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, it, vi } from 'vitest'; +import { AspectRatioDerivativeService } from '../src/media/aspect-ratio-derivative.service.js'; + +const workspaceId = '30cf54b7-06a3-4e5f-a5fc-d74165115094'; +const sourceAssetId = '664d0d73-3d39-4a98-ac0a-5c0e521d8998'; +const derivativeId = '68db226f-3fcb-4579-ab9e-4932b1d84067'; +const productId = '733d0d73-3d39-4a98-ac0a-5c0e521d8998'; + +describe('一键 3:4 派生任务', () => { + it('在同一个租户事务写入派生记录、操作进度和 Outbox', async () => { + const statements: string[] = []; + const query = vi.fn(async (sql: string) => { + statements.push(sql); + if (sql.includes('SELECT id FROM atlas.media_assets')) return { rows: [{ id: sourceAssetId }] }; + if (sql.includes('RETURNING id')) return { rows: [{ id: derivativeId }] }; + return { rows: [] }; + }); + const run = vi.fn(async (_input: unknown, work: (tx: { query: typeof query }) => Promise) => + work({ query })); + const service = new AspectRatioDerivativeService( + {} as ConstructorParameters[0], + { run } as unknown as ConstructorParameters[1], + {} as ConstructorParameters[2], + ); + const result = await service.create( + workspaceId, + 'user-id', + sourceAssetId, + 'idempotency-key', + { preset: '3:4', fit: 'contain' }, + ); + expect(result).toMatchObject({ + status: 202, + body: { data: { id: derivativeId, status: 'queued', source_asset_id: sourceAssetId } }, + }); + expect(run).toHaveBeenCalledOnce(); + expect(statements.some((sql) => sql.includes('INSERT INTO atlas.media_aspect_ratio_derivatives'))).toBe(true); + expect(statements.some((sql) => sql.includes('INSERT INTO atlas.operations'))).toBe(true); + expect(statements.some((sql) => sql.includes('INSERT INTO atlas.outbox_events'))).toBe(true); + }); + + it('缺少或跨工作空间的源素材不会创建任务', async () => { + const query = vi.fn(async () => ({ rows: [] })); + const run = vi.fn(async (_input: unknown, work: (tx: { query: typeof query }) => Promise) => + work({ query })); + const service = new AspectRatioDerivativeService( + {} as ConstructorParameters[0], + { run } as unknown as ConstructorParameters[1], + {} as ConstructorParameters[2], + ); + await expect(service.create( + workspaceId, + 'user-id', + sourceAssetId, + 'idempotency-key', + { preset: '3:4', fit: 'contain' }, + )).rejects.toMatchObject({ status: 404 }); + expect(query).toHaveBeenCalledOnce(); + }); + + it('将当前工作空间的商品关联到新派生任务', async () => { + const query = vi.fn(async (sql: string, values?: unknown[]) => { + if (sql.includes('SELECT id FROM atlas.media_assets')) return { rows: [{ id: sourceAssetId }] }; + if (sql.includes('SELECT id FROM atlas.products')) return { rows: [{ id: productId }] }; + if (sql.includes('INSERT INTO atlas.media_aspect_ratio_derivatives')) { + expect(values).toEqual([productId, sourceAssetId, 'user-id']); + return { rows: [{ id: derivativeId }] }; + } + return { rows: [] }; + }); + const run = vi.fn(async (_input: unknown, work: (tx: { query: typeof query }) => Promise) => work({ query })); + const service = new AspectRatioDerivativeService( + {} as ConstructorParameters[0], + { run } as unknown as ConstructorParameters[1], + {} as ConstructorParameters[2], + ); + await service.create(workspaceId, 'user-id', sourceAssetId, 'key', + { preset: '3:4', fit: 'contain', product_id: productId }); + expect(query.mock.calls.some(([sql]) => String(sql).includes('INSERT INTO atlas.media_aspect_ratio_derivatives'))).toBe(true); + }); +}); diff --git a/apps/api/test/media-product-image.unit.test.ts b/apps/api/test/media-product-image.unit.test.ts index e1c556b..256d078 100644 --- a/apps/api/test/media-product-image.unit.test.ts +++ b/apps/api/test/media-product-image.unit.test.ts @@ -1,6 +1,7 @@ import { createHash } from 'node:crypto'; import { GetObjectCommand, HeadObjectCommand } from '@aws-sdk/client-s3'; import { + createMediaAccessUrlRequestSchema, createMediaUploadIntentRequestSchema, importProductImageRequestSchema, } from '@atlas/contracts'; @@ -55,6 +56,35 @@ function png(width: number, height: number) { } describe('商品图片上传策略', () => { + it('下载地址签名 attachment 响应头,预览地址保持内联显示', async () => { + const assetId = '7c910684-7070-4f36-8a7b-b10bd91acd79'; + const query = vi.fn(async () => ({ + rows: [{ bucket: 'test', object_key: 'uploads/test.png', content_type: 'image/png' }], + })); + const workspaceTransaction = vi.fn(async ( + _workspaceId: string, + callback: (tx: { query: typeof query }) => Promise, + ) => callback({ query })); + const service = new MediaService( + {} as ConstructorParameters[0], + { workspaceTransaction } as unknown as ConstructorParameters[1], + { + OBJECT_STORAGE_ENDPOINT: 'http://127.0.0.1:9000', + OBJECT_STORAGE_BUCKET: 'test', + objectStorageForcePathStyle: true, + objectStorageAccessKey: 'test', + objectStorageSecretKey: 'test', + } as ConstructorParameters[2], + ); + expect(createMediaAccessUrlRequestSchema.safeParse({ purpose: 'download' }).success).toBe(true); + const preview = await service.accessUrl('workspace-id', assetId); + const download = await service.accessUrl('workspace-id', assetId, 'download'); + expect(new URL(preview.url).searchParams.has('response-content-disposition')).toBe(false); + expect(new URL(download.url).searchParams.get('response-content-disposition')) + .toBe(`attachment; filename="atlas-image-${assetId}.png"`); + expect(workspaceTransaction).toHaveBeenCalledTimes(2); + }); + it('创建商品图片上传意图时持久化用途并签发独立对象路径', async () => { const query = vi.fn(async (_sql: string, _params?: unknown[]) => ({ rows: [], diff --git a/apps/api/test/media-watermark.e2e.test.ts b/apps/api/test/media-watermark.e2e.test.ts new file mode 100644 index 0000000..8c68755 --- /dev/null +++ b/apps/api/test/media-watermark.e2e.test.ts @@ -0,0 +1,170 @@ +import assert from 'node:assert/strict'; +import { randomUUID } from 'node:crypto'; +import type { AddressInfo } from 'node:net'; +import type { Database } from '@atlas/database'; +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'; + +let app: NestExpressApplication; +let database: Database; +let baseUrl = ''; +let workspaceId = ''; +let userId = ''; +let token = ''; + +describe('水印派生 API 集成测试', () => { + beforeAll(async () => { + Object.assign(process.env, { + NODE_ENV: 'test', SWAGGER_ENABLED: 'false', + AUTH_REGISTER_IP_LIMIT: '100', AUTH_REGISTER_EMAIL_LIMIT: '100', + }); + const created = await createApplication(); + app = created.app; + database = app.get(DATABASE); + await app.listen(0, '127.0.0.1'); + baseUrl = `http://127.0.0.1:${(app.getHttpServer().address() as AddressInfo).port}`; + const response = await fetch(`${baseUrl}/api/auth/register`, { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + email: `watermark-derivative-${randomUUID()}@example.test`, + password: 'correct-horse-battery-staple-123', + password_confirmation: 'correct-horse-battery-staple-123', + }), + }); + assert.equal(response.status, 201); + const body = await response.json() as { data: { + user: { id: string }; workspace: { id: string }; access_token: string; + } }; + userId = body.data.user.id; + workspaceId = body.data.workspace.id; + token = body.data.access_token; + }); + + afterAll(async () => { + if (workspaceId) { + await database.workspaceTransaction(workspaceId, async (tx) => { + await tx.query('DELETE FROM atlas.idempotency_requests WHERE workspace_id = $1', [workspaceId]); + await tx.query('DELETE FROM atlas.outbox_events WHERE workspace_id = $1', [workspaceId]); + await tx.query('DELETE FROM atlas.operations WHERE workspace_id = $1', [workspaceId]); + await tx.query('DELETE FROM atlas.media_aspect_ratio_derivatives WHERE workspace_id = $1', [workspaceId]); + await tx.query('DELETE FROM atlas.media_watermark_derivatives WHERE workspace_id = $1', [workspaceId]); + await tx.query('DELETE FROM atlas.watermark_template_versions WHERE workspace_id = $1', [workspaceId]); + await tx.query('DELETE FROM atlas.watermark_templates WHERE workspace_id = $1', [workspaceId]); + await tx.query('DELETE FROM atlas.media_assets WHERE workspace_id = $1', [workspaceId]); + await tx.query('DELETE FROM atlas.products WHERE workspace_id = $1', [workspaceId]); + await tx.query('DELETE FROM atlas.collection_items WHERE workspace_id = $1', [workspaceId]); + }); + await database.query('DELETE FROM atlas.workspace_memberships WHERE user_id = $1', [userId]); + await database.query('DELETE FROM atlas.workspaces WHERE id = $1', [workspaceId]); + await database.query('DELETE FROM atlas.users WHERE id = $1', [userId]); + } + await app?.close(); + }); + + it('HTTP 创建任务锁定模板版本,结果查询保持工作空间边界', async () => { + const { assetId, templateId, productId } = await database.workspaceTransaction(workspaceId, async (tx) => { + const item = await tx.query<{ id: string }>( + `INSERT INTO atlas.collection_items (workspace_id, source_kind, source_item_key) + VALUES ($1, 'manual', $2) RETURNING id`, + [workspaceId, `watermark-history-${randomUUID()}`], + ); + const product = await tx.query<{ id: string }>( + `INSERT INTO atlas.products (workspace_id, collection_item_id) + VALUES ($1, $2) RETURNING id`, + [workspaceId, item.rows[0].id], + ); + const asset = await tx.query<{ id: string }>( + `INSERT INTO atlas.media_assets + (workspace_id, storage_provider, bucket, object_key, content_type, + byte_size, checksum_sha256, width_px, height_px) + VALUES ($1, 'local_minio', 'atlas-media', $2, 'image/jpeg', 100, + $3, 120, 100) RETURNING id`, + [workspaceId, `test/watermark-${randomUUID()}.jpg`, randomUUID().replaceAll('-', '').padEnd(64, '0')], + ); + const template = await tx.query<{ id: string }>( + `INSERT INTO atlas.watermark_templates (workspace_id, name, kind) + VALUES ($1, $2, 'text') RETURNING id`, + [workspaceId, `集成测试水印 ${randomUUID()}`], + ); + await tx.query( + `INSERT INTO atlas.watermark_template_versions + (workspace_id, template_id, version, kind, text_content, font_code, + font_size_px, opacity, position, position_x, position_y, created_by_user_id) + VALUES ($1, $2, 1, 'text', 'Atlas', 'NotoSans', 20, 0.5, + 'bottom_right', 1, 1, $3)`, + [workspaceId, template.rows[0].id, userId], + ); + return { assetId: asset.rows[0].id, templateId: template.rows[0].id, productId: product.rows[0].id }; + }); + const key = randomUUID(); + const create = () => fetch(`${baseUrl}/api/media-assets/${assetId}/watermark-derivatives`, { + method: 'POST', headers: { + Authorization: `Bearer ${token}`, 'Content-Type': 'application/json', 'Idempotency-Key': key, + }, + body: JSON.stringify({ watermark_template_id: templateId, product_id: productId }), + }); + const response = await create(); + assert.equal(response.status, 202); + const body = await response.json() as { data: { + id: string; watermark_template_id: string; watermark_template_version: number; + } }; + assert.equal(body.data.watermark_template_id, templateId); + assert.equal(body.data.watermark_template_version, 1); + const replay = await create(); + assert.equal(replay.status, 202); + assert.equal((await replay.json() as { data: { id: string } }).data.id, body.data.id); + const read = await fetch(`${baseUrl}/api/media-watermark-derivatives/${body.data.id}`, { + headers: { Authorization: `Bearer ${token}` }, + }); + assert.equal(read.status, 200); + assert.equal((await read.json() as { data: { id: string } }).data.id, body.data.id); + const stored = await database.workspaceTransaction(workspaceId, async (tx) => + tx.query<{ product_id: string }>( + 'SELECT product_id FROM atlas.media_watermark_derivatives WHERE id = $1', + [body.data.id], + ), + ); + assert.equal(stored.rows[0]?.product_id, productId); + + const outputs = await database.workspaceTransaction(workspaceId, async (tx) => { + const ids: string[] = []; + for (const kind of ['watermark', 'ratio']) { + const asset = await tx.query<{ id: string }>( + `INSERT INTO atlas.media_assets + (workspace_id, storage_provider, bucket, object_key, content_type, + byte_size, checksum_sha256, width_px, height_px) + VALUES ($1, 'local_minio', 'atlas-media', $2, 'image/jpeg', 100, + $3, 1200, 1600) RETURNING id`, + [workspaceId, `test/${kind}-${randomUUID()}.jpg`, randomUUID().replaceAll('-', '').padEnd(64, '0')], + ); + ids.push(asset.rows[0].id); + } + await tx.query( + `INSERT INTO atlas.media_watermark_derivatives + (workspace_id, product_id, source_asset_id, watermark_template_id, + watermark_template_version, output_asset_id, status, completed_at) + VALUES ($1, $2, $3, $4, 1, $5, 'succeeded', now())`, + [workspaceId, productId, assetId, templateId, ids[0]], + ); + await tx.query( + `INSERT INTO atlas.media_aspect_ratio_derivatives + (workspace_id, product_id, source_asset_id, output_asset_id, status, completed_at) + VALUES ($1, $2, $3, $4, 'succeeded', now())`, + [workspaceId, productId, assetId, ids[1]], + ); + return ids; + }); + const history = await fetch(`${baseUrl}/api/products/${productId}/image-derivatives`, { + headers: { Authorization: `Bearer ${token}` }, + }); + assert.equal(history.status, 200); + const records = (await history.json() as { data: Array<{ + kind: string; output_asset: { id: string; preview_url: string }; + }> }).data; + assert.deepEqual(new Set(records.map((record) => record.kind)), new Set(['watermark', 'aspect_ratio_3_4'])); + assert.deepEqual(new Set(records.map((record) => record.output_asset.id)), new Set(outputs)); + assert.ok(records.every((record) => record.output_asset.preview_url)); + }); +}); diff --git a/apps/api/test/media-watermark.unit.test.ts b/apps/api/test/media-watermark.unit.test.ts new file mode 100644 index 0000000..6cef2b4 --- /dev/null +++ b/apps/api/test/media-watermark.unit.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it, vi } from 'vitest'; +import { WatermarkDerivativeService } from '../src/media/watermark-derivative.service.js'; + +const workspaceId = '30cf54b7-06a3-4e5f-a5fc-d74165115094'; +const sourceAssetId = '664d0d73-3d39-4a98-ac0a-5c0e521d8998'; +const templateId = '0878262e-6c38-4f3b-953a-2084ef297a33'; +const derivativeId = '68db226f-3fcb-4579-ab9e-4932b1d84067'; +const productId = '733d0d73-3d39-4a98-ac0a-5c0e521d8998'; + +function serviceWithQuery(query: ReturnType) { + const run = vi.fn(async (_input: unknown, work: (tx: { query: typeof query }) => Promise) => + work({ query })); + const service = new WatermarkDerivativeService( + {} as ConstructorParameters[0], + { run } as unknown as ConstructorParameters[1], + {} as ConstructorParameters[2], + ); + return { service, run }; +} + +describe('水印派生任务', () => { + it('固定当前模板版本,并在同一个事务创建任务、操作和 Outbox', async () => { + const statements: string[] = []; + const query = vi.fn(async (sql: string) => { + statements.push(sql); + if (sql.includes('SELECT id FROM atlas.media_assets')) return { rows: [{ id: sourceAssetId }] }; + if (sql.includes('FROM atlas.watermark_templates')) return { rows: [{ id: templateId, current_version: 4 }] }; + if (sql.includes('RETURNING id')) return { rows: [{ id: derivativeId }] }; + return { rows: [] }; + }); + const { service, run } = serviceWithQuery(query); + const result = await service.create(workspaceId, 'user-id', sourceAssetId, 'idempotency-key', + { watermark_template_id: templateId }); + expect(result).toMatchObject({ status: 202, body: { data: { + id: derivativeId, watermark_template_id: templateId, watermark_template_version: 4, + } } }); + expect(run).toHaveBeenCalledOnce(); + expect(statements.some((sql) => sql.includes('INSERT INTO atlas.media_watermark_derivatives'))).toBe(true); + expect(statements.some((sql) => sql.includes('INSERT INTO atlas.operations'))).toBe(true); + expect(statements.some((sql) => sql.includes('INSERT INTO atlas.outbox_events'))).toBe(true); + }); + + it('拒绝不可用模板且不创建派生任务', async () => { + const query = vi.fn(async (sql: string) => sql.includes('SELECT id FROM atlas.media_assets') + ? { rows: [{ id: sourceAssetId }] } : { rows: [] }); + const { service } = serviceWithQuery(query); + await expect(service.create(workspaceId, 'user-id', sourceAssetId, 'idempotency-key', + { watermark_template_id: templateId })).rejects.toMatchObject({ status: 404 }); + expect(query.mock.calls.every(([sql]) => !String(sql).includes('INSERT INTO'))).toBe(true); + }); + + it('将当前工作空间的商品关联到水印结果', async () => { + const query = vi.fn(async (sql: string, values?: unknown[]) => { + if (sql.includes('SELECT id FROM atlas.media_assets')) return { rows: [{ id: sourceAssetId }] }; + if (sql.includes('SELECT id FROM atlas.products')) return { rows: [{ id: productId }] }; + if (sql.includes('FROM atlas.watermark_templates')) return { rows: [{ id: templateId, current_version: 4 }] }; + if (sql.includes('INSERT INTO atlas.media_watermark_derivatives')) { + expect(values).toEqual([productId, sourceAssetId, templateId, 4, 'user-id']); + return { rows: [{ id: derivativeId }] }; + } + return { rows: [] }; + }); + const { service } = serviceWithQuery(query); + await service.create(workspaceId, 'user-id', sourceAssetId, 'key', + { watermark_template_id: templateId, product_id: productId }); + expect(query.mock.calls.some(([sql]) => String(sql).includes('INSERT INTO atlas.media_watermark_derivatives'))).toBe(true); + }); +}); diff --git a/apps/api/test/online-products.e2e.test.ts b/apps/api/test/online-products.e2e.test.ts new file mode 100644 index 0000000..314bdf9 --- /dev/null +++ b/apps/api/test/online-products.e2e.test.ts @@ -0,0 +1,258 @@ +import assert from 'node:assert/strict'; +import { randomUUID } from 'node:crypto'; +import type { AddressInfo } from 'node:net'; +import type { Database } from '@atlas/database'; +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'; + +let app: NestExpressApplication; +let database: Database; +let baseUrl: string; +let token = ''; +let workspaceId = ''; +let userId = ''; +let platformId = ''; +let storeId = ''; +let listingId = ''; +let runId = ''; + +async function request(path: string, init: RequestInit = {}) { + const response = await fetch(`${baseUrl}${path}`, { + ...init, + headers: { 'Content-Type': 'application/json', ...init.headers }, + }); + return { response, body: (await response.json()) as Record }; +} + +function auth(extra: Record = {}) { + return { + Authorization: `Bearer ${token}`, + 'X-Atlas-Platform-Id': platformId, + ...extra, + }; +} + +describe('在线商品 HTTP 契约', () => { + beforeAll(async () => { + Object.assign(process.env, { + NODE_ENV: 'test', + SWAGGER_ENABLED: 'false', + AUTH_REGISTER_IP_LIMIT: '100', + AUTH_REGISTER_EMAIL_LIMIT: '100', + }); + const created = await createApplication(); + app = created.app; + database = app.get(DATABASE); + await app.listen(0, '127.0.0.1'); + baseUrl = `http://127.0.0.1:${(app.getHttpServer().address() as AddressInfo).port}`; + const registered = await request('/api/auth/register', { + method: 'POST', + body: JSON.stringify({ + email: `online-${randomUUID()}@example.test`, + password: 'correct-horse-battery-staple-123', + password_confirmation: 'correct-horse-battery-staple-123', + }), + }); + assert.equal(registered.response.status, 201); + token = registered.body.data.access_token; + workspaceId = registered.body.data.workspace.id; + userId = registered.body.data.user.id; + const platforms = await request('/api/platforms', { + headers: { Authorization: `Bearer ${token}` }, + }); + platformId = platforms.body.data.find( + (platform: { code: string }) => platform.code === 'OZON', + ).id; + storeId = randomUUID(); + await database.workspaceTransaction(workspaceId, async (tx) => { + await tx.query( + `INSERT INTO atlas.stores (id, workspace_id, platform_id, name, platform_client_id, currency_code, is_default, status, credential_status) + VALUES ($1, $2, $3, '测试门店', $4, 'RUB', true, 'active', 'valid')`, + [storeId, workspaceId, platformId, `online-http-${storeId}`], + ); + const run = await tx.query<{ id: string }>( + `INSERT INTO atlas.platform_product_sync_runs + (workspace_id, store_id, platform_id, trigger_source, status, completed_at) + VALUES ($1, $2, $3, 'manual', 'succeeded', now()) RETURNING id`, + [workspaceId, storeId, platformId], + ); + runId = run.rows[0].id; + const listing = await tx.query<{ id: string }>( + `INSERT INTO atlas.platform_listings + (workspace_id, store_id, platform_id, remote_product_id, offer_id, remote_sku, name, + currency_code, selling_price, visible, moderation_status, stock_summary, + image_summary, source, last_sync_run_id, last_synced_at, raw_payload) + VALUES ($1, $2, $3, '123456', 'test-offer', '654321', '测试商品', + 'RUB', 99, true, 'approved', '{"total":0,"has_data":true}', + '{"url":null,"width_px":null,"height_px":null}', 'external_sync', $4, now(), '{}') RETURNING id`, + [workspaceId, storeId, platformId, runId], + ); + listingId = listing.rows[0].id; + await tx.query( + `INSERT INTO atlas.platform_listings + (workspace_id, store_id, platform_id, remote_product_id, offer_id, name, + visible, stock_summary, image_summary, source, last_sync_run_id, last_synced_at, + missing_since, archived_at, raw_payload) + VALUES ($1, $2, $3, '123457', 'archived-offer', '归档商品', + NULL, '{"total":0,"has_data":false}', '{}', 'external_sync', $4, + now(), now() - interval '8 days', now(), '{}')`, + [workspaceId, storeId, platformId, runId], + ); + }); + }); + + afterAll(async () => { + if (workspaceId && database) { + await database.workspaceTransaction(workspaceId, async (tx) => { + await tx.query( + 'DELETE FROM atlas.platform_listing_sync_snapshots WHERE workspace_id = $1', + [workspaceId], + ); + await tx.query( + 'DELETE FROM atlas.platform_listings WHERE workspace_id = $1', + [workspaceId], + ); + await tx.query( + 'DELETE FROM atlas.platform_product_sync_runs WHERE workspace_id = $1', + [workspaceId], + ); + await tx.query( + 'DELETE FROM atlas.idempotency_requests WHERE workspace_id = $1', + [workspaceId], + ); + await tx.query( + 'DELETE FROM atlas.outbox_events WHERE workspace_id = $1', + [workspaceId], + ); + await tx.query( + 'DELETE FROM atlas.store_secrets WHERE workspace_id = $1', + [workspaceId], + ); + await tx.query( + 'DELETE FROM atlas.stores WHERE workspace_id = $1', + [workspaceId], + ); + }); + await database.query( + 'DELETE FROM atlas.workspace_memberships WHERE user_id = $1', + [userId], + ); + await database.query('DELETE FROM atlas.workspaces WHERE id = $1', [ + workspaceId, + ]); + await database.query('DELETE FROM atlas.users WHERE id = $1', [ + userId, + ]); + } + if (app) await app.close(); + }); + + it('列表、详情与同步历史按平台店铺返回镜像,不返回原始响应', async () => { + const listed = await request( + `/api/platform-listings?store_id=${storeId}&q=test-offer`, + { headers: auth() }, + ); + assert.equal(listed.response.status, 200); + assert.equal(listed.body.data.length, 1); + assert.equal(listed.body.data[0].visible, true); + assert.equal(listed.body.data[0].selling_price, '99.00'); + assert.deepEqual(listed.body.data[0].stock_summary, { + total: 0, + has_data: true, + }); + assert.equal(listed.body.meta.store.id, storeId); + assert.equal(listed.body.meta.last_sync_status, 'succeeded'); + assert.equal('raw_payload' in listed.body.data[0], false); + const detail = await request(`/api/platform-listings/${listingId}`, { + headers: auth(), + }); + assert.equal(detail.response.status, 200); + assert.equal(detail.body.data.offer_id, 'test-offer'); + const runs = await request( + `/api/stores/${storeId}/platform-product-sync-runs`, + { headers: auth() }, + ); + assert.equal(runs.response.status, 200); + assert.equal(runs.body.data[0].id, runId); + const run = await request(`/api/platform-product-sync-runs/${runId}`, { + headers: auth(), + }); + assert.equal(run.response.status, 200); + assert.equal(run.body.data.status, 'succeeded'); + }); + + it('筛选未知可见性与缺少平台头的请求按契约处理', async () => { + const unknown = await request( + `/api/platform-listings?store_id=${storeId}&visibility=unknown`, + { headers: auth() }, + ); + assert.equal(unknown.response.status, 200); + assert.equal(unknown.body.data.length, 0); + const archived = await request( + `/api/platform-listings?store_id=${storeId}&visibility=unknown&include_archived=true`, + { headers: auth() }, + ); + assert.equal(archived.response.status, 200); + assert.equal(archived.body.data.length, 1); + assert.equal(archived.body.data[0].offer_id, 'archived-offer'); + const missingHeader = await request('/api/platform-listings', { + headers: { Authorization: `Bearer ${token}` }, + }); + assert.equal(missingHeader.response.status, 400); + assert.equal( + missingHeader.body.error.code, + 'PLATFORM_CONTEXT_REQUIRED', + ); + const noCredentials = await request( + `/api/stores/${storeId}/platform-product-sync-runs`, + { + method: 'POST', + headers: auth({ 'Idempotency-Key': randomUUID() }), + body: JSON.stringify({ reason: 'manual_refresh' }), + }, + ); + assert.equal(noCredentials.response.status, 422); + assert.equal( + noCredentials.body.error.code, + 'STORE_CREDENTIAL_UNAVAILABLE', + ); + }); + + it('触发同步写入 Outbox,同一幂等键复用任务,并发请求返回冲突', async () => { + await database.workspaceTransaction(workspaceId, (tx) => + tx.query( + `INSERT INTO atlas.store_secrets + (workspace_id, store_id, kind, secret_ciphertext, secret_nonce, + encryption_key_version, fingerprint, status) + VALUES ($1, $2, 'seller_api_key', $3, $4, 'v1', $5, 'valid')`, + [workspaceId, storeId, Buffer.alloc(32), Buffer.alloc(12), randomUUID()], + ), + ); + const key = randomUUID(); + const post = (idempotencyKey: string) => + request(`/api/stores/${storeId}/platform-product-sync-runs`, { + method: 'POST', + headers: auth({ 'Idempotency-Key': idempotencyKey }), + body: JSON.stringify({ reason: 'manual_refresh' }), + }); + const created = await post(key); + assert.equal(created.response.status, 202); + assert.equal(created.body.data.sync_run.status, 'queued'); + const repeated = await post(key); + assert.equal(repeated.response.status, 202); + assert.equal(repeated.body.data.sync_run.id, created.body.data.sync_run.id); + const concurrent = await post(randomUUID()); + assert.equal(concurrent.response.status, 409); + assert.equal(concurrent.body.error.code, 'SYNC_ALREADY_RUNNING'); + const events = await database.workspaceTransaction(workspaceId, (tx) => + tx.query<{ count: string }>( + `SELECT count(*)::text AS count FROM atlas.outbox_events + WHERE aggregate_id = $1 AND event_type = 'platform_product_sync.requested'`, + [created.body.data.sync_run.id], + ), + ); + assert.equal(events.rows[0].count, '1'); + }); +}); diff --git a/apps/api/test/product-editor-processed-media.unit.test.ts b/apps/api/test/product-editor-processed-media.unit.test.ts new file mode 100644 index 0000000..7234782 --- /dev/null +++ b/apps/api/test/product-editor-processed-media.unit.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it, vi } from 'vitest'; +import { ProductEditorService } from '../src/product-editor/product-editor.service.js'; + +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) { + 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_aspect_ratio_derivatives')) { + expect(values).toEqual([productId, [assetId]]); + return { rows: processed ? [{ asset_id: assetId }] : [] }; + } + return { rows: [] }; + }); + return { query }; +} + +describe('SKU 图片处理结果的保存资格', () => { + const input = { + product: { id: productId, collection_item_id: 'collection-id', source_snapshot_revision: 1 }, + profile: { id: 'profile-id' }, + variants: [{ variantId, assetIds: [assetId] }], + }; + const service = new ProductEditorService( + {} as ConstructorParameters[0], + {} as ConstructorParameters[1], + ) as unknown as { + persistListingMedia: (tx: ReturnType, value: typeof input) => Promise; + }; + + it('当前商品的成功处理结果可加入 SKU 并保存', async () => { + const tx = mediaTransaction(true); + await service.persistListingMedia(tx, input); + expect(tx.query.mock.calls.some(([sql]) => String(sql).includes('INSERT INTO atlas.product_variant_listing_media'))).toBe(true); + }); + + it('其他商品或未成功的处理结果仍不能加入 SKU', async () => { + const tx = mediaTransaction(false); + await expect(service.persistListingMedia(tx, input)).rejects.toMatchObject({ + status: 422, response: { error: { code: 'LISTING_MEDIA_NOT_SELECTABLE' } }, + }); + }); +}); diff --git a/apps/api/test/product-editor.e2e.test.ts b/apps/api/test/product-editor.e2e.test.ts index 7e0325e..c04e701 100644 --- a/apps/api/test/product-editor.e2e.test.ts +++ b/apps/api/test/product-editor.e2e.test.ts @@ -132,6 +132,10 @@ describe('商品编辑 M3.0–M3.3 HTTP 集成测试', () => { 'DELETE FROM atlas.collection_asset_transfers WHERE workspace_id = $1', [workspaceId], ); + await tx.query( + 'DELETE FROM atlas.media_aspect_ratio_derivatives WHERE workspace_id = $1', + [workspaceId], + ); await tx.query( 'DELETE FROM atlas.product_image_imports WHERE workspace_id = $1', [workspaceId], @@ -1985,6 +1989,79 @@ describe('商品编辑 M3.0–M3.3 HTTP 集成测试', () => { assert.equal(privateImport.body.error.code, 'PRODUCT_IMAGE_IMPORT_INVALID'); }); + it('当前商品的 3:4 处理记录可重新加入 SKU 并保存', async () => { + const before = await request(`/api/products/${first.productId}/editor`, { + headers: headers({ 'X-Atlas-Platform-Id': platformId }), + }); + const sourceAssetId = before.body.data.variants[0].media[0].asset_id as string; + const outputAssetId = await database.workspaceTransaction(workspaceId, async (tx) => { + const output = await tx.query<{ id: string }>( + `INSERT INTO atlas.media_assets + (workspace_id, storage_provider, bucket, object_key, content_type, + byte_size, checksum_sha256, width_px, height_px) + VALUES ($1, 'test', 'test', $2, 'image/jpeg', 1024, $3, 1200, 1600) + RETURNING id`, + [workspaceId, `processed/${randomUUID()}.jpg`, randomUUID().replaceAll('-', '').repeat(2)], + ); + await tx.query( + `INSERT INTO atlas.media_aspect_ratio_derivatives + (workspace_id, product_id, source_asset_id, output_asset_id, status, completed_at) + VALUES ($1, $2, $3, $4, 'succeeded', now())`, + [workspaceId, first.productId, sourceAssetId, output.rows[0].id], + ); + return output.rows[0].id; + }); + const snapshot = before.body.data; + const payload = { + ai_source_material: snapshot.source_material, + ai_source_overridden_fields: snapshot.source_material_state.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: snapshot.profile.title, + description: snapshot.profile.description, + tags: snapshot.profile.tags, + platform_product_category_id: snapshot.profile.category.id, + category_source: snapshot.profile.category.source, + }, + attributes: snapshot.attributes.map((attribute: any) => ({ + scope: attribute.scope, variant_id: attribute.variant_id, + platform_attribute_id: attribute.platform_attribute_id, + complex_id: attribute.complex_id, values: attribute.values, + language_code: attribute.language_code, source: attribute.source, + confirmation_status: attribute.confirmation_status, + })), + variants: snapshot.variants.map((item: any) => ({ + id: item.id, client_key: null, copy_from_variant_id: null, + display_name: item.display_name, source_sku: item.source_sku, + specification_values: item.specification_values, + purchase_cost_cny: item.purchase_cost_cny, + tags: item.tags, description: item.description, + physical_spec: item.physical_spec, package_spec: item.package_spec, + offer_id: item.listing.offer_id, + media_asset_ids: item.id === snapshot.variants[0].id + ? [outputAssetId] : item.media.map((media: any) => media.asset_id), + })), + }; + const saved = await request(`/api/products/${first.productId}/editor-draft`, { + method: 'PUT', + headers: headers({ + 'X-Atlas-Platform-Id': platformId, + 'Idempotency-Key': randomUUID(), + 'If-Match': `"${snapshot.product.revision}:${snapshot.profile.revision}"`, + }), + body: JSON.stringify(payload), + }); + assert.equal(saved.response.status, 200); + const after = await request(`/api/products/${first.productId}/editor`, { + headers: headers({ 'X-Atlas-Platform-Id': platformId }), + }); + assert.equal(after.body.data.variants[0].media[0].asset_id, outputAssetId); + }); + it('独立保存采集描述会持久化覆盖值并保持幂等', async () => { const created = await createCollectionItem('独立保存描述测试商品'); const entered = await request( diff --git a/apps/api/test/product-image-derivative-history.unit.test.ts b/apps/api/test/product-image-derivative-history.unit.test.ts new file mode 100644 index 0000000..a890f6a --- /dev/null +++ b/apps/api/test/product-image-derivative-history.unit.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it, vi } from 'vitest'; +import { ProductImageDerivativeHistoryService } from '../src/media/product-image-derivative-history.service.js'; + +const workspaceId = '30cf54b7-06a3-4e5f-a5fc-d74165115094'; +const productId = '733d0d73-3d39-4a98-ac0a-5c0e521d8998'; + +describe('商品图片处理记录', () => { + it('只读取当前商品成功的 3:4 与水印结果,并重新签发预览地址', async () => { + const query = vi.fn(async (sql: string, values: unknown[]) => { + expect(values).toEqual([productId]); + if (sql.includes('FROM atlas.products')) return { rows: [{ id: productId }] }; + expect(sql).toContain("WHERE product_id = $1 AND status = 'succeeded'"); + expect(sql).toContain('LIMIT 100'); + return { rows: [ + { + id: 'watermark-task', kind: 'watermark', source_asset_id: 'source-1', + output_asset_id: 'output-1', created_at: new Date('2026-09-27T08:00:00Z'), + content_type: 'image/jpeg', byte_size: '1200', width_px: 1200, height_px: 1600, + asset_created_at: new Date('2026-09-27T08:01:00Z'), + }, + { + id: 'ratio-task', kind: 'aspect_ratio_3_4', source_asset_id: 'source-2', + output_asset_id: 'output-2', created_at: new Date('2026-09-27T07:00:00Z'), + content_type: 'image/jpeg', byte_size: '1100', width_px: 1200, height_px: 1600, + asset_created_at: new Date('2026-09-27T07:01:00Z'), + }, + ] }; + }); + const workspaceTransaction = vi.fn(async (_id: string, work: (tx: { query: typeof query }) => Promise) => work({ query })); + const accessUrl = vi.fn(async (_workspace: string, assetId: string) => ({ + url: `https://example.test/${assetId}`, expires_at: '2026-09-27T08:10:00Z', + })); + const service = new ProductImageDerivativeHistoryService( + { workspaceTransaction } as unknown as ConstructorParameters[0], + { accessUrl } as unknown as ConstructorParameters[1], + ); + const records = await service.list(workspaceId, productId); + expect(workspaceTransaction).toHaveBeenCalledWith(workspaceId, expect.any(Function)); + expect(records).toMatchObject([ + { kind: 'watermark', output_asset: { id: 'output-1', preview_url: 'https://example.test/output-1' } }, + { kind: 'aspect_ratio_3_4', output_asset: { id: 'output-2', preview_url: 'https://example.test/output-2' } }, + ]); + expect(accessUrl).toHaveBeenCalledTimes(2); + }); + + it('商品不在当前工作空间时返回 404,不查询图片记录', async () => { + const query = vi.fn(async () => ({ rows: [] })); + const workspaceTransaction = vi.fn(async (_id: string, work: (tx: { query: typeof query }) => Promise) => work({ query })); + const service = new ProductImageDerivativeHistoryService( + { workspaceTransaction } as unknown as ConstructorParameters[0], + {} as ConstructorParameters[1], + ); + await expect(service.list(workspaceId, productId)).rejects.toMatchObject({ status: 404 }); + expect(query).toHaveBeenCalledOnce(); + }); +}); diff --git a/apps/api/test/product-image-tasks.unit.test.ts b/apps/api/test/product-image-tasks.unit.test.ts new file mode 100644 index 0000000..1b98454 --- /dev/null +++ b/apps/api/test/product-image-tasks.unit.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, it, vi } from 'vitest'; +import { ProductImageTaskService } from '../src/media/product-image-task.service.js'; + +const workspaceId = '30cf54b7-06a3-4e5f-a5fc-d74165115094'; +const productId = '733d0d73-3d39-4a98-ac0a-5c0e521d8998'; + +describe('商品图片任务列表', () => { + it('按工作空间和商品读取任务,结果只包含可公开的任务字段', async () => { + const query = vi.fn(async (sql: string, values: unknown[]) => { + expect(values[0]).toBe(productId); + if (sql.includes('atlas.products')) return { rows: [{ id: productId }] }; + expect(values).toEqual([productId, 3]); + return { rows: [{ + id: 'edit-id', status: 'running', provider_task_id: 'private-provider-id', + output_asset_id: null, error_code: null, + created_at: new Date('2026-09-26T03:00:00Z'), completed_at: null, + }, { + id: 'completed-id', status: 'succeeded', provider_task_id: 'private-provider-id', + output_asset_id: 'asset-id', error_code: null, + created_at: new Date('2026-09-26T02:00:00Z'), completed_at: new Date('2026-09-26T02:05:00Z'), + }] }; + }); + const workspaceTransaction = vi.fn(async (_workspace: string, work: (tx: { query: typeof query }) => Promise) => work({ query })); + const service = new ProductImageTaskService( + { workspaceTransaction } as unknown as ConstructorParameters[0], + ); + const result = await service.list(workspaceId, productId, { limit: 2 }); + expect(workspaceTransaction).toHaveBeenCalledWith(workspaceId, expect.any(Function)); + expect(result.meta).toEqual({ has_more: false, next_cursor: null }); + expect(result.data).toEqual([{ + id: 'edit-id', kind: 'single_ai_edit', status: 'running', phase: 'waiting_provider', + total: 1, completed: 0, output_asset_ids: [], error_code: null, provider_error_raw: null, + failure_source: null, failure_stage: null, failure_detail: null, + created_at: '2026-09-26T03:00:00.000Z', completed_at: null, + }, { + id: 'completed-id', kind: 'single_ai_edit', status: 'succeeded', phase: null, + total: 1, completed: 1, output_asset_ids: ['asset-id'], error_code: null, provider_error_raw: null, + failure_source: null, failure_stage: null, failure_detail: null, + created_at: '2026-09-26T02:00:00.000Z', completed_at: '2026-09-26T02:05:00.000Z', + }]); + expect(JSON.stringify(result)).not.toContain('private-provider-id'); + }); + + it('失败任务返回 Pcore 原始错误,不暴露供应商任务 ID', async () => { + const raw = '{"message":"请求无法用于生成图像","type":"image_task_error","code":"failed"}'; + const query = vi.fn(async (sql: string) => { + if (sql.includes('atlas.products')) return { rows: [{ id: productId }] }; + expect(sql).toContain('o.workspace_id = e.workspace_id'); + return { rows: [{ + id: 'failed-id', status: 'failed', provider_task_id: 'private-provider-id', + output_asset_id: null, error_code: 'AI_IMAGE_PROVIDER_FAILED', provider_error_raw: raw, + failure_source: 'pcore', failure_stage: 'provider_polling', failure_detail: 'Pcore 图片任务失败', + created_at: new Date('2026-09-27T03:00:00Z'), completed_at: new Date('2026-09-27T03:01:00Z'), + }] }; + }); + const workspaceTransaction = vi.fn(async (_workspace: string, work: (tx: { query: typeof query }) => Promise) => work({ query })); + const service = new ProductImageTaskService( + { workspaceTransaction } as unknown as ConstructorParameters[0], + ); + const result = await service.list(workspaceId, productId, { limit: 20 }); + expect(result.data[0].provider_error_raw).toBe(raw); + expect(result.data[0]).toMatchObject({ + failure_source: 'pcore', failure_stage: 'provider_polling', failure_detail: 'Pcore 图片任务失败', + }); + expect(JSON.stringify(result)).not.toContain('private-provider-id'); + }); + + it('商品不属于当前工作空间时返回 404,不查询任务', async () => { + const query = vi.fn(async () => ({ rows: [] })); + const workspaceTransaction = vi.fn(async (_workspace: string, work: (tx: { query: typeof query }) => Promise) => work({ query })); + const service = new ProductImageTaskService( + { workspaceTransaction } as unknown as ConstructorParameters[0], + ); + await expect(service.list(workspaceId, productId, { limit: 20 })).rejects.toMatchObject({ status: 404 }); + expect(query).toHaveBeenCalledOnce(); + }); + + it('超过页大小时返回游标,并将游标作为下一页的稳定排序条件', async () => { + const id = '68db226f-3fcb-4579-ab9e-4932b1d84067'; + const rows = [0, 1, 2].map((index) => ({ + id, status: 'succeeded', provider_task_id: null, output_asset_id: null, + error_code: null, created_at: new Date(`2026-09-26T0${3 - index}:00:00Z`), + completed_at: null, + })); + const query = vi.fn(async (sql: string, _values?: unknown[]) => ({ rows: sql.includes('atlas.products') ? [{ id: productId }] : rows })); + const workspaceTransaction = vi.fn(async (_workspace: string, work: (tx: { query: typeof query }) => Promise) => work({ query })); + const service = new ProductImageTaskService( + { workspaceTransaction } as unknown as ConstructorParameters[0], + ); + const first = await service.list(workspaceId, productId, { limit: 2 }); + expect(first.meta.has_more).toBe(true); + expect(first.meta.next_cursor).toEqual(expect.any(String)); + await service.list(workspaceId, productId, { limit: 2, cursor: first.meta.next_cursor! }); + const nextQuery = query.mock.calls.at(-1)!; + expect(nextQuery[0]).toContain('e.created_at, e.id)'); + expect(nextQuery[1]).toEqual([productId, 0, '2026-09-26T02:00:00.000Z', id, 3]); + }); +}); diff --git a/apps/api/test/watermark-default.unit.test.ts b/apps/api/test/watermark-default.unit.test.ts new file mode 100644 index 0000000..f6972ac --- /dev/null +++ b/apps/api/test/watermark-default.unit.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, it, vi } from 'vitest'; +import { WatermarksService } from '../src/watermarks/watermarks.service.js'; + +const templateId = '46ea1f9c-28d0-4b32-92e1-3d4e74ad7757'; + +const templateRow = (isDefault: boolean) => ({ + id: templateId, + name: '品牌水印', + kind: 'text' as const, + is_default: isDefault, + current_version: 1, + created_at: new Date('2026-09-25T00:00:00Z'), + updated_at: new Date('2026-09-25T00:00:00Z'), + revision: 1, + text_content: 'Atlas', + image_asset_id: null, + font_code: 'NotoSans', + font_size_px: 15, + opacity: '0.2600', + scale_ratio: null, + image_size_px: null, + border_radius_px: 0, + position: 'center', + position_x: '0.5000', + position_y: '0.5000', +}); + +describe('工作空间默认水印', () => { + it('切换默认后返回新状态,重复设置不会再写入', async () => { + let isDefault = false; + const statements: string[] = []; + const query = vi.fn(async (sql: string) => { + statements.push(sql); + if (sql.includes('SELECT id FROM atlas.workspaces')) return { rows: [{ id: 'workspace' }] }; + if (sql.includes('SELECT template.id')) return { rows: [templateRow(isDefault)] }; + if (sql.includes('SET is_default = false')) return { rows: [] }; + if (sql.includes('SET is_default = true')) { + isDefault = true; + return { rows: [] }; + } + throw new Error(`未知 SQL: ${sql}`); + }); + const run = vi.fn(async (_input: unknown, work: (tx: { query: typeof query }) => Promise) => + work({ query })); + const service = new WatermarksService( + { run } as unknown as ConstructorParameters[0], + {} as ConstructorParameters[1], + ); + + const first = await service.setDefault('workspace', 'user', templateId, 'key-1'); + expect(first).toMatchObject({ status: 200, body: { data: { id: templateId, is_default: true } } }); + expect(statements.filter((sql) => sql.includes('SET is_default'))).toHaveLength(2); + expect(statements[0]).toContain('FOR UPDATE'); + + const second = await service.setDefault('workspace', 'user', templateId, 'key-2'); + expect(second).toMatchObject({ status: 200, body: { data: { is_default: true } } }); + expect(statements.filter((sql) => sql.includes('SET is_default'))).toHaveLength(2); + expect(run).toHaveBeenCalledWith(expect.objectContaining({ + method: 'PUT', + path: `/api/watermark-templates/${templateId}/default`, + }), expect.any(Function)); + }); + + it('未设置默认时返回 null', async () => { + const query = vi.fn(async () => ({ rows: [] })); + const workspaceTransaction = vi.fn(async (_workspaceId: string, work: (tx: { query: typeof query }) => Promise) => + work({ query })); + const service = new WatermarksService( + {} as ConstructorParameters[0], + { workspaceTransaction } as unknown as ConstructorParameters[1], + ); + + expect(await service.getDefault('workspace')).toBeNull(); + expect(query.mock.calls).toHaveLength(1); + expect(workspaceTransaction).toHaveBeenCalledWith('workspace', expect.any(Function)); + }); + + it('删除默认模板时同时清除默认标记', async () => { + const query = vi.fn(async (sql: string) => { + if (sql.includes('SELECT template.id')) return { rows: [templateRow(true)] }; + return { rows: [] }; + }); + const run = vi.fn(async (_input: unknown, work: (tx: { query: typeof query }) => Promise) => + work({ query })); + const service = new WatermarksService( + { run } as unknown as ConstructorParameters[0], + {} as ConstructorParameters[1], + ); + + expect(await service.remove('workspace', 'user', templateId, 'key')).toEqual({ status: 204, body: null }); + expect(query.mock.calls.some(([sql]) => sql.includes("status = 'archived'") + && sql.includes('is_default = false'))).toBe(true); + }); +}); diff --git a/apps/api/test/watermark-image-size.unit.test.ts b/apps/api/test/watermark-image-size.unit.test.ts new file mode 100644 index 0000000..9362e8a --- /dev/null +++ b/apps/api/test/watermark-image-size.unit.test.ts @@ -0,0 +1,153 @@ +import { describe, expect, it, vi } from 'vitest'; +import { createWatermarkTemplateRequestSchema } from '@atlas/contracts'; +import { WatermarksService } from '../src/watermarks/watermarks.service.js'; + +const templateId = '46ea1f9c-28d0-4b32-92e1-3d4e74ad7757'; +const assetId = '54799515-028d-4320-80a2-f53fd13fb588'; + +const imageRow = (size: number, radius: number, version = 1) => ({ + id: templateId, + name: '图片水印', + kind: 'image' as const, + current_version: version, + created_at: new Date('2026-09-26T00:00:00Z'), + updated_at: new Date('2026-09-26T00:00:00Z'), + revision: version, + text_content: null, + image_asset_id: assetId, + font_code: null, + font_size_px: null, + opacity: '0.2600', + scale_ratio: '0.5556', + image_size_px: size, + border_radius_px: radius, + position: 'bottom_right', + position_x: '1.0000', + position_y: '1.0000', +}); + +const serviceWith = (query: ReturnType) => { + const run = vi.fn(async (_input: unknown, work: (tx: { query: typeof query }) => Promise) => + work({ query })); + return new WatermarksService( + { run } as unknown as ConstructorParameters[0], + {} as ConstructorParameters[1], + ); +}; + +describe('图片水印像素尺寸和圆角', () => { + it('创建契约接收像素尺寸并约束尺寸和圆角范围,同时兼容旧比例', () => { + const base = { + name: '图片水印', kind: 'image', + config: { image_asset_id: assetId, opacity: '0.26', position_x: 1, position_y: 1 }, + }; + expect(createWatermarkTemplateRequestSchema.safeParse({ + ...base, config: { ...base.config, image_size_px: 80, border_radius_px: 12 }, + }).success).toBe(true); + expect(createWatermarkTemplateRequestSchema.safeParse({ + ...base, config: { ...base.config, image_size_px: 10 }, + }).success).toBe(true); + expect(createWatermarkTemplateRequestSchema.safeParse({ + ...base, config: { ...base.config, scale_ratio: '0.25' }, + }).success).toBe(true); + expect(createWatermarkTemplateRequestSchema.safeParse(base).success).toBe(false); + expect(createWatermarkTemplateRequestSchema.safeParse({ + ...base, config: { ...base.config, image_size_px: 9 }, + }).success).toBe(false); + expect(createWatermarkTemplateRequestSchema.safeParse({ + ...base, config: { ...base.config, image_size_px: 201 }, + }).success).toBe(false); + expect(createWatermarkTemplateRequestSchema.safeParse({ + ...base, config: { ...base.config, image_size_px: 80, border_radius_px: 101 }, + }).success).toBe(false); + }); + + it('创建时保存绝对尺寸和圆角,并保留旧比例字段', async () => { + let inserted: unknown[] = []; + const query = vi.fn(async (sql: string, args?: unknown[]) => { + if (sql.includes('SELECT id FROM atlas.media_assets')) return { rows: [{ id: assetId }] }; + if (sql.includes('INSERT INTO atlas.watermark_templates')) return { rows: [{ id: templateId }] }; + if (sql.includes('INSERT INTO atlas.watermark_template_versions')) { + inserted = args ?? []; + return { rows: [] }; + } + if (sql.includes('SELECT template.id')) return { rows: [imageRow(Number(inserted[13]), Number(inserted[14]))] }; + throw new Error(`未知 SQL: ${sql}`); + }); + const result = await serviceWith(query).create('workspace', 'user', 'key', { + name: '图片水印', kind: 'image', + config: { image_asset_id: assetId, image_size_px: 80, border_radius_px: 12, + opacity: '0.26', position_x: 1, position_y: 1 }, + }); + expect(inserted[8]).toBe('0.5556'); + expect(inserted.slice(13, 15)).toEqual([80, 12]); + expect(result).toMatchObject({ body: { data: { config: { + image_size_px: 80, border_radius_px: 12, + } } } }); + }); + + it('旧版比例输入换算为像素尺寸,圆角默认为零', async () => { + let inserted: unknown[] = []; + const query = vi.fn(async (sql: string, args?: unknown[]) => { + if (sql.includes('SELECT id FROM atlas.media_assets')) return { rows: [{ id: assetId }] }; + if (sql.includes('INSERT INTO atlas.watermark_templates')) return { rows: [{ id: templateId }] }; + if (sql.includes('INSERT INTO atlas.watermark_template_versions')) { + inserted = args ?? []; + return { rows: [] }; + } + if (sql.includes('SELECT template.id')) return { rows: [imageRow(36, 0)] }; + throw new Error(`未知 SQL: ${sql}`); + }); + await serviceWith(query).create('workspace', 'user', 'key', { + name: '图片水印', kind: 'image', + config: { image_asset_id: assetId, scale_ratio: '0.25', + opacity: '0.26', position_x: 1, position_y: 1 }, + }); + expect(inserted[8]).toBe('0.25'); + expect(inserted.slice(13, 15)).toEqual([36, 0]); + }); + + it('10px 尺寸原值保存,兼容比例保持旧版允许的最小值', async () => { + let inserted: unknown[] = []; + const query = vi.fn(async (sql: string, args?: unknown[]) => { + if (sql.includes('SELECT id FROM atlas.media_assets')) return { rows: [{ id: assetId }] }; + if (sql.includes('INSERT INTO atlas.watermark_templates')) return { rows: [{ id: templateId }] }; + if (sql.includes('INSERT INTO atlas.watermark_template_versions')) { + inserted = args ?? []; + return { rows: [] }; + } + if (sql.includes('SELECT template.id')) return { rows: [imageRow(10, 0)] }; + throw new Error(`未知 SQL: ${sql}`); + }); + await serviceWith(query).create('workspace', 'user', 'key', { + name: '图片水印', kind: 'image', + config: { image_asset_id: assetId, image_size_px: 10, + opacity: '0.26', position_x: 1, position_y: 1 }, + }); + expect(inserted[8]).toBe('0.1'); + expect(inserted[13]).toBe(10); + }); + + it('只修改圆角时保留图片尺寸并创建新版本', async () => { + let inserted: unknown[] = []; + const query = vi.fn(async (sql: string, args?: unknown[]) => { + if (sql.includes('SELECT template.id') && sql.includes('FOR UPDATE')) return { rows: [imageRow(80, 0)] }; + if (sql.includes('SELECT id FROM atlas.media_assets')) return { rows: [{ id: assetId }] }; + if (sql.includes('UPDATE atlas.watermark_templates')) return { rows: [] }; + if (sql.includes('INSERT INTO atlas.watermark_template_versions')) { + inserted = args ?? []; + return { rows: [] }; + } + if (sql.includes('SELECT template.id')) return { rows: [imageRow(80, 16, 2)] }; + throw new Error(`未知 SQL: ${sql}`); + }); + const result = await serviceWith(query).update( + 'workspace', 'user', templateId, 'key', 1, { config: { border_radius_px: 16 } }, + ); + expect(inserted[13]).toBe(80); + expect(inserted[14]).toBe(16); + expect(result).toMatchObject({ body: { data: { current_version: 2, config: { + image_size_px: 80, border_radius_px: 16, + } } } }); + }); +}); diff --git a/apps/api/test/watermark-position.unit.test.ts b/apps/api/test/watermark-position.unit.test.ts new file mode 100644 index 0000000..23e0ea8 --- /dev/null +++ b/apps/api/test/watermark-position.unit.test.ts @@ -0,0 +1,109 @@ +import { describe, expect, it, vi } from 'vitest'; +import { createWatermarkTemplateRequestSchema } from '@atlas/contracts'; +import { WatermarksService } from '../src/watermarks/watermarks.service.js'; + +const templateId = '46ea1f9c-28d0-4b32-92e1-3d4e74ad7757'; + +const templateRow = (x: number, y: number, position: string) => ({ + id: templateId, + name: '测试水印', + kind: 'text' as const, + current_version: 1, + created_at: new Date('2026-09-25T00:00:00Z'), + updated_at: new Date('2026-09-25T00:00:00Z'), + revision: 1, + text_content: 'Atlas', + image_asset_id: null, + font_code: 'NotoSans', + font_size_px: 15, + opacity: '0.2600', + scale_ratio: null, + position, + position_x: x.toFixed(4), + position_y: y.toFixed(4), +}); + +describe('水印任意位置坐标', () => { + it('契约接收精确坐标、兼容旧位置,并拒绝创建时只给一个坐标', () => { + const base = { + name: '测试水印', + kind: 'text', + config: { + text_content: 'Atlas', + font_code: 'NotoSans', + font_size_px: 15, + opacity: '0.26', + }, + }; + expect(createWatermarkTemplateRequestSchema.safeParse({ + ...base, config: { ...base.config, position_x: 0.3737, position_y: 0.6161 }, + }).success).toBe(true); + expect(createWatermarkTemplateRequestSchema.safeParse({ + ...base, config: { ...base.config, position: 'bottom_right' }, + }).success).toBe(true); + expect(createWatermarkTemplateRequestSchema.safeParse({ + ...base, config: { ...base.config, position_x: 0.3737 }, + }).success).toBe(false); + }); + + it('创建时保存精确坐标并在读取响应中返回,不吸附九宫格', async () => { + let insertedVersion: unknown[] = []; + const query = vi.fn(async (sql: string, args?: unknown[]) => { + if (sql.includes('SELECT code FROM atlas.watermark_fonts')) return { rows: [{ code: 'NotoSans' }] }; + if (sql.includes('INSERT INTO atlas.watermark_templates')) return { rows: [{ id: templateId }] }; + if (sql.includes('INSERT INTO atlas.watermark_template_versions')) { + insertedVersion = args ?? []; + return { rows: [] }; + } + if (sql.includes('SELECT template.id')) return { rows: [templateRow( + Number(insertedVersion[9]), Number(insertedVersion[10]), String(insertedVersion[11]), + )] }; + throw new Error(`未知 SQL: ${sql}`); + }); + const run = vi.fn(async (_input: unknown, work: (tx: { query: typeof query }) => Promise) => + work({ query })); + const service = new WatermarksService( + { run } as unknown as ConstructorParameters[0], + {} as ConstructorParameters[1], + ); + const result = await service.create('workspace', 'user', 'key', { + name: '测试水印', kind: 'text', + config: { + text_content: 'Atlas', font_code: 'NotoSans', font_size_px: 15, + opacity: '0.26', position_x: 0.3737, position_y: 0.6161, + }, + }); + expect(insertedVersion.slice(9, 12)).toEqual([0.3737, 0.6161, 'center']); + expect(result).toMatchObject({ + body: { data: { config: { position_x: 0.3737, position_y: 0.6161 } } }, + }); + }); + + it('旧版九宫格输入换算为精确坐标', async () => { + let insertedVersion: unknown[] = []; + const query = vi.fn(async (sql: string, args?: unknown[]) => { + if (sql.includes('SELECT code FROM atlas.watermark_fonts')) return { rows: [{ code: 'NotoSans' }] }; + if (sql.includes('INSERT INTO atlas.watermark_templates')) return { rows: [{ id: templateId }] }; + if (sql.includes('INSERT INTO atlas.watermark_template_versions')) { + insertedVersion = args ?? []; + return { rows: [] }; + } + if (sql.includes('SELECT template.id')) return { rows: [templateRow(1, 0, 'top_right')] }; + throw new Error(`未知 SQL: ${sql}`); + }); + const run = vi.fn(async (_input: unknown, work: (tx: { query: typeof query }) => Promise) => + work({ query })); + const service = new WatermarksService( + { run } as unknown as ConstructorParameters[0], + {} as ConstructorParameters[1], + ); + await service.create('workspace', 'user', 'key', { + name: '测试水印', kind: 'text', + config: { + text_content: 'Atlas', font_code: 'NotoSans', font_size_px: 15, + opacity: '0.26', position: 'top_right', + }, + }); + expect(insertedVersion.slice(9, 12)).toEqual([1, 0, 'top_right']); + }); +}); diff --git a/apps/extension/lib/source-parsers.ts b/apps/extension/lib/source-parsers.ts index ca4bddf..ca3de34 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 } from './product-media.js'; +import { collectPlatformDomAssets, normaliseImageUrl } from './product-media.js'; import { formatCollectedDescription } from './description-format.js'; type SourcePlatform = '1688' | 'taobao' | 'tmall' | 'ozon'; @@ -49,7 +49,10 @@ export async function collectCurrentProduct( function collect1688(root: Document, pageUrl: string, bridgedState?: unknown): CollectedProductPayload { const state = [bridgedState, ...pageState(root)]; - const skuMap = findFirst(state, 'skuMapOriginal'); + const skuInfoMap = findFirst(state, 'skuInfoMap'); + const skuMap = isRecord(skuInfoMap) && Object.keys(skuInfoMap).length + ? skuInfoMap + : findFirst(state, 'skuMapOriginal'); const skuProps = findFirst(state, 'skuProps'); const axes = parse1688Axes(skuProps); const evidence: CollectedProductPayload['evidence'] = [ @@ -70,32 +73,29 @@ function collect1688(root: Document, pageUrl: string, bridgedState?: unknown): C evidence.push({ ref: '1688:sku-map', kind: 'state', - path: 'skuMapOriginal', + path: isRecord(skuInfoMap) ? 'skuModel.skuInfoMap' : 'skuMapOriginal', raw_value: `组合记录 ${skuRows.length} 条`, }); for (const [combination, row] of skuRows) { if (!isRecord(row)) continue; const sourceSku = stringAt(row, ['skuId', 'sku_id', 'skuCode']); - // A skuMapOriginal entry itself is a source-provided sellable-combination - // record. Never expand it with the Cartesian product of skuProps. + const specAttrs = stringAt(row, ['specAttrs', 'propPath']) ?? combination; + const selections = selectionsFor1688Combination(specAttrs, axes); + // 来源记录本身证明可售组合,不能用规格轴的笛卡尔积扩充 SKU。 const sourceVariantKey = sourceSku ?? `1688:${combination}`; if (!combination || combination === 'undefined') continue; variants.push({ sourceVariantKey, ...(sourceSku ? { sourceSku } : {}), displayName: - selectionsForCombination(combination, axes) + selections .map((item) => item.valueName) - .join(' / ') || `1688 SKU ${sourceSku ?? combination}`, - selections: selectionsForCombination(combination, axes), - ...(moneyAt(row, ['discountPrice', 'price', 'salePrice']) + .join(' / ') || specAttrs, + selections, + ...(moneyAt(row, ['price']) ? { sourcePrice: { - amount: moneyAt(row, [ - 'discountPrice', - 'price', - 'salePrice', - ])!, + amount: moneyAt(row, ['price'])!, currencyCode: 'CNY', }, } @@ -105,7 +105,7 @@ function collect1688(root: Document, pageUrl: string, bridgedState?: unknown): C }); } } - return basePayload( + const payload = basePayload( '1688', offerId(pageUrl), root, @@ -113,9 +113,29 @@ function collect1688(root: Document, pageUrl: string, bridgedState?: unknown): C axes, uniqueVariants(variants), evidence, - '1688-structured-2.0.1', + '1688-structured-2.1.0', state, ); + // SKU 图片由规格值和真实可售组合关联,独立于页面当时选中的 DOM 状态。 + for (const variant of payload.variants) { + for (const selection of variant.selections) { + const rawUrl = axes.find((axis) => axis.key === selection.axisKey) + ?.values.find((value) => value.key === selection.valueKey)?.image_url; + const url = rawUrl && normaliseImageUrl(rawUrl, pageUrl, '1688'); + if (!url || payload.assets.length >= 500) continue; + if (payload.assets.some((asset) => asset.variantSourceKey === variant.sourceVariantKey && asset.url === url)) continue; + payload.assets.push({ + url, + mediaType: 'image', + role: 'source_sku', + relation: 'axis_value', + variantSourceKey: variant.sourceVariantKey, + sortOrder: payload.assets.length, + evidenceRef: '1688:sku-map', + }); + } + } + return payload; } function collectTaobao( @@ -411,13 +431,14 @@ function pageState(root: Document) { } } for (const marker of [ + 'skuInfoMap', 'skuMapOriginal', 'skuBase', 'skuCore', 'skuProps', ]) { const object = objectAfterMarker(text, marker); - if (object) all.push(object); + if (object) all.push({ [marker]: object }); } } return all; @@ -589,6 +610,19 @@ function selectionsForCombination(combination: string, axes: Axis[]) { : []; }); } +function selectionsFor1688Combination(combination: string, axes: Axis[]) { + const selected = selectionsForCombination(combination, axes); + if (selected.length === axes.length) return selected; + const parts = combination.split(/[;,&|]/).map((part) => part.trim()).filter(Boolean); + return axes.flatMap((axis) => { + const existing = selected.find((item) => item.axisKey === axis.key); + if (existing) return [existing]; + const value = axis.values.find((item) => + parts.some((part) => part === item.name || part === `${axis.name}:${item.name}`) + ); + return value ? [{ axisKey: axis.key, valueKey: value.key, valueName: value.name }] : []; + }); +} function stringAt(value: Json, keys: string[]) { for (const key of keys) if (typeof value[key] === 'string' || typeof value[key] === 'number') diff --git a/apps/extension/test/1688-sku.test.mts b/apps/extension/test/1688-sku.test.mts new file mode 100644 index 0000000..b5f8f04 --- /dev/null +++ b/apps/extension/test/1688-sku.test.mts @@ -0,0 +1,62 @@ +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import test from 'node:test'; +import { collectCurrentProduct } from '../lib/source-parsers.ts'; + +function fixtureSkuModel(html: string) { + const marker = '"skuModel": '; + const start = html.indexOf(marker); + assert.ok(start >= 0); + const source = html.slice(start + marker.length); + let depth = 0; + let quoted = false; + let escaped = false; + for (let index = 0; index < source.length; index += 1) { + const char = source[index]; + if (quoted) { + if (escaped) escaped = false; + else if (char === '\\') escaped = true; + else if (char === '"') quoted = false; + } else if (char === '"') quoted = true; + else if (char === '{') depth += 1; + else if (char === '}' && --depth === 0) return JSON.parse(source.slice(0, index + 1)); + } + throw new Error('1688 样本缺少完整 skuModel'); +} + +test('1688 真实样本按 skuInfoMap 的 price 和规格图片生成逐 SKU 数据', async () => { + const html = await readFile(new URL('../../../packages/reference/1688.html', import.meta.url), 'utf8'); + const skuModel = fixtureSkuModel(html); + const root = { + scripts: [], + querySelector: () => null, + querySelectorAll: () => [], + } as unknown as Document; + const payload = await collectCurrentProduct( + root, + 'https://detail.1688.com/offer/123456789.html', + undefined, + async () => ({ skuModel }), + ); + assert.equal(payload.variants.length, Object.keys(skuModel.skuInfoMap).length); + for (const [name, row] of Object.entries(skuModel.skuInfoMap)) { + const variant = payload.variants.find((item) => item.sourceSku === String(row.skuId)); + assert.ok(variant, `缺少 ${name}`); + assert.equal(variant.displayName, name); + assert.equal(variant.sourcePrice?.amount, row.price); + const image = skuModel.skuProps[0].value.find((item: any) => item.name === name)?.imageUrl; + assert.ok(payload.assets.some((asset) => + asset.variantSourceKey === variant.sourceVariantKey && asset.url === image + ), `${name} 未关联规格图片`); + } + const scriptRoot = { + ...root, + scripts: [{ type: 'text/javascript', textContent: html }], + } as unknown as Document; + const withoutBridge = await collectCurrentProduct( + scriptRoot, + 'https://detail.1688.com/offer/123456789.html', + ); + assert.equal(withoutBridge.variants.length, payload.variants.length); + assert.equal(withoutBridge.assets.filter((asset) => asset.variantSourceKey).length, payload.variants.length); +}); diff --git a/apps/web/src/App.css b/apps/web/src/App.css index a00a567..823b961 100644 --- a/apps/web/src/App.css +++ b/apps/web/src/App.css @@ -94,36 +94,12 @@ margin-top: 16px; } -/* 3x3 水印位置九宫格 */ -.position-grid { - display: grid; - grid-template-columns: repeat(3, 44px); - grid-auto-rows: 44px; - gap: 8px; -} - -.position-grid-cell { - border: 1px solid var(--border-color); - border-radius: 6px; - background: #f5f5f5; - cursor: pointer; - transition: all 0.2s; - padding: 0; -} - -.position-grid-cell:hover { - border-color: var(--primary-light); -} - -.position-grid-cell.active { - border-color: var(--primary-color); - background: rgba(139, 92, 246, 0.12); -} - /* 水印预览:模拟透明底图的棋盘格 */ .watermark-preview { display: flex; - padding: 12px; + position: relative; + max-width: 100%; + padding: 5px; box-sizing: border-box; border: 1px dashed var(--border-color); border-radius: 4px; @@ -132,6 +108,49 @@ overflow: hidden; } +.watermark-preview-mark { + display: inline-flex; + position: absolute; + align-items: center; + justify-content: center; + max-width: calc(100% - 10px); + max-height: calc(100% - 10px); + overflow: hidden; +} + +.watermark-preview-mark-draggable { + cursor: grab; + touch-action: none; + user-select: none; +} + +.watermark-preview-mark-dragging { + cursor: grabbing; + z-index: 1; +} + +.watermark-preview-dialog-content { + display: flex; + justify-content: center; +} + +.watermark-preview-dialog-footer { + display: flex; + justify-content: center; +} + +.watermark-editor-pair { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + column-gap: 16px; +} + +@media (max-width: 575px) { + .watermark-editor-pair { + grid-template-columns: minmax(0, 1fr); + } +} + .watermark-form-actions { display: flex; justify-content: center; diff --git a/apps/web/src/components/WatermarkPreview.tsx b/apps/web/src/components/WatermarkPreview.tsx index e0f18f7..2f5e4bb 100644 --- a/apps/web/src/components/WatermarkPreview.tsx +++ b/apps/web/src/components/WatermarkPreview.tsx @@ -1,10 +1,10 @@ -import { useState } from 'react'; -import type { CSSProperties } from 'react'; +import { useRef, useState } from 'react'; +import type { PointerEvent as ReactPointerEvent, KeyboardEvent as ReactKeyboardEvent } from 'react'; import { Typography } from 'antd'; import type { WatermarkPosition } from '@atlas/contracts'; import type { WatermarkTemplateConfig } from '@atlas/contracts'; -/** 九宫格位置选项,顺序与编辑器 3x3 网格一致 */ +/** 旧版九宫格位置与精确坐标的映射,供已有模板读取。 */ export const WATERMARK_POSITION_OPTIONS: Array<{ value: WatermarkPosition; label: string; @@ -25,71 +25,173 @@ export const WATERMARK_POSITION_OPTIONS: Array<{ export const watermarkPositionLabel = (position: string): string => WATERMARK_POSITION_OPTIONS.find((option) => option.value === position)?.label ?? position; -const POSITION_FLEX: Record< - WatermarkPosition, - Pick -> = { - top_left: { justifyContent: 'flex-start', alignItems: 'flex-start' }, - top_center: { justifyContent: 'center', alignItems: 'flex-start' }, - top_right: { justifyContent: 'flex-end', alignItems: 'flex-start' }, - middle_left: { justifyContent: 'flex-start', alignItems: 'center' }, - center: { justifyContent: 'center', alignItems: 'center' }, - middle_right: { justifyContent: 'flex-end', alignItems: 'center' }, - bottom_left: { justifyContent: 'flex-start', alignItems: 'flex-end' }, - bottom_center: { justifyContent: 'center', alignItems: 'flex-end' }, - bottom_right: { justifyContent: 'flex-end', alignItems: 'flex-end' }, -}; - interface WatermarkPreviewProps { /** 允许传入部分字段:编辑弹窗按当前表单值实时渲染 */ config: Partial; width?: number; height?: number; + onPositionChange?: (position: { position_x: number; position_y: number }) => void; } +type DragState = { + pointerId: number; + offsetX: number; + offsetY: number; + markWidth: number; + markHeight: number; +}; + +const clamp = (value: number, min: number, max: number) => + Math.min(Math.max(value, min), Math.max(min, max)); + +const roundCoordinate = (value: number) => Math.round(clamp(value, 0, 1) * 10_000) / 10_000; +const PREVIEW_INSET_PX = 5; +// 开发服务直接加载前端源码,范围须与共享契约中的校验值保持一致。 +export const WATERMARK_IMAGE_SIZE_MIN_PX = 10; +export const WATERMARK_IMAGE_SIZE_DEFAULT_PX = 80; +export const WATERMARK_IMAGE_SIZE_MAX_PX = 200; +export const WATERMARK_BORDER_RADIUS_MAX_PX = 100; + /** * 按模板 config 在内置演示底图上实时渲染水印效果(docs/api/watermarks-and-assets.md §3.1), * 不调用服务端渲染;图片使用响应中的短时 preview_url,过期时提示重新读取。 */ -const WatermarkPreview = ({ config, width = 320, height = 420 }: WatermarkPreviewProps) => { - const [imageBroken, setImageBroken] = useState(false); - const position = config.position ?? 'bottom_right'; - const flex = POSITION_FLEX[position] ?? POSITION_FLEX.bottom_right; +const WatermarkPreview = ({ config, width = 240, height = 320, onPositionChange }: WatermarkPreviewProps) => { + const [brokenImageUrl, setBrokenImageUrl] = useState(null); + const [imageDimensions, setImageDimensions] = useState<{ url: string; width: number; height: number } | null>(null); + const [dragPoint, setDragPoint] = useState<{ left: number; top: number } | null>(null); + const previewRef = useRef(null); + const dragRef = useRef(null); + const legacy = WATERMARK_POSITION_OPTIONS.find((option) => option.value === config.position); + const positionX = config.position_x ?? (legacy ? legacy.col / 2 : 1); + const positionY = config.position_y ?? (legacy ? legacy.row / 2 : 1); const previewUrl = config.image_asset?.preview_url; + const legacyImageSize = Number(config.scale_ratio) * width * 0.6; + const imageSize = config.image_size_px + ?? (Number.isFinite(legacyImageSize) + ? Math.round(legacyImageSize) : WATERMARK_IMAGE_SIZE_DEFAULT_PX); + const imageAspect = imageDimensions && imageDimensions.url === previewUrl && imageDimensions.height > 0 + ? imageDimensions.width / imageDimensions.height : 1; + const imageWidth = imageAspect >= 1 ? imageSize : Math.max(1, Math.round(imageSize * imageAspect)); + const imageHeight = imageAspect >= 1 ? Math.max(1, Math.round(imageSize / imageAspect)) : imageSize; + + const moveMark = (event: ReactPointerEvent) => { + const drag = dragRef.current; + const preview = previewRef.current; + if (!drag || !preview || drag.pointerId !== event.pointerId) return null; + const bounds = preview.getBoundingClientRect(); + const point = { + left: clamp(event.clientX - bounds.left - drag.offsetX, PREVIEW_INSET_PX, bounds.width - drag.markWidth - PREVIEW_INSET_PX), + top: clamp(event.clientY - bounds.top - drag.offsetY, PREVIEW_INSET_PX, bounds.height - drag.markHeight - PREVIEW_INSET_PX), + }; + setDragPoint(point); + return { point, bounds, drag }; + }; + + const startDrag = (event: ReactPointerEvent) => { + if (!onPositionChange || !previewRef.current) return; + const markBounds = event.currentTarget.getBoundingClientRect(); + const previewBounds = previewRef.current.getBoundingClientRect(); + dragRef.current = { + pointerId: event.pointerId, + offsetX: event.clientX - markBounds.left, + offsetY: event.clientY - markBounds.top, + markWidth: markBounds.width, + markHeight: markBounds.height, + }; + setDragPoint({ left: markBounds.left - previewBounds.left, top: markBounds.top - previewBounds.top }); + event.currentTarget.setPointerCapture(event.pointerId); + event.preventDefault(); + }; + + const finishDrag = (event: ReactPointerEvent) => { + const result = moveMark(event); + if (!result) return; + const { point, bounds, drag } = result; + dragRef.current = null; + setDragPoint(null); + event.currentTarget.releasePointerCapture(event.pointerId); + // 坐标按水印实际可移动范围归一化,松手后保留精确位置。 + const travelX = bounds.width - drag.markWidth - PREVIEW_INSET_PX * 2; + const travelY = bounds.height - drag.markHeight - PREVIEW_INSET_PX * 2; + onPositionChange?.({ + position_x: roundCoordinate(travelX > 0 ? (point.left - PREVIEW_INSET_PX) / travelX : 0), + position_y: roundCoordinate(travelY > 0 ? (point.top - PREVIEW_INSET_PX) / travelY : 0), + }); + }; + + const moveWithKeyboard = (event: ReactKeyboardEvent) => { + if (!onPositionChange) return; + let x = positionX; + let y = positionY; + if (event.key === 'ArrowUp') y -= 0.01; + else if (event.key === 'ArrowDown') y += 0.01; + else if (event.key === 'ArrowLeft') x -= 0.01; + else if (event.key === 'ArrowRight') x += 0.01; + else return; + event.preventDefault(); + onPositionChange({ position_x: roundCoordinate(x), position_y: roundCoordinate(y) }); + }; return (
- {config.image_asset ? ( - imageBroken || !previewUrl ? ( - 图片预览已过期或不可用 +
{ dragRef.current = null; setDragPoint(null); }} + onKeyDown={moveWithKeyboard} + > + {config.image_asset ? ( + brokenImageUrl === previewUrl || !previewUrl ? ( + 图片预览已过期或不可用 + ) : ( + 水印图片 setImageDimensions({ + url: previewUrl, + width: event.currentTarget.naturalWidth, + height: event.currentTarget.naturalHeight, + })} + onError={() => setBrokenImageUrl(previewUrl)} + /> + ) ) : ( - 水印图片 setImageBroken(true)} - /> - ) - ) : ( - - {config.text_content || '文字水印'} - - )} + > + {config.text_content || '文字水印'} + + )} +
); }; diff --git a/apps/web/src/pages/collection/CollectionPage.css b/apps/web/src/pages/collection/CollectionPage.css new file mode 100644 index 0000000..73ab876 --- /dev/null +++ b/apps/web/src/pages/collection/CollectionPage.css @@ -0,0 +1,15 @@ +.collection-toolbar { + display: flex; + align-items: center; + justify-content: space-between; + flex-wrap: wrap; + gap: 16px; + margin-bottom: 16px; +} + +.collection-toolbar-usage { + width: 240px; + max-width: 100%; + margin-left: auto; + text-align: right; +} diff --git a/apps/web/src/pages/collection/CollectionPage.tsx b/apps/web/src/pages/collection/CollectionPage.tsx index ded26d2..78d2d4d 100644 --- a/apps/web/src/pages/collection/CollectionPage.tsx +++ b/apps/web/src/pages/collection/CollectionPage.tsx @@ -8,6 +8,7 @@ import { collectionApi } from '@/services/collection'; import { apiErrorMessage } from '@/services/api'; import { usePlatform } from '@/services/platform'; import { productEditorApi } from '@/services/productEditor'; +import './CollectionPage.css'; const statusLabel: Record = { received: '已接收', materializing: '素材处理中', ready_for_edit: '可编辑', needs_review: '待确认', failed: '采集失败' }; export default function CollectionPage() { @@ -21,8 +22,10 @@ export default function CollectionPage() { { title: '产品名称', dataIndex: 'title', render: (value) => value || '未命名商品' }, { title: '来源', dataIndex: 'source', width: 110, render: (value) => {value.kind === 'manual' ? '手动创建' : value.platform_code} }, { title: 'SKU', dataIndex: 'variant_summary', width: 80, render: (value) => value.count }, { title: '采集状态', dataIndex: 'collection_status', width: 120, render: (value) => statusLabel[value] ?? value }, { title: '当前平台上架', dataIndex: 'listing_summary', width: 150, render: (value) => {value.detail} }, { title: '备注', dataIndex: 'note', ellipsis: true, render: (value) => value || '—' }, { title: '更新时间', dataIndex: 'updated_at', width: 170, render: (value) => new Date(value).toLocaleString('zh-CN', { hour12: false }) }, { title: '操作', width: 150, render: (_, item) => }, ]; return }> - `${meta.active_count} / ${meta.collection_limit}`} style={{ maxWidth: 420, marginBottom: 18 }} /> - setQ(e.target.value)} placeholder="搜索产品名称" allowClear style={{ width: 220 }} /> setQ(e.target.value)} placeholder="搜索产品名称" allowClear style={{ width: 220 }} /> ; diff --git a/apps/web/src/pages/online-products/OnlineProductsPage.css b/apps/web/src/pages/online-products/OnlineProductsPage.css new file mode 100644 index 0000000..a747f6b --- /dev/null +++ b/apps/web/src/pages/online-products/OnlineProductsPage.css @@ -0,0 +1,123 @@ +.online-products-page { + display: flex; + flex-direction: column; + gap: 18px; +} +.online-products-heading { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; +} +.online-products-heading h3 { + margin: 0 0 4px !important; +} +.online-products-store-select { + min-width: 190px; + max-width: 260px; +} +.online-products-sync-status { + background: #fff; +} +.online-products-filters { + display: flex; + flex-wrap: wrap; + gap: 9px; +} +.online-products-search { + width: min(320px, 100%); +} +.online-products-filter { + min-width: 145px; +} +.online-products-list { + overflow: hidden; + background: #fff; + border: 1px solid var(--border-color); + border-radius: 8px; +} +.online-products-list .ant-table-wrapper { + overflow: hidden; +} +.online-products-list-footer { + display: flex; + align-items: center; + justify-content: space-between; + min-height: 53px; + padding: 8px 16px; + border-top: 1px solid var(--border-color); +} +.online-product-name-cell { + display: flex; + align-items: center; + gap: 10px; + min-width: 0; +} +.online-product-image { + display: flex; + flex: 0 0 44px; + align-items: center; + justify-content: center; + width: 44px; + height: 44px; + overflow: hidden; + border-radius: 6px; + background: #f3f4f6; + color: var(--text-secondary); + font-size: 11px; +} +.online-product-image img { + object-fit: cover; +} +.online-product-name-text, +.online-product-ids { + display: flex; + flex-direction: column; + min-width: 0; +} +.online-product-name-text .ant-typography { + display: block; + max-width: 200px; +} +.online-product-name-text .ant-typography + .ant-typography, +.online-product-ids .ant-typography + .ant-typography { + font-size: 12px; +} +.online-product-detail { + width: 100%; +} +.online-product-detail-heading { + display: flex; + align-items: center; + gap: 14px; +} +.online-product-detail-heading h5 { + margin: 0 0 4px !important; +} +.online-product-detail-empty-image { + display: grid; + place-items: center; + flex: 0 0 64px; + height: 64px; + border-radius: 6px; + background: #f3f4f6; + color: var(--text-secondary); +} +@media (max-width: 760px) { + .online-products-heading { + flex-direction: column; + } + .online-products-heading > .ant-space { + width: 100%; + } + .online-products-store-select { + flex: 1; + } + .online-products-filters > * { + flex: 1 1 145px; + } + .online-products-search { + flex-basis: 100%; + width: 100%; + } +} diff --git a/apps/web/src/pages/online-products/OnlineProductsPage.tsx b/apps/web/src/pages/online-products/OnlineProductsPage.tsx index 77dc6de..db6b63c 100644 --- a/apps/web/src/pages/online-products/OnlineProductsPage.tsx +++ b/apps/web/src/pages/online-products/OnlineProductsPage.tsx @@ -1,11 +1,426 @@ -import PagePlaceholder from '@/components/PagePlaceholder'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { App as AntdApp, Alert, Button, Card, Select, Space, Typography } from 'antd'; +import { ReloadOutlined } from '@ant-design/icons'; +import type { + OnlineProduct, + OnlineProductSyncRun, + OnlineProductsMeta, + Store, +} from '@atlas/contracts'; +import { apiErrorCode, apiErrorMessage } from '@/services/api'; +import { onlineProductsApi, type OnlineProductFilters } from '@/services/onlineProducts'; +import { usePlatform } from '@/services/platform'; +import { storesApi } from '@/services/stores'; +import { OnlineProductDetailDrawer } from './components/OnlineProductDetailDrawer'; +import { OnlineProductFilters as Filters } from './components/OnlineProductFilters'; +import { OnlineProductTable } from './components/OnlineProductTable'; +import { OnlineSyncStatus } from './components/OnlineSyncStatus'; +import './OnlineProductsPage.css'; -/** 在线商品:按 SKU 查看平台已上架商品与同步状态(业务模块尚未实现) */ -const OnlineProductsPage = () => ( - -); +const initialFilters: OnlineProductFilters = { q: '', visibility: 'all' }; +const isActive = (run: OnlineProductSyncRun | null) => + run?.status === 'queued' || run?.status === 'running'; -export default OnlineProductsPage; +export default function OnlineProductsPage() { + const { message } = AntdApp.useApp(); + const navigate = useNavigate(); + const { + currentPlatform, + context, + contextLoading, + error: platformError, + reload: reloadPlatforms, + } = usePlatform(); + const platformId = currentPlatform?.id ?? null; + const [stores, setStores] = useState([]); + const [storesLoadedPlatformId, setStoresLoadedPlatformId] = useState(null); + const [storesLoading, setStoresLoading] = useState(false); + const [storesError, setStoresError] = useState(null); + const [storesRetry, setStoresRetry] = useState(0); + const [selectedStore, setSelectedStore] = useState<{ platformId: string; id: string } | null>(null); + const [filters, setFilters] = useState(initialFilters); + const [items, setItems] = useState([]); + const [meta, setMeta] = useState(null); + const [loadedScope, setLoadedScope] = useState(null); + const [loading, setLoading] = useState(false); + const [loadingMore, setLoadingMore] = useState(false); + const [listError, setListError] = useState(null); + const [refreshVersion, setRefreshVersion] = useState(0); + const [latestRun, setLatestRun] = useState(null); + const [runScope, setRunScope] = useState(null); + const [history, setHistory] = useState([]); + const [historyCursor, setHistoryCursor] = useState(null); + const [historyOpen, setHistoryOpen] = useState(false); + const [historyLoading, setHistoryLoading] = useState(false); + const [historyError, setHistoryError] = useState(null); + const [syncStarting, setSyncStarting] = useState(false); + const [detailOpen, setDetailOpen] = useState(false); + const [detail, setDetail] = useState(null); + const [detailScope, setDetailScope] = useState(null); + const [detailLoading, setDetailLoading] = useState(false); + const [detailError, setDetailError] = useState(null); + const listRequestRef = useRef(0); + const detailRequestRef = useRef(0); + const scopeRef = useRef(null); + + const contextStoreId = + context?.platform.id === platformId ? (context.default_store?.id ?? null) : null; + const scopedStores = useMemo(() => stores.filter((store) => store.platform_id === platformId), [stores, platformId]); + const storeId = + (selectedStore?.platformId === platformId ? selectedStore.id : null) ?? + contextStoreId ?? scopedStores.find((store) => store.is_default)?.id ?? null; + const store = useMemo( + () => scopedStores.find((item) => item.id === storeId) ?? null, + [scopedStores, storeId] + ); + const scopeKey = platformId && storeId ? `${platformId}:${storeId}` : null; + scopeRef.current = scopeKey; + const visibleItems = loadedScope === scopeKey ? items : []; + const visibleMeta = loadedScope === scopeKey ? meta : null; + const visibleListError = loadedScope === scopeKey ? listError : null; + const visibleRun = runScope === scopeKey ? latestRun : null; + const visibleHistory = runScope === scopeKey ? history : []; + const visibleHistoryError = runScope === scopeKey ? historyError : null; + const visibleHistoryCursor = runScope === scopeKey ? historyCursor : null; + const canSync = + currentPlatform?.code.toUpperCase() === 'OZON' && + store?.status === 'active' && + store.credential_status === 'valid'; + + useEffect(() => { + setSelectedStore(null); + setStores([]); + setStoresLoadedPlatformId(null); + setFilters(initialFilters); + setItems([]); + setMeta(null); + setLatestRun(null); + setHistory([]); + setHistoryError(null); + setHistoryOpen(false); + detailRequestRef.current += 1; + setDetailOpen(false); + if (!platformId) return; + let cancelled = false; + setStoresLoading(true); + setStoresError(null); + void (async () => { + try { + const all: Store[] = []; + let cursor: string | null = null; + do { + const result = await storesApi.list(platformId, cursor ?? undefined); + all.push(...result.data); + cursor = result.meta.next_cursor; + } while (cursor); + if (!cancelled) setStores(all); + } catch (error) { + if (!cancelled) setStoresError(apiErrorMessage(error)); + } finally { + if (!cancelled) { setStoresLoading(false); setStoresLoadedPlatformId(platformId); } + } + })(); + return () => { + cancelled = true; + }; + }, [platformId, storesRetry]); + + useEffect(() => { + const requestId = ++listRequestRef.current; + setItems([]); + setMeta(null); + setLoadedScope(null); + setRunScope(null); + setLoadingMore(false); + setListError(null); + setLatestRun(null); + setHistoryError(null); + setHistoryLoading(false); + if (!platformId || !storeId) { + setLoading(false); + return; + } + setLoading(true); + void Promise.allSettled([ + onlineProductsApi.list(platformId, storeId, filters), + onlineProductsApi.listSyncRuns(platformId, storeId), + ]).then(([listResult, runsResult]) => { + if (requestId !== listRequestRef.current) return; + if (listResult.status === 'fulfilled') { + setItems(listResult.value.data); + setMeta(listResult.value.meta); + } else setListError(apiErrorMessage(listResult.reason)); + setLoadedScope(`${platformId}:${storeId}`); + if (runsResult.status === 'fulfilled') { + setLatestRun(runsResult.value.data[0] ?? null); + setHistory(runsResult.value.data); + setHistoryCursor(runsResult.value.meta.next_cursor); + } else setHistoryError(apiErrorMessage(runsResult.reason)); + setRunScope(`${platformId}:${storeId}`); + setLoading(false); + }); + }, [platformId, storeId, filters, refreshVersion]); + + useEffect(() => { + if (!platformId || !storeId || !isActive(visibleRun)) return; + const runId = visibleRun!.id; + let cancelled = false; + const timer = window.setInterval(() => { + void onlineProductsApi + .getSyncRun(platformId, runId) + .then((run) => { + if (cancelled) return; + setLatestRun(run); + setHistory((previous) => + previous.map((item) => (item.id === run.id ? run : item)) + ); + if (!isActive(run)) setRefreshVersion((value) => value + 1); + }) + .catch(() => {}); + }, 2500); + return () => { + cancelled = true; + window.clearInterval(timer); + }; + }, [platformId, storeId, visibleRun?.id, visibleRun?.status]); + + const loadMore = async () => { + if (!platformId || !storeId || !visibleMeta?.next_cursor || loadingMore) return; + const requestId = listRequestRef.current; + setLoadingMore(true); + try { + const result = await onlineProductsApi.list( + platformId, + storeId, + filters, + visibleMeta.next_cursor + ); + if (requestId !== listRequestRef.current) return; + setItems((previous) => [...previous, ...result.data]); + setMeta(result.meta); + } catch (error) { + message.error(apiErrorMessage(error)); + } finally { + if (requestId === listRequestRef.current) setLoadingMore(false); + } + }; + + const startSync = async () => { + if (!platformId || !storeId || !scopeKey || syncStarting || !canSync) return; + const requestScope = scopeKey; + setSyncStarting(true); + try { + const result = await onlineProductsApi.createSync(platformId, storeId); + if (scopeRef.current !== requestScope) return; + setRunScope(scopeKey); + setLatestRun(result.sync_run); + setHistory((previous) => [ + result.sync_run, + ...previous.filter((run) => run.id !== result.sync_run.id), + ]); + message.success('已开始同步 Ozon 商品'); + } catch (error) { + if (scopeRef.current !== requestScope) return; + if (apiErrorCode(error) === 'SYNC_ALREADY_RUNNING') { + setRefreshVersion((value) => value + 1); + message.info('该店铺已有正在进行的商品同步'); + } else message.error(apiErrorMessage(error)); + } finally { + setSyncStarting(false); + } + }; + + const openDetail = useCallback( + async (item: OnlineProduct) => { + if (!platformId || !scopeKey) return; + const requestId = ++detailRequestRef.current; + setDetailOpen(true); + setDetail(item); + setDetailScope(scopeKey); + setDetailError(null); + setDetailLoading(true); + try { + const result = await onlineProductsApi.get(platformId, item.id); + if (requestId === detailRequestRef.current && scopeRef.current === scopeKey) setDetail(result); + } catch (error) { + if (requestId === detailRequestRef.current && scopeRef.current === scopeKey) setDetailError(apiErrorMessage(error)); + } finally { + if (requestId === detailRequestRef.current && scopeRef.current === scopeKey) setDetailLoading(false); + } + }, + [platformId, scopeKey] + ); + + const loadMoreHistory = async () => { + if (!platformId || !storeId || !scopeKey || !visibleHistoryCursor || historyLoading) return; + const requestScope = scopeKey; + setHistoryLoading(true); + setHistoryError(null); + try { + const result = await onlineProductsApi.listSyncRuns(platformId, storeId, visibleHistoryCursor); + if (scopeRef.current !== requestScope) return; + setHistory((previous) => [...previous, ...result.data]); + setHistoryCursor(result.meta.next_cursor); + } catch (error) { + if (scopeRef.current === requestScope) setHistoryError(apiErrorMessage(error)); + } finally { + if (scopeRef.current === requestScope) setHistoryLoading(false); + } + }; + + if (platformError) + return ( + 重试} + /> + ); + return ( +
+
+
+ 在线商品 + + 查看店铺在 {currentPlatform?.name ?? '当前平台'}{' '} + 的实际商品状态。每行对应一个 SKU。 + +
+ + onChange({ ...filters, visibility })} + options={[ + { value: 'all', label: '全部可见性' }, + { value: 'visible', label: '可见' }, + { value: 'invisible', label: '不可见' }, + { value: 'unknown', label: '未知' }, + ]} + aria-label="筛选可见性" + disabled={disabled} + className="online-products-filter" + /> + onChange({ ...filters, moderation_status })} + allowClear + placeholder="全部审核" + options={[ + { value: 'approved', label: '已通过' }, + { value: 'pending', label: '待审核' }, + { value: 'declined', label: '未通过' }, + ]} + aria-label="筛选审核状态" + disabled={disabled} + className="online-products-filter" + /> + +
+ ); +} diff --git a/apps/web/src/pages/online-products/components/OnlineProductTable.tsx b/apps/web/src/pages/online-products/components/OnlineProductTable.tsx new file mode 100644 index 0000000..df54188 --- /dev/null +++ b/apps/web/src/pages/online-products/components/OnlineProductTable.tsx @@ -0,0 +1,145 @@ +import { Button, Empty, Image, Table, Tag, Typography } from 'antd'; +import type { TableProps } from 'antd'; +import type { OnlineProduct } from '@atlas/contracts'; + +type Props = { + items: OnlineProduct[]; + loading: boolean; + nextCursor: string | null; + loadingMore: boolean; + onMore: () => void; + onDetail: (item: OnlineProduct) => void; + emptyText: string; +}; + +export function visibilityView(value: boolean | null) { + if (value === true) return 可见; + if (value === false) return 不可见; + return 未知; +} + +export function moderationView(value: string | null) { + if (value === 'approved') return 已通过; + if (value === 'declined') return 未通过; + if (value === 'pending') return 待审核; + return {value || '未知'}; +} + +export function formatOnlineTime(value: string | null) { + return value ? new Date(value).toLocaleString('zh-CN', { hour12: false }) : '—'; +} + +export function OnlineProductTable({ + items, + loading, + nextCursor, + loadingMore, + onMore, + onDetail, + emptyText, +}: Props) { + const columns: TableProps['columns'] = [ + { + title: '商品', + key: 'product', + width: 270, + render: (_, item) => ( +
+
+ {item.image?.url ? ( + {item.name + ) : ( + 无图 + )} +
+
+ + {item.name || '未命名商品'} + + + SKU {item.remote_sku || '—'} + {item.missing_since ? ' · 最近同步未见' : ''} + +
+
+ ), + }, + { + title: 'Offer ID / 商品 ID', + key: 'ids', + width: 200, + render: (_, item) => ( +
+ + {item.offer_id} + + ID {item.remote_product_id} +
+ ), + }, + { title: '可见性', dataIndex: 'visible', width: 95, render: visibilityView }, + { + title: '库存', + dataIndex: 'stock_summary', + width: 80, + render: (stock: OnlineProduct['stock_summary']) => + stock?.has_data ? stock.total : '—', + }, + { title: '审核', dataIndex: 'moderation_status', width: 105, render: moderationView }, + { + title: '售价', + key: 'price', + width: 105, + render: (_, item) => + item.selling_price + ? `${item.currency_code === 'RUB' ? '₽ ' : ''}${item.selling_price}` + : '—', + }, + { + title: '来源', + dataIndex: 'source', + width: 100, + render: (source: OnlineProduct['source']) => + source === 'atlas_published' ? 'Atlas 发布' : '外部同步', + }, + { title: '最近同步', dataIndex: 'last_synced_at', width: 170, render: formatOnlineTime }, + { + title: '操作', + key: 'action', + width: 75, + fixed: 'right', + render: (_, item) => ( + + ), + }, + ]; + return ( +
+ }} + /> +
+ 已显示 {items.length} 条商品 + {nextCursor && ( + + )} +
+ + ); +} diff --git a/apps/web/src/pages/online-products/components/OnlineSyncStatus.tsx b/apps/web/src/pages/online-products/components/OnlineSyncStatus.tsx new file mode 100644 index 0000000..9d82c0c --- /dev/null +++ b/apps/web/src/pages/online-products/components/OnlineSyncStatus.tsx @@ -0,0 +1,146 @@ +import { Alert, Button, Drawer, Empty, List, Space, Tag, Typography } from 'antd'; +import type { OnlineProductSyncRun, OnlineProductsMeta } from '@atlas/contracts'; +import { formatOnlineTime } from './OnlineProductTable'; + +type Props = { + meta: OnlineProductsMeta | null; + latestRun: OnlineProductSyncRun | null; + history: OnlineProductSyncRun[]; + historyOpen: boolean; + historyLoading: boolean; + historyError: string | null; + historyCursor: string | null; + onOpenHistory: () => void; + onCloseHistory: () => void; + onMoreHistory: () => void; + onRetryHistory: () => void; +}; + +const runLabel: Record = { + queued: '等待同步', + running: '正在同步', + succeeded: '同步成功', + failed: '同步失败', + cancelled: '已取消', +}; + +export function OnlineSyncStatus({ + meta, + latestRun, + history, + historyOpen, + historyLoading, + historyError, + historyCursor, + onOpenHistory, + onCloseHistory, + onMoreHistory, + onRetryHistory, +}: Props) { + const status = latestRun?.status ?? meta?.last_sync_status; + const alertType = + status === 'failed' + ? 'warning' + : status === 'queued' || status === 'running' + ? 'info' + : 'success'; + const description = + status === 'failed' + ? `最近同步失败,已保存的商品继续显示。上次完整成功:${formatOnlineTime(meta?.last_successful_sync_at ?? null)}` + : status === 'running' || status === 'queued' + ? `同步中,当前列表展示已保存的商品镜像。上次完整成功:${formatOnlineTime(meta?.last_successful_sync_at ?? null)}` + : meta?.last_successful_sync_at + ? `上次成功:${formatOnlineTime(meta.last_successful_sync_at)} · 当前展示同步镜像` + : '尚未同步过该店铺的 Ozon 商品'; + return ( + <> + + 商品同步 + + {status ? (runLabel[status] ?? status) : '尚未同步'} + + {latestRun && (status === 'running' || status === 'queued') && ( + + 已读取 {latestRun.listed_count} · 已保存 {latestRun.upserted_count} + + )} + + } + description={description} + action={ + + } + /> + + {historyError && ( + 重试} + /> + )} + }} + renderItem={(run) => ( + + + {formatOnlineTime(run.created_at)} + + {runLabel[run.status] ?? run.status} + + + } + description={ + <> +
+ 已读取 {run.listed_count},已保存 {run.upserted_count} +
+ {run.error && ( + + {run.error.message} + + )} + + } + /> +
+ )} + /> + {historyCursor && ( + + )} +
+ + ); +} diff --git a/apps/web/src/pages/products/ProductEditPage.css b/apps/web/src/pages/products/ProductEditPage.css index ebd6681..4198ddc 100644 --- a/apps/web/src/pages/products/ProductEditPage.css +++ b/apps/web/src/pages/products/ProductEditPage.css @@ -219,6 +219,7 @@ left: 220px; z-index: 50; display: flex; + flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 16px; @@ -237,6 +238,7 @@ } .footer-ai-actions { display: flex; + min-width: 0; } /* @@ -297,6 +299,9 @@ } } @media (max-width: 560px) { + .product-editor { + padding-bottom: 230px; + } .editor-section .ant-card-body { padding: 20px 16px; } diff --git a/apps/web/src/pages/products/ProductEditPage.tsx b/apps/web/src/pages/products/ProductEditPage.tsx index 5798b47..815d450 100644 --- a/apps/web/src/pages/products/ProductEditPage.tsx +++ b/apps/web/src/pages/products/ProductEditPage.tsx @@ -35,6 +35,7 @@ import { ProductAttributeSection } from './components/ProductAttributeSection'; import { ProductMainInfoSection } from './components/ProductMainInfoSection'; import { ProductOtherSection } from './components/ProductOtherSection'; import { ProductVariantSection } from './components/ProductVariantSection'; +import { ProductImageTaskCenter } from './components/ProductImageTaskCenter'; import { ProductCopyComparisonModal, SkuCopyAiModal, @@ -1061,8 +1062,7 @@ const ProductEditPage = () => { const showFutureFeature = (name: string) => modal.info({ title: `${name}将在下一阶段接入`, - content: - '当前已支持 SKU 图片上传、网络导入和采集图转存。AI 生图与图片编辑将在后续接入。', + content: '单张 AI 编辑已可在 SKU 图片选择中使用。AI 套图生成仍在开发。', okText: '知道了', }); const validSupplyUrl = @@ -1478,10 +1478,11 @@ const ProductEditPage = () => { + diff --git a/apps/web/src/pages/products/components/CollectedProductInfoPanel.css b/apps/web/src/pages/products/components/CollectedProductInfoPanel.css index 777984b..11c8cc9 100644 --- a/apps/web/src/pages/products/components/CollectedProductInfoPanel.css +++ b/apps/web/src/pages/products/components/CollectedProductInfoPanel.css @@ -186,6 +186,10 @@ grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 6px; } +.collected-image-card { + position: relative; + min-width: 0; +} .collected-image-grid .ant-image { display: block; overflow: hidden; @@ -197,7 +201,18 @@ display: block; width: 100%; aspect-ratio: 3 / 4; - object-fit: cover; + object-fit: contain; +} +.collected-image-edit.ant-btn { + position: absolute; + right: 5px; + bottom: 5px; + z-index: 2; + height: 25px; + padding: 0 6px; + background: rgba(255, 255, 255, 0.94); + font-size: 11px; + box-shadow: 0 1px 4px rgba(0, 0, 0, 0.15); } @container (max-width: 1099px) { /* 主内容区不足 1360px 时,面板退化为工作区下方整行,不再固定高度。 */ diff --git a/apps/web/src/pages/products/components/CollectedProductInfoPanel.tsx b/apps/web/src/pages/products/components/CollectedProductInfoPanel.tsx index 6d4b3df..963c96a 100644 --- a/apps/web/src/pages/products/components/CollectedProductInfoPanel.tsx +++ b/apps/web/src/pages/products/components/CollectedProductInfoPanel.tsx @@ -1,22 +1,35 @@ -import { useEffect, useState } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; import { Alert, + App as AntdApp, Breadcrumb, Button, Card, Collapse, + Dropdown, Image, Input, Space, Tag, Typography, } from 'antd'; +import type { MenuProps } from 'antd'; +import { DownOutlined, LoadingOutlined } from '@ant-design/icons'; import type { CollectionItemInspection, ProductAiSourceField, ProductAiSourceMaterial, ProductEditorSnapshot, + ProductImageDerivativeRecord, + WatermarkTemplate, } from '@atlas/contracts'; +import { apiErrorMessage } from '@/services/api'; +import { createThreeFourImage, createWatermarkedImage, downloadMediaAsset, mediaApi } from '@/services/media'; +import { watermarksApi } from '@/services/watermarks'; +import { resolveSkuImageSelection } from './skuImageSelection'; +import type { SkuEditorImage } from './SkuImageCard'; +import { SkuSingleAiEditDrawer } from './SkuSingleAiEditDrawer'; +import { SkuWatermarkPickerModal } from './SkuWatermarkPickerModal'; import './CollectedProductInfoPanel.css'; export type CollectedProductSummary = { @@ -48,7 +61,7 @@ type Props = { /** * 右侧「采集的商品信息」面板:展示并可编辑采集来源的标题与描述, * 以及价格、尺寸、重量摘要、原始规格参数,和按 主图 / SKU 图 / 详情图 - * 分组折叠展示的采集图片(只读,点击可放大预览)。 + * 分组折叠展示的采集图片及其商品级处理结果。 * * 用户改过的字段记入 overriddenFields(显示"恢复来源值"入口); * 编辑、恢复、采用最新等动作均通过回调交由页面处理。 @@ -66,6 +79,28 @@ export function CollectedProductInfoPanel({ onShowDifferences, onAcceptLatest, }: Props) { + const { message } = AntdApp.useApp(); + const [processedRecords, setProcessedRecords] = useState([]); + const [historyError, setHistoryError] = useState(false); + const [resolvedTransfers, setResolvedTransfers] = useState>({}); + const [busyImageId, setBusyImageId] = useState(null); + const [aiTarget, setAiTarget] = useState(null); + const [watermarkTarget, setWatermarkTarget] = useState(null); + const actionLock = useRef(false); + const defaultWatermarkRequest = useRef | null>(null); + const refreshProcessed = useCallback(async () => { + setProcessedRecords(await mediaApi.listProductImageDerivatives(snapshot.product.id)); + setHistoryError(false); + }, [snapshot.product.id]); + useEffect(() => { + setProcessedRecords([]); + setHistoryError(false); + setResolvedTransfers({}); + defaultWatermarkRequest.current = watermarksApi.getDefault().catch(() => null); + void refreshProcessed().catch(() => setHistoryError(true)); + const interval = window.setInterval(() => void refreshProcessed().catch(() => setHistoryError(true)), 8 * 60_000); + return () => window.clearInterval(interval); + }, [refreshProcessed]); const sourceDescription = collected?.description ?? ''; // 类目自动回填始终基于最新采集快照,不能被 AI 输入素材的用户覆盖影响。 const capturedCategories = snapshot.source_material_state.latest.categories; @@ -92,6 +127,111 @@ export function CollectedProductInfoPanel({ return { key, label, items }; }) .filter((group) => group.items.length > 0); + + /** 派生图保留原素材;按处理链的最新成功结果恢复采集区的显示图。 */ + const roots = new Map(); + const latestByRoot = new Map(); + for (const record of [...processedRecords].reverse()) { + const root = roots.get(record.source_asset_id) ?? record.source_asset_id; + roots.set(record.output_asset.id, root); + latestByRoot.set(root, record); + } + const imageFor = (item: CollectionItemInspection['asset_transfers'][number], label: string): SkuEditorImage => { + const originalAssetId = item.asset_id ?? resolvedTransfers[item.id]; + const latest = originalAssetId ? latestByRoot.get(originalAssetId) : undefined; + return { + id: `collection:${item.id}`, + url: latest?.output_asset.preview_url ?? item.source_url, + label, + origin: latest ? 'derivative' : 'collection', + assetId: latest?.output_asset.id ?? originalAssetId ?? undefined, + transferId: item.id, + }; + }; + const images = imageGroups.flatMap((group) => group.items.map((item) => imageFor(item, group.label))); + const resolveAsset = async (image: SkuEditorImage) => { + const [assetId] = await resolveSkuImageSelection([image], collectionRecord?.id ?? null); + if (image.transferId && !image.assetId) { + setResolvedTransfers((current) => ({ ...current, [image.transferId!]: assetId })); + } + return assetId; + }; + const recordResult = (kind: ProductImageDerivativeRecord['kind'], sourceAssetId: string, + result: { id: string; output_asset: ProductImageDerivativeRecord['output_asset'] | null }) => { + if (!result.output_asset) throw new Error('图片处理结果不可用'); + const record: ProductImageDerivativeRecord = { + id: result.id, kind, source_asset_id: sourceAssetId, + created_at: new Date().toISOString(), output_asset: result.output_asset, + }; + setProcessedRecords((current) => [record, ...current.filter((item) => item.id !== record.id)]); + void refreshProcessed().catch(() => setHistoryError(true)); + }; + const processImage = async (image: SkuEditorImage, kind: 'aspect_ratio_3_4' | 'watermark', + template?: WatermarkTemplate) => { + if (actionLock.current) return; + actionLock.current = true; + setBusyImageId(image.id); + try { + const assetId = await resolveAsset(image); + if (kind === 'watermark' && !template) throw new Error('请选择水印模板'); + const result = kind === 'watermark' + ? await createWatermarkedImage(assetId, template!.id, snapshot.product.id) + : await createThreeFourImage(assetId, snapshot.product.id); + recordResult(kind, assetId, result); + message.success(kind === 'watermark' ? '水印图片已替换当前展示图' : '3:4 图片已替换当前展示图'); + } finally { + actionLock.current = false; + setBusyImageId(null); + } + }; + const addWatermark = async (image: SkuEditorImage) => { + if (actionLock.current) return; + actionLock.current = true; + setBusyImageId(image.id); + try { + const template = await (defaultWatermarkRequest.current + ?? watermarksApi.getDefault().catch(() => null)); + if (template) { + actionLock.current = false; + await processImage(image, 'watermark', template); + } else { + setWatermarkTarget(image); + } + } finally { + actionLock.current = false; + setBusyImageId(null); + } + }; + const downloadImage = async (image: SkuEditorImage) => { + if (actionLock.current) return; + actionLock.current = true; + setBusyImageId(image.id); + try { + await downloadMediaAsset(await resolveAsset(image)); + } catch (error) { + message.error(`${image.label} 下载失败:${apiErrorMessage(error)}`); + } finally { + actionLock.current = false; + setBusyImageId(null); + } + }; + const imageMenu = (image: SkuEditorImage): MenuProps => ({ + onClick: ({ key }) => { + if (actionLock.current) return; + if (key === 'ai') setAiTarget(image); + else if (key === 'watermark') void addWatermark(image).catch((error) => + message.error(`添加水印失败:${apiErrorMessage(error)}`)); + else if (key === 'three_four') void processImage(image, 'aspect_ratio_3_4').catch((error) => + message.error(`${image.label} 处理失败:${apiErrorMessage(error)}`)); + else if (key === 'download') void downloadImage(image); + }, + items: [ + { key: 'ai', label: 'AI 编辑' }, + { key: 'watermark', label: '添加水印' }, + { key: 'three_four', label: '一键 3:4' }, + { key: 'download', label: '下载' }, + ], + }); return ( ); } diff --git a/apps/web/src/pages/products/components/ProductImageTaskCenter.css b/apps/web/src/pages/products/components/ProductImageTaskCenter.css new file mode 100644 index 0000000..f2c0bd0 --- /dev/null +++ b/apps/web/src/pages/products/components/ProductImageTaskCenter.css @@ -0,0 +1,83 @@ +.product-image-task-trigger { + display: inline-flex; +} + +.product-image-task-section + .product-image-task-section { + margin-top: 24px; +} + +.product-image-task-section h3 { + margin: 0 0 12px; + font-size: 15px; +} + +.product-image-task-card { + display: flex; + flex-direction: column; + gap: 8px; + margin-bottom: 10px; + padding: 14px; + border: 1px solid #e9e5f0; + border-radius: 10px; + background: #fff; +} + +.product-image-task-heading, +.product-image-task-actions { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; +} + +.product-image-task-heading .ant-tag { + margin: 0; +} + +.product-image-task-meta { + font-size: 12px; +} + +.product-image-task-failure-diagnosis { + font-size: 12px; + line-height: 1.5; + overflow-wrap: anywhere; +} + +.product-image-task-actions { + justify-content: flex-end; +} + +.product-image-task-actions:empty { + display: none; +} + +.product-image-task-provider-error pre { + max-height: 220px; + margin: 6px 0 0; + padding: 10px; + overflow: auto; + border-radius: 6px; + background: #f7f6fa; + font-size: 12px; + line-height: 1.5; + white-space: pre-wrap; + overflow-wrap: anywhere; +} + +.product-image-task-error { + margin-bottom: 16px; +} + +.product-image-task-results { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(min(180px, 100%), 1fr)); + gap: 12px; +} + +@media (max-width: 560px) { + .product-image-task-trigger, + .product-image-task-trigger .ant-btn { + width: 100%; + } +} diff --git a/apps/web/src/pages/products/components/ProductImageTaskCenter.tsx b/apps/web/src/pages/products/components/ProductImageTaskCenter.tsx new file mode 100644 index 0000000..29b332e --- /dev/null +++ b/apps/web/src/pages/products/components/ProductImageTaskCenter.tsx @@ -0,0 +1,171 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import { Alert, App as AntdApp, Badge, Button, Drawer, Empty, Image, Modal, Popconfirm, Progress, Spin, Tag, Typography } from 'antd'; +import { ClockCircleOutlined, LoadingOutlined, ReloadOutlined } from '@ant-design/icons'; +import type { ProductImageTask } from '@atlas/contracts'; +import { apiErrorMessage } from '@/services/api'; +import { mediaAiEditFailureDiagnosis, mediaAiEditFailureMessage, mediaApi } from '@/services/media'; +import './ProductImageTaskCenter.css'; + +const { Text } = Typography; +const activeStatuses = new Set(['queued', 'running']); +const statusLabels: Record = { + queued: '排队中', running: '处理中', succeeded: '已完成', partial: '部分完成', + failed: '失败', cancelled: '已取消', +}; +const statusColors: Record = { + queued: 'default', running: 'processing', succeeded: 'success', partial: 'warning', + failed: 'error', cancelled: 'default', +}; + +function taskTitle(task: ProductImageTask): string { + return task.kind === 'single_ai_edit' ? '单张 AI 编辑' : 'AI 套图生成'; +} + +function taskTime(value: string): string { + return new Date(value).toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' }); +} + +/** 商品级图片任务入口;任务列表只传素材 ID,预览时再签发短时地址。 */ +export function ProductImageTaskCenter({ productId }: { productId: string }) { + const { message } = AntdApp.useApp(); + const [open, setOpen] = useState(false); + const [tasks, setTasks] = useState([]); + const latestTasks = useRef([]); + const requestSeq = useRef(0); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(''); + const [preview, setPreview] = useState<{ title: string; urls: string[] } | null>(null); + const [previewLoadingId, setPreviewLoadingId] = useState(null); + const [cancellingId, setCancellingId] = useState(null); + + const refresh = useCallback(async (): Promise => { + const sequence = ++requestSeq.current; + try { + const current = await mediaApi.listProductImageTasks(productId); + if (sequence === requestSeq.current) { + latestTasks.current = current; + setTasks(current); + setError(''); + } + return current; + } catch (cause) { + if (sequence === requestSeq.current) setError(apiErrorMessage(cause)); + return latestTasks.current; + } finally { + if (sequence === requestSeq.current) setLoading(false); + } + }, [productId]); + + useEffect(() => { + requestSeq.current += 1; + latestTasks.current = []; + setTasks([]); + setLoading(true); + const submitted = (event: Event) => { + const detail = (event as CustomEvent<{ productId: string }>).detail; + if (detail?.productId === productId) void refresh(); + }; + void refresh(); + window.addEventListener('atlas:product-image-task-submitted', submitted); + return () => { + requestSeq.current += 1; + window.removeEventListener('atlas:product-image-task-submitted', submitted); + }; + }, [productId, refresh]); + + const hasActive = tasks.some((task) => activeStatuses.has(task.status)); + useEffect(() => { + const timer = window.setInterval(() => void refresh(), hasActive ? 3000 : 15000); + return () => window.clearInterval(timer); + }, [hasActive, refresh]); + + const cancel = async (task: ProductImageTask) => { + setCancellingId(task.id); + try { + await mediaApi.cancelAiEdit(task.id); + await refresh(); + message.success('任务已取消'); + } catch (cause) { + message.error(apiErrorMessage(cause)); + } finally { + setCancellingId(null); + } + }; + + const showResult = async (task: ProductImageTask) => { + setPreviewLoadingId(task.id); + try { + const accesses = await Promise.all(task.output_asset_ids.map((id) => mediaApi.createPreviewUrl(id))); + setPreview({ title: taskTitle(task), urls: accesses.map((access) => access.url) }); + } catch (cause) { + message.error(apiErrorMessage(cause)); + } finally { + setPreviewLoadingId(null); + } + }; + + const active = tasks.filter((task) => activeStatuses.has(task.status)); + const recent = tasks.filter((task) => !activeStatuses.has(task.status)).slice(0, 12); + const renderTask = (task: ProductImageTask) =>
+
+ {taskTitle(task)} + {statusLabels[task.status]} +
+ + {taskTime(task.created_at)} + {task.phase === 'waiting_provider' ? ' · 等待 AI 服务返回' : ''} + + {activeStatuses.has(task.status) && `${task.completed}/${task.total}`} + />} + {task.status === 'partial' && 已完成 {task.completed}/{task.total} 张} + {task.status === 'failed' && {mediaAiEditFailureMessage(task.error_code)}} + {task.status === 'failed' && + {mediaAiEditFailureDiagnosis(task)} + } + {task.status === 'failed' && task.provider_error_raw &&
+ Pcore 原始错误 +
{task.provider_error_raw}
+
} +
+ {task.output_asset_ids.length > 0 && } + {task.kind === 'single_ai_edit' && activeStatuses.has(task.status) && void cancel(task)}> + + } +
+
; + + return <> + + + + setOpen(false)} + extra={}> + {error && } + {loading && tasks.length === 0 ? : <> +
+

进行中 · {active.length}

+ {active.length ? active.map(renderTask) : } +
+
+

最近任务

+ {recent.length ? recent.map(renderTask) : } +
+ } +
+ setPreview(null)} footer={null} width={760}> +
+ {preview?.urls.map((url) => )} +
+
+ ; +} diff --git a/apps/web/src/pages/products/components/ProductVariantSection.css b/apps/web/src/pages/products/components/ProductVariantSection.css index fcde0cf..b4cb90b 100644 --- a/apps/web/src/pages/products/components/ProductVariantSection.css +++ b/apps/web/src/pages/products/components/ProductVariantSection.css @@ -121,6 +121,7 @@ font-size: 12px; } .sku-main-thumbnail { + position: relative; display: flex; flex: 0 0 auto; align-self: flex-start; @@ -137,13 +138,19 @@ font-size: 10px; text-align: center; } -.sku-main-thumbnail .ant-image, -.sku-main-thumbnail img { +.sku-main-thumbnail .ant-image { + position: absolute; + inset: 0 4px; + display: flex; + align-items: center; + justify-content: center; +} +.sku-main-thumbnail .ant-image img { + display: block; width: 100%; height: 100%; -} -.sku-main-thumbnail img { - object-fit: cover; + object-fit: contain; + object-position: center; } .sku-card-value { flex: 0 0 auto; @@ -222,7 +229,7 @@ .sku-media-item { position: relative; min-width: 0; - /* 每张图片框固定为 3:4;实际图片使用 contain,完整居中展示。 */ + /* 图片框固定为 3:4;图片容器铺满框后用 contain 水平、垂直居中。 */ aspect-ratio: 3 / 4; align-self: start; overflow: hidden; @@ -246,12 +253,16 @@ font-size: 11px; line-height: 18px; } -.sku-media-item .ant-image, -.sku-media-item img { +.sku-media-item .ant-image { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; +} +.sku-media-item .ant-image img { width: 100%; height: 100%; -} -.sku-media-item img { display: block; object-fit: contain; object-position: center; diff --git a/apps/web/src/pages/products/components/ProductVariantSection.tsx b/apps/web/src/pages/products/components/ProductVariantSection.tsx index fb32ecd..cbf045f 100644 --- a/apps/web/src/pages/products/components/ProductVariantSection.tsx +++ b/apps/web/src/pages/products/components/ProductVariantSection.tsx @@ -31,6 +31,9 @@ import type { import { SectionTitle } from './SectionTitle'; import { DynamicAttributeFields } from './DynamicAttributeFields'; import { SkuImageEditorModal, type SkuEditorImage } from './SkuImageEditorModal'; +import { createThreeFourImage, downloadMediaAsset } from '@/services/media'; +import { createWatermarkedImage } from '@/services/media'; +import type { WatermarkTemplate } from '@atlas/contracts'; import { resolveSkuImageSelection } from './skuImageSelection'; import { mediaApi, uploadProductImage } from '@/services/media'; import './ProductVariantSection.css'; @@ -293,6 +296,21 @@ export function ProductVariantSection({ }) .filter((image): image is SkuEditorImage => image !== null); const selectedImages = localImagesByVariant[variantKey] ?? savedImages; + const sourceMainCandidate = !selectedAssetIds.length && !selectedImages.length && original + ? imageCandidates.find((item) => + item.source_variant_id === original.id && + item.role === 'source_sku' + ) + : null; + const displayImages: SkuEditorImage[] = sourceMainCandidate + ? [{ + id: `collection:${sourceMainCandidate.id}`, + url: sourceMainCandidate.source_url, + label: '来源 SKU 图片', + origin: 'collection', + transferId: sourceMainCandidate.id, + }] + : selectedImages; const copyVariant = () => add({ ...variantValue, @@ -334,10 +352,10 @@ export function ProductVariantSection({
- {selectedImages[0] ? ( + {displayImages[0] ? ( ) : ( @@ -412,14 +430,15 @@ export function ProductVariantSection({ noStyle rules={[ { - pattern: /^\d+(\.\d{1,4})?$/, - message: '请输入金额', + pattern: /^\d+(\.\d{1,2})?$/, + message: '请输入最多两位小数的金额', }, ]} > min="0" - step="0.0001" + step="0.01" + precision={2} stringMode placeholder="—" /> @@ -467,32 +486,37 @@ export function ProductVariantSection({ SKU 图片 + {sourceMainCandidate && ( + + 来源 SKU 图片预览;选择后转存为正式主图 + + )} {Object.hasOwn(localImagesByVariant, variantKey) && ( 当前图片仅在本页预览,刷新后恢复 )}
- {selectedImages.map((image, imageIndex) => ( + {displayImages.map((image, imageIndex) => (
{imageIndex === 0 && ( - 主图 + {sourceMainCandidate ? '来源主图' : '主图'} )}
))} + ; + if (!processedRecords.length) return ; + return
+ {processedRecords.map((record, index) => { + const image = processedImages[index]; + return
+ sameImage(item, image))} + menu={imageMenu(image)} + onToggle={() => addImage(image)} + onPreview={() => showPreview(processedImages, index)} + /> +
+ {record.kind === 'watermark' ? '水印' : '一键 3:4'} + {new Date(record.created_at).toLocaleString('zh-CN')} +
+
; + })} +
; + }; const confirmSelection = async () => { setConfirming(true); @@ -235,19 +533,19 @@ export function SkuImageEditorModal({ } width="min(1480px, 96vw)" onCancel={() => { - if (!confirming) onCancel(); + if (!confirming && !processingThreeFour && !processingWatermark) onCancel(); }} - closable={!confirming} - maskClosable={!confirming} + closable={!confirming && !processingThreeFour && !processingWatermark} + maskClosable={!confirming && !processingThreeFour && !processingWatermark} footer={[ - , - @@ -305,7 +601,7 @@ export function SkuImageEditorModal({ 已选图片({images.length} / {MAX_IMAGES}) - 第一张为主图,拖动可调整顺序 + 拖动可调整顺序
{images.length ? ( @@ -322,11 +618,13 @@ export function SkuImageEditorModal({ onPreview={() => showPreview(images, index)} onRemove={() => removeImages([image.id])} onDragStart={() => { + if (processingLock.current) return; dragIndex.current = index; }} onDragOver={(event) => event.preventDefault()} onDrop={(event) => { event.preventDefault(); + if (processingLock.current) return; const from = dragIndex.current; if (from === null || from === index) return; setImages((current) => { @@ -351,9 +649,16 @@ export function SkuImageEditorModal({
{ + setActiveTab(tab); + if (tab === 'processed') { + void refreshProcessedImages().catch(() => setProcessedError(true)); + } + }} sourceImages={sourceImages} renderImages={renderSourceImages} + aiImages={aiImages} + renderProcessedImages={renderProcessedImages} networkImages={networkImages} networkUrls={networkUrls} onNetworkUrlsChange={setNetworkUrls} @@ -366,6 +671,51 @@ export function SkuImageEditorModal({
+ 0} + onCancel={() => setWatermarkTargets([])} + onApply={applyWatermark} + /> + setAiTarget(null)} + onResolveAsset={onResolveAsset} + onGenerated={refreshAiImages} + onApply={(source, task) => { + const asset = task.output_asset; + if (!asset) return false; + const output: SkuEditorImage = { + id: `asset:${asset.id}`, url: asset.preview_url, + label: `${source.label} · AI 编辑`, origin: 'derivative', assetId: asset.id, + }; + if (images.some((image) => sameImage(image, output))) { + message.info('这张 AI 生成图已在当前 SKU 已选图片中'); + return false; + } + if (images.length >= MAX_IMAGES) { + message.warning(`每个 SKU 最多选择 ${MAX_IMAGES} 张图片`); + return false; + } + setImages((current) => { + const index = current.findIndex((image) => sameImage(image, source)); + const next = [...current]; + next.splice(index < 0 ? next.length : index + 1, 0, output); + return next; + }); + message.success('AI 生成图已加入当前 SKU,请确认选图后保存页面'); + return true; + }} + /> image.url) ?? []} preview={{ diff --git a/apps/web/src/pages/products/components/SkuImageSourceTabs.tsx b/apps/web/src/pages/products/components/SkuImageSourceTabs.tsx index 5986222..ac02b13 100644 --- a/apps/web/src/pages/products/components/SkuImageSourceTabs.tsx +++ b/apps/web/src/pages/products/components/SkuImageSourceTabs.tsx @@ -16,6 +16,8 @@ type Props = { onTabChange: (tab: string) => void; sourceImages: (role: string) => SkuEditorImage[]; renderImages: (images: SkuEditorImage[]) => ReactNode; + aiImages: SkuEditorImage[]; + renderProcessedImages: () => ReactNode; networkImages: SkuEditorImage[]; networkUrls: string; onNetworkUrlsChange: (value: string) => void; @@ -32,6 +34,8 @@ export function SkuImageSourceTabs({ onTabChange, sourceImages, renderImages, + aiImages, + renderProcessedImages, networkImages, networkUrls, onNetworkUrlsChange, @@ -50,12 +54,17 @@ export function SkuImageSourceTabs({ key: 'ai', label: 'AI 生图记录', children: ( - ), }, + { + key: 'processed', + label: '图片处理记录', + children: renderProcessedImages(), + }, { key: 'collection', label: '采集图片', diff --git a/apps/web/src/pages/products/components/SkuSingleAiEditDrawer.css b/apps/web/src/pages/products/components/SkuSingleAiEditDrawer.css new file mode 100644 index 0000000..3f6ba23 --- /dev/null +++ b/apps/web/src/pages/products/components/SkuSingleAiEditDrawer.css @@ -0,0 +1,39 @@ +.sku-single-ai-drawer .ant-drawer-body { padding: 20px 24px; } +.sku-single-ai-layout { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 20px; } +.sku-single-ai-media, .sku-single-ai-settings { min-width: 0; } +.sku-single-ai-section { margin-bottom: 16px; padding: 16px; border: 1px solid #e8e7ee; border-radius: 10px; background: #fff; } +.sku-single-ai-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-bottom: 12px; } +.sku-single-ai-head .ant-typography { font-size: 12px; } +.sku-single-ai-reference-grid { display: grid; grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); grid-template-rows: repeat(2, minmax(0, 1fr)); gap: 9px; aspect-ratio: 5 / 4; } +.sku-single-ai-reference { position: relative; min-width: 0; min-height: 0; overflow: hidden; border: 1px dashed #d8d4e7; border-radius: 8px; background: #f7f6fa; } +.sku-single-ai-reference:first-child { grid-column: 1; grid-row: 1 / 3; } +.sku-single-ai-reference:nth-child(2) { grid-column: 2; grid-row: 1; } +.sku-single-ai-reference:nth-child(3) { grid-column: 2; grid-row: 2; } +.sku-single-ai-reference img { display: block; width: 100%; height: 100%; object-fit: contain; } +.sku-single-ai-reference > span { position: absolute; left: 0; right: 0; bottom: 0; padding: 5px; background: #292733b8; color: #fff; font-size: 11px; text-align: center; } +.sku-single-ai-reference > .ant-btn { position: absolute; top: 5px; right: 5px; } +.sku-single-ai-reference > button:not(.ant-btn) { width: 100%; height: 100%; border: 0; background: transparent; color: #7165d8; cursor: pointer; } +.sku-single-ai-reference > .sku-single-ai-change { position: absolute; right: 0; bottom: 25px; left: 0; height: 27px !important; background: #ffffffd9 !important; } +.sku-single-ai-result { display: flex; align-items: center; justify-content: center; width: 100%; max-width: 390px; margin-inline: auto; overflow: hidden; border-radius: 8px; background: #f5f4f8; } +.sku-single-ai-result .ant-image, .sku-single-ai-result .ant-image-img { width: 100%; height: 100%; object-fit: contain; } +.sku-single-ai-empty { display: flex; flex-direction: column; align-items: center; gap: 8px; color: #8b8798; text-align: center; } +.sku-single-ai-empty strong { color: #494556; } +.sku-single-ai-sparkle { color: #7465e6; font-size: 36px; } +.sku-single-ai-styles { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 7px; } +.sku-single-ai-styles button { min-height: 34px; border: 1px solid #e1deeb; border-radius: 6px; background: #fff; cursor: pointer; } +.sku-single-ai-styles button.selected { border-color: #7062e9; background: #f2f0ff; color: #6254dc; } +.sku-single-ai-styles + .ant-btn, .sku-single-ai-section > .ant-btn-link { padding-left: 0; } +.sku-single-ai-dimensions { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)) 78px; gap: 6px; margin-bottom: 8px; } +.sku-single-ai-dimensions .ant-input-number { width: 100%; } +.sku-single-ai-footer { display: flex; align-items: center; justify-content: space-between; gap: 12px; } +.sku-single-ai-footer .ant-typography { font-size: 12px; } +.sku-single-ai-footer > div { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 8px; } +.sku-single-ai-picker { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 10px; max-height: 450px; overflow-y: auto; } +.sku-single-ai-picker button { display: flex; flex-direction: column; gap: 5px; min-width: 0; padding: 4px; border: 1px solid #e7e4ed; border-radius: 6px; background: #fff; cursor: pointer; } +.sku-single-ai-picker img { width: 100%; aspect-ratio: 3 / 4; object-fit: contain; } +.sku-single-ai-picker span { overflow: hidden; font-size: 12px; text-overflow: ellipsis; white-space: nowrap; } +@media (max-width: 760px) { + .sku-single-ai-layout { grid-template-columns: 1fr; } + .sku-single-ai-footer { align-items: flex-start; flex-direction: column; } + .sku-single-ai-footer > div { width: 100%; } +} diff --git a/apps/web/src/pages/products/components/SkuSingleAiEditDrawer.tsx b/apps/web/src/pages/products/components/SkuSingleAiEditDrawer.tsx new file mode 100644 index 0000000..8d08482 --- /dev/null +++ b/apps/web/src/pages/products/components/SkuSingleAiEditDrawer.tsx @@ -0,0 +1,237 @@ +import { useEffect, useMemo, useState } from 'react'; +import { Alert, App as AntdApp, Button, Checkbox, Drawer, Image, Input, InputNumber, Modal, Radio, Select, Spin, Switch, Typography } from 'antd'; +import { DeleteOutlined, PlusOutlined } from '@ant-design/icons'; +import type { CreateMediaAiEditRequest, MediaAiEdit } from '@atlas/contracts'; +import { apiErrorMessage } from '@/services/api'; +import { mediaAiEditFailureDiagnosis, mediaAiEditFailureMessage, mediaApi, notifyProductImageTaskSubmitted } from '@/services/media'; +import type { SkuEditorImage } from './SkuImageCard'; +import './SkuSingleAiEditDrawer.css'; + +const { Text } = Typography; +const styles = [ + { value: 1, label: '北欧极简', prompt: 'Nordic minimalist e-commerce style: warm ivory, soft daylight, natural texture and spacious composition.' }, + { value: 2, label: '清新明亮', prompt: 'Fresh bright e-commerce style: clean high-key daylight, natural colors and an airy retail composition.' }, + { value: 3, label: '高级感深色', prompt: 'Premium dark editorial style: charcoal setting, sculpted side lighting, realistic material texture.' }, + { value: 4, label: '暖调生活', prompt: 'Warm lifestyle style: believable cream home setting, balanced golden and window light, product as the hero.' }, + { value: 5, label: '纯净棚拍', prompt: 'Clean studio style: neutral seamless background, even softbox light, crisp edges and no decorative props.' }, +]; +type Settings = Required; +type Ratio = Settings['ratio']; +type Dimensions = NonNullable; +type Props = { + open: boolean; + productId: string; + source: SkuEditorImage | null; + referenceOptions: SkuEditorImage[]; + onClose: () => void; + onResolveAsset: (image: SkuEditorImage) => Promise; + onApply?: (source: SkuEditorImage, result: MediaAiEdit) => boolean; + onGenerated: () => Promise; + footerHint?: string; +}; + +export function SkuSingleAiEditDrawer({ open, productId, source, referenceOptions, onClose, onResolveAsset, onApply, onGenerated, footerHint }: Props) { + const { message } = AntdApp.useApp(); + const [references, setReferences] = useState<(SkuEditorImage | null)[]>([null, null]); + const [choosingSlot, setChoosingSlot] = useState(null); + const [prompt, setPrompt] = useState(''); + const [ratio, setRatio] = useState('3:4'); + const [translateText, setTranslateText] = useState(false); + const [language, setLanguage] = useState('ru'); + const [background, setBackground] = useState('auto'); + const [backgroundDescription, setBackgroundDescription] = useState(''); + const [styleSet, setStyleSet] = useState(2); + const [stylePrompts, setStylePrompts] = useState>({}); + const [showStylePrompt, setShowStylePrompt] = useState(false); + const [dimensions, setDimensions] = useState({ unit: 'cm' }); + const [sizeChartOverride, setSizeChartOverride] = useState(null); + const [taskId, setTaskId] = useState(null); + const [generating, setGenerating] = useState(false); + const [result, setResult] = useState(null); + const [error, setError] = useState(''); + const [resultRatio, setResultRatio] = useState('3:4'); + const [appliedId, setAppliedId] = useState(null); + const hasDimensions = [dimensions.length, dimensions.width, dimensions.height] + .some((value) => typeof value === 'number' && value > 0); + const generateSizeChart = hasDimensions && (sizeChartOverride ?? true); + const stylePrompt = stylePrompts[styleSet] ?? styles.find((item) => item.value === styleSet)?.prompt ?? ''; + const choices = useMemo(() => Array.from(new Map(referenceOptions + .filter((image) => image.url && image.id !== source?.id + && !(image.assetId && source?.assetId && image.assetId === source.assetId)) + .map((image) => [image.assetId ?? image.id, image])).values()), [referenceOptions, source]); + + useEffect(() => { + if (!open || !source) return; + setReferences([null, null]); + setChoosingSlot(null); + setPrompt(''); + setRatio('3:4'); + setTranslateText(false); + setLanguage('ru'); + setBackground('auto'); + setBackgroundDescription(''); + setStyleSet(2); + setStylePrompts({}); + setDimensions({ unit: 'cm' }); + setSizeChartOverride(null); + setResult(null); + setError(''); + setAppliedId(null); + setTaskId(null); + }, [source?.id, open]); + + useEffect(() => { + if (!taskId) return; + let active = true; + let timer: number | undefined; + let failures = 0; + const poll = async () => { + try { + const current = await mediaApi.readAiEdit(taskId); + if (!active) return; + failures = 0; + setError(''); + if (current.status === 'succeeded' && current.output_asset) { + setResult(current); + setGenerating(false); + setTaskId(null); + void onGenerated().catch(() => undefined); + message.success('单张图片生成完成'); + return; + } + if (current.status === 'failed' || current.status === 'cancelled') { + setError(current.status === 'cancelled' ? '任务已取消' + : `${mediaAiEditFailureMessage(current.error_code)} ${mediaAiEditFailureDiagnosis(current)}`); + setGenerating(false); + setTaskId(null); + return; + } + timer = window.setTimeout(() => void poll(), 3000); + } catch { + if (!active) return; + failures += 1; + if (failures >= 5) { + setError('查询任务进度暂时失败,正在继续重试;如需退出可先取消任务。'); + } + timer = window.setTimeout(() => void poll(), Math.min(3000 * (2 ** Math.min(failures, 4)), 15000)); + } + }; + void poll(); + return () => { active = false; if (timer) window.clearTimeout(timer); }; + }, [taskId, message, onGenerated]); + + const generate = async () => { + if (!source || generating) return; + if (background === 'reference' && !references.some(Boolean)) { + message.warning('请选择一张辅助参考图作为背景参考'); + return; + } + setGenerating(true); + setResult(null); + setError(''); + setAppliedId(null); + setResultRatio(ratio); + try { + const [sourceId, ...refIds] = await Promise.all([ + onResolveAsset(source), + ...references.flatMap((image) => image ? [onResolveAsset(image)] : []), + ]); + const created = await mediaApi.createAiEdit(sourceId, { + product_id: productId, reference_asset_ids: refIds, prompt: prompt.trim(), mode: 'edit', + ratio, + translate_text: translateText, + target_language: language, background, background_description: backgroundDescription.trim(), + style_set: styleSet, style_prompt: stylePrompt, + dimensions: hasDimensions ? dimensions : undefined, + generate_size_chart: generateSizeChart, + }); + setTaskId(created.id); + notifyProductImageTaskSubmitted(productId); + } catch (cause) { + setError(apiErrorMessage(cause)); + setGenerating(false); + } + }; + const cancelTask = async () => { + if (!taskId) return; + try { + await mediaApi.cancelAiEdit(taskId); + setTaskId(null); + setGenerating(false); + setError('任务已取消'); + } catch (cause) { message.error(apiErrorMessage(cause)); } + }; + const close = () => { + if (generating && taskId) message.info('任务会继续运行,可在底部操作栏的图片任务查看进度'); + onClose(); + }; + const chooseReference = (image: SkuEditorImage) => { + if (choosingSlot === null) return; + setReferences((current) => current.map((item, index) => index === choosingSlot + ? image : item?.id === image.id ? null : item)); + setChoosingSlot(null); + }; + + return <> + {footerHint ?? '生成图将作为独立素材,应用后仍需确认选图并保存页面'}
+ {generating && taskId && } + + {onApply && result?.output_asset && } + +
}> +
+
+
主图与辅助参考主图固定 · 辅图最多 2 张
+
+
{source?.label主图 · 编辑底图
+ {references.map((image, index) =>
+ {image ? <>{image.label}辅图 {index + 1} + : } +
)} +
+
+
AI 生成结果{resultRatio}
+
+ {result?.output_asset ? + : generating ?
正在生成图片完成后会显示在这里
+ :
✦生成的图片会显示在这里原图会保留
} +
+ {error && } +
+
+
+
编辑要求
setPrompt(event.target.value)} placeholder="描述要改什么,例如:保留商品外观,换成温暖的客厅背景" />
+
图中文字翻译
+ {translateText && <> + {['solid', 'scene', 'reference'].includes(background) && setBackgroundDescription(event.target.value)} placeholder={background === 'solid' ? '例如:浅米色' : '补充背景要求(可选)'} />} +
+
视觉风格
+
{styles.map((item) => )}
+ + {showStylePrompt && setStylePrompts((current) => ({ ...current, [styleSet]: event.target.value }))} />} +
+
商品尺寸默认 cm
+
{(['length', 'width', 'height'] as const).map((key, index) => setDimensions((current) => ({ ...current, [key]: typeof value === 'number' && value > 0 ? value : undefined }))} />)} + + + + { 5MB `${value ?? 0}%` }} + tooltip={{ formatter: (value) => `${value ?? 0}px` }} + /> + + + @@ -878,34 +949,40 @@ const WatermarksPage = () => { > - - ({ + value: font.code, + label: font.display_name, + }))} + /> + +
+
+ + + +
+
) } @@ -920,14 +997,14 @@ const WatermarksPage = () => { /> - - - - - - - - + +
@@ -943,11 +1020,19 @@ const WatermarksPage = () => { title={previewing ? `查看效果:${previewing.name}` : '查看效果'} open={!!previewing} onCancel={() => setPreviewing(null)} - footer={null} + footer={ +
+ +
+ } width={420} destroyOnHidden > - {previewing && } + {previewing && ( +
+ +
+ )}
); @@ -958,33 +1043,4 @@ const dayjsOrNull = (value: string) => { return parsed.isValid() ? parsed : null; }; -/** 3x3 水印位置九宫格选择器:选中格高亮,旁边显示位置名称 */ -const PositionGrid = ({ - value, - onChange, -}: { - value?: WatermarkPositionValue; - onChange?: (value: WatermarkPositionValue) => void; -}) => { - const selected = WATERMARK_POSITION_OPTIONS.find((option) => option.value === value); - return ( - -
- {WATERMARK_POSITION_OPTIONS.map((option) => ( -
- {positionGridLabel(selected?.label)} -
- ); -}; - -const positionGridLabel = (label?: string) => label ?? '请选择位置'; - export default WatermarksPage; diff --git a/apps/web/src/services/media.ts b/apps/web/src/services/media.ts index 3dac447..af42579 100644 --- a/apps/web/src/services/media.ts +++ b/apps/web/src/services/media.ts @@ -6,6 +6,12 @@ import type { ProductUploadImage, ProductImportedImage, ImportProductImageRequest, + AspectRatioDerivative, + WatermarkDerivative, + CreateMediaAiEditRequest, + MediaAiEdit, + ProductImageTask, + ProductImageDerivativeRecord, } from '@atlas/contracts'; import { api } from '@/services/api'; @@ -15,6 +21,32 @@ export const WATERMARK_IMAGE_MAX_BYTES = 5 * 1024 * 1024; /** 异步操作查询,契约见 docs/api/conventions.md §5(GET /api/operations/{operation_id}) */ export const mediaApi = { + createAiEdit: (assetId: string, payload: CreateMediaAiEditRequest) => + api.post(`/media-assets/${assetId}/ai-edits`, payload, + { headers: { 'Idempotency-Key': crypto.randomUUID() } }), + readAiEdit: (id: string) => api.get(`/media-ai-edits/${id}`), + listAiEdits: (productId: string) => api.get(`/media-ai-edits?product_id=${encodeURIComponent(productId)}`), + listProductImageDerivatives: (productId: string) => + api.get(`/products/${encodeURIComponent(productId)}/image-derivatives`), + listProductImageTasks: (productId: string) => + api.getList(`/products/${encodeURIComponent(productId)}/image-tasks?limit=100`) + .then((response) => response.data), + cancelAiEdit: (id: string) => api.post<{ id: string; status: MediaAiEdit['status'] }>( + `/media-ai-edits/${id}/cancel`, {}, { headers: { 'Idempotency-Key': crypto.randomUUID() } }), + createThreeFour: (assetId: string, productId: string) => + api.post( + `/media-assets/${assetId}/aspect-ratio-derivatives`, + { preset: '3:4', fit: 'contain', product_id: productId }, + { headers: { 'Idempotency-Key': crypto.randomUUID() } } + ), + readDerivative: (derivativeId: string) => + api.get(`/media-derivatives/${derivativeId}`), + createWatermarkDerivative: (assetId: string, templateId: string, productId: string) => + api.post(`/media-assets/${assetId}/watermark-derivatives`, + { watermark_template_id: templateId, product_id: productId }, + { headers: { 'Idempotency-Key': crypto.randomUUID() } }), + readWatermarkDerivative: (derivativeId: string) => + api.get(`/media-watermark-derivatives/${derivativeId}`), listProductUploads: () => api.get('/media-assets/product-uploads'), listProductImports: () => api.get('/media-assets/product-imports'), importProductImage: (payload: ImportProductImageRequest) => @@ -40,8 +72,81 @@ export const mediaApi = { { purpose: 'preview' }, { headers: { 'Idempotency-Key': crypto.randomUUID() } } ), + createDownloadUrl: (assetId: string) => + api.post( + `/media-assets/${assetId}/access-urls`, + { purpose: 'download' }, + { headers: { 'Idempotency-Key': crypto.randomUUID() } } + ), }; +/** 图片任务提交后通知商品页刷新右下角任务列表。 */ +export function notifyProductImageTaskSubmitted(productId: string): void { + window.dispatchEvent(new CustomEvent('atlas:product-image-task-submitted', { detail: { productId } })); +} + +/** 图片编辑错误码对应可操作的提示,不向前端透传供应商原始响应。 */ +export function mediaAiEditFailureMessage(code: string | null): string { + if (code === 'AI_IMAGE_CONTENT_REJECTED') + return 'Pcore 未能完成这次生图;可能是输入或生成结果被审核拦截。可尝试其他图片或调整编辑要求。'; + if (code === 'AI_IMAGE_PROVIDER_FAILED') + return 'Pcore 图片任务失败,请稍后重试。'; + return '图片生成失败,请重试。'; +} + +const aiEditFailureStageLabels = { + task_setup: '任务初始化', input_preparation: '读取与准备原图', + provider_submission: '提交 Pcore 生图请求', provider_polling: '查询 Pcore 生图结果', + result_download: '获取生成图片', result_processing: 'Atlas 处理生成图片', + result_storage: 'Atlas 保存生成图片', +} as const; + +export function mediaAiEditFailureDiagnosis(failure: { + failure_source: 'pcore' | 'atlas' | null; + failure_stage: keyof typeof aiEditFailureStageLabels | null; + failure_detail: string | null; +}): string { + if (!failure.failure_source && !failure.failure_stage) return '失败阶段未记录(历史任务)'; + const source = failure.failure_source === 'pcore' ? 'Pcore 接口' : 'Atlas 自身处理'; + const stage = failure.failure_stage ? aiEditFailureStageLabels[failure.failure_stage] : '未知步骤'; + return `失败来源:${source} · 失败步骤:${stage}${failure.failure_detail ? ` · 详情:${failure.failure_detail}` : ''}`; +} + +/** 下载地址由对象存储返回 attachment 响应头,跨域图片也会保存为文件。 */ +export async function downloadMediaAsset(assetId: string): Promise { + const access = await mediaApi.createDownloadUrl(assetId); + const link = document.createElement('a'); + link.href = access.url; + link.rel = 'noopener noreferrer'; + document.body.appendChild(link); + link.click(); + link.remove(); +} + +/** 派生任务可能跨越多次 Worker 重试;页面只使用成功返回的正式素材 ID。 */ +export async function createThreeFourImage(assetId: string, productId: string): Promise { + const created = await mediaApi.createThreeFour(assetId, productId); + for (let attempt = 0; attempt < 120; attempt += 1) { + const current = await mediaApi.readDerivative(created.id); + if (current.status === 'succeeded' && current.output_asset) return current; + if (current.status === 'failed') throw new Error('图片处理失败,请重试'); + await new Promise((resolve) => window.setTimeout(resolve, 1_500)); + } + throw new Error('图片仍在处理中,请稍后重试'); +} + +/** 应用水印创建独立素材,调用方在选图弹窗中替换预览。 */ +export async function createWatermarkedImage(assetId: string, templateId: string, productId: string): Promise { + const created = await mediaApi.createWatermarkDerivative(assetId, templateId, productId); + for (let attempt = 0; attempt < 120; attempt += 1) { + const current = await mediaApi.readWatermarkDerivative(created.id); + if (current.status === 'succeeded' && current.output_asset) return current; + if (current.status === 'failed') throw new Error('水印处理失败,请重试'); + await new Promise((resolve) => window.setTimeout(resolve, 1_500)); + } + throw new Error('水印仍在处理中,请稍后重试'); +} + /** 浏览器端计算文件的 SHA-256 十六进制摘要(complete 校验用) */ export async function sha256Hex(file: File): Promise { const digest = await crypto.subtle.digest('SHA-256', await file.arrayBuffer()); diff --git a/apps/web/src/services/onlineProducts.ts b/apps/web/src/services/onlineProducts.ts new file mode 100644 index 0000000..2c9bddd --- /dev/null +++ b/apps/web/src/services/onlineProducts.ts @@ -0,0 +1,59 @@ +import type { + CursorPaginationMeta, + OnlineProduct, + OnlineProductSyncRun, + OnlineProductsMeta, +} from '@atlas/contracts'; +import { api } from './api'; + +export type OnlineProductFilters = { + q: string; + visibility: 'all' | 'visible' | 'invisible' | 'unknown'; + source?: 'atlas_published' | 'external_sync'; + moderation_status?: string; +}; + +export const onlineProductsApi = { + list: (platformId: string, storeId: string, filters: OnlineProductFilters, cursor?: string) => + api.getList('/platform-listings', { + headers: { 'X-Atlas-Platform-Id': platformId }, + params: { + store_id: storeId, + limit: 20, + ...(cursor ? { cursor } : {}), + ...(filters.q.trim() ? { q: filters.q.trim() } : {}), + visibility: filters.visibility, + ...(filters.source ? { source: filters.source } : {}), + ...(filters.moderation_status + ? { moderation_status: filters.moderation_status } + : {}), + }, + }), + get: (platformId: string, listingId: string) => + api.get(`/platform-listings/${listingId}`, { + headers: { 'X-Atlas-Platform-Id': platformId }, + }), + createSync: (platformId: string, storeId: string) => + api.post<{ sync_run: OnlineProductSyncRun }>( + `/stores/${storeId}/platform-product-sync-runs`, + { reason: 'manual_refresh' }, + { + headers: { + 'X-Atlas-Platform-Id': platformId, + 'Idempotency-Key': crypto.randomUUID(), + }, + } + ), + getSyncRun: (platformId: string, runId: string) => + api.get(`/platform-product-sync-runs/${runId}`, { + headers: { 'X-Atlas-Platform-Id': platformId }, + }), + listSyncRuns: (platformId: string, storeId: string, cursor?: string) => + api.getList( + `/stores/${storeId}/platform-product-sync-runs`, + { + headers: { 'X-Atlas-Platform-Id': platformId }, + params: { limit: 20, ...(cursor ? { cursor } : {}) }, + } + ), +}; diff --git a/apps/web/src/services/watermarks.ts b/apps/web/src/services/watermarks.ts index dbca796..90b243a 100644 --- a/apps/web/src/services/watermarks.ts +++ b/apps/web/src/services/watermarks.ts @@ -32,6 +32,13 @@ export const watermarksApi = { get: (templateId: string) => api.get(`/watermark-templates/${templateId}`), + getDefault: () => api.get('/watermark-templates/default'), + + setDefault: (templateId: string) => + api.put(`/watermark-templates/${templateId}/default`, {}, { + headers: { 'Idempotency-Key': crypto.randomUUID() }, + }), + create: (payload: CreateWatermarkTemplateRequest) => api.post('/watermark-templates', payload, { headers: { 'Idempotency-Key': crypto.randomUUID() }, diff --git a/apps/worker/package.json b/apps/worker/package.json index ac2e29d..39814e8 100644 --- a/apps/worker/package.json +++ b/apps/worker/package.json @@ -15,7 +15,8 @@ "@atlas/database": "workspace:*", "@aws-sdk/client-s3": "3.1015.0", "bullmq": "6.3.4", - "ioredis": "6.0.0" + "ioredis": "6.0.0", + "sharp": "0.35.4" }, "devDependencies": { "@types/node": "26.3.0", diff --git a/apps/worker/src/main.ts b/apps/worker/src/main.ts index 80df277..a3a2649 100644 --- a/apps/worker/src/main.ts +++ b/apps/worker/src/main.ts @@ -29,6 +29,10 @@ import { syncPlatformCatalog, } from './platform-catalog-sync.js'; import { runProductAiTask } from './product-ai.js'; +import { markAspectRatioDerivativeFailed, runAspectRatioDerivative } from './media-aspect-ratio.js'; +import { markWatermarkDerivativeFailed, runWatermarkDerivative } from './media-watermark.js'; +import { classifyAiEditFailure, markMediaAiEditFailed, runMediaAiEdit } from './media-ai-edit.js'; +import { archiveExpiredMissingOnlineProducts, markOnlineProductSyncFailed, runOnlineProductSync, type OnlineProductSyncJob } from './online-product-sync.js'; const connection = new Redis(workerConfig.redisUrl, { maxRetriesPerRequest: null, @@ -82,6 +86,18 @@ const worker = new Worker( await syncPlatformCatalog(database, job.data as PlatformCatalogJob); } else if (job.name === 'product_ai_task.requested') { await runProductAiTask(database, job.data as OutboxJob); + } else if (job.name === 'media.aspect_ratio_derivative.requested') { + await runAspectRatioDerivative( + database, + objectStorage, + job.data as OutboxJob, + ); + } else if (job.name === 'media.watermark_derivative.requested') { + await runWatermarkDerivative(database, objectStorage, job.data as OutboxJob); + } else if (job.name === 'media.ai_edit.requested') { + await runMediaAiEdit(database, objectStorage, job.data as OutboxJob); + } else if (job.name === 'platform_product_sync.requested') { + await runOnlineProductSync(database, job.data as OnlineProductSyncJob); } else if (job.name === 'store_credential_verification.requested') { await verifyStoreCredentials( database, @@ -160,6 +176,14 @@ worker.on('failed', (job, error) => { database, job.data as PlatformCatalogJob, ); + } else if (job.name === 'media.aspect_ratio_derivative.requested') { + void markAspectRatioDerivativeFailed(database, job.data as OutboxJob); + } else if (job.name === 'media.watermark_derivative.requested') { + void markWatermarkDerivativeFailed(database, job.data as OutboxJob); + } else if (job.name === 'media.ai_edit.requested') { + void markMediaAiEditFailed(database, job.data as OutboxJob, classifyAiEditFailure(error)); + } else if (job.name === 'platform_product_sync.requested') { + void markOnlineProductSyncFailed(database, job.data as OnlineProductSyncJob, error); } else if (job.name === 'store_credential_verification.requested') { void markUnavailable(database, job.data as StoreVerificationJob, error); } else if ( @@ -376,6 +400,7 @@ async function markUnavailable( let dispatcherTimer: ReturnType | undefined; let mediaCleanupTimer: ReturnType | undefined; let platformCatalogTimer: ReturnType | undefined; +let onlineProductArchiveTimer: ReturnType | undefined; async function bootstrap() { await queue.waitUntilReady(); await platformQueue.waitUntilReady(); @@ -384,6 +409,7 @@ async function bootstrap() { await dispatcher.dispatchBatch(); await platformDispatcher.dispatchBatch(); await platformScheduler.run(); + await archiveExpiredMissingOnlineProducts(database); dispatcherTimer = setInterval(() => { void dispatcher.dispatchBatch().catch((error: unknown) => { console.error( @@ -408,6 +434,12 @@ async function bootstrap() { }), 60_000, ); + onlineProductArchiveTimer = setInterval( + () => void archiveExpiredMissingOnlineProducts(database).catch((error: unknown) => { + console.error('online_product_archive_failed', error instanceof Error ? error.message : error); + }), + 60 * 60_000, + ); void mediaUploadCleanup.run().catch((error: unknown) => { console.error( 'media_upload_cleanup_startup_failed', @@ -439,6 +471,7 @@ async function shutdown() { if (dispatcherTimer) clearInterval(dispatcherTimer); if (mediaCleanupTimer) clearInterval(mediaCleanupTimer); if (platformCatalogTimer) clearInterval(platformCatalogTimer); + if (onlineProductArchiveTimer) clearInterval(onlineProductArchiveTimer); await worker.close(); await platformWorker.close(); await queue.close(); diff --git a/apps/worker/src/media-ai-edit.ts b/apps/worker/src/media-ai-edit.ts new file mode 100644 index 0000000..acfd0df --- /dev/null +++ b/apps/worker/src/media-ai-edit.ts @@ -0,0 +1,456 @@ +import { createHash, randomUUID } from 'node:crypto'; +import { lookup } from 'node:dns/promises'; +import { request as httpsRequest } from 'node:https'; +import { isIP, type LookupFunction } from 'node:net'; +import { GetObjectCommand, PutObjectCommand, type S3Client } from '@aws-sdk/client-s3'; +import type { MediaAiEditFailureStage, MediaAiEditSettings } from '@atlas/contracts'; +import type { Database } from '@atlas/database'; +import sharp from 'sharp'; +import { buildMediaAiPrompt } from './media-ai-prompt.js'; +import type { OutboxJob } from './outbox-dispatcher.js'; + +type StoredAsset = { id: string; storage_provider: string; bucket: string; object_key: string }; +type EditTask = { + status: 'queued' | 'running' | 'succeeded' | 'failed' | 'cancelled'; + source_asset_id: string; + reference_asset_ids: string[]; + settings: MediaAiEditSettings; + provider_task_id: string | null; + created_at: Date | string; +}; +const sizes: Record = { + '3:4': [1152, 1536], '1:1': [1024, 1024], '4:5': [1280, 1600], +}; +type AiEditFailureCode = 'AI_IMAGE_GENERATION_FAILED' | 'AI_IMAGE_CONTENT_REJECTED' | 'AI_IMAGE_PROVIDER_FAILED'; +export type AiEditFailure = { + code: AiEditFailureCode; + source: 'pcore' | 'atlas'; + stage: MediaAiEditFailureStage; + detail: string; + providerErrorRaw: string | null; +}; + +class AiEditStageError extends Error { + public constructor(public readonly stage: MediaAiEditFailureStage, public readonly cause: unknown) { + super(cause instanceof Error ? cause.message : '未知错误'); + } +} + +async function atStage(stage: MediaAiEditFailureStage, action: () => Promise): Promise { + try { return await action(); } + catch (error) { throw error instanceof AiEditStageError ? error : new AiEditStageError(stage, error); } +} + +/** 只保存可供排查的短摘要,避免把密钥或带签名的地址写入任务结果。 */ +function safeFailureDetail(error: unknown, stage: MediaAiEditFailureStage): string { + const message = error instanceof Error ? error.message : '未知错误'; + if (stage === 'task_setup' || stage === 'input_preparation' || stage === 'result_storage') { + if (['图片对象不存在', '源图片超过处理上限', '参考图片格式不受支持', + '参考图片素材不存在'].includes(message)) return message; + if (error instanceof PcoreResponseError) return message.slice(0, 500); + const code = error && typeof error === 'object' && 'code' in error && typeof error.code === 'string' + && /^[A-Z0-9_]{2,32}$/i.test(error.code) ? `(${error.code})` : ''; + return `${error instanceof Error ? error.name : 'Error'}${code}`; + } + return message.replace(/Bearer\s+\S+/gi, 'Bearer [已隐藏]') + .replace(/https?:\/\/[^\s)]+/gi, '[地址已隐藏]') + .replace(/(?:api[_-]?key|token|secret)\s*[:=]\s*\S+/gi, '[凭据已隐藏]') + .replace(/\bai-edits\/[^\s)]+/gi, '[对象键已隐藏]') + .slice(0, 500); +} + +export function classifyAiEditFailure(error: unknown): AiEditFailure { + const stage = error instanceof AiEditStageError ? error.stage : 'task_setup'; + const cause = error instanceof AiEditStageError ? error.cause : error; + const providerError = cause instanceof PcoreResponseError ? cause : null; + const transportFailure = (stage === 'provider_submission' || stage === 'provider_polling') + && cause instanceof Error && ['TypeError', 'TimeoutError', 'AbortError'].includes(cause.name); + return { + code: 'AI_IMAGE_GENERATION_FAILED', + source: providerError || transportFailure ? 'pcore' : 'atlas', + stage, + detail: safeFailureDetail(cause, stage), + providerErrorRaw: providerError?.rawResponse ?? null, + }; +} + +export class PcoreResponseError extends Error { + public readonly rawResponse: string | null; + + public constructor(message: string, rawResponse: string | null) { + super(message); + this.rawResponse = rawResponse ? rawResponse.slice(0, 32_768) : null; + } +} + +class PcoreTerminalError extends PcoreResponseError { + public constructor(public readonly errorCode: AiEditFailureCode, rawResponse: string | null) { + super(errorCode === 'AI_IMAGE_CONTENT_REJECTED' + ? 'Pcore 未能完成这次生图,可能是输入或生成结果被审核拦截' + : 'Pcore 图片任务失败', rawResponse); + } +} + +function pcoreErrorText(value: unknown): string { + if (typeof value === 'string') return value; + if (!value || typeof value !== 'object') return ''; + const error = value as { code?: unknown; message?: unknown }; + return [error.code, error.message].filter((item) => typeof item === 'string').join(' '); +} + +function pcoreFailureCode(value: unknown): AiEditFailureCode { + if (!value || typeof value !== 'object') return 'AI_IMAGE_PROVIDER_FAILED'; + const error = value as { code?: unknown; type?: unknown }; + const explicitCode = [error.code, error.type].filter((item) => typeof item === 'string').join(' '); + return /safety|content[_-]?policy|moderation|blocked/i.test(explicitCode) + ? 'AI_IMAGE_CONTENT_REJECTED' : 'AI_IMAGE_PROVIDER_FAILED'; +} +function publicIpv4(address: string): boolean { + if (isIP(address) !== 4) return false; + const [a, b, c] = address.split('.').map(Number); + return !(a === 0 || a === 10 || a === 127 || a >= 224 + || a === 100 && b >= 64 && b <= 127 + || a === 169 && b === 254 + || a === 172 && b >= 16 && b <= 31 + || a === 192 && (b === 168 || b === 0 && (c === 0 || c === 2)) + || a === 198 && (b === 18 || b === 19 || b === 51 && c === 100) + || a === 203 && b === 0 && c === 113); +} + +async function readObject(storage: S3Client, asset: StoredAsset): Promise { + const response = await storage.send(new GetObjectCommand({ Bucket: asset.bucket, Key: asset.object_key })); + if (!response.Body) throw new Error('图片对象不存在'); + const chunks: Buffer[] = []; + let bytes = 0; + for await (const chunk of response.Body as AsyncIterable) { + bytes += chunk.byteLength; + if (bytes > 20 * 1024 * 1024) throw new Error('源图片超过处理上限'); + chunks.push(Buffer.from(chunk)); + } + return Buffer.concat(chunks, bytes); +} + +/** 普通编辑向供应商传原始图片字节,避免转码改变参考图内容。 */ +async function referenceImageDataUri(bytes: Buffer): Promise { + const image = sharp(bytes, { limitInputPixels: 100_000_000, failOn: 'error' }); + const meta = await image.metadata(); + if (!['jpeg', 'png', 'webp'].includes(meta.format ?? '') || !meta.width || !meta.height) + throw new Error('参考图片格式不受支持'); + const mime = meta.format === 'jpeg' ? 'image/jpeg' : `image/${meta.format}`; + return `data:${mime};base64,${bytes.toString('base64')}`; +} + +async function outpaintInputs(bytes: Buffer): Promise<{ image: Buffer; mask: Buffer; original: Buffer; left: number; top: number }> { + const [width, height] = sizes['3:4']; + const original = await sharp(bytes, { limitInputPixels: 100_000_000, failOn: 'error' }) + .rotate().resize(width, height, { fit: 'inside', withoutEnlargement: false }) + .ensureAlpha().png().toBuffer(); + const meta = await sharp(original).metadata(); + const left = Math.floor((width - meta.width!) / 2); + const top = Math.floor((height - meta.height!) / 2); + const canvas = await sharp({ create: { width, height, channels: 4, background: '#00000000' } }) + .composite([{ input: original, left, top }]).png().toBuffer(); + const opaque = await sharp({ create: { width: meta.width!, height: meta.height!, channels: 4, background: '#ffffffff' } }) + .png().toBuffer(); + const mask = await sharp({ create: { width, height, channels: 4, background: '#00000000' } }) + .composite([{ input: opaque, left, top }]).png().toBuffer(); + return { + image: canvas, + mask, + original, left, top, + }; +} + +function providerConfig(): { base: string; key: string } { + const key = process.env.PCORE_API_KEY; + const rawBase = process.env.PCORE_BASE_URL?.trim(); + if (!key || !rawBase) throw new Error('PCORE_API_KEY 和 PCORE_BASE_URL 必须配置'); + const url = new URL(rawBase); + const path = url.pathname.replace(/\/+$/, ''); + if (url.protocol !== 'https:' || url.username || url.password || url.port + || !['', '/v1'].includes(path) || url.search || url.hash) + throw new Error('PCORE_BASE_URL 必须是 HTTPS 根地址或 /v1 地址'); + return { base: `${url.origin}/v1`, key }; +} + +async function providerRequest(url: string, key: string, body?: Record): Promise> { + const response = await fetch(url, { + method: body ? 'POST' : 'GET', + headers: { Authorization: `Bearer ${key}`, ...(body ? { 'Content-Type': 'application/json' } : {}) }, + body: body ? JSON.stringify(body) : undefined, + signal: AbortSignal.timeout(body ? 90_000 : 35_000), + redirect: 'error', + }); + if (!response.ok) throw new PcoreResponseError( + `Pcore 图片任务请求失败(HTTP ${response.status})`, await response.text()); + let result: Record; + try { + result = await response.json() as Record; + if (!result || typeof result !== 'object' || Array.isArray(result)) throw new Error('invalid response'); + } catch { + throw new PcoreResponseError('Pcore 返回的图片任务响应不是有效 JSON 对象', null); + } + if (result.error && (body || !['failed', 'error', 'cancelled'].includes(String(result.status ?? '')))) + throw new PcoreResponseError(`Pcore 图片任务请求被拒绝${pcoreErrorText(result.error) + ? `(${pcoreErrorText(result.error).slice(0, 200)})` : ''}`, + JSON.stringify(result.error)); + return result; +} + +async function submitProvider(storage: S3Client, assets: StoredAsset[], settings: MediaAiEditSettings): Promise { + const provider = await atStage('provider_submission', async () => providerConfig()); + const size = sizes[settings.mode === 'outpaint' ? '3:4' : settings.ratio]; + const main = await atStage('input_preparation', () => readObject(storage, assets[0])); + const outpaint = settings.mode === 'outpaint' + ? await atStage('input_preparation', () => outpaintInputs(main)) : null; + const images = await atStage('input_preparation', async () => outpaint + ? [`data:image/png;base64,${outpaint.image.toString('base64')}`] + : await Promise.all(assets.map(async (asset, index) => + referenceImageDataUri(index === 0 ? main : await readObject(storage, asset))))); + const body: Record = { + model: 'gpt-image-2', prompt: buildMediaAiPrompt(settings), n: 1, + size: `${size[0]}x${size[1]}`, async: true, + images: images.map((image_url) => ({ image_url })), + }; + if (outpaint) body.mask = { image_url: `data:image/png;base64,${outpaint.mask.toString('base64')}` }; + const result = await atStage('provider_submission', () => + providerRequest(`${provider.base}/images/edits`, provider.key, body)); + const id = result.task_id ?? result.id; + if (typeof id !== 'string' || !id || id.length > 256) + throw new AiEditStageError('provider_submission', new PcoreResponseError('Pcore 未返回有效的任务 ID', null)); + return id; +} + +export async function downloadProviderImage(urlValue: string): Promise { + const url = new URL(urlValue); + if (url.protocol !== 'https:' || url.username || url.password || url.port || isIP(url.hostname)) + throw new Error('Pcore 结果地址不安全'); + const addresses = await lookup(url.hostname, { all: true, family: 4 }); + if (!addresses.length || addresses.some((item) => !publicIpv4(item.address))) + throw new Error('Pcore 结果地址不安全'); + /** 固定到校验过的公网 IPv4,同时保留域名用于 TLS 证书校验。 */ + const response = await new Promise((resolve, reject) => { + const request = httpsRequest(url, { + lookup: pinnedLookup(addresses[0].address), + timeout: 60_000, + }, resolve); + request.on('error', reject); + request.on('timeout', () => request.destroy(new PcoreResponseError('Pcore 结果下载超时', null))); + request.end(); + }); + if (response.statusCode !== 200 || Number(response.headers['content-length'] ?? 0) > 25 * 1024 * 1024) { + response.resume(); + throw new PcoreResponseError(`Pcore 结果下载失败(HTTP ${response.statusCode})`, null); + } + const chunks: Buffer[] = []; + let size = 0; + for await (const chunk of response) { + size += chunk.length; + if (size > 25 * 1024 * 1024) { + response.destroy(); + throw new Error('Pcore 结果超过处理上限'); + } + chunks.push(Buffer.from(chunk)); + } + return Buffer.concat(chunks, size); +} + +export function pinnedLookup(address: string): LookupFunction { + return (_hostname, options, callback) => { + const pinned = { address, family: 4 }; + if (options.all) callback(null, [pinned]); + else callback(null, pinned.address, pinned.family); + }; +} + +export function providerImageResult(result: Record): Buffer | string { + const first = Array.isArray(result.data) ? result.data[0] as Record | undefined : undefined; + for (const encoded of [first?.b64_json, first?.b64_image]) { + if (typeof encoded !== 'string' || !encoded.trim()) continue; + if (encoded.length > 35 * 1024 * 1024) throw new Error('Pcore 结果超过处理上限'); + const bytes = Buffer.from(encoded.replace(/^data:[^,]*,/, ''), 'base64'); + if (bytes.length) return bytes; + } + if (typeof first?.url === 'string' && first.url) return first.url; + throw new Error('Pcore 任务完成但没有图片'); +} + +async function pollProvider(taskId: string): Promise { + const provider = await atStage('provider_polling', async () => providerConfig()); + const result = await atStage('provider_polling', () => + providerRequest(`${provider.base}/images/edits/${encodeURIComponent(taskId)}`, provider.key)); + const status = String(result.status ?? ''); + if (['failed', 'error', 'cancelled'].includes(status)) + throw new PcoreTerminalError(pcoreFailureCode(result.error), + JSON.stringify(result.error ?? result)); + if (!['completed', 'succeeded'].includes(status)) return null; + const image = await atStage('result_download', async () => { + try { return providerImageResult(result); } + catch (error) { + throw new PcoreResponseError(error instanceof Error ? error.message : 'Pcore 图片结果无效', null); + } + }); + return typeof image === 'string' + ? atStage('result_download', () => downloadProviderImage(image)) : image; +} + +async function schedulePoll(database: Database, workspaceId: string, id: string) { + await database.workspaceTransaction(workspaceId, async (tx) => { + const current = await tx.query<{ status: string }>( + 'SELECT status FROM atlas.media_ai_edits WHERE id = $1 FOR UPDATE', [id], + ); + if (current.rows[0]?.status !== 'running') return; + await tx.query( + `INSERT INTO atlas.outbox_events + (workspace_id, aggregate_type, aggregate_id, event_type, event_payload, deduplication_key, next_attempt_at) + VALUES (current_setting('app.workspace_id')::uuid, 'media_ai_edit', $1, + 'media.ai_edit.requested', jsonb_build_object('edit_id', $1::uuid), $2, now() + interval '3 seconds')`, + [id, `${id}:${randomUUID()}`], + ); + }); +} + +export async function normalizeAiEditOutput(bytes: Buffer, settings: MediaAiEditSettings, source?: Buffer): Promise { + const target = sizes[settings.mode === 'outpaint' ? '3:4' : settings.ratio]; + const image = sharp(bytes, { limitInputPixels: 100_000_000, failOn: 'error' }); + const meta = await image.metadata(); + if (!['png', 'jpeg', 'webp'].includes(meta.format ?? '') || !meta.width || !meta.height) + throw new Error('Pcore 结果图片无效'); + if (settings.mode === 'outpaint' && source) { + const restored = await outpaintInputs(source); + return image.rotate().resize(target[0], target[1], { fit: 'cover' }) + .composite([{ input: restored.original, left: restored.left, top: restored.top }]) + .flatten({ background: '#ffffff' }).jpeg({ quality: 92 }).toBuffer(); + } + return image.rotate().resize(target[0], target[1], { fit: 'contain', background: '#ffffff' }) + .flatten({ background: '#ffffff' }).jpeg({ quality: 92 }).toBuffer(); +} + +/** 每个 Outbox 事件只推进一次供应商状态,等待由下一条延迟事件承担。 */ +export async function runMediaAiEdit(database: Database, storage: S3Client, job: OutboxJob) { + const id = String(job.payload.edit_id ?? ''); + const task = await atStage('task_setup', () => database.workspaceTransaction(job.workspaceId, async (tx) => { + const result = await tx.query( + 'SELECT status, source_asset_id, reference_asset_ids, settings, provider_task_id, created_at FROM atlas.media_ai_edits WHERE id = $1 FOR UPDATE', [id], + ); + const current = result.rows[0]; + if (!current || !['queued', 'running'].includes(current.status)) return null; + if (Date.now() - new Date(current.created_at).getTime() > 20 * 60_000) + throw new AiEditStageError('provider_polling', new PcoreResponseError('等待 Pcore 图片任务超时', null)); + await tx.query( + `UPDATE atlas.media_ai_edits SET status = 'running', started_at = coalesce(started_at, now()) WHERE id = $1`, [id], + ); + await tx.query( + `UPDATE atlas.operations SET status = 'running', started_at = coalesce(started_at, now()), + attempt_count = attempt_count + 1 WHERE id = $1 AND status IN ('queued', 'running')`, [id], + ); + return current; + })); + if (!task) return; + const assetIds = [task.source_asset_id, ...task.reference_asset_ids]; + const assets = await atStage('input_preparation', () => database.workspaceTransaction(job.workspaceId, async (tx) => { + const result = await tx.query( + `SELECT id, storage_provider, bucket, object_key FROM atlas.media_assets WHERE id = ANY($1::uuid[])`, [assetIds], + ); + const byId = new Map(result.rows.map((item) => [item.id, item])); + return assetIds.map((assetId) => { + const asset = byId.get(assetId); + if (!asset) throw new Error('参考图片素材不存在'); + return asset; + }); + })); + if (!task.provider_task_id) { + const providerTaskId = await submitProvider(storage, assets, task.settings); + await atStage('task_setup', () => database.workspaceTransaction(job.workspaceId, async (tx) => { + await tx.query( + `UPDATE atlas.media_ai_edits SET provider_task_id = $2 WHERE id = $1 AND status = 'running' AND provider_task_id IS NULL`, + [id, providerTaskId], + ); + })); + await atStage('task_setup', () => schedulePoll(database, job.workspaceId, id)); + return; + } + let result: Buffer | null; + try { + result = await pollProvider(task.provider_task_id); + } catch (error) { + // 供应商任务已是终态,重试同一个任务 ID 不会改变结果。 + if (!(error instanceof PcoreTerminalError)) throw error; + await markMediaAiEditFailed(database, job, { + code: error.errorCode, source: 'pcore', stage: 'provider_polling', + detail: safeFailureDetail(error, 'provider_polling'), providerErrorRaw: error.rawResponse, + }); + return; + } + if (!result) { + await atStage('task_setup', () => schedulePoll(database, job.workspaceId, id)); + return; + } + const source = task.settings.mode === 'outpaint' + ? await atStage('result_processing', () => readObject(storage, assets[0])) : undefined; + const output = await atStage('result_processing', () => normalizeAiEditOutput(result, task.settings, source)); + if (output.byteLength > 20 * 1024 * 1024) + throw new AiEditStageError('result_processing', new Error('AI 编辑结果超过素材上限')); + const checksum = createHash('sha256').update(output).digest('hex'); + const objectKey = `ai-edits/${job.workspaceId}/${checksum}.jpg`; + const existing = await atStage('result_storage', () => database.workspaceTransaction(job.workspaceId, async (tx) => { + const row = await tx.query<{ id: string }>('SELECT id FROM atlas.media_assets WHERE checksum_sha256 = $1', [checksum]); + return row.rows[0]?.id ?? null; + })); + if (!existing) await atStage('result_storage', () => storage.send(new PutObjectCommand({ + Bucket: assets[0].bucket, Key: objectKey, Body: output, ContentType: 'image/jpeg', + }))); + const [width, height] = sizes[task.settings.mode === 'outpaint' ? '3:4' : task.settings.ratio]; + await atStage('result_storage', () => database.workspaceTransaction(job.workspaceId, async (tx) => { + const current = await tx.query<{ status: string }>( + 'SELECT status FROM atlas.media_ai_edits WHERE id = $1 FOR UPDATE', [id], + ); + if (current.rows[0]?.status !== 'running') return; + const asset = await tx.query<{ id: string }>( + `INSERT INTO atlas.media_assets + (workspace_id, storage_provider, bucket, object_key, content_type, byte_size, checksum_sha256, width_px, height_px) + VALUES (current_setting('app.workspace_id')::uuid, $1, $2, $3, 'image/jpeg', $4, $5, $6, $7) + ON CONFLICT (workspace_id, checksum_sha256) + DO UPDATE SET checksum_sha256 = EXCLUDED.checksum_sha256 RETURNING id`, + [assets[0].storage_provider, assets[0].bucket, objectKey, output.byteLength, checksum, width, height], + ); + const assetId = asset.rows[0].id; + await tx.query( + `UPDATE atlas.media_ai_edits SET status = 'succeeded', output_asset_id = $2, error_code = NULL, + completed_at = now() WHERE id = $1`, [id, assetId], + ); + await tx.query( + `UPDATE atlas.operations SET status = 'succeeded', completed_at = now(), + result = jsonb_build_object('output_asset_id', $2::uuid), error_code = NULL, error_message = NULL + WHERE id = $1 AND status IN ('queued', 'running')`, [id, assetId], + ); + })); +} + +export async function markMediaAiEditFailed( + database: Database, job: OutboxJob, failure: AiEditFailure, +) { + const id = String(job.payload.edit_id ?? ''); + const { code, source, stage, detail, providerErrorRaw } = failure; + const message = code === 'AI_IMAGE_CONTENT_REJECTED' + ? 'Pcore 未能完成这次生图,可能是输入或生成结果被审核拦截' + : code === 'AI_IMAGE_PROVIDER_FAILED' + ? 'Pcore 图片任务失败,请稍后重试' + : '单张 AI 编辑失败,请重试'; + await database.workspaceTransaction(job.workspaceId, async (tx) => { + await tx.query( + `UPDATE atlas.media_ai_edits SET status = 'failed', error_code = $2, + completed_at = now() WHERE id = $1 AND status IN ('queued', 'running')`, [id, code], + ); + await tx.query( + `UPDATE atlas.operations SET status = 'failed', error_code = $2, + error_message = $3, + result = coalesce(result, '{}'::jsonb) || jsonb_strip_nulls(jsonb_build_object( + 'failure_source', $4::text, 'failure_stage', $5::text, + 'failure_detail', $6::text, 'provider_error_raw', $7::text)), + completed_at = now() + WHERE id = $1 AND status IN ('queued', 'running')`, + [id, code, message, source, stage, detail, providerErrorRaw], + ); + }); +} diff --git a/apps/worker/src/media-ai-prompt.ts b/apps/worker/src/media-ai-prompt.ts new file mode 100644 index 0000000..d7661dc --- /dev/null +++ b/apps/worker/src/media-ai-prompt.ts @@ -0,0 +1,47 @@ +import type { MediaAiEditSettings } from '@atlas/contracts'; + +const styles: Record = { + 1: 'Nordic minimalist e-commerce style: warm ivory, soft daylight, natural texture and spacious composition.', + 2: 'Fresh bright e-commerce style: clean high-key daylight, natural colors and an airy retail composition.', + 3: 'Premium dark editorial style: charcoal setting, sculpted side lighting, realistic material texture.', + 4: 'Warm lifestyle style: believable cream home setting, balanced golden and window light, product as the hero.', + 5: 'Clean studio style: neutral seamless background, even softbox light, crisp edges and no decorative props.', +}; +const backgrounds: Record = { + auto: 'Keep the original background unless the user requests a change.', + white: 'Replace only the background with pure white; retain natural contact shadows.', + solid: 'Replace only the background with the solid color described by the user.', + scene: 'Replace only the background with the realistic scene described by the user.', + reference: 'Use Image 2 as background and lighting reference, but never copy its product.', +}; +const languages = { ru: 'Russian', ar: 'Arabic', en: 'English' }; + +/** 主图是商品事实来源;辅助图仅影响背景、光线和质感。 */ +export function buildMediaAiPrompt(input: MediaAiEditSettings): string { + if (input.mode === 'outpaint') return [ + 'Extend Image 1 to an exact 3:4 canvas. Generate only the transparent surrounding area.', + 'Keep the entire original image region visually unchanged and continue the surrounding scene naturally.', + input.prompt ? `User requirements for the new edge area: ${input.prompt}` : '', + 'Do not add text, watermarks, panels or new products.', + ].filter(Boolean).join('\n\n'); + const parts = [ + input.prompt ? `User requirements (highest priority): ${input.prompt}` : '', + 'Create exactly ONE finished e-commerce image. Image 1 is the fixed main product image. Preserve its shape, proportions, color, material, logo and distinguishing details. Never replace it with a product from another image.', + input.reference_asset_ids.length + ? `Images 2 through ${input.reference_asset_ids.length + 1} are auxiliary references only. Use them for details, background or lighting; Image 1 remains the product source of truth.` : '', + `BACKGROUND: ${backgrounds[input.background]}`, + input.background_description ? `Background details: ${input.background_description}` : '', + input.translate_text + ? `Read visible marketing text in Image 1, translate its meaning into ${languages[input.target_language]}, replace source-language marketing copy and redesign for a legible layout. For Arabic use right-to-left typesetting. Preserve physical product branding, factual values and units; add no unsupported claims.` + : 'Do not invent marketing copy or translate existing image text; preserve text on the physical product.', + ]; + if (input.generate_size_chart && input.dimensions) { + const dimensions = ([['length', 'length'], ['width', 'width'], ['height', 'height']] as const) + .flatMap(([key, label]) => input.dimensions?.[key] + ? [`${label}: ${input.dimensions[key]} ${input.dimensions.unit}`] : []); + parts.push(`Create a clean product measurement diagram with legible arrows. Label only these supplied dimensions: ${dimensions.join(', ')}. Do not infer other measurements.`); + } + parts.push(`VISUAL STYLE: ${input.style_prompt || styles[input.style_set]}`); + parts.push(`OUTPUT: exact ${input.ratio} composition. Follow explicit user, background and text instructions before style preferences. No watermark or extra panels.`); + return parts.filter(Boolean).join('\n\n'); +} diff --git a/apps/worker/src/media-aspect-ratio.ts b/apps/worker/src/media-aspect-ratio.ts new file mode 100644 index 0000000..b558d74 --- /dev/null +++ b/apps/worker/src/media-aspect-ratio.ts @@ -0,0 +1,221 @@ +import { createHash } from 'node:crypto'; +import { GetObjectCommand, PutObjectCommand, type S3Client } from '@aws-sdk/client-s3'; +import type { Database } from '@atlas/database'; +import sharp from 'sharp'; +import type { OutboxJob } from './outbox-dispatcher.js'; + +type Source = { + status: 'queued' | 'running' | 'succeeded' | 'failed'; + storage_provider: string; + bucket: string; + object_key: string; +}; + +/** 读取正式素材时始终限制字节数,避免对象被外部替换后耗尽 Worker 内存。 */ +async function readSource(storage: S3Client, source: Source): Promise { + const object = await storage.send(new GetObjectCommand({ + Bucket: source.bucket, + Key: source.object_key, + })); + if (!object.Body) throw new Error('源图片不存在'); + const chunks: Buffer[] = []; + let size = 0; + for await (const chunk of object.Body as AsyncIterable) { + size += chunk.byteLength; + if (size > 20 * 1024 * 1024) throw new Error('源图片超过处理上限'); + chunks.push(Buffer.from(chunk)); + } + return Buffer.concat(chunks, size); +} + +type Edge = 'top' | 'bottom' | 'left' | 'right'; +type Rgb = [number, number, number]; + +/** 用边缘内侧的窄带计算底色,减弱单行像素噪声对留白的影响。 */ +function edgeMean(data: Buffer, width: number, height: number, edge: Edge): Rgb { + const sums: Rgb = [0, 0, 0]; + let count = 0; + const depth = 8; + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) { + if (edge === 'top' && y >= depth) continue; + if (edge === 'bottom' && y < height - depth) continue; + if (edge === 'left' && x >= depth) continue; + if (edge === 'right' && x < width - depth) continue; + const offset = (y * width + x) * 3; + for (let channel = 0; channel < 3; channel++) sums[channel] += data[offset + channel]!; + count++; + } + } + return sums.map((sum) => Math.round(sum / count)) as Rgb; +} + +/** 接缝处保持原图边缘色,短距离内平滑过渡为边缘底色。 */ +function fadeWeight(distance: number): number { + const t = Math.min(Math.max(distance - 1, 0) / 5, 1); + return t * t * (3 - 2 * t); +} + +/** 等比扩充到固定画布,边缘颜色平滑延展,不让纹理形成贯穿留白的条纹。 */ +export async function renderThreeFour(bytes: Buffer): Promise { + const image = sharp(bytes, { limitInputPixels: 100_000_000, failOn: 'error' }); + const metadata = await image.metadata(); + if (!['jpeg', 'png', 'webp'].includes(metadata.format ?? '')) + throw new Error('源图片格式不受支持'); + if (!metadata.width || !metadata.height || metadata.width > 10_000 || metadata.height > 10_000) + throw new Error('源图片尺寸不受支持'); + const swapped = (metadata.orientation ?? 1) >= 5 && (metadata.orientation ?? 1) <= 8; + const width = swapped ? metadata.height : metadata.width; + const height = swapped ? metadata.width : metadata.height; + const wide = width * 4 > height * 3; + const outputWidth = wide ? 1200 : Math.max(1, Math.round(width * 1600 / height)); + const outputHeight = wide ? Math.max(1, Math.round(height * 1200 / width)) : 1600; + const left = Math.floor((1200 - outputWidth) / 2); + const top = Math.floor((1600 - outputHeight) / 2); + const { data, info } = await image.rotate().resize(outputWidth, outputHeight, { fit: 'fill' }) + .flatten({ background: '#ffffff' }).toColourspace('srgb').removeAlpha() + .raw().toBuffer({ resolveWithObject: true }); + if (info.channels !== 3) throw new Error('图片颜色通道不受支持'); + const firstMean = edgeMean(data, outputWidth, outputHeight, wide ? 'top' : 'left'); + const secondMean = edgeMean(data, outputWidth, outputHeight, wide ? 'bottom' : 'right'); + const canvas = Buffer.alloc(1200 * 1600 * 3); + for (let y = 0; y < 1600; y++) { + const targetStart = y * 1200 * 3; + if (wide) { + const relativeY = y - top; + if (relativeY >= 0 && relativeY < outputHeight) { + const sourceStart = relativeY * 1200 * 3; + data.copy(canvas, targetStart, sourceStart, sourceStart + 1200 * 3); + continue; + } + const firstSide = relativeY < 0; + const sourceStart = (firstSide ? 0 : outputHeight - 1) * 1200 * 3; + const mean = firstSide ? firstMean : secondMean; + const weight = fadeWeight(firstSide ? -relativeY : relativeY - outputHeight + 1); + for (let x = 0; x < 1200; x++) { + const sourceOffset = sourceStart + x * 3; + const targetOffset = targetStart + x * 3; + for (let channel = 0; channel < 3; channel++) + canvas[targetOffset + channel] = Math.round(data[sourceOffset + channel]! * (1 - weight) + mean[channel]! * weight); + } + continue; + } + const sourceStart = y * outputWidth * 3; + const firstPixel = data.subarray(sourceStart, sourceStart + 3); + const lastPixel = data.subarray(sourceStart + (outputWidth - 1) * 3, sourceStart + outputWidth * 3); + data.copy(canvas, targetStart + left * 3, sourceStart, sourceStart + outputWidth * 3); + for (let x = 0; x < left; x++) { + const weight = fadeWeight(left - x); + for (let channel = 0; channel < 3; channel++) + canvas[targetStart + x * 3 + channel] = Math.round(firstPixel[channel]! * (1 - weight) + firstMean[channel]! * weight); + } + for (let x = left + outputWidth; x < 1200; x++) { + const weight = fadeWeight(x - left - outputWidth + 1); + for (let channel = 0; channel < 3; channel++) + canvas[targetStart + x * 3 + channel] = Math.round(lastPixel[channel]! * (1 - weight) + secondMean[channel]! * weight); + } + } + return sharp(canvas, { raw: { width: 1200, height: 1600, channels: 3 } }) + .jpeg({ quality: 95, chromaSubsampling: '4:4:4' }).toBuffer(); +} + +/** 同一派生 ID 反复执行得到同一结果;业务完成状态只保存在 PostgreSQL。 */ +export async function runAspectRatioDerivative( + database: Database, + storage: S3Client, + job: OutboxJob, +) { + const derivativeId = String(job.payload.derivative_id ?? ''); + const source = await database.workspaceTransaction(job.workspaceId, async (tx) => { + const result = await tx.query( + `SELECT d.status, a.storage_provider, a.bucket, a.object_key + FROM atlas.media_aspect_ratio_derivatives d + JOIN atlas.media_assets a ON a.id = d.source_asset_id + WHERE d.id = $1 FOR UPDATE OF d`, + [derivativeId], + ); + const row = result.rows[0]; + if (!row || row.status === 'succeeded' || row.status === 'failed') return null; + await tx.query( + `UPDATE atlas.media_aspect_ratio_derivatives + SET status = 'running', started_at = coalesce(started_at, now()) + WHERE id = $1`, + [derivativeId], + ); + await tx.query( + `UPDATE atlas.operations SET status = 'running', + started_at = coalesce(started_at, now()), attempt_count = attempt_count + 1 + WHERE id = $1 AND status IN ('queued', 'running')`, + [derivativeId], + ); + return row; + }); + if (!source) return; + + const output = await renderThreeFour(await readSource(storage, source)); + if (output.byteLength > 20 * 1024 * 1024) throw new Error('生成图片超过处理上限'); + const checksum = createHash('sha256').update(output).digest('hex'); + const objectKey = `derivatives/${job.workspaceId}/three-four/${checksum}.jpg`; + const existing = await database.workspaceTransaction(job.workspaceId, async (tx) => { + const result = await tx.query<{ id: string }>( + 'SELECT id FROM atlas.media_assets WHERE checksum_sha256 = $1', + [checksum], + ); + return result.rows[0]?.id ?? null; + }); + if (!existing) { + await storage.send(new PutObjectCommand({ + Bucket: source.bucket, + Key: objectKey, + Body: output, + ContentType: 'image/jpeg', + })); + } + await database.workspaceTransaction(job.workspaceId, async (tx) => { + const asset = await tx.query<{ id: string }>( + `INSERT INTO atlas.media_assets + (workspace_id, storage_provider, bucket, object_key, content_type, + byte_size, checksum_sha256, width_px, height_px) + VALUES (current_setting('app.workspace_id')::uuid, $1, + $2, $3, 'image/jpeg', $4, $5, 1200, 1600) + ON CONFLICT (workspace_id, checksum_sha256) + DO UPDATE SET checksum_sha256 = EXCLUDED.checksum_sha256 + RETURNING id`, + [source.storage_provider, source.bucket, objectKey, output.byteLength, checksum], + ); + if (!asset.rows[0]) throw new Error('派生素材登记失败'); + await tx.query( + `UPDATE atlas.media_aspect_ratio_derivatives + SET status = 'succeeded', output_asset_id = $2, completed_at = now(), error_code = NULL + WHERE id = $1 AND status = 'running'`, + [derivativeId, asset.rows[0].id], + ); + await tx.query( + `UPDATE atlas.operations SET status = 'succeeded', completed_at = now(), + result = jsonb_build_object('output_asset_id', $2::uuid), + error_code = NULL, error_message = NULL + WHERE id = $1 AND status IN ('queued', 'running')`, + [derivativeId, asset.rows[0].id], + ); + }); +} + +/** 重试耗尽后只向前端暴露稳定错误码,内部异常不会进入任务记录。 */ +export async function markAspectRatioDerivativeFailed(database: Database, job: OutboxJob) { + const derivativeId = String(job.payload.derivative_id ?? ''); + await database.workspaceTransaction(job.workspaceId, async (tx) => { + await tx.query( + `UPDATE atlas.media_aspect_ratio_derivatives + SET status = 'failed', error_code = 'IMAGE_PROCESSING_FAILED', completed_at = now() + WHERE id = $1 AND status IN ('queued', 'running')`, + [derivativeId], + ); + await tx.query( + `UPDATE atlas.operations + SET status = 'failed', error_code = 'IMAGE_PROCESSING_FAILED', + error_message = '图片处理失败,请重试', completed_at = now() + WHERE id = $1 AND status IN ('queued', 'running')`, + [derivativeId], + ); + }); +} diff --git a/apps/worker/src/media-watermark.ts b/apps/worker/src/media-watermark.ts new file mode 100644 index 0000000..b45672b --- /dev/null +++ b/apps/worker/src/media-watermark.ts @@ -0,0 +1,218 @@ +import { createHash } from 'node:crypto'; +import { GetObjectCommand, PutObjectCommand, type S3Client } from '@aws-sdk/client-s3'; +import type { Database } from '@atlas/database'; +import sharp from 'sharp'; +import type { OutboxJob } from './outbox-dispatcher.js'; + +type StoredObject = { storage_provider: string; bucket: string; object_key: string }; +type Task = StoredObject & { + status: 'queued' | 'running' | 'succeeded' | 'failed'; + kind: 'text' | 'image'; + text_content: string | null; + font_code: string | null; + font_size_px: number | null; + opacity: string; + scale_ratio: string | null; + image_size_px: number | null; + border_radius_px: number | null; + position_x: number | null; + position_y: number | null; + position: string; + watermark_bucket: string | null; + watermark_object_key: string | null; +}; + +/** 对象存储内容在读入内存时再次限制大小,不依赖上传时的校验。 */ +async function readObject(storage: S3Client, bucket: string, key: string, maxBytes: number): Promise { + const object = await storage.send(new GetObjectCommand({ Bucket: bucket, Key: key })); + if (!object.Body) throw new Error('图片对象不存在'); + const chunks: Buffer[] = []; + let size = 0; + for await (const chunk of object.Body as AsyncIterable) { + size += chunk.byteLength; + if (size > maxBytes) throw new Error('图片对象超过处理上限'); + chunks.push(Buffer.from(chunk)); + } + return Buffer.concat(chunks, size); +} + +function escapeXml(value: string): string { + return value.replace(/[&<>"']/g, (character) => ({ + '&': '&', '<': '<', '>': '>', '"': '"', "'": ''', + })[character]!); +} + +function legacyCoordinate(position: string): [number, number] { + const values: Record = { + top_left: [0, 0], top_center: [0.5, 0], top_right: [1, 0], + middle_left: [0, 0.5], center: [0.5, 0.5], middle_right: [1, 0.5], + bottom_left: [0, 1], bottom_center: [0.5, 1], bottom_right: [1, 1], + }; + return values[position] ?? [1, 1]; +} + +function placement(canvas: number, mark: number, coordinate: number, requestedInset: number): number { + const inset = Math.min(requestedInset, Math.max(0, Math.floor((canvas - mark) / 2))); + return Math.max(0, Math.min(canvas - mark, Math.round(inset + coordinate * Math.max(0, canvas - mark - 2 * inset)))); +} + +/** 渲染器只使用已锁定的模板版本;透明度作用于整个水印图层。 */ +export async function renderWatermark(sourceBytes: Buffer, template: Task, watermarkBytes?: Buffer): Promise<{ + bytes: Buffer; width: number; height: number; +}> { + const input = sharp(sourceBytes, { limitInputPixels: 100_000_000, failOn: 'error' }); + const meta = await input.metadata(); + if (!['jpeg', 'png', 'webp'].includes(meta.format ?? '') || !meta.width || !meta.height + || meta.width > 10_000 || meta.height > 10_000) throw new Error('源图片格式或尺寸不受支持'); + const rotated = (meta.orientation ?? 1) >= 5 && (meta.orientation ?? 1) <= 8; + const width = rotated ? meta.height : meta.width; + const height = rotated ? meta.width : meta.height; + /** 模板中的像素值以 240×320 预览框为基准,等比映射到完整源图。 */ + const previewScale = Math.max(1, width / 240, height / 320); + const inset = Math.max(1, Math.round(5 * previewScale)); + let layer: Buffer; + if (template.kind === 'image') { + if (!watermarkBytes) throw new Error('图片水印素材不存在'); + const overlay = sharp(watermarkBytes, { limitInputPixels: 25_000_000, failOn: 'error' }); + const overlayMeta = await overlay.metadata(); + if (!['jpeg', 'png', 'webp'].includes(overlayMeta.format ?? '') || !overlayMeta.width || !overlayMeta.height) + throw new Error('图片水印格式不受支持'); + const baseSize = Math.min(200, Math.max(10, template.image_size_px ?? Math.round(Number(template.scale_ratio ?? 0.5) * 144))); + const overlayRotated = (overlayMeta.orientation ?? 1) >= 5 && (overlayMeta.orientation ?? 1) <= 8; + const overlayWidth = overlayRotated ? overlayMeta.height : overlayMeta.width; + const overlayHeight = overlayRotated ? overlayMeta.width : overlayMeta.height; + const ratio = overlayWidth / overlayHeight; + const desiredWidth = baseSize * previewScale * Math.min(1, ratio); + const desiredHeight = baseSize * previewScale / Math.max(1, ratio); + const fit = Math.min(1, Math.max(1, width - 2 * inset) / desiredWidth, + Math.max(1, height - 2 * inset) / desiredHeight); + const markWidth = Math.max(1, Math.round(desiredWidth * fit)); + const markHeight = Math.max(1, Math.round(desiredHeight * fit)); + const resized = await overlay.rotate().resize(markWidth, markHeight, { fit: 'fill' }).ensureAlpha().png().toBuffer(); + const radius = Math.max(0, Math.round((template.border_radius_px ?? 0) * previewScale * fit)); + if (radius) { + const resizedMeta = await sharp(resized).metadata(); + const mask = Buffer.from(``); + layer = await sharp(resized).composite([{ input: mask, blend: 'dest-in' }]).png().toBuffer(); + } else layer = resized; + } else { + const content = template.text_content ?? ''; + const baseFontSize = template.font_size_px ?? 16; + if (!content || content.length > 200 || baseFontSize < 12 || baseFontSize > 60) throw new Error('文字水印配置无效'); + const fontSize = Math.min(1000, Math.max(1, Math.round(baseFontSize * previewScale))); + const textContent = content.replace(/\s+/gu, ' '); + const safeFont = escapeXml(template.font_code ?? 'sans-serif'); + const svgWidth = Math.min(6000, Math.max(100, Math.ceil(textContent.length * fontSize * 1.2 + 20))); + const svgHeight = Math.max(100, Math.ceil(fontSize * 1.6 + 20)); + const svg = `${escapeXml(textContent)}`; + layer = await sharp(Buffer.from(svg)).trim().png().toBuffer(); + } + const opacity = Number(template.opacity); + if (!Number.isFinite(opacity) || opacity < 0 || opacity > 1) throw new Error('水印透明度无效'); + const { data, info } = await sharp(layer).ensureAlpha().raw().toBuffer({ resolveWithObject: true }); + if (info.channels !== 4) throw new Error('水印通道无效'); + for (let index = 3; index < data.length; index += 4) data[index] = Math.round(data[index]! * opacity); + const maxWidth = Math.max(1, width - 2 * inset); + const maxHeight = Math.max(1, height - 2 * inset); + layer = await sharp(data, { raw: { width: info.width, height: info.height, channels: 4 } }) + .resize({ width: maxWidth, height: maxHeight, fit: 'inside', withoutEnlargement: true }) + .png().toBuffer(); + const layerMeta = await sharp(layer).metadata(); + const legacy = legacyCoordinate(template.position); + const x = template.position_x ?? legacy[0]; + const y = template.position_y ?? legacy[1]; + if (x < 0 || x > 1 || y < 0 || y > 1) throw new Error('水印坐标无效'); + const composite = { + input: layer, + left: placement(width, layerMeta.width!, x, inset), + top: placement(height, layerMeta.height!, y, inset), + }; + /** 大幅面源图可能生成超过素材上限的 JPEG,逐级压缩但不改变画布尺寸。 */ + for (const quality of [92, 82, 72, 62, 50]) { + const bytes = await input.clone().rotate().flatten({ background: '#ffffff' }) + .composite([composite]).jpeg({ quality, chromaSubsampling: '4:4:4' }).toBuffer(); + if (bytes.byteLength <= 20 * 1024 * 1024) return { bytes, width, height }; + } + throw new Error('水印图片超过处理上限'); +} + +/** 同一任务可安全重试,正式素材以工作空间和内容哈希去重。 */ +export async function runWatermarkDerivative(database: Database, storage: S3Client, job: OutboxJob) { + const id = String(job.payload.derivative_id ?? ''); + const task = await database.workspaceTransaction(job.workspaceId, async (tx) => { + const result = await tx.query( + `SELECT d.status, source.storage_provider, source.bucket, source.object_key, + v.kind, v.text_content, v.font_code, v.font_size_px, v.opacity::text, + v.scale_ratio::text, v.image_size_px, v.border_radius_px, + v.position_x::float8, v.position_y::float8, v.position::text, + mark.bucket AS watermark_bucket, mark.object_key AS watermark_object_key + FROM atlas.media_watermark_derivatives d + JOIN atlas.media_assets source ON source.id = d.source_asset_id + JOIN atlas.watermark_template_versions v + ON v.workspace_id = d.workspace_id AND v.template_id = d.watermark_template_id + AND v.version = d.watermark_template_version + LEFT JOIN atlas.media_assets mark ON mark.id = v.image_asset_id + WHERE d.id = $1 FOR UPDATE OF d`, + [id], + ); + const row = result.rows[0]; + if (!row || row.status === 'succeeded' || row.status === 'failed') return null; + await tx.query( + `UPDATE atlas.media_watermark_derivatives + SET status = 'running', started_at = coalesce(started_at, now()) WHERE id = $1`, [id]); + await tx.query( + `UPDATE atlas.operations SET status = 'running', started_at = coalesce(started_at, now()), + attempt_count = attempt_count + 1 WHERE id = $1 AND status IN ('queued', 'running')`, [id]); + return row; + }); + if (!task) return; + const sourceBytes = await readObject(storage, task.bucket, task.object_key, 20 * 1024 * 1024); + const markBytes = task.kind === 'image' && task.watermark_bucket && task.watermark_object_key + ? await readObject(storage, task.watermark_bucket, task.watermark_object_key, 5 * 1024 * 1024) + : undefined; + const output = await renderWatermark(sourceBytes, task, markBytes); + if (output.bytes.byteLength > 20 * 1024 * 1024) throw new Error('水印图片超过处理上限'); + const checksum = createHash('sha256').update(output.bytes).digest('hex'); + const objectKey = `derivatives/${job.workspaceId}/watermark/${checksum}.jpg`; + const existing = await database.workspaceTransaction(job.workspaceId, async (tx) => { + const result = await tx.query<{ id: string }>('SELECT id FROM atlas.media_assets WHERE checksum_sha256 = $1', [checksum]); + return result.rows[0]?.id ?? null; + }); + if (!existing) await storage.send(new PutObjectCommand({ + Bucket: task.bucket, Key: objectKey, Body: output.bytes, ContentType: 'image/jpeg', + })); + await database.workspaceTransaction(job.workspaceId, async (tx) => { + const asset = await tx.query<{ id: string }>( + `INSERT INTO atlas.media_assets + (workspace_id, storage_provider, bucket, object_key, content_type, + byte_size, checksum_sha256, width_px, height_px) + VALUES (current_setting('app.workspace_id')::uuid, $1, $2, $3, 'image/jpeg', $4, $5, $6, $7) + ON CONFLICT (workspace_id, checksum_sha256) + DO UPDATE SET checksum_sha256 = EXCLUDED.checksum_sha256 RETURNING id`, + [task.storage_provider, task.bucket, objectKey, output.bytes.byteLength, checksum, output.width, output.height], + ); + const assetId = asset.rows[0]?.id; + if (!assetId) throw new Error('水印素材登记失败'); + await tx.query( + `UPDATE atlas.media_watermark_derivatives SET status = 'succeeded', output_asset_id = $2, + completed_at = now(), error_code = NULL WHERE id = $1 AND status = 'running'`, [id, assetId]); + await tx.query( + `UPDATE atlas.operations SET status = 'succeeded', completed_at = now(), + result = jsonb_build_object('output_asset_id', $2::uuid), error_code = NULL, error_message = NULL + WHERE id = $1 AND status IN ('queued', 'running')`, [id, assetId]); + }); +} + +/** 重试耗尽后只暴露稳定错误码。 */ +export async function markWatermarkDerivativeFailed(database: Database, job: OutboxJob) { + const id = String(job.payload.derivative_id ?? ''); + await database.workspaceTransaction(job.workspaceId, async (tx) => { + await tx.query( + `UPDATE atlas.media_watermark_derivatives SET status = 'failed', error_code = 'IMAGE_PROCESSING_FAILED', + completed_at = now() WHERE id = $1 AND status IN ('queued', 'running')`, [id]); + await tx.query( + `UPDATE atlas.operations SET status = 'failed', error_code = 'IMAGE_PROCESSING_FAILED', + error_message = '水印处理失败,请重试', completed_at = now() + WHERE id = $1 AND status IN ('queued', 'running')`, [id]); + }); +} diff --git a/apps/worker/src/online-product-mapper.ts b/apps/worker/src/online-product-mapper.ts new file mode 100644 index 0000000..363f42f --- /dev/null +++ b/apps/worker/src/online-product-mapper.ts @@ -0,0 +1,155 @@ +export type OzonProductListItem = { + product_id: number | string; + offer_id: string; +}; +export type OzonProductDetail = Record; + +export type MappedOnlineProduct = { + remote_product_id: string; + offer_id: string; + remote_sku: string | null; + name: string | null; + selling_price: string | null; + list_price: string | null; + visible: boolean | null; + remote_state: string | null; + moderation_status: string | null; + validation_status: string | null; + stock_summary: { total: number; has_data: boolean }; + image_summary: { + url: string | null; + width_px: number | null; + height_px: number | null; + }; + error_payload: Array<{ code?: string; message: string }>; + visibility_details: Record; + raw_payload: OzonProductDetail; +}; + +export function parseOzonListPage(value: unknown): { + items: OzonProductListItem[]; + lastId: string; +} { + const result = record(record(value)?.result); + if (!result || !Array.isArray(result.items)) + throw new Error('Ozon 商品列表响应格式异常'); + const items = result.items.map((raw) => { + const item = record(raw); + const productId = identifier(item?.product_id); + const offerId = text(item?.offer_id); + if ( + !productId || + !/^\d+$/.test(productId) || + !Number.isSafeInteger(Number(productId)) || + !offerId + ) + throw new Error('Ozon 商品列表缺少商品标识'); + return { product_id: productId, offer_id: offerId }; + }); + return { items, lastId: text(result.last_id) ?? '' }; +} + +export function parseOzonInfoItems(value: unknown): OzonProductDetail[] { + const result = record(record(value)?.result); + if (!result || !Array.isArray(result.items)) + throw new Error('Ozon 商品详情响应格式异常'); + return result.items.filter((item): item is OzonProductDetail => + Boolean(record(item)), + ); +} + +/** 只把列表需要的状态和脱敏错误摘要放入可读字段;完整平台响应仅留在受控快照。 */ +export function mapOzonProduct( + detail: OzonProductDetail, + listed: OzonProductListItem, +): MappedOnlineProduct { + const status = record(detail.status); + const stock = record(detail.stocks); + const present = finiteNumber(stock?.present); + const sku = Array.isArray(detail.sources) + ? (detail.sources + .map((source) => identifier(record(source)?.sku)) + .find(Boolean) ?? null) + : null; + const images = Array.isArray(detail.images) ? detail.images : []; + const firstImage = + url(detail.primary_image) ?? + images + .map((item) => url(item) ?? url(record(item)?.file_name)) + .find(Boolean) ?? + null; + const rawErrors = Array.isArray(detail.errors) ? detail.errors : []; + const declineReasons = Array.isArray(status?.decline_reasons) + ? status.decline_reasons + : []; + const errors = [...rawErrors, ...declineReasons] + .slice(0, 10) + .map((item) => { + const source = record(item); + return { + ...(text(source?.code) ? { code: text(source?.code)! } : {}), + message: ( + text(source?.message) ?? + text(source?.name) ?? + text(item) ?? + '平台未提供具体原因' + ).slice(0, 300), + }; + }); + return { + remote_product_id: + identifier(detail.id) ?? identifier(listed.product_id)!, + offer_id: text(detail.offer_id) ?? listed.offer_id, + remote_sku: sku, + name: text(detail.name), + selling_price: amount(detail.price), + list_price: amount(detail.old_price), + visible: typeof detail.visible === 'boolean' ? detail.visible : null, + remote_state: text(status?.state) ?? text(detail.state), + moderation_status: text(status?.moderate_status), + validation_status: text(status?.validation_state), + stock_summary: { total: present ?? 0, has_data: present !== null }, + image_summary: { url: firstImage, width_px: null, height_px: null }, + error_payload: errors, + visibility_details: record(detail.visibility_details) ?? {}, + raw_payload: detail, + }; +} + +function record(value: unknown): Record | null { + return value && typeof value === 'object' && !Array.isArray(value) + ? (value as Record) + : null; +} +function text(value: unknown): string | null { + if (typeof value !== 'string') return null; + return value.trim() || null; +} +function identifier(value: unknown): string | null { + if (typeof value === 'number' && Number.isSafeInteger(value) && value >= 0) + return String(value); + return text(value); +} +function finiteNumber(value: unknown): number | null { + const parsed = + typeof value === 'number' + ? value + : typeof value === 'string' && value.trim() + ? Number(value) + : NaN; + return Number.isFinite(parsed) && parsed >= 0 ? parsed : null; +} +function amount(value: unknown): string | null { + const raw = typeof value === 'number' ? String(value) : text(value); + return raw && /^\d{1,14}(?:\.\d{1,4})?$/.test(raw) ? raw : null; +} +function url(value: unknown): string | null { + const candidate = text(value); + if (!candidate) return null; + try { + const parsed = new URL(candidate); + return parsed.protocol === 'https:' ? parsed.toString() : null; + } catch { + return null; + } +} diff --git a/apps/worker/src/online-product-sync.ts b/apps/worker/src/online-product-sync.ts new file mode 100644 index 0000000..89aad0d --- /dev/null +++ b/apps/worker/src/online-product-sync.ts @@ -0,0 +1,346 @@ +import type { Database, DatabaseTransaction } from '@atlas/database'; +import type { OutboxJob } from './outbox-dispatcher.js'; +import { decryptStoreSecret } from './store-secret.crypto.js'; +import { workerConfig } from './config.js'; +import { + OzonCredentialError, + OzonRequestError, + requestOzon, +} from './ozon-client.js'; +import { + mapOzonProduct, + parseOzonInfoItems, + parseOzonListPage, + type MappedOnlineProduct, + type OzonProductListItem, +} from './online-product-mapper.js'; + +export type OnlineProductSyncJob = OutboxJob & { + payload: { + sync_run_id: string; + store_id: string; + platform_id: string; + secret_id: string; + }; +}; + +type Source = { + platform_code: string; + platform_client_id: string; + secret_ciphertext: Buffer; + secret_nonce: Buffer; +}; + +export async function runOnlineProductSync( + database: Database, + job: OnlineProductSyncJob, +): Promise { + const source = await database.workspaceTransaction( + job.workspaceId, + async (tx) => { + const run = await tx.query<{ status: string }>( + `SELECT status FROM atlas.platform_product_sync_runs WHERE id = $1 AND store_id = $2 AND platform_id = $3 FOR UPDATE`, + [ + job.payload.sync_run_id, + job.payload.store_id, + job.payload.platform_id, + ], + ); + if ( + !run.rows[0] || + run.rows[0].status === 'succeeded' || + run.rows[0].status === 'failed' || + run.rows[0].status === 'cancelled' + ) + return null; + const connection = await tx.query( + `SELECT platform.code AS platform_code, store.platform_client_id, + secret.secret_ciphertext, secret.secret_nonce + FROM atlas.stores store + JOIN atlas.platforms platform ON platform.id = store.platform_id + JOIN atlas.store_secrets secret ON secret.id = $3 AND secret.store_id = store.id + WHERE store.id = $1 AND store.platform_id = $2 AND store.deleted_at IS NULL + AND store.credential_status = 'valid' AND secret.revoked_at IS NULL + AND secret.kind = 'seller_api_key'`, + [ + job.payload.store_id, + job.payload.platform_id, + job.payload.secret_id, + ], + ); + if (!connection.rows[0]) throw new Error('店铺凭据已变更或不可用'); + // 同一任务重试时清除上次尝试的已见快照,避免把本次未返回的商品误判为仍在售。 + await tx.query( + `DELETE FROM atlas.platform_listing_sync_snapshots WHERE sync_run_id = $1`, + [job.payload.sync_run_id], + ); + await tx.query( + `UPDATE atlas.platform_product_sync_runs SET status = 'running', started_at = COALESCE(started_at, now()), + completed_at = NULL, error_payload = NULL, listed_count = 0, upserted_count = 0, + failed_count = 0, cursor_started = NULL, cursor_finished = NULL WHERE id = $1`, + [job.payload.sync_run_id], + ); + return connection.rows[0]; + }, + ); + if (!source) return; + if (source.platform_code.toUpperCase() !== 'OZON') + throw new Error('当前平台不支持商品同步'); + const apiKey = decryptStoreSecret( + source.secret_ciphertext, + source.secret_nonce, + workerConfig.storeSecretEncryptionKey, + ); + let cursor = ''; + let listedCount = 0; + let upsertedCount = 0; + const seenCursors = new Set(); + for (;;) { + const page = parseOzonListPage( + await requestOzon( + source.platform_client_id, + apiKey, + '/v3/product/list', + { + filter: { visibility: 'ALL' }, + last_id: cursor, + limit: 1000, + }, + ), + ); + listedCount += page.items.length; + if (listedCount > 1_000_000) + throw new Error('商品数量超过单次同步安全上限'); + for (let start = 0; start < page.items.length; start += 100) { + const batch = page.items.slice(start, start + 100); + const body = await requestOzon( + source.platform_client_id, + apiKey, + '/v3/product/info/list', + { + product_id: batch.map((item) => Number(item.product_id)), + }, + ); + const details = parseOzonInfoItems(body); + const detailById = new Map( + details.map((detail) => [String(detail.id ?? ''), detail]), + ); + const mapped = batch.map((item) => { + const detail = detailById.get(String(item.product_id)); + if (!detail) throw new Error('Ozon 商品详情缺少列表中的商品'); + return mapOzonProduct(detail, item); + }); + await database.workspaceTransaction(job.workspaceId, async (tx) => { + await upsertBatch(tx, job, mapped); + upsertedCount += mapped.length; + await tx.query( + `UPDATE atlas.platform_product_sync_runs SET listed_count = $2, upserted_count = $3, cursor_finished = $4 + WHERE id = $1 AND status = 'running'`, + [ + job.payload.sync_run_id, + listedCount, + upsertedCount, + page.lastId, + ], + ); + }); + } + if (!page.lastId) break; + if (page.lastId === cursor || seenCursors.has(page.lastId)) + throw new Error('Ozon 商品列表游标重复'); + seenCursors.add(page.lastId); + cursor = page.lastId; + await database.workspaceTransaction(job.workspaceId, (tx) => + tx.query( + `UPDATE atlas.platform_product_sync_runs SET cursor_finished = $2, listed_count = $3 WHERE id = $1 AND status = 'running'`, + [job.payload.sync_run_id, cursor, listedCount], + ), + ); + } + await database.workspaceTransaction(job.workspaceId, async (tx) => { + const run = await tx.query<{ status: string }>( + `SELECT status FROM atlas.platform_product_sync_runs WHERE id = $1 FOR UPDATE`, + [job.payload.sync_run_id], + ); + if (run.rows[0]?.status !== 'running') return; + // 只有完整成功的全量同步可以改变未见与归档状态;失败批次保留这些标记。 + await tx.query( + `UPDATE atlas.platform_listings listing SET missing_since = NULL, archived_at = NULL, updated_at = now() + WHERE listing.store_id = $1 AND listing.platform_id = $2 + AND (listing.missing_since IS NOT NULL OR listing.archived_at IS NOT NULL) + AND EXISTS ( + SELECT 1 FROM atlas.platform_listing_sync_snapshots snapshot + WHERE snapshot.sync_run_id = $3 AND snapshot.platform_listing_id = listing.id + )`, + [job.payload.store_id, job.payload.platform_id, job.payload.sync_run_id], + ); + await tx.query( + `UPDATE atlas.platform_listings listing SET + archived_at = CASE WHEN missing_since IS NOT NULL THEN COALESCE(archived_at, now()) ELSE archived_at END, + missing_since = COALESCE(missing_since, now()), updated_at = now() + WHERE listing.store_id = $1 AND listing.platform_id = $2 AND listing.archived_at IS NULL + AND NOT EXISTS ( + SELECT 1 FROM atlas.platform_listing_sync_snapshots snapshot + WHERE snapshot.sync_run_id = $3 AND snapshot.platform_listing_id = listing.id + )`, + [ + job.payload.store_id, + job.payload.platform_id, + job.payload.sync_run_id, + ], + ); + await tx.query( + `UPDATE atlas.platform_product_sync_runs SET status = 'succeeded', listed_count = $2, + upserted_count = $3, cursor_finished = $4, completed_at = now(), error_payload = NULL + WHERE id = $1 AND status = 'running'`, + [job.payload.sync_run_id, listedCount, upsertedCount, cursor], + ); + }); +} + +async function upsertBatch( + tx: DatabaseTransaction, + job: OnlineProductSyncJob, + items: MappedOnlineProduct[], +) { + if (!items.length) return; + if (new Set(items.map((item) => item.offer_id)).size !== items.length) + throw new Error('商品批次存在重复的商家编码'); + // 平台商品 ID 变化时,沿用同店铺的商家编码所对应的镜像记录和快照历史。 + await tx.query( + `UPDATE atlas.platform_listings listing SET remote_product_id = incoming.remote_product_id + FROM jsonb_to_recordset($3::jsonb) AS incoming(remote_product_id text, offer_id text) + WHERE listing.store_id = $1 AND listing.platform_id = $2 + AND listing.offer_id = incoming.offer_id + AND listing.remote_product_id IS DISTINCT FROM incoming.remote_product_id`, + [ + job.payload.store_id, + job.payload.platform_id, + JSON.stringify(items.map(({ remote_product_id, offer_id }) => ({ remote_product_id, offer_id }))), + ], + ); + const result = await tx.query<{ id: string; remote_product_id: string }>( + `WITH incoming AS ( + SELECT * FROM jsonb_to_recordset($4::jsonb) AS p( + remote_product_id text, offer_id text, remote_sku text, name text, + selling_price text, list_price text, visible boolean, remote_state text, + moderation_status text, validation_status text, stock_summary jsonb, + image_summary jsonb, error_payload jsonb, visibility_details jsonb, raw_payload jsonb) + ), prepared AS ( + SELECT incoming.*, origin.id AS atlas_variant_listing_id + FROM incoming LEFT JOIN LATERAL ( + SELECT id FROM atlas.product_variant_platform_listings + WHERE store_id = $2 AND platform_id = $3 AND status = 'imported' + AND (offer_id = incoming.offer_id OR platform_product_id = incoming.remote_product_id) + ORDER BY (offer_id = incoming.offer_id) DESC LIMIT 1 + ) origin ON true + ) + INSERT INTO atlas.platform_listings ( + workspace_id, store_id, platform_id, remote_product_id, offer_id, remote_sku, + name, currency_code, selling_price, list_price, visible, remote_state, + moderation_status, validation_status, stock_summary, image_summary, error_payload, + visibility_details, source, atlas_variant_listing_id, last_sync_run_id, + last_synced_at, raw_payload) + SELECT current_setting('app.workspace_id')::uuid, $2, $3, remote_product_id, offer_id, + remote_sku, name, 'RUB', selling_price::numeric, list_price::numeric, visible, + remote_state, moderation_status, validation_status, stock_summary, image_summary, + error_payload, visibility_details, + CASE WHEN atlas_variant_listing_id IS NULL THEN 'external_sync'::atlas.platform_listing_source + ELSE 'atlas_published'::atlas.platform_listing_source END, + atlas_variant_listing_id, $1, now(), raw_payload + FROM prepared WHERE true + ON CONFLICT (workspace_id, store_id, platform_id, remote_product_id) DO UPDATE SET + offer_id = EXCLUDED.offer_id, remote_sku = EXCLUDED.remote_sku, name = EXCLUDED.name, + currency_code = EXCLUDED.currency_code, selling_price = EXCLUDED.selling_price, + list_price = EXCLUDED.list_price, visible = EXCLUDED.visible, remote_state = EXCLUDED.remote_state, + moderation_status = EXCLUDED.moderation_status, validation_status = EXCLUDED.validation_status, + stock_summary = EXCLUDED.stock_summary, image_summary = EXCLUDED.image_summary, + error_payload = EXCLUDED.error_payload, visibility_details = EXCLUDED.visibility_details, + source = EXCLUDED.source, atlas_variant_listing_id = EXCLUDED.atlas_variant_listing_id, + last_sync_run_id = EXCLUDED.last_sync_run_id, last_synced_at = EXCLUDED.last_synced_at, + raw_payload = EXCLUDED.raw_payload, updated_at = now() + RETURNING id, remote_product_id`, + [ + job.payload.sync_run_id, + job.payload.store_id, + job.payload.platform_id, + JSON.stringify(items), + ], + ); + const idByRemoteId = new Map( + result.rows.map((row) => [row.remote_product_id, row.id]), + ); + await tx.query( + `INSERT INTO atlas.platform_listing_sync_snapshots (workspace_id, platform_listing_id, sync_run_id, payload) + SELECT current_setting('app.workspace_id')::uuid, listing.id, $1, snapshot.payload + FROM jsonb_to_recordset($2::jsonb) AS snapshot(remote_product_id text, payload jsonb) + JOIN atlas.platform_listings listing ON listing.store_id = $3 AND listing.remote_product_id = snapshot.remote_product_id + ON CONFLICT (workspace_id, platform_listing_id, sync_run_id) DO UPDATE SET payload = EXCLUDED.payload`, + [ + job.payload.sync_run_id, + JSON.stringify( + items.map((item) => ({ + remote_product_id: item.remote_product_id, + payload: item.raw_payload, + })), + ), + job.payload.store_id, + ], + ); + if (idByRemoteId.size !== items.length) + throw new Error('商品批次存在重复的平台 ID'); +} + +export async function markOnlineProductSyncFailed( + database: Database, + job: OnlineProductSyncJob, + error: unknown, +) { + const code = + error instanceof OzonCredentialError + ? 'STORE_CREDENTIAL_UNAVAILABLE' + : error instanceof OzonRequestError + ? 'OZON_REQUEST_REJECTED' + : 'OZON_SYNC_UNAVAILABLE'; + const message = + code === 'STORE_CREDENTIAL_UNAVAILABLE' + ? '店铺凭据不可用于读取 Ozon 商品' + : code === 'OZON_REQUEST_REJECTED' + ? 'Ozon 拒绝了商品读取请求,请检查店铺权限' + : '商品同步失败,请稍后重试'; + await database.workspaceTransaction(job.workspaceId, (tx) => + tx.query( + `UPDATE atlas.platform_product_sync_runs SET status = 'failed', failed_count = GREATEST(failed_count, 1), + completed_at = now(), error_payload = $2 WHERE id = $1 AND status IN ('queued', 'running')`, + [job.payload.sync_run_id, { code, message }], + ), + ); +} + +/** 首次未见超过七天即归档;活跃全量同步期间等待其结果,避免临时隐藏正在恢复的商品。 */ +export async function archiveExpiredMissingOnlineProducts( + database: Database, + workspaceId?: string, +): Promise { + const workspaces = workspaceId + ? [{ id: workspaceId }] + : ( + await database.query<{ id: string }>( + 'SELECT id FROM atlas.workspaces', + ) + ).rows; + for (const workspace of workspaces) { + await database.workspaceTransaction(workspace.id, (tx) => + tx.query( + `UPDATE atlas.platform_listings listing + SET archived_at = now(), updated_at = now() + WHERE listing.missing_since <= now() - interval '7 days' + AND listing.archived_at IS NULL + AND NOT EXISTS ( + SELECT 1 FROM atlas.platform_product_sync_runs run + WHERE run.store_id = listing.store_id AND run.status IN ('queued', 'running') + )`, + ), + ); + } +} diff --git a/apps/worker/src/ozon-client.ts b/apps/worker/src/ozon-client.ts index 7d0156d..4537bfd 100644 --- a/apps/worker/src/ozon-client.ts +++ b/apps/worker/src/ozon-client.ts @@ -168,7 +168,7 @@ export async function fetchOzonDictionaryBatch( return { values, isComplete: false }; } -async function requestOzon( +export async function requestOzon( clientId: string, apiKey: string, path: string, diff --git a/apps/worker/test/media-ai-edit.test.mjs b/apps/worker/test/media-ai-edit.test.mjs new file mode 100644 index 0000000..c794d11 --- /dev/null +++ b/apps/worker/test/media-ai-edit.test.mjs @@ -0,0 +1,350 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import sharp from 'sharp'; +import { GetObjectCommand, PutObjectCommand } from '@aws-sdk/client-s3'; +import { createMediaAiEditRequestSchema } from '@atlas/contracts'; +import { classifyAiEditFailure, normalizeAiEditOutput, pinnedLookup, providerImageResult, runMediaAiEdit } from '../src/media-ai-edit.ts'; +import { buildMediaAiPrompt } from '../src/media-ai-prompt.ts'; + +const productId = '733d0d73-3d39-4a98-ac0a-5c0e521d8998'; + +test('仅扩图在供应商改写中心后仍把原图区域覆盖回结果', async () => { + const source = await sharp({ create: { width: 600, height: 600, channels: 3, background: '#e1252a' } }).png().toBuffer(); + const generated = await sharp({ create: { width: 1152, height: 1536, channels: 3, background: '#2055d8' } }).png().toBuffer(); + const settings = createMediaAiEditRequestSchema.parse({ product_id: productId, mode: 'outpaint' }); + const output = await normalizeAiEditOutput(generated, settings, source); + const { data, info } = await sharp(output).raw().toBuffer({ resolveWithObject: true }); + assert.equal(info.width, 1152); + assert.equal(info.height, 1536); + const pixel = (x, y) => [...data.subarray((y * info.width + x) * 3, (y * info.width + x) * 3 + 3)]; + assert.ok(pixel(576, 768)[0] > 200, '原图中心仍应为红色'); + assert.ok(pixel(576, 20)[2] > 170, '扩充边缘仍应为蓝色'); +}); + +test('提示词明确区分主图、辅助图和尺寸数值', () => { + const settings = createMediaAiEditRequestSchema.parse({ + product_id: productId, + reference_asset_ids: ['50ea00b7-6675-4cc7-b7a2-d45f05c07100'], + dimensions: { length: 12, unit: 'cm' }, generate_size_chart: true, + }); + const prompt = buildMediaAiPrompt(settings); + assert.match(prompt, /Image 1 is the fixed main product image/); + assert.match(prompt, /Images 2 through 2 are auxiliary references only/); + assert.match(prompt, /length: 12 cm/); + assert.match(prompt, /Do not infer other measurements/); +}); + +test('Pcore 完成结果的空 b64_json 回退到可下载 URL', () => { + const url = 'https://pcore.ai/v1/images/content/task-example/0'; + assert.equal(providerImageResult({ data: [{ b64_json: '', url }] }), url); + assert.equal(providerImageResult({ data: [{ b64_json: ' ', url }] }), url); + assert.deepEqual(providerImageResult({ data: [{ b64_json: '', b64_image: Buffer.from('image').toString('base64'), url }] }), + Buffer.from('image')); + assert.deepEqual(providerImageResult({ data: [{ b64_json: Buffer.from('image').toString('base64'), url }] }), + Buffer.from('image')); +}); + +test('固定 DNS 回调按 Node 的 all 选项返回地址列表或单个地址', () => { + const lookup = pinnedLookup('8.8.8.8'); + lookup('pcore.ai', { all: true }, (error, addresses) => { + assert.equal(error, null); + assert.deepEqual(addresses, [{ address: '8.8.8.8', family: 4 }]); + }); + lookup('pcore.ai', { all: false }, (error, address, family) => { + assert.equal(error, null); + assert.equal(address, '8.8.8.8'); + assert.equal(family, 4); + }); +}); + +test('异步供应商任务分两次 Outbox 推进,并把结果登记为独立素材', async () => { + const sourceId = '664d0d73-3d39-4a98-ac0a-5c0e521d8998'; + const referenceId = '6ebfe347-bc77-4270-8213-b2021271c6b2'; + const outputId = '113d0d73-3d39-4a98-ac0a-5c0e521d8998'; + const editId = '68db226f-3fcb-4579-ab9e-4932b1d84067'; + const source = await sharp({ create: { width: 600, height: 800, channels: 3, background: '#ad3222' } }).jpeg().toBuffer(); + const reference = await sharp({ create: { width: 400, height: 400, channels: 4, background: '#2452af80' } }).png().toBuffer(); + const generated = await sharp({ create: { width: 1152, height: 1536, channels: 3, background: '#2f72b7' } }).png().toBuffer(); + const settings = createMediaAiEditRequestSchema.parse({ product_id: productId, + reference_asset_ids: [referenceId], prompt: '保留商品,使用明亮背景' }); + const task = { status: 'queued', source_asset_id: sourceId, reference_asset_ids: [referenceId], settings, + provider_task_id: null, created_at: new Date() }; + const outbox = []; + const stored = []; + const database = { workspaceTransaction: async (_workspaceId, work) => work({ + query: async (sql, params = []) => { + if (sql.includes('SELECT status, source_asset_id, reference_asset_ids')) return { rows: [{ ...task }] }; + if (sql.includes('SELECT id, storage_provider, bucket, object_key')) return { rows: [ + { id: sourceId, storage_provider: 'local_minio', bucket: 'atlas-assets', object_key: 'main.jpg' }, + { id: referenceId, storage_provider: 'local_minio', bucket: 'atlas-assets', object_key: 'reference.jpg' }, + ] }; + if (sql.includes('SELECT status FROM atlas.media_ai_edits')) return { rows: [{ status: task.status }] }; + if (sql.includes('SELECT id FROM atlas.media_assets WHERE checksum_sha256')) return { rows: [] }; + if (sql.includes('INSERT INTO atlas.media_assets')) return { rows: [{ id: outputId }] }; + if (sql.includes('INSERT INTO atlas.outbox_events')) outbox.push(sql); + if (sql.includes("SET status = 'running'")) task.status = 'running'; + if (sql.includes('SET provider_task_id =')) task.provider_task_id = params[1]; + if (sql.includes("SET status = 'succeeded'")) task.status = 'succeeded'; + return { rows: [] }; + }, + }) }; + const storage = { send: async (command) => { + if (command instanceof GetObjectCommand) return { Body: (async function* () { + yield command.input.Key === 'main.jpg' ? source : reference; + })() }; + if (command instanceof PutObjectCommand) { stored.push(command.input); return {}; } + throw new Error('unexpected object storage command'); + } }; + const oldFetch = globalThis.fetch; + const oldBase = process.env.PCORE_BASE_URL; + const oldKey = process.env.PCORE_API_KEY; + process.env.PCORE_BASE_URL = 'https://pcore.ai/v1'; + process.env.PCORE_API_KEY = 'test-key'; + let requests = 0; + globalThis.fetch = async (url, init) => { + requests += 1; + if (init.method === 'POST') { + assert.equal(url, 'https://pcore.ai/v1/images/edits'); + assert.equal(init.headers['Content-Type'], 'application/json'); + const body = JSON.parse(init.body); + assert.equal(body.model, 'gpt-image-2'); + assert.equal(body.size, '1152x1536'); + assert.equal(body.n, 1); + assert.equal(body.async, true); + assert.equal(body.images.length, 2); + assert.deepEqual(body.images, [ + { image_url: `data:image/jpeg;base64,${source.toString('base64')}` }, + { image_url: `data:image/png;base64,${reference.toString('base64')}` }, + ]); + assert.equal(body.mask, undefined); + return Response.json({ task_id: 'provider-123' }); + } + assert.equal(url, 'https://pcore.ai/v1/images/edits/provider-123'); + return Response.json({ status: 'completed', data: [{ b64_json: generated.toString('base64') }] }); + }; + try { + const job = { workspaceId: '30cf54b7-06a3-4e5f-a5fc-d74165115094', payload: { edit_id: editId } }; + await runMediaAiEdit(database, storage, job); + assert.equal(task.status, 'running'); + assert.equal(task.provider_task_id, 'provider-123'); + assert.equal(outbox.length, 1); + await runMediaAiEdit(database, storage, job); + assert.equal(task.status, 'succeeded'); + assert.equal(stored.length, 1); + assert.equal(stored[0].ContentType, 'image/jpeg'); + assert.equal(requests, 2); + } finally { + globalThis.fetch = oldFetch; + if (oldBase === undefined) delete process.env.PCORE_BASE_URL; else process.env.PCORE_BASE_URL = oldBase; + if (oldKey === undefined) delete process.env.PCORE_API_KEY; else process.env.PCORE_API_KEY = oldKey; + } +}); + +test('仅扩图以 JSON 图片和蒙版对象提交 Pcore', async () => { + const sourceId = '664d0d73-3d39-4a98-ac0a-5c0e521d8998'; + const editId = '68db226f-3fcb-4579-ab9e-4932b1d84067'; + const source = await sharp({ create: { width: 600, height: 600, channels: 3, background: '#ad3222' } }).jpeg().toBuffer(); + const settings = createMediaAiEditRequestSchema.parse({ product_id: productId, mode: 'outpaint' }); + const database = { workspaceTransaction: async (_workspaceId, work) => work({ + query: async (sql) => { + if (sql.includes('SELECT status, source_asset_id, reference_asset_ids')) return { rows: [{ + status: 'queued', source_asset_id: sourceId, reference_asset_ids: [], settings, + provider_task_id: null, created_at: new Date(), + }] }; + if (sql.includes('SELECT id, storage_provider, bucket, object_key')) return { rows: [{ + id: sourceId, storage_provider: 'local_minio', bucket: 'atlas-assets', object_key: 'main.jpg', + }] }; + return { rows: [] }; + }, + }) }; + const storage = { send: async () => ({ Body: (async function* () { yield source; })() }) }; + const oldFetch = globalThis.fetch; + const oldBase = process.env.PCORE_BASE_URL; + const oldKey = process.env.PCORE_API_KEY; + process.env.PCORE_BASE_URL = 'https://pcore.ai'; + process.env.PCORE_API_KEY = 'test-key'; + globalThis.fetch = async (_url, init) => { + const body = JSON.parse(init.body); + assert.equal(init.headers['Content-Type'], 'application/json'); + assert.equal(body.images.length, 1); + assert.match(body.images[0].image_url, /^data:image\/png;base64,/); + assert.match(body.mask.image_url, /^data:image\/png;base64,/); + const image = Buffer.from(body.images[0].image_url.split(',')[1], 'base64'); + const mask = Buffer.from(body.mask.image_url.split(',')[1], 'base64'); + assert.equal((await sharp(image).metadata()).width, 1152); + assert.equal((await sharp(mask).metadata()).height, 1536); + return Response.json({ task_id: 'provider-outpaint' }); + }; + try { + await runMediaAiEdit(database, storage, { + workspaceId: '30cf54b7-06a3-4e5f-a5fc-d74165115094', payload: { edit_id: editId }, + }); + } finally { + globalThis.fetch = oldFetch; + if (oldBase === undefined) delete process.env.PCORE_BASE_URL; else process.env.PCORE_BASE_URL = oldBase; + if (oldKey === undefined) delete process.env.PCORE_API_KEY; else process.env.PCORE_API_KEY = oldKey; + } +}); + +test('Pcore 终态失败不重复轮询,泛化错误不会误判成内容拒绝', async () => { + const sourceId = '664d0d73-3d39-4a98-ac0a-5c0e521d8998'; + const editId = '68db226f-3fcb-4579-ab9e-4932b1d84067'; + const settings = createMediaAiEditRequestSchema.parse({ product_id: productId }); + let status = 'running'; + let failureCode = null; + let providerErrorRaw = null; + let polls = 0; + let providerError = { + code: 'failed', message: '您的请求无法用于生成图像。该请求可能因安全政策被拦截。', + }; + const database = { workspaceTransaction: async (_workspaceId, work) => work({ + query: async (sql, params = []) => { + if (sql.includes('SELECT status, source_asset_id, reference_asset_ids')) return { rows: [{ + status, source_asset_id: sourceId, reference_asset_ids: [], settings, + provider_task_id: 'provider-123', created_at: new Date(), + }] }; + if (sql.includes('SELECT id, storage_provider, bucket, object_key')) return { rows: [{ + id: sourceId, storage_provider: 'local_minio', bucket: 'atlas-assets', object_key: 'main.jpg', + }] }; + if (sql.includes("SET status = 'failed'")) { + status = 'failed'; + failureCode = params[1]; + if (sql.includes('atlas.operations')) { + providerErrorRaw = params[6]; + assert.equal(params[3], 'pcore'); + assert.equal(params[4], 'provider_polling'); + } + } + return { rows: [] }; + }, + }) }; + const oldFetch = globalThis.fetch; + const oldBase = process.env.PCORE_BASE_URL; + const oldKey = process.env.PCORE_API_KEY; + process.env.PCORE_BASE_URL = 'https://pcore.ai'; + process.env.PCORE_API_KEY = 'test-key'; + globalThis.fetch = async (url) => { + polls += 1; + assert.equal(url, 'https://pcore.ai/v1/images/edits/provider-123'); + return Response.json({ status: 'failed', error: providerError }); + }; + try { + const job = { workspaceId: '30cf54b7-06a3-4e5f-a5fc-d74165115094', payload: { edit_id: editId } }; + await runMediaAiEdit(database, {}, job); + assert.equal(status, 'failed'); + assert.equal(failureCode, 'AI_IMAGE_PROVIDER_FAILED'); + assert.equal(providerErrorRaw, JSON.stringify(providerError)); + assert.equal(polls, 1); + status = 'running'; + providerError = { code: 'content_policy_violation', message: 'The image was blocked.' }; + await runMediaAiEdit(database, {}, job); + assert.equal(failureCode, 'AI_IMAGE_CONTENT_REJECTED'); + assert.equal(providerErrorRaw, JSON.stringify(providerError)); + assert.equal(polls, 2); + } finally { + globalThis.fetch = oldFetch; + if (oldBase === undefined) delete process.env.PCORE_BASE_URL; else process.env.PCORE_BASE_URL = oldBase; + if (oldKey === undefined) delete process.env.PCORE_API_KEY; else process.env.PCORE_API_KEY = oldKey; + } +}); + +test('Pcore 提交时返回错误体会保留原始内容', async () => { + const sourceId = '664d0d73-3d39-4a98-ac0a-5c0e521d8998'; + const editId = '68db226f-3fcb-4579-ab9e-4932b1d84067'; + const source = await sharp({ create: { width: 600, height: 800, channels: 3, background: '#ad3222' } }).jpeg().toBuffer(); + const settings = createMediaAiEditRequestSchema.parse({ product_id: productId }); + const database = { workspaceTransaction: async (_workspaceId, work) => work({ + query: async (sql) => { + if (sql.includes('SELECT status, source_asset_id, reference_asset_ids')) return { rows: [{ + status: 'queued', source_asset_id: sourceId, reference_asset_ids: [], settings, + provider_task_id: null, created_at: new Date(), + }] }; + if (sql.includes('SELECT id, storage_provider, bucket, object_key')) return { rows: [{ + id: sourceId, storage_provider: 'local_minio', bucket: 'atlas-assets', object_key: 'main.jpg', + }] }; + return { rows: [] }; + }, + }) }; + const storage = { send: async () => ({ Body: (async function* () { yield source; })() }) }; + const oldFetch = globalThis.fetch; + const oldBase = process.env.PCORE_BASE_URL; + const oldKey = process.env.PCORE_API_KEY; + process.env.PCORE_BASE_URL = 'https://pcore.ai'; + process.env.PCORE_API_KEY = 'test-key'; + const providerError = { code: 'invalid_request_error', message: '图像文件无效' }; + globalThis.fetch = async () => Response.json({ status: 'failed', error: providerError }); + try { + const job = { workspaceId: '30cf54b7-06a3-4e5f-a5fc-d74165115094', payload: { edit_id: editId } }; + await assert.rejects(runMediaAiEdit(database, storage, job), (error) => { + assert.deepEqual(classifyAiEditFailure(error), { + code: 'AI_IMAGE_GENERATION_FAILED', source: 'pcore', stage: 'provider_submission', + detail: 'Pcore 图片任务请求被拒绝(invalid_request_error 图像文件无效)', + providerErrorRaw: JSON.stringify(providerError), + }); + return true; + }); + } finally { + globalThis.fetch = oldFetch; + if (oldBase === undefined) delete process.env.PCORE_BASE_URL; else process.env.PCORE_BASE_URL = oldBase; + if (oldKey === undefined) delete process.env.PCORE_API_KEY; else process.env.PCORE_API_KEY = oldKey; + } +}); + +test('失败诊断区分 Pcore 查询中断、Atlas 图片处理与保存失败', async () => { + const sourceId = '664d0d73-3d39-4a98-ac0a-5c0e521d8998'; + const settings = createMediaAiEditRequestSchema.parse({ product_id: productId }); + const database = { workspaceTransaction: async (_workspaceId, work) => work({ + query: async (sql) => { + if (sql.includes('SELECT status, source_asset_id, reference_asset_ids')) return { rows: [{ + status: 'running', source_asset_id: sourceId, reference_asset_ids: [], settings, + provider_task_id: 'provider-123', created_at: new Date(), + }] }; + if (sql.includes('SELECT id, storage_provider, bucket, object_key')) return { rows: [{ + id: sourceId, storage_provider: 'local_minio', bucket: 'atlas-assets', object_key: 'main.jpg', + }] }; + return { rows: [] }; + }, + }) }; + const job = { workspaceId: '30cf54b7-06a3-4e5f-a5fc-d74165115094', + payload: { edit_id: '68db226f-3fcb-4579-ab9e-4932b1d84067' } }; + const oldFetch = globalThis.fetch; + const oldBase = process.env.PCORE_BASE_URL; + const oldKey = process.env.PCORE_API_KEY; + process.env.PCORE_BASE_URL = 'https://pcore.ai/v1'; + process.env.PCORE_API_KEY = 'test-key'; + try { + globalThis.fetch = async () => { throw new TypeError('fetch failed'); }; + await assert.rejects(runMediaAiEdit(database, {}, job), (error) => { + assert.deepEqual(classifyAiEditFailure(error), { + code: 'AI_IMAGE_GENERATION_FAILED', source: 'pcore', stage: 'provider_polling', + detail: 'fetch failed', providerErrorRaw: null, + }); + return true; + }); + globalThis.fetch = async () => Response.json({ status: 'completed', + data: [{ b64_json: Buffer.from('invalid image').toString('base64') }] }); + await assert.rejects(runMediaAiEdit(database, {}, job), (error) => { + const failure = classifyAiEditFailure(error); + assert.equal(failure.source, 'atlas'); + assert.equal(failure.stage, 'result_processing'); + assert.match(failure.detail, /image|format|buffer/i); + return true; + }); + const generated = await sharp({ create: { width: 100, height: 100, channels: 3, + background: '#445566' } }).png().toBuffer(); + globalThis.fetch = async () => Response.json({ status: 'completed', + data: [{ b64_json: generated.toString('base64') }] }); + const storage = { send: async () => { throw new Error('private/object-key.jpg'); } }; + await assert.rejects(runMediaAiEdit(database, storage, job), (error) => { + const failure = classifyAiEditFailure(error); + assert.equal(failure.source, 'atlas'); + assert.equal(failure.stage, 'result_storage'); + assert.equal(failure.detail, 'Error'); + return true; + }); + } finally { + globalThis.fetch = oldFetch; + if (oldBase === undefined) delete process.env.PCORE_BASE_URL; else process.env.PCORE_BASE_URL = oldBase; + if (oldKey === undefined) delete process.env.PCORE_API_KEY; else process.env.PCORE_API_KEY = oldKey; + } +}); diff --git a/apps/worker/test/media-aspect-ratio.test.mjs b/apps/worker/test/media-aspect-ratio.test.mjs new file mode 100644 index 0000000..efae34d --- /dev/null +++ b/apps/worker/test/media-aspect-ratio.test.mjs @@ -0,0 +1,186 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import sharp from 'sharp'; +import { GetObjectCommand, PutObjectCommand } from '@aws-sdk/client-s3'; +import { renderThreeFour, runAspectRatioDerivative } from '../src/media-aspect-ratio.ts'; + +const assertColorNear = (actual, expected) => { + for (let channel = 0; channel < 3; channel++) + assert.ok(Math.abs(actual[channel] - expected[channel]) <= 6, `${actual} 与 ${expected} 色差过大`); +}; + +const readPixels = async (output) => { + const { data, info } = await sharp(output).raw().toBuffer({ resolveWithObject: true }); + assert.equal(info.width, 1200); + assert.equal(info.height, 1600); + assert.equal(info.channels, 3); + return (x, y) => [...data.subarray((y * info.width + x) * 3, (y * info.width + x) * 3 + 3)]; +}; + +test('正方形商品图等比缩放后,上下扩充处使用红色边缘色', async () => { + const source = await sharp({ + create: { width: 400, height: 400, channels: 3, background: '#ff0000' }, + }).png().toBuffer(); + const pixel = await readPixels(await renderThreeFour(source)); + assertColorNear(pixel(600, 0), [255, 0, 0]); + assertColorNear(pixel(600, 800), [255, 0, 0]); + assertColorNear(pixel(600, 1599), [255, 0, 0]); +}); + +test('上下边缘颜色不同,各自填充对应的一侧', async () => { + const source = await sharp({ + create: { width: 400, height: 400, channels: 3, background: '#0000ff' }, + }).composite([ + { input: { create: { width: 400, height: 48, channels: 3, background: '#ff0000' } }, left: 0, top: 0 }, + { input: { create: { width: 400, height: 48, channels: 3, background: '#808080' } }, left: 0, top: 352 }, + ]).png().toBuffer(); + const pixel = await readPixels(await renderThreeFour(source)); + assertColorNear(pixel(600, 0), [255, 0, 0]); + assertColorNear(pixel(600, 800), [0, 0, 255]); + assertColorNear(pixel(600, 1599), [128, 128, 128]); +}); + +test('同一边缘有多种颜色时,接缝相接且远处不形成竖向条纹', async () => { + const source = await sharp({ + create: { width: 400, height: 400, channels: 3, background: '#808080' }, + }).composite([ + { input: { create: { width: 200, height: 48, channels: 3, background: '#ff0000' } }, left: 0, top: 0 }, + { input: { create: { width: 200, height: 48, channels: 3, background: '#0000ff' } }, left: 200, top: 0 }, + { input: { create: { width: 200, height: 48, channels: 3, background: '#ffff00' } }, left: 0, top: 352 }, + { input: { create: { width: 200, height: 48, channels: 3, background: '#00ff00' } }, left: 200, top: 352 }, + ]).png().toBuffer(); + const pixel = await readPixels(await renderThreeFour(source)); + for (const [x, topColor, bottomColor] of [ + [300, [255, 0, 0], [255, 255, 0]], + [900, [0, 0, 255], [0, 255, 0]], + ]) { + assertColorNear(pixel(x, 199), topColor); + assertColorNear(pixel(x, 199), pixel(x, 200)); + assertColorNear(pixel(x, 1400), pixel(x, 1399)); + assertColorNear(pixel(x, 1400), bottomColor); + } + assertColorNear(pixel(300, 0), pixel(900, 0)); + assertColorNear(pixel(300, 1599), pixel(900, 1599)); + assertColorNear(pixel(300, 0), [128, 0, 128]); + assertColorNear(pixel(300, 1599), [128, 255, 0]); +}); + +test('竖长图片左右扩充处分别使用左绿右黄的边缘色', async () => { + const source = await sharp({ + create: { width: 300, height: 800, channels: 3, background: '#0000ff' }, + }).composite([ + { input: { create: { width: 36, height: 800, channels: 3, background: '#00ff00' } }, left: 0, top: 0 }, + { input: { create: { width: 36, height: 800, channels: 3, background: '#ffff00' } }, left: 264, top: 0 }, + ]).png().toBuffer(); + const pixel = await readPixels(await renderThreeFour(source)); + assertColorNear(pixel(0, 800), [0, 255, 0]); + assertColorNear(pixel(600, 800), [0, 0, 255]); + assertColorNear(pixel(1199, 800), [255, 255, 0]); +}); + +test('左右边缘沿高度变色时,接缝相接且远处不形成横向条纹', async () => { + const source = await sharp({ + create: { width: 300, height: 800, channels: 3, background: '#808080' }, + }).composite([ + { input: { create: { width: 36, height: 400, channels: 3, background: '#00ff00' } }, left: 0, top: 0 }, + { input: { create: { width: 36, height: 400, channels: 3, background: '#0000ff' } }, left: 0, top: 400 }, + { input: { create: { width: 36, height: 400, channels: 3, background: '#ffff00' } }, left: 264, top: 0 }, + { input: { create: { width: 36, height: 400, channels: 3, background: '#ff00ff' } }, left: 264, top: 400 }, + ]).png().toBuffer(); + const pixel = await readPixels(await renderThreeFour(source)); + for (const [y, leftColor, rightColor] of [ + [400, [0, 255, 0], [255, 255, 0]], + [1200, [0, 0, 255], [255, 0, 255]], + ]) { + assertColorNear(pixel(299, y), leftColor); + assertColorNear(pixel(299, y), pixel(300, y)); + assertColorNear(pixel(900, y), pixel(899, y)); + assertColorNear(pixel(900, y), rightColor); + } + assertColorNear(pixel(0, 400), pixel(0, 1200)); + assertColorNear(pixel(1199, 400), pixel(1199, 1200)); + assertColorNear(pixel(0, 400), [0, 128, 128]); + assertColorNear(pixel(1199, 400), [255, 128, 128]); +}); + +test('透明边缘以白底取色', async () => { + const source = await sharp({ + create: { width: 400, height: 400, channels: 4, background: '#00000000' }, + }).composite([{ input: { + create: { width: 200, height: 200, channels: 3, background: '#ff0000' }, + }, left: 100, top: 100 }]).png().toBuffer(); + const pixel = await readPixels(await renderThreeFour(source)); + assertColorNear(pixel(600, 0), [255, 255, 255]); + assertColorNear(pixel(600, 800), [255, 0, 0]); + assertColorNear(pixel(600, 1599), [255, 255, 255]); +}); + +test('取色和留白遵循 EXIF 旋转后的显示方向', async () => { + const source = await sharp({ + create: { width: 400, height: 200, channels: 3, background: '#0000ff' }, + }).composite([ + { input: { create: { width: 400, height: 24, channels: 3, background: '#ff0000' } }, left: 0, top: 0 }, + { input: { create: { width: 400, height: 24, channels: 3, background: '#808080' } }, left: 0, top: 176 }, + ]).jpeg({ quality: 100 }).withMetadata({ orientation: 6 }).toBuffer(); + const pixel = await readPixels(await renderThreeFour(source)); + assertColorNear(pixel(0, 800), [128, 128, 128]); + assertColorNear(pixel(1199, 800), [255, 0, 0]); +}); + +test('拒绝不受支持的来源格式', async () => { + const source = await sharp({ + create: { width: 8, height: 8, channels: 3, background: '#ff0000' }, + }).gif().toBuffer(); + await assert.rejects(renderThreeFour(source), /格式不受支持/); +}); + +test('任务读取原图、写入独立派生素材,并完成持久化状态', async () => { + const source = await sharp({ + create: { width: 400, height: 400, channels: 3, background: '#ff0000' }, + }).png().toBuffer(); + const writes = []; + const storage = { send: async (command) => { + if (command instanceof GetObjectCommand) return { + Body: (async function* () { yield source; })(), + }; + if (command instanceof PutObjectCommand) { + writes.push(command.input); + return {}; + } + throw new Error('未知存储操作'); + } }; + let status = 'queued'; + let outputAssetId = null; + const queries = []; + const database = { workspaceTransaction: async (_workspaceId, work) => work({ + query: async (sql, args) => { + queries.push({ sql, args }); + if (sql.includes('SELECT d.status')) return { rows: [{ + status, + storage_provider: 'local_minio', + bucket: 'test-bucket', + object_key: 'original.png', + }] }; + if (sql.includes("SET status = 'running'")) status = 'running'; + if (sql.includes("SET status = 'succeeded'")) status = 'succeeded'; + if (sql.includes('SELECT id FROM atlas.media_assets')) return { rows: [] }; + if (sql.includes('INSERT INTO atlas.media_assets')) return { rows: [{ id: 'output-asset' }] }; + if (sql.includes("result = jsonb_build_object")) outputAssetId = args[1]; + return { rows: [] }; + }, + }) }; + const job = { + workspaceId: 'workspace-id', + payload: { derivative_id: 'derivative-id' }, + }; + await runAspectRatioDerivative(database, storage, job); + assert.equal(status, 'succeeded'); + assert.equal(outputAssetId, 'output-asset'); + assert.equal(writes.length, 1); + assert.equal(writes[0].Bucket, 'test-bucket'); + assert.match(writes[0].Key, /^derivatives\/workspace-id\/three-four\/[a-f0-9]{64}\.jpg$/); + assert.deepEqual(await sharp(writes[0].Body).metadata().then(({ width, height }) => [width, height]), [1200, 1600]); + await runAspectRatioDerivative(database, storage, job); + assert.equal(writes.length, 1); + assert.ok(queries.some(({ sql }) => sql.includes('UPDATE atlas.operations'))); +}); diff --git a/apps/worker/test/media-watermark.test.mjs b/apps/worker/test/media-watermark.test.mjs new file mode 100644 index 0000000..e5298c2 --- /dev/null +++ b/apps/worker/test/media-watermark.test.mjs @@ -0,0 +1,62 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import sharp from 'sharp'; +import { renderWatermark } from '../src/media-watermark.ts'; + +const template = { + kind: 'image', opacity: '1', image_size_px: 40, border_radius_px: 0, + position_x: 1, position_y: 1, position: 'bottom_right', +}; + +const pixel = async (buffer, x, y) => { + const { data, info } = await sharp(buffer).raw().toBuffer({ resolveWithObject: true }); + return [...data.subarray((y * info.width + x) * info.channels, + (y * info.width + x) * info.channels + 3)]; +}; + +test('图片水印按坐标贴近右下边,并保持源图尺寸', async () => { + const source = await sharp({ create: { width: 120, height: 100, channels: 3, background: '#ffffff' } }).png().toBuffer(); + const mark = await sharp({ create: { width: 40, height: 40, channels: 4, background: '#ff0000ff' } }).png().toBuffer(); + const output = await renderWatermark(source, template, mark); + assert.equal(output.width, 120); + assert.equal(output.height, 100); + assert.ok((await pixel(output.bytes, 90, 70))[0] > 200); + assert.ok((await pixel(output.bytes, 90, 70))[1] < 60); + assert.ok((await pixel(output.bytes, 15, 15))[1] > 240); +}); + +test('大图中的水印大小和底边距与 240×320 管理预览等比一致', async () => { + const source = await sharp({ create: { width: 1200, height: 1600, channels: 3, background: '#ffffff' } }).png().toBuffer(); + const mark = await sharp({ create: { width: 80, height: 80, channels: 4, background: '#ff0000ff' } }).png().toBuffer(); + const output = await renderWatermark(source, { ...template, image_size_px: 80 }, mark); + assert.ok((await pixel(output.bytes, 800, 1200))[1] < 60); + assert.ok((await pixel(output.bytes, 700, 1200))[1] > 240); + assert.ok((await pixel(output.bytes, 1100, 1500))[1] < 60); + assert.ok((await pixel(output.bytes, 1100, 1585))[1] > 240); +}); + +test('图片水印允许 10px 最小尺寸', async () => { + const source = await sharp({ create: { width: 240, height: 320, channels: 3, background: '#ffffff' } }).png().toBuffer(); + const mark = await sharp({ create: { width: 20, height: 20, channels: 4, background: '#ff0000ff' } }).png().toBuffer(); + const output = await renderWatermark(source, { ...template, image_size_px: 10 }, mark); + assert.ok((await pixel(output.bytes, 227, 307))[1] < 80); + assert.ok((await pixel(output.bytes, 217, 297))[1] > 220); +}); + +test('圆角使图片水印角落保持底图颜色', async () => { + const source = await sharp({ create: { width: 120, height: 100, channels: 3, background: '#ffffff' } }).png().toBuffer(); + const mark = await sharp({ create: { width: 40, height: 40, channels: 4, background: '#ff0000ff' } }).png().toBuffer(); + const output = await renderWatermark(source, { ...template, border_radius_px: 18 }, mark); + assert.ok((await pixel(output.bytes, 76, 56))[1] > 200); + assert.ok((await pixel(output.bytes, 95, 75))[1] < 60); +}); + +test('文字水印支持 XML 特殊字符,不会把内容当成 SVG', async () => { + const source = await sharp({ create: { width: 200, height: 100, channels: 3, background: '#ffffff' } }).png().toBuffer(); + const output = await renderWatermark(source, { + ...template, kind: 'text', text_content: '', font_code: 'NotoSans', font_size_px: 24, + position_x: 0, position_y: 0, + }); + const { data } = await sharp(output.bytes).raw().toBuffer({ resolveWithObject: true }); + assert.ok(data.some((value) => value < 100)); +}); diff --git a/apps/worker/test/online-product-mapper.test.mjs b/apps/worker/test/online-product-mapper.test.mjs new file mode 100644 index 0000000..76962c0 --- /dev/null +++ b/apps/worker/test/online-product-mapper.test.mjs @@ -0,0 +1,63 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { + mapOzonProduct, + parseOzonInfoItems, + parseOzonListPage, +} from '../src/online-product-mapper.ts'; + +test('列表游标与商品标识按平台响应读取,缺失标识时拒绝继续同步', () => { + const page = parseOzonListPage({ + result: { + items: [{ product_id: 123, offer_id: 'offer-1' }], + last_id: 'next-page', + }, + }); + assert.deepEqual(page, { + items: [{ product_id: '123', offer_id: 'offer-1' }], + lastId: 'next-page', + }); + assert.throws( + () => parseOzonListPage({ result: { items: [{ product_id: 123 }] } }), + /缺少商品标识/, + ); + assert.throws(() => parseOzonInfoItems({ result: {} }), /响应格式异常/); +}); + +test('可见性未知、库存零和审核错误保留各自语义', () => { + const product = mapOzonProduct( + { + id: 123, + offer_id: 'offer-1', + name: '商品', + price: '999.00', + old_price: '1110.00', + stocks: { present: 0 }, + status: { + moderate_status: 'declined', + validation_state: 'failed', + decline_reasons: [{ message: '图片不符合要求' }], + }, + images: [{ file_name: 'https://example.com/product.jpg' }], + sources: [{ sku: 456 }], + }, + { product_id: '123', offer_id: 'offer-1' }, + ); + assert.equal(product.visible, null); + assert.deepEqual(product.stock_summary, { total: 0, has_data: true }); + assert.equal(product.moderation_status, 'declined'); + assert.equal(product.remote_sku, '456'); + assert.equal(product.image_summary.url, 'https://example.com/product.jpg'); + assert.equal(product.error_payload[0].message, '图片不符合要求'); + assert.equal(product.selling_price, '999.00'); +}); + +test('不存在库存数据和非 HTTPS 图片不能伪装成有效平台数据', () => { + const product = mapOzonProduct( + { id: 123, price: 'abc', primary_image: 'http://example.com/a.jpg' }, + { product_id: '123', offer_id: 'offer-1' }, + ); + assert.deepEqual(product.stock_summary, { total: 0, has_data: false }); + assert.equal(product.image_summary.url, null); + assert.equal(product.selling_price, null); +}); diff --git a/apps/worker/test/online-product-sync.integration.test.mjs b/apps/worker/test/online-product-sync.integration.test.mjs new file mode 100644 index 0000000..c4a6336 --- /dev/null +++ b/apps/worker/test/online-product-sync.integration.test.mjs @@ -0,0 +1,304 @@ +import assert from 'node:assert/strict'; +import { + createCipheriv, + createHash, + randomBytes, + randomUUID, +} from 'node:crypto'; +import { test } from 'node:test'; +import { createApplicationDatabase } from '@atlas/database'; +import { + archiveExpiredMissingOnlineProducts, + markOnlineProductSyncFailed, + runOnlineProductSync, +} from '../src/online-product-sync.ts'; + +const databaseUrl = process.env.DATABASE_URL; +const encryptionKey = process.env.STORE_SECRET_ENCRYPTION_KEY; + +test( + '全量同步写入镜像,连续未见归档,再出现恢复且租户隔离', + { skip: !databaseUrl || !encryptionKey }, + async () => { + const database = createApplicationDatabase(databaseUrl); + const workspaceId = randomUUID(); + const storeId = randomUUID(); + const secretId = randomUUID(); + const platform = await database.query( + "SELECT id FROM atlas.platforms WHERE code = 'OZON' LIMIT 1", + ); + assert.ok(platform.rows[0]); + const platformId = platform.rows[0].id; + const originalFetch = globalThis.fetch; + let productPresent = true; + let failList = false; + let remoteProductId = 93847265; + let failAfterFirstPage = false; + globalThis.fetch = async (url, init) => { + if (String(url).endsWith('/v3/product/list')) { + if (failList) return new Response('{}', { status: 503 }); + const cursor = JSON.parse(init.body).last_id; + if (failAfterFirstPage && cursor) + return new Response('{}', { status: 503 }); + return Response.json({ + result: { + items: productPresent + ? [ + { + product_id: remoteProductId, + offer_id: 'sync-test-offer', + }, + ] + : [], + last_id: failAfterFirstPage ? 'second-page' : '', + }, + }); + } + if (String(url).endsWith('/v3/product/info/list')) + return Response.json({ + result: { + items: [ + { + id: remoteProductId, + offer_id: 'sync-test-offer', + name: '同步测试商品', + price: '99.00', + old_price: '109.00', + visible: true, + stocks: { present: 0 }, + sources: [{ sku: 987654 }], + status: { + moderate_status: 'approved', + validation_state: 'success', + }, + }, + ], + }, + }); + throw new Error('Unexpected Ozon path'); + }; + const key = createHash('sha256').update(encryptionKey).digest(); + const nonce = randomBytes(12); + const cipher = createCipheriv('aes-256-gcm', key, nonce); + const ciphertext = Buffer.concat([ + cipher.update('test-key', 'utf8'), + cipher.final(), + cipher.getAuthTag(), + ]); + try { + await database.query( + 'INSERT INTO atlas.workspaces (id, name, slug) VALUES ($1, $2, $3)', + [workspaceId, '在线商品同步测试', `online-sync-${workspaceId}`], + ); + await database.workspaceTransaction(workspaceId, async (tx) => { + await tx.query( + `INSERT INTO atlas.stores (id, workspace_id, platform_id, name, platform_client_id, currency_code, is_default, status, credential_status) + VALUES ($1, $2, $3, '测试门店', $4, 'RUB', true, 'active', 'valid')`, + [ + storeId, + workspaceId, + platformId, + `online-sync-${storeId}`, + ], + ); + await tx.query( + `INSERT INTO atlas.store_secrets (id, workspace_id, store_id, kind, secret_ciphertext, secret_nonce, encryption_key_version, fingerprint, status) + VALUES ($1, $2, $3, 'seller_api_key', $4, $5, 'v1', $6, 'valid')`, + [ + secretId, + workspaceId, + storeId, + ciphertext, + nonce, + randomUUID(), + ], + ); + }); + const createJob = async () => { + const created = await database.workspaceTransaction( + workspaceId, + (tx) => + tx.query( + `INSERT INTO atlas.platform_product_sync_runs + (workspace_id, store_id, platform_id, trigger_source) VALUES ($1, $2, $3, 'manual') RETURNING id`, + [workspaceId, storeId, platformId], + ), + ); + const runId = created.rows[0].id; + const job = { + eventId: randomUUID(), + workspaceId, + eventType: 'platform_product_sync.requested', + payload: { + sync_run_id: runId, + store_id: storeId, + platform_id: platformId, + secret_id: secretId, + }, + }; + return { runId, job }; + }; + const sync = async () => { + const { runId, job } = await createJob(); + if (failList) { + let failure; + try { + await runOnlineProductSync(database, job); + } catch (error) { + failure = error; + } + assert.ok(failure); + await markOnlineProductSyncFailed(database, job, failure); + await runOnlineProductSync(database, job); + } else await runOnlineProductSync(database, job); + const run = await database.workspaceTransaction( + workspaceId, + (tx) => + tx.query( + 'SELECT status, listed_count, upserted_count FROM atlas.platform_product_sync_runs WHERE id = $1', + [runId], + ), + ); + assert.equal( + run.rows[0].status, + failList ? 'failed' : 'succeeded', + ); + return run.rows[0]; + }; + const first = await sync(); + assert.equal(first.listed_count, 1); + assert.equal(first.upserted_count, 1); + const listing = async () => + database.workspaceTransaction(workspaceId, (tx) => + tx.query( + 'SELECT id, remote_product_id, name, visible, stock_summary, missing_since, archived_at FROM atlas.platform_listings WHERE store_id = $1', + [storeId], + ), + ); + let row = (await listing()).rows[0]; + assert.equal(row.name, '同步测试商品'); + assert.equal(row.visible, true); + assert.deepEqual(row.stock_summary, { total: 0, has_data: true }); + const listingId = row.id; + remoteProductId = 93847266; + await sync(); + row = (await listing()).rows[0]; + assert.equal(row.id, listingId); + assert.equal(row.remote_product_id, String(remoteProductId)); + assert.equal((await listing()).rows.length, 1); + failList = true; + await sync(); + row = (await listing()).rows[0]; + assert.equal(row.missing_since, null); + assert.equal(row.archived_at, null); + failList = false; + productPresent = false; + await sync(); + row = (await listing()).rows[0]; + assert.ok(row.missing_since); + assert.equal(row.archived_at, null); + await database.workspaceTransaction(workspaceId, (tx) => + tx.query( + `UPDATE atlas.platform_listings SET missing_since = now() - interval '8 days' WHERE store_id = $1`, + [storeId], + ), + ); + const activeRun = await database.workspaceTransaction( + workspaceId, + (tx) => + tx.query( + `INSERT INTO atlas.platform_product_sync_runs (workspace_id, store_id, platform_id, trigger_source) + VALUES ($1, $2, $3, 'manual') RETURNING id`, + [workspaceId, storeId, platformId], + ), + ); + await archiveExpiredMissingOnlineProducts(database, workspaceId); + assert.equal((await listing()).rows[0].archived_at, null); + await database.workspaceTransaction(workspaceId, (tx) => + tx.query( + `DELETE FROM atlas.platform_product_sync_runs WHERE id = $1`, + [activeRun.rows[0].id], + ), + ); + await archiveExpiredMissingOnlineProducts(database, workspaceId); + row = (await listing()).rows[0]; + assert.ok(row.archived_at); + productPresent = true; + await sync(); + row = (await listing()).rows[0]; + assert.equal(row.missing_since, null); + assert.equal(row.archived_at, null); + productPresent = false; + await sync(); + row = (await listing()).rows[0]; + assert.ok(row.missing_since); + assert.equal(row.archived_at, null); + await sync(); + row = (await listing()).rows[0]; + assert.ok(row.archived_at); + productPresent = true; + await sync(); + row = (await listing()).rows[0]; + assert.equal(row.missing_since, null); + assert.equal(row.archived_at, null); + productPresent = false; + await sync(); + const missingSinceBeforeRetry = (await listing()).rows[0].missing_since; + assert.ok(missingSinceBeforeRetry); + productPresent = true; + const { job: retryJob } = await createJob(); + failAfterFirstPage = true; + await assert.rejects(runOnlineProductSync(database, retryJob)); + row = (await listing()).rows[0]; + assert.equal(row.missing_since.toISOString(), missingSinceBeforeRetry.toISOString()); + assert.equal(row.archived_at, null); + failAfterFirstPage = false; + productPresent = false; + await runOnlineProductSync(database, retryJob); + row = (await listing()).rows[0]; + assert.ok(row.missing_since); + assert.ok(row.archived_at); + const otherWorkspace = await database.workspaceTransaction( + randomUUID(), + (tx) => + tx.query( + 'SELECT id FROM atlas.platform_listings WHERE store_id = $1', + [storeId], + ), + ); + assert.equal(otherWorkspace.rows.length, 0); + } finally { + globalThis.fetch = originalFetch; + await database + .workspaceTransaction(workspaceId, async (tx) => { + await tx.query( + 'DELETE FROM atlas.platform_listing_sync_snapshots WHERE workspace_id = $1', + [workspaceId], + ); + await tx.query( + 'DELETE FROM atlas.platform_listings WHERE workspace_id = $1', + [workspaceId], + ); + await tx.query( + 'DELETE FROM atlas.platform_product_sync_runs WHERE workspace_id = $1', + [workspaceId], + ); + await tx.query( + 'DELETE FROM atlas.store_secrets WHERE workspace_id = $1', + [workspaceId], + ); + await tx.query( + 'DELETE FROM atlas.stores WHERE workspace_id = $1', + [workspaceId], + ); + }) + .catch(() => {}); + await database + .query('DELETE FROM atlas.workspaces WHERE id = $1', [ + workspaceId, + ]) + .catch(() => {}); + await database.close(); + } + }, +); diff --git a/apps/worker/tsconfig.tsbuildinfo b/apps/worker/tsconfig.tsbuildinfo new file mode 100644 index 0000000..75a3733 --- /dev/null +++ b/apps/worker/tsconfig.tsbuildinfo @@ -0,0 +1 @@ +{"version":"7.0.2","root":["./src/check-ozon-store.ts","./src/collection-assets.ts","./src/config.ts","./src/main.ts","./src/media-ai-edit.ts","./src/media-ai-prompt.ts","./src/media-aspect-ratio.ts","./src/media-upload-cleanup.ts","./src/media-watermark.ts","./src/online-product-mapper.ts","./src/online-product-sync.ts","./src/outbox-dispatcher.ts","./src/ozon-catalog-sync.ts","./src/ozon-client.ts","./src/ozon-product-import.ts","./src/platform-catalog-dispatcher.ts","./src/platform-catalog-mappers.ts","./src/platform-catalog-scheduler.ts","./src/platform-catalog-sync.ts","./src/product-ai.ts","./src/store-category-tree-scheduler.ts","./src/store-secret.crypto.ts"],"packageJsons":["./package.json","../../node_modules/.pnpm/@aws-sdk+checksums@3.1000.29/node_modules/@aws-sdk/checksums/package.json","../../node_modules/.pnpm/@aws-sdk+client-s3@3.1015.0/node_modules/@aws-sdk/client-s3/package.json","../../node_modules/.pnpm/@aws-sdk+core@3.977.9/node_modules/@aws-sdk/core/package.json","../../node_modules/.pnpm/@aws-sdk+middleware-flexible-checksums@3.974.54/node_modules/@aws-sdk/middleware-flexible-checksums/package.json","../../node_modules/.pnpm/@aws-sdk+middleware-host-header@3.972.45/node_modules/@aws-sdk/middleware-host-header/package.json","../../node_modules/.pnpm/@aws-sdk+middleware-sdk-s3@3.972.75/node_modules/@aws-sdk/middleware-sdk-s3/package.json","../../node_modules/.pnpm/@aws-sdk+middleware-user-agent@3.972.74/node_modules/@aws-sdk/middleware-user-agent/package.json","../../node_modules/.pnpm/@aws-sdk+signature-v4-multi-region@3.996.46/node_modules/@aws-sdk/signature-v4-multi-region/package.json","../../node_modules/.pnpm/@aws-sdk+types@3.974.5/node_modules/@aws-sdk/types/package.json","../../node_modules/.pnpm/@img+colour@1.1.0/node_modules/@img/colour/package.json","../../node_modules/.pnpm/@smithy+config-resolver@4.7.2/node_modules/@smithy/config-resolver/package.json","../../node_modules/.pnpm/@smithy+core@3.33.3/node_modules/@smithy/core/package.json","../../node_modules/.pnpm/@smithy+eventstream-serde-config-resolver@4.6.2/node_modules/@smithy/eventstream-serde-config-resolver/package.json","../../node_modules/.pnpm/@smithy+middleware-endpoint@4.7.2/node_modules/@smithy/middleware-endpoint/package.json","../../node_modules/.pnpm/@smithy+middleware-retry@4.8.2/node_modules/@smithy/middleware-retry/package.json","../../node_modules/.pnpm/@smithy+protocol-http@5.6.2/node_modules/@smithy/protocol-http/package.json","../../node_modules/.pnpm/@smithy+signature-v4@5.7.3/node_modules/@smithy/signature-v4/package.json","../../node_modules/.pnpm/@smithy+smithy-client@4.15.2/node_modules/@smithy/smithy-client/package.json","../../node_modules/.pnpm/@smithy+types@4.17.2/node_modules/@smithy/types/package.json","../../node_modules/.pnpm/@smithy+util-waiter@4.6.2/node_modules/@smithy/util-waiter/package.json","../../node_modules/.pnpm/@types+node@26.3.0/node_modules/@types/node/package.json","../../node_modules/.pnpm/@types+pg@8.23.1/node_modules/@types/pg/package.json","../../node_modules/.pnpm/bullmq@6.3.4_ioredis@6.0.0_pg@8.23.0/node_modules/bullmq/package.json","../../node_modules/.pnpm/denque@2.1.0/node_modules/denque/package.json","../../node_modules/.pnpm/ioredis@6.0.0/node_modules/ioredis/package.json","../../node_modules/.pnpm/node-abort-controller@3.1.1/node_modules/node-abort-controller/package.json","../../node_modules/.pnpm/pg-protocol@1.16.0/node_modules/pg-protocol/package.json","../../node_modules/.pnpm/pg-types@2.2.0/node_modules/pg-types/package.json","../../node_modules/.pnpm/pg@8.23.0/node_modules/pg/package.json","../../node_modules/.pnpm/sharp@0.35.4_@types+node@26.3.0/node_modules/sharp/package.json","../../node_modules/.pnpm/string_decoder@1.3.0/node_modules/string_decoder/package.json","../../node_modules/.pnpm/undici-types@8.3.0/node_modules/undici-types/package.json","../../node_modules/.pnpm/zod@4.5.4/node_modules/zod/package.json","../../node_modules/.pnpm/zod@4.5.4/node_modules/zod/v4/classic/package.json","../../node_modules/.pnpm/zod@4.5.4/node_modules/zod/v4/core/package.json","../../node_modules/.pnpm/zod@4.5.4/node_modules/zod/v4/locales/package.json","../../packages/contracts/package.json","../../packages/database/package.json"],"missingPackageJsons":["../../node_modules/.pnpm/@aws-sdk+checksums@3.1000.29/node_modules/@aws-sdk/checksums/dist-types/package.json","../../node_modules/.pnpm/@aws-sdk+checksums@3.1000.29/node_modules/@aws-sdk/checksums/dist-types/submodules/flexible-checksums/package.json","../../node_modules/.pnpm/@aws-sdk+checksums@3.1000.29/node_modules/@aws-sdk/checksums/dist-types/submodules/package.json","../../node_modules/.pnpm/@aws-sdk+checksums@3.1000.29/node_modules/@smithy/core/config/package.json","../../node_modules/.pnpm/@aws-sdk+client-s3@3.1015.0/node_modules/@aws-sdk/client-s3/dist-types/auth/package.json","../../node_modules/.pnpm/@aws-sdk+client-s3@3.1015.0/node_modules/@aws-sdk/client-s3/dist-types/commands/package.json","../../node_modules/.pnpm/@aws-sdk+client-s3@3.1015.0/node_modules/@aws-sdk/client-s3/dist-types/endpoint/package.json","../../node_modules/.pnpm/@aws-sdk+client-s3@3.1015.0/node_modules/@aws-sdk/client-s3/dist-types/models/package.json","../../node_modules/.pnpm/@aws-sdk+client-s3@3.1015.0/node_modules/@aws-sdk/client-s3/dist-types/package.json","../../node_modules/.pnpm/@aws-sdk+client-s3@3.1015.0/node_modules/@aws-sdk/client-s3/dist-types/pagination/package.json","../../node_modules/.pnpm/@aws-sdk+client-s3@3.1015.0/node_modules/@aws-sdk/client-s3/dist-types/schemas/package.json","../../node_modules/.pnpm/@aws-sdk+client-s3@3.1015.0/node_modules/@aws-sdk/client-s3/dist-types/waiters/package.json","../../node_modules/.pnpm/@aws-sdk+client-s3@3.1015.0/node_modules/@smithy/core/schema/package.json","../../node_modules/.pnpm/@aws-sdk+client-s3@3.1015.0/node_modules/stream/package.json","../../node_modules/.pnpm/@aws-sdk+core@3.977.9/node_modules/@aws-sdk/core/dist-types/package.json","../../node_modules/.pnpm/@aws-sdk+core@3.977.9/node_modules/@aws-sdk/core/dist-types/submodules/client/middleware-host-header/package.json","../../node_modules/.pnpm/@aws-sdk+core@3.977.9/node_modules/@aws-sdk/core/dist-types/submodules/client/middleware-logger/package.json","../../node_modules/.pnpm/@aws-sdk+core@3.977.9/node_modules/@aws-sdk/core/dist-types/submodules/client/middleware-recursion-detection/package.json","../../node_modules/.pnpm/@aws-sdk+core@3.977.9/node_modules/@aws-sdk/core/dist-types/submodules/client/middleware-user-agent/package.json","../../node_modules/.pnpm/@aws-sdk+core@3.977.9/node_modules/@aws-sdk/core/dist-types/submodules/client/package.json","../../node_modules/.pnpm/@aws-sdk+core@3.977.9/node_modules/@aws-sdk/core/dist-types/submodules/client/region-config-resolver/package.json","../../node_modules/.pnpm/@aws-sdk+core@3.977.9/node_modules/@aws-sdk/core/dist-types/submodules/client/util-endpoints/lib/aws/package.json","../../node_modules/.pnpm/@aws-sdk+core@3.977.9/node_modules/@aws-sdk/core/dist-types/submodules/client/util-endpoints/lib/package.json","../../node_modules/.pnpm/@aws-sdk+core@3.977.9/node_modules/@aws-sdk/core/dist-types/submodules/client/util-endpoints/package.json","../../node_modules/.pnpm/@aws-sdk+core@3.977.9/node_modules/@aws-sdk/core/dist-types/submodules/client/util-endpoints/types/package.json","../../node_modules/.pnpm/@aws-sdk+core@3.977.9/node_modules/@aws-sdk/core/dist-types/submodules/client/util-user-agent-browser/package.json","../../node_modules/.pnpm/@aws-sdk+core@3.977.9/node_modules/@aws-sdk/core/dist-types/submodules/client/util-user-agent-node/package.json","../../node_modules/.pnpm/@aws-sdk+core@3.977.9/node_modules/@aws-sdk/core/dist-types/submodules/httpAuthSchemes/aws_sdk/package.json","../../node_modules/.pnpm/@aws-sdk+core@3.977.9/node_modules/@aws-sdk/core/dist-types/submodules/httpAuthSchemes/package.json","../../node_modules/.pnpm/@aws-sdk+core@3.977.9/node_modules/@aws-sdk/core/dist-types/submodules/httpAuthSchemes/utils/package.json","../../node_modules/.pnpm/@aws-sdk+core@3.977.9/node_modules/@aws-sdk/core/dist-types/submodules/package.json","../../node_modules/.pnpm/@aws-sdk+core@3.977.9/node_modules/@aws-sdk/core/dist-types/submodules/protocols/cbor/package.json","../../node_modules/.pnpm/@aws-sdk+core@3.977.9/node_modules/@aws-sdk/core/dist-types/submodules/protocols/json/codec-v1/package.json","../../node_modules/.pnpm/@aws-sdk+core@3.977.9/node_modules/@aws-sdk/core/dist-types/submodules/protocols/json/codec-v2/package.json","../../node_modules/.pnpm/@aws-sdk+core@3.977.9/node_modules/@aws-sdk/core/dist-types/submodules/protocols/json/package.json","../../node_modules/.pnpm/@aws-sdk+core@3.977.9/node_modules/@aws-sdk/core/dist-types/submodules/protocols/package.json","../../node_modules/.pnpm/@aws-sdk+core@3.977.9/node_modules/@aws-sdk/core/dist-types/submodules/protocols/query/package.json","../../node_modules/.pnpm/@aws-sdk+core@3.977.9/node_modules/@aws-sdk/core/dist-types/submodules/protocols/xml/package.json","../../node_modules/.pnpm/@aws-sdk+core@3.977.9/node_modules/@aws-sdk/core/dist-types/submodules/util/package.json","../../node_modules/.pnpm/@aws-sdk+core@3.977.9/node_modules/@aws-sdk/core/dist-types/submodules/util/util-arn-parser/package.json","../../node_modules/.pnpm/@aws-sdk+core@3.977.9/node_modules/@aws-sdk/core/dist-types/submodules/util/util-format-url/package.json","../../node_modules/.pnpm/@aws-sdk+core@3.977.9/node_modules/@smithy/core/cbor/package.json","../../node_modules/.pnpm/@aws-sdk+core@3.977.9/node_modules/@smithy/core/config/package.json","../../node_modules/.pnpm/@aws-sdk+core@3.977.9/node_modules/@smithy/core/endpoints/package.json","../../node_modules/.pnpm/@aws-sdk+core@3.977.9/node_modules/@smithy/core/protocols/package.json","../../node_modules/.pnpm/@aws-sdk+core@3.977.9/node_modules/@smithy/core/schema/package.json","../../node_modules/.pnpm/@aws-sdk+middleware-flexible-checksums@3.974.54/node_modules/@aws-sdk/checksums/flexible-checksums/package.json","../../node_modules/.pnpm/@aws-sdk+middleware-flexible-checksums@3.974.54/node_modules/@aws-sdk/middleware-flexible-checksums/dist-types/package.json","../../node_modules/.pnpm/@aws-sdk+middleware-host-header@3.972.45/node_modules/@aws-sdk/core/client/package.json","../../node_modules/.pnpm/@aws-sdk+middleware-host-header@3.972.45/node_modules/@aws-sdk/middleware-host-header/dist-types/package.json","../../node_modules/.pnpm/@aws-sdk+middleware-sdk-s3@3.972.75/node_modules/@aws-sdk/core/protocols/package.json","../../node_modules/.pnpm/@aws-sdk+middleware-sdk-s3@3.972.75/node_modules/@aws-sdk/core/util/package.json","../../node_modules/.pnpm/@aws-sdk+middleware-sdk-s3@3.972.75/node_modules/@aws-sdk/middleware-sdk-s3/dist-types/package.json","../../node_modules/.pnpm/@aws-sdk+middleware-sdk-s3@3.972.75/node_modules/@aws-sdk/middleware-sdk-s3/dist-types/submodules/package.json","../../node_modules/.pnpm/@aws-sdk+middleware-sdk-s3@3.972.75/node_modules/@aws-sdk/middleware-sdk-s3/dist-types/submodules/s3/middleware-bucket-endpoint/package.json","../../node_modules/.pnpm/@aws-sdk+middleware-sdk-s3@3.972.75/node_modules/@aws-sdk/middleware-sdk-s3/dist-types/submodules/s3/middleware-check-content-length-header/package.json","../../node_modules/.pnpm/@aws-sdk+middleware-sdk-s3@3.972.75/node_modules/@aws-sdk/middleware-sdk-s3/dist-types/submodules/s3/middleware-expect-continue/package.json","../../node_modules/.pnpm/@aws-sdk+middleware-sdk-s3@3.972.75/node_modules/@aws-sdk/middleware-sdk-s3/dist-types/submodules/s3/middleware-location-constraint/package.json","../../node_modules/.pnpm/@aws-sdk+middleware-sdk-s3@3.972.75/node_modules/@aws-sdk/middleware-sdk-s3/dist-types/submodules/s3/middleware-region-redirect/package.json","../../node_modules/.pnpm/@aws-sdk+middleware-sdk-s3@3.972.75/node_modules/@aws-sdk/middleware-sdk-s3/dist-types/submodules/s3/middleware-s3-configuration/package.json","../../node_modules/.pnpm/@aws-sdk+middleware-sdk-s3@3.972.75/node_modules/@aws-sdk/middleware-sdk-s3/dist-types/submodules/s3/middleware-s3-expires/package.json","../../node_modules/.pnpm/@aws-sdk+middleware-sdk-s3@3.972.75/node_modules/@aws-sdk/middleware-sdk-s3/dist-types/submodules/s3/middleware-s3-express/classes/package.json","../../node_modules/.pnpm/@aws-sdk+middleware-sdk-s3@3.972.75/node_modules/@aws-sdk/middleware-sdk-s3/dist-types/submodules/s3/middleware-s3-express/functions/package.json","../../node_modules/.pnpm/@aws-sdk+middleware-sdk-s3@3.972.75/node_modules/@aws-sdk/middleware-sdk-s3/dist-types/submodules/s3/middleware-s3-express/interfaces/package.json","../../node_modules/.pnpm/@aws-sdk+middleware-sdk-s3@3.972.75/node_modules/@aws-sdk/middleware-sdk-s3/dist-types/submodules/s3/middleware-s3-express/package.json","../../node_modules/.pnpm/@aws-sdk+middleware-sdk-s3@3.972.75/node_modules/@aws-sdk/middleware-sdk-s3/dist-types/submodules/s3/middleware-ssec/package.json","../../node_modules/.pnpm/@aws-sdk+middleware-sdk-s3@3.972.75/node_modules/@aws-sdk/middleware-sdk-s3/dist-types/submodules/s3/middleware-throw-200-exceptions/package.json","../../node_modules/.pnpm/@aws-sdk+middleware-sdk-s3@3.972.75/node_modules/@aws-sdk/middleware-sdk-s3/dist-types/submodules/s3/middleware-validate-bucket-name/package.json","../../node_modules/.pnpm/@aws-sdk+middleware-sdk-s3@3.972.75/node_modules/@aws-sdk/middleware-sdk-s3/dist-types/submodules/s3/package.json","../../node_modules/.pnpm/@aws-sdk+middleware-sdk-s3@3.972.75/node_modules/@aws-sdk/middleware-sdk-s3/dist-types/submodules/s3/protocol/package.json","../../node_modules/.pnpm/@aws-sdk+middleware-sdk-s3@3.972.75/node_modules/@smithy/core/config/package.json","../../node_modules/.pnpm/@aws-sdk+middleware-sdk-s3@3.972.75/node_modules/@smithy/core/protocols/package.json","../../node_modules/.pnpm/@aws-sdk+middleware-user-agent@3.972.74/node_modules/@aws-sdk/core/client/package.json","../../node_modules/.pnpm/@aws-sdk+middleware-user-agent@3.972.74/node_modules/@aws-sdk/middleware-user-agent/dist-types/package.json","../../node_modules/.pnpm/@aws-sdk+signature-v4-multi-region@3.996.46/node_modules/@aws-sdk/signature-v4-multi-region/dist-types/package.json","../../node_modules/.pnpm/@aws-sdk+types@3.974.5/node_modules/@aws-sdk/types/dist-types/blob/package.json","../../node_modules/.pnpm/@aws-sdk+types@3.974.5/node_modules/@aws-sdk/types/dist-types/extensions/package.json","../../node_modules/.pnpm/@aws-sdk+types@3.974.5/node_modules/@aws-sdk/types/dist-types/identity/package.json","../../node_modules/.pnpm/@aws-sdk+types@3.974.5/node_modules/@aws-sdk/types/dist-types/package.json","../../node_modules/.pnpm/@smithy+config-resolver@4.7.2/node_modules/@smithy/config-resolver/dist-types/package.json","../../node_modules/.pnpm/@smithy+config-resolver@4.7.2/node_modules/@smithy/core/config/package.json","../../node_modules/.pnpm/@smithy+core@3.33.3/node_modules/@smithy/core/dist-types/package.json","../../node_modules/.pnpm/@smithy+core@3.33.3/node_modules/@smithy/core/dist-types/submodules/cbor/codec-v1/package.json","../../node_modules/.pnpm/@smithy+core@3.33.3/node_modules/@smithy/core/dist-types/submodules/cbor/codec-v2/package.json","../../node_modules/.pnpm/@smithy+core@3.33.3/node_modules/@smithy/core/dist-types/submodules/cbor/package.json","../../node_modules/.pnpm/@smithy+core@3.33.3/node_modules/@smithy/core/dist-types/submodules/client/invalid-dependency/package.json","../../node_modules/.pnpm/@smithy+core@3.33.3/node_modules/@smithy/core/dist-types/submodules/client/middleware-stack/package.json","../../node_modules/.pnpm/@smithy+core@3.33.3/node_modules/@smithy/core/dist-types/submodules/client/package.json","../../node_modules/.pnpm/@smithy+core@3.33.3/node_modules/@smithy/core/dist-types/submodules/client/smithy-client/extensions/package.json","../../node_modules/.pnpm/@smithy+core@3.33.3/node_modules/@smithy/core/dist-types/submodules/client/smithy-client/package.json","../../node_modules/.pnpm/@smithy+core@3.33.3/node_modules/@smithy/core/dist-types/submodules/client/util-waiter/package.json","../../node_modules/.pnpm/@smithy+core@3.33.3/node_modules/@smithy/core/dist-types/submodules/config/config-resolver/endpointsConfig/package.json","../../node_modules/.pnpm/@smithy+core@3.33.3/node_modules/@smithy/core/dist-types/submodules/config/config-resolver/package.json","../../node_modules/.pnpm/@smithy+core@3.33.3/node_modules/@smithy/core/dist-types/submodules/config/config-resolver/regionConfig/package.json","../../node_modules/.pnpm/@smithy+core@3.33.3/node_modules/@smithy/core/dist-types/submodules/config/config-resolver/regionInfo/package.json","../../node_modules/.pnpm/@smithy+core@3.33.3/node_modules/@smithy/core/dist-types/submodules/config/defaults-mode/package.json","../../node_modules/.pnpm/@smithy+core@3.33.3/node_modules/@smithy/core/dist-types/submodules/config/node-config-provider/package.json","../../node_modules/.pnpm/@smithy+core@3.33.3/node_modules/@smithy/core/dist-types/submodules/config/package.json","../../node_modules/.pnpm/@smithy+core@3.33.3/node_modules/@smithy/core/dist-types/submodules/config/property-provider/package.json","../../node_modules/.pnpm/@smithy+core@3.33.3/node_modules/@smithy/core/dist-types/submodules/config/shared-ini-file-loader/package.json","../../node_modules/.pnpm/@smithy+core@3.33.3/node_modules/@smithy/core/dist-types/submodules/config/util-config-provider/package.json","../../node_modules/.pnpm/@smithy+core@3.33.3/node_modules/@smithy/core/dist-types/submodules/endpoints/middleware-endpoint/adaptors/package.json","../../node_modules/.pnpm/@smithy+core@3.33.3/node_modules/@smithy/core/dist-types/submodules/endpoints/middleware-endpoint/package.json","../../node_modules/.pnpm/@smithy+core@3.33.3/node_modules/@smithy/core/dist-types/submodules/endpoints/package.json","../../node_modules/.pnpm/@smithy+core@3.33.3/node_modules/@smithy/core/dist-types/submodules/endpoints/util-endpoints/bdd/package.json","../../node_modules/.pnpm/@smithy+core@3.33.3/node_modules/@smithy/core/dist-types/submodules/endpoints/util-endpoints/cache/package.json","../../node_modules/.pnpm/@smithy+core@3.33.3/node_modules/@smithy/core/dist-types/submodules/endpoints/util-endpoints/lib/package.json","../../node_modules/.pnpm/@smithy+core@3.33.3/node_modules/@smithy/core/dist-types/submodules/endpoints/util-endpoints/package.json","../../node_modules/.pnpm/@smithy+core@3.33.3/node_modules/@smithy/core/dist-types/submodules/endpoints/util-endpoints/types/package.json","../../node_modules/.pnpm/@smithy+core@3.33.3/node_modules/@smithy/core/dist-types/submodules/endpoints/util-endpoints/utils/package.json","../../node_modules/.pnpm/@smithy+core@3.33.3/node_modules/@smithy/core/dist-types/submodules/event-streams/eventstream-codec/package.json","../../node_modules/.pnpm/@smithy+core@3.33.3/node_modules/@smithy/core/dist-types/submodules/event-streams/eventstream-serde-config-resolver/package.json","../../node_modules/.pnpm/@smithy+core@3.33.3/node_modules/@smithy/core/dist-types/submodules/event-streams/eventstream-serde-universal/package.json","../../node_modules/.pnpm/@smithy+core@3.33.3/node_modules/@smithy/core/dist-types/submodules/event-streams/eventstream-serde/package.json","../../node_modules/.pnpm/@smithy+core@3.33.3/node_modules/@smithy/core/dist-types/submodules/event-streams/package.json","../../node_modules/.pnpm/@smithy+core@3.33.3/node_modules/@smithy/core/dist-types/submodules/package.json","../../node_modules/.pnpm/@smithy+core@3.33.3/node_modules/@smithy/core/dist-types/submodules/protocols/middleware-content-length/package.json","../../node_modules/.pnpm/@smithy+core@3.33.3/node_modules/@smithy/core/dist-types/submodules/protocols/package.json","../../node_modules/.pnpm/@smithy+core@3.33.3/node_modules/@smithy/core/dist-types/submodules/protocols/protocol-http/extensions/package.json","../../node_modules/.pnpm/@smithy+core@3.33.3/node_modules/@smithy/core/dist-types/submodules/protocols/protocol-http/package.json","../../node_modules/.pnpm/@smithy+core@3.33.3/node_modules/@smithy/core/dist-types/submodules/protocols/querystring-builder/package.json","../../node_modules/.pnpm/@smithy+core@3.33.3/node_modules/@smithy/core/dist-types/submodules/protocols/serde/package.json","../../node_modules/.pnpm/@smithy+core@3.33.3/node_modules/@smithy/core/dist-types/submodules/protocols/util-uri-escape/package.json","../../node_modules/.pnpm/@smithy+core@3.33.3/node_modules/@smithy/core/dist-types/submodules/retry/middleware-retry/package.json","../../node_modules/.pnpm/@smithy+core@3.33.3/node_modules/@smithy/core/dist-types/submodules/retry/middleware-retry/retry-pre-sra-deprecated/package.json","../../node_modules/.pnpm/@smithy+core@3.33.3/node_modules/@smithy/core/dist-types/submodules/retry/package.json","../../node_modules/.pnpm/@smithy+core@3.33.3/node_modules/@smithy/core/dist-types/submodules/retry/service-error-classification/package.json","../../node_modules/.pnpm/@smithy+core@3.33.3/node_modules/@smithy/core/dist-types/submodules/retry/util-retry/package.json","../../node_modules/.pnpm/@smithy+core@3.33.3/node_modules/@smithy/core/dist-types/submodules/schema/middleware/package.json","../../node_modules/.pnpm/@smithy+core@3.33.3/node_modules/@smithy/core/dist-types/submodules/schema/package.json","../../node_modules/.pnpm/@smithy+core@3.33.3/node_modules/@smithy/core/dist-types/submodules/schema/schemas/package.json","../../node_modules/.pnpm/@smithy+core@3.33.3/node_modules/@smithy/core/dist-types/submodules/serde/hash-node/package.json","../../node_modules/.pnpm/@smithy+core@3.33.3/node_modules/@smithy/core/dist-types/submodules/serde/is-array-buffer/package.json","../../node_modules/.pnpm/@smithy+core@3.33.3/node_modules/@smithy/core/dist-types/submodules/serde/middleware-serde/package.json","../../node_modules/.pnpm/@smithy+core@3.33.3/node_modules/@smithy/core/dist-types/submodules/serde/package.json","../../node_modules/.pnpm/@smithy+core@3.33.3/node_modules/@smithy/core/dist-types/submodules/serde/schema-serde-lib/package.json","../../node_modules/.pnpm/@smithy+core@3.33.3/node_modules/@smithy/core/dist-types/submodules/serde/util-base64/package.json","../../node_modules/.pnpm/@smithy+core@3.33.3/node_modules/@smithy/core/dist-types/submodules/serde/util-body-length/package.json","../../node_modules/.pnpm/@smithy+core@3.33.3/node_modules/@smithy/core/dist-types/submodules/serde/util-buffer-from/package.json","../../node_modules/.pnpm/@smithy+core@3.33.3/node_modules/@smithy/core/dist-types/submodules/serde/util-hex-encoding/package.json","../../node_modules/.pnpm/@smithy+core@3.33.3/node_modules/@smithy/core/dist-types/submodules/serde/util-stream/blob/package.json","../../node_modules/.pnpm/@smithy+core@3.33.3/node_modules/@smithy/core/dist-types/submodules/serde/util-stream/checksum/package.json","../../node_modules/.pnpm/@smithy+core@3.33.3/node_modules/@smithy/core/dist-types/submodules/serde/util-stream/package.json","../../node_modules/.pnpm/@smithy+core@3.33.3/node_modules/@smithy/core/dist-types/submodules/serde/util-utf8/package.json","../../node_modules/.pnpm/@smithy+core@3.33.3/node_modules/@smithy/core/dist-types/submodules/serde/value/package.json","../../node_modules/.pnpm/@smithy+core@3.33.3/node_modules/@smithy/core/dist-types/submodules/transport/package.json","../../node_modules/.pnpm/@smithy+eventstream-serde-config-resolver@4.6.2/node_modules/@smithy/core/event-streams/package.json","../../node_modules/.pnpm/@smithy+eventstream-serde-config-resolver@4.6.2/node_modules/@smithy/eventstream-serde-config-resolver/dist-types/package.json","../../node_modules/.pnpm/@smithy+middleware-endpoint@4.7.2/node_modules/@smithy/core/endpoints/package.json","../../node_modules/.pnpm/@smithy+middleware-endpoint@4.7.2/node_modules/@smithy/middleware-endpoint/dist-types/package.json","../../node_modules/.pnpm/@smithy+middleware-retry@4.8.2/node_modules/@smithy/core/retry/package.json","../../node_modules/.pnpm/@smithy+middleware-retry@4.8.2/node_modules/@smithy/middleware-retry/dist-types/package.json","../../node_modules/.pnpm/@smithy+protocol-http@5.6.2/node_modules/@smithy/core/protocols/package.json","../../node_modules/.pnpm/@smithy+protocol-http@5.6.2/node_modules/@smithy/protocol-http/dist-types/package.json","../../node_modules/.pnpm/@smithy+signature-v4@5.7.3/node_modules/@smithy/signature-v4/dist-types/package.json","../../node_modules/.pnpm/@smithy+smithy-client@4.15.2/node_modules/@smithy/core/client/package.json","../../node_modules/.pnpm/@smithy+smithy-client@4.15.2/node_modules/@smithy/core/protocols/package.json","../../node_modules/.pnpm/@smithy+smithy-client@4.15.2/node_modules/@smithy/core/serde/package.json","../../node_modules/.pnpm/@smithy+smithy-client@4.15.2/node_modules/@smithy/smithy-client/dist-types/package.json","../../node_modules/.pnpm/@smithy+types@4.17.2/node_modules/@smithy/types/dist-types/auth/package.json","../../node_modules/.pnpm/@smithy+types@4.17.2/node_modules/@smithy/types/dist-types/blob/package.json","../../node_modules/.pnpm/@smithy+types@4.17.2/node_modules/@smithy/types/dist-types/connection/package.json","../../node_modules/.pnpm/@smithy+types@4.17.2/node_modules/@smithy/types/dist-types/endpoints/package.json","../../node_modules/.pnpm/@smithy+types@4.17.2/node_modules/@smithy/types/dist-types/extensions/package.json","../../node_modules/.pnpm/@smithy+types@4.17.2/node_modules/@smithy/types/dist-types/externals-check/package.json","../../node_modules/.pnpm/@smithy+types@4.17.2/node_modules/@smithy/types/dist-types/http/package.json","../../node_modules/.pnpm/@smithy+types@4.17.2/node_modules/@smithy/types/dist-types/identity/package.json","../../node_modules/.pnpm/@smithy+types@4.17.2/node_modules/@smithy/types/dist-types/package.json","../../node_modules/.pnpm/@smithy+types@4.17.2/node_modules/@smithy/types/dist-types/schema/package.json","../../node_modules/.pnpm/@smithy+types@4.17.2/node_modules/@smithy/types/dist-types/streaming-payload/package.json","../../node_modules/.pnpm/@smithy+types@4.17.2/node_modules/@smithy/types/dist-types/transform/package.json","../../node_modules/.pnpm/@smithy+util-waiter@4.6.2/node_modules/@smithy/core/client/package.json","../../node_modules/.pnpm/@smithy+util-waiter@4.6.2/node_modules/@smithy/util-waiter/dist-types/package.json","../../node_modules/.pnpm/@types+node@26.3.0/node_modules/@types/assert/package.json","../../node_modules/.pnpm/@types+node@26.3.0/node_modules/@types/assert/strict/package.json","../../node_modules/.pnpm/@types+node@26.3.0/node_modules/@types/async_hooks/package.json","../../node_modules/.pnpm/@types+node@26.3.0/node_modules/@types/buffer/package.json","../../node_modules/.pnpm/@types+node@26.3.0/node_modules/@types/child_process/package.json","../../node_modules/.pnpm/@types+node@26.3.0/node_modules/@types/cluster/package.json","../../node_modules/.pnpm/@types+node@26.3.0/node_modules/@types/console/package.json","../../node_modules/.pnpm/@types+node@26.3.0/node_modules/@types/constants/package.json","../../node_modules/.pnpm/@types+node@26.3.0/node_modules/@types/crypto/package.json","../../node_modules/.pnpm/@types+node@26.3.0/node_modules/@types/dgram/package.json","../../node_modules/.pnpm/@types+node@26.3.0/node_modules/@types/diagnostics_channel/package.json","../../node_modules/.pnpm/@types+node@26.3.0/node_modules/@types/dns/package.json","../../node_modules/.pnpm/@types+node@26.3.0/node_modules/@types/dns/promises/package.json","../../node_modules/.pnpm/@types+node@26.3.0/node_modules/@types/domain/package.json","../../node_modules/.pnpm/@types+node@26.3.0/node_modules/@types/events/package.json","../../node_modules/.pnpm/@types+node@26.3.0/node_modules/@types/fs/package.json","../../node_modules/.pnpm/@types+node@26.3.0/node_modules/@types/fs/promises/package.json","../../node_modules/.pnpm/@types+node@26.3.0/node_modules/@types/http/package.json","../../node_modules/.pnpm/@types+node@26.3.0/node_modules/@types/http2/package.json","../../node_modules/.pnpm/@types+node@26.3.0/node_modules/@types/https/package.json","../../node_modules/.pnpm/@types+node@26.3.0/node_modules/@types/inspector/package.json","../../node_modules/.pnpm/@types+node@26.3.0/node_modules/@types/inspector/promises/package.json","../../node_modules/.pnpm/@types+node@26.3.0/node_modules/@types/module/package.json","../../node_modules/.pnpm/@types+node@26.3.0/node_modules/@types/net/package.json","../../node_modules/.pnpm/@types+node@26.3.0/node_modules/@types/node/assert/package.json","../../node_modules/.pnpm/@types+node@26.3.0/node_modules/@types/node/dns/package.json","../../node_modules/.pnpm/@types+node@26.3.0/node_modules/@types/node/fs/package.json","../../node_modules/.pnpm/@types+node@26.3.0/node_modules/@types/node/inspector/package.json","../../node_modules/.pnpm/@types+node@26.3.0/node_modules/@types/node/path/package.json","../../node_modules/.pnpm/@types+node@26.3.0/node_modules/@types/node/readline/package.json","../../node_modules/.pnpm/@types+node@26.3.0/node_modules/@types/node/stream/package.json","../../node_modules/.pnpm/@types+node@26.3.0/node_modules/@types/node/test/package.json","../../node_modules/.pnpm/@types+node@26.3.0/node_modules/@types/node/timers/package.json","../../node_modules/.pnpm/@types+node@26.3.0/node_modules/@types/node/util/package.json","../../node_modules/.pnpm/@types+node@26.3.0/node_modules/@types/node/web-globals/package.json","../../node_modules/.pnpm/@types+node@26.3.0/node_modules/@types/node/zlib/package.json","../../node_modules/.pnpm/@types+node@26.3.0/node_modules/@types/os/package.json","../../node_modules/.pnpm/@types+node@26.3.0/node_modules/@types/path/package.json","../../node_modules/.pnpm/@types+node@26.3.0/node_modules/@types/path/posix/package.json","../../node_modules/.pnpm/@types+node@26.3.0/node_modules/@types/path/win32/package.json","../../node_modules/.pnpm/@types+node@26.3.0/node_modules/@types/perf_hooks/package.json","../../node_modules/.pnpm/@types+node@26.3.0/node_modules/@types/process/package.json","../../node_modules/.pnpm/@types+node@26.3.0/node_modules/@types/punycode/package.json","../../node_modules/.pnpm/@types+node@26.3.0/node_modules/@types/querystring/package.json","../../node_modules/.pnpm/@types+node@26.3.0/node_modules/@types/readline/package.json","../../node_modules/.pnpm/@types+node@26.3.0/node_modules/@types/readline/promises/package.json","../../node_modules/.pnpm/@types+node@26.3.0/node_modules/@types/repl/package.json","../../node_modules/.pnpm/@types+node@26.3.0/node_modules/@types/stream/consumers/package.json","../../node_modules/.pnpm/@types+node@26.3.0/node_modules/@types/stream/iter/package.json","../../node_modules/.pnpm/@types+node@26.3.0/node_modules/@types/stream/package.json","../../node_modules/.pnpm/@types+node@26.3.0/node_modules/@types/stream/promises/package.json","../../node_modules/.pnpm/@types+node@26.3.0/node_modules/@types/stream/web/package.json","../../node_modules/.pnpm/@types+node@26.3.0/node_modules/@types/string_decoder/package.json","../../node_modules/.pnpm/@types+node@26.3.0/node_modules/@types/timers/package.json","../../node_modules/.pnpm/@types+node@26.3.0/node_modules/@types/timers/promises/package.json","../../node_modules/.pnpm/@types+node@26.3.0/node_modules/@types/tls/package.json","../../node_modules/.pnpm/@types+node@26.3.0/node_modules/@types/trace_events/package.json","../../node_modules/.pnpm/@types+node@26.3.0/node_modules/@types/tty/package.json","../../node_modules/.pnpm/@types+node@26.3.0/node_modules/@types/url/package.json","../../node_modules/.pnpm/@types+node@26.3.0/node_modules/@types/util/package.json","../../node_modules/.pnpm/@types+node@26.3.0/node_modules/@types/util/types/package.json","../../node_modules/.pnpm/@types+node@26.3.0/node_modules/@types/v8/package.json","../../node_modules/.pnpm/@types+node@26.3.0/node_modules/@types/vm/package.json","../../node_modules/.pnpm/@types+node@26.3.0/node_modules/@types/wasi/package.json","../../node_modules/.pnpm/@types+node@26.3.0/node_modules/@types/worker_threads/package.json","../../node_modules/.pnpm/@types+node@26.3.0/node_modules/@types/zlib/package.json","../../node_modules/.pnpm/@types+node@26.3.0/node_modules/assert/package.json","../../node_modules/.pnpm/@types+node@26.3.0/node_modules/assert/strict/package.json","../../node_modules/.pnpm/@types+node@26.3.0/node_modules/async_hooks/package.json","../../node_modules/.pnpm/@types+node@26.3.0/node_modules/buffer/package.json","../../node_modules/.pnpm/@types+node@26.3.0/node_modules/child_process/package.json","../../node_modules/.pnpm/@types+node@26.3.0/node_modules/cluster/package.json","../../node_modules/.pnpm/@types+node@26.3.0/node_modules/console/package.json","../../node_modules/.pnpm/@types+node@26.3.0/node_modules/constants/package.json","../../node_modules/.pnpm/@types+node@26.3.0/node_modules/crypto/package.json","../../node_modules/.pnpm/@types+node@26.3.0/node_modules/dgram/package.json","../../node_modules/.pnpm/@types+node@26.3.0/node_modules/diagnostics_channel/package.json","../../node_modules/.pnpm/@types+node@26.3.0/node_modules/dns/package.json","../../node_modules/.pnpm/@types+node@26.3.0/node_modules/dns/promises/package.json","../../node_modules/.pnpm/@types+node@26.3.0/node_modules/domain/package.json","../../node_modules/.pnpm/@types+node@26.3.0/node_modules/events/package.json","../../node_modules/.pnpm/@types+node@26.3.0/node_modules/fs/package.json","../../node_modules/.pnpm/@types+node@26.3.0/node_modules/fs/promises/package.json","../../node_modules/.pnpm/@types+node@26.3.0/node_modules/http/package.json","../../node_modules/.pnpm/@types+node@26.3.0/node_modules/http2/package.json","../../node_modules/.pnpm/@types+node@26.3.0/node_modules/https/package.json","../../node_modules/.pnpm/@types+node@26.3.0/node_modules/inspector/package.json","../../node_modules/.pnpm/@types+node@26.3.0/node_modules/inspector/promises/package.json","../../node_modules/.pnpm/@types+node@26.3.0/node_modules/module/package.json","../../node_modules/.pnpm/@types+node@26.3.0/node_modules/net/package.json","../../node_modules/.pnpm/@types+node@26.3.0/node_modules/os/package.json","../../node_modules/.pnpm/@types+node@26.3.0/node_modules/path/package.json","../../node_modules/.pnpm/@types+node@26.3.0/node_modules/path/posix/package.json","../../node_modules/.pnpm/@types+node@26.3.0/node_modules/path/win32/package.json","../../node_modules/.pnpm/@types+node@26.3.0/node_modules/perf_hooks/package.json","../../node_modules/.pnpm/@types+node@26.3.0/node_modules/process/package.json","../../node_modules/.pnpm/@types+node@26.3.0/node_modules/punycode/package.json","../../node_modules/.pnpm/@types+node@26.3.0/node_modules/querystring/package.json","../../node_modules/.pnpm/@types+node@26.3.0/node_modules/readline/package.json","../../node_modules/.pnpm/@types+node@26.3.0/node_modules/readline/promises/package.json","../../node_modules/.pnpm/@types+node@26.3.0/node_modules/repl/package.json","../../node_modules/.pnpm/@types+node@26.3.0/node_modules/stream/consumers/package.json","../../node_modules/.pnpm/@types+node@26.3.0/node_modules/stream/iter/package.json","../../node_modules/.pnpm/@types+node@26.3.0/node_modules/stream/package.json","../../node_modules/.pnpm/@types+node@26.3.0/node_modules/stream/promises/package.json","../../node_modules/.pnpm/@types+node@26.3.0/node_modules/stream/web/package.json","../../node_modules/.pnpm/@types+node@26.3.0/node_modules/string_decoder/package.json","../../node_modules/.pnpm/@types+node@26.3.0/node_modules/timers/package.json","../../node_modules/.pnpm/@types+node@26.3.0/node_modules/timers/promises/package.json","../../node_modules/.pnpm/@types+node@26.3.0/node_modules/tls/package.json","../../node_modules/.pnpm/@types+node@26.3.0/node_modules/trace_events/package.json","../../node_modules/.pnpm/@types+node@26.3.0/node_modules/tty/package.json","../../node_modules/.pnpm/@types+node@26.3.0/node_modules/url/package.json","../../node_modules/.pnpm/@types+node@26.3.0/node_modules/util/package.json","../../node_modules/.pnpm/@types+node@26.3.0/node_modules/util/types/package.json","../../node_modules/.pnpm/@types+node@26.3.0/node_modules/v8/package.json","../../node_modules/.pnpm/@types+node@26.3.0/node_modules/vm/package.json","../../node_modules/.pnpm/@types+node@26.3.0/node_modules/wasi/package.json","../../node_modules/.pnpm/@types+node@26.3.0/node_modules/worker_threads/package.json","../../node_modules/.pnpm/@types+node@26.3.0/node_modules/zlib/package.json","../../node_modules/.pnpm/@types+pg@8.23.1/node_modules/@types/events/package.json","../../node_modules/.pnpm/@types+pg@8.23.1/node_modules/@types/pg/lib/package.json","../../node_modules/.pnpm/@types+pg@8.23.1/node_modules/@types/stream/package.json","../../node_modules/.pnpm/@types+pg@8.23.1/node_modules/@types/tls/package.json","../../node_modules/.pnpm/@types+pg@8.23.1/node_modules/events/package.json","../../node_modules/.pnpm/@types+pg@8.23.1/node_modules/pg-protocol/dist/messages.js/package.json","../../node_modules/.pnpm/@types+pg@8.23.1/node_modules/stream/package.json","../../node_modules/.pnpm/@types+pg@8.23.1/node_modules/tls/package.json","../../node_modules/.pnpm/bullmq@6.3.4_ioredis@6.0.0_pg@8.23.0/node_modules/bullmq-otel/package.json","../../node_modules/.pnpm/bullmq@6.3.4_ioredis@6.0.0_pg@8.23.0/node_modules/bullmq/dist/esm/classes/errors/package.json","../../node_modules/.pnpm/bullmq@6.3.4_ioredis@6.0.0_pg@8.23.0/node_modules/bullmq/dist/esm/classes/package.json","../../node_modules/.pnpm/bullmq@6.3.4_ioredis@6.0.0_pg@8.23.0/node_modules/bullmq/dist/esm/enums/package.json","../../node_modules/.pnpm/bullmq@6.3.4_ioredis@6.0.0_pg@8.23.0/node_modules/bullmq/dist/esm/interfaces/package.json","../../node_modules/.pnpm/bullmq@6.3.4_ioredis@6.0.0_pg@8.23.0/node_modules/bullmq/dist/esm/package.json","../../node_modules/.pnpm/bullmq@6.3.4_ioredis@6.0.0_pg@8.23.0/node_modules/bullmq/dist/esm/postgres/migrations/package.json","../../node_modules/.pnpm/bullmq@6.3.4_ioredis@6.0.0_pg@8.23.0/node_modules/bullmq/dist/esm/postgres/package.json","../../node_modules/.pnpm/bullmq@6.3.4_ioredis@6.0.0_pg@8.23.0/node_modules/bullmq/dist/esm/types/package.json","../../node_modules/.pnpm/bullmq@6.3.4_ioredis@6.0.0_pg@8.23.0/node_modules/bullmq/dist/esm/utils/package.json","../../node_modules/.pnpm/bullmq@6.3.4_ioredis@6.0.0_pg@8.23.0/node_modules/bullmq/dist/package.json","../../node_modules/.pnpm/bullmq@6.3.4_ioredis@6.0.0_pg@8.23.0/node_modules/bullmq/node_modules/child_process/package.json","../../node_modules/.pnpm/bullmq@6.3.4_ioredis@6.0.0_pg@8.23.0/node_modules/bullmq/node_modules/crypto/package.json","../../node_modules/.pnpm/bullmq@6.3.4_ioredis@6.0.0_pg@8.23.0/node_modules/bullmq/node_modules/events/package.json","../../node_modules/.pnpm/bullmq@6.3.4_ioredis@6.0.0_pg@8.23.0/node_modules/bullmq/node_modules/node-abort-controller/package.json","../../node_modules/.pnpm/bullmq@6.3.4_ioredis@6.0.0_pg@8.23.0/node_modules/bullmq/node_modules/stream/package.json","../../node_modules/.pnpm/bullmq@6.3.4_ioredis@6.0.0_pg@8.23.0/node_modules/bullmq/node_modules/url/package.json","../../node_modules/.pnpm/bullmq@6.3.4_ioredis@6.0.0_pg@8.23.0/node_modules/bullmq/node_modules/worker_threads/package.json","../../node_modules/.pnpm/bullmq@6.3.4_ioredis@6.0.0_pg@8.23.0/node_modules/child_process/package.json","../../node_modules/.pnpm/bullmq@6.3.4_ioredis@6.0.0_pg@8.23.0/node_modules/crypto/package.json","../../node_modules/.pnpm/bullmq@6.3.4_ioredis@6.0.0_pg@8.23.0/node_modules/events/package.json","../../node_modules/.pnpm/bullmq@6.3.4_ioredis@6.0.0_pg@8.23.0/node_modules/redis/package.json","../../node_modules/.pnpm/bullmq@6.3.4_ioredis@6.0.0_pg@8.23.0/node_modules/stream/package.json","../../node_modules/.pnpm/bullmq@6.3.4_ioredis@6.0.0_pg@8.23.0/node_modules/url/package.json","../../node_modules/.pnpm/bullmq@6.3.4_ioredis@6.0.0_pg@8.23.0/node_modules/worker_threads/package.json","../../node_modules/.pnpm/ioredis@6.0.0/node_modules/dns/package.json","../../node_modules/.pnpm/ioredis@6.0.0/node_modules/events/package.json","../../node_modules/.pnpm/ioredis@6.0.0/node_modules/ioredis/built/cluster/package.json","../../node_modules/.pnpm/ioredis@6.0.0/node_modules/ioredis/built/connectors/SentinelConnector/package.json","../../node_modules/.pnpm/ioredis@6.0.0/node_modules/ioredis/built/connectors/package.json","../../node_modules/.pnpm/ioredis@6.0.0/node_modules/ioredis/built/himport/package.json","../../node_modules/.pnpm/ioredis@6.0.0/node_modules/ioredis/built/package.json","../../node_modules/.pnpm/ioredis@6.0.0/node_modules/ioredis/built/redis/package.json","../../node_modules/.pnpm/ioredis@6.0.0/node_modules/ioredis/built/utils/package.json","../../node_modules/.pnpm/ioredis@6.0.0/node_modules/net/package.json","../../node_modules/.pnpm/ioredis@6.0.0/node_modules/stream/package.json","../../node_modules/.pnpm/ioredis@6.0.0/node_modules/tls/package.json","../../node_modules/.pnpm/node_modules/@types/assert/package.json","../../node_modules/.pnpm/node_modules/@types/assert/strict/package.json","../../node_modules/.pnpm/node_modules/@types/async_hooks/package.json","../../node_modules/.pnpm/node_modules/@types/buffer/package.json","../../node_modules/.pnpm/node_modules/@types/child_process/package.json","../../node_modules/.pnpm/node_modules/@types/cluster/package.json","../../node_modules/.pnpm/node_modules/@types/console/package.json","../../node_modules/.pnpm/node_modules/@types/constants/package.json","../../node_modules/.pnpm/node_modules/@types/crypto/package.json","../../node_modules/.pnpm/node_modules/@types/dgram/package.json","../../node_modules/.pnpm/node_modules/@types/diagnostics_channel/package.json","../../node_modules/.pnpm/node_modules/@types/dns/package.json","../../node_modules/.pnpm/node_modules/@types/dns/promises/package.json","../../node_modules/.pnpm/node_modules/@types/domain/package.json","../../node_modules/.pnpm/node_modules/@types/events/package.json","../../node_modules/.pnpm/node_modules/@types/fs/package.json","../../node_modules/.pnpm/node_modules/@types/fs/promises/package.json","../../node_modules/.pnpm/node_modules/@types/http/package.json","../../node_modules/.pnpm/node_modules/@types/http2/package.json","../../node_modules/.pnpm/node_modules/@types/https/package.json","../../node_modules/.pnpm/node_modules/@types/inspector/package.json","../../node_modules/.pnpm/node_modules/@types/inspector/promises/package.json","../../node_modules/.pnpm/node_modules/@types/module/package.json","../../node_modules/.pnpm/node_modules/@types/net/package.json","../../node_modules/.pnpm/node_modules/@types/os/package.json","../../node_modules/.pnpm/node_modules/@types/path/package.json","../../node_modules/.pnpm/node_modules/@types/path/posix/package.json","../../node_modules/.pnpm/node_modules/@types/path/win32/package.json","../../node_modules/.pnpm/node_modules/@types/perf_hooks/package.json","../../node_modules/.pnpm/node_modules/@types/process/package.json","../../node_modules/.pnpm/node_modules/@types/punycode/package.json","../../node_modules/.pnpm/node_modules/@types/querystring/package.json","../../node_modules/.pnpm/node_modules/@types/readline/package.json","../../node_modules/.pnpm/node_modules/@types/readline/promises/package.json","../../node_modules/.pnpm/node_modules/@types/repl/package.json","../../node_modules/.pnpm/node_modules/@types/stream/consumers/package.json","../../node_modules/.pnpm/node_modules/@types/stream/iter/package.json","../../node_modules/.pnpm/node_modules/@types/stream/package.json","../../node_modules/.pnpm/node_modules/@types/stream/promises/package.json","../../node_modules/.pnpm/node_modules/@types/stream/web/package.json","../../node_modules/.pnpm/node_modules/@types/string_decoder/package.json","../../node_modules/.pnpm/node_modules/@types/timers/package.json","../../node_modules/.pnpm/node_modules/@types/timers/promises/package.json","../../node_modules/.pnpm/node_modules/@types/tls/package.json","../../node_modules/.pnpm/node_modules/@types/trace_events/package.json","../../node_modules/.pnpm/node_modules/@types/tty/package.json","../../node_modules/.pnpm/node_modules/@types/url/package.json","../../node_modules/.pnpm/node_modules/@types/util/package.json","../../node_modules/.pnpm/node_modules/@types/util/types/package.json","../../node_modules/.pnpm/node_modules/@types/v8/package.json","../../node_modules/.pnpm/node_modules/@types/vm/package.json","../../node_modules/.pnpm/node_modules/@types/wasi/package.json","../../node_modules/.pnpm/node_modules/@types/worker_threads/package.json","../../node_modules/.pnpm/node_modules/@types/zlib/package.json","../../node_modules/.pnpm/node_modules/assert/package.json","../../node_modules/.pnpm/node_modules/assert/strict/package.json","../../node_modules/.pnpm/node_modules/async_hooks/package.json","../../node_modules/.pnpm/node_modules/buffer/package.json","../../node_modules/.pnpm/node_modules/child_process/package.json","../../node_modules/.pnpm/node_modules/cluster/package.json","../../node_modules/.pnpm/node_modules/console/package.json","../../node_modules/.pnpm/node_modules/constants/package.json","../../node_modules/.pnpm/node_modules/crypto/package.json","../../node_modules/.pnpm/node_modules/dgram/package.json","../../node_modules/.pnpm/node_modules/diagnostics_channel/package.json","../../node_modules/.pnpm/node_modules/dns/package.json","../../node_modules/.pnpm/node_modules/dns/promises/package.json","../../node_modules/.pnpm/node_modules/domain/package.json","../../node_modules/.pnpm/node_modules/events/package.json","../../node_modules/.pnpm/node_modules/fs/package.json","../../node_modules/.pnpm/node_modules/fs/promises/package.json","../../node_modules/.pnpm/node_modules/http/package.json","../../node_modules/.pnpm/node_modules/http2/package.json","../../node_modules/.pnpm/node_modules/https/package.json","../../node_modules/.pnpm/node_modules/inspector/package.json","../../node_modules/.pnpm/node_modules/inspector/promises/package.json","../../node_modules/.pnpm/node_modules/module/package.json","../../node_modules/.pnpm/node_modules/net/package.json","../../node_modules/.pnpm/node_modules/os/package.json","../../node_modules/.pnpm/node_modules/path/package.json","../../node_modules/.pnpm/node_modules/path/posix/package.json","../../node_modules/.pnpm/node_modules/path/win32/package.json","../../node_modules/.pnpm/node_modules/perf_hooks/package.json","../../node_modules/.pnpm/node_modules/process/package.json","../../node_modules/.pnpm/node_modules/punycode/package.json","../../node_modules/.pnpm/node_modules/querystring/package.json","../../node_modules/.pnpm/node_modules/readline/package.json","../../node_modules/.pnpm/node_modules/readline/promises/package.json","../../node_modules/.pnpm/node_modules/repl/package.json","../../node_modules/.pnpm/node_modules/stream/consumers/package.json","../../node_modules/.pnpm/node_modules/stream/iter/package.json","../../node_modules/.pnpm/node_modules/stream/package.json","../../node_modules/.pnpm/node_modules/stream/promises/package.json","../../node_modules/.pnpm/node_modules/stream/web/package.json","../../node_modules/.pnpm/node_modules/timers/package.json","../../node_modules/.pnpm/node_modules/timers/promises/package.json","../../node_modules/.pnpm/node_modules/tls/package.json","../../node_modules/.pnpm/node_modules/trace_events/package.json","../../node_modules/.pnpm/node_modules/tty/package.json","../../node_modules/.pnpm/node_modules/url/package.json","../../node_modules/.pnpm/node_modules/util/package.json","../../node_modules/.pnpm/node_modules/util/types/package.json","../../node_modules/.pnpm/node_modules/v8/package.json","../../node_modules/.pnpm/node_modules/vm/package.json","../../node_modules/.pnpm/node_modules/wasi/package.json","../../node_modules/.pnpm/node_modules/worker_threads/package.json","../../node_modules/.pnpm/node_modules/zlib/package.json","../../node_modules/.pnpm/pg-protocol@1.16.0/node_modules/pg-protocol/dist/package.json","../../node_modules/.pnpm/pg-protocol@1.16.0/node_modules/stream/package.json","../../node_modules/.pnpm/sharp@0.35.4_@types+node@26.3.0/node_modules/sharp/dist/package.json","../../node_modules/.pnpm/sharp@0.35.4_@types+node@26.3.0/node_modules/sharp/node_modules/@img/colour/package.json","../../node_modules/assert/package.json","../../node_modules/assert/strict/package.json","../../node_modules/async_hooks/package.json","../../node_modules/buffer/package.json","../../node_modules/child_process/package.json","../../node_modules/cluster/package.json","../../node_modules/console/package.json","../../node_modules/constants/package.json","../../node_modules/crypto/package.json","../../node_modules/dgram/package.json","../../node_modules/diagnostics_channel/package.json","../../node_modules/dns/package.json","../../node_modules/dns/promises/package.json","../../node_modules/domain/package.json","../../node_modules/events/package.json","../../node_modules/fs/package.json","../../node_modules/fs/promises/package.json","../../node_modules/http/package.json","../../node_modules/http2/package.json","../../node_modules/https/package.json","../../node_modules/inspector/package.json","../../node_modules/inspector/promises/package.json","../../node_modules/module/package.json","../../node_modules/net/package.json","../../node_modules/os/package.json","../../node_modules/path/package.json","../../node_modules/path/posix/package.json","../../node_modules/path/win32/package.json","../../node_modules/perf_hooks/package.json","../../node_modules/process/package.json","../../node_modules/punycode/package.json","../../node_modules/querystring/package.json","../../node_modules/readline/package.json","../../node_modules/readline/promises/package.json","../../node_modules/repl/package.json","../../node_modules/stream/consumers/package.json","../../node_modules/stream/iter/package.json","../../node_modules/stream/package.json","../../node_modules/stream/promises/package.json","../../node_modules/stream/web/package.json","../../node_modules/string_decoder/package.json","../../node_modules/timers/package.json","../../node_modules/timers/promises/package.json","../../node_modules/tls/package.json","../../node_modules/trace_events/package.json","../../node_modules/tty/package.json","../../node_modules/url/package.json","../../node_modules/util/package.json","../../node_modules/util/types/package.json","../../node_modules/v8/package.json","../../node_modules/vm/package.json","../../node_modules/wasi/package.json","../../node_modules/worker_threads/package.json","../../node_modules/zlib/package.json"]} \ No newline at end of file diff --git a/docs/api/README.md b/docs/api/README.md index ea0ac55..821ff8e 100644 --- a/docs/api/README.md +++ b/docs/api/README.md @@ -17,7 +17,7 @@ API 启动后会在 `/api-docs` 提供 Swagger UI,并在其 OpenAPI 文档中 | [`pricing.md`](./pricing.md) | 已设计 | 汇率、物流价卡、即时试算、定价方案、报价快照衔接。 | | [`collection-and-extension.md`](./collection-and-extension.md) | 已设计 | 插件授权、采集写入、采集箱查询、素材转存与状态。 | | [`product-editor-and-publishing.md`](./product-editor-and-publishing.md) | 编辑器与发布预检已实现 | 编辑上下文、完整 SKU 草稿、店铺切换、规格继承、AI 来源素材字段级合并、平台级 Ozon 类目/属性/字典按需刷新及首次上架目录预检;定价、AI 任务与真正的 Ozon 发布 job 仍待实现。 | -| [`online-listings.md`](./online-listings.md) | 初版已设计 | 店铺独有的在线商品镜像、读取与异步同步;编辑/认领仍待讨论。 | +| [`online-listings.md`](./online-listings.md) | 首期已实现 | 店铺独有的在线商品镜像、读取与手动异步同步;编辑/认领仍待讨论。 | | [`platform-catalog-admin.md`](./platform-catalog-admin.md) | 已实现 | 全局系统管理员授权、平台参考源配置、类目/属性/字典读取、每周树同步、刷新策略、三种类型属性批次、优先级、任务状态及后台页面。 | 旧的 [`../product/api.md`](../product/api.md) 是商品域的早期草案:已确认的产品规则可作为背景,但不能当作最终 HTTP 契约。后续商品模块会迁入本目录并替换其中的路径和细节。 diff --git a/docs/api/collection-and-extension.md b/docs/api/collection-and-extension.md index 31b1296..d801979 100644 --- a/docs/api/collection-and-extension.md +++ b/docs/api/collection-and-extension.md @@ -30,6 +30,8 @@ Headers:扩展 Bearer token 与 `Idempotency-Key` 必填。请求体为已定 尚未创建任何 `product_platform_profile` 时,SKU 草稿仍是来源投影:同一 `source_variant_key` 原位更新,新 key 创建,缺失 key 软删除。**一旦进入任一平台编辑器,SKU 草稿及其平台 mapping 不再是可替换来源投影。** 此后的重复采集仍更新来源证据、规范化载荷和候选素材;同 key 复用原 SKU ID 且不覆盖用户编辑字段,新发现的来源 SKU 会为每个已有平台配置创建 `not_listed` mapping,来源未返回的 SKU 不自动删除或恢复。来源差异的人工审查 UI 另由商品编辑模块提供,采集接口不得通过删除 SKU 或级联删除映射来“覆盖”草稿。 +1688 结构化采集以 `skuModel.skuInfoMap` 的真实可售记录生成 SKU;每条记录的 `price`(CNY)按两位小数四舍五入后写入对应 SKU 的 `purchase_cost_cny`,`skuProps.value` 中按规格名称匹配的图片作为带 `variantSourceKey` 的 `source_sku` 候选。尚无平台编辑草稿时,重复采集会刷新来源采购价;进入编辑器后不覆盖用户修改过的采购价。SKU 对应的来源图片可在编辑页预览,用户选择后才转存并保存为正式主图。 + 成功返回 `201`(新建)或 `200`(复用): ```json diff --git a/docs/api/online-listings.md b/docs/api/online-listings.md index cd7b437..d527daa 100644 --- a/docs/api/online-listings.md +++ b/docs/api/online-listings.md @@ -93,6 +93,8 @@ Headers:`Idempotency-Key` 必填。Body 可省略,或为: Worker 只在完成所有分页和详情批次后把 run 标记 `succeeded`。部分请求失败时 run 为 `failed`,已成功 upsert 的记录仍保留,但不执行“未见记录归档”步骤,避免将网络错误放大为商品消失。 +归档规则(2026-09-26 确认):首次成功全量同步未见某 SKU,仅记录 `missing_since`;第二次成功全量同步仍未见,或 `missing_since` 已超过 7 天,写入 `archived_at`。Worker 定时处理超过 7 天的记录,活跃全量同步期间暂缓归档。失败、部分同步或取消本身不改变这两个字段;再次出现时清空并恢复展示。列表默认排除已归档商品。 + ## 5. 明确不在首期接口中的操作 - 从在线商品编辑 Ozon 商品、重新发布或更新已导入 SKU; diff --git a/docs/api/product-editor-and-publishing.md b/docs/api/product-editor-and-publishing.md index 9fd579c..ef2bbc8 100644 --- a/docs/api/product-editor-and-publishing.md +++ b/docs/api/product-editor-and-publishing.md @@ -86,6 +86,18 @@ Headers:`Idempotency-Key` 必填,页头平台由 header 指定,body 为空 ## 2. 原子保存草稿与 SKU 规则 +### 单张 AI 图片编辑(SKU 选图弹窗) + +商品编辑页固定任务入口使用 `GET /api/products/{product_id}/image-tasks?limit=20&cursor=...`。`limit` 默认 20、最大 100,按进行中优先、创建时间倒序、ID 倒序稳定分页,返回 `{ data: ProductImageTask[], meta: { next_cursor, has_more } }`。接口只返回当前工作空间、当前商品的图片 AI 任务;首期包含单张编辑,后续套图任务按同一结构加入。每项包含 `kind: single_ai_edit | ai_suite`、`status`、`phase`、已完成数/总数、创建/完成时间、稳定错误码、`failure_source: pcore | atlas | null`、`failure_stage: task_setup | input_preparation | provider_submission | provider_polling | result_download | result_processing | result_storage | null`、`failure_detail: string | null`、`provider_error_raw: string | null` 与结果素材 ID 数组。失败来源表示 Pcore 接口返回/调用失败或 Atlas 自身处理失败;步骤指出实际出错的位置。`failure_detail` 是最长 500 字符的脱敏诊断摘要,不包含密钥、对象键或预签名 URL。Pcore 失败时 `provider_error_raw` 保留供应商原始错误响应文本(JSON 响应保存错误对象,最多 32,768 字符),便于在当前工作空间的任务列表中查看;没有供应商响应时为 `null`。历史任务未记录的字段返回 `null`,不得按通用错误码猜测失败来源。`GET /api/media-ai-edits/{task_id}` 的单张编辑响应也返回三个诊断字段,供未关闭的编辑弹窗显示。不返回供应商任务 ID、对象键或预览 URL。任务入口每 3 秒刷新运行中的任务,其余时间低频刷新;任务提交后即时刷新。查看结果时再用素材访问接口签发短时预览 URL。单张编辑任务取消沿用 `POST /api/media-ai-edits/{task_id}/cancel`。 + +`POST /api/media-assets/{asset_id}/ai-edits`:必须带 `Idempotency-Key`,请求体带当前工作空间 `product_id`,图片字段只使用正式素材 ID,不接受图片 URL。`asset_id` 为固定主图;`reference_asset_ids` 最多两项,仅作辅助参考。请求支持 `prompt`、`mode: edit | outpaint`、`ratio: 3:4 | 1:1 | 4:5`、图中文字翻译与目标语言、背景模式与描述、五种可改写的视觉风格、商品长宽高及尺寸标注图。`outpaint` 只允许 3:4、单主图,不生成尺寸标注。成功返回 HTTP 202、任务 ID 和 `queued` 状态。 + +`GET /api/media-ai-edits/{task_id}` 查询 `queued | running | succeeded | failed | cancelled`,成功时才返回独立 `output_asset` 的正式素材 ID、尺寸与短时预览地址;失败仅返回稳定错误码。`GET /api/media-ai-edits?product_id={product_id}` 返回当前商品最近 100 条成功任务,供右侧「AI 生图记录」复用素材。`POST /api/media-ai-edits/{task_id}/cancel` 需带 `Idempotency-Key`,将未完成任务标记为取消;已完成任务保持原结果。任务、operation、Outbox 在同一工作空间事务提交;Worker 持久化供应商任务 ID 后通过延迟 Outbox 继续轮询。供应商密钥、对象存储键和供应商临时图片地址不进入 API 响应或 Outbox。 + +供应商异步任务返回终态失败时不再轮询或重试同一任务 ID。只有供应商明确返回内容政策或审核错误码时才使用稳定错误码 `AI_IMAGE_CONTENT_REJECTED`;笼统的 `failed`(即使消息称“可能”涉及安全政策)及其他供应商终态失败使用 `AI_IMAGE_PROVIDER_FAILED`;提交、网络、存储等非终态问题在重试耗尽后使用 `AI_IMAGE_GENERATION_FAILED`。Worker 在终态失败或重试耗尽时把失败来源、步骤和脱敏摘要写入同工作空间的 operation 结果;用户界面显示阶段和摘要,并在任务卡片展示已取得的 Pcore 原始错误内容。历史任务若原先未保存错误内容,则相关字段为 `null`。 + +生成结果先在单张 AI 编辑抽屉中预览。用户点击“添加到当前 SKU”后,新素材插入原图后方,原图保留;选图弹窗“确认”仅写入编辑页表单,页面“保存”后才持久化 SKU `media_asset_ids`。关闭或取消编辑不修改 SKU 草稿。图片源、参考图、结果均受工作空间隔离;任务输出与源图分别存储。 + `PUT /api/products/{product_id}/editor-draft` Headers:`If-Match: "{product_revision}:{profile_revision}"`、`Idempotency-Key` 必填。请求是整个编辑页的原子快照: @@ -123,11 +135,12 @@ Headers:`If-Match: "{product_revision}:{profile_revision}"`、`Idempotency-Key - `profile.platform_product_category_id` 必须是当前平台的活动类目;同平台门店共享类目、类型属性和字典缓存。服务端从平台类目记录取得 `platform_type_id`,客户端不能伪造类型 ID。过渡期仍接受 `store_product_category_id`,但只用于按 `description_category_id + type_id` 映射平台类目,不参与当前读取和校验。 - 类目切换或平台 schema 移除属性时,原有不再适用的属性不会进入新的发布草稿;服务端将它们写入 `unmapped_attributes` 并在编辑快照中返回。用户删除 SKU 时,其 SKU 属性随 SKU 一起移除,不生成无主未映射属性。 - `variants` 是当前完整未删除 SKU 集合。省略既有 SKU 表示软删除;无论当前 SPU 状态都允许新增、复制、删除。`copy_from_variant_id` 仅用于新行,复制通用 SKU 数据但不复制任何平台导入映射,生成的新 SKU 从 `not_listed` 开始。 +- `variants[].purchase_cost_cny` 是人民币采购价,编辑快照以两位小数字符串返回;草稿输入最多两位小数。来源采集写入时按两位小数四舍五入。 - 删除已导入 SKU 只从 Atlas 草稿及当前平台可发布集合移除,**不删除、不下架、不修改** Ozon 商品或在线镜像;其他平台历史映射保留。删除后当前平台只剩已导入 SKU 时,SPU 汇总自然为“已上架”。 - `null` 的 SKU 规格字段表示继承父商品;包装物流规格的 `status` 为 `missing`/`ambiguous` 时,不允许定价确认或首次上架,但仍允许保存草稿。发布前服务端必须对每个待发布 SKU 解析 `effective_package_spec`,确认长、宽、高、重量均存在且大于 0 后才可生成 Ozon item。实物规格不用于物流;是否向 SPU 或 SKU 展示由其对应的类目 schema 属性决定,尺寸图仍可使用已确认的有效实物规格。 - `target_store_id` 只能是当前工作空间、当前平台的活动店铺。变更目标店铺只更新**尚未导入** listing 的将来发布目标;`imported` listing 永远保留其首次导入时的店铺快照。 - 类目变化后不匹配的旧属性作为未映射草稿返回 warnings,不能自动进入发布 payload。属性由平台 schema 校验 `type`、必填、字典、多值/复杂属性和 `is_aspect`;客户端不能用任意 JSON 绕过。 -- `media_asset_ids` 是本 SKU 当前平台的最终选图顺序;最多 30 张。格式只允许 JPG/PNG/WebP,单文件最多 20 MB,边长超过 10,000 px 拒绝,短边小于 700 px 不得作为首图并给出警告。第一张是主图。已完成的 `product_image` 上传素材和网络导入素材可供同工作空间的 SKU 选择;采集图片仍须先转存,且来源变体须匹配。 +- `media_asset_ids` 是本 SKU 当前平台的最终选图顺序;最多 30 张。格式只允许 JPG/PNG/WebP,单文件最多 20 MB,边长超过 10,000 px 拒绝,短边小于 700 px 不得作为首图并给出警告。第一张是主图。已完成的 `product_image` 上传素材和网络导入素材可供同工作空间的 SKU 选择;当前商品成功的水印与 3:4 处理结果也可选择。采集图片仍须先转存,且来源变体须匹配。 - `common.model` 是用户手填或主动 AI 填报的商品型号;采集时允许为空,但每次首次上架前必须非空。`offer_id` 是用户手填或主动 AI 填报的目标平台卖家货号;采集入箱时不填值,生成时不直接复用 `source_sku`。草稿可为空,但每个待首次上架 SKU 的 `offer_id` 必须非空且在同一店铺/平台唯一。AI 填报接口和回填规则见[方案](../product/ai-autofill-plan.md)。 - `common.source_title` 在编辑页显示为“中文商品名”;`profile.title` 显示为“俄文发布标题”,页面保存时必填。为允许先保存采集草稿再使用 AI 填报,API 草稿契约仍接受空标题;发布就绪校验在 `profile.title` 为空时返回 `PUBLISH_TITLE_REQUIRED`。SKU 发布标题 `variants[].title` 为 `null` 时继承 `profile.title`,不回退到 `display_name`;现有 SKU `tags=[]`、`description=null` 保持继承语义。标题、标签、描述分别独立覆盖。中俄对照按 SPU/SKU 与字段随 `editor-draft` 原子保存;SKU 继承项直接引用 SPU 对照,手工俄文覆盖但无匹配中文时标为对照待更新。发布适配器使用 SKU 有效文案仍是后续工作。 @@ -251,17 +264,17 @@ Headers:`Idempotency-Key` 必填。Body: 媒体选择通常随第 2 节草稿一起保存。为支持图片弹窗的异步“添加水印”,提供: -`POST /api/product-variant-platform-listings/{variant_listing_id}/watermark-derivatives` +`POST /api/media-assets/{asset_id}/watermark-derivatives` Headers:`Idempotency-Key` 必填。Body: ```json -{ "source_asset_id": "uuid", "watermark_template_id": "uuid" } +{ "watermark_template_id": "uuid" } ``` -服务端验证 source asset 是该 SKU 当前已选图片、模板属于当前工作空间且活动;固化模板**当前版本**后创建 `media_derivatives` 和 Worker 任务,返回 `202` 及 operation。任务成功后返回独立 output asset;原图和其他 SKU 的选择均不改变。用户在图片弹窗确认后,将 output asset 写入下一次 `editor-draft.media_asset_ids` 来替换原图位置;可再次选择原图恢复。首期不在发布时自动添加水印。 +服务端验证源图片是当前工作空间已校验的 JPG/PNG/WebP 正式素材,模板属于同一工作空间且活动;固化模板**当前版本**后,在同一事务创建水印派生记录、operation 和 Outbox 事件,返回 `202` 及任务摘要。采集候选图先按需转存为正式素材。任务成功后返回独立 output asset;原图和其他 SKU 的选择均不改变。用户在图片弹窗确认后,将 output asset 写入下一次 `editor-draft.media_asset_ids` 来替换原图位置;同一弹窗内可撤销为原图。首期不在发布时自动添加水印。 -`GET /api/media-derivatives/{derivative_id}` 返回任务状态、脱敏错误及成功时的输出素材短时预览;不能泄露 `object_key`。 +`GET /api/media-watermark-derivatives/{derivative_id}` 返回 `id`、`status`、`source_asset_id`、`watermark_template_id`、`watermark_template_version`、`output_asset` 和脱敏 `error_code`。成功时 `output_asset` 包含正式素材 ID、宽高、类型、大小及短时预览 URL;不能泄露 `object_key`。输出保留原图显示方向与宽高,合成水印后编码为 JPG;透明原图以白色合成。模板的文字大小、图片像素尺寸、不透明度、圆角和相对位置以固化版本为准。失败后原图留在弹窗中。 ## 6. AI 填报任务与页面回填 diff --git a/docs/api/watermarks-and-assets.md b/docs/api/watermarks-and-assets.md index fd59696..b6d1b17 100644 --- a/docs/api/watermarks-and-assets.md +++ b/docs/api/watermarks-and-assets.md @@ -69,7 +69,7 @@ Headers:`Idempotency-Key` 必填。 `object_key` 是服务端内部字段,不放在普通客户端响应。上传意图和完成状态均持久化在数据库,不能只存在于 Redis 或预签名 token 中;Worker 每 5 分钟将逾期未完成意图标记为 `expired`,并删除 `failed` / `expired` 意图的临时对象。删除成功才记录清理时间;失败会在下一轮重试。 -### 1.3 临时预览 URL +### 1.3 临时预览与下载 URL `POST /api/media-assets/{asset_id}/access-urls` @@ -77,7 +77,7 @@ Headers:`Idempotency-Key` 必填。 { "purpose": "preview" } ``` -返回 10 分钟有效的短时 `url` 与 `expires_at`。仅当前工作空间的素材可签发;前端在 URL 过期后重新请求,不能把 URL 写回业务表或长期缓存。 +`purpose` 可为 `preview` 或 `download`。下载时传 `{ "purpose": "download" }`,服务端在签名 GET 请求中指定 `Content-Disposition: attachment`,文件名为 `atlas-image-{asset_id}` 加素材类型对应扩展名,浏览器打开该短时地址时保存文件。两种用途都返回 10 分钟有效的 `url` 与 `expires_at`。仅当前工作空间的素材可签发;前端在 URL 过期后重新请求,不能把 URL 写回业务表或长期缓存。 `GET /api/media-assets/product-uploads` 返回当前工作空间最近 100 个已经完成校验的 `product_image` 素材。每项包含 `MediaAsset` 字段及 10 分钟有效的 `preview_url`、`preview_url_expires_at`,供编辑页“上传图片”候选区展示;该地址不得写入草稿。 @@ -87,6 +87,18 @@ Headers:`Idempotency-Key` 必填。 `GET /api/media-assets/product-imports` 返回当前工作空间最近 100 条已导入的网络图片,响应项同上。图片已生成正式素材 ID 后才可进入 SKU 草稿;网络来源地址和预览地址均不得写入 `media_asset_ids`。 +### 1.5 商品图片一键 3:4 + +`POST /api/media-assets/{asset_id}/aspect-ratio-derivatives`,需 `Idempotency-Key`,请求 `{ "preset": "3:4", "fit": "contain", "product_id": "uuid" }`。`product_id` 在商品编辑器中传入,以便将结果归档到该商品;旧调用可省略。`asset_id` 必须是当前工作空间已校验的 JPG/PNG/WebP 正式素材。采集候选图先按需转存,再以正式素材 ID 请求。服务端在同一事务创建工作空间隔离的派生记录、用户可见 `operation` 和 Outbox 事件,返回 HTTP `202`: + +```json +{ "data": { "id": "uuid", "status": "queued", "source_asset_id": "uuid", "output_asset": null, "error_code": null } } +``` + +`GET /api/media-derivatives/{derivative_id}` 返回相同结构,`status` 为 `queued/running/succeeded/failed`。成功时 `output_asset` 为独立正式素材,包含 `id`、宽高、类型、大小以及 10 分钟有效的 `preview_url` 和 `preview_url_expires_at`;失败时只返回脱敏 `error_code`。任务等比缩放原图;留白贴近图片的部分采用对应边缘像素,向外平滑过渡到该边缘窄带的平均色,避免细纹理被拉成贯穿留白的条纹。透明边缘先与白色合成。输出为 1200×1600 JPG,不裁切商品,原图不修改。相同幂等键重放同一任务,Worker 重试必须复用同一派生记录。 + +单张或批量操作都逐张创建任务。弹窗用成功的 output asset 替换当前已选位置;未选的来源图处理成功后加入已选区。失败的图保持原样。弹窗「确认」只把新素材 ID 写入当前编辑表单,页面「保存」才持久化 SKU 选图。短时预览 URL、存储路径和远程来源 URL 不写入草稿或 Outbox。 + ## 2. 可用字体 `GET /api/watermark-fonts` @@ -108,11 +120,16 @@ Headers:`Idempotency-Key` 必填。 "id": "uuid", "name": "右下角品牌", "kind": "image", + "is_default": false, "current_version": 3, "config": { "image_asset": { "id": "uuid", "preview_url": "https://short-lived", "preview_url_expires_at": "2026-09-02T08:15:00Z" }, "opacity": "0.8000", - "scale_ratio": "0.2500", + "scale_ratio": "0.5556", + "image_size_px": 80, + "border_radius_px": 0, + "position_x": 1, + "position_y": 1, "position": "bottom_right" }, "created_at": "2026-09-02T08:00:00Z", @@ -129,11 +146,13 @@ Headers:`Idempotency-Key` 必填。 "font_code": "NotoSans", "font_size_px": 24, "opacity": "0.8000", + "position_x": 1, + "position_y": 1, "position": "bottom_right" } ``` -`position` 只能是 `top_left`、`top_center`、`top_right`、`middle_left`、`center`、`middle_right`、`bottom_left`、`bottom_center`、`bottom_right`。`opacity` 范围 `"0"`–`"1"`;图片 `scale_ratio` 范围 `"0.1"`–`"2"`;文字 `font_size_px` 范围 12–60。 +`position_x`、`position_y` 是 0–1 的数字,分别表示水印在水平、垂直**可移动范围**内的相对位置:0 贴左/上边缘,1 贴右/下边缘,中间值不吸附九宫格;坐标保存到小数点后四位。`position` 是兼容旧版客户端的九宫格近似值,不得用于精确渲染;其枚举值仍为 `top_left`、`top_center`、`top_right`、`middle_left`、`center`、`middle_right`、`bottom_left`、`bottom_center`、`bottom_right`。`opacity` 范围 `"0"`–`"1"`;图片 `image_size_px` 是在 240×320 基准预览框中等比缩放后的最长边像素数,范围 10–200,默认 80;`border_radius_px` 是该预览框中的圆角半径,范围 0–100,默认 0。`scale_ratio` 仅用于旧客户端兼容,范围 `"0.1"`–`"2"`,新客户端以 `image_size_px` 为准;10–14px 时兼容比例固定为最小值 `"0.1"`,实际渲染仍以像素尺寸为准。文字 `font_size_px` 范围 12–60,也以基准预览框为单位。实际渲染时,Worker 用 `max(源图显示宽度/240, 源图显示高度/320)` 同比例放大图片大小、文字大小、圆角和 5px 边距;水印超出源图时再等比缩小到可容纳范围。这样商品图缩小到预览尺度后,水印大小与底边距接近模板预览。 ### 3.1 查询 @@ -145,6 +164,10 @@ Headers:`Idempotency-Key` 必填。 返回模板当前版本,供编辑弹窗和“查看效果”使用。固定演示底图由前端内置,前端按 `config` 实时渲染预览,不在每次滑动 Slider 时创建服务端图片。 +`GET /api/watermark-templates/default` + +返回 `{ "data": 模板资源 }`;当前工作空间未设置默认模板时返回 `{ "data": null }`。列表和详情中的 `is_default` 表示当前工作空间的唯一默认水印。打开「SKU 图片选择」弹窗时客户端静默读取一次默认模板;点击单张或批量「添加水印」时,有默认模板便直接创建派生任务,没有默认模板才打开三列模板选择弹窗。查询尚未完成时等待结果再决定,查询失败时允许用户从选择弹窗手动选择。设置默认不会自动修改已有图片、店铺推荐水印或已生成的派生图。 + ### 3.2 创建 `POST /api/watermark-templates` @@ -162,7 +185,8 @@ Headers:`Idempotency-Key` 必填。 "font_code": "NotoSans", "font_size_px": 24, "opacity": "0.8", - "position": "bottom_right" + "position_x": 0.73, + "position_y": 0.82 } } ``` @@ -175,20 +199,22 @@ Headers:`Idempotency-Key` 必填。 "kind": "image", "config": { "image_asset_id": "uuid", - "scale_ratio": "0.25", + "image_size_px": 80, + "border_radius_px": 12, "opacity": "0.8", - "position": "bottom_right" + "position_x": 0.73, + "position_y": 0.82 } } ``` -模板名称在当前工作空间内唯一;图片资产必须属于当前工作空间且已完成校验。成功返回 `201 Created` 与模板资源。 +创建时须同时提交 `position_x` 和 `position_y`;旧版客户端可继续只提交 `position`,服务端会换算成 0、0.5 或 1 的坐标。若两种格式都提交,以精确坐标为准。图片水印须提交 `image_size_px`;旧版客户端可继续提交 `scale_ratio`,服务端会换算为像素尺寸。若两者都提交,以像素尺寸为准;未提交 `border_radius_px` 时取 0。模板名称在当前工作空间内唯一;图片资产必须属于当前工作空间且已完成校验。成功返回 `201 Created` 与模板资源。 ### 3.3 编辑与删除 `PATCH /api/watermark-templates/{template_id}` -Headers:`If-Match` 与 `Idempotency-Key` 必填。请求体与创建相同,但可只提交待改字段;若切换 `kind`,须提交新类型完整 `config`,服务端不保留旧类型专属字段。 +Headers:`If-Match` 与 `Idempotency-Key` 必填。请求体与创建相同,但可只提交待改字段;坐标可单独修改一个轴,另一轴沿用当前值。旧版客户端只提交 `position` 时,两个坐标按九宫格同步换算。图片尺寸和圆角也可独立修改;仅提交旧字段 `scale_ratio` 时重新换算像素尺寸。若切换 `kind`,须提交新类型完整 `config`,服务端不保留旧类型专属字段。 每次影响渲染的更新都会创建新的 `watermark_template_version`,并增加模板 `current_version`。旧版本不会被覆盖,供已生成派生图追溯;模板改名只更新模板名称,不需要创建渲染版本。 @@ -196,6 +222,14 @@ Headers:`If-Match` 与 `Idempotency-Key` 必填。请求体与创建相同, Headers:`Idempotency-Key` 必填。服务端在同一事务软删除模板并解除所有当前店铺绑定,返回 `204 No Content`。不删除原始图片资产,也不影响已生成水印派生图和历史发布快照。 +`PUT /api/watermark-templates/{template_id}/default` + +Headers:`Idempotency-Key` 必填;请求体为 `{}`。将当前工作空间的活动模板设为唯一默认模板,原默认模板同时取消默认状态,返回 `200 OK` 与更新后的模板资源。重复设置同一模板成功且不会创建新的渲染版本。模板不存在或不属于当前工作空间时返回 `404 RESOURCE_NOT_FOUND`。删除默认模板后,工作空间不再有默认模板。 + ## 4. 与商品编辑的边界 -本文件只定义水印模板和通用素材。商品编辑的“图片悬浮 → 编辑 → 添加水印”将在商品接口中定义:它选择这里的活动模板、创建持久化派生任务、固化模板版本,并将生成的独立素材替换当前 SKU 已选图片位置;不会在上架时临时加水印。 +商品编辑的“图片悬浮 → 编辑 → 添加水印”使用 `POST /api/media-assets/{asset_id}/watermark-derivatives` 创建异步派生任务,需 `Idempotency-Key`,请求体为 `{ "watermark_template_id": "uuid", "product_id": "uuid" }`。`product_id` 在商品编辑器中传入,旧调用可省略。仅接受当前工作空间内已校验、最大 20 MiB、最大边长 10,000px 的 JPG/PNG/WebP 正式素材和活动模板。创建时固定模板当前版本,在同一事务写入派生任务、operation 和 Outbox,返回 HTTP `202`;`GET /api/media-watermark-derivatives/{derivative_id}` 查询进度。响应包含 `id`、`status`、`source_asset_id`、`watermark_template_id`、`watermark_template_version`、`output_asset` 和 `error_code`。成功时结果为保留源图显示方向及尺寸的独立 JPG 素材,透明源图用白底合成,返回短时预览地址;失败只返回稳定错误码。 + +单张和批量处理逐张创建任务。成功结果在图片弹窗中替换对应位置;来源候选图成功后加入已选区,失败图保持原样。弹窗可撤销本次水印替换;确认后才写入当前编辑表单,页面保存后持久化 SKU 选图。上架任务不会临时加水印。 + +`GET /api/products/{product_id}/image-derivatives` 返回当前工作空间内该商品最近 100 条成功的 3:4 与水印处理记录,按处理时间倒序;商品不存在返回 `404`。每条记录包含 `id`、`kind`(`aspect_ratio_3_4` / `watermark`)、`source_asset_id`、`created_at` 和带短时预览地址的 `output_asset`。编辑页选图弹窗的「图片处理记录」Tab 用它找回结果,选用后仍需确认选图并保存页面。未绑定商品的历史任务无法确定商品归属,不进入该列表;来源图与原素材不被覆盖。 diff --git a/docs/product/product-ai-editor.md b/docs/product/product-ai-editor.md index 12adfb2..a827ce0 100644 --- a/docs/product/product-ai-editor.md +++ b/docs/product/product-ai-editor.md @@ -17,7 +17,7 @@ ├─ SKU 商品:卡片(默认)/ 紧凑列表 │ └─ N 个 SKU;维护货号、条形码、图片、价格、变体属性、三项 SKU 文案及可选包装覆盖 └─ 固定操作栏 - ├─ 左:AI 填报 · AI 套图生成 + ├─ 左:AI 填报 · AI 套图生成 · 图片任务 └─ 右:取消并关闭 · 保存草稿 · 选择未上架 SKU 并首次上架 ``` @@ -59,11 +59,19 @@ SKU 名称只用于展示,不是发布标题;采集 SKU 用稳定 `source_va AI 输入由两部分组成:一是用户在右侧“采集的商品信息”中整理并保存的来源标题、描述、品牌、价格、规格参数和来源类目;二是用户在左侧商品与 SKU 表单中保存的实物规格、包装规格和规格轴。右侧实际展示“最新规范化采集值 + 用户明确覆盖字段”的有效结果:没有编辑过的字段随复采更新,编辑过的字段保留用户值,并在来源变化时提供对比、逐项恢复和全部采用最新。原始采集证据不因编辑而改变。 -点击任何 AI 动作前须先保存草稿,服务端再从当前 revision 组装输入;客户端不得把未保存的尺寸或自行解析的来源 JSON 直接提交给 AI。父商品规格与 SKU 有效规格均以后端读模型为准,SKU 的空覆盖字段按继承规则读取左侧父商品规格。 +商品文案、类目等 AI 填报动作前须先保存草稿,服务端再从当前 revision 组装输入;客户端不得把未保存的尺寸或自行解析的来源 JSON 直接提交给这类 AI 任务。图片「AI 编辑」是独立素材任务,只引用已物化的正式图片素材 ID,尺寸标注参数由用户在单张编辑抽屉中明确填写,不修改商品规格。父商品规格与 SKU 有效规格均以后端读模型为准,SKU 的空覆盖字段按继承规则读取左侧父商品规格。 -SKU 图片选择弹窗左侧是该 SKU 的已选图片(排序/移除),右侧分组展示 AI 生图记录、采集主图、采集 SKU 图、采集详情图、网络图片和上传图片。商品级素材可被多个 SKU 选择;明确有稳定来源关联的 SKU 图只展示给对应 SKU。Ozon 首期每个 SKU 最多选择 30 张发布图片,第一张为主图,可拖动排序;平台适配器以自身限制驱动该上限。 +SKU 图片选择弹窗左侧是该 SKU 的已选图片(排序/移除),右侧依次展示 AI 生图记录、图片处理记录、采集图片、网络图片和上传图片;采集图片内按主图、SKU 图、详情图分组。商品级素材可被多个 SKU 选择;明确有稳定来源关联的 SKU 图只展示给对应 SKU。Ozon 首期每个 SKU 最多选择 30 张发布图片,第一张为主图,可拖动排序;平台适配器以自身限制驱动该上限。 -已选图片悬浮时显示“编辑”菜单,至少包含“添加水印”。点击后在弹窗中选择一个工作空间水印模板,后台创建带水印的派生素材;原图和其他 SKU 的选择不被改写。用户确认后,以派生图替换该 SKU 当前图片位置,并可在同一弹窗撤销为原图。首期不在发布时自动加水印,也不因门店默认水印自动处理图片;后续再支持默认模板的预选、批量应用或自动应用。 +右侧「采集的商品信息」中的采集图片也提供「编辑」菜单,包含 AI 编辑、添加水印、一键 3:4 和下载。AI 编辑复用单张编辑抽屉,生成结果进入当前商品的 AI 生图记录;水印和 3:4 处理复用商品级派生任务,成功后在采集区显示处理图,重开页面时按该商品的处理记录恢复最新结果。原始采集证据与原素材保持不变;处理图可以在 SKU 图片选择弹窗的「图片处理记录」中重新选用。 + +单张图片菜单和左侧批量操作中的「一键 3:4」现使用异步素材派生任务:采集候选图先转存,Worker 对正式素材等比缩放,留白从对应边缘像素平滑过渡到该侧的平均底色,生成 1200×1600 JPG 新素材;原图保留。处理成功后在弹窗中替换对应已选位置,失败项保持原样。弹窗确认只回填当前表单,页面保存后才持久化选图。批量翻译仍待开发。 + +图片「编辑 → AI 编辑」打开标题为「AI编辑」的单张编辑抽屉,主图固定,可选最多两张辅助图。用户可设置编辑要求、图中文字翻译、背景、五种视觉风格、尺寸标注和画面比例;抽屉不展示生成方式,提交时固定使用 `edit` 模式。Worker 调用 Pcore `gpt-image-2` 异步接口;普通编辑参照旧项目以 JSON `images[].image_url` 发送原始图片的 data URI,仅扩图发送预处理后的 PNG 画布和蒙版 data URI。生成图独立保存。提交和生图期间都可关闭抽屉,任务在后台继续,可从底部操作栏的图片任务入口查看;失败任务同时展示通用提示和已取得的 Pcore 原始错误内容。用户预览后点击「添加到当前 SKU」,结果插在原图后方;已完成结果也保留在当前商品的「AI 生图记录」,可在重开弹窗后选择。选图弹窗确认和页面保存仍分别控制表单回填与持久化。 + +商品编辑页底部固定操作栏在「AI 套图生成」后展示「图片任务」入口;有运行中任务时显示「任务处理中」和数量。展开后按进行中和最近任务显示当前商品的图片 AI 任务、状态、完成数、失败信息和结果预览;单张 AI 编辑可在此取消。提交后立即刷新,进行中每 3 秒刷新,其余时间低频刷新。当前接入单张编辑;后续 AI 套图沿用商品图片任务结构并增加任务来源,入口无需重新设计。 + +打开「SKU 图片选择」时静默读取当前工作空间默认水印。点击单张或批量「添加水印」时,有默认模板便直接生成带水印的派生素材;没有默认模板或读取失败才打开每行最多三张卡片的选模板弹窗,水印管理在新标签页打开。原图和其他 SKU 的选择不被改写;生成结果替换当前图片位置,并可在同一弹窗撤销。弹窗确认只更新编辑表单,页面保存后才持久化选图;发布时不会自动加水印。 首期素材规则:发布图片仅接受 JPG、PNG、WebP;单文件最大 20 MB,最短边低于 700 px 给出警告且不得作为主图,超过 10,000 px 的边长在上传时拒绝;建议 1:1 或 3:4,允许其他比例但在预览中提示裁切风险。采集候选素材可保留原始文件供编辑选择,实际发布时始终按目标平台的图片数、格式和可访问性校验。Ozon 发布图片必须是可由 Ozon 拉取的 HTTPS 地址。 diff --git a/docs/project/HANDOFF.md b/docs/project/HANDOFF.md index 40071df..7a53d7d 100644 --- a/docs/project/HANDOFF.md +++ b/docs/project/HANDOFF.md @@ -1,6 +1,6 @@ # Atlas Seller Kit 交接说明 -> 更新:2026-09-24。此文是新 AI 对话、架构讨论与开发工作的首读入口。开始开发前,必须先阅读并遵守 [`development-collaboration-contract.md`](./development-collaboration-contract.md) 和仓库 `AGENTS.md`。下方按日期记录的旧方案仅供追溯;当前状态以本节及最新的 1.40 节为准。 +> 更新:2026-09-27。此文是新 AI 对话、架构讨论与开发工作的首读入口。开始开发前,必须先阅读并遵守 [`development-collaboration-contract.md`](./development-collaboration-contract.md) 和仓库 `AGENTS.md`。下方按日期记录的旧方案仅供追溯;当前状态以本节及最新的 1.47 节为准。 ## 1. 项目定位 @@ -14,7 +14,7 @@ pnpm monorepo 包含 Web、NestJS API、BullMQ Worker、WXT 浏览器扩展、 已完成“采集 → 采集箱 → 商品编辑草稿”的主要数据闭环、平台级类目/属性/字典目录与发布预检。编辑页已接入异步「AI 填报」:基于来源资料与 Atlas 类目树生成类目建议、型号/SKU 货号、证据充分的包装建议及 SPU 中俄文案;单 SKU 文案由用户单独生成并确认应用。AI 结果只回填未保存表单,保存草稿仍是持久化边界。Ozon 动态类目属性可按已选类目加载并手工填写,**当前不由 AI 自动填报**。SKU 图片选择现支持采集候选图、工作空间上传图和网络导入图;在弹窗确认后回填草稿,页面保存后持久化。 -尚未完成可直接发布的闭环:定价确认、真正的 Ozon 发布 job/import 及结果轮询、在线商品同步仍待开发。AI 类目推荐和文案已能运行,但不能等同于跨来源样本验收;尤其应继续用 1688/淘宝/天猫样本核验 Atlas 类目候选质量,以及俄文文案、包装证据和 SKU 继承行为。AI 生图仍是占位入口;图片「编辑」菜单中的 AI 编辑、水印、一键 3:4 等处理动作本轮未接入。 +尚未完成可直接发布的闭环:定价确认、真正的 Ozon 发布 job/import 及结果轮询、在线商品同步仍待开发。AI 类目推荐和文案已能运行,但不能等同于跨来源样本验收;尤其应继续用 1688/淘宝/天猫样本核验 Atlas 类目候选质量,以及俄文文案、包装证据和 SKU 继承行为。AI 套图生图仍是占位入口;图片「编辑」菜单中的一键 3:4、添加水印和单张 AI 编辑已接入异步素材任务。 当前优先事项:用真实来源样本回归 AI 类目推荐、文案与图片选择,补做真实图片上传/网络导入联调;完成定价确认;让首次发布 job 复用既有发布预检,再实现 Ozon import 与结果轮询。平台参考目录目前由平台级参考源和同步任务维护,不能把 9 月 10 日的门店级缓存描述当作现行架构。 @@ -458,7 +458,63 @@ BullMQ Worker(最多 5 次,5 秒起指数退避) **验证与后续范围** - 本地 `031`、`032` 迁移已执行。contracts、database、API、Web TypeScript 检查及 API/Web 生产构建通过;API 媒体单元测试共 6 项、商品编辑集成测试共 11 项通过。集成测试覆盖 30 张选图保存、已上传和网络导入素材的草稿引用、非法内网网络图片链接拒绝。 -- 尚未用真实外部图片 URL、浏览器直传 MinIO 和完整编辑页操作做端到端回归;上线前需联调短时预览地址刷新、上传失败重试、跨 SKU 选图与重新打开弹窗。AI 生图和图片「编辑」菜单中的 AI 编辑、添加水印、一键 3:4、批量翻译由后续迭代接入;当前菜单仅提供入口提示,设为封图、下载和移出已选可用。 +- 尚未用真实外部图片 URL、浏览器直传 MinIO 和完整编辑页操作做端到端回归;上线前需联调短时预览地址刷新、上传失败重试、跨 SKU 选图与重新打开弹窗。AI 套图生图和批量翻译由后续迭代接入;单张 AI 编辑、一键 3:4、添加水印、设为封图、下载和移出已选可用。下载对当前工作空间素材签发带 `Content-Disposition: attachment` 的短时地址,采集候选图先按需转存,避免跨域预览链接只打开图片。 + +### 1.41 开发进度(2026-09-25):SKU 图片一键 3:4 + +- 图片弹窗的单张菜单和左侧批量操作已接入一键 3:4。采集候选图先按需转存,再以正式素材 ID 创建异步派生任务;Worker 等比缩放,留白从对应边缘像素平滑过渡到该侧窄带的平均底色,避免出现贯穿留白的条纹,生成独立的 1200×1600 JPG,保留原图。成功项替换弹窗中的图片,失败项不变;确认写入当前表单,页面保存后持久化。 +- API、共享类型、Worker 与 `033_media_aspect_ratio_derivatives.sql` 已同步;任务、operation 和 Outbox 在同一事务创建,任务表受工作空间 RLS 约束。结果查询只签发短时预览 URL。新增图像处理库 sharp,项目包管理器统一为 pnpm 11.25.0。 +- `033` 已在当前本地 `atlas` 数据库应用。API/Worker 受限角色可读写新表;图像转换、任务执行与媒体 API 单元测试已通过。临时账号和图片的 API → MinIO → Outbox → Worker → 结果查询实测返回独立的 1200×1600 JPEG,测试记录与对象已清理。仍需在真实编辑页点击单张和批量入口,回归弹窗状态与保存后的重读。 + +### 1.42 开发进度(2026-09-26):商品图片主动应用水印 + +- “添加水印”打开标题为「选择水印」的三列模板弹窗,左侧「水印管理」在新标签页打开。选择后对单张或批量图片逐张创建水印派生任务;成功项替换弹窗选图,失败项保留原图,当前弹窗支持撤销本次水印替换。确认只写入编辑表单,页面保存后才持久化。 +- `036_media_watermark_derivatives.sql` 保存源素材、锁定的模板版本及处理结果;API 创建任务时原子写入 operation 和 Outbox,Worker 读取不可变模板版本,以文字或图片水印生成独立 JPG 素材。结果查询只签发短时预览 URL。 +- 本地已应用 `036` 并核对受限应用角色可读写新表;contracts、API、Worker、Web 类型检查与 Web 生产构建通过,API 单元测试 26 项、图像 Worker 测试 13 项及新增的水印 API/数据库集成测试通过。尚需在真实商品编辑页回归选模板、批量处理、撤销与保存后重读。API 全量集成测试仍有一项既有发布预检优先级断言失败,与本轮水印路径无关。 +- 水印图片大小、文字字号、圆角和边距以管理页 240×320 预览框为基准,Worker 按源图显示尺寸同比例换算。此前直接把预览的 80px 水印贴到 1200×1600 等大图,缩小展示后明显偏小且过于贴底;已修正渲染计算,旧的派生图不会自动重生成,需要重新应用水印。 +- 打开「SKU 图片选择」时前端静默请求当前工作空间默认水印。单张和批量「添加水印」等待这次请求:有默认模板就直接执行已有派生处理,没有默认模板或请求失败才打开选模板弹窗。默认模板仅在用户主动点击后应用,不会因打开图片弹窗而修改草稿。 + +### 1.43 开发进度(2026-09-26):SKU 图片单张 AI 编辑 + +- 参考旧项目 `studio/src/pages/trial/AiImageGenModal.tsx`,图片「编辑 → AI 编辑」现打开单张抽屉:固定主图、最多两张辅助图、编辑要求、图片文字翻译、背景、五种风格及自定义提示词、尺寸标注、3:4/1:1/4:5 比例与仅扩图。Pcore `gpt-image-2` 任务由 Worker 异步提交、持久化供应商任务 ID,再经延迟 Outbox 轮询;仅扩图完成后覆盖回原图区域。生成结果独立保存为商品关联素材,用户点击「添加到当前 SKU」后插在原图后方;确认选图并保存页面后才持久化 SKU 顺序。右侧「AI 生图记录」可重新选择当前商品的最近 100 张成功结果。 +- API 契约见 `docs/api/product-editor-and-publishing.md`;`039_media_ai_edits.sql` 建立工作空间隔离任务表,`040_media_ai_edit_product_scope.sql` 关联商品。两项迁移已应用到当前本地 `atlas` 数据库,受限应用角色读取检查通过。contracts、API、Worker、Web 类型检查通过;Web 生产构建通过;API 定向测试 4 项与 Worker 定向测试 3 项通过(含仅扩图回填原图、供应商异步状态推进)。浏览器中以模拟素材挂载抽屉,验证了翻译开关、尺寸标注选项、仅扩图模式和窄视口布局;临时前端服务已停止。本次未向真实 Pcore 提交收费任务,也未登录真实商品编辑页做完整操作回归;联调时需验证 Pcore 结果地址、任务取消与页面重新打开后的图片选择。 + +### 1.44 开发进度(2026-09-26):商品图片任务入口 + +- 参考旧项目 `studio/src/pages/trial/TrialTaskCenter.tsx`,商品编辑页右下角增加固定「图片任务」入口和右侧任务抽屉。按进行中、最近任务展示当前商品任务,支持手动刷新、单张编辑取消和按需签发短时地址查看结果;单张编辑提交即刷新,运行中每 3 秒轮询。API `GET /api/products/{product_id}/image-tasks` 在工作空间事务内分页读取任务,首期聚合单张编辑;共享 `ProductImageTask` 已预留 `ai_suite` 类型。后续套图生成落地时需把套图表查询接入聚合服务,并补上套图任务的取消与结果操作。API 与 Web 类型检查、Web 生产构建、API 定向测试通过;尚未用真实登录商品页做端到端界面回归。 +- 单张 AI 编辑抽屉已移除「生成方式」选择,提交固定使用 `edit` 模式;抽屉标题改为「AI编辑」。服务端仍保留既有 `outpaint` 契约,供其他调用端或后续能力使用。 +- 商品编辑页底部操作栏已将原「AI 生图」入口更名为「AI 套图生成」,并把图片任务入口放在其后;进行中时显示任务数。套图按钮当前仍为待开发提示。 + +### 1.45 故障排查(2026-09-26):单张 AI 编辑任务失败 + +- 本地 4 条失败任务都已成功提交到 Pcore,供应商任务 ID 已持久化;Pcore 轮询返回 `status=failed` 和“请求无法用于生成图像,可能因安全政策被拦截,或不适合进行图像生成”。4 条任务使用同一张蜘蛛侠玩具主图,切换背景后仍遭相同拒绝;供应商未指明具体触发项,不能断定是图片、辅助图或提示词中的哪一项。`PCORE_API_KEY` 与 `PCORE_BASE_URL` 已在当前根 `.env` 配置,Worker 通过根 `.env` 读取并实际调用了 `gpt-image-2` 的 `/v1/images/edits`;本次排查只读查询已有供应商任务,没有提交新的付费生图。 +- Worker 现在识别 Pcore 的终态错误:内容策略/输入不适合时写 `AI_IMAGE_CONTENT_REJECTED`,其他终态写 `AI_IMAGE_PROVIDER_FAILED`,不再对同一终态任务重复尝试 5 次;前端显示对应中文提示。历史 4 条任务仍保留原通用错误码 `AI_IMAGE_GENERATION_FAILED`。Worker 单元测试 4 项、Worker/Web 类型检查和 Web 生产构建通过。排查时确认维护者的 API/Worker `tsx watch` 均在运行,Worker 子进程已随代码变更重新加载;尚未对其他图片发起实际生图验证。前端无法绕过 Pcore 内容判定。 +- 2026-09-27 复核时新增的第 5 条失败任务仍是同一张主图,但没有辅图、背景为 `auto`、编辑要求为空。Pcore 返回 `status=failed`、`error.type=image_task_error`、`error.code=failed`,消息仅称“可能因安全政策被拦截,或不适合进行图像生成”,没有审核阶段或类别;因此不能将原因确定归于主图、辅助图、提示词或生成结果。对照旧项目的 `/v1/images/edits`、`gpt-image-2`、异步轮询、`images[].image_url` 和 3:4 尺寸均一致;Atlas 的差异是发出前会压缩为 JPEG,旧项目发送原始图片字节。当前输入图本身已是 1170×1170、197 KB 的 JPEG,尚无证据表明转码导致拒绝。界面提示已改为不指定具体拦截阶段。没有提交新的付费诊断任务。 + +### 1.46 Pcore 图片编辑协议修正(2026-09-27) + +- 根 `.env` 的 `PCORE_BASE_URL=https://pcore.ai` 没有隐藏空白;Worker 原本拼接为 `/v1/images/edits`,并能拿到供应商任务 ID,故少写 `/v1` 不是失败原因。现兼容根地址与文档的 `/v1` 地址,均规范化为同一接口路径;`.env.example` 推荐文档写法,现有根 `.env` 不必改动。 +- 用同一密钥做三项单张对照:合成花瓶图通过 JSON `images[].image_url` 提交后返回 `queued`,随后以笼统的 `image_task_error/failed` 终止;纯文生图通过 `/v1/images/generations` 成功;同一花瓶图改用 `multipart/form-data` 的 `image` 文件上传到 `/v1/images/edits` 后成功。因此笼统错误消息不能证明内容审核拒绝,此次故障定位为图片编辑请求格式与当前 Pcore 通道不兼容。 +- Worker 现在以 multipart 上传主图及各辅图,扩图时上传 PNG 图片和 `mask` 文件,保留原有异步任务 ID 与轮询。仅在供应商明确给出审核类错误码时标记 `AI_IMAGE_CONTENT_REJECTED`,笼统终态失败标记 `AI_IMAGE_PROVIDER_FAILED`。旧任务状态不回写;需重新发起生成来验证真实商品图。对照诊断提交了三条可能计费的供应商任务,其中两条成功、一条失败。 + +### 1.47 图片任务展示 Pcore 原始错误(2026-09-27) + +- Pcore 图片编辑失败时,Worker 将供应商错误对象原文保存在对应 operation 的 `result.provider_error_raw`,稳定错误码及通用提示仍保留。商品图片任务列表在工作空间事务内联结 operation,响应增加 `provider_error_raw`;前端失败任务卡片直接展示该内容,其他任务为 `null`。供应商任务 ID、密钥及结果图片地址不随任务列表返回。历史失败任务此前未保存原始错误,需按已持久化的供应商任务 ID 补取;无法补取的记录继续显示通用提示。当前根 `.env` 指向的本地 `atlas` 数据库经只读核对为 0 个商品、0 条失败的 AI 编辑任务,因此本次无历史记录可补取。 + +### 1.48 单张 AI 编辑请求对齐旧项目(2026-09-27) + +- 复核旧项目 SQLite 中最近两条成功的单张编辑任务:均使用 `gpt-image-2`、3:4、一张参考图,并经 `/v1/images/edits` 的 JSON `images[].image_url` 提交。两项目配置的 Pcore key 与 base URL 相同。旧项目也存在一次同端点的笼统失败,因此上一节用合成图得出的“JSON 格式与通道不兼容”不能推广到所有输入图。 +- 按维护者要求,Atlas Worker 的普通编辑改为 JSON 请求,`model/prompt/n/size/async/images` 结构与旧项目一致;图片以原始 JPG/PNG/WebP 字节生成 data URI,不再压缩、铺白或转码。仅扩图仍先构造 PNG 画布和蒙版,再用同一 JSON 协议发送 `images` 与 `mask.image_url`。异步轮询、原始错误记录与生成结果保存流程不变。Worker 定向测试 6 项、类型检查和构建通过;尚未以真实商品图重新向 Pcore 发起收费任务。 + +### 1.49 Pcore 已完成任务的结果解析修正(2026-09-27) + +- Pcore 官方图片文档区分 `/v1/images/generations` 文生图和 `/v1/images/edits` 图生图;单张 AI 编辑传原图,应继续调用后者。文档的 JSON 图生图输入写为 URL 或 data URI 字符串或字符串数组;Atlas 当前的 `images[].image_url` 对象数组与旧项目成功任务一致,而且最新任务在 Pcore 返回 `completed`,故这次失败并非提交参数被拒绝。 +- 读取 Joey 工作空间内最近失败任务及 BullMQ 栈后确认:最近三条任务都取得供应商任务 ID,Pcore 返回 `data[0].url`,并同时给出空字符串 `b64_json`。Atlas 原先优先解码空 `b64_json`,产生空 Buffer,Sharp 抛出 `Input Buffer is empty`;BullMQ 重试五次后写通用失败。已完成任务的 Pcore 内容 URL 经只读 GET 返回 HTTP 200、`image/png`、1,580,645 字节。Worker 现仅使用非空的 base64 数据,否则回退到结果 URL。定向测试 7 项、Worker 类型检查和构建通过;未提交新的收费生图任务。此前失败记录仍为失败状态,未自动改写业务数据。 + +### 1.50 Pcore 结果下载 DNS 回调修正(2026-09-27) + +- 前一节修复后再次提交的两条 Atlas 任务都已取得 Pcore 任务 ID,却在读取结果 URL 时以 `Invalid IP address: undefined` 失败。BullMQ 栈指向 `downloadProviderImage` 的自定义 DNS `lookup` 回调;Node 请求 `options.all=true` 时要求回调返回地址数组,旧实现仍返回单个地址,导致 TLS 连接前失败。旧项目使用 `httpx` 直接读取结果 URL,没有这一分支。 +- Worker 的 DNS 固定逻辑现按 `options.all` 返回地址数组或单个地址,公网地址校验与 TLS 域名校验保持不变。用已经完成的 Pcore 任务调用修复后的 Atlas 下载函数,只读取回有效 PNG(1,734,068 字节,1086×1448);没有发起新的收费生图。Worker 定向测试 8 项、类型检查与构建通过。历史失败记录仍未改写。 ## 2. 已确认产品决策 diff --git a/docs/requirements/account-store-watermark.md b/docs/requirements/account-store-watermark.md index 6a78824..48d2fd8 100644 --- a/docs/requirements/account-store-watermark.md +++ b/docs/requirements/account-store-watermark.md @@ -106,7 +106,7 @@ | 水印名称 | 模板名称,在当前账户内唯一。 | | 水印类型 | `文字水印` 或 `图片水印`。 | | 水印内容 | 文字水印展示文字内容;图片水印展示缩略图/文件名,不显示原始存储路径。 | -| 水印位置 | 九宫格位置,以清晰中文名展示,如“右下”。 | +| 水印位置 | 显示水平、垂直相对位置;旧模板的九宫格位置会转换为相应坐标。 | | 操作 | `查看效果`、`编辑`、`删除`。 | 删除已被店铺绑定的模板时,必须二次确认并说明影响;确认删除后应解除所有店铺绑定,而不是让任务继续引用一个已删除的模板。已生成的历史图片不受影响。 @@ -122,15 +122,15 @@ | 模板名称 | 必填;当前账户内唯一。 | | 水印类型 | 必填,文字或图片。切换类型时需确认,因为原类型专属字段会被清空。 | | 不透明度 | 使用 Ant Design Slider;取值 0%–100%,预览同步更新。 | -| 水印位置 | 九宫格单选:左上、上中、右上、左中、居中、右中、左下、下中、右下。 | -| 预览效果 | 基于固定演示底图实时渲染位置、缩放和透明度;首期仅预览,不写入商品图。 | +| 预览效果与水印位置 | 基于固定演示底图实时渲染位置、大小、圆角和透明度;在预览框内拖动水印可停在任意位置,距边缘最少 5px,也可用方向键微调。保存相对坐标,重新打开后保持原位置。商品编辑页主动应用时生成独立派生图。 | 图片水印专属字段: | 字段 | 必填 | 规则 | |---|---:|---| | 上传图片 | 是 | 上传 PNG/JPG/WebP;透明底 PNG 是推荐格式。具体大小、像素与文件大小限制由媒体模块确定。 | -| 图片缩放 | 是 | 使用 Ant Design Slider;首期建议取值 10%–200%,表示相对演示底图短边的比例。 | +| 图片大小 | 是 | 使用 Ant Design Slider;在 240×320 预览框中的最长边为 10–200px,默认 80px;应用到商品图时按预览框与源图的比例换算。 | +| 图片圆角 | 否 | 输入 0–100px,默认 0;同一个值控制四个圆角。 | 文字水印专属字段: @@ -144,11 +144,11 @@ ### 4.3 商品编辑时主动加水印规则 -用户在商品编辑页的已选图片上悬浮,点击“编辑 → 添加水印”,在弹窗选择一个工作空间水印模板。确认后系统对这张图片生成一份带水印的派生图,并以派生图替换此 SKU 当前图片位置。规则如下: +打开「SKU 图片选择」时静默读取当前工作空间默认水印。用户点击“编辑 → 添加水印”后,有默认模板就直接生成带水印的派生图;没有默认模板或读取失败才在弹窗中选择工作空间水印模板。生成后以派生图替换此 SKU 当前图片位置。规则如下: - 商品级候选图片与每个 SKU 已选图片都可处理;最终仅提交用户明确选中的图片集合。 - 原始图片不被改写;水印处理产物是独立、可追溯的派生素材,用户可在编辑弹窗恢复使用原图。 -- 派生任务固化水印模板版本、位置、缩放/字体和不透明度;模板后续编辑不影响已生成的派生图。 +- 派生任务固化水印模板版本、位置、图片大小与圆角/字体和不透明度;模板后续编辑不影响已生成的派生图。 - 首期不在上架任务中自动加水印,也不允许发布时临时替换;后续另行支持绑定水印的默认预选、批量添加或自动添加。 ## 5. 本轮待确认事项 diff --git a/docs/requirements/collection-box-and-online-products.md b/docs/requirements/collection-box-and-online-products.md index c02d006..3dc1f8f 100644 --- a/docs/requirements/collection-box-and-online-products.md +++ b/docs/requirements/collection-box-and-online-products.md @@ -126,7 +126,7 @@ ## 6. 待补充 1. 采集箱草稿已绑定目标后,是否允许换平台/换店铺及其迁移规则; -2. 在线商品列表的字段、筛选、详情、编辑入口和“认领外部商品”流程; +2. 在线商品首期列表字段、筛选与只读详情已按 [`../api/online-listings.md`](../api/online-listings.md) 实现;编辑入口和“认领外部商品”流程仍待设计; 3. 自动同步频率、手动同步冷却时间、单店并发和限流参数; 4. 采集箱容量 1000 的最终套餐口径及超限提示; 5. 采集箱“首次导入成功”的用户文案,以及在线商品“实际可见/库存”的用户文案;二者必须区分,不能把 `visible=true` 当作采集箱上架状态。 diff --git a/docs/reviews/implementation-readiness-open-items.md b/docs/reviews/implementation-readiness-open-items.md index 93c05bf..2a44bf3 100644 --- a/docs/reviews/implementation-readiness-open-items.md +++ b/docs/reviews/implementation-readiness-open-items.md @@ -84,9 +84,9 @@ WHERE id = :id AND revision = :expected_revision; ### 5. 在线商品同步中的“未见”与归档 -**状态:待确认。** +**状态:已确认(2026-09-26)。** 维护者选择以下规则,在线商品全量同步按此执行。 -建议规则如下,待产品确认后写入接口和同步 Worker: +规则如下: 1. 一次**成功的全量同步**中未见某在线 SKU:仅首次写入 `missing_since`;不归档。 2. 之后连续第二次成功全量同步仍未见,或 `missing_since` 已超过 7 天:写入 `archived_at`,列表默认隐藏但可筛选查看。 diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index db1bb8f..cd16307 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -5,3 +5,4 @@ export * from './watermarks'; export * from './media'; export * from './product-editor'; export * from './product-ai'; +export * from './online-products'; diff --git a/packages/contracts/src/media.ts b/packages/contracts/src/media.ts index 0785980..f7f9b82 100644 --- a/packages/contracts/src/media.ts +++ b/packages/contracts/src/media.ts @@ -27,9 +27,10 @@ export const completeMediaUploadIntentRequestSchema = z .strict(); export const createMediaAccessUrlRequestSchema = z .object({ - purpose: z.literal('preview'), + purpose: z.enum(['preview', 'download']), }) .strict(); +export type CreateMediaAccessUrlRequest = z.infer; export const importProductImageRequestSchema = z.object({ url: z.url().max(2048).refine((value) => { try { @@ -42,6 +43,50 @@ export const importProductImageRequestSchema = z.object({ }, '仅支持 HTTPS 的 JPG 或 PNG 图片链接'), }).strict(); export type ImportProductImageRequest = z.infer; +export const createAspectRatioDerivativeRequestSchema = z.object({ + preset: z.literal('3:4'), + fit: z.literal('contain'), + product_id: z.uuid().optional(), +}).strict(); +export type CreateAspectRatioDerivativeRequest = z.infer; +export const createWatermarkDerivativeRequestSchema = z.object({ + watermark_template_id: z.uuid(), + product_id: z.uuid().optional(), +}).strict(); +export type CreateWatermarkDerivativeRequest = z.infer; +export const createMediaAiEditRequestSchema = z.object({ + product_id: z.uuid(), + reference_asset_ids: z.array(z.uuid()).max(2).default([]), + prompt: z.string().trim().max(2000).default(''), + mode: z.enum(['edit', 'outpaint']).default('edit'), + ratio: z.enum(['3:4', '1:1', '4:5']).default('3:4'), + translate_text: z.boolean().default(false), + target_language: z.enum(['ru', 'ar', 'en']).default('ru'), + background: z.enum(['auto', 'white', 'solid', 'scene', 'reference']).default('auto'), + background_description: z.string().trim().max(500).default(''), + style_set: z.number().int().min(1).max(5).default(2), + style_prompt: z.string().trim().max(2000).default(''), + dimensions: z.object({ + length: z.number().positive().max(100000).optional(), + width: z.number().positive().max(100000).optional(), + height: z.number().positive().max(100000).optional(), + unit: z.enum(['cm', 'mm']).default('cm'), + }).strict().optional(), + generate_size_chart: z.boolean().default(false), +}).strict().superRefine((input, ctx) => { + if (new Set(input.reference_asset_ids).size !== input.reference_asset_ids.length) + ctx.addIssue({ code: 'custom', message: '辅助参考图不能重复', path: ['reference_asset_ids'] }); + if (input.mode === 'outpaint' && (input.ratio !== '3:4' || input.reference_asset_ids.length || input.generate_size_chart)) + ctx.addIssue({ code: 'custom', message: '仅扩图只支持单张主图与 3:4 比例', path: ['mode'] }); + if (input.mode === 'edit' && input.background === 'reference' && !input.reference_asset_ids.length) + ctx.addIssue({ code: 'custom', message: '参考背景需要一张辅助图', path: ['reference_asset_ids'] }); + if (input.generate_size_chart && !input.dimensions + || input.generate_size_chart && input.dimensions + && ![input.dimensions.length, input.dimensions.width, input.dimensions.height].some((value) => value && value > 0)) + ctx.addIssue({ code: 'custom', message: '尺寸标注图需要至少填写一个尺寸', path: ['dimensions'] }); +}); +export type CreateMediaAiEditRequest = z.input; +export type MediaAiEditSettings = z.output; export type CreateMediaUploadIntentRequest = z.infer< typeof createMediaUploadIntentRequestSchema @@ -82,6 +127,59 @@ export type ProductUploadImage = MediaAsset & { preview_url_expires_at: string; }; export type ProductImportedImage = ProductUploadImage & { source_url: string }; +export type AspectRatioDerivative = { + id: string; + status: 'queued' | 'running' | 'succeeded' | 'failed'; + source_asset_id: string; + output_asset: ProductUploadImage | null; + error_code: string | null; +}; +export type WatermarkDerivative = AspectRatioDerivative & { + watermark_template_id: string; + watermark_template_version: number; +}; +/** 商品图片处理记录只返回可重新选用的成功结果。 */ +export type ProductImageDerivativeRecord = { + id: string; + kind: 'aspect_ratio_3_4' | 'watermark'; + source_asset_id: string; + created_at: string; + output_asset: ProductUploadImage; +}; +export type MediaAiEdit = { + id: string; + status: 'queued' | 'running' | 'succeeded' | 'failed' | 'cancelled'; + source_asset_id: string; + output_asset: ProductUploadImage | null; + error_code: string | null; + failure_source: 'pcore' | 'atlas' | null; + failure_stage: MediaAiEditFailureStage | null; + failure_detail: string | null; +}; +export type MediaAiEditFailureStage = 'task_setup' | 'input_preparation' | 'provider_submission' + | 'provider_polling' | 'result_download' | 'result_processing' | 'result_storage'; +/** 商品页右下角任务列表的统一图片任务模型;套图任务后续使用同一结构。 */ +export type ProductImageTask = { + id: string; + kind: 'single_ai_edit' | 'ai_suite'; + status: 'queued' | 'running' | 'succeeded' | 'partial' | 'failed' | 'cancelled'; + phase: 'waiting_provider' | null; + total: number; + completed: number; + output_asset_ids: string[]; + error_code: string | null; + failure_source: 'pcore' | 'atlas' | null; + failure_stage: MediaAiEditFailureStage | null; + failure_detail: string | null; + provider_error_raw: string | null; + created_at: string; + completed_at: string | null; +}; +export const listProductImageTasksQuerySchema = z.object({ + limit: z.coerce.number().int().min(1).max(100).default(20), + cursor: z.string().optional(), +}); +export type ListProductImageTasksQuery = z.infer; /** POST /api/media-assets/{asset_id}/access-urls 响应(短时预览 URL) */ export type MediaAccessUrl = { diff --git a/packages/contracts/src/online-products.ts b/packages/contracts/src/online-products.ts new file mode 100644 index 0000000..27ceb0c --- /dev/null +++ b/packages/contracts/src/online-products.ts @@ -0,0 +1,104 @@ +import { z } from 'zod'; + +export const listOnlineProductsQuerySchema = z + .object({ + store_id: z.uuid().optional(), + cursor: z.string().min(1).optional(), + limit: z.coerce.number().int().min(1).max(100).default(20), + q: z.string().trim().max(200).optional(), + visibility: z + .enum(['all', 'visible', 'invisible', 'unknown']) + .default('all'), + source: z.enum(['atlas_published', 'external_sync']).optional(), + moderation_status: z.string().trim().max(100).optional(), + include_archived: z + .union([ + z.boolean(), + z + .enum(['true', 'false']) + .transform((value) => value === 'true'), + ]) + .default(false), + }) + .strict(); + +export const onlineProductSyncRequestSchema = z + .object({ + reason: z.literal('manual_refresh').optional(), + }) + .strict(); + +export const listOnlineProductSyncRunsQuerySchema = z + .object({ + cursor: z.string().min(1).optional(), + limit: z.coerce.number().int().min(1).max(100).default(20), + }) + .strict(); + +export type ListOnlineProductsQuery = z.infer< + typeof listOnlineProductsQuerySchema +>; +export type ListOnlineProductSyncRunsQuery = z.infer< + typeof listOnlineProductSyncRunsQuerySchema +>; +export type OnlineProductSource = 'atlas_published' | 'external_sync'; +export type OnlineProductSyncStatus = + 'queued' | 'running' | 'succeeded' | 'failed' | 'cancelled'; + +export type OnlineProduct = { + id: string; + store_id: string; + platform_id: string; + remote_product_id: string; + offer_id: string; + remote_sku: string | null; + name: string | null; + currency_code: string | null; + selling_price: string | null; + list_price: string | null; + visible: boolean | null; + remote_state: string | null; + moderation_status: string | null; + validation_status: string | null; + stock_summary: { total: number; has_data: boolean }; + image: { + url: string | null; + width_px: number | null; + height_px: number | null; + }; + errors: Array<{ code?: string; message: string }>; + visibility_details: Record; + source: OnlineProductSource; + atlas_origin: { + collection_item_id: string; + product_id: string; + variant_id: string; + } | null; + last_synced_at: string; + missing_since: string | null; + archived_at: string | null; +}; + +export type OnlineProductSyncRun = { + id: string; + store_id: string; + status: OnlineProductSyncStatus; + trigger_source: 'manual' | 'scheduled' | 'recovery'; + cursor_started: string | null; + cursor_finished: string | null; + listed_count: number; + upserted_count: number; + failed_count: number; + created_at: string; + started_at: string | null; + completed_at: string | null; + error: { code: string; message: string } | null; +}; + +export type OnlineProductsMeta = { + next_cursor: string | null; + has_more: boolean; + store: { id: string; name: string }; + last_successful_sync_at: string | null; + last_sync_status: OnlineProductSyncStatus | null; +}; diff --git a/packages/contracts/src/product-editor.ts b/packages/contracts/src/product-editor.ts index 6f5b50e..e75dab5 100644 --- a/packages/contracts/src/product-editor.ts +++ b/packages/contracts/src/product-editor.ts @@ -540,7 +540,7 @@ export const saveProductEditorDraftRequestSchema = z .max(20), purchase_cost_cny: z .string() - .regex(/^\d+(\.\d{1,4})?$/) + .regex(/^\d+(\.\d{1,2})?$/) .nullable(), title: nullableText(1_000).optional(), tags: z diff --git a/packages/contracts/src/watermarks.ts b/packages/contracts/src/watermarks.ts index 8cc6fbd..d6b25c9 100644 --- a/packages/contracts/src/watermarks.ts +++ b/packages/contracts/src/watermarks.ts @@ -7,6 +7,13 @@ const opacity = z const scaleRatio = z .string() .regex(/^(0\.([1-9]|[1-9]\d{1,3})|1(\.\d{1,4})?|2(\.0{1,4})?)$/, '缩放比例必须在 0.1 到 2 之间'); +export const WATERMARK_IMAGE_SIZE_MIN_PX = 10; +export const WATERMARK_IMAGE_SIZE_DEFAULT_PX = 80; +export const WATERMARK_IMAGE_SIZE_MAX_PX = 200; +export const WATERMARK_BORDER_RADIUS_MAX_PX = 100; +const imageSizePx = z.number().int().min(WATERMARK_IMAGE_SIZE_MIN_PX).max(WATERMARK_IMAGE_SIZE_MAX_PX); +const borderRadiusPx = z.number().int().min(0).max(WATERMARK_BORDER_RADIUS_MAX_PX); +const positionCoordinate = z.number().finite().min(0).max(1); export type WatermarkPosition = z.infer; export const watermarkPositionSchema = z.enum([ 'top_left', 'top_center', 'top_right', 'middle_left', 'center', @@ -18,13 +25,19 @@ const textConfig = z.object({ font_code: z.string().trim().min(1).max(120), font_size_px: z.number().int().min(12).max(60), opacity, - position: watermarkPositionSchema, + position_x: positionCoordinate.optional(), + position_y: positionCoordinate.optional(), + position: watermarkPositionSchema.optional(), }).strict(); const imageConfig = z.object({ image_asset_id: uuid, - scale_ratio: scaleRatio, + image_size_px: imageSizePx.optional(), + border_radius_px: borderRadiusPx.optional(), + scale_ratio: scaleRatio.optional(), opacity, - position: watermarkPositionSchema, + position_x: positionCoordinate.optional(), + position_y: positionCoordinate.optional(), + position: watermarkPositionSchema.optional(), }).strict(); export const createWatermarkTemplateRequestSchema = z.object({ @@ -38,6 +51,15 @@ export const createWatermarkTemplateRequestSchema = z.object({ if (value.kind === 'image' && !('image_asset_id' in value.config)) { context.addIssue({ code: 'custom', path: ['config'], message: '图片水印需要完整图片配置' }); } + if (value.kind === 'image' && 'image_asset_id' in value.config + && value.config.image_size_px === undefined && value.config.scale_ratio === undefined) { + context.addIssue({ code: 'custom', path: ['config'], message: '图片水印需要提供像素尺寸' }); + } + const hasX = value.config.position_x !== undefined; + const hasY = value.config.position_y !== undefined; + if (hasX !== hasY || (!hasX && !value.config.position)) { + context.addIssue({ code: 'custom', path: ['config'], message: '需要同时提供水平和垂直坐标,或提供旧版九宫格位置' }); + } }); const partialConfig = z.object({ @@ -47,6 +69,10 @@ const partialConfig = z.object({ font_size_px: z.number().int().min(12).max(60).optional(), opacity: opacity.optional(), scale_ratio: scaleRatio.optional(), + image_size_px: imageSizePx.optional(), + border_radius_px: borderRadiusPx.optional(), + position_x: positionCoordinate.optional(), + position_y: positionCoordinate.optional(), position: watermarkPositionSchema.optional(), }).strict(); @@ -94,6 +120,12 @@ export type WatermarkTemplateConfig = { image_asset?: WatermarkTemplateImageAsset; opacity?: string; scale_ratio?: string; + image_size_px?: number; + border_radius_px?: number; + /** 0 为左/上边缘,1 为右/下边缘,按水印可移动范围归一化。 */ + position_x: number; + position_y: number; + /** 兼容旧版九宫格客户端的近似位置;精确渲染以坐标为准。 */ position: WatermarkPosition; }; @@ -102,6 +134,7 @@ export type WatermarkTemplate = { id: string; name: string; kind: 'text' | 'image'; + is_default: boolean; current_version: number; config: WatermarkTemplateConfig; created_at: string; diff --git a/packages/database/migrations/033_media_aspect_ratio_derivatives.sql b/packages/database/migrations/033_media_aspect_ratio_derivatives.sql new file mode 100644 index 0000000..0a3d626 --- /dev/null +++ b/packages/database/migrations/033_media_aspect_ratio_derivatives.sql @@ -0,0 +1,28 @@ +SET search_path TO atlas, public; + +-- 派生任务独立保存源图和结果,避免编辑草稿确认前修改原图或其他 SKU 的选图。 +CREATE TABLE media_aspect_ratio_derivatives ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + workspace_id UUID NOT NULL REFERENCES workspaces(id) ON DELETE RESTRICT, + source_asset_id UUID NOT NULL, + output_asset_id UUID, + status TEXT NOT NULL DEFAULT 'queued' CHECK (status IN ('queued', 'running', 'succeeded', 'failed')), + error_code TEXT, + created_by_user_id UUID REFERENCES users(id) ON DELETE SET NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + started_at TIMESTAMPTZ, + completed_at TIMESTAMPTZ, + FOREIGN KEY (workspace_id, source_asset_id) REFERENCES media_assets(workspace_id, id) ON DELETE RESTRICT, + FOREIGN KEY (workspace_id, output_asset_id) REFERENCES media_assets(workspace_id, id) ON DELETE RESTRICT, + UNIQUE (workspace_id, id), + CHECK ((status = 'succeeded') = (output_asset_id IS NOT NULL)), + CHECK (status <> 'failed' OR error_code IS NOT NULL) +); +CREATE INDEX ix_media_aspect_ratio_derivatives_source + ON media_aspect_ratio_derivatives (workspace_id, source_asset_id, created_at DESC); + +ALTER TABLE media_aspect_ratio_derivatives ENABLE ROW LEVEL SECURITY; +ALTER TABLE media_aspect_ratio_derivatives FORCE ROW LEVEL SECURITY; +CREATE POLICY workspace_isolation ON media_aspect_ratio_derivatives + USING (workspace_id = app_current_workspace_id()) + WITH CHECK (workspace_id = app_current_workspace_id()); diff --git a/packages/database/migrations/034_watermark_free_position.sql b/packages/database/migrations/034_watermark_free_position.sql new file mode 100644 index 0000000..df8671a --- /dev/null +++ b/packages/database/migrations/034_watermark_free_position.sql @@ -0,0 +1,44 @@ +SET search_path TO atlas, public; + +-- 精确坐标按水印在预览区域内的可移动范围归一化;旧九宫格数据保留原位置。 +ALTER TABLE watermark_template_versions + ADD COLUMN IF NOT EXISTS position_x NUMERIC(5,4), + ADD COLUMN IF NOT EXISTS position_y NUMERIC(5,4); + +-- 迁移角色受强制 RLS 限制,回填期间临时关闭并在同一事务内恢复。 +ALTER TABLE watermark_template_versions NO FORCE ROW LEVEL SECURITY; +ALTER TABLE watermark_template_versions DISABLE ROW LEVEL SECURITY; + +UPDATE watermark_template_versions +SET position_x = CASE + WHEN position IN ('top_left', 'middle_left', 'bottom_left') THEN 0 + WHEN position IN ('top_center', 'center', 'bottom_center') THEN 0.5 + ELSE 1 + END, + position_y = CASE + WHEN position IN ('top_left', 'top_center', 'top_right') THEN 0 + WHEN position IN ('middle_left', 'center', 'middle_right') THEN 0.5 + ELSE 1 + END +WHERE position_x IS NULL OR position_y IS NULL; + +ALTER TABLE watermark_template_versions ENABLE ROW LEVEL SECURITY; +ALTER TABLE watermark_template_versions FORCE ROW LEVEL SECURITY; + +ALTER TABLE watermark_template_versions + ALTER COLUMN position_x SET NOT NULL, + ALTER COLUMN position_y SET NOT NULL; + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint + WHERE conrelid = 'atlas.watermark_template_versions'::regclass + AND conname = 'watermark_template_versions_position_coordinates_check' + ) THEN + ALTER TABLE watermark_template_versions + ADD CONSTRAINT watermark_template_versions_position_coordinates_check + CHECK (position_x BETWEEN 0 AND 1 AND position_y BETWEEN 0 AND 1); + END IF; +END +$$; diff --git a/packages/database/migrations/035_watermark_image_size_and_radius.sql b/packages/database/migrations/035_watermark_image_size_and_radius.sql new file mode 100644 index 0000000..b669e9e --- /dev/null +++ b/packages/database/migrations/035_watermark_image_size_and_radius.sql @@ -0,0 +1,48 @@ +SET search_path TO atlas, public; + +ALTER TABLE watermark_template_versions + ADD COLUMN IF NOT EXISTS image_size_px SMALLINT, + ADD COLUMN IF NOT EXISTS border_radius_px SMALLINT NOT NULL DEFAULT 0; + +-- 旧图片模板的比例按原 240px 预览框中的显示宽度换算,保留可编辑的像素尺寸。 +ALTER TABLE watermark_template_versions NO FORCE ROW LEVEL SECURITY; +ALTER TABLE watermark_template_versions DISABLE ROW LEVEL SECURITY; + +UPDATE watermark_template_versions +SET image_size_px = LEAST(200, GREATEST(20, ROUND(scale_ratio * 144)::integer)) +WHERE kind = 'image' AND image_size_px IS NULL; + +ALTER TABLE watermark_template_versions ENABLE ROW LEVEL SECURITY; +ALTER TABLE watermark_template_versions FORCE ROW LEVEL SECURITY; + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint + WHERE conrelid = 'atlas.watermark_template_versions'::regclass + AND conname = 'watermark_template_versions_image_size_check' + ) THEN + ALTER TABLE watermark_template_versions + ADD CONSTRAINT watermark_template_versions_image_size_check + CHECK (image_size_px IS NULL OR image_size_px BETWEEN 20 AND 200); + END IF; + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint + WHERE conrelid = 'atlas.watermark_template_versions'::regclass + AND conname = 'watermark_template_versions_image_size_required_check' + ) THEN + ALTER TABLE watermark_template_versions + ADD CONSTRAINT watermark_template_versions_image_size_required_check + CHECK (kind <> 'image' OR image_size_px IS NOT NULL); + END IF; + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint + WHERE conrelid = 'atlas.watermark_template_versions'::regclass + AND conname = 'watermark_template_versions_border_radius_check' + ) THEN + ALTER TABLE watermark_template_versions + ADD CONSTRAINT watermark_template_versions_border_radius_check + CHECK (border_radius_px BETWEEN 0 AND 100); + END IF; +END +$$; diff --git a/packages/database/migrations/036_media_watermark_derivatives.sql b/packages/database/migrations/036_media_watermark_derivatives.sql new file mode 100644 index 0000000..240cb1b --- /dev/null +++ b/packages/database/migrations/036_media_watermark_derivatives.sql @@ -0,0 +1,32 @@ +SET search_path TO atlas, public; + +-- 水印派生引用不可变模板版本;编辑草稿确认前不改写原图或 SKU 选图。 +CREATE TABLE media_watermark_derivatives ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + workspace_id UUID NOT NULL REFERENCES workspaces(id) ON DELETE RESTRICT, + source_asset_id UUID NOT NULL, + watermark_template_id UUID NOT NULL, + watermark_template_version INTEGER NOT NULL CHECK (watermark_template_version > 0), + output_asset_id UUID, + status TEXT NOT NULL DEFAULT 'queued' CHECK (status IN ('queued', 'running', 'succeeded', 'failed')), + error_code TEXT, + created_by_user_id UUID REFERENCES users(id) ON DELETE SET NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + started_at TIMESTAMPTZ, + completed_at TIMESTAMPTZ, + FOREIGN KEY (workspace_id, source_asset_id) REFERENCES media_assets(workspace_id, id) ON DELETE RESTRICT, + FOREIGN KEY (workspace_id, output_asset_id) REFERENCES media_assets(workspace_id, id) ON DELETE RESTRICT, + FOREIGN KEY (workspace_id, watermark_template_id, watermark_template_version) + REFERENCES watermark_template_versions(workspace_id, template_id, version) ON DELETE RESTRICT, + UNIQUE (workspace_id, id), + CHECK ((status = 'succeeded') = (output_asset_id IS NOT NULL)), + CHECK (status <> 'failed' OR error_code IS NOT NULL) +); +CREATE INDEX ix_media_watermark_derivatives_source + ON media_watermark_derivatives (workspace_id, source_asset_id, created_at DESC); + +ALTER TABLE media_watermark_derivatives ENABLE ROW LEVEL SECURITY; +ALTER TABLE media_watermark_derivatives FORCE ROW LEVEL SECURITY; +CREATE POLICY workspace_isolation ON media_watermark_derivatives + USING (workspace_id = app_current_workspace_id()) + WITH CHECK (workspace_id = app_current_workspace_id()); diff --git a/packages/database/migrations/037_watermark_image_size_min_10.sql b/packages/database/migrations/037_watermark_image_size_min_10.sql new file mode 100644 index 0000000..afa2b7e --- /dev/null +++ b/packages/database/migrations/037_watermark_image_size_min_10.sql @@ -0,0 +1,9 @@ +SET search_path TO atlas, public; + +-- 图片水印最小最长边从 20px 调整为 10px;已有模板数据保持不变。 +ALTER TABLE watermark_template_versions + DROP CONSTRAINT IF EXISTS watermark_template_versions_image_size_check; + +ALTER TABLE watermark_template_versions + ADD CONSTRAINT watermark_template_versions_image_size_check + CHECK (image_size_px IS NULL OR image_size_px BETWEEN 10 AND 200); diff --git a/packages/database/migrations/038_watermark_default.sql b/packages/database/migrations/038_watermark_default.sql new file mode 100644 index 0000000..69d56d3 --- /dev/null +++ b/packages/database/migrations/038_watermark_default.sql @@ -0,0 +1,8 @@ +SET search_path TO atlas, public; + +ALTER TABLE watermark_templates + ADD COLUMN IF NOT EXISTS is_default BOOLEAN NOT NULL DEFAULT false; + +CREATE UNIQUE INDEX IF NOT EXISTS ux_watermark_templates_workspace_default + ON watermark_templates (workspace_id) + WHERE is_default AND status = 'active' AND deleted_at IS NULL; diff --git a/packages/database/migrations/039_media_ai_edits.sql b/packages/database/migrations/039_media_ai_edits.sql new file mode 100644 index 0000000..e809296 --- /dev/null +++ b/packages/database/migrations/039_media_ai_edits.sql @@ -0,0 +1,32 @@ +SET search_path TO atlas, public; + +-- 单张 AI 编辑保存素材 ID 和编辑参数;供应商任务 ID 只在工作空间内可见。 +CREATE TABLE media_ai_edits ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + workspace_id UUID NOT NULL REFERENCES workspaces(id) ON DELETE RESTRICT, + source_asset_id UUID NOT NULL, + reference_asset_ids UUID[] NOT NULL DEFAULT '{}', + settings JSONB NOT NULL, + provider_task_id TEXT, + output_asset_id UUID, + status TEXT NOT NULL DEFAULT 'queued' + CHECK (status IN ('queued', 'running', 'succeeded', 'failed', 'cancelled')), + error_code TEXT, + created_by_user_id UUID REFERENCES users(id) ON DELETE SET NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + started_at TIMESTAMPTZ, + completed_at TIMESTAMPTZ, + FOREIGN KEY (workspace_id, source_asset_id) REFERENCES media_assets(workspace_id, id) ON DELETE RESTRICT, + FOREIGN KEY (workspace_id, output_asset_id) REFERENCES media_assets(workspace_id, id) ON DELETE RESTRICT, + UNIQUE (workspace_id, id), + CHECK (cardinality(reference_asset_ids) <= 2), + CHECK ((status = 'succeeded') = (output_asset_id IS NOT NULL)), + CHECK (status <> 'failed' OR error_code IS NOT NULL) +); +CREATE INDEX ix_media_ai_edits_source ON media_ai_edits (workspace_id, source_asset_id, created_at DESC); + +ALTER TABLE media_ai_edits ENABLE ROW LEVEL SECURITY; +ALTER TABLE media_ai_edits FORCE ROW LEVEL SECURITY; +CREATE POLICY workspace_isolation ON media_ai_edits + USING (workspace_id = app_current_workspace_id()) + WITH CHECK (workspace_id = app_current_workspace_id()); diff --git a/packages/database/migrations/040_media_ai_edit_product_scope.sql b/packages/database/migrations/040_media_ai_edit_product_scope.sql new file mode 100644 index 0000000..b2e95d2 --- /dev/null +++ b/packages/database/migrations/040_media_ai_edit_product_scope.sql @@ -0,0 +1,10 @@ +SET search_path TO atlas, public; + +-- AI 生图记录按商品归档;旧任务若没有关联商品,不出现在商品素材库中。 +ALTER TABLE media_ai_edits + ADD COLUMN product_id UUID, + ADD CONSTRAINT media_ai_edits_product_fk + FOREIGN KEY (workspace_id, product_id) REFERENCES products(workspace_id, id) ON DELETE RESTRICT; +CREATE INDEX ix_media_ai_edits_product + ON media_ai_edits (workspace_id, product_id, completed_at DESC) + WHERE status = 'succeeded'; diff --git a/packages/database/migrations/041_online_product_sync.sql b/packages/database/migrations/041_online_product_sync.sql new file mode 100644 index 0000000..24d067b --- /dev/null +++ b/packages/database/migrations/041_online_product_sync.sql @@ -0,0 +1,89 @@ +SET search_path TO atlas, public; + +CREATE TYPE platform_listing_source AS ENUM ('atlas_published', 'external_sync'); +CREATE TYPE platform_sync_status AS ENUM ('queued', 'running', 'succeeded', 'failed', 'cancelled'); + +CREATE TABLE platform_product_sync_runs ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + workspace_id UUID NOT NULL REFERENCES workspaces(id) ON DELETE RESTRICT, + store_id UUID NOT NULL, + platform_id UUID NOT NULL, + status platform_sync_status NOT NULL DEFAULT 'queued', + trigger_source TEXT NOT NULL CHECK (trigger_source IN ('manual', 'scheduled', 'recovery')), + cursor_started TEXT, + cursor_finished TEXT, + listed_count INTEGER NOT NULL DEFAULT 0 CHECK (listed_count >= 0), + upserted_count INTEGER NOT NULL DEFAULT 0 CHECK (upserted_count >= 0), + failed_count INTEGER NOT NULL DEFAULT 0 CHECK (failed_count >= 0), + error_payload JSONB, + started_at TIMESTAMPTZ, + completed_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + FOREIGN KEY (workspace_id, store_id, platform_id) REFERENCES stores(workspace_id, id, platform_id) ON DELETE RESTRICT, + UNIQUE (workspace_id, id) +); +CREATE INDEX ix_platform_product_sync_runs_store ON platform_product_sync_runs (workspace_id, store_id, created_at DESC, id DESC); +CREATE UNIQUE INDEX ux_platform_product_sync_runs_active ON platform_product_sync_runs (workspace_id, store_id) WHERE status IN ('queued', 'running'); + +CREATE TABLE platform_listings ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + workspace_id UUID NOT NULL REFERENCES workspaces(id) ON DELETE RESTRICT, + store_id UUID NOT NULL, + platform_id UUID NOT NULL, + remote_product_id TEXT NOT NULL, + offer_id TEXT NOT NULL, + remote_sku TEXT, + name TEXT, + currency_code CHAR(3) REFERENCES currencies(code), + selling_price NUMERIC(18,4) CHECK (selling_price IS NULL OR selling_price >= 0), + list_price NUMERIC(18,4) CHECK (list_price IS NULL OR list_price >= 0), + visible BOOLEAN, + remote_state TEXT, + moderation_status TEXT, + validation_status TEXT, + stock_summary JSONB NOT NULL DEFAULT '{}'::jsonb, + image_summary JSONB NOT NULL DEFAULT '{}'::jsonb, + error_payload JSONB NOT NULL DEFAULT '[]'::jsonb, + visibility_details JSONB NOT NULL DEFAULT '{}'::jsonb, + source platform_listing_source NOT NULL DEFAULT 'external_sync', + atlas_variant_listing_id UUID, + last_sync_run_id UUID, + last_synced_at TIMESTAMPTZ NOT NULL, + missing_since TIMESTAMPTZ, + archived_at TIMESTAMPTZ, + raw_payload JSONB NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + FOREIGN KEY (workspace_id, store_id, platform_id) REFERENCES stores(workspace_id, id, platform_id) ON DELETE RESTRICT, + FOREIGN KEY (workspace_id, atlas_variant_listing_id) REFERENCES product_variant_platform_listings(workspace_id, id) ON DELETE SET NULL, + FOREIGN KEY (workspace_id, last_sync_run_id) REFERENCES platform_product_sync_runs(workspace_id, id) ON DELETE SET NULL, + UNIQUE (workspace_id, id), + UNIQUE (workspace_id, store_id, platform_id, remote_product_id), + UNIQUE (workspace_id, store_id, platform_id, offer_id) +); +CREATE INDEX ix_platform_listings_store_list ON platform_listings (workspace_id, store_id, updated_at DESC, id DESC); +CREATE INDEX ix_platform_listings_store_visibility ON platform_listings (workspace_id, store_id, visible, updated_at DESC); +CREATE INDEX ix_platform_listings_atlas_mapping ON platform_listings (workspace_id, atlas_variant_listing_id) WHERE atlas_variant_listing_id IS NOT NULL; + +CREATE TABLE platform_listing_sync_snapshots ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + workspace_id UUID NOT NULL REFERENCES workspaces(id) ON DELETE RESTRICT, + platform_listing_id UUID NOT NULL, + sync_run_id UUID NOT NULL, + payload JSONB NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + FOREIGN KEY (workspace_id, platform_listing_id) REFERENCES platform_listings(workspace_id, id) ON DELETE CASCADE, + FOREIGN KEY (workspace_id, sync_run_id) REFERENCES platform_product_sync_runs(workspace_id, id) ON DELETE CASCADE, + UNIQUE (workspace_id, platform_listing_id, sync_run_id) +); + +-- 商品镜像、同步运行和响应快照均强制租户隔离,Worker 也必须在租户事务中访问。 +DO $$ +DECLARE table_name TEXT; +BEGIN + FOREACH table_name IN ARRAY ARRAY['platform_product_sync_runs', 'platform_listings', 'platform_listing_sync_snapshots'] LOOP + EXECUTE format('ALTER TABLE %I ENABLE ROW LEVEL SECURITY', table_name); + EXECUTE format('ALTER TABLE %I FORCE ROW LEVEL SECURITY', table_name); + EXECUTE format('CREATE POLICY workspace_isolation ON %I USING (workspace_id = app_current_workspace_id()) WITH CHECK (workspace_id = app_current_workspace_id())', table_name); + END LOOP; +END $$; diff --git a/packages/database/migrations/042_product_image_derivative_history.sql b/packages/database/migrations/042_product_image_derivative_history.sql new file mode 100644 index 0000000..1a7be84 --- /dev/null +++ b/packages/database/migrations/042_product_image_derivative_history.sql @@ -0,0 +1,18 @@ +SET search_path TO atlas, public; + +-- 派生记录绑定商品,供选图弹窗找回未加入 SKU 草稿的处理结果。 +ALTER TABLE media_aspect_ratio_derivatives + ADD COLUMN product_id UUID, + ADD CONSTRAINT media_aspect_ratio_derivatives_product_fk + FOREIGN KEY (workspace_id, product_id) REFERENCES products(workspace_id, id) ON DELETE RESTRICT; +CREATE INDEX ix_media_aspect_ratio_derivatives_product + ON media_aspect_ratio_derivatives (workspace_id, product_id, completed_at DESC) + WHERE status = 'succeeded'; + +ALTER TABLE media_watermark_derivatives + ADD COLUMN product_id UUID, + ADD CONSTRAINT media_watermark_derivatives_product_fk + FOREIGN KEY (workspace_id, product_id) REFERENCES products(workspace_id, id) ON DELETE RESTRICT; +CREATE INDEX ix_media_watermark_derivatives_product + ON media_watermark_derivatives (workspace_id, product_id, completed_at DESC) + WHERE status = 'succeeded'; diff --git a/packages/reference/1688.html b/packages/reference/1688.html new file mode 100644 index 0000000..e97c7f0 --- /dev/null +++ b/packages/reference/1688.html @@ -0,0 +1,169 @@ + + + + + + + + + + + 火影忍者手办童年鸣人佐助可换脸组立超大晓组织鼬模型摆件动漫 - 阿里巴巴 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ + + +
+ +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cd04948..eb4d67d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -176,6 +176,9 @@ importers: ioredis: specifier: 6.0.0 version: 6.0.0 + sharp: + specifier: 0.35.4 + version: 0.35.4(@types/node@26.3.0) devDependencies: '@types/node': specifier: 26.3.0 @@ -447,6 +450,9 @@ packages: '@borewit/text-codec@0.2.2': resolution: {integrity: sha512-DDaRehssg1aNrH4+2hnj1B7vnUGEjU6OIlyRdkMd0aUdIUvKXrJfXsy8LVtXAy7DRvYVluWbMspsRhz2lcW0mQ==} + '@emnapi/runtime@1.11.3': + resolution: {integrity: sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==} + '@emotion/hash@0.8.0': resolution: {integrity: sha512-kBJtf7PH6aWwZ6fka3zQ0p6SBYzx4fl1LoZXE2RrnYST9Xljm7WfKJrU4g/Xr3Beg72MLrp1AWNUmuYJTL7Cow==} @@ -609,6 +615,168 @@ packages: cpu: [x64] os: [win32] + '@img/colour@1.1.0': + resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==} + engines: {node: '>=18'} + + '@img/sharp-darwin-arm64@0.35.4': + resolution: {integrity: sha512-Uhfl4V4lhP2nbUVF9+hyH1+luj86f1gUFeo8ALYxFoULoU+G87D43BfeMP8XHsk9boxAnCY/bf2EHwhA7MuGsA==} + engines: {node: '>=20.9.0'} + cpu: [arm64] + os: [darwin] + + '@img/sharp-darwin-x64@0.35.4': + resolution: {integrity: sha512-hWniXY3bG5qKpkKrAwPe4y+VTPmf086YQAnkxWh7uA1YrlRouWGa0M0Mxj3ZjnXFkv7/TD1bTy9lGUK26vRvWw==} + engines: {node: '>=20.9.0'} + cpu: [x64] + os: [darwin] + + '@img/sharp-freebsd-wasm32@0.35.4': + resolution: {integrity: sha512-lIsKw/BU+kjB4eZjxrYrZmwOJYi3Ajrv66iAlBmUPyKc3HpnloevB1g3wxGD9P/5BbQ1brBGl65VRRrCvQDEqA==} + engines: {node: '>=20.9.0'} + os: [freebsd] + + '@img/sharp-libvips-darwin-arm64@1.3.3': + resolution: {integrity: sha512-suTBPTDGrI9WodccaDdwZItTSaBYASlBk1NSfElSHrUfzu3szG6lvIF58+WiFvnfzuK8ZBFS5zE00PxqxnRiPg==} + cpu: [arm64] + os: [darwin] + + '@img/sharp-libvips-darwin-x64@1.3.3': + resolution: {integrity: sha512-FVJZ5mITMobmXIz/hPDTw0EintTW5H3WfrxwLqEqjiIihlu+hVRyGrFQ60xl0Lxn7Bt3zdpevPaQi0HEzqz9fw==} + cpu: [x64] + os: [darwin] + + '@img/sharp-libvips-linux-arm64@1.3.3': + resolution: {integrity: sha512-0DaL0A6Xu6sQSQFwe4iVCrKWU2cCTItnRsYsCdxAMm9NF6twAA9BKnoqy4hqz4+azQ0JHuA26qiUKsf1XJ/v5A==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-arm@1.3.3': + resolution: {integrity: sha512-3rbU4vqXXc3hY/OiXdl52xZvT0F1yEngWfvqudtPJg/KkyiaQw2DRsFrNzpmLvfavbwOq3qXn36GP8obHRULQA==} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-ppc64@1.3.3': + resolution: {integrity: sha512-cdn1OvUBwsXhbC0zSzJnNzf5MZ/mTrobawDvNXBTxe8VtqKAm0sRuEY2Evzovb/w9JMk4TvRxqt1mekSuJz64w==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-riscv64@1.3.3': + resolution: {integrity: sha512-HjPVx7yKz+0lqdhDlTw1tt90wamBoxhiXpvl1XZpJLiHH4RCJ5yDTqH+VlYPv2fwFs89JFw4c1IexYOcQUi4IQ==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-s390x@1.3.3': + resolution: {integrity: sha512-neWLh+3yCNThxnfy3c4BbVBeGgt9aftno+XbT56iK28RgeDs3UOFWviLWlUu0bArYVYJaFDK+RRohbicUNCm8Q==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-x64@1.3.3': + resolution: {integrity: sha512-4vKmvAst9nrowcqquKFAyZJUDolUaIp8uRiN0mWFguJ1IplC9/pitXtlnnlU4aa/eJw3J7i67V+pwUL+wZGdsA==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linuxmusl-arm64@1.3.3': + resolution: {integrity: sha512-Y9kQaLMuNoB0bPYOOdcZMaseNrFpPodIWWMrx+CZyydf2xn68j9WYc6sWWRrDwNkzCQjKYfc68L7jKjGlHMibw==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@img/sharp-libvips-linuxmusl-x64@1.3.3': + resolution: {integrity: sha512-fj8Mv0HHfD1Rr+4I68+3agJynxDWtBFgicTbSOb9Bke6pIwzGcJ+RX/yHjmiEGFMCavY/dxvem7MyNaJF+wDiw==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@img/sharp-linux-arm64@0.35.4': + resolution: {integrity: sha512-De4jpEnAU8Hd5oT0j1G3uL4ZvTuipVMn7YC6vPaJhy6/7EwEae0SVAoBrUMYQbkLGDm85taVWwuPc1a44LTzCQ==} + engines: {node: '>=20.9.0'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-arm@0.35.4': + resolution: {integrity: sha512-7OAS8gI0EReKGVN2HssHlM6umJgxF5VI3xN0p9FA91p/YO+ou5hiNghLdZ5BEHztwaaK5+bLKRf8x/o2L2nk9A==} + engines: {node: '>=20.9.0'} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-ppc64@0.35.4': + resolution: {integrity: sha512-2oYZJeIl4kCcMGk4ouZVjnkCtFrpQFlNEtJ6GbxzhHQchwH0NH/qEb9ykmOl29dqwMq+JhFdZn+1ak2FKhI9fQ==} + engines: {node: '>=20.9.0'} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-riscv64@0.35.4': + resolution: {integrity: sha512-cPbNChoRURAWdebDIHSenxRpgEdy7JkPydSnUxRm9VvKD7m0/xVaR/8Fzlu81pk5nHEvHH87UZUA7cTtwnbJSA==} + engines: {node: '>=20.9.0'} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-s390x@0.35.4': + resolution: {integrity: sha512-RY0JFY8Fd6RonCBtHz+DvadaPkXDSI1AUn6yWL9TipqkZ1vY8w8evqdgyDFnkm4/K1ve1TvZiaePP5oSd4+WVQ==} + engines: {node: '>=20.9.0'} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-x64@0.35.4': + resolution: {integrity: sha512-9qvvEAuk8k89TfWUoX2htWjbAMX8p+NxCppjpcg5k6xMsjhBQPTsoIh36h9Qde4WRuGpJeYnOjdosDn/cnv+OA==} + engines: {node: '>=20.9.0'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@img/sharp-linuxmusl-arm64@0.35.4': + resolution: {integrity: sha512-KB5jxpfWQTr0nc3xdHtWChdbifHrBGsd2SM62Eyxrl8afikm+f5qGBU75SJIZBT/S1MC8XyacdlXBMSWq6OURA==} + engines: {node: '>=20.9.0'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@img/sharp-linuxmusl-x64@0.35.4': + resolution: {integrity: sha512-f+eZJZIQNEEd26RPSW+76chwOf1XtA2Y/O+5ocVyLliHkeih3e+jhLVBdNTd2rS3IbNXK8+ug93Vf5ZXtF5Lxg==} + engines: {node: '>=20.9.0'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@img/sharp-wasm32@0.35.4': + resolution: {integrity: sha512-zQnl4Kwp7Q6NHsENtU2T/00Zi+w3AQNwz3+UaTyVBy2FpXrzXzGjndpK61onhZjRtRpQXxCTeqw19bVyXOh7jA==} + engines: {node: '>=20.9.0'} + + '@img/sharp-webcontainers-wasm32@0.35.4': + resolution: {integrity: sha512-ESfNkywmCfPNyaZjxooddJQiQ+l/nTpGEOGthxiLnIHXC/CmcBixnfwUleX9mCz9ovrUUvKMap/pm8RYbzfwaA==} + engines: {node: '>=20.9.0'} + cpu: [wasm32] + + '@img/sharp-win32-arm64@0.35.4': + resolution: {integrity: sha512-iNdlBX9gLVvqe2I3uIJSIKTq6wckP/DYxZtcqxm09x5Gi24DnFBmPAWZmr60ZyYMG0xlzo6goG3670ar+RXvRw==} + engines: {node: '>=20.9.0'} + cpu: [arm64] + os: [win32] + + '@img/sharp-win32-ia32@0.35.4': + resolution: {integrity: sha512-kqRsbaa5CS6KHlpxnN7WhE6vAAugXyZButpRdvDWetlv6Qv4N9WTcrWzF7tXfB9T7MsoadqdI8hmwLq6UlLvtw==} + engines: {node: ^20.9.0} + cpu: [ia32] + os: [win32] + + '@img/sharp-win32-x64@0.35.4': + resolution: {integrity: sha512-XtmnYhBcrORsJ4XJngyzr/EWP0hRZLAZRFaApdKuviyqF78+ylxh2y06ZmtULAMOnObJ3ucpN0AcwSWnMowTRg==} + engines: {node: '>=20.9.0'} + cpu: [x64] + os: [win32] + '@ioredis/commands@2.0.0': resolution: {integrity: sha512-vrx0AE/T0h7cRZwfo1M39Cr+ZhZrkf0V8mQN75wucKCxCLD9l/VX6no3gFvrLqD1IlG/1LtzWovqEw3t0Vr9zg==} @@ -2536,6 +2704,15 @@ packages: setprototypeof@1.2.0: resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} + sharp@0.35.4: + resolution: {integrity: sha512-n++8XWcj+jCOr2IOl7h8LbKnGBDY4aPbmprMONBNFdn0ImXqpGVv5zliDs0V9HbmbCQLpbuo2ej9rAoOQTvMDA==} + engines: {node: '>=20.9.0'} + peerDependencies: + '@types/node': '*' + peerDependenciesMeta: + '@types/node': + optional: true + side-channel-list@1.0.1: resolution: {integrity: sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==} engines: {node: '>= 0.4'} @@ -3299,6 +3476,11 @@ snapshots: '@borewit/text-codec@0.2.2': {} + '@emnapi/runtime@1.11.3': + dependencies: + tslib: 2.8.1 + optional: true + '@emotion/hash@0.8.0': {} '@emotion/unitless@0.7.5': {} @@ -3381,6 +3563,112 @@ snapshots: '@esbuild/win32-x64@0.28.2': optional: true + '@img/colour@1.1.0': {} + + '@img/sharp-darwin-arm64@0.35.4': + optionalDependencies: + '@img/sharp-libvips-darwin-arm64': 1.3.3 + optional: true + + '@img/sharp-darwin-x64@0.35.4': + optionalDependencies: + '@img/sharp-libvips-darwin-x64': 1.3.3 + optional: true + + '@img/sharp-freebsd-wasm32@0.35.4': + dependencies: + '@img/sharp-wasm32': 0.35.4 + optional: true + + '@img/sharp-libvips-darwin-arm64@1.3.3': + optional: true + + '@img/sharp-libvips-darwin-x64@1.3.3': + optional: true + + '@img/sharp-libvips-linux-arm64@1.3.3': + optional: true + + '@img/sharp-libvips-linux-arm@1.3.3': + optional: true + + '@img/sharp-libvips-linux-ppc64@1.3.3': + optional: true + + '@img/sharp-libvips-linux-riscv64@1.3.3': + optional: true + + '@img/sharp-libvips-linux-s390x@1.3.3': + optional: true + + '@img/sharp-libvips-linux-x64@1.3.3': + optional: true + + '@img/sharp-libvips-linuxmusl-arm64@1.3.3': + optional: true + + '@img/sharp-libvips-linuxmusl-x64@1.3.3': + optional: true + + '@img/sharp-linux-arm64@0.35.4': + optionalDependencies: + '@img/sharp-libvips-linux-arm64': 1.3.3 + optional: true + + '@img/sharp-linux-arm@0.35.4': + optionalDependencies: + '@img/sharp-libvips-linux-arm': 1.3.3 + optional: true + + '@img/sharp-linux-ppc64@0.35.4': + optionalDependencies: + '@img/sharp-libvips-linux-ppc64': 1.3.3 + optional: true + + '@img/sharp-linux-riscv64@0.35.4': + optionalDependencies: + '@img/sharp-libvips-linux-riscv64': 1.3.3 + optional: true + + '@img/sharp-linux-s390x@0.35.4': + optionalDependencies: + '@img/sharp-libvips-linux-s390x': 1.3.3 + optional: true + + '@img/sharp-linux-x64@0.35.4': + optionalDependencies: + '@img/sharp-libvips-linux-x64': 1.3.3 + optional: true + + '@img/sharp-linuxmusl-arm64@0.35.4': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-arm64': 1.3.3 + optional: true + + '@img/sharp-linuxmusl-x64@0.35.4': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-x64': 1.3.3 + optional: true + + '@img/sharp-wasm32@0.35.4': + dependencies: + '@emnapi/runtime': 1.11.3 + optional: true + + '@img/sharp-webcontainers-wasm32@0.35.4': + dependencies: + '@img/sharp-wasm32': 0.35.4 + optional: true + + '@img/sharp-win32-arm64@0.35.4': + optional: true + + '@img/sharp-win32-ia32@0.35.4': + optional: true + + '@img/sharp-win32-x64@0.35.4': + optional: true + '@ioredis/commands@2.0.0': {} '@jridgewell/gen-mapping@0.3.13': @@ -5319,6 +5607,39 @@ snapshots: setprototypeof@1.2.0: {} + sharp@0.35.4(@types/node@26.3.0): + dependencies: + '@img/colour': 1.1.0 + detect-libc: 2.1.2 + semver: 7.8.5 + optionalDependencies: + '@img/sharp-darwin-arm64': 0.35.4 + '@img/sharp-darwin-x64': 0.35.4 + '@img/sharp-freebsd-wasm32': 0.35.4 + '@img/sharp-libvips-darwin-arm64': 1.3.3 + '@img/sharp-libvips-darwin-x64': 1.3.3 + '@img/sharp-libvips-linux-arm': 1.3.3 + '@img/sharp-libvips-linux-arm64': 1.3.3 + '@img/sharp-libvips-linux-ppc64': 1.3.3 + '@img/sharp-libvips-linux-riscv64': 1.3.3 + '@img/sharp-libvips-linux-s390x': 1.3.3 + '@img/sharp-libvips-linux-x64': 1.3.3 + '@img/sharp-libvips-linuxmusl-arm64': 1.3.3 + '@img/sharp-libvips-linuxmusl-x64': 1.3.3 + '@img/sharp-linux-arm': 0.35.4 + '@img/sharp-linux-arm64': 0.35.4 + '@img/sharp-linux-ppc64': 0.35.4 + '@img/sharp-linux-riscv64': 0.35.4 + '@img/sharp-linux-s390x': 0.35.4 + '@img/sharp-linux-x64': 0.35.4 + '@img/sharp-linuxmusl-arm64': 0.35.4 + '@img/sharp-linuxmusl-x64': 0.35.4 + '@img/sharp-webcontainers-wasm32': 0.35.4 + '@img/sharp-win32-arm64': 0.35.4 + '@img/sharp-win32-ia32': 0.35.4 + '@img/sharp-win32-x64': 0.35.4 + '@types/node': 26.3.0 + side-channel-list@1.0.1: dependencies: es-errors: 1.3.0