22 Commits

Author SHA1 Message Date
joey5628 06dbd817f0 feat(edit): 优化图片导入ozon问题 2026-10-07 21:49:28 +08:00
joey5628 79b310efe7 feat(edit): 编辑上报细节优化 2026-10-06 18:30:05 +08:00
joey5628 e20cd7547f feat(edit): AI套图生成、图片切七牛 2026-10-04 20:47:47 +08:00
R524809 696112e74d docs: 域名、证书配置文档 2026-09-30 17:48:03 +08:00
R524809 45871584b3 feat(edit): 编辑页上报ozon 开发 2026-09-30 15:57:54 +08:00
R524809 0a303414d8 feat(edit): 定价试算开发、SKU 样式功能修改 2026-09-29 17:50:19 +08:00
R524809 84b230e69a feat(pricing): 开发定价方案 2026-09-28 17:34:33 +08:00
joey5628 a29dd92676 feat(ai-image): AI生图开发 2026-09-27 22:40:52 +08:00
R524809 e670d06ca6 feat: 完成 SKU 图片选择及素材导入后端 2026-09-24 17:46:34 +08:00
R524809 d24ce8f4e0 feat: 编辑页 SKU 图标默认值优化 2026-09-23 17:39:05 +08:00
R524809 b3958166dd feat(ai-edit): 开发 AI 填报功能 2026-09-23 17:18:09 +08:00
R524809 95a2a37f1b feat(edit|extension): 编辑页类目采集开发 2026-09-22 13:47:24 +08:00
R524809 2b06ef0692 feat(edit): 采集插件、编辑页SKU 属性动态展示、颜色展示开发 2026-09-22 10:11:57 +08:00
R524809 05747d6f22 feat(edit): 商品编辑页优化 2026-09-21 17:44:50 +08:00
R524809 75fa2b60f3 feat(edit): 编辑页样式修改 2026-09-20 17:44:40 +08:00
R524809 e0e07be4ea docs: 更新文档 2026-09-20 15:23:44 +08:00
R524809 880e441bc6 feat(category): 类目树、类目属性、属性字典维护系统构建完成 2026-09-20 15:11:32 +08:00
R524809 388429dbd0 feat(category): 类目树、属性、属性字典维护,新增系统管理员管理类目数据 2026-09-18 18:55:16 +08:00
R524809 fcddb11da0 feat(edit): 编辑页 SKU 展示样式修改 2026-09-18 14:39:40 +08:00
R524809 df3fb0c754 feat(edit): 编辑页样式修改 2026-09-18 13:38:00 +08:00
R524809 5684cbd517 feat(edit): 编辑页样式优化 2026-09-17 17:47:18 +08:00
R524809 52ef75f719 feat:编辑页响应式样式修改 2026-09-16 17:21:33 +08:00
322 changed files with 78479 additions and 3651 deletions
+22
View File
@@ -84,3 +84,25 @@ MINIO_CONSOLE_PORT=9001
WEB_PORT=8100 WEB_PORT=8100
# Local WXT browser-extension development port. # Local WXT browser-extension development port.
EXTENSION_PORT=8110 EXTENSION_PORT=8110
# DeepSeek API Key
DEEPSEEK_API_KEY=
# DeepSeek Model Name
DEEPSEEK_MODEL_NAME=deepseek-flash
# 单张 AI 图片编辑使用 Pcore 的 gpt-image-2 异步图片接口;根地址和 /v1 地址均可,仅 Worker 读取。
PCORE_BASE_URL=https://pcore.ai/v1
PCORE_API_KEY=
# 首次上架:开发/测试 auto 允许,生产 auto 关闭;完成 VAT 与七牛联调后显式 true。
OZON_PUBLISH_ENABLED=auto
# 与素材实际入库环境一致,生产设置 qiniu_kodo。
OBJECT_STORAGE_PROVIDER=local_minio
# 七牛绑定的公网 HTTPS 下载域名,与 S3 服务 endpoint 分离。
# 七牛模式下,API 图片预览、下载和 Ozon 交付共用此 HTTPS 图片域名。
OZON_IMAGE_DOWNLOAD_ORIGIN=
# 图片签名初始有效期 7 天;生产值须通过异步/延迟抓图验收。
OZON_IMAGE_URL_TTL_SECONDS=604800
# 可复用对象存储凭据;分离授权时填写专用签名凭据。
QINIU_ACCESS_KEY=
QINIU_SECRET_KEY=
+3 -3
View File
@@ -7,10 +7,10 @@
- 可按任务需要修改 `apps/web/`、`apps/api/`、`apps/worker/`、`apps/extension/`、`packages/` 及相关文档。 - 可按任务需要修改 `apps/web/`、`apps/api/`、`apps/worker/`、`apps/extension/`、`packages/` 及相关文档。
- 修改应遵守 `docs/project/development-collaboration-contract.md` 中的领域边界、API 契约、数据安全与验证要求;跨领域改动须明确说明影响范围。 - 修改应遵守 `docs/project/development-collaboration-contract.md` 中的领域边界、API 契约、数据安全与验证要求;跨领域改动须明确说明影响范围。
## 2. 不要启动服务端和 Worker ## 2. 服务端、Worker 与前端验证
- API(NestJS)和 Worker 由维护者自行在其他地方启动,AI 不需要也不应该启动、重启或杀死它们。 - API(NestJS)和 Worker 可在开发、联调或验证需要时由 AI 启动、重启或停止;操作前应先确认目标进程与端口,避免误杀无关进程。
- 开发/验证时若检测到 API 或 Worker 未运行,直接在回复中说明即可,不要尝试代为启动。 - API 或 Worker 已由维护者启动时,可在本任务需要时重启,但应在完成后说明实际操作和运行状态。
- 前端 Vite dev server 可为验证目的临时启动,用完后应停掉,并避免占用维护者已在使用的端口(如 5173/8200)。 - 前端 Vite dev server 可为验证目的临时启动,用完后应停掉,并避免占用维护者已在使用的端口(如 5173/8200)。
## 3. 代码注释 ## 3. 代码注释
+3
View File
@@ -20,6 +20,7 @@
"@nestjs/core": "12.0.1", "@nestjs/core": "12.0.1",
"@nestjs/platform-express": "12.0.1", "@nestjs/platform-express": "12.0.1",
"@nestjs/swagger": "12.0.1", "@nestjs/swagger": "12.0.1",
"decimal.js": "10.6.0",
"helmet": "8.3.0", "helmet": "8.3.0",
"ioredis": "6.0.0", "ioredis": "6.0.0",
"reflect-metadata": "0.2.2", "reflect-metadata": "0.2.2",
@@ -30,6 +31,8 @@
"@nestjs/testing": "12.0.1", "@nestjs/testing": "12.0.1",
"@types/express": "5.0.6", "@types/express": "5.0.6",
"@types/node": "26.3.0", "@types/node": "26.3.0",
"@types/pg": "8.23.1",
"pg": "8.23.0",
"tsx": "4.23.13", "tsx": "4.23.13",
"typescript": "7.0.2", "typescript": "7.0.2",
"vitest": "^4.1.11" "vitest": "^4.1.11"
+6
View File
@@ -1,3 +1,4 @@
import { PricingModule } from './pricing/pricing.module.js';
import { Controller, Get, Inject, Module } from '@nestjs/common'; import { Controller, Get, Inject, Module } from '@nestjs/common';
import type { Database } from '@atlas/database'; import type { Database } from '@atlas/database';
import { AuthModule } from './auth/auth.module.js'; import { AuthModule } from './auth/auth.module.js';
@@ -12,6 +13,8 @@ import { MediaModule } from './media/media.module.js';
import { CollectionModule } from './collection/collection.module.js'; import { CollectionModule } from './collection/collection.module.js';
import { ExtensionModule } from './extension/extension.module.js'; import { ExtensionModule } from './extension/extension.module.js';
import { ProductEditorModule } from './product-editor/product-editor.module.js'; import { ProductEditorModule } from './product-editor/product-editor.module.js';
import { PlatformCatalogAdminModule } from './platform-catalog-admin/platform-catalog-admin.module.js';
import { OnlineProductsModule } from './online-products/online-products.module.js';
@Controller() @Controller()
class HealthController { class HealthController {
@@ -51,6 +54,9 @@ class HealthController {
CollectionModule, CollectionModule,
ExtensionModule, ExtensionModule,
ProductEditorModule, ProductEditorModule,
PlatformCatalogAdminModule,
OnlineProductsModule,
PricingModule,
], ],
controllers: [HealthController], controllers: [HealthController],
}) })
@@ -167,7 +167,7 @@ export class CollectionService {
created_at: Date | string; created_at: Date | string;
completed_at: Date | string | null; completed_at: Date | string | null;
}>( }>(
`SELECT id, source_variant_id, media_type::text AS media_type, poster_url, role::text AS role, source_url, status::text AS status, asset_id, error_payload, created_at, completed_at FROM atlas.collection_asset_transfers WHERE collection_item_id = $1 AND source_snapshot_revision = $2 ORDER BY coalesce((error_payload->>'sort_order')::integer, 0), created_at, id`, `SELECT id, source_variant_id, media_type::text AS media_type, poster_url, role::text AS role, source_url, status::text AS status, asset_id, error_payload, created_at, completed_at FROM atlas.collection_asset_transfers WHERE collection_item_id = $1 AND source_snapshot_revision = $2 AND deleted_at IS NULL ORDER BY coalesce((error_payload->>'sort_order')::integer, 0), created_at, id`,
[id, source.rows[0]?.source_snapshot_revision ?? 1], [id, source.rows[0]?.source_snapshot_revision ?? 1],
); );
return { return {
@@ -280,7 +280,7 @@ export class CollectionService {
const result = await tx.query<{ const result = await tx.query<{
source_snapshot_revision: string | number; source_snapshot_revision: string | number;
}>( }>(
`UPDATE atlas.collection_asset_transfers t SET status = 'queued', error_payload = NULL, updated_at = now() FROM atlas.collection_items c WHERE t.collection_item_id = c.id AND t.collection_item_id = $1 AND t.id = ANY($2::uuid[]) AND t.source_snapshot_revision = c.source_snapshot_revision AND t.status IN ('failed', 'cancelled') RETURNING c.source_snapshot_revision`, `UPDATE atlas.collection_asset_transfers t SET status = 'queued', error_payload = NULL, updated_at = now() FROM atlas.collection_items c WHERE t.collection_item_id = c.id AND t.collection_item_id = $1 AND t.id = ANY($2::uuid[]) AND t.source_snapshot_revision = c.source_snapshot_revision AND t.deleted_at IS NULL AND t.status IN ('failed', 'cancelled') RETURNING c.source_snapshot_revision`,
[id, input.transfer_ids], [id, input.transfer_ids],
); );
if (result.rowCount !== input.transfer_ids.length) if (result.rowCount !== input.transfer_ids.length)
@@ -328,7 +328,7 @@ export class CollectionService {
const result = await tx.query<{ const result = await tx.query<{
source_snapshot_revision: string | number; source_snapshot_revision: string | number;
}>( }>(
`UPDATE atlas.collection_asset_transfers t SET status = 'queued', error_payload = NULL, completed_at = NULL, updated_at = now() FROM atlas.collection_items c WHERE t.collection_item_id = c.id AND t.collection_item_id = $1 AND t.id = ANY($2::uuid[]) AND t.source_snapshot_revision = c.source_snapshot_revision AND t.status = 'pending_selection' RETURNING c.source_snapshot_revision`, `UPDATE atlas.collection_asset_transfers t SET status = 'queued', error_payload = NULL, completed_at = NULL, updated_at = now() FROM atlas.collection_items c WHERE t.collection_item_id = c.id AND t.collection_item_id = $1 AND t.id = ANY($2::uuid[]) AND t.source_snapshot_revision = c.source_snapshot_revision AND t.deleted_at IS NULL AND t.status = 'pending_selection' RETURNING c.source_snapshot_revision`,
[id, input.transfer_ids], [id, input.transfer_ids],
); );
if (result.rowCount !== input.transfer_ids.length) if (result.rowCount !== input.transfer_ids.length)
+52 -1
View File
@@ -24,16 +24,33 @@ const environmentSchema = z.object({
NODE_ENV: z NODE_ENV: z
.enum(['development', 'test', 'production']) .enum(['development', 'test', 'production'])
.default('development'), .default('development'),
PRICING_ALLOW_DRAFT_RATES: z.enum(['true', 'false', 'auto']).default('auto'),
OZON_PUBLISH_ENABLED: z.enum(['true', 'false', 'auto']).default('auto'),
OBJECT_STORAGE_PROVIDER: z.enum(['local_minio', 'qiniu_kodo']).default('local_minio'),
API_PORT: z.coerce.number().int().min(1).max(65_535).default(8200), API_PORT: z.coerce.number().int().min(1).max(65_535).default(8200),
API_PUBLIC_ORIGIN: z.url().default('http://localhost:8200'), API_PUBLIC_ORIGIN: z.url().default('http://localhost:8200'),
WEB_ORIGIN: z.url().default('http://localhost:8100'), WEB_ORIGIN: z.url().default('http://localhost:8100'),
DATABASE_URL: z.url(), DATABASE_URL: z.url(),
REDIS_URL: z.url(), REDIS_URL: z.url(),
OBJECT_STORAGE_ENDPOINT: z.url().default('http://localhost:9000'), OBJECT_STORAGE_ENDPOINT: z.url().default('http://localhost:9000'),
OBJECT_STORAGE_REGION: z.string().min(1).default('us-east-1'),
OBJECT_STORAGE_BUCKET: z.string().min(3).max(63).default('atlas-assets'), OBJECT_STORAGE_BUCKET: z.string().min(3).max(63).default('atlas-assets'),
OBJECT_STORAGE_FORCE_PATH_STYLE: z.enum(['true', 'false']).default('true'), OBJECT_STORAGE_FORCE_PATH_STYLE: z.enum(['true', 'false']).default('true'),
OBJECT_STORAGE_ACCESS_KEY: z.string().min(3).optional(), OBJECT_STORAGE_ACCESS_KEY: z.string().min(3).optional(),
OBJECT_STORAGE_SECRET_KEY: z.string().min(8).optional(), OBJECT_STORAGE_SECRET_KEY: z.string().min(8).optional(),
OZON_IMAGE_DOWNLOAD_ORIGIN: z.preprocess(
(value) => value === '' ? undefined : value,
z.url().optional(),
),
QINIU_ACCESS_KEY: z.preprocess(
(value) => value === '' ? undefined : value,
z.string().min(3).optional(),
),
QINIU_SECRET_KEY: z.preprocess(
(value) => value === '' ? undefined : value,
z.string().min(8).optional(),
),
LEGACY_MINIO_ENDPOINT: z.url().default('http://localhost:9000'),
MINIO_ROOT_USER: z.string().min(3).optional(), MINIO_ROOT_USER: z.string().min(3).optional(),
MINIO_ROOT_PASSWORD: z.string().min(8).optional(), MINIO_ROOT_PASSWORD: z.string().min(8).optional(),
AUTH_JWT_SECRET: z.string().min(32), AUTH_JWT_SECRET: z.string().min(32),
@@ -96,15 +113,25 @@ const environmentSchema = z.object({
}); });
type Environment = z.infer<typeof environmentSchema>; type Environment = z.infer<typeof environmentSchema>;
export function draftPricingEnabled(
nodeEnvironment: Environment['NODE_ENV'],
setting: Environment['PRICING_ALLOW_DRAFT_RATES'],
): boolean {
return setting === 'true' || (setting === 'auto' && nodeEnvironment !== 'test');
}
export type AppConfig = Omit< export type AppConfig = Omit<
Environment, Environment,
'TRUSTED_PROXY_CIDRS' | 'SWAGGER_ENABLED' 'TRUSTED_PROXY_CIDRS' | 'SWAGGER_ENABLED' | 'PRICING_ALLOW_DRAFT_RATES'
> & { > & {
trustedProxyCidrs: string[]; trustedProxyCidrs: string[];
swaggerEnabled: boolean; swaggerEnabled: boolean;
objectStorageAccessKey: string; objectStorageAccessKey: string;
objectStorageSecretKey: string; objectStorageSecretKey: string;
objectStorageForcePathStyle: boolean; objectStorageForcePathStyle: boolean;
qiniuDownloadOrigin: string | null;
qiniuDownloadAccessKey: string;
qiniuDownloadSecretKey: string;
pricingAllowDraftRates: boolean;
}; };
function validateEnvironment( function validateEnvironment(
@@ -129,6 +156,22 @@ function createAppConfig(environment: Environment): AppConfig {
'OBJECT_STORAGE_ACCESS_KEY and OBJECT_STORAGE_SECRET_KEY are required', 'OBJECT_STORAGE_ACCESS_KEY and OBJECT_STORAGE_SECRET_KEY are required',
); );
} }
const qiniuDownloadOrigin = environment.OZON_IMAGE_DOWNLOAD_ORIGIN ?? null;
if (environment.OBJECT_STORAGE_PROVIDER === 'qiniu_kodo') {
if (!qiniuDownloadOrigin) {
throw new Error('OZON_IMAGE_DOWNLOAD_ORIGIN is required for Qiniu image previews');
}
const origin = new URL(qiniuDownloadOrigin);
if (
origin.protocol !== 'https:' || origin.username || origin.password ||
origin.search || origin.hash || origin.pathname !== '/'
) {
throw new Error('OZON_IMAGE_DOWNLOAD_ORIGIN must be an HTTPS origin');
}
}
if (Boolean(environment.QINIU_ACCESS_KEY) !== Boolean(environment.QINIU_SECRET_KEY)) {
throw new Error('QINIU_ACCESS_KEY and QINIU_SECRET_KEY must be configured together');
}
return { return {
...environment, ...environment,
// 只有显式给出代理网段时才信任转发头,避免客户端伪造来源 IP。 // 只有显式给出代理网段时才信任转发头,避免客户端伪造来源 IP。
@@ -138,8 +181,16 @@ function createAppConfig(environment: Environment): AppConfig {
swaggerEnabled: environment.SWAGGER_ENABLED === 'true', swaggerEnabled: environment.SWAGGER_ENABLED === 'true',
objectStorageAccessKey, objectStorageAccessKey,
objectStorageSecretKey, objectStorageSecretKey,
qiniuDownloadOrigin,
qiniuDownloadAccessKey: environment.QINIU_ACCESS_KEY ?? objectStorageAccessKey,
qiniuDownloadSecretKey: environment.QINIU_SECRET_KEY ?? objectStorageSecretKey,
objectStorageForcePathStyle: objectStorageForcePathStyle:
environment.OBJECT_STORAGE_FORCE_PATH_STYLE === 'true', environment.OBJECT_STORAGE_FORCE_PATH_STYLE === 'true',
// 草稿费率默认可参与报价;仍可通过环境配置限制新试算。
pricingAllowDraftRates: draftPricingEnabled(
environment.NODE_ENV,
environment.PRICING_ALLOW_DRAFT_RATES,
),
}; };
} }
+30 -10
View File
@@ -87,9 +87,13 @@ export class ExtensionService {
], ],
); );
const product = await tx.query<{ id: string }>( const product = await tx.query<{ id: string }>(
`INSERT INTO atlas.products (workspace_id, collection_item_id, source_title) `INSERT INTO atlas.products (workspace_id, collection_item_id, source_title, purchase_url)
VALUES (current_setting('app.workspace_id')::uuid, $1, $2) RETURNING id`, VALUES (current_setting('app.workspace_id')::uuid, $1, $2, $3) RETURNING id`,
[item.rows[0].id, payload.common.title?.value ?? null], [
item.rows[0].id,
payload.common.title?.value ?? null,
payload.source.canonicalUrl,
],
); );
await this.writeSourceSnapshotRows( await this.writeSourceSnapshotRows(
tx, tx,
@@ -321,12 +325,14 @@ export class ExtensionService {
await tx.query( await tx.query(
`UPDATE atlas.product_variants `UPDATE atlas.product_variants
SET source_sku = $1, display_name = $2, sort_order = $3, SET source_sku = $1, display_name = $2, sort_order = $3,
purchase_cost_cny = COALESCE(ROUND($4::numeric, 2), purchase_cost_cny),
deleted_at = NULL, updated_at = now() deleted_at = NULL, updated_at = now()
WHERE id = $4`, WHERE id = $5`,
[ [
variant.sourceSku ?? null, variant.sourceSku ?? null,
variant.displayName, variant.displayName,
index, index,
sourcePurchaseCost(payload.source.platform, variant),
variantId, variantId,
], ],
); );
@@ -334,8 +340,8 @@ export class ExtensionService {
} else { } else {
const inserted = await tx.query<{ id: string }>( const inserted = await tx.query<{ id: string }>(
`INSERT INTO atlas.product_variants `INSERT INTO atlas.product_variants
(workspace_id, product_id, source_variant_key, source_sku, display_name, sort_order) (workspace_id, product_id, source_variant_key, source_sku, display_name, sort_order, purchase_cost_cny)
VALUES ($1, $2, $3, $4, $5, $6) RETURNING id`, VALUES ($1, $2, $3, $4, $5, $6, ROUND($7::numeric, 2)) RETURNING id`,
[ [
workspaceId, workspaceId,
productId, productId,
@@ -343,6 +349,7 @@ export class ExtensionService {
variant.sourceSku ?? null, variant.sourceSku ?? null,
variant.displayName, variant.displayName,
index, index,
sourcePurchaseCost(payload.source.platform, variant),
], ],
); );
variantId = inserted.rows[0].id; variantId = inserted.rows[0].id;
@@ -446,21 +453,24 @@ export class ExtensionService {
const status = await tx.query<{ const status = await tx.query<{
status: string; status: string;
source_title: string | null; source_title: string | null;
variant_count: string;
}>( }>(
'SELECT status, source_title FROM atlas.collection_items WHERE id = $1', `SELECT status, source_title,
[collectionItemId], (SELECT count(*)::text FROM atlas.product_variants WHERE product_id=$2 AND deleted_at IS NULL) AS variant_count
FROM atlas.collection_items WHERE id=$1`,
[collectionItemId, productId],
); );
return { return {
collection_item: { collection_item: {
id: collectionItemId, id: collectionItemId,
status: status.rows[0]?.status ?? 'received', status: status.rows[0]?.status ?? 'received',
source_title: status.rows[0]?.source_title ?? null, source_title: status.rows[0]?.source_title ?? null,
variant_count: 0, variant_count: Number(status.rows[0]?.variant_count ?? 0),
}, },
product_id: productId, product_id: productId,
reused, reused,
refreshed, refreshed,
editor_entry_path: `/collection-test/${collectionItemId}`, editor_entry_path: `/products/${productId}/editor?collection_item_id=${collectionItemId}`,
materialization: { materialization: {
total: Number(count.rows[0]?.total ?? 0), total: Number(count.rows[0]?.total ?? 0),
pending_selection: Number( pending_selection: Number(
@@ -486,3 +496,13 @@ function sourceState(payload: CollectedProductPayload) {
sourcePrice: payload.common.sourcePrice, sourcePrice: payload.common.sourcePrice,
}; };
} }
/** 仅将 1688 明确标为人民币的逐 SKU 来源价写入采购价。 */
function sourcePurchaseCost(
platform: CollectedProductPayload['source']['platform'],
variant: CollectedProductPayload['variants'][number] | { sourceVariantKey: null },
) {
return platform === '1688' && 'sourcePrice' in variant && variant.sourcePrice?.currencyCode === 'CNY'
? variant.sourcePrice.amount
: null;
}
+5
View File
@@ -58,6 +58,11 @@ export async function createApplication() {
}); });
// Helmet、body 限制、请求 ID 与全局错误过滤器必须在业务路由前注册。 // Helmet、body 限制、请求 ID 与全局错误过滤器必须在业务路由前注册。
app.use(helmet({ contentSecurityPolicy: false })); app.use(helmet({ contentSecurityPolicy: false }));
// 批量佣金文件可达 2 MiB,专用入口预留元数据空间,其他 API 保留原限制。
app.useBodyParser('json', {
limit: 3 * 1024 * 1024,
type: (req) => (req as Request).path.startsWith('/api/system/pricing/commissions') && Boolean((req as Request).is('application/json')),
});
app.useBodyParser('json', { limit: config.API_BODY_LIMIT_BYTES }); app.useBodyParser('json', { limit: config.API_BODY_LIMIT_BYTES });
app.useBodyParser('urlencoded', { app.useBodyParser('urlencoded', {
limit: config.API_BODY_LIMIT_BYTES, limit: config.API_BODY_LIMIT_BYTES,
@@ -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 deleted_at IS NULL AND content_type IN ('image/jpeg', 'image/png', 'image/webp')
AND byte_size <= 20971520 AND width_px BETWEEN 1 AND 10000
AND height_px BETWEEN 1 AND 10000`,
[assetId],
);
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 AND a.deleted_at IS NULL
WHERE d.id = $1`,
[derivativeId],
);
return result.rows[0] ?? null;
});
if (!row) throw new ApiError(404, 'RESOURCE_NOT_FOUND', '图片处理任务不存在');
const access = row.output_asset_id && row.content_type
? await this.media.accessUrl(workspaceId, row.output_asset_id)
: null;
return {
id: row.id,
status: row.status,
source_asset_id: row.source_asset_id,
error_code: row.error_code,
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,
};
}
}
+441
View File
@@ -0,0 +1,441 @@
import { randomUUID } from 'node:crypto';
import { Inject, Injectable } from '@nestjs/common';
import {
createImageSuiteSchema,
imageSuitePlanErrorMessages,
createMediaAiEditRequestSchema,
type CreateImageSuiteRequest,
type ImageSuite,
type ImageSuiteInput,
type ImageSuitePlanInput,
type ImageSuiteShot,
type ListProductImageTasksQuery,
} from '@atlas/contracts';
import type { Database, DatabaseTransaction } from '@atlas/database';
import { ApiError } from '../common/api-error.js';
import { IdempotencyService } from '../common/idempotency.service.js';
import { DATABASE } from '../database/database.module.js';
import { MediaAiEditService } from './media-ai-edit.service.js';
type SuiteRow = Omit<
ImageSuite,
'items' | 'operation' | 'created_at' | 'completed_at'
> & { created_at: Date | string; completed_at: Date | string | null };
const iso = (value: Date | string) =>
value instanceof Date ? value.toISOString() : value;
@Injectable()
export class ImageSuiteService {
public constructor(
@Inject(DATABASE) private readonly database: Database,
@Inject(IdempotencyService)
private readonly idempotency: IdempotencyService,
@Inject(MediaAiEditService) private readonly edits: MediaAiEditService,
) {}
public async create(
workspaceId: string,
userId: string,
productId: string,
key: string,
request:
ImageSuiteInput | ImageSuitePlanInput | CreateImageSuiteRequest,
purpose: 'plan' | 'generate',
) {
const path = `/api/products/${productId}/ai/${purpose === 'plan' ? 'suite-plans' : 'image-suites'}`;
return this.idempotency.run(
{ workspaceId, userId, method: 'POST', path, key, request },
async (tx) => {
const variant = await tx.query<{ id: string }>(
'SELECT id FROM atlas.product_variants WHERE id = $1 AND product_id = $2 AND deleted_at IS NULL',
[request.variant_id, productId],
);
if (!variant.rows[0])
throw new ApiError(
404,
'RESOURCE_NOT_FOUND',
'目标 SKU 不存在或不属于此商品,请先保存新 SKU',
);
const assets = await tx.query<{ id: string }>(
`SELECT id FROM atlas.media_assets WHERE id = ANY($1::uuid[]) AND deleted_at IS NULL
AND content_type IN ('image/jpeg', 'image/png', 'image/webp')
AND byte_size <= 20971520 AND width_px BETWEEN 1 AND 10000 AND height_px BETWEEN 1 AND 10000`,
[request.reference_asset_ids],
);
if (assets.rows.length !== request.reference_asset_ids.length)
throw new ApiError(
404,
'RESOURCE_NOT_FOUND',
'参考素材不存在或不可处理',
);
const shots =
purpose === 'generate'
? createImageSuiteSchema.parse(request).shots
: [];
const { shots: _shots, ...input } =
request as CreateImageSuiteRequest;
const created = await tx.query<SuiteRow>(
`INSERT INTO atlas.product_image_suites (workspace_id, product_id, variant_id, purpose, input, shots, created_by_user_id)
VALUES (current_setting('app.workspace_id')::uuid, $1, $2, $3, $4::jsonb, $5::jsonb, $6) RETURNING *`,
[
productId,
input.variant_id,
purpose,
JSON.stringify(input),
JSON.stringify(shots),
userId,
],
);
const row = created.rows[0];
for (const [
position,
assetId,
] of input.reference_asset_ids.entries())
await tx.query(
`INSERT INTO atlas.product_image_suite_references (workspace_id, suite_id, asset_id, position)
VALUES (current_setting('app.workspace_id')::uuid, $1, $2, $3)`,
[row.id, assetId, position],
);
await this.operation(
tx,
row.id,
purpose === 'plan' ? 'image_suite_plan' : 'image_suite',
userId,
);
if (purpose === 'plan')
await this.outbox(
tx,
'image_suite.plan.requested',
row.id,
{ suite_id: row.id },
);
else
for (const [index, shot] of shots.entries()) {
const supporting = input.reference_asset_ids.slice(1);
// 默认方案轮换全部辅助底图,避免后面的参考角度始终未被使用。
const selected = shot.reference_asset_ids ?? [
input.reference_asset_ids[0],
...Array.from(
{ length: Math.min(2, supporting.length) },
(_, offset) =>
supporting[
(index + offset) % supporting.length
],
),
];
const settings = createMediaAiEditRequestSchema.parse({
product_id: productId,
reference_asset_ids: selected.slice(1),
mode: 'edit',
ratio: input.ratio,
target_language: input.language,
background:
shot.category === 'white' ? 'white' : 'auto',
style_set: input.style_set,
style_prompt: input.style_prompt,
prompt: `${shot.theme}\n${shot.description}\n资料依据:${shot.evidence}`,
suite_context: {
type: shot.type,
product_name: input.product_name,
color: input.color,
dimensions: input.dimensions,
weight: input.weight,
collected_info: input.collected_info,
selling_points: input.selling_points,
},
});
const id = randomUUID();
await tx.query(
`INSERT INTO atlas.media_ai_edits (id, workspace_id, product_id, suite_id, suite_shot_id, suite_position,
source_asset_id, reference_asset_ids, settings, created_by_user_id)
VALUES ($1, current_setting('app.workspace_id')::uuid, $2, $3, $4, $5, $6, $7::uuid[], $8::jsonb, $9)`,
[
id,
productId,
row.id,
shot.id,
index,
selected[0],
selected.slice(1),
JSON.stringify(settings),
userId,
],
);
await this.operation(tx, id, 'media_ai_edit', userId);
await this.outbox(tx, 'media.ai_edit.requested', id, {
edit_id: id,
});
}
return { status: 202, body: { data: this.resource(row) } };
},
);
}
public async read(
workspaceId: string,
productId: string,
id: string,
): Promise<ImageSuite> {
const { row, children } = await this.database.workspaceTransaction(
workspaceId,
async (tx) => {
const row = await this.find(tx, productId, id);
const children = await tx.query<{
id: string;
suite_shot_id: string;
}>(
'SELECT id, suite_shot_id FROM atlas.media_ai_edits WHERE suite_id = $1 ORDER BY suite_position',
[id],
);
return { row, children: children.rows };
},
);
return {
...this.resource(row),
items: await Promise.all(
children.map(async (child) => ({
...(await this.edits.read(workspaceId, child.id)),
shot_id: child.suite_shot_id,
edit_id: child.id,
})),
),
};
}
public async list(
workspaceId: string,
productId: string,
query: ListProductImageTasksQuery,
) {
let cursor: { at: string; id: string } | null = null;
if (query.cursor) {
try {
cursor = JSON.parse(
Buffer.from(query.cursor, 'base64url').toString(),
);
if (
!cursor ||
typeof cursor.at !== 'string' ||
typeof cursor.id !== 'string' ||
!Number.isFinite(Date.parse(cursor.at)) ||
!/^[0-9a-f-]{36}$/i.test(cursor.id)
)
throw new Error();
} catch {
throw new ApiError(422, 'VALIDATION_FAILED', '分页游标无效');
}
}
const rows = await this.database.workspaceTransaction(
workspaceId,
async (tx) => {
const found = await tx.query<{ id: string }>(
'SELECT id FROM atlas.products WHERE id = $1',
[productId],
);
if (!found.rows[0])
throw new ApiError(404, 'RESOURCE_NOT_FOUND', '商品不存在');
return (
await tx.query<SuiteRow>(
`SELECT * FROM atlas.product_image_suites WHERE product_id = $1
AND ($2::timestamptz IS NULL OR (created_at, id) < ($2::timestamptz, $3::uuid))
ORDER BY created_at DESC, id DESC LIMIT $4`,
[
productId,
cursor?.at ?? null,
cursor?.id ?? null,
query.limit + 1,
],
)
).rows;
},
);
const page = rows.slice(0, query.limit),
last = page.at(-1),
hasMore = rows.length > query.limit;
return {
data: page.map((row) => this.resource(row)),
meta: {
has_more: hasMore,
next_cursor:
hasMore && last
? Buffer.from(
JSON.stringify({
at: iso(last.created_at),
id: last.id,
}),
).toString('base64url')
: null,
},
};
}
public async action(
workspaceId: string,
userId: string,
productId: string,
id: string,
key: string,
action: 'cancel' | 'retry',
) {
return this.idempotency.run(
{
workspaceId,
userId,
method: 'POST',
path: `/api/products/${productId}/ai/image-suites/${id}/${action}`,
key,
request: {},
},
async (tx) => {
// 与 Worker 一致先锁子任务再锁父任务,避免取消与结果回写形成相反锁序。
const children = await tx.query<{ id: string; status: string }>(
'SELECT id, status FROM atlas.media_ai_edits WHERE suite_id = $1 ORDER BY id FOR UPDATE',
[id],
);
const row = await this.find(tx, productId, id, true);
if (action === 'cancel') {
if (['queued', 'running'].includes(row.status)) {
await tx.query(
"UPDATE atlas.product_image_suites SET status = 'cancelled', completed_at = now() WHERE id = $1",
[id],
);
await tx.query(
"UPDATE atlas.media_ai_edits SET status = 'cancelled', completed_at = now() WHERE suite_id = $1 AND status IN ('queued', 'running')",
[id],
);
await tx.query(
"UPDATE atlas.operations SET status = 'cancelled', completed_at = now() WHERE (id = $1 OR id = ANY($2::uuid[])) AND status IN ('queued', 'running')",
[id, children.rows.map((child) => child.id)],
);
}
} else {
if (
row.purpose !== 'generate' ||
['queued', 'running'].includes(row.status)
)
throw new ApiError(
409,
'INVALID_TASK_STATE',
'当前任务不能重试,请等待生成完成',
);
const failed = children.rows.filter((child) =>
['failed', 'cancelled'].includes(child.status),
);
if (failed.length) {
await tx.query(
"UPDATE atlas.product_image_suites SET status = 'queued', error_code = NULL, completed_at = NULL WHERE id = $1",
[id],
);
await tx.query(
"UPDATE atlas.operations SET status = 'queued', error_code = NULL, error_message = NULL, completed_at = NULL WHERE id = $1",
[id],
);
for (const child of failed) {
const attempt = await tx.query<{
generation_attempt: number;
}>(
"UPDATE atlas.media_ai_edits SET status = 'queued', provider_task_id = NULL, error_code = NULL, completed_at = NULL, started_at = NULL, created_at = now(), generation_attempt = generation_attempt + 1 WHERE id = $1 RETURNING generation_attempt",
[child.id],
);
await tx.query(
"UPDATE atlas.operations SET status = 'queued', result = '{}'::jsonb, error_code = NULL, error_message = NULL, completed_at = NULL WHERE id = $1",
[child.id],
);
await this.outbox(
tx,
'media.ai_edit.requested',
child.id,
{
edit_id: child.id,
attempt: attempt.rows[0].generation_attempt,
},
randomUUID(),
);
}
}
}
return {
status: 200,
body: {
data: this.resource(await this.find(tx, productId, id)),
},
};
},
);
}
private async find(
tx: DatabaseTransaction,
productId: string,
id: string,
lock = false,
) {
const result = await tx.query<SuiteRow>(
`SELECT * FROM atlas.product_image_suites WHERE id = $1 AND product_id = $2 ${lock ? 'FOR UPDATE' : ''}`,
[id, productId],
);
if (!result.rows[0])
throw new ApiError(404, 'RESOURCE_NOT_FOUND', '套图任务不存在');
return result.rows[0];
}
private resource(row: SuiteRow): ImageSuite {
return {
id: row.id,
product_id: row.product_id,
variant_id: row.variant_id,
purpose: row.purpose,
status: row.status,
input: row.input,
shots: row.shots as ImageSuiteShot[],
items: [],
error_code: row.error_code,
created_at: iso(row.created_at),
completed_at: row.completed_at ? iso(row.completed_at) : null,
operation: {
id: row.id,
kind:
row.purpose === 'plan' ? 'image_suite_plan' : 'image_suite',
status: row.status === 'partial' ? 'failed' : row.status,
created_at: iso(row.created_at),
completed_at: row.completed_at ? iso(row.completed_at) : null,
error: row.error_code
? {
code: row.error_code,
message:
row.purpose === 'plan'
? (imageSuitePlanErrorMessages[
row.error_code
] ??
imageSuitePlanErrorMessages.AI_SUITE_PLAN_FAILED)
: '部分图片未完成,可重试失败项',
}
: null,
},
};
}
private async operation(
tx: DatabaseTransaction,
id: string,
kind: string,
userId: string,
) {
await tx.query(
`INSERT INTO atlas.operations (id, workspace_id, kind, resource_type, resource_id, created_by_user_id)
VALUES ($1, current_setting('app.workspace_id')::uuid, $2, $2, $1, $3)`,
[id, kind, userId],
);
}
private async outbox(
tx: DatabaseTransaction,
event: string,
id: string,
payload: Record<string, string | number>,
dedup = id,
) {
await tx.query(
`INSERT INTO atlas.outbox_events (workspace_id, aggregate_type, aggregate_id, event_type, event_payload, deduplication_key)
VALUES (current_setting('app.workspace_id')::uuid, 'image_suite', $1, $2, $3::jsonb, $4)`,
[id, event, JSON.stringify(payload), dedup],
);
}
}
+166
View File
@@ -0,0 +1,166 @@
import { Inject, Injectable } from '@nestjs/common';
import type { MediaAiEdit, MediaAiEditSettings } from '@atlas/contracts';
import type { Database } from '@atlas/database';
import { ApiError } from '../common/api-error.js';
import { IdempotencyService } from '../common/idempotency.service.js';
import { DATABASE } from '../database/database.module.js';
import { MediaService } from './media.service.js';
type EditRow = {
id: string;
status: MediaAiEdit['status'];
source_asset_id: string;
output_asset_id: string | null;
error_code: string | null;
failure_source?: MediaAiEdit['failure_source'];
failure_stage?: MediaAiEdit['failure_stage'];
failure_detail?: string | null;
content_type: string | null;
byte_size: string | null;
width_px: number | null;
height_px: number | null;
asset_created_at: Date | string | null;
};
@Injectable()
export class MediaAiEditService {
public constructor(
@Inject(DATABASE) private readonly database: Database,
@Inject(IdempotencyService) private readonly idempotency: IdempotencyService,
@Inject(MediaService) private readonly media: MediaService,
) {}
/** 创建任务前确认主图和辅图均为本工作空间已校验的正式素材。 */
public async create(workspaceId: string, userId: string, sourceId: string, key: string, settings: MediaAiEditSettings) {
return this.idempotency.run({
workspaceId, userId, method: 'POST', path: `/api/media-assets/${sourceId}/ai-edits`,
key, request: settings,
}, async (tx) => {
const allIds = [sourceId, ...settings.reference_asset_ids];
const product = await tx.query<{ id: string }>(
'SELECT id FROM atlas.products WHERE id = $1', [settings.product_id],
);
if (!product.rows[0]) throw new ApiError(404, 'RESOURCE_NOT_FOUND', '商品不存在');
if (new Set(allIds).size !== allIds.length)
throw new ApiError(422, 'INVALID_REFERENCE_IMAGE', '主图和辅助图不能重复');
const assets = await tx.query<{ id: string }>(
`SELECT id FROM atlas.media_assets WHERE id = ANY($1::uuid[]) AND deleted_at IS NULL
AND content_type IN ('image/jpeg', 'image/png', 'image/webp')
AND byte_size <= 20971520 AND width_px BETWEEN 1 AND 10000
AND height_px BETWEEN 1 AND 10000`, [allIds],
);
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 AND a.deleted_at IS NULL
WHERE e.id = $1`, [id],
);
return result.rows[0] ?? null;
});
if (!row) throw new ApiError(404, 'RESOURCE_NOT_FOUND', 'AI 编辑任务不存在');
const access = row.output_asset_id && row.content_type ? await this.media.accessUrl(workspaceId, row.output_asset_id) : null;
return {
id: row.id, status: row.status, source_asset_id: row.source_asset_id, error_code: row.error_code,
failure_source: row.status === 'failed' ? row.failure_source ?? null : null,
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 AND a.deleted_at IS NULL
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 } } };
});
}
}
@@ -0,0 +1,87 @@
export function detectImage(
bytes: Buffer,
maxDimension = 8192,
): {
contentType: 'image/png' | 'image/jpeg' | 'image/webp';
width: number;
height: number;
} | null {
if (
bytes.length >= 24 &&
bytes
.subarray(0, 8)
.equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]))
) {
return dimensions(
'image/png',
bytes.readUInt32BE(16),
bytes.readUInt32BE(20),
maxDimension,
);
}
if (
bytes.length >= 12 &&
bytes.subarray(0, 4).toString() === 'RIFF' &&
bytes.subarray(8, 12).toString() === 'WEBP'
) {
const type = bytes.subarray(12, 16).toString();
if (type === 'VP8X' && bytes.length >= 30) {
return dimensions(
'image/webp',
1 + bytes.readUIntLE(24, 3),
1 + bytes.readUIntLE(27, 3),
maxDimension,
);
}
if (
type === 'VP8 ' &&
bytes.length >= 30 &&
bytes[23] === 0x9d &&
bytes[24] === 0x01 &&
bytes[25] === 0x2a
) {
return dimensions(
'image/webp',
bytes.readUInt16LE(26) & 0x3fff,
bytes.readUInt16LE(28) & 0x3fff,
maxDimension,
);
}
}
if (bytes.length > 4 && bytes[0] === 0xff && bytes[1] === 0xd8) {
for (let offset = 2; offset + 9 < bytes.length;) {
if (bytes[offset] !== 0xff) return null;
const marker = bytes[offset + 1];
const length = bytes.readUInt16BE(offset + 2);
if (length < 2 || offset + 2 + length > bytes.length) return null;
if (
[
0xc0, 0xc1, 0xc2, 0xc3, 0xc5, 0xc6, 0xc7, 0xc9, 0xca, 0xcb,
0xcd, 0xce, 0xcf,
].includes(marker)
) {
return dimensions(
'image/jpeg',
bytes.readUInt16BE(offset + 7),
bytes.readUInt16BE(offset + 5),
maxDimension,
);
}
offset += 2 + length;
}
}
return null;
}
function dimensions(
contentType: 'image/png' | 'image/jpeg' | 'image/webp',
width: number,
height: number,
maxDimension: number,
) {
return width >= 1 &&
height >= 1 &&
width <= maxDimension &&
height <= maxDimension
? { contentType, width, height }
: null;
}
+237 -2
View File
@@ -1,11 +1,15 @@
import { ProductImageDeletionService } from './product-image-deletion.service.js';
import { import {
Body, Body,
Controller, Controller,
Headers, Headers,
Inject, Inject,
Get,
Param, Param,
Post, Post,
Query,
Req, Req,
Res,
} from '@nestjs/common'; } from '@nestjs/common';
import { import {
ApiBearerAuth, ApiBearerAuth,
@@ -14,9 +18,18 @@ import {
ApiTags, ApiTags,
} from '@nestjs/swagger'; } from '@nestjs/swagger';
import { import {
deleteProductImageRequestSchema,
completeMediaUploadIntentRequestSchema, completeMediaUploadIntentRequestSchema,
createMediaAccessUrlRequestSchema, createMediaAccessUrlRequestSchema,
createMediaUploadIntentRequestSchema, createMediaUploadIntentRequestSchema,
importProductImageRequestSchema,
createAspectRatioDerivativeRequestSchema,
createWatermarkDerivativeRequestSchema,
createMediaAiEditRequestSchema,
listProductImageTasksQuerySchema,
imageSuitePlanInputSchema,
createImageSuiteSchema,
type CreateMediaAccessUrlRequest,
type Workspace, type Workspace,
} from '@atlas/contracts'; } from '@atlas/contracts';
import { z } from 'zod'; import { z } from 'zod';
@@ -24,15 +37,236 @@ import { ApiError } from '../common/api-error.js';
import type { AuthenticatedRequest } from '../auth/auth.guard.js'; import type { AuthenticatedRequest } from '../auth/auth.guard.js';
import { CurrentWorkspace } from '../workspace/current-workspace.decorator.js'; import { CurrentWorkspace } from '../workspace/current-workspace.decorator.js';
import { MediaService } from './media.service.js'; import { MediaService } from './media.service.js';
import { AspectRatioDerivativeService } from './aspect-ratio-derivative.service.js';
import { WatermarkDerivativeService } from './watermark-derivative.service.js';
import { MediaAiEditService } from './media-ai-edit.service.js';
import { ProductImageTaskService } from './product-image-task.service.js';
import { ProductImageDerivativeHistoryService } from './product-image-derivative-history.service.js';
import { ImageSuiteService } from './image-suite.service.js';
@ApiTags('Media') @ApiTags('Media')
@ApiBearerAuth('access-token') @ApiBearerAuth('access-token')
@Controller() @Controller()
export class MediaController { export class MediaController {
public constructor( public constructor(
@Inject(ProductImageDeletionService) private readonly imageDeletions: ProductImageDeletionService,
@Inject(MediaService) private readonly media: MediaService, @Inject(MediaService) private readonly media: MediaService,
@Inject(AspectRatioDerivativeService) private readonly derivatives: AspectRatioDerivativeService,
@Inject(WatermarkDerivativeService) private readonly watermarkDerivatives: WatermarkDerivativeService,
@Inject(MediaAiEditService) private readonly aiEdits: MediaAiEditService,
@Inject(ProductImageTaskService) private readonly productImageTasks: ProductImageTaskService,
@Inject(ProductImageDerivativeHistoryService) private readonly derivativeHistory: ProductImageDerivativeHistoryService,
@Inject(ImageSuiteService) private readonly suites: ImageSuiteService,
) {} ) {}
@Post('products/:productId/image-deletions')
public async deleteProductImage(@Param('productId') productId: string, @Body() body: unknown,
@Headers('idempotency-key') key: string | undefined, @Req() request: AuthenticatedRequest,
@CurrentWorkspace() workspace: Workspace, @Res({ passthrough: true }) response: { status: (code: number) => void }) {
const result = await this.imageDeletions.create(workspace.id, request.auth.userId, uuid('product_id', productId), idempotencyKey(key), parse(deleteProductImageRequestSchema, body));
response.status(result.status); return result.body;
}
@Get('products/:productId/image-deletions')
public async pendingImageDeletions(@Param('productId') productId: string, @CurrentWorkspace() workspace: Workspace) {
return { data: await this.imageDeletions.pending(workspace.id, uuid('product_id', productId)) };
}
@Get('products/:productId/image-deletions/:deletionId')
public async readImageDeletion(@Param('productId') productId: string, @Param('deletionId') id: string, @CurrentWorkspace() workspace: Workspace) {
return { data: await this.imageDeletions.read(workspace.id, uuid('product_id', productId), uuid('deletion_id', id)) };
}
@Post('products/:productId/image-deletions/:deletionId/retry')
public async retryImageDeletion(@Param('productId') productId: string, @Param('deletionId') id: string,
@Headers('idempotency-key') key: string | undefined, @Req() request: AuthenticatedRequest, @CurrentWorkspace() workspace: Workspace,
@Res({ passthrough: true }) response: { status: (code: number) => void }) {
const result = await this.imageDeletions.retry(workspace.id, request.auth.userId, uuid('product_id', productId), uuid('deletion_id', id), idempotencyKey(key));
response.status(result.status); return result.body;
}
@Post('products/:productId/ai/suite-plans')
public async planSuite(@Param('productId') productId: string, @Body() body: unknown,
@Headers('idempotency-key') key: string | undefined, @Req() request: AuthenticatedRequest,
@CurrentWorkspace() workspace: Workspace, @Res({ passthrough: true }) response: { status: (code: number) => void }) {
const result = await this.suites.create(workspace.id, request.auth.userId, uuid('product_id', productId), idempotencyKey(key), parse(imageSuitePlanInputSchema, body), 'plan');
response.status(result.status); return result.body;
}
@Post('products/:productId/ai/image-suites')
public async generateSuite(@Param('productId') productId: string, @Body() body: unknown,
@Headers('idempotency-key') key: string | undefined, @Req() request: AuthenticatedRequest,
@CurrentWorkspace() workspace: Workspace, @Res({ passthrough: true }) response: { status: (code: number) => void }) {
const result = await this.suites.create(workspace.id, request.auth.userId, uuid('product_id', productId), idempotencyKey(key), parse(createImageSuiteSchema, body), 'generate');
response.status(result.status); return result.body;
}
@Get('products/:productId/ai/image-suites')
public async listSuites(@Param('productId') productId: string, @Query() query: unknown, @CurrentWorkspace() workspace: Workspace) {
return this.suites.list(workspace.id, uuid('product_id', productId), parse(listProductImageTasksQuerySchema, query));
}
@Get('products/:productId/ai/image-suites/:suiteId')
public async readSuite(@Param('productId') productId: string, @Param('suiteId') suiteId: string, @CurrentWorkspace() workspace: Workspace) {
return { data: await this.suites.read(workspace.id, uuid('product_id', productId), uuid('suite_id', suiteId)) };
}
@Post('products/:productId/ai/image-suites/:suiteId/cancel')
public async cancelSuite(@Param('productId') productId: string, @Param('suiteId') suiteId: string,
@Headers('idempotency-key') key: string | undefined, @Req() request: AuthenticatedRequest, @CurrentWorkspace() workspace: Workspace) {
return (await this.suites.action(workspace.id, request.auth.userId, uuid('product_id', productId), uuid('suite_id', suiteId), idempotencyKey(key), 'cancel')).body;
}
@Post('products/:productId/ai/image-suites/:suiteId/retry')
public async retrySuite(@Param('productId') productId: string, @Param('suiteId') suiteId: string,
@Headers('idempotency-key') key: string | undefined, @Req() request: AuthenticatedRequest, @CurrentWorkspace() workspace: Workspace) {
return (await this.suites.action(workspace.id, request.auth.userId, uuid('product_id', productId), uuid('suite_id', suiteId), idempotencyKey(key), 'retry')).body;
}
@Get('products/:productId/image-derivatives')
public async listProductImageDerivatives(
@Param('productId') productId: string,
@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, @Query('product_id') productId: string) {
return { data: await this.media.productUploads(workspace.id, uuid('product_id', productId)) };
}
@Get('media-assets/product-imports')
@ApiOkResponse({ description: '当前商品最近导入的网络图片及短时预览地址' })
public async productImports(@CurrentWorkspace() workspace: Workspace, @Query('product_id') productId: string) {
return { data: await this.media.productImports(workspace.id, uuid('product_id', productId)) };
}
@Post('media-assets/product-imports')
@ApiCreatedResponse({ description: '已下载并校验网络图片,创建正式素材' })
public async importProductImage(
@Body() body: unknown,
@Headers('idempotency-key') key: string | undefined,
@Req() request: AuthenticatedRequest,
@CurrentWorkspace() workspace: Workspace,
) {
return (await this.media.importProductImage(
workspace.id,
request.auth.userId,
idempotencyKey(key),
parse(importProductImageRequestSchema, body),
)).body;
}
@Post('media/upload-intents') @Post('media/upload-intents')
@ApiCreatedResponse({ description: '已创建一次性预签名上传意图' }) @ApiCreatedResponse({ description: '已创建一次性预签名上传意图' })
public async createIntent( public async createIntent(
@@ -72,17 +306,18 @@ export class MediaController {
} }
@Post('media-assets/:assetId/access-urls') @Post('media-assets/:assetId/access-urls')
@ApiOkResponse({ description: '已签发短时预览 URL' }) @ApiOkResponse({ description: '已签发短时预览或下载 URL' })
public async accessUrl( public async accessUrl(
@Param('assetId') assetId: string, @Param('assetId') assetId: string,
@Body() body: unknown, @Body() body: unknown,
@CurrentWorkspace() workspace: Workspace, @CurrentWorkspace() workspace: Workspace,
) { ) {
parse(createMediaAccessUrlRequestSchema, body); const input = parse(createMediaAccessUrlRequestSchema, body) as CreateMediaAccessUrlRequest;
return { return {
data: await this.media.accessUrl( data: await this.media.accessUrl(
workspace.id, workspace.id,
uuid('asset_id', assetId), uuid('asset_id', assetId),
input.purpose,
), ),
}; };
} }
+8 -1
View File
@@ -1,11 +1,18 @@
import { ProductImageDeletionService } from './product-image-deletion.service.js';
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { IdempotencyService } from '../common/idempotency.service.js'; import { IdempotencyService } from '../common/idempotency.service.js';
import { MediaController } from './media.controller.js'; import { MediaController } from './media.controller.js';
import { MediaService } from './media.service.js'; import { MediaService } from './media.service.js';
import { AspectRatioDerivativeService } from './aspect-ratio-derivative.service.js';
import { WatermarkDerivativeService } from './watermark-derivative.service.js';
import { MediaAiEditService } from './media-ai-edit.service.js';
import { ProductImageTaskService } from './product-image-task.service.js';
import { ProductImageDerivativeHistoryService } from './product-image-derivative-history.service.js';
import { ImageSuiteService } from './image-suite.service.js';
@Module({ @Module({
controllers: [MediaController], controllers: [MediaController],
providers: [IdempotencyService, MediaService], providers: [ProductImageDeletionService, IdempotencyService, MediaService, AspectRatioDerivativeService, WatermarkDerivativeService, MediaAiEditService, ProductImageTaskService, ProductImageDerivativeHistoryService, ImageSuiteService],
exports: [MediaService], exports: [MediaService],
}) })
export class MediaModule {} export class MediaModule {}
+248 -98
View File
@@ -1,4 +1,4 @@
import { createHash, randomUUID } from 'node:crypto'; import { createHash, createHmac, randomUUID } from 'node:crypto';
import { import {
CreateBucketCommand, CreateBucketCommand,
GetObjectCommand, GetObjectCommand,
@@ -11,16 +11,21 @@ import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
import { Inject, Injectable } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import type { import type {
CompleteMediaUploadIntentRequest, CompleteMediaUploadIntentRequest,
CreateMediaAccessUrlRequest,
CreateMediaUploadIntentRequest, CreateMediaUploadIntentRequest,
ImportProductImageRequest,
} from '@atlas/contracts'; } from '@atlas/contracts';
import type { Database, DatabaseTransaction } from '@atlas/database'; import type { Database, DatabaseTransaction } from '@atlas/database';
import { ApiError } from '../common/api-error.js'; import { ApiError } from '../common/api-error.js';
import { IdempotencyService } from '../common/idempotency.service.js'; import { IdempotencyService } from '../common/idempotency.service.js';
import { APP_CONFIG, type AppConfig } from '../config/app-config.module.js'; import { APP_CONFIG, type AppConfig } from '../config/app-config.module.js';
import { DATABASE } from '../database/database.module.js'; import { DATABASE } from '../database/database.module.js';
import { detectImage } from './media-image-validation.js';
import { downloadProductImage } from './product-image-download.js';
type IntentRow = { type IntentRow = {
id: string; id: string;
purpose: 'watermark_image' | 'product_image';
storage_provider: string; storage_provider: string;
bucket: string; bucket: string;
object_key: string; object_key: string;
@@ -43,6 +48,7 @@ type AssetRow = {
@Injectable() @Injectable()
export class MediaService { export class MediaService {
private readonly s3: S3Client; private readonly s3: S3Client;
private readonly legacyMinioS3: S3Client | null;
private bucketReady: Promise<void> | null = null; private bucketReady: Promise<void> | null = null;
public constructor( public constructor(
@@ -53,13 +59,29 @@ export class MediaService {
) { ) {
this.s3 = new S3Client({ this.s3 = new S3Client({
endpoint: config.OBJECT_STORAGE_ENDPOINT, endpoint: config.OBJECT_STORAGE_ENDPOINT,
region: 'us-east-1', region: config.OBJECT_STORAGE_REGION,
forcePathStyle: config.objectStorageForcePathStyle, forcePathStyle: config.objectStorageForcePathStyle,
credentials: { credentials: {
accessKeyId: config.objectStorageAccessKey, accessKeyId: config.objectStorageAccessKey,
secretAccessKey: config.objectStorageSecretKey, secretAccessKey: config.objectStorageSecretKey,
}, },
}); });
// 开发环境保留旧 MinIO 素材预览,历史素材正式发布前仍需迁移到七牛。
this.legacyMinioS3 =
config.NODE_ENV !== 'production' &&
config.OBJECT_STORAGE_PROVIDER === 'qiniu_kodo' &&
config.MINIO_ROOT_USER &&
config.MINIO_ROOT_PASSWORD
? new S3Client({
endpoint: config.LEGACY_MINIO_ENDPOINT,
region: 'us-east-1',
forcePathStyle: true,
credentials: {
accessKeyId: config.MINIO_ROOT_USER,
secretAccessKey: config.MINIO_ROOT_PASSWORD,
},
})
: null;
} }
public async createIntent( public async createIntent(
@@ -78,18 +100,23 @@ export class MediaService {
request: input, request: input,
}, },
async (tx) => { async (tx) => {
if (input.purpose === 'product_image') {
if (!input.product_id) throw new ApiError(422, 'VALIDATION_FAILED', '商品图片必须绑定商品');
await this.requireProduct(tx, input.product_id);
}
await this.ensureBucket(); await this.ensureBucket();
const id = randomUUID(); const id = randomUUID();
const objectKey = `uploads/${workspaceId}/watermark-images/${id}`; const objectKey = `uploads/${workspaceId}/${input.purpose === 'product_image' ? 'product-images' : 'watermark-images'}/${id}`;
const expiresAt = new Date(Date.now() + 10 * 60_000); const expiresAt = new Date(Date.now() + 10 * 60_000);
await tx.query( await tx.query(
`INSERT INTO atlas.media_upload_intents `INSERT INTO atlas.media_upload_intents
(id, workspace_id, purpose, storage_provider, bucket, object_key, expected_content_type, (id, workspace_id, purpose, storage_provider, bucket, object_key, expected_content_type,
expected_byte_size, expected_checksum_sha256, expires_at, created_by_user_id) expected_byte_size, expected_checksum_sha256, expires_at, created_by_user_id, product_id)
VALUES ($1, current_setting('app.workspace_id')::uuid, 'watermark_image', 'local_minio', VALUES ($1, current_setting('app.workspace_id')::uuid, $2, $10,
$2, $3, $4, $5, $6, $7, $8)`, $3, $4, $5, $6, $7, $8, $9, $11)`,
[ [
id, id,
input.purpose,
this.config.OBJECT_STORAGE_BUCKET, this.config.OBJECT_STORAGE_BUCKET,
objectKey, objectKey,
input.content_type, input.content_type,
@@ -97,6 +124,8 @@ export class MediaService {
input.checksum_sha256, input.checksum_sha256,
expiresAt, expiresAt,
userId, userId,
this.config.OBJECT_STORAGE_PROVIDER,
input.product_id ?? null,
], ],
); );
const url = await getSignedUrl( const url = await getSignedUrl(
@@ -213,7 +242,7 @@ export class MediaService {
`INSERT INTO atlas.media_assets `INSERT INTO atlas.media_assets
(id, workspace_id, storage_provider, bucket, object_key, content_type, byte_size, (id, workspace_id, storage_provider, bucket, object_key, content_type, byte_size,
checksum_sha256, width_px, height_px) checksum_sha256, width_px, height_px)
VALUES ($1, current_setting('app.workspace_id')::uuid, 'local_minio', $2, $3, $4, $5, $6, $7, $8)`, VALUES ($1, current_setting('app.workspace_id')::uuid, $9, $2, $3, $4, $5, $6, $7, $8)`,
[ [
assetId, assetId,
intent.bucket, intent.bucket,
@@ -223,6 +252,7 @@ export class MediaService {
inspected.checksum, inspected.checksum,
inspected.width, inspected.width,
inspected.height, inspected.height,
intent.storage_provider,
], ],
); );
} }
@@ -258,36 +288,183 @@ export class MediaService {
} }
} }
public async accessUrl(workspaceId: string, assetId: string) { public async accessUrl(
workspaceId: string,
assetId: string,
purpose: CreateMediaAccessUrlRequest['purpose'] = 'preview',
) {
return this.database.workspaceTransaction(workspaceId, async (tx) => { return this.database.workspaceTransaction(workspaceId, async (tx) => {
const asset = await tx.query<{ const asset = await tx.query<{
storage_provider: string;
bucket: string; bucket: string;
object_key: string; object_key: string;
content_type: string;
}>( }>(
`SELECT bucket, object_key FROM atlas.media_assets WHERE id = $1`, `SELECT storage_provider, bucket, object_key, content_type FROM atlas.media_assets WHERE id = $1 AND deleted_at IS NULL`,
[assetId], [assetId],
); );
if (!asset.rows[0]) if (!asset.rows[0])
throw new ApiError(404, 'RESOURCE_NOT_FOUND', '素材不存在'); throw new ApiError(404, 'RESOURCE_NOT_FOUND', '素材不存在');
const extension = ({
'image/jpeg': 'jpg',
'image/png': 'png',
'image/webp': 'webp',
} as Record<string, string>)[asset.rows[0].content_type] ?? 'bin';
const expiresAt = new Date(Date.now() + 10 * 60_000); const expiresAt = new Date(Date.now() + 10 * 60_000);
const url = await getSignedUrl( const url = await this.signedReadUrl(
this.s3, asset.rows[0],
new GetObjectCommand({ expiresAt,
Bucket: asset.rows[0].bucket, purpose === 'download' ? `atlas-image-${assetId}.${extension}` : undefined,
Key: asset.rows[0].object_key,
}),
{ expiresIn: 600 },
); );
return { url, expires_at: expiresAt.toISOString() }; return { url, expires_at: expiresAt.toISOString() };
}); });
} }
/** 返回当前商品最近上传的素材,预览 URL 仅在本次响应中签发。 */
public async productUploads(workspaceId: string, productId: string) {
return this.database.workspaceTransaction(workspaceId, async (tx) => {
const uploads = await tx.query<AssetRow & { storage_provider: string; bucket: string; object_key: string }>(
`SELECT a.id, a.storage_provider, a.content_type, a.byte_size::text, a.width_px, a.height_px,
a.created_at, a.bucket, a.object_key, max(i.completed_at) AS uploaded_at
FROM atlas.media_assets a
JOIN atlas.media_upload_intents i ON i.asset_id = a.id
WHERE i.purpose = 'product_image' AND i.status = 'ready' AND i.product_id = $1 AND a.deleted_at IS NULL
GROUP BY a.id
ORDER BY uploaded_at DESC, a.id DESC
LIMIT 100`,
[productId],
);
const expiresAt = new Date(Date.now() + 10 * 60_000).toISOString();
return Promise.all(uploads.rows.map(async (asset) => ({
id: asset.id,
content_type: asset.content_type,
byte_size: Number(asset.byte_size),
width_px: asset.width_px,
height_px: asset.height_px,
created_at: asset.created_at instanceof Date
? asset.created_at.toISOString()
: asset.created_at,
preview_url: await this.signedReadUrl(asset, new Date(expiresAt)),
preview_url_expires_at: expiresAt,
})));
});
}
/** 网络图片先做公网下载与内容校验,再登记为工作空间正式素材。 */
public async importProductImage(
workspaceId: string,
userId: string,
key: string,
input: ImportProductImageRequest,
) {
return this.idempotency.run(
{ workspaceId, userId, method: 'POST', path: '/api/media-assets/product-imports', key, request: input },
async (tx) => {
await this.requireProduct(tx, input.product_id);
const image = await downloadProductImage(input.url);
const checksum = createHash('sha256').update(image.bytes).digest('hex');
const existing = await tx.query<{ id: string }>(
'SELECT id FROM atlas.media_assets WHERE checksum_sha256 = $1',
[checksum],
);
let assetId = existing.rows[0]?.id ?? randomUUID();
if (!existing.rows[0]) {
await this.ensureBucket();
const objectKey = `product-imports/${workspaceId}/${checksum}/${randomUUID()}`;
await this.s3.send(new PutObjectCommand({
Bucket: this.config.OBJECT_STORAGE_BUCKET,
Key: objectKey,
Body: image.bytes,
ContentType: image.contentType,
}));
const inserted = await tx.query<{ id: string }>(
`INSERT INTO atlas.media_assets
(id, workspace_id, storage_provider, bucket, object_key, content_type,
byte_size, checksum_sha256, width_px, height_px)
VALUES ($1, current_setting('app.workspace_id')::uuid, $9,
$2, $3, $4, $5, $6, $7, $8)
ON CONFLICT (workspace_id, checksum_sha256) DO NOTHING
RETURNING id`,
[assetId, this.config.OBJECT_STORAGE_BUCKET, objectKey, image.contentType,
image.bytes.length, checksum, image.width, image.height, this.config.OBJECT_STORAGE_PROVIDER],
);
assetId = inserted.rows[0]?.id ?? (await tx.query<{ id: string }>(
'SELECT id FROM atlas.media_assets WHERE checksum_sha256 = $1',
[checksum],
)).rows[0].id;
}
await tx.query(
`INSERT INTO atlas.product_image_imports
(workspace_id, source_url, asset_id, created_by_user_id, product_id)
VALUES (current_setting('app.workspace_id')::uuid, $1, $2, $3, $4)
ON CONFLICT (workspace_id, product_id, source_url)
DO UPDATE SET asset_id = EXCLUDED.asset_id, updated_at = now()`,
[input.url, assetId, userId, input.product_id],
);
const asset = await this.requireAsset(tx, assetId);
const stored = await tx.query<{ storage_provider: string; bucket: string; object_key: string }>(
'SELECT storage_provider, bucket, object_key FROM atlas.media_assets WHERE id = $1',
[assetId],
);
const expiresAt = new Date(Date.now() + 10 * 60_000);
const previewUrl = await this.signedReadUrl(stored.rows[0], expiresAt);
return {
status: 201,
body: { data: { ...asset, source_url: input.url,
preview_url: previewUrl, preview_url_expires_at: expiresAt.toISOString() } },
};
},
);
}
public async productImports(workspaceId: string, productId: string) {
return this.database.workspaceTransaction(workspaceId, async (tx) => {
const imports = await tx.query<AssetRow & {
storage_provider: string;
bucket: string;
object_key: string;
source_url: string;
}>(
`SELECT a.id, a.storage_provider, a.content_type, a.byte_size::text, a.width_px, a.height_px,
a.created_at, a.bucket, a.object_key, i.source_url
FROM (
SELECT DISTINCT ON (asset_id) asset_id, source_url, updated_at, id
FROM atlas.product_image_imports
WHERE product_id = $1
ORDER BY asset_id, updated_at DESC, id DESC
) i
JOIN atlas.media_assets a ON a.id = i.asset_id AND a.deleted_at IS NULL
ORDER BY i.updated_at DESC, i.id DESC
LIMIT 100`,
[productId],
);
const expiresAt = new Date(Date.now() + 10 * 60_000).toISOString();
return Promise.all(imports.rows.map(async (asset) => ({
id: asset.id,
content_type: asset.content_type,
byte_size: Number(asset.byte_size),
width_px: asset.width_px,
height_px: asset.height_px,
created_at: asset.created_at instanceof Date
? asset.created_at.toISOString() : asset.created_at,
source_url: asset.source_url,
preview_url: await this.signedReadUrl(asset, new Date(expiresAt)),
preview_url_expires_at: expiresAt,
})));
});
}
private async requireProduct(tx: DatabaseTransaction, productId: string) {
const result = await tx.query('SELECT id FROM atlas.products WHERE id = $1', [productId]);
if (!result.rows.length) throw new ApiError(404, 'RESOURCE_NOT_FOUND', '商品不存在');
}
private async lockIntent( private async lockIntent(
tx: DatabaseTransaction, tx: DatabaseTransaction,
intentId: string, intentId: string,
): Promise<IntentRow> { ): Promise<IntentRow> {
const rows = await tx.query<IntentRow>( const rows = await tx.query<IntentRow>(
`SELECT id, storage_provider, bucket, object_key, expected_content_type, expected_byte_size, `SELECT id, purpose, storage_provider, bucket, object_key, expected_content_type, expected_byte_size,
expected_checksum_sha256, status, expires_at, asset_id expected_checksum_sha256, status, expires_at, asset_id
FROM atlas.media_upload_intents WHERE id = $1 FOR UPDATE`, FROM atlas.media_upload_intents WHERE id = $1 FOR UPDATE`,
[intentId], [intentId],
@@ -300,7 +477,7 @@ export class MediaService {
private async requireAsset(tx: DatabaseTransaction, assetId: string) { private async requireAsset(tx: DatabaseTransaction, assetId: string) {
const rows = await tx.query<AssetRow>( const rows = await tx.query<AssetRow>(
`SELECT id, content_type, byte_size, width_px, height_px, created_at `SELECT id, content_type, byte_size, width_px, height_px, created_at
FROM atlas.media_assets WHERE id = $1`, FROM atlas.media_assets WHERE id = $1 AND deleted_at IS NULL`,
[assetId], [assetId],
); );
if (!rows.rows[0]) if (!rows.rows[0])
@@ -335,7 +512,8 @@ export class MediaService {
'尚未找到已上传文件', '尚未找到已上传文件',
); );
} }
if (!head.ContentLength || head.ContentLength > 5 * 1024 * 1024) { const byteLimit = intent.purpose === 'product_image' ? 20 * 1024 * 1024 : 5 * 1024 * 1024;
if (!head.ContentLength || head.ContentLength > byteLimit) {
throw new ApiError( throw new ApiError(
422, 422,
'UPLOAD_VALIDATION_FAILED', 'UPLOAD_VALIDATION_FAILED',
@@ -370,7 +548,7 @@ export class MediaService {
for await (const chunk of object.Body as AsyncIterable<Uint8Array>) { for await (const chunk of object.Body as AsyncIterable<Uint8Array>) {
byteSize += chunk.byteLength; byteSize += chunk.byteLength;
// HeadObject 和 GetObject 之间对象可能被替换;读取时也必须有硬上限。 // HeadObject 和 GetObject 之间对象可能被替换;读取时也必须有硬上限。
if (byteSize > 5 * 1024 * 1024) { if (byteSize > byteLimit) {
throw new ApiError( throw new ApiError(
422, 422,
'UPLOAD_VALIDATION_FAILED', 'UPLOAD_VALIDATION_FAILED',
@@ -380,7 +558,7 @@ export class MediaService {
chunks.push(Buffer.from(chunk)); chunks.push(Buffer.from(chunk));
} }
const bytes = Buffer.concat(chunks, byteSize); const bytes = Buffer.concat(chunks, byteSize);
const detected = detectImage(bytes); const detected = detectImage(bytes, intent.purpose === 'product_image' ? 10_000 : 8192);
if ( if (
!detected || !detected ||
detected.contentType !== intent.expected_content_type detected.contentType !== intent.expected_content_type
@@ -400,6 +578,55 @@ export class MediaService {
}; };
} }
/** 七牛图片从绑定域名签发短时下载凭证;MinIO 素材仍使用 S3 预签名。 */
private async signedReadUrl(
asset: { storage_provider: string; bucket: string; object_key: string },
expiresAt: Date,
downloadName?: string,
): Promise<string> {
if (
asset.storage_provider === 'qiniu_kodo' &&
asset.bucket === this.config.OBJECT_STORAGE_BUCKET &&
this.config.qiniuDownloadOrigin
) {
const origin = new URL(this.config.qiniuDownloadOrigin).origin;
const path = asset.object_key.split('/').map(encodeURIComponent).join('/');
const deadline = Math.floor(expiresAt.getTime() / 1000);
const attachment = downloadName
? `attname=${encodeURIComponent(downloadName)}&`
: '';
const unsignedUrl = `${origin}/${path}?${attachment}e=${deadline}`;
const signature = createHmac('sha1', this.config.qiniuDownloadSecretKey)
.update(unsignedUrl)
.digest('base64')
.replace(/\+/g, '-')
.replace(/\//g, '_');
return `${unsignedUrl}&token=${this.config.qiniuDownloadAccessKey}:${signature}`;
}
return getSignedUrl(
this.storageForPreview(asset),
new GetObjectCommand({
Bucket: asset.bucket,
Key: asset.object_key,
...(downloadName ? {
ResponseContentDisposition: `attachment; filename="${downloadName}"`,
} : {}),
}),
{ expiresIn: Math.max(1, Math.floor((expiresAt.getTime() - Date.now()) / 1000)) },
);
}
private storageForPreview(asset: { storage_provider: string; bucket: string }): S3Client {
if (
asset.storage_provider === 'local_minio' &&
asset.bucket === 'atlas-assets' &&
this.legacyMinioS3
) {
return this.legacyMinioS3;
}
return this.s3;
}
private async ensureBucket() { private async ensureBucket() {
if (!this.bucketReady) if (!this.bucketReady)
this.bucketReady = (async () => { this.bucketReady = (async () => {
@@ -458,80 +685,3 @@ function apiErrorCode(error: unknown): string | undefined {
? apiError.code ? apiError.code
: undefined; : undefined;
} }
function detectImage(bytes: Buffer): {
contentType: 'image/png' | 'image/jpeg' | 'image/webp';
width: number;
height: number;
} | null {
if (
bytes.length >= 24 &&
bytes
.subarray(0, 8)
.equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]))
) {
return dimensions(
'image/png',
bytes.readUInt32BE(16),
bytes.readUInt32BE(20),
);
}
if (
bytes.length >= 12 &&
bytes.subarray(0, 4).toString() === 'RIFF' &&
bytes.subarray(8, 12).toString() === 'WEBP'
) {
const type = bytes.subarray(12, 16).toString();
if (type === 'VP8X' && bytes.length >= 30) {
return dimensions(
'image/webp',
1 + bytes.readUIntLE(24, 3),
1 + bytes.readUIntLE(27, 3),
);
}
if (
type === 'VP8 ' &&
bytes.length >= 30 &&
bytes[23] === 0x9d &&
bytes[24] === 0x01 &&
bytes[25] === 0x2a
) {
return dimensions(
'image/webp',
bytes.readUInt16LE(26) & 0x3fff,
bytes.readUInt16LE(28) & 0x3fff,
);
}
}
if (bytes.length > 4 && bytes[0] === 0xff && bytes[1] === 0xd8) {
for (let offset = 2; offset + 9 < bytes.length;) {
if (bytes[offset] !== 0xff) return null;
const marker = bytes[offset + 1];
const length = bytes.readUInt16BE(offset + 2);
if (length < 2 || offset + 2 + length > bytes.length) return null;
if (
[
0xc0, 0xc1, 0xc2, 0xc3, 0xc5, 0xc6, 0xc7, 0xc9, 0xca, 0xcb,
0xcd, 0xce, 0xcf,
].includes(marker)
) {
return dimensions(
'image/jpeg',
bytes.readUInt16BE(offset + 7),
bytes.readUInt16BE(offset + 5),
);
}
offset += 2 + length;
}
}
return null;
}
function dimensions(
contentType: 'image/png' | 'image/jpeg' | 'image/webp',
width: number,
height: number,
) {
return width >= 1 && height >= 1 && width <= 8192 && height <= 8192
? { contentType, width, height }
: null;
}
@@ -0,0 +1,125 @@
import { randomUUID } from 'node:crypto';
import { Inject, Injectable } from '@nestjs/common';
import type { DeleteProductImageRequest, ProductImageDeletion, Operation } from '@atlas/contracts';
import type { Database, DatabaseTransaction } from '@atlas/database';
import { ApiError } from '../common/api-error.js';
import { IdempotencyService } from '../common/idempotency.service.js';
import { DATABASE } from '../database/database.module.js';
type StoredAsset = { id: string; deleted_at: string | null; storage_provider: string; bucket: string; object_key: string };
@Injectable()
export class ProductImageDeletionService {
constructor(@Inject(DATABASE) private readonly database: Database,
@Inject(IdempotencyService) private readonly idempotency: IdempotencyService) {}
/** 先锁素材和校验全部引用,再在同一事务禁用候选并登记可靠清理任务。 */
async create(workspaceId: string, userId: string, productId: string, key: string, input: DeleteProductImageRequest) {
return this.idempotency.run({ workspaceId, userId, method: 'POST', path: `/api/products/${productId}/image-deletions`, key, request: input }, async tx => {
const product = await tx.query('SELECT id FROM atlas.products WHERE id = $1', [productId]);
if (!product.rows.length) throw new ApiError(404, 'RESOURCE_NOT_FOUND', '商品不存在');
let assetId = input.asset_id ?? null;
if (input.collection_image_id) {
const source = await tx.query<{ asset_id: string | null; status: string }>(
'SELECT asset_id, status FROM atlas.collection_asset_transfers WHERE id = $1 AND product_id = $2 FOR UPDATE', [input.collection_image_id, productId]);
if (!source.rows.length) throw new ApiError(404, 'RESOURCE_NOT_FOUND', '采集图片不存在');
if (source.rows[0].status === 'running') throw new ApiError(409, 'MEDIA_ASSET_IN_USE', '图片正在转存,请完成后再删除');
assetId = source.rows[0].asset_id;
}
const existing = await tx.query<ProductImageDeletion>(
`SELECT id, status, asset_id, collection_image_id, error_code FROM atlas.product_image_deletions
WHERE product_id = $1 AND (asset_id = $2 OR collection_image_id = $3)`, [productId, assetId, input.collection_image_id ?? null]);
if (existing.rows.length) return { status: 202, body: { data: await this.withOperation(tx, existing.rows[0]) } };
let objects: Array<{ storage_provider: string; bucket: string; object_key: string }> = [];
if (assetId) {
const result = await tx.query<StoredAsset>('SELECT id, deleted_at, storage_provider, bucket, object_key FROM atlas.media_assets WHERE id = $1 FOR UPDATE', [assetId]);
const asset = result.rows[0];
if (!asset || asset.deleted_at) throw new ApiError(404, 'RESOURCE_NOT_FOUND', '图片不存在或已删除');
const owned = await tx.query(`SELECT 1 FROM atlas.media_upload_intents WHERE asset_id = $1 AND product_id = $2 AND purpose = 'product_image' AND status = 'ready'
UNION ALL SELECT 1 FROM atlas.product_image_imports WHERE asset_id = $1 AND product_id = $2
UNION ALL SELECT 1 FROM atlas.collection_asset_transfers WHERE asset_id = $1 AND product_id = $2
UNION ALL SELECT 1 FROM atlas.media_ai_edits WHERE output_asset_id = $1 AND product_id = $2
UNION ALL SELECT 1 FROM atlas.media_aspect_ratio_derivatives WHERE output_asset_id = $1 AND product_id = $2
UNION ALL SELECT 1 FROM atlas.media_watermark_derivatives WHERE output_asset_id = $1 AND product_id = $2 LIMIT 1`, [assetId, productId]);
if (!owned.rows.length) throw new ApiError(404, 'RESOURCE_NOT_FOUND', '图片不属于当前商品');
await this.requireUnused(tx, assetId, productId);
const uploads = await tx.query<{ storage_provider: string; bucket: string; object_key: string }>(
"SELECT storage_provider, bucket, object_key FROM atlas.media_upload_intents WHERE asset_id = $1 AND status = 'ready'", [assetId]);
objects = [asset, ...uploads.rows].map(({storage_provider, bucket, object_key}) => ({storage_provider, bucket, object_key}));
objects = objects.filter((v, i) => objects.findIndex(o => o.storage_provider === v.storage_provider && o.bucket === v.bucket && o.object_key === v.object_key) === i);
await tx.query('UPDATE atlas.media_assets SET deleted_at = now(), deleted_checksum_sha256 = checksum_sha256, checksum_sha256 = NULL WHERE id = $1', [assetId]);
await tx.query('UPDATE atlas.collection_asset_transfers SET deleted_at = now() WHERE asset_id = $1 AND product_id = $2', [assetId, productId]);
}
if (input.collection_image_id) await tx.query(
"UPDATE atlas.collection_asset_transfers SET deleted_at = now(), status = CASE WHEN status IN ('queued', 'pending_selection') THEN 'cancelled'::atlas.processing_status ELSE status END WHERE id = $1", [input.collection_image_id]);
const id = randomUUID();
await tx.query(`INSERT INTO atlas.operations (id, workspace_id, kind, resource_type, resource_id, created_by_user_id, status, completed_at)
VALUES ($1, current_setting('app.workspace_id')::uuid, 'product_image_deletion', 'product_image_deletion', $1, $2,
$3::atlas.operation_status, CASE WHEN $3 = 'succeeded' THEN now() ELSE NULL END)`, [id, userId, objects.length ? 'queued' : 'succeeded']);
const deletion = await tx.query<ProductImageDeletion>(`INSERT INTO atlas.product_image_deletions
(id, workspace_id, product_id, asset_id, collection_image_id, objects, status, completed_at)
VALUES ($1, current_setting('app.workspace_id')::uuid, $2, $3, $4, $5::jsonb, $6,
CASE WHEN $6 = 'succeeded' THEN now() ELSE NULL END)
RETURNING id, status, asset_id, collection_image_id, error_code`,
[id, productId, assetId, input.collection_image_id ?? null, JSON.stringify(objects), objects.length ? 'queued' : 'succeeded']);
if (objects.length) await this.enqueue(tx, id, randomUUID());
return { status: 202, body: { data: await this.withOperation(tx, deletion.rows[0]) } };
});
}
async pending(workspaceId: string, productId: string) {
return this.database.workspaceTransaction(workspaceId, async tx => {
const rows = await tx.query<ProductImageDeletion>("SELECT id, status, asset_id, collection_image_id, error_code FROM atlas.product_image_deletions WHERE product_id = $1 AND status <> 'succeeded' ORDER BY created_at DESC LIMIT 100", [productId]);
return Promise.all(rows.rows.map(row => this.withOperation(tx, row)));
});
}
async read(workspaceId: string, productId: string, id: string) {
return this.database.workspaceTransaction(workspaceId, async tx => {
const result = await tx.query<ProductImageDeletion>('SELECT id, status, asset_id, collection_image_id, error_code FROM atlas.product_image_deletions WHERE id = $1 AND product_id = $2', [id, productId]);
if (!result.rows.length) throw new ApiError(404, 'RESOURCE_NOT_FOUND', '图片删除记录不存在');
return this.withOperation(tx, result.rows[0]);
});
}
async retry(workspaceId: string, userId: string, productId: string, id: string, key: string) {
return this.idempotency.run({workspaceId, userId, method: 'POST', path: `/api/products/${productId}/image-deletions/${id}/retry`, key, request: {}}, async tx => {
const row = await tx.query<ProductImageDeletion>('SELECT id, status, asset_id, collection_image_id, error_code FROM atlas.product_image_deletions WHERE id = $1 AND product_id = $2 FOR UPDATE', [id, productId]);
if (!row.rows.length) throw new ApiError(404, 'RESOURCE_NOT_FOUND', '图片删除记录不存在');
if (row.rows[0].status === 'failed') {
await tx.query("UPDATE atlas.product_image_deletions SET status = 'queued', error_code = NULL, completed_at = NULL WHERE id = $1", [id]);
await tx.query("UPDATE atlas.operations SET status = 'queued', error_code = NULL, error_message = NULL, completed_at = NULL WHERE id = $1", [id]);
await this.enqueue(tx, id, randomUUID());
row.rows[0].status = 'queued'; row.rows[0].error_code = null;
}
return {status: 202, body: {data: await this.withOperation(tx, row.rows[0])}};
});
}
private async withOperation(tx: DatabaseTransaction, row: ProductImageDeletion): Promise<ProductImageDeletion> {
const result = await tx.query<Operation>(`SELECT id, kind, status, created_at::text, completed_at::text,
CASE WHEN error_code IS NULL THEN NULL ELSE jsonb_build_object('code', error_code, 'message', error_message) END AS error
FROM atlas.operations WHERE id = $1`, [row.id]);
const operation = result.rows[0];
return { ...row, operation: { ...operation,
created_at: new Date(operation.created_at).toISOString(),
completed_at: operation.completed_at ? new Date(operation.completed_at).toISOString() : null,
} };
}
private async enqueue(tx: DatabaseTransaction, id: string, attempt: string) {
await tx.query(`INSERT INTO atlas.outbox_events (workspace_id, aggregate_type, aggregate_id, event_type, event_payload, deduplication_key)
VALUES (current_setting('app.workspace_id')::uuid, 'product_image_deletion', $1, 'media.asset_delete.requested', jsonb_build_object('deletion_id', $1::uuid), $2)`, [id, attempt]);
}
private async requireUnused(tx: DatabaseTransaction, assetId: string, productId: string) {
const refs = await tx.query(`SELECT 1 FROM atlas.product_variant_listing_media WHERE asset_id = $1
UNION ALL SELECT 1 FROM atlas.watermark_template_versions WHERE image_asset_id = $1
UNION ALL SELECT 1 FROM atlas.users WHERE avatar_asset_id = $1
UNION ALL SELECT 1 FROM atlas.media_upload_intents WHERE asset_id = $1 AND product_id IS DISTINCT FROM $2::uuid
UNION ALL SELECT 1 FROM atlas.product_image_imports WHERE asset_id = $1 AND product_id IS DISTINCT FROM $2::uuid
UNION ALL SELECT 1 FROM atlas.collection_asset_transfers WHERE asset_id = $1 AND product_id <> $2
UNION ALL SELECT 1 FROM atlas.media_ai_edits WHERE (output_asset_id = $1 AND product_id IS DISTINCT FROM $2::uuid)
OR (status IN ('queued','running') AND (source_asset_id = $1 OR $1 = ANY(reference_asset_ids)))
UNION ALL SELECT 1 FROM atlas.media_aspect_ratio_derivatives WHERE (output_asset_id = $1 AND product_id IS DISTINCT FROM $2::uuid)
OR (status IN ('queued','running') AND source_asset_id = $1)
UNION ALL SELECT 1 FROM atlas.media_watermark_derivatives WHERE (output_asset_id = $1 AND product_id IS DISTINCT FROM $2::uuid)
OR (status IN ('queued','running') AND source_asset_id = $1)
UNION ALL SELECT 1 FROM atlas.product_image_suite_references r JOIN atlas.product_image_suites s ON s.id = r.suite_id
WHERE r.asset_id = $1 AND s.status IN ('queued','running') LIMIT 1`, [assetId, productId]);
if (refs.rows.length) throw new ApiError(409, 'MEDIA_ASSET_IN_USE', '图片已被 SKU、其他商品或图片任务使用,请先移除引用并保存后再删除');
}
}
@@ -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 AND a.deleted_at IS NULL
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,137 @@
import { lookup } from 'node:dns/promises';
import { request as httpsRequest } from 'node:https';
import type { IncomingMessage } from 'node:http';
import { isIP } from 'node:net';
import { ApiError } from '../common/api-error.js';
import { detectImage } from './media-image-validation.js';
const MAX_BYTES = 5 * 1024 * 1024;
function invalidImage(): never {
throw new ApiError(
422,
'PRODUCT_IMAGE_IMPORT_INVALID',
'网络图片链接无效或图片不符合要求',
);
}
export function publicIpv4(address: string) {
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) || (b === 0 && c === 2))) ||
(a === 198 && (b === 18 || b === 19 || (b === 51 && c === 100))) ||
(a === 203 && b === 0 && c === 113)
);
}
/** 每次重定向重新解析域名,并将 HTTPS 连接固定到已检查的公网 IPv4。 */
async function requestPublicImage(url: URL): Promise<IncomingMessage> {
if (
url.protocol !== 'https:' ||
url.username ||
url.password ||
url.port ||
isIP(url.hostname)
)
invalidImage();
const addresses = await lookup(url.hostname, {
all: true,
family: 4,
}).catch(() => []);
if (
!addresses.length ||
addresses.some((item) => !publicIpv4(item.address))
)
invalidImage();
const address = addresses[0].address;
return new Promise<IncomingMessage>((resolve, reject) => {
const request = httpsRequest(
url,
{
lookup: (_hostname, _options, callback) =>
callback(null, address, 4),
headers: {
Accept: 'image/jpeg,image/png',
'User-Agent': 'Atlas-Image-Importer/0.1',
},
timeout: 15_000,
},
resolve,
);
request.on('error', reject);
request.on('timeout', () =>
request.destroy(new Error('网络图片下载超时')),
);
request.end();
});
}
/** 仅导入公开 HTTPS 的 JPG/PNG,硬限制 5 MiB 并验证文件内容。 */
export async function downloadProductImage(source: string) {
let url = new URL(source);
for (let redirect = 0; redirect < 4; redirect += 1) {
let response: IncomingMessage;
try {
response = await requestPublicImage(url);
} catch (error) {
if (error instanceof ApiError) throw error;
invalidImage();
}
if ([301, 302, 303, 307, 308].includes(response.statusCode ?? 0)) {
const location = response.headers.location;
response.resume();
if (!location) invalidImage();
url = new URL(location, url);
continue;
}
if (
response.statusCode !== 200 ||
Number(response.headers['content-length'] ?? 0) > MAX_BYTES
) {
response.resume();
invalidImage();
}
const chunks: Buffer[] = [];
let total = 0;
try {
for await (const chunk of response) {
total += chunk.length;
if (total > MAX_BYTES) {
response.destroy();
invalidImage();
}
chunks.push(Buffer.from(chunk));
}
} catch (error) {
if (error instanceof ApiError) throw error;
invalidImage();
}
const bytes = Buffer.concat(chunks, total);
const detected = detectImage(bytes, 10_000);
if (
!detected ||
!['image/jpeg', 'image/png'].includes(detected.contentType)
)
invalidImage();
const declared = response.headers['content-type']
?.split(';', 1)[0]
?.toLowerCase();
if (declared && declared !== detected.contentType) invalidImage();
return {
bytes,
contentType: detected.contentType,
width: detected.width,
height: detected.height,
};
}
invalidImage();
}
@@ -0,0 +1,131 @@
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;
kind?: ProductImageTask['kind'];
total?: number;
completed?: number;
output_asset_ids?: string[];
active_output_asset_ids?: string[];
};
@Injectable()
export class ProductImageTaskService {
public constructor(@Inject(DATABASE) private readonly database: Database) {}
/** 聚合单图与整套任务;套图子任务不重复出现在商品任务列表。 */
public async list(workspaceId: string, productId: string, query: ListProductImageTasksQuery) {
return this.database.workspaceTransaction(workspaceId, async (tx) => {
const product = await tx.query<{ id: string }>(
'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.*, ARRAY(SELECT a.id FROM unnest(e.output_asset_ids) WITH ORDINALITY refs(id, position)
JOIN atlas.media_assets a ON a.id = refs.id AND a.deleted_at IS NULL ORDER BY refs.position) AS active_output_asset_ids FROM (
SELECT e.id, e.product_id, e.status, e.provider_task_id, e.output_asset_id, e.error_code,
o.result ->> 'provider_error_raw' AS provider_error_raw,
o.result ->> 'failure_source' AS failure_source,
o.result ->> 'failure_stage' AS failure_stage,
o.result ->> 'failure_detail' AS failure_detail,
e.created_at, e.completed_at, 'single_ai_edit' AS kind, 1 AS total,
(e.status = 'succeeded')::int AS completed,
CASE WHEN e.output_asset_id IS NULL THEN '{}'::uuid[] ELSE ARRAY[e.output_asset_id] END AS output_asset_ids
FROM atlas.media_ai_edits e
LEFT JOIN atlas.operations o ON o.id = e.id AND o.workspace_id = e.workspace_id
WHERE e.suite_id IS NULL
UNION ALL
SELECT s.id, s.product_id, s.status, NULL::text, NULL::uuid, s.error_code,
failure.result ->> 'provider_error_raw', failure.result ->> 'failure_source',
failure.result ->> 'failure_stage', failure.result ->> 'failure_detail',
s.created_at, s.completed_at, 'ai_suite', jsonb_array_length(s.shots),
progress.completed::int, progress.outputs
FROM atlas.product_image_suites s
LEFT JOIN LATERAL (SELECT count(*) FILTER (WHERE status = 'succeeded') AS completed,
coalesce(array_agg(output_asset_id ORDER BY suite_position) FILTER (WHERE output_asset_id IS NOT NULL), '{}'::uuid[]) AS outputs
FROM atlas.media_ai_edits WHERE suite_id = s.id) progress ON true
LEFT JOIN LATERAL (SELECT o.result FROM atlas.media_ai_edits child JOIN atlas.operations o ON o.id = child.id
AND o.workspace_id = child.workspace_id WHERE child.suite_id = s.id AND child.status = 'failed'
ORDER BY child.suite_position LIMIT 1) failure ON true
WHERE s.purpose = 'generate'
) e
WHERE e.product_id = $1 ${cursorFilter}
ORDER BY (e.status IN ('queued', 'running')) DESC, e.created_at DESC, e.id DESC
LIMIT $${params.length}`, params,
);
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: row.kind ?? 'single_ai_edit',
status: row.status,
phase: row.status === 'running' && row.provider_task_id ? 'waiting_provider' as const : null,
total: row.total ?? 1,
completed: row.completed ?? (row.status === 'succeeded' ? 1 : 0),
output_asset_ids: row.active_output_asset_ids ?? row.output_asset_ids ?? (row.output_asset_id ? [row.output_asset_id] : []),
error_code: row.error_code,
failure_source: ['failed', 'partial'].includes(row.status) ? row.failure_source ?? null : null,
failure_stage: ['failed', 'partial'].includes(row.status) ? row.failure_stage ?? null : null,
failure_detail: ['failed', 'partial'].includes(row.status) ? row.failure_detail ?? null : null,
provider_error_raw: ['failed', 'partial'].includes(row.status) ? row.provider_error_raw ?? null : null,
created_at: iso(row.created_at),
completed_at: row.completed_at ? iso(row.completed_at) : null,
}));
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 deleted_at IS NULL AND content_type IN ('image/jpeg', 'image/png', 'image/webp')
AND byte_size <= 20971520 AND width_px BETWEEN 1 AND 10000
AND height_px BETWEEN 1 AND 10000`,
[assetId],
);
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 AND a.deleted_at IS NULL
WHERE d.id = $1`,
[derivativeId],
);
return result.rows[0] ?? null;
});
if (!row) throw new ApiError(404, 'RESOURCE_NOT_FOUND', '水印处理任务不存在');
const access = row.output_asset_id && row.content_type
? await this.media.accessUrl(workspaceId, row.output_asset_id)
: null;
return {
id: row.id,
status: row.status,
source_asset_id: row.source_asset_id,
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', '分页游标无效');
}
@@ -0,0 +1,424 @@
import { Inject, Injectable } from '@nestjs/common';
import type { Database, DatabaseTransaction } from '@atlas/database';
import { ApiError } from '../common/api-error.js';
import { APP_CONFIG, type AppConfig } from '../config/app-config.module.js';
import { DATABASE } from '../database/database.module.js';
import { encryptStoreSecret } from '../stores/store-secret.crypto.js';
import {
enqueuePlatformCatalogRun,
type PlatformCatalogRunRow,
} from '../platform-catalog/platform-catalog-run.js';
import type {
AttributeBatchInput, RefreshPolicyInput, SourceInput, SyncInput,
} from './platform-catalog-admin.models.js';
type BatchRow = {
id: string;
platform_id: string;
mode: AttributeBatchInput['mode'];
status: string;
total_count: number;
created_at: Date;
started_at: Date | null;
completed_at: Date | null;
};
@Injectable()
export class PlatformCatalogAdminCommands {
public constructor(
@Inject(DATABASE) private readonly database: Database,
@Inject(APP_CONFIG) private readonly config: AppConfig,
) {}
public async configureSource(platformId: string, userId: string, input: SourceInput) {
const encrypted = encryptStoreSecret(input.api_key, this.config.STORE_SECRET_ENCRYPTION_KEY);
return this.database.transaction(async (tx) => {
const platform = await tx.query<{ code: string }>(
'SELECT code FROM atlas.platforms WHERE id = $1 FOR UPDATE',
[platformId],
);
if (!platform.rows[0]) throw new ApiError(404, 'PLATFORM_NOT_FOUND', '平台不存在');
if (platform.rows[0].code.toUpperCase() !== 'OZON') {
throw new ApiError(422, 'PLATFORM_CATALOG_UNSUPPORTED', '该平台尚未接入目录同步');
}
const existing = await tx.query<{
client_id: string; secret_fingerprint: string; revision: number;
}>(
`SELECT client_id, secret_fingerprint, revision
FROM atlas.platform_catalog_sources WHERE platform_id = $1 FOR UPDATE`,
[platformId],
);
const unchanged = existing.rows[0]?.client_id === input.client_id &&
existing.rows[0]?.secret_fingerprint === encrypted.fingerprint;
const revision = unchanged ? existing.rows[0].revision : (existing.rows[0]?.revision ?? 0) + 1;
if (!unchanged) {
await tx.query(
`INSERT INTO atlas.platform_catalog_sources
(platform_id, client_id, secret_ciphertext, secret_nonce,
secret_fingerprint, encryption_key_version, revision, updated_by_user_id)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
ON CONFLICT (platform_id) DO UPDATE
SET client_id = EXCLUDED.client_id,
secret_ciphertext = EXCLUDED.secret_ciphertext,
secret_nonce = EXCLUDED.secret_nonce,
secret_fingerprint = EXCLUDED.secret_fingerprint,
encryption_key_version = EXCLUDED.encryption_key_version,
revision = EXCLUDED.revision,
updated_by_user_id = EXCLUDED.updated_by_user_id,
updated_at = now()`,
[platformId, input.client_id, encrypted.ciphertext, encrypted.nonce,
encrypted.fingerprint, this.config.STORE_SECRET_ENCRYPTION_KEY_VERSION,
revision, userId],
);
// 旧来源的任务即使已进队列,也不能拿新凭据执行。
await tx.query(
`UPDATE atlas.platform_catalog_sync_runs
SET status = 'cancelled', completed_at = now(),
error_code = 'CATALOG_SOURCE_REPLACED', error_message = '同步来源已更新'
WHERE platform_id = $1 AND status IN ('queued', 'running')`,
[platformId],
);
await tx.query(
`UPDATE atlas.platform_catalog_attribute_batches
SET status = 'cancelled', completed_at = now()
WHERE platform_id = $1 AND status IN ('queued', 'running')`,
[platformId],
);
await enqueuePlatformCatalogRun(tx, {
platformId, scope: 'tree', scopeKey: 'tree', sourceRevision: revision,
trigger: 'source_configured', priority: 'high', userId,
});
}
return {
configured: true,
client_id_hint: `••••${input.client_id.slice(-4)}`,
revision,
changed: !unchanged,
};
});
}
public async sync(platformId: string, userId: string, idempotencyKey: string, input: SyncInput) {
return this.database.transaction(async (tx) => {
const platform = await tx.query<{ code: string }>(
'SELECT code FROM atlas.platforms WHERE id = $1 FOR UPDATE',
[platformId],
);
if (!platform.rows[0]) throw new ApiError(404, 'PLATFORM_NOT_FOUND', '平台不存在');
if (platform.rows[0].code.toUpperCase() !== 'OZON') {
throw new ApiError(422, 'PLATFORM_CATALOG_UNSUPPORTED', '该平台尚未接入目录同步');
}
const source = await tx.query<{ revision: number }>(
'SELECT revision FROM atlas.platform_catalog_sources WHERE platform_id = $1 FOR UPDATE',
[platformId],
);
if (!source.rows[0]) {
throw new ApiError(422, 'CATALOG_SOURCE_REQUIRED', '请先配置平台目录同步来源');
}
const target = await this.resolveTarget(tx, platformId, input);
const prior = await tx.query<PlatformCatalogRunRow>(
`SELECT run.id, run.platform_id, run.scope, run.scope_key, run.status,
run.trigger_kind, run.priority, run.created_at
FROM atlas.platform_catalog_sync_requests request
JOIN atlas.platform_catalog_sync_runs run ON run.id = request.run_id
WHERE request.requested_by_user_id = $1 AND request.idempotency_key = $2`,
[userId, idempotencyKey],
);
if (prior.rows[0]) {
if (prior.rows[0].platform_id !== platformId || prior.rows[0].scope_key !== target.scopeKey) {
throw new ApiError(409, 'IDEMPOTENCY_KEY_REUSED', '幂等键已用于其他同步请求');
}
return presentRun(prior.rows[0]);
}
const run = await enqueuePlatformCatalogRun(tx, {
platformId,
scope: input.scope,
scopeKey: target.scopeKey,
categoryId: target.categoryId,
attributeId: target.attributeId,
sourceRevision: source.rows[0].revision,
trigger: 'manual',
priority: input.scope === 'tree'
? 'high'
: await resourcePriority(tx, platformId, input.scope, target.scopeKey),
userId,
idempotencyKey,
});
await tx.query(
`INSERT INTO atlas.platform_catalog_sync_requests
(requested_by_user_id, idempotency_key, platform_id, scope_key, run_id)
VALUES ($1, $2, $3, $4, $5)`,
[userId, idempotencyKey, platformId, target.scopeKey, run.id],
);
return presentRun(run);
});
}
public async updateRefreshPolicy(
platformId: string,
userId: string,
input: RefreshPolicyInput,
) {
return this.database.transaction(async (tx) => {
await this.requireSupportedPlatform(tx, platformId);
const previous = await ensurePolicy(tx, platformId);
if (samePolicy(previous, input)) return previous;
const result = await tx.query<{
attribute_fresh_days: number; attribute_expire_days: number;
dictionary_fresh_days: number; dictionary_expire_days: number;
updated_at: Date;
}>(
`INSERT INTO atlas.platform_catalog_refresh_policies
(platform_id, attribute_fresh_days, attribute_expire_days,
dictionary_fresh_days, dictionary_expire_days, updated_by_user_id)
VALUES ($1, $2, $3, $4, $5, $6)
ON CONFLICT (platform_id) DO UPDATE
SET attribute_fresh_days = EXCLUDED.attribute_fresh_days,
attribute_expire_days = EXCLUDED.attribute_expire_days,
dictionary_fresh_days = EXCLUDED.dictionary_fresh_days,
dictionary_expire_days = EXCLUDED.dictionary_expire_days,
updated_by_user_id = EXCLUDED.updated_by_user_id,
updated_at = now()
RETURNING attribute_fresh_days, attribute_expire_days,
dictionary_fresh_days, dictionary_expire_days, updated_at`,
[platformId, input.attribute_fresh_days, input.attribute_expire_days,
input.dictionary_fresh_days, input.dictionary_expire_days, userId],
);
const next = result.rows[0];
await tx.query(
`INSERT INTO atlas.platform_catalog_refresh_policy_revisions
(platform_id, changed_by_user_id, previous_policy, next_policy)
VALUES ($1, $2, $3::jsonb, $4::jsonb)`,
[platformId, userId, JSON.stringify(policyValues(previous)),
JSON.stringify(policyValues(next))],
);
return next;
});
}
public async createAttributeBatch(
platformId: string,
userId: string,
idempotencyKey: string,
input: AttributeBatchInput,
) {
return this.database.transaction(async (tx) => {
await this.requireSupportedPlatform(tx, platformId);
const source = await tx.query<{ revision: number }>(
`SELECT revision FROM atlas.platform_catalog_sources
WHERE platform_id = $1 FOR UPDATE`,
[platformId],
);
if (!source.rows[0]) {
throw new ApiError(422, 'CATALOG_SOURCE_REQUIRED', '请先配置平台目录同步来源');
}
const prior = await tx.query<BatchRow>(
`SELECT id, platform_id, mode, status, total_count,
created_at, started_at, completed_at
FROM atlas.platform_catalog_attribute_batches
WHERE requested_by_user_id = $1 AND idempotency_key = $2`,
[userId, idempotencyKey],
);
if (prior.rows[0]) {
if (prior.rows[0].platform_id !== platformId || prior.rows[0].mode !== input.mode) {
throw new ApiError(409, 'IDEMPOTENCY_KEY_REUSED', '幂等键已用于其他属性同步批次');
}
return presentBatch(tx, prior.rows[0]);
}
const active = await tx.query<{ id: string }>(
`SELECT id FROM atlas.platform_catalog_attribute_batches
WHERE platform_id = $1 AND status IN ('queued', 'running') LIMIT 1`,
[platformId],
);
if (active.rows[0]) {
throw new ApiError(
409,
'CATALOG_ATTRIBUTE_BATCH_ACTIVE',
`已有类型属性批次正在执行:${active.rows[0].id}`,
);
}
const policy = await ensurePolicy(tx, platformId);
const candidates = await tx.query<{ id: string }>(
`SELECT category.id
FROM atlas.platform_product_categories category
LEFT JOIN atlas.platform_catalog_resource_states state
ON state.platform_id = category.platform_id
AND state.scope_key = 'attributes:' || category.id
WHERE category.platform_id = $1 AND category.is_active
AND category.external_type_id IS NOT NULL AND category.external_type_id <> '0'
AND (
$2 = 'all'
OR state.last_success_at IS NULL
OR ($2 = 'missing_expired'
AND state.last_success_at < now() - make_interval(days => $3))
OR ($2 = 'missing_stale_expired'
AND state.last_success_at <= now() - make_interval(days => $4))
)
ORDER BY category.id`,
[platformId, input.mode, policy.attribute_expire_days,
policy.attribute_fresh_days],
);
if (!candidates.rows.length) {
throw new ApiError(
422,
'CATALOG_ATTRIBUTE_BATCH_EMPTY',
'当前没有符合条件的商品类型',
);
}
const inserted = await tx.query<BatchRow>(
`INSERT INTO atlas.platform_catalog_attribute_batches
(platform_id, mode, source_revision, attribute_fresh_days,
attribute_expire_days, requested_by_user_id, idempotency_key, total_count)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
RETURNING id, platform_id, mode, status, total_count,
created_at, started_at, completed_at`,
[platformId, input.mode, source.rows[0].revision,
policy.attribute_fresh_days, policy.attribute_expire_days,
userId, idempotencyKey, candidates.rows.length],
);
const batch = inserted.rows[0];
await tx.query(
`INSERT INTO atlas.platform_catalog_attribute_batch_items
(batch_id, platform_id, category_id)
SELECT $1, $2, category_id
FROM unnest($3::uuid[]) AS category_id`,
[batch.id, platformId, candidates.rows.map((candidate) => candidate.id)],
);
return presentBatch(tx, batch);
});
}
private async requireSupportedPlatform(tx: DatabaseTransaction, platformId: string) {
const platform = await tx.query<{ code: string }>(
'SELECT code FROM atlas.platforms WHERE id = $1 FOR UPDATE',
[platformId],
);
if (!platform.rows[0]) throw new ApiError(404, 'PLATFORM_NOT_FOUND', '平台不存在');
if (platform.rows[0].code.toUpperCase() !== 'OZON') {
throw new ApiError(422, 'PLATFORM_CATALOG_UNSUPPORTED', '该平台尚未接入目录同步');
}
}
private async resolveTarget(tx: DatabaseTransaction, platformId: string, input: SyncInput) {
if (input.scope === 'tree') return { scopeKey: 'tree' };
if (input.scope === 'attributes') {
const category = await tx.query<{ id: string }>(
`SELECT id FROM atlas.platform_product_categories
WHERE id = $1 AND platform_id = $2 AND is_active
AND external_type_id IS NOT NULL AND external_type_id <> '0'`,
[input.category_id, platformId],
);
if (!category.rows[0]) throw new ApiError(404, 'CATEGORY_NOT_FOUND', '商品类型不存在');
return { scopeKey: `attributes:${input.category_id}`, categoryId: input.category_id };
}
const attribute = await tx.query<{ platform_product_category_id: string }>(
`SELECT platform_product_category_id
FROM atlas.platform_product_category_attributes
WHERE id = $1 AND platform_id = $2 AND is_active AND dictionary_id IS NOT NULL`,
[input.attribute_id, platformId],
);
if (!attribute.rows[0]) {
throw new ApiError(404, 'ATTRIBUTE_NOT_FOUND', '字典属性不存在');
}
return {
scopeKey: `dictionary:${input.attribute_id}`,
categoryId: attribute.rows[0].platform_product_category_id,
attributeId: input.attribute_id,
};
}
}
function presentRun(run: PlatformCatalogRunRow) {
return {
id: run.id,
platform_id: run.platform_id,
scope: run.scope,
status: run.status,
trigger: run.trigger_kind,
priority: run.priority,
created_at: run.created_at,
};
}
async function ensurePolicy(tx: DatabaseTransaction, platformId: string) {
const result = await tx.query<{
attribute_fresh_days: number; attribute_expire_days: number;
dictionary_fresh_days: number; dictionary_expire_days: number;
updated_at: Date;
}>(
`INSERT INTO atlas.platform_catalog_refresh_policies (platform_id)
VALUES ($1) ON CONFLICT (platform_id) DO UPDATE SET platform_id = EXCLUDED.platform_id
RETURNING attribute_fresh_days, attribute_expire_days,
dictionary_fresh_days, dictionary_expire_days, updated_at`,
[platformId],
);
return result.rows[0];
}
type PolicyValues = Pick<RefreshPolicyInput,
'attribute_fresh_days' | 'attribute_expire_days' |
'dictionary_fresh_days' | 'dictionary_expire_days'>;
function samePolicy(current: PolicyValues, next: PolicyValues) {
return current.attribute_fresh_days === next.attribute_fresh_days &&
current.attribute_expire_days === next.attribute_expire_days &&
current.dictionary_fresh_days === next.dictionary_fresh_days &&
current.dictionary_expire_days === next.dictionary_expire_days;
}
function policyValues(policy: PolicyValues): PolicyValues {
return {
attribute_fresh_days: policy.attribute_fresh_days,
attribute_expire_days: policy.attribute_expire_days,
dictionary_fresh_days: policy.dictionary_fresh_days,
dictionary_expire_days: policy.dictionary_expire_days,
};
}
async function resourcePriority(
tx: DatabaseTransaction,
platformId: string,
scope: 'attributes' | 'dictionary',
scopeKey: string,
): Promise<'high' | 'low'> {
const policy = await ensurePolicy(tx, platformId);
const state = await tx.query<{ high_priority: boolean }>(
`SELECT state.last_success_at IS NULL
OR state.last_success_at < now() - make_interval(days => $3)
AS high_priority
FROM (SELECT 1) marker
LEFT JOIN atlas.platform_catalog_resource_states state
ON state.platform_id = $1 AND state.scope_key = $2`,
[platformId, scopeKey, scope === 'attributes'
? policy.attribute_expire_days
: policy.dictionary_expire_days],
);
return state.rows[0]?.high_priority === true ? 'high' : 'low';
}
async function presentBatch(tx: DatabaseTransaction, batch: BatchRow) {
const counts = await tx.query<{
pending_count: string; running_count: string; succeeded_count: string;
failed_count: string; cancelled_count: string;
}>(
`SELECT
count(*) FILTER (WHERE item.run_id IS NULL OR run.status = 'queued')::text AS pending_count,
count(*) FILTER (WHERE run.status = 'running')::text AS running_count,
count(*) FILTER (WHERE run.status = 'succeeded')::text AS succeeded_count,
count(*) FILTER (WHERE run.status = 'failed')::text AS failed_count,
count(*) FILTER (WHERE run.status = 'cancelled')::text AS cancelled_count
FROM atlas.platform_catalog_attribute_batch_items item
LEFT JOIN atlas.platform_catalog_sync_runs run ON run.id = item.run_id
WHERE item.batch_id = $1`,
[batch.id],
);
const progress = counts.rows[0];
return {
...batch,
pending_count: Number(progress?.pending_count ?? batch.total_count),
running_count: Number(progress?.running_count ?? 0),
succeeded_count: Number(progress?.succeeded_count ?? 0),
failed_count: Number(progress?.failed_count ?? 0),
cancelled_count: Number(progress?.cancelled_count ?? 0),
};
}
@@ -0,0 +1,211 @@
import {
Body, Controller, Get, Headers, Inject, Param, Post, Put, Query,
Req, UseGuards, HttpCode,
} from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import type { Database } from '@atlas/database';
import type { AuthenticatedRequest } from '../auth/auth.guard.js';
import { ApiError } from '../common/api-error.js';
import { DATABASE } from '../database/database.module.js';
import { PlatformCatalogAdminCommands } from './platform-catalog-admin.commands.js';
import {
attributeBatchInputSchema, parseInput, parseListLimit, parseUuid,
refreshPolicyInputSchema, sourceInputSchema, syncInputSchema,
} from './platform-catalog-admin.models.js';
import { PlatformCatalogAdminReads } from './platform-catalog-admin.reads.js';
import { hasSystemAdminGrant, SystemAdminGuard } from './system-admin.guard.js';
@ApiTags('System')
@ApiBearerAuth('access-token')
@Controller('system')
export class SystemAccessController {
public constructor(@Inject(DATABASE) private readonly database: Database) {}
@Get('access')
public async access(@Req() request: AuthenticatedRequest) {
return { data: { is_system_admin: await hasSystemAdminGrant(this.database, request.auth.userId) } };
}
}
@ApiTags('System platform catalog')
@ApiBearerAuth('access-token')
@UseGuards(SystemAdminGuard)
@Controller('system/platform-catalog')
export class PlatformCatalogAdminController {
public constructor(
@Inject(PlatformCatalogAdminReads) private readonly reads: PlatformCatalogAdminReads,
@Inject(PlatformCatalogAdminCommands) private readonly commands: PlatformCatalogAdminCommands,
) {}
@Get('platforms')
public async platforms() {
return { data: await this.reads.platforms() };
}
@Put('platforms/:platformId/source')
public async configureSource(
@Param('platformId') platformId: string,
@Req() request: AuthenticatedRequest,
@Body() body: unknown,
) {
return {
data: await this.commands.configureSource(
parseUuid(platformId, 'platform_id'),
request.auth.userId,
parseInput(sourceInputSchema, body),
),
};
}
@Get('platforms/:platformId/categories')
public async categories(@Param('platformId') platformId: string) {
return { data: await this.reads.categories(parseUuid(platformId, 'platform_id')) };
}
@Get('platforms/:platformId/refresh-policy')
public async refreshPolicy(@Param('platformId') platformId: string) {
return {
data: await this.reads.refreshPolicy(parseUuid(platformId, 'platform_id')),
};
}
@Put('platforms/:platformId/refresh-policy')
public async updateRefreshPolicy(
@Param('platformId') platformId: string,
@Req() request: AuthenticatedRequest,
@Body() body: unknown,
) {
return {
data: await this.commands.updateRefreshPolicy(
parseUuid(platformId, 'platform_id'),
request.auth.userId,
parseInput(refreshPolicyInputSchema, body),
),
};
}
@Get('platforms/:platformId/categories/:categoryId/attributes')
public async attributes(
@Param('platformId') platformId: string,
@Param('categoryId') categoryId: string,
) {
return {
data: await this.reads.attributes(
parseUuid(platformId, 'platform_id'),
parseUuid(categoryId, 'category_id'),
),
};
}
@Get('platforms/:platformId/attributes/:attributeId/values')
public async values(
@Param('platformId') platformId: string,
@Param('attributeId') attributeId: string,
@Query('q') keyword: string | undefined,
@Query('page') pageValue: string | undefined,
) {
const page = pageValue === undefined ? 1 : Number(pageValue);
if (!Number.isInteger(page) || page < 1 || page > 100000) {
throw new ApiError(400, 'VALIDATION_FAILED', 'page 必须为正整数');
}
if (keyword && keyword.length > 120) {
throw new ApiError(400, 'VALIDATION_FAILED', '搜索词不能超过 120 字符');
}
return this.reads.values(
parseUuid(platformId, 'platform_id'),
parseUuid(attributeId, 'attribute_id'),
keyword,
page,
);
}
@Post('platforms/:platformId/syncs')
@HttpCode(202)
public async sync(
@Param('platformId') platformId: string,
@Headers('idempotency-key') idempotencyKey: string | undefined,
@Req() request: AuthenticatedRequest,
@Body() body: unknown,
) {
if (!idempotencyKey) {
throw new ApiError(400, 'IDEMPOTENCY_KEY_REQUIRED', '请提供 Idempotency-Key');
}
return {
data: await this.commands.sync(
parseUuid(platformId, 'platform_id'),
request.auth.userId,
parseUuid(idempotencyKey, 'Idempotency-Key'),
parseInput(syncInputSchema, body),
),
};
}
@Get('platforms/:platformId/syncs')
public async syncs(
@Param('platformId') platformId: string,
@Query('limit') limit: string | undefined,
) {
return {
data: await this.reads.runs(
parseUuid(platformId, 'platform_id'),
parseListLimit(limit),
),
};
}
@Get('platforms/:platformId/attribute-sync-summary')
public async attributeSyncSummary(@Param('platformId') platformId: string) {
return {
data: await this.reads.attributeSyncSummary(
parseUuid(platformId, 'platform_id'),
),
};
}
@Post('platforms/:platformId/attribute-sync-batches')
@HttpCode(202)
public async createAttributeBatch(
@Param('platformId') platformId: string,
@Headers('idempotency-key') idempotencyKey: string | undefined,
@Req() request: AuthenticatedRequest,
@Body() body: unknown,
) {
if (!idempotencyKey) {
throw new ApiError(400, 'IDEMPOTENCY_KEY_REQUIRED', '请提供 Idempotency-Key');
}
return {
data: await this.commands.createAttributeBatch(
parseUuid(platformId, 'platform_id'),
request.auth.userId,
parseUuid(idempotencyKey, 'Idempotency-Key'),
parseInput(attributeBatchInputSchema, body),
),
};
}
@Get('platforms/:platformId/attribute-sync-batches')
public async attributeBatches(
@Param('platformId') platformId: string,
@Query('limit') limit: string | undefined,
) {
return {
data: await this.reads.attributeBatches(
parseUuid(platformId, 'platform_id'),
parseListLimit(limit),
),
};
}
@Get('platforms/:platformId/attribute-sync-batches/:batchId')
public async attributeBatch(
@Param('platformId') platformId: string,
@Param('batchId') batchId: string,
) {
return {
data: await this.reads.attributeBatch(
parseUuid(platformId, 'platform_id'),
parseUuid(batchId, 'batch_id'),
),
};
}
}
@@ -0,0 +1,62 @@
import { z } from 'zod';
import { ApiError } from '../common/api-error.js';
export const sourceInputSchema = z.object({
client_id: z.string().trim().min(1).max(128),
api_key: z.string().trim().min(1).max(2048),
}).strict();
export const syncInputSchema = z.discriminatedUnion('scope', [
z.object({ scope: z.literal('tree') }).strict(),
z.object({ scope: z.literal('attributes'), category_id: z.uuid() }).strict(),
z.object({ scope: z.literal('dictionary'), attribute_id: z.uuid() }).strict(),
]);
export const refreshPolicyInputSchema = z.object({
attribute_fresh_days: z.number().int().min(1).max(365),
attribute_expire_days: z.number().int().min(1).max(365),
dictionary_fresh_days: z.number().int().min(1).max(365),
dictionary_expire_days: z.number().int().min(1).max(365),
}).strict().superRefine((value, context) => {
if (value.attribute_expire_days <= value.attribute_fresh_days) {
context.addIssue({
code: 'custom',
path: ['attribute_expire_days'],
message: '类型属性过期天数必须大于新鲜天数',
});
}
if (value.dictionary_expire_days <= value.dictionary_fresh_days) {
context.addIssue({
code: 'custom',
path: ['dictionary_expire_days'],
message: '属性字典过期天数必须大于新鲜天数',
});
}
});
export const attributeBatchInputSchema = z.object({
mode: z.enum(['missing_expired', 'missing_stale_expired', 'all']),
}).strict();
export type SyncInput = z.infer<typeof syncInputSchema>;
export type SourceInput = z.infer<typeof sourceInputSchema>;
export type RefreshPolicyInput = z.infer<typeof refreshPolicyInputSchema>;
export type AttributeBatchInput = z.infer<typeof attributeBatchInputSchema>;
export function parseInput<T>(schema: z.ZodType<T>, input: unknown): T {
const parsed = schema.safeParse(input);
if (parsed.success) return parsed.data;
throw new ApiError(400, 'VALIDATION_FAILED', '请求参数不合法');
}
export function parseUuid(value: string, field: string): string {
if (z.uuid().safeParse(value).success) return value;
throw new ApiError(400, 'VALIDATION_FAILED', `${field} 不是有效 UUID`);
}
export function parseListLimit(value: unknown, defaultValue = 20): number {
if (value === undefined) return defaultValue;
const count = Number(value);
if (Number.isInteger(count) && count >= 1 && count <= 50) return count;
throw new ApiError(400, 'VALIDATION_FAILED', 'limit 必须在 1 到 50 之间');
}
@@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import {
PlatformCatalogAdminController, SystemAccessController,
} from './platform-catalog-admin.controller.js';
import { PlatformCatalogAdminCommands } from './platform-catalog-admin.commands.js';
import { PlatformCatalogAdminReads } from './platform-catalog-admin.reads.js';
import { SystemAdminGuard } from './system-admin.guard.js';
@Module({
controllers: [SystemAccessController, PlatformCatalogAdminController],
providers: [SystemAdminGuard, PlatformCatalogAdminCommands, PlatformCatalogAdminReads],
})
export class PlatformCatalogAdminModule {}
@@ -0,0 +1,328 @@
import { Inject, Injectable } from '@nestjs/common';
import type { Database } from '@atlas/database';
import { ApiError } from '../common/api-error.js';
import { DATABASE } from '../database/database.module.js';
@Injectable()
export class PlatformCatalogAdminReads {
public constructor(@Inject(DATABASE) private readonly database: Database) {}
public async platforms() {
const rows = await this.database.query<{
id: string; code: string; name: string; status: string;
client_id: string | null; source_updated_at: Date | null;
category_count: string; last_tree_sync_at: Date | null;
next_tree_sync_at: Date | null; last_error: string | null;
}>(
`SELECT p.id, p.code, p.name, p.status,
source.client_id, source.updated_at AS source_updated_at,
(SELECT count(*) FROM atlas.platform_product_categories category
WHERE category.platform_id = p.id AND category.is_active)::text AS category_count,
tree_state.last_success_at AS last_tree_sync_at,
tree_state.last_success_at + interval '7 days' AS next_tree_sync_at,
tree_state.last_error_message AS last_error
FROM atlas.platforms p
LEFT JOIN atlas.platform_catalog_sources source ON source.platform_id = p.id
LEFT JOIN atlas.platform_catalog_resource_states tree_state
ON tree_state.platform_id = p.id AND tree_state.scope_key = 'tree'
ORDER BY CASE WHEN upper(p.code) = 'OZON' THEN 0 ELSE 1 END, p.name`,
);
return rows.rows.map((row) => ({
id: row.id,
code: row.code,
name: row.name,
status: row.status,
source: {
configured: row.client_id !== null,
client_id_hint: row.client_id ? `••••${row.client_id.slice(-4)}` : null,
updated_at: row.source_updated_at,
},
summary: {
category_count: Number(row.category_count),
last_tree_sync_at: row.last_tree_sync_at,
next_tree_sync_at: row.next_tree_sync_at,
last_error: row.last_error,
},
}));
}
public async categories(platformId: string) {
await this.requirePlatform(platformId);
const result = await this.database.query(
`SELECT category.id, category.parent_id, category.external_category_id,
category.external_type_id, category.name, category.path,
category.is_active, category.synced_at,
state.last_success_at AS attribute_last_success_at,
state.last_error_message AS attribute_last_error,
state.last_error_at AS attribute_last_error_at,
CASE
WHEN category.external_type_id IS NULL OR category.external_type_id = '0'
THEN NULL
WHEN state.last_success_at IS NULL THEN 'missing'
WHEN state.last_success_at > now() -
make_interval(days => COALESCE(policy.attribute_fresh_days, 14))
THEN 'fresh'
WHEN state.last_success_at >= now() -
make_interval(days => COALESCE(policy.attribute_expire_days, 21))
THEN 'stale'
ELSE 'expired'
END AS attribute_cache_status
FROM atlas.platform_product_categories category
LEFT JOIN atlas.platform_catalog_resource_states state
ON state.platform_id = category.platform_id
AND state.scope_key = 'attributes:' || category.id
LEFT JOIN atlas.platform_catalog_refresh_policies policy
ON policy.platform_id = category.platform_id
WHERE category.platform_id = $1 ORDER BY path, id LIMIT 20000`,
[platformId],
);
return result.rows;
}
public async refreshPolicy(platformId: string) {
await this.requirePlatform(platformId);
const result = await this.database.query<{
attribute_fresh_days: number; attribute_expire_days: number;
dictionary_fresh_days: number; dictionary_expire_days: number;
updated_at: Date | null;
}>(
`SELECT COALESCE(policy.attribute_fresh_days, 14)::int AS attribute_fresh_days,
COALESCE(policy.attribute_expire_days, 21)::int AS attribute_expire_days,
COALESCE(policy.dictionary_fresh_days, 14)::int AS dictionary_fresh_days,
COALESCE(policy.dictionary_expire_days, 21)::int AS dictionary_expire_days,
policy.updated_at
FROM atlas.platforms platform
LEFT JOIN atlas.platform_catalog_refresh_policies policy
ON policy.platform_id = platform.id
WHERE platform.id = $1`,
[platformId],
);
return result.rows[0];
}
public async attributeSyncSummary(platformId: string) {
await this.requirePlatform(platformId);
const result = await this.database.query<{
total: string; fresh: string; stale: string; expired: string; missing: string;
active_batch_id: string | null; evaluated_at: Date;
}>(
`WITH policy AS (
SELECT COALESCE(refresh.attribute_fresh_days, 14)::int AS fresh_days,
COALESCE(refresh.attribute_expire_days, 21)::int AS expire_days
FROM atlas.platforms platform
LEFT JOIN atlas.platform_catalog_refresh_policies refresh
ON refresh.platform_id = platform.id
WHERE platform.id = $1
), resources AS (
SELECT state.last_success_at, policy.fresh_days, policy.expire_days
FROM atlas.platform_product_categories category
CROSS JOIN policy
LEFT JOIN atlas.platform_catalog_resource_states state
ON state.platform_id = category.platform_id
AND state.scope_key = 'attributes:' || category.id
WHERE category.platform_id = $1 AND category.is_active
AND category.external_type_id IS NOT NULL AND category.external_type_id <> '0'
)
SELECT count(*)::text AS total,
count(*) FILTER (
WHERE last_success_at > now() - make_interval(days => fresh_days)
)::text AS fresh,
count(*) FILTER (
WHERE last_success_at <= now() - make_interval(days => fresh_days)
AND last_success_at >= now() - make_interval(days => expire_days)
)::text AS stale,
count(*) FILTER (
WHERE last_success_at < now() - make_interval(days => expire_days)
)::text AS expired,
count(*) FILTER (WHERE last_success_at IS NULL)::text AS missing,
(SELECT id FROM atlas.platform_catalog_attribute_batches batch
WHERE batch.platform_id = $1 AND batch.status IN ('queued', 'running')
LIMIT 1) AS active_batch_id,
now() AS evaluated_at
FROM resources`,
[platformId],
);
const row = result.rows[0];
return {
total: Number(row?.total ?? 0),
fresh: Number(row?.fresh ?? 0),
stale: Number(row?.stale ?? 0),
expired: Number(row?.expired ?? 0),
missing: Number(row?.missing ?? 0),
active_batch_id: row?.active_batch_id ?? null,
evaluated_at: row?.evaluated_at ?? new Date(),
};
}
public async attributes(platformId: string, categoryId: string) {
await this.requireCategory(platformId, categoryId);
const result = await this.database.query(
`SELECT attribute.id, attribute.platform_attribute_id, attribute.complex_id,
attribute.name, attribute.description, attribute.group_id, attribute.group_name,
attribute.value_type, attribute.is_required, attribute.is_aspect,
attribute.is_collection, attribute.max_value_count, attribute.dictionary_id,
attribute.category_dependent, attribute.is_active, attribute.synced_at,
schema_state.last_success_at,
CASE
WHEN schema_state.last_success_at IS NULL THEN 'missing'
WHEN schema_state.last_success_at > now() -
make_interval(days => COALESCE(policy.attribute_fresh_days, 14))
THEN 'fresh'
WHEN schema_state.last_success_at >= now() -
make_interval(days => COALESCE(policy.attribute_expire_days, 21))
THEN 'stale'
ELSE 'expired'
END AS cache_status,
schema_state.last_error_message,
schema_state.last_error_at,
dictionary_state.last_success_at AS dictionary_last_success_at,
CASE
WHEN attribute.dictionary_id IS NULL THEN NULL
WHEN dictionary_state.last_success_at IS NULL THEN 'missing'
WHEN dictionary_state.last_success_at > now() -
make_interval(days => COALESCE(policy.dictionary_fresh_days, 14))
THEN 'fresh'
WHEN dictionary_state.last_success_at >= now() -
make_interval(days => COALESCE(policy.dictionary_expire_days, 21))
THEN 'stale'
ELSE 'expired'
END AS dictionary_cache_status,
dictionary_state.is_complete AS dictionary_is_complete,
dictionary_state.last_error_message AS dictionary_last_error_message,
dictionary_state.last_error_at AS dictionary_last_error_at,
(SELECT count(*)::int FROM atlas.platform_catalog_dictionary_values value
WHERE value.attribute_id = attribute.id AND value.is_active) AS dictionary_value_count
FROM atlas.platform_product_category_attributes attribute
LEFT JOIN atlas.platform_catalog_resource_states schema_state
ON schema_state.platform_id = attribute.platform_id
AND schema_state.scope_key = 'attributes:' || attribute.platform_product_category_id
LEFT JOIN atlas.platform_catalog_resource_states dictionary_state
ON dictionary_state.platform_id = attribute.platform_id
AND dictionary_state.scope_key = 'dictionary:' || attribute.id
LEFT JOIN atlas.platform_catalog_refresh_policies policy
ON policy.platform_id = attribute.platform_id
WHERE attribute.platform_id = $1 AND attribute.platform_product_category_id = $2
ORDER BY attribute.is_required DESC, attribute.group_name NULLS LAST,
attribute.name, attribute.platform_attribute_id`,
[platformId, categoryId],
);
return result.rows;
}
public async values(
platformId: string,
attributeId: string,
keyword: string | undefined,
page: number,
) {
const attribute = await this.database.query<{ dictionary_id: string | null }>(
`SELECT dictionary_id FROM atlas.platform_product_category_attributes
WHERE id = $1 AND platform_id = $2 AND is_active`,
[attributeId, platformId],
);
if (!attribute.rows[0]) throw new ApiError(404, 'ATTRIBUTE_NOT_FOUND', '属性不存在');
if (!attribute.rows[0].dictionary_id) {
throw new ApiError(422, 'ATTRIBUTE_DICTIONARY_UNAVAILABLE', '该属性没有字典候选值');
}
const q = keyword?.trim() ?? '';
const [items, total, lastRun] = await Promise.all([
this.database.query(
`SELECT id, external_value_id, value, info, picture
FROM atlas.platform_catalog_dictionary_values
WHERE attribute_id = $1 AND is_active
AND ($2 = '' OR value ILIKE '%' || $2 || '%' OR external_value_id = $2)
ORDER BY value, external_value_id LIMIT 100 OFFSET $3`,
[attributeId, q, (page - 1) * 100],
),
this.database.query<{ count: string }>(
`SELECT count(*)::text AS count FROM atlas.platform_catalog_dictionary_values
WHERE attribute_id = $1 AND is_active
AND ($2 = '' OR value ILIKE '%' || $2 || '%' OR external_value_id = $2)`,
[attributeId, q],
),
this.database.query<{ is_complete: boolean }>(
`SELECT is_complete FROM atlas.platform_catalog_resource_states
WHERE platform_id = $1 AND scope_key = $2`,
[platformId, `dictionary:${attributeId}`],
),
]);
return {
data: items.rows,
meta: {
page,
page_size: 100,
total: Number(total.rows[0]?.count ?? 0),
is_complete: lastRun.rows[0]?.is_complete ?? false,
},
};
}
public async runs(platformId: string, limit: number) {
await this.requirePlatform(platformId);
const result = await this.database.query(
`SELECT id, platform_id, scope, scope_key, category_id, attribute_id,
trigger_kind AS trigger, priority, status, result, error_code, error_message,
attempt_count, created_at, started_at, completed_at
FROM atlas.platform_catalog_sync_runs
WHERE platform_id = $1 ORDER BY created_at DESC LIMIT $2`,
[platformId, limit],
);
return result.rows;
}
public async attributeBatches(platformId: string, limit: number) {
await this.requirePlatform(platformId);
const result = await this.database.query(
`${batchProgressSelect}
WHERE batch.platform_id = $1
GROUP BY batch.id
ORDER BY batch.created_at DESC LIMIT $2`,
[platformId, limit],
);
return result.rows;
}
public async attributeBatch(platformId: string, batchId: string) {
await this.requirePlatform(platformId);
const result = await this.database.query(
`${batchProgressSelect}
WHERE batch.platform_id = $1 AND batch.id = $2
GROUP BY batch.id`,
[platformId, batchId],
);
if (!result.rows[0]) {
throw new ApiError(404, 'CATALOG_ATTRIBUTE_BATCH_NOT_FOUND', '类型属性同步批次不存在');
}
return result.rows[0];
}
public async requirePlatform(platformId: string) {
const result = await this.database.query<{ code: string }>(
'SELECT code FROM atlas.platforms WHERE id = $1',
[platformId],
);
if (!result.rows[0]) throw new ApiError(404, 'PLATFORM_NOT_FOUND', '平台不存在');
return result.rows[0];
}
public async requireCategory(platformId: string, categoryId: string) {
const result = await this.database.query(
`SELECT 1 FROM atlas.platform_product_categories WHERE id = $1 AND platform_id = $2`,
[categoryId, platformId],
);
if (!result.rows[0]) throw new ApiError(404, 'CATEGORY_NOT_FOUND', '类目不属于当前平台');
}
}
const batchProgressSelect = `
SELECT batch.id, batch.platform_id, batch.mode, batch.status, batch.total_count,
count(*) FILTER (WHERE item.run_id IS NULL OR run.status = 'queued')::int
AS pending_count,
count(*) FILTER (WHERE run.status = 'running')::int AS running_count,
count(*) FILTER (WHERE run.status = 'succeeded')::int AS succeeded_count,
count(*) FILTER (WHERE run.status = 'failed')::int AS failed_count,
count(*) FILTER (WHERE run.status = 'cancelled')::int AS cancelled_count,
batch.created_at, batch.started_at, batch.completed_at
FROM atlas.platform_catalog_attribute_batches batch
JOIN atlas.platform_catalog_attribute_batch_items item ON item.batch_id = batch.id
LEFT JOIN atlas.platform_catalog_sync_runs run ON run.id = item.run_id`;
@@ -0,0 +1,28 @@
import { CanActivate, ExecutionContext, Inject, Injectable } from '@nestjs/common';
import type { Database } from '@atlas/database';
import type { AuthenticatedRequest } from '../auth/auth.guard.js';
import { ApiError } from '../common/api-error.js';
import { DATABASE } from '../database/database.module.js';
@Injectable()
export class SystemAdminGuard implements CanActivate {
public constructor(@Inject(DATABASE) private readonly database: Database) {}
public async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest<AuthenticatedRequest>();
if (await hasSystemAdminGrant(this.database, request.auth.userId)) return true;
throw new ApiError(403, 'SYSTEM_ADMIN_REQUIRED', '需要系统管理员权限');
}
}
/** 授权实时查库;撤销后旧 JWT 不能继续使用后台接口。 */
export async function hasSystemAdminGrant(database: Database, userId: string): Promise<boolean> {
const result = await database.query(
`SELECT 1 FROM atlas.system_admin_grants grant_row
JOIN atlas.users user_row ON user_row.id = grant_row.user_id
WHERE grant_row.user_id = $1 AND grant_row.revoked_at IS NULL
AND user_row.status = 'active'`,
[userId],
);
return !!result.rowCount;
}
@@ -0,0 +1,71 @@
import type { DatabaseTransaction } from '@atlas/database';
export type PlatformCatalogRunRow = {
id: string;
platform_id: string;
scope: string;
scope_key: string;
status: string;
trigger_kind: string;
priority: 'high' | 'low';
created_at: Date;
};
type EnqueuePlatformCatalogRunInput = {
platformId: string;
scope: 'tree' | 'attributes' | 'dictionary' | 'dictionary_search';
scopeKey: string;
categoryId?: string;
attributeId?: string;
searchKey?: string;
sourceRevision: number;
trigger: 'manual' | 'scheduled' | 'weekly' | 'source_configured' |
'admin_batch' | 'user_access' | 'publish';
priority: 'high' | 'low';
userId?: string;
idempotencyKey?: string;
};
/** 同一事务写入平台任务和 Outbox;同一作用域的活动任务直接复用。 */
export async function enqueuePlatformCatalogRun(
tx: DatabaseTransaction,
input: EnqueuePlatformCatalogRunInput,
): Promise<PlatformCatalogRunRow> {
const active = await tx.query<PlatformCatalogRunRow>(
`SELECT id, platform_id, scope, scope_key, status, trigger_kind, priority, created_at
FROM atlas.platform_catalog_sync_runs
WHERE platform_id = $1 AND scope_key = $2 AND status IN ('queued', 'running')
LIMIT 1`,
[input.platformId, input.scopeKey],
);
if (active.rows[0]) {
if (input.priority === 'high' && active.rows[0].priority === 'low') {
const promoted = await tx.query<PlatformCatalogRunRow>(
`UPDATE atlas.platform_catalog_sync_runs
SET priority = 'high', promoted_at = now()
WHERE id = $1 AND status = 'queued'
RETURNING id, platform_id, scope, scope_key, status,
trigger_kind, priority, created_at`,
[active.rows[0].id],
);
return promoted.rows[0] ?? active.rows[0];
}
return active.rows[0];
}
const inserted = await tx.query<PlatformCatalogRunRow>(
`INSERT INTO atlas.platform_catalog_sync_runs
(platform_id, scope, scope_key, category_id, attribute_id, search_key,
trigger_kind, priority, source_revision, requested_by_user_id, idempotency_key)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
RETURNING id, platform_id, scope, scope_key, status, trigger_kind, priority, created_at`,
[input.platformId, input.scope, input.scopeKey, input.categoryId ?? null,
input.attributeId ?? null, input.searchKey ?? null, input.trigger, input.priority,
input.sourceRevision, input.userId ?? null, input.idempotencyKey ?? null],
);
const run = inserted.rows[0];
await tx.query(
`INSERT INTO atlas.platform_catalog_outbox_events (run_id, platform_id) VALUES ($1, $2)`,
[run.id, input.platformId],
);
return run;
}
@@ -0,0 +1,106 @@
import { z } from 'zod';
import type { CommissionImportCategory } from '@atlas/contracts';
import { ApiError } from '../common/api-error.js';
import { D } from './pricing-engine.js';
const sourceId = z
.union([
z.number().int().positive().max(Number.MAX_SAFE_INTEGER),
z.string().regex(/^[1-9]\d{0,17}$/),
])
.transform(String);
const leaf = z
.object({
id: z.union([z.string(), z.number()]),
label: z.string().max(200),
value: z.string().max(50),
})
.strict();
const category = z
.object({
id: z.union([z.string(), z.number()]),
label: z.string().trim().min(1).max(200),
value: z.string().max(200),
cate_id: sourceId,
children: z.array(leaf).length(3),
})
.strict();
const root = category
.omit({ children: true })
.extend({ children: z.array(category).min(1).max(2000) });
const documentSchema = z
.object({
code: z.literal(1),
msg: z.string(),
data: z.array(root).min(1).max(100),
})
.strict();
const fail = (message: string): never => {
throw new ApiError(422, 'COMMISSION_IMPORT_INVALID', message);
};
/** 仅在导入时解析来源文字;报价读取结构化区间,不解析展示标签。 */
export function parseCommissionDocument(
document: unknown,
): CommissionImportCategory[] {
if (Buffer.byteLength(JSON.stringify(document) ?? '') > 2 * 1024 * 1024)
fail('JSON 不能超过 2 MiB');
const parsed = documentSchema.safeParse(document);
if (!parsed.success)
return fail(
'JSON 格式不正确:应包含一级类目、二级类目和每类完整的三个价格档位',
);
const ids = new Set<string>();
const result: CommissionImportCategory[] = [];
for (const group of parsed.data.data) {
if (ids.has(group.cate_id)) fail(`类目 ID 重复:${group.cate_id}`);
ids.add(group.cate_id);
for (const item of group.children) {
if (ids.has(item.cate_id)) fail(`类目 ID 重复:${item.cate_id}`);
ids.add(item.cate_id);
const bands = item.children.map((tier, index) => {
const value = /^([123]),(\d{1,2}(?:\.\d{1,2})?)$/.exec(
tier.value,
);
const prefix = [
'售价 ≤ 1500₽',
'1500₽ < 售价 ≤ 5000₽',
'售价 > 5000₽',
][index];
const label = /^(.*?)\s*\((\d{1,2}(?:\.\d{1,2})?)%\)$/.exec(
tier.label,
);
if (
!value ||
!label ||
value[1] !== String(index + 1) ||
label[1].trim() !== prefix ||
!D(value[2]).eq(label[2])
)
return fail(
`${item.label}:档位或标签百分比与 value 不一致`,
);
return {
min_price: ['0', '1500', '5000'][index],
max_price: ['1500', '5000', null][index],
min_inclusive: false,
max_inclusive: index < 2,
commission_rate: D(value[2]).div(100).toFixed(6),
};
});
result.push({
source_category_id: item.cate_id,
parent_source_id: group.cate_id,
name: item.label,
parent_name: group.label,
bands,
status: 'eligible',
issues: [],
change: 'added',
mapped_types: 0,
});
}
}
if (result.length > 2000) fail('二级类目不能超过 2,000 个');
return result;
}
@@ -0,0 +1,84 @@
import {
Body,
Controller,
Get,
Headers,
Inject,
Param,
Post,
Query,
Req,
Res,
UseGuards,
} from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import { z } from 'zod';
import {
commissionImportRequestSchema,
commissionImportPublishSchema,
type Workspace,
} from '@atlas/contracts';
import { CurrentWorkspace } from '../workspace/current-workspace.decorator.js';
import type { AuthenticatedRequest } from '../auth/auth.guard.js';
import { ApiError } from '../common/api-error.js';
import { SystemAdminGuard } from '../platform-catalog-admin/system-admin.guard.js';
import { CommissionImportService } from './commission-import.service.js';
function parse<T>(schema: z.ZodType<T>, value: unknown): T {
const r = schema.safeParse(value);
if (!r.success)
throw new ApiError(
422,
'VALIDATION_ERROR',
'请检查平台、版本名称、来源、采集日期及确认项',
r.error.issues.map((i) => ({
field: i.path.join('.'),
message: i.message,
})),
);
return r.data;
}
@ApiTags('System commissions')
@ApiBearerAuth('access-token')
@UseGuards(SystemAdminGuard)
@Controller('system/pricing/commissions')
export class CommissionImportController {
constructor(
@Inject(CommissionImportService)
private readonly service: CommissionImportService,
) {}
@Get() async list(@Query('platform_id') id: string) {
return { data: await this.service.list(parse(z.uuid(), id)) };
}
@Get(':id') async detail(@Param('id') id: string) {
return { data: await this.service.detail(parse(z.uuid(), id)) };
}
@Post('preview') async preview(@Body() body: unknown) {
return {
data: await this.service.preview(
parse(commissionImportRequestSchema, body),
),
};
}
@Post('imports') async publish(
@Body() body: unknown,
@Req() req: AuthenticatedRequest,
@CurrentWorkspace() w: Workspace,
@Headers('idempotency-key') key: string | undefined,
@Res({ passthrough: true }) res: { status: (n: number) => unknown },
) {
if (!key)
throw new ApiError(
400,
'IDEMPOTENCY_KEY_REQUIRED',
'需要 Idempotency-Key',
);
const r = await this.service.publish(
w.id,
req.auth.userId,
parse(z.uuid(), key),
parse(commissionImportPublishSchema, body),
);
res.status(r.status);
return r.body;
}
}
@@ -0,0 +1,347 @@
import { Inject, Injectable } from '@nestjs/common';
import { randomUUID } from 'node:crypto';
import type { Database, DatabaseTransaction } from '@atlas/database';
import type {
CommissionImportRequest,
CommissionImportPublish,
CommissionImportCategory,
CommissionImportPreview,
CommissionImportVersion,
CommissionImportDetail,
} from '@atlas/contracts';
import { DATABASE } from '../database/database.module.js';
import { IdempotencyService } from '../common/idempotency.service.js';
import { ApiError } from '../common/api-error.js';
import { fingerprint } from './pricing-engine.js';
import { parseCommissionDocument } from './commission-import-parser.js';
type Tx = DatabaseTransaction;
type CatalogRow = {
id: string;
external_category_id: string;
external_type_id: string;
parent_external_id: string | null;
is_active: boolean;
};
const columns =
'id,platform_id,version,source_reference,captured_on::text,activated_at,is_current,summary';
const inputFields = (v: CommissionImportRequest): CommissionImportRequest => ({
platform_id: v.platform_id,
version: v.version,
source_reference: v.source_reference,
captured_on: v.captured_on,
document: v.document,
});
@Injectable()
export class CommissionImportService {
constructor(
@Inject(DATABASE) private readonly db: Database,
@Inject(IdempotencyService)
private readonly idempotency: IdempotencyService,
) {}
async list(platformId: string): Promise<CommissionImportVersion[]> {
return (
await this.db.query<CommissionImportVersion>(
`SELECT ${columns} FROM atlas.commission_imports WHERE platform_id=$1 ORDER BY activated_at DESC,id`,
[platformId],
)
).rows;
}
async detail(id: string): Promise<CommissionImportDetail> {
const row = (
await this.db.query<CommissionImportDetail>(
`SELECT ${columns},categories FROM atlas.commission_imports WHERE id=$1`,
[id],
)
).rows[0];
if (!row)
throw new ApiError(
404,
'COMMISSION_IMPORT_NOT_FOUND',
'佣金版本不存在',
);
return row;
}
async preview(input: CommissionImportRequest) {
return this.db.transaction(
async (tx) => (await this.inspect(tx, input)).preview,
);
}
private async inspect(
tx: Tx,
input: CommissionImportRequest,
lock = false,
) {
const platform = (
await tx.query<{ code: string }>(
`SELECT code FROM atlas.platforms WHERE id=$1 ${lock ? 'FOR UPDATE' : ''}`,
[input.platform_id],
)
).rows[0];
if (platform?.code.toUpperCase() !== 'OZON')
throw new ApiError(
422,
'COMMISSION_PLATFORM_UNSUPPORTED',
'当前仅支持 Ozon 类目佣金',
);
if (input.captured_on > new Date().toISOString().slice(0, 10))
throw new ApiError(
422,
'COMMISSION_IMPORT_INVALID',
'采集日期不能晚于今天',
);
const categories = parseCommissionDocument(input.document);
const rows = (
await tx.query<CatalogRow>(
`SELECT c.id,c.external_category_id,c.external_type_id,c.is_active,p.external_category_id AS parent_external_id
FROM atlas.platform_product_categories c LEFT JOIN atlas.platform_product_categories p ON p.id=c.parent_id
WHERE c.platform_id=$1 ORDER BY c.id ${lock ? 'FOR SHARE OF c' : ''}`,
[input.platform_id],
)
).rows;
const previous = (
await tx.query<{
id: string;
categories: CommissionImportCategory[];
}>(
`SELECT id,categories FROM atlas.commission_imports WHERE platform_id=$1 AND is_current`,
[input.platform_id],
)
).rows[0];
const old = new Map(
(previous?.categories ?? [])
.filter((c) => c.status === 'eligible')
.map((c) => [c.source_category_id, c]),
);
const mappings = new Map<string, string[]>();
for (const c of categories) {
const matching = rows.filter(
(r) => r.external_category_id === c.source_category_id,
);
const parent = matching.filter(
(r) => !r.external_type_id || r.external_type_id === '0',
);
const types = matching.filter(
(r) =>
r.is_active &&
r.external_type_id &&
r.external_type_id !== '0',
);
if (parent.length !== 1)
c.issues.push('平台目录中没有唯一对应的类目');
else if (parent[0].parent_external_id !== c.parent_source_id)
c.issues.push('来源一级父类目与平台目录不一致');
if (!types.length) c.issues.push('没有可映射的有效商品类型');
c.status = c.issues.length ? 'excluded' : 'eligible';
c.mapped_types = c.status === 'eligible' ? types.length : 0;
const before = old.get(c.source_category_id);
c.change = !before
? 'added'
: fingerprint(before.bands) === fingerprint(c.bands)
? 'unchanged'
: 'changed';
if (c.status === 'eligible')
mappings.set(
c.source_category_id,
types.map((t) => t.id),
);
}
const eligible = categories.filter((c) => c.status === 'eligible');
const summary = {
categories: categories.length,
rules: categories.reduce((n, c) => n + c.bands.length, 0),
eligible: eligible.length,
excluded: categories.length - eligible.length,
mapped_types: eligible.reduce((n, c) => n + c.mapped_types, 0),
removed: [...old.keys()].filter((id) => !mappings.has(id)).length,
};
const preview: CommissionImportPreview = {
categories,
summary,
previous_import_id: previous?.id ?? null,
preview_hash: fingerprint({
input: inputFields(input),
categories,
mappings: [...mappings],
previous: previous?.id ?? null,
}),
};
return { preview, mappings };
}
async publish(
workspaceId: string,
userId: string,
key: string,
input: CommissionImportPublish,
) {
return this.idempotency.run(
{
workspaceId,
userId,
key,
method: 'POST',
path: '/api/system/pricing/commissions/imports',
request: input,
},
async (tx) => {
// 发布时重新查询有效管理员授权,避免仅信任旧 JWT。
const grant = await tx.query(
`SELECT g.user_id FROM atlas.system_admin_grants g JOIN atlas.users u ON u.id=g.user_id WHERE g.user_id=$1 AND g.revoked_at IS NULL AND u.status='active' FOR SHARE OF u`,
[userId],
);
if (!grant.rowCount)
throw new ApiError(
403,
'SYSTEM_ADMIN_REQUIRED',
'需要系统管理员权限',
);
const { preview, mappings } = await this.inspect(
tx,
input,
true,
);
if (preview.preview_hash !== input.preview_hash)
throw new ApiError(
409,
'COMMISSION_PREVIEW_STALE',
'目录、当前版本或输入已变化,请重新预览',
);
if (!preview.summary.eligible)
throw new ApiError(
422,
'COMMISSION_IMPORT_INVALID',
'没有可启用的类目,请核对异常项',
);
const contentHash = fingerprint(input.document);
if (
(
await tx.query(
`SELECT id FROM atlas.commission_imports WHERE platform_id=$1 AND (content_hash=$2 OR version=$3)`,
[input.platform_id, contentHash, input.version],
)
).rowCount
)
throw new ApiError(
409,
'COMMISSION_IMPORT_EXISTS',
'此版本名称或 JSON 内容已导入',
);
const typeIds = [...mappings.values()].flat();
if (
(
await tx.query(
`SELECT id FROM atlas.platform_product_category_commission_mappings WHERE platform_id=$1 AND platform_product_category_id=ANY($2::uuid[]) AND fulfillment='rfbs' AND market_code='RU' AND status='active' AND (effective_to IS NULL OR effective_to>now()) AND import_id IS NULL FOR SHARE`,
[input.platform_id, typeIds],
)
).rowCount
)
throw new ApiError(
409,
'COMMISSION_MAPPING_CONFLICT',
'部分类型已有其他来源的有效佣金映射,请先核对',
);
if (preview.previous_import_id) {
await tx.query(
`UPDATE atlas.commission_category_rules SET status='archived',effective_to=now() WHERE import_id=$1 AND effective_to IS NULL`,
[preview.previous_import_id],
);
await tx.query(
`UPDATE atlas.platform_product_category_commission_mappings SET status='archived',effective_to=now() WHERE import_id=$1 AND effective_to IS NULL`,
[preview.previous_import_id],
);
await tx.query(
`UPDATE atlas.commission_imports SET is_current=false WHERE id=$1`,
[preview.previous_import_id],
);
}
const id = randomUUID();
const saved = (
await tx.query<CommissionImportVersion>(
`INSERT INTO atlas.commission_imports(id,platform_id,version,source_reference,captured_on,document,content_hash,categories,summary,activated_by) VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10) RETURNING ${columns}`,
[
id,
input.platform_id,
input.version,
input.source_reference,
input.captured_on,
JSON.stringify(input.document),
contentHash,
JSON.stringify(preview.categories),
JSON.stringify(preview.summary),
userId,
],
)
).rows[0];
const roots = new Map<string, string>();
for (const c of preview.categories.filter(
(c) => c.status === 'eligible',
)) {
let rootId = roots.get(c.parent_source_id);
if (!rootId) {
rootId = await this.category(
tx,
input.platform_id,
c.parent_source_id,
c.parent_name,
`毛子ERP / ${c.parent_name} (${c.parent_source_id})`,
null,
);
roots.set(c.parent_source_id, rootId);
}
const categoryId = await this.category(
tx,
input.platform_id,
c.source_category_id,
c.name,
`毛子ERP / ${c.parent_name} / ${c.name} (${c.source_category_id})`,
rootId,
);
await tx.query(
`INSERT INTO atlas.commission_category_rules(commission_category_id,fulfillment,market_code,currency_code,min_price,max_price,min_inclusive,max_inclusive,commission_rate,source,version,status,effective_from,import_id)
SELECT $1,'rfbs','RU','RUB',min_price::numeric,max_price::numeric,min_inclusive,max_inclusive,commission_rate::numeric,'competitor_capture',$2,'active',now(),$3 FROM jsonb_to_recordset($4::jsonb) AS b(min_price text,max_price text,min_inclusive boolean,max_inclusive boolean,commission_rate text)`,
[
categoryId,
input.version,
id,
JSON.stringify(c.bands),
],
);
await tx.query(
`INSERT INTO atlas.platform_product_category_commission_mappings(platform_id,platform_product_category_id,commission_category_id,fulfillment,market_code,priority,source,status,effective_from,import_id)
SELECT $1,unnest($2::uuid[]),$3,'rfbs','RU',100,'competitor_capture','active',now(),$4`,
[
input.platform_id,
mappings.get(c.source_category_id),
categoryId,
id,
],
);
}
return { status: 201, body: { data: saved } };
},
);
}
private async category(
tx: Tx,
platform: string,
key: string,
name: string,
path: string,
parent: string | null,
): Promise<string> {
const existing = (
await tx.query<{ id: string }>(
`SELECT id FROM atlas.commission_categories WHERE platform_id=$1 AND source_category_key=$2`,
[platform, `maozierp:${key}`],
)
).rows[0];
if (existing) return existing.id;
return (
await tx.query<{ id: string }>(
`INSERT INTO atlas.commission_categories(platform_id,parent_id,source_category_key,name,path,source,status) VALUES($1,$2,$3,$4,$5,'competitor_capture','active') RETURNING id`,
[platform, parent, `maozierp:${key}`, name, path],
)
).rows[0].id;
}
}
+28
View File
@@ -0,0 +1,28 @@
import type { DatabaseTransaction } from '@atlas/database';
import type { CommissionRule } from './pricing-engine.js';
/** 查询与正式报价共用同一组已启用规则,避免页面显示和实际算价使用不同费率。 */
export async function readCommissionRules(
tx: DatabaseTransaction,
platformId: string,
categoryId: string,
fulfillment: string,
) {
const mapping = (
await tx.query<{ commission_category_id: string }>(
`SELECT * FROM atlas.platform_product_category_commission_mappings WHERE platform_id=$1 AND platform_product_category_id=$2 AND fulfillment=$3 AND market_code='RU' AND status='active' AND effective_from<=now() AND (effective_to IS NULL OR effective_to>now()) ORDER BY priority,effective_from DESC,id LIMIT 1 FOR SHARE`,
[platformId, categoryId, fulfillment],
)
).rows[0];
const commissions = mapping
? (
await tx.query<CommissionRule>(
`SELECT r.*,c.name AS category_name FROM atlas.commission_category_rules r JOIN atlas.commission_categories c ON c.id=r.commission_category_id
WHERE r.commission_category_id=$1 AND r.fulfillment=$2 AND r.market_code='RU' AND r.currency_code IN ('CNY','RUB')
AND r.status='active' AND r.effective_from<=now() AND (r.effective_to IS NULL OR r.effective_to>now()) ORDER BY r.min_price NULLS FIRST,r.id FOR SHARE OF r,c`,
[mapping.commission_category_id, fulfillment],
)
).rows
: [];
return { mapping, commissions };
}
@@ -0,0 +1,82 @@
import {
Body,
Controller,
Get,
Headers,
Inject,
Param,
Post,
Query,
Req,
UseGuards,
} from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import { pricingRateReviewRequestSchema } from '@atlas/contracts';
import { z } from 'zod';
import type { AuthenticatedRequest } from '../auth/auth.guard.js';
import { ApiError } from '../common/api-error.js';
import { SystemAdminGuard } from '../platform-catalog-admin/system-admin.guard.js';
import { PricingAdminService } from './pricing-admin.service.js';
function uuid(value: string, field: string): string {
if (!z.uuid().safeParse(value).success)
throw new ApiError(400, 'VALIDATION_ERROR', `${field} 无效`);
return value;
}
@ApiTags('System pricing')
@ApiBearerAuth('access-token')
@UseGuards(SystemAdminGuard)
@Controller('system/pricing/rate-cards')
export class PricingAdminController {
constructor(
@Inject(PricingAdminService)
private readonly service: PricingAdminService,
) {}
@Get()
async list(@Query('platform_id') platformId: string) {
return {
data: await this.service.list(uuid(platformId, 'platform_id')),
};
}
@Get(':id')
async detail(@Param('id') id: string) {
return { data: await this.service.detail(uuid(id, 'rate_card_id')) };
}
@Post(':id/reviews')
async approve(
@Param('id') id: string,
@Headers('idempotency-key') key: string | undefined,
@Body() body: unknown,
@Req() request: AuthenticatedRequest,
) {
if (!key)
throw new ApiError(
400,
'IDEMPOTENCY_KEY_REQUIRED',
'需要 Idempotency-Key',
);
const parsed = pricingRateReviewRequestSchema.safeParse(body);
if (!parsed.success)
throw new ApiError(
422,
'VALIDATION_ERROR',
'请检查审核证据与账单样本',
parsed.error.issues.map((issue) => ({
field: issue.path.join('.'),
message: issue.message,
})),
);
return {
data: await this.service.approve(
uuid(id, 'rate_card_id'),
request.auth.userId,
uuid(key, 'Idempotency-Key'),
parsed.data,
),
};
}
}
@@ -0,0 +1,352 @@
import { Inject, Injectable } from '@nestjs/common';
import type { Database, DatabaseTransaction } from '@atlas/database';
import type {
PricingRateCardAdmin,
PricingRateReviewRequest,
} from '@atlas/contracts';
import { DATABASE } from '../database/database.module.js';
import { ApiError } from '../common/api-error.js';
import {
calculateLogisticsFee,
D,
fingerprint,
logisticsMerchandiseFits,
type LogisticsRule,
} from './pricing-engine.js';
type Tx = DatabaseTransaction;
type CardRow = {
id: string;
platform_id: string;
provider_code: string;
version: string;
source_name: string;
source_uri: string | null;
status: string;
fulfillment: string;
market_code: string;
effective_from: Date;
reviewed_at: Date | null;
reviewer_name: string | null;
};
type TypeRow = PricingRateCardAdmin['types'][number] & Record<string, unknown>;
type RuleRow = LogisticsRule & {
product_type_code: string;
transport_method_name: string;
status: string;
product_type_name: string;
transport_name: string;
};
@Injectable()
export class PricingAdminService {
constructor(@Inject(DATABASE) private readonly db: Database) {}
async list(platformId: string): Promise<PricingRateCardAdmin[]> {
const ids = await this.db.query<{ id: string }>(
`SELECT id FROM atlas.logistics_rate_cards WHERE platform_id=$1 AND fulfillment='rfbs' AND market_code='RU' ORDER BY created_at DESC`,
[platformId],
);
const cards: PricingRateCardAdmin[] = [];
for (const row of ids.rows) cards.push(await this.detail(row.id));
return cards;
}
async detail(id: string): Promise<PricingRateCardAdmin> {
return this.db.transaction((tx) => this.read(tx, id));
}
private async read(tx: Tx, id: string): Promise<PricingRateCardAdmin> {
const card = (
await tx.query<CardRow>(
`SELECT c.id,c.platform_id,p.code AS provider_code,c.version,c.source_name,c.source_uri,c.status,c.fulfillment,c.market_code,c.effective_from,c.reviewed_at,u.display_name AS reviewer_name
FROM atlas.logistics_rate_cards c JOIN atlas.logistics_providers p ON p.id=c.provider_id
LEFT JOIN atlas.users u ON u.id=c.reviewed_by_user_id WHERE c.id=$1`,
[id],
)
).rows[0];
if (!card)
throw new ApiError(404, 'RATE_CARD_NOT_FOUND', '物流费率卡不存在');
const types = (
await tx.query<TypeRow>(
`SELECT id,code::text,min_merchandise_value::text,max_merchandise_value::text,min_actual_weight_kg::text,max_actual_weight_kg::text,billing_basis::text,
merchandise_currency_code,min_value_inclusive,max_value_inclusive,min_actual_weight_inclusive,max_sum_dimensions_cm::text,max_longest_side_cm::text,max_second_longest_side_cm::text,volume_divisor::text,volume_condition_sum_gt_cm::text,volume_round_scale,billing_round_scale,max_volume_weight_kg::text,volume_limit_round_scale
FROM atlas.logistics_product_types WHERE rate_card_id=$1 ORDER BY code`,
[id],
)
).rows;
const rules = (
await tx.query<RuleRow>(
`SELECT r.id,r.product_type_id,t.code::text AS product_type_code,t.name AS product_type_name,r.transport_method_id,m.name AS transport_method_name,m.name AS transport_name,
r.fixed_fee_cny::text,r.per_billable_kg_fee_cny::text,r.min_charge_cny::text,r.surcharge_rules,r.status::text,
t.merchandise_currency_code,t.min_merchandise_value::text,t.max_merchandise_value::text,t.min_value_inclusive,t.max_value_inclusive,
t.min_actual_weight_kg::text,t.min_actual_weight_inclusive,t.max_actual_weight_kg::text,t.max_sum_dimensions_cm::text,t.max_longest_side_cm::text,t.max_second_longest_side_cm::text,
t.billing_basis::text,t.volume_divisor::text,t.volume_condition_sum_gt_cm::text,t.volume_round_scale,t.billing_round_scale,t.max_volume_weight_kg::text,t.volume_limit_round_scale,
r.updated_at AS rule_updated_at,t.updated_at AS type_updated_at,m.updated_at AS transport_updated_at,m.status::text AS transport_status
FROM atlas.logistics_rate_rules r JOIN atlas.logistics_product_types t ON t.id=r.product_type_id
JOIN atlas.logistics_transport_methods m ON m.id=r.transport_method_id AND m.rate_card_id=t.rate_card_id
WHERE t.rate_card_id=$1 ORDER BY t.code,m.code`,
[id],
)
).rows;
const review =
(
await tx.query<{
source_reference: string;
review_note: string;
samples: PricingRateReviewRequest['samples'];
reviewed_at: Date;
}>(
`SELECT source_reference,review_note,samples,reviewed_at FROM atlas.logistics_rate_card_reviews WHERE rate_card_id=$1`,
[id],
)
).rows[0] ?? null;
const { reviewer_name: reviewerName, ...publicCard } = card;
return {
...publicCard,
effective_from: card.effective_from.toISOString(),
reviewed_at: card.reviewed_at?.toISOString() ?? null,
reviewer_name: reviewerName,
rules_hash: fingerprint({
card: {
id: card.id,
version: card.version,
source_name: card.source_name,
source_uri: card.source_uri,
},
types,
rules,
}),
types,
rules: rules.map((r) => ({
id: r.id,
product_type_id: r.product_type_id,
product_type_code: r.product_type_code,
transport_method_id: r.transport_method_id,
transport_method_name: r.transport_method_name,
fixed_fee_cny: r.fixed_fee_cny,
per_billable_kg_fee_cny: r.per_billable_kg_fee_cny,
min_charge_cny: r.min_charge_cny,
status: r.status,
})),
review: review && {
...review,
reviewed_at: review.reviewed_at.toISOString(),
},
};
}
async approve(
id: string,
userId: string,
key: string,
input: PricingRateReviewRequest,
) {
return this.db.transaction(async (tx) => {
await tx.query(
`SELECT pg_advisory_xact_lock(hashtextextended($1,0))`,
[`pricing-review:${userId}:${key}`],
);
const card = (
await tx.query<{
status: string;
provider_code: string;
platform_code: string;
}>(
`SELECT c.status,p.code AS provider_code,platform.code AS platform_code
FROM atlas.logistics_rate_cards c JOIN atlas.logistics_providers p ON p.id=c.provider_id
JOIN atlas.platforms platform ON platform.id=c.platform_id WHERE c.id=$1 FOR UPDATE OF c`,
[id],
)
).rows[0];
if (!card)
throw new ApiError(
404,
'RATE_CARD_NOT_FOUND',
'物流费率卡不存在',
);
const requestHash = fingerprint(input);
const replay = (
await tx.query<{ rate_card_id: string; request_hash: string }>(
`SELECT rate_card_id,request_hash FROM atlas.logistics_rate_card_reviews WHERE reviewer_user_id=$1 AND idempotency_key=$2`,
[userId, key],
)
).rows[0];
if (replay) {
if (
replay.rate_card_id !== id ||
replay.request_hash !== requestHash
)
throw new ApiError(
409,
'IDEMPOTENCY_KEY_CONFLICT',
'幂等键已用于其他审核请求',
);
return this.read(tx, id);
}
if (card.status !== 'draft')
throw new ApiError(
409,
'RATE_CARD_NOT_DRAFT',
'费率卡已审核或不可审核,请刷新',
);
if (
card.provider_code.toUpperCase() !== 'CEL' ||
card.platform_code.toUpperCase() !== 'OZON'
)
throw new ApiError(
422,
'RATE_CARD_REVIEW_UNSUPPORTED',
'当前审核流程仅支持 Ozon CEL 费率',
);
const detail = await this.read(tx, id);
if (detail.rules_hash !== input.rules_hash)
throw new ApiError(
409,
'RATE_CARD_RULES_CHANGED',
'费率规则已变化,请重新查看并复核',
);
const types = new Set(detail.types.map((type) => type.id));
const typeCodes = new Set(detail.types.map((type) => type.code));
if (
types.size !== 6 ||
[
'ExtraSmall',
'Budget',
'Small',
'Big',
'PremiumSmall',
'PremiumBig',
].some((code) => !typeCodes.has(code)) ||
detail.rules.length !== 16 ||
detail.rules.some((r) => r.status !== 'draft')
)
throw new ApiError(
422,
'RATE_CARD_INCOMPLETE',
'CEL 费率应包含六种产品类型和十六条待审核规则',
);
if (
new Date(input.effective_from).getTime() >
Date.now() + 365 * 86400000
)
throw new ApiError(
422,
'RATE_CARD_EFFECTIVE_DATE_INVALID',
'生效日期不能晚于一年后',
);
const fullRules = (
await tx.query<RuleRow>(
`SELECT r.*,t.code::text AS product_type_code,t.name AS product_type_name,m.name AS transport_name,
t.merchandise_currency_code,t.min_merchandise_value::text,t.max_merchandise_value::text,t.min_value_inclusive,t.max_value_inclusive,
t.min_actual_weight_kg::text,t.min_actual_weight_inclusive,t.max_actual_weight_kg::text,t.max_sum_dimensions_cm::text,t.max_longest_side_cm::text,t.max_second_longest_side_cm::text,
t.billing_basis::text,t.volume_divisor::text,t.volume_condition_sum_gt_cm::text,t.volume_round_scale,t.billing_round_scale,t.max_volume_weight_kg::text,t.volume_limit_round_scale,m.status::text AS transport_status
FROM atlas.logistics_rate_rules r JOIN atlas.logistics_product_types t ON t.id=r.product_type_id
JOIN atlas.logistics_transport_methods m ON m.id=r.transport_method_id WHERE t.rate_card_id=$1 FOR SHARE OF r,t,m`,
[id],
)
).rows;
if (
fullRules.some(
(rule) =>
rule.surcharge_rules.length > 0 ||
(rule as RuleRow & { transport_status: string })
.transport_status !== 'active',
)
)
throw new ApiError(
422,
'RATE_CARD_UNSUPPORTED_RULE',
'存在尚未支持的附加费或停用运输方式',
);
const covered = new Set<string>();
const invoiceReferences = new Set<string>();
for (const [index, sample] of input.samples.entries()) {
const invoiceReference = sample.invoice_reference
.trim()
.toLocaleLowerCase();
if (invoiceReferences.has(invoiceReference))
throw new ApiError(
422,
'RATE_CARD_SAMPLE_DUPLICATE',
'账单编号不能重复使用',
);
invoiceReferences.add(invoiceReference);
const rule = fullRules.find((r) => r.id === sample.rule_id);
if (!rule)
throw new ApiError(
422,
'RATE_CARD_SAMPLE_INVALID',
`第 ${index + 1} 条账单不属于当前费率卡`,
);
covered.add(rule.product_type_id);
if (
!logisticsMerchandiseFits(
rule,
sample.merchandise_value_cny,
)
)
throw new ApiError(
422,
'RATE_CARD_SAMPLE_INVALID',
`第 ${index + 1} 条账单货值不在该产品类型范围`,
);
if (D(sample.shipping_weight_g).lt(1))
throw new ApiError(
422,
'RATE_CARD_SAMPLE_INVALID',
`第 ${index + 1} 条账单重量至少 1 g`,
);
const fee = calculateLogisticsFee(rule, {
shipping_weight_g: sample.shipping_weight_g,
length_mm: sample.length_mm,
width_mm: sample.width_mm,
height_mm: sample.height_mm,
status: 'confirmed',
});
if (
'reason' in fee ||
fee.logistics
.sub(sample.billed_logistics_cny)
.abs()
.gt('0.01')
)
throw new ApiError(
422,
'RATE_CARD_SAMPLE_MISMATCH',
`第 ${index + 1} 条账单与费率计算不一致`,
);
}
if ([...types].some((type) => !covered.has(type)))
throw new ApiError(
422,
'RATE_CARD_SAMPLE_INCOMPLETE',
'每种 CEL 产品类型至少需要一条真实账单样本',
);
await tx.query(
`UPDATE atlas.logistics_rate_rules r SET status='active',updated_at=now() FROM atlas.logistics_product_types t WHERE r.product_type_id=t.id AND t.rate_card_id=$1`,
[id],
);
await tx.query(
`UPDATE atlas.logistics_rate_cards SET status='active',effective_from=$2,reviewed_by_user_id=$3,reviewed_at=now(),updated_at=now() WHERE id=$1`,
[id, input.effective_from, userId],
);
await tx.query(
`INSERT INTO atlas.logistics_rate_card_reviews(rate_card_id,reviewer_user_id,idempotency_key,request_hash,rules_hash,source_reference,review_note,samples,effective_from)
VALUES($1,$2,$3,$4,$5,$6,$7,$8::jsonb,$9)`,
[
id,
userId,
key,
requestHash,
input.rules_hash,
input.source_reference,
input.review_note,
JSON.stringify(input.samples),
input.effective_from,
],
);
return this.read(tx, id);
});
}
}
+309
View File
@@ -0,0 +1,309 @@
import { normalizePricingInput } from '@atlas/contracts';
import { randomUUID, createHash } from 'node:crypto';
import { Decimal } from 'decimal.js';
import type { PricingOption, PricingPreviewRequest } from '@atlas/contracts';
Decimal.set({ precision: 40, rounding: Decimal.ROUND_HALF_UP });
export const D = (value: Decimal.Value) => new Decimal(value);
export function fingerprint(value: unknown): string {
const sort = (v: unknown): unknown =>
v instanceof Date
? v.toISOString()
: Array.isArray(v)
? v.map(sort)
: v && typeof v === 'object'
? Object.fromEntries(
Object.entries(v)
.sort(([a], [b]) => a.localeCompare(b))
.map(([k, x]) => [k, sort(x)]),
)
: v;
return createHash('sha256')
.update(JSON.stringify(sort(value)))
.digest('hex');
}
export type LogisticsRule = {
id: string;
product_type_id: string;
transport_method_id: string;
transport_name: string;
product_type_name: string;
fixed_fee_cny: string;
per_billable_kg_fee_cny: string;
min_charge_cny: string | null;
surcharge_rules: unknown[];
merchandise_currency_code: string;
min_merchandise_value: string | null;
max_merchandise_value: string | null;
min_value_inclusive: boolean;
max_value_inclusive: boolean;
min_actual_weight_kg: string;
min_actual_weight_inclusive: boolean;
max_actual_weight_kg: string | null;
max_sum_dimensions_cm: string | null;
max_longest_side_cm: string | null;
max_second_longest_side_cm: string | null;
billing_basis: string;
volume_divisor: string | null;
volume_condition_sum_gt_cm: string | null;
volume_round_scale: number | null;
billing_round_scale: number | null;
max_volume_weight_kg: string | null;
volume_limit_round_scale: number | null;
};
export type CommissionRule = {
category_name?: string;
source?: string;
effective_from?: string | Date;
id: string | null;
currency_code: string;
min_price: string | null;
max_price: string | null;
min_inclusive: boolean;
max_inclusive: boolean;
commission_rate: string;
version: string;
};
const inRange = (
n: Decimal,
min: string | null,
max: string | null,
includeMin: boolean,
includeMax: boolean,
) =>
(min === null || (includeMin ? n.gte(min) : n.gt(min))) &&
(max === null || (includeMax ? n.lte(max) : n.lt(max)));
const ceil = (n: Decimal, scale: number | null) =>
scale === null ? n : n.toDecimalPlaces(scale, Decimal.ROUND_CEIL);
export const normalizeInput = normalizePricingInput;
/** 审核样本与正式报价共用包装适用性和运费计算,避免两套规则产生不同结果。 */
export function calculateLogisticsFee(
r: LogisticsRule,
packageSpec: PricingPreviewRequest['package'],
): { billable: Decimal; logistics: Decimal } | { reason: string } {
if (r.surcharge_rules.length)
return { reason: '此规则含尚未支持的附加费,需完成审核后使用' };
const weight = D(packageSpec.shipping_weight_g).div(1000);
const sides = [
packageSpec.length_mm,
packageSpec.width_mm,
packageSpec.height_mm,
]
.map((v) => D(v).div(10))
.sort((a, b) => b.cmp(a));
const sum = sides.reduce((a, b) => a.add(b), D(0));
if (
!inRange(
weight,
r.min_actual_weight_kg,
r.max_actual_weight_kg,
r.min_actual_weight_inclusive,
true,
) ||
(r.max_sum_dimensions_cm && sum.gt(r.max_sum_dimensions_cm)) ||
(r.max_longest_side_cm && sides[0].gt(r.max_longest_side_cm)) ||
(r.max_second_longest_side_cm &&
sides[1].gt(r.max_second_longest_side_cm))
)
return { reason: '包装重量或尺寸超出物流范围' };
let billable = weight;
if (r.billing_basis !== 'actual_only') {
if (!r.volume_divisor || D(r.volume_divisor).lte(0))
return { reason: '体积重规则无效' };
const volume = sides
.reduce((a, b) => a.mul(b), D(1))
.div(r.volume_divisor);
if (
r.max_volume_weight_kg &&
ceil(volume, r.volume_limit_round_scale).gt(r.max_volume_weight_kg)
)
return { reason: '体积重超限' };
if (
r.billing_basis === 'max_actual_volume' ||
(r.volume_condition_sum_gt_cm &&
sum.gt(r.volume_condition_sum_gt_cm))
)
billable = Decimal.max(weight, ceil(volume, r.volume_round_scale));
}
billable = ceil(billable, r.billing_round_scale);
const logistics = Decimal.max(
billable.mul(r.per_billable_kg_fee_cny).add(r.fixed_fee_cny),
r.min_charge_cny ?? '0',
);
return { billable, logistics };
}
export function logisticsMerchandiseFits(
r: LogisticsRule,
valueCny: string,
): boolean {
return (
r.merchandise_currency_code === 'CNY' &&
inRange(
D(valueCny),
r.min_merchandise_value,
r.max_merchandise_value,
r.min_value_inclusive,
r.max_value_inclusive,
)
);
}
/** 枚举自洽候选;先舍入目标币种售价,再回验佣金档与货值档,避免跨档误报。 */
export function calculatePricing(
input: PricingPreviewRequest,
currency: string,
fx: string,
rules: LogisticsRule[],
commissions: CommissionRule[],
) {
if (!['CNY', 'RUB'].includes(currency))
throw new Error('门店币种仅支持 CNY / RUB');
const p = input.pricing,
exchange = D(fx),
purchase = D(input.purchase_cost_cny),
domestic = D(p.domestic_shipping_and_label_fee_cny);
const options: PricingOption[] = [],
unavailable_options: Array<{
transport_method_id: string;
reason: string;
}> = [];
for (const transport of p.transport_method_ids) {
const reasons = new Set<string>();
for (const r of rules.filter(
(r) => r.transport_method_id === transport,
)) {
const fee = calculateLogisticsFee(r, input.package);
if ('reason' in fee) {
reasons.add(fee.reason);
continue;
}
const { billable, logistics } = fee;
for (const c of commissions) {
const totalRate = D(c.commission_rate)
.add(p.ad_rate)
.add(p.last_mile_rate)
.add(p.withdrawal_rate)
.add(p.other_rate);
if (totalRate.gte(1)) {
reasons.add('总扣费率达到或超过 100%');
continue;
}
const factor = currency === 'RUB' ? exchange : D(1);
const sale =
p.mode === 'fixed_sale'
? D(p.selling_price!)
: purchase
.mul(D(1).add(p.desired_markup_rate))
.add(logistics)
.add(domestic)
.div(D(1).sub(totalRate))
.mul(factor);
const rounded = sale.toDecimalPlaces(
2,
p.mode === 'fixed_sale'
? Decimal.ROUND_HALF_UP
: Decimal.ROUND_CEIL,
),
cny = rounded.div(factor),
rub = cny.mul(exchange);
if (
rounded.lte(0) ||
!inRange(
c.currency_code === 'RUB' ? rub : cny,
c.min_price,
c.max_price,
c.min_inclusive,
c.max_inclusive,
) ||
!inRange(
r.merchandise_currency_code === 'RUB' ? rub : cny,
r.min_merchandise_value,
r.max_merchandise_value,
r.min_value_inclusive,
r.max_value_inclusive,
)
) {
reasons.add('售价不在该物流货值区间或佣金档内');
continue;
}
const profit = cny
.mul(D(1).sub(totalRate))
.sub(purchase)
.sub(logistics)
.sub(domestic),
list = rounded
.div(D(1).sub(p.list_price_discount_rate))
.toDecimalPlaces(2, Decimal.ROUND_CEIL);
if (
[
rounded,
list,
rub,
list.div(factor).mul(exchange),
profit.mul(exchange),
logistics,
].some((v) => v.abs().gte('100000000000000'))
) {
reasons.add('金额超过可保存范围,请调整费率或输入');
continue;
}
options.push({
id: randomUUID(),
can_confirm: profit.gte(0),
transport_method_id: transport,
transport_name: r.transport_name,
product_type_id: r.product_type_id,
product_type_name: r.product_type_name,
logistics_rate_rule_id: r.id,
commission_rule_id: c.id,
commission_rate: c.commission_rate,
...(c.id ? {commission_details: {
category_name: c.category_name ?? '',currency_code:c.currency_code,version:c.version,source:c.source ?? 'manual_rule',
min_price:c.min_price,max_price:c.max_price,min_inclusive:c.min_inclusive,max_inclusive:c.max_inclusive,commission_rate:c.commission_rate,
}} : {}),
selling_price: rounded.toFixed(2),
list_price: list.toFixed(2),
currency_code: currency,
selling_price_rub: rub.toFixed(4),
list_price_rub: list.div(factor).mul(exchange).toFixed(4),
merchandise_value_cny: cny.toFixed(4),
profit_cny: profit.toFixed(2),
profit_rub: profit.mul(exchange).toFixed(4),
margin_rate: profit.div(cny).toFixed(6),
billable_weight_kg: billable.toFixed(3),
fees: {
logistics_cny: logistics.toFixed(4),
domestic_cny: domestic.toFixed(4),
commission_cny: cny.mul(c.commission_rate).toFixed(4),
ad_cny: cny.mul(p.ad_rate).toFixed(4),
last_mile_cny: cny.mul(p.last_mile_rate).toFixed(4),
withdrawal_cny: cny.mul(p.withdrawal_rate).toFixed(4),
other_cny: cny.mul(p.other_rate).toFixed(4),
},
warnings: [
...(profit.lt(0) ? ['亏损报价不能确认'] : []),
...(profit.lt(purchase.mul(p.desired_markup_rate))
? ['未达到目标加价利润']
: []),
...(!c.id ? ['使用临时佣金率'] : []),
],
});
}
}
if (!options.some((o) => o.transport_method_id === transport))
unavailable_options.push({
transport_method_id: transport,
reason: [...reasons].join(';') || '没有可用规则',
});
}
options.sort((a, b) => D(a.selling_price).cmp(b.selling_price));
const recommended =
options.find(
(o) => o.transport_method_id === p.default_transport_method_id,
) ?? (!p.default_transport_method_id ? options[0] : undefined);
return {
options,
unavailable_options,
recommended_option_id: recommended?.id ?? null,
};
}
+199
View File
@@ -0,0 +1,199 @@
import {
Body,
Controller,
Delete,
Get,
Headers,
Inject,
Param,
Patch,
Post,
Query,
Req,
Res,
} from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import { z } from 'zod';
import {
pricingPreviewRequestSchema,
pricingPolicyRequestSchema,
type Workspace,
} from '@atlas/contracts';
import { CurrentWorkspace } from '../workspace/current-workspace.decorator.js';
import type { AuthenticatedRequest } from '../auth/auth.guard.js';
import { ApiError } from '../common/api-error.js';
import { PricingService } from './pricing.service.js';
type Response = { status: (status: number) => unknown };
function parse<T>(schema: z.ZodType<T>, value: unknown): T {
const result = schema.safeParse(value);
if (!result.success)
throw new ApiError(
422,
'VALIDATION_ERROR',
'请检查定价输入',
result.error.issues.map((i) => ({
field: i.path.join('.'),
message: i.message,
})),
);
return result.data;
}
function key(value: string | undefined) {
if (!value || value.length > 200)
throw new ApiError(
400,
'IDEMPOTENCY_KEY_REQUIRED',
'需要 Idempotency-Key',
);
return value;
}
@ApiTags('Pricing')
@ApiBearerAuth('access-token')
@Controller()
export class PricingController {
constructor(
@Inject(PricingService) private readonly service: PricingService,
) {}
@Get('pricing/commission-rules') async commissionRules(
@CurrentWorkspace() w: Workspace,
@Query('store_id') store: string,
@Query('platform_product_category_id') category: string | undefined,
@Query('description_category_id') external: string | undefined,
) {
if (Boolean(category) === Boolean(external)) throw new ApiError(422,'VALIDATION_ERROR','请指定一种类目 ID');
return {data:await this.service.commissionRules(w.id,parse(z.uuid(),store),category ? parse(z.uuid(),category) : undefined,external ? parse(z.string().regex(/^[1-9]\d{0,17}$/),external) : undefined)};
}
@Get('pricing/categories') async categories(
@CurrentWorkspace() w: Workspace,
@Query('store_id') store: string,
@Query('q') q: string | undefined,
) {
return {
data: await this.service.categories(
w.id,
parse(z.uuid(), store),
parse(z.string().max(200), q ?? ''),
),
};
}
@Get('pricing/catalog') async catalog(
@CurrentWorkspace() w: Workspace,
@Query('store_id') store: string,
) {
return {
data: await this.service.catalog(w.id, parse(z.uuid(), store)),
};
}
@Get('stores/:id/pricing-policies') async policies(
@CurrentWorkspace() w: Workspace,
@Param('id') id: string,
) {
return {
data: (await this.service.catalog(w.id, parse(z.uuid(), id)))
.policies,
};
}
@Post('pricing/quote-previews') async preview(
@CurrentWorkspace() w: Workspace,
@Req() req: AuthenticatedRequest,
@Headers('idempotency-key') k: string | undefined,
@Body() body: unknown,
@Res({ passthrough: true }) res: Response,
) {
const r = await this.service.preview(
w.id,
req.auth.userId,
key(k),
parse(pricingPreviewRequestSchema, body),
);
res.status(r.status);
return r.body;
}
@Post('stores/:id/pricing-policies') async create(
@CurrentWorkspace() w: Workspace,
@Req() req: AuthenticatedRequest,
@Param('id') id: string,
@Headers('idempotency-key') k: string | undefined,
@Body() body: unknown,
@Res({ passthrough: true }) res: Response,
) {
const r = await this.service.writePolicy(
w.id,
req.auth.userId,
key(k),
parse(z.uuid(), id),
null,
'create',
parse(pricingPolicyRequestSchema, body),
null,
);
res.status(r.status);
return r.body;
}
@Patch('pricing-policies/:id') async update(
@CurrentWorkspace() w: Workspace,
@Req() req: AuthenticatedRequest,
@Param('id') id: string,
@Headers('idempotency-key') k: string | undefined,
@Headers('if-match') match: string | undefined,
@Body() body: unknown,
) {
if (!match || !/^(?:[1-9]\d*|"[1-9]\d*")$/.test(match))
throw new ApiError(
428,
'PRECONDITION_REQUIRED',
'需要方案版本 If-Match',
);
return (
await this.service.writePolicy(
w.id,
req.auth.userId,
key(k),
null,
parse(z.uuid(), id),
'update',
parse(pricingPolicyRequestSchema, body),
Number(match.replaceAll('"', '')),
)
).body;
}
@Post('pricing-policies/:id/set-default') async defaultPolicy(
@CurrentWorkspace() w: Workspace,
@Req() req: AuthenticatedRequest,
@Param('id') id: string,
@Headers('idempotency-key') k: string | undefined,
@Res({ passthrough: true }) res: Response,
) {
const r = await this.service.writePolicy(
w.id,
req.auth.userId,
key(k),
null,
parse(z.uuid(), id),
'default',
null,
null,
);
res.status(r.status);
return r.body;
}
@Delete('pricing-policies/:id') async archive(
@CurrentWorkspace() w: Workspace,
@Req() req: AuthenticatedRequest,
@Param('id') id: string,
@Headers('idempotency-key') k: string | undefined,
) {
return (
await this.service.writePolicy(
w.id,
req.auth.userId,
key(k),
null,
parse(z.uuid(), id),
'archive',
null,
null,
)
).body;
}
}
+21
View File
@@ -0,0 +1,21 @@
import { CommissionImportController } from './commission-import.controller.js';
import { CommissionImportService } from './commission-import.service.js';
import { Module } from '@nestjs/common';
import { IdempotencyService } from '../common/idempotency.service.js';
import { PricingController } from './pricing.controller.js';
import { PricingService } from './pricing.service.js';
import { PricingAdminController } from './pricing-admin.controller.js';
import { PricingAdminService } from './pricing-admin.service.js';
import { SystemAdminGuard } from '../platform-catalog-admin/system-admin.guard.js';
@Module({
controllers: [PricingController, PricingAdminController, CommissionImportController],
providers: [
PricingService,
CommissionImportService,
PricingAdminService,
SystemAdminGuard,
IdempotencyService,
],
exports: [PricingService],
})
export class PricingModule {}
+867
View File
@@ -0,0 +1,867 @@
import { readCommissionRules } from './commission-rules.js';
import type { CommissionRulesResult } from '@atlas/contracts';
import { Inject, Injectable } from '@nestjs/common';
import { randomUUID } from 'node:crypto';
import type { Database, DatabaseTransaction } from '@atlas/database';
import type {
PricingCatalog,
PricingPolicy,
PricingPolicyRequest,
PricingPreview,
PricingPreviewRequest,
PricingParameters,
SaveProductEditorDraftRequest,
} from '@atlas/contracts';
import { pricingPreviewRequestSchema } from '@atlas/contracts';
import { DATABASE } from '../database/database.module.js';
import { APP_CONFIG, type AppConfig } from '../config/app-config.module.js';
import { ApiError } from '../common/api-error.js';
import { IdempotencyService } from '../common/idempotency.service.js';
import {
calculatePricing,
D,
fingerprint,
normalizeInput,
type CommissionRule,
type LogisticsRule,
} from './pricing-engine.js';
type Tx = DatabaseTransaction;
type Store = {
id: string;
name: string;
platform_id: string;
currency_code: string;
};
type PolicyRow = {
id: string;
store_id: string;
name: string;
note: string | null;
revision: number;
is_default: boolean;
rate_card_id: string;
default_transport_method_id: string | null;
domestic_shipping_and_label_fee_cny: string;
ad_rate: string;
last_mile_rate: string;
withdrawal_rate: string;
other_rate: string;
desired_markup_rate: string;
list_price_discount_rate: string;
allow_commission_override: boolean;
fx_strategy: string;
fixed_cny_to_rub_rate: string | null;
transport_ids: string[];
};
const active = `status='active' AND effective_from<=now() AND (effective_to IS NULL OR effective_to>now())`;
@Injectable()
export class PricingService {
constructor(
@Inject(DATABASE) private readonly db: Database,
@Inject(IdempotencyService)
private readonly idempotency: IdempotencyService,
@Inject(APP_CONFIG) private readonly config: AppConfig,
) {}
private get usableCardCondition() {
const reviewed = `(${active} AND reviewed_at IS NOT NULL AND reviewed_by_user_id IS NOT NULL)`;
return this.config.pricingAllowDraftRates
? `(${reviewed} OR status='draft')`
: reviewed;
}
async store(tx: Tx, id: string): Promise<Store> {
const r = await tx.query<Store>(
`SELECT id,name,platform_id,currency_code FROM atlas.stores WHERE id=$1 AND deleted_at IS NULL`,
[id],
);
if (!r.rows[0])
throw new ApiError(404, 'STORE_NOT_FOUND', '门店不存在');
if (!['CNY', 'RUB'].includes(r.rows[0].currency_code))
throw new ApiError(
422,
'PRICING_CURRENCY_UNSUPPORTED',
'定价仅支持 CNY / RUB 门店',
);
return r.rows[0];
}
async policies(tx: Tx, storeId: string): Promise<PricingPolicy[]> {
const result = await tx.query<PolicyRow>(
`SELECT p.*,ARRAY(SELECT transport_method_id FROM atlas.pricing_policy_transport_methods t WHERE t.pricing_policy_id=p.id AND is_enabled ORDER BY sort_order,transport_method_id) AS transport_ids FROM atlas.pricing_policies p WHERE p.store_id=$1 AND p.status='active' ORDER BY p.is_default DESC,p.name`,
[storeId],
);
return result.rows.map((p) => ({
id: p.id,
store_id: p.store_id,
name: p.name,
note: p.note,
revision: p.revision,
is_default: p.is_default,
allow_commission_override: p.allow_commission_override,
parameters: {
pricing_policy_id: p.id,
rate_card_id: p.rate_card_id,
transport_method_ids: p.transport_ids,
default_transport_method_id: p.default_transport_method_id,
domestic_shipping_and_label_fee_cny:
p.domestic_shipping_and_label_fee_cny,
ad_rate: p.ad_rate,
last_mile_rate: p.last_mile_rate,
withdrawal_rate: p.withdrawal_rate,
other_rate: p.other_rate,
desired_markup_rate: p.desired_markup_rate,
list_price_discount_rate: p.list_price_discount_rate,
mode: 'target_markup',
selling_price: null,
temporary_commission_rate: null,
fx:
p.fx_strategy === 'fixed'
? {
mode: 'policy_fixed',
rate: p.fixed_cny_to_rub_rate!,
}
: { mode: 'system' },
},
}));
}
async commissionRules(workspaceId: string, storeId: string, categoryId?: string, externalId?: string): Promise<CommissionRulesResult> {
return this.db.workspaceTransaction(workspaceId, async tx => {
const store = await this.store(tx, storeId);
const categories = (await tx.query<{id:string}>(`SELECT id FROM atlas.platform_product_categories WHERE platform_id=$1 AND is_active AND ($2::uuid IS NULL OR id=$2) AND ($3::text IS NULL OR external_category_id=$3) ORDER BY id`, [store.platform_id, categoryId ?? null, externalId ?? null])).rows;
if (!categories.length) throw new ApiError(422, 'PRICING_CATEGORY_REQUIRED', '请选择当前平台的有效类目');
let resolvedCategoryId = categories[0].id;
// 外部类目下可以有多个类型,但不能将相互冲突的映射折叠成一个费率。
if (externalId) {
const mapped = await tx.query<{id:string; platform_product_category_id:string}>(`SELECT DISTINCT commission_category_id AS id,platform_product_category_id FROM atlas.platform_product_category_commission_mappings WHERE platform_id=$1 AND platform_product_category_id=ANY($2::uuid[]) AND fulfillment='rfbs' AND market_code='RU' AND status='active' AND effective_from<=now() AND (effective_to IS NULL OR effective_to>now())`,[store.platform_id,categories.map(c=>c.id)]);
if (new Set(mapped.rows.map(row => row.id)).size>1) throw new ApiError(422,'COMMISSION_MAPPING_CONFLICT','此类目下的商品类型使用不同规则,请指定具体商品类型');
resolvedCategoryId = mapped.rows[0]?.platform_product_category_id ?? resolvedCategoryId;
}
const {commissions} = await readCommissionRules(tx,store.platform_id,resolvedCategoryId,'rfbs');
if (!commissions.length) throw new ApiError(422,'PRICING_COMMISSION_UNAVAILABLE','该类目没有有效佣金规则');
const first=commissions[0];
return {category_name:first.category_name ?? '',currency_code:first.currency_code,version:first.version,source:first.source ?? 'manual_rule',effective_from:new Date(first.effective_from!).toISOString(),bands:commissions.map(c=>({id:c.id!,min_price:c.min_price,max_price:c.max_price,min_inclusive:c.min_inclusive,max_inclusive:c.max_inclusive,commission_rate:c.commission_rate}))};
});
}
async categories(workspaceId: string, storeId: string, q: string) {
return this.db.workspaceTransaction(workspaceId, async (tx) => {
const store = await this.store(tx, storeId);
return (
await tx.query<{ id: string; name: string; path: string }>(
`SELECT id,name,path FROM atlas.platform_product_categories WHERE platform_id=$1 AND is_active AND (name ILIKE $2 OR path ILIKE $2) ORDER BY path LIMIT 50`,
[store.platform_id, `%${q}%`],
)
).rows;
});
}
async exchange(tx: Tx) {
const r = await tx.query<{
id: string;
rate: string;
effective_on: string;
stale: boolean;
}>(
`SELECT id,rate::text,effective_on::text,(effective_on::timestamp AT TIME ZONE 'Europe/Moscow')<now()-interval '72 hours' AS stale FROM atlas.exchange_rates WHERE base_currency_code='CNY' AND quote_currency_code='RUB' AND source='cbr' AND kind='official_reference' ORDER BY effective_on DESC,fetched_at DESC LIMIT 1`,
);
return r.rows[0] ?? null;
}
async catalog(workspace: string, storeId: string): Promise<PricingCatalog> {
return this.db.workspaceTransaction(workspace, async (tx) => {
const store = await this.store(tx, storeId);
const cards = await tx.query<{
id: string;
name: string;
version: string;
development_only: boolean;
transports: Array<{ id: string; name: string }>;
}>(
`SELECT c.id,c.source_name AS name,c.version,(c.status='draft') AS development_only,coalesce((SELECT jsonb_agg(jsonb_build_object('id',t.id,'name',t.name) ORDER BY t.name) FROM atlas.logistics_transport_methods t WHERE t.rate_card_id=c.id AND t.status='active'),'[]') AS transports FROM atlas.logistics_rate_cards c WHERE c.platform_id=$1 AND c.fulfillment='rfbs' AND c.market_code='RU' AND ${this.usableCardCondition} ORDER BY c.status='active' DESC,c.effective_from DESC`,
[store.platform_id],
);
const pendingCards = await tx.query<{
id: string;
name: string;
version: string;
status: 'draft';
}>(
`SELECT id,source_name AS name,version,status FROM atlas.logistics_rate_cards WHERE platform_id=$1 AND fulfillment='rfbs' AND market_code='RU' AND status='draft' ORDER BY created_at DESC`,
[store.platform_id],
);
return {
store,
rate_cards: cards.rows,
pending_rate_cards: pendingCards.rows,
policies: await this.policies(tx, storeId),
exchange_rate: await this.exchange(tx),
};
});
}
/** 保存确认时复读规则;草稿卡是否参与新报价由配置控制。 */
async rules(tx: Tx, input: PricingPreviewRequest, store: Store) {
const card = (
await tx.query<{ id: string; version: string; status: string; updated_at: Date }>(
`SELECT * FROM atlas.logistics_rate_cards WHERE id=$1 AND platform_id=$2 AND fulfillment=$3 AND market_code='RU' AND ${this.usableCardCondition} FOR SHARE`,
[
input.pricing.rate_card_id,
store.platform_id,
input.fulfillment,
],
)
).rows[0];
if (!card)
throw new ApiError(
422,
'PRICING_RULES_UNAVAILABLE',
'没有可用的物流费率,请检查费率卡状态和草稿卡使用配置',
);
const category = await tx.query(
`SELECT id FROM atlas.platform_product_categories WHERE id=$1 AND platform_id=$2 AND is_active`,
[input.platform_product_category_id, store.platform_id],
);
if (!category.rowCount)
throw new ApiError(
422,
'PRICING_CATEGORY_REQUIRED',
'请选择当前平台的有效类目',
);
if (input.pricing.pricing_policy_id)
await tx.query(
'SELECT id FROM atlas.pricing_policies WHERE id=$1 FOR SHARE',
[input.pricing.pricing_policy_id],
);
const policy = input.pricing.pricing_policy_id
? (await this.policies(tx, store.id)).find(
(p) => p.id === input.pricing.pricing_policy_id,
)
: null;
if (
input.pricing.pricing_policy_id &&
(!policy || policy.parameters.rate_card_id !== card.id)
)
throw new ApiError(
422,
'PRICING_POLICY_UNAVAILABLE',
'方案不可用或费率卡不匹配',
);
const logistics = (
await tx.query<LogisticsRule>(
`SELECT t.*,r.id,r.product_type_id,r.transport_method_id,m.name AS transport_name,t.name AS product_type_name,r.fixed_fee_cny,r.per_billable_kg_fee_cny,r.min_charge_cny,r.surcharge_rules,r.updated_at AS rule_updated_at,m.updated_at AS transport_updated_at FROM atlas.logistics_rate_rules r JOIN atlas.logistics_product_types t ON t.id=r.product_type_id JOIN atlas.logistics_transport_methods m ON m.id=r.transport_method_id AND m.rate_card_id=t.rate_card_id WHERE t.rate_card_id=$1 AND r.status=$3 AND m.status='active' AND m.id=ANY($2::uuid[]) ORDER BY r.id FOR SHARE OF r,t,m`,
[card.id, input.pricing.transport_method_ids, card.status],
)
).rows;
const official = await readCommissionRules(tx, store.platform_id, input.platform_product_category_id, input.fulfillment);
const mapping = official.mapping;
let commissions = official.commissions;
if (
!commissions.length &&
input.pricing.temporary_commission_rate !== null &&
(!policy || policy.allow_commission_override)
)
commissions = [
{
id: null,
currency_code: 'RUB',
min_price: null,
max_price: null,
min_inclusive: true,
max_inclusive: true,
commission_rate: input.pricing.temporary_commission_rate,
version: 'temporary',
},
];
if (!commissions.length)
throw new ApiError(
422,
'PRICING_COMMISSION_UNAVAILABLE',
'该类目尚无有效佣金规则;请补充已核实的临时佣金率,并使用允许临时佣金的方案或本次自定义参数',
);
if (
commissions.some((c) => c.id) &&
input.pricing.temporary_commission_rate !== null
)
throw new ApiError(
422,
'PRICING_COMMISSION_OVERRIDE_FORBIDDEN',
'已有正式佣金规则,不能临时覆盖',
);
return {
card,
policy,
logistics,
commissions,
hash: fingerprint({
card,
policy,
logistics,
commissions,
mapping,
}),
};
}
async preview(
workspaceId: string,
userId: string,
key: string,
input: PricingPreviewRequest,
) {
return this.idempotency.run(
{
workspaceId,
userId,
key,
method: 'POST',
path: '/api/pricing/quote-previews',
request: input,
},
async (tx) => {
const store = await this.store(tx, input.store_id);
if (input.binding) {
const binding = input.binding;
const profile = (
await tx.query(
`SELECT id FROM atlas.product_platform_profiles WHERE id=$1 AND platform_id=$2`,
[binding.profile_id, store.platform_id],
)
).rows[0];
if (!profile)
throw new ApiError(
404,
'PROFILE_NOT_FOUND',
'商品发布配置不存在',
);
if (binding.variant_id) {
const listing = (
await tx.query<{ id: string; status: string; store_id: string }>(
`SELECT l.id,l.status,l.store_id FROM atlas.product_variant_platform_listings l JOIN atlas.product_variants v ON v.id=l.product_variant_id WHERE l.profile_id=$1 AND l.product_variant_id=$2 AND v.deleted_at IS NULL`,
[binding.profile_id, binding.variant_id],
)
).rows[0];
if (!listing)
throw new ApiError(
422,
'PRICING_VARIANT_UNAVAILABLE',
'SKU 不存在或已删除',
);
if (listing.status === 'imported' && listing.store_id !== store.id)
throw new ApiError(
422,
'PRICING_VARIANT_STORE_MISMATCH',
'已上架 SKU 请按首次导入店铺试算',
);
}
}
const rules = await this.rules(tx, input, store),
exchange = await this.exchange(tx);
let fx: string, origin: string;
if (input.pricing.fx.mode === 'policy_fixed') {
const fixed = rules.policy?.parameters.fx;
if (fixed?.mode !== 'policy_fixed' || !fixed.rate)
throw new ApiError(
422,
'PRICING_FX_REQUIRED',
'方案没有固定汇率',
);
fx = fixed.rate;
origin = 'policy_fixed';
} else if (input.pricing.fx.mode === 'one_time_override') {
fx = input.pricing.fx.rate!;
origin = 'quote_override';
} else {
if (!exchange)
throw new ApiError(
422,
'PRICING_FX_UNAVAILABLE',
'尚无 CBR 汇率,请稍后重试或明确填写本次汇率',
);
fx = D(exchange.rate).toDecimalPlaces(8).toString();
origin = 'system_rate';
}
const normalized = normalizeInput(input),
id = randomUUID(),
expires = new Date(Date.now() + 15 * 60_000).toISOString();
const result: PricingPreview = {
preview_id: id,
expires_at: expires,
input_hash: fingerprint(normalized),
...calculatePricing(
normalized,
store.currency_code,
fx,
rules.logistics,
rules.commissions,
),
context: {
currency_code: store.currency_code,
fx_rate: fx,
fx_origin: origin,
rule_version: rules.card.version,
development_only: rules.card.status === 'draft',
exchange_rate:
origin === 'system_rate' ? exchange : null,
warnings:
origin === 'system_rate' && exchange?.stale
? ['CBR 汇率超过 72 小时,请确认参考日期']
: [],
},
};
await tx.query(
`INSERT INTO atlas.pricing_previews(id,workspace_id,user_id,store_id,platform_id,input,input_hash,rules_hash,result,expires_at) VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)`,
[
id,
workspaceId,
userId,
store.id,
store.platform_id,
JSON.stringify(normalized),
result.input_hash,
rules.hash,
JSON.stringify(result),
expires,
],
);
return { status: 201, body: { data: result } };
},
);
}
async writePolicy(
workspaceId: string,
userId: string,
key: string,
storeId: string | null,
id: string | null,
action: 'create' | 'update' | 'default' | 'archive',
input: PricingPolicyRequest | null,
revision: number | null,
) {
return this.idempotency
.run(
{
workspaceId,
userId,
key,
method:
action === 'update'
? 'PATCH'
: action === 'archive'
? 'DELETE'
: 'POST',
path: id
? `/api/pricing-policies/${id}/${action}`
: `/api/stores/${storeId}/pricing-policies`,
request: { input, revision },
},
async (tx) => {
let current:
{ store_id: string; revision: number } | undefined;
if (id) {
current = (
await tx.query<{
store_id: string;
revision: number;
}>(
`SELECT store_id,revision FROM atlas.pricing_policies WHERE id=$1 AND status='active'`,
[id],
)
).rows[0];
if (!current)
throw new ApiError(
404,
'PRICING_POLICY_NOT_FOUND',
'方案不存在',
);
storeId = current.store_id;
}
const store = await this.store(tx, storeId!);
await tx.query(
`SELECT id FROM atlas.stores WHERE id=$1 FOR UPDATE`,
[store.id],
);
if (id)
current = (
await tx.query<{
store_id: string;
revision: number;
}>(
`SELECT store_id,revision FROM atlas.pricing_policies WHERE id=$1 AND status='active' FOR UPDATE`,
[id],
)
).rows[0];
if (id && !current)
throw new ApiError(
404,
'PRICING_POLICY_NOT_FOUND',
'方案不存在',
);
if (action === 'update' && current?.revision !== revision)
throw new ApiError(
409,
'VERSION_CONFLICT',
'方案已更新,请刷新后重试',
);
if (action === 'default') {
await tx.query(
`UPDATE atlas.pricing_policies SET is_default=false,revision=revision+1,updated_at=now() WHERE store_id=$1 AND is_default AND id<>$2`,
[store.id, id],
);
await tx.query(
`UPDATE atlas.pricing_policies SET is_default=true,revision=revision+1,updated_at=now() WHERE id=$1`,
[id],
);
} else if (action === 'archive')
await tx.query(
`UPDATE atlas.pricing_policies SET status='archived',is_default=false,revision=revision+1,updated_at=now() WHERE id=$1`,
[id],
);
else {
const p = input!.parameters;
if (p.fx.mode === 'policy_fixed' && !p.fx.rate)
throw new ApiError(
422,
'PRICING_FX_REQUIRED',
'固定汇率不能为空',
);
if (
p.default_transport_method_id &&
!p.transport_method_ids.includes(
p.default_transport_method_id,
)
)
throw new ApiError(
422,
'PRICING_TRANSPORT_INVALID',
'默认运输方式必须包含在比较列表中',
);
const card = (
await tx.query(
`SELECT id FROM atlas.logistics_rate_cards WHERE id=$1 AND platform_id=$2 AND fulfillment='rfbs' AND market_code='RU' AND ${this.usableCardCondition}`,
[p.rate_card_id, store.platform_id],
)
).rowCount;
const transports = (
await tx.query(
`SELECT id FROM atlas.logistics_transport_methods WHERE rate_card_id=$1 AND status='active' AND id=ANY($2::uuid[])`,
[p.rate_card_id, p.transport_method_ids],
)
).rowCount;
if (
!card ||
transports !== new Set(p.transport_method_ids).size
)
throw new ApiError(
422,
'PRICING_RULES_UNAVAILABLE',
'费率卡或运输方式不可用',
);
const values = [
input!.name,
input!.note,
p.rate_card_id,
p.default_transport_method_id,
p.domestic_shipping_and_label_fee_cny,
p.ad_rate,
p.last_mile_rate,
p.withdrawal_rate,
p.other_rate,
p.desired_markup_rate,
p.list_price_discount_rate,
input!.allow_commission_override,
p.fx.mode === 'policy_fixed' ? 'fixed' : 'system',
p.fx.mode === 'policy_fixed' ? p.fx.rate : null,
];
if (action === 'create') {
id = randomUUID();
await tx.query(
`INSERT INTO atlas.pricing_policies(name,note,rate_card_id,default_transport_method_id,domestic_shipping_and_label_fee_cny,ad_rate,last_mile_rate,withdrawal_rate,other_rate,desired_markup_rate,list_price_discount_rate,allow_commission_override,fx_strategy,fixed_cny_to_rub_rate,id,workspace_id,store_id,platform_id) VALUES(${values.map((_, i) => `$${i + 1}`).join(',')},$15,$16,$17,$18)`,
[
...values,
id,
workspaceId,
store.id,
store.platform_id,
],
);
} else
await tx.query(
`UPDATE atlas.pricing_policies SET name=$1,note=$2,rate_card_id=$3,default_transport_method_id=$4,domestic_shipping_and_label_fee_cny=$5,ad_rate=$6,last_mile_rate=$7,withdrawal_rate=$8,other_rate=$9,desired_markup_rate=$10,list_price_discount_rate=$11,allow_commission_override=$12,fx_strategy=$13,fixed_cny_to_rub_rate=$14,revision=revision+1,updated_at=now() WHERE id=$15`,
[...values, id],
);
await tx.query(
`DELETE FROM atlas.pricing_policy_transport_methods WHERE pricing_policy_id=$1`,
[id],
);
for (const [i, t] of [
...new Set(p.transport_method_ids),
].entries())
await tx.query(
`INSERT INTO atlas.pricing_policy_transport_methods(workspace_id,pricing_policy_id,transport_method_id,sort_order) VALUES($1,$2,$3,$4)`,
[workspaceId, id, t, i],
);
}
return {
status: action === 'create' ? 201 : 200,
body: {
data:
action === 'archive'
? { id, archived: true }
: (await this.policies(tx, store.id)).find(
(p) => p.id === id,
),
},
};
},
)
.catch((error: unknown) => {
if (
error &&
typeof error === 'object' &&
'code' in error &&
error.code === '23505'
)
throw new ApiError(
409,
'PRICING_POLICY_NAME_CONFLICT',
'此门店已存在同名方案,请使用其他名称',
);
throw error;
});
}
/** 必须在 editor-draft 的现有事务中调用,任何确认失败使商品修改一起回滚。 */
async persistDraft(
tx: Tx,
workspaceId: string,
userId: string,
profileId: string,
input: SaveProductEditorDraftRequest,
created: Array<{ client_key: string; id: string }>,
) {
for (const v of input.variants) {
const variantId =
v.id ?? created.find((c) => c.client_key === v.client_key)!.id;
const listing = (
await tx.query<{
id: string;
status: string;
store_id: string;
pricing_draft: PricingParameters | null;
pricing_quote_id: string | null;
}>(
`SELECT id,status,store_id,pricing_draft,pricing_quote_id FROM atlas.product_variant_platform_listings WHERE profile_id=$1 AND product_variant_id=$2 FOR UPDATE`,
[profileId, variantId],
)
).rows[0];
// 已上架 SKU 只有显式应用新报价时更新本地记录,普通草稿保存保留历史报价。
if (listing.status === 'imported' && !v.pricing_selection) continue;
const state = (
await tx.query<{
product_id: string;
platform_id: string;
store_id: string;
currency_code: string;
category_id: string;
fulfillment: 'rfbs';
purchase_cost_cny: string;
package: PricingPreviewRequest['package'];
}>(
`SELECT v.product_id,p.platform_id,s.id AS store_id,s.currency_code,p.platform_product_category_id AS category_id,p.fulfillment,v.purchase_cost_cny::text,jsonb_build_object('shipping_weight_g',coalesce(v.shipping_weight_g,g.default_shipping_weight_g)::text,'length_mm',coalesce(v.package_length_mm,g.default_package_length_mm)::text,'width_mm',coalesce(v.package_width_mm,g.default_package_width_mm)::text,'height_mm',coalesce(v.package_height_mm,g.default_package_height_mm)::text,'status',coalesce(v.package_spec_status,g.package_spec_status)) AS package FROM atlas.product_variants v JOIN atlas.products g ON g.id=v.product_id JOIN atlas.product_platform_profiles p ON p.id=$1 JOIN atlas.stores s ON s.id=coalesce($3::uuid,p.target_store_id) WHERE v.id=$2`,
[
profileId,
variantId,
listing.status === 'imported' ? listing.store_id : null,
],
)
).rows[0];
const params =
v.pricing_draft === undefined
? listing.pricing_draft
: v.pricing_draft;
const parsed = pricingPreviewRequestSchema.safeParse({
store_id: state.store_id,
platform_product_category_id: state.category_id,
fulfillment: state.fulfillment,
purchase_cost_cny:
state.purchase_cost_cny === null
? null
: D(state.purchase_cost_cny).toFixed(2),
package: state.package,
pricing: params,
binding: {
profile_id: profileId,
variant_id: v.id,
client_key: v.id ? null : v.client_key,
},
});
const normalized = parsed.success
? normalizeInput(parsed.data)
: null;
const fail = () => {
throw new ApiError(
409,
'PRICING_PREVIEW_STALE',
`${v.display_name ?? 'SKU'} 的报价已失效,请重新试算或仅保存草稿`,
[
{
field: `variants.${variantId}.pricing_selection`,
code: 'PRICING_PREVIEW_STALE',
message: '报价输入、规则或有效期已变化',
},
],
);
};
if (!v.pricing_selection) {
// 旧报价保留供审计,当前引用仅在输入和归属仍一致时保持有效。
const old = listing.pricing_quote_id
? (
await tx.query<{
input_snapshot: {
input: PricingPreviewRequest;
context: PricingPreview['context'];
};
}>(
`SELECT input_snapshot FROM atlas.pricing_quotes WHERE id=$1`,
[listing.pricing_quote_id],
)
).rows[0]
: null;
const same =
normalized &&
old &&
old.input_snapshot.context.currency_code ===
state.currency_code &&
fingerprint({ ...normalized, binding: null }) ===
fingerprint({
...old.input_snapshot.input,
binding: null,
});
await tx.query(
`UPDATE atlas.product_variant_platform_listings SET pricing_draft=$2,pricing_valid=$3,currency_code=$4,pricing_quote_id=CASE WHEN store_id=$5 THEN pricing_quote_id ELSE NULL END WHERE id=$1`,
[
listing.id,
params ? JSON.stringify(params) : null,
Boolean(same),
state.currency_code,
old?.input_snapshot.input.store_id ?? state.store_id,
],
);
continue;
}
if (!normalized) fail();
const selection = v.pricing_selection;
const preview = (
await tx.query<{
input: PricingPreviewRequest;
input_hash: string;
rules_hash: string;
result: PricingPreview;
valid: boolean;
}>(
`SELECT input,input_hash,rules_hash,result,expires_at>now() AS valid FROM atlas.pricing_previews WHERE id=$1 AND user_id=$2 FOR SHARE`,
[selection.preview_id, userId],
)
).rows[0];
if (
!preview ||
!preview.valid ||
selection.input_hash !== preview.input_hash ||
fingerprint(normalized) !== preview.input_hash
)
fail();
const store = await this.store(tx, state.store_id);
let currentRules: Awaited<ReturnType<PricingService['rules']>>;
try {
currentRules = await this.rules(tx, normalized!, store);
} catch (error) {
if (error instanceof ApiError && error.getStatus() === 422)
return fail();
throw error;
}
if (
currentRules.hash !== preview.rules_hash ||
preview.result.context.currency_code !== state.currency_code
)
fail();
const option = preview.result.options.find(
(o) => o.id === selection.option_id,
);
if (!option || !option.can_confirm) fail();
const o = option!,
quoteId = randomUUID(),
selectedId = randomUUID();
const savedInput = {
...normalized!,
binding: {
profile_id: profileId,
variant_id: variantId,
client_key: null,
},
};
await tx.query(
`INSERT INTO atlas.pricing_quotes(id,workspace_id,store_id,platform_id,pricing_policy_id,product_variant_id,product_id,profile_id,platform_product_category_id,fulfillment,purchase_cost_cny,package_weight_kg,package_length_cm,package_width_cm,package_height_cm,fx_cny_to_rub,fx_rate_origin,input_snapshot,status,selected_option_id,calculated_at,confirmed_at,created_by_user_id,input_hash) VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,'confirmed',$19,now(),now(),$20,$21)`,
[
quoteId,
workspaceId,
state.store_id,
state.platform_id,
params!.pricing_policy_id,
variantId,
state.product_id,
profileId,
state.category_id,
state.fulfillment,
state.purchase_cost_cny,
D(state.package.shipping_weight_g).div(1000).toString(),
D(state.package.length_mm).div(10).toString(),
D(state.package.width_mm).div(10).toString(),
D(state.package.height_mm).div(10).toString(),
preview.result.context.fx_rate,
preview.result.context.fx_origin,
JSON.stringify({
input: savedInput,
context: preview.result.context,
rules: currentRules,
}),
selectedId,
userId,
fingerprint(savedInput),
],
);
for (const candidate of preview.result.options) {
const f = candidate.fees,
fx = D(preview.result.context.fx_rate);
await tx.query(
`INSERT INTO atlas.pricing_quote_options(id,workspace_id,quote_id,logistics_rate_rule_id,commission_rule_id,logistics_product_type_id,transport_method_id,merchandise_value_cny,selling_price_rub,list_price_rub,billable_weight_kg,logistics_fee_cny,commission_fee_rub,domestic_fee_cny,ad_fee_rub,last_mile_fee_rub,withdrawal_fee_rub,other_fee_rub,profit_rub,calculation_snapshot) VALUES(${Array.from({ length: 20 }, (_, i) => `$${i + 1}`).join(',')})`,
[
candidate.id === o.id ? selectedId : randomUUID(),
workspaceId,
quoteId,
candidate.logistics_rate_rule_id,
candidate.commission_rule_id,
candidate.product_type_id,
candidate.transport_method_id,
candidate.merchandise_value_cny,
candidate.selling_price_rub,
candidate.list_price_rub,
candidate.billable_weight_kg,
f.logistics_cny,
fx.mul(f.commission_cny).toFixed(4),
f.domestic_cny,
fx.mul(f.ad_cny).toFixed(4),
fx.mul(f.last_mile_cny).toFixed(4),
fx.mul(f.withdrawal_cny).toFixed(4),
fx.mul(f.other_cny).toFixed(4),
candidate.profit_rub,
JSON.stringify(candidate),
],
);
}
await tx.query(
`UPDATE atlas.product_variant_platform_listings SET pricing_quote_id=$2,pricing_draft=$3,pricing_valid=true,selling_price=$4,list_price=$5,currency_code=$6 WHERE id=$1`,
[
listing.id,
quoteId,
JSON.stringify(params),
o.selling_price,
o.list_price,
o.currency_code,
],
);
}
}
}
@@ -0,0 +1,233 @@
import { Inject, Injectable } from '@nestjs/common';
import type { ProductAiTask, ProductEditorSnapshot } 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 { ProductEditorService } from './product-editor.service.js';
@Injectable()
export class ProductAiService {
public constructor(
@Inject(DATABASE) private readonly database: Database,
@Inject(IdempotencyService)
private readonly idempotency: IdempotencyService,
@Inject(ProductEditorService)
private readonly editor: ProductEditorService,
) {}
/** 固化当前编辑快照并在同一事务写入任务与 Outbox。 */
async create(
workspaceId: string,
userId: string,
profileId: string,
platformId: string,
variantId: string | null,
key: string,
hint: string | undefined,
) {
const profile = await this.database.workspaceTransaction(
workspaceId,
async (tx) => {
const row = await tx.query<{ product_id: string }>(
`SELECT product_id FROM atlas.product_platform_profiles
WHERE id = $1 AND platform_id = $2`,
[profileId, platformId],
);
if (!row.rows[0])
throw new ApiError(
404,
'PROFILE_NOT_FOUND',
'商品发布配置不存在',
);
return row.rows[0];
},
);
const snapshot = await this.editor.read(
workspaceId,
profile.product_id,
platformId,
);
if (snapshot.profile.id !== profileId)
throw new ApiError(
409,
'PROFILE_CHANGED',
'商品发布配置已变化,请刷新页面',
);
if (
variantId &&
!snapshot.variants.some((variant) => variant.id === variantId)
)
throw new ApiError(404, 'VARIANT_NOT_FOUND', 'SKU 不属于当前商品');
const kind = variantId ? 'sku_copy' : 'autofill';
const path = variantId
? `/api/product-platform-profiles/${profileId}/variants/${variantId}/ai/copy`
: `/api/product-platform-profiles/${profileId}/ai/autofills`;
return this.idempotency.run(
{
workspaceId,
userId,
method: 'POST',
path,
key,
request: { platform_id: platformId, hint: hint ?? null },
},
async (tx) => {
const current = await tx.query<{
product_revision: number;
profile_revision: number;
source_snapshot_revision: number;
}>(
`SELECT product.revision AS product_revision,
profile.revision AS profile_revision,
item.source_snapshot_revision
FROM atlas.product_platform_profiles profile
JOIN atlas.products product ON product.id = profile.product_id
JOIN atlas.collection_items item ON item.id = product.collection_item_id
WHERE profile.id = $1 AND profile.platform_id = $2
FOR UPDATE OF profile, product, item`,
[profileId, platformId],
);
const row = current.rows[0];
if (
!row ||
Number(row.product_revision) !==
snapshot.product.revision ||
Number(row.profile_revision) !==
snapshot.profile.revision ||
Number(row.source_snapshot_revision) !==
snapshot.source_material.source_snapshot_revision
) {
throw new ApiError(
409,
'AI_INPUT_CHANGED',
'商品资料已变化,请重新点击 AI 填报',
);
}
const input = taskInput(snapshot, variantId, hint);
const inserted = await tx.query<{ id: string }>(
`INSERT INTO atlas.product_ai_tasks
(workspace_id, product_id, profile_id, variant_id, kind, input_snapshot, created_by_user_id)
VALUES (current_setting('app.workspace_id')::uuid, $1, $2, $3, $4, $5::jsonb, $6)
RETURNING id`,
[
profile.product_id,
profileId,
variantId,
kind,
JSON.stringify(input),
userId,
],
);
const taskId = inserted.rows[0].id;
await tx.query(
`INSERT INTO atlas.outbox_events
(workspace_id, aggregate_type, aggregate_id, event_type, event_payload, deduplication_key)
VALUES (current_setting('app.workspace_id')::uuid, 'product_ai_task', $1,
'product_ai_task.requested', $2::jsonb, $3)`,
[
taskId,
JSON.stringify({ task_id: taskId }),
`product_ai_task:${taskId}`,
],
);
return {
status: 202,
body: { data: { ai_task_id: taskId, status: 'queued' } },
};
},
);
}
/** 只返回已校验结果和输入版本,不暴露采集快照与模型提示词。 */
async read(
workspaceId: string,
taskId: string,
platformId: string,
): Promise<ProductAiTask> {
return this.database.workspaceTransaction(workspaceId, async (tx) => {
const result = await tx.query<{
id: string;
kind: 'autofill' | 'sku_copy' | 'attribute_fill';
status: ProductAiTask['status'];
variant_id: string | null;
input_snapshot: Record<string, unknown>;
result: ProductAiTask['result'];
error_code: string | null;
error_message: string | null;
}>(
`SELECT task.id, task.kind, task.status, task.variant_id,
task.input_snapshot, task.result, task.error_code, task.error_message
FROM atlas.product_ai_tasks task
JOIN atlas.product_platform_profiles profile ON profile.id = task.profile_id
WHERE task.id = $1 AND profile.platform_id = $2`,
[taskId, platformId],
);
const row = result.rows[0];
if (!row)
throw new ApiError(404, 'AI_TASK_NOT_FOUND', 'AI 任务不存在');
const input = row.input_snapshot as {
revision?: ProductAiTask['input_revision'];
};
return {
id: row.id,
kind: row.kind,
status: row.status,
input_revision: input.revision ?? {
product: 0,
profile: 0,
source_snapshot: 0,
},
variant_id: row.variant_id,
result:
row.status === 'succeeded' || row.status === 'partial'
? row.result
: null,
error_code: row.error_code,
error_message: row.error_message,
};
});
}
}
function taskInput(
snapshot: ProductEditorSnapshot,
variantId: string | null,
hint?: string,
) {
return {
revision: {
product: snapshot.product.revision,
profile: snapshot.profile.revision,
source_snapshot: snapshot.source_material.source_snapshot_revision,
},
platform_id: snapshot.profile.platform_id,
product_id: snapshot.product.id,
profile_id: snapshot.profile.id,
variant_id: variantId,
hint: hint ?? null,
source_material: snapshot.source_material,
common: snapshot.product.common,
profile: {
title: snapshot.profile.title,
tags: snapshot.profile.tags,
description: snapshot.profile.description,
category: snapshot.profile.category,
target_store_id: snapshot.profile.target_store.id,
},
variants: snapshot.variants.map((variant) => ({
id: variant.id,
display_name: variant.display_name,
source_sku: variant.source_sku,
specification_values: variant.specification_values,
listing: {
status: variant.listing.status,
offer_id: variant.listing.offer_id,
},
title: variant.title,
tags: variant.tags,
description: variant.description,
package_spec: variant.effective_package_spec,
})),
};
}
@@ -0,0 +1,33 @@
import type { ProductAttributeSchema } from '@atlas/contracts';
type AttributeDefinition = Omit<ProductAttributeSchema, 'editor_field'>;
type EditorField = Exclude<ProductAttributeSchema['editor_field'], null>;
/** Ozon 固定属性 ID 到编辑器内建字段的唯一映射,不使用任何名称或翻译文本判断。 */
const editorFieldByAttributeId: Readonly<Record<string, EditorField>> = {
'85': 'brand',
'9048': 'model',
'8229': 'category',
'23171': 'tags',
'11254': 'rich_content',
'4191': 'description',
};
/** 仅依据 Ozon 属性 ID 将类目属性绑定到编辑器内建字段。 */
export function editorFieldForAttributeId(
platformAttributeId: string,
): ProductAttributeSchema['editor_field'] {
return editorFieldByAttributeId[platformAttributeId] ?? null;
}
/** 为数据库中的原始类目属性补充前端和校验共用的内建字段绑定。 */
export function bindEditorFields(
definitions: AttributeDefinition[],
): ProductAttributeSchema[] {
return definitions.map((definition) => ({
...definition,
editor_field: editorFieldForAttributeId(
definition.platform_attribute_id,
),
}));
}
@@ -0,0 +1,76 @@
import type {
ProductCatalogCacheMetadata,
ProductCatalogCacheStatus,
} from '@atlas/contracts';
export type ProductCatalogResourceState = {
last_success_at: Date | string | null;
is_complete: boolean;
fresh_days: number;
expire_days: number;
refresh_run_id: string | null;
refresh_status: string | null;
};
/** 统一实现 14/21 天边界,供所有商品目录读取与发布预检复用。 */
export function productCatalogCacheStatus(
lastSuccessAt: Date | string | null,
freshDays: number,
expireDays: number,
now = Date.now(),
): ProductCatalogCacheStatus {
if (!lastSuccessAt) return 'missing';
const age = now - new Date(lastSuccessAt).getTime();
const day = 86_400_000;
if (age < freshDays * day) return 'fresh';
if (age <= expireDays * day) return 'stale';
return 'expired';
}
export function productCatalogResourceStatus(
state: ProductCatalogResourceState,
): ProductCatalogCacheStatus {
return productCatalogCacheStatus(
state.last_success_at,
state.fresh_days,
state.expire_days,
);
}
/** cache 元数据在编辑读取与发布预检之间保持同一形状和边界计算。 */
export function productCatalogCacheMetadata(
state: ProductCatalogResourceState,
status = productCatalogResourceStatus(state),
): ProductCatalogCacheMetadata {
const last = state.last_success_at ? new Date(state.last_success_at) : null;
return {
status,
last_success_at: last?.toISOString() ?? null,
fresh_until: last
? new Date(
last.getTime() + state.fresh_days * 86_400_000,
).toISOString()
: null,
expires_at: last
? new Date(
last.getTime() + state.expire_days * 86_400_000,
).toISOString()
: null,
is_complete: state.is_complete,
refresh_run_id: state.refresh_run_id,
refresh_status: state.refresh_status,
retry_after_ms:
status === 'expired' || status === 'missing' ? 1_000 : null,
};
}
export function productCatalogStateWithRun(
state: ProductCatalogResourceState,
run: { id: string; status: string },
): ProductCatalogResourceState {
return {
...state,
refresh_run_id: run.id,
refresh_status: run.status,
};
}
@@ -1,25 +1,21 @@
import { createHash } from 'node:crypto';
import type { ProductAttributeSchema } from '@atlas/contracts'; import type { ProductAttributeSchema } from '@atlas/contracts';
import type { Database, DatabaseTransaction } from '@atlas/database'; import type { Database, DatabaseTransaction } from '@atlas/database';
import { ApiError } from '../common/api-error.js'; import { ApiError } from '../common/api-error.js';
import {
enqueuePlatformCatalogRun,
type PlatformCatalogRunRow,
} from '../platform-catalog/platform-catalog-run.js';
import {
productCatalogCacheMetadata as cacheMetadata,
productCatalogResourceStatus as cacheStatus,
type ProductCatalogResourceState as ResourceState,
} from './product-editor.catalog-cache.js';
import type { ProfileRow } from './product-editor.models.js'; import type { ProfileRow } from './product-editor.models.js';
type CatalogSyncInput = { type DictionaryCursor = { value: string; value_id: string; query: string };
workspaceId: string; type RefreshScope = 'attributes' | 'dictionary' | 'dictionary_search';
userId: string; type RunRow = PlatformCatalogRunRow;
profileId: string;
platformId: string;
key: string;
scope: 'attributes' | 'dictionary';
path: string;
categoryId?: string;
dictionaryId?: string;
query?: string;
};
type CatalogSyncResult = {
status: number;
body: unknown;
};
type CatalogAccess = { type CatalogAccess = {
requireProfileForPlatform: ( requireProfileForPlatform: (
@@ -27,58 +23,67 @@ type CatalogAccess = {
profileId: string, profileId: string,
platformId: string, platformId: string,
) => Promise<ProfileRow>; ) => Promise<ProfileRow>;
requireEditableStore: (
transaction: DatabaseTransaction,
storeId: string,
platformId: string,
) => Promise<void>;
requireActiveCategory: ( requireActiveCategory: (
transaction: DatabaseTransaction, transaction: DatabaseTransaction,
storeId: string, platformId: string,
categoryId: string, categoryId: string,
) => Promise<unknown>; ) => Promise<unknown>;
attributeSchemaForCategory: ( attributeSchemaForCategory: (
transaction: DatabaseTransaction, transaction: DatabaseTransaction,
storeId: string, platformId: string,
categoryId: string, categoryId: string,
) => Promise<ProductAttributeSchema[]>; ) => Promise<ProductAttributeSchema[]>;
requestCatalogSync: (input: CatalogSyncInput) => Promise<CatalogSyncResult>;
}; };
/** 游标只保存排序位置,类目和属性归属仍由每次请求重新校验。 */
function parseDictionaryCursor(
cursor: string | undefined,
): DictionaryCursor | null {
if (!cursor) return null;
if (cursor.length > 2_000)
throw new ApiError(400, 'VALIDATION_FAILED', '字典游标不合法');
try {
const parsed: unknown = JSON.parse(
Buffer.from(cursor, 'base64url').toString('utf8'),
);
if (
parsed &&
typeof parsed === 'object' &&
typeof (parsed as DictionaryCursor).value === 'string' &&
typeof (parsed as DictionaryCursor).value_id === 'string' &&
typeof (parsed as DictionaryCursor).query === 'string'
)
return parsed as DictionaryCursor;
} catch {
// 无效游标统一返回参数错误。
}
throw new ApiError(400, 'VALIDATION_FAILED', '字典游标不合法');
}
/** /**
* 处理商品编辑器的类目、属性定义和字典缓存读取。 * 商品编辑器只读取平台级目录缓存。stale 立即返回并后台刷新;expired/missing
* 数据校验仍复用工作流服务的统一访问边界,避免目录读取绕过平台和店铺隔离。 * 只创建平台 Worker 任务,短暂等待后仍未完成则以 202 告知前端继续轮询。
*/ */
export class ProductEditorCatalogOperations { export class ProductEditorCatalogOperations {
/** 注入工作空间事务入口及共享的权限、归属校验能力。 */
public constructor( public constructor(
private readonly database: Database, private readonly database: Database,
private readonly access: CatalogAccess, private readonly access: CatalogAccess,
) {} ) {}
/** 读取当前或指定目标店铺的完整类目缓存。 */
async categoryOptions( async categoryOptions(
workspaceId: string, workspaceId: string,
profileId: string, profileId: string,
platformId: string, platformId: string,
search: string | undefined, search: string | undefined,
storeId?: string,
) { ) {
return this.database.workspaceTransaction( return this.database.workspaceTransaction(
workspaceId, workspaceId,
async (transaction) => { async (transaction) => {
const profile = await this.access.requireProfileForPlatform( await this.access.requireProfileForPlatform(
transaction, transaction,
profileId, profileId,
platformId, platformId,
); );
const selectedStoreId = storeId ?? profile.target_store_id;
if (storeId)
await this.access.requireEditableStore(
transaction,
storeId,
platformId,
);
const categories = await transaction.query<{ const categories = await transaction.query<{
id: string; id: string;
parent_id: string | null; parent_id: string | null;
@@ -89,138 +94,93 @@ export class ProductEditorCatalogOperations {
is_active: boolean; is_active: boolean;
}>( }>(
`SELECT id, parent_id, external_category_id, external_type_id, name, path, is_active `SELECT id, parent_id, external_category_id, external_type_id, name, path, is_active
FROM atlas.store_product_categories FROM atlas.platform_product_categories
WHERE store_id = $1 WHERE platform_id = $1
ORDER BY path, name, id ORDER BY path, name, id
LIMIT 10000`, LIMIT 10000`,
[selectedStoreId], [platformId],
); );
// 保留参数以保持既有 HTTP 契约;当前缓存树始终完整返回。 // 搜索由 Cascader 在完整平台树上执行,保持父子路径完整。
void search; void search;
return categories.rows; return categories.rows;
}, },
); );
} }
/** 获取当前或指定类目的已缓存动态属性定义。 */
async attributeSchema( async attributeSchema(
workspaceId: string, workspaceId: string,
userId: string,
profileId: string, profileId: string,
platformId: string, platformId: string,
categoryId?: string, categoryId?: string,
storeId?: string,
) { ) {
return this.database.workspaceTransaction( const prepared = await this.readAttributeSchema(
workspaceId, workspaceId,
async (transaction) => { userId,
const profile = await this.access.requireProfileForPlatform( profileId,
transaction, platformId,
profileId, categoryId,
platformId, true,
);
const selectedCategoryId =
categoryId ?? profile.store_product_category_id;
const selectedStoreId = storeId ?? profile.target_store_id;
if (storeId)
await this.access.requireEditableStore(
transaction,
storeId,
platformId,
);
if (!selectedCategoryId)
throw new ApiError(
422,
'CATEGORY_REQUIRED',
'请先选择产品类目',
);
if (categoryId)
await this.access.requireActiveCategory(
transaction,
selectedStoreId,
categoryId,
);
return this.access.attributeSchemaForCategory(
transaction,
selectedStoreId,
selectedCategoryId,
);
},
); );
if (!prepared.waitForRunId)
return { status: 200, body: { data: prepared.result } };
await this.waitForRun(prepared.waitForRunId);
const completed = await this.readAttributeSchema(
workspaceId,
userId,
profileId,
platformId,
categoryId,
false,
);
const usable =
completed.result.cache.status === 'fresh' ||
completed.result.cache.status === 'stale';
return { status: usable ? 200 : 202, body: { data: completed.result } };
} }
/** 在确认字典属于当前类目后,搜索店铺本地缓存的字典值。 */
async attributeDictionary( async attributeDictionary(
workspaceId: string, workspaceId: string,
userId: string,
profileId: string, profileId: string,
platformId: string, platformId: string,
dictionaryId: string, attributeId: string,
search: string | undefined, search: string | undefined,
cursor: string | undefined,
categoryId?: string, categoryId?: string,
storeId?: string,
) { ) {
return this.database.workspaceTransaction( const after = parseDictionaryCursor(cursor);
const prepared = await this.readDictionary(
workspaceId, workspaceId,
async (transaction) => { userId,
const profile = await this.access.requireProfileForPlatform( profileId,
transaction, platformId,
profileId, attributeId,
platformId, search,
); after,
const selectedCategoryId = categoryId,
categoryId ?? profile.store_product_category_id; true,
const selectedStoreId = storeId ?? profile.target_store_id;
if (storeId)
await this.access.requireEditableStore(
transaction,
storeId,
platformId,
);
if (!selectedCategoryId)
throw new ApiError(
422,
'CATEGORY_REQUIRED',
'请先选择产品类目',
);
if (categoryId)
await this.access.requireActiveCategory(
transaction,
selectedStoreId,
categoryId,
);
const allowed = await transaction.query<{ dictionary_id: string }>(
`SELECT dictionary_id
FROM atlas.store_product_category_attributes
WHERE store_id = $1 AND store_product_category_id = $2
AND dictionary_id = $3
LIMIT 1`,
[selectedStoreId, selectedCategoryId, dictionaryId],
);
if (!allowed.rows[0])
throw new ApiError(
422,
'ATTRIBUTE_DICTIONARY_INVALID',
'字典不属于当前类目的属性',
);
const needle = search?.trim() ?? '';
const values = await transaction.query<{
value_id: string;
value: string;
}>(
`SELECT external_value_id AS value_id, value
FROM atlas.store_attribute_dictionary_values
WHERE store_id = $1 AND store_product_category_id = $2
AND dictionary_id = $3 AND is_active
AND ($4 = '' OR value ILIKE '%' || $4 || '%')
ORDER BY value, external_value_id
LIMIT 100`,
[selectedStoreId, selectedCategoryId, dictionaryId, needle],
);
return { items: values.rows, next_cursor: null };
},
); );
if (!prepared.waitForRunId)
return { status: 200, body: { data: prepared.result } };
await this.waitForRun(prepared.waitForRunId);
const completed = await this.readDictionary(
workspaceId,
userId,
profileId,
platformId,
attributeId,
search,
after,
categoryId,
false,
);
const usable =
completed.result.cache.status === 'fresh' ||
completed.result.cache.status === 'stale';
return { status: usable ? 200 : 202, body: { data: completed.result } };
} }
/** 创建类目属性定义同步任务。 */
async requestAttributeSchemaSync( async requestAttributeSchemaSync(
workspaceId: string, workspaceId: string,
userId: string, userId: string,
@@ -229,19 +189,33 @@ export class ProductEditorCatalogOperations {
key: string, key: string,
categoryId: string, categoryId: string,
) { ) {
return this.access.requestCatalogSync({ const run = await this.database.workspaceTransaction(
workspaceId, workspaceId,
userId, async (transaction) => {
profileId, await this.access.requireProfileForPlatform(
platformId, transaction,
key, profileId,
scope: 'attributes', platformId,
path: 'attribute-schema-syncs', );
categoryId, await this.access.requireActiveCategory(
}); transaction,
platformId,
categoryId,
);
return this.enqueueRequestedRefresh(transaction, key, {
platformId,
userId,
scope: 'attributes',
scopeKey: `attributes:${categoryId}`,
categoryId,
priority: 'high',
trigger: 'user_access',
});
},
);
return { status: 202, body: { data: { run } } };
} }
/** 创建带关键词限制的属性字典同步任务。 */
async requestAttributeDictionarySync( async requestAttributeDictionarySync(
workspaceId: string, workspaceId: string,
userId: string, userId: string,
@@ -249,26 +223,435 @@ export class ProductEditorCatalogOperations {
platformId: string, platformId: string,
key: string, key: string,
categoryId: string, categoryId: string,
dictionaryId: string, attributeId: string,
query: string, query: string,
) { ) {
if (query.trim().length < 2) const needle = query.trim();
if (needle.length === 1)
throw new ApiError( throw new ApiError(
422, 422,
'DICTIONARY_SEARCH_REQUIRED', 'DICTIONARY_SEARCH_REQUIRED',
'请至少输入 2 个字符后再从 Ozon 搜索字典值', '请至少输入 2 个字符后再从 Ozon 搜索字典值',
); );
return this.access.requestCatalogSync({ const run = await this.database.workspaceTransaction(
workspaceId, workspaceId,
userId, async (transaction) => {
profileId, await this.access.requireProfileForPlatform(
platformId, transaction,
key, profileId,
scope: 'dictionary', platformId,
path: `attribute-dictionaries/${encodeURIComponent(dictionaryId)}/syncs`, );
categoryId, await this.access.requireActiveCategory(
dictionaryId, transaction,
query: query.trim(), platformId,
categoryId,
);
await this.requireDictionaryAttribute(
transaction,
platformId,
categoryId,
attributeId,
);
const searchScope = needle.length >= 2;
const normalized = normalizeSearch(needle);
return this.enqueueRequestedRefresh(transaction, key, {
platformId,
userId,
scope: searchScope ? 'dictionary_search' : 'dictionary',
scopeKey: searchScope
? dictionarySearchScopeKey(attributeId, normalized)
: `dictionary:${attributeId}`,
categoryId,
attributeId,
searchKey: searchScope ? normalized : undefined,
priority: 'high',
trigger: 'user_access',
});
},
);
return { status: 202, body: { data: { run } } };
}
private async readAttributeSchema(
workspaceId: string,
userId: string,
profileId: string,
platformId: string,
categoryId: string | undefined,
enqueue: boolean,
) {
return this.database.workspaceTransaction(
workspaceId,
async (transaction) => {
const profile = await this.access.requireProfileForPlatform(
transaction,
profileId,
platformId,
);
const selectedCategoryId =
categoryId ?? profile.platform_product_category_id;
if (!selectedCategoryId)
throw new ApiError(
422,
'CATEGORY_REQUIRED',
'请先选择产品类目',
);
await this.access.requireActiveCategory(
transaction,
platformId,
selectedCategoryId,
);
const state = await this.resourceState(
transaction,
platformId,
'attributes',
`attributes:${selectedCategoryId}`,
);
const status = cacheStatus(state);
let run: RunRow | null = null;
if (enqueue && status !== 'fresh') {
run = await this.enqueueRefresh(transaction, {
platformId,
userId,
scope: 'attributes',
scopeKey: `attributes:${selectedCategoryId}`,
categoryId: selectedCategoryId,
priority: status === 'stale' ? 'low' : 'high',
trigger: 'user_access',
});
}
const effectiveState = run
? {
...state,
refresh_run_id: run.id,
refresh_status: run.status,
}
: state;
const usable = status === 'fresh' || status === 'stale';
return {
result: {
items: usable
? await this.access.attributeSchemaForCategory(
transaction,
platformId,
selectedCategoryId,
)
: [],
cache: cacheMetadata(effectiveState, status),
},
waitForRunId: enqueue && !usable ? (run?.id ?? null) : null,
};
},
);
}
private async readDictionary(
workspaceId: string,
userId: string,
profileId: string,
platformId: string,
attributeId: string,
search: string | undefined,
after: DictionaryCursor | null,
categoryId: string | undefined,
enqueue: boolean,
) {
return this.database.workspaceTransaction(
workspaceId,
async (transaction) => {
const profile = await this.access.requireProfileForPlatform(
transaction,
profileId,
platformId,
);
const selectedCategoryId =
categoryId ?? profile.platform_product_category_id;
if (!selectedCategoryId)
throw new ApiError(
422,
'CATEGORY_REQUIRED',
'请先选择产品类目',
);
await this.access.requireActiveCategory(
transaction,
platformId,
selectedCategoryId,
);
await this.requireDictionaryAttribute(
transaction,
platformId,
selectedCategoryId,
attributeId,
);
const needle = search?.trim() ?? '';
if (after && after.query !== needle)
throw new ApiError(
400,
'VALIDATION_FAILED',
'字典游标与搜索条件不匹配',
);
const values = await this.dictionaryValues(
transaction,
attributeId,
needle,
after,
);
const dictionaryState = await this.resourceState(
transaction,
platformId,
'dictionary',
`dictionary:${attributeId}`,
);
const normalized = normalizeSearch(needle);
const searchScopeKey = dictionarySearchScopeKey(
attributeId,
normalized,
);
const searchState =
needle.length >= 2 && !dictionaryState.is_complete
? await this.resourceState(
transaction,
platformId,
'dictionary_search',
searchScopeKey,
)
: null;
// 部分字典本地未命中时补齐查询词;一旦建立过该词作用域,后续继续
// 使用它的新鲜度,避免搜索成功写入值后又退回整个字典的状态。
const usesSearchScope =
!!searchState &&
(values.rows.length === 0 ||
searchState.last_success_at !== null);
const scope: RefreshScope = usesSearchScope
? 'dictionary_search'
: 'dictionary';
const scopeKey = usesSearchScope
? searchScopeKey
: `dictionary:${attributeId}`;
const state = usesSearchScope ? searchState : dictionaryState;
const status = cacheStatus(state);
let run: RunRow | null = null;
if (enqueue && status !== 'fresh') {
run = await this.enqueueRefresh(transaction, {
platformId,
userId,
scope,
scopeKey,
categoryId: selectedCategoryId,
attributeId,
searchKey: usesSearchScope ? normalized : undefined,
priority: status === 'stale' ? 'low' : 'high',
trigger: 'user_access',
});
}
const effectiveState = run
? {
...state,
refresh_run_id: run.id,
refresh_status: run.status,
}
: state;
const usable = status === 'fresh' || status === 'stale';
const items = usable ? values.rows.slice(0, 200) : [];
const last = items.at(-1);
return {
result: {
items,
next_cursor:
usable && values.rows.length > 200 && last
? Buffer.from(
JSON.stringify({
...last,
query: needle,
}),
).toString('base64url')
: null,
cache: cacheMetadata(effectiveState, status),
},
waitForRunId: enqueue && !usable ? (run?.id ?? null) : null,
};
},
);
}
private async dictionaryValues(
transaction: DatabaseTransaction,
attributeId: string,
needle: string,
after: DictionaryCursor | null,
) {
return transaction.query<{ value_id: string; value: string }>(
`SELECT external_value_id AS value_id, value
FROM atlas.platform_catalog_dictionary_values
WHERE attribute_id = $1 AND is_active
AND ($2 = '' OR value ILIKE '%' || $2 || '%')
AND ($3::text IS NULL OR (value, external_value_id) > ($3, $4))
ORDER BY value, external_value_id
LIMIT 201`,
[
attributeId,
needle,
after?.value ?? null,
after?.value_id ?? null,
],
);
}
private async requireDictionaryAttribute(
transaction: DatabaseTransaction,
platformId: string,
categoryId: string,
attributeId: string,
) {
const attribute = await transaction.query<{ id: string }>(
`SELECT id FROM atlas.platform_product_category_attributes
WHERE id = $1 AND platform_id = $2 AND platform_product_category_id = $3
AND is_active AND dictionary_id IS NOT NULL`,
[attributeId, platformId, categoryId],
);
if (!attribute.rows[0])
throw new ApiError(
422,
'ATTRIBUTE_DICTIONARY_INVALID',
'字典不属于当前类目的属性',
);
}
private async resourceState(
transaction: DatabaseTransaction,
platformId: string,
scope: RefreshScope,
scopeKey: string,
): Promise<ResourceState> {
const result = await transaction.query<ResourceState>(
`SELECT state.last_success_at, coalesce(state.is_complete, false) AS is_complete,
CASE WHEN $2 = 'attributes' THEN policy.attribute_fresh_days
ELSE policy.dictionary_fresh_days END AS fresh_days,
CASE WHEN $2 = 'attributes' THEN policy.attribute_expire_days
ELSE policy.dictionary_expire_days END AS expire_days,
run.id AS refresh_run_id, run.status AS refresh_status
FROM (SELECT 1) marker
JOIN atlas.platform_catalog_refresh_policies policy ON policy.platform_id = $1
LEFT JOIN atlas.platform_catalog_resource_states state
ON state.platform_id = $1 AND state.scope_key = $3
LEFT JOIN LATERAL (
SELECT id, status FROM atlas.platform_catalog_sync_runs
WHERE platform_id = $1 AND scope_key = $3
AND status IN ('queued', 'running')
ORDER BY created_at DESC LIMIT 1
) run ON true`,
[platformId, scope, scopeKey],
);
if (!result.rows[0])
throw new ApiError(
422,
'CATALOG_POLICY_REQUIRED',
'平台目录刷新策略尚未初始化',
);
return result.rows[0];
}
private async enqueueRefresh(
transaction: DatabaseTransaction,
input: {
platformId: string;
userId: string;
scope: RefreshScope;
scopeKey: string;
categoryId: string;
attributeId?: string;
searchKey?: string;
priority: 'high' | 'low';
trigger: 'user_access';
},
): Promise<RunRow> {
const source = await transaction.query<{ revision: number }>(
`SELECT revision FROM atlas.platform_catalog_sources
WHERE platform_id = $1 FOR UPDATE`,
[input.platformId],
);
if (!source.rows[0])
throw new ApiError(
422,
'CATALOG_SOURCE_REQUIRED',
'平台目录同步来源尚未配置',
);
return enqueuePlatformCatalogRun(transaction, {
platformId: input.platformId,
scope: input.scope,
scopeKey: input.scopeKey,
categoryId: input.categoryId,
attributeId: input.attributeId,
searchKey: input.searchKey,
sourceRevision: source.rows[0].revision,
trigger: input.trigger,
priority: input.priority,
userId: input.userId,
}); });
} }
/** 手动兼容入口把幂等键映射到平台 run,即使复用活动任务也可安全重放。 */
private async enqueueRequestedRefresh(
transaction: DatabaseTransaction,
key: string,
input: Parameters<ProductEditorCatalogOperations['enqueueRefresh']>[1],
) {
const prior = await transaction.query<PlatformCatalogRunRow>(
`SELECT run.id, run.platform_id, run.scope, run.scope_key, run.status,
run.trigger_kind, run.priority, run.created_at
FROM atlas.platform_catalog_sync_requests request
JOIN atlas.platform_catalog_sync_runs run ON run.id = request.run_id
WHERE request.requested_by_user_id = $1 AND request.idempotency_key = $2`,
[input.userId, key],
);
if (prior.rows[0]) {
if (
prior.rows[0].platform_id !== input.platformId ||
prior.rows[0].scope_key !== input.scopeKey
) {
throw new ApiError(
409,
'IDEMPOTENCY_KEY_REUSED',
'幂等键已用于其他目录刷新请求',
);
}
return prior.rows[0];
}
const run = await this.enqueueRefresh(transaction, input);
await transaction.query(
`INSERT INTO atlas.platform_catalog_sync_requests
(requested_by_user_id, idempotency_key, platform_id, scope_key, run_id)
VALUES ($1, $2, $3, $4, $5)`,
[input.userId, key, input.platformId, input.scopeKey, run.id],
);
return run;
}
/** 最多等待 5 秒;API 不直连 Ozon,只观察 Worker 写入的任务终态。 */
private async waitForRun(runId: string) {
const deadline = Date.now() + 5_000;
while (Date.now() < deadline) {
const result = await this.database.query<{ status: string }>(
`SELECT status FROM atlas.platform_catalog_sync_runs WHERE id = $1`,
[runId],
);
if (
!result.rows[0] ||
['succeeded', 'failed', 'cancelled'].includes(
result.rows[0].status,
)
)
return;
await new Promise<void>((resolve) => setTimeout(resolve, 250));
}
}
}
function normalizeSearch(value: string) {
return value.trim().toLocaleLowerCase('zh-CN').replace(/\s+/g, ' ');
}
function dictionarySearchScopeKey(attributeId: string, search: string) {
const hash = createHash('sha256').update(search).digest('hex').slice(0, 24);
return `dictionary_search:${attributeId}:${hash}`;
} }
@@ -13,13 +13,18 @@ import {
} from '@nestjs/common'; } from '@nestjs/common';
import { z } from 'zod'; import { z } from 'zod';
import { import {
productPublishPreflightRequestSchema,
createProductPublishJobRequestSchema,
requestProductAiTaskSchema,
saveProductEditorDraftRequestSchema, saveProductEditorDraftRequestSchema,
saveProductSourceDescriptionRequestSchema,
type Workspace, type Workspace,
} from '@atlas/contracts'; } from '@atlas/contracts';
import { ApiError } from '../common/api-error.js'; import { ApiError } from '../common/api-error.js';
import type { AuthenticatedRequest } from '../auth/auth.guard.js'; import type { AuthenticatedRequest } from '../auth/auth.guard.js';
import { CurrentWorkspace } from '../workspace/current-workspace.decorator.js'; import { CurrentWorkspace } from '../workspace/current-workspace.decorator.js';
import { ProductEditorService } from './product-editor.service.js'; import { ProductEditorService } from './product-editor.service.js';
import { ProductAiService } from './product-ai.service.js';
type Response = { status: (value: number) => Response }; type Response = { status: (value: number) => Response };
@@ -29,6 +34,8 @@ export class ProductEditorController {
public constructor( public constructor(
@Inject(ProductEditorService) @Inject(ProductEditorService)
private readonly editor: ProductEditorService, private readonly editor: ProductEditorService,
@Inject(ProductAiService)
private readonly productAi: ProductAiService,
) {} ) {}
/** 为采集记录创建或复用当前平台的商品编辑上下文。 */ /** 为采集记录创建或复用当前平台的商品编辑上下文。 */
@@ -68,12 +75,90 @@ export class ProductEditorController {
}; };
} }
/** 返回目标店铺已缓存的类目树,供编辑器选择。 */ /** 用户主动创建 SPU AI 填报任务。 */
@Post('product-platform-profiles/:profileId/ai/autofills')
async aiAutofill(
@Param('profileId') profileId: string,
@Body() body: unknown,
@Headers('x-atlas-platform-id') platformId: string | undefined,
@Headers('idempotency-key') key: string | undefined,
@Req() request: AuthenticatedRequest,
@CurrentWorkspace() workspace: Workspace,
@Res({ passthrough: true }) response: Response,
) {
const parsed = requestProductAiTaskSchema.safeParse(body ?? {});
if (!parsed.success)
throw new ApiError(
422,
'VALIDATION_FAILED',
'AI 请求参数不符合要求',
);
const result = await this.productAi.create(
workspace.id,
request.auth.userId,
requiredUuid('profile_id', profileId),
requiredUuid('X-Atlas-Platform-Id', platformId),
null,
requiredUuid('Idempotency-Key', key),
parsed.data.hint,
);
response.status(result.status);
return result.body;
}
/** 用户主动创建单个 SKU 的文案任务,不影响其他 SKU。 */
@Post('product-platform-profiles/:profileId/variants/:variantId/ai/copy')
async aiSkuCopy(
@Param('profileId') profileId: string,
@Param('variantId') variantId: string,
@Body() body: unknown,
@Headers('x-atlas-platform-id') platformId: string | undefined,
@Headers('idempotency-key') key: string | undefined,
@Req() request: AuthenticatedRequest,
@CurrentWorkspace() workspace: Workspace,
@Res({ passthrough: true }) response: Response,
) {
const parsed = requestProductAiTaskSchema.safeParse(body ?? {});
if (!parsed.success)
throw new ApiError(
422,
'VALIDATION_FAILED',
'AI 请求参数不符合要求',
);
const result = await this.productAi.create(
workspace.id,
request.auth.userId,
requiredUuid('profile_id', profileId),
requiredUuid('X-Atlas-Platform-Id', platformId),
requiredUuid('variant_id', variantId),
requiredUuid('Idempotency-Key', key),
parsed.data.hint,
);
response.status(result.status);
return result.body;
}
/** 查询当前工作空间和平台的 AI 任务状态。 */
@Get('product-ai-tasks/:taskId')
async aiTask(
@Param('taskId') taskId: string,
@Headers('x-atlas-platform-id') platformId: string | undefined,
@CurrentWorkspace() workspace: Workspace,
) {
return {
data: await this.productAi.read(
workspace.id,
requiredUuid('ai_task_id', taskId),
requiredUuid('X-Atlas-Platform-Id', platformId),
),
};
}
/** 返回当前平台共享的类目树,供编辑器选择。 */
@Get('product-platform-profiles/:profileId/category-options') @Get('product-platform-profiles/:profileId/category-options')
async categoryOptions( async categoryOptions(
@Param('profileId') profileId: string, @Param('profileId') profileId: string,
@Query('q') query: string | undefined, @Query('q') query: string | undefined,
@Query('store_id') storeId: string | undefined,
@Headers('x-atlas-platform-id') platformId: string | undefined, @Headers('x-atlas-platform-id') platformId: string | undefined,
@CurrentWorkspace() workspace: Workspace, @CurrentWorkspace() workspace: Workspace,
) { ) {
@@ -83,7 +168,6 @@ export class ProductEditorController {
requiredUuid('profile_id', profileId), requiredUuid('profile_id', profileId),
requiredUuid('X-Atlas-Platform-Id', platformId), requiredUuid('X-Atlas-Platform-Id', platformId),
query, query,
optionalUuid('store_id', storeId),
), ),
}; };
} }
@@ -93,21 +177,96 @@ export class ProductEditorController {
async attributeSchema( async attributeSchema(
@Param('profileId') profileId: string, @Param('profileId') profileId: string,
@Query('category_id') categoryId: string | undefined, @Query('category_id') categoryId: string | undefined,
@Query('store_id') storeId: string | undefined, @Headers('x-atlas-platform-id') platformId: string | undefined,
@Req() request: AuthenticatedRequest,
@CurrentWorkspace() workspace: Workspace,
@Res({ passthrough: true }) response: Response,
) {
const result = await this.editor.attributeSchema(
workspace.id,
request.auth.userId,
requiredUuid('profile_id', profileId),
requiredUuid('X-Atlas-Platform-Id', platformId),
optionalUuid('category_id', categoryId),
);
response.status(result.status);
return result.body;
}
/** 返回首次上架前的服务端发布就绪校验。 */
@Get('product-platform-profiles/:profileId/validation')
async validation(
@Param('profileId') profileId: string,
@Headers('x-atlas-platform-id') platformId: string | undefined, @Headers('x-atlas-platform-id') platformId: string | undefined,
@CurrentWorkspace() workspace: Workspace, @CurrentWorkspace() workspace: Workspace,
) { ) {
return { return {
data: await this.editor.attributeSchema( data: await this.editor.validation(
workspace.id, workspace.id,
requiredUuid('profile_id', profileId), requiredUuid('profile_id', profileId),
requiredUuid('X-Atlas-Platform-Id', platformId), requiredUuid('X-Atlas-Platform-Id', platformId),
optionalUuid('category_id', categoryId),
optionalUuid('store_id', storeId),
), ),
}; };
} }
/** 定向刷新本次首次上架依赖的目录资源,并返回最终发布预检。 */
@Post('product-platform-profiles/:profileId/publish-preflights')
async publishPreflight(
@Param('profileId') profileId: string,
@Body() body: unknown,
@Headers('x-atlas-platform-id') platformId: string | undefined,
@Req() request: AuthenticatedRequest,
@CurrentWorkspace() workspace: Workspace,
@Res({ passthrough: true }) response: Response,
) {
const parsed = productPublishPreflightRequestSchema.safeParse(body);
if (!parsed.success) {
throw new ApiError(
422,
'VALIDATION_FAILED',
'请求参数不符合要求',
parsed.error.issues.map((issue) => ({
field: issue.path.join('.'),
code: 'INVALID_FIELD',
message: issue.message,
})),
);
}
const result = await this.editor.publishPreflight(
workspace.id,
request.auth.userId,
requiredUuid('profile_id', profileId),
requiredUuid('X-Atlas-Platform-Id', platformId),
parsed.data.variant_ids,
);
response.status(result.status);
return result.body;
}
/** 同一事务重新校验并创建首次发布批次。 */
@Post('product-platform-profiles/:profileId/publish-jobs')
async createPublishJob(@Param('profileId') profileId: string, @Body() body: unknown,
@Headers('x-atlas-platform-id') platformId: string | undefined, @Headers('idempotency-key') key: string | undefined,
@Headers('if-match') revision: string | undefined, @Req() request: AuthenticatedRequest,
@CurrentWorkspace() workspace: Workspace, @Res({ passthrough: true }) response: Response) {
const parsed = createProductPublishJobRequestSchema.safeParse(body);
if (!parsed.success) throw new ApiError(422, 'VALIDATION_FAILED', '请选择 1–50 个不同的待上架 SKU');
const result = await this.editor.createPublishJob(workspace.id, request.auth.userId, requiredUuid('profile_id', profileId), requiredUuid('X-Atlas-Platform-Id', platformId), requiredUuid('Idempotency-Key', key), parseEditorRevision(revision), parsed.data.variant_ids);
response.status(result.status); return result.body;
}
/** 恢复编辑页最近发布任务,刷新页面后也能查看进度。 */
@Get('product-platform-profiles/:profileId/publish-jobs')
async publishJobs(@Param('profileId') profileId: string, @Headers('x-atlas-platform-id') platformId: string | undefined, @CurrentWorkspace() workspace: Workspace) {
return {data: await this.editor.listPublishJobs(workspace.id, requiredUuid('profile_id', profileId), requiredUuid('X-Atlas-Platform-Id', platformId))};
}
/** 查询当前租户及平台下的逐 SKU 发布结果。 */
@Get('product-publish-jobs/:jobId')
async publishJob(@Param('jobId') jobId: string, @Headers('x-atlas-platform-id') platformId: string | undefined, @CurrentWorkspace() workspace: Workspace) {
return {data: await this.editor.readPublishJob(workspace.id, requiredUuid('publish_job_id', jobId), requiredUuid('X-Atlas-Platform-Id', platformId))};
}
/** 幂等地创建所选类目属性定义的刷新任务。 */ /** 幂等地创建所选类目属性定义的刷新任务。 */
@Post('product-platform-profiles/:profileId/attribute-schema-syncs') @Post('product-platform-profiles/:profileId/attribute-schema-syncs')
async attributeSchemaSync( async attributeSchemaSync(
@@ -133,39 +292,40 @@ export class ProductEditorController {
/** 从本地缓存中查询属性字典值。 */ /** 从本地缓存中查询属性字典值。 */
@Get( @Get(
'product-platform-profiles/:profileId/attribute-dictionaries/:dictionaryId', 'product-platform-profiles/:profileId/attribute-dictionaries/:attributeId',
) )
async attributeDictionary( async attributeDictionary(
@Param('profileId') profileId: string, @Param('profileId') profileId: string,
@Param('dictionaryId') dictionaryId: string, @Param('attributeId') attributeId: string,
@Query('q') query: string | undefined, @Query('q') query: string | undefined,
@Query('cursor') cursor: string | undefined,
@Query('category_id') categoryId: string | undefined, @Query('category_id') categoryId: string | undefined,
@Query('store_id') storeId: string | undefined,
@Headers('x-atlas-platform-id') platformId: string | undefined, @Headers('x-atlas-platform-id') platformId: string | undefined,
@Req() request: AuthenticatedRequest,
@CurrentWorkspace() workspace: Workspace, @CurrentWorkspace() workspace: Workspace,
@Res({ passthrough: true }) response: Response,
) { ) {
if (!dictionaryId.trim() || dictionaryId.length > 200) const result = await this.editor.attributeDictionary(
throw new ApiError(400, 'VALIDATION_FAILED', '字典标识不合法'); workspace.id,
return { request.auth.userId,
data: await this.editor.attributeDictionary( requiredUuid('profile_id', profileId),
workspace.id, requiredUuid('X-Atlas-Platform-Id', platformId),
requiredUuid('profile_id', profileId), requiredUuid('attribute_id', attributeId),
requiredUuid('X-Atlas-Platform-Id', platformId), query,
dictionaryId, cursor,
query, optionalUuid('category_id', categoryId),
optionalUuid('category_id', categoryId), );
optionalUuid('store_id', storeId), response.status(result.status);
), return result.body;
};
} }
/** 本地缓存不足时幂等地创建远程字典查询任务。 */ /** 本地缓存不足时幂等地创建远程字典查询任务。 */
@Post( @Post(
'product-platform-profiles/:profileId/attribute-dictionaries/:dictionaryId/syncs', 'product-platform-profiles/:profileId/attribute-dictionaries/:attributeId/syncs',
) )
async attributeDictionarySync( async attributeDictionarySync(
@Param('profileId') profileId: string, @Param('profileId') profileId: string,
@Param('dictionaryId') dictionaryId: string, @Param('attributeId') attributeId: string,
@Query('category_id') categoryId: string | undefined, @Query('category_id') categoryId: string | undefined,
@Query('q') query: string | undefined, @Query('q') query: string | undefined,
@Headers('x-atlas-platform-id') platformId: string | undefined, @Headers('x-atlas-platform-id') platformId: string | undefined,
@@ -174,8 +334,6 @@ export class ProductEditorController {
@CurrentWorkspace() workspace: Workspace, @CurrentWorkspace() workspace: Workspace,
@Res({ passthrough: true }) response: Response, @Res({ passthrough: true }) response: Response,
) { ) {
if (!dictionaryId.trim() || dictionaryId.length > 200)
throw new ApiError(400, 'VALIDATION_FAILED', '字典标识不合法');
const result = await this.editor.requestAttributeDictionarySync( const result = await this.editor.requestAttributeDictionarySync(
workspace.id, workspace.id,
request.auth.userId, request.auth.userId,
@@ -183,7 +341,7 @@ export class ProductEditorController {
requiredUuid('X-Atlas-Platform-Id', platformId), requiredUuid('X-Atlas-Platform-Id', platformId),
requiredUuid('Idempotency-Key', key), requiredUuid('Idempotency-Key', key),
requiredUuid('category_id', categoryId), requiredUuid('category_id', categoryId),
dictionaryId, requiredUuid('attribute_id', attributeId),
query ?? '', query ?? '',
); );
response.status(result.status); response.status(result.status);
@@ -226,6 +384,44 @@ export class ProductEditorController {
) )
).body; ).body;
} }
/** 独立保存右侧采集面板的商品描述,不要求整页草稿处于可保存状态。 */
@Put('products/:productId/source-description')
async saveSourceDescription(
@Param('productId') productId: string,
@Body() body: unknown,
@Headers('x-atlas-platform-id') platformId: string | undefined,
@Headers('idempotency-key') key: string | undefined,
@Headers('if-match') ifMatch: string | undefined,
@Req() request: AuthenticatedRequest,
@CurrentWorkspace() workspace: Workspace,
) {
const parsed =
saveProductSourceDescriptionRequestSchema.safeParse(body);
if (!parsed.success) {
throw new ApiError(
422,
'VALIDATION_FAILED',
'请求参数不符合要求',
parsed.error.issues.map((issue) => ({
field: issue.path.join('.'),
code: 'INVALID_FIELD',
message: issue.message,
})),
);
}
return (
await this.editor.saveSourceDescription(
workspace.id,
request.auth.userId,
requiredUuid('product_id', productId),
requiredUuid('X-Atlas-Platform-Id', platformId),
requiredUuid('Idempotency-Key', key),
parseEditorRevision(ifMatch),
parsed.data,
)
).body;
}
} }
/** 校验路由参数或请求头中的 UUID,并返回原始值。 */ /** 校验路由参数或请求头中的 UUID,并返回原始值。 */
@@ -8,6 +8,7 @@ export type ProductRow = {
model: string | null; model: string | null;
barcode: string | null; barcode: string | null;
purchase_url: string | null; purchase_url: string | null;
source_url: string | null;
supply_note: string | null; supply_note: string | null;
default_physical_length_mm: string | null; default_physical_length_mm: string | null;
default_physical_width_mm: string | null; default_physical_width_mm: string | null;
@@ -17,6 +18,8 @@ export type ProductRow = {
default_package_width_mm: string | null; default_package_width_mm: string | null;
default_package_height_mm: string | null; default_package_height_mm: string | null;
default_shipping_weight_g: string | null; default_shipping_weight_g: string | null;
package_weight_mode: 'common' | 'variant';
package_dimensions_mode: 'common' | 'variant';
physical_spec_status: 'missing' | 'ambiguous' | 'confirmed'; physical_spec_status: 'missing' | 'ambiguous' | 'confirmed';
package_spec_status: 'missing' | 'ambiguous' | 'confirmed'; package_spec_status: 'missing' | 'ambiguous' | 'confirmed';
ai_source_material_overrides: unknown; ai_source_material_overrides: unknown;
@@ -28,13 +31,16 @@ export type ProductRow = {
/** 商品在一个发布平台下的编辑配置行。 */ /** 商品在一个发布平台下的编辑配置行。 */
export type ProfileRow = { export type ProfileRow = {
id: string; id: string;
product_id: string;
revision: number; revision: number;
platform_id: string; platform_id: string;
market_code: string | null; market_code: string | null;
fulfillment: string; fulfillment: string;
title: string | null; title: string | null;
title_mode: 'common' | 'variant';
description: string | null; description: string | null;
tags: unknown; tags: unknown;
platform_product_category_id: string | null;
store_product_category_id: string | null; store_product_category_id: string | null;
platform_type_id: string | null; platform_type_id: string | null;
category_source: string | null; category_source: string | null;
@@ -57,13 +63,14 @@ export type OperationRow = {
}; };
/** 统一读取发布配置及其目标店铺与类目展示信息的查询片段。 */ /** 统一读取发布配置及其目标店铺与类目展示信息的查询片段。 */
export const profileSelect = `SELECT p.id, p.revision, p.platform_id, p.market_code, export const profileSelect = `SELECT p.id, p.product_id, p.revision, p.platform_id, p.market_code,
p.fulfillment, p.title, p.description, p.tags, p.store_product_category_id, p.fulfillment, p.title, p.title_mode, p.description, p.tags, p.platform_product_category_id,
p.store_product_category_id,
p.platform_type_id, p.category_source, category.name AS category_name, p.platform_type_id, p.category_source, category.name AS category_name,
category.path AS category_path, p.target_store_id, s.name AS store_name, category.path AS category_path, p.target_store_id, s.name AS store_name,
s.currency_code s.currency_code
FROM atlas.product_platform_profiles p FROM atlas.product_platform_profiles p
JOIN atlas.stores s ON s.id = p.target_store_id JOIN atlas.stores s ON s.id = p.target_store_id
LEFT JOIN atlas.store_product_categories category LEFT JOIN atlas.platform_product_categories category
ON category.id = p.store_product_category_id ON category.id = p.platform_product_category_id
AND category.store_id = p.target_store_id`; AND category.platform_id = p.platform_id`;
@@ -2,9 +2,10 @@ import { Module } from '@nestjs/common';
import { IdempotencyService } from '../common/idempotency.service.js'; import { IdempotencyService } from '../common/idempotency.service.js';
import { ProductEditorController } from './product-editor.controller.js'; import { ProductEditorController } from './product-editor.controller.js';
import { ProductEditorService } from './product-editor.service.js'; import { ProductEditorService } from './product-editor.service.js';
import { ProductAiService } from './product-ai.service.js';
@Module({ @Module({
controllers: [ProductEditorController], controllers: [ProductEditorController],
providers: [IdempotencyService, ProductEditorService], providers: [IdempotencyService, ProductEditorService, ProductAiService],
}) })
export class ProductEditorModule {} export class ProductEditorModule {}
@@ -0,0 +1,508 @@
import type {
ProductAttributeSchema,
ProductCatalogCacheMetadata,
ProductCatalogRefreshRun,
ProductEditorValidation,
ProductPublishPreflight,
} from '@atlas/contracts';
import type { Database, DatabaseTransaction } from '@atlas/database';
import { ApiError } from '../common/api-error.js';
import {
enqueuePlatformCatalogRun,
type PlatformCatalogRunRow,
} from '../platform-catalog/platform-catalog-run.js';
import {
productCatalogCacheMetadata as metadata,
productCatalogResourceStatus as statusOf,
productCatalogStateWithRun as withRun,
type ProductCatalogResourceState as ResourceState,
} from './product-editor.catalog-cache.js';
import type { ProfileRow } from './product-editor.models.js';
type PublishPreflightAccess = {
requireProfileForPlatform: (
transaction: DatabaseTransaction,
profileId: string,
platformId: string,
) => Promise<ProfileRow>;
requireActiveCategory: (
transaction: DatabaseTransaction,
platformId: string,
categoryId: string,
) => Promise<unknown>;
attributeSchemaForCategory: (
transaction: DatabaseTransaction,
platformId: string,
categoryId: string,
) => Promise<ProductAttributeSchema[]>;
validation: (
workspaceId: string,
profileId: string,
platformId: string,
variantIds: string[],
) => Promise<ProductEditorValidation>;
};
type PreparedCatalog = {
schema: ProductCatalogCacheMetadata;
dictionaries: ProductPublishPreflight['catalog']['dictionaries'];
runs: PlatformCatalogRunRow[];
blockingRunIds: string[];
hasBlockingResources: boolean;
};
type UsedDictionary = {
attribute_id: string;
platform_attribute_id: string;
name: string;
};
/**
* 首次发布预检只刷新当前商品实际依赖的平台目录资源。
* API 只创建平台任务并观察 Worker 结果,绝不在请求进程直接调用 Ozon。
*/
export class ProductEditorPublishPreflightOperations {
public constructor(
private readonly database: Database,
private readonly access: PublishPreflightAccess,
) {}
async preflight(
workspaceId: string,
userId: string,
profileId: string,
platformId: string,
variantIds: string[],
): Promise<{ status: number; body: { data: ProductPublishPreflight } }> {
const deadline = Date.now() + 5_000;
const observedRuns = new Map<string, PlatformCatalogRunRow>();
let prepared = await this.prepare(
workspaceId,
userId,
profileId,
platformId,
variantIds,
true,
);
rememberRuns(observedRuns, prepared.runs);
// 第一轮通常等待 schema;成功后第二轮才知道本商品实际引用哪些字典。
for (
let pass = 0;
pass < 2 && prepared.blockingRunIds.length;
pass += 1
) {
const succeeded = await this.waitForRuns(
prepared.blockingRunIds,
deadline,
);
prepared = await this.prepare(
workspaceId,
userId,
profileId,
platformId,
variantIds,
succeeded && Date.now() < deadline,
);
rememberRuns(observedRuns, prepared.runs);
if (!succeeded) break;
}
const validation = await this.access.validation(
workspaceId,
profileId,
platformId,
variantIds,
);
const currentRuns = await this.currentRuns([...observedRuns.keys()]);
const refreshing = prepared.hasBlockingResources;
const result: ProductPublishPreflight = {
status: refreshing
? 'refreshing'
: validation.ready
? 'ready'
: 'blocked',
validation,
catalog: {
schema: prepared.schema,
dictionaries: prepared.dictionaries,
},
refresh_runs: currentRuns.map(toRefreshRun),
};
return {
status: refreshing ? 202 : 200,
body: { data: result },
};
}
private async prepare(
workspaceId: string,
userId: string,
profileId: string,
platformId: string,
variantIds: string[],
enqueue: boolean,
): Promise<PreparedCatalog> {
return this.database.workspaceTransaction(
workspaceId,
async (transaction) => {
const profile = await this.access.requireProfileForPlatform(
transaction,
profileId,
platformId,
);
if (!profile.platform_product_category_id)
throw new ApiError(
422,
'CATEGORY_REQUIRED',
'首次上架前请先选择产品类目',
);
await this.access.requireActiveCategory(
transaction,
platformId,
profile.platform_product_category_id,
);
await this.requirePublishableVariants(
transaction,
profile,
variantIds,
);
let sourceRevision: number | null = null;
const runs: PlatformCatalogRunRow[] = [];
const blockingRunIds: string[] = [];
const enqueueResource = async (input: {
scope: 'attributes' | 'dictionary';
scopeKey: string;
categoryId: string;
attributeId?: string;
priority: 'high' | 'low';
}) => {
if (sourceRevision === null) {
const source = await transaction.query<{
revision: number;
}>(
`SELECT revision FROM atlas.platform_catalog_sources
WHERE platform_id = $1 FOR UPDATE`,
[platformId],
);
if (!source.rows[0])
throw new ApiError(
422,
'CATALOG_SOURCE_REQUIRED',
'平台目录同步来源尚未配置',
);
sourceRevision = source.rows[0].revision;
}
const run = await enqueuePlatformCatalogRun(transaction, {
platformId,
scope: input.scope,
scopeKey: input.scopeKey,
categoryId: input.categoryId,
attributeId: input.attributeId,
sourceRevision,
trigger: 'publish',
priority: input.priority,
userId,
});
runs.push(run);
return run;
};
const categoryId = profile.platform_product_category_id;
let schemaState = await this.resourceState(
transaction,
platformId,
'attributes',
`attributes:${categoryId}`,
);
const schemaStatus = statusOf(schemaState);
if (enqueue && schemaStatus !== 'fresh') {
const run = await enqueueResource({
scope: 'attributes',
scopeKey: `attributes:${categoryId}`,
categoryId,
priority: schemaStatus === 'stale' ? 'low' : 'high',
});
schemaState = withRun(schemaState, run);
if (
schemaStatus === 'expired' ||
schemaStatus === 'missing'
)
blockingRunIds.push(run.id);
} else if (
schemaStatus === 'expired' ||
schemaStatus === 'missing'
) {
if (schemaState.refresh_run_id)
blockingRunIds.push(schemaState.refresh_run_id);
}
const schemaUsable =
schemaStatus === 'fresh' || schemaStatus === 'stale';
const dictionaries: ProductPublishPreflight['catalog']['dictionaries'] =
[];
if (schemaUsable) {
const schema = await this.access.attributeSchemaForCategory(
transaction,
platformId,
categoryId,
);
const used = await this.usedDictionaries(
transaction,
profileId,
variantIds,
schema,
);
for (const dictionary of used) {
let state = await this.resourceState(
transaction,
platformId,
'dictionary',
`dictionary:${dictionary.attribute_id}`,
);
const status = statusOf(state);
if (enqueue && status !== 'fresh') {
const run = await enqueueResource({
scope: 'dictionary',
scopeKey: `dictionary:${dictionary.attribute_id}`,
categoryId,
attributeId: dictionary.attribute_id,
priority: status === 'stale' ? 'low' : 'high',
});
state = withRun(state, run);
if (status === 'expired' || status === 'missing')
blockingRunIds.push(run.id);
} else if (
status === 'expired' ||
status === 'missing'
) {
if (state.refresh_run_id)
blockingRunIds.push(state.refresh_run_id);
}
dictionaries.push({
...dictionary,
cache: metadata(state, status),
});
}
}
return {
schema: metadata(schemaState, schemaStatus),
dictionaries,
runs,
blockingRunIds: [...new Set(blockingRunIds)],
hasBlockingResources:
!schemaUsable ||
dictionaries.some(
(item) =>
item.cache.status === 'expired' ||
item.cache.status === 'missing',
),
};
},
);
}
/** 发布入口只能选择当前 profile 下尚未首次导入的活动 SKU。 */
private async requirePublishableVariants(
transaction: DatabaseTransaction,
profile: ProfileRow,
variantIds: string[],
) {
const result = await transaction.query<{ id: string; status: string }>(
`SELECT variant.id, listing.status
FROM atlas.product_variants variant
JOIN atlas.product_variant_platform_listings listing
ON listing.product_variant_id = variant.id
AND listing.profile_id = $1
WHERE variant.product_id = $2 AND variant.deleted_at IS NULL
AND variant.id = ANY($3::uuid[])`,
[profile.id, profile.product_id, variantIds],
);
if (result.rows.length !== new Set(variantIds).size)
throw new ApiError(
422,
'PUBLISH_VARIANTS_INVALID',
'待发布 SKU 不属于当前商品,或已经被删除',
);
if (result.rows.some((item) => item.status === 'submitting'))
throw new ApiError(
409,
'SKU_IMPORT_IN_PROGRESS',
'所选 SKU 正在首次上架,请等待当前任务完成',
);
if (result.rows.some((item) => item.status === 'imported'))
throw new ApiError(
422,
'SKU_ALREADY_IMPORTED',
'所选 SKU 已经首次导入,不能再次上架',
);
if (
result.rows.some(
(item) => !['not_listed', 'failed'].includes(item.status),
)
)
throw new ApiError(
422,
'SKU_NOT_PUBLISHABLE',
'所选 SKU 当前状态不允许首次上架',
);
}
/** 从已确认草稿中提取本次 SKU 集合真正使用的字典属性。 */
private async usedDictionaries(
transaction: DatabaseTransaction,
profileId: string,
variantIds: string[],
schema: ProductAttributeSchema[],
): Promise<UsedDictionary[]> {
const definitions = new Map(
schema
.filter(
(item) =>
item.value_type === 'dictionary' &&
item.dictionary_id &&
!item.editor_field,
)
.map((item) => [
`${item.platform_attribute_id}\u0000${item.complex_id}`,
item,
]),
);
const attributes = await transaction.query<{
platform_attribute_id: string;
complex_id: string;
values: unknown;
}>(
`SELECT platform_attribute_id, complex_id, values
FROM atlas.product_attributes
WHERE profile_id = $1 AND confirmation_status = 'confirmed'
AND (scope = 'common' OR product_variant_id = ANY($2::uuid[]))`,
[profileId, variantIds],
);
const used = new Map<string, UsedDictionary>();
// 内建品牌等字典字段也需要平台候选值,不能绕过发布目录有效期检查。
for (const definition of schema.filter(item => item.editor_field && item.value_type === 'dictionary' && item.dictionary_id)) {
used.set(definition.attribute_id, {attribute_id: definition.attribute_id, platform_attribute_id: definition.platform_attribute_id, name: definition.name});
}
for (const attribute of attributes.rows) {
if (!hasStringValue(attribute.values)) continue;
const definition = definitions.get(
`${attribute.platform_attribute_id}\u0000${attribute.complex_id}`,
);
if (!definition || used.has(definition.attribute_id)) continue;
used.set(definition.attribute_id, {
attribute_id: definition.attribute_id,
platform_attribute_id: definition.platform_attribute_id,
name: definition.name,
});
}
return [...used.values()];
}
private async resourceState(
transaction: DatabaseTransaction,
platformId: string,
scope: 'attributes' | 'dictionary',
scopeKey: string,
): Promise<ResourceState> {
const result = await transaction.query<ResourceState>(
`SELECT state.last_success_at,
coalesce(state.is_complete, false) AS is_complete,
CASE WHEN $2 = 'attributes' THEN policy.attribute_fresh_days
ELSE policy.dictionary_fresh_days END AS fresh_days,
CASE WHEN $2 = 'attributes' THEN policy.attribute_expire_days
ELSE policy.dictionary_expire_days END AS expire_days,
run.id AS refresh_run_id, run.status AS refresh_status
FROM atlas.platform_catalog_refresh_policies policy
LEFT JOIN atlas.platform_catalog_resource_states state
ON state.platform_id = policy.platform_id AND state.scope_key = $3
LEFT JOIN LATERAL (
SELECT id, status
FROM atlas.platform_catalog_sync_runs
WHERE platform_id = $1 AND scope_key = $3
AND status IN ('queued', 'running')
ORDER BY created_at DESC LIMIT 1
) run ON true
WHERE policy.platform_id = $1`,
[platformId, scope, scopeKey],
);
if (!result.rows[0])
throw new ApiError(
422,
'CATALOG_POLICY_REQUIRED',
'平台目录刷新策略尚未初始化',
);
return result.rows[0];
}
/** 多个高优先级资源共用同一个五秒窗口,避免字典数量线性放大请求时长。 */
private async waitForRuns(runIds: string[], deadline: number) {
while (Date.now() < deadline) {
const result = await this.database.query<{
id: string;
status: string;
}>(
`SELECT id, status FROM atlas.platform_catalog_sync_runs
WHERE id = ANY($1::uuid[])`,
[runIds],
);
const statusById = new Map(
result.rows.map((item) => [item.id, item.status]),
);
const statuses = runIds.map(
(id) => statusById.get(id) ?? 'cancelled',
);
if (statuses.every((status) => status === 'succeeded')) return true;
if (
statuses.some((status) =>
['failed', 'cancelled'].includes(status),
)
)
return false;
await new Promise<void>((resolve) => setTimeout(resolve, 250));
}
return false;
}
/** 等待结束后重新读取任务状态,避免响应继续展示排队时的旧快照。 */
private async currentRuns(runIds: string[]) {
if (!runIds.length) return [];
const result = await this.database.query<PlatformCatalogRunRow>(
`SELECT id, platform_id, scope, scope_key, status, trigger_kind,
priority, created_at
FROM atlas.platform_catalog_sync_runs
WHERE id = ANY($1::uuid[])
ORDER BY created_at, id`,
[runIds],
);
return result.rows;
}
}
function rememberRuns(
target: Map<string, PlatformCatalogRunRow>,
runs: PlatformCatalogRunRow[],
) {
for (const run of runs) target.set(run.id, run);
}
function toRefreshRun(run: PlatformCatalogRunRow): ProductCatalogRefreshRun {
return {
id: run.id,
platform_id: run.platform_id,
scope: run.scope,
status: run.status,
trigger_kind: run.trigger_kind,
priority: run.priority,
created_at: new Date(run.created_at).toISOString(),
};
}
function hasStringValue(value: unknown) {
return (
Array.isArray(value) &&
value.some((item) => typeof item === 'string' && item.trim())
);
}
@@ -0,0 +1,515 @@
import { randomUUID } from 'node:crypto';
import { productAttributeValueRules } from '@atlas/contracts';
import type {
OzonPublishItemSnapshot,
ProductAttributeSchema,
ProductPublishJob,
ProductEditorValidation,
} from '@atlas/contracts';
import type { Database, DatabaseTransaction } from '@atlas/database';
import { ApiError } from '../common/api-error.js';
import type { IdempotencyService } from '../common/idempotency.service.js';
import type { ProfileRow, ProductRow } from './product-editor.models.js';
type Access = {
profile: (
tx: DatabaseTransaction,
id: string,
platformId: string,
) => Promise<ProfileRow>;
product: (
tx: DatabaseTransaction,
id: string,
lock?: boolean,
) => Promise<ProductRow>;
schema: (
tx: DatabaseTransaction,
platformId: string,
categoryId: string,
) => Promise<ProductAttributeSchema[]>;
validate: (
tx: DatabaseTransaction,
profileId: string,
platformId: string,
ids: string[],
) => Promise<ProductEditorValidation>;
};
type Listing = {
id: string;
variant_id: string;
display_name: string | null;
offer_id: string;
store_id: string;
status: string;
title: string | null;
tags: string[];
description: string | null;
selling_price: string;
list_price: string | null;
currency_code: string | null;
depth: string;
width: string;
height: string;
weight: string;
};
type Attribute = {
scope: string;
product_variant_id: string | null;
platform_attribute_id: string;
complex_id: string;
values: string[];
};
/** 发布创建只读取本地事实,所有远端访问由 Worker 执行。 */
export class ProductEditorPublishOperations {
constructor(
private readonly database: Database,
private readonly idempotency: IdempotencyService,
private readonly access: Access,
private readonly enabled: boolean,
) {}
async create(
workspaceId: string,
userId: string,
profileId: string,
platformId: string,
key: string,
expected: { product: number; profile: number },
ids: string[],
) {
return this.idempotency.run(
{
workspaceId,
userId,
method: 'POST',
path: `/api/product-platform-profiles/${profileId}/publish-jobs`,
key,
request: { platformId, expected, variant_ids: ids },
},
async (tx) => {
if (!this.enabled)
throw new ApiError(
503,
'OZON_PUBLISH_DISABLED',
'生产上架尚未开启,请先完成店铺 VAT 与图片交付验收',
);
if (
!ids.length ||
ids.length > 50 ||
new Set(ids).size !== ids.length
)
throw new ApiError(
422,
'PUBLISH_VARIANTS_INVALID',
'本次最多上架 50 个 SKU,请使用部分上架分批提交',
);
const initial = await this.access.profile(
tx,
profileId,
platformId,
);
const product = await this.access.product(
tx,
initial.product_id,
true,
);
await tx.query(
'SELECT id FROM atlas.product_platform_profiles WHERE id = $1 FOR UPDATE',
[profileId],
);
const profile = await this.access.profile(
tx,
profileId,
platformId,
);
if (
Number(product.revision) !== expected.product ||
Number(profile.revision) !== expected.profile
)
throw new ApiError(
409,
'VERSION_CONFLICT',
'商品资料已更新,请重新加载后上架',
);
const store = await tx.query<{ code: string }>(
`SELECT p.code FROM atlas.stores s JOIN atlas.platforms p ON p.id=s.platform_id WHERE s.id=$1 AND s.platform_id=$2 AND s.deleted_at IS NULL AND s.status='active' AND s.credential_status='valid' FOR SHARE OF s`,
[profile.target_store_id, platformId],
);
if (store.rows[0]?.code.toUpperCase() !== 'OZON')
throw new ApiError(
422,
'STORE_UNAVAILABLE',
'当前 Ozon 店铺不可用,请检查授权',
);
const category = await tx.query<{
external_category_id: string;
external_type_id: string;
}>(
`SELECT external_category_id, external_type_id FROM atlas.platform_product_categories WHERE id=$1 AND platform_id=$2 AND is_active FOR SHARE`,
[profile.platform_product_category_id, platformId],
);
if (!category.rows[0])
throw new ApiError(
422,
'CATEGORY_REQUIRED',
'请选择当前有效的 Ozon 产品类目',
);
const listings = await tx.query<Listing>(
`SELECT l.id, v.id AS variant_id, v.display_name, l.offer_id, l.store_id, l.status, l.title, l.tags, l.description, l.selling_price::text, l.list_price::text, l.currency_code,
coalesce(v.package_length_mm,p.default_package_length_mm)::text AS depth,
coalesce(v.package_width_mm,p.default_package_width_mm)::text AS width,
coalesce(v.package_height_mm,p.default_package_height_mm)::text AS height,
coalesce(v.shipping_weight_g,p.default_shipping_weight_g)::text AS weight
FROM atlas.product_variant_platform_listings l JOIN atlas.product_variants v ON v.id=l.product_variant_id JOIN atlas.products p ON p.id=v.product_id
WHERE l.profile_id=$1 AND v.product_id=$2 AND v.deleted_at IS NULL AND v.id=ANY($3::uuid[]) ORDER BY v.sort_order,v.id FOR UPDATE OF l,v`,
[profileId, product.id, ids],
);
if (listings.rows.length !== ids.length)
throw new ApiError(
422,
'PUBLISH_VARIANTS_INVALID',
'所选 SKU 不属于当前商品或已经删除',
);
if (listings.rows.some((l) => l.status === 'imported'))
throw new ApiError(
422,
'SKU_ALREADY_IMPORTED',
'所选 SKU 已在当前店铺导入,请前往在线商品查看',
);
if (listings.rows.some((l) => l.status === 'submitting'))
throw new ApiError(
409,
'SKU_IMPORT_IN_PROGRESS',
'所选 SKU 正在提交或等待平台对账,请等待任务完成',
);
if (
listings.rows.some(
(l) => l.store_id !== profile.target_store_id,
)
)
throw new ApiError(
409,
'PUBLISH_STORE_CHANGED',
'SKU 店铺与目标店铺不一致,请保存草稿后重试',
);
const validation = await this.access.validate(
tx,
profileId,
platformId,
ids,
);
if (!validation.ready)
throw new ApiError(
422,
'PUBLISH_VALIDATION_FAILED',
'请完善待上架商品资料',
[
...validation.profile.issues,
...validation.variants.flatMap((v) => v.issues),
],
);
const schema = await this.access.schema(
tx,
platformId,
profile.platform_product_category_id!,
);
const attributes = await tx.query<Attribute>(
`SELECT scope, product_variant_id, platform_attribute_id, complex_id, values FROM atlas.product_attributes WHERE profile_id=$1 AND confirmation_status='confirmed' AND (scope='common' OR product_variant_id=ANY($2::uuid[]))`,
[profileId, ids],
);
const dictionaries = await tx.query<{
attribute_id: string;
external_value_id: string;
value: string;
}>(
`SELECT attribute_id, external_value_id, value FROM atlas.platform_catalog_dictionary_values WHERE attribute_id=ANY($1::uuid[]) AND is_active`,
[
schema
.filter((d) => d.dictionary_id)
.map((d) => d.attribute_id),
],
);
const builtinDictionaryIds = schema
.filter(
(d) =>
d.editor_field &&
d.value_type === 'dictionary' &&
d.dictionary_id,
)
.map((d) => d.attribute_id);
if (builtinDictionaryIds.length) {
const states = await tx.query<{ usable: boolean }>(
`SELECT state.last_success_at IS NOT NULL AND state.last_success_at >= now()-make_interval(days=>policy.dictionary_expire_days) AS usable FROM unnest($2::uuid[]) used(id) JOIN atlas.platform_catalog_refresh_policies policy ON policy.platform_id=$1 LEFT JOIN atlas.platform_catalog_resource_states state ON state.platform_id=$1 AND state.scope_key='dictionary:' || used.id::text`,
[platformId, builtinDictionaryIds],
);
if (
states.rows.length !== builtinDictionaryIds.length ||
states.rows.some((s) => !s.usable)
)
throw new ApiError(
422,
'PUBLISH_VALIDATION_FAILED',
'品牌等内建字段的 Ozon 候选值需要更新,请重新预检',
);
}
const snapshots: OzonPublishItemSnapshot[] = [];
for (const listing of listings.rows) {
const media = await tx.query<
OzonPublishItemSnapshot['media'][number]
>(
`SELECT a.id AS asset_id,a.checksum_sha256,a.bucket,a.object_key,a.storage_provider,a.content_type FROM atlas.product_variant_listing_media m JOIN atlas.media_assets a ON a.id=m.asset_id WHERE m.listing_id=$1 ORDER BY m.sort_order`,
[listing.id],
);
if (!['not_listed', 'failed'].includes(listing.status))
throw new ApiError(
422,
'SKU_NOT_PUBLISHABLE',
'所选 SKU 当前状态不允许首次上架',
);
if (
!media.rows.length ||
media.rows.some(
(a) =>
![
'image/jpeg',
'image/png',
'image/webp',
].includes(a.content_type),
)
)
throw new ApiError(
422,
'PUBLISH_IMAGE_INVALID',
'SKU 图片必须为 JPG、PNG 或 WebP',
);
const mapped: OzonPublishItemSnapshot['attributes'] = [];
for (const definition of schema) {
if (
definition.editor_field === 'category' ||
definition.editor_field === 'rich_content'
)
continue;
let values: string[];
if (definition.editor_field) {
const fields = {
brand: product.brand,
model: product.model,
tags: (listing.tags?.length
? listing.tags
: profile.tags) as string[],
description:
listing.description ?? profile.description,
};
const field = fields[definition.editor_field];
values = Array.isArray(field)
? field
: field
? [field]
: [];
} else
values =
attributes.rows.find(
(a) =>
a.platform_attribute_id ===
definition.platform_attribute_id &&
a.complex_id ===
definition.complex_id &&
(definition.is_aspect
? a.product_variant_id ===
listing.variant_id
: a.scope === 'common'),
)?.values ?? [];
values = values
.filter((v) => typeof v === 'string' && v.trim())
.map((v) => v.trim());
// Ozon 主题标签用空格分隔后放在同一个属性值中,单值限制不代表只允许一个标签。
if (definition.editor_field === 'tags' && values.length)
values = [values.join(' ')];
const { maxValueCount } = productAttributeValueRules(definition);
if (values.length > maxValueCount)
throw new ApiError(
422,
'PUBLISH_VALIDATION_FAILED',
`${definition.name}最多 ${maxValueCount} 个值`,
[
{
field: `variants.${listing.variant_id}.attributes.${definition.platform_attribute_id}`,
code: 'ATTRIBUTE_VALUE_LIMIT',
message: `${definition.name}值数量超限`,
},
],
);
if (!values.length) continue;
mapped.push({
id: numberId(definition.platform_attribute_id),
complex_id: Number(definition.complex_id),
values: values.map((value) => {
if (definition.value_type !== 'dictionary')
return { value };
const entry = dictionaries.rows.find(
(d) =>
d.attribute_id ===
definition.attribute_id &&
(definition.editor_field
? d.value === value ||
d.external_value_id === value
: d.external_value_id === value),
);
if (!entry)
throw new ApiError(
422,
'PUBLISH_VALIDATION_FAILED',
`${definition.name}未匹配有效 Ozon 候选值`,
[
{
field: definition.editor_field
? `common.${definition.editor_field}`
: `variants.${listing.variant_id}.attributes.${definition.platform_attribute_id}`,
code: 'ATTRIBUTE_DICTIONARY_VALUE_INVALID',
message: `${definition.name}未匹配有效 Ozon 候选值`,
},
],
);
return {
dictionary_value_id: numberId(
entry.external_value_id,
),
value: entry.value,
};
}),
});
}
snapshots.push({
offer_id: listing.offer_id,
name: (profile.title_mode === 'variant'
? listing.title
: profile.title)!.trim(),
description_category_id: numberId(
category.rows[0].external_category_id,
),
type_id: numberId(category.rows[0].external_type_id),
// 首次上架以划线价预留折扣空间,后续实际售价由用户在 Ozon 调整。
price: listing.list_price!,
currency_code: profile.currency_code,
depth: Number(listing.depth),
width: Number(listing.width),
height: Number(listing.height),
weight: Number(listing.weight),
dimension_unit: 'mm',
weight_unit: 'g',
attributes: mapped,
media: media.rows,
});
}
const jobId = randomUUID();
await tx.query(
`INSERT INTO atlas.product_publish_jobs(id,workspace_id,product_id,profile_id,platform_id,store_id,product_revision,profile_revision,item_count) VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9)`,
[
jobId,
workspaceId,
product.id,
profileId,
platformId,
profile.target_store_id,
expected.product,
expected.profile,
ids.length,
],
);
for (let i = 0; i < listings.rows.length; i++) {
const listing = listings.rows[i];
await tx.query(
`INSERT INTO atlas.product_publish_job_items(workspace_id,job_id,product_id,profile_id,platform_id,store_id,listing_id,variant_id,display_name,offer_id,snapshot) VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11::jsonb)`,
[
workspaceId,
jobId,
product.id,
profileId,
platformId,
profile.target_store_id,
listing.id,
listing.variant_id,
listing.display_name,
listing.offer_id,
JSON.stringify(snapshots[i]),
],
);
}
await tx.query(
`UPDATE atlas.product_variant_platform_listings SET status='submitting',last_error=NULL,revision=revision+1,updated_at=now() WHERE id=ANY($1::uuid[])`,
[listings.rows.map((l) => l.id)],
);
await tx.query(
`INSERT INTO atlas.outbox_events(workspace_id,aggregate_type,aggregate_id,event_type,event_payload) VALUES($1,'product_publish_job',$2,'product_publish.requested',$3::jsonb)`,
[
workspaceId,
jobId,
JSON.stringify({ publish_job_id: jobId }),
],
);
return {
status: 202,
body: {
data: {
publish_job: await readJob(tx, jobId, platformId),
},
},
};
},
);
}
async read(workspaceId: string, id: string, platformId: string) {
return this.database.workspaceTransaction(workspaceId, (tx) =>
readJob(tx, id, platformId),
);
}
async list(workspaceId: string, profileId: string, platformId: string) {
return this.database.workspaceTransaction(workspaceId, async (tx) => {
await this.access.profile(tx, profileId, platformId);
const rows = await tx.query<{ id: string }>(
'SELECT id FROM atlas.product_publish_jobs WHERE profile_id=$1 AND platform_id=$2 ORDER BY created_at DESC,id DESC LIMIT 10',
[profileId, platformId],
);
const jobs = [];
for (const row of rows.rows)
jobs.push(await readJob(tx, row.id, platformId));
return jobs;
});
}
}
async function readJob(
tx: DatabaseTransaction,
id: string,
platformId: string,
): Promise<ProductPublishJob> {
const rows = await tx.query<ProductPublishJob>(
`SELECT id,profile_id,store_id,status,item_count,created_at,completed_at FROM atlas.product_publish_jobs WHERE id=$1 AND platform_id=$2`,
[id, platformId],
);
const job = rows.rows[0];
if (!job)
throw new ApiError(404, 'PUBLISH_JOB_NOT_FOUND', '发布任务不存在');
const items = await tx.query<ProductPublishJob['items'][number]>(
`SELECT id,variant_id,display_name,offer_id,status,platform_product_id,errors FROM atlas.product_publish_job_items WHERE job_id=$1 ORDER BY created_at,id`,
[id],
);
return {
...job,
created_at: new Date(job.created_at).toISOString(),
completed_at: job.completed_at
? new Date(job.completed_at).toISOString()
: null,
items: items.rows,
};
}
function numberId(value: string) {
const n = Number(value);
if (!Number.isSafeInteger(n) || n <= 0)
throw new ApiError(
422,
'OZON_ID_INVALID',
'平台目录标识无效,请刷新目录',
);
return n;
}
File diff suppressed because it is too large Load Diff
@@ -134,14 +134,16 @@ function objectValue(value: unknown): Record<string, unknown> | undefined {
: undefined; : undefined;
} }
/** 规范化来源类目面包屑,以便和缓存路径做精确匹配。 */ /**
export function normalizedCategoryPath( * Ozon 公开详情页的后二、三级导航并不稳定;自动类目匹配只使用首级
categories: Array<{ name: string }>, * 面包屑和页面结构化特征中的 Type_0 值。该信号缺失时必须交给人工选择。
): string | null { */
const names = categories export function capturedOzonCategorySignals(
.map((category) => source: ProductAiSourceMaterial,
category.name.normalize('NFKC').trim().replace(/\s+/g, ' ').toLocaleLowerCase(), ): { rootCategoryName: string | null; typeName: string | null } {
) const rootCategoryName = source.categories[0]?.name?.trim() || null;
.filter(Boolean); const typeName = source.attributes.find((attribute) =>
return names.length ? names.join(' › ') : null; /(?:^|:)type_0$/i.test(attribute.key.trim()),
)?.value.trim() || null;
return { rootCategoryName, typeName };
} }
@@ -0,0 +1,630 @@
import type {
ProductAttributeSchema,
ProductEditorValidation,
} from '@atlas/contracts';
import { ozonHashtagProblem } from '@atlas/contracts';
import { Decimal } from 'decimal.js';
import type { Database, DatabaseTransaction } from '@atlas/database';
import { ApiError } from '../common/api-error.js';
import type { ProductRow, ProfileRow } from './product-editor.models.js';
type ValidationAccess = {
requireProfileForPlatform: (
transaction: DatabaseTransaction,
profileId: string,
platformId: string,
) => Promise<ProfileRow>;
requireProduct: (
transaction: DatabaseTransaction,
productId: string,
) => Promise<ProductRow>;
attributeSchemaForCategory: (
transaction: DatabaseTransaction,
platformId: string,
categoryId: string,
) => Promise<ProductAttributeSchema[]>;
};
type VariantValidationRow = {
id: string;
display_name: string | null;
title: string | null;
tags: unknown;
listing_status: string;
offer_id: string | null;
selling_price: string | null;
list_price: string | null;
pricing_valid: boolean;
package_length_mm: string | null;
package_width_mm: string | null;
package_height_mm: string | null;
shipping_weight_g: string | null;
package_spec_status: string;
media_count: string;
};
type AttributeValidationRow = {
scope: 'common' | 'variant';
product_variant_id: string | null;
platform_attribute_id: string;
complex_id: string;
values: unknown;
confirmation_status: string;
};
type ScopedValidationIssue = {
scope: 'common' | 'variant';
variant_id: string | null;
issue: ProductEditorValidation['profile']['issues'][number];
};
/** 商品颜色是 SKU 维度的业务必填属性,即使部分 Ozon 类目将其标为选填。 */
function isRequiredVariantAttribute(definition: ProductAttributeSchema) {
return definition.is_required || definition.name.trim() === '商品颜色';
}
/**
* 计算商品草稿的首次上架就绪状态。
* 草稿保存保持宽松,这里的结果供发布前校验和页面问题汇总使用。
*/
export class ProductEditorValidationOperations {
/** 注入工作空间事务入口和编辑器统一访问边界。 */
public constructor(
private readonly database: Database,
private readonly access: ValidationAccess,
) {}
/** 按当前持久化草稿和 Ozon 类目 schema 返回结构化校验结果。 */
async validation(
workspaceId: string,
profileId: string,
platformId: string,
variantIds?: string[],
): Promise<ProductEditorValidation> {
return this.database.workspaceTransaction(
workspaceId,
async (transaction) => {
return this.validateTransaction(
transaction,
profileId,
platformId,
variantIds,
);
},
);
}
/** 创建发布任务必须在持有商品锁的同一事务内重算,防止校验与快照读到不同版本。 */
async validateTransaction(
transaction: DatabaseTransaction,
profileId: string,
platformId: string,
variantIds?: string[],
): Promise<ProductEditorValidation> {
const profile = await this.access.requireProfileForPlatform(
transaction,
profileId,
platformId,
);
const product = await this.access.requireProduct(
transaction,
profile.product_id,
);
const variants = await this.readVariants(
transaction,
profile.id,
profile.product_id,
variantIds,
);
if (variantIds && variants.length !== new Set(variantIds).size)
throw new ApiError(
422,
'PUBLISH_VARIANTS_INVALID',
'待发布 SKU 不属于当前商品,或已经被删除',
);
const attributes = await this.readAttributes(
transaction,
profile.id,
variantIds,
);
const schemaUsable = profile.platform_product_category_id
? await this.schemaUsable(
transaction,
profile.platform_id,
profile.platform_product_category_id,
)
: false;
const schema =
profile.platform_product_category_id && schemaUsable
? await this.access.attributeSchemaForCategory(
transaction,
profile.platform_id,
profile.platform_product_category_id,
)
: [];
const dictionaryIssues = schema.length
? await this.dictionaryIssues(
transaction,
profile.platform_id,
schema,
attributes,
)
: [];
const profileIssues = this.profileIssues(
product,
profile,
schema,
attributes,
);
profileIssues.push(
...dictionaryIssues
.filter((item) => item.scope === 'common')
.map((item) => item.issue),
);
const variantResults = variants.map((variant) =>
this.variantResult(
variant,
profile,
schema,
attributes,
dictionaryIssues
.filter(
(item) =>
item.scope === 'variant' &&
item.variant_id === variant.id,
)
.map((item) => item.issue),
),
);
const publishableVariants = variantResults.filter(
(variant) => variant.status !== 'not_publishable_again',
);
return {
ready:
profileIssues.length === 0 &&
publishableVariants.length > 0 &&
publishableVariants.every((variant) => variant.ready),
profile: {
ready: profileIssues.length === 0,
issues: profileIssues,
},
variants: variantResults,
};
}
/** 草稿就绪校验不得把超过过期阈值的旧 schema 当成当前 Ozon 规则。 */
private async schemaUsable(
transaction: DatabaseTransaction,
platformId: string,
categoryId: string,
) {
const result = await transaction.query<{ usable: boolean }>(
`SELECT state.last_success_at IS NOT NULL AND state.is_complete
AND state.last_success_at >= now() -
make_interval(days => policy.attribute_expire_days) AS usable
FROM atlas.platform_catalog_refresh_policies policy
LEFT JOIN atlas.platform_catalog_resource_states state
ON state.platform_id = policy.platform_id
AND state.scope_key = 'attributes:' || $2
WHERE policy.platform_id = $1`,
[platformId, categoryId],
);
return result.rows[0]?.usable === true;
}
/** 汇总商品级基础信息与通用类目属性问题。 */
private profileIssues(
product: ProductRow,
profile: ProfileRow,
schema: ProductAttributeSchema[],
attributes: AttributeValidationRow[],
): ProductEditorValidation['profile']['issues'] {
const issues: ProductEditorValidation['profile']['issues'] = [];
if (!product.source_title?.trim())
issues.push({
code: 'TITLE_REQUIRED',
field: 'common.source_title',
message: '请填写中文商品名',
});
if (profile.title_mode === 'common' && !profile.title?.trim())
issues.push({
code: 'PUBLISH_TITLE_REQUIRED',
field: 'profile.title',
message: '请填写俄文标题',
});
if (profile.title && [...profile.title].length > 500)
issues.push({
code: 'TITLE_TOO_LONG',
field: 'profile.title',
message: '俄文标题最多 500 字符',
});
if (profile.fulfillment !== 'rfbs')
issues.push({
code: 'FULFILLMENT_UNSUPPORTED',
field: 'profile.fulfillment',
message: '首次上架当前仅支持跨境 rFBS',
});
if (!product.model?.trim())
issues.push({
code: 'MODEL_REQUIRED',
field: 'common.model',
message: '请填写型号',
});
if (!product.brand?.trim())
issues.push({
code: 'BRAND_REQUIRED',
field: 'common.brand',
message: '请填写品牌',
});
if (!profile.platform_product_category_id)
issues.push({
code: 'CATEGORY_REQUIRED',
field: 'profile.platform_product_category_id',
message: '请选择产品类目',
});
if (profile.platform_product_category_id && !schema.length)
issues.push({
code: 'ATTRIBUTE_SCHEMA_REQUIRED',
field: 'attributes',
message: '当前类目的 Ozon 属性尚未同步',
});
for (const definition of schema.filter(
(item) => item.is_required && !item.is_aspect && !item.editor_field,
)) {
if (!hasConfirmedValue(attributes, 'common', null, definition))
issues.push({
code: 'OZON_ATTRIBUTE_REQUIRED',
field: `attributes.${definition.platform_attribute_id}.${definition.complex_id}`,
message: `请填写${definition.name}`,
platform_attribute_id: definition.platform_attribute_id,
});
}
return issues;
}
/** 计算单个 SKU 的发布资格与缺失项。 */
private variantResult(
variant: VariantValidationRow,
profile: ProfileRow,
schema: ProductAttributeSchema[],
attributes: AttributeValidationRow[],
dictionaryIssues: ProductEditorValidation['profile']['issues'] = [],
): ProductEditorValidation['variants'][number] {
if (variant.listing_status === 'imported')
return {
variant_id: variant.id,
display_name: variant.display_name,
status: 'not_publishable_again',
ready: false,
issues: [],
};
const issues: ProductEditorValidation['variants'][number]['issues'] = [
...dictionaryIssues,
];
// 只校验本次 SKU 实际使用的标签,独立覆盖时不受未使用的通用文案阻断。
const ownTags = stringValues(variant.tags);
const tagsProblem = ozonHashtagProblem(
ownTags.length ? ownTags : stringValues(profile.tags),
);
if (tagsProblem)
issues.push({
code: 'OZON_HASHTAG_INVALID',
field: ownTags.length
? `variants.${variant.id}.tags`
: 'profile.tags',
message: tagsProblem,
});
if (profile.title_mode === 'variant' && !variant.title?.trim())
issues.push({
code: 'PUBLISH_TITLE_REQUIRED',
field: `variants.${variant.id}.title`,
message: '请填写该 SKU 的俄文标题',
});
if (variant.listing_status === 'submitting')
issues.push({
code: 'SKU_IMPORT_IN_PROGRESS',
field: `variants.${variant.id}.listing`,
message: '该 SKU 正在首次上架,请等待当前任务完成',
});
if (variant.title && [...variant.title].length > 500)
issues.push({
code: 'TITLE_TOO_LONG',
field: `variants.${variant.id}.title`,
message: '俄文标题最多 500 字符',
});
if (variant.offer_id && [...variant.offer_id].length > 50)
issues.push({
code: 'OFFER_ID_TOO_LONG',
field: `variants.${variant.id}.offer_id`,
message: '卖家货号最多 50 字符',
});
if (Number(variant.media_count) > 30)
issues.push({
code: 'IMAGE_LIMIT_EXCEEDED',
field: `variants.${variant.id}.media_asset_ids`,
message: '每个 SKU 最多 30 张图片',
});
if (!variant.offer_id?.trim())
issues.push({
code: 'OFFER_ID_REQUIRED',
field: `variants.${variant.id}.offer_id`,
message: '请填写卖家货号',
});
if (!variant.pricing_valid)
issues.push({
code: 'PRICING_REQUIRED',
field: `variants.${variant.id}.pricing_selection`,
message: '请按当前采购价和包装确认报价',
});
const sellingPrice = variant.selling_price
? new Decimal(variant.selling_price)
: null;
const listPrice = variant.list_price ? new Decimal(variant.list_price) : null;
if (!sellingPrice?.isFinite() || sellingPrice.lte(0))
issues.push({
code: 'PRICE_REQUIRED',
field: `variants.${variant.id}.selling_price`,
message: '请先确认实际售价',
});
if (!listPrice?.isFinite() || listPrice.lte(0))
issues.push({
code: 'LIST_PRICE_REQUIRED',
field: `variants.${variant.id}.list_price`,
message: '请重新试算并确认划线价,首次上架将按划线价提交',
});
else if (sellingPrice?.isFinite() && listPrice.lt(sellingPrice))
issues.push({
code: 'LIST_PRICE_INVALID',
field: `variants.${variant.id}.list_price`,
message: '划线价不能低于实际售价,请重新确认报价',
});
if (
variant.package_spec_status !== 'confirmed' ||
!variant.package_length_mm ||
!variant.package_width_mm ||
!variant.package_height_mm ||
!variant.shipping_weight_g ||
![
variant.package_length_mm,
variant.package_width_mm,
variant.package_height_mm,
variant.shipping_weight_g,
].every(
(value) => Number.isInteger(Number(value)) && Number(value) > 0,
)
)
issues.push({
code: 'PACKAGE_SPEC_REQUIRED',
field: `variants.${variant.id}.package_spec`,
message: '请确认有效的包装重量和包装尺寸',
});
if (Number(variant.media_count) < 1)
issues.push({
code: 'IMAGE_REQUIRED',
field: `variants.${variant.id}.media_asset_ids`,
message: '请至少选择一张 SKU 图片',
});
for (const definition of schema.filter(
(item) =>
isRequiredVariantAttribute(item) &&
item.is_aspect &&
!item.editor_field,
)) {
if (
!hasConfirmedValue(
attributes,
'variant',
variant.id,
definition,
)
)
issues.push({
code: 'OZON_ATTRIBUTE_REQUIRED',
field: `variants.${variant.id}.attributes.${definition.platform_attribute_id}.${definition.complex_id}`,
message: `请填写${definition.name}`,
platform_attribute_id: definition.platform_attribute_id,
});
}
return {
variant_id: variant.id,
display_name: variant.display_name,
status: issues.length ? 'blocked' : 'ready',
ready: issues.length === 0,
issues,
};
}
/** 一次读取 SKU 的售价、有效包装规格和素材数量。 */
private async readVariants(
transaction: DatabaseTransaction,
profileId: string,
productId: string,
variantIds?: string[],
) {
const variants = await transaction.query<VariantValidationRow>(
`SELECT v.id, v.display_name, listing.title, listing.tags, listing.status AS listing_status,
listing.offer_id, listing.selling_price::text, listing.list_price::text, listing.pricing_valid,
coalesce(v.package_length_mm, product.default_package_length_mm)::text AS package_length_mm,
coalesce(v.package_width_mm, product.default_package_width_mm)::text AS package_width_mm,
coalesce(v.package_height_mm, product.default_package_height_mm)::text AS package_height_mm,
coalesce(v.shipping_weight_g, product.default_shipping_weight_g)::text AS shipping_weight_g,
coalesce(v.package_spec_status, product.package_spec_status) AS package_spec_status,
count(media.id)::text AS media_count
FROM atlas.product_variants v
JOIN atlas.products product ON product.id = v.product_id
JOIN atlas.product_variant_platform_listings listing
ON listing.product_variant_id = v.id AND listing.profile_id = $1
LEFT JOIN atlas.product_variant_listing_media media ON media.listing_id = listing.id
WHERE v.product_id = $2 AND v.deleted_at IS NULL
AND ($3::uuid[] IS NULL OR v.id = ANY($3::uuid[]))
GROUP BY v.id, v.display_name, listing.title, listing.tags, listing.status, listing.offer_id,
listing.selling_price, listing.list_price, listing.pricing_valid, product.default_package_length_mm,
product.default_package_width_mm, product.default_package_height_mm,
product.default_shipping_weight_g, product.package_spec_status
ORDER BY v.sort_order, v.id`,
[profileId, productId, variantIds ?? null],
);
return variants.rows;
}
/** 读取当前发布配置下已经持久化的动态属性值。 */
private async readAttributes(
transaction: DatabaseTransaction,
profileId: string,
variantIds?: string[],
) {
const attributes = await transaction.query<AttributeValidationRow>(
`SELECT scope, product_variant_id, platform_attribute_id, complex_id,
values, confirmation_status
FROM atlas.product_attributes
WHERE profile_id = $1
AND ($2::uuid[] IS NULL OR scope = 'common'
OR product_variant_id = ANY($2::uuid[]))`,
[profileId, variantIds ?? null],
);
return attributes.rows;
}
/** 发布校验只检查草稿实际引用的字典,并要求平台缓存仍在有效期内。 */
private async dictionaryIssues(
transaction: DatabaseTransaction,
platformId: string,
schema: ProductAttributeSchema[],
attributes: AttributeValidationRow[],
): Promise<ScopedValidationIssue[]> {
const definitions = new Map(
schema
.filter(
(item) =>
item.value_type === 'dictionary' &&
item.dictionary_id &&
!item.editor_field,
)
.map((item) => [
`${item.platform_attribute_id}\u0000${item.complex_id}`,
item,
]),
);
const used = attributes.flatMap((attribute) => {
if (attribute.confirmation_status !== 'confirmed') return [];
const values = stringValues(attribute.values).filter((value) =>
value.trim(),
);
const definition = definitions.get(
`${attribute.platform_attribute_id}\u0000${attribute.complex_id}`,
);
return definition && values.length
? [{ attribute, definition, values }]
: [];
});
const attributeIds = [
...new Set(used.map((item) => item.definition.attribute_id)),
];
if (!attributeIds.length) return [];
const states = await transaction.query<{
attribute_id: string;
usable: boolean;
}>(
`SELECT requested.attribute_id,
state.last_success_at IS NOT NULL
AND state.last_success_at >= now() -
make_interval(days => policy.dictionary_expire_days) AS usable
FROM unnest($2::uuid[]) requested(attribute_id)
JOIN atlas.platform_catalog_refresh_policies policy
ON policy.platform_id = $1
LEFT JOIN atlas.platform_catalog_resource_states state
ON state.platform_id = $1
AND state.scope_key = 'dictionary:' || requested.attribute_id::text`,
[platformId, attributeIds],
);
const usableByAttribute = new Map(
states.rows.map((item) => [item.attribute_id, item.usable]),
);
const values = await transaction.query<{
attribute_id: string;
value_id: string;
}>(
`SELECT attribute_id, external_value_id AS value_id
FROM atlas.platform_catalog_dictionary_values
WHERE attribute_id = ANY($1::uuid[]) AND is_active`,
[attributeIds],
);
const valueKeys = new Set(
values.rows.map(
(item) => `${item.attribute_id}\u0000${item.value_id}`,
),
);
const seen = new Set<string>();
const issues: ScopedValidationIssue[] = [];
for (const item of used) {
const field =
item.attribute.scope === 'variant'
? `variants.${item.attribute.product_variant_id}.attributes.${item.definition.platform_attribute_id}.${item.definition.complex_id}`
: `attributes.${item.definition.platform_attribute_id}.${item.definition.complex_id}`;
const issue = usableByAttribute.get(item.definition.attribute_id)
? item.values.every((value) =>
valueKeys.has(
`${item.definition.attribute_id}\u0000${value}`,
),
)
? null
: {
code: 'ATTRIBUTE_DICTIONARY_VALUE_INVALID',
field,
message: `${item.definition.name}包含已失效的 Ozon 候选值`,
platform_attribute_id:
item.definition.platform_attribute_id,
}
: {
code: 'ATTRIBUTE_DICTIONARY_REFRESH_PENDING',
field,
message: `${item.definition.name}的 Ozon 候选值需要更新`,
platform_attribute_id:
item.definition.platform_attribute_id,
};
if (!issue) continue;
const key = `${item.attribute.scope}\u0000${item.attribute.product_variant_id ?? ''}\u0000${issue.code}\u0000${field}`;
if (seen.has(key)) continue;
seen.add(key);
issues.push({
scope: item.attribute.scope,
variant_id: item.attribute.product_variant_id,
issue,
});
}
return issues;
}
}
/** 只有已确认且至少包含一个非空字符串的值才满足发布要求。 */
function hasConfirmedValue(
attributes: AttributeValidationRow[],
scope: 'common' | 'variant',
variantId: string | null,
definition: ProductAttributeSchema,
) {
return attributes.some(
(attribute) =>
attribute.scope === scope &&
attribute.product_variant_id === variantId &&
attribute.platform_attribute_id ===
definition.platform_attribute_id &&
attribute.complex_id === definition.complex_id &&
attribute.confirmation_status === 'confirmed' &&
stringValues(attribute.values).some((value) => value.trim()),
);
}
function stringValues(value: unknown): string[] {
return Array.isArray(value)
? value.filter((item): item is string => typeof item === 'string')
: [];
}
@@ -28,6 +28,12 @@ export class WatermarksController {
return this.watermarks.list(workspace.id, parse(listWatermarkTemplatesQuerySchema, query)); return this.watermarks.list(workspace.id, parse(listWatermarkTemplatesQuerySchema, query));
} }
@Get('watermark-templates/default')
@ApiOperation({ summary: '读取当前工作空间的默认水印模板' })
public async getDefault(@CurrentWorkspace() workspace: Workspace) {
return { data: await this.watermarks.getDefault(workspace.id) };
}
@Get('watermark-templates/:templateId') @Get('watermark-templates/:templateId')
@ApiOperation({ summary: '读取水印模板当前版本' }) @ApiOperation({ summary: '读取水印模板当前版本' })
public async get(@Param('templateId') templateId: string, @CurrentWorkspace() workspace: Workspace) { public async get(@Param('templateId') templateId: string, @CurrentWorkspace() workspace: Workspace) {
@@ -51,6 +57,15 @@ export class WatermarksController {
idempotencyKey(key), revision(ifMatch), parse(updateWatermarkTemplateRequestSchema, body))).body; idempotencyKey(key), revision(ifMatch), parse(updateWatermarkTemplateRequestSchema, body))).body;
} }
@Put('watermark-templates/:templateId/default')
@ApiOkResponse({ description: '已设为当前工作空间的默认水印' })
public async setDefault(@Param('templateId') templateId: string,
@Headers('idempotency-key') key: string | undefined,
@Req() request: AuthenticatedRequest, @CurrentWorkspace() workspace: Workspace) {
return (await this.watermarks.setDefault(workspace.id, request.auth.userId,
uuid('template_id', templateId), idempotencyKey(key))).body;
}
@Delete('watermark-templates/:templateId') @Delete('watermark-templates/:templateId')
@HttpCode(204) @HttpCode(204)
@ApiNoContentResponse({ description: '已软删除模板并解除当前店铺绑定' }) @ApiNoContentResponse({ description: '已软删除模板并解除当前店铺绑定' })
+172 -11
View File
@@ -1,4 +1,9 @@
import { Inject, Injectable } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import {
WATERMARK_BORDER_RADIUS_MAX_PX,
WATERMARK_IMAGE_SIZE_MAX_PX,
WATERMARK_IMAGE_SIZE_MIN_PX,
} from '@atlas/contracts';
import type { import type {
CreateWatermarkTemplateRequest, CreateWatermarkTemplateRequest,
ListWatermarkTemplatesQuery, ListWatermarkTemplatesQuery,
@@ -13,6 +18,7 @@ type TemplateRow = {
id: string; id: string;
name: string; name: string;
kind: 'text' | 'image'; kind: 'text' | 'image';
is_default: boolean;
current_version: number; current_version: number;
created_at: Date | string; created_at: Date | string;
updated_at: Date | string; updated_at: Date | string;
@@ -23,7 +29,11 @@ type TemplateRow = {
font_size_px: number | null; font_size_px: number | null;
opacity: string; opacity: string;
scale_ratio: string | null; scale_ratio: string | null;
image_size_px: number | null;
border_radius_px: number;
position: string; position: string;
position_x: string;
position_y: string;
}; };
type TemplateConfig = Record<string, unknown>; type TemplateConfig = Record<string, unknown>;
@@ -81,6 +91,59 @@ export class WatermarksService {
); );
} }
public async getDefault(workspaceId: string) {
return this.database.workspaceTransaction(workspaceId, async (tx) => {
const rows = await tx.query<TemplateRow>(
`${templateSelect} WHERE template.is_default = true
AND template.status = 'active' AND template.deleted_at IS NULL`,
);
return rows.rows[0] ? toTemplate(rows.rows[0]) : null;
});
}
public async setDefault(
workspaceId: string,
userId: string,
templateId: string,
idempotencyKey: string,
) {
return this.idempotency.run(
{
workspaceId,
userId,
method: 'PUT',
path: `/api/watermark-templates/${templateId}/default`,
key: idempotencyKey,
request: {},
},
async (tx) => {
// 同一工作空间的切换操作串行化,避免两个请求同时争用唯一默认索引。
await tx.query(
`SELECT id FROM atlas.workspaces
WHERE id = current_setting('app.workspace_id')::uuid FOR UPDATE`,
);
const current = await this.lockTemplate(tx, templateId);
if (!current.is_default) {
await tx.query(
`UPDATE atlas.watermark_templates
SET is_default = false
WHERE is_default = true AND status = 'active' AND deleted_at IS NULL`,
);
await tx.query(
`UPDATE atlas.watermark_templates
SET is_default = true
WHERE id = $1`,
[templateId],
);
}
return {
status: 200,
body: { data: await this.requireTemplate(tx, templateId) },
};
},
);
}
public async create( public async create(
workspaceId: string, workspaceId: string,
userId: string, userId: string,
@@ -97,7 +160,8 @@ export class WatermarksService {
request: input, request: input,
}, },
async (tx) => { async (tx) => {
await this.validateConfig(tx, input.kind, input.config); const config = normalizeImageConfig(input.kind, normalizePlacement(input.config), input.config);
await this.validateConfig(tx, input.kind, config);
const inserted = await tx.query<{ id: string }>( const inserted = await tx.query<{ id: string }>(
`INSERT INTO atlas.watermark_templates (workspace_id, name, kind) `INSERT INTO atlas.watermark_templates (workspace_id, name, kind)
VALUES (current_setting('app.workspace_id')::uuid, $1, $2) RETURNING id`, VALUES (current_setting('app.workspace_id')::uuid, $1, $2) RETURNING id`,
@@ -108,7 +172,7 @@ export class WatermarksService {
inserted.rows[0].id, inserted.rows[0].id,
1, 1,
input.kind, input.kind,
input.config, config,
userId, userId,
); );
return { return {
@@ -158,10 +222,10 @@ export class WatermarksService {
); );
} }
// 同类型编辑允许只提交变化字段;切换类型时旧类型字段不会通过新类型校验。 // 同类型编辑允许只提交变化字段;切换类型时旧类型字段不会通过新类型校验。
const nextConfig = normalizeConfig(nextKind, { const nextConfig = normalizeConfig(nextKind, normalizeImageConfig(nextKind, normalizePlacement({
...configFromRow(current), ...configFromRow(current),
...(input.config ?? {}), ...(input.config ?? {}),
}); }, input.config), input.config));
await this.validateConfig(tx, nextKind, nextConfig); await this.validateConfig(tx, nextKind, nextConfig);
const renderingChanged = const renderingChanged =
input.config !== undefined || nextKind !== current.kind; input.config !== undefined || nextKind !== current.kind;
@@ -217,7 +281,7 @@ export class WatermarksService {
[templateId], [templateId],
); );
await tx.query( await tx.query(
`UPDATE atlas.watermark_templates SET status = 'archived', deleted_at = now(), `UPDATE atlas.watermark_templates SET status = 'archived', deleted_at = now(), is_default = false,
revision = revision + 1, updated_at = now() WHERE id = $1`, revision = revision + 1, updated_at = now() WHERE id = $1`,
[templateId], [templateId],
); );
@@ -312,7 +376,9 @@ export class WatermarksService {
) { ) {
if ( if (
typeof config.opacity !== 'string' || typeof config.opacity !== 'string' ||
typeof config.position !== 'string' typeof config.position !== 'string' ||
!validCoordinate(config.position_x) ||
!validCoordinate(config.position_y)
) { ) {
throw new ApiError(422, 'VALIDATION_FAILED', '水印配置不完整'); throw new ApiError(422, 'VALIDATION_FAILED', '水印配置不完整');
} }
@@ -344,7 +410,9 @@ export class WatermarksService {
const assetId = config.image_asset_id; const assetId = config.image_asset_id;
if ( if (
typeof assetId !== 'string' || typeof assetId !== 'string' ||
typeof config.scale_ratio !== 'string' typeof config.scale_ratio !== 'string' ||
!validImageSize(config.image_size_px) ||
!validBorderRadius(config.border_radius_px)
) { ) {
throw new ApiError(422, 'VALIDATION_FAILED', '图片水印配置不完整'); throw new ApiError(422, 'VALIDATION_FAILED', '图片水印配置不完整');
} }
@@ -371,8 +439,8 @@ export class WatermarksService {
) { ) {
await tx.query( await tx.query(
`INSERT INTO atlas.watermark_template_versions `INSERT INTO atlas.watermark_template_versions
(workspace_id, template_id, version, kind, text_content, image_asset_id, font_code, font_size_px, opacity, scale_ratio, position, created_by_user_id) (workspace_id, template_id, version, kind, text_content, image_asset_id, font_code, font_size_px, opacity, scale_ratio, position_x, position_y, position, created_by_user_id, image_size_px, border_radius_px)
VALUES (current_setting('app.workspace_id')::uuid, $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)`, VALUES (current_setting('app.workspace_id')::uuid, $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15)`,
[ [
templateId, templateId,
version, version,
@@ -383,17 +451,23 @@ export class WatermarksService {
config.font_size_px ?? null, config.font_size_px ?? null,
config.opacity, config.opacity,
config.scale_ratio ?? null, config.scale_ratio ?? null,
config.position_x,
config.position_y,
config.position, config.position,
userId, userId,
config.image_size_px ?? null,
config.border_radius_px ?? 0,
], ],
); );
} }
} }
const templateSelect = `SELECT template.id, template.name, template.kind, template.current_version, const templateSelect = `SELECT template.id, template.name, template.kind, template.is_default, template.current_version,
template.created_at, template.updated_at, template.revision, version.text_content, version.image_asset_id, template.created_at, template.updated_at, template.revision, version.text_content, version.image_asset_id,
version.font_code, version.font_size_px, version.opacity::text AS opacity, version.scale_ratio::text AS scale_ratio, version.font_code, version.font_size_px, version.opacity::text AS opacity, version.scale_ratio::text AS scale_ratio,
version.position FROM atlas.watermark_templates template JOIN atlas.watermark_template_versions version version.image_size_px, version.border_radius_px,
version.position, version.position_x::text AS position_x, version.position_y::text AS position_y
FROM atlas.watermark_templates template JOIN atlas.watermark_template_versions version
ON version.template_id = template.id AND version.version = template.current_version`; ON version.template_id = template.id AND version.version = template.current_version`;
function toTemplate(row: TemplateRow) { function toTemplate(row: TemplateRow) {
@@ -401,6 +475,7 @@ function toTemplate(row: TemplateRow) {
id: row.id, id: row.id,
name: row.name, name: row.name,
kind: row.kind, kind: row.kind,
is_default: row.is_default,
current_version: row.current_version, current_version: row.current_version,
config: configFromRow(row), config: configFromRow(row),
created_at: iso(row.created_at), created_at: iso(row.created_at),
@@ -416,12 +491,18 @@ function configFromRow(row: TemplateRow): TemplateConfig {
font_size_px: row.font_size_px, font_size_px: row.font_size_px,
opacity: row.opacity, opacity: row.opacity,
position: row.position, position: row.position,
position_x: Number(row.position_x),
position_y: Number(row.position_y),
} }
: { : {
image_asset_id: row.image_asset_id, image_asset_id: row.image_asset_id,
scale_ratio: row.scale_ratio, scale_ratio: row.scale_ratio,
image_size_px: row.image_size_px,
border_radius_px: row.border_radius_px,
opacity: row.opacity, opacity: row.opacity,
position: row.position, position: row.position,
position_x: Number(row.position_x),
position_y: Number(row.position_y),
}; };
} }
function normalizeConfig( function normalizeConfig(
@@ -435,14 +516,94 @@ function normalizeConfig(
font_size_px: config.font_size_px, font_size_px: config.font_size_px,
opacity: config.opacity, opacity: config.opacity,
position: config.position, position: config.position,
position_x: config.position_x,
position_y: config.position_y,
} }
: { : {
image_asset_id: config.image_asset_id, image_asset_id: config.image_asset_id,
scale_ratio: config.scale_ratio, scale_ratio: config.scale_ratio,
image_size_px: config.image_size_px,
border_radius_px: config.border_radius_px,
opacity: config.opacity, opacity: config.opacity,
position: config.position, position: config.position,
position_x: config.position_x,
position_y: config.position_y,
}; };
} }
const legacyPositions = [
'top_left', 'top_center', 'top_right',
'middle_left', 'center', 'middle_right',
'bottom_left', 'bottom_center', 'bottom_right',
] as const;
function validCoordinate(value: unknown): value is number {
return typeof value === 'number' && Number.isFinite(value) && value >= 0 && value <= 1;
}
function validImageSize(value: unknown): value is number {
return Number.isInteger(value) && Number(value) >= WATERMARK_IMAGE_SIZE_MIN_PX
&& Number(value) <= WATERMARK_IMAGE_SIZE_MAX_PX;
}
function validBorderRadius(value: unknown): value is number {
return Number.isInteger(value) && Number(value) >= 0
&& Number(value) <= WATERMARK_BORDER_RADIUS_MAX_PX;
}
function legacyImageSize(value: unknown): number | undefined {
const ratio = Number(value);
if (!Number.isFinite(ratio)) return undefined;
return Math.min(WATERMARK_IMAGE_SIZE_MAX_PX,
Math.max(WATERMARK_IMAGE_SIZE_MIN_PX, Math.round(ratio * 144)));
}
function normalizeImageConfig(
kind: 'text' | 'image', config: TemplateConfig, patch?: Record<string, unknown>,
): TemplateConfig {
if (kind !== 'image') return config;
const legacyOnly = patch?.scale_ratio !== undefined && patch.image_size_px === undefined;
const size = legacyOnly
? legacyImageSize(patch?.scale_ratio)
: config.image_size_px ?? legacyImageSize(config.scale_ratio);
const scaleRatio = legacyOnly || !validImageSize(size)
? config.scale_ratio
: String(Number(Math.max(0.1, size / 144).toFixed(4)));
return {
...config,
image_size_px: size,
border_radius_px: config.border_radius_px ?? 0,
// 旧比例字段只供旧客户端读取;像素尺寸决定新预览与后续渲染。
scale_ratio: scaleRatio,
};
}
function legacyCoordinates(position: unknown) {
const index = legacyPositions.indexOf(position as typeof legacyPositions[number]);
if (index < 0) return null;
return { x: (index % 3) / 2, y: Math.floor(index / 3) / 2 };
}
function normalizePlacement(config: TemplateConfig, patch?: Record<string, unknown>): TemplateConfig {
const legacyOnly = patch?.position !== undefined
&& patch.position_x === undefined && patch.position_y === undefined;
const legacy = legacyCoordinates(config.position);
const x = legacyOnly ? legacy?.x : config.position_x ?? legacy?.x;
const y = legacyOnly ? legacy?.y : config.position_y ?? legacy?.y;
if (!validCoordinate(x) || !validCoordinate(y)) return { ...config, position_x: x, position_y: y };
const positionX = Math.round(x * 10_000) / 10_000;
const positionY = Math.round(y * 10_000) / 10_000;
const col = Math.min(2, Math.floor(positionX * 3));
const row = Math.min(2, Math.floor(positionY * 3));
return {
...config,
position_x: positionX,
position_y: positionY,
// 旧字段仅供兼容展示;精确坐标始终决定实际水印位置。
position: legacyPositions[row * 3 + col],
};
}
function iso(value: Date | string) { function iso(value: Date | string) {
return value instanceof Date ? value.toISOString() : value; return value instanceof Date ? value.toISOString() : value;
} }
+466
View File
@@ -0,0 +1,466 @@
import { beforeEach, afterEach, describe, it, expect, vi } from 'vitest';
import { randomUUID, randomInt } from 'node:crypto';
import { loadEnvFile } from 'node:process';
import { Client } from 'pg';
import type { Database, DatabaseTransaction } from '@atlas/database';
import type {
CommissionImportRequest,
CommissionImportPublish,
} from '@atlas/contracts';
import { CommissionImportService } from '../src/pricing/commission-import.service.js';
import { parseCommissionDocument } from '../src/pricing/commission-import-parser.js';
import { IdempotencyService } from '../src/common/idempotency.service.js';
import { PricingService } from '../src/pricing/pricing.service.js';
import { fingerprint } from '../src/pricing/pricing-engine.js';
import type { AppConfig } from '../src/config/app-config.module.js';
loadEnvFile(new URL('../../../.env', import.meta.url));
const makeDocument = (
parent = '900000001',
child = '900000002',
rates = ['12.00', '14.00', '20.50'],
) => ({
code: 1,
msg: '获取成功',
data: [
{
id: 1,
label: '测试分组',
value: '测试分组',
cate_id: parent,
children: [
{
id: '1.1',
label: '测试类目',
value: '测试类目',
cate_id: child,
children: rates.map((rate, i) => ({
id: `1.1.${i + 1}`,
label: `${['售价 ≤ 1500₽', '1500₽ < 售价 ≤ 5000₽', '售价 > 5000₽'][i]} (${rate}%)`,
value: `${i + 1},${rate}`,
})),
},
],
},
],
});
describe('佣金来源解析', () => {
it('保留三个精确区间并将百分数转换为比例', () => {
const c = parseCommissionDocument(makeDocument())[0];
expect(c.bands.map((b) => b.commission_rate)).toEqual([
'0.120000',
'0.140000',
'0.205000',
]);
expect(
c.bands.map((b) => [
b.min_price,
b.max_price,
b.min_inclusive,
b.max_inclusive,
]),
).toEqual([
['0', '1500', false, true],
['1500', '5000', false, true],
['5000', null, false, false],
]);
});
it('拒绝标签和值不一致、漏档、重复类目及超大文件', () => {
const d = makeDocument();
d.data[0].children[0].children[0].value = '1,15.00';
expect(() => parseCommissionDocument(d)).toThrow();
expect(() => parseCommissionDocument(makeDocument('1', '1'))).toThrow();
const missing = makeDocument();
missing.data[0].children[0].children.pop();
expect(() => parseCommissionDocument(missing)).toThrow();
expect(() =>
parseCommissionDocument({ x: 'a'.repeat(2 * 1024 * 1024) }),
).toThrow();
});
});
// 所有业务数据在测试事务内,结束一律回滚;不会发布或替换用户的实际佣金版本。
describe('佣金导入 PostgreSQL 集成', () => {
let client: Client,
db: Database,
service: CommissionImportService,
pricing: PricingService;
let user: string,
workspace: string,
platform: string,
store: string,
leaf: string,
oldId: string,
oldRule: string;
let request: CommissionImportRequest;
let savepoint = 0;
beforeEach(async () => {
const connection = new URL(process.env.MIGRATOR_DATABASE_URL!);
connection.username = process.env.POSTGRES_ADMIN_USER!;
connection.password = process.env.POSTGRES_ADMIN_PASSWORD!;
client = new Client({ connectionString: connection.toString() });
await client.connect();
await client.query('BEGIN');
user = randomUUID();
workspace = randomUUID();
await client.query(
`INSERT INTO atlas.users(id,email,password_hash) VALUES($1,$2,'test-only')`,
[user, `${user}@example.test`],
);
await client.query(
`INSERT INTO atlas.workspaces(id,name,slug) VALUES($1,'佣金回滚测试',$2)`,
[workspace, workspace],
);
await client.query(
`INSERT INTO atlas.workspace_memberships(workspace_id,user_id,role) VALUES($1,$2,'owner')`,
[workspace, user],
);
await client.query(
`INSERT INTO atlas.system_admin_grants(user_id) VALUES($1)`,
[user],
);
await client.query('SET LOCAL ROLE atlas_app');
await client.query(`SELECT set_config('app.workspace_id',$1,true)`, [
workspace,
]);
platform = (
await client.query(
`SELECT id FROM atlas.platforms WHERE upper(code::text)='OZON'`,
)
).rows[0].id;
store = (
await client.query(
`INSERT INTO atlas.stores(workspace_id,platform_id,name,platform_client_id,currency_code,status,credential_status) VALUES($1,$2,'佣金测试',$3,'RUB','active','valid') RETURNING id`,
[workspace, platform, randomUUID()],
)
).rows[0].id;
const p = String(randomInt(100000000, 999999990)),
c = String(Number(p) + 1);
const parent = (
await client.query(
`INSERT INTO atlas.platform_product_categories(platform_id,external_category_id,external_type_id,name,path,is_active) VALUES($1,$2,'0','测试分组','测试分组',false) RETURNING id`,
[platform, p],
)
).rows[0].id;
const cat = (
await client.query(
`INSERT INTO atlas.platform_product_categories(platform_id,parent_id,external_category_id,external_type_id,name,path,is_active) VALUES($1,$2,$3,'0','测试类目','测试分组 / 测试类目',false) RETURNING id`,
[platform, parent, c],
)
).rows[0].id;
leaf = (
await client.query(
`INSERT INTO atlas.platform_product_categories(platform_id,parent_id,external_category_id,external_type_id,name,path) VALUES($1,$2,$3,'991','测试类型','测试类型') RETURNING id`,
[platform, cat, c],
)
).rows[0].id;
request = {
platform_id: platform,
version: `qa-${randomUUID()}`,
source_reference: '自动化测试,事务回滚',
captured_on: '2026-09-28',
document: makeDocument(p, c),
};
const oldCategory = parseCommissionDocument(
makeDocument(p, c, ['10.00', '11.00', '12.00']),
)[0];
oldCategory.mapped_types = 1;
await client.query(
`UPDATE atlas.commission_imports SET is_current=false WHERE platform_id=$1 AND is_current`,
[platform],
);
oldId = (
await client.query(
`INSERT INTO atlas.commission_imports(platform_id,version,source_reference,captured_on,document,content_hash,categories,summary,activated_by,activated_at) VALUES($1,$2,'旧测试版本','2026-09-27','{}',$3,$4,'{}',$5,now()-interval '1 day') RETURNING id`,
[
platform,
`old-${randomUUID()}`,
fingerprint(randomUUID()),
JSON.stringify([oldCategory]),
user,
],
)
).rows[0].id;
const cc = (
await client.query(
`INSERT INTO atlas.commission_categories(platform_id,source_category_key,name,path,source,status) VALUES($1,$2,'测试类目',$3,'competitor_capture','active') RETURNING id`,
[platform, `maozierp:${c}`, `test-${c}`],
)
).rows[0].id;
oldRule = (
await client.query(
`INSERT INTO atlas.commission_category_rules(commission_category_id,fulfillment,market_code,currency_code,commission_rate,source,version,status,effective_from,import_id) VALUES($1,'rfbs','RU','RUB',0.1,'competitor_capture','old','active',now()-interval '1 day',$2) RETURNING id`,
[cc, oldId],
)
).rows[0].id;
await client.query(
`INSERT INTO atlas.platform_product_category_commission_mappings(platform_id,platform_product_category_id,commission_category_id,fulfillment,source,status,effective_from,import_id) VALUES($1,$2,$3,'rfbs','competitor_capture','active',now()-interval '1 day',$4)`,
[platform, leaf, cc, oldId],
);
const transaction = async <T>(
work: (tx: DatabaseTransaction) => Promise<T>,
) => {
const sp = `operation_${++savepoint}`;
await client.query(`SAVEPOINT ${sp}`);
try {
const result = await work(client);
await client.query(`RELEASE SAVEPOINT ${sp}`);
return result;
} catch (e) {
await client.query(`ROLLBACK TO SAVEPOINT ${sp}`);
throw e;
}
};
db = {
transaction,
query: (sql: string, args: unknown[]) =>
transaction((tx) => tx.query(sql, args)),
workspaceTransaction: (
id: string,
work: (tx: DatabaseTransaction) => Promise<unknown>,
) =>
transaction(async (tx) => {
await tx.query(
`SELECT set_config('app.workspace_id',$1,true)`,
[id],
);
return work(tx);
}),
} as unknown as Database;
const config = {
IDEMPOTENCY_KEY_SECRET: 'commission-test-idempotency-secret',
pricingAllowDraftRates: false,
} as AppConfig;
const idem = new IdempotencyService(db, config);
service = new CommissionImportService(db, idem);
pricing = new PricingService(db, idem, config);
});
afterEach(async () => {
if (client) {
await client.query('ROLLBACK');
await client.end();
}
});
const publishInput = async (
s: CommissionImportService,
r: CommissionImportRequest,
): Promise<CommissionImportPublish> => ({
...r,
preview_hash: (await s.preview(r)).preview_hash,
confirm_scope: true,
});
it('预览差异,启用三档规则,按内部及外部类目查询;旧规则保留', async () => {
const p = await service.preview(request);
expect(p.summary.eligible).toBe(1);
expect(p.categories[0].change).toBe('changed');
const r = await service.publish(
workspace,
user,
randomUUID(),
await publishInput(service, request),
);
expect(r.status).toBe(201);
const rules = await pricing.commissionRules(workspace, store, leaf);
expect(rules.bands.map((b) => b.commission_rate)).toEqual([
'0.120000',
'0.140000',
'0.205000',
]);
expect(
(
await pricing.commissionRules(
workspace,
store,
undefined,
p.categories[0].source_category_id,
)
).bands,
).toEqual(rules.bands);
expect(
(
await client.query(
`SELECT status,commission_rate FROM atlas.commission_category_rules WHERE id=$1`,
[oldRule],
)
).rows[0],
).toEqual({ status: 'archived', commission_rate: '0.100000' });
expect((await service.preview(request)).preview_hash).not.toBe(
p.preview_hash,
);
});
it('相同幂等请求只发布一次,旧预览不能再启用', async () => {
const input = await publishInput(service, request),
key = randomUUID();
const a = await service.publish(workspace, user, key, input);
expect(await service.publish(workspace, user, key, input)).toEqual(
JSON.parse(JSON.stringify(a)),
);
await expect(
service.publish(workspace, user, randomUUID(), input),
).rejects.toMatchObject({
response: { error: { code: 'COMMISSION_PREVIEW_STALE' } },
});
const fresh = await publishInput(service, request);
await expect(
service.publish(workspace, user, randomUUID(), fresh),
).rejects.toMatchObject({
response: { error: { code: 'COMMISSION_IMPORT_EXISTS' } },
});
});
it('0% 佣金可以启用并用于类目查询', async () => {
const d = request.document as ReturnType<typeof makeDocument>;
d.data[0].children[0].children = makeDocument('1', '2', [
'0.00',
'0.00',
'0.00',
]).data[0].children[0].children;
const p = await service.preview(request);
expect(p.summary).toMatchObject({
eligible: 1,
excluded: 0,
mapped_types: 1,
});
await service.publish(
workspace,
user,
randomUUID(),
await publishInput(service, request),
);
expect(
(await pricing.commissionRules(workspace, store, leaf)).bands.map(
(b) => b.commission_rate,
),
).toEqual(['0.000000', '0.000000', '0.000000']);
});
it('错误父类目被提示;无可映射规则时拒绝空版本', async () => {
const d = request.document as ReturnType<typeof makeDocument>;
d.data[0].cate_id = '123';
expect(
(await service.preview(request)).categories[0].issues.join(),
).toContain('父类目');
await expect(
service.publish(
workspace,
user,
randomUUID(),
await publishInput(service, request),
),
).rejects.toMatchObject({
response: { error: { code: 'COMMISSION_IMPORT_INVALID' } },
});
});
it('目录变化后必须重新预览', async () => {
const input = await publishInput(service, request);
await client.query(
`UPDATE atlas.platform_product_categories SET is_active=false WHERE id=$1`,
[leaf],
);
await expect(
service.publish(workspace, user, randomUUID(), input),
).rejects.toMatchObject({
response: { error: { code: 'COMMISSION_PREVIEW_STALE' } },
});
});
it('撤销管理员授权阻止发布,其他工作空间不能读取门店规则', async () => {
const input = await publishInput(service, request);
await client.query('RESET ROLE');
await client.query(
`UPDATE atlas.system_admin_grants SET revoked_at=now() WHERE user_id=$1`,
[user],
);
await client.query('SET LOCAL ROLE atlas_app');
await expect(
service.publish(workspace, user, randomUUID(), input),
).rejects.toMatchObject({
response: { error: { code: 'SYSTEM_ADMIN_REQUIRED' } },
});
await expect(
pricing.commissionRules(randomUUID(), store, leaf),
).rejects.toMatchObject({
response: { error: { code: 'STORE_NOT_FOUND' } },
});
});
it('有目录异常时无需额外确认,仍可启用其他可用类目', async () => {
const d = request.document as ReturnType<typeof makeDocument>;
d.data[0].children.push(
makeDocument('1', '899999999').data[0].children[0],
);
const preview = await service.preview(request);
expect(preview.summary).toMatchObject({ eligible: 1, excluded: 1 });
const input = await publishInput(service, request);
await service.publish(workspace, user, randomUUID(), input);
expect(
(await pricing.commissionRules(workspace, store, leaf)).bands,
).toHaveLength(3);
expect(
(
await service.detail((await service.list(platform))[0].id)
).categories.filter((c) => c.status === 'excluded'),
).toHaveLength(1);
});
it('后续写入失败时旧版本保持有效', async () => {
const input = await publishInput(service, request);
const original = client.query.bind(client);
const spy = vi.spyOn(client, 'query').mockImplementation(((
sql: string,
args: unknown[],
) => {
if (
typeof sql === 'string' &&
sql.includes(
'INSERT INTO atlas.platform_product_category_commission_mappings',
)
)
throw new Error('模拟映射写入失败');
return original(sql, args);
}) as typeof client.query);
try {
await expect(
service.publish(workspace, user, randomUUID(), input),
).rejects.toThrow('模拟映射写入失败');
} finally {
spy.mockRestore();
}
expect(
(
await client.query(
'SELECT is_current FROM atlas.commission_imports WHERE id=$1',
[oldId],
)
).rows[0].is_current,
).toBe(true);
expect(
(
await client.query(
'SELECT status FROM atlas.commission_category_rules WHERE id=$1',
[oldRule],
)
).rows[0].status,
).toBe('active');
expect(
(
await client.query(
'SELECT id FROM atlas.commission_imports WHERE version=$1',
[request.version],
)
).rowCount,
).toBe(0);
});
it('数据库拒绝直接改写已启用的历史费率', async () => {
await expect(
db.transaction((tx) =>
tx.query(
`UPDATE atlas.commission_category_rules SET commission_rate=0.9 WHERE id=$1`,
[oldRule],
),
),
).rejects.toMatchObject({ code: '23514' });
expect(
(
await client.query(
`SELECT commission_rate FROM atlas.commission_category_rules WHERE id=$1`,
[oldRule],
)
).rows[0].commission_rate,
).toBe('0.100000');
});
});
+220 -5
View File
@@ -6,12 +6,14 @@ import type { NestExpressApplication } from '@nestjs/platform-express';
import { afterAll, beforeAll, describe, it } from 'vitest'; import { afterAll, beforeAll, describe, it } from 'vitest';
import { DATABASE } from '../src/database/database.module.js'; import { DATABASE } from '../src/database/database.module.js';
import { createApplication } from '../src/main.js'; import { createApplication } from '../src/main.js';
import { PRODUCT_VARIANT_LIMIT } from '@atlas/contracts';
let app: NestExpressApplication; let app: NestExpressApplication;
let database: Database; let database: Database;
let baseUrl = ''; let baseUrl = '';
let workspaceId = ''; let workspaceId = '';
let userId = ''; let userId = '';
let webAccessToken = '';
async function request(path: string, init: RequestInit = {}) { async function request(path: string, init: RequestInit = {}) {
const response = await fetch(`${baseUrl}${path}`, { const response = await fetch(`${baseUrl}${path}`, {
redirect: 'manual', redirect: 'manual',
@@ -124,7 +126,7 @@ describe('扩展授权与采集 HTTP 集成测试', () => {
assert.equal(registration.response.status, 201); assert.equal(registration.response.status, 201);
userId = registration.body.data.user.id; userId = registration.body.data.user.id;
workspaceId = registration.body.data.workspace.id; workspaceId = registration.body.data.workspace.id;
const webAccessToken = registration.body.data.access_token as string; webAccessToken = registration.body.data.access_token as string;
const session = await request('/api/extension/session', { const session = await request('/api/extension/session', {
headers: { Authorization: `Bearer ${webAccessToken}` }, headers: { Authorization: `Bearer ${webAccessToken}` },
}); });
@@ -166,11 +168,12 @@ describe('扩展授权与采集 HTTP 集成测试', () => {
], ],
evidence: [{ ref: 'page', kind: 'page', path: 'test' }], evidence: [{ ref: 'page', kind: 'page', path: 'test' }],
}; };
const firstKey = randomUUID();
const first = await request('/api/extension/materials', { const first = await request('/api/extension/materials', {
method: 'POST', method: 'POST',
headers: { headers: {
Authorization: `Bearer ${webAccessToken}`, Authorization: `Bearer ${webAccessToken}`,
'Idempotency-Key': randomUUID(), 'Idempotency-Key': firstKey,
}, },
body: JSON.stringify(payload), body: JSON.stringify(payload),
}); });
@@ -179,6 +182,85 @@ describe('扩展授权与采集 HTTP 集成测试', () => {
assert.equal(first.body.data.collection_item.status, 'ready_for_edit'); assert.equal(first.body.data.collection_item.status, 'ready_for_edit');
assert.equal(first.body.data.materialization.pending_selection, 1); assert.equal(first.body.data.materialization.pending_selection, 1);
assert.equal(first.body.data.materialization.queued, 0); assert.equal(first.body.data.materialization.queued, 0);
const editorEntry = new URL(first.body.data.editor_entry_path, 'http://atlas.example.test');
assert.equal(editorEntry.pathname, `/products/${first.body.data.product_id}/editor`);
assert.equal(editorEntry.searchParams.get('collection_item_id'), first.body.data.collection_item.id);
const replay = await request('/api/extension/materials', {
method: 'POST',
headers: {
Authorization: `Bearer ${webAccessToken}`,
'Idempotency-Key': firstKey,
},
body: JSON.stringify(payload),
});
assert.deepEqual(replay.body, first.body);
const profilesBeforeEntry = await database.workspaceTransaction(workspaceId, (tx) =>
tx.query('SELECT id FROM atlas.product_platform_profiles WHERE product_id=$1', [first.body.data.product_id]),
);
assert.equal(profilesBeforeEntry.rowCount, 0);
const source1688 = {
source: {
platform: '1688', itemKey: '1688-price-image-test',
canonicalUrl: 'https://detail.1688.com/offer/123456789.html',
collectedAt: new Date().toISOString(), parserVersion: '1688-test',
},
common: { attributes: [], categories: [] },
variantAxes: [],
variants: [{
sourceVariantKey: '6100454176961', sourceSku: '6100454176961',
displayName: '六代目卡卡西25cm', selections: [],
sourcePrice: { amount: '31.335', currencyCode: 'CNY' },
confidence: 'verified', evidenceRefs: ['1688:sku-map'],
}],
assets: [{
url: 'https://cbu01.alicdn.com/sku.jpg', role: 'source_sku',
relation: 'axis_value', variantSourceKey: '6100454176961',
sortOrder: 0, evidenceRef: '1688:sku-map',
}],
evidence: [{ ref: '1688:sku-map', kind: 'state', path: 'skuModel.skuInfoMap' }],
};
const captured1688 = await request('/api/extension/materials', {
method: 'POST',
headers: {
Authorization: `Bearer ${webAccessToken}`,
'Idempotency-Key': randomUUID(),
},
body: JSON.stringify(source1688),
});
assert.equal(captured1688.response.status, 201);
const associated1688 = await database.workspaceTransaction(workspaceId, async (tx) =>
tx.query<{ purchase_cost_cny: string; source_variant_id: string; variant_id: string }>(
`SELECT v.purchase_cost_cny::text, t.source_variant_id, v.id AS variant_id
FROM atlas.product_variants v
JOIN atlas.collection_asset_transfers t ON t.product_id = v.product_id
WHERE v.product_id = $1 AND t.role = 'source_sku'`,
[captured1688.body.data.product_id],
),
);
assert.equal(associated1688.rows[0]?.purchase_cost_cny, '31.3400');
assert.equal(associated1688.rows[0]?.source_variant_id, associated1688.rows[0]?.variant_id);
const refreshed1688 = await request('/api/extension/materials', {
method: 'POST',
headers: {
Authorization: `Bearer ${webAccessToken}`,
'Idempotency-Key': randomUUID(),
},
body: JSON.stringify({
...source1688,
variants: [{
...source1688.variants[0],
sourcePrice: { amount: '32.525', currencyCode: 'CNY' },
}],
}),
});
assert.equal(refreshed1688.response.status, 200);
const updatedCost = await database.workspaceTransaction(workspaceId, async (tx) =>
tx.query<{ purchase_cost_cny: string }>(
`SELECT purchase_cost_cny::text FROM atlas.product_variants WHERE product_id = $1`,
[captured1688.body.data.product_id],
),
);
assert.equal(updatedCost.rows[0]?.purchase_cost_cny, '32.5300');
const platforms = await request('/api/platforms', { const platforms = await request('/api/platforms', {
headers: { Authorization: `Bearer ${webAccessToken}` }, headers: { Authorization: `Bearer ${webAccessToken}` },
}); });
@@ -197,7 +279,7 @@ describe('扩展授权与采集 HTTP 集成测试', () => {
); );
}); });
const editorContext = await request( const editorContext = await request(
`/api/collection-items/${first.body.data.collection_item.id}/editor-contexts`, `/api/collection-items/${editorEntry.searchParams.get('collection_item_id')}/editor-contexts`,
{ {
method: 'POST', method: 'POST',
headers: { headers: {
@@ -208,6 +290,39 @@ describe('扩展授权与采集 HTTP 集成测试', () => {
}, },
); );
assert.equal(editorContext.response.status, 201); assert.equal(editorContext.response.status, 201);
assert.equal(editorContext.body.data.product_id, first.body.data.product_id);
assert.equal(editorContext.body.data.editor_path, editorEntry.pathname);
const readEditor = (productId: string) => request(
`/api/products/${productId}/editor`,
{ headers: {
Authorization: `Bearer ${webAccessToken}`,
'X-Atlas-Platform-Id': targetPlatform.id,
} },
);
const initialEditor = await readEditor(first.body.data.product_id);
assert.equal(initialEditor.response.status, 200);
assert.equal(initialEditor.body.data.product.common.purchase_url, payload.source.canonicalUrl);
const sourceLink = await database.workspaceTransaction(workspaceId, (tx) => tx.query(
'SELECT purchase_url FROM atlas.products WHERE id=$1',
[captured1688.body.data.product_id],
));
assert.equal(sourceLink.rows[0].purchase_url, source1688.source.canonicalUrl);
await request(`/api/collection-items/${captured1688.body.data.collection_item.id}/editor-contexts`, {
method: 'POST',
headers: {
Authorization: `Bearer ${webAccessToken}`,
'X-Atlas-Platform-Id': targetPlatform.id,
'Idempotency-Key': randomUUID(),
},
});
// 模拟历史采集商品:原来只有采集记录保存了页面地址,商品货源字段为空。
await database.workspaceTransaction(workspaceId, (tx) => tx.query(
'UPDATE atlas.products SET purchase_url=NULL WHERE id=$1',
[captured1688.body.data.product_id],
));
const historicalEditor = await readEditor(captured1688.body.data.product_id);
assert.equal(historicalEditor.response.status, 200);
assert.equal(historicalEditor.body.data.product.common.purchase_url, source1688.source.canonicalUrl);
// Simulate real draft work before the source page is collected again. // Simulate real draft work before the source page is collected again.
// Re-collection must neither delete this SKU nor overwrite its Atlas // Re-collection must neither delete this SKU nor overwrite its Atlas
// fields / platform mapping just because a newer source snapshot arrives. // fields / platform mapping just because a newer source snapshot arrives.
@@ -218,7 +333,7 @@ describe('扩展授权与采集 HTTP 集成测试', () => {
[first.body.data.product_id], [first.body.data.product_id],
); );
await tx.query( await tx.query(
`UPDATE atlas.products SET source_title = '人工确认商品名' `UPDATE atlas.products SET source_title = '人工确认商品名', purchase_url = 'https://detail.1688.com/offer/888888888.html'
WHERE id = $1`, WHERE id = $1`,
[first.body.data.product_id], [first.body.data.product_id],
); );
@@ -303,6 +418,8 @@ describe('扩展授权与采集 HTTP 集成测试', () => {
assert.equal(second.response.status, 200); assert.equal(second.response.status, 200);
assert.equal(second.body.data.reused, true); assert.equal(second.body.data.reused, true);
assert.equal(second.body.data.refreshed, true); assert.equal(second.body.data.refreshed, true);
assert.equal(second.body.data.product_id, first.body.data.product_id);
assert.equal(second.body.data.editor_entry_path, first.body.data.editor_entry_path);
assert.equal( assert.equal(
second.body.data.collection_item.id, second.body.data.collection_item.id,
first.body.data.collection_item.id, first.body.data.collection_item.id,
@@ -319,6 +436,10 @@ describe('扩展授权与采集 HTTP 集成测试', () => {
}, },
); );
assert.equal(editorAfterRefresh.response.status, 200); assert.equal(editorAfterRefresh.response.status, 200);
assert.equal(
editorAfterRefresh.body.data.product.common.purchase_url,
'https://detail.1688.com/offer/888888888.html',
);
assert.equal( assert.equal(
editorAfterRefresh.body.data.product.common.source_title, editorAfterRefresh.body.data.product.common.source_title,
'人工确认商品名', '人工确认商品名',
@@ -328,7 +449,7 @@ describe('扩展授权与采集 HTTP 集成测试', () => {
variant.source_variant_key === '123456789', variant.source_variant_key === '123456789',
); );
assert.equal(originalVariant.display_name, '人工确认 SKU'); assert.equal(originalVariant.display_name, '人工确认 SKU');
assert.equal(originalVariant.purchase_cost_cny, '88.8000'); assert.equal(originalVariant.purchase_cost_cny, '88.80');
assert.equal(originalVariant.listing.offer_id, 'manual-offer-001'); assert.equal(originalVariant.listing.offer_id, 'manual-offer-001');
const discoveredVariant = editorAfterRefresh.body.data.variants.find( const discoveredVariant = editorAfterRefresh.body.data.variants.find(
(variant: { source_variant_key: string }) => (variant: { source_variant_key: string }) =>
@@ -431,4 +552,98 @@ describe('扩展授权与采集 HTTP 集成测试', () => {
); );
assert.equal(transferWorkspace.rows[0]?.workspace_id, workspaceId); assert.equal(transferWorkspace.rows[0]?.workspace_id, workspaceId);
}); });
it('完整采集并保存 1000 个 SKU,复采保持身份,超限请求原子拒绝', async () => {
const variants = Array.from({ length: PRODUCT_VARIANT_LIMIT }, (_, index) => ({
sourceVariantKey: String(8_000_000_000_000 + index),
sourceSku: String(8_000_000_000_000 + index),
displayName: `来源 SKU ${index}`,
selections: [], confidence: 'verified', evidenceRefs: [],
sourcePrice: { amount: '20.00', currencyCode: 'CNY' },
}));
const payload = {
source: {
platform: '1688', itemKey: `large-${randomUUID()}`,
canonicalUrl: 'https://detail.1688.com/offer/123456789.html',
collectedAt: new Date().toISOString(), parserVersion: 'large-sku-test',
},
common: { title: { value: '多 SKU 来源商品', confidence: 'verified' }, attributes: [], categories: [] },
variantAxes: [], variants, assets: [], evidence: [],
};
const capture = (body: unknown) => request('/api/extension/materials', {
method: 'POST',
headers: { Authorization: `Bearer ${webAccessToken}`, 'Idempotency-Key': randomUUID() },
body: JSON.stringify(body),
});
const captured = await capture(payload);
assert.equal(captured.response.status, 201, JSON.stringify(captured.body));
assert.equal(captured.body.data.collection_item.variant_count, PRODUCT_VARIANT_LIMIT);
const collectionId = captured.body.data.collection_item.id;
const productId = captured.body.data.product_id;
const platforms = await request('/api/platforms', { headers: { Authorization: `Bearer ${webAccessToken}` } });
const platformId = platforms.body.data.find((platform: { code: string }) => platform.code === 'OZON').id;
const context = await request(`/api/collection-items/${collectionId}/editor-contexts`, {
method: 'POST',
headers: {
Authorization: `Bearer ${webAccessToken}`, 'X-Atlas-Platform-Id': platformId,
'Idempotency-Key': randomUUID(),
},
});
assert.equal(context.response.status, 201, JSON.stringify(context.body));
const read = () => request(`/api/products/${productId}/editor`, {
headers: { Authorization: `Bearer ${webAccessToken}`, 'X-Atlas-Platform-Id': platformId },
});
const before = await read();
assert.equal(before.response.status, 200);
const snapshot = before.body.data;
assert.equal(snapshot.variants.length, PRODUCT_VARIANT_LIMIT);
assert.equal(snapshot.variants.at(-1).source_sku, variants.at(-1)!.sourceSku);
const draft = {
ai_source_material: snapshot.source_material,
ai_source_overridden_fields: [],
common: {
...snapshot.product.common,
default_physical_spec: snapshot.product.default_physical_spec,
default_package_spec: snapshot.product.default_package_spec,
},
profile: {
target_store_id: snapshot.profile.target_store.id,
fulfillment: snapshot.profile.fulfillment, title: null, description: null, tags: [],
platform_product_category_id: null,
},
variants: snapshot.variants.map((variant: any, index: number) => ({
id: variant.id, client_key: null,
display_name: index === PRODUCT_VARIANT_LIMIT - 1 ? '末尾 SKU 已编辑' : variant.display_name,
source_sku: variant.source_sku, specification_values: variant.specification_values,
purchase_cost_cny: variant.purchase_cost_cny, physical_spec: variant.physical_spec,
package_spec: variant.package_spec, tags: [], description: null,
offer_id: null, media_asset_ids: [],
})),
};
const saved = await request(`/api/products/${productId}/editor-draft`, {
method: 'PUT', headers: {
Authorization: `Bearer ${webAccessToken}`, 'X-Atlas-Platform-Id': platformId,
'Idempotency-Key': randomUUID(),
'If-Match': `"${snapshot.product.revision}:${snapshot.profile.revision}"`,
}, body: JSON.stringify(draft),
});
assert.equal(saved.response.status, 200, JSON.stringify(saved.body));
const after = await read();
assert.equal(after.body.data.variants.length, PRODUCT_VARIANT_LIMIT);
assert.equal(after.body.data.variants.at(-1).display_name, '末尾 SKU 已编辑');
const refreshed = await capture(payload);
assert.equal(refreshed.response.status, 200);
assert.equal(refreshed.body.data.product_id, productId);
assert.equal(refreshed.body.data.collection_item.variant_count, PRODUCT_VARIANT_LIMIT);
const reread = await read();
assert.deepEqual(reread.body.data.variants.map((v: any) => v.id), snapshot.variants.map((v: any) => v.id));
assert.equal(reread.body.data.variants.at(-1).display_name, '末尾 SKU 已编辑');
const revision = reread.body.data.source_material.source_snapshot_revision;
const rejected = await capture({ ...payload, variants: [...variants, { ...variants[0], sourceVariantKey: 'extra-sku' }] });
assert.equal(rejected.response.status, 422);
assert.equal(rejected.body.error.details[0].field, 'variants');
const unchanged = await read();
assert.equal(unchanged.body.data.source_material.source_snapshot_revision, revision);
assert.equal(unchanged.body.data.variants.length, PRODUCT_VARIANT_LIMIT);
}, 60_000);
}); });
+328
View File
@@ -0,0 +1,328 @@
import { randomUUID } from 'node:crypto';
import {
createApplicationDatabase,
type Database,
type DatabaseTransaction,
} from '@atlas/database';
import {
createImageSuiteSchema,
imageSuiteInputSchema,
type ImageSuite,
} from '@atlas/contracts';
import { describe, expect, it } from 'vitest';
import { IdempotencyService } from '../src/common/idempotency.service.js';
import { ImageSuiteService } from '../src/media/image-suite.service.js';
import { MediaAiEditService } from '../src/media/media-ai-edit.service.js';
import { ProductImageTaskService } from '../src/media/product-image-task.service.js';
/** 所有实体和 Outbox 都只存在于最终回滚的事务,验证不调用实际收费生图。 */
describe.skipIf(!process.env.DATABASE_URL)(
'套图数据库隔离、幂等与状态汇总',
() => {
it('创建/部分失败/取消/重试保留成功图片,FK 与 RLS 拒绝越权引用', async () => {
const db = createApplicationDatabase(process.env.DATABASE_URL!);
const rollback = Symbol('rollback');
try {
await db.transaction(async (tx) => {
const workspaceId = randomUUID(),
otherWorkspace = randomUUID(),
userId = randomUUID();
await tx.query(
"INSERT INTO atlas.users (id, email, password_hash) VALUES ($1, $2, 'test-only')",
[userId, `${userId}@example.test`],
);
for (const id of [workspaceId, otherWorkspace])
await tx.query(
"INSERT INTO atlas.workspaces (id, name, slug) VALUES ($1::uuid, '套图测试', $1::text)",
[id],
);
const inWorkspace = async (id: string) =>
tx.query(
"SELECT set_config('app.workspace_id', $1, true)",
[id],
);
await inWorkspace(workspaceId);
const productId = randomUUID(),
variantId = randomUUID(),
itemId = randomUUID(),
assetId = randomUUID(),
outputId = randomUUID();
await tx.query(
"INSERT INTO atlas.collection_items (id, workspace_id, source_kind, source_item_key) VALUES ($1::uuid, $2, 'manual', $1::text)",
[itemId, workspaceId],
);
await tx.query(
'INSERT INTO atlas.products (id, workspace_id, collection_item_id) VALUES ($1, $2, $3)',
[productId, workspaceId, itemId],
);
await tx.query(
'INSERT INTO atlas.product_variants (id, workspace_id, product_id) VALUES ($1, $2, $3)',
[variantId, workspaceId, productId],
);
const asset = async (id: string, ws: string) =>
tx.query(
`INSERT INTO atlas.media_assets
(id, workspace_id, storage_provider, bucket, object_key, content_type, byte_size, checksum_sha256, width_px, height_px)
VALUES ($1::uuid, $2, 'local_minio', 'test', $1::text, 'image/jpeg', 100, $1::text, 100, 100)`,
[id, ws],
);
await asset(assetId, workspaceId);
await asset(outputId, workspaceId);
await inWorkspace(otherWorkspace);
const foreignAsset = randomUUID();
await asset(foreignAsset, otherWorkspace);
await inWorkspace(workspaceId);
let queryQueue: Promise<void> = Promise.resolve();
const scoped = {
workspaceTransaction: <T>(
ws: string,
work: (
transaction: DatabaseTransaction,
) => Promise<T>,
) => {
const result = queryQueue.then(async () => {
await inWorkspace(ws);
return work(tx);
});
queryQueue = result.then(
() => undefined,
() => undefined,
);
return result;
},
} as Database;
const idempotency = new IdempotencyService(scoped, {
IDEMPOTENCY_KEY_SECRET: 'suite-test-secret',
} as never);
const edits = new MediaAiEditService(scoped, idempotency, {
accessUrl: async () => ({
url: 'https://example.test/preview',
expires_at: '2099-01-01T00:00:00Z',
}),
} as never);
const suites = new ImageSuiteService(
scoped,
idempotency,
edits,
);
const input = imageSuiteInputSchema.parse({
variant_id: variantId,
reference_asset_ids: [assetId],
product_name: '台灯',
});
const shots = ['white', 'scene', 'selling'].map(
(category, i) => ({
id: randomUUID(),
category,
type: ['white_background', 'lifestyle', 'detail'][
i
],
theme: `方案${i}`,
description: '依据真实底图展示商品',
}),
);
const request = createImageSuiteSchema.parse({
...input,
shots,
});
const key = randomUUID();
const created = await suites.create(
workspaceId,
userId,
productId,
key,
request,
'generate',
);
const id = (created.body as { data: ImageSuite }).data.id;
expect(
(
await suites.create(
workspaceId,
userId,
productId,
key,
request,
'generate',
)
).body,
).toEqual(created.body);
await expect(
suites.create(
workspaceId,
userId,
productId,
key,
{ ...request, product_name: '不同请求' },
'generate',
),
).rejects.toMatchObject({
status: 409,
response: { error: { code: 'IDEMPOTENCY_KEY_REUSED' } },
});
const children = (
await tx.query<{ id: string }>(
'SELECT id FROM atlas.media_ai_edits WHERE suite_id = $1 ORDER BY suite_position',
[id],
)
).rows;
expect(children).toHaveLength(3);
expect(
(
await tx.query(
'SELECT id FROM atlas.outbox_events WHERE aggregate_id = ANY($1::uuid[])',
[children.map((child) => child.id)],
)
).rows,
).toHaveLength(3);
await tx.query(
"UPDATE atlas.media_ai_edits SET status = 'succeeded', output_asset_id = $2 WHERE id = $1",
[children[0].id, outputId],
);
await tx.query(
"UPDATE atlas.media_ai_edits SET status = 'failed', error_code = 'TEST_FAILURE' WHERE id = $1",
[children[1].id],
);
expect(
(await suites.read(workspaceId, productId, id)).status,
).toBe('running');
await suites.action(
workspaceId,
userId,
productId,
id,
randomUUID(),
'cancel',
);
expect(
(await suites.read(workspaceId, productId, id)).status,
).toBe('cancelled');
await suites.action(
workspaceId,
userId,
productId,
id,
randomUUID(),
'retry',
);
const retried = await suites.read(
workspaceId,
productId,
id,
);
expect(retried.items.map((item) => item.status)).toEqual([
'succeeded',
'queued',
'queued',
]);
expect(retried.items[0].output_asset?.id).toBe(outputId);
const attempts = (
await tx.query<{ generation_attempt: number }>(
'SELECT generation_attempt FROM atlas.media_ai_edits WHERE suite_id = $1 ORDER BY suite_position',
[id],
)
).rows;
expect(
attempts.map((item) => item.generation_attempt),
).toEqual([1, 2, 2]);
await tx.query(
"UPDATE atlas.media_ai_edits SET status = 'failed', error_code = 'TEST_FAILURE' WHERE id = ANY($1::uuid[])",
[children.slice(1).map((child) => child.id)],
);
expect(
(await suites.read(workspaceId, productId, id)).status,
).toBe('partial');
const tasks = await new ProductImageTaskService(
scoped,
).list(workspaceId, productId, { limit: 20 });
expect(tasks.data).toMatchObject([
{
id,
kind: 'ai_suite',
status: 'partial',
total: 3,
completed: 1,
output_asset_ids: [outputId],
},
]);
expect(tasks.data).toHaveLength(1);
await expect(
suites.read(otherWorkspace, productId, id),
).rejects.toMatchObject({ status: 404 });
await expect(
suites.create(
workspaceId,
userId,
productId,
randomUUID(),
{ ...input, reference_asset_ids: [foreignAsset] },
'plan',
),
).rejects.toMatchObject({ status: 404 });
const forbidden = async (
sql: string,
params: unknown[],
code: string,
) => {
await tx.query('SAVEPOINT forbidden_write');
try {
await tx.query(sql, params);
throw new Error('本应拒绝的写入成功');
} catch (error) {
expect((error as { code: string }).code).toBe(code);
} finally {
await tx.query(
'ROLLBACK TO SAVEPOINT forbidden_write',
);
}
};
await forbidden(
'UPDATE atlas.product_image_suites SET input = input || \'{"color":"篡改"}\'::jsonb WHERE id = $1',
[id],
'23514',
);
await forbidden(
'UPDATE atlas.media_ai_edits SET suite_position = NULL WHERE id = $1',
[children[0].id],
'23514',
);
await forbidden(
'UPDATE atlas.media_ai_edits SET source_asset_id = $2 WHERE id = $1',
[children[0].id, foreignAsset],
'23514',
);
const foreignReferenceParent = await suites.create(
workspaceId,
userId,
productId,
randomUUID(),
input,
'plan',
);
const foreignParentId = (
foreignReferenceParent.body as { data: ImageSuite }
).data.id;
await forbidden(
'UPDATE atlas.product_image_suite_references SET asset_id = $2 WHERE suite_id = $1',
[foreignParentId, foreignAsset],
'23514',
);
await inWorkspace(otherWorkspace);
expect(
(
await tx.query(
'SELECT id FROM atlas.product_image_suites WHERE id = $1',
[id],
)
).rows,
).toHaveLength(0);
throw rollback;
});
} catch (error) {
if (error !== rollback) throw error;
} finally {
await db.close();
}
});
},
);
+291
View File
@@ -0,0 +1,291 @@
import { randomUUID } from 'node:crypto';
import { describe, expect, it, vi } from 'vitest';
import {
createImageSuiteSchema,
imageSuiteInputSchema,
imageSuitePlanInputSchema,
} from '@atlas/contracts';
import { ImageSuiteService } from '../src/media/image-suite.service.js';
const input = () =>
imageSuiteInputSchema.parse({
variant_id: randomUUID(),
reference_asset_ids: [randomUUID()],
product_name: '台灯',
});
const shot = () => ({
id: randomUUID(),
category: 'white',
type: 'white_background',
theme: '白底主图',
description: '展示商品完整外观',
});
describe('套图共享契约与资源边界', () => {
it('规划默认四张,接受 1–20 张预算,生成接口不接收规划预算字段', () => {
const valid = input();
expect(imageSuitePlanInputSchema.parse(valid).image_count).toBe(4);
expect(
imageSuitePlanInputSchema.parse({ ...valid, image_count: 6 })
.image_count,
).toBe(6);
for (const image_count of [0, 21, 1.2])
expect(
imageSuitePlanInputSchema.safeParse({ ...valid, image_count })
.success,
).toBe(false);
expect(
createImageSuiteSchema.safeParse({
...valid,
image_count: 4,
shots: [shot()],
}).success,
).toBe(false);
});
it('规划保存预算并只创建规划 Outbox,不创建生图子任务', async () => {
const request = imageSuitePlanInputSchema.parse({
...input(),
image_count: 6,
});
const suiteId = randomUUID(),
productId = randomUUID();
const query = vi.fn(async (sql: string, values: unknown[] = []) => {
if (sql.startsWith('SELECT id FROM atlas.product_variants'))
return { rows: [{ id: request.variant_id }] };
if (sql.startsWith('SELECT id FROM atlas.media_assets'))
return {
rows: request.reference_asset_ids.map((id) => ({ id })),
};
if (sql.startsWith('INSERT INTO atlas.product_image_suites'))
return {
rows: [
{
id: suiteId,
product_id: productId,
variant_id: request.variant_id,
purpose: 'plan',
status: 'queued',
input: JSON.parse(values[3] as string),
shots: [],
created_at: new Date(),
completed_at: null,
error_code: null,
},
],
};
return { rows: [] };
});
const service = new ImageSuiteService(
{} as never,
{
run: async (
_context: unknown,
work: (tx: { query: typeof query }) => Promise<unknown>,
) => work({ query }),
} as never,
{} as never,
);
const result = await service.create(
randomUUID(),
randomUUID(),
productId,
randomUUID(),
request,
'plan',
);
expect(result).toMatchObject({
status: 202,
body: {
data: { purpose: 'plan', input: { image_count: 6 }, shots: [] },
},
});
expect(
query.mock.calls.some(([sql]) =>
sql.includes('INSERT INTO atlas.media_ai_edits'),
),
).toBe(false);
expect(
query.mock.calls.find(([sql]) =>
sql.includes('INSERT INTO atlas.outbox_events'),
)?.[1],
).toContain('image_suite.plan.requested');
});
it('拒绝重复底图、超过 8 张底图、超过分类数量和无实物尺寸的尺寸图', () => {
const valid = input();
expect(
imageSuiteInputSchema.safeParse({
...valid,
reference_asset_ids: Array.from({ length: 9 }, randomUUID),
}).success,
).toBe(false);
expect(
imageSuiteInputSchema.safeParse({
...valid,
reference_asset_ids: [
...valid.reference_asset_ids,
...valid.reference_asset_ids,
],
}).success,
).toBe(false);
expect(
createImageSuiteSchema.safeParse({
...valid,
shots: Array.from({ length: 6 }, shot),
}).success,
).toBe(false);
expect(
createImageSuiteSchema.safeParse({
...valid,
shots: [{ ...shot(), category: 'other', type: 'dimensions' }],
}).success,
).toBe(false);
});
it('逐张底图必须来自已选图库,并保留主体图,类型必须匹配分类', () => {
const valid = input();
expect(
createImageSuiteSchema.safeParse({
...valid,
shots: [{ ...shot(), reference_asset_ids: [randomUUID()] }],
}).success,
).toBe(false);
expect(
createImageSuiteSchema.safeParse({
...valid,
shots: [{ ...shot(), category: 'scene' }],
}).success,
).toBe(false);
expect(
createImageSuiteSchema.safeParse({
...valid,
shots: [shot()],
ratio: '4:3',
language: 'zh',
}).success,
).toBe(true);
});
it('目标 SKU 不属于当前商品时,在产生任何任务前拒绝请求', async () => {
const query = vi.fn(async () => ({ rows: [] }));
const run = vi.fn(
async (
_input: unknown,
work: (tx: { query: typeof query }) => Promise<unknown>,
) => work({ query }),
);
const service = new ImageSuiteService(
{} as never,
{ run } as never,
{} as never,
);
await expect(
service.create(
randomUUID(),
randomUUID(),
randomUUID(),
randomUUID(),
input(),
'plan',
),
).rejects.toMatchObject({ status: 404 });
expect(query).toHaveBeenCalledOnce();
});
it('任一底图不可见时拒绝,不产生套图及 Outbox', async () => {
const query = vi.fn(async (sql: string) => ({
rows: sql.includes('atlas.product_variants')
? [{ id: 'variant' }]
: [],
}));
const run = vi.fn(
async (
_input: unknown,
work: (tx: { query: typeof query }) => Promise<unknown>,
) => work({ query }),
);
const service = new ImageSuiteService(
{} as never,
{ run } as never,
{} as never,
);
await expect(
service.create(
randomUUID(),
randomUUID(),
randomUUID(),
randomUUID(),
input(),
'plan',
),
).rejects.toMatchObject({ status: 404 });
expect(query).toHaveBeenCalledTimes(2);
});
it('默认逐张用图保留主体,每张最多 3 张,整套轮换覆盖已选 8 张底图', async () => {
const request = createImageSuiteSchema.parse({
...input(),
reference_asset_ids: Array.from({ length: 8 }, randomUUID),
shots: Array.from({ length: 8 }, (_, index) => ({
...shot(),
category: ['white', 'scene', 'selling', 'other'][index % 4],
type: ['white_background', 'lifestyle', 'detail', 'custom'][
index % 4
],
})),
});
const used: string[][] = [];
const query = vi.fn(async (sql: string, values: unknown[] = []) => {
if (sql.startsWith('SELECT id FROM atlas.product_variants'))
return { rows: [{ id: request.variant_id }] };
if (sql.startsWith('SELECT id FROM atlas.media_assets'))
return {
rows: request.reference_asset_ids.map((id) => ({ id })),
};
if (sql.includes('INSERT INTO atlas.product_image_suites'))
return {
rows: [
{
id: randomUUID(),
product_id: randomUUID(),
variant_id: request.variant_id,
purpose: 'generate',
status: 'queued',
input: request,
shots: request.shots,
created_at: new Date(),
completed_at: null,
error_code: null,
},
],
};
if (sql.includes('INSERT INTO atlas.media_ai_edits'))
used.push([values[5] as string, ...(values[6] as string[])]);
return { rows: [] };
});
const service = new ImageSuiteService(
{} as never,
{
run: async (
_request: unknown,
work: (tx: { query: typeof query }) => Promise<unknown>,
) => work({ query }),
} as never,
{} as never,
);
await service.create(
randomUUID(),
randomUUID(),
randomUUID(),
randomUUID(),
request,
'generate',
);
expect(used).toHaveLength(8);
expect(
used.every(
(refs) =>
refs.length <= 3 &&
refs[0] === request.reference_asset_ids[0],
),
).toBe(true);
expect(new Set(used.flat())).toEqual(
new Set(request.reference_asset_ids),
);
});
});
+101
View File
@@ -0,0 +1,101 @@
import { describe, expect, it, vi } from 'vitest';
import { createMediaAiEditRequestSchema } from '@atlas/contracts';
import { MediaAiEditService } from '../src/media/media-ai-edit.service.js';
const workspaceId = '30cf54b7-06a3-4e5f-a5fc-d74165115094';
const sourceId = '664d0d73-3d39-4a98-ac0a-5c0e521d8998';
const referenceId = '50ea00b7-6675-4cc7-b7a2-d45f05c07100';
const editId = '68db226f-3fcb-4579-ab9e-4932b1d84067';
const productId = '733d0d73-3d39-4a98-ac0a-5c0e521d8998';
describe('单张 AI 编辑任务', () => {
it('验证主图和辅图都属于当前工作空间后,原子写入任务、进度和 Outbox', async () => {
const statements: string[] = [];
const query = vi.fn(async (sql: string) => {
statements.push(sql);
if (sql.includes('SELECT id FROM atlas.products')) return { rows: [{ id: productId }] };
if (sql.includes('SELECT id FROM atlas.media_assets'))
return { rows: [{ id: sourceId }, { id: referenceId }] };
if (sql.includes('RETURNING id')) return { rows: [{ id: editId }] };
return { rows: [] };
});
const run = vi.fn(async (_input: unknown, work: (tx: { query: typeof query }) => Promise<unknown>) => work({ query }));
const service = new MediaAiEditService(
{} as ConstructorParameters<typeof MediaAiEditService>[0],
{ run } as unknown as ConstructorParameters<typeof MediaAiEditService>[1],
{} as ConstructorParameters<typeof MediaAiEditService>[2],
);
const input = createMediaAiEditRequestSchema.parse({ product_id: productId, reference_asset_ids: [referenceId], background: 'reference' });
const result = await service.create(workspaceId, 'user-id', sourceId, 'key', input);
expect(result).toMatchObject({ status: 202, body: { data: { id: editId, status: 'queued' } } });
expect(run).toHaveBeenCalledOnce();
expect(statements.some((sql) => sql.includes('INSERT INTO atlas.media_ai_edits'))).toBe(true);
expect(statements.some((sql) => sql.includes('INSERT INTO atlas.operations'))).toBe(true);
expect(statements.some((sql) => sql.includes('INSERT INTO atlas.outbox_events'))).toBe(true);
});
it('任一辅助图不在当前工作空间时,不创建任务', async () => {
const query = vi.fn(async (sql: string) => ({ rows: [{ id: sql.includes('atlas.products') ? productId : sourceId }] }));
const run = vi.fn(async (_input: unknown, work: (tx: { query: typeof query }) => Promise<unknown>) => work({ query }));
const service = new MediaAiEditService(
{} as ConstructorParameters<typeof MediaAiEditService>[0],
{ run } as unknown as ConstructorParameters<typeof MediaAiEditService>[1],
{} as ConstructorParameters<typeof MediaAiEditService>[2],
);
const input = createMediaAiEditRequestSchema.parse({ product_id: productId, reference_asset_ids: [referenceId] });
await expect(service.create(workspaceId, 'user-id', sourceId, 'key', input))
.rejects.toMatchObject({ status: 404 });
expect(query).toHaveBeenCalledTimes(2);
});
it('仅扩图不能带辅图或尺寸标注,参考背景必须带辅图', () => {
expect(createMediaAiEditRequestSchema.safeParse({ product_id: productId, mode: 'outpaint', reference_asset_ids: [referenceId] }).success).toBe(false);
expect(createMediaAiEditRequestSchema.safeParse({ product_id: productId, background: 'reference' }).success).toBe(false);
expect(createMediaAiEditRequestSchema.safeParse({ product_id: productId, generate_size_chart: true }).success).toBe(false);
});
it('读取失败任务时返回记录的失败来源、步骤和诊断', async () => {
const query = vi.fn(async (sql: string) => {
expect(sql).toContain('o.workspace_id = e.workspace_id');
return { rows: [{
id: editId, status: 'failed', source_asset_id: sourceId,
output_asset_id: null, error_code: 'AI_IMAGE_GENERATION_FAILED',
failure_source: 'atlas', failure_stage: 'result_processing',
failure_detail: 'Pcore 结果图片无效',
}] };
});
const database = { workspaceTransaction: vi.fn(async (_workspace: string, work: (tx: { query: typeof query }) => Promise<unknown>) => work({ query })) };
const service = new MediaAiEditService(
database as unknown as ConstructorParameters<typeof MediaAiEditService>[0],
{} as ConstructorParameters<typeof MediaAiEditService>[1],
{} as ConstructorParameters<typeof MediaAiEditService>[2],
);
const result = await service.read(workspaceId, editId);
expect(result).toMatchObject({
failure_source: 'atlas', failure_stage: 'result_processing',
failure_detail: 'Pcore 结果图片无效',
});
});
it('AI 生图记录按当前商品筛选并重新签发短时预览地址', async () => {
const query = vi.fn(async (_sql: string, values: unknown[]) => {
expect(values).toEqual([productId]);
return { rows: [{
id: editId, status: 'succeeded', source_asset_id: sourceId,
output_asset_id: referenceId, error_code: null,
content_type: 'image/jpeg', byte_size: '1234', width_px: 1152,
height_px: 1536, asset_created_at: new Date('2026-09-26T00:00:00Z'),
}] };
});
const database = { workspaceTransaction: vi.fn(async (_workspace: string, work: (tx: { query: typeof query }) => Promise<unknown>) => work({ query })) };
const accessUrl = vi.fn(async () => ({ url: 'https://example.com/short-lived', expires_at: '2026-09-26T01:00:00Z' }));
const service = new MediaAiEditService(
database as unknown as ConstructorParameters<typeof MediaAiEditService>[0],
{} as ConstructorParameters<typeof MediaAiEditService>[1],
{ accessUrl } as unknown as ConstructorParameters<typeof MediaAiEditService>[2],
);
const items = await service.list(workspaceId, productId);
expect(items).toMatchObject([{ id: editId, output_asset: { id: referenceId, preview_url: 'https://example.com/short-lived' } }]);
expect(accessUrl).toHaveBeenCalledWith(workspaceId, referenceId);
});
});
@@ -0,0 +1,81 @@
import { describe, expect, it, vi } from 'vitest';
import { AspectRatioDerivativeService } from '../src/media/aspect-ratio-derivative.service.js';
const workspaceId = '30cf54b7-06a3-4e5f-a5fc-d74165115094';
const sourceAssetId = '664d0d73-3d39-4a98-ac0a-5c0e521d8998';
const derivativeId = '68db226f-3fcb-4579-ab9e-4932b1d84067';
const productId = '733d0d73-3d39-4a98-ac0a-5c0e521d8998';
describe('一键 3:4 派生任务', () => {
it('在同一个租户事务写入派生记录、操作进度和 Outbox', async () => {
const statements: string[] = [];
const query = vi.fn(async (sql: string) => {
statements.push(sql);
if (sql.includes('SELECT id FROM atlas.media_assets')) return { rows: [{ id: sourceAssetId }] };
if (sql.includes('RETURNING id')) return { rows: [{ id: derivativeId }] };
return { rows: [] };
});
const run = vi.fn(async (_input: unknown, work: (tx: { query: typeof query }) => Promise<unknown>) =>
work({ query }));
const service = new AspectRatioDerivativeService(
{} as ConstructorParameters<typeof AspectRatioDerivativeService>[0],
{ run } as unknown as ConstructorParameters<typeof AspectRatioDerivativeService>[1],
{} as ConstructorParameters<typeof AspectRatioDerivativeService>[2],
);
const result = await service.create(
workspaceId,
'user-id',
sourceAssetId,
'idempotency-key',
{ preset: '3:4', fit: 'contain' },
);
expect(result).toMatchObject({
status: 202,
body: { data: { id: derivativeId, status: 'queued', source_asset_id: sourceAssetId } },
});
expect(run).toHaveBeenCalledOnce();
expect(statements.some((sql) => sql.includes('INSERT INTO atlas.media_aspect_ratio_derivatives'))).toBe(true);
expect(statements.some((sql) => sql.includes('INSERT INTO atlas.operations'))).toBe(true);
expect(statements.some((sql) => sql.includes('INSERT INTO atlas.outbox_events'))).toBe(true);
});
it('缺少或跨工作空间的源素材不会创建任务', async () => {
const query = vi.fn(async () => ({ rows: [] }));
const run = vi.fn(async (_input: unknown, work: (tx: { query: typeof query }) => Promise<unknown>) =>
work({ query }));
const service = new AspectRatioDerivativeService(
{} as ConstructorParameters<typeof AspectRatioDerivativeService>[0],
{ run } as unknown as ConstructorParameters<typeof AspectRatioDerivativeService>[1],
{} as ConstructorParameters<typeof AspectRatioDerivativeService>[2],
);
await expect(service.create(
workspaceId,
'user-id',
sourceAssetId,
'idempotency-key',
{ preset: '3:4', fit: 'contain' },
)).rejects.toMatchObject({ status: 404 });
expect(query).toHaveBeenCalledOnce();
});
it('将当前工作空间的商品关联到新派生任务', async () => {
const query = vi.fn(async (sql: string, values?: unknown[]) => {
if (sql.includes('SELECT id FROM atlas.media_assets')) return { rows: [{ id: sourceAssetId }] };
if (sql.includes('SELECT id FROM atlas.products')) return { rows: [{ id: productId }] };
if (sql.includes('INSERT INTO atlas.media_aspect_ratio_derivatives')) {
expect(values).toEqual([productId, sourceAssetId, 'user-id']);
return { rows: [{ id: derivativeId }] };
}
return { rows: [] };
});
const run = vi.fn(async (_input: unknown, work: (tx: { query: typeof query }) => Promise<unknown>) => work({ query }));
const service = new AspectRatioDerivativeService(
{} as ConstructorParameters<typeof AspectRatioDerivativeService>[0],
{ run } as unknown as ConstructorParameters<typeof AspectRatioDerivativeService>[1],
{} as ConstructorParameters<typeof AspectRatioDerivativeService>[2],
);
await service.create(workspaceId, 'user-id', sourceAssetId, 'key',
{ preset: '3:4', fit: 'contain', product_id: productId });
expect(query.mock.calls.some(([sql]) => String(sql).includes('INSERT INTO atlas.media_aspect_ratio_derivatives'))).toBe(true);
});
});
+109
View File
@@ -0,0 +1,109 @@
import { createHash } from 'node:crypto';
import { describe, expect, it, vi } from 'vitest';
import { MediaService } from '../src/media/media.service.js';
vi.mock('../src/media/product-image-download.js', async (importOriginal) => {
const original =
await importOriginal<
typeof import('../src/media/product-image-download.js')
>();
return {
...original,
downloadProductImage: vi.fn(async () => ({
bytes: Buffer.from('validated-image'),
contentType: 'image/png',
width: 800,
height: 800,
})),
};
});
describe('网络商品图片导入', () => {
it('复用同工作空间素材并返回正式 ID 与短时预览地址', async () => {
const productId = '733d0d73-3d39-4a98-ac0a-5c0e521d8998';
const assetId = '7c910684-7070-4f36-8a7b-b10bd91acd79';
const bytes = Buffer.from('validated-image');
const checksum = createHash('sha256').update(bytes).digest('hex');
const query = vi.fn(async (sql: string, args?: unknown[]) => {
if (sql.includes('FROM atlas.products')) {
expect(args).toEqual([productId]);
return { rows: [{ id: productId }] };
}
if (sql.includes('WHERE checksum_sha256 = $1')) {
expect(args).toEqual([checksum]);
return { rows: [{ id: assetId }] };
}
if (sql.includes('INSERT INTO atlas.product_image_imports')) {
expect(args?.[0]).toBe('https://example.com/photo.png');
expect(args?.[1]).toBe(assetId);
expect(args?.[3]).toBe(productId);
expect(sql).toContain('ON CONFLICT (workspace_id, product_id, source_url)');
return { rows: [] };
}
if (sql.includes('SELECT id, content_type')) {
return {
rows: [
{
id: assetId,
content_type: 'image/png',
byte_size: '15',
width_px: 800,
height_px: 800,
created_at: new Date('2026-09-24T00:00:00Z'),
},
],
};
}
if (sql.includes('SELECT storage_provider, bucket, object_key')) {
return {
rows: [
{
storage_provider: 'local_minio',
bucket: 'test',
object_key: 'product-imports/existing.png',
},
],
};
}
throw new Error(`未知 SQL: ${sql}`);
});
const run = vi.fn(
async (
request: { path: string; key: string },
callback: (tx: { query: typeof query }) => Promise<unknown>,
) => {
expect(request.path).toBe('/api/media-assets/product-imports');
expect(request.key).toBe('idempotency-key');
return callback({ query });
},
);
const service = new MediaService(
{ run } as unknown as ConstructorParameters<typeof MediaService>[0],
{} as ConstructorParameters<typeof MediaService>[1],
{
OBJECT_STORAGE_ENDPOINT: 'http://127.0.0.1:9000',
OBJECT_STORAGE_REGION: 'us-east-1',
OBJECT_STORAGE_BUCKET: 'test',
objectStorageForcePathStyle: true,
objectStorageAccessKey: 'test',
objectStorageSecretKey: 'test',
} as ConstructorParameters<typeof MediaService>[2],
);
const result = await service.importProductImage(
'workspace-id',
'user-id',
'idempotency-key',
{ url: 'https://example.com/photo.png', product_id: productId },
);
const body = result.body as {
data: { id: string; source_url: string; preview_url: string };
};
expect(result.status).toBe(201);
expect(body.data).toMatchObject({
id: assetId,
source_url: 'https://example.com/photo.png',
});
expect(body.data.preview_url).toContain('product-imports/existing.png');
expect(query).toHaveBeenCalledTimes(5);
});
});
@@ -0,0 +1,354 @@
const productId = '733d0d73-3d39-4a98-ac0a-5c0e521d8998';
import { createHash } from 'node:crypto';
import { GetObjectCommand, HeadObjectCommand } from '@aws-sdk/client-s3';
import {
createMediaAccessUrlRequestSchema,
createMediaUploadIntentRequestSchema,
importProductImageRequestSchema,
} from '@atlas/contracts';
import { describe, expect, it, vi } from 'vitest';
import { MediaService } from '../src/media/media.service.js';
import {
downloadProductImage,
publicIpv4,
} from '../src/media/product-image-download.js';
function serviceWithObject(bytes: Buffer) {
const service = new MediaService(
{} as ConstructorParameters<typeof MediaService>[0],
{} as ConstructorParameters<typeof MediaService>[1],
{
OBJECT_STORAGE_ENDPOINT: 'http://127.0.0.1:9000',
OBJECT_STORAGE_REGION: 'us-east-1',
OBJECT_STORAGE_BUCKET: 'test',
objectStorageForcePathStyle: true,
objectStorageAccessKey: 'test',
objectStorageSecretKey: 'test',
} as ConstructorParameters<typeof MediaService>[2],
);
const send = vi.fn(async (command: unknown) => {
if (command instanceof HeadObjectCommand) {
return { ContentLength: bytes.length, ContentType: 'image/png' };
}
if (command instanceof GetObjectCommand) {
return {
Body: (async function* () {
yield bytes;
})(),
};
}
throw new Error('未知 S3 命令');
});
Object.assign(service, { s3: { send } });
return service as unknown as {
inspectObject: (intent: Record<string, unknown>) => Promise<{
width: number;
height: number;
checksum: string;
}>;
};
}
function png(width: number, height: number) {
const bytes = Buffer.alloc(24);
Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]).copy(bytes);
bytes.writeUInt32BE(width, 16);
bytes.writeUInt32BE(height, 20);
return bytes;
}
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_REGION: 'cn-east-1',
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(preview.url).searchParams.get('X-Amz-Credential')).toContain('/cn-east-1/');
expect(new URL(download.url).searchParams.get('response-content-disposition'))
.toBe(`attachment; filename="atlas-image-${assetId}.png"`);
expect(workspaceTransaction).toHaveBeenCalledTimes(2);
});
it('开发环境为旧 MinIO 素材签发本地预览地址', async () => {
const query = vi.fn(async () => ({
rows: [{
storage_provider: 'local_minio',
bucket: 'atlas-assets',
object_key: 'collections/test/image',
content_type: 'image/jpeg',
}],
}));
const database = {
workspaceTransaction: async (_workspaceId: string, work: (tx: { query: typeof query }) => Promise<unknown>) => work({ query }),
};
const service = new MediaService(
{} as ConstructorParameters<typeof MediaService>[0],
database as unknown as ConstructorParameters<typeof MediaService>[1],
{
NODE_ENV: 'development',
OBJECT_STORAGE_PROVIDER: 'qiniu_kodo',
OBJECT_STORAGE_ENDPOINT: 'https://s3.cn-east-1.qiniucs.com',
OBJECT_STORAGE_REGION: 'cn-east-1',
OBJECT_STORAGE_BUCKET: 'atlas-dev-assets',
objectStorageForcePathStyle: true,
objectStorageAccessKey: 'test',
objectStorageSecretKey: 'test',
LEGACY_MINIO_ENDPOINT: 'http://localhost:9000',
MINIO_ROOT_USER: 'local-test',
MINIO_ROOT_PASSWORD: 'local-secret',
} as ConstructorParameters<typeof MediaService>[2],
);
const preview = await service.accessUrl('workspace-id', 'asset-id');
expect(new URL(preview.url).host).toBe('localhost:9000');
expect(new URL(preview.url).pathname).toBe('/atlas-assets/collections/test/image');
});
it('七牛素材使用绑定域名签发预览和附件下载链接', async () => {
const assetId = '7c910684-7070-4f36-8a7b-b10bd91acd79';
const query = vi.fn(async () => ({
rows: [{
storage_provider: 'qiniu_kodo',
bucket: 'atlas-dev-assets',
object_key: 'collections/workspace/image',
content_type: 'image/jpeg',
}],
}));
const database = {
workspaceTransaction: async (_workspaceId: string, work: (tx: { query: typeof query }) => Promise<unknown>) => work({ query }),
};
const service = new MediaService(
{} as ConstructorParameters<typeof MediaService>[0],
database as unknown as ConstructorParameters<typeof MediaService>[1],
{
OBJECT_STORAGE_ENDPOINT: 'https://s3.cn-east-1.qiniucs.com',
OBJECT_STORAGE_REGION: 'cn-east-1',
OBJECT_STORAGE_BUCKET: 'atlas-dev-assets',
objectStorageForcePathStyle: true,
objectStorageAccessKey: 's3-key',
objectStorageSecretKey: 's3-secret',
qiniuDownloadOrigin: 'https://img-dev.atlas.joeyone.cn',
qiniuDownloadAccessKey: 'download-key',
qiniuDownloadSecretKey: 'download-secret',
} as ConstructorParameters<typeof MediaService>[2],
);
const preview = await service.accessUrl('workspace-id', assetId);
const download = await service.accessUrl('workspace-id', assetId, 'download');
const previewUrl = new URL(preview.url);
const downloadUrl = new URL(download.url);
expect(previewUrl.origin).toBe('https://img-dev.atlas.joeyone.cn');
expect(previewUrl.pathname).toBe('/collections/workspace/image');
expect(previewUrl.searchParams.has('X-Amz-Signature')).toBe(false);
expect(previewUrl.searchParams.has('attname')).toBe(false);
expect(previewUrl.searchParams.get('token')).toMatch(/^download-key:/);
expect(preview.url.split('&').at(-1)).toMatch(/^token=/);
expect(downloadUrl.searchParams.get('attname')).toBe(`atlas-image-${assetId}.jpg`);
expect(download.url.split('&').at(-1)).toMatch(/^token=/);
});
it('创建商品图片上传意图时持久化用途并签发独立对象路径', async () => {
const query = vi.fn(async (_sql: string, _params?: unknown[]) => ({
rows: [{ id: productId }],
}));
const run = vi.fn(
async (
_request: unknown,
callback: (tx: { query: typeof query }) => Promise<unknown>,
) => callback({ query }),
);
const service = new MediaService(
{ run } as unknown as ConstructorParameters<typeof MediaService>[0],
{} as ConstructorParameters<typeof MediaService>[1],
{
OBJECT_STORAGE_ENDPOINT: 'http://127.0.0.1:9000',
OBJECT_STORAGE_REGION: 'us-east-1',
OBJECT_STORAGE_BUCKET: 'test',
objectStorageForcePathStyle: true,
objectStorageAccessKey: 'test',
objectStorageSecretKey: 'test',
} as ConstructorParameters<typeof MediaService>[2],
);
Object.assign(service, { ensureBucket: async () => undefined });
const result = await service.createIntent(
'workspace-id',
'user-id',
'key',
{
purpose: 'product_image',
product_id: productId,
filename: 'sku.png',
content_type: 'image/png',
byte_size: 1024,
checksum_sha256: 'a'.repeat(64),
},
);
const args = query.mock.calls.find(([sql]) => sql.includes('INSERT INTO atlas.media_upload_intents'))?.[1] as unknown[];
expect(args[10]).toBe(productId);
expect(args[1]).toBe('product_image');
expect(args[3]).toContain('/product-images/');
const body = result.body as { data: { upload: { url: string } } };
expect(body.data.upload.url).toContain('product-images');
});
it('商品图允许 20 MiB,水印图仍限制 5 MiB', () => {
const request = {
filename: 'sku.png',
content_type: 'image/png',
byte_size: 6 * 1024 * 1024,
checksum_sha256: 'a'.repeat(64),
};
expect(
createMediaUploadIntentRequestSchema.safeParse({
...request,
purpose: 'product_image',
product_id: productId,
}).success,
).toBe(true);
expect(
createMediaUploadIntentRequestSchema.safeParse({
...request,
purpose: 'watermark_image',
}).success,
).toBe(false);
expect(
createMediaUploadIntentRequestSchema.safeParse({
...request,
purpose: 'product_image',
product_id: productId,
byte_size: 21 * 1024 * 1024,
}).success,
).toBe(false);
});
it('商品上传和网络导入必须绑定商品,水印仍无需商品', () => {
const request = { filename: 'image.png', content_type: 'image/png', byte_size: 1024, checksum_sha256: 'a'.repeat(64) };
expect(createMediaUploadIntentRequestSchema.safeParse({ ...request, purpose: 'product_image' }).success).toBe(false);
expect(createMediaUploadIntentRequestSchema.safeParse({ ...request, purpose: 'watermark_image' }).success).toBe(true);
expect(createMediaUploadIntentRequestSchema.safeParse({ ...request, purpose: 'watermark_image', product_id: productId }).success).toBe(false);
expect(importProductImageRequestSchema.safeParse({ url: 'https://example.test/image.png' }).success).toBe(false);
});
it('商品图校验接受 9000px,水印图仍拒绝超过 8192px', async () => {
const bytes = png(9000, 9000);
const service = serviceWithObject(bytes);
const intent = {
bucket: 'test',
object_key: 'product.png',
expected_content_type: 'image/png',
purpose: 'product_image',
};
await expect(service.inspectObject(intent)).resolves.toMatchObject({
width: 9000,
height: 9000,
checksum: createHash('sha256').update(bytes).digest('hex'),
});
await expect(
service.inspectObject({ ...intent, purpose: 'watermark_image' }),
).rejects.toThrow();
});
it('上传图片列表仅查询当前工作空间已完成的商品素材', async () => {
const query = vi.fn(async (sql: string) => {
expect(sql).toContain("i.purpose = 'product_image'");
expect(sql).toContain("i.status = 'ready'");
expect(sql).toContain('i.product_id = $1');
return {
rows: [
{
id: '7c910684-7070-4f36-8a7b-b10bd91acd79',
content_type: 'image/png',
byte_size: '24',
width_px: 800,
height_px: 800,
created_at: new Date('2026-09-24T00:00:00Z'),
bucket: 'test',
object_key: 'uploads/test.png',
},
],
};
});
const workspaceTransaction = vi.fn(
async (
workspaceId: string,
callback: (tx: { query: typeof query }) => Promise<unknown>,
) => {
expect(workspaceId).toBe('workspace-id');
return 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_REGION: 'us-east-1',
OBJECT_STORAGE_BUCKET: 'test',
objectStorageForcePathStyle: true,
objectStorageAccessKey: 'test',
objectStorageSecretKey: 'test',
} as ConstructorParameters<typeof MediaService>[2],
);
const uploads = await service.productUploads('workspace-id', productId);
expect(uploads).toHaveLength(1);
expect(uploads[0]).toMatchObject({
id: '7c910684-7070-4f36-8a7b-b10bd91acd79',
byte_size: 24,
});
expect(uploads[0].preview_url).toContain('uploads/test.png');
});
it('网络导入只允许 HTTPS 图片链接,拒绝内网地址', async () => {
expect(
importProductImageRequestSchema.safeParse({
url: 'https://example.com/image.jpg',
product_id: productId,
}).success,
).toBe(true);
expect(
importProductImageRequestSchema.safeParse({
url: 'http://example.com/image.jpg',
}).success,
).toBe(false);
expect(
importProductImageRequestSchema.safeParse({
url: 'https://example.com/image.svg',
}).success,
).toBe(false);
expect(publicIpv4('8.8.8.8')).toBe(true);
for (const address of [
'127.0.0.1',
'10.1.2.3',
'172.16.0.1',
'192.168.1.1',
'169.254.1.1',
'100.64.0.1',
'224.0.0.1',
]) {
expect(publicIpv4(address)).toBe(false);
}
await expect(
downloadProductImage('https://127.0.0.1/image.jpg'),
).rejects.toThrow();
});
});
+170
View File
@@ -0,0 +1,170 @@
import assert from 'node:assert/strict';
import { randomUUID } from 'node:crypto';
import type { AddressInfo } from 'node:net';
import type { Database } from '@atlas/database';
import type { NestExpressApplication } from '@nestjs/platform-express';
import { afterAll, beforeAll, describe, it } from 'vitest';
import { DATABASE } from '../src/database/database.module.js';
import { createApplication } from '../src/main.js';
let app: NestExpressApplication;
let database: Database;
let baseUrl = '';
let workspaceId = '';
let userId = '';
let token = '';
describe('水印派生 API 集成测试', () => {
beforeAll(async () => {
Object.assign(process.env, {
NODE_ENV: 'test', SWAGGER_ENABLED: 'false',
AUTH_REGISTER_IP_LIMIT: '100', AUTH_REGISTER_EMAIL_LIMIT: '100',
});
const created = await createApplication();
app = created.app;
database = app.get<Database>(DATABASE);
await app.listen(0, '127.0.0.1');
baseUrl = `http://127.0.0.1:${(app.getHttpServer().address() as AddressInfo).port}`;
const response = await fetch(`${baseUrl}/api/auth/register`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
email: `watermark-derivative-${randomUUID()}@example.test`,
password: 'correct-horse-battery-staple-123',
password_confirmation: 'correct-horse-battery-staple-123',
}),
});
assert.equal(response.status, 201);
const body = await response.json() as { data: {
user: { id: string }; workspace: { id: string }; access_token: string;
} };
userId = body.data.user.id;
workspaceId = body.data.workspace.id;
token = body.data.access_token;
});
afterAll(async () => {
if (workspaceId) {
await database.workspaceTransaction(workspaceId, async (tx) => {
await tx.query('DELETE FROM atlas.idempotency_requests WHERE workspace_id = $1', [workspaceId]);
await tx.query('DELETE FROM atlas.outbox_events WHERE workspace_id = $1', [workspaceId]);
await tx.query('DELETE FROM atlas.operations WHERE workspace_id = $1', [workspaceId]);
await tx.query('DELETE FROM atlas.media_aspect_ratio_derivatives WHERE workspace_id = $1', [workspaceId]);
await tx.query('DELETE FROM atlas.media_watermark_derivatives WHERE workspace_id = $1', [workspaceId]);
await tx.query('DELETE FROM atlas.watermark_template_versions WHERE workspace_id = $1', [workspaceId]);
await tx.query('DELETE FROM atlas.watermark_templates WHERE workspace_id = $1', [workspaceId]);
await tx.query('DELETE FROM atlas.media_assets WHERE workspace_id = $1', [workspaceId]);
await tx.query('DELETE FROM atlas.products WHERE workspace_id = $1', [workspaceId]);
await tx.query('DELETE FROM atlas.collection_items WHERE workspace_id = $1', [workspaceId]);
});
await database.query('DELETE FROM atlas.workspace_memberships WHERE user_id = $1', [userId]);
await database.query('DELETE FROM atlas.workspaces WHERE id = $1', [workspaceId]);
await database.query('DELETE FROM atlas.users WHERE id = $1', [userId]);
}
await app?.close();
});
it('HTTP 创建任务锁定模板版本,结果查询保持工作空间边界', async () => {
const { assetId, templateId, productId } = await database.workspaceTransaction(workspaceId, async (tx) => {
const item = await tx.query<{ id: string }>(
`INSERT INTO atlas.collection_items (workspace_id, source_kind, source_item_key)
VALUES ($1, 'manual', $2) RETURNING id`,
[workspaceId, `watermark-history-${randomUUID()}`],
);
const product = await tx.query<{ id: string }>(
`INSERT INTO atlas.products (workspace_id, collection_item_id)
VALUES ($1, $2) RETURNING id`,
[workspaceId, item.rows[0].id],
);
const asset = await tx.query<{ id: string }>(
`INSERT INTO atlas.media_assets
(workspace_id, storage_provider, bucket, object_key, content_type,
byte_size, checksum_sha256, width_px, height_px)
VALUES ($1, 'local_minio', 'atlas-media', $2, 'image/jpeg', 100,
$3, 120, 100) RETURNING id`,
[workspaceId, `test/watermark-${randomUUID()}.jpg`, randomUUID().replaceAll('-', '').padEnd(64, '0')],
);
const template = await tx.query<{ id: string }>(
`INSERT INTO atlas.watermark_templates (workspace_id, name, kind)
VALUES ($1, $2, 'text') RETURNING id`,
[workspaceId, `集成测试水印 ${randomUUID()}`],
);
await tx.query(
`INSERT INTO atlas.watermark_template_versions
(workspace_id, template_id, version, kind, text_content, font_code,
font_size_px, opacity, position, position_x, position_y, created_by_user_id)
VALUES ($1, $2, 1, 'text', 'Atlas', 'NotoSans', 20, 0.5,
'bottom_right', 1, 1, $3)`,
[workspaceId, template.rows[0].id, userId],
);
return { assetId: asset.rows[0].id, templateId: template.rows[0].id, productId: product.rows[0].id };
});
const key = randomUUID();
const create = () => fetch(`${baseUrl}/api/media-assets/${assetId}/watermark-derivatives`, {
method: 'POST', headers: {
Authorization: `Bearer ${token}`, 'Content-Type': 'application/json', 'Idempotency-Key': key,
},
body: JSON.stringify({ watermark_template_id: templateId, product_id: productId }),
});
const response = await create();
assert.equal(response.status, 202);
const body = await response.json() as { data: {
id: string; watermark_template_id: string; watermark_template_version: number;
} };
assert.equal(body.data.watermark_template_id, templateId);
assert.equal(body.data.watermark_template_version, 1);
const replay = await create();
assert.equal(replay.status, 202);
assert.equal((await replay.json() as { data: { id: string } }).data.id, body.data.id);
const read = await fetch(`${baseUrl}/api/media-watermark-derivatives/${body.data.id}`, {
headers: { Authorization: `Bearer ${token}` },
});
assert.equal(read.status, 200);
assert.equal((await read.json() as { data: { id: string } }).data.id, body.data.id);
const stored = await database.workspaceTransaction(workspaceId, async (tx) =>
tx.query<{ product_id: string }>(
'SELECT product_id FROM atlas.media_watermark_derivatives WHERE id = $1',
[body.data.id],
),
);
assert.equal(stored.rows[0]?.product_id, productId);
const outputs = await database.workspaceTransaction(workspaceId, async (tx) => {
const ids: string[] = [];
for (const kind of ['watermark', 'ratio']) {
const asset = await tx.query<{ id: string }>(
`INSERT INTO atlas.media_assets
(workspace_id, storage_provider, bucket, object_key, content_type,
byte_size, checksum_sha256, width_px, height_px)
VALUES ($1, 'local_minio', 'atlas-media', $2, 'image/jpeg', 100,
$3, 1200, 1600) RETURNING id`,
[workspaceId, `test/${kind}-${randomUUID()}.jpg`, randomUUID().replaceAll('-', '').padEnd(64, '0')],
);
ids.push(asset.rows[0].id);
}
await tx.query(
`INSERT INTO atlas.media_watermark_derivatives
(workspace_id, product_id, source_asset_id, watermark_template_id,
watermark_template_version, output_asset_id, status, completed_at)
VALUES ($1, $2, $3, $4, 1, $5, 'succeeded', now())`,
[workspaceId, productId, assetId, templateId, ids[0]],
);
await tx.query(
`INSERT INTO atlas.media_aspect_ratio_derivatives
(workspace_id, product_id, source_asset_id, output_asset_id, status, completed_at)
VALUES ($1, $2, $3, $4, 'succeeded', now())`,
[workspaceId, productId, assetId, ids[1]],
);
return ids;
});
const history = await fetch(`${baseUrl}/api/products/${productId}/image-derivatives`, {
headers: { Authorization: `Bearer ${token}` },
});
assert.equal(history.status, 200);
const records = (await history.json() as { data: Array<{
kind: string; output_asset: { id: string; preview_url: string };
}> }).data;
assert.deepEqual(new Set(records.map((record) => record.kind)), new Set(['watermark', 'aspect_ratio_3_4']));
assert.deepEqual(new Set(records.map((record) => record.output_asset.id)), new Set(outputs));
assert.ok(records.every((record) => record.output_asset.preview_url));
});
});
@@ -0,0 +1,68 @@
import { describe, expect, it, vi } from 'vitest';
import { WatermarkDerivativeService } from '../src/media/watermark-derivative.service.js';
const workspaceId = '30cf54b7-06a3-4e5f-a5fc-d74165115094';
const sourceAssetId = '664d0d73-3d39-4a98-ac0a-5c0e521d8998';
const templateId = '0878262e-6c38-4f3b-953a-2084ef297a33';
const derivativeId = '68db226f-3fcb-4579-ab9e-4932b1d84067';
const productId = '733d0d73-3d39-4a98-ac0a-5c0e521d8998';
function serviceWithQuery(query: ReturnType<typeof vi.fn>) {
const run = vi.fn(async (_input: unknown, work: (tx: { query: typeof query }) => Promise<unknown>) =>
work({ query }));
const service = new WatermarkDerivativeService(
{} as ConstructorParameters<typeof WatermarkDerivativeService>[0],
{ run } as unknown as ConstructorParameters<typeof WatermarkDerivativeService>[1],
{} as ConstructorParameters<typeof WatermarkDerivativeService>[2],
);
return { service, run };
}
describe('水印派生任务', () => {
it('固定当前模板版本,并在同一个事务创建任务、操作和 Outbox', async () => {
const statements: string[] = [];
const query = vi.fn(async (sql: string) => {
statements.push(sql);
if (sql.includes('SELECT id FROM atlas.media_assets')) return { rows: [{ id: sourceAssetId }] };
if (sql.includes('FROM atlas.watermark_templates')) return { rows: [{ id: templateId, current_version: 4 }] };
if (sql.includes('RETURNING id')) return { rows: [{ id: derivativeId }] };
return { rows: [] };
});
const { service, run } = serviceWithQuery(query);
const result = await service.create(workspaceId, 'user-id', sourceAssetId, 'idempotency-key',
{ watermark_template_id: templateId });
expect(result).toMatchObject({ status: 202, body: { data: {
id: derivativeId, watermark_template_id: templateId, watermark_template_version: 4,
} } });
expect(run).toHaveBeenCalledOnce();
expect(statements.some((sql) => sql.includes('INSERT INTO atlas.media_watermark_derivatives'))).toBe(true);
expect(statements.some((sql) => sql.includes('INSERT INTO atlas.operations'))).toBe(true);
expect(statements.some((sql) => sql.includes('INSERT INTO atlas.outbox_events'))).toBe(true);
});
it('拒绝不可用模板且不创建派生任务', async () => {
const query = vi.fn(async (sql: string) => sql.includes('SELECT id FROM atlas.media_assets')
? { rows: [{ id: sourceAssetId }] } : { rows: [] });
const { service } = serviceWithQuery(query);
await expect(service.create(workspaceId, 'user-id', sourceAssetId, 'idempotency-key',
{ watermark_template_id: templateId })).rejects.toMatchObject({ status: 404 });
expect(query.mock.calls.every(([sql]) => !String(sql).includes('INSERT INTO'))).toBe(true);
});
it('将当前工作空间的商品关联到水印结果', async () => {
const query = vi.fn(async (sql: string, values?: unknown[]) => {
if (sql.includes('SELECT id FROM atlas.media_assets')) return { rows: [{ id: sourceAssetId }] };
if (sql.includes('SELECT id FROM atlas.products')) return { rows: [{ id: productId }] };
if (sql.includes('FROM atlas.watermark_templates')) return { rows: [{ id: templateId, current_version: 4 }] };
if (sql.includes('INSERT INTO atlas.media_watermark_derivatives')) {
expect(values).toEqual([productId, sourceAssetId, templateId, 4, 'user-id']);
return { rows: [{ id: derivativeId }] };
}
return { rows: [] };
});
const { service } = serviceWithQuery(query);
await service.create(workspaceId, 'user-id', sourceAssetId, 'key',
{ watermark_template_id: templateId, product_id: productId });
expect(query.mock.calls.some(([sql]) => String(sql).includes('INSERT INTO atlas.media_watermark_derivatives'))).toBe(true);
});
});
+258
View File
@@ -0,0 +1,258 @@
import assert from 'node:assert/strict';
import { randomUUID } from 'node:crypto';
import type { AddressInfo } from 'node:net';
import type { Database } from '@atlas/database';
import type { NestExpressApplication } from '@nestjs/platform-express';
import { afterAll, beforeAll, describe, it } from 'vitest';
import { DATABASE } from '../src/database/database.module.js';
import { createApplication } from '../src/main.js';
let app: NestExpressApplication;
let database: Database;
let baseUrl: string;
let token = '';
let workspaceId = '';
let userId = '';
let platformId = '';
let storeId = '';
let listingId = '';
let runId = '';
async function request(path: string, init: RequestInit = {}) {
const response = await fetch(`${baseUrl}${path}`, {
...init,
headers: { 'Content-Type': 'application/json', ...init.headers },
});
return { response, body: (await response.json()) as Record<string, any> };
}
function auth(extra: Record<string, string> = {}) {
return {
Authorization: `Bearer ${token}`,
'X-Atlas-Platform-Id': platformId,
...extra,
};
}
describe('在线商品 HTTP 契约', () => {
beforeAll(async () => {
Object.assign(process.env, {
NODE_ENV: 'test',
SWAGGER_ENABLED: 'false',
AUTH_REGISTER_IP_LIMIT: '100',
AUTH_REGISTER_EMAIL_LIMIT: '100',
});
const created = await createApplication();
app = created.app;
database = app.get<Database>(DATABASE);
await app.listen(0, '127.0.0.1');
baseUrl = `http://127.0.0.1:${(app.getHttpServer().address() as AddressInfo).port}`;
const registered = await request('/api/auth/register', {
method: 'POST',
body: JSON.stringify({
email: `online-${randomUUID()}@example.test`,
password: 'correct-horse-battery-staple-123',
password_confirmation: 'correct-horse-battery-staple-123',
}),
});
assert.equal(registered.response.status, 201);
token = registered.body.data.access_token;
workspaceId = registered.body.data.workspace.id;
userId = registered.body.data.user.id;
const platforms = await request('/api/platforms', {
headers: { Authorization: `Bearer ${token}` },
});
platformId = platforms.body.data.find(
(platform: { code: string }) => platform.code === 'OZON',
).id;
storeId = randomUUID();
await database.workspaceTransaction(workspaceId, async (tx) => {
await tx.query(
`INSERT INTO atlas.stores (id, workspace_id, platform_id, name, platform_client_id, currency_code, is_default, status, credential_status)
VALUES ($1, $2, $3, '测试门店', $4, 'RUB', true, 'active', 'valid')`,
[storeId, workspaceId, platformId, `online-http-${storeId}`],
);
const run = await tx.query<{ id: string }>(
`INSERT INTO atlas.platform_product_sync_runs
(workspace_id, store_id, platform_id, trigger_source, status, completed_at)
VALUES ($1, $2, $3, 'manual', 'succeeded', now()) RETURNING id`,
[workspaceId, storeId, platformId],
);
runId = run.rows[0].id;
const listing = await tx.query<{ id: string }>(
`INSERT INTO atlas.platform_listings
(workspace_id, store_id, platform_id, remote_product_id, offer_id, remote_sku, name,
currency_code, selling_price, visible, moderation_status, stock_summary,
image_summary, source, last_sync_run_id, last_synced_at, raw_payload)
VALUES ($1, $2, $3, '123456', 'test-offer', '654321', '测试商品',
'RUB', 99, true, 'approved', '{"total":0,"has_data":true}',
'{"url":null,"width_px":null,"height_px":null}', 'external_sync', $4, now(), '{}') RETURNING id`,
[workspaceId, storeId, platformId, runId],
);
listingId = listing.rows[0].id;
await tx.query(
`INSERT INTO atlas.platform_listings
(workspace_id, store_id, platform_id, remote_product_id, offer_id, name,
visible, stock_summary, image_summary, source, last_sync_run_id, last_synced_at,
missing_since, archived_at, raw_payload)
VALUES ($1, $2, $3, '123457', 'archived-offer', '归档商品',
NULL, '{"total":0,"has_data":false}', '{}', 'external_sync', $4,
now(), now() - interval '8 days', now(), '{}')`,
[workspaceId, storeId, platformId, runId],
);
});
});
afterAll(async () => {
if (workspaceId && database) {
await database.workspaceTransaction(workspaceId, async (tx) => {
await tx.query(
'DELETE FROM atlas.platform_listing_sync_snapshots WHERE workspace_id = $1',
[workspaceId],
);
await tx.query(
'DELETE FROM atlas.platform_listings WHERE workspace_id = $1',
[workspaceId],
);
await tx.query(
'DELETE FROM atlas.platform_product_sync_runs WHERE workspace_id = $1',
[workspaceId],
);
await tx.query(
'DELETE FROM atlas.idempotency_requests WHERE workspace_id = $1',
[workspaceId],
);
await tx.query(
'DELETE FROM atlas.outbox_events WHERE workspace_id = $1',
[workspaceId],
);
await tx.query(
'DELETE FROM atlas.store_secrets WHERE workspace_id = $1',
[workspaceId],
);
await tx.query(
'DELETE FROM atlas.stores WHERE workspace_id = $1',
[workspaceId],
);
});
await database.query(
'DELETE FROM atlas.workspace_memberships WHERE user_id = $1',
[userId],
);
await database.query('DELETE FROM atlas.workspaces WHERE id = $1', [
workspaceId,
]);
await database.query('DELETE FROM atlas.users WHERE id = $1', [
userId,
]);
}
if (app) await app.close();
});
it('列表、详情与同步历史按平台店铺返回镜像,不返回原始响应', async () => {
const listed = await request(
`/api/platform-listings?store_id=${storeId}&q=test-offer`,
{ headers: auth() },
);
assert.equal(listed.response.status, 200);
assert.equal(listed.body.data.length, 1);
assert.equal(listed.body.data[0].visible, true);
assert.equal(listed.body.data[0].selling_price, '99.00');
assert.deepEqual(listed.body.data[0].stock_summary, {
total: 0,
has_data: true,
});
assert.equal(listed.body.meta.store.id, storeId);
assert.equal(listed.body.meta.last_sync_status, 'succeeded');
assert.equal('raw_payload' in listed.body.data[0], false);
const detail = await request(`/api/platform-listings/${listingId}`, {
headers: auth(),
});
assert.equal(detail.response.status, 200);
assert.equal(detail.body.data.offer_id, 'test-offer');
const runs = await request(
`/api/stores/${storeId}/platform-product-sync-runs`,
{ headers: auth() },
);
assert.equal(runs.response.status, 200);
assert.equal(runs.body.data[0].id, runId);
const run = await request(`/api/platform-product-sync-runs/${runId}`, {
headers: auth(),
});
assert.equal(run.response.status, 200);
assert.equal(run.body.data.status, 'succeeded');
});
it('筛选未知可见性与缺少平台头的请求按契约处理', async () => {
const unknown = await request(
`/api/platform-listings?store_id=${storeId}&visibility=unknown`,
{ headers: auth() },
);
assert.equal(unknown.response.status, 200);
assert.equal(unknown.body.data.length, 0);
const archived = await request(
`/api/platform-listings?store_id=${storeId}&visibility=unknown&include_archived=true`,
{ headers: auth() },
);
assert.equal(archived.response.status, 200);
assert.equal(archived.body.data.length, 1);
assert.equal(archived.body.data[0].offer_id, 'archived-offer');
const missingHeader = await request('/api/platform-listings', {
headers: { Authorization: `Bearer ${token}` },
});
assert.equal(missingHeader.response.status, 400);
assert.equal(
missingHeader.body.error.code,
'PLATFORM_CONTEXT_REQUIRED',
);
const noCredentials = await request(
`/api/stores/${storeId}/platform-product-sync-runs`,
{
method: 'POST',
headers: auth({ 'Idempotency-Key': randomUUID() }),
body: JSON.stringify({ reason: 'manual_refresh' }),
},
);
assert.equal(noCredentials.response.status, 422);
assert.equal(
noCredentials.body.error.code,
'STORE_CREDENTIAL_UNAVAILABLE',
);
});
it('触发同步写入 Outbox,同一幂等键复用任务,并发请求返回冲突', async () => {
await database.workspaceTransaction(workspaceId, (tx) =>
tx.query(
`INSERT INTO atlas.store_secrets
(workspace_id, store_id, kind, secret_ciphertext, secret_nonce,
encryption_key_version, fingerprint, status)
VALUES ($1, $2, 'seller_api_key', $3, $4, 'v1', $5, 'valid')`,
[workspaceId, storeId, Buffer.alloc(32), Buffer.alloc(12), randomUUID()],
),
);
const key = randomUUID();
const post = (idempotencyKey: string) =>
request(`/api/stores/${storeId}/platform-product-sync-runs`, {
method: 'POST',
headers: auth({ 'Idempotency-Key': idempotencyKey }),
body: JSON.stringify({ reason: 'manual_refresh' }),
});
const created = await post(key);
assert.equal(created.response.status, 202);
assert.equal(created.body.data.sync_run.status, 'queued');
const repeated = await post(key);
assert.equal(repeated.response.status, 202);
assert.equal(repeated.body.data.sync_run.id, created.body.data.sync_run.id);
const concurrent = await post(randomUUID());
assert.equal(concurrent.response.status, 409);
assert.equal(concurrent.body.error.code, 'SYNC_ALREADY_RUNNING');
const events = await database.workspaceTransaction(workspaceId, (tx) =>
tx.query<{ count: string }>(
`SELECT count(*)::text AS count FROM atlas.outbox_events
WHERE aggregate_id = $1 AND event_type = 'platform_product_sync.requested'`,
[created.body.data.sync_run.id],
),
);
assert.equal(events.rows[0].count, '1');
});
});
@@ -0,0 +1,88 @@
import assert from 'node:assert/strict';
import type { Database } from '@atlas/database';
import { describe, it } from 'vitest';
import {
attributeBatchInputSchema,
parseInput,
refreshPolicyInputSchema,
sourceInputSchema,
syncInputSchema,
} from '../src/platform-catalog-admin/platform-catalog-admin.models.js';
import { hasSystemAdminGrant } from '../src/platform-catalog-admin/system-admin.guard.js';
describe('平台目录系统管理员边界', () => {
it('只有未撤销且用户有效的数据库授权才被视为系统管理员', async () => {
const queries: Array<{ sql: string; parameters: unknown[] }> = [];
const database = {
query: async (sql: string, parameters: unknown[]) => {
queries.push({ sql, parameters });
return { rowCount: 1, rows: [{ '?column?': 1 }] };
},
} as unknown as Database;
assert.equal(await hasSystemAdminGrant(database, 'user-1'), true);
assert.deepEqual(queries[0].parameters, ['user-1']);
assert.match(queries[0].sql, /revoked_at IS NULL/);
assert.match(queries[0].sql, /user_row\.status = 'active'/);
});
it('同步请求不能混入其他作用域字段', () => {
assert.throws(() => parseInput(syncInputSchema, {
scope: 'tree',
category_id: '3f7e8462-c23c-4a38-ae86-0f3d78503870',
}));
assert.throws(() => parseInput(syncInputSchema, {
scope: 'dictionary',
attribute_id: 'not-a-uuid',
}));
});
it('同步来源仅接受 Client-Id 与 Api-Key', () => {
assert.deepEqual(parseInput(sourceInputSchema, {
client_id: ' 12345 ',
api_key: ' secret ',
}), { client_id: '12345', api_key: 'secret' });
assert.throws(() => parseInput(sourceInputSchema, {
client_id: '12345',
api_key: 'secret',
workspace_id: 'forbidden',
}));
});
it('刷新策略要求过期天数严格大于新鲜天数', () => {
assert.deepEqual(parseInput(refreshPolicyInputSchema, {
attribute_fresh_days: 14,
attribute_expire_days: 21,
dictionary_fresh_days: 14,
dictionary_expire_days: 21,
}), {
attribute_fresh_days: 14,
attribute_expire_days: 21,
dictionary_fresh_days: 14,
dictionary_expire_days: 21,
});
assert.throws(() => parseInput(refreshPolicyInputSchema, {
attribute_fresh_days: 21,
attribute_expire_days: 21,
dictionary_fresh_days: 14,
dictionary_expire_days: 21,
}));
assert.throws(() => parseInput(refreshPolicyInputSchema, {
attribute_fresh_days: 14,
attribute_expire_days: 21,
dictionary_fresh_days: 0,
dictionary_expire_days: 21,
}));
});
it('属性同步批次只接受三种冻结范围', () => {
for (const mode of ['missing_expired', 'missing_stale_expired', 'all'] as const) {
assert.deepEqual(parseInput(attributeBatchInputSchema, { mode }), { mode });
}
assert.throws(() => parseInput(attributeBatchInputSchema, { mode: 'stale_only' }));
assert.throws(() => parseInput(attributeBatchInputSchema, {
mode: 'all',
category_id: '3f7e8462-c23c-4a38-ae86-0f3d78503870',
}));
});
});
+294
View File
@@ -0,0 +1,294 @@
import assert from 'node:assert/strict';
import { randomUUID } from 'node:crypto';
import type { AddressInfo } from 'node:net';
import { Pool } from 'pg';
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 db: Database;
let migrator: Pool;
let base = '',
token = '',
user = '',
workspace = '',
card = '',
platform = '';
const request = async (
path: string,
method = 'GET',
body?: unknown,
key?: string,
) => {
const response = await fetch(`${base}/api${path}`, {
method,
headers: {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
...(key ? { 'Idempotency-Key': key } : {}),
},
body: body === undefined ? undefined : JSON.stringify(body),
});
return { status: response.status, body: (await response.json()) as any };
};
describe('系统物流费率审核', () => {
beforeAll(async () => {
Object.assign(process.env, {
NODE_ENV: 'test',
SWAGGER_ENABLED: 'false',
AUTH_REGISTER_IP_LIMIT: '100',
AUTH_REGISTER_EMAIL_LIMIT: '100',
});
app = (await createApplication()).app;
db = app.get(DATABASE);
migrator = new Pool({
connectionString: process.env.MIGRATOR_DATABASE_URL,
max: 1,
});
await app.listen(0, '127.0.0.1');
base = `http://127.0.0.1:${(app.getHttpServer().address() as AddressInfo).port}`;
const registered = await request('/auth/register', 'POST', {
email: `pricing-review-${randomUUID()}@example.test`,
password: 'pricing-review-test-password-123',
password_confirmation: 'pricing-review-test-password-123',
});
assert.equal(registered.status, 201, JSON.stringify(registered.body));
token = registered.body.data.access_token;
user = registered.body.data.user.id;
workspace = registered.body.data.workspace.id;
const scope = await db.query<{
platform_id: string;
provider_id: string;
}>(
`SELECT platform.id AS platform_id,provider.id AS provider_id FROM atlas.platforms platform CROSS JOIN atlas.logistics_providers provider WHERE upper(platform.code::text)='OZON' AND upper(provider.code::text)='CEL' LIMIT 1`,
);
platform = scope.rows[0].platform_id;
const provider = scope.rows[0].provider_id;
card = (
await db.query<{ id: string }>(
`INSERT INTO atlas.logistics_rate_cards(provider_id,platform_id,fulfillment,market_code,version,source_name,status,effective_from)
VALUES($1,$2,'rfbs','RU',$3,'自动化审核测试,未来生效','draft',now()) RETURNING id`,
[provider, platform, `qa-${randomUUID()}`],
)
).rows[0].id;
const methods: string[] = [];
for (const code of ['express', 'standard', 'economy']) {
methods.push(
(
await db.query<{ id: string }>(
`INSERT INTO atlas.logistics_transport_methods(rate_card_id,code,name) VALUES($1,$2::text,$2::text) RETURNING id`,
[card, code],
)
).rows[0].id,
);
}
for (const [index, code] of [
'ExtraSmall',
'Budget',
'Small',
'Big',
'PremiumSmall',
'PremiumBig',
].entries()) {
const type = (
await db.query<{ id: string }>(
`INSERT INTO atlas.logistics_product_types(rate_card_id,code,name,merchandise_currency_code,min_merchandise_value,max_merchandise_value,min_actual_weight_kg,max_actual_weight_kg,billing_basis)
VALUES($1,$2::text,$2::text,'CNY',0,10000,0.001,30,'actual_only') RETURNING id`,
[card, code],
)
).rows[0].id;
for (const method of methods.slice(0, [3, 3, 3, 2, 3, 2][index]))
await db.query(
`INSERT INTO atlas.logistics_rate_rules(product_type_id,transport_method_id,fixed_fee_cny,per_billable_kg_fee_cny,status)
VALUES($1,$2,3.12,10,'draft')`,
[type, method],
);
}
});
afterAll(async () => {
if (migrator) {
if (card)
await migrator.query(
`DELETE FROM atlas.logistics_rate_card_reviews WHERE rate_card_id=$1`,
[card],
);
if (user)
await migrator.query(
`DELETE FROM atlas.system_admin_grants WHERE user_id=$1`,
[user],
);
}
if (db) {
if (card)
await db.query(
`DELETE FROM atlas.logistics_rate_cards WHERE id=$1`,
[card],
);
if (user) {
await db.query(
`DELETE FROM atlas.workspace_memberships WHERE user_id=$1`,
[user],
);
await db.query(`DELETE FROM atlas.workspaces WHERE id=$1`, [
workspace,
]);
await db.query(`DELETE FROM atlas.users WHERE id=$1`, [user]);
}
}
if (migrator) await migrator.end();
if (app) await app.close();
});
it('仅系统管理员可读取和启用;六类账单复算、重放与过期版本保护', async () => {
assert.equal(
(
await request(
`/system/pricing/rate-cards?platform_id=${platform}`,
)
).status,
403,
);
await migrator.query(
`INSERT INTO atlas.system_admin_grants(user_id) VALUES($1)`,
[user],
);
const listed = await request(
`/system/pricing/rate-cards?platform_id=${platform}`,
);
assert.equal(listed.status, 200, JSON.stringify(listed.body));
const item = listed.body.data.find((row: any) => row.id === card);
assert.equal(item.status, 'draft');
assert.equal(item.types.length, 6);
assert.equal(item.rules.length, 16);
const input = {
rules_hash: item.rules_hash,
source_reference: 'CEL 真实账单 QA 批次-2026-09',
review_note:
'逐类型核对实际包装、货值、计费重量与物流账单费用;本测试版本仅在未来生效。',
effective_from: new Date(Date.now() + 86400000).toISOString(),
samples: item.types.map((type: any, index: number) => ({
rule_id: item.rules.find(
(rule: any) => rule.product_type_id === type.id,
).id,
invoice_reference: `QA-INVOICE-${index + 1}`,
merchandise_value_cny: '100',
shipping_weight_g: '1000',
length_mm: '100',
width_mm: '100',
height_mm: '100',
billed_logistics_cny: '13.12',
})),
};
const key = randomUUID();
const mismatch = structuredClone(input);
mismatch.samples[0].billed_logistics_cny = '12.00';
const rejected = await request(
`/system/pricing/rate-cards/${card}/reviews`,
'POST',
mismatch,
randomUUID(),
);
assert.equal(rejected.status, 422, JSON.stringify(rejected.body));
const repeatedInvoice = structuredClone(input);
repeatedInvoice.samples[1].invoice_reference =
repeatedInvoice.samples[0].invoice_reference;
assert.equal(
(
await request(
`/system/pricing/rate-cards/${card}/reviews`,
'POST',
repeatedInvoice,
randomUUID(),
)
).status,
422,
);
assert.equal(
(await request(`/system/pricing/rate-cards/${card}`)).body.data
.status,
'draft',
);
const [accepted, concurrentReplay] = await Promise.all([
request(
`/system/pricing/rate-cards/${card}/reviews`,
'POST',
input,
key,
),
request(
`/system/pricing/rate-cards/${card}/reviews`,
'POST',
input,
key,
),
]);
assert.equal(accepted.status, 201, JSON.stringify(accepted.body));
assert.equal(
concurrentReplay.status,
201,
JSON.stringify(concurrentReplay.body),
);
assert.equal(accepted.body.data.status, 'active');
assert.equal(accepted.body.data.review.samples.length, 6);
const replay = await request(
`/system/pricing/rate-cards/${card}/reviews`,
'POST',
input,
key,
);
assert.equal(replay.status, 201, JSON.stringify(replay.body));
assert.equal(
(
await request(
`/system/pricing/rate-cards/${card}/reviews`,
'POST',
mismatch,
key,
)
).status,
409,
);
assert.equal(
(
await request(
`/system/pricing/rate-cards/${card}/reviews`,
'POST',
input,
randomUUID(),
)
).status,
409,
);
const activeRuleCount = await db.query<{ count: string }>(
`SELECT count(*) FROM atlas.logistics_rate_rules r JOIN atlas.logistics_product_types t ON t.id=r.product_type_id WHERE t.rate_card_id=$1 AND r.status='active'`,
[card],
);
assert.equal(Number(activeRuleCount.rows[0].count), 16);
await assert.rejects(() =>
db.query(
`UPDATE atlas.logistics_rate_rules SET fixed_fee_cny=4 WHERE id=$1`,
[item.rules[0].id],
),
);
await assert.rejects(() =>
db.query(
`UPDATE atlas.logistics_rate_cards SET source_name='错误改写' WHERE id=$1`,
[card],
),
);
const draftCard = await db.query<{ id: string }>(
`SELECT id FROM atlas.logistics_rate_cards WHERE version='cel-rfbs-review-2026-09' LIMIT 1`,
);
await assert.rejects(() =>
db.query(
`UPDATE atlas.logistics_product_types SET rate_card_id=$1 WHERE id=$2`,
[draftCard.rows[0].id, item.types[0].id],
),
);
});
});
@@ -0,0 +1,18 @@
import assert from 'node:assert/strict';
import { describe, it } from 'vitest';
import { draftPricingEnabled } from '../src/config/app-config.module.js';
describe('草稿费率配置', () => {
it('开发环境默认开放,可显式关闭;测试环境需显式开启', () => {
assert.equal(draftPricingEnabled('development', 'auto'), true);
assert.equal(draftPricingEnabled('development', 'false'), false);
assert.equal(draftPricingEnabled('test', 'auto'), false);
assert.equal(draftPricingEnabled('test', 'true'), true);
});
it('生产环境默认允许草稿卡,可显式关闭', () => {
assert.equal(draftPricingEnabled('production', 'true'), true);
assert.equal(draftPricingEnabled('production', 'auto'), true);
assert.equal(draftPricingEnabled('production', 'false'), false);
});
});
+334
View File
@@ -0,0 +1,334 @@
import { describe, it, expect } from 'vitest';
import {
calculatePricing,
type LogisticsRule,
type CommissionRule,
} from '../src/pricing/pricing-engine.js';
import { pricingPreviewRequestSchema, pricingInputKey } from '@atlas/contracts';
const id = 'ad877f74-36d0-4ef9-a711-cd904b37912f';
const input = () =>
pricingPreviewRequestSchema.parse({
store_id: id,
platform_product_category_id: id,
fulfillment: 'rfbs',
purchase_cost_cny: '28',
package: {
shipping_weight_g: '380',
length_mm: '220',
width_mm: '160',
height_mm: '90',
status: 'confirmed',
},
pricing: {
rate_card_id: id,
transport_method_ids: [id],
default_transport_method_id: id,
domestic_shipping_and_label_fee_cny: '3',
ad_rate: '0.03',
last_mile_rate: '0.02',
withdrawal_rate: '0.01',
other_rate: '0.02',
desired_markup_rate: '0.5',
list_price_discount_rate: '0.2',
},
});
const rule: LogisticsRule = {
id,
product_type_id: id,
transport_method_id: id,
transport_name: '标准',
product_type_name: 'ExtraSmall',
fixed_fee_cny: '3.12',
per_billable_kg_fee_cny: '36.4',
min_charge_cny: null,
surcharge_rules: [],
merchandise_currency_code: 'CNY',
min_merchandise_value: null,
max_merchandise_value: '135',
min_value_inclusive: true,
max_value_inclusive: true,
min_actual_weight_kg: '0.001',
min_actual_weight_inclusive: true,
max_actual_weight_kg: '0.5',
max_sum_dimensions_cm: '90',
max_longest_side_cm: '60',
max_second_longest_side_cm: null,
billing_basis: 'actual_only',
volume_divisor: null,
volume_condition_sum_gt_cm: null,
volume_round_scale: null,
billing_round_scale: null,
max_volume_weight_kg: null,
volume_limit_round_scale: null,
};
const commission: CommissionRule = {
id,
currency_code: 'RUB',
min_price: null,
max_price: null,
min_inclusive: true,
max_inclusive: true,
commission_rate: '0.12',
version: 'test',
};
describe('十进制定价引擎', () => {
it('默认方案折扣率为 50% 时,划线价是实际售价的两倍', () => {
const i = input();
i.pricing.list_price_discount_rate = '0.5';
for (const currency of ['CNY', 'RUB']) {
const option = calculatePricing(i, currency, '12.3', [rule], [commission])
.options[0];
expect(Number(option.list_price)).toBe(Number(option.selling_price) * 2);
}
});
it('目标加价与明细一致,CNY / RUB 都按最终币种舍入', () => {
const c = calculatePricing(input(), 'CNY', '12.3', [rule], [commission])
.options[0];
expect(c.selling_price).toBe('77.44');
expect(c.profit_cny).toBe('14.00');
expect(c.list_price).toBe('96.80');
expect(c.fees.logistics_cny).toBe('16.9520');
const rub = calculatePricing(
input(),
'RUB',
'12.3',
[rule],
[commission],
).options[0];
expect(rub.selling_price).toBe('952.52');
expect(rub.can_confirm).toBe(true);
});
it('固定售价保持原价并识别亏损', () => {
const i = input();
i.pricing.mode = 'fixed_sale';
i.pricing.selling_price = '10';
const o = calculatePricing(i, 'CNY', '12.3', [rule], [commission])
.options[0];
expect(o.selling_price).toBe('10.00');
expect(o.can_confirm).toBe(false);
expect(Number(o.profit_cny)).toBeLessThan(0);
});
it('零佣金仍生成正式报价,平台佣金费用为零', () => {
const o = calculatePricing(
input(),
'RUB',
'12.3',
[rule],
[{ ...commission, commission_rate: '0.000000' }],
).options[0];
expect(o.commission_rate).toBe('0.000000');
expect(o.fees.commission_cny).toBe('0.0000');
});
it('实际重、尺寸和开区间边界精确生效', () => {
const i = input();
i.package.shipping_weight_g = '500';
expect(
calculatePricing(i, 'CNY', '12.3', [rule], [commission]).options,
).toHaveLength(1);
i.package.shipping_weight_g = '500.001';
expect(
calculatePricing(i, 'CNY', '12.3', [rule], [commission]).options,
).toHaveLength(0);
i.package.shipping_weight_g = '380';
expect(
calculatePricing(
i,
'CNY',
'12.3',
[
{
...rule,
min_actual_weight_kg: '0.380',
min_actual_weight_inclusive: false,
},
],
[commission],
).options,
).toHaveLength(0);
});
it('先舍入售价再检查货值及佣金档,不跨档蒙混', () => {
const i = input();
expect(
calculatePricing(
i,
'CNY',
'12.3',
[{ ...rule, max_merchandise_value: '77.439' }],
[commission],
).options,
).toHaveLength(0);
expect(
calculatePricing(
i,
'CNY',
'12.3',
[rule],
[{ ...commission, max_price: '952.51' }],
).options,
).toHaveLength(0);
});
it('总费率达到 100% 拒绝,未知附加费不能漏算', () => {
const i = input();
i.pricing.ad_rate = '0.9';
expect(
calculatePricing(i, 'CNY', '12.3', [rule], [commission]).options,
).toHaveLength(0);
expect(
calculatePricing(
input(),
'CNY',
'12.3',
[{ ...rule, surcharge_rules: [{ unknown: true }] }],
[commission],
).options,
).toHaveLength(0);
});
it('体积重按指定位数进位并检查进位后的上限', () => {
const i = input();
i.package = {
shipping_weight_g: '5000',
length_mm: '500',
width_mm: '400',
height_mm: '301',
status: 'confirmed',
};
const volume = {
...rule,
max_actual_weight_kg: '30',
max_sum_dimensions_cm: '310',
billing_basis: 'max_actual_volume',
volume_divisor: '12000',
volume_round_scale: 3,
max_volume_weight_kg: '6',
volume_limit_round_scale: 0,
};
expect(
calculatePricing(
i,
'CNY',
'12.3',
[{ ...volume, max_merchandise_value: null }],
[commission],
).options[0].billable_weight_kg,
).toBe('5.017');
expect(
calculatePricing(
i,
'CNY',
'12.3',
[{ ...volume, max_volume_weight_kg: '5' }],
[commission],
).options,
).toHaveLength(0);
});
it('默认运输失效时不偷偷选择其他方式', () => {
const i = input();
i.pricing.default_transport_method_id =
'8d6df67c-1fa3-45b9-9766-d4f5c813b903';
expect(
calculatePricing(i, 'CNY', '12.3', [rule], [commission])
.recommended_option_id,
).toBeNull();
});
it('格式化金额与 JSONB 字段重排不会使同一输入失效', () => {
const original = input();
const reordered = JSON.parse(
JSON.stringify(original, (_key, value) =>
value && typeof value === 'object' && !Array.isArray(value)
? Object.fromEntries(Object.entries(value).reverse())
: value,
),
);
reordered.purchase_cost_cny = '28.00';
reordered.package.shipping_weight_g = '380.000';
reordered.pricing.ad_rate = '0.030000';
expect(pricingInputKey(original)).toBe(pricingInputKey(reordered));
reordered.package.length_mm = '221';
expect(pricingInputKey(original)).not.toBe(pricingInputKey(reordered));
});
it('显示利润舍入为零时,真实微亏仍禁止确认', () => {
const i = input();
i.pricing.mode = 'fixed_sale';
i.pricing.selling_price = '59.94';
const option = calculatePricing(
i,
'CNY',
'12.3',
[{ ...rule, fixed_fee_cny: '3.121' }],
[commission],
).options[0];
expect(option.profit_cny).toBe('-0.00');
expect(option.can_confirm).toBe(false);
});
});
describe('导入佣金的卢布分档边界', () => {
const bands: CommissionRule[] = [
{
...commission,
min_price: '0',
max_price: '1500',
min_inclusive: false,
commission_rate: '0.12',
},
{
...commission,
min_price: '1500',
max_price: '5000',
min_inclusive: false,
commission_rate: '0.14',
},
{
...commission,
min_price: '5000',
max_price: null,
min_inclusive: false,
commission_rate: '0.205',
},
];
it.each([
['1500', '0.12'],
['1500.01', '0.14'],
['5000', '0.14'],
['5000.01', '0.205'],
])('售价 %s 只命中对应佣金档', (price, rate) => {
const i = input();
i.pricing.mode = 'fixed_sale';
i.pricing.selling_price = price;
const result = calculatePricing(
i,
'RUB',
'12.3',
[{ ...rule, max_merchandise_value: null }],
bands,
);
expect(result.options).toHaveLength(1);
expect(Number(result.options[0].commission_rate)).toBe(Number(rate));
});
it('人民币售价换算卢布后匹配费率', () => {
const i = input();
i.pricing.mode = 'fixed_sale';
i.pricing.selling_price = '150';
const result = calculatePricing(
i,
'CNY',
'10',
[{ ...rule, max_merchandise_value: null }],
bands,
);
expect(result.options).toHaveLength(1);
expect(Number(result.options[0].commission_rate)).toBe(0.12);
i.pricing.selling_price = '150.01';
expect(
Number(
calculatePricing(
i,
'CNY',
'10',
[{ ...rule, max_merchandise_value: null }],
bands,
).options[0].commission_rate,
),
).toBe(0.14);
});
});
+725
View File
@@ -0,0 +1,725 @@
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 type {
PricingPreviewRequest,
ProductEditorSnapshot,
SaveProductEditorDraftRequest,
} from '@atlas/contracts';
import { pricingPreviewRequestSchema } from '@atlas/contracts';
import { beforeAll, afterAll, it, describe } from 'vitest';
import { DATABASE } from '../src/database/database.module.js';
import { createApplication } from '../src/main.js';
let app: NestExpressApplication,
db: Database,
base = '',
workspace = '',
user = '',
token = '',
platform = '',
store = '',
category = '',
product = '',
profile = '',
provider = '',
card = '',
transport = '',
variant = '';
async function request(
path: string,
method = 'GET',
body?: unknown,
extra: Record<string, string> = {},
) {
const r = await fetch(base + '/api' + path, {
method,
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
...(method !== 'GET' ? { 'Idempotency-Key': randomUUID() } : {}),
...extra,
},
body: body === undefined ? undefined : JSON.stringify(body),
});
const data = (await r.json()) as any;
return { status: r.status, ...data };
}
async function read(): Promise<ProductEditorSnapshot> {
const r = await request(`/products/${product}/editor`, 'GET', undefined, {
'X-Atlas-Platform-Id': platform,
});
assert.equal(r.status, 200, JSON.stringify(r));
return r.data;
}
function draft(s: ProductEditorSnapshot): SaveProductEditorDraftRequest {
return {
ai_source_material: s.source_material,
ai_source_overridden_fields: s.source_material_state.overridden_fields,
common: {
...s.product.common,
default_physical_spec: s.product.default_physical_spec,
default_package_spec: {
shipping_weight_g: '380',
length_mm: '220',
width_mm: '160',
height_mm: '90',
status: 'confirmed',
},
},
profile: {
target_store_id: store,
fulfillment: 'rfbs',
title: s.profile.title,
description: s.profile.description,
tags: s.profile.tags,
platform_product_category_id: category,
category_source: 'manual',
},
variants: s.variants.map((v) => ({
id: v.id,
client_key: null,
display_name: v.display_name,
source_sku: v.source_sku,
purchase_cost_cny: '28.00',
specification_values: v.specification_values,
title: v.title,
tags: v.tags,
description: v.description,
physical_spec: v.physical_spec,
package_spec: v.package_spec,
offer_id: v.listing.offer_id,
media_asset_ids: [],
pricing_draft: v.listing.pricing_draft ?? null,
})),
};
}
function quote(
variantId: string | null = variant,
clientKey: string | null = null,
): PricingPreviewRequest {
return pricingPreviewRequestSchema.parse({
store_id: store,
platform_product_category_id: category,
fulfillment: 'rfbs',
binding: {
profile_id: profile,
variant_id: variantId,
client_key: clientKey,
},
purchase_cost_cny: '28',
package: {
shipping_weight_g: '380',
length_mm: '220',
width_mm: '160',
height_mm: '90',
status: 'confirmed',
},
pricing: {
rate_card_id: card,
transport_method_ids: [transport],
default_transport_method_id: transport,
domestic_shipping_and_label_fee_cny: '3',
ad_rate: '0.03',
last_mile_rate: '0.02',
withdrawal_rate: '0.01',
other_rate: '0.02',
desired_markup_rate: '0.5',
list_price_discount_rate: '0.2',
temporary_commission_rate: '0.12',
fx: {
mode: 'one_time_override',
rate: '12.3',
reason: '仅集成测试',
},
},
});
}
async function save(
s: ProductEditorSnapshot,
d: SaveProductEditorDraftRequest,
key = randomUUID(),
) {
return request(`/products/${product}/editor-draft`, 'PUT', d, {
'X-Atlas-Platform-Id': platform,
'If-Match': `"${s.product.revision}:${s.profile.revision}"`,
'Idempotency-Key': key,
});
}
describe('SKU 试算与原子确认 HTTP / PostgreSQL', () => {
beforeAll(async () => {
Object.assign(process.env, {
NODE_ENV: 'test',
PRICING_ALLOW_DRAFT_RATES: 'true',
SWAGGER_ENABLED: 'false',
AUTH_REGISTER_IP_LIMIT: '100',
AUTH_REGISTER_EMAIL_LIMIT: '100',
});
app = (await createApplication()).app;
db = app.get(DATABASE);
await app.listen(0, '127.0.0.1');
base = `http://127.0.0.1:${(app.getHttpServer().address() as AddressInfo).port}`;
const reg = await request('/auth/register', 'POST', {
email: `pricing-${randomUUID()}@example.test`,
password: 'pricing-test-password-123',
password_confirmation: 'pricing-test-password-123',
});
assert.equal(reg.status, 201, JSON.stringify(reg));
workspace = reg.data.workspace.id;
user = reg.data.user.id;
token = reg.data.access_token;
platform = (await request('/platforms')).data[0].id;
await db.workspaceTransaction(workspace, async (tx) => {
store = (
await tx.query<{ id: string }>(
`INSERT INTO atlas.stores(workspace_id,platform_id,name,platform_client_id,currency_code,is_default,status,credential_status) VALUES($1,$2,'定价集成测试',$3,'CNY',true,'active','valid') RETURNING id`,
[workspace, platform, randomUUID()],
)
).rows[0].id;
category = (
await tx.query<{ id: string }>(
`INSERT INTO atlas.platform_product_categories(platform_id,external_category_id,external_type_id,name,path) VALUES($1,$2,'test','测试类目','定价测试') RETURNING id`,
[platform, randomUUID()],
)
).rows[0].id;
await tx.query(
`INSERT INTO atlas.platform_catalog_resource_states(platform_id,scope,scope_key,category_id,last_success_at,is_complete) VALUES($1,'attributes','attributes:' || $2::uuid,$2,now(),true)`,
[platform, category],
);
provider = (
await tx.query<{ id: string }>(
`INSERT INTO atlas.logistics_providers(code,name) VALUES($1,'仅自动化测试') RETURNING id`,
[randomUUID()],
)
).rows[0].id;
card = (
await tx.query<{ id: string }>(
`INSERT INTO atlas.logistics_rate_cards(provider_id,platform_id,fulfillment,version,source_name,status,effective_from,reviewed_at,reviewed_by_user_id) VALUES($1,$2,'rfbs','test-1','测试规则,不用于业务','active',now()-interval '1 day',now(),$3) RETURNING id`,
[provider, platform, user],
)
).rows[0].id;
transport = (
await tx.query<{ id: string }>(
`INSERT INTO atlas.logistics_transport_methods(rate_card_id,code,name) VALUES($1,'standard','标准测试') RETURNING id`,
[card],
)
).rows[0].id;
const type = (
await tx.query<{ id: string }>(
`INSERT INTO atlas.logistics_product_types(rate_card_id,code,name,merchandise_currency_code,max_merchandise_value,max_actual_weight_kg,max_sum_dimensions_cm,max_longest_side_cm,billing_basis) VALUES($1,'ExtraSmall','小包测试','CNY',135,0.5,90,60,'actual_only') RETURNING id`,
[card],
)
).rows[0].id;
await tx.query(
`INSERT INTO atlas.logistics_rate_rules(product_type_id,transport_method_id,fixed_fee_cny,per_billable_kg_fee_cny) VALUES($1,$2,3.12,36.4)`,
[type, transport],
);
});
const collection = await request('/collection-items', 'POST', {
title: '定价集成测试商品',
});
assert.equal(collection.status, 201);
product = collection.data.product_id;
const context = await request(
`/collection-items/${collection.data.collection_item.id}/editor-contexts`,
'POST',
undefined,
{ 'X-Atlas-Platform-Id': platform },
);
assert.equal(context.status, 201, JSON.stringify(context));
profile = context.data.profile_id;
variant = (await read()).variants[0].id;
});
afterAll(async () => {
if (db && workspace) {
await db.workspaceTransaction(workspace, async (tx) => {
// 仅清理本测试创建的租户,按外键依赖顺序删除。
for (const table of [
'idempotency_requests',
'pricing_previews',
'product_variant_listing_media',
'product_variant_platform_listings',
'pricing_quote_options',
'pricing_quotes',
'pricing_policy_transport_methods',
'pricing_policies',
'product_unmapped_attributes',
'product_attributes',
'product_variants',
'product_platform_profiles',
'products',
'collection_items',
'outbox_events',
'operations',
'stores',
])
await tx.query(
`DELETE FROM atlas.${table} WHERE workspace_id=$1`,
[workspace],
);
});
if (card)
await db.query(
'DELETE FROM atlas.logistics_rate_cards WHERE id=$1',
[card],
);
if (provider)
await db.query(
'DELETE FROM atlas.logistics_providers WHERE id=$1',
[provider],
);
if (category) {
await db.query(
'DELETE FROM atlas.platform_catalog_resource_states WHERE category_id=$1',
[category],
);
await db.query(
'DELETE FROM atlas.platform_product_categories WHERE id=$1',
[category],
);
}
await db.query(
'DELETE FROM atlas.workspace_memberships WHERE user_id=$1',
[user],
);
await db.query('DELETE FROM atlas.workspaces WHERE id=$1', [
workspace,
]);
await db.query('DELETE FROM atlas.users WHERE id=$1', [user]);
}
if (app) await app.close();
});
it('目录只返回审核规则,预览幂等且独立试算不写商品', async () => {
const catalog = await request(`/pricing/catalog?store_id=${store}`);
assert.equal(catalog.status, 200, JSON.stringify(catalog));
assert.ok(catalog.data.rate_cards.some((c: any) => c.id === card));
const categories = await request(
`/pricing/categories?store_id=${store}&q=定价测试`,
);
assert.equal(categories.status, 200, JSON.stringify(categories));
const input = quote(),
key = randomUUID(),
a = await request('/pricing/quote-previews', 'POST', input, {
'Idempotency-Key': key,
}),
b = await request('/pricing/quote-previews', 'POST', input, {
'Idempotency-Key': key,
});
assert.equal(a.status, 201, JSON.stringify(a));
assert.deepEqual(b.data, a.data);
assert.equal(a.data.options[0].selling_price, '77.44');
assert.equal((await read()).variants[0].listing.selling_price, null);
});
it('原子确认、重放、重读与同输入再保存保持快照', async () => {
const s = await read(),
d = draft(s),
input = quote(),
p = await request('/pricing/quote-previews', 'POST', input);
d.variants[0].pricing_draft = input.pricing;
d.variants[0].pricing_selection = {
preview_id: p.data.preview_id,
option_id: p.data.recommended_option_id,
input_hash: p.data.input_hash,
};
const key = randomUUID(),
saved = await save(s, d, key);
assert.equal(saved.status, 200, JSON.stringify(saved));
assert.deepEqual((await save(s, d, key)).data, saved.data);
const after = await read();
assert.equal(after.variants[0].listing.pricing_valid, true);
assert.equal(
after.variants[0].listing.pricing_result?.profit_cny,
'14.00',
);
assert.equal(Number(after.variants[0].listing.selling_price), 77.44);
const again = await save(after, draft(after));
assert.equal(again.status, 200, JSON.stringify(again));
assert.equal((await read()).variants[0].listing.pricing_valid, true);
});
it('输入/有效期变化返回 SKU 错误,整笔商品保存回滚;无报价允许保存', async () => {
const s = await read(),
d = draft(s),
input = quote(),
p = await request('/pricing/quote-previews', 'POST', input);
d.common.source_title = '不应被保存';
d.variants[0].pricing_draft = input.pricing;
d.variants[0].purchase_cost_cny = '29';
d.variants[0].pricing_selection = {
preview_id: p.data.preview_id,
option_id: p.data.recommended_option_id,
input_hash: p.data.input_hash,
};
const failed = await save(s, d);
assert.equal(failed.status, 409, JSON.stringify(failed));
assert.equal(failed.error.code, 'PRICING_PREVIEW_STALE');
assert.equal((await read()).product.revision, s.product.revision);
d.variants[0].purchase_cost_cny = '28';
await db.workspaceTransaction(workspace, (tx) =>
tx.query(
`UPDATE atlas.pricing_previews SET expires_at=now()-interval '1 second' WHERE id=$1`,
[p.data.preview_id],
),
);
assert.equal((await save(s, d)).status, 409);
d.variants[0].pricing_selection = null;
d.variants[0].purchase_cost_cny = '29';
assert.equal((await save(s, d)).status, 200);
assert.equal((await read()).variants[0].listing.pricing_valid, false);
});
it('新 SKU client_key 与真实 ID 在同一事务映射', async () => {
const s = await read(),
d = draft(s),
input = quote(null, 'new-pricing'),
p = await request('/pricing/quote-previews', 'POST', input);
d.variants.push({
...d.variants[0],
id: null,
client_key: 'new-pricing',
display_name: '新报价 SKU',
pricing_draft: input.pricing,
pricing_selection: {
preview_id: p.data.preview_id,
option_id: p.data.recommended_option_id,
input_hash: p.data.input_hash,
},
});
const saved = await save(s, d);
assert.equal(saved.status, 200, JSON.stringify(saved));
const after = await read(),
newId = saved.data.created_variants[0].id;
assert.equal(
after.variants.find((v) => v.id === newId)?.listing.pricing_valid,
true,
);
});
it('其他租户不能读取预览或使用门店;报价复合外键拒绝跨 SKU 引用', async () => {
const outsider = randomUUID();
await db.workspaceTransaction(outsider, async (tx) => {
assert.equal(
(
await tx.query(
'SELECT id FROM atlas.pricing_previews WHERE store_id=$1',
[store],
)
).rowCount,
0,
);
assert.equal(
(
await tx.query(
'SELECT id FROM atlas.pricing_quotes WHERE store_id=$1',
[store],
)
).rowCount,
0,
);
});
const s = await read();
await assert.rejects(() =>
db.workspaceTransaction(workspace, async (tx) => {
await tx.query(
`UPDATE atlas.product_variant_platform_listings SET pricing_quote_id=(SELECT pricing_quote_id FROM atlas.product_variant_platform_listings WHERE product_variant_id=$1) WHERE product_variant_id=$2`,
[s.variants[1].id, s.variants[0].id],
);
}),
);
});
it('方案 CRUD、版本冲突、默认与归档不改变已确认报价', async () => {
const body = {
name: '测试默认方案',
parameters: {
...quote().pricing,
fx: { mode: 'policy_fixed', rate: '12.3' },
},
allow_commission_override: true,
};
const c = await request(
`/stores/${store}/pricing-policies`,
'POST',
body,
);
assert.equal(c.status, 201, JSON.stringify(c));
const id = c.data.id;
assert.equal(
(await request(`/pricing-policies/${id}/set-default`, 'POST'))
.status,
200,
);
assert.equal(
(
await request(`/pricing-policies/${id}`, 'PATCH', body, {
'If-Match': '"1"',
})
).status,
409,
);
const before = await read();
assert.equal(
(await request(`/pricing-policies/${id}`, 'DELETE')).status,
200,
);
assert.deepEqual(
(await read()).variants[1].listing.pricing_result,
before.variants[1].listing.pricing_result,
);
});
it('规则更新、独立预览和其他 SKU 预览不能被旧确认意图接受', async () => {
const state = await read(),
payload = draft(state),
input = quote();
const preview = await request('/pricing/quote-previews', 'POST', input);
payload.variants[0].pricing_draft = input.pricing;
payload.variants[0].pricing_selection = {
preview_id: preview.data.preview_id,
option_id: preview.data.recommended_option_id,
input_hash: preview.data.input_hash,
};
await db.query(
`UPDATE atlas.logistics_rate_cards SET version='test-2',updated_at=now() WHERE id=$1`,
[card],
);
assert.equal(
(await save(state, payload)).error.code,
'PRICING_PREVIEW_STALE',
);
const standalone = await request('/pricing/quote-previews', 'POST', {
...input,
binding: null,
});
payload.variants[0].pricing_selection = {
preview_id: standalone.data.preview_id,
option_id: standalone.data.recommended_option_id,
input_hash: standalone.data.input_hash,
};
assert.equal((await save(state, payload)).status, 409);
const other = await request('/pricing/quote-previews', 'POST', {
...input,
binding: { ...input.binding, variant_id: state.variants[1].id },
});
payload.variants[0].pricing_selection = {
preview_id: other.data.preview_id,
option_id: other.data.recommended_option_id,
input_hash: other.data.input_hash,
};
assert.equal((await save(state, payload)).status, 409);
assert.equal((await read()).product.revision, state.product.revision);
});
it('确认快照不可更新;历史预览过期不使确认失效', async () => {
const before = await read();
await assert.rejects(() =>
db.workspaceTransaction(workspace, (tx) =>
tx.query(
`UPDATE atlas.pricing_quotes SET purchase_cost_cny=0 WHERE workspace_id=$1 AND status='confirmed'`,
[workspace],
),
),
);
await assert.rejects(() =>
db.workspaceTransaction(workspace, (tx) =>
tx.query(
`UPDATE atlas.pricing_quote_options SET profit_rub=0 WHERE workspace_id=$1`,
[workspace],
),
),
);
await db.workspaceTransaction(workspace, (tx) =>
tx.query(
`UPDATE atlas.pricing_previews SET expires_at=now()-interval '1 day' WHERE workspace_id=$1`,
[workspace],
),
);
assert.deepEqual(
(await read()).variants[1].listing,
before.variants[1].listing,
);
});
it('没有当前报价的草稿保存成功,但发布校验给出定价问题', async () => {
const state = await read(),
payload = draft(state);
payload.variants[0].purchase_cost_cny = '41.00';
assert.equal((await save(state, payload)).status, 200);
const validation = await request(
`/product-platform-profiles/${profile}/validation`,
'GET',
undefined,
{ 'X-Atlas-Platform-Id': platform },
);
assert.equal(validation.status, 200, JSON.stringify(validation));
assert.ok(
validation.data.variants[0].issues.some(
(i: { code: string }) => i.code === 'PRICING_REQUIRED',
),
);
});
it('并发商品保存只有一个版本成功', async () => {
const state = await read(),
a = draft(state),
b = draft(state);
a.common.source_title = '并发 A';
b.common.source_title = '并发 B';
const results = await Promise.all([save(state, a), save(state, b)]);
assert.deepEqual(results.map((r) => r.status).sort(), [200, 409]);
});
it('未审核费率、缺失正式佣金以及亏损候选不会变成发布报价', async () => {
await db.query(
`UPDATE atlas.logistics_rate_cards SET reviewed_at=NULL WHERE id=$1`,
[card],
);
assert.equal(
(await request('/pricing/quote-previews', 'POST', quote())).error
.code,
'PRICING_RULES_UNAVAILABLE',
);
await db.query(
`UPDATE atlas.logistics_rate_cards SET reviewed_at=now() WHERE id=$1`,
[card],
);
const input = quote();
input.pricing.temporary_commission_rate = null;
const withoutCommission = await request(
'/pricing/quote-previews',
'POST',
input,
);
assert.equal(withoutCommission.error.code, 'PRICING_COMMISSION_UNAVAILABLE');
assert.match(withoutCommission.error.message, /本次自定义参数/);
input.pricing.temporary_commission_rate = '0.12';
input.pricing.mode = 'fixed_sale';
input.pricing.selling_price = '10';
const p = await request('/pricing/quote-previews', 'POST', input);
assert.equal(p.status, 201);
assert.equal(p.data.options[0].can_confirm, false);
const state = await read(),
payload = draft(state);
payload.variants[0].pricing_draft = input.pricing;
payload.variants[0].pricing_selection = {
preview_id: p.data.preview_id,
option_id: p.data.options[0].id,
input_hash: p.data.input_hash,
};
assert.equal((await save(state, payload)).status, 409);
});
it('草稿费率可试算、确认且不阻断首次上架预检', async () => {
await db.query(
`UPDATE atlas.logistics_rate_cards SET status='draft',reviewed_at=NULL,reviewed_by_user_id=NULL WHERE id=$1`,
[card],
);
await db.query(
`UPDATE atlas.logistics_rate_rules SET status='draft' WHERE product_type_id IN (SELECT id FROM atlas.logistics_product_types WHERE rate_card_id=$1)`,
[card],
);
const catalog = await request(`/pricing/catalog?store_id=${store}`);
assert.equal(catalog.status, 200, JSON.stringify(catalog));
assert.equal(
catalog.data.rate_cards.find((item: { id: string }) => item.id === card)
?.development_only,
true,
);
const input = quote();
input.purchase_cost_cny = '41.00';
const preview = await request('/pricing/quote-previews', 'POST', input);
assert.equal(preview.status, 201, JSON.stringify(preview));
assert.equal(preview.data.context.development_only, true);
const state = await read();
const payload = draft(state);
payload.variants[0].purchase_cost_cny = '41.00';
payload.variants[0].pricing_draft = input.pricing;
payload.variants[0].pricing_selection = {
preview_id: preview.data.preview_id,
option_id: preview.data.recommended_option_id,
input_hash: preview.data.input_hash,
};
const saved = await save(state, payload);
assert.equal(saved.status, 200, JSON.stringify(saved));
const validation = await request(
`/product-platform-profiles/${profile}/validation`,
'GET',
undefined,
{ 'X-Atlas-Platform-Id': platform },
);
assert.equal(validation.status, 200, JSON.stringify(validation));
assert.equal(
validation.data.variants[0].issues.some(
(issue: { code: string }) =>
issue.code === 'PRICING_RATE_REVIEW_REQUIRED' ||
issue.code === 'PRICING_REQUIRED',
),
false,
);
const rateCard = await db.query<{ status: string; reviewed_at: Date | null }>(
'SELECT status,reviewed_at FROM atlas.logistics_rate_cards WHERE id=$1',
[card],
);
assert.equal(rateCard.rows[0].status, 'draft');
assert.equal(rateCard.rows[0].reviewed_at, null);
});
it('已上架 SKU 可确认本地新价格,保留首次导入身份且不触发平台任务', async () => {
const otherStore = randomUUID();
await db.workspaceTransaction(workspace, async (tx) => {
await tx.query('UPDATE atlas.products SET model=$2 WHERE id=$1', [product, 'LOCAL-RECORD-MODEL']);
await tx.query(
`UPDATE atlas.product_variant_platform_listings SET status='imported',offer_id='LOCAL-RECORD-SKU',platform_product_id='123456',first_imported_at=now() WHERE profile_id=$1 AND product_variant_id=$2`,
[profile, variant],
);
await tx.query(
`INSERT INTO atlas.stores(id,workspace_id,platform_id,name,platform_client_id,currency_code,status,credential_status) VALUES($1,$2,$3,'未来目标店铺',$4,'RUB','active','valid')`,
[otherStore, workspace, platform, randomUUID()],
);
await tx.query('UPDATE atlas.product_platform_profiles SET target_store_id=$2 WHERE id=$1', [profile, otherStore]);
});
const facts = () => db.workspaceTransaction(workspace, async (tx) => ({
listing: (await tx.query(
'SELECT status,store_id,offer_id,platform_product_id,first_imported_at,selling_price::text,list_price::text,pricing_quote_id FROM atlas.product_variant_platform_listings WHERE profile_id=$1 AND product_variant_id=$2',
[profile, variant],
)).rows[0],
publishJobs: (await tx.query('SELECT id FROM atlas.product_publish_jobs WHERE product_id=$1', [product])).rows.length,
events: (await tx.query('SELECT id FROM atlas.outbox_events')).rows.length,
}));
const before = await facts();
const state = await read();
assert.equal(state.variants.find((v) => v.id === variant)?.listing.store_id, store);
const input = quote();
input.pricing.mode = 'fixed_sale';
input.pricing.selling_price = '100';
input.pricing.list_price_discount_rate = '0.5';
const wrongStore = await request('/pricing/quote-previews', 'POST', { ...input, store_id: otherStore });
assert.equal(wrongStore.status, 422);
assert.equal(wrongStore.error.code, 'PRICING_VARIANT_STORE_MISMATCH');
const preview = await request('/pricing/quote-previews', 'POST', input);
assert.equal(preview.status, 201, JSON.stringify(preview));
const payload = draft(state);
payload.profile.target_store_id = otherStore;
const changed = payload.variants.find((v) => v.id === variant)!;
changed.pricing_draft = input.pricing;
changed.pricing_selection = {
preview_id: preview.data.preview_id,
option_id: preview.data.recommended_option_id,
input_hash: preview.data.input_hash,
};
changed.purchase_cost_cny = '29.00';
assert.equal((await save(state, payload)).status, 409);
assert.deepEqual(await facts(), before);
changed.purchase_cost_cny = '28.00';
const key = randomUUID();
const saved = await save(state, payload, key);
assert.equal(saved.status, 200, JSON.stringify(saved));
assert.deepEqual((await save(state, payload, key)).data, saved.data);
const after = await facts();
assert.equal(after.listing.selling_price, '100.0000');
assert.equal(after.listing.list_price, '200.0000');
assert.notEqual(after.listing.pricing_quote_id, before.listing.pricing_quote_id);
for (const field of ['status', 'store_id', 'offer_id', 'platform_product_id', 'first_imported_at'])
assert.deepEqual(after.listing[field], before.listing[field]);
assert.equal(after.publishJobs, before.publishJobs);
assert.equal(after.events, before.events);
const reread = await read();
const listing = reread.variants.find((v) => v.id === variant)!.listing;
assert.equal(listing.status, 'imported');
assert.equal(listing.pricing_result?.selling_price, '100.00');
const ordinary = draft(reread);
ordinary.profile.target_store_id = otherStore;
assert.equal((await save(reread, ordinary)).status, 200);
assert.deepEqual((await facts()).listing, after.listing);
});
});
+54
View File
@@ -0,0 +1,54 @@
import { describe, expect, it, vi } from 'vitest';
import { PRODUCT_AI_HINT_MAX_LENGTH, requestProductAiTaskSchema } from '@atlas/contracts';
import type { ProductEditorSnapshot } from '@atlas/contracts';
import { ProductAiService } from '../src/product-editor/product-ai.service.js';
describe('SKU 文案用户要求', () => {
it('将用户要求与 SKU 输入原子固化,并纳入幂等请求,不改写商品字段', async () => {
const hint = requestProductAiTaskSchema.parse({ hint: ' 改为红色款,25cm;标题不要写包邮。 ' }).hint;
let stored: Record<string, unknown> | undefined;
let outbox: unknown;
const statements: string[] = [];
const query = vi.fn(async (sql: string, values: unknown[] = []) => {
statements.push(sql);
if (sql.includes('SELECT product_id')) return { rows: [{ product_id: 'product' }] };
if (sql.includes('SELECT product.revision')) return { rows: [{ product_revision: 3, profile_revision: 4, source_snapshot_revision: 5 }] };
if (sql.includes('INSERT INTO atlas.product_ai_tasks')) {
stored = JSON.parse(values[4] as string);
return { rows: [{ id: 'task' }] };
}
if (sql.includes('INSERT INTO atlas.outbox_events')) outbox = JSON.parse(values[1] as string);
return { rows: [] };
});
const transaction = { query };
const workspaceTransaction = vi.fn(async (_workspace: string, work: (tx: typeof transaction) => Promise<unknown>) => work(transaction));
const run = vi.fn(async (_input: unknown, work: (tx: typeof transaction) => Promise<unknown>) => work(transaction));
const snapshot = {
product: { id: 'product', revision: 3, common: { model: 'model' } },
profile: { id: 'profile', revision: 4, platform_id: 'platform', target_store: { id: 'store' }, category: { id: null }, title: null, tags: [], description: null },
source_material: { source_snapshot_revision: 5, title: '采集蓝色 20cm', description: null },
variants: [{ id: 'sku', display_name: '蓝色款', specification_values: [], listing: { status: 'not_listed', offer_id: 'model-blue' }, tags: [] }],
} as unknown as ProductEditorSnapshot;
const service = new ProductAiService(
{ workspaceTransaction } as unknown as ConstructorParameters<typeof ProductAiService>[0],
{ run } as unknown as ConstructorParameters<typeof ProductAiService>[1],
{ read: vi.fn(async () => snapshot) } as unknown as ConstructorParameters<typeof ProductAiService>[2],
);
const result = await service.create('workspace', 'user', 'profile', 'platform', 'sku', 'key', hint);
expect(result).toMatchObject({ status: 202, body: { data: { ai_task_id: 'task', status: 'queued' } } });
expect(stored).toMatchObject({ hint, variant_id: 'sku', revision: { product: 3, profile: 4, source_snapshot: 5 } });
expect(stored?.source_material).toEqual(snapshot.source_material);
expect(run.mock.calls[0][0]).toMatchObject({ request: { hint, platform_id: 'platform' } });
expect(outbox).toEqual({ task_id: 'task' });
expect(statements.some((sql) => /UPDATE atlas\.(products|product_variants|collection_items)/.test(sql))).toBe(false);
expect(workspaceTransaction.mock.calls.every(([workspace]) => workspace === 'workspace')).toBe(true);
});
it('兼容不传要求,接受 1000 字符并拒绝超限和未知字段', () => {
expect(requestProductAiTaskSchema.parse({})).toEqual({});
expect(requestProductAiTaskSchema.parse({ hint: ' ' })).toEqual({ hint: '' });
expect(requestProductAiTaskSchema.safeParse({ hint: '要'.repeat(PRODUCT_AI_HINT_MAX_LENGTH) }).success).toBe(true);
expect(requestProductAiTaskSchema.safeParse({ hint: '要'.repeat(PRODUCT_AI_HINT_MAX_LENGTH + 1) }).success).toBe(false);
expect(requestProductAiTaskSchema.safeParse({ hint: '红色款', source_override: {} }).success).toBe(false);
});
});
@@ -0,0 +1,44 @@
import assert from 'node:assert/strict';
import { describe, it } from 'vitest';
import { productCatalogCacheStatus } from '../src/product-editor/product-editor.catalog-cache.js';
describe('商品编辑器平台目录新鲜度边界', () => {
const day = 86_400_000;
const now = Date.parse('2026-09-20T00:00:00.000Z');
it('从未成功同步时为 missing', () => {
assert.equal(productCatalogCacheStatus(null, 14, 21, now), 'missing');
});
it('14 天前为 fresh,恰好 14 天进入 stale', () => {
assert.equal(
productCatalogCacheStatus(
new Date(now - 14 * day + 1),
14,
21,
now,
),
'fresh',
);
assert.equal(
productCatalogCacheStatus(new Date(now - 14 * day), 14, 21, now),
'stale',
);
});
it('恰好 21 天仍 stale,严格超过才 expired', () => {
assert.equal(
productCatalogCacheStatus(new Date(now - 21 * day), 14, 21, now),
'stale',
);
assert.equal(
productCatalogCacheStatus(
new Date(now - 21 * day - 1),
14,
21,
now,
),
'expired',
);
});
});
@@ -0,0 +1,62 @@
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, library?: 'upload' | 'import', owner = productId) {
const query = vi.fn(async (sql: string, values?: unknown[]) => {
if (sql.includes('FROM atlas.product_variant_platform_listings'))
return { rows: [{ id: 'listing-id', product_variant_id: variantId }] };
if (sql.includes('FROM atlas.media_assets'))
return { rows: [{ id: assetId, content_type: 'image/jpeg', byte_size: '1200', width_px: 1200, height_px: 1600 }] };
if (sql.includes('FROM atlas.media_upload_intents') || sql.includes('FROM atlas.product_image_imports')) {
expect(values).toEqual([productId, [assetId]]);
expect(sql).toContain('product_id = $1');
const matches = library === 'upload' ? sql.includes('media_upload_intents') : library === 'import' && sql.includes('product_image_imports');
return { rows: matches && owner === productId ? [{ asset_id: assetId }] : [] };
}
if (sql.includes('FROM atlas.media_aspect_ratio_derivatives')) {
expect(values).toEqual([productId, [assetId]]);
return { rows: processed ? [{ asset_id: assetId }] : [] };
}
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],
{ pricingAllowDraftRates: false } as ConstructorParameters<typeof ProductEditorService>[2],
) as unknown as {
persistListingMedia: (tx: ReturnType<typeof mediaTransaction>, value: typeof input) => Promise<void>;
};
it.each(['upload', 'import'] as const)('%s 图片仅允许所属商品保存', async (library) => {
await service.persistListingMedia(mediaTransaction(false, library), input);
await expect(service.persistListingMedia(mediaTransaction(false, library, '其他商品'), input)).rejects.toMatchObject({
status: 422, response: { error: { code: 'LISTING_MEDIA_NOT_SELECTABLE' } },
});
});
it('当前商品的成功处理结果可加入 SKU 并保存', async () => {
const tx = mediaTransaction(true);
await service.persistListingMedia(tx, input);
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' } },
});
});
});
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,83 @@
import { randomUUID } from 'node:crypto';
import { createApplicationDatabase, type Database } from '@atlas/database';
import { describe, expect, it, vi } from 'vitest';
import { ProductImageDeletionService } from '../src/media/product-image-deletion.service.js';
import { IdempotencyService } from '../src/common/idempotency.service.js';
import { runMediaAssetDeletion, markMediaAssetDeletionFailed } from '../../worker/src/media-asset-delete.js';
/** 全部业务实体在结束时回滚,清理对象请求使用替身。 */
describe.skipIf(!process.env.DATABASE_URL)('商品图片永久删除数据库闭环', () => {
it('校验归属和引用,可靠清理可重试且幂等,删除后不能新增引用', async () => {
const db = createApplicationDatabase(process.env.DATABASE_URL!);
const rollback = Symbol('rollback');
try {
await db.transaction(async tx => {
const ws = randomUUID(), foreignWs = randomUUID(), user = randomUUID();
await tx.query("INSERT INTO atlas.users (id, email, password_hash) VALUES ($1, $2, 'test')", [user, `${user}@example.test`]);
for (const id of [ws, foreignWs]) await tx.query("INSERT INTO atlas.workspaces (id,name,slug) VALUES ($1::uuid,'删除测试',$1::text)", [id]);
const setWorkspace = (id: string) => tx.query("SELECT set_config('app.workspace_id', $1, true)", [id]);
await setWorkspace(ws);
const item = randomUUID(), product = randomUUID(), otherProduct = randomUUID(), asset = randomUUID();
await tx.query("INSERT INTO atlas.collection_items (id,workspace_id,source_kind,source_item_key) VALUES ($1::uuid,$2,'manual',$1::text)", [item, ws]);
await tx.query('INSERT INTO atlas.products (id,workspace_id,collection_item_id) VALUES ($1,$2,$3)', [product, ws, item]);
const otherItem = randomUUID();
await tx.query("INSERT INTO atlas.collection_items (id,workspace_id,source_kind,source_item_key) VALUES ($1::uuid,$2,'manual',$1::text)", [otherItem, ws]);
await tx.query('INSERT INTO atlas.products (id,workspace_id,collection_item_id) VALUES ($1,$2,$3)', [otherProduct, ws, otherItem]);
const checksum = 'a'.repeat(64);
await tx.query(`INSERT INTO atlas.media_assets (id,workspace_id,storage_provider,bucket,object_key,content_type,byte_size,checksum_sha256,width_px,height_px)
VALUES ($1,$2,'local_minio','test','test/canonical','image/png',100,$3,800,800)`, [asset, ws, checksum]);
await tx.query(`INSERT INTO atlas.media_upload_intents (workspace_id,product_id,purpose,storage_provider,bucket,object_key,expected_content_type,expected_byte_size,
expected_checksum_sha256,status,asset_id,expires_at,completed_at)
VALUES ($1,$2,'product_image','local_minio','test','test/duplicate','image/png',100,$3,'ready',$4,now()+interval '1 hour',now())`, [ws,product,checksum,asset]);
const scoped = { workspaceTransaction: async (id: string, work: (transaction: typeof tx) => Promise<unknown>) => { await setWorkspace(id); return work(tx); } } as Database;
const service = new ProductImageDeletionService(scoped, new IdempotencyService(scoped, { IDEMPOTENCY_KEY_SECRET: 'delete-test' } as never));
await expect(service.create(ws,user,otherProduct,randomUUID(),{asset_id:asset})).rejects.toMatchObject({status:404});
await tx.query('UPDATE atlas.users SET avatar_asset_id = $1 WHERE id = $2', [asset,user]);
await expect(service.create(ws,user,product,randomUUID(),{asset_id:asset})).rejects.toMatchObject({status:409});
await tx.query('SAVEPOINT in_use_delete');
await expect(tx.query('UPDATE atlas.media_assets SET deleted_at = now(), checksum_sha256 = NULL WHERE id = $1', [asset])).rejects.toMatchObject({code:'23514'});
await tx.query('ROLLBACK TO SAVEPOINT in_use_delete');
await tx.query('UPDATE atlas.users SET avatar_asset_id = NULL WHERE id = $1', [user]);
await tx.query("INSERT INTO atlas.product_image_imports (workspace_id,product_id,source_url,asset_id) VALUES ($1,$2,'https://example.test/shared.png',$3)", [ws,otherProduct,asset]);
await expect(service.create(ws,user,product,randomUUID(),{asset_id:asset})).rejects.toMatchObject({status:409});
await tx.query('DELETE FROM atlas.product_image_imports WHERE product_id = $1', [otherProduct]);
const key = randomUUID();
const result = await service.create(ws,user,product,key,{asset_id:asset});
const deletion = (result.body as {data:{id:string;status:string}}).data;
expect(deletion.status).toBe('queued');
expect((await service.create(ws,user,product,key,{asset_id:asset})).body).toEqual(result.body);
const removed = (await tx.query('SELECT deleted_at,checksum_sha256,deleted_checksum_sha256 FROM atlas.media_assets WHERE id = $1',[asset])).rows[0];
expect(removed.deleted_at).toBeTruthy(); expect(removed.checksum_sha256).toBeNull(); expect(removed.deleted_checksum_sha256).toBe(checksum);
expect((await tx.query("SELECT event_payload FROM atlas.outbox_events WHERE event_type = 'media.asset_delete.requested'")).rows).toHaveLength(1);
await tx.query('SAVEPOINT deleted_reference');
await expect(tx.query("INSERT INTO atlas.product_image_imports (workspace_id,product_id,source_url,asset_id) VALUES ($1,$2,'https://example.test/forbidden.png',$3)",[ws,product,asset])).rejects.toMatchObject({code:'23514'});
await tx.query('ROLLBACK TO SAVEPOINT deleted_reference');
const send = vi.fn().mockRejectedValueOnce(new Error('存储暂不可用')).mockResolvedValue({});
const storage = () => ({ send }) as never;
const job = {eventId:randomUUID(),workspaceId:ws,eventType:'media.asset_delete.requested',payload:{deletion_id:deletion.id}};
await expect(runMediaAssetDeletion(scoped,storage,job)).rejects.toThrow('存储暂不可用');
await markMediaAssetDeletionFailed(scoped,job);
const failed = await service.read(ws,product,deletion.id);
expect(failed.status).toBe('failed'); expect(failed.operation.status).toBe('failed');
await service.retry(ws,user,product,deletion.id,randomUUID());
await runMediaAssetDeletion(scoped,storage,job);
const succeeded = await service.read(ws,product,deletion.id);
expect(succeeded.status).toBe('succeeded'); expect(succeeded.operation.status).toBe('succeeded');
expect(send).toHaveBeenCalledTimes(3);
await runMediaAssetDeletion(scoped,storage,job);
expect(send).toHaveBeenCalledTimes(3);
await tx.query(`INSERT INTO atlas.media_assets (workspace_id,storage_provider,bucket,object_key,content_type,byte_size,checksum_sha256)
VALUES ($1,'local_minio','test','test/new-object','image/png',100,$2)`,[ws,checksum]);
const transfer = randomUUID();
await tx.query(`INSERT INTO atlas.collection_asset_transfers (id,workspace_id,collection_item_id,product_id,role,source_url)
VALUES ($1,$2,$3,$4,'source_main','https://example.test/source.png')`,[transfer,ws,item,product]);
const sourceDelete = await service.create(ws,user,product,randomUUID(),{collection_image_id:transfer});
expect((sourceDelete.body as {data:{status:string}}).data.status).toBe('succeeded');
expect((await tx.query('SELECT status,deleted_at FROM atlas.collection_asset_transfers WHERE id = $1',[transfer])).rows[0]).toMatchObject({status:'cancelled'});
await expect(service.read(foreignWs,product,deletion.id)).rejects.toMatchObject({status:404});
throw rollback;
});
} catch (error) { if (error !== rollback) throw error; }
finally { await db.close(); }
});
});
@@ -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,101 @@
import { createHash, randomUUID } from 'node:crypto';
import { createApplicationDatabase, type Database, type DatabaseTransaction } from '@atlas/database';
import { describe, expect, it, vi } from 'vitest';
import { IdempotencyService } from '../src/common/idempotency.service.js';
import { MediaService } from '../src/media/media.service.js';
const bytes = Buffer.from('validated-product-image');
vi.mock('../src/media/product-image-download.js', () => ({
downloadProductImage: vi.fn(async () => ({ bytes, contentType: 'image/png', width: 800, height: 800 })),
}));
/** 归属验证只在回滚事务中创建数据,存储和公网下载由测试替身承担。 */
describe.skipIf(!process.env.DATABASE_URL)('商品上传与网络图片数据库隔离', () => {
it('候选只属于当前商品,重复素材可独立绑定,FK 与 RLS 拒绝越权', async () => {
const db = createApplicationDatabase(process.env.DATABASE_URL!);
const rollback = Symbol('rollback');
try {
await db.transaction(async (tx) => {
const workspaceId = randomUUID(), otherWorkspace = randomUUID(), userId = randomUUID();
await tx.query("INSERT INTO atlas.users (id, email, password_hash) VALUES ($1, $2, 'test-only')", [userId, `${userId}@example.test`]);
for (const id of [workspaceId, otherWorkspace])
await tx.query("INSERT INTO atlas.workspaces (id, name, slug) VALUES ($1::uuid, '图片归属测试', $1::text)", [id]);
const setWorkspace = (id: string) => tx.query("SELECT set_config('app.workspace_id', $1, true)", [id]);
const product = async (ws: string) => {
await setWorkspace(ws);
const id = randomUUID(), itemId = randomUUID();
await tx.query("INSERT INTO atlas.collection_items (id, workspace_id, source_kind, source_item_key) VALUES ($1::uuid, $2, 'manual', $1::text)", [itemId, ws]);
await tx.query('INSERT INTO atlas.products (id, workspace_id, collection_item_id) VALUES ($1, $2, $3)', [id, ws, itemId]);
return id;
};
const productA = await product(workspaceId), productB = await product(workspaceId), foreignProduct = await product(otherWorkspace);
await setWorkspace(workspaceId);
const assetA = randomUUID(), assetB = randomUUID(), legacyAsset = randomUUID();
const checksum = createHash('sha256').update(bytes).digest('hex');
for (const id of [assetA, assetB, legacyAsset])
await tx.query(`INSERT INTO atlas.media_assets
(id, workspace_id, storage_provider, bucket, object_key, content_type, byte_size, checksum_sha256, width_px, height_px)
VALUES ($1::uuid, $2, 'local_minio', 'test', $1::text, 'image/png', $3, $4, 800, 800)`,
[id, workspaceId, bytes.length, id === assetA ? checksum : id]);
const upload = async (productId: string | null, assetId: string) => tx.query(`INSERT INTO atlas.media_upload_intents
(workspace_id, purpose, storage_provider, bucket, object_key, expected_content_type,
expected_byte_size, expected_checksum_sha256, status, asset_id, expires_at, completed_at, product_id)
VALUES ($1, 'product_image', 'local_minio', 'test', $2, 'image/png', $3, $4, 'ready', $5, now() + interval '1 hour', now(), $6)`,
[workspaceId, randomUUID(), bytes.length, checksum, assetId, productId]);
const imported = async (productId: string | null, assetId: string) => tx.query(`INSERT INTO atlas.product_image_imports
(workspace_id, product_id, source_url, asset_id) VALUES ($1, $2, 'https://example.test/shared.png', $3)`,
[workspaceId, productId, assetId]);
await upload(productA, assetA);
await upload(productB, assetB);
await upload(null, legacyAsset);
await imported(productA, assetA);
await imported(productB, assetB);
await imported(null, legacyAsset);
const scoped = { workspaceTransaction: async <T>(ws: string, work: (tx: DatabaseTransaction) => Promise<T>) => {
await setWorkspace(ws);
return work(tx);
} } as Database;
const media = new MediaService(new IdempotencyService(scoped, { IDEMPOTENCY_KEY_SECRET: 'image-test-secret' } as never), scoped, {
OBJECT_STORAGE_ENDPOINT: 'http://127.0.0.1:9000', OBJECT_STORAGE_REGION: 'us-east-1', OBJECT_STORAGE_BUCKET: 'test',
OBJECT_STORAGE_PROVIDER: 'local_minio', objectStorageForcePathStyle: true, objectStorageAccessKey: 'test', objectStorageSecretKey: 'test',
} as never);
Object.assign(media, { ensureBucket: async () => undefined, inspectObject: async () => ({
byteSize: bytes.length, checksum, contentType: 'image/png', width: 800, height: 800,
}) });
const ids = (rows: Array<{ id: string }>) => rows.map((row) => row.id).sort();
expect(ids(await media.productUploads(workspaceId, productA))).toEqual([assetA]);
expect(ids(await media.productUploads(workspaceId, productB))).toEqual([assetB]);
expect(ids(await media.productImports(workspaceId, productA))).toEqual([assetA]);
expect(ids(await media.productImports(workspaceId, productB))).toEqual([assetB]);
const input = { purpose: 'product_image' as const, product_id: productB, filename: 'shared.png',
content_type: 'image/png' as const, byte_size: bytes.length, checksum_sha256: checksum };
const key = randomUUID();
const intent = await media.createIntent(workspaceId, userId, key, input);
const uploadId = (intent.body as { data: { upload_id: string } }).data.upload_id;
const repeated = await media.createIntent(workspaceId, userId, key, input);
expect((repeated.body as { data: { upload_id: string } }).data.upload_id).toBe(uploadId);
expect((await tx.query('SELECT product_id FROM atlas.media_upload_intents WHERE id = $1', [uploadId])).rows[0].product_id).toBe(productB);
await media.completeIntent(workspaceId, userId, uploadId, randomUUID(), { checksum_sha256: checksum });
expect(ids(await media.productUploads(workspaceId, productB))).toEqual([assetA, assetB].sort());
expect(ids(await media.productUploads(workspaceId, productA))).toEqual([assetA]);
await media.importProductImage(workspaceId, userId, randomUUID(), { product_id: productB, url: 'https://example.test/shared.png' });
expect(ids(await media.productImports(workspaceId, productB))).toEqual([assetA]);
expect(ids(await media.productImports(workspaceId, productA))).toEqual([assetA]);
const forbidden = async (work: () => Promise<unknown>) => {
await tx.query('SAVEPOINT forbidden_image');
try { await expect(work()).rejects.toMatchObject({ code: '23503' }); }
finally { await tx.query('ROLLBACK TO SAVEPOINT forbidden_image'); }
};
await forbidden(() => upload(foreignProduct, assetA));
await forbidden(() => imported(foreignProduct, assetA));
await tx.query('SAVEPOINT unknown_product');
await expect(media.createIntent(workspaceId, userId, randomUUID(), { ...input, product_id: foreignProduct })).rejects.toMatchObject({ status: 404 });
await tx.query('ROLLBACK TO SAVEPOINT unknown_product');
expect(await media.productUploads(otherWorkspace, productA)).toEqual([]);
expect(await media.productImports(otherWorkspace, productA)).toEqual([]);
throw rollback;
});
} catch (error) { if (error !== rollback) throw error; }
finally { await db.close(); }
});
});
@@ -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]);
});
});
+865
View File
@@ -0,0 +1,865 @@
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 type { ProductPublishJob } from '@atlas/contracts';
import { ozonHashtagProblem } from '@atlas/contracts';
import type { S3Client } from '@aws-sdk/client-s3';
import { beforeAll, afterAll, describe, it } from 'vitest';
import { DATABASE } from '../src/database/database.module.js';
import { createApplication } from '../src/main.js';
import {
runProductPublish,
type PublishDependencies,
} from '../../worker/src/product-publish.js';
import {
OzonRateLimitError,
OzonRetryableError,
} from '../../worker/src/ozon-client.js';
let app: NestExpressApplication,
db: Database,
base = '',
workspace = '',
token = '',
platform = '',
store = '',
category = '';
const storage = {} as S3Client;
async function request(
path: string,
method = 'GET',
body?: unknown,
extra: Record<string, string> = {},
) {
const res = await fetch(`${base}/api${path}`, {
method,
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
'X-Atlas-Platform-Id': platform,
...extra,
},
body: body === undefined ? undefined : JSON.stringify(body),
});
return { status: res.status, body: (await res.json()) as any };
}
async function fixture(count = 2, mediaCount = 1) {
const created = await request(
'/collection-items',
'POST',
{ title: '发布测试商品' },
{ 'Idempotency-Key': randomUUID() },
);
assert.equal(created.status, 201, JSON.stringify(created));
const product = created.body.data.product_id;
const entered = await request(
`/collection-items/${created.body.data.collection_item.id}/editor-contexts`,
'POST',
{},
{ 'Idempotency-Key': randomUUID() },
);
assert.equal(entered.status, 201, JSON.stringify(entered));
const profile = entered.body.data.profile_id;
const ids: string[] = [];
await db.workspaceTransaction(workspace, async (tx) => {
await tx.query(
`UPDATE atlas.products SET brand='Без бренда',model='TEST-MODEL',default_package_length_mm=240,default_package_width_mm=120,default_package_height_mm=60,default_shipping_weight_g=350,package_spec_status='confirmed' WHERE id=$1`,
[product],
);
await tx.query(
`UPDATE atlas.product_platform_profiles SET title='Тестовый товар',fulfillment='rfbs',platform_product_category_id=$2,platform_type_id='1002' WHERE id=$1`,
[profile, category],
);
const first = (
await tx.query<{ id: string }>(
'SELECT id FROM atlas.product_variants WHERE product_id=$1',
[product],
)
).rows[0].id;
ids.push(first);
for (let i = 1; i < count; i++)
ids.push(
(
await tx.query<{ id: string }>(
`INSERT INTO atlas.product_variants(workspace_id,product_id,display_name,sort_order) VALUES($1,$2,$3,$4) RETURNING id`,
[workspace, product, `SKU ${i}`, i],
)
).rows[0].id,
);
for (let i = 0; i < ids.length; i++) {
const variant = ids[i];
await tx.query(
`INSERT INTO atlas.product_variant_platform_listings(workspace_id,product_id,profile_id,platform_id,product_variant_id,store_id) VALUES($1,$2,$3,$4,$5,$6) ON CONFLICT DO NOTHING`,
[workspace, product, profile, platform, variant, store],
);
const quote = (
await tx.query<{ id: string }>(
`INSERT INTO atlas.pricing_quotes(workspace_id,store_id,platform_id,product_id,profile_id,product_variant_id,fulfillment,purchase_cost_cny,package_weight_kg,package_length_cm,package_width_cm,package_height_cm,fx_cny_to_rub,input_snapshot,status,confirmed_at) VALUES($1,$2,$3,$4,$5,$6,'rfbs',20,0.350,24,12,6,12,'{}','confirmed',now()) RETURNING id`,
[workspace, store, platform, product, profile, variant],
)
).rows[0].id;
const listing = (
await tx.query<{ id: string }>(
`UPDATE atlas.product_variant_platform_listings SET offer_id=$2,selling_price=1999.1234,list_price=2999.1234,currency_code='RUB',pricing_quote_id=$3,pricing_valid=true WHERE profile_id=$1 AND product_variant_id=$4 RETURNING id`,
[profile, `TEST-${randomUUID()}`, quote, variant],
)
).rows[0].id;
for (let mediaIndex = 0; mediaIndex < mediaCount; mediaIndex++) {
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) VALUES($1,'qiniu_kodo','test-bucket',$2,'image/png',100,$3) RETURNING id`,
[workspace, randomUUID(), randomUUID()],
)
).rows[0].id;
await tx.query(
`INSERT INTO atlas.product_variant_listing_media(workspace_id,product_id,profile_id,listing_id,product_variant_id,asset_id,sort_order) VALUES($1,$2,$3,$4,$5,$6,$7)`,
[workspace, product, profile, listing, variant, asset, mediaIndex],
);
}
}
});
return { profile, product, ids };
}
async function publish(
f: Awaited<ReturnType<typeof fixture>>,
ids = f.ids,
key = randomUUID(),
) {
return request(
`/product-platform-profiles/${f.profile}/publish-jobs`,
'POST',
{ variant_ids: ids },
{ 'Idempotency-Key': key, 'If-Match': '"1:1"' },
);
}
async function run(
job: ProductPublishJob,
requestFn: PublishDependencies['request'],
images: PublishDependencies['images'] = async () => [
'https://images.example.test/fixture.png',
],
) {
await runProductPublish(
db,
storage,
{
eventId: randomUUID(),
workspaceId: workspace,
eventType: 'product_publish.requested',
payload: { publish_job_id: job.id },
},
{
request: requestFn,
images,
decrypt: () => 'fixture-key',
},
);
const response = await request(`/product-publish-jobs/${job.id}`);
assert.equal(response.status, 200);
return response.body.data as ProductPublishJob;
}
describe('首次发布 HTTP / 数据库 / Worker 闭环', () => {
beforeAll(async () => {
Object.assign(process.env, {
NODE_ENV: 'test',
SWAGGER_ENABLED: 'false',
LOG_LEVEL: 'error',
AUTH_REGISTER_IP_LIMIT: '100',
AUTH_REGISTER_EMAIL_LIMIT: '100',
OZON_PUBLISH_ENABLED: 'true',
});
({ app } = await createApplication());
db = app.get<Database>(DATABASE);
await app.listen(0, '127.0.0.1');
base = `http://127.0.0.1:${(app.getHttpServer().address() as AddressInfo).port}`;
const registration = await fetch(`${base}/api/auth/register`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
email: `publish-${randomUUID()}@example.test`,
password: 'correct-horse-battery-staple-123',
password_confirmation: 'correct-horse-battery-staple-123',
}),
});
const data = (await registration.json()) as any;
assert.equal(registration.status, 201, JSON.stringify(data));
workspace = data.data.workspace.id;
token = data.data.access_token;
platform = (await request('/platforms')).body.data[0].id;
await db.workspaceTransaction(workspace, async (tx) => {
store = (
await tx.query<{ id: string }>(
`INSERT INTO atlas.stores(workspace_id,platform_id,name,platform_client_id,currency_code,is_default,status,credential_status) VALUES($1,$2,'发布测试店铺',$3,'RUB',true,'active','valid') RETURNING id`,
[workspace, platform, randomUUID()],
)
).rows[0].id;
await 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','fixture','valid')`,
[workspace, store, Buffer.from('fixture'), Buffer.alloc(12)],
);
category = (
await tx.query<{ id: string }>(
`INSERT INTO atlas.platform_product_categories(platform_id,external_category_id,external_type_id,name,path) VALUES($1,$2,'1002','发布测试类目','发布测试类目') RETURNING id`,
[platform, `${Date.now()}`],
)
).rows[0].id;
await tx.query(
`INSERT INTO atlas.platform_product_category_attributes(platform_id,platform_product_category_id,platform_attribute_id,name,value_type,is_required,is_aspect,max_value_count) VALUES($1,$2,'9048','型号','text',true,false,1),($1,$2,'23171','#主题标签','text',false,false,1)`,
[platform, category],
);
await tx.query(
`INSERT INTO atlas.platform_catalog_resource_states(platform_id,scope_key,scope,category_id,last_success_at,is_complete) VALUES($1,$2,'attributes',$3,now(),true)`,
[platform, `attributes:${category}`, category],
);
});
});
afterAll(async () => {
await app?.close();
});
it('商品颜色按属性 ID 支持多选保存、重读和发布,其他单值属性仍限制数量', async () => {
const f = await fixture(1);
const dictionaryId = `color-${randomUUID()}`;
await db.workspaceTransaction(workspace, async tx => {
const ownCategory = (await tx.query<{ id: string }>(
`INSERT INTO atlas.platform_product_categories(platform_id,external_category_id,external_type_id,name,path)
VALUES($1,$2,'1002','颜色多选测试','颜色多选测试') RETURNING id`,
[platform, String(Date.now()) + '96'],
)).rows[0].id;
const color = (await tx.query<{ id: string }>(
`INSERT INTO atlas.platform_product_category_attributes
(platform_id,platform_product_category_id,platform_attribute_id,name,value_type,is_required,is_aspect,is_collection,max_value_count,dictionary_id)
VALUES($1,$2,'10096','Цвет товара','dictionary',true,true,false,1,$3) RETURNING id`,
[platform, ownCategory, dictionaryId],
)).rows[0].id;
await tx.query(`INSERT INTO atlas.platform_product_category_attributes
(platform_id,platform_product_category_id,platform_attribute_id,name,value_type,is_aspect,is_collection,max_value_count)
VALUES($1,$2,'200001','单值测试','text',true,false,1)`, [platform, ownCategory]);
await tx.query(`INSERT INTO atlas.platform_catalog_dictionary_values
(platform_id,platform_product_category_id,attribute_id,dictionary_id,external_value_id,value)
VALUES($1,$2,$3,$4,'61579','红色'),($1,$2,$3,$4,'61581','蓝色')`,
[platform, ownCategory, color, dictionaryId]);
await tx.query(`INSERT INTO atlas.platform_catalog_resource_states
(platform_id,scope_key,scope,category_id,attribute_id,last_success_at,is_complete)
VALUES($1,'attributes:' || $2::text,'attributes',$2::uuid,NULL,now(),true),
($1,'dictionary:' || $3::text,'dictionary',$2::uuid,$3::uuid,now(),true)`,
[platform, ownCategory, color]);
await tx.query('UPDATE atlas.product_platform_profiles SET platform_product_category_id=$2 WHERE id=$1', [f.profile, ownCategory]);
await tx.query('UPDATE atlas.product_variant_platform_listings SET pricing_quote_id=NULL WHERE profile_id=$1', [f.profile]);
await tx.query(`UPDATE atlas.media_assets SET width_px=800,height_px=800
WHERE id IN (SELECT asset_id FROM atlas.product_variant_listing_media WHERE profile_id=$1)`, [f.profile]);
});
const before = (await request(`/products/${f.product}/editor`)).body.data;
const colorDraft = {
scope: 'variant', variant_id: f.ids[0], platform_attribute_id: '10096', complex_id: '0',
values: ['61579', '61581'], language_code: null, source: 'manual', confirmation_status: 'confirmed',
};
const draft = {
ai_source_material: before.source_material,
ai_source_overridden_fields: before.source_material_state.overridden_fields,
common: { ...before.product.common, default_physical_spec: before.product.default_physical_spec,
default_package_spec: before.product.default_package_spec },
profile: { target_store_id: store, fulfillment: before.profile.fulfillment, title: before.profile.title,
description: before.profile.description, tags: before.profile.tags,
platform_product_category_id: before.profile.category.id, category_source: 'manual' },
attributes: [colorDraft],
variants: before.variants.map((v: any) => ({
id: v.id, client_key: null, display_name: v.display_name, source_sku: v.source_sku,
specification_values: v.specification_values, purchase_cost_cny: v.purchase_cost_cny,
title: v.title, tags: v.tags, description: v.description, physical_spec: v.physical_spec,
package_spec: v.package_spec, offer_id: v.listing.offer_id,
media_asset_ids: v.media.map((m: any) => m.asset_id),
})),
};
const save = (attributes = draft.attributes) => request(`/products/${f.product}/editor-draft`, 'PUT',
{ ...draft, attributes }, { 'Idempotency-Key': randomUUID(), 'If-Match': '"1:1"' });
const invalidSingle = await save([{ ...colorDraft, platform_attribute_id: '200001', values: ['one', 'two'] }]);
assert.equal(invalidSingle.status, 422);
assert.equal(invalidSingle.body.error.code, 'ATTRIBUTE_VALUE_COUNT_INVALID');
const invalidDictionary = await save([{ ...colorDraft, values: ['61579', '99999999'] }]);
assert.equal(invalidDictionary.status, 422);
const saved = await save();
assert.equal(saved.status, 200, JSON.stringify(saved));
const after = (await request(`/products/${f.product}/editor`)).body.data;
assert.deepEqual(after.attributes.find((a: any) => a.platform_attribute_id === '10096').values, ['61579', '61581']);
// fixture 使用预先确认的模拟报价;草稿保存后恢复其有效标记,仅验证颜色发布映射。
await db.workspaceTransaction(workspace, tx => tx.query(
`UPDATE atlas.product_variant_platform_listings listing SET pricing_valid=true,
pricing_quote_id=(SELECT quote.id FROM atlas.pricing_quotes quote
WHERE quote.profile_id=listing.profile_id AND quote.product_variant_id=listing.product_variant_id LIMIT 1)
WHERE listing.profile_id=$1`, [f.profile]));
const job = await request(`/product-platform-profiles/${f.profile}/publish-jobs`, 'POST',
{ variant_ids: f.ids }, { 'Idempotency-Key': randomUUID(), 'If-Match': `"${after.product.revision}:${after.profile.revision}"` });
assert.equal(job.status, 202, JSON.stringify(job));
const snapshot = await db.workspaceTransaction(workspace, async tx =>
(await tx.query<{ snapshot: { attributes: Array<{ id: number; values: unknown[] }> } }>(
'SELECT snapshot FROM atlas.product_publish_job_items WHERE job_id=$1', [job.body.data.publish_job.id])).rows[0].snapshot);
assert.deepEqual(snapshot.attributes.find(a => a.id === 10096)?.values, [
{ dictionary_value_id: 61579, value: '红色' },
{ dictionary_value_id: 61581, value: '蓝色' },
]);
});
it('简介保留 SKU 覆盖和 SPU 继承的换行,Ozon 请求转为 br 且不修改快照', async () => {
const f = await fixture();
const common = 'Общее описание\r\n\r\nХарактеристики:\r\n• Один & два';
const own = 'Описание SKU\n\nРазмер < 10\n• Красный';
await db.workspaceTransaction(workspace, async tx => {
await tx.query(`INSERT INTO atlas.platform_product_category_attributes
(platform_id,platform_product_category_id,platform_attribute_id,name,value_type,is_required,is_aspect,max_value_count)
VALUES($1,$2,'4191','简介','text',false,false,1) ON CONFLICT DO NOTHING`, [platform, category]);
await tx.query('UPDATE atlas.product_platform_profiles SET description=$2 WHERE id=$1', [f.profile, common]);
await tx.query('UPDATE atlas.product_variant_platform_listings SET description=$3 WHERE profile_id=$1 AND product_variant_id=$2', [f.profile,f.ids[0],own]);
});
const created = await publish(f);
assert.equal(created.status, 202, JSON.stringify(created));
const job = created.body.data.publish_job as ProductPublishJob;
const snapshots = () => db.workspaceTransaction(workspace, async tx => (await tx.query<{
variant_id: string; snapshot: { attributes: Array<{ id: number; values: Array<{ value: string }> }> };
}>('SELECT variant_id,snapshot FROM atlas.product_publish_job_items WHERE job_id=$1', [job.id])).rows);
const before = await snapshots();
for (const row of before) assert.equal(row.snapshot.attributes.find(a=>a.id===4191)?.values[0].value,
row.variant_id === f.ids[0] ? own : common);
let submitted = false;
await run(job, async (_client, _key, path, body) => {
if (path === '/v3/product/info/list') return { items: [] };
assert.equal(path, '/v3/product/import');
submitted = true;
const items = (body as { items: Array<{ attributes: Array<{ id: number; values: Array<{ value: string }> }> }> }).items;
assert.deepEqual(new Set(items.map(item=>item.attributes.find(a=>a.id===4191)?.values[0].value)), new Set([
'Описание SKU<br><br>Размер &lt; 10<br>• Красный',
'Общее описание<br><br>Характеристики:<br>• Один &amp; два',
]));
return { result: { task_id: 778899 } };
});
assert.equal(submitted,true);
assert.deepEqual(await snapshots(), before);
});
it('多个主题标签合并为一个 Ozon 属性值,SKU 覆盖与继承均保留', async () => {
const f = await fixture();
await db.workspaceTransaction(workspace, async (tx) => {
await tx.query(
'UPDATE atlas.product_platform_profiles SET tags=$2::jsonb WHERE id=$1',
[f.profile, JSON.stringify(['#boho', '#loft'])],
);
await tx.query(
'UPDATE atlas.product_variant_platform_listings SET tags=$3::jsonb WHERE profile_id=$1 AND product_variant_id=$2',
[f.profile, f.ids[0], JSON.stringify(['#kpop', '#oldmoney'])],
);
});
const created = await publish(f);
assert.equal(created.status, 202, JSON.stringify(created));
const job = created.body.data.publish_job as ProductPublishJob;
await run(job, async (_client, _key, path, body) => {
if (path === '/v3/product/info/list') return { items: [] };
assert.equal(path, '/v3/product/import');
const items = body.items as Array<{
attributes: Array<{
id: number;
values: Array<{ value: string }>;
}>;
}>;
assert.deepEqual(
items.map(
(item) =>
item.attributes.find(
(attribute) => attribute.id === 23171,
)?.values,
),
[[{ value: '#kpop #oldmoney' }], [{ value: '#boho #loft' }]],
);
return { result: { task_id: 23171 } };
});
});
it('主题标签支持 30 个标签,空标签不提交属性', async () => {
for (const tags of [
[],
Array.from({ length: 30 }, (_, index) => `#tag${index}`),
]) {
const f = await fixture(1);
await db.workspaceTransaction(workspace, (tx) =>
tx.query(
'UPDATE atlas.product_platform_profiles SET tags=$2::jsonb WHERE id=$1',
[f.profile, JSON.stringify(tags)],
),
);
const created = await publish(f);
assert.equal(created.status, 202, JSON.stringify(created));
const result = await db.workspaceTransaction(workspace, (tx) =>
tx.query<{
snapshot: {
attributes: Array<{
id: number;
values: Array<{ value: string }>;
}>;
};
}>(
'SELECT snapshot FROM atlas.product_publish_job_items WHERE job_id=$1',
[created.body.data.publish_job.id],
),
);
const attribute = result.rows[0].snapshot.attributes.find(
(item) => item.id === 23171,
);
assert.deepEqual(
attribute?.values,
tags.length ? [{ value: tags.join(' ') }] : undefined,
);
}
});
it('主题标签只接受单个井号、字母数字下划线及 30 字符边界', () => {
assert.equal(ozonHashtagProblem(['#стиль_2026', '#boho']), null);
assert.equal(ozonHashtagProblem(['#' + 'я'.repeat(29)]), null);
for (const tag of [
'стиль',
'#стиль мода',
'#стиль-мода',
'#стиль/мода',
'#стиль#мода',
'#стиль😀',
'#',
'#' + 'я'.repeat(30),
])
assert.ok(ozonHashtagProblem([tag]), tag);
assert.ok(
ozonHashtagProblem(Array.from({ length: 31 }, () => '#стиль')),
);
});
it('历史不合规主题标签在预检与创建发布时拦截,不影响合法 SKU 覆盖', async () => {
const f = await fixture();
await db.workspaceTransaction(workspace, async (tx) => {
await tx.query(
'UPDATE atlas.product_platform_profiles SET tags=$2::jsonb WHERE id=$1',
[f.profile, JSON.stringify(['спортивный стиль'])],
);
await tx.query(
'UPDATE atlas.product_variant_platform_listings SET tags=$3::jsonb WHERE profile_id=$1 AND product_variant_id=$2',
[f.profile, f.ids[0], JSON.stringify(['#спортивный_стиль'])],
);
});
const preflight = await request(
`/product-platform-profiles/${f.profile}/publish-preflights`,
'POST',
{ variant_ids: f.ids },
);
assert.equal(preflight.status, 200, JSON.stringify(preflight));
assert.equal(preflight.body.data.status, 'blocked');
const validation = preflight.body.data.validation;
assert.equal(
validation.variants.find(
(v: { variant_id: string }) => v.variant_id === f.ids[0],
).ready,
true,
);
assert.ok(
validation.variants
.find((v: { variant_id: string }) => v.variant_id === f.ids[1])
.issues.some(
(issue: { code: string; field: string }) =>
issue.code === 'OZON_HASHTAG_INVALID' &&
issue.field === 'profile.tags',
),
);
const rejected = await publish(f);
assert.equal(rejected.status, 422);
assert.ok(
rejected.body.error.details.some(
(issue: { code: string }) =>
issue.code === 'OZON_HASHTAG_INVALID',
),
);
const selected = await publish(f, [f.ids[0]]);
assert.equal(selected.status, 202, JSON.stringify(selected));
const invalidSku = await fixture(1);
await db.workspaceTransaction(workspace, (tx) =>
tx.query(
'UPDATE atlas.product_variant_platform_listings SET tags=$3::jsonb WHERE profile_id=$1 AND product_variant_id=$2',
[
invalidSku.profile,
invalidSku.ids[0],
JSON.stringify(['#стиль-мода']),
],
),
);
const response = await publish(invalidSku);
assert.equal(response.status, 422);
assert.ok(
response.body.error.details.some(
(issue: { code: string; field: string }) =>
issue.code === 'OZON_HASHTAG_INVALID' &&
issue.field === `variants.${invalidSku.ids[0]}.tags`,
),
);
});
it('预检、并发幂等、精度快照、Outbox 与跨租户隔离', async () => {
const f = await fixture();
const key = randomUUID();
const [a, b] = await Promise.all([
publish(f, undefined, key),
publish(f, undefined, key),
]);
assert.equal(a.status, 202, JSON.stringify(a));
assert.deepEqual(a.body, b.body);
const job = a.body.data.publish_job as ProductPublishJob;
assert.equal(job.items.length, 2);
const facts = await db.workspaceTransaction(workspace, async (tx) => ({
items: (
await tx.query(
'SELECT snapshot FROM atlas.product_publish_job_items WHERE job_id=$1',
[job.id],
)
).rows,
events: (
await tx.query(
'SELECT event_payload FROM atlas.outbox_events WHERE aggregate_id=$1',
[job.id],
)
).rows,
listings: (
await tx.query(
'SELECT selling_price::text, list_price::text FROM atlas.product_variant_platform_listings WHERE profile_id=$1',
[f.profile],
)
).rows,
}));
assert.equal(facts.events.length, 1);
assert.deepEqual(facts.events[0].event_payload, {
publish_job_id: job.id,
});
assert.equal(facts.items[0].snapshot.price, '2999.1234');
assert.equal(facts.listings[0].selling_price, '1999.1234');
assert.equal(facts.listings[0].list_price, '2999.1234');
assert.ok(!('old_price' in facts.items[0].snapshot));
assert.equal(facts.items[0].snapshot.depth, 240);
assert.ok(!('vat' in facts.items[0].snapshot));
assert.ok(!('images' in facts.items[0].snapshot));
const other = randomUUID();
assert.equal(
(
await db.workspaceTransaction(other, (tx) =>
tx.query(
'SELECT id FROM atlas.product_publish_jobs WHERE id=$1',
[job.id],
),
)
).rows.length,
0,
);
assert.equal(
(
await request(
`/product-publish-jobs/${job.id}`,
'GET',
undefined,
{ 'X-Atlas-Platform-Id': randomUUID() },
)
).status,
404,
);
assert.equal(
(await publish(f)).body.error.code,
'SKU_IMPORT_IN_PROGRESS',
);
assert.equal(
(
await publish(f, [
...Array.from({ length: 51 }, () => randomUUID()),
])
).status,
422,
);
assert.equal(
(
await request(
`/product-platform-profiles/${f.profile}/publish-jobs`,
'POST',
{ variant_ids: f.ids },
{ 'Idempotency-Key': randomUUID(), 'If-Match': '"2:1"' },
)
).body.error.code,
'VERSION_CONFLICT',
);
});
it('首次价格使用划线价;零折扣仍可提交,缺失或零划线价阻断预检和创建', async () => {
const equal = await fixture(1);
await db.workspaceTransaction(workspace, (tx) =>
tx.query(
'UPDATE atlas.product_variant_platform_listings SET list_price=selling_price WHERE profile_id=$1',
[equal.profile],
),
);
const created = await publish(equal);
assert.equal(created.status, 202, JSON.stringify(created));
await run(created.body.data.publish_job, async (_client, _key, path, body) => {
if (path === '/v3/product/info/list') return { items: [] };
assert.equal(path, '/v3/product/import');
const item = (body.items as any[])[0];
assert.equal(item.price, '1999.1234');
assert.ok(!('old_price' in item));
return { result: { task_id: 987654 } };
});
for (const listPrice of [null, '0']) {
const f = await fixture(1);
await db.workspaceTransaction(workspace, (tx) =>
tx.query(
'UPDATE atlas.product_variant_platform_listings SET list_price=$2, selling_price=CASE WHEN $2::numeric=0 THEN 0 ELSE selling_price END WHERE profile_id=$1',
[f.profile, listPrice],
),
);
const preflight = await request(
`/product-platform-profiles/${f.profile}/publish-preflights`,
'POST',
{ variant_ids: f.ids },
{ 'Idempotency-Key': randomUUID(), 'If-Match': '"1:1"' },
);
assert.equal(preflight.status, 200, JSON.stringify(preflight));
assert.equal(preflight.body.data.validation.variants[0].ready, false);
assert.ok(preflight.body.data.validation.variants[0].issues.some(
(issue: { code: string; field: string }) =>
issue.code === 'LIST_PRICE_REQUIRED' && issue.field === `variants.${f.ids[0]}.list_price`,
));
const rejected = await publish(f);
assert.equal(rejected.status, 422);
assert.ok(rejected.body.error.details.some(
(issue: { code: string }) => issue.code === 'LIST_PRICE_REQUIRED',
));
}
});
it('只校验所选 SKU;不就绪的未选 SKU 不阻断部分上架', async () => {
const f = await fixture();
await db.workspaceTransaction(workspace, (tx) =>
tx.query(
`UPDATE atlas.product_variant_platform_listings SET offer_id=NULL WHERE profile_id=$1 AND product_variant_id=$2`,
[f.profile, f.ids[1]],
),
);
const all = await publish(f);
assert.equal(all.status, 422);
assert.equal(all.body.error.code, 'PUBLISH_VALIDATION_FAILED');
const partial = await publish(f, [f.ids[0]]);
assert.equal(partial.status, 202, JSON.stringify(partial));
assert.equal(partial.body.data.publish_job.item_count, 1);
});
it('混合结果持久化,成功锁定型号/货号,重复消息不重复上报', async () => {
const f = await fixture();
let job = (await publish(f)).body.data.publish_job as ProductPublishJob;
let imports = 0;
job = await run(job, async (_client, _key, path, body) => {
if (path === '/v3/product/info/list') return { items: [] };
assert.equal(path, '/v3/product/import');
imports++;
const items = body.items as any[];
assert.equal(items.length, 2);
assert.equal(items[0].price, '2999.1234');
assert.ok(!('old_price' in items[0]));
assert.ok(!('vat' in items[0]));
assert.ok(!('barcode' in items[0]));
assert.ok(!('description' in items[0]));
return { result: { task_id: 123456 } };
});
assert.equal(job.items[0].status, 'polling');
const offers = job.items.map((i) => i.offer_id);
job = await run(job, async (_c, _k, path) => path === '/v2/product/pictures/info' ? ({
items: [{ product_id: 101, primary_photo: ['https://images.example.test/fixture.png'], photo: [], errors: [] }],
}) : ({
result: {
items: [
{
offer_id: offers[0],
product_id: 101,
status: 'imported',
errors: [],
},
{
offer_id: offers[1],
status: 'failed',
errors: [
{
code: 'BAD_IMAGE',
message:
'失败 https://private.example.test/a?token=hidden',
},
],
},
],
},
}));
assert.equal(job.status, 'partially_succeeded');
assert.equal(
job.items.find((i) => i.status === 'imported')?.platform_product_id,
'101',
);
assert.ok(!JSON.stringify(job).includes('token='));
await assert.rejects(
db.workspaceTransaction(workspace, (tx) =>
tx.query(
`UPDATE atlas.products SET model='CHANGED' WHERE id=$1`,
[f.product],
),
),
/PRODUCT_MODEL_LOCKED/,
);
await assert.rejects(
db.workspaceTransaction(workspace, (tx) =>
tx.query(
`UPDATE atlas.product_variant_platform_listings SET offer_id='CHANGED' WHERE profile_id=$1 AND status='imported'`,
[f.profile],
),
),
/SKU_ALREADY_IMPORTED/,
);
await run(job, async () => {
imports++;
throw new Error('终态不应再访问平台');
});
assert.equal(imports, 1);
});
it('响应丢失后只按货号对账,绝不重复导入', async () => {
const f = await fixture(1);
let job = (await publish(f)).body.data.publish_job as ProductPublishJob;
let imports = 0;
job = await run(job, async (_c, _k, path) => {
if (path === '/v3/product/info/list') return { items: [] };
imports++;
throw new OzonRetryableError('response lost');
});
assert.equal(job.status, 'reconciling');
job = await run(job, async (_c, _k, path) => {
assert.equal(path, '/v3/product/info/list');
return { items: [] };
});
assert.equal(job.status, 'reconciling');
assert.equal(imports, 1);
job = await run(job, async (_c, _k, path) => ({
items: path === '/v2/product/pictures/info'
? [{ product_id: 999, primary_photo: ['https://images.example.test/fixture.png'], photo: [], errors: [] }]
: [{ offer_id: job.items[0].offer_id, id: 999 }],
}));
assert.equal(job.status, 'succeeded');
assert.equal(imports, 1);
});
it('导入成功后等待图片结果,缺图警告保留到 HTTP 结果与 SKU,并且不重复导入', async () => {
const f = await fixture(1, 3);
let job = (await publish(f)).body.data.publish_job as ProductPublishJob;
let imports = 0;
job = await run(job, async (_c, _k, path) => {
if (path === '/v3/product/info/list') return { items: [] };
assert.equal(path, '/v3/product/import');
imports++;
return { result: { task_id: 456 } };
});
const imported = { result: { items: [{ offer_id: job.items[0].offer_id, product_id: 102,
status: 'imported', errors: [] }] } };
job = await run(job, async (_c, _k, path) => path === '/v1/product/import/info' ? imported
: { items: [{ product_id: 102, primary_photo: [], photo: [], errors: [] }] });
assert.equal(job.status, 'running');
assert.equal(job.items[0].status, 'polling');
job = await run(job, async (_c, _k, path) => path === '/v1/product/import/info' ? {
result: { items: [{ ...imported.result.items[0], errors: [{ code: 'primary_image_load_failed',
message: 'https://private.example.test/primary.png?token=hidden' }] }] },
} : { items: [{ product_id: 102, primary_photo: ['https://images.example.test/second.png'], photo: [],
errors: [{ url: 'https://private.example.test/primary.png?token=hidden', message: 'request process failed: unknown error host unreachable' },
{ url: 'https://private.example.test/third.png?token=hidden', message: 'failed https://private.example.test/third.png?token=hidden' }] }] });
assert.equal(job.status, 'succeeded');
assert.equal(job.items[0].status, 'imported');
assert.equal(job.items[0].platform_product_id, '102');
assert.ok(job.items[0].errors.some((error) => error.code === 'OZON_IMAGES_INCOMPLETE' && error.message.includes('1/3')));
assert.ok(job.items[0].errors.some((error) => error.code === 'primary_image_load_failed'));
assert.ok(job.items[0].errors.some((error) => error.message.includes('全球加速')));
assert.ok(!JSON.stringify(job).includes('token='));
assert.ok(!JSON.stringify(job).includes('private.example.test'));
const listing = (await db.workspaceTransaction(workspace, tx => tx.query(
'SELECT status,last_error FROM atlas.product_variant_platform_listings WHERE profile_id=$1', [f.profile],
))).rows[0];
assert.equal(listing.status, 'imported');
assert.deepEqual(listing.last_error, job.items[0].errors);
await run(job, async () => { imports++; throw new Error('终态不得重复上报'); });
assert.equal(imports, 1);
});
it('图片全部到齐才报告无警告成功,长时间未齐时以已导入并提示缺图结束', async () => {
for (const completePictures of [true, false]) {
const f = await fixture(1, 3);
let job = (await publish(f)).body.data.publish_job as ProductPublishJob;
job = await run(job, async (_c, _k, path) => path === '/v3/product/info/list'
? { items: [] } : { result: { task_id: 457 } });
if (!completePictures) await db.workspaceTransaction(workspace, tx => tx.query(
'UPDATE atlas.product_publish_jobs SET poll_count=121 WHERE id=$1', [job.id],
));
job = await run(job, async (_c, _k, path) => path === '/v1/product/import/info'
? { result: { items: [{ offer_id: job.items[0].offer_id, product_id: 103, status: 'imported', errors: [] }] } }
: { items: [{ product_id: 103, primary_photo: completePictures ? ['https://images.example.test/1.png'] : [],
photo: completePictures ? ['https://images.example.test/2.png', 'https://images.example.test/3.png'] : [], errors: [] }] });
assert.equal(job.status, 'succeeded');
assert.equal(job.items[0].status, 'imported');
assert.equal(job.items[0].errors.length, completePictures ? 0 : 1);
}
});
it('任一图片准备失败时整批不提交,并隐藏存储路径', async () => {
const f = await fixture(2);
const created = (await publish(f)).body.data
.publish_job as ProductPublishJob;
let prepared = 0;
const job = await run(
created,
async (_c, _k, path) => {
assert.equal(path, '/v3/product/info/list');
return { items: [] };
},
async () => {
if (++prepared === 2)
throw new Error('AccessDenied secret/object/key');
return ['https://images.example.test/fixture.png'];
},
);
assert.equal(job.status, 'failed');
assert.ok(job.items.every((i) => i.status === 'failed'));
assert.ok(!JSON.stringify(job).includes('secret/object'));
const source = await db.workspaceTransaction(workspace, (tx) =>
tx.query(
'SELECT submit_started_at FROM atlas.product_publish_jobs WHERE id=$1',
[job.id],
),
);
assert.equal(source.rows[0].submit_started_at, null);
});
it('远端已有同货号时跳过,不调用更新接口;429 延迟后允许提交', async () => {
const existing = await fixture(1);
let job = (await publish(existing)).body.data
.publish_job as ProductPublishJob;
job = await run(job, async (_c, _k, path) => {
assert.equal(path, '/v3/product/info/list');
return { items: [{ offer_id: job.items[0].offer_id, id: 88 }] };
});
assert.equal(job.items[0].status, 'skipped');
assert.equal(job.items[0].errors[0].code, 'OZON_OFFER_ALREADY_EXISTS');
const f = await fixture(1);
job = (await publish(f)).body.data.publish_job as ProductPublishJob;
job = await run(job, async (_c, _k, path) => {
if (path === '/v3/product/info/list') return { items: [] };
throw new OzonRateLimitError(120);
});
assert.equal(job.items[0].status, 'queued');
const source = await db.workspaceTransaction(workspace, (tx) =>
tx.query(
'SELECT submit_started_at FROM atlas.product_publish_jobs WHERE id=$1',
[job.id],
),
);
assert.equal(source.rows[0].submit_started_at, null);
job = await run(job, async (_c, _k, path) =>
path === '/v3/product/info/list'
? { items: [] }
: { result: { task_id: 987 } },
);
assert.equal(job.items[0].status, 'polling');
});
});
@@ -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']);
});
});
+10 -5
View File
@@ -1,10 +1,15 @@
import { getSession, login, openErp, submit } from '../lib/api.js'; import { getSession, login, openErp, submit } from '../lib/api.js';
import { backgroundError } from '../lib/request-errors.js';
export default defineBackground(() => { export default defineBackground(() => {
browser.runtime.onMessage.addListener(async (message: { type?: string; payload?: unknown; path?: string }) => { browser.runtime.onMessage.addListener(async (message: { type?: string; payload?: unknown; path?: string }) => {
if (message?.type === 'atlas.session') return { session: await getSession() }; try {
if (message?.type === 'atlas.login') return { session: await login() }; if (message?.type === 'atlas.session') return { session: await getSession() };
if (message?.type === 'atlas.collect') return { result: await submit(message.payload as import('@atlas/contracts').CollectedProductPayload) }; if (message?.type === 'atlas.login') return { session: await login() };
if (message?.type === 'atlas.open') { await openErp(message.path); return { ok: true }; } if (message?.type === 'atlas.collect') return { result: await submit(message.payload as import('@atlas/contracts').CollectedProductPayload) };
return undefined; if (message?.type === 'atlas.open') { await openErp(message.path); return { ok: true }; }
return undefined;
} catch (error) {
return backgroundError(error);
}
}); });
}); });
+22 -18
View File
@@ -4,6 +4,8 @@ import {
} from '../lib/source-parsers.js'; } from '../lib/source-parsers.js';
import type { OzonPageJsonFetcher } from '../lib/ozon-parser.js'; import type { OzonPageJsonFetcher } from '../lib/ozon-parser.js';
import type { MarketplacePageStateFetcher } from '../lib/source-parsers.js'; import type { MarketplacePageStateFetcher } from '../lib/source-parsers.js';
import { renderCollectorPanel } from '../lib/collector-panel.js';
import { unwrapBackgroundResponse } from '../lib/request-errors.js';
export default defineContentScript({ export default defineContentScript({
matches: [ matches: [
@@ -19,6 +21,7 @@ export default defineContentScript({
let mount: HTMLDivElement | null = null; let mount: HTMLDivElement | null = null;
let shadow: ShadowRoot | null = null; let shadow: ShadowRoot | null = null;
let observedUrl = location.href; let observedUrl = location.href;
let minimized = false;
const render = async () => { const render = async () => {
if (!supportedProductPlatform()) { if (!supportedProductPlatform()) {
@@ -35,21 +38,17 @@ export default defineContentScript({
shadow = mount.attachShadow({ mode: 'open' }); shadow = mount.attachShadow({ mode: 'open' });
} }
const root = shadow!; const root = shadow!;
const session = await browser.runtime const session = await sendBackgroundMessage<{ session?: unknown }>({ type: 'atlas.session' })
.sendMessage({ type: 'atlas.session' })
.then((value: { session?: unknown }) => value.session) .then((value: { session?: unknown }) => value.session)
.catch(() => null); .catch(() => null);
root.innerHTML = `<style>:host{all:initial}.box{width:156px;padding:12px;background:#fff;border-radius:12px;box-shadow:0 8px 28px #0003;font:14px -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;color:#1f2937}.head{display:flex;justify-content:space-between;align-items:center;font-weight:700;margin-bottom:10px}.dot{width:9px;height:9px;border-radius:50%;background:#ffd43b;border:0}.btn{display:block;width:100%;border:0;border-radius:18px;padding:9px 8px;margin:7px 0;background:#2878f0;color:#fff;font-weight:600;cursor:pointer}.btn.primary{background:#ff5353}.link{display:block;width:100%;border:0;background:transparent;color:#1668dc;cursor:pointer;padding:5px}.hint{font-size:12px;color:#6b7280;text-align:center;margin-top:6px}</style><div class="box"><div class="head"><span>Atlas ERP</span><button class="dot" aria-label="收起"></button></div>${session ? '<button class="btn primary" data-action="edit">编辑上架</button><button class="btn" data-action="collect">采集商品</button><button class="link" data-action="erp">进入 ERP</button>' : '<button class="btn primary" data-action="login">登录 Atlas ERP</button>'}<div class="hint" data-hint></div></div>`; renderCollectorPanel(
root.querySelector('.dot')?.addEventListener('click', () => { root,
mount?.remove(); Boolean(session),
mount = null; minimized,
shadow = null; (next) => {
}); minimized = next;
root.querySelectorAll<HTMLButtonElement>('[data-action]').forEach( },
(button) => (kind) => void action(kind, root),
button.addEventListener('click', () =>
void action(button.dataset.action ?? '', root),
),
); );
}; };
@@ -58,12 +57,12 @@ export default defineContentScript({
try { try {
if (kind === 'login') { if (kind === 'login') {
hint.textContent = '正在登录…'; hint.textContent = '正在登录…';
await browser.runtime.sendMessage({ type: 'atlas.login' }); await sendBackgroundMessage({ type: 'atlas.login' });
await render(); await render();
return; return;
} }
if (kind === 'erp') { if (kind === 'erp') {
await browser.runtime.sendMessage({ type: 'atlas.open', path: '/' }); await sendBackgroundMessage({ type: 'atlas.open', path: '/' });
return; return;
} }
hint.textContent = '正在采集多 SKU…'; hint.textContent = '正在采集多 SKU…';
@@ -77,18 +76,19 @@ export default defineContentScript({
? requestMarketplacePageState ? requestMarketplacePageState
: undefined, : undefined,
); );
const response = await browser.runtime.sendMessage({ hint.textContent = `已解析 ${payload.variants.length || 1} 个 SKU,正在入库…`;
const response = await sendBackgroundMessage<{ result: { data: { editor_entry_path: string } } }>({
type: 'atlas.collect', type: 'atlas.collect',
payload, payload,
}); });
if (kind === 'edit') { if (kind === 'edit') {
await browser.runtime.sendMessage({ await sendBackgroundMessage({
type: 'atlas.open', type: 'atlas.open',
path: response.result.data.editor_entry_path, path: response.result.data.editor_entry_path,
}); });
} }
hint.textContent = hint.textContent =
kind === 'edit' ? '已打开采集数据测试页' : '已进入采集箱'; `${kind === 'edit' ? '已打开商品编辑页' : '已进入采集箱'}(${payload.variants.length || 1} 个 SKU)`;
} catch (error) { } catch (error) {
hint.textContent = hint.textContent =
error instanceof Error ? error.message : '采集失败,请重试'; error instanceof Error ? error.message : '采集失败,请重试';
@@ -105,6 +105,10 @@ export default defineContentScript({
}, },
}); });
async function sendBackgroundMessage<T>(message: { type: string; payload?: unknown; path?: string }) {
return unwrapBackgroundResponse<T>(await browser.runtime.sendMessage(message));
}
const requestOzonPageJson: OzonPageJsonFetcher = ({ kind, sku }) => const requestOzonPageJson: OzonPageJsonFetcher = ({ kind, sku }) =>
new Promise((resolve, reject) => { new Promise((resolve, reject) => {
const requestId = crypto.randomUUID(); const requestId = crypto.randomUUID();
+16 -2
View File
@@ -1,4 +1,6 @@
import type { CollectedProductPayload } from '@atlas/contracts'; import type { CollectedProductPayload } from '@atlas/contracts';
import { requestError } from './request-errors.js';
import { validateCollectedPayload } from './collection-payload-validation.js';
const apiOrigin = (import.meta.env.ATLAS_API_ORIGIN as string | undefined) ?? 'http://localhost:8200'; const apiOrigin = (import.meta.env.ATLAS_API_ORIGIN as string | undefined) ?? 'http://localhost:8200';
const webOrigin = (import.meta.env.ATLAS_WEB_ORIGIN as string | undefined) ?? 'http://localhost:8100'; const webOrigin = (import.meta.env.ATLAS_WEB_ORIGIN as string | undefined) ?? 'http://localhost:8100';
@@ -10,7 +12,10 @@ async function saveToken(token: string) { await browser.storage.local.set({ [sto
async function clearTokens() { await browser.storage.local.remove(storageKey); } async function clearTokens() { await browser.storage.local.remove(storageKey); }
async function request(path: string, options: RequestInit = {}, token?: string) { async function request(path: string, options: RequestInit = {}, token?: string) {
const response = await fetch(`${apiOrigin}/api${path}`, { ...options, headers: { Accept: 'application/json', ...(options.headers ?? {}), ...(token ? { Authorization: `Bearer ${token}` } : {}) } }); const response = await fetch(`${apiOrigin}/api${path}`, { ...options, headers: { Accept: 'application/json', ...(options.headers ?? {}), ...(token ? { Authorization: `Bearer ${token}` } : {}) } });
if (!response.ok) { const body = await response.json().catch(() => null) as { error?: { message?: string } } | null; throw Object.assign(new Error(body?.error?.message ?? `请求失败(HTTP ${response.status})`), { status: response.status }); } if (!response.ok) {
const body: unknown = await response.json().catch(() => null);
throw requestError(body, response.status);
}
return response.status === 204 ? undefined : response.json() as Promise<{ data: unknown }>; return response.status === 204 ? undefined : response.json() as Promise<{ data: unknown }>;
} }
async function activeAccessToken() { async function activeAccessToken() {
@@ -43,7 +48,16 @@ export async function login() {
} }
throw new Error('登录超时,请在 Atlas ERP 网页完成登录后重试'); throw new Error('登录超时,请在 Atlas ERP 网页完成登录后重试');
} }
export async function submit(payload: CollectedProductPayload) { const token = await activeAccessToken(); if (!token) throw new Error('请先登录 Atlas ERP'); return request('/extension/materials', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Idempotency-Key': crypto.randomUUID() }, body: JSON.stringify(payload) }, token) as Promise<{ data: { editor_entry_path: string; product_id: string; collection_item: { id: string }; reused: boolean } }>; } export async function submit(payload: CollectedProductPayload) {
const validated = validateCollectedPayload(payload);
const token = await activeAccessToken();
if (!token) throw new Error('请先登录 Atlas ERP');
return request('/extension/materials', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Idempotency-Key': crypto.randomUUID() },
body: JSON.stringify(validated),
}, token) as Promise<{ data: { editor_entry_path: string; product_id: string; collection_item: { id: string }; reused: boolean } }>;
}
export async function openErp(path = '/') { await browser.tabs.create({ url: new URL(path, webOrigin).toString() }); } export async function openErp(path = '/') { await browser.tabs.create({ url: new URL(path, webOrigin).toString() }); }
async function readWebAccessToken(tabId: number) { async function readWebAccessToken(tabId: number) {
try { try {
@@ -0,0 +1,11 @@
import { collectedProductPayloadSchema } from '@atlas/contracts';
/** 先按共享契约校验采集载荷,失败时保留字段路径,不丢弃或截断真实 SKU。 */
export function validateCollectedPayload(payload: unknown) {
const result = collectedProductPayloadSchema.safeParse(payload);
if (result.success) return result.data;
const details = result.error.issues.slice(0, 3).map((issue) =>
`${issue.path.join('.') || '采集数据'}:${issue.message}`,
);
throw new Error(`采集数据不符合要求(${details.join(';')})`);
}
+58
View File
@@ -0,0 +1,58 @@
/** 采集浮窗只负责展示与折叠,登录、采集和页面跳转交由内容脚本处理。 */
export function renderCollectorPanel(
root: ShadowRoot,
authenticated: boolean,
minimized: boolean,
onToggle: (minimized: boolean) => void,
onAction: (kind: string) => void,
) {
root.innerHTML = `
<style>
:host{all:initial}
.collector{font:14px -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;color:#1f2937}
button{box-sizing:border-box;cursor:pointer}
button:focus-visible{outline:3px solid #93c5fd;outline-offset:3px}
.box{width:156px;padding:12px;background:#fff;border-radius:12px;box-shadow:0 8px 28px #0003}
.head{display:flex;justify-content:space-between;align-items:center;font-weight:700;margin-bottom:10px;gap:8px}
.minimize{display:inline-flex;align-items:center;justify-content:center;flex:0 0 24px;width:24px;height:24px;padding:0;border:0;border-radius:50%;background:#f3f4f6;color:#4b5563}
.minimize:hover{background:#e5e7eb;color:#1f2937}
.minimize svg{width:14px;height:14px;pointer-events:none}
.btn{display:block;width:100%;border:0;border-radius:18px;padding:9px 8px;margin:7px 0;background:#2878f0;color:#fff;font-weight:600}
.btn.primary{background:#ff5353}
.link{display:block;width:100%;border:0;background:transparent;color:#1668dc;padding:5px}
.hint{font-size:12px;color:#6b7280;text-align:center;margin-top:6px;overflow-wrap:anywhere}
.launcher{display:none;align-items:center;justify-content:center;width:40px;height:40px;padding:0;border:0;border-radius:50%;background:#2878f0;color:#fff;box-shadow:0 4px 16px #0003;font:700 20px -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}
.launcher:hover{background:#1668dc}
.is-minimized .box{display:none}
.is-minimized .launcher{display:inline-flex}
</style>
<div class="collector${minimized ? ' is-minimized' : ''}">
<div class="box" id="atlas-collector-panel">
<div class="head">
<span>Atlas ERP</span>
<button class="minimize" type="button" aria-label="最小化采集窗口" title="最小化采集窗口">
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true"><path d="M5 12h14" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>
</button>
</div>
${authenticated
? '<button class="btn primary" type="button" data-action="edit">编辑上架</button><button class="btn" type="button" data-action="collect">采集商品</button><button class="link" type="button" data-action="erp">进入 ERP</button>'
: '<button class="btn primary" type="button" data-action="login">登录 Atlas ERP</button>'}
<div class="hint" data-hint></div>
</div>
<button class="launcher" type="button" aria-label="展开采集窗口" title="展开采集窗口" aria-controls="atlas-collector-panel" aria-expanded="${!minimized}">A</button>
</div>`;
const panel = root.querySelector<HTMLElement>('.collector')!;
const minimize = root.querySelector<HTMLButtonElement>('.minimize')!;
const launcher = root.querySelector<HTMLButtonElement>('.launcher')!;
const toggle = (next: boolean) => {
panel.classList.toggle('is-minimized', next);
launcher.setAttribute('aria-expanded', String(!next));
onToggle(next);
(next ? launcher : minimize).focus();
};
minimize.addEventListener('click', () => toggle(true));
launcher.addEventListener('click', () => toggle(false));
root.querySelectorAll<HTMLButtonElement>('[data-action]').forEach((button) =>
button.addEventListener('click', () => onAction(button.dataset.action ?? '')),
);
}
+22
View File
@@ -0,0 +1,22 @@
/**
* 将采集到的描述规范为可阅读的纯文本。保留原有段落和列表边界;只有来源
* 已把结构压成一行时,才依据常见商品段落标题补出最小必要的换行。
*/
export function formatCollectedDescription(value: string): string {
const withoutMarkup = value
.replace(/\r\n?/g, '\n')
.replace(/<\s*br\s*\/?\s*>/gi, '\n')
.replace(/<\s*\/\s*(?:p|div|section|article|li|h[1-6])\s*>/gi, '\n')
.replace(/<\s*li\b[^>]*>/gi, '\n- ')
.replace(/<[^>]*>/g, '');
const formatted = withoutMarkup
.replace(
/\s*(Описание(?:\s+товара)?|Характеристики|Преимущества|Особенности|Комплектация|Технические\s+характеристики|Specifications?|Features?|Package\s+contents?|商品描述|产品描述|商品信息|规格参数|产品参数|产品特点|包装清单)\s*([::])\s*/gi,
'\n\n$1$2\n',
)
.replace(/([^\n])\s*[-•·]\s+(?=[\p{L}\p{N}])/gu, '$1\n- ')
.replace(/[\t\f\v ]*\n[\t\f\v ]*/g, '\n')
.replace(/\n{3,}/g, '\n\n')
.trim();
return formatted;
}
+4 -6
View File
@@ -1,5 +1,6 @@
import type { CollectedProductPayload } from '@atlas/contracts'; import type { CollectedProductPayload } from '@atlas/contracts';
import { dedupeImageUrl, normaliseImageUrl } from './product-media.js'; import { dedupeImageUrl, normaliseImageUrl } from './product-media.js';
import { formatCollectedDescription } from './description-format.js';
export type OzonPageJsonRequest = { export type OzonPageJsonRequest = {
kind: 'modal-aspects' | 'product' | 'product-detail'; kind: 'modal-aspects' | 'product' | 'product-detail';
@@ -341,7 +342,7 @@ export async function collectOzonProduct(
itemKey: currentSku, itemKey: currentSku,
canonicalUrl: canonical, canonicalUrl: canonical,
collectedAt: new Date().toISOString(), collectedAt: new Date().toISOString(),
parserVersion: 'ozon-public-2.3.0', parserVersion: 'ozon-public-2.3.1',
}, },
common: { common: {
...(title ...(title
@@ -650,10 +651,7 @@ export function extractOzonDescription(
} catch { } catch {
// Some older page responses already contain plain text here. // Some older page responses already contain plain text here.
} }
value = value value = formatCollectedDescription(value);
.replace(/\s+\n/g, '\n')
.replace(/\n{3,}/g, '\n\n')
.trim();
if (!value) continue; if (!value) continue;
return { return {
value: value.slice(0, 100_000), value: value.slice(0, 100_000),
@@ -678,7 +676,7 @@ export function extractOzonJsonLdDescription(
: ''; : '';
if (!raw) continue; if (!raw) continue;
return { return {
value: raw.replace(/\s+\n/g, '\n').replace(/\n{3,}/g, '\n\n').slice(0, 100_000), value: formatCollectedDescription(raw).slice(0, 100_000),
rawValue: raw.slice(0, 120_000), rawValue: raw.slice(0, 120_000),
}; };
} catch { } catch {
+55 -2
View File
@@ -29,6 +29,14 @@ type ImageGroup = {
*/ */
const DOM_IMAGE_PROFILES: Record<DomMediaPlatform, ImageGroup[]> = { const DOM_IMAGE_PROFILES: Record<DomMediaPlatform, ImageGroup[]> = {
'1688': [ '1688': [
{
role: 'source_main',
relation: 'inherited_product_default',
// 画廊缩略项虽小,但背景地址指向商品原图;位置与控件类型比尺寸更可靠。
selectors: ['.od-picture-gallery-list > .v-image-cover'],
sourceProps: ['backgroundImage'],
excludeWithin: ['.video-image-cover', '.v-image-cover:has(.cpv-icon, .cpv-tag-name)'],
},
{ {
role: 'source_main', role: 'source_main',
relation: 'inherited_product_default', relation: 'inherited_product_default',
@@ -43,6 +51,7 @@ const DOM_IMAGE_PROFILES: Record<DomMediaPlatform, ImageGroup[]> = {
'.detail-gallery-turn img.detail-gallery-img', '.detail-gallery-turn img.detail-gallery-img',
'.img-list-wrapper img.od-gallery-img', '.img-list-wrapper img.od-gallery-img',
], ],
excludeWithin: ['.video-image-cover', '.v-image-cover:has(.cpv-icon, .cpv-tag-name)'],
minWidth: 200, minWidth: 200,
minHeight: 200, minHeight: 200,
}, },
@@ -189,15 +198,58 @@ export function collectPlatformDomAssets(
} }
} }
} }
if (platform === '1688') add1688GalleryVideos(root, pageUrl, assets);
return assets; return assets;
} }
/** 只读取商品画廊内播放器的公开直链,不扫描全页视频或根据封面猜测地址。 */
function add1688GalleryVideos(root: Document, pageUrl: string, assets: Asset[]) {
const selectors = [
'[data-module="od_picture_gallery"] video',
'.module-od-picture-gallery video',
'.od-picture-gallery video',
'.od-picture-gallery-list video',
'.detail-gallery-turn video',
'.od-gallery-turn-item-wrapper video',
];
const seen = new Set<string>();
for (const selector of selectors) {
for (const video of Array.from(root.querySelectorAll<HTMLVideoElement>(selector))) {
const candidates = [video.currentSrc, video.getAttribute('src'),
...Array.from(video.querySelectorAll('source')).map((source) => source.getAttribute('src'))];
const url = candidates.map((candidate) => normaliseVideoUrl(candidate ?? '', pageUrl)).find(Boolean);
if (!url || seen.has(url) || assets.length >= 500) continue;
seen.add(url);
const cover = root.querySelector<HTMLElement>('.od-picture-gallery-list > .video-image-cover');
const poster = video.poster || video.getAttribute('poster');
const posterUrl = (poster ? normaliseImageUrl(poster, pageUrl, '1688') : undefined) ??
(cover ? imageUrlFromElement(cover, ['backgroundImage'], pageUrl, '1688') : undefined);
assets.push({
url, mediaType: 'video', role: 'source_main', relation: 'product_candidate',
...(posterUrl?.startsWith('https://') ? { posterUrl } : {}),
sortOrder: assets.length, evidenceRef: '1688:dom:source_video',
});
}
}
}
export function normaliseVideoUrl(raw: string, pageUrl: string): string | undefined {
if (!raw.trim()) return undefined;
try {
const url = new URL(raw, pageUrl);
return url.protocol === 'https:' && /\.(mp4|webm)$/i.test(url.pathname) ? url.href : undefined;
} catch {
return undefined;
}
}
/** Restores the original CDN URL before payload storage and de-duplication. */ /** Restores the original CDN URL before payload storage and de-duplication. */
export function normaliseImageUrl( export function normaliseImageUrl(
raw: string, raw: string,
pageUrl: string, pageUrl: string,
platform: SupportedMediaPlatform, platform: SupportedMediaPlatform,
): string | undefined { ): string | undefined {
if (!raw.trim() || /^(?:undefined|null|none)$/i.test(raw.trim())) return undefined;
let url: URL; let url: URL;
try { try {
url = new URL(raw, pageUrl); url = new URL(raw, pageUrl);
@@ -266,9 +318,10 @@ function imageUrlFromElement(
else if (prop === 'currentSrc') else if (prop === 'currentSrc')
raw = (target as HTMLImageElement).currentSrc ?? ''; raw = (target as HTMLImageElement).currentSrc ?? '';
else if (prop === 'backgroundImage') { else if (prop === 'backgroundImage') {
// 优先使用内联地址,避免占位值 undefined 被计算样式解析成一个页面相对 URL。
const style = const style =
target.ownerDocument.defaultView?.getComputedStyle(target) target.style.backgroundImage ||
.backgroundImage ?? target.style.backgroundImage; target.ownerDocument.defaultView?.getComputedStyle(target).backgroundImage || '';
raw = style.match(/url\(["']?(.*?)["']?\)/i)?.[1] ?? ''; raw = style.match(/url\(["']?(.*?)["']?\)/i)?.[1] ?? '';
// Some 1688 SKU selectors directly match an <img> despite the // Some 1688 SKU selectors directly match an <img> despite the
// group being background-image-first. // group being background-image-first.
+40
View File
@@ -0,0 +1,40 @@
type FieldIssue = { field: string; message: string };
function describeIssues(message: string, issues: FieldIssue[]) {
const details = issues.slice(0, 3).map((issue) => `${issue.field}:${issue.message}`);
return details.length ? `${message}(${details.join(';')})` : message;
}
/** 只展示服务端错误信封的业务说明,避免把请求或授权信息带入错误提示。 */
export function requestError(body: unknown, status: number) {
const error = body && typeof body === 'object' && 'error' in body
? body.error
: null;
const info = error && typeof error === 'object'
? error as { message?: unknown; details?: unknown }
: null;
const message = typeof info?.message === 'string'
? info.message
: `请求失败(HTTP ${status})`;
const issues = Array.isArray(info?.details)
? info.details.filter((item): item is FieldIssue =>
!!item && typeof item === 'object' &&
typeof item.field === 'string' && typeof item.message === 'string')
: [];
return Object.assign(new Error(describeIssues(message, issues)), { status });
}
/** Chrome 消息只返回可序列化错误,交给内容脚本显示,避免后台未处理异常。 */
export function backgroundError(error: unknown) {
return { error: { message: error instanceof Error ? error.message : '操作失败,请重试' } };
}
export function unwrapBackgroundResponse<T>(response: unknown): T {
if (!response || typeof response !== 'object')
throw new Error('插件后台未响应,请重载扩展并刷新商品页');
if ('error' in response && response.error && typeof response.error === 'object') {
const error = response.error as { message?: unknown };
throw new Error(typeof error.message === 'string' ? error.message : '操作失败,请重试');
}
return response as T;
}
+96 -44
View File
@@ -4,7 +4,8 @@ import {
isOzonProductPage, isOzonProductPage,
type OzonPageJsonFetcher, type OzonPageJsonFetcher,
} from './ozon-parser.js'; } from './ozon-parser.js';
import { collectPlatformDomAssets } from './product-media.js'; import { collectPlatformDomAssets, normaliseImageUrl, normaliseVideoUrl } from './product-media.js';
import { formatCollectedDescription } from './description-format.js';
type SourcePlatform = '1688' | 'taobao' | 'tmall' | 'ozon'; type SourcePlatform = '1688' | 'taobao' | 'tmall' | 'ozon';
type Json = Record<string, unknown>; type Json = Record<string, unknown>;
@@ -48,8 +49,12 @@ export async function collectCurrentProduct(
function collect1688(root: Document, pageUrl: string, bridgedState?: unknown): CollectedProductPayload { function collect1688(root: Document, pageUrl: string, bridgedState?: unknown): CollectedProductPayload {
const state = [bridgedState, ...pageState(root)]; const state = [bridgedState, ...pageState(root)];
const skuMap = findFirst(state, 'skuMapOriginal'); const skuModel = findFirst(state, 'skuModel');
const skuProps = findFirst(state, 'skuProps'); const skuInfoMap = isRecord(skuModel) ? skuModel.skuInfoMap : findFirst(state, 'skuInfoMap');
const skuMap = isRecord(skuInfoMap) && Object.keys(skuInfoMap).length
? skuInfoMap
: findFirst(state, 'skuMapOriginal');
const skuProps = isRecord(skuModel) ? skuModel.skuProps : findFirst(state, 'skuProps');
const axes = parse1688Axes(skuProps); const axes = parse1688Axes(skuProps);
const evidence: CollectedProductPayload['evidence'] = [ const evidence: CollectedProductPayload['evidence'] = [
{ {
@@ -69,32 +74,34 @@ function collect1688(root: Document, pageUrl: string, bridgedState?: unknown): C
evidence.push({ evidence.push({
ref: '1688:sku-map', ref: '1688:sku-map',
kind: 'state', kind: 'state',
path: 'skuMapOriginal', path: isRecord(skuInfoMap) ? 'skuModel.skuInfoMap' : 'skuMapOriginal',
raw_value: `组合记录 ${skuRows.length} 条`, raw_value: `组合记录 ${skuRows.length} 条`,
}); });
for (const [combination, row] of skuRows) { for (const [combination, row] of skuRows) {
if (!isRecord(row)) continue; if (!isRecord(row)) continue;
const sourceSku = stringAt(row, ['skuId', 'sku_id', 'skuCode']); const sku = stringAt(row, ['skuId', 'sku_id', 'skuCode'])?.trim();
// A skuMapOriginal entry itself is a source-provided sellable-combination const sourceSku = sku && !/^(0|null|undefined)$/.test(sku) ? sku : undefined;
// record. Never expand it with the Cartesian product of skuProps. const specAttrs = stringAt(row, ['specAttrs', 'propPath']) ?? combination;
const selections = selectionsFor1688Combination(specAttrs, axes);
// 元数据对象不是 SKU;无来源 ID 时须同时具备完整规格匹配和可售记录证据。
const hasSaleEvidence = moneyAt(row, ['price']) !== undefined ||
(typeof row.canBookCount === 'number' && Number.isFinite(row.canBookCount) && row.canBookCount >= 0);
if (!sourceSku && !(axes.length > 0 && selections.length === axes.length && hasSaleEvidence)) continue;
// 来源记录本身证明可售组合,不能用规格轴的笛卡尔积扩充 SKU。
const sourceVariantKey = sourceSku ?? `1688:${combination}`; const sourceVariantKey = sourceSku ?? `1688:${combination}`;
if (!combination || combination === 'undefined') continue; if (!combination || combination === 'undefined') continue;
variants.push({ variants.push({
sourceVariantKey, sourceVariantKey,
...(sourceSku ? { sourceSku } : {}), ...(sourceSku ? { sourceSku } : {}),
displayName: displayName:
selectionsForCombination(combination, axes) selections
.map((item) => item.valueName) .map((item) => item.valueName)
.join(' / ') || `1688 SKU ${sourceSku ?? combination}`, .join(' / ') || specAttrs,
selections: selectionsForCombination(combination, axes), selections,
...(moneyAt(row, ['discountPrice', 'price', 'salePrice']) ...(moneyAt(row, ['price'])
? { ? {
sourcePrice: { sourcePrice: {
amount: moneyAt(row, [ amount: moneyAt(row, ['price'])!,
'discountPrice',
'price',
'salePrice',
])!,
currencyCode: 'CNY', currencyCode: 'CNY',
}, },
} }
@@ -104,7 +111,7 @@ function collect1688(root: Document, pageUrl: string, bridgedState?: unknown): C
}); });
} }
} }
return basePayload( const payload = basePayload(
'1688', '1688',
offerId(pageUrl), offerId(pageUrl),
root, root,
@@ -112,9 +119,29 @@ function collect1688(root: Document, pageUrl: string, bridgedState?: unknown): C
axes, axes,
uniqueVariants(variants), uniqueVariants(variants),
evidence, evidence,
'1688-structured-2.0.0', '1688-structured-2.1.0',
state, state,
); );
// SKU 图片由规格值和真实可售组合关联,独立于页面当时选中的 DOM 状态。
for (const variant of payload.variants) {
for (const selection of variant.selections) {
const rawUrl = axes.find((axis) => axis.key === selection.axisKey)
?.values.find((value) => value.key === selection.valueKey)?.image_url;
const url = rawUrl && normaliseImageUrl(rawUrl, pageUrl, '1688');
if (!url || payload.assets.length >= 500) continue;
if (payload.assets.some((asset) => asset.variantSourceKey === variant.sourceVariantKey && asset.url === url)) continue;
payload.assets.push({
url,
mediaType: 'image',
role: 'source_sku',
relation: 'axis_value',
variantSourceKey: variant.sourceVariantKey,
sortOrder: payload.assets.length,
evidenceRef: '1688:sku-map',
});
}
}
return payload;
} }
function collectTaobao( function collectTaobao(
@@ -187,7 +214,7 @@ function collectTaobao(
axes, axes,
uniqueVariants(variants), uniqueVariants(variants),
evidence, evidence,
`${platform}-structured-2.0.0`, `${platform}-structured-2.0.1`,
state, state,
); );
} }
@@ -218,7 +245,7 @@ function basePayload(
ref: `${platform}:dom:image-profile`, ref: `${platform}:dom:image-profile`,
kind: 'dom', kind: 'dom',
path: 'platform-scoped gallery / sku / detail selectors', path: 'platform-scoped gallery / sku / detail selectors',
raw_value: `采集 ${assets.length} 张受限商品区域图片`, raw_value: `采集 ${assets.filter((asset) => asset.mediaType === 'image').length} 张受限商品区域图片、${assets.filter((asset) => asset.mediaType === 'video').length} 个画廊视频`,
}); });
const videoCount = addMarketplaceVideos(platform, states, assets, canonical); const videoCount = addMarketplaceVideos(platform, states, assets, canonical);
if (videoCount) evidence.push({ if (videoCount) evidence.push({
@@ -259,8 +286,7 @@ function basePayload(
}; };
} }
/** The old collector's proven video sources are structured product state. Do /** 结构化画廊数据补充公开视频地址,与受限画廊 DOM 去重,不读取推荐或评价视频。 */
* not broaden this to page video elements: they also contain feeds and reviews. */
function addMarketplaceVideos( function addMarketplaceVideos(
platform: Exclude<SourcePlatform, 'ozon'>, platform: Exclude<SourcePlatform, 'ozon'>,
states: unknown[], states: unknown[],
@@ -285,16 +311,21 @@ function addMarketplaceVideos(
for (const entry of raw) { for (const entry of raw) {
if (assets.length >= 500) break; if (assets.length >= 500) break;
const record = isRecord(entry) ? entry : undefined; const record = isRecord(entry) ? entry : undefined;
const url = directVideoUrl( const candidates = typeof entry === 'string' ? [entry]
typeof entry === 'string' ? entry : stringAt(record ?? {}, ['url', 'videoUrl', 'src']), : ['url', 'videoUrl', 'videoURL', 'videoAddress', 'address', 'src', 'playUrl']
pageUrl, .map((key) => stringAt(record ?? {}, [key]));
); const url = candidates.map((candidate) => directVideoUrl(candidate, pageUrl)).find(Boolean);
if (!url || seen.has(`video:${url}`)) continue; if (!url) continue;
seen.add(`video:${url}`);
const posterUrl = imageUrl( const posterUrl = imageUrl(
stringAt(record ?? {}, ['videoThumbnailURL', 'thumbnailUrl', 'coverUrl', 'coverImage', 'poster']), stringAt(record ?? {}, ['videoThumbnailURL', 'thumbnailUrl', 'coverUrl', 'coverImage', 'poster']),
pageUrl, pageUrl,
); );
if (seen.has(`video:${url}`)) {
const existing = assets.find((asset) => asset.mediaType === 'video' && asset.url === url);
if (existing && !existing.posterUrl && posterUrl) existing.posterUrl = posterUrl;
continue;
}
seen.add(`video:${url}`);
assets.push({ assets.push({
url, url,
mediaType: 'video', mediaType: 'video',
@@ -310,14 +341,7 @@ function addMarketplaceVideos(
} }
function directVideoUrl(value: string | undefined, pageUrl: string) { function directVideoUrl(value: string | undefined, pageUrl: string) {
if (!value) return undefined; return value ? normaliseVideoUrl(value, pageUrl) : undefined;
try {
const url = new URL(value, pageUrl);
if (url.protocol !== 'https:' || /\.m3u8(?:$|[?#])/i.test(url.pathname)) return undefined;
return /\.(mp4|webm)(?:$|[?#])/i.test(url.pathname) ? url.href : undefined;
} catch {
return undefined;
}
} }
function imageUrl(value: string | undefined, pageUrl: string) { function imageUrl(value: string | undefined, pageUrl: string) {
@@ -357,7 +381,11 @@ function extractMarketplaceFacts(root: Document, states: unknown[], platform: Ex
return { return {
title: (isRecord(item) ? stringAt(item, ['title', 'name']) : undefined) ?? (isRecord(temp) ? stringAt(temp, ['offerTitle']) : undefined) ?? (product ? stringAt(product, ['name']) : undefined), title: (isRecord(item) ? stringAt(item, ['title', 'name']) : undefined) ?? (isRecord(temp) ? stringAt(temp, ['offerTitle']) : undefined) ?? (product ? stringAt(product, ['name']) : undefined),
price, price,
description: (product ? stringAt(product, ['description']) : undefined) ?? scopedDescription(root, platform), description: formatCollectedDescription(
(product ? stringAt(product, ['description']) : undefined) ??
scopedDescription(root, platform) ??
'',
) || undefined,
attributes, attributes,
categories: [...new Set(categoryNames)].map((name) => ({ key: `${platform}:category:${name.toLowerCase()}`.slice(0, 300), name })), categories: [...new Set(categoryNames)].map((name) => ({ key: `${platform}:category:${name.toLowerCase()}`.slice(0, 300), name })),
}; };
@@ -368,7 +396,10 @@ function scopedDescription(root: Document, platform: Exclude<SourcePlatform, 'oz
? ['.de-description-detail', '#detailContentContainer', '.html-description'] ? ['.de-description-detail', '#detailContentContainer', '.html-description']
: ['[class*="tabDetailWrap--"]', '[class*="detailInfo--"]']; : ['[class*="tabDetailWrap--"]', '[class*="detailInfo--"]'];
for (const selector of selectors) { for (const selector of selectors) {
const value = root.querySelector(selector)?.textContent?.replace(/\s+/g, ' ').trim(); const element = root.querySelector<HTMLElement>(selector);
const value = formatCollectedDescription(
element?.innerText ?? element?.textContent ?? '',
);
if (value && value.length >= 10) return value.slice(0, 100_000); if (value && value.length >= 10) return value.slice(0, 100_000);
} }
return undefined; return undefined;
@@ -403,23 +434,31 @@ function pageState(root: Document) {
} }
} }
for (const marker of [ for (const marker of [
'gallery',
'skuModel',
'skuInfoMap',
'skuMapOriginal', 'skuMapOriginal',
'skuBase', 'skuBase',
'skuCore', 'skuCore',
'skuProps', 'skuProps',
]) { ]) {
const object = objectAfterMarker(text, marker); const object = objectAfterMarker(text, marker);
if (object) all.push(object); if (object) all.push({ [marker]: object });
} }
} }
return all; return all;
} }
function objectAfterMarker(text: string, marker: string): unknown | null { function objectAfterMarker(text: string, marker: string): unknown | null {
const position = text.indexOf(marker); // 只读取对象属性的直接字面值,避免从字段名字符串或 null 跳到后续无关对象。
if (position < 0) return null; const property = new RegExp(`(?:^|[,{])\\s*(?:"${marker}"|'${marker}'|${marker})\\s*:\\s*(?=[\\[{])`, 'g');
const start = text.slice(position).search(/[\[{]/); for (const match of text.matchAll(property)) {
if (start < 0) return null; const first = match.index! + match[0].length;
const first = position + start; const parsed = objectLiteralAt(text, first);
if (parsed !== null) return parsed;
}
return null;
}
function objectLiteralAt(text: string, first: number): unknown | null {
const open = text[first]; const open = text[first];
const close = open === '{' ? '}' : ']'; const close = open === '{' ? '}' : ']';
let depth = 0; let depth = 0;
@@ -581,6 +620,19 @@ function selectionsForCombination(combination: string, axes: Axis[]) {
: []; : [];
}); });
} }
function selectionsFor1688Combination(combination: string, axes: Axis[]) {
const selected = selectionsForCombination(combination, axes);
if (selected.length === axes.length) return selected;
const parts = combination.split(/[;,&|]/).map((part) => part.trim()).filter(Boolean);
return axes.flatMap((axis) => {
const existing = selected.find((item) => item.axisKey === axis.key);
if (existing) return [existing];
const value = axis.values.find((item) =>
parts.some((part) => part === item.name || part === `${axis.name}:${item.name}`)
);
return value ? [{ axisKey: axis.key, valueKey: value.key, valueName: value.name }] : [];
});
}
function stringAt(value: Json, keys: string[]) { function stringAt(value: Json, keys: string[]) {
for (const key of keys) for (const key of keys)
if (typeof value[key] === 'string' || typeof value[key] === 'number') if (typeof value[key] === 'string' || typeof value[key] === 'number')
+65
View File
@@ -0,0 +1,65 @@
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import { createRequire } from 'node:module';
import test from 'node:test';
import { collectedProductPayloadSchema } from '@atlas/contracts';
import { collectPlatformDomAssets, normaliseImageUrl } from '../lib/product-media.ts';
import { collectCurrentProduct } from '../lib/source-parsers.ts';
// 使用 WXT 已有的 HTML 解析依赖构建离线 DOM,不加载样本里的远程媒体。
const require = createRequire(import.meta.url);
const wxtRequire = createRequire(require.resolve('wxt'));
const { parseHTML } = wxtRequire('linkedom');
const pageUrl = 'https://detail.1688.com/offer/1012823100668.html';
const videoUrl = 'https://cloud.video.taobao.com/qa-product.mp4';
const poster = 'https://cbu01.alicdn.com/img/ibank/O1CN01lCwIgP1UjKGylKwAN_!!2563422553-0-cib.jpg';
async function fixture(extra = ''): Promise<Document> {
const html = await readFile(new URL('./fixtures/1688-gallery-background.html', import.meta.url), 'utf8');
const { document, window } = parseHTML(`<html><body>${html}${extra}</body></html>`);
window.getComputedStyle = (element: HTMLElement) => ({ backgroundImage: element.style.backgroundImage });
Object.defineProperty(document, 'scripts', { get: () => document.querySelectorAll('script') });
for (const element of document.querySelectorAll('.v-image-cover')) {
Object.defineProperty(element, 'offsetWidth', { value: 60 });
Object.defineProperty(element, 'offsetHeight', { value: 60 });
}
return document;
}
test('用户画廊样本的 15 张背景缩略图完整采集、还原原图,排除参数与视频控件', async () => {
const assets = collectPlatformDomAssets('1688', await fixture(), pageUrl);
assert.equal(assets.length, 15);
assert.ok(assets.every((asset) => asset.mediaType === 'image' && asset.role === 'source_main'));
assert.equal(assets[0].url, poster);
assert.ok(assets.every((asset) => asset.url.endsWith('.jpg') && !asset.url.includes('_b.jpg')));
assert.ok(assets.every((asset) => !/undefined|\.svg/.test(asset.url)));
assert.equal(normaliseImageUrl('undefined', pageUrl, '1688'), undefined);
});
test('画廊播放器可采集真实视频及封面,忽略推荐视频、blob 和 HLS', async () => {
const root = await fixture(`<div class="od-picture-gallery"><video src="${videoUrl}"></video><video src="blob:https://detail.1688.com/opaque"></video><video src="https://cloud.video.taobao.com/stream.m3u8"></video></div><aside><video src="https://cloud.video.taobao.com/recommend.mp4"></video></aside>`);
const assets = collectPlatformDomAssets('1688', root, pageUrl);
const videos = assets.filter((asset) => asset.mediaType === 'video');
assert.equal(videos.length, 1);
assert.equal(videos[0].url, videoUrl);
assert.equal(videos[0].posterUrl, poster);
assert.equal(assets.filter((asset) => asset.mediaType === 'image').length, 15);
});
test('脚本画廊数据与 DOM 视频去重,完整采集载荷符合 API 契约', async () => {
const gallery = { fields: { video: { url: videoUrl, videoThumbnailURL: poster } } };
const root = await fixture(`<div class="od-picture-gallery"><video><source src="${videoUrl}"></video></div><script>window.context=${JSON.stringify({ gallery })};</script>`);
const payload = await collectCurrentProduct(root, pageUrl);
assert.equal(payload.assets.filter((asset) => asset.mediaType === 'video').length, 1);
assert.equal(payload.assets.filter((asset) => asset.mediaType === 'image').length, 15);
assert.equal(collectedProductPayloadSchema.safeParse(payload).success, true);
});
test('尚未挂载播放器时仍可从明确的 gallery 字面值读取视频直链', async () => {
const gallery = { fields: { video: { url: '', videoAddress: videoUrl, videoThumbnailURL: poster } } };
const payload = await collectCurrentProduct(await fixture(`<script>window.context=${JSON.stringify({ gallery })};</script>`), pageUrl);
const videos = payload.assets.filter((asset) => asset.mediaType === 'video');
assert.equal(videos.length, 1);
assert.equal(videos[0].url, videoUrl);
assert.equal(videos[0].posterUrl, poster);
});
+116
View File
@@ -0,0 +1,116 @@
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import test from 'node:test';
import { collectCurrentProduct } from '../lib/source-parsers.ts';
import { collectedProductPayloadSchema } from '@atlas/contracts';
function fixtureSkuModel(html: string) {
const marker = '"skuModel": ';
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);
assert.equal(collectedProductPayloadSchema.safeParse(payload).success, true);
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);
});
test('1688 超过 100 条真实可售记录完整解析,不按旧上限裁剪', async () => {
const root = { scripts: [], querySelector: () => null, querySelectorAll: () => [] } as unknown as Document;
const skuInfoMap = Object.fromEntries(Array.from({ length: 200 }, (_, index) => [
`组合 ${index}`, { skuId: String(8_000_000_000_000 + index), price: '20.00' },
]));
const payload = await collectCurrentProduct(root, 'https://detail.1688.com/offer/123456789.html', undefined,
async () => ({ skuModel: { skuInfoMap } }));
assert.equal(payload.variants.length, 200);
assert.equal(payload.variants.at(-1)?.sourceSku, String(8_000_000_000_199));
assert.equal(collectedProductPayloadSchema.safeParse(payload).success, true);
});
test('字段名字符串与后续 150 个无关对象不会被误采为 SKU', async () => {
const unrelated = Object.fromEntries(Array.from({ length: 150 }, (_, index) => [
`metadata${index}`, { label: '推荐数据' },
]));
const model = {
skuInfoMap: { 白色: { skuId: '1001', price: '20.00' }, 蓝色: { skuId: '1002', price: '20.00' } },
skuProps: [{ prop: 'color', name: '颜色', value: [{ valueId: 'white', name: '白色' }, { valueId: 'blue', name: '蓝色' }] }],
};
const script = `const key="skuInfoMap";const unrelated=${JSON.stringify(unrelated)};window.context=${JSON.stringify({ skuModel: model })};`;
const root = { scripts: [{ type: 'text/javascript', textContent: script }], querySelector: () => null, querySelectorAll: () => [] } as unknown as Document;
const payload = await collectCurrentProduct(root, 'https://detail.1688.com/offer/123456789.html');
assert.deepEqual(payload.variants.map((variant) => variant.sourceSku), ['1001', '1002']);
assert.deepEqual(payload.variants.map((variant) => variant.displayName), ['白色', '蓝色']);
});
test('skuInfoMap 为 null 时不跳读后续对象,仍读取明确的原始可售表', async () => {
const script = `window.context={"skuInfoMap":null,"unrelated":{"bad":{"title":"不是 SKU"}},"skuMapOriginal":[{"skuId":"1001","specAttrs":"白色","price":"20.00"}]};`;
const root = { scripts: [{ type: 'text/javascript', textContent: script }], querySelector: () => null, querySelectorAll: () => [] } as unknown as Document;
const payload = await collectCurrentProduct(root, 'https://detail.1688.com/offer/123456789.html');
assert.equal(payload.variants.length, 1);
assert.equal(payload.variants[0].sourceSku, '1001');
});
test('真实规格表内的元数据不生成 SKU,无 ID 的完整可售组合仍保留', async () => {
const root = { scripts: [], querySelector: () => null, querySelectorAll: () => [] } as unknown as Document;
const model = {
skuInfoMap: {
metadata: { label: '元数据' },
未匹配颜色: { price: '30.00' },
白色: { price: '20.00', canBookCount: 0 },
},
skuProps: [{ prop: 'color', name: '颜色', value: [{ valueId: 'white', name: '白色' }] }],
};
const payload = await collectCurrentProduct(root, 'https://detail.1688.com/offer/123456789.html', undefined,
async () => ({ skuModel: model }));
assert.equal(payload.variants.length, 1);
assert.equal(payload.variants[0].sourceVariantKey, '1688:白色');
assert.equal(payload.variants[0].selections[0].valueKey, 'white');
});
@@ -0,0 +1,21 @@
<div class="od-picture-gallery">
<ul class="od-picture-gallery-list">
<li class="v-image-cover video-image-cover tag-image-cover" style="background-image:url('https://cbu01.alicdn.com/img/ibank/O1CN01lCwIgP1UjKGylKwAN_!!2563422553-0-cib.jpg_b.jpg')"><span class="video-type-name">视频</span></li>
<li class="v-image-cover" style="background-image:url('https://cbu01.alicdn.com/img/ibank/O1CN01lCwIgP1UjKGylKwAN_!!2563422553-0-cib.jpg_b.jpg')"></li>
<li class="v-image-cover image-item-active" style="background-image:url('https://cbu01.alicdn.com/img/ibank/O1CN01gb1fgp1UjKGxwxP2i_!!2563422553-0-cib.jpg_b.jpg')"></li>
<li class="v-image-cover" style="background-image:url('https://cbu01.alicdn.com/img/ibank/O1CN01aF0MUa1UjKGyhzb5T_!!2563422553-0-cib.jpg_b.jpg')"></li>
<li class="v-image-cover" style="background-image:url('https://cbu01.alicdn.com/img/ibank/O1CN01v0hY3O1UjKGxb6l8V_!!2563422553-0-cib.jpg_b.jpg')"></li>
<li class="v-image-cover" style="background-image:url('https://cbu01.alicdn.com/img/ibank/O1CN01p7D3oc1UjKGyxpfs6_!!2563422553-0-cib.jpg_b.jpg')"></li>
<li class="v-image-cover" style="background-image:url('undefined')"><img class="cpv-icon" src="https://img.alicdn.com/imgextra/i2/O1CN01pa4yFv245mEaNULYJ_!!6000000007340-55-tps-24-24.svg"><span class="cpv-tag-name">参数</span></li>
<li class="v-image-cover" style="background-image:url('https://cbu01.alicdn.com/img/ibank/O1CN01yJR26t1UjKINnDDxH_!!2563422553-0-cib.jpg_b.jpg')"></li>
<li class="v-image-cover" style="background-image:url('https://cbu01.alicdn.com/img/ibank/O1CN01jpXmyT1UjKINnG6rY_!!2563422553-0-cib.jpg_b.jpg')"></li>
<li class="v-image-cover" style="background-image:url('https://cbu01.alicdn.com/img/ibank/O1CN010SAH2j1UjKINnJKfy_!!2563422553-0-cib.jpg_b.jpg')"></li>
<li class="v-image-cover" style="background-image:url('https://cbu01.alicdn.com/img/ibank/O1CN01QYAYN91UjKIPWV6Z4_!!2563422553-0-cib.jpg_b.jpg')"></li>
<li class="v-image-cover" style="background-image:url('https://cbu01.alicdn.com/img/ibank/O1CN01zhfBE81UjKIO5c0qt_!!2563422553-0-cib.jpg_b.jpg')"></li>
<li class="v-image-cover" style="background-image:url('https://cbu01.alicdn.com/img/ibank/O1CN01Ddm4iM1UjKIOXEug1_!!2563422553-0-cib.jpg_b.jpg')"></li>
<li class="v-image-cover" style="background-image:url('https://cbu01.alicdn.com/img/ibank/O1CN01UEg1Qk1UjKIPWn1rT_!!2563422553-0-cib.jpg_b.jpg')"></li>
<li class="v-image-cover" style="background-image:url('https://cbu01.alicdn.com/img/ibank/O1CN01aEAp2k1UjKINoYpQ1_!!2563422553-0-cib.jpg_b.jpg')"></li>
<li class="v-image-cover" style="background-image:url('https://cbu01.alicdn.com/img/ibank/O1CN01PdBVY31UjKIjUplsf_!!2563422553-0-cib.jpg_b.jpg')"></li>
<li class="v-image-cover" style="background-image:url('https://cbu01.alicdn.com/img/ibank/O1CN01DBpghm1UjKIjVh3Ru_!!2563422553-0-cib.jpg_b.jpg')"></li>
</ul>
</div>
@@ -9,6 +9,7 @@ import {
extractOzonGalleryImageUrls, extractOzonGalleryImageUrls,
extractOzonGalleryVideos, extractOzonGalleryVideos,
} from '../lib/ozon-parser.ts'; } from '../lib/ozon-parser.ts';
import { formatCollectedDescription } from '../lib/description-format.ts';
import { dedupeImageUrl, normaliseImageUrl } from '../lib/product-media.ts'; import { dedupeImageUrl, normaliseImageUrl } from '../lib/product-media.ts';
test('Ozon gallery fixture only returns product-gallery images', async () => { test('Ozon gallery fixture only returns product-gallery images', async () => {
@@ -93,6 +94,15 @@ test('Ozon JSON-LD Product description is the fallback when rich content is abse
); );
}); });
test('采集描述保留段落和列表,并为压平的 Ozon 结构标题补出换行', () => {
assert.equal(
formatCollectedDescription(
'Описание товара:适合户外使用。Характеристики:- 容量:25 L- 材质:尼龙Преимущества:- 防水\n\nКомплектация:<br>背包',
),
'Описание товара:\n适合户外使用。\n\nХарактеристики:\n- 容量:25 L\n- 材质:尼龙\n\nПреимущества:\n- 防水\n\nКомплектация:\n背包',
);
});
test('Ozon full characteristics merge with the five short header characteristics', () => { test('Ozon full characteristics merge with the five short header characteristics', () => {
const attributes = extractOzonCharacteristics([ const attributes = extractOzonCharacteristics([
{ {
@@ -0,0 +1,56 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
backgroundError,
requestError,
unwrapBackgroundResponse,
} from '../lib/request-errors.ts';
import { validateCollectedPayload } from '../lib/collection-payload-validation.ts';
import { PRODUCT_VARIANT_LIMIT, productPublishPreflightRequestSchema } from '@atlas/contracts';
const payload = {
source: {
platform: '1688', itemKey: '123456789',
canonicalUrl: 'https://detail.1688.com/offer/123456789.html',
collectedAt: '2026-10-05T10:00:00.000Z', parserVersion: 'test',
},
common: { attributes: [], categories: [] },
variantAxes: [], variants: [], assets: [], evidence: [],
};
test('采集支持超过 100 个 SKU,上限与草稿一致,超限时不静默截断', () => {
const variants = Array.from({ length: PRODUCT_VARIANT_LIMIT + 1 }, (_, index) => ({
sourceVariantKey: String(index), displayName: `SKU ${index}`,
selections: [], confidence: 'verified', evidenceRefs: [],
}));
assert.throws(() => validateCollectedPayload({ ...payload, variants }), /采集数据不符合要求.*variants/);
assert.equal(variants.length, PRODUCT_VARIANT_LIMIT + 1);
assert.equal(validateCollectedPayload({ ...payload, variants: variants.slice(0, 101) }).variants.length, 101);
assert.equal(validateCollectedPayload({ ...payload, variants: variants.slice(0, PRODUCT_VARIANT_LIMIT) }).variants.length, PRODUCT_VARIANT_LIMIT);
assert.equal(validateCollectedPayload(payload).source.itemKey, '123456789');
});
test('提高采集容量不放宽每批 50 个 SKU 的首次上架约束', () => {
const ids = Array.from({ length: 51 }, () => crypto.randomUUID());
assert.equal(productPublishPreflightRequestSchema.safeParse({ variant_ids: ids.slice(0, 50) }).success, true);
assert.equal(productPublishPreflightRequestSchema.safeParse({ variant_ids: ids }).success, false);
});
test('采集字段路径错误可经 Chrome 可序列化响应返回浮窗', () => {
const error = requestError({ error: {
message: '请求参数不符合要求',
details: [{ field: 'variantAxes.0.values.0.name', message: 'Too small: expected string to have >=1 characters' }],
} }, 422);
assert.equal(error.status, 422);
const response = JSON.parse(JSON.stringify(backgroundError(error)));
assert.throws(() => unwrapBackgroundResponse(response), /variantAxes\.0\.values\.0\.name/);
assert.throws(() => unwrapBackgroundResponse(response), /请求参数不符合要求/);
});
test('通用 HTTP 错误及空后台响应仍可操作,成功响应保持原有结构', () => {
assert.equal(requestError(null, 503).message, '请求失败(HTTP 503)');
assert.equal(requestError({ error: { message: '登录过期', details: [null, { token: '不应展示' }] } }, 401).message, '登录过期');
assert.throws(() => unwrapBackgroundResponse(undefined), /请重载扩展并刷新商品页/);
const success = { result: { data: { editor_entry_path: '/products/test/editor' } } };
assert.strictEqual(unwrapBackgroundResponse(success), success);
});
+9
View File
@@ -1,9 +1,18 @@
import { defineConfig } from 'wxt'; import { defineConfig } from 'wxt';
import { fileURLToPath } from 'node:url';
const atlasWebOrigin = new URL(process.env.ATLAS_WEB_ORIGIN ?? 'http://localhost:8100').origin; const atlasWebOrigin = new URL(process.env.ATLAS_WEB_ORIGIN ?? 'http://localhost:8100').origin;
export default defineConfig({ export default defineConfig({
modules: ['@wxt-dev/module-react'], modules: ['@wxt-dev/module-react'],
vite: () => ({
resolve: {
// 开发和正式构建均读取共享源码,防止开发包持续缓存旧的校验上限。
alias: {
'@atlas/contracts': fileURLToPath(new URL('../../packages/contracts/src/index.ts', import.meta.url)),
},
},
}),
// 扩展 dev server 固定 8110:默认取 3000–3010 第一个空闲端口,会漂移且易与常见服务冲突 // 扩展 dev server 固定 8110:默认取 3000–3010 第一个空闲端口,会漂移且易与常见服务冲突
dev: { dev: {
server: { server: {
+178 -7
View File
@@ -1,10 +1,148 @@
# Web 前端交接:商品编辑器与门店类目 # Web 前端交接:商品编辑器与 AI 填报
更新日期:2026-09-11 更新日期:2026-10-06
本文只覆盖 `apps/web/` 当前未提交的前端改动。开始继续开发前,请先查看仓库根目录的 `git status --short`;工作区同时存在服务端、Worker 和文档改动,它们不属于前端接手者的修改范围。 ## 2026-10-06 SKU 文案用户要求与弹窗重设计
## 当前完成状态 SKU 文案弹窗以「用户描述 / 生成要求」为主要输入(1000 字符,计数、生成时禁用),标明优先采用;当前 SKU 名称独立展示,采集标题/描述/规格移入默认折叠的辅助参考,右侧按标题/标签/描述展示俄文与中文对照,窄屏单列且内容可滚动。用户要求通过既有 `hint` 提交,按 SKU 暂存于页面内;不写采集信息或商品草稿。要求修改会清空旧预览并禁用应用。生成/重新生成/应用状态明确,仍需用户应用并保存。
Web 请求方法接受共享 `RequestProductAiTask`,复用共享 1000 字符常量;API 字段和校验规则不变。Worker 将用户要求作为独立高优先级输入,明确用户补充或修正优先于采集资料、旧 SKU 和既有文案,辅助 SKU 摘要仅包含当前 SKU,输出格式/俄文/标签约束继续生效。前端通过请求序号隔离关闭或切换 SKU 后完成的旧任务,并核对结果 SKU 与采集/产品/profile 版本。应用当前标题需切换逐 SKU 模式时,保留其他 SKU 原先继承的标题和中文对照,修复旧行为清空其他 SKU 标题的问题。
Contracts/API(含测试)/Worker/Web 类型检查及构建通过;API 单元测试 2 项、Worker 文案测试 12 项通过,均模拟数据库或供应商,不创建真实任务或收费请求。ego-browser TaskSpace 9 的真实 ProductEditPage + 模拟 API 验证 hint 请求、修改要求使旧结果失效、按 SKU 保留要求、关闭/切换后旧结果不回填、加载禁用输入、当前 SKU 应用/模拟保存、中俄对照、其他 SKU 标题保留、空要求兼容、1000 字符边界及 1280/390px 布局。弹窗在 390px 下宽度/scrollWidth 均 374px;底层编辑页有原有 478px 横向宽度,本轮未扩展修改该布局。临时页面与浏览器已清理,未读写真实商品、调用 AI 供应商或 Ozon,未手动启停维护者服务。Web 原有大包体警告保留。
## 2026-10-06 SKU 货号以型号为自动前缀
本轮仅修改 Web 商品编辑器、SKU 信息区及局部样式,新增 `skuOfferId.ts` 与 `SkuOfferIdInput.tsx`,并同步 API/AI 方案与决策说明;HTTP 字段、服务端、数据库、Worker 和扩展无改动。货号控件占整行,型号加连接符为只读前缀,用户只输入 SKU 后缀,表单与保存请求继续持有完整 `offer_id`。手工修改型号同步全部未导入 SKU,包括未挂载的收起项;使用完整表单值,避免丢失采购价、选图等资料。清空型号保留后缀,清空后缀保持空货号。已导入/进行中货号只读并保留历史事实,已有型号锁定规则继续生效。
加载时匹配当前型号的旧货号拆出后缀;未匹配的旧值完整保留为后缀,补前缀只进入待保存草稿并标记未保存,不做后台批量写入。AI 结果的生成型号前缀先拆出,再按表单型号组合,用户非空后缀不被覆盖。完整货号限制 50 字符;输入长度扣除前缀占用,型号变长不截断已有后缀,超限保存给予明确提示。
Web 类型检查与生产构建通过,原有大包体警告保留。ego-browser TaskSpace 8 的隔离真实 SkuInfoPanel/Form 验证前缀及后缀编辑、反复修改/清空型号、收起字段联动、旧值完整保留、空后缀、采购价/图片保留、已上架只读、50 字符输入上限和 390/1280px 布局;长型号导致整串 57 字符时,38 字符旧后缀完整保留。临时 QA 页面和浏览器已清理,没有读取/保存真实商品或触发发布;未手动启停维护者现有服务。
## 2026-10-06 采集与编辑保存支持超过 100 个 SKU
用户补充的明确字段错误为 `variants: Too big: expected array to have <=100 items`,关闭上一轮待定位事项:根因是插件与 API 共享的采集数组上限为 100,且编辑完整草稿也有独立的 100 项上限,真实 1688 组合超过此值即失败。当前集成会话修改共享契约、API 采集响应统计、扩展/API 回归与 API/交接文档。新增 `PRODUCT_VARIANT_LIMIT=1000`,采集和商品完整保存统一使用;超限中文提示,不静默截断 SKU。采集响应的 `variant_count` 改为实际未删除 SKU 数。首次发布每批 50 个、媒体数量、其他字段约束、工作空间采集商品配额及 Ozon 公开页有界补全规则均保持原边界,无数据库迁移或 Worker/Web 业务逻辑修改。
扩展回归 15 项通过,验证 1688 200 条明确可售记录完整解析、101/1000 条接受、1001 条拒绝和上架 50/51 条边界。API 采集/商品编辑 16 项通过,新增完整 1000 SKU 的采集入库、编辑初始化、读取末尾 SKU、完整草稿保存、复采保留 ID 与人工修改、超限请求不改变来源版本或 SKU 数;测试数据隔离,无真实采集或 Ozon 发布。Contracts/API(含测试)/Web/Worker/Extension 类型检查和构建通过;Web 原有包体警告保留,差异检查通过。维护者现有 API 开发进程随源码自动重载,未手动启停服务;插件静态产物已重建,需重载扩展并刷新来源页后再次采集。
## 2026-10-05 采集插件编辑上架进入商品编辑器
修改扩展 `collector.content.ts`、API `extension.service.ts`、Web 编辑页及 `productEditor` 服务和 API/交接文档。根因是采集响应仍提供测试页路径,且新商品尚未初始化平台编辑配置。采集成功响应改为 `/products/{product_id}/editor?collection_item_id={collection_item_id}`,扩展继续使用响应入口并提示「已打开商品编辑页」。Web 携带采集 ID 调用现有 `editor-contexts`,核对商品 ID 后读取快照;当前页头平台和默认店铺规则沿用既有契约。重复采集进入原商品,既有上下文保留资料和上架状态;「采集商品」仍不打开编辑页,没有自动上架、媒体下载或平台绑定写入采集事务。测试页和旧手工商品入口保留,无新增 HTTP 字段或数据库迁移。
API 采集/商品编辑集成回归 15 项通过,覆盖新入口实际商品 ID、幂等重放、复采路径稳定以及首次上下文初始化。API 源码/测试类型检查与构建、Web 类型检查/构建、扩展 WXT prepare/类型检查/Chrome MV3 构建通过;Web 原有包体大小警告保留。ego-browser 隔离真实前端服务验证首次入口先初始化后读快照,普通既有入口直接读取,商品不匹配及无默认店铺阻断读取,并沿用当前平台。临时页面和任务浏览器已清理,没有采集/保存真实商品或调用 Ozon,未手动启停维护者服务。扩展新产物位于 `apps/extension/.output/chrome-mv3`,已加载静态扩展的浏览器需重载扩展并刷新来源页以更新成功提示;既有插件已使用响应入口,API 更新即可更改下一次跳转地址。
## 2026-10-05 已上架 SKU 本地价格重新试算
本轮跨 Web `SkuPricing`、定价 API、商品编辑读模型、共享契约与接口/交接文档。已上架 SKU 开放「重新试算」和「试算详情」,应用价格前确认:「该 SKU 已上架。本次价格调整仅保存为本地记录,不会同步至 Ozon;如需修改线上价格,请前往 Ozon 后台。」取消不修改价格,确认后更新草稿,仍须保存商品。批量计算和自动试算继续跳过已导入项。
编辑快照新增 `variants[].listing.store_id`。已上架 SKU 固定读取首次导入店铺的定价目录和币种,即使 profile 的将来目标店铺已变更。API 允许此 SKU 生成预览,跨店铺试算返回 `PRICING_VARIANT_STORE_MISMATCH`;显式 `pricing_selection` 按已有有效期、输入、规则和幂等边界确认新本地报价,普通草稿保存保留原报价。保持首次导入状态、店铺、货号、平台商品 ID 和导入时间,不创建发布或价格同步任务,无数据库迁移、Worker 或扩展业务改动。
定价保存 13 项、首次发布 11 项、商品编辑 13 项和采集 2 项集成测试通过,覆盖已上架新报价、错误店铺拦截、失效报价原子回滚、幂等重放、普通保存保留报价及零平台任务。Contracts/API/Web/Worker 类型检查与构建通过;Web 存在原有大包体警告。ego-browser 隔离真实组件验证两个入口均可用,取消不改变草稿,确认后实际售价 120 / 划线价 240,始终使用首次导入店铺,批量不自动改价。临时页面与代理浏览器已清理;未保存真实商品或操作 Ozon,没有手动启停维护者服务。
## 2026-10-05 采集页面地址回填货源链接
当前集成会话修改 API `extension.service.ts` 新采集商品写入、`product-editor.service.ts`/models 编辑读模型、extension HTTP 集成回归以及 API/交接文档。插件既有解析器已上报 `source.canonicalUrl`,问题是地址只保存于采集记录,未用于商品货源字段。新建商品同时初始化 `purchase_url`;编辑读模型优先使用手工非空链接,空值回退关联采集记录 `source_url`,历史商品无需复采,手动无来源商品仍为空。清空表示恢复来源地址,复采不覆盖手工非空链接。不修改插件、HTTP 字段或数据库,无历史草稿批量写入。
采集授权/素材 HTTP 2 项与商品编辑 13 项通过,覆盖新 1688 地址初始化、历史空字段回填、Ozon 地址、复采保留手工链接和既有商品编辑边界;API 源码/测试类型检查与构建、Web 类型检查/构建及差异检查通过。在用户当前商品的独立浏览器验证页确认 1688 来源地址已显示、打开按钮启用,输入框与按钮均 35px 且对齐。原用户页面未重载或保存,避免丢失编辑;API 开发进程自动重载,无手动启停。未向真实平台发布,未重新采集或写入真实商品。
## 2026-10-05 货源链接按钮与输入框等高
仅修改 Web `ProductOtherSection` 和编辑页局部样式,货源链接 Compact 组增加专用类,按钮按输入框所在 flex 行伸展高度,避免 Input 的 35px 主题高度与 Button 的默认 32px 不一致;添加按钮可访问名称。其他按钮样式和打开链接行为不变,无接口变更。
Web 类型检查、构建及差异检查通过;ego-browser 隔离真实组件在 620/390/1280px 宽度检查启用与禁用按钮,均与输入框同为 35px 且顶部对齐,打开回调正常。临时页面已清理,未修改真实商品,未启停原有服务。
## 2026-10-05 部分上架按钮显示进行中状态
本轮仅修改 Web `ProductPublishActions.tsx` 与交接文档,复用现有任务读取、提交和轮询接口,无 API、Worker、数据库或契约变更。「部分上架」按钮在任务未结束时显示排队中、提交中、等待 Ozon、等待对账或处理中,配合时钟/加载/提醒图标;running 任务按 SKU 状态细分阶段。点击进行中按钮直接打开任务详情,保持可查看,避免同一入口再次创建任务;任务成功、部分成功或失败后恢复正常部分上架选择入口。页面重载沿用现有最近任务恢复逻辑。
Web 类型检查、生产构建与差异检查通过。ego-browser 隔离真实组件验证页面恢复排队任务、轮询切换提交/等待平台/对账、进行中点击详情不保存或创建任务、失败与成功后恢复入口,以及新任务提交后立即显示排队状态;模拟提交仅一次。临时页面已清理,未向真实 Ozon 发布,未保存真实商品,未手动启停维护者服务。
## 2026-10-04 SKU 主动重算使用最新默认方案
根因:`SkuPricing.calculate` 优先使用 SKU 已保存的 `pricing_draft`,「重新试算」和批量按钮只强制重发相同参数,旧零折扣因此使实际售价与划线价相同。当前集成会话仅调整 Web `SkuPricing`、补充定价引擎公式回归测试及 API/交接说明,不修改服务端业务、HTTP 字段、定价引擎或数据库。
两个主动入口重读目标店铺定价目录,使用最新默认方案的完整参数替换旧 SKU 参数,沿用当前成本与有效包装;无默认方案/费率不可用/读取失败时明确提示,不回退零折扣。自动试算、手工售价输入和试算详情继续使用当前参数。批量跳过已导入 SKU,试算结果仍需保存草稿确认。表单监听保留未注册字段,返回报价直接核对实时表单,避免参数回填的渲染时序误丢弃报价;建立同一计算上下文时不取消已经发出的匹配请求。
验证:ego-browser 隔离真实组件复现旧零折扣两价相同,确认单 SKU 与批量使用最新 50% 折扣、划线价为实际售价两倍,保留成本和包装继承,跳过已上架 SKU;页面打开后将默认方案改为 25% 再重算能读取新值;手工实际售价不覆盖当前折扣;无默认方案、费率不可用、目录失败均不生成回退报价。定价引擎 16 项(新增 CNY/RUB 的 50% 折扣)和定价保存 12 项通过;Web/API 源码与测试类型检查、构建及差异检查通过。临时页面已清理,无真实商品保存或平台发布,未手动启停原有服务。
## 2026-10-04 实际售价、划线价展示与首次发布价格
本轮由当前集成会话负责,范围为 Web `SkuPricing`、`PricingCalculator`、`ProductPublishActions`、SKU 样式,API 发布映射/校验、发布回归测试和相关文档。按维护者要求,首次上架的 Ozon `price` 改为已确认 `listing.list_price`,不发送 `old_price`;保持 HTTP 字段、定价公式、数据库和 Worker 冻结快照执行方式。实际售价继续用于目标利润计算,用户上线后在 Ozon 调整实际售价;已创建任务沿用原价格快照,已导入 SKU 不自动更新。
SKU 收起摘要和展开定价区显示采购价、实际售价、划线价、预估净利润;试算结果实际售价与划线价同行。非人民币报价按同一报价快照比例换算摘要,不读取当前汇率改写历史价格。确认上架表格同时显示两价,划线价标为「本次上报价」。服务端预检与创建要求划线价有效且不低于实际售价,缺失/零值返回 `LIST_PRICE_REQUIRED`,低于实际售价返回 `LIST_PRICE_INVALID`;零折扣两价相同可发布。
验证:发布 11 项、商品编辑 13 项、定价引擎 15 项、定价保存 12 项全部通过,覆盖十进制划线价冻结/实际模拟 Ozon 请求、实际售价保留、零折扣、缺失/零划线价阻断、幂等和租户隔离。API 源码/测试类型检查及构建、Web 类型检查及构建通过。ego-browser 隔离真实组件检查 721px/390px 下的两价同行与无页面横向溢出、展开区、上架确认和 RUB 报价的人民币摘要;未向真实 Ozon 发布,未修改真实商品,原有服务未手动启停。临时验证页面已清理。
## 2026-10-04 主题标签格式与 AI 输出校验
本轮由当前集成会话负责,范围为共享 `ozon-hashtags.ts` 规则、Web SPU/SKU 标签提示、API 发布预检/创建校验、Worker AI 全局及 SKU 文案提示词与输出整理,以及相关测试和文档;不修改 HTTP 请求结构、数据库或标签覆盖继承语义。标签逐项以单个 # 开头,正文只允许字母、数字和下划线,无内部空格;每项含 # 最多 30 字符,最多 30 项,发布继续拼为一个 Ozon 属性值。AI 补齐缺失 #,词间空白及连字符转下划线,过滤非法、超长及重复项,并保留同项中文对照。
历史与手工草稿仍允许保存,Web 给出具体格式提示;预检和任务创建按所选 SKU 实际使用的标签拦截,返回 `OZON_HASHTAG_INVALID`,合法 SKU 独立覆盖不受未使用的通用标签阻断。Web 支持以空格拆分多个输入标签,不自动改写已有内容。新规则及示例见商品编辑 API 文档。
验证:Worker 11 项、发布 HTTP/数据库/模拟 Ozon 10 项、商品编辑 13 项通过;contracts/API/Worker/Web 类型检查与构建通过。ego-browser 隔离真实组件验证缺少 #、内部空格、非法标点、合法标签清除提示、空格拆分及 SKU 覆盖独立提示;临时页面已清理。未调用收费 AI 或向真实 Ozon 店铺发布,API/Worker 仍沿用原有开发监听进程,无手动启停。
## 2026-10-04 上架 SKU 整行选择
「选择与校验上架 SKU」弹窗支持点击整行选中/取消 SKU;复选框保留原有行为,点击不会因冒泡重复切换。行点击与复选框共用状态判断,只有未上架或失败 SKU 可选,已导入、提交中及其他不可重复提交状态仍禁选。仅修改 Web `ProductPublishActions.tsx` 和本文档,无接口、契约或发布规则变更。
Web 类型检查和生产构建通过;ego-browser 隔离页面验证整行选中/取消、复选框无重复切换、失败 SKU 可选、已导入/提交中禁选与全选。未执行真实预检或发布,临时页面已删除,未启停维护者服务。
## 2026-10-04 商品编辑属性与 SKU 文案显示修复
本轮负责人为当前前端会话,修改范围为 `ProductEditPage.tsx`、`DynamicAttributeFields.tsx`、`SkuInfoPanel.tsx` 和本文档;复用商品编辑快照、类目 schema、草稿保存与 AI 任务接口,无 API、Worker、数据库或共享契约变更。
属性草稿补齐从 schema 请求回调拆为独立表单联动:同类目保存重载过滤掉空属性后,仍补齐商品级与已有 SKU 的属性控件;动态属性组件监听顶层 `attributes`,避免缓存 schema 下不重新渲染。schema 请求仅随 profile、平台、类目或刷新版本变化,避免请求自身写入的缓存状态清理其轮询回调。AI 任务前后比较归一化后的保存内容,补齐空属性控件不会误判为人工修改。
SKU 展开区始终显示标题、标签、描述和文案 AI/中文对照入口;通用模式只预览 SPU 文案,不写入 SKU 覆盖值,开启逐 SKU 编辑后沿用原有字段绑定和标题模式。全局 AI 仍只生成 SPU 文案;类目属性仍按 schema 手工填写。
验证:Web 类型检查、生产构建和 `git diff --check` 通过。ego-browser 隔离模拟商品验证了 AI 选定类目后必填/选填及 SKU 属性出现、空属性保存重载后控件恢复、商品/SKU 属性作用域与重读、通用预览不产生覆盖值、AI 前自动保存后正常回填、缺失 schema 自动轮询恢复、逐 SKU 标题/描述编辑保存后恢复。未调用真实模型或写入真实商品;临时验证页面已删除。沿用维护者原有 Web/API 服务,无手动启动、停止或重启。
## 2026-10-04 套图生成固定操作区
按维护者确认的「并列操作」方案实现。生图策略列拆为独立滚动内容和底部常驻操作区;待生成保留 48px 主按钮,排队/生成时左侧取消、右侧状态,生成数量进度显示在下方。规划期间显示「取消规划」与规划状态,取消前确认并保留当前方案;生成取消前提示已完成数量,取消请求有重复操作保护。失败/取消后的未完成图片可在底部重试,成功图片保留;原结果列重复的进度与重试入口移至操作区。执行和重试的摘要取任务快照,调整下一轮方案不改变任务摘要;另保留按当前方案创建新任务的入口。
本轮负责人为当前集成会话,修改范围为 Web 抽屉、操作区组件、样式和交接记录,复用既有套图取消/重试接口,无共享契约、API、Worker 或数据库变更。桌面三列保持独立滚动,880px 以下两列时策略区随抽屉保持可见,580px 以下上下排列;策略高度取抽屉正文容器高度,避免依赖固定标题高度。当前业务页停留在登录页,使用临时隔离页面挂载真实组件,以模拟任务验证 1280px、838px、320px 布局、滚动固定、取消确认/继续生成、取消后保留成功图片、只重试未完成项、取消规划保留方案及任务快照摘要。未提交真实模型请求或修改商品草稿,临时验证页面已删除;Web 类型检查、生产构建和差异格式检查通过,沿用维护者原有开发服务,无手动重启。
## 2026-10-03 AI 套图生成
智能规划失败诊断已补齐:真实 DeepSeek 诊断复现连续两次在四张图片的 `type` 字段收到枚举校验错误。Worker 提示词改用有效的 `white_background` 示例和完整英文类型枚举,第二次规划携带具体字段路径及校验规则;共享错误码区分字段、张数、首图、主题、尺寸与保存失败。每次校验及最终失败打印结构化日志,安全诊断保存在 operation.result.failure。历史失败没有原始诊断,仍保持原记录。本轮实际模型请求限用户授权的两次,未执行生图;修复后的真实模型结果需下次规划确认。Worker 规划及 DeepSeek 客户端 19 项测试通过,覆盖同类错误反馈与纠正;contracts/API/Worker/Web 类型检查与构建通过,API/Worker 原有 watch 进程运行,无手动重启。
参考底图弹窗改为左右两列,复用 `SkuImageCard`、`SkuImageSourceTabs` 和 SKU 弹窗基础样式。左侧用 3:4 缩略图和顺序编号展示已选底图,第一张标为主体图,可移除、设为主体和拖动排序;右侧保留五类来源,点击图片选中/取消,左上角显示勾选。移除重复来源名称标签,不对原图裁切或拉伸。共享图片卡片允许不传编辑菜单,主体标记文案可配置,键盘操作只响应卡片自身,避免操作子按钮时重复触发。顶部数量靠左、灰色提示靠右;左右区域独立滚动,580px 以下上下排列。已在页面核对框比例 0.75、选择两张、主体切换、拖动排序、键盘移除和预览返回;Web 类型检查和构建通过,验证后恢复原有未选状态,未保存商品或调用生图。
本轮验证:Worker 规划/DeepSeek 客户端相关 17 项、API/数据库套图相关 8 项通过;contracts/API/Worker/Web 类型检查与构建通过。页面核对总张数由 4 调整到 5 后预算提示同步,已恢复默认 4。模型响应使用模拟数据,未实际调用 DeepSeek 或收费生图;没有手动启动或重启维护者服务。
智能规划按当前方案总张数提交 `image_count`(1–20,默认 4),预算独立于商品文本。DeepSeek 基于当前 SKU 的文字资料分配用途,按必要性选择场景、尺寸、材质等;规划完成后显示主题、说明和依据,不自动生图。取消/失败保留当前方案,诊断区分配置、连接和无效方案;历史恢复剥离规划预算字段,避免将其传入生成接口。
四类图片数量控件的减号按钮、输入框外框和加号按钮统一由 24px 加高至 28.8px(20%),输入内容垂直居中,宽度保持现有规格。
生成策略移除默认/AI 两个选项,改为右对齐的「AI 智能规划」操作按钮。默认四张自动展示;点击按钮异步规划并更新当前方案,规划成功后可再次点击重新规划;失败保留当前方案,规划期间禁用重复提交。
三列布局在上次策略列加宽的基础上,生图策略收窄 5%、商品资料加宽 5%;标准及 1100px 以下的三列布局同步调整比例和最小宽度。
界面反馈已落实:实物尺寸拆为长/宽/高,默认 CM,切换 MM/M 时换算实际数值,仍沿用带单位尺寸字符串契约。长/宽/高和实物重量统一使用 Ant Design `InputNumber`,支持小数;重量默认 g,可切换 kg 并换算实际净重,历史带明确单位的重量以 g 回填,提交仍使用带单位字符串。文本域为「采集信息/描述」「核心卖点/要求」。风格折叠标题、文本域和恢复默认按钮与风格按钮对齐,生成按钮高 48px。四类图片数量采用减号/输入框/加号组合,支持直接输入 0–5,边界禁用对应按钮;点击数量控件不触发分类展开/收起。
底部「AI 套图生成」已接入 `ProductImageSuiteDrawer` 三列右侧抽屉。目标为已保存 SKU,底图库最多 8 张,来源顺序采集/AI/处理/上传/网络;默认 4 张(白底/场景/核心卖点/细节各 1),分类数量 1/1/2/0,四大类逐张计划可编辑,AI 规划走异步 DeepSeek。Pcore 使用现有单图 Worker 生成子任务,整套与子任务通过数据库汇总,可后台生成、取消、失败重试。成功图片加入目标 SKU 后只修改表单,仍需保存。新契约见 [套图 API](../../docs/api/product-image-suites.md),迁移为 056–059。联调验证不执行真实收费生图,实际图像质量需用业务商品验证。
本文记录 Web 编辑页的现行状态;跨领域总体进度以 [`../../docs/project/HANDOFF.md`](../../docs/project/HANDOFF.md) 的 1.39 为准。开始继续开发前请查看仓库根目录的 `git status --short`,保留目前 API、Worker、contracts、迁移及 Web 中未提交和未跟踪的改动。
## 当前状态(2026-09-30)
- 编辑页 `/products/:id/editor` 的 `ProductEditPage.tsx` 将底部操作条固定在视口底部,提供「AI 填报」和「保存草稿」;AI 套图生成已接入三列右侧抽屉;首次上架已接入,见下方记录。全局 AI 填报会先保存未保存草稿,再创建异步任务并轮询;核对商品、profile、来源版本和本地表单未变化后,只把合格结果填进空字段,不直接持久化。用户仍须检查并保存。
- 全局 AI 只回填 SPU 发布标题、标签、描述和中文对照,以及合格的类目、型号、未导入 SKU 货号与有证据的包装建议。类目有多个候选时,「确认 AI 推荐类目」弹窗用单选卡片让用户选择;选择前不自动写入类目。类目一旦确定,页面按现有机制加载动态属性,但不调用 AI 填写 Ozon 类目属性。
- SPU 的「中文商品名」和「俄文标题」标示首次上架必填,草稿保存允许留空;俄文标题可在通用与按 SKU 设置间切换并保存模式,按 SKU 设置时清空、禁用 SPU 标题并在各 SKU 填写。标题、标签、描述各有中文对照入口;用户可在当前 SKU 弹窗中单独生成、预览并明确应用,不因全局 AI 填报而批量覆盖 SKU。
- SKU 的 Ozon `is_aspect` 字段现在直接放在展开卡片的「SKU 信息」中,不另显示「Ozon 变体属性」标题。产品类目 Cascader 的清除事件按空类目处理,避免读取 `undefined.at`。来源采集信息侧栏仍展示来源资料与差异处理。
- 关键组件:`ProductMainInfoSection.tsx`(名称、类目、SPU 标题等)、`ProductAttributeSection.tsx`(SPU 属性、标签与描述)、`ProductVariantSection.tsx`(SKU 信息及动态变体字段)、`ProductCopyAiDialogs.tsx`(SKU AI 生成与中俄对照)。商品编辑流程编排仍主要在 `ProductEditPage.tsx`。
下一轮前端回归应覆盖固定 footer 在不同视口/侧栏状态下不遮挡内容、类目清除和候选确认、AI 任务失败/部分成功/版本过期、SPU/SKU 文案继承与中文对照保存后重读。俄文禁汉字的最终拦截在 Worker,旧文案不会由前端自动清洗。本次更新只修改交接文档,未重新运行浏览器回归。
## 2026-09-30 首次上架
- `ProductPublishActions.tsx` 提供「上架至 OZON」「部分上架」,沿用父页面草稿保存与版本锁,按指定 SKU 调用预检;超过 50 个需分批选择,任一待提交项失败不创建任务。
- 选择表禁选已导入/进行中 SKU,并显示原因;确认摘要列出店铺、货号、售价和图片数量。任务列表恢复最近任务,轮询后仅合并 listing 状态,保留未保存表单。
- 发布标题最多 500、货号最多 50、SKU 图片最多 30;中文来源商品名仍为 1000。已导入或进行中锁定型号,进行中锁定换店;其他草稿资料可以编辑。
- Web 类型检查及构建通过。隔离数据库页面验证了禁选、全部/部分预检、缺货号阻断、确认提交和刷新恢复;没有向真实 Ozon 店铺导入。
- VAT 首期省略且生产发布默认关闭;真实店铺与七牛图片交付待验收。后端契约见 `docs/api/product-editor-and-publishing.md` 第 7 节。
## 2026-09-11 历史前端记录(以下内容仅供追溯)
### 商品编辑器 ### 商品编辑器
@@ -51,8 +189,41 @@ git diff --check
两项均通过。后续对交互或样式作变更时,应在已启动的 Web 环境中回归:类目选择、更多特征展开/收起与加载状态、字典查询、包装重量同一行展示、SKU 展开编辑、货源链接按钮对齐,以及调试页的树形渲染。 两项均通过。后续对交互或样式作变更时,应在已启动的 Web 环境中回归:类目选择、更多特征展开/收起与加载状态、字典查询、包装重量同一行展示、SKU 展开编辑、货源链接按钮对齐,以及调试页的树形渲染。
## 前端边界 ## 当前工作约定
- 仅修改 `apps/web/` 及前端相关文档。 - 当前任务可按仓库 `AGENTS.md` 修改相关项目代码;跨领域改动需遵守 [`../../docs/project/development-collaboration-contract.md`](../../docs/project/development-collaboration-contract.md) 并说明影响范围。
- 不启动、停止或修改 API/Worker;若类目刷新或 schema 请求失败,应先检查服务端运行状态与 API 契约,再交由服务端维护者处理。 - 验证需要时可在确认目标进程和端口后启动、停止或重启 API/Worker;完成后说明实际操作与状态。上方 2026-09-11 的验证记录不能替代新改动的检查。
- 不在浏览器持久化、日志或页面状态中保存 Ozon API Key。 - 不在浏览器持久化、日志或页面状态中保存 Ozon API Key。
## 2026-10-06 SKU 上传/网络图片按商品隔离
`useSkuImageLibrary` 必须接收 productId,两个候选 GET 携带 product_id,网络导入和商品上传意图绑定当前商品。变体图片弹窗与套图参考图均已接入;水印协议不变。同商品 SKU 可共用候选,切换商品立即隐藏旧候选并重置指纹和计数,延迟请求使用商品世代隔离。直接上传到 SKU 使用编辑会话序号隔离,关闭/切换后不追加旧结果。历史无归属候选不再全局展示,已有 SKU 选图保留。需先应用数据库迁移 060,API 与 Web 一起更新。浏览器真实 Hook 回归和 Web 类型检查/构建通过;临时 QA 文件与 TaskSpace 10 已清理。
## 2026-10-06 SKU 名称与货号恢复同行
修复型号前缀控件引入时将货号设置为整行的布局回归。Web `SkuInfoPanel` 将名称与货号放入独立两列基础资料行,`ProductVariantSection.css` 调整行布局和长型号前缀截断宽度;即使其余发布字段在窄容器中切为单列,基础资料仍保持同行。型号前缀、后缀编辑、完整货号与锁定规则不变,无 API/共享契约/数据库变更。Web 类型检查、生产构建和差异检查通过;ego-browser TaskSpace 11 验证 1280/620/390px 名称和货号输入同行、不溢出,并确认后缀修改仍组成完整货号。临时 QA 文件和浏览器已清理,未手动启停服务或修改真实商品,保留既有包体警告。
## 2026-10-06 SKU 候选图片永久删除
`SkuImageEditorModal` 右侧候选图片的「编辑」菜单最底部新增红色「删除」,二次确认明确同步永久删除七牛文件。左侧「移出已选」仅取消选图。接口确认接受后清理当前商品候选、所有未保存 SKU 选图及本地处理/预览缓存;删除状态轮询成功后才显示存储清理完成,失败可在确认框或弹窗「重试清理」入口恢复,重新打开可读取待处理任务。已保存引用的图片被服务端拒绝,提示先移除引用并保存。
依赖共享 `ProductImageDeletion` 和 `mediaApi` 商品删除/列表/读取/重试接口,API/Worker 与迁移 061、062 需同步更新,契约见 `docs/api/watermarks-and-assets.md`。Web 类型检查、生产构建通过;ego-browser TaskSpace 13 实际组件验证菜单、二次确认、取消、引用拒绝、失败提示和重试恢复,临时文件与浏览器已清理。后端另以真实七牛对象验证删除后 HEAD 404;没有删除真实用户图片,未手动启停服务,保留原有包体大小警告。
## 2026-10-06 定价试算费用显示精度
`PricingCalculator` 的费用明细统一四舍五入显示两位小数(包括零费用),不修改接口原始金额、计算精度或应用到 SKU 的价格数据。本轮仅修改前端显示。Web 类型检查、生产构建和差异检查通过,保留既有包体大小警告;未启停维护者服务。
## 2026-10-06 SKU 颜色多选与已选文本
`DynamicAttributeFields` 使用共享 `isOzonProductColorAttribute` / `productAttributeValueRules`,颜色属性 10096 在 schema 单值时仍支持多选,保存和发布也采用同一 100 项限制。控件初始化加载颜色字典,按属性 UUID 保留已选文本,搜索、候选分页替换和草稿回填不再让首项退回编号;加载前使用既有俄文颜色名兜底,未知值提示待加载。字典 ID 继续作为提交值,新类目不沿用旧名称缓存。
共享契约和 API 须一起更新;无迁移。Contracts/API/Web 类型检查及构建、25 项后端编辑/发布回归通过。ego-browser TaskSpace 14 验证真实 Form.List 控件的回填、多选、搜索、重新展开和类目隔离;临时文件与浏览器已清理,未修改真实商品或手动启停服务,保留既有包体警告。
## 2026-10-06 产品描述复制保留换行
CopyTextButton 直接读取原始文本写入剪贴板,保留内部换行和空行。通用描述、SKU 描述、中俄对照与 SKU AI 描述预览均增加明确复制按钮;SKU 空白覆盖值按有效文案复制 SPU 描述,未复制 HTML。上报由 Worker 仅对简介属性 4191 转成 Ozon 支持的 br;表单、草稿与快照仍是纯文本。已导入商品不会自动更新。Web 类型检查/构建及 ego-browser TaskSpace 16 真实组件模拟剪贴板验证通过;临时文件和浏览器已清理,未修改真实商品或手动启停服务,保留既有包体警告。
+46 -27
View File
@@ -94,36 +94,12 @@
margin-top: 16px; margin-top: 16px;
} }
/* 3x3 水印位置九宫格 */
.position-grid {
display: grid;
grid-template-columns: repeat(3, 44px);
grid-auto-rows: 44px;
gap: 8px;
}
.position-grid-cell {
border: 1px solid var(--border-color);
border-radius: 6px;
background: #f5f5f5;
cursor: pointer;
transition: all 0.2s;
padding: 0;
}
.position-grid-cell:hover {
border-color: var(--primary-light);
}
.position-grid-cell.active {
border-color: var(--primary-color);
background: rgba(139, 92, 246, 0.12);
}
/* 水印预览:模拟透明底图的棋盘格 */ /* 水印预览:模拟透明底图的棋盘格 */
.watermark-preview { .watermark-preview {
display: flex; display: flex;
padding: 12px; position: relative;
max-width: 100%;
padding: 5px;
box-sizing: border-box; box-sizing: border-box;
border: 1px dashed var(--border-color); border: 1px dashed var(--border-color);
border-radius: 4px; border-radius: 4px;
@@ -132,6 +108,49 @@
overflow: hidden; overflow: hidden;
} }
.watermark-preview-mark {
display: inline-flex;
position: absolute;
align-items: center;
justify-content: center;
max-width: calc(100% - 10px);
max-height: calc(100% - 10px);
overflow: hidden;
}
.watermark-preview-mark-draggable {
cursor: grab;
touch-action: none;
user-select: none;
}
.watermark-preview-mark-dragging {
cursor: grabbing;
z-index: 1;
}
.watermark-preview-dialog-content {
display: flex;
justify-content: center;
}
.watermark-preview-dialog-footer {
display: flex;
justify-content: center;
}
.watermark-editor-pair {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
column-gap: 16px;
}
@media (max-width: 575px) {
.watermark-editor-pair {
grid-template-columns: minmax(0, 1fr);
}
}
.watermark-form-actions { .watermark-form-actions {
display: flex; display: flex;
justify-content: center; justify-content: center;

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