Compare commits
28 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 06dbd817f0 | |||
| 79b310efe7 | |||
| e20cd7547f | |||
| 696112e74d | |||
| 45871584b3 | |||
| 0a303414d8 | |||
| 84b230e69a | |||
| a29dd92676 | |||
| e670d06ca6 | |||
| d24ce8f4e0 | |||
| b3958166dd | |||
| 95a2a37f1b | |||
| 2b06ef0692 | |||
| 05747d6f22 | |||
| 75fa2b60f3 | |||
| e0e07be4ea | |||
| 880e441bc6 | |||
| 388429dbd0 | |||
| fcddb11da0 | |||
| df3fb0c754 | |||
| 5684cbd517 | |||
| 52ef75f719 | |||
| a66b07652b | |||
| 7f68aad9f3 | |||
| 3156a7516f | |||
| da7342b466 | |||
| 1b03ea5871 | |||
| 2c4e94ab3d |
@@ -84,3 +84,25 @@ MINIO_CONSOLE_PORT=9001
|
||||
WEB_PORT=8100
|
||||
# Local WXT browser-extension development port.
|
||||
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=
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
# AGENTS.md — 工作区工作约定
|
||||
|
||||
本工作区的 AI 会话必须遵守以下规则,优先级高于会话内的自行判断。
|
||||
|
||||
## 1. 可修改全部项目代码
|
||||
|
||||
- 可按任务需要修改 `apps/web/`、`apps/api/`、`apps/worker/`、`apps/extension/`、`packages/` 及相关文档。
|
||||
- 修改应遵守 `docs/project/development-collaboration-contract.md` 中的领域边界、API 契约、数据安全与验证要求;跨领域改动须明确说明影响范围。
|
||||
|
||||
## 2. 服务端、Worker 与前端验证
|
||||
|
||||
- API(NestJS)和 Worker 可在开发、联调或验证需要时由 AI 启动、重启或停止;操作前应先确认目标进程与端口,避免误杀无关进程。
|
||||
- API 或 Worker 已由维护者启动时,可在本任务需要时重启,但应在完成后说明实际操作和运行状态。
|
||||
- 前端 Vite dev server 可为验证目的临时启动,用完后应停掉,并避免占用维护者已在使用的端口(如 5173/8200)。
|
||||
|
||||
## 3. 代码注释
|
||||
|
||||
- 新增或修改的代码注释必须使用中文;注释应说明职责、边界或不易从代码直接看出的业务约束,避免逐行复述实现。
|
||||
@@ -20,6 +20,7 @@
|
||||
"@nestjs/core": "12.0.1",
|
||||
"@nestjs/platform-express": "12.0.1",
|
||||
"@nestjs/swagger": "12.0.1",
|
||||
"decimal.js": "10.6.0",
|
||||
"helmet": "8.3.0",
|
||||
"ioredis": "6.0.0",
|
||||
"reflect-metadata": "0.2.2",
|
||||
@@ -30,6 +31,8 @@
|
||||
"@nestjs/testing": "12.0.1",
|
||||
"@types/express": "5.0.6",
|
||||
"@types/node": "26.3.0",
|
||||
"@types/pg": "8.23.1",
|
||||
"pg": "8.23.0",
|
||||
"tsx": "4.23.13",
|
||||
"typescript": "7.0.2",
|
||||
"vitest": "^4.1.11"
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { PricingModule } from './pricing/pricing.module.js';
|
||||
import { Controller, Get, Inject, Module } from '@nestjs/common';
|
||||
import type { Database } from '@atlas/database';
|
||||
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 { ExtensionModule } from './extension/extension.module.js';
|
||||
import { ProductEditorModule } from './product-editor/product-editor.module.js';
|
||||
import { PlatformCatalogAdminModule } from './platform-catalog-admin/platform-catalog-admin.module.js';
|
||||
import { OnlineProductsModule } from './online-products/online-products.module.js';
|
||||
|
||||
@Controller()
|
||||
class HealthController {
|
||||
@@ -51,6 +54,9 @@ class HealthController {
|
||||
CollectionModule,
|
||||
ExtensionModule,
|
||||
ProductEditorModule,
|
||||
PlatformCatalogAdminModule,
|
||||
OnlineProductsModule,
|
||||
PricingModule,
|
||||
],
|
||||
controllers: [HealthController],
|
||||
})
|
||||
|
||||
@@ -1,6 +1,27 @@
|
||||
import { Body, Controller, Get, Headers, Inject, Param, Patch, Post, Query, Req, Res } from '@nestjs/common';
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
Headers,
|
||||
Inject,
|
||||
Param,
|
||||
Patch,
|
||||
Post,
|
||||
Query,
|
||||
Req,
|
||||
Res,
|
||||
} from '@nestjs/common';
|
||||
import { z } from 'zod';
|
||||
import { bulkDeleteCollectionItemsRequestSchema, clearCollectionItemsRequestSchema, createCollectionItemRequestSchema, listCollectionItemsQuerySchema, materializeCollectionAssetsRequestSchema, retryCollectionAssetsRequestSchema, updateCollectionItemRequestSchema, type Workspace } from '@atlas/contracts';
|
||||
import {
|
||||
bulkDeleteCollectionItemsRequestSchema,
|
||||
clearCollectionItemsRequestSchema,
|
||||
createCollectionItemRequestSchema,
|
||||
listCollectionItemsQuerySchema,
|
||||
materializeCollectionAssetsRequestSchema,
|
||||
retryCollectionAssetsRequestSchema,
|
||||
updateCollectionItemRequestSchema,
|
||||
type Workspace,
|
||||
} from '@atlas/contracts';
|
||||
import { ApiError } from '../common/api-error.js';
|
||||
import type { AuthenticatedRequest } from '../auth/auth.guard.js';
|
||||
import { CurrentWorkspace } from '../workspace/current-workspace.decorator.js';
|
||||
@@ -9,34 +30,199 @@ import { CollectionService } from './collection.service.js';
|
||||
type Response = { status: (value: number) => Response };
|
||||
@Controller('collection-items')
|
||||
export class CollectionController {
|
||||
public constructor(@Inject(CollectionService) private readonly collection: CollectionService) {}
|
||||
@Get() async list(@Query() query: unknown, @Headers('x-atlas-platform-id') platformId: string | undefined, @CurrentWorkspace() workspace: Workspace) {
|
||||
if (!platformId) throw new ApiError(400, 'PLATFORM_CONTEXT_REQUIRED', '请求缺少 X-Atlas-Platform-Id');
|
||||
return this.collection.list(workspace.id, requireUuid('X-Atlas-Platform-Id', platformId), parse(listCollectionItemsQuerySchema, query));
|
||||
/** 注入采集箱接口共用的应用服务。 */
|
||||
public constructor(
|
||||
@Inject(CollectionService)
|
||||
private readonly collection: CollectionService,
|
||||
) {}
|
||||
/** 按页头平台上下文查询当前工作空间的采集箱。 */
|
||||
@Get() async list(
|
||||
@Query() query: unknown,
|
||||
@Headers('x-atlas-platform-id') platformId: string | undefined,
|
||||
@CurrentWorkspace() workspace: Workspace,
|
||||
) {
|
||||
if (!platformId)
|
||||
throw new ApiError(
|
||||
400,
|
||||
'PLATFORM_CONTEXT_REQUIRED',
|
||||
'请求缺少 X-Atlas-Platform-Id',
|
||||
);
|
||||
return this.collection.list(
|
||||
workspace.id,
|
||||
requireUuid('X-Atlas-Platform-Id', platformId),
|
||||
parse(listCollectionItemsQuerySchema, query),
|
||||
);
|
||||
}
|
||||
@Get(':id') async detail(@Param('id') id: string, @CurrentWorkspace() workspace: Workspace) {
|
||||
return { data: await this.collection.detail(workspace.id, requireUuid('collection_item_id', id)) };
|
||||
/** 返回采集记录、解析证据、SKU 与当前快照的素材任务。 */
|
||||
@Get(':id') async detail(
|
||||
@Param('id') id: string,
|
||||
@CurrentWorkspace() workspace: Workspace,
|
||||
) {
|
||||
return {
|
||||
data: await this.collection.detail(
|
||||
workspace.id,
|
||||
requireUuid('collection_item_id', id),
|
||||
),
|
||||
};
|
||||
}
|
||||
@Post() async create(@Body() body: unknown, @Headers('idempotency-key') key: string | undefined, @Req() request: AuthenticatedRequest, @CurrentWorkspace() workspace: Workspace, @Res({ passthrough: true }) response: Response) {
|
||||
const result = await this.collection.create(workspace.id, request.auth.userId, requireKey(key), parse(createCollectionItemRequestSchema, body)); response.status(result.status); return result.body;
|
||||
/** 幂等地创建手工采集草稿及其默认商品和 SKU。 */
|
||||
@Post() async create(
|
||||
@Body() body: unknown,
|
||||
@Headers('idempotency-key') key: string | undefined,
|
||||
@Req() request: AuthenticatedRequest,
|
||||
@CurrentWorkspace() workspace: Workspace,
|
||||
@Res({ passthrough: true }) response: Response,
|
||||
) {
|
||||
const result = await this.collection.create(
|
||||
workspace.id,
|
||||
request.auth.userId,
|
||||
requireKey(key),
|
||||
parse(createCollectionItemRequestSchema, body),
|
||||
);
|
||||
response.status(result.status);
|
||||
return result.body;
|
||||
}
|
||||
@Patch(':id') async update(@Param('id') id: string, @Body() body: unknown, @Headers('idempotency-key') key: string | undefined, @Headers('if-match') match: string | undefined, @Req() request: AuthenticatedRequest, @CurrentWorkspace() workspace: Workspace) {
|
||||
return (await this.collection.update(workspace.id, request.auth.userId, requireUuid('collection_item_id', id), requireKey(key), revision(match), parse(updateCollectionItemRequestSchema, body))).body;
|
||||
/** 通过 If-Match 乐观锁更新当前允许编辑的采集记录字段。 */
|
||||
@Patch(':id') async update(
|
||||
@Param('id') id: string,
|
||||
@Body() body: unknown,
|
||||
@Headers('idempotency-key') key: string | undefined,
|
||||
@Headers('if-match') match: string | undefined,
|
||||
@Req() request: AuthenticatedRequest,
|
||||
@CurrentWorkspace() workspace: Workspace,
|
||||
) {
|
||||
return (
|
||||
await this.collection.update(
|
||||
workspace.id,
|
||||
request.auth.userId,
|
||||
requireUuid('collection_item_id', id),
|
||||
requireKey(key),
|
||||
revision(match),
|
||||
parse(updateCollectionItemRequestSchema, body),
|
||||
)
|
||||
).body;
|
||||
}
|
||||
@Post('bulk-delete') async bulkDelete(@Body() body: unknown, @Headers('idempotency-key') key: string | undefined, @Req() request: AuthenticatedRequest, @CurrentWorkspace() workspace: Workspace, @Res({ passthrough: true }) response: Response) {
|
||||
const result = await this.collection.bulkDelete(workspace.id, request.auth.userId, requireKey(key), parse(bulkDeleteCollectionItemsRequestSchema, body)); response.status(result.status); return result.body;
|
||||
/** 幂等地软删除用户指定的多条采集记录。 */
|
||||
@Post('bulk-delete') async bulkDelete(
|
||||
@Body() body: unknown,
|
||||
@Headers('idempotency-key') key: string | undefined,
|
||||
@Req() request: AuthenticatedRequest,
|
||||
@CurrentWorkspace() workspace: Workspace,
|
||||
@Res({ passthrough: true }) response: Response,
|
||||
) {
|
||||
const result = await this.collection.bulkDelete(
|
||||
workspace.id,
|
||||
request.auth.userId,
|
||||
requireKey(key),
|
||||
parse(bulkDeleteCollectionItemsRequestSchema, body),
|
||||
);
|
||||
response.status(result.status);
|
||||
return result.body;
|
||||
}
|
||||
@Post('clear') async clear(@Body() body: unknown, @Headers('idempotency-key') key: string | undefined, @Req() request: AuthenticatedRequest, @CurrentWorkspace() workspace: Workspace, @Res({ passthrough: true }) response: Response) {
|
||||
const result = await this.collection.clear(workspace.id, request.auth.userId, requireKey(key), parse(clearCollectionItemsRequestSchema, body)); response.status(result.status); return result.body;
|
||||
/** 在数量确认未过期时,幂等地清空当前工作空间的采集箱。 */
|
||||
@Post('clear') async clear(
|
||||
@Body() body: unknown,
|
||||
@Headers('idempotency-key') key: string | undefined,
|
||||
@Req() request: AuthenticatedRequest,
|
||||
@CurrentWorkspace() workspace: Workspace,
|
||||
@Res({ passthrough: true }) response: Response,
|
||||
) {
|
||||
const result = await this.collection.clear(
|
||||
workspace.id,
|
||||
request.auth.userId,
|
||||
requireKey(key),
|
||||
parse(clearCollectionItemsRequestSchema, body),
|
||||
);
|
||||
response.status(result.status);
|
||||
return result.body;
|
||||
}
|
||||
@Post(':id/asset-retries') async retry(@Param('id') id: string, @Body() body: unknown, @Headers('idempotency-key') key: string | undefined, @Req() request: AuthenticatedRequest, @CurrentWorkspace() workspace: Workspace, @Res({ passthrough: true }) response: Response) {
|
||||
const result = await this.collection.retryAssets(workspace.id, request.auth.userId, requireUuid('collection_item_id', id), requireKey(key), parse(retryCollectionAssetsRequestSchema, body)); response.status(result.status); return result.body;
|
||||
/** 将当前采集快照中失败或取消的素材任务重新投递给 Worker。 */
|
||||
@Post(':id/asset-retries') async retry(
|
||||
@Param('id') id: string,
|
||||
@Body() body: unknown,
|
||||
@Headers('idempotency-key') key: string | undefined,
|
||||
@Req() request: AuthenticatedRequest,
|
||||
@CurrentWorkspace() workspace: Workspace,
|
||||
@Res({ passthrough: true }) response: Response,
|
||||
) {
|
||||
const result = await this.collection.retryAssets(
|
||||
workspace.id,
|
||||
request.auth.userId,
|
||||
requireUuid('collection_item_id', id),
|
||||
requireKey(key),
|
||||
parse(retryCollectionAssetsRequestSchema, body),
|
||||
);
|
||||
response.status(result.status);
|
||||
return result.body;
|
||||
}
|
||||
@Post(':id/asset-materializations') async materialize(@Param('id') id: string, @Body() body: unknown, @Headers('idempotency-key') key: string | undefined, @Req() request: AuthenticatedRequest, @CurrentWorkspace() workspace: Workspace, @Res({ passthrough: true }) response: Response) {
|
||||
const result = await this.collection.materializeAssets(workspace.id, request.auth.userId, requireUuid('collection_item_id', id), requireKey(key), parse(materializeCollectionAssetsRequestSchema, body)); response.status(result.status); return result.body;
|
||||
/** 将当前采集快照中用户选定的来源素材请求转存为 Atlas 素材。 */
|
||||
@Post(':id/asset-materializations') async materialize(
|
||||
@Param('id') id: string,
|
||||
@Body() body: unknown,
|
||||
@Headers('idempotency-key') key: string | undefined,
|
||||
@Req() request: AuthenticatedRequest,
|
||||
@CurrentWorkspace() workspace: Workspace,
|
||||
@Res({ passthrough: true }) response: Response,
|
||||
) {
|
||||
const result = await this.collection.materializeAssets(
|
||||
workspace.id,
|
||||
request.auth.userId,
|
||||
requireUuid('collection_item_id', id),
|
||||
requireKey(key),
|
||||
parse(materializeCollectionAssetsRequestSchema, body),
|
||||
);
|
||||
response.status(result.status);
|
||||
return result.body;
|
||||
}
|
||||
}
|
||||
function parse<T extends { safeParse: (input: unknown) => { success: boolean; data?: unknown; error?: { issues: Array<{ path: PropertyKey[]; message: string }> } } }>(schema: T, input: unknown) { const result = schema.safeParse(input); if (result.success) return result.data as never; throw new ApiError(422, 'VALIDATION_FAILED', '请求参数不符合要求', result.error?.issues.map((x) => ({ field: x.path.join('.'), code: 'INVALID_FIELD', message: x.message }))); }
|
||||
function requireUuid(field: string, value: string) { if (z.uuid().safeParse(value).success) return value; throw new ApiError(422, 'VALIDATION_FAILED', '请求参数不符合要求', [{ field, code: 'INVALID_FIELD', message: `${field} 必须是 UUID` }]); }
|
||||
function requireKey(value: string | undefined) { if (!value) throw new ApiError(400, 'IDEMPOTENCY_KEY_REQUIRED', '请求缺少 Idempotency-Key'); return requireUuid('Idempotency-Key', value); }
|
||||
function revision(value: string | undefined) { const valueMatch = value?.match(/^"([1-9]\d*)"$/); if (!valueMatch) throw new ApiError(400, 'IF_MATCH_REQUIRED', '请求缺少或格式错误的 If-Match'); return Number(valueMatch[1]); }
|
||||
/** 使用契约 Schema 校验请求体或查询参数,并转换为统一错误响应。 */
|
||||
function parse<
|
||||
T extends {
|
||||
safeParse: (input: unknown) => {
|
||||
success: boolean;
|
||||
data?: unknown;
|
||||
error?: { issues: Array<{ path: PropertyKey[]; message: string }> };
|
||||
};
|
||||
},
|
||||
>(schema: T, input: unknown) {
|
||||
const result = schema.safeParse(input);
|
||||
if (result.success) return result.data as never;
|
||||
throw new ApiError(
|
||||
422,
|
||||
'VALIDATION_FAILED',
|
||||
'请求参数不符合要求',
|
||||
result.error?.issues.map((x) => ({
|
||||
field: x.path.join('.'),
|
||||
code: 'INVALID_FIELD',
|
||||
message: x.message,
|
||||
})),
|
||||
);
|
||||
}
|
||||
/** 校验路径、请求头中的 UUID 标识。 */
|
||||
function requireUuid(field: string, value: string) {
|
||||
if (z.uuid().safeParse(value).success) return value;
|
||||
throw new ApiError(422, 'VALIDATION_FAILED', '请求参数不符合要求', [
|
||||
{ field, code: 'INVALID_FIELD', message: `${field} 必须是 UUID` },
|
||||
]);
|
||||
}
|
||||
/** 要求关键写操作提供可追踪的幂等键。 */
|
||||
function requireKey(value: string | undefined) {
|
||||
if (!value)
|
||||
throw new ApiError(
|
||||
400,
|
||||
'IDEMPOTENCY_KEY_REQUIRED',
|
||||
'请求缺少 Idempotency-Key',
|
||||
);
|
||||
return requireUuid('Idempotency-Key', value);
|
||||
}
|
||||
/** 解析单资源更新使用的 If-Match 版本号。 */
|
||||
function revision(value: string | undefined) {
|
||||
const valueMatch = value?.match(/^"([1-9]\d*)"$/);
|
||||
if (!valueMatch)
|
||||
throw new ApiError(
|
||||
400,
|
||||
'IF_MATCH_REQUIRED',
|
||||
'请求缺少或格式错误的 If-Match',
|
||||
);
|
||||
return Number(valueMatch[1]);
|
||||
}
|
||||
|
||||
@@ -3,5 +3,8 @@ import { IdempotencyService } from '../common/idempotency.service.js';
|
||||
import { CollectionController } from './collection.controller.js';
|
||||
import { CollectionService } from './collection.service.js';
|
||||
|
||||
@Module({ controllers: [CollectionController], providers: [IdempotencyService, CollectionService] })
|
||||
@Module({
|
||||
controllers: [CollectionController],
|
||||
providers: [IdempotencyService, CollectionService],
|
||||
})
|
||||
export class CollectionModule {}
|
||||
|
||||
@@ -1,67 +1,547 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import type { CreateCollectionItemRequest, ListCollectionItemsQuery, MaterializeCollectionAssetsRequest, UpdateCollectionItemRequest } from '@atlas/contracts';
|
||||
import type {
|
||||
CreateCollectionItemRequest,
|
||||
ListCollectionItemsQuery,
|
||||
MaterializeCollectionAssetsRequest,
|
||||
UpdateCollectionItemRequest,
|
||||
} 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 { throwRevisionConflict } from '../common/revision.js';
|
||||
import { DATABASE } from '../database/database.module.js';
|
||||
|
||||
type Row = { id: string; product_id: string; source_kind: 'platform' | 'manual'; platform_code: string | null; source_url: string | null; source_title: string | null; source_min_price: string | null; source_max_price: string | null; source_currency_code: string | null; note: string | null; status: string; created_at: Date | string; updated_at: Date | string; revision: number; variant_count: string; transfer_status: string | null };
|
||||
type Row = {
|
||||
id: string;
|
||||
product_id: string;
|
||||
source_kind: 'platform' | 'manual';
|
||||
platform_code: string | null;
|
||||
source_url: string | null;
|
||||
source_title: string | null;
|
||||
source_min_price: string | null;
|
||||
source_max_price: string | null;
|
||||
source_currency_code: string | null;
|
||||
note: string | null;
|
||||
status: string;
|
||||
created_at: Date | string;
|
||||
updated_at: Date | string;
|
||||
revision: number;
|
||||
variant_count: string;
|
||||
transfer_status: string | null;
|
||||
};
|
||||
@Injectable()
|
||||
export class CollectionService {
|
||||
public constructor(@Inject(IdempotencyService) private readonly idempotency: IdempotencyService, @Inject(DATABASE) private readonly database: Database) {}
|
||||
async list(workspaceId: string, platformId: string, query: ListCollectionItemsQuery) {
|
||||
/** 注入工作空间事务数据库与关键写操作的幂等边界。 */
|
||||
public constructor(
|
||||
@Inject(IdempotencyService)
|
||||
private readonly idempotency: IdempotencyService,
|
||||
@Inject(DATABASE) private readonly database: Database,
|
||||
) {}
|
||||
/** 分页查询未删除的采集记录,并返回当前工作空间的配额摘要。 */
|
||||
async list(
|
||||
workspaceId: string,
|
||||
platformId: string,
|
||||
query: ListCollectionItemsQuery,
|
||||
) {
|
||||
const cursor = query.cursor ? decodeCursor(query.cursor) : null;
|
||||
return this.database.workspaceTransaction(workspaceId, async (tx) => {
|
||||
await this.requireHeaderPlatform(tx, platformId);
|
||||
const rows = await tx.query<Row>(`${selectRow}
|
||||
const rows = await tx.query<Row>(
|
||||
`${selectRow}
|
||||
WHERE c.deleted_at IS NULL AND ($1::text IS NULL OR c.source_title ILIKE '%' || $1 || '%')
|
||||
AND ($2::text IS NULL OR p.code = $2) AND ($3::text IS NULL OR $3 = 'not_listed')
|
||||
AND ($4::timestamptz IS NULL OR (c.updated_at, c.id) < ($4, $5::uuid))
|
||||
${collectionGroupBy} ORDER BY c.updated_at DESC, c.id DESC LIMIT $6`, [query.q ?? null, query.source_platform_code ?? null, query.listing_status ?? null, cursor?.at ?? null, cursor?.id ?? null, query.limit + 1]);
|
||||
const items = rows.rows.slice(0, query.limit).map(toItem); const last = items.at(-1);
|
||||
const count = await tx.query<{ count: string; collection_item_limit: number }>('SELECT count(*)::text AS count, max(w.collection_item_limit)::int AS collection_item_limit FROM atlas.collection_items c CROSS JOIN atlas.workspaces w WHERE c.workspace_id = w.id AND c.deleted_at IS NULL');
|
||||
return { data: items, meta: { has_more: rows.rows.length > query.limit, next_cursor: rows.rows.length > query.limit && last ? encodeCursor(last.updated_at, last.id) : null, active_count: Number(count.rows[0]?.count ?? 0), collection_limit: count.rows[0]?.collection_item_limit ?? 1000 } };
|
||||
${collectionGroupBy} ORDER BY c.updated_at DESC, c.id DESC LIMIT $6`,
|
||||
[
|
||||
query.q ?? null,
|
||||
query.source_platform_code ?? null,
|
||||
query.listing_status ?? null,
|
||||
cursor?.at ?? null,
|
||||
cursor?.id ?? null,
|
||||
query.limit + 1,
|
||||
],
|
||||
);
|
||||
const items = rows.rows.slice(0, query.limit).map(toItem);
|
||||
const last = items.at(-1);
|
||||
const count = await tx.query<{
|
||||
count: string;
|
||||
collection_item_limit: number;
|
||||
}>(
|
||||
'SELECT count(*)::text AS count, max(w.collection_item_limit)::int AS collection_item_limit FROM atlas.collection_items c CROSS JOIN atlas.workspaces w WHERE c.workspace_id = w.id AND c.deleted_at IS NULL',
|
||||
);
|
||||
return {
|
||||
data: items,
|
||||
meta: {
|
||||
has_more: rows.rows.length > query.limit,
|
||||
next_cursor:
|
||||
rows.rows.length > query.limit && last
|
||||
? encodeCursor(last.updated_at, last.id)
|
||||
: null,
|
||||
active_count: Number(count.rows[0]?.count ?? 0),
|
||||
collection_limit:
|
||||
count.rows[0]?.collection_item_limit ?? 1000,
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
async create(workspaceId: string, userId: string, key: string, input: CreateCollectionItemRequest) {
|
||||
return this.idempotency.run({ workspaceId, userId, method: 'POST', path: '/api/collection-items', key, request: input }, async (tx) => {
|
||||
const item = await tx.query<{ id: string }>(`INSERT INTO atlas.collection_items (workspace_id, source_kind, source_item_key, source_title, note, status) VALUES (current_setting('app.workspace_id')::uuid, 'manual', $1, $2, $3, 'ready_for_edit') RETURNING id`, [randomUUID(), input.title ?? null, input.note ?? null]);
|
||||
const product = await tx.query<{ id: string }>(`INSERT INTO atlas.products (workspace_id, collection_item_id, source_title) VALUES (current_setting('app.workspace_id')::uuid, $1, $2) RETURNING id`, [item.rows[0].id, input.title ?? null]);
|
||||
await tx.query(`INSERT INTO atlas.product_variants (workspace_id, product_id, display_name, sort_order) VALUES (current_setting('app.workspace_id')::uuid, $1, '默认 SKU', 0)`, [product.rows[0].id]);
|
||||
return { status: 201, body: { data: { collection_item: await this.requireItem(tx, item.rows[0].id), product_id: product.rows[0].id, editor_entry_path: `/products/${product.rows[0].id}` } } };
|
||||
});
|
||||
/** 原子创建手工采集记录、商品和默认 SKU,供后续编辑流程使用。 */
|
||||
async create(
|
||||
workspaceId: string,
|
||||
userId: string,
|
||||
key: string,
|
||||
input: CreateCollectionItemRequest,
|
||||
) {
|
||||
return this.idempotency.run(
|
||||
{
|
||||
workspaceId,
|
||||
userId,
|
||||
method: 'POST',
|
||||
path: '/api/collection-items',
|
||||
key,
|
||||
request: input,
|
||||
},
|
||||
async (tx) => {
|
||||
const item = await tx.query<{ id: string }>(
|
||||
`INSERT INTO atlas.collection_items (workspace_id, source_kind, source_item_key, source_title, note, status) VALUES (current_setting('app.workspace_id')::uuid, 'manual', $1, $2, $3, 'ready_for_edit') RETURNING id`,
|
||||
[randomUUID(), input.title ?? null, input.note ?? null],
|
||||
);
|
||||
const product = await tx.query<{ id: string }>(
|
||||
`INSERT INTO atlas.products (workspace_id, collection_item_id, source_title) VALUES (current_setting('app.workspace_id')::uuid, $1, $2) RETURNING id`,
|
||||
[item.rows[0].id, input.title ?? null],
|
||||
);
|
||||
await tx.query(
|
||||
`INSERT INTO atlas.product_variants (workspace_id, product_id, display_name, sort_order) VALUES (current_setting('app.workspace_id')::uuid, $1, '默认 SKU', 0)`,
|
||||
[product.rows[0].id],
|
||||
);
|
||||
return {
|
||||
status: 201,
|
||||
body: {
|
||||
data: {
|
||||
collection_item: await this.requireItem(
|
||||
tx,
|
||||
item.rows[0].id,
|
||||
),
|
||||
product_id: product.rows[0].id,
|
||||
editor_entry_path: `/products/${product.rows[0].id}`,
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
);
|
||||
}
|
||||
/** 返回当前采集快照的来源证据、SKU 与待转存素材任务。 */
|
||||
async detail(workspaceId: string, id: string) {
|
||||
return this.database.workspaceTransaction(workspaceId, async (tx) => {
|
||||
const item = await this.requireItem(tx, id);
|
||||
const source = await tx.query<{ normalized_payload: unknown; raw_evidence: unknown; parser_version: string | null; collected_at: Date | string | null; source_snapshot_revision: string | number }>('SELECT normalized_payload, raw_evidence, parser_version, collected_at, source_snapshot_revision FROM atlas.collection_items WHERE id = $1 AND deleted_at IS NULL', [id]);
|
||||
const variants = await tx.query<{ id: string; source_variant_key: string | null; source_sku: string | null; display_name: string | null; sort_order: number }>('SELECT id, source_variant_key, source_sku, display_name, sort_order FROM atlas.product_variants WHERE product_id = $1 AND deleted_at IS NULL ORDER BY sort_order, id', [item.product_id]);
|
||||
const transfers = await tx.query<{ id: string; source_variant_id: string | null; media_type: 'image' | 'video'; poster_url: string | null; role: string; source_url: string; status: string; asset_id: string | null; error_payload: unknown; created_at: Date | string; 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`, [id, source.rows[0]?.source_snapshot_revision ?? 1]);
|
||||
return { ...item, normalized_payload: source.rows[0]?.normalized_payload ?? {}, raw_evidence: source.rows[0]?.raw_evidence ?? {}, parser_version: source.rows[0]?.parser_version ?? null, collected_at: source.rows[0]?.collected_at ? iso(source.rows[0].collected_at) : null, variants: variants.rows, asset_transfers: transfers.rows.map((transfer) => ({ ...transfer, created_at: iso(transfer.created_at), completed_at: transfer.completed_at ? iso(transfer.completed_at) : null })) };
|
||||
const source = await tx.query<{
|
||||
normalized_payload: unknown;
|
||||
raw_evidence: unknown;
|
||||
parser_version: string | null;
|
||||
collected_at: Date | string | null;
|
||||
source_snapshot_revision: string | number;
|
||||
}>(
|
||||
'SELECT normalized_payload, raw_evidence, parser_version, collected_at, source_snapshot_revision FROM atlas.collection_items WHERE id = $1 AND deleted_at IS NULL',
|
||||
[id],
|
||||
);
|
||||
const variants = await tx.query<{
|
||||
id: string;
|
||||
source_variant_key: string | null;
|
||||
source_sku: string | null;
|
||||
display_name: string | null;
|
||||
sort_order: number;
|
||||
}>(
|
||||
'SELECT id, source_variant_key, source_sku, display_name, sort_order FROM atlas.product_variants WHERE product_id = $1 AND deleted_at IS NULL ORDER BY sort_order, id',
|
||||
[item.product_id],
|
||||
);
|
||||
const transfers = await tx.query<{
|
||||
id: string;
|
||||
source_variant_id: string | null;
|
||||
media_type: 'image' | 'video';
|
||||
poster_url: string | null;
|
||||
role: string;
|
||||
source_url: string;
|
||||
status: string;
|
||||
asset_id: string | null;
|
||||
error_payload: unknown;
|
||||
created_at: Date | string;
|
||||
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 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],
|
||||
);
|
||||
return {
|
||||
...item,
|
||||
normalized_payload: source.rows[0]?.normalized_payload ?? {},
|
||||
raw_evidence: source.rows[0]?.raw_evidence ?? {},
|
||||
parser_version: source.rows[0]?.parser_version ?? null,
|
||||
collected_at: source.rows[0]?.collected_at
|
||||
? iso(source.rows[0].collected_at)
|
||||
: null,
|
||||
variants: variants.rows,
|
||||
asset_transfers: transfers.rows.map((transfer) => ({
|
||||
...transfer,
|
||||
created_at: iso(transfer.created_at),
|
||||
completed_at: transfer.completed_at
|
||||
? iso(transfer.completed_at)
|
||||
: null,
|
||||
})),
|
||||
};
|
||||
});
|
||||
}
|
||||
async update(workspaceId: string, userId: string, id: string, key: string, expected: number, input: UpdateCollectionItemRequest) {
|
||||
return this.idempotency.run({ workspaceId, userId, method: 'PATCH', path: `/api/collection-items/${id}`, key, request: { if_match: expected, ...input } }, async (tx) => {
|
||||
const result = await tx.query(`UPDATE atlas.collection_items SET note = $1, revision = revision + 1, updated_at = now() WHERE id = $2 AND deleted_at IS NULL AND revision = $3`, [input.note, id, expected]);
|
||||
if (!result.rowCount) await throwRevisionConflict(tx, 'collection_item', id, expected);
|
||||
return { status: 200, body: { data: await this.requireItem(tx, id) } };
|
||||
/** 使用乐观锁更新采集记录备注,避免覆盖并发编辑。 */
|
||||
async update(
|
||||
workspaceId: string,
|
||||
userId: string,
|
||||
id: string,
|
||||
key: string,
|
||||
expected: number,
|
||||
input: UpdateCollectionItemRequest,
|
||||
) {
|
||||
return this.idempotency.run(
|
||||
{
|
||||
workspaceId,
|
||||
userId,
|
||||
method: 'PATCH',
|
||||
path: `/api/collection-items/${id}`,
|
||||
key,
|
||||
request: { if_match: expected, ...input },
|
||||
},
|
||||
async (tx) => {
|
||||
const result = await tx.query(
|
||||
`UPDATE atlas.collection_items SET note = $1, revision = revision + 1, updated_at = now() WHERE id = $2 AND deleted_at IS NULL AND revision = $3`,
|
||||
[input.note, id, expected],
|
||||
);
|
||||
if (!result.rowCount)
|
||||
await throwRevisionConflict(
|
||||
tx,
|
||||
'collection_item',
|
||||
id,
|
||||
expected,
|
||||
);
|
||||
return {
|
||||
status: 200,
|
||||
body: { data: await this.requireItem(tx, id) },
|
||||
};
|
||||
},
|
||||
);
|
||||
}
|
||||
/** 请求软删除指定采集记录,并投递异步清理任务。 */
|
||||
async bulkDelete(
|
||||
workspaceId: string,
|
||||
userId: string,
|
||||
key: string,
|
||||
ids: { ids: string[] },
|
||||
) {
|
||||
return this.removeMany(
|
||||
workspaceId,
|
||||
userId,
|
||||
key,
|
||||
'/api/collection-items/bulk-delete',
|
||||
ids,
|
||||
false,
|
||||
);
|
||||
}
|
||||
/** 确认采集箱数量未变化后,软删除全部活动记录。 */
|
||||
async clear(
|
||||
workspaceId: string,
|
||||
userId: string,
|
||||
key: string,
|
||||
input: { confirm: true; expected_active_count: number },
|
||||
) {
|
||||
return this.removeMany(
|
||||
workspaceId,
|
||||
userId,
|
||||
key,
|
||||
'/api/collection-items/clear',
|
||||
input,
|
||||
true,
|
||||
);
|
||||
}
|
||||
/** 重新投递当前来源快照中失败或取消的素材转存任务。 */
|
||||
async retryAssets(
|
||||
workspaceId: string,
|
||||
userId: string,
|
||||
id: string,
|
||||
key: string,
|
||||
input: { transfer_ids: string[] },
|
||||
) {
|
||||
return this.idempotency.run(
|
||||
{
|
||||
workspaceId,
|
||||
userId,
|
||||
method: 'POST',
|
||||
path: `/api/collection-items/${id}/asset-retries`,
|
||||
key,
|
||||
request: input,
|
||||
},
|
||||
async (tx) => {
|
||||
await this.requireItem(tx, id);
|
||||
const result = await tx.query<{
|
||||
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.deleted_at IS NULL AND t.status IN ('failed', 'cancelled') RETURNING c.source_snapshot_revision`,
|
||||
[id, input.transfer_ids],
|
||||
);
|
||||
if (result.rowCount !== input.transfer_ids.length)
|
||||
throw new ApiError(
|
||||
422,
|
||||
'ASSET_RETRY_INVALID',
|
||||
'只能重试当前采集快照中失败或已取消的素材任务',
|
||||
);
|
||||
const operation = await this.operation(
|
||||
tx,
|
||||
userId,
|
||||
'collection_asset_retry',
|
||||
id,
|
||||
);
|
||||
await this.event(tx, id, 'collection.assets.retry.requested', {
|
||||
operation_id: operation.id,
|
||||
collection_item_id: id,
|
||||
source_snapshot_revision: Number(
|
||||
result.rows[0].source_snapshot_revision,
|
||||
),
|
||||
});
|
||||
return { status: 202, body: { data: { operation } } };
|
||||
},
|
||||
);
|
||||
}
|
||||
async bulkDelete(workspaceId: string, userId: string, key: string, ids: { ids: string[] }) { return this.removeMany(workspaceId, userId, key, '/api/collection-items/bulk-delete', ids, false); }
|
||||
async clear(workspaceId: string, userId: string, key: string, input: { confirm: true; expected_active_count: number }) { return this.removeMany(workspaceId, userId, key, '/api/collection-items/clear', input, true); }
|
||||
async retryAssets(workspaceId: string, userId: string, id: string, key: string, input: { transfer_ids: string[] }) { return this.idempotency.run({ workspaceId, userId, method: 'POST', path: `/api/collection-items/${id}/asset-retries`, key, request: input }, async (tx) => { await this.requireItem(tx, id); const result = await tx.query<{ 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`, [id, input.transfer_ids]); if (result.rowCount !== input.transfer_ids.length) throw new ApiError(422, 'ASSET_RETRY_INVALID', '只能重试当前采集快照中失败或已取消的素材任务'); const operation = await this.operation(tx, userId, 'collection_asset_retry', id); await this.event(tx, id, 'collection.assets.retry.requested', { operation_id: operation.id, collection_item_id: id, source_snapshot_revision: Number(result.rows[0].source_snapshot_revision) }); return { status: 202, body: { data: { operation } } }; }); }
|
||||
async materializeAssets(workspaceId: string, userId: string, id: string, key: string, input: MaterializeCollectionAssetsRequest) { return this.idempotency.run({ workspaceId, userId, method: 'POST', path: `/api/collection-items/${id}/asset-materializations`, key, request: input }, async (tx) => { await this.requireItem(tx, id); const result = await tx.query<{ 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`, [id, input.transfer_ids]); if (result.rowCount !== input.transfer_ids.length) throw new ApiError(422, 'ASSET_MATERIALIZATION_INVALID', '只能转存当前采集快照中尚未转存的来源媒体'); const operation = await this.operation(tx, userId, 'collection_asset_materialization', id); await this.event(tx, operation.id, 'collection.assets.materialize.requested', { operation_id: operation.id, collection_item_id: id, source_snapshot_revision: Number(result.rows[0].source_snapshot_revision) }); return { status: 202, body: { data: { operation } } }; }); }
|
||||
private async removeMany(workspaceId: string, userId: string, key: string, path: string, input: unknown, clear: boolean) { return this.idempotency.run({ workspaceId, userId, method: 'POST', path, key, request: input }, async (tx) => { const requested = input as { ids?: string[]; expected_active_count?: number }; const count = await tx.query<{ count: string }>('SELECT count(*)::text AS count FROM atlas.collection_items WHERE deleted_at IS NULL'); if (clear && Number(count.rows[0].count) !== requested.expected_active_count) throw new ApiError(409, 'COLLECTION_COUNT_CHANGED', '采集箱数量已变化,请重新确认'); const deleted = await tx.query<{ id: string }>(clear ? `UPDATE atlas.collection_items SET status = 'deleted', deleted_at = now(), revision = revision + 1, updated_at = now() WHERE deleted_at IS NULL RETURNING id` : `UPDATE atlas.collection_items SET status = 'deleted', deleted_at = now(), revision = revision + 1, updated_at = now() WHERE id = ANY($1::uuid[]) AND deleted_at IS NULL RETURNING id`, clear ? [] : [requested.ids]); if (!clear && deleted.rows.length !== requested.ids?.length) throw new ApiError(404, 'RESOURCE_NOT_FOUND', '部分采集记录不存在'); const operation = await this.operation(tx, userId, 'collection_cleanup', deleted.rows[0]?.id ?? null); await this.event(tx, operation.id, 'collection.cleanup.requested', { operation_id: operation.id, collection_item_ids: deleted.rows.map((row) => row.id) }); return { status: 202, body: { data: { deleted_count: deleted.rows.length, operation } } }; }); }
|
||||
private async operation(tx: DatabaseTransaction, userId: string, kind: string, resourceId: string | null) { const row = await tx.query<{ id: string; status: string; created_at: Date | string }>(`INSERT INTO atlas.operations (workspace_id, kind, resource_type, resource_id, created_by_user_id) VALUES (current_setting('app.workspace_id')::uuid, $1, 'collection_item', $2, $3) RETURNING id, status, created_at`, [kind, resourceId, userId]); return { id: row.rows[0].id, kind, status: row.rows[0].status, created_at: iso(row.rows[0].created_at), completed_at: null, error: null }; }
|
||||
private async event(tx: DatabaseTransaction, aggregateId: string, type: string, payload: unknown) { 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, 'collection_item', $1, $2, $3, $4)`, [aggregateId, type, payload, `${type}:${aggregateId}`]); }
|
||||
private async requireHeaderPlatform(tx: DatabaseTransaction, id: string) { const row = await tx.query(`SELECT id FROM atlas.platforms WHERE id = $1 AND status = 'active' AND coalesce((capabilities->>'can_be_header_context')::boolean, false)`, [id]); if (!row.rows[0]) throw new ApiError(422, 'PLATFORM_UNAVAILABLE', '当前平台不可用于 ERP 上下文'); }
|
||||
private async requireItem(tx: DatabaseTransaction, id: string) { const row = await tx.query<Row>(`${selectRow} WHERE c.id = $1 AND c.deleted_at IS NULL ${collectionGroupBy}`, [id]); if (!row.rows[0]) throw new ApiError(404, 'RESOURCE_NOT_FOUND', '采集记录不存在'); return toItem(row.rows[0]); }
|
||||
/** 将用户选中的候选来源素材转为可供编辑和发布的 Atlas 素材。 */
|
||||
async materializeAssets(
|
||||
workspaceId: string,
|
||||
userId: string,
|
||||
id: string,
|
||||
key: string,
|
||||
input: MaterializeCollectionAssetsRequest,
|
||||
) {
|
||||
return this.idempotency.run(
|
||||
{
|
||||
workspaceId,
|
||||
userId,
|
||||
method: 'POST',
|
||||
path: `/api/collection-items/${id}/asset-materializations`,
|
||||
key,
|
||||
request: input,
|
||||
},
|
||||
async (tx) => {
|
||||
await this.requireItem(tx, id);
|
||||
const result = await tx.query<{
|
||||
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.deleted_at IS NULL AND t.status = 'pending_selection' RETURNING c.source_snapshot_revision`,
|
||||
[id, input.transfer_ids],
|
||||
);
|
||||
if (result.rowCount !== input.transfer_ids.length)
|
||||
throw new ApiError(
|
||||
422,
|
||||
'ASSET_MATERIALIZATION_INVALID',
|
||||
'只能转存当前采集快照中尚未转存的来源媒体',
|
||||
);
|
||||
const operation = await this.operation(
|
||||
tx,
|
||||
userId,
|
||||
'collection_asset_materialization',
|
||||
id,
|
||||
);
|
||||
await this.event(
|
||||
tx,
|
||||
operation.id,
|
||||
'collection.assets.materialize.requested',
|
||||
{
|
||||
operation_id: operation.id,
|
||||
collection_item_id: id,
|
||||
source_snapshot_revision: Number(
|
||||
result.rows[0].source_snapshot_revision,
|
||||
),
|
||||
},
|
||||
);
|
||||
return { status: 202, body: { data: { operation } } };
|
||||
},
|
||||
);
|
||||
}
|
||||
/** 复用批量删除和清空流程,并以 Outbox 触发后续素材清理。 */
|
||||
private async removeMany(
|
||||
workspaceId: string,
|
||||
userId: string,
|
||||
key: string,
|
||||
path: string,
|
||||
input: unknown,
|
||||
clear: boolean,
|
||||
) {
|
||||
return this.idempotency.run(
|
||||
{ workspaceId, userId, method: 'POST', path, key, request: input },
|
||||
async (tx) => {
|
||||
const requested = input as {
|
||||
ids?: string[];
|
||||
expected_active_count?: number;
|
||||
};
|
||||
const count = await tx.query<{ count: string }>(
|
||||
'SELECT count(*)::text AS count FROM atlas.collection_items WHERE deleted_at IS NULL',
|
||||
);
|
||||
if (
|
||||
clear &&
|
||||
Number(count.rows[0].count) !==
|
||||
requested.expected_active_count
|
||||
)
|
||||
throw new ApiError(
|
||||
409,
|
||||
'COLLECTION_COUNT_CHANGED',
|
||||
'采集箱数量已变化,请重新确认',
|
||||
);
|
||||
const deleted = await tx.query<{ id: string }>(
|
||||
clear
|
||||
? `UPDATE atlas.collection_items SET status = 'deleted', deleted_at = now(), revision = revision + 1, updated_at = now() WHERE deleted_at IS NULL RETURNING id`
|
||||
: `UPDATE atlas.collection_items SET status = 'deleted', deleted_at = now(), revision = revision + 1, updated_at = now() WHERE id = ANY($1::uuid[]) AND deleted_at IS NULL RETURNING id`,
|
||||
clear ? [] : [requested.ids],
|
||||
);
|
||||
if (!clear && deleted.rows.length !== requested.ids?.length)
|
||||
throw new ApiError(
|
||||
404,
|
||||
'RESOURCE_NOT_FOUND',
|
||||
'部分采集记录不存在',
|
||||
);
|
||||
const operation = await this.operation(
|
||||
tx,
|
||||
userId,
|
||||
'collection_cleanup',
|
||||
deleted.rows[0]?.id ?? null,
|
||||
);
|
||||
await this.event(
|
||||
tx,
|
||||
operation.id,
|
||||
'collection.cleanup.requested',
|
||||
{
|
||||
operation_id: operation.id,
|
||||
collection_item_ids: deleted.rows.map((row) => row.id),
|
||||
},
|
||||
);
|
||||
return {
|
||||
status: 202,
|
||||
body: {
|
||||
data: { deleted_count: deleted.rows.length, operation },
|
||||
},
|
||||
};
|
||||
},
|
||||
);
|
||||
}
|
||||
/** 创建供客户端轮询的异步操作记录。 */
|
||||
private async operation(
|
||||
tx: DatabaseTransaction,
|
||||
userId: string,
|
||||
kind: string,
|
||||
resourceId: string | null,
|
||||
) {
|
||||
const row = await tx.query<{
|
||||
id: string;
|
||||
status: string;
|
||||
created_at: Date | string;
|
||||
}>(
|
||||
`INSERT INTO atlas.operations (workspace_id, kind, resource_type, resource_id, created_by_user_id) VALUES (current_setting('app.workspace_id')::uuid, $1, 'collection_item', $2, $3) RETURNING id, status, created_at`,
|
||||
[kind, resourceId, userId],
|
||||
);
|
||||
return {
|
||||
id: row.rows[0].id,
|
||||
kind,
|
||||
status: row.rows[0].status,
|
||||
created_at: iso(row.rows[0].created_at),
|
||||
completed_at: null,
|
||||
error: null,
|
||||
};
|
||||
}
|
||||
/** 在同一事务写入 Outbox 事件,避免业务事实与异步任务脱节。 */
|
||||
private async event(
|
||||
tx: DatabaseTransaction,
|
||||
aggregateId: string,
|
||||
type: string,
|
||||
payload: unknown,
|
||||
) {
|
||||
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, 'collection_item', $1, $2, $3, $4)`,
|
||||
[aggregateId, type, payload, `${type}:${aggregateId}`],
|
||||
);
|
||||
}
|
||||
/** 确认页头平台已启用且允许作为 ERP 上下文。 */
|
||||
private async requireHeaderPlatform(tx: DatabaseTransaction, id: string) {
|
||||
const row = await tx.query(
|
||||
`SELECT id FROM atlas.platforms WHERE id = $1 AND status = 'active' AND coalesce((capabilities->>'can_be_header_context')::boolean, false)`,
|
||||
[id],
|
||||
);
|
||||
if (!row.rows[0])
|
||||
throw new ApiError(
|
||||
422,
|
||||
'PLATFORM_UNAVAILABLE',
|
||||
'当前平台不可用于 ERP 上下文',
|
||||
);
|
||||
}
|
||||
/** 读取工作空间内未删除的采集记录;RLS 负责隔离其他工作空间数据。 */
|
||||
private async requireItem(tx: DatabaseTransaction, id: string) {
|
||||
const row = await tx.query<Row>(
|
||||
`${selectRow} WHERE c.id = $1 AND c.deleted_at IS NULL ${collectionGroupBy}`,
|
||||
[id],
|
||||
);
|
||||
if (!row.rows[0])
|
||||
throw new ApiError(404, 'RESOURCE_NOT_FOUND', '采集记录不存在');
|
||||
return toItem(row.rows[0]);
|
||||
}
|
||||
}
|
||||
/** 采集箱列表和单项读取共用的聚合查询,素材状态只取当前来源快照。 */
|
||||
const selectRow = `SELECT c.id, product.id AS product_id, c.source_kind, p.code AS platform_code, c.source_url, c.source_title, c.source_min_price::text, c.source_max_price::text, c.source_currency_code, c.note, c.status, c.created_at, c.updated_at, c.revision, count(DISTINCT v.id)::text AS variant_count, min(t.status) AS transfer_status FROM atlas.collection_items c JOIN atlas.products product ON product.collection_item_id = c.id LEFT JOIN atlas.platforms p ON p.id = c.source_platform_id LEFT JOIN atlas.product_variants v ON v.product_id = product.id AND v.deleted_at IS NULL LEFT JOIN atlas.collection_asset_transfers t ON t.collection_item_id = c.id AND t.source_snapshot_revision = c.source_snapshot_revision`;
|
||||
const collectionGroupBy = 'GROUP BY c.id, product.id, p.code';
|
||||
function toItem(row: Row) { return { id: row.id, product_id: row.product_id, source: { kind: row.source_kind, platform_code: row.platform_code, url: row.source_url }, title: row.source_title, thumbnail: { asset_id: null, preview_url: null, status: row.transfer_status ?? 'none' }, source_price: { min: row.source_min_price, max: row.source_max_price, currency_code: row.source_currency_code }, variant_summary: { count: Number(row.variant_count), needs_review: row.status === 'needs_review' }, note: row.note, collection_status: row.status, listing_summary: { status: 'not_listed' as const, detail: '等待首次上架', last_error: null }, created_at: iso(row.created_at), updated_at: iso(row.updated_at), revision: row.revision }; }
|
||||
function iso(value: Date | string) { return value instanceof Date ? value.toISOString() : value; }
|
||||
function encodeCursor(at: string, id: string) { return Buffer.from(JSON.stringify({ at, id })).toString('base64url'); }
|
||||
function decodeCursor(cursor: string): { at: string; id: string } { try { const value = JSON.parse(Buffer.from(cursor, 'base64url').toString('utf8')) as { at?: string; id?: string }; if (!value.at || !value.id || Number.isNaN(Date.parse(value.at))) throw new Error(); return { at: value.at, id: value.id }; } catch { throw new ApiError(422, 'VALIDATION_FAILED', '分页游标无效'); } }
|
||||
/** 将数据库聚合行转换为冻结的采集箱列表读模型。 */
|
||||
function toItem(row: Row) {
|
||||
return {
|
||||
id: row.id,
|
||||
product_id: row.product_id,
|
||||
source: {
|
||||
kind: row.source_kind,
|
||||
platform_code: row.platform_code,
|
||||
url: row.source_url,
|
||||
},
|
||||
title: row.source_title,
|
||||
thumbnail: {
|
||||
asset_id: null,
|
||||
preview_url: null,
|
||||
status: row.transfer_status ?? 'none',
|
||||
},
|
||||
source_price: {
|
||||
min: row.source_min_price,
|
||||
max: row.source_max_price,
|
||||
currency_code: row.source_currency_code,
|
||||
},
|
||||
variant_summary: {
|
||||
count: Number(row.variant_count),
|
||||
needs_review: row.status === 'needs_review',
|
||||
},
|
||||
note: row.note,
|
||||
collection_status: row.status,
|
||||
listing_summary: {
|
||||
status: 'not_listed' as const,
|
||||
detail: '等待首次上架',
|
||||
last_error: null,
|
||||
},
|
||||
created_at: iso(row.created_at),
|
||||
updated_at: iso(row.updated_at),
|
||||
revision: row.revision,
|
||||
};
|
||||
}
|
||||
/** 统一将数据库时间值序列化为 ISO 8601 字符串。 */
|
||||
function iso(value: Date | string) {
|
||||
return value instanceof Date ? value.toISOString() : value;
|
||||
}
|
||||
/** 将排序键编码为不透明游标,避免客户端拼接 SQL 条件。 */
|
||||
function encodeCursor(at: string, id: string) {
|
||||
return Buffer.from(JSON.stringify({ at, id })).toString('base64url');
|
||||
}
|
||||
/** 校验并解析分页游标,拒绝损坏或不完整的客户端输入。 */
|
||||
function decodeCursor(cursor: string): { at: string; id: string } {
|
||||
try {
|
||||
const value = JSON.parse(
|
||||
Buffer.from(cursor, 'base64url').toString('utf8'),
|
||||
) as { at?: string; id?: string };
|
||||
if (!value.at || !value.id || Number.isNaN(Date.parse(value.at)))
|
||||
throw new Error();
|
||||
return { at: value.at, id: value.id };
|
||||
} catch {
|
||||
throw new ApiError(422, 'VALIDATION_FAILED', '分页游标无效');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,16 +24,33 @@ const environmentSchema = z.object({
|
||||
NODE_ENV: z
|
||||
.enum(['development', 'test', 'production'])
|
||||
.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_PUBLIC_ORIGIN: z.url().default('http://localhost:8200'),
|
||||
WEB_ORIGIN: z.url().default('http://localhost:8100'),
|
||||
DATABASE_URL: z.url(),
|
||||
REDIS_URL: z.url(),
|
||||
OBJECT_STORAGE_ENDPOINT: z.url().default('http://localhost:9000'),
|
||||
OBJECT_STORAGE_REGION: z.string().min(1).default('us-east-1'),
|
||||
OBJECT_STORAGE_BUCKET: z.string().min(3).max(63).default('atlas-assets'),
|
||||
OBJECT_STORAGE_FORCE_PATH_STYLE: z.enum(['true', 'false']).default('true'),
|
||||
OBJECT_STORAGE_ACCESS_KEY: z.string().min(3).optional(),
|
||||
OBJECT_STORAGE_SECRET_KEY: z.string().min(8).optional(),
|
||||
OZON_IMAGE_DOWNLOAD_ORIGIN: z.preprocess(
|
||||
(value) => value === '' ? undefined : value,
|
||||
z.url().optional(),
|
||||
),
|
||||
QINIU_ACCESS_KEY: z.preprocess(
|
||||
(value) => value === '' ? undefined : value,
|
||||
z.string().min(3).optional(),
|
||||
),
|
||||
QINIU_SECRET_KEY: z.preprocess(
|
||||
(value) => value === '' ? undefined : value,
|
||||
z.string().min(8).optional(),
|
||||
),
|
||||
LEGACY_MINIO_ENDPOINT: z.url().default('http://localhost:9000'),
|
||||
MINIO_ROOT_USER: z.string().min(3).optional(),
|
||||
MINIO_ROOT_PASSWORD: z.string().min(8).optional(),
|
||||
AUTH_JWT_SECRET: z.string().min(32),
|
||||
@@ -96,15 +113,25 @@ const environmentSchema = z.object({
|
||||
});
|
||||
|
||||
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<
|
||||
Environment,
|
||||
'TRUSTED_PROXY_CIDRS' | 'SWAGGER_ENABLED'
|
||||
'TRUSTED_PROXY_CIDRS' | 'SWAGGER_ENABLED' | 'PRICING_ALLOW_DRAFT_RATES'
|
||||
> & {
|
||||
trustedProxyCidrs: string[];
|
||||
swaggerEnabled: boolean;
|
||||
objectStorageAccessKey: string;
|
||||
objectStorageSecretKey: string;
|
||||
objectStorageForcePathStyle: boolean;
|
||||
qiniuDownloadOrigin: string | null;
|
||||
qiniuDownloadAccessKey: string;
|
||||
qiniuDownloadSecretKey: string;
|
||||
pricingAllowDraftRates: boolean;
|
||||
};
|
||||
|
||||
function validateEnvironment(
|
||||
@@ -129,6 +156,22 @@ function createAppConfig(environment: Environment): AppConfig {
|
||||
'OBJECT_STORAGE_ACCESS_KEY and OBJECT_STORAGE_SECRET_KEY are required',
|
||||
);
|
||||
}
|
||||
const qiniuDownloadOrigin = environment.OZON_IMAGE_DOWNLOAD_ORIGIN ?? null;
|
||||
if (environment.OBJECT_STORAGE_PROVIDER === 'qiniu_kodo') {
|
||||
if (!qiniuDownloadOrigin) {
|
||||
throw new Error('OZON_IMAGE_DOWNLOAD_ORIGIN is required for Qiniu image previews');
|
||||
}
|
||||
const origin = new URL(qiniuDownloadOrigin);
|
||||
if (
|
||||
origin.protocol !== 'https:' || origin.username || origin.password ||
|
||||
origin.search || origin.hash || origin.pathname !== '/'
|
||||
) {
|
||||
throw new Error('OZON_IMAGE_DOWNLOAD_ORIGIN must be an HTTPS origin');
|
||||
}
|
||||
}
|
||||
if (Boolean(environment.QINIU_ACCESS_KEY) !== Boolean(environment.QINIU_SECRET_KEY)) {
|
||||
throw new Error('QINIU_ACCESS_KEY and QINIU_SECRET_KEY must be configured together');
|
||||
}
|
||||
return {
|
||||
...environment,
|
||||
// 只有显式给出代理网段时才信任转发头,避免客户端伪造来源 IP。
|
||||
@@ -138,8 +181,16 @@ function createAppConfig(environment: Environment): AppConfig {
|
||||
swaggerEnabled: environment.SWAGGER_ENABLED === 'true',
|
||||
objectStorageAccessKey,
|
||||
objectStorageSecretKey,
|
||||
qiniuDownloadOrigin,
|
||||
qiniuDownloadAccessKey: environment.QINIU_ACCESS_KEY ?? objectStorageAccessKey,
|
||||
qiniuDownloadSecretKey: environment.QINIU_SECRET_KEY ?? objectStorageSecretKey,
|
||||
objectStorageForcePathStyle:
|
||||
environment.OBJECT_STORAGE_FORCE_PATH_STYLE === 'true',
|
||||
// 草稿费率默认可参与报价;仍可通过环境配置限制新试算。
|
||||
pricingAllowDraftRates: draftPricingEnabled(
|
||||
environment.NODE_ENV,
|
||||
environment.PRICING_ALLOW_DRAFT_RATES,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
import { CanActivate, ExecutionContext, Inject, Injectable, UnauthorizedException } from '@nestjs/common';
|
||||
import {
|
||||
CanActivate,
|
||||
ExecutionContext,
|
||||
Inject,
|
||||
Injectable,
|
||||
UnauthorizedException,
|
||||
} from '@nestjs/common';
|
||||
import type { Database } from '@atlas/database';
|
||||
import { AuthService } from '../auth/auth.service.js';
|
||||
import { ApiError } from '../common/api-error.js';
|
||||
@@ -6,32 +12,61 @@ import { DATABASE } from '../database/database.module.js';
|
||||
|
||||
export type ExtensionAuthenticatedRequest = {
|
||||
header: (name: string) => string | undefined;
|
||||
extensionAuth: { userId: string; workspaceId: string; sessionId: string; clientId: string };
|
||||
extensionAuth: {
|
||||
userId: string;
|
||||
workspaceId: string;
|
||||
sessionId: string;
|
||||
clientId: string;
|
||||
};
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class ExtensionAccessGuard implements CanActivate {
|
||||
public constructor(@Inject(AuthService) private readonly auth: AuthService, @Inject(DATABASE) private readonly database: Database) {}
|
||||
public constructor(
|
||||
@Inject(AuthService) private readonly auth: AuthService,
|
||||
@Inject(DATABASE) private readonly database: Database,
|
||||
) {}
|
||||
public async canActivate(context: ExecutionContext) {
|
||||
const request = context.switchToHttp().getRequest<ExtensionAuthenticatedRequest>();
|
||||
const request = context
|
||||
.switchToHttp()
|
||||
.getRequest<ExtensionAuthenticatedRequest>();
|
||||
// 毛子 ERP 风格:插件携带从 Atlas Web 同步而来的短期 Web access token。
|
||||
// AuthService 同时核对 JWT 和未撤销的 Web session,不能仅信任 token 声明。
|
||||
let authenticated: { userId: string; sessionId: string };
|
||||
try {
|
||||
authenticated = await this.auth.authenticate(request.header('authorization'));
|
||||
authenticated = await this.auth.authenticate(
|
||||
request.header('authorization'),
|
||||
);
|
||||
} catch (error) {
|
||||
// AuthService 的通用认证守卫会抛 Nest UnauthorizedException;扩展接口
|
||||
// 应返回稳定、可操作的错误信息,不能在 UI 中退化成“请求无法处理”。
|
||||
if (error instanceof UnauthorizedException)
|
||||
throw new ApiError(401, 'SESSION_EXPIRED', 'Atlas ERP 登录已过期,请重新登录');
|
||||
throw new ApiError(
|
||||
401,
|
||||
'SESSION_EXPIRED',
|
||||
'Atlas ERP 登录已过期,请重新登录',
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
const workspace = await this.database.query<{ id: string }>(`SELECT w.id FROM atlas.workspace_memberships m
|
||||
const workspace = await this.database.query<{ id: string }>(
|
||||
`SELECT w.id FROM atlas.workspace_memberships m
|
||||
JOIN atlas.workspaces w ON w.id = m.workspace_id
|
||||
WHERE m.user_id = $1 AND w.status = 'active'
|
||||
ORDER BY m.joined_at LIMIT 1`, [authenticated.userId]);
|
||||
if (!workspace.rows[0]) throw new ApiError(403, 'WORKSPACE_MEMBERSHIP_REQUIRED', '当前账户没有可用工作空间');
|
||||
request.extensionAuth = { userId: authenticated.userId, workspaceId: workspace.rows[0].id, sessionId: authenticated.sessionId, clientId: 'atlas-web' };
|
||||
ORDER BY m.joined_at LIMIT 1`,
|
||||
[authenticated.userId],
|
||||
);
|
||||
if (!workspace.rows[0])
|
||||
throw new ApiError(
|
||||
403,
|
||||
'WORKSPACE_MEMBERSHIP_REQUIRED',
|
||||
'当前账户没有可用工作空间',
|
||||
);
|
||||
request.extensionAuth = {
|
||||
userId: authenticated.userId,
|
||||
workspaceId: workspace.rows[0].id,
|
||||
sessionId: authenticated.sessionId,
|
||||
clientId: 'atlas-web',
|
||||
};
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,22 +1,90 @@
|
||||
import { Body, Controller, Get, Headers, Inject, Post, Req, Res, UseGuards } from '@nestjs/common';
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
Headers,
|
||||
Inject,
|
||||
Post,
|
||||
Req,
|
||||
Res,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { z } from 'zod';
|
||||
import { collectedProductPayloadSchema } from '@atlas/contracts';
|
||||
import { ApiError } from '../common/api-error.js';
|
||||
import { Public } from '../common/public.decorator.js';
|
||||
import { ExtensionAccessGuard, type ExtensionAuthenticatedRequest } from './extension-auth.guard.js';
|
||||
import {
|
||||
ExtensionAccessGuard,
|
||||
type ExtensionAuthenticatedRequest,
|
||||
} from './extension-auth.guard.js';
|
||||
import { ExtensionService } from './extension.service.js';
|
||||
|
||||
type ResponseLike = { status: (code: number) => ResponseLike };
|
||||
|
||||
@Controller('extension')
|
||||
export class ExtensionController {
|
||||
public constructor(@Inject(ExtensionService) private readonly extension: ExtensionService) {}
|
||||
@Get('session') @Public() @UseGuards(ExtensionAccessGuard)
|
||||
public session(@Req() request: ExtensionAuthenticatedRequest) { return { data: { authenticated: true, workspace: { id: request.extensionAuth.workspaceId, available: true }, can_collect: true } }; }
|
||||
@Post('materials') @Public() @UseGuards(ExtensionAccessGuard)
|
||||
public async materials(@Req() request: ExtensionAuthenticatedRequest, @Headers('idempotency-key') key: string | undefined, @Body() body: unknown, @Res({ passthrough: true }) response: ResponseLike) {
|
||||
if (!key || !z.uuid().safeParse(key).success) throw new ApiError(400, 'IDEMPOTENCY_KEY_REQUIRED', '请求缺少或格式错误的 Idempotency-Key');
|
||||
const result = await this.extension.ingest(request.extensionAuth.workspaceId, request.extensionAuth.userId, key, parse(collectedProductPayloadSchema, body)); response.status(result.status); return result.body;
|
||||
public constructor(
|
||||
@Inject(ExtensionService) private readonly extension: ExtensionService,
|
||||
) {}
|
||||
@Get('session')
|
||||
@Public()
|
||||
@UseGuards(ExtensionAccessGuard)
|
||||
public session(@Req() request: ExtensionAuthenticatedRequest) {
|
||||
return {
|
||||
data: {
|
||||
authenticated: true,
|
||||
workspace: {
|
||||
id: request.extensionAuth.workspaceId,
|
||||
available: true,
|
||||
},
|
||||
can_collect: true,
|
||||
},
|
||||
};
|
||||
}
|
||||
@Post('materials')
|
||||
@Public()
|
||||
@UseGuards(ExtensionAccessGuard)
|
||||
public async materials(
|
||||
@Req() request: ExtensionAuthenticatedRequest,
|
||||
@Headers('idempotency-key') key: string | undefined,
|
||||
@Body() body: unknown,
|
||||
@Res({ passthrough: true }) response: ResponseLike,
|
||||
) {
|
||||
if (!key || !z.uuid().safeParse(key).success)
|
||||
throw new ApiError(
|
||||
400,
|
||||
'IDEMPOTENCY_KEY_REQUIRED',
|
||||
'请求缺少或格式错误的 Idempotency-Key',
|
||||
);
|
||||
const result = await this.extension.ingest(
|
||||
request.extensionAuth.workspaceId,
|
||||
request.extensionAuth.userId,
|
||||
key,
|
||||
parse(collectedProductPayloadSchema, body),
|
||||
);
|
||||
response.status(result.status);
|
||||
return result.body;
|
||||
}
|
||||
}
|
||||
function parse<T extends { safeParse: (value: unknown) => { success: boolean; data?: unknown; error?: { issues: Array<{ path: PropertyKey[]; message: string }> } } }>(schema: T, value: unknown) { const result = schema.safeParse(value); if (result.success) return result.data as never; throw new ApiError(422, 'VALIDATION_FAILED', '请求参数不符合要求', result.error?.issues.map((item) => ({ field: item.path.join('.'), code: 'INVALID_FIELD', message: item.message }))); }
|
||||
function parse<
|
||||
T extends {
|
||||
safeParse: (value: unknown) => {
|
||||
success: boolean;
|
||||
data?: unknown;
|
||||
error?: { issues: Array<{ path: PropertyKey[]; message: string }> };
|
||||
};
|
||||
},
|
||||
>(schema: T, value: unknown) {
|
||||
const result = schema.safeParse(value);
|
||||
if (result.success) return result.data as never;
|
||||
throw new ApiError(
|
||||
422,
|
||||
'VALIDATION_FAILED',
|
||||
'请求参数不符合要求',
|
||||
result.error?.issues.map((item) => ({
|
||||
field: item.path.join('.'),
|
||||
code: 'INVALID_FIELD',
|
||||
message: item.message,
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,5 +3,9 @@ import { AuthModule } from '../auth/auth.module.js';
|
||||
import { ExtensionAccessGuard } from './extension-auth.guard.js';
|
||||
import { ExtensionController } from './extension.controller.js';
|
||||
import { ExtensionService } from './extension.service.js';
|
||||
@Module({ imports: [AuthModule], controllers: [ExtensionController], providers: [ExtensionService, ExtensionAccessGuard] })
|
||||
@Module({
|
||||
imports: [AuthModule],
|
||||
controllers: [ExtensionController],
|
||||
providers: [ExtensionService, ExtensionAccessGuard],
|
||||
})
|
||||
export class ExtensionModule {}
|
||||
|
||||
@@ -87,9 +87,13 @@ export class ExtensionService {
|
||||
],
|
||||
);
|
||||
const product = await tx.query<{ id: string }>(
|
||||
`INSERT INTO atlas.products (workspace_id, collection_item_id, source_title)
|
||||
VALUES (current_setting('app.workspace_id')::uuid, $1, $2) RETURNING id`,
|
||||
[item.rows[0].id, payload.common.title?.value ?? null],
|
||||
`INSERT INTO atlas.products (workspace_id, collection_item_id, source_title, purchase_url)
|
||||
VALUES (current_setting('app.workspace_id')::uuid, $1, $2, $3) RETURNING id`,
|
||||
[
|
||||
item.rows[0].id,
|
||||
payload.common.title?.value ?? null,
|
||||
payload.source.canonicalUrl,
|
||||
],
|
||||
);
|
||||
await this.writeSourceSnapshotRows(
|
||||
tx,
|
||||
@@ -321,12 +325,14 @@ export class ExtensionService {
|
||||
await tx.query(
|
||||
`UPDATE atlas.product_variants
|
||||
SET source_sku = $1, display_name = $2, sort_order = $3,
|
||||
purchase_cost_cny = COALESCE(ROUND($4::numeric, 2), purchase_cost_cny),
|
||||
deleted_at = NULL, updated_at = now()
|
||||
WHERE id = $4`,
|
||||
WHERE id = $5`,
|
||||
[
|
||||
variant.sourceSku ?? null,
|
||||
variant.displayName,
|
||||
index,
|
||||
sourcePurchaseCost(payload.source.platform, variant),
|
||||
variantId,
|
||||
],
|
||||
);
|
||||
@@ -334,8 +340,8 @@ export class ExtensionService {
|
||||
} else {
|
||||
const inserted = await tx.query<{ id: string }>(
|
||||
`INSERT INTO atlas.product_variants
|
||||
(workspace_id, product_id, source_variant_key, source_sku, display_name, sort_order)
|
||||
VALUES ($1, $2, $3, $4, $5, $6) RETURNING id`,
|
||||
(workspace_id, product_id, source_variant_key, source_sku, display_name, sort_order, purchase_cost_cny)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, ROUND($7::numeric, 2)) RETURNING id`,
|
||||
[
|
||||
workspaceId,
|
||||
productId,
|
||||
@@ -343,6 +349,7 @@ export class ExtensionService {
|
||||
variant.sourceSku ?? null,
|
||||
variant.displayName,
|
||||
index,
|
||||
sourcePurchaseCost(payload.source.platform, variant),
|
||||
],
|
||||
);
|
||||
variantId = inserted.rows[0].id;
|
||||
@@ -446,21 +453,24 @@ export class ExtensionService {
|
||||
const status = await tx.query<{
|
||||
status: string;
|
||||
source_title: string | null;
|
||||
variant_count: string;
|
||||
}>(
|
||||
'SELECT status, source_title FROM atlas.collection_items WHERE id = $1',
|
||||
[collectionItemId],
|
||||
`SELECT status, source_title,
|
||||
(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 {
|
||||
collection_item: {
|
||||
id: collectionItemId,
|
||||
status: status.rows[0]?.status ?? 'received',
|
||||
source_title: status.rows[0]?.source_title ?? null,
|
||||
variant_count: 0,
|
||||
variant_count: Number(status.rows[0]?.variant_count ?? 0),
|
||||
},
|
||||
product_id: productId,
|
||||
reused,
|
||||
refreshed,
|
||||
editor_entry_path: `/collection-test/${collectionItemId}`,
|
||||
editor_entry_path: `/products/${productId}/editor?collection_item_id=${collectionItemId}`,
|
||||
materialization: {
|
||||
total: Number(count.rows[0]?.total ?? 0),
|
||||
pending_selection: Number(
|
||||
@@ -486,3 +496,13 @@ function sourceState(payload: CollectedProductPayload) {
|
||||
sourcePrice: payload.common.sourcePrice,
|
||||
};
|
||||
}
|
||||
|
||||
/** 仅将 1688 明确标为人民币的逐 SKU 来源价写入采购价。 */
|
||||
function sourcePurchaseCost(
|
||||
platform: CollectedProductPayload['source']['platform'],
|
||||
variant: CollectedProductPayload['variants'][number] | { sourceVariantKey: null },
|
||||
) {
|
||||
return platform === '1688' && 'sourcePrice' in variant && variant.sourcePrice?.currencyCode === 'CNY'
|
||||
? variant.sourcePrice.amount
|
||||
: null;
|
||||
}
|
||||
|
||||
@@ -58,6 +58,11 @@ export async function createApplication() {
|
||||
});
|
||||
// Helmet、body 限制、请求 ID 与全局错误过滤器必须在业务路由前注册。
|
||||
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('urlencoded', {
|
||||
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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -1,11 +1,15 @@
|
||||
import { ProductImageDeletionService } from './product-image-deletion.service.js';
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Headers,
|
||||
Inject,
|
||||
Get,
|
||||
Param,
|
||||
Post,
|
||||
Query,
|
||||
Req,
|
||||
Res,
|
||||
} from '@nestjs/common';
|
||||
import {
|
||||
ApiBearerAuth,
|
||||
@@ -14,9 +18,18 @@ import {
|
||||
ApiTags,
|
||||
} from '@nestjs/swagger';
|
||||
import {
|
||||
deleteProductImageRequestSchema,
|
||||
completeMediaUploadIntentRequestSchema,
|
||||
createMediaAccessUrlRequestSchema,
|
||||
createMediaUploadIntentRequestSchema,
|
||||
importProductImageRequestSchema,
|
||||
createAspectRatioDerivativeRequestSchema,
|
||||
createWatermarkDerivativeRequestSchema,
|
||||
createMediaAiEditRequestSchema,
|
||||
listProductImageTasksQuerySchema,
|
||||
imageSuitePlanInputSchema,
|
||||
createImageSuiteSchema,
|
||||
type CreateMediaAccessUrlRequest,
|
||||
type Workspace,
|
||||
} from '@atlas/contracts';
|
||||
import { z } from 'zod';
|
||||
@@ -24,15 +37,236 @@ import { ApiError } from '../common/api-error.js';
|
||||
import type { AuthenticatedRequest } from '../auth/auth.guard.js';
|
||||
import { CurrentWorkspace } from '../workspace/current-workspace.decorator.js';
|
||||
import { MediaService } from './media.service.js';
|
||||
import { AspectRatioDerivativeService } from './aspect-ratio-derivative.service.js';
|
||||
import { WatermarkDerivativeService } from './watermark-derivative.service.js';
|
||||
import { MediaAiEditService } from './media-ai-edit.service.js';
|
||||
import { ProductImageTaskService } from './product-image-task.service.js';
|
||||
import { ProductImageDerivativeHistoryService } from './product-image-derivative-history.service.js';
|
||||
import { ImageSuiteService } from './image-suite.service.js';
|
||||
|
||||
@ApiTags('Media')
|
||||
@ApiBearerAuth('access-token')
|
||||
@Controller()
|
||||
export class MediaController {
|
||||
public constructor(
|
||||
@Inject(ProductImageDeletionService) private readonly imageDeletions: ProductImageDeletionService,
|
||||
@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')
|
||||
@ApiCreatedResponse({ description: '已创建一次性预签名上传意图' })
|
||||
public async createIntent(
|
||||
@@ -72,17 +306,18 @@ export class MediaController {
|
||||
}
|
||||
|
||||
@Post('media-assets/:assetId/access-urls')
|
||||
@ApiOkResponse({ description: '已签发短时预览 URL' })
|
||||
@ApiOkResponse({ description: '已签发短时预览或下载 URL' })
|
||||
public async accessUrl(
|
||||
@Param('assetId') assetId: string,
|
||||
@Body() body: unknown,
|
||||
@CurrentWorkspace() workspace: Workspace,
|
||||
) {
|
||||
parse(createMediaAccessUrlRequestSchema, body);
|
||||
const input = parse(createMediaAccessUrlRequestSchema, body) as CreateMediaAccessUrlRequest;
|
||||
return {
|
||||
data: await this.media.accessUrl(
|
||||
workspace.id,
|
||||
uuid('asset_id', assetId),
|
||||
input.purpose,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,11 +1,18 @@
|
||||
import { ProductImageDeletionService } from './product-image-deletion.service.js';
|
||||
import { Module } from '@nestjs/common';
|
||||
import { IdempotencyService } from '../common/idempotency.service.js';
|
||||
import { MediaController } from './media.controller.js';
|
||||
import { MediaService } from './media.service.js';
|
||||
import { AspectRatioDerivativeService } from './aspect-ratio-derivative.service.js';
|
||||
import { WatermarkDerivativeService } from './watermark-derivative.service.js';
|
||||
import { MediaAiEditService } from './media-ai-edit.service.js';
|
||||
import { ProductImageTaskService } from './product-image-task.service.js';
|
||||
import { ProductImageDerivativeHistoryService } from './product-image-derivative-history.service.js';
|
||||
import { ImageSuiteService } from './image-suite.service.js';
|
||||
|
||||
@Module({
|
||||
controllers: [MediaController],
|
||||
providers: [IdempotencyService, MediaService],
|
||||
providers: [ProductImageDeletionService, IdempotencyService, MediaService, AspectRatioDerivativeService, WatermarkDerivativeService, MediaAiEditService, ProductImageTaskService, ProductImageDerivativeHistoryService, ImageSuiteService],
|
||||
exports: [MediaService],
|
||||
})
|
||||
export class MediaModule {}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createHash, randomUUID } from 'node:crypto';
|
||||
import { createHash, createHmac, randomUUID } from 'node:crypto';
|
||||
import {
|
||||
CreateBucketCommand,
|
||||
GetObjectCommand,
|
||||
@@ -11,16 +11,21 @@ import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import type {
|
||||
CompleteMediaUploadIntentRequest,
|
||||
CreateMediaAccessUrlRequest,
|
||||
CreateMediaUploadIntentRequest,
|
||||
ImportProductImageRequest,
|
||||
} 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 { APP_CONFIG, type AppConfig } from '../config/app-config.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 = {
|
||||
id: string;
|
||||
purpose: 'watermark_image' | 'product_image';
|
||||
storage_provider: string;
|
||||
bucket: string;
|
||||
object_key: string;
|
||||
@@ -43,6 +48,7 @@ type AssetRow = {
|
||||
@Injectable()
|
||||
export class MediaService {
|
||||
private readonly s3: S3Client;
|
||||
private readonly legacyMinioS3: S3Client | null;
|
||||
private bucketReady: Promise<void> | null = null;
|
||||
|
||||
public constructor(
|
||||
@@ -53,13 +59,29 @@ export class MediaService {
|
||||
) {
|
||||
this.s3 = new S3Client({
|
||||
endpoint: config.OBJECT_STORAGE_ENDPOINT,
|
||||
region: 'us-east-1',
|
||||
region: config.OBJECT_STORAGE_REGION,
|
||||
forcePathStyle: config.objectStorageForcePathStyle,
|
||||
credentials: {
|
||||
accessKeyId: config.objectStorageAccessKey,
|
||||
secretAccessKey: config.objectStorageSecretKey,
|
||||
},
|
||||
});
|
||||
// 开发环境保留旧 MinIO 素材预览,历史素材正式发布前仍需迁移到七牛。
|
||||
this.legacyMinioS3 =
|
||||
config.NODE_ENV !== 'production' &&
|
||||
config.OBJECT_STORAGE_PROVIDER === 'qiniu_kodo' &&
|
||||
config.MINIO_ROOT_USER &&
|
||||
config.MINIO_ROOT_PASSWORD
|
||||
? new S3Client({
|
||||
endpoint: config.LEGACY_MINIO_ENDPOINT,
|
||||
region: 'us-east-1',
|
||||
forcePathStyle: true,
|
||||
credentials: {
|
||||
accessKeyId: config.MINIO_ROOT_USER,
|
||||
secretAccessKey: config.MINIO_ROOT_PASSWORD,
|
||||
},
|
||||
})
|
||||
: null;
|
||||
}
|
||||
|
||||
public async createIntent(
|
||||
@@ -78,18 +100,23 @@ export class MediaService {
|
||||
request: input,
|
||||
},
|
||||
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();
|
||||
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);
|
||||
await tx.query(
|
||||
`INSERT INTO atlas.media_upload_intents
|
||||
(id, workspace_id, purpose, storage_provider, bucket, object_key, expected_content_type,
|
||||
expected_byte_size, expected_checksum_sha256, expires_at, created_by_user_id)
|
||||
VALUES ($1, current_setting('app.workspace_id')::uuid, 'watermark_image', 'local_minio',
|
||||
$2, $3, $4, $5, $6, $7, $8)`,
|
||||
expected_byte_size, expected_checksum_sha256, expires_at, created_by_user_id, product_id)
|
||||
VALUES ($1, current_setting('app.workspace_id')::uuid, $2, $10,
|
||||
$3, $4, $5, $6, $7, $8, $9, $11)`,
|
||||
[
|
||||
id,
|
||||
input.purpose,
|
||||
this.config.OBJECT_STORAGE_BUCKET,
|
||||
objectKey,
|
||||
input.content_type,
|
||||
@@ -97,6 +124,8 @@ export class MediaService {
|
||||
input.checksum_sha256,
|
||||
expiresAt,
|
||||
userId,
|
||||
this.config.OBJECT_STORAGE_PROVIDER,
|
||||
input.product_id ?? null,
|
||||
],
|
||||
);
|
||||
const url = await getSignedUrl(
|
||||
@@ -213,7 +242,7 @@ export class MediaService {
|
||||
`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, '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,
|
||||
intent.bucket,
|
||||
@@ -223,6 +252,7 @@ export class MediaService {
|
||||
inspected.checksum,
|
||||
inspected.width,
|
||||
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) => {
|
||||
const asset = await tx.query<{
|
||||
storage_provider: string;
|
||||
bucket: 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],
|
||||
);
|
||||
if (!asset.rows[0])
|
||||
throw new ApiError(404, 'RESOURCE_NOT_FOUND', '素材不存在');
|
||||
const extension = ({
|
||||
'image/jpeg': 'jpg',
|
||||
'image/png': 'png',
|
||||
'image/webp': 'webp',
|
||||
} as Record<string, string>)[asset.rows[0].content_type] ?? 'bin';
|
||||
const expiresAt = new Date(Date.now() + 10 * 60_000);
|
||||
const url = await getSignedUrl(
|
||||
this.s3,
|
||||
new GetObjectCommand({
|
||||
Bucket: asset.rows[0].bucket,
|
||||
Key: asset.rows[0].object_key,
|
||||
}),
|
||||
{ expiresIn: 600 },
|
||||
const url = await this.signedReadUrl(
|
||||
asset.rows[0],
|
||||
expiresAt,
|
||||
purpose === 'download' ? `atlas-image-${assetId}.${extension}` : undefined,
|
||||
);
|
||||
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(
|
||||
tx: DatabaseTransaction,
|
||||
intentId: string,
|
||||
): Promise<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
|
||||
FROM atlas.media_upload_intents WHERE id = $1 FOR UPDATE`,
|
||||
[intentId],
|
||||
@@ -300,7 +477,7 @@ export class MediaService {
|
||||
private async requireAsset(tx: DatabaseTransaction, assetId: string) {
|
||||
const rows = await tx.query<AssetRow>(
|
||||
`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],
|
||||
);
|
||||
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(
|
||||
422,
|
||||
'UPLOAD_VALIDATION_FAILED',
|
||||
@@ -370,7 +548,7 @@ export class MediaService {
|
||||
for await (const chunk of object.Body as AsyncIterable<Uint8Array>) {
|
||||
byteSize += chunk.byteLength;
|
||||
// HeadObject 和 GetObject 之间对象可能被替换;读取时也必须有硬上限。
|
||||
if (byteSize > 5 * 1024 * 1024) {
|
||||
if (byteSize > byteLimit) {
|
||||
throw new ApiError(
|
||||
422,
|
||||
'UPLOAD_VALIDATION_FAILED',
|
||||
@@ -380,7 +558,7 @@ export class MediaService {
|
||||
chunks.push(Buffer.from(chunk));
|
||||
}
|
||||
const bytes = Buffer.concat(chunks, byteSize);
|
||||
const detected = detectImage(bytes);
|
||||
const detected = detectImage(bytes, intent.purpose === 'product_image' ? 10_000 : 8192);
|
||||
if (
|
||||
!detected ||
|
||||
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() {
|
||||
if (!this.bucketReady)
|
||||
this.bucketReady = (async () => {
|
||||
@@ -458,80 +685,3 @@ function apiErrorCode(error: unknown): string | undefined {
|
||||
? apiError.code
|
||||
: 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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,657 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import type { ProductAttributeSchema } 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 cacheMetadata,
|
||||
productCatalogResourceStatus as cacheStatus,
|
||||
type ProductCatalogResourceState as ResourceState,
|
||||
} from './product-editor.catalog-cache.js';
|
||||
import type { ProfileRow } from './product-editor.models.js';
|
||||
|
||||
type DictionaryCursor = { value: string; value_id: string; query: string };
|
||||
type RefreshScope = 'attributes' | 'dictionary' | 'dictionary_search';
|
||||
type RunRow = PlatformCatalogRunRow;
|
||||
|
||||
type CatalogAccess = {
|
||||
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[]>;
|
||||
};
|
||||
|
||||
/** 游标只保存排序位置,类目和属性归属仍由每次请求重新校验。 */
|
||||
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 {
|
||||
public constructor(
|
||||
private readonly database: Database,
|
||||
private readonly access: CatalogAccess,
|
||||
) {}
|
||||
|
||||
async categoryOptions(
|
||||
workspaceId: string,
|
||||
profileId: string,
|
||||
platformId: string,
|
||||
search: string | undefined,
|
||||
) {
|
||||
return this.database.workspaceTransaction(
|
||||
workspaceId,
|
||||
async (transaction) => {
|
||||
await this.access.requireProfileForPlatform(
|
||||
transaction,
|
||||
profileId,
|
||||
platformId,
|
||||
);
|
||||
const categories = await transaction.query<{
|
||||
id: string;
|
||||
parent_id: string | null;
|
||||
external_category_id: string;
|
||||
external_type_id: string | null;
|
||||
name: string;
|
||||
path: string;
|
||||
is_active: boolean;
|
||||
}>(
|
||||
`SELECT id, parent_id, external_category_id, external_type_id, name, path, is_active
|
||||
FROM atlas.platform_product_categories
|
||||
WHERE platform_id = $1
|
||||
ORDER BY path, name, id
|
||||
LIMIT 10000`,
|
||||
[platformId],
|
||||
);
|
||||
// 搜索由 Cascader 在完整平台树上执行,保持父子路径完整。
|
||||
void search;
|
||||
return categories.rows;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async attributeSchema(
|
||||
workspaceId: string,
|
||||
userId: string,
|
||||
profileId: string,
|
||||
platformId: string,
|
||||
categoryId?: string,
|
||||
) {
|
||||
const prepared = await this.readAttributeSchema(
|
||||
workspaceId,
|
||||
userId,
|
||||
profileId,
|
||||
platformId,
|
||||
categoryId,
|
||||
true,
|
||||
);
|
||||
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(
|
||||
workspaceId: string,
|
||||
userId: string,
|
||||
profileId: string,
|
||||
platformId: string,
|
||||
attributeId: string,
|
||||
search: string | undefined,
|
||||
cursor: string | undefined,
|
||||
categoryId?: string,
|
||||
) {
|
||||
const after = parseDictionaryCursor(cursor);
|
||||
const prepared = await this.readDictionary(
|
||||
workspaceId,
|
||||
userId,
|
||||
profileId,
|
||||
platformId,
|
||||
attributeId,
|
||||
search,
|
||||
after,
|
||||
categoryId,
|
||||
true,
|
||||
);
|
||||
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(
|
||||
workspaceId: string,
|
||||
userId: string,
|
||||
profileId: string,
|
||||
platformId: string,
|
||||
key: string,
|
||||
categoryId: string,
|
||||
) {
|
||||
const run = await this.database.workspaceTransaction(
|
||||
workspaceId,
|
||||
async (transaction) => {
|
||||
await this.access.requireProfileForPlatform(
|
||||
transaction,
|
||||
profileId,
|
||||
platformId,
|
||||
);
|
||||
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(
|
||||
workspaceId: string,
|
||||
userId: string,
|
||||
profileId: string,
|
||||
platformId: string,
|
||||
key: string,
|
||||
categoryId: string,
|
||||
attributeId: string,
|
||||
query: string,
|
||||
) {
|
||||
const needle = query.trim();
|
||||
if (needle.length === 1)
|
||||
throw new ApiError(
|
||||
422,
|
||||
'DICTIONARY_SEARCH_REQUIRED',
|
||||
'请至少输入 2 个字符后再从 Ozon 搜索字典值',
|
||||
);
|
||||
const run = await this.database.workspaceTransaction(
|
||||
workspaceId,
|
||||
async (transaction) => {
|
||||
await this.access.requireProfileForPlatform(
|
||||
transaction,
|
||||
profileId,
|
||||
platformId,
|
||||
);
|
||||
await this.access.requireActiveCategory(
|
||||
transaction,
|
||||
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,23 +13,32 @@ import {
|
||||
} from '@nestjs/common';
|
||||
import { z } from 'zod';
|
||||
import {
|
||||
productPublishPreflightRequestSchema,
|
||||
createProductPublishJobRequestSchema,
|
||||
requestProductAiTaskSchema,
|
||||
saveProductEditorDraftRequestSchema,
|
||||
saveProductSourceDescriptionRequestSchema,
|
||||
type Workspace,
|
||||
} from '@atlas/contracts';
|
||||
import { ApiError } from '../common/api-error.js';
|
||||
import type { AuthenticatedRequest } from '../auth/auth.guard.js';
|
||||
import { CurrentWorkspace } from '../workspace/current-workspace.decorator.js';
|
||||
import { ProductEditorService } from './product-editor.service.js';
|
||||
import { ProductAiService } from './product-ai.service.js';
|
||||
|
||||
type Response = { status: (value: number) => Response };
|
||||
|
||||
@Controller()
|
||||
export class ProductEditorController {
|
||||
/** 注入所有商品编辑接口共用的工作流服务。 */
|
||||
public constructor(
|
||||
@Inject(ProductEditorService)
|
||||
private readonly editor: ProductEditorService,
|
||||
@Inject(ProductAiService)
|
||||
private readonly productAi: ProductAiService,
|
||||
) {}
|
||||
|
||||
/** 为采集记录创建或复用当前平台的商品编辑上下文。 */
|
||||
@Post('collection-items/:collectionItemId/editor-contexts')
|
||||
async enter(
|
||||
@Param('collectionItemId') collectionItemId: string,
|
||||
@@ -50,6 +59,7 @@ export class ProductEditorController {
|
||||
return result.body;
|
||||
}
|
||||
|
||||
/** 返回当前平台下完整的商品编辑读模型。 */
|
||||
@Get('products/:productId/editor')
|
||||
async read(
|
||||
@Param('productId') productId: string,
|
||||
@@ -65,11 +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')
|
||||
async categoryOptions(
|
||||
@Param('profileId') profileId: string,
|
||||
@Query('q') query: string | undefined,
|
||||
@Query('store_id') storeId: string | undefined,
|
||||
@Headers('x-atlas-platform-id') platformId: string | undefined,
|
||||
@CurrentWorkspace() workspace: Workspace,
|
||||
) {
|
||||
@@ -79,30 +168,106 @@ export class ProductEditorController {
|
||||
requiredUuid('profile_id', profileId),
|
||||
requiredUuid('X-Atlas-Platform-Id', platformId),
|
||||
query,
|
||||
optionalUuid('store_id', storeId),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
/** 返回所选类目的缓存动态属性定义。 */
|
||||
@Get('product-platform-profiles/:profileId/attribute-schema')
|
||||
async attributeSchema(
|
||||
@Param('profileId') profileId: string,
|
||||
@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,
|
||||
@CurrentWorkspace() workspace: Workspace,
|
||||
) {
|
||||
return {
|
||||
data: await this.editor.attributeSchema(
|
||||
data: await this.editor.validation(
|
||||
workspace.id,
|
||||
requiredUuid('profile_id', profileId),
|
||||
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')
|
||||
async attributeSchemaSync(
|
||||
@Param('profileId') profileId: string,
|
||||
@@ -125,39 +290,42 @@ export class ProductEditorController {
|
||||
return result.body;
|
||||
}
|
||||
|
||||
/** 从本地缓存中查询属性字典值。 */
|
||||
@Get(
|
||||
'product-platform-profiles/:profileId/attribute-dictionaries/:dictionaryId',
|
||||
'product-platform-profiles/:profileId/attribute-dictionaries/:attributeId',
|
||||
)
|
||||
async attributeDictionary(
|
||||
@Param('profileId') profileId: string,
|
||||
@Param('dictionaryId') dictionaryId: string,
|
||||
@Param('attributeId') attributeId: string,
|
||||
@Query('q') query: string | undefined,
|
||||
@Query('cursor') cursor: 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,
|
||||
) {
|
||||
if (!dictionaryId.trim() || dictionaryId.length > 200)
|
||||
throw new ApiError(400, 'VALIDATION_FAILED', '字典标识不合法');
|
||||
return {
|
||||
data: await this.editor.attributeDictionary(
|
||||
const result = await this.editor.attributeDictionary(
|
||||
workspace.id,
|
||||
request.auth.userId,
|
||||
requiredUuid('profile_id', profileId),
|
||||
requiredUuid('X-Atlas-Platform-Id', platformId),
|
||||
dictionaryId,
|
||||
requiredUuid('attribute_id', attributeId),
|
||||
query,
|
||||
cursor,
|
||||
optionalUuid('category_id', categoryId),
|
||||
optionalUuid('store_id', storeId),
|
||||
),
|
||||
};
|
||||
);
|
||||
response.status(result.status);
|
||||
return result.body;
|
||||
}
|
||||
|
||||
/** 本地缓存不足时幂等地创建远程字典查询任务。 */
|
||||
@Post(
|
||||
'product-platform-profiles/:profileId/attribute-dictionaries/:dictionaryId/syncs',
|
||||
'product-platform-profiles/:profileId/attribute-dictionaries/:attributeId/syncs',
|
||||
)
|
||||
async attributeDictionarySync(
|
||||
@Param('profileId') profileId: string,
|
||||
@Param('dictionaryId') dictionaryId: string,
|
||||
@Param('attributeId') attributeId: string,
|
||||
@Query('category_id') categoryId: string | undefined,
|
||||
@Query('q') query: string | undefined,
|
||||
@Headers('x-atlas-platform-id') platformId: string | undefined,
|
||||
@@ -166,8 +334,6 @@ export class ProductEditorController {
|
||||
@CurrentWorkspace() workspace: Workspace,
|
||||
@Res({ passthrough: true }) response: Response,
|
||||
) {
|
||||
if (!dictionaryId.trim() || dictionaryId.length > 200)
|
||||
throw new ApiError(400, 'VALIDATION_FAILED', '字典标识不合法');
|
||||
const result = await this.editor.requestAttributeDictionarySync(
|
||||
workspace.id,
|
||||
request.auth.userId,
|
||||
@@ -175,13 +341,14 @@ export class ProductEditorController {
|
||||
requiredUuid('X-Atlas-Platform-Id', platformId),
|
||||
requiredUuid('Idempotency-Key', key),
|
||||
requiredUuid('category_id', categoryId),
|
||||
dictionaryId,
|
||||
requiredUuid('attribute_id', attributeId),
|
||||
query ?? '',
|
||||
);
|
||||
response.status(result.status);
|
||||
return result.body;
|
||||
}
|
||||
|
||||
/** 校验并原子保存商品及其上架草稿。 */
|
||||
@Put('products/:productId/editor-draft')
|
||||
async save(
|
||||
@Param('productId') productId: string,
|
||||
@@ -217,8 +384,47 @@ export class ProductEditorController {
|
||||
)
|
||||
).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,并返回原始值。 */
|
||||
function requiredUuid(field: string, value: string | undefined) {
|
||||
if (value && z.uuid().safeParse(value).success) return value;
|
||||
throw new ApiError(400, 'VALIDATION_FAILED', '请求参数不符合要求', [
|
||||
@@ -226,11 +432,13 @@ function requiredUuid(field: string, value: string | undefined) {
|
||||
]);
|
||||
}
|
||||
|
||||
/** 将缺省查询参数保留为 undefined;有值时校验其 UUID 格式。 */
|
||||
function optionalUuid(field: string, value: string | undefined) {
|
||||
if (value === undefined || value === '') return undefined;
|
||||
return requiredUuid(field, value);
|
||||
}
|
||||
|
||||
/** 解析 If-Match 中成对传递的商品和发布配置版本号。 */
|
||||
function parseEditorRevision(value: string | undefined) {
|
||||
const match = value?.match(/^"([1-9]\d*):([1-9]\d*)"$/);
|
||||
if (!match)
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
/** 商品编辑领域从数据库读取的商品行。 */
|
||||
export type ProductRow = {
|
||||
id: string;
|
||||
collection_item_id: string;
|
||||
revision: number;
|
||||
source_title: string | null;
|
||||
brand: string | null;
|
||||
model: string | null;
|
||||
barcode: string | null;
|
||||
purchase_url: string | null;
|
||||
source_url: string | null;
|
||||
supply_note: string | null;
|
||||
default_physical_length_mm: string | null;
|
||||
default_physical_width_mm: string | null;
|
||||
default_physical_height_mm: string | null;
|
||||
default_net_weight_g: string | null;
|
||||
default_package_length_mm: string | null;
|
||||
default_package_width_mm: string | null;
|
||||
default_package_height_mm: string | null;
|
||||
default_shipping_weight_g: string | null;
|
||||
package_weight_mode: 'common' | 'variant';
|
||||
package_dimensions_mode: 'common' | 'variant';
|
||||
physical_spec_status: 'missing' | 'ambiguous' | 'confirmed';
|
||||
package_spec_status: 'missing' | 'ambiguous' | 'confirmed';
|
||||
ai_source_material_overrides: unknown;
|
||||
ai_source_material_base_revision: number | string | null;
|
||||
normalized_payload: unknown;
|
||||
source_snapshot_revision: number | string;
|
||||
};
|
||||
|
||||
/** 商品在一个发布平台下的编辑配置行。 */
|
||||
export type ProfileRow = {
|
||||
id: string;
|
||||
product_id: string;
|
||||
revision: number;
|
||||
platform_id: string;
|
||||
market_code: string | null;
|
||||
fulfillment: string;
|
||||
title: string | null;
|
||||
title_mode: 'common' | 'variant';
|
||||
description: string | null;
|
||||
tags: unknown;
|
||||
platform_product_category_id: string | null;
|
||||
store_product_category_id: string | null;
|
||||
platform_type_id: string | null;
|
||||
category_source: string | null;
|
||||
category_name: string | null;
|
||||
category_path: string | null;
|
||||
target_store_id: string;
|
||||
store_name: string;
|
||||
currency_code: string;
|
||||
};
|
||||
|
||||
/** 异步目录任务返回给客户端所需的操作行。 */
|
||||
export type OperationRow = {
|
||||
id: string;
|
||||
kind: string;
|
||||
status: string;
|
||||
created_at: Date | string;
|
||||
completed_at: Date | string | null;
|
||||
error_code: string | null;
|
||||
error_message: string | null;
|
||||
};
|
||||
|
||||
/** 统一读取发布配置及其目标店铺与类目展示信息的查询片段。 */
|
||||
export const profileSelect = `SELECT p.id, p.product_id, p.revision, p.platform_id, p.market_code,
|
||||
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,
|
||||
category.path AS category_path, p.target_store_id, s.name AS store_name,
|
||||
s.currency_code
|
||||
FROM atlas.product_platform_profiles p
|
||||
JOIN atlas.stores s ON s.id = p.target_store_id
|
||||
LEFT JOIN atlas.platform_product_categories category
|
||||
ON category.id = p.platform_product_category_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 { ProductEditorController } from './product-editor.controller.js';
|
||||
import { ProductEditorService } from './product-editor.service.js';
|
||||
import { ProductAiService } from './product-ai.service.js';
|
||||
|
||||
@Module({
|
||||
controllers: [ProductEditorController],
|
||||
providers: [IdempotencyService, ProductEditorService],
|
||||
providers: [IdempotencyService, ProductEditorService, ProductAiService],
|
||||
})
|
||||
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
@@ -0,0 +1,149 @@
|
||||
import {
|
||||
productAiSourceMaterialOverridesSchema,
|
||||
productAiSourceMaterialSchema,
|
||||
} from '@atlas/contracts';
|
||||
import type {
|
||||
ProductAiSourceField,
|
||||
ProductAiSourceMaterial,
|
||||
} from '@atlas/contracts';
|
||||
|
||||
type SourceMaterialProduct = {
|
||||
source_title: string | null;
|
||||
brand: string | null;
|
||||
normalized_payload: unknown;
|
||||
source_snapshot_revision: number | string;
|
||||
ai_source_material_overrides: unknown;
|
||||
ai_source_material_base_revision: number | string | null;
|
||||
};
|
||||
|
||||
const productAiSourceFields: ProductAiSourceField[] = [
|
||||
'title',
|
||||
'description',
|
||||
'price',
|
||||
'brand',
|
||||
'attributes',
|
||||
'categories',
|
||||
];
|
||||
|
||||
/** 计算包含用户覆盖字段的有效 AI 来源素材及其状态。 */
|
||||
export function productAiSourceMaterial(product: SourceMaterialProduct) {
|
||||
const latest = latestProductAiSourceMaterial(product);
|
||||
const parsedOverrides = productAiSourceMaterialOverridesSchema.safeParse(
|
||||
product.ai_source_material_overrides,
|
||||
);
|
||||
const overrides = parsedOverrides.success ? parsedOverrides.data : {};
|
||||
const overriddenFields = productAiSourceFields.filter((field) =>
|
||||
Object.prototype.hasOwnProperty.call(overrides, field),
|
||||
);
|
||||
const effective = productAiSourceMaterialSchema.parse({
|
||||
...latest,
|
||||
...overrides,
|
||||
source_snapshot_revision: latest.source_snapshot_revision,
|
||||
});
|
||||
const baseRevision = product.ai_source_material_base_revision
|
||||
? Number(product.ai_source_material_base_revision)
|
||||
: null;
|
||||
return {
|
||||
effective,
|
||||
state: {
|
||||
latest,
|
||||
overridden_fields: overriddenFields,
|
||||
changed_fields: overriddenFields.filter(
|
||||
(field) =>
|
||||
JSON.stringify(effective[field]) !==
|
||||
JSON.stringify(latest[field]),
|
||||
),
|
||||
base_source_snapshot_revision: baseRevision,
|
||||
has_source_updates:
|
||||
baseRevision !== null &&
|
||||
baseRevision !== latest.source_snapshot_revision,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** 仅提取用户明确覆盖的字段,供草稿持久化。 */
|
||||
export function productAiSourceOverrides(
|
||||
material: ProductAiSourceMaterial,
|
||||
overriddenFields: ProductAiSourceField[],
|
||||
) {
|
||||
const overrides: Record<string, unknown> = {};
|
||||
for (const field of overriddenFields) overrides[field] = material[field];
|
||||
return productAiSourceMaterialOverridesSchema.parse(overrides);
|
||||
}
|
||||
|
||||
/** 提取未经覆盖的最新采集快照,用于类目匹配和变更检测。 */
|
||||
export function latestProductAiSourceMaterial(
|
||||
product: SourceMaterialProduct,
|
||||
): ProductAiSourceMaterial {
|
||||
const payload = objectValue(product.normalized_payload);
|
||||
const common = objectValue(payload?.common);
|
||||
const title = objectValue(common?.title)?.value;
|
||||
const description = objectValue(common?.description)?.value;
|
||||
const sourcePrice = objectValue(common?.sourcePrice);
|
||||
const attributes = Array.isArray(common?.attributes)
|
||||
? common.attributes.flatMap((item) => {
|
||||
const attribute = objectValue(item);
|
||||
return typeof attribute?.key === 'string' &&
|
||||
typeof attribute.name === 'string' &&
|
||||
typeof attribute.value === 'string'
|
||||
? [{ key: attribute.key, name: attribute.name, value: attribute.value }]
|
||||
: [];
|
||||
})
|
||||
: [];
|
||||
const categories = Array.isArray(common?.categories)
|
||||
? common.categories.flatMap((item) => {
|
||||
const category = objectValue(item);
|
||||
return typeof category?.key === 'string' && typeof category.name === 'string'
|
||||
? [{ key: category.key, name: category.name }]
|
||||
: [];
|
||||
})
|
||||
: [];
|
||||
const brand = attributes.find((attribute) =>
|
||||
/brand|品牌/i.test(`${attribute.key} ${attribute.name}`),
|
||||
)?.value;
|
||||
const candidate: ProductAiSourceMaterial = {
|
||||
title: typeof title === 'string' ? title : product.source_title,
|
||||
description: typeof description === 'string' ? description : null,
|
||||
price:
|
||||
typeof sourcePrice?.amount === 'string' &&
|
||||
typeof sourcePrice.currencyCode === 'string'
|
||||
? { amount: sourcePrice.amount, currency_code: sourcePrice.currencyCode.toUpperCase() }
|
||||
: null,
|
||||
brand: brand ?? product.brand,
|
||||
attributes,
|
||||
categories,
|
||||
source_snapshot_revision: Number(product.source_snapshot_revision),
|
||||
};
|
||||
const parsed = productAiSourceMaterialSchema.safeParse(candidate);
|
||||
if (parsed.success) return parsed.data;
|
||||
return {
|
||||
title: product.source_title,
|
||||
description: null,
|
||||
price: null,
|
||||
brand: product.brand,
|
||||
attributes: [],
|
||||
categories: [],
|
||||
source_snapshot_revision: Math.max(1, Number(product.source_snapshot_revision) || 1),
|
||||
};
|
||||
}
|
||||
|
||||
/** 将未知 JSON 收窄为对象记录,供采集快照解析。 */
|
||||
function objectValue(value: unknown): Record<string, unknown> | undefined {
|
||||
return value && typeof value === 'object' && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ozon 公开详情页的后二、三级导航并不稳定;自动类目匹配只使用首级
|
||||
* 面包屑和页面结构化特征中的 Type_0 值。该信号缺失时必须交给人工选择。
|
||||
*/
|
||||
export function capturedOzonCategorySignals(
|
||||
source: ProductAiSourceMaterial,
|
||||
): { rootCategoryName: string | null; typeName: string | null } {
|
||||
const rootCategoryName = source.categories[0]?.name?.trim() || null;
|
||||
const typeName = source.attributes.find((attribute) =>
|
||||
/(?:^|:)type_0$/i.test(attribute.key.trim()),
|
||||
)?.value.trim() || null;
|
||||
return { rootCategoryName, typeName };
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
/**
|
||||
* 编辑读模型共用的纯规格映射函数。
|
||||
* 独立于工作流服务,便于单独审查继承规则。
|
||||
*/
|
||||
type ProductSpecSource = {
|
||||
default_physical_length_mm: string | null;
|
||||
default_physical_width_mm: string | null;
|
||||
default_physical_height_mm: string | null;
|
||||
default_net_weight_g: string | null;
|
||||
default_package_length_mm: string | null;
|
||||
default_package_width_mm: string | null;
|
||||
default_package_height_mm: string | null;
|
||||
default_shipping_weight_g: string | null;
|
||||
physical_spec_status: 'missing' | 'ambiguous' | 'confirmed';
|
||||
package_spec_status: 'missing' | 'ambiguous' | 'confirmed';
|
||||
};
|
||||
|
||||
type VariantSpecSource = {
|
||||
specification_values: unknown;
|
||||
physical_length_mm: string | null;
|
||||
physical_width_mm: string | null;
|
||||
physical_height_mm: string | null;
|
||||
net_weight_g: string | null;
|
||||
physical_spec_status: 'missing' | 'ambiguous' | 'confirmed' | null;
|
||||
package_length_mm: string | null;
|
||||
package_width_mm: string | null;
|
||||
package_height_mm: string | null;
|
||||
shipping_weight_g: string | null;
|
||||
package_spec_status: 'missing' | 'ambiguous' | 'confirmed' | null;
|
||||
};
|
||||
|
||||
/** 将持久化的商品默认值映射为对外的实物规格。 */
|
||||
export function productPhysicalSpec(product: ProductSpecSource) {
|
||||
return {
|
||||
length_mm: product.default_physical_length_mm,
|
||||
width_mm: product.default_physical_width_mm,
|
||||
height_mm: product.default_physical_height_mm,
|
||||
net_weight_g: product.default_net_weight_g,
|
||||
status: product.physical_spec_status,
|
||||
};
|
||||
}
|
||||
|
||||
/** 将持久化的商品默认值映射为对外的包装规格。 */
|
||||
export function productPackageSpec(product: ProductSpecSource) {
|
||||
return {
|
||||
length_mm: product.default_package_length_mm,
|
||||
width_mm: product.default_package_width_mm,
|
||||
height_mm: product.default_package_height_mm,
|
||||
shipping_weight_g: product.default_shipping_weight_g,
|
||||
status: product.package_spec_status,
|
||||
};
|
||||
}
|
||||
|
||||
/** 映射 SKU 可空的实物规格覆盖值,不应用商品继承。 */
|
||||
export function variantPhysicalSpec(variant: VariantSpecSource) {
|
||||
return {
|
||||
length_mm: variant.physical_length_mm,
|
||||
width_mm: variant.physical_width_mm,
|
||||
height_mm: variant.physical_height_mm,
|
||||
net_weight_g: variant.net_weight_g,
|
||||
status: variant.physical_spec_status,
|
||||
};
|
||||
}
|
||||
|
||||
/** 映射 SKU 可空的包装规格覆盖值,不应用商品继承。 */
|
||||
export function variantPackageSpec(variant: VariantSpecSource) {
|
||||
return {
|
||||
length_mm: variant.package_length_mm,
|
||||
width_mm: variant.package_width_mm,
|
||||
height_mm: variant.package_height_mm,
|
||||
shipping_weight_g: variant.shipping_weight_g,
|
||||
status: variant.package_spec_status,
|
||||
};
|
||||
}
|
||||
|
||||
/** 计算 SKU 继承后的有效实物规格。 */
|
||||
export function effectivePhysicalSpec(
|
||||
product: ProductSpecSource,
|
||||
variant: VariantSpecSource,
|
||||
) {
|
||||
const fallback = productPhysicalSpec(product);
|
||||
const override = variantPhysicalSpec(variant);
|
||||
return {
|
||||
length_mm: override.length_mm ?? fallback.length_mm,
|
||||
width_mm: override.width_mm ?? fallback.width_mm,
|
||||
height_mm: override.height_mm ?? fallback.height_mm,
|
||||
net_weight_g: override.net_weight_g ?? fallback.net_weight_g,
|
||||
status: override.status ?? fallback.status,
|
||||
};
|
||||
}
|
||||
|
||||
/** 计算 SKU 继承后的有效包装规格。 */
|
||||
export function effectivePackageSpec(
|
||||
product: ProductSpecSource,
|
||||
variant: VariantSpecSource,
|
||||
) {
|
||||
const fallback = productPackageSpec(product);
|
||||
const override = variantPackageSpec(variant);
|
||||
return {
|
||||
length_mm: override.length_mm ?? fallback.length_mm,
|
||||
width_mm: override.width_mm ?? fallback.width_mm,
|
||||
height_mm: override.height_mm ?? fallback.height_mm,
|
||||
shipping_weight_g:
|
||||
override.shipping_weight_g ?? fallback.shipping_weight_g,
|
||||
status: override.status ?? fallback.status,
|
||||
};
|
||||
}
|
||||
|
||||
/** 将不可信 JSON 过滤为稳定的来源 SKU 规格值结构。 */
|
||||
export function specificationValues(value: unknown) {
|
||||
if (!Array.isArray(value)) return [];
|
||||
return value.filter(
|
||||
(
|
||||
item,
|
||||
): item is {
|
||||
axis_key: string;
|
||||
value_key: string;
|
||||
value_name: string;
|
||||
} =>
|
||||
!!item &&
|
||||
typeof item === 'object' &&
|
||||
typeof (item as { axis_key?: unknown }).axis_key === 'string' &&
|
||||
typeof (item as { value_key?: unknown }).value_key === 'string' &&
|
||||
typeof (item as { value_name?: unknown }).value_name === 'string',
|
||||
);
|
||||
}
|
||||
@@ -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')
|
||||
: [];
|
||||
}
|
||||
@@ -52,7 +52,8 @@ export function decryptStoreSecret(
|
||||
const tag = ciphertextWithTag.subarray(-authenticationTagLength);
|
||||
const decipher = createDecipheriv('aes-256-gcm', key, nonce);
|
||||
decipher.setAuthTag(tag);
|
||||
return Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString(
|
||||
'utf8',
|
||||
);
|
||||
return Buffer.concat([
|
||||
decipher.update(ciphertext),
|
||||
decipher.final(),
|
||||
]).toString('utf8');
|
||||
}
|
||||
|
||||
@@ -207,6 +207,48 @@ export class StoresController {
|
||||
);
|
||||
}
|
||||
|
||||
@Post(':storeId/category-tree-syncs')
|
||||
@ApiOperation({ summary: '同步刷新门店类目树' })
|
||||
@ApiOkResponse({ description: 'Ozon 类目树已成功写入门店缓存' })
|
||||
public async categoryTreeSync(
|
||||
@Param('storeId') storeId: string,
|
||||
@Headers('idempotency-key') idempotencyKey: string | undefined,
|
||||
@Req() request: AuthenticatedRequest,
|
||||
@CurrentWorkspace() workspace: Workspace,
|
||||
@Res({ passthrough: true }) response: HttpResponse,
|
||||
) {
|
||||
return withStatus(
|
||||
response,
|
||||
await this.storesService.requestCategoryTreeSync(
|
||||
workspace.id,
|
||||
request.auth.userId,
|
||||
requireUuid('store_id', storeId),
|
||||
requireIdempotencyKey(idempotencyKey),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@Post('default/category-tree-debug')
|
||||
@ApiOperation({ summary: '读取当前平台默认门店的 Ozon 原始类目树(调试)' })
|
||||
public async defaultCategoryTreeDebug(
|
||||
@Headers('x-atlas-platform-id') platformId: string | undefined,
|
||||
@CurrentWorkspace() workspace: Workspace,
|
||||
) {
|
||||
if (!platformId) {
|
||||
throw new ApiError(
|
||||
400,
|
||||
'PLATFORM_CONTEXT_REQUIRED',
|
||||
'请求缺少 X-Atlas-Platform-Id',
|
||||
);
|
||||
}
|
||||
return {
|
||||
data: await this.storesService.fetchDefaultCategoryTreeDebug(
|
||||
workspace.id,
|
||||
requireUuid('X-Atlas-Platform-Id', platformId),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
@Post(':storeId/warehouse-syncs')
|
||||
@ApiOperation({ summary: '请求异步同步门店仓库' })
|
||||
@ApiAcceptedResponse({ description: '仓库同步操作已入队' })
|
||||
|
||||
@@ -11,7 +11,7 @@ import { ApiError } from '../common/api-error.js';
|
||||
import { IdempotencyService } from '../common/idempotency.service.js';
|
||||
import { APP_CONFIG, type AppConfig } from '../config/app-config.module.js';
|
||||
import { DATABASE } from '../database/database.module.js';
|
||||
import { encryptStoreSecret } from './store-secret.crypto.js';
|
||||
import { decryptStoreSecret, encryptStoreSecret } from './store-secret.crypto.js';
|
||||
|
||||
type StoreRow = Omit<Store, keyof { created_at: never; updated_at: never }> & {
|
||||
credential_checked_at: Date | string | null;
|
||||
@@ -46,6 +46,23 @@ type WarehouseRow = {
|
||||
status: string;
|
||||
synced_at: Date | string;
|
||||
};
|
||||
type OzonCategoryNode = {
|
||||
description_category_id?: number | string;
|
||||
category_name?: string | null;
|
||||
type_id?: number | string | null;
|
||||
type_name?: string | null;
|
||||
disabled?: boolean;
|
||||
children?: OzonCategoryNode[];
|
||||
};
|
||||
type FlatCategory = {
|
||||
external_category_id: string;
|
||||
external_type_id: string;
|
||||
parent_key: string | null;
|
||||
name: string;
|
||||
path: string;
|
||||
is_active: boolean;
|
||||
source_payload: OzonCategoryNode;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class StoresService {
|
||||
@@ -335,6 +352,170 @@ export class StoresService {
|
||||
);
|
||||
}
|
||||
|
||||
/** 手动刷新是同步请求:Ozon 完整响应成功落库后才向浏览器返回成功。 */
|
||||
public async requestCategoryTreeSync(
|
||||
workspaceId: string,
|
||||
userId: string,
|
||||
storeId: string,
|
||||
idempotencyKey: string,
|
||||
) {
|
||||
return this.idempotency.run(
|
||||
{
|
||||
workspaceId,
|
||||
userId,
|
||||
method: 'POST',
|
||||
path: `/api/stores/${storeId}/category-tree-syncs`,
|
||||
key: idempotencyKey,
|
||||
request: {},
|
||||
},
|
||||
async (transaction) => {
|
||||
const store = await this.requireStore(transaction, storeId);
|
||||
if (store.status === 'disabled') {
|
||||
throw new ApiError(422, 'STORE_DISABLED', '门店已停用');
|
||||
}
|
||||
if (store.credential_status !== 'valid') {
|
||||
throw new ApiError(
|
||||
422,
|
||||
'STORE_CREDENTIAL_UNAVAILABLE',
|
||||
'门店凭据尚未验证通过,无法刷新类目',
|
||||
);
|
||||
}
|
||||
|
||||
// 保持门店行锁直到完成写入,防止同一门店的两次刷新交错覆盖缓存。
|
||||
await transaction.query(
|
||||
'SELECT id FROM atlas.stores WHERE id = $1 FOR UPDATE',
|
||||
[storeId],
|
||||
);
|
||||
const source = await transaction.query<{
|
||||
platform_id: string;
|
||||
platform_code: string;
|
||||
platform_client_id: string;
|
||||
secret_ciphertext: Buffer;
|
||||
secret_nonce: Buffer;
|
||||
}>(
|
||||
`SELECT store.platform_id, platform.code AS platform_code, store.platform_client_id,
|
||||
secret.secret_ciphertext, secret.secret_nonce
|
||||
FROM atlas.stores store
|
||||
JOIN atlas.platforms platform ON platform.id = store.platform_id
|
||||
JOIN atlas.store_secrets secret ON secret.store_id = store.id
|
||||
WHERE store.id = $1 AND store.deleted_at IS NULL
|
||||
AND secret.kind = 'seller_api_key' AND secret.revoked_at IS NULL
|
||||
AND secret.status = 'valid'
|
||||
LIMIT 1`,
|
||||
[storeId],
|
||||
);
|
||||
const current = source.rows[0];
|
||||
if (!current) {
|
||||
throw new ApiError(
|
||||
422,
|
||||
'STORE_CREDENTIAL_UNAVAILABLE',
|
||||
'门店凭据尚未验证通过,无法刷新类目',
|
||||
);
|
||||
}
|
||||
if (current.platform_code.toUpperCase() !== 'OZON') {
|
||||
throw new ApiError(
|
||||
422,
|
||||
'PLATFORM_CATALOG_UNSUPPORTED',
|
||||
'当前平台暂不支持类目缓存同步',
|
||||
);
|
||||
}
|
||||
const apiKey = decryptStoreSecret(
|
||||
current.secret_ciphertext,
|
||||
current.secret_nonce,
|
||||
this.config.STORE_SECRET_ENCRYPTION_KEY,
|
||||
);
|
||||
const tree = await requestOzonCategoryTree(
|
||||
current.platform_client_id,
|
||||
apiKey,
|
||||
);
|
||||
const result = await persistCategoryTreeInTransaction(
|
||||
transaction,
|
||||
storeId,
|
||||
current.platform_id,
|
||||
tree,
|
||||
);
|
||||
return { status: 200, body: { data: result } };
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Debug-only read-through for the current platform's default store. The
|
||||
* credential is decrypted only for this outbound request and is never
|
||||
* placed in the browser response, logs, operations, or outbox payloads.
|
||||
*/
|
||||
public async fetchDefaultCategoryTreeDebug(
|
||||
workspaceId: string,
|
||||
platformId: string,
|
||||
): Promise<unknown> {
|
||||
const source = await this.database.workspaceTransaction(
|
||||
workspaceId,
|
||||
async (transaction) => {
|
||||
const rows = await transaction.query<{
|
||||
platform_client_id: string;
|
||||
secret_ciphertext: Buffer;
|
||||
secret_nonce: Buffer;
|
||||
}>(
|
||||
`SELECT store.platform_client_id, secret.secret_ciphertext, secret.secret_nonce
|
||||
FROM atlas.stores store
|
||||
JOIN atlas.platforms platform ON platform.id = store.platform_id
|
||||
JOIN atlas.store_secrets secret ON secret.store_id = store.id
|
||||
WHERE store.platform_id = $1 AND store.is_default
|
||||
AND store.deleted_at IS NULL AND store.status <> 'disabled'
|
||||
AND store.credential_status = 'valid'
|
||||
AND platform.code = 'OZON'
|
||||
AND secret.kind = 'seller_api_key' AND secret.status = 'valid'
|
||||
AND secret.revoked_at IS NULL
|
||||
LIMIT 1`,
|
||||
[platformId],
|
||||
);
|
||||
return rows.rows[0] ?? null;
|
||||
},
|
||||
);
|
||||
if (!source) {
|
||||
throw new ApiError(
|
||||
422,
|
||||
'STORE_CREDENTIAL_UNAVAILABLE',
|
||||
'当前平台没有凭据已验证的默认 Ozon 门店',
|
||||
);
|
||||
}
|
||||
|
||||
const apiKey = decryptStoreSecret(
|
||||
source.secret_ciphertext,
|
||||
source.secret_nonce,
|
||||
this.config.STORE_SECRET_ENCRYPTION_KEY,
|
||||
);
|
||||
try {
|
||||
const response = await fetch(
|
||||
'https://api-seller.ozon.ru/v1/description-category/tree',
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Client-Id': source.platform_client_id,
|
||||
'Api-Key': apiKey,
|
||||
},
|
||||
body: JSON.stringify({ language: 'ZH_HANS' }),
|
||||
signal: AbortSignal.timeout(30_000),
|
||||
},
|
||||
);
|
||||
const body = (await response.json()) as unknown;
|
||||
if (response.ok) return body;
|
||||
throw new ApiError(
|
||||
502,
|
||||
'OZON_CATEGORY_TREE_DEBUG_FAILED',
|
||||
`Ozon 类目树请求失败(HTTP ${response.status})`,
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof ApiError) throw error;
|
||||
throw new ApiError(
|
||||
502,
|
||||
'OZON_CATEGORY_TREE_DEBUG_UNAVAILABLE',
|
||||
'Ozon 类目树请求暂时不可用,请稍后重试',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public async requestWarehouseSync(
|
||||
workspaceId: string,
|
||||
userId: string,
|
||||
@@ -661,6 +842,163 @@ export class StoresService {
|
||||
}
|
||||
}
|
||||
|
||||
async function requestOzonCategoryTree(
|
||||
clientId: string,
|
||||
apiKey: string,
|
||||
): Promise<OzonCategoryNode[]> {
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch('https://api-seller.ozon.ru/v1/description-category/tree', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Client-Id': clientId,
|
||||
'Api-Key': apiKey,
|
||||
},
|
||||
body: JSON.stringify({ language: 'ZH_HANS' }),
|
||||
signal: AbortSignal.timeout(30_000),
|
||||
});
|
||||
} catch {
|
||||
throw new ApiError(
|
||||
502,
|
||||
'OZON_CATEGORY_TREE_UNAVAILABLE',
|
||||
'Ozon 类目请求暂时不可用,请稍后重试',
|
||||
);
|
||||
}
|
||||
if (!response.ok) {
|
||||
throw new ApiError(
|
||||
502,
|
||||
'OZON_CATEGORY_TREE_REFRESH_FAILED',
|
||||
`Ozon 类目请求失败(HTTP ${response.status})`,
|
||||
);
|
||||
}
|
||||
let body: { result?: unknown };
|
||||
try {
|
||||
body = (await response.json()) as { result?: unknown };
|
||||
} catch {
|
||||
throw new ApiError(
|
||||
502,
|
||||
'OZON_CATEGORY_TREE_INVALID',
|
||||
'Ozon 返回的类目树格式无效',
|
||||
);
|
||||
}
|
||||
if (!Array.isArray(body?.result)) {
|
||||
throw new ApiError(
|
||||
502,
|
||||
'OZON_CATEGORY_TREE_INVALID',
|
||||
'Ozon 返回的类目树格式无效',
|
||||
);
|
||||
}
|
||||
return body.result as OzonCategoryNode[];
|
||||
}
|
||||
|
||||
async function persistCategoryTreeInTransaction(
|
||||
transaction: DatabaseTransaction,
|
||||
storeId: string,
|
||||
platformId: string,
|
||||
roots: OzonCategoryNode[],
|
||||
) {
|
||||
const records = [
|
||||
...new Map(
|
||||
flattenCategoryTree(roots).map((record) => [
|
||||
`${record.external_category_id}:${record.external_type_id}`,
|
||||
record,
|
||||
]),
|
||||
).values(),
|
||||
];
|
||||
const syncTime = new Date();
|
||||
await transaction.query(
|
||||
`WITH incoming AS (
|
||||
SELECT * FROM jsonb_to_recordset($3::jsonb) AS value(
|
||||
external_category_id text, external_type_id text, parent_key text,
|
||||
name text, path text, is_active boolean, source_payload jsonb
|
||||
)
|
||||
)
|
||||
INSERT INTO atlas.store_product_categories
|
||||
(workspace_id, store_id, platform_id, external_category_id, external_type_id, name, path,
|
||||
is_active, source_payload, synced_at)
|
||||
SELECT current_setting('app.workspace_id')::uuid, $1, $2, external_category_id, external_type_id,
|
||||
name, path, is_active, source_payload, $4::timestamptz
|
||||
FROM incoming
|
||||
ON CONFLICT (store_id, external_category_id, external_type_id) DO UPDATE
|
||||
SET name = EXCLUDED.name, path = EXCLUDED.path, is_active = EXCLUDED.is_active,
|
||||
source_payload = EXCLUDED.source_payload, synced_at = EXCLUDED.synced_at,
|
||||
updated_at = now()`,
|
||||
[storeId, platformId, JSON.stringify(records), syncTime],
|
||||
);
|
||||
await transaction.query(
|
||||
`WITH incoming AS (
|
||||
SELECT * FROM jsonb_to_recordset($2::jsonb) AS value(
|
||||
external_category_id text, external_type_id text, parent_key text,
|
||||
name text, path text, is_active boolean, source_payload jsonb
|
||||
)
|
||||
), resolved AS (
|
||||
SELECT child.id AS child_id, parent.id AS parent_id
|
||||
FROM incoming
|
||||
JOIN atlas.store_product_categories child
|
||||
ON child.store_id = $1
|
||||
AND child.external_category_id = incoming.external_category_id
|
||||
AND child.external_type_id = incoming.external_type_id
|
||||
LEFT JOIN atlas.store_product_categories parent
|
||||
ON parent.store_id = $1
|
||||
AND parent.external_category_id || ':' || parent.external_type_id = incoming.parent_key
|
||||
)
|
||||
UPDATE atlas.store_product_categories category
|
||||
SET parent_id = resolved.parent_id, updated_at = now()
|
||||
FROM resolved WHERE category.id = resolved.child_id`,
|
||||
[storeId, JSON.stringify(records)],
|
||||
);
|
||||
await transaction.query(
|
||||
`UPDATE atlas.store_product_categories SET is_active = false, updated_at = now()
|
||||
WHERE store_id = $1 AND synced_at IS DISTINCT FROM $2::timestamptz`,
|
||||
[storeId, syncTime],
|
||||
);
|
||||
return {
|
||||
category_count: records.length,
|
||||
active_category_count: records.filter((record) => record.is_active).length,
|
||||
};
|
||||
}
|
||||
|
||||
function flattenCategoryTree(
|
||||
nodes: OzonCategoryNode[],
|
||||
path: string[] = [],
|
||||
parentKey: string | null = null,
|
||||
inheritedCategory?: { id: string; name: string },
|
||||
): FlatCategory[] {
|
||||
const records: FlatCategory[] = [];
|
||||
for (const node of nodes) {
|
||||
const explicitCategoryId = String(node.description_category_id ?? '').trim();
|
||||
const explicitCategoryName = node.category_name?.trim() ?? '';
|
||||
const categoryId = explicitCategoryId || inheritedCategory?.id || '';
|
||||
const categoryName = explicitCategoryName || inheritedCategory?.name || '';
|
||||
const typeId = String(node.type_id ?? 0);
|
||||
const name = explicitCategoryName || node.type_name?.trim() || categoryName;
|
||||
if (!categoryId || !name) continue;
|
||||
const key = `${categoryId}:${typeId}`;
|
||||
const nextPath = [...path, name];
|
||||
records.push({
|
||||
external_category_id: categoryId,
|
||||
external_type_id: typeId,
|
||||
parent_key: parentKey,
|
||||
name,
|
||||
path: nextPath.join(' › '),
|
||||
is_active: node.disabled !== true && typeId !== '0',
|
||||
source_payload: node,
|
||||
});
|
||||
records.push(
|
||||
...flattenCategoryTree(
|
||||
node.children ?? [],
|
||||
nextPath,
|
||||
key,
|
||||
explicitCategoryId && explicitCategoryName
|
||||
? { id: explicitCategoryId, name: explicitCategoryName }
|
||||
: inheritedCategory,
|
||||
),
|
||||
);
|
||||
}
|
||||
return records;
|
||||
}
|
||||
|
||||
const storeSelect = `
|
||||
SELECT s.id, s.platform_id, s.name, s.platform_client_id, s.currency_code,
|
||||
s.is_default, s.note, s.status, s.credential_status, s.credential_checked_at,
|
||||
|
||||
@@ -28,6 +28,12 @@ export class WatermarksController {
|
||||
return this.watermarks.list(workspace.id, parse(listWatermarkTemplatesQuerySchema, query));
|
||||
}
|
||||
|
||||
@Get('watermark-templates/default')
|
||||
@ApiOperation({ summary: '读取当前工作空间的默认水印模板' })
|
||||
public async getDefault(@CurrentWorkspace() workspace: Workspace) {
|
||||
return { data: await this.watermarks.getDefault(workspace.id) };
|
||||
}
|
||||
|
||||
@Get('watermark-templates/:templateId')
|
||||
@ApiOperation({ summary: '读取水印模板当前版本' })
|
||||
public async get(@Param('templateId') templateId: string, @CurrentWorkspace() workspace: Workspace) {
|
||||
@@ -51,6 +57,15 @@ export class WatermarksController {
|
||||
idempotencyKey(key), revision(ifMatch), parse(updateWatermarkTemplateRequestSchema, body))).body;
|
||||
}
|
||||
|
||||
@Put('watermark-templates/:templateId/default')
|
||||
@ApiOkResponse({ description: '已设为当前工作空间的默认水印' })
|
||||
public async setDefault(@Param('templateId') templateId: string,
|
||||
@Headers('idempotency-key') key: string | undefined,
|
||||
@Req() request: AuthenticatedRequest, @CurrentWorkspace() workspace: Workspace) {
|
||||
return (await this.watermarks.setDefault(workspace.id, request.auth.userId,
|
||||
uuid('template_id', templateId), idempotencyKey(key))).body;
|
||||
}
|
||||
|
||||
@Delete('watermark-templates/:templateId')
|
||||
@HttpCode(204)
|
||||
@ApiNoContentResponse({ description: '已软删除模板并解除当前店铺绑定' })
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import {
|
||||
WATERMARK_BORDER_RADIUS_MAX_PX,
|
||||
WATERMARK_IMAGE_SIZE_MAX_PX,
|
||||
WATERMARK_IMAGE_SIZE_MIN_PX,
|
||||
} from '@atlas/contracts';
|
||||
import type {
|
||||
CreateWatermarkTemplateRequest,
|
||||
ListWatermarkTemplatesQuery,
|
||||
@@ -13,6 +18,7 @@ type TemplateRow = {
|
||||
id: string;
|
||||
name: string;
|
||||
kind: 'text' | 'image';
|
||||
is_default: boolean;
|
||||
current_version: number;
|
||||
created_at: Date | string;
|
||||
updated_at: Date | string;
|
||||
@@ -23,7 +29,11 @@ type TemplateRow = {
|
||||
font_size_px: number | null;
|
||||
opacity: string;
|
||||
scale_ratio: string | null;
|
||||
image_size_px: number | null;
|
||||
border_radius_px: number;
|
||||
position: string;
|
||||
position_x: string;
|
||||
position_y: string;
|
||||
};
|
||||
type TemplateConfig = Record<string, unknown>;
|
||||
|
||||
@@ -81,6 +91,59 @@ export class WatermarksService {
|
||||
);
|
||||
}
|
||||
|
||||
public async getDefault(workspaceId: string) {
|
||||
return this.database.workspaceTransaction(workspaceId, async (tx) => {
|
||||
const rows = await tx.query<TemplateRow>(
|
||||
`${templateSelect} WHERE template.is_default = true
|
||||
AND template.status = 'active' AND template.deleted_at IS NULL`,
|
||||
);
|
||||
return rows.rows[0] ? toTemplate(rows.rows[0]) : null;
|
||||
});
|
||||
}
|
||||
|
||||
public async setDefault(
|
||||
workspaceId: string,
|
||||
userId: string,
|
||||
templateId: string,
|
||||
idempotencyKey: string,
|
||||
) {
|
||||
return this.idempotency.run(
|
||||
{
|
||||
workspaceId,
|
||||
userId,
|
||||
method: 'PUT',
|
||||
path: `/api/watermark-templates/${templateId}/default`,
|
||||
key: idempotencyKey,
|
||||
request: {},
|
||||
},
|
||||
async (tx) => {
|
||||
// 同一工作空间的切换操作串行化,避免两个请求同时争用唯一默认索引。
|
||||
await tx.query(
|
||||
`SELECT id FROM atlas.workspaces
|
||||
WHERE id = current_setting('app.workspace_id')::uuid FOR UPDATE`,
|
||||
);
|
||||
const current = await this.lockTemplate(tx, templateId);
|
||||
if (!current.is_default) {
|
||||
await tx.query(
|
||||
`UPDATE atlas.watermark_templates
|
||||
SET is_default = false
|
||||
WHERE is_default = true AND status = 'active' AND deleted_at IS NULL`,
|
||||
);
|
||||
await tx.query(
|
||||
`UPDATE atlas.watermark_templates
|
||||
SET is_default = true
|
||||
WHERE id = $1`,
|
||||
[templateId],
|
||||
);
|
||||
}
|
||||
return {
|
||||
status: 200,
|
||||
body: { data: await this.requireTemplate(tx, templateId) },
|
||||
};
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
public async create(
|
||||
workspaceId: string,
|
||||
userId: string,
|
||||
@@ -97,7 +160,8 @@ export class WatermarksService {
|
||||
request: input,
|
||||
},
|
||||
async (tx) => {
|
||||
await this.validateConfig(tx, input.kind, input.config);
|
||||
const config = normalizeImageConfig(input.kind, normalizePlacement(input.config), input.config);
|
||||
await this.validateConfig(tx, input.kind, config);
|
||||
const inserted = await tx.query<{ id: string }>(
|
||||
`INSERT INTO atlas.watermark_templates (workspace_id, name, kind)
|
||||
VALUES (current_setting('app.workspace_id')::uuid, $1, $2) RETURNING id`,
|
||||
@@ -108,7 +172,7 @@ export class WatermarksService {
|
||||
inserted.rows[0].id,
|
||||
1,
|
||||
input.kind,
|
||||
input.config,
|
||||
config,
|
||||
userId,
|
||||
);
|
||||
return {
|
||||
@@ -158,10 +222,10 @@ export class WatermarksService {
|
||||
);
|
||||
}
|
||||
// 同类型编辑允许只提交变化字段;切换类型时旧类型字段不会通过新类型校验。
|
||||
const nextConfig = normalizeConfig(nextKind, {
|
||||
const nextConfig = normalizeConfig(nextKind, normalizeImageConfig(nextKind, normalizePlacement({
|
||||
...configFromRow(current),
|
||||
...(input.config ?? {}),
|
||||
});
|
||||
}, input.config), input.config));
|
||||
await this.validateConfig(tx, nextKind, nextConfig);
|
||||
const renderingChanged =
|
||||
input.config !== undefined || nextKind !== current.kind;
|
||||
@@ -217,7 +281,7 @@ export class WatermarksService {
|
||||
[templateId],
|
||||
);
|
||||
await tx.query(
|
||||
`UPDATE atlas.watermark_templates SET status = 'archived', deleted_at = now(),
|
||||
`UPDATE atlas.watermark_templates SET status = 'archived', deleted_at = now(), is_default = false,
|
||||
revision = revision + 1, updated_at = now() WHERE id = $1`,
|
||||
[templateId],
|
||||
);
|
||||
@@ -312,7 +376,9 @@ export class WatermarksService {
|
||||
) {
|
||||
if (
|
||||
typeof config.opacity !== 'string' ||
|
||||
typeof config.position !== 'string'
|
||||
typeof config.position !== 'string' ||
|
||||
!validCoordinate(config.position_x) ||
|
||||
!validCoordinate(config.position_y)
|
||||
) {
|
||||
throw new ApiError(422, 'VALIDATION_FAILED', '水印配置不完整');
|
||||
}
|
||||
@@ -344,7 +410,9 @@ export class WatermarksService {
|
||||
const assetId = config.image_asset_id;
|
||||
if (
|
||||
typeof assetId !== 'string' ||
|
||||
typeof config.scale_ratio !== 'string'
|
||||
typeof config.scale_ratio !== 'string' ||
|
||||
!validImageSize(config.image_size_px) ||
|
||||
!validBorderRadius(config.border_radius_px)
|
||||
) {
|
||||
throw new ApiError(422, 'VALIDATION_FAILED', '图片水印配置不完整');
|
||||
}
|
||||
@@ -371,8 +439,8 @@ export class WatermarksService {
|
||||
) {
|
||||
await tx.query(
|
||||
`INSERT INTO atlas.watermark_template_versions
|
||||
(workspace_id, template_id, version, kind, text_content, image_asset_id, font_code, font_size_px, opacity, scale_ratio, position, created_by_user_id)
|
||||
VALUES (current_setting('app.workspace_id')::uuid, $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)`,
|
||||
(workspace_id, template_id, version, kind, text_content, image_asset_id, font_code, font_size_px, opacity, scale_ratio, position_x, position_y, position, created_by_user_id, image_size_px, border_radius_px)
|
||||
VALUES (current_setting('app.workspace_id')::uuid, $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15)`,
|
||||
[
|
||||
templateId,
|
||||
version,
|
||||
@@ -383,17 +451,23 @@ export class WatermarksService {
|
||||
config.font_size_px ?? null,
|
||||
config.opacity,
|
||||
config.scale_ratio ?? null,
|
||||
config.position_x,
|
||||
config.position_y,
|
||||
config.position,
|
||||
userId,
|
||||
config.image_size_px ?? null,
|
||||
config.border_radius_px ?? 0,
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const templateSelect = `SELECT template.id, template.name, template.kind, template.current_version,
|
||||
const templateSelect = `SELECT template.id, template.name, template.kind, template.is_default, template.current_version,
|
||||
template.created_at, template.updated_at, template.revision, version.text_content, version.image_asset_id,
|
||||
version.font_code, version.font_size_px, version.opacity::text AS opacity, version.scale_ratio::text AS scale_ratio,
|
||||
version.position FROM atlas.watermark_templates template JOIN atlas.watermark_template_versions version
|
||||
version.image_size_px, version.border_radius_px,
|
||||
version.position, version.position_x::text AS position_x, version.position_y::text AS position_y
|
||||
FROM atlas.watermark_templates template JOIN atlas.watermark_template_versions version
|
||||
ON version.template_id = template.id AND version.version = template.current_version`;
|
||||
|
||||
function toTemplate(row: TemplateRow) {
|
||||
@@ -401,6 +475,7 @@ function toTemplate(row: TemplateRow) {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
kind: row.kind,
|
||||
is_default: row.is_default,
|
||||
current_version: row.current_version,
|
||||
config: configFromRow(row),
|
||||
created_at: iso(row.created_at),
|
||||
@@ -416,12 +491,18 @@ function configFromRow(row: TemplateRow): TemplateConfig {
|
||||
font_size_px: row.font_size_px,
|
||||
opacity: row.opacity,
|
||||
position: row.position,
|
||||
position_x: Number(row.position_x),
|
||||
position_y: Number(row.position_y),
|
||||
}
|
||||
: {
|
||||
image_asset_id: row.image_asset_id,
|
||||
scale_ratio: row.scale_ratio,
|
||||
image_size_px: row.image_size_px,
|
||||
border_radius_px: row.border_radius_px,
|
||||
opacity: row.opacity,
|
||||
position: row.position,
|
||||
position_x: Number(row.position_x),
|
||||
position_y: Number(row.position_y),
|
||||
};
|
||||
}
|
||||
function normalizeConfig(
|
||||
@@ -435,14 +516,94 @@ function normalizeConfig(
|
||||
font_size_px: config.font_size_px,
|
||||
opacity: config.opacity,
|
||||
position: config.position,
|
||||
position_x: config.position_x,
|
||||
position_y: config.position_y,
|
||||
}
|
||||
: {
|
||||
image_asset_id: config.image_asset_id,
|
||||
scale_ratio: config.scale_ratio,
|
||||
image_size_px: config.image_size_px,
|
||||
border_radius_px: config.border_radius_px,
|
||||
opacity: config.opacity,
|
||||
position: config.position,
|
||||
position_x: config.position_x,
|
||||
position_y: config.position_y,
|
||||
};
|
||||
}
|
||||
|
||||
const legacyPositions = [
|
||||
'top_left', 'top_center', 'top_right',
|
||||
'middle_left', 'center', 'middle_right',
|
||||
'bottom_left', 'bottom_center', 'bottom_right',
|
||||
] as const;
|
||||
|
||||
function validCoordinate(value: unknown): value is number {
|
||||
return typeof value === 'number' && Number.isFinite(value) && value >= 0 && value <= 1;
|
||||
}
|
||||
|
||||
function validImageSize(value: unknown): value is number {
|
||||
return Number.isInteger(value) && Number(value) >= WATERMARK_IMAGE_SIZE_MIN_PX
|
||||
&& Number(value) <= WATERMARK_IMAGE_SIZE_MAX_PX;
|
||||
}
|
||||
|
||||
function validBorderRadius(value: unknown): value is number {
|
||||
return Number.isInteger(value) && Number(value) >= 0
|
||||
&& Number(value) <= WATERMARK_BORDER_RADIUS_MAX_PX;
|
||||
}
|
||||
|
||||
function legacyImageSize(value: unknown): number | undefined {
|
||||
const ratio = Number(value);
|
||||
if (!Number.isFinite(ratio)) return undefined;
|
||||
return Math.min(WATERMARK_IMAGE_SIZE_MAX_PX,
|
||||
Math.max(WATERMARK_IMAGE_SIZE_MIN_PX, Math.round(ratio * 144)));
|
||||
}
|
||||
|
||||
function normalizeImageConfig(
|
||||
kind: 'text' | 'image', config: TemplateConfig, patch?: Record<string, unknown>,
|
||||
): TemplateConfig {
|
||||
if (kind !== 'image') return config;
|
||||
const legacyOnly = patch?.scale_ratio !== undefined && patch.image_size_px === undefined;
|
||||
const size = legacyOnly
|
||||
? legacyImageSize(patch?.scale_ratio)
|
||||
: config.image_size_px ?? legacyImageSize(config.scale_ratio);
|
||||
const scaleRatio = legacyOnly || !validImageSize(size)
|
||||
? config.scale_ratio
|
||||
: String(Number(Math.max(0.1, size / 144).toFixed(4)));
|
||||
return {
|
||||
...config,
|
||||
image_size_px: size,
|
||||
border_radius_px: config.border_radius_px ?? 0,
|
||||
// 旧比例字段只供旧客户端读取;像素尺寸决定新预览与后续渲染。
|
||||
scale_ratio: scaleRatio,
|
||||
};
|
||||
}
|
||||
|
||||
function legacyCoordinates(position: unknown) {
|
||||
const index = legacyPositions.indexOf(position as typeof legacyPositions[number]);
|
||||
if (index < 0) return null;
|
||||
return { x: (index % 3) / 2, y: Math.floor(index / 3) / 2 };
|
||||
}
|
||||
|
||||
function normalizePlacement(config: TemplateConfig, patch?: Record<string, unknown>): TemplateConfig {
|
||||
const legacyOnly = patch?.position !== undefined
|
||||
&& patch.position_x === undefined && patch.position_y === undefined;
|
||||
const legacy = legacyCoordinates(config.position);
|
||||
const x = legacyOnly ? legacy?.x : config.position_x ?? legacy?.x;
|
||||
const y = legacyOnly ? legacy?.y : config.position_y ?? legacy?.y;
|
||||
if (!validCoordinate(x) || !validCoordinate(y)) return { ...config, position_x: x, position_y: y };
|
||||
const positionX = Math.round(x * 10_000) / 10_000;
|
||||
const positionY = Math.round(y * 10_000) / 10_000;
|
||||
const col = Math.min(2, Math.floor(positionX * 3));
|
||||
const row = Math.min(2, Math.floor(positionY * 3));
|
||||
return {
|
||||
...config,
|
||||
position_x: positionX,
|
||||
position_y: positionY,
|
||||
// 旧字段仅供兼容展示;精确坐标始终决定实际水印位置。
|
||||
position: legacyPositions[row * 3 + col],
|
||||
};
|
||||
}
|
||||
|
||||
function iso(value: Date | string) {
|
||||
return value instanceof Date ? value.toISOString() : value;
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -6,12 +6,14 @@ 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';
|
||||
import { PRODUCT_VARIANT_LIMIT } from '@atlas/contracts';
|
||||
|
||||
let app: NestExpressApplication;
|
||||
let database: Database;
|
||||
let baseUrl = '';
|
||||
let workspaceId = '';
|
||||
let userId = '';
|
||||
let webAccessToken = '';
|
||||
async function request(path: string, init: RequestInit = {}) {
|
||||
const response = await fetch(`${baseUrl}${path}`, {
|
||||
redirect: 'manual',
|
||||
@@ -124,7 +126,7 @@ describe('扩展授权与采集 HTTP 集成测试', () => {
|
||||
assert.equal(registration.response.status, 201);
|
||||
userId = registration.body.data.user.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', {
|
||||
headers: { Authorization: `Bearer ${webAccessToken}` },
|
||||
});
|
||||
@@ -166,11 +168,12 @@ describe('扩展授权与采集 HTTP 集成测试', () => {
|
||||
],
|
||||
evidence: [{ ref: 'page', kind: 'page', path: 'test' }],
|
||||
};
|
||||
const firstKey = randomUUID();
|
||||
const first = await request('/api/extension/materials', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${webAccessToken}`,
|
||||
'Idempotency-Key': randomUUID(),
|
||||
'Idempotency-Key': firstKey,
|
||||
},
|
||||
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.materialization.pending_selection, 1);
|
||||
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', {
|
||||
headers: { Authorization: `Bearer ${webAccessToken}` },
|
||||
});
|
||||
@@ -197,7 +279,7 @@ describe('扩展授权与采集 HTTP 集成测试', () => {
|
||||
);
|
||||
});
|
||||
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',
|
||||
headers: {
|
||||
@@ -208,6 +290,39 @@ describe('扩展授权与采集 HTTP 集成测试', () => {
|
||||
},
|
||||
);
|
||||
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.
|
||||
// Re-collection must neither delete this SKU nor overwrite its Atlas
|
||||
// fields / platform mapping just because a newer source snapshot arrives.
|
||||
@@ -218,7 +333,7 @@ describe('扩展授权与采集 HTTP 集成测试', () => {
|
||||
[first.body.data.product_id],
|
||||
);
|
||||
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`,
|
||||
[first.body.data.product_id],
|
||||
);
|
||||
@@ -303,6 +418,8 @@ describe('扩展授权与采集 HTTP 集成测试', () => {
|
||||
assert.equal(second.response.status, 200);
|
||||
assert.equal(second.body.data.reused, 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(
|
||||
second.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.body.data.product.common.purchase_url,
|
||||
'https://detail.1688.com/offer/888888888.html',
|
||||
);
|
||||
assert.equal(
|
||||
editorAfterRefresh.body.data.product.common.source_title,
|
||||
'人工确认商品名',
|
||||
@@ -328,7 +449,7 @@ describe('扩展授权与采集 HTTP 集成测试', () => {
|
||||
variant.source_variant_key === '123456789',
|
||||
);
|
||||
assert.equal(originalVariant.display_name, '人工确认 SKU');
|
||||
assert.equal(originalVariant.purchase_cost_cny, '88.8000');
|
||||
assert.equal(originalVariant.purchase_cost_cny, '88.80');
|
||||
assert.equal(originalVariant.listing.offer_id, 'manual-offer-001');
|
||||
const discoveredVariant = editorAfterRefresh.body.data.variants.find(
|
||||
(variant: { source_variant_key: string }) =>
|
||||
@@ -431,4 +552,98 @@ describe('扩展授权与采集 HTTP 集成测试', () => {
|
||||
);
|
||||
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);
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -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),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,170 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import type { AddressInfo } from 'node:net';
|
||||
import type { Database } from '@atlas/database';
|
||||
import type { NestExpressApplication } from '@nestjs/platform-express';
|
||||
import { afterAll, beforeAll, describe, it } from 'vitest';
|
||||
import { DATABASE } from '../src/database/database.module.js';
|
||||
import { createApplication } from '../src/main.js';
|
||||
|
||||
let app: NestExpressApplication;
|
||||
let database: Database;
|
||||
let baseUrl = '';
|
||||
let workspaceId = '';
|
||||
let userId = '';
|
||||
let token = '';
|
||||
|
||||
describe('水印派生 API 集成测试', () => {
|
||||
beforeAll(async () => {
|
||||
Object.assign(process.env, {
|
||||
NODE_ENV: 'test', SWAGGER_ENABLED: 'false',
|
||||
AUTH_REGISTER_IP_LIMIT: '100', AUTH_REGISTER_EMAIL_LIMIT: '100',
|
||||
});
|
||||
const created = await createApplication();
|
||||
app = created.app;
|
||||
database = app.get<Database>(DATABASE);
|
||||
await app.listen(0, '127.0.0.1');
|
||||
baseUrl = `http://127.0.0.1:${(app.getHttpServer().address() as AddressInfo).port}`;
|
||||
const response = await fetch(`${baseUrl}/api/auth/register`, {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
email: `watermark-derivative-${randomUUID()}@example.test`,
|
||||
password: 'correct-horse-battery-staple-123',
|
||||
password_confirmation: 'correct-horse-battery-staple-123',
|
||||
}),
|
||||
});
|
||||
assert.equal(response.status, 201);
|
||||
const body = await response.json() as { data: {
|
||||
user: { id: string }; workspace: { id: string }; access_token: string;
|
||||
} };
|
||||
userId = body.data.user.id;
|
||||
workspaceId = body.data.workspace.id;
|
||||
token = body.data.access_token;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
if (workspaceId) {
|
||||
await database.workspaceTransaction(workspaceId, async (tx) => {
|
||||
await tx.query('DELETE FROM atlas.idempotency_requests WHERE workspace_id = $1', [workspaceId]);
|
||||
await tx.query('DELETE FROM atlas.outbox_events WHERE workspace_id = $1', [workspaceId]);
|
||||
await tx.query('DELETE FROM atlas.operations WHERE workspace_id = $1', [workspaceId]);
|
||||
await tx.query('DELETE FROM atlas.media_aspect_ratio_derivatives WHERE workspace_id = $1', [workspaceId]);
|
||||
await tx.query('DELETE FROM atlas.media_watermark_derivatives WHERE workspace_id = $1', [workspaceId]);
|
||||
await tx.query('DELETE FROM atlas.watermark_template_versions WHERE workspace_id = $1', [workspaceId]);
|
||||
await tx.query('DELETE FROM atlas.watermark_templates WHERE workspace_id = $1', [workspaceId]);
|
||||
await tx.query('DELETE FROM atlas.media_assets WHERE workspace_id = $1', [workspaceId]);
|
||||
await tx.query('DELETE FROM atlas.products WHERE workspace_id = $1', [workspaceId]);
|
||||
await tx.query('DELETE FROM atlas.collection_items WHERE workspace_id = $1', [workspaceId]);
|
||||
});
|
||||
await database.query('DELETE FROM atlas.workspace_memberships WHERE user_id = $1', [userId]);
|
||||
await database.query('DELETE FROM atlas.workspaces WHERE id = $1', [workspaceId]);
|
||||
await database.query('DELETE FROM atlas.users WHERE id = $1', [userId]);
|
||||
}
|
||||
await app?.close();
|
||||
});
|
||||
|
||||
it('HTTP 创建任务锁定模板版本,结果查询保持工作空间边界', async () => {
|
||||
const { assetId, templateId, productId } = await database.workspaceTransaction(workspaceId, async (tx) => {
|
||||
const item = await tx.query<{ id: string }>(
|
||||
`INSERT INTO atlas.collection_items (workspace_id, source_kind, source_item_key)
|
||||
VALUES ($1, 'manual', $2) RETURNING id`,
|
||||
[workspaceId, `watermark-history-${randomUUID()}`],
|
||||
);
|
||||
const product = await tx.query<{ id: string }>(
|
||||
`INSERT INTO atlas.products (workspace_id, collection_item_id)
|
||||
VALUES ($1, $2) RETURNING id`,
|
||||
[workspaceId, item.rows[0].id],
|
||||
);
|
||||
const asset = await tx.query<{ id: string }>(
|
||||
`INSERT INTO atlas.media_assets
|
||||
(workspace_id, storage_provider, bucket, object_key, content_type,
|
||||
byte_size, checksum_sha256, width_px, height_px)
|
||||
VALUES ($1, 'local_minio', 'atlas-media', $2, 'image/jpeg', 100,
|
||||
$3, 120, 100) RETURNING id`,
|
||||
[workspaceId, `test/watermark-${randomUUID()}.jpg`, randomUUID().replaceAll('-', '').padEnd(64, '0')],
|
||||
);
|
||||
const template = await tx.query<{ id: string }>(
|
||||
`INSERT INTO atlas.watermark_templates (workspace_id, name, kind)
|
||||
VALUES ($1, $2, 'text') RETURNING id`,
|
||||
[workspaceId, `集成测试水印 ${randomUUID()}`],
|
||||
);
|
||||
await tx.query(
|
||||
`INSERT INTO atlas.watermark_template_versions
|
||||
(workspace_id, template_id, version, kind, text_content, font_code,
|
||||
font_size_px, opacity, position, position_x, position_y, created_by_user_id)
|
||||
VALUES ($1, $2, 1, 'text', 'Atlas', 'NotoSans', 20, 0.5,
|
||||
'bottom_right', 1, 1, $3)`,
|
||||
[workspaceId, template.rows[0].id, userId],
|
||||
);
|
||||
return { assetId: asset.rows[0].id, templateId: template.rows[0].id, productId: product.rows[0].id };
|
||||
});
|
||||
const key = randomUUID();
|
||||
const create = () => fetch(`${baseUrl}/api/media-assets/${assetId}/watermark-derivatives`, {
|
||||
method: 'POST', headers: {
|
||||
Authorization: `Bearer ${token}`, 'Content-Type': 'application/json', 'Idempotency-Key': key,
|
||||
},
|
||||
body: JSON.stringify({ watermark_template_id: templateId, product_id: productId }),
|
||||
});
|
||||
const response = await create();
|
||||
assert.equal(response.status, 202);
|
||||
const body = await response.json() as { data: {
|
||||
id: string; watermark_template_id: string; watermark_template_version: number;
|
||||
} };
|
||||
assert.equal(body.data.watermark_template_id, templateId);
|
||||
assert.equal(body.data.watermark_template_version, 1);
|
||||
const replay = await create();
|
||||
assert.equal(replay.status, 202);
|
||||
assert.equal((await replay.json() as { data: { id: string } }).data.id, body.data.id);
|
||||
const read = await fetch(`${baseUrl}/api/media-watermark-derivatives/${body.data.id}`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
assert.equal(read.status, 200);
|
||||
assert.equal((await read.json() as { data: { id: string } }).data.id, body.data.id);
|
||||
const stored = await database.workspaceTransaction(workspaceId, async (tx) =>
|
||||
tx.query<{ product_id: string }>(
|
||||
'SELECT product_id FROM atlas.media_watermark_derivatives WHERE id = $1',
|
||||
[body.data.id],
|
||||
),
|
||||
);
|
||||
assert.equal(stored.rows[0]?.product_id, productId);
|
||||
|
||||
const outputs = await database.workspaceTransaction(workspaceId, async (tx) => {
|
||||
const ids: string[] = [];
|
||||
for (const kind of ['watermark', 'ratio']) {
|
||||
const asset = await tx.query<{ id: string }>(
|
||||
`INSERT INTO atlas.media_assets
|
||||
(workspace_id, storage_provider, bucket, object_key, content_type,
|
||||
byte_size, checksum_sha256, width_px, height_px)
|
||||
VALUES ($1, 'local_minio', 'atlas-media', $2, 'image/jpeg', 100,
|
||||
$3, 1200, 1600) RETURNING id`,
|
||||
[workspaceId, `test/${kind}-${randomUUID()}.jpg`, randomUUID().replaceAll('-', '').padEnd(64, '0')],
|
||||
);
|
||||
ids.push(asset.rows[0].id);
|
||||
}
|
||||
await tx.query(
|
||||
`INSERT INTO atlas.media_watermark_derivatives
|
||||
(workspace_id, product_id, source_asset_id, watermark_template_id,
|
||||
watermark_template_version, output_asset_id, status, completed_at)
|
||||
VALUES ($1, $2, $3, $4, 1, $5, 'succeeded', now())`,
|
||||
[workspaceId, productId, assetId, templateId, ids[0]],
|
||||
);
|
||||
await tx.query(
|
||||
`INSERT INTO atlas.media_aspect_ratio_derivatives
|
||||
(workspace_id, product_id, source_asset_id, output_asset_id, status, completed_at)
|
||||
VALUES ($1, $2, $3, $4, 'succeeded', now())`,
|
||||
[workspaceId, productId, assetId, ids[1]],
|
||||
);
|
||||
return ids;
|
||||
});
|
||||
const history = await fetch(`${baseUrl}/api/products/${productId}/image-derivatives`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
assert.equal(history.status, 200);
|
||||
const records = (await history.json() as { data: Array<{
|
||||
kind: string; output_asset: { id: string; preview_url: string };
|
||||
}> }).data;
|
||||
assert.deepEqual(new Set(records.map((record) => record.kind)), new Set(['watermark', 'aspect_ratio_3_4']));
|
||||
assert.deepEqual(new Set(records.map((record) => record.output_asset.id)), new Set(outputs));
|
||||
assert.ok(records.every((record) => record.output_asset.preview_url));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { WatermarkDerivativeService } from '../src/media/watermark-derivative.service.js';
|
||||
|
||||
const workspaceId = '30cf54b7-06a3-4e5f-a5fc-d74165115094';
|
||||
const sourceAssetId = '664d0d73-3d39-4a98-ac0a-5c0e521d8998';
|
||||
const templateId = '0878262e-6c38-4f3b-953a-2084ef297a33';
|
||||
const derivativeId = '68db226f-3fcb-4579-ab9e-4932b1d84067';
|
||||
const productId = '733d0d73-3d39-4a98-ac0a-5c0e521d8998';
|
||||
|
||||
function serviceWithQuery(query: ReturnType<typeof vi.fn>) {
|
||||
const run = vi.fn(async (_input: unknown, work: (tx: { query: typeof query }) => Promise<unknown>) =>
|
||||
work({ query }));
|
||||
const service = new WatermarkDerivativeService(
|
||||
{} as ConstructorParameters<typeof WatermarkDerivativeService>[0],
|
||||
{ run } as unknown as ConstructorParameters<typeof WatermarkDerivativeService>[1],
|
||||
{} as ConstructorParameters<typeof WatermarkDerivativeService>[2],
|
||||
);
|
||||
return { service, run };
|
||||
}
|
||||
|
||||
describe('水印派生任务', () => {
|
||||
it('固定当前模板版本,并在同一个事务创建任务、操作和 Outbox', async () => {
|
||||
const statements: string[] = [];
|
||||
const query = vi.fn(async (sql: string) => {
|
||||
statements.push(sql);
|
||||
if (sql.includes('SELECT id FROM atlas.media_assets')) return { rows: [{ id: sourceAssetId }] };
|
||||
if (sql.includes('FROM atlas.watermark_templates')) return { rows: [{ id: templateId, current_version: 4 }] };
|
||||
if (sql.includes('RETURNING id')) return { rows: [{ id: derivativeId }] };
|
||||
return { rows: [] };
|
||||
});
|
||||
const { service, run } = serviceWithQuery(query);
|
||||
const result = await service.create(workspaceId, 'user-id', sourceAssetId, 'idempotency-key',
|
||||
{ watermark_template_id: templateId });
|
||||
expect(result).toMatchObject({ status: 202, body: { data: {
|
||||
id: derivativeId, watermark_template_id: templateId, watermark_template_version: 4,
|
||||
} } });
|
||||
expect(run).toHaveBeenCalledOnce();
|
||||
expect(statements.some((sql) => sql.includes('INSERT INTO atlas.media_watermark_derivatives'))).toBe(true);
|
||||
expect(statements.some((sql) => sql.includes('INSERT INTO atlas.operations'))).toBe(true);
|
||||
expect(statements.some((sql) => sql.includes('INSERT INTO atlas.outbox_events'))).toBe(true);
|
||||
});
|
||||
|
||||
it('拒绝不可用模板且不创建派生任务', async () => {
|
||||
const query = vi.fn(async (sql: string) => sql.includes('SELECT id FROM atlas.media_assets')
|
||||
? { rows: [{ id: sourceAssetId }] } : { rows: [] });
|
||||
const { service } = serviceWithQuery(query);
|
||||
await expect(service.create(workspaceId, 'user-id', sourceAssetId, 'idempotency-key',
|
||||
{ watermark_template_id: templateId })).rejects.toMatchObject({ status: 404 });
|
||||
expect(query.mock.calls.every(([sql]) => !String(sql).includes('INSERT INTO'))).toBe(true);
|
||||
});
|
||||
|
||||
it('将当前工作空间的商品关联到水印结果', async () => {
|
||||
const query = vi.fn(async (sql: string, values?: unknown[]) => {
|
||||
if (sql.includes('SELECT id FROM atlas.media_assets')) return { rows: [{ id: sourceAssetId }] };
|
||||
if (sql.includes('SELECT id FROM atlas.products')) return { rows: [{ id: productId }] };
|
||||
if (sql.includes('FROM atlas.watermark_templates')) return { rows: [{ id: templateId, current_version: 4 }] };
|
||||
if (sql.includes('INSERT INTO atlas.media_watermark_derivatives')) {
|
||||
expect(values).toEqual([productId, sourceAssetId, templateId, 4, 'user-id']);
|
||||
return { rows: [{ id: derivativeId }] };
|
||||
}
|
||||
return { rows: [] };
|
||||
});
|
||||
const { service } = serviceWithQuery(query);
|
||||
await service.create(workspaceId, 'user-id', sourceAssetId, 'key',
|
||||
{ watermark_template_id: templateId, product_id: productId });
|
||||
expect(query.mock.calls.some(([sql]) => String(sql).includes('INSERT INTO atlas.media_watermark_derivatives'))).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,258 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import type { AddressInfo } from 'node:net';
|
||||
import type { Database } from '@atlas/database';
|
||||
import type { NestExpressApplication } from '@nestjs/platform-express';
|
||||
import { afterAll, beforeAll, describe, it } from 'vitest';
|
||||
import { DATABASE } from '../src/database/database.module.js';
|
||||
import { createApplication } from '../src/main.js';
|
||||
|
||||
let app: NestExpressApplication;
|
||||
let database: Database;
|
||||
let baseUrl: string;
|
||||
let token = '';
|
||||
let workspaceId = '';
|
||||
let userId = '';
|
||||
let platformId = '';
|
||||
let storeId = '';
|
||||
let listingId = '';
|
||||
let runId = '';
|
||||
|
||||
async function request(path: string, init: RequestInit = {}) {
|
||||
const response = await fetch(`${baseUrl}${path}`, {
|
||||
...init,
|
||||
headers: { 'Content-Type': 'application/json', ...init.headers },
|
||||
});
|
||||
return { response, body: (await response.json()) as Record<string, any> };
|
||||
}
|
||||
|
||||
function auth(extra: Record<string, string> = {}) {
|
||||
return {
|
||||
Authorization: `Bearer ${token}`,
|
||||
'X-Atlas-Platform-Id': platformId,
|
||||
...extra,
|
||||
};
|
||||
}
|
||||
|
||||
describe('在线商品 HTTP 契约', () => {
|
||||
beforeAll(async () => {
|
||||
Object.assign(process.env, {
|
||||
NODE_ENV: 'test',
|
||||
SWAGGER_ENABLED: 'false',
|
||||
AUTH_REGISTER_IP_LIMIT: '100',
|
||||
AUTH_REGISTER_EMAIL_LIMIT: '100',
|
||||
});
|
||||
const created = await createApplication();
|
||||
app = created.app;
|
||||
database = app.get<Database>(DATABASE);
|
||||
await app.listen(0, '127.0.0.1');
|
||||
baseUrl = `http://127.0.0.1:${(app.getHttpServer().address() as AddressInfo).port}`;
|
||||
const registered = await request('/api/auth/register', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
email: `online-${randomUUID()}@example.test`,
|
||||
password: 'correct-horse-battery-staple-123',
|
||||
password_confirmation: 'correct-horse-battery-staple-123',
|
||||
}),
|
||||
});
|
||||
assert.equal(registered.response.status, 201);
|
||||
token = registered.body.data.access_token;
|
||||
workspaceId = registered.body.data.workspace.id;
|
||||
userId = registered.body.data.user.id;
|
||||
const platforms = await request('/api/platforms', {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
platformId = platforms.body.data.find(
|
||||
(platform: { code: string }) => platform.code === 'OZON',
|
||||
).id;
|
||||
storeId = randomUUID();
|
||||
await database.workspaceTransaction(workspaceId, async (tx) => {
|
||||
await tx.query(
|
||||
`INSERT INTO atlas.stores (id, workspace_id, platform_id, name, platform_client_id, currency_code, is_default, status, credential_status)
|
||||
VALUES ($1, $2, $3, '测试门店', $4, 'RUB', true, 'active', 'valid')`,
|
||||
[storeId, workspaceId, platformId, `online-http-${storeId}`],
|
||||
);
|
||||
const run = await tx.query<{ id: string }>(
|
||||
`INSERT INTO atlas.platform_product_sync_runs
|
||||
(workspace_id, store_id, platform_id, trigger_source, status, completed_at)
|
||||
VALUES ($1, $2, $3, 'manual', 'succeeded', now()) RETURNING id`,
|
||||
[workspaceId, storeId, platformId],
|
||||
);
|
||||
runId = run.rows[0].id;
|
||||
const listing = await tx.query<{ id: string }>(
|
||||
`INSERT INTO atlas.platform_listings
|
||||
(workspace_id, store_id, platform_id, remote_product_id, offer_id, remote_sku, name,
|
||||
currency_code, selling_price, visible, moderation_status, stock_summary,
|
||||
image_summary, source, last_sync_run_id, last_synced_at, raw_payload)
|
||||
VALUES ($1, $2, $3, '123456', 'test-offer', '654321', '测试商品',
|
||||
'RUB', 99, true, 'approved', '{"total":0,"has_data":true}',
|
||||
'{"url":null,"width_px":null,"height_px":null}', 'external_sync', $4, now(), '{}') RETURNING id`,
|
||||
[workspaceId, storeId, platformId, runId],
|
||||
);
|
||||
listingId = listing.rows[0].id;
|
||||
await tx.query(
|
||||
`INSERT INTO atlas.platform_listings
|
||||
(workspace_id, store_id, platform_id, remote_product_id, offer_id, name,
|
||||
visible, stock_summary, image_summary, source, last_sync_run_id, last_synced_at,
|
||||
missing_since, archived_at, raw_payload)
|
||||
VALUES ($1, $2, $3, '123457', 'archived-offer', '归档商品',
|
||||
NULL, '{"total":0,"has_data":false}', '{}', 'external_sync', $4,
|
||||
now(), now() - interval '8 days', now(), '{}')`,
|
||||
[workspaceId, storeId, platformId, runId],
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
if (workspaceId && database) {
|
||||
await database.workspaceTransaction(workspaceId, async (tx) => {
|
||||
await tx.query(
|
||||
'DELETE FROM atlas.platform_listing_sync_snapshots WHERE workspace_id = $1',
|
||||
[workspaceId],
|
||||
);
|
||||
await tx.query(
|
||||
'DELETE FROM atlas.platform_listings WHERE workspace_id = $1',
|
||||
[workspaceId],
|
||||
);
|
||||
await tx.query(
|
||||
'DELETE FROM atlas.platform_product_sync_runs WHERE workspace_id = $1',
|
||||
[workspaceId],
|
||||
);
|
||||
await tx.query(
|
||||
'DELETE FROM atlas.idempotency_requests WHERE workspace_id = $1',
|
||||
[workspaceId],
|
||||
);
|
||||
await tx.query(
|
||||
'DELETE FROM atlas.outbox_events WHERE workspace_id = $1',
|
||||
[workspaceId],
|
||||
);
|
||||
await tx.query(
|
||||
'DELETE FROM atlas.store_secrets WHERE workspace_id = $1',
|
||||
[workspaceId],
|
||||
);
|
||||
await tx.query(
|
||||
'DELETE FROM atlas.stores WHERE workspace_id = $1',
|
||||
[workspaceId],
|
||||
);
|
||||
});
|
||||
await database.query(
|
||||
'DELETE FROM atlas.workspace_memberships WHERE user_id = $1',
|
||||
[userId],
|
||||
);
|
||||
await database.query('DELETE FROM atlas.workspaces WHERE id = $1', [
|
||||
workspaceId,
|
||||
]);
|
||||
await database.query('DELETE FROM atlas.users WHERE id = $1', [
|
||||
userId,
|
||||
]);
|
||||
}
|
||||
if (app) await app.close();
|
||||
});
|
||||
|
||||
it('列表、详情与同步历史按平台店铺返回镜像,不返回原始响应', async () => {
|
||||
const listed = await request(
|
||||
`/api/platform-listings?store_id=${storeId}&q=test-offer`,
|
||||
{ headers: auth() },
|
||||
);
|
||||
assert.equal(listed.response.status, 200);
|
||||
assert.equal(listed.body.data.length, 1);
|
||||
assert.equal(listed.body.data[0].visible, true);
|
||||
assert.equal(listed.body.data[0].selling_price, '99.00');
|
||||
assert.deepEqual(listed.body.data[0].stock_summary, {
|
||||
total: 0,
|
||||
has_data: true,
|
||||
});
|
||||
assert.equal(listed.body.meta.store.id, storeId);
|
||||
assert.equal(listed.body.meta.last_sync_status, 'succeeded');
|
||||
assert.equal('raw_payload' in listed.body.data[0], false);
|
||||
const detail = await request(`/api/platform-listings/${listingId}`, {
|
||||
headers: auth(),
|
||||
});
|
||||
assert.equal(detail.response.status, 200);
|
||||
assert.equal(detail.body.data.offer_id, 'test-offer');
|
||||
const runs = await request(
|
||||
`/api/stores/${storeId}/platform-product-sync-runs`,
|
||||
{ headers: auth() },
|
||||
);
|
||||
assert.equal(runs.response.status, 200);
|
||||
assert.equal(runs.body.data[0].id, runId);
|
||||
const run = await request(`/api/platform-product-sync-runs/${runId}`, {
|
||||
headers: auth(),
|
||||
});
|
||||
assert.equal(run.response.status, 200);
|
||||
assert.equal(run.body.data.status, 'succeeded');
|
||||
});
|
||||
|
||||
it('筛选未知可见性与缺少平台头的请求按契约处理', async () => {
|
||||
const unknown = await request(
|
||||
`/api/platform-listings?store_id=${storeId}&visibility=unknown`,
|
||||
{ headers: auth() },
|
||||
);
|
||||
assert.equal(unknown.response.status, 200);
|
||||
assert.equal(unknown.body.data.length, 0);
|
||||
const archived = await request(
|
||||
`/api/platform-listings?store_id=${storeId}&visibility=unknown&include_archived=true`,
|
||||
{ headers: auth() },
|
||||
);
|
||||
assert.equal(archived.response.status, 200);
|
||||
assert.equal(archived.body.data.length, 1);
|
||||
assert.equal(archived.body.data[0].offer_id, 'archived-offer');
|
||||
const missingHeader = await request('/api/platform-listings', {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
assert.equal(missingHeader.response.status, 400);
|
||||
assert.equal(
|
||||
missingHeader.body.error.code,
|
||||
'PLATFORM_CONTEXT_REQUIRED',
|
||||
);
|
||||
const noCredentials = await request(
|
||||
`/api/stores/${storeId}/platform-product-sync-runs`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: auth({ 'Idempotency-Key': randomUUID() }),
|
||||
body: JSON.stringify({ reason: 'manual_refresh' }),
|
||||
},
|
||||
);
|
||||
assert.equal(noCredentials.response.status, 422);
|
||||
assert.equal(
|
||||
noCredentials.body.error.code,
|
||||
'STORE_CREDENTIAL_UNAVAILABLE',
|
||||
);
|
||||
});
|
||||
|
||||
it('触发同步写入 Outbox,同一幂等键复用任务,并发请求返回冲突', async () => {
|
||||
await database.workspaceTransaction(workspaceId, (tx) =>
|
||||
tx.query(
|
||||
`INSERT INTO atlas.store_secrets
|
||||
(workspace_id, store_id, kind, secret_ciphertext, secret_nonce,
|
||||
encryption_key_version, fingerprint, status)
|
||||
VALUES ($1, $2, 'seller_api_key', $3, $4, 'v1', $5, 'valid')`,
|
||||
[workspaceId, storeId, Buffer.alloc(32), Buffer.alloc(12), randomUUID()],
|
||||
),
|
||||
);
|
||||
const key = randomUUID();
|
||||
const post = (idempotencyKey: string) =>
|
||||
request(`/api/stores/${storeId}/platform-product-sync-runs`, {
|
||||
method: 'POST',
|
||||
headers: auth({ 'Idempotency-Key': idempotencyKey }),
|
||||
body: JSON.stringify({ reason: 'manual_refresh' }),
|
||||
});
|
||||
const created = await post(key);
|
||||
assert.equal(created.response.status, 202);
|
||||
assert.equal(created.body.data.sync_run.status, 'queued');
|
||||
const repeated = await post(key);
|
||||
assert.equal(repeated.response.status, 202);
|
||||
assert.equal(repeated.body.data.sync_run.id, created.body.data.sync_run.id);
|
||||
const concurrent = await post(randomUUID());
|
||||
assert.equal(concurrent.response.status, 409);
|
||||
assert.equal(concurrent.body.error.code, 'SYNC_ALREADY_RUNNING');
|
||||
const events = await database.workspaceTransaction(workspaceId, (tx) =>
|
||||
tx.query<{ count: string }>(
|
||||
`SELECT count(*)::text AS count FROM atlas.outbox_events
|
||||
WHERE aggregate_id = $1 AND event_type = 'platform_product_sync.requested'`,
|
||||
[created.body.data.sync_run.id],
|
||||
),
|
||||
);
|
||||
assert.equal(events.rows[0].count, '1');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,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',
|
||||
}));
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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]);
|
||||
});
|
||||
});
|
||||
@@ -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>Размер < 10<br>• Красный',
|
||||
'Общее описание<br><br>Характеристики:<br>• Один & два',
|
||||
]));
|
||||
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');
|
||||
});
|
||||
});
|
||||
@@ -231,6 +231,115 @@ describe('门店 HTTP 集成测试', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('同步刷新门店类目:等待 Ozon 返回并完整落库后才成功', async () => {
|
||||
const owner = await register('stores-category-refresh');
|
||||
const platforms = await request('/api/platforms', {
|
||||
headers: headers(owner),
|
||||
});
|
||||
const platform = success<Array<{ id: string; code: string }>>(
|
||||
platforms.body,
|
||||
).data.find((item) => item.code === 'OZON');
|
||||
assert.ok(platform);
|
||||
const store = await createStore(owner, platform.id, '类目刷新测试店');
|
||||
await database.workspaceTransaction(owner.workspace.id, async (tx) => {
|
||||
await tx.query(
|
||||
`UPDATE atlas.stores
|
||||
SET status = 'active', credential_status = 'valid'
|
||||
WHERE id = $1`,
|
||||
[store.id],
|
||||
);
|
||||
await tx.query(
|
||||
`UPDATE atlas.store_secrets SET status = 'valid'
|
||||
WHERE store_id = $1 AND kind = 'seller_api_key' AND revoked_at IS NULL`,
|
||||
[store.id],
|
||||
);
|
||||
await tx.query(
|
||||
`INSERT INTO atlas.store_product_categories
|
||||
(workspace_id, store_id, platform_id, external_category_id, external_type_id,
|
||||
name, path, synced_at)
|
||||
VALUES (current_setting('app.workspace_id')::uuid, $1, $2,
|
||||
'cached-category', 'cached-type', '已有缓存', '已有缓存', now())`,
|
||||
[store.id, platform.id],
|
||||
);
|
||||
});
|
||||
|
||||
const originalFetch = globalThis.fetch;
|
||||
let ozonCalls = 0;
|
||||
globalThis.fetch = async (input, init) => {
|
||||
if (String(input).startsWith('https://api-seller.ozon.ru/')) {
|
||||
ozonCalls += 1;
|
||||
assert.equal(init?.method, 'POST');
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
result: [
|
||||
{
|
||||
description_category_id: 17028750,
|
||||
category_name: '汽车脚垫和车座套',
|
||||
disabled: false,
|
||||
children: [
|
||||
{
|
||||
type_name: '汽车罩',
|
||||
type_id: 93587,
|
||||
disabled: false,
|
||||
children: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}),
|
||||
{ status: 200, headers: { 'Content-Type': 'application/json' } },
|
||||
);
|
||||
}
|
||||
return originalFetch(input, init);
|
||||
};
|
||||
try {
|
||||
const idempotencyKey = randomUUID();
|
||||
const first = await request(`/api/stores/${store.id}/category-tree-syncs`, {
|
||||
method: 'POST',
|
||||
headers: headers(owner, idempotencyKey),
|
||||
});
|
||||
assert.equal(first.response.status, 200);
|
||||
assert.deepEqual(
|
||||
success<{ category_count: number; active_category_count: number }>(first.body).data,
|
||||
{ category_count: 2, active_category_count: 1 },
|
||||
);
|
||||
|
||||
const replay = await request(`/api/stores/${store.id}/category-tree-syncs`, {
|
||||
method: 'POST',
|
||||
headers: headers(owner, idempotencyKey),
|
||||
});
|
||||
assert.equal(replay.response.status, 200);
|
||||
assert.deepEqual(replay.body, first.body);
|
||||
assert.equal(ozonCalls, 1);
|
||||
|
||||
await database.workspaceTransaction(owner.workspace.id, async (tx) => {
|
||||
const categories = await tx.query<{
|
||||
external_category_id: string;
|
||||
external_type_id: string;
|
||||
name: string;
|
||||
path: string;
|
||||
is_active: boolean;
|
||||
}>(
|
||||
`SELECT external_category_id, external_type_id, name, path, is_active
|
||||
FROM atlas.store_product_categories
|
||||
WHERE store_id = $1 ORDER BY path`,
|
||||
[store.id],
|
||||
);
|
||||
assert.deepEqual(categories.rows.filter((row) => row.is_active), [
|
||||
{
|
||||
external_category_id: '17028750',
|
||||
external_type_id: '93587',
|
||||
name: '汽车罩',
|
||||
path: '汽车脚垫和车座套 › 汽车罩',
|
||||
is_active: true,
|
||||
},
|
||||
]);
|
||||
});
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
it('更新版本、切换默认门店、删除和工作空间隔离都遵守契约', async () => {
|
||||
const owner = await register('stores-lifecycle');
|
||||
const otherOwner = await register('stores-isolation');
|
||||
|
||||
@@ -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']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,250 @@
|
||||
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';
|
||||
import { OutboxDispatcher } from '../../worker/src/outbox-dispatcher.js';
|
||||
import { flattenCategories } from '../../worker/src/ozon-catalog-sync.js';
|
||||
import { scheduleStoreCategoryTreeSync } from '../../worker/src/store-category-tree-scheduler.js';
|
||||
|
||||
let app: NestExpressApplication;
|
||||
let database: Database;
|
||||
let baseUrl = '';
|
||||
let workspaceId = '';
|
||||
let userId = '';
|
||||
let token = '';
|
||||
let platformId = '';
|
||||
let storeId = '';
|
||||
let secretId = '';
|
||||
|
||||
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 any };
|
||||
}
|
||||
|
||||
async function scheduleWithStoreLock() {
|
||||
return database.workspaceTransaction(workspaceId, async (transaction) => {
|
||||
await transaction.query(
|
||||
'SELECT id FROM atlas.stores WHERE id = $1 FOR UPDATE',
|
||||
[storeId],
|
||||
);
|
||||
return scheduleStoreCategoryTreeSync(transaction, {
|
||||
storeId,
|
||||
platformId,
|
||||
secretId,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
describe('门店类目树 Worker 调度集成测试', () => {
|
||||
it('类型叶子继承父类目 ID,并使用 type_name 作为可选项名称', () => {
|
||||
const records = flattenCategories([
|
||||
{
|
||||
description_category_id: 17027495,
|
||||
category_name: '汽车用品',
|
||||
disabled: false,
|
||||
children: [
|
||||
{
|
||||
description_category_id: 17028750,
|
||||
category_name: '汽车脚垫和车座套',
|
||||
disabled: false,
|
||||
children: [
|
||||
{
|
||||
type_name: '汽车罩',
|
||||
type_id: 93587,
|
||||
disabled: false,
|
||||
children: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
assert.deepEqual(
|
||||
records.map((record) => ({
|
||||
category: record.external_category_id,
|
||||
type: record.external_type_id,
|
||||
name: record.name,
|
||||
path: record.path,
|
||||
parent: record.parent_key,
|
||||
active: record.is_active,
|
||||
})),
|
||||
[
|
||||
{
|
||||
category: '17027495', type: '0', name: '汽车用品', path: '汽车用品',
|
||||
parent: null, active: false,
|
||||
},
|
||||
{
|
||||
category: '17028750', type: '0', name: '汽车脚垫和车座套',
|
||||
path: '汽车用品 › 汽车脚垫和车座套', parent: '17027495:0', active: false,
|
||||
},
|
||||
{
|
||||
category: '17028750', type: '93587', name: '汽车罩',
|
||||
path: '汽车用品 › 汽车脚垫和车座套 › 汽车罩', parent: '17028750:0', active: true,
|
||||
},
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
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 registration = await request('/api/auth/register', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
email: `catalog-worker-${randomUUID()}@example.test`,
|
||||
password: 'correct-horse-battery-staple-123',
|
||||
password_confirmation: 'correct-horse-battery-staple-123',
|
||||
}),
|
||||
});
|
||||
assert.equal(registration.response.status, 201);
|
||||
workspaceId = registration.body.data.workspace.id;
|
||||
userId = registration.body.data.user.id;
|
||||
token = registration.body.data.access_token;
|
||||
const platforms = await request('/api/platforms', {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
platformId = platforms.body.data[0].id;
|
||||
const seeded = await database.workspaceTransaction(workspaceId, async (tx) => {
|
||||
const 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 (current_setting('app.workspace_id')::uuid, $1, '类目 Worker 测试店',
|
||||
$2, 'RUB', true, 'active', 'valid')
|
||||
RETURNING id`,
|
||||
[platformId, `catalog-worker-${randomUUID()}`],
|
||||
);
|
||||
const secret = await tx.query<{ id: string }>(
|
||||
`INSERT INTO atlas.store_secrets
|
||||
(workspace_id, store_id, kind, secret_ciphertext, secret_nonce,
|
||||
encryption_key_version, fingerprint, status)
|
||||
VALUES (current_setting('app.workspace_id')::uuid, $1, 'seller_api_key',
|
||||
$2, $3, 'test', $4, 'valid')
|
||||
RETURNING id`,
|
||||
[
|
||||
store.rows[0].id,
|
||||
Buffer.from('catalog-worker-api-key-must-not-leak'),
|
||||
Buffer.from('catalog-worker-nonce'),
|
||||
randomUUID(),
|
||||
],
|
||||
);
|
||||
return { storeId: store.rows[0].id, secretId: secret.rows[0].id };
|
||||
});
|
||||
storeId = seeded.storeId;
|
||||
secretId = seeded.secretId;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await database.workspaceTransaction(workspaceId, async (tx) => {
|
||||
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.store_product_categories 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]);
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it('空缓存的并发验证只创建一个无密钥的树同步任务', async () => {
|
||||
const results = await Promise.all([scheduleWithStoreLock(), scheduleWithStoreLock()]);
|
||||
assert.equal(results.filter((result) => result.scheduled).length, 1);
|
||||
assert.ok(results.some((result) => !result.scheduled && result.reason === 'already_queued'));
|
||||
await database.workspaceTransaction(workspaceId, async (tx) => {
|
||||
const events = await tx.query<{ event_payload: unknown }>(
|
||||
`SELECT event_payload FROM atlas.outbox_events
|
||||
WHERE aggregate_id = $1 AND event_type = 'store_category_tree_sync.requested'`,
|
||||
[storeId],
|
||||
);
|
||||
assert.equal(events.rows.length, 1);
|
||||
const payload = JSON.stringify(events.rows[0].event_payload);
|
||||
assert.ok(!payload.includes('catalog-worker-api-key-must-not-leak'));
|
||||
assert.ok(!payload.includes('catalog-worker-nonce'));
|
||||
});
|
||||
});
|
||||
|
||||
it('已有已同步的门店缓存时跳过 Ozon 请求调度', async () => {
|
||||
await database.workspaceTransaction(workspaceId, async (tx) => {
|
||||
await tx.query(
|
||||
`UPDATE atlas.operations SET status = 'succeeded', completed_at = now()
|
||||
WHERE kind = 'store_category_tree_sync' AND resource_id = $1`,
|
||||
[storeId],
|
||||
);
|
||||
await tx.query(
|
||||
`INSERT INTO atlas.store_product_categories
|
||||
(workspace_id, store_id, platform_id, external_category_id, external_type_id,
|
||||
name, path, synced_at)
|
||||
VALUES (current_setting('app.workspace_id')::uuid, $1, $2,
|
||||
'worker-cache', 'worker-type', '已缓存类目', '已缓存类目', now())`,
|
||||
[storeId, platformId],
|
||||
);
|
||||
});
|
||||
const result = await scheduleWithStoreLock();
|
||||
assert.deepEqual(result, { scheduled: false, reason: 'cache_available' });
|
||||
});
|
||||
|
||||
it('为 Ozon 暂时失败配置五次指数退避', async () => {
|
||||
const enqueued: Array<{ name: string; options: Record<string, unknown> }> = [];
|
||||
const dispatcher = new OutboxDispatcher(
|
||||
{
|
||||
query: async () => ({ rows: [{ id: workspaceId }] }),
|
||||
workspaceTransaction: async (_workspaceId: string, work: any) =>
|
||||
work({
|
||||
query: async (sql: string) =>
|
||||
sql.includes('RETURNING event.id')
|
||||
? {
|
||||
rows: [
|
||||
{
|
||||
id: randomUUID(),
|
||||
workspace_id: workspaceId,
|
||||
event_type: 'store_category_tree_sync.requested',
|
||||
event_payload: {
|
||||
store_id: storeId,
|
||||
secret_id: secretId,
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
: { rows: [] },
|
||||
}),
|
||||
} as never,
|
||||
{
|
||||
add: async (name: string, _data: unknown, options: Record<string, unknown>) => {
|
||||
enqueued.push({ name, options });
|
||||
},
|
||||
} as never,
|
||||
'worker-test',
|
||||
);
|
||||
await dispatcher.dispatchBatch();
|
||||
assert.deepEqual(enqueued, [
|
||||
{
|
||||
name: 'store_category_tree_sync.requested',
|
||||
options: {
|
||||
jobId: enqueued[0]?.options.jobId,
|
||||
attempts: 5,
|
||||
backoff: { type: 'exponential', delay: 5_000 },
|
||||
removeOnComplete: 1_000,
|
||||
removeOnFail: 1_000,
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": ".",
|
||||
"rootDir": "..",
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["src/**/*.ts", "test/**/*.ts", "vitest.config.ts"]
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
import { getSession, login, openErp, submit } from '../lib/api.js';
|
||||
import { backgroundError } from '../lib/request-errors.js';
|
||||
export default defineBackground(() => {
|
||||
browser.runtime.onMessage.addListener(async (message: { type?: string; payload?: unknown; path?: string }) => {
|
||||
try {
|
||||
if (message?.type === 'atlas.session') return { session: await getSession() };
|
||||
if (message?.type === 'atlas.login') return { session: await login() };
|
||||
if (message?.type === 'atlas.collect') return { result: await submit(message.payload as import('@atlas/contracts').CollectedProductPayload) };
|
||||
if (message?.type === 'atlas.open') { await openErp(message.path); return { ok: true }; }
|
||||
return undefined;
|
||||
} catch (error) {
|
||||
return backgroundError(error);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,6 +4,8 @@ import {
|
||||
} from '../lib/source-parsers.js';
|
||||
import type { OzonPageJsonFetcher } from '../lib/ozon-parser.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({
|
||||
matches: [
|
||||
@@ -19,6 +21,7 @@ export default defineContentScript({
|
||||
let mount: HTMLDivElement | null = null;
|
||||
let shadow: ShadowRoot | null = null;
|
||||
let observedUrl = location.href;
|
||||
let minimized = false;
|
||||
|
||||
const render = async () => {
|
||||
if (!supportedProductPlatform()) {
|
||||
@@ -35,21 +38,17 @@ export default defineContentScript({
|
||||
shadow = mount.attachShadow({ mode: 'open' });
|
||||
}
|
||||
const root = shadow!;
|
||||
const session = await browser.runtime
|
||||
.sendMessage({ type: 'atlas.session' })
|
||||
const session = await sendBackgroundMessage<{ session?: unknown }>({ type: 'atlas.session' })
|
||||
.then((value: { session?: unknown }) => value.session)
|
||||
.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>`;
|
||||
root.querySelector('.dot')?.addEventListener('click', () => {
|
||||
mount?.remove();
|
||||
mount = null;
|
||||
shadow = null;
|
||||
});
|
||||
root.querySelectorAll<HTMLButtonElement>('[data-action]').forEach(
|
||||
(button) =>
|
||||
button.addEventListener('click', () =>
|
||||
void action(button.dataset.action ?? '', root),
|
||||
),
|
||||
renderCollectorPanel(
|
||||
root,
|
||||
Boolean(session),
|
||||
minimized,
|
||||
(next) => {
|
||||
minimized = next;
|
||||
},
|
||||
(kind) => void action(kind, root),
|
||||
);
|
||||
};
|
||||
|
||||
@@ -58,12 +57,12 @@ export default defineContentScript({
|
||||
try {
|
||||
if (kind === 'login') {
|
||||
hint.textContent = '正在登录…';
|
||||
await browser.runtime.sendMessage({ type: 'atlas.login' });
|
||||
await sendBackgroundMessage({ type: 'atlas.login' });
|
||||
await render();
|
||||
return;
|
||||
}
|
||||
if (kind === 'erp') {
|
||||
await browser.runtime.sendMessage({ type: 'atlas.open', path: '/' });
|
||||
await sendBackgroundMessage({ type: 'atlas.open', path: '/' });
|
||||
return;
|
||||
}
|
||||
hint.textContent = '正在采集多 SKU…';
|
||||
@@ -77,18 +76,19 @@ export default defineContentScript({
|
||||
? requestMarketplacePageState
|
||||
: 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',
|
||||
payload,
|
||||
});
|
||||
if (kind === 'edit') {
|
||||
await browser.runtime.sendMessage({
|
||||
await sendBackgroundMessage({
|
||||
type: 'atlas.open',
|
||||
path: response.result.data.editor_entry_path,
|
||||
});
|
||||
}
|
||||
hint.textContent =
|
||||
kind === 'edit' ? '已打开采集数据测试页' : '已进入采集箱';
|
||||
`${kind === 'edit' ? '已打开商品编辑页' : '已进入采集箱'}(${payload.variants.length || 1} 个 SKU)`;
|
||||
} catch (error) {
|
||||
hint.textContent =
|
||||
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 }) =>
|
||||
new Promise((resolve, reject) => {
|
||||
const requestId = crypto.randomUUID();
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
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 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 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}` } : {}) } });
|
||||
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 }>;
|
||||
}
|
||||
async function activeAccessToken() {
|
||||
@@ -43,7 +48,16 @@ export async function login() {
|
||||
}
|
||||
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() }); }
|
||||
async function readWebAccessToken(tabId: number) {
|
||||
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(';')})`);
|
||||
}
|
||||
@@ -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 ?? '')),
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user