34 Commits

Author SHA1 Message Date
joey5628 06dbd817f0 feat(edit): 优化图片导入ozon问题 2026-10-07 21:49:28 +08:00
joey5628 79b310efe7 feat(edit): 编辑上报细节优化 2026-10-06 18:30:05 +08:00
joey5628 e20cd7547f feat(edit): AI套图生成、图片切七牛 2026-10-04 20:47:47 +08:00
R524809 696112e74d docs: 域名、证书配置文档 2026-09-30 17:48:03 +08:00
R524809 45871584b3 feat(edit): 编辑页上报ozon 开发 2026-09-30 15:57:54 +08:00
R524809 0a303414d8 feat(edit): 定价试算开发、SKU 样式功能修改 2026-09-29 17:50:19 +08:00
R524809 84b230e69a feat(pricing): 开发定价方案 2026-09-28 17:34:33 +08:00
joey5628 a29dd92676 feat(ai-image): AI生图开发 2026-09-27 22:40:52 +08:00
R524809 e670d06ca6 feat: 完成 SKU 图片选择及素材导入后端 2026-09-24 17:46:34 +08:00
R524809 d24ce8f4e0 feat: 编辑页 SKU 图标默认值优化 2026-09-23 17:39:05 +08:00
R524809 b3958166dd feat(ai-edit): 开发 AI 填报功能 2026-09-23 17:18:09 +08:00
R524809 95a2a37f1b feat(edit|extension): 编辑页类目采集开发 2026-09-22 13:47:24 +08:00
R524809 2b06ef0692 feat(edit): 采集插件、编辑页SKU 属性动态展示、颜色展示开发 2026-09-22 10:11:57 +08:00
R524809 05747d6f22 feat(edit): 商品编辑页优化 2026-09-21 17:44:50 +08:00
R524809 75fa2b60f3 feat(edit): 编辑页样式修改 2026-09-20 17:44:40 +08:00
R524809 e0e07be4ea docs: 更新文档 2026-09-20 15:23:44 +08:00
R524809 880e441bc6 feat(category): 类目树、类目属性、属性字典维护系统构建完成 2026-09-20 15:11:32 +08:00
R524809 388429dbd0 feat(category): 类目树、属性、属性字典维护,新增系统管理员管理类目数据 2026-09-18 18:55:16 +08:00
R524809 fcddb11da0 feat(edit): 编辑页 SKU 展示样式修改 2026-09-18 14:39:40 +08:00
R524809 df3fb0c754 feat(edit): 编辑页样式修改 2026-09-18 13:38:00 +08:00
R524809 5684cbd517 feat(edit): 编辑页样式优化 2026-09-17 17:47:18 +08:00
R524809 52ef75f719 feat:编辑页响应式样式修改 2026-09-16 17:21:33 +08:00
R524809 a66b07652b Merge branch 'zcode/frontend' into feat-0.2.0 2026-09-15 18:56:22 +08:00
R524809 7f68aad9f3 feat(edit): 服务端代码拆分、优化 2026-09-15 18:56:13 +08:00
R524809 3156a7516f feat(edit): 编辑页面前端样式修改 2026-09-15 18:55:46 +08:00
R524809 da7342b466 feat(edit): 编辑页开发 2026-09-11 17:28:58 +08:00
R524809 1b03ea5871 feat(edit): 页面表单高度优化 2026-09-11 10:38:46 +08:00
R524809 2c4e94ab3d fix(edit): 类目问题修改 2026-09-11 10:36:25 +08:00
joey5628 05a00d46b9 feat(edit): 编辑页类目树逻辑开发 2026-09-10 22:26:52 +08:00
R524809 946af649dd feat(edit): 商品编辑页开发 2026-09-10 17:45:46 +08:00
R524809 130a005231 feat(product-edit):商品编辑页初版 2026-09-09 15:11:58 +08:00
R524809 319887726e Update seller store experience 2026-09-08 16:19:38 +08:00
R524809 ceb644a3f9 Expand seller store functionality 2026-09-07 17:51:11 +08:00
joey5628 4c0f3a4a79 feat(extension): 采集插件功能开发 2026-09-06 22:32:39 +08:00
377 changed files with 152671 additions and 1823 deletions
+23
View File
@@ -18,6 +18,7 @@ MIGRATOR_DATABASE_URL=postgresql://atlas_migrator:change-me-local-only@localhost
NODE_ENV=development
# Browser origin allowed to send credentialed requests to this API.
WEB_ORIGIN=http://localhost:8100
API_PUBLIC_ORIGIN=http://localhost:8200
# HTTP listener port for the NestJS API.
API_PORT=8200
@@ -83,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=
+18
View File
@@ -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. 代码注释
- 新增或修改的代码注释必须使用中文;注释应说明职责、边界或不易从代码直接看出的业务约束,避免逐行复述实现。
+38
View File
@@ -0,0 +1,38 @@
SELECT
u.id,
u.email,
u.display_name,
u.status,
u.created_at,
u.last_login_at,
w.id AS workspace_id,
w.name AS workspace_name,
m.role AS workspace_role
FROM atlas.users u
LEFT JOIN atlas.workspace_memberships m ON m.user_id = u.id
LEFT JOIN atlas.workspaces w ON w.id = m.workspace_id
ORDER BY u.created_at DESC;
SELECT code, name, capabilities
FROM atlas.platforms
WHERE code = 'OZON';
BEGIN;
SET search_path TO atlas, public;
INSERT INTO platform_currency_support (platform_id, currency_code)
SELECT id, 'CNY'::CHAR(3)
FROM platforms
WHERE code = 'OZON'
ON CONFLICT (platform_id, currency_code) DO UPDATE
SET is_enabled = true;
UPDATE platforms
SET capabilities = capabilities || jsonb_build_object(
'default_currency_code', 'CNY'
),
updated_at = now()
WHERE code = 'OZON';
COMMIT;
+3
View File
@@ -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"
+10
View File
@@ -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';
@@ -10,6 +11,10 @@ import { StoresModule } from './stores/stores.module.js';
import { WatermarksModule } from './watermarks/watermarks.module.js';
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 {
@@ -47,6 +52,11 @@ class HealthController {
WatermarksModule,
MediaModule,
CollectionModule,
ExtensionModule,
ProductEditorModule,
PlatformCatalogAdminModule,
OnlineProductsModule,
PricingModule,
],
controllers: [HealthController],
})
+19
View File
@@ -51,6 +51,8 @@ export type AccessTokenClaims = {
exp: number;
iat: number;
};
export type ExtensionAccessTokenClaims = AccessTokenClaims & { typ: 'extension'; wid: string };
export function signAccessToken(
claims: AccessTokenClaims,
secret: string,
@@ -92,3 +94,20 @@ export function verifyAccessToken(
return null;
}
}
/** Extension tokens have an explicit audience so they can never be accepted by
* the normal Web/API guard, even though both are signed by the same key. */
export function signExtensionAccessToken(
claims: ExtensionAccessTokenClaims,
secret: string,
): string {
const header = Buffer.from(JSON.stringify({ alg: 'HS256', typ: 'JWT' })).toString('base64url');
const payload = Buffer.from(JSON.stringify(claims)).toString('base64url');
const signature = createHmac('sha256', secret).update(`${header}.${payload}`).digest('base64url');
return `${header}.${payload}.${signature}`;
}
export function verifyExtensionAccessToken(token: string, secret: string): ExtensionAccessTokenClaims | null {
const claims = verifyAccessToken(token, secret) as Partial<ExtensionAccessTokenClaims> | null;
return claims?.typ === 'extension' ? claims as ExtensionAccessTokenClaims : null;
}
+1
View File
@@ -16,5 +16,6 @@ import { IdempotencyService } from '../common/idempotency.service.js';
{ provide: APP_GUARD, useExisting: AccessTokenGuard },
{ provide: APP_GUARD, useExisting: WorkspaceContextGuard },
],
exports: [AuthService, IdempotencyService],
})
export class AuthModule {}
+20
View File
@@ -176,6 +176,26 @@ export class AuthService {
});
}
/** Resolves an existing Web refresh cookie without rotating it. This is used
* only to complete an interactive extension authorization request in the
* user's browser; the cookie itself is never returned to the extension. */
public async resolveWebRefreshSession(refreshToken: string | undefined) {
if (!refreshToken) return null;
return this.database.transaction(async (transaction) => {
const sessions = await transaction.query<UserRow & { session_id: string }>(
`SELECT u.id, u.email, u.display_name, u.avatar_asset_id, u.email_verified_at, u.created_at,
u.revision, u.status, u.password_hash, s.id AS session_id
FROM atlas.user_sessions s JOIN atlas.users u ON u.id = s.user_id
WHERE s.refresh_token_hash = $1 AND s.revoked_at IS NULL AND s.expires_at > now()`,
[tokenHash(refreshToken)],
);
const user = sessions.rows[0];
if (!user || user.status !== 'active') return null;
const workspace = await findWorkspaceInTransaction(transaction, user.id);
return { userId: user.id, workspace };
});
}
public async logout(sessionId: string) {
await this.database.query(
'UPDATE atlas.user_sessions SET revoked_at = now() WHERE id = $1 AND revoked_at IS NULL',
+218 -26
View File
@@ -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, 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,28 +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));
}
@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;
}
@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(':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;
}
/** 注入采集箱接口共用的应用服务。 */
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),
);
}
/** 返回采集记录、解析证据、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),
),
};
}
/** 幂等地创建手工采集草稿及其默认商品和 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;
}
/** 通过 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('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;
}
/** 将当前采集快照中失败或取消的素材任务重新投递给 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;
}
/** 将当前采集快照中用户选定的来源素材请求转存为 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;
}
}
/** 使用契约 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]);
}
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]); }
+4 -1
View File
@@ -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 {}
+533 -43
View File
@@ -1,57 +1,547 @@
import { randomUUID } from 'node:crypto';
import { Inject, Injectable } from '@nestjs/common';
import type { CreateCollectionItemRequest, ListCollectionItemsQuery, 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) {
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}
/** 注入工作空间事务数据库与关键写操作的幂等边界。 */
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}
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 } };
});
}
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: `/collection-items/${item.rows[0].id}/edit` } } };
});
}
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 this.versionConflict(tx, 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(`UPDATE atlas.collection_asset_transfers SET status = 'queued', error_payload = NULL, updated_at = now() WHERE collection_item_id = $1 AND id = ANY($2::uuid[]) AND status IN ('failed', 'cancelled')`, [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 }); 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]); }
private async versionConflict(tx: DatabaseTransaction, id: string, expected: number) { const row = await tx.query<{ revision: number }>('SELECT revision FROM atlas.collection_items WHERE id = $1 AND deleted_at IS NULL', [id]); if (!row.rows[0]) throw new ApiError(404, 'RESOURCE_NOT_FOUND', '采集记录不存在'); if (row.rows[0].revision !== expected) throw new ApiError(409, 'VERSION_CONFLICT', '采集记录已被更新', [{ field: 'If-Match', code: 'VERSION_CONFLICT', message: `当前版本为 ${row.rows[0].revision}`, current_revision: row.rows[0].revision }]); }
${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,
},
};
});
}
/** 原子创建手工采集记录、商品和默认 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 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 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 } } };
},
);
}
/** 将用户选中的候选来源素材转为可供编辑和发布的 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`;
/** 采集箱列表和单项读取共用的聚合查询,素材状态只取当前来源快照。 */
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', '分页游标无效');
}
}
+50
View File
@@ -0,0 +1,50 @@
import type { DatabaseTransaction } from '@atlas/database';
import { ApiError } from './api-error.js';
/**
* Shared optimistic-lock conflict response. Versioned writes still keep their
* domain-specific SET clauses, but they must use a conditional `revision =
* expected` update and this helper when no row was affected.
*/
const resources = {
collection_item: {
table: 'atlas.collection_items',
label: '采集记录',
active: ' AND deleted_at IS NULL',
},
product: { table: 'atlas.products', label: '商品', active: '' },
product_platform_profile: {
table: 'atlas.product_platform_profiles',
label: '商品发布配置',
active: '',
},
} as const;
export async function throwRevisionConflict(
transaction: DatabaseTransaction,
resource: keyof typeof resources,
id: string,
expected: number,
): Promise<never> {
const target = resources[resource];
const current = await transaction.query<{ revision: number }>(
`SELECT revision FROM ${target.table} WHERE id = $1${target.active}`,
[id],
);
if (!current.rows[0])
throw new ApiError(404, 'RESOURCE_NOT_FOUND', `${target.label}不存在`);
if (current.rows[0].revision !== expected) {
throw new ApiError(409, 'VERSION_CONFLICT', `${target.label}已被更新`, [
{
field: 'If-Match',
code: 'VERSION_CONFLICT',
message: `当前版本为 ${current.rows[0].revision}`,
current_revision: current.rows[0].revision,
},
]);
}
// A conditional write with a matching version affecting no rows is a
// resource-state error (for example, a concurrent soft delete), not a
// successful retry.
throw new ApiError(409, 'VERSION_CONFLICT', `${target.label}状态已变化`);
}
+53 -1
View File
@@ -24,15 +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),
@@ -95,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(
@@ -128,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。
@@ -137,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,
),
};
}
@@ -0,0 +1,72 @@
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';
import { DATABASE } from '../database/database.module.js';
export type ExtensionAuthenticatedRequest = {
header: (name: string) => string | undefined;
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 async canActivate(context: ExecutionContext) {
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'),
);
} catch (error) {
// AuthService 的通用认证守卫会抛 Nest UnauthorizedException;扩展接口
// 应返回稳定、可操作的错误信息,不能在 UI 中退化成“请求无法处理”。
if (error instanceof UnauthorizedException)
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
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',
};
return true;
}
}
@@ -0,0 +1,90 @@
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 { 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;
}
}
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,
})),
);
}
@@ -0,0 +1,11 @@
import { Module } from '@nestjs/common';
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],
})
export class ExtensionModule {}
+508
View File
@@ -0,0 +1,508 @@
import { Inject, Injectable } from '@nestjs/common';
import type { CollectedProductPayload } 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';
@Injectable()
export class ExtensionService {
public constructor(
@Inject(DATABASE) private readonly database: Database,
@Inject(IdempotencyService)
private readonly idempotency: IdempotencyService,
) {}
public async ingest(
workspaceId: string,
userId: string,
key: string,
payload: CollectedProductPayload,
) {
return this.idempotency.run(
{
workspaceId,
userId,
method: 'POST',
path: '/api/extension/materials',
key,
request: payload,
},
async (tx) => {
const platform = await tx.query<{ id: string; code: string }>(
`SELECT id, code FROM atlas.platforms
WHERE code = $1 AND status = 'active' AND coalesce((capabilities->>'can_be_source')::boolean, false)`,
[payload.source.platform.toUpperCase()],
);
if (!platform.rows[0])
throw new ApiError(
422,
'SOURCE_PLATFORM_UNAVAILABLE',
'该来源平台当前不可采集',
);
const existing = await this.findExistingSourceItem(
tx,
workspaceId,
platform.rows[0].id,
payload,
);
if (existing) {
await this.replaceSourceSnapshot(
tx,
workspaceId,
existing.id,
existing.product_id,
payload,
);
return {
status: 200,
body: {
data: await this.materialResponse(
tx,
existing.id,
existing.product_id,
true,
true,
),
},
};
}
const { status, sourcePrice } = sourceState(payload);
const item = await tx.query<{ id: string }>(
`INSERT INTO atlas.collection_items
(workspace_id, source_kind, source_platform_id, source_item_key, source_url, source_title, source_currency_code, source_min_price, source_max_price, status, raw_evidence, normalized_payload, parser_version, collected_at)
VALUES (current_setting('app.workspace_id')::uuid, 'platform', $1, $2, $3, $4, $5, $6, $6, $7::atlas.collection_item_status, $8, $9, $10, $11) RETURNING id`,
[
platform.rows[0].id,
payload.source.itemKey,
payload.source.canonicalUrl,
payload.common.title?.value ?? null,
sourcePrice?.currencyCode ?? null,
sourcePrice?.amount ?? null,
status,
JSON.stringify(payload.evidence),
JSON.stringify(payload),
payload.source.parserVersion,
payload.source.collectedAt,
],
);
const product = await tx.query<{ id: string }>(
`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,
workspaceId,
item.rows[0].id,
product.rows[0].id,
1,
payload,
false,
);
return {
status: 201,
body: {
data: await this.materialResponse(
tx,
item.rows[0].id,
product.rows[0].id,
false,
false,
),
},
};
},
);
}
/**
* Ozon has no public, stable SPU ID. The entered URL SKU remains the source
* identity for audit purposes, while a re-collection from any sibling URL
* reuses the same item when its real source SKU set intersects this one.
*/
private async findExistingSourceItem(
tx: { query: Database['query'] },
workspaceId: string,
platformId: string,
payload: CollectedProductPayload,
): Promise<{ id: string; product_id: string } | null> {
const bySourceKey = await tx.query<{ id: string; product_id: string }>(
`SELECT c.id, p.id AS product_id
FROM atlas.collection_items c
JOIN atlas.products p ON p.collection_item_id = c.id
WHERE c.source_platform_id = $1
AND c.source_item_key = $2
AND c.deleted_at IS NULL`,
[platformId, payload.source.itemKey],
);
if (bySourceKey.rows[0]) return bySourceKey.rows[0];
if (payload.source.platform !== 'ozon') return null;
const sourceSkus = [
...new Set(
payload.variants
.map(
(variant) =>
variant.sourceSku ?? variant.sourceVariantKey,
)
.filter((sku) => sku.trim().length > 0),
),
];
if (!sourceSkus.length) return null;
// Different sibling URLs have different source_item_key values. Locking
// each real SKU in sorted order makes two concurrent first collections
// of the same public variant group observe one another's snapshot.
for (const sourceSku of sourceSkus.sort()) {
await tx.query(
`SELECT pg_advisory_xact_lock(hashtextextended($1::text, 0))`,
[`${workspaceId}:${platformId}:${sourceSku}`],
);
}
const byVariantOverlap = await tx.query<{
id: string;
product_id: string;
}>(
`SELECT DISTINCT c.id, p.id AS product_id
FROM atlas.collection_items c
JOIN atlas.products p ON p.collection_item_id = c.id
JOIN atlas.product_variants v ON v.product_id = p.id
WHERE c.source_platform_id = $1
AND c.deleted_at IS NULL
AND v.deleted_at IS NULL
AND coalesce(v.source_sku, v.source_variant_key) = ANY($2::text[])
LIMIT 2`,
[platformId, sourceSkus],
);
if (byVariantOverlap.rows.length > 1) {
throw new ApiError(
409,
'SOURCE_VARIANT_GROUP_AMBIGUOUS',
'发现多条历史采集记录包含相同 Ozon SKU,请先在采集箱处理重复记录',
);
}
return byVariantOverlap.rows[0] ?? null;
}
private async replaceSourceSnapshot(
tx: { query: Database['query'] },
workspaceId: string,
collectionItemId: string,
productId: string,
payload: CollectedProductPayload,
) {
const { status, sourcePrice } = sourceState(payload);
// Once a platform profile exists, product_variants are no longer a
// disposable collection projection: they may carry user edits and are
// referenced by platform listings. A re-collection refreshes the
// source evidence/candidates, but must not overwrite that draft or
// sever a SKU's publishing history.
const profileCount = await tx.query<{ count: string }>(
`SELECT count(*)::text AS count
FROM atlas.product_platform_profiles
WHERE product_id = $1`,
[productId],
);
const preserveEditorDraft =
Number(profileCount.rows[0]?.count ?? 0) > 0;
// 不删除旧候选/任务记录:运行中的 Worker 可能已拿到它。取消并保留审计记录,
// 新快照在查询和 Worker 中均按 source_snapshot_revision 隔离。
await tx.query(
`UPDATE atlas.collection_asset_transfers SET status = 'cancelled', completed_at = now(), updated_at = now()
WHERE collection_item_id = $1 AND status IN ('pending_selection', 'queued', 'running')`,
[collectionItemId],
);
const item = await tx.query<{
source_snapshot_revision: string | number;
}>(
`UPDATE atlas.collection_items
SET source_url = $1, source_title = $2, source_currency_code = $3, source_min_price = $4, source_max_price = $4,
status = $5::atlas.collection_item_status, raw_evidence = $6, normalized_payload = $7, parser_version = $8,
collected_at = $9, source_snapshot_revision = source_snapshot_revision + 1, revision = revision + 1, updated_at = now()
WHERE id = $10 AND deleted_at IS NULL RETURNING source_snapshot_revision`,
[
payload.source.canonicalUrl,
payload.common.title?.value ?? null,
sourcePrice?.currencyCode ?? null,
sourcePrice?.amount ?? null,
status,
JSON.stringify(payload.evidence),
JSON.stringify(payload),
payload.source.parserVersion,
payload.source.collectedAt,
collectionItemId,
],
);
const snapshotRevision = Number(item.rows[0]?.source_snapshot_revision);
if (!Number.isSafeInteger(snapshotRevision) || snapshotRevision < 1)
throw new Error('采集来源快照版本更新失败');
if (!preserveEditorDraft) {
await tx.query(
`UPDATE atlas.products
SET source_title = $1, updated_at = now(), revision = revision + 1
WHERE id = $2`,
[payload.common.title?.value ?? null, productId],
);
}
await this.writeSourceSnapshotRows(
tx,
workspaceId,
collectionItemId,
productId,
snapshotRevision,
payload,
preserveEditorDraft,
);
return snapshotRevision;
}
private async writeSourceSnapshotRows(
tx: { query: Database['query'] },
workspaceId: string,
collectionItemId: string,
productId: string,
snapshotRevision: number,
payload: CollectedProductPayload,
preserveEditorDraft: boolean,
) {
const verifiedVariants = payload.variants.filter(
(variant) => variant.sourceVariantKey.trim().length > 0,
);
const variantRows = verifiedVariants.length
? verifiedVariants
: [
{
sourceVariantKey: null,
sourceSku: undefined,
displayName: '默认 SKU',
},
];
const sourceKeys = variantRows
.map((variant) => variant.sourceVariantKey)
.filter((key): key is string => !!key);
const existing = await tx.query<{
id: string;
source_variant_key: string | null;
deleted_at: Date | string | null;
}>(
`SELECT id, source_variant_key, deleted_at
FROM atlas.product_variants
WHERE product_id = $1
AND (source_variant_key = ANY($2::text[]) OR source_variant_key IS NULL)
ORDER BY sort_order, id`,
[productId, sourceKeys],
);
const existingBySourceKey = new Map(
existing.rows
.filter((variant) => variant.source_variant_key)
.map((variant) => [variant.source_variant_key!, variant]),
);
// A capture without a stable source key has exactly one synthetic
// default SKU. Reuse an existing unkeyed row when possible; this keeps
// its platform mapping rather than attempting a duplicate insert.
const existingDefault = existing.rows.find(
(variant) => variant.source_variant_key === null,
);
const variants = new Map<string, string>();
const retainedVariantIds: string[] = [];
for (let index = 0; index < variantRows.length; index += 1) {
const variant = variantRows[index];
const matched = variant.sourceVariantKey
? existingBySourceKey.get(variant.sourceVariantKey)
: existingDefault;
let variantId: string;
if (matched) {
variantId = matched.id;
// A user may have deliberately removed an Atlas SKU after
// editing began. Keep that decision; its source evidence is
// still present in normalized_payload, but it is not silently
// revived into the editable/publishable SKU set.
if (!preserveEditorDraft) {
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 = $5`,
[
variant.sourceSku ?? null,
variant.displayName,
index,
sourcePurchaseCost(payload.source.platform, variant),
variantId,
],
);
}
} 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, purchase_cost_cny)
VALUES ($1, $2, $3, $4, $5, $6, ROUND($7::numeric, 2)) RETURNING id`,
[
workspaceId,
productId,
variant.sourceVariantKey,
variant.sourceSku ?? null,
variant.displayName,
index,
sourcePurchaseCost(payload.source.platform, variant),
],
);
variantId = inserted.rows[0].id;
await this.ensureProfileListingsForVariant(
tx,
productId,
variantId,
);
}
retainedVariantIds.push(variantId);
if (variant.sourceVariantKey)
variants.set(variant.sourceVariantKey, variantId);
}
// Before an editor profile exists, variants are still just the latest
// source projection, so a missing source SKU can be safely hidden. Do
// not physically delete it: the partial source-key unique index does
// not exclude soft-deleted rows, and a later refresh may legitimately
// rediscover the same stable key. After editing begins, omission from a
// source refresh must never act as a user-requested SKU deletion.
if (!preserveEditorDraft) {
await tx.query(
`UPDATE atlas.product_variants
SET deleted_at = now(), updated_at = now()
WHERE product_id = $1 AND deleted_at IS NULL
AND NOT (id = ANY($2::uuid[]))`,
[productId, retainedVariantIds],
);
}
for (const asset of payload.assets) {
await tx.query(
`INSERT INTO atlas.collection_asset_transfers (workspace_id, collection_item_id, product_id, source_variant_id, media_type, poster_url, role, source_url, status, error_payload, source_snapshot_revision)
VALUES ($1, $2, $3, $4, $5::atlas.collection_media_type, $6, $7::atlas.media_candidate_role, $8, 'pending_selection', $9, $10)`,
[
workspaceId,
collectionItemId,
productId,
asset.variantSourceKey
? (variants.get(asset.variantSourceKey) ?? null)
: null,
asset.mediaType,
asset.posterUrl ?? null,
asset.role,
asset.url,
JSON.stringify({
relation: asset.relation,
referer_origin: asset.refererOrigin ?? null,
evidence_ref: asset.evidenceRef ?? null,
sort_order: asset.sortOrder,
}),
snapshotRevision,
],
);
}
// Collecting records source-media candidates only. A later explicit
// selection queues the media and creates the materialisation event.
await tx.query(
`UPDATE atlas.collection_items SET status = CASE WHEN status = 'needs_review' THEN status ELSE 'ready_for_edit' END, updated_at = now() WHERE id = $1 AND source_snapshot_revision = $2`,
[collectionItemId, snapshotRevision],
);
}
private async ensureProfileListingsForVariant(
tx: { query: Database['query'] },
productId: string,
variantId: string,
) {
// A source refresh may discover a new real SKU after a user has
// already entered one or more platform editors. Every existing profile
// needs a fresh, explicitly not-listed mapping for that SKU; imported
// mappings for other SKU rows are untouched.
await tx.query(
`INSERT INTO atlas.product_variant_platform_listings
(workspace_id, product_id, profile_id, platform_id, product_variant_id, store_id, currency_code)
SELECT current_setting('app.workspace_id')::uuid, $1, p.id, p.platform_id,
$2, p.target_store_id, s.currency_code
FROM atlas.product_platform_profiles p
JOIN atlas.stores s ON s.id = p.target_store_id
WHERE p.product_id = $1
ON CONFLICT (workspace_id, profile_id, product_variant_id) DO NOTHING`,
[productId, variantId],
);
}
private async materialResponse(
tx: { query: Database['query'] },
collectionItemId: string,
productId: string,
reused: boolean,
refreshed: boolean,
) {
const count = await tx.query<{
total: string;
pending_selection: string;
queued: string;
succeeded: string;
failed: string;
}>(
`SELECT count(*)::text AS total, count(*) FILTER (WHERE t.status = 'pending_selection')::text AS pending_selection, count(*) FILTER (WHERE t.status = 'queued')::text AS queued, count(*) FILTER (WHERE t.status = 'succeeded')::text AS succeeded, count(*) FILTER (WHERE t.status = 'failed')::text AS failed FROM atlas.collection_asset_transfers t JOIN atlas.collection_items c ON c.id = t.collection_item_id WHERE t.collection_item_id = $1 AND t.source_snapshot_revision = c.source_snapshot_revision`,
[collectionItemId],
);
const status = await tx.query<{
status: string;
source_title: string | null;
variant_count: string;
}>(
`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: Number(status.rows[0]?.variant_count ?? 0),
},
product_id: productId,
reused,
refreshed,
editor_entry_path: `/products/${productId}/editor?collection_item_id=${collectionItemId}`,
materialization: {
total: Number(count.rows[0]?.total ?? 0),
pending_selection: Number(
count.rows[0]?.pending_selection ?? 0,
),
queued: Number(count.rows[0]?.queued ?? 0),
succeeded: Number(count.rows[0]?.succeeded ?? 0),
failed: Number(count.rows[0]?.failed ?? 0),
},
};
}
}
function sourceState(payload: CollectedProductPayload) {
const verifiedVariants = payload.variants.filter(
(variant) => variant.sourceVariantKey.trim().length > 0,
);
return {
status:
verifiedVariants.length || !payload.variantAxes.length
? 'ready_for_edit'
: 'needs_review',
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;
}
+14 -1
View File
@@ -35,7 +35,15 @@ export async function createApplication() {
}
app.enableCors({
origin: config.WEB_ORIGIN,
origin: (origin, callback) => {
// Extension API calls are bearer-token authenticated. Allow only Chrome
// extension origins in addition to the configured Web application.
if (!origin || origin === config.WEB_ORIGIN || /^chrome-extension:\/\/[a-p]{32}$/.test(origin)) {
callback(null, true);
return;
}
callback(null, false);
},
credentials: true,
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
allowedHeaders: [
@@ -50,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,
};
}
}
+441
View File
@@ -0,0 +1,441 @@
import { randomUUID } from 'node:crypto';
import { Inject, Injectable } from '@nestjs/common';
import {
createImageSuiteSchema,
imageSuitePlanErrorMessages,
createMediaAiEditRequestSchema,
type CreateImageSuiteRequest,
type ImageSuite,
type ImageSuiteInput,
type ImageSuitePlanInput,
type ImageSuiteShot,
type ListProductImageTasksQuery,
} from '@atlas/contracts';
import type { Database, DatabaseTransaction } from '@atlas/database';
import { ApiError } from '../common/api-error.js';
import { IdempotencyService } from '../common/idempotency.service.js';
import { DATABASE } from '../database/database.module.js';
import { MediaAiEditService } from './media-ai-edit.service.js';
type SuiteRow = Omit<
ImageSuite,
'items' | 'operation' | 'created_at' | 'completed_at'
> & { created_at: Date | string; completed_at: Date | string | null };
const iso = (value: Date | string) =>
value instanceof Date ? value.toISOString() : value;
@Injectable()
export class ImageSuiteService {
public constructor(
@Inject(DATABASE) private readonly database: Database,
@Inject(IdempotencyService)
private readonly idempotency: IdempotencyService,
@Inject(MediaAiEditService) private readonly edits: MediaAiEditService,
) {}
public async create(
workspaceId: string,
userId: string,
productId: string,
key: string,
request:
ImageSuiteInput | ImageSuitePlanInput | CreateImageSuiteRequest,
purpose: 'plan' | 'generate',
) {
const path = `/api/products/${productId}/ai/${purpose === 'plan' ? 'suite-plans' : 'image-suites'}`;
return this.idempotency.run(
{ workspaceId, userId, method: 'POST', path, key, request },
async (tx) => {
const variant = await tx.query<{ id: string }>(
'SELECT id FROM atlas.product_variants WHERE id = $1 AND product_id = $2 AND deleted_at IS NULL',
[request.variant_id, productId],
);
if (!variant.rows[0])
throw new ApiError(
404,
'RESOURCE_NOT_FOUND',
'目标 SKU 不存在或不属于此商品,请先保存新 SKU',
);
const assets = await tx.query<{ id: string }>(
`SELECT id FROM atlas.media_assets WHERE id = ANY($1::uuid[]) AND deleted_at IS NULL
AND content_type IN ('image/jpeg', 'image/png', 'image/webp')
AND byte_size <= 20971520 AND width_px BETWEEN 1 AND 10000 AND height_px BETWEEN 1 AND 10000`,
[request.reference_asset_ids],
);
if (assets.rows.length !== request.reference_asset_ids.length)
throw new ApiError(
404,
'RESOURCE_NOT_FOUND',
'参考素材不存在或不可处理',
);
const shots =
purpose === 'generate'
? createImageSuiteSchema.parse(request).shots
: [];
const { shots: _shots, ...input } =
request as CreateImageSuiteRequest;
const created = await tx.query<SuiteRow>(
`INSERT INTO atlas.product_image_suites (workspace_id, product_id, variant_id, purpose, input, shots, created_by_user_id)
VALUES (current_setting('app.workspace_id')::uuid, $1, $2, $3, $4::jsonb, $5::jsonb, $6) RETURNING *`,
[
productId,
input.variant_id,
purpose,
JSON.stringify(input),
JSON.stringify(shots),
userId,
],
);
const row = created.rows[0];
for (const [
position,
assetId,
] of input.reference_asset_ids.entries())
await tx.query(
`INSERT INTO atlas.product_image_suite_references (workspace_id, suite_id, asset_id, position)
VALUES (current_setting('app.workspace_id')::uuid, $1, $2, $3)`,
[row.id, assetId, position],
);
await this.operation(
tx,
row.id,
purpose === 'plan' ? 'image_suite_plan' : 'image_suite',
userId,
);
if (purpose === 'plan')
await this.outbox(
tx,
'image_suite.plan.requested',
row.id,
{ suite_id: row.id },
);
else
for (const [index, shot] of shots.entries()) {
const supporting = input.reference_asset_ids.slice(1);
// 默认方案轮换全部辅助底图,避免后面的参考角度始终未被使用。
const selected = shot.reference_asset_ids ?? [
input.reference_asset_ids[0],
...Array.from(
{ length: Math.min(2, supporting.length) },
(_, offset) =>
supporting[
(index + offset) % supporting.length
],
),
];
const settings = createMediaAiEditRequestSchema.parse({
product_id: productId,
reference_asset_ids: selected.slice(1),
mode: 'edit',
ratio: input.ratio,
target_language: input.language,
background:
shot.category === 'white' ? 'white' : 'auto',
style_set: input.style_set,
style_prompt: input.style_prompt,
prompt: `${shot.theme}\n${shot.description}\n资料依据:${shot.evidence}`,
suite_context: {
type: shot.type,
product_name: input.product_name,
color: input.color,
dimensions: input.dimensions,
weight: input.weight,
collected_info: input.collected_info,
selling_points: input.selling_points,
},
});
const id = randomUUID();
await tx.query(
`INSERT INTO atlas.media_ai_edits (id, workspace_id, product_id, suite_id, suite_shot_id, suite_position,
source_asset_id, reference_asset_ids, settings, created_by_user_id)
VALUES ($1, current_setting('app.workspace_id')::uuid, $2, $3, $4, $5, $6, $7::uuid[], $8::jsonb, $9)`,
[
id,
productId,
row.id,
shot.id,
index,
selected[0],
selected.slice(1),
JSON.stringify(settings),
userId,
],
);
await this.operation(tx, id, 'media_ai_edit', userId);
await this.outbox(tx, 'media.ai_edit.requested', id, {
edit_id: id,
});
}
return { status: 202, body: { data: this.resource(row) } };
},
);
}
public async read(
workspaceId: string,
productId: string,
id: string,
): Promise<ImageSuite> {
const { row, children } = await this.database.workspaceTransaction(
workspaceId,
async (tx) => {
const row = await this.find(tx, productId, id);
const children = await tx.query<{
id: string;
suite_shot_id: string;
}>(
'SELECT id, suite_shot_id FROM atlas.media_ai_edits WHERE suite_id = $1 ORDER BY suite_position',
[id],
);
return { row, children: children.rows };
},
);
return {
...this.resource(row),
items: await Promise.all(
children.map(async (child) => ({
...(await this.edits.read(workspaceId, child.id)),
shot_id: child.suite_shot_id,
edit_id: child.id,
})),
),
};
}
public async list(
workspaceId: string,
productId: string,
query: ListProductImageTasksQuery,
) {
let cursor: { at: string; id: string } | null = null;
if (query.cursor) {
try {
cursor = JSON.parse(
Buffer.from(query.cursor, 'base64url').toString(),
);
if (
!cursor ||
typeof cursor.at !== 'string' ||
typeof cursor.id !== 'string' ||
!Number.isFinite(Date.parse(cursor.at)) ||
!/^[0-9a-f-]{36}$/i.test(cursor.id)
)
throw new Error();
} catch {
throw new ApiError(422, 'VALIDATION_FAILED', '分页游标无效');
}
}
const rows = await this.database.workspaceTransaction(
workspaceId,
async (tx) => {
const found = await tx.query<{ id: string }>(
'SELECT id FROM atlas.products WHERE id = $1',
[productId],
);
if (!found.rows[0])
throw new ApiError(404, 'RESOURCE_NOT_FOUND', '商品不存在');
return (
await tx.query<SuiteRow>(
`SELECT * FROM atlas.product_image_suites WHERE product_id = $1
AND ($2::timestamptz IS NULL OR (created_at, id) < ($2::timestamptz, $3::uuid))
ORDER BY created_at DESC, id DESC LIMIT $4`,
[
productId,
cursor?.at ?? null,
cursor?.id ?? null,
query.limit + 1,
],
)
).rows;
},
);
const page = rows.slice(0, query.limit),
last = page.at(-1),
hasMore = rows.length > query.limit;
return {
data: page.map((row) => this.resource(row)),
meta: {
has_more: hasMore,
next_cursor:
hasMore && last
? Buffer.from(
JSON.stringify({
at: iso(last.created_at),
id: last.id,
}),
).toString('base64url')
: null,
},
};
}
public async action(
workspaceId: string,
userId: string,
productId: string,
id: string,
key: string,
action: 'cancel' | 'retry',
) {
return this.idempotency.run(
{
workspaceId,
userId,
method: 'POST',
path: `/api/products/${productId}/ai/image-suites/${id}/${action}`,
key,
request: {},
},
async (tx) => {
// 与 Worker 一致先锁子任务再锁父任务,避免取消与结果回写形成相反锁序。
const children = await tx.query<{ id: string; status: string }>(
'SELECT id, status FROM atlas.media_ai_edits WHERE suite_id = $1 ORDER BY id FOR UPDATE',
[id],
);
const row = await this.find(tx, productId, id, true);
if (action === 'cancel') {
if (['queued', 'running'].includes(row.status)) {
await tx.query(
"UPDATE atlas.product_image_suites SET status = 'cancelled', completed_at = now() WHERE id = $1",
[id],
);
await tx.query(
"UPDATE atlas.media_ai_edits SET status = 'cancelled', completed_at = now() WHERE suite_id = $1 AND status IN ('queued', 'running')",
[id],
);
await tx.query(
"UPDATE atlas.operations SET status = 'cancelled', completed_at = now() WHERE (id = $1 OR id = ANY($2::uuid[])) AND status IN ('queued', 'running')",
[id, children.rows.map((child) => child.id)],
);
}
} else {
if (
row.purpose !== 'generate' ||
['queued', 'running'].includes(row.status)
)
throw new ApiError(
409,
'INVALID_TASK_STATE',
'当前任务不能重试,请等待生成完成',
);
const failed = children.rows.filter((child) =>
['failed', 'cancelled'].includes(child.status),
);
if (failed.length) {
await tx.query(
"UPDATE atlas.product_image_suites SET status = 'queued', error_code = NULL, completed_at = NULL WHERE id = $1",
[id],
);
await tx.query(
"UPDATE atlas.operations SET status = 'queued', error_code = NULL, error_message = NULL, completed_at = NULL WHERE id = $1",
[id],
);
for (const child of failed) {
const attempt = await tx.query<{
generation_attempt: number;
}>(
"UPDATE atlas.media_ai_edits SET status = 'queued', provider_task_id = NULL, error_code = NULL, completed_at = NULL, started_at = NULL, created_at = now(), generation_attempt = generation_attempt + 1 WHERE id = $1 RETURNING generation_attempt",
[child.id],
);
await tx.query(
"UPDATE atlas.operations SET status = 'queued', result = '{}'::jsonb, error_code = NULL, error_message = NULL, completed_at = NULL WHERE id = $1",
[child.id],
);
await this.outbox(
tx,
'media.ai_edit.requested',
child.id,
{
edit_id: child.id,
attempt: attempt.rows[0].generation_attempt,
},
randomUUID(),
);
}
}
}
return {
status: 200,
body: {
data: this.resource(await this.find(tx, productId, id)),
},
};
},
);
}
private async find(
tx: DatabaseTransaction,
productId: string,
id: string,
lock = false,
) {
const result = await tx.query<SuiteRow>(
`SELECT * FROM atlas.product_image_suites WHERE id = $1 AND product_id = $2 ${lock ? 'FOR UPDATE' : ''}`,
[id, productId],
);
if (!result.rows[0])
throw new ApiError(404, 'RESOURCE_NOT_FOUND', '套图任务不存在');
return result.rows[0];
}
private resource(row: SuiteRow): ImageSuite {
return {
id: row.id,
product_id: row.product_id,
variant_id: row.variant_id,
purpose: row.purpose,
status: row.status,
input: row.input,
shots: row.shots as ImageSuiteShot[],
items: [],
error_code: row.error_code,
created_at: iso(row.created_at),
completed_at: row.completed_at ? iso(row.completed_at) : null,
operation: {
id: row.id,
kind:
row.purpose === 'plan' ? 'image_suite_plan' : 'image_suite',
status: row.status === 'partial' ? 'failed' : row.status,
created_at: iso(row.created_at),
completed_at: row.completed_at ? iso(row.completed_at) : null,
error: row.error_code
? {
code: row.error_code,
message:
row.purpose === 'plan'
? (imageSuitePlanErrorMessages[
row.error_code
] ??
imageSuitePlanErrorMessages.AI_SUITE_PLAN_FAILED)
: '部分图片未完成,可重试失败项',
}
: null,
},
};
}
private async operation(
tx: DatabaseTransaction,
id: string,
kind: string,
userId: string,
) {
await tx.query(
`INSERT INTO atlas.operations (id, workspace_id, kind, resource_type, resource_id, created_by_user_id)
VALUES ($1, current_setting('app.workspace_id')::uuid, $2, $2, $1, $3)`,
[id, kind, userId],
);
}
private async outbox(
tx: DatabaseTransaction,
event: string,
id: string,
payload: Record<string, string | number>,
dedup = id,
) {
await tx.query(
`INSERT INTO atlas.outbox_events (workspace_id, aggregate_type, aggregate_id, event_type, event_payload, deduplication_key)
VALUES (current_setting('app.workspace_id')::uuid, 'image_suite', $1, $2, $3::jsonb, $4)`,
[id, event, JSON.stringify(payload), dedup],
);
}
}
+166
View File
@@ -0,0 +1,166 @@
import { Inject, Injectable } from '@nestjs/common';
import type { MediaAiEdit, MediaAiEditSettings } from '@atlas/contracts';
import type { Database } from '@atlas/database';
import { ApiError } from '../common/api-error.js';
import { IdempotencyService } from '../common/idempotency.service.js';
import { DATABASE } from '../database/database.module.js';
import { MediaService } from './media.service.js';
type EditRow = {
id: string;
status: MediaAiEdit['status'];
source_asset_id: string;
output_asset_id: string | null;
error_code: string | null;
failure_source?: MediaAiEdit['failure_source'];
failure_stage?: MediaAiEdit['failure_stage'];
failure_detail?: string | null;
content_type: string | null;
byte_size: string | null;
width_px: number | null;
height_px: number | null;
asset_created_at: Date | string | null;
};
@Injectable()
export class MediaAiEditService {
public constructor(
@Inject(DATABASE) private readonly database: Database,
@Inject(IdempotencyService) private readonly idempotency: IdempotencyService,
@Inject(MediaService) private readonly media: MediaService,
) {}
/** 创建任务前确认主图和辅图均为本工作空间已校验的正式素材。 */
public async create(workspaceId: string, userId: string, sourceId: string, key: string, settings: MediaAiEditSettings) {
return this.idempotency.run({
workspaceId, userId, method: 'POST', path: `/api/media-assets/${sourceId}/ai-edits`,
key, request: settings,
}, async (tx) => {
const allIds = [sourceId, ...settings.reference_asset_ids];
const product = await tx.query<{ id: string }>(
'SELECT id FROM atlas.products WHERE id = $1', [settings.product_id],
);
if (!product.rows[0]) throw new ApiError(404, 'RESOURCE_NOT_FOUND', '商品不存在');
if (new Set(allIds).size !== allIds.length)
throw new ApiError(422, 'INVALID_REFERENCE_IMAGE', '主图和辅助图不能重复');
const assets = await tx.query<{ id: string }>(
`SELECT id FROM atlas.media_assets WHERE id = ANY($1::uuid[]) AND deleted_at IS NULL
AND content_type IN ('image/jpeg', 'image/png', 'image/webp')
AND byte_size <= 20971520 AND width_px BETWEEN 1 AND 10000
AND height_px BETWEEN 1 AND 10000`, [allIds],
);
if (assets.rows.length !== allIds.length)
throw new ApiError(404, 'RESOURCE_NOT_FOUND', '图片素材不存在或不可处理');
const created = await tx.query<{ id: string }>(
`INSERT INTO atlas.media_ai_edits
(workspace_id, product_id, source_asset_id, reference_asset_ids, settings, created_by_user_id)
VALUES (current_setting('app.workspace_id')::uuid, $1, $2, $3::uuid[], $4::jsonb, $5)
RETURNING id`,
[settings.product_id, sourceId, settings.reference_asset_ids, JSON.stringify(settings), userId],
);
const id = created.rows[0].id;
await tx.query(
`INSERT INTO atlas.operations (id, workspace_id, kind, resource_type, resource_id, created_by_user_id)
VALUES ($1, current_setting('app.workspace_id')::uuid, 'media_ai_edit', 'media_ai_edit', $1, $2)`,
[id, userId],
);
await tx.query(
`INSERT INTO atlas.outbox_events
(workspace_id, aggregate_type, aggregate_id, event_type, event_payload, deduplication_key)
VALUES (current_setting('app.workspace_id')::uuid, 'media_ai_edit', $1,
'media.ai_edit.requested', jsonb_build_object('edit_id', $1::uuid), $1::text)`, [id],
);
return { status: 202, body: { data: {
id, status: 'queued', source_asset_id: sourceId, output_asset: null, error_code: null,
failure_source: null, failure_stage: null, failure_detail: null,
} satisfies MediaAiEdit } };
});
}
public async read(workspaceId: string, id: string): Promise<MediaAiEdit> {
const row = await this.database.workspaceTransaction(workspaceId, async (tx) => {
const result = await tx.query<EditRow>(
`SELECT e.id, e.status, e.source_asset_id, e.output_asset_id, e.error_code,
o.result ->> 'failure_source' AS failure_source,
o.result ->> 'failure_stage' AS failure_stage,
o.result ->> 'failure_detail' AS failure_detail,
a.content_type, a.byte_size::text, a.width_px, a.height_px,
a.created_at AS asset_created_at
FROM atlas.media_ai_edits e
LEFT JOIN atlas.operations o ON o.id = e.id AND o.workspace_id = e.workspace_id
LEFT JOIN atlas.media_assets a ON a.id = e.output_asset_id AND a.deleted_at IS NULL
WHERE e.id = $1`, [id],
);
return result.rows[0] ?? null;
});
if (!row) throw new ApiError(404, 'RESOURCE_NOT_FOUND', 'AI 编辑任务不存在');
const access = row.output_asset_id && row.content_type ? await this.media.accessUrl(workspaceId, row.output_asset_id) : null;
return {
id: row.id, status: row.status, source_asset_id: row.source_asset_id, error_code: row.error_code,
failure_source: row.status === 'failed' ? row.failure_source ?? null : null,
failure_stage: row.status === 'failed' ? row.failure_stage ?? null : null,
failure_detail: row.status === 'failed' ? row.failure_detail ?? null : null,
output_asset: row.output_asset_id && access ? {
id: row.output_asset_id, content_type: row.content_type!, byte_size: Number(row.byte_size),
width_px: row.width_px, height_px: row.height_px,
created_at: row.asset_created_at instanceof Date
? row.asset_created_at.toISOString() : String(row.asset_created_at),
preview_url: access.url, preview_url_expires_at: access.expires_at,
} : null,
};
}
/** 右侧素材库展示最近完成的生成图,预览链接每次读取时重新签发。 */
public async list(workspaceId: string, productId: string): Promise<MediaAiEdit[]> {
const rows = await this.database.workspaceTransaction(workspaceId, async (tx) => {
const result = await tx.query<EditRow>(
`SELECT e.id, e.status, e.source_asset_id, e.output_asset_id, e.error_code,
a.content_type, a.byte_size::text, a.width_px, a.height_px,
a.created_at AS asset_created_at
FROM atlas.media_ai_edits e
JOIN atlas.media_assets a ON a.id = e.output_asset_id AND a.deleted_at IS NULL
WHERE e.status = 'succeeded' AND e.product_id = $1
ORDER BY e.completed_at DESC LIMIT 100`, [productId],
);
return result.rows;
});
return Promise.all(rows.map(async (row) => {
const access = await this.media.accessUrl(workspaceId, row.output_asset_id!);
return {
id: row.id, status: 'succeeded' as const, source_asset_id: row.source_asset_id,
error_code: null,
failure_source: null, failure_stage: null, failure_detail: null,
output_asset: {
id: row.output_asset_id!, content_type: row.content_type!,
byte_size: Number(row.byte_size), width_px: row.width_px, height_px: row.height_px,
created_at: row.asset_created_at instanceof Date
? row.asset_created_at.toISOString() : String(row.asset_created_at),
preview_url: access.url, preview_url_expires_at: access.expires_at,
},
};
}));
}
/** 取消是工作空间内的幂等状态变更;已完成结果不回滚。 */
public async cancel(workspaceId: string, userId: string, id: string, key: string) {
return this.idempotency.run({
workspaceId, userId, method: 'POST', path: `/api/media-ai-edits/${id}/cancel`, key, request: {},
}, async (tx) => {
const existing = await tx.query<{ status: MediaAiEdit['status'] }>(
'SELECT status FROM atlas.media_ai_edits WHERE id = $1 FOR UPDATE', [id],
);
if (!existing.rows[0]) throw new ApiError(404, 'RESOURCE_NOT_FOUND', 'AI 编辑任务不存在');
if (existing.rows[0].status === 'queued' || existing.rows[0].status === 'running') {
await tx.query(
`UPDATE atlas.media_ai_edits SET status = 'cancelled', completed_at = now() WHERE id = $1`, [id],
);
await tx.query(
`UPDATE atlas.operations SET status = 'cancelled', completed_at = now()
WHERE id = $1 AND status IN ('queued', 'running')`, [id],
);
}
return { status: 200, body: { data: { id, status: existing.rows[0].status === 'queued'
|| existing.rows[0].status === 'running' ? 'cancelled' : existing.rows[0].status } } };
});
}
}
@@ -0,0 +1,87 @@
export function detectImage(
bytes: Buffer,
maxDimension = 8192,
): {
contentType: 'image/png' | 'image/jpeg' | 'image/webp';
width: number;
height: number;
} | null {
if (
bytes.length >= 24 &&
bytes
.subarray(0, 8)
.equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]))
) {
return dimensions(
'image/png',
bytes.readUInt32BE(16),
bytes.readUInt32BE(20),
maxDimension,
);
}
if (
bytes.length >= 12 &&
bytes.subarray(0, 4).toString() === 'RIFF' &&
bytes.subarray(8, 12).toString() === 'WEBP'
) {
const type = bytes.subarray(12, 16).toString();
if (type === 'VP8X' && bytes.length >= 30) {
return dimensions(
'image/webp',
1 + bytes.readUIntLE(24, 3),
1 + bytes.readUIntLE(27, 3),
maxDimension,
);
}
if (
type === 'VP8 ' &&
bytes.length >= 30 &&
bytes[23] === 0x9d &&
bytes[24] === 0x01 &&
bytes[25] === 0x2a
) {
return dimensions(
'image/webp',
bytes.readUInt16LE(26) & 0x3fff,
bytes.readUInt16LE(28) & 0x3fff,
maxDimension,
);
}
}
if (bytes.length > 4 && bytes[0] === 0xff && bytes[1] === 0xd8) {
for (let offset = 2; offset + 9 < bytes.length;) {
if (bytes[offset] !== 0xff) return null;
const marker = bytes[offset + 1];
const length = bytes.readUInt16BE(offset + 2);
if (length < 2 || offset + 2 + length > bytes.length) return null;
if (
[
0xc0, 0xc1, 0xc2, 0xc3, 0xc5, 0xc6, 0xc7, 0xc9, 0xca, 0xcb,
0xcd, 0xce, 0xcf,
].includes(marker)
) {
return dimensions(
'image/jpeg',
bytes.readUInt16BE(offset + 7),
bytes.readUInt16BE(offset + 5),
maxDimension,
);
}
offset += 2 + length;
}
}
return null;
}
function dimensions(
contentType: 'image/png' | 'image/jpeg' | 'image/webp',
width: number,
height: number,
maxDimension: number,
) {
return width >= 1 &&
height >= 1 &&
width <= maxDimension &&
height <= maxDimension
? { contentType, width, height }
: null;
}
+237 -2
View File
@@ -1,11 +1,15 @@
import { ProductImageDeletionService } from './product-image-deletion.service.js';
import {
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,
),
};
}
+8 -1
View File
@@ -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 {}
+248 -98
View File
@@ -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;
}
}
+28
View File
@@ -0,0 +1,28 @@
import type { DatabaseTransaction } from '@atlas/database';
import type { CommissionRule } from './pricing-engine.js';
/** 查询与正式报价共用同一组已启用规则,避免页面显示和实际算价使用不同费率。 */
export async function readCommissionRules(
tx: DatabaseTransaction,
platformId: string,
categoryId: string,
fulfillment: string,
) {
const mapping = (
await tx.query<{ commission_category_id: string }>(
`SELECT * FROM atlas.platform_product_category_commission_mappings WHERE platform_id=$1 AND platform_product_category_id=$2 AND fulfillment=$3 AND market_code='RU' AND status='active' AND effective_from<=now() AND (effective_to IS NULL OR effective_to>now()) ORDER BY priority,effective_from DESC,id LIMIT 1 FOR SHARE`,
[platformId, categoryId, fulfillment],
)
).rows[0];
const commissions = mapping
? (
await tx.query<CommissionRule>(
`SELECT r.*,c.name AS category_name FROM atlas.commission_category_rules r JOIN atlas.commission_categories c ON c.id=r.commission_category_id
WHERE r.commission_category_id=$1 AND r.fulfillment=$2 AND r.market_code='RU' AND r.currency_code IN ('CNY','RUB')
AND r.status='active' AND r.effective_from<=now() AND (r.effective_to IS NULL OR r.effective_to>now()) ORDER BY r.min_price NULLS FIRST,r.id FOR SHARE OF r,c`,
[mapping.commission_category_id, fulfillment],
)
).rows
: [];
return { mapping, commissions };
}
@@ -0,0 +1,82 @@
import {
Body,
Controller,
Get,
Headers,
Inject,
Param,
Post,
Query,
Req,
UseGuards,
} from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import { pricingRateReviewRequestSchema } from '@atlas/contracts';
import { z } from 'zod';
import type { AuthenticatedRequest } from '../auth/auth.guard.js';
import { ApiError } from '../common/api-error.js';
import { SystemAdminGuard } from '../platform-catalog-admin/system-admin.guard.js';
import { PricingAdminService } from './pricing-admin.service.js';
function uuid(value: string, field: string): string {
if (!z.uuid().safeParse(value).success)
throw new ApiError(400, 'VALIDATION_ERROR', `${field} 无效`);
return value;
}
@ApiTags('System pricing')
@ApiBearerAuth('access-token')
@UseGuards(SystemAdminGuard)
@Controller('system/pricing/rate-cards')
export class PricingAdminController {
constructor(
@Inject(PricingAdminService)
private readonly service: PricingAdminService,
) {}
@Get()
async list(@Query('platform_id') platformId: string) {
return {
data: await this.service.list(uuid(platformId, 'platform_id')),
};
}
@Get(':id')
async detail(@Param('id') id: string) {
return { data: await this.service.detail(uuid(id, 'rate_card_id')) };
}
@Post(':id/reviews')
async approve(
@Param('id') id: string,
@Headers('idempotency-key') key: string | undefined,
@Body() body: unknown,
@Req() request: AuthenticatedRequest,
) {
if (!key)
throw new ApiError(
400,
'IDEMPOTENCY_KEY_REQUIRED',
'需要 Idempotency-Key',
);
const parsed = pricingRateReviewRequestSchema.safeParse(body);
if (!parsed.success)
throw new ApiError(
422,
'VALIDATION_ERROR',
'请检查审核证据与账单样本',
parsed.error.issues.map((issue) => ({
field: issue.path.join('.'),
message: issue.message,
})),
);
return {
data: await this.service.approve(
uuid(id, 'rate_card_id'),
request.auth.userId,
uuid(key, 'Idempotency-Key'),
parsed.data,
),
};
}
}
@@ -0,0 +1,352 @@
import { Inject, Injectable } from '@nestjs/common';
import type { Database, DatabaseTransaction } from '@atlas/database';
import type {
PricingRateCardAdmin,
PricingRateReviewRequest,
} from '@atlas/contracts';
import { DATABASE } from '../database/database.module.js';
import { ApiError } from '../common/api-error.js';
import {
calculateLogisticsFee,
D,
fingerprint,
logisticsMerchandiseFits,
type LogisticsRule,
} from './pricing-engine.js';
type Tx = DatabaseTransaction;
type CardRow = {
id: string;
platform_id: string;
provider_code: string;
version: string;
source_name: string;
source_uri: string | null;
status: string;
fulfillment: string;
market_code: string;
effective_from: Date;
reviewed_at: Date | null;
reviewer_name: string | null;
};
type TypeRow = PricingRateCardAdmin['types'][number] & Record<string, unknown>;
type RuleRow = LogisticsRule & {
product_type_code: string;
transport_method_name: string;
status: string;
product_type_name: string;
transport_name: string;
};
@Injectable()
export class PricingAdminService {
constructor(@Inject(DATABASE) private readonly db: Database) {}
async list(platformId: string): Promise<PricingRateCardAdmin[]> {
const ids = await this.db.query<{ id: string }>(
`SELECT id FROM atlas.logistics_rate_cards WHERE platform_id=$1 AND fulfillment='rfbs' AND market_code='RU' ORDER BY created_at DESC`,
[platformId],
);
const cards: PricingRateCardAdmin[] = [];
for (const row of ids.rows) cards.push(await this.detail(row.id));
return cards;
}
async detail(id: string): Promise<PricingRateCardAdmin> {
return this.db.transaction((tx) => this.read(tx, id));
}
private async read(tx: Tx, id: string): Promise<PricingRateCardAdmin> {
const card = (
await tx.query<CardRow>(
`SELECT c.id,c.platform_id,p.code AS provider_code,c.version,c.source_name,c.source_uri,c.status,c.fulfillment,c.market_code,c.effective_from,c.reviewed_at,u.display_name AS reviewer_name
FROM atlas.logistics_rate_cards c JOIN atlas.logistics_providers p ON p.id=c.provider_id
LEFT JOIN atlas.users u ON u.id=c.reviewed_by_user_id WHERE c.id=$1`,
[id],
)
).rows[0];
if (!card)
throw new ApiError(404, 'RATE_CARD_NOT_FOUND', '物流费率卡不存在');
const types = (
await tx.query<TypeRow>(
`SELECT id,code::text,min_merchandise_value::text,max_merchandise_value::text,min_actual_weight_kg::text,max_actual_weight_kg::text,billing_basis::text,
merchandise_currency_code,min_value_inclusive,max_value_inclusive,min_actual_weight_inclusive,max_sum_dimensions_cm::text,max_longest_side_cm::text,max_second_longest_side_cm::text,volume_divisor::text,volume_condition_sum_gt_cm::text,volume_round_scale,billing_round_scale,max_volume_weight_kg::text,volume_limit_round_scale
FROM atlas.logistics_product_types WHERE rate_card_id=$1 ORDER BY code`,
[id],
)
).rows;
const rules = (
await tx.query<RuleRow>(
`SELECT r.id,r.product_type_id,t.code::text AS product_type_code,t.name AS product_type_name,r.transport_method_id,m.name AS transport_method_name,m.name AS transport_name,
r.fixed_fee_cny::text,r.per_billable_kg_fee_cny::text,r.min_charge_cny::text,r.surcharge_rules,r.status::text,
t.merchandise_currency_code,t.min_merchandise_value::text,t.max_merchandise_value::text,t.min_value_inclusive,t.max_value_inclusive,
t.min_actual_weight_kg::text,t.min_actual_weight_inclusive,t.max_actual_weight_kg::text,t.max_sum_dimensions_cm::text,t.max_longest_side_cm::text,t.max_second_longest_side_cm::text,
t.billing_basis::text,t.volume_divisor::text,t.volume_condition_sum_gt_cm::text,t.volume_round_scale,t.billing_round_scale,t.max_volume_weight_kg::text,t.volume_limit_round_scale,
r.updated_at AS rule_updated_at,t.updated_at AS type_updated_at,m.updated_at AS transport_updated_at,m.status::text AS transport_status
FROM atlas.logistics_rate_rules r JOIN atlas.logistics_product_types t ON t.id=r.product_type_id
JOIN atlas.logistics_transport_methods m ON m.id=r.transport_method_id AND m.rate_card_id=t.rate_card_id
WHERE t.rate_card_id=$1 ORDER BY t.code,m.code`,
[id],
)
).rows;
const review =
(
await tx.query<{
source_reference: string;
review_note: string;
samples: PricingRateReviewRequest['samples'];
reviewed_at: Date;
}>(
`SELECT source_reference,review_note,samples,reviewed_at FROM atlas.logistics_rate_card_reviews WHERE rate_card_id=$1`,
[id],
)
).rows[0] ?? null;
const { reviewer_name: reviewerName, ...publicCard } = card;
return {
...publicCard,
effective_from: card.effective_from.toISOString(),
reviewed_at: card.reviewed_at?.toISOString() ?? null,
reviewer_name: reviewerName,
rules_hash: fingerprint({
card: {
id: card.id,
version: card.version,
source_name: card.source_name,
source_uri: card.source_uri,
},
types,
rules,
}),
types,
rules: rules.map((r) => ({
id: r.id,
product_type_id: r.product_type_id,
product_type_code: r.product_type_code,
transport_method_id: r.transport_method_id,
transport_method_name: r.transport_method_name,
fixed_fee_cny: r.fixed_fee_cny,
per_billable_kg_fee_cny: r.per_billable_kg_fee_cny,
min_charge_cny: r.min_charge_cny,
status: r.status,
})),
review: review && {
...review,
reviewed_at: review.reviewed_at.toISOString(),
},
};
}
async approve(
id: string,
userId: string,
key: string,
input: PricingRateReviewRequest,
) {
return this.db.transaction(async (tx) => {
await tx.query(
`SELECT pg_advisory_xact_lock(hashtextextended($1,0))`,
[`pricing-review:${userId}:${key}`],
);
const card = (
await tx.query<{
status: string;
provider_code: string;
platform_code: string;
}>(
`SELECT c.status,p.code AS provider_code,platform.code AS platform_code
FROM atlas.logistics_rate_cards c JOIN atlas.logistics_providers p ON p.id=c.provider_id
JOIN atlas.platforms platform ON platform.id=c.platform_id WHERE c.id=$1 FOR UPDATE OF c`,
[id],
)
).rows[0];
if (!card)
throw new ApiError(
404,
'RATE_CARD_NOT_FOUND',
'物流费率卡不存在',
);
const requestHash = fingerprint(input);
const replay = (
await tx.query<{ rate_card_id: string; request_hash: string }>(
`SELECT rate_card_id,request_hash FROM atlas.logistics_rate_card_reviews WHERE reviewer_user_id=$1 AND idempotency_key=$2`,
[userId, key],
)
).rows[0];
if (replay) {
if (
replay.rate_card_id !== id ||
replay.request_hash !== requestHash
)
throw new ApiError(
409,
'IDEMPOTENCY_KEY_CONFLICT',
'幂等键已用于其他审核请求',
);
return this.read(tx, id);
}
if (card.status !== 'draft')
throw new ApiError(
409,
'RATE_CARD_NOT_DRAFT',
'费率卡已审核或不可审核,请刷新',
);
if (
card.provider_code.toUpperCase() !== 'CEL' ||
card.platform_code.toUpperCase() !== 'OZON'
)
throw new ApiError(
422,
'RATE_CARD_REVIEW_UNSUPPORTED',
'当前审核流程仅支持 Ozon CEL 费率',
);
const detail = await this.read(tx, id);
if (detail.rules_hash !== input.rules_hash)
throw new ApiError(
409,
'RATE_CARD_RULES_CHANGED',
'费率规则已变化,请重新查看并复核',
);
const types = new Set(detail.types.map((type) => type.id));
const typeCodes = new Set(detail.types.map((type) => type.code));
if (
types.size !== 6 ||
[
'ExtraSmall',
'Budget',
'Small',
'Big',
'PremiumSmall',
'PremiumBig',
].some((code) => !typeCodes.has(code)) ||
detail.rules.length !== 16 ||
detail.rules.some((r) => r.status !== 'draft')
)
throw new ApiError(
422,
'RATE_CARD_INCOMPLETE',
'CEL 费率应包含六种产品类型和十六条待审核规则',
);
if (
new Date(input.effective_from).getTime() >
Date.now() + 365 * 86400000
)
throw new ApiError(
422,
'RATE_CARD_EFFECTIVE_DATE_INVALID',
'生效日期不能晚于一年后',
);
const fullRules = (
await tx.query<RuleRow>(
`SELECT r.*,t.code::text AS product_type_code,t.name AS product_type_name,m.name AS transport_name,
t.merchandise_currency_code,t.min_merchandise_value::text,t.max_merchandise_value::text,t.min_value_inclusive,t.max_value_inclusive,
t.min_actual_weight_kg::text,t.min_actual_weight_inclusive,t.max_actual_weight_kg::text,t.max_sum_dimensions_cm::text,t.max_longest_side_cm::text,t.max_second_longest_side_cm::text,
t.billing_basis::text,t.volume_divisor::text,t.volume_condition_sum_gt_cm::text,t.volume_round_scale,t.billing_round_scale,t.max_volume_weight_kg::text,t.volume_limit_round_scale,m.status::text AS transport_status
FROM atlas.logistics_rate_rules r JOIN atlas.logistics_product_types t ON t.id=r.product_type_id
JOIN atlas.logistics_transport_methods m ON m.id=r.transport_method_id WHERE t.rate_card_id=$1 FOR SHARE OF r,t,m`,
[id],
)
).rows;
if (
fullRules.some(
(rule) =>
rule.surcharge_rules.length > 0 ||
(rule as RuleRow & { transport_status: string })
.transport_status !== 'active',
)
)
throw new ApiError(
422,
'RATE_CARD_UNSUPPORTED_RULE',
'存在尚未支持的附加费或停用运输方式',
);
const covered = new Set<string>();
const invoiceReferences = new Set<string>();
for (const [index, sample] of input.samples.entries()) {
const invoiceReference = sample.invoice_reference
.trim()
.toLocaleLowerCase();
if (invoiceReferences.has(invoiceReference))
throw new ApiError(
422,
'RATE_CARD_SAMPLE_DUPLICATE',
'账单编号不能重复使用',
);
invoiceReferences.add(invoiceReference);
const rule = fullRules.find((r) => r.id === sample.rule_id);
if (!rule)
throw new ApiError(
422,
'RATE_CARD_SAMPLE_INVALID',
`第 ${index + 1} 条账单不属于当前费率卡`,
);
covered.add(rule.product_type_id);
if (
!logisticsMerchandiseFits(
rule,
sample.merchandise_value_cny,
)
)
throw new ApiError(
422,
'RATE_CARD_SAMPLE_INVALID',
`第 ${index + 1} 条账单货值不在该产品类型范围`,
);
if (D(sample.shipping_weight_g).lt(1))
throw new ApiError(
422,
'RATE_CARD_SAMPLE_INVALID',
`第 ${index + 1} 条账单重量至少 1 g`,
);
const fee = calculateLogisticsFee(rule, {
shipping_weight_g: sample.shipping_weight_g,
length_mm: sample.length_mm,
width_mm: sample.width_mm,
height_mm: sample.height_mm,
status: 'confirmed',
});
if (
'reason' in fee ||
fee.logistics
.sub(sample.billed_logistics_cny)
.abs()
.gt('0.01')
)
throw new ApiError(
422,
'RATE_CARD_SAMPLE_MISMATCH',
`第 ${index + 1} 条账单与费率计算不一致`,
);
}
if ([...types].some((type) => !covered.has(type)))
throw new ApiError(
422,
'RATE_CARD_SAMPLE_INCOMPLETE',
'每种 CEL 产品类型至少需要一条真实账单样本',
);
await tx.query(
`UPDATE atlas.logistics_rate_rules r SET status='active',updated_at=now() FROM atlas.logistics_product_types t WHERE r.product_type_id=t.id AND t.rate_card_id=$1`,
[id],
);
await tx.query(
`UPDATE atlas.logistics_rate_cards SET status='active',effective_from=$2,reviewed_by_user_id=$3,reviewed_at=now(),updated_at=now() WHERE id=$1`,
[id, input.effective_from, userId],
);
await tx.query(
`INSERT INTO atlas.logistics_rate_card_reviews(rate_card_id,reviewer_user_id,idempotency_key,request_hash,rules_hash,source_reference,review_note,samples,effective_from)
VALUES($1,$2,$3,$4,$5,$6,$7,$8::jsonb,$9)`,
[
id,
userId,
key,
requestHash,
input.rules_hash,
input.source_reference,
input.review_note,
JSON.stringify(input.samples),
input.effective_from,
],
);
return this.read(tx, id);
});
}
}
+309
View File
@@ -0,0 +1,309 @@
import { normalizePricingInput } from '@atlas/contracts';
import { randomUUID, createHash } from 'node:crypto';
import { Decimal } from 'decimal.js';
import type { PricingOption, PricingPreviewRequest } from '@atlas/contracts';
Decimal.set({ precision: 40, rounding: Decimal.ROUND_HALF_UP });
export const D = (value: Decimal.Value) => new Decimal(value);
export function fingerprint(value: unknown): string {
const sort = (v: unknown): unknown =>
v instanceof Date
? v.toISOString()
: Array.isArray(v)
? v.map(sort)
: v && typeof v === 'object'
? Object.fromEntries(
Object.entries(v)
.sort(([a], [b]) => a.localeCompare(b))
.map(([k, x]) => [k, sort(x)]),
)
: v;
return createHash('sha256')
.update(JSON.stringify(sort(value)))
.digest('hex');
}
export type LogisticsRule = {
id: string;
product_type_id: string;
transport_method_id: string;
transport_name: string;
product_type_name: string;
fixed_fee_cny: string;
per_billable_kg_fee_cny: string;
min_charge_cny: string | null;
surcharge_rules: unknown[];
merchandise_currency_code: string;
min_merchandise_value: string | null;
max_merchandise_value: string | null;
min_value_inclusive: boolean;
max_value_inclusive: boolean;
min_actual_weight_kg: string;
min_actual_weight_inclusive: boolean;
max_actual_weight_kg: string | null;
max_sum_dimensions_cm: string | null;
max_longest_side_cm: string | null;
max_second_longest_side_cm: string | null;
billing_basis: string;
volume_divisor: string | null;
volume_condition_sum_gt_cm: string | null;
volume_round_scale: number | null;
billing_round_scale: number | null;
max_volume_weight_kg: string | null;
volume_limit_round_scale: number | null;
};
export type CommissionRule = {
category_name?: string;
source?: string;
effective_from?: string | Date;
id: string | null;
currency_code: string;
min_price: string | null;
max_price: string | null;
min_inclusive: boolean;
max_inclusive: boolean;
commission_rate: string;
version: string;
};
const inRange = (
n: Decimal,
min: string | null,
max: string | null,
includeMin: boolean,
includeMax: boolean,
) =>
(min === null || (includeMin ? n.gte(min) : n.gt(min))) &&
(max === null || (includeMax ? n.lte(max) : n.lt(max)));
const ceil = (n: Decimal, scale: number | null) =>
scale === null ? n : n.toDecimalPlaces(scale, Decimal.ROUND_CEIL);
export const normalizeInput = normalizePricingInput;
/** 审核样本与正式报价共用包装适用性和运费计算,避免两套规则产生不同结果。 */
export function calculateLogisticsFee(
r: LogisticsRule,
packageSpec: PricingPreviewRequest['package'],
): { billable: Decimal; logistics: Decimal } | { reason: string } {
if (r.surcharge_rules.length)
return { reason: '此规则含尚未支持的附加费,需完成审核后使用' };
const weight = D(packageSpec.shipping_weight_g).div(1000);
const sides = [
packageSpec.length_mm,
packageSpec.width_mm,
packageSpec.height_mm,
]
.map((v) => D(v).div(10))
.sort((a, b) => b.cmp(a));
const sum = sides.reduce((a, b) => a.add(b), D(0));
if (
!inRange(
weight,
r.min_actual_weight_kg,
r.max_actual_weight_kg,
r.min_actual_weight_inclusive,
true,
) ||
(r.max_sum_dimensions_cm && sum.gt(r.max_sum_dimensions_cm)) ||
(r.max_longest_side_cm && sides[0].gt(r.max_longest_side_cm)) ||
(r.max_second_longest_side_cm &&
sides[1].gt(r.max_second_longest_side_cm))
)
return { reason: '包装重量或尺寸超出物流范围' };
let billable = weight;
if (r.billing_basis !== 'actual_only') {
if (!r.volume_divisor || D(r.volume_divisor).lte(0))
return { reason: '体积重规则无效' };
const volume = sides
.reduce((a, b) => a.mul(b), D(1))
.div(r.volume_divisor);
if (
r.max_volume_weight_kg &&
ceil(volume, r.volume_limit_round_scale).gt(r.max_volume_weight_kg)
)
return { reason: '体积重超限' };
if (
r.billing_basis === 'max_actual_volume' ||
(r.volume_condition_sum_gt_cm &&
sum.gt(r.volume_condition_sum_gt_cm))
)
billable = Decimal.max(weight, ceil(volume, r.volume_round_scale));
}
billable = ceil(billable, r.billing_round_scale);
const logistics = Decimal.max(
billable.mul(r.per_billable_kg_fee_cny).add(r.fixed_fee_cny),
r.min_charge_cny ?? '0',
);
return { billable, logistics };
}
export function logisticsMerchandiseFits(
r: LogisticsRule,
valueCny: string,
): boolean {
return (
r.merchandise_currency_code === 'CNY' &&
inRange(
D(valueCny),
r.min_merchandise_value,
r.max_merchandise_value,
r.min_value_inclusive,
r.max_value_inclusive,
)
);
}
/** 枚举自洽候选;先舍入目标币种售价,再回验佣金档与货值档,避免跨档误报。 */
export function calculatePricing(
input: PricingPreviewRequest,
currency: string,
fx: string,
rules: LogisticsRule[],
commissions: CommissionRule[],
) {
if (!['CNY', 'RUB'].includes(currency))
throw new Error('门店币种仅支持 CNY / RUB');
const p = input.pricing,
exchange = D(fx),
purchase = D(input.purchase_cost_cny),
domestic = D(p.domestic_shipping_and_label_fee_cny);
const options: PricingOption[] = [],
unavailable_options: Array<{
transport_method_id: string;
reason: string;
}> = [];
for (const transport of p.transport_method_ids) {
const reasons = new Set<string>();
for (const r of rules.filter(
(r) => r.transport_method_id === transport,
)) {
const fee = calculateLogisticsFee(r, input.package);
if ('reason' in fee) {
reasons.add(fee.reason);
continue;
}
const { billable, logistics } = fee;
for (const c of commissions) {
const totalRate = D(c.commission_rate)
.add(p.ad_rate)
.add(p.last_mile_rate)
.add(p.withdrawal_rate)
.add(p.other_rate);
if (totalRate.gte(1)) {
reasons.add('总扣费率达到或超过 100%');
continue;
}
const factor = currency === 'RUB' ? exchange : D(1);
const sale =
p.mode === 'fixed_sale'
? D(p.selling_price!)
: purchase
.mul(D(1).add(p.desired_markup_rate))
.add(logistics)
.add(domestic)
.div(D(1).sub(totalRate))
.mul(factor);
const rounded = sale.toDecimalPlaces(
2,
p.mode === 'fixed_sale'
? Decimal.ROUND_HALF_UP
: Decimal.ROUND_CEIL,
),
cny = rounded.div(factor),
rub = cny.mul(exchange);
if (
rounded.lte(0) ||
!inRange(
c.currency_code === 'RUB' ? rub : cny,
c.min_price,
c.max_price,
c.min_inclusive,
c.max_inclusive,
) ||
!inRange(
r.merchandise_currency_code === 'RUB' ? rub : cny,
r.min_merchandise_value,
r.max_merchandise_value,
r.min_value_inclusive,
r.max_value_inclusive,
)
) {
reasons.add('售价不在该物流货值区间或佣金档内');
continue;
}
const profit = cny
.mul(D(1).sub(totalRate))
.sub(purchase)
.sub(logistics)
.sub(domestic),
list = rounded
.div(D(1).sub(p.list_price_discount_rate))
.toDecimalPlaces(2, Decimal.ROUND_CEIL);
if (
[
rounded,
list,
rub,
list.div(factor).mul(exchange),
profit.mul(exchange),
logistics,
].some((v) => v.abs().gte('100000000000000'))
) {
reasons.add('金额超过可保存范围,请调整费率或输入');
continue;
}
options.push({
id: randomUUID(),
can_confirm: profit.gte(0),
transport_method_id: transport,
transport_name: r.transport_name,
product_type_id: r.product_type_id,
product_type_name: r.product_type_name,
logistics_rate_rule_id: r.id,
commission_rule_id: c.id,
commission_rate: c.commission_rate,
...(c.id ? {commission_details: {
category_name: c.category_name ?? '',currency_code:c.currency_code,version:c.version,source:c.source ?? 'manual_rule',
min_price:c.min_price,max_price:c.max_price,min_inclusive:c.min_inclusive,max_inclusive:c.max_inclusive,commission_rate:c.commission_rate,
}} : {}),
selling_price: rounded.toFixed(2),
list_price: list.toFixed(2),
currency_code: currency,
selling_price_rub: rub.toFixed(4),
list_price_rub: list.div(factor).mul(exchange).toFixed(4),
merchandise_value_cny: cny.toFixed(4),
profit_cny: profit.toFixed(2),
profit_rub: profit.mul(exchange).toFixed(4),
margin_rate: profit.div(cny).toFixed(6),
billable_weight_kg: billable.toFixed(3),
fees: {
logistics_cny: logistics.toFixed(4),
domestic_cny: domestic.toFixed(4),
commission_cny: cny.mul(c.commission_rate).toFixed(4),
ad_cny: cny.mul(p.ad_rate).toFixed(4),
last_mile_cny: cny.mul(p.last_mile_rate).toFixed(4),
withdrawal_cny: cny.mul(p.withdrawal_rate).toFixed(4),
other_cny: cny.mul(p.other_rate).toFixed(4),
},
warnings: [
...(profit.lt(0) ? ['亏损报价不能确认'] : []),
...(profit.lt(purchase.mul(p.desired_markup_rate))
? ['未达到目标加价利润']
: []),
...(!c.id ? ['使用临时佣金率'] : []),
],
});
}
}
if (!options.some((o) => o.transport_method_id === transport))
unavailable_options.push({
transport_method_id: transport,
reason: [...reasons].join(';') || '没有可用规则',
});
}
options.sort((a, b) => D(a.selling_price).cmp(b.selling_price));
const recommended =
options.find(
(o) => o.transport_method_id === p.default_transport_method_id,
) ?? (!p.default_transport_method_id ? options[0] : undefined);
return {
options,
unavailable_options,
recommended_option_id: recommended?.id ?? null,
};
}
+199
View File
@@ -0,0 +1,199 @@
import {
Body,
Controller,
Delete,
Get,
Headers,
Inject,
Param,
Patch,
Post,
Query,
Req,
Res,
} from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import { z } from 'zod';
import {
pricingPreviewRequestSchema,
pricingPolicyRequestSchema,
type Workspace,
} from '@atlas/contracts';
import { CurrentWorkspace } from '../workspace/current-workspace.decorator.js';
import type { AuthenticatedRequest } from '../auth/auth.guard.js';
import { ApiError } from '../common/api-error.js';
import { PricingService } from './pricing.service.js';
type Response = { status: (status: number) => unknown };
function parse<T>(schema: z.ZodType<T>, value: unknown): T {
const result = schema.safeParse(value);
if (!result.success)
throw new ApiError(
422,
'VALIDATION_ERROR',
'请检查定价输入',
result.error.issues.map((i) => ({
field: i.path.join('.'),
message: i.message,
})),
);
return result.data;
}
function key(value: string | undefined) {
if (!value || value.length > 200)
throw new ApiError(
400,
'IDEMPOTENCY_KEY_REQUIRED',
'需要 Idempotency-Key',
);
return value;
}
@ApiTags('Pricing')
@ApiBearerAuth('access-token')
@Controller()
export class PricingController {
constructor(
@Inject(PricingService) private readonly service: PricingService,
) {}
@Get('pricing/commission-rules') async commissionRules(
@CurrentWorkspace() w: Workspace,
@Query('store_id') store: string,
@Query('platform_product_category_id') category: string | undefined,
@Query('description_category_id') external: string | undefined,
) {
if (Boolean(category) === Boolean(external)) throw new ApiError(422,'VALIDATION_ERROR','请指定一种类目 ID');
return {data:await this.service.commissionRules(w.id,parse(z.uuid(),store),category ? parse(z.uuid(),category) : undefined,external ? parse(z.string().regex(/^[1-9]\d{0,17}$/),external) : undefined)};
}
@Get('pricing/categories') async categories(
@CurrentWorkspace() w: Workspace,
@Query('store_id') store: string,
@Query('q') q: string | undefined,
) {
return {
data: await this.service.categories(
w.id,
parse(z.uuid(), store),
parse(z.string().max(200), q ?? ''),
),
};
}
@Get('pricing/catalog') async catalog(
@CurrentWorkspace() w: Workspace,
@Query('store_id') store: string,
) {
return {
data: await this.service.catalog(w.id, parse(z.uuid(), store)),
};
}
@Get('stores/:id/pricing-policies') async policies(
@CurrentWorkspace() w: Workspace,
@Param('id') id: string,
) {
return {
data: (await this.service.catalog(w.id, parse(z.uuid(), id)))
.policies,
};
}
@Post('pricing/quote-previews') async preview(
@CurrentWorkspace() w: Workspace,
@Req() req: AuthenticatedRequest,
@Headers('idempotency-key') k: string | undefined,
@Body() body: unknown,
@Res({ passthrough: true }) res: Response,
) {
const r = await this.service.preview(
w.id,
req.auth.userId,
key(k),
parse(pricingPreviewRequestSchema, body),
);
res.status(r.status);
return r.body;
}
@Post('stores/:id/pricing-policies') async create(
@CurrentWorkspace() w: Workspace,
@Req() req: AuthenticatedRequest,
@Param('id') id: string,
@Headers('idempotency-key') k: string | undefined,
@Body() body: unknown,
@Res({ passthrough: true }) res: Response,
) {
const r = await this.service.writePolicy(
w.id,
req.auth.userId,
key(k),
parse(z.uuid(), id),
null,
'create',
parse(pricingPolicyRequestSchema, body),
null,
);
res.status(r.status);
return r.body;
}
@Patch('pricing-policies/:id') async update(
@CurrentWorkspace() w: Workspace,
@Req() req: AuthenticatedRequest,
@Param('id') id: string,
@Headers('idempotency-key') k: string | undefined,
@Headers('if-match') match: string | undefined,
@Body() body: unknown,
) {
if (!match || !/^(?:[1-9]\d*|"[1-9]\d*")$/.test(match))
throw new ApiError(
428,
'PRECONDITION_REQUIRED',
'需要方案版本 If-Match',
);
return (
await this.service.writePolicy(
w.id,
req.auth.userId,
key(k),
null,
parse(z.uuid(), id),
'update',
parse(pricingPolicyRequestSchema, body),
Number(match.replaceAll('"', '')),
)
).body;
}
@Post('pricing-policies/:id/set-default') async defaultPolicy(
@CurrentWorkspace() w: Workspace,
@Req() req: AuthenticatedRequest,
@Param('id') id: string,
@Headers('idempotency-key') k: string | undefined,
@Res({ passthrough: true }) res: Response,
) {
const r = await this.service.writePolicy(
w.id,
req.auth.userId,
key(k),
null,
parse(z.uuid(), id),
'default',
null,
null,
);
res.status(r.status);
return r.body;
}
@Delete('pricing-policies/:id') async archive(
@CurrentWorkspace() w: Workspace,
@Req() req: AuthenticatedRequest,
@Param('id') id: string,
@Headers('idempotency-key') k: string | undefined,
) {
return (
await this.service.writePolicy(
w.id,
req.auth.userId,
key(k),
null,
parse(z.uuid(), id),
'archive',
null,
null,
)
).body;
}
}
+21
View File
@@ -0,0 +1,21 @@
import { CommissionImportController } from './commission-import.controller.js';
import { CommissionImportService } from './commission-import.service.js';
import { Module } from '@nestjs/common';
import { IdempotencyService } from '../common/idempotency.service.js';
import { PricingController } from './pricing.controller.js';
import { PricingService } from './pricing.service.js';
import { PricingAdminController } from './pricing-admin.controller.js';
import { PricingAdminService } from './pricing-admin.service.js';
import { SystemAdminGuard } from '../platform-catalog-admin/system-admin.guard.js';
@Module({
controllers: [PricingController, PricingAdminController, CommissionImportController],
providers: [
PricingService,
CommissionImportService,
PricingAdminService,
SystemAdminGuard,
IdempotencyService,
],
exports: [PricingService],
})
export class PricingModule {}
+867
View File
@@ -0,0 +1,867 @@
import { readCommissionRules } from './commission-rules.js';
import type { CommissionRulesResult } from '@atlas/contracts';
import { Inject, Injectable } from '@nestjs/common';
import { randomUUID } from 'node:crypto';
import type { Database, DatabaseTransaction } from '@atlas/database';
import type {
PricingCatalog,
PricingPolicy,
PricingPolicyRequest,
PricingPreview,
PricingPreviewRequest,
PricingParameters,
SaveProductEditorDraftRequest,
} from '@atlas/contracts';
import { pricingPreviewRequestSchema } from '@atlas/contracts';
import { DATABASE } from '../database/database.module.js';
import { APP_CONFIG, type AppConfig } from '../config/app-config.module.js';
import { ApiError } from '../common/api-error.js';
import { IdempotencyService } from '../common/idempotency.service.js';
import {
calculatePricing,
D,
fingerprint,
normalizeInput,
type CommissionRule,
type LogisticsRule,
} from './pricing-engine.js';
type Tx = DatabaseTransaction;
type Store = {
id: string;
name: string;
platform_id: string;
currency_code: string;
};
type PolicyRow = {
id: string;
store_id: string;
name: string;
note: string | null;
revision: number;
is_default: boolean;
rate_card_id: string;
default_transport_method_id: string | null;
domestic_shipping_and_label_fee_cny: string;
ad_rate: string;
last_mile_rate: string;
withdrawal_rate: string;
other_rate: string;
desired_markup_rate: string;
list_price_discount_rate: string;
allow_commission_override: boolean;
fx_strategy: string;
fixed_cny_to_rub_rate: string | null;
transport_ids: string[];
};
const active = `status='active' AND effective_from<=now() AND (effective_to IS NULL OR effective_to>now())`;
@Injectable()
export class PricingService {
constructor(
@Inject(DATABASE) private readonly db: Database,
@Inject(IdempotencyService)
private readonly idempotency: IdempotencyService,
@Inject(APP_CONFIG) private readonly config: AppConfig,
) {}
private get usableCardCondition() {
const reviewed = `(${active} AND reviewed_at IS NOT NULL AND reviewed_by_user_id IS NOT NULL)`;
return this.config.pricingAllowDraftRates
? `(${reviewed} OR status='draft')`
: reviewed;
}
async store(tx: Tx, id: string): Promise<Store> {
const r = await tx.query<Store>(
`SELECT id,name,platform_id,currency_code FROM atlas.stores WHERE id=$1 AND deleted_at IS NULL`,
[id],
);
if (!r.rows[0])
throw new ApiError(404, 'STORE_NOT_FOUND', '门店不存在');
if (!['CNY', 'RUB'].includes(r.rows[0].currency_code))
throw new ApiError(
422,
'PRICING_CURRENCY_UNSUPPORTED',
'定价仅支持 CNY / RUB 门店',
);
return r.rows[0];
}
async policies(tx: Tx, storeId: string): Promise<PricingPolicy[]> {
const result = await tx.query<PolicyRow>(
`SELECT p.*,ARRAY(SELECT transport_method_id FROM atlas.pricing_policy_transport_methods t WHERE t.pricing_policy_id=p.id AND is_enabled ORDER BY sort_order,transport_method_id) AS transport_ids FROM atlas.pricing_policies p WHERE p.store_id=$1 AND p.status='active' ORDER BY p.is_default DESC,p.name`,
[storeId],
);
return result.rows.map((p) => ({
id: p.id,
store_id: p.store_id,
name: p.name,
note: p.note,
revision: p.revision,
is_default: p.is_default,
allow_commission_override: p.allow_commission_override,
parameters: {
pricing_policy_id: p.id,
rate_card_id: p.rate_card_id,
transport_method_ids: p.transport_ids,
default_transport_method_id: p.default_transport_method_id,
domestic_shipping_and_label_fee_cny:
p.domestic_shipping_and_label_fee_cny,
ad_rate: p.ad_rate,
last_mile_rate: p.last_mile_rate,
withdrawal_rate: p.withdrawal_rate,
other_rate: p.other_rate,
desired_markup_rate: p.desired_markup_rate,
list_price_discount_rate: p.list_price_discount_rate,
mode: 'target_markup',
selling_price: null,
temporary_commission_rate: null,
fx:
p.fx_strategy === 'fixed'
? {
mode: 'policy_fixed',
rate: p.fixed_cny_to_rub_rate!,
}
: { mode: 'system' },
},
}));
}
async commissionRules(workspaceId: string, storeId: string, categoryId?: string, externalId?: string): Promise<CommissionRulesResult> {
return this.db.workspaceTransaction(workspaceId, async tx => {
const store = await this.store(tx, storeId);
const categories = (await tx.query<{id:string}>(`SELECT id FROM atlas.platform_product_categories WHERE platform_id=$1 AND is_active AND ($2::uuid IS NULL OR id=$2) AND ($3::text IS NULL OR external_category_id=$3) ORDER BY id`, [store.platform_id, categoryId ?? null, externalId ?? null])).rows;
if (!categories.length) throw new ApiError(422, 'PRICING_CATEGORY_REQUIRED', '请选择当前平台的有效类目');
let resolvedCategoryId = categories[0].id;
// 外部类目下可以有多个类型,但不能将相互冲突的映射折叠成一个费率。
if (externalId) {
const mapped = await tx.query<{id:string; platform_product_category_id:string}>(`SELECT DISTINCT commission_category_id AS id,platform_product_category_id FROM atlas.platform_product_category_commission_mappings WHERE platform_id=$1 AND platform_product_category_id=ANY($2::uuid[]) AND fulfillment='rfbs' AND market_code='RU' AND status='active' AND effective_from<=now() AND (effective_to IS NULL OR effective_to>now())`,[store.platform_id,categories.map(c=>c.id)]);
if (new Set(mapped.rows.map(row => row.id)).size>1) throw new ApiError(422,'COMMISSION_MAPPING_CONFLICT','此类目下的商品类型使用不同规则,请指定具体商品类型');
resolvedCategoryId = mapped.rows[0]?.platform_product_category_id ?? resolvedCategoryId;
}
const {commissions} = await readCommissionRules(tx,store.platform_id,resolvedCategoryId,'rfbs');
if (!commissions.length) throw new ApiError(422,'PRICING_COMMISSION_UNAVAILABLE','该类目没有有效佣金规则');
const first=commissions[0];
return {category_name:first.category_name ?? '',currency_code:first.currency_code,version:first.version,source:first.source ?? 'manual_rule',effective_from:new Date(first.effective_from!).toISOString(),bands:commissions.map(c=>({id:c.id!,min_price:c.min_price,max_price:c.max_price,min_inclusive:c.min_inclusive,max_inclusive:c.max_inclusive,commission_rate:c.commission_rate}))};
});
}
async categories(workspaceId: string, storeId: string, q: string) {
return this.db.workspaceTransaction(workspaceId, async (tx) => {
const store = await this.store(tx, storeId);
return (
await tx.query<{ id: string; name: string; path: string }>(
`SELECT id,name,path FROM atlas.platform_product_categories WHERE platform_id=$1 AND is_active AND (name ILIKE $2 OR path ILIKE $2) ORDER BY path LIMIT 50`,
[store.platform_id, `%${q}%`],
)
).rows;
});
}
async exchange(tx: Tx) {
const r = await tx.query<{
id: string;
rate: string;
effective_on: string;
stale: boolean;
}>(
`SELECT id,rate::text,effective_on::text,(effective_on::timestamp AT TIME ZONE 'Europe/Moscow')<now()-interval '72 hours' AS stale FROM atlas.exchange_rates WHERE base_currency_code='CNY' AND quote_currency_code='RUB' AND source='cbr' AND kind='official_reference' ORDER BY effective_on DESC,fetched_at DESC LIMIT 1`,
);
return r.rows[0] ?? null;
}
async catalog(workspace: string, storeId: string): Promise<PricingCatalog> {
return this.db.workspaceTransaction(workspace, async (tx) => {
const store = await this.store(tx, storeId);
const cards = await tx.query<{
id: string;
name: string;
version: string;
development_only: boolean;
transports: Array<{ id: string; name: string }>;
}>(
`SELECT c.id,c.source_name AS name,c.version,(c.status='draft') AS development_only,coalesce((SELECT jsonb_agg(jsonb_build_object('id',t.id,'name',t.name) ORDER BY t.name) FROM atlas.logistics_transport_methods t WHERE t.rate_card_id=c.id AND t.status='active'),'[]') AS transports FROM atlas.logistics_rate_cards c WHERE c.platform_id=$1 AND c.fulfillment='rfbs' AND c.market_code='RU' AND ${this.usableCardCondition} ORDER BY c.status='active' DESC,c.effective_from DESC`,
[store.platform_id],
);
const pendingCards = await tx.query<{
id: string;
name: string;
version: string;
status: 'draft';
}>(
`SELECT id,source_name AS name,version,status FROM atlas.logistics_rate_cards WHERE platform_id=$1 AND fulfillment='rfbs' AND market_code='RU' AND status='draft' ORDER BY created_at DESC`,
[store.platform_id],
);
return {
store,
rate_cards: cards.rows,
pending_rate_cards: pendingCards.rows,
policies: await this.policies(tx, storeId),
exchange_rate: await this.exchange(tx),
};
});
}
/** 保存确认时复读规则;草稿卡是否参与新报价由配置控制。 */
async rules(tx: Tx, input: PricingPreviewRequest, store: Store) {
const card = (
await tx.query<{ id: string; version: string; status: string; updated_at: Date }>(
`SELECT * FROM atlas.logistics_rate_cards WHERE id=$1 AND platform_id=$2 AND fulfillment=$3 AND market_code='RU' AND ${this.usableCardCondition} FOR SHARE`,
[
input.pricing.rate_card_id,
store.platform_id,
input.fulfillment,
],
)
).rows[0];
if (!card)
throw new ApiError(
422,
'PRICING_RULES_UNAVAILABLE',
'没有可用的物流费率,请检查费率卡状态和草稿卡使用配置',
);
const category = await tx.query(
`SELECT id FROM atlas.platform_product_categories WHERE id=$1 AND platform_id=$2 AND is_active`,
[input.platform_product_category_id, store.platform_id],
);
if (!category.rowCount)
throw new ApiError(
422,
'PRICING_CATEGORY_REQUIRED',
'请选择当前平台的有效类目',
);
if (input.pricing.pricing_policy_id)
await tx.query(
'SELECT id FROM atlas.pricing_policies WHERE id=$1 FOR SHARE',
[input.pricing.pricing_policy_id],
);
const policy = input.pricing.pricing_policy_id
? (await this.policies(tx, store.id)).find(
(p) => p.id === input.pricing.pricing_policy_id,
)
: null;
if (
input.pricing.pricing_policy_id &&
(!policy || policy.parameters.rate_card_id !== card.id)
)
throw new ApiError(
422,
'PRICING_POLICY_UNAVAILABLE',
'方案不可用或费率卡不匹配',
);
const logistics = (
await tx.query<LogisticsRule>(
`SELECT t.*,r.id,r.product_type_id,r.transport_method_id,m.name AS transport_name,t.name AS product_type_name,r.fixed_fee_cny,r.per_billable_kg_fee_cny,r.min_charge_cny,r.surcharge_rules,r.updated_at AS rule_updated_at,m.updated_at AS transport_updated_at FROM atlas.logistics_rate_rules r JOIN atlas.logistics_product_types t ON t.id=r.product_type_id JOIN atlas.logistics_transport_methods m ON m.id=r.transport_method_id AND m.rate_card_id=t.rate_card_id WHERE t.rate_card_id=$1 AND r.status=$3 AND m.status='active' AND m.id=ANY($2::uuid[]) ORDER BY r.id FOR SHARE OF r,t,m`,
[card.id, input.pricing.transport_method_ids, card.status],
)
).rows;
const official = await readCommissionRules(tx, store.platform_id, input.platform_product_category_id, input.fulfillment);
const mapping = official.mapping;
let commissions = official.commissions;
if (
!commissions.length &&
input.pricing.temporary_commission_rate !== null &&
(!policy || policy.allow_commission_override)
)
commissions = [
{
id: null,
currency_code: 'RUB',
min_price: null,
max_price: null,
min_inclusive: true,
max_inclusive: true,
commission_rate: input.pricing.temporary_commission_rate,
version: 'temporary',
},
];
if (!commissions.length)
throw new ApiError(
422,
'PRICING_COMMISSION_UNAVAILABLE',
'该类目尚无有效佣金规则;请补充已核实的临时佣金率,并使用允许临时佣金的方案或本次自定义参数',
);
if (
commissions.some((c) => c.id) &&
input.pricing.temporary_commission_rate !== null
)
throw new ApiError(
422,
'PRICING_COMMISSION_OVERRIDE_FORBIDDEN',
'已有正式佣金规则,不能临时覆盖',
);
return {
card,
policy,
logistics,
commissions,
hash: fingerprint({
card,
policy,
logistics,
commissions,
mapping,
}),
};
}
async preview(
workspaceId: string,
userId: string,
key: string,
input: PricingPreviewRequest,
) {
return this.idempotency.run(
{
workspaceId,
userId,
key,
method: 'POST',
path: '/api/pricing/quote-previews',
request: input,
},
async (tx) => {
const store = await this.store(tx, input.store_id);
if (input.binding) {
const binding = input.binding;
const profile = (
await tx.query(
`SELECT id FROM atlas.product_platform_profiles WHERE id=$1 AND platform_id=$2`,
[binding.profile_id, store.platform_id],
)
).rows[0];
if (!profile)
throw new ApiError(
404,
'PROFILE_NOT_FOUND',
'商品发布配置不存在',
);
if (binding.variant_id) {
const listing = (
await tx.query<{ id: string; status: string; store_id: string }>(
`SELECT l.id,l.status,l.store_id FROM atlas.product_variant_platform_listings l JOIN atlas.product_variants v ON v.id=l.product_variant_id WHERE l.profile_id=$1 AND l.product_variant_id=$2 AND v.deleted_at IS NULL`,
[binding.profile_id, binding.variant_id],
)
).rows[0];
if (!listing)
throw new ApiError(
422,
'PRICING_VARIANT_UNAVAILABLE',
'SKU 不存在或已删除',
);
if (listing.status === 'imported' && listing.store_id !== store.id)
throw new ApiError(
422,
'PRICING_VARIANT_STORE_MISMATCH',
'已上架 SKU 请按首次导入店铺试算',
);
}
}
const rules = await this.rules(tx, input, store),
exchange = await this.exchange(tx);
let fx: string, origin: string;
if (input.pricing.fx.mode === 'policy_fixed') {
const fixed = rules.policy?.parameters.fx;
if (fixed?.mode !== 'policy_fixed' || !fixed.rate)
throw new ApiError(
422,
'PRICING_FX_REQUIRED',
'方案没有固定汇率',
);
fx = fixed.rate;
origin = 'policy_fixed';
} else if (input.pricing.fx.mode === 'one_time_override') {
fx = input.pricing.fx.rate!;
origin = 'quote_override';
} else {
if (!exchange)
throw new ApiError(
422,
'PRICING_FX_UNAVAILABLE',
'尚无 CBR 汇率,请稍后重试或明确填写本次汇率',
);
fx = D(exchange.rate).toDecimalPlaces(8).toString();
origin = 'system_rate';
}
const normalized = normalizeInput(input),
id = randomUUID(),
expires = new Date(Date.now() + 15 * 60_000).toISOString();
const result: PricingPreview = {
preview_id: id,
expires_at: expires,
input_hash: fingerprint(normalized),
...calculatePricing(
normalized,
store.currency_code,
fx,
rules.logistics,
rules.commissions,
),
context: {
currency_code: store.currency_code,
fx_rate: fx,
fx_origin: origin,
rule_version: rules.card.version,
development_only: rules.card.status === 'draft',
exchange_rate:
origin === 'system_rate' ? exchange : null,
warnings:
origin === 'system_rate' && exchange?.stale
? ['CBR 汇率超过 72 小时,请确认参考日期']
: [],
},
};
await tx.query(
`INSERT INTO atlas.pricing_previews(id,workspace_id,user_id,store_id,platform_id,input,input_hash,rules_hash,result,expires_at) VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)`,
[
id,
workspaceId,
userId,
store.id,
store.platform_id,
JSON.stringify(normalized),
result.input_hash,
rules.hash,
JSON.stringify(result),
expires,
],
);
return { status: 201, body: { data: result } };
},
);
}
async writePolicy(
workspaceId: string,
userId: string,
key: string,
storeId: string | null,
id: string | null,
action: 'create' | 'update' | 'default' | 'archive',
input: PricingPolicyRequest | null,
revision: number | null,
) {
return this.idempotency
.run(
{
workspaceId,
userId,
key,
method:
action === 'update'
? 'PATCH'
: action === 'archive'
? 'DELETE'
: 'POST',
path: id
? `/api/pricing-policies/${id}/${action}`
: `/api/stores/${storeId}/pricing-policies`,
request: { input, revision },
},
async (tx) => {
let current:
{ store_id: string; revision: number } | undefined;
if (id) {
current = (
await tx.query<{
store_id: string;
revision: number;
}>(
`SELECT store_id,revision FROM atlas.pricing_policies WHERE id=$1 AND status='active'`,
[id],
)
).rows[0];
if (!current)
throw new ApiError(
404,
'PRICING_POLICY_NOT_FOUND',
'方案不存在',
);
storeId = current.store_id;
}
const store = await this.store(tx, storeId!);
await tx.query(
`SELECT id FROM atlas.stores WHERE id=$1 FOR UPDATE`,
[store.id],
);
if (id)
current = (
await tx.query<{
store_id: string;
revision: number;
}>(
`SELECT store_id,revision FROM atlas.pricing_policies WHERE id=$1 AND status='active' FOR UPDATE`,
[id],
)
).rows[0];
if (id && !current)
throw new ApiError(
404,
'PRICING_POLICY_NOT_FOUND',
'方案不存在',
);
if (action === 'update' && current?.revision !== revision)
throw new ApiError(
409,
'VERSION_CONFLICT',
'方案已更新,请刷新后重试',
);
if (action === 'default') {
await tx.query(
`UPDATE atlas.pricing_policies SET is_default=false,revision=revision+1,updated_at=now() WHERE store_id=$1 AND is_default AND id<>$2`,
[store.id, id],
);
await tx.query(
`UPDATE atlas.pricing_policies SET is_default=true,revision=revision+1,updated_at=now() WHERE id=$1`,
[id],
);
} else if (action === 'archive')
await tx.query(
`UPDATE atlas.pricing_policies SET status='archived',is_default=false,revision=revision+1,updated_at=now() WHERE id=$1`,
[id],
);
else {
const p = input!.parameters;
if (p.fx.mode === 'policy_fixed' && !p.fx.rate)
throw new ApiError(
422,
'PRICING_FX_REQUIRED',
'固定汇率不能为空',
);
if (
p.default_transport_method_id &&
!p.transport_method_ids.includes(
p.default_transport_method_id,
)
)
throw new ApiError(
422,
'PRICING_TRANSPORT_INVALID',
'默认运输方式必须包含在比较列表中',
);
const card = (
await tx.query(
`SELECT id FROM atlas.logistics_rate_cards WHERE id=$1 AND platform_id=$2 AND fulfillment='rfbs' AND market_code='RU' AND ${this.usableCardCondition}`,
[p.rate_card_id, store.platform_id],
)
).rowCount;
const transports = (
await tx.query(
`SELECT id FROM atlas.logistics_transport_methods WHERE rate_card_id=$1 AND status='active' AND id=ANY($2::uuid[])`,
[p.rate_card_id, p.transport_method_ids],
)
).rowCount;
if (
!card ||
transports !== new Set(p.transport_method_ids).size
)
throw new ApiError(
422,
'PRICING_RULES_UNAVAILABLE',
'费率卡或运输方式不可用',
);
const values = [
input!.name,
input!.note,
p.rate_card_id,
p.default_transport_method_id,
p.domestic_shipping_and_label_fee_cny,
p.ad_rate,
p.last_mile_rate,
p.withdrawal_rate,
p.other_rate,
p.desired_markup_rate,
p.list_price_discount_rate,
input!.allow_commission_override,
p.fx.mode === 'policy_fixed' ? 'fixed' : 'system',
p.fx.mode === 'policy_fixed' ? p.fx.rate : null,
];
if (action === 'create') {
id = randomUUID();
await tx.query(
`INSERT INTO atlas.pricing_policies(name,note,rate_card_id,default_transport_method_id,domestic_shipping_and_label_fee_cny,ad_rate,last_mile_rate,withdrawal_rate,other_rate,desired_markup_rate,list_price_discount_rate,allow_commission_override,fx_strategy,fixed_cny_to_rub_rate,id,workspace_id,store_id,platform_id) VALUES(${values.map((_, i) => `$${i + 1}`).join(',')},$15,$16,$17,$18)`,
[
...values,
id,
workspaceId,
store.id,
store.platform_id,
],
);
} else
await tx.query(
`UPDATE atlas.pricing_policies SET name=$1,note=$2,rate_card_id=$3,default_transport_method_id=$4,domestic_shipping_and_label_fee_cny=$5,ad_rate=$6,last_mile_rate=$7,withdrawal_rate=$8,other_rate=$9,desired_markup_rate=$10,list_price_discount_rate=$11,allow_commission_override=$12,fx_strategy=$13,fixed_cny_to_rub_rate=$14,revision=revision+1,updated_at=now() WHERE id=$15`,
[...values, id],
);
await tx.query(
`DELETE FROM atlas.pricing_policy_transport_methods WHERE pricing_policy_id=$1`,
[id],
);
for (const [i, t] of [
...new Set(p.transport_method_ids),
].entries())
await tx.query(
`INSERT INTO atlas.pricing_policy_transport_methods(workspace_id,pricing_policy_id,transport_method_id,sort_order) VALUES($1,$2,$3,$4)`,
[workspaceId, id, t, i],
);
}
return {
status: action === 'create' ? 201 : 200,
body: {
data:
action === 'archive'
? { id, archived: true }
: (await this.policies(tx, store.id)).find(
(p) => p.id === id,
),
},
};
},
)
.catch((error: unknown) => {
if (
error &&
typeof error === 'object' &&
'code' in error &&
error.code === '23505'
)
throw new ApiError(
409,
'PRICING_POLICY_NAME_CONFLICT',
'此门店已存在同名方案,请使用其他名称',
);
throw error;
});
}
/** 必须在 editor-draft 的现有事务中调用,任何确认失败使商品修改一起回滚。 */
async persistDraft(
tx: Tx,
workspaceId: string,
userId: string,
profileId: string,
input: SaveProductEditorDraftRequest,
created: Array<{ client_key: string; id: string }>,
) {
for (const v of input.variants) {
const variantId =
v.id ?? created.find((c) => c.client_key === v.client_key)!.id;
const listing = (
await tx.query<{
id: string;
status: string;
store_id: string;
pricing_draft: PricingParameters | null;
pricing_quote_id: string | null;
}>(
`SELECT id,status,store_id,pricing_draft,pricing_quote_id FROM atlas.product_variant_platform_listings WHERE profile_id=$1 AND product_variant_id=$2 FOR UPDATE`,
[profileId, variantId],
)
).rows[0];
// 已上架 SKU 只有显式应用新报价时更新本地记录,普通草稿保存保留历史报价。
if (listing.status === 'imported' && !v.pricing_selection) continue;
const state = (
await tx.query<{
product_id: string;
platform_id: string;
store_id: string;
currency_code: string;
category_id: string;
fulfillment: 'rfbs';
purchase_cost_cny: string;
package: PricingPreviewRequest['package'];
}>(
`SELECT v.product_id,p.platform_id,s.id AS store_id,s.currency_code,p.platform_product_category_id AS category_id,p.fulfillment,v.purchase_cost_cny::text,jsonb_build_object('shipping_weight_g',coalesce(v.shipping_weight_g,g.default_shipping_weight_g)::text,'length_mm',coalesce(v.package_length_mm,g.default_package_length_mm)::text,'width_mm',coalesce(v.package_width_mm,g.default_package_width_mm)::text,'height_mm',coalesce(v.package_height_mm,g.default_package_height_mm)::text,'status',coalesce(v.package_spec_status,g.package_spec_status)) AS package FROM atlas.product_variants v JOIN atlas.products g ON g.id=v.product_id JOIN atlas.product_platform_profiles p ON p.id=$1 JOIN atlas.stores s ON s.id=coalesce($3::uuid,p.target_store_id) WHERE v.id=$2`,
[
profileId,
variantId,
listing.status === 'imported' ? listing.store_id : null,
],
)
).rows[0];
const params =
v.pricing_draft === undefined
? listing.pricing_draft
: v.pricing_draft;
const parsed = pricingPreviewRequestSchema.safeParse({
store_id: state.store_id,
platform_product_category_id: state.category_id,
fulfillment: state.fulfillment,
purchase_cost_cny:
state.purchase_cost_cny === null
? null
: D(state.purchase_cost_cny).toFixed(2),
package: state.package,
pricing: params,
binding: {
profile_id: profileId,
variant_id: v.id,
client_key: v.id ? null : v.client_key,
},
});
const normalized = parsed.success
? normalizeInput(parsed.data)
: null;
const fail = () => {
throw new ApiError(
409,
'PRICING_PREVIEW_STALE',
`${v.display_name ?? 'SKU'} 的报价已失效,请重新试算或仅保存草稿`,
[
{
field: `variants.${variantId}.pricing_selection`,
code: 'PRICING_PREVIEW_STALE',
message: '报价输入、规则或有效期已变化',
},
],
);
};
if (!v.pricing_selection) {
// 旧报价保留供审计,当前引用仅在输入和归属仍一致时保持有效。
const old = listing.pricing_quote_id
? (
await tx.query<{
input_snapshot: {
input: PricingPreviewRequest;
context: PricingPreview['context'];
};
}>(
`SELECT input_snapshot FROM atlas.pricing_quotes WHERE id=$1`,
[listing.pricing_quote_id],
)
).rows[0]
: null;
const same =
normalized &&
old &&
old.input_snapshot.context.currency_code ===
state.currency_code &&
fingerprint({ ...normalized, binding: null }) ===
fingerprint({
...old.input_snapshot.input,
binding: null,
});
await tx.query(
`UPDATE atlas.product_variant_platform_listings SET pricing_draft=$2,pricing_valid=$3,currency_code=$4,pricing_quote_id=CASE WHEN store_id=$5 THEN pricing_quote_id ELSE NULL END WHERE id=$1`,
[
listing.id,
params ? JSON.stringify(params) : null,
Boolean(same),
state.currency_code,
old?.input_snapshot.input.store_id ?? state.store_id,
],
);
continue;
}
if (!normalized) fail();
const selection = v.pricing_selection;
const preview = (
await tx.query<{
input: PricingPreviewRequest;
input_hash: string;
rules_hash: string;
result: PricingPreview;
valid: boolean;
}>(
`SELECT input,input_hash,rules_hash,result,expires_at>now() AS valid FROM atlas.pricing_previews WHERE id=$1 AND user_id=$2 FOR SHARE`,
[selection.preview_id, userId],
)
).rows[0];
if (
!preview ||
!preview.valid ||
selection.input_hash !== preview.input_hash ||
fingerprint(normalized) !== preview.input_hash
)
fail();
const store = await this.store(tx, state.store_id);
let currentRules: Awaited<ReturnType<PricingService['rules']>>;
try {
currentRules = await this.rules(tx, normalized!, store);
} catch (error) {
if (error instanceof ApiError && error.getStatus() === 422)
return fail();
throw error;
}
if (
currentRules.hash !== preview.rules_hash ||
preview.result.context.currency_code !== state.currency_code
)
fail();
const option = preview.result.options.find(
(o) => o.id === selection.option_id,
);
if (!option || !option.can_confirm) fail();
const o = option!,
quoteId = randomUUID(),
selectedId = randomUUID();
const savedInput = {
...normalized!,
binding: {
profile_id: profileId,
variant_id: variantId,
client_key: null,
},
};
await tx.query(
`INSERT INTO atlas.pricing_quotes(id,workspace_id,store_id,platform_id,pricing_policy_id,product_variant_id,product_id,profile_id,platform_product_category_id,fulfillment,purchase_cost_cny,package_weight_kg,package_length_cm,package_width_cm,package_height_cm,fx_cny_to_rub,fx_rate_origin,input_snapshot,status,selected_option_id,calculated_at,confirmed_at,created_by_user_id,input_hash) VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,'confirmed',$19,now(),now(),$20,$21)`,
[
quoteId,
workspaceId,
state.store_id,
state.platform_id,
params!.pricing_policy_id,
variantId,
state.product_id,
profileId,
state.category_id,
state.fulfillment,
state.purchase_cost_cny,
D(state.package.shipping_weight_g).div(1000).toString(),
D(state.package.length_mm).div(10).toString(),
D(state.package.width_mm).div(10).toString(),
D(state.package.height_mm).div(10).toString(),
preview.result.context.fx_rate,
preview.result.context.fx_origin,
JSON.stringify({
input: savedInput,
context: preview.result.context,
rules: currentRules,
}),
selectedId,
userId,
fingerprint(savedInput),
],
);
for (const candidate of preview.result.options) {
const f = candidate.fees,
fx = D(preview.result.context.fx_rate);
await tx.query(
`INSERT INTO atlas.pricing_quote_options(id,workspace_id,quote_id,logistics_rate_rule_id,commission_rule_id,logistics_product_type_id,transport_method_id,merchandise_value_cny,selling_price_rub,list_price_rub,billable_weight_kg,logistics_fee_cny,commission_fee_rub,domestic_fee_cny,ad_fee_rub,last_mile_fee_rub,withdrawal_fee_rub,other_fee_rub,profit_rub,calculation_snapshot) VALUES(${Array.from({ length: 20 }, (_, i) => `$${i + 1}`).join(',')})`,
[
candidate.id === o.id ? selectedId : randomUUID(),
workspaceId,
quoteId,
candidate.logistics_rate_rule_id,
candidate.commission_rule_id,
candidate.product_type_id,
candidate.transport_method_id,
candidate.merchandise_value_cny,
candidate.selling_price_rub,
candidate.list_price_rub,
candidate.billable_weight_kg,
f.logistics_cny,
fx.mul(f.commission_cny).toFixed(4),
f.domestic_cny,
fx.mul(f.ad_cny).toFixed(4),
fx.mul(f.last_mile_cny).toFixed(4),
fx.mul(f.withdrawal_cny).toFixed(4),
fx.mul(f.other_cny).toFixed(4),
candidate.profit_rub,
JSON.stringify(candidate),
],
);
}
await tx.query(
`UPDATE atlas.product_variant_platform_listings SET pricing_quote_id=$2,pricing_draft=$3,pricing_valid=true,selling_price=$4,list_price=$5,currency_code=$6 WHERE id=$1`,
[
listing.id,
quoteId,
JSON.stringify(params),
o.selling_price,
o.list_price,
o.currency_code,
],
);
}
}
}
@@ -0,0 +1,233 @@
import { Inject, Injectable } from '@nestjs/common';
import type { ProductAiTask, ProductEditorSnapshot } from '@atlas/contracts';
import type { Database } from '@atlas/database';
import { ApiError } from '../common/api-error.js';
import { IdempotencyService } from '../common/idempotency.service.js';
import { DATABASE } from '../database/database.module.js';
import { ProductEditorService } from './product-editor.service.js';
@Injectable()
export class ProductAiService {
public constructor(
@Inject(DATABASE) private readonly database: Database,
@Inject(IdempotencyService)
private readonly idempotency: IdempotencyService,
@Inject(ProductEditorService)
private readonly editor: ProductEditorService,
) {}
/** 固化当前编辑快照并在同一事务写入任务与 Outbox。 */
async create(
workspaceId: string,
userId: string,
profileId: string,
platformId: string,
variantId: string | null,
key: string,
hint: string | undefined,
) {
const profile = await this.database.workspaceTransaction(
workspaceId,
async (tx) => {
const row = await tx.query<{ product_id: string }>(
`SELECT product_id FROM atlas.product_platform_profiles
WHERE id = $1 AND platform_id = $2`,
[profileId, platformId],
);
if (!row.rows[0])
throw new ApiError(
404,
'PROFILE_NOT_FOUND',
'商品发布配置不存在',
);
return row.rows[0];
},
);
const snapshot = await this.editor.read(
workspaceId,
profile.product_id,
platformId,
);
if (snapshot.profile.id !== profileId)
throw new ApiError(
409,
'PROFILE_CHANGED',
'商品发布配置已变化,请刷新页面',
);
if (
variantId &&
!snapshot.variants.some((variant) => variant.id === variantId)
)
throw new ApiError(404, 'VARIANT_NOT_FOUND', 'SKU 不属于当前商品');
const kind = variantId ? 'sku_copy' : 'autofill';
const path = variantId
? `/api/product-platform-profiles/${profileId}/variants/${variantId}/ai/copy`
: `/api/product-platform-profiles/${profileId}/ai/autofills`;
return this.idempotency.run(
{
workspaceId,
userId,
method: 'POST',
path,
key,
request: { platform_id: platformId, hint: hint ?? null },
},
async (tx) => {
const current = await tx.query<{
product_revision: number;
profile_revision: number;
source_snapshot_revision: number;
}>(
`SELECT product.revision AS product_revision,
profile.revision AS profile_revision,
item.source_snapshot_revision
FROM atlas.product_platform_profiles profile
JOIN atlas.products product ON product.id = profile.product_id
JOIN atlas.collection_items item ON item.id = product.collection_item_id
WHERE profile.id = $1 AND profile.platform_id = $2
FOR UPDATE OF profile, product, item`,
[profileId, platformId],
);
const row = current.rows[0];
if (
!row ||
Number(row.product_revision) !==
snapshot.product.revision ||
Number(row.profile_revision) !==
snapshot.profile.revision ||
Number(row.source_snapshot_revision) !==
snapshot.source_material.source_snapshot_revision
) {
throw new ApiError(
409,
'AI_INPUT_CHANGED',
'商品资料已变化,请重新点击 AI 填报',
);
}
const input = taskInput(snapshot, variantId, hint);
const inserted = await tx.query<{ id: string }>(
`INSERT INTO atlas.product_ai_tasks
(workspace_id, product_id, profile_id, variant_id, kind, input_snapshot, created_by_user_id)
VALUES (current_setting('app.workspace_id')::uuid, $1, $2, $3, $4, $5::jsonb, $6)
RETURNING id`,
[
profile.product_id,
profileId,
variantId,
kind,
JSON.stringify(input),
userId,
],
);
const taskId = inserted.rows[0].id;
await tx.query(
`INSERT INTO atlas.outbox_events
(workspace_id, aggregate_type, aggregate_id, event_type, event_payload, deduplication_key)
VALUES (current_setting('app.workspace_id')::uuid, 'product_ai_task', $1,
'product_ai_task.requested', $2::jsonb, $3)`,
[
taskId,
JSON.stringify({ task_id: taskId }),
`product_ai_task:${taskId}`,
],
);
return {
status: 202,
body: { data: { ai_task_id: taskId, status: 'queued' } },
};
},
);
}
/** 只返回已校验结果和输入版本,不暴露采集快照与模型提示词。 */
async read(
workspaceId: string,
taskId: string,
platformId: string,
): Promise<ProductAiTask> {
return this.database.workspaceTransaction(workspaceId, async (tx) => {
const result = await tx.query<{
id: string;
kind: 'autofill' | 'sku_copy' | 'attribute_fill';
status: ProductAiTask['status'];
variant_id: string | null;
input_snapshot: Record<string, unknown>;
result: ProductAiTask['result'];
error_code: string | null;
error_message: string | null;
}>(
`SELECT task.id, task.kind, task.status, task.variant_id,
task.input_snapshot, task.result, task.error_code, task.error_message
FROM atlas.product_ai_tasks task
JOIN atlas.product_platform_profiles profile ON profile.id = task.profile_id
WHERE task.id = $1 AND profile.platform_id = $2`,
[taskId, platformId],
);
const row = result.rows[0];
if (!row)
throw new ApiError(404, 'AI_TASK_NOT_FOUND', 'AI 任务不存在');
const input = row.input_snapshot as {
revision?: ProductAiTask['input_revision'];
};
return {
id: row.id,
kind: row.kind,
status: row.status,
input_revision: input.revision ?? {
product: 0,
profile: 0,
source_snapshot: 0,
},
variant_id: row.variant_id,
result:
row.status === 'succeeded' || row.status === 'partial'
? row.result
: null,
error_code: row.error_code,
error_message: row.error_message,
};
});
}
}
function taskInput(
snapshot: ProductEditorSnapshot,
variantId: string | null,
hint?: string,
) {
return {
revision: {
product: snapshot.product.revision,
profile: snapshot.profile.revision,
source_snapshot: snapshot.source_material.source_snapshot_revision,
},
platform_id: snapshot.profile.platform_id,
product_id: snapshot.product.id,
profile_id: snapshot.profile.id,
variant_id: variantId,
hint: hint ?? null,
source_material: snapshot.source_material,
common: snapshot.product.common,
profile: {
title: snapshot.profile.title,
tags: snapshot.profile.tags,
description: snapshot.profile.description,
category: snapshot.profile.category,
target_store_id: snapshot.profile.target_store.id,
},
variants: snapshot.variants.map((variant) => ({
id: variant.id,
display_name: variant.display_name,
source_sku: variant.source_sku,
specification_values: variant.specification_values,
listing: {
status: variant.listing.status,
offer_id: variant.listing.offer_id,
},
title: variant.title,
tags: variant.tags,
description: variant.description,
package_spec: variant.effective_package_spec,
})),
};
}
@@ -0,0 +1,33 @@
import type { ProductAttributeSchema } from '@atlas/contracts';
type AttributeDefinition = Omit<ProductAttributeSchema, 'editor_field'>;
type EditorField = Exclude<ProductAttributeSchema['editor_field'], null>;
/** Ozon 固定属性 ID 到编辑器内建字段的唯一映射,不使用任何名称或翻译文本判断。 */
const editorFieldByAttributeId: Readonly<Record<string, EditorField>> = {
'85': 'brand',
'9048': 'model',
'8229': 'category',
'23171': 'tags',
'11254': 'rich_content',
'4191': 'description',
};
/** 仅依据 Ozon 属性 ID 将类目属性绑定到编辑器内建字段。 */
export function editorFieldForAttributeId(
platformAttributeId: string,
): ProductAttributeSchema['editor_field'] {
return editorFieldByAttributeId[platformAttributeId] ?? null;
}
/** 为数据库中的原始类目属性补充前端和校验共用的内建字段绑定。 */
export function bindEditorFields(
definitions: AttributeDefinition[],
): ProductAttributeSchema[] {
return definitions.map((definition) => ({
...definition,
editor_field: editorFieldForAttributeId(
definition.platform_attribute_id,
),
}));
}
@@ -0,0 +1,76 @@
import type {
ProductCatalogCacheMetadata,
ProductCatalogCacheStatus,
} from '@atlas/contracts';
export type ProductCatalogResourceState = {
last_success_at: Date | string | null;
is_complete: boolean;
fresh_days: number;
expire_days: number;
refresh_run_id: string | null;
refresh_status: string | null;
};
/** 统一实现 14/21 天边界,供所有商品目录读取与发布预检复用。 */
export function productCatalogCacheStatus(
lastSuccessAt: Date | string | null,
freshDays: number,
expireDays: number,
now = Date.now(),
): ProductCatalogCacheStatus {
if (!lastSuccessAt) return 'missing';
const age = now - new Date(lastSuccessAt).getTime();
const day = 86_400_000;
if (age < freshDays * day) return 'fresh';
if (age <= expireDays * day) return 'stale';
return 'expired';
}
export function productCatalogResourceStatus(
state: ProductCatalogResourceState,
): ProductCatalogCacheStatus {
return productCatalogCacheStatus(
state.last_success_at,
state.fresh_days,
state.expire_days,
);
}
/** cache 元数据在编辑读取与发布预检之间保持同一形状和边界计算。 */
export function productCatalogCacheMetadata(
state: ProductCatalogResourceState,
status = productCatalogResourceStatus(state),
): ProductCatalogCacheMetadata {
const last = state.last_success_at ? new Date(state.last_success_at) : null;
return {
status,
last_success_at: last?.toISOString() ?? null,
fresh_until: last
? new Date(
last.getTime() + state.fresh_days * 86_400_000,
).toISOString()
: null,
expires_at: last
? new Date(
last.getTime() + state.expire_days * 86_400_000,
).toISOString()
: null,
is_complete: state.is_complete,
refresh_run_id: state.refresh_run_id,
refresh_status: state.refresh_status,
retry_after_ms:
status === 'expired' || status === 'missing' ? 1_000 : null,
};
}
export function productCatalogStateWithRun(
state: ProductCatalogResourceState,
run: { id: string; status: string },
): ProductCatalogResourceState {
return {
...state,
refresh_run_id: run.id,
refresh_status: run.status,
};
}
@@ -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}`;
}
@@ -0,0 +1,451 @@
import {
Body,
Controller,
Get,
Headers,
Inject,
Param,
Post,
Put,
Query,
Req,
Res,
} 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,
@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 result = await this.editor.enter(
workspace.id,
request.auth.userId,
requiredUuid('collection_item_id', collectionItemId),
requiredUuid('X-Atlas-Platform-Id', platformId),
requiredUuid('Idempotency-Key', key),
);
response.status(result.status);
return result.body;
}
/** 返回当前平台下完整的商品编辑读模型。 */
@Get('products/:productId/editor')
async read(
@Param('productId') productId: string,
@Headers('x-atlas-platform-id') platformId: string | undefined,
@CurrentWorkspace() workspace: Workspace,
) {
return {
data: await this.editor.read(
workspace.id,
requiredUuid('product_id', productId),
requiredUuid('X-Atlas-Platform-Id', platformId),
),
};
}
/** 用户主动创建 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,
@Headers('x-atlas-platform-id') platformId: string | undefined,
@CurrentWorkspace() workspace: Workspace,
) {
return {
data: await this.editor.categoryOptions(
workspace.id,
requiredUuid('profile_id', profileId),
requiredUuid('X-Atlas-Platform-Id', platformId),
query,
),
};
}
/** 返回所选类目的缓存动态属性定义。 */
@Get('product-platform-profiles/:profileId/attribute-schema')
async attributeSchema(
@Param('profileId') profileId: string,
@Query('category_id') categoryId: 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.validation(
workspace.id,
requiredUuid('profile_id', profileId),
requiredUuid('X-Atlas-Platform-Id', platformId),
),
};
}
/** 定向刷新本次首次上架依赖的目录资源,并返回最终发布预检。 */
@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,
@Query('category_id') categoryId: string | undefined,
@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 result = await this.editor.requestAttributeSchemaSync(
workspace.id,
request.auth.userId,
requiredUuid('profile_id', profileId),
requiredUuid('X-Atlas-Platform-Id', platformId),
requiredUuid('Idempotency-Key', key),
requiredUuid('category_id', categoryId),
);
response.status(result.status);
return result.body;
}
/** 从本地缓存中查询属性字典值。 */
@Get(
'product-platform-profiles/:profileId/attribute-dictionaries/:attributeId',
)
async attributeDictionary(
@Param('profileId') profileId: string,
@Param('attributeId') attributeId: string,
@Query('q') query: string | undefined,
@Query('cursor') cursor: string | undefined,
@Query('category_id') categoryId: 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.attributeDictionary(
workspace.id,
request.auth.userId,
requiredUuid('profile_id', profileId),
requiredUuid('X-Atlas-Platform-Id', platformId),
requiredUuid('attribute_id', attributeId),
query,
cursor,
optionalUuid('category_id', categoryId),
);
response.status(result.status);
return result.body;
}
/** 本地缓存不足时幂等地创建远程字典查询任务。 */
@Post(
'product-platform-profiles/:profileId/attribute-dictionaries/:attributeId/syncs',
)
async attributeDictionarySync(
@Param('profileId') profileId: string,
@Param('attributeId') attributeId: string,
@Query('category_id') categoryId: string | undefined,
@Query('q') query: string | undefined,
@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 result = await this.editor.requestAttributeDictionarySync(
workspace.id,
request.auth.userId,
requiredUuid('profile_id', profileId),
requiredUuid('X-Atlas-Platform-Id', platformId),
requiredUuid('Idempotency-Key', key),
requiredUuid('category_id', categoryId),
requiredUuid('attribute_id', attributeId),
query ?? '',
);
response.status(result.status);
return result.body;
}
/** 校验并原子保存商品及其上架草稿。 */
@Put('products/:productId/editor-draft')
async save(
@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 = saveProductEditorDraftRequestSchema.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.save(
workspace.id,
request.auth.userId,
requiredUuid('product_id', productId),
requiredUuid('X-Atlas-Platform-Id', platformId),
requiredUuid('Idempotency-Key', key),
parseEditorRevision(ifMatch),
parsed.data,
)
).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', '请求参数不符合要求', [
{ field, code: 'INVALID_FIELD', message: `${field} 必须是 UUID` },
]);
}
/** 将缺省查询参数保留为 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)
throw new ApiError(
400,
'IF_MATCH_REQUIRED',
'请求缺少或格式错误的 If-Match',
);
return { product: Number(match[1]), profile: Number(match[2]) };
}
@@ -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`;
@@ -0,0 +1,11 @@
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, 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')
: [];
}
+4 -3
View File
@@ -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');
}
+42
View File
@@ -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: '仓库同步操作已入队' })
+339 -1
View File
@@ -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: '已软删除模板并解除当前店铺绑定' })
+172 -11
View File
@@ -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;
}
+466
View File
@@ -0,0 +1,466 @@
import { beforeEach, afterEach, describe, it, expect, vi } from 'vitest';
import { randomUUID, randomInt } from 'node:crypto';
import { loadEnvFile } from 'node:process';
import { Client } from 'pg';
import type { Database, DatabaseTransaction } from '@atlas/database';
import type {
CommissionImportRequest,
CommissionImportPublish,
} from '@atlas/contracts';
import { CommissionImportService } from '../src/pricing/commission-import.service.js';
import { parseCommissionDocument } from '../src/pricing/commission-import-parser.js';
import { IdempotencyService } from '../src/common/idempotency.service.js';
import { PricingService } from '../src/pricing/pricing.service.js';
import { fingerprint } from '../src/pricing/pricing-engine.js';
import type { AppConfig } from '../src/config/app-config.module.js';
loadEnvFile(new URL('../../../.env', import.meta.url));
const makeDocument = (
parent = '900000001',
child = '900000002',
rates = ['12.00', '14.00', '20.50'],
) => ({
code: 1,
msg: '获取成功',
data: [
{
id: 1,
label: '测试分组',
value: '测试分组',
cate_id: parent,
children: [
{
id: '1.1',
label: '测试类目',
value: '测试类目',
cate_id: child,
children: rates.map((rate, i) => ({
id: `1.1.${i + 1}`,
label: `${['售价 ≤ 1500₽', '1500₽ < 售价 ≤ 5000₽', '售价 > 5000₽'][i]} (${rate}%)`,
value: `${i + 1},${rate}`,
})),
},
],
},
],
});
describe('佣金来源解析', () => {
it('保留三个精确区间并将百分数转换为比例', () => {
const c = parseCommissionDocument(makeDocument())[0];
expect(c.bands.map((b) => b.commission_rate)).toEqual([
'0.120000',
'0.140000',
'0.205000',
]);
expect(
c.bands.map((b) => [
b.min_price,
b.max_price,
b.min_inclusive,
b.max_inclusive,
]),
).toEqual([
['0', '1500', false, true],
['1500', '5000', false, true],
['5000', null, false, false],
]);
});
it('拒绝标签和值不一致、漏档、重复类目及超大文件', () => {
const d = makeDocument();
d.data[0].children[0].children[0].value = '1,15.00';
expect(() => parseCommissionDocument(d)).toThrow();
expect(() => parseCommissionDocument(makeDocument('1', '1'))).toThrow();
const missing = makeDocument();
missing.data[0].children[0].children.pop();
expect(() => parseCommissionDocument(missing)).toThrow();
expect(() =>
parseCommissionDocument({ x: 'a'.repeat(2 * 1024 * 1024) }),
).toThrow();
});
});
// 所有业务数据在测试事务内,结束一律回滚;不会发布或替换用户的实际佣金版本。
describe('佣金导入 PostgreSQL 集成', () => {
let client: Client,
db: Database,
service: CommissionImportService,
pricing: PricingService;
let user: string,
workspace: string,
platform: string,
store: string,
leaf: string,
oldId: string,
oldRule: string;
let request: CommissionImportRequest;
let savepoint = 0;
beforeEach(async () => {
const connection = new URL(process.env.MIGRATOR_DATABASE_URL!);
connection.username = process.env.POSTGRES_ADMIN_USER!;
connection.password = process.env.POSTGRES_ADMIN_PASSWORD!;
client = new Client({ connectionString: connection.toString() });
await client.connect();
await client.query('BEGIN');
user = randomUUID();
workspace = randomUUID();
await client.query(
`INSERT INTO atlas.users(id,email,password_hash) VALUES($1,$2,'test-only')`,
[user, `${user}@example.test`],
);
await client.query(
`INSERT INTO atlas.workspaces(id,name,slug) VALUES($1,'佣金回滚测试',$2)`,
[workspace, workspace],
);
await client.query(
`INSERT INTO atlas.workspace_memberships(workspace_id,user_id,role) VALUES($1,$2,'owner')`,
[workspace, user],
);
await client.query(
`INSERT INTO atlas.system_admin_grants(user_id) VALUES($1)`,
[user],
);
await client.query('SET LOCAL ROLE atlas_app');
await client.query(`SELECT set_config('app.workspace_id',$1,true)`, [
workspace,
]);
platform = (
await client.query(
`SELECT id FROM atlas.platforms WHERE upper(code::text)='OZON'`,
)
).rows[0].id;
store = (
await client.query(
`INSERT INTO atlas.stores(workspace_id,platform_id,name,platform_client_id,currency_code,status,credential_status) VALUES($1,$2,'佣金测试',$3,'RUB','active','valid') RETURNING id`,
[workspace, platform, randomUUID()],
)
).rows[0].id;
const p = String(randomInt(100000000, 999999990)),
c = String(Number(p) + 1);
const parent = (
await client.query(
`INSERT INTO atlas.platform_product_categories(platform_id,external_category_id,external_type_id,name,path,is_active) VALUES($1,$2,'0','测试分组','测试分组',false) RETURNING id`,
[platform, p],
)
).rows[0].id;
const cat = (
await client.query(
`INSERT INTO atlas.platform_product_categories(platform_id,parent_id,external_category_id,external_type_id,name,path,is_active) VALUES($1,$2,$3,'0','测试类目','测试分组 / 测试类目',false) RETURNING id`,
[platform, parent, c],
)
).rows[0].id;
leaf = (
await client.query(
`INSERT INTO atlas.platform_product_categories(platform_id,parent_id,external_category_id,external_type_id,name,path) VALUES($1,$2,$3,'991','测试类型','测试类型') RETURNING id`,
[platform, cat, c],
)
).rows[0].id;
request = {
platform_id: platform,
version: `qa-${randomUUID()}`,
source_reference: '自动化测试,事务回滚',
captured_on: '2026-09-28',
document: makeDocument(p, c),
};
const oldCategory = parseCommissionDocument(
makeDocument(p, c, ['10.00', '11.00', '12.00']),
)[0];
oldCategory.mapped_types = 1;
await client.query(
`UPDATE atlas.commission_imports SET is_current=false WHERE platform_id=$1 AND is_current`,
[platform],
);
oldId = (
await client.query(
`INSERT INTO atlas.commission_imports(platform_id,version,source_reference,captured_on,document,content_hash,categories,summary,activated_by,activated_at) VALUES($1,$2,'旧测试版本','2026-09-27','{}',$3,$4,'{}',$5,now()-interval '1 day') RETURNING id`,
[
platform,
`old-${randomUUID()}`,
fingerprint(randomUUID()),
JSON.stringify([oldCategory]),
user,
],
)
).rows[0].id;
const cc = (
await client.query(
`INSERT INTO atlas.commission_categories(platform_id,source_category_key,name,path,source,status) VALUES($1,$2,'测试类目',$3,'competitor_capture','active') RETURNING id`,
[platform, `maozierp:${c}`, `test-${c}`],
)
).rows[0].id;
oldRule = (
await client.query(
`INSERT INTO atlas.commission_category_rules(commission_category_id,fulfillment,market_code,currency_code,commission_rate,source,version,status,effective_from,import_id) VALUES($1,'rfbs','RU','RUB',0.1,'competitor_capture','old','active',now()-interval '1 day',$2) RETURNING id`,
[cc, oldId],
)
).rows[0].id;
await client.query(
`INSERT INTO atlas.platform_product_category_commission_mappings(platform_id,platform_product_category_id,commission_category_id,fulfillment,source,status,effective_from,import_id) VALUES($1,$2,$3,'rfbs','competitor_capture','active',now()-interval '1 day',$4)`,
[platform, leaf, cc, oldId],
);
const transaction = async <T>(
work: (tx: DatabaseTransaction) => Promise<T>,
) => {
const sp = `operation_${++savepoint}`;
await client.query(`SAVEPOINT ${sp}`);
try {
const result = await work(client);
await client.query(`RELEASE SAVEPOINT ${sp}`);
return result;
} catch (e) {
await client.query(`ROLLBACK TO SAVEPOINT ${sp}`);
throw e;
}
};
db = {
transaction,
query: (sql: string, args: unknown[]) =>
transaction((tx) => tx.query(sql, args)),
workspaceTransaction: (
id: string,
work: (tx: DatabaseTransaction) => Promise<unknown>,
) =>
transaction(async (tx) => {
await tx.query(
`SELECT set_config('app.workspace_id',$1,true)`,
[id],
);
return work(tx);
}),
} as unknown as Database;
const config = {
IDEMPOTENCY_KEY_SECRET: 'commission-test-idempotency-secret',
pricingAllowDraftRates: false,
} as AppConfig;
const idem = new IdempotencyService(db, config);
service = new CommissionImportService(db, idem);
pricing = new PricingService(db, idem, config);
});
afterEach(async () => {
if (client) {
await client.query('ROLLBACK');
await client.end();
}
});
const publishInput = async (
s: CommissionImportService,
r: CommissionImportRequest,
): Promise<CommissionImportPublish> => ({
...r,
preview_hash: (await s.preview(r)).preview_hash,
confirm_scope: true,
});
it('预览差异,启用三档规则,按内部及外部类目查询;旧规则保留', async () => {
const p = await service.preview(request);
expect(p.summary.eligible).toBe(1);
expect(p.categories[0].change).toBe('changed');
const r = await service.publish(
workspace,
user,
randomUUID(),
await publishInput(service, request),
);
expect(r.status).toBe(201);
const rules = await pricing.commissionRules(workspace, store, leaf);
expect(rules.bands.map((b) => b.commission_rate)).toEqual([
'0.120000',
'0.140000',
'0.205000',
]);
expect(
(
await pricing.commissionRules(
workspace,
store,
undefined,
p.categories[0].source_category_id,
)
).bands,
).toEqual(rules.bands);
expect(
(
await client.query(
`SELECT status,commission_rate FROM atlas.commission_category_rules WHERE id=$1`,
[oldRule],
)
).rows[0],
).toEqual({ status: 'archived', commission_rate: '0.100000' });
expect((await service.preview(request)).preview_hash).not.toBe(
p.preview_hash,
);
});
it('相同幂等请求只发布一次,旧预览不能再启用', async () => {
const input = await publishInput(service, request),
key = randomUUID();
const a = await service.publish(workspace, user, key, input);
expect(await service.publish(workspace, user, key, input)).toEqual(
JSON.parse(JSON.stringify(a)),
);
await expect(
service.publish(workspace, user, randomUUID(), input),
).rejects.toMatchObject({
response: { error: { code: 'COMMISSION_PREVIEW_STALE' } },
});
const fresh = await publishInput(service, request);
await expect(
service.publish(workspace, user, randomUUID(), fresh),
).rejects.toMatchObject({
response: { error: { code: 'COMMISSION_IMPORT_EXISTS' } },
});
});
it('0% 佣金可以启用并用于类目查询', async () => {
const d = request.document as ReturnType<typeof makeDocument>;
d.data[0].children[0].children = makeDocument('1', '2', [
'0.00',
'0.00',
'0.00',
]).data[0].children[0].children;
const p = await service.preview(request);
expect(p.summary).toMatchObject({
eligible: 1,
excluded: 0,
mapped_types: 1,
});
await service.publish(
workspace,
user,
randomUUID(),
await publishInput(service, request),
);
expect(
(await pricing.commissionRules(workspace, store, leaf)).bands.map(
(b) => b.commission_rate,
),
).toEqual(['0.000000', '0.000000', '0.000000']);
});
it('错误父类目被提示;无可映射规则时拒绝空版本', async () => {
const d = request.document as ReturnType<typeof makeDocument>;
d.data[0].cate_id = '123';
expect(
(await service.preview(request)).categories[0].issues.join(),
).toContain('父类目');
await expect(
service.publish(
workspace,
user,
randomUUID(),
await publishInput(service, request),
),
).rejects.toMatchObject({
response: { error: { code: 'COMMISSION_IMPORT_INVALID' } },
});
});
it('目录变化后必须重新预览', async () => {
const input = await publishInput(service, request);
await client.query(
`UPDATE atlas.platform_product_categories SET is_active=false WHERE id=$1`,
[leaf],
);
await expect(
service.publish(workspace, user, randomUUID(), input),
).rejects.toMatchObject({
response: { error: { code: 'COMMISSION_PREVIEW_STALE' } },
});
});
it('撤销管理员授权阻止发布,其他工作空间不能读取门店规则', async () => {
const input = await publishInput(service, request);
await client.query('RESET ROLE');
await client.query(
`UPDATE atlas.system_admin_grants SET revoked_at=now() WHERE user_id=$1`,
[user],
);
await client.query('SET LOCAL ROLE atlas_app');
await expect(
service.publish(workspace, user, randomUUID(), input),
).rejects.toMatchObject({
response: { error: { code: 'SYSTEM_ADMIN_REQUIRED' } },
});
await expect(
pricing.commissionRules(randomUUID(), store, leaf),
).rejects.toMatchObject({
response: { error: { code: 'STORE_NOT_FOUND' } },
});
});
it('有目录异常时无需额外确认,仍可启用其他可用类目', async () => {
const d = request.document as ReturnType<typeof makeDocument>;
d.data[0].children.push(
makeDocument('1', '899999999').data[0].children[0],
);
const preview = await service.preview(request);
expect(preview.summary).toMatchObject({ eligible: 1, excluded: 1 });
const input = await publishInput(service, request);
await service.publish(workspace, user, randomUUID(), input);
expect(
(await pricing.commissionRules(workspace, store, leaf)).bands,
).toHaveLength(3);
expect(
(
await service.detail((await service.list(platform))[0].id)
).categories.filter((c) => c.status === 'excluded'),
).toHaveLength(1);
});
it('后续写入失败时旧版本保持有效', async () => {
const input = await publishInput(service, request);
const original = client.query.bind(client);
const spy = vi.spyOn(client, 'query').mockImplementation(((
sql: string,
args: unknown[],
) => {
if (
typeof sql === 'string' &&
sql.includes(
'INSERT INTO atlas.platform_product_category_commission_mappings',
)
)
throw new Error('模拟映射写入失败');
return original(sql, args);
}) as typeof client.query);
try {
await expect(
service.publish(workspace, user, randomUUID(), input),
).rejects.toThrow('模拟映射写入失败');
} finally {
spy.mockRestore();
}
expect(
(
await client.query(
'SELECT is_current FROM atlas.commission_imports WHERE id=$1',
[oldId],
)
).rows[0].is_current,
).toBe(true);
expect(
(
await client.query(
'SELECT status FROM atlas.commission_category_rules WHERE id=$1',
[oldRule],
)
).rows[0].status,
).toBe('active');
expect(
(
await client.query(
'SELECT id FROM atlas.commission_imports WHERE version=$1',
[request.version],
)
).rowCount,
).toBe(0);
});
it('数据库拒绝直接改写已启用的历史费率', async () => {
await expect(
db.transaction((tx) =>
tx.query(
`UPDATE atlas.commission_category_rules SET commission_rate=0.9 WHERE id=$1`,
[oldRule],
),
),
).rejects.toMatchObject({ code: '23514' });
expect(
(
await client.query(
`SELECT commission_rate FROM atlas.commission_category_rules WHERE id=$1`,
[oldRule],
)
).rows[0].commission_rate,
).toBe('0.100000');
});
});
+649
View File
@@ -0,0 +1,649 @@
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 { 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',
...init,
headers: { 'Content-Type': 'application/json', ...init.headers },
});
return {
response,
body:
response.status === 204 || response.status === 302
? undefined
: ((await response.json()) as any),
};
}
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}`;
});
afterAll(async () => {
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.collection_asset_transfers WHERE workspace_id = $1',
[workspaceId],
);
await tx.query(
'DELETE FROM atlas.product_variant_platform_listings WHERE workspace_id = $1',
[workspaceId],
);
await tx.query(
'DELETE FROM atlas.product_platform_profiles WHERE workspace_id = $1',
[workspaceId],
);
await tx.query('DELETE FROM atlas.stores WHERE workspace_id = $1', [
workspaceId,
]);
await tx.query(
'DELETE FROM atlas.product_variants 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 tx.query(
'DELETE FROM atlas.extension_authorization_codes WHERE workspace_id = $1',
[workspaceId],
);
await tx.query(
'DELETE FROM atlas.extension_sessions 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 session = await request('/api/extension/session');
assert.equal(session.response.status, 401);
assert.equal(session.body.error.code, 'SESSION_EXPIRED');
assert.equal(
session.body.error.message,
'Atlas ERP 登录已过期,请重新登录',
);
});
it('通过 Atlas Web access token 写入来源商品,并以新快照覆盖重复采集', async () => {
const registration = await request('/api/auth/register', {
method: 'POST',
body: JSON.stringify({
email: `extension-${randomUUID()}@example.test`,
password: 'correct-horse-battery-staple-123',
password_confirmation: 'correct-horse-battery-staple-123',
}),
});
assert.equal(registration.response.status, 201);
userId = registration.body.data.user.id;
workspaceId = registration.body.data.workspace.id;
webAccessToken = registration.body.data.access_token as string;
const session = await request('/api/extension/session', {
headers: { Authorization: `Bearer ${webAccessToken}` },
});
assert.equal(session.response.status, 200);
assert.equal(session.body.data.workspace.id, workspaceId);
const payload = {
source: {
platform: 'ozon',
itemKey: '123456789',
canonicalUrl: 'https://www.ozon.ru/product/demo-123456789/',
collectedAt: new Date().toISOString(),
parserVersion: 'test-1',
},
common: {
title: { value: '来源商品', confidence: 'verified' },
sourcePrice: { amount: '99.90', currencyCode: 'RUB' },
attributes: [],
categories: [],
},
variantAxes: [],
variants: [
{
sourceVariantKey: '123456789',
sourceSku: '123456789',
displayName: '默认 SKU',
selections: [],
confidence: 'verified',
evidenceRefs: ['page'],
},
],
assets: [
{
url: 'https://images.example.test/product.jpg',
role: 'source_main',
relation: 'product_candidate',
sortOrder: 0,
evidenceRef: 'page',
},
],
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': firstKey,
},
body: JSON.stringify(payload),
});
assert.equal(first.response.status, 201);
assert.equal(first.body.data.reused, false);
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}` },
});
const targetPlatform = platforms.body.data.find(
(platform: { code: string }) => platform.code === 'OZON',
);
assert.ok(targetPlatform);
await database.workspaceTransaction(workspaceId, async (tx) => {
await tx.query(
`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, '复采测试店铺',
$2, 'RUB', true, 'active', 'valid')`,
[targetPlatform.id, `refresh-${randomUUID()}`],
);
});
const editorContext = await request(
`/api/collection-items/${editorEntry.searchParams.get('collection_item_id')}/editor-contexts`,
{
method: 'POST',
headers: {
Authorization: `Bearer ${webAccessToken}`,
'X-Atlas-Platform-Id': targetPlatform.id,
'Idempotency-Key': randomUUID(),
},
},
);
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.
await database.workspaceTransaction(workspaceId, async (tx) => {
const variant = await tx.query<{ id: string }>(
`SELECT id FROM atlas.product_variants
WHERE product_id = $1 AND source_variant_key = '123456789'`,
[first.body.data.product_id],
);
await tx.query(
`UPDATE atlas.products SET source_title = '人工确认商品名', purchase_url = 'https://detail.1688.com/offer/888888888.html'
WHERE id = $1`,
[first.body.data.product_id],
);
await tx.query(
`UPDATE atlas.product_variants
SET display_name = '人工确认 SKU', purchase_cost_cny = '88.80'
WHERE id = $1`,
[variant.rows[0].id],
);
await tx.query(
`UPDATE atlas.product_variant_platform_listings
SET offer_id = 'manual-offer-001'
WHERE product_variant_id = $1`,
[variant.rows[0].id],
);
});
// A sibling Ozon URL has a different entered SKU. It must still reuse
// the item because the submitted real-SKU sets overlap at 123456789.
const refreshedPayload = {
...payload,
source: {
...payload.source,
itemKey: '987654321',
canonicalUrl: 'https://www.ozon.ru/product/demo-987654321/',
collectedAt: new Date(Date.now() + 1_000).toISOString(),
parserVersion: 'test-2',
},
common: {
...payload.common,
title: {
value: '第二次采集的中文标题',
confidence: 'verified',
},
sourcePrice: { amount: '109.90', currencyCode: 'RUB' },
},
variants: [
{
sourceVariantKey: '123456789',
sourceSku: '123456789',
displayName: '第一 SKU',
selections: [],
confidence: 'verified',
evidenceRefs: ['page'],
},
{
sourceVariantKey: '987654321',
sourceSku: '987654321',
displayName: '第二次采集 SKU',
selections: [],
confidence: 'verified',
evidenceRefs: ['page'],
},
],
assets: [
{
url: 'https://images.example.test/refreshed-product.jpg',
role: 'source_main',
relation: 'product_candidate',
sortOrder: 0,
evidenceRef: 'page',
},
{
url: 'https://videos.example.test/refreshed-product.mp4',
mediaType: 'video',
posterUrl:
'https://images.example.test/refreshed-video-poster.jpg',
role: 'source_main',
relation: 'product_candidate',
sortOrder: 1,
evidenceRef: 'page',
},
],
};
const second = await request('/api/extension/materials', {
method: 'POST',
headers: {
Authorization: `Bearer ${webAccessToken}`,
'Idempotency-Key': randomUUID(),
},
body: JSON.stringify(refreshedPayload),
});
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,
);
assert.equal(second.body.data.materialization.pending_selection, 2);
assert.equal(second.body.data.materialization.queued, 0);
const editorAfterRefresh = await request(
`/api/products/${first.body.data.product_id}/editor`,
{
headers: {
Authorization: `Bearer ${webAccessToken}`,
'X-Atlas-Platform-Id': targetPlatform.id,
},
},
);
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,
'人工确认商品名',
);
const originalVariant = editorAfterRefresh.body.data.variants.find(
(variant: { source_variant_key: string }) =>
variant.source_variant_key === '123456789',
);
assert.equal(originalVariant.display_name, '人工确认 SKU');
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 }) =>
variant.source_variant_key === '987654321',
);
assert.ok(discoveredVariant);
assert.equal(discoveredVariant.listing.status, 'not_listed');
const inspection = await request(
`/api/collection-items/${first.body.data.collection_item.id}`,
{ headers: { Authorization: `Bearer ${webAccessToken}` } },
);
assert.equal(inspection.response.status, 200);
assert.equal(inspection.body.data.title, '第二次采集的中文标题');
assert.equal(
inspection.body.data.normalized_payload.source.parserVersion,
'test-2',
);
assert.equal(inspection.body.data.variants.length, 2);
assert.deepEqual(
inspection.body.data.variants.map(
(variant: { source_variant_key: string }) =>
variant.source_variant_key,
),
['123456789', '987654321'],
);
assert.equal(inspection.body.data.asset_transfers.length, 2);
assert.equal(
inspection.body.data.asset_transfers[0].source_url,
'https://images.example.test/refreshed-product.jpg',
);
assert.equal(
inspection.body.data.asset_transfers[0].status,
'pending_selection',
);
const videoTransfer = inspection.body.data.asset_transfers.find(
(transfer: { media_type: string }) =>
transfer.media_type === 'video',
);
assert.equal(
videoTransfer?.source_url,
'https://videos.example.test/refreshed-product.mp4',
);
assert.equal(
videoTransfer?.poster_url,
'https://images.example.test/refreshed-video-poster.jpg',
);
const eventsBeforeSelection = await database.workspaceTransaction(
workspaceId,
async (tx) =>
tx.query<{ count: string }>(
`SELECT count(*)::text AS count FROM atlas.outbox_events
WHERE event_type = 'collection.assets.materialize.requested'
AND event_payload->>'collection_item_id' = $1`,
[first.body.data.collection_item.id],
),
);
assert.equal(eventsBeforeSelection.rows[0]?.count, '0');
const materialization = await request(
`/api/collection-items/${first.body.data.collection_item.id}/asset-materializations`,
{
method: 'POST',
headers: {
Authorization: `Bearer ${webAccessToken}`,
'Idempotency-Key': randomUUID(),
},
body: JSON.stringify({
transfer_ids: [inspection.body.data.asset_transfers[0].id],
}),
},
);
assert.equal(materialization.response.status, 202);
const afterSelection = await request(
`/api/collection-items/${first.body.data.collection_item.id}`,
{ headers: { Authorization: `Bearer ${webAccessToken}` } },
);
assert.equal(afterSelection.response.status, 200);
assert.ok(
['queued', 'running', 'failed', 'succeeded'].includes(
afterSelection.body.data.asset_transfers[0].status,
),
);
const eventsAfterSelection = await database.workspaceTransaction(
workspaceId,
async (tx) =>
tx.query<{ count: string }>(
`SELECT count(*)::text AS count FROM atlas.outbox_events
WHERE event_type = 'collection.assets.materialize.requested'
AND event_payload->>'collection_item_id' = $1`,
[first.body.data.collection_item.id],
),
);
assert.equal(eventsAfterSelection.rows[0]?.count, '1');
const transferWorkspace = await database.workspaceTransaction(
workspaceId,
async (tx) =>
tx.query<{ workspace_id: string }>(
'SELECT workspace_id FROM atlas.collection_asset_transfers WHERE id = $1',
[inspection.body.data.asset_transfers[0].id],
),
);
assert.equal(transferWorkspace.rows[0]?.workspace_id, workspaceId);
});
it('完整采集并保存 1000 个 SKU,复采保持身份,超限请求原子拒绝', async () => {
const variants = Array.from({ length: PRODUCT_VARIANT_LIMIT }, (_, index) => ({
sourceVariantKey: String(8_000_000_000_000 + index),
sourceSku: String(8_000_000_000_000 + index),
displayName: `来源 SKU ${index}`,
selections: [], confidence: 'verified', evidenceRefs: [],
sourcePrice: { amount: '20.00', currencyCode: 'CNY' },
}));
const payload = {
source: {
platform: '1688', itemKey: `large-${randomUUID()}`,
canonicalUrl: 'https://detail.1688.com/offer/123456789.html',
collectedAt: new Date().toISOString(), parserVersion: 'large-sku-test',
},
common: { title: { value: '多 SKU 来源商品', confidence: 'verified' }, attributes: [], categories: [] },
variantAxes: [], variants, assets: [], evidence: [],
};
const capture = (body: unknown) => request('/api/extension/materials', {
method: 'POST',
headers: { Authorization: `Bearer ${webAccessToken}`, 'Idempotency-Key': randomUUID() },
body: JSON.stringify(body),
});
const captured = await capture(payload);
assert.equal(captured.response.status, 201, JSON.stringify(captured.body));
assert.equal(captured.body.data.collection_item.variant_count, PRODUCT_VARIANT_LIMIT);
const collectionId = captured.body.data.collection_item.id;
const productId = captured.body.data.product_id;
const platforms = await request('/api/platforms', { headers: { Authorization: `Bearer ${webAccessToken}` } });
const platformId = platforms.body.data.find((platform: { code: string }) => platform.code === 'OZON').id;
const context = await request(`/api/collection-items/${collectionId}/editor-contexts`, {
method: 'POST',
headers: {
Authorization: `Bearer ${webAccessToken}`, 'X-Atlas-Platform-Id': platformId,
'Idempotency-Key': randomUUID(),
},
});
assert.equal(context.response.status, 201, JSON.stringify(context.body));
const read = () => request(`/api/products/${productId}/editor`, {
headers: { Authorization: `Bearer ${webAccessToken}`, 'X-Atlas-Platform-Id': platformId },
});
const before = await read();
assert.equal(before.response.status, 200);
const snapshot = before.body.data;
assert.equal(snapshot.variants.length, PRODUCT_VARIANT_LIMIT);
assert.equal(snapshot.variants.at(-1).source_sku, variants.at(-1)!.sourceSku);
const draft = {
ai_source_material: snapshot.source_material,
ai_source_overridden_fields: [],
common: {
...snapshot.product.common,
default_physical_spec: snapshot.product.default_physical_spec,
default_package_spec: snapshot.product.default_package_spec,
},
profile: {
target_store_id: snapshot.profile.target_store.id,
fulfillment: snapshot.profile.fulfillment, title: null, description: null, tags: [],
platform_product_category_id: null,
},
variants: snapshot.variants.map((variant: any, index: number) => ({
id: variant.id, client_key: null,
display_name: index === PRODUCT_VARIANT_LIMIT - 1 ? '末尾 SKU 已编辑' : variant.display_name,
source_sku: variant.source_sku, specification_values: variant.specification_values,
purchase_cost_cny: variant.purchase_cost_cny, physical_spec: variant.physical_spec,
package_spec: variant.package_spec, tags: [], description: null,
offer_id: null, media_asset_ids: [],
})),
};
const saved = await request(`/api/products/${productId}/editor-draft`, {
method: 'PUT', headers: {
Authorization: `Bearer ${webAccessToken}`, 'X-Atlas-Platform-Id': platformId,
'Idempotency-Key': randomUUID(),
'If-Match': `"${snapshot.product.revision}:${snapshot.profile.revision}"`,
}, body: JSON.stringify(draft),
});
assert.equal(saved.response.status, 200, JSON.stringify(saved.body));
const after = await read();
assert.equal(after.body.data.variants.length, PRODUCT_VARIANT_LIMIT);
assert.equal(after.body.data.variants.at(-1).display_name, '末尾 SKU 已编辑');
const refreshed = await capture(payload);
assert.equal(refreshed.response.status, 200);
assert.equal(refreshed.body.data.product_id, productId);
assert.equal(refreshed.body.data.collection_item.variant_count, PRODUCT_VARIANT_LIMIT);
const reread = await read();
assert.deepEqual(reread.body.data.variants.map((v: any) => v.id), snapshot.variants.map((v: any) => v.id));
assert.equal(reread.body.data.variants.at(-1).display_name, '末尾 SKU 已编辑');
const revision = reread.body.data.source_material.source_snapshot_revision;
const rejected = await capture({ ...payload, variants: [...variants, { ...variants[0], sourceVariantKey: 'extra-sku' }] });
assert.equal(rejected.response.status, 422);
assert.equal(rejected.body.error.details[0].field, 'variants');
const unchanged = await read();
assert.equal(unchanged.body.data.source_material.source_snapshot_revision, revision);
assert.equal(unchanged.body.data.variants.length, PRODUCT_VARIANT_LIMIT);
}, 60_000);
});
+328
View File
@@ -0,0 +1,328 @@
import { randomUUID } from 'node:crypto';
import {
createApplicationDatabase,
type Database,
type DatabaseTransaction,
} from '@atlas/database';
import {
createImageSuiteSchema,
imageSuiteInputSchema,
type ImageSuite,
} from '@atlas/contracts';
import { describe, expect, it } from 'vitest';
import { IdempotencyService } from '../src/common/idempotency.service.js';
import { ImageSuiteService } from '../src/media/image-suite.service.js';
import { MediaAiEditService } from '../src/media/media-ai-edit.service.js';
import { ProductImageTaskService } from '../src/media/product-image-task.service.js';
/** 所有实体和 Outbox 都只存在于最终回滚的事务,验证不调用实际收费生图。 */
describe.skipIf(!process.env.DATABASE_URL)(
'套图数据库隔离、幂等与状态汇总',
() => {
it('创建/部分失败/取消/重试保留成功图片,FK 与 RLS 拒绝越权引用', async () => {
const db = createApplicationDatabase(process.env.DATABASE_URL!);
const rollback = Symbol('rollback');
try {
await db.transaction(async (tx) => {
const workspaceId = randomUUID(),
otherWorkspace = randomUUID(),
userId = randomUUID();
await tx.query(
"INSERT INTO atlas.users (id, email, password_hash) VALUES ($1, $2, 'test-only')",
[userId, `${userId}@example.test`],
);
for (const id of [workspaceId, otherWorkspace])
await tx.query(
"INSERT INTO atlas.workspaces (id, name, slug) VALUES ($1::uuid, '套图测试', $1::text)",
[id],
);
const inWorkspace = async (id: string) =>
tx.query(
"SELECT set_config('app.workspace_id', $1, true)",
[id],
);
await inWorkspace(workspaceId);
const productId = randomUUID(),
variantId = randomUUID(),
itemId = randomUUID(),
assetId = randomUUID(),
outputId = randomUUID();
await tx.query(
"INSERT INTO atlas.collection_items (id, workspace_id, source_kind, source_item_key) VALUES ($1::uuid, $2, 'manual', $1::text)",
[itemId, workspaceId],
);
await tx.query(
'INSERT INTO atlas.products (id, workspace_id, collection_item_id) VALUES ($1, $2, $3)',
[productId, workspaceId, itemId],
);
await tx.query(
'INSERT INTO atlas.product_variants (id, workspace_id, product_id) VALUES ($1, $2, $3)',
[variantId, workspaceId, productId],
);
const asset = async (id: string, ws: string) =>
tx.query(
`INSERT INTO atlas.media_assets
(id, workspace_id, storage_provider, bucket, object_key, content_type, byte_size, checksum_sha256, width_px, height_px)
VALUES ($1::uuid, $2, 'local_minio', 'test', $1::text, 'image/jpeg', 100, $1::text, 100, 100)`,
[id, ws],
);
await asset(assetId, workspaceId);
await asset(outputId, workspaceId);
await inWorkspace(otherWorkspace);
const foreignAsset = randomUUID();
await asset(foreignAsset, otherWorkspace);
await inWorkspace(workspaceId);
let queryQueue: Promise<void> = Promise.resolve();
const scoped = {
workspaceTransaction: <T>(
ws: string,
work: (
transaction: DatabaseTransaction,
) => Promise<T>,
) => {
const result = queryQueue.then(async () => {
await inWorkspace(ws);
return work(tx);
});
queryQueue = result.then(
() => undefined,
() => undefined,
);
return result;
},
} as Database;
const idempotency = new IdempotencyService(scoped, {
IDEMPOTENCY_KEY_SECRET: 'suite-test-secret',
} as never);
const edits = new MediaAiEditService(scoped, idempotency, {
accessUrl: async () => ({
url: 'https://example.test/preview',
expires_at: '2099-01-01T00:00:00Z',
}),
} as never);
const suites = new ImageSuiteService(
scoped,
idempotency,
edits,
);
const input = imageSuiteInputSchema.parse({
variant_id: variantId,
reference_asset_ids: [assetId],
product_name: '台灯',
});
const shots = ['white', 'scene', 'selling'].map(
(category, i) => ({
id: randomUUID(),
category,
type: ['white_background', 'lifestyle', 'detail'][
i
],
theme: `方案${i}`,
description: '依据真实底图展示商品',
}),
);
const request = createImageSuiteSchema.parse({
...input,
shots,
});
const key = randomUUID();
const created = await suites.create(
workspaceId,
userId,
productId,
key,
request,
'generate',
);
const id = (created.body as { data: ImageSuite }).data.id;
expect(
(
await suites.create(
workspaceId,
userId,
productId,
key,
request,
'generate',
)
).body,
).toEqual(created.body);
await expect(
suites.create(
workspaceId,
userId,
productId,
key,
{ ...request, product_name: '不同请求' },
'generate',
),
).rejects.toMatchObject({
status: 409,
response: { error: { code: 'IDEMPOTENCY_KEY_REUSED' } },
});
const children = (
await tx.query<{ id: string }>(
'SELECT id FROM atlas.media_ai_edits WHERE suite_id = $1 ORDER BY suite_position',
[id],
)
).rows;
expect(children).toHaveLength(3);
expect(
(
await tx.query(
'SELECT id FROM atlas.outbox_events WHERE aggregate_id = ANY($1::uuid[])',
[children.map((child) => child.id)],
)
).rows,
).toHaveLength(3);
await tx.query(
"UPDATE atlas.media_ai_edits SET status = 'succeeded', output_asset_id = $2 WHERE id = $1",
[children[0].id, outputId],
);
await tx.query(
"UPDATE atlas.media_ai_edits SET status = 'failed', error_code = 'TEST_FAILURE' WHERE id = $1",
[children[1].id],
);
expect(
(await suites.read(workspaceId, productId, id)).status,
).toBe('running');
await suites.action(
workspaceId,
userId,
productId,
id,
randomUUID(),
'cancel',
);
expect(
(await suites.read(workspaceId, productId, id)).status,
).toBe('cancelled');
await suites.action(
workspaceId,
userId,
productId,
id,
randomUUID(),
'retry',
);
const retried = await suites.read(
workspaceId,
productId,
id,
);
expect(retried.items.map((item) => item.status)).toEqual([
'succeeded',
'queued',
'queued',
]);
expect(retried.items[0].output_asset?.id).toBe(outputId);
const attempts = (
await tx.query<{ generation_attempt: number }>(
'SELECT generation_attempt FROM atlas.media_ai_edits WHERE suite_id = $1 ORDER BY suite_position',
[id],
)
).rows;
expect(
attempts.map((item) => item.generation_attempt),
).toEqual([1, 2, 2]);
await tx.query(
"UPDATE atlas.media_ai_edits SET status = 'failed', error_code = 'TEST_FAILURE' WHERE id = ANY($1::uuid[])",
[children.slice(1).map((child) => child.id)],
);
expect(
(await suites.read(workspaceId, productId, id)).status,
).toBe('partial');
const tasks = await new ProductImageTaskService(
scoped,
).list(workspaceId, productId, { limit: 20 });
expect(tasks.data).toMatchObject([
{
id,
kind: 'ai_suite',
status: 'partial',
total: 3,
completed: 1,
output_asset_ids: [outputId],
},
]);
expect(tasks.data).toHaveLength(1);
await expect(
suites.read(otherWorkspace, productId, id),
).rejects.toMatchObject({ status: 404 });
await expect(
suites.create(
workspaceId,
userId,
productId,
randomUUID(),
{ ...input, reference_asset_ids: [foreignAsset] },
'plan',
),
).rejects.toMatchObject({ status: 404 });
const forbidden = async (
sql: string,
params: unknown[],
code: string,
) => {
await tx.query('SAVEPOINT forbidden_write');
try {
await tx.query(sql, params);
throw new Error('本应拒绝的写入成功');
} catch (error) {
expect((error as { code: string }).code).toBe(code);
} finally {
await tx.query(
'ROLLBACK TO SAVEPOINT forbidden_write',
);
}
};
await forbidden(
'UPDATE atlas.product_image_suites SET input = input || \'{"color":"篡改"}\'::jsonb WHERE id = $1',
[id],
'23514',
);
await forbidden(
'UPDATE atlas.media_ai_edits SET suite_position = NULL WHERE id = $1',
[children[0].id],
'23514',
);
await forbidden(
'UPDATE atlas.media_ai_edits SET source_asset_id = $2 WHERE id = $1',
[children[0].id, foreignAsset],
'23514',
);
const foreignReferenceParent = await suites.create(
workspaceId,
userId,
productId,
randomUUID(),
input,
'plan',
);
const foreignParentId = (
foreignReferenceParent.body as { data: ImageSuite }
).data.id;
await forbidden(
'UPDATE atlas.product_image_suite_references SET asset_id = $2 WHERE suite_id = $1',
[foreignParentId, foreignAsset],
'23514',
);
await inWorkspace(otherWorkspace);
expect(
(
await tx.query(
'SELECT id FROM atlas.product_image_suites WHERE id = $1',
[id],
)
).rows,
).toHaveLength(0);
throw rollback;
});
} catch (error) {
if (error !== rollback) throw error;
} finally {
await db.close();
}
});
},
);
+291
View File
@@ -0,0 +1,291 @@
import { randomUUID } from 'node:crypto';
import { describe, expect, it, vi } from 'vitest';
import {
createImageSuiteSchema,
imageSuiteInputSchema,
imageSuitePlanInputSchema,
} from '@atlas/contracts';
import { ImageSuiteService } from '../src/media/image-suite.service.js';
const input = () =>
imageSuiteInputSchema.parse({
variant_id: randomUUID(),
reference_asset_ids: [randomUUID()],
product_name: '台灯',
});
const shot = () => ({
id: randomUUID(),
category: 'white',
type: 'white_background',
theme: '白底主图',
description: '展示商品完整外观',
});
describe('套图共享契约与资源边界', () => {
it('规划默认四张,接受 1–20 张预算,生成接口不接收规划预算字段', () => {
const valid = input();
expect(imageSuitePlanInputSchema.parse(valid).image_count).toBe(4);
expect(
imageSuitePlanInputSchema.parse({ ...valid, image_count: 6 })
.image_count,
).toBe(6);
for (const image_count of [0, 21, 1.2])
expect(
imageSuitePlanInputSchema.safeParse({ ...valid, image_count })
.success,
).toBe(false);
expect(
createImageSuiteSchema.safeParse({
...valid,
image_count: 4,
shots: [shot()],
}).success,
).toBe(false);
});
it('规划保存预算并只创建规划 Outbox,不创建生图子任务', async () => {
const request = imageSuitePlanInputSchema.parse({
...input(),
image_count: 6,
});
const suiteId = randomUUID(),
productId = randomUUID();
const query = vi.fn(async (sql: string, values: unknown[] = []) => {
if (sql.startsWith('SELECT id FROM atlas.product_variants'))
return { rows: [{ id: request.variant_id }] };
if (sql.startsWith('SELECT id FROM atlas.media_assets'))
return {
rows: request.reference_asset_ids.map((id) => ({ id })),
};
if (sql.startsWith('INSERT INTO atlas.product_image_suites'))
return {
rows: [
{
id: suiteId,
product_id: productId,
variant_id: request.variant_id,
purpose: 'plan',
status: 'queued',
input: JSON.parse(values[3] as string),
shots: [],
created_at: new Date(),
completed_at: null,
error_code: null,
},
],
};
return { rows: [] };
});
const service = new ImageSuiteService(
{} as never,
{
run: async (
_context: unknown,
work: (tx: { query: typeof query }) => Promise<unknown>,
) => work({ query }),
} as never,
{} as never,
);
const result = await service.create(
randomUUID(),
randomUUID(),
productId,
randomUUID(),
request,
'plan',
);
expect(result).toMatchObject({
status: 202,
body: {
data: { purpose: 'plan', input: { image_count: 6 }, shots: [] },
},
});
expect(
query.mock.calls.some(([sql]) =>
sql.includes('INSERT INTO atlas.media_ai_edits'),
),
).toBe(false);
expect(
query.mock.calls.find(([sql]) =>
sql.includes('INSERT INTO atlas.outbox_events'),
)?.[1],
).toContain('image_suite.plan.requested');
});
it('拒绝重复底图、超过 8 张底图、超过分类数量和无实物尺寸的尺寸图', () => {
const valid = input();
expect(
imageSuiteInputSchema.safeParse({
...valid,
reference_asset_ids: Array.from({ length: 9 }, randomUUID),
}).success,
).toBe(false);
expect(
imageSuiteInputSchema.safeParse({
...valid,
reference_asset_ids: [
...valid.reference_asset_ids,
...valid.reference_asset_ids,
],
}).success,
).toBe(false);
expect(
createImageSuiteSchema.safeParse({
...valid,
shots: Array.from({ length: 6 }, shot),
}).success,
).toBe(false);
expect(
createImageSuiteSchema.safeParse({
...valid,
shots: [{ ...shot(), category: 'other', type: 'dimensions' }],
}).success,
).toBe(false);
});
it('逐张底图必须来自已选图库,并保留主体图,类型必须匹配分类', () => {
const valid = input();
expect(
createImageSuiteSchema.safeParse({
...valid,
shots: [{ ...shot(), reference_asset_ids: [randomUUID()] }],
}).success,
).toBe(false);
expect(
createImageSuiteSchema.safeParse({
...valid,
shots: [{ ...shot(), category: 'scene' }],
}).success,
).toBe(false);
expect(
createImageSuiteSchema.safeParse({
...valid,
shots: [shot()],
ratio: '4:3',
language: 'zh',
}).success,
).toBe(true);
});
it('目标 SKU 不属于当前商品时,在产生任何任务前拒绝请求', async () => {
const query = vi.fn(async () => ({ rows: [] }));
const run = vi.fn(
async (
_input: unknown,
work: (tx: { query: typeof query }) => Promise<unknown>,
) => work({ query }),
);
const service = new ImageSuiteService(
{} as never,
{ run } as never,
{} as never,
);
await expect(
service.create(
randomUUID(),
randomUUID(),
randomUUID(),
randomUUID(),
input(),
'plan',
),
).rejects.toMatchObject({ status: 404 });
expect(query).toHaveBeenCalledOnce();
});
it('任一底图不可见时拒绝,不产生套图及 Outbox', async () => {
const query = vi.fn(async (sql: string) => ({
rows: sql.includes('atlas.product_variants')
? [{ id: 'variant' }]
: [],
}));
const run = vi.fn(
async (
_input: unknown,
work: (tx: { query: typeof query }) => Promise<unknown>,
) => work({ query }),
);
const service = new ImageSuiteService(
{} as never,
{ run } as never,
{} as never,
);
await expect(
service.create(
randomUUID(),
randomUUID(),
randomUUID(),
randomUUID(),
input(),
'plan',
),
).rejects.toMatchObject({ status: 404 });
expect(query).toHaveBeenCalledTimes(2);
});
it('默认逐张用图保留主体,每张最多 3 张,整套轮换覆盖已选 8 张底图', async () => {
const request = createImageSuiteSchema.parse({
...input(),
reference_asset_ids: Array.from({ length: 8 }, randomUUID),
shots: Array.from({ length: 8 }, (_, index) => ({
...shot(),
category: ['white', 'scene', 'selling', 'other'][index % 4],
type: ['white_background', 'lifestyle', 'detail', 'custom'][
index % 4
],
})),
});
const used: string[][] = [];
const query = vi.fn(async (sql: string, values: unknown[] = []) => {
if (sql.startsWith('SELECT id FROM atlas.product_variants'))
return { rows: [{ id: request.variant_id }] };
if (sql.startsWith('SELECT id FROM atlas.media_assets'))
return {
rows: request.reference_asset_ids.map((id) => ({ id })),
};
if (sql.includes('INSERT INTO atlas.product_image_suites'))
return {
rows: [
{
id: randomUUID(),
product_id: randomUUID(),
variant_id: request.variant_id,
purpose: 'generate',
status: 'queued',
input: request,
shots: request.shots,
created_at: new Date(),
completed_at: null,
error_code: null,
},
],
};
if (sql.includes('INSERT INTO atlas.media_ai_edits'))
used.push([values[5] as string, ...(values[6] as string[])]);
return { rows: [] };
});
const service = new ImageSuiteService(
{} as never,
{
run: async (
_request: unknown,
work: (tx: { query: typeof query }) => Promise<unknown>,
) => work({ query }),
} as never,
{} as never,
);
await service.create(
randomUUID(),
randomUUID(),
randomUUID(),
randomUUID(),
request,
'generate',
);
expect(used).toHaveLength(8);
expect(
used.every(
(refs) =>
refs.length <= 3 &&
refs[0] === request.reference_asset_ids[0],
),
).toBe(true);
expect(new Set(used.flat())).toEqual(
new Set(request.reference_asset_ids),
);
});
});
+101
View File
@@ -0,0 +1,101 @@
import { describe, expect, it, vi } from 'vitest';
import { createMediaAiEditRequestSchema } from '@atlas/contracts';
import { MediaAiEditService } from '../src/media/media-ai-edit.service.js';
const workspaceId = '30cf54b7-06a3-4e5f-a5fc-d74165115094';
const sourceId = '664d0d73-3d39-4a98-ac0a-5c0e521d8998';
const referenceId = '50ea00b7-6675-4cc7-b7a2-d45f05c07100';
const editId = '68db226f-3fcb-4579-ab9e-4932b1d84067';
const productId = '733d0d73-3d39-4a98-ac0a-5c0e521d8998';
describe('单张 AI 编辑任务', () => {
it('验证主图和辅图都属于当前工作空间后,原子写入任务、进度和 Outbox', async () => {
const statements: string[] = [];
const query = vi.fn(async (sql: string) => {
statements.push(sql);
if (sql.includes('SELECT id FROM atlas.products')) return { rows: [{ id: productId }] };
if (sql.includes('SELECT id FROM atlas.media_assets'))
return { rows: [{ id: sourceId }, { id: referenceId }] };
if (sql.includes('RETURNING id')) return { rows: [{ id: editId }] };
return { rows: [] };
});
const run = vi.fn(async (_input: unknown, work: (tx: { query: typeof query }) => Promise<unknown>) => work({ query }));
const service = new MediaAiEditService(
{} as ConstructorParameters<typeof MediaAiEditService>[0],
{ run } as unknown as ConstructorParameters<typeof MediaAiEditService>[1],
{} as ConstructorParameters<typeof MediaAiEditService>[2],
);
const input = createMediaAiEditRequestSchema.parse({ product_id: productId, reference_asset_ids: [referenceId], background: 'reference' });
const result = await service.create(workspaceId, 'user-id', sourceId, 'key', input);
expect(result).toMatchObject({ status: 202, body: { data: { id: editId, status: 'queued' } } });
expect(run).toHaveBeenCalledOnce();
expect(statements.some((sql) => sql.includes('INSERT INTO atlas.media_ai_edits'))).toBe(true);
expect(statements.some((sql) => sql.includes('INSERT INTO atlas.operations'))).toBe(true);
expect(statements.some((sql) => sql.includes('INSERT INTO atlas.outbox_events'))).toBe(true);
});
it('任一辅助图不在当前工作空间时,不创建任务', async () => {
const query = vi.fn(async (sql: string) => ({ rows: [{ id: sql.includes('atlas.products') ? productId : sourceId }] }));
const run = vi.fn(async (_input: unknown, work: (tx: { query: typeof query }) => Promise<unknown>) => work({ query }));
const service = new MediaAiEditService(
{} as ConstructorParameters<typeof MediaAiEditService>[0],
{ run } as unknown as ConstructorParameters<typeof MediaAiEditService>[1],
{} as ConstructorParameters<typeof MediaAiEditService>[2],
);
const input = createMediaAiEditRequestSchema.parse({ product_id: productId, reference_asset_ids: [referenceId] });
await expect(service.create(workspaceId, 'user-id', sourceId, 'key', input))
.rejects.toMatchObject({ status: 404 });
expect(query).toHaveBeenCalledTimes(2);
});
it('仅扩图不能带辅图或尺寸标注,参考背景必须带辅图', () => {
expect(createMediaAiEditRequestSchema.safeParse({ product_id: productId, mode: 'outpaint', reference_asset_ids: [referenceId] }).success).toBe(false);
expect(createMediaAiEditRequestSchema.safeParse({ product_id: productId, background: 'reference' }).success).toBe(false);
expect(createMediaAiEditRequestSchema.safeParse({ product_id: productId, generate_size_chart: true }).success).toBe(false);
});
it('读取失败任务时返回记录的失败来源、步骤和诊断', async () => {
const query = vi.fn(async (sql: string) => {
expect(sql).toContain('o.workspace_id = e.workspace_id');
return { rows: [{
id: editId, status: 'failed', source_asset_id: sourceId,
output_asset_id: null, error_code: 'AI_IMAGE_GENERATION_FAILED',
failure_source: 'atlas', failure_stage: 'result_processing',
failure_detail: 'Pcore 结果图片无效',
}] };
});
const database = { workspaceTransaction: vi.fn(async (_workspace: string, work: (tx: { query: typeof query }) => Promise<unknown>) => work({ query })) };
const service = new MediaAiEditService(
database as unknown as ConstructorParameters<typeof MediaAiEditService>[0],
{} as ConstructorParameters<typeof MediaAiEditService>[1],
{} as ConstructorParameters<typeof MediaAiEditService>[2],
);
const result = await service.read(workspaceId, editId);
expect(result).toMatchObject({
failure_source: 'atlas', failure_stage: 'result_processing',
failure_detail: 'Pcore 结果图片无效',
});
});
it('AI 生图记录按当前商品筛选并重新签发短时预览地址', async () => {
const query = vi.fn(async (_sql: string, values: unknown[]) => {
expect(values).toEqual([productId]);
return { rows: [{
id: editId, status: 'succeeded', source_asset_id: sourceId,
output_asset_id: referenceId, error_code: null,
content_type: 'image/jpeg', byte_size: '1234', width_px: 1152,
height_px: 1536, asset_created_at: new Date('2026-09-26T00:00:00Z'),
}] };
});
const database = { workspaceTransaction: vi.fn(async (_workspace: string, work: (tx: { query: typeof query }) => Promise<unknown>) => work({ query })) };
const accessUrl = vi.fn(async () => ({ url: 'https://example.com/short-lived', expires_at: '2026-09-26T01:00:00Z' }));
const service = new MediaAiEditService(
database as unknown as ConstructorParameters<typeof MediaAiEditService>[0],
{} as ConstructorParameters<typeof MediaAiEditService>[1],
{ accessUrl } as unknown as ConstructorParameters<typeof MediaAiEditService>[2],
);
const items = await service.list(workspaceId, productId);
expect(items).toMatchObject([{ id: editId, output_asset: { id: referenceId, preview_url: 'https://example.com/short-lived' } }]);
expect(accessUrl).toHaveBeenCalledWith(workspaceId, referenceId);
});
});
@@ -0,0 +1,81 @@
import { describe, expect, it, vi } from 'vitest';
import { AspectRatioDerivativeService } from '../src/media/aspect-ratio-derivative.service.js';
const workspaceId = '30cf54b7-06a3-4e5f-a5fc-d74165115094';
const sourceAssetId = '664d0d73-3d39-4a98-ac0a-5c0e521d8998';
const derivativeId = '68db226f-3fcb-4579-ab9e-4932b1d84067';
const productId = '733d0d73-3d39-4a98-ac0a-5c0e521d8998';
describe('一键 3:4 派生任务', () => {
it('在同一个租户事务写入派生记录、操作进度和 Outbox', async () => {
const statements: string[] = [];
const query = vi.fn(async (sql: string) => {
statements.push(sql);
if (sql.includes('SELECT id FROM atlas.media_assets')) return { rows: [{ id: sourceAssetId }] };
if (sql.includes('RETURNING id')) return { rows: [{ id: derivativeId }] };
return { rows: [] };
});
const run = vi.fn(async (_input: unknown, work: (tx: { query: typeof query }) => Promise<unknown>) =>
work({ query }));
const service = new AspectRatioDerivativeService(
{} as ConstructorParameters<typeof AspectRatioDerivativeService>[0],
{ run } as unknown as ConstructorParameters<typeof AspectRatioDerivativeService>[1],
{} as ConstructorParameters<typeof AspectRatioDerivativeService>[2],
);
const result = await service.create(
workspaceId,
'user-id',
sourceAssetId,
'idempotency-key',
{ preset: '3:4', fit: 'contain' },
);
expect(result).toMatchObject({
status: 202,
body: { data: { id: derivativeId, status: 'queued', source_asset_id: sourceAssetId } },
});
expect(run).toHaveBeenCalledOnce();
expect(statements.some((sql) => sql.includes('INSERT INTO atlas.media_aspect_ratio_derivatives'))).toBe(true);
expect(statements.some((sql) => sql.includes('INSERT INTO atlas.operations'))).toBe(true);
expect(statements.some((sql) => sql.includes('INSERT INTO atlas.outbox_events'))).toBe(true);
});
it('缺少或跨工作空间的源素材不会创建任务', async () => {
const query = vi.fn(async () => ({ rows: [] }));
const run = vi.fn(async (_input: unknown, work: (tx: { query: typeof query }) => Promise<unknown>) =>
work({ query }));
const service = new AspectRatioDerivativeService(
{} as ConstructorParameters<typeof AspectRatioDerivativeService>[0],
{ run } as unknown as ConstructorParameters<typeof AspectRatioDerivativeService>[1],
{} as ConstructorParameters<typeof AspectRatioDerivativeService>[2],
);
await expect(service.create(
workspaceId,
'user-id',
sourceAssetId,
'idempotency-key',
{ preset: '3:4', fit: 'contain' },
)).rejects.toMatchObject({ status: 404 });
expect(query).toHaveBeenCalledOnce();
});
it('将当前工作空间的商品关联到新派生任务', async () => {
const query = vi.fn(async (sql: string, values?: unknown[]) => {
if (sql.includes('SELECT id FROM atlas.media_assets')) return { rows: [{ id: sourceAssetId }] };
if (sql.includes('SELECT id FROM atlas.products')) return { rows: [{ id: productId }] };
if (sql.includes('INSERT INTO atlas.media_aspect_ratio_derivatives')) {
expect(values).toEqual([productId, sourceAssetId, 'user-id']);
return { rows: [{ id: derivativeId }] };
}
return { rows: [] };
});
const run = vi.fn(async (_input: unknown, work: (tx: { query: typeof query }) => Promise<unknown>) => work({ query }));
const service = new AspectRatioDerivativeService(
{} as ConstructorParameters<typeof AspectRatioDerivativeService>[0],
{ run } as unknown as ConstructorParameters<typeof AspectRatioDerivativeService>[1],
{} as ConstructorParameters<typeof AspectRatioDerivativeService>[2],
);
await service.create(workspaceId, 'user-id', sourceAssetId, 'key',
{ preset: '3:4', fit: 'contain', product_id: productId });
expect(query.mock.calls.some(([sql]) => String(sql).includes('INSERT INTO atlas.media_aspect_ratio_derivatives'))).toBe(true);
});
});
+109
View File
@@ -0,0 +1,109 @@
import { createHash } from 'node:crypto';
import { describe, expect, it, vi } from 'vitest';
import { MediaService } from '../src/media/media.service.js';
vi.mock('../src/media/product-image-download.js', async (importOriginal) => {
const original =
await importOriginal<
typeof import('../src/media/product-image-download.js')
>();
return {
...original,
downloadProductImage: vi.fn(async () => ({
bytes: Buffer.from('validated-image'),
contentType: 'image/png',
width: 800,
height: 800,
})),
};
});
describe('网络商品图片导入', () => {
it('复用同工作空间素材并返回正式 ID 与短时预览地址', async () => {
const productId = '733d0d73-3d39-4a98-ac0a-5c0e521d8998';
const assetId = '7c910684-7070-4f36-8a7b-b10bd91acd79';
const bytes = Buffer.from('validated-image');
const checksum = createHash('sha256').update(bytes).digest('hex');
const query = vi.fn(async (sql: string, args?: unknown[]) => {
if (sql.includes('FROM atlas.products')) {
expect(args).toEqual([productId]);
return { rows: [{ id: productId }] };
}
if (sql.includes('WHERE checksum_sha256 = $1')) {
expect(args).toEqual([checksum]);
return { rows: [{ id: assetId }] };
}
if (sql.includes('INSERT INTO atlas.product_image_imports')) {
expect(args?.[0]).toBe('https://example.com/photo.png');
expect(args?.[1]).toBe(assetId);
expect(args?.[3]).toBe(productId);
expect(sql).toContain('ON CONFLICT (workspace_id, product_id, source_url)');
return { rows: [] };
}
if (sql.includes('SELECT id, content_type')) {
return {
rows: [
{
id: assetId,
content_type: 'image/png',
byte_size: '15',
width_px: 800,
height_px: 800,
created_at: new Date('2026-09-24T00:00:00Z'),
},
],
};
}
if (sql.includes('SELECT storage_provider, bucket, object_key')) {
return {
rows: [
{
storage_provider: 'local_minio',
bucket: 'test',
object_key: 'product-imports/existing.png',
},
],
};
}
throw new Error(`未知 SQL: ${sql}`);
});
const run = vi.fn(
async (
request: { path: string; key: string },
callback: (tx: { query: typeof query }) => Promise<unknown>,
) => {
expect(request.path).toBe('/api/media-assets/product-imports');
expect(request.key).toBe('idempotency-key');
return callback({ query });
},
);
const service = new MediaService(
{ run } as unknown as ConstructorParameters<typeof MediaService>[0],
{} as ConstructorParameters<typeof MediaService>[1],
{
OBJECT_STORAGE_ENDPOINT: 'http://127.0.0.1:9000',
OBJECT_STORAGE_REGION: 'us-east-1',
OBJECT_STORAGE_BUCKET: 'test',
objectStorageForcePathStyle: true,
objectStorageAccessKey: 'test',
objectStorageSecretKey: 'test',
} as ConstructorParameters<typeof MediaService>[2],
);
const result = await service.importProductImage(
'workspace-id',
'user-id',
'idempotency-key',
{ url: 'https://example.com/photo.png', product_id: productId },
);
const body = result.body as {
data: { id: string; source_url: string; preview_url: string };
};
expect(result.status).toBe(201);
expect(body.data).toMatchObject({
id: assetId,
source_url: 'https://example.com/photo.png',
});
expect(body.data.preview_url).toContain('product-imports/existing.png');
expect(query).toHaveBeenCalledTimes(5);
});
});
@@ -0,0 +1,354 @@
const productId = '733d0d73-3d39-4a98-ac0a-5c0e521d8998';
import { createHash } from 'node:crypto';
import { GetObjectCommand, HeadObjectCommand } from '@aws-sdk/client-s3';
import {
createMediaAccessUrlRequestSchema,
createMediaUploadIntentRequestSchema,
importProductImageRequestSchema,
} from '@atlas/contracts';
import { describe, expect, it, vi } from 'vitest';
import { MediaService } from '../src/media/media.service.js';
import {
downloadProductImage,
publicIpv4,
} from '../src/media/product-image-download.js';
function serviceWithObject(bytes: Buffer) {
const service = new MediaService(
{} as ConstructorParameters<typeof MediaService>[0],
{} as ConstructorParameters<typeof MediaService>[1],
{
OBJECT_STORAGE_ENDPOINT: 'http://127.0.0.1:9000',
OBJECT_STORAGE_REGION: 'us-east-1',
OBJECT_STORAGE_BUCKET: 'test',
objectStorageForcePathStyle: true,
objectStorageAccessKey: 'test',
objectStorageSecretKey: 'test',
} as ConstructorParameters<typeof MediaService>[2],
);
const send = vi.fn(async (command: unknown) => {
if (command instanceof HeadObjectCommand) {
return { ContentLength: bytes.length, ContentType: 'image/png' };
}
if (command instanceof GetObjectCommand) {
return {
Body: (async function* () {
yield bytes;
})(),
};
}
throw new Error('未知 S3 命令');
});
Object.assign(service, { s3: { send } });
return service as unknown as {
inspectObject: (intent: Record<string, unknown>) => Promise<{
width: number;
height: number;
checksum: string;
}>;
};
}
function png(width: number, height: number) {
const bytes = Buffer.alloc(24);
Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]).copy(bytes);
bytes.writeUInt32BE(width, 16);
bytes.writeUInt32BE(height, 20);
return bytes;
}
describe('商品图片上传策略', () => {
it('下载地址签名 attachment 响应头,预览地址保持内联显示', async () => {
const assetId = '7c910684-7070-4f36-8a7b-b10bd91acd79';
const query = vi.fn(async () => ({
rows: [{ bucket: 'test', object_key: 'uploads/test.png', content_type: 'image/png' }],
}));
const workspaceTransaction = vi.fn(async (
_workspaceId: string,
callback: (tx: { query: typeof query }) => Promise<unknown>,
) => callback({ query }));
const service = new MediaService(
{} as ConstructorParameters<typeof MediaService>[0],
{ workspaceTransaction } as unknown as ConstructorParameters<typeof MediaService>[1],
{
OBJECT_STORAGE_ENDPOINT: 'http://127.0.0.1:9000',
OBJECT_STORAGE_REGION: 'cn-east-1',
OBJECT_STORAGE_BUCKET: 'test',
objectStorageForcePathStyle: true,
objectStorageAccessKey: 'test',
objectStorageSecretKey: 'test',
} as ConstructorParameters<typeof MediaService>[2],
);
expect(createMediaAccessUrlRequestSchema.safeParse({ purpose: 'download' }).success).toBe(true);
const preview = await service.accessUrl('workspace-id', assetId);
const download = await service.accessUrl('workspace-id', assetId, 'download');
expect(new URL(preview.url).searchParams.has('response-content-disposition')).toBe(false);
expect(new URL(preview.url).searchParams.get('X-Amz-Credential')).toContain('/cn-east-1/');
expect(new URL(download.url).searchParams.get('response-content-disposition'))
.toBe(`attachment; filename="atlas-image-${assetId}.png"`);
expect(workspaceTransaction).toHaveBeenCalledTimes(2);
});
it('开发环境为旧 MinIO 素材签发本地预览地址', async () => {
const query = vi.fn(async () => ({
rows: [{
storage_provider: 'local_minio',
bucket: 'atlas-assets',
object_key: 'collections/test/image',
content_type: 'image/jpeg',
}],
}));
const database = {
workspaceTransaction: async (_workspaceId: string, work: (tx: { query: typeof query }) => Promise<unknown>) => work({ query }),
};
const service = new MediaService(
{} as ConstructorParameters<typeof MediaService>[0],
database as unknown as ConstructorParameters<typeof MediaService>[1],
{
NODE_ENV: 'development',
OBJECT_STORAGE_PROVIDER: 'qiniu_kodo',
OBJECT_STORAGE_ENDPOINT: 'https://s3.cn-east-1.qiniucs.com',
OBJECT_STORAGE_REGION: 'cn-east-1',
OBJECT_STORAGE_BUCKET: 'atlas-dev-assets',
objectStorageForcePathStyle: true,
objectStorageAccessKey: 'test',
objectStorageSecretKey: 'test',
LEGACY_MINIO_ENDPOINT: 'http://localhost:9000',
MINIO_ROOT_USER: 'local-test',
MINIO_ROOT_PASSWORD: 'local-secret',
} as ConstructorParameters<typeof MediaService>[2],
);
const preview = await service.accessUrl('workspace-id', 'asset-id');
expect(new URL(preview.url).host).toBe('localhost:9000');
expect(new URL(preview.url).pathname).toBe('/atlas-assets/collections/test/image');
});
it('七牛素材使用绑定域名签发预览和附件下载链接', async () => {
const assetId = '7c910684-7070-4f36-8a7b-b10bd91acd79';
const query = vi.fn(async () => ({
rows: [{
storage_provider: 'qiniu_kodo',
bucket: 'atlas-dev-assets',
object_key: 'collections/workspace/image',
content_type: 'image/jpeg',
}],
}));
const database = {
workspaceTransaction: async (_workspaceId: string, work: (tx: { query: typeof query }) => Promise<unknown>) => work({ query }),
};
const service = new MediaService(
{} as ConstructorParameters<typeof MediaService>[0],
database as unknown as ConstructorParameters<typeof MediaService>[1],
{
OBJECT_STORAGE_ENDPOINT: 'https://s3.cn-east-1.qiniucs.com',
OBJECT_STORAGE_REGION: 'cn-east-1',
OBJECT_STORAGE_BUCKET: 'atlas-dev-assets',
objectStorageForcePathStyle: true,
objectStorageAccessKey: 's3-key',
objectStorageSecretKey: 's3-secret',
qiniuDownloadOrigin: 'https://img-dev.atlas.joeyone.cn',
qiniuDownloadAccessKey: 'download-key',
qiniuDownloadSecretKey: 'download-secret',
} as ConstructorParameters<typeof MediaService>[2],
);
const preview = await service.accessUrl('workspace-id', assetId);
const download = await service.accessUrl('workspace-id', assetId, 'download');
const previewUrl = new URL(preview.url);
const downloadUrl = new URL(download.url);
expect(previewUrl.origin).toBe('https://img-dev.atlas.joeyone.cn');
expect(previewUrl.pathname).toBe('/collections/workspace/image');
expect(previewUrl.searchParams.has('X-Amz-Signature')).toBe(false);
expect(previewUrl.searchParams.has('attname')).toBe(false);
expect(previewUrl.searchParams.get('token')).toMatch(/^download-key:/);
expect(preview.url.split('&').at(-1)).toMatch(/^token=/);
expect(downloadUrl.searchParams.get('attname')).toBe(`atlas-image-${assetId}.jpg`);
expect(download.url.split('&').at(-1)).toMatch(/^token=/);
});
it('创建商品图片上传意图时持久化用途并签发独立对象路径', async () => {
const query = vi.fn(async (_sql: string, _params?: unknown[]) => ({
rows: [{ id: productId }],
}));
const run = vi.fn(
async (
_request: unknown,
callback: (tx: { query: typeof query }) => Promise<unknown>,
) => callback({ query }),
);
const service = new MediaService(
{ run } as unknown as ConstructorParameters<typeof MediaService>[0],
{} as ConstructorParameters<typeof MediaService>[1],
{
OBJECT_STORAGE_ENDPOINT: 'http://127.0.0.1:9000',
OBJECT_STORAGE_REGION: 'us-east-1',
OBJECT_STORAGE_BUCKET: 'test',
objectStorageForcePathStyle: true,
objectStorageAccessKey: 'test',
objectStorageSecretKey: 'test',
} as ConstructorParameters<typeof MediaService>[2],
);
Object.assign(service, { ensureBucket: async () => undefined });
const result = await service.createIntent(
'workspace-id',
'user-id',
'key',
{
purpose: 'product_image',
product_id: productId,
filename: 'sku.png',
content_type: 'image/png',
byte_size: 1024,
checksum_sha256: 'a'.repeat(64),
},
);
const args = query.mock.calls.find(([sql]) => sql.includes('INSERT INTO atlas.media_upload_intents'))?.[1] as unknown[];
expect(args[10]).toBe(productId);
expect(args[1]).toBe('product_image');
expect(args[3]).toContain('/product-images/');
const body = result.body as { data: { upload: { url: string } } };
expect(body.data.upload.url).toContain('product-images');
});
it('商品图允许 20 MiB,水印图仍限制 5 MiB', () => {
const request = {
filename: 'sku.png',
content_type: 'image/png',
byte_size: 6 * 1024 * 1024,
checksum_sha256: 'a'.repeat(64),
};
expect(
createMediaUploadIntentRequestSchema.safeParse({
...request,
purpose: 'product_image',
product_id: productId,
}).success,
).toBe(true);
expect(
createMediaUploadIntentRequestSchema.safeParse({
...request,
purpose: 'watermark_image',
}).success,
).toBe(false);
expect(
createMediaUploadIntentRequestSchema.safeParse({
...request,
purpose: 'product_image',
product_id: productId,
byte_size: 21 * 1024 * 1024,
}).success,
).toBe(false);
});
it('商品上传和网络导入必须绑定商品,水印仍无需商品', () => {
const request = { filename: 'image.png', content_type: 'image/png', byte_size: 1024, checksum_sha256: 'a'.repeat(64) };
expect(createMediaUploadIntentRequestSchema.safeParse({ ...request, purpose: 'product_image' }).success).toBe(false);
expect(createMediaUploadIntentRequestSchema.safeParse({ ...request, purpose: 'watermark_image' }).success).toBe(true);
expect(createMediaUploadIntentRequestSchema.safeParse({ ...request, purpose: 'watermark_image', product_id: productId }).success).toBe(false);
expect(importProductImageRequestSchema.safeParse({ url: 'https://example.test/image.png' }).success).toBe(false);
});
it('商品图校验接受 9000px,水印图仍拒绝超过 8192px', async () => {
const bytes = png(9000, 9000);
const service = serviceWithObject(bytes);
const intent = {
bucket: 'test',
object_key: 'product.png',
expected_content_type: 'image/png',
purpose: 'product_image',
};
await expect(service.inspectObject(intent)).resolves.toMatchObject({
width: 9000,
height: 9000,
checksum: createHash('sha256').update(bytes).digest('hex'),
});
await expect(
service.inspectObject({ ...intent, purpose: 'watermark_image' }),
).rejects.toThrow();
});
it('上传图片列表仅查询当前工作空间已完成的商品素材', async () => {
const query = vi.fn(async (sql: string) => {
expect(sql).toContain("i.purpose = 'product_image'");
expect(sql).toContain("i.status = 'ready'");
expect(sql).toContain('i.product_id = $1');
return {
rows: [
{
id: '7c910684-7070-4f36-8a7b-b10bd91acd79',
content_type: 'image/png',
byte_size: '24',
width_px: 800,
height_px: 800,
created_at: new Date('2026-09-24T00:00:00Z'),
bucket: 'test',
object_key: 'uploads/test.png',
},
],
};
});
const workspaceTransaction = vi.fn(
async (
workspaceId: string,
callback: (tx: { query: typeof query }) => Promise<unknown>,
) => {
expect(workspaceId).toBe('workspace-id');
return callback({ query });
},
);
const service = new MediaService(
{} as ConstructorParameters<typeof MediaService>[0],
{ workspaceTransaction } as unknown as ConstructorParameters<
typeof MediaService
>[1],
{
OBJECT_STORAGE_ENDPOINT: 'http://127.0.0.1:9000',
OBJECT_STORAGE_REGION: 'us-east-1',
OBJECT_STORAGE_BUCKET: 'test',
objectStorageForcePathStyle: true,
objectStorageAccessKey: 'test',
objectStorageSecretKey: 'test',
} as ConstructorParameters<typeof MediaService>[2],
);
const uploads = await service.productUploads('workspace-id', productId);
expect(uploads).toHaveLength(1);
expect(uploads[0]).toMatchObject({
id: '7c910684-7070-4f36-8a7b-b10bd91acd79',
byte_size: 24,
});
expect(uploads[0].preview_url).toContain('uploads/test.png');
});
it('网络导入只允许 HTTPS 图片链接,拒绝内网地址', async () => {
expect(
importProductImageRequestSchema.safeParse({
url: 'https://example.com/image.jpg',
product_id: productId,
}).success,
).toBe(true);
expect(
importProductImageRequestSchema.safeParse({
url: 'http://example.com/image.jpg',
}).success,
).toBe(false);
expect(
importProductImageRequestSchema.safeParse({
url: 'https://example.com/image.svg',
}).success,
).toBe(false);
expect(publicIpv4('8.8.8.8')).toBe(true);
for (const address of [
'127.0.0.1',
'10.1.2.3',
'172.16.0.1',
'192.168.1.1',
'169.254.1.1',
'100.64.0.1',
'224.0.0.1',
]) {
expect(publicIpv4(address)).toBe(false);
}
await expect(
downloadProductImage('https://127.0.0.1/image.jpg'),
).rejects.toThrow();
});
});
+170
View File
@@ -0,0 +1,170 @@
import assert from 'node:assert/strict';
import { randomUUID } from 'node:crypto';
import type { AddressInfo } from 'node:net';
import type { Database } from '@atlas/database';
import type { NestExpressApplication } from '@nestjs/platform-express';
import { afterAll, beforeAll, describe, it } from 'vitest';
import { DATABASE } from '../src/database/database.module.js';
import { createApplication } from '../src/main.js';
let app: NestExpressApplication;
let database: Database;
let baseUrl = '';
let workspaceId = '';
let userId = '';
let token = '';
describe('水印派生 API 集成测试', () => {
beforeAll(async () => {
Object.assign(process.env, {
NODE_ENV: 'test', SWAGGER_ENABLED: 'false',
AUTH_REGISTER_IP_LIMIT: '100', AUTH_REGISTER_EMAIL_LIMIT: '100',
});
const created = await createApplication();
app = created.app;
database = app.get<Database>(DATABASE);
await app.listen(0, '127.0.0.1');
baseUrl = `http://127.0.0.1:${(app.getHttpServer().address() as AddressInfo).port}`;
const response = await fetch(`${baseUrl}/api/auth/register`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
email: `watermark-derivative-${randomUUID()}@example.test`,
password: 'correct-horse-battery-staple-123',
password_confirmation: 'correct-horse-battery-staple-123',
}),
});
assert.equal(response.status, 201);
const body = await response.json() as { data: {
user: { id: string }; workspace: { id: string }; access_token: string;
} };
userId = body.data.user.id;
workspaceId = body.data.workspace.id;
token = body.data.access_token;
});
afterAll(async () => {
if (workspaceId) {
await database.workspaceTransaction(workspaceId, async (tx) => {
await tx.query('DELETE FROM atlas.idempotency_requests WHERE workspace_id = $1', [workspaceId]);
await tx.query('DELETE FROM atlas.outbox_events WHERE workspace_id = $1', [workspaceId]);
await tx.query('DELETE FROM atlas.operations WHERE workspace_id = $1', [workspaceId]);
await tx.query('DELETE FROM atlas.media_aspect_ratio_derivatives WHERE workspace_id = $1', [workspaceId]);
await tx.query('DELETE FROM atlas.media_watermark_derivatives WHERE workspace_id = $1', [workspaceId]);
await tx.query('DELETE FROM atlas.watermark_template_versions WHERE workspace_id = $1', [workspaceId]);
await tx.query('DELETE FROM atlas.watermark_templates WHERE workspace_id = $1', [workspaceId]);
await tx.query('DELETE FROM atlas.media_assets WHERE workspace_id = $1', [workspaceId]);
await tx.query('DELETE FROM atlas.products WHERE workspace_id = $1', [workspaceId]);
await tx.query('DELETE FROM atlas.collection_items WHERE workspace_id = $1', [workspaceId]);
});
await database.query('DELETE FROM atlas.workspace_memberships WHERE user_id = $1', [userId]);
await database.query('DELETE FROM atlas.workspaces WHERE id = $1', [workspaceId]);
await database.query('DELETE FROM atlas.users WHERE id = $1', [userId]);
}
await app?.close();
});
it('HTTP 创建任务锁定模板版本,结果查询保持工作空间边界', async () => {
const { assetId, templateId, productId } = await database.workspaceTransaction(workspaceId, async (tx) => {
const item = await tx.query<{ id: string }>(
`INSERT INTO atlas.collection_items (workspace_id, source_kind, source_item_key)
VALUES ($1, 'manual', $2) RETURNING id`,
[workspaceId, `watermark-history-${randomUUID()}`],
);
const product = await tx.query<{ id: string }>(
`INSERT INTO atlas.products (workspace_id, collection_item_id)
VALUES ($1, $2) RETURNING id`,
[workspaceId, item.rows[0].id],
);
const asset = await tx.query<{ id: string }>(
`INSERT INTO atlas.media_assets
(workspace_id, storage_provider, bucket, object_key, content_type,
byte_size, checksum_sha256, width_px, height_px)
VALUES ($1, 'local_minio', 'atlas-media', $2, 'image/jpeg', 100,
$3, 120, 100) RETURNING id`,
[workspaceId, `test/watermark-${randomUUID()}.jpg`, randomUUID().replaceAll('-', '').padEnd(64, '0')],
);
const template = await tx.query<{ id: string }>(
`INSERT INTO atlas.watermark_templates (workspace_id, name, kind)
VALUES ($1, $2, 'text') RETURNING id`,
[workspaceId, `集成测试水印 ${randomUUID()}`],
);
await tx.query(
`INSERT INTO atlas.watermark_template_versions
(workspace_id, template_id, version, kind, text_content, font_code,
font_size_px, opacity, position, position_x, position_y, created_by_user_id)
VALUES ($1, $2, 1, 'text', 'Atlas', 'NotoSans', 20, 0.5,
'bottom_right', 1, 1, $3)`,
[workspaceId, template.rows[0].id, userId],
);
return { assetId: asset.rows[0].id, templateId: template.rows[0].id, productId: product.rows[0].id };
});
const key = randomUUID();
const create = () => fetch(`${baseUrl}/api/media-assets/${assetId}/watermark-derivatives`, {
method: 'POST', headers: {
Authorization: `Bearer ${token}`, 'Content-Type': 'application/json', 'Idempotency-Key': key,
},
body: JSON.stringify({ watermark_template_id: templateId, product_id: productId }),
});
const response = await create();
assert.equal(response.status, 202);
const body = await response.json() as { data: {
id: string; watermark_template_id: string; watermark_template_version: number;
} };
assert.equal(body.data.watermark_template_id, templateId);
assert.equal(body.data.watermark_template_version, 1);
const replay = await create();
assert.equal(replay.status, 202);
assert.equal((await replay.json() as { data: { id: string } }).data.id, body.data.id);
const read = await fetch(`${baseUrl}/api/media-watermark-derivatives/${body.data.id}`, {
headers: { Authorization: `Bearer ${token}` },
});
assert.equal(read.status, 200);
assert.equal((await read.json() as { data: { id: string } }).data.id, body.data.id);
const stored = await database.workspaceTransaction(workspaceId, async (tx) =>
tx.query<{ product_id: string }>(
'SELECT product_id FROM atlas.media_watermark_derivatives WHERE id = $1',
[body.data.id],
),
);
assert.equal(stored.rows[0]?.product_id, productId);
const outputs = await database.workspaceTransaction(workspaceId, async (tx) => {
const ids: string[] = [];
for (const kind of ['watermark', 'ratio']) {
const asset = await tx.query<{ id: string }>(
`INSERT INTO atlas.media_assets
(workspace_id, storage_provider, bucket, object_key, content_type,
byte_size, checksum_sha256, width_px, height_px)
VALUES ($1, 'local_minio', 'atlas-media', $2, 'image/jpeg', 100,
$3, 1200, 1600) RETURNING id`,
[workspaceId, `test/${kind}-${randomUUID()}.jpg`, randomUUID().replaceAll('-', '').padEnd(64, '0')],
);
ids.push(asset.rows[0].id);
}
await tx.query(
`INSERT INTO atlas.media_watermark_derivatives
(workspace_id, product_id, source_asset_id, watermark_template_id,
watermark_template_version, output_asset_id, status, completed_at)
VALUES ($1, $2, $3, $4, 1, $5, 'succeeded', now())`,
[workspaceId, productId, assetId, templateId, ids[0]],
);
await tx.query(
`INSERT INTO atlas.media_aspect_ratio_derivatives
(workspace_id, product_id, source_asset_id, output_asset_id, status, completed_at)
VALUES ($1, $2, $3, $4, 'succeeded', now())`,
[workspaceId, productId, assetId, ids[1]],
);
return ids;
});
const history = await fetch(`${baseUrl}/api/products/${productId}/image-derivatives`, {
headers: { Authorization: `Bearer ${token}` },
});
assert.equal(history.status, 200);
const records = (await history.json() as { data: Array<{
kind: string; output_asset: { id: string; preview_url: string };
}> }).data;
assert.deepEqual(new Set(records.map((record) => record.kind)), new Set(['watermark', 'aspect_ratio_3_4']));
assert.deepEqual(new Set(records.map((record) => record.output_asset.id)), new Set(outputs));
assert.ok(records.every((record) => record.output_asset.preview_url));
});
});
@@ -0,0 +1,68 @@
import { describe, expect, it, vi } from 'vitest';
import { WatermarkDerivativeService } from '../src/media/watermark-derivative.service.js';
const workspaceId = '30cf54b7-06a3-4e5f-a5fc-d74165115094';
const sourceAssetId = '664d0d73-3d39-4a98-ac0a-5c0e521d8998';
const templateId = '0878262e-6c38-4f3b-953a-2084ef297a33';
const derivativeId = '68db226f-3fcb-4579-ab9e-4932b1d84067';
const productId = '733d0d73-3d39-4a98-ac0a-5c0e521d8998';
function serviceWithQuery(query: ReturnType<typeof vi.fn>) {
const run = vi.fn(async (_input: unknown, work: (tx: { query: typeof query }) => Promise<unknown>) =>
work({ query }));
const service = new WatermarkDerivativeService(
{} as ConstructorParameters<typeof WatermarkDerivativeService>[0],
{ run } as unknown as ConstructorParameters<typeof WatermarkDerivativeService>[1],
{} as ConstructorParameters<typeof WatermarkDerivativeService>[2],
);
return { service, run };
}
describe('水印派生任务', () => {
it('固定当前模板版本,并在同一个事务创建任务、操作和 Outbox', async () => {
const statements: string[] = [];
const query = vi.fn(async (sql: string) => {
statements.push(sql);
if (sql.includes('SELECT id FROM atlas.media_assets')) return { rows: [{ id: sourceAssetId }] };
if (sql.includes('FROM atlas.watermark_templates')) return { rows: [{ id: templateId, current_version: 4 }] };
if (sql.includes('RETURNING id')) return { rows: [{ id: derivativeId }] };
return { rows: [] };
});
const { service, run } = serviceWithQuery(query);
const result = await service.create(workspaceId, 'user-id', sourceAssetId, 'idempotency-key',
{ watermark_template_id: templateId });
expect(result).toMatchObject({ status: 202, body: { data: {
id: derivativeId, watermark_template_id: templateId, watermark_template_version: 4,
} } });
expect(run).toHaveBeenCalledOnce();
expect(statements.some((sql) => sql.includes('INSERT INTO atlas.media_watermark_derivatives'))).toBe(true);
expect(statements.some((sql) => sql.includes('INSERT INTO atlas.operations'))).toBe(true);
expect(statements.some((sql) => sql.includes('INSERT INTO atlas.outbox_events'))).toBe(true);
});
it('拒绝不可用模板且不创建派生任务', async () => {
const query = vi.fn(async (sql: string) => sql.includes('SELECT id FROM atlas.media_assets')
? { rows: [{ id: sourceAssetId }] } : { rows: [] });
const { service } = serviceWithQuery(query);
await expect(service.create(workspaceId, 'user-id', sourceAssetId, 'idempotency-key',
{ watermark_template_id: templateId })).rejects.toMatchObject({ status: 404 });
expect(query.mock.calls.every(([sql]) => !String(sql).includes('INSERT INTO'))).toBe(true);
});
it('将当前工作空间的商品关联到水印结果', async () => {
const query = vi.fn(async (sql: string, values?: unknown[]) => {
if (sql.includes('SELECT id FROM atlas.media_assets')) return { rows: [{ id: sourceAssetId }] };
if (sql.includes('SELECT id FROM atlas.products')) return { rows: [{ id: productId }] };
if (sql.includes('FROM atlas.watermark_templates')) return { rows: [{ id: templateId, current_version: 4 }] };
if (sql.includes('INSERT INTO atlas.media_watermark_derivatives')) {
expect(values).toEqual([productId, sourceAssetId, templateId, 4, 'user-id']);
return { rows: [{ id: derivativeId }] };
}
return { rows: [] };
});
const { service } = serviceWithQuery(query);
await service.create(workspaceId, 'user-id', sourceAssetId, 'key',
{ watermark_template_id: templateId, product_id: productId });
expect(query.mock.calls.some(([sql]) => String(sql).includes('INSERT INTO atlas.media_watermark_derivatives'))).toBe(true);
});
});
+258
View File
@@ -0,0 +1,258 @@
import assert from 'node:assert/strict';
import { randomUUID } from 'node:crypto';
import type { AddressInfo } from 'node:net';
import type { Database } from '@atlas/database';
import type { NestExpressApplication } from '@nestjs/platform-express';
import { afterAll, beforeAll, describe, it } from 'vitest';
import { DATABASE } from '../src/database/database.module.js';
import { createApplication } from '../src/main.js';
let app: NestExpressApplication;
let database: Database;
let baseUrl: string;
let token = '';
let workspaceId = '';
let userId = '';
let platformId = '';
let storeId = '';
let listingId = '';
let runId = '';
async function request(path: string, init: RequestInit = {}) {
const response = await fetch(`${baseUrl}${path}`, {
...init,
headers: { 'Content-Type': 'application/json', ...init.headers },
});
return { response, body: (await response.json()) as Record<string, any> };
}
function auth(extra: Record<string, string> = {}) {
return {
Authorization: `Bearer ${token}`,
'X-Atlas-Platform-Id': platformId,
...extra,
};
}
describe('在线商品 HTTP 契约', () => {
beforeAll(async () => {
Object.assign(process.env, {
NODE_ENV: 'test',
SWAGGER_ENABLED: 'false',
AUTH_REGISTER_IP_LIMIT: '100',
AUTH_REGISTER_EMAIL_LIMIT: '100',
});
const created = await createApplication();
app = created.app;
database = app.get<Database>(DATABASE);
await app.listen(0, '127.0.0.1');
baseUrl = `http://127.0.0.1:${(app.getHttpServer().address() as AddressInfo).port}`;
const registered = await request('/api/auth/register', {
method: 'POST',
body: JSON.stringify({
email: `online-${randomUUID()}@example.test`,
password: 'correct-horse-battery-staple-123',
password_confirmation: 'correct-horse-battery-staple-123',
}),
});
assert.equal(registered.response.status, 201);
token = registered.body.data.access_token;
workspaceId = registered.body.data.workspace.id;
userId = registered.body.data.user.id;
const platforms = await request('/api/platforms', {
headers: { Authorization: `Bearer ${token}` },
});
platformId = platforms.body.data.find(
(platform: { code: string }) => platform.code === 'OZON',
).id;
storeId = randomUUID();
await database.workspaceTransaction(workspaceId, async (tx) => {
await tx.query(
`INSERT INTO atlas.stores (id, workspace_id, platform_id, name, platform_client_id, currency_code, is_default, status, credential_status)
VALUES ($1, $2, $3, '测试门店', $4, 'RUB', true, 'active', 'valid')`,
[storeId, workspaceId, platformId, `online-http-${storeId}`],
);
const run = await tx.query<{ id: string }>(
`INSERT INTO atlas.platform_product_sync_runs
(workspace_id, store_id, platform_id, trigger_source, status, completed_at)
VALUES ($1, $2, $3, 'manual', 'succeeded', now()) RETURNING id`,
[workspaceId, storeId, platformId],
);
runId = run.rows[0].id;
const listing = await tx.query<{ id: string }>(
`INSERT INTO atlas.platform_listings
(workspace_id, store_id, platform_id, remote_product_id, offer_id, remote_sku, name,
currency_code, selling_price, visible, moderation_status, stock_summary,
image_summary, source, last_sync_run_id, last_synced_at, raw_payload)
VALUES ($1, $2, $3, '123456', 'test-offer', '654321', '测试商品',
'RUB', 99, true, 'approved', '{"total":0,"has_data":true}',
'{"url":null,"width_px":null,"height_px":null}', 'external_sync', $4, now(), '{}') RETURNING id`,
[workspaceId, storeId, platformId, runId],
);
listingId = listing.rows[0].id;
await tx.query(
`INSERT INTO atlas.platform_listings
(workspace_id, store_id, platform_id, remote_product_id, offer_id, name,
visible, stock_summary, image_summary, source, last_sync_run_id, last_synced_at,
missing_since, archived_at, raw_payload)
VALUES ($1, $2, $3, '123457', 'archived-offer', '归档商品',
NULL, '{"total":0,"has_data":false}', '{}', 'external_sync', $4,
now(), now() - interval '8 days', now(), '{}')`,
[workspaceId, storeId, platformId, runId],
);
});
});
afterAll(async () => {
if (workspaceId && database) {
await database.workspaceTransaction(workspaceId, async (tx) => {
await tx.query(
'DELETE FROM atlas.platform_listing_sync_snapshots WHERE workspace_id = $1',
[workspaceId],
);
await tx.query(
'DELETE FROM atlas.platform_listings WHERE workspace_id = $1',
[workspaceId],
);
await tx.query(
'DELETE FROM atlas.platform_product_sync_runs WHERE workspace_id = $1',
[workspaceId],
);
await tx.query(
'DELETE FROM atlas.idempotency_requests WHERE workspace_id = $1',
[workspaceId],
);
await tx.query(
'DELETE FROM atlas.outbox_events WHERE workspace_id = $1',
[workspaceId],
);
await tx.query(
'DELETE FROM atlas.store_secrets WHERE workspace_id = $1',
[workspaceId],
);
await tx.query(
'DELETE FROM atlas.stores WHERE workspace_id = $1',
[workspaceId],
);
});
await database.query(
'DELETE FROM atlas.workspace_memberships WHERE user_id = $1',
[userId],
);
await database.query('DELETE FROM atlas.workspaces WHERE id = $1', [
workspaceId,
]);
await database.query('DELETE FROM atlas.users WHERE id = $1', [
userId,
]);
}
if (app) await app.close();
});
it('列表、详情与同步历史按平台店铺返回镜像,不返回原始响应', async () => {
const listed = await request(
`/api/platform-listings?store_id=${storeId}&q=test-offer`,
{ headers: auth() },
);
assert.equal(listed.response.status, 200);
assert.equal(listed.body.data.length, 1);
assert.equal(listed.body.data[0].visible, true);
assert.equal(listed.body.data[0].selling_price, '99.00');
assert.deepEqual(listed.body.data[0].stock_summary, {
total: 0,
has_data: true,
});
assert.equal(listed.body.meta.store.id, storeId);
assert.equal(listed.body.meta.last_sync_status, 'succeeded');
assert.equal('raw_payload' in listed.body.data[0], false);
const detail = await request(`/api/platform-listings/${listingId}`, {
headers: auth(),
});
assert.equal(detail.response.status, 200);
assert.equal(detail.body.data.offer_id, 'test-offer');
const runs = await request(
`/api/stores/${storeId}/platform-product-sync-runs`,
{ headers: auth() },
);
assert.equal(runs.response.status, 200);
assert.equal(runs.body.data[0].id, runId);
const run = await request(`/api/platform-product-sync-runs/${runId}`, {
headers: auth(),
});
assert.equal(run.response.status, 200);
assert.equal(run.body.data.status, 'succeeded');
});
it('筛选未知可见性与缺少平台头的请求按契约处理', async () => {
const unknown = await request(
`/api/platform-listings?store_id=${storeId}&visibility=unknown`,
{ headers: auth() },
);
assert.equal(unknown.response.status, 200);
assert.equal(unknown.body.data.length, 0);
const archived = await request(
`/api/platform-listings?store_id=${storeId}&visibility=unknown&include_archived=true`,
{ headers: auth() },
);
assert.equal(archived.response.status, 200);
assert.equal(archived.body.data.length, 1);
assert.equal(archived.body.data[0].offer_id, 'archived-offer');
const missingHeader = await request('/api/platform-listings', {
headers: { Authorization: `Bearer ${token}` },
});
assert.equal(missingHeader.response.status, 400);
assert.equal(
missingHeader.body.error.code,
'PLATFORM_CONTEXT_REQUIRED',
);
const noCredentials = await request(
`/api/stores/${storeId}/platform-product-sync-runs`,
{
method: 'POST',
headers: auth({ 'Idempotency-Key': randomUUID() }),
body: JSON.stringify({ reason: 'manual_refresh' }),
},
);
assert.equal(noCredentials.response.status, 422);
assert.equal(
noCredentials.body.error.code,
'STORE_CREDENTIAL_UNAVAILABLE',
);
});
it('触发同步写入 Outbox,同一幂等键复用任务,并发请求返回冲突', async () => {
await database.workspaceTransaction(workspaceId, (tx) =>
tx.query(
`INSERT INTO atlas.store_secrets
(workspace_id, store_id, kind, secret_ciphertext, secret_nonce,
encryption_key_version, fingerprint, status)
VALUES ($1, $2, 'seller_api_key', $3, $4, 'v1', $5, 'valid')`,
[workspaceId, storeId, Buffer.alloc(32), Buffer.alloc(12), randomUUID()],
),
);
const key = randomUUID();
const post = (idempotencyKey: string) =>
request(`/api/stores/${storeId}/platform-product-sync-runs`, {
method: 'POST',
headers: auth({ 'Idempotency-Key': idempotencyKey }),
body: JSON.stringify({ reason: 'manual_refresh' }),
});
const created = await post(key);
assert.equal(created.response.status, 202);
assert.equal(created.body.data.sync_run.status, 'queued');
const repeated = await post(key);
assert.equal(repeated.response.status, 202);
assert.equal(repeated.body.data.sync_run.id, created.body.data.sync_run.id);
const concurrent = await post(randomUUID());
assert.equal(concurrent.response.status, 409);
assert.equal(concurrent.body.error.code, 'SYNC_ALREADY_RUNNING');
const events = await database.workspaceTransaction(workspaceId, (tx) =>
tx.query<{ count: string }>(
`SELECT count(*)::text AS count FROM atlas.outbox_events
WHERE aggregate_id = $1 AND event_type = 'platform_product_sync.requested'`,
[created.body.data.sync_run.id],
),
);
assert.equal(events.rows[0].count, '1');
});
});
@@ -0,0 +1,88 @@
import assert from 'node:assert/strict';
import type { Database } from '@atlas/database';
import { describe, it } from 'vitest';
import {
attributeBatchInputSchema,
parseInput,
refreshPolicyInputSchema,
sourceInputSchema,
syncInputSchema,
} from '../src/platform-catalog-admin/platform-catalog-admin.models.js';
import { hasSystemAdminGrant } from '../src/platform-catalog-admin/system-admin.guard.js';
describe('平台目录系统管理员边界', () => {
it('只有未撤销且用户有效的数据库授权才被视为系统管理员', async () => {
const queries: Array<{ sql: string; parameters: unknown[] }> = [];
const database = {
query: async (sql: string, parameters: unknown[]) => {
queries.push({ sql, parameters });
return { rowCount: 1, rows: [{ '?column?': 1 }] };
},
} as unknown as Database;
assert.equal(await hasSystemAdminGrant(database, 'user-1'), true);
assert.deepEqual(queries[0].parameters, ['user-1']);
assert.match(queries[0].sql, /revoked_at IS NULL/);
assert.match(queries[0].sql, /user_row\.status = 'active'/);
});
it('同步请求不能混入其他作用域字段', () => {
assert.throws(() => parseInput(syncInputSchema, {
scope: 'tree',
category_id: '3f7e8462-c23c-4a38-ae86-0f3d78503870',
}));
assert.throws(() => parseInput(syncInputSchema, {
scope: 'dictionary',
attribute_id: 'not-a-uuid',
}));
});
it('同步来源仅接受 Client-Id 与 Api-Key', () => {
assert.deepEqual(parseInput(sourceInputSchema, {
client_id: ' 12345 ',
api_key: ' secret ',
}), { client_id: '12345', api_key: 'secret' });
assert.throws(() => parseInput(sourceInputSchema, {
client_id: '12345',
api_key: 'secret',
workspace_id: 'forbidden',
}));
});
it('刷新策略要求过期天数严格大于新鲜天数', () => {
assert.deepEqual(parseInput(refreshPolicyInputSchema, {
attribute_fresh_days: 14,
attribute_expire_days: 21,
dictionary_fresh_days: 14,
dictionary_expire_days: 21,
}), {
attribute_fresh_days: 14,
attribute_expire_days: 21,
dictionary_fresh_days: 14,
dictionary_expire_days: 21,
});
assert.throws(() => parseInput(refreshPolicyInputSchema, {
attribute_fresh_days: 21,
attribute_expire_days: 21,
dictionary_fresh_days: 14,
dictionary_expire_days: 21,
}));
assert.throws(() => parseInput(refreshPolicyInputSchema, {
attribute_fresh_days: 14,
attribute_expire_days: 21,
dictionary_fresh_days: 0,
dictionary_expire_days: 21,
}));
});
it('属性同步批次只接受三种冻结范围', () => {
for (const mode of ['missing_expired', 'missing_stale_expired', 'all'] as const) {
assert.deepEqual(parseInput(attributeBatchInputSchema, { mode }), { mode });
}
assert.throws(() => parseInput(attributeBatchInputSchema, { mode: 'stale_only' }));
assert.throws(() => parseInput(attributeBatchInputSchema, {
mode: 'all',
category_id: '3f7e8462-c23c-4a38-ae86-0f3d78503870',
}));
});
});
+294
View File
@@ -0,0 +1,294 @@
import assert from 'node:assert/strict';
import { randomUUID } from 'node:crypto';
import type { AddressInfo } from 'node:net';
import { Pool } from 'pg';
import type { Database } from '@atlas/database';
import type { NestExpressApplication } from '@nestjs/platform-express';
import { afterAll, beforeAll, describe, it } from 'vitest';
import { DATABASE } from '../src/database/database.module.js';
import { createApplication } from '../src/main.js';
let app: NestExpressApplication;
let db: Database;
let migrator: Pool;
let base = '',
token = '',
user = '',
workspace = '',
card = '',
platform = '';
const request = async (
path: string,
method = 'GET',
body?: unknown,
key?: string,
) => {
const response = await fetch(`${base}/api${path}`, {
method,
headers: {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
...(key ? { 'Idempotency-Key': key } : {}),
},
body: body === undefined ? undefined : JSON.stringify(body),
});
return { status: response.status, body: (await response.json()) as any };
};
describe('系统物流费率审核', () => {
beforeAll(async () => {
Object.assign(process.env, {
NODE_ENV: 'test',
SWAGGER_ENABLED: 'false',
AUTH_REGISTER_IP_LIMIT: '100',
AUTH_REGISTER_EMAIL_LIMIT: '100',
});
app = (await createApplication()).app;
db = app.get(DATABASE);
migrator = new Pool({
connectionString: process.env.MIGRATOR_DATABASE_URL,
max: 1,
});
await app.listen(0, '127.0.0.1');
base = `http://127.0.0.1:${(app.getHttpServer().address() as AddressInfo).port}`;
const registered = await request('/auth/register', 'POST', {
email: `pricing-review-${randomUUID()}@example.test`,
password: 'pricing-review-test-password-123',
password_confirmation: 'pricing-review-test-password-123',
});
assert.equal(registered.status, 201, JSON.stringify(registered.body));
token = registered.body.data.access_token;
user = registered.body.data.user.id;
workspace = registered.body.data.workspace.id;
const scope = await db.query<{
platform_id: string;
provider_id: string;
}>(
`SELECT platform.id AS platform_id,provider.id AS provider_id FROM atlas.platforms platform CROSS JOIN atlas.logistics_providers provider WHERE upper(platform.code::text)='OZON' AND upper(provider.code::text)='CEL' LIMIT 1`,
);
platform = scope.rows[0].platform_id;
const provider = scope.rows[0].provider_id;
card = (
await db.query<{ id: string }>(
`INSERT INTO atlas.logistics_rate_cards(provider_id,platform_id,fulfillment,market_code,version,source_name,status,effective_from)
VALUES($1,$2,'rfbs','RU',$3,'自动化审核测试,未来生效','draft',now()) RETURNING id`,
[provider, platform, `qa-${randomUUID()}`],
)
).rows[0].id;
const methods: string[] = [];
for (const code of ['express', 'standard', 'economy']) {
methods.push(
(
await db.query<{ id: string }>(
`INSERT INTO atlas.logistics_transport_methods(rate_card_id,code,name) VALUES($1,$2::text,$2::text) RETURNING id`,
[card, code],
)
).rows[0].id,
);
}
for (const [index, code] of [
'ExtraSmall',
'Budget',
'Small',
'Big',
'PremiumSmall',
'PremiumBig',
].entries()) {
const type = (
await db.query<{ id: string }>(
`INSERT INTO atlas.logistics_product_types(rate_card_id,code,name,merchandise_currency_code,min_merchandise_value,max_merchandise_value,min_actual_weight_kg,max_actual_weight_kg,billing_basis)
VALUES($1,$2::text,$2::text,'CNY',0,10000,0.001,30,'actual_only') RETURNING id`,
[card, code],
)
).rows[0].id;
for (const method of methods.slice(0, [3, 3, 3, 2, 3, 2][index]))
await db.query(
`INSERT INTO atlas.logistics_rate_rules(product_type_id,transport_method_id,fixed_fee_cny,per_billable_kg_fee_cny,status)
VALUES($1,$2,3.12,10,'draft')`,
[type, method],
);
}
});
afterAll(async () => {
if (migrator) {
if (card)
await migrator.query(
`DELETE FROM atlas.logistics_rate_card_reviews WHERE rate_card_id=$1`,
[card],
);
if (user)
await migrator.query(
`DELETE FROM atlas.system_admin_grants WHERE user_id=$1`,
[user],
);
}
if (db) {
if (card)
await db.query(
`DELETE FROM atlas.logistics_rate_cards WHERE id=$1`,
[card],
);
if (user) {
await db.query(
`DELETE FROM atlas.workspace_memberships WHERE user_id=$1`,
[user],
);
await db.query(`DELETE FROM atlas.workspaces WHERE id=$1`, [
workspace,
]);
await db.query(`DELETE FROM atlas.users WHERE id=$1`, [user]);
}
}
if (migrator) await migrator.end();
if (app) await app.close();
});
it('仅系统管理员可读取和启用;六类账单复算、重放与过期版本保护', async () => {
assert.equal(
(
await request(
`/system/pricing/rate-cards?platform_id=${platform}`,
)
).status,
403,
);
await migrator.query(
`INSERT INTO atlas.system_admin_grants(user_id) VALUES($1)`,
[user],
);
const listed = await request(
`/system/pricing/rate-cards?platform_id=${platform}`,
);
assert.equal(listed.status, 200, JSON.stringify(listed.body));
const item = listed.body.data.find((row: any) => row.id === card);
assert.equal(item.status, 'draft');
assert.equal(item.types.length, 6);
assert.equal(item.rules.length, 16);
const input = {
rules_hash: item.rules_hash,
source_reference: 'CEL 真实账单 QA 批次-2026-09',
review_note:
'逐类型核对实际包装、货值、计费重量与物流账单费用;本测试版本仅在未来生效。',
effective_from: new Date(Date.now() + 86400000).toISOString(),
samples: item.types.map((type: any, index: number) => ({
rule_id: item.rules.find(
(rule: any) => rule.product_type_id === type.id,
).id,
invoice_reference: `QA-INVOICE-${index + 1}`,
merchandise_value_cny: '100',
shipping_weight_g: '1000',
length_mm: '100',
width_mm: '100',
height_mm: '100',
billed_logistics_cny: '13.12',
})),
};
const key = randomUUID();
const mismatch = structuredClone(input);
mismatch.samples[0].billed_logistics_cny = '12.00';
const rejected = await request(
`/system/pricing/rate-cards/${card}/reviews`,
'POST',
mismatch,
randomUUID(),
);
assert.equal(rejected.status, 422, JSON.stringify(rejected.body));
const repeatedInvoice = structuredClone(input);
repeatedInvoice.samples[1].invoice_reference =
repeatedInvoice.samples[0].invoice_reference;
assert.equal(
(
await request(
`/system/pricing/rate-cards/${card}/reviews`,
'POST',
repeatedInvoice,
randomUUID(),
)
).status,
422,
);
assert.equal(
(await request(`/system/pricing/rate-cards/${card}`)).body.data
.status,
'draft',
);
const [accepted, concurrentReplay] = await Promise.all([
request(
`/system/pricing/rate-cards/${card}/reviews`,
'POST',
input,
key,
),
request(
`/system/pricing/rate-cards/${card}/reviews`,
'POST',
input,
key,
),
]);
assert.equal(accepted.status, 201, JSON.stringify(accepted.body));
assert.equal(
concurrentReplay.status,
201,
JSON.stringify(concurrentReplay.body),
);
assert.equal(accepted.body.data.status, 'active');
assert.equal(accepted.body.data.review.samples.length, 6);
const replay = await request(
`/system/pricing/rate-cards/${card}/reviews`,
'POST',
input,
key,
);
assert.equal(replay.status, 201, JSON.stringify(replay.body));
assert.equal(
(
await request(
`/system/pricing/rate-cards/${card}/reviews`,
'POST',
mismatch,
key,
)
).status,
409,
);
assert.equal(
(
await request(
`/system/pricing/rate-cards/${card}/reviews`,
'POST',
input,
randomUUID(),
)
).status,
409,
);
const activeRuleCount = await db.query<{ count: string }>(
`SELECT count(*) FROM atlas.logistics_rate_rules r JOIN atlas.logistics_product_types t ON t.id=r.product_type_id WHERE t.rate_card_id=$1 AND r.status='active'`,
[card],
);
assert.equal(Number(activeRuleCount.rows[0].count), 16);
await assert.rejects(() =>
db.query(
`UPDATE atlas.logistics_rate_rules SET fixed_fee_cny=4 WHERE id=$1`,
[item.rules[0].id],
),
);
await assert.rejects(() =>
db.query(
`UPDATE atlas.logistics_rate_cards SET source_name='错误改写' WHERE id=$1`,
[card],
),
);
const draftCard = await db.query<{ id: string }>(
`SELECT id FROM atlas.logistics_rate_cards WHERE version='cel-rfbs-review-2026-09' LIMIT 1`,
);
await assert.rejects(() =>
db.query(
`UPDATE atlas.logistics_product_types SET rate_card_id=$1 WHERE id=$2`,
[draftCard.rows[0].id, item.types[0].id],
),
);
});
});
@@ -0,0 +1,18 @@
import assert from 'node:assert/strict';
import { describe, it } from 'vitest';
import { draftPricingEnabled } from '../src/config/app-config.module.js';
describe('草稿费率配置', () => {
it('开发环境默认开放,可显式关闭;测试环境需显式开启', () => {
assert.equal(draftPricingEnabled('development', 'auto'), true);
assert.equal(draftPricingEnabled('development', 'false'), false);
assert.equal(draftPricingEnabled('test', 'auto'), false);
assert.equal(draftPricingEnabled('test', 'true'), true);
});
it('生产环境默认允许草稿卡,可显式关闭', () => {
assert.equal(draftPricingEnabled('production', 'true'), true);
assert.equal(draftPricingEnabled('production', 'auto'), true);
assert.equal(draftPricingEnabled('production', 'false'), false);
});
});
+334
View File
@@ -0,0 +1,334 @@
import { describe, it, expect } from 'vitest';
import {
calculatePricing,
type LogisticsRule,
type CommissionRule,
} from '../src/pricing/pricing-engine.js';
import { pricingPreviewRequestSchema, pricingInputKey } from '@atlas/contracts';
const id = 'ad877f74-36d0-4ef9-a711-cd904b37912f';
const input = () =>
pricingPreviewRequestSchema.parse({
store_id: id,
platform_product_category_id: id,
fulfillment: 'rfbs',
purchase_cost_cny: '28',
package: {
shipping_weight_g: '380',
length_mm: '220',
width_mm: '160',
height_mm: '90',
status: 'confirmed',
},
pricing: {
rate_card_id: id,
transport_method_ids: [id],
default_transport_method_id: id,
domestic_shipping_and_label_fee_cny: '3',
ad_rate: '0.03',
last_mile_rate: '0.02',
withdrawal_rate: '0.01',
other_rate: '0.02',
desired_markup_rate: '0.5',
list_price_discount_rate: '0.2',
},
});
const rule: LogisticsRule = {
id,
product_type_id: id,
transport_method_id: id,
transport_name: '标准',
product_type_name: 'ExtraSmall',
fixed_fee_cny: '3.12',
per_billable_kg_fee_cny: '36.4',
min_charge_cny: null,
surcharge_rules: [],
merchandise_currency_code: 'CNY',
min_merchandise_value: null,
max_merchandise_value: '135',
min_value_inclusive: true,
max_value_inclusive: true,
min_actual_weight_kg: '0.001',
min_actual_weight_inclusive: true,
max_actual_weight_kg: '0.5',
max_sum_dimensions_cm: '90',
max_longest_side_cm: '60',
max_second_longest_side_cm: null,
billing_basis: 'actual_only',
volume_divisor: null,
volume_condition_sum_gt_cm: null,
volume_round_scale: null,
billing_round_scale: null,
max_volume_weight_kg: null,
volume_limit_round_scale: null,
};
const commission: CommissionRule = {
id,
currency_code: 'RUB',
min_price: null,
max_price: null,
min_inclusive: true,
max_inclusive: true,
commission_rate: '0.12',
version: 'test',
};
describe('十进制定价引擎', () => {
it('默认方案折扣率为 50% 时,划线价是实际售价的两倍', () => {
const i = input();
i.pricing.list_price_discount_rate = '0.5';
for (const currency of ['CNY', 'RUB']) {
const option = calculatePricing(i, currency, '12.3', [rule], [commission])
.options[0];
expect(Number(option.list_price)).toBe(Number(option.selling_price) * 2);
}
});
it('目标加价与明细一致,CNY / RUB 都按最终币种舍入', () => {
const c = calculatePricing(input(), 'CNY', '12.3', [rule], [commission])
.options[0];
expect(c.selling_price).toBe('77.44');
expect(c.profit_cny).toBe('14.00');
expect(c.list_price).toBe('96.80');
expect(c.fees.logistics_cny).toBe('16.9520');
const rub = calculatePricing(
input(),
'RUB',
'12.3',
[rule],
[commission],
).options[0];
expect(rub.selling_price).toBe('952.52');
expect(rub.can_confirm).toBe(true);
});
it('固定售价保持原价并识别亏损', () => {
const i = input();
i.pricing.mode = 'fixed_sale';
i.pricing.selling_price = '10';
const o = calculatePricing(i, 'CNY', '12.3', [rule], [commission])
.options[0];
expect(o.selling_price).toBe('10.00');
expect(o.can_confirm).toBe(false);
expect(Number(o.profit_cny)).toBeLessThan(0);
});
it('零佣金仍生成正式报价,平台佣金费用为零', () => {
const o = calculatePricing(
input(),
'RUB',
'12.3',
[rule],
[{ ...commission, commission_rate: '0.000000' }],
).options[0];
expect(o.commission_rate).toBe('0.000000');
expect(o.fees.commission_cny).toBe('0.0000');
});
it('实际重、尺寸和开区间边界精确生效', () => {
const i = input();
i.package.shipping_weight_g = '500';
expect(
calculatePricing(i, 'CNY', '12.3', [rule], [commission]).options,
).toHaveLength(1);
i.package.shipping_weight_g = '500.001';
expect(
calculatePricing(i, 'CNY', '12.3', [rule], [commission]).options,
).toHaveLength(0);
i.package.shipping_weight_g = '380';
expect(
calculatePricing(
i,
'CNY',
'12.3',
[
{
...rule,
min_actual_weight_kg: '0.380',
min_actual_weight_inclusive: false,
},
],
[commission],
).options,
).toHaveLength(0);
});
it('先舍入售价再检查货值及佣金档,不跨档蒙混', () => {
const i = input();
expect(
calculatePricing(
i,
'CNY',
'12.3',
[{ ...rule, max_merchandise_value: '77.439' }],
[commission],
).options,
).toHaveLength(0);
expect(
calculatePricing(
i,
'CNY',
'12.3',
[rule],
[{ ...commission, max_price: '952.51' }],
).options,
).toHaveLength(0);
});
it('总费率达到 100% 拒绝,未知附加费不能漏算', () => {
const i = input();
i.pricing.ad_rate = '0.9';
expect(
calculatePricing(i, 'CNY', '12.3', [rule], [commission]).options,
).toHaveLength(0);
expect(
calculatePricing(
input(),
'CNY',
'12.3',
[{ ...rule, surcharge_rules: [{ unknown: true }] }],
[commission],
).options,
).toHaveLength(0);
});
it('体积重按指定位数进位并检查进位后的上限', () => {
const i = input();
i.package = {
shipping_weight_g: '5000',
length_mm: '500',
width_mm: '400',
height_mm: '301',
status: 'confirmed',
};
const volume = {
...rule,
max_actual_weight_kg: '30',
max_sum_dimensions_cm: '310',
billing_basis: 'max_actual_volume',
volume_divisor: '12000',
volume_round_scale: 3,
max_volume_weight_kg: '6',
volume_limit_round_scale: 0,
};
expect(
calculatePricing(
i,
'CNY',
'12.3',
[{ ...volume, max_merchandise_value: null }],
[commission],
).options[0].billable_weight_kg,
).toBe('5.017');
expect(
calculatePricing(
i,
'CNY',
'12.3',
[{ ...volume, max_volume_weight_kg: '5' }],
[commission],
).options,
).toHaveLength(0);
});
it('默认运输失效时不偷偷选择其他方式', () => {
const i = input();
i.pricing.default_transport_method_id =
'8d6df67c-1fa3-45b9-9766-d4f5c813b903';
expect(
calculatePricing(i, 'CNY', '12.3', [rule], [commission])
.recommended_option_id,
).toBeNull();
});
it('格式化金额与 JSONB 字段重排不会使同一输入失效', () => {
const original = input();
const reordered = JSON.parse(
JSON.stringify(original, (_key, value) =>
value && typeof value === 'object' && !Array.isArray(value)
? Object.fromEntries(Object.entries(value).reverse())
: value,
),
);
reordered.purchase_cost_cny = '28.00';
reordered.package.shipping_weight_g = '380.000';
reordered.pricing.ad_rate = '0.030000';
expect(pricingInputKey(original)).toBe(pricingInputKey(reordered));
reordered.package.length_mm = '221';
expect(pricingInputKey(original)).not.toBe(pricingInputKey(reordered));
});
it('显示利润舍入为零时,真实微亏仍禁止确认', () => {
const i = input();
i.pricing.mode = 'fixed_sale';
i.pricing.selling_price = '59.94';
const option = calculatePricing(
i,
'CNY',
'12.3',
[{ ...rule, fixed_fee_cny: '3.121' }],
[commission],
).options[0];
expect(option.profit_cny).toBe('-0.00');
expect(option.can_confirm).toBe(false);
});
});
describe('导入佣金的卢布分档边界', () => {
const bands: CommissionRule[] = [
{
...commission,
min_price: '0',
max_price: '1500',
min_inclusive: false,
commission_rate: '0.12',
},
{
...commission,
min_price: '1500',
max_price: '5000',
min_inclusive: false,
commission_rate: '0.14',
},
{
...commission,
min_price: '5000',
max_price: null,
min_inclusive: false,
commission_rate: '0.205',
},
];
it.each([
['1500', '0.12'],
['1500.01', '0.14'],
['5000', '0.14'],
['5000.01', '0.205'],
])('售价 %s 只命中对应佣金档', (price, rate) => {
const i = input();
i.pricing.mode = 'fixed_sale';
i.pricing.selling_price = price;
const result = calculatePricing(
i,
'RUB',
'12.3',
[{ ...rule, max_merchandise_value: null }],
bands,
);
expect(result.options).toHaveLength(1);
expect(Number(result.options[0].commission_rate)).toBe(Number(rate));
});
it('人民币售价换算卢布后匹配费率', () => {
const i = input();
i.pricing.mode = 'fixed_sale';
i.pricing.selling_price = '150';
const result = calculatePricing(
i,
'CNY',
'10',
[{ ...rule, max_merchandise_value: null }],
bands,
);
expect(result.options).toHaveLength(1);
expect(Number(result.options[0].commission_rate)).toBe(0.12);
i.pricing.selling_price = '150.01';
expect(
Number(
calculatePricing(
i,
'CNY',
'10',
[{ ...rule, max_merchandise_value: null }],
bands,
).options[0].commission_rate,
),
).toBe(0.14);
});
});
+725
View File
@@ -0,0 +1,725 @@
import assert from 'node:assert/strict';
import { randomUUID } from 'node:crypto';
import type { AddressInfo } from 'node:net';
import type { Database } from '@atlas/database';
import type { NestExpressApplication } from '@nestjs/platform-express';
import type {
PricingPreviewRequest,
ProductEditorSnapshot,
SaveProductEditorDraftRequest,
} from '@atlas/contracts';
import { pricingPreviewRequestSchema } from '@atlas/contracts';
import { beforeAll, afterAll, it, describe } from 'vitest';
import { DATABASE } from '../src/database/database.module.js';
import { createApplication } from '../src/main.js';
let app: NestExpressApplication,
db: Database,
base = '',
workspace = '',
user = '',
token = '',
platform = '',
store = '',
category = '',
product = '',
profile = '',
provider = '',
card = '',
transport = '',
variant = '';
async function request(
path: string,
method = 'GET',
body?: unknown,
extra: Record<string, string> = {},
) {
const r = await fetch(base + '/api' + path, {
method,
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
...(method !== 'GET' ? { 'Idempotency-Key': randomUUID() } : {}),
...extra,
},
body: body === undefined ? undefined : JSON.stringify(body),
});
const data = (await r.json()) as any;
return { status: r.status, ...data };
}
async function read(): Promise<ProductEditorSnapshot> {
const r = await request(`/products/${product}/editor`, 'GET', undefined, {
'X-Atlas-Platform-Id': platform,
});
assert.equal(r.status, 200, JSON.stringify(r));
return r.data;
}
function draft(s: ProductEditorSnapshot): SaveProductEditorDraftRequest {
return {
ai_source_material: s.source_material,
ai_source_overridden_fields: s.source_material_state.overridden_fields,
common: {
...s.product.common,
default_physical_spec: s.product.default_physical_spec,
default_package_spec: {
shipping_weight_g: '380',
length_mm: '220',
width_mm: '160',
height_mm: '90',
status: 'confirmed',
},
},
profile: {
target_store_id: store,
fulfillment: 'rfbs',
title: s.profile.title,
description: s.profile.description,
tags: s.profile.tags,
platform_product_category_id: category,
category_source: 'manual',
},
variants: s.variants.map((v) => ({
id: v.id,
client_key: null,
display_name: v.display_name,
source_sku: v.source_sku,
purchase_cost_cny: '28.00',
specification_values: v.specification_values,
title: v.title,
tags: v.tags,
description: v.description,
physical_spec: v.physical_spec,
package_spec: v.package_spec,
offer_id: v.listing.offer_id,
media_asset_ids: [],
pricing_draft: v.listing.pricing_draft ?? null,
})),
};
}
function quote(
variantId: string | null = variant,
clientKey: string | null = null,
): PricingPreviewRequest {
return pricingPreviewRequestSchema.parse({
store_id: store,
platform_product_category_id: category,
fulfillment: 'rfbs',
binding: {
profile_id: profile,
variant_id: variantId,
client_key: clientKey,
},
purchase_cost_cny: '28',
package: {
shipping_weight_g: '380',
length_mm: '220',
width_mm: '160',
height_mm: '90',
status: 'confirmed',
},
pricing: {
rate_card_id: card,
transport_method_ids: [transport],
default_transport_method_id: transport,
domestic_shipping_and_label_fee_cny: '3',
ad_rate: '0.03',
last_mile_rate: '0.02',
withdrawal_rate: '0.01',
other_rate: '0.02',
desired_markup_rate: '0.5',
list_price_discount_rate: '0.2',
temporary_commission_rate: '0.12',
fx: {
mode: 'one_time_override',
rate: '12.3',
reason: '仅集成测试',
},
},
});
}
async function save(
s: ProductEditorSnapshot,
d: SaveProductEditorDraftRequest,
key = randomUUID(),
) {
return request(`/products/${product}/editor-draft`, 'PUT', d, {
'X-Atlas-Platform-Id': platform,
'If-Match': `"${s.product.revision}:${s.profile.revision}"`,
'Idempotency-Key': key,
});
}
describe('SKU 试算与原子确认 HTTP / PostgreSQL', () => {
beforeAll(async () => {
Object.assign(process.env, {
NODE_ENV: 'test',
PRICING_ALLOW_DRAFT_RATES: 'true',
SWAGGER_ENABLED: 'false',
AUTH_REGISTER_IP_LIMIT: '100',
AUTH_REGISTER_EMAIL_LIMIT: '100',
});
app = (await createApplication()).app;
db = app.get(DATABASE);
await app.listen(0, '127.0.0.1');
base = `http://127.0.0.1:${(app.getHttpServer().address() as AddressInfo).port}`;
const reg = await request('/auth/register', 'POST', {
email: `pricing-${randomUUID()}@example.test`,
password: 'pricing-test-password-123',
password_confirmation: 'pricing-test-password-123',
});
assert.equal(reg.status, 201, JSON.stringify(reg));
workspace = reg.data.workspace.id;
user = reg.data.user.id;
token = reg.data.access_token;
platform = (await request('/platforms')).data[0].id;
await db.workspaceTransaction(workspace, async (tx) => {
store = (
await tx.query<{ id: string }>(
`INSERT INTO atlas.stores(workspace_id,platform_id,name,platform_client_id,currency_code,is_default,status,credential_status) VALUES($1,$2,'定价集成测试',$3,'CNY',true,'active','valid') RETURNING id`,
[workspace, platform, randomUUID()],
)
).rows[0].id;
category = (
await tx.query<{ id: string }>(
`INSERT INTO atlas.platform_product_categories(platform_id,external_category_id,external_type_id,name,path) VALUES($1,$2,'test','测试类目','定价测试') RETURNING id`,
[platform, randomUUID()],
)
).rows[0].id;
await tx.query(
`INSERT INTO atlas.platform_catalog_resource_states(platform_id,scope,scope_key,category_id,last_success_at,is_complete) VALUES($1,'attributes','attributes:' || $2::uuid,$2,now(),true)`,
[platform, category],
);
provider = (
await tx.query<{ id: string }>(
`INSERT INTO atlas.logistics_providers(code,name) VALUES($1,'仅自动化测试') RETURNING id`,
[randomUUID()],
)
).rows[0].id;
card = (
await tx.query<{ id: string }>(
`INSERT INTO atlas.logistics_rate_cards(provider_id,platform_id,fulfillment,version,source_name,status,effective_from,reviewed_at,reviewed_by_user_id) VALUES($1,$2,'rfbs','test-1','测试规则,不用于业务','active',now()-interval '1 day',now(),$3) RETURNING id`,
[provider, platform, user],
)
).rows[0].id;
transport = (
await tx.query<{ id: string }>(
`INSERT INTO atlas.logistics_transport_methods(rate_card_id,code,name) VALUES($1,'standard','标准测试') RETURNING id`,
[card],
)
).rows[0].id;
const type = (
await tx.query<{ id: string }>(
`INSERT INTO atlas.logistics_product_types(rate_card_id,code,name,merchandise_currency_code,max_merchandise_value,max_actual_weight_kg,max_sum_dimensions_cm,max_longest_side_cm,billing_basis) VALUES($1,'ExtraSmall','小包测试','CNY',135,0.5,90,60,'actual_only') RETURNING id`,
[card],
)
).rows[0].id;
await tx.query(
`INSERT INTO atlas.logistics_rate_rules(product_type_id,transport_method_id,fixed_fee_cny,per_billable_kg_fee_cny) VALUES($1,$2,3.12,36.4)`,
[type, transport],
);
});
const collection = await request('/collection-items', 'POST', {
title: '定价集成测试商品',
});
assert.equal(collection.status, 201);
product = collection.data.product_id;
const context = await request(
`/collection-items/${collection.data.collection_item.id}/editor-contexts`,
'POST',
undefined,
{ 'X-Atlas-Platform-Id': platform },
);
assert.equal(context.status, 201, JSON.stringify(context));
profile = context.data.profile_id;
variant = (await read()).variants[0].id;
});
afterAll(async () => {
if (db && workspace) {
await db.workspaceTransaction(workspace, async (tx) => {
// 仅清理本测试创建的租户,按外键依赖顺序删除。
for (const table of [
'idempotency_requests',
'pricing_previews',
'product_variant_listing_media',
'product_variant_platform_listings',
'pricing_quote_options',
'pricing_quotes',
'pricing_policy_transport_methods',
'pricing_policies',
'product_unmapped_attributes',
'product_attributes',
'product_variants',
'product_platform_profiles',
'products',
'collection_items',
'outbox_events',
'operations',
'stores',
])
await tx.query(
`DELETE FROM atlas.${table} WHERE workspace_id=$1`,
[workspace],
);
});
if (card)
await db.query(
'DELETE FROM atlas.logistics_rate_cards WHERE id=$1',
[card],
);
if (provider)
await db.query(
'DELETE FROM atlas.logistics_providers WHERE id=$1',
[provider],
);
if (category) {
await db.query(
'DELETE FROM atlas.platform_catalog_resource_states WHERE category_id=$1',
[category],
);
await db.query(
'DELETE FROM atlas.platform_product_categories WHERE id=$1',
[category],
);
}
await db.query(
'DELETE FROM atlas.workspace_memberships WHERE user_id=$1',
[user],
);
await db.query('DELETE FROM atlas.workspaces WHERE id=$1', [
workspace,
]);
await db.query('DELETE FROM atlas.users WHERE id=$1', [user]);
}
if (app) await app.close();
});
it('目录只返回审核规则,预览幂等且独立试算不写商品', async () => {
const catalog = await request(`/pricing/catalog?store_id=${store}`);
assert.equal(catalog.status, 200, JSON.stringify(catalog));
assert.ok(catalog.data.rate_cards.some((c: any) => c.id === card));
const categories = await request(
`/pricing/categories?store_id=${store}&q=定价测试`,
);
assert.equal(categories.status, 200, JSON.stringify(categories));
const input = quote(),
key = randomUUID(),
a = await request('/pricing/quote-previews', 'POST', input, {
'Idempotency-Key': key,
}),
b = await request('/pricing/quote-previews', 'POST', input, {
'Idempotency-Key': key,
});
assert.equal(a.status, 201, JSON.stringify(a));
assert.deepEqual(b.data, a.data);
assert.equal(a.data.options[0].selling_price, '77.44');
assert.equal((await read()).variants[0].listing.selling_price, null);
});
it('原子确认、重放、重读与同输入再保存保持快照', async () => {
const s = await read(),
d = draft(s),
input = quote(),
p = await request('/pricing/quote-previews', 'POST', input);
d.variants[0].pricing_draft = input.pricing;
d.variants[0].pricing_selection = {
preview_id: p.data.preview_id,
option_id: p.data.recommended_option_id,
input_hash: p.data.input_hash,
};
const key = randomUUID(),
saved = await save(s, d, key);
assert.equal(saved.status, 200, JSON.stringify(saved));
assert.deepEqual((await save(s, d, key)).data, saved.data);
const after = await read();
assert.equal(after.variants[0].listing.pricing_valid, true);
assert.equal(
after.variants[0].listing.pricing_result?.profit_cny,
'14.00',
);
assert.equal(Number(after.variants[0].listing.selling_price), 77.44);
const again = await save(after, draft(after));
assert.equal(again.status, 200, JSON.stringify(again));
assert.equal((await read()).variants[0].listing.pricing_valid, true);
});
it('输入/有效期变化返回 SKU 错误,整笔商品保存回滚;无报价允许保存', async () => {
const s = await read(),
d = draft(s),
input = quote(),
p = await request('/pricing/quote-previews', 'POST', input);
d.common.source_title = '不应被保存';
d.variants[0].pricing_draft = input.pricing;
d.variants[0].purchase_cost_cny = '29';
d.variants[0].pricing_selection = {
preview_id: p.data.preview_id,
option_id: p.data.recommended_option_id,
input_hash: p.data.input_hash,
};
const failed = await save(s, d);
assert.equal(failed.status, 409, JSON.stringify(failed));
assert.equal(failed.error.code, 'PRICING_PREVIEW_STALE');
assert.equal((await read()).product.revision, s.product.revision);
d.variants[0].purchase_cost_cny = '28';
await db.workspaceTransaction(workspace, (tx) =>
tx.query(
`UPDATE atlas.pricing_previews SET expires_at=now()-interval '1 second' WHERE id=$1`,
[p.data.preview_id],
),
);
assert.equal((await save(s, d)).status, 409);
d.variants[0].pricing_selection = null;
d.variants[0].purchase_cost_cny = '29';
assert.equal((await save(s, d)).status, 200);
assert.equal((await read()).variants[0].listing.pricing_valid, false);
});
it('新 SKU client_key 与真实 ID 在同一事务映射', async () => {
const s = await read(),
d = draft(s),
input = quote(null, 'new-pricing'),
p = await request('/pricing/quote-previews', 'POST', input);
d.variants.push({
...d.variants[0],
id: null,
client_key: 'new-pricing',
display_name: '新报价 SKU',
pricing_draft: input.pricing,
pricing_selection: {
preview_id: p.data.preview_id,
option_id: p.data.recommended_option_id,
input_hash: p.data.input_hash,
},
});
const saved = await save(s, d);
assert.equal(saved.status, 200, JSON.stringify(saved));
const after = await read(),
newId = saved.data.created_variants[0].id;
assert.equal(
after.variants.find((v) => v.id === newId)?.listing.pricing_valid,
true,
);
});
it('其他租户不能读取预览或使用门店;报价复合外键拒绝跨 SKU 引用', async () => {
const outsider = randomUUID();
await db.workspaceTransaction(outsider, async (tx) => {
assert.equal(
(
await tx.query(
'SELECT id FROM atlas.pricing_previews WHERE store_id=$1',
[store],
)
).rowCount,
0,
);
assert.equal(
(
await tx.query(
'SELECT id FROM atlas.pricing_quotes WHERE store_id=$1',
[store],
)
).rowCount,
0,
);
});
const s = await read();
await assert.rejects(() =>
db.workspaceTransaction(workspace, async (tx) => {
await tx.query(
`UPDATE atlas.product_variant_platform_listings SET pricing_quote_id=(SELECT pricing_quote_id FROM atlas.product_variant_platform_listings WHERE product_variant_id=$1) WHERE product_variant_id=$2`,
[s.variants[1].id, s.variants[0].id],
);
}),
);
});
it('方案 CRUD、版本冲突、默认与归档不改变已确认报价', async () => {
const body = {
name: '测试默认方案',
parameters: {
...quote().pricing,
fx: { mode: 'policy_fixed', rate: '12.3' },
},
allow_commission_override: true,
};
const c = await request(
`/stores/${store}/pricing-policies`,
'POST',
body,
);
assert.equal(c.status, 201, JSON.stringify(c));
const id = c.data.id;
assert.equal(
(await request(`/pricing-policies/${id}/set-default`, 'POST'))
.status,
200,
);
assert.equal(
(
await request(`/pricing-policies/${id}`, 'PATCH', body, {
'If-Match': '"1"',
})
).status,
409,
);
const before = await read();
assert.equal(
(await request(`/pricing-policies/${id}`, 'DELETE')).status,
200,
);
assert.deepEqual(
(await read()).variants[1].listing.pricing_result,
before.variants[1].listing.pricing_result,
);
});
it('规则更新、独立预览和其他 SKU 预览不能被旧确认意图接受', async () => {
const state = await read(),
payload = draft(state),
input = quote();
const preview = await request('/pricing/quote-previews', 'POST', input);
payload.variants[0].pricing_draft = input.pricing;
payload.variants[0].pricing_selection = {
preview_id: preview.data.preview_id,
option_id: preview.data.recommended_option_id,
input_hash: preview.data.input_hash,
};
await db.query(
`UPDATE atlas.logistics_rate_cards SET version='test-2',updated_at=now() WHERE id=$1`,
[card],
);
assert.equal(
(await save(state, payload)).error.code,
'PRICING_PREVIEW_STALE',
);
const standalone = await request('/pricing/quote-previews', 'POST', {
...input,
binding: null,
});
payload.variants[0].pricing_selection = {
preview_id: standalone.data.preview_id,
option_id: standalone.data.recommended_option_id,
input_hash: standalone.data.input_hash,
};
assert.equal((await save(state, payload)).status, 409);
const other = await request('/pricing/quote-previews', 'POST', {
...input,
binding: { ...input.binding, variant_id: state.variants[1].id },
});
payload.variants[0].pricing_selection = {
preview_id: other.data.preview_id,
option_id: other.data.recommended_option_id,
input_hash: other.data.input_hash,
};
assert.equal((await save(state, payload)).status, 409);
assert.equal((await read()).product.revision, state.product.revision);
});
it('确认快照不可更新;历史预览过期不使确认失效', async () => {
const before = await read();
await assert.rejects(() =>
db.workspaceTransaction(workspace, (tx) =>
tx.query(
`UPDATE atlas.pricing_quotes SET purchase_cost_cny=0 WHERE workspace_id=$1 AND status='confirmed'`,
[workspace],
),
),
);
await assert.rejects(() =>
db.workspaceTransaction(workspace, (tx) =>
tx.query(
`UPDATE atlas.pricing_quote_options SET profit_rub=0 WHERE workspace_id=$1`,
[workspace],
),
),
);
await db.workspaceTransaction(workspace, (tx) =>
tx.query(
`UPDATE atlas.pricing_previews SET expires_at=now()-interval '1 day' WHERE workspace_id=$1`,
[workspace],
),
);
assert.deepEqual(
(await read()).variants[1].listing,
before.variants[1].listing,
);
});
it('没有当前报价的草稿保存成功,但发布校验给出定价问题', async () => {
const state = await read(),
payload = draft(state);
payload.variants[0].purchase_cost_cny = '41.00';
assert.equal((await save(state, payload)).status, 200);
const validation = await request(
`/product-platform-profiles/${profile}/validation`,
'GET',
undefined,
{ 'X-Atlas-Platform-Id': platform },
);
assert.equal(validation.status, 200, JSON.stringify(validation));
assert.ok(
validation.data.variants[0].issues.some(
(i: { code: string }) => i.code === 'PRICING_REQUIRED',
),
);
});
it('并发商品保存只有一个版本成功', async () => {
const state = await read(),
a = draft(state),
b = draft(state);
a.common.source_title = '并发 A';
b.common.source_title = '并发 B';
const results = await Promise.all([save(state, a), save(state, b)]);
assert.deepEqual(results.map((r) => r.status).sort(), [200, 409]);
});
it('未审核费率、缺失正式佣金以及亏损候选不会变成发布报价', async () => {
await db.query(
`UPDATE atlas.logistics_rate_cards SET reviewed_at=NULL WHERE id=$1`,
[card],
);
assert.equal(
(await request('/pricing/quote-previews', 'POST', quote())).error
.code,
'PRICING_RULES_UNAVAILABLE',
);
await db.query(
`UPDATE atlas.logistics_rate_cards SET reviewed_at=now() WHERE id=$1`,
[card],
);
const input = quote();
input.pricing.temporary_commission_rate = null;
const withoutCommission = await request(
'/pricing/quote-previews',
'POST',
input,
);
assert.equal(withoutCommission.error.code, 'PRICING_COMMISSION_UNAVAILABLE');
assert.match(withoutCommission.error.message, /本次自定义参数/);
input.pricing.temporary_commission_rate = '0.12';
input.pricing.mode = 'fixed_sale';
input.pricing.selling_price = '10';
const p = await request('/pricing/quote-previews', 'POST', input);
assert.equal(p.status, 201);
assert.equal(p.data.options[0].can_confirm, false);
const state = await read(),
payload = draft(state);
payload.variants[0].pricing_draft = input.pricing;
payload.variants[0].pricing_selection = {
preview_id: p.data.preview_id,
option_id: p.data.options[0].id,
input_hash: p.data.input_hash,
};
assert.equal((await save(state, payload)).status, 409);
});
it('草稿费率可试算、确认且不阻断首次上架预检', async () => {
await db.query(
`UPDATE atlas.logistics_rate_cards SET status='draft',reviewed_at=NULL,reviewed_by_user_id=NULL WHERE id=$1`,
[card],
);
await db.query(
`UPDATE atlas.logistics_rate_rules SET status='draft' WHERE product_type_id IN (SELECT id FROM atlas.logistics_product_types WHERE rate_card_id=$1)`,
[card],
);
const catalog = await request(`/pricing/catalog?store_id=${store}`);
assert.equal(catalog.status, 200, JSON.stringify(catalog));
assert.equal(
catalog.data.rate_cards.find((item: { id: string }) => item.id === card)
?.development_only,
true,
);
const input = quote();
input.purchase_cost_cny = '41.00';
const preview = await request('/pricing/quote-previews', 'POST', input);
assert.equal(preview.status, 201, JSON.stringify(preview));
assert.equal(preview.data.context.development_only, true);
const state = await read();
const payload = draft(state);
payload.variants[0].purchase_cost_cny = '41.00';
payload.variants[0].pricing_draft = input.pricing;
payload.variants[0].pricing_selection = {
preview_id: preview.data.preview_id,
option_id: preview.data.recommended_option_id,
input_hash: preview.data.input_hash,
};
const saved = await save(state, payload);
assert.equal(saved.status, 200, JSON.stringify(saved));
const validation = await request(
`/product-platform-profiles/${profile}/validation`,
'GET',
undefined,
{ 'X-Atlas-Platform-Id': platform },
);
assert.equal(validation.status, 200, JSON.stringify(validation));
assert.equal(
validation.data.variants[0].issues.some(
(issue: { code: string }) =>
issue.code === 'PRICING_RATE_REVIEW_REQUIRED' ||
issue.code === 'PRICING_REQUIRED',
),
false,
);
const rateCard = await db.query<{ status: string; reviewed_at: Date | null }>(
'SELECT status,reviewed_at FROM atlas.logistics_rate_cards WHERE id=$1',
[card],
);
assert.equal(rateCard.rows[0].status, 'draft');
assert.equal(rateCard.rows[0].reviewed_at, null);
});
it('已上架 SKU 可确认本地新价格,保留首次导入身份且不触发平台任务', async () => {
const otherStore = randomUUID();
await db.workspaceTransaction(workspace, async (tx) => {
await tx.query('UPDATE atlas.products SET model=$2 WHERE id=$1', [product, 'LOCAL-RECORD-MODEL']);
await tx.query(
`UPDATE atlas.product_variant_platform_listings SET status='imported',offer_id='LOCAL-RECORD-SKU',platform_product_id='123456',first_imported_at=now() WHERE profile_id=$1 AND product_variant_id=$2`,
[profile, variant],
);
await tx.query(
`INSERT INTO atlas.stores(id,workspace_id,platform_id,name,platform_client_id,currency_code,status,credential_status) VALUES($1,$2,$3,'未来目标店铺',$4,'RUB','active','valid')`,
[otherStore, workspace, platform, randomUUID()],
);
await tx.query('UPDATE atlas.product_platform_profiles SET target_store_id=$2 WHERE id=$1', [profile, otherStore]);
});
const facts = () => db.workspaceTransaction(workspace, async (tx) => ({
listing: (await tx.query(
'SELECT status,store_id,offer_id,platform_product_id,first_imported_at,selling_price::text,list_price::text,pricing_quote_id FROM atlas.product_variant_platform_listings WHERE profile_id=$1 AND product_variant_id=$2',
[profile, variant],
)).rows[0],
publishJobs: (await tx.query('SELECT id FROM atlas.product_publish_jobs WHERE product_id=$1', [product])).rows.length,
events: (await tx.query('SELECT id FROM atlas.outbox_events')).rows.length,
}));
const before = await facts();
const state = await read();
assert.equal(state.variants.find((v) => v.id === variant)?.listing.store_id, store);
const input = quote();
input.pricing.mode = 'fixed_sale';
input.pricing.selling_price = '100';
input.pricing.list_price_discount_rate = '0.5';
const wrongStore = await request('/pricing/quote-previews', 'POST', { ...input, store_id: otherStore });
assert.equal(wrongStore.status, 422);
assert.equal(wrongStore.error.code, 'PRICING_VARIANT_STORE_MISMATCH');
const preview = await request('/pricing/quote-previews', 'POST', input);
assert.equal(preview.status, 201, JSON.stringify(preview));
const payload = draft(state);
payload.profile.target_store_id = otherStore;
const changed = payload.variants.find((v) => v.id === variant)!;
changed.pricing_draft = input.pricing;
changed.pricing_selection = {
preview_id: preview.data.preview_id,
option_id: preview.data.recommended_option_id,
input_hash: preview.data.input_hash,
};
changed.purchase_cost_cny = '29.00';
assert.equal((await save(state, payload)).status, 409);
assert.deepEqual(await facts(), before);
changed.purchase_cost_cny = '28.00';
const key = randomUUID();
const saved = await save(state, payload, key);
assert.equal(saved.status, 200, JSON.stringify(saved));
assert.deepEqual((await save(state, payload, key)).data, saved.data);
const after = await facts();
assert.equal(after.listing.selling_price, '100.0000');
assert.equal(after.listing.list_price, '200.0000');
assert.notEqual(after.listing.pricing_quote_id, before.listing.pricing_quote_id);
for (const field of ['status', 'store_id', 'offer_id', 'platform_product_id', 'first_imported_at'])
assert.deepEqual(after.listing[field], before.listing[field]);
assert.equal(after.publishJobs, before.publishJobs);
assert.equal(after.events, before.events);
const reread = await read();
const listing = reread.variants.find((v) => v.id === variant)!.listing;
assert.equal(listing.status, 'imported');
assert.equal(listing.pricing_result?.selling_price, '100.00');
const ordinary = draft(reread);
ordinary.profile.target_store_id = otherStore;
assert.equal((await save(reread, ordinary)).status, 200);
assert.deepEqual((await facts()).listing, after.listing);
});
});
+54
View File
@@ -0,0 +1,54 @@
import { describe, expect, it, vi } from 'vitest';
import { PRODUCT_AI_HINT_MAX_LENGTH, requestProductAiTaskSchema } from '@atlas/contracts';
import type { ProductEditorSnapshot } from '@atlas/contracts';
import { ProductAiService } from '../src/product-editor/product-ai.service.js';
describe('SKU 文案用户要求', () => {
it('将用户要求与 SKU 输入原子固化,并纳入幂等请求,不改写商品字段', async () => {
const hint = requestProductAiTaskSchema.parse({ hint: ' 改为红色款,25cm;标题不要写包邮。 ' }).hint;
let stored: Record<string, unknown> | undefined;
let outbox: unknown;
const statements: string[] = [];
const query = vi.fn(async (sql: string, values: unknown[] = []) => {
statements.push(sql);
if (sql.includes('SELECT product_id')) return { rows: [{ product_id: 'product' }] };
if (sql.includes('SELECT product.revision')) return { rows: [{ product_revision: 3, profile_revision: 4, source_snapshot_revision: 5 }] };
if (sql.includes('INSERT INTO atlas.product_ai_tasks')) {
stored = JSON.parse(values[4] as string);
return { rows: [{ id: 'task' }] };
}
if (sql.includes('INSERT INTO atlas.outbox_events')) outbox = JSON.parse(values[1] as string);
return { rows: [] };
});
const transaction = { query };
const workspaceTransaction = vi.fn(async (_workspace: string, work: (tx: typeof transaction) => Promise<unknown>) => work(transaction));
const run = vi.fn(async (_input: unknown, work: (tx: typeof transaction) => Promise<unknown>) => work(transaction));
const snapshot = {
product: { id: 'product', revision: 3, common: { model: 'model' } },
profile: { id: 'profile', revision: 4, platform_id: 'platform', target_store: { id: 'store' }, category: { id: null }, title: null, tags: [], description: null },
source_material: { source_snapshot_revision: 5, title: '采集蓝色 20cm', description: null },
variants: [{ id: 'sku', display_name: '蓝色款', specification_values: [], listing: { status: 'not_listed', offer_id: 'model-blue' }, tags: [] }],
} as unknown as ProductEditorSnapshot;
const service = new ProductAiService(
{ workspaceTransaction } as unknown as ConstructorParameters<typeof ProductAiService>[0],
{ run } as unknown as ConstructorParameters<typeof ProductAiService>[1],
{ read: vi.fn(async () => snapshot) } as unknown as ConstructorParameters<typeof ProductAiService>[2],
);
const result = await service.create('workspace', 'user', 'profile', 'platform', 'sku', 'key', hint);
expect(result).toMatchObject({ status: 202, body: { data: { ai_task_id: 'task', status: 'queued' } } });
expect(stored).toMatchObject({ hint, variant_id: 'sku', revision: { product: 3, profile: 4, source_snapshot: 5 } });
expect(stored?.source_material).toEqual(snapshot.source_material);
expect(run.mock.calls[0][0]).toMatchObject({ request: { hint, platform_id: 'platform' } });
expect(outbox).toEqual({ task_id: 'task' });
expect(statements.some((sql) => /UPDATE atlas\.(products|product_variants|collection_items)/.test(sql))).toBe(false);
expect(workspaceTransaction.mock.calls.every(([workspace]) => workspace === 'workspace')).toBe(true);
});
it('兼容不传要求,接受 1000 字符并拒绝超限和未知字段', () => {
expect(requestProductAiTaskSchema.parse({})).toEqual({});
expect(requestProductAiTaskSchema.parse({ hint: ' ' })).toEqual({ hint: '' });
expect(requestProductAiTaskSchema.safeParse({ hint: '要'.repeat(PRODUCT_AI_HINT_MAX_LENGTH) }).success).toBe(true);
expect(requestProductAiTaskSchema.safeParse({ hint: '要'.repeat(PRODUCT_AI_HINT_MAX_LENGTH + 1) }).success).toBe(false);
expect(requestProductAiTaskSchema.safeParse({ hint: '红色款', source_override: {} }).success).toBe(false);
});
});
@@ -0,0 +1,44 @@
import assert from 'node:assert/strict';
import { describe, it } from 'vitest';
import { productCatalogCacheStatus } from '../src/product-editor/product-editor.catalog-cache.js';
describe('商品编辑器平台目录新鲜度边界', () => {
const day = 86_400_000;
const now = Date.parse('2026-09-20T00:00:00.000Z');
it('从未成功同步时为 missing', () => {
assert.equal(productCatalogCacheStatus(null, 14, 21, now), 'missing');
});
it('14 天前为 fresh,恰好 14 天进入 stale', () => {
assert.equal(
productCatalogCacheStatus(
new Date(now - 14 * day + 1),
14,
21,
now,
),
'fresh',
);
assert.equal(
productCatalogCacheStatus(new Date(now - 14 * day), 14, 21, now),
'stale',
);
});
it('恰好 21 天仍 stale,严格超过才 expired', () => {
assert.equal(
productCatalogCacheStatus(new Date(now - 21 * day), 14, 21, now),
'stale',
);
assert.equal(
productCatalogCacheStatus(
new Date(now - 21 * day - 1),
14,
21,
now,
),
'expired',
);
});
});
@@ -0,0 +1,62 @@
import { describe, expect, it, vi } from 'vitest';
import { ProductEditorService } from '../src/product-editor/product-editor.service.js';
const productId = '733d0d73-3d39-4a98-ac0a-5c0e521d8998';
const variantId = '50ea00b7-6675-4cc7-b7a2-d45f05c07100';
const assetId = '664d0d73-3d39-4a98-ac0a-5c0e521d8998';
function mediaTransaction(processed: boolean, library?: 'upload' | 'import', owner = productId) {
const query = vi.fn(async (sql: string, values?: unknown[]) => {
if (sql.includes('FROM atlas.product_variant_platform_listings'))
return { rows: [{ id: 'listing-id', product_variant_id: variantId }] };
if (sql.includes('FROM atlas.media_assets'))
return { rows: [{ id: assetId, content_type: 'image/jpeg', byte_size: '1200', width_px: 1200, height_px: 1600 }] };
if (sql.includes('FROM atlas.media_upload_intents') || sql.includes('FROM atlas.product_image_imports')) {
expect(values).toEqual([productId, [assetId]]);
expect(sql).toContain('product_id = $1');
const matches = library === 'upload' ? sql.includes('media_upload_intents') : library === 'import' && sql.includes('product_image_imports');
return { rows: matches && owner === productId ? [{ asset_id: assetId }] : [] };
}
if (sql.includes('FROM atlas.media_aspect_ratio_derivatives')) {
expect(values).toEqual([productId, [assetId]]);
return { rows: processed ? [{ asset_id: assetId }] : [] };
}
return { rows: [] };
});
return { query };
}
describe('SKU 图片处理结果的保存资格', () => {
const input = {
product: { id: productId, collection_item_id: 'collection-id', source_snapshot_revision: 1 },
profile: { id: 'profile-id' },
variants: [{ variantId, assetIds: [assetId] }],
};
const service = new ProductEditorService(
{} as ConstructorParameters<typeof ProductEditorService>[0],
{} as ConstructorParameters<typeof ProductEditorService>[1],
{ pricingAllowDraftRates: false } as ConstructorParameters<typeof ProductEditorService>[2],
) as unknown as {
persistListingMedia: (tx: ReturnType<typeof mediaTransaction>, value: typeof input) => Promise<void>;
};
it.each(['upload', 'import'] as const)('%s 图片仅允许所属商品保存', async (library) => {
await service.persistListingMedia(mediaTransaction(false, library), input);
await expect(service.persistListingMedia(mediaTransaction(false, library, '其他商品'), input)).rejects.toMatchObject({
status: 422, response: { error: { code: 'LISTING_MEDIA_NOT_SELECTABLE' } },
});
});
it('当前商品的成功处理结果可加入 SKU 并保存', async () => {
const tx = mediaTransaction(true);
await service.persistListingMedia(tx, input);
expect(tx.query.mock.calls.some(([sql]) => String(sql).includes('INSERT INTO atlas.product_variant_listing_media'))).toBe(true);
});
it('其他商品或未成功的处理结果仍不能加入 SKU', async () => {
const tx = mediaTransaction(false);
await expect(service.persistListingMedia(tx, input)).rejects.toMatchObject({
status: 422, response: { error: { code: 'LISTING_MEDIA_NOT_SELECTABLE' } },
});
});
});
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,83 @@
import { randomUUID } from 'node:crypto';
import { createApplicationDatabase, type Database } from '@atlas/database';
import { describe, expect, it, vi } from 'vitest';
import { ProductImageDeletionService } from '../src/media/product-image-deletion.service.js';
import { IdempotencyService } from '../src/common/idempotency.service.js';
import { runMediaAssetDeletion, markMediaAssetDeletionFailed } from '../../worker/src/media-asset-delete.js';
/** 全部业务实体在结束时回滚,清理对象请求使用替身。 */
describe.skipIf(!process.env.DATABASE_URL)('商品图片永久删除数据库闭环', () => {
it('校验归属和引用,可靠清理可重试且幂等,删除后不能新增引用', async () => {
const db = createApplicationDatabase(process.env.DATABASE_URL!);
const rollback = Symbol('rollback');
try {
await db.transaction(async tx => {
const ws = randomUUID(), foreignWs = randomUUID(), user = randomUUID();
await tx.query("INSERT INTO atlas.users (id, email, password_hash) VALUES ($1, $2, 'test')", [user, `${user}@example.test`]);
for (const id of [ws, foreignWs]) await tx.query("INSERT INTO atlas.workspaces (id,name,slug) VALUES ($1::uuid,'删除测试',$1::text)", [id]);
const setWorkspace = (id: string) => tx.query("SELECT set_config('app.workspace_id', $1, true)", [id]);
await setWorkspace(ws);
const item = randomUUID(), product = randomUUID(), otherProduct = randomUUID(), asset = randomUUID();
await tx.query("INSERT INTO atlas.collection_items (id,workspace_id,source_kind,source_item_key) VALUES ($1::uuid,$2,'manual',$1::text)", [item, ws]);
await tx.query('INSERT INTO atlas.products (id,workspace_id,collection_item_id) VALUES ($1,$2,$3)', [product, ws, item]);
const otherItem = randomUUID();
await tx.query("INSERT INTO atlas.collection_items (id,workspace_id,source_kind,source_item_key) VALUES ($1::uuid,$2,'manual',$1::text)", [otherItem, ws]);
await tx.query('INSERT INTO atlas.products (id,workspace_id,collection_item_id) VALUES ($1,$2,$3)', [otherProduct, ws, otherItem]);
const checksum = 'a'.repeat(64);
await tx.query(`INSERT INTO atlas.media_assets (id,workspace_id,storage_provider,bucket,object_key,content_type,byte_size,checksum_sha256,width_px,height_px)
VALUES ($1,$2,'local_minio','test','test/canonical','image/png',100,$3,800,800)`, [asset, ws, checksum]);
await tx.query(`INSERT INTO atlas.media_upload_intents (workspace_id,product_id,purpose,storage_provider,bucket,object_key,expected_content_type,expected_byte_size,
expected_checksum_sha256,status,asset_id,expires_at,completed_at)
VALUES ($1,$2,'product_image','local_minio','test','test/duplicate','image/png',100,$3,'ready',$4,now()+interval '1 hour',now())`, [ws,product,checksum,asset]);
const scoped = { workspaceTransaction: async (id: string, work: (transaction: typeof tx) => Promise<unknown>) => { await setWorkspace(id); return work(tx); } } as Database;
const service = new ProductImageDeletionService(scoped, new IdempotencyService(scoped, { IDEMPOTENCY_KEY_SECRET: 'delete-test' } as never));
await expect(service.create(ws,user,otherProduct,randomUUID(),{asset_id:asset})).rejects.toMatchObject({status:404});
await tx.query('UPDATE atlas.users SET avatar_asset_id = $1 WHERE id = $2', [asset,user]);
await expect(service.create(ws,user,product,randomUUID(),{asset_id:asset})).rejects.toMatchObject({status:409});
await tx.query('SAVEPOINT in_use_delete');
await expect(tx.query('UPDATE atlas.media_assets SET deleted_at = now(), checksum_sha256 = NULL WHERE id = $1', [asset])).rejects.toMatchObject({code:'23514'});
await tx.query('ROLLBACK TO SAVEPOINT in_use_delete');
await tx.query('UPDATE atlas.users SET avatar_asset_id = NULL WHERE id = $1', [user]);
await tx.query("INSERT INTO atlas.product_image_imports (workspace_id,product_id,source_url,asset_id) VALUES ($1,$2,'https://example.test/shared.png',$3)", [ws,otherProduct,asset]);
await expect(service.create(ws,user,product,randomUUID(),{asset_id:asset})).rejects.toMatchObject({status:409});
await tx.query('DELETE FROM atlas.product_image_imports WHERE product_id = $1', [otherProduct]);
const key = randomUUID();
const result = await service.create(ws,user,product,key,{asset_id:asset});
const deletion = (result.body as {data:{id:string;status:string}}).data;
expect(deletion.status).toBe('queued');
expect((await service.create(ws,user,product,key,{asset_id:asset})).body).toEqual(result.body);
const removed = (await tx.query('SELECT deleted_at,checksum_sha256,deleted_checksum_sha256 FROM atlas.media_assets WHERE id = $1',[asset])).rows[0];
expect(removed.deleted_at).toBeTruthy(); expect(removed.checksum_sha256).toBeNull(); expect(removed.deleted_checksum_sha256).toBe(checksum);
expect((await tx.query("SELECT event_payload FROM atlas.outbox_events WHERE event_type = 'media.asset_delete.requested'")).rows).toHaveLength(1);
await tx.query('SAVEPOINT deleted_reference');
await expect(tx.query("INSERT INTO atlas.product_image_imports (workspace_id,product_id,source_url,asset_id) VALUES ($1,$2,'https://example.test/forbidden.png',$3)",[ws,product,asset])).rejects.toMatchObject({code:'23514'});
await tx.query('ROLLBACK TO SAVEPOINT deleted_reference');
const send = vi.fn().mockRejectedValueOnce(new Error('存储暂不可用')).mockResolvedValue({});
const storage = () => ({ send }) as never;
const job = {eventId:randomUUID(),workspaceId:ws,eventType:'media.asset_delete.requested',payload:{deletion_id:deletion.id}};
await expect(runMediaAssetDeletion(scoped,storage,job)).rejects.toThrow('存储暂不可用');
await markMediaAssetDeletionFailed(scoped,job);
const failed = await service.read(ws,product,deletion.id);
expect(failed.status).toBe('failed'); expect(failed.operation.status).toBe('failed');
await service.retry(ws,user,product,deletion.id,randomUUID());
await runMediaAssetDeletion(scoped,storage,job);
const succeeded = await service.read(ws,product,deletion.id);
expect(succeeded.status).toBe('succeeded'); expect(succeeded.operation.status).toBe('succeeded');
expect(send).toHaveBeenCalledTimes(3);
await runMediaAssetDeletion(scoped,storage,job);
expect(send).toHaveBeenCalledTimes(3);
await tx.query(`INSERT INTO atlas.media_assets (workspace_id,storage_provider,bucket,object_key,content_type,byte_size,checksum_sha256)
VALUES ($1,'local_minio','test','test/new-object','image/png',100,$2)`,[ws,checksum]);
const transfer = randomUUID();
await tx.query(`INSERT INTO atlas.collection_asset_transfers (id,workspace_id,collection_item_id,product_id,role,source_url)
VALUES ($1,$2,$3,$4,'source_main','https://example.test/source.png')`,[transfer,ws,item,product]);
const sourceDelete = await service.create(ws,user,product,randomUUID(),{collection_image_id:transfer});
expect((sourceDelete.body as {data:{status:string}}).data.status).toBe('succeeded');
expect((await tx.query('SELECT status,deleted_at FROM atlas.collection_asset_transfers WHERE id = $1',[transfer])).rows[0]).toMatchObject({status:'cancelled'});
await expect(service.read(foreignWs,product,deletion.id)).rejects.toMatchObject({status:404});
throw rollback;
});
} catch (error) { if (error !== rollback) throw error; }
finally { await db.close(); }
});
});
@@ -0,0 +1,56 @@
import { describe, expect, it, vi } from 'vitest';
import { ProductImageDerivativeHistoryService } from '../src/media/product-image-derivative-history.service.js';
const workspaceId = '30cf54b7-06a3-4e5f-a5fc-d74165115094';
const productId = '733d0d73-3d39-4a98-ac0a-5c0e521d8998';
describe('商品图片处理记录', () => {
it('只读取当前商品成功的 3:4 与水印结果,并重新签发预览地址', async () => {
const query = vi.fn(async (sql: string, values: unknown[]) => {
expect(values).toEqual([productId]);
if (sql.includes('FROM atlas.products')) return { rows: [{ id: productId }] };
expect(sql).toContain("WHERE product_id = $1 AND status = 'succeeded'");
expect(sql).toContain('LIMIT 100');
return { rows: [
{
id: 'watermark-task', kind: 'watermark', source_asset_id: 'source-1',
output_asset_id: 'output-1', created_at: new Date('2026-09-27T08:00:00Z'),
content_type: 'image/jpeg', byte_size: '1200', width_px: 1200, height_px: 1600,
asset_created_at: new Date('2026-09-27T08:01:00Z'),
},
{
id: 'ratio-task', kind: 'aspect_ratio_3_4', source_asset_id: 'source-2',
output_asset_id: 'output-2', created_at: new Date('2026-09-27T07:00:00Z'),
content_type: 'image/jpeg', byte_size: '1100', width_px: 1200, height_px: 1600,
asset_created_at: new Date('2026-09-27T07:01:00Z'),
},
] };
});
const workspaceTransaction = vi.fn(async (_id: string, work: (tx: { query: typeof query }) => Promise<unknown>) => work({ query }));
const accessUrl = vi.fn(async (_workspace: string, assetId: string) => ({
url: `https://example.test/${assetId}`, expires_at: '2026-09-27T08:10:00Z',
}));
const service = new ProductImageDerivativeHistoryService(
{ workspaceTransaction } as unknown as ConstructorParameters<typeof ProductImageDerivativeHistoryService>[0],
{ accessUrl } as unknown as ConstructorParameters<typeof ProductImageDerivativeHistoryService>[1],
);
const records = await service.list(workspaceId, productId);
expect(workspaceTransaction).toHaveBeenCalledWith(workspaceId, expect.any(Function));
expect(records).toMatchObject([
{ kind: 'watermark', output_asset: { id: 'output-1', preview_url: 'https://example.test/output-1' } },
{ kind: 'aspect_ratio_3_4', output_asset: { id: 'output-2', preview_url: 'https://example.test/output-2' } },
]);
expect(accessUrl).toHaveBeenCalledTimes(2);
});
it('商品不在当前工作空间时返回 404,不查询图片记录', async () => {
const query = vi.fn(async () => ({ rows: [] }));
const workspaceTransaction = vi.fn(async (_id: string, work: (tx: { query: typeof query }) => Promise<unknown>) => work({ query }));
const service = new ProductImageDerivativeHistoryService(
{ workspaceTransaction } as unknown as ConstructorParameters<typeof ProductImageDerivativeHistoryService>[0],
{} as ConstructorParameters<typeof ProductImageDerivativeHistoryService>[1],
);
await expect(service.list(workspaceId, productId)).rejects.toMatchObject({ status: 404 });
expect(query).toHaveBeenCalledOnce();
});
});
@@ -0,0 +1,101 @@
import { createHash, randomUUID } from 'node:crypto';
import { createApplicationDatabase, type Database, type DatabaseTransaction } from '@atlas/database';
import { describe, expect, it, vi } from 'vitest';
import { IdempotencyService } from '../src/common/idempotency.service.js';
import { MediaService } from '../src/media/media.service.js';
const bytes = Buffer.from('validated-product-image');
vi.mock('../src/media/product-image-download.js', () => ({
downloadProductImage: vi.fn(async () => ({ bytes, contentType: 'image/png', width: 800, height: 800 })),
}));
/** 归属验证只在回滚事务中创建数据,存储和公网下载由测试替身承担。 */
describe.skipIf(!process.env.DATABASE_URL)('商品上传与网络图片数据库隔离', () => {
it('候选只属于当前商品,重复素材可独立绑定,FK 与 RLS 拒绝越权', async () => {
const db = createApplicationDatabase(process.env.DATABASE_URL!);
const rollback = Symbol('rollback');
try {
await db.transaction(async (tx) => {
const workspaceId = randomUUID(), otherWorkspace = randomUUID(), userId = randomUUID();
await tx.query("INSERT INTO atlas.users (id, email, password_hash) VALUES ($1, $2, 'test-only')", [userId, `${userId}@example.test`]);
for (const id of [workspaceId, otherWorkspace])
await tx.query("INSERT INTO atlas.workspaces (id, name, slug) VALUES ($1::uuid, '图片归属测试', $1::text)", [id]);
const setWorkspace = (id: string) => tx.query("SELECT set_config('app.workspace_id', $1, true)", [id]);
const product = async (ws: string) => {
await setWorkspace(ws);
const id = randomUUID(), itemId = randomUUID();
await tx.query("INSERT INTO atlas.collection_items (id, workspace_id, source_kind, source_item_key) VALUES ($1::uuid, $2, 'manual', $1::text)", [itemId, ws]);
await tx.query('INSERT INTO atlas.products (id, workspace_id, collection_item_id) VALUES ($1, $2, $3)', [id, ws, itemId]);
return id;
};
const productA = await product(workspaceId), productB = await product(workspaceId), foreignProduct = await product(otherWorkspace);
await setWorkspace(workspaceId);
const assetA = randomUUID(), assetB = randomUUID(), legacyAsset = randomUUID();
const checksum = createHash('sha256').update(bytes).digest('hex');
for (const id of [assetA, assetB, legacyAsset])
await tx.query(`INSERT INTO atlas.media_assets
(id, workspace_id, storage_provider, bucket, object_key, content_type, byte_size, checksum_sha256, width_px, height_px)
VALUES ($1::uuid, $2, 'local_minio', 'test', $1::text, 'image/png', $3, $4, 800, 800)`,
[id, workspaceId, bytes.length, id === assetA ? checksum : id]);
const upload = async (productId: string | null, assetId: string) => tx.query(`INSERT INTO atlas.media_upload_intents
(workspace_id, purpose, storage_provider, bucket, object_key, expected_content_type,
expected_byte_size, expected_checksum_sha256, status, asset_id, expires_at, completed_at, product_id)
VALUES ($1, 'product_image', 'local_minio', 'test', $2, 'image/png', $3, $4, 'ready', $5, now() + interval '1 hour', now(), $6)`,
[workspaceId, randomUUID(), bytes.length, checksum, assetId, productId]);
const imported = async (productId: string | null, assetId: string) => tx.query(`INSERT INTO atlas.product_image_imports
(workspace_id, product_id, source_url, asset_id) VALUES ($1, $2, 'https://example.test/shared.png', $3)`,
[workspaceId, productId, assetId]);
await upload(productA, assetA);
await upload(productB, assetB);
await upload(null, legacyAsset);
await imported(productA, assetA);
await imported(productB, assetB);
await imported(null, legacyAsset);
const scoped = { workspaceTransaction: async <T>(ws: string, work: (tx: DatabaseTransaction) => Promise<T>) => {
await setWorkspace(ws);
return work(tx);
} } as Database;
const media = new MediaService(new IdempotencyService(scoped, { IDEMPOTENCY_KEY_SECRET: 'image-test-secret' } as never), scoped, {
OBJECT_STORAGE_ENDPOINT: 'http://127.0.0.1:9000', OBJECT_STORAGE_REGION: 'us-east-1', OBJECT_STORAGE_BUCKET: 'test',
OBJECT_STORAGE_PROVIDER: 'local_minio', objectStorageForcePathStyle: true, objectStorageAccessKey: 'test', objectStorageSecretKey: 'test',
} as never);
Object.assign(media, { ensureBucket: async () => undefined, inspectObject: async () => ({
byteSize: bytes.length, checksum, contentType: 'image/png', width: 800, height: 800,
}) });
const ids = (rows: Array<{ id: string }>) => rows.map((row) => row.id).sort();
expect(ids(await media.productUploads(workspaceId, productA))).toEqual([assetA]);
expect(ids(await media.productUploads(workspaceId, productB))).toEqual([assetB]);
expect(ids(await media.productImports(workspaceId, productA))).toEqual([assetA]);
expect(ids(await media.productImports(workspaceId, productB))).toEqual([assetB]);
const input = { purpose: 'product_image' as const, product_id: productB, filename: 'shared.png',
content_type: 'image/png' as const, byte_size: bytes.length, checksum_sha256: checksum };
const key = randomUUID();
const intent = await media.createIntent(workspaceId, userId, key, input);
const uploadId = (intent.body as { data: { upload_id: string } }).data.upload_id;
const repeated = await media.createIntent(workspaceId, userId, key, input);
expect((repeated.body as { data: { upload_id: string } }).data.upload_id).toBe(uploadId);
expect((await tx.query('SELECT product_id FROM atlas.media_upload_intents WHERE id = $1', [uploadId])).rows[0].product_id).toBe(productB);
await media.completeIntent(workspaceId, userId, uploadId, randomUUID(), { checksum_sha256: checksum });
expect(ids(await media.productUploads(workspaceId, productB))).toEqual([assetA, assetB].sort());
expect(ids(await media.productUploads(workspaceId, productA))).toEqual([assetA]);
await media.importProductImage(workspaceId, userId, randomUUID(), { product_id: productB, url: 'https://example.test/shared.png' });
expect(ids(await media.productImports(workspaceId, productB))).toEqual([assetA]);
expect(ids(await media.productImports(workspaceId, productA))).toEqual([assetA]);
const forbidden = async (work: () => Promise<unknown>) => {
await tx.query('SAVEPOINT forbidden_image');
try { await expect(work()).rejects.toMatchObject({ code: '23503' }); }
finally { await tx.query('ROLLBACK TO SAVEPOINT forbidden_image'); }
};
await forbidden(() => upload(foreignProduct, assetA));
await forbidden(() => imported(foreignProduct, assetA));
await tx.query('SAVEPOINT unknown_product');
await expect(media.createIntent(workspaceId, userId, randomUUID(), { ...input, product_id: foreignProduct })).rejects.toMatchObject({ status: 404 });
await tx.query('ROLLBACK TO SAVEPOINT unknown_product');
expect(await media.productUploads(otherWorkspace, productA)).toEqual([]);
expect(await media.productImports(otherWorkspace, productA)).toEqual([]);
throw rollback;
});
} catch (error) { if (error !== rollback) throw error; }
finally { await db.close(); }
});
});
@@ -0,0 +1,98 @@
import { describe, expect, it, vi } from 'vitest';
import { ProductImageTaskService } from '../src/media/product-image-task.service.js';
const workspaceId = '30cf54b7-06a3-4e5f-a5fc-d74165115094';
const productId = '733d0d73-3d39-4a98-ac0a-5c0e521d8998';
describe('商品图片任务列表', () => {
it('按工作空间和商品读取任务,结果只包含可公开的任务字段', async () => {
const query = vi.fn(async (sql: string, values: unknown[]) => {
expect(values[0]).toBe(productId);
if (sql.includes('atlas.products')) return { rows: [{ id: productId }] };
expect(values).toEqual([productId, 3]);
return { rows: [{
id: 'edit-id', status: 'running', provider_task_id: 'private-provider-id',
output_asset_id: null, error_code: null,
created_at: new Date('2026-09-26T03:00:00Z'), completed_at: null,
}, {
id: 'completed-id', status: 'succeeded', provider_task_id: 'private-provider-id',
output_asset_id: 'asset-id', error_code: null,
created_at: new Date('2026-09-26T02:00:00Z'), completed_at: new Date('2026-09-26T02:05:00Z'),
}] };
});
const workspaceTransaction = vi.fn(async (_workspace: string, work: (tx: { query: typeof query }) => Promise<unknown>) => work({ query }));
const service = new ProductImageTaskService(
{ workspaceTransaction } as unknown as ConstructorParameters<typeof ProductImageTaskService>[0],
);
const result = await service.list(workspaceId, productId, { limit: 2 });
expect(workspaceTransaction).toHaveBeenCalledWith(workspaceId, expect.any(Function));
expect(result.meta).toEqual({ has_more: false, next_cursor: null });
expect(result.data).toEqual([{
id: 'edit-id', kind: 'single_ai_edit', status: 'running', phase: 'waiting_provider',
total: 1, completed: 0, output_asset_ids: [], error_code: null, provider_error_raw: null,
failure_source: null, failure_stage: null, failure_detail: null,
created_at: '2026-09-26T03:00:00.000Z', completed_at: null,
}, {
id: 'completed-id', kind: 'single_ai_edit', status: 'succeeded', phase: null,
total: 1, completed: 1, output_asset_ids: ['asset-id'], error_code: null, provider_error_raw: null,
failure_source: null, failure_stage: null, failure_detail: null,
created_at: '2026-09-26T02:00:00.000Z', completed_at: '2026-09-26T02:05:00.000Z',
}]);
expect(JSON.stringify(result)).not.toContain('private-provider-id');
});
it('失败任务返回 Pcore 原始错误,不暴露供应商任务 ID', async () => {
const raw = '{"message":"请求无法用于生成图像","type":"image_task_error","code":"failed"}';
const query = vi.fn(async (sql: string) => {
if (sql.includes('atlas.products')) return { rows: [{ id: productId }] };
expect(sql).toContain('o.workspace_id = e.workspace_id');
return { rows: [{
id: 'failed-id', status: 'failed', provider_task_id: 'private-provider-id',
output_asset_id: null, error_code: 'AI_IMAGE_PROVIDER_FAILED', provider_error_raw: raw,
failure_source: 'pcore', failure_stage: 'provider_polling', failure_detail: 'Pcore 图片任务失败',
created_at: new Date('2026-09-27T03:00:00Z'), completed_at: new Date('2026-09-27T03:01:00Z'),
}] };
});
const workspaceTransaction = vi.fn(async (_workspace: string, work: (tx: { query: typeof query }) => Promise<unknown>) => work({ query }));
const service = new ProductImageTaskService(
{ workspaceTransaction } as unknown as ConstructorParameters<typeof ProductImageTaskService>[0],
);
const result = await service.list(workspaceId, productId, { limit: 20 });
expect(result.data[0].provider_error_raw).toBe(raw);
expect(result.data[0]).toMatchObject({
failure_source: 'pcore', failure_stage: 'provider_polling', failure_detail: 'Pcore 图片任务失败',
});
expect(JSON.stringify(result)).not.toContain('private-provider-id');
});
it('商品不属于当前工作空间时返回 404,不查询任务', async () => {
const query = vi.fn(async () => ({ rows: [] }));
const workspaceTransaction = vi.fn(async (_workspace: string, work: (tx: { query: typeof query }) => Promise<unknown>) => work({ query }));
const service = new ProductImageTaskService(
{ workspaceTransaction } as unknown as ConstructorParameters<typeof ProductImageTaskService>[0],
);
await expect(service.list(workspaceId, productId, { limit: 20 })).rejects.toMatchObject({ status: 404 });
expect(query).toHaveBeenCalledOnce();
});
it('超过页大小时返回游标,并将游标作为下一页的稳定排序条件', async () => {
const id = '68db226f-3fcb-4579-ab9e-4932b1d84067';
const rows = [0, 1, 2].map((index) => ({
id, status: 'succeeded', provider_task_id: null, output_asset_id: null,
error_code: null, created_at: new Date(`2026-09-26T0${3 - index}:00:00Z`),
completed_at: null,
}));
const query = vi.fn(async (sql: string, _values?: unknown[]) => ({ rows: sql.includes('atlas.products') ? [{ id: productId }] : rows }));
const workspaceTransaction = vi.fn(async (_workspace: string, work: (tx: { query: typeof query }) => Promise<unknown>) => work({ query }));
const service = new ProductImageTaskService(
{ workspaceTransaction } as unknown as ConstructorParameters<typeof ProductImageTaskService>[0],
);
const first = await service.list(workspaceId, productId, { limit: 2 });
expect(first.meta.has_more).toBe(true);
expect(first.meta.next_cursor).toEqual(expect.any(String));
await service.list(workspaceId, productId, { limit: 2, cursor: first.meta.next_cursor! });
const nextQuery = query.mock.calls.at(-1)!;
expect(nextQuery[0]).toContain('e.created_at, e.id)');
expect(nextQuery[1]).toEqual([productId, 0, '2026-09-26T02:00:00.000Z', id, 3]);
});
});
+865
View File
@@ -0,0 +1,865 @@
import assert from 'node:assert/strict';
import { randomUUID } from 'node:crypto';
import type { AddressInfo } from 'node:net';
import type { Database } from '@atlas/database';
import type { NestExpressApplication } from '@nestjs/platform-express';
import type { ProductPublishJob } from '@atlas/contracts';
import { ozonHashtagProblem } from '@atlas/contracts';
import type { S3Client } from '@aws-sdk/client-s3';
import { beforeAll, afterAll, describe, it } from 'vitest';
import { DATABASE } from '../src/database/database.module.js';
import { createApplication } from '../src/main.js';
import {
runProductPublish,
type PublishDependencies,
} from '../../worker/src/product-publish.js';
import {
OzonRateLimitError,
OzonRetryableError,
} from '../../worker/src/ozon-client.js';
let app: NestExpressApplication,
db: Database,
base = '',
workspace = '',
token = '',
platform = '',
store = '',
category = '';
const storage = {} as S3Client;
async function request(
path: string,
method = 'GET',
body?: unknown,
extra: Record<string, string> = {},
) {
const res = await fetch(`${base}/api${path}`, {
method,
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
'X-Atlas-Platform-Id': platform,
...extra,
},
body: body === undefined ? undefined : JSON.stringify(body),
});
return { status: res.status, body: (await res.json()) as any };
}
async function fixture(count = 2, mediaCount = 1) {
const created = await request(
'/collection-items',
'POST',
{ title: '发布测试商品' },
{ 'Idempotency-Key': randomUUID() },
);
assert.equal(created.status, 201, JSON.stringify(created));
const product = created.body.data.product_id;
const entered = await request(
`/collection-items/${created.body.data.collection_item.id}/editor-contexts`,
'POST',
{},
{ 'Idempotency-Key': randomUUID() },
);
assert.equal(entered.status, 201, JSON.stringify(entered));
const profile = entered.body.data.profile_id;
const ids: string[] = [];
await db.workspaceTransaction(workspace, async (tx) => {
await tx.query(
`UPDATE atlas.products SET brand='Без бренда',model='TEST-MODEL',default_package_length_mm=240,default_package_width_mm=120,default_package_height_mm=60,default_shipping_weight_g=350,package_spec_status='confirmed' WHERE id=$1`,
[product],
);
await tx.query(
`UPDATE atlas.product_platform_profiles SET title='Тестовый товар',fulfillment='rfbs',platform_product_category_id=$2,platform_type_id='1002' WHERE id=$1`,
[profile, category],
);
const first = (
await tx.query<{ id: string }>(
'SELECT id FROM atlas.product_variants WHERE product_id=$1',
[product],
)
).rows[0].id;
ids.push(first);
for (let i = 1; i < count; i++)
ids.push(
(
await tx.query<{ id: string }>(
`INSERT INTO atlas.product_variants(workspace_id,product_id,display_name,sort_order) VALUES($1,$2,$3,$4) RETURNING id`,
[workspace, product, `SKU ${i}`, i],
)
).rows[0].id,
);
for (let i = 0; i < ids.length; i++) {
const variant = ids[i];
await tx.query(
`INSERT INTO atlas.product_variant_platform_listings(workspace_id,product_id,profile_id,platform_id,product_variant_id,store_id) VALUES($1,$2,$3,$4,$5,$6) ON CONFLICT DO NOTHING`,
[workspace, product, profile, platform, variant, store],
);
const quote = (
await tx.query<{ id: string }>(
`INSERT INTO atlas.pricing_quotes(workspace_id,store_id,platform_id,product_id,profile_id,product_variant_id,fulfillment,purchase_cost_cny,package_weight_kg,package_length_cm,package_width_cm,package_height_cm,fx_cny_to_rub,input_snapshot,status,confirmed_at) VALUES($1,$2,$3,$4,$5,$6,'rfbs',20,0.350,24,12,6,12,'{}','confirmed',now()) RETURNING id`,
[workspace, store, platform, product, profile, variant],
)
).rows[0].id;
const listing = (
await tx.query<{ id: string }>(
`UPDATE atlas.product_variant_platform_listings SET offer_id=$2,selling_price=1999.1234,list_price=2999.1234,currency_code='RUB',pricing_quote_id=$3,pricing_valid=true WHERE profile_id=$1 AND product_variant_id=$4 RETURNING id`,
[profile, `TEST-${randomUUID()}`, quote, variant],
)
).rows[0].id;
for (let mediaIndex = 0; mediaIndex < mediaCount; mediaIndex++) {
const asset = (
await tx.query<{ id: string }>(
`INSERT INTO atlas.media_assets(workspace_id,storage_provider,bucket,object_key,content_type,byte_size,checksum_sha256) VALUES($1,'qiniu_kodo','test-bucket',$2,'image/png',100,$3) RETURNING id`,
[workspace, randomUUID(), randomUUID()],
)
).rows[0].id;
await tx.query(
`INSERT INTO atlas.product_variant_listing_media(workspace_id,product_id,profile_id,listing_id,product_variant_id,asset_id,sort_order) VALUES($1,$2,$3,$4,$5,$6,$7)`,
[workspace, product, profile, listing, variant, asset, mediaIndex],
);
}
}
});
return { profile, product, ids };
}
async function publish(
f: Awaited<ReturnType<typeof fixture>>,
ids = f.ids,
key = randomUUID(),
) {
return request(
`/product-platform-profiles/${f.profile}/publish-jobs`,
'POST',
{ variant_ids: ids },
{ 'Idempotency-Key': key, 'If-Match': '"1:1"' },
);
}
async function run(
job: ProductPublishJob,
requestFn: PublishDependencies['request'],
images: PublishDependencies['images'] = async () => [
'https://images.example.test/fixture.png',
],
) {
await runProductPublish(
db,
storage,
{
eventId: randomUUID(),
workspaceId: workspace,
eventType: 'product_publish.requested',
payload: { publish_job_id: job.id },
},
{
request: requestFn,
images,
decrypt: () => 'fixture-key',
},
);
const response = await request(`/product-publish-jobs/${job.id}`);
assert.equal(response.status, 200);
return response.body.data as ProductPublishJob;
}
describe('首次发布 HTTP / 数据库 / Worker 闭环', () => {
beforeAll(async () => {
Object.assign(process.env, {
NODE_ENV: 'test',
SWAGGER_ENABLED: 'false',
LOG_LEVEL: 'error',
AUTH_REGISTER_IP_LIMIT: '100',
AUTH_REGISTER_EMAIL_LIMIT: '100',
OZON_PUBLISH_ENABLED: 'true',
});
({ app } = await createApplication());
db = app.get<Database>(DATABASE);
await app.listen(0, '127.0.0.1');
base = `http://127.0.0.1:${(app.getHttpServer().address() as AddressInfo).port}`;
const registration = await fetch(`${base}/api/auth/register`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
email: `publish-${randomUUID()}@example.test`,
password: 'correct-horse-battery-staple-123',
password_confirmation: 'correct-horse-battery-staple-123',
}),
});
const data = (await registration.json()) as any;
assert.equal(registration.status, 201, JSON.stringify(data));
workspace = data.data.workspace.id;
token = data.data.access_token;
platform = (await request('/platforms')).body.data[0].id;
await db.workspaceTransaction(workspace, async (tx) => {
store = (
await tx.query<{ id: string }>(
`INSERT INTO atlas.stores(workspace_id,platform_id,name,platform_client_id,currency_code,is_default,status,credential_status) VALUES($1,$2,'发布测试店铺',$3,'RUB',true,'active','valid') RETURNING id`,
[workspace, platform, randomUUID()],
)
).rows[0].id;
await tx.query(
`INSERT INTO atlas.store_secrets(workspace_id,store_id,kind,secret_ciphertext,secret_nonce,encryption_key_version,fingerprint,status) VALUES($1,$2,'seller_api_key',$3,$4,'v1','fixture','valid')`,
[workspace, store, Buffer.from('fixture'), Buffer.alloc(12)],
);
category = (
await tx.query<{ id: string }>(
`INSERT INTO atlas.platform_product_categories(platform_id,external_category_id,external_type_id,name,path) VALUES($1,$2,'1002','发布测试类目','发布测试类目') RETURNING id`,
[platform, `${Date.now()}`],
)
).rows[0].id;
await tx.query(
`INSERT INTO atlas.platform_product_category_attributes(platform_id,platform_product_category_id,platform_attribute_id,name,value_type,is_required,is_aspect,max_value_count) VALUES($1,$2,'9048','型号','text',true,false,1),($1,$2,'23171','#主题标签','text',false,false,1)`,
[platform, category],
);
await tx.query(
`INSERT INTO atlas.platform_catalog_resource_states(platform_id,scope_key,scope,category_id,last_success_at,is_complete) VALUES($1,$2,'attributes',$3,now(),true)`,
[platform, `attributes:${category}`, category],
);
});
});
afterAll(async () => {
await app?.close();
});
it('商品颜色按属性 ID 支持多选保存、重读和发布,其他单值属性仍限制数量', async () => {
const f = await fixture(1);
const dictionaryId = `color-${randomUUID()}`;
await db.workspaceTransaction(workspace, async tx => {
const ownCategory = (await tx.query<{ id: string }>(
`INSERT INTO atlas.platform_product_categories(platform_id,external_category_id,external_type_id,name,path)
VALUES($1,$2,'1002','颜色多选测试','颜色多选测试') RETURNING id`,
[platform, String(Date.now()) + '96'],
)).rows[0].id;
const color = (await tx.query<{ id: string }>(
`INSERT INTO atlas.platform_product_category_attributes
(platform_id,platform_product_category_id,platform_attribute_id,name,value_type,is_required,is_aspect,is_collection,max_value_count,dictionary_id)
VALUES($1,$2,'10096','Цвет товара','dictionary',true,true,false,1,$3) RETURNING id`,
[platform, ownCategory, dictionaryId],
)).rows[0].id;
await tx.query(`INSERT INTO atlas.platform_product_category_attributes
(platform_id,platform_product_category_id,platform_attribute_id,name,value_type,is_aspect,is_collection,max_value_count)
VALUES($1,$2,'200001','单值测试','text',true,false,1)`, [platform, ownCategory]);
await tx.query(`INSERT INTO atlas.platform_catalog_dictionary_values
(platform_id,platform_product_category_id,attribute_id,dictionary_id,external_value_id,value)
VALUES($1,$2,$3,$4,'61579','红色'),($1,$2,$3,$4,'61581','蓝色')`,
[platform, ownCategory, color, dictionaryId]);
await tx.query(`INSERT INTO atlas.platform_catalog_resource_states
(platform_id,scope_key,scope,category_id,attribute_id,last_success_at,is_complete)
VALUES($1,'attributes:' || $2::text,'attributes',$2::uuid,NULL,now(),true),
($1,'dictionary:' || $3::text,'dictionary',$2::uuid,$3::uuid,now(),true)`,
[platform, ownCategory, color]);
await tx.query('UPDATE atlas.product_platform_profiles SET platform_product_category_id=$2 WHERE id=$1', [f.profile, ownCategory]);
await tx.query('UPDATE atlas.product_variant_platform_listings SET pricing_quote_id=NULL WHERE profile_id=$1', [f.profile]);
await tx.query(`UPDATE atlas.media_assets SET width_px=800,height_px=800
WHERE id IN (SELECT asset_id FROM atlas.product_variant_listing_media WHERE profile_id=$1)`, [f.profile]);
});
const before = (await request(`/products/${f.product}/editor`)).body.data;
const colorDraft = {
scope: 'variant', variant_id: f.ids[0], platform_attribute_id: '10096', complex_id: '0',
values: ['61579', '61581'], language_code: null, source: 'manual', confirmation_status: 'confirmed',
};
const draft = {
ai_source_material: before.source_material,
ai_source_overridden_fields: before.source_material_state.overridden_fields,
common: { ...before.product.common, default_physical_spec: before.product.default_physical_spec,
default_package_spec: before.product.default_package_spec },
profile: { target_store_id: store, fulfillment: before.profile.fulfillment, title: before.profile.title,
description: before.profile.description, tags: before.profile.tags,
platform_product_category_id: before.profile.category.id, category_source: 'manual' },
attributes: [colorDraft],
variants: before.variants.map((v: any) => ({
id: v.id, client_key: null, display_name: v.display_name, source_sku: v.source_sku,
specification_values: v.specification_values, purchase_cost_cny: v.purchase_cost_cny,
title: v.title, tags: v.tags, description: v.description, physical_spec: v.physical_spec,
package_spec: v.package_spec, offer_id: v.listing.offer_id,
media_asset_ids: v.media.map((m: any) => m.asset_id),
})),
};
const save = (attributes = draft.attributes) => request(`/products/${f.product}/editor-draft`, 'PUT',
{ ...draft, attributes }, { 'Idempotency-Key': randomUUID(), 'If-Match': '"1:1"' });
const invalidSingle = await save([{ ...colorDraft, platform_attribute_id: '200001', values: ['one', 'two'] }]);
assert.equal(invalidSingle.status, 422);
assert.equal(invalidSingle.body.error.code, 'ATTRIBUTE_VALUE_COUNT_INVALID');
const invalidDictionary = await save([{ ...colorDraft, values: ['61579', '99999999'] }]);
assert.equal(invalidDictionary.status, 422);
const saved = await save();
assert.equal(saved.status, 200, JSON.stringify(saved));
const after = (await request(`/products/${f.product}/editor`)).body.data;
assert.deepEqual(after.attributes.find((a: any) => a.platform_attribute_id === '10096').values, ['61579', '61581']);
// fixture 使用预先确认的模拟报价;草稿保存后恢复其有效标记,仅验证颜色发布映射。
await db.workspaceTransaction(workspace, tx => tx.query(
`UPDATE atlas.product_variant_platform_listings listing SET pricing_valid=true,
pricing_quote_id=(SELECT quote.id FROM atlas.pricing_quotes quote
WHERE quote.profile_id=listing.profile_id AND quote.product_variant_id=listing.product_variant_id LIMIT 1)
WHERE listing.profile_id=$1`, [f.profile]));
const job = await request(`/product-platform-profiles/${f.profile}/publish-jobs`, 'POST',
{ variant_ids: f.ids }, { 'Idempotency-Key': randomUUID(), 'If-Match': `"${after.product.revision}:${after.profile.revision}"` });
assert.equal(job.status, 202, JSON.stringify(job));
const snapshot = await db.workspaceTransaction(workspace, async tx =>
(await tx.query<{ snapshot: { attributes: Array<{ id: number; values: unknown[] }> } }>(
'SELECT snapshot FROM atlas.product_publish_job_items WHERE job_id=$1', [job.body.data.publish_job.id])).rows[0].snapshot);
assert.deepEqual(snapshot.attributes.find(a => a.id === 10096)?.values, [
{ dictionary_value_id: 61579, value: '红色' },
{ dictionary_value_id: 61581, value: '蓝色' },
]);
});
it('简介保留 SKU 覆盖和 SPU 继承的换行,Ozon 请求转为 br 且不修改快照', async () => {
const f = await fixture();
const common = 'Общее описание\r\n\r\nХарактеристики:\r\n• Один & два';
const own = 'Описание SKU\n\nРазмер < 10\n• Красный';
await db.workspaceTransaction(workspace, async tx => {
await tx.query(`INSERT INTO atlas.platform_product_category_attributes
(platform_id,platform_product_category_id,platform_attribute_id,name,value_type,is_required,is_aspect,max_value_count)
VALUES($1,$2,'4191','简介','text',false,false,1) ON CONFLICT DO NOTHING`, [platform, category]);
await tx.query('UPDATE atlas.product_platform_profiles SET description=$2 WHERE id=$1', [f.profile, common]);
await tx.query('UPDATE atlas.product_variant_platform_listings SET description=$3 WHERE profile_id=$1 AND product_variant_id=$2', [f.profile,f.ids[0],own]);
});
const created = await publish(f);
assert.equal(created.status, 202, JSON.stringify(created));
const job = created.body.data.publish_job as ProductPublishJob;
const snapshots = () => db.workspaceTransaction(workspace, async tx => (await tx.query<{
variant_id: string; snapshot: { attributes: Array<{ id: number; values: Array<{ value: string }> }> };
}>('SELECT variant_id,snapshot FROM atlas.product_publish_job_items WHERE job_id=$1', [job.id])).rows);
const before = await snapshots();
for (const row of before) assert.equal(row.snapshot.attributes.find(a=>a.id===4191)?.values[0].value,
row.variant_id === f.ids[0] ? own : common);
let submitted = false;
await run(job, async (_client, _key, path, body) => {
if (path === '/v3/product/info/list') return { items: [] };
assert.equal(path, '/v3/product/import');
submitted = true;
const items = (body as { items: Array<{ attributes: Array<{ id: number; values: Array<{ value: string }> }> }> }).items;
assert.deepEqual(new Set(items.map(item=>item.attributes.find(a=>a.id===4191)?.values[0].value)), new Set([
'Описание SKU<br><br>Размер &lt; 10<br>• Красный',
'Общее описание<br><br>Характеристики:<br>• Один &amp; два',
]));
return { result: { task_id: 778899 } };
});
assert.equal(submitted,true);
assert.deepEqual(await snapshots(), before);
});
it('多个主题标签合并为一个 Ozon 属性值,SKU 覆盖与继承均保留', async () => {
const f = await fixture();
await db.workspaceTransaction(workspace, async (tx) => {
await tx.query(
'UPDATE atlas.product_platform_profiles SET tags=$2::jsonb WHERE id=$1',
[f.profile, JSON.stringify(['#boho', '#loft'])],
);
await tx.query(
'UPDATE atlas.product_variant_platform_listings SET tags=$3::jsonb WHERE profile_id=$1 AND product_variant_id=$2',
[f.profile, f.ids[0], JSON.stringify(['#kpop', '#oldmoney'])],
);
});
const created = await publish(f);
assert.equal(created.status, 202, JSON.stringify(created));
const job = created.body.data.publish_job as ProductPublishJob;
await run(job, async (_client, _key, path, body) => {
if (path === '/v3/product/info/list') return { items: [] };
assert.equal(path, '/v3/product/import');
const items = body.items as Array<{
attributes: Array<{
id: number;
values: Array<{ value: string }>;
}>;
}>;
assert.deepEqual(
items.map(
(item) =>
item.attributes.find(
(attribute) => attribute.id === 23171,
)?.values,
),
[[{ value: '#kpop #oldmoney' }], [{ value: '#boho #loft' }]],
);
return { result: { task_id: 23171 } };
});
});
it('主题标签支持 30 个标签,空标签不提交属性', async () => {
for (const tags of [
[],
Array.from({ length: 30 }, (_, index) => `#tag${index}`),
]) {
const f = await fixture(1);
await db.workspaceTransaction(workspace, (tx) =>
tx.query(
'UPDATE atlas.product_platform_profiles SET tags=$2::jsonb WHERE id=$1',
[f.profile, JSON.stringify(tags)],
),
);
const created = await publish(f);
assert.equal(created.status, 202, JSON.stringify(created));
const result = await db.workspaceTransaction(workspace, (tx) =>
tx.query<{
snapshot: {
attributes: Array<{
id: number;
values: Array<{ value: string }>;
}>;
};
}>(
'SELECT snapshot FROM atlas.product_publish_job_items WHERE job_id=$1',
[created.body.data.publish_job.id],
),
);
const attribute = result.rows[0].snapshot.attributes.find(
(item) => item.id === 23171,
);
assert.deepEqual(
attribute?.values,
tags.length ? [{ value: tags.join(' ') }] : undefined,
);
}
});
it('主题标签只接受单个井号、字母数字下划线及 30 字符边界', () => {
assert.equal(ozonHashtagProblem(['#стиль_2026', '#boho']), null);
assert.equal(ozonHashtagProblem(['#' + 'я'.repeat(29)]), null);
for (const tag of [
'стиль',
'#стиль мода',
'#стиль-мода',
'#стиль/мода',
'#стиль#мода',
'#стиль😀',
'#',
'#' + 'я'.repeat(30),
])
assert.ok(ozonHashtagProblem([tag]), tag);
assert.ok(
ozonHashtagProblem(Array.from({ length: 31 }, () => '#стиль')),
);
});
it('历史不合规主题标签在预检与创建发布时拦截,不影响合法 SKU 覆盖', async () => {
const f = await fixture();
await db.workspaceTransaction(workspace, async (tx) => {
await tx.query(
'UPDATE atlas.product_platform_profiles SET tags=$2::jsonb WHERE id=$1',
[f.profile, JSON.stringify(['спортивный стиль'])],
);
await tx.query(
'UPDATE atlas.product_variant_platform_listings SET tags=$3::jsonb WHERE profile_id=$1 AND product_variant_id=$2',
[f.profile, f.ids[0], JSON.stringify(['#спортивный_стиль'])],
);
});
const preflight = await request(
`/product-platform-profiles/${f.profile}/publish-preflights`,
'POST',
{ variant_ids: f.ids },
);
assert.equal(preflight.status, 200, JSON.stringify(preflight));
assert.equal(preflight.body.data.status, 'blocked');
const validation = preflight.body.data.validation;
assert.equal(
validation.variants.find(
(v: { variant_id: string }) => v.variant_id === f.ids[0],
).ready,
true,
);
assert.ok(
validation.variants
.find((v: { variant_id: string }) => v.variant_id === f.ids[1])
.issues.some(
(issue: { code: string; field: string }) =>
issue.code === 'OZON_HASHTAG_INVALID' &&
issue.field === 'profile.tags',
),
);
const rejected = await publish(f);
assert.equal(rejected.status, 422);
assert.ok(
rejected.body.error.details.some(
(issue: { code: string }) =>
issue.code === 'OZON_HASHTAG_INVALID',
),
);
const selected = await publish(f, [f.ids[0]]);
assert.equal(selected.status, 202, JSON.stringify(selected));
const invalidSku = await fixture(1);
await db.workspaceTransaction(workspace, (tx) =>
tx.query(
'UPDATE atlas.product_variant_platform_listings SET tags=$3::jsonb WHERE profile_id=$1 AND product_variant_id=$2',
[
invalidSku.profile,
invalidSku.ids[0],
JSON.stringify(['#стиль-мода']),
],
),
);
const response = await publish(invalidSku);
assert.equal(response.status, 422);
assert.ok(
response.body.error.details.some(
(issue: { code: string; field: string }) =>
issue.code === 'OZON_HASHTAG_INVALID' &&
issue.field === `variants.${invalidSku.ids[0]}.tags`,
),
);
});
it('预检、并发幂等、精度快照、Outbox 与跨租户隔离', async () => {
const f = await fixture();
const key = randomUUID();
const [a, b] = await Promise.all([
publish(f, undefined, key),
publish(f, undefined, key),
]);
assert.equal(a.status, 202, JSON.stringify(a));
assert.deepEqual(a.body, b.body);
const job = a.body.data.publish_job as ProductPublishJob;
assert.equal(job.items.length, 2);
const facts = await db.workspaceTransaction(workspace, async (tx) => ({
items: (
await tx.query(
'SELECT snapshot FROM atlas.product_publish_job_items WHERE job_id=$1',
[job.id],
)
).rows,
events: (
await tx.query(
'SELECT event_payload FROM atlas.outbox_events WHERE aggregate_id=$1',
[job.id],
)
).rows,
listings: (
await tx.query(
'SELECT selling_price::text, list_price::text FROM atlas.product_variant_platform_listings WHERE profile_id=$1',
[f.profile],
)
).rows,
}));
assert.equal(facts.events.length, 1);
assert.deepEqual(facts.events[0].event_payload, {
publish_job_id: job.id,
});
assert.equal(facts.items[0].snapshot.price, '2999.1234');
assert.equal(facts.listings[0].selling_price, '1999.1234');
assert.equal(facts.listings[0].list_price, '2999.1234');
assert.ok(!('old_price' in facts.items[0].snapshot));
assert.equal(facts.items[0].snapshot.depth, 240);
assert.ok(!('vat' in facts.items[0].snapshot));
assert.ok(!('images' in facts.items[0].snapshot));
const other = randomUUID();
assert.equal(
(
await db.workspaceTransaction(other, (tx) =>
tx.query(
'SELECT id FROM atlas.product_publish_jobs WHERE id=$1',
[job.id],
),
)
).rows.length,
0,
);
assert.equal(
(
await request(
`/product-publish-jobs/${job.id}`,
'GET',
undefined,
{ 'X-Atlas-Platform-Id': randomUUID() },
)
).status,
404,
);
assert.equal(
(await publish(f)).body.error.code,
'SKU_IMPORT_IN_PROGRESS',
);
assert.equal(
(
await publish(f, [
...Array.from({ length: 51 }, () => randomUUID()),
])
).status,
422,
);
assert.equal(
(
await request(
`/product-platform-profiles/${f.profile}/publish-jobs`,
'POST',
{ variant_ids: f.ids },
{ 'Idempotency-Key': randomUUID(), 'If-Match': '"2:1"' },
)
).body.error.code,
'VERSION_CONFLICT',
);
});
it('首次价格使用划线价;零折扣仍可提交,缺失或零划线价阻断预检和创建', async () => {
const equal = await fixture(1);
await db.workspaceTransaction(workspace, (tx) =>
tx.query(
'UPDATE atlas.product_variant_platform_listings SET list_price=selling_price WHERE profile_id=$1',
[equal.profile],
),
);
const created = await publish(equal);
assert.equal(created.status, 202, JSON.stringify(created));
await run(created.body.data.publish_job, async (_client, _key, path, body) => {
if (path === '/v3/product/info/list') return { items: [] };
assert.equal(path, '/v3/product/import');
const item = (body.items as any[])[0];
assert.equal(item.price, '1999.1234');
assert.ok(!('old_price' in item));
return { result: { task_id: 987654 } };
});
for (const listPrice of [null, '0']) {
const f = await fixture(1);
await db.workspaceTransaction(workspace, (tx) =>
tx.query(
'UPDATE atlas.product_variant_platform_listings SET list_price=$2, selling_price=CASE WHEN $2::numeric=0 THEN 0 ELSE selling_price END WHERE profile_id=$1',
[f.profile, listPrice],
),
);
const preflight = await request(
`/product-platform-profiles/${f.profile}/publish-preflights`,
'POST',
{ variant_ids: f.ids },
{ 'Idempotency-Key': randomUUID(), 'If-Match': '"1:1"' },
);
assert.equal(preflight.status, 200, JSON.stringify(preflight));
assert.equal(preflight.body.data.validation.variants[0].ready, false);
assert.ok(preflight.body.data.validation.variants[0].issues.some(
(issue: { code: string; field: string }) =>
issue.code === 'LIST_PRICE_REQUIRED' && issue.field === `variants.${f.ids[0]}.list_price`,
));
const rejected = await publish(f);
assert.equal(rejected.status, 422);
assert.ok(rejected.body.error.details.some(
(issue: { code: string }) => issue.code === 'LIST_PRICE_REQUIRED',
));
}
});
it('只校验所选 SKU;不就绪的未选 SKU 不阻断部分上架', async () => {
const f = await fixture();
await db.workspaceTransaction(workspace, (tx) =>
tx.query(
`UPDATE atlas.product_variant_platform_listings SET offer_id=NULL WHERE profile_id=$1 AND product_variant_id=$2`,
[f.profile, f.ids[1]],
),
);
const all = await publish(f);
assert.equal(all.status, 422);
assert.equal(all.body.error.code, 'PUBLISH_VALIDATION_FAILED');
const partial = await publish(f, [f.ids[0]]);
assert.equal(partial.status, 202, JSON.stringify(partial));
assert.equal(partial.body.data.publish_job.item_count, 1);
});
it('混合结果持久化,成功锁定型号/货号,重复消息不重复上报', async () => {
const f = await fixture();
let job = (await publish(f)).body.data.publish_job as ProductPublishJob;
let imports = 0;
job = await run(job, async (_client, _key, path, body) => {
if (path === '/v3/product/info/list') return { items: [] };
assert.equal(path, '/v3/product/import');
imports++;
const items = body.items as any[];
assert.equal(items.length, 2);
assert.equal(items[0].price, '2999.1234');
assert.ok(!('old_price' in items[0]));
assert.ok(!('vat' in items[0]));
assert.ok(!('barcode' in items[0]));
assert.ok(!('description' in items[0]));
return { result: { task_id: 123456 } };
});
assert.equal(job.items[0].status, 'polling');
const offers = job.items.map((i) => i.offer_id);
job = await run(job, async (_c, _k, path) => path === '/v2/product/pictures/info' ? ({
items: [{ product_id: 101, primary_photo: ['https://images.example.test/fixture.png'], photo: [], errors: [] }],
}) : ({
result: {
items: [
{
offer_id: offers[0],
product_id: 101,
status: 'imported',
errors: [],
},
{
offer_id: offers[1],
status: 'failed',
errors: [
{
code: 'BAD_IMAGE',
message:
'失败 https://private.example.test/a?token=hidden',
},
],
},
],
},
}));
assert.equal(job.status, 'partially_succeeded');
assert.equal(
job.items.find((i) => i.status === 'imported')?.platform_product_id,
'101',
);
assert.ok(!JSON.stringify(job).includes('token='));
await assert.rejects(
db.workspaceTransaction(workspace, (tx) =>
tx.query(
`UPDATE atlas.products SET model='CHANGED' WHERE id=$1`,
[f.product],
),
),
/PRODUCT_MODEL_LOCKED/,
);
await assert.rejects(
db.workspaceTransaction(workspace, (tx) =>
tx.query(
`UPDATE atlas.product_variant_platform_listings SET offer_id='CHANGED' WHERE profile_id=$1 AND status='imported'`,
[f.profile],
),
),
/SKU_ALREADY_IMPORTED/,
);
await run(job, async () => {
imports++;
throw new Error('终态不应再访问平台');
});
assert.equal(imports, 1);
});
it('响应丢失后只按货号对账,绝不重复导入', async () => {
const f = await fixture(1);
let job = (await publish(f)).body.data.publish_job as ProductPublishJob;
let imports = 0;
job = await run(job, async (_c, _k, path) => {
if (path === '/v3/product/info/list') return { items: [] };
imports++;
throw new OzonRetryableError('response lost');
});
assert.equal(job.status, 'reconciling');
job = await run(job, async (_c, _k, path) => {
assert.equal(path, '/v3/product/info/list');
return { items: [] };
});
assert.equal(job.status, 'reconciling');
assert.equal(imports, 1);
job = await run(job, async (_c, _k, path) => ({
items: path === '/v2/product/pictures/info'
? [{ product_id: 999, primary_photo: ['https://images.example.test/fixture.png'], photo: [], errors: [] }]
: [{ offer_id: job.items[0].offer_id, id: 999 }],
}));
assert.equal(job.status, 'succeeded');
assert.equal(imports, 1);
});
it('导入成功后等待图片结果,缺图警告保留到 HTTP 结果与 SKU,并且不重复导入', async () => {
const f = await fixture(1, 3);
let job = (await publish(f)).body.data.publish_job as ProductPublishJob;
let imports = 0;
job = await run(job, async (_c, _k, path) => {
if (path === '/v3/product/info/list') return { items: [] };
assert.equal(path, '/v3/product/import');
imports++;
return { result: { task_id: 456 } };
});
const imported = { result: { items: [{ offer_id: job.items[0].offer_id, product_id: 102,
status: 'imported', errors: [] }] } };
job = await run(job, async (_c, _k, path) => path === '/v1/product/import/info' ? imported
: { items: [{ product_id: 102, primary_photo: [], photo: [], errors: [] }] });
assert.equal(job.status, 'running');
assert.equal(job.items[0].status, 'polling');
job = await run(job, async (_c, _k, path) => path === '/v1/product/import/info' ? {
result: { items: [{ ...imported.result.items[0], errors: [{ code: 'primary_image_load_failed',
message: 'https://private.example.test/primary.png?token=hidden' }] }] },
} : { items: [{ product_id: 102, primary_photo: ['https://images.example.test/second.png'], photo: [],
errors: [{ url: 'https://private.example.test/primary.png?token=hidden', message: 'request process failed: unknown error host unreachable' },
{ url: 'https://private.example.test/third.png?token=hidden', message: 'failed https://private.example.test/third.png?token=hidden' }] }] });
assert.equal(job.status, 'succeeded');
assert.equal(job.items[0].status, 'imported');
assert.equal(job.items[0].platform_product_id, '102');
assert.ok(job.items[0].errors.some((error) => error.code === 'OZON_IMAGES_INCOMPLETE' && error.message.includes('1/3')));
assert.ok(job.items[0].errors.some((error) => error.code === 'primary_image_load_failed'));
assert.ok(job.items[0].errors.some((error) => error.message.includes('全球加速')));
assert.ok(!JSON.stringify(job).includes('token='));
assert.ok(!JSON.stringify(job).includes('private.example.test'));
const listing = (await db.workspaceTransaction(workspace, tx => tx.query(
'SELECT status,last_error FROM atlas.product_variant_platform_listings WHERE profile_id=$1', [f.profile],
))).rows[0];
assert.equal(listing.status, 'imported');
assert.deepEqual(listing.last_error, job.items[0].errors);
await run(job, async () => { imports++; throw new Error('终态不得重复上报'); });
assert.equal(imports, 1);
});
it('图片全部到齐才报告无警告成功,长时间未齐时以已导入并提示缺图结束', async () => {
for (const completePictures of [true, false]) {
const f = await fixture(1, 3);
let job = (await publish(f)).body.data.publish_job as ProductPublishJob;
job = await run(job, async (_c, _k, path) => path === '/v3/product/info/list'
? { items: [] } : { result: { task_id: 457 } });
if (!completePictures) await db.workspaceTransaction(workspace, tx => tx.query(
'UPDATE atlas.product_publish_jobs SET poll_count=121 WHERE id=$1', [job.id],
));
job = await run(job, async (_c, _k, path) => path === '/v1/product/import/info'
? { result: { items: [{ offer_id: job.items[0].offer_id, product_id: 103, status: 'imported', errors: [] }] } }
: { items: [{ product_id: 103, primary_photo: completePictures ? ['https://images.example.test/1.png'] : [],
photo: completePictures ? ['https://images.example.test/2.png', 'https://images.example.test/3.png'] : [], errors: [] }] });
assert.equal(job.status, 'succeeded');
assert.equal(job.items[0].status, 'imported');
assert.equal(job.items[0].errors.length, completePictures ? 0 : 1);
}
});
it('任一图片准备失败时整批不提交,并隐藏存储路径', async () => {
const f = await fixture(2);
const created = (await publish(f)).body.data
.publish_job as ProductPublishJob;
let prepared = 0;
const job = await run(
created,
async (_c, _k, path) => {
assert.equal(path, '/v3/product/info/list');
return { items: [] };
},
async () => {
if (++prepared === 2)
throw new Error('AccessDenied secret/object/key');
return ['https://images.example.test/fixture.png'];
},
);
assert.equal(job.status, 'failed');
assert.ok(job.items.every((i) => i.status === 'failed'));
assert.ok(!JSON.stringify(job).includes('secret/object'));
const source = await db.workspaceTransaction(workspace, (tx) =>
tx.query(
'SELECT submit_started_at FROM atlas.product_publish_jobs WHERE id=$1',
[job.id],
),
);
assert.equal(source.rows[0].submit_started_at, null);
});
it('远端已有同货号时跳过,不调用更新接口;429 延迟后允许提交', async () => {
const existing = await fixture(1);
let job = (await publish(existing)).body.data
.publish_job as ProductPublishJob;
job = await run(job, async (_c, _k, path) => {
assert.equal(path, '/v3/product/info/list');
return { items: [{ offer_id: job.items[0].offer_id, id: 88 }] };
});
assert.equal(job.items[0].status, 'skipped');
assert.equal(job.items[0].errors[0].code, 'OZON_OFFER_ALREADY_EXISTS');
const f = await fixture(1);
job = (await publish(f)).body.data.publish_job as ProductPublishJob;
job = await run(job, async (_c, _k, path) => {
if (path === '/v3/product/info/list') return { items: [] };
throw new OzonRateLimitError(120);
});
assert.equal(job.items[0].status, 'queued');
const source = await db.workspaceTransaction(workspace, (tx) =>
tx.query(
'SELECT submit_started_at FROM atlas.product_publish_jobs WHERE id=$1',
[job.id],
),
);
assert.equal(source.rows[0].submit_started_at, null);
job = await run(job, async (_c, _k, path) =>
path === '/v3/product/info/list'
? { items: [] }
: { result: { task_id: 987 } },
);
assert.equal(job.items[0].status, 'polling');
});
});
+109
View File
@@ -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 -1
View File
@@ -1,7 +1,7 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"rootDir": ".",
"rootDir": "..",
"noEmit": true
},
"include": ["src/**/*.ts", "test/**/*.ts", "vitest.config.ts"]
+3
View File
@@ -6,6 +6,9 @@ declare module "wxt/browser" {
| ""
| "/"
| "/background.js"
| "/content-scripts/collector.js"
| "/content-scripts/ozon-page-json.js"
| "/content-scripts/source-page-state.js"
type HtmlPublicPath = Extract<PublicPath, `${string}.html`>
export interface WxtRuntime {
getURL(path: PublicPath): string;

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