feat(ai-image): AI生图开发

This commit is contained in:
2026-09-27 22:40:52 +08:00
parent e670d06ca6
commit a29dd92676
105 changed files with 9943 additions and 361 deletions
+3
View File
@@ -87,5 +87,8 @@ EXTENSION_PORT=8110
# DeepSeek API Key # DeepSeek API Key
DEEPSEEK_API_KEY= DEEPSEEK_API_KEY=
# 单张 AI 图片编辑使用 Pcore 的 gpt-image-2 异步图片接口;根地址和 /v1 地址均可,仅 Worker 读取。
PCORE_BASE_URL=https://pcore.ai/v1
PCORE_API_KEY=
# DeepSeek Model Name # DeepSeek Model Name
DEEPSEEK_MODEL_NAME=deepseek-flash DEEPSEEK_MODEL_NAME=deepseek-flash
+2
View File
@@ -13,6 +13,7 @@ import { CollectionModule } from './collection/collection.module.js';
import { ExtensionModule } from './extension/extension.module.js'; import { ExtensionModule } from './extension/extension.module.js';
import { ProductEditorModule } from './product-editor/product-editor.module.js'; import { ProductEditorModule } from './product-editor/product-editor.module.js';
import { PlatformCatalogAdminModule } from './platform-catalog-admin/platform-catalog-admin.module.js'; import { PlatformCatalogAdminModule } from './platform-catalog-admin/platform-catalog-admin.module.js';
import { OnlineProductsModule } from './online-products/online-products.module.js';
@Controller() @Controller()
class HealthController { class HealthController {
@@ -53,6 +54,7 @@ class HealthController {
ExtensionModule, ExtensionModule,
ProductEditorModule, ProductEditorModule,
PlatformCatalogAdminModule, PlatformCatalogAdminModule,
OnlineProductsModule,
], ],
controllers: [HealthController], controllers: [HealthController],
}) })
+16 -3
View File
@@ -321,12 +321,14 @@ export class ExtensionService {
await tx.query( await tx.query(
`UPDATE atlas.product_variants `UPDATE atlas.product_variants
SET source_sku = $1, display_name = $2, sort_order = $3, SET source_sku = $1, display_name = $2, sort_order = $3,
purchase_cost_cny = COALESCE(ROUND($4::numeric, 2), purchase_cost_cny),
deleted_at = NULL, updated_at = now() deleted_at = NULL, updated_at = now()
WHERE id = $4`, WHERE id = $5`,
[ [
variant.sourceSku ?? null, variant.sourceSku ?? null,
variant.displayName, variant.displayName,
index, index,
sourcePurchaseCost(payload.source.platform, variant),
variantId, variantId,
], ],
); );
@@ -334,8 +336,8 @@ export class ExtensionService {
} else { } else {
const inserted = await tx.query<{ id: string }>( const inserted = await tx.query<{ id: string }>(
`INSERT INTO atlas.product_variants `INSERT INTO atlas.product_variants
(workspace_id, product_id, source_variant_key, source_sku, display_name, sort_order) (workspace_id, product_id, source_variant_key, source_sku, display_name, sort_order, purchase_cost_cny)
VALUES ($1, $2, $3, $4, $5, $6) RETURNING id`, VALUES ($1, $2, $3, $4, $5, $6, ROUND($7::numeric, 2)) RETURNING id`,
[ [
workspaceId, workspaceId,
productId, productId,
@@ -343,6 +345,7 @@ export class ExtensionService {
variant.sourceSku ?? null, variant.sourceSku ?? null,
variant.displayName, variant.displayName,
index, index,
sourcePurchaseCost(payload.source.platform, variant),
], ],
); );
variantId = inserted.rows[0].id; variantId = inserted.rows[0].id;
@@ -486,3 +489,13 @@ function sourceState(payload: CollectedProductPayload) {
sourcePrice: payload.common.sourcePrice, sourcePrice: payload.common.sourcePrice,
}; };
} }
/** 仅将 1688 明确标为人民币的逐 SKU 来源价写入采购价。 */
function sourcePurchaseCost(
platform: CollectedProductPayload['source']['platform'],
variant: CollectedProductPayload['variants'][number] | { sourceVariantKey: null },
) {
return platform === '1688' && 'sourcePrice' in variant && variant.sourcePrice?.currencyCode === 'CNY'
? variant.sourcePrice.amount
: null;
}
@@ -0,0 +1,139 @@
import { Inject, Injectable } from '@nestjs/common';
import type { AspectRatioDerivative, CreateAspectRatioDerivativeRequest } from '@atlas/contracts';
import type { Database } from '@atlas/database';
import { ApiError } from '../common/api-error.js';
import { IdempotencyService } from '../common/idempotency.service.js';
import { DATABASE } from '../database/database.module.js';
import { MediaService } from './media.service.js';
type DerivativeRow = {
id: string;
status: AspectRatioDerivative['status'];
source_asset_id: string;
output_asset_id: string | null;
error_code: string | null;
content_type: string | null;
byte_size: string | null;
width_px: number | null;
height_px: number | null;
asset_created_at: Date | string | null;
};
@Injectable()
export class AspectRatioDerivativeService {
public constructor(
@Inject(DATABASE) private readonly database: Database,
@Inject(IdempotencyService) private readonly idempotency: IdempotencyService,
@Inject(MediaService) private readonly media: MediaService,
) {}
/** 任务、用户可见进度和 Outbox 必须在同一工作空间事务中创建。 */
public async create(
workspaceId: string,
userId: string,
assetId: string,
key: string,
input: CreateAspectRatioDerivativeRequest,
) {
return this.idempotency.run(
{
workspaceId,
userId,
method: 'POST',
path: `/api/media-assets/${assetId}/aspect-ratio-derivatives`,
key,
request: input,
},
async (tx) => {
const source = await tx.query<{ id: string }>(
`SELECT id FROM atlas.media_assets
WHERE id = $1 AND content_type IN ('image/jpeg', 'image/png', 'image/webp')
AND byte_size <= 20971520 AND width_px BETWEEN 1 AND 10000
AND height_px BETWEEN 1 AND 10000`,
[assetId],
);
if (!source.rows[0])
throw new ApiError(404, 'RESOURCE_NOT_FOUND', '图片素材不存在或不可处理');
if (input.product_id) {
const product = await tx.query<{ id: string }>(
'SELECT id FROM atlas.products WHERE id = $1',
[input.product_id],
);
if (!product.rows[0])
throw new ApiError(404, 'RESOURCE_NOT_FOUND', '商品不存在');
}
const created = await tx.query<{ id: string }>(
`INSERT INTO atlas.media_aspect_ratio_derivatives
(workspace_id, product_id, source_asset_id, created_by_user_id)
VALUES (current_setting('app.workspace_id')::uuid, $1, $2, $3)
RETURNING id`,
[input.product_id ?? null, assetId, userId],
);
const id = created.rows[0].id;
await tx.query(
`INSERT INTO atlas.operations
(id, workspace_id, kind, resource_type, resource_id, created_by_user_id)
VALUES ($1, current_setting('app.workspace_id')::uuid,
'media_aspect_ratio_derivative', 'media_derivative', $1, $2)`,
[id, userId],
);
await tx.query(
`INSERT INTO atlas.outbox_events
(workspace_id, aggregate_type, aggregate_id, event_type, event_payload, deduplication_key)
VALUES (current_setting('app.workspace_id')::uuid, 'media_derivative', $1,
'media.aspect_ratio_derivative.requested',
jsonb_build_object('derivative_id', $1::uuid), $1::text)`,
[id],
);
return {
status: 202,
body: { data: {
id,
status: 'queued',
source_asset_id: assetId,
output_asset: null,
error_code: null,
} satisfies AspectRatioDerivative },
};
},
);
}
/** 短时预览地址仅在读取成功结果时签发,不写入任务或草稿。 */
public async read(workspaceId: string, derivativeId: string): Promise<AspectRatioDerivative> {
const row = await this.database.workspaceTransaction(workspaceId, async (tx) => {
const result = await tx.query<DerivativeRow>(
`SELECT d.id, d.status, d.source_asset_id, d.output_asset_id, d.error_code,
a.content_type, a.byte_size::text, a.width_px, a.height_px,
a.created_at AS asset_created_at
FROM atlas.media_aspect_ratio_derivatives d
LEFT JOIN atlas.media_assets a ON a.id = d.output_asset_id
WHERE d.id = $1`,
[derivativeId],
);
return result.rows[0] ?? null;
});
if (!row) throw new ApiError(404, 'RESOURCE_NOT_FOUND', '图片处理任务不存在');
const access = row.output_asset_id
? await this.media.accessUrl(workspaceId, row.output_asset_id)
: null;
return {
id: row.id,
status: row.status,
source_asset_id: row.source_asset_id,
error_code: row.error_code,
output_asset: row.output_asset_id && access ? {
id: row.output_asset_id,
content_type: row.content_type!,
byte_size: Number(row.byte_size),
width_px: row.width_px,
height_px: row.height_px,
created_at: row.asset_created_at instanceof Date
? row.asset_created_at.toISOString()
: String(row.asset_created_at),
preview_url: access.url,
preview_url_expires_at: access.expires_at,
} : null,
};
}
}
+166
View File
@@ -0,0 +1,166 @@
import { Inject, Injectable } from '@nestjs/common';
import type { MediaAiEdit, MediaAiEditSettings } from '@atlas/contracts';
import type { Database } from '@atlas/database';
import { ApiError } from '../common/api-error.js';
import { IdempotencyService } from '../common/idempotency.service.js';
import { DATABASE } from '../database/database.module.js';
import { MediaService } from './media.service.js';
type EditRow = {
id: string;
status: MediaAiEdit['status'];
source_asset_id: string;
output_asset_id: string | null;
error_code: string | null;
failure_source?: MediaAiEdit['failure_source'];
failure_stage?: MediaAiEdit['failure_stage'];
failure_detail?: string | null;
content_type: string | null;
byte_size: string | null;
width_px: number | null;
height_px: number | null;
asset_created_at: Date | string | null;
};
@Injectable()
export class MediaAiEditService {
public constructor(
@Inject(DATABASE) private readonly database: Database,
@Inject(IdempotencyService) private readonly idempotency: IdempotencyService,
@Inject(MediaService) private readonly media: MediaService,
) {}
/** 创建任务前确认主图和辅图均为本工作空间已校验的正式素材。 */
public async create(workspaceId: string, userId: string, sourceId: string, key: string, settings: MediaAiEditSettings) {
return this.idempotency.run({
workspaceId, userId, method: 'POST', path: `/api/media-assets/${sourceId}/ai-edits`,
key, request: settings,
}, async (tx) => {
const allIds = [sourceId, ...settings.reference_asset_ids];
const product = await tx.query<{ id: string }>(
'SELECT id FROM atlas.products WHERE id = $1', [settings.product_id],
);
if (!product.rows[0]) throw new ApiError(404, 'RESOURCE_NOT_FOUND', '商品不存在');
if (new Set(allIds).size !== allIds.length)
throw new ApiError(422, 'INVALID_REFERENCE_IMAGE', '主图和辅助图不能重复');
const assets = await tx.query<{ id: string }>(
`SELECT id FROM atlas.media_assets WHERE id = ANY($1::uuid[])
AND content_type IN ('image/jpeg', 'image/png', 'image/webp')
AND byte_size <= 20971520 AND width_px BETWEEN 1 AND 10000
AND height_px BETWEEN 1 AND 10000`, [allIds],
);
if (assets.rows.length !== allIds.length)
throw new ApiError(404, 'RESOURCE_NOT_FOUND', '图片素材不存在或不可处理');
const created = await tx.query<{ id: string }>(
`INSERT INTO atlas.media_ai_edits
(workspace_id, product_id, source_asset_id, reference_asset_ids, settings, created_by_user_id)
VALUES (current_setting('app.workspace_id')::uuid, $1, $2, $3::uuid[], $4::jsonb, $5)
RETURNING id`,
[settings.product_id, sourceId, settings.reference_asset_ids, JSON.stringify(settings), userId],
);
const id = created.rows[0].id;
await tx.query(
`INSERT INTO atlas.operations (id, workspace_id, kind, resource_type, resource_id, created_by_user_id)
VALUES ($1, current_setting('app.workspace_id')::uuid, 'media_ai_edit', 'media_ai_edit', $1, $2)`,
[id, userId],
);
await tx.query(
`INSERT INTO atlas.outbox_events
(workspace_id, aggregate_type, aggregate_id, event_type, event_payload, deduplication_key)
VALUES (current_setting('app.workspace_id')::uuid, 'media_ai_edit', $1,
'media.ai_edit.requested', jsonb_build_object('edit_id', $1::uuid), $1::text)`, [id],
);
return { status: 202, body: { data: {
id, status: 'queued', source_asset_id: sourceId, output_asset: null, error_code: null,
failure_source: null, failure_stage: null, failure_detail: null,
} satisfies MediaAiEdit } };
});
}
public async read(workspaceId: string, id: string): Promise<MediaAiEdit> {
const row = await this.database.workspaceTransaction(workspaceId, async (tx) => {
const result = await tx.query<EditRow>(
`SELECT e.id, e.status, e.source_asset_id, e.output_asset_id, e.error_code,
o.result ->> 'failure_source' AS failure_source,
o.result ->> 'failure_stage' AS failure_stage,
o.result ->> 'failure_detail' AS failure_detail,
a.content_type, a.byte_size::text, a.width_px, a.height_px,
a.created_at AS asset_created_at
FROM atlas.media_ai_edits e
LEFT JOIN atlas.operations o ON o.id = e.id AND o.workspace_id = e.workspace_id
LEFT JOIN atlas.media_assets a ON a.id = e.output_asset_id
WHERE e.id = $1`, [id],
);
return result.rows[0] ?? null;
});
if (!row) throw new ApiError(404, 'RESOURCE_NOT_FOUND', 'AI 编辑任务不存在');
const access = row.output_asset_id ? await this.media.accessUrl(workspaceId, row.output_asset_id) : null;
return {
id: row.id, status: row.status, source_asset_id: row.source_asset_id, error_code: row.error_code,
failure_source: row.status === 'failed' ? row.failure_source ?? null : null,
failure_stage: row.status === 'failed' ? row.failure_stage ?? null : null,
failure_detail: row.status === 'failed' ? row.failure_detail ?? null : null,
output_asset: row.output_asset_id && access ? {
id: row.output_asset_id, content_type: row.content_type!, byte_size: Number(row.byte_size),
width_px: row.width_px, height_px: row.height_px,
created_at: row.asset_created_at instanceof Date
? row.asset_created_at.toISOString() : String(row.asset_created_at),
preview_url: access.url, preview_url_expires_at: access.expires_at,
} : null,
};
}
/** 右侧素材库展示最近完成的生成图,预览链接每次读取时重新签发。 */
public async list(workspaceId: string, productId: string): Promise<MediaAiEdit[]> {
const rows = await this.database.workspaceTransaction(workspaceId, async (tx) => {
const result = await tx.query<EditRow>(
`SELECT e.id, e.status, e.source_asset_id, e.output_asset_id, e.error_code,
a.content_type, a.byte_size::text, a.width_px, a.height_px,
a.created_at AS asset_created_at
FROM atlas.media_ai_edits e
JOIN atlas.media_assets a ON a.id = e.output_asset_id
WHERE e.status = 'succeeded' AND e.product_id = $1
ORDER BY e.completed_at DESC LIMIT 100`, [productId],
);
return result.rows;
});
return Promise.all(rows.map(async (row) => {
const access = await this.media.accessUrl(workspaceId, row.output_asset_id!);
return {
id: row.id, status: 'succeeded' as const, source_asset_id: row.source_asset_id,
error_code: null,
failure_source: null, failure_stage: null, failure_detail: null,
output_asset: {
id: row.output_asset_id!, content_type: row.content_type!,
byte_size: Number(row.byte_size), width_px: row.width_px, height_px: row.height_px,
created_at: row.asset_created_at instanceof Date
? row.asset_created_at.toISOString() : String(row.asset_created_at),
preview_url: access.url, preview_url_expires_at: access.expires_at,
},
};
}));
}
/** 取消是工作空间内的幂等状态变更;已完成结果不回滚。 */
public async cancel(workspaceId: string, userId: string, id: string, key: string) {
return this.idempotency.run({
workspaceId, userId, method: 'POST', path: `/api/media-ai-edits/${id}/cancel`, key, request: {},
}, async (tx) => {
const existing = await tx.query<{ status: MediaAiEdit['status'] }>(
'SELECT status FROM atlas.media_ai_edits WHERE id = $1 FOR UPDATE', [id],
);
if (!existing.rows[0]) throw new ApiError(404, 'RESOURCE_NOT_FOUND', 'AI 编辑任务不存在');
if (existing.rows[0].status === 'queued' || existing.rows[0].status === 'running') {
await tx.query(
`UPDATE atlas.media_ai_edits SET status = 'cancelled', completed_at = now() WHERE id = $1`, [id],
);
await tx.query(
`UPDATE atlas.operations SET status = 'cancelled', completed_at = now()
WHERE id = $1 AND status IN ('queued', 'running')`, [id],
);
}
return { status: 200, body: { data: { id, status: existing.rows[0].status === 'queued'
|| existing.rows[0].status === 'running' ? 'cancelled' : existing.rows[0].status } } };
});
}
}
+139 -2
View File
@@ -6,7 +6,9 @@ import {
Get, Get,
Param, Param,
Post, Post,
Query,
Req, Req,
Res,
} from '@nestjs/common'; } from '@nestjs/common';
import { import {
ApiBearerAuth, ApiBearerAuth,
@@ -19,6 +21,11 @@ import {
createMediaAccessUrlRequestSchema, createMediaAccessUrlRequestSchema,
createMediaUploadIntentRequestSchema, createMediaUploadIntentRequestSchema,
importProductImageRequestSchema, importProductImageRequestSchema,
createAspectRatioDerivativeRequestSchema,
createWatermarkDerivativeRequestSchema,
createMediaAiEditRequestSchema,
listProductImageTasksQuerySchema,
type CreateMediaAccessUrlRequest,
type Workspace, type Workspace,
} from '@atlas/contracts'; } from '@atlas/contracts';
import { z } from 'zod'; import { z } from 'zod';
@@ -26,6 +33,11 @@ import { ApiError } from '../common/api-error.js';
import type { AuthenticatedRequest } from '../auth/auth.guard.js'; import type { AuthenticatedRequest } from '../auth/auth.guard.js';
import { CurrentWorkspace } from '../workspace/current-workspace.decorator.js'; import { CurrentWorkspace } from '../workspace/current-workspace.decorator.js';
import { MediaService } from './media.service.js'; import { MediaService } from './media.service.js';
import { AspectRatioDerivativeService } from './aspect-ratio-derivative.service.js';
import { WatermarkDerivativeService } from './watermark-derivative.service.js';
import { MediaAiEditService } from './media-ai-edit.service.js';
import { ProductImageTaskService } from './product-image-task.service.js';
import { ProductImageDerivativeHistoryService } from './product-image-derivative-history.service.js';
@ApiTags('Media') @ApiTags('Media')
@ApiBearerAuth('access-token') @ApiBearerAuth('access-token')
@@ -33,8 +45,132 @@ import { MediaService } from './media.service.js';
export class MediaController { export class MediaController {
public constructor( public constructor(
@Inject(MediaService) private readonly media: MediaService, @Inject(MediaService) private readonly media: MediaService,
@Inject(AspectRatioDerivativeService) private readonly derivatives: AspectRatioDerivativeService,
@Inject(WatermarkDerivativeService) private readonly watermarkDerivatives: WatermarkDerivativeService,
@Inject(MediaAiEditService) private readonly aiEdits: MediaAiEditService,
@Inject(ProductImageTaskService) private readonly productImageTasks: ProductImageTaskService,
@Inject(ProductImageDerivativeHistoryService) private readonly derivativeHistory: ProductImageDerivativeHistoryService,
) {} ) {}
@Get('products/:productId/image-derivatives')
public async listProductImageDerivatives(
@Param('productId') productId: string,
@CurrentWorkspace() workspace: Workspace,
) {
return { data: await this.derivativeHistory.list(workspace.id, uuid('product_id', productId)) };
}
@Get('products/:productId/image-tasks')
public async listProductImageTasks(
@Param('productId') productId: string,
@CurrentWorkspace() workspace: Workspace,
@Query() query: unknown,
) {
return this.productImageTasks.list(workspace.id, uuid('product_id', productId),
parse(listProductImageTasksQuerySchema, query));
}
@Post('media-assets/:assetId/ai-edits')
public async createAiEdit(
@Param('assetId') assetId: string,
@Body() body: unknown,
@Headers('idempotency-key') key: string | undefined,
@Req() request: AuthenticatedRequest,
@CurrentWorkspace() workspace: Workspace,
@Res({ passthrough: true }) response: { status: (code: number) => void },
) {
const result = await this.aiEdits.create(
workspace.id, request.auth.userId, uuid('asset_id', assetId),
idempotencyKey(key), parse(createMediaAiEditRequestSchema, body),
);
response.status(result.status);
return result.body;
}
@Get('media-ai-edits/:editId')
public async aiEdit(
@Param('editId') editId: string,
@CurrentWorkspace() workspace: Workspace,
) {
return { data: await this.aiEdits.read(workspace.id, uuid('edit_id', editId)) };
}
@Get('media-ai-edits')
public async listAiEdits(
@Query('product_id') productId: string,
@CurrentWorkspace() workspace: Workspace,
) {
return { data: await this.aiEdits.list(workspace.id, uuid('product_id', productId)) };
}
@Post('media-ai-edits/:editId/cancel')
public async cancelAiEdit(
@Param('editId') editId: string,
@Headers('idempotency-key') key: string | undefined,
@Req() request: AuthenticatedRequest,
@CurrentWorkspace() workspace: Workspace,
) {
return (await this.aiEdits.cancel(
workspace.id, request.auth.userId, uuid('edit_id', editId), idempotencyKey(key),
)).body;
}
@Post('media-assets/:assetId/watermark-derivatives')
public async createWatermarkDerivative(
@Param('assetId') assetId: string,
@Body() body: unknown,
@Headers('idempotency-key') key: string | undefined,
@Req() request: AuthenticatedRequest,
@CurrentWorkspace() workspace: Workspace,
@Res({ passthrough: true }) response: { status: (code: number) => void },
) {
const result = await this.watermarkDerivatives.create(
workspace.id,
request.auth.userId,
uuid('asset_id', assetId),
idempotencyKey(key),
parse(createWatermarkDerivativeRequestSchema, body),
);
response.status(result.status);
return result.body;
}
@Get('media-watermark-derivatives/:derivativeId')
public async watermarkDerivative(
@Param('derivativeId') derivativeId: string,
@CurrentWorkspace() workspace: Workspace,
) {
return { data: await this.watermarkDerivatives.read(workspace.id, uuid('derivative_id', derivativeId)) };
}
@Post('media-assets/:assetId/aspect-ratio-derivatives')
public async createAspectRatioDerivative(
@Param('assetId') assetId: string,
@Body() body: unknown,
@Headers('idempotency-key') key: string | undefined,
@Req() request: AuthenticatedRequest,
@CurrentWorkspace() workspace: Workspace,
@Res({ passthrough: true }) response: { status: (code: number) => void },
) {
const result = await this.derivatives.create(
workspace.id,
request.auth.userId,
uuid('asset_id', assetId),
idempotencyKey(key),
parse(createAspectRatioDerivativeRequestSchema, body),
);
response.status(result.status);
return result.body;
}
@Get('media-derivatives/:derivativeId')
public async aspectRatioDerivative(
@Param('derivativeId') derivativeId: string,
@CurrentWorkspace() workspace: Workspace,
) {
return { data: await this.derivatives.read(workspace.id, uuid('derivative_id', derivativeId)) };
}
@Get('media-assets/product-uploads') @Get('media-assets/product-uploads')
@ApiOkResponse({ description: '最近上传的商品图片及短时预览地址' }) @ApiOkResponse({ description: '最近上传的商品图片及短时预览地址' })
public async productUploads(@CurrentWorkspace() workspace: Workspace) { public async productUploads(@CurrentWorkspace() workspace: Workspace) {
@@ -102,17 +238,18 @@ export class MediaController {
} }
@Post('media-assets/:assetId/access-urls') @Post('media-assets/:assetId/access-urls')
@ApiOkResponse({ description: '已签发短时预览 URL' }) @ApiOkResponse({ description: '已签发短时预览或下载 URL' })
public async accessUrl( public async accessUrl(
@Param('assetId') assetId: string, @Param('assetId') assetId: string,
@Body() body: unknown, @Body() body: unknown,
@CurrentWorkspace() workspace: Workspace, @CurrentWorkspace() workspace: Workspace,
) { ) {
parse(createMediaAccessUrlRequestSchema, body); const input = parse(createMediaAccessUrlRequestSchema, body) as CreateMediaAccessUrlRequest;
return { return {
data: await this.media.accessUrl( data: await this.media.accessUrl(
workspace.id, workspace.id,
uuid('asset_id', assetId), uuid('asset_id', assetId),
input.purpose,
), ),
}; };
} }
+6 -1
View File
@@ -2,10 +2,15 @@ import { Module } from '@nestjs/common';
import { IdempotencyService } from '../common/idempotency.service.js'; import { IdempotencyService } from '../common/idempotency.service.js';
import { MediaController } from './media.controller.js'; import { MediaController } from './media.controller.js';
import { MediaService } from './media.service.js'; import { MediaService } from './media.service.js';
import { AspectRatioDerivativeService } from './aspect-ratio-derivative.service.js';
import { WatermarkDerivativeService } from './watermark-derivative.service.js';
import { MediaAiEditService } from './media-ai-edit.service.js';
import { ProductImageTaskService } from './product-image-task.service.js';
import { ProductImageDerivativeHistoryService } from './product-image-derivative-history.service.js';
@Module({ @Module({
controllers: [MediaController], controllers: [MediaController],
providers: [IdempotencyService, MediaService], providers: [IdempotencyService, MediaService, AspectRatioDerivativeService, WatermarkDerivativeService, MediaAiEditService, ProductImageTaskService, ProductImageDerivativeHistoryService],
exports: [MediaService], exports: [MediaService],
}) })
export class MediaModule {} export class MediaModule {}
+16 -2
View File
@@ -11,6 +11,7 @@ import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
import { Inject, Injectable } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import type { import type {
CompleteMediaUploadIntentRequest, CompleteMediaUploadIntentRequest,
CreateMediaAccessUrlRequest,
CreateMediaUploadIntentRequest, CreateMediaUploadIntentRequest,
ImportProductImageRequest, ImportProductImageRequest,
} from '@atlas/contracts'; } from '@atlas/contracts';
@@ -263,23 +264,36 @@ export class MediaService {
} }
} }
public async accessUrl(workspaceId: string, assetId: string) { public async accessUrl(
workspaceId: string,
assetId: string,
purpose: CreateMediaAccessUrlRequest['purpose'] = 'preview',
) {
return this.database.workspaceTransaction(workspaceId, async (tx) => { return this.database.workspaceTransaction(workspaceId, async (tx) => {
const asset = await tx.query<{ const asset = await tx.query<{
bucket: string; bucket: string;
object_key: string; object_key: string;
content_type: string;
}>( }>(
`SELECT bucket, object_key FROM atlas.media_assets WHERE id = $1`, `SELECT bucket, object_key, content_type FROM atlas.media_assets WHERE id = $1`,
[assetId], [assetId],
); );
if (!asset.rows[0]) if (!asset.rows[0])
throw new ApiError(404, 'RESOURCE_NOT_FOUND', '素材不存在'); throw new ApiError(404, 'RESOURCE_NOT_FOUND', '素材不存在');
const extension = ({
'image/jpeg': 'jpg',
'image/png': 'png',
'image/webp': 'webp',
} as Record<string, string>)[asset.rows[0].content_type] ?? 'bin';
const expiresAt = new Date(Date.now() + 10 * 60_000); const expiresAt = new Date(Date.now() + 10 * 60_000);
const url = await getSignedUrl( const url = await getSignedUrl(
this.s3, this.s3,
new GetObjectCommand({ new GetObjectCommand({
Bucket: asset.rows[0].bucket, Bucket: asset.rows[0].bucket,
Key: asset.rows[0].object_key, Key: asset.rows[0].object_key,
...(purpose === 'download' ? {
ResponseContentDisposition: `attachment; filename="atlas-image-${assetId}.${extension}"`,
} : {}),
}), }),
{ expiresIn: 600 }, { expiresIn: 600 },
); );
@@ -0,0 +1,81 @@
import { Inject, Injectable } from '@nestjs/common';
import type { ProductImageDerivativeRecord } from '@atlas/contracts';
import type { Database } from '@atlas/database';
import { ApiError } from '../common/api-error.js';
import { DATABASE } from '../database/database.module.js';
import { MediaService } from './media.service.js';
type HistoryRow = {
id: string;
kind: ProductImageDerivativeRecord['kind'];
source_asset_id: string;
output_asset_id: string;
created_at: Date | string;
content_type: string;
byte_size: string;
width_px: number | null;
height_px: number | null;
asset_created_at: Date | string;
};
@Injectable()
export class ProductImageDerivativeHistoryService {
public constructor(
@Inject(DATABASE) private readonly database: Database,
@Inject(MediaService) private readonly media: MediaService,
) {}
/** 只列当前商品成功的派生图;预览地址每次读取时重新签发。 */
public async list(workspaceId: string, productId: string): Promise<ProductImageDerivativeRecord[]> {
const rows = await this.database.workspaceTransaction(workspaceId, async (tx) => {
const product = await tx.query<{ id: string }>(
'SELECT id FROM atlas.products WHERE id = $1', [productId],
);
if (!product.rows[0]) throw new ApiError(404, 'RESOURCE_NOT_FOUND', '商品不存在');
const result = await tx.query<HistoryRow>(
`SELECT d.id, d.kind, d.source_asset_id, d.output_asset_id, d.created_at,
a.content_type, a.byte_size::text, a.width_px, a.height_px,
a.created_at AS asset_created_at
FROM (
SELECT id, 'aspect_ratio_3_4'::text AS kind, source_asset_id,
output_asset_id, created_at, completed_at
FROM atlas.media_aspect_ratio_derivatives
WHERE product_id = $1 AND status = 'succeeded'
UNION ALL
SELECT id, 'watermark'::text AS kind, source_asset_id,
output_asset_id, created_at, completed_at
FROM atlas.media_watermark_derivatives
WHERE product_id = $1 AND status = 'succeeded'
) d
JOIN atlas.media_assets a ON a.id = d.output_asset_id
ORDER BY d.completed_at DESC, d.id DESC
LIMIT 100`,
[productId],
);
return result.rows;
});
return Promise.all(rows.map(async (row) => {
const access = await this.media.accessUrl(workspaceId, row.output_asset_id);
return {
id: row.id,
kind: row.kind,
source_asset_id: row.source_asset_id,
created_at: isoTime(row.created_at),
output_asset: {
id: row.output_asset_id,
content_type: row.content_type,
byte_size: Number(row.byte_size),
width_px: row.width_px,
height_px: row.height_px,
created_at: isoTime(row.asset_created_at),
preview_url: access.url,
preview_url_expires_at: access.expires_at,
},
};
}));
}
}
function isoTime(value: Date | string) {
return value instanceof Date ? value.toISOString() : String(value);
}
@@ -0,0 +1,106 @@
import { Inject, Injectable } from '@nestjs/common';
import type { ListProductImageTasksQuery, ProductImageTask } from '@atlas/contracts';
import type { Database } from '@atlas/database';
import { ApiError } from '../common/api-error.js';
import { DATABASE } from '../database/database.module.js';
type AiEditTaskRow = {
id: string;
status: ProductImageTask['status'];
provider_task_id: string | null;
output_asset_id: string | null;
error_code: string | null;
provider_error_raw: string | null;
failure_source: ProductImageTask['failure_source'];
failure_stage: ProductImageTask['failure_stage'];
failure_detail: string | null;
created_at: Date | string;
completed_at: Date | string | null;
};
@Injectable()
export class ProductImageTaskService {
public constructor(@Inject(DATABASE) private readonly database: Database) {}
/** 聚合当前商品的图片任务;新增套图任务时在此追加来源,接口结构保持稳定。 */
public async list(workspaceId: string, productId: string, query: ListProductImageTasksQuery) {
return this.database.workspaceTransaction(workspaceId, async (tx) => {
const product = await tx.query<{ id: string }>(
'SELECT id FROM atlas.products WHERE id = $1', [productId],
);
if (!product.rows[0]) throw new ApiError(404, 'RESOURCE_NOT_FOUND', '商品不存在');
const params: unknown[] = [productId];
let cursorFilter = '';
if (query.cursor) {
const cursor = decodeCursor(query.cursor);
params.push(cursor.active, cursor.at, cursor.id);
cursorFilter = `AND ((e.status IN ('queued', 'running'))::int, e.created_at, e.id)
< ($2::int, $3::timestamptz, $4::uuid)`;
}
params.push(query.limit + 1);
const edits = await tx.query<AiEditTaskRow>(
`SELECT e.id, e.status, e.provider_task_id, e.output_asset_id, e.error_code,
o.result ->> 'provider_error_raw' AS provider_error_raw,
o.result ->> 'failure_source' AS failure_source,
o.result ->> 'failure_stage' AS failure_stage,
o.result ->> 'failure_detail' AS failure_detail,
e.created_at, e.completed_at
FROM atlas.media_ai_edits e
LEFT JOIN atlas.operations o ON o.id = e.id AND o.workspace_id = e.workspace_id
WHERE e.product_id = $1 ${cursorFilter}
ORDER BY (e.status IN ('queued', 'running')) DESC, e.created_at DESC, e.id DESC
LIMIT $${params.length}`, params,
);
const hasMore = edits.rows.length > query.limit;
const rows = edits.rows.slice(0, query.limit);
const last = rows.at(-1);
const data: ProductImageTask[] = rows.map((row) => ({
id: row.id,
kind: 'single_ai_edit' as const,
status: row.status,
phase: row.status === 'running' && row.provider_task_id ? 'waiting_provider' as const : null,
total: 1,
completed: row.status === 'succeeded' ? 1 : 0,
output_asset_ids: row.output_asset_id ? [row.output_asset_id] : [],
error_code: row.error_code,
failure_source: row.status === 'failed' ? row.failure_source ?? null : null,
failure_stage: row.status === 'failed' ? row.failure_stage ?? null : null,
failure_detail: row.status === 'failed' ? row.failure_detail ?? null : null,
provider_error_raw: row.status === 'failed' ? row.provider_error_raw ?? null : null,
created_at: iso(row.created_at),
completed_at: row.completed_at ? iso(row.completed_at) : null,
}));
return {
data,
meta: {
has_more: hasMore,
next_cursor: hasMore && last ? encodeCursor(last) : null,
},
};
});
}
}
function iso(value: Date | string): string {
return value instanceof Date ? value.toISOString() : value;
}
function encodeCursor(row: AiEditTaskRow): string {
return Buffer.from(JSON.stringify({
active: row.status === 'queued' || row.status === 'running' ? 1 : 0,
at: iso(row.created_at), id: row.id,
})).toString('base64url');
}
function decodeCursor(value: string): { active: number; at: string; id: string } {
try {
const decoded = JSON.parse(Buffer.from(value, 'base64url').toString('utf8')) as Record<string, unknown>;
if ((decoded.active === 0 || decoded.active === 1)
&& typeof decoded.at === 'string' && !Number.isNaN(Date.parse(decoded.at))
&& typeof decoded.id === 'string' && /^[0-9a-f-]{36}$/i.test(decoded.id)) {
return { active: decoded.active, at: decoded.at, id: decoded.id };
}
} catch { /* 非法游标按字段错误返回。 */ }
throw new ApiError(422, 'VALIDATION_FAILED', '分页游标无效');
}
@@ -0,0 +1,145 @@
import { Inject, Injectable } from '@nestjs/common';
import type { CreateWatermarkDerivativeRequest, WatermarkDerivative } from '@atlas/contracts';
import type { Database } from '@atlas/database';
import { ApiError } from '../common/api-error.js';
import { IdempotencyService } from '../common/idempotency.service.js';
import { DATABASE } from '../database/database.module.js';
import { MediaService } from './media.service.js';
type DerivativeRow = {
id: string;
status: WatermarkDerivative['status'];
source_asset_id: string;
watermark_template_id: string;
watermark_template_version: number;
output_asset_id: string | null;
error_code: string | null;
content_type: string | null;
byte_size: string | null;
width_px: number | null;
height_px: number | null;
asset_created_at: Date | string | null;
};
@Injectable()
export class WatermarkDerivativeService {
public constructor(
@Inject(DATABASE) private readonly database: Database,
@Inject(IdempotencyService) private readonly idempotency: IdempotencyService,
@Inject(MediaService) private readonly media: MediaService,
) {}
/** 创建任务时固定模板版本,并与进度和 Outbox 一起提交。 */
public async create(
workspaceId: string,
userId: string,
assetId: string,
key: string,
input: CreateWatermarkDerivativeRequest,
) {
return this.idempotency.run({
workspaceId,
userId,
method: 'POST',
path: `/api/media-assets/${assetId}/watermark-derivatives`,
key,
request: input,
}, async (tx) => {
const source = await tx.query<{ id: string }>(
`SELECT id FROM atlas.media_assets
WHERE id = $1 AND content_type IN ('image/jpeg', 'image/png', 'image/webp')
AND byte_size <= 20971520 AND width_px BETWEEN 1 AND 10000
AND height_px BETWEEN 1 AND 10000`,
[assetId],
);
if (!source.rows[0]) throw new ApiError(404, 'RESOURCE_NOT_FOUND', '图片素材不存在或不可处理');
if (input.product_id) {
const product = await tx.query<{ id: string }>(
'SELECT id FROM atlas.products WHERE id = $1',
[input.product_id],
);
if (!product.rows[0]) throw new ApiError(404, 'RESOURCE_NOT_FOUND', '商品不存在');
}
const template = await tx.query<{ id: string; current_version: number }>(
`SELECT id, current_version FROM atlas.watermark_templates
WHERE id = $1 AND status = 'active' AND deleted_at IS NULL`,
[input.watermark_template_id],
);
if (!template.rows[0]) throw new ApiError(404, 'RESOURCE_NOT_FOUND', '水印模板不存在');
const version = template.rows[0].current_version;
const created = await tx.query<{ id: string }>(
`INSERT INTO atlas.media_watermark_derivatives
(workspace_id, product_id, source_asset_id, watermark_template_id,
watermark_template_version, created_by_user_id)
VALUES (current_setting('app.workspace_id')::uuid, $1, $2, $3, $4, $5)
RETURNING id`,
[input.product_id ?? null, assetId, input.watermark_template_id, version, userId],
);
const id = created.rows[0].id;
await tx.query(
`INSERT INTO atlas.operations
(id, workspace_id, kind, resource_type, resource_id, created_by_user_id)
VALUES ($1, current_setting('app.workspace_id')::uuid,
'media_watermark_derivative', 'media_derivative', $1, $2)`,
[id, userId],
);
await tx.query(
`INSERT INTO atlas.outbox_events
(workspace_id, aggregate_type, aggregate_id, event_type, event_payload, deduplication_key)
VALUES (current_setting('app.workspace_id')::uuid, 'media_derivative', $1,
'media.watermark_derivative.requested',
jsonb_build_object('derivative_id', $1::uuid), $1::text)`,
[id],
);
return { status: 202, body: { data: {
id,
status: 'queued',
source_asset_id: assetId,
watermark_template_id: input.watermark_template_id,
watermark_template_version: version,
output_asset: null,
error_code: null,
} satisfies WatermarkDerivative } };
});
}
/** 成功结果读取时才签发短时预览地址。 */
public async read(workspaceId: string, derivativeId: string): Promise<WatermarkDerivative> {
const row = await this.database.workspaceTransaction(workspaceId, async (tx) => {
const result = await tx.query<DerivativeRow>(
`SELECT d.id, d.status, d.source_asset_id, d.watermark_template_id,
d.watermark_template_version, d.output_asset_id, d.error_code,
a.content_type, a.byte_size::text, a.width_px, a.height_px,
a.created_at AS asset_created_at
FROM atlas.media_watermark_derivatives d
LEFT JOIN atlas.media_assets a ON a.id = d.output_asset_id
WHERE d.id = $1`,
[derivativeId],
);
return result.rows[0] ?? null;
});
if (!row) throw new ApiError(404, 'RESOURCE_NOT_FOUND', '水印处理任务不存在');
const access = row.output_asset_id
? await this.media.accessUrl(workspaceId, row.output_asset_id)
: null;
return {
id: row.id,
status: row.status,
source_asset_id: row.source_asset_id,
watermark_template_id: row.watermark_template_id,
watermark_template_version: row.watermark_template_version,
error_code: row.error_code,
output_asset: row.output_asset_id && access ? {
id: row.output_asset_id,
content_type: row.content_type!,
byte_size: Number(row.byte_size),
width_px: row.width_px,
height_px: row.height_px,
created_at: row.asset_created_at instanceof Date
? row.asset_created_at.toISOString() : String(row.asset_created_at),
preview_url: access.url,
preview_url_expires_at: access.expires_at,
} : null,
};
}
}
@@ -0,0 +1,191 @@
import {
Body,
Controller,
Get,
Headers,
Inject,
Param,
Post,
Query,
Req,
Res,
} from '@nestjs/common';
import {
ApiAcceptedResponse,
ApiBearerAuth,
ApiOkResponse,
ApiOperation,
ApiTags,
} from '@nestjs/swagger';
import { z } from 'zod';
import {
listOnlineProductsQuerySchema,
listOnlineProductSyncRunsQuerySchema,
onlineProductSyncRequestSchema,
type Workspace,
} from '@atlas/contracts';
import type { AuthenticatedRequest } from '../auth/auth.guard.js';
import { ApiError } from '../common/api-error.js';
import { CurrentWorkspace } from '../workspace/current-workspace.decorator.js';
import { OnlineProductsService } from './online-products.service.js';
type HttpResponse = { status: (status: number) => HttpResponse };
@ApiTags('Online products')
@ApiBearerAuth('access-token')
@Controller('platform-listings')
export class OnlineProductsController {
public constructor(
@Inject(OnlineProductsService)
private readonly service: OnlineProductsService,
) {}
@Get()
@ApiOperation({ summary: '按店铺和 SKU 查询 Ozon 商品镜像' })
@ApiOkResponse({ description: '商品列表及最近同步状态' })
public list(
@Headers('x-atlas-platform-id') header: string | undefined,
@CurrentWorkspace() workspace: Workspace,
@Query() query: unknown,
) {
return this.service.list(
workspace.id,
platformId(header),
parse(listOnlineProductsQuerySchema, query),
);
}
@Get(':listingId')
@ApiOperation({ summary: '读取一个在线 SKU 的业务摘要' })
public async get(
@Headers('x-atlas-platform-id') header: string | undefined,
@CurrentWorkspace() workspace: Workspace,
@Param('listingId') listingId: string,
) {
return {
data: await this.service.get(
workspace.id,
platformId(header),
uuid('listing_id', listingId),
),
};
}
}
@ApiTags('Online product sync')
@ApiBearerAuth('access-token')
@Controller('stores/:storeId/platform-product-sync-runs')
export class StoreOnlineProductSyncController {
public constructor(
@Inject(OnlineProductsService)
private readonly service: OnlineProductsService,
) {}
@Post()
@ApiOperation({ summary: '手动发起该店铺的全量 Ozon 商品同步' })
@ApiAcceptedResponse({ description: '已创建异步同步任务' })
public async create(
@Headers('x-atlas-platform-id') header: string | undefined,
@Headers('idempotency-key') key: string | undefined,
@Param('storeId') storeId: string,
@Body() body: unknown,
@Req() request: AuthenticatedRequest,
@CurrentWorkspace() workspace: Workspace,
@Res({ passthrough: true }) response: HttpResponse,
) {
parse(onlineProductSyncRequestSchema, body ?? {});
const result = await this.service.createSync(
workspace.id,
request.auth.userId,
platformId(header),
uuid('store_id', storeId),
requireKey(key),
);
response.status(result.status);
return result.body;
}
@Get()
@ApiOperation({ summary: '查询店铺同步历史' })
public list(
@Headers('x-atlas-platform-id') header: string | undefined,
@CurrentWorkspace() workspace: Workspace,
@Param('storeId') storeId: string,
@Query() query: unknown,
) {
return this.service.listRuns(
workspace.id,
platformId(header),
uuid('store_id', storeId),
parse(listOnlineProductSyncRunsQuerySchema, query),
);
}
}
@ApiTags('Online product sync')
@ApiBearerAuth('access-token')
@Controller('platform-product-sync-runs')
export class OnlineProductSyncRunsController {
public constructor(
@Inject(OnlineProductsService)
private readonly service: OnlineProductsService,
) {}
@Get(':runId')
@ApiOperation({ summary: '查询一次在线商品同步的进度和结果' })
public async get(
@Headers('x-atlas-platform-id') header: string | undefined,
@CurrentWorkspace() workspace: Workspace,
@Param('runId') runId: string,
) {
return {
data: await this.service.getRun(
workspace.id,
platformId(header),
uuid('sync_run_id', runId),
),
};
}
}
function platformId(value: string | undefined): string {
if (!value)
throw new ApiError(
400,
'PLATFORM_CONTEXT_REQUIRED',
'请求缺少 X-Atlas-Platform-Id',
);
return uuid('X-Atlas-Platform-Id', value);
}
function requireKey(value: string | undefined): string {
if (!value)
throw new ApiError(
400,
'IDEMPOTENCY_KEY_REQUIRED',
'请求缺少 Idempotency-Key',
);
return uuid('Idempotency-Key', value);
}
function uuid(field: string, value: string): string {
if (z.uuid().safeParse(value).success) return value;
throw new ApiError(422, 'VALIDATION_FAILED', '请求参数不符合要求', [
{ field, code: 'INVALID_FIELD', message: `${field} 必须是 UUID` },
]);
}
function parse<T>(schema: z.ZodType<T>, value: unknown): T {
const result = schema.safeParse(value);
if (result.success) return result.data;
throw new ApiError(
422,
'VALIDATION_FAILED',
'请求参数不符合要求',
result.error.issues.map((issue) => ({
field: issue.path.join('.'),
code: 'INVALID_FIELD',
message: issue.message,
})),
);
}
@@ -0,0 +1,18 @@
import { Module } from '@nestjs/common';
import { IdempotencyService } from '../common/idempotency.service.js';
import {
OnlineProductsController,
OnlineProductSyncRunsController,
StoreOnlineProductSyncController,
} from './online-products.controller.js';
import { OnlineProductsService } from './online-products.service.js';
@Module({
controllers: [
OnlineProductsController,
StoreOnlineProductSyncController,
OnlineProductSyncRunsController,
],
providers: [OnlineProductsService, IdempotencyService],
})
export class OnlineProductsModule {}
@@ -0,0 +1,415 @@
import { Inject, Injectable } from '@nestjs/common';
import type { Database, DatabaseTransaction } from '@atlas/database';
import type {
ListOnlineProductsQuery,
ListOnlineProductSyncRunsQuery,
OnlineProduct,
OnlineProductSyncRun,
OnlineProductsMeta,
} from '@atlas/contracts';
import { ApiError } from '../common/api-error.js';
import { IdempotencyService } from '../common/idempotency.service.js';
import { DATABASE } from '../database/database.module.js';
type StoreRow = {
id: string;
name: string;
platform_id: string;
status: string;
credential_status: string;
};
type ListingRow = Omit<
OnlineProduct,
| 'image'
| 'stock_summary'
| 'last_synced_at'
| 'missing_since'
| 'archived_at'
> & {
image_summary: OnlineProduct['image'];
stock_summary: OnlineProduct['stock_summary'];
last_synced_at: Date | string;
missing_since: Date | string | null;
archived_at: Date | string | null;
updated_at: Date | string;
};
type RunRow = Omit<
OnlineProductSyncRun,
'created_at' | 'started_at' | 'completed_at' | 'error'
> & {
created_at: Date | string;
started_at: Date | string | null;
completed_at: Date | string | null;
error_payload: { code?: string; message?: string } | null;
};
const listingSelect = `SELECT l.id, l.store_id, l.platform_id, l.remote_product_id, l.offer_id,
l.remote_sku, l.name, l.currency_code, round(l.selling_price, 2)::numeric(18,2)::text AS selling_price,
round(l.list_price, 2)::numeric(18,2)::text AS list_price, l.visible, l.remote_state, l.moderation_status,
l.validation_status, l.stock_summary, l.image_summary, l.error_payload AS errors,
l.visibility_details, l.source, l.last_synced_at, l.missing_since, l.archived_at, l.updated_at,
CASE WHEN origin.id IS NULL THEN NULL ELSE jsonb_build_object(
'collection_item_id', product.collection_item_id, 'product_id', origin.product_id,
'variant_id', origin.product_variant_id) END AS atlas_origin
FROM atlas.platform_listings l
LEFT JOIN atlas.product_variant_platform_listings origin ON origin.id = l.atlas_variant_listing_id
LEFT JOIN atlas.products product ON product.id = origin.product_id`;
const runSelect = `SELECT id, store_id, status, trigger_source, cursor_started, cursor_finished,
listed_count, upserted_count, failed_count, created_at, started_at, completed_at, error_payload
FROM atlas.platform_product_sync_runs`;
@Injectable()
export class OnlineProductsService {
public constructor(
@Inject(DATABASE) private readonly database: Database,
@Inject(IdempotencyService)
private readonly idempotency: IdempotencyService,
) {}
public list(
workspaceId: string,
platformId: string,
query: ListOnlineProductsQuery,
) {
return this.database.workspaceTransaction(workspaceId, async (tx) => {
const store = await this.resolveStore(
tx,
platformId,
query.store_id,
);
const params: unknown[] = [store.id, platformId];
const filters = ['l.store_id = $1', 'l.platform_id = $2'];
if (!query.include_archived) filters.push('l.archived_at IS NULL');
if (query.q) {
params.push(`%${query.q.replace(/[\\%_]/g, '\\$&')}%`);
filters.push(
`(l.name ILIKE $${params.length} ESCAPE '\\' OR l.offer_id ILIKE $${params.length} ESCAPE '\\' OR l.remote_product_id ILIKE $${params.length} ESCAPE '\\' OR l.remote_sku ILIKE $${params.length} ESCAPE '\\')`,
);
}
if (query.visibility !== 'all') {
filters.push(
query.visibility === 'unknown'
? 'l.visible IS NULL'
: `l.visible = ${query.visibility === 'visible' ? 'true' : 'false'}`,
);
}
if (query.source) {
params.push(query.source);
filters.push(`l.source = $${params.length}`);
}
if (query.moderation_status) {
params.push(query.moderation_status);
filters.push(`l.moderation_status = $${params.length}`);
}
if (query.cursor) {
const cursor = decodeCursor(query.cursor);
params.push(cursor.at, cursor.id);
filters.push(
`(l.updated_at, l.id) < ($${params.length - 1}::timestamptz, $${params.length}::uuid)`,
);
}
params.push(query.limit + 1);
const result = await tx.query<ListingRow>(
`${listingSelect} WHERE ${filters.join(' AND ')}
ORDER BY l.updated_at DESC, l.id DESC LIMIT $${params.length}`,
params,
);
const hasMore = result.rows.length > query.limit;
const rows = result.rows.slice(0, query.limit);
const last = rows.at(-1);
const sync = await tx.query<{
last_successful_sync_at: Date | string | null;
last_sync_status: OnlineProductsMeta['last_sync_status'];
}>(
`SELECT (SELECT completed_at FROM atlas.platform_product_sync_runs
WHERE store_id = $1 AND status = 'succeeded' ORDER BY completed_at DESC LIMIT 1) AS last_successful_sync_at,
(SELECT status FROM atlas.platform_product_sync_runs
WHERE store_id = $1 ORDER BY created_at DESC, id DESC LIMIT 1) AS last_sync_status`,
[store.id],
);
return {
data: rows.map(toListing),
meta: {
next_cursor:
hasMore && last
? encodeCursor(last.updated_at, last.id)
: null,
has_more: hasMore,
store: { id: store.id, name: store.name },
last_successful_sync_at: iso(
sync.rows[0]?.last_successful_sync_at ?? null,
),
last_sync_status: sync.rows[0]?.last_sync_status ?? null,
} satisfies OnlineProductsMeta,
};
});
}
public get(workspaceId: string, platformId: string, listingId: string) {
return this.database.workspaceTransaction(workspaceId, async (tx) => {
await this.assertPlatform(tx, platformId);
const result = await tx.query<ListingRow>(
`${listingSelect} WHERE l.id = $1 AND l.platform_id = $2`,
[listingId, platformId],
);
if (!result.rows[0])
throw new ApiError(404, 'LISTING_NOT_FOUND', '在线商品不存在');
return toListing(result.rows[0]);
});
}
public async createSync(
workspaceId: string,
userId: string,
platformId: string,
storeId: string,
key: string,
) {
try {
return await this.idempotency.run(
{
workspaceId,
userId,
method: 'POST',
path: `/api/stores/${storeId}/platform-product-sync-runs`,
key,
request: {
reason: 'manual_refresh',
platform_id: platformId,
},
},
async (tx) => {
const store = await this.resolveStore(
tx,
platformId,
storeId,
);
const platform = await tx.query<{ code: string }>(
`SELECT code FROM atlas.platforms WHERE id = $1`,
[platformId],
);
if (platform.rows[0]?.code.toUpperCase() !== 'OZON')
throw new ApiError(
422,
'PLATFORM_UNAVAILABLE',
'当前平台暂不支持商品同步',
);
if (
store.status !== 'active' ||
store.credential_status !== 'valid'
)
throw new ApiError(
422,
'STORE_CREDENTIAL_UNAVAILABLE',
'店铺凭据暂不可用于同步',
);
const secret = await tx.query<{ id: string }>(
`SELECT id FROM atlas.store_secrets WHERE store_id = $1 AND kind = 'seller_api_key' AND revoked_at IS NULL`,
[store.id],
);
if (!secret.rows[0])
throw new ApiError(
422,
'STORE_CREDENTIAL_UNAVAILABLE',
'店铺凭据暂不可用于同步',
);
const active = await tx.query<{ id: string }>(
`SELECT id FROM atlas.platform_product_sync_runs WHERE store_id = $1 AND status IN ('queued', 'running') FOR UPDATE`,
[store.id],
);
if (active.rows[0])
throw new ApiError(
409,
'SYNC_ALREADY_RUNNING',
'该店铺已有正在进行的商品同步',
[{ sync_run_id: active.rows[0].id }],
);
const created = await tx.query<RunRow>(
`INSERT INTO atlas.platform_product_sync_runs (workspace_id, store_id, platform_id, trigger_source)
VALUES (current_setting('app.workspace_id')::uuid, $1, $2, 'manual') RETURNING id, store_id,
status, trigger_source, cursor_started, cursor_finished, listed_count, upserted_count,
failed_count, created_at, started_at, completed_at, error_payload`,
[store.id, platformId],
);
const run = toRun(created.rows[0]);
await tx.query(
`INSERT INTO atlas.outbox_events (workspace_id, aggregate_type, aggregate_id, event_type, event_payload, deduplication_key)
VALUES (current_setting('app.workspace_id')::uuid, 'platform_product_sync_run', $1,
'platform_product_sync.requested', $2, $1::uuid::text)`,
[
run.id,
{
sync_run_id: run.id,
store_id: store.id,
platform_id: platformId,
secret_id: secret.rows[0].id,
},
],
);
return { status: 202, body: { data: { sync_run: run } } };
},
);
} catch (error) {
if (
typeof error === 'object' &&
error !== null &&
'constraint' in error &&
error.constraint === 'ux_platform_product_sync_runs_active'
) {
const active = await this.database.workspaceTransaction(
workspaceId,
(tx) =>
tx.query<{ id: string }>(
`SELECT id FROM atlas.platform_product_sync_runs WHERE store_id = $1 AND status IN ('queued', 'running') ORDER BY created_at DESC LIMIT 1`,
[storeId],
),
);
throw new ApiError(
409,
'SYNC_ALREADY_RUNNING',
'该店铺已有正在进行的商品同步',
active.rows[0] ? [{ sync_run_id: active.rows[0].id }] : [],
);
}
throw error;
}
}
public getRun(workspaceId: string, platformId: string, runId: string) {
return this.database.workspaceTransaction(workspaceId, async (tx) => {
await this.assertPlatform(tx, platformId);
const result = await tx.query<RunRow>(
`${runSelect} WHERE id = $1 AND platform_id = $2`,
[runId, platformId],
);
if (!result.rows[0])
throw new ApiError(404, 'SYNC_RUN_NOT_FOUND', '同步记录不存在');
return toRun(result.rows[0]);
});
}
public listRuns(
workspaceId: string,
platformId: string,
storeId: string,
query: ListOnlineProductSyncRunsQuery,
) {
return this.database.workspaceTransaction(workspaceId, async (tx) => {
await this.resolveStore(tx, platformId, storeId);
const cursor = query.cursor ? decodeCursor(query.cursor) : null;
const result = await tx.query<RunRow>(
`${runSelect} WHERE store_id = $1 AND platform_id = $2
AND ($3::timestamptz IS NULL OR (created_at, id) < ($3::timestamptz, $4::uuid))
ORDER BY created_at DESC, id DESC LIMIT $5`,
[
storeId,
platformId,
cursor?.at ?? null,
cursor?.id ?? null,
query.limit + 1,
],
);
const hasMore = result.rows.length > query.limit;
const rows = result.rows.slice(0, query.limit);
const last = rows.at(-1);
return {
data: rows.map(toRun),
meta: {
next_cursor:
hasMore && last
? encodeCursor(last.created_at, last.id)
: null,
has_more: hasMore,
},
};
});
}
private async assertPlatform(tx: DatabaseTransaction, platformId: string) {
const platform = await tx.query<{ id: string }>(
`SELECT id FROM atlas.platforms WHERE id = $1 AND status = 'active'`,
[platformId],
);
if (!platform.rows[0])
throw new ApiError(422, 'PLATFORM_UNAVAILABLE', '当前平台不可用');
}
private async resolveStore(
tx: DatabaseTransaction,
platformId: string,
storeId?: string,
): Promise<StoreRow> {
await this.assertPlatform(tx, platformId);
const result = await tx.query<StoreRow>(
`SELECT id, name, platform_id, status, credential_status FROM atlas.stores
WHERE platform_id = $1 AND deleted_at IS NULL AND ${storeId ? 'id = $2' : 'is_default = true'}`,
storeId ? [platformId, storeId] : [platformId],
);
if (!result.rows[0])
throw new ApiError(
storeId ? 404 : 422,
storeId ? 'STORE_NOT_FOUND' : 'DEFAULT_STORE_REQUIRED',
storeId ? '店铺不存在' : '请先设置当前平台的默认店铺',
);
return result.rows[0];
}
}
function toListing(row: ListingRow): OnlineProduct {
const { image_summary, updated_at: _updatedAt, ...rest } = row;
return {
...rest,
stock_summary: row.stock_summary ?? { total: 0, has_data: false },
image: image_summary ?? { url: null, width_px: null, height_px: null },
errors: row.errors ?? [],
visibility_details: row.visibility_details ?? {},
last_synced_at: iso(row.last_synced_at)!,
missing_since: iso(row.missing_since),
archived_at: iso(row.archived_at),
};
}
function toRun(row: RunRow): OnlineProductSyncRun {
const { error_payload, ...rest } = row;
return {
...rest,
created_at: iso(row.created_at)!,
started_at: iso(row.started_at),
completed_at: iso(row.completed_at),
error: error_payload
? {
code: error_payload.code ?? 'SYNC_FAILED',
message: error_payload.message ?? '商品同步失败',
}
: null,
};
}
function iso(value: Date | string | null): string | null {
return value ? new Date(value).toISOString() : null;
}
function encodeCursor(at: Date | string, id: string): string {
return Buffer.from(JSON.stringify({ at: iso(at), id })).toString(
'base64url',
);
}
function decodeCursor(value: string): { at: string; id: string } {
try {
const decoded = JSON.parse(
Buffer.from(value, 'base64url').toString('utf8'),
) as { at?: unknown; id?: unknown };
if (
typeof decoded.at === 'string' &&
!Number.isNaN(Date.parse(decoded.at)) &&
typeof decoded.id === 'string' &&
/^[0-9a-f-]{36}$/i.test(decoded.id)
)
return { at: decoded.at, id: decoded.id };
} catch {
/* 非法游标统一转成字段校验错误。 */
}
throw new ApiError(422, 'VALIDATION_FAILED', '分页游标无效');
}
@@ -237,7 +237,7 @@ export class ProductEditorService {
'missing' | 'ambiguous' | 'confirmed' | null; 'missing' | 'ambiguous' | 'confirmed' | null;
}>( }>(
`SELECT v.id, v.source_variant_key, v.source_sku, v.display_name, v.sort_order, `SELECT v.id, v.source_variant_key, v.source_sku, v.display_name, v.sort_order,
v.purchase_cost_cny::text AS purchase_cost_cny, l.id AS listing_id, l.offer_id, v.purchase_cost_cny::numeric(18, 2)::text AS purchase_cost_cny, l.id AS listing_id, l.offer_id,
l.title, l.description, l.tags, l.status, l.selling_price::text, l.title, l.description, l.tags, l.status, l.selling_price::text,
l.list_price::text, l.currency_code, v.specification_values, l.list_price::text, l.currency_code, v.specification_values,
v.physical_length_mm::text, v.physical_width_mm::text, v.physical_height_mm::text, v.physical_length_mm::text, v.physical_width_mm::text, v.physical_height_mm::text,
@@ -1395,6 +1395,21 @@ export class ProductEditorService {
) )
: { rows: [] }; : { rows: [] };
const importedIds = new Set(imported.rows.map((item) => item.asset_id)); const importedIds = new Set(imported.rows.map((item) => item.asset_id));
const processed = assetIds.length
? await transaction.query<{ asset_id: string }>(
`SELECT output_asset_id AS asset_id
FROM atlas.media_aspect_ratio_derivatives
WHERE product_id = $1 AND status = 'succeeded'
AND output_asset_id = ANY($2::uuid[])
UNION
SELECT output_asset_id AS asset_id
FROM atlas.media_watermark_derivatives
WHERE product_id = $1 AND status = 'succeeded'
AND output_asset_id = ANY($2::uuid[])`,
[input.product.id, assetIds],
)
: { rows: [] };
const processedIds = new Set(processed.rows.map((item) => item.asset_id));
const candidateVariants = new Map<string, Set<string | null>>(); const candidateVariants = new Map<string, Set<string | null>>();
for (const candidate of candidates.rows) { for (const candidate of candidates.rows) {
const variants = const variants =
@@ -1435,6 +1450,7 @@ export class ProductEditorService {
continue; continue;
if (uploadedIds.has(assetId)) continue; if (uploadedIds.has(assetId)) continue;
if (importedIds.has(assetId)) continue; if (importedIds.has(assetId)) continue;
if (processedIds.has(assetId)) continue;
const allowedFor = candidateVariants.get(assetId); const allowedFor = candidateVariants.get(assetId);
if ( if (
!allowedFor?.has(null) && !allowedFor?.has(null) &&
@@ -1443,7 +1459,7 @@ export class ProductEditorService {
throw new ApiError( throw new ApiError(
422, 422,
'LISTING_MEDIA_NOT_SELECTABLE', 'LISTING_MEDIA_NOT_SELECTABLE',
'只能选择已上传、已导入或当前采集快照中该 SKU 可用的已转存图片', '只能选择当前商品可用的采集、上传、导入或图片处理结果',
); );
} }
} }
@@ -28,6 +28,12 @@ export class WatermarksController {
return this.watermarks.list(workspace.id, parse(listWatermarkTemplatesQuerySchema, query)); return this.watermarks.list(workspace.id, parse(listWatermarkTemplatesQuerySchema, query));
} }
@Get('watermark-templates/default')
@ApiOperation({ summary: '读取当前工作空间的默认水印模板' })
public async getDefault(@CurrentWorkspace() workspace: Workspace) {
return { data: await this.watermarks.getDefault(workspace.id) };
}
@Get('watermark-templates/:templateId') @Get('watermark-templates/:templateId')
@ApiOperation({ summary: '读取水印模板当前版本' }) @ApiOperation({ summary: '读取水印模板当前版本' })
public async get(@Param('templateId') templateId: string, @CurrentWorkspace() workspace: Workspace) { public async get(@Param('templateId') templateId: string, @CurrentWorkspace() workspace: Workspace) {
@@ -51,6 +57,15 @@ export class WatermarksController {
idempotencyKey(key), revision(ifMatch), parse(updateWatermarkTemplateRequestSchema, body))).body; idempotencyKey(key), revision(ifMatch), parse(updateWatermarkTemplateRequestSchema, body))).body;
} }
@Put('watermark-templates/:templateId/default')
@ApiOkResponse({ description: '已设为当前工作空间的默认水印' })
public async setDefault(@Param('templateId') templateId: string,
@Headers('idempotency-key') key: string | undefined,
@Req() request: AuthenticatedRequest, @CurrentWorkspace() workspace: Workspace) {
return (await this.watermarks.setDefault(workspace.id, request.auth.userId,
uuid('template_id', templateId), idempotencyKey(key))).body;
}
@Delete('watermark-templates/:templateId') @Delete('watermark-templates/:templateId')
@HttpCode(204) @HttpCode(204)
@ApiNoContentResponse({ description: '已软删除模板并解除当前店铺绑定' }) @ApiNoContentResponse({ description: '已软删除模板并解除当前店铺绑定' })
+172 -11
View File
@@ -1,4 +1,9 @@
import { Inject, Injectable } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import {
WATERMARK_BORDER_RADIUS_MAX_PX,
WATERMARK_IMAGE_SIZE_MAX_PX,
WATERMARK_IMAGE_SIZE_MIN_PX,
} from '@atlas/contracts';
import type { import type {
CreateWatermarkTemplateRequest, CreateWatermarkTemplateRequest,
ListWatermarkTemplatesQuery, ListWatermarkTemplatesQuery,
@@ -13,6 +18,7 @@ type TemplateRow = {
id: string; id: string;
name: string; name: string;
kind: 'text' | 'image'; kind: 'text' | 'image';
is_default: boolean;
current_version: number; current_version: number;
created_at: Date | string; created_at: Date | string;
updated_at: Date | string; updated_at: Date | string;
@@ -23,7 +29,11 @@ type TemplateRow = {
font_size_px: number | null; font_size_px: number | null;
opacity: string; opacity: string;
scale_ratio: string | null; scale_ratio: string | null;
image_size_px: number | null;
border_radius_px: number;
position: string; position: string;
position_x: string;
position_y: string;
}; };
type TemplateConfig = Record<string, unknown>; type TemplateConfig = Record<string, unknown>;
@@ -81,6 +91,59 @@ export class WatermarksService {
); );
} }
public async getDefault(workspaceId: string) {
return this.database.workspaceTransaction(workspaceId, async (tx) => {
const rows = await tx.query<TemplateRow>(
`${templateSelect} WHERE template.is_default = true
AND template.status = 'active' AND template.deleted_at IS NULL`,
);
return rows.rows[0] ? toTemplate(rows.rows[0]) : null;
});
}
public async setDefault(
workspaceId: string,
userId: string,
templateId: string,
idempotencyKey: string,
) {
return this.idempotency.run(
{
workspaceId,
userId,
method: 'PUT',
path: `/api/watermark-templates/${templateId}/default`,
key: idempotencyKey,
request: {},
},
async (tx) => {
// 同一工作空间的切换操作串行化,避免两个请求同时争用唯一默认索引。
await tx.query(
`SELECT id FROM atlas.workspaces
WHERE id = current_setting('app.workspace_id')::uuid FOR UPDATE`,
);
const current = await this.lockTemplate(tx, templateId);
if (!current.is_default) {
await tx.query(
`UPDATE atlas.watermark_templates
SET is_default = false
WHERE is_default = true AND status = 'active' AND deleted_at IS NULL`,
);
await tx.query(
`UPDATE atlas.watermark_templates
SET is_default = true
WHERE id = $1`,
[templateId],
);
}
return {
status: 200,
body: { data: await this.requireTemplate(tx, templateId) },
};
},
);
}
public async create( public async create(
workspaceId: string, workspaceId: string,
userId: string, userId: string,
@@ -97,7 +160,8 @@ export class WatermarksService {
request: input, request: input,
}, },
async (tx) => { async (tx) => {
await this.validateConfig(tx, input.kind, input.config); const config = normalizeImageConfig(input.kind, normalizePlacement(input.config), input.config);
await this.validateConfig(tx, input.kind, config);
const inserted = await tx.query<{ id: string }>( const inserted = await tx.query<{ id: string }>(
`INSERT INTO atlas.watermark_templates (workspace_id, name, kind) `INSERT INTO atlas.watermark_templates (workspace_id, name, kind)
VALUES (current_setting('app.workspace_id')::uuid, $1, $2) RETURNING id`, VALUES (current_setting('app.workspace_id')::uuid, $1, $2) RETURNING id`,
@@ -108,7 +172,7 @@ export class WatermarksService {
inserted.rows[0].id, inserted.rows[0].id,
1, 1,
input.kind, input.kind,
input.config, config,
userId, userId,
); );
return { return {
@@ -158,10 +222,10 @@ export class WatermarksService {
); );
} }
// 同类型编辑允许只提交变化字段;切换类型时旧类型字段不会通过新类型校验。 // 同类型编辑允许只提交变化字段;切换类型时旧类型字段不会通过新类型校验。
const nextConfig = normalizeConfig(nextKind, { const nextConfig = normalizeConfig(nextKind, normalizeImageConfig(nextKind, normalizePlacement({
...configFromRow(current), ...configFromRow(current),
...(input.config ?? {}), ...(input.config ?? {}),
}); }, input.config), input.config));
await this.validateConfig(tx, nextKind, nextConfig); await this.validateConfig(tx, nextKind, nextConfig);
const renderingChanged = const renderingChanged =
input.config !== undefined || nextKind !== current.kind; input.config !== undefined || nextKind !== current.kind;
@@ -217,7 +281,7 @@ export class WatermarksService {
[templateId], [templateId],
); );
await tx.query( await tx.query(
`UPDATE atlas.watermark_templates SET status = 'archived', deleted_at = now(), `UPDATE atlas.watermark_templates SET status = 'archived', deleted_at = now(), is_default = false,
revision = revision + 1, updated_at = now() WHERE id = $1`, revision = revision + 1, updated_at = now() WHERE id = $1`,
[templateId], [templateId],
); );
@@ -312,7 +376,9 @@ export class WatermarksService {
) { ) {
if ( if (
typeof config.opacity !== 'string' || typeof config.opacity !== 'string' ||
typeof config.position !== 'string' typeof config.position !== 'string' ||
!validCoordinate(config.position_x) ||
!validCoordinate(config.position_y)
) { ) {
throw new ApiError(422, 'VALIDATION_FAILED', '水印配置不完整'); throw new ApiError(422, 'VALIDATION_FAILED', '水印配置不完整');
} }
@@ -344,7 +410,9 @@ export class WatermarksService {
const assetId = config.image_asset_id; const assetId = config.image_asset_id;
if ( if (
typeof assetId !== 'string' || typeof assetId !== 'string' ||
typeof config.scale_ratio !== 'string' typeof config.scale_ratio !== 'string' ||
!validImageSize(config.image_size_px) ||
!validBorderRadius(config.border_radius_px)
) { ) {
throw new ApiError(422, 'VALIDATION_FAILED', '图片水印配置不完整'); throw new ApiError(422, 'VALIDATION_FAILED', '图片水印配置不完整');
} }
@@ -371,8 +439,8 @@ export class WatermarksService {
) { ) {
await tx.query( await tx.query(
`INSERT INTO atlas.watermark_template_versions `INSERT INTO atlas.watermark_template_versions
(workspace_id, template_id, version, kind, text_content, image_asset_id, font_code, font_size_px, opacity, scale_ratio, position, created_by_user_id) (workspace_id, template_id, version, kind, text_content, image_asset_id, font_code, font_size_px, opacity, scale_ratio, position_x, position_y, position, created_by_user_id, image_size_px, border_radius_px)
VALUES (current_setting('app.workspace_id')::uuid, $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)`, VALUES (current_setting('app.workspace_id')::uuid, $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15)`,
[ [
templateId, templateId,
version, version,
@@ -383,17 +451,23 @@ export class WatermarksService {
config.font_size_px ?? null, config.font_size_px ?? null,
config.opacity, config.opacity,
config.scale_ratio ?? null, config.scale_ratio ?? null,
config.position_x,
config.position_y,
config.position, config.position,
userId, userId,
config.image_size_px ?? null,
config.border_radius_px ?? 0,
], ],
); );
} }
} }
const templateSelect = `SELECT template.id, template.name, template.kind, template.current_version, const templateSelect = `SELECT template.id, template.name, template.kind, template.is_default, template.current_version,
template.created_at, template.updated_at, template.revision, version.text_content, version.image_asset_id, template.created_at, template.updated_at, template.revision, version.text_content, version.image_asset_id,
version.font_code, version.font_size_px, version.opacity::text AS opacity, version.scale_ratio::text AS scale_ratio, version.font_code, version.font_size_px, version.opacity::text AS opacity, version.scale_ratio::text AS scale_ratio,
version.position FROM atlas.watermark_templates template JOIN atlas.watermark_template_versions version version.image_size_px, version.border_radius_px,
version.position, version.position_x::text AS position_x, version.position_y::text AS position_y
FROM atlas.watermark_templates template JOIN atlas.watermark_template_versions version
ON version.template_id = template.id AND version.version = template.current_version`; ON version.template_id = template.id AND version.version = template.current_version`;
function toTemplate(row: TemplateRow) { function toTemplate(row: TemplateRow) {
@@ -401,6 +475,7 @@ function toTemplate(row: TemplateRow) {
id: row.id, id: row.id,
name: row.name, name: row.name,
kind: row.kind, kind: row.kind,
is_default: row.is_default,
current_version: row.current_version, current_version: row.current_version,
config: configFromRow(row), config: configFromRow(row),
created_at: iso(row.created_at), created_at: iso(row.created_at),
@@ -416,12 +491,18 @@ function configFromRow(row: TemplateRow): TemplateConfig {
font_size_px: row.font_size_px, font_size_px: row.font_size_px,
opacity: row.opacity, opacity: row.opacity,
position: row.position, position: row.position,
position_x: Number(row.position_x),
position_y: Number(row.position_y),
} }
: { : {
image_asset_id: row.image_asset_id, image_asset_id: row.image_asset_id,
scale_ratio: row.scale_ratio, scale_ratio: row.scale_ratio,
image_size_px: row.image_size_px,
border_radius_px: row.border_radius_px,
opacity: row.opacity, opacity: row.opacity,
position: row.position, position: row.position,
position_x: Number(row.position_x),
position_y: Number(row.position_y),
}; };
} }
function normalizeConfig( function normalizeConfig(
@@ -435,14 +516,94 @@ function normalizeConfig(
font_size_px: config.font_size_px, font_size_px: config.font_size_px,
opacity: config.opacity, opacity: config.opacity,
position: config.position, position: config.position,
position_x: config.position_x,
position_y: config.position_y,
} }
: { : {
image_asset_id: config.image_asset_id, image_asset_id: config.image_asset_id,
scale_ratio: config.scale_ratio, scale_ratio: config.scale_ratio,
image_size_px: config.image_size_px,
border_radius_px: config.border_radius_px,
opacity: config.opacity, opacity: config.opacity,
position: config.position, position: config.position,
position_x: config.position_x,
position_y: config.position_y,
}; };
} }
const legacyPositions = [
'top_left', 'top_center', 'top_right',
'middle_left', 'center', 'middle_right',
'bottom_left', 'bottom_center', 'bottom_right',
] as const;
function validCoordinate(value: unknown): value is number {
return typeof value === 'number' && Number.isFinite(value) && value >= 0 && value <= 1;
}
function validImageSize(value: unknown): value is number {
return Number.isInteger(value) && Number(value) >= WATERMARK_IMAGE_SIZE_MIN_PX
&& Number(value) <= WATERMARK_IMAGE_SIZE_MAX_PX;
}
function validBorderRadius(value: unknown): value is number {
return Number.isInteger(value) && Number(value) >= 0
&& Number(value) <= WATERMARK_BORDER_RADIUS_MAX_PX;
}
function legacyImageSize(value: unknown): number | undefined {
const ratio = Number(value);
if (!Number.isFinite(ratio)) return undefined;
return Math.min(WATERMARK_IMAGE_SIZE_MAX_PX,
Math.max(WATERMARK_IMAGE_SIZE_MIN_PX, Math.round(ratio * 144)));
}
function normalizeImageConfig(
kind: 'text' | 'image', config: TemplateConfig, patch?: Record<string, unknown>,
): TemplateConfig {
if (kind !== 'image') return config;
const legacyOnly = patch?.scale_ratio !== undefined && patch.image_size_px === undefined;
const size = legacyOnly
? legacyImageSize(patch?.scale_ratio)
: config.image_size_px ?? legacyImageSize(config.scale_ratio);
const scaleRatio = legacyOnly || !validImageSize(size)
? config.scale_ratio
: String(Number(Math.max(0.1, size / 144).toFixed(4)));
return {
...config,
image_size_px: size,
border_radius_px: config.border_radius_px ?? 0,
// 旧比例字段只供旧客户端读取;像素尺寸决定新预览与后续渲染。
scale_ratio: scaleRatio,
};
}
function legacyCoordinates(position: unknown) {
const index = legacyPositions.indexOf(position as typeof legacyPositions[number]);
if (index < 0) return null;
return { x: (index % 3) / 2, y: Math.floor(index / 3) / 2 };
}
function normalizePlacement(config: TemplateConfig, patch?: Record<string, unknown>): TemplateConfig {
const legacyOnly = patch?.position !== undefined
&& patch.position_x === undefined && patch.position_y === undefined;
const legacy = legacyCoordinates(config.position);
const x = legacyOnly ? legacy?.x : config.position_x ?? legacy?.x;
const y = legacyOnly ? legacy?.y : config.position_y ?? legacy?.y;
if (!validCoordinate(x) || !validCoordinate(y)) return { ...config, position_x: x, position_y: y };
const positionX = Math.round(x * 10_000) / 10_000;
const positionY = Math.round(y * 10_000) / 10_000;
const col = Math.min(2, Math.floor(positionX * 3));
const row = Math.min(2, Math.floor(positionY * 3));
return {
...config,
position_x: positionX,
position_y: positionY,
// 旧字段仅供兼容展示;精确坐标始终决定实际水印位置。
position: legacyPositions[row * 3 + col],
};
}
function iso(value: Date | string) { function iso(value: Date | string) {
return value instanceof Date ? value.toISOString() : value; return value instanceof Date ? value.toISOString() : value;
} }
+64 -1
View File
@@ -179,6 +179,69 @@ describe('扩展授权与采集 HTTP 集成测试', () => {
assert.equal(first.body.data.collection_item.status, 'ready_for_edit'); assert.equal(first.body.data.collection_item.status, 'ready_for_edit');
assert.equal(first.body.data.materialization.pending_selection, 1); assert.equal(first.body.data.materialization.pending_selection, 1);
assert.equal(first.body.data.materialization.queued, 0); assert.equal(first.body.data.materialization.queued, 0);
const source1688 = {
source: {
platform: '1688', itemKey: '1688-price-image-test',
canonicalUrl: 'https://detail.1688.com/offer/123456789.html',
collectedAt: new Date().toISOString(), parserVersion: '1688-test',
},
common: { attributes: [], categories: [] },
variantAxes: [],
variants: [{
sourceVariantKey: '6100454176961', sourceSku: '6100454176961',
displayName: '六代目卡卡西25cm', selections: [],
sourcePrice: { amount: '31.335', currencyCode: 'CNY' },
confidence: 'verified', evidenceRefs: ['1688:sku-map'],
}],
assets: [{
url: 'https://cbu01.alicdn.com/sku.jpg', role: 'source_sku',
relation: 'axis_value', variantSourceKey: '6100454176961',
sortOrder: 0, evidenceRef: '1688:sku-map',
}],
evidence: [{ ref: '1688:sku-map', kind: 'state', path: 'skuModel.skuInfoMap' }],
};
const captured1688 = await request('/api/extension/materials', {
method: 'POST',
headers: {
Authorization: `Bearer ${webAccessToken}`,
'Idempotency-Key': randomUUID(),
},
body: JSON.stringify(source1688),
});
assert.equal(captured1688.response.status, 201);
const associated1688 = await database.workspaceTransaction(workspaceId, async (tx) =>
tx.query<{ purchase_cost_cny: string; source_variant_id: string; variant_id: string }>(
`SELECT v.purchase_cost_cny::text, t.source_variant_id, v.id AS variant_id
FROM atlas.product_variants v
JOIN atlas.collection_asset_transfers t ON t.product_id = v.product_id
WHERE v.product_id = $1 AND t.role = 'source_sku'`,
[captured1688.body.data.product_id],
),
);
assert.equal(associated1688.rows[0]?.purchase_cost_cny, '31.3400');
assert.equal(associated1688.rows[0]?.source_variant_id, associated1688.rows[0]?.variant_id);
const refreshed1688 = await request('/api/extension/materials', {
method: 'POST',
headers: {
Authorization: `Bearer ${webAccessToken}`,
'Idempotency-Key': randomUUID(),
},
body: JSON.stringify({
...source1688,
variants: [{
...source1688.variants[0],
sourcePrice: { amount: '32.525', currencyCode: 'CNY' },
}],
}),
});
assert.equal(refreshed1688.response.status, 200);
const updatedCost = await database.workspaceTransaction(workspaceId, async (tx) =>
tx.query<{ purchase_cost_cny: string }>(
`SELECT purchase_cost_cny::text FROM atlas.product_variants WHERE product_id = $1`,
[captured1688.body.data.product_id],
),
);
assert.equal(updatedCost.rows[0]?.purchase_cost_cny, '32.5300');
const platforms = await request('/api/platforms', { const platforms = await request('/api/platforms', {
headers: { Authorization: `Bearer ${webAccessToken}` }, headers: { Authorization: `Bearer ${webAccessToken}` },
}); });
@@ -328,7 +391,7 @@ describe('扩展授权与采集 HTTP 集成测试', () => {
variant.source_variant_key === '123456789', variant.source_variant_key === '123456789',
); );
assert.equal(originalVariant.display_name, '人工确认 SKU'); assert.equal(originalVariant.display_name, '人工确认 SKU');
assert.equal(originalVariant.purchase_cost_cny, '88.8000'); assert.equal(originalVariant.purchase_cost_cny, '88.80');
assert.equal(originalVariant.listing.offer_id, 'manual-offer-001'); assert.equal(originalVariant.listing.offer_id, 'manual-offer-001');
const discoveredVariant = editorAfterRefresh.body.data.variants.find( const discoveredVariant = editorAfterRefresh.body.data.variants.find(
(variant: { source_variant_key: string }) => (variant: { source_variant_key: string }) =>
+101
View File
@@ -0,0 +1,101 @@
import { describe, expect, it, vi } from 'vitest';
import { createMediaAiEditRequestSchema } from '@atlas/contracts';
import { MediaAiEditService } from '../src/media/media-ai-edit.service.js';
const workspaceId = '30cf54b7-06a3-4e5f-a5fc-d74165115094';
const sourceId = '664d0d73-3d39-4a98-ac0a-5c0e521d8998';
const referenceId = '50ea00b7-6675-4cc7-b7a2-d45f05c07100';
const editId = '68db226f-3fcb-4579-ab9e-4932b1d84067';
const productId = '733d0d73-3d39-4a98-ac0a-5c0e521d8998';
describe('单张 AI 编辑任务', () => {
it('验证主图和辅图都属于当前工作空间后,原子写入任务、进度和 Outbox', async () => {
const statements: string[] = [];
const query = vi.fn(async (sql: string) => {
statements.push(sql);
if (sql.includes('SELECT id FROM atlas.products')) return { rows: [{ id: productId }] };
if (sql.includes('SELECT id FROM atlas.media_assets'))
return { rows: [{ id: sourceId }, { id: referenceId }] };
if (sql.includes('RETURNING id')) return { rows: [{ id: editId }] };
return { rows: [] };
});
const run = vi.fn(async (_input: unknown, work: (tx: { query: typeof query }) => Promise<unknown>) => work({ query }));
const service = new MediaAiEditService(
{} as ConstructorParameters<typeof MediaAiEditService>[0],
{ run } as unknown as ConstructorParameters<typeof MediaAiEditService>[1],
{} as ConstructorParameters<typeof MediaAiEditService>[2],
);
const input = createMediaAiEditRequestSchema.parse({ product_id: productId, reference_asset_ids: [referenceId], background: 'reference' });
const result = await service.create(workspaceId, 'user-id', sourceId, 'key', input);
expect(result).toMatchObject({ status: 202, body: { data: { id: editId, status: 'queued' } } });
expect(run).toHaveBeenCalledOnce();
expect(statements.some((sql) => sql.includes('INSERT INTO atlas.media_ai_edits'))).toBe(true);
expect(statements.some((sql) => sql.includes('INSERT INTO atlas.operations'))).toBe(true);
expect(statements.some((sql) => sql.includes('INSERT INTO atlas.outbox_events'))).toBe(true);
});
it('任一辅助图不在当前工作空间时,不创建任务', async () => {
const query = vi.fn(async (sql: string) => ({ rows: [{ id: sql.includes('atlas.products') ? productId : sourceId }] }));
const run = vi.fn(async (_input: unknown, work: (tx: { query: typeof query }) => Promise<unknown>) => work({ query }));
const service = new MediaAiEditService(
{} as ConstructorParameters<typeof MediaAiEditService>[0],
{ run } as unknown as ConstructorParameters<typeof MediaAiEditService>[1],
{} as ConstructorParameters<typeof MediaAiEditService>[2],
);
const input = createMediaAiEditRequestSchema.parse({ product_id: productId, reference_asset_ids: [referenceId] });
await expect(service.create(workspaceId, 'user-id', sourceId, 'key', input))
.rejects.toMatchObject({ status: 404 });
expect(query).toHaveBeenCalledTimes(2);
});
it('仅扩图不能带辅图或尺寸标注,参考背景必须带辅图', () => {
expect(createMediaAiEditRequestSchema.safeParse({ product_id: productId, mode: 'outpaint', reference_asset_ids: [referenceId] }).success).toBe(false);
expect(createMediaAiEditRequestSchema.safeParse({ product_id: productId, background: 'reference' }).success).toBe(false);
expect(createMediaAiEditRequestSchema.safeParse({ product_id: productId, generate_size_chart: true }).success).toBe(false);
});
it('读取失败任务时返回记录的失败来源、步骤和诊断', async () => {
const query = vi.fn(async (sql: string) => {
expect(sql).toContain('o.workspace_id = e.workspace_id');
return { rows: [{
id: editId, status: 'failed', source_asset_id: sourceId,
output_asset_id: null, error_code: 'AI_IMAGE_GENERATION_FAILED',
failure_source: 'atlas', failure_stage: 'result_processing',
failure_detail: 'Pcore 结果图片无效',
}] };
});
const database = { workspaceTransaction: vi.fn(async (_workspace: string, work: (tx: { query: typeof query }) => Promise<unknown>) => work({ query })) };
const service = new MediaAiEditService(
database as unknown as ConstructorParameters<typeof MediaAiEditService>[0],
{} as ConstructorParameters<typeof MediaAiEditService>[1],
{} as ConstructorParameters<typeof MediaAiEditService>[2],
);
const result = await service.read(workspaceId, editId);
expect(result).toMatchObject({
failure_source: 'atlas', failure_stage: 'result_processing',
failure_detail: 'Pcore 结果图片无效',
});
});
it('AI 生图记录按当前商品筛选并重新签发短时预览地址', async () => {
const query = vi.fn(async (_sql: string, values: unknown[]) => {
expect(values).toEqual([productId]);
return { rows: [{
id: editId, status: 'succeeded', source_asset_id: sourceId,
output_asset_id: referenceId, error_code: null,
content_type: 'image/jpeg', byte_size: '1234', width_px: 1152,
height_px: 1536, asset_created_at: new Date('2026-09-26T00:00:00Z'),
}] };
});
const database = { workspaceTransaction: vi.fn(async (_workspace: string, work: (tx: { query: typeof query }) => Promise<unknown>) => work({ query })) };
const accessUrl = vi.fn(async () => ({ url: 'https://example.com/short-lived', expires_at: '2026-09-26T01:00:00Z' }));
const service = new MediaAiEditService(
database as unknown as ConstructorParameters<typeof MediaAiEditService>[0],
{} as ConstructorParameters<typeof MediaAiEditService>[1],
{ accessUrl } as unknown as ConstructorParameters<typeof MediaAiEditService>[2],
);
const items = await service.list(workspaceId, productId);
expect(items).toMatchObject([{ id: editId, output_asset: { id: referenceId, preview_url: 'https://example.com/short-lived' } }]);
expect(accessUrl).toHaveBeenCalledWith(workspaceId, referenceId);
});
});
@@ -0,0 +1,81 @@
import { describe, expect, it, vi } from 'vitest';
import { AspectRatioDerivativeService } from '../src/media/aspect-ratio-derivative.service.js';
const workspaceId = '30cf54b7-06a3-4e5f-a5fc-d74165115094';
const sourceAssetId = '664d0d73-3d39-4a98-ac0a-5c0e521d8998';
const derivativeId = '68db226f-3fcb-4579-ab9e-4932b1d84067';
const productId = '733d0d73-3d39-4a98-ac0a-5c0e521d8998';
describe('一键 3:4 派生任务', () => {
it('在同一个租户事务写入派生记录、操作进度和 Outbox', async () => {
const statements: string[] = [];
const query = vi.fn(async (sql: string) => {
statements.push(sql);
if (sql.includes('SELECT id FROM atlas.media_assets')) return { rows: [{ id: sourceAssetId }] };
if (sql.includes('RETURNING id')) return { rows: [{ id: derivativeId }] };
return { rows: [] };
});
const run = vi.fn(async (_input: unknown, work: (tx: { query: typeof query }) => Promise<unknown>) =>
work({ query }));
const service = new AspectRatioDerivativeService(
{} as ConstructorParameters<typeof AspectRatioDerivativeService>[0],
{ run } as unknown as ConstructorParameters<typeof AspectRatioDerivativeService>[1],
{} as ConstructorParameters<typeof AspectRatioDerivativeService>[2],
);
const result = await service.create(
workspaceId,
'user-id',
sourceAssetId,
'idempotency-key',
{ preset: '3:4', fit: 'contain' },
);
expect(result).toMatchObject({
status: 202,
body: { data: { id: derivativeId, status: 'queued', source_asset_id: sourceAssetId } },
});
expect(run).toHaveBeenCalledOnce();
expect(statements.some((sql) => sql.includes('INSERT INTO atlas.media_aspect_ratio_derivatives'))).toBe(true);
expect(statements.some((sql) => sql.includes('INSERT INTO atlas.operations'))).toBe(true);
expect(statements.some((sql) => sql.includes('INSERT INTO atlas.outbox_events'))).toBe(true);
});
it('缺少或跨工作空间的源素材不会创建任务', async () => {
const query = vi.fn(async () => ({ rows: [] }));
const run = vi.fn(async (_input: unknown, work: (tx: { query: typeof query }) => Promise<unknown>) =>
work({ query }));
const service = new AspectRatioDerivativeService(
{} as ConstructorParameters<typeof AspectRatioDerivativeService>[0],
{ run } as unknown as ConstructorParameters<typeof AspectRatioDerivativeService>[1],
{} as ConstructorParameters<typeof AspectRatioDerivativeService>[2],
);
await expect(service.create(
workspaceId,
'user-id',
sourceAssetId,
'idempotency-key',
{ preset: '3:4', fit: 'contain' },
)).rejects.toMatchObject({ status: 404 });
expect(query).toHaveBeenCalledOnce();
});
it('将当前工作空间的商品关联到新派生任务', async () => {
const query = vi.fn(async (sql: string, values?: unknown[]) => {
if (sql.includes('SELECT id FROM atlas.media_assets')) return { rows: [{ id: sourceAssetId }] };
if (sql.includes('SELECT id FROM atlas.products')) return { rows: [{ id: productId }] };
if (sql.includes('INSERT INTO atlas.media_aspect_ratio_derivatives')) {
expect(values).toEqual([productId, sourceAssetId, 'user-id']);
return { rows: [{ id: derivativeId }] };
}
return { rows: [] };
});
const run = vi.fn(async (_input: unknown, work: (tx: { query: typeof query }) => Promise<unknown>) => work({ query }));
const service = new AspectRatioDerivativeService(
{} as ConstructorParameters<typeof AspectRatioDerivativeService>[0],
{ run } as unknown as ConstructorParameters<typeof AspectRatioDerivativeService>[1],
{} as ConstructorParameters<typeof AspectRatioDerivativeService>[2],
);
await service.create(workspaceId, 'user-id', sourceAssetId, 'key',
{ preset: '3:4', fit: 'contain', product_id: productId });
expect(query.mock.calls.some(([sql]) => String(sql).includes('INSERT INTO atlas.media_aspect_ratio_derivatives'))).toBe(true);
});
});
@@ -1,6 +1,7 @@
import { createHash } from 'node:crypto'; import { createHash } from 'node:crypto';
import { GetObjectCommand, HeadObjectCommand } from '@aws-sdk/client-s3'; import { GetObjectCommand, HeadObjectCommand } from '@aws-sdk/client-s3';
import { import {
createMediaAccessUrlRequestSchema,
createMediaUploadIntentRequestSchema, createMediaUploadIntentRequestSchema,
importProductImageRequestSchema, importProductImageRequestSchema,
} from '@atlas/contracts'; } from '@atlas/contracts';
@@ -55,6 +56,35 @@ function png(width: number, height: number) {
} }
describe('商品图片上传策略', () => { describe('商品图片上传策略', () => {
it('下载地址签名 attachment 响应头,预览地址保持内联显示', async () => {
const assetId = '7c910684-7070-4f36-8a7b-b10bd91acd79';
const query = vi.fn(async () => ({
rows: [{ bucket: 'test', object_key: 'uploads/test.png', content_type: 'image/png' }],
}));
const workspaceTransaction = vi.fn(async (
_workspaceId: string,
callback: (tx: { query: typeof query }) => Promise<unknown>,
) => callback({ query }));
const service = new MediaService(
{} as ConstructorParameters<typeof MediaService>[0],
{ workspaceTransaction } as unknown as ConstructorParameters<typeof MediaService>[1],
{
OBJECT_STORAGE_ENDPOINT: 'http://127.0.0.1:9000',
OBJECT_STORAGE_BUCKET: 'test',
objectStorageForcePathStyle: true,
objectStorageAccessKey: 'test',
objectStorageSecretKey: 'test',
} as ConstructorParameters<typeof MediaService>[2],
);
expect(createMediaAccessUrlRequestSchema.safeParse({ purpose: 'download' }).success).toBe(true);
const preview = await service.accessUrl('workspace-id', assetId);
const download = await service.accessUrl('workspace-id', assetId, 'download');
expect(new URL(preview.url).searchParams.has('response-content-disposition')).toBe(false);
expect(new URL(download.url).searchParams.get('response-content-disposition'))
.toBe(`attachment; filename="atlas-image-${assetId}.png"`);
expect(workspaceTransaction).toHaveBeenCalledTimes(2);
});
it('创建商品图片上传意图时持久化用途并签发独立对象路径', async () => { it('创建商品图片上传意图时持久化用途并签发独立对象路径', async () => {
const query = vi.fn(async (_sql: string, _params?: unknown[]) => ({ const query = vi.fn(async (_sql: string, _params?: unknown[]) => ({
rows: [], rows: [],
+170
View File
@@ -0,0 +1,170 @@
import assert from 'node:assert/strict';
import { randomUUID } from 'node:crypto';
import type { AddressInfo } from 'node:net';
import type { Database } from '@atlas/database';
import type { NestExpressApplication } from '@nestjs/platform-express';
import { afterAll, beforeAll, describe, it } from 'vitest';
import { DATABASE } from '../src/database/database.module.js';
import { createApplication } from '../src/main.js';
let app: NestExpressApplication;
let database: Database;
let baseUrl = '';
let workspaceId = '';
let userId = '';
let token = '';
describe('水印派生 API 集成测试', () => {
beforeAll(async () => {
Object.assign(process.env, {
NODE_ENV: 'test', SWAGGER_ENABLED: 'false',
AUTH_REGISTER_IP_LIMIT: '100', AUTH_REGISTER_EMAIL_LIMIT: '100',
});
const created = await createApplication();
app = created.app;
database = app.get<Database>(DATABASE);
await app.listen(0, '127.0.0.1');
baseUrl = `http://127.0.0.1:${(app.getHttpServer().address() as AddressInfo).port}`;
const response = await fetch(`${baseUrl}/api/auth/register`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
email: `watermark-derivative-${randomUUID()}@example.test`,
password: 'correct-horse-battery-staple-123',
password_confirmation: 'correct-horse-battery-staple-123',
}),
});
assert.equal(response.status, 201);
const body = await response.json() as { data: {
user: { id: string }; workspace: { id: string }; access_token: string;
} };
userId = body.data.user.id;
workspaceId = body.data.workspace.id;
token = body.data.access_token;
});
afterAll(async () => {
if (workspaceId) {
await database.workspaceTransaction(workspaceId, async (tx) => {
await tx.query('DELETE FROM atlas.idempotency_requests WHERE workspace_id = $1', [workspaceId]);
await tx.query('DELETE FROM atlas.outbox_events WHERE workspace_id = $1', [workspaceId]);
await tx.query('DELETE FROM atlas.operations WHERE workspace_id = $1', [workspaceId]);
await tx.query('DELETE FROM atlas.media_aspect_ratio_derivatives WHERE workspace_id = $1', [workspaceId]);
await tx.query('DELETE FROM atlas.media_watermark_derivatives WHERE workspace_id = $1', [workspaceId]);
await tx.query('DELETE FROM atlas.watermark_template_versions WHERE workspace_id = $1', [workspaceId]);
await tx.query('DELETE FROM atlas.watermark_templates WHERE workspace_id = $1', [workspaceId]);
await tx.query('DELETE FROM atlas.media_assets WHERE workspace_id = $1', [workspaceId]);
await tx.query('DELETE FROM atlas.products WHERE workspace_id = $1', [workspaceId]);
await tx.query('DELETE FROM atlas.collection_items WHERE workspace_id = $1', [workspaceId]);
});
await database.query('DELETE FROM atlas.workspace_memberships WHERE user_id = $1', [userId]);
await database.query('DELETE FROM atlas.workspaces WHERE id = $1', [workspaceId]);
await database.query('DELETE FROM atlas.users WHERE id = $1', [userId]);
}
await app?.close();
});
it('HTTP 创建任务锁定模板版本,结果查询保持工作空间边界', async () => {
const { assetId, templateId, productId } = await database.workspaceTransaction(workspaceId, async (tx) => {
const item = await tx.query<{ id: string }>(
`INSERT INTO atlas.collection_items (workspace_id, source_kind, source_item_key)
VALUES ($1, 'manual', $2) RETURNING id`,
[workspaceId, `watermark-history-${randomUUID()}`],
);
const product = await tx.query<{ id: string }>(
`INSERT INTO atlas.products (workspace_id, collection_item_id)
VALUES ($1, $2) RETURNING id`,
[workspaceId, item.rows[0].id],
);
const asset = await tx.query<{ id: string }>(
`INSERT INTO atlas.media_assets
(workspace_id, storage_provider, bucket, object_key, content_type,
byte_size, checksum_sha256, width_px, height_px)
VALUES ($1, 'local_minio', 'atlas-media', $2, 'image/jpeg', 100,
$3, 120, 100) RETURNING id`,
[workspaceId, `test/watermark-${randomUUID()}.jpg`, randomUUID().replaceAll('-', '').padEnd(64, '0')],
);
const template = await tx.query<{ id: string }>(
`INSERT INTO atlas.watermark_templates (workspace_id, name, kind)
VALUES ($1, $2, 'text') RETURNING id`,
[workspaceId, `集成测试水印 ${randomUUID()}`],
);
await tx.query(
`INSERT INTO atlas.watermark_template_versions
(workspace_id, template_id, version, kind, text_content, font_code,
font_size_px, opacity, position, position_x, position_y, created_by_user_id)
VALUES ($1, $2, 1, 'text', 'Atlas', 'NotoSans', 20, 0.5,
'bottom_right', 1, 1, $3)`,
[workspaceId, template.rows[0].id, userId],
);
return { assetId: asset.rows[0].id, templateId: template.rows[0].id, productId: product.rows[0].id };
});
const key = randomUUID();
const create = () => fetch(`${baseUrl}/api/media-assets/${assetId}/watermark-derivatives`, {
method: 'POST', headers: {
Authorization: `Bearer ${token}`, 'Content-Type': 'application/json', 'Idempotency-Key': key,
},
body: JSON.stringify({ watermark_template_id: templateId, product_id: productId }),
});
const response = await create();
assert.equal(response.status, 202);
const body = await response.json() as { data: {
id: string; watermark_template_id: string; watermark_template_version: number;
} };
assert.equal(body.data.watermark_template_id, templateId);
assert.equal(body.data.watermark_template_version, 1);
const replay = await create();
assert.equal(replay.status, 202);
assert.equal((await replay.json() as { data: { id: string } }).data.id, body.data.id);
const read = await fetch(`${baseUrl}/api/media-watermark-derivatives/${body.data.id}`, {
headers: { Authorization: `Bearer ${token}` },
});
assert.equal(read.status, 200);
assert.equal((await read.json() as { data: { id: string } }).data.id, body.data.id);
const stored = await database.workspaceTransaction(workspaceId, async (tx) =>
tx.query<{ product_id: string }>(
'SELECT product_id FROM atlas.media_watermark_derivatives WHERE id = $1',
[body.data.id],
),
);
assert.equal(stored.rows[0]?.product_id, productId);
const outputs = await database.workspaceTransaction(workspaceId, async (tx) => {
const ids: string[] = [];
for (const kind of ['watermark', 'ratio']) {
const asset = await tx.query<{ id: string }>(
`INSERT INTO atlas.media_assets
(workspace_id, storage_provider, bucket, object_key, content_type,
byte_size, checksum_sha256, width_px, height_px)
VALUES ($1, 'local_minio', 'atlas-media', $2, 'image/jpeg', 100,
$3, 1200, 1600) RETURNING id`,
[workspaceId, `test/${kind}-${randomUUID()}.jpg`, randomUUID().replaceAll('-', '').padEnd(64, '0')],
);
ids.push(asset.rows[0].id);
}
await tx.query(
`INSERT INTO atlas.media_watermark_derivatives
(workspace_id, product_id, source_asset_id, watermark_template_id,
watermark_template_version, output_asset_id, status, completed_at)
VALUES ($1, $2, $3, $4, 1, $5, 'succeeded', now())`,
[workspaceId, productId, assetId, templateId, ids[0]],
);
await tx.query(
`INSERT INTO atlas.media_aspect_ratio_derivatives
(workspace_id, product_id, source_asset_id, output_asset_id, status, completed_at)
VALUES ($1, $2, $3, $4, 'succeeded', now())`,
[workspaceId, productId, assetId, ids[1]],
);
return ids;
});
const history = await fetch(`${baseUrl}/api/products/${productId}/image-derivatives`, {
headers: { Authorization: `Bearer ${token}` },
});
assert.equal(history.status, 200);
const records = (await history.json() as { data: Array<{
kind: string; output_asset: { id: string; preview_url: string };
}> }).data;
assert.deepEqual(new Set(records.map((record) => record.kind)), new Set(['watermark', 'aspect_ratio_3_4']));
assert.deepEqual(new Set(records.map((record) => record.output_asset.id)), new Set(outputs));
assert.ok(records.every((record) => record.output_asset.preview_url));
});
});
@@ -0,0 +1,68 @@
import { describe, expect, it, vi } from 'vitest';
import { WatermarkDerivativeService } from '../src/media/watermark-derivative.service.js';
const workspaceId = '30cf54b7-06a3-4e5f-a5fc-d74165115094';
const sourceAssetId = '664d0d73-3d39-4a98-ac0a-5c0e521d8998';
const templateId = '0878262e-6c38-4f3b-953a-2084ef297a33';
const derivativeId = '68db226f-3fcb-4579-ab9e-4932b1d84067';
const productId = '733d0d73-3d39-4a98-ac0a-5c0e521d8998';
function serviceWithQuery(query: ReturnType<typeof vi.fn>) {
const run = vi.fn(async (_input: unknown, work: (tx: { query: typeof query }) => Promise<unknown>) =>
work({ query }));
const service = new WatermarkDerivativeService(
{} as ConstructorParameters<typeof WatermarkDerivativeService>[0],
{ run } as unknown as ConstructorParameters<typeof WatermarkDerivativeService>[1],
{} as ConstructorParameters<typeof WatermarkDerivativeService>[2],
);
return { service, run };
}
describe('水印派生任务', () => {
it('固定当前模板版本,并在同一个事务创建任务、操作和 Outbox', async () => {
const statements: string[] = [];
const query = vi.fn(async (sql: string) => {
statements.push(sql);
if (sql.includes('SELECT id FROM atlas.media_assets')) return { rows: [{ id: sourceAssetId }] };
if (sql.includes('FROM atlas.watermark_templates')) return { rows: [{ id: templateId, current_version: 4 }] };
if (sql.includes('RETURNING id')) return { rows: [{ id: derivativeId }] };
return { rows: [] };
});
const { service, run } = serviceWithQuery(query);
const result = await service.create(workspaceId, 'user-id', sourceAssetId, 'idempotency-key',
{ watermark_template_id: templateId });
expect(result).toMatchObject({ status: 202, body: { data: {
id: derivativeId, watermark_template_id: templateId, watermark_template_version: 4,
} } });
expect(run).toHaveBeenCalledOnce();
expect(statements.some((sql) => sql.includes('INSERT INTO atlas.media_watermark_derivatives'))).toBe(true);
expect(statements.some((sql) => sql.includes('INSERT INTO atlas.operations'))).toBe(true);
expect(statements.some((sql) => sql.includes('INSERT INTO atlas.outbox_events'))).toBe(true);
});
it('拒绝不可用模板且不创建派生任务', async () => {
const query = vi.fn(async (sql: string) => sql.includes('SELECT id FROM atlas.media_assets')
? { rows: [{ id: sourceAssetId }] } : { rows: [] });
const { service } = serviceWithQuery(query);
await expect(service.create(workspaceId, 'user-id', sourceAssetId, 'idempotency-key',
{ watermark_template_id: templateId })).rejects.toMatchObject({ status: 404 });
expect(query.mock.calls.every(([sql]) => !String(sql).includes('INSERT INTO'))).toBe(true);
});
it('将当前工作空间的商品关联到水印结果', async () => {
const query = vi.fn(async (sql: string, values?: unknown[]) => {
if (sql.includes('SELECT id FROM atlas.media_assets')) return { rows: [{ id: sourceAssetId }] };
if (sql.includes('SELECT id FROM atlas.products')) return { rows: [{ id: productId }] };
if (sql.includes('FROM atlas.watermark_templates')) return { rows: [{ id: templateId, current_version: 4 }] };
if (sql.includes('INSERT INTO atlas.media_watermark_derivatives')) {
expect(values).toEqual([productId, sourceAssetId, templateId, 4, 'user-id']);
return { rows: [{ id: derivativeId }] };
}
return { rows: [] };
});
const { service } = serviceWithQuery(query);
await service.create(workspaceId, 'user-id', sourceAssetId, 'key',
{ watermark_template_id: templateId, product_id: productId });
expect(query.mock.calls.some(([sql]) => String(sql).includes('INSERT INTO atlas.media_watermark_derivatives'))).toBe(true);
});
});
+258
View File
@@ -0,0 +1,258 @@
import assert from 'node:assert/strict';
import { randomUUID } from 'node:crypto';
import type { AddressInfo } from 'node:net';
import type { Database } from '@atlas/database';
import type { NestExpressApplication } from '@nestjs/platform-express';
import { afterAll, beforeAll, describe, it } from 'vitest';
import { DATABASE } from '../src/database/database.module.js';
import { createApplication } from '../src/main.js';
let app: NestExpressApplication;
let database: Database;
let baseUrl: string;
let token = '';
let workspaceId = '';
let userId = '';
let platformId = '';
let storeId = '';
let listingId = '';
let runId = '';
async function request(path: string, init: RequestInit = {}) {
const response = await fetch(`${baseUrl}${path}`, {
...init,
headers: { 'Content-Type': 'application/json', ...init.headers },
});
return { response, body: (await response.json()) as Record<string, any> };
}
function auth(extra: Record<string, string> = {}) {
return {
Authorization: `Bearer ${token}`,
'X-Atlas-Platform-Id': platformId,
...extra,
};
}
describe('在线商品 HTTP 契约', () => {
beforeAll(async () => {
Object.assign(process.env, {
NODE_ENV: 'test',
SWAGGER_ENABLED: 'false',
AUTH_REGISTER_IP_LIMIT: '100',
AUTH_REGISTER_EMAIL_LIMIT: '100',
});
const created = await createApplication();
app = created.app;
database = app.get<Database>(DATABASE);
await app.listen(0, '127.0.0.1');
baseUrl = `http://127.0.0.1:${(app.getHttpServer().address() as AddressInfo).port}`;
const registered = await request('/api/auth/register', {
method: 'POST',
body: JSON.stringify({
email: `online-${randomUUID()}@example.test`,
password: 'correct-horse-battery-staple-123',
password_confirmation: 'correct-horse-battery-staple-123',
}),
});
assert.equal(registered.response.status, 201);
token = registered.body.data.access_token;
workspaceId = registered.body.data.workspace.id;
userId = registered.body.data.user.id;
const platforms = await request('/api/platforms', {
headers: { Authorization: `Bearer ${token}` },
});
platformId = platforms.body.data.find(
(platform: { code: string }) => platform.code === 'OZON',
).id;
storeId = randomUUID();
await database.workspaceTransaction(workspaceId, async (tx) => {
await tx.query(
`INSERT INTO atlas.stores (id, workspace_id, platform_id, name, platform_client_id, currency_code, is_default, status, credential_status)
VALUES ($1, $2, $3, '测试门店', $4, 'RUB', true, 'active', 'valid')`,
[storeId, workspaceId, platformId, `online-http-${storeId}`],
);
const run = await tx.query<{ id: string }>(
`INSERT INTO atlas.platform_product_sync_runs
(workspace_id, store_id, platform_id, trigger_source, status, completed_at)
VALUES ($1, $2, $3, 'manual', 'succeeded', now()) RETURNING id`,
[workspaceId, storeId, platformId],
);
runId = run.rows[0].id;
const listing = await tx.query<{ id: string }>(
`INSERT INTO atlas.platform_listings
(workspace_id, store_id, platform_id, remote_product_id, offer_id, remote_sku, name,
currency_code, selling_price, visible, moderation_status, stock_summary,
image_summary, source, last_sync_run_id, last_synced_at, raw_payload)
VALUES ($1, $2, $3, '123456', 'test-offer', '654321', '测试商品',
'RUB', 99, true, 'approved', '{"total":0,"has_data":true}',
'{"url":null,"width_px":null,"height_px":null}', 'external_sync', $4, now(), '{}') RETURNING id`,
[workspaceId, storeId, platformId, runId],
);
listingId = listing.rows[0].id;
await tx.query(
`INSERT INTO atlas.platform_listings
(workspace_id, store_id, platform_id, remote_product_id, offer_id, name,
visible, stock_summary, image_summary, source, last_sync_run_id, last_synced_at,
missing_since, archived_at, raw_payload)
VALUES ($1, $2, $3, '123457', 'archived-offer', '归档商品',
NULL, '{"total":0,"has_data":false}', '{}', 'external_sync', $4,
now(), now() - interval '8 days', now(), '{}')`,
[workspaceId, storeId, platformId, runId],
);
});
});
afterAll(async () => {
if (workspaceId && database) {
await database.workspaceTransaction(workspaceId, async (tx) => {
await tx.query(
'DELETE FROM atlas.platform_listing_sync_snapshots WHERE workspace_id = $1',
[workspaceId],
);
await tx.query(
'DELETE FROM atlas.platform_listings WHERE workspace_id = $1',
[workspaceId],
);
await tx.query(
'DELETE FROM atlas.platform_product_sync_runs WHERE workspace_id = $1',
[workspaceId],
);
await tx.query(
'DELETE FROM atlas.idempotency_requests WHERE workspace_id = $1',
[workspaceId],
);
await tx.query(
'DELETE FROM atlas.outbox_events WHERE workspace_id = $1',
[workspaceId],
);
await tx.query(
'DELETE FROM atlas.store_secrets WHERE workspace_id = $1',
[workspaceId],
);
await tx.query(
'DELETE FROM atlas.stores WHERE workspace_id = $1',
[workspaceId],
);
});
await database.query(
'DELETE FROM atlas.workspace_memberships WHERE user_id = $1',
[userId],
);
await database.query('DELETE FROM atlas.workspaces WHERE id = $1', [
workspaceId,
]);
await database.query('DELETE FROM atlas.users WHERE id = $1', [
userId,
]);
}
if (app) await app.close();
});
it('列表、详情与同步历史按平台店铺返回镜像,不返回原始响应', async () => {
const listed = await request(
`/api/platform-listings?store_id=${storeId}&q=test-offer`,
{ headers: auth() },
);
assert.equal(listed.response.status, 200);
assert.equal(listed.body.data.length, 1);
assert.equal(listed.body.data[0].visible, true);
assert.equal(listed.body.data[0].selling_price, '99.00');
assert.deepEqual(listed.body.data[0].stock_summary, {
total: 0,
has_data: true,
});
assert.equal(listed.body.meta.store.id, storeId);
assert.equal(listed.body.meta.last_sync_status, 'succeeded');
assert.equal('raw_payload' in listed.body.data[0], false);
const detail = await request(`/api/platform-listings/${listingId}`, {
headers: auth(),
});
assert.equal(detail.response.status, 200);
assert.equal(detail.body.data.offer_id, 'test-offer');
const runs = await request(
`/api/stores/${storeId}/platform-product-sync-runs`,
{ headers: auth() },
);
assert.equal(runs.response.status, 200);
assert.equal(runs.body.data[0].id, runId);
const run = await request(`/api/platform-product-sync-runs/${runId}`, {
headers: auth(),
});
assert.equal(run.response.status, 200);
assert.equal(run.body.data.status, 'succeeded');
});
it('筛选未知可见性与缺少平台头的请求按契约处理', async () => {
const unknown = await request(
`/api/platform-listings?store_id=${storeId}&visibility=unknown`,
{ headers: auth() },
);
assert.equal(unknown.response.status, 200);
assert.equal(unknown.body.data.length, 0);
const archived = await request(
`/api/platform-listings?store_id=${storeId}&visibility=unknown&include_archived=true`,
{ headers: auth() },
);
assert.equal(archived.response.status, 200);
assert.equal(archived.body.data.length, 1);
assert.equal(archived.body.data[0].offer_id, 'archived-offer');
const missingHeader = await request('/api/platform-listings', {
headers: { Authorization: `Bearer ${token}` },
});
assert.equal(missingHeader.response.status, 400);
assert.equal(
missingHeader.body.error.code,
'PLATFORM_CONTEXT_REQUIRED',
);
const noCredentials = await request(
`/api/stores/${storeId}/platform-product-sync-runs`,
{
method: 'POST',
headers: auth({ 'Idempotency-Key': randomUUID() }),
body: JSON.stringify({ reason: 'manual_refresh' }),
},
);
assert.equal(noCredentials.response.status, 422);
assert.equal(
noCredentials.body.error.code,
'STORE_CREDENTIAL_UNAVAILABLE',
);
});
it('触发同步写入 Outbox,同一幂等键复用任务,并发请求返回冲突', async () => {
await database.workspaceTransaction(workspaceId, (tx) =>
tx.query(
`INSERT INTO atlas.store_secrets
(workspace_id, store_id, kind, secret_ciphertext, secret_nonce,
encryption_key_version, fingerprint, status)
VALUES ($1, $2, 'seller_api_key', $3, $4, 'v1', $5, 'valid')`,
[workspaceId, storeId, Buffer.alloc(32), Buffer.alloc(12), randomUUID()],
),
);
const key = randomUUID();
const post = (idempotencyKey: string) =>
request(`/api/stores/${storeId}/platform-product-sync-runs`, {
method: 'POST',
headers: auth({ 'Idempotency-Key': idempotencyKey }),
body: JSON.stringify({ reason: 'manual_refresh' }),
});
const created = await post(key);
assert.equal(created.response.status, 202);
assert.equal(created.body.data.sync_run.status, 'queued');
const repeated = await post(key);
assert.equal(repeated.response.status, 202);
assert.equal(repeated.body.data.sync_run.id, created.body.data.sync_run.id);
const concurrent = await post(randomUUID());
assert.equal(concurrent.response.status, 409);
assert.equal(concurrent.body.error.code, 'SYNC_ALREADY_RUNNING');
const events = await database.workspaceTransaction(workspaceId, (tx) =>
tx.query<{ count: string }>(
`SELECT count(*)::text AS count FROM atlas.outbox_events
WHERE aggregate_id = $1 AND event_type = 'platform_product_sync.requested'`,
[created.body.data.sync_run.id],
),
);
assert.equal(events.rows[0].count, '1');
});
});
@@ -0,0 +1,48 @@
import { describe, expect, it, vi } from 'vitest';
import { ProductEditorService } from '../src/product-editor/product-editor.service.js';
const productId = '733d0d73-3d39-4a98-ac0a-5c0e521d8998';
const variantId = '50ea00b7-6675-4cc7-b7a2-d45f05c07100';
const assetId = '664d0d73-3d39-4a98-ac0a-5c0e521d8998';
function mediaTransaction(processed: boolean) {
const query = vi.fn(async (sql: string, values?: unknown[]) => {
if (sql.includes('FROM atlas.product_variant_platform_listings'))
return { rows: [{ id: 'listing-id', product_variant_id: variantId }] };
if (sql.includes('FROM atlas.media_assets'))
return { rows: [{ id: assetId, content_type: 'image/jpeg', byte_size: '1200', width_px: 1200, height_px: 1600 }] };
if (sql.includes('FROM atlas.media_aspect_ratio_derivatives')) {
expect(values).toEqual([productId, [assetId]]);
return { rows: processed ? [{ asset_id: assetId }] : [] };
}
return { rows: [] };
});
return { query };
}
describe('SKU 图片处理结果的保存资格', () => {
const input = {
product: { id: productId, collection_item_id: 'collection-id', source_snapshot_revision: 1 },
profile: { id: 'profile-id' },
variants: [{ variantId, assetIds: [assetId] }],
};
const service = new ProductEditorService(
{} as ConstructorParameters<typeof ProductEditorService>[0],
{} as ConstructorParameters<typeof ProductEditorService>[1],
) as unknown as {
persistListingMedia: (tx: ReturnType<typeof mediaTransaction>, value: typeof input) => Promise<void>;
};
it('当前商品的成功处理结果可加入 SKU 并保存', async () => {
const tx = mediaTransaction(true);
await service.persistListingMedia(tx, input);
expect(tx.query.mock.calls.some(([sql]) => String(sql).includes('INSERT INTO atlas.product_variant_listing_media'))).toBe(true);
});
it('其他商品或未成功的处理结果仍不能加入 SKU', async () => {
const tx = mediaTransaction(false);
await expect(service.persistListingMedia(tx, input)).rejects.toMatchObject({
status: 422, response: { error: { code: 'LISTING_MEDIA_NOT_SELECTABLE' } },
});
});
});
+77
View File
@@ -132,6 +132,10 @@ describe('商品编辑 M3.0–M3.3 HTTP 集成测试', () => {
'DELETE FROM atlas.collection_asset_transfers WHERE workspace_id = $1', 'DELETE FROM atlas.collection_asset_transfers WHERE workspace_id = $1',
[workspaceId], [workspaceId],
); );
await tx.query(
'DELETE FROM atlas.media_aspect_ratio_derivatives WHERE workspace_id = $1',
[workspaceId],
);
await tx.query( await tx.query(
'DELETE FROM atlas.product_image_imports WHERE workspace_id = $1', 'DELETE FROM atlas.product_image_imports WHERE workspace_id = $1',
[workspaceId], [workspaceId],
@@ -1985,6 +1989,79 @@ describe('商品编辑 M3.0–M3.3 HTTP 集成测试', () => {
assert.equal(privateImport.body.error.code, 'PRODUCT_IMAGE_IMPORT_INVALID'); assert.equal(privateImport.body.error.code, 'PRODUCT_IMAGE_IMPORT_INVALID');
}); });
it('当前商品的 3:4 处理记录可重新加入 SKU 并保存', async () => {
const before = await request(`/api/products/${first.productId}/editor`, {
headers: headers({ 'X-Atlas-Platform-Id': platformId }),
});
const sourceAssetId = before.body.data.variants[0].media[0].asset_id as string;
const outputAssetId = await database.workspaceTransaction(workspaceId, async (tx) => {
const output = await tx.query<{ id: string }>(
`INSERT INTO atlas.media_assets
(workspace_id, storage_provider, bucket, object_key, content_type,
byte_size, checksum_sha256, width_px, height_px)
VALUES ($1, 'test', 'test', $2, 'image/jpeg', 1024, $3, 1200, 1600)
RETURNING id`,
[workspaceId, `processed/${randomUUID()}.jpg`, randomUUID().replaceAll('-', '').repeat(2)],
);
await tx.query(
`INSERT INTO atlas.media_aspect_ratio_derivatives
(workspace_id, product_id, source_asset_id, output_asset_id, status, completed_at)
VALUES ($1, $2, $3, $4, 'succeeded', now())`,
[workspaceId, first.productId, sourceAssetId, output.rows[0].id],
);
return output.rows[0].id;
});
const snapshot = before.body.data;
const payload = {
ai_source_material: snapshot.source_material,
ai_source_overridden_fields: snapshot.source_material_state.overridden_fields,
common: { ...snapshot.product.common,
default_physical_spec: snapshot.product.default_physical_spec,
default_package_spec: snapshot.product.default_package_spec },
profile: {
target_store_id: snapshot.profile.target_store.id,
fulfillment: snapshot.profile.fulfillment,
title: snapshot.profile.title,
description: snapshot.profile.description,
tags: snapshot.profile.tags,
platform_product_category_id: snapshot.profile.category.id,
category_source: snapshot.profile.category.source,
},
attributes: snapshot.attributes.map((attribute: any) => ({
scope: attribute.scope, variant_id: attribute.variant_id,
platform_attribute_id: attribute.platform_attribute_id,
complex_id: attribute.complex_id, values: attribute.values,
language_code: attribute.language_code, source: attribute.source,
confirmation_status: attribute.confirmation_status,
})),
variants: snapshot.variants.map((item: any) => ({
id: item.id, client_key: null, copy_from_variant_id: null,
display_name: item.display_name, source_sku: item.source_sku,
specification_values: item.specification_values,
purchase_cost_cny: item.purchase_cost_cny,
tags: item.tags, description: item.description,
physical_spec: item.physical_spec, package_spec: item.package_spec,
offer_id: item.listing.offer_id,
media_asset_ids: item.id === snapshot.variants[0].id
? [outputAssetId] : item.media.map((media: any) => media.asset_id),
})),
};
const saved = await request(`/api/products/${first.productId}/editor-draft`, {
method: 'PUT',
headers: headers({
'X-Atlas-Platform-Id': platformId,
'Idempotency-Key': randomUUID(),
'If-Match': `"${snapshot.product.revision}:${snapshot.profile.revision}"`,
}),
body: JSON.stringify(payload),
});
assert.equal(saved.response.status, 200);
const after = await request(`/api/products/${first.productId}/editor`, {
headers: headers({ 'X-Atlas-Platform-Id': platformId }),
});
assert.equal(after.body.data.variants[0].media[0].asset_id, outputAssetId);
});
it('独立保存采集描述会持久化覆盖值并保持幂等', async () => { it('独立保存采集描述会持久化覆盖值并保持幂等', async () => {
const created = await createCollectionItem('独立保存描述测试商品'); const created = await createCollectionItem('独立保存描述测试商品');
const entered = await request( const entered = await request(
@@ -0,0 +1,56 @@
import { describe, expect, it, vi } from 'vitest';
import { ProductImageDerivativeHistoryService } from '../src/media/product-image-derivative-history.service.js';
const workspaceId = '30cf54b7-06a3-4e5f-a5fc-d74165115094';
const productId = '733d0d73-3d39-4a98-ac0a-5c0e521d8998';
describe('商品图片处理记录', () => {
it('只读取当前商品成功的 3:4 与水印结果,并重新签发预览地址', async () => {
const query = vi.fn(async (sql: string, values: unknown[]) => {
expect(values).toEqual([productId]);
if (sql.includes('FROM atlas.products')) return { rows: [{ id: productId }] };
expect(sql).toContain("WHERE product_id = $1 AND status = 'succeeded'");
expect(sql).toContain('LIMIT 100');
return { rows: [
{
id: 'watermark-task', kind: 'watermark', source_asset_id: 'source-1',
output_asset_id: 'output-1', created_at: new Date('2026-09-27T08:00:00Z'),
content_type: 'image/jpeg', byte_size: '1200', width_px: 1200, height_px: 1600,
asset_created_at: new Date('2026-09-27T08:01:00Z'),
},
{
id: 'ratio-task', kind: 'aspect_ratio_3_4', source_asset_id: 'source-2',
output_asset_id: 'output-2', created_at: new Date('2026-09-27T07:00:00Z'),
content_type: 'image/jpeg', byte_size: '1100', width_px: 1200, height_px: 1600,
asset_created_at: new Date('2026-09-27T07:01:00Z'),
},
] };
});
const workspaceTransaction = vi.fn(async (_id: string, work: (tx: { query: typeof query }) => Promise<unknown>) => work({ query }));
const accessUrl = vi.fn(async (_workspace: string, assetId: string) => ({
url: `https://example.test/${assetId}`, expires_at: '2026-09-27T08:10:00Z',
}));
const service = new ProductImageDerivativeHistoryService(
{ workspaceTransaction } as unknown as ConstructorParameters<typeof ProductImageDerivativeHistoryService>[0],
{ accessUrl } as unknown as ConstructorParameters<typeof ProductImageDerivativeHistoryService>[1],
);
const records = await service.list(workspaceId, productId);
expect(workspaceTransaction).toHaveBeenCalledWith(workspaceId, expect.any(Function));
expect(records).toMatchObject([
{ kind: 'watermark', output_asset: { id: 'output-1', preview_url: 'https://example.test/output-1' } },
{ kind: 'aspect_ratio_3_4', output_asset: { id: 'output-2', preview_url: 'https://example.test/output-2' } },
]);
expect(accessUrl).toHaveBeenCalledTimes(2);
});
it('商品不在当前工作空间时返回 404,不查询图片记录', async () => {
const query = vi.fn(async () => ({ rows: [] }));
const workspaceTransaction = vi.fn(async (_id: string, work: (tx: { query: typeof query }) => Promise<unknown>) => work({ query }));
const service = new ProductImageDerivativeHistoryService(
{ workspaceTransaction } as unknown as ConstructorParameters<typeof ProductImageDerivativeHistoryService>[0],
{} as ConstructorParameters<typeof ProductImageDerivativeHistoryService>[1],
);
await expect(service.list(workspaceId, productId)).rejects.toMatchObject({ status: 404 });
expect(query).toHaveBeenCalledOnce();
});
});
@@ -0,0 +1,98 @@
import { describe, expect, it, vi } from 'vitest';
import { ProductImageTaskService } from '../src/media/product-image-task.service.js';
const workspaceId = '30cf54b7-06a3-4e5f-a5fc-d74165115094';
const productId = '733d0d73-3d39-4a98-ac0a-5c0e521d8998';
describe('商品图片任务列表', () => {
it('按工作空间和商品读取任务,结果只包含可公开的任务字段', async () => {
const query = vi.fn(async (sql: string, values: unknown[]) => {
expect(values[0]).toBe(productId);
if (sql.includes('atlas.products')) return { rows: [{ id: productId }] };
expect(values).toEqual([productId, 3]);
return { rows: [{
id: 'edit-id', status: 'running', provider_task_id: 'private-provider-id',
output_asset_id: null, error_code: null,
created_at: new Date('2026-09-26T03:00:00Z'), completed_at: null,
}, {
id: 'completed-id', status: 'succeeded', provider_task_id: 'private-provider-id',
output_asset_id: 'asset-id', error_code: null,
created_at: new Date('2026-09-26T02:00:00Z'), completed_at: new Date('2026-09-26T02:05:00Z'),
}] };
});
const workspaceTransaction = vi.fn(async (_workspace: string, work: (tx: { query: typeof query }) => Promise<unknown>) => work({ query }));
const service = new ProductImageTaskService(
{ workspaceTransaction } as unknown as ConstructorParameters<typeof ProductImageTaskService>[0],
);
const result = await service.list(workspaceId, productId, { limit: 2 });
expect(workspaceTransaction).toHaveBeenCalledWith(workspaceId, expect.any(Function));
expect(result.meta).toEqual({ has_more: false, next_cursor: null });
expect(result.data).toEqual([{
id: 'edit-id', kind: 'single_ai_edit', status: 'running', phase: 'waiting_provider',
total: 1, completed: 0, output_asset_ids: [], error_code: null, provider_error_raw: null,
failure_source: null, failure_stage: null, failure_detail: null,
created_at: '2026-09-26T03:00:00.000Z', completed_at: null,
}, {
id: 'completed-id', kind: 'single_ai_edit', status: 'succeeded', phase: null,
total: 1, completed: 1, output_asset_ids: ['asset-id'], error_code: null, provider_error_raw: null,
failure_source: null, failure_stage: null, failure_detail: null,
created_at: '2026-09-26T02:00:00.000Z', completed_at: '2026-09-26T02:05:00.000Z',
}]);
expect(JSON.stringify(result)).not.toContain('private-provider-id');
});
it('失败任务返回 Pcore 原始错误,不暴露供应商任务 ID', async () => {
const raw = '{"message":"请求无法用于生成图像","type":"image_task_error","code":"failed"}';
const query = vi.fn(async (sql: string) => {
if (sql.includes('atlas.products')) return { rows: [{ id: productId }] };
expect(sql).toContain('o.workspace_id = e.workspace_id');
return { rows: [{
id: 'failed-id', status: 'failed', provider_task_id: 'private-provider-id',
output_asset_id: null, error_code: 'AI_IMAGE_PROVIDER_FAILED', provider_error_raw: raw,
failure_source: 'pcore', failure_stage: 'provider_polling', failure_detail: 'Pcore 图片任务失败',
created_at: new Date('2026-09-27T03:00:00Z'), completed_at: new Date('2026-09-27T03:01:00Z'),
}] };
});
const workspaceTransaction = vi.fn(async (_workspace: string, work: (tx: { query: typeof query }) => Promise<unknown>) => work({ query }));
const service = new ProductImageTaskService(
{ workspaceTransaction } as unknown as ConstructorParameters<typeof ProductImageTaskService>[0],
);
const result = await service.list(workspaceId, productId, { limit: 20 });
expect(result.data[0].provider_error_raw).toBe(raw);
expect(result.data[0]).toMatchObject({
failure_source: 'pcore', failure_stage: 'provider_polling', failure_detail: 'Pcore 图片任务失败',
});
expect(JSON.stringify(result)).not.toContain('private-provider-id');
});
it('商品不属于当前工作空间时返回 404,不查询任务', async () => {
const query = vi.fn(async () => ({ rows: [] }));
const workspaceTransaction = vi.fn(async (_workspace: string, work: (tx: { query: typeof query }) => Promise<unknown>) => work({ query }));
const service = new ProductImageTaskService(
{ workspaceTransaction } as unknown as ConstructorParameters<typeof ProductImageTaskService>[0],
);
await expect(service.list(workspaceId, productId, { limit: 20 })).rejects.toMatchObject({ status: 404 });
expect(query).toHaveBeenCalledOnce();
});
it('超过页大小时返回游标,并将游标作为下一页的稳定排序条件', async () => {
const id = '68db226f-3fcb-4579-ab9e-4932b1d84067';
const rows = [0, 1, 2].map((index) => ({
id, status: 'succeeded', provider_task_id: null, output_asset_id: null,
error_code: null, created_at: new Date(`2026-09-26T0${3 - index}:00:00Z`),
completed_at: null,
}));
const query = vi.fn(async (sql: string, _values?: unknown[]) => ({ rows: sql.includes('atlas.products') ? [{ id: productId }] : rows }));
const workspaceTransaction = vi.fn(async (_workspace: string, work: (tx: { query: typeof query }) => Promise<unknown>) => work({ query }));
const service = new ProductImageTaskService(
{ workspaceTransaction } as unknown as ConstructorParameters<typeof ProductImageTaskService>[0],
);
const first = await service.list(workspaceId, productId, { limit: 2 });
expect(first.meta.has_more).toBe(true);
expect(first.meta.next_cursor).toEqual(expect.any(String));
await service.list(workspaceId, productId, { limit: 2, cursor: first.meta.next_cursor! });
const nextQuery = query.mock.calls.at(-1)!;
expect(nextQuery[0]).toContain('e.created_at, e.id)');
expect(nextQuery[1]).toEqual([productId, 0, '2026-09-26T02:00:00.000Z', id, 3]);
});
});
@@ -0,0 +1,94 @@
import { describe, expect, it, vi } from 'vitest';
import { WatermarksService } from '../src/watermarks/watermarks.service.js';
const templateId = '46ea1f9c-28d0-4b32-92e1-3d4e74ad7757';
const templateRow = (isDefault: boolean) => ({
id: templateId,
name: '品牌水印',
kind: 'text' as const,
is_default: isDefault,
current_version: 1,
created_at: new Date('2026-09-25T00:00:00Z'),
updated_at: new Date('2026-09-25T00:00:00Z'),
revision: 1,
text_content: 'Atlas',
image_asset_id: null,
font_code: 'NotoSans',
font_size_px: 15,
opacity: '0.2600',
scale_ratio: null,
image_size_px: null,
border_radius_px: 0,
position: 'center',
position_x: '0.5000',
position_y: '0.5000',
});
describe('工作空间默认水印', () => {
it('切换默认后返回新状态,重复设置不会再写入', async () => {
let isDefault = false;
const statements: string[] = [];
const query = vi.fn(async (sql: string) => {
statements.push(sql);
if (sql.includes('SELECT id FROM atlas.workspaces')) return { rows: [{ id: 'workspace' }] };
if (sql.includes('SELECT template.id')) return { rows: [templateRow(isDefault)] };
if (sql.includes('SET is_default = false')) return { rows: [] };
if (sql.includes('SET is_default = true')) {
isDefault = true;
return { rows: [] };
}
throw new Error(`未知 SQL: ${sql}`);
});
const run = vi.fn(async (_input: unknown, work: (tx: { query: typeof query }) => Promise<unknown>) =>
work({ query }));
const service = new WatermarksService(
{ run } as unknown as ConstructorParameters<typeof WatermarksService>[0],
{} as ConstructorParameters<typeof WatermarksService>[1],
);
const first = await service.setDefault('workspace', 'user', templateId, 'key-1');
expect(first).toMatchObject({ status: 200, body: { data: { id: templateId, is_default: true } } });
expect(statements.filter((sql) => sql.includes('SET is_default'))).toHaveLength(2);
expect(statements[0]).toContain('FOR UPDATE');
const second = await service.setDefault('workspace', 'user', templateId, 'key-2');
expect(second).toMatchObject({ status: 200, body: { data: { is_default: true } } });
expect(statements.filter((sql) => sql.includes('SET is_default'))).toHaveLength(2);
expect(run).toHaveBeenCalledWith(expect.objectContaining({
method: 'PUT',
path: `/api/watermark-templates/${templateId}/default`,
}), expect.any(Function));
});
it('未设置默认时返回 null', async () => {
const query = vi.fn(async () => ({ rows: [] }));
const workspaceTransaction = vi.fn(async (_workspaceId: string, work: (tx: { query: typeof query }) => Promise<unknown>) =>
work({ query }));
const service = new WatermarksService(
{} as ConstructorParameters<typeof WatermarksService>[0],
{ workspaceTransaction } as unknown as ConstructorParameters<typeof WatermarksService>[1],
);
expect(await service.getDefault('workspace')).toBeNull();
expect(query.mock.calls).toHaveLength(1);
expect(workspaceTransaction).toHaveBeenCalledWith('workspace', expect.any(Function));
});
it('删除默认模板时同时清除默认标记', async () => {
const query = vi.fn(async (sql: string) => {
if (sql.includes('SELECT template.id')) return { rows: [templateRow(true)] };
return { rows: [] };
});
const run = vi.fn(async (_input: unknown, work: (tx: { query: typeof query }) => Promise<unknown>) =>
work({ query }));
const service = new WatermarksService(
{ run } as unknown as ConstructorParameters<typeof WatermarksService>[0],
{} as ConstructorParameters<typeof WatermarksService>[1],
);
expect(await service.remove('workspace', 'user', templateId, 'key')).toEqual({ status: 204, body: null });
expect(query.mock.calls.some(([sql]) => sql.includes("status = 'archived'")
&& sql.includes('is_default = false'))).toBe(true);
});
});
@@ -0,0 +1,153 @@
import { describe, expect, it, vi } from 'vitest';
import { createWatermarkTemplateRequestSchema } from '@atlas/contracts';
import { WatermarksService } from '../src/watermarks/watermarks.service.js';
const templateId = '46ea1f9c-28d0-4b32-92e1-3d4e74ad7757';
const assetId = '54799515-028d-4320-80a2-f53fd13fb588';
const imageRow = (size: number, radius: number, version = 1) => ({
id: templateId,
name: '图片水印',
kind: 'image' as const,
current_version: version,
created_at: new Date('2026-09-26T00:00:00Z'),
updated_at: new Date('2026-09-26T00:00:00Z'),
revision: version,
text_content: null,
image_asset_id: assetId,
font_code: null,
font_size_px: null,
opacity: '0.2600',
scale_ratio: '0.5556',
image_size_px: size,
border_radius_px: radius,
position: 'bottom_right',
position_x: '1.0000',
position_y: '1.0000',
});
const serviceWith = (query: ReturnType<typeof vi.fn>) => {
const run = vi.fn(async (_input: unknown, work: (tx: { query: typeof query }) => Promise<unknown>) =>
work({ query }));
return new WatermarksService(
{ run } as unknown as ConstructorParameters<typeof WatermarksService>[0],
{} as ConstructorParameters<typeof WatermarksService>[1],
);
};
describe('图片水印像素尺寸和圆角', () => {
it('创建契约接收像素尺寸并约束尺寸和圆角范围,同时兼容旧比例', () => {
const base = {
name: '图片水印', kind: 'image',
config: { image_asset_id: assetId, opacity: '0.26', position_x: 1, position_y: 1 },
};
expect(createWatermarkTemplateRequestSchema.safeParse({
...base, config: { ...base.config, image_size_px: 80, border_radius_px: 12 },
}).success).toBe(true);
expect(createWatermarkTemplateRequestSchema.safeParse({
...base, config: { ...base.config, image_size_px: 10 },
}).success).toBe(true);
expect(createWatermarkTemplateRequestSchema.safeParse({
...base, config: { ...base.config, scale_ratio: '0.25' },
}).success).toBe(true);
expect(createWatermarkTemplateRequestSchema.safeParse(base).success).toBe(false);
expect(createWatermarkTemplateRequestSchema.safeParse({
...base, config: { ...base.config, image_size_px: 9 },
}).success).toBe(false);
expect(createWatermarkTemplateRequestSchema.safeParse({
...base, config: { ...base.config, image_size_px: 201 },
}).success).toBe(false);
expect(createWatermarkTemplateRequestSchema.safeParse({
...base, config: { ...base.config, image_size_px: 80, border_radius_px: 101 },
}).success).toBe(false);
});
it('创建时保存绝对尺寸和圆角,并保留旧比例字段', async () => {
let inserted: unknown[] = [];
const query = vi.fn(async (sql: string, args?: unknown[]) => {
if (sql.includes('SELECT id FROM atlas.media_assets')) return { rows: [{ id: assetId }] };
if (sql.includes('INSERT INTO atlas.watermark_templates')) return { rows: [{ id: templateId }] };
if (sql.includes('INSERT INTO atlas.watermark_template_versions')) {
inserted = args ?? [];
return { rows: [] };
}
if (sql.includes('SELECT template.id')) return { rows: [imageRow(Number(inserted[13]), Number(inserted[14]))] };
throw new Error(`未知 SQL: ${sql}`);
});
const result = await serviceWith(query).create('workspace', 'user', 'key', {
name: '图片水印', kind: 'image',
config: { image_asset_id: assetId, image_size_px: 80, border_radius_px: 12,
opacity: '0.26', position_x: 1, position_y: 1 },
});
expect(inserted[8]).toBe('0.5556');
expect(inserted.slice(13, 15)).toEqual([80, 12]);
expect(result).toMatchObject({ body: { data: { config: {
image_size_px: 80, border_radius_px: 12,
} } } });
});
it('旧版比例输入换算为像素尺寸,圆角默认为零', async () => {
let inserted: unknown[] = [];
const query = vi.fn(async (sql: string, args?: unknown[]) => {
if (sql.includes('SELECT id FROM atlas.media_assets')) return { rows: [{ id: assetId }] };
if (sql.includes('INSERT INTO atlas.watermark_templates')) return { rows: [{ id: templateId }] };
if (sql.includes('INSERT INTO atlas.watermark_template_versions')) {
inserted = args ?? [];
return { rows: [] };
}
if (sql.includes('SELECT template.id')) return { rows: [imageRow(36, 0)] };
throw new Error(`未知 SQL: ${sql}`);
});
await serviceWith(query).create('workspace', 'user', 'key', {
name: '图片水印', kind: 'image',
config: { image_asset_id: assetId, scale_ratio: '0.25',
opacity: '0.26', position_x: 1, position_y: 1 },
});
expect(inserted[8]).toBe('0.25');
expect(inserted.slice(13, 15)).toEqual([36, 0]);
});
it('10px 尺寸原值保存,兼容比例保持旧版允许的最小值', async () => {
let inserted: unknown[] = [];
const query = vi.fn(async (sql: string, args?: unknown[]) => {
if (sql.includes('SELECT id FROM atlas.media_assets')) return { rows: [{ id: assetId }] };
if (sql.includes('INSERT INTO atlas.watermark_templates')) return { rows: [{ id: templateId }] };
if (sql.includes('INSERT INTO atlas.watermark_template_versions')) {
inserted = args ?? [];
return { rows: [] };
}
if (sql.includes('SELECT template.id')) return { rows: [imageRow(10, 0)] };
throw new Error(`未知 SQL: ${sql}`);
});
await serviceWith(query).create('workspace', 'user', 'key', {
name: '图片水印', kind: 'image',
config: { image_asset_id: assetId, image_size_px: 10,
opacity: '0.26', position_x: 1, position_y: 1 },
});
expect(inserted[8]).toBe('0.1');
expect(inserted[13]).toBe(10);
});
it('只修改圆角时保留图片尺寸并创建新版本', async () => {
let inserted: unknown[] = [];
const query = vi.fn(async (sql: string, args?: unknown[]) => {
if (sql.includes('SELECT template.id') && sql.includes('FOR UPDATE')) return { rows: [imageRow(80, 0)] };
if (sql.includes('SELECT id FROM atlas.media_assets')) return { rows: [{ id: assetId }] };
if (sql.includes('UPDATE atlas.watermark_templates')) return { rows: [] };
if (sql.includes('INSERT INTO atlas.watermark_template_versions')) {
inserted = args ?? [];
return { rows: [] };
}
if (sql.includes('SELECT template.id')) return { rows: [imageRow(80, 16, 2)] };
throw new Error(`未知 SQL: ${sql}`);
});
const result = await serviceWith(query).update(
'workspace', 'user', templateId, 'key', 1, { config: { border_radius_px: 16 } },
);
expect(inserted[13]).toBe(80);
expect(inserted[14]).toBe(16);
expect(result).toMatchObject({ body: { data: { current_version: 2, config: {
image_size_px: 80, border_radius_px: 16,
} } } });
});
});
@@ -0,0 +1,109 @@
import { describe, expect, it, vi } from 'vitest';
import { createWatermarkTemplateRequestSchema } from '@atlas/contracts';
import { WatermarksService } from '../src/watermarks/watermarks.service.js';
const templateId = '46ea1f9c-28d0-4b32-92e1-3d4e74ad7757';
const templateRow = (x: number, y: number, position: string) => ({
id: templateId,
name: '测试水印',
kind: 'text' as const,
current_version: 1,
created_at: new Date('2026-09-25T00:00:00Z'),
updated_at: new Date('2026-09-25T00:00:00Z'),
revision: 1,
text_content: 'Atlas',
image_asset_id: null,
font_code: 'NotoSans',
font_size_px: 15,
opacity: '0.2600',
scale_ratio: null,
position,
position_x: x.toFixed(4),
position_y: y.toFixed(4),
});
describe('水印任意位置坐标', () => {
it('契约接收精确坐标、兼容旧位置,并拒绝创建时只给一个坐标', () => {
const base = {
name: '测试水印',
kind: 'text',
config: {
text_content: 'Atlas',
font_code: 'NotoSans',
font_size_px: 15,
opacity: '0.26',
},
};
expect(createWatermarkTemplateRequestSchema.safeParse({
...base, config: { ...base.config, position_x: 0.3737, position_y: 0.6161 },
}).success).toBe(true);
expect(createWatermarkTemplateRequestSchema.safeParse({
...base, config: { ...base.config, position: 'bottom_right' },
}).success).toBe(true);
expect(createWatermarkTemplateRequestSchema.safeParse({
...base, config: { ...base.config, position_x: 0.3737 },
}).success).toBe(false);
});
it('创建时保存精确坐标并在读取响应中返回,不吸附九宫格', async () => {
let insertedVersion: unknown[] = [];
const query = vi.fn(async (sql: string, args?: unknown[]) => {
if (sql.includes('SELECT code FROM atlas.watermark_fonts')) return { rows: [{ code: 'NotoSans' }] };
if (sql.includes('INSERT INTO atlas.watermark_templates')) return { rows: [{ id: templateId }] };
if (sql.includes('INSERT INTO atlas.watermark_template_versions')) {
insertedVersion = args ?? [];
return { rows: [] };
}
if (sql.includes('SELECT template.id')) return { rows: [templateRow(
Number(insertedVersion[9]), Number(insertedVersion[10]), String(insertedVersion[11]),
)] };
throw new Error(`未知 SQL: ${sql}`);
});
const run = vi.fn(async (_input: unknown, work: (tx: { query: typeof query }) => Promise<unknown>) =>
work({ query }));
const service = new WatermarksService(
{ run } as unknown as ConstructorParameters<typeof WatermarksService>[0],
{} as ConstructorParameters<typeof WatermarksService>[1],
);
const result = await service.create('workspace', 'user', 'key', {
name: '测试水印', kind: 'text',
config: {
text_content: 'Atlas', font_code: 'NotoSans', font_size_px: 15,
opacity: '0.26', position_x: 0.3737, position_y: 0.6161,
},
});
expect(insertedVersion.slice(9, 12)).toEqual([0.3737, 0.6161, 'center']);
expect(result).toMatchObject({
body: { data: { config: { position_x: 0.3737, position_y: 0.6161 } } },
});
});
it('旧版九宫格输入换算为精确坐标', async () => {
let insertedVersion: unknown[] = [];
const query = vi.fn(async (sql: string, args?: unknown[]) => {
if (sql.includes('SELECT code FROM atlas.watermark_fonts')) return { rows: [{ code: 'NotoSans' }] };
if (sql.includes('INSERT INTO atlas.watermark_templates')) return { rows: [{ id: templateId }] };
if (sql.includes('INSERT INTO atlas.watermark_template_versions')) {
insertedVersion = args ?? [];
return { rows: [] };
}
if (sql.includes('SELECT template.id')) return { rows: [templateRow(1, 0, 'top_right')] };
throw new Error(`未知 SQL: ${sql}`);
});
const run = vi.fn(async (_input: unknown, work: (tx: { query: typeof query }) => Promise<unknown>) =>
work({ query }));
const service = new WatermarksService(
{ run } as unknown as ConstructorParameters<typeof WatermarksService>[0],
{} as ConstructorParameters<typeof WatermarksService>[1],
);
await service.create('workspace', 'user', 'key', {
name: '测试水印', kind: 'text',
config: {
text_content: 'Atlas', font_code: 'NotoSans', font_size_px: 15,
opacity: '0.26', position: 'top_right',
},
});
expect(insertedVersion.slice(9, 12)).toEqual([1, 0, 'top_right']);
});
});
+51 -17
View File
@@ -4,7 +4,7 @@ import {
isOzonProductPage, isOzonProductPage,
type OzonPageJsonFetcher, type OzonPageJsonFetcher,
} from './ozon-parser.js'; } from './ozon-parser.js';
import { collectPlatformDomAssets } from './product-media.js'; import { collectPlatformDomAssets, normaliseImageUrl } from './product-media.js';
import { formatCollectedDescription } from './description-format.js'; import { formatCollectedDescription } from './description-format.js';
type SourcePlatform = '1688' | 'taobao' | 'tmall' | 'ozon'; type SourcePlatform = '1688' | 'taobao' | 'tmall' | 'ozon';
@@ -49,7 +49,10 @@ export async function collectCurrentProduct(
function collect1688(root: Document, pageUrl: string, bridgedState?: unknown): CollectedProductPayload { function collect1688(root: Document, pageUrl: string, bridgedState?: unknown): CollectedProductPayload {
const state = [bridgedState, ...pageState(root)]; const state = [bridgedState, ...pageState(root)];
const skuMap = findFirst(state, 'skuMapOriginal'); const skuInfoMap = findFirst(state, 'skuInfoMap');
const skuMap = isRecord(skuInfoMap) && Object.keys(skuInfoMap).length
? skuInfoMap
: findFirst(state, 'skuMapOriginal');
const skuProps = findFirst(state, 'skuProps'); const skuProps = findFirst(state, 'skuProps');
const axes = parse1688Axes(skuProps); const axes = parse1688Axes(skuProps);
const evidence: CollectedProductPayload['evidence'] = [ const evidence: CollectedProductPayload['evidence'] = [
@@ -70,32 +73,29 @@ function collect1688(root: Document, pageUrl: string, bridgedState?: unknown): C
evidence.push({ evidence.push({
ref: '1688:sku-map', ref: '1688:sku-map',
kind: 'state', kind: 'state',
path: 'skuMapOriginal', path: isRecord(skuInfoMap) ? 'skuModel.skuInfoMap' : 'skuMapOriginal',
raw_value: `组合记录 ${skuRows.length} 条`, raw_value: `组合记录 ${skuRows.length} 条`,
}); });
for (const [combination, row] of skuRows) { for (const [combination, row] of skuRows) {
if (!isRecord(row)) continue; if (!isRecord(row)) continue;
const sourceSku = stringAt(row, ['skuId', 'sku_id', 'skuCode']); const sourceSku = stringAt(row, ['skuId', 'sku_id', 'skuCode']);
// A skuMapOriginal entry itself is a source-provided sellable-combination const specAttrs = stringAt(row, ['specAttrs', 'propPath']) ?? combination;
// record. Never expand it with the Cartesian product of skuProps. const selections = selectionsFor1688Combination(specAttrs, axes);
// 来源记录本身证明可售组合,不能用规格轴的笛卡尔积扩充 SKU。
const sourceVariantKey = sourceSku ?? `1688:${combination}`; const sourceVariantKey = sourceSku ?? `1688:${combination}`;
if (!combination || combination === 'undefined') continue; if (!combination || combination === 'undefined') continue;
variants.push({ variants.push({
sourceVariantKey, sourceVariantKey,
...(sourceSku ? { sourceSku } : {}), ...(sourceSku ? { sourceSku } : {}),
displayName: displayName:
selectionsForCombination(combination, axes) selections
.map((item) => item.valueName) .map((item) => item.valueName)
.join(' / ') || `1688 SKU ${sourceSku ?? combination}`, .join(' / ') || specAttrs,
selections: selectionsForCombination(combination, axes), selections,
...(moneyAt(row, ['discountPrice', 'price', 'salePrice']) ...(moneyAt(row, ['price'])
? { ? {
sourcePrice: { sourcePrice: {
amount: moneyAt(row, [ amount: moneyAt(row, ['price'])!,
'discountPrice',
'price',
'salePrice',
])!,
currencyCode: 'CNY', currencyCode: 'CNY',
}, },
} }
@@ -105,7 +105,7 @@ function collect1688(root: Document, pageUrl: string, bridgedState?: unknown): C
}); });
} }
} }
return basePayload( const payload = basePayload(
'1688', '1688',
offerId(pageUrl), offerId(pageUrl),
root, root,
@@ -113,9 +113,29 @@ function collect1688(root: Document, pageUrl: string, bridgedState?: unknown): C
axes, axes,
uniqueVariants(variants), uniqueVariants(variants),
evidence, evidence,
'1688-structured-2.0.1', '1688-structured-2.1.0',
state, state,
); );
// SKU 图片由规格值和真实可售组合关联,独立于页面当时选中的 DOM 状态。
for (const variant of payload.variants) {
for (const selection of variant.selections) {
const rawUrl = axes.find((axis) => axis.key === selection.axisKey)
?.values.find((value) => value.key === selection.valueKey)?.image_url;
const url = rawUrl && normaliseImageUrl(rawUrl, pageUrl, '1688');
if (!url || payload.assets.length >= 500) continue;
if (payload.assets.some((asset) => asset.variantSourceKey === variant.sourceVariantKey && asset.url === url)) continue;
payload.assets.push({
url,
mediaType: 'image',
role: 'source_sku',
relation: 'axis_value',
variantSourceKey: variant.sourceVariantKey,
sortOrder: payload.assets.length,
evidenceRef: '1688:sku-map',
});
}
}
return payload;
} }
function collectTaobao( function collectTaobao(
@@ -411,13 +431,14 @@ function pageState(root: Document) {
} }
} }
for (const marker of [ for (const marker of [
'skuInfoMap',
'skuMapOriginal', 'skuMapOriginal',
'skuBase', 'skuBase',
'skuCore', 'skuCore',
'skuProps', 'skuProps',
]) { ]) {
const object = objectAfterMarker(text, marker); const object = objectAfterMarker(text, marker);
if (object) all.push(object); if (object) all.push({ [marker]: object });
} }
} }
return all; return all;
@@ -589,6 +610,19 @@ function selectionsForCombination(combination: string, axes: Axis[]) {
: []; : [];
}); });
} }
function selectionsFor1688Combination(combination: string, axes: Axis[]) {
const selected = selectionsForCombination(combination, axes);
if (selected.length === axes.length) return selected;
const parts = combination.split(/[;,&|]/).map((part) => part.trim()).filter(Boolean);
return axes.flatMap((axis) => {
const existing = selected.find((item) => item.axisKey === axis.key);
if (existing) return [existing];
const value = axis.values.find((item) =>
parts.some((part) => part === item.name || part === `${axis.name}:${item.name}`)
);
return value ? [{ axisKey: axis.key, valueKey: value.key, valueName: value.name }] : [];
});
}
function stringAt(value: Json, keys: string[]) { function stringAt(value: Json, keys: string[]) {
for (const key of keys) for (const key of keys)
if (typeof value[key] === 'string' || typeof value[key] === 'number') if (typeof value[key] === 'string' || typeof value[key] === 'number')
+62
View File
@@ -0,0 +1,62 @@
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import test from 'node:test';
import { collectCurrentProduct } from '../lib/source-parsers.ts';
function fixtureSkuModel(html: string) {
const marker = '"skuModel": ';
const start = html.indexOf(marker);
assert.ok(start >= 0);
const source = html.slice(start + marker.length);
let depth = 0;
let quoted = false;
let escaped = false;
for (let index = 0; index < source.length; index += 1) {
const char = source[index];
if (quoted) {
if (escaped) escaped = false;
else if (char === '\\') escaped = true;
else if (char === '"') quoted = false;
} else if (char === '"') quoted = true;
else if (char === '{') depth += 1;
else if (char === '}' && --depth === 0) return JSON.parse(source.slice(0, index + 1));
}
throw new Error('1688 样本缺少完整 skuModel');
}
test('1688 真实样本按 skuInfoMap 的 price 和规格图片生成逐 SKU 数据', async () => {
const html = await readFile(new URL('../../../packages/reference/1688.html', import.meta.url), 'utf8');
const skuModel = fixtureSkuModel(html);
const root = {
scripts: [],
querySelector: () => null,
querySelectorAll: () => [],
} as unknown as Document;
const payload = await collectCurrentProduct(
root,
'https://detail.1688.com/offer/123456789.html',
undefined,
async () => ({ skuModel }),
);
assert.equal(payload.variants.length, Object.keys(skuModel.skuInfoMap).length);
for (const [name, row] of Object.entries<any>(skuModel.skuInfoMap)) {
const variant = payload.variants.find((item) => item.sourceSku === String(row.skuId));
assert.ok(variant, `缺少 ${name}`);
assert.equal(variant.displayName, name);
assert.equal(variant.sourcePrice?.amount, row.price);
const image = skuModel.skuProps[0].value.find((item: any) => item.name === name)?.imageUrl;
assert.ok(payload.assets.some((asset) =>
asset.variantSourceKey === variant.sourceVariantKey && asset.url === image
), `${name} 未关联规格图片`);
}
const scriptRoot = {
...root,
scripts: [{ type: 'text/javascript', textContent: html }],
} as unknown as Document;
const withoutBridge = await collectCurrentProduct(
scriptRoot,
'https://detail.1688.com/offer/123456789.html',
);
assert.equal(withoutBridge.variants.length, payload.variants.length);
assert.equal(withoutBridge.assets.filter((asset) => asset.variantSourceKey).length, payload.variants.length);
});
+46 -27
View File
@@ -94,36 +94,12 @@
margin-top: 16px; margin-top: 16px;
} }
/* 3x3 水印位置九宫格 */
.position-grid {
display: grid;
grid-template-columns: repeat(3, 44px);
grid-auto-rows: 44px;
gap: 8px;
}
.position-grid-cell {
border: 1px solid var(--border-color);
border-radius: 6px;
background: #f5f5f5;
cursor: pointer;
transition: all 0.2s;
padding: 0;
}
.position-grid-cell:hover {
border-color: var(--primary-light);
}
.position-grid-cell.active {
border-color: var(--primary-color);
background: rgba(139, 92, 246, 0.12);
}
/* 水印预览:模拟透明底图的棋盘格 */ /* 水印预览:模拟透明底图的棋盘格 */
.watermark-preview { .watermark-preview {
display: flex; display: flex;
padding: 12px; position: relative;
max-width: 100%;
padding: 5px;
box-sizing: border-box; box-sizing: border-box;
border: 1px dashed var(--border-color); border: 1px dashed var(--border-color);
border-radius: 4px; border-radius: 4px;
@@ -132,6 +108,49 @@
overflow: hidden; overflow: hidden;
} }
.watermark-preview-mark {
display: inline-flex;
position: absolute;
align-items: center;
justify-content: center;
max-width: calc(100% - 10px);
max-height: calc(100% - 10px);
overflow: hidden;
}
.watermark-preview-mark-draggable {
cursor: grab;
touch-action: none;
user-select: none;
}
.watermark-preview-mark-dragging {
cursor: grabbing;
z-index: 1;
}
.watermark-preview-dialog-content {
display: flex;
justify-content: center;
}
.watermark-preview-dialog-footer {
display: flex;
justify-content: center;
}
.watermark-editor-pair {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
column-gap: 16px;
}
@media (max-width: 575px) {
.watermark-editor-pair {
grid-template-columns: minmax(0, 1fr);
}
}
.watermark-form-actions { .watermark-form-actions {
display: flex; display: flex;
justify-content: center; justify-content: center;
+131 -29
View File
@@ -1,10 +1,10 @@
import { useState } from 'react'; import { useRef, useState } from 'react';
import type { CSSProperties } from 'react'; import type { PointerEvent as ReactPointerEvent, KeyboardEvent as ReactKeyboardEvent } from 'react';
import { Typography } from 'antd'; import { Typography } from 'antd';
import type { WatermarkPosition } from '@atlas/contracts'; import type { WatermarkPosition } from '@atlas/contracts';
import type { WatermarkTemplateConfig } from '@atlas/contracts'; import type { WatermarkTemplateConfig } from '@atlas/contracts';
/** 九宫格位置选项,顺序与编辑器 3x3 网格一致 */ /** 旧版九宫格位置与精确坐标的映射,供已有模板读取。 */
export const WATERMARK_POSITION_OPTIONS: Array<{ export const WATERMARK_POSITION_OPTIONS: Array<{
value: WatermarkPosition; value: WatermarkPosition;
label: string; label: string;
@@ -25,62 +25,163 @@ export const WATERMARK_POSITION_OPTIONS: Array<{
export const watermarkPositionLabel = (position: string): string => export const watermarkPositionLabel = (position: string): string =>
WATERMARK_POSITION_OPTIONS.find((option) => option.value === position)?.label ?? position; WATERMARK_POSITION_OPTIONS.find((option) => option.value === position)?.label ?? position;
const POSITION_FLEX: Record<
WatermarkPosition,
Pick<CSSProperties, 'justifyContent' | 'alignItems'>
> = {
top_left: { justifyContent: 'flex-start', alignItems: 'flex-start' },
top_center: { justifyContent: 'center', alignItems: 'flex-start' },
top_right: { justifyContent: 'flex-end', alignItems: 'flex-start' },
middle_left: { justifyContent: 'flex-start', alignItems: 'center' },
center: { justifyContent: 'center', alignItems: 'center' },
middle_right: { justifyContent: 'flex-end', alignItems: 'center' },
bottom_left: { justifyContent: 'flex-start', alignItems: 'flex-end' },
bottom_center: { justifyContent: 'center', alignItems: 'flex-end' },
bottom_right: { justifyContent: 'flex-end', alignItems: 'flex-end' },
};
interface WatermarkPreviewProps { interface WatermarkPreviewProps {
/** 允许传入部分字段:编辑弹窗按当前表单值实时渲染 */ /** 允许传入部分字段:编辑弹窗按当前表单值实时渲染 */
config: Partial<WatermarkTemplateConfig>; config: Partial<WatermarkTemplateConfig>;
width?: number; width?: number;
height?: number; height?: number;
onPositionChange?: (position: { position_x: number; position_y: number }) => void;
} }
type DragState = {
pointerId: number;
offsetX: number;
offsetY: number;
markWidth: number;
markHeight: number;
};
const clamp = (value: number, min: number, max: number) =>
Math.min(Math.max(value, min), Math.max(min, max));
const roundCoordinate = (value: number) => Math.round(clamp(value, 0, 1) * 10_000) / 10_000;
const PREVIEW_INSET_PX = 5;
// 开发服务直接加载前端源码,范围须与共享契约中的校验值保持一致。
export const WATERMARK_IMAGE_SIZE_MIN_PX = 10;
export const WATERMARK_IMAGE_SIZE_DEFAULT_PX = 80;
export const WATERMARK_IMAGE_SIZE_MAX_PX = 200;
export const WATERMARK_BORDER_RADIUS_MAX_PX = 100;
/** /**
* 按模板 config 在内置演示底图上实时渲染水印效果(docs/api/watermarks-and-assets.md §3.1), * 按模板 config 在内置演示底图上实时渲染水印效果(docs/api/watermarks-and-assets.md §3.1),
* 不调用服务端渲染;图片使用响应中的短时 preview_url,过期时提示重新读取。 * 不调用服务端渲染;图片使用响应中的短时 preview_url,过期时提示重新读取。
*/ */
const WatermarkPreview = ({ config, width = 320, height = 420 }: WatermarkPreviewProps) => { const WatermarkPreview = ({ config, width = 240, height = 320, onPositionChange }: WatermarkPreviewProps) => {
const [imageBroken, setImageBroken] = useState(false); const [brokenImageUrl, setBrokenImageUrl] = useState<string | null>(null);
const position = config.position ?? 'bottom_right'; const [imageDimensions, setImageDimensions] = useState<{ url: string; width: number; height: number } | null>(null);
const flex = POSITION_FLEX[position] ?? POSITION_FLEX.bottom_right; const [dragPoint, setDragPoint] = useState<{ left: number; top: number } | null>(null);
const previewRef = useRef<HTMLDivElement>(null);
const dragRef = useRef<DragState | null>(null);
const legacy = WATERMARK_POSITION_OPTIONS.find((option) => option.value === config.position);
const positionX = config.position_x ?? (legacy ? legacy.col / 2 : 1);
const positionY = config.position_y ?? (legacy ? legacy.row / 2 : 1);
const previewUrl = config.image_asset?.preview_url; const previewUrl = config.image_asset?.preview_url;
const legacyImageSize = Number(config.scale_ratio) * width * 0.6;
const imageSize = config.image_size_px
?? (Number.isFinite(legacyImageSize)
? Math.round(legacyImageSize) : WATERMARK_IMAGE_SIZE_DEFAULT_PX);
const imageAspect = imageDimensions && imageDimensions.url === previewUrl && imageDimensions.height > 0
? imageDimensions.width / imageDimensions.height : 1;
const imageWidth = imageAspect >= 1 ? imageSize : Math.max(1, Math.round(imageSize * imageAspect));
const imageHeight = imageAspect >= 1 ? Math.max(1, Math.round(imageSize / imageAspect)) : imageSize;
const moveMark = (event: ReactPointerEvent<HTMLDivElement>) => {
const drag = dragRef.current;
const preview = previewRef.current;
if (!drag || !preview || drag.pointerId !== event.pointerId) return null;
const bounds = preview.getBoundingClientRect();
const point = {
left: clamp(event.clientX - bounds.left - drag.offsetX, PREVIEW_INSET_PX, bounds.width - drag.markWidth - PREVIEW_INSET_PX),
top: clamp(event.clientY - bounds.top - drag.offsetY, PREVIEW_INSET_PX, bounds.height - drag.markHeight - PREVIEW_INSET_PX),
};
setDragPoint(point);
return { point, bounds, drag };
};
const startDrag = (event: ReactPointerEvent<HTMLDivElement>) => {
if (!onPositionChange || !previewRef.current) return;
const markBounds = event.currentTarget.getBoundingClientRect();
const previewBounds = previewRef.current.getBoundingClientRect();
dragRef.current = {
pointerId: event.pointerId,
offsetX: event.clientX - markBounds.left,
offsetY: event.clientY - markBounds.top,
markWidth: markBounds.width,
markHeight: markBounds.height,
};
setDragPoint({ left: markBounds.left - previewBounds.left, top: markBounds.top - previewBounds.top });
event.currentTarget.setPointerCapture(event.pointerId);
event.preventDefault();
};
const finishDrag = (event: ReactPointerEvent<HTMLDivElement>) => {
const result = moveMark(event);
if (!result) return;
const { point, bounds, drag } = result;
dragRef.current = null;
setDragPoint(null);
event.currentTarget.releasePointerCapture(event.pointerId);
// 坐标按水印实际可移动范围归一化,松手后保留精确位置。
const travelX = bounds.width - drag.markWidth - PREVIEW_INSET_PX * 2;
const travelY = bounds.height - drag.markHeight - PREVIEW_INSET_PX * 2;
onPositionChange?.({
position_x: roundCoordinate(travelX > 0 ? (point.left - PREVIEW_INSET_PX) / travelX : 0),
position_y: roundCoordinate(travelY > 0 ? (point.top - PREVIEW_INSET_PX) / travelY : 0),
});
};
const moveWithKeyboard = (event: ReactKeyboardEvent<HTMLDivElement>) => {
if (!onPositionChange) return;
let x = positionX;
let y = positionY;
if (event.key === 'ArrowUp') y -= 0.01;
else if (event.key === 'ArrowDown') y += 0.01;
else if (event.key === 'ArrowLeft') x -= 0.01;
else if (event.key === 'ArrowRight') x += 0.01;
else return;
event.preventDefault();
onPositionChange({ position_x: roundCoordinate(x), position_y: roundCoordinate(y) });
};
return ( return (
<div <div
ref={previewRef}
className="watermark-preview" className="watermark-preview"
style={{ width, height, ...flex, opacity: Number(config.opacity ?? '1') }} style={{ width, height: 'auto', aspectRatio: `${width} / ${height}` }}
>
<div
className={`watermark-preview-mark${config.image_asset ? ' watermark-preview-mark-image' : ''}${onPositionChange ? ' watermark-preview-mark-draggable' : ''}${dragPoint ? ' watermark-preview-mark-dragging' : ''}`}
style={{
opacity: Number(config.opacity ?? '0.26'),
left: dragPoint?.left ?? `calc(${positionX * 100}% + ${PREVIEW_INSET_PX - positionX * PREVIEW_INSET_PX * 2}px)`,
top: dragPoint?.top ?? `calc(${positionY * 100}% + ${PREVIEW_INSET_PX - positionY * PREVIEW_INSET_PX * 2}px)`,
transform: dragPoint ? 'none' : `translate(-${positionX * 100}%, -${positionY * 100}%)`,
}}
role={onPositionChange ? 'button' : undefined}
tabIndex={onPositionChange ? 0 : undefined}
aria-label={onPositionChange ? `拖动水印调整位置,水平 ${Math.round(positionX * 1000) / 10}%,垂直 ${Math.round(positionY * 1000) / 10}%;可用方向键微调` : undefined}
onPointerDown={startDrag}
onPointerMove={moveMark}
onPointerUp={finishDrag}
onPointerCancel={() => { dragRef.current = null; setDragPoint(null); }}
onKeyDown={moveWithKeyboard}
> >
{config.image_asset ? ( {config.image_asset ? (
imageBroken || !previewUrl ? ( brokenImageUrl === previewUrl || !previewUrl ? (
<Typography.Text type="secondary">图片预览已过期或不可用</Typography.Text> <Typography.Text type="secondary">图片预览已过期或不可用</Typography.Text>
) : ( ) : (
<img <img
src={previewUrl} src={previewUrl}
alt="水印图片" alt="水印图片"
draggable={false}
style={{ style={{
// scale_ratio 以底图短边为基准的视觉近似:1.0 ≈ 底图宽度的 60% width: imageWidth,
width: Math.round(Number(config.scale_ratio ?? '1') * width * 0.6), height: imageHeight,
maxWidth: '80%', borderRadius: config.border_radius_px ?? 0,
objectFit: 'contain',
}} }}
onError={() => setImageBroken(true)} onLoad={(event) => setImageDimensions({
url: previewUrl,
width: event.currentTarget.naturalWidth,
height: event.currentTarget.naturalHeight,
})}
onError={() => setBrokenImageUrl(previewUrl)}
/> />
) )
) : ( ) : (
<span <span
style={{ style={{
fontSize: config.font_size_px ?? 24, fontSize: config.font_size_px ?? 15,
fontWeight: 600, fontWeight: 600,
color: '#111827', color: '#111827',
whiteSpace: 'nowrap', whiteSpace: 'nowrap',
@@ -91,6 +192,7 @@ const WatermarkPreview = ({ config, width = 320, height = 420 }: WatermarkPrevie
</span> </span>
)} )}
</div> </div>
</div>
); );
}; };
@@ -0,0 +1,15 @@
.collection-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 16px;
margin-bottom: 16px;
}
.collection-toolbar-usage {
width: 240px;
max-width: 100%;
margin-left: auto;
text-align: right;
}
@@ -8,6 +8,7 @@ import { collectionApi } from '@/services/collection';
import { apiErrorMessage } from '@/services/api'; import { apiErrorMessage } from '@/services/api';
import { usePlatform } from '@/services/platform'; import { usePlatform } from '@/services/platform';
import { productEditorApi } from '@/services/productEditor'; import { productEditorApi } from '@/services/productEditor';
import './CollectionPage.css';
const statusLabel: Record<string, string> = { received: '已接收', materializing: '素材处理中', ready_for_edit: '可编辑', needs_review: '待确认', failed: '采集失败' }; const statusLabel: Record<string, string> = { received: '已接收', materializing: '素材处理中', ready_for_edit: '可编辑', needs_review: '待确认', failed: '采集失败' };
export default function CollectionPage() { export default function CollectionPage() {
@@ -21,8 +22,10 @@ export default function CollectionPage() {
{ title: '产品名称', dataIndex: 'title', render: (value) => value || '未命名商品' }, { title: '来源', dataIndex: 'source', width: 110, render: (value) => <Tag>{value.kind === 'manual' ? '手动创建' : value.platform_code}</Tag> }, { title: 'SKU', dataIndex: 'variant_summary', width: 80, render: (value) => value.count }, { title: '采集状态', dataIndex: 'collection_status', width: 120, render: (value) => statusLabel[value] ?? value }, { title: '当前平台上架', dataIndex: 'listing_summary', width: 150, render: (value) => <Typography.Text type="secondary">{value.detail}</Typography.Text> }, { title: '备注', dataIndex: 'note', ellipsis: true, render: (value) => value || '—' }, { title: '更新时间', dataIndex: 'updated_at', width: 170, render: (value) => new Date(value).toLocaleString('zh-CN', { hour12: false }) }, { title: '操作', width: 150, render: (_, item) => <Space><Button type="link" size="small" onClick={() => void openEditor(item.id)}>编辑上架</Button><Button type="link" danger size="small" onClick={() => remove([item.id])}>删除</Button></Space> }, { title: '产品名称', dataIndex: 'title', render: (value) => value || '未命名商品' }, { title: '来源', dataIndex: 'source', width: 110, render: (value) => <Tag>{value.kind === 'manual' ? '手动创建' : value.platform_code}</Tag> }, { title: 'SKU', dataIndex: 'variant_summary', width: 80, render: (value) => value.count }, { title: '采集状态', dataIndex: 'collection_status', width: 120, render: (value) => statusLabel[value] ?? value }, { title: '当前平台上架', dataIndex: 'listing_summary', width: 150, render: (value) => <Typography.Text type="secondary">{value.detail}</Typography.Text> }, { title: '备注', dataIndex: 'note', ellipsis: true, render: (value) => value || '—' }, { title: '更新时间', dataIndex: 'updated_at', width: 170, render: (value) => new Date(value).toLocaleString('zh-CN', { hour12: false }) }, { title: '操作', width: 150, render: (_, item) => <Space><Button type="link" size="small" onClick={() => void openEditor(item.id)}>编辑上架</Button><Button type="link" danger size="small" onClick={() => remove([item.id])}>删除</Button></Space> },
]; ];
return <Card title="采集箱" extra={<Space><Button icon={<ReloadOutlined />} onClick={() => void load()} /><Button danger disabled={!items.length} onClick={() => remove([], true)}>清空采集箱</Button><Button danger icon={<DeleteOutlined />} disabled={!selected.length} onClick={() => remove(selected)}>批量删除</Button><Button type="primary" icon={<PlusOutlined />} onClick={() => setCreateOpen(true)}>手动创建商品</Button></Space>}> return <Card title="采集箱" extra={<Space><Button icon={<ReloadOutlined />} onClick={() => void load()} /><Button danger disabled={!items.length} onClick={() => remove([], true)}>清空采集箱</Button><Button danger icon={<DeleteOutlined />} disabled={!selected.length} onClick={() => remove(selected)}>批量删除</Button><Button type="primary" icon={<PlusOutlined />} onClick={() => setCreateOpen(true)}>手动创建商品</Button></Space>}>
<Progress percent={meta.collection_limit ? Math.round(meta.active_count / meta.collection_limit * 100) : 0} format={() => `${meta.active_count} / ${meta.collection_limit}`} style={{ maxWidth: 420, marginBottom: 18 }} /> <div className="collection-toolbar">
<Space wrap style={{ marginBottom: 16 }}><Input value={q} onChange={(e) => setQ(e.target.value)} placeholder="搜索产品名称" allowClear style={{ width: 220 }} /><Select allowClear placeholder="采集来源" value={source} onChange={setSource} style={{ width: 140 }} options={[{ value: 'OZON', label: 'Ozon' }, { value: '1688', label: '1688' }, { value: 'TAOBAO', label: '淘宝' }, { value: 'TMALL', label: '天猫' }]} /><Button type="primary" onClick={() => void load()}>查询</Button></Space> <Space wrap><Input value={q} onChange={(e) => setQ(e.target.value)} placeholder="搜索产品名称" allowClear style={{ width: 220 }} /><Select allowClear placeholder="采集来源" value={source} onChange={setSource} style={{ width: 140 }} options={[{ value: 'OZON', label: 'Ozon' }, { value: '1688', label: '1688' }, { value: 'TAOBAO', label: '淘宝' }, { value: 'TMALL', label: '天猫' }]} /><Button type="primary" onClick={() => void load()}>查询</Button></Space>
<Progress className="collection-toolbar-usage" percent={meta.collection_limit ? Math.round(meta.active_count / meta.collection_limit * 100) : 0} format={() => `${meta.active_count} / ${meta.collection_limit}`} />
</div>
<Table rowKey="id" loading={loading} columns={columns} dataSource={items} pagination={false} rowSelection={{ selectedRowKeys: selected, onChange: (keys) => setSelected(keys.map(String)) }} locale={{ emptyText: '暂无采集商品,点击右上角手动创建商品' }} /> <Table rowKey="id" loading={loading} columns={columns} dataSource={items} pagination={false} rowSelection={{ selectedRowKeys: selected, onChange: (keys) => setSelected(keys.map(String)) }} locale={{ emptyText: '暂无采集商品,点击右上角手动创建商品' }} />
<Modal title="手动创建商品" open={createOpen} footer={null} onCancel={() => setCreateOpen(false)} destroyOnHidden><Form form={form} layout="vertical" onFinish={create}><Form.Item name="title" label="商品名称"><Input maxLength={300} placeholder="可稍后在商品编辑中完善" /></Form.Item><Form.Item name="note" label="备注"><Input.TextArea maxLength={2000} /></Form.Item><Space><Button type="primary" htmlType="submit">创建草稿</Button><Button onClick={() => setCreateOpen(false)}>取消</Button></Space></Form></Modal> <Modal title="手动创建商品" open={createOpen} footer={null} onCancel={() => setCreateOpen(false)} destroyOnHidden><Form form={form} layout="vertical" onFinish={create}><Form.Item name="title" label="商品名称"><Input maxLength={300} placeholder="可稍后在商品编辑中完善" /></Form.Item><Form.Item name="note" label="备注"><Input.TextArea maxLength={2000} /></Form.Item><Space><Button type="primary" htmlType="submit">创建草稿</Button><Button onClick={() => setCreateOpen(false)}>取消</Button></Space></Form></Modal>
</Card>; </Card>;
@@ -0,0 +1,123 @@
.online-products-page {
display: flex;
flex-direction: column;
gap: 18px;
}
.online-products-heading {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
}
.online-products-heading h3 {
margin: 0 0 4px !important;
}
.online-products-store-select {
min-width: 190px;
max-width: 260px;
}
.online-products-sync-status {
background: #fff;
}
.online-products-filters {
display: flex;
flex-wrap: wrap;
gap: 9px;
}
.online-products-search {
width: min(320px, 100%);
}
.online-products-filter {
min-width: 145px;
}
.online-products-list {
overflow: hidden;
background: #fff;
border: 1px solid var(--border-color);
border-radius: 8px;
}
.online-products-list .ant-table-wrapper {
overflow: hidden;
}
.online-products-list-footer {
display: flex;
align-items: center;
justify-content: space-between;
min-height: 53px;
padding: 8px 16px;
border-top: 1px solid var(--border-color);
}
.online-product-name-cell {
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
}
.online-product-image {
display: flex;
flex: 0 0 44px;
align-items: center;
justify-content: center;
width: 44px;
height: 44px;
overflow: hidden;
border-radius: 6px;
background: #f3f4f6;
color: var(--text-secondary);
font-size: 11px;
}
.online-product-image img {
object-fit: cover;
}
.online-product-name-text,
.online-product-ids {
display: flex;
flex-direction: column;
min-width: 0;
}
.online-product-name-text .ant-typography {
display: block;
max-width: 200px;
}
.online-product-name-text .ant-typography + .ant-typography,
.online-product-ids .ant-typography + .ant-typography {
font-size: 12px;
}
.online-product-detail {
width: 100%;
}
.online-product-detail-heading {
display: flex;
align-items: center;
gap: 14px;
}
.online-product-detail-heading h5 {
margin: 0 0 4px !important;
}
.online-product-detail-empty-image {
display: grid;
place-items: center;
flex: 0 0 64px;
height: 64px;
border-radius: 6px;
background: #f3f4f6;
color: var(--text-secondary);
}
@media (max-width: 760px) {
.online-products-heading {
flex-direction: column;
}
.online-products-heading > .ant-space {
width: 100%;
}
.online-products-store-select {
flex: 1;
}
.online-products-filters > * {
flex: 1 1 145px;
}
.online-products-search {
flex-basis: 100%;
width: 100%;
}
}
@@ -1,11 +1,426 @@
import PagePlaceholder from '@/components/PagePlaceholder'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { App as AntdApp, Alert, Button, Card, Select, Space, Typography } from 'antd';
import { ReloadOutlined } from '@ant-design/icons';
import type {
OnlineProduct,
OnlineProductSyncRun,
OnlineProductsMeta,
Store,
} from '@atlas/contracts';
import { apiErrorCode, apiErrorMessage } from '@/services/api';
import { onlineProductsApi, type OnlineProductFilters } from '@/services/onlineProducts';
import { usePlatform } from '@/services/platform';
import { storesApi } from '@/services/stores';
import { OnlineProductDetailDrawer } from './components/OnlineProductDetailDrawer';
import { OnlineProductFilters as Filters } from './components/OnlineProductFilters';
import { OnlineProductTable } from './components/OnlineProductTable';
import { OnlineSyncStatus } from './components/OnlineSyncStatus';
import './OnlineProductsPage.css';
/** 在线商品:按 SKU 查看平台已上架商品与同步状态(业务模块尚未实现) */ const initialFilters: OnlineProductFilters = { q: '', visibility: 'all' };
const OnlineProductsPage = () => ( const isActive = (run: OnlineProductSyncRun | null) =>
<PagePlaceholder run?.status === 'queued' || run?.status === 'running';
title="在线商品"
description="按 SKU 查看平台已上架商品与同步状态,模块开发中。" export default function OnlineProductsPage() {
/> const { message } = AntdApp.useApp();
const navigate = useNavigate();
const {
currentPlatform,
context,
contextLoading,
error: platformError,
reload: reloadPlatforms,
} = usePlatform();
const platformId = currentPlatform?.id ?? null;
const [stores, setStores] = useState<Store[]>([]);
const [storesLoadedPlatformId, setStoresLoadedPlatformId] = useState<string | null>(null);
const [storesLoading, setStoresLoading] = useState(false);
const [storesError, setStoresError] = useState<string | null>(null);
const [storesRetry, setStoresRetry] = useState(0);
const [selectedStore, setSelectedStore] = useState<{ platformId: string; id: string } | null>(null);
const [filters, setFilters] = useState<OnlineProductFilters>(initialFilters);
const [items, setItems] = useState<OnlineProduct[]>([]);
const [meta, setMeta] = useState<OnlineProductsMeta | null>(null);
const [loadedScope, setLoadedScope] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [loadingMore, setLoadingMore] = useState(false);
const [listError, setListError] = useState<string | null>(null);
const [refreshVersion, setRefreshVersion] = useState(0);
const [latestRun, setLatestRun] = useState<OnlineProductSyncRun | null>(null);
const [runScope, setRunScope] = useState<string | null>(null);
const [history, setHistory] = useState<OnlineProductSyncRun[]>([]);
const [historyCursor, setHistoryCursor] = useState<string | null>(null);
const [historyOpen, setHistoryOpen] = useState(false);
const [historyLoading, setHistoryLoading] = useState(false);
const [historyError, setHistoryError] = useState<string | null>(null);
const [syncStarting, setSyncStarting] = useState(false);
const [detailOpen, setDetailOpen] = useState(false);
const [detail, setDetail] = useState<OnlineProduct | null>(null);
const [detailScope, setDetailScope] = useState<string | null>(null);
const [detailLoading, setDetailLoading] = useState(false);
const [detailError, setDetailError] = useState<string | null>(null);
const listRequestRef = useRef(0);
const detailRequestRef = useRef(0);
const scopeRef = useRef<string | null>(null);
const contextStoreId =
context?.platform.id === platformId ? (context.default_store?.id ?? null) : null;
const scopedStores = useMemo(() => stores.filter((store) => store.platform_id === platformId), [stores, platformId]);
const storeId =
(selectedStore?.platformId === platformId ? selectedStore.id : null) ??
contextStoreId ?? scopedStores.find((store) => store.is_default)?.id ?? null;
const store = useMemo(
() => scopedStores.find((item) => item.id === storeId) ?? null,
[scopedStores, storeId]
);
const scopeKey = platformId && storeId ? `${platformId}:${storeId}` : null;
scopeRef.current = scopeKey;
const visibleItems = loadedScope === scopeKey ? items : [];
const visibleMeta = loadedScope === scopeKey ? meta : null;
const visibleListError = loadedScope === scopeKey ? listError : null;
const visibleRun = runScope === scopeKey ? latestRun : null;
const visibleHistory = runScope === scopeKey ? history : [];
const visibleHistoryError = runScope === scopeKey ? historyError : null;
const visibleHistoryCursor = runScope === scopeKey ? historyCursor : null;
const canSync =
currentPlatform?.code.toUpperCase() === 'OZON' &&
store?.status === 'active' &&
store.credential_status === 'valid';
useEffect(() => {
setSelectedStore(null);
setStores([]);
setStoresLoadedPlatformId(null);
setFilters(initialFilters);
setItems([]);
setMeta(null);
setLatestRun(null);
setHistory([]);
setHistoryError(null);
setHistoryOpen(false);
detailRequestRef.current += 1;
setDetailOpen(false);
if (!platformId) return;
let cancelled = false;
setStoresLoading(true);
setStoresError(null);
void (async () => {
try {
const all: Store[] = [];
let cursor: string | null = null;
do {
const result = await storesApi.list(platformId, cursor ?? undefined);
all.push(...result.data);
cursor = result.meta.next_cursor;
} while (cursor);
if (!cancelled) setStores(all);
} catch (error) {
if (!cancelled) setStoresError(apiErrorMessage(error));
} finally {
if (!cancelled) { setStoresLoading(false); setStoresLoadedPlatformId(platformId); }
}
})();
return () => {
cancelled = true;
};
}, [platformId, storesRetry]);
useEffect(() => {
const requestId = ++listRequestRef.current;
setItems([]);
setMeta(null);
setLoadedScope(null);
setRunScope(null);
setLoadingMore(false);
setListError(null);
setLatestRun(null);
setHistoryError(null);
setHistoryLoading(false);
if (!platformId || !storeId) {
setLoading(false);
return;
}
setLoading(true);
void Promise.allSettled([
onlineProductsApi.list(platformId, storeId, filters),
onlineProductsApi.listSyncRuns(platformId, storeId),
]).then(([listResult, runsResult]) => {
if (requestId !== listRequestRef.current) return;
if (listResult.status === 'fulfilled') {
setItems(listResult.value.data);
setMeta(listResult.value.meta);
} else setListError(apiErrorMessage(listResult.reason));
setLoadedScope(`${platformId}:${storeId}`);
if (runsResult.status === 'fulfilled') {
setLatestRun(runsResult.value.data[0] ?? null);
setHistory(runsResult.value.data);
setHistoryCursor(runsResult.value.meta.next_cursor);
} else setHistoryError(apiErrorMessage(runsResult.reason));
setRunScope(`${platformId}:${storeId}`);
setLoading(false);
});
}, [platformId, storeId, filters, refreshVersion]);
useEffect(() => {
if (!platformId || !storeId || !isActive(visibleRun)) return;
const runId = visibleRun!.id;
let cancelled = false;
const timer = window.setInterval(() => {
void onlineProductsApi
.getSyncRun(platformId, runId)
.then((run) => {
if (cancelled) return;
setLatestRun(run);
setHistory((previous) =>
previous.map((item) => (item.id === run.id ? run : item))
);
if (!isActive(run)) setRefreshVersion((value) => value + 1);
})
.catch(() => {});
}, 2500);
return () => {
cancelled = true;
window.clearInterval(timer);
};
}, [platformId, storeId, visibleRun?.id, visibleRun?.status]);
const loadMore = async () => {
if (!platformId || !storeId || !visibleMeta?.next_cursor || loadingMore) return;
const requestId = listRequestRef.current;
setLoadingMore(true);
try {
const result = await onlineProductsApi.list(
platformId,
storeId,
filters,
visibleMeta.next_cursor
);
if (requestId !== listRequestRef.current) return;
setItems((previous) => [...previous, ...result.data]);
setMeta(result.meta);
} catch (error) {
message.error(apiErrorMessage(error));
} finally {
if (requestId === listRequestRef.current) setLoadingMore(false);
}
};
const startSync = async () => {
if (!platformId || !storeId || !scopeKey || syncStarting || !canSync) return;
const requestScope = scopeKey;
setSyncStarting(true);
try {
const result = await onlineProductsApi.createSync(platformId, storeId);
if (scopeRef.current !== requestScope) return;
setRunScope(scopeKey);
setLatestRun(result.sync_run);
setHistory((previous) => [
result.sync_run,
...previous.filter((run) => run.id !== result.sync_run.id),
]);
message.success('已开始同步 Ozon 商品');
} catch (error) {
if (scopeRef.current !== requestScope) return;
if (apiErrorCode(error) === 'SYNC_ALREADY_RUNNING') {
setRefreshVersion((value) => value + 1);
message.info('该店铺已有正在进行的商品同步');
} else message.error(apiErrorMessage(error));
} finally {
setSyncStarting(false);
}
};
const openDetail = useCallback(
async (item: OnlineProduct) => {
if (!platformId || !scopeKey) return;
const requestId = ++detailRequestRef.current;
setDetailOpen(true);
setDetail(item);
setDetailScope(scopeKey);
setDetailError(null);
setDetailLoading(true);
try {
const result = await onlineProductsApi.get(platformId, item.id);
if (requestId === detailRequestRef.current && scopeRef.current === scopeKey) setDetail(result);
} catch (error) {
if (requestId === detailRequestRef.current && scopeRef.current === scopeKey) setDetailError(apiErrorMessage(error));
} finally {
if (requestId === detailRequestRef.current && scopeRef.current === scopeKey) setDetailLoading(false);
}
},
[platformId, scopeKey]
); );
export default OnlineProductsPage; const loadMoreHistory = async () => {
if (!platformId || !storeId || !scopeKey || !visibleHistoryCursor || historyLoading) return;
const requestScope = scopeKey;
setHistoryLoading(true);
setHistoryError(null);
try {
const result = await onlineProductsApi.listSyncRuns(platformId, storeId, visibleHistoryCursor);
if (scopeRef.current !== requestScope) return;
setHistory((previous) => [...previous, ...result.data]);
setHistoryCursor(result.meta.next_cursor);
} catch (error) {
if (scopeRef.current === requestScope) setHistoryError(apiErrorMessage(error));
} finally {
if (scopeRef.current === requestScope) setHistoryLoading(false);
}
};
if (platformError)
return (
<Alert
type="error"
showIcon
message="平台目录加载失败"
description={platformError}
action={<Button onClick={reloadPlatforms}>重试</Button>}
/>
);
return (
<div className="online-products-page">
<div className="online-products-heading">
<div>
<Typography.Title level={3}>在线商品</Typography.Title>
<Typography.Text type="secondary">
查看店铺在 {currentPlatform?.name ?? '当前平台'}{' '}
的实际商品状态。每行对应一个 SKU。
</Typography.Text>
</div>
<Space wrap>
<Select
className="online-products-store-select"
aria-label="选择店铺"
placeholder="选择店铺"
value={storeId}
loading={storesLoading || contextLoading}
onChange={(value) => {
if (platformId) setSelectedStore({ platformId, id: value });
detailRequestRef.current += 1;
setDetailOpen(false);
setHistoryOpen(false);
}}
options={scopedStores.map((store) => ({
value: store.id,
label: `${store.name}${store.is_default ? '(默认)' : ''}`,
}))}
disabled={scopedStores.length === 0}
/>
<Button
type="primary"
icon={<ReloadOutlined />}
onClick={() => void startSync()}
loading={syncStarting}
disabled={!canSync || isActive(visibleRun)}
>
同步 Ozon 商品
</Button>
</Space>
</div>
{storesLoadedPlatformId === platformId && storesError && (
<Alert
type="error"
showIcon
message="店铺加载失败"
description={storesError}
action={
<Button onClick={() => setStoresRetry((value) => value + 1)}>重试</Button>
}
/>
)}
{!storesLoading &&
!contextLoading &&
!!platformId &&
storesLoadedPlatformId === platformId &&
!storesError &&
scopedStores.length === 0 ? (
<Card>
<Alert
type="info"
showIcon
message="还没有店铺"
description="先在店铺管理中连接 Ozon 店铺,才能同步在线商品。"
action={
<Button type="primary" onClick={() => navigate('/shops')}>
前往店铺管理
</Button>
}
/>
</Card>
) : (
<>
{store && !canSync && (
<Alert
type="warning"
showIcon
message="当前店铺暂不可同步"
description="请在店铺管理中检查店铺状态与 Seller API 凭据。"
/>
)}
{storeId && (
<>
<OnlineSyncStatus
meta={visibleMeta}
latestRun={visibleRun}
history={visibleHistory}
historyOpen={historyOpen}
historyLoading={historyLoading}
historyError={visibleHistoryError}
historyCursor={visibleHistoryCursor}
onOpenHistory={() => setHistoryOpen(true)}
onCloseHistory={() => setHistoryOpen(false)}
onMoreHistory={() => void loadMoreHistory()}
onRetryHistory={() => setRefreshVersion((value) => value + 1)}
/>
<Filters
filters={filters}
onChange={setFilters}
disabled={loading && visibleItems.length === 0}
/>
{visibleListError && (
<Alert
type="error"
showIcon
message="在线商品加载失败"
description={visibleListError}
action={
<Button
onClick={() => setRefreshVersion((value) => value + 1)}
>
重试
</Button>
}
/>
)}
<OnlineProductTable
items={visibleItems}
loading={loading}
nextCursor={visibleMeta?.next_cursor ?? null}
loadingMore={loadingMore}
onMore={() => void loadMore()}
onDetail={(item) => void openDetail(item)}
emptyText={
visibleMeta?.last_successful_sync_at
? '该店铺暂无符合条件的商品'
: '尚未同步商品,点击右上角“同步 Ozon 商品”'
}
/>
</>
)}
</>
)}
<OnlineProductDetailDrawer
open={detailOpen && detailScope === scopeKey}
item={detail}
loading={detailLoading}
error={detailError}
onClose={() => {
detailRequestRef.current += 1;
setDetailOpen(false);
}}
/>
</div>
);
}
@@ -0,0 +1,121 @@
import { Alert, Descriptions, Drawer, Image, Space, Spin, Tag, Typography } from 'antd';
import type { OnlineProduct } from '@atlas/contracts';
import { formatOnlineTime, moderationView, visibilityView } from './OnlineProductTable';
type Props = {
open: boolean;
item: OnlineProduct | null;
loading: boolean;
error: string | null;
onClose: () => void;
};
function visibilityReason(item: OnlineProduct): string {
if (item.visible === null) return '平台尚未返回可见性';
if (item.visible) return '商品当前可见';
const details = item.visibility_details;
const reasons = [
details.has_price === false ? '缺少价格' : null,
details.has_stock === false ? '暂无库存' : null,
details.active_product === false ? '商品未激活' : null,
].filter(Boolean);
return reasons.length ? reasons.join('、') : '平台当前不可见,请结合审核与错误提示查看';
}
export function OnlineProductDetailDrawer({ open, item, loading, error, onClose }: Props) {
return (
<Drawer title="商品详情" open={open} onClose={onClose} width={440} destroyOnHidden>
{loading && <Spin />}
{error && <Alert type="error" showIcon message={error} />}
{item && (
<Space direction="vertical" size="large" className="online-product-detail">
<div className="online-product-detail-heading">
{item.image?.url ? (
<Image
src={item.image.url}
alt={item.name ?? '商品图片'}
width={64}
height={64}
preview={false}
/>
) : (
<div className="online-product-detail-empty-image">无图</div>
)}
<div>
<Typography.Title level={5}>
{item.name || '未命名商品'}
</Typography.Title>
<Typography.Text type="secondary">{item.offer_id}</Typography.Text>
</div>
</div>
<Descriptions title="平台状态" column={1} size="small">
<Descriptions.Item label="可见性">
{visibilityView(item.visible)}
</Descriptions.Item>
<Descriptions.Item label="可见性说明">
{visibilityReason(item)}
</Descriptions.Item>
<Descriptions.Item label="库存合计">
{item.stock_summary.has_data ? item.stock_summary.total : '未知'}
</Descriptions.Item>
<Descriptions.Item label="审核状态">
{moderationView(item.moderation_status)}
</Descriptions.Item>
<Descriptions.Item label="售价">
{item.selling_price
? `${item.currency_code ?? ''} ${item.selling_price}`
: '—'}
</Descriptions.Item>
<Descriptions.Item label="平台状态">
{item.remote_state || '—'}
</Descriptions.Item>
<Descriptions.Item label="校验状态">
{item.validation_status || '—'}
</Descriptions.Item>
</Descriptions>
{item.errors.length > 0 && (
<Alert
type="warning"
showIcon
message="平台提示"
description={item.errors.map((error, index) => (
<div key={index}>{error.message}</div>
))}
/>
)}
<Descriptions title="商品标识" column={1} size="small">
<Descriptions.Item label="Offer ID">{item.offer_id}</Descriptions.Item>
<Descriptions.Item label="Ozon 商品 ID">
{item.remote_product_id}
</Descriptions.Item>
<Descriptions.Item label="Ozon SKU">
{item.remote_sku || '—'}
</Descriptions.Item>
<Descriptions.Item label="商品来源">
<Tag>
{item.source === 'atlas_published' ? 'Atlas 发布' : '外部同步'}
</Tag>
</Descriptions.Item>
<Descriptions.Item label="Atlas 来源">
{item.atlas_origin ? (
<a href={`/products/${item.atlas_origin.product_id}/editor`}>
查看采集箱商品
</a>
) : (
'无关联'
)}
</Descriptions.Item>
<Descriptions.Item label="最近同步">
{formatOnlineTime(item.last_synced_at)}
</Descriptions.Item>
{item.missing_since && (
<Descriptions.Item label="首次未见">
{formatOnlineTime(item.missing_since)}
</Descriptions.Item>
)}
</Descriptions>
</Space>
)}
</Drawer>
);
}
@@ -0,0 +1,70 @@
import { Button, Input, Select } from 'antd';
import { useEffect, useState } from 'react';
import type { OnlineProductFilters as Filters } from '@/services/onlineProducts';
type Props = { filters: Filters; onChange: (next: Filters) => void; disabled?: boolean };
export function OnlineProductFilters({ filters, onChange, disabled }: Props) {
const [search, setSearch] = useState(filters.q);
useEffect(() => setSearch(filters.q), [filters.q]);
return (
<div className="online-products-filters">
<Input.Search
value={search}
onChange={(event) => {
setSearch(event.target.value);
if (!event.target.value && filters.q) onChange({ ...filters, q: '' });
}}
onSearch={(value) => onChange({ ...filters, q: value.trim() })}
placeholder="搜索名称、Offer ID、商品 ID、SKU"
aria-label="搜索在线商品"
allowClear
disabled={disabled}
className="online-products-search"
/>
<Select
value={filters.visibility}
onChange={(visibility) => onChange({ ...filters, visibility })}
options={[
{ value: 'all', label: '全部可见性' },
{ value: 'visible', label: '可见' },
{ value: 'invisible', label: '不可见' },
{ value: 'unknown', label: '未知' },
]}
aria-label="筛选可见性"
disabled={disabled}
className="online-products-filter"
/>
<Select
value={filters.source}
onChange={(source) => onChange({ ...filters, source })}
allowClear
placeholder="全部来源"
options={[
{ value: 'atlas_published', label: 'Atlas 发布' },
{ value: 'external_sync', label: '外部同步' },
]}
aria-label="筛选来源"
disabled={disabled}
className="online-products-filter"
/>
<Select
value={filters.moderation_status}
onChange={(moderation_status) => onChange({ ...filters, moderation_status })}
allowClear
placeholder="全部审核"
options={[
{ value: 'approved', label: '已通过' },
{ value: 'pending', label: '待审核' },
{ value: 'declined', label: '未通过' },
]}
aria-label="筛选审核状态"
disabled={disabled}
className="online-products-filter"
/>
<Button onClick={() => onChange({ q: '', visibility: 'all' })} disabled={disabled}>
重置
</Button>
</div>
);
}
@@ -0,0 +1,145 @@
import { Button, Empty, Image, Table, Tag, Typography } from 'antd';
import type { TableProps } from 'antd';
import type { OnlineProduct } from '@atlas/contracts';
type Props = {
items: OnlineProduct[];
loading: boolean;
nextCursor: string | null;
loadingMore: boolean;
onMore: () => void;
onDetail: (item: OnlineProduct) => void;
emptyText: string;
};
export function visibilityView(value: boolean | null) {
if (value === true) return <Tag color="green">可见</Tag>;
if (value === false) return <Tag color="red">不可见</Tag>;
return <Tag>未知</Tag>;
}
export function moderationView(value: string | null) {
if (value === 'approved') return <Tag color="green">已通过</Tag>;
if (value === 'declined') return <Tag color="red">未通过</Tag>;
if (value === 'pending') return <Tag color="gold">待审核</Tag>;
return <Tag>{value || '未知'}</Tag>;
}
export function formatOnlineTime(value: string | null) {
return value ? new Date(value).toLocaleString('zh-CN', { hour12: false }) : '—';
}
export function OnlineProductTable({
items,
loading,
nextCursor,
loadingMore,
onMore,
onDetail,
emptyText,
}: Props) {
const columns: TableProps<OnlineProduct>['columns'] = [
{
title: '商品',
key: 'product',
width: 270,
render: (_, item) => (
<div className="online-product-name-cell">
<div className="online-product-image">
{item.image?.url ? (
<Image
src={item.image.url}
alt={item.name ?? '商品图片'}
width={44}
height={44}
preview={false}
/>
) : (
<span>无图</span>
)}
</div>
<div className="online-product-name-text">
<Typography.Text strong ellipsis title={item.name ?? '未命名商品'}>
{item.name || '未命名商品'}
</Typography.Text>
<Typography.Text type="secondary">
SKU {item.remote_sku || '—'}
{item.missing_since ? ' · 最近同步未见' : ''}
</Typography.Text>
</div>
</div>
),
},
{
title: 'Offer ID / 商品 ID',
key: 'ids',
width: 200,
render: (_, item) => (
<div className="online-product-ids">
<Typography.Text copyable={{ text: item.offer_id }}>
{item.offer_id}
</Typography.Text>
<Typography.Text type="secondary">ID {item.remote_product_id}</Typography.Text>
</div>
),
},
{ title: '可见性', dataIndex: 'visible', width: 95, render: visibilityView },
{
title: '库存',
dataIndex: 'stock_summary',
width: 80,
render: (stock: OnlineProduct['stock_summary']) =>
stock?.has_data ? stock.total : '—',
},
{ title: '审核', dataIndex: 'moderation_status', width: 105, render: moderationView },
{
title: '售价',
key: 'price',
width: 105,
render: (_, item) =>
item.selling_price
? `${item.currency_code === 'RUB' ? '₽ ' : ''}${item.selling_price}`
: '—',
},
{
title: '来源',
dataIndex: 'source',
width: 100,
render: (source: OnlineProduct['source']) =>
source === 'atlas_published' ? 'Atlas 发布' : '外部同步',
},
{ title: '最近同步', dataIndex: 'last_synced_at', width: 170, render: formatOnlineTime },
{
title: '操作',
key: 'action',
width: 75,
fixed: 'right',
render: (_, item) => (
<Button type="link" size="small" onClick={() => onDetail(item)}>
详情
</Button>
),
},
];
return (
<div className="online-products-list">
<Table
rowKey="id"
columns={columns}
dataSource={items}
loading={loading}
pagination={false}
scroll={{ x: 1220 }}
locale={{ emptyText: <Empty description={emptyText} /> }}
/>
<div className="online-products-list-footer">
<Typography.Text type="secondary">已显示 {items.length} 条商品</Typography.Text>
{nextCursor && (
<Button loading={loadingMore} onClick={onMore}>
加载更多
</Button>
)}
</div>
</div>
);
}
@@ -0,0 +1,146 @@
import { Alert, Button, Drawer, Empty, List, Space, Tag, Typography } from 'antd';
import type { OnlineProductSyncRun, OnlineProductsMeta } from '@atlas/contracts';
import { formatOnlineTime } from './OnlineProductTable';
type Props = {
meta: OnlineProductsMeta | null;
latestRun: OnlineProductSyncRun | null;
history: OnlineProductSyncRun[];
historyOpen: boolean;
historyLoading: boolean;
historyError: string | null;
historyCursor: string | null;
onOpenHistory: () => void;
onCloseHistory: () => void;
onMoreHistory: () => void;
onRetryHistory: () => void;
};
const runLabel: Record<string, string> = {
queued: '等待同步',
running: '正在同步',
succeeded: '同步成功',
failed: '同步失败',
cancelled: '已取消',
};
export function OnlineSyncStatus({
meta,
latestRun,
history,
historyOpen,
historyLoading,
historyError,
historyCursor,
onOpenHistory,
onCloseHistory,
onMoreHistory,
onRetryHistory,
}: Props) {
const status = latestRun?.status ?? meta?.last_sync_status;
const alertType =
status === 'failed'
? 'warning'
: status === 'queued' || status === 'running'
? 'info'
: 'success';
const description =
status === 'failed'
? `最近同步失败,已保存的商品继续显示。上次完整成功:${formatOnlineTime(meta?.last_successful_sync_at ?? null)}`
: status === 'running' || status === 'queued'
? `同步中,当前列表展示已保存的商品镜像。上次完整成功:${formatOnlineTime(meta?.last_successful_sync_at ?? null)}`
: meta?.last_successful_sync_at
? `上次成功:${formatOnlineTime(meta.last_successful_sync_at)} · 当前展示同步镜像`
: '尚未同步过该店铺的 Ozon 商品';
return (
<>
<Alert
className="online-products-sync-status"
type={alertType}
showIcon
message={
<Space wrap>
<Typography.Text strong>商品同步</Typography.Text>
<Tag
color={
status === 'failed'
? 'orange'
: status === 'succeeded'
? 'green'
: 'blue'
}
>
{status ? (runLabel[status] ?? status) : '尚未同步'}
</Tag>
{latestRun && (status === 'running' || status === 'queued') && (
<Typography.Text type="secondary">
已读取 {latestRun.listed_count} · 已保存 {latestRun.upserted_count}
</Typography.Text>
)}
</Space>
}
description={description}
action={
<Button type="link" onClick={onOpenHistory}>
同步记录
</Button>
}
/>
<Drawer title="同步记录" open={historyOpen} onClose={onCloseHistory} width={440}>
{historyError && (
<Alert
type="error"
showIcon
message="同步记录加载失败"
description={historyError}
action={<Button onClick={onRetryHistory}>重试</Button>}
/>
)}
<List
loading={historyLoading}
dataSource={history}
locale={{ emptyText: <Empty description="暂无同步记录" /> }}
renderItem={(run) => (
<List.Item>
<List.Item.Meta
title={
<Space>
<span>{formatOnlineTime(run.created_at)}</span>
<Tag
color={
run.status === 'succeeded'
? 'green'
: run.status === 'failed'
? 'red'
: 'blue'
}
>
{runLabel[run.status] ?? run.status}
</Tag>
</Space>
}
description={
<>
<div>
已读取 {run.listed_count},已保存 {run.upserted_count}
</div>
{run.error && (
<Typography.Text type="danger">
{run.error.message}
</Typography.Text>
)}
</>
}
/>
</List.Item>
)}
/>
{historyCursor && (
<Button block loading={historyLoading} onClick={onMoreHistory}>
加载更多
</Button>
)}
</Drawer>
</>
);
}
@@ -219,6 +219,7 @@
left: 220px; left: 220px;
z-index: 50; z-index: 50;
display: flex; display: flex;
flex-wrap: wrap;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
gap: 16px; gap: 16px;
@@ -237,6 +238,7 @@
} }
.footer-ai-actions { .footer-ai-actions {
display: flex; display: flex;
min-width: 0;
} }
/* /*
@@ -297,6 +299,9 @@
} }
} }
@media (max-width: 560px) { @media (max-width: 560px) {
.product-editor {
padding-bottom: 230px;
}
.editor-section .ant-card-body { .editor-section .ant-card-body {
padding: 20px 16px; padding: 20px 16px;
} }
@@ -35,6 +35,7 @@ import { ProductAttributeSection } from './components/ProductAttributeSection';
import { ProductMainInfoSection } from './components/ProductMainInfoSection'; import { ProductMainInfoSection } from './components/ProductMainInfoSection';
import { ProductOtherSection } from './components/ProductOtherSection'; import { ProductOtherSection } from './components/ProductOtherSection';
import { ProductVariantSection } from './components/ProductVariantSection'; import { ProductVariantSection } from './components/ProductVariantSection';
import { ProductImageTaskCenter } from './components/ProductImageTaskCenter';
import { import {
ProductCopyComparisonModal, ProductCopyComparisonModal,
SkuCopyAiModal, SkuCopyAiModal,
@@ -1061,8 +1062,7 @@ const ProductEditPage = () => {
const showFutureFeature = (name: string) => const showFutureFeature = (name: string) =>
modal.info({ modal.info({
title: `${name}将在下一阶段接入`, title: `${name}将在下一阶段接入`,
content: content: '单张 AI 编辑已可在 SKU 图片选择中使用。AI 套图生成仍在开发。',
'当前已支持 SKU 图片上传、网络导入和采集图转存。AI 生图与图片编辑将在后续接入。',
okText: '知道了', okText: '知道了',
}); });
const validSupplyUrl = const validSupplyUrl =
@@ -1478,10 +1478,11 @@ const ProductEditPage = () => {
</Button> </Button>
<Button <Button
icon={<FileTextOutlined />} icon={<FileTextOutlined />}
onClick={() => showFutureFeature('AI 生图')} onClick={() => showFutureFeature('AI 套图生成')}
> >
AI 生图 AI 套图生成
</Button> </Button>
<ProductImageTaskCenter productId={snapshot.product.id} />
</Space> </Space>
<Space className="footer-primary-actions"> <Space className="footer-primary-actions">
<Button onClick={leave}>取消并关闭</Button> <Button onClick={leave}>取消并关闭</Button>
@@ -186,6 +186,10 @@
grid-template-columns: repeat(3, minmax(0, 1fr)); grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 6px; gap: 6px;
} }
.collected-image-card {
position: relative;
min-width: 0;
}
.collected-image-grid .ant-image { .collected-image-grid .ant-image {
display: block; display: block;
overflow: hidden; overflow: hidden;
@@ -197,7 +201,18 @@
display: block; display: block;
width: 100%; width: 100%;
aspect-ratio: 3 / 4; aspect-ratio: 3 / 4;
object-fit: cover; object-fit: contain;
}
.collected-image-edit.ant-btn {
position: absolute;
right: 5px;
bottom: 5px;
z-index: 2;
height: 25px;
padding: 0 6px;
background: rgba(255, 255, 255, 0.94);
font-size: 11px;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.15);
} }
@container (max-width: 1099px) { @container (max-width: 1099px) {
/* 主内容区不足 1360px 时,面板退化为工作区下方整行,不再固定高度。 */ /* 主内容区不足 1360px 时,面板退化为工作区下方整行,不再固定高度。 */
@@ -1,22 +1,35 @@
import { useEffect, useState } from 'react'; import { useCallback, useEffect, useRef, useState } from 'react';
import { import {
Alert, Alert,
App as AntdApp,
Breadcrumb, Breadcrumb,
Button, Button,
Card, Card,
Collapse, Collapse,
Dropdown,
Image, Image,
Input, Input,
Space, Space,
Tag, Tag,
Typography, Typography,
} from 'antd'; } from 'antd';
import type { MenuProps } from 'antd';
import { DownOutlined, LoadingOutlined } from '@ant-design/icons';
import type { import type {
CollectionItemInspection, CollectionItemInspection,
ProductAiSourceField, ProductAiSourceField,
ProductAiSourceMaterial, ProductAiSourceMaterial,
ProductEditorSnapshot, ProductEditorSnapshot,
ProductImageDerivativeRecord,
WatermarkTemplate,
} from '@atlas/contracts'; } from '@atlas/contracts';
import { apiErrorMessage } from '@/services/api';
import { createThreeFourImage, createWatermarkedImage, downloadMediaAsset, mediaApi } from '@/services/media';
import { watermarksApi } from '@/services/watermarks';
import { resolveSkuImageSelection } from './skuImageSelection';
import type { SkuEditorImage } from './SkuImageCard';
import { SkuSingleAiEditDrawer } from './SkuSingleAiEditDrawer';
import { SkuWatermarkPickerModal } from './SkuWatermarkPickerModal';
import './CollectedProductInfoPanel.css'; import './CollectedProductInfoPanel.css';
export type CollectedProductSummary = { export type CollectedProductSummary = {
@@ -48,7 +61,7 @@ type Props = {
/** /**
* 右侧「采集的商品信息」面板:展示并可编辑采集来源的标题与描述, * 右侧「采集的商品信息」面板:展示并可编辑采集来源的标题与描述,
* 以及价格、尺寸、重量摘要、原始规格参数,和按 主图 / SKU 图 / 详情图 * 以及价格、尺寸、重量摘要、原始规格参数,和按 主图 / SKU 图 / 详情图
* 分组折叠展示的采集图片(只读,点击可放大预览)。 * 分组折叠展示的采集图片及其商品级处理结果。
* *
* 用户改过的字段记入 overriddenFields(显示"恢复来源值"入口); * 用户改过的字段记入 overriddenFields(显示"恢复来源值"入口);
* 编辑、恢复、采用最新等动作均通过回调交由页面处理。 * 编辑、恢复、采用最新等动作均通过回调交由页面处理。
@@ -66,6 +79,28 @@ export function CollectedProductInfoPanel({
onShowDifferences, onShowDifferences,
onAcceptLatest, onAcceptLatest,
}: Props) { }: Props) {
const { message } = AntdApp.useApp();
const [processedRecords, setProcessedRecords] = useState<ProductImageDerivativeRecord[]>([]);
const [historyError, setHistoryError] = useState(false);
const [resolvedTransfers, setResolvedTransfers] = useState<Record<string, string>>({});
const [busyImageId, setBusyImageId] = useState<string | null>(null);
const [aiTarget, setAiTarget] = useState<SkuEditorImage | null>(null);
const [watermarkTarget, setWatermarkTarget] = useState<SkuEditorImage | null>(null);
const actionLock = useRef(false);
const defaultWatermarkRequest = useRef<Promise<WatermarkTemplate | null> | null>(null);
const refreshProcessed = useCallback(async () => {
setProcessedRecords(await mediaApi.listProductImageDerivatives(snapshot.product.id));
setHistoryError(false);
}, [snapshot.product.id]);
useEffect(() => {
setProcessedRecords([]);
setHistoryError(false);
setResolvedTransfers({});
defaultWatermarkRequest.current = watermarksApi.getDefault().catch(() => null);
void refreshProcessed().catch(() => setHistoryError(true));
const interval = window.setInterval(() => void refreshProcessed().catch(() => setHistoryError(true)), 8 * 60_000);
return () => window.clearInterval(interval);
}, [refreshProcessed]);
const sourceDescription = collected?.description ?? ''; const sourceDescription = collected?.description ?? '';
// 类目自动回填始终基于最新采集快照,不能被 AI 输入素材的用户覆盖影响。 // 类目自动回填始终基于最新采集快照,不能被 AI 输入素材的用户覆盖影响。
const capturedCategories = snapshot.source_material_state.latest.categories; const capturedCategories = snapshot.source_material_state.latest.categories;
@@ -92,6 +127,111 @@ export function CollectedProductInfoPanel({
return { key, label, items }; return { key, label, items };
}) })
.filter((group) => group.items.length > 0); .filter((group) => group.items.length > 0);
/** 派生图保留原素材;按处理链的最新成功结果恢复采集区的显示图。 */
const roots = new Map<string, string>();
const latestByRoot = new Map<string, ProductImageDerivativeRecord>();
for (const record of [...processedRecords].reverse()) {
const root = roots.get(record.source_asset_id) ?? record.source_asset_id;
roots.set(record.output_asset.id, root);
latestByRoot.set(root, record);
}
const imageFor = (item: CollectionItemInspection['asset_transfers'][number], label: string): SkuEditorImage => {
const originalAssetId = item.asset_id ?? resolvedTransfers[item.id];
const latest = originalAssetId ? latestByRoot.get(originalAssetId) : undefined;
return {
id: `collection:${item.id}`,
url: latest?.output_asset.preview_url ?? item.source_url,
label,
origin: latest ? 'derivative' : 'collection',
assetId: latest?.output_asset.id ?? originalAssetId ?? undefined,
transferId: item.id,
};
};
const images = imageGroups.flatMap((group) => group.items.map((item) => imageFor(item, group.label)));
const resolveAsset = async (image: SkuEditorImage) => {
const [assetId] = await resolveSkuImageSelection([image], collectionRecord?.id ?? null);
if (image.transferId && !image.assetId) {
setResolvedTransfers((current) => ({ ...current, [image.transferId!]: assetId }));
}
return assetId;
};
const recordResult = (kind: ProductImageDerivativeRecord['kind'], sourceAssetId: string,
result: { id: string; output_asset: ProductImageDerivativeRecord['output_asset'] | null }) => {
if (!result.output_asset) throw new Error('图片处理结果不可用');
const record: ProductImageDerivativeRecord = {
id: result.id, kind, source_asset_id: sourceAssetId,
created_at: new Date().toISOString(), output_asset: result.output_asset,
};
setProcessedRecords((current) => [record, ...current.filter((item) => item.id !== record.id)]);
void refreshProcessed().catch(() => setHistoryError(true));
};
const processImage = async (image: SkuEditorImage, kind: 'aspect_ratio_3_4' | 'watermark',
template?: WatermarkTemplate) => {
if (actionLock.current) return;
actionLock.current = true;
setBusyImageId(image.id);
try {
const assetId = await resolveAsset(image);
if (kind === 'watermark' && !template) throw new Error('请选择水印模板');
const result = kind === 'watermark'
? await createWatermarkedImage(assetId, template!.id, snapshot.product.id)
: await createThreeFourImage(assetId, snapshot.product.id);
recordResult(kind, assetId, result);
message.success(kind === 'watermark' ? '水印图片已替换当前展示图' : '3:4 图片已替换当前展示图');
} finally {
actionLock.current = false;
setBusyImageId(null);
}
};
const addWatermark = async (image: SkuEditorImage) => {
if (actionLock.current) return;
actionLock.current = true;
setBusyImageId(image.id);
try {
const template = await (defaultWatermarkRequest.current
?? watermarksApi.getDefault().catch(() => null));
if (template) {
actionLock.current = false;
await processImage(image, 'watermark', template);
} else {
setWatermarkTarget(image);
}
} finally {
actionLock.current = false;
setBusyImageId(null);
}
};
const downloadImage = async (image: SkuEditorImage) => {
if (actionLock.current) return;
actionLock.current = true;
setBusyImageId(image.id);
try {
await downloadMediaAsset(await resolveAsset(image));
} catch (error) {
message.error(`${image.label} 下载失败:${apiErrorMessage(error)}`);
} finally {
actionLock.current = false;
setBusyImageId(null);
}
};
const imageMenu = (image: SkuEditorImage): MenuProps => ({
onClick: ({ key }) => {
if (actionLock.current) return;
if (key === 'ai') setAiTarget(image);
else if (key === 'watermark') void addWatermark(image).catch((error) =>
message.error(`添加水印失败:${apiErrorMessage(error)}`));
else if (key === 'three_four') void processImage(image, 'aspect_ratio_3_4').catch((error) =>
message.error(`${image.label} 处理失败:${apiErrorMessage(error)}`));
else if (key === 'download') void downloadImage(image);
},
items: [
{ key: 'ai', label: 'AI 编辑' },
{ key: 'watermark', label: '添加水印' },
{ key: 'three_four', label: '一键 3:4' },
{ key: 'download', label: '下载' },
],
});
return ( return (
<aside className="editor-aside"> <aside className="editor-aside">
<Card className="collected-info-panel" bordered={false}> <Card className="collected-info-panel" bordered={false}>
@@ -239,6 +379,9 @@ export function CollectedProductInfoPanel({
</div> </div>
<div className="collected-field"> <div className="collected-field">
<span>采集图片</span> <span>采集图片</span>
{historyError && <Alert className="collected-image-history-error" type="warning"
message="图片处理记录加载失败,部分处理图可能暂时显示为原图"
action={<Button size="small" onClick={() => void refreshProcessed().catch(() => setHistoryError(true))}>重试</Button>} />}
{imageGroups.length ? ( {imageGroups.length ? (
<Collapse <Collapse
className="collected-image-groups" className="collected-image-groups"
@@ -250,13 +393,19 @@ export function CollectedProductInfoPanel({
label: `${label}(${items.length})`, label: `${label}(${items.length})`,
children: ( children: (
<div className="collected-image-grid"> <div className="collected-image-grid">
{items.map((item) => ( {items.map((item) => {
<Image const image = imageFor(item, label);
key={item.id} return <div className="collected-image-card" key={item.id}>
src={item.source_url} <Image src={image.url} alt={label} />
alt={label} <Dropdown menu={imageMenu(image)} trigger={['click']} placement="bottomRight">
/> <Button className="collected-image-edit" size="small"
))} disabled={!!busyImageId}>
{busyImageId === image.id ? <LoadingOutlined /> : null}
编辑 <DownOutlined />
</Button>
</Dropdown>
</div>;
})}
</div> </div>
), ),
}))} }))}
@@ -266,6 +415,23 @@ export function CollectedProductInfoPanel({
)} )}
</div> </div>
</Card> </Card>
<SkuWatermarkPickerModal
open={!!watermarkTarget}
onCancel={() => setWatermarkTarget(null)}
onApply={async (template) => {
if (watermarkTarget) await processImage(watermarkTarget, 'watermark', template);
}}
/>
<SkuSingleAiEditDrawer
open={!!aiTarget}
productId={snapshot.product.id}
source={aiTarget}
referenceOptions={images}
onClose={() => setAiTarget(null)}
onResolveAsset={resolveAsset}
onGenerated={async () => undefined}
footerHint="生成图将保存到当前商品的 AI 生图记录,可在 SKU 图片选择中使用"
/>
</aside> </aside>
); );
} }
@@ -0,0 +1,83 @@
.product-image-task-trigger {
display: inline-flex;
}
.product-image-task-section + .product-image-task-section {
margin-top: 24px;
}
.product-image-task-section h3 {
margin: 0 0 12px;
font-size: 15px;
}
.product-image-task-card {
display: flex;
flex-direction: column;
gap: 8px;
margin-bottom: 10px;
padding: 14px;
border: 1px solid #e9e5f0;
border-radius: 10px;
background: #fff;
}
.product-image-task-heading,
.product-image-task-actions {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.product-image-task-heading .ant-tag {
margin: 0;
}
.product-image-task-meta {
font-size: 12px;
}
.product-image-task-failure-diagnosis {
font-size: 12px;
line-height: 1.5;
overflow-wrap: anywhere;
}
.product-image-task-actions {
justify-content: flex-end;
}
.product-image-task-actions:empty {
display: none;
}
.product-image-task-provider-error pre {
max-height: 220px;
margin: 6px 0 0;
padding: 10px;
overflow: auto;
border-radius: 6px;
background: #f7f6fa;
font-size: 12px;
line-height: 1.5;
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.product-image-task-error {
margin-bottom: 16px;
}
.product-image-task-results {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(180px, 100%), 1fr));
gap: 12px;
}
@media (max-width: 560px) {
.product-image-task-trigger,
.product-image-task-trigger .ant-btn {
width: 100%;
}
}
@@ -0,0 +1,171 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { Alert, App as AntdApp, Badge, Button, Drawer, Empty, Image, Modal, Popconfirm, Progress, Spin, Tag, Typography } from 'antd';
import { ClockCircleOutlined, LoadingOutlined, ReloadOutlined } from '@ant-design/icons';
import type { ProductImageTask } from '@atlas/contracts';
import { apiErrorMessage } from '@/services/api';
import { mediaAiEditFailureDiagnosis, mediaAiEditFailureMessage, mediaApi } from '@/services/media';
import './ProductImageTaskCenter.css';
const { Text } = Typography;
const activeStatuses = new Set<ProductImageTask['status']>(['queued', 'running']);
const statusLabels: Record<ProductImageTask['status'], string> = {
queued: '排队中', running: '处理中', succeeded: '已完成', partial: '部分完成',
failed: '失败', cancelled: '已取消',
};
const statusColors: Record<ProductImageTask['status'], string> = {
queued: 'default', running: 'processing', succeeded: 'success', partial: 'warning',
failed: 'error', cancelled: 'default',
};
function taskTitle(task: ProductImageTask): string {
return task.kind === 'single_ai_edit' ? '单张 AI 编辑' : 'AI 套图生成';
}
function taskTime(value: string): string {
return new Date(value).toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' });
}
/** 商品级图片任务入口;任务列表只传素材 ID,预览时再签发短时地址。 */
export function ProductImageTaskCenter({ productId }: { productId: string }) {
const { message } = AntdApp.useApp();
const [open, setOpen] = useState(false);
const [tasks, setTasks] = useState<ProductImageTask[]>([]);
const latestTasks = useRef<ProductImageTask[]>([]);
const requestSeq = useRef(0);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [preview, setPreview] = useState<{ title: string; urls: string[] } | null>(null);
const [previewLoadingId, setPreviewLoadingId] = useState<string | null>(null);
const [cancellingId, setCancellingId] = useState<string | null>(null);
const refresh = useCallback(async (): Promise<ProductImageTask[]> => {
const sequence = ++requestSeq.current;
try {
const current = await mediaApi.listProductImageTasks(productId);
if (sequence === requestSeq.current) {
latestTasks.current = current;
setTasks(current);
setError('');
}
return current;
} catch (cause) {
if (sequence === requestSeq.current) setError(apiErrorMessage(cause));
return latestTasks.current;
} finally {
if (sequence === requestSeq.current) setLoading(false);
}
}, [productId]);
useEffect(() => {
requestSeq.current += 1;
latestTasks.current = [];
setTasks([]);
setLoading(true);
const submitted = (event: Event) => {
const detail = (event as CustomEvent<{ productId: string }>).detail;
if (detail?.productId === productId) void refresh();
};
void refresh();
window.addEventListener('atlas:product-image-task-submitted', submitted);
return () => {
requestSeq.current += 1;
window.removeEventListener('atlas:product-image-task-submitted', submitted);
};
}, [productId, refresh]);
const hasActive = tasks.some((task) => activeStatuses.has(task.status));
useEffect(() => {
const timer = window.setInterval(() => void refresh(), hasActive ? 3000 : 15000);
return () => window.clearInterval(timer);
}, [hasActive, refresh]);
const cancel = async (task: ProductImageTask) => {
setCancellingId(task.id);
try {
await mediaApi.cancelAiEdit(task.id);
await refresh();
message.success('任务已取消');
} catch (cause) {
message.error(apiErrorMessage(cause));
} finally {
setCancellingId(null);
}
};
const showResult = async (task: ProductImageTask) => {
setPreviewLoadingId(task.id);
try {
const accesses = await Promise.all(task.output_asset_ids.map((id) => mediaApi.createPreviewUrl(id)));
setPreview({ title: taskTitle(task), urls: accesses.map((access) => access.url) });
} catch (cause) {
message.error(apiErrorMessage(cause));
} finally {
setPreviewLoadingId(null);
}
};
const active = tasks.filter((task) => activeStatuses.has(task.status));
const recent = tasks.filter((task) => !activeStatuses.has(task.status)).slice(0, 12);
const renderTask = (task: ProductImageTask) => <article className="product-image-task-card" key={`${task.kind}:${task.id}`}>
<div className="product-image-task-heading">
<strong>{taskTitle(task)}</strong>
<Tag color={statusColors[task.status]}>{statusLabels[task.status]}</Tag>
</div>
<Text type="secondary" className="product-image-task-meta">
<ClockCircleOutlined /> {taskTime(task.created_at)}
{task.phase === 'waiting_provider' ? ' · 等待 AI 服务返回' : ''}
</Text>
{activeStatuses.has(task.status) && <Progress
percent={Math.round(task.completed / Math.max(task.total, 1) * 100)}
size="small" status="active" format={() => `${task.completed}/${task.total}`}
/>}
{task.status === 'partial' && <Text type="secondary">已完成 {task.completed}/{task.total} 张</Text>}
{task.status === 'failed' && <Text type="danger">{mediaAiEditFailureMessage(task.error_code)}</Text>}
{task.status === 'failed' && <Text type="secondary" className="product-image-task-failure-diagnosis">
{mediaAiEditFailureDiagnosis(task)}
</Text>}
{task.status === 'failed' && task.provider_error_raw && <div className="product-image-task-provider-error">
<Text type="secondary">Pcore 原始错误</Text>
<pre>{task.provider_error_raw}</pre>
</div>}
<div className="product-image-task-actions">
{task.output_asset_ids.length > 0 && <Button type="link" htmlType="button"
loading={previewLoadingId === task.id} onClick={() => void showResult(task)}>查看结果</Button>}
{task.kind === 'single_ai_edit' && activeStatuses.has(task.status) && <Popconfirm
title="取消这个 AI 编辑任务?" okText="取消任务" cancelText="返回"
onConfirm={() => void cancel(task)}>
<Button type="link" danger htmlType="button" loading={cancellingId === task.id}>取消任务</Button>
</Popconfirm>}
</div>
</article>;
return <>
<Badge className="product-image-task-trigger" count={active.length} size="small">
<Button type="primary" htmlType="button"
icon={active.length ? <LoadingOutlined spin /> : <ClockCircleOutlined />}
onClick={() => setOpen(true)}>
{active.length ? '任务处理中' : '图片任务'}
</Button>
</Badge>
<Drawer title="当前商品的图片任务" placement="right" width="min(460px, 100vw)" open={open}
onClose={() => setOpen(false)}
extra={<Button htmlType="button" icon={<ReloadOutlined />} onClick={() => void refresh()}>刷新</Button>}>
{error && <Alert type="warning" showIcon message="任务列表暂时无法更新" description={error} className="product-image-task-error" />}
{loading && tasks.length === 0 ? <Spin /> : <>
<section className="product-image-task-section">
<h3>进行中 · {active.length}</h3>
{active.length ? active.map(renderTask) : <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="暂无进行中的任务" />}
</section>
<section className="product-image-task-section">
<h3>最近任务</h3>
{recent.length ? recent.map(renderTask) : <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="暂无任务记录" />}
</section>
</>}
</Drawer>
<Modal title={preview?.title} open={!!preview} onCancel={() => setPreview(null)} footer={null} width={760}>
<div className="product-image-task-results">
{preview?.urls.map((url) => <Image key={url} src={url} alt="AI 生成结果" />)}
</div>
</Modal>
</>;
}
@@ -121,6 +121,7 @@
font-size: 12px; font-size: 12px;
} }
.sku-main-thumbnail { .sku-main-thumbnail {
position: relative;
display: flex; display: flex;
flex: 0 0 auto; flex: 0 0 auto;
align-self: flex-start; align-self: flex-start;
@@ -137,13 +138,19 @@
font-size: 10px; font-size: 10px;
text-align: center; text-align: center;
} }
.sku-main-thumbnail .ant-image, .sku-main-thumbnail .ant-image {
.sku-main-thumbnail img { position: absolute;
inset: 0 4px;
display: flex;
align-items: center;
justify-content: center;
}
.sku-main-thumbnail .ant-image img {
display: block;
width: 100%; width: 100%;
height: 100%; height: 100%;
} object-fit: contain;
.sku-main-thumbnail img { object-position: center;
object-fit: cover;
} }
.sku-card-value { .sku-card-value {
flex: 0 0 auto; flex: 0 0 auto;
@@ -222,7 +229,7 @@
.sku-media-item { .sku-media-item {
position: relative; position: relative;
min-width: 0; min-width: 0;
/* 每张图片框固定为 3:4;实际图片使用 contain,完整居中展示。 */ /* 图片框固定为 3:4;图片容器铺满框后用 contain 水平、垂直居中。 */
aspect-ratio: 3 / 4; aspect-ratio: 3 / 4;
align-self: start; align-self: start;
overflow: hidden; overflow: hidden;
@@ -246,12 +253,16 @@
font-size: 11px; font-size: 11px;
line-height: 18px; line-height: 18px;
} }
.sku-media-item .ant-image, .sku-media-item .ant-image {
.sku-media-item img { position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
}
.sku-media-item .ant-image img {
width: 100%; width: 100%;
height: 100%; height: 100%;
}
.sku-media-item img {
display: block; display: block;
object-fit: contain; object-fit: contain;
object-position: center; object-position: center;
@@ -31,6 +31,9 @@ import type {
import { SectionTitle } from './SectionTitle'; import { SectionTitle } from './SectionTitle';
import { DynamicAttributeFields } from './DynamicAttributeFields'; import { DynamicAttributeFields } from './DynamicAttributeFields';
import { SkuImageEditorModal, type SkuEditorImage } from './SkuImageEditorModal'; import { SkuImageEditorModal, type SkuEditorImage } from './SkuImageEditorModal';
import { createThreeFourImage, downloadMediaAsset } from '@/services/media';
import { createWatermarkedImage } from '@/services/media';
import type { WatermarkTemplate } from '@atlas/contracts';
import { resolveSkuImageSelection } from './skuImageSelection'; import { resolveSkuImageSelection } from './skuImageSelection';
import { mediaApi, uploadProductImage } from '@/services/media'; import { mediaApi, uploadProductImage } from '@/services/media';
import './ProductVariantSection.css'; import './ProductVariantSection.css';
@@ -293,6 +296,21 @@ export function ProductVariantSection({
}) })
.filter((image): image is SkuEditorImage => image !== null); .filter((image): image is SkuEditorImage => image !== null);
const selectedImages = localImagesByVariant[variantKey] ?? savedImages; const selectedImages = localImagesByVariant[variantKey] ?? savedImages;
const sourceMainCandidate = !selectedAssetIds.length && !selectedImages.length && original
? imageCandidates.find((item) =>
item.source_variant_id === original.id &&
item.role === 'source_sku'
)
: null;
const displayImages: SkuEditorImage[] = sourceMainCandidate
? [{
id: `collection:${sourceMainCandidate.id}`,
url: sourceMainCandidate.source_url,
label: '来源 SKU 图片',
origin: 'collection',
transferId: sourceMainCandidate.id,
}]
: selectedImages;
const copyVariant = () => const copyVariant = () =>
add({ add({
...variantValue, ...variantValue,
@@ -334,10 +352,10 @@ export function ProductVariantSection({
</Form.Item> </Form.Item>
<div className="sku-card-main"> <div className="sku-card-main">
<div className="sku-main-thumbnail"> <div className="sku-main-thumbnail">
{selectedImages[0] ? ( {displayImages[0] ? (
<Image <Image
preview={false} preview={false}
src={selectedImages[0].url} src={displayImages[0].url}
alt="SKU 主图" alt="SKU 主图"
/> />
) : ( ) : (
@@ -412,14 +430,15 @@ export function ProductVariantSection({
noStyle noStyle
rules={[ rules={[
{ {
pattern: /^\d+(\.\d{1,4})?$/, pattern: /^\d+(\.\d{1,2})?$/,
message: '请输入金额', message: '请输入最多两位小数的金额',
}, },
]} ]}
> >
<InputNumber<string> <InputNumber<string>
min="0" min="0"
step="0.0001" step="0.01"
precision={2}
stringMode stringMode
placeholder="—" placeholder="—"
/> />
@@ -467,32 +486,37 @@ export function ProductVariantSection({
<Typography.Title level={5}> <Typography.Title level={5}>
SKU 图片 SKU 图片
</Typography.Title> </Typography.Title>
{sourceMainCandidate && (
<Typography.Text className="sku-media-local-note">
来源 SKU 图片预览;选择后转存为正式主图
</Typography.Text>
)}
{Object.hasOwn(localImagesByVariant, variantKey) && ( {Object.hasOwn(localImagesByVariant, variantKey) && (
<Typography.Text className="sku-media-local-note"> <Typography.Text className="sku-media-local-note">
当前图片仅在本页预览,刷新后恢复 当前图片仅在本页预览,刷新后恢复
</Typography.Text> </Typography.Text>
)} )}
<div className="sku-media-grid"> <div className="sku-media-grid">
{selectedImages.map((image, imageIndex) => ( {displayImages.map((image, imageIndex) => (
<div <div
className={`sku-media-item ${imageIndex === 0 ? 'sku-media-item--main' : ''}`} className={`sku-media-item ${imageIndex === 0 ? 'sku-media-item--main' : ''}`}
key={image.id} key={image.id}
> >
{imageIndex === 0 && ( {imageIndex === 0 && (
<span className="sku-media-main-badge"> <span className="sku-media-main-badge">
主图 {sourceMainCandidate ? '来源主图' : '主图'}
</span> </span>
)} )}
<Image src={image.url} alt="已选 SKU 图片" /> <Image src={image.url} alt="已选 SKU 图片" />
</div> </div>
))} ))}
<Button <Button
className={`sku-media-picker ${selectedImages.length ? '' : 'sku-media-picker--empty'}`} className={`sku-media-picker ${displayImages.length ? '' : 'sku-media-picker--empty'}`}
type="dashed" type="dashed"
onClick={() => setImageEditorTarget({ onClick={() => setImageEditorTarget({
key: variantKey, key: variantKey,
name: variantValue?.display_name || `SKU ${index + 1}`, name: variantValue?.display_name || `SKU ${index + 1}`,
images: selectedImages, images: displayImages,
candidates: imageCandidates, candidates: imageCandidates,
})} })}
> >
@@ -757,6 +781,7 @@ export function ProductVariantSection({
</Card> </Card>
<SkuImageEditorModal <SkuImageEditorModal
open={!!imageEditorTarget} open={!!imageEditorTarget}
productId={snapshot.product.id}
skuKey={imageEditorTarget?.key ?? ''} skuKey={imageEditorTarget?.key ?? ''}
skuName={imageEditorTarget?.name ?? ''} skuName={imageEditorTarget?.name ?? ''}
initialImages={imageEditorTarget?.images ?? []} initialImages={imageEditorTarget?.images ?? []}
@@ -791,6 +816,46 @@ export function ProductVariantSection({
assetId: asset.id, assetId: asset.id,
}; };
}} }}
onMakeThreeFour={async (image) => {
const [assetId] = await resolveSkuImageSelection(
[image],
collectionRecord?.id ?? null
);
const derivative = await createThreeFourImage(assetId, snapshot.product.id);
const asset = derivative.output_asset;
if (!asset) throw new Error('图片处理结果不可用');
return {
id: `asset:${asset.id}`,
url: asset.preview_url,
label: '3:4 图片',
origin: 'derivative',
assetId: asset.id,
};
}}
onApplyWatermark={async (image, template: WatermarkTemplate) => {
const [assetId] = await resolveSkuImageSelection([image], collectionRecord?.id ?? null);
const derivative = await createWatermarkedImage(assetId, template.id, snapshot.product.id);
const asset = derivative.output_asset;
if (!asset) throw new Error('水印处理结果不可用');
return {
id: `asset:${asset.id}`,
url: asset.preview_url,
label: `${image.label} · ${template.name}`,
origin: 'derivative',
assetId: asset.id,
};
}}
onResolveAsset={async (image) => {
const [assetId] = await resolveSkuImageSelection([image], collectionRecord?.id ?? null);
return assetId;
}}
onDownloadImage={async (image) => {
const [assetId] = await resolveSkuImageSelection(
[image],
collectionRecord?.id ?? null
);
await downloadMediaAsset(assetId);
}}
/> />
</> </>
); );
@@ -12,7 +12,7 @@ export type SkuEditorImage = {
id: string; id: string;
url: string; url: string;
label: string; label: string;
origin: 'saved' | 'collection' | 'upload' | 'network'; origin: 'saved' | 'collection' | 'upload' | 'network' | 'derivative';
assetId?: string; assetId?: string;
transferId?: string; transferId?: string;
sourceUrl?: string; sourceUrl?: string;
@@ -46,20 +46,11 @@
padding: 14px 24px; padding: 14px 24px;
border-top: 1px solid #ebe9f1; border-top: 1px solid #ebe9f1;
} }
.sku-image-editor-note {
flex: 0 0 auto;
margin: 16px 0 12px;
padding: 9px 13px;
border: 1px solid #e9e2ff;
border-radius: 8px;
background: #f8f5ff;
color: #695d86;
font-size: 12px;
}
.sku-image-editor-columns { .sku-image-editor-columns {
display: grid; display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
gap: 24px; gap: 24px;
margin-top: 16px;
min-height: 0; min-height: 0;
flex: 1 1 auto; flex: 1 1 auto;
} }
@@ -140,6 +131,26 @@
gap: 12px; gap: 12px;
padding: 3px; padding: 3px;
} }
.sku-image-processed-entry {
min-width: 0;
}
.sku-image-processed-caption {
display: flex;
flex-direction: column;
gap: 2px;
margin-top: 5px;
color: #77718a;
font-size: 11px;
line-height: 16px;
}
.sku-image-processed-caption strong {
color: #40394f;
}
.sku-image-processed-status {
display: flex;
justify-content: center;
padding: 32px;
}
.sku-image-selected-card, .sku-image-selected-card,
.sku-image-source-card { .sku-image-source-card {
position: relative; position: relative;
@@ -1,11 +1,15 @@
import { useEffect, useRef, useState } from 'react'; import { useCallback, useEffect, useRef, useState } from 'react';
import { App as AntdApp, Button, Dropdown, Empty, Image, Modal } from 'antd'; import { App as AntdApp, Button, Dropdown, Empty, Image, Modal, Spin } from 'antd';
import type { MenuProps } from 'antd'; import type { MenuProps } from 'antd';
import { CheckOutlined, DownOutlined, UploadOutlined } from '@ant-design/icons'; import { CheckOutlined, DownOutlined, UploadOutlined } from '@ant-design/icons';
import type { CollectionItemInspection } from '@atlas/contracts'; import type { CollectionItemInspection, ProductImageDerivativeRecord, WatermarkTemplate } from '@atlas/contracts';
import { apiErrorMessage } from '@/services/api'; import { apiErrorMessage } from '@/services/api';
import { mediaApi } from '@/services/media';
import { watermarksApi } from '@/services/watermarks';
import { SkuImageCard, type SkuEditorImage } from './SkuImageCard'; import { SkuImageCard, type SkuEditorImage } from './SkuImageCard';
import { SkuImageSourceTabs } from './SkuImageSourceTabs'; import { SkuImageSourceTabs } from './SkuImageSourceTabs';
import { SkuWatermarkPickerModal } from './SkuWatermarkPickerModal';
import { SkuSingleAiEditDrawer } from './SkuSingleAiEditDrawer';
import { uploadFileError, useSkuImageLibrary } from './useSkuImageLibrary'; import { uploadFileError, useSkuImageLibrary } from './useSkuImageLibrary';
import './SkuImageEditorModal.css'; import './SkuImageEditorModal.css';
@@ -15,6 +19,7 @@ export type { SkuEditorImage } from './SkuImageCard';
type Props = { type Props = {
open: boolean; open: boolean;
productId: string;
skuKey: string; skuKey: string;
skuName: string; skuName: string;
initialImages: SkuEditorImage[]; initialImages: SkuEditorImage[];
@@ -22,21 +27,14 @@ type Props = {
onCancel: () => void; onCancel: () => void;
onConfirm: (images: SkuEditorImage[]) => Promise<void>; onConfirm: (images: SkuEditorImage[]) => Promise<void>;
onUploadFile: (file: File) => Promise<SkuEditorImage>; onUploadFile: (file: File) => Promise<SkuEditorImage>;
onMakeThreeFour: (image: SkuEditorImage) => Promise<SkuEditorImage>;
onApplyWatermark: (image: SkuEditorImage, template: WatermarkTemplate) => Promise<SkuEditorImage>;
onResolveAsset: (image: SkuEditorImage) => Promise<string>;
onDownloadImage: (image: SkuEditorImage) => Promise<void>;
}; };
const MAX_IMAGES = 30; const MAX_IMAGES = 30;
function downloadImage(image: SkuEditorImage) {
const link = document.createElement('a');
link.href = image.url;
link.download = image.label || 'sku-image';
link.target = '_blank';
link.rel = 'noopener noreferrer';
document.body.appendChild(link);
link.click();
link.remove();
}
function sameImage(left: SkuEditorImage, right: SkuEditorImage) { function sameImage(left: SkuEditorImage, right: SkuEditorImage) {
return left.id === right.id || !!(left.assetId && left.assetId === right.assetId); return left.id === right.id || !!(left.assetId && left.assetId === right.assetId);
} }
@@ -44,6 +42,7 @@ function sameImage(left: SkuEditorImage, right: SkuEditorImage) {
/** 图片选择弹窗管理交互状态;素材上传与草稿写入由外层服务完成。 */ /** 图片选择弹窗管理交互状态;素材上传与草稿写入由外层服务完成。 */
export function SkuImageEditorModal({ export function SkuImageEditorModal({
open, open,
productId,
skuKey, skuKey,
skuName, skuName,
initialImages, initialImages,
@@ -51,12 +50,45 @@ export function SkuImageEditorModal({
onCancel, onCancel,
onConfirm, onConfirm,
onUploadFile, onUploadFile,
onMakeThreeFour,
onApplyWatermark,
onResolveAsset,
onDownloadImage,
}: Props) { }: Props) {
const { message } = AntdApp.useApp(); const { message } = AntdApp.useApp();
const [images, setImages] = useState<SkuEditorImage[]>(initialImages); const [images, setImages] = useState<SkuEditorImage[]>(initialImages);
const [markedIds, setMarkedIds] = useState<string[]>([]); const [markedIds, setMarkedIds] = useState<string[]>([]);
const [activeTab, setActiveTab] = useState('collection'); const [activeTab, setActiveTab] = useState('collection');
const [confirming, setConfirming] = useState(false); const [confirming, setConfirming] = useState(false);
const [processingThreeFour, setProcessingThreeFour] = useState(false);
const [processingWatermark, setProcessingWatermark] = useState(false);
const [watermarkTargets, setWatermarkTargets] = useState<SkuEditorImage[]>([]);
const [watermarkUndo, setWatermarkUndo] = useState<Record<string, SkuEditorImage | null>>({});
const [aiTarget, setAiTarget] = useState<SkuEditorImage | null>(null);
const [aiImages, setAiImages] = useState<SkuEditorImage[]>([]);
const [processedRecords, setProcessedRecords] = useState<ProductImageDerivativeRecord[]>([]);
const [processedLoading, setProcessedLoading] = useState(false);
const [processedError, setProcessedError] = useState(false);
const refreshAiImages = useCallback(async () => {
const tasks = await mediaApi.listAiEdits(productId);
setAiImages(tasks.flatMap((task) => task.output_asset ? [{
id: `asset:${task.output_asset.id}`,
url: task.output_asset.preview_url,
label: 'AI 生成图', origin: 'derivative' as const,
assetId: task.output_asset.id,
}] : []));
}, [productId]);
const refreshProcessedImages = useCallback(async () => {
const records = await mediaApi.listProductImageDerivatives(productId);
setProcessedRecords(records);
setProcessedError(false);
}, [productId]);
const [downloading, setDownloading] = useState(false);
const processingLock = useRef(false);
const watermarkActionLock = useRef<symbol | null>(null);
const defaultWatermarkRequest = useRef<Promise<WatermarkTemplate | null> | null>(null);
const editorSession = useRef(0);
const downloadLock = useRef(false);
const { const {
networkUrls, networkUrls,
setNetworkUrls, setNetworkUrls,
@@ -75,12 +107,68 @@ export function SkuImageEditorModal({
const dragIndex = useRef<number | null>(null); const dragIndex = useRef<number | null>(null);
useEffect(() => { useEffect(() => {
if (!open) return; if (!open) return;
const session = ++editorSession.current;
watermarkActionLock.current = null;
defaultWatermarkRequest.current = watermarksApi.getDefault().catch(() => null);
setImages(initialImages); setImages(initialImages);
setMarkedIds([]); setMarkedIds([]);
setActiveTab('collection'); setActiveTab('collection');
setPreview(null); setPreview(null);
setWatermarkTargets([]);
setWatermarkUndo({});
setAiTarget(null);
return () => {
if (editorSession.current === session) {
editorSession.current += 1;
defaultWatermarkRequest.current = null;
}
};
}, [open, skuKey]); }, [open, skuKey]);
useEffect(() => {
if (!open) return;
let active = true;
const refresh = async () => {
const assetIds = [...new Set(images.flatMap((image) => image.assetId ? [image.assetId] : []))];
if (!assetIds.length) return;
const results = await Promise.allSettled(
assetIds.map((id) => mediaApi.createPreviewUrl(id))
);
if (!active) return;
const urls = new Map<string, string>();
results.forEach((result, index) => {
if (result.status === 'fulfilled') urls.set(assetIds[index], result.value.url);
});
if (urls.size) setImages((current) => current.map((image) => ({
...image,
url: image.assetId ? (urls.get(image.assetId) ?? image.url) : image.url,
})));
};
void refresh();
const interval = window.setInterval(() => void refresh(), 8 * 60_000);
return () => {
active = false;
window.clearInterval(interval);
};
}, [open, skuKey, images.map((image) => image.assetId ?? image.id).join('|')]);
useEffect(() => {
if (!open) return;
void refreshAiImages().catch(() => setAiImages([]));
const interval = window.setInterval(() => void refreshAiImages().catch(() => undefined), 8 * 60_000);
return () => window.clearInterval(interval);
}, [open, skuKey, refreshAiImages]);
useEffect(() => {
if (!open) return;
setProcessedLoading(true);
void refreshProcessedImages()
.catch(() => setProcessedError(true))
.finally(() => setProcessedLoading(false));
const interval = window.setInterval(() => {
void refreshProcessedImages().catch(() => setProcessedError(true));
}, 8 * 60_000);
return () => window.clearInterval(interval);
}, [open, skuKey, refreshProcessedImages]);
const addImage = (image: SkuEditorImage) => { const addImage = (image: SkuEditorImage) => {
if (processingLock.current) return;
const existing = images.find((item) => sameImage(item, image)); const existing = images.find((item) => sameImage(item, image));
if (existing) { if (existing) {
setImages((current) => current.filter((item) => item.id !== existing.id)); setImages((current) => current.filter((item) => item.id !== existing.id));
@@ -94,10 +182,12 @@ export function SkuImageEditorModal({
setImages((current) => [...current, image]); setImages((current) => [...current, image]);
}; };
const removeImages = (ids: string[]) => { const removeImages = (ids: string[]) => {
if (processingLock.current) return;
setImages((current) => current.filter((image) => !ids.includes(image.id))); setImages((current) => current.filter((image) => !ids.includes(image.id)));
setMarkedIds((current) => current.filter((id) => !ids.includes(id))); setMarkedIds((current) => current.filter((id) => !ids.includes(id)));
}; };
const setCover = (image: SkuEditorImage) => { const setCover = (image: SkuEditorImage) => {
if (processingLock.current) return;
if (!images.some((item) => sameImage(item, image)) && images.length >= MAX_IMAGES) { if (!images.some((item) => sameImage(item, image)) && images.length >= MAX_IMAGES) {
message.warning(`每个 SKU 最多选择 ${MAX_IMAGES} 张图片`); message.warning(`每个 SKU 最多选择 ${MAX_IMAGES} 张图片`);
return; return;
@@ -107,26 +197,195 @@ export function SkuImageEditorModal({
return [selected, ...current.filter((item) => !sameImage(item, image))]; return [selected, ...current.filter((item) => !sameImage(item, image))];
}); });
}; };
const markImage = (id: string) => const markImage = (id: string) => {
if (processingLock.current) return;
setMarkedIds((current) => setMarkedIds((current) =>
current.includes(id) ? current.filter((item) => item !== id) : [...current, id] current.includes(id) ? current.filter((item) => item !== id) : [...current, id]
); );
};
const makeThreeFour = async (targets: SkuEditorImage[]) => {
if (processingLock.current || !targets.length) return;
processingLock.current = true;
setProcessingThreeFour(true);
let succeeded = 0;
const selectedAssetIds = new Set(images.flatMap((image) => image.assetId ? [image.assetId] : []));
try {
for (const image of targets) {
if (!images.some((item) => sameImage(item, image)) && images.length >= MAX_IMAGES) {
message.warning(`每个 SKU 最多选择 ${MAX_IMAGES} 张图片`);
break;
}
try {
const output = await onMakeThreeFour(image);
if (output.assetId && selectedAssetIds.has(output.assetId)
&& output.assetId !== image.assetId) {
message.info(`${image.label} 的 3:4 图片已在已选区,保留原图`);
continue;
}
if (image.assetId) selectedAssetIds.delete(image.assetId);
if (output.assetId) selectedAssetIds.add(output.assetId);
setImages((current) => {
if (sameImage(image, output)) return current;
const index = current.findIndex((item) => sameImage(item, image));
const withoutOutput = current.filter((item) => !sameImage(item, output));
if (index < 0) return [...withoutOutput, output].slice(0, MAX_IMAGES);
const replacement = withoutOutput.findIndex((item) => sameImage(item, image));
if (replacement < 0) return withoutOutput;
const next = [...withoutOutput];
next[replacement] = output;
return next;
});
setMarkedIds((current) => current.map((id) => id === image.id ? output.id : id));
succeeded += 1;
} catch (error) {
message.error(`${image.label} 处理失败:${apiErrorMessage(error)}`);
}
}
if (succeeded) {
message.success(`已生成 ${succeeded} 张 3:4 图片,请确认后保存草稿`);
void refreshProcessedImages().catch(() => setProcessedError(true));
}
} finally {
processingLock.current = false;
setProcessingThreeFour(false);
}
};
const applyWatermark = async (template: WatermarkTemplate, targets = watermarkTargets) => {
if (processingLock.current || !targets.length) return;
processingLock.current = true;
setProcessingWatermark(true);
let succeeded = 0;
let selectedCount = images.length;
const selectedAssetIds = new Set(images.flatMap((image) => image.assetId ? [image.assetId] : []));
try {
for (const image of targets) {
const originalSelected = images.some((item) => sameImage(item, image));
if (!originalSelected && selectedCount >= MAX_IMAGES) {
message.warning(`每个 SKU 最多选择 ${MAX_IMAGES} 张图片`);
break;
}
try {
const output = await onApplyWatermark(image, template);
if (output.assetId && selectedAssetIds.has(output.assetId)
&& output.assetId !== image.assetId) {
message.info(`${image.label} 的水印图片已在已选区,保留原图`);
continue;
}
if (originalSelected && image.assetId) selectedAssetIds.delete(image.assetId);
if (output.assetId) selectedAssetIds.add(output.assetId);
if (!originalSelected) selectedCount += 1;
setImages((current) => {
const index = current.findIndex((item) => sameImage(item, image));
if (index < 0) return [...current, output].slice(0, MAX_IMAGES);
const next = [...current];
next[index] = output;
return next;
});
setMarkedIds((current) => current.map((id) => id === image.id ? output.id : id));
setWatermarkUndo((current) => ({
...current,
[output.id]: image.id in current ? current[image.id] : originalSelected ? image : null,
}));
succeeded += 1;
} catch (error) {
message.error(`${image.label} 添加水印失败:${apiErrorMessage(error)}`);
}
}
if (succeeded) {
message.success(`已生成 ${succeeded} 张水印图片,请确认后保存草稿`);
void refreshProcessedImages().catch(() => setProcessedError(true));
}
else throw new Error('没有图片成功添加水印,请检查模板和素材后重试');
} finally {
processingLock.current = false;
setProcessingWatermark(false);
}
};
/** 打开图片弹窗时预取默认模板;点击时等待同一次查询后决定是否需要选模板。 */
const addWatermark = async (targets: SkuEditorImage[]) => {
if (processingLock.current || watermarkActionLock.current || !targets.length) return;
const action = Symbol('add-watermark');
watermarkActionLock.current = action;
const session = editorSession.current;
try {
const request = defaultWatermarkRequest.current
?? watermarksApi.getDefault().catch(() => null);
defaultWatermarkRequest.current = request;
const template = await request;
if (session !== editorSession.current) return;
if (template) {
try {
await applyWatermark(template, targets);
} catch (error) {
message.error(`应用默认水印失败:${apiErrorMessage(error)}`);
}
} else {
setWatermarkTargets(targets);
}
} finally {
if (watermarkActionLock.current === action) watermarkActionLock.current = null;
}
};
const undoWatermark = (image: SkuEditorImage) => {
if (processingLock.current || !(image.id in watermarkUndo)) return;
const original = watermarkUndo[image.id];
setImages((current) => original
? current.map((item) => item.id === image.id ? original : item)
: current.filter((item) => item.id !== image.id));
setMarkedIds((current) => original
? current.map((id) => id === image.id ? original.id : id)
: current.filter((id) => id !== image.id));
setWatermarkUndo((current) => {
const next = { ...current };
delete next[image.id];
return next;
});
};
const pendingAction = (label: string) => message.info(`${label}将在后续接入图片处理服务`); const pendingAction = (label: string) => message.info(`${label}将在后续接入图片处理服务`);
const downloadImages = async (targets: SkuEditorImage[]) => {
if (downloadLock.current || !targets.length) return;
downloadLock.current = true;
setDownloading(true);
message.open({ type: 'loading', content: '正在准备图片下载…', key: 'sku-image-download', duration: 0 });
let succeeded = 0;
try {
for (const image of targets) {
try {
await onDownloadImage(image);
succeeded += 1;
} catch (error) {
message.error(`${image.label} 下载失败:${apiErrorMessage(error)}`);
}
}
if (targets.length > 1 && succeeded) message.success(`已发起 ${succeeded} 张图片下载`);
} finally {
message.destroy('sku-image-download');
downloadLock.current = false;
setDownloading(false);
}
};
const onBatchAction: MenuProps['onClick'] = ({ key }) => { const onBatchAction: MenuProps['onClick'] = ({ key }) => {
if (processingLock.current) return;
if (!markedIds.length) { if (!markedIds.length) {
message.info('请先点击左侧图片进行选择'); message.info('请先点击左侧图片进行选择');
return; return;
} }
if (key === 'delete') removeImages(markedIds); if (key === 'delete') removeImages(markedIds);
else if (key === 'download') else if (key === 'download')
images.filter((image) => markedIds.includes(image.id)).forEach(downloadImage); void downloadImages(images.filter((image) => markedIds.includes(image.id)));
else if (key === 'three_four')
void makeThreeFour(images.filter((image) => markedIds.includes(image.id)));
else if (key === 'watermark')
void addWatermark(images.filter((image) => markedIds.includes(image.id)));
else if (key === 'undo_watermark') images.filter((image) => markedIds.includes(image.id)).forEach(undoWatermark);
else pendingAction(String(key)); else pendingAction(String(key));
}; };
const batchMenu: MenuProps = { const batchMenu: MenuProps = {
onClick: onBatchAction, onClick: onBatchAction,
items: [ items: [
{ key: '添加水印', label: '添加水印' }, { key: 'watermark', label: '添加水印' },
{ key: '一键 3:4', label: '一键 3:4' }, { key: 'undo_watermark', label: '撤销水印', disabled: !markedIds.some((id) => id in watermarkUndo) },
{ key: 'three_four', label: '一键 3:4' },
{ key: '批量翻译', label: '批量翻译' }, { key: '批量翻译', label: '批量翻译' },
{ key: 'download', label: '下载' }, { key: 'download', label: '下载' },
{ type: 'divider' }, { type: 'divider' },
@@ -136,20 +395,26 @@ export function SkuImageEditorModal({
const imageMenu = (image: SkuEditorImage): MenuProps => ({ const imageMenu = (image: SkuEditorImage): MenuProps => ({
onClick: ({ key, domEvent }) => { onClick: ({ key, domEvent }) => {
domEvent.stopPropagation(); domEvent.stopPropagation();
if (processingLock.current) return;
if (key === 'cover') setCover(image); if (key === 'cover') setCover(image);
else if (key === 'download') downloadImage(image); else if (key === 'download') void downloadImages([image]);
else if (key === 'three_four') void makeThreeFour([image]);
else if (key === 'watermark') void addWatermark([image]);
else if (key === 'AI 编辑') setAiTarget(image);
else if (key === 'undo_watermark') undoWatermark(image);
else pendingAction(String(key)); else pendingAction(String(key));
}, },
items: [ items: [
{ key: 'AI 编辑', label: 'AI 编辑' }, { key: 'AI 编辑', label: 'AI 编辑' },
{ key: '添加水印', label: '添加水印' }, { key: 'watermark', label: '添加水印' },
{ key: '一键 3:4', label: '一键 3:4' }, ...(image.id in watermarkUndo ? [{ key: 'undo_watermark', label: '撤销水印' }] : []),
{ key: 'three_four', label: '一键 3:4' },
{ key: 'cover', label: '设为封图' }, { key: 'cover', label: '设为封图' },
{ key: 'download', label: '下载' }, { key: 'download', label: '下载' },
], ],
}); });
const uploadFiles = async (files: FileList | null) => { const uploadFiles = async (files: FileList | null) => {
if (!files) return; if (!files || processingLock.current) return;
const available = MAX_IMAGES - images.length; const available = MAX_IMAGES - images.length;
const accepted = Array.from(files).filter((file) => { const accepted = Array.from(files).filter((file) => {
const error = uploadFileError(file); const error = uploadFileError(file);
@@ -210,6 +475,39 @@ export function SkuImageEditorModal({
) : ( ) : (
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="暂无图片" /> <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="暂无图片" />
); );
const processedImages = processedRecords.map((record): SkuEditorImage => ({
id: `asset:${record.output_asset.id}`,
url: record.output_asset.preview_url,
label: record.kind === 'watermark' ? '水印处理图' : '3:4 处理图',
origin: 'derivative',
assetId: record.output_asset.id,
}));
const renderProcessedImages = () => {
if (processedLoading) return <div className="sku-image-processed-status"><Spin /></div>;
if (processedError) return <Empty description="图片处理记录加载失败">
<Button onClick={() => void refreshProcessedImages().catch(() => setProcessedError(true))}>重试</Button>
</Empty>;
if (!processedRecords.length) return <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="暂无图片处理记录" />;
return <div className="sku-image-source-grid">
{processedRecords.map((record, index) => {
const image = processedImages[index];
return <div className="sku-image-processed-entry" key={record.id}>
<SkuImageCard
image={image}
mode="source"
selected={images.some((item) => sameImage(item, image))}
menu={imageMenu(image)}
onToggle={() => addImage(image)}
onPreview={() => showPreview(processedImages, index)}
/>
<div className="sku-image-processed-caption">
<strong>{record.kind === 'watermark' ? '水印' : '一键 3:4'}</strong>
<span>{new Date(record.created_at).toLocaleString('zh-CN')}</span>
</div>
</div>;
})}
</div>;
};
const confirmSelection = async () => { const confirmSelection = async () => {
setConfirming(true); setConfirming(true);
@@ -235,19 +533,19 @@ export function SkuImageEditorModal({
} }
width="min(1480px, 96vw)" width="min(1480px, 96vw)"
onCancel={() => { onCancel={() => {
if (!confirming) onCancel(); if (!confirming && !processingThreeFour && !processingWatermark) onCancel();
}} }}
closable={!confirming} closable={!confirming && !processingThreeFour && !processingWatermark}
maskClosable={!confirming} maskClosable={!confirming && !processingThreeFour && !processingWatermark}
footer={[ footer={[
<Button key="cancel" onClick={onCancel} disabled={confirming}> <Button key="cancel" onClick={onCancel} disabled={confirming || processingThreeFour || processingWatermark}>
取消 取消
</Button>, </Button>,
<Button <Button
key="confirm" key="confirm"
type="primary" type="primary"
loading={confirming} loading={confirming}
disabled={uploading} disabled={uploading || processingThreeFour || processingWatermark}
onClick={() => void confirmSelection()} onClick={() => void confirmSelection()}
> >
确认 确认
@@ -255,9 +553,6 @@ export function SkuImageEditorModal({
]} ]}
centered centered
> >
<div className="sku-image-editor-note">
第一张为主图。确认后将选图写入当前编辑草稿,点击页面“保存”后持久化。
</div>
<div className="sku-image-editor-columns"> <div className="sku-image-editor-columns">
<section className="sku-image-editor-left" aria-label="已选 SKU 图片"> <section className="sku-image-editor-left" aria-label="已选 SKU 图片">
<div className="sku-image-editor-toolbar"> <div className="sku-image-editor-toolbar">
@@ -273,6 +568,7 @@ export function SkuImageEditorModal({
type="primary" type="primary"
icon={<UploadOutlined />} icon={<UploadOutlined />}
loading={uploading} loading={uploading}
disabled={processingThreeFour || processingWatermark}
onClick={() => fileInput.current?.click()} onClick={() => fileInput.current?.click()}
> >
上传图片 上传图片
@@ -285,18 +581,18 @@ export function SkuImageEditorModal({
) : undefined ) : undefined
} }
onClick={() => onClick={() =>
setMarkedIds( !processingLock.current && setMarkedIds(
markedIds.length === images.length markedIds.length === images.length
? [] ? []
: images.map((image) => image.id) : images.map((image) => image.id)
) )
} }
disabled={!images.length} disabled={!images.length || processingThreeFour || processingWatermark}
> >
全选 全选
</Button> </Button>
<Dropdown menu={batchMenu} trigger={['click']}> <Dropdown menu={batchMenu} trigger={['click']}>
<Button> <Button loading={processingThreeFour || processingWatermark || downloading} disabled={processingThreeFour || processingWatermark || downloading}>
批量操作 <DownOutlined /> 批量操作 <DownOutlined />
</Button> </Button>
</Dropdown> </Dropdown>
@@ -305,7 +601,7 @@ export function SkuImageEditorModal({
<strong> <strong>
已选图片({images.length} / {MAX_IMAGES}) 已选图片({images.length} / {MAX_IMAGES})
</strong> </strong>
<span>第一张为主图,拖动可调整顺序</span> <span>拖动可调整顺序</span>
</div> </div>
<div className="sku-image-editor-scroll"> <div className="sku-image-editor-scroll">
{images.length ? ( {images.length ? (
@@ -322,11 +618,13 @@ export function SkuImageEditorModal({
onPreview={() => showPreview(images, index)} onPreview={() => showPreview(images, index)}
onRemove={() => removeImages([image.id])} onRemove={() => removeImages([image.id])}
onDragStart={() => { onDragStart={() => {
if (processingLock.current) return;
dragIndex.current = index; dragIndex.current = index;
}} }}
onDragOver={(event) => event.preventDefault()} onDragOver={(event) => event.preventDefault()}
onDrop={(event) => { onDrop={(event) => {
event.preventDefault(); event.preventDefault();
if (processingLock.current) return;
const from = dragIndex.current; const from = dragIndex.current;
if (from === null || from === index) return; if (from === null || from === index) return;
setImages((current) => { setImages((current) => {
@@ -351,9 +649,16 @@ export function SkuImageEditorModal({
<section className="sku-image-editor-right" aria-label="可选图片"> <section className="sku-image-editor-right" aria-label="可选图片">
<SkuImageSourceTabs <SkuImageSourceTabs
activeTab={activeTab} activeTab={activeTab}
onTabChange={setActiveTab} onTabChange={(tab) => {
setActiveTab(tab);
if (tab === 'processed') {
void refreshProcessedImages().catch(() => setProcessedError(true));
}
}}
sourceImages={sourceImages} sourceImages={sourceImages}
renderImages={renderSourceImages} renderImages={renderSourceImages}
aiImages={aiImages}
renderProcessedImages={renderProcessedImages}
networkImages={networkImages} networkImages={networkImages}
networkUrls={networkUrls} networkUrls={networkUrls}
onNetworkUrlsChange={setNetworkUrls} onNetworkUrlsChange={setNetworkUrls}
@@ -366,6 +671,51 @@ export function SkuImageEditorModal({
</section> </section>
</div> </div>
</Modal> </Modal>
<SkuWatermarkPickerModal
open={open && watermarkTargets.length > 0}
onCancel={() => setWatermarkTargets([])}
onApply={applyWatermark}
/>
<SkuSingleAiEditDrawer
open={open && !!aiTarget}
productId={productId}
source={aiTarget}
referenceOptions={[
...images,
...sourceImages('source_main'),
...sourceImages('source_sku'),
...sourceImages('source_detail'),
...networkImages,
...uploadedImages,
]}
onClose={() => setAiTarget(null)}
onResolveAsset={onResolveAsset}
onGenerated={refreshAiImages}
onApply={(source, task) => {
const asset = task.output_asset;
if (!asset) return false;
const output: SkuEditorImage = {
id: `asset:${asset.id}`, url: asset.preview_url,
label: `${source.label} · AI 编辑`, origin: 'derivative', assetId: asset.id,
};
if (images.some((image) => sameImage(image, output))) {
message.info('这张 AI 生成图已在当前 SKU 已选图片中');
return false;
}
if (images.length >= MAX_IMAGES) {
message.warning(`每个 SKU 最多选择 ${MAX_IMAGES} 张图片`);
return false;
}
setImages((current) => {
const index = current.findIndex((image) => sameImage(image, source));
const next = [...current];
next.splice(index < 0 ? next.length : index + 1, 0, output);
return next;
});
message.success('AI 生成图已加入当前 SKU,请确认选图后保存页面');
return true;
}}
/>
<Image.PreviewGroup <Image.PreviewGroup
items={preview?.images.map((image) => image.url) ?? []} items={preview?.images.map((image) => image.url) ?? []}
preview={{ preview={{
@@ -16,6 +16,8 @@ type Props = {
onTabChange: (tab: string) => void; onTabChange: (tab: string) => void;
sourceImages: (role: string) => SkuEditorImage[]; sourceImages: (role: string) => SkuEditorImage[];
renderImages: (images: SkuEditorImage[]) => ReactNode; renderImages: (images: SkuEditorImage[]) => ReactNode;
aiImages: SkuEditorImage[];
renderProcessedImages: () => ReactNode;
networkImages: SkuEditorImage[]; networkImages: SkuEditorImage[];
networkUrls: string; networkUrls: string;
onNetworkUrlsChange: (value: string) => void; onNetworkUrlsChange: (value: string) => void;
@@ -32,6 +34,8 @@ export function SkuImageSourceTabs({
onTabChange, onTabChange,
sourceImages, sourceImages,
renderImages, renderImages,
aiImages,
renderProcessedImages,
networkImages, networkImages,
networkUrls, networkUrls,
onNetworkUrlsChange, onNetworkUrlsChange,
@@ -50,12 +54,17 @@ export function SkuImageSourceTabs({
key: 'ai', key: 'ai',
label: 'AI 生图记录', label: 'AI 生图记录',
children: ( children: (
<Empty aiImages.length ? renderImages(aiImages) : <Empty
image={Empty.PRESENTED_IMAGE_SIMPLE} image={Empty.PRESENTED_IMAGE_SIMPLE}
description="暂无 AI 生图记录" description="暂无 AI 生图记录"
/> />
), ),
}, },
{
key: 'processed',
label: '图片处理记录',
children: renderProcessedImages(),
},
{ {
key: 'collection', key: 'collection',
label: '采集图片', label: '采集图片',
@@ -0,0 +1,39 @@
.sku-single-ai-drawer .ant-drawer-body { padding: 20px 24px; }
.sku-single-ai-layout { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 20px; }
.sku-single-ai-media, .sku-single-ai-settings { min-width: 0; }
.sku-single-ai-section { margin-bottom: 16px; padding: 16px; border: 1px solid #e8e7ee; border-radius: 10px; background: #fff; }
.sku-single-ai-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-bottom: 12px; }
.sku-single-ai-head .ant-typography { font-size: 12px; }
.sku-single-ai-reference-grid { display: grid; grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); grid-template-rows: repeat(2, minmax(0, 1fr)); gap: 9px; aspect-ratio: 5 / 4; }
.sku-single-ai-reference { position: relative; min-width: 0; min-height: 0; overflow: hidden; border: 1px dashed #d8d4e7; border-radius: 8px; background: #f7f6fa; }
.sku-single-ai-reference:first-child { grid-column: 1; grid-row: 1 / 3; }
.sku-single-ai-reference:nth-child(2) { grid-column: 2; grid-row: 1; }
.sku-single-ai-reference:nth-child(3) { grid-column: 2; grid-row: 2; }
.sku-single-ai-reference img { display: block; width: 100%; height: 100%; object-fit: contain; }
.sku-single-ai-reference > span { position: absolute; left: 0; right: 0; bottom: 0; padding: 5px; background: #292733b8; color: #fff; font-size: 11px; text-align: center; }
.sku-single-ai-reference > .ant-btn { position: absolute; top: 5px; right: 5px; }
.sku-single-ai-reference > button:not(.ant-btn) { width: 100%; height: 100%; border: 0; background: transparent; color: #7165d8; cursor: pointer; }
.sku-single-ai-reference > .sku-single-ai-change { position: absolute; right: 0; bottom: 25px; left: 0; height: 27px !important; background: #ffffffd9 !important; }
.sku-single-ai-result { display: flex; align-items: center; justify-content: center; width: 100%; max-width: 390px; margin-inline: auto; overflow: hidden; border-radius: 8px; background: #f5f4f8; }
.sku-single-ai-result .ant-image, .sku-single-ai-result .ant-image-img { width: 100%; height: 100%; object-fit: contain; }
.sku-single-ai-empty { display: flex; flex-direction: column; align-items: center; gap: 8px; color: #8b8798; text-align: center; }
.sku-single-ai-empty strong { color: #494556; }
.sku-single-ai-sparkle { color: #7465e6; font-size: 36px; }
.sku-single-ai-styles { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 7px; }
.sku-single-ai-styles button { min-height: 34px; border: 1px solid #e1deeb; border-radius: 6px; background: #fff; cursor: pointer; }
.sku-single-ai-styles button.selected { border-color: #7062e9; background: #f2f0ff; color: #6254dc; }
.sku-single-ai-styles + .ant-btn, .sku-single-ai-section > .ant-btn-link { padding-left: 0; }
.sku-single-ai-dimensions { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)) 78px; gap: 6px; margin-bottom: 8px; }
.sku-single-ai-dimensions .ant-input-number { width: 100%; }
.sku-single-ai-footer { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
.sku-single-ai-footer .ant-typography { font-size: 12px; }
.sku-single-ai-footer > div { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 8px; }
.sku-single-ai-picker { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 10px; max-height: 450px; overflow-y: auto; }
.sku-single-ai-picker button { display: flex; flex-direction: column; gap: 5px; min-width: 0; padding: 4px; border: 1px solid #e7e4ed; border-radius: 6px; background: #fff; cursor: pointer; }
.sku-single-ai-picker img { width: 100%; aspect-ratio: 3 / 4; object-fit: contain; }
.sku-single-ai-picker span { overflow: hidden; font-size: 12px; text-overflow: ellipsis; white-space: nowrap; }
@media (max-width: 760px) {
.sku-single-ai-layout { grid-template-columns: 1fr; }
.sku-single-ai-footer { align-items: flex-start; flex-direction: column; }
.sku-single-ai-footer > div { width: 100%; }
}
@@ -0,0 +1,237 @@
import { useEffect, useMemo, useState } from 'react';
import { Alert, App as AntdApp, Button, Checkbox, Drawer, Image, Input, InputNumber, Modal, Radio, Select, Spin, Switch, Typography } from 'antd';
import { DeleteOutlined, PlusOutlined } from '@ant-design/icons';
import type { CreateMediaAiEditRequest, MediaAiEdit } from '@atlas/contracts';
import { apiErrorMessage } from '@/services/api';
import { mediaAiEditFailureDiagnosis, mediaAiEditFailureMessage, mediaApi, notifyProductImageTaskSubmitted } from '@/services/media';
import type { SkuEditorImage } from './SkuImageCard';
import './SkuSingleAiEditDrawer.css';
const { Text } = Typography;
const styles = [
{ value: 1, label: '北欧极简', prompt: 'Nordic minimalist e-commerce style: warm ivory, soft daylight, natural texture and spacious composition.' },
{ value: 2, label: '清新明亮', prompt: 'Fresh bright e-commerce style: clean high-key daylight, natural colors and an airy retail composition.' },
{ value: 3, label: '高级感深色', prompt: 'Premium dark editorial style: charcoal setting, sculpted side lighting, realistic material texture.' },
{ value: 4, label: '暖调生活', prompt: 'Warm lifestyle style: believable cream home setting, balanced golden and window light, product as the hero.' },
{ value: 5, label: '纯净棚拍', prompt: 'Clean studio style: neutral seamless background, even softbox light, crisp edges and no decorative props.' },
];
type Settings = Required<CreateMediaAiEditRequest>;
type Ratio = Settings['ratio'];
type Dimensions = NonNullable<Settings['dimensions']>;
type Props = {
open: boolean;
productId: string;
source: SkuEditorImage | null;
referenceOptions: SkuEditorImage[];
onClose: () => void;
onResolveAsset: (image: SkuEditorImage) => Promise<string>;
onApply?: (source: SkuEditorImage, result: MediaAiEdit) => boolean;
onGenerated: () => Promise<void>;
footerHint?: string;
};
export function SkuSingleAiEditDrawer({ open, productId, source, referenceOptions, onClose, onResolveAsset, onApply, onGenerated, footerHint }: Props) {
const { message } = AntdApp.useApp();
const [references, setReferences] = useState<(SkuEditorImage | null)[]>([null, null]);
const [choosingSlot, setChoosingSlot] = useState<number | null>(null);
const [prompt, setPrompt] = useState('');
const [ratio, setRatio] = useState<Ratio>('3:4');
const [translateText, setTranslateText] = useState(false);
const [language, setLanguage] = useState<Settings['target_language']>('ru');
const [background, setBackground] = useState<Settings['background']>('auto');
const [backgroundDescription, setBackgroundDescription] = useState('');
const [styleSet, setStyleSet] = useState(2);
const [stylePrompts, setStylePrompts] = useState<Record<number, string>>({});
const [showStylePrompt, setShowStylePrompt] = useState(false);
const [dimensions, setDimensions] = useState<Dimensions>({ unit: 'cm' });
const [sizeChartOverride, setSizeChartOverride] = useState<boolean | null>(null);
const [taskId, setTaskId] = useState<string | null>(null);
const [generating, setGenerating] = useState(false);
const [result, setResult] = useState<MediaAiEdit | null>(null);
const [error, setError] = useState('');
const [resultRatio, setResultRatio] = useState<Ratio>('3:4');
const [appliedId, setAppliedId] = useState<string | null>(null);
const hasDimensions = [dimensions.length, dimensions.width, dimensions.height]
.some((value) => typeof value === 'number' && value > 0);
const generateSizeChart = hasDimensions && (sizeChartOverride ?? true);
const stylePrompt = stylePrompts[styleSet] ?? styles.find((item) => item.value === styleSet)?.prompt ?? '';
const choices = useMemo(() => Array.from(new Map(referenceOptions
.filter((image) => image.url && image.id !== source?.id
&& !(image.assetId && source?.assetId && image.assetId === source.assetId))
.map((image) => [image.assetId ?? image.id, image])).values()), [referenceOptions, source]);
useEffect(() => {
if (!open || !source) return;
setReferences([null, null]);
setChoosingSlot(null);
setPrompt('');
setRatio('3:4');
setTranslateText(false);
setLanguage('ru');
setBackground('auto');
setBackgroundDescription('');
setStyleSet(2);
setStylePrompts({});
setDimensions({ unit: 'cm' });
setSizeChartOverride(null);
setResult(null);
setError('');
setAppliedId(null);
setTaskId(null);
}, [source?.id, open]);
useEffect(() => {
if (!taskId) return;
let active = true;
let timer: number | undefined;
let failures = 0;
const poll = async () => {
try {
const current = await mediaApi.readAiEdit(taskId);
if (!active) return;
failures = 0;
setError('');
if (current.status === 'succeeded' && current.output_asset) {
setResult(current);
setGenerating(false);
setTaskId(null);
void onGenerated().catch(() => undefined);
message.success('单张图片生成完成');
return;
}
if (current.status === 'failed' || current.status === 'cancelled') {
setError(current.status === 'cancelled' ? '任务已取消'
: `${mediaAiEditFailureMessage(current.error_code)} ${mediaAiEditFailureDiagnosis(current)}`);
setGenerating(false);
setTaskId(null);
return;
}
timer = window.setTimeout(() => void poll(), 3000);
} catch {
if (!active) return;
failures += 1;
if (failures >= 5) {
setError('查询任务进度暂时失败,正在继续重试;如需退出可先取消任务。');
}
timer = window.setTimeout(() => void poll(), Math.min(3000 * (2 ** Math.min(failures, 4)), 15000));
}
};
void poll();
return () => { active = false; if (timer) window.clearTimeout(timer); };
}, [taskId, message, onGenerated]);
const generate = async () => {
if (!source || generating) return;
if (background === 'reference' && !references.some(Boolean)) {
message.warning('请选择一张辅助参考图作为背景参考');
return;
}
setGenerating(true);
setResult(null);
setError('');
setAppliedId(null);
setResultRatio(ratio);
try {
const [sourceId, ...refIds] = await Promise.all([
onResolveAsset(source),
...references.flatMap((image) => image ? [onResolveAsset(image)] : []),
]);
const created = await mediaApi.createAiEdit(sourceId, {
product_id: productId, reference_asset_ids: refIds, prompt: prompt.trim(), mode: 'edit',
ratio,
translate_text: translateText,
target_language: language, background, background_description: backgroundDescription.trim(),
style_set: styleSet, style_prompt: stylePrompt,
dimensions: hasDimensions ? dimensions : undefined,
generate_size_chart: generateSizeChart,
});
setTaskId(created.id);
notifyProductImageTaskSubmitted(productId);
} catch (cause) {
setError(apiErrorMessage(cause));
setGenerating(false);
}
};
const cancelTask = async () => {
if (!taskId) return;
try {
await mediaApi.cancelAiEdit(taskId);
setTaskId(null);
setGenerating(false);
setError('任务已取消');
} catch (cause) { message.error(apiErrorMessage(cause)); }
};
const close = () => {
if (generating && taskId) message.info('任务会继续运行,可在底部操作栏的图片任务查看进度');
onClose();
};
const chooseReference = (image: SkuEditorImage) => {
if (choosingSlot === null) return;
setReferences((current) => current.map((item, index) => index === choosingSlot
? image : item?.id === image.id ? null : item));
setChoosingSlot(null);
};
return <>
<Drawer className="sku-single-ai-drawer" title="AI编辑"
placement="right" size="min(1040px, 100vw)" zIndex={1100} open={open} onClose={close}
footer={<div className="sku-single-ai-footer"><Text type="secondary">{footerHint ?? '生成图将作为独立素材,应用后仍需确认选图并保存页面'}</Text><div>
{generating && taskId && <Button danger onClick={() => void cancelTask()}>取消任务</Button>}
<Button onClick={close}>关闭</Button>
{onApply && result?.output_asset && <Button onClick={() => {
if (!source) return;
if (onApply(source, result)) setAppliedId(result.id);
}} disabled={appliedId === result.id}>{appliedId === result.id ? '已添加到当前 SKU' : '添加到当前 SKU'}</Button>}
<Button type="primary" loading={generating} disabled={!source} onClick={() => void generate()}>{result ? '重新生成 1 张' : '生成 1 张'}</Button>
</div></div>}>
<div className="sku-single-ai-layout">
<div className="sku-single-ai-media">
<section className="sku-single-ai-section"><div className="sku-single-ai-head"><strong>主图与辅助参考</strong><Text type="secondary">主图固定 · 辅图最多 2 张</Text></div>
<div className="sku-single-ai-reference-grid">
<div className="sku-single-ai-reference"><img src={source?.url} alt={source?.label ?? '主图'} /><span>主图 · 编辑底图</span></div>
{references.map((image, index) => <div className="sku-single-ai-reference" key={index}>
{image ? <><img src={image.url} alt={image.label} /><span>辅图 {index + 1}</span><Button size="small" icon={<DeleteOutlined />} title="移除辅图" disabled={generating} onClick={() => setReferences((current) => current.map((item, slot) => slot === index ? null : item))} /><button type="button" className="sku-single-ai-change" disabled={generating} onClick={() => setChoosingSlot(index)}>更换</button></>
: <button type="button" disabled={generating} onClick={() => setChoosingSlot(index)}><PlusOutlined /> 添加辅图 {index + 1}</button>}
</div>)}
</div>
</section>
<section className="sku-single-ai-section"><div className="sku-single-ai-head"><strong>AI 生成结果</strong><Text type="secondary">{resultRatio}</Text></div>
<div className="sku-single-ai-result" style={{ aspectRatio: resultRatio.replace(':', ' / ') }}>
{result?.output_asset ? <Image src={result.output_asset.preview_url} alt="AI 生成结果" />
: generating ? <div className="sku-single-ai-empty"><Spin size="large" /><strong>正在生成图片</strong><span>完成后会显示在这里</span></div>
: <div className="sku-single-ai-empty"><span className="sku-single-ai-sparkle">✦</span><strong>生成的图片会显示在这里</strong><span>原图会保留</span></div>}
</div>
{error && <Alert style={{ marginTop: 10 }} type="error" showIcon message={error} />}
</section>
</div>
<div className="sku-single-ai-settings">
<section className="sku-single-ai-section"><div className="sku-single-ai-head"><strong>编辑要求</strong></div><Input.TextArea rows={4} value={prompt} disabled={generating} onChange={(event) => setPrompt(event.target.value)} placeholder="描述要改什么,例如:保留商品外观,换成温暖的客厅背景" /></section>
<section className="sku-single-ai-section"><div className="sku-single-ai-head"><strong>图中文字翻译</strong><Switch checked={translateText} disabled={generating} onChange={setTranslateText} /></div>
{translateText && <><Select value={language} disabled={generating} onChange={setLanguage} options={[{ value: 'ru', label: '俄文(默认)' }, { value: 'ar', label: '阿拉伯语' }, { value: 'en', label: '英语' }]} style={{ width: '100%' }} /><Text type="secondary">生成时识别并翻译图片文案,重新排版</Text></>}
</section>
<section className="sku-single-ai-section"><div className="sku-single-ai-head"><strong>背景</strong></div>
<Select value={background} disabled={generating} onChange={setBackground} style={{ width: '100%' }} options={[{ value: 'auto', label: '保持原背景 / 按描述' }, { value: 'white', label: '纯白背景' }, { value: 'solid', label: '指定纯色' }, { value: 'scene', label: '更换场景' }, { value: 'reference', label: '参考首张辅图背景' }]} />
{['solid', 'scene', 'reference'].includes(background) && <Input style={{ marginTop: 9 }} value={backgroundDescription} disabled={generating} onChange={(event) => setBackgroundDescription(event.target.value)} placeholder={background === 'solid' ? '例如:浅米色' : '补充背景要求(可选)'} />}
</section>
<section className="sku-single-ai-section"><div className="sku-single-ai-head"><strong>视觉风格</strong></div>
<div className="sku-single-ai-styles">{styles.map((item) => <button key={item.value} type="button" className={styleSet === item.value ? 'selected' : ''} disabled={generating} onClick={() => setStyleSet(item.value)}>{item.label}</button>)}</div>
<Button type="link" size="small" onClick={() => setShowStylePrompt(!showStylePrompt)}>{showStylePrompt ? '收起风格提示词' : '查看 / 修改风格提示词'}</Button>
{showStylePrompt && <Input.TextArea rows={4} value={stylePrompt} disabled={generating} onChange={(event) => setStylePrompts((current) => ({ ...current, [styleSet]: event.target.value }))} />}
</section>
<section className="sku-single-ai-section"><div className="sku-single-ai-head"><strong>商品尺寸</strong><Text type="secondary">默认 cm</Text></div>
<div className="sku-single-ai-dimensions">{(['length', 'width', 'height'] as const).map((key, index) => <InputNumber key={key} min={0.01} precision={2} controls={false} disabled={generating} value={dimensions[key]} placeholder={['长', '宽', '高'][index]} onChange={(value) => setDimensions((current) => ({ ...current, [key]: typeof value === 'number' && value > 0 ? value : undefined }))} />)}
<Select value={dimensions.unit ?? 'cm'} disabled={generating} onChange={(unit: 'mm' | 'cm') => setDimensions((current) => ({ ...current, unit }))} options={[{ value: 'cm', label: 'cm' }, { value: 'mm', label: 'mm' }]} /></div>
{hasDimensions && <Checkbox checked={generateSizeChart} disabled={generating} onChange={(event) => setSizeChartOverride(event.target.checked)}>生成尺寸标注图</Checkbox>}
<Text type="secondary">填写尺寸后默认生成标注图;只标注已填写的方向</Text>
</section>
<section className="sku-single-ai-section"><div className="sku-single-ai-head"><strong>画面比例</strong></div>
<Radio.Group optionType="button" buttonStyle="solid" value={ratio} disabled={generating} onChange={(event) => setRatio(event.target.value as Ratio)} options={['3:4', '1:1', '4:5']} />
</section>
</div>
</div>
</Drawer>
<Modal title={`选择辅助参考图 ${choosingSlot === null ? '' : choosingSlot + 1}`} open={choosingSlot !== null} zIndex={1200} footer={null} onCancel={() => setChoosingSlot(null)}>
<div className="sku-single-ai-picker">{choices.length ? choices.map((image) => <button type="button" key={image.id} onClick={() => chooseReference(image)}><img src={image.url} alt={image.label} /><span>{image.label}</span></button>) : <Text type="secondary">暂无其他商品图片可选</Text>}</div>
</Modal>
</>;
}
@@ -0,0 +1,137 @@
.sku-watermark-picker-modal {
max-width: calc(100vw - 32px);
}
.sku-watermark-picker-modal .ant-modal-body {
padding: 0 24px 20px;
}
.sku-watermark-picker-toolbar {
display: flex;
align-items: center;
justify-content: flex-start;
gap: 12px;
margin-bottom: 16px;
}
.sku-watermark-picker-toolbar strong {
font-size: 16px;
}
.sku-watermark-picker-scroll {
max-height: min(440px, 56dvh);
overflow-y: auto;
}
.sku-watermark-picker-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 12px;
}
.sku-watermark-picker-card {
display: flex;
min-width: 0;
min-height: 112px;
align-items: center;
gap: 12px;
padding: 12px;
border: 1px solid #e5e3eb;
border-radius: 8px;
background: #fff;
color: inherit;
cursor: pointer;
text-align: left;
}
.sku-watermark-picker-card:hover,
.sku-watermark-picker-card.is-selected {
border-color: #7062e9;
}
.sku-watermark-picker-card.is-selected {
background: #f7f6ff;
box-shadow: 0 0 0 2px rgb(112 98 233 / 12%);
}
.sku-watermark-picker-card:focus-visible {
outline: 2px solid #7062e9;
outline-offset: 2px;
}
.sku-watermark-picker-card img,
.sku-watermark-picker-image-empty {
width: 64px;
height: 76px;
flex: 0 0 64px;
border-radius: 4px;
object-fit: contain;
}
.sku-watermark-picker-image-empty {
display: grid;
place-items: center;
background: #f4f2f7;
color: #a29da9;
}
.sku-watermark-picker-card-content {
display: flex;
min-width: 0;
flex: 1;
flex-direction: column;
gap: 8px;
}
.sku-watermark-picker-card-heading {
display: flex;
min-width: 0;
align-items: center;
gap: 4px;
}
.sku-watermark-picker-card-heading strong,
.sku-watermark-picker-detail {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.sku-watermark-picker-card-heading strong {
flex: 1;
}
.sku-watermark-picker-card-heading .ant-tag {
flex: 0 0 auto;
margin-inline-end: 0;
}
.sku-watermark-picker-detail {
color: #777384;
font-size: 12px;
}
.sku-watermark-picker-loading {
display: grid;
min-height: 120px;
place-items: center;
}
.sku-watermark-picker-more {
margin-top: 16px;
text-align: center;
}
.sku-watermark-picker-unavailable {
margin: 12px 0 0;
color: #8d879e;
font-size: 12px;
}
@media (max-width: 720px) {
.sku-watermark-picker-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
@media (max-width: 480px) {
.sku-watermark-picker-grid { grid-template-columns: 1fr; }
}
@@ -0,0 +1,165 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { App as AntdApp, Button, Empty, Modal, Spin, Tag } from 'antd';
import { AppstoreOutlined } from '@ant-design/icons';
import type { WatermarkTemplate } from '@atlas/contracts';
import { watermarkPositionLabel } from '@/components/WatermarkPreview';
import { apiErrorMessage } from '@/services/api';
import { mediaApi } from '@/services/media';
import { watermarksApi } from '@/services/watermarks';
import './SkuWatermarkPickerModal.css';
type Props = {
open: boolean;
onCancel: () => void;
onApply: (template: WatermarkTemplate) => Promise<void>;
};
/** 选模板只保存模板 ID;短时预览地址只用于弹窗展示。 */
export function SkuWatermarkPickerModal({ open, onCancel, onApply }: Props) {
const { message } = AntdApp.useApp();
const requestVersion = useRef(0);
const [templates, setTemplates] = useState<WatermarkTemplate[]>([]);
const [previewUrls, setPreviewUrls] = useState<Record<string, string>>({});
const [selectedId, setSelectedId] = useState<string | null>(null);
const [nextCursor, setNextCursor] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [loadingMore, setLoadingMore] = useState(false);
const [applying, setApplying] = useState(false);
const loadPage = useCallback(async (cursor: string | undefined, version: number) => {
if (cursor) setLoadingMore(true);
else setLoading(true);
try {
const [result, defaultTemplate] = await Promise.all([
watermarksApi.list(cursor),
cursor ? Promise.resolve(null) : watermarksApi.getDefault(),
]);
if (requestVersion.current !== version) return;
const page = defaultTemplate && !result.data.some((template) => template.id === defaultTemplate.id)
? [defaultTemplate, ...result.data] : result.data;
setTemplates((current) => cursor
? [...current, ...page.filter((template) => !current.some((item) => item.id === template.id))]
: page);
if (!cursor) setSelectedId(defaultTemplate?.id ?? null);
setNextCursor(result.meta.has_more ? result.meta.next_cursor : null);
const missing = page.filter((template) => template.kind === 'image'
&& template.config.image_asset_id && !template.config.image_asset?.preview_url);
const previews = await Promise.allSettled(missing.map((template) =>
mediaApi.createPreviewUrl(template.config.image_asset_id!)));
if (requestVersion.current !== version) return;
const urls: Record<string, string> = {};
previews.forEach((preview, index) => {
if (preview.status === 'fulfilled') urls[missing[index].id] = preview.value.url;
});
setPreviewUrls((current) => cursor ? { ...current, ...urls } : urls);
} catch (error) {
if (requestVersion.current === version) message.error(`读取水印模板失败:${apiErrorMessage(error)}`);
} finally {
if (requestVersion.current === version) {
setLoading(false);
setLoadingMore(false);
}
}
}, [message]);
useEffect(() => {
if (!open) return;
const version = ++requestVersion.current;
setTemplates([]);
setPreviewUrls({});
setSelectedId(null);
setNextCursor(null);
void loadPage(undefined, version);
return () => { requestVersion.current++; };
}, [open, loadPage]);
const selected = templates.find((template) => template.id === selectedId);
const apply = async () => {
if (!selected || applying) return;
setApplying(true);
try {
await onApply(selected);
onCancel();
} catch (error) {
message.error(`应用水印失败:${apiErrorMessage(error)}`);
} finally {
setApplying(false);
}
};
return (
<Modal
className="sku-watermark-picker-modal"
open={open}
title="选择水印"
width={820}
onCancel={onCancel}
closable={!applying}
maskClosable={!applying}
keyboard={!applying}
footer={[
<Button key="cancel" onClick={onCancel} disabled={applying}>取消</Button>,
<Button key="apply" type="primary" disabled={!selected} loading={applying} onClick={() => void apply()}>
应用水印
</Button>,
]}
centered
>
<div className="sku-watermark-picker-toolbar">
<Button icon={<AppstoreOutlined />} href="/watermarks" target="_blank" rel="noopener noreferrer">
水印管理
</Button>
</div>
<div className="sku-watermark-picker-scroll">
{loading ? <div className="sku-watermark-picker-loading"><Spin /></div> : templates.length ? (
<div className="sku-watermark-picker-grid">
{templates.map((template) => {
const previewUrl = template.config.image_asset?.preview_url ?? previewUrls[template.id];
return (
<button
key={template.id}
className={`sku-watermark-picker-card${selectedId === template.id ? ' is-selected' : ''}`}
type="button"
aria-pressed={selectedId === template.id}
onClick={() => setSelectedId(template.id)}
>
{template.kind === 'image' && (
previewUrl ? <img src={previewUrl} alt="" />
: <span className="sku-watermark-picker-image-empty">图片</span>
)}
<span className="sku-watermark-picker-card-content">
<span className="sku-watermark-picker-card-heading">
<strong title={template.name}>{template.name}</strong>
<Tag color={template.kind === 'image' ? 'green' : 'blue'}>
{template.kind === 'image' ? '图片' : '文字'}
</Tag>
{template.is_default && <Tag color="gold">默认</Tag>}
</span>
{template.kind === 'text' && (
<span className="sku-watermark-picker-detail" title={template.config.text_content}>
内容:{template.config.text_content}
</span>
)}
<span className="sku-watermark-picker-detail">
{watermarkPositionLabel(template.config.position)}
{template.kind === 'text'
? ` ${template.config.font_code ?? ''}, ${template.config.font_size_px ?? ''}px`
: ''}
</span>
</span>
</button>
);
})}
</div>
) : <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="暂无水印模板,可前往水印管理创建" />}
{nextCursor && (
<div className="sku-watermark-picker-more">
<Button loading={loadingMore} onClick={() => void loadPage(nextCursor, requestVersion.current)}>
加载更多
</Button>
</div>
)}
</div>
</Modal>
);
}
+146 -90
View File
@@ -33,8 +33,11 @@ import type {
WatermarkTemplateConfig, WatermarkTemplateConfig,
} from '@atlas/contracts'; } from '@atlas/contracts';
import WatermarkPreview, { import WatermarkPreview, {
WATERMARK_BORDER_RADIUS_MAX_PX,
WATERMARK_IMAGE_SIZE_DEFAULT_PX,
WATERMARK_IMAGE_SIZE_MAX_PX,
WATERMARK_IMAGE_SIZE_MIN_PX,
WATERMARK_POSITION_OPTIONS, WATERMARK_POSITION_OPTIONS,
watermarkPositionLabel,
} from '@/components/WatermarkPreview'; } from '@/components/WatermarkPreview';
import { apiErrorDetails, apiErrorMessage } from '@/services/api'; import { apiErrorDetails, apiErrorMessage } from '@/services/api';
import { watermarksApi } from '@/services/watermarks'; import { watermarksApi } from '@/services/watermarks';
@@ -45,9 +48,11 @@ import {
WATERMARK_IMAGE_MAX_BYTES, WATERMARK_IMAGE_MAX_BYTES,
} from '@/services/media'; } from '@/services/media';
type WatermarkPositionValue = (typeof WATERMARK_POSITION_OPTIONS)[number]['value'];
type WatermarkKind = 'text' | 'image'; type WatermarkKind = 'text' | 'image';
const DEFAULT_FONT_SIZE_PX = 15;
const DEFAULT_OPACITY_PERCENT = 26;
interface WatermarkFormValues { interface WatermarkFormValues {
name: string; name: string;
kind: WatermarkKind; kind: WatermarkKind;
@@ -55,8 +60,10 @@ interface WatermarkFormValues {
font_code: string; font_code: string;
font_size_px: number; font_size_px: number;
opacityPercent: number; opacityPercent: number;
scalePercent: number; image_size_px: number;
position: WatermarkPositionValue; border_radius_px: number;
position_x: number;
position_y: number;
} }
type KindFilter = 'all' | 'text' | 'image'; type KindFilter = 'all' | 'text' | 'image';
@@ -71,11 +78,6 @@ const percentToOpacity = (percent: number): string => {
return value === 0 ? '0' : value === 1 ? '1' : String(Number(value.toFixed(4))); return value === 0 ? '0' : value === 1 ? '1' : String(Number(value.toFixed(4)));
}; };
const percentToScale = (percent: number): string => {
const value = percent / 100;
return value === 2 ? '2' : String(Number(value.toFixed(4)));
};
const formatDateTime = (value: string): string => const formatDateTime = (value: string): string =>
new Date(value).toLocaleString('zh-CN', { hour12: false }); new Date(value).toLocaleString('zh-CN', { hour12: false });
@@ -91,6 +93,7 @@ const WatermarksPage = () => {
const [nextCursor, setNextCursor] = useState<string | null>(null); const [nextCursor, setNextCursor] = useState<string | null>(null);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [loadingMore, setLoadingMore] = useState(false); const [loadingMore, setLoadingMore] = useState(false);
const [settingDefaultId, setSettingDefaultId] = useState<string | null>(null);
const [fonts, setFonts] = useState<Array<{ code: string; display_name: string }>>([]); const [fonts, setFonts] = useState<Array<{ code: string; display_name: string }>>([]);
@@ -214,10 +217,12 @@ const WatermarksPage = () => {
kind: 'text', kind: 'text',
text_content: '', text_content: '',
font_code: fonts[0]?.code ?? '', font_code: fonts[0]?.code ?? '',
font_size_px: 24, font_size_px: DEFAULT_FONT_SIZE_PX,
opacityPercent: 80, opacityPercent: DEFAULT_OPACITY_PERCENT,
scalePercent: 25, image_size_px: WATERMARK_IMAGE_SIZE_DEFAULT_PX,
position: 'bottom_right', border_radius_px: 0,
position_x: 1,
position_y: 1,
}; };
form.setFieldsValue(initial); form.setFieldsValue(initial);
setPreviewValues(initial); setPreviewValues(initial);
@@ -227,6 +232,7 @@ const WatermarksPage = () => {
const openEdit = (template: WatermarkTemplate) => { const openEdit = (template: WatermarkTemplate) => {
setEditing(template); setEditing(template);
const config = template.config; const config = template.config;
const legacyPosition = WATERMARK_POSITION_OPTIONS.find((item) => item.value === config.position);
const assetId = config.image_asset?.id ?? config.image_asset_id ?? null; const assetId = config.image_asset?.id ?? config.image_asset_id ?? null;
setImageAsset( setImageAsset(
template.kind === 'image' && assetId template.kind === 'image' && assetId
@@ -238,10 +244,12 @@ const WatermarksPage = () => {
kind: template.kind, kind: template.kind,
text_content: config.text_content ?? '', text_content: config.text_content ?? '',
font_code: config.font_code ?? fonts[0]?.code ?? '', font_code: config.font_code ?? fonts[0]?.code ?? '',
font_size_px: config.font_size_px ?? 24, font_size_px: config.font_size_px ?? DEFAULT_FONT_SIZE_PX,
opacityPercent: Math.round(Number(config.opacity ?? '0.8') * 100), opacityPercent: Math.round(Number(config.opacity ?? '0.26') * 100),
scalePercent: Math.round(Number(config.scale_ratio ?? '0.25') * 100), image_size_px: config.image_size_px ?? WATERMARK_IMAGE_SIZE_DEFAULT_PX,
position: config.position, border_radius_px: config.border_radius_px ?? 0,
position_x: config.position_x ?? (legacyPosition ? legacyPosition.col / 2 : 1),
position_y: config.position_y ?? (legacyPosition ? legacyPosition.row / 2 : 1),
}; };
form.setFieldsValue(initial); form.setFieldsValue(initial);
setPreviewValues(initial); setPreviewValues(initial);
@@ -319,8 +327,9 @@ const WatermarksPage = () => {
setSaving(true); setSaving(true);
try { try {
// 滑杆字段在类型切换后可能缺值,统一兜底默认值,避免 NaN // 滑杆字段在类型切换后可能缺值,统一兜底默认值,避免 NaN
const opacity = percentToOpacity(values.opacityPercent ?? 80); const opacity = percentToOpacity(values.opacityPercent ?? DEFAULT_OPACITY_PERCENT);
const scaleRatio = percentToScale(values.scalePercent ?? 25); const imageSize = values.image_size_px ?? WATERMARK_IMAGE_SIZE_DEFAULT_PX;
const borderRadius = values.border_radius_px ?? 0;
// 编辑时只提交有变化的字段:渲染参数未变时仅改名, // 编辑时只提交有变化的字段:渲染参数未变时仅改名,
// 服务端不会创建新的渲染版本(docs/api/watermarks-and-assets.md §3.3); // 服务端不会创建新的渲染版本(docs/api/watermarks-and-assets.md §3.3);
// 切换类型必须提交新类型的完整 config。 // 切换类型必须提交新类型的完整 config。
@@ -335,14 +344,17 @@ const WatermarksPage = () => {
font_code: values.font_code, font_code: values.font_code,
font_size_px: values.font_size_px, font_size_px: values.font_size_px,
opacity, opacity,
position: values.position, position_x: values.position_x,
position_y: values.position_y,
}; };
} else { } else {
config = { config = {
image_asset_id: imageAsset?.id ?? '', image_asset_id: imageAsset?.id ?? '',
scale_ratio: scaleRatio, image_size_px: imageSize,
border_radius_px: borderRadius,
opacity, opacity,
position: values.position, position_x: values.position_x,
position_y: values.position_y,
}; };
} }
payload = { kind: values.kind, config }; payload = { kind: values.kind, config };
@@ -368,9 +380,8 @@ const WatermarksPage = () => {
if (Number(opacity) !== Number(current.opacity)) { if (Number(opacity) !== Number(current.opacity)) {
configPatch.opacity = opacity; configPatch.opacity = opacity;
} }
if (values.position !== current.position) { if (values.position_x !== current.position_x) configPatch.position_x = values.position_x;
configPatch.position = values.position; if (values.position_y !== current.position_y) configPatch.position_y = values.position_y;
}
if (Object.keys(configPatch).length > 0) { if (Object.keys(configPatch).length > 0) {
diff.config = configPatch; diff.config = configPatch;
} }
@@ -386,15 +397,13 @@ const WatermarksPage = () => {
if (imageAsset && imageAsset.id !== currentAssetId) { if (imageAsset && imageAsset.id !== currentAssetId) {
configPatch.image_asset_id = imageAsset.id; configPatch.image_asset_id = imageAsset.id;
} }
if (percentToScale(values.scalePercent) !== current.scale_ratio) { if (imageSize !== current.image_size_px) configPatch.image_size_px = imageSize;
configPatch.scale_ratio = scaleRatio; if (borderRadius !== current.border_radius_px) configPatch.border_radius_px = borderRadius;
}
if (Number(opacity) !== Number(current.opacity)) { if (Number(opacity) !== Number(current.opacity)) {
configPatch.opacity = opacity; configPatch.opacity = opacity;
} }
if (values.position !== current.position) { if (values.position_x !== current.position_x) configPatch.position_x = values.position_x;
configPatch.position = values.position; if (values.position_y !== current.position_y) configPatch.position_y = values.position_y;
}
if (Object.keys(configPatch).length > 0) { if (Object.keys(configPatch).length > 0) {
diff.config = configPatch; diff.config = configPatch;
} }
@@ -419,7 +428,8 @@ const WatermarksPage = () => {
font_code: values.font_code, font_code: values.font_code,
font_size_px: values.font_size_px, font_size_px: values.font_size_px,
opacity, opacity,
position: values.position, position_x: values.position_x,
position_y: values.position_y,
}, },
}); });
applyTemplate(template); applyTemplate(template);
@@ -430,9 +440,11 @@ const WatermarksPage = () => {
kind: 'image', kind: 'image',
config: { config: {
image_asset_id: imageAsset?.id ?? '', image_asset_id: imageAsset?.id ?? '',
scale_ratio: scaleRatio, image_size_px: imageSize,
border_radius_px: borderRadius,
opacity, opacity,
position: values.position, position_x: values.position_x,
position_y: values.position_y,
}, },
}); });
applyTemplate(template); applyTemplate(template);
@@ -451,8 +463,8 @@ const WatermarksPage = () => {
'font_code', 'font_code',
'font_size_px', 'font_size_px',
'opacityPercent', 'opacityPercent',
'scalePercent', 'image_size_px',
'position', 'border_radius_px',
]; ];
if (details.length > 0) { if (details.length > 0) {
const fieldErrors = []; const fieldErrors = [];
@@ -550,6 +562,21 @@ const WatermarksPage = () => {
}); });
}; };
const setDefaultTemplate = async (templateId: string) => {
setSettingDefaultId(templateId);
try {
const nextDefault = await watermarksApi.setDefault(templateId);
setTemplates((current) => current.map((template) => template.id === templateId
? nextDefault
: template.is_default ? { ...template, is_default: false } : template));
message.success('已设为默认水印');
} catch (error) {
message.error(`设置默认水印失败:${apiErrorMessage(error)}`);
} finally {
setSettingDefaultId(null);
}
};
const columns: TableProps<WatermarkTemplate>['columns'] = [ const columns: TableProps<WatermarkTemplate>['columns'] = [
{ {
title: '序号', title: '序号',
@@ -562,6 +589,12 @@ const WatermarksPage = () => {
dataIndex: 'name', dataIndex: 'name',
width: 220, width: 220,
ellipsis: true, ellipsis: true,
render: (name: string, template) => (
<Space size={4}>
<span>{name}</span>
{template.is_default && <Tag color="gold">默认</Tag>}
</Space>
),
}, },
{ {
title: '水印类型', title: '水印类型',
@@ -605,8 +638,8 @@ const WatermarksPage = () => {
{ {
title: '水印位置', title: '水印位置',
key: 'position', key: 'position',
width: 110, width: 160,
render: (_, template) => watermarkPositionLabel(template.config.position), render: (_, template) => `横 ${Math.round(template.config.position_x * 100)}% · 纵 ${Math.round(template.config.position_y * 100)}%`,
}, },
{ {
title: '版本', title: '版本',
@@ -625,9 +658,18 @@ const WatermarksPage = () => {
{ {
title: '操作', title: '操作',
key: 'actions', key: 'actions',
width: 190, width: 320,
render: (_, template) => ( render: (_, template) => (
<Space size={4}> <Space size={4}>
<Button
type="link"
size="small"
disabled={template.is_default || settingDefaultId !== null}
loading={settingDefaultId === template.id}
onClick={() => void setDefaultTemplate(template.id)}
>
{template.is_default ? '默认水印' : '设为默认水印'}
</Button>
<Button type="link" size="small" onClick={() => void openPreview(template)}> <Button type="link" size="small" onClick={() => void openPreview(template)}>
查看效果 查看效果
</Button> </Button>
@@ -647,6 +689,11 @@ const WatermarksPage = () => {
}, },
]; ];
const handlePreviewPositionChange = (position: { position_x: number; position_y: number }) => {
form.setFieldsValue(position);
setPreviewValues((current) => current ? { ...current, ...position } : current);
};
const previewConfig = useMemo<Partial<WatermarkTemplateConfig>>(() => { const previewConfig = useMemo<Partial<WatermarkTemplateConfig>>(() => {
if (!previewValues) return {}; if (!previewValues) return {};
if (previewValues.kind === 'image') { if (previewValues.kind === 'image') {
@@ -656,9 +703,11 @@ const WatermarksPage = () => {
preview_url: imageAsset?.previewUrl ?? '', preview_url: imageAsset?.previewUrl ?? '',
preview_url_expires_at: '', preview_url_expires_at: '',
}, },
scale_ratio: percentToScale(previewValues.scalePercent), image_size_px: previewValues.image_size_px,
border_radius_px: previewValues.border_radius_px,
opacity: percentToOpacity(previewValues.opacityPercent), opacity: percentToOpacity(previewValues.opacityPercent),
position: previewValues.position, position_x: previewValues.position_x,
position_y: previewValues.position_y,
}; };
} }
return { return {
@@ -666,7 +715,8 @@ const WatermarksPage = () => {
font_code: previewValues.font_code, font_code: previewValues.font_code,
font_size_px: previewValues.font_size_px, font_size_px: previewValues.font_size_px,
opacity: percentToOpacity(previewValues.opacityPercent), opacity: percentToOpacity(previewValues.opacityPercent),
position: previewValues.position, position_x: previewValues.position_x,
position_y: previewValues.position_y,
}; };
}, [previewValues, imageAsset]); }, [previewValues, imageAsset]);
@@ -788,10 +838,22 @@ const WatermarksPage = () => {
> >
<Form<WatermarkFormValues> <Form<WatermarkFormValues>
form={form} form={form}
layout="vertical" className="watermark-editor-form"
layout="horizontal"
labelCol={{ flex: '88px' }}
wrapperCol={{ flex: 'auto' }}
onValuesChange={(_, values) => setPreviewValues(values as WatermarkFormValues)} onValuesChange={(_, values) => setPreviewValues(values as WatermarkFormValues)}
onFinish={handleSave} onFinish={handleSave}
> >
<Form.Item name="kind" hidden>
<Input />
</Form.Item>
<Form.Item name="position_x" hidden>
<Input />
</Form.Item>
<Form.Item name="position_y" hidden>
<Input />
</Form.Item>
<Form.Item <Form.Item
name="name" name="name"
label="模板名称" label="模板名称"
@@ -850,15 +912,24 @@ const WatermarksPage = () => {
5MB 5MB
</Typography.Paragraph> </Typography.Paragraph>
<Form.Item <Form.Item
name="scalePercent" name="image_size_px"
label="图片缩放" label="图片大小"
preserve={false} extra={`预览最长边 ${previewValues?.image_size_px ?? WATERMARK_IMAGE_SIZE_DEFAULT_PX}px;应用到商品图时等比换算`}
> >
<Slider <Slider
min={10} min={WATERMARK_IMAGE_SIZE_MIN_PX}
max={200} max={WATERMARK_IMAGE_SIZE_MAX_PX}
step={1} step={1}
tooltip={{ formatter: (value) => `${value ?? 0}%` }} tooltip={{ formatter: (value) => `${value ?? 0}px` }}
/>
</Form.Item>
<Form.Item name="border_radius_px" label="图片圆角">
<InputNumber
min={0}
max={WATERMARK_BORDER_RADIUS_MAX_PX}
precision={0}
addonAfter="px"
style={{ width: 150 }}
/> />
</Form.Item> </Form.Item>
</> </>
@@ -878,11 +949,12 @@ const WatermarksPage = () => {
> >
<Input maxLength={200} placeholder="例如:Atlas Store" /> <Input maxLength={200} placeholder="例如:Atlas Store" />
</Form.Item> </Form.Item>
<div className="watermark-editor-pair">
<div>
<Form.Item <Form.Item
name="font_code" name="font_code"
label="字体" label="字体"
required required
preserve={false}
> >
<Select <Select
placeholder="选择字体" placeholder="选择字体"
@@ -893,9 +965,12 @@ const WatermarksPage = () => {
}))} }))}
/> />
</Form.Item> </Form.Item>
</div>
<div>
<Form.Item <Form.Item
name="font_size_px" name="font_size_px"
label="字号(px)" label="字号(px)"
labelCol={{ flex: '136px' }}
required required
rules={[{ required: true, message: '请输入字号' }]} rules={[{ required: true, message: '请输入字号' }]}
> >
@@ -903,9 +978,11 @@ const WatermarksPage = () => {
min={12} min={12}
max={60} max={60}
precision={0} precision={0}
style={{ width: 140 }} style={{ width: 96 }}
/> />
</Form.Item> </Form.Item>
</div>
</div>
</> </>
) )
} }
@@ -920,14 +997,14 @@ const WatermarksPage = () => {
/> />
</Form.Item> </Form.Item>
<Form.Item label="水印位置" required> <Form.Item
<Form.Item name="position" noStyle> label="预览效果"
<PositionGrid /> extra="拖动水印可放在任意位置;方向键可微调"
</Form.Item> >
</Form.Item> <WatermarkPreview
config={previewConfig}
<Form.Item label="预览效果"> onPositionChange={handlePreviewPositionChange}
<WatermarkPreview config={previewConfig} /> />
</Form.Item> </Form.Item>
<div className="watermark-form-actions"> <div className="watermark-form-actions">
@@ -943,11 +1020,19 @@ const WatermarksPage = () => {
title={previewing ? `查看效果:${previewing.name}` : '查看效果'} title={previewing ? `查看效果:${previewing.name}` : '查看效果'}
open={!!previewing} open={!!previewing}
onCancel={() => setPreviewing(null)} onCancel={() => setPreviewing(null)}
footer={null} footer={
<div className="watermark-preview-dialog-footer">
<Button type="primary" onClick={() => setPreviewing(null)}>关闭</Button>
</div>
}
width={420} width={420}
destroyOnHidden destroyOnHidden
> >
{previewing && <WatermarkPreview config={previewing.config} />} {previewing && (
<div className="watermark-preview-dialog-content">
<WatermarkPreview config={previewing.config} />
</div>
)}
</Modal> </Modal>
</div> </div>
); );
@@ -958,33 +1043,4 @@ const dayjsOrNull = (value: string) => {
return parsed.isValid() ? parsed : null; return parsed.isValid() ? parsed : null;
}; };
/** 3x3 水印位置九宫格选择器:选中格高亮,旁边显示位置名称 */
const PositionGrid = ({
value,
onChange,
}: {
value?: WatermarkPositionValue;
onChange?: (value: WatermarkPositionValue) => void;
}) => {
const selected = WATERMARK_POSITION_OPTIONS.find((option) => option.value === value);
return (
<Space size={12} align="center">
<div className="position-grid">
{WATERMARK_POSITION_OPTIONS.map((option) => (
<button
key={option.value}
type="button"
className={`position-grid-cell${option.value === value ? ' active' : ''}`}
aria-label={option.label}
onClick={() => onChange?.(option.value)}
/>
))}
</div>
<Typography.Text type="secondary">{positionGridLabel(selected?.label)}</Typography.Text>
</Space>
);
};
const positionGridLabel = (label?: string) => label ?? '请选择位置';
export default WatermarksPage; export default WatermarksPage;
+105
View File
@@ -6,6 +6,12 @@ import type {
ProductUploadImage, ProductUploadImage,
ProductImportedImage, ProductImportedImage,
ImportProductImageRequest, ImportProductImageRequest,
AspectRatioDerivative,
WatermarkDerivative,
CreateMediaAiEditRequest,
MediaAiEdit,
ProductImageTask,
ProductImageDerivativeRecord,
} from '@atlas/contracts'; } from '@atlas/contracts';
import { api } from '@/services/api'; import { api } from '@/services/api';
@@ -15,6 +21,32 @@ export const WATERMARK_IMAGE_MAX_BYTES = 5 * 1024 * 1024;
/** 异步操作查询,契约见 docs/api/conventions.md §5(GET /api/operations/{operation_id}) */ /** 异步操作查询,契约见 docs/api/conventions.md §5(GET /api/operations/{operation_id}) */
export const mediaApi = { export const mediaApi = {
createAiEdit: (assetId: string, payload: CreateMediaAiEditRequest) =>
api.post<MediaAiEdit>(`/media-assets/${assetId}/ai-edits`, payload,
{ headers: { 'Idempotency-Key': crypto.randomUUID() } }),
readAiEdit: (id: string) => api.get<MediaAiEdit>(`/media-ai-edits/${id}`),
listAiEdits: (productId: string) => api.get<MediaAiEdit[]>(`/media-ai-edits?product_id=${encodeURIComponent(productId)}`),
listProductImageDerivatives: (productId: string) =>
api.get<ProductImageDerivativeRecord[]>(`/products/${encodeURIComponent(productId)}/image-derivatives`),
listProductImageTasks: (productId: string) =>
api.getList<ProductImageTask>(`/products/${encodeURIComponent(productId)}/image-tasks?limit=100`)
.then((response) => response.data),
cancelAiEdit: (id: string) => api.post<{ id: string; status: MediaAiEdit['status'] }>(
`/media-ai-edits/${id}/cancel`, {}, { headers: { 'Idempotency-Key': crypto.randomUUID() } }),
createThreeFour: (assetId: string, productId: string) =>
api.post<AspectRatioDerivative>(
`/media-assets/${assetId}/aspect-ratio-derivatives`,
{ preset: '3:4', fit: 'contain', product_id: productId },
{ headers: { 'Idempotency-Key': crypto.randomUUID() } }
),
readDerivative: (derivativeId: string) =>
api.get<AspectRatioDerivative>(`/media-derivatives/${derivativeId}`),
createWatermarkDerivative: (assetId: string, templateId: string, productId: string) =>
api.post<WatermarkDerivative>(`/media-assets/${assetId}/watermark-derivatives`,
{ watermark_template_id: templateId, product_id: productId },
{ headers: { 'Idempotency-Key': crypto.randomUUID() } }),
readWatermarkDerivative: (derivativeId: string) =>
api.get<WatermarkDerivative>(`/media-watermark-derivatives/${derivativeId}`),
listProductUploads: () => api.get<ProductUploadImage[]>('/media-assets/product-uploads'), listProductUploads: () => api.get<ProductUploadImage[]>('/media-assets/product-uploads'),
listProductImports: () => api.get<ProductImportedImage[]>('/media-assets/product-imports'), listProductImports: () => api.get<ProductImportedImage[]>('/media-assets/product-imports'),
importProductImage: (payload: ImportProductImageRequest) => importProductImage: (payload: ImportProductImageRequest) =>
@@ -40,8 +72,81 @@ export const mediaApi = {
{ purpose: 'preview' }, { purpose: 'preview' },
{ headers: { 'Idempotency-Key': crypto.randomUUID() } } { headers: { 'Idempotency-Key': crypto.randomUUID() } }
), ),
createDownloadUrl: (assetId: string) =>
api.post<MediaAccessUrl>(
`/media-assets/${assetId}/access-urls`,
{ purpose: 'download' },
{ headers: { 'Idempotency-Key': crypto.randomUUID() } }
),
}; };
/** 图片任务提交后通知商品页刷新右下角任务列表。 */
export function notifyProductImageTaskSubmitted(productId: string): void {
window.dispatchEvent(new CustomEvent('atlas:product-image-task-submitted', { detail: { productId } }));
}
/** 图片编辑错误码对应可操作的提示,不向前端透传供应商原始响应。 */
export function mediaAiEditFailureMessage(code: string | null): string {
if (code === 'AI_IMAGE_CONTENT_REJECTED')
return 'Pcore 未能完成这次生图;可能是输入或生成结果被审核拦截。可尝试其他图片或调整编辑要求。';
if (code === 'AI_IMAGE_PROVIDER_FAILED')
return 'Pcore 图片任务失败,请稍后重试。';
return '图片生成失败,请重试。';
}
const aiEditFailureStageLabels = {
task_setup: '任务初始化', input_preparation: '读取与准备原图',
provider_submission: '提交 Pcore 生图请求', provider_polling: '查询 Pcore 生图结果',
result_download: '获取生成图片', result_processing: 'Atlas 处理生成图片',
result_storage: 'Atlas 保存生成图片',
} as const;
export function mediaAiEditFailureDiagnosis(failure: {
failure_source: 'pcore' | 'atlas' | null;
failure_stage: keyof typeof aiEditFailureStageLabels | null;
failure_detail: string | null;
}): string {
if (!failure.failure_source && !failure.failure_stage) return '失败阶段未记录(历史任务)';
const source = failure.failure_source === 'pcore' ? 'Pcore 接口' : 'Atlas 自身处理';
const stage = failure.failure_stage ? aiEditFailureStageLabels[failure.failure_stage] : '未知步骤';
return `失败来源:${source} · 失败步骤:${stage}${failure.failure_detail ? ` · 详情:${failure.failure_detail}` : ''}`;
}
/** 下载地址由对象存储返回 attachment 响应头,跨域图片也会保存为文件。 */
export async function downloadMediaAsset(assetId: string): Promise<void> {
const access = await mediaApi.createDownloadUrl(assetId);
const link = document.createElement('a');
link.href = access.url;
link.rel = 'noopener noreferrer';
document.body.appendChild(link);
link.click();
link.remove();
}
/** 派生任务可能跨越多次 Worker 重试;页面只使用成功返回的正式素材 ID。 */
export async function createThreeFourImage(assetId: string, productId: string): Promise<AspectRatioDerivative> {
const created = await mediaApi.createThreeFour(assetId, productId);
for (let attempt = 0; attempt < 120; attempt += 1) {
const current = await mediaApi.readDerivative(created.id);
if (current.status === 'succeeded' && current.output_asset) return current;
if (current.status === 'failed') throw new Error('图片处理失败,请重试');
await new Promise<void>((resolve) => window.setTimeout(resolve, 1_500));
}
throw new Error('图片仍在处理中,请稍后重试');
}
/** 应用水印创建独立素材,调用方在选图弹窗中替换预览。 */
export async function createWatermarkedImage(assetId: string, templateId: string, productId: string): Promise<WatermarkDerivative> {
const created = await mediaApi.createWatermarkDerivative(assetId, templateId, productId);
for (let attempt = 0; attempt < 120; attempt += 1) {
const current = await mediaApi.readWatermarkDerivative(created.id);
if (current.status === 'succeeded' && current.output_asset) return current;
if (current.status === 'failed') throw new Error('水印处理失败,请重试');
await new Promise<void>((resolve) => window.setTimeout(resolve, 1_500));
}
throw new Error('水印仍在处理中,请稍后重试');
}
/** 浏览器端计算文件的 SHA-256 十六进制摘要(complete 校验用) */ /** 浏览器端计算文件的 SHA-256 十六进制摘要(complete 校验用) */
export async function sha256Hex(file: File): Promise<string> { export async function sha256Hex(file: File): Promise<string> {
const digest = await crypto.subtle.digest('SHA-256', await file.arrayBuffer()); const digest = await crypto.subtle.digest('SHA-256', await file.arrayBuffer());
+59
View File
@@ -0,0 +1,59 @@
import type {
CursorPaginationMeta,
OnlineProduct,
OnlineProductSyncRun,
OnlineProductsMeta,
} from '@atlas/contracts';
import { api } from './api';
export type OnlineProductFilters = {
q: string;
visibility: 'all' | 'visible' | 'invisible' | 'unknown';
source?: 'atlas_published' | 'external_sync';
moderation_status?: string;
};
export const onlineProductsApi = {
list: (platformId: string, storeId: string, filters: OnlineProductFilters, cursor?: string) =>
api.getList<OnlineProduct, OnlineProductsMeta>('/platform-listings', {
headers: { 'X-Atlas-Platform-Id': platformId },
params: {
store_id: storeId,
limit: 20,
...(cursor ? { cursor } : {}),
...(filters.q.trim() ? { q: filters.q.trim() } : {}),
visibility: filters.visibility,
...(filters.source ? { source: filters.source } : {}),
...(filters.moderation_status
? { moderation_status: filters.moderation_status }
: {}),
},
}),
get: (platformId: string, listingId: string) =>
api.get<OnlineProduct>(`/platform-listings/${listingId}`, {
headers: { 'X-Atlas-Platform-Id': platformId },
}),
createSync: (platformId: string, storeId: string) =>
api.post<{ sync_run: OnlineProductSyncRun }>(
`/stores/${storeId}/platform-product-sync-runs`,
{ reason: 'manual_refresh' },
{
headers: {
'X-Atlas-Platform-Id': platformId,
'Idempotency-Key': crypto.randomUUID(),
},
}
),
getSyncRun: (platformId: string, runId: string) =>
api.get<OnlineProductSyncRun>(`/platform-product-sync-runs/${runId}`, {
headers: { 'X-Atlas-Platform-Id': platformId },
}),
listSyncRuns: (platformId: string, storeId: string, cursor?: string) =>
api.getList<OnlineProductSyncRun, CursorPaginationMeta>(
`/stores/${storeId}/platform-product-sync-runs`,
{
headers: { 'X-Atlas-Platform-Id': platformId },
params: { limit: 20, ...(cursor ? { cursor } : {}) },
}
),
};
+7
View File
@@ -32,6 +32,13 @@ export const watermarksApi = {
get: (templateId: string) => api.get<WatermarkTemplate>(`/watermark-templates/${templateId}`), get: (templateId: string) => api.get<WatermarkTemplate>(`/watermark-templates/${templateId}`),
getDefault: () => api.get<WatermarkTemplate | null>('/watermark-templates/default'),
setDefault: (templateId: string) =>
api.put<WatermarkTemplate>(`/watermark-templates/${templateId}/default`, {}, {
headers: { 'Idempotency-Key': crypto.randomUUID() },
}),
create: (payload: CreateWatermarkTemplateRequest) => create: (payload: CreateWatermarkTemplateRequest) =>
api.post<WatermarkTemplate>('/watermark-templates', payload, { api.post<WatermarkTemplate>('/watermark-templates', payload, {
headers: { 'Idempotency-Key': crypto.randomUUID() }, headers: { 'Idempotency-Key': crypto.randomUUID() },
+2 -1
View File
@@ -15,7 +15,8 @@
"@atlas/database": "workspace:*", "@atlas/database": "workspace:*",
"@aws-sdk/client-s3": "3.1015.0", "@aws-sdk/client-s3": "3.1015.0",
"bullmq": "6.3.4", "bullmq": "6.3.4",
"ioredis": "6.0.0" "ioredis": "6.0.0",
"sharp": "0.35.4"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "26.3.0", "@types/node": "26.3.0",
+33
View File
@@ -29,6 +29,10 @@ import {
syncPlatformCatalog, syncPlatformCatalog,
} from './platform-catalog-sync.js'; } from './platform-catalog-sync.js';
import { runProductAiTask } from './product-ai.js'; import { runProductAiTask } from './product-ai.js';
import { markAspectRatioDerivativeFailed, runAspectRatioDerivative } from './media-aspect-ratio.js';
import { markWatermarkDerivativeFailed, runWatermarkDerivative } from './media-watermark.js';
import { classifyAiEditFailure, markMediaAiEditFailed, runMediaAiEdit } from './media-ai-edit.js';
import { archiveExpiredMissingOnlineProducts, markOnlineProductSyncFailed, runOnlineProductSync, type OnlineProductSyncJob } from './online-product-sync.js';
const connection = new Redis(workerConfig.redisUrl, { const connection = new Redis(workerConfig.redisUrl, {
maxRetriesPerRequest: null, maxRetriesPerRequest: null,
@@ -82,6 +86,18 @@ const worker = new Worker<OutboxJob | PlatformCatalogJob>(
await syncPlatformCatalog(database, job.data as PlatformCatalogJob); await syncPlatformCatalog(database, job.data as PlatformCatalogJob);
} else if (job.name === 'product_ai_task.requested') { } else if (job.name === 'product_ai_task.requested') {
await runProductAiTask(database, job.data as OutboxJob); await runProductAiTask(database, job.data as OutboxJob);
} else if (job.name === 'media.aspect_ratio_derivative.requested') {
await runAspectRatioDerivative(
database,
objectStorage,
job.data as OutboxJob,
);
} else if (job.name === 'media.watermark_derivative.requested') {
await runWatermarkDerivative(database, objectStorage, job.data as OutboxJob);
} else if (job.name === 'media.ai_edit.requested') {
await runMediaAiEdit(database, objectStorage, job.data as OutboxJob);
} else if (job.name === 'platform_product_sync.requested') {
await runOnlineProductSync(database, job.data as OnlineProductSyncJob);
} else if (job.name === 'store_credential_verification.requested') { } else if (job.name === 'store_credential_verification.requested') {
await verifyStoreCredentials( await verifyStoreCredentials(
database, database,
@@ -160,6 +176,14 @@ worker.on('failed', (job, error) => {
database, database,
job.data as PlatformCatalogJob, job.data as PlatformCatalogJob,
); );
} else if (job.name === 'media.aspect_ratio_derivative.requested') {
void markAspectRatioDerivativeFailed(database, job.data as OutboxJob);
} else if (job.name === 'media.watermark_derivative.requested') {
void markWatermarkDerivativeFailed(database, job.data as OutboxJob);
} else if (job.name === 'media.ai_edit.requested') {
void markMediaAiEditFailed(database, job.data as OutboxJob, classifyAiEditFailure(error));
} else if (job.name === 'platform_product_sync.requested') {
void markOnlineProductSyncFailed(database, job.data as OnlineProductSyncJob, error);
} else if (job.name === 'store_credential_verification.requested') { } else if (job.name === 'store_credential_verification.requested') {
void markUnavailable(database, job.data as StoreVerificationJob, error); void markUnavailable(database, job.data as StoreVerificationJob, error);
} else if ( } else if (
@@ -376,6 +400,7 @@ async function markUnavailable(
let dispatcherTimer: ReturnType<typeof setInterval> | undefined; let dispatcherTimer: ReturnType<typeof setInterval> | undefined;
let mediaCleanupTimer: ReturnType<typeof setInterval> | undefined; let mediaCleanupTimer: ReturnType<typeof setInterval> | undefined;
let platformCatalogTimer: ReturnType<typeof setInterval> | undefined; let platformCatalogTimer: ReturnType<typeof setInterval> | undefined;
let onlineProductArchiveTimer: ReturnType<typeof setInterval> | undefined;
async function bootstrap() { async function bootstrap() {
await queue.waitUntilReady(); await queue.waitUntilReady();
await platformQueue.waitUntilReady(); await platformQueue.waitUntilReady();
@@ -384,6 +409,7 @@ async function bootstrap() {
await dispatcher.dispatchBatch(); await dispatcher.dispatchBatch();
await platformDispatcher.dispatchBatch(); await platformDispatcher.dispatchBatch();
await platformScheduler.run(); await platformScheduler.run();
await archiveExpiredMissingOnlineProducts(database);
dispatcherTimer = setInterval(() => { dispatcherTimer = setInterval(() => {
void dispatcher.dispatchBatch().catch((error: unknown) => { void dispatcher.dispatchBatch().catch((error: unknown) => {
console.error( console.error(
@@ -408,6 +434,12 @@ async function bootstrap() {
}), }),
60_000, 60_000,
); );
onlineProductArchiveTimer = setInterval(
() => void archiveExpiredMissingOnlineProducts(database).catch((error: unknown) => {
console.error('online_product_archive_failed', error instanceof Error ? error.message : error);
}),
60 * 60_000,
);
void mediaUploadCleanup.run().catch((error: unknown) => { void mediaUploadCleanup.run().catch((error: unknown) => {
console.error( console.error(
'media_upload_cleanup_startup_failed', 'media_upload_cleanup_startup_failed',
@@ -439,6 +471,7 @@ async function shutdown() {
if (dispatcherTimer) clearInterval(dispatcherTimer); if (dispatcherTimer) clearInterval(dispatcherTimer);
if (mediaCleanupTimer) clearInterval(mediaCleanupTimer); if (mediaCleanupTimer) clearInterval(mediaCleanupTimer);
if (platformCatalogTimer) clearInterval(platformCatalogTimer); if (platformCatalogTimer) clearInterval(platformCatalogTimer);
if (onlineProductArchiveTimer) clearInterval(onlineProductArchiveTimer);
await worker.close(); await worker.close();
await platformWorker.close(); await platformWorker.close();
await queue.close(); await queue.close();
+456
View File
@@ -0,0 +1,456 @@
import { createHash, randomUUID } from 'node:crypto';
import { lookup } from 'node:dns/promises';
import { request as httpsRequest } from 'node:https';
import { isIP, type LookupFunction } from 'node:net';
import { GetObjectCommand, PutObjectCommand, type S3Client } from '@aws-sdk/client-s3';
import type { MediaAiEditFailureStage, MediaAiEditSettings } from '@atlas/contracts';
import type { Database } from '@atlas/database';
import sharp from 'sharp';
import { buildMediaAiPrompt } from './media-ai-prompt.js';
import type { OutboxJob } from './outbox-dispatcher.js';
type StoredAsset = { id: string; storage_provider: string; bucket: string; object_key: string };
type EditTask = {
status: 'queued' | 'running' | 'succeeded' | 'failed' | 'cancelled';
source_asset_id: string;
reference_asset_ids: string[];
settings: MediaAiEditSettings;
provider_task_id: string | null;
created_at: Date | string;
};
const sizes: Record<MediaAiEditSettings['ratio'], [number, number]> = {
'3:4': [1152, 1536], '1:1': [1024, 1024], '4:5': [1280, 1600],
};
type AiEditFailureCode = 'AI_IMAGE_GENERATION_FAILED' | 'AI_IMAGE_CONTENT_REJECTED' | 'AI_IMAGE_PROVIDER_FAILED';
export type AiEditFailure = {
code: AiEditFailureCode;
source: 'pcore' | 'atlas';
stage: MediaAiEditFailureStage;
detail: string;
providerErrorRaw: string | null;
};
class AiEditStageError extends Error {
public constructor(public readonly stage: MediaAiEditFailureStage, public readonly cause: unknown) {
super(cause instanceof Error ? cause.message : '未知错误');
}
}
async function atStage<T>(stage: MediaAiEditFailureStage, action: () => Promise<T>): Promise<T> {
try { return await action(); }
catch (error) { throw error instanceof AiEditStageError ? error : new AiEditStageError(stage, error); }
}
/** 只保存可供排查的短摘要,避免把密钥或带签名的地址写入任务结果。 */
function safeFailureDetail(error: unknown, stage: MediaAiEditFailureStage): string {
const message = error instanceof Error ? error.message : '未知错误';
if (stage === 'task_setup' || stage === 'input_preparation' || stage === 'result_storage') {
if (['图片对象不存在', '源图片超过处理上限', '参考图片格式不受支持',
'参考图片素材不存在'].includes(message)) return message;
if (error instanceof PcoreResponseError) return message.slice(0, 500);
const code = error && typeof error === 'object' && 'code' in error && typeof error.code === 'string'
&& /^[A-Z0-9_]{2,32}$/i.test(error.code) ? `(${error.code})` : '';
return `${error instanceof Error ? error.name : 'Error'}${code}`;
}
return message.replace(/Bearer\s+\S+/gi, 'Bearer [已隐藏]')
.replace(/https?:\/\/[^\s)]+/gi, '[地址已隐藏]')
.replace(/(?:api[_-]?key|token|secret)\s*[:=]\s*\S+/gi, '[凭据已隐藏]')
.replace(/\bai-edits\/[^\s)]+/gi, '[对象键已隐藏]')
.slice(0, 500);
}
export function classifyAiEditFailure(error: unknown): AiEditFailure {
const stage = error instanceof AiEditStageError ? error.stage : 'task_setup';
const cause = error instanceof AiEditStageError ? error.cause : error;
const providerError = cause instanceof PcoreResponseError ? cause : null;
const transportFailure = (stage === 'provider_submission' || stage === 'provider_polling')
&& cause instanceof Error && ['TypeError', 'TimeoutError', 'AbortError'].includes(cause.name);
return {
code: 'AI_IMAGE_GENERATION_FAILED',
source: providerError || transportFailure ? 'pcore' : 'atlas',
stage,
detail: safeFailureDetail(cause, stage),
providerErrorRaw: providerError?.rawResponse ?? null,
};
}
export class PcoreResponseError extends Error {
public readonly rawResponse: string | null;
public constructor(message: string, rawResponse: string | null) {
super(message);
this.rawResponse = rawResponse ? rawResponse.slice(0, 32_768) : null;
}
}
class PcoreTerminalError extends PcoreResponseError {
public constructor(public readonly errorCode: AiEditFailureCode, rawResponse: string | null) {
super(errorCode === 'AI_IMAGE_CONTENT_REJECTED'
? 'Pcore 未能完成这次生图,可能是输入或生成结果被审核拦截'
: 'Pcore 图片任务失败', rawResponse);
}
}
function pcoreErrorText(value: unknown): string {
if (typeof value === 'string') return value;
if (!value || typeof value !== 'object') return '';
const error = value as { code?: unknown; message?: unknown };
return [error.code, error.message].filter((item) => typeof item === 'string').join(' ');
}
function pcoreFailureCode(value: unknown): AiEditFailureCode {
if (!value || typeof value !== 'object') return 'AI_IMAGE_PROVIDER_FAILED';
const error = value as { code?: unknown; type?: unknown };
const explicitCode = [error.code, error.type].filter((item) => typeof item === 'string').join(' ');
return /safety|content[_-]?policy|moderation|blocked/i.test(explicitCode)
? 'AI_IMAGE_CONTENT_REJECTED' : 'AI_IMAGE_PROVIDER_FAILED';
}
function publicIpv4(address: string): boolean {
if (isIP(address) !== 4) return false;
const [a, b, c] = address.split('.').map(Number);
return !(a === 0 || a === 10 || a === 127 || a >= 224
|| a === 100 && b >= 64 && b <= 127
|| a === 169 && b === 254
|| a === 172 && b >= 16 && b <= 31
|| a === 192 && (b === 168 || b === 0 && (c === 0 || c === 2))
|| a === 198 && (b === 18 || b === 19 || b === 51 && c === 100)
|| a === 203 && b === 0 && c === 113);
}
async function readObject(storage: S3Client, asset: StoredAsset): Promise<Buffer> {
const response = await storage.send(new GetObjectCommand({ Bucket: asset.bucket, Key: asset.object_key }));
if (!response.Body) throw new Error('图片对象不存在');
const chunks: Buffer[] = [];
let bytes = 0;
for await (const chunk of response.Body as AsyncIterable<Uint8Array>) {
bytes += chunk.byteLength;
if (bytes > 20 * 1024 * 1024) throw new Error('源图片超过处理上限');
chunks.push(Buffer.from(chunk));
}
return Buffer.concat(chunks, bytes);
}
/** 普通编辑向供应商传原始图片字节,避免转码改变参考图内容。 */
async function referenceImageDataUri(bytes: Buffer): Promise<string> {
const image = sharp(bytes, { limitInputPixels: 100_000_000, failOn: 'error' });
const meta = await image.metadata();
if (!['jpeg', 'png', 'webp'].includes(meta.format ?? '') || !meta.width || !meta.height)
throw new Error('参考图片格式不受支持');
const mime = meta.format === 'jpeg' ? 'image/jpeg' : `image/${meta.format}`;
return `data:${mime};base64,${bytes.toString('base64')}`;
}
async function outpaintInputs(bytes: Buffer): Promise<{ image: Buffer; mask: Buffer; original: Buffer; left: number; top: number }> {
const [width, height] = sizes['3:4'];
const original = await sharp(bytes, { limitInputPixels: 100_000_000, failOn: 'error' })
.rotate().resize(width, height, { fit: 'inside', withoutEnlargement: false })
.ensureAlpha().png().toBuffer();
const meta = await sharp(original).metadata();
const left = Math.floor((width - meta.width!) / 2);
const top = Math.floor((height - meta.height!) / 2);
const canvas = await sharp({ create: { width, height, channels: 4, background: '#00000000' } })
.composite([{ input: original, left, top }]).png().toBuffer();
const opaque = await sharp({ create: { width: meta.width!, height: meta.height!, channels: 4, background: '#ffffffff' } })
.png().toBuffer();
const mask = await sharp({ create: { width, height, channels: 4, background: '#00000000' } })
.composite([{ input: opaque, left, top }]).png().toBuffer();
return {
image: canvas,
mask,
original, left, top,
};
}
function providerConfig(): { base: string; key: string } {
const key = process.env.PCORE_API_KEY;
const rawBase = process.env.PCORE_BASE_URL?.trim();
if (!key || !rawBase) throw new Error('PCORE_API_KEY 和 PCORE_BASE_URL 必须配置');
const url = new URL(rawBase);
const path = url.pathname.replace(/\/+$/, '');
if (url.protocol !== 'https:' || url.username || url.password || url.port
|| !['', '/v1'].includes(path) || url.search || url.hash)
throw new Error('PCORE_BASE_URL 必须是 HTTPS 根地址或 /v1 地址');
return { base: `${url.origin}/v1`, key };
}
async function providerRequest(url: string, key: string, body?: Record<string, unknown>): Promise<Record<string, unknown>> {
const response = await fetch(url, {
method: body ? 'POST' : 'GET',
headers: { Authorization: `Bearer ${key}`, ...(body ? { 'Content-Type': 'application/json' } : {}) },
body: body ? JSON.stringify(body) : undefined,
signal: AbortSignal.timeout(body ? 90_000 : 35_000),
redirect: 'error',
});
if (!response.ok) throw new PcoreResponseError(
`Pcore 图片任务请求失败(HTTP ${response.status})`, await response.text());
let result: Record<string, unknown>;
try {
result = await response.json() as Record<string, unknown>;
if (!result || typeof result !== 'object' || Array.isArray(result)) throw new Error('invalid response');
} catch {
throw new PcoreResponseError('Pcore 返回的图片任务响应不是有效 JSON 对象', null);
}
if (result.error && (body || !['failed', 'error', 'cancelled'].includes(String(result.status ?? ''))))
throw new PcoreResponseError(`Pcore 图片任务请求被拒绝${pcoreErrorText(result.error)
? `(${pcoreErrorText(result.error).slice(0, 200)})` : ''}`,
JSON.stringify(result.error));
return result;
}
async function submitProvider(storage: S3Client, assets: StoredAsset[], settings: MediaAiEditSettings): Promise<string> {
const provider = await atStage('provider_submission', async () => providerConfig());
const size = sizes[settings.mode === 'outpaint' ? '3:4' : settings.ratio];
const main = await atStage('input_preparation', () => readObject(storage, assets[0]));
const outpaint = settings.mode === 'outpaint'
? await atStage('input_preparation', () => outpaintInputs(main)) : null;
const images = await atStage('input_preparation', async () => outpaint
? [`data:image/png;base64,${outpaint.image.toString('base64')}`]
: await Promise.all(assets.map(async (asset, index) =>
referenceImageDataUri(index === 0 ? main : await readObject(storage, asset)))));
const body: Record<string, unknown> = {
model: 'gpt-image-2', prompt: buildMediaAiPrompt(settings), n: 1,
size: `${size[0]}x${size[1]}`, async: true,
images: images.map((image_url) => ({ image_url })),
};
if (outpaint) body.mask = { image_url: `data:image/png;base64,${outpaint.mask.toString('base64')}` };
const result = await atStage('provider_submission', () =>
providerRequest(`${provider.base}/images/edits`, provider.key, body));
const id = result.task_id ?? result.id;
if (typeof id !== 'string' || !id || id.length > 256)
throw new AiEditStageError('provider_submission', new PcoreResponseError('Pcore 未返回有效的任务 ID', null));
return id;
}
export async function downloadProviderImage(urlValue: string): Promise<Buffer> {
const url = new URL(urlValue);
if (url.protocol !== 'https:' || url.username || url.password || url.port || isIP(url.hostname))
throw new Error('Pcore 结果地址不安全');
const addresses = await lookup(url.hostname, { all: true, family: 4 });
if (!addresses.length || addresses.some((item) => !publicIpv4(item.address)))
throw new Error('Pcore 结果地址不安全');
/** 固定到校验过的公网 IPv4,同时保留域名用于 TLS 证书校验。 */
const response = await new Promise<import('node:http').IncomingMessage>((resolve, reject) => {
const request = httpsRequest(url, {
lookup: pinnedLookup(addresses[0].address),
timeout: 60_000,
}, resolve);
request.on('error', reject);
request.on('timeout', () => request.destroy(new PcoreResponseError('Pcore 结果下载超时', null)));
request.end();
});
if (response.statusCode !== 200 || Number(response.headers['content-length'] ?? 0) > 25 * 1024 * 1024) {
response.resume();
throw new PcoreResponseError(`Pcore 结果下载失败(HTTP ${response.statusCode})`, null);
}
const chunks: Buffer[] = [];
let size = 0;
for await (const chunk of response) {
size += chunk.length;
if (size > 25 * 1024 * 1024) {
response.destroy();
throw new Error('Pcore 结果超过处理上限');
}
chunks.push(Buffer.from(chunk));
}
return Buffer.concat(chunks, size);
}
export function pinnedLookup(address: string): LookupFunction {
return (_hostname, options, callback) => {
const pinned = { address, family: 4 };
if (options.all) callback(null, [pinned]);
else callback(null, pinned.address, pinned.family);
};
}
export function providerImageResult(result: Record<string, unknown>): Buffer | string {
const first = Array.isArray(result.data) ? result.data[0] as Record<string, unknown> | undefined : undefined;
for (const encoded of [first?.b64_json, first?.b64_image]) {
if (typeof encoded !== 'string' || !encoded.trim()) continue;
if (encoded.length > 35 * 1024 * 1024) throw new Error('Pcore 结果超过处理上限');
const bytes = Buffer.from(encoded.replace(/^data:[^,]*,/, ''), 'base64');
if (bytes.length) return bytes;
}
if (typeof first?.url === 'string' && first.url) return first.url;
throw new Error('Pcore 任务完成但没有图片');
}
async function pollProvider(taskId: string): Promise<Buffer | null> {
const provider = await atStage('provider_polling', async () => providerConfig());
const result = await atStage('provider_polling', () =>
providerRequest(`${provider.base}/images/edits/${encodeURIComponent(taskId)}`, provider.key));
const status = String(result.status ?? '');
if (['failed', 'error', 'cancelled'].includes(status))
throw new PcoreTerminalError(pcoreFailureCode(result.error),
JSON.stringify(result.error ?? result));
if (!['completed', 'succeeded'].includes(status)) return null;
const image = await atStage('result_download', async () => {
try { return providerImageResult(result); }
catch (error) {
throw new PcoreResponseError(error instanceof Error ? error.message : 'Pcore 图片结果无效', null);
}
});
return typeof image === 'string'
? atStage('result_download', () => downloadProviderImage(image)) : image;
}
async function schedulePoll(database: Database, workspaceId: string, id: string) {
await database.workspaceTransaction(workspaceId, async (tx) => {
const current = await tx.query<{ status: string }>(
'SELECT status FROM atlas.media_ai_edits WHERE id = $1 FOR UPDATE', [id],
);
if (current.rows[0]?.status !== 'running') return;
await tx.query(
`INSERT INTO atlas.outbox_events
(workspace_id, aggregate_type, aggregate_id, event_type, event_payload, deduplication_key, next_attempt_at)
VALUES (current_setting('app.workspace_id')::uuid, 'media_ai_edit', $1,
'media.ai_edit.requested', jsonb_build_object('edit_id', $1::uuid), $2, now() + interval '3 seconds')`,
[id, `${id}:${randomUUID()}`],
);
});
}
export async function normalizeAiEditOutput(bytes: Buffer, settings: MediaAiEditSettings, source?: Buffer): Promise<Buffer> {
const target = sizes[settings.mode === 'outpaint' ? '3:4' : settings.ratio];
const image = sharp(bytes, { limitInputPixels: 100_000_000, failOn: 'error' });
const meta = await image.metadata();
if (!['png', 'jpeg', 'webp'].includes(meta.format ?? '') || !meta.width || !meta.height)
throw new Error('Pcore 结果图片无效');
if (settings.mode === 'outpaint' && source) {
const restored = await outpaintInputs(source);
return image.rotate().resize(target[0], target[1], { fit: 'cover' })
.composite([{ input: restored.original, left: restored.left, top: restored.top }])
.flatten({ background: '#ffffff' }).jpeg({ quality: 92 }).toBuffer();
}
return image.rotate().resize(target[0], target[1], { fit: 'contain', background: '#ffffff' })
.flatten({ background: '#ffffff' }).jpeg({ quality: 92 }).toBuffer();
}
/** 每个 Outbox 事件只推进一次供应商状态,等待由下一条延迟事件承担。 */
export async function runMediaAiEdit(database: Database, storage: S3Client, job: OutboxJob) {
const id = String(job.payload.edit_id ?? '');
const task = await atStage('task_setup', () => database.workspaceTransaction(job.workspaceId, async (tx) => {
const result = await tx.query<EditTask>(
'SELECT status, source_asset_id, reference_asset_ids, settings, provider_task_id, created_at FROM atlas.media_ai_edits WHERE id = $1 FOR UPDATE', [id],
);
const current = result.rows[0];
if (!current || !['queued', 'running'].includes(current.status)) return null;
if (Date.now() - new Date(current.created_at).getTime() > 20 * 60_000)
throw new AiEditStageError('provider_polling', new PcoreResponseError('等待 Pcore 图片任务超时', null));
await tx.query(
`UPDATE atlas.media_ai_edits SET status = 'running', started_at = coalesce(started_at, now()) WHERE id = $1`, [id],
);
await tx.query(
`UPDATE atlas.operations SET status = 'running', started_at = coalesce(started_at, now()),
attempt_count = attempt_count + 1 WHERE id = $1 AND status IN ('queued', 'running')`, [id],
);
return current;
}));
if (!task) return;
const assetIds = [task.source_asset_id, ...task.reference_asset_ids];
const assets = await atStage('input_preparation', () => database.workspaceTransaction(job.workspaceId, async (tx) => {
const result = await tx.query<StoredAsset>(
`SELECT id, storage_provider, bucket, object_key FROM atlas.media_assets WHERE id = ANY($1::uuid[])`, [assetIds],
);
const byId = new Map(result.rows.map((item) => [item.id, item]));
return assetIds.map((assetId) => {
const asset = byId.get(assetId);
if (!asset) throw new Error('参考图片素材不存在');
return asset;
});
}));
if (!task.provider_task_id) {
const providerTaskId = await submitProvider(storage, assets, task.settings);
await atStage('task_setup', () => database.workspaceTransaction(job.workspaceId, async (tx) => {
await tx.query(
`UPDATE atlas.media_ai_edits SET provider_task_id = $2 WHERE id = $1 AND status = 'running' AND provider_task_id IS NULL`,
[id, providerTaskId],
);
}));
await atStage('task_setup', () => schedulePoll(database, job.workspaceId, id));
return;
}
let result: Buffer | null;
try {
result = await pollProvider(task.provider_task_id);
} catch (error) {
// 供应商任务已是终态,重试同一个任务 ID 不会改变结果。
if (!(error instanceof PcoreTerminalError)) throw error;
await markMediaAiEditFailed(database, job, {
code: error.errorCode, source: 'pcore', stage: 'provider_polling',
detail: safeFailureDetail(error, 'provider_polling'), providerErrorRaw: error.rawResponse,
});
return;
}
if (!result) {
await atStage('task_setup', () => schedulePoll(database, job.workspaceId, id));
return;
}
const source = task.settings.mode === 'outpaint'
? await atStage('result_processing', () => readObject(storage, assets[0])) : undefined;
const output = await atStage('result_processing', () => normalizeAiEditOutput(result, task.settings, source));
if (output.byteLength > 20 * 1024 * 1024)
throw new AiEditStageError('result_processing', new Error('AI 编辑结果超过素材上限'));
const checksum = createHash('sha256').update(output).digest('hex');
const objectKey = `ai-edits/${job.workspaceId}/${checksum}.jpg`;
const existing = await atStage('result_storage', () => database.workspaceTransaction(job.workspaceId, async (tx) => {
const row = await tx.query<{ id: string }>('SELECT id FROM atlas.media_assets WHERE checksum_sha256 = $1', [checksum]);
return row.rows[0]?.id ?? null;
}));
if (!existing) await atStage('result_storage', () => storage.send(new PutObjectCommand({
Bucket: assets[0].bucket, Key: objectKey, Body: output, ContentType: 'image/jpeg',
})));
const [width, height] = sizes[task.settings.mode === 'outpaint' ? '3:4' : task.settings.ratio];
await atStage('result_storage', () => database.workspaceTransaction(job.workspaceId, async (tx) => {
const current = await tx.query<{ status: string }>(
'SELECT status FROM atlas.media_ai_edits WHERE id = $1 FOR UPDATE', [id],
);
if (current.rows[0]?.status !== 'running') return;
const asset = await tx.query<{ id: string }>(
`INSERT INTO atlas.media_assets
(workspace_id, storage_provider, bucket, object_key, content_type, byte_size, checksum_sha256, width_px, height_px)
VALUES (current_setting('app.workspace_id')::uuid, $1, $2, $3, 'image/jpeg', $4, $5, $6, $7)
ON CONFLICT (workspace_id, checksum_sha256)
DO UPDATE SET checksum_sha256 = EXCLUDED.checksum_sha256 RETURNING id`,
[assets[0].storage_provider, assets[0].bucket, objectKey, output.byteLength, checksum, width, height],
);
const assetId = asset.rows[0].id;
await tx.query(
`UPDATE atlas.media_ai_edits SET status = 'succeeded', output_asset_id = $2, error_code = NULL,
completed_at = now() WHERE id = $1`, [id, assetId],
);
await tx.query(
`UPDATE atlas.operations SET status = 'succeeded', completed_at = now(),
result = jsonb_build_object('output_asset_id', $2::uuid), error_code = NULL, error_message = NULL
WHERE id = $1 AND status IN ('queued', 'running')`, [id, assetId],
);
}));
}
export async function markMediaAiEditFailed(
database: Database, job: OutboxJob, failure: AiEditFailure,
) {
const id = String(job.payload.edit_id ?? '');
const { code, source, stage, detail, providerErrorRaw } = failure;
const message = code === 'AI_IMAGE_CONTENT_REJECTED'
? 'Pcore 未能完成这次生图,可能是输入或生成结果被审核拦截'
: code === 'AI_IMAGE_PROVIDER_FAILED'
? 'Pcore 图片任务失败,请稍后重试'
: '单张 AI 编辑失败,请重试';
await database.workspaceTransaction(job.workspaceId, async (tx) => {
await tx.query(
`UPDATE atlas.media_ai_edits SET status = 'failed', error_code = $2,
completed_at = now() WHERE id = $1 AND status IN ('queued', 'running')`, [id, code],
);
await tx.query(
`UPDATE atlas.operations SET status = 'failed', error_code = $2,
error_message = $3,
result = coalesce(result, '{}'::jsonb) || jsonb_strip_nulls(jsonb_build_object(
'failure_source', $4::text, 'failure_stage', $5::text,
'failure_detail', $6::text, 'provider_error_raw', $7::text)),
completed_at = now()
WHERE id = $1 AND status IN ('queued', 'running')`,
[id, code, message, source, stage, detail, providerErrorRaw],
);
});
}
+47
View File
@@ -0,0 +1,47 @@
import type { MediaAiEditSettings } from '@atlas/contracts';
const styles: Record<number, string> = {
1: 'Nordic minimalist e-commerce style: warm ivory, soft daylight, natural texture and spacious composition.',
2: 'Fresh bright e-commerce style: clean high-key daylight, natural colors and an airy retail composition.',
3: 'Premium dark editorial style: charcoal setting, sculpted side lighting, realistic material texture.',
4: 'Warm lifestyle style: believable cream home setting, balanced golden and window light, product as the hero.',
5: 'Clean studio style: neutral seamless background, even softbox light, crisp edges and no decorative props.',
};
const backgrounds: Record<MediaAiEditSettings['background'], string> = {
auto: 'Keep the original background unless the user requests a change.',
white: 'Replace only the background with pure white; retain natural contact shadows.',
solid: 'Replace only the background with the solid color described by the user.',
scene: 'Replace only the background with the realistic scene described by the user.',
reference: 'Use Image 2 as background and lighting reference, but never copy its product.',
};
const languages = { ru: 'Russian', ar: 'Arabic', en: 'English' };
/** 主图是商品事实来源;辅助图仅影响背景、光线和质感。 */
export function buildMediaAiPrompt(input: MediaAiEditSettings): string {
if (input.mode === 'outpaint') return [
'Extend Image 1 to an exact 3:4 canvas. Generate only the transparent surrounding area.',
'Keep the entire original image region visually unchanged and continue the surrounding scene naturally.',
input.prompt ? `User requirements for the new edge area: ${input.prompt}` : '',
'Do not add text, watermarks, panels or new products.',
].filter(Boolean).join('\n\n');
const parts = [
input.prompt ? `User requirements (highest priority): ${input.prompt}` : '',
'Create exactly ONE finished e-commerce image. Image 1 is the fixed main product image. Preserve its shape, proportions, color, material, logo and distinguishing details. Never replace it with a product from another image.',
input.reference_asset_ids.length
? `Images 2 through ${input.reference_asset_ids.length + 1} are auxiliary references only. Use them for details, background or lighting; Image 1 remains the product source of truth.` : '',
`BACKGROUND: ${backgrounds[input.background]}`,
input.background_description ? `Background details: ${input.background_description}` : '',
input.translate_text
? `Read visible marketing text in Image 1, translate its meaning into ${languages[input.target_language]}, replace source-language marketing copy and redesign for a legible layout. For Arabic use right-to-left typesetting. Preserve physical product branding, factual values and units; add no unsupported claims.`
: 'Do not invent marketing copy or translate existing image text; preserve text on the physical product.',
];
if (input.generate_size_chart && input.dimensions) {
const dimensions = ([['length', 'length'], ['width', 'width'], ['height', 'height']] as const)
.flatMap(([key, label]) => input.dimensions?.[key]
? [`${label}: ${input.dimensions[key]} ${input.dimensions.unit}`] : []);
parts.push(`Create a clean product measurement diagram with legible arrows. Label only these supplied dimensions: ${dimensions.join(', ')}. Do not infer other measurements.`);
}
parts.push(`VISUAL STYLE: ${input.style_prompt || styles[input.style_set]}`);
parts.push(`OUTPUT: exact ${input.ratio} composition. Follow explicit user, background and text instructions before style preferences. No watermark or extra panels.`);
return parts.filter(Boolean).join('\n\n');
}
+221
View File
@@ -0,0 +1,221 @@
import { createHash } from 'node:crypto';
import { GetObjectCommand, PutObjectCommand, type S3Client } from '@aws-sdk/client-s3';
import type { Database } from '@atlas/database';
import sharp from 'sharp';
import type { OutboxJob } from './outbox-dispatcher.js';
type Source = {
status: 'queued' | 'running' | 'succeeded' | 'failed';
storage_provider: string;
bucket: string;
object_key: string;
};
/** 读取正式素材时始终限制字节数,避免对象被外部替换后耗尽 Worker 内存。 */
async function readSource(storage: S3Client, source: Source): Promise<Buffer> {
const object = await storage.send(new GetObjectCommand({
Bucket: source.bucket,
Key: source.object_key,
}));
if (!object.Body) throw new Error('源图片不存在');
const chunks: Buffer[] = [];
let size = 0;
for await (const chunk of object.Body as AsyncIterable<Uint8Array>) {
size += chunk.byteLength;
if (size > 20 * 1024 * 1024) throw new Error('源图片超过处理上限');
chunks.push(Buffer.from(chunk));
}
return Buffer.concat(chunks, size);
}
type Edge = 'top' | 'bottom' | 'left' | 'right';
type Rgb = [number, number, number];
/** 用边缘内侧的窄带计算底色,减弱单行像素噪声对留白的影响。 */
function edgeMean(data: Buffer, width: number, height: number, edge: Edge): Rgb {
const sums: Rgb = [0, 0, 0];
let count = 0;
const depth = 8;
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
if (edge === 'top' && y >= depth) continue;
if (edge === 'bottom' && y < height - depth) continue;
if (edge === 'left' && x >= depth) continue;
if (edge === 'right' && x < width - depth) continue;
const offset = (y * width + x) * 3;
for (let channel = 0; channel < 3; channel++) sums[channel] += data[offset + channel]!;
count++;
}
}
return sums.map((sum) => Math.round(sum / count)) as Rgb;
}
/** 接缝处保持原图边缘色,短距离内平滑过渡为边缘底色。 */
function fadeWeight(distance: number): number {
const t = Math.min(Math.max(distance - 1, 0) / 5, 1);
return t * t * (3 - 2 * t);
}
/** 等比扩充到固定画布,边缘颜色平滑延展,不让纹理形成贯穿留白的条纹。 */
export async function renderThreeFour(bytes: Buffer): Promise<Buffer> {
const image = sharp(bytes, { limitInputPixels: 100_000_000, failOn: 'error' });
const metadata = await image.metadata();
if (!['jpeg', 'png', 'webp'].includes(metadata.format ?? ''))
throw new Error('源图片格式不受支持');
if (!metadata.width || !metadata.height || metadata.width > 10_000 || metadata.height > 10_000)
throw new Error('源图片尺寸不受支持');
const swapped = (metadata.orientation ?? 1) >= 5 && (metadata.orientation ?? 1) <= 8;
const width = swapped ? metadata.height : metadata.width;
const height = swapped ? metadata.width : metadata.height;
const wide = width * 4 > height * 3;
const outputWidth = wide ? 1200 : Math.max(1, Math.round(width * 1600 / height));
const outputHeight = wide ? Math.max(1, Math.round(height * 1200 / width)) : 1600;
const left = Math.floor((1200 - outputWidth) / 2);
const top = Math.floor((1600 - outputHeight) / 2);
const { data, info } = await image.rotate().resize(outputWidth, outputHeight, { fit: 'fill' })
.flatten({ background: '#ffffff' }).toColourspace('srgb').removeAlpha()
.raw().toBuffer({ resolveWithObject: true });
if (info.channels !== 3) throw new Error('图片颜色通道不受支持');
const firstMean = edgeMean(data, outputWidth, outputHeight, wide ? 'top' : 'left');
const secondMean = edgeMean(data, outputWidth, outputHeight, wide ? 'bottom' : 'right');
const canvas = Buffer.alloc(1200 * 1600 * 3);
for (let y = 0; y < 1600; y++) {
const targetStart = y * 1200 * 3;
if (wide) {
const relativeY = y - top;
if (relativeY >= 0 && relativeY < outputHeight) {
const sourceStart = relativeY * 1200 * 3;
data.copy(canvas, targetStart, sourceStart, sourceStart + 1200 * 3);
continue;
}
const firstSide = relativeY < 0;
const sourceStart = (firstSide ? 0 : outputHeight - 1) * 1200 * 3;
const mean = firstSide ? firstMean : secondMean;
const weight = fadeWeight(firstSide ? -relativeY : relativeY - outputHeight + 1);
for (let x = 0; x < 1200; x++) {
const sourceOffset = sourceStart + x * 3;
const targetOffset = targetStart + x * 3;
for (let channel = 0; channel < 3; channel++)
canvas[targetOffset + channel] = Math.round(data[sourceOffset + channel]! * (1 - weight) + mean[channel]! * weight);
}
continue;
}
const sourceStart = y * outputWidth * 3;
const firstPixel = data.subarray(sourceStart, sourceStart + 3);
const lastPixel = data.subarray(sourceStart + (outputWidth - 1) * 3, sourceStart + outputWidth * 3);
data.copy(canvas, targetStart + left * 3, sourceStart, sourceStart + outputWidth * 3);
for (let x = 0; x < left; x++) {
const weight = fadeWeight(left - x);
for (let channel = 0; channel < 3; channel++)
canvas[targetStart + x * 3 + channel] = Math.round(firstPixel[channel]! * (1 - weight) + firstMean[channel]! * weight);
}
for (let x = left + outputWidth; x < 1200; x++) {
const weight = fadeWeight(x - left - outputWidth + 1);
for (let channel = 0; channel < 3; channel++)
canvas[targetStart + x * 3 + channel] = Math.round(lastPixel[channel]! * (1 - weight) + secondMean[channel]! * weight);
}
}
return sharp(canvas, { raw: { width: 1200, height: 1600, channels: 3 } })
.jpeg({ quality: 95, chromaSubsampling: '4:4:4' }).toBuffer();
}
/** 同一派生 ID 反复执行得到同一结果;业务完成状态只保存在 PostgreSQL。 */
export async function runAspectRatioDerivative(
database: Database,
storage: S3Client,
job: OutboxJob,
) {
const derivativeId = String(job.payload.derivative_id ?? '');
const source = await database.workspaceTransaction(job.workspaceId, async (tx) => {
const result = await tx.query<Source>(
`SELECT d.status, a.storage_provider, a.bucket, a.object_key
FROM atlas.media_aspect_ratio_derivatives d
JOIN atlas.media_assets a ON a.id = d.source_asset_id
WHERE d.id = $1 FOR UPDATE OF d`,
[derivativeId],
);
const row = result.rows[0];
if (!row || row.status === 'succeeded' || row.status === 'failed') return null;
await tx.query(
`UPDATE atlas.media_aspect_ratio_derivatives
SET status = 'running', started_at = coalesce(started_at, now())
WHERE id = $1`,
[derivativeId],
);
await tx.query(
`UPDATE atlas.operations SET status = 'running',
started_at = coalesce(started_at, now()), attempt_count = attempt_count + 1
WHERE id = $1 AND status IN ('queued', 'running')`,
[derivativeId],
);
return row;
});
if (!source) return;
const output = await renderThreeFour(await readSource(storage, source));
if (output.byteLength > 20 * 1024 * 1024) throw new Error('生成图片超过处理上限');
const checksum = createHash('sha256').update(output).digest('hex');
const objectKey = `derivatives/${job.workspaceId}/three-four/${checksum}.jpg`;
const existing = await database.workspaceTransaction(job.workspaceId, async (tx) => {
const result = await tx.query<{ id: string }>(
'SELECT id FROM atlas.media_assets WHERE checksum_sha256 = $1',
[checksum],
);
return result.rows[0]?.id ?? null;
});
if (!existing) {
await storage.send(new PutObjectCommand({
Bucket: source.bucket,
Key: objectKey,
Body: output,
ContentType: 'image/jpeg',
}));
}
await database.workspaceTransaction(job.workspaceId, async (tx) => {
const asset = await tx.query<{ id: string }>(
`INSERT INTO atlas.media_assets
(workspace_id, storage_provider, bucket, object_key, content_type,
byte_size, checksum_sha256, width_px, height_px)
VALUES (current_setting('app.workspace_id')::uuid, $1,
$2, $3, 'image/jpeg', $4, $5, 1200, 1600)
ON CONFLICT (workspace_id, checksum_sha256)
DO UPDATE SET checksum_sha256 = EXCLUDED.checksum_sha256
RETURNING id`,
[source.storage_provider, source.bucket, objectKey, output.byteLength, checksum],
);
if (!asset.rows[0]) throw new Error('派生素材登记失败');
await tx.query(
`UPDATE atlas.media_aspect_ratio_derivatives
SET status = 'succeeded', output_asset_id = $2, completed_at = now(), error_code = NULL
WHERE id = $1 AND status = 'running'`,
[derivativeId, asset.rows[0].id],
);
await tx.query(
`UPDATE atlas.operations SET status = 'succeeded', completed_at = now(),
result = jsonb_build_object('output_asset_id', $2::uuid),
error_code = NULL, error_message = NULL
WHERE id = $1 AND status IN ('queued', 'running')`,
[derivativeId, asset.rows[0].id],
);
});
}
/** 重试耗尽后只向前端暴露稳定错误码,内部异常不会进入任务记录。 */
export async function markAspectRatioDerivativeFailed(database: Database, job: OutboxJob) {
const derivativeId = String(job.payload.derivative_id ?? '');
await database.workspaceTransaction(job.workspaceId, async (tx) => {
await tx.query(
`UPDATE atlas.media_aspect_ratio_derivatives
SET status = 'failed', error_code = 'IMAGE_PROCESSING_FAILED', completed_at = now()
WHERE id = $1 AND status IN ('queued', 'running')`,
[derivativeId],
);
await tx.query(
`UPDATE atlas.operations
SET status = 'failed', error_code = 'IMAGE_PROCESSING_FAILED',
error_message = '图片处理失败,请重试', completed_at = now()
WHERE id = $1 AND status IN ('queued', 'running')`,
[derivativeId],
);
});
}
+218
View File
@@ -0,0 +1,218 @@
import { createHash } from 'node:crypto';
import { GetObjectCommand, PutObjectCommand, type S3Client } from '@aws-sdk/client-s3';
import type { Database } from '@atlas/database';
import sharp from 'sharp';
import type { OutboxJob } from './outbox-dispatcher.js';
type StoredObject = { storage_provider: string; bucket: string; object_key: string };
type Task = StoredObject & {
status: 'queued' | 'running' | 'succeeded' | 'failed';
kind: 'text' | 'image';
text_content: string | null;
font_code: string | null;
font_size_px: number | null;
opacity: string;
scale_ratio: string | null;
image_size_px: number | null;
border_radius_px: number | null;
position_x: number | null;
position_y: number | null;
position: string;
watermark_bucket: string | null;
watermark_object_key: string | null;
};
/** 对象存储内容在读入内存时再次限制大小,不依赖上传时的校验。 */
async function readObject(storage: S3Client, bucket: string, key: string, maxBytes: number): Promise<Buffer> {
const object = await storage.send(new GetObjectCommand({ Bucket: bucket, Key: key }));
if (!object.Body) throw new Error('图片对象不存在');
const chunks: Buffer[] = [];
let size = 0;
for await (const chunk of object.Body as AsyncIterable<Uint8Array>) {
size += chunk.byteLength;
if (size > maxBytes) throw new Error('图片对象超过处理上限');
chunks.push(Buffer.from(chunk));
}
return Buffer.concat(chunks, size);
}
function escapeXml(value: string): string {
return value.replace(/[&<>"']/g, (character) => ({
'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&apos;',
})[character]!);
}
function legacyCoordinate(position: string): [number, number] {
const values: Record<string, [number, number]> = {
top_left: [0, 0], top_center: [0.5, 0], top_right: [1, 0],
middle_left: [0, 0.5], center: [0.5, 0.5], middle_right: [1, 0.5],
bottom_left: [0, 1], bottom_center: [0.5, 1], bottom_right: [1, 1],
};
return values[position] ?? [1, 1];
}
function placement(canvas: number, mark: number, coordinate: number, requestedInset: number): number {
const inset = Math.min(requestedInset, Math.max(0, Math.floor((canvas - mark) / 2)));
return Math.max(0, Math.min(canvas - mark, Math.round(inset + coordinate * Math.max(0, canvas - mark - 2 * inset))));
}
/** 渲染器只使用已锁定的模板版本;透明度作用于整个水印图层。 */
export async function renderWatermark(sourceBytes: Buffer, template: Task, watermarkBytes?: Buffer): Promise<{
bytes: Buffer; width: number; height: number;
}> {
const input = sharp(sourceBytes, { limitInputPixels: 100_000_000, failOn: 'error' });
const meta = await input.metadata();
if (!['jpeg', 'png', 'webp'].includes(meta.format ?? '') || !meta.width || !meta.height
|| meta.width > 10_000 || meta.height > 10_000) throw new Error('源图片格式或尺寸不受支持');
const rotated = (meta.orientation ?? 1) >= 5 && (meta.orientation ?? 1) <= 8;
const width = rotated ? meta.height : meta.width;
const height = rotated ? meta.width : meta.height;
/** 模板中的像素值以 240×320 预览框为基准,等比映射到完整源图。 */
const previewScale = Math.max(1, width / 240, height / 320);
const inset = Math.max(1, Math.round(5 * previewScale));
let layer: Buffer;
if (template.kind === 'image') {
if (!watermarkBytes) throw new Error('图片水印素材不存在');
const overlay = sharp(watermarkBytes, { limitInputPixels: 25_000_000, failOn: 'error' });
const overlayMeta = await overlay.metadata();
if (!['jpeg', 'png', 'webp'].includes(overlayMeta.format ?? '') || !overlayMeta.width || !overlayMeta.height)
throw new Error('图片水印格式不受支持');
const baseSize = Math.min(200, Math.max(10, template.image_size_px ?? Math.round(Number(template.scale_ratio ?? 0.5) * 144)));
const overlayRotated = (overlayMeta.orientation ?? 1) >= 5 && (overlayMeta.orientation ?? 1) <= 8;
const overlayWidth = overlayRotated ? overlayMeta.height : overlayMeta.width;
const overlayHeight = overlayRotated ? overlayMeta.width : overlayMeta.height;
const ratio = overlayWidth / overlayHeight;
const desiredWidth = baseSize * previewScale * Math.min(1, ratio);
const desiredHeight = baseSize * previewScale / Math.max(1, ratio);
const fit = Math.min(1, Math.max(1, width - 2 * inset) / desiredWidth,
Math.max(1, height - 2 * inset) / desiredHeight);
const markWidth = Math.max(1, Math.round(desiredWidth * fit));
const markHeight = Math.max(1, Math.round(desiredHeight * fit));
const resized = await overlay.rotate().resize(markWidth, markHeight, { fit: 'fill' }).ensureAlpha().png().toBuffer();
const radius = Math.max(0, Math.round((template.border_radius_px ?? 0) * previewScale * fit));
if (radius) {
const resizedMeta = await sharp(resized).metadata();
const mask = Buffer.from(`<svg width="${resizedMeta.width}" height="${resizedMeta.height}"><rect width="100%" height="100%" rx="${radius}" ry="${radius}" fill="white"/></svg>`);
layer = await sharp(resized).composite([{ input: mask, blend: 'dest-in' }]).png().toBuffer();
} else layer = resized;
} else {
const content = template.text_content ?? '';
const baseFontSize = template.font_size_px ?? 16;
if (!content || content.length > 200 || baseFontSize < 12 || baseFontSize > 60) throw new Error('文字水印配置无效');
const fontSize = Math.min(1000, Math.max(1, Math.round(baseFontSize * previewScale)));
const textContent = content.replace(/\s+/gu, ' ');
const safeFont = escapeXml(template.font_code ?? 'sans-serif');
const svgWidth = Math.min(6000, Math.max(100, Math.ceil(textContent.length * fontSize * 1.2 + 20)));
const svgHeight = Math.max(100, Math.ceil(fontSize * 1.6 + 20));
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${svgWidth}" height="${svgHeight}"><text x="5" y="${fontSize + 5}" font-family="${safeFont},sans-serif" font-size="${fontSize}" font-weight="600" fill="#111827">${escapeXml(textContent)}</text></svg>`;
layer = await sharp(Buffer.from(svg)).trim().png().toBuffer();
}
const opacity = Number(template.opacity);
if (!Number.isFinite(opacity) || opacity < 0 || opacity > 1) throw new Error('水印透明度无效');
const { data, info } = await sharp(layer).ensureAlpha().raw().toBuffer({ resolveWithObject: true });
if (info.channels !== 4) throw new Error('水印通道无效');
for (let index = 3; index < data.length; index += 4) data[index] = Math.round(data[index]! * opacity);
const maxWidth = Math.max(1, width - 2 * inset);
const maxHeight = Math.max(1, height - 2 * inset);
layer = await sharp(data, { raw: { width: info.width, height: info.height, channels: 4 } })
.resize({ width: maxWidth, height: maxHeight, fit: 'inside', withoutEnlargement: true })
.png().toBuffer();
const layerMeta = await sharp(layer).metadata();
const legacy = legacyCoordinate(template.position);
const x = template.position_x ?? legacy[0];
const y = template.position_y ?? legacy[1];
if (x < 0 || x > 1 || y < 0 || y > 1) throw new Error('水印坐标无效');
const composite = {
input: layer,
left: placement(width, layerMeta.width!, x, inset),
top: placement(height, layerMeta.height!, y, inset),
};
/** 大幅面源图可能生成超过素材上限的 JPEG,逐级压缩但不改变画布尺寸。 */
for (const quality of [92, 82, 72, 62, 50]) {
const bytes = await input.clone().rotate().flatten({ background: '#ffffff' })
.composite([composite]).jpeg({ quality, chromaSubsampling: '4:4:4' }).toBuffer();
if (bytes.byteLength <= 20 * 1024 * 1024) return { bytes, width, height };
}
throw new Error('水印图片超过处理上限');
}
/** 同一任务可安全重试,正式素材以工作空间和内容哈希去重。 */
export async function runWatermarkDerivative(database: Database, storage: S3Client, job: OutboxJob) {
const id = String(job.payload.derivative_id ?? '');
const task = await database.workspaceTransaction(job.workspaceId, async (tx) => {
const result = await tx.query<Task>(
`SELECT d.status, source.storage_provider, source.bucket, source.object_key,
v.kind, v.text_content, v.font_code, v.font_size_px, v.opacity::text,
v.scale_ratio::text, v.image_size_px, v.border_radius_px,
v.position_x::float8, v.position_y::float8, v.position::text,
mark.bucket AS watermark_bucket, mark.object_key AS watermark_object_key
FROM atlas.media_watermark_derivatives d
JOIN atlas.media_assets source ON source.id = d.source_asset_id
JOIN atlas.watermark_template_versions v
ON v.workspace_id = d.workspace_id AND v.template_id = d.watermark_template_id
AND v.version = d.watermark_template_version
LEFT JOIN atlas.media_assets mark ON mark.id = v.image_asset_id
WHERE d.id = $1 FOR UPDATE OF d`,
[id],
);
const row = result.rows[0];
if (!row || row.status === 'succeeded' || row.status === 'failed') return null;
await tx.query(
`UPDATE atlas.media_watermark_derivatives
SET status = 'running', started_at = coalesce(started_at, now()) WHERE id = $1`, [id]);
await tx.query(
`UPDATE atlas.operations SET status = 'running', started_at = coalesce(started_at, now()),
attempt_count = attempt_count + 1 WHERE id = $1 AND status IN ('queued', 'running')`, [id]);
return row;
});
if (!task) return;
const sourceBytes = await readObject(storage, task.bucket, task.object_key, 20 * 1024 * 1024);
const markBytes = task.kind === 'image' && task.watermark_bucket && task.watermark_object_key
? await readObject(storage, task.watermark_bucket, task.watermark_object_key, 5 * 1024 * 1024)
: undefined;
const output = await renderWatermark(sourceBytes, task, markBytes);
if (output.bytes.byteLength > 20 * 1024 * 1024) throw new Error('水印图片超过处理上限');
const checksum = createHash('sha256').update(output.bytes).digest('hex');
const objectKey = `derivatives/${job.workspaceId}/watermark/${checksum}.jpg`;
const existing = await database.workspaceTransaction(job.workspaceId, async (tx) => {
const result = await tx.query<{ id: string }>('SELECT id FROM atlas.media_assets WHERE checksum_sha256 = $1', [checksum]);
return result.rows[0]?.id ?? null;
});
if (!existing) await storage.send(new PutObjectCommand({
Bucket: task.bucket, Key: objectKey, Body: output.bytes, ContentType: 'image/jpeg',
}));
await database.workspaceTransaction(job.workspaceId, async (tx) => {
const asset = await tx.query<{ id: string }>(
`INSERT INTO atlas.media_assets
(workspace_id, storage_provider, bucket, object_key, content_type,
byte_size, checksum_sha256, width_px, height_px)
VALUES (current_setting('app.workspace_id')::uuid, $1, $2, $3, 'image/jpeg', $4, $5, $6, $7)
ON CONFLICT (workspace_id, checksum_sha256)
DO UPDATE SET checksum_sha256 = EXCLUDED.checksum_sha256 RETURNING id`,
[task.storage_provider, task.bucket, objectKey, output.bytes.byteLength, checksum, output.width, output.height],
);
const assetId = asset.rows[0]?.id;
if (!assetId) throw new Error('水印素材登记失败');
await tx.query(
`UPDATE atlas.media_watermark_derivatives SET status = 'succeeded', output_asset_id = $2,
completed_at = now(), error_code = NULL WHERE id = $1 AND status = 'running'`, [id, assetId]);
await tx.query(
`UPDATE atlas.operations SET status = 'succeeded', completed_at = now(),
result = jsonb_build_object('output_asset_id', $2::uuid), error_code = NULL, error_message = NULL
WHERE id = $1 AND status IN ('queued', 'running')`, [id, assetId]);
});
}
/** 重试耗尽后只暴露稳定错误码。 */
export async function markWatermarkDerivativeFailed(database: Database, job: OutboxJob) {
const id = String(job.payload.derivative_id ?? '');
await database.workspaceTransaction(job.workspaceId, async (tx) => {
await tx.query(
`UPDATE atlas.media_watermark_derivatives SET status = 'failed', error_code = 'IMAGE_PROCESSING_FAILED',
completed_at = now() WHERE id = $1 AND status IN ('queued', 'running')`, [id]);
await tx.query(
`UPDATE atlas.operations SET status = 'failed', error_code = 'IMAGE_PROCESSING_FAILED',
error_message = '水印处理失败,请重试', completed_at = now()
WHERE id = $1 AND status IN ('queued', 'running')`, [id]);
});
}
+155
View File
@@ -0,0 +1,155 @@
export type OzonProductListItem = {
product_id: number | string;
offer_id: string;
};
export type OzonProductDetail = Record<string, unknown>;
export type MappedOnlineProduct = {
remote_product_id: string;
offer_id: string;
remote_sku: string | null;
name: string | null;
selling_price: string | null;
list_price: string | null;
visible: boolean | null;
remote_state: string | null;
moderation_status: string | null;
validation_status: string | null;
stock_summary: { total: number; has_data: boolean };
image_summary: {
url: string | null;
width_px: number | null;
height_px: number | null;
};
error_payload: Array<{ code?: string; message: string }>;
visibility_details: Record<string, unknown>;
raw_payload: OzonProductDetail;
};
export function parseOzonListPage(value: unknown): {
items: OzonProductListItem[];
lastId: string;
} {
const result = record(record(value)?.result);
if (!result || !Array.isArray(result.items))
throw new Error('Ozon 商品列表响应格式异常');
const items = result.items.map((raw) => {
const item = record(raw);
const productId = identifier(item?.product_id);
const offerId = text(item?.offer_id);
if (
!productId ||
!/^\d+$/.test(productId) ||
!Number.isSafeInteger(Number(productId)) ||
!offerId
)
throw new Error('Ozon 商品列表缺少商品标识');
return { product_id: productId, offer_id: offerId };
});
return { items, lastId: text(result.last_id) ?? '' };
}
export function parseOzonInfoItems(value: unknown): OzonProductDetail[] {
const result = record(record(value)?.result);
if (!result || !Array.isArray(result.items))
throw new Error('Ozon 商品详情响应格式异常');
return result.items.filter((item): item is OzonProductDetail =>
Boolean(record(item)),
);
}
/** 只把列表需要的状态和脱敏错误摘要放入可读字段;完整平台响应仅留在受控快照。 */
export function mapOzonProduct(
detail: OzonProductDetail,
listed: OzonProductListItem,
): MappedOnlineProduct {
const status = record(detail.status);
const stock = record(detail.stocks);
const present = finiteNumber(stock?.present);
const sku = Array.isArray(detail.sources)
? (detail.sources
.map((source) => identifier(record(source)?.sku))
.find(Boolean) ?? null)
: null;
const images = Array.isArray(detail.images) ? detail.images : [];
const firstImage =
url(detail.primary_image) ??
images
.map((item) => url(item) ?? url(record(item)?.file_name))
.find(Boolean) ??
null;
const rawErrors = Array.isArray(detail.errors) ? detail.errors : [];
const declineReasons = Array.isArray(status?.decline_reasons)
? status.decline_reasons
: [];
const errors = [...rawErrors, ...declineReasons]
.slice(0, 10)
.map((item) => {
const source = record(item);
return {
...(text(source?.code) ? { code: text(source?.code)! } : {}),
message: (
text(source?.message) ??
text(source?.name) ??
text(item) ??
'平台未提供具体原因'
).slice(0, 300),
};
});
return {
remote_product_id:
identifier(detail.id) ?? identifier(listed.product_id)!,
offer_id: text(detail.offer_id) ?? listed.offer_id,
remote_sku: sku,
name: text(detail.name),
selling_price: amount(detail.price),
list_price: amount(detail.old_price),
visible: typeof detail.visible === 'boolean' ? detail.visible : null,
remote_state: text(status?.state) ?? text(detail.state),
moderation_status: text(status?.moderate_status),
validation_status: text(status?.validation_state),
stock_summary: { total: present ?? 0, has_data: present !== null },
image_summary: { url: firstImage, width_px: null, height_px: null },
error_payload: errors,
visibility_details: record(detail.visibility_details) ?? {},
raw_payload: detail,
};
}
function record(value: unknown): Record<string, unknown> | null {
return value && typeof value === 'object' && !Array.isArray(value)
? (value as Record<string, unknown>)
: null;
}
function text(value: unknown): string | null {
if (typeof value !== 'string') return null;
return value.trim() || null;
}
function identifier(value: unknown): string | null {
if (typeof value === 'number' && Number.isSafeInteger(value) && value >= 0)
return String(value);
return text(value);
}
function finiteNumber(value: unknown): number | null {
const parsed =
typeof value === 'number'
? value
: typeof value === 'string' && value.trim()
? Number(value)
: NaN;
return Number.isFinite(parsed) && parsed >= 0 ? parsed : null;
}
function amount(value: unknown): string | null {
const raw = typeof value === 'number' ? String(value) : text(value);
return raw && /^\d{1,14}(?:\.\d{1,4})?$/.test(raw) ? raw : null;
}
function url(value: unknown): string | null {
const candidate = text(value);
if (!candidate) return null;
try {
const parsed = new URL(candidate);
return parsed.protocol === 'https:' ? parsed.toString() : null;
} catch {
return null;
}
}
+346
View File
@@ -0,0 +1,346 @@
import type { Database, DatabaseTransaction } from '@atlas/database';
import type { OutboxJob } from './outbox-dispatcher.js';
import { decryptStoreSecret } from './store-secret.crypto.js';
import { workerConfig } from './config.js';
import {
OzonCredentialError,
OzonRequestError,
requestOzon,
} from './ozon-client.js';
import {
mapOzonProduct,
parseOzonInfoItems,
parseOzonListPage,
type MappedOnlineProduct,
type OzonProductListItem,
} from './online-product-mapper.js';
export type OnlineProductSyncJob = OutboxJob & {
payload: {
sync_run_id: string;
store_id: string;
platform_id: string;
secret_id: string;
};
};
type Source = {
platform_code: string;
platform_client_id: string;
secret_ciphertext: Buffer;
secret_nonce: Buffer;
};
export async function runOnlineProductSync(
database: Database,
job: OnlineProductSyncJob,
): Promise<void> {
const source = await database.workspaceTransaction(
job.workspaceId,
async (tx) => {
const run = await tx.query<{ status: string }>(
`SELECT status FROM atlas.platform_product_sync_runs WHERE id = $1 AND store_id = $2 AND platform_id = $3 FOR UPDATE`,
[
job.payload.sync_run_id,
job.payload.store_id,
job.payload.platform_id,
],
);
if (
!run.rows[0] ||
run.rows[0].status === 'succeeded' ||
run.rows[0].status === 'failed' ||
run.rows[0].status === 'cancelled'
)
return null;
const connection = await tx.query<Source>(
`SELECT platform.code AS platform_code, store.platform_client_id,
secret.secret_ciphertext, secret.secret_nonce
FROM atlas.stores store
JOIN atlas.platforms platform ON platform.id = store.platform_id
JOIN atlas.store_secrets secret ON secret.id = $3 AND secret.store_id = store.id
WHERE store.id = $1 AND store.platform_id = $2 AND store.deleted_at IS NULL
AND store.credential_status = 'valid' AND secret.revoked_at IS NULL
AND secret.kind = 'seller_api_key'`,
[
job.payload.store_id,
job.payload.platform_id,
job.payload.secret_id,
],
);
if (!connection.rows[0]) throw new Error('店铺凭据已变更或不可用');
// 同一任务重试时清除上次尝试的已见快照,避免把本次未返回的商品误判为仍在售。
await tx.query(
`DELETE FROM atlas.platform_listing_sync_snapshots WHERE sync_run_id = $1`,
[job.payload.sync_run_id],
);
await tx.query(
`UPDATE atlas.platform_product_sync_runs SET status = 'running', started_at = COALESCE(started_at, now()),
completed_at = NULL, error_payload = NULL, listed_count = 0, upserted_count = 0,
failed_count = 0, cursor_started = NULL, cursor_finished = NULL WHERE id = $1`,
[job.payload.sync_run_id],
);
return connection.rows[0];
},
);
if (!source) return;
if (source.platform_code.toUpperCase() !== 'OZON')
throw new Error('当前平台不支持商品同步');
const apiKey = decryptStoreSecret(
source.secret_ciphertext,
source.secret_nonce,
workerConfig.storeSecretEncryptionKey,
);
let cursor = '';
let listedCount = 0;
let upsertedCount = 0;
const seenCursors = new Set<string>();
for (;;) {
const page = parseOzonListPage(
await requestOzon(
source.platform_client_id,
apiKey,
'/v3/product/list',
{
filter: { visibility: 'ALL' },
last_id: cursor,
limit: 1000,
},
),
);
listedCount += page.items.length;
if (listedCount > 1_000_000)
throw new Error('商品数量超过单次同步安全上限');
for (let start = 0; start < page.items.length; start += 100) {
const batch = page.items.slice(start, start + 100);
const body = await requestOzon(
source.platform_client_id,
apiKey,
'/v3/product/info/list',
{
product_id: batch.map((item) => Number(item.product_id)),
},
);
const details = parseOzonInfoItems(body);
const detailById = new Map(
details.map((detail) => [String(detail.id ?? ''), detail]),
);
const mapped = batch.map((item) => {
const detail = detailById.get(String(item.product_id));
if (!detail) throw new Error('Ozon 商品详情缺少列表中的商品');
return mapOzonProduct(detail, item);
});
await database.workspaceTransaction(job.workspaceId, async (tx) => {
await upsertBatch(tx, job, mapped);
upsertedCount += mapped.length;
await tx.query(
`UPDATE atlas.platform_product_sync_runs SET listed_count = $2, upserted_count = $3, cursor_finished = $4
WHERE id = $1 AND status = 'running'`,
[
job.payload.sync_run_id,
listedCount,
upsertedCount,
page.lastId,
],
);
});
}
if (!page.lastId) break;
if (page.lastId === cursor || seenCursors.has(page.lastId))
throw new Error('Ozon 商品列表游标重复');
seenCursors.add(page.lastId);
cursor = page.lastId;
await database.workspaceTransaction(job.workspaceId, (tx) =>
tx.query(
`UPDATE atlas.platform_product_sync_runs SET cursor_finished = $2, listed_count = $3 WHERE id = $1 AND status = 'running'`,
[job.payload.sync_run_id, cursor, listedCount],
),
);
}
await database.workspaceTransaction(job.workspaceId, async (tx) => {
const run = await tx.query<{ status: string }>(
`SELECT status FROM atlas.platform_product_sync_runs WHERE id = $1 FOR UPDATE`,
[job.payload.sync_run_id],
);
if (run.rows[0]?.status !== 'running') return;
// 只有完整成功的全量同步可以改变未见与归档状态;失败批次保留这些标记。
await tx.query(
`UPDATE atlas.platform_listings listing SET missing_since = NULL, archived_at = NULL, updated_at = now()
WHERE listing.store_id = $1 AND listing.platform_id = $2
AND (listing.missing_since IS NOT NULL OR listing.archived_at IS NOT NULL)
AND EXISTS (
SELECT 1 FROM atlas.platform_listing_sync_snapshots snapshot
WHERE snapshot.sync_run_id = $3 AND snapshot.platform_listing_id = listing.id
)`,
[job.payload.store_id, job.payload.platform_id, job.payload.sync_run_id],
);
await tx.query(
`UPDATE atlas.platform_listings listing SET
archived_at = CASE WHEN missing_since IS NOT NULL THEN COALESCE(archived_at, now()) ELSE archived_at END,
missing_since = COALESCE(missing_since, now()), updated_at = now()
WHERE listing.store_id = $1 AND listing.platform_id = $2 AND listing.archived_at IS NULL
AND NOT EXISTS (
SELECT 1 FROM atlas.platform_listing_sync_snapshots snapshot
WHERE snapshot.sync_run_id = $3 AND snapshot.platform_listing_id = listing.id
)`,
[
job.payload.store_id,
job.payload.platform_id,
job.payload.sync_run_id,
],
);
await tx.query(
`UPDATE atlas.platform_product_sync_runs SET status = 'succeeded', listed_count = $2,
upserted_count = $3, cursor_finished = $4, completed_at = now(), error_payload = NULL
WHERE id = $1 AND status = 'running'`,
[job.payload.sync_run_id, listedCount, upsertedCount, cursor],
);
});
}
async function upsertBatch(
tx: DatabaseTransaction,
job: OnlineProductSyncJob,
items: MappedOnlineProduct[],
) {
if (!items.length) return;
if (new Set(items.map((item) => item.offer_id)).size !== items.length)
throw new Error('商品批次存在重复的商家编码');
// 平台商品 ID 变化时,沿用同店铺的商家编码所对应的镜像记录和快照历史。
await tx.query(
`UPDATE atlas.platform_listings listing SET remote_product_id = incoming.remote_product_id
FROM jsonb_to_recordset($3::jsonb) AS incoming(remote_product_id text, offer_id text)
WHERE listing.store_id = $1 AND listing.platform_id = $2
AND listing.offer_id = incoming.offer_id
AND listing.remote_product_id IS DISTINCT FROM incoming.remote_product_id`,
[
job.payload.store_id,
job.payload.platform_id,
JSON.stringify(items.map(({ remote_product_id, offer_id }) => ({ remote_product_id, offer_id }))),
],
);
const result = await tx.query<{ id: string; remote_product_id: string }>(
`WITH incoming AS (
SELECT * FROM jsonb_to_recordset($4::jsonb) AS p(
remote_product_id text, offer_id text, remote_sku text, name text,
selling_price text, list_price text, visible boolean, remote_state text,
moderation_status text, validation_status text, stock_summary jsonb,
image_summary jsonb, error_payload jsonb, visibility_details jsonb, raw_payload jsonb)
), prepared AS (
SELECT incoming.*, origin.id AS atlas_variant_listing_id
FROM incoming LEFT JOIN LATERAL (
SELECT id FROM atlas.product_variant_platform_listings
WHERE store_id = $2 AND platform_id = $3 AND status = 'imported'
AND (offer_id = incoming.offer_id OR platform_product_id = incoming.remote_product_id)
ORDER BY (offer_id = incoming.offer_id) DESC LIMIT 1
) origin ON true
)
INSERT INTO atlas.platform_listings (
workspace_id, store_id, platform_id, remote_product_id, offer_id, remote_sku,
name, currency_code, selling_price, list_price, visible, remote_state,
moderation_status, validation_status, stock_summary, image_summary, error_payload,
visibility_details, source, atlas_variant_listing_id, last_sync_run_id,
last_synced_at, raw_payload)
SELECT current_setting('app.workspace_id')::uuid, $2, $3, remote_product_id, offer_id,
remote_sku, name, 'RUB', selling_price::numeric, list_price::numeric, visible,
remote_state, moderation_status, validation_status, stock_summary, image_summary,
error_payload, visibility_details,
CASE WHEN atlas_variant_listing_id IS NULL THEN 'external_sync'::atlas.platform_listing_source
ELSE 'atlas_published'::atlas.platform_listing_source END,
atlas_variant_listing_id, $1, now(), raw_payload
FROM prepared WHERE true
ON CONFLICT (workspace_id, store_id, platform_id, remote_product_id) DO UPDATE SET
offer_id = EXCLUDED.offer_id, remote_sku = EXCLUDED.remote_sku, name = EXCLUDED.name,
currency_code = EXCLUDED.currency_code, selling_price = EXCLUDED.selling_price,
list_price = EXCLUDED.list_price, visible = EXCLUDED.visible, remote_state = EXCLUDED.remote_state,
moderation_status = EXCLUDED.moderation_status, validation_status = EXCLUDED.validation_status,
stock_summary = EXCLUDED.stock_summary, image_summary = EXCLUDED.image_summary,
error_payload = EXCLUDED.error_payload, visibility_details = EXCLUDED.visibility_details,
source = EXCLUDED.source, atlas_variant_listing_id = EXCLUDED.atlas_variant_listing_id,
last_sync_run_id = EXCLUDED.last_sync_run_id, last_synced_at = EXCLUDED.last_synced_at,
raw_payload = EXCLUDED.raw_payload, updated_at = now()
RETURNING id, remote_product_id`,
[
job.payload.sync_run_id,
job.payload.store_id,
job.payload.platform_id,
JSON.stringify(items),
],
);
const idByRemoteId = new Map(
result.rows.map((row) => [row.remote_product_id, row.id]),
);
await tx.query(
`INSERT INTO atlas.platform_listing_sync_snapshots (workspace_id, platform_listing_id, sync_run_id, payload)
SELECT current_setting('app.workspace_id')::uuid, listing.id, $1, snapshot.payload
FROM jsonb_to_recordset($2::jsonb) AS snapshot(remote_product_id text, payload jsonb)
JOIN atlas.platform_listings listing ON listing.store_id = $3 AND listing.remote_product_id = snapshot.remote_product_id
ON CONFLICT (workspace_id, platform_listing_id, sync_run_id) DO UPDATE SET payload = EXCLUDED.payload`,
[
job.payload.sync_run_id,
JSON.stringify(
items.map((item) => ({
remote_product_id: item.remote_product_id,
payload: item.raw_payload,
})),
),
job.payload.store_id,
],
);
if (idByRemoteId.size !== items.length)
throw new Error('商品批次存在重复的平台 ID');
}
export async function markOnlineProductSyncFailed(
database: Database,
job: OnlineProductSyncJob,
error: unknown,
) {
const code =
error instanceof OzonCredentialError
? 'STORE_CREDENTIAL_UNAVAILABLE'
: error instanceof OzonRequestError
? 'OZON_REQUEST_REJECTED'
: 'OZON_SYNC_UNAVAILABLE';
const message =
code === 'STORE_CREDENTIAL_UNAVAILABLE'
? '店铺凭据不可用于读取 Ozon 商品'
: code === 'OZON_REQUEST_REJECTED'
? 'Ozon 拒绝了商品读取请求,请检查店铺权限'
: '商品同步失败,请稍后重试';
await database.workspaceTransaction(job.workspaceId, (tx) =>
tx.query(
`UPDATE atlas.platform_product_sync_runs SET status = 'failed', failed_count = GREATEST(failed_count, 1),
completed_at = now(), error_payload = $2 WHERE id = $1 AND status IN ('queued', 'running')`,
[job.payload.sync_run_id, { code, message }],
),
);
}
/** 首次未见超过七天即归档;活跃全量同步期间等待其结果,避免临时隐藏正在恢复的商品。 */
export async function archiveExpiredMissingOnlineProducts(
database: Database,
workspaceId?: string,
): Promise<void> {
const workspaces = workspaceId
? [{ id: workspaceId }]
: (
await database.query<{ id: string }>(
'SELECT id FROM atlas.workspaces',
)
).rows;
for (const workspace of workspaces) {
await database.workspaceTransaction(workspace.id, (tx) =>
tx.query(
`UPDATE atlas.platform_listings listing
SET archived_at = now(), updated_at = now()
WHERE listing.missing_since <= now() - interval '7 days'
AND listing.archived_at IS NULL
AND NOT EXISTS (
SELECT 1 FROM atlas.platform_product_sync_runs run
WHERE run.store_id = listing.store_id AND run.status IN ('queued', 'running')
)`,
),
);
}
}
+1 -1
View File
@@ -168,7 +168,7 @@ export async function fetchOzonDictionaryBatch(
return { values, isComplete: false }; return { values, isComplete: false };
} }
async function requestOzon( export async function requestOzon(
clientId: string, clientId: string,
apiKey: string, apiKey: string,
path: string, path: string,
+350
View File
@@ -0,0 +1,350 @@
import assert from 'node:assert/strict';
import { test } from 'node:test';
import sharp from 'sharp';
import { GetObjectCommand, PutObjectCommand } from '@aws-sdk/client-s3';
import { createMediaAiEditRequestSchema } from '@atlas/contracts';
import { classifyAiEditFailure, normalizeAiEditOutput, pinnedLookup, providerImageResult, runMediaAiEdit } from '../src/media-ai-edit.ts';
import { buildMediaAiPrompt } from '../src/media-ai-prompt.ts';
const productId = '733d0d73-3d39-4a98-ac0a-5c0e521d8998';
test('仅扩图在供应商改写中心后仍把原图区域覆盖回结果', async () => {
const source = await sharp({ create: { width: 600, height: 600, channels: 3, background: '#e1252a' } }).png().toBuffer();
const generated = await sharp({ create: { width: 1152, height: 1536, channels: 3, background: '#2055d8' } }).png().toBuffer();
const settings = createMediaAiEditRequestSchema.parse({ product_id: productId, mode: 'outpaint' });
const output = await normalizeAiEditOutput(generated, settings, source);
const { data, info } = await sharp(output).raw().toBuffer({ resolveWithObject: true });
assert.equal(info.width, 1152);
assert.equal(info.height, 1536);
const pixel = (x, y) => [...data.subarray((y * info.width + x) * 3, (y * info.width + x) * 3 + 3)];
assert.ok(pixel(576, 768)[0] > 200, '原图中心仍应为红色');
assert.ok(pixel(576, 20)[2] > 170, '扩充边缘仍应为蓝色');
});
test('提示词明确区分主图、辅助图和尺寸数值', () => {
const settings = createMediaAiEditRequestSchema.parse({
product_id: productId,
reference_asset_ids: ['50ea00b7-6675-4cc7-b7a2-d45f05c07100'],
dimensions: { length: 12, unit: 'cm' }, generate_size_chart: true,
});
const prompt = buildMediaAiPrompt(settings);
assert.match(prompt, /Image 1 is the fixed main product image/);
assert.match(prompt, /Images 2 through 2 are auxiliary references only/);
assert.match(prompt, /length: 12 cm/);
assert.match(prompt, /Do not infer other measurements/);
});
test('Pcore 完成结果的空 b64_json 回退到可下载 URL', () => {
const url = 'https://pcore.ai/v1/images/content/task-example/0';
assert.equal(providerImageResult({ data: [{ b64_json: '', url }] }), url);
assert.equal(providerImageResult({ data: [{ b64_json: ' ', url }] }), url);
assert.deepEqual(providerImageResult({ data: [{ b64_json: '', b64_image: Buffer.from('image').toString('base64'), url }] }),
Buffer.from('image'));
assert.deepEqual(providerImageResult({ data: [{ b64_json: Buffer.from('image').toString('base64'), url }] }),
Buffer.from('image'));
});
test('固定 DNS 回调按 Node 的 all 选项返回地址列表或单个地址', () => {
const lookup = pinnedLookup('8.8.8.8');
lookup('pcore.ai', { all: true }, (error, addresses) => {
assert.equal(error, null);
assert.deepEqual(addresses, [{ address: '8.8.8.8', family: 4 }]);
});
lookup('pcore.ai', { all: false }, (error, address, family) => {
assert.equal(error, null);
assert.equal(address, '8.8.8.8');
assert.equal(family, 4);
});
});
test('异步供应商任务分两次 Outbox 推进,并把结果登记为独立素材', async () => {
const sourceId = '664d0d73-3d39-4a98-ac0a-5c0e521d8998';
const referenceId = '6ebfe347-bc77-4270-8213-b2021271c6b2';
const outputId = '113d0d73-3d39-4a98-ac0a-5c0e521d8998';
const editId = '68db226f-3fcb-4579-ab9e-4932b1d84067';
const source = await sharp({ create: { width: 600, height: 800, channels: 3, background: '#ad3222' } }).jpeg().toBuffer();
const reference = await sharp({ create: { width: 400, height: 400, channels: 4, background: '#2452af80' } }).png().toBuffer();
const generated = await sharp({ create: { width: 1152, height: 1536, channels: 3, background: '#2f72b7' } }).png().toBuffer();
const settings = createMediaAiEditRequestSchema.parse({ product_id: productId,
reference_asset_ids: [referenceId], prompt: '保留商品,使用明亮背景' });
const task = { status: 'queued', source_asset_id: sourceId, reference_asset_ids: [referenceId], settings,
provider_task_id: null, created_at: new Date() };
const outbox = [];
const stored = [];
const database = { workspaceTransaction: async (_workspaceId, work) => work({
query: async (sql, params = []) => {
if (sql.includes('SELECT status, source_asset_id, reference_asset_ids')) return { rows: [{ ...task }] };
if (sql.includes('SELECT id, storage_provider, bucket, object_key')) return { rows: [
{ id: sourceId, storage_provider: 'local_minio', bucket: 'atlas-assets', object_key: 'main.jpg' },
{ id: referenceId, storage_provider: 'local_minio', bucket: 'atlas-assets', object_key: 'reference.jpg' },
] };
if (sql.includes('SELECT status FROM atlas.media_ai_edits')) return { rows: [{ status: task.status }] };
if (sql.includes('SELECT id FROM atlas.media_assets WHERE checksum_sha256')) return { rows: [] };
if (sql.includes('INSERT INTO atlas.media_assets')) return { rows: [{ id: outputId }] };
if (sql.includes('INSERT INTO atlas.outbox_events')) outbox.push(sql);
if (sql.includes("SET status = 'running'")) task.status = 'running';
if (sql.includes('SET provider_task_id =')) task.provider_task_id = params[1];
if (sql.includes("SET status = 'succeeded'")) task.status = 'succeeded';
return { rows: [] };
},
}) };
const storage = { send: async (command) => {
if (command instanceof GetObjectCommand) return { Body: (async function* () {
yield command.input.Key === 'main.jpg' ? source : reference;
})() };
if (command instanceof PutObjectCommand) { stored.push(command.input); return {}; }
throw new Error('unexpected object storage command');
} };
const oldFetch = globalThis.fetch;
const oldBase = process.env.PCORE_BASE_URL;
const oldKey = process.env.PCORE_API_KEY;
process.env.PCORE_BASE_URL = 'https://pcore.ai/v1';
process.env.PCORE_API_KEY = 'test-key';
let requests = 0;
globalThis.fetch = async (url, init) => {
requests += 1;
if (init.method === 'POST') {
assert.equal(url, 'https://pcore.ai/v1/images/edits');
assert.equal(init.headers['Content-Type'], 'application/json');
const body = JSON.parse(init.body);
assert.equal(body.model, 'gpt-image-2');
assert.equal(body.size, '1152x1536');
assert.equal(body.n, 1);
assert.equal(body.async, true);
assert.equal(body.images.length, 2);
assert.deepEqual(body.images, [
{ image_url: `data:image/jpeg;base64,${source.toString('base64')}` },
{ image_url: `data:image/png;base64,${reference.toString('base64')}` },
]);
assert.equal(body.mask, undefined);
return Response.json({ task_id: 'provider-123' });
}
assert.equal(url, 'https://pcore.ai/v1/images/edits/provider-123');
return Response.json({ status: 'completed', data: [{ b64_json: generated.toString('base64') }] });
};
try {
const job = { workspaceId: '30cf54b7-06a3-4e5f-a5fc-d74165115094', payload: { edit_id: editId } };
await runMediaAiEdit(database, storage, job);
assert.equal(task.status, 'running');
assert.equal(task.provider_task_id, 'provider-123');
assert.equal(outbox.length, 1);
await runMediaAiEdit(database, storage, job);
assert.equal(task.status, 'succeeded');
assert.equal(stored.length, 1);
assert.equal(stored[0].ContentType, 'image/jpeg');
assert.equal(requests, 2);
} finally {
globalThis.fetch = oldFetch;
if (oldBase === undefined) delete process.env.PCORE_BASE_URL; else process.env.PCORE_BASE_URL = oldBase;
if (oldKey === undefined) delete process.env.PCORE_API_KEY; else process.env.PCORE_API_KEY = oldKey;
}
});
test('仅扩图以 JSON 图片和蒙版对象提交 Pcore', async () => {
const sourceId = '664d0d73-3d39-4a98-ac0a-5c0e521d8998';
const editId = '68db226f-3fcb-4579-ab9e-4932b1d84067';
const source = await sharp({ create: { width: 600, height: 600, channels: 3, background: '#ad3222' } }).jpeg().toBuffer();
const settings = createMediaAiEditRequestSchema.parse({ product_id: productId, mode: 'outpaint' });
const database = { workspaceTransaction: async (_workspaceId, work) => work({
query: async (sql) => {
if (sql.includes('SELECT status, source_asset_id, reference_asset_ids')) return { rows: [{
status: 'queued', source_asset_id: sourceId, reference_asset_ids: [], settings,
provider_task_id: null, created_at: new Date(),
}] };
if (sql.includes('SELECT id, storage_provider, bucket, object_key')) return { rows: [{
id: sourceId, storage_provider: 'local_minio', bucket: 'atlas-assets', object_key: 'main.jpg',
}] };
return { rows: [] };
},
}) };
const storage = { send: async () => ({ Body: (async function* () { yield source; })() }) };
const oldFetch = globalThis.fetch;
const oldBase = process.env.PCORE_BASE_URL;
const oldKey = process.env.PCORE_API_KEY;
process.env.PCORE_BASE_URL = 'https://pcore.ai';
process.env.PCORE_API_KEY = 'test-key';
globalThis.fetch = async (_url, init) => {
const body = JSON.parse(init.body);
assert.equal(init.headers['Content-Type'], 'application/json');
assert.equal(body.images.length, 1);
assert.match(body.images[0].image_url, /^data:image\/png;base64,/);
assert.match(body.mask.image_url, /^data:image\/png;base64,/);
const image = Buffer.from(body.images[0].image_url.split(',')[1], 'base64');
const mask = Buffer.from(body.mask.image_url.split(',')[1], 'base64');
assert.equal((await sharp(image).metadata()).width, 1152);
assert.equal((await sharp(mask).metadata()).height, 1536);
return Response.json({ task_id: 'provider-outpaint' });
};
try {
await runMediaAiEdit(database, storage, {
workspaceId: '30cf54b7-06a3-4e5f-a5fc-d74165115094', payload: { edit_id: editId },
});
} finally {
globalThis.fetch = oldFetch;
if (oldBase === undefined) delete process.env.PCORE_BASE_URL; else process.env.PCORE_BASE_URL = oldBase;
if (oldKey === undefined) delete process.env.PCORE_API_KEY; else process.env.PCORE_API_KEY = oldKey;
}
});
test('Pcore 终态失败不重复轮询,泛化错误不会误判成内容拒绝', async () => {
const sourceId = '664d0d73-3d39-4a98-ac0a-5c0e521d8998';
const editId = '68db226f-3fcb-4579-ab9e-4932b1d84067';
const settings = createMediaAiEditRequestSchema.parse({ product_id: productId });
let status = 'running';
let failureCode = null;
let providerErrorRaw = null;
let polls = 0;
let providerError = {
code: 'failed', message: '您的请求无法用于生成图像。该请求可能因安全政策被拦截。',
};
const database = { workspaceTransaction: async (_workspaceId, work) => work({
query: async (sql, params = []) => {
if (sql.includes('SELECT status, source_asset_id, reference_asset_ids')) return { rows: [{
status, source_asset_id: sourceId, reference_asset_ids: [], settings,
provider_task_id: 'provider-123', created_at: new Date(),
}] };
if (sql.includes('SELECT id, storage_provider, bucket, object_key')) return { rows: [{
id: sourceId, storage_provider: 'local_minio', bucket: 'atlas-assets', object_key: 'main.jpg',
}] };
if (sql.includes("SET status = 'failed'")) {
status = 'failed';
failureCode = params[1];
if (sql.includes('atlas.operations')) {
providerErrorRaw = params[6];
assert.equal(params[3], 'pcore');
assert.equal(params[4], 'provider_polling');
}
}
return { rows: [] };
},
}) };
const oldFetch = globalThis.fetch;
const oldBase = process.env.PCORE_BASE_URL;
const oldKey = process.env.PCORE_API_KEY;
process.env.PCORE_BASE_URL = 'https://pcore.ai';
process.env.PCORE_API_KEY = 'test-key';
globalThis.fetch = async (url) => {
polls += 1;
assert.equal(url, 'https://pcore.ai/v1/images/edits/provider-123');
return Response.json({ status: 'failed', error: providerError });
};
try {
const job = { workspaceId: '30cf54b7-06a3-4e5f-a5fc-d74165115094', payload: { edit_id: editId } };
await runMediaAiEdit(database, {}, job);
assert.equal(status, 'failed');
assert.equal(failureCode, 'AI_IMAGE_PROVIDER_FAILED');
assert.equal(providerErrorRaw, JSON.stringify(providerError));
assert.equal(polls, 1);
status = 'running';
providerError = { code: 'content_policy_violation', message: 'The image was blocked.' };
await runMediaAiEdit(database, {}, job);
assert.equal(failureCode, 'AI_IMAGE_CONTENT_REJECTED');
assert.equal(providerErrorRaw, JSON.stringify(providerError));
assert.equal(polls, 2);
} finally {
globalThis.fetch = oldFetch;
if (oldBase === undefined) delete process.env.PCORE_BASE_URL; else process.env.PCORE_BASE_URL = oldBase;
if (oldKey === undefined) delete process.env.PCORE_API_KEY; else process.env.PCORE_API_KEY = oldKey;
}
});
test('Pcore 提交时返回错误体会保留原始内容', async () => {
const sourceId = '664d0d73-3d39-4a98-ac0a-5c0e521d8998';
const editId = '68db226f-3fcb-4579-ab9e-4932b1d84067';
const source = await sharp({ create: { width: 600, height: 800, channels: 3, background: '#ad3222' } }).jpeg().toBuffer();
const settings = createMediaAiEditRequestSchema.parse({ product_id: productId });
const database = { workspaceTransaction: async (_workspaceId, work) => work({
query: async (sql) => {
if (sql.includes('SELECT status, source_asset_id, reference_asset_ids')) return { rows: [{
status: 'queued', source_asset_id: sourceId, reference_asset_ids: [], settings,
provider_task_id: null, created_at: new Date(),
}] };
if (sql.includes('SELECT id, storage_provider, bucket, object_key')) return { rows: [{
id: sourceId, storage_provider: 'local_minio', bucket: 'atlas-assets', object_key: 'main.jpg',
}] };
return { rows: [] };
},
}) };
const storage = { send: async () => ({ Body: (async function* () { yield source; })() }) };
const oldFetch = globalThis.fetch;
const oldBase = process.env.PCORE_BASE_URL;
const oldKey = process.env.PCORE_API_KEY;
process.env.PCORE_BASE_URL = 'https://pcore.ai';
process.env.PCORE_API_KEY = 'test-key';
const providerError = { code: 'invalid_request_error', message: '图像文件无效' };
globalThis.fetch = async () => Response.json({ status: 'failed', error: providerError });
try {
const job = { workspaceId: '30cf54b7-06a3-4e5f-a5fc-d74165115094', payload: { edit_id: editId } };
await assert.rejects(runMediaAiEdit(database, storage, job), (error) => {
assert.deepEqual(classifyAiEditFailure(error), {
code: 'AI_IMAGE_GENERATION_FAILED', source: 'pcore', stage: 'provider_submission',
detail: 'Pcore 图片任务请求被拒绝(invalid_request_error 图像文件无效)',
providerErrorRaw: JSON.stringify(providerError),
});
return true;
});
} finally {
globalThis.fetch = oldFetch;
if (oldBase === undefined) delete process.env.PCORE_BASE_URL; else process.env.PCORE_BASE_URL = oldBase;
if (oldKey === undefined) delete process.env.PCORE_API_KEY; else process.env.PCORE_API_KEY = oldKey;
}
});
test('失败诊断区分 Pcore 查询中断、Atlas 图片处理与保存失败', async () => {
const sourceId = '664d0d73-3d39-4a98-ac0a-5c0e521d8998';
const settings = createMediaAiEditRequestSchema.parse({ product_id: productId });
const database = { workspaceTransaction: async (_workspaceId, work) => work({
query: async (sql) => {
if (sql.includes('SELECT status, source_asset_id, reference_asset_ids')) return { rows: [{
status: 'running', source_asset_id: sourceId, reference_asset_ids: [], settings,
provider_task_id: 'provider-123', created_at: new Date(),
}] };
if (sql.includes('SELECT id, storage_provider, bucket, object_key')) return { rows: [{
id: sourceId, storage_provider: 'local_minio', bucket: 'atlas-assets', object_key: 'main.jpg',
}] };
return { rows: [] };
},
}) };
const job = { workspaceId: '30cf54b7-06a3-4e5f-a5fc-d74165115094',
payload: { edit_id: '68db226f-3fcb-4579-ab9e-4932b1d84067' } };
const oldFetch = globalThis.fetch;
const oldBase = process.env.PCORE_BASE_URL;
const oldKey = process.env.PCORE_API_KEY;
process.env.PCORE_BASE_URL = 'https://pcore.ai/v1';
process.env.PCORE_API_KEY = 'test-key';
try {
globalThis.fetch = async () => { throw new TypeError('fetch failed'); };
await assert.rejects(runMediaAiEdit(database, {}, job), (error) => {
assert.deepEqual(classifyAiEditFailure(error), {
code: 'AI_IMAGE_GENERATION_FAILED', source: 'pcore', stage: 'provider_polling',
detail: 'fetch failed', providerErrorRaw: null,
});
return true;
});
globalThis.fetch = async () => Response.json({ status: 'completed',
data: [{ b64_json: Buffer.from('invalid image').toString('base64') }] });
await assert.rejects(runMediaAiEdit(database, {}, job), (error) => {
const failure = classifyAiEditFailure(error);
assert.equal(failure.source, 'atlas');
assert.equal(failure.stage, 'result_processing');
assert.match(failure.detail, /image|format|buffer/i);
return true;
});
const generated = await sharp({ create: { width: 100, height: 100, channels: 3,
background: '#445566' } }).png().toBuffer();
globalThis.fetch = async () => Response.json({ status: 'completed',
data: [{ b64_json: generated.toString('base64') }] });
const storage = { send: async () => { throw new Error('private/object-key.jpg'); } };
await assert.rejects(runMediaAiEdit(database, storage, job), (error) => {
const failure = classifyAiEditFailure(error);
assert.equal(failure.source, 'atlas');
assert.equal(failure.stage, 'result_storage');
assert.equal(failure.detail, 'Error');
return true;
});
} finally {
globalThis.fetch = oldFetch;
if (oldBase === undefined) delete process.env.PCORE_BASE_URL; else process.env.PCORE_BASE_URL = oldBase;
if (oldKey === undefined) delete process.env.PCORE_API_KEY; else process.env.PCORE_API_KEY = oldKey;
}
});
@@ -0,0 +1,186 @@
import assert from 'node:assert/strict';
import { test } from 'node:test';
import sharp from 'sharp';
import { GetObjectCommand, PutObjectCommand } from '@aws-sdk/client-s3';
import { renderThreeFour, runAspectRatioDerivative } from '../src/media-aspect-ratio.ts';
const assertColorNear = (actual, expected) => {
for (let channel = 0; channel < 3; channel++)
assert.ok(Math.abs(actual[channel] - expected[channel]) <= 6, `${actual} 与 ${expected} 色差过大`);
};
const readPixels = async (output) => {
const { data, info } = await sharp(output).raw().toBuffer({ resolveWithObject: true });
assert.equal(info.width, 1200);
assert.equal(info.height, 1600);
assert.equal(info.channels, 3);
return (x, y) => [...data.subarray((y * info.width + x) * 3, (y * info.width + x) * 3 + 3)];
};
test('正方形商品图等比缩放后,上下扩充处使用红色边缘色', async () => {
const source = await sharp({
create: { width: 400, height: 400, channels: 3, background: '#ff0000' },
}).png().toBuffer();
const pixel = await readPixels(await renderThreeFour(source));
assertColorNear(pixel(600, 0), [255, 0, 0]);
assertColorNear(pixel(600, 800), [255, 0, 0]);
assertColorNear(pixel(600, 1599), [255, 0, 0]);
});
test('上下边缘颜色不同,各自填充对应的一侧', async () => {
const source = await sharp({
create: { width: 400, height: 400, channels: 3, background: '#0000ff' },
}).composite([
{ input: { create: { width: 400, height: 48, channels: 3, background: '#ff0000' } }, left: 0, top: 0 },
{ input: { create: { width: 400, height: 48, channels: 3, background: '#808080' } }, left: 0, top: 352 },
]).png().toBuffer();
const pixel = await readPixels(await renderThreeFour(source));
assertColorNear(pixel(600, 0), [255, 0, 0]);
assertColorNear(pixel(600, 800), [0, 0, 255]);
assertColorNear(pixel(600, 1599), [128, 128, 128]);
});
test('同一边缘有多种颜色时,接缝相接且远处不形成竖向条纹', async () => {
const source = await sharp({
create: { width: 400, height: 400, channels: 3, background: '#808080' },
}).composite([
{ input: { create: { width: 200, height: 48, channels: 3, background: '#ff0000' } }, left: 0, top: 0 },
{ input: { create: { width: 200, height: 48, channels: 3, background: '#0000ff' } }, left: 200, top: 0 },
{ input: { create: { width: 200, height: 48, channels: 3, background: '#ffff00' } }, left: 0, top: 352 },
{ input: { create: { width: 200, height: 48, channels: 3, background: '#00ff00' } }, left: 200, top: 352 },
]).png().toBuffer();
const pixel = await readPixels(await renderThreeFour(source));
for (const [x, topColor, bottomColor] of [
[300, [255, 0, 0], [255, 255, 0]],
[900, [0, 0, 255], [0, 255, 0]],
]) {
assertColorNear(pixel(x, 199), topColor);
assertColorNear(pixel(x, 199), pixel(x, 200));
assertColorNear(pixel(x, 1400), pixel(x, 1399));
assertColorNear(pixel(x, 1400), bottomColor);
}
assertColorNear(pixel(300, 0), pixel(900, 0));
assertColorNear(pixel(300, 1599), pixel(900, 1599));
assertColorNear(pixel(300, 0), [128, 0, 128]);
assertColorNear(pixel(300, 1599), [128, 255, 0]);
});
test('竖长图片左右扩充处分别使用左绿右黄的边缘色', async () => {
const source = await sharp({
create: { width: 300, height: 800, channels: 3, background: '#0000ff' },
}).composite([
{ input: { create: { width: 36, height: 800, channels: 3, background: '#00ff00' } }, left: 0, top: 0 },
{ input: { create: { width: 36, height: 800, channels: 3, background: '#ffff00' } }, left: 264, top: 0 },
]).png().toBuffer();
const pixel = await readPixels(await renderThreeFour(source));
assertColorNear(pixel(0, 800), [0, 255, 0]);
assertColorNear(pixel(600, 800), [0, 0, 255]);
assertColorNear(pixel(1199, 800), [255, 255, 0]);
});
test('左右边缘沿高度变色时,接缝相接且远处不形成横向条纹', async () => {
const source = await sharp({
create: { width: 300, height: 800, channels: 3, background: '#808080' },
}).composite([
{ input: { create: { width: 36, height: 400, channels: 3, background: '#00ff00' } }, left: 0, top: 0 },
{ input: { create: { width: 36, height: 400, channels: 3, background: '#0000ff' } }, left: 0, top: 400 },
{ input: { create: { width: 36, height: 400, channels: 3, background: '#ffff00' } }, left: 264, top: 0 },
{ input: { create: { width: 36, height: 400, channels: 3, background: '#ff00ff' } }, left: 264, top: 400 },
]).png().toBuffer();
const pixel = await readPixels(await renderThreeFour(source));
for (const [y, leftColor, rightColor] of [
[400, [0, 255, 0], [255, 255, 0]],
[1200, [0, 0, 255], [255, 0, 255]],
]) {
assertColorNear(pixel(299, y), leftColor);
assertColorNear(pixel(299, y), pixel(300, y));
assertColorNear(pixel(900, y), pixel(899, y));
assertColorNear(pixel(900, y), rightColor);
}
assertColorNear(pixel(0, 400), pixel(0, 1200));
assertColorNear(pixel(1199, 400), pixel(1199, 1200));
assertColorNear(pixel(0, 400), [0, 128, 128]);
assertColorNear(pixel(1199, 400), [255, 128, 128]);
});
test('透明边缘以白底取色', async () => {
const source = await sharp({
create: { width: 400, height: 400, channels: 4, background: '#00000000' },
}).composite([{ input: {
create: { width: 200, height: 200, channels: 3, background: '#ff0000' },
}, left: 100, top: 100 }]).png().toBuffer();
const pixel = await readPixels(await renderThreeFour(source));
assertColorNear(pixel(600, 0), [255, 255, 255]);
assertColorNear(pixel(600, 800), [255, 0, 0]);
assertColorNear(pixel(600, 1599), [255, 255, 255]);
});
test('取色和留白遵循 EXIF 旋转后的显示方向', async () => {
const source = await sharp({
create: { width: 400, height: 200, channels: 3, background: '#0000ff' },
}).composite([
{ input: { create: { width: 400, height: 24, channels: 3, background: '#ff0000' } }, left: 0, top: 0 },
{ input: { create: { width: 400, height: 24, channels: 3, background: '#808080' } }, left: 0, top: 176 },
]).jpeg({ quality: 100 }).withMetadata({ orientation: 6 }).toBuffer();
const pixel = await readPixels(await renderThreeFour(source));
assertColorNear(pixel(0, 800), [128, 128, 128]);
assertColorNear(pixel(1199, 800), [255, 0, 0]);
});
test('拒绝不受支持的来源格式', async () => {
const source = await sharp({
create: { width: 8, height: 8, channels: 3, background: '#ff0000' },
}).gif().toBuffer();
await assert.rejects(renderThreeFour(source), /格式不受支持/);
});
test('任务读取原图、写入独立派生素材,并完成持久化状态', async () => {
const source = await sharp({
create: { width: 400, height: 400, channels: 3, background: '#ff0000' },
}).png().toBuffer();
const writes = [];
const storage = { send: async (command) => {
if (command instanceof GetObjectCommand) return {
Body: (async function* () { yield source; })(),
};
if (command instanceof PutObjectCommand) {
writes.push(command.input);
return {};
}
throw new Error('未知存储操作');
} };
let status = 'queued';
let outputAssetId = null;
const queries = [];
const database = { workspaceTransaction: async (_workspaceId, work) => work({
query: async (sql, args) => {
queries.push({ sql, args });
if (sql.includes('SELECT d.status')) return { rows: [{
status,
storage_provider: 'local_minio',
bucket: 'test-bucket',
object_key: 'original.png',
}] };
if (sql.includes("SET status = 'running'")) status = 'running';
if (sql.includes("SET status = 'succeeded'")) status = 'succeeded';
if (sql.includes('SELECT id FROM atlas.media_assets')) return { rows: [] };
if (sql.includes('INSERT INTO atlas.media_assets')) return { rows: [{ id: 'output-asset' }] };
if (sql.includes("result = jsonb_build_object")) outputAssetId = args[1];
return { rows: [] };
},
}) };
const job = {
workspaceId: 'workspace-id',
payload: { derivative_id: 'derivative-id' },
};
await runAspectRatioDerivative(database, storage, job);
assert.equal(status, 'succeeded');
assert.equal(outputAssetId, 'output-asset');
assert.equal(writes.length, 1);
assert.equal(writes[0].Bucket, 'test-bucket');
assert.match(writes[0].Key, /^derivatives\/workspace-id\/three-four\/[a-f0-9]{64}\.jpg$/);
assert.deepEqual(await sharp(writes[0].Body).metadata().then(({ width, height }) => [width, height]), [1200, 1600]);
await runAspectRatioDerivative(database, storage, job);
assert.equal(writes.length, 1);
assert.ok(queries.some(({ sql }) => sql.includes('UPDATE atlas.operations')));
});
+62
View File
@@ -0,0 +1,62 @@
import assert from 'node:assert/strict';
import { test } from 'node:test';
import sharp from 'sharp';
import { renderWatermark } from '../src/media-watermark.ts';
const template = {
kind: 'image', opacity: '1', image_size_px: 40, border_radius_px: 0,
position_x: 1, position_y: 1, position: 'bottom_right',
};
const pixel = async (buffer, x, y) => {
const { data, info } = await sharp(buffer).raw().toBuffer({ resolveWithObject: true });
return [...data.subarray((y * info.width + x) * info.channels,
(y * info.width + x) * info.channels + 3)];
};
test('图片水印按坐标贴近右下边,并保持源图尺寸', async () => {
const source = await sharp({ create: { width: 120, height: 100, channels: 3, background: '#ffffff' } }).png().toBuffer();
const mark = await sharp({ create: { width: 40, height: 40, channels: 4, background: '#ff0000ff' } }).png().toBuffer();
const output = await renderWatermark(source, template, mark);
assert.equal(output.width, 120);
assert.equal(output.height, 100);
assert.ok((await pixel(output.bytes, 90, 70))[0] > 200);
assert.ok((await pixel(output.bytes, 90, 70))[1] < 60);
assert.ok((await pixel(output.bytes, 15, 15))[1] > 240);
});
test('大图中的水印大小和底边距与 240×320 管理预览等比一致', async () => {
const source = await sharp({ create: { width: 1200, height: 1600, channels: 3, background: '#ffffff' } }).png().toBuffer();
const mark = await sharp({ create: { width: 80, height: 80, channels: 4, background: '#ff0000ff' } }).png().toBuffer();
const output = await renderWatermark(source, { ...template, image_size_px: 80 }, mark);
assert.ok((await pixel(output.bytes, 800, 1200))[1] < 60);
assert.ok((await pixel(output.bytes, 700, 1200))[1] > 240);
assert.ok((await pixel(output.bytes, 1100, 1500))[1] < 60);
assert.ok((await pixel(output.bytes, 1100, 1585))[1] > 240);
});
test('图片水印允许 10px 最小尺寸', async () => {
const source = await sharp({ create: { width: 240, height: 320, channels: 3, background: '#ffffff' } }).png().toBuffer();
const mark = await sharp({ create: { width: 20, height: 20, channels: 4, background: '#ff0000ff' } }).png().toBuffer();
const output = await renderWatermark(source, { ...template, image_size_px: 10 }, mark);
assert.ok((await pixel(output.bytes, 227, 307))[1] < 80);
assert.ok((await pixel(output.bytes, 217, 297))[1] > 220);
});
test('圆角使图片水印角落保持底图颜色', async () => {
const source = await sharp({ create: { width: 120, height: 100, channels: 3, background: '#ffffff' } }).png().toBuffer();
const mark = await sharp({ create: { width: 40, height: 40, channels: 4, background: '#ff0000ff' } }).png().toBuffer();
const output = await renderWatermark(source, { ...template, border_radius_px: 18 }, mark);
assert.ok((await pixel(output.bytes, 76, 56))[1] > 200);
assert.ok((await pixel(output.bytes, 95, 75))[1] < 60);
});
test('文字水印支持 XML 特殊字符,不会把内容当成 SVG', async () => {
const source = await sharp({ create: { width: 200, height: 100, channels: 3, background: '#ffffff' } }).png().toBuffer();
const output = await renderWatermark(source, {
...template, kind: 'text', text_content: '<ATLAS & 1>', font_code: 'NotoSans', font_size_px: 24,
position_x: 0, position_y: 0,
});
const { data } = await sharp(output.bytes).raw().toBuffer({ resolveWithObject: true });
assert.ok(data.some((value) => value < 100));
});
@@ -0,0 +1,63 @@
import assert from 'node:assert/strict';
import { test } from 'node:test';
import {
mapOzonProduct,
parseOzonInfoItems,
parseOzonListPage,
} from '../src/online-product-mapper.ts';
test('列表游标与商品标识按平台响应读取,缺失标识时拒绝继续同步', () => {
const page = parseOzonListPage({
result: {
items: [{ product_id: 123, offer_id: 'offer-1' }],
last_id: 'next-page',
},
});
assert.deepEqual(page, {
items: [{ product_id: '123', offer_id: 'offer-1' }],
lastId: 'next-page',
});
assert.throws(
() => parseOzonListPage({ result: { items: [{ product_id: 123 }] } }),
/缺少商品标识/,
);
assert.throws(() => parseOzonInfoItems({ result: {} }), /响应格式异常/);
});
test('可见性未知、库存零和审核错误保留各自语义', () => {
const product = mapOzonProduct(
{
id: 123,
offer_id: 'offer-1',
name: '商品',
price: '999.00',
old_price: '1110.00',
stocks: { present: 0 },
status: {
moderate_status: 'declined',
validation_state: 'failed',
decline_reasons: [{ message: '图片不符合要求' }],
},
images: [{ file_name: 'https://example.com/product.jpg' }],
sources: [{ sku: 456 }],
},
{ product_id: '123', offer_id: 'offer-1' },
);
assert.equal(product.visible, null);
assert.deepEqual(product.stock_summary, { total: 0, has_data: true });
assert.equal(product.moderation_status, 'declined');
assert.equal(product.remote_sku, '456');
assert.equal(product.image_summary.url, 'https://example.com/product.jpg');
assert.equal(product.error_payload[0].message, '图片不符合要求');
assert.equal(product.selling_price, '999.00');
});
test('不存在库存数据和非 HTTPS 图片不能伪装成有效平台数据', () => {
const product = mapOzonProduct(
{ id: 123, price: 'abc', primary_image: 'http://example.com/a.jpg' },
{ product_id: '123', offer_id: 'offer-1' },
);
assert.deepEqual(product.stock_summary, { total: 0, has_data: false });
assert.equal(product.image_summary.url, null);
assert.equal(product.selling_price, null);
});
@@ -0,0 +1,304 @@
import assert from 'node:assert/strict';
import {
createCipheriv,
createHash,
randomBytes,
randomUUID,
} from 'node:crypto';
import { test } from 'node:test';
import { createApplicationDatabase } from '@atlas/database';
import {
archiveExpiredMissingOnlineProducts,
markOnlineProductSyncFailed,
runOnlineProductSync,
} from '../src/online-product-sync.ts';
const databaseUrl = process.env.DATABASE_URL;
const encryptionKey = process.env.STORE_SECRET_ENCRYPTION_KEY;
test(
'全量同步写入镜像,连续未见归档,再出现恢复且租户隔离',
{ skip: !databaseUrl || !encryptionKey },
async () => {
const database = createApplicationDatabase(databaseUrl);
const workspaceId = randomUUID();
const storeId = randomUUID();
const secretId = randomUUID();
const platform = await database.query(
"SELECT id FROM atlas.platforms WHERE code = 'OZON' LIMIT 1",
);
assert.ok(platform.rows[0]);
const platformId = platform.rows[0].id;
const originalFetch = globalThis.fetch;
let productPresent = true;
let failList = false;
let remoteProductId = 93847265;
let failAfterFirstPage = false;
globalThis.fetch = async (url, init) => {
if (String(url).endsWith('/v3/product/list')) {
if (failList) return new Response('{}', { status: 503 });
const cursor = JSON.parse(init.body).last_id;
if (failAfterFirstPage && cursor)
return new Response('{}', { status: 503 });
return Response.json({
result: {
items: productPresent
? [
{
product_id: remoteProductId,
offer_id: 'sync-test-offer',
},
]
: [],
last_id: failAfterFirstPage ? 'second-page' : '',
},
});
}
if (String(url).endsWith('/v3/product/info/list'))
return Response.json({
result: {
items: [
{
id: remoteProductId,
offer_id: 'sync-test-offer',
name: '同步测试商品',
price: '99.00',
old_price: '109.00',
visible: true,
stocks: { present: 0 },
sources: [{ sku: 987654 }],
status: {
moderate_status: 'approved',
validation_state: 'success',
},
},
],
},
});
throw new Error('Unexpected Ozon path');
};
const key = createHash('sha256').update(encryptionKey).digest();
const nonce = randomBytes(12);
const cipher = createCipheriv('aes-256-gcm', key, nonce);
const ciphertext = Buffer.concat([
cipher.update('test-key', 'utf8'),
cipher.final(),
cipher.getAuthTag(),
]);
try {
await database.query(
'INSERT INTO atlas.workspaces (id, name, slug) VALUES ($1, $2, $3)',
[workspaceId, '在线商品同步测试', `online-sync-${workspaceId}`],
);
await database.workspaceTransaction(workspaceId, async (tx) => {
await tx.query(
`INSERT INTO atlas.stores (id, workspace_id, platform_id, name, platform_client_id, currency_code, is_default, status, credential_status)
VALUES ($1, $2, $3, '测试门店', $4, 'RUB', true, 'active', 'valid')`,
[
storeId,
workspaceId,
platformId,
`online-sync-${storeId}`,
],
);
await tx.query(
`INSERT INTO atlas.store_secrets (id, workspace_id, store_id, kind, secret_ciphertext, secret_nonce, encryption_key_version, fingerprint, status)
VALUES ($1, $2, $3, 'seller_api_key', $4, $5, 'v1', $6, 'valid')`,
[
secretId,
workspaceId,
storeId,
ciphertext,
nonce,
randomUUID(),
],
);
});
const createJob = async () => {
const created = await database.workspaceTransaction(
workspaceId,
(tx) =>
tx.query(
`INSERT INTO atlas.platform_product_sync_runs
(workspace_id, store_id, platform_id, trigger_source) VALUES ($1, $2, $3, 'manual') RETURNING id`,
[workspaceId, storeId, platformId],
),
);
const runId = created.rows[0].id;
const job = {
eventId: randomUUID(),
workspaceId,
eventType: 'platform_product_sync.requested',
payload: {
sync_run_id: runId,
store_id: storeId,
platform_id: platformId,
secret_id: secretId,
},
};
return { runId, job };
};
const sync = async () => {
const { runId, job } = await createJob();
if (failList) {
let failure;
try {
await runOnlineProductSync(database, job);
} catch (error) {
failure = error;
}
assert.ok(failure);
await markOnlineProductSyncFailed(database, job, failure);
await runOnlineProductSync(database, job);
} else await runOnlineProductSync(database, job);
const run = await database.workspaceTransaction(
workspaceId,
(tx) =>
tx.query(
'SELECT status, listed_count, upserted_count FROM atlas.platform_product_sync_runs WHERE id = $1',
[runId],
),
);
assert.equal(
run.rows[0].status,
failList ? 'failed' : 'succeeded',
);
return run.rows[0];
};
const first = await sync();
assert.equal(first.listed_count, 1);
assert.equal(first.upserted_count, 1);
const listing = async () =>
database.workspaceTransaction(workspaceId, (tx) =>
tx.query(
'SELECT id, remote_product_id, name, visible, stock_summary, missing_since, archived_at FROM atlas.platform_listings WHERE store_id = $1',
[storeId],
),
);
let row = (await listing()).rows[0];
assert.equal(row.name, '同步测试商品');
assert.equal(row.visible, true);
assert.deepEqual(row.stock_summary, { total: 0, has_data: true });
const listingId = row.id;
remoteProductId = 93847266;
await sync();
row = (await listing()).rows[0];
assert.equal(row.id, listingId);
assert.equal(row.remote_product_id, String(remoteProductId));
assert.equal((await listing()).rows.length, 1);
failList = true;
await sync();
row = (await listing()).rows[0];
assert.equal(row.missing_since, null);
assert.equal(row.archived_at, null);
failList = false;
productPresent = false;
await sync();
row = (await listing()).rows[0];
assert.ok(row.missing_since);
assert.equal(row.archived_at, null);
await database.workspaceTransaction(workspaceId, (tx) =>
tx.query(
`UPDATE atlas.platform_listings SET missing_since = now() - interval '8 days' WHERE store_id = $1`,
[storeId],
),
);
const activeRun = await database.workspaceTransaction(
workspaceId,
(tx) =>
tx.query(
`INSERT INTO atlas.platform_product_sync_runs (workspace_id, store_id, platform_id, trigger_source)
VALUES ($1, $2, $3, 'manual') RETURNING id`,
[workspaceId, storeId, platformId],
),
);
await archiveExpiredMissingOnlineProducts(database, workspaceId);
assert.equal((await listing()).rows[0].archived_at, null);
await database.workspaceTransaction(workspaceId, (tx) =>
tx.query(
`DELETE FROM atlas.platform_product_sync_runs WHERE id = $1`,
[activeRun.rows[0].id],
),
);
await archiveExpiredMissingOnlineProducts(database, workspaceId);
row = (await listing()).rows[0];
assert.ok(row.archived_at);
productPresent = true;
await sync();
row = (await listing()).rows[0];
assert.equal(row.missing_since, null);
assert.equal(row.archived_at, null);
productPresent = false;
await sync();
row = (await listing()).rows[0];
assert.ok(row.missing_since);
assert.equal(row.archived_at, null);
await sync();
row = (await listing()).rows[0];
assert.ok(row.archived_at);
productPresent = true;
await sync();
row = (await listing()).rows[0];
assert.equal(row.missing_since, null);
assert.equal(row.archived_at, null);
productPresent = false;
await sync();
const missingSinceBeforeRetry = (await listing()).rows[0].missing_since;
assert.ok(missingSinceBeforeRetry);
productPresent = true;
const { job: retryJob } = await createJob();
failAfterFirstPage = true;
await assert.rejects(runOnlineProductSync(database, retryJob));
row = (await listing()).rows[0];
assert.equal(row.missing_since.toISOString(), missingSinceBeforeRetry.toISOString());
assert.equal(row.archived_at, null);
failAfterFirstPage = false;
productPresent = false;
await runOnlineProductSync(database, retryJob);
row = (await listing()).rows[0];
assert.ok(row.missing_since);
assert.ok(row.archived_at);
const otherWorkspace = await database.workspaceTransaction(
randomUUID(),
(tx) =>
tx.query(
'SELECT id FROM atlas.platform_listings WHERE store_id = $1',
[storeId],
),
);
assert.equal(otherWorkspace.rows.length, 0);
} finally {
globalThis.fetch = originalFetch;
await database
.workspaceTransaction(workspaceId, async (tx) => {
await tx.query(
'DELETE FROM atlas.platform_listing_sync_snapshots WHERE workspace_id = $1',
[workspaceId],
);
await tx.query(
'DELETE FROM atlas.platform_listings WHERE workspace_id = $1',
[workspaceId],
);
await tx.query(
'DELETE FROM atlas.platform_product_sync_runs WHERE workspace_id = $1',
[workspaceId],
);
await tx.query(
'DELETE FROM atlas.store_secrets WHERE workspace_id = $1',
[workspaceId],
);
await tx.query(
'DELETE FROM atlas.stores WHERE workspace_id = $1',
[workspaceId],
);
})
.catch(() => {});
await database
.query('DELETE FROM atlas.workspaces WHERE id = $1', [
workspaceId,
])
.catch(() => {});
await database.close();
}
},
);
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -17,7 +17,7 @@ API 启动后会在 `/api-docs` 提供 Swagger UI,并在其 OpenAPI 文档中
| [`pricing.md`](./pricing.md) | 已设计 | 汇率、物流价卡、即时试算、定价方案、报价快照衔接。 | | [`pricing.md`](./pricing.md) | 已设计 | 汇率、物流价卡、即时试算、定价方案、报价快照衔接。 |
| [`collection-and-extension.md`](./collection-and-extension.md) | 已设计 | 插件授权、采集写入、采集箱查询、素材转存与状态。 | | [`collection-and-extension.md`](./collection-and-extension.md) | 已设计 | 插件授权、采集写入、采集箱查询、素材转存与状态。 |
| [`product-editor-and-publishing.md`](./product-editor-and-publishing.md) | 编辑器与发布预检已实现 | 编辑上下文、完整 SKU 草稿、店铺切换、规格继承、AI 来源素材字段级合并、平台级 Ozon 类目/属性/字典按需刷新及首次上架目录预检;定价、AI 任务与真正的 Ozon 发布 job 仍待实现。 | | [`product-editor-and-publishing.md`](./product-editor-and-publishing.md) | 编辑器与发布预检已实现 | 编辑上下文、完整 SKU 草稿、店铺切换、规格继承、AI 来源素材字段级合并、平台级 Ozon 类目/属性/字典按需刷新及首次上架目录预检;定价、AI 任务与真正的 Ozon 发布 job 仍待实现。 |
| [`online-listings.md`](./online-listings.md) | 初版已设计 | 店铺独有的在线商品镜像、读取与异步同步;编辑/认领仍待讨论。 | | [`online-listings.md`](./online-listings.md) | 首期已实现 | 店铺独有的在线商品镜像、读取与手动异步同步;编辑/认领仍待讨论。 |
| [`platform-catalog-admin.md`](./platform-catalog-admin.md) | 已实现 | 全局系统管理员授权、平台参考源配置、类目/属性/字典读取、每周树同步、刷新策略、三种类型属性批次、优先级、任务状态及后台页面。 | | [`platform-catalog-admin.md`](./platform-catalog-admin.md) | 已实现 | 全局系统管理员授权、平台参考源配置、类目/属性/字典读取、每周树同步、刷新策略、三种类型属性批次、优先级、任务状态及后台页面。 |
旧的 [`../product/api.md`](../product/api.md) 是商品域的早期草案:已确认的产品规则可作为背景,但不能当作最终 HTTP 契约。后续商品模块会迁入本目录并替换其中的路径和细节。 旧的 [`../product/api.md`](../product/api.md) 是商品域的早期草案:已确认的产品规则可作为背景,但不能当作最终 HTTP 契约。后续商品模块会迁入本目录并替换其中的路径和细节。
+2
View File
@@ -30,6 +30,8 @@ Headers:扩展 Bearer token 与 `Idempotency-Key` 必填。请求体为已定
尚未创建任何 `product_platform_profile` 时,SKU 草稿仍是来源投影:同一 `source_variant_key` 原位更新,新 key 创建,缺失 key 软删除。**一旦进入任一平台编辑器,SKU 草稿及其平台 mapping 不再是可替换来源投影。** 此后的重复采集仍更新来源证据、规范化载荷和候选素材;同 key 复用原 SKU ID 且不覆盖用户编辑字段,新发现的来源 SKU 会为每个已有平台配置创建 `not_listed` mapping,来源未返回的 SKU 不自动删除或恢复。来源差异的人工审查 UI 另由商品编辑模块提供,采集接口不得通过删除 SKU 或级联删除映射来“覆盖”草稿。 尚未创建任何 `product_platform_profile` 时,SKU 草稿仍是来源投影:同一 `source_variant_key` 原位更新,新 key 创建,缺失 key 软删除。**一旦进入任一平台编辑器,SKU 草稿及其平台 mapping 不再是可替换来源投影。** 此后的重复采集仍更新来源证据、规范化载荷和候选素材;同 key 复用原 SKU ID 且不覆盖用户编辑字段,新发现的来源 SKU 会为每个已有平台配置创建 `not_listed` mapping,来源未返回的 SKU 不自动删除或恢复。来源差异的人工审查 UI 另由商品编辑模块提供,采集接口不得通过删除 SKU 或级联删除映射来“覆盖”草稿。
1688 结构化采集以 `skuModel.skuInfoMap` 的真实可售记录生成 SKU;每条记录的 `price`(CNY)按两位小数四舍五入后写入对应 SKU 的 `purchase_cost_cny`,`skuProps.value` 中按规格名称匹配的图片作为带 `variantSourceKey` 的 `source_sku` 候选。尚无平台编辑草稿时,重复采集会刷新来源采购价;进入编辑器后不覆盖用户修改过的采购价。SKU 对应的来源图片可在编辑页预览,用户选择后才转存并保存为正式主图。
成功返回 `201`(新建)或 `200`(复用): 成功返回 `201`(新建)或 `200`(复用):
```json ```json
+2
View File
@@ -93,6 +93,8 @@ Headers:`Idempotency-Key` 必填。Body 可省略,或为:
Worker 只在完成所有分页和详情批次后把 run 标记 `succeeded`。部分请求失败时 run 为 `failed`,已成功 upsert 的记录仍保留,但不执行“未见记录归档”步骤,避免将网络错误放大为商品消失。 Worker 只在完成所有分页和详情批次后把 run 标记 `succeeded`。部分请求失败时 run 为 `failed`,已成功 upsert 的记录仍保留,但不执行“未见记录归档”步骤,避免将网络错误放大为商品消失。
归档规则(2026-09-26 确认):首次成功全量同步未见某 SKU,仅记录 `missing_since`;第二次成功全量同步仍未见,或 `missing_since` 已超过 7 天,写入 `archived_at`。Worker 定时处理超过 7 天的记录,活跃全量同步期间暂缓归档。失败、部分同步或取消本身不改变这两个字段;再次出现时清空并恢复展示。列表默认排除已归档商品。
## 5. 明确不在首期接口中的操作 ## 5. 明确不在首期接口中的操作
- 从在线商品编辑 Ozon 商品、重新发布或更新已导入 SKU; - 从在线商品编辑 Ozon 商品、重新发布或更新已导入 SKU;
+18 -5
View File
@@ -86,6 +86,18 @@ Headers:`Idempotency-Key` 必填,页头平台由 header 指定,body 为空
## 2. 原子保存草稿与 SKU 规则 ## 2. 原子保存草稿与 SKU 规则
### 单张 AI 图片编辑(SKU 选图弹窗)
商品编辑页固定任务入口使用 `GET /api/products/{product_id}/image-tasks?limit=20&cursor=...`。`limit` 默认 20、最大 100,按进行中优先、创建时间倒序、ID 倒序稳定分页,返回 `{ data: ProductImageTask[], meta: { next_cursor, has_more } }`。接口只返回当前工作空间、当前商品的图片 AI 任务;首期包含单张编辑,后续套图任务按同一结构加入。每项包含 `kind: single_ai_edit | ai_suite`、`status`、`phase`、已完成数/总数、创建/完成时间、稳定错误码、`failure_source: pcore | atlas | null`、`failure_stage: task_setup | input_preparation | provider_submission | provider_polling | result_download | result_processing | result_storage | null`、`failure_detail: string | null`、`provider_error_raw: string | null` 与结果素材 ID 数组。失败来源表示 Pcore 接口返回/调用失败或 Atlas 自身处理失败;步骤指出实际出错的位置。`failure_detail` 是最长 500 字符的脱敏诊断摘要,不包含密钥、对象键或预签名 URL。Pcore 失败时 `provider_error_raw` 保留供应商原始错误响应文本(JSON 响应保存错误对象,最多 32,768 字符),便于在当前工作空间的任务列表中查看;没有供应商响应时为 `null`。历史任务未记录的字段返回 `null`,不得按通用错误码猜测失败来源。`GET /api/media-ai-edits/{task_id}` 的单张编辑响应也返回三个诊断字段,供未关闭的编辑弹窗显示。不返回供应商任务 ID、对象键或预览 URL。任务入口每 3 秒刷新运行中的任务,其余时间低频刷新;任务提交后即时刷新。查看结果时再用素材访问接口签发短时预览 URL。单张编辑任务取消沿用 `POST /api/media-ai-edits/{task_id}/cancel`。
`POST /api/media-assets/{asset_id}/ai-edits`:必须带 `Idempotency-Key`,请求体带当前工作空间 `product_id`,图片字段只使用正式素材 ID,不接受图片 URL。`asset_id` 为固定主图;`reference_asset_ids` 最多两项,仅作辅助参考。请求支持 `prompt`、`mode: edit | outpaint`、`ratio: 3:4 | 1:1 | 4:5`、图中文字翻译与目标语言、背景模式与描述、五种可改写的视觉风格、商品长宽高及尺寸标注图。`outpaint` 只允许 3:4、单主图,不生成尺寸标注。成功返回 HTTP 202、任务 ID 和 `queued` 状态。
`GET /api/media-ai-edits/{task_id}` 查询 `queued | running | succeeded | failed | cancelled`,成功时才返回独立 `output_asset` 的正式素材 ID、尺寸与短时预览地址;失败仅返回稳定错误码。`GET /api/media-ai-edits?product_id={product_id}` 返回当前商品最近 100 条成功任务,供右侧「AI 生图记录」复用素材。`POST /api/media-ai-edits/{task_id}/cancel` 需带 `Idempotency-Key`,将未完成任务标记为取消;已完成任务保持原结果。任务、operation、Outbox 在同一工作空间事务提交;Worker 持久化供应商任务 ID 后通过延迟 Outbox 继续轮询。供应商密钥、对象存储键和供应商临时图片地址不进入 API 响应或 Outbox。
供应商异步任务返回终态失败时不再轮询或重试同一任务 ID。只有供应商明确返回内容政策或审核错误码时才使用稳定错误码 `AI_IMAGE_CONTENT_REJECTED`;笼统的 `failed`(即使消息称“可能”涉及安全政策)及其他供应商终态失败使用 `AI_IMAGE_PROVIDER_FAILED`;提交、网络、存储等非终态问题在重试耗尽后使用 `AI_IMAGE_GENERATION_FAILED`。Worker 在终态失败或重试耗尽时把失败来源、步骤和脱敏摘要写入同工作空间的 operation 结果;用户界面显示阶段和摘要,并在任务卡片展示已取得的 Pcore 原始错误内容。历史任务若原先未保存错误内容,则相关字段为 `null`。
生成结果先在单张 AI 编辑抽屉中预览。用户点击“添加到当前 SKU”后,新素材插入原图后方,原图保留;选图弹窗“确认”仅写入编辑页表单,页面“保存”后才持久化 SKU `media_asset_ids`。关闭或取消编辑不修改 SKU 草稿。图片源、参考图、结果均受工作空间隔离;任务输出与源图分别存储。
`PUT /api/products/{product_id}/editor-draft` `PUT /api/products/{product_id}/editor-draft`
Headers:`If-Match: "{product_revision}:{profile_revision}"`、`Idempotency-Key` 必填。请求是整个编辑页的原子快照: Headers:`If-Match: "{product_revision}:{profile_revision}"`、`Idempotency-Key` 必填。请求是整个编辑页的原子快照:
@@ -123,11 +135,12 @@ Headers:`If-Match: "{product_revision}:{profile_revision}"`、`Idempotency-Key
- `profile.platform_product_category_id` 必须是当前平台的活动类目;同平台门店共享类目、类型属性和字典缓存。服务端从平台类目记录取得 `platform_type_id`,客户端不能伪造类型 ID。过渡期仍接受 `store_product_category_id`,但只用于按 `description_category_id + type_id` 映射平台类目,不参与当前读取和校验。 - `profile.platform_product_category_id` 必须是当前平台的活动类目;同平台门店共享类目、类型属性和字典缓存。服务端从平台类目记录取得 `platform_type_id`,客户端不能伪造类型 ID。过渡期仍接受 `store_product_category_id`,但只用于按 `description_category_id + type_id` 映射平台类目,不参与当前读取和校验。
- 类目切换或平台 schema 移除属性时,原有不再适用的属性不会进入新的发布草稿;服务端将它们写入 `unmapped_attributes` 并在编辑快照中返回。用户删除 SKU 时,其 SKU 属性随 SKU 一起移除,不生成无主未映射属性。 - 类目切换或平台 schema 移除属性时,原有不再适用的属性不会进入新的发布草稿;服务端将它们写入 `unmapped_attributes` 并在编辑快照中返回。用户删除 SKU 时,其 SKU 属性随 SKU 一起移除,不生成无主未映射属性。
- `variants` 是当前完整未删除 SKU 集合。省略既有 SKU 表示软删除;无论当前 SPU 状态都允许新增、复制、删除。`copy_from_variant_id` 仅用于新行,复制通用 SKU 数据但不复制任何平台导入映射,生成的新 SKU 从 `not_listed` 开始。 - `variants` 是当前完整未删除 SKU 集合。省略既有 SKU 表示软删除;无论当前 SPU 状态都允许新增、复制、删除。`copy_from_variant_id` 仅用于新行,复制通用 SKU 数据但不复制任何平台导入映射,生成的新 SKU 从 `not_listed` 开始。
- `variants[].purchase_cost_cny` 是人民币采购价,编辑快照以两位小数字符串返回;草稿输入最多两位小数。来源采集写入时按两位小数四舍五入。
- 删除已导入 SKU 只从 Atlas 草稿及当前平台可发布集合移除,**不删除、不下架、不修改** Ozon 商品或在线镜像;其他平台历史映射保留。删除后当前平台只剩已导入 SKU 时,SPU 汇总自然为“已上架”。 - 删除已导入 SKU 只从 Atlas 草稿及当前平台可发布集合移除,**不删除、不下架、不修改** Ozon 商品或在线镜像;其他平台历史映射保留。删除后当前平台只剩已导入 SKU 时,SPU 汇总自然为“已上架”。
- `null` 的 SKU 规格字段表示继承父商品;包装物流规格的 `status` 为 `missing`/`ambiguous` 时,不允许定价确认或首次上架,但仍允许保存草稿。发布前服务端必须对每个待发布 SKU 解析 `effective_package_spec`,确认长、宽、高、重量均存在且大于 0 后才可生成 Ozon item。实物规格不用于物流;是否向 SPU 或 SKU 展示由其对应的类目 schema 属性决定,尺寸图仍可使用已确认的有效实物规格。 - `null` 的 SKU 规格字段表示继承父商品;包装物流规格的 `status` 为 `missing`/`ambiguous` 时,不允许定价确认或首次上架,但仍允许保存草稿。发布前服务端必须对每个待发布 SKU 解析 `effective_package_spec`,确认长、宽、高、重量均存在且大于 0 后才可生成 Ozon item。实物规格不用于物流;是否向 SPU 或 SKU 展示由其对应的类目 schema 属性决定,尺寸图仍可使用已确认的有效实物规格。
- `target_store_id` 只能是当前工作空间、当前平台的活动店铺。变更目标店铺只更新**尚未导入** listing 的将来发布目标;`imported` listing 永远保留其首次导入时的店铺快照。 - `target_store_id` 只能是当前工作空间、当前平台的活动店铺。变更目标店铺只更新**尚未导入** listing 的将来发布目标;`imported` listing 永远保留其首次导入时的店铺快照。
- 类目变化后不匹配的旧属性作为未映射草稿返回 warnings,不能自动进入发布 payload。属性由平台 schema 校验 `type`、必填、字典、多值/复杂属性和 `is_aspect`;客户端不能用任意 JSON 绕过。 - 类目变化后不匹配的旧属性作为未映射草稿返回 warnings,不能自动进入发布 payload。属性由平台 schema 校验 `type`、必填、字典、多值/复杂属性和 `is_aspect`;客户端不能用任意 JSON 绕过。
- `media_asset_ids` 是本 SKU 当前平台的最终选图顺序;最多 30 张。格式只允许 JPG/PNG/WebP,单文件最多 20 MB,边长超过 10,000 px 拒绝,短边小于 700 px 不得作为首图并给出警告。第一张是主图。已完成的 `product_image` 上传素材和网络导入素材可供同工作空间的 SKU 选择;采集图片仍须先转存,且来源变体须匹配。 - `media_asset_ids` 是本 SKU 当前平台的最终选图顺序;最多 30 张。格式只允许 JPG/PNG/WebP,单文件最多 20 MB,边长超过 10,000 px 拒绝,短边小于 700 px 不得作为首图并给出警告。第一张是主图。已完成的 `product_image` 上传素材和网络导入素材可供同工作空间的 SKU 选择;当前商品成功的水印与 3:4 处理结果也可选择。采集图片仍须先转存,且来源变体须匹配。
- `common.model` 是用户手填或主动 AI 填报的商品型号;采集时允许为空,但每次首次上架前必须非空。`offer_id` 是用户手填或主动 AI 填报的目标平台卖家货号;采集入箱时不填值,生成时不直接复用 `source_sku`。草稿可为空,但每个待首次上架 SKU 的 `offer_id` 必须非空且在同一店铺/平台唯一。AI 填报接口和回填规则见[方案](../product/ai-autofill-plan.md)。 - `common.model` 是用户手填或主动 AI 填报的商品型号;采集时允许为空,但每次首次上架前必须非空。`offer_id` 是用户手填或主动 AI 填报的目标平台卖家货号;采集入箱时不填值,生成时不直接复用 `source_sku`。草稿可为空,但每个待首次上架 SKU 的 `offer_id` 必须非空且在同一店铺/平台唯一。AI 填报接口和回填规则见[方案](../product/ai-autofill-plan.md)。
- `common.source_title` 在编辑页显示为“中文商品名”;`profile.title` 显示为“俄文发布标题”,页面保存时必填。为允许先保存采集草稿再使用 AI 填报,API 草稿契约仍接受空标题;发布就绪校验在 `profile.title` 为空时返回 `PUBLISH_TITLE_REQUIRED`。SKU 发布标题 `variants[].title` 为 `null` 时继承 `profile.title`,不回退到 `display_name`;现有 SKU `tags=[]`、`description=null` 保持继承语义。标题、标签、描述分别独立覆盖。中俄对照按 SPU/SKU 与字段随 `editor-draft` 原子保存;SKU 继承项直接引用 SPU 对照,手工俄文覆盖但无匹配中文时标为对照待更新。发布适配器使用 SKU 有效文案仍是后续工作。 - `common.source_title` 在编辑页显示为“中文商品名”;`profile.title` 显示为“俄文发布标题”,页面保存时必填。为允许先保存采集草稿再使用 AI 填报,API 草稿契约仍接受空标题;发布就绪校验在 `profile.title` 为空时返回 `PUBLISH_TITLE_REQUIRED`。SKU 发布标题 `variants[].title` 为 `null` 时继承 `profile.title`,不回退到 `display_name`;现有 SKU `tags=[]`、`description=null` 保持继承语义。标题、标签、描述分别独立覆盖。中俄对照按 SPU/SKU 与字段随 `editor-draft` 原子保存;SKU 继承项直接引用 SPU 对照,手工俄文覆盖但无匹配中文时标为对照待更新。发布适配器使用 SKU 有效文案仍是后续工作。
@@ -251,17 +264,17 @@ Headers:`Idempotency-Key` 必填。Body:
媒体选择通常随第 2 节草稿一起保存。为支持图片弹窗的异步“添加水印”,提供: 媒体选择通常随第 2 节草稿一起保存。为支持图片弹窗的异步“添加水印”,提供:
`POST /api/product-variant-platform-listings/{variant_listing_id}/watermark-derivatives` `POST /api/media-assets/{asset_id}/watermark-derivatives`
Headers:`Idempotency-Key` 必填。Body: Headers:`Idempotency-Key` 必填。Body:
```json ```json
{ "source_asset_id": "uuid", "watermark_template_id": "uuid" } { "watermark_template_id": "uuid" }
``` ```
服务端验证 source asset 是该 SKU 当前已选图片、模板属于当前工作空间且活动;固化模板**当前版本**后创建 `media_derivatives` 和 Worker 任务,返回 `202` 及 operation。任务成功后返回独立 output asset;原图和其他 SKU 的选择均不改变。用户在图片弹窗确认后,将 output asset 写入下一次 `editor-draft.media_asset_ids` 来替换原图位置;可再次选择原图恢复。首期不在发布时自动添加水印。 服务端验证源图片是当前工作空间已校验的 JPG/PNG/WebP 正式素材,模板属于同一工作空间且活动;固化模板**当前版本**后,在同一事务创建水印派生记录、operation 和 Outbox 事件,返回 `202` 及任务摘要。采集候选图先按需转存为正式素材。任务成功后返回独立 output asset;原图和其他 SKU 的选择均不改变。用户在图片弹窗确认后,将 output asset 写入下一次 `editor-draft.media_asset_ids` 来替换原图位置;同一弹窗内可撤销为原图。首期不在发布时自动添加水印。
`GET /api/media-derivatives/{derivative_id}` 返回任务状态、脱敏错误及成功时的输出素材短时预览;不能泄露 `object_key`。 `GET /api/media-watermark-derivatives/{derivative_id}` 返回 `id`、`status`、`source_asset_id`、`watermark_template_id`、`watermark_template_version`、`output_asset` 和脱敏 `error_code`。成功时 `output_asset` 包含正式素材 ID、宽高、类型、大小及短时预览 URL;不能泄露 `object_key`。输出保留原图显示方向与宽高,合成水印后编码为 JPG;透明原图以白色合成。模板的文字大小、图片像素尺寸、不透明度、圆角和相对位置以固化版本为准。失败后原图留在弹窗中。
## 6. AI 填报任务与页面回填 ## 6. AI 填报任务与页面回填
+44 -10
View File
@@ -69,7 +69,7 @@ Headers:`Idempotency-Key` 必填。
`object_key` 是服务端内部字段,不放在普通客户端响应。上传意图和完成状态均持久化在数据库,不能只存在于 Redis 或预签名 token 中;Worker 每 5 分钟将逾期未完成意图标记为 `expired`,并删除 `failed` / `expired` 意图的临时对象。删除成功才记录清理时间;失败会在下一轮重试。 `object_key` 是服务端内部字段,不放在普通客户端响应。上传意图和完成状态均持久化在数据库,不能只存在于 Redis 或预签名 token 中;Worker 每 5 分钟将逾期未完成意图标记为 `expired`,并删除 `failed` / `expired` 意图的临时对象。删除成功才记录清理时间;失败会在下一轮重试。
### 1.3 临时预览 URL ### 1.3 临时预览与下载 URL
`POST /api/media-assets/{asset_id}/access-urls` `POST /api/media-assets/{asset_id}/access-urls`
@@ -77,7 +77,7 @@ Headers:`Idempotency-Key` 必填。
{ "purpose": "preview" } { "purpose": "preview" }
``` ```
返回 10 分钟有效的短时 `url` 与 `expires_at`。仅当前工作空间的素材可签发;前端在 URL 过期后重新请求,不能把 URL 写回业务表或长期缓存。 `purpose` 可为 `preview` 或 `download`。下载时传 `{ "purpose": "download" }`,服务端在签名 GET 请求中指定 `Content-Disposition: attachment`,文件名为 `atlas-image-{asset_id}` 加素材类型对应扩展名,浏览器打开该短时地址时保存文件。两种用途都返回 10 分钟有效的 `url` 与 `expires_at`。仅当前工作空间的素材可签发;前端在 URL 过期后重新请求,不能把 URL 写回业务表或长期缓存。
`GET /api/media-assets/product-uploads` 返回当前工作空间最近 100 个已经完成校验的 `product_image` 素材。每项包含 `MediaAsset` 字段及 10 分钟有效的 `preview_url`、`preview_url_expires_at`,供编辑页“上传图片”候选区展示;该地址不得写入草稿。 `GET /api/media-assets/product-uploads` 返回当前工作空间最近 100 个已经完成校验的 `product_image` 素材。每项包含 `MediaAsset` 字段及 10 分钟有效的 `preview_url`、`preview_url_expires_at`,供编辑页“上传图片”候选区展示;该地址不得写入草稿。
@@ -87,6 +87,18 @@ Headers:`Idempotency-Key` 必填。
`GET /api/media-assets/product-imports` 返回当前工作空间最近 100 条已导入的网络图片,响应项同上。图片已生成正式素材 ID 后才可进入 SKU 草稿;网络来源地址和预览地址均不得写入 `media_asset_ids`。 `GET /api/media-assets/product-imports` 返回当前工作空间最近 100 条已导入的网络图片,响应项同上。图片已生成正式素材 ID 后才可进入 SKU 草稿;网络来源地址和预览地址均不得写入 `media_asset_ids`。
### 1.5 商品图片一键 3:4
`POST /api/media-assets/{asset_id}/aspect-ratio-derivatives`,需 `Idempotency-Key`,请求 `{ "preset": "3:4", "fit": "contain", "product_id": "uuid" }`。`product_id` 在商品编辑器中传入,以便将结果归档到该商品;旧调用可省略。`asset_id` 必须是当前工作空间已校验的 JPG/PNG/WebP 正式素材。采集候选图先按需转存,再以正式素材 ID 请求。服务端在同一事务创建工作空间隔离的派生记录、用户可见 `operation` 和 Outbox 事件,返回 HTTP `202`:
```json
{ "data": { "id": "uuid", "status": "queued", "source_asset_id": "uuid", "output_asset": null, "error_code": null } }
```
`GET /api/media-derivatives/{derivative_id}` 返回相同结构,`status` 为 `queued/running/succeeded/failed`。成功时 `output_asset` 为独立正式素材,包含 `id`、宽高、类型、大小以及 10 分钟有效的 `preview_url` 和 `preview_url_expires_at`;失败时只返回脱敏 `error_code`。任务等比缩放原图;留白贴近图片的部分采用对应边缘像素,向外平滑过渡到该边缘窄带的平均色,避免细纹理被拉成贯穿留白的条纹。透明边缘先与白色合成。输出为 1200×1600 JPG,不裁切商品,原图不修改。相同幂等键重放同一任务,Worker 重试必须复用同一派生记录。
单张或批量操作都逐张创建任务。弹窗用成功的 output asset 替换当前已选位置;未选的来源图处理成功后加入已选区。失败的图保持原样。弹窗「确认」只把新素材 ID 写入当前编辑表单,页面「保存」才持久化 SKU 选图。短时预览 URL、存储路径和远程来源 URL 不写入草稿或 Outbox。
## 2. 可用字体 ## 2. 可用字体
`GET /api/watermark-fonts` `GET /api/watermark-fonts`
@@ -108,11 +120,16 @@ Headers:`Idempotency-Key` 必填。
"id": "uuid", "id": "uuid",
"name": "右下角品牌", "name": "右下角品牌",
"kind": "image", "kind": "image",
"is_default": false,
"current_version": 3, "current_version": 3,
"config": { "config": {
"image_asset": { "id": "uuid", "preview_url": "https://short-lived", "preview_url_expires_at": "2026-09-02T08:15:00Z" }, "image_asset": { "id": "uuid", "preview_url": "https://short-lived", "preview_url_expires_at": "2026-09-02T08:15:00Z" },
"opacity": "0.8000", "opacity": "0.8000",
"scale_ratio": "0.2500", "scale_ratio": "0.5556",
"image_size_px": 80,
"border_radius_px": 0,
"position_x": 1,
"position_y": 1,
"position": "bottom_right" "position": "bottom_right"
}, },
"created_at": "2026-09-02T08:00:00Z", "created_at": "2026-09-02T08:00:00Z",
@@ -129,11 +146,13 @@ Headers:`Idempotency-Key` 必填。
"font_code": "NotoSans", "font_code": "NotoSans",
"font_size_px": 24, "font_size_px": 24,
"opacity": "0.8000", "opacity": "0.8000",
"position_x": 1,
"position_y": 1,
"position": "bottom_right" "position": "bottom_right"
} }
``` ```
`position` 只能是 `top_left`、`top_center`、`top_right`、`middle_left`、`center`、`middle_right`、`bottom_left`、`bottom_center`、`bottom_right`。`opacity` 范围 `"0"`–`"1"`;图片 `scale_ratio` 范围 `"0.1"`–`"2"`;文字 `font_size_px` 范围 12–60。 `position_x`、`position_y` 是 0–1 的数字,分别表示水印在水平、垂直**可移动范围**内的相对位置:0 贴左/上边缘,1 贴右/下边缘,中间值不吸附九宫格;坐标保存到小数点后四位。`position` 是兼容旧版客户端的九宫格近似值,不得用于精确渲染;其枚举值仍为 `top_left`、`top_center`、`top_right`、`middle_left`、`center`、`middle_right`、`bottom_left`、`bottom_center`、`bottom_right`。`opacity` 范围 `"0"`–`"1"`;图片 `image_size_px` 是在 240×320 基准预览框中等比缩放后的最长边像素数,范围 10–200,默认 80;`border_radius_px` 是该预览框中的圆角半径,范围 0–100,默认 0。`scale_ratio` 仅用于旧客户端兼容,范围 `"0.1"`–`"2"`,新客户端以 `image_size_px` 为准;10–14px 时兼容比例固定为最小值 `"0.1"`,实际渲染仍以像素尺寸为准。文字 `font_size_px` 范围 12–60,也以基准预览框为单位。实际渲染时,Worker 用 `max(源图显示宽度/240, 源图显示高度/320)` 同比例放大图片大小、文字大小、圆角和 5px 边距;水印超出源图时再等比缩小到可容纳范围。这样商品图缩小到预览尺度后,水印大小与底边距接近模板预览。
### 3.1 查询 ### 3.1 查询
@@ -145,6 +164,10 @@ Headers:`Idempotency-Key` 必填。
返回模板当前版本,供编辑弹窗和“查看效果”使用。固定演示底图由前端内置,前端按 `config` 实时渲染预览,不在每次滑动 Slider 时创建服务端图片。 返回模板当前版本,供编辑弹窗和“查看效果”使用。固定演示底图由前端内置,前端按 `config` 实时渲染预览,不在每次滑动 Slider 时创建服务端图片。
`GET /api/watermark-templates/default`
返回 `{ "data": 模板资源 }`;当前工作空间未设置默认模板时返回 `{ "data": null }`。列表和详情中的 `is_default` 表示当前工作空间的唯一默认水印。打开「SKU 图片选择」弹窗时客户端静默读取一次默认模板;点击单张或批量「添加水印」时,有默认模板便直接创建派生任务,没有默认模板才打开三列模板选择弹窗。查询尚未完成时等待结果再决定,查询失败时允许用户从选择弹窗手动选择。设置默认不会自动修改已有图片、店铺推荐水印或已生成的派生图。
### 3.2 创建 ### 3.2 创建
`POST /api/watermark-templates` `POST /api/watermark-templates`
@@ -162,7 +185,8 @@ Headers:`Idempotency-Key` 必填。
"font_code": "NotoSans", "font_code": "NotoSans",
"font_size_px": 24, "font_size_px": 24,
"opacity": "0.8", "opacity": "0.8",
"position": "bottom_right" "position_x": 0.73,
"position_y": 0.82
} }
} }
``` ```
@@ -175,20 +199,22 @@ Headers:`Idempotency-Key` 必填。
"kind": "image", "kind": "image",
"config": { "config": {
"image_asset_id": "uuid", "image_asset_id": "uuid",
"scale_ratio": "0.25", "image_size_px": 80,
"border_radius_px": 12,
"opacity": "0.8", "opacity": "0.8",
"position": "bottom_right" "position_x": 0.73,
"position_y": 0.82
} }
} }
``` ```
模板名称在当前工作空间内唯一;图片资产必须属于当前工作空间且已完成校验。成功返回 `201 Created` 与模板资源。 创建时须同时提交 `position_x` 和 `position_y`;旧版客户端可继续只提交 `position`,服务端会换算成 0、0.5 或 1 的坐标。若两种格式都提交,以精确坐标为准。图片水印须提交 `image_size_px`;旧版客户端可继续提交 `scale_ratio`,服务端会换算为像素尺寸。若两者都提交,以像素尺寸为准;未提交 `border_radius_px` 时取 0。模板名称在当前工作空间内唯一;图片资产必须属于当前工作空间且已完成校验。成功返回 `201 Created` 与模板资源。
### 3.3 编辑与删除 ### 3.3 编辑与删除
`PATCH /api/watermark-templates/{template_id}` `PATCH /api/watermark-templates/{template_id}`
Headers:`If-Match` 与 `Idempotency-Key` 必填。请求体与创建相同,但可只提交待改字段;若切换 `kind`,须提交新类型完整 `config`,服务端不保留旧类型专属字段。 Headers:`If-Match` 与 `Idempotency-Key` 必填。请求体与创建相同,但可只提交待改字段;坐标可单独修改一个轴,另一轴沿用当前值。旧版客户端只提交 `position` 时,两个坐标按九宫格同步换算。图片尺寸和圆角也可独立修改;仅提交旧字段 `scale_ratio` 时重新换算像素尺寸。若切换 `kind`,须提交新类型完整 `config`,服务端不保留旧类型专属字段。
每次影响渲染的更新都会创建新的 `watermark_template_version`,并增加模板 `current_version`。旧版本不会被覆盖,供已生成派生图追溯;模板改名只更新模板名称,不需要创建渲染版本。 每次影响渲染的更新都会创建新的 `watermark_template_version`,并增加模板 `current_version`。旧版本不会被覆盖,供已生成派生图追溯;模板改名只更新模板名称,不需要创建渲染版本。
@@ -196,6 +222,14 @@ Headers:`If-Match` 与 `Idempotency-Key` 必填。请求体与创建相同,
Headers:`Idempotency-Key` 必填。服务端在同一事务软删除模板并解除所有当前店铺绑定,返回 `204 No Content`。不删除原始图片资产,也不影响已生成水印派生图和历史发布快照。 Headers:`Idempotency-Key` 必填。服务端在同一事务软删除模板并解除所有当前店铺绑定,返回 `204 No Content`。不删除原始图片资产,也不影响已生成水印派生图和历史发布快照。
`PUT /api/watermark-templates/{template_id}/default`
Headers:`Idempotency-Key` 必填;请求体为 `{}`。将当前工作空间的活动模板设为唯一默认模板,原默认模板同时取消默认状态,返回 `200 OK` 与更新后的模板资源。重复设置同一模板成功且不会创建新的渲染版本。模板不存在或不属于当前工作空间时返回 `404 RESOURCE_NOT_FOUND`。删除默认模板后,工作空间不再有默认模板。
## 4. 与商品编辑的边界 ## 4. 与商品编辑的边界
本文件只定义水印模板和通用素材。商品编辑的“图片悬浮 → 编辑 → 添加水印”将在商品接口中定义:它选择这里的活动模板、创建持久化派生任务、固化模板版本,并将生成的独立素材替换当前 SKU 已选图片位置;不会在上架时临时加水印。 商品编辑的“图片悬浮 → 编辑 → 添加水印”使用 `POST /api/media-assets/{asset_id}/watermark-derivatives` 创建异步派生任务,需 `Idempotency-Key`,请求体为 `{ "watermark_template_id": "uuid", "product_id": "uuid" }`。`product_id` 在商品编辑器中传入,旧调用可省略。仅接受当前工作空间内已校验、最大 20 MiB、最大边长 10,000px 的 JPG/PNG/WebP 正式素材和活动模板。创建时固定模板当前版本,在同一事务写入派生任务、operation 和 Outbox,返回 HTTP `202`;`GET /api/media-watermark-derivatives/{derivative_id}` 查询进度。响应包含 `id`、`status`、`source_asset_id`、`watermark_template_id`、`watermark_template_version`、`output_asset` 和 `error_code`。成功时结果为保留源图显示方向及尺寸的独立 JPG 素材,透明源图用白底合成,返回短时预览地址;失败只返回稳定错误码。
单张和批量处理逐张创建任务。成功结果在图片弹窗中替换对应位置;来源候选图成功后加入已选区,失败图保持原样。弹窗可撤销本次水印替换;确认后才写入当前编辑表单,页面保存后持久化 SKU 选图。上架任务不会临时加水印。
`GET /api/products/{product_id}/image-derivatives` 返回当前工作空间内该商品最近 100 条成功的 3:4 与水印处理记录,按处理时间倒序;商品不存在返回 `404`。每条记录包含 `id`、`kind`(`aspect_ratio_3_4` / `watermark`)、`source_asset_id`、`created_at` 和带短时预览地址的 `output_asset`。编辑页选图弹窗的「图片处理记录」Tab 用它找回结果,选用后仍需确认选图并保存页面。未绑定商品的历史任务无法确定商品归属,不进入该列表;来源图与原素材不被覆盖。
+12 -4
View File
@@ -17,7 +17,7 @@
├─ SKU 商品:卡片(默认)/ 紧凑列表 ├─ SKU 商品:卡片(默认)/ 紧凑列表
│ └─ N 个 SKU;维护货号、条形码、图片、价格、变体属性、三项 SKU 文案及可选包装覆盖 │ └─ N 个 SKU;维护货号、条形码、图片、价格、变体属性、三项 SKU 文案及可选包装覆盖
└─ 固定操作栏 └─ 固定操作栏
├─ 左:AI 填报 · AI 套图生成 ├─ 左:AI 填报 · AI 套图生成 · 图片任务
└─ 右:取消并关闭 · 保存草稿 · 选择未上架 SKU 并首次上架 └─ 右:取消并关闭 · 保存草稿 · 选择未上架 SKU 并首次上架
``` ```
@@ -59,11 +59,19 @@ SKU 名称只用于展示,不是发布标题;采集 SKU 用稳定 `source_va
AI 输入由两部分组成:一是用户在右侧“采集的商品信息”中整理并保存的来源标题、描述、品牌、价格、规格参数和来源类目;二是用户在左侧商品与 SKU 表单中保存的实物规格、包装规格和规格轴。右侧实际展示“最新规范化采集值 + 用户明确覆盖字段”的有效结果:没有编辑过的字段随复采更新,编辑过的字段保留用户值,并在来源变化时提供对比、逐项恢复和全部采用最新。原始采集证据不因编辑而改变。 AI 输入由两部分组成:一是用户在右侧“采集的商品信息”中整理并保存的来源标题、描述、品牌、价格、规格参数和来源类目;二是用户在左侧商品与 SKU 表单中保存的实物规格、包装规格和规格轴。右侧实际展示“最新规范化采集值 + 用户明确覆盖字段”的有效结果:没有编辑过的字段随复采更新,编辑过的字段保留用户值,并在来源变化时提供对比、逐项恢复和全部采用最新。原始采集证据不因编辑而改变。
点击任何 AI 动作前须先保存草稿,服务端再从当前 revision 组装输入;客户端不得把未保存的尺寸或自行解析的来源 JSON 直接提交给 AI。父商品规格与 SKU 有效规格均以后端读模型为准,SKU 的空覆盖字段按继承规则读取左侧父商品规格。 商品文案、类目等 AI 填报动作前须先保存草稿,服务端再从当前 revision 组装输入;客户端不得把未保存的尺寸或自行解析的来源 JSON 直接提交给这类 AI 任务。图片「AI 编辑」是独立素材任务,只引用已物化的正式图片素材 ID,尺寸标注参数由用户在单张编辑抽屉中明确填写,不修改商品规格。父商品规格与 SKU 有效规格均以后端读模型为准,SKU 的空覆盖字段按继承规则读取左侧父商品规格。
SKU 图片选择弹窗左侧是该 SKU 的已选图片(排序/移除),右侧分组展示 AI 生图记录、采集主图、采集 SKU 图、采集详情图、网络图片和上传图片。商品级素材可被多个 SKU 选择;明确有稳定来源关联的 SKU 图只展示给对应 SKU。Ozon 首期每个 SKU 最多选择 30 张发布图片,第一张为主图,可拖动排序;平台适配器以自身限制驱动该上限。 SKU 图片选择弹窗左侧是该 SKU 的已选图片(排序/移除),右侧依次展示 AI 生图记录、图片处理记录、采集图片、网络图片和上传图片;采集图片内按主图、SKU 图、详情图分组。商品级素材可被多个 SKU 选择;明确有稳定来源关联的 SKU 图只展示给对应 SKU。Ozon 首期每个 SKU 最多选择 30 张发布图片,第一张为主图,可拖动排序;平台适配器以自身限制驱动该上限。
已选图片悬浮时显示“编辑”菜单,至少包含“添加水印”。点击后在弹窗中选择一个工作空间水印模板,后台创建带水印的派生素材;原图和其他 SKU 的选择不被改写。用户确认后,以派生图替换该 SKU 当前图片位置,并可在同一弹窗撤销为原图。首期不在发布时自动加水印,也不因门店默认水印自动处理图片;后续再支持默认模板的预选、批量应用或自动应用。 右侧「采集的商品信息」中的采集图片也提供「编辑」菜单,包含 AI 编辑、添加水印、一键 3:4 和下载。AI 编辑复用单张编辑抽屉,生成结果进入当前商品的 AI 生图记录;水印和 3:4 处理复用商品级派生任务,成功后在采集区显示处理图,重开页面时按该商品的处理记录恢复最新结果。原始采集证据与原素材保持不变;处理图可以在 SKU 图片选择弹窗的「图片处理记录」中重新选用。
单张图片菜单和左侧批量操作中的「一键 3:4」现使用异步素材派生任务:采集候选图先转存,Worker 对正式素材等比缩放,留白从对应边缘像素平滑过渡到该侧的平均底色,生成 1200×1600 JPG 新素材;原图保留。处理成功后在弹窗中替换对应已选位置,失败项保持原样。弹窗确认只回填当前表单,页面保存后才持久化选图。批量翻译仍待开发。
图片「编辑 → AI 编辑」打开标题为「AI编辑」的单张编辑抽屉,主图固定,可选最多两张辅助图。用户可设置编辑要求、图中文字翻译、背景、五种视觉风格、尺寸标注和画面比例;抽屉不展示生成方式,提交时固定使用 `edit` 模式。Worker 调用 Pcore `gpt-image-2` 异步接口;普通编辑参照旧项目以 JSON `images[].image_url` 发送原始图片的 data URI,仅扩图发送预处理后的 PNG 画布和蒙版 data URI。生成图独立保存。提交和生图期间都可关闭抽屉,任务在后台继续,可从底部操作栏的图片任务入口查看;失败任务同时展示通用提示和已取得的 Pcore 原始错误内容。用户预览后点击「添加到当前 SKU」,结果插在原图后方;已完成结果也保留在当前商品的「AI 生图记录」,可在重开弹窗后选择。选图弹窗确认和页面保存仍分别控制表单回填与持久化。
商品编辑页底部固定操作栏在「AI 套图生成」后展示「图片任务」入口;有运行中任务时显示「任务处理中」和数量。展开后按进行中和最近任务显示当前商品的图片 AI 任务、状态、完成数、失败信息和结果预览;单张 AI 编辑可在此取消。提交后立即刷新,进行中每 3 秒刷新,其余时间低频刷新。当前接入单张编辑;后续 AI 套图沿用商品图片任务结构并增加任务来源,入口无需重新设计。
打开「SKU 图片选择」时静默读取当前工作空间默认水印。点击单张或批量「添加水印」时,有默认模板便直接生成带水印的派生素材;没有默认模板或读取失败才打开每行最多三张卡片的选模板弹窗,水印管理在新标签页打开。原图和其他 SKU 的选择不被改写;生成结果替换当前图片位置,并可在同一弹窗撤销。弹窗确认只更新编辑表单,页面保存后才持久化选图;发布时不会自动加水印。
首期素材规则:发布图片仅接受 JPG、PNG、WebP;单文件最大 20 MB,最短边低于 700 px 给出警告且不得作为主图,超过 10,000 px 的边长在上传时拒绝;建议 1:1 或 3:4,允许其他比例但在预览中提示裁切风险。采集候选素材可保留原始文件供编辑选择,实际发布时始终按目标平台的图片数、格式和可访问性校验。Ozon 发布图片必须是可由 Ozon 拉取的 HTTPS 地址。 首期素材规则:发布图片仅接受 JPG、PNG、WebP;单文件最大 20 MB,最短边低于 700 px 给出警告且不得作为主图,超过 10,000 px 的边长在上传时拒绝;建议 1:1 或 3:4,允许其他比例但在预览中提示裁切风险。采集候选素材可保留原始文件供编辑选择,实际发布时始终按目标平台的图片数、格式和可访问性校验。Ozon 发布图片必须是可由 Ozon 拉取的 HTTPS 地址。
+59 -3
View File
@@ -1,6 +1,6 @@
# Atlas Seller Kit 交接说明 # Atlas Seller Kit 交接说明
> 更新:2026-09-24。此文是新 AI 对话、架构讨论与开发工作的首读入口。开始开发前,必须先阅读并遵守 [`development-collaboration-contract.md`](./development-collaboration-contract.md) 和仓库 `AGENTS.md`。下方按日期记录的旧方案仅供追溯;当前状态以本节及最新的 1.40 节为准。 > 更新:2026-09-27。此文是新 AI 对话、架构讨论与开发工作的首读入口。开始开发前,必须先阅读并遵守 [`development-collaboration-contract.md`](./development-collaboration-contract.md) 和仓库 `AGENTS.md`。下方按日期记录的旧方案仅供追溯;当前状态以本节及最新的 1.47 节为准。
## 1. 项目定位 ## 1. 项目定位
@@ -14,7 +14,7 @@ pnpm monorepo 包含 Web、NestJS API、BullMQ Worker、WXT 浏览器扩展、
已完成“采集 → 采集箱 → 商品编辑草稿”的主要数据闭环、平台级类目/属性/字典目录与发布预检。编辑页已接入异步「AI 填报」:基于来源资料与 Atlas 类目树生成类目建议、型号/SKU 货号、证据充分的包装建议及 SPU 中俄文案;单 SKU 文案由用户单独生成并确认应用。AI 结果只回填未保存表单,保存草稿仍是持久化边界。Ozon 动态类目属性可按已选类目加载并手工填写,**当前不由 AI 自动填报**。SKU 图片选择现支持采集候选图、工作空间上传图和网络导入图;在弹窗确认后回填草稿,页面保存后持久化。 已完成“采集 → 采集箱 → 商品编辑草稿”的主要数据闭环、平台级类目/属性/字典目录与发布预检。编辑页已接入异步「AI 填报」:基于来源资料与 Atlas 类目树生成类目建议、型号/SKU 货号、证据充分的包装建议及 SPU 中俄文案;单 SKU 文案由用户单独生成并确认应用。AI 结果只回填未保存表单,保存草稿仍是持久化边界。Ozon 动态类目属性可按已选类目加载并手工填写,**当前不由 AI 自动填报**。SKU 图片选择现支持采集候选图、工作空间上传图和网络导入图;在弹窗确认后回填草稿,页面保存后持久化。
尚未完成可直接发布的闭环:定价确认、真正的 Ozon 发布 job/import 及结果轮询、在线商品同步仍待开发。AI 类目推荐和文案已能运行,但不能等同于跨来源样本验收;尤其应继续用 1688/淘宝/天猫样本核验 Atlas 类目候选质量,以及俄文文案、包装证据和 SKU 继承行为。AI 生图仍是占位入口;图片「编辑」菜单中的 AI 编辑、水印、一键 3:4 等处理动作本轮未接入。 尚未完成可直接发布的闭环:定价确认、真正的 Ozon 发布 job/import 及结果轮询、在线商品同步仍待开发。AI 类目推荐和文案已能运行,但不能等同于跨来源样本验收;尤其应继续用 1688/淘宝/天猫样本核验 Atlas 类目候选质量,以及俄文文案、包装证据和 SKU 继承行为。AI 套图生图仍是占位入口;图片「编辑」菜单中的一键 3:4、添加水印和单张 AI 编辑已接入异步素材任务。
当前优先事项:用真实来源样本回归 AI 类目推荐、文案与图片选择,补做真实图片上传/网络导入联调;完成定价确认;让首次发布 job 复用既有发布预检,再实现 Ozon import 与结果轮询。平台参考目录目前由平台级参考源和同步任务维护,不能把 9 月 10 日的门店级缓存描述当作现行架构。 当前优先事项:用真实来源样本回归 AI 类目推荐、文案与图片选择,补做真实图片上传/网络导入联调;完成定价确认;让首次发布 job 复用既有发布预检,再实现 Ozon import 与结果轮询。平台参考目录目前由平台级参考源和同步任务维护,不能把 9 月 10 日的门店级缓存描述当作现行架构。
@@ -458,7 +458,63 @@ BullMQ Worker(最多 5 次,5 秒起指数退避)
**验证与后续范围** **验证与后续范围**
- 本地 `031`、`032` 迁移已执行。contracts、database、API、Web TypeScript 检查及 API/Web 生产构建通过;API 媒体单元测试共 6 项、商品编辑集成测试共 11 项通过。集成测试覆盖 30 张选图保存、已上传和网络导入素材的草稿引用、非法内网网络图片链接拒绝。 - 本地 `031`、`032` 迁移已执行。contracts、database、API、Web TypeScript 检查及 API/Web 生产构建通过;API 媒体单元测试共 6 项、商品编辑集成测试共 11 项通过。集成测试覆盖 30 张选图保存、已上传和网络导入素材的草稿引用、非法内网网络图片链接拒绝。
- 尚未用真实外部图片 URL、浏览器直传 MinIO 和完整编辑页操作做端到端回归;上线前需联调短时预览地址刷新、上传失败重试、跨 SKU 选图与重新打开弹窗。AI 生图和图片「编辑」菜单中的 AI 编辑、添加水印、一键 3:4、批量翻译由后续迭代接入;当前菜单仅提供入口提示,设为封图、下载和移出已选可用。 - 尚未用真实外部图片 URL、浏览器直传 MinIO 和完整编辑页操作做端到端回归;上线前需联调短时预览地址刷新、上传失败重试、跨 SKU 选图与重新打开弹窗。AI 套图生图和批量翻译由后续迭代接入;单张 AI 编辑、一键 3:4、添加水印、设为封图、下载和移出已选可用。下载对当前工作空间素材签发带 `Content-Disposition: attachment` 的短时地址,采集候选图先按需转存,避免跨域预览链接只打开图片。
### 1.41 开发进度(2026-09-25):SKU 图片一键 3:4
- 图片弹窗的单张菜单和左侧批量操作已接入一键 3:4。采集候选图先按需转存,再以正式素材 ID 创建异步派生任务;Worker 等比缩放,留白从对应边缘像素平滑过渡到该侧窄带的平均底色,避免出现贯穿留白的条纹,生成独立的 1200×1600 JPG,保留原图。成功项替换弹窗中的图片,失败项不变;确认写入当前表单,页面保存后持久化。
- API、共享类型、Worker 与 `033_media_aspect_ratio_derivatives.sql` 已同步;任务、operation 和 Outbox 在同一事务创建,任务表受工作空间 RLS 约束。结果查询只签发短时预览 URL。新增图像处理库 sharp,项目包管理器统一为 pnpm 11.25.0。
- `033` 已在当前本地 `atlas` 数据库应用。API/Worker 受限角色可读写新表;图像转换、任务执行与媒体 API 单元测试已通过。临时账号和图片的 API → MinIO → Outbox → Worker → 结果查询实测返回独立的 1200×1600 JPEG,测试记录与对象已清理。仍需在真实编辑页点击单张和批量入口,回归弹窗状态与保存后的重读。
### 1.42 开发进度(2026-09-26):商品图片主动应用水印
- “添加水印”打开标题为「选择水印」的三列模板弹窗,左侧「水印管理」在新标签页打开。选择后对单张或批量图片逐张创建水印派生任务;成功项替换弹窗选图,失败项保留原图,当前弹窗支持撤销本次水印替换。确认只写入编辑表单,页面保存后才持久化。
- `036_media_watermark_derivatives.sql` 保存源素材、锁定的模板版本及处理结果;API 创建任务时原子写入 operation 和 Outbox,Worker 读取不可变模板版本,以文字或图片水印生成独立 JPG 素材。结果查询只签发短时预览 URL。
- 本地已应用 `036` 并核对受限应用角色可读写新表;contracts、API、Worker、Web 类型检查与 Web 生产构建通过,API 单元测试 26 项、图像 Worker 测试 13 项及新增的水印 API/数据库集成测试通过。尚需在真实商品编辑页回归选模板、批量处理、撤销与保存后重读。API 全量集成测试仍有一项既有发布预检优先级断言失败,与本轮水印路径无关。
- 水印图片大小、文字字号、圆角和边距以管理页 240×320 预览框为基准,Worker 按源图显示尺寸同比例换算。此前直接把预览的 80px 水印贴到 1200×1600 等大图,缩小展示后明显偏小且过于贴底;已修正渲染计算,旧的派生图不会自动重生成,需要重新应用水印。
- 打开「SKU 图片选择」时前端静默请求当前工作空间默认水印。单张和批量「添加水印」等待这次请求:有默认模板就直接执行已有派生处理,没有默认模板或请求失败才打开选模板弹窗。默认模板仅在用户主动点击后应用,不会因打开图片弹窗而修改草稿。
### 1.43 开发进度(2026-09-26):SKU 图片单张 AI 编辑
- 参考旧项目 `studio/src/pages/trial/AiImageGenModal.tsx`,图片「编辑 → AI 编辑」现打开单张抽屉:固定主图、最多两张辅助图、编辑要求、图片文字翻译、背景、五种风格及自定义提示词、尺寸标注、3:4/1:1/4:5 比例与仅扩图。Pcore `gpt-image-2` 任务由 Worker 异步提交、持久化供应商任务 ID,再经延迟 Outbox 轮询;仅扩图完成后覆盖回原图区域。生成结果独立保存为商品关联素材,用户点击「添加到当前 SKU」后插在原图后方;确认选图并保存页面后才持久化 SKU 顺序。右侧「AI 生图记录」可重新选择当前商品的最近 100 张成功结果。
- API 契约见 `docs/api/product-editor-and-publishing.md`;`039_media_ai_edits.sql` 建立工作空间隔离任务表,`040_media_ai_edit_product_scope.sql` 关联商品。两项迁移已应用到当前本地 `atlas` 数据库,受限应用角色读取检查通过。contracts、API、Worker、Web 类型检查通过;Web 生产构建通过;API 定向测试 4 项与 Worker 定向测试 3 项通过(含仅扩图回填原图、供应商异步状态推进)。浏览器中以模拟素材挂载抽屉,验证了翻译开关、尺寸标注选项、仅扩图模式和窄视口布局;临时前端服务已停止。本次未向真实 Pcore 提交收费任务,也未登录真实商品编辑页做完整操作回归;联调时需验证 Pcore 结果地址、任务取消与页面重新打开后的图片选择。
### 1.44 开发进度(2026-09-26):商品图片任务入口
- 参考旧项目 `studio/src/pages/trial/TrialTaskCenter.tsx`,商品编辑页右下角增加固定「图片任务」入口和右侧任务抽屉。按进行中、最近任务展示当前商品任务,支持手动刷新、单张编辑取消和按需签发短时地址查看结果;单张编辑提交即刷新,运行中每 3 秒轮询。API `GET /api/products/{product_id}/image-tasks` 在工作空间事务内分页读取任务,首期聚合单张编辑;共享 `ProductImageTask` 已预留 `ai_suite` 类型。后续套图生成落地时需把套图表查询接入聚合服务,并补上套图任务的取消与结果操作。API 与 Web 类型检查、Web 生产构建、API 定向测试通过;尚未用真实登录商品页做端到端界面回归。
- 单张 AI 编辑抽屉已移除「生成方式」选择,提交固定使用 `edit` 模式;抽屉标题改为「AI编辑」。服务端仍保留既有 `outpaint` 契约,供其他调用端或后续能力使用。
- 商品编辑页底部操作栏已将原「AI 生图」入口更名为「AI 套图生成」,并把图片任务入口放在其后;进行中时显示任务数。套图按钮当前仍为待开发提示。
### 1.45 故障排查(2026-09-26):单张 AI 编辑任务失败
- 本地 4 条失败任务都已成功提交到 Pcore,供应商任务 ID 已持久化;Pcore 轮询返回 `status=failed` 和“请求无法用于生成图像,可能因安全政策被拦截,或不适合进行图像生成”。4 条任务使用同一张蜘蛛侠玩具主图,切换背景后仍遭相同拒绝;供应商未指明具体触发项,不能断定是图片、辅助图或提示词中的哪一项。`PCORE_API_KEY` 与 `PCORE_BASE_URL` 已在当前根 `.env` 配置,Worker 通过根 `.env` 读取并实际调用了 `gpt-image-2` 的 `/v1/images/edits`;本次排查只读查询已有供应商任务,没有提交新的付费生图。
- Worker 现在识别 Pcore 的终态错误:内容策略/输入不适合时写 `AI_IMAGE_CONTENT_REJECTED`,其他终态写 `AI_IMAGE_PROVIDER_FAILED`,不再对同一终态任务重复尝试 5 次;前端显示对应中文提示。历史 4 条任务仍保留原通用错误码 `AI_IMAGE_GENERATION_FAILED`。Worker 单元测试 4 项、Worker/Web 类型检查和 Web 生产构建通过。排查时确认维护者的 API/Worker `tsx watch` 均在运行,Worker 子进程已随代码变更重新加载;尚未对其他图片发起实际生图验证。前端无法绕过 Pcore 内容判定。
- 2026-09-27 复核时新增的第 5 条失败任务仍是同一张主图,但没有辅图、背景为 `auto`、编辑要求为空。Pcore 返回 `status=failed`、`error.type=image_task_error`、`error.code=failed`,消息仅称“可能因安全政策被拦截,或不适合进行图像生成”,没有审核阶段或类别;因此不能将原因确定归于主图、辅助图、提示词或生成结果。对照旧项目的 `/v1/images/edits`、`gpt-image-2`、异步轮询、`images[].image_url` 和 3:4 尺寸均一致;Atlas 的差异是发出前会压缩为 JPEG,旧项目发送原始图片字节。当前输入图本身已是 1170×1170、197 KB 的 JPEG,尚无证据表明转码导致拒绝。界面提示已改为不指定具体拦截阶段。没有提交新的付费诊断任务。
### 1.46 Pcore 图片编辑协议修正(2026-09-27)
- 根 `.env` 的 `PCORE_BASE_URL=https://pcore.ai` 没有隐藏空白;Worker 原本拼接为 `/v1/images/edits`,并能拿到供应商任务 ID,故少写 `/v1` 不是失败原因。现兼容根地址与文档的 `/v1` 地址,均规范化为同一接口路径;`.env.example` 推荐文档写法,现有根 `.env` 不必改动。
- 用同一密钥做三项单张对照:合成花瓶图通过 JSON `images[].image_url` 提交后返回 `queued`,随后以笼统的 `image_task_error/failed` 终止;纯文生图通过 `/v1/images/generations` 成功;同一花瓶图改用 `multipart/form-data` 的 `image` 文件上传到 `/v1/images/edits` 后成功。因此笼统错误消息不能证明内容审核拒绝,此次故障定位为图片编辑请求格式与当前 Pcore 通道不兼容。
- Worker 现在以 multipart 上传主图及各辅图,扩图时上传 PNG 图片和 `mask` 文件,保留原有异步任务 ID 与轮询。仅在供应商明确给出审核类错误码时标记 `AI_IMAGE_CONTENT_REJECTED`,笼统终态失败标记 `AI_IMAGE_PROVIDER_FAILED`。旧任务状态不回写;需重新发起生成来验证真实商品图。对照诊断提交了三条可能计费的供应商任务,其中两条成功、一条失败。
### 1.47 图片任务展示 Pcore 原始错误(2026-09-27)
- Pcore 图片编辑失败时,Worker 将供应商错误对象原文保存在对应 operation 的 `result.provider_error_raw`,稳定错误码及通用提示仍保留。商品图片任务列表在工作空间事务内联结 operation,响应增加 `provider_error_raw`;前端失败任务卡片直接展示该内容,其他任务为 `null`。供应商任务 ID、密钥及结果图片地址不随任务列表返回。历史失败任务此前未保存原始错误,需按已持久化的供应商任务 ID 补取;无法补取的记录继续显示通用提示。当前根 `.env` 指向的本地 `atlas` 数据库经只读核对为 0 个商品、0 条失败的 AI 编辑任务,因此本次无历史记录可补取。
### 1.48 单张 AI 编辑请求对齐旧项目(2026-09-27)
- 复核旧项目 SQLite 中最近两条成功的单张编辑任务:均使用 `gpt-image-2`、3:4、一张参考图,并经 `/v1/images/edits` 的 JSON `images[].image_url` 提交。两项目配置的 Pcore key 与 base URL 相同。旧项目也存在一次同端点的笼统失败,因此上一节用合成图得出的“JSON 格式与通道不兼容”不能推广到所有输入图。
- 按维护者要求,Atlas Worker 的普通编辑改为 JSON 请求,`model/prompt/n/size/async/images` 结构与旧项目一致;图片以原始 JPG/PNG/WebP 字节生成 data URI,不再压缩、铺白或转码。仅扩图仍先构造 PNG 画布和蒙版,再用同一 JSON 协议发送 `images` 与 `mask.image_url`。异步轮询、原始错误记录与生成结果保存流程不变。Worker 定向测试 6 项、类型检查和构建通过;尚未以真实商品图重新向 Pcore 发起收费任务。
### 1.49 Pcore 已完成任务的结果解析修正(2026-09-27)
- Pcore 官方图片文档区分 `/v1/images/generations` 文生图和 `/v1/images/edits` 图生图;单张 AI 编辑传原图,应继续调用后者。文档的 JSON 图生图输入写为 URL 或 data URI 字符串或字符串数组;Atlas 当前的 `images[].image_url` 对象数组与旧项目成功任务一致,而且最新任务在 Pcore 返回 `completed`,故这次失败并非提交参数被拒绝。
- 读取 Joey 工作空间内最近失败任务及 BullMQ 栈后确认:最近三条任务都取得供应商任务 ID,Pcore 返回 `data[0].url`,并同时给出空字符串 `b64_json`。Atlas 原先优先解码空 `b64_json`,产生空 Buffer,Sharp 抛出 `Input Buffer is empty`;BullMQ 重试五次后写通用失败。已完成任务的 Pcore 内容 URL 经只读 GET 返回 HTTP 200、`image/png`、1,580,645 字节。Worker 现仅使用非空的 base64 数据,否则回退到结果 URL。定向测试 7 项、Worker 类型检查和构建通过;未提交新的收费生图任务。此前失败记录仍为失败状态,未自动改写业务数据。
### 1.50 Pcore 结果下载 DNS 回调修正(2026-09-27)
- 前一节修复后再次提交的两条 Atlas 任务都已取得 Pcore 任务 ID,却在读取结果 URL 时以 `Invalid IP address: undefined` 失败。BullMQ 栈指向 `downloadProviderImage` 的自定义 DNS `lookup` 回调;Node 请求 `options.all=true` 时要求回调返回地址数组,旧实现仍返回单个地址,导致 TLS 连接前失败。旧项目使用 `httpx` 直接读取结果 URL,没有这一分支。
- Worker 的 DNS 固定逻辑现按 `options.all` 返回地址数组或单个地址,公网地址校验与 TLS 域名校验保持不变。用已经完成的 Pcore 任务调用修复后的 Atlas 下载函数,只读取回有效 PNG(1,734,068 字节,1086×1448);没有发起新的收费生图。Worker 定向测试 8 项、类型检查与构建通过。历史失败记录仍未改写。
## 2. 已确认产品决策 ## 2. 已确认产品决策
+6 -6
View File
@@ -106,7 +106,7 @@
| 水印名称 | 模板名称,在当前账户内唯一。 | | 水印名称 | 模板名称,在当前账户内唯一。 |
| 水印类型 | `文字水印` 或 `图片水印`。 | | 水印类型 | `文字水印` 或 `图片水印`。 |
| 水印内容 | 文字水印展示文字内容;图片水印展示缩略图/文件名,不显示原始存储路径。 | | 水印内容 | 文字水印展示文字内容;图片水印展示缩略图/文件名,不显示原始存储路径。 |
| 水印位置 | 九宫格位置,以清晰中文名展示,如“右下”。 | | 水印位置 | 显示水平、垂直相对位置;旧模板的九宫格位置会转换为相应坐标。 |
| 操作 | `查看效果`、`编辑`、`删除`。 | | 操作 | `查看效果`、`编辑`、`删除`。 |
删除已被店铺绑定的模板时,必须二次确认并说明影响;确认删除后应解除所有店铺绑定,而不是让任务继续引用一个已删除的模板。已生成的历史图片不受影响。 删除已被店铺绑定的模板时,必须二次确认并说明影响;确认删除后应解除所有店铺绑定,而不是让任务继续引用一个已删除的模板。已生成的历史图片不受影响。
@@ -122,15 +122,15 @@
| 模板名称 | 必填;当前账户内唯一。 | | 模板名称 | 必填;当前账户内唯一。 |
| 水印类型 | 必填,文字或图片。切换类型时需确认,因为原类型专属字段会被清空。 | | 水印类型 | 必填,文字或图片。切换类型时需确认,因为原类型专属字段会被清空。 |
| 不透明度 | 使用 Ant Design Slider;取值 0%–100%,预览同步更新。 | | 不透明度 | 使用 Ant Design Slider;取值 0%–100%,预览同步更新。 |
| 水印位置 | 九宫格单选:左上、上中、右上、左中、居中、右中、左下、下中、右下。 | | 预览效果与水印位置 | 基于固定演示底图实时渲染位置、大小、圆角和透明度;在预览框内拖动水印可停在任意位置,距边缘最少 5px,也可用方向键微调。保存相对坐标,重新打开后保持原位置。商品编辑页主动应用时生成独立派生图。 |
| 预览效果 | 基于固定演示底图实时渲染位置、缩放和透明度;首期仅预览,不写入商品图。 |
图片水印专属字段: 图片水印专属字段:
| 字段 | 必填 | 规则 | | 字段 | 必填 | 规则 |
|---|---:|---| |---|---:|---|
| 上传图片 | 是 | 上传 PNG/JPG/WebP;透明底 PNG 是推荐格式。具体大小、像素与文件大小限制由媒体模块确定。 | | 上传图片 | 是 | 上传 PNG/JPG/WebP;透明底 PNG 是推荐格式。具体大小、像素与文件大小限制由媒体模块确定。 |
| 图片缩放 | 是 | 使用 Ant Design Slider;首期建议取值 10%–200%,表示相对演示底图短边的比例。 | | 图片大小 | 是 | 使用 Ant Design Slider;在 240×320 预览框中的最长边为 10–200px,默认 80px;应用到商品图时按预览框与源图的比例换算。 |
| 图片圆角 | 否 | 输入 0–100px,默认 0;同一个值控制四个圆角。 |
文字水印专属字段: 文字水印专属字段:
@@ -144,11 +144,11 @@
### 4.3 商品编辑时主动加水印规则 ### 4.3 商品编辑时主动加水印规则
用户在商品编辑页的已选图片上悬浮,点击“编辑 → 添加水印”,在弹窗选择一个工作空间水印模板。确认后系统对这张图片生成一份带水印的派生图,并以派生图替换此 SKU 当前图片位置。规则如下: 打开「SKU 图片选择」时静默读取当前工作空间默认水印。用户点击“编辑 → 添加水印”后,有默认模板就直接生成带水印的派生图;没有默认模板或读取失败才在弹窗中选择工作空间水印模板。生成后以派生图替换此 SKU 当前图片位置。规则如下:
- 商品级候选图片与每个 SKU 已选图片都可处理;最终仅提交用户明确选中的图片集合。 - 商品级候选图片与每个 SKU 已选图片都可处理;最终仅提交用户明确选中的图片集合。
- 原始图片不被改写;水印处理产物是独立、可追溯的派生素材,用户可在编辑弹窗恢复使用原图。 - 原始图片不被改写;水印处理产物是独立、可追溯的派生素材,用户可在编辑弹窗恢复使用原图。
- 派生任务固化水印模板版本、位置、缩放/字体和不透明度;模板后续编辑不影响已生成的派生图。 - 派生任务固化水印模板版本、位置、图片大小与圆角/字体和不透明度;模板后续编辑不影响已生成的派生图。
- 首期不在上架任务中自动加水印,也不允许发布时临时替换;后续另行支持绑定水印的默认预选、批量添加或自动添加。 - 首期不在上架任务中自动加水印,也不允许发布时临时替换;后续另行支持绑定水印的默认预选、批量添加或自动添加。
## 5. 本轮待确认事项 ## 5. 本轮待确认事项
@@ -126,7 +126,7 @@
## 6. 待补充 ## 6. 待补充
1. 采集箱草稿已绑定目标后,是否允许换平台/换店铺及其迁移规则; 1. 采集箱草稿已绑定目标后,是否允许换平台/换店铺及其迁移规则;
2. 在线商品列表的字段、筛选、详情、编辑入口和“认领外部商品”流程; 2. 在线商品首期列表字段、筛选与只读详情已按 [`../api/online-listings.md`](../api/online-listings.md) 实现;编辑入口和“认领外部商品”流程仍待设计;
3. 自动同步频率、手动同步冷却时间、单店并发和限流参数; 3. 自动同步频率、手动同步冷却时间、单店并发和限流参数;
4. 采集箱容量 1000 的最终套餐口径及超限提示; 4. 采集箱容量 1000 的最终套餐口径及超限提示;
5. 采集箱“首次导入成功”的用户文案,以及在线商品“实际可见/库存”的用户文案;二者必须区分,不能把 `visible=true` 当作采集箱上架状态。 5. 采集箱“首次导入成功”的用户文案,以及在线商品“实际可见/库存”的用户文案;二者必须区分,不能把 `visible=true` 当作采集箱上架状态。
@@ -84,9 +84,9 @@ WHERE id = :id AND revision = :expected_revision;
### 5. 在线商品同步中的“未见”与归档 ### 5. 在线商品同步中的“未见”与归档
**状态:待确认。** **状态:已确认(2026-09-26)。** 维护者选择以下规则,在线商品全量同步按此执行。
建议规则如下,待产品确认后写入接口和同步 Worker: 规则如下:
1. 一次**成功的全量同步**中未见某在线 SKU:仅首次写入 `missing_since`;不归档。 1. 一次**成功的全量同步**中未见某在线 SKU:仅首次写入 `missing_since`;不归档。
2. 之后连续第二次成功全量同步仍未见,或 `missing_since` 已超过 7 天:写入 `archived_at`,列表默认隐藏但可筛选查看。 2. 之后连续第二次成功全量同步仍未见,或 `missing_since` 已超过 7 天:写入 `archived_at`,列表默认隐藏但可筛选查看。
+1
View File
@@ -5,3 +5,4 @@ export * from './watermarks';
export * from './media'; export * from './media';
export * from './product-editor'; export * from './product-editor';
export * from './product-ai'; export * from './product-ai';
export * from './online-products';
+99 -1
View File
@@ -27,9 +27,10 @@ export const completeMediaUploadIntentRequestSchema = z
.strict(); .strict();
export const createMediaAccessUrlRequestSchema = z export const createMediaAccessUrlRequestSchema = z
.object({ .object({
purpose: z.literal('preview'), purpose: z.enum(['preview', 'download']),
}) })
.strict(); .strict();
export type CreateMediaAccessUrlRequest = z.infer<typeof createMediaAccessUrlRequestSchema>;
export const importProductImageRequestSchema = z.object({ export const importProductImageRequestSchema = z.object({
url: z.url().max(2048).refine((value) => { url: z.url().max(2048).refine((value) => {
try { try {
@@ -42,6 +43,50 @@ export const importProductImageRequestSchema = z.object({
}, '仅支持 HTTPS 的 JPG 或 PNG 图片链接'), }, '仅支持 HTTPS 的 JPG 或 PNG 图片链接'),
}).strict(); }).strict();
export type ImportProductImageRequest = z.infer<typeof importProductImageRequestSchema>; export type ImportProductImageRequest = z.infer<typeof importProductImageRequestSchema>;
export const createAspectRatioDerivativeRequestSchema = z.object({
preset: z.literal('3:4'),
fit: z.literal('contain'),
product_id: z.uuid().optional(),
}).strict();
export type CreateAspectRatioDerivativeRequest = z.infer<typeof createAspectRatioDerivativeRequestSchema>;
export const createWatermarkDerivativeRequestSchema = z.object({
watermark_template_id: z.uuid(),
product_id: z.uuid().optional(),
}).strict();
export type CreateWatermarkDerivativeRequest = z.infer<typeof createWatermarkDerivativeRequestSchema>;
export const createMediaAiEditRequestSchema = z.object({
product_id: z.uuid(),
reference_asset_ids: z.array(z.uuid()).max(2).default([]),
prompt: z.string().trim().max(2000).default(''),
mode: z.enum(['edit', 'outpaint']).default('edit'),
ratio: z.enum(['3:4', '1:1', '4:5']).default('3:4'),
translate_text: z.boolean().default(false),
target_language: z.enum(['ru', 'ar', 'en']).default('ru'),
background: z.enum(['auto', 'white', 'solid', 'scene', 'reference']).default('auto'),
background_description: z.string().trim().max(500).default(''),
style_set: z.number().int().min(1).max(5).default(2),
style_prompt: z.string().trim().max(2000).default(''),
dimensions: z.object({
length: z.number().positive().max(100000).optional(),
width: z.number().positive().max(100000).optional(),
height: z.number().positive().max(100000).optional(),
unit: z.enum(['cm', 'mm']).default('cm'),
}).strict().optional(),
generate_size_chart: z.boolean().default(false),
}).strict().superRefine((input, ctx) => {
if (new Set(input.reference_asset_ids).size !== input.reference_asset_ids.length)
ctx.addIssue({ code: 'custom', message: '辅助参考图不能重复', path: ['reference_asset_ids'] });
if (input.mode === 'outpaint' && (input.ratio !== '3:4' || input.reference_asset_ids.length || input.generate_size_chart))
ctx.addIssue({ code: 'custom', message: '仅扩图只支持单张主图与 3:4 比例', path: ['mode'] });
if (input.mode === 'edit' && input.background === 'reference' && !input.reference_asset_ids.length)
ctx.addIssue({ code: 'custom', message: '参考背景需要一张辅助图', path: ['reference_asset_ids'] });
if (input.generate_size_chart && !input.dimensions
|| input.generate_size_chart && input.dimensions
&& ![input.dimensions.length, input.dimensions.width, input.dimensions.height].some((value) => value && value > 0))
ctx.addIssue({ code: 'custom', message: '尺寸标注图需要至少填写一个尺寸', path: ['dimensions'] });
});
export type CreateMediaAiEditRequest = z.input<typeof createMediaAiEditRequestSchema>;
export type MediaAiEditSettings = z.output<typeof createMediaAiEditRequestSchema>;
export type CreateMediaUploadIntentRequest = z.infer< export type CreateMediaUploadIntentRequest = z.infer<
typeof createMediaUploadIntentRequestSchema typeof createMediaUploadIntentRequestSchema
@@ -82,6 +127,59 @@ export type ProductUploadImage = MediaAsset & {
preview_url_expires_at: string; preview_url_expires_at: string;
}; };
export type ProductImportedImage = ProductUploadImage & { source_url: string }; export type ProductImportedImage = ProductUploadImage & { source_url: string };
export type AspectRatioDerivative = {
id: string;
status: 'queued' | 'running' | 'succeeded' | 'failed';
source_asset_id: string;
output_asset: ProductUploadImage | null;
error_code: string | null;
};
export type WatermarkDerivative = AspectRatioDerivative & {
watermark_template_id: string;
watermark_template_version: number;
};
/** 商品图片处理记录只返回可重新选用的成功结果。 */
export type ProductImageDerivativeRecord = {
id: string;
kind: 'aspect_ratio_3_4' | 'watermark';
source_asset_id: string;
created_at: string;
output_asset: ProductUploadImage;
};
export type MediaAiEdit = {
id: string;
status: 'queued' | 'running' | 'succeeded' | 'failed' | 'cancelled';
source_asset_id: string;
output_asset: ProductUploadImage | null;
error_code: string | null;
failure_source: 'pcore' | 'atlas' | null;
failure_stage: MediaAiEditFailureStage | null;
failure_detail: string | null;
};
export type MediaAiEditFailureStage = 'task_setup' | 'input_preparation' | 'provider_submission'
| 'provider_polling' | 'result_download' | 'result_processing' | 'result_storage';
/** 商品页右下角任务列表的统一图片任务模型;套图任务后续使用同一结构。 */
export type ProductImageTask = {
id: string;
kind: 'single_ai_edit' | 'ai_suite';
status: 'queued' | 'running' | 'succeeded' | 'partial' | 'failed' | 'cancelled';
phase: 'waiting_provider' | null;
total: number;
completed: number;
output_asset_ids: string[];
error_code: string | null;
failure_source: 'pcore' | 'atlas' | null;
failure_stage: MediaAiEditFailureStage | null;
failure_detail: string | null;
provider_error_raw: string | null;
created_at: string;
completed_at: string | null;
};
export const listProductImageTasksQuerySchema = z.object({
limit: z.coerce.number().int().min(1).max(100).default(20),
cursor: z.string().optional(),
});
export type ListProductImageTasksQuery = z.infer<typeof listProductImageTasksQuerySchema>;
/** POST /api/media-assets/{asset_id}/access-urls 响应(短时预览 URL) */ /** POST /api/media-assets/{asset_id}/access-urls 响应(短时预览 URL) */
export type MediaAccessUrl = { export type MediaAccessUrl = {
+104
View File
@@ -0,0 +1,104 @@
import { z } from 'zod';
export const listOnlineProductsQuerySchema = z
.object({
store_id: z.uuid().optional(),
cursor: z.string().min(1).optional(),
limit: z.coerce.number().int().min(1).max(100).default(20),
q: z.string().trim().max(200).optional(),
visibility: z
.enum(['all', 'visible', 'invisible', 'unknown'])
.default('all'),
source: z.enum(['atlas_published', 'external_sync']).optional(),
moderation_status: z.string().trim().max(100).optional(),
include_archived: z
.union([
z.boolean(),
z
.enum(['true', 'false'])
.transform((value) => value === 'true'),
])
.default(false),
})
.strict();
export const onlineProductSyncRequestSchema = z
.object({
reason: z.literal('manual_refresh').optional(),
})
.strict();
export const listOnlineProductSyncRunsQuerySchema = z
.object({
cursor: z.string().min(1).optional(),
limit: z.coerce.number().int().min(1).max(100).default(20),
})
.strict();
export type ListOnlineProductsQuery = z.infer<
typeof listOnlineProductsQuerySchema
>;
export type ListOnlineProductSyncRunsQuery = z.infer<
typeof listOnlineProductSyncRunsQuerySchema
>;
export type OnlineProductSource = 'atlas_published' | 'external_sync';
export type OnlineProductSyncStatus =
'queued' | 'running' | 'succeeded' | 'failed' | 'cancelled';
export type OnlineProduct = {
id: string;
store_id: string;
platform_id: string;
remote_product_id: string;
offer_id: string;
remote_sku: string | null;
name: string | null;
currency_code: string | null;
selling_price: string | null;
list_price: string | null;
visible: boolean | null;
remote_state: string | null;
moderation_status: string | null;
validation_status: string | null;
stock_summary: { total: number; has_data: boolean };
image: {
url: string | null;
width_px: number | null;
height_px: number | null;
};
errors: Array<{ code?: string; message: string }>;
visibility_details: Record<string, unknown>;
source: OnlineProductSource;
atlas_origin: {
collection_item_id: string;
product_id: string;
variant_id: string;
} | null;
last_synced_at: string;
missing_since: string | null;
archived_at: string | null;
};
export type OnlineProductSyncRun = {
id: string;
store_id: string;
status: OnlineProductSyncStatus;
trigger_source: 'manual' | 'scheduled' | 'recovery';
cursor_started: string | null;
cursor_finished: string | null;
listed_count: number;
upserted_count: number;
failed_count: number;
created_at: string;
started_at: string | null;
completed_at: string | null;
error: { code: string; message: string } | null;
};
export type OnlineProductsMeta = {
next_cursor: string | null;
has_more: boolean;
store: { id: string; name: string };
last_successful_sync_at: string | null;
last_sync_status: OnlineProductSyncStatus | null;
};
+1 -1
View File
@@ -540,7 +540,7 @@ export const saveProductEditorDraftRequestSchema = z
.max(20), .max(20),
purchase_cost_cny: z purchase_cost_cny: z
.string() .string()
.regex(/^\d+(\.\d{1,4})?$/) .regex(/^\d+(\.\d{1,2})?$/)
.nullable(), .nullable(),
title: nullableText(1_000).optional(), title: nullableText(1_000).optional(),
tags: z tags: z
+36 -3
View File
@@ -7,6 +7,13 @@ const opacity = z
const scaleRatio = z const scaleRatio = z
.string() .string()
.regex(/^(0\.([1-9]|[1-9]\d{1,3})|1(\.\d{1,4})?|2(\.0{1,4})?)$/, '缩放比例必须在 0.1 到 2 之间'); .regex(/^(0\.([1-9]|[1-9]\d{1,3})|1(\.\d{1,4})?|2(\.0{1,4})?)$/, '缩放比例必须在 0.1 到 2 之间');
export const WATERMARK_IMAGE_SIZE_MIN_PX = 10;
export const WATERMARK_IMAGE_SIZE_DEFAULT_PX = 80;
export const WATERMARK_IMAGE_SIZE_MAX_PX = 200;
export const WATERMARK_BORDER_RADIUS_MAX_PX = 100;
const imageSizePx = z.number().int().min(WATERMARK_IMAGE_SIZE_MIN_PX).max(WATERMARK_IMAGE_SIZE_MAX_PX);
const borderRadiusPx = z.number().int().min(0).max(WATERMARK_BORDER_RADIUS_MAX_PX);
const positionCoordinate = z.number().finite().min(0).max(1);
export type WatermarkPosition = z.infer<typeof watermarkPositionSchema>; export type WatermarkPosition = z.infer<typeof watermarkPositionSchema>;
export const watermarkPositionSchema = z.enum([ export const watermarkPositionSchema = z.enum([
'top_left', 'top_center', 'top_right', 'middle_left', 'center', 'top_left', 'top_center', 'top_right', 'middle_left', 'center',
@@ -18,13 +25,19 @@ const textConfig = z.object({
font_code: z.string().trim().min(1).max(120), font_code: z.string().trim().min(1).max(120),
font_size_px: z.number().int().min(12).max(60), font_size_px: z.number().int().min(12).max(60),
opacity, opacity,
position: watermarkPositionSchema, position_x: positionCoordinate.optional(),
position_y: positionCoordinate.optional(),
position: watermarkPositionSchema.optional(),
}).strict(); }).strict();
const imageConfig = z.object({ const imageConfig = z.object({
image_asset_id: uuid, image_asset_id: uuid,
scale_ratio: scaleRatio, image_size_px: imageSizePx.optional(),
border_radius_px: borderRadiusPx.optional(),
scale_ratio: scaleRatio.optional(),
opacity, opacity,
position: watermarkPositionSchema, position_x: positionCoordinate.optional(),
position_y: positionCoordinate.optional(),
position: watermarkPositionSchema.optional(),
}).strict(); }).strict();
export const createWatermarkTemplateRequestSchema = z.object({ export const createWatermarkTemplateRequestSchema = z.object({
@@ -38,6 +51,15 @@ export const createWatermarkTemplateRequestSchema = z.object({
if (value.kind === 'image' && !('image_asset_id' in value.config)) { if (value.kind === 'image' && !('image_asset_id' in value.config)) {
context.addIssue({ code: 'custom', path: ['config'], message: '图片水印需要完整图片配置' }); context.addIssue({ code: 'custom', path: ['config'], message: '图片水印需要完整图片配置' });
} }
if (value.kind === 'image' && 'image_asset_id' in value.config
&& value.config.image_size_px === undefined && value.config.scale_ratio === undefined) {
context.addIssue({ code: 'custom', path: ['config'], message: '图片水印需要提供像素尺寸' });
}
const hasX = value.config.position_x !== undefined;
const hasY = value.config.position_y !== undefined;
if (hasX !== hasY || (!hasX && !value.config.position)) {
context.addIssue({ code: 'custom', path: ['config'], message: '需要同时提供水平和垂直坐标,或提供旧版九宫格位置' });
}
}); });
const partialConfig = z.object({ const partialConfig = z.object({
@@ -47,6 +69,10 @@ const partialConfig = z.object({
font_size_px: z.number().int().min(12).max(60).optional(), font_size_px: z.number().int().min(12).max(60).optional(),
opacity: opacity.optional(), opacity: opacity.optional(),
scale_ratio: scaleRatio.optional(), scale_ratio: scaleRatio.optional(),
image_size_px: imageSizePx.optional(),
border_radius_px: borderRadiusPx.optional(),
position_x: positionCoordinate.optional(),
position_y: positionCoordinate.optional(),
position: watermarkPositionSchema.optional(), position: watermarkPositionSchema.optional(),
}).strict(); }).strict();
@@ -94,6 +120,12 @@ export type WatermarkTemplateConfig = {
image_asset?: WatermarkTemplateImageAsset; image_asset?: WatermarkTemplateImageAsset;
opacity?: string; opacity?: string;
scale_ratio?: string; scale_ratio?: string;
image_size_px?: number;
border_radius_px?: number;
/** 0 为左/上边缘,1 为右/下边缘,按水印可移动范围归一化。 */
position_x: number;
position_y: number;
/** 兼容旧版九宫格客户端的近似位置;精确渲染以坐标为准。 */
position: WatermarkPosition; position: WatermarkPosition;
}; };
@@ -102,6 +134,7 @@ export type WatermarkTemplate = {
id: string; id: string;
name: string; name: string;
kind: 'text' | 'image'; kind: 'text' | 'image';
is_default: boolean;
current_version: number; current_version: number;
config: WatermarkTemplateConfig; config: WatermarkTemplateConfig;
created_at: string; created_at: string;
@@ -0,0 +1,28 @@
SET search_path TO atlas, public;
-- 派生任务独立保存源图和结果,避免编辑草稿确认前修改原图或其他 SKU 的选图。
CREATE TABLE media_aspect_ratio_derivatives (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
workspace_id UUID NOT NULL REFERENCES workspaces(id) ON DELETE RESTRICT,
source_asset_id UUID NOT NULL,
output_asset_id UUID,
status TEXT NOT NULL DEFAULT 'queued' CHECK (status IN ('queued', 'running', 'succeeded', 'failed')),
error_code TEXT,
created_by_user_id UUID REFERENCES users(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
started_at TIMESTAMPTZ,
completed_at TIMESTAMPTZ,
FOREIGN KEY (workspace_id, source_asset_id) REFERENCES media_assets(workspace_id, id) ON DELETE RESTRICT,
FOREIGN KEY (workspace_id, output_asset_id) REFERENCES media_assets(workspace_id, id) ON DELETE RESTRICT,
UNIQUE (workspace_id, id),
CHECK ((status = 'succeeded') = (output_asset_id IS NOT NULL)),
CHECK (status <> 'failed' OR error_code IS NOT NULL)
);
CREATE INDEX ix_media_aspect_ratio_derivatives_source
ON media_aspect_ratio_derivatives (workspace_id, source_asset_id, created_at DESC);
ALTER TABLE media_aspect_ratio_derivatives ENABLE ROW LEVEL SECURITY;
ALTER TABLE media_aspect_ratio_derivatives FORCE ROW LEVEL SECURITY;
CREATE POLICY workspace_isolation ON media_aspect_ratio_derivatives
USING (workspace_id = app_current_workspace_id())
WITH CHECK (workspace_id = app_current_workspace_id());
@@ -0,0 +1,44 @@
SET search_path TO atlas, public;
-- 精确坐标按水印在预览区域内的可移动范围归一化;旧九宫格数据保留原位置。
ALTER TABLE watermark_template_versions
ADD COLUMN IF NOT EXISTS position_x NUMERIC(5,4),
ADD COLUMN IF NOT EXISTS position_y NUMERIC(5,4);
-- 迁移角色受强制 RLS 限制,回填期间临时关闭并在同一事务内恢复。
ALTER TABLE watermark_template_versions NO FORCE ROW LEVEL SECURITY;
ALTER TABLE watermark_template_versions DISABLE ROW LEVEL SECURITY;
UPDATE watermark_template_versions
SET position_x = CASE
WHEN position IN ('top_left', 'middle_left', 'bottom_left') THEN 0
WHEN position IN ('top_center', 'center', 'bottom_center') THEN 0.5
ELSE 1
END,
position_y = CASE
WHEN position IN ('top_left', 'top_center', 'top_right') THEN 0
WHEN position IN ('middle_left', 'center', 'middle_right') THEN 0.5
ELSE 1
END
WHERE position_x IS NULL OR position_y IS NULL;
ALTER TABLE watermark_template_versions ENABLE ROW LEVEL SECURITY;
ALTER TABLE watermark_template_versions FORCE ROW LEVEL SECURITY;
ALTER TABLE watermark_template_versions
ALTER COLUMN position_x SET NOT NULL,
ALTER COLUMN position_y SET NOT NULL;
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint
WHERE conrelid = 'atlas.watermark_template_versions'::regclass
AND conname = 'watermark_template_versions_position_coordinates_check'
) THEN
ALTER TABLE watermark_template_versions
ADD CONSTRAINT watermark_template_versions_position_coordinates_check
CHECK (position_x BETWEEN 0 AND 1 AND position_y BETWEEN 0 AND 1);
END IF;
END
$$;
@@ -0,0 +1,48 @@
SET search_path TO atlas, public;
ALTER TABLE watermark_template_versions
ADD COLUMN IF NOT EXISTS image_size_px SMALLINT,
ADD COLUMN IF NOT EXISTS border_radius_px SMALLINT NOT NULL DEFAULT 0;
-- 旧图片模板的比例按原 240px 预览框中的显示宽度换算,保留可编辑的像素尺寸。
ALTER TABLE watermark_template_versions NO FORCE ROW LEVEL SECURITY;
ALTER TABLE watermark_template_versions DISABLE ROW LEVEL SECURITY;
UPDATE watermark_template_versions
SET image_size_px = LEAST(200, GREATEST(20, ROUND(scale_ratio * 144)::integer))
WHERE kind = 'image' AND image_size_px IS NULL;
ALTER TABLE watermark_template_versions ENABLE ROW LEVEL SECURITY;
ALTER TABLE watermark_template_versions FORCE ROW LEVEL SECURITY;
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint
WHERE conrelid = 'atlas.watermark_template_versions'::regclass
AND conname = 'watermark_template_versions_image_size_check'
) THEN
ALTER TABLE watermark_template_versions
ADD CONSTRAINT watermark_template_versions_image_size_check
CHECK (image_size_px IS NULL OR image_size_px BETWEEN 20 AND 200);
END IF;
IF NOT EXISTS (
SELECT 1 FROM pg_constraint
WHERE conrelid = 'atlas.watermark_template_versions'::regclass
AND conname = 'watermark_template_versions_image_size_required_check'
) THEN
ALTER TABLE watermark_template_versions
ADD CONSTRAINT watermark_template_versions_image_size_required_check
CHECK (kind <> 'image' OR image_size_px IS NOT NULL);
END IF;
IF NOT EXISTS (
SELECT 1 FROM pg_constraint
WHERE conrelid = 'atlas.watermark_template_versions'::regclass
AND conname = 'watermark_template_versions_border_radius_check'
) THEN
ALTER TABLE watermark_template_versions
ADD CONSTRAINT watermark_template_versions_border_radius_check
CHECK (border_radius_px BETWEEN 0 AND 100);
END IF;
END
$$;
@@ -0,0 +1,32 @@
SET search_path TO atlas, public;
-- 水印派生引用不可变模板版本;编辑草稿确认前不改写原图或 SKU 选图。
CREATE TABLE media_watermark_derivatives (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
workspace_id UUID NOT NULL REFERENCES workspaces(id) ON DELETE RESTRICT,
source_asset_id UUID NOT NULL,
watermark_template_id UUID NOT NULL,
watermark_template_version INTEGER NOT NULL CHECK (watermark_template_version > 0),
output_asset_id UUID,
status TEXT NOT NULL DEFAULT 'queued' CHECK (status IN ('queued', 'running', 'succeeded', 'failed')),
error_code TEXT,
created_by_user_id UUID REFERENCES users(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
started_at TIMESTAMPTZ,
completed_at TIMESTAMPTZ,
FOREIGN KEY (workspace_id, source_asset_id) REFERENCES media_assets(workspace_id, id) ON DELETE RESTRICT,
FOREIGN KEY (workspace_id, output_asset_id) REFERENCES media_assets(workspace_id, id) ON DELETE RESTRICT,
FOREIGN KEY (workspace_id, watermark_template_id, watermark_template_version)
REFERENCES watermark_template_versions(workspace_id, template_id, version) ON DELETE RESTRICT,
UNIQUE (workspace_id, id),
CHECK ((status = 'succeeded') = (output_asset_id IS NOT NULL)),
CHECK (status <> 'failed' OR error_code IS NOT NULL)
);
CREATE INDEX ix_media_watermark_derivatives_source
ON media_watermark_derivatives (workspace_id, source_asset_id, created_at DESC);
ALTER TABLE media_watermark_derivatives ENABLE ROW LEVEL SECURITY;
ALTER TABLE media_watermark_derivatives FORCE ROW LEVEL SECURITY;
CREATE POLICY workspace_isolation ON media_watermark_derivatives
USING (workspace_id = app_current_workspace_id())
WITH CHECK (workspace_id = app_current_workspace_id());
@@ -0,0 +1,9 @@
SET search_path TO atlas, public;
-- 图片水印最小最长边从 20px 调整为 10px;已有模板数据保持不变。
ALTER TABLE watermark_template_versions
DROP CONSTRAINT IF EXISTS watermark_template_versions_image_size_check;
ALTER TABLE watermark_template_versions
ADD CONSTRAINT watermark_template_versions_image_size_check
CHECK (image_size_px IS NULL OR image_size_px BETWEEN 10 AND 200);
@@ -0,0 +1,8 @@
SET search_path TO atlas, public;
ALTER TABLE watermark_templates
ADD COLUMN IF NOT EXISTS is_default BOOLEAN NOT NULL DEFAULT false;
CREATE UNIQUE INDEX IF NOT EXISTS ux_watermark_templates_workspace_default
ON watermark_templates (workspace_id)
WHERE is_default AND status = 'active' AND deleted_at IS NULL;
@@ -0,0 +1,32 @@
SET search_path TO atlas, public;
-- 单张 AI 编辑保存素材 ID 和编辑参数;供应商任务 ID 只在工作空间内可见。
CREATE TABLE media_ai_edits (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
workspace_id UUID NOT NULL REFERENCES workspaces(id) ON DELETE RESTRICT,
source_asset_id UUID NOT NULL,
reference_asset_ids UUID[] NOT NULL DEFAULT '{}',
settings JSONB NOT NULL,
provider_task_id TEXT,
output_asset_id UUID,
status TEXT NOT NULL DEFAULT 'queued'
CHECK (status IN ('queued', 'running', 'succeeded', 'failed', 'cancelled')),
error_code TEXT,
created_by_user_id UUID REFERENCES users(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
started_at TIMESTAMPTZ,
completed_at TIMESTAMPTZ,
FOREIGN KEY (workspace_id, source_asset_id) REFERENCES media_assets(workspace_id, id) ON DELETE RESTRICT,
FOREIGN KEY (workspace_id, output_asset_id) REFERENCES media_assets(workspace_id, id) ON DELETE RESTRICT,
UNIQUE (workspace_id, id),
CHECK (cardinality(reference_asset_ids) <= 2),
CHECK ((status = 'succeeded') = (output_asset_id IS NOT NULL)),
CHECK (status <> 'failed' OR error_code IS NOT NULL)
);
CREATE INDEX ix_media_ai_edits_source ON media_ai_edits (workspace_id, source_asset_id, created_at DESC);
ALTER TABLE media_ai_edits ENABLE ROW LEVEL SECURITY;
ALTER TABLE media_ai_edits FORCE ROW LEVEL SECURITY;
CREATE POLICY workspace_isolation ON media_ai_edits
USING (workspace_id = app_current_workspace_id())
WITH CHECK (workspace_id = app_current_workspace_id());

Some files were not shown because too many files have changed in this diff Show More