feat(ai-image): AI生图开发
This commit is contained in:
@@ -13,6 +13,7 @@ import { CollectionModule } from './collection/collection.module.js';
|
||||
import { ExtensionModule } from './extension/extension.module.js';
|
||||
import { ProductEditorModule } from './product-editor/product-editor.module.js';
|
||||
import { PlatformCatalogAdminModule } from './platform-catalog-admin/platform-catalog-admin.module.js';
|
||||
import { OnlineProductsModule } from './online-products/online-products.module.js';
|
||||
|
||||
@Controller()
|
||||
class HealthController {
|
||||
@@ -53,6 +54,7 @@ class HealthController {
|
||||
ExtensionModule,
|
||||
ProductEditorModule,
|
||||
PlatformCatalogAdminModule,
|
||||
OnlineProductsModule,
|
||||
],
|
||||
controllers: [HealthController],
|
||||
})
|
||||
|
||||
@@ -321,12 +321,14 @@ export class ExtensionService {
|
||||
await tx.query(
|
||||
`UPDATE atlas.product_variants
|
||||
SET source_sku = $1, display_name = $2, sort_order = $3,
|
||||
purchase_cost_cny = COALESCE(ROUND($4::numeric, 2), purchase_cost_cny),
|
||||
deleted_at = NULL, updated_at = now()
|
||||
WHERE id = $4`,
|
||||
WHERE id = $5`,
|
||||
[
|
||||
variant.sourceSku ?? null,
|
||||
variant.displayName,
|
||||
index,
|
||||
sourcePurchaseCost(payload.source.platform, variant),
|
||||
variantId,
|
||||
],
|
||||
);
|
||||
@@ -334,8 +336,8 @@ export class ExtensionService {
|
||||
} else {
|
||||
const inserted = await tx.query<{ id: string }>(
|
||||
`INSERT INTO atlas.product_variants
|
||||
(workspace_id, product_id, source_variant_key, source_sku, display_name, sort_order)
|
||||
VALUES ($1, $2, $3, $4, $5, $6) RETURNING id`,
|
||||
(workspace_id, product_id, source_variant_key, source_sku, display_name, sort_order, purchase_cost_cny)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, ROUND($7::numeric, 2)) RETURNING id`,
|
||||
[
|
||||
workspaceId,
|
||||
productId,
|
||||
@@ -343,6 +345,7 @@ export class ExtensionService {
|
||||
variant.sourceSku ?? null,
|
||||
variant.displayName,
|
||||
index,
|
||||
sourcePurchaseCost(payload.source.platform, variant),
|
||||
],
|
||||
);
|
||||
variantId = inserted.rows[0].id;
|
||||
@@ -486,3 +489,13 @@ function sourceState(payload: CollectedProductPayload) {
|
||||
sourcePrice: payload.common.sourcePrice,
|
||||
};
|
||||
}
|
||||
|
||||
/** 仅将 1688 明确标为人民币的逐 SKU 来源价写入采购价。 */
|
||||
function sourcePurchaseCost(
|
||||
platform: CollectedProductPayload['source']['platform'],
|
||||
variant: CollectedProductPayload['variants'][number] | { sourceVariantKey: null },
|
||||
) {
|
||||
return platform === '1688' && 'sourcePrice' in variant && variant.sourcePrice?.currencyCode === 'CNY'
|
||||
? variant.sourcePrice.amount
|
||||
: null;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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 } } };
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,9 @@ import {
|
||||
Get,
|
||||
Param,
|
||||
Post,
|
||||
Query,
|
||||
Req,
|
||||
Res,
|
||||
} from '@nestjs/common';
|
||||
import {
|
||||
ApiBearerAuth,
|
||||
@@ -19,6 +21,11 @@ import {
|
||||
createMediaAccessUrlRequestSchema,
|
||||
createMediaUploadIntentRequestSchema,
|
||||
importProductImageRequestSchema,
|
||||
createAspectRatioDerivativeRequestSchema,
|
||||
createWatermarkDerivativeRequestSchema,
|
||||
createMediaAiEditRequestSchema,
|
||||
listProductImageTasksQuerySchema,
|
||||
type CreateMediaAccessUrlRequest,
|
||||
type Workspace,
|
||||
} from '@atlas/contracts';
|
||||
import { z } from 'zod';
|
||||
@@ -26,6 +33,11 @@ import { ApiError } from '../common/api-error.js';
|
||||
import type { AuthenticatedRequest } from '../auth/auth.guard.js';
|
||||
import { CurrentWorkspace } from '../workspace/current-workspace.decorator.js';
|
||||
import { MediaService } from './media.service.js';
|
||||
import { AspectRatioDerivativeService } from './aspect-ratio-derivative.service.js';
|
||||
import { WatermarkDerivativeService } from './watermark-derivative.service.js';
|
||||
import { MediaAiEditService } from './media-ai-edit.service.js';
|
||||
import { ProductImageTaskService } from './product-image-task.service.js';
|
||||
import { ProductImageDerivativeHistoryService } from './product-image-derivative-history.service.js';
|
||||
|
||||
@ApiTags('Media')
|
||||
@ApiBearerAuth('access-token')
|
||||
@@ -33,8 +45,132 @@ import { MediaService } from './media.service.js';
|
||||
export class MediaController {
|
||||
public constructor(
|
||||
@Inject(MediaService) private readonly media: MediaService,
|
||||
@Inject(AspectRatioDerivativeService) private readonly derivatives: AspectRatioDerivativeService,
|
||||
@Inject(WatermarkDerivativeService) private readonly watermarkDerivatives: WatermarkDerivativeService,
|
||||
@Inject(MediaAiEditService) private readonly aiEdits: MediaAiEditService,
|
||||
@Inject(ProductImageTaskService) private readonly productImageTasks: ProductImageTaskService,
|
||||
@Inject(ProductImageDerivativeHistoryService) private readonly derivativeHistory: ProductImageDerivativeHistoryService,
|
||||
) {}
|
||||
|
||||
@Get('products/:productId/image-derivatives')
|
||||
public async listProductImageDerivatives(
|
||||
@Param('productId') productId: string,
|
||||
@CurrentWorkspace() workspace: Workspace,
|
||||
) {
|
||||
return { data: await this.derivativeHistory.list(workspace.id, uuid('product_id', productId)) };
|
||||
}
|
||||
|
||||
@Get('products/:productId/image-tasks')
|
||||
public async listProductImageTasks(
|
||||
@Param('productId') productId: string,
|
||||
@CurrentWorkspace() workspace: Workspace,
|
||||
@Query() query: unknown,
|
||||
) {
|
||||
return this.productImageTasks.list(workspace.id, uuid('product_id', productId),
|
||||
parse(listProductImageTasksQuerySchema, query));
|
||||
}
|
||||
|
||||
@Post('media-assets/:assetId/ai-edits')
|
||||
public async createAiEdit(
|
||||
@Param('assetId') assetId: string,
|
||||
@Body() body: unknown,
|
||||
@Headers('idempotency-key') key: string | undefined,
|
||||
@Req() request: AuthenticatedRequest,
|
||||
@CurrentWorkspace() workspace: Workspace,
|
||||
@Res({ passthrough: true }) response: { status: (code: number) => void },
|
||||
) {
|
||||
const result = await this.aiEdits.create(
|
||||
workspace.id, request.auth.userId, uuid('asset_id', assetId),
|
||||
idempotencyKey(key), parse(createMediaAiEditRequestSchema, body),
|
||||
);
|
||||
response.status(result.status);
|
||||
return result.body;
|
||||
}
|
||||
|
||||
@Get('media-ai-edits/:editId')
|
||||
public async aiEdit(
|
||||
@Param('editId') editId: string,
|
||||
@CurrentWorkspace() workspace: Workspace,
|
||||
) {
|
||||
return { data: await this.aiEdits.read(workspace.id, uuid('edit_id', editId)) };
|
||||
}
|
||||
|
||||
@Get('media-ai-edits')
|
||||
public async listAiEdits(
|
||||
@Query('product_id') productId: string,
|
||||
@CurrentWorkspace() workspace: Workspace,
|
||||
) {
|
||||
return { data: await this.aiEdits.list(workspace.id, uuid('product_id', productId)) };
|
||||
}
|
||||
|
||||
@Post('media-ai-edits/:editId/cancel')
|
||||
public async cancelAiEdit(
|
||||
@Param('editId') editId: string,
|
||||
@Headers('idempotency-key') key: string | undefined,
|
||||
@Req() request: AuthenticatedRequest,
|
||||
@CurrentWorkspace() workspace: Workspace,
|
||||
) {
|
||||
return (await this.aiEdits.cancel(
|
||||
workspace.id, request.auth.userId, uuid('edit_id', editId), idempotencyKey(key),
|
||||
)).body;
|
||||
}
|
||||
|
||||
@Post('media-assets/:assetId/watermark-derivatives')
|
||||
public async createWatermarkDerivative(
|
||||
@Param('assetId') assetId: string,
|
||||
@Body() body: unknown,
|
||||
@Headers('idempotency-key') key: string | undefined,
|
||||
@Req() request: AuthenticatedRequest,
|
||||
@CurrentWorkspace() workspace: Workspace,
|
||||
@Res({ passthrough: true }) response: { status: (code: number) => void },
|
||||
) {
|
||||
const result = await this.watermarkDerivatives.create(
|
||||
workspace.id,
|
||||
request.auth.userId,
|
||||
uuid('asset_id', assetId),
|
||||
idempotencyKey(key),
|
||||
parse(createWatermarkDerivativeRequestSchema, body),
|
||||
);
|
||||
response.status(result.status);
|
||||
return result.body;
|
||||
}
|
||||
|
||||
@Get('media-watermark-derivatives/:derivativeId')
|
||||
public async watermarkDerivative(
|
||||
@Param('derivativeId') derivativeId: string,
|
||||
@CurrentWorkspace() workspace: Workspace,
|
||||
) {
|
||||
return { data: await this.watermarkDerivatives.read(workspace.id, uuid('derivative_id', derivativeId)) };
|
||||
}
|
||||
|
||||
@Post('media-assets/:assetId/aspect-ratio-derivatives')
|
||||
public async createAspectRatioDerivative(
|
||||
@Param('assetId') assetId: string,
|
||||
@Body() body: unknown,
|
||||
@Headers('idempotency-key') key: string | undefined,
|
||||
@Req() request: AuthenticatedRequest,
|
||||
@CurrentWorkspace() workspace: Workspace,
|
||||
@Res({ passthrough: true }) response: { status: (code: number) => void },
|
||||
) {
|
||||
const result = await this.derivatives.create(
|
||||
workspace.id,
|
||||
request.auth.userId,
|
||||
uuid('asset_id', assetId),
|
||||
idempotencyKey(key),
|
||||
parse(createAspectRatioDerivativeRequestSchema, body),
|
||||
);
|
||||
response.status(result.status);
|
||||
return result.body;
|
||||
}
|
||||
|
||||
@Get('media-derivatives/:derivativeId')
|
||||
public async aspectRatioDerivative(
|
||||
@Param('derivativeId') derivativeId: string,
|
||||
@CurrentWorkspace() workspace: Workspace,
|
||||
) {
|
||||
return { data: await this.derivatives.read(workspace.id, uuid('derivative_id', derivativeId)) };
|
||||
}
|
||||
|
||||
@Get('media-assets/product-uploads')
|
||||
@ApiOkResponse({ description: '最近上传的商品图片及短时预览地址' })
|
||||
public async productUploads(@CurrentWorkspace() workspace: Workspace) {
|
||||
@@ -102,17 +238,18 @@ export class MediaController {
|
||||
}
|
||||
|
||||
@Post('media-assets/:assetId/access-urls')
|
||||
@ApiOkResponse({ description: '已签发短时预览 URL' })
|
||||
@ApiOkResponse({ description: '已签发短时预览或下载 URL' })
|
||||
public async accessUrl(
|
||||
@Param('assetId') assetId: string,
|
||||
@Body() body: unknown,
|
||||
@CurrentWorkspace() workspace: Workspace,
|
||||
) {
|
||||
parse(createMediaAccessUrlRequestSchema, body);
|
||||
const input = parse(createMediaAccessUrlRequestSchema, body) as CreateMediaAccessUrlRequest;
|
||||
return {
|
||||
data: await this.media.accessUrl(
|
||||
workspace.id,
|
||||
uuid('asset_id', assetId),
|
||||
input.purpose,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2,10 +2,15 @@ import { Module } from '@nestjs/common';
|
||||
import { IdempotencyService } from '../common/idempotency.service.js';
|
||||
import { MediaController } from './media.controller.js';
|
||||
import { MediaService } from './media.service.js';
|
||||
import { AspectRatioDerivativeService } from './aspect-ratio-derivative.service.js';
|
||||
import { WatermarkDerivativeService } from './watermark-derivative.service.js';
|
||||
import { MediaAiEditService } from './media-ai-edit.service.js';
|
||||
import { ProductImageTaskService } from './product-image-task.service.js';
|
||||
import { ProductImageDerivativeHistoryService } from './product-image-derivative-history.service.js';
|
||||
|
||||
@Module({
|
||||
controllers: [MediaController],
|
||||
providers: [IdempotencyService, MediaService],
|
||||
providers: [IdempotencyService, MediaService, AspectRatioDerivativeService, WatermarkDerivativeService, MediaAiEditService, ProductImageTaskService, ProductImageDerivativeHistoryService],
|
||||
exports: [MediaService],
|
||||
})
|
||||
export class MediaModule {}
|
||||
|
||||
@@ -11,6 +11,7 @@ import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import type {
|
||||
CompleteMediaUploadIntentRequest,
|
||||
CreateMediaAccessUrlRequest,
|
||||
CreateMediaUploadIntentRequest,
|
||||
ImportProductImageRequest,
|
||||
} from '@atlas/contracts';
|
||||
@@ -263,23 +264,36 @@ export class MediaService {
|
||||
}
|
||||
}
|
||||
|
||||
public async accessUrl(workspaceId: string, assetId: string) {
|
||||
public async accessUrl(
|
||||
workspaceId: string,
|
||||
assetId: string,
|
||||
purpose: CreateMediaAccessUrlRequest['purpose'] = 'preview',
|
||||
) {
|
||||
return this.database.workspaceTransaction(workspaceId, async (tx) => {
|
||||
const asset = await tx.query<{
|
||||
bucket: string;
|
||||
object_key: string;
|
||||
content_type: string;
|
||||
}>(
|
||||
`SELECT bucket, object_key FROM atlas.media_assets WHERE id = $1`,
|
||||
`SELECT bucket, object_key, content_type FROM atlas.media_assets WHERE id = $1`,
|
||||
[assetId],
|
||||
);
|
||||
if (!asset.rows[0])
|
||||
throw new ApiError(404, 'RESOURCE_NOT_FOUND', '素材不存在');
|
||||
const extension = ({
|
||||
'image/jpeg': 'jpg',
|
||||
'image/png': 'png',
|
||||
'image/webp': 'webp',
|
||||
} as Record<string, string>)[asset.rows[0].content_type] ?? 'bin';
|
||||
const expiresAt = new Date(Date.now() + 10 * 60_000);
|
||||
const url = await getSignedUrl(
|
||||
this.s3,
|
||||
new GetObjectCommand({
|
||||
Bucket: asset.rows[0].bucket,
|
||||
Key: asset.rows[0].object_key,
|
||||
...(purpose === 'download' ? {
|
||||
ResponseContentDisposition: `attachment; filename="atlas-image-${assetId}.${extension}"`,
|
||||
} : {}),
|
||||
}),
|
||||
{ expiresIn: 600 },
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
}>(
|
||||
`SELECT v.id, v.source_variant_key, v.source_sku, v.display_name, v.sort_order,
|
||||
v.purchase_cost_cny::text AS purchase_cost_cny, l.id AS listing_id, l.offer_id,
|
||||
v.purchase_cost_cny::numeric(18, 2)::text AS purchase_cost_cny, l.id AS listing_id, l.offer_id,
|
||||
l.title, l.description, l.tags, l.status, l.selling_price::text,
|
||||
l.list_price::text, l.currency_code, v.specification_values,
|
||||
v.physical_length_mm::text, v.physical_width_mm::text, v.physical_height_mm::text,
|
||||
@@ -1395,6 +1395,21 @@ export class ProductEditorService {
|
||||
)
|
||||
: { rows: [] };
|
||||
const importedIds = new Set(imported.rows.map((item) => item.asset_id));
|
||||
const processed = assetIds.length
|
||||
? await transaction.query<{ asset_id: string }>(
|
||||
`SELECT output_asset_id AS asset_id
|
||||
FROM atlas.media_aspect_ratio_derivatives
|
||||
WHERE product_id = $1 AND status = 'succeeded'
|
||||
AND output_asset_id = ANY($2::uuid[])
|
||||
UNION
|
||||
SELECT output_asset_id AS asset_id
|
||||
FROM atlas.media_watermark_derivatives
|
||||
WHERE product_id = $1 AND status = 'succeeded'
|
||||
AND output_asset_id = ANY($2::uuid[])`,
|
||||
[input.product.id, assetIds],
|
||||
)
|
||||
: { rows: [] };
|
||||
const processedIds = new Set(processed.rows.map((item) => item.asset_id));
|
||||
const candidateVariants = new Map<string, Set<string | null>>();
|
||||
for (const candidate of candidates.rows) {
|
||||
const variants =
|
||||
@@ -1435,6 +1450,7 @@ export class ProductEditorService {
|
||||
continue;
|
||||
if (uploadedIds.has(assetId)) continue;
|
||||
if (importedIds.has(assetId)) continue;
|
||||
if (processedIds.has(assetId)) continue;
|
||||
const allowedFor = candidateVariants.get(assetId);
|
||||
if (
|
||||
!allowedFor?.has(null) &&
|
||||
@@ -1443,7 +1459,7 @@ export class ProductEditorService {
|
||||
throw new ApiError(
|
||||
422,
|
||||
'LISTING_MEDIA_NOT_SELECTABLE',
|
||||
'只能选择已上传、已导入或当前采集快照中该 SKU 可用的已转存图片',
|
||||
'只能选择当前商品可用的采集、上传、导入或图片处理结果',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,6 +28,12 @@ export class WatermarksController {
|
||||
return this.watermarks.list(workspace.id, parse(listWatermarkTemplatesQuerySchema, query));
|
||||
}
|
||||
|
||||
@Get('watermark-templates/default')
|
||||
@ApiOperation({ summary: '读取当前工作空间的默认水印模板' })
|
||||
public async getDefault(@CurrentWorkspace() workspace: Workspace) {
|
||||
return { data: await this.watermarks.getDefault(workspace.id) };
|
||||
}
|
||||
|
||||
@Get('watermark-templates/:templateId')
|
||||
@ApiOperation({ summary: '读取水印模板当前版本' })
|
||||
public async get(@Param('templateId') templateId: string, @CurrentWorkspace() workspace: Workspace) {
|
||||
@@ -51,6 +57,15 @@ export class WatermarksController {
|
||||
idempotencyKey(key), revision(ifMatch), parse(updateWatermarkTemplateRequestSchema, body))).body;
|
||||
}
|
||||
|
||||
@Put('watermark-templates/:templateId/default')
|
||||
@ApiOkResponse({ description: '已设为当前工作空间的默认水印' })
|
||||
public async setDefault(@Param('templateId') templateId: string,
|
||||
@Headers('idempotency-key') key: string | undefined,
|
||||
@Req() request: AuthenticatedRequest, @CurrentWorkspace() workspace: Workspace) {
|
||||
return (await this.watermarks.setDefault(workspace.id, request.auth.userId,
|
||||
uuid('template_id', templateId), idempotencyKey(key))).body;
|
||||
}
|
||||
|
||||
@Delete('watermark-templates/:templateId')
|
||||
@HttpCode(204)
|
||||
@ApiNoContentResponse({ description: '已软删除模板并解除当前店铺绑定' })
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import {
|
||||
WATERMARK_BORDER_RADIUS_MAX_PX,
|
||||
WATERMARK_IMAGE_SIZE_MAX_PX,
|
||||
WATERMARK_IMAGE_SIZE_MIN_PX,
|
||||
} from '@atlas/contracts';
|
||||
import type {
|
||||
CreateWatermarkTemplateRequest,
|
||||
ListWatermarkTemplatesQuery,
|
||||
@@ -13,6 +18,7 @@ type TemplateRow = {
|
||||
id: string;
|
||||
name: string;
|
||||
kind: 'text' | 'image';
|
||||
is_default: boolean;
|
||||
current_version: number;
|
||||
created_at: Date | string;
|
||||
updated_at: Date | string;
|
||||
@@ -23,7 +29,11 @@ type TemplateRow = {
|
||||
font_size_px: number | null;
|
||||
opacity: string;
|
||||
scale_ratio: string | null;
|
||||
image_size_px: number | null;
|
||||
border_radius_px: number;
|
||||
position: string;
|
||||
position_x: string;
|
||||
position_y: string;
|
||||
};
|
||||
type TemplateConfig = Record<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(
|
||||
workspaceId: string,
|
||||
userId: string,
|
||||
@@ -97,7 +160,8 @@ export class WatermarksService {
|
||||
request: input,
|
||||
},
|
||||
async (tx) => {
|
||||
await this.validateConfig(tx, input.kind, input.config);
|
||||
const config = normalizeImageConfig(input.kind, normalizePlacement(input.config), input.config);
|
||||
await this.validateConfig(tx, input.kind, config);
|
||||
const inserted = await tx.query<{ id: string }>(
|
||||
`INSERT INTO atlas.watermark_templates (workspace_id, name, kind)
|
||||
VALUES (current_setting('app.workspace_id')::uuid, $1, $2) RETURNING id`,
|
||||
@@ -108,7 +172,7 @@ export class WatermarksService {
|
||||
inserted.rows[0].id,
|
||||
1,
|
||||
input.kind,
|
||||
input.config,
|
||||
config,
|
||||
userId,
|
||||
);
|
||||
return {
|
||||
@@ -158,10 +222,10 @@ export class WatermarksService {
|
||||
);
|
||||
}
|
||||
// 同类型编辑允许只提交变化字段;切换类型时旧类型字段不会通过新类型校验。
|
||||
const nextConfig = normalizeConfig(nextKind, {
|
||||
const nextConfig = normalizeConfig(nextKind, normalizeImageConfig(nextKind, normalizePlacement({
|
||||
...configFromRow(current),
|
||||
...(input.config ?? {}),
|
||||
});
|
||||
}, input.config), input.config));
|
||||
await this.validateConfig(tx, nextKind, nextConfig);
|
||||
const renderingChanged =
|
||||
input.config !== undefined || nextKind !== current.kind;
|
||||
@@ -217,7 +281,7 @@ export class WatermarksService {
|
||||
[templateId],
|
||||
);
|
||||
await tx.query(
|
||||
`UPDATE atlas.watermark_templates SET status = 'archived', deleted_at = now(),
|
||||
`UPDATE atlas.watermark_templates SET status = 'archived', deleted_at = now(), is_default = false,
|
||||
revision = revision + 1, updated_at = now() WHERE id = $1`,
|
||||
[templateId],
|
||||
);
|
||||
@@ -312,7 +376,9 @@ export class WatermarksService {
|
||||
) {
|
||||
if (
|
||||
typeof config.opacity !== 'string' ||
|
||||
typeof config.position !== 'string'
|
||||
typeof config.position !== 'string' ||
|
||||
!validCoordinate(config.position_x) ||
|
||||
!validCoordinate(config.position_y)
|
||||
) {
|
||||
throw new ApiError(422, 'VALIDATION_FAILED', '水印配置不完整');
|
||||
}
|
||||
@@ -344,7 +410,9 @@ export class WatermarksService {
|
||||
const assetId = config.image_asset_id;
|
||||
if (
|
||||
typeof assetId !== 'string' ||
|
||||
typeof config.scale_ratio !== 'string'
|
||||
typeof config.scale_ratio !== 'string' ||
|
||||
!validImageSize(config.image_size_px) ||
|
||||
!validBorderRadius(config.border_radius_px)
|
||||
) {
|
||||
throw new ApiError(422, 'VALIDATION_FAILED', '图片水印配置不完整');
|
||||
}
|
||||
@@ -371,8 +439,8 @@ export class WatermarksService {
|
||||
) {
|
||||
await tx.query(
|
||||
`INSERT INTO atlas.watermark_template_versions
|
||||
(workspace_id, template_id, version, kind, text_content, image_asset_id, font_code, font_size_px, opacity, scale_ratio, position, created_by_user_id)
|
||||
VALUES (current_setting('app.workspace_id')::uuid, $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)`,
|
||||
(workspace_id, template_id, version, kind, text_content, image_asset_id, font_code, font_size_px, opacity, scale_ratio, position_x, position_y, position, created_by_user_id, image_size_px, border_radius_px)
|
||||
VALUES (current_setting('app.workspace_id')::uuid, $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15)`,
|
||||
[
|
||||
templateId,
|
||||
version,
|
||||
@@ -383,17 +451,23 @@ export class WatermarksService {
|
||||
config.font_size_px ?? null,
|
||||
config.opacity,
|
||||
config.scale_ratio ?? null,
|
||||
config.position_x,
|
||||
config.position_y,
|
||||
config.position,
|
||||
userId,
|
||||
config.image_size_px ?? null,
|
||||
config.border_radius_px ?? 0,
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const templateSelect = `SELECT template.id, template.name, template.kind, template.current_version,
|
||||
const templateSelect = `SELECT template.id, template.name, template.kind, template.is_default, template.current_version,
|
||||
template.created_at, template.updated_at, template.revision, version.text_content, version.image_asset_id,
|
||||
version.font_code, version.font_size_px, version.opacity::text AS opacity, version.scale_ratio::text AS scale_ratio,
|
||||
version.position FROM atlas.watermark_templates template JOIN atlas.watermark_template_versions version
|
||||
version.image_size_px, version.border_radius_px,
|
||||
version.position, version.position_x::text AS position_x, version.position_y::text AS position_y
|
||||
FROM atlas.watermark_templates template JOIN atlas.watermark_template_versions version
|
||||
ON version.template_id = template.id AND version.version = template.current_version`;
|
||||
|
||||
function toTemplate(row: TemplateRow) {
|
||||
@@ -401,6 +475,7 @@ function toTemplate(row: TemplateRow) {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
kind: row.kind,
|
||||
is_default: row.is_default,
|
||||
current_version: row.current_version,
|
||||
config: configFromRow(row),
|
||||
created_at: iso(row.created_at),
|
||||
@@ -416,12 +491,18 @@ function configFromRow(row: TemplateRow): TemplateConfig {
|
||||
font_size_px: row.font_size_px,
|
||||
opacity: row.opacity,
|
||||
position: row.position,
|
||||
position_x: Number(row.position_x),
|
||||
position_y: Number(row.position_y),
|
||||
}
|
||||
: {
|
||||
image_asset_id: row.image_asset_id,
|
||||
scale_ratio: row.scale_ratio,
|
||||
image_size_px: row.image_size_px,
|
||||
border_radius_px: row.border_radius_px,
|
||||
opacity: row.opacity,
|
||||
position: row.position,
|
||||
position_x: Number(row.position_x),
|
||||
position_y: Number(row.position_y),
|
||||
};
|
||||
}
|
||||
function normalizeConfig(
|
||||
@@ -435,14 +516,94 @@ function normalizeConfig(
|
||||
font_size_px: config.font_size_px,
|
||||
opacity: config.opacity,
|
||||
position: config.position,
|
||||
position_x: config.position_x,
|
||||
position_y: config.position_y,
|
||||
}
|
||||
: {
|
||||
image_asset_id: config.image_asset_id,
|
||||
scale_ratio: config.scale_ratio,
|
||||
image_size_px: config.image_size_px,
|
||||
border_radius_px: config.border_radius_px,
|
||||
opacity: config.opacity,
|
||||
position: config.position,
|
||||
position_x: config.position_x,
|
||||
position_y: config.position_y,
|
||||
};
|
||||
}
|
||||
|
||||
const legacyPositions = [
|
||||
'top_left', 'top_center', 'top_right',
|
||||
'middle_left', 'center', 'middle_right',
|
||||
'bottom_left', 'bottom_center', 'bottom_right',
|
||||
] as const;
|
||||
|
||||
function validCoordinate(value: unknown): value is number {
|
||||
return typeof value === 'number' && Number.isFinite(value) && value >= 0 && value <= 1;
|
||||
}
|
||||
|
||||
function validImageSize(value: unknown): value is number {
|
||||
return Number.isInteger(value) && Number(value) >= WATERMARK_IMAGE_SIZE_MIN_PX
|
||||
&& Number(value) <= WATERMARK_IMAGE_SIZE_MAX_PX;
|
||||
}
|
||||
|
||||
function validBorderRadius(value: unknown): value is number {
|
||||
return Number.isInteger(value) && Number(value) >= 0
|
||||
&& Number(value) <= WATERMARK_BORDER_RADIUS_MAX_PX;
|
||||
}
|
||||
|
||||
function legacyImageSize(value: unknown): number | undefined {
|
||||
const ratio = Number(value);
|
||||
if (!Number.isFinite(ratio)) return undefined;
|
||||
return Math.min(WATERMARK_IMAGE_SIZE_MAX_PX,
|
||||
Math.max(WATERMARK_IMAGE_SIZE_MIN_PX, Math.round(ratio * 144)));
|
||||
}
|
||||
|
||||
function normalizeImageConfig(
|
||||
kind: 'text' | 'image', config: TemplateConfig, patch?: Record<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) {
|
||||
return value instanceof Date ? value.toISOString() : value;
|
||||
}
|
||||
|
||||
@@ -179,6 +179,69 @@ describe('扩展授权与采集 HTTP 集成测试', () => {
|
||||
assert.equal(first.body.data.collection_item.status, 'ready_for_edit');
|
||||
assert.equal(first.body.data.materialization.pending_selection, 1);
|
||||
assert.equal(first.body.data.materialization.queued, 0);
|
||||
const source1688 = {
|
||||
source: {
|
||||
platform: '1688', itemKey: '1688-price-image-test',
|
||||
canonicalUrl: 'https://detail.1688.com/offer/123456789.html',
|
||||
collectedAt: new Date().toISOString(), parserVersion: '1688-test',
|
||||
},
|
||||
common: { attributes: [], categories: [] },
|
||||
variantAxes: [],
|
||||
variants: [{
|
||||
sourceVariantKey: '6100454176961', sourceSku: '6100454176961',
|
||||
displayName: '六代目卡卡西25cm', selections: [],
|
||||
sourcePrice: { amount: '31.335', currencyCode: 'CNY' },
|
||||
confidence: 'verified', evidenceRefs: ['1688:sku-map'],
|
||||
}],
|
||||
assets: [{
|
||||
url: 'https://cbu01.alicdn.com/sku.jpg', role: 'source_sku',
|
||||
relation: 'axis_value', variantSourceKey: '6100454176961',
|
||||
sortOrder: 0, evidenceRef: '1688:sku-map',
|
||||
}],
|
||||
evidence: [{ ref: '1688:sku-map', kind: 'state', path: 'skuModel.skuInfoMap' }],
|
||||
};
|
||||
const captured1688 = await request('/api/extension/materials', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${webAccessToken}`,
|
||||
'Idempotency-Key': randomUUID(),
|
||||
},
|
||||
body: JSON.stringify(source1688),
|
||||
});
|
||||
assert.equal(captured1688.response.status, 201);
|
||||
const associated1688 = await database.workspaceTransaction(workspaceId, async (tx) =>
|
||||
tx.query<{ purchase_cost_cny: string; source_variant_id: string; variant_id: string }>(
|
||||
`SELECT v.purchase_cost_cny::text, t.source_variant_id, v.id AS variant_id
|
||||
FROM atlas.product_variants v
|
||||
JOIN atlas.collection_asset_transfers t ON t.product_id = v.product_id
|
||||
WHERE v.product_id = $1 AND t.role = 'source_sku'`,
|
||||
[captured1688.body.data.product_id],
|
||||
),
|
||||
);
|
||||
assert.equal(associated1688.rows[0]?.purchase_cost_cny, '31.3400');
|
||||
assert.equal(associated1688.rows[0]?.source_variant_id, associated1688.rows[0]?.variant_id);
|
||||
const refreshed1688 = await request('/api/extension/materials', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${webAccessToken}`,
|
||||
'Idempotency-Key': randomUUID(),
|
||||
},
|
||||
body: JSON.stringify({
|
||||
...source1688,
|
||||
variants: [{
|
||||
...source1688.variants[0],
|
||||
sourcePrice: { amount: '32.525', currencyCode: 'CNY' },
|
||||
}],
|
||||
}),
|
||||
});
|
||||
assert.equal(refreshed1688.response.status, 200);
|
||||
const updatedCost = await database.workspaceTransaction(workspaceId, async (tx) =>
|
||||
tx.query<{ purchase_cost_cny: string }>(
|
||||
`SELECT purchase_cost_cny::text FROM atlas.product_variants WHERE product_id = $1`,
|
||||
[captured1688.body.data.product_id],
|
||||
),
|
||||
);
|
||||
assert.equal(updatedCost.rows[0]?.purchase_cost_cny, '32.5300');
|
||||
const platforms = await request('/api/platforms', {
|
||||
headers: { Authorization: `Bearer ${webAccessToken}` },
|
||||
});
|
||||
@@ -328,7 +391,7 @@ describe('扩展授权与采集 HTTP 集成测试', () => {
|
||||
variant.source_variant_key === '123456789',
|
||||
);
|
||||
assert.equal(originalVariant.display_name, '人工确认 SKU');
|
||||
assert.equal(originalVariant.purchase_cost_cny, '88.8000');
|
||||
assert.equal(originalVariant.purchase_cost_cny, '88.80');
|
||||
assert.equal(originalVariant.listing.offer_id, 'manual-offer-001');
|
||||
const discoveredVariant = editorAfterRefresh.body.data.variants.find(
|
||||
(variant: { source_variant_key: string }) =>
|
||||
|
||||
@@ -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 { GetObjectCommand, HeadObjectCommand } from '@aws-sdk/client-s3';
|
||||
import {
|
||||
createMediaAccessUrlRequestSchema,
|
||||
createMediaUploadIntentRequestSchema,
|
||||
importProductImageRequestSchema,
|
||||
} from '@atlas/contracts';
|
||||
@@ -55,6 +56,35 @@ function png(width: number, height: number) {
|
||||
}
|
||||
|
||||
describe('商品图片上传策略', () => {
|
||||
it('下载地址签名 attachment 响应头,预览地址保持内联显示', async () => {
|
||||
const assetId = '7c910684-7070-4f36-8a7b-b10bd91acd79';
|
||||
const query = vi.fn(async () => ({
|
||||
rows: [{ bucket: 'test', object_key: 'uploads/test.png', content_type: 'image/png' }],
|
||||
}));
|
||||
const workspaceTransaction = vi.fn(async (
|
||||
_workspaceId: string,
|
||||
callback: (tx: { query: typeof query }) => Promise<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 () => {
|
||||
const query = vi.fn(async (_sql: string, _params?: unknown[]) => ({
|
||||
rows: [],
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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' } },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -132,6 +132,10 @@ describe('商品编辑 M3.0–M3.3 HTTP 集成测试', () => {
|
||||
'DELETE FROM atlas.collection_asset_transfers WHERE workspace_id = $1',
|
||||
[workspaceId],
|
||||
);
|
||||
await tx.query(
|
||||
'DELETE FROM atlas.media_aspect_ratio_derivatives WHERE workspace_id = $1',
|
||||
[workspaceId],
|
||||
);
|
||||
await tx.query(
|
||||
'DELETE FROM atlas.product_image_imports WHERE workspace_id = $1',
|
||||
[workspaceId],
|
||||
@@ -1985,6 +1989,79 @@ describe('商品编辑 M3.0–M3.3 HTTP 集成测试', () => {
|
||||
assert.equal(privateImport.body.error.code, 'PRODUCT_IMAGE_IMPORT_INVALID');
|
||||
});
|
||||
|
||||
it('当前商品的 3:4 处理记录可重新加入 SKU 并保存', async () => {
|
||||
const before = await request(`/api/products/${first.productId}/editor`, {
|
||||
headers: headers({ 'X-Atlas-Platform-Id': platformId }),
|
||||
});
|
||||
const sourceAssetId = before.body.data.variants[0].media[0].asset_id as string;
|
||||
const outputAssetId = await database.workspaceTransaction(workspaceId, async (tx) => {
|
||||
const output = await tx.query<{ id: string }>(
|
||||
`INSERT INTO atlas.media_assets
|
||||
(workspace_id, storage_provider, bucket, object_key, content_type,
|
||||
byte_size, checksum_sha256, width_px, height_px)
|
||||
VALUES ($1, 'test', 'test', $2, 'image/jpeg', 1024, $3, 1200, 1600)
|
||||
RETURNING id`,
|
||||
[workspaceId, `processed/${randomUUID()}.jpg`, randomUUID().replaceAll('-', '').repeat(2)],
|
||||
);
|
||||
await tx.query(
|
||||
`INSERT INTO atlas.media_aspect_ratio_derivatives
|
||||
(workspace_id, product_id, source_asset_id, output_asset_id, status, completed_at)
|
||||
VALUES ($1, $2, $3, $4, 'succeeded', now())`,
|
||||
[workspaceId, first.productId, sourceAssetId, output.rows[0].id],
|
||||
);
|
||||
return output.rows[0].id;
|
||||
});
|
||||
const snapshot = before.body.data;
|
||||
const payload = {
|
||||
ai_source_material: snapshot.source_material,
|
||||
ai_source_overridden_fields: snapshot.source_material_state.overridden_fields,
|
||||
common: { ...snapshot.product.common,
|
||||
default_physical_spec: snapshot.product.default_physical_spec,
|
||||
default_package_spec: snapshot.product.default_package_spec },
|
||||
profile: {
|
||||
target_store_id: snapshot.profile.target_store.id,
|
||||
fulfillment: snapshot.profile.fulfillment,
|
||||
title: snapshot.profile.title,
|
||||
description: snapshot.profile.description,
|
||||
tags: snapshot.profile.tags,
|
||||
platform_product_category_id: snapshot.profile.category.id,
|
||||
category_source: snapshot.profile.category.source,
|
||||
},
|
||||
attributes: snapshot.attributes.map((attribute: any) => ({
|
||||
scope: attribute.scope, variant_id: attribute.variant_id,
|
||||
platform_attribute_id: attribute.platform_attribute_id,
|
||||
complex_id: attribute.complex_id, values: attribute.values,
|
||||
language_code: attribute.language_code, source: attribute.source,
|
||||
confirmation_status: attribute.confirmation_status,
|
||||
})),
|
||||
variants: snapshot.variants.map((item: any) => ({
|
||||
id: item.id, client_key: null, copy_from_variant_id: null,
|
||||
display_name: item.display_name, source_sku: item.source_sku,
|
||||
specification_values: item.specification_values,
|
||||
purchase_cost_cny: item.purchase_cost_cny,
|
||||
tags: item.tags, description: item.description,
|
||||
physical_spec: item.physical_spec, package_spec: item.package_spec,
|
||||
offer_id: item.listing.offer_id,
|
||||
media_asset_ids: item.id === snapshot.variants[0].id
|
||||
? [outputAssetId] : item.media.map((media: any) => media.asset_id),
|
||||
})),
|
||||
};
|
||||
const saved = await request(`/api/products/${first.productId}/editor-draft`, {
|
||||
method: 'PUT',
|
||||
headers: headers({
|
||||
'X-Atlas-Platform-Id': platformId,
|
||||
'Idempotency-Key': randomUUID(),
|
||||
'If-Match': `"${snapshot.product.revision}:${snapshot.profile.revision}"`,
|
||||
}),
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
assert.equal(saved.response.status, 200);
|
||||
const after = await request(`/api/products/${first.productId}/editor`, {
|
||||
headers: headers({ 'X-Atlas-Platform-Id': platformId }),
|
||||
});
|
||||
assert.equal(after.body.data.variants[0].media[0].asset_id, outputAssetId);
|
||||
});
|
||||
|
||||
it('独立保存采集描述会持久化覆盖值并保持幂等', async () => {
|
||||
const created = await createCollectionItem('独立保存描述测试商品');
|
||||
const entered = await request(
|
||||
|
||||
@@ -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']);
|
||||
});
|
||||
});
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
isOzonProductPage,
|
||||
type OzonPageJsonFetcher,
|
||||
} from './ozon-parser.js';
|
||||
import { collectPlatformDomAssets } from './product-media.js';
|
||||
import { collectPlatformDomAssets, normaliseImageUrl } from './product-media.js';
|
||||
import { formatCollectedDescription } from './description-format.js';
|
||||
|
||||
type SourcePlatform = '1688' | 'taobao' | 'tmall' | 'ozon';
|
||||
@@ -49,7 +49,10 @@ export async function collectCurrentProduct(
|
||||
|
||||
function collect1688(root: Document, pageUrl: string, bridgedState?: unknown): CollectedProductPayload {
|
||||
const state = [bridgedState, ...pageState(root)];
|
||||
const skuMap = findFirst(state, 'skuMapOriginal');
|
||||
const skuInfoMap = findFirst(state, 'skuInfoMap');
|
||||
const skuMap = isRecord(skuInfoMap) && Object.keys(skuInfoMap).length
|
||||
? skuInfoMap
|
||||
: findFirst(state, 'skuMapOriginal');
|
||||
const skuProps = findFirst(state, 'skuProps');
|
||||
const axes = parse1688Axes(skuProps);
|
||||
const evidence: CollectedProductPayload['evidence'] = [
|
||||
@@ -70,32 +73,29 @@ function collect1688(root: Document, pageUrl: string, bridgedState?: unknown): C
|
||||
evidence.push({
|
||||
ref: '1688:sku-map',
|
||||
kind: 'state',
|
||||
path: 'skuMapOriginal',
|
||||
path: isRecord(skuInfoMap) ? 'skuModel.skuInfoMap' : 'skuMapOriginal',
|
||||
raw_value: `组合记录 ${skuRows.length} 条`,
|
||||
});
|
||||
for (const [combination, row] of skuRows) {
|
||||
if (!isRecord(row)) continue;
|
||||
const sourceSku = stringAt(row, ['skuId', 'sku_id', 'skuCode']);
|
||||
// A skuMapOriginal entry itself is a source-provided sellable-combination
|
||||
// record. Never expand it with the Cartesian product of skuProps.
|
||||
const specAttrs = stringAt(row, ['specAttrs', 'propPath']) ?? combination;
|
||||
const selections = selectionsFor1688Combination(specAttrs, axes);
|
||||
// 来源记录本身证明可售组合,不能用规格轴的笛卡尔积扩充 SKU。
|
||||
const sourceVariantKey = sourceSku ?? `1688:${combination}`;
|
||||
if (!combination || combination === 'undefined') continue;
|
||||
variants.push({
|
||||
sourceVariantKey,
|
||||
...(sourceSku ? { sourceSku } : {}),
|
||||
displayName:
|
||||
selectionsForCombination(combination, axes)
|
||||
selections
|
||||
.map((item) => item.valueName)
|
||||
.join(' / ') || `1688 SKU ${sourceSku ?? combination}`,
|
||||
selections: selectionsForCombination(combination, axes),
|
||||
...(moneyAt(row, ['discountPrice', 'price', 'salePrice'])
|
||||
.join(' / ') || specAttrs,
|
||||
selections,
|
||||
...(moneyAt(row, ['price'])
|
||||
? {
|
||||
sourcePrice: {
|
||||
amount: moneyAt(row, [
|
||||
'discountPrice',
|
||||
'price',
|
||||
'salePrice',
|
||||
])!,
|
||||
amount: moneyAt(row, ['price'])!,
|
||||
currencyCode: 'CNY',
|
||||
},
|
||||
}
|
||||
@@ -105,7 +105,7 @@ function collect1688(root: Document, pageUrl: string, bridgedState?: unknown): C
|
||||
});
|
||||
}
|
||||
}
|
||||
return basePayload(
|
||||
const payload = basePayload(
|
||||
'1688',
|
||||
offerId(pageUrl),
|
||||
root,
|
||||
@@ -113,9 +113,29 @@ function collect1688(root: Document, pageUrl: string, bridgedState?: unknown): C
|
||||
axes,
|
||||
uniqueVariants(variants),
|
||||
evidence,
|
||||
'1688-structured-2.0.1',
|
||||
'1688-structured-2.1.0',
|
||||
state,
|
||||
);
|
||||
// SKU 图片由规格值和真实可售组合关联,独立于页面当时选中的 DOM 状态。
|
||||
for (const variant of payload.variants) {
|
||||
for (const selection of variant.selections) {
|
||||
const rawUrl = axes.find((axis) => axis.key === selection.axisKey)
|
||||
?.values.find((value) => value.key === selection.valueKey)?.image_url;
|
||||
const url = rawUrl && normaliseImageUrl(rawUrl, pageUrl, '1688');
|
||||
if (!url || payload.assets.length >= 500) continue;
|
||||
if (payload.assets.some((asset) => asset.variantSourceKey === variant.sourceVariantKey && asset.url === url)) continue;
|
||||
payload.assets.push({
|
||||
url,
|
||||
mediaType: 'image',
|
||||
role: 'source_sku',
|
||||
relation: 'axis_value',
|
||||
variantSourceKey: variant.sourceVariantKey,
|
||||
sortOrder: payload.assets.length,
|
||||
evidenceRef: '1688:sku-map',
|
||||
});
|
||||
}
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
function collectTaobao(
|
||||
@@ -411,13 +431,14 @@ function pageState(root: Document) {
|
||||
}
|
||||
}
|
||||
for (const marker of [
|
||||
'skuInfoMap',
|
||||
'skuMapOriginal',
|
||||
'skuBase',
|
||||
'skuCore',
|
||||
'skuProps',
|
||||
]) {
|
||||
const object = objectAfterMarker(text, marker);
|
||||
if (object) all.push(object);
|
||||
if (object) all.push({ [marker]: object });
|
||||
}
|
||||
}
|
||||
return all;
|
||||
@@ -589,6 +610,19 @@ function selectionsForCombination(combination: string, axes: Axis[]) {
|
||||
: [];
|
||||
});
|
||||
}
|
||||
function selectionsFor1688Combination(combination: string, axes: Axis[]) {
|
||||
const selected = selectionsForCombination(combination, axes);
|
||||
if (selected.length === axes.length) return selected;
|
||||
const parts = combination.split(/[;,&|]/).map((part) => part.trim()).filter(Boolean);
|
||||
return axes.flatMap((axis) => {
|
||||
const existing = selected.find((item) => item.axisKey === axis.key);
|
||||
if (existing) return [existing];
|
||||
const value = axis.values.find((item) =>
|
||||
parts.some((part) => part === item.name || part === `${axis.name}:${item.name}`)
|
||||
);
|
||||
return value ? [{ axisKey: axis.key, valueKey: value.key, valueName: value.name }] : [];
|
||||
});
|
||||
}
|
||||
function stringAt(value: Json, keys: string[]) {
|
||||
for (const key of keys)
|
||||
if (typeof value[key] === 'string' || typeof value[key] === 'number')
|
||||
|
||||
@@ -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
@@ -94,36 +94,12 @@
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
/* 3x3 水印位置九宫格 */
|
||||
.position-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 44px);
|
||||
grid-auto-rows: 44px;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.position-grid-cell {
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 6px;
|
||||
background: #f5f5f5;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.position-grid-cell:hover {
|
||||
border-color: var(--primary-light);
|
||||
}
|
||||
|
||||
.position-grid-cell.active {
|
||||
border-color: var(--primary-color);
|
||||
background: rgba(139, 92, 246, 0.12);
|
||||
}
|
||||
|
||||
/* 水印预览:模拟透明底图的棋盘格 */
|
||||
.watermark-preview {
|
||||
display: flex;
|
||||
padding: 12px;
|
||||
position: relative;
|
||||
max-width: 100%;
|
||||
padding: 5px;
|
||||
box-sizing: border-box;
|
||||
border: 1px dashed var(--border-color);
|
||||
border-radius: 4px;
|
||||
@@ -132,6 +108,49 @@
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.watermark-preview-mark {
|
||||
display: inline-flex;
|
||||
position: absolute;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
max-width: calc(100% - 10px);
|
||||
max-height: calc(100% - 10px);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.watermark-preview-mark-draggable {
|
||||
cursor: grab;
|
||||
touch-action: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.watermark-preview-mark-dragging {
|
||||
cursor: grabbing;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.watermark-preview-dialog-content {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.watermark-preview-dialog-footer {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.watermark-editor-pair {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
column-gap: 16px;
|
||||
}
|
||||
|
||||
@media (max-width: 575px) {
|
||||
.watermark-editor-pair {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
.watermark-form-actions {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { useState } from 'react';
|
||||
import type { CSSProperties } from 'react';
|
||||
import { useRef, useState } from 'react';
|
||||
import type { PointerEvent as ReactPointerEvent, KeyboardEvent as ReactKeyboardEvent } from 'react';
|
||||
import { Typography } from 'antd';
|
||||
import type { WatermarkPosition } from '@atlas/contracts';
|
||||
import type { WatermarkTemplateConfig } from '@atlas/contracts';
|
||||
|
||||
/** 九宫格位置选项,顺序与编辑器 3x3 网格一致 */
|
||||
/** 旧版九宫格位置与精确坐标的映射,供已有模板读取。 */
|
||||
export const WATERMARK_POSITION_OPTIONS: Array<{
|
||||
value: WatermarkPosition;
|
||||
label: string;
|
||||
@@ -25,71 +25,173 @@ export const WATERMARK_POSITION_OPTIONS: Array<{
|
||||
export const watermarkPositionLabel = (position: string): string =>
|
||||
WATERMARK_POSITION_OPTIONS.find((option) => option.value === position)?.label ?? position;
|
||||
|
||||
const POSITION_FLEX: Record<
|
||||
WatermarkPosition,
|
||||
Pick<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 {
|
||||
/** 允许传入部分字段:编辑弹窗按当前表单值实时渲染 */
|
||||
config: Partial<WatermarkTemplateConfig>;
|
||||
width?: number;
|
||||
height?: number;
|
||||
onPositionChange?: (position: { position_x: number; position_y: number }) => void;
|
||||
}
|
||||
|
||||
type DragState = {
|
||||
pointerId: number;
|
||||
offsetX: number;
|
||||
offsetY: number;
|
||||
markWidth: number;
|
||||
markHeight: number;
|
||||
};
|
||||
|
||||
const clamp = (value: number, min: number, max: number) =>
|
||||
Math.min(Math.max(value, min), Math.max(min, max));
|
||||
|
||||
const roundCoordinate = (value: number) => Math.round(clamp(value, 0, 1) * 10_000) / 10_000;
|
||||
const PREVIEW_INSET_PX = 5;
|
||||
// 开发服务直接加载前端源码,范围须与共享契约中的校验值保持一致。
|
||||
export const WATERMARK_IMAGE_SIZE_MIN_PX = 10;
|
||||
export const WATERMARK_IMAGE_SIZE_DEFAULT_PX = 80;
|
||||
export const WATERMARK_IMAGE_SIZE_MAX_PX = 200;
|
||||
export const WATERMARK_BORDER_RADIUS_MAX_PX = 100;
|
||||
|
||||
/**
|
||||
* 按模板 config 在内置演示底图上实时渲染水印效果(docs/api/watermarks-and-assets.md §3.1),
|
||||
* 不调用服务端渲染;图片使用响应中的短时 preview_url,过期时提示重新读取。
|
||||
*/
|
||||
const WatermarkPreview = ({ config, width = 320, height = 420 }: WatermarkPreviewProps) => {
|
||||
const [imageBroken, setImageBroken] = useState(false);
|
||||
const position = config.position ?? 'bottom_right';
|
||||
const flex = POSITION_FLEX[position] ?? POSITION_FLEX.bottom_right;
|
||||
const WatermarkPreview = ({ config, width = 240, height = 320, onPositionChange }: WatermarkPreviewProps) => {
|
||||
const [brokenImageUrl, setBrokenImageUrl] = useState<string | null>(null);
|
||||
const [imageDimensions, setImageDimensions] = useState<{ url: string; width: number; height: number } | null>(null);
|
||||
const [dragPoint, setDragPoint] = useState<{ left: number; top: number } | null>(null);
|
||||
const previewRef = useRef<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 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 (
|
||||
<div
|
||||
ref={previewRef}
|
||||
className="watermark-preview"
|
||||
style={{ width, height, ...flex, opacity: Number(config.opacity ?? '1') }}
|
||||
style={{ width, height: 'auto', aspectRatio: `${width} / ${height}` }}
|
||||
>
|
||||
{config.image_asset ? (
|
||||
imageBroken || !previewUrl ? (
|
||||
<Typography.Text type="secondary">图片预览已过期或不可用</Typography.Text>
|
||||
<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 ? (
|
||||
brokenImageUrl === previewUrl || !previewUrl ? (
|
||||
<Typography.Text type="secondary">图片预览已过期或不可用</Typography.Text>
|
||||
) : (
|
||||
<img
|
||||
src={previewUrl}
|
||||
alt="水印图片"
|
||||
draggable={false}
|
||||
style={{
|
||||
width: imageWidth,
|
||||
height: imageHeight,
|
||||
borderRadius: config.border_radius_px ?? 0,
|
||||
objectFit: 'contain',
|
||||
}}
|
||||
onLoad={(event) => setImageDimensions({
|
||||
url: previewUrl,
|
||||
width: event.currentTarget.naturalWidth,
|
||||
height: event.currentTarget.naturalHeight,
|
||||
})}
|
||||
onError={() => setBrokenImageUrl(previewUrl)}
|
||||
/>
|
||||
)
|
||||
) : (
|
||||
<img
|
||||
src={previewUrl}
|
||||
alt="水印图片"
|
||||
<span
|
||||
style={{
|
||||
// scale_ratio 以底图短边为基准的视觉近似:1.0 ≈ 底图宽度的 60%
|
||||
width: Math.round(Number(config.scale_ratio ?? '1') * width * 0.6),
|
||||
maxWidth: '80%',
|
||||
fontSize: config.font_size_px ?? 15,
|
||||
fontWeight: 600,
|
||||
color: '#111827',
|
||||
whiteSpace: 'nowrap',
|
||||
textShadow: '0 0 2px rgba(255,255,255,0.8)',
|
||||
}}
|
||||
onError={() => setImageBroken(true)}
|
||||
/>
|
||||
)
|
||||
) : (
|
||||
<span
|
||||
style={{
|
||||
fontSize: config.font_size_px ?? 24,
|
||||
fontWeight: 600,
|
||||
color: '#111827',
|
||||
whiteSpace: 'nowrap',
|
||||
textShadow: '0 0 2px rgba(255,255,255,0.8)',
|
||||
}}
|
||||
>
|
||||
{config.text_content || '文字水印'}
|
||||
</span>
|
||||
)}
|
||||
>
|
||||
{config.text_content || '文字水印'}
|
||||
</span>
|
||||
)}
|
||||
</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 { usePlatform } from '@/services/platform';
|
||||
import { productEditorApi } from '@/services/productEditor';
|
||||
import './CollectionPage.css';
|
||||
|
||||
const statusLabel: Record<string, string> = { received: '已接收', materializing: '素材处理中', ready_for_edit: '可编辑', needs_review: '待确认', failed: '采集失败' };
|
||||
export default function CollectionPage() {
|
||||
@@ -21,8 +22,10 @@ export default function CollectionPage() {
|
||||
{ title: '产品名称', dataIndex: 'title', render: (value) => value || '未命名商品' }, { title: '来源', dataIndex: 'source', width: 110, render: (value) => <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>}>
|
||||
<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 }} />
|
||||
<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>
|
||||
<div className="collection-toolbar">
|
||||
<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: '暂无采集商品,点击右上角手动创建商品' }} />
|
||||
<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>;
|
||||
|
||||
@@ -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 OnlineProductsPage = () => (
|
||||
<PagePlaceholder
|
||||
title="在线商品"
|
||||
description="按 SKU 查看平台已上架商品与同步状态,模块开发中。"
|
||||
/>
|
||||
);
|
||||
const initialFilters: OnlineProductFilters = { q: '', visibility: 'all' };
|
||||
const isActive = (run: OnlineProductSyncRun | null) =>
|
||||
run?.status === 'queued' || run?.status === 'running';
|
||||
|
||||
export default OnlineProductsPage;
|
||||
export default function OnlineProductsPage() {
|
||||
const { message } = AntdApp.useApp();
|
||||
const navigate = useNavigate();
|
||||
const {
|
||||
currentPlatform,
|
||||
context,
|
||||
contextLoading,
|
||||
error: platformError,
|
||||
reload: reloadPlatforms,
|
||||
} = usePlatform();
|
||||
const platformId = currentPlatform?.id ?? null;
|
||||
const [stores, setStores] = useState<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]
|
||||
);
|
||||
|
||||
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;
|
||||
z-index: 50;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
@@ -237,6 +238,7 @@
|
||||
}
|
||||
.footer-ai-actions {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/*
|
||||
@@ -297,6 +299,9 @@
|
||||
}
|
||||
}
|
||||
@media (max-width: 560px) {
|
||||
.product-editor {
|
||||
padding-bottom: 230px;
|
||||
}
|
||||
.editor-section .ant-card-body {
|
||||
padding: 20px 16px;
|
||||
}
|
||||
|
||||
@@ -35,6 +35,7 @@ import { ProductAttributeSection } from './components/ProductAttributeSection';
|
||||
import { ProductMainInfoSection } from './components/ProductMainInfoSection';
|
||||
import { ProductOtherSection } from './components/ProductOtherSection';
|
||||
import { ProductVariantSection } from './components/ProductVariantSection';
|
||||
import { ProductImageTaskCenter } from './components/ProductImageTaskCenter';
|
||||
import {
|
||||
ProductCopyComparisonModal,
|
||||
SkuCopyAiModal,
|
||||
@@ -1061,8 +1062,7 @@ const ProductEditPage = () => {
|
||||
const showFutureFeature = (name: string) =>
|
||||
modal.info({
|
||||
title: `${name}将在下一阶段接入`,
|
||||
content:
|
||||
'当前已支持 SKU 图片上传、网络导入和采集图转存。AI 生图与图片编辑将在后续接入。',
|
||||
content: '单张 AI 编辑已可在 SKU 图片选择中使用。AI 套图生成仍在开发。',
|
||||
okText: '知道了',
|
||||
});
|
||||
const validSupplyUrl =
|
||||
@@ -1478,10 +1478,11 @@ const ProductEditPage = () => {
|
||||
</Button>
|
||||
<Button
|
||||
icon={<FileTextOutlined />}
|
||||
onClick={() => showFutureFeature('AI 生图')}
|
||||
onClick={() => showFutureFeature('AI 套图生成')}
|
||||
>
|
||||
AI 生图
|
||||
AI 套图生成
|
||||
</Button>
|
||||
<ProductImageTaskCenter productId={snapshot.product.id} />
|
||||
</Space>
|
||||
<Space className="footer-primary-actions">
|
||||
<Button onClick={leave}>取消并关闭</Button>
|
||||
|
||||
@@ -186,6 +186,10 @@
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 6px;
|
||||
}
|
||||
.collected-image-card {
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
}
|
||||
.collected-image-grid .ant-image {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
@@ -197,7 +201,18 @@
|
||||
display: block;
|
||||
width: 100%;
|
||||
aspect-ratio: 3 / 4;
|
||||
object-fit: cover;
|
||||
object-fit: contain;
|
||||
}
|
||||
.collected-image-edit.ant-btn {
|
||||
position: absolute;
|
||||
right: 5px;
|
||||
bottom: 5px;
|
||||
z-index: 2;
|
||||
height: 25px;
|
||||
padding: 0 6px;
|
||||
background: rgba(255, 255, 255, 0.94);
|
||||
font-size: 11px;
|
||||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
@container (max-width: 1099px) {
|
||||
/* 主内容区不足 1360px 时,面板退化为工作区下方整行,不再固定高度。 */
|
||||
|
||||
@@ -1,22 +1,35 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import {
|
||||
Alert,
|
||||
App as AntdApp,
|
||||
Breadcrumb,
|
||||
Button,
|
||||
Card,
|
||||
Collapse,
|
||||
Dropdown,
|
||||
Image,
|
||||
Input,
|
||||
Space,
|
||||
Tag,
|
||||
Typography,
|
||||
} from 'antd';
|
||||
import type { MenuProps } from 'antd';
|
||||
import { DownOutlined, LoadingOutlined } from '@ant-design/icons';
|
||||
import type {
|
||||
CollectionItemInspection,
|
||||
ProductAiSourceField,
|
||||
ProductAiSourceMaterial,
|
||||
ProductEditorSnapshot,
|
||||
ProductImageDerivativeRecord,
|
||||
WatermarkTemplate,
|
||||
} from '@atlas/contracts';
|
||||
import { apiErrorMessage } from '@/services/api';
|
||||
import { createThreeFourImage, createWatermarkedImage, downloadMediaAsset, mediaApi } from '@/services/media';
|
||||
import { watermarksApi } from '@/services/watermarks';
|
||||
import { resolveSkuImageSelection } from './skuImageSelection';
|
||||
import type { SkuEditorImage } from './SkuImageCard';
|
||||
import { SkuSingleAiEditDrawer } from './SkuSingleAiEditDrawer';
|
||||
import { SkuWatermarkPickerModal } from './SkuWatermarkPickerModal';
|
||||
import './CollectedProductInfoPanel.css';
|
||||
|
||||
export type CollectedProductSummary = {
|
||||
@@ -48,7 +61,7 @@ type Props = {
|
||||
/**
|
||||
* 右侧「采集的商品信息」面板:展示并可编辑采集来源的标题与描述,
|
||||
* 以及价格、尺寸、重量摘要、原始规格参数,和按 主图 / SKU 图 / 详情图
|
||||
* 分组折叠展示的采集图片(只读,点击可放大预览)。
|
||||
* 分组折叠展示的采集图片及其商品级处理结果。
|
||||
*
|
||||
* 用户改过的字段记入 overriddenFields(显示"恢复来源值"入口);
|
||||
* 编辑、恢复、采用最新等动作均通过回调交由页面处理。
|
||||
@@ -66,6 +79,28 @@ export function CollectedProductInfoPanel({
|
||||
onShowDifferences,
|
||||
onAcceptLatest,
|
||||
}: Props) {
|
||||
const { message } = AntdApp.useApp();
|
||||
const [processedRecords, setProcessedRecords] = useState<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 ?? '';
|
||||
// 类目自动回填始终基于最新采集快照,不能被 AI 输入素材的用户覆盖影响。
|
||||
const capturedCategories = snapshot.source_material_state.latest.categories;
|
||||
@@ -92,6 +127,111 @@ export function CollectedProductInfoPanel({
|
||||
return { key, label, items };
|
||||
})
|
||||
.filter((group) => group.items.length > 0);
|
||||
|
||||
/** 派生图保留原素材;按处理链的最新成功结果恢复采集区的显示图。 */
|
||||
const roots = new Map<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 (
|
||||
<aside className="editor-aside">
|
||||
<Card className="collected-info-panel" bordered={false}>
|
||||
@@ -239,6 +379,9 @@ export function CollectedProductInfoPanel({
|
||||
</div>
|
||||
<div className="collected-field">
|
||||
<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 ? (
|
||||
<Collapse
|
||||
className="collected-image-groups"
|
||||
@@ -250,13 +393,19 @@ export function CollectedProductInfoPanel({
|
||||
label: `${label}(${items.length})`,
|
||||
children: (
|
||||
<div className="collected-image-grid">
|
||||
{items.map((item) => (
|
||||
<Image
|
||||
key={item.id}
|
||||
src={item.source_url}
|
||||
alt={label}
|
||||
/>
|
||||
))}
|
||||
{items.map((item) => {
|
||||
const image = imageFor(item, label);
|
||||
return <div className="collected-image-card" key={item.id}>
|
||||
<Image src={image.url} 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>
|
||||
),
|
||||
}))}
|
||||
@@ -266,6 +415,23 @@ export function CollectedProductInfoPanel({
|
||||
)}
|
||||
</div>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
.sku-main-thumbnail {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex: 0 0 auto;
|
||||
align-self: flex-start;
|
||||
@@ -137,13 +138,19 @@
|
||||
font-size: 10px;
|
||||
text-align: center;
|
||||
}
|
||||
.sku-main-thumbnail .ant-image,
|
||||
.sku-main-thumbnail img {
|
||||
.sku-main-thumbnail .ant-image {
|
||||
position: absolute;
|
||||
inset: 0 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.sku-main-thumbnail .ant-image img {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
.sku-main-thumbnail img {
|
||||
object-fit: cover;
|
||||
object-fit: contain;
|
||||
object-position: center;
|
||||
}
|
||||
.sku-card-value {
|
||||
flex: 0 0 auto;
|
||||
@@ -222,7 +229,7 @@
|
||||
.sku-media-item {
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
/* 每张图片框固定为 3:4;实际图片使用 contain,完整居中展示。 */
|
||||
/* 图片框固定为 3:4;图片容器铺满框后用 contain 水平、垂直居中。 */
|
||||
aspect-ratio: 3 / 4;
|
||||
align-self: start;
|
||||
overflow: hidden;
|
||||
@@ -246,12 +253,16 @@
|
||||
font-size: 11px;
|
||||
line-height: 18px;
|
||||
}
|
||||
.sku-media-item .ant-image,
|
||||
.sku-media-item img {
|
||||
.sku-media-item .ant-image {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.sku-media-item .ant-image img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
.sku-media-item img {
|
||||
display: block;
|
||||
object-fit: contain;
|
||||
object-position: center;
|
||||
|
||||
@@ -31,6 +31,9 @@ import type {
|
||||
import { SectionTitle } from './SectionTitle';
|
||||
import { DynamicAttributeFields } from './DynamicAttributeFields';
|
||||
import { SkuImageEditorModal, type SkuEditorImage } from './SkuImageEditorModal';
|
||||
import { createThreeFourImage, downloadMediaAsset } from '@/services/media';
|
||||
import { createWatermarkedImage } from '@/services/media';
|
||||
import type { WatermarkTemplate } from '@atlas/contracts';
|
||||
import { resolveSkuImageSelection } from './skuImageSelection';
|
||||
import { mediaApi, uploadProductImage } from '@/services/media';
|
||||
import './ProductVariantSection.css';
|
||||
@@ -293,6 +296,21 @@ export function ProductVariantSection({
|
||||
})
|
||||
.filter((image): image is SkuEditorImage => image !== null);
|
||||
const selectedImages = localImagesByVariant[variantKey] ?? savedImages;
|
||||
const sourceMainCandidate = !selectedAssetIds.length && !selectedImages.length && original
|
||||
? imageCandidates.find((item) =>
|
||||
item.source_variant_id === original.id &&
|
||||
item.role === 'source_sku'
|
||||
)
|
||||
: null;
|
||||
const displayImages: SkuEditorImage[] = sourceMainCandidate
|
||||
? [{
|
||||
id: `collection:${sourceMainCandidate.id}`,
|
||||
url: sourceMainCandidate.source_url,
|
||||
label: '来源 SKU 图片',
|
||||
origin: 'collection',
|
||||
transferId: sourceMainCandidate.id,
|
||||
}]
|
||||
: selectedImages;
|
||||
const copyVariant = () =>
|
||||
add({
|
||||
...variantValue,
|
||||
@@ -334,10 +352,10 @@ export function ProductVariantSection({
|
||||
</Form.Item>
|
||||
<div className="sku-card-main">
|
||||
<div className="sku-main-thumbnail">
|
||||
{selectedImages[0] ? (
|
||||
{displayImages[0] ? (
|
||||
<Image
|
||||
preview={false}
|
||||
src={selectedImages[0].url}
|
||||
src={displayImages[0].url}
|
||||
alt="SKU 主图"
|
||||
/>
|
||||
) : (
|
||||
@@ -412,14 +430,15 @@ export function ProductVariantSection({
|
||||
noStyle
|
||||
rules={[
|
||||
{
|
||||
pattern: /^\d+(\.\d{1,4})?$/,
|
||||
message: '请输入金额',
|
||||
pattern: /^\d+(\.\d{1,2})?$/,
|
||||
message: '请输入最多两位小数的金额',
|
||||
},
|
||||
]}
|
||||
>
|
||||
<InputNumber<string>
|
||||
min="0"
|
||||
step="0.0001"
|
||||
step="0.01"
|
||||
precision={2}
|
||||
stringMode
|
||||
placeholder="—"
|
||||
/>
|
||||
@@ -467,32 +486,37 @@ export function ProductVariantSection({
|
||||
<Typography.Title level={5}>
|
||||
SKU 图片
|
||||
</Typography.Title>
|
||||
{sourceMainCandidate && (
|
||||
<Typography.Text className="sku-media-local-note">
|
||||
来源 SKU 图片预览;选择后转存为正式主图
|
||||
</Typography.Text>
|
||||
)}
|
||||
{Object.hasOwn(localImagesByVariant, variantKey) && (
|
||||
<Typography.Text className="sku-media-local-note">
|
||||
当前图片仅在本页预览,刷新后恢复
|
||||
</Typography.Text>
|
||||
)}
|
||||
<div className="sku-media-grid">
|
||||
{selectedImages.map((image, imageIndex) => (
|
||||
{displayImages.map((image, imageIndex) => (
|
||||
<div
|
||||
className={`sku-media-item ${imageIndex === 0 ? 'sku-media-item--main' : ''}`}
|
||||
key={image.id}
|
||||
>
|
||||
{imageIndex === 0 && (
|
||||
<span className="sku-media-main-badge">
|
||||
主图
|
||||
{sourceMainCandidate ? '来源主图' : '主图'}
|
||||
</span>
|
||||
)}
|
||||
<Image src={image.url} alt="已选 SKU 图片" />
|
||||
</div>
|
||||
))}
|
||||
<Button
|
||||
className={`sku-media-picker ${selectedImages.length ? '' : 'sku-media-picker--empty'}`}
|
||||
className={`sku-media-picker ${displayImages.length ? '' : 'sku-media-picker--empty'}`}
|
||||
type="dashed"
|
||||
onClick={() => setImageEditorTarget({
|
||||
key: variantKey,
|
||||
name: variantValue?.display_name || `SKU ${index + 1}`,
|
||||
images: selectedImages,
|
||||
images: displayImages,
|
||||
candidates: imageCandidates,
|
||||
})}
|
||||
>
|
||||
@@ -757,6 +781,7 @@ export function ProductVariantSection({
|
||||
</Card>
|
||||
<SkuImageEditorModal
|
||||
open={!!imageEditorTarget}
|
||||
productId={snapshot.product.id}
|
||||
skuKey={imageEditorTarget?.key ?? ''}
|
||||
skuName={imageEditorTarget?.name ?? ''}
|
||||
initialImages={imageEditorTarget?.images ?? []}
|
||||
@@ -791,6 +816,46 @@ export function ProductVariantSection({
|
||||
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;
|
||||
url: string;
|
||||
label: string;
|
||||
origin: 'saved' | 'collection' | 'upload' | 'network';
|
||||
origin: 'saved' | 'collection' | 'upload' | 'network' | 'derivative';
|
||||
assetId?: string;
|
||||
transferId?: string;
|
||||
sourceUrl?: string;
|
||||
|
||||
@@ -46,20 +46,11 @@
|
||||
padding: 14px 24px;
|
||||
border-top: 1px solid #ebe9f1;
|
||||
}
|
||||
.sku-image-editor-note {
|
||||
flex: 0 0 auto;
|
||||
margin: 16px 0 12px;
|
||||
padding: 9px 13px;
|
||||
border: 1px solid #e9e2ff;
|
||||
border-radius: 8px;
|
||||
background: #f8f5ff;
|
||||
color: #695d86;
|
||||
font-size: 12px;
|
||||
}
|
||||
.sku-image-editor-columns {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||||
gap: 24px;
|
||||
margin-top: 16px;
|
||||
min-height: 0;
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
@@ -140,6 +131,26 @@
|
||||
gap: 12px;
|
||||
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-source-card {
|
||||
position: relative;
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { App as AntdApp, Button, Dropdown, Empty, Image, Modal } from 'antd';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { App as AntdApp, Button, Dropdown, Empty, Image, Modal, Spin } from 'antd';
|
||||
import type { MenuProps } from 'antd';
|
||||
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 { mediaApi } from '@/services/media';
|
||||
import { watermarksApi } from '@/services/watermarks';
|
||||
import { SkuImageCard, type SkuEditorImage } from './SkuImageCard';
|
||||
import { SkuImageSourceTabs } from './SkuImageSourceTabs';
|
||||
import { SkuWatermarkPickerModal } from './SkuWatermarkPickerModal';
|
||||
import { SkuSingleAiEditDrawer } from './SkuSingleAiEditDrawer';
|
||||
import { uploadFileError, useSkuImageLibrary } from './useSkuImageLibrary';
|
||||
import './SkuImageEditorModal.css';
|
||||
|
||||
@@ -15,6 +19,7 @@ export type { SkuEditorImage } from './SkuImageCard';
|
||||
|
||||
type Props = {
|
||||
open: boolean;
|
||||
productId: string;
|
||||
skuKey: string;
|
||||
skuName: string;
|
||||
initialImages: SkuEditorImage[];
|
||||
@@ -22,21 +27,14 @@ type Props = {
|
||||
onCancel: () => void;
|
||||
onConfirm: (images: SkuEditorImage[]) => Promise<void>;
|
||||
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;
|
||||
|
||||
function downloadImage(image: SkuEditorImage) {
|
||||
const link = document.createElement('a');
|
||||
link.href = image.url;
|
||||
link.download = image.label || 'sku-image';
|
||||
link.target = '_blank';
|
||||
link.rel = 'noopener noreferrer';
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
}
|
||||
|
||||
function sameImage(left: SkuEditorImage, right: SkuEditorImage) {
|
||||
return left.id === right.id || !!(left.assetId && left.assetId === right.assetId);
|
||||
}
|
||||
@@ -44,6 +42,7 @@ function sameImage(left: SkuEditorImage, right: SkuEditorImage) {
|
||||
/** 图片选择弹窗管理交互状态;素材上传与草稿写入由外层服务完成。 */
|
||||
export function SkuImageEditorModal({
|
||||
open,
|
||||
productId,
|
||||
skuKey,
|
||||
skuName,
|
||||
initialImages,
|
||||
@@ -51,12 +50,45 @@ export function SkuImageEditorModal({
|
||||
onCancel,
|
||||
onConfirm,
|
||||
onUploadFile,
|
||||
onMakeThreeFour,
|
||||
onApplyWatermark,
|
||||
onResolveAsset,
|
||||
onDownloadImage,
|
||||
}: Props) {
|
||||
const { message } = AntdApp.useApp();
|
||||
const [images, setImages] = useState<SkuEditorImage[]>(initialImages);
|
||||
const [markedIds, setMarkedIds] = useState<string[]>([]);
|
||||
const [activeTab, setActiveTab] = useState('collection');
|
||||
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 {
|
||||
networkUrls,
|
||||
setNetworkUrls,
|
||||
@@ -75,12 +107,68 @@ export function SkuImageEditorModal({
|
||||
const dragIndex = useRef<number | null>(null);
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const session = ++editorSession.current;
|
||||
watermarkActionLock.current = null;
|
||||
defaultWatermarkRequest.current = watermarksApi.getDefault().catch(() => null);
|
||||
setImages(initialImages);
|
||||
setMarkedIds([]);
|
||||
setActiveTab('collection');
|
||||
setPreview(null);
|
||||
setWatermarkTargets([]);
|
||||
setWatermarkUndo({});
|
||||
setAiTarget(null);
|
||||
return () => {
|
||||
if (editorSession.current === session) {
|
||||
editorSession.current += 1;
|
||||
defaultWatermarkRequest.current = null;
|
||||
}
|
||||
};
|
||||
}, [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) => {
|
||||
if (processingLock.current) return;
|
||||
const existing = images.find((item) => sameImage(item, image));
|
||||
if (existing) {
|
||||
setImages((current) => current.filter((item) => item.id !== existing.id));
|
||||
@@ -94,10 +182,12 @@ export function SkuImageEditorModal({
|
||||
setImages((current) => [...current, image]);
|
||||
};
|
||||
const removeImages = (ids: string[]) => {
|
||||
if (processingLock.current) return;
|
||||
setImages((current) => current.filter((image) => !ids.includes(image.id)));
|
||||
setMarkedIds((current) => current.filter((id) => !ids.includes(id)));
|
||||
};
|
||||
const setCover = (image: SkuEditorImage) => {
|
||||
if (processingLock.current) return;
|
||||
if (!images.some((item) => sameImage(item, image)) && images.length >= MAX_IMAGES) {
|
||||
message.warning(`每个 SKU 最多选择 ${MAX_IMAGES} 张图片`);
|
||||
return;
|
||||
@@ -107,26 +197,195 @@ export function SkuImageEditorModal({
|
||||
return [selected, ...current.filter((item) => !sameImage(item, image))];
|
||||
});
|
||||
};
|
||||
const markImage = (id: string) =>
|
||||
const markImage = (id: string) => {
|
||||
if (processingLock.current) return;
|
||||
setMarkedIds((current) =>
|
||||
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 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 }) => {
|
||||
if (processingLock.current) return;
|
||||
if (!markedIds.length) {
|
||||
message.info('请先点击左侧图片进行选择');
|
||||
return;
|
||||
}
|
||||
if (key === 'delete') removeImages(markedIds);
|
||||
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));
|
||||
};
|
||||
const batchMenu: MenuProps = {
|
||||
onClick: onBatchAction,
|
||||
items: [
|
||||
{ key: '添加水印', label: '添加水印' },
|
||||
{ key: '一键 3:4', label: '一键 3:4' },
|
||||
{ key: 'watermark', label: '添加水印' },
|
||||
{ key: 'undo_watermark', label: '撤销水印', disabled: !markedIds.some((id) => id in watermarkUndo) },
|
||||
{ key: 'three_four', label: '一键 3:4' },
|
||||
{ key: '批量翻译', label: '批量翻译' },
|
||||
{ key: 'download', label: '下载' },
|
||||
{ type: 'divider' },
|
||||
@@ -136,20 +395,26 @@ export function SkuImageEditorModal({
|
||||
const imageMenu = (image: SkuEditorImage): MenuProps => ({
|
||||
onClick: ({ key, domEvent }) => {
|
||||
domEvent.stopPropagation();
|
||||
if (processingLock.current) return;
|
||||
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));
|
||||
},
|
||||
items: [
|
||||
{ key: 'AI 编辑', label: 'AI 编辑' },
|
||||
{ key: '添加水印', label: '添加水印' },
|
||||
{ key: '一键 3:4', label: '一键 3:4' },
|
||||
{ key: 'watermark', label: '添加水印' },
|
||||
...(image.id in watermarkUndo ? [{ key: 'undo_watermark', label: '撤销水印' }] : []),
|
||||
{ key: 'three_four', label: '一键 3:4' },
|
||||
{ key: 'cover', label: '设为封图' },
|
||||
{ key: 'download', label: '下载' },
|
||||
],
|
||||
});
|
||||
const uploadFiles = async (files: FileList | null) => {
|
||||
if (!files) return;
|
||||
if (!files || processingLock.current) return;
|
||||
const available = MAX_IMAGES - images.length;
|
||||
const accepted = Array.from(files).filter((file) => {
|
||||
const error = uploadFileError(file);
|
||||
@@ -210,6 +475,39 @@ export function SkuImageEditorModal({
|
||||
) : (
|
||||
<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 () => {
|
||||
setConfirming(true);
|
||||
@@ -235,19 +533,19 @@ export function SkuImageEditorModal({
|
||||
}
|
||||
width="min(1480px, 96vw)"
|
||||
onCancel={() => {
|
||||
if (!confirming) onCancel();
|
||||
if (!confirming && !processingThreeFour && !processingWatermark) onCancel();
|
||||
}}
|
||||
closable={!confirming}
|
||||
maskClosable={!confirming}
|
||||
closable={!confirming && !processingThreeFour && !processingWatermark}
|
||||
maskClosable={!confirming && !processingThreeFour && !processingWatermark}
|
||||
footer={[
|
||||
<Button key="cancel" onClick={onCancel} disabled={confirming}>
|
||||
<Button key="cancel" onClick={onCancel} disabled={confirming || processingThreeFour || processingWatermark}>
|
||||
取消
|
||||
</Button>,
|
||||
<Button
|
||||
key="confirm"
|
||||
type="primary"
|
||||
loading={confirming}
|
||||
disabled={uploading}
|
||||
disabled={uploading || processingThreeFour || processingWatermark}
|
||||
onClick={() => void confirmSelection()}
|
||||
>
|
||||
确认
|
||||
@@ -255,9 +553,6 @@ export function SkuImageEditorModal({
|
||||
]}
|
||||
centered
|
||||
>
|
||||
<div className="sku-image-editor-note">
|
||||
第一张为主图。确认后将选图写入当前编辑草稿,点击页面“保存”后持久化。
|
||||
</div>
|
||||
<div className="sku-image-editor-columns">
|
||||
<section className="sku-image-editor-left" aria-label="已选 SKU 图片">
|
||||
<div className="sku-image-editor-toolbar">
|
||||
@@ -273,6 +568,7 @@ export function SkuImageEditorModal({
|
||||
type="primary"
|
||||
icon={<UploadOutlined />}
|
||||
loading={uploading}
|
||||
disabled={processingThreeFour || processingWatermark}
|
||||
onClick={() => fileInput.current?.click()}
|
||||
>
|
||||
上传图片
|
||||
@@ -285,18 +581,18 @@ export function SkuImageEditorModal({
|
||||
) : undefined
|
||||
}
|
||||
onClick={() =>
|
||||
setMarkedIds(
|
||||
!processingLock.current && setMarkedIds(
|
||||
markedIds.length === images.length
|
||||
? []
|
||||
: images.map((image) => image.id)
|
||||
)
|
||||
}
|
||||
disabled={!images.length}
|
||||
disabled={!images.length || processingThreeFour || processingWatermark}
|
||||
>
|
||||
全选
|
||||
</Button>
|
||||
<Dropdown menu={batchMenu} trigger={['click']}>
|
||||
<Button>
|
||||
<Button loading={processingThreeFour || processingWatermark || downloading} disabled={processingThreeFour || processingWatermark || downloading}>
|
||||
批量操作 <DownOutlined />
|
||||
</Button>
|
||||
</Dropdown>
|
||||
@@ -305,7 +601,7 @@ export function SkuImageEditorModal({
|
||||
<strong>
|
||||
已选图片({images.length} / {MAX_IMAGES})
|
||||
</strong>
|
||||
<span>第一张为主图,拖动可调整顺序</span>
|
||||
<span>拖动可调整顺序</span>
|
||||
</div>
|
||||
<div className="sku-image-editor-scroll">
|
||||
{images.length ? (
|
||||
@@ -322,11 +618,13 @@ export function SkuImageEditorModal({
|
||||
onPreview={() => showPreview(images, index)}
|
||||
onRemove={() => removeImages([image.id])}
|
||||
onDragStart={() => {
|
||||
if (processingLock.current) return;
|
||||
dragIndex.current = index;
|
||||
}}
|
||||
onDragOver={(event) => event.preventDefault()}
|
||||
onDrop={(event) => {
|
||||
event.preventDefault();
|
||||
if (processingLock.current) return;
|
||||
const from = dragIndex.current;
|
||||
if (from === null || from === index) return;
|
||||
setImages((current) => {
|
||||
@@ -351,9 +649,16 @@ export function SkuImageEditorModal({
|
||||
<section className="sku-image-editor-right" aria-label="可选图片">
|
||||
<SkuImageSourceTabs
|
||||
activeTab={activeTab}
|
||||
onTabChange={setActiveTab}
|
||||
onTabChange={(tab) => {
|
||||
setActiveTab(tab);
|
||||
if (tab === 'processed') {
|
||||
void refreshProcessedImages().catch(() => setProcessedError(true));
|
||||
}
|
||||
}}
|
||||
sourceImages={sourceImages}
|
||||
renderImages={renderSourceImages}
|
||||
aiImages={aiImages}
|
||||
renderProcessedImages={renderProcessedImages}
|
||||
networkImages={networkImages}
|
||||
networkUrls={networkUrls}
|
||||
onNetworkUrlsChange={setNetworkUrls}
|
||||
@@ -366,6 +671,51 @@ export function SkuImageEditorModal({
|
||||
</section>
|
||||
</div>
|
||||
</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
|
||||
items={preview?.images.map((image) => image.url) ?? []}
|
||||
preview={{
|
||||
|
||||
@@ -16,6 +16,8 @@ type Props = {
|
||||
onTabChange: (tab: string) => void;
|
||||
sourceImages: (role: string) => SkuEditorImage[];
|
||||
renderImages: (images: SkuEditorImage[]) => ReactNode;
|
||||
aiImages: SkuEditorImage[];
|
||||
renderProcessedImages: () => ReactNode;
|
||||
networkImages: SkuEditorImage[];
|
||||
networkUrls: string;
|
||||
onNetworkUrlsChange: (value: string) => void;
|
||||
@@ -32,6 +34,8 @@ export function SkuImageSourceTabs({
|
||||
onTabChange,
|
||||
sourceImages,
|
||||
renderImages,
|
||||
aiImages,
|
||||
renderProcessedImages,
|
||||
networkImages,
|
||||
networkUrls,
|
||||
onNetworkUrlsChange,
|
||||
@@ -50,12 +54,17 @@ export function SkuImageSourceTabs({
|
||||
key: 'ai',
|
||||
label: 'AI 生图记录',
|
||||
children: (
|
||||
<Empty
|
||||
aiImages.length ? renderImages(aiImages) : <Empty
|
||||
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||
description="暂无 AI 生图记录"
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'processed',
|
||||
label: '图片处理记录',
|
||||
children: renderProcessedImages(),
|
||||
},
|
||||
{
|
||||
key: 'collection',
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -33,8 +33,11 @@ import type {
|
||||
WatermarkTemplateConfig,
|
||||
} from '@atlas/contracts';
|
||||
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,
|
||||
watermarkPositionLabel,
|
||||
} from '@/components/WatermarkPreview';
|
||||
import { apiErrorDetails, apiErrorMessage } from '@/services/api';
|
||||
import { watermarksApi } from '@/services/watermarks';
|
||||
@@ -45,9 +48,11 @@ import {
|
||||
WATERMARK_IMAGE_MAX_BYTES,
|
||||
} from '@/services/media';
|
||||
|
||||
type WatermarkPositionValue = (typeof WATERMARK_POSITION_OPTIONS)[number]['value'];
|
||||
type WatermarkKind = 'text' | 'image';
|
||||
|
||||
const DEFAULT_FONT_SIZE_PX = 15;
|
||||
const DEFAULT_OPACITY_PERCENT = 26;
|
||||
|
||||
interface WatermarkFormValues {
|
||||
name: string;
|
||||
kind: WatermarkKind;
|
||||
@@ -55,8 +60,10 @@ interface WatermarkFormValues {
|
||||
font_code: string;
|
||||
font_size_px: number;
|
||||
opacityPercent: number;
|
||||
scalePercent: number;
|
||||
position: WatermarkPositionValue;
|
||||
image_size_px: number;
|
||||
border_radius_px: number;
|
||||
position_x: number;
|
||||
position_y: number;
|
||||
}
|
||||
|
||||
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)));
|
||||
};
|
||||
|
||||
const percentToScale = (percent: number): string => {
|
||||
const value = percent / 100;
|
||||
return value === 2 ? '2' : String(Number(value.toFixed(4)));
|
||||
};
|
||||
|
||||
const formatDateTime = (value: string): string =>
|
||||
new Date(value).toLocaleString('zh-CN', { hour12: false });
|
||||
|
||||
@@ -91,6 +93,7 @@ const WatermarksPage = () => {
|
||||
const [nextCursor, setNextCursor] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [loadingMore, setLoadingMore] = useState(false);
|
||||
const [settingDefaultId, setSettingDefaultId] = useState<string | null>(null);
|
||||
|
||||
const [fonts, setFonts] = useState<Array<{ code: string; display_name: string }>>([]);
|
||||
|
||||
@@ -214,10 +217,12 @@ const WatermarksPage = () => {
|
||||
kind: 'text',
|
||||
text_content: '',
|
||||
font_code: fonts[0]?.code ?? '',
|
||||
font_size_px: 24,
|
||||
opacityPercent: 80,
|
||||
scalePercent: 25,
|
||||
position: 'bottom_right',
|
||||
font_size_px: DEFAULT_FONT_SIZE_PX,
|
||||
opacityPercent: DEFAULT_OPACITY_PERCENT,
|
||||
image_size_px: WATERMARK_IMAGE_SIZE_DEFAULT_PX,
|
||||
border_radius_px: 0,
|
||||
position_x: 1,
|
||||
position_y: 1,
|
||||
};
|
||||
form.setFieldsValue(initial);
|
||||
setPreviewValues(initial);
|
||||
@@ -227,6 +232,7 @@ const WatermarksPage = () => {
|
||||
const openEdit = (template: WatermarkTemplate) => {
|
||||
setEditing(template);
|
||||
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;
|
||||
setImageAsset(
|
||||
template.kind === 'image' && assetId
|
||||
@@ -238,10 +244,12 @@ const WatermarksPage = () => {
|
||||
kind: template.kind,
|
||||
text_content: config.text_content ?? '',
|
||||
font_code: config.font_code ?? fonts[0]?.code ?? '',
|
||||
font_size_px: config.font_size_px ?? 24,
|
||||
opacityPercent: Math.round(Number(config.opacity ?? '0.8') * 100),
|
||||
scalePercent: Math.round(Number(config.scale_ratio ?? '0.25') * 100),
|
||||
position: config.position,
|
||||
font_size_px: config.font_size_px ?? DEFAULT_FONT_SIZE_PX,
|
||||
opacityPercent: Math.round(Number(config.opacity ?? '0.26') * 100),
|
||||
image_size_px: config.image_size_px ?? WATERMARK_IMAGE_SIZE_DEFAULT_PX,
|
||||
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);
|
||||
setPreviewValues(initial);
|
||||
@@ -319,8 +327,9 @@ const WatermarksPage = () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
// 滑杆字段在类型切换后可能缺值,统一兜底默认值,避免 NaN
|
||||
const opacity = percentToOpacity(values.opacityPercent ?? 80);
|
||||
const scaleRatio = percentToScale(values.scalePercent ?? 25);
|
||||
const opacity = percentToOpacity(values.opacityPercent ?? DEFAULT_OPACITY_PERCENT);
|
||||
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);
|
||||
// 切换类型必须提交新类型的完整 config。
|
||||
@@ -335,14 +344,17 @@ const WatermarksPage = () => {
|
||||
font_code: values.font_code,
|
||||
font_size_px: values.font_size_px,
|
||||
opacity,
|
||||
position: values.position,
|
||||
position_x: values.position_x,
|
||||
position_y: values.position_y,
|
||||
};
|
||||
} else {
|
||||
config = {
|
||||
image_asset_id: imageAsset?.id ?? '',
|
||||
scale_ratio: scaleRatio,
|
||||
image_size_px: imageSize,
|
||||
border_radius_px: borderRadius,
|
||||
opacity,
|
||||
position: values.position,
|
||||
position_x: values.position_x,
|
||||
position_y: values.position_y,
|
||||
};
|
||||
}
|
||||
payload = { kind: values.kind, config };
|
||||
@@ -368,9 +380,8 @@ const WatermarksPage = () => {
|
||||
if (Number(opacity) !== Number(current.opacity)) {
|
||||
configPatch.opacity = opacity;
|
||||
}
|
||||
if (values.position !== current.position) {
|
||||
configPatch.position = values.position;
|
||||
}
|
||||
if (values.position_x !== current.position_x) configPatch.position_x = values.position_x;
|
||||
if (values.position_y !== current.position_y) configPatch.position_y = values.position_y;
|
||||
if (Object.keys(configPatch).length > 0) {
|
||||
diff.config = configPatch;
|
||||
}
|
||||
@@ -386,15 +397,13 @@ const WatermarksPage = () => {
|
||||
if (imageAsset && imageAsset.id !== currentAssetId) {
|
||||
configPatch.image_asset_id = imageAsset.id;
|
||||
}
|
||||
if (percentToScale(values.scalePercent) !== current.scale_ratio) {
|
||||
configPatch.scale_ratio = scaleRatio;
|
||||
}
|
||||
if (imageSize !== current.image_size_px) configPatch.image_size_px = imageSize;
|
||||
if (borderRadius !== current.border_radius_px) configPatch.border_radius_px = borderRadius;
|
||||
if (Number(opacity) !== Number(current.opacity)) {
|
||||
configPatch.opacity = opacity;
|
||||
}
|
||||
if (values.position !== current.position) {
|
||||
configPatch.position = values.position;
|
||||
}
|
||||
if (values.position_x !== current.position_x) configPatch.position_x = values.position_x;
|
||||
if (values.position_y !== current.position_y) configPatch.position_y = values.position_y;
|
||||
if (Object.keys(configPatch).length > 0) {
|
||||
diff.config = configPatch;
|
||||
}
|
||||
@@ -419,7 +428,8 @@ const WatermarksPage = () => {
|
||||
font_code: values.font_code,
|
||||
font_size_px: values.font_size_px,
|
||||
opacity,
|
||||
position: values.position,
|
||||
position_x: values.position_x,
|
||||
position_y: values.position_y,
|
||||
},
|
||||
});
|
||||
applyTemplate(template);
|
||||
@@ -430,9 +440,11 @@ const WatermarksPage = () => {
|
||||
kind: 'image',
|
||||
config: {
|
||||
image_asset_id: imageAsset?.id ?? '',
|
||||
scale_ratio: scaleRatio,
|
||||
image_size_px: imageSize,
|
||||
border_radius_px: borderRadius,
|
||||
opacity,
|
||||
position: values.position,
|
||||
position_x: values.position_x,
|
||||
position_y: values.position_y,
|
||||
},
|
||||
});
|
||||
applyTemplate(template);
|
||||
@@ -451,8 +463,8 @@ const WatermarksPage = () => {
|
||||
'font_code',
|
||||
'font_size_px',
|
||||
'opacityPercent',
|
||||
'scalePercent',
|
||||
'position',
|
||||
'image_size_px',
|
||||
'border_radius_px',
|
||||
];
|
||||
if (details.length > 0) {
|
||||
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'] = [
|
||||
{
|
||||
title: '序号',
|
||||
@@ -562,6 +589,12 @@ const WatermarksPage = () => {
|
||||
dataIndex: 'name',
|
||||
width: 220,
|
||||
ellipsis: true,
|
||||
render: (name: string, template) => (
|
||||
<Space size={4}>
|
||||
<span>{name}</span>
|
||||
{template.is_default && <Tag color="gold">默认</Tag>}
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '水印类型',
|
||||
@@ -605,8 +638,8 @@ const WatermarksPage = () => {
|
||||
{
|
||||
title: '水印位置',
|
||||
key: 'position',
|
||||
width: 110,
|
||||
render: (_, template) => watermarkPositionLabel(template.config.position),
|
||||
width: 160,
|
||||
render: (_, template) => `横 ${Math.round(template.config.position_x * 100)}% · 纵 ${Math.round(template.config.position_y * 100)}%`,
|
||||
},
|
||||
{
|
||||
title: '版本',
|
||||
@@ -625,9 +658,18 @@ const WatermarksPage = () => {
|
||||
{
|
||||
title: '操作',
|
||||
key: 'actions',
|
||||
width: 190,
|
||||
width: 320,
|
||||
render: (_, template) => (
|
||||
<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>
|
||||
@@ -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>>(() => {
|
||||
if (!previewValues) return {};
|
||||
if (previewValues.kind === 'image') {
|
||||
@@ -656,9 +703,11 @@ const WatermarksPage = () => {
|
||||
preview_url: imageAsset?.previewUrl ?? '',
|
||||
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),
|
||||
position: previewValues.position,
|
||||
position_x: previewValues.position_x,
|
||||
position_y: previewValues.position_y,
|
||||
};
|
||||
}
|
||||
return {
|
||||
@@ -666,7 +715,8 @@ const WatermarksPage = () => {
|
||||
font_code: previewValues.font_code,
|
||||
font_size_px: previewValues.font_size_px,
|
||||
opacity: percentToOpacity(previewValues.opacityPercent),
|
||||
position: previewValues.position,
|
||||
position_x: previewValues.position_x,
|
||||
position_y: previewValues.position_y,
|
||||
};
|
||||
}, [previewValues, imageAsset]);
|
||||
|
||||
@@ -788,10 +838,22 @@ const WatermarksPage = () => {
|
||||
>
|
||||
<Form<WatermarkFormValues>
|
||||
form={form}
|
||||
layout="vertical"
|
||||
className="watermark-editor-form"
|
||||
layout="horizontal"
|
||||
labelCol={{ flex: '88px' }}
|
||||
wrapperCol={{ flex: 'auto' }}
|
||||
onValuesChange={(_, values) => setPreviewValues(values as WatermarkFormValues)}
|
||||
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
|
||||
name="name"
|
||||
label="模板名称"
|
||||
@@ -850,15 +912,24 @@ const WatermarksPage = () => {
|
||||
5MB
|
||||
</Typography.Paragraph>
|
||||
<Form.Item
|
||||
name="scalePercent"
|
||||
label="图片缩放"
|
||||
preserve={false}
|
||||
name="image_size_px"
|
||||
label="图片大小"
|
||||
extra={`预览最长边 ${previewValues?.image_size_px ?? WATERMARK_IMAGE_SIZE_DEFAULT_PX}px;应用到商品图时等比换算`}
|
||||
>
|
||||
<Slider
|
||||
min={10}
|
||||
max={200}
|
||||
min={WATERMARK_IMAGE_SIZE_MIN_PX}
|
||||
max={WATERMARK_IMAGE_SIZE_MAX_PX}
|
||||
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>
|
||||
</>
|
||||
@@ -878,34 +949,40 @@ const WatermarksPage = () => {
|
||||
>
|
||||
<Input maxLength={200} placeholder="例如:Atlas Store" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="font_code"
|
||||
label="字体"
|
||||
required
|
||||
preserve={false}
|
||||
>
|
||||
<Select
|
||||
placeholder="选择字体"
|
||||
notFoundContent="渲染服务暂无可用字体"
|
||||
options={fonts.map((font) => ({
|
||||
value: font.code,
|
||||
label: font.display_name,
|
||||
}))}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="font_size_px"
|
||||
label="字号(px)"
|
||||
required
|
||||
rules={[{ required: true, message: '请输入字号' }]}
|
||||
>
|
||||
<InputNumber
|
||||
min={12}
|
||||
max={60}
|
||||
precision={0}
|
||||
style={{ width: 140 }}
|
||||
/>
|
||||
</Form.Item>
|
||||
<div className="watermark-editor-pair">
|
||||
<div>
|
||||
<Form.Item
|
||||
name="font_code"
|
||||
label="字体"
|
||||
required
|
||||
>
|
||||
<Select
|
||||
placeholder="选择字体"
|
||||
notFoundContent="渲染服务暂无可用字体"
|
||||
options={fonts.map((font) => ({
|
||||
value: font.code,
|
||||
label: font.display_name,
|
||||
}))}
|
||||
/>
|
||||
</Form.Item>
|
||||
</div>
|
||||
<div>
|
||||
<Form.Item
|
||||
name="font_size_px"
|
||||
label="字号(px)"
|
||||
labelCol={{ flex: '136px' }}
|
||||
required
|
||||
rules={[{ required: true, message: '请输入字号' }]}
|
||||
>
|
||||
<InputNumber
|
||||
min={12}
|
||||
max={60}
|
||||
precision={0}
|
||||
style={{ width: 96 }}
|
||||
/>
|
||||
</Form.Item>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -920,14 +997,14 @@ const WatermarksPage = () => {
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label="水印位置" required>
|
||||
<Form.Item name="position" noStyle>
|
||||
<PositionGrid />
|
||||
</Form.Item>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label="预览效果">
|
||||
<WatermarkPreview config={previewConfig} />
|
||||
<Form.Item
|
||||
label="预览效果"
|
||||
extra="拖动水印可放在任意位置;方向键可微调"
|
||||
>
|
||||
<WatermarkPreview
|
||||
config={previewConfig}
|
||||
onPositionChange={handlePreviewPositionChange}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<div className="watermark-form-actions">
|
||||
@@ -943,11 +1020,19 @@ const WatermarksPage = () => {
|
||||
title={previewing ? `查看效果:${previewing.name}` : '查看效果'}
|
||||
open={!!previewing}
|
||||
onCancel={() => setPreviewing(null)}
|
||||
footer={null}
|
||||
footer={
|
||||
<div className="watermark-preview-dialog-footer">
|
||||
<Button type="primary" onClick={() => setPreviewing(null)}>关闭</Button>
|
||||
</div>
|
||||
}
|
||||
width={420}
|
||||
destroyOnHidden
|
||||
>
|
||||
{previewing && <WatermarkPreview config={previewing.config} />}
|
||||
{previewing && (
|
||||
<div className="watermark-preview-dialog-content">
|
||||
<WatermarkPreview config={previewing.config} />
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
@@ -958,33 +1043,4 @@ const dayjsOrNull = (value: string) => {
|
||||
return parsed.isValid() ? parsed : null;
|
||||
};
|
||||
|
||||
/** 3x3 水印位置九宫格选择器:选中格高亮,旁边显示位置名称 */
|
||||
const PositionGrid = ({
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
value?: WatermarkPositionValue;
|
||||
onChange?: (value: WatermarkPositionValue) => void;
|
||||
}) => {
|
||||
const selected = WATERMARK_POSITION_OPTIONS.find((option) => option.value === value);
|
||||
return (
|
||||
<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;
|
||||
|
||||
@@ -6,6 +6,12 @@ import type {
|
||||
ProductUploadImage,
|
||||
ProductImportedImage,
|
||||
ImportProductImageRequest,
|
||||
AspectRatioDerivative,
|
||||
WatermarkDerivative,
|
||||
CreateMediaAiEditRequest,
|
||||
MediaAiEdit,
|
||||
ProductImageTask,
|
||||
ProductImageDerivativeRecord,
|
||||
} from '@atlas/contracts';
|
||||
import { api } from '@/services/api';
|
||||
|
||||
@@ -15,6 +21,32 @@ export const WATERMARK_IMAGE_MAX_BYTES = 5 * 1024 * 1024;
|
||||
|
||||
/** 异步操作查询,契约见 docs/api/conventions.md §5(GET /api/operations/{operation_id}) */
|
||||
export const mediaApi = {
|
||||
createAiEdit: (assetId: string, payload: CreateMediaAiEditRequest) =>
|
||||
api.post<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'),
|
||||
listProductImports: () => api.get<ProductImportedImage[]>('/media-assets/product-imports'),
|
||||
importProductImage: (payload: ImportProductImageRequest) =>
|
||||
@@ -40,8 +72,81 @@ export const mediaApi = {
|
||||
{ purpose: 'preview' },
|
||||
{ headers: { 'Idempotency-Key': crypto.randomUUID() } }
|
||||
),
|
||||
createDownloadUrl: (assetId: string) =>
|
||||
api.post<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 校验用) */
|
||||
export async function sha256Hex(file: File): Promise<string> {
|
||||
const digest = await crypto.subtle.digest('SHA-256', await file.arrayBuffer());
|
||||
|
||||
@@ -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 } : {}) },
|
||||
}
|
||||
),
|
||||
};
|
||||
@@ -32,6 +32,13 @@ export const watermarksApi = {
|
||||
|
||||
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) =>
|
||||
api.post<WatermarkTemplate>('/watermark-templates', payload, {
|
||||
headers: { 'Idempotency-Key': crypto.randomUUID() },
|
||||
|
||||
@@ -15,7 +15,8 @@
|
||||
"@atlas/database": "workspace:*",
|
||||
"@aws-sdk/client-s3": "3.1015.0",
|
||||
"bullmq": "6.3.4",
|
||||
"ioredis": "6.0.0"
|
||||
"ioredis": "6.0.0",
|
||||
"sharp": "0.35.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "26.3.0",
|
||||
|
||||
@@ -29,6 +29,10 @@ import {
|
||||
syncPlatformCatalog,
|
||||
} from './platform-catalog-sync.js';
|
||||
import { runProductAiTask } from './product-ai.js';
|
||||
import { markAspectRatioDerivativeFailed, runAspectRatioDerivative } from './media-aspect-ratio.js';
|
||||
import { markWatermarkDerivativeFailed, runWatermarkDerivative } from './media-watermark.js';
|
||||
import { classifyAiEditFailure, markMediaAiEditFailed, runMediaAiEdit } from './media-ai-edit.js';
|
||||
import { archiveExpiredMissingOnlineProducts, markOnlineProductSyncFailed, runOnlineProductSync, type OnlineProductSyncJob } from './online-product-sync.js';
|
||||
|
||||
const connection = new Redis(workerConfig.redisUrl, {
|
||||
maxRetriesPerRequest: null,
|
||||
@@ -82,6 +86,18 @@ const worker = new Worker<OutboxJob | PlatformCatalogJob>(
|
||||
await syncPlatformCatalog(database, job.data as PlatformCatalogJob);
|
||||
} else if (job.name === 'product_ai_task.requested') {
|
||||
await runProductAiTask(database, job.data as OutboxJob);
|
||||
} else if (job.name === 'media.aspect_ratio_derivative.requested') {
|
||||
await runAspectRatioDerivative(
|
||||
database,
|
||||
objectStorage,
|
||||
job.data as OutboxJob,
|
||||
);
|
||||
} else if (job.name === 'media.watermark_derivative.requested') {
|
||||
await runWatermarkDerivative(database, objectStorage, job.data as OutboxJob);
|
||||
} else if (job.name === 'media.ai_edit.requested') {
|
||||
await runMediaAiEdit(database, objectStorage, job.data as OutboxJob);
|
||||
} else if (job.name === 'platform_product_sync.requested') {
|
||||
await runOnlineProductSync(database, job.data as OnlineProductSyncJob);
|
||||
} else if (job.name === 'store_credential_verification.requested') {
|
||||
await verifyStoreCredentials(
|
||||
database,
|
||||
@@ -160,6 +176,14 @@ worker.on('failed', (job, error) => {
|
||||
database,
|
||||
job.data as PlatformCatalogJob,
|
||||
);
|
||||
} else if (job.name === 'media.aspect_ratio_derivative.requested') {
|
||||
void markAspectRatioDerivativeFailed(database, job.data as OutboxJob);
|
||||
} else if (job.name === 'media.watermark_derivative.requested') {
|
||||
void markWatermarkDerivativeFailed(database, job.data as OutboxJob);
|
||||
} else if (job.name === 'media.ai_edit.requested') {
|
||||
void markMediaAiEditFailed(database, job.data as OutboxJob, classifyAiEditFailure(error));
|
||||
} else if (job.name === 'platform_product_sync.requested') {
|
||||
void markOnlineProductSyncFailed(database, job.data as OnlineProductSyncJob, error);
|
||||
} else if (job.name === 'store_credential_verification.requested') {
|
||||
void markUnavailable(database, job.data as StoreVerificationJob, error);
|
||||
} else if (
|
||||
@@ -376,6 +400,7 @@ async function markUnavailable(
|
||||
let dispatcherTimer: ReturnType<typeof setInterval> | undefined;
|
||||
let mediaCleanupTimer: ReturnType<typeof setInterval> | undefined;
|
||||
let platformCatalogTimer: ReturnType<typeof setInterval> | undefined;
|
||||
let onlineProductArchiveTimer: ReturnType<typeof setInterval> | undefined;
|
||||
async function bootstrap() {
|
||||
await queue.waitUntilReady();
|
||||
await platformQueue.waitUntilReady();
|
||||
@@ -384,6 +409,7 @@ async function bootstrap() {
|
||||
await dispatcher.dispatchBatch();
|
||||
await platformDispatcher.dispatchBatch();
|
||||
await platformScheduler.run();
|
||||
await archiveExpiredMissingOnlineProducts(database);
|
||||
dispatcherTimer = setInterval(() => {
|
||||
void dispatcher.dispatchBatch().catch((error: unknown) => {
|
||||
console.error(
|
||||
@@ -408,6 +434,12 @@ async function bootstrap() {
|
||||
}),
|
||||
60_000,
|
||||
);
|
||||
onlineProductArchiveTimer = setInterval(
|
||||
() => void archiveExpiredMissingOnlineProducts(database).catch((error: unknown) => {
|
||||
console.error('online_product_archive_failed', error instanceof Error ? error.message : error);
|
||||
}),
|
||||
60 * 60_000,
|
||||
);
|
||||
void mediaUploadCleanup.run().catch((error: unknown) => {
|
||||
console.error(
|
||||
'media_upload_cleanup_startup_failed',
|
||||
@@ -439,6 +471,7 @@ async function shutdown() {
|
||||
if (dispatcherTimer) clearInterval(dispatcherTimer);
|
||||
if (mediaCleanupTimer) clearInterval(mediaCleanupTimer);
|
||||
if (platformCatalogTimer) clearInterval(platformCatalogTimer);
|
||||
if (onlineProductArchiveTimer) clearInterval(onlineProductArchiveTimer);
|
||||
await worker.close();
|
||||
await platformWorker.close();
|
||||
await queue.close();
|
||||
|
||||
@@ -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],
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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],
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -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) => ({
|
||||
'&': '&', '<': '<', '>': '>', '"': '"', "'": ''',
|
||||
})[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]);
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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')
|
||||
)`,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -168,7 +168,7 @@ export async function fetchOzonDictionaryBatch(
|
||||
return { values, isComplete: false };
|
||||
}
|
||||
|
||||
async function requestOzon(
|
||||
export async function requestOzon(
|
||||
clientId: string,
|
||||
apiKey: string,
|
||||
path: string,
|
||||
|
||||
@@ -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')));
|
||||
});
|
||||
@@ -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
Reference in New Issue
Block a user