From e20cd7547fbded3451dfe692d04aecd6e646cccd Mon Sep 17 00:00:00 2001 From: joey-home Date: Sun, 4 Oct 2026 20:47:47 +0800 Subject: [PATCH] =?UTF-8?q?feat(edit):=20AI=E5=A5=97=E5=9B=BE=E7=94=9F?= =?UTF-8?q?=E6=88=90=E3=80=81=E5=9B=BE=E7=89=87=E5=88=87=E4=B8=83=E7=89=9B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 1 + apps/api/src/config/app-config.module.ts | 36 + apps/api/src/media/image-suite.service.ts | 441 ++++++ apps/api/src/media/media.controller.ts | 42 + apps/api/src/media/media.module.ts | 3 +- apps/api/src/media/media.service.ts | 113 +- .../src/media/product-image-task.service.ts | 49 +- .../product-editor/product-editor.publish.ts | 13 +- .../product-editor.validation.ts | 45 +- apps/api/test/image-suite.database.test.ts | 328 ++++ apps/api/test/image-suite.unit.test.ts | 291 ++++ apps/api/test/media-import.unit.test.ts | 4 +- .../api/test/media-product-image.unit.test.ts | 81 + apps/api/test/pricing-engine.unit.test.ts | 9 + apps/api/test/product-publish.e2e.test.ts | 223 ++- apps/web/HANDOFF.md | 70 +- .../components/pricing/PricingCalculator.tsx | 34 +- .../web/src/components/pricing/SkuPricing.tsx | 101 +- .../src/pages/products/ProductEditPage.tsx | 74 +- .../components/DynamicAttributeFields.tsx | 4 +- .../components/ImageSuiteReferencePicker.tsx | 303 ++++ .../components/ProductAttributeSection.tsx | 14 +- .../components/ProductImageSuiteActions.tsx | 161 ++ .../components/ProductImageSuiteDrawer.css | 518 +++++++ .../components/ProductImageSuiteDrawer.tsx | 1321 +++++++++++++++++ .../components/ProductImageTaskCenter.tsx | 12 +- .../components/ProductPublishActions.tsx | 39 +- .../components/ProductVariantSection.css | 20 +- .../components/ProductVariantSection.tsx | 36 +- .../products/components/SkuImageCard.tsx | 20 +- .../components/SkuImageEditorModal.tsx | 4 +- .../components/SkuImageSourceTabs.tsx | 4 +- .../products/components/SkuInfoPanel.tsx | 217 +-- .../components/imageSuiteDimensions.ts | 78 + .../products/components/imageSuiteStrategy.ts | 102 ++ .../products/components/imageSuiteWeight.ts | 40 + apps/web/src/services/imageSuites.ts | 20 + apps/worker/src/config.ts | 1 + apps/worker/src/image-suite-plan.ts | 370 +++++ apps/worker/src/main.ts | 7 +- apps/worker/src/media-ai-edit.ts | 39 +- apps/worker/src/media-ai-prompt.ts | 16 +- apps/worker/src/product-ai.ts | 18 +- apps/worker/test/image-suite-plan.test.mjs | 494 ++++++ apps/worker/test/media-ai-edit.test.mjs | 3 +- apps/worker/test/product-ai.test.mjs | 73 +- docs/api/pricing.md | 3 + docs/api/product-editor-and-publishing.md | 12 +- docs/api/product-image-suites.md | 41 + .../qiniu-https-auto-renewal-tencent-cloud.md | 142 +- docs/product/product-ai-editor.md | 11 +- docs/project/HANDOFF.md | 46 +- packages/contracts/src/image-suite.ts | 184 +++ packages/contracts/src/index.ts | 2 + packages/contracts/src/media.ts | 15 +- packages/contracts/src/ozon-hashtags.ts | 32 + .../migrations/056_product_image_suites.sql | 101 ++ .../057_image_suite_retry_guards.sql | 35 + .../058_image_suite_trigger_search_path.sql | 7 + .../059_image_suite_operation_status.sql | 30 + 60 files changed, 6241 insertions(+), 312 deletions(-) create mode 100644 apps/api/src/media/image-suite.service.ts create mode 100644 apps/api/test/image-suite.database.test.ts create mode 100644 apps/api/test/image-suite.unit.test.ts create mode 100644 apps/web/src/pages/products/components/ImageSuiteReferencePicker.tsx create mode 100644 apps/web/src/pages/products/components/ProductImageSuiteActions.tsx create mode 100644 apps/web/src/pages/products/components/ProductImageSuiteDrawer.css create mode 100644 apps/web/src/pages/products/components/ProductImageSuiteDrawer.tsx create mode 100644 apps/web/src/pages/products/components/imageSuiteDimensions.ts create mode 100644 apps/web/src/pages/products/components/imageSuiteStrategy.ts create mode 100644 apps/web/src/pages/products/components/imageSuiteWeight.ts create mode 100644 apps/web/src/services/imageSuites.ts create mode 100644 apps/worker/src/image-suite-plan.ts create mode 100644 apps/worker/test/image-suite-plan.test.mjs create mode 100644 docs/api/product-image-suites.md create mode 100644 packages/contracts/src/image-suite.ts create mode 100644 packages/contracts/src/ozon-hashtags.ts create mode 100644 packages/database/migrations/056_product_image_suites.sql create mode 100644 packages/database/migrations/057_image_suite_retry_guards.sql create mode 100644 packages/database/migrations/058_image_suite_trigger_search_path.sql create mode 100644 packages/database/migrations/059_image_suite_operation_status.sql diff --git a/.env.example b/.env.example index 4584363..6c3735d 100644 --- a/.env.example +++ b/.env.example @@ -99,6 +99,7 @@ OZON_PUBLISH_ENABLED=auto # 与素材实际入库环境一致,生产设置 qiniu_kodo。 OBJECT_STORAGE_PROVIDER=local_minio # 七牛绑定的公网 HTTPS 下载域名,与 S3 服务 endpoint 分离。 +# 七牛模式下,API 图片预览、下载和 Ozon 交付共用此 HTTPS 图片域名。 OZON_IMAGE_DOWNLOAD_ORIGIN= # 图片签名初始有效期 7 天;生产值须通过异步/延迟抓图验收。 OZON_IMAGE_URL_TTL_SECONDS=604800 diff --git a/apps/api/src/config/app-config.module.ts b/apps/api/src/config/app-config.module.ts index fd4041c..13a47b2 100644 --- a/apps/api/src/config/app-config.module.ts +++ b/apps/api/src/config/app-config.module.ts @@ -33,10 +33,24 @@ const environmentSchema = z.object({ DATABASE_URL: z.url(), REDIS_URL: z.url(), OBJECT_STORAGE_ENDPOINT: z.url().default('http://localhost:9000'), + OBJECT_STORAGE_REGION: z.string().min(1).default('us-east-1'), OBJECT_STORAGE_BUCKET: z.string().min(3).max(63).default('atlas-assets'), OBJECT_STORAGE_FORCE_PATH_STYLE: z.enum(['true', 'false']).default('true'), OBJECT_STORAGE_ACCESS_KEY: z.string().min(3).optional(), OBJECT_STORAGE_SECRET_KEY: z.string().min(8).optional(), + OZON_IMAGE_DOWNLOAD_ORIGIN: z.preprocess( + (value) => value === '' ? undefined : value, + z.url().optional(), + ), + QINIU_ACCESS_KEY: z.preprocess( + (value) => value === '' ? undefined : value, + z.string().min(3).optional(), + ), + QINIU_SECRET_KEY: z.preprocess( + (value) => value === '' ? undefined : value, + z.string().min(8).optional(), + ), + LEGACY_MINIO_ENDPOINT: z.url().default('http://localhost:9000'), MINIO_ROOT_USER: z.string().min(3).optional(), MINIO_ROOT_PASSWORD: z.string().min(8).optional(), AUTH_JWT_SECRET: z.string().min(32), @@ -114,6 +128,9 @@ export type AppConfig = Omit< objectStorageAccessKey: string; objectStorageSecretKey: string; objectStorageForcePathStyle: boolean; + qiniuDownloadOrigin: string | null; + qiniuDownloadAccessKey: string; + qiniuDownloadSecretKey: string; pricingAllowDraftRates: boolean; }; @@ -139,6 +156,22 @@ function createAppConfig(environment: Environment): AppConfig { 'OBJECT_STORAGE_ACCESS_KEY and OBJECT_STORAGE_SECRET_KEY are required', ); } + const qiniuDownloadOrigin = environment.OZON_IMAGE_DOWNLOAD_ORIGIN ?? null; + if (environment.OBJECT_STORAGE_PROVIDER === 'qiniu_kodo') { + if (!qiniuDownloadOrigin) { + throw new Error('OZON_IMAGE_DOWNLOAD_ORIGIN is required for Qiniu image previews'); + } + const origin = new URL(qiniuDownloadOrigin); + if ( + origin.protocol !== 'https:' || origin.username || origin.password || + origin.search || origin.hash || origin.pathname !== '/' + ) { + throw new Error('OZON_IMAGE_DOWNLOAD_ORIGIN must be an HTTPS origin'); + } + } + if (Boolean(environment.QINIU_ACCESS_KEY) !== Boolean(environment.QINIU_SECRET_KEY)) { + throw new Error('QINIU_ACCESS_KEY and QINIU_SECRET_KEY must be configured together'); + } return { ...environment, // 只有显式给出代理网段时才信任转发头,避免客户端伪造来源 IP。 @@ -148,6 +181,9 @@ function createAppConfig(environment: Environment): AppConfig { swaggerEnabled: environment.SWAGGER_ENABLED === 'true', objectStorageAccessKey, objectStorageSecretKey, + qiniuDownloadOrigin, + qiniuDownloadAccessKey: environment.QINIU_ACCESS_KEY ?? objectStorageAccessKey, + qiniuDownloadSecretKey: environment.QINIU_SECRET_KEY ?? objectStorageSecretKey, objectStorageForcePathStyle: environment.OBJECT_STORAGE_FORCE_PATH_STYLE === 'true', // 草稿费率默认可参与报价;仍可通过环境配置限制新试算。 diff --git a/apps/api/src/media/image-suite.service.ts b/apps/api/src/media/image-suite.service.ts new file mode 100644 index 0000000..7cf5920 --- /dev/null +++ b/apps/api/src/media/image-suite.service.ts @@ -0,0 +1,441 @@ +import { randomUUID } from 'node:crypto'; +import { Inject, Injectable } from '@nestjs/common'; +import { + createImageSuiteSchema, + imageSuitePlanErrorMessages, + createMediaAiEditRequestSchema, + type CreateImageSuiteRequest, + type ImageSuite, + type ImageSuiteInput, + type ImageSuitePlanInput, + type ImageSuiteShot, + type ListProductImageTasksQuery, +} from '@atlas/contracts'; +import type { Database, DatabaseTransaction } from '@atlas/database'; +import { ApiError } from '../common/api-error.js'; +import { IdempotencyService } from '../common/idempotency.service.js'; +import { DATABASE } from '../database/database.module.js'; +import { MediaAiEditService } from './media-ai-edit.service.js'; + +type SuiteRow = Omit< + ImageSuite, + 'items' | 'operation' | 'created_at' | 'completed_at' +> & { created_at: Date | string; completed_at: Date | string | null }; +const iso = (value: Date | string) => + value instanceof Date ? value.toISOString() : value; + +@Injectable() +export class ImageSuiteService { + public constructor( + @Inject(DATABASE) private readonly database: Database, + @Inject(IdempotencyService) + private readonly idempotency: IdempotencyService, + @Inject(MediaAiEditService) private readonly edits: MediaAiEditService, + ) {} + + public async create( + workspaceId: string, + userId: string, + productId: string, + key: string, + request: + ImageSuiteInput | ImageSuitePlanInput | CreateImageSuiteRequest, + purpose: 'plan' | 'generate', + ) { + const path = `/api/products/${productId}/ai/${purpose === 'plan' ? 'suite-plans' : 'image-suites'}`; + return this.idempotency.run( + { workspaceId, userId, method: 'POST', path, key, request }, + async (tx) => { + const variant = await tx.query<{ id: string }>( + 'SELECT id FROM atlas.product_variants WHERE id = $1 AND product_id = $2 AND deleted_at IS NULL', + [request.variant_id, productId], + ); + if (!variant.rows[0]) + throw new ApiError( + 404, + 'RESOURCE_NOT_FOUND', + '目标 SKU 不存在或不属于此商品,请先保存新 SKU', + ); + 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`, + [request.reference_asset_ids], + ); + if (assets.rows.length !== request.reference_asset_ids.length) + throw new ApiError( + 404, + 'RESOURCE_NOT_FOUND', + '参考素材不存在或不可处理', + ); + const shots = + purpose === 'generate' + ? createImageSuiteSchema.parse(request).shots + : []; + const { shots: _shots, ...input } = + request as CreateImageSuiteRequest; + const created = await tx.query( + `INSERT INTO atlas.product_image_suites (workspace_id, product_id, variant_id, purpose, input, shots, created_by_user_id) + VALUES (current_setting('app.workspace_id')::uuid, $1, $2, $3, $4::jsonb, $5::jsonb, $6) RETURNING *`, + [ + productId, + input.variant_id, + purpose, + JSON.stringify(input), + JSON.stringify(shots), + userId, + ], + ); + const row = created.rows[0]; + for (const [ + position, + assetId, + ] of input.reference_asset_ids.entries()) + await tx.query( + `INSERT INTO atlas.product_image_suite_references (workspace_id, suite_id, asset_id, position) + VALUES (current_setting('app.workspace_id')::uuid, $1, $2, $3)`, + [row.id, assetId, position], + ); + await this.operation( + tx, + row.id, + purpose === 'plan' ? 'image_suite_plan' : 'image_suite', + userId, + ); + if (purpose === 'plan') + await this.outbox( + tx, + 'image_suite.plan.requested', + row.id, + { suite_id: row.id }, + ); + else + for (const [index, shot] of shots.entries()) { + const supporting = input.reference_asset_ids.slice(1); + // 默认方案轮换全部辅助底图,避免后面的参考角度始终未被使用。 + const selected = shot.reference_asset_ids ?? [ + input.reference_asset_ids[0], + ...Array.from( + { length: Math.min(2, supporting.length) }, + (_, offset) => + supporting[ + (index + offset) % supporting.length + ], + ), + ]; + const settings = createMediaAiEditRequestSchema.parse({ + product_id: productId, + reference_asset_ids: selected.slice(1), + mode: 'edit', + ratio: input.ratio, + target_language: input.language, + background: + shot.category === 'white' ? 'white' : 'auto', + style_set: input.style_set, + style_prompt: input.style_prompt, + prompt: `${shot.theme}\n${shot.description}\n资料依据:${shot.evidence}`, + suite_context: { + type: shot.type, + product_name: input.product_name, + color: input.color, + dimensions: input.dimensions, + weight: input.weight, + collected_info: input.collected_info, + selling_points: input.selling_points, + }, + }); + const id = randomUUID(); + await tx.query( + `INSERT INTO atlas.media_ai_edits (id, workspace_id, product_id, suite_id, suite_shot_id, suite_position, + source_asset_id, reference_asset_ids, settings, created_by_user_id) + VALUES ($1, current_setting('app.workspace_id')::uuid, $2, $3, $4, $5, $6, $7::uuid[], $8::jsonb, $9)`, + [ + id, + productId, + row.id, + shot.id, + index, + selected[0], + selected.slice(1), + JSON.stringify(settings), + userId, + ], + ); + await this.operation(tx, id, 'media_ai_edit', userId); + await this.outbox(tx, 'media.ai_edit.requested', id, { + edit_id: id, + }); + } + return { status: 202, body: { data: this.resource(row) } }; + }, + ); + } + + public async read( + workspaceId: string, + productId: string, + id: string, + ): Promise { + const { row, children } = await this.database.workspaceTransaction( + workspaceId, + async (tx) => { + const row = await this.find(tx, productId, id); + const children = await tx.query<{ + id: string; + suite_shot_id: string; + }>( + 'SELECT id, suite_shot_id FROM atlas.media_ai_edits WHERE suite_id = $1 ORDER BY suite_position', + [id], + ); + return { row, children: children.rows }; + }, + ); + return { + ...this.resource(row), + items: await Promise.all( + children.map(async (child) => ({ + ...(await this.edits.read(workspaceId, child.id)), + shot_id: child.suite_shot_id, + edit_id: child.id, + })), + ), + }; + } + + public async list( + workspaceId: string, + productId: string, + query: ListProductImageTasksQuery, + ) { + let cursor: { at: string; id: string } | null = null; + if (query.cursor) { + try { + cursor = JSON.parse( + Buffer.from(query.cursor, 'base64url').toString(), + ); + if ( + !cursor || + typeof cursor.at !== 'string' || + typeof cursor.id !== 'string' || + !Number.isFinite(Date.parse(cursor.at)) || + !/^[0-9a-f-]{36}$/i.test(cursor.id) + ) + throw new Error(); + } catch { + throw new ApiError(422, 'VALIDATION_FAILED', '分页游标无效'); + } + } + const rows = await this.database.workspaceTransaction( + workspaceId, + async (tx) => { + const found = await tx.query<{ id: string }>( + 'SELECT id FROM atlas.products WHERE id = $1', + [productId], + ); + if (!found.rows[0]) + throw new ApiError(404, 'RESOURCE_NOT_FOUND', '商品不存在'); + return ( + await tx.query( + `SELECT * FROM atlas.product_image_suites WHERE product_id = $1 + AND ($2::timestamptz IS NULL OR (created_at, id) < ($2::timestamptz, $3::uuid)) + ORDER BY created_at DESC, id DESC LIMIT $4`, + [ + productId, + cursor?.at ?? null, + cursor?.id ?? null, + query.limit + 1, + ], + ) + ).rows; + }, + ); + const page = rows.slice(0, query.limit), + last = page.at(-1), + hasMore = rows.length > query.limit; + return { + data: page.map((row) => this.resource(row)), + meta: { + has_more: hasMore, + next_cursor: + hasMore && last + ? Buffer.from( + JSON.stringify({ + at: iso(last.created_at), + id: last.id, + }), + ).toString('base64url') + : null, + }, + }; + } + + public async action( + workspaceId: string, + userId: string, + productId: string, + id: string, + key: string, + action: 'cancel' | 'retry', + ) { + return this.idempotency.run( + { + workspaceId, + userId, + method: 'POST', + path: `/api/products/${productId}/ai/image-suites/${id}/${action}`, + key, + request: {}, + }, + async (tx) => { + // 与 Worker 一致先锁子任务再锁父任务,避免取消与结果回写形成相反锁序。 + const children = await tx.query<{ id: string; status: string }>( + 'SELECT id, status FROM atlas.media_ai_edits WHERE suite_id = $1 ORDER BY id FOR UPDATE', + [id], + ); + const row = await this.find(tx, productId, id, true); + if (action === 'cancel') { + if (['queued', 'running'].includes(row.status)) { + await tx.query( + "UPDATE atlas.product_image_suites SET status = 'cancelled', completed_at = now() WHERE id = $1", + [id], + ); + await tx.query( + "UPDATE atlas.media_ai_edits SET status = 'cancelled', completed_at = now() WHERE suite_id = $1 AND status IN ('queued', 'running')", + [id], + ); + await tx.query( + "UPDATE atlas.operations SET status = 'cancelled', completed_at = now() WHERE (id = $1 OR id = ANY($2::uuid[])) AND status IN ('queued', 'running')", + [id, children.rows.map((child) => child.id)], + ); + } + } else { + if ( + row.purpose !== 'generate' || + ['queued', 'running'].includes(row.status) + ) + throw new ApiError( + 409, + 'INVALID_TASK_STATE', + '当前任务不能重试,请等待生成完成', + ); + const failed = children.rows.filter((child) => + ['failed', 'cancelled'].includes(child.status), + ); + if (failed.length) { + await tx.query( + "UPDATE atlas.product_image_suites SET status = 'queued', error_code = NULL, completed_at = NULL WHERE id = $1", + [id], + ); + await tx.query( + "UPDATE atlas.operations SET status = 'queued', error_code = NULL, error_message = NULL, completed_at = NULL WHERE id = $1", + [id], + ); + for (const child of failed) { + const attempt = await tx.query<{ + generation_attempt: number; + }>( + "UPDATE atlas.media_ai_edits SET status = 'queued', provider_task_id = NULL, error_code = NULL, completed_at = NULL, started_at = NULL, created_at = now(), generation_attempt = generation_attempt + 1 WHERE id = $1 RETURNING generation_attempt", + [child.id], + ); + await tx.query( + "UPDATE atlas.operations SET status = 'queued', result = '{}'::jsonb, error_code = NULL, error_message = NULL, completed_at = NULL WHERE id = $1", + [child.id], + ); + await this.outbox( + tx, + 'media.ai_edit.requested', + child.id, + { + edit_id: child.id, + attempt: attempt.rows[0].generation_attempt, + }, + randomUUID(), + ); + } + } + } + return { + status: 200, + body: { + data: this.resource(await this.find(tx, productId, id)), + }, + }; + }, + ); + } + + private async find( + tx: DatabaseTransaction, + productId: string, + id: string, + lock = false, + ) { + const result = await tx.query( + `SELECT * FROM atlas.product_image_suites WHERE id = $1 AND product_id = $2 ${lock ? 'FOR UPDATE' : ''}`, + [id, productId], + ); + if (!result.rows[0]) + throw new ApiError(404, 'RESOURCE_NOT_FOUND', '套图任务不存在'); + return result.rows[0]; + } + private resource(row: SuiteRow): ImageSuite { + return { + id: row.id, + product_id: row.product_id, + variant_id: row.variant_id, + purpose: row.purpose, + status: row.status, + input: row.input, + shots: row.shots as ImageSuiteShot[], + items: [], + error_code: row.error_code, + created_at: iso(row.created_at), + completed_at: row.completed_at ? iso(row.completed_at) : null, + operation: { + id: row.id, + kind: + row.purpose === 'plan' ? 'image_suite_plan' : 'image_suite', + status: row.status === 'partial' ? 'failed' : row.status, + created_at: iso(row.created_at), + completed_at: row.completed_at ? iso(row.completed_at) : null, + error: row.error_code + ? { + code: row.error_code, + message: + row.purpose === 'plan' + ? (imageSuitePlanErrorMessages[ + row.error_code + ] ?? + imageSuitePlanErrorMessages.AI_SUITE_PLAN_FAILED) + : '部分图片未完成,可重试失败项', + } + : null, + }, + }; + } + private async operation( + tx: DatabaseTransaction, + id: string, + kind: string, + userId: string, + ) { + 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, $2, $2, $1, $3)`, + [id, kind, userId], + ); + } + private async outbox( + tx: DatabaseTransaction, + event: string, + id: string, + payload: Record, + dedup = id, + ) { + 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, 'image_suite', $1, $2, $3::jsonb, $4)`, + [id, event, JSON.stringify(payload), dedup], + ); + } +} diff --git a/apps/api/src/media/media.controller.ts b/apps/api/src/media/media.controller.ts index dc78840..e17738e 100644 --- a/apps/api/src/media/media.controller.ts +++ b/apps/api/src/media/media.controller.ts @@ -25,6 +25,8 @@ import { createWatermarkDerivativeRequestSchema, createMediaAiEditRequestSchema, listProductImageTasksQuerySchema, + imageSuitePlanInputSchema, + createImageSuiteSchema, type CreateMediaAccessUrlRequest, type Workspace, } from '@atlas/contracts'; @@ -38,6 +40,7 @@ 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'; +import { ImageSuiteService } from './image-suite.service.js'; @ApiTags('Media') @ApiBearerAuth('access-token') @@ -50,8 +53,47 @@ export class MediaController { @Inject(MediaAiEditService) private readonly aiEdits: MediaAiEditService, @Inject(ProductImageTaskService) private readonly productImageTasks: ProductImageTaskService, @Inject(ProductImageDerivativeHistoryService) private readonly derivativeHistory: ProductImageDerivativeHistoryService, + @Inject(ImageSuiteService) private readonly suites: ImageSuiteService, ) {} + @Post('products/:productId/ai/suite-plans') + public async planSuite(@Param('productId') productId: string, @Body() body: unknown, + @Headers('idempotency-key') key: string | undefined, @Req() request: AuthenticatedRequest, + @CurrentWorkspace() workspace: Workspace, @Res({ passthrough: true }) response: { status: (code: number) => void }) { + const result = await this.suites.create(workspace.id, request.auth.userId, uuid('product_id', productId), idempotencyKey(key), parse(imageSuitePlanInputSchema, body), 'plan'); + response.status(result.status); return result.body; + } + + @Post('products/:productId/ai/image-suites') + public async generateSuite(@Param('productId') productId: string, @Body() body: unknown, + @Headers('idempotency-key') key: string | undefined, @Req() request: AuthenticatedRequest, + @CurrentWorkspace() workspace: Workspace, @Res({ passthrough: true }) response: { status: (code: number) => void }) { + const result = await this.suites.create(workspace.id, request.auth.userId, uuid('product_id', productId), idempotencyKey(key), parse(createImageSuiteSchema, body), 'generate'); + response.status(result.status); return result.body; + } + + @Get('products/:productId/ai/image-suites') + public async listSuites(@Param('productId') productId: string, @Query() query: unknown, @CurrentWorkspace() workspace: Workspace) { + return this.suites.list(workspace.id, uuid('product_id', productId), parse(listProductImageTasksQuerySchema, query)); + } + + @Get('products/:productId/ai/image-suites/:suiteId') + public async readSuite(@Param('productId') productId: string, @Param('suiteId') suiteId: string, @CurrentWorkspace() workspace: Workspace) { + return { data: await this.suites.read(workspace.id, uuid('product_id', productId), uuid('suite_id', suiteId)) }; + } + + @Post('products/:productId/ai/image-suites/:suiteId/cancel') + public async cancelSuite(@Param('productId') productId: string, @Param('suiteId') suiteId: string, + @Headers('idempotency-key') key: string | undefined, @Req() request: AuthenticatedRequest, @CurrentWorkspace() workspace: Workspace) { + return (await this.suites.action(workspace.id, request.auth.userId, uuid('product_id', productId), uuid('suite_id', suiteId), idempotencyKey(key), 'cancel')).body; + } + + @Post('products/:productId/ai/image-suites/:suiteId/retry') + public async retrySuite(@Param('productId') productId: string, @Param('suiteId') suiteId: string, + @Headers('idempotency-key') key: string | undefined, @Req() request: AuthenticatedRequest, @CurrentWorkspace() workspace: Workspace) { + return (await this.suites.action(workspace.id, request.auth.userId, uuid('product_id', productId), uuid('suite_id', suiteId), idempotencyKey(key), 'retry')).body; + } + @Get('products/:productId/image-derivatives') public async listProductImageDerivatives( @Param('productId') productId: string, diff --git a/apps/api/src/media/media.module.ts b/apps/api/src/media/media.module.ts index 2c303fb..d90f0d7 100644 --- a/apps/api/src/media/media.module.ts +++ b/apps/api/src/media/media.module.ts @@ -7,10 +7,11 @@ 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'; +import { ImageSuiteService } from './image-suite.service.js'; @Module({ controllers: [MediaController], - providers: [IdempotencyService, MediaService, AspectRatioDerivativeService, WatermarkDerivativeService, MediaAiEditService, ProductImageTaskService, ProductImageDerivativeHistoryService], + providers: [IdempotencyService, MediaService, AspectRatioDerivativeService, WatermarkDerivativeService, MediaAiEditService, ProductImageTaskService, ProductImageDerivativeHistoryService, ImageSuiteService], exports: [MediaService], }) export class MediaModule {} diff --git a/apps/api/src/media/media.service.ts b/apps/api/src/media/media.service.ts index 7d1c72c..df43ef5 100644 --- a/apps/api/src/media/media.service.ts +++ b/apps/api/src/media/media.service.ts @@ -1,4 +1,4 @@ -import { createHash, randomUUID } from 'node:crypto'; +import { createHash, createHmac, randomUUID } from 'node:crypto'; import { CreateBucketCommand, GetObjectCommand, @@ -48,6 +48,7 @@ type AssetRow = { @Injectable() export class MediaService { private readonly s3: S3Client; + private readonly legacyMinioS3: S3Client | null; private bucketReady: Promise | null = null; public constructor( @@ -58,13 +59,29 @@ export class MediaService { ) { this.s3 = new S3Client({ endpoint: config.OBJECT_STORAGE_ENDPOINT, - region: 'us-east-1', + region: config.OBJECT_STORAGE_REGION, forcePathStyle: config.objectStorageForcePathStyle, credentials: { accessKeyId: config.objectStorageAccessKey, secretAccessKey: config.objectStorageSecretKey, }, }); + // 开发环境保留旧 MinIO 素材预览,历史素材正式发布前仍需迁移到七牛。 + this.legacyMinioS3 = + config.NODE_ENV !== 'production' && + config.OBJECT_STORAGE_PROVIDER === 'qiniu_kodo' && + config.MINIO_ROOT_USER && + config.MINIO_ROOT_PASSWORD + ? new S3Client({ + endpoint: config.LEGACY_MINIO_ENDPOINT, + region: 'us-east-1', + forcePathStyle: true, + credentials: { + accessKeyId: config.MINIO_ROOT_USER, + secretAccessKey: config.MINIO_ROOT_PASSWORD, + }, + }) + : null; } public async createIntent( @@ -273,11 +290,12 @@ export class MediaService { ) { return this.database.workspaceTransaction(workspaceId, async (tx) => { const asset = await tx.query<{ + storage_provider: string; bucket: string; object_key: string; content_type: string; }>( - `SELECT bucket, object_key, content_type FROM atlas.media_assets WHERE id = $1`, + `SELECT storage_provider, bucket, object_key, content_type FROM atlas.media_assets WHERE id = $1`, [assetId], ); if (!asset.rows[0]) @@ -288,16 +306,10 @@ export class MediaService { '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 }, + const url = await this.signedReadUrl( + asset.rows[0], + expiresAt, + purpose === 'download' ? `atlas-image-${assetId}.${extension}` : undefined, ); return { url, expires_at: expiresAt.toISOString() }; }); @@ -306,8 +318,8 @@ export class MediaService { /** 返回工作空间最近上传的商品素材,预览 URL 仅在本次响应中签发。 */ public async productUploads(workspaceId: string) { return this.database.workspaceTransaction(workspaceId, async (tx) => { - const uploads = await tx.query( - `SELECT a.id, a.content_type, a.byte_size::text, a.width_px, a.height_px, + const uploads = await tx.query( + `SELECT a.id, a.storage_provider, a.content_type, a.byte_size::text, a.width_px, a.height_px, a.created_at, a.bucket, a.object_key, max(i.completed_at) AS uploaded_at FROM atlas.media_assets a JOIN atlas.media_upload_intents i ON i.asset_id = a.id @@ -326,11 +338,7 @@ export class MediaService { created_at: asset.created_at instanceof Date ? asset.created_at.toISOString() : asset.created_at, - preview_url: await getSignedUrl( - this.s3, - new GetObjectCommand({ Bucket: asset.bucket, Key: asset.object_key }), - { expiresIn: 600 }, - ), + preview_url: await this.signedReadUrl(asset, new Date(expiresAt)), preview_url_expires_at: expiresAt, }))); }); @@ -387,15 +395,12 @@ export class MediaService { [input.url, assetId, userId], ); const asset = await this.requireAsset(tx, assetId); - const stored = await tx.query<{ bucket: string; object_key: string }>( - 'SELECT bucket, object_key FROM atlas.media_assets WHERE id = $1', + const stored = await tx.query<{ storage_provider: string; bucket: string; object_key: string }>( + 'SELECT storage_provider, bucket, object_key FROM atlas.media_assets WHERE id = $1', [assetId], ); const expiresAt = new Date(Date.now() + 10 * 60_000); - const previewUrl = await getSignedUrl(this.s3, new GetObjectCommand({ - Bucket: stored.rows[0].bucket, - Key: stored.rows[0].object_key, - }), { expiresIn: 600 }); + const previewUrl = await this.signedReadUrl(stored.rows[0], expiresAt); return { status: 201, body: { data: { ...asset, source_url: input.url, @@ -408,11 +413,12 @@ export class MediaService { public async productImports(workspaceId: string) { return this.database.workspaceTransaction(workspaceId, async (tx) => { const imports = await tx.query( - `SELECT a.id, a.content_type, a.byte_size::text, a.width_px, a.height_px, + `SELECT a.id, a.storage_provider, a.content_type, a.byte_size::text, a.width_px, a.height_px, a.created_at, a.bucket, a.object_key, i.source_url FROM ( SELECT DISTINCT ON (asset_id) asset_id, source_url, updated_at, id @@ -433,9 +439,7 @@ export class MediaService { created_at: asset.created_at instanceof Date ? asset.created_at.toISOString() : asset.created_at, source_url: asset.source_url, - preview_url: await getSignedUrl(this.s3, new GetObjectCommand({ - Bucket: asset.bucket, Key: asset.object_key, - }), { expiresIn: 600 }), + preview_url: await this.signedReadUrl(asset, new Date(expiresAt)), preview_url_expires_at: expiresAt, }))); }); @@ -560,6 +564,55 @@ export class MediaService { }; } + /** 七牛图片从绑定域名签发短时下载凭证;MinIO 素材仍使用 S3 预签名。 */ + private async signedReadUrl( + asset: { storage_provider: string; bucket: string; object_key: string }, + expiresAt: Date, + downloadName?: string, + ): Promise { + if ( + asset.storage_provider === 'qiniu_kodo' && + asset.bucket === this.config.OBJECT_STORAGE_BUCKET && + this.config.qiniuDownloadOrigin + ) { + const origin = new URL(this.config.qiniuDownloadOrigin).origin; + const path = asset.object_key.split('/').map(encodeURIComponent).join('/'); + const deadline = Math.floor(expiresAt.getTime() / 1000); + const attachment = downloadName + ? `attname=${encodeURIComponent(downloadName)}&` + : ''; + const unsignedUrl = `${origin}/${path}?${attachment}e=${deadline}`; + const signature = createHmac('sha1', this.config.qiniuDownloadSecretKey) + .update(unsignedUrl) + .digest('base64') + .replace(/\+/g, '-') + .replace(/\//g, '_'); + return `${unsignedUrl}&token=${this.config.qiniuDownloadAccessKey}:${signature}`; + } + return getSignedUrl( + this.storageForPreview(asset), + new GetObjectCommand({ + Bucket: asset.bucket, + Key: asset.object_key, + ...(downloadName ? { + ResponseContentDisposition: `attachment; filename="${downloadName}"`, + } : {}), + }), + { expiresIn: Math.max(1, Math.floor((expiresAt.getTime() - Date.now()) / 1000)) }, + ); + } + + private storageForPreview(asset: { storage_provider: string; bucket: string }): S3Client { + if ( + asset.storage_provider === 'local_minio' && + asset.bucket === 'atlas-assets' && + this.legacyMinioS3 + ) { + return this.legacyMinioS3; + } + return this.s3; + } + private async ensureBucket() { if (!this.bucketReady) this.bucketReady = (async () => { diff --git a/apps/api/src/media/product-image-task.service.ts b/apps/api/src/media/product-image-task.service.ts index a14bba8..3791fec 100644 --- a/apps/api/src/media/product-image-task.service.ts +++ b/apps/api/src/media/product-image-task.service.ts @@ -16,13 +16,17 @@ type AiEditTaskRow = { failure_detail: string | null; created_at: Date | string; completed_at: Date | string | null; + kind?: ProductImageTask['kind']; + total?: number; + completed?: number; + output_asset_ids?: string[]; }; @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 }>( @@ -40,14 +44,33 @@ export class ProductImageTaskService { } 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, + `SELECT e.* FROM ( + SELECT e.id, e.product_id, e.status, e.provider_task_id, e.output_asset_id, e.error_code, o.result ->> 'provider_error_raw' AS provider_error_raw, o.result ->> 'failure_source' AS failure_source, 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 + e.created_at, e.completed_at, 'single_ai_edit' AS kind, 1 AS total, + (e.status = 'succeeded')::int AS completed, + CASE WHEN e.output_asset_id IS NULL THEN '{}'::uuid[] ELSE ARRAY[e.output_asset_id] END AS output_asset_ids + 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.suite_id IS NULL + UNION ALL + SELECT s.id, s.product_id, s.status, NULL::text, NULL::uuid, s.error_code, + failure.result ->> 'provider_error_raw', failure.result ->> 'failure_source', + failure.result ->> 'failure_stage', failure.result ->> 'failure_detail', + s.created_at, s.completed_at, 'ai_suite', jsonb_array_length(s.shots), + progress.completed::int, progress.outputs + FROM atlas.product_image_suites s + LEFT JOIN LATERAL (SELECT count(*) FILTER (WHERE status = 'succeeded') AS completed, + coalesce(array_agg(output_asset_id ORDER BY suite_position) FILTER (WHERE output_asset_id IS NOT NULL), '{}'::uuid[]) AS outputs + FROM atlas.media_ai_edits WHERE suite_id = s.id) progress ON true + LEFT JOIN LATERAL (SELECT o.result FROM atlas.media_ai_edits child JOIN atlas.operations o ON o.id = child.id + AND o.workspace_id = child.workspace_id WHERE child.suite_id = s.id AND child.status = 'failed' + ORDER BY child.suite_position LIMIT 1) failure ON true + WHERE s.purpose = 'generate' + ) e 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, @@ -57,17 +80,17 @@ export class ProductImageTaskService { const last = rows.at(-1); const data: ProductImageTask[] = rows.map((row) => ({ id: row.id, - kind: 'single_ai_edit' as const, + kind: row.kind ?? 'single_ai_edit', 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] : [], + total: row.total ?? 1, + completed: row.completed ?? (row.status === 'succeeded' ? 1 : 0), + output_asset_ids: row.output_asset_ids ?? (row.output_asset_id ? [row.output_asset_id] : []), 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, + failure_source: ['failed', 'partial'].includes(row.status) ? row.failure_source ?? null : null, + failure_stage: ['failed', 'partial'].includes(row.status) ? row.failure_stage ?? null : null, + failure_detail: ['failed', 'partial'].includes(row.status) ? row.failure_detail ?? null : null, + provider_error_raw: ['failed', 'partial'].includes(row.status) ? row.provider_error_raw ?? null : null, created_at: iso(row.created_at), completed_at: row.completed_at ? iso(row.completed_at) : null, })); diff --git a/apps/api/src/product-editor/product-editor.publish.ts b/apps/api/src/product-editor/product-editor.publish.ts index 13d8e56..028988f 100644 --- a/apps/api/src/product-editor/product-editor.publish.ts +++ b/apps/api/src/product-editor/product-editor.publish.ts @@ -1,4 +1,3 @@ -import { Decimal } from 'decimal.js'; import { randomUUID } from 'node:crypto'; import type { OzonPublishItemSnapshot, @@ -323,6 +322,9 @@ export class ProductEditorPublishOperations { values = values .filter((v) => typeof v === 'string' && v.trim()) .map((v) => v.trim()); + // Ozon 主题标签用空格分隔后放在同一个属性值中,单值限制不代表只允许一个标签。 + if (definition.editor_field === 'tags' && values.length) + values = [values.join(' ')]; if (values.length > definition.max_value_count) throw new ApiError( 422, @@ -385,13 +387,8 @@ export class ProductEditorPublishOperations { category.rows[0].external_category_id, ), type_id: numberId(category.rows[0].external_type_id), - price: listing.selling_price, - ...(listing.list_price && - new Decimal(listing.list_price).greaterThan( - listing.selling_price, - ) - ? { old_price: listing.list_price! } - : {}), + // 首次上架以划线价预留折扣空间,后续实际售价由用户在 Ozon 调整。 + price: listing.list_price!, currency_code: profile.currency_code, depth: Number(listing.depth), width: Number(listing.width), diff --git a/apps/api/src/product-editor/product-editor.validation.ts b/apps/api/src/product-editor/product-editor.validation.ts index 858570f..adc13d9 100644 --- a/apps/api/src/product-editor/product-editor.validation.ts +++ b/apps/api/src/product-editor/product-editor.validation.ts @@ -2,6 +2,8 @@ import type { ProductAttributeSchema, ProductEditorValidation, } from '@atlas/contracts'; +import { ozonHashtagProblem } from '@atlas/contracts'; +import { Decimal } from 'decimal.js'; import type { Database, DatabaseTransaction } from '@atlas/database'; import { ApiError } from '../common/api-error.js'; import type { ProductRow, ProfileRow } from './product-editor.models.js'; @@ -27,9 +29,11 @@ type VariantValidationRow = { id: string; display_name: string | null; title: string | null; + tags: unknown; listing_status: string; offer_id: string | null; selling_price: string | null; + list_price: string | null; pricing_valid: boolean; package_length_mm: string | null; package_width_mm: string | null; @@ -302,6 +306,19 @@ export class ProductEditorValidationOperations { const issues: ProductEditorValidation['variants'][number]['issues'] = [ ...dictionaryIssues, ]; + // 只校验本次 SKU 实际使用的标签,独立覆盖时不受未使用的通用文案阻断。 + const ownTags = stringValues(variant.tags); + const tagsProblem = ozonHashtagProblem( + ownTags.length ? ownTags : stringValues(profile.tags), + ); + if (tagsProblem) + issues.push({ + code: 'OZON_HASHTAG_INVALID', + field: ownTags.length + ? `variants.${variant.id}.tags` + : 'profile.tags', + message: tagsProblem, + }); if (profile.title_mode === 'variant' && !variant.title?.trim()) issues.push({ code: 'PUBLISH_TITLE_REQUIRED', @@ -344,11 +361,27 @@ export class ProductEditorValidationOperations { field: `variants.${variant.id}.pricing_selection`, message: '请按当前采购价和包装确认报价', }); - if (!variant.selling_price || Number(variant.selling_price) <= 0) + const sellingPrice = variant.selling_price + ? new Decimal(variant.selling_price) + : null; + const listPrice = variant.list_price ? new Decimal(variant.list_price) : null; + if (!sellingPrice?.isFinite() || sellingPrice.lte(0)) issues.push({ code: 'PRICE_REQUIRED', field: `variants.${variant.id}.selling_price`, - message: '请先确认售价', + message: '请先确认实际售价', + }); + if (!listPrice?.isFinite() || listPrice.lte(0)) + issues.push({ + code: 'LIST_PRICE_REQUIRED', + field: `variants.${variant.id}.list_price`, + message: '请重新试算并确认划线价,首次上架将按划线价提交', + }); + else if (sellingPrice?.isFinite() && listPrice.lt(sellingPrice)) + issues.push({ + code: 'LIST_PRICE_INVALID', + field: `variants.${variant.id}.list_price`, + message: '划线价不能低于实际售价,请重新确认报价', }); if ( variant.package_spec_status !== 'confirmed' || @@ -416,8 +449,8 @@ export class ProductEditorValidationOperations { variantIds?: string[], ) { const variants = await transaction.query( - `SELECT v.id, v.display_name, listing.title, listing.status AS listing_status, - listing.offer_id, listing.selling_price::text, listing.pricing_valid, + `SELECT v.id, v.display_name, listing.title, listing.tags, listing.status AS listing_status, + listing.offer_id, listing.selling_price::text, listing.list_price::text, listing.pricing_valid, coalesce(v.package_length_mm, product.default_package_length_mm)::text AS package_length_mm, coalesce(v.package_width_mm, product.default_package_width_mm)::text AS package_width_mm, coalesce(v.package_height_mm, product.default_package_height_mm)::text AS package_height_mm, @@ -431,8 +464,8 @@ export class ProductEditorValidationOperations { LEFT JOIN atlas.product_variant_listing_media media ON media.listing_id = listing.id WHERE v.product_id = $2 AND v.deleted_at IS NULL AND ($3::uuid[] IS NULL OR v.id = ANY($3::uuid[])) - GROUP BY v.id, v.display_name, listing.title, listing.status, listing.offer_id, - listing.selling_price, listing.pricing_valid, product.default_package_length_mm, + GROUP BY v.id, v.display_name, listing.title, listing.tags, listing.status, listing.offer_id, + listing.selling_price, listing.list_price, listing.pricing_valid, product.default_package_length_mm, product.default_package_width_mm, product.default_package_height_mm, product.default_shipping_weight_g, product.package_spec_status ORDER BY v.sort_order, v.id`, diff --git a/apps/api/test/image-suite.database.test.ts b/apps/api/test/image-suite.database.test.ts new file mode 100644 index 0000000..af1eb5b --- /dev/null +++ b/apps/api/test/image-suite.database.test.ts @@ -0,0 +1,328 @@ +import { randomUUID } from 'node:crypto'; +import { + createApplicationDatabase, + type Database, + type DatabaseTransaction, +} from '@atlas/database'; +import { + createImageSuiteSchema, + imageSuiteInputSchema, + type ImageSuite, +} from '@atlas/contracts'; +import { describe, expect, it } from 'vitest'; +import { IdempotencyService } from '../src/common/idempotency.service.js'; +import { ImageSuiteService } from '../src/media/image-suite.service.js'; +import { MediaAiEditService } from '../src/media/media-ai-edit.service.js'; +import { ProductImageTaskService } from '../src/media/product-image-task.service.js'; + +/** 所有实体和 Outbox 都只存在于最终回滚的事务,验证不调用实际收费生图。 */ +describe.skipIf(!process.env.DATABASE_URL)( + '套图数据库隔离、幂等与状态汇总', + () => { + it('创建/部分失败/取消/重试保留成功图片,FK 与 RLS 拒绝越权引用', async () => { + const db = createApplicationDatabase(process.env.DATABASE_URL!); + const rollback = Symbol('rollback'); + try { + await db.transaction(async (tx) => { + const workspaceId = randomUUID(), + otherWorkspace = randomUUID(), + userId = randomUUID(); + await tx.query( + "INSERT INTO atlas.users (id, email, password_hash) VALUES ($1, $2, 'test-only')", + [userId, `${userId}@example.test`], + ); + for (const id of [workspaceId, otherWorkspace]) + await tx.query( + "INSERT INTO atlas.workspaces (id, name, slug) VALUES ($1::uuid, '套图测试', $1::text)", + [id], + ); + const inWorkspace = async (id: string) => + tx.query( + "SELECT set_config('app.workspace_id', $1, true)", + [id], + ); + await inWorkspace(workspaceId); + const productId = randomUUID(), + variantId = randomUUID(), + itemId = randomUUID(), + assetId = randomUUID(), + outputId = randomUUID(); + await tx.query( + "INSERT INTO atlas.collection_items (id, workspace_id, source_kind, source_item_key) VALUES ($1::uuid, $2, 'manual', $1::text)", + [itemId, workspaceId], + ); + await tx.query( + 'INSERT INTO atlas.products (id, workspace_id, collection_item_id) VALUES ($1, $2, $3)', + [productId, workspaceId, itemId], + ); + await tx.query( + 'INSERT INTO atlas.product_variants (id, workspace_id, product_id) VALUES ($1, $2, $3)', + [variantId, workspaceId, productId], + ); + const asset = async (id: string, ws: string) => + tx.query( + `INSERT INTO atlas.media_assets + (id, workspace_id, storage_provider, bucket, object_key, content_type, byte_size, checksum_sha256, width_px, height_px) + VALUES ($1::uuid, $2, 'local_minio', 'test', $1::text, 'image/jpeg', 100, $1::text, 100, 100)`, + [id, ws], + ); + await asset(assetId, workspaceId); + await asset(outputId, workspaceId); + await inWorkspace(otherWorkspace); + const foreignAsset = randomUUID(); + await asset(foreignAsset, otherWorkspace); + await inWorkspace(workspaceId); + let queryQueue: Promise = Promise.resolve(); + const scoped = { + workspaceTransaction: ( + ws: string, + work: ( + transaction: DatabaseTransaction, + ) => Promise, + ) => { + const result = queryQueue.then(async () => { + await inWorkspace(ws); + return work(tx); + }); + queryQueue = result.then( + () => undefined, + () => undefined, + ); + return result; + }, + } as Database; + const idempotency = new IdempotencyService(scoped, { + IDEMPOTENCY_KEY_SECRET: 'suite-test-secret', + } as never); + const edits = new MediaAiEditService(scoped, idempotency, { + accessUrl: async () => ({ + url: 'https://example.test/preview', + expires_at: '2099-01-01T00:00:00Z', + }), + } as never); + const suites = new ImageSuiteService( + scoped, + idempotency, + edits, + ); + const input = imageSuiteInputSchema.parse({ + variant_id: variantId, + reference_asset_ids: [assetId], + product_name: '台灯', + }); + const shots = ['white', 'scene', 'selling'].map( + (category, i) => ({ + id: randomUUID(), + category, + type: ['white_background', 'lifestyle', 'detail'][ + i + ], + theme: `方案${i}`, + description: '依据真实底图展示商品', + }), + ); + const request = createImageSuiteSchema.parse({ + ...input, + shots, + }); + const key = randomUUID(); + const created = await suites.create( + workspaceId, + userId, + productId, + key, + request, + 'generate', + ); + const id = (created.body as { data: ImageSuite }).data.id; + expect( + ( + await suites.create( + workspaceId, + userId, + productId, + key, + request, + 'generate', + ) + ).body, + ).toEqual(created.body); + await expect( + suites.create( + workspaceId, + userId, + productId, + key, + { ...request, product_name: '不同请求' }, + 'generate', + ), + ).rejects.toMatchObject({ + status: 409, + response: { error: { code: 'IDEMPOTENCY_KEY_REUSED' } }, + }); + const children = ( + await tx.query<{ id: string }>( + 'SELECT id FROM atlas.media_ai_edits WHERE suite_id = $1 ORDER BY suite_position', + [id], + ) + ).rows; + expect(children).toHaveLength(3); + expect( + ( + await tx.query( + 'SELECT id FROM atlas.outbox_events WHERE aggregate_id = ANY($1::uuid[])', + [children.map((child) => child.id)], + ) + ).rows, + ).toHaveLength(3); + await tx.query( + "UPDATE atlas.media_ai_edits SET status = 'succeeded', output_asset_id = $2 WHERE id = $1", + [children[0].id, outputId], + ); + await tx.query( + "UPDATE atlas.media_ai_edits SET status = 'failed', error_code = 'TEST_FAILURE' WHERE id = $1", + [children[1].id], + ); + expect( + (await suites.read(workspaceId, productId, id)).status, + ).toBe('running'); + await suites.action( + workspaceId, + userId, + productId, + id, + randomUUID(), + 'cancel', + ); + expect( + (await suites.read(workspaceId, productId, id)).status, + ).toBe('cancelled'); + await suites.action( + workspaceId, + userId, + productId, + id, + randomUUID(), + 'retry', + ); + const retried = await suites.read( + workspaceId, + productId, + id, + ); + expect(retried.items.map((item) => item.status)).toEqual([ + 'succeeded', + 'queued', + 'queued', + ]); + expect(retried.items[0].output_asset?.id).toBe(outputId); + const attempts = ( + await tx.query<{ generation_attempt: number }>( + 'SELECT generation_attempt FROM atlas.media_ai_edits WHERE suite_id = $1 ORDER BY suite_position', + [id], + ) + ).rows; + expect( + attempts.map((item) => item.generation_attempt), + ).toEqual([1, 2, 2]); + await tx.query( + "UPDATE atlas.media_ai_edits SET status = 'failed', error_code = 'TEST_FAILURE' WHERE id = ANY($1::uuid[])", + [children.slice(1).map((child) => child.id)], + ); + expect( + (await suites.read(workspaceId, productId, id)).status, + ).toBe('partial'); + const tasks = await new ProductImageTaskService( + scoped, + ).list(workspaceId, productId, { limit: 20 }); + expect(tasks.data).toMatchObject([ + { + id, + kind: 'ai_suite', + status: 'partial', + total: 3, + completed: 1, + output_asset_ids: [outputId], + }, + ]); + expect(tasks.data).toHaveLength(1); + await expect( + suites.read(otherWorkspace, productId, id), + ).rejects.toMatchObject({ status: 404 }); + await expect( + suites.create( + workspaceId, + userId, + productId, + randomUUID(), + { ...input, reference_asset_ids: [foreignAsset] }, + 'plan', + ), + ).rejects.toMatchObject({ status: 404 }); + const forbidden = async ( + sql: string, + params: unknown[], + code: string, + ) => { + await tx.query('SAVEPOINT forbidden_write'); + try { + await tx.query(sql, params); + throw new Error('本应拒绝的写入成功'); + } catch (error) { + expect((error as { code: string }).code).toBe(code); + } finally { + await tx.query( + 'ROLLBACK TO SAVEPOINT forbidden_write', + ); + } + }; + await forbidden( + 'UPDATE atlas.product_image_suites SET input = input || \'{"color":"篡改"}\'::jsonb WHERE id = $1', + [id], + '23514', + ); + await forbidden( + 'UPDATE atlas.media_ai_edits SET suite_position = NULL WHERE id = $1', + [children[0].id], + '23514', + ); + await forbidden( + 'UPDATE atlas.media_ai_edits SET source_asset_id = $2 WHERE id = $1', + [children[0].id, foreignAsset], + '23514', + ); + const foreignReferenceParent = await suites.create( + workspaceId, + userId, + productId, + randomUUID(), + input, + 'plan', + ); + const foreignParentId = ( + foreignReferenceParent.body as { data: ImageSuite } + ).data.id; + await forbidden( + 'UPDATE atlas.product_image_suite_references SET asset_id = $2 WHERE suite_id = $1', + [foreignParentId, foreignAsset], + '23514', + ); + await inWorkspace(otherWorkspace); + expect( + ( + await tx.query( + 'SELECT id FROM atlas.product_image_suites WHERE id = $1', + [id], + ) + ).rows, + ).toHaveLength(0); + throw rollback; + }); + } catch (error) { + if (error !== rollback) throw error; + } finally { + await db.close(); + } + }); + }, +); diff --git a/apps/api/test/image-suite.unit.test.ts b/apps/api/test/image-suite.unit.test.ts new file mode 100644 index 0000000..900b946 --- /dev/null +++ b/apps/api/test/image-suite.unit.test.ts @@ -0,0 +1,291 @@ +import { randomUUID } from 'node:crypto'; +import { describe, expect, it, vi } from 'vitest'; +import { + createImageSuiteSchema, + imageSuiteInputSchema, + imageSuitePlanInputSchema, +} from '@atlas/contracts'; +import { ImageSuiteService } from '../src/media/image-suite.service.js'; + +const input = () => + imageSuiteInputSchema.parse({ + variant_id: randomUUID(), + reference_asset_ids: [randomUUID()], + product_name: '台灯', + }); +const shot = () => ({ + id: randomUUID(), + category: 'white', + type: 'white_background', + theme: '白底主图', + description: '展示商品完整外观', +}); + +describe('套图共享契约与资源边界', () => { + it('规划默认四张,接受 1–20 张预算,生成接口不接收规划预算字段', () => { + const valid = input(); + expect(imageSuitePlanInputSchema.parse(valid).image_count).toBe(4); + expect( + imageSuitePlanInputSchema.parse({ ...valid, image_count: 6 }) + .image_count, + ).toBe(6); + for (const image_count of [0, 21, 1.2]) + expect( + imageSuitePlanInputSchema.safeParse({ ...valid, image_count }) + .success, + ).toBe(false); + expect( + createImageSuiteSchema.safeParse({ + ...valid, + image_count: 4, + shots: [shot()], + }).success, + ).toBe(false); + }); + it('规划保存预算并只创建规划 Outbox,不创建生图子任务', async () => { + const request = imageSuitePlanInputSchema.parse({ + ...input(), + image_count: 6, + }); + const suiteId = randomUUID(), + productId = randomUUID(); + const query = vi.fn(async (sql: string, values: unknown[] = []) => { + if (sql.startsWith('SELECT id FROM atlas.product_variants')) + return { rows: [{ id: request.variant_id }] }; + if (sql.startsWith('SELECT id FROM atlas.media_assets')) + return { + rows: request.reference_asset_ids.map((id) => ({ id })), + }; + if (sql.startsWith('INSERT INTO atlas.product_image_suites')) + return { + rows: [ + { + id: suiteId, + product_id: productId, + variant_id: request.variant_id, + purpose: 'plan', + status: 'queued', + input: JSON.parse(values[3] as string), + shots: [], + created_at: new Date(), + completed_at: null, + error_code: null, + }, + ], + }; + return { rows: [] }; + }); + const service = new ImageSuiteService( + {} as never, + { + run: async ( + _context: unknown, + work: (tx: { query: typeof query }) => Promise, + ) => work({ query }), + } as never, + {} as never, + ); + const result = await service.create( + randomUUID(), + randomUUID(), + productId, + randomUUID(), + request, + 'plan', + ); + expect(result).toMatchObject({ + status: 202, + body: { + data: { purpose: 'plan', input: { image_count: 6 }, shots: [] }, + }, + }); + expect( + query.mock.calls.some(([sql]) => + sql.includes('INSERT INTO atlas.media_ai_edits'), + ), + ).toBe(false); + expect( + query.mock.calls.find(([sql]) => + sql.includes('INSERT INTO atlas.outbox_events'), + )?.[1], + ).toContain('image_suite.plan.requested'); + }); + it('拒绝重复底图、超过 8 张底图、超过分类数量和无实物尺寸的尺寸图', () => { + const valid = input(); + expect( + imageSuiteInputSchema.safeParse({ + ...valid, + reference_asset_ids: Array.from({ length: 9 }, randomUUID), + }).success, + ).toBe(false); + expect( + imageSuiteInputSchema.safeParse({ + ...valid, + reference_asset_ids: [ + ...valid.reference_asset_ids, + ...valid.reference_asset_ids, + ], + }).success, + ).toBe(false); + expect( + createImageSuiteSchema.safeParse({ + ...valid, + shots: Array.from({ length: 6 }, shot), + }).success, + ).toBe(false); + expect( + createImageSuiteSchema.safeParse({ + ...valid, + shots: [{ ...shot(), category: 'other', type: 'dimensions' }], + }).success, + ).toBe(false); + }); + it('逐张底图必须来自已选图库,并保留主体图,类型必须匹配分类', () => { + const valid = input(); + expect( + createImageSuiteSchema.safeParse({ + ...valid, + shots: [{ ...shot(), reference_asset_ids: [randomUUID()] }], + }).success, + ).toBe(false); + expect( + createImageSuiteSchema.safeParse({ + ...valid, + shots: [{ ...shot(), category: 'scene' }], + }).success, + ).toBe(false); + expect( + createImageSuiteSchema.safeParse({ + ...valid, + shots: [shot()], + ratio: '4:3', + language: 'zh', + }).success, + ).toBe(true); + }); + it('目标 SKU 不属于当前商品时,在产生任何任务前拒绝请求', 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 ImageSuiteService( + {} as never, + { run } as never, + {} as never, + ); + await expect( + service.create( + randomUUID(), + randomUUID(), + randomUUID(), + randomUUID(), + input(), + 'plan', + ), + ).rejects.toMatchObject({ status: 404 }); + expect(query).toHaveBeenCalledOnce(); + }); + it('任一底图不可见时拒绝,不产生套图及 Outbox', async () => { + const query = vi.fn(async (sql: string) => ({ + rows: sql.includes('atlas.product_variants') + ? [{ id: 'variant' }] + : [], + })); + const run = vi.fn( + async ( + _input: unknown, + work: (tx: { query: typeof query }) => Promise, + ) => work({ query }), + ); + const service = new ImageSuiteService( + {} as never, + { run } as never, + {} as never, + ); + await expect( + service.create( + randomUUID(), + randomUUID(), + randomUUID(), + randomUUID(), + input(), + 'plan', + ), + ).rejects.toMatchObject({ status: 404 }); + expect(query).toHaveBeenCalledTimes(2); + }); + it('默认逐张用图保留主体,每张最多 3 张,整套轮换覆盖已选 8 张底图', async () => { + const request = createImageSuiteSchema.parse({ + ...input(), + reference_asset_ids: Array.from({ length: 8 }, randomUUID), + shots: Array.from({ length: 8 }, (_, index) => ({ + ...shot(), + category: ['white', 'scene', 'selling', 'other'][index % 4], + type: ['white_background', 'lifestyle', 'detail', 'custom'][ + index % 4 + ], + })), + }); + const used: string[][] = []; + const query = vi.fn(async (sql: string, values: unknown[] = []) => { + if (sql.startsWith('SELECT id FROM atlas.product_variants')) + return { rows: [{ id: request.variant_id }] }; + if (sql.startsWith('SELECT id FROM atlas.media_assets')) + return { + rows: request.reference_asset_ids.map((id) => ({ id })), + }; + if (sql.includes('INSERT INTO atlas.product_image_suites')) + return { + rows: [ + { + id: randomUUID(), + product_id: randomUUID(), + variant_id: request.variant_id, + purpose: 'generate', + status: 'queued', + input: request, + shots: request.shots, + created_at: new Date(), + completed_at: null, + error_code: null, + }, + ], + }; + if (sql.includes('INSERT INTO atlas.media_ai_edits')) + used.push([values[5] as string, ...(values[6] as string[])]); + return { rows: [] }; + }); + const service = new ImageSuiteService( + {} as never, + { + run: async ( + _request: unknown, + work: (tx: { query: typeof query }) => Promise, + ) => work({ query }), + } as never, + {} as never, + ); + await service.create( + randomUUID(), + randomUUID(), + randomUUID(), + randomUUID(), + request, + 'generate', + ); + expect(used).toHaveLength(8); + expect( + used.every( + (refs) => + refs.length <= 3 && + refs[0] === request.reference_asset_ids[0], + ), + ).toBe(true); + expect(new Set(used.flat())).toEqual( + new Set(request.reference_asset_ids), + ); + }); +}); diff --git a/apps/api/test/media-import.unit.test.ts b/apps/api/test/media-import.unit.test.ts index 38a4a2a..2755ab9 100644 --- a/apps/api/test/media-import.unit.test.ts +++ b/apps/api/test/media-import.unit.test.ts @@ -47,10 +47,11 @@ describe('网络商品图片导入', () => { ], }; } - if (sql.includes('SELECT bucket, object_key')) { + if (sql.includes('SELECT storage_provider, bucket, object_key')) { return { rows: [ { + storage_provider: 'local_minio', bucket: 'test', object_key: 'product-imports/existing.png', }, @@ -74,6 +75,7 @@ describe('网络商品图片导入', () => { {} as ConstructorParameters[1], { OBJECT_STORAGE_ENDPOINT: 'http://127.0.0.1:9000', + OBJECT_STORAGE_REGION: 'us-east-1', OBJECT_STORAGE_BUCKET: 'test', objectStorageForcePathStyle: true, objectStorageAccessKey: 'test', diff --git a/apps/api/test/media-product-image.unit.test.ts b/apps/api/test/media-product-image.unit.test.ts index 256d078..d98d75a 100644 --- a/apps/api/test/media-product-image.unit.test.ts +++ b/apps/api/test/media-product-image.unit.test.ts @@ -18,6 +18,7 @@ function serviceWithObject(bytes: Buffer) { {} as ConstructorParameters[1], { OBJECT_STORAGE_ENDPOINT: 'http://127.0.0.1:9000', + OBJECT_STORAGE_REGION: 'us-east-1', OBJECT_STORAGE_BUCKET: 'test', objectStorageForcePathStyle: true, objectStorageAccessKey: 'test', @@ -70,6 +71,7 @@ describe('商品图片上传策略', () => { { workspaceTransaction } as unknown as ConstructorParameters[1], { OBJECT_STORAGE_ENDPOINT: 'http://127.0.0.1:9000', + OBJECT_STORAGE_REGION: 'cn-east-1', OBJECT_STORAGE_BUCKET: 'test', objectStorageForcePathStyle: true, objectStorageAccessKey: 'test', @@ -80,11 +82,88 @@ describe('商品图片上传策略', () => { 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(preview.url).searchParams.get('X-Amz-Credential')).toContain('/cn-east-1/'); expect(new URL(download.url).searchParams.get('response-content-disposition')) .toBe(`attachment; filename="atlas-image-${assetId}.png"`); expect(workspaceTransaction).toHaveBeenCalledTimes(2); }); + it('开发环境为旧 MinIO 素材签发本地预览地址', async () => { + const query = vi.fn(async () => ({ + rows: [{ + storage_provider: 'local_minio', + bucket: 'atlas-assets', + object_key: 'collections/test/image', + content_type: 'image/jpeg', + }], + })); + const database = { + workspaceTransaction: async (_workspaceId: string, work: (tx: { query: typeof query }) => Promise) => work({ query }), + }; + const service = new MediaService( + {} as ConstructorParameters[0], + database as unknown as ConstructorParameters[1], + { + NODE_ENV: 'development', + OBJECT_STORAGE_PROVIDER: 'qiniu_kodo', + OBJECT_STORAGE_ENDPOINT: 'https://s3.cn-east-1.qiniucs.com', + OBJECT_STORAGE_REGION: 'cn-east-1', + OBJECT_STORAGE_BUCKET: 'atlas-dev-assets', + objectStorageForcePathStyle: true, + objectStorageAccessKey: 'test', + objectStorageSecretKey: 'test', + LEGACY_MINIO_ENDPOINT: 'http://localhost:9000', + MINIO_ROOT_USER: 'local-test', + MINIO_ROOT_PASSWORD: 'local-secret', + } as ConstructorParameters[2], + ); + const preview = await service.accessUrl('workspace-id', 'asset-id'); + expect(new URL(preview.url).host).toBe('localhost:9000'); + expect(new URL(preview.url).pathname).toBe('/atlas-assets/collections/test/image'); + }); + + it('七牛素材使用绑定域名签发预览和附件下载链接', async () => { + const assetId = '7c910684-7070-4f36-8a7b-b10bd91acd79'; + const query = vi.fn(async () => ({ + rows: [{ + storage_provider: 'qiniu_kodo', + bucket: 'atlas-dev-assets', + object_key: 'collections/workspace/image', + content_type: 'image/jpeg', + }], + })); + const database = { + workspaceTransaction: async (_workspaceId: string, work: (tx: { query: typeof query }) => Promise) => work({ query }), + }; + const service = new MediaService( + {} as ConstructorParameters[0], + database as unknown as ConstructorParameters[1], + { + OBJECT_STORAGE_ENDPOINT: 'https://s3.cn-east-1.qiniucs.com', + OBJECT_STORAGE_REGION: 'cn-east-1', + OBJECT_STORAGE_BUCKET: 'atlas-dev-assets', + objectStorageForcePathStyle: true, + objectStorageAccessKey: 's3-key', + objectStorageSecretKey: 's3-secret', + qiniuDownloadOrigin: 'https://img-dev.atlas.joeyone.cn', + qiniuDownloadAccessKey: 'download-key', + qiniuDownloadSecretKey: 'download-secret', + } as ConstructorParameters[2], + ); + const preview = await service.accessUrl('workspace-id', assetId); + const download = await service.accessUrl('workspace-id', assetId, 'download'); + const previewUrl = new URL(preview.url); + const downloadUrl = new URL(download.url); + expect(previewUrl.origin).toBe('https://img-dev.atlas.joeyone.cn'); + expect(previewUrl.pathname).toBe('/collections/workspace/image'); + expect(previewUrl.searchParams.has('X-Amz-Signature')).toBe(false); + expect(previewUrl.searchParams.has('attname')).toBe(false); + expect(previewUrl.searchParams.get('token')).toMatch(/^download-key:/); + expect(preview.url.split('&').at(-1)).toMatch(/^token=/); + expect(downloadUrl.searchParams.get('attname')).toBe(`atlas-image-${assetId}.jpg`); + expect(download.url.split('&').at(-1)).toMatch(/^token=/); + }); + it('创建商品图片上传意图时持久化用途并签发独立对象路径', async () => { const query = vi.fn(async (_sql: string, _params?: unknown[]) => ({ rows: [], @@ -100,6 +179,7 @@ describe('商品图片上传策略', () => { {} as ConstructorParameters[1], { OBJECT_STORAGE_ENDPOINT: 'http://127.0.0.1:9000', + OBJECT_STORAGE_REGION: 'us-east-1', OBJECT_STORAGE_BUCKET: 'test', objectStorageForcePathStyle: true, objectStorageAccessKey: 'test', @@ -208,6 +288,7 @@ describe('商品图片上传策略', () => { >[1], { OBJECT_STORAGE_ENDPOINT: 'http://127.0.0.1:9000', + OBJECT_STORAGE_REGION: 'us-east-1', OBJECT_STORAGE_BUCKET: 'test', objectStorageForcePathStyle: true, objectStorageAccessKey: 'test', diff --git a/apps/api/test/pricing-engine.unit.test.ts b/apps/api/test/pricing-engine.unit.test.ts index 41a2182..0951d15 100644 --- a/apps/api/test/pricing-engine.unit.test.ts +++ b/apps/api/test/pricing-engine.unit.test.ts @@ -72,6 +72,15 @@ const commission: CommissionRule = { version: 'test', }; describe('十进制定价引擎', () => { + it('默认方案折扣率为 50% 时,划线价是实际售价的两倍', () => { + const i = input(); + i.pricing.list_price_discount_rate = '0.5'; + for (const currency of ['CNY', 'RUB']) { + const option = calculatePricing(i, currency, '12.3', [rule], [commission]) + .options[0]; + expect(Number(option.list_price)).toBe(Number(option.selling_price) * 2); + } + }); it('目标加价与明细一致,CNY / RUB 都按最终币种舍入', () => { const c = calculatePricing(input(), 'CNY', '12.3', [rule], [commission]) .options[0]; diff --git a/apps/api/test/product-publish.e2e.test.ts b/apps/api/test/product-publish.e2e.test.ts index e98de2c..35cfb3c 100644 --- a/apps/api/test/product-publish.e2e.test.ts +++ b/apps/api/test/product-publish.e2e.test.ts @@ -4,6 +4,7 @@ import type { AddressInfo } from 'node:net'; import type { Database } from '@atlas/database'; import type { NestExpressApplication } from '@nestjs/platform-express'; import type { ProductPublishJob } from '@atlas/contracts'; +import { ozonHashtagProblem } from '@atlas/contracts'; import type { S3Client } from '@aws-sdk/client-s3'; import { beforeAll, afterAll, describe, it } from 'vitest'; import { DATABASE } from '../src/database/database.module.js'; @@ -204,7 +205,7 @@ describe('首次发布 HTTP / 数据库 / Worker 闭环', () => { ) ).rows[0].id; await tx.query( - `INSERT INTO atlas.platform_product_category_attributes(platform_id,platform_product_category_id,platform_attribute_id,name,value_type,is_required,is_aspect,max_value_count) VALUES($1,$2,'9048','型号','text',true,false,1)`, + `INSERT INTO atlas.platform_product_category_attributes(platform_id,platform_product_category_id,platform_attribute_id,name,value_type,is_required,is_aspect,max_value_count) VALUES($1,$2,'9048','型号','text',true,false,1),($1,$2,'23171','#主题标签','text',false,false,1)`, [platform, category], ); await tx.query( @@ -217,6 +218,167 @@ describe('首次发布 HTTP / 数据库 / Worker 闭环', () => { await app?.close(); }); + it('多个主题标签合并为一个 Ozon 属性值,SKU 覆盖与继承均保留', async () => { + const f = await fixture(); + await db.workspaceTransaction(workspace, async (tx) => { + await tx.query( + 'UPDATE atlas.product_platform_profiles SET tags=$2::jsonb WHERE id=$1', + [f.profile, JSON.stringify(['#boho', '#loft'])], + ); + await tx.query( + 'UPDATE atlas.product_variant_platform_listings SET tags=$3::jsonb WHERE profile_id=$1 AND product_variant_id=$2', + [f.profile, f.ids[0], JSON.stringify(['#kpop', '#oldmoney'])], + ); + }); + const created = await publish(f); + assert.equal(created.status, 202, JSON.stringify(created)); + const job = created.body.data.publish_job as ProductPublishJob; + await run(job, async (_client, _key, path, body) => { + if (path === '/v3/product/info/list') return { items: [] }; + assert.equal(path, '/v3/product/import'); + const items = body.items as Array<{ + attributes: Array<{ + id: number; + values: Array<{ value: string }>; + }>; + }>; + assert.deepEqual( + items.map( + (item) => + item.attributes.find( + (attribute) => attribute.id === 23171, + )?.values, + ), + [[{ value: '#kpop #oldmoney' }], [{ value: '#boho #loft' }]], + ); + return { result: { task_id: 23171 } }; + }); + }); + + it('主题标签支持 30 个标签,空标签不提交属性', async () => { + for (const tags of [ + [], + Array.from({ length: 30 }, (_, index) => `#tag${index}`), + ]) { + const f = await fixture(1); + await db.workspaceTransaction(workspace, (tx) => + tx.query( + 'UPDATE atlas.product_platform_profiles SET tags=$2::jsonb WHERE id=$1', + [f.profile, JSON.stringify(tags)], + ), + ); + const created = await publish(f); + assert.equal(created.status, 202, JSON.stringify(created)); + const result = await db.workspaceTransaction(workspace, (tx) => + tx.query<{ + snapshot: { + attributes: Array<{ + id: number; + values: Array<{ value: string }>; + }>; + }; + }>( + 'SELECT snapshot FROM atlas.product_publish_job_items WHERE job_id=$1', + [created.body.data.publish_job.id], + ), + ); + const attribute = result.rows[0].snapshot.attributes.find( + (item) => item.id === 23171, + ); + assert.deepEqual( + attribute?.values, + tags.length ? [{ value: tags.join(' ') }] : undefined, + ); + } + }); + + it('主题标签只接受单个井号、字母数字下划线及 30 字符边界', () => { + assert.equal(ozonHashtagProblem(['#стиль_2026', '#boho']), null); + assert.equal(ozonHashtagProblem(['#' + 'я'.repeat(29)]), null); + for (const tag of [ + 'стиль', + '#стиль мода', + '#стиль-мода', + '#стиль/мода', + '#стиль#мода', + '#стиль😀', + '#', + '#' + 'я'.repeat(30), + ]) + assert.ok(ozonHashtagProblem([tag]), tag); + assert.ok( + ozonHashtagProblem(Array.from({ length: 31 }, () => '#стиль')), + ); + }); + + it('历史不合规主题标签在预检与创建发布时拦截,不影响合法 SKU 覆盖', async () => { + const f = await fixture(); + await db.workspaceTransaction(workspace, async (tx) => { + await tx.query( + 'UPDATE atlas.product_platform_profiles SET tags=$2::jsonb WHERE id=$1', + [f.profile, JSON.stringify(['спортивный стиль'])], + ); + await tx.query( + 'UPDATE atlas.product_variant_platform_listings SET tags=$3::jsonb WHERE profile_id=$1 AND product_variant_id=$2', + [f.profile, f.ids[0], JSON.stringify(['#спортивный_стиль'])], + ); + }); + const preflight = await request( + `/product-platform-profiles/${f.profile}/publish-preflights`, + 'POST', + { variant_ids: f.ids }, + ); + assert.equal(preflight.status, 200, JSON.stringify(preflight)); + assert.equal(preflight.body.data.status, 'blocked'); + const validation = preflight.body.data.validation; + assert.equal( + validation.variants.find( + (v: { variant_id: string }) => v.variant_id === f.ids[0], + ).ready, + true, + ); + assert.ok( + validation.variants + .find((v: { variant_id: string }) => v.variant_id === f.ids[1]) + .issues.some( + (issue: { code: string; field: string }) => + issue.code === 'OZON_HASHTAG_INVALID' && + issue.field === 'profile.tags', + ), + ); + const rejected = await publish(f); + assert.equal(rejected.status, 422); + assert.ok( + rejected.body.error.details.some( + (issue: { code: string }) => + issue.code === 'OZON_HASHTAG_INVALID', + ), + ); + const selected = await publish(f, [f.ids[0]]); + assert.equal(selected.status, 202, JSON.stringify(selected)); + + const invalidSku = await fixture(1); + await db.workspaceTransaction(workspace, (tx) => + tx.query( + 'UPDATE atlas.product_variant_platform_listings SET tags=$3::jsonb WHERE profile_id=$1 AND product_variant_id=$2', + [ + invalidSku.profile, + invalidSku.ids[0], + JSON.stringify(['#стиль-мода']), + ], + ), + ); + const response = await publish(invalidSku); + assert.equal(response.status, 422); + assert.ok( + response.body.error.details.some( + (issue: { code: string; field: string }) => + issue.code === 'OZON_HASHTAG_INVALID' && + issue.field === `variants.${invalidSku.ids[0]}.tags`, + ), + ); + }); + it('预检、并发幂等、精度快照、Outbox 与跨租户隔离', async () => { const f = await fixture(); const key = randomUUID(); @@ -241,12 +403,21 @@ describe('首次发布 HTTP / 数据库 / Worker 闭环', () => { [job.id], ) ).rows, + listings: ( + await tx.query( + 'SELECT selling_price::text, list_price::text FROM atlas.product_variant_platform_listings WHERE profile_id=$1', + [f.profile], + ) + ).rows, })); assert.equal(facts.events.length, 1); assert.deepEqual(facts.events[0].event_payload, { publish_job_id: job.id, }); - assert.equal(facts.items[0].snapshot.price, '1999.1234'); + assert.equal(facts.items[0].snapshot.price, '2999.1234'); + assert.equal(facts.listings[0].selling_price, '1999.1234'); + assert.equal(facts.listings[0].list_price, '2999.1234'); + assert.ok(!('old_price' in facts.items[0].snapshot)); assert.equal(facts.items[0].snapshot.depth, 240); assert.ok(!('vat' in facts.items[0].snapshot)); assert.ok(!('images' in facts.items[0].snapshot)); @@ -297,6 +468,51 @@ describe('首次发布 HTTP / 数据库 / Worker 闭环', () => { 'VERSION_CONFLICT', ); }); + it('首次价格使用划线价;零折扣仍可提交,缺失或零划线价阻断预检和创建', async () => { + const equal = await fixture(1); + await db.workspaceTransaction(workspace, (tx) => + tx.query( + 'UPDATE atlas.product_variant_platform_listings SET list_price=selling_price WHERE profile_id=$1', + [equal.profile], + ), + ); + const created = await publish(equal); + assert.equal(created.status, 202, JSON.stringify(created)); + await run(created.body.data.publish_job, async (_client, _key, path, body) => { + if (path === '/v3/product/info/list') return { items: [] }; + assert.equal(path, '/v3/product/import'); + const item = (body.items as any[])[0]; + assert.equal(item.price, '1999.1234'); + assert.ok(!('old_price' in item)); + return { result: { task_id: 987654 } }; + }); + for (const listPrice of [null, '0']) { + const f = await fixture(1); + await db.workspaceTransaction(workspace, (tx) => + tx.query( + 'UPDATE atlas.product_variant_platform_listings SET list_price=$2, selling_price=CASE WHEN $2::numeric=0 THEN 0 ELSE selling_price END WHERE profile_id=$1', + [f.profile, listPrice], + ), + ); + const preflight = await request( + `/product-platform-profiles/${f.profile}/publish-preflights`, + 'POST', + { variant_ids: f.ids }, + { 'Idempotency-Key': randomUUID(), 'If-Match': '"1:1"' }, + ); + assert.equal(preflight.status, 200, JSON.stringify(preflight)); + assert.equal(preflight.body.data.validation.variants[0].ready, false); + assert.ok(preflight.body.data.validation.variants[0].issues.some( + (issue: { code: string; field: string }) => + issue.code === 'LIST_PRICE_REQUIRED' && issue.field === `variants.${f.ids[0]}.list_price`, + )); + const rejected = await publish(f); + assert.equal(rejected.status, 422); + assert.ok(rejected.body.error.details.some( + (issue: { code: string }) => issue.code === 'LIST_PRICE_REQUIRED', + )); + } + }); it('只校验所选 SKU;不就绪的未选 SKU 不阻断部分上架', async () => { const f = await fixture(); await db.workspaceTransaction(workspace, (tx) => @@ -322,7 +538,8 @@ describe('首次发布 HTTP / 数据库 / Worker 闭环', () => { imports++; const items = body.items as any[]; assert.equal(items.length, 2); - assert.equal(items[0].price, '1999.1234'); + assert.equal(items[0].price, '2999.1234'); + assert.ok(!('old_price' in items[0])); assert.ok(!('vat' in items[0])); assert.ok(!('barcode' in items[0])); assert.ok(!('description' in items[0])); diff --git a/apps/web/HANDOFF.md b/apps/web/HANDOFF.md index 3c1e200..3e9abb3 100644 --- a/apps/web/HANDOFF.md +++ b/apps/web/HANDOFF.md @@ -1,12 +1,78 @@ # Web 前端交接:商品编辑器与 AI 填报 -更新日期:2026-09-30 +更新日期:2026-10-04 + +## 2026-10-04 SKU 主动重算使用最新默认方案 + +根因:`SkuPricing.calculate` 优先使用 SKU 已保存的 `pricing_draft`,「重新试算」和批量按钮只强制重发相同参数,旧零折扣因此使实际售价与划线价相同。当前集成会话仅调整 Web `SkuPricing`、补充定价引擎公式回归测试及 API/交接说明,不修改服务端业务、HTTP 字段、定价引擎或数据库。 + +两个主动入口重读目标店铺定价目录,使用最新默认方案的完整参数替换旧 SKU 参数,沿用当前成本与有效包装;无默认方案/费率不可用/读取失败时明确提示,不回退零折扣。自动试算、手工售价输入和试算详情继续使用当前参数。批量跳过已导入 SKU,试算结果仍需保存草稿确认。表单监听保留未注册字段,返回报价直接核对实时表单,避免参数回填的渲染时序误丢弃报价;建立同一计算上下文时不取消已经发出的匹配请求。 + +验证:ego-browser 隔离真实组件复现旧零折扣两价相同,确认单 SKU 与批量使用最新 50% 折扣、划线价为实际售价两倍,保留成本和包装继承,跳过已上架 SKU;页面打开后将默认方案改为 25% 再重算能读取新值;手工实际售价不覆盖当前折扣;无默认方案、费率不可用、目录失败均不生成回退报价。定价引擎 16 项(新增 CNY/RUB 的 50% 折扣)和定价保存 12 项通过;Web/API 源码与测试类型检查、构建及差异检查通过。临时页面已清理,无真实商品保存或平台发布,未手动启停原有服务。 + +## 2026-10-04 实际售价、划线价展示与首次发布价格 + +本轮由当前集成会话负责,范围为 Web `SkuPricing`、`PricingCalculator`、`ProductPublishActions`、SKU 样式,API 发布映射/校验、发布回归测试和相关文档。按维护者要求,首次上架的 Ozon `price` 改为已确认 `listing.list_price`,不发送 `old_price`;保持 HTTP 字段、定价公式、数据库和 Worker 冻结快照执行方式。实际售价继续用于目标利润计算,用户上线后在 Ozon 调整实际售价;已创建任务沿用原价格快照,已导入 SKU 不自动更新。 + +SKU 收起摘要和展开定价区显示采购价、实际售价、划线价、预估净利润;试算结果实际售价与划线价同行。非人民币报价按同一报价快照比例换算摘要,不读取当前汇率改写历史价格。确认上架表格同时显示两价,划线价标为「本次上报价」。服务端预检与创建要求划线价有效且不低于实际售价,缺失/零值返回 `LIST_PRICE_REQUIRED`,低于实际售价返回 `LIST_PRICE_INVALID`;零折扣两价相同可发布。 + +验证:发布 11 项、商品编辑 13 项、定价引擎 15 项、定价保存 12 项全部通过,覆盖十进制划线价冻结/实际模拟 Ozon 请求、实际售价保留、零折扣、缺失/零划线价阻断、幂等和租户隔离。API 源码/测试类型检查及构建、Web 类型检查及构建通过。ego-browser 隔离真实组件检查 721px/390px 下的两价同行与无页面横向溢出、展开区、上架确认和 RUB 报价的人民币摘要;未向真实 Ozon 发布,未修改真实商品,原有服务未手动启停。临时验证页面已清理。 + +## 2026-10-04 主题标签格式与 AI 输出校验 + +本轮由当前集成会话负责,范围为共享 `ozon-hashtags.ts` 规则、Web SPU/SKU 标签提示、API 发布预检/创建校验、Worker AI 全局及 SKU 文案提示词与输出整理,以及相关测试和文档;不修改 HTTP 请求结构、数据库或标签覆盖继承语义。标签逐项以单个 # 开头,正文只允许字母、数字和下划线,无内部空格;每项含 # 最多 30 字符,最多 30 项,发布继续拼为一个 Ozon 属性值。AI 补齐缺失 #,词间空白及连字符转下划线,过滤非法、超长及重复项,并保留同项中文对照。 + +历史与手工草稿仍允许保存,Web 给出具体格式提示;预检和任务创建按所选 SKU 实际使用的标签拦截,返回 `OZON_HASHTAG_INVALID`,合法 SKU 独立覆盖不受未使用的通用标签阻断。Web 支持以空格拆分多个输入标签,不自动改写已有内容。新规则及示例见商品编辑 API 文档。 + +验证:Worker 11 项、发布 HTTP/数据库/模拟 Ozon 10 项、商品编辑 13 项通过;contracts/API/Worker/Web 类型检查与构建通过。ego-browser 隔离真实组件验证缺少 #、内部空格、非法标点、合法标签清除提示、空格拆分及 SKU 覆盖独立提示;临时页面已清理。未调用收费 AI 或向真实 Ozon 店铺发布,API/Worker 仍沿用原有开发监听进程,无手动启停。 + +## 2026-10-04 上架 SKU 整行选择 + +「选择与校验上架 SKU」弹窗支持点击整行选中/取消 SKU;复选框保留原有行为,点击不会因冒泡重复切换。行点击与复选框共用状态判断,只有未上架或失败 SKU 可选,已导入、提交中及其他不可重复提交状态仍禁选。仅修改 Web `ProductPublishActions.tsx` 和本文档,无接口、契约或发布规则变更。 + +Web 类型检查和生产构建通过;ego-browser 隔离页面验证整行选中/取消、复选框无重复切换、失败 SKU 可选、已导入/提交中禁选与全选。未执行真实预检或发布,临时页面已删除,未启停维护者服务。 + +## 2026-10-04 商品编辑属性与 SKU 文案显示修复 + +本轮负责人为当前前端会话,修改范围为 `ProductEditPage.tsx`、`DynamicAttributeFields.tsx`、`SkuInfoPanel.tsx` 和本文档;复用商品编辑快照、类目 schema、草稿保存与 AI 任务接口,无 API、Worker、数据库或共享契约变更。 + +属性草稿补齐从 schema 请求回调拆为独立表单联动:同类目保存重载过滤掉空属性后,仍补齐商品级与已有 SKU 的属性控件;动态属性组件监听顶层 `attributes`,避免缓存 schema 下不重新渲染。schema 请求仅随 profile、平台、类目或刷新版本变化,避免请求自身写入的缓存状态清理其轮询回调。AI 任务前后比较归一化后的保存内容,补齐空属性控件不会误判为人工修改。 + +SKU 展开区始终显示标题、标签、描述和文案 AI/中文对照入口;通用模式只预览 SPU 文案,不写入 SKU 覆盖值,开启逐 SKU 编辑后沿用原有字段绑定和标题模式。全局 AI 仍只生成 SPU 文案;类目属性仍按 schema 手工填写。 + +验证:Web 类型检查、生产构建和 `git diff --check` 通过。ego-browser 隔离模拟商品验证了 AI 选定类目后必填/选填及 SKU 属性出现、空属性保存重载后控件恢复、商品/SKU 属性作用域与重读、通用预览不产生覆盖值、AI 前自动保存后正常回填、缺失 schema 自动轮询恢复、逐 SKU 标题/描述编辑保存后恢复。未调用真实模型或写入真实商品;临时验证页面已删除。沿用维护者原有 Web/API 服务,无手动启动、停止或重启。 + +## 2026-10-04 套图生成固定操作区 + +按维护者确认的「并列操作」方案实现。生图策略列拆为独立滚动内容和底部常驻操作区;待生成保留 48px 主按钮,排队/生成时左侧取消、右侧状态,生成数量进度显示在下方。规划期间显示「取消规划」与规划状态,取消前确认并保留当前方案;生成取消前提示已完成数量,取消请求有重复操作保护。失败/取消后的未完成图片可在底部重试,成功图片保留;原结果列重复的进度与重试入口移至操作区。执行和重试的摘要取任务快照,调整下一轮方案不改变任务摘要;另保留按当前方案创建新任务的入口。 + +本轮负责人为当前集成会话,修改范围为 Web 抽屉、操作区组件、样式和交接记录,复用既有套图取消/重试接口,无共享契约、API、Worker 或数据库变更。桌面三列保持独立滚动,880px 以下两列时策略区随抽屉保持可见,580px 以下上下排列;策略高度取抽屉正文容器高度,避免依赖固定标题高度。当前业务页停留在登录页,使用临时隔离页面挂载真实组件,以模拟任务验证 1280px、838px、320px 布局、滚动固定、取消确认/继续生成、取消后保留成功图片、只重试未完成项、取消规划保留方案及任务快照摘要。未提交真实模型请求或修改商品草稿,临时验证页面已删除;Web 类型检查、生产构建和差异格式检查通过,沿用维护者原有开发服务,无手动重启。 + +## 2026-10-03 AI 套图生成 + +智能规划失败诊断已补齐:真实 DeepSeek 诊断复现连续两次在四张图片的 `type` 字段收到枚举校验错误。Worker 提示词改用有效的 `white_background` 示例和完整英文类型枚举,第二次规划携带具体字段路径及校验规则;共享错误码区分字段、张数、首图、主题、尺寸与保存失败。每次校验及最终失败打印结构化日志,安全诊断保存在 operation.result.failure。历史失败没有原始诊断,仍保持原记录。本轮实际模型请求限用户授权的两次,未执行生图;修复后的真实模型结果需下次规划确认。Worker 规划及 DeepSeek 客户端 19 项测试通过,覆盖同类错误反馈与纠正;contracts/API/Worker/Web 类型检查与构建通过,API/Worker 原有 watch 进程运行,无手动重启。 + +参考底图弹窗改为左右两列,复用 `SkuImageCard`、`SkuImageSourceTabs` 和 SKU 弹窗基础样式。左侧用 3:4 缩略图和顺序编号展示已选底图,第一张标为主体图,可移除、设为主体和拖动排序;右侧保留五类来源,点击图片选中/取消,左上角显示勾选。移除重复来源名称标签,不对原图裁切或拉伸。共享图片卡片允许不传编辑菜单,主体标记文案可配置,键盘操作只响应卡片自身,避免操作子按钮时重复触发。顶部数量靠左、灰色提示靠右;左右区域独立滚动,580px 以下上下排列。已在页面核对框比例 0.75、选择两张、主体切换、拖动排序、键盘移除和预览返回;Web 类型检查和构建通过,验证后恢复原有未选状态,未保存商品或调用生图。 + +本轮验证:Worker 规划/DeepSeek 客户端相关 17 项、API/数据库套图相关 8 项通过;contracts/API/Worker/Web 类型检查与构建通过。页面核对总张数由 4 调整到 5 后预算提示同步,已恢复默认 4。模型响应使用模拟数据,未实际调用 DeepSeek 或收费生图;没有手动启动或重启维护者服务。 + +智能规划按当前方案总张数提交 `image_count`(1–20,默认 4),预算独立于商品文本。DeepSeek 基于当前 SKU 的文字资料分配用途,按必要性选择场景、尺寸、材质等;规划完成后显示主题、说明和依据,不自动生图。取消/失败保留当前方案,诊断区分配置、连接和无效方案;历史恢复剥离规划预算字段,避免将其传入生成接口。 + +四类图片数量控件的减号按钮、输入框外框和加号按钮统一由 24px 加高至 28.8px(20%),输入内容垂直居中,宽度保持现有规格。 + +生成策略移除默认/AI 两个选项,改为右对齐的「AI 智能规划」操作按钮。默认四张自动展示;点击按钮异步规划并更新当前方案,规划成功后可再次点击重新规划;失败保留当前方案,规划期间禁用重复提交。 + +三列布局在上次策略列加宽的基础上,生图策略收窄 5%、商品资料加宽 5%;标准及 1100px 以下的三列布局同步调整比例和最小宽度。 + +界面反馈已落实:实物尺寸拆为长/宽/高,默认 CM,切换 MM/M 时换算实际数值,仍沿用带单位尺寸字符串契约。长/宽/高和实物重量统一使用 Ant Design `InputNumber`,支持小数;重量默认 g,可切换 kg 并换算实际净重,历史带明确单位的重量以 g 回填,提交仍使用带单位字符串。文本域为「采集信息/描述」「核心卖点/要求」。风格折叠标题、文本域和恢复默认按钮与风格按钮对齐,生成按钮高 48px。四类图片数量采用减号/输入框/加号组合,支持直接输入 0–5,边界禁用对应按钮;点击数量控件不触发分类展开/收起。 + +底部「AI 套图生成」已接入 `ProductImageSuiteDrawer` 三列右侧抽屉。目标为已保存 SKU,底图库最多 8 张,来源顺序采集/AI/处理/上传/网络;默认 4 张(白底/场景/核心卖点/细节各 1),分类数量 1/1/2/0,四大类逐张计划可编辑,AI 规划走异步 DeepSeek。Pcore 使用现有单图 Worker 生成子任务,整套与子任务通过数据库汇总,可后台生成、取消、失败重试。成功图片加入目标 SKU 后只修改表单,仍需保存。新契约见 [套图 API](../../docs/api/product-image-suites.md),迁移为 056–059。联调验证不执行真实收费生图,实际图像质量需用业务商品验证。 本文记录 Web 编辑页的现行状态;跨领域总体进度以 [`../../docs/project/HANDOFF.md`](../../docs/project/HANDOFF.md) 的 1.39 为准。开始继续开发前请查看仓库根目录的 `git status --short`,保留目前 API、Worker、contracts、迁移及 Web 中未提交和未跟踪的改动。 ## 当前状态(2026-09-30) -- 编辑页 `/products/:id/editor` 的 `ProductEditPage.tsx` 将底部操作条固定在视口底部,提供「AI 填报」和「保存草稿」;AI 套图生成仍是占位入口;首次上架已接入,见下方记录。全局 AI 填报会先保存未保存草稿,再创建异步任务并轮询;核对商品、profile、来源版本和本地表单未变化后,只把合格结果填进空字段,不直接持久化。用户仍须检查并保存。 +- 编辑页 `/products/:id/editor` 的 `ProductEditPage.tsx` 将底部操作条固定在视口底部,提供「AI 填报」和「保存草稿」;AI 套图生成已接入三列右侧抽屉;首次上架已接入,见下方记录。全局 AI 填报会先保存未保存草稿,再创建异步任务并轮询;核对商品、profile、来源版本和本地表单未变化后,只把合格结果填进空字段,不直接持久化。用户仍须检查并保存。 - 全局 AI 只回填 SPU 发布标题、标签、描述和中文对照,以及合格的类目、型号、未导入 SKU 货号与有证据的包装建议。类目有多个候选时,「确认 AI 推荐类目」弹窗用单选卡片让用户选择;选择前不自动写入类目。类目一旦确定,页面按现有机制加载动态属性,但不调用 AI 填写 Ozon 类目属性。 - SPU 的「中文商品名」和「俄文标题」标示首次上架必填,草稿保存允许留空;俄文标题可在通用与按 SKU 设置间切换并保存模式,按 SKU 设置时清空、禁用 SPU 标题并在各 SKU 填写。标题、标签、描述各有中文对照入口;用户可在当前 SKU 弹窗中单独生成、预览并明确应用,不因全局 AI 填报而批量覆盖 SKU。 - SKU 的 Ozon `is_aspect` 字段现在直接放在展开卡片的「SKU 信息」中,不另显示「Ozon 变体属性」标题。产品类目 Cascader 的清除事件按空类目处理,避免读取 `undefined.at`。来源采集信息侧栏仍展示来源资料与差异处理。 diff --git a/apps/web/src/components/pricing/PricingCalculator.tsx b/apps/web/src/components/pricing/PricingCalculator.tsx index 05a81b2..7798035 100644 --- a/apps/web/src/components/pricing/PricingCalculator.tsx +++ b/apps/web/src/components/pricing/PricingCalculator.tsx @@ -551,12 +551,12 @@ export function PricingCalculator({ } options={[ { value: 'target_markup', label: '按目标算价' }, - { value: 'fixed_sale', label: '手动售价' }, + { value: 'fixed_sale', label: '手动实际售价' }, ]} /> {input.pricing.mode === 'fixed_sale' && ( - aria-label="手动售价" + aria-label="手动实际售价" stringMode min="0.01" value={input.pricing.selling_price} @@ -566,7 +566,7 @@ export function PricingCalculator({ pricing: { ...input.pricing, selling_price: v }, }) } - placeholder={`销售价 ${catalog.store.currency_code}`} + placeholder={`实际售价 ${catalog.store.currency_code}`} /> )} @@ -618,18 +618,22 @@ export function PricingCalculator({ {option ? ( <> - - + + - - 划线价 {option.list_price} - - + + + + + + 首次上架按划线价提交,预估净利润按实际售价计算。 + {option.commission_details && ( void; children: (slots: PricingSlots) => ReactNode; }) { - Form.useWatch('variants', form); - Form.useWatch('common', form); - Form.useWatch('profile', form); + Form.useWatch('variants', { form, preserve: true }); + Form.useWatch('common', { form, preserve: true }); + Form.useWatch('profile', { form, preserve: true }); const values = form.getFieldsValue(true) as Values, v = values.variants[index], original = snapshot.variants.find((x) => x.id === v?.id); @@ -128,8 +128,6 @@ export function SkuPricing({ : null; const live = params && v ? skuPricingInput(values, index, snapshot.profile.id, params) : null; const liveKey = live ? keyFor(live) : ''; - const liveRef = useRef(liveKey); - liveRef.current = liveKey; const [now, setNow] = useState(Date.now()); useEffect(() => { if (!result) return; @@ -212,14 +210,19 @@ export function SkuPricing({ setError(''); onChanged(); } - async function calculate(force = false, override?: PricingParameters) { - if (!catalog || !v || original?.listing.status === 'imported') return; + async function calculate( + force = false, + override?: PricingParameters, + catalogOverride?: PricingCatalog + ) { + const sourceCatalog = catalogOverride ?? catalog; + if (!sourceCatalog || !v || original?.listing.status === 'imported') return; if (!fulfillmentSupported) { setError('当前仅支持 Ozon rFBS 定价;请先确认商品的履约方式'); return; } const current = form.getFieldValue(['variants', index]) as Values['variants'][number]; - const parameters = override ?? current.pricing_draft ?? defaultPricing(catalog); + const parameters = override ?? current.pricing_draft ?? defaultPricing(sourceCatalog); if (!parameters) { setError('没有可用的物流费率,请检查费率卡和草稿卡使用配置'); return; @@ -251,7 +254,16 @@ export function SkuPricing({ setError(''); try { const p = await pricingApi.preview(parsed.data, controller.current.signal); - if (seq !== sequence.current || liveRef.current !== inputKey) return; + if (seq !== sequence.current) return; + // 直接核对表单,避免方案回填后的 React 渲染时序误丢弃新报价。 + const latest = form.getFieldsValue(true) as Values; + const latestParameters = latest.variants[index]?.pricing_draft; + if ( + !latestParameters || + keyFor(skuPricingInput(latest, index, snapshot.profile.id, latestParameters)) !== + inputKey + ) + return; const recommended = p.options.find((o) => o.id === p.recommended_option_id); if (!recommended) { setError( @@ -279,6 +291,42 @@ export function SkuPricing({ if (seq === sequence.current) setBusy(false); } } + async function calculateFromDefault() { + if (!v || original?.listing.status === 'imported') return; + if (!fulfillmentSupported) { + setError('当前仅支持 Ozon rFBS 定价;请先确认商品的履约方式'); + return; + } + const requestedStore = form.getFieldValue(['profile', 'target_store_id']); + controller.current?.abort(); + const seq = ++sequence.current; + setBusy(true); + setError(''); + try { + // 主动按方案计算必须重读默认方案,不能沿用旧 SKU 快照或页面加载时的缓存。 + const fresh = await pricingApi.catalog(requestedStore); + if ( + seq !== sequence.current || + form.getFieldValue(['profile', 'target_store_id']) !== requestedStore + ) + return; + setCatalog(fresh); + const policy = fresh.policies.find((item) => item.is_default); + if (!policy) { + setError('请先为目标店铺设置默认定价方案,再按方案计算'); + return; + } + if (!fresh.rate_cards.some((card) => card.id === policy.parameters.rate_card_id)) { + setError('默认定价方案的物流费率卡不可用,请检查店铺定价方案'); + return; + } + await calculate(true, structuredClone(policy.parameters), fresh); + } catch (e) { + if (seq === sequence.current) setError(apiErrorMessage(e)); + } finally { + if (seq === sequence.current) setBusy(false); + } + } // 包装和门店上下文变化自动重算;采购价与手动售价仅在失焦/Enter 时提交。 const contextKey = live ? JSON.stringify([ @@ -292,6 +340,8 @@ export function SkuPricing({ useEffect(() => { if (previousContext.current === contextKey) return; previousContext.current = contextKey; + // 首次取得方案建立计算上下文时,同输入的主动试算已经在进行,无需取消重发。 + if (lastRequest.current === liveKey) return; sequence.current++; controller.current?.abort(); setBusy(false); @@ -303,7 +353,7 @@ export function SkuPricing({ useEffect(() => { if (batch === lastBatch.current) return; lastBatch.current = batch; - void calculate(true); + void calculateFromDefault(); }, [batch]); if (!v) return null; const imported = original?.listing.status === 'imported'; @@ -314,6 +364,21 @@ export function SkuPricing({ : (option?.merchandise_value_cny ?? original?.listing.pricing_result?.merchandise_value_cny); const profitCny = option?.profit_cny; + const displayedQuote = option ?? original?.listing.pricing_result; + // 两价使用同一报价的换算比例,避免历史报价被当前汇率重新换算。 + const listCny = displayedQuote + ? displayedQuote.currency_code === 'CNY' + ? displayedQuote.list_price + : Number(displayedQuote.selling_price) > 0 + ? String( + (Number(displayedQuote.list_price) * + Number(displayedQuote.merchandise_value_cny)) / + Number(displayedQuote.selling_price) + ) + : null + : currency === 'CNY' + ? original?.listing.list_price + : null; const statusText = busy ? '计算中' : error || !fulfillmentSupported @@ -361,9 +426,13 @@ export function SkuPricing({ {cny(v.purchase_cost_cny)}
- 售价 + 实际售价 {cny(saleCny)}
+
+ 划线价 + {cny(listCny)} +
预估净利润 void calculate(true)} + onClick={() => void calculateFromDefault()} > 重新试算 @@ -438,10 +507,10 @@ export function SkuPricing({ /> +
+ 划线价(人民币) +
{cny(listCny)}
+
预估净利润(人民币)
{ // 关键表单值的实时监听:驱动类目/属性数据联动与货源链接打开按钮。 const supplyUrl = Form.useWatch(['common', 'purchase_url'], form); const selectedCategoryId = Form.useWatch(['profile', 'platform_product_category_id'], form); + const attributeDrafts = Form.useWatch('attributes', { form, preserve: true }); // 编辑器快照与采集来源:sourceDraft 是用户可能改写过的采集素材副本。 const [snapshot, setSnapshot] = useState(null); + const attributeProfileId = snapshot?.profile.id; const [collectionRecord, setCollectionRecord] = useState(null); const [sourceDraft, setSourceDraft] = useState(null); const [sourceOverriddenFields, setSourceOverriddenFields] = useState( @@ -474,9 +478,6 @@ const ProductEditPage = () => { // 类目属性 schema 与字典值:选定类目后加载,必填属性始终展示。 const [attributeSchema, setAttributeSchema] = useState([]); const [attributeSchemaCategoryId, setAttributeSchemaCategoryId] = useState(null); - const [attributeSchemaCatalogRefresh, setAttributeSchemaCatalogRefresh] = useState< - number | null - >(null); const [attributeSchemaLoading, setAttributeSchemaLoading] = useState(false); const [attributeSchemaError, setAttributeSchemaError] = useState(null); const [dictionaryValues, setDictionaryValues] = useState< @@ -497,6 +498,8 @@ const ProductEditPage = () => { const [dirty, setDirty] = useState(false); const [publishStatusContainer, setPublishStatusContainer] = useState(null); const [moreFeaturesExpanded, setMoreFeaturesExpanded] = useState(false); + const [suiteOpen, setSuiteOpen] = useState(false); + const [suiteImages, setSuiteImages] = useState>({}); const [usesVariantPackageWeight, setUsesVariantPackageWeight] = useState(false); const [usesVariantPackageDimensions, setUsesVariantPackageDimensions] = useState(false); const [usesVariantTags, setUsesVariantTags] = useState(false); @@ -539,6 +542,13 @@ const ProductEditPage = () => { const brandDefinition = activeAttributeSchema.find( (attribute) => attribute.editor_field === 'brand' ); + // 保存重载或清空属性草稿时仍需补齐控件,不能依赖同类目 schema 再次请求。 + useEffect(() => { + if (!snapshot || !activeAttributeSchema.length) return; + const attributes = (form.getFieldValue('attributes') ?? []) as AttributeDraft[]; + const missing = missingAttributeDrafts(attributes, activeAttributeSchema, snapshot); + if (missing.length) form.setFieldValue('attributes', [...attributes, ...missing]); + }, [activeAttributeSchema, attributeDrafts, form, snapshot]); /** 拉取编辑器快照并重置表单;保存成功或冲突后也会调用,以合并服务端最新状态。 */ const load = useCallback(async () => { @@ -603,21 +613,15 @@ const ProductEditPage = () => { }, [selectedCategoryId, snapshot?.profile.id]); // 属性 schema:同一类目与刷新版本只请求一次;未选中类目时清空相关状态。 useEffect(() => { - if (!snapshot || !currentPlatform || !selectedCategoryId) { + if (!attributeProfileId || !currentPlatform || !selectedCategoryId) { setAttributeSchema([]); setAttributeSchemaCategoryId(null); - setAttributeSchemaCatalogRefresh(null); setAttributeSchemaLoading(false); setAttributeSchemaError(null); setDictionaryValues({}); setDictionaryCursors({}); return; } - if ( - attributeSchemaCategoryId === selectedCategoryId && - attributeSchemaCatalogRefresh === catalogRefresh - ) - return; let active = true; setAttributeSchemaLoading(true); setAttributeSchemaError(null); @@ -626,19 +630,12 @@ const ProductEditPage = () => { setDictionaryValues({}); setDictionaryCursors({}); void productEditorApi - .attributeSchema(snapshot.profile.id, currentPlatform.id, selectedCategoryId) + .attributeSchema(attributeProfileId, currentPlatform.id, selectedCategoryId) .then((result) => { if (active) { const schema = result.items; - const currentAttributes = (form.getFieldValue('attributes') ?? - []) as AttributeDraft[]; - const missing = missingAttributeDrafts(currentAttributes, schema, snapshot); - if (missing.length) { - form.setFieldValue('attributes', [...currentAttributes, ...missing]); - } setAttributeSchema(schema); setAttributeSchemaCategoryId(selectedCategoryId); - setAttributeSchemaCatalogRefresh(catalogRefresh); setAttributeSchemaLoading(false); if (result.cache.status === 'expired' || result.cache.status === 'missing') { schemaPollAttempts.current += 1; @@ -657,7 +654,6 @@ const ProductEditPage = () => { if (active) { setAttributeSchema([]); setAttributeSchemaCategoryId(null); - setAttributeSchemaCatalogRefresh(null); setAttributeSchemaLoading(false); setAttributeSchemaError(apiErrorMessage(error)); } @@ -666,13 +662,10 @@ const ProductEditPage = () => { active = false; }; }, [ - attributeSchemaCategoryId, - attributeSchemaCatalogRefresh, catalogRefresh, currentPlatform, - form, selectedCategoryId, - snapshot, + attributeProfileId, ]); // 预取类目属性字典;品牌初始只查询 Ozon 的无品牌候选,其他品牌按关键词加载。 useEffect(() => { @@ -1093,7 +1086,10 @@ const ProductEditPage = () => { setAiAutofillLoading(true); try { if (dirty && !(await save(form.getFieldsValue(true) as EditorFormValues))) return; - const formAtStart = JSON.stringify(form.getFieldsValue(true)); + // 保存重载后补齐的空属性控件不属于人工修改,按实际保存内容比较。 + const formAtStart = JSON.stringify( + normalizedValues(form.getFieldsValue(true) as EditorFormValues) + ); setAiAutofillStatus('正在分析采集信息…'); const task = await productEditorApi.aiAutofill(snapshot.profile.id, currentPlatform.id); const completed = await waitForAiTask(task.ai_task_id); @@ -1105,7 +1101,7 @@ const ProductEditPage = () => { latest.profile.revision !== completed.input_revision.profile || latest.source_material.source_snapshot_revision !== completed.input_revision.source_snapshot || - JSON.stringify(form.getFieldsValue(true)) !== formAtStart + JSON.stringify(normalizedValues(form.getFieldsValue(true) as EditorFormValues)) !== formAtStart ) { message.warning( '商品或采集信息在 AI 处理期间已变化,结果未自动回填,请重新点击 AI 填报' @@ -1127,7 +1123,10 @@ const ProductEditPage = () => { setSkuCopyResult(null); try { if (dirty && !(await save(form.getFieldsValue(true) as EditorFormValues))) return; - const formAtStart = JSON.stringify(form.getFieldsValue(true)); + // 保存重载后补齐的空属性控件不属于人工修改,按实际保存内容比较。 + const formAtStart = JSON.stringify( + normalizedValues(form.getFieldsValue(true) as EditorFormValues) + ); const task = await productEditorApi.aiSkuCopy( snapshot.profile.id, skuCopyVariantId, @@ -1140,7 +1139,7 @@ const ProductEditPage = () => { if ( latest.product.revision !== completed.input_revision.product || latest.profile.revision !== completed.input_revision.profile || - JSON.stringify(form.getFieldsValue(true)) !== formAtStart + JSON.stringify(normalizedValues(form.getFieldsValue(true) as EditorFormValues)) !== formAtStart ) throw new Error('商品已被其他操作更新,请重新生成'); skuCopyTaskId.current = completed.id; @@ -1181,12 +1180,6 @@ const ProductEditPage = () => { message.success('已应用到当前 SKU,保存草稿后生效'); }; const skuCopyTaskId = useRef(null); - const showFutureFeature = (name: string) => - modal.info({ - title: `${name}将在下一阶段接入`, - content: '单张 AI 编辑已可在 SKU 图片选择中使用。AI 套图生成仍在开发。', - okText: '知道了', - }); const validSupplyUrl = typeof supplyUrl === 'string' && /^https?:\/\//i.test(supplyUrl) ? supplyUrl : null; const canOpenSupplyUrl = !!validSupplyUrl; @@ -1584,6 +1577,7 @@ const ProductEditPage = () => { onCompareCopy={(variantId, field) => setComparison({ variantId, field })} onAttributeChanged={() => setDirty(true)} onImagesChanged={() => setDirty(true)} + suiteImagesByVariant={suiteImages} /> { @@ -1650,6 +1644,18 @@ const ProductEditPage = () => { /> + setSuiteOpen(false)} snapshot={snapshot} form={form} + source={sourceDraft ?? snapshot.source_material} collection={collectionRecord} + onApply={(variantId, images) => { + const variants = form.getFieldValue('variants') as EditorFormValues['variants']; + const index = variants.findIndex((variant) => variant.id === variantId); + if (index < 0) throw new Error('目标 SKU 已被删除,请重新选择'); + const ids = [...new Set([...(variants[index].media_asset_ids ?? []), ...images.flatMap((image) => image.assetId ? [image.assetId] : [])])]; + if (ids.length > 30) throw new Error('当前 SKU 最多 30 张图片,请先移除部分图片'); + form.setFieldValue(['variants', index, 'media_asset_ids'], ids); + setSuiteImages((current) => ({ ...current, [variantId]: [...(current[variantId] ?? []), ...images] })); + setDirty(true); + }} /> { const index = attributeIndex(attributes, definition, scope, variantId); return index < 0 ? [] : [{ definition, index }]; diff --git a/apps/web/src/pages/products/components/ImageSuiteReferencePicker.tsx b/apps/web/src/pages/products/components/ImageSuiteReferencePicker.tsx new file mode 100644 index 0000000..617994d --- /dev/null +++ b/apps/web/src/pages/products/components/ImageSuiteReferencePicker.tsx @@ -0,0 +1,303 @@ +import { useEffect, useRef, useState } from 'react'; +import { Alert, App, Button, Empty, Image, Modal, Spin } from 'antd'; +import type { CollectionItemInspection } from '@atlas/contracts'; +import { apiErrorMessage } from '@/services/api'; +import { mediaApi, uploadProductImage } from '@/services/media'; +import { SkuImageSourceTabs } from './SkuImageSourceTabs'; +import { useSkuImageLibrary } from './useSkuImageLibrary'; +import { SkuImageCard, type SkuEditorImage } from './SkuImageCard'; +import './SkuImageEditorModal.css'; + +type Props = { + open: boolean; + productId: string; + variantId: string; + selected: SkuEditorImage[]; + candidates: CollectionItemInspection['asset_transfers']; + onCancel: () => void; + onConfirm: (images: SkuEditorImage[]) => void; +}; + +export function ImageSuiteReferencePicker({ + open, + productId, + variantId, + selected, + candidates, + onCancel, + onConfirm, +}: Props) { + const { message } = App.useApp(); + const [draft, setDraft] = useState([]); + const [tab, setTab] = useState('collection'); + const [ai, setAi] = useState([]); + const [processed, setProcessed] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(''); + const [reload, setReload] = useState(0); + const dragIndex = useRef(null); + const [preview, setPreview] = useState<{ images: SkuEditorImage[]; index: number } | null>( + null + ); + const upload = async (file: File): Promise => { + const asset = await uploadProductImage(file), + access = await mediaApi.createPreviewUrl(asset.id); + return { + id: `asset:${asset.id}`, + assetId: asset.id, + url: access.url, + label: file.name, + origin: 'upload', + }; + }; + const library = useSkuImageLibrary(open, variantId, draft, upload); + useEffect(() => { + if (open) { + setDraft(selected); + setTab('collection'); + setPreview(null); + dragIndex.current = null; + } + }, [open, variantId]); + useEffect(() => { + if (!open) return; + let active = true; + setLoading(true); + setError(''); + void Promise.all([ + mediaApi.listAiEdits(productId), + mediaApi.listProductImageDerivatives(productId), + ]) + .then(([edits, derivatives]) => { + if (!active) return; + setAi( + edits.flatMap((edit) => + edit.output_asset + ? [ + { + id: `asset:${edit.output_asset.id}`, + assetId: edit.output_asset.id, + url: edit.output_asset.preview_url, + label: 'AI 生成图', + origin: 'derivative' as const, + }, + ] + : [] + ) + ); + setProcessed( + derivatives.map((record) => ({ + id: `asset:${record.output_asset.id}`, + assetId: record.output_asset.id, + url: record.output_asset.preview_url, + label: '图片处理结果', + origin: 'derivative' as const, + })) + ); + }) + .catch((cause) => { + if (active) setError(apiErrorMessage(cause)); + }) + .finally(() => { + if (active) setLoading(false); + }); + return () => { + active = false; + }; + }, [open, productId, reload]); + const same = (a: SkuEditorImage, b: SkuEditorImage) => + a.id === b.id || (!!a.assetId && a.assetId === b.assetId); + const toggle = (image: SkuEditorImage) => { + if (draft.some((item) => same(item, image))) + setDraft((items) => items.filter((item) => !same(item, image))); + else if (draft.length < 8) setDraft((items) => [...items, image]); + else message.warning('最多选择 8 张底图,请先移除一张'); + }; + const gallery = (images: SkuEditorImage[]) => + images.length ? ( +
+ {images.map((image, index) => ( + same(item, image))} + onToggle={() => toggle(image)} + onPreview={() => setPreview({ images, index })} + /> + ))} +
+ ) : ( + + ); + const collected = (role: string): SkuEditorImage[] => + candidates + .filter((item) => item.role === role) + .map((item) => ({ + id: `collection:${item.id}`, + transferId: item.id, + assetId: item.asset_id ?? undefined, + url: item.source_url, + label: + role === 'source_sku' + ? 'SKU 图' + : role === 'source_detail' + ? '详情图' + : '采集主图', + origin: 'collection', + })); + return ( + <> + onConfirm(draft)} + okText="确认选择" + width="min(1320px, 96vw)" + zIndex={1200} + className="sku-image-editor-modal ai-suite-picker" + centered + destroyOnHidden + > +
+ 已选 {draft.length} / 8 张 + + 最多 8 张 · 建议 3–6 张不同角度与细节图,第一张用于确定商品主体。 + +
+
+
+
+ 已选底图 + 拖动调整顺序 · 第一张为主体图 +
+
+ {draft.length ? ( +
+ {draft.map((image, index) => ( +
+ + setPreview({ images: draft, index }) + } + onPreview={() => + setPreview({ images: draft, index }) + } + onRemove={() => toggle(image)} + onDragStart={() => { + dragIndex.current = index; + }} + onDragOver={(event) => event.preventDefault()} + onDrop={(event) => { + event.preventDefault(); + const from = dragIndex.current; + dragIndex.current = null; + if (from === null || from === index) return; + setDraft((items) => { + const next = [...items]; + const [moved] = next.splice(from, 1); + if (moved) next.splice(index, 0, moved); + return next; + }); + }} + /> +
+ + {index === 0 + ? '01 · 主体图' + : `${String(index + 1).padStart(2, '0')} · 参考图`} + + {index > 0 && ( + + )} +
+
+ ))} +
+ ) : ( + + )} +
+
+
+ {error && ( + setReload((value) => value + 1)}> + 重新加载 + + } + /> + )} + + gallery(processed)} + networkImages={library.networkImages} + networkUrls={library.networkUrls} + onNetworkUrlsChange={library.setNetworkUrls} + networkAdding={library.networkAdding} + onAddNetworkImages={() => void library.addNetworkImages()} + uploadedImages={library.uploadedImages} + uploading={library.uploading} + onUploadFile={library.uploadToRight} + tabOrder={['collection', 'ai', 'processed', 'upload', 'network']} + /> + +
+
+
+ image.url) ?? []} + preview={{ + open: !!preview, + zIndex: 1300, + current: preview?.index ?? 0, + onOpenChange: (open) => { + if (!open) setPreview(null); + }, + onChange: (index) => + setPreview((current) => (current ? { ...current, index } : null)), + }} + /> + + ); +} diff --git a/apps/web/src/pages/products/components/ProductAttributeSection.tsx b/apps/web/src/pages/products/components/ProductAttributeSection.tsx index 5d690e4..90bdfe7 100644 --- a/apps/web/src/pages/products/components/ProductAttributeSection.tsx +++ b/apps/web/src/pages/products/components/ProductAttributeSection.tsx @@ -1,6 +1,7 @@ import { useMemo, useState } from 'react'; import { Alert, Button, Card, Empty, Form, Input, Select, Space, Spin, Typography } from 'antd'; import type { FormInstance } from 'antd'; +import { ozonHashtagProblem } from '@atlas/contracts'; import { EditOutlined, PlusCircleOutlined, TranslationOutlined } from '@ant-design/icons'; import type { ProductAttributeDictionaryValue, @@ -75,6 +76,8 @@ export function ProductAttributeSection({ }: Props) { // 富内容本地草稿:富内容能力接入前不写入表单、不随草稿保存。 const [richContent, setRichContent] = useState(''); + const tags = Form.useWatch(['profile', 'tags'], { form, preserve: true }) ?? []; + const tagsProblem = ozonHashtagProblem(tags); const commonDefinitions = useMemo( () => activeAttributeSchema.filter( @@ -97,14 +100,19 @@ export function ProductAttributeSection({ title="产品属性" description="用于当前平台发布与 AI 文案生成。" /> - +
({ + value: variant.id, + label: variant.display_name || variant.source_sku || variant.id, + }))} + /> + +
+
+ 参考底图 + + 已选 {references.length} / 8 张 + +
+
+ {references.map((image, index) => ( +
+ {image.label} + {index === 0 ? '主体图' : `参考 ${index + 1}`} +
+ ))} +
+ +

+ 最多 8 张 · 建议选择 3–6 张不同角度与细节图 +

+

+ 整套共用底图库,每张图选用最多 3 张相关底图 +

+
+ {(['product_name', 'color'] as const).map((key, index) => ( + + ))} +
+ 尺寸(实物) +
+ {(['length', 'width', 'height'] as const).map((axis, index) => ( + + changeDimensions({ + ...dimensionFields, + [axis]: value === null ? '' : String(value), + }) + } + /> + ))} + ({ + value: unit, + label: unit, + }))} + onChange={(unit) => + changeWeight(convertSuiteWeight(weightFields, unit)) + } + /> +
+
+ + +

+ 尺寸、重量为商品实物参数;未填写的参数不生成标注。 +

+ +
+
+

+ 02 生图策略 {shots.length} 张 +

+
+ +
+

+ {planning + ? `正在按 ${planTask?.input && 'image_count' in planTask.input ? planTask.input.image_count : shots.length} 张预算分析商品资料并规划…` + : `当前 ${shots.length} 张 · AI 按此总数规划,展开分类核对用途和依据`} +

+ + setExpanded(typeof keys === 'string' ? [keys] : keys) + } + className="ai-suite-plans" + items={suiteCategories.map((category) => { + const group = shots.filter( + (shot) => shot.category === category.key + ); + return { + key: category.key, + label: ( +
+ {category.label} +
+ {group.map((shot) => shot.theme).join(' · ') || + '暂无方案'} +
+
+ ), + extra: ( + event.stopPropagation()} + > + +
+ {shot.theme} +

{shot.description}

+ + 依据: + {shot.evidence || '请核对商品资料'} + + + )) + ) : ( + + )} +
+ ), + }; + })} + /> +
+ 视觉风格 +
+ {suiteStyleOptions.map((style) => ( + + ))} +
+ + + update({ + style_prompt: event.target.value, + }) + } + /> + + + ), + }, + ]} + /> +
+
+ 出图配置 +
+ +
+

+ 白底主图无文字,其他图片按所选语言排版。 +

+
+
+ void submit('generate')} + onRetry={() => void retry()} + onCancel={cancel} + /> + +
+

+ 03 生成结果{' '} + + {completed} / {task?.shots.length ?? shots.length} 张 + +

+ +
+ setEditing({ ...editing, type })} + options={suiteTypes(editing.category).map((type) => ({ + value: type, + label: suiteTypeLabels[type], + }))} + /> + + + + + + )} + + + ); +} diff --git a/apps/web/src/pages/products/components/ProductImageTaskCenter.tsx b/apps/web/src/pages/products/components/ProductImageTaskCenter.tsx index 29b332e..0ada3b9 100644 --- a/apps/web/src/pages/products/components/ProductImageTaskCenter.tsx +++ b/apps/web/src/pages/products/components/ProductImageTaskCenter.tsx @@ -4,6 +4,7 @@ import { ClockCircleOutlined, LoadingOutlined, ReloadOutlined } from '@ant-desig import type { ProductImageTask } from '@atlas/contracts'; import { apiErrorMessage } from '@/services/api'; import { mediaAiEditFailureDiagnosis, mediaAiEditFailureMessage, mediaApi } from '@/services/media'; +import { imageSuitesApi } from '@/services/imageSuites'; import './ProductImageTaskCenter.css'; const { Text } = Typography; @@ -82,7 +83,8 @@ export function ProductImageTaskCenter({ productId }: { productId: string }) { const cancel = async (task: ProductImageTask) => { setCancellingId(task.id); try { - await mediaApi.cancelAiEdit(task.id); + if (task.kind === 'ai_suite') await imageSuitesApi.cancel(productId, task.id); + else await mediaApi.cancelAiEdit(task.id); await refresh(); message.success('任务已取消'); } catch (cause) { @@ -121,18 +123,18 @@ export function ProductImageTaskCenter({ productId }: { productId: string }) { />} {task.status === 'partial' && 已完成 {task.completed}/{task.total} 张} {task.status === 'failed' && {mediaAiEditFailureMessage(task.error_code)}} - {task.status === 'failed' && + {(task.status === 'failed' || task.status === 'partial') && task.error_code && {mediaAiEditFailureDiagnosis(task)} } - {task.status === 'failed' && task.provider_error_raw &&
+ {(task.status === 'failed' || task.status === 'partial') && 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)}> } diff --git a/apps/web/src/pages/products/components/ProductPublishActions.tsx b/apps/web/src/pages/products/components/ProductPublishActions.tsx index 8a1db69..7a69ddf 100644 --- a/apps/web/src/pages/products/components/ProductPublishActions.tsx +++ b/apps/web/src/pages/products/components/ProductPublishActions.tsx @@ -101,8 +101,9 @@ export function ProductPublishActions({ clearTimeout(timer); }; }, [job?.id, job?.status, platformId]); - const eligible = (data: ProductEditorSnapshot) => - data.variants.filter((v) => ['not_listed', 'failed'].includes(v.listing.status)); + const canSelectSku = (variant: ProductEditorSnapshot['variants'][number]) => + ['not_listed', 'failed'].includes(variant.listing.status); + const eligible = (data: ProductEditorSnapshot) => data.variants.filter(canSelectSku); const skipReason = (status: string) => status === 'imported' ? '已有成功导入记录,不能重复提交' @@ -400,6 +401,28 @@ export function ProductPublishActions({ size="small" pagination={{ pageSize: 20 }} dataSource={saved.variants} + onRow={(variant) => ({ + style: { + cursor: canSelectSku(variant) ? 'pointer' : 'not-allowed', + }, + onClick: (event) => { + if (!canSelectSku(variant)) return; + // 复选框保留自身选择事件,避免冒泡到行时切换两次。 + if ( + event.target instanceof Element && + event.target.closest( + 'input, button, a, label, .ant-checkbox-wrapper' + ) + ) + return; + setIds((selected) => + selected.includes(variant.id) + ? selected.filter((id) => id !== variant.id) + : [...selected, variant.id] + ); + setPreflight(null); + }, + })} rowSelection={{ selectedRowKeys: ids, onChange: (keys) => { @@ -407,9 +430,7 @@ export function ProductPublishActions({ setPreflight(null); }, getCheckboxProps: (v) => ({ - disabled: !['not_listed', 'failed'].includes( - v.listing.status - ), + disabled: !canSelectSku(v), }), }} columns={[ @@ -441,10 +462,15 @@ export function ProductPublishActions({ { title: 'SKU', dataIndex: 'display_name' }, { title: '货号', render: (_, v) => v.listing.offer_id }, { - title: '售价', + title: '实际售价', render: (_, v) => `${v.listing.selling_price} ${saved.profile.target_store.currency_code}`, }, + { + title: '划线价(本次上报价)', + render: (_, v) => + `${v.listing.list_price ?? '未确认'} ${saved.profile.target_store.currency_code}`, + }, { title: '图片', render: (_, v) => `${v.media.length} 张` }, ]} /> @@ -474,6 +500,7 @@ export function ProductPublishActions({ type="success" showIcon title={`预检通过,将提交 ${ids.length} 个 SKU`} + description="首次上架按划线价发布,上线后可在 Ozon 调整实际售价。" style={{ marginTop: 16 }} /> )} diff --git a/apps/web/src/pages/products/components/ProductVariantSection.css b/apps/web/src/pages/products/components/ProductVariantSection.css index 2deb278..2cc7516 100644 --- a/apps/web/src/pages/products/components/ProductVariantSection.css +++ b/apps/web/src/pages/products/components/ProductVariantSection.css @@ -82,7 +82,7 @@ .sku-main-count { position: absolute; z-index: 1; - left: 8px; + left: 4px; padding: 3px 6px; border-radius: 5px; background: rgb(41 36 51 / 82%); @@ -92,10 +92,10 @@ white-space: nowrap; } .sku-main-badge { - top: 8px; + top: 4px; } .sku-main-count { - bottom: 8px; + bottom: 4px; display: inline-flex; align-items: center; gap: 4px; @@ -185,7 +185,7 @@ } .sku-pricing-summary { display: grid; - grid-template-columns: repeat(3, minmax(0, 1fr)); + grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 10px; min-width: 0; } @@ -272,7 +272,7 @@ } .sku-pricing-fields { display: grid; - grid-template-columns: repeat(3, minmax(0, 1fr)); + grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 11px; } .sku-pricing-fields > label, @@ -619,11 +619,15 @@ .sku-pricing-fields { grid-template-columns: repeat(2, minmax(0, 1fr)); } - .sku-pricing-fields > div:last-child { - grid-column: 1 / -1; - } } @container (max-width: 430px) { + .sku-pricing-summary { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + .sku-pricing-summary > div:first-child, + .sku-pricing-summary > div:last-child { + grid-column: 1 / -1; + } .sku-pricing-summary strong { font-size: 14px; } diff --git a/apps/web/src/pages/products/components/ProductVariantSection.tsx b/apps/web/src/pages/products/components/ProductVariantSection.tsx index 90637c9..4d26c93 100644 --- a/apps/web/src/pages/products/components/ProductVariantSection.tsx +++ b/apps/web/src/pages/products/components/ProductVariantSection.tsx @@ -106,6 +106,7 @@ type Props = { onCompareCopy: (variantId: string, field: 'title' | 'tags' | 'description') => void; onAttributeChanged: () => void; onImagesChanged: () => void; + suiteImagesByVariant?: Record; }; /** @@ -137,6 +138,7 @@ export function ProductVariantSection({ onCompareCopy, onAttributeChanged, onImagesChanged, + suiteImagesByVariant = {}, }: Props) { const [pricingAuto, setPricingAuto] = useState( () => localStorage.getItem('atlas.pricing.auto') !== 'false' @@ -166,11 +168,14 @@ export function ProductVariantSection({ const refresh = async () => { const ids = [ ...new Set( - snapshot.variants.flatMap((variant) => - variant.media - .filter((media) => !media.source_url) - .map((media) => media.asset_id) - ) + [ + ...snapshot.variants.flatMap((variant) => + variant.media.map((media) => media.asset_id) + ), + ...(collectionRecord?.asset_transfers ?? []).flatMap((transfer) => + transfer.asset_id ? [transfer.asset_id] : [] + ), + ] ), ]; const previews = await Promise.allSettled( @@ -191,7 +196,7 @@ export function ProductVariantSection({ active = false; window.clearInterval(interval); }; - }, [snapshot]); + }, [snapshot, collectionRecord]); // SKU 类目属性放入展开后的“规格与发布信息”,必填状态仍由 schema 决定。 const skuInfoAspectDefinitions = activeAttributeSchema .filter((definition) => definition.is_aspect && !definition.editor_field) @@ -298,12 +303,13 @@ export function ProductVariantSection({ const candidate = imageCandidates.find( (item) => item.asset_id === assetId ); + // 来源地址可能过期,已转存素材始终优先使用新签发的预览链接。 const url = + assetPreviewUrls[assetId] ?? candidate?.source_url ?? original?.media.find( (media) => media.asset_id === assetId - )?.source_url ?? - assetPreviewUrls[assetId]; + )?.source_url; return url ? { id: candidate @@ -317,8 +323,15 @@ export function ProductVariantSection({ : null; }) .filter((image): image is SkuEditorImage => image !== null); - const selectedImages = - localImagesByVariant[variantKey] ?? savedImages; + const previewByAsset = new Map([ + ...savedImages, + ...(localImagesByVariant[variantKey] ?? []), + ...(suiteImagesByVariant[variantKey] ?? []), + ].filter((image) => image.assetId).map((image) => [image.assetId!, image])); + const selectedImages = selectedAssetIds.flatMap((id) => { + const image = previewByAsset.get(id); + return image ? [{ ...image, url: assetPreviewUrls[id] ?? image.url }] : []; + }); const sourceMainCandidate = !selectedAssetIds.length && !selectedImages.length && @@ -333,7 +346,7 @@ export function ProductVariantSection({ ? [ { id: `collection:${sourceMainCandidate.id}`, - url: sourceMainCandidate.source_url, + url: (sourceMainCandidate.asset_id && assetPreviewUrls[sourceMainCandidate.asset_id]) ?? sourceMainCandidate.source_url, label: '来源 SKU 图片', origin: 'collection', transferId: sourceMainCandidate.id, @@ -612,6 +625,7 @@ export function ProductVariantSection({ skuKey={imageEditorTarget?.key ?? ''} skuName={imageEditorTarget?.name ?? ''} initialImages={imageEditorTarget?.images ?? []} + previewUrls={assetPreviewUrls} collectionImages={imageEditorTarget?.candidates ?? []} onCancel={() => setImageEditorTarget(null)} onConfirm={async (images) => { diff --git a/apps/web/src/pages/products/components/SkuImageCard.tsx b/apps/web/src/pages/products/components/SkuImageCard.tsx index 26e7b95..275794c 100644 --- a/apps/web/src/pages/products/components/SkuImageCard.tsx +++ b/apps/web/src/pages/products/components/SkuImageCard.tsx @@ -23,7 +23,8 @@ type Props = { mode: 'selected' | 'source'; selected: boolean; cover?: boolean; - menu: MenuProps; + coverLabel?: string; + menu?: MenuProps; onToggle: () => void; onPreview: () => void; onRemove?: () => void; @@ -38,6 +39,7 @@ export function SkuImageCard({ mode, selected, cover, + coverLabel = '主图', menu, onToggle, onPreview, @@ -48,7 +50,7 @@ export function SkuImageCard({ }: Props) { const isSelectedColumn = mode === 'selected'; const handleKeyDown = (event: KeyboardEvent) => { - if (event.key === 'Enter' || event.key === ' ') { + if (event.target === event.currentTarget && (event.key === 'Enter' || event.key === ' ')) { event.preventDefault(); onToggle(); } @@ -69,7 +71,7 @@ export function SkuImageCard({ onKeyDown={handleKeyDown} > {image.label} - {cover && 主图} + {cover && {coverLabel}} {!isSelectedColumn && selected && ( @@ -101,11 +103,13 @@ export function SkuImageCard({ }} /> - - - + {menu && ( + + + + )}
); diff --git a/apps/web/src/pages/products/components/SkuImageEditorModal.tsx b/apps/web/src/pages/products/components/SkuImageEditorModal.tsx index a97f1e1..83f36e8 100644 --- a/apps/web/src/pages/products/components/SkuImageEditorModal.tsx +++ b/apps/web/src/pages/products/components/SkuImageEditorModal.tsx @@ -23,6 +23,7 @@ type Props = { skuKey: string; skuName: string; initialImages: SkuEditorImage[]; + previewUrls: Record; collectionImages: CollectionImage[]; onCancel: () => void; onConfirm: (images: SkuEditorImage[]) => Promise; @@ -46,6 +47,7 @@ export function SkuImageEditorModal({ skuKey, skuName, initialImages, + previewUrls, collectionImages, onCancel, onConfirm, @@ -441,7 +443,7 @@ export function SkuImageEditorModal({ .filter((image) => image.role === role) .map((image) => ({ id: `collection:${image.id}`, - url: image.source_url, + url: (image.asset_id && previewUrls[image.asset_id]) ?? image.source_url, label: image.role === 'source_main' ? '采集主图' diff --git a/apps/web/src/pages/products/components/SkuImageSourceTabs.tsx b/apps/web/src/pages/products/components/SkuImageSourceTabs.tsx index ac02b13..10c0658 100644 --- a/apps/web/src/pages/products/components/SkuImageSourceTabs.tsx +++ b/apps/web/src/pages/products/components/SkuImageSourceTabs.tsx @@ -26,6 +26,7 @@ type Props = { uploadedImages: SkuEditorImage[]; uploading: boolean; onUploadFile: (file: File) => Promise; + tabOrder?: string[]; }; /** 图片来源分区只负责展示和输入,选中及素材状态由弹窗控制。 */ @@ -44,6 +45,7 @@ export function SkuImageSourceTabs({ uploadedImages, uploading, onUploadFile, + tabOrder, }: Props) { return ( ), }, - ]} + ].sort((left, right) => tabOrder ? tabOrder.indexOf(left.key) - tabOrder.indexOf(right.key) : 0)} /> ); } diff --git a/apps/web/src/pages/products/components/SkuInfoPanel.tsx b/apps/web/src/pages/products/components/SkuInfoPanel.tsx index 6ea3769..e07dcd7 100644 --- a/apps/web/src/pages/products/components/SkuInfoPanel.tsx +++ b/apps/web/src/pages/products/components/SkuInfoPanel.tsx @@ -1,5 +1,6 @@ import { Button, Form, Input, Select, Space, Typography } from 'antd'; import type { FormInstance } from 'antd'; +import { ozonHashtagProblem } from '@atlas/contracts'; import { RobotOutlined, TranslationOutlined } from '@ant-design/icons'; import type { ProductAttributeDictionaryValue, @@ -81,6 +82,13 @@ export function SkuInfoPanel({ onAiCopy, onCompareCopy, }: Props) { + // 通用模式也展示 SKU 实际使用的文案,预览不写入 SKU 覆盖字段。 + const profile = Form.useWatch('profile', { form, preserve: true }) as + EditorFormValues['profile'] | undefined; + const ownTags = Form.useWatch(['variants', fieldName, 'tags'], { form, preserve: true }) ?? []; + const tagsProblem = ozonHashtagProblem( + usesVariantTags && ownTags.length ? ownTags : profile?.tags ?? [] + ); return (
@@ -90,7 +98,12 @@ export function SkuInfoPanel({ - + {(usesVariantPackageWeight || usesVariantPackageDimensions) && ( @@ -148,100 +161,130 @@ export function SkuInfoPanel({ )}
发布文案与类目属性
- {usesVariantTitle && ( - - 俄文标题 + + 俄文标题 + {usesVariantTitle ? ( - - } - layout="vertical" - className="sku-info-variant-title" - required - > - - - - -