Compare commits
69 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 06dbd817f0 | |||
| 79b310efe7 | |||
| e20cd7547f | |||
| 696112e74d | |||
| 45871584b3 | |||
| 0a303414d8 | |||
| 84b230e69a | |||
| a29dd92676 | |||
| e670d06ca6 | |||
| d24ce8f4e0 | |||
| b3958166dd | |||
| 95a2a37f1b | |||
| 2b06ef0692 | |||
| 05747d6f22 | |||
| 75fa2b60f3 | |||
| e0e07be4ea | |||
| 880e441bc6 | |||
| 388429dbd0 | |||
| fcddb11da0 | |||
| df3fb0c754 | |||
| 5684cbd517 | |||
| 52ef75f719 | |||
| a66b07652b | |||
| 7f68aad9f3 | |||
| 3156a7516f | |||
| da7342b466 | |||
| 1b03ea5871 | |||
| 2c4e94ab3d | |||
| 05a00d46b9 | |||
| 946af649dd | |||
| 130a005231 | |||
| 319887726e | |||
| ceb644a3f9 | |||
| 4c0f3a4a79 | |||
| 87db74fb89 | |||
| edf7939963 | |||
| f635359216 | |||
| e277754588 | |||
| 009082d0f4 | |||
| c2523fe349 | |||
| 70d9d5b92a | |||
| 66ca8b496c | |||
| 5bdf1645d2 | |||
| 1b0cdc33aa | |||
| d199c4d6df | |||
| aa3076c434 | |||
| eecec5e0d0 | |||
| c0473d607a | |||
| 27b9e07b62 | |||
| 6879e7a8b7 | |||
| 9164fc962e | |||
| 24a25dcbd1 | |||
| 5bf5f80b34 | |||
| 46d40b184b | |||
| 446baf5939 | |||
| 8655b7805f | |||
| 3ab999d0b2 | |||
| cc61967e3b | |||
| dc39e3e4e5 | |||
| 2d435143ab | |||
| 06a3a4da75 | |||
| a5b89ddb1d | |||
| 46d98ab11e | |||
| ffa86f8d55 | |||
| ada02e9135 | |||
| 35fcf8776e | |||
| df40ddde8c | |||
| 037928c778 | |||
| 195b6d25e9 |
+103
-7
@@ -1,12 +1,108 @@
|
|||||||
POSTGRES_USER=atlas_app_login
|
# Local PostgreSQL bootstrap administrator. It creates roles but must never be used by API or Worker.
|
||||||
POSTGRES_PASSWORD=change-me-local-only
|
POSTGRES_ADMIN_USER=atlas_admin
|
||||||
|
POSTGRES_ADMIN_PASSWORD=change-me-local-only
|
||||||
|
# Database name and host port exposed by local Docker Compose.
|
||||||
POSTGRES_DB=atlas
|
POSTGRES_DB=atlas
|
||||||
POSTGRES_PORT=5432
|
POSTGRES_PORT=5432
|
||||||
REDIS_URL=redis://localhost:6379
|
|
||||||
MINIO_ROOT_USER=atlas_minio_local
|
# Password for the schema-owning migrator role; only the migration runner may use it.
|
||||||
MINIO_ROOT_PASSWORD=change-me-local-only
|
ATLAS_MIGRATOR_PASSWORD=change-me-local-only
|
||||||
MINIO_PORT=9000
|
# Password for the least-privileged API/Worker login role.
|
||||||
MINIO_CONSOLE_PORT=9001
|
ATLAS_APP_PASSWORD=change-me-local-only
|
||||||
|
# API/Worker application connection. This role has no table ownership and is constrained by RLS.
|
||||||
|
DATABASE_URL=postgresql://atlas_app_login:change-me-local-only@localhost:5432/atlas
|
||||||
|
# Migration-only connection. Do not put this value in production API or Worker environments.
|
||||||
|
MIGRATOR_DATABASE_URL=postgresql://atlas_migrator:change-me-local-only@localhost:5432/atlas
|
||||||
|
|
||||||
|
# Runtime mode controls production-only safeguards such as Secure cookies.
|
||||||
|
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
|
API_PORT=8200
|
||||||
|
|
||||||
|
# HMAC signing secret for short-lived access JWTs; replace with a unique 32+ character secret.
|
||||||
|
AUTH_JWT_SECRET=replace-with-a-random-secret-of-at-least-32-characters
|
||||||
|
# Separate HMAC secret used to pseudonymize IP/email values in Redis rate-limit keys.
|
||||||
|
AUTH_RATE_LIMIT_KEY_SECRET=replace-with-a-different-random-secret-of-at-least-32-characters
|
||||||
|
# AES-GCM 密钥材料,用来加密门店 API Key;只保存在服务端,轮换时变更版本号但保留旧密钥解密能力。
|
||||||
|
STORE_SECRET_ENCRYPTION_KEY=replace-with-a-random-secret-of-at-least-32-characters
|
||||||
|
# 当前门店密钥使用的加密密钥版本,会随密钥轮换递增。
|
||||||
|
STORE_SECRET_ENCRYPTION_KEY_VERSION=v1
|
||||||
|
# 对幂等请求内容生成 HMAC 摘要的独立密钥,避免数据库保存原始请求体或 API Key。
|
||||||
|
IDEMPOTENCY_KEY_SECRET=replace-with-a-different-random-secret-of-at-least-32-characters
|
||||||
|
# Server-side minimum password length. The browser must not weaken this rule.
|
||||||
|
AUTH_PASSWORD_MIN_LENGTH=12
|
||||||
|
# Maximum JSON or URL-encoded request size in bytes. Media files must use object-storage direct upload.
|
||||||
|
API_BODY_LIMIT_BYTES=1048576
|
||||||
|
# Shared fixed-window duration for authentication rate limits, in seconds.
|
||||||
|
AUTH_RATE_LIMIT_WINDOW_SECONDS=900
|
||||||
|
# Maximum login attempts per IP inside the window.
|
||||||
|
AUTH_LOGIN_IP_LIMIT=10
|
||||||
|
# Maximum login attempts per normalized email inside the window.
|
||||||
|
AUTH_LOGIN_EMAIL_LIMIT=5
|
||||||
|
# Maximum registration attempts per IP inside the window.
|
||||||
|
AUTH_REGISTER_IP_LIMIT=5
|
||||||
|
# Maximum registration attempts per normalized email inside the window.
|
||||||
|
AUTH_REGISTER_EMAIL_LIMIT=3
|
||||||
|
# Maximum refresh attempts per IP inside the window; refresh tokens have no trusted email before validation.
|
||||||
|
AUTH_REFRESH_IP_LIMIT=30
|
||||||
|
# 密码修改在同一限流窗口内允许的每 IP 最大次数。
|
||||||
|
AUTH_CHANGE_PASSWORD_IP_LIMIT=10
|
||||||
|
# 密码修改在同一限流窗口内允许的每已认证用户最大次数。
|
||||||
|
AUTH_CHANGE_PASSWORD_USER_LIMIT=5
|
||||||
|
|
||||||
|
# Comma-separated trusted reverse-proxy IPs/CIDRs. Keep empty for direct local access; never set to true.
|
||||||
|
TRUSTED_PROXY_CIDRS=
|
||||||
|
# Whether to expose Swagger UI at /api-docs.
|
||||||
|
SWAGGER_ENABLED=true
|
||||||
|
# Minimum structured-log severity: error, warn, log, or debug.
|
||||||
|
LOG_LEVEL=log
|
||||||
|
|
||||||
|
# Redis endpoint used by BullMQ and shared authentication rate limits.
|
||||||
|
REDIS_URL=redis://localhost:6379
|
||||||
|
|
||||||
|
# S3-compatible object storage. Local development falls back to MinIO root credentials above;
|
||||||
|
# production must set dedicated, least-privileged OBJECT_STORAGE_ACCESS_KEY / SECRET_KEY values.
|
||||||
|
OBJECT_STORAGE_ENDPOINT=http://localhost:9000
|
||||||
|
OBJECT_STORAGE_BUCKET=atlas-assets
|
||||||
|
OBJECT_STORAGE_FORCE_PATH_STYLE=true
|
||||||
|
# OBJECT_STORAGE_ACCESS_KEY=
|
||||||
|
# OBJECT_STORAGE_SECRET_KEY=
|
||||||
|
|
||||||
|
# Local MinIO root account; it is only for Docker bootstrap, not browser credentials.
|
||||||
|
MINIO_ROOT_USER=atlas_minio_local
|
||||||
|
# Local MinIO root password; replace outside disposable local development.
|
||||||
|
MINIO_ROOT_PASSWORD=change-me-local-only
|
||||||
|
# S3-compatible API port for local MinIO.
|
||||||
|
MINIO_PORT=9000
|
||||||
|
# MinIO administrative console port.
|
||||||
|
MINIO_CONSOLE_PORT=9001
|
||||||
|
|
||||||
|
# Local Vite frontend port.
|
||||||
WEB_PORT=8100
|
WEB_PORT=8100
|
||||||
|
# Local WXT browser-extension development port.
|
||||||
EXTENSION_PORT=8110
|
EXTENSION_PORT=8110
|
||||||
|
|
||||||
|
# DeepSeek API Key
|
||||||
|
DEEPSEEK_API_KEY=
|
||||||
|
# DeepSeek Model Name
|
||||||
|
DEEPSEEK_MODEL_NAME=deepseek-flash
|
||||||
|
|
||||||
|
# 单张 AI 图片编辑使用 Pcore 的 gpt-image-2 异步图片接口;根地址和 /v1 地址均可,仅 Worker 读取。
|
||||||
|
PCORE_BASE_URL=https://pcore.ai/v1
|
||||||
|
PCORE_API_KEY=
|
||||||
|
|
||||||
|
# 首次上架:开发/测试 auto 允许,生产 auto 关闭;完成 VAT 与七牛联调后显式 true。
|
||||||
|
OZON_PUBLISH_ENABLED=auto
|
||||||
|
# 与素材实际入库环境一致,生产设置 qiniu_kodo。
|
||||||
|
OBJECT_STORAGE_PROVIDER=local_minio
|
||||||
|
# 七牛绑定的公网 HTTPS 下载域名,与 S3 服务 endpoint 分离。
|
||||||
|
# 七牛模式下,API 图片预览、下载和 Ozon 交付共用此 HTTPS 图片域名。
|
||||||
|
OZON_IMAGE_DOWNLOAD_ORIGIN=
|
||||||
|
# 图片签名初始有效期 7 天;生产值须通过异步/延迟抓图验收。
|
||||||
|
OZON_IMAGE_URL_TTL_SECONDS=604800
|
||||||
|
# 可复用对象存储凭据;分离授权时填写专用签名凭据。
|
||||||
|
QINIU_ACCESS_KEY=
|
||||||
|
QINIU_SECRET_KEY=
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"singleQuote": true,
|
||||||
|
"trailingComma": "all",
|
||||||
|
"bracketSpacing": true,
|
||||||
|
"arrowParens": "always",
|
||||||
|
"printWidth": 80,
|
||||||
|
"tabWidth": 4,
|
||||||
|
"useTabs": false
|
||||||
|
}
|
||||||
@@ -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. 代码注释
|
||||||
|
|
||||||
|
- 新增或修改的代码注释必须使用中文;注释应说明职责、边界或不易从代码直接看出的业务约束,避免逐行复述实现。
|
||||||
@@ -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;
|
||||||
@@ -6,7 +6,9 @@ Atlas Seller Kit 是面向多用户、多门店、多平台与多国家市场的
|
|||||||
|
|
||||||
## 当前状态
|
## 当前状态
|
||||||
|
|
||||||
仅完成产品与架构设计,尚未开始应用代码开发。开始新 AI 对话或开发前,请先阅读 [项目交接](./docs/project/HANDOFF.md),再阅读 [文档导航](./docs/README.md)。
|
项目已完成 monorepo 与本地基础设施初始化,并实现认证、个人工作空间、平台目录/上下文和门店管理 HTTP API。Web 已提供登录/注册、主布局、页头平台选择器和接入真实 API 的店铺管理页;其余业务页面仍为占位。门店凭据验证已接入 Outbox Dispatcher 与 Worker;仓库同步仍待实现。
|
||||||
|
|
||||||
|
开始新 AI 对话或开发前,请先阅读 [项目交接](./docs/project/HANDOFF.md),再阅读 [文档导航](./docs/README.md)。
|
||||||
|
|
||||||
## 设计目标
|
## 设计目标
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"singleQuote": true,
|
||||||
|
"trailingComma": "all",
|
||||||
|
"bracketSpacing": true,
|
||||||
|
"arrowParens": "always",
|
||||||
|
"printWidth": 80,
|
||||||
|
"tabWidth": 4,
|
||||||
|
"useTabs": false
|
||||||
|
}
|
||||||
+38
-23
@@ -1,25 +1,40 @@
|
|||||||
{
|
{
|
||||||
"name": "@atlas/api",
|
"name": "@atlas/api",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "tsc -p tsconfig.json",
|
"build": "tsc -p tsconfig.json",
|
||||||
"dev": "tsx watch src/main.ts",
|
"dev": "node scripts/dev.mjs",
|
||||||
"start": "node dist/main.js",
|
"start": "node dist/main.js",
|
||||||
"typecheck": "tsc -p tsconfig.json --noEmit"
|
"typecheck": "tsc -p tsconfig.json --noEmit && tsc -p tsconfig.test.json --noEmit",
|
||||||
},
|
"test": "vitest run"
|
||||||
"dependencies": {
|
},
|
||||||
"@atlas/contracts": "workspace:*",
|
"dependencies": {
|
||||||
"@nestjs/common": "12.0.1",
|
"@atlas/contracts": "workspace:*",
|
||||||
"@nestjs/core": "12.0.1",
|
"@atlas/database": "workspace:*",
|
||||||
"@nestjs/platform-express": "12.0.1",
|
"@aws-sdk/client-s3": "3.1015.0",
|
||||||
"reflect-metadata": "0.2.2",
|
"@aws-sdk/s3-request-presigner": "3.1015.0",
|
||||||
"rxjs": "7.8.2"
|
"@nestjs/common": "12.0.1",
|
||||||
},
|
"@nestjs/config": "12.0.0",
|
||||||
"devDependencies": {
|
"@nestjs/core": "12.0.1",
|
||||||
"@types/node": "26.3.0",
|
"@nestjs/platform-express": "12.0.1",
|
||||||
"tsx": "4.23.13",
|
"@nestjs/swagger": "12.0.1",
|
||||||
"typescript": "7.0.2"
|
"decimal.js": "10.6.0",
|
||||||
}
|
"helmet": "8.3.0",
|
||||||
|
"ioredis": "6.0.0",
|
||||||
|
"reflect-metadata": "0.2.2",
|
||||||
|
"rxjs": "7.8.2",
|
||||||
|
"zod": "4.5.4"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@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"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import { spawn } from 'node:child_process';
|
||||||
|
import { existsSync, readFileSync } from 'node:fs';
|
||||||
|
import { dirname, resolve } from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
|
const scriptDirectory = dirname(fileURLToPath(import.meta.url));
|
||||||
|
const repositoryRoot = resolve(scriptDirectory, '../../..');
|
||||||
|
const environmentFile = resolve(repositoryRoot, '.env');
|
||||||
|
const localEnvironment = existsSync(environmentFile)
|
||||||
|
? parseEnvironment(readFileSync(environmentFile, 'utf8'))
|
||||||
|
: {};
|
||||||
|
const apiPort = process.env.API_PORT ?? localEnvironment.API_PORT ?? '8200';
|
||||||
|
const swaggerEnabled =
|
||||||
|
(process.env.SWAGGER_ENABLED ??
|
||||||
|
localEnvironment.SWAGGER_ENABLED ??
|
||||||
|
'true') === 'true';
|
||||||
|
const nodeEnvironment =
|
||||||
|
process.env.NODE_ENV ?? localEnvironment.NODE_ENV ?? 'development';
|
||||||
|
const swaggerUrl = `http://localhost:${apiPort}/api-docs`;
|
||||||
|
const tsxCommand = process.platform === 'win32' ? 'tsx.cmd' : 'tsx';
|
||||||
|
const apiProcess = spawn(tsxCommand, ['watch', 'src/main.ts'], {
|
||||||
|
shell: process.platform === 'win32',
|
||||||
|
stdio: 'inherit',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (
|
||||||
|
process.platform === 'darwin' &&
|
||||||
|
nodeEnvironment === 'development' &&
|
||||||
|
swaggerEnabled
|
||||||
|
) {
|
||||||
|
void openSwaggerAfterApiIsReady();
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const signal of ['SIGINT', 'SIGTERM']) {
|
||||||
|
process.on(signal, () => apiProcess.kill(signal));
|
||||||
|
}
|
||||||
|
|
||||||
|
apiProcess.on('exit', (code, signal) => {
|
||||||
|
if (signal) process.exitCode = 1;
|
||||||
|
else process.exitCode = code ?? 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
function parseEnvironment(content) {
|
||||||
|
return Object.fromEntries(
|
||||||
|
content
|
||||||
|
.split(/\r?\n/)
|
||||||
|
.map((line) => line.match(/^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/))
|
||||||
|
.filter(Boolean)
|
||||||
|
.map((match) => [match[1], match[2]]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openSwaggerAfterApiIsReady() {
|
||||||
|
const deadline = Date.now() + 30_000;
|
||||||
|
while (Date.now() < deadline) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`http://localhost:${apiPort}/health`);
|
||||||
|
if (response.ok) {
|
||||||
|
// 开发启动器进程不会随 tsx watch 的热重启重建,因此只会打开一次。
|
||||||
|
spawn('open', [swaggerUrl], {
|
||||||
|
detached: true,
|
||||||
|
stdio: 'ignore',
|
||||||
|
}).unref();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// API 仍在启动或热重启,继续等待到超时为止。
|
||||||
|
}
|
||||||
|
await new Promise((resolveDelay) => setTimeout(resolveDelay, 250));
|
||||||
|
}
|
||||||
|
console.warn(`Swagger 未在 30 秒内就绪:${swaggerUrl}`);
|
||||||
|
}
|
||||||
@@ -1,12 +1,63 @@
|
|||||||
import { Controller, Get, Module } from '@nestjs/common';
|
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';
|
||||||
|
import { ApiError } from './common/api-error.js';
|
||||||
|
import { Public } from './common/public.decorator.js';
|
||||||
|
import { AppConfigModule } from './config/app-config.module.js';
|
||||||
|
import { DATABASE, DatabaseModule } from './database/database.module.js';
|
||||||
|
import { SecurityModule } from './security/security.module.js';
|
||||||
|
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('health')
|
@Controller()
|
||||||
class HealthController {
|
class HealthController {
|
||||||
@Get()
|
public constructor(@Inject(DATABASE) private readonly database: Database) {}
|
||||||
check() {
|
|
||||||
return { status: 'ok', service: 'api' };
|
@Get('health')
|
||||||
}
|
@Public()
|
||||||
|
check() {
|
||||||
|
return { status: 'ok', service: 'api' };
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('ready')
|
||||||
|
@Public()
|
||||||
|
async ready() {
|
||||||
|
try {
|
||||||
|
await this.database.query('SELECT 1');
|
||||||
|
return { status: 'ready', service: 'api' };
|
||||||
|
} catch {
|
||||||
|
throw new ApiError(
|
||||||
|
503,
|
||||||
|
'DEPENDENCY_UNAVAILABLE',
|
||||||
|
'服务依赖暂不可用',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Module({ controllers: [HealthController] })
|
@Module({
|
||||||
|
imports: [
|
||||||
|
AppConfigModule,
|
||||||
|
DatabaseModule,
|
||||||
|
SecurityModule,
|
||||||
|
AuthModule,
|
||||||
|
StoresModule,
|
||||||
|
WatermarksModule,
|
||||||
|
MediaModule,
|
||||||
|
CollectionModule,
|
||||||
|
ExtensionModule,
|
||||||
|
ProductEditorModule,
|
||||||
|
PlatformCatalogAdminModule,
|
||||||
|
OnlineProductsModule,
|
||||||
|
PricingModule,
|
||||||
|
],
|
||||||
|
controllers: [HealthController],
|
||||||
|
})
|
||||||
export class AppModule {}
|
export class AppModule {}
|
||||||
|
|||||||
@@ -0,0 +1,323 @@
|
|||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
Headers,
|
||||||
|
HttpCode,
|
||||||
|
Inject,
|
||||||
|
Patch,
|
||||||
|
Post,
|
||||||
|
Req,
|
||||||
|
Res,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import {
|
||||||
|
ApiBearerAuth,
|
||||||
|
ApiBody,
|
||||||
|
ApiNoContentResponse,
|
||||||
|
ApiOkResponse,
|
||||||
|
ApiOperation,
|
||||||
|
ApiTags,
|
||||||
|
ApiUnauthorizedResponse,
|
||||||
|
} from '@nestjs/swagger';
|
||||||
|
import { createHash } from 'node:crypto';
|
||||||
|
import {
|
||||||
|
changePasswordRequestSchema,
|
||||||
|
loginRequestSchema,
|
||||||
|
registerRequestSchema,
|
||||||
|
updateCurrentUserRequestSchema,
|
||||||
|
type Workspace,
|
||||||
|
} from '@atlas/contracts';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { ApiError } from '../common/api-error.js';
|
||||||
|
import { Public } from '../common/public.decorator.js';
|
||||||
|
import type { AuthenticatedRequest } from './auth.guard.js';
|
||||||
|
import { AuthService, type RequestMetadata } from './auth.service.js';
|
||||||
|
import { CurrentWorkspace } from '../workspace/current-workspace.decorator.js';
|
||||||
|
|
||||||
|
const refreshCookie = 'atlas_refresh';
|
||||||
|
// 控制器只负责 HTTP 边界;账户、会话与工作空间的一致性由 AuthService 的事务保证。
|
||||||
|
type HttpRequest = {
|
||||||
|
header: (name: string) => string | undefined;
|
||||||
|
ip?: string;
|
||||||
|
};
|
||||||
|
type HttpResponse = {
|
||||||
|
cookie: (
|
||||||
|
name: string,
|
||||||
|
value: string,
|
||||||
|
options: Record<string, unknown>,
|
||||||
|
) => void;
|
||||||
|
clearCookie: (name: string, options: Record<string, unknown>) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
@ApiTags('Authentication')
|
||||||
|
@Controller('auth')
|
||||||
|
export class AuthController {
|
||||||
|
public constructor(
|
||||||
|
@Inject(AuthService) private readonly authService: AuthService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
@Post('register')
|
||||||
|
@Public()
|
||||||
|
@ApiOperation({ summary: '注册账户并创建个人工作空间' })
|
||||||
|
@ApiBody({
|
||||||
|
schema: {
|
||||||
|
example: {
|
||||||
|
email: 'owner@example.com',
|
||||||
|
password: 'a-strong-password',
|
||||||
|
password_confirmation: 'a-strong-password',
|
||||||
|
display_name: 'Joey',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
public async register(
|
||||||
|
@Body() body: unknown,
|
||||||
|
@Req() request: HttpRequest,
|
||||||
|
@Res({ passthrough: true }) response: HttpResponse,
|
||||||
|
) {
|
||||||
|
const result = await this.authService.register(
|
||||||
|
parse(registerRequestSchema, body),
|
||||||
|
metadata(request),
|
||||||
|
);
|
||||||
|
setRefreshCookie(response, result.refreshToken);
|
||||||
|
return { data: result.payload };
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('login')
|
||||||
|
@Public()
|
||||||
|
@HttpCode(200)
|
||||||
|
@ApiOperation({ summary: '使用邮箱和密码登录' })
|
||||||
|
@ApiBody({
|
||||||
|
schema: {
|
||||||
|
example: {
|
||||||
|
email: 'owner@example.com',
|
||||||
|
password: 'a-strong-password',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
public async login(
|
||||||
|
@Body() body: unknown,
|
||||||
|
@Req() request: HttpRequest,
|
||||||
|
@Res({ passthrough: true }) response: HttpResponse,
|
||||||
|
) {
|
||||||
|
const result = await this.authService.login(
|
||||||
|
parse(loginRequestSchema, body),
|
||||||
|
metadata(request),
|
||||||
|
);
|
||||||
|
setRefreshCookie(response, result.refreshToken);
|
||||||
|
return { data: result.payload };
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('refresh')
|
||||||
|
@Public()
|
||||||
|
@HttpCode(200)
|
||||||
|
@ApiOperation({ summary: '轮换 refresh cookie 并获取新的 access token' })
|
||||||
|
public async refresh(
|
||||||
|
@Req() request: HttpRequest,
|
||||||
|
@Res({ passthrough: true }) response: HttpResponse,
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
// refresh token 不进入 JSON body,仅从浏览器自动发送的 HttpOnly cookie 读取。
|
||||||
|
const result = await this.authService.refresh(
|
||||||
|
readCookie(request.header('cookie'), refreshCookie),
|
||||||
|
metadata(request),
|
||||||
|
);
|
||||||
|
setRefreshCookie(response, result.refreshToken);
|
||||||
|
return {
|
||||||
|
data: {
|
||||||
|
access_token: result.payload.access_token,
|
||||||
|
access_token_expires_in:
|
||||||
|
result.payload.access_token_expires_in,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
clearRefreshCookie(response);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('logout')
|
||||||
|
@HttpCode(204)
|
||||||
|
@ApiBearerAuth('access-token')
|
||||||
|
@ApiOperation({ summary: '撤销当前登录会话' })
|
||||||
|
@ApiNoContentResponse({ description: '会话已撤销' })
|
||||||
|
@ApiUnauthorizedResponse({ description: 'access token 无效或已过期' })
|
||||||
|
public async logout(
|
||||||
|
@Req() request: AuthenticatedRequest,
|
||||||
|
@Res({ passthrough: true }) response: HttpResponse,
|
||||||
|
) {
|
||||||
|
await this.authService.logout(request.auth.sessionId);
|
||||||
|
clearRefreshCookie(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('me')
|
||||||
|
@ApiBearerAuth('access-token')
|
||||||
|
@ApiOperation({ summary: '获取当前用户、工作空间和页头初始上下文' })
|
||||||
|
@ApiOkResponse({ description: '当前身份和工作空间' })
|
||||||
|
@ApiUnauthorizedResponse({ description: 'access token 无效或已过期' })
|
||||||
|
public async me(
|
||||||
|
@Req() request: AuthenticatedRequest,
|
||||||
|
@CurrentWorkspace() workspace: Workspace,
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
data: await this.authService.me(request.auth.userId, workspace),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch('me')
|
||||||
|
@ApiBearerAuth('access-token')
|
||||||
|
@ApiOperation({ summary: '按版本更新当前账户的显示名称或头像素材引用' })
|
||||||
|
@ApiBody({
|
||||||
|
schema: {
|
||||||
|
example: {
|
||||||
|
display_name: 'Joey',
|
||||||
|
avatar_asset_id: 'b2b592e8-96b0-4d2b-afc4-2c6462f2065b',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
public async updateProfile(
|
||||||
|
@Body() body: unknown,
|
||||||
|
@Headers('if-match') ifMatch: string | undefined,
|
||||||
|
@Headers('idempotency-key') idempotencyKey: string | undefined,
|
||||||
|
@Req() request: AuthenticatedRequest,
|
||||||
|
@CurrentWorkspace() workspace: Workspace,
|
||||||
|
) {
|
||||||
|
return (
|
||||||
|
await this.authService.updateProfile(
|
||||||
|
workspace,
|
||||||
|
request.auth.userId,
|
||||||
|
requireIdempotencyKey(idempotencyKey),
|
||||||
|
parseRevision(ifMatch),
|
||||||
|
parse(updateCurrentUserRequestSchema, body),
|
||||||
|
)
|
||||||
|
).body;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('change-password')
|
||||||
|
@HttpCode(200)
|
||||||
|
@ApiBearerAuth('access-token')
|
||||||
|
@ApiOperation({ summary: '修改密码、撤销旧会话并轮换当前会话' })
|
||||||
|
@ApiBody({
|
||||||
|
schema: {
|
||||||
|
example: {
|
||||||
|
old_password: 'old-strong-password',
|
||||||
|
password: 'new-strong-password',
|
||||||
|
password_confirmation: 'new-strong-password',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
public async changePassword(
|
||||||
|
@Body() body: unknown,
|
||||||
|
@Req() request: AuthenticatedRequest & HttpRequest,
|
||||||
|
@CurrentWorkspace() workspace: Workspace,
|
||||||
|
@Res({ passthrough: true }) response: HttpResponse,
|
||||||
|
) {
|
||||||
|
const result = await this.authService.changePassword(
|
||||||
|
workspace,
|
||||||
|
request.auth.userId,
|
||||||
|
request.auth.sessionId,
|
||||||
|
parse(changePasswordRequestSchema, body),
|
||||||
|
metadata(request),
|
||||||
|
);
|
||||||
|
setRefreshCookie(response, result.refreshToken);
|
||||||
|
return {
|
||||||
|
data: {
|
||||||
|
access_token: result.payload.access_token,
|
||||||
|
access_token_expires_in: result.payload.access_token_expires_in,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 ReturnType<T['safeParse']> extends {
|
||||||
|
data: infer Data;
|
||||||
|
}
|
||||||
|
? Data
|
||||||
|
: never;
|
||||||
|
throw new ApiError(
|
||||||
|
422,
|
||||||
|
'VALIDATION_FAILED',
|
||||||
|
'请求参数不符合要求',
|
||||||
|
result.error?.issues.map((issue) => ({
|
||||||
|
field: issue.path.join('.'),
|
||||||
|
code: 'INVALID_FIELD',
|
||||||
|
message: issue.message,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
function metadata(request: HttpRequest): RequestMetadata {
|
||||||
|
return {
|
||||||
|
ip: request.ip ?? null,
|
||||||
|
ipHash: request.ip
|
||||||
|
? createHash('sha256').update(request.ip).digest('base64url')
|
||||||
|
: null,
|
||||||
|
userAgent: request.header('user-agent') ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
function cookieOptions() {
|
||||||
|
// cookie 路径限制在认证接口,避免无关请求携带长期会话凭证。
|
||||||
|
return {
|
||||||
|
httpOnly: true,
|
||||||
|
secure: process.env.NODE_ENV === 'production',
|
||||||
|
sameSite: 'lax' as const,
|
||||||
|
path: '/api/auth',
|
||||||
|
maxAge: 30 * 24 * 60 * 60 * 1000,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
function setRefreshCookie(response: HttpResponse, token: string) {
|
||||||
|
response.cookie(refreshCookie, token, cookieOptions());
|
||||||
|
}
|
||||||
|
function clearRefreshCookie(response: HttpResponse) {
|
||||||
|
response.clearCookie(refreshCookie, {
|
||||||
|
httpOnly: true,
|
||||||
|
secure: process.env.NODE_ENV === 'production',
|
||||||
|
sameSite: 'lax',
|
||||||
|
path: '/api/auth',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function readCookie(header: string | undefined, name: string) {
|
||||||
|
// 只解析本服务需要的单个 cookie,避免引入与认证无关的 cookie 状态。
|
||||||
|
return header
|
||||||
|
?.split(';')
|
||||||
|
.map((part) => part.trim())
|
||||||
|
.find((part) => part.startsWith(`${name}=`))
|
||||||
|
?.slice(name.length + 1);
|
||||||
|
}
|
||||||
|
function requireIdempotencyKey(value: string | undefined): string {
|
||||||
|
if (!value) {
|
||||||
|
throw new ApiError(
|
||||||
|
400,
|
||||||
|
'IDEMPOTENCY_KEY_REQUIRED',
|
||||||
|
'请求缺少 Idempotency-Key',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (z.uuid().safeParse(value).success) return value;
|
||||||
|
throw new ApiError(422, 'VALIDATION_FAILED', '请求参数不符合要求', [
|
||||||
|
{
|
||||||
|
field: 'Idempotency-Key',
|
||||||
|
code: 'INVALID_FIELD',
|
||||||
|
message: 'Idempotency-Key 必须是 UUID',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
function parseRevision(value: string | undefined): number {
|
||||||
|
const match = value?.match(/^"([1-9]\d*)"$/);
|
||||||
|
if (!match) {
|
||||||
|
throw new ApiError(
|
||||||
|
400,
|
||||||
|
'IF_MATCH_REQUIRED',
|
||||||
|
'请求必须携带形如 "1" 的 If-Match',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return Number(match[1]);
|
||||||
|
}
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
import {
|
||||||
|
createHash,
|
||||||
|
createHmac,
|
||||||
|
randomBytes,
|
||||||
|
scrypt as scryptCallback,
|
||||||
|
timingSafeEqual,
|
||||||
|
} from 'node:crypto';
|
||||||
|
import { promisify } from 'node:util';
|
||||||
|
|
||||||
|
const scrypt = promisify(scryptCallback) as (
|
||||||
|
password: string,
|
||||||
|
salt: string,
|
||||||
|
keyLength: number,
|
||||||
|
options: { N: number; r: number; p: number; maxmem: number },
|
||||||
|
) => Promise<Buffer>;
|
||||||
|
// 使用较高成本的 scrypt 参数,控制离线破解密码哈希时的时间与内存成本。
|
||||||
|
const scryptOptions = { N: 16_384, r: 8, p: 1, maxmem: 32 * 1024 * 1024 };
|
||||||
|
|
||||||
|
export async function hashPassword(password: string): Promise<string> {
|
||||||
|
// 每个密码使用独立随机盐,数据库泄露时不能直接复用预计算结果。
|
||||||
|
const salt = randomBytes(16).toString('base64url');
|
||||||
|
const derived = (await scrypt(password, salt, 64, scryptOptions)) as Buffer;
|
||||||
|
return `scrypt$${salt}$${derived.toString('base64url')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function verifyPassword(
|
||||||
|
password: string,
|
||||||
|
encoded: string,
|
||||||
|
): Promise<boolean> {
|
||||||
|
const [algorithm, salt, expected] = encoded.split('$');
|
||||||
|
if (algorithm !== 'scrypt' || !salt || !expected) return false;
|
||||||
|
const actual = (await scrypt(password, salt, 64, scryptOptions)) as Buffer;
|
||||||
|
const expectedBuffer = Buffer.from(expected, 'base64url');
|
||||||
|
return (
|
||||||
|
expectedBuffer.length === actual.length &&
|
||||||
|
timingSafeEqual(expectedBuffer, actual)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createOpaqueToken(): string {
|
||||||
|
return randomBytes(48).toString('base64url');
|
||||||
|
}
|
||||||
|
export function tokenHash(token: string): string {
|
||||||
|
// refresh token 只以摘要形式存库;服务端无需保存可直接用于登录的原文。
|
||||||
|
return createHash('sha256').update(token).digest('base64url');
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AccessTokenClaims = {
|
||||||
|
sub: string;
|
||||||
|
sid: string;
|
||||||
|
exp: number;
|
||||||
|
iat: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ExtensionAccessTokenClaims = AccessTokenClaims & { typ: 'extension'; wid: string };
|
||||||
|
export function signAccessToken(
|
||||||
|
claims: AccessTokenClaims,
|
||||||
|
secret: string,
|
||||||
|
): string {
|
||||||
|
// access token 是短期自包含凭证;真正可撤销的会话状态仍以 PostgreSQL 为准。
|
||||||
|
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 verifyAccessToken(
|
||||||
|
token: string,
|
||||||
|
secret: string,
|
||||||
|
): AccessTokenClaims | null {
|
||||||
|
const [header, payload, signature] = token.split('.');
|
||||||
|
if (!header || !payload || !signature) return null;
|
||||||
|
const expected = createHmac('sha256', secret)
|
||||||
|
.update(`${header}.${payload}`)
|
||||||
|
.digest();
|
||||||
|
const actual = Buffer.from(signature, 'base64url');
|
||||||
|
// 恒定时间比较可避免根据签名比对耗时推测正确前缀。
|
||||||
|
if (actual.length !== expected.length || !timingSafeEqual(actual, expected))
|
||||||
|
return null;
|
||||||
|
try {
|
||||||
|
const claims = JSON.parse(
|
||||||
|
Buffer.from(payload, 'base64url').toString('utf8'),
|
||||||
|
) as AccessTokenClaims;
|
||||||
|
return typeof claims.sub === 'string' &&
|
||||||
|
typeof claims.sid === 'string' &&
|
||||||
|
typeof claims.exp === 'number' &&
|
||||||
|
claims.exp > Math.floor(Date.now() / 1000)
|
||||||
|
? claims
|
||||||
|
: null;
|
||||||
|
} catch {
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import {
|
||||||
|
CanActivate,
|
||||||
|
ExecutionContext,
|
||||||
|
Inject,
|
||||||
|
Injectable,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { Reflector } from '@nestjs/core';
|
||||||
|
import type { Workspace } from '@atlas/contracts';
|
||||||
|
import { IS_PUBLIC_ENDPOINT } from '../common/public.decorator.js';
|
||||||
|
import { AuthService } from './auth.service.js';
|
||||||
|
|
||||||
|
export type AuthenticatedRequest = {
|
||||||
|
header: (name: string) => string | undefined;
|
||||||
|
auth: {
|
||||||
|
userId: string;
|
||||||
|
sessionId: string;
|
||||||
|
workspace?: Workspace;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class AccessTokenGuard implements CanActivate {
|
||||||
|
public constructor(
|
||||||
|
@Inject(Reflector) private readonly reflector: Reflector,
|
||||||
|
@Inject(AuthService) private readonly authService: AuthService,
|
||||||
|
) {}
|
||||||
|
public async canActivate(context: ExecutionContext) {
|
||||||
|
// 公开端点必须显式声明,避免新路由因忘记添加守卫而意外暴露。
|
||||||
|
if (
|
||||||
|
this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_ENDPOINT, [
|
||||||
|
context.getHandler(),
|
||||||
|
context.getClass(),
|
||||||
|
])
|
||||||
|
) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const request = context
|
||||||
|
.switchToHttp()
|
||||||
|
.getRequest<AuthenticatedRequest>();
|
||||||
|
// 只在此处解析 Authorization,后续守卫和控制器共享已验证的会话身份。
|
||||||
|
request.auth = await this.authService.authenticate(
|
||||||
|
request.header('authorization'),
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { APP_GUARD } from '@nestjs/core';
|
||||||
|
import { AuthController } from './auth.controller.js';
|
||||||
|
import { AccessTokenGuard } from './auth.guard.js';
|
||||||
|
import { AuthService } from './auth.service.js';
|
||||||
|
import { WorkspaceContextGuard } from '../workspace/workspace-context.guard.js';
|
||||||
|
import { IdempotencyService } from '../common/idempotency.service.js';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [AuthController],
|
||||||
|
providers: [
|
||||||
|
AuthService,
|
||||||
|
IdempotencyService,
|
||||||
|
AccessTokenGuard,
|
||||||
|
WorkspaceContextGuard,
|
||||||
|
{ provide: APP_GUARD, useExisting: AccessTokenGuard },
|
||||||
|
{ provide: APP_GUARD, useExisting: WorkspaceContextGuard },
|
||||||
|
],
|
||||||
|
exports: [AuthService, IdempotencyService],
|
||||||
|
})
|
||||||
|
export class AuthModule {}
|
||||||
@@ -0,0 +1,532 @@
|
|||||||
|
import { Inject, Injectable, UnauthorizedException } from '@nestjs/common';
|
||||||
|
import type {
|
||||||
|
AuthSessionResponse,
|
||||||
|
AuthUser,
|
||||||
|
ChangePasswordRequest,
|
||||||
|
LoginRequest,
|
||||||
|
RegisterRequest,
|
||||||
|
UpdateCurrentUserRequest,
|
||||||
|
Workspace,
|
||||||
|
} 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 { AuthRateLimiter } from '../security/auth-rate-limiter.service.js';
|
||||||
|
import {
|
||||||
|
createOpaqueToken,
|
||||||
|
hashPassword,
|
||||||
|
signAccessToken,
|
||||||
|
tokenHash,
|
||||||
|
verifyAccessToken,
|
||||||
|
verifyPassword,
|
||||||
|
} from './auth.crypto.js';
|
||||||
|
|
||||||
|
type UserRow = {
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
display_name: string | null;
|
||||||
|
avatar_asset_id: string | null;
|
||||||
|
email_verified_at: Date | null;
|
||||||
|
created_at: Date;
|
||||||
|
revision: number;
|
||||||
|
status: 'active' | 'disabled' | 'pending_review';
|
||||||
|
password_hash: string;
|
||||||
|
};
|
||||||
|
type WorkspaceRow = { id: string; name: string; role: Workspace['role'] };
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class AuthService {
|
||||||
|
// access token 短期存在于浏览器内存;refresh session 的有效期更长且可服务端撤销。
|
||||||
|
private readonly accessTtlSeconds = 900;
|
||||||
|
private readonly refreshTtlDays = 30;
|
||||||
|
|
||||||
|
public constructor(
|
||||||
|
@Inject(DATABASE) private readonly database: Database,
|
||||||
|
@Inject(APP_CONFIG) private readonly config: AppConfig,
|
||||||
|
@Inject(AuthRateLimiter)
|
||||||
|
private readonly rateLimiter: AuthRateLimiter,
|
||||||
|
@Inject(IdempotencyService)
|
||||||
|
private readonly idempotency: IdempotencyService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public async register(input: RegisterRequest, metadata: RequestMetadata) {
|
||||||
|
if (input.password !== input.password_confirmation)
|
||||||
|
throw validationError(
|
||||||
|
'password_confirmation',
|
||||||
|
'PASSWORD_CONFIRMATION_MISMATCH',
|
||||||
|
'两次输入的密码不一致',
|
||||||
|
);
|
||||||
|
if (input.password.length < this.config.AUTH_PASSWORD_MIN_LENGTH)
|
||||||
|
throw validationError(
|
||||||
|
'password',
|
||||||
|
'PASSWORD_TOO_SHORT',
|
||||||
|
`密码至少需要 ${this.config.AUTH_PASSWORD_MIN_LENGTH} 个字符`,
|
||||||
|
);
|
||||||
|
const email = input.email.trim().toLowerCase();
|
||||||
|
// 限流必须早于密码哈希和用户查询,避免攻击者消耗 CPU 或探测账户状态。
|
||||||
|
await this.rateLimiter.consume('register', { ip: metadata.ip, email });
|
||||||
|
const passwordHash = await hashPassword(input.password);
|
||||||
|
try {
|
||||||
|
return await this.database.transaction(async (transaction) => {
|
||||||
|
// 用户、个人工作空间和 owner membership 必须原子创建,不能留下半成品租户。
|
||||||
|
const user = await insertUser(
|
||||||
|
transaction,
|
||||||
|
email,
|
||||||
|
passwordHash,
|
||||||
|
input.display_name?.trim() || null,
|
||||||
|
);
|
||||||
|
const workspace = await createPersonalWorkspace(
|
||||||
|
transaction,
|
||||||
|
user.id,
|
||||||
|
user.display_name ?? email,
|
||||||
|
);
|
||||||
|
return this.createSessionResponse(
|
||||||
|
transaction,
|
||||||
|
user,
|
||||||
|
workspace,
|
||||||
|
metadata,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
} catch (error: unknown) {
|
||||||
|
if (isUniqueViolation(error))
|
||||||
|
throw new ApiError(
|
||||||
|
409,
|
||||||
|
'EMAIL_ALREADY_REGISTERED',
|
||||||
|
'该邮箱已注册',
|
||||||
|
[
|
||||||
|
{
|
||||||
|
field: 'email',
|
||||||
|
code: 'EMAIL_ALREADY_REGISTERED',
|
||||||
|
message: '该邮箱已注册',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async login(input: LoginRequest, metadata: RequestMetadata) {
|
||||||
|
const email = input.email.trim().toLowerCase();
|
||||||
|
// 限流与 citext 查询使用同一规范化邮箱,避免大小写绕过限流桶。
|
||||||
|
await this.rateLimiter.consume('login', { ip: metadata.ip, email });
|
||||||
|
const result = await this.database.query<UserRow>(
|
||||||
|
'SELECT id, email, display_name, avatar_asset_id, email_verified_at, created_at, revision, status, password_hash FROM atlas.users WHERE email = $1',
|
||||||
|
[email],
|
||||||
|
);
|
||||||
|
const user = result.rows[0];
|
||||||
|
if (
|
||||||
|
!user ||
|
||||||
|
!(await verifyPassword(input.password, user.password_hash))
|
||||||
|
)
|
||||||
|
throw new ApiError(401, 'INVALID_CREDENTIALS', '邮箱或密码错误');
|
||||||
|
if (user.status === 'disabled')
|
||||||
|
throw new ApiError(403, 'ACCOUNT_DISABLED', '账户已停用');
|
||||||
|
const workspace = await this.findWorkspace(user.id);
|
||||||
|
return this.database.transaction(async (transaction) => {
|
||||||
|
await transaction.query(
|
||||||
|
'UPDATE atlas.users SET last_login_at = now() WHERE id = $1',
|
||||||
|
[user.id],
|
||||||
|
);
|
||||||
|
return this.createSessionResponse(
|
||||||
|
transaction,
|
||||||
|
user,
|
||||||
|
workspace,
|
||||||
|
metadata,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public async refresh(
|
||||||
|
refreshToken: string | undefined,
|
||||||
|
metadata: RequestMetadata,
|
||||||
|
) {
|
||||||
|
// refresh token 验证前没有可信邮箱,只能按 IP 维度限流。
|
||||||
|
await this.rateLimiter.consume('refresh', { ip: metadata.ip });
|
||||||
|
if (!refreshToken)
|
||||||
|
throw new ApiError(401, 'SESSION_EXPIRED', '登录会话已过期');
|
||||||
|
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() FOR UPDATE`,
|
||||||
|
[tokenHash(refreshToken)],
|
||||||
|
);
|
||||||
|
const user = sessions.rows[0];
|
||||||
|
if (!user || user.status !== 'active')
|
||||||
|
throw new ApiError(401, 'SESSION_EXPIRED', '登录会话已过期');
|
||||||
|
// 行锁保证并发 refresh 时旧 token 只能成功使用一次。
|
||||||
|
await transaction.query(
|
||||||
|
'UPDATE atlas.user_sessions SET revoked_at = now() WHERE id = $1',
|
||||||
|
[user.session_id],
|
||||||
|
);
|
||||||
|
const workspace = await findWorkspaceInTransaction(
|
||||||
|
transaction,
|
||||||
|
user.id,
|
||||||
|
);
|
||||||
|
return this.createSessionResponse(
|
||||||
|
transaction,
|
||||||
|
user,
|
||||||
|
workspace,
|
||||||
|
metadata,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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',
|
||||||
|
[sessionId],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async authenticate(accessToken: string | undefined) {
|
||||||
|
const token = accessToken?.replace(/^Bearer\s+/i, '');
|
||||||
|
const claims = token
|
||||||
|
? verifyAccessToken(token, this.config.AUTH_JWT_SECRET)
|
||||||
|
: null;
|
||||||
|
if (!claims) throw new UnauthorizedException();
|
||||||
|
// JWT 签名有效仍不足够;必须确认对应 session 未撤销且未过期。
|
||||||
|
const active = await this.database.query(
|
||||||
|
'SELECT 1 FROM atlas.user_sessions WHERE id = $1 AND user_id = $2 AND revoked_at IS NULL AND expires_at > now()',
|
||||||
|
[claims.sid, claims.sub],
|
||||||
|
);
|
||||||
|
if (!active.rowCount) throw new UnauthorizedException();
|
||||||
|
return { userId: claims.sub, sessionId: claims.sid };
|
||||||
|
}
|
||||||
|
|
||||||
|
public async me(userId: string, workspace: Workspace) {
|
||||||
|
// workspace 由全局守卫解析,避免控制器或服务各自选择租户造成边界不一致。
|
||||||
|
const users = await this.database.query<UserRow>(
|
||||||
|
'SELECT id, email, display_name, avatar_asset_id, email_verified_at, created_at, revision, status, password_hash FROM atlas.users WHERE id = $1',
|
||||||
|
[userId],
|
||||||
|
);
|
||||||
|
const user = users.rows[0];
|
||||||
|
if (!user) throw new UnauthorizedException();
|
||||||
|
const platforms = await this.database.workspaceTransaction(
|
||||||
|
workspace.id,
|
||||||
|
async (transaction) => {
|
||||||
|
const rows = await transaction.query<{
|
||||||
|
id: string;
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
}>(
|
||||||
|
"SELECT id, code, name FROM atlas.platforms WHERE status = 'active' ORDER BY CASE WHEN code = 'OZON' THEN 0 ELSE 1 END, name",
|
||||||
|
);
|
||||||
|
return rows.rows;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
user: publicUser(user),
|
||||||
|
workspace,
|
||||||
|
platforms,
|
||||||
|
suggested_context: {
|
||||||
|
platform_id: platforms[0]?.id ?? null,
|
||||||
|
default_store: null,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public async updateProfile(
|
||||||
|
workspace: Workspace,
|
||||||
|
userId: string,
|
||||||
|
idempotencyKey: string,
|
||||||
|
revision: number,
|
||||||
|
input: UpdateCurrentUserRequest,
|
||||||
|
) {
|
||||||
|
return this.idempotency.run(
|
||||||
|
{
|
||||||
|
workspaceId: workspace.id,
|
||||||
|
userId,
|
||||||
|
method: 'PATCH',
|
||||||
|
path: '/api/auth/me',
|
||||||
|
key: idempotencyKey,
|
||||||
|
request: { if_match: revision, ...input },
|
||||||
|
},
|
||||||
|
async (transaction) => {
|
||||||
|
const user = await findUserInTransaction(
|
||||||
|
transaction,
|
||||||
|
userId,
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
if (user.revision !== revision) {
|
||||||
|
throw versionConflict(user.revision);
|
||||||
|
}
|
||||||
|
if (input.avatar_asset_id) {
|
||||||
|
// 头像只能引用当前工作空间内已完成入库的素材,不能接受任意对象路径或 URL。
|
||||||
|
const asset = await transaction.query<{ id: string }>(
|
||||||
|
`SELECT id FROM atlas.media_assets WHERE id = $1`,
|
||||||
|
[input.avatar_asset_id],
|
||||||
|
);
|
||||||
|
if (!asset.rows[0]) {
|
||||||
|
throw new ApiError(
|
||||||
|
422,
|
||||||
|
'AVATAR_ASSET_UNAVAILABLE',
|
||||||
|
'头像素材不存在或不属于当前工作空间',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const updated = await transaction.query<UserRow>(
|
||||||
|
`UPDATE atlas.users
|
||||||
|
SET display_name = CASE WHEN $1 THEN $2 ELSE display_name END,
|
||||||
|
avatar_asset_id = CASE WHEN $3 THEN $4::uuid ELSE avatar_asset_id END,
|
||||||
|
revision = revision + 1, updated_at = now()
|
||||||
|
WHERE id = $5
|
||||||
|
RETURNING id, email, display_name, avatar_asset_id, email_verified_at,
|
||||||
|
created_at, revision, status, password_hash`,
|
||||||
|
[
|
||||||
|
Object.hasOwn(input, 'display_name'),
|
||||||
|
input.display_name ?? null,
|
||||||
|
Object.hasOwn(input, 'avatar_asset_id'),
|
||||||
|
input.avatar_asset_id ?? null,
|
||||||
|
userId,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
status: 200,
|
||||||
|
body: { data: publicUser(updated.rows[0]) },
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async changePassword(
|
||||||
|
workspace: Workspace,
|
||||||
|
userId: string,
|
||||||
|
sessionId: string,
|
||||||
|
input: ChangePasswordRequest,
|
||||||
|
metadata: RequestMetadata,
|
||||||
|
) {
|
||||||
|
if (input.password !== input.password_confirmation) {
|
||||||
|
throw validationError(
|
||||||
|
'password_confirmation',
|
||||||
|
'PASSWORD_CONFIRMATION_MISMATCH',
|
||||||
|
'两次输入的密码不一致',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (input.password.length < this.config.AUTH_PASSWORD_MIN_LENGTH) {
|
||||||
|
throw validationError(
|
||||||
|
'password',
|
||||||
|
'PASSWORD_TOO_SHORT',
|
||||||
|
`密码至少需要 ${this.config.AUTH_PASSWORD_MIN_LENGTH} 个字符`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// 修改密码按已认证用户和来源 IP 双维度限流,避免已泄露会话被用于高频猜测旧密码。
|
||||||
|
await this.rateLimiter.consume('change_password', {
|
||||||
|
ip: metadata.ip,
|
||||||
|
userId,
|
||||||
|
});
|
||||||
|
return this.database.transaction(async (transaction) => {
|
||||||
|
const session = await transaction.query<{ id: string }>(
|
||||||
|
`SELECT id FROM atlas.user_sessions
|
||||||
|
WHERE id = $1 AND user_id = $2 AND revoked_at IS NULL AND expires_at > now()
|
||||||
|
FOR UPDATE`,
|
||||||
|
[sessionId, userId],
|
||||||
|
);
|
||||||
|
if (!session.rows[0]) throw new UnauthorizedException();
|
||||||
|
const user = await findUserInTransaction(transaction, userId, true);
|
||||||
|
if (user.status !== 'active') {
|
||||||
|
throw new ApiError(403, 'ACCOUNT_DISABLED', '账户已停用');
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
!(await verifyPassword(input.old_password, user.password_hash))
|
||||||
|
) {
|
||||||
|
throw validationError(
|
||||||
|
'old_password',
|
||||||
|
'CURRENT_PASSWORD_INVALID',
|
||||||
|
'当前密码错误',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const passwordHash = await hashPassword(input.password);
|
||||||
|
const updated = await transaction.query<UserRow>(
|
||||||
|
`UPDATE atlas.users SET password_hash = $1, updated_at = now()
|
||||||
|
WHERE id = $2
|
||||||
|
RETURNING id, email, display_name, avatar_asset_id, email_verified_at,
|
||||||
|
created_at, revision, status, password_hash`,
|
||||||
|
[passwordHash, userId],
|
||||||
|
);
|
||||||
|
// 先撤销所有旧 session,再创建新 session;旧 access token 会立即失效。
|
||||||
|
await transaction.query(
|
||||||
|
`UPDATE atlas.user_sessions SET revoked_at = now()
|
||||||
|
WHERE user_id = $1 AND revoked_at IS NULL`,
|
||||||
|
[userId],
|
||||||
|
);
|
||||||
|
return this.createSessionResponse(
|
||||||
|
transaction,
|
||||||
|
updated.rows[0],
|
||||||
|
workspace,
|
||||||
|
metadata,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async findWorkspace(userId: string) {
|
||||||
|
const result = await this.database.query<WorkspaceRow>(
|
||||||
|
`SELECT w.id, w.name, m.role 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`,
|
||||||
|
[userId],
|
||||||
|
);
|
||||||
|
return requireWorkspace(result.rows[0]);
|
||||||
|
}
|
||||||
|
private async createSessionResponse(
|
||||||
|
transaction: DatabaseTransaction,
|
||||||
|
user: UserRow,
|
||||||
|
workspace: WorkspaceRow,
|
||||||
|
metadata: RequestMetadata,
|
||||||
|
) {
|
||||||
|
// refresh token 仅通过 HttpOnly cookie 交给浏览器,响应体只暴露短期 access token。
|
||||||
|
const refreshToken = createOpaqueToken();
|
||||||
|
const session = await transaction.query<{ id: string }>(
|
||||||
|
`INSERT INTO atlas.user_sessions (user_id, refresh_token_hash, expires_at, ip_hash, user_agent)
|
||||||
|
VALUES ($1, $2, now() + ($3 || ' days')::interval, $4, $5) RETURNING id`,
|
||||||
|
[
|
||||||
|
user.id,
|
||||||
|
tokenHash(refreshToken),
|
||||||
|
this.refreshTtlDays,
|
||||||
|
metadata.ipHash,
|
||||||
|
metadata.userAgent,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
const accessToken = signAccessToken(
|
||||||
|
{
|
||||||
|
sub: user.id,
|
||||||
|
sid: session.rows[0].id,
|
||||||
|
iat: now,
|
||||||
|
exp: now + this.accessTtlSeconds,
|
||||||
|
},
|
||||||
|
this.config.AUTH_JWT_SECRET,
|
||||||
|
);
|
||||||
|
const payload: AuthSessionResponse = {
|
||||||
|
access_token: accessToken,
|
||||||
|
access_token_expires_in: this.accessTtlSeconds,
|
||||||
|
user: publicUser(user),
|
||||||
|
workspace,
|
||||||
|
};
|
||||||
|
return { payload, refreshToken };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type RequestMetadata = {
|
||||||
|
ip: string | null;
|
||||||
|
ipHash: string | null;
|
||||||
|
userAgent: string | null;
|
||||||
|
};
|
||||||
|
function validationError(field: string, code: string, message: string) {
|
||||||
|
return new ApiError(422, 'VALIDATION_FAILED', '请求参数不符合要求', [
|
||||||
|
{ field, code, message },
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
function isUniqueViolation(error: unknown): error is { code: string } {
|
||||||
|
return (
|
||||||
|
typeof error === 'object' &&
|
||||||
|
error !== null &&
|
||||||
|
'code' in error &&
|
||||||
|
(error as { code: string }).code === '23505'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
async function findUserInTransaction(
|
||||||
|
transaction: DatabaseTransaction,
|
||||||
|
userId: string,
|
||||||
|
lock: boolean,
|
||||||
|
): Promise<UserRow> {
|
||||||
|
const result = await transaction.query<UserRow>(
|
||||||
|
`SELECT id, email, display_name, avatar_asset_id, email_verified_at, created_at,
|
||||||
|
revision, status, password_hash
|
||||||
|
FROM atlas.users WHERE id = $1${lock ? ' FOR UPDATE' : ''}`,
|
||||||
|
[userId],
|
||||||
|
);
|
||||||
|
if (!result.rows[0]) throw new UnauthorizedException();
|
||||||
|
return result.rows[0];
|
||||||
|
}
|
||||||
|
function versionConflict(currentRevision: number) {
|
||||||
|
return new ApiError(409, 'VERSION_CONFLICT', '账户资料已被其他操作更新', [
|
||||||
|
{
|
||||||
|
field: 'If-Match',
|
||||||
|
code: 'VERSION_CONFLICT',
|
||||||
|
message: `当前版本为 ${currentRevision}`,
|
||||||
|
current_revision: currentRevision,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
function publicUser(user: UserRow): AuthUser {
|
||||||
|
// 白名单映射防止 password_hash 等内部字段随查询结果泄露给客户端。
|
||||||
|
return {
|
||||||
|
id: user.id,
|
||||||
|
email: user.email,
|
||||||
|
display_name: user.display_name,
|
||||||
|
avatar_asset_id: user.avatar_asset_id,
|
||||||
|
email_verified_at: user.email_verified_at?.toISOString() ?? null,
|
||||||
|
created_at: user.created_at.toISOString(),
|
||||||
|
revision: user.revision,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
async function insertUser(
|
||||||
|
transaction: DatabaseTransaction,
|
||||||
|
email: string,
|
||||||
|
passwordHash: string,
|
||||||
|
displayName: string | null,
|
||||||
|
) {
|
||||||
|
// slug 使用用户 UUID,显示名称变化不会影响个人工作空间的稳定标识。
|
||||||
|
const result = await transaction.query<UserRow>(
|
||||||
|
'INSERT INTO atlas.users (email, password_hash, display_name) VALUES ($1, $2, $3) RETURNING id, email, display_name, avatar_asset_id, email_verified_at, created_at, revision, status, password_hash',
|
||||||
|
[email, passwordHash, displayName],
|
||||||
|
);
|
||||||
|
return result.rows[0];
|
||||||
|
}
|
||||||
|
async function createPersonalWorkspace(
|
||||||
|
transaction: DatabaseTransaction,
|
||||||
|
userId: string,
|
||||||
|
label: string,
|
||||||
|
) {
|
||||||
|
const workspace = await transaction.query<{ id: string; name: string }>(
|
||||||
|
'INSERT INTO atlas.workspaces (name, slug) VALUES ($1, $2) RETURNING id, name',
|
||||||
|
[`${label} 的工作空间`, `personal-${userId}`],
|
||||||
|
);
|
||||||
|
await transaction.query(
|
||||||
|
"INSERT INTO atlas.workspace_memberships (workspace_id, user_id, role) VALUES ($1, $2, 'owner')",
|
||||||
|
[workspace.rows[0].id, userId],
|
||||||
|
);
|
||||||
|
return { ...workspace.rows[0], role: 'owner' as const };
|
||||||
|
}
|
||||||
|
async function findWorkspaceInTransaction(
|
||||||
|
transaction: DatabaseTransaction,
|
||||||
|
userId: string,
|
||||||
|
): Promise<WorkspaceRow> {
|
||||||
|
const result = await transaction.query<WorkspaceRow>(
|
||||||
|
`SELECT w.id, w.name, m.role 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`,
|
||||||
|
[userId],
|
||||||
|
);
|
||||||
|
return requireWorkspace(result.rows[0]);
|
||||||
|
}
|
||||||
|
function requireWorkspace(workspace: WorkspaceRow | undefined): WorkspaceRow {
|
||||||
|
if (!workspace)
|
||||||
|
throw new ApiError(
|
||||||
|
403,
|
||||||
|
'WORKSPACE_MEMBERSHIP_REQUIRED',
|
||||||
|
'当前账户没有可用工作空间',
|
||||||
|
);
|
||||||
|
return workspace;
|
||||||
|
}
|
||||||
@@ -0,0 +1,228 @@
|
|||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
Headers,
|
||||||
|
Inject,
|
||||||
|
Param,
|
||||||
|
Patch,
|
||||||
|
Post,
|
||||||
|
Query,
|
||||||
|
Req,
|
||||||
|
Res,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import {
|
||||||
|
bulkDeleteCollectionItemsRequestSchema,
|
||||||
|
clearCollectionItemsRequestSchema,
|
||||||
|
createCollectionItemRequestSchema,
|
||||||
|
listCollectionItemsQuerySchema,
|
||||||
|
materializeCollectionAssetsRequestSchema,
|
||||||
|
retryCollectionAssetsRequestSchema,
|
||||||
|
updateCollectionItemRequestSchema,
|
||||||
|
type Workspace,
|
||||||
|
} from '@atlas/contracts';
|
||||||
|
import { ApiError } from '../common/api-error.js';
|
||||||
|
import type { AuthenticatedRequest } from '../auth/auth.guard.js';
|
||||||
|
import { CurrentWorkspace } from '../workspace/current-workspace.decorator.js';
|
||||||
|
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),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
/** 返回采集记录、解析证据、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]);
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
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],
|
||||||
|
})
|
||||||
|
export class CollectionModule {}
|
||||||
@@ -0,0 +1,547 @@
|
|||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
|
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;
|
||||||
|
};
|
||||||
|
@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}
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
/** 原子创建手工采集记录、商品和默认 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 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
/** 统一将数据库时间值序列化为 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', '分页游标无效');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import {
|
||||||
|
ArgumentsHost,
|
||||||
|
Catch,
|
||||||
|
ExceptionFilter,
|
||||||
|
HttpException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
import { ApiError } from './api-error.js';
|
||||||
|
import { AppLogger } from '../logging/app-logger.js';
|
||||||
|
|
||||||
|
@Catch()
|
||||||
|
export class ApiErrorFilter implements ExceptionFilter {
|
||||||
|
public constructor(private readonly logger: AppLogger) {}
|
||||||
|
|
||||||
|
public catch(exception: unknown, host: ArgumentsHost) {
|
||||||
|
const response = host.switchToHttp().getResponse<{
|
||||||
|
status: (status: number) => {
|
||||||
|
json: (body: unknown) => unknown;
|
||||||
|
};
|
||||||
|
}>();
|
||||||
|
const request = host.switchToHttp().getRequest<{
|
||||||
|
requestId?: string;
|
||||||
|
method?: string;
|
||||||
|
originalUrl?: string;
|
||||||
|
}>();
|
||||||
|
const requestId = request.requestId ?? `req_${randomUUID()}`;
|
||||||
|
if (exception instanceof ApiError) {
|
||||||
|
// 已知业务错误保留其稳定 code 与 details,方便前端精确处理。
|
||||||
|
const body = exception.getResponse() as {
|
||||||
|
error: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
return response
|
||||||
|
.status(exception.getStatus())
|
||||||
|
.json({ error: { ...body.error, request_id: requestId } });
|
||||||
|
}
|
||||||
|
if (exception instanceof HttpException) {
|
||||||
|
// Nest 内部异常不泄露原始 message,避免暴露认证或解析实现细节。
|
||||||
|
return response.status(exception.getStatus()).json({
|
||||||
|
error: {
|
||||||
|
code: 'HTTP_ERROR',
|
||||||
|
message: '请求无法处理',
|
||||||
|
request_id: requestId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
this.logger.error(
|
||||||
|
'unhandled_exception',
|
||||||
|
exception instanceof Error ? exception.stack : undefined,
|
||||||
|
{
|
||||||
|
request_id: requestId,
|
||||||
|
method: request.method,
|
||||||
|
path: request.originalUrl,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
// 未预期异常只返回通用信息,request_id 用于从结构化日志定位根因。
|
||||||
|
return response.status(500).json({
|
||||||
|
error: {
|
||||||
|
code: 'INTERNAL_ERROR',
|
||||||
|
message: '服务器暂时无法处理请求',
|
||||||
|
request_id: requestId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { HttpException, HttpStatus } from '@nestjs/common';
|
||||||
|
|
||||||
|
export class ApiError extends HttpException {
|
||||||
|
// 业务代码只能通过此类型抛出可预期错误,最终由全局过滤器统一封装为 error 信封。
|
||||||
|
public constructor(
|
||||||
|
status: HttpStatus,
|
||||||
|
code: string,
|
||||||
|
message: string,
|
||||||
|
details?: unknown[],
|
||||||
|
) {
|
||||||
|
super({ error: { code, message, details } }, status);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
import { createHmac } from 'node:crypto';
|
||||||
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
|
import type { Database, DatabaseTransaction } from '@atlas/database';
|
||||||
|
import { APP_CONFIG, type AppConfig } from '../config/app-config.module.js';
|
||||||
|
import { DATABASE } from '../database/database.module.js';
|
||||||
|
import { ApiError } from './api-error.js';
|
||||||
|
|
||||||
|
type IdempotentResponse = { status: number; body: unknown };
|
||||||
|
type IdempotencyInput = {
|
||||||
|
workspaceId: string;
|
||||||
|
userId: string;
|
||||||
|
method: 'POST' | 'PUT' | 'PATCH' | 'DELETE';
|
||||||
|
path: string;
|
||||||
|
key: string;
|
||||||
|
request: unknown;
|
||||||
|
};
|
||||||
|
type IdempotencyRow = {
|
||||||
|
request_fingerprint: string;
|
||||||
|
status: 'in_progress' | 'completed';
|
||||||
|
response_status: number | null;
|
||||||
|
response_body: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class IdempotencyService {
|
||||||
|
public constructor(
|
||||||
|
@Inject(DATABASE) private readonly database: Database,
|
||||||
|
@Inject(APP_CONFIG) private readonly config: AppConfig,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 幂等记录与业务写入共用同一个工作空间事务。只有两者都提交,重试才会得到可靠的历史响应。
|
||||||
|
*/
|
||||||
|
public async run(
|
||||||
|
input: IdempotencyInput,
|
||||||
|
work: (transaction: DatabaseTransaction) => Promise<IdempotentResponse>,
|
||||||
|
): Promise<IdempotentResponse> {
|
||||||
|
const fingerprint = createHmac(
|
||||||
|
'sha256',
|
||||||
|
this.config.IDEMPOTENCY_KEY_SECRET,
|
||||||
|
)
|
||||||
|
.update(canonicalize(input.request))
|
||||||
|
.digest('base64url');
|
||||||
|
|
||||||
|
return this.database.workspaceTransaction(
|
||||||
|
input.workspaceId,
|
||||||
|
async (transaction) => {
|
||||||
|
const inserted = await transaction.query<IdempotencyRow>(
|
||||||
|
`INSERT INTO atlas.idempotency_requests
|
||||||
|
(workspace_id, user_id, http_method, request_path, idempotency_key, request_fingerprint)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6)
|
||||||
|
ON CONFLICT (workspace_id, user_id, http_method, request_path, idempotency_key) DO NOTHING
|
||||||
|
RETURNING request_fingerprint, status, response_status, response_body`,
|
||||||
|
[
|
||||||
|
input.workspaceId,
|
||||||
|
input.userId,
|
||||||
|
input.method,
|
||||||
|
input.path,
|
||||||
|
input.key,
|
||||||
|
fingerprint,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
if (!inserted.rows[0]) {
|
||||||
|
// 冲突插入会等待首个事务结束;随后行锁确保只能读取完整的持久化结果。
|
||||||
|
const existing = await transaction.query<IdempotencyRow>(
|
||||||
|
`SELECT request_fingerprint, status, response_status, response_body
|
||||||
|
FROM atlas.idempotency_requests
|
||||||
|
WHERE workspace_id = $1 AND user_id = $2 AND http_method = $3
|
||||||
|
AND request_path = $4 AND idempotency_key = $5
|
||||||
|
FOR UPDATE`,
|
||||||
|
[
|
||||||
|
input.workspaceId,
|
||||||
|
input.userId,
|
||||||
|
input.method,
|
||||||
|
input.path,
|
||||||
|
input.key,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
const row = existing.rows[0];
|
||||||
|
if (!row || row.request_fingerprint !== fingerprint) {
|
||||||
|
throw new ApiError(
|
||||||
|
409,
|
||||||
|
'IDEMPOTENCY_KEY_REUSED',
|
||||||
|
'幂等键已用于不同请求',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (row.status !== 'completed' || !row.response_status) {
|
||||||
|
throw new ApiError(
|
||||||
|
409,
|
||||||
|
'IDEMPOTENCY_REQUEST_IN_PROGRESS',
|
||||||
|
'相同请求仍在处理中',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
status: row.response_status,
|
||||||
|
body: row.response_body,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await work(transaction);
|
||||||
|
await transaction.query(
|
||||||
|
`UPDATE atlas.idempotency_requests
|
||||||
|
SET status = 'completed', response_status = $1, response_body = $2,
|
||||||
|
completed_at = now()
|
||||||
|
WHERE workspace_id = $3 AND user_id = $4 AND http_method = $5
|
||||||
|
AND request_path = $6 AND idempotency_key = $7`,
|
||||||
|
[
|
||||||
|
response.status,
|
||||||
|
response.body,
|
||||||
|
input.workspaceId,
|
||||||
|
input.userId,
|
||||||
|
input.method,
|
||||||
|
input.path,
|
||||||
|
input.key,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
return response;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 规范化排序后再签名,使 JSON 字段顺序不会改变同一请求的身份。 */
|
||||||
|
function canonicalize(value: unknown): string {
|
||||||
|
if (value === null || typeof value !== 'object')
|
||||||
|
return JSON.stringify(value);
|
||||||
|
if (Array.isArray(value)) return `[${value.map(canonicalize).join(',')}]`;
|
||||||
|
const record = value as Record<string, unknown>;
|
||||||
|
return `{${Object.keys(record)
|
||||||
|
.sort()
|
||||||
|
.map((key) => `${JSON.stringify(key)}:${canonicalize(record[key])}`)
|
||||||
|
.join(',')}}`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { SetMetadata } from '@nestjs/common';
|
||||||
|
|
||||||
|
export const IS_PUBLIC_ENDPOINT = 'atlas:is-public-endpoint';
|
||||||
|
|
||||||
|
// 全局认证守卫只跳过显式标记的端点,避免新接口因遗漏守卫而意外公开。
|
||||||
|
export const Public = () => SetMetadata(IS_PUBLIC_ENDPOINT, true);
|
||||||
@@ -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}状态已变化`);
|
||||||
|
}
|
||||||
@@ -0,0 +1,217 @@
|
|||||||
|
import { existsSync } from 'node:fs';
|
||||||
|
import { resolve } from 'node:path';
|
||||||
|
import { Global, Module } from '@nestjs/common';
|
||||||
|
import { ConfigModule } from '@nestjs/config';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
export const APP_CONFIG = Symbol('APP_CONFIG');
|
||||||
|
|
||||||
|
function findEnvironmentFile(): string {
|
||||||
|
const directories = [
|
||||||
|
process.cwd(),
|
||||||
|
resolve(process.cwd(), '..'),
|
||||||
|
resolve(process.cwd(), '../..'),
|
||||||
|
];
|
||||||
|
const environmentFile = directories
|
||||||
|
.map((directory) => resolve(directory, '.env'))
|
||||||
|
.find((file) => existsSync(file));
|
||||||
|
|
||||||
|
return environmentFile ?? resolve(process.cwd(), '.env');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 此 schema 是 API 运行配置的唯一来源;默认值只允许用于不含秘密的本地开发场景。
|
||||||
|
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),
|
||||||
|
AUTH_RATE_LIMIT_KEY_SECRET: z.string().min(32),
|
||||||
|
STORE_SECRET_ENCRYPTION_KEY: z.string().min(32),
|
||||||
|
STORE_SECRET_ENCRYPTION_KEY_VERSION: z
|
||||||
|
.string()
|
||||||
|
.min(1)
|
||||||
|
.max(64)
|
||||||
|
.default('v1'),
|
||||||
|
IDEMPOTENCY_KEY_SECRET: z.string().min(32),
|
||||||
|
AUTH_PASSWORD_MIN_LENGTH: z.coerce
|
||||||
|
.number()
|
||||||
|
.int()
|
||||||
|
.min(12)
|
||||||
|
.max(256)
|
||||||
|
.default(12),
|
||||||
|
API_BODY_LIMIT_BYTES: z.coerce
|
||||||
|
.number()
|
||||||
|
.int()
|
||||||
|
.min(1_024)
|
||||||
|
.max(10 * 1024 * 1024)
|
||||||
|
.default(1_048_576),
|
||||||
|
AUTH_RATE_LIMIT_WINDOW_SECONDS: z.coerce
|
||||||
|
.number()
|
||||||
|
.int()
|
||||||
|
.min(1)
|
||||||
|
.max(86_400)
|
||||||
|
.default(900),
|
||||||
|
AUTH_LOGIN_IP_LIMIT: z.coerce.number().int().min(1).max(1000).default(10),
|
||||||
|
AUTH_LOGIN_EMAIL_LIMIT: z.coerce.number().int().min(1).max(1000).default(5),
|
||||||
|
AUTH_REGISTER_IP_LIMIT: z.coerce.number().int().min(1).max(1000).default(5),
|
||||||
|
AUTH_REGISTER_EMAIL_LIMIT: z.coerce
|
||||||
|
.number()
|
||||||
|
.int()
|
||||||
|
.min(1)
|
||||||
|
.max(1000)
|
||||||
|
.default(3),
|
||||||
|
AUTH_REFRESH_IP_LIMIT: z.coerce
|
||||||
|
.number()
|
||||||
|
.int()
|
||||||
|
.min(1)
|
||||||
|
.max(10_000)
|
||||||
|
.default(30),
|
||||||
|
AUTH_CHANGE_PASSWORD_IP_LIMIT: z.coerce
|
||||||
|
.number()
|
||||||
|
.int()
|
||||||
|
.min(1)
|
||||||
|
.max(1_000)
|
||||||
|
.default(10),
|
||||||
|
AUTH_CHANGE_PASSWORD_USER_LIMIT: z.coerce
|
||||||
|
.number()
|
||||||
|
.int()
|
||||||
|
.min(1)
|
||||||
|
.max(1_000)
|
||||||
|
.default(5),
|
||||||
|
TRUSTED_PROXY_CIDRS: z.string().default(''),
|
||||||
|
SWAGGER_ENABLED: z.enum(['true', 'false']).default('true'),
|
||||||
|
LOG_LEVEL: z.enum(['error', 'warn', 'log', 'debug']).default('log'),
|
||||||
|
});
|
||||||
|
|
||||||
|
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' | '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(
|
||||||
|
environment: Record<string, unknown>,
|
||||||
|
): Environment {
|
||||||
|
const result = environmentSchema.safeParse(environment);
|
||||||
|
if (result.success) return result.data;
|
||||||
|
// 在 Nest 开始监听前失败,避免半配置 API 对外提供不安全服务。
|
||||||
|
throw new Error(
|
||||||
|
`Invalid environment configuration: ${result.error.issues.map((issue) => issue.path.join('.')).join(', ')}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function createAppConfig(environment: Environment): AppConfig {
|
||||||
|
const objectStorageAccessKey =
|
||||||
|
environment.OBJECT_STORAGE_ACCESS_KEY ?? environment.MINIO_ROOT_USER;
|
||||||
|
const objectStorageSecretKey =
|
||||||
|
environment.OBJECT_STORAGE_SECRET_KEY ??
|
||||||
|
environment.MINIO_ROOT_PASSWORD;
|
||||||
|
if (!objectStorageAccessKey || !objectStorageSecretKey) {
|
||||||
|
throw new Error(
|
||||||
|
'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。
|
||||||
|
trustedProxyCidrs: environment.TRUSTED_PROXY_CIDRS.split(',')
|
||||||
|
.map((value) => value.trim())
|
||||||
|
.filter(Boolean),
|
||||||
|
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,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@Global()
|
||||||
|
@Module({
|
||||||
|
imports: [
|
||||||
|
ConfigModule.forRoot({
|
||||||
|
isGlobal: true,
|
||||||
|
cache: true,
|
||||||
|
// Turbo 从 apps/api 运行脚本,但共享开发环境文件位于仓库根目录。
|
||||||
|
envFilePath: findEnvironmentFile(),
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
providers: [
|
||||||
|
{
|
||||||
|
provide: APP_CONFIG,
|
||||||
|
// 导出规范化配置,业务服务不得散落读取 process.env。
|
||||||
|
useFactory: (): AppConfig =>
|
||||||
|
createAppConfig(validateEnvironment(process.env)),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
exports: [APP_CONFIG],
|
||||||
|
})
|
||||||
|
export class AppConfigModule {}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { Global, Inject, Module, OnModuleDestroy } from '@nestjs/common';
|
||||||
|
import { createApplicationDatabase, type Database } from '@atlas/database';
|
||||||
|
import { APP_CONFIG, type AppConfig } from '../config/app-config.module.js';
|
||||||
|
|
||||||
|
export const DATABASE = Symbol('DATABASE');
|
||||||
|
|
||||||
|
@Global()
|
||||||
|
@Module({
|
||||||
|
providers: [
|
||||||
|
{
|
||||||
|
provide: DATABASE,
|
||||||
|
inject: [APP_CONFIG],
|
||||||
|
// 业务代码只能拿到受限应用连接,迁移连接串绝不注入 API 容器。
|
||||||
|
useFactory: (config: AppConfig): Database =>
|
||||||
|
createApplicationDatabase(config.DATABASE_URL),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
exports: [DATABASE],
|
||||||
|
})
|
||||||
|
export class DatabaseModule implements OnModuleDestroy {
|
||||||
|
public constructor(@Inject(DATABASE) private readonly database: Database) {}
|
||||||
|
public async onModuleDestroy() {
|
||||||
|
// Nest 关闭时主动释放连接池,避免测试和热重启遗留连接。
|
||||||
|
await this.database.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import type { LoggerService } from '@nestjs/common';
|
||||||
|
|
||||||
|
type LogLevel = 'error' | 'warn' | 'log' | 'debug';
|
||||||
|
const severity: Record<LogLevel, number> = {
|
||||||
|
error: 0,
|
||||||
|
warn: 1,
|
||||||
|
log: 2,
|
||||||
|
debug: 3,
|
||||||
|
};
|
||||||
|
// 按字段名递归脱敏,日志中不得出现会话、密码、密钥或授权头原文。
|
||||||
|
const sensitiveKeys = /authorization|cookie|password|secret|token|api[_-]?key/i;
|
||||||
|
|
||||||
|
export class AppLogger implements LoggerService {
|
||||||
|
public constructor(private readonly minimumLevel: LogLevel) {}
|
||||||
|
|
||||||
|
public log(message: unknown, context?: unknown): void {
|
||||||
|
this.write('log', message, context);
|
||||||
|
}
|
||||||
|
|
||||||
|
public error(message: unknown, stack?: string, context?: unknown): void {
|
||||||
|
this.write('error', message, { stack, context });
|
||||||
|
}
|
||||||
|
|
||||||
|
public warn(message: unknown, context?: unknown): void {
|
||||||
|
this.write('warn', message, context);
|
||||||
|
}
|
||||||
|
|
||||||
|
public debug(message: unknown, context?: unknown): void {
|
||||||
|
this.write('debug', message, context);
|
||||||
|
}
|
||||||
|
|
||||||
|
public verbose(message: unknown, context?: unknown): void {
|
||||||
|
this.debug(message, context);
|
||||||
|
}
|
||||||
|
|
||||||
|
private write(level: LogLevel, message: unknown, context?: unknown): void {
|
||||||
|
if (severity[level] > severity[this.minimumLevel]) return;
|
||||||
|
const entry = {
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
level,
|
||||||
|
message: typeof message === 'string' ? message : sanitize(message),
|
||||||
|
context: sanitize(context),
|
||||||
|
};
|
||||||
|
// 每行独立 JSON,方便日志采集系统按字段索引 request_id、状态码等上下文。
|
||||||
|
const output = JSON.stringify(entry);
|
||||||
|
if (level === 'error') console.error(output);
|
||||||
|
else console.log(output);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitize(value: unknown): unknown {
|
||||||
|
if (Array.isArray(value)) return value.map(sanitize);
|
||||||
|
if (value && typeof value === 'object') {
|
||||||
|
return Object.fromEntries(
|
||||||
|
Object.entries(value).map(([key, child]) => [
|
||||||
|
key,
|
||||||
|
sensitiveKeys.test(key) ? '[REDACTED]' : sanitize(child),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
+150
-5
@@ -1,12 +1,157 @@
|
|||||||
import 'reflect-metadata';
|
import 'reflect-metadata';
|
||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
import { resolve } from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
import { NestFactory } from '@nestjs/core';
|
import { NestFactory } from '@nestjs/core';
|
||||||
|
import { NestExpressApplication } from '@nestjs/platform-express';
|
||||||
|
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
|
||||||
|
import type { NextFunction, Request, Response } from 'express';
|
||||||
|
import helmet from 'helmet';
|
||||||
|
|
||||||
import { AppModule } from './app.module.js';
|
import { AppModule } from './app.module.js';
|
||||||
|
import { ApiErrorFilter } from './common/api-error.filter.js';
|
||||||
|
import { APP_CONFIG, type AppConfig } from './config/app-config.module.js';
|
||||||
|
import { AppLogger } from './logging/app-logger.js';
|
||||||
|
|
||||||
async function bootstrap() {
|
type RequestWithId = Request & {
|
||||||
const app = await NestFactory.create(AppModule);
|
requestId?: string;
|
||||||
app.enableCors({ origin: process.env.WEB_ORIGIN ?? 'http://localhost:8100' });
|
};
|
||||||
await app.listen(Number(process.env.API_PORT ?? 8200));
|
|
||||||
|
export async function createApplication() {
|
||||||
|
const app = await NestFactory.create<NestExpressApplication>(AppModule, {
|
||||||
|
bodyParser: false,
|
||||||
|
// 自定义日志器安装前保留 Nest 启动日志,避免配置错误没有可见诊断。
|
||||||
|
logger: ['error', 'warn', 'log'],
|
||||||
|
});
|
||||||
|
const config = app.get<AppConfig>(APP_CONFIG);
|
||||||
|
const logger = new AppLogger(config.LOG_LEVEL);
|
||||||
|
app.useLogger(logger);
|
||||||
|
|
||||||
|
if (config.trustedProxyCidrs.length > 0) {
|
||||||
|
// 仅在部署明确声明代理网段时信任转发头,避免伪造客户端 IP。
|
||||||
|
app.getHttpAdapter()
|
||||||
|
.getInstance()
|
||||||
|
.set('trust proxy', config.trustedProxyCidrs);
|
||||||
|
}
|
||||||
|
|
||||||
|
app.enableCors({
|
||||||
|
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: [
|
||||||
|
'Authorization',
|
||||||
|
'Content-Type',
|
||||||
|
'Idempotency-Key',
|
||||||
|
'If-Match',
|
||||||
|
'X-Atlas-Platform-Id',
|
||||||
|
'X-Request-Id',
|
||||||
|
],
|
||||||
|
exposedHeaders: ['X-Request-Id'],
|
||||||
|
});
|
||||||
|
// 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,
|
||||||
|
extended: true,
|
||||||
|
});
|
||||||
|
app.use(
|
||||||
|
(request: RequestWithId, response: Response, next: NextFunction) => {
|
||||||
|
const typedRequest = request;
|
||||||
|
const typedResponse = response;
|
||||||
|
const suppliedId = typedRequest.headers['x-request-id'];
|
||||||
|
// 合法调用方提供的 request ID 可贯穿上游链路;其他情况由服务端生成。
|
||||||
|
const requestId =
|
||||||
|
typeof suppliedId === 'string' &&
|
||||||
|
/^[A-Za-z0-9_-]{8,128}$/.test(suppliedId)
|
||||||
|
? suppliedId
|
||||||
|
: `req_${randomUUID()}`;
|
||||||
|
const startedAt = performance.now();
|
||||||
|
typedRequest.requestId = requestId;
|
||||||
|
typedResponse.setHeader('X-Request-Id', requestId);
|
||||||
|
typedResponse.on('finish', () => {
|
||||||
|
logger.log('http_request_completed', {
|
||||||
|
request_id: requestId,
|
||||||
|
method: typedRequest.method,
|
||||||
|
path: typedRequest.originalUrl,
|
||||||
|
status_code: typedResponse.statusCode,
|
||||||
|
duration_ms: Math.round(performance.now() - startedAt),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
next();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
app.useGlobalFilters(new ApiErrorFilter(logger));
|
||||||
|
app.setGlobalPrefix('api', { exclude: ['health', 'ready'] });
|
||||||
|
app.enableShutdownHooks();
|
||||||
|
|
||||||
|
if (config.swaggerEnabled) {
|
||||||
|
// Swagger 仅描述运行时已注册路由;冻结接口契约仍以 docs/api 为准。
|
||||||
|
const swaggerConfig = new DocumentBuilder()
|
||||||
|
.setTitle('Atlas Seller Kit API')
|
||||||
|
.setDescription('Atlas 卖家工作台 HTTP API 文档')
|
||||||
|
.setVersion('0.1.0')
|
||||||
|
.addBearerAuth(
|
||||||
|
{ type: 'http', scheme: 'bearer', bearerFormat: 'JWT' },
|
||||||
|
'access-token',
|
||||||
|
)
|
||||||
|
.build();
|
||||||
|
const swaggerDocument = SwaggerModule.createDocument(
|
||||||
|
app,
|
||||||
|
swaggerConfig,
|
||||||
|
);
|
||||||
|
SwaggerModule.setup('api-docs', app, swaggerDocument, {
|
||||||
|
swaggerOptions: { persistAuthorization: true },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return { app, config, logger };
|
||||||
}
|
}
|
||||||
|
|
||||||
void bootstrap();
|
export async function bootstrap() {
|
||||||
|
const { app, config, logger } = await createApplication();
|
||||||
|
await app.listen(config.API_PORT);
|
||||||
|
logger.log('api_started', {
|
||||||
|
port: config.API_PORT,
|
||||||
|
swagger_enabled: config.swaggerEnabled,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 测试复用应用工厂时不自动监听固定端口,只有直接运行入口才启动服务。
|
||||||
|
if (
|
||||||
|
process.argv[1] &&
|
||||||
|
resolve(process.argv[1]) === fileURLToPath(import.meta.url)
|
||||||
|
) {
|
||||||
|
void bootstrap().catch((error: unknown) => {
|
||||||
|
const details =
|
||||||
|
error instanceof Error
|
||||||
|
? {
|
||||||
|
name: error.name,
|
||||||
|
message: error.message,
|
||||||
|
stack: error.stack,
|
||||||
|
}
|
||||||
|
: error;
|
||||||
|
console.error(
|
||||||
|
JSON.stringify({
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
level: 'error',
|
||||||
|
message: 'api_startup_failed',
|
||||||
|
error: details,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
process.exitCode = 1;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,139 @@
|
|||||||
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
|
import type { AspectRatioDerivative, CreateAspectRatioDerivativeRequest } from '@atlas/contracts';
|
||||||
|
import type { Database } from '@atlas/database';
|
||||||
|
import { ApiError } from '../common/api-error.js';
|
||||||
|
import { IdempotencyService } from '../common/idempotency.service.js';
|
||||||
|
import { DATABASE } from '../database/database.module.js';
|
||||||
|
import { MediaService } from './media.service.js';
|
||||||
|
|
||||||
|
type DerivativeRow = {
|
||||||
|
id: string;
|
||||||
|
status: AspectRatioDerivative['status'];
|
||||||
|
source_asset_id: string;
|
||||||
|
output_asset_id: string | null;
|
||||||
|
error_code: string | null;
|
||||||
|
content_type: string | null;
|
||||||
|
byte_size: string | null;
|
||||||
|
width_px: number | null;
|
||||||
|
height_px: number | null;
|
||||||
|
asset_created_at: Date | string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class AspectRatioDerivativeService {
|
||||||
|
public constructor(
|
||||||
|
@Inject(DATABASE) private readonly database: Database,
|
||||||
|
@Inject(IdempotencyService) private readonly idempotency: IdempotencyService,
|
||||||
|
@Inject(MediaService) private readonly media: MediaService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/** 任务、用户可见进度和 Outbox 必须在同一工作空间事务中创建。 */
|
||||||
|
public async create(
|
||||||
|
workspaceId: string,
|
||||||
|
userId: string,
|
||||||
|
assetId: string,
|
||||||
|
key: string,
|
||||||
|
input: CreateAspectRatioDerivativeRequest,
|
||||||
|
) {
|
||||||
|
return this.idempotency.run(
|
||||||
|
{
|
||||||
|
workspaceId,
|
||||||
|
userId,
|
||||||
|
method: 'POST',
|
||||||
|
path: `/api/media-assets/${assetId}/aspect-ratio-derivatives`,
|
||||||
|
key,
|
||||||
|
request: input,
|
||||||
|
},
|
||||||
|
async (tx) => {
|
||||||
|
const source = await tx.query<{ id: string }>(
|
||||||
|
`SELECT id FROM atlas.media_assets
|
||||||
|
WHERE id = $1 AND deleted_at IS NULL AND content_type IN ('image/jpeg', 'image/png', 'image/webp')
|
||||||
|
AND byte_size <= 20971520 AND width_px BETWEEN 1 AND 10000
|
||||||
|
AND height_px BETWEEN 1 AND 10000`,
|
||||||
|
[assetId],
|
||||||
|
);
|
||||||
|
if (!source.rows[0])
|
||||||
|
throw new ApiError(404, 'RESOURCE_NOT_FOUND', '图片素材不存在或不可处理');
|
||||||
|
if (input.product_id) {
|
||||||
|
const product = await tx.query<{ id: string }>(
|
||||||
|
'SELECT id FROM atlas.products WHERE id = $1',
|
||||||
|
[input.product_id],
|
||||||
|
);
|
||||||
|
if (!product.rows[0])
|
||||||
|
throw new ApiError(404, 'RESOURCE_NOT_FOUND', '商品不存在');
|
||||||
|
}
|
||||||
|
const created = await tx.query<{ id: string }>(
|
||||||
|
`INSERT INTO atlas.media_aspect_ratio_derivatives
|
||||||
|
(workspace_id, product_id, source_asset_id, created_by_user_id)
|
||||||
|
VALUES (current_setting('app.workspace_id')::uuid, $1, $2, $3)
|
||||||
|
RETURNING id`,
|
||||||
|
[input.product_id ?? null, assetId, userId],
|
||||||
|
);
|
||||||
|
const id = created.rows[0].id;
|
||||||
|
await tx.query(
|
||||||
|
`INSERT INTO atlas.operations
|
||||||
|
(id, workspace_id, kind, resource_type, resource_id, created_by_user_id)
|
||||||
|
VALUES ($1, current_setting('app.workspace_id')::uuid,
|
||||||
|
'media_aspect_ratio_derivative', 'media_derivative', $1, $2)`,
|
||||||
|
[id, userId],
|
||||||
|
);
|
||||||
|
await tx.query(
|
||||||
|
`INSERT INTO atlas.outbox_events
|
||||||
|
(workspace_id, aggregate_type, aggregate_id, event_type, event_payload, deduplication_key)
|
||||||
|
VALUES (current_setting('app.workspace_id')::uuid, 'media_derivative', $1,
|
||||||
|
'media.aspect_ratio_derivative.requested',
|
||||||
|
jsonb_build_object('derivative_id', $1::uuid), $1::text)`,
|
||||||
|
[id],
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
status: 202,
|
||||||
|
body: { data: {
|
||||||
|
id,
|
||||||
|
status: 'queued',
|
||||||
|
source_asset_id: assetId,
|
||||||
|
output_asset: null,
|
||||||
|
error_code: null,
|
||||||
|
} satisfies AspectRatioDerivative },
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 短时预览地址仅在读取成功结果时签发,不写入任务或草稿。 */
|
||||||
|
public async read(workspaceId: string, derivativeId: string): Promise<AspectRatioDerivative> {
|
||||||
|
const row = await this.database.workspaceTransaction(workspaceId, async (tx) => {
|
||||||
|
const result = await tx.query<DerivativeRow>(
|
||||||
|
`SELECT d.id, d.status, d.source_asset_id, d.output_asset_id, d.error_code,
|
||||||
|
a.content_type, a.byte_size::text, a.width_px, a.height_px,
|
||||||
|
a.created_at AS asset_created_at
|
||||||
|
FROM atlas.media_aspect_ratio_derivatives d
|
||||||
|
LEFT JOIN atlas.media_assets a ON a.id = d.output_asset_id AND a.deleted_at IS NULL
|
||||||
|
WHERE d.id = $1`,
|
||||||
|
[derivativeId],
|
||||||
|
);
|
||||||
|
return result.rows[0] ?? null;
|
||||||
|
});
|
||||||
|
if (!row) throw new ApiError(404, 'RESOURCE_NOT_FOUND', '图片处理任务不存在');
|
||||||
|
const access = row.output_asset_id && row.content_type
|
||||||
|
? await this.media.accessUrl(workspaceId, row.output_asset_id)
|
||||||
|
: null;
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
status: row.status,
|
||||||
|
source_asset_id: row.source_asset_id,
|
||||||
|
error_code: row.error_code,
|
||||||
|
output_asset: row.output_asset_id && access ? {
|
||||||
|
id: row.output_asset_id,
|
||||||
|
content_type: row.content_type!,
|
||||||
|
byte_size: Number(row.byte_size),
|
||||||
|
width_px: row.width_px,
|
||||||
|
height_px: row.height_px,
|
||||||
|
created_at: row.asset_created_at instanceof Date
|
||||||
|
? row.asset_created_at.toISOString()
|
||||||
|
: String(row.asset_created_at),
|
||||||
|
preview_url: access.url,
|
||||||
|
preview_url_expires_at: access.expires_at,
|
||||||
|
} : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,441 @@
|
|||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
|
import {
|
||||||
|
createImageSuiteSchema,
|
||||||
|
imageSuitePlanErrorMessages,
|
||||||
|
createMediaAiEditRequestSchema,
|
||||||
|
type CreateImageSuiteRequest,
|
||||||
|
type ImageSuite,
|
||||||
|
type ImageSuiteInput,
|
||||||
|
type ImageSuitePlanInput,
|
||||||
|
type ImageSuiteShot,
|
||||||
|
type ListProductImageTasksQuery,
|
||||||
|
} from '@atlas/contracts';
|
||||||
|
import type { Database, DatabaseTransaction } from '@atlas/database';
|
||||||
|
import { ApiError } from '../common/api-error.js';
|
||||||
|
import { IdempotencyService } from '../common/idempotency.service.js';
|
||||||
|
import { DATABASE } from '../database/database.module.js';
|
||||||
|
import { MediaAiEditService } from './media-ai-edit.service.js';
|
||||||
|
|
||||||
|
type SuiteRow = Omit<
|
||||||
|
ImageSuite,
|
||||||
|
'items' | 'operation' | 'created_at' | 'completed_at'
|
||||||
|
> & { created_at: Date | string; completed_at: Date | string | null };
|
||||||
|
const iso = (value: Date | string) =>
|
||||||
|
value instanceof Date ? value.toISOString() : value;
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ImageSuiteService {
|
||||||
|
public constructor(
|
||||||
|
@Inject(DATABASE) private readonly database: Database,
|
||||||
|
@Inject(IdempotencyService)
|
||||||
|
private readonly idempotency: IdempotencyService,
|
||||||
|
@Inject(MediaAiEditService) private readonly edits: MediaAiEditService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public async create(
|
||||||
|
workspaceId: string,
|
||||||
|
userId: string,
|
||||||
|
productId: string,
|
||||||
|
key: string,
|
||||||
|
request:
|
||||||
|
ImageSuiteInput | ImageSuitePlanInput | CreateImageSuiteRequest,
|
||||||
|
purpose: 'plan' | 'generate',
|
||||||
|
) {
|
||||||
|
const path = `/api/products/${productId}/ai/${purpose === 'plan' ? 'suite-plans' : 'image-suites'}`;
|
||||||
|
return this.idempotency.run(
|
||||||
|
{ workspaceId, userId, method: 'POST', path, key, request },
|
||||||
|
async (tx) => {
|
||||||
|
const variant = await tx.query<{ id: string }>(
|
||||||
|
'SELECT id FROM atlas.product_variants WHERE id = $1 AND product_id = $2 AND deleted_at IS NULL',
|
||||||
|
[request.variant_id, productId],
|
||||||
|
);
|
||||||
|
if (!variant.rows[0])
|
||||||
|
throw new ApiError(
|
||||||
|
404,
|
||||||
|
'RESOURCE_NOT_FOUND',
|
||||||
|
'目标 SKU 不存在或不属于此商品,请先保存新 SKU',
|
||||||
|
);
|
||||||
|
const assets = await tx.query<{ id: string }>(
|
||||||
|
`SELECT id FROM atlas.media_assets WHERE id = ANY($1::uuid[]) AND deleted_at IS NULL
|
||||||
|
AND content_type IN ('image/jpeg', 'image/png', 'image/webp')
|
||||||
|
AND byte_size <= 20971520 AND width_px BETWEEN 1 AND 10000 AND height_px BETWEEN 1 AND 10000`,
|
||||||
|
[request.reference_asset_ids],
|
||||||
|
);
|
||||||
|
if (assets.rows.length !== request.reference_asset_ids.length)
|
||||||
|
throw new ApiError(
|
||||||
|
404,
|
||||||
|
'RESOURCE_NOT_FOUND',
|
||||||
|
'参考素材不存在或不可处理',
|
||||||
|
);
|
||||||
|
const shots =
|
||||||
|
purpose === 'generate'
|
||||||
|
? createImageSuiteSchema.parse(request).shots
|
||||||
|
: [];
|
||||||
|
const { shots: _shots, ...input } =
|
||||||
|
request as CreateImageSuiteRequest;
|
||||||
|
const created = await tx.query<SuiteRow>(
|
||||||
|
`INSERT INTO atlas.product_image_suites (workspace_id, product_id, variant_id, purpose, input, shots, created_by_user_id)
|
||||||
|
VALUES (current_setting('app.workspace_id')::uuid, $1, $2, $3, $4::jsonb, $5::jsonb, $6) RETURNING *`,
|
||||||
|
[
|
||||||
|
productId,
|
||||||
|
input.variant_id,
|
||||||
|
purpose,
|
||||||
|
JSON.stringify(input),
|
||||||
|
JSON.stringify(shots),
|
||||||
|
userId,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
const row = created.rows[0];
|
||||||
|
for (const [
|
||||||
|
position,
|
||||||
|
assetId,
|
||||||
|
] of input.reference_asset_ids.entries())
|
||||||
|
await tx.query(
|
||||||
|
`INSERT INTO atlas.product_image_suite_references (workspace_id, suite_id, asset_id, position)
|
||||||
|
VALUES (current_setting('app.workspace_id')::uuid, $1, $2, $3)`,
|
||||||
|
[row.id, assetId, position],
|
||||||
|
);
|
||||||
|
await this.operation(
|
||||||
|
tx,
|
||||||
|
row.id,
|
||||||
|
purpose === 'plan' ? 'image_suite_plan' : 'image_suite',
|
||||||
|
userId,
|
||||||
|
);
|
||||||
|
if (purpose === 'plan')
|
||||||
|
await this.outbox(
|
||||||
|
tx,
|
||||||
|
'image_suite.plan.requested',
|
||||||
|
row.id,
|
||||||
|
{ suite_id: row.id },
|
||||||
|
);
|
||||||
|
else
|
||||||
|
for (const [index, shot] of shots.entries()) {
|
||||||
|
const supporting = input.reference_asset_ids.slice(1);
|
||||||
|
// 默认方案轮换全部辅助底图,避免后面的参考角度始终未被使用。
|
||||||
|
const selected = shot.reference_asset_ids ?? [
|
||||||
|
input.reference_asset_ids[0],
|
||||||
|
...Array.from(
|
||||||
|
{ length: Math.min(2, supporting.length) },
|
||||||
|
(_, offset) =>
|
||||||
|
supporting[
|
||||||
|
(index + offset) % supporting.length
|
||||||
|
],
|
||||||
|
),
|
||||||
|
];
|
||||||
|
const settings = createMediaAiEditRequestSchema.parse({
|
||||||
|
product_id: productId,
|
||||||
|
reference_asset_ids: selected.slice(1),
|
||||||
|
mode: 'edit',
|
||||||
|
ratio: input.ratio,
|
||||||
|
target_language: input.language,
|
||||||
|
background:
|
||||||
|
shot.category === 'white' ? 'white' : 'auto',
|
||||||
|
style_set: input.style_set,
|
||||||
|
style_prompt: input.style_prompt,
|
||||||
|
prompt: `${shot.theme}\n${shot.description}\n资料依据:${shot.evidence}`,
|
||||||
|
suite_context: {
|
||||||
|
type: shot.type,
|
||||||
|
product_name: input.product_name,
|
||||||
|
color: input.color,
|
||||||
|
dimensions: input.dimensions,
|
||||||
|
weight: input.weight,
|
||||||
|
collected_info: input.collected_info,
|
||||||
|
selling_points: input.selling_points,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const id = randomUUID();
|
||||||
|
await tx.query(
|
||||||
|
`INSERT INTO atlas.media_ai_edits (id, workspace_id, product_id, suite_id, suite_shot_id, suite_position,
|
||||||
|
source_asset_id, reference_asset_ids, settings, created_by_user_id)
|
||||||
|
VALUES ($1, current_setting('app.workspace_id')::uuid, $2, $3, $4, $5, $6, $7::uuid[], $8::jsonb, $9)`,
|
||||||
|
[
|
||||||
|
id,
|
||||||
|
productId,
|
||||||
|
row.id,
|
||||||
|
shot.id,
|
||||||
|
index,
|
||||||
|
selected[0],
|
||||||
|
selected.slice(1),
|
||||||
|
JSON.stringify(settings),
|
||||||
|
userId,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
await this.operation(tx, id, 'media_ai_edit', userId);
|
||||||
|
await this.outbox(tx, 'media.ai_edit.requested', id, {
|
||||||
|
edit_id: id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return { status: 202, body: { data: this.resource(row) } };
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async read(
|
||||||
|
workspaceId: string,
|
||||||
|
productId: string,
|
||||||
|
id: string,
|
||||||
|
): Promise<ImageSuite> {
|
||||||
|
const { row, children } = await this.database.workspaceTransaction(
|
||||||
|
workspaceId,
|
||||||
|
async (tx) => {
|
||||||
|
const row = await this.find(tx, productId, id);
|
||||||
|
const children = await tx.query<{
|
||||||
|
id: string;
|
||||||
|
suite_shot_id: string;
|
||||||
|
}>(
|
||||||
|
'SELECT id, suite_shot_id FROM atlas.media_ai_edits WHERE suite_id = $1 ORDER BY suite_position',
|
||||||
|
[id],
|
||||||
|
);
|
||||||
|
return { row, children: children.rows };
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
...this.resource(row),
|
||||||
|
items: await Promise.all(
|
||||||
|
children.map(async (child) => ({
|
||||||
|
...(await this.edits.read(workspaceId, child.id)),
|
||||||
|
shot_id: child.suite_shot_id,
|
||||||
|
edit_id: child.id,
|
||||||
|
})),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public async list(
|
||||||
|
workspaceId: string,
|
||||||
|
productId: string,
|
||||||
|
query: ListProductImageTasksQuery,
|
||||||
|
) {
|
||||||
|
let cursor: { at: string; id: string } | null = null;
|
||||||
|
if (query.cursor) {
|
||||||
|
try {
|
||||||
|
cursor = JSON.parse(
|
||||||
|
Buffer.from(query.cursor, 'base64url').toString(),
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
!cursor ||
|
||||||
|
typeof cursor.at !== 'string' ||
|
||||||
|
typeof cursor.id !== 'string' ||
|
||||||
|
!Number.isFinite(Date.parse(cursor.at)) ||
|
||||||
|
!/^[0-9a-f-]{36}$/i.test(cursor.id)
|
||||||
|
)
|
||||||
|
throw new Error();
|
||||||
|
} catch {
|
||||||
|
throw new ApiError(422, 'VALIDATION_FAILED', '分页游标无效');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const rows = await this.database.workspaceTransaction(
|
||||||
|
workspaceId,
|
||||||
|
async (tx) => {
|
||||||
|
const found = await tx.query<{ id: string }>(
|
||||||
|
'SELECT id FROM atlas.products WHERE id = $1',
|
||||||
|
[productId],
|
||||||
|
);
|
||||||
|
if (!found.rows[0])
|
||||||
|
throw new ApiError(404, 'RESOURCE_NOT_FOUND', '商品不存在');
|
||||||
|
return (
|
||||||
|
await tx.query<SuiteRow>(
|
||||||
|
`SELECT * FROM atlas.product_image_suites WHERE product_id = $1
|
||||||
|
AND ($2::timestamptz IS NULL OR (created_at, id) < ($2::timestamptz, $3::uuid))
|
||||||
|
ORDER BY created_at DESC, id DESC LIMIT $4`,
|
||||||
|
[
|
||||||
|
productId,
|
||||||
|
cursor?.at ?? null,
|
||||||
|
cursor?.id ?? null,
|
||||||
|
query.limit + 1,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
).rows;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const page = rows.slice(0, query.limit),
|
||||||
|
last = page.at(-1),
|
||||||
|
hasMore = rows.length > query.limit;
|
||||||
|
return {
|
||||||
|
data: page.map((row) => this.resource(row)),
|
||||||
|
meta: {
|
||||||
|
has_more: hasMore,
|
||||||
|
next_cursor:
|
||||||
|
hasMore && last
|
||||||
|
? Buffer.from(
|
||||||
|
JSON.stringify({
|
||||||
|
at: iso(last.created_at),
|
||||||
|
id: last.id,
|
||||||
|
}),
|
||||||
|
).toString('base64url')
|
||||||
|
: null,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public async action(
|
||||||
|
workspaceId: string,
|
||||||
|
userId: string,
|
||||||
|
productId: string,
|
||||||
|
id: string,
|
||||||
|
key: string,
|
||||||
|
action: 'cancel' | 'retry',
|
||||||
|
) {
|
||||||
|
return this.idempotency.run(
|
||||||
|
{
|
||||||
|
workspaceId,
|
||||||
|
userId,
|
||||||
|
method: 'POST',
|
||||||
|
path: `/api/products/${productId}/ai/image-suites/${id}/${action}`,
|
||||||
|
key,
|
||||||
|
request: {},
|
||||||
|
},
|
||||||
|
async (tx) => {
|
||||||
|
// 与 Worker 一致先锁子任务再锁父任务,避免取消与结果回写形成相反锁序。
|
||||||
|
const children = await tx.query<{ id: string; status: string }>(
|
||||||
|
'SELECT id, status FROM atlas.media_ai_edits WHERE suite_id = $1 ORDER BY id FOR UPDATE',
|
||||||
|
[id],
|
||||||
|
);
|
||||||
|
const row = await this.find(tx, productId, id, true);
|
||||||
|
if (action === 'cancel') {
|
||||||
|
if (['queued', 'running'].includes(row.status)) {
|
||||||
|
await tx.query(
|
||||||
|
"UPDATE atlas.product_image_suites SET status = 'cancelled', completed_at = now() WHERE id = $1",
|
||||||
|
[id],
|
||||||
|
);
|
||||||
|
await tx.query(
|
||||||
|
"UPDATE atlas.media_ai_edits SET status = 'cancelled', completed_at = now() WHERE suite_id = $1 AND status IN ('queued', 'running')",
|
||||||
|
[id],
|
||||||
|
);
|
||||||
|
await tx.query(
|
||||||
|
"UPDATE atlas.operations SET status = 'cancelled', completed_at = now() WHERE (id = $1 OR id = ANY($2::uuid[])) AND status IN ('queued', 'running')",
|
||||||
|
[id, children.rows.map((child) => child.id)],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (
|
||||||
|
row.purpose !== 'generate' ||
|
||||||
|
['queued', 'running'].includes(row.status)
|
||||||
|
)
|
||||||
|
throw new ApiError(
|
||||||
|
409,
|
||||||
|
'INVALID_TASK_STATE',
|
||||||
|
'当前任务不能重试,请等待生成完成',
|
||||||
|
);
|
||||||
|
const failed = children.rows.filter((child) =>
|
||||||
|
['failed', 'cancelled'].includes(child.status),
|
||||||
|
);
|
||||||
|
if (failed.length) {
|
||||||
|
await tx.query(
|
||||||
|
"UPDATE atlas.product_image_suites SET status = 'queued', error_code = NULL, completed_at = NULL WHERE id = $1",
|
||||||
|
[id],
|
||||||
|
);
|
||||||
|
await tx.query(
|
||||||
|
"UPDATE atlas.operations SET status = 'queued', error_code = NULL, error_message = NULL, completed_at = NULL WHERE id = $1",
|
||||||
|
[id],
|
||||||
|
);
|
||||||
|
for (const child of failed) {
|
||||||
|
const attempt = await tx.query<{
|
||||||
|
generation_attempt: number;
|
||||||
|
}>(
|
||||||
|
"UPDATE atlas.media_ai_edits SET status = 'queued', provider_task_id = NULL, error_code = NULL, completed_at = NULL, started_at = NULL, created_at = now(), generation_attempt = generation_attempt + 1 WHERE id = $1 RETURNING generation_attempt",
|
||||||
|
[child.id],
|
||||||
|
);
|
||||||
|
await tx.query(
|
||||||
|
"UPDATE atlas.operations SET status = 'queued', result = '{}'::jsonb, error_code = NULL, error_message = NULL, completed_at = NULL WHERE id = $1",
|
||||||
|
[child.id],
|
||||||
|
);
|
||||||
|
await this.outbox(
|
||||||
|
tx,
|
||||||
|
'media.ai_edit.requested',
|
||||||
|
child.id,
|
||||||
|
{
|
||||||
|
edit_id: child.id,
|
||||||
|
attempt: attempt.rows[0].generation_attempt,
|
||||||
|
},
|
||||||
|
randomUUID(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
status: 200,
|
||||||
|
body: {
|
||||||
|
data: this.resource(await this.find(tx, productId, id)),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async find(
|
||||||
|
tx: DatabaseTransaction,
|
||||||
|
productId: string,
|
||||||
|
id: string,
|
||||||
|
lock = false,
|
||||||
|
) {
|
||||||
|
const result = await tx.query<SuiteRow>(
|
||||||
|
`SELECT * FROM atlas.product_image_suites WHERE id = $1 AND product_id = $2 ${lock ? 'FOR UPDATE' : ''}`,
|
||||||
|
[id, productId],
|
||||||
|
);
|
||||||
|
if (!result.rows[0])
|
||||||
|
throw new ApiError(404, 'RESOURCE_NOT_FOUND', '套图任务不存在');
|
||||||
|
return result.rows[0];
|
||||||
|
}
|
||||||
|
private resource(row: SuiteRow): ImageSuite {
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
product_id: row.product_id,
|
||||||
|
variant_id: row.variant_id,
|
||||||
|
purpose: row.purpose,
|
||||||
|
status: row.status,
|
||||||
|
input: row.input,
|
||||||
|
shots: row.shots as ImageSuiteShot[],
|
||||||
|
items: [],
|
||||||
|
error_code: row.error_code,
|
||||||
|
created_at: iso(row.created_at),
|
||||||
|
completed_at: row.completed_at ? iso(row.completed_at) : null,
|
||||||
|
operation: {
|
||||||
|
id: row.id,
|
||||||
|
kind:
|
||||||
|
row.purpose === 'plan' ? 'image_suite_plan' : 'image_suite',
|
||||||
|
status: row.status === 'partial' ? 'failed' : row.status,
|
||||||
|
created_at: iso(row.created_at),
|
||||||
|
completed_at: row.completed_at ? iso(row.completed_at) : null,
|
||||||
|
error: row.error_code
|
||||||
|
? {
|
||||||
|
code: row.error_code,
|
||||||
|
message:
|
||||||
|
row.purpose === 'plan'
|
||||||
|
? (imageSuitePlanErrorMessages[
|
||||||
|
row.error_code
|
||||||
|
] ??
|
||||||
|
imageSuitePlanErrorMessages.AI_SUITE_PLAN_FAILED)
|
||||||
|
: '部分图片未完成,可重试失败项',
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
private async operation(
|
||||||
|
tx: DatabaseTransaction,
|
||||||
|
id: string,
|
||||||
|
kind: string,
|
||||||
|
userId: string,
|
||||||
|
) {
|
||||||
|
await tx.query(
|
||||||
|
`INSERT INTO atlas.operations (id, workspace_id, kind, resource_type, resource_id, created_by_user_id)
|
||||||
|
VALUES ($1, current_setting('app.workspace_id')::uuid, $2, $2, $1, $3)`,
|
||||||
|
[id, kind, userId],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
private async outbox(
|
||||||
|
tx: DatabaseTransaction,
|
||||||
|
event: string,
|
||||||
|
id: string,
|
||||||
|
payload: Record<string, string | number>,
|
||||||
|
dedup = id,
|
||||||
|
) {
|
||||||
|
await tx.query(
|
||||||
|
`INSERT INTO atlas.outbox_events (workspace_id, aggregate_type, aggregate_id, event_type, event_payload, deduplication_key)
|
||||||
|
VALUES (current_setting('app.workspace_id')::uuid, 'image_suite', $1, $2, $3::jsonb, $4)`,
|
||||||
|
[id, event, JSON.stringify(payload), dedup],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
|
import type { MediaAiEdit, MediaAiEditSettings } from '@atlas/contracts';
|
||||||
|
import type { Database } from '@atlas/database';
|
||||||
|
import { ApiError } from '../common/api-error.js';
|
||||||
|
import { IdempotencyService } from '../common/idempotency.service.js';
|
||||||
|
import { DATABASE } from '../database/database.module.js';
|
||||||
|
import { MediaService } from './media.service.js';
|
||||||
|
|
||||||
|
type EditRow = {
|
||||||
|
id: string;
|
||||||
|
status: MediaAiEdit['status'];
|
||||||
|
source_asset_id: string;
|
||||||
|
output_asset_id: string | null;
|
||||||
|
error_code: string | null;
|
||||||
|
failure_source?: MediaAiEdit['failure_source'];
|
||||||
|
failure_stage?: MediaAiEdit['failure_stage'];
|
||||||
|
failure_detail?: string | null;
|
||||||
|
content_type: string | null;
|
||||||
|
byte_size: string | null;
|
||||||
|
width_px: number | null;
|
||||||
|
height_px: number | null;
|
||||||
|
asset_created_at: Date | string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class MediaAiEditService {
|
||||||
|
public constructor(
|
||||||
|
@Inject(DATABASE) private readonly database: Database,
|
||||||
|
@Inject(IdempotencyService) private readonly idempotency: IdempotencyService,
|
||||||
|
@Inject(MediaService) private readonly media: MediaService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/** 创建任务前确认主图和辅图均为本工作空间已校验的正式素材。 */
|
||||||
|
public async create(workspaceId: string, userId: string, sourceId: string, key: string, settings: MediaAiEditSettings) {
|
||||||
|
return this.idempotency.run({
|
||||||
|
workspaceId, userId, method: 'POST', path: `/api/media-assets/${sourceId}/ai-edits`,
|
||||||
|
key, request: settings,
|
||||||
|
}, async (tx) => {
|
||||||
|
const allIds = [sourceId, ...settings.reference_asset_ids];
|
||||||
|
const product = await tx.query<{ id: string }>(
|
||||||
|
'SELECT id FROM atlas.products WHERE id = $1', [settings.product_id],
|
||||||
|
);
|
||||||
|
if (!product.rows[0]) throw new ApiError(404, 'RESOURCE_NOT_FOUND', '商品不存在');
|
||||||
|
if (new Set(allIds).size !== allIds.length)
|
||||||
|
throw new ApiError(422, 'INVALID_REFERENCE_IMAGE', '主图和辅助图不能重复');
|
||||||
|
const assets = await tx.query<{ id: string }>(
|
||||||
|
`SELECT id FROM atlas.media_assets WHERE id = ANY($1::uuid[]) AND deleted_at IS NULL
|
||||||
|
AND content_type IN ('image/jpeg', 'image/png', 'image/webp')
|
||||||
|
AND byte_size <= 20971520 AND width_px BETWEEN 1 AND 10000
|
||||||
|
AND height_px BETWEEN 1 AND 10000`, [allIds],
|
||||||
|
);
|
||||||
|
if (assets.rows.length !== allIds.length)
|
||||||
|
throw new ApiError(404, 'RESOURCE_NOT_FOUND', '图片素材不存在或不可处理');
|
||||||
|
const created = await tx.query<{ id: string }>(
|
||||||
|
`INSERT INTO atlas.media_ai_edits
|
||||||
|
(workspace_id, product_id, source_asset_id, reference_asset_ids, settings, created_by_user_id)
|
||||||
|
VALUES (current_setting('app.workspace_id')::uuid, $1, $2, $3::uuid[], $4::jsonb, $5)
|
||||||
|
RETURNING id`,
|
||||||
|
[settings.product_id, sourceId, settings.reference_asset_ids, JSON.stringify(settings), userId],
|
||||||
|
);
|
||||||
|
const id = created.rows[0].id;
|
||||||
|
await tx.query(
|
||||||
|
`INSERT INTO atlas.operations (id, workspace_id, kind, resource_type, resource_id, created_by_user_id)
|
||||||
|
VALUES ($1, current_setting('app.workspace_id')::uuid, 'media_ai_edit', 'media_ai_edit', $1, $2)`,
|
||||||
|
[id, userId],
|
||||||
|
);
|
||||||
|
await tx.query(
|
||||||
|
`INSERT INTO atlas.outbox_events
|
||||||
|
(workspace_id, aggregate_type, aggregate_id, event_type, event_payload, deduplication_key)
|
||||||
|
VALUES (current_setting('app.workspace_id')::uuid, 'media_ai_edit', $1,
|
||||||
|
'media.ai_edit.requested', jsonb_build_object('edit_id', $1::uuid), $1::text)`, [id],
|
||||||
|
);
|
||||||
|
return { status: 202, body: { data: {
|
||||||
|
id, status: 'queued', source_asset_id: sourceId, output_asset: null, error_code: null,
|
||||||
|
failure_source: null, failure_stage: null, failure_detail: null,
|
||||||
|
} satisfies MediaAiEdit } };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public async read(workspaceId: string, id: string): Promise<MediaAiEdit> {
|
||||||
|
const row = await this.database.workspaceTransaction(workspaceId, async (tx) => {
|
||||||
|
const result = await tx.query<EditRow>(
|
||||||
|
`SELECT e.id, e.status, e.source_asset_id, e.output_asset_id, e.error_code,
|
||||||
|
o.result ->> 'failure_source' AS failure_source,
|
||||||
|
o.result ->> 'failure_stage' AS failure_stage,
|
||||||
|
o.result ->> 'failure_detail' AS failure_detail,
|
||||||
|
a.content_type, a.byte_size::text, a.width_px, a.height_px,
|
||||||
|
a.created_at AS asset_created_at
|
||||||
|
FROM atlas.media_ai_edits e
|
||||||
|
LEFT JOIN atlas.operations o ON o.id = e.id AND o.workspace_id = e.workspace_id
|
||||||
|
LEFT JOIN atlas.media_assets a ON a.id = e.output_asset_id AND a.deleted_at IS NULL
|
||||||
|
WHERE e.id = $1`, [id],
|
||||||
|
);
|
||||||
|
return result.rows[0] ?? null;
|
||||||
|
});
|
||||||
|
if (!row) throw new ApiError(404, 'RESOURCE_NOT_FOUND', 'AI 编辑任务不存在');
|
||||||
|
const access = row.output_asset_id && row.content_type ? await this.media.accessUrl(workspaceId, row.output_asset_id) : null;
|
||||||
|
return {
|
||||||
|
id: row.id, status: row.status, source_asset_id: row.source_asset_id, error_code: row.error_code,
|
||||||
|
failure_source: row.status === 'failed' ? row.failure_source ?? null : null,
|
||||||
|
failure_stage: row.status === 'failed' ? row.failure_stage ?? null : null,
|
||||||
|
failure_detail: row.status === 'failed' ? row.failure_detail ?? null : null,
|
||||||
|
output_asset: row.output_asset_id && access ? {
|
||||||
|
id: row.output_asset_id, content_type: row.content_type!, byte_size: Number(row.byte_size),
|
||||||
|
width_px: row.width_px, height_px: row.height_px,
|
||||||
|
created_at: row.asset_created_at instanceof Date
|
||||||
|
? row.asset_created_at.toISOString() : String(row.asset_created_at),
|
||||||
|
preview_url: access.url, preview_url_expires_at: access.expires_at,
|
||||||
|
} : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 右侧素材库展示最近完成的生成图,预览链接每次读取时重新签发。 */
|
||||||
|
public async list(workspaceId: string, productId: string): Promise<MediaAiEdit[]> {
|
||||||
|
const rows = await this.database.workspaceTransaction(workspaceId, async (tx) => {
|
||||||
|
const result = await tx.query<EditRow>(
|
||||||
|
`SELECT e.id, e.status, e.source_asset_id, e.output_asset_id, e.error_code,
|
||||||
|
a.content_type, a.byte_size::text, a.width_px, a.height_px,
|
||||||
|
a.created_at AS asset_created_at
|
||||||
|
FROM atlas.media_ai_edits e
|
||||||
|
JOIN atlas.media_assets a ON a.id = e.output_asset_id AND a.deleted_at IS NULL
|
||||||
|
WHERE e.status = 'succeeded' AND e.product_id = $1
|
||||||
|
ORDER BY e.completed_at DESC LIMIT 100`, [productId],
|
||||||
|
);
|
||||||
|
return result.rows;
|
||||||
|
});
|
||||||
|
return Promise.all(rows.map(async (row) => {
|
||||||
|
const access = await this.media.accessUrl(workspaceId, row.output_asset_id!);
|
||||||
|
return {
|
||||||
|
id: row.id, status: 'succeeded' as const, source_asset_id: row.source_asset_id,
|
||||||
|
error_code: null,
|
||||||
|
failure_source: null, failure_stage: null, failure_detail: null,
|
||||||
|
output_asset: {
|
||||||
|
id: row.output_asset_id!, content_type: row.content_type!,
|
||||||
|
byte_size: Number(row.byte_size), width_px: row.width_px, height_px: row.height_px,
|
||||||
|
created_at: row.asset_created_at instanceof Date
|
||||||
|
? row.asset_created_at.toISOString() : String(row.asset_created_at),
|
||||||
|
preview_url: access.url, preview_url_expires_at: access.expires_at,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 取消是工作空间内的幂等状态变更;已完成结果不回滚。 */
|
||||||
|
public async cancel(workspaceId: string, userId: string, id: string, key: string) {
|
||||||
|
return this.idempotency.run({
|
||||||
|
workspaceId, userId, method: 'POST', path: `/api/media-ai-edits/${id}/cancel`, key, request: {},
|
||||||
|
}, async (tx) => {
|
||||||
|
const existing = await tx.query<{ status: MediaAiEdit['status'] }>(
|
||||||
|
'SELECT status FROM atlas.media_ai_edits WHERE id = $1 FOR UPDATE', [id],
|
||||||
|
);
|
||||||
|
if (!existing.rows[0]) throw new ApiError(404, 'RESOURCE_NOT_FOUND', 'AI 编辑任务不存在');
|
||||||
|
if (existing.rows[0].status === 'queued' || existing.rows[0].status === 'running') {
|
||||||
|
await tx.query(
|
||||||
|
`UPDATE atlas.media_ai_edits SET status = 'cancelled', completed_at = now() WHERE id = $1`, [id],
|
||||||
|
);
|
||||||
|
await tx.query(
|
||||||
|
`UPDATE atlas.operations SET status = 'cancelled', completed_at = now()
|
||||||
|
WHERE id = $1 AND status IN ('queued', 'running')`, [id],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return { status: 200, body: { data: { id, status: existing.rows[0].status === 'queued'
|
||||||
|
|| existing.rows[0].status === 'running' ? 'cancelled' : existing.rows[0].status } } };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
export function detectImage(
|
||||||
|
bytes: Buffer,
|
||||||
|
maxDimension = 8192,
|
||||||
|
): {
|
||||||
|
contentType: 'image/png' | 'image/jpeg' | 'image/webp';
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
} | null {
|
||||||
|
if (
|
||||||
|
bytes.length >= 24 &&
|
||||||
|
bytes
|
||||||
|
.subarray(0, 8)
|
||||||
|
.equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]))
|
||||||
|
) {
|
||||||
|
return dimensions(
|
||||||
|
'image/png',
|
||||||
|
bytes.readUInt32BE(16),
|
||||||
|
bytes.readUInt32BE(20),
|
||||||
|
maxDimension,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
bytes.length >= 12 &&
|
||||||
|
bytes.subarray(0, 4).toString() === 'RIFF' &&
|
||||||
|
bytes.subarray(8, 12).toString() === 'WEBP'
|
||||||
|
) {
|
||||||
|
const type = bytes.subarray(12, 16).toString();
|
||||||
|
if (type === 'VP8X' && bytes.length >= 30) {
|
||||||
|
return dimensions(
|
||||||
|
'image/webp',
|
||||||
|
1 + bytes.readUIntLE(24, 3),
|
||||||
|
1 + bytes.readUIntLE(27, 3),
|
||||||
|
maxDimension,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
type === 'VP8 ' &&
|
||||||
|
bytes.length >= 30 &&
|
||||||
|
bytes[23] === 0x9d &&
|
||||||
|
bytes[24] === 0x01 &&
|
||||||
|
bytes[25] === 0x2a
|
||||||
|
) {
|
||||||
|
return dimensions(
|
||||||
|
'image/webp',
|
||||||
|
bytes.readUInt16LE(26) & 0x3fff,
|
||||||
|
bytes.readUInt16LE(28) & 0x3fff,
|
||||||
|
maxDimension,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (bytes.length > 4 && bytes[0] === 0xff && bytes[1] === 0xd8) {
|
||||||
|
for (let offset = 2; offset + 9 < bytes.length;) {
|
||||||
|
if (bytes[offset] !== 0xff) return null;
|
||||||
|
const marker = bytes[offset + 1];
|
||||||
|
const length = bytes.readUInt16BE(offset + 2);
|
||||||
|
if (length < 2 || offset + 2 + length > bytes.length) return null;
|
||||||
|
if (
|
||||||
|
[
|
||||||
|
0xc0, 0xc1, 0xc2, 0xc3, 0xc5, 0xc6, 0xc7, 0xc9, 0xca, 0xcb,
|
||||||
|
0xcd, 0xce, 0xcf,
|
||||||
|
].includes(marker)
|
||||||
|
) {
|
||||||
|
return dimensions(
|
||||||
|
'image/jpeg',
|
||||||
|
bytes.readUInt16BE(offset + 7),
|
||||||
|
bytes.readUInt16BE(offset + 5),
|
||||||
|
maxDimension,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
offset += 2 + length;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
function dimensions(
|
||||||
|
contentType: 'image/png' | 'image/jpeg' | 'image/webp',
|
||||||
|
width: number,
|
||||||
|
height: number,
|
||||||
|
maxDimension: number,
|
||||||
|
) {
|
||||||
|
return width >= 1 &&
|
||||||
|
height >= 1 &&
|
||||||
|
width <= maxDimension &&
|
||||||
|
height <= maxDimension
|
||||||
|
? { contentType, width, height }
|
||||||
|
: null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,367 @@
|
|||||||
|
import { ProductImageDeletionService } from './product-image-deletion.service.js';
|
||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Headers,
|
||||||
|
Inject,
|
||||||
|
Get,
|
||||||
|
Param,
|
||||||
|
Post,
|
||||||
|
Query,
|
||||||
|
Req,
|
||||||
|
Res,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import {
|
||||||
|
ApiBearerAuth,
|
||||||
|
ApiCreatedResponse,
|
||||||
|
ApiOkResponse,
|
||||||
|
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';
|
||||||
|
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(
|
||||||
|
@Body() body: unknown,
|
||||||
|
@Headers('idempotency-key') key: string | undefined,
|
||||||
|
@Req() request: AuthenticatedRequest,
|
||||||
|
@CurrentWorkspace() workspace: Workspace,
|
||||||
|
) {
|
||||||
|
return (
|
||||||
|
await this.media.createIntent(
|
||||||
|
workspace.id,
|
||||||
|
request.auth.userId,
|
||||||
|
idempotencyKey(key),
|
||||||
|
parse(createMediaUploadIntentRequestSchema, body),
|
||||||
|
)
|
||||||
|
).body;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('media/upload-intents/:uploadId/complete')
|
||||||
|
@ApiCreatedResponse({ description: '已完成对象安全校验并创建素材' })
|
||||||
|
public async complete(
|
||||||
|
@Param('uploadId') uploadId: string,
|
||||||
|
@Body() body: unknown,
|
||||||
|
@Headers('idempotency-key') key: string | undefined,
|
||||||
|
@Req() request: AuthenticatedRequest,
|
||||||
|
@CurrentWorkspace() workspace: Workspace,
|
||||||
|
) {
|
||||||
|
return (
|
||||||
|
await this.media.completeIntent(
|
||||||
|
workspace.id,
|
||||||
|
request.auth.userId,
|
||||||
|
uuid('upload_id', uploadId),
|
||||||
|
idempotencyKey(key),
|
||||||
|
parse(completeMediaUploadIntentRequestSchema, body),
|
||||||
|
)
|
||||||
|
).body;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('media-assets/:assetId/access-urls')
|
||||||
|
@ApiOkResponse({ description: '已签发短时预览或下载 URL' })
|
||||||
|
public async accessUrl(
|
||||||
|
@Param('assetId') assetId: string,
|
||||||
|
@Body() body: unknown,
|
||||||
|
@CurrentWorkspace() workspace: Workspace,
|
||||||
|
) {
|
||||||
|
const input = parse(createMediaAccessUrlRequestSchema, body) as CreateMediaAccessUrlRequest;
|
||||||
|
return {
|
||||||
|
data: await this.media.accessUrl(
|
||||||
|
workspace.id,
|
||||||
|
uuid('asset_id', assetId),
|
||||||
|
input.purpose,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 ReturnType<T['safeParse']> extends {
|
||||||
|
data: infer Data;
|
||||||
|
}
|
||||||
|
? Data
|
||||||
|
: never;
|
||||||
|
throw new ApiError(
|
||||||
|
422,
|
||||||
|
'VALIDATION_FAILED',
|
||||||
|
'请求参数不符合要求',
|
||||||
|
result.error?.issues.map((issue) => ({
|
||||||
|
field: issue.path.join('.'),
|
||||||
|
code: 'INVALID_FIELD',
|
||||||
|
message: issue.message,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
function uuid(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 idempotencyKey(value: string | undefined) {
|
||||||
|
if (!value)
|
||||||
|
throw new ApiError(
|
||||||
|
400,
|
||||||
|
'IDEMPOTENCY_KEY_REQUIRED',
|
||||||
|
'请求缺少 Idempotency-Key',
|
||||||
|
);
|
||||||
|
return uuid('Idempotency-Key', value);
|
||||||
|
}
|
||||||
@@ -0,0 +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: [ProductImageDeletionService, IdempotencyService, MediaService, AspectRatioDerivativeService, WatermarkDerivativeService, MediaAiEditService, ProductImageTaskService, ProductImageDerivativeHistoryService, ImageSuiteService],
|
||||||
|
exports: [MediaService],
|
||||||
|
})
|
||||||
|
export class MediaModule {}
|
||||||
@@ -0,0 +1,687 @@
|
|||||||
|
import { createHash, createHmac, randomUUID } from 'node:crypto';
|
||||||
|
import {
|
||||||
|
CreateBucketCommand,
|
||||||
|
GetObjectCommand,
|
||||||
|
HeadBucketCommand,
|
||||||
|
HeadObjectCommand,
|
||||||
|
PutObjectCommand,
|
||||||
|
S3Client,
|
||||||
|
} from '@aws-sdk/client-s3';
|
||||||
|
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;
|
||||||
|
expected_content_type: string;
|
||||||
|
expected_byte_size: string;
|
||||||
|
expected_checksum_sha256: string;
|
||||||
|
status: 'pending' | 'ready' | 'failed' | 'expired';
|
||||||
|
expires_at: Date | string;
|
||||||
|
asset_id: string | null;
|
||||||
|
};
|
||||||
|
type AssetRow = {
|
||||||
|
id: string;
|
||||||
|
content_type: string;
|
||||||
|
byte_size: string;
|
||||||
|
width_px: number | null;
|
||||||
|
height_px: number | null;
|
||||||
|
created_at: Date | string;
|
||||||
|
};
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class MediaService {
|
||||||
|
private readonly s3: S3Client;
|
||||||
|
private readonly legacyMinioS3: S3Client | null;
|
||||||
|
private bucketReady: Promise<void> | null = null;
|
||||||
|
|
||||||
|
public constructor(
|
||||||
|
@Inject(IdempotencyService)
|
||||||
|
private readonly idempotency: IdempotencyService,
|
||||||
|
@Inject(DATABASE) private readonly database: Database,
|
||||||
|
@Inject(APP_CONFIG) private readonly config: AppConfig,
|
||||||
|
) {
|
||||||
|
this.s3 = new S3Client({
|
||||||
|
endpoint: config.OBJECT_STORAGE_ENDPOINT,
|
||||||
|
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(
|
||||||
|
workspaceId: string,
|
||||||
|
userId: string,
|
||||||
|
key: string,
|
||||||
|
input: CreateMediaUploadIntentRequest,
|
||||||
|
) {
|
||||||
|
return this.idempotency.run(
|
||||||
|
{
|
||||||
|
workspaceId,
|
||||||
|
userId,
|
||||||
|
method: 'POST',
|
||||||
|
path: '/api/media/upload-intents',
|
||||||
|
key,
|
||||||
|
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}/${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, 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,
|
||||||
|
input.byte_size,
|
||||||
|
input.checksum_sha256,
|
||||||
|
expiresAt,
|
||||||
|
userId,
|
||||||
|
this.config.OBJECT_STORAGE_PROVIDER,
|
||||||
|
input.product_id ?? null,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
const url = await getSignedUrl(
|
||||||
|
this.s3,
|
||||||
|
new PutObjectCommand({
|
||||||
|
Bucket: this.config.OBJECT_STORAGE_BUCKET,
|
||||||
|
Key: objectKey,
|
||||||
|
ContentType: input.content_type,
|
||||||
|
}),
|
||||||
|
{ expiresIn: 600 },
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
status: 201,
|
||||||
|
body: {
|
||||||
|
data: {
|
||||||
|
upload_id: id,
|
||||||
|
expires_at: expiresAt.toISOString(),
|
||||||
|
upload: {
|
||||||
|
method: 'PUT',
|
||||||
|
url,
|
||||||
|
headers: { 'content-type': input.content_type },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async completeIntent(
|
||||||
|
workspaceId: string,
|
||||||
|
userId: string,
|
||||||
|
intentId: string,
|
||||||
|
key: string,
|
||||||
|
input: CompleteMediaUploadIntentRequest,
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
return await this.idempotency.run(
|
||||||
|
{
|
||||||
|
workspaceId,
|
||||||
|
userId,
|
||||||
|
method: 'POST',
|
||||||
|
path: `/api/media/upload-intents/${intentId}/complete`,
|
||||||
|
key,
|
||||||
|
request: input,
|
||||||
|
},
|
||||||
|
async (tx) => {
|
||||||
|
const intent = await this.lockIntent(tx, intentId);
|
||||||
|
if (intent.status === 'ready' && intent.asset_id) {
|
||||||
|
return {
|
||||||
|
status: 201,
|
||||||
|
body: {
|
||||||
|
data: {
|
||||||
|
asset: await this.requireAsset(
|
||||||
|
tx,
|
||||||
|
intent.asset_id,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (intent.status === 'failed') {
|
||||||
|
throw new ApiError(
|
||||||
|
422,
|
||||||
|
'UPLOAD_VALIDATION_FAILED',
|
||||||
|
'该上传意图的文件未通过安全校验,请重新上传',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (intent.status === 'expired') {
|
||||||
|
throw new ApiError(
|
||||||
|
422,
|
||||||
|
'UPLOAD_EXPIRED',
|
||||||
|
'上传意图已过期,请重新发起上传',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (new Date(intent.expires_at).getTime() <= Date.now()) {
|
||||||
|
throw new ApiError(
|
||||||
|
422,
|
||||||
|
'UPLOAD_EXPIRED',
|
||||||
|
'上传意图已过期,请重新发起上传',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
input.checksum_sha256 !==
|
||||||
|
intent.expected_checksum_sha256
|
||||||
|
) {
|
||||||
|
throw new ApiError(
|
||||||
|
422,
|
||||||
|
'UPLOAD_CHECKSUM_MISMATCH',
|
||||||
|
'完成请求的文件摘要与上传意图不一致',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const inspected = await this.inspectObject(intent);
|
||||||
|
if (
|
||||||
|
inspected.byteSize !==
|
||||||
|
Number(intent.expected_byte_size) ||
|
||||||
|
inspected.checksum !==
|
||||||
|
intent.expected_checksum_sha256 ||
|
||||||
|
inspected.contentType !== intent.expected_content_type
|
||||||
|
) {
|
||||||
|
throw new ApiError(
|
||||||
|
422,
|
||||||
|
'UPLOAD_VALIDATION_FAILED',
|
||||||
|
'上传文件未通过安全校验',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const existing = await tx.query<{ id: string }>(
|
||||||
|
`SELECT id FROM atlas.media_assets WHERE checksum_sha256 = $1`,
|
||||||
|
[inspected.checksum],
|
||||||
|
);
|
||||||
|
const assetId = existing.rows[0]?.id ?? randomUUID();
|
||||||
|
if (!existing.rows[0]) {
|
||||||
|
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, current_setting('app.workspace_id')::uuid, $9, $2, $3, $4, $5, $6, $7, $8)`,
|
||||||
|
[
|
||||||
|
assetId,
|
||||||
|
intent.bucket,
|
||||||
|
intent.object_key,
|
||||||
|
inspected.contentType,
|
||||||
|
inspected.byteSize,
|
||||||
|
inspected.checksum,
|
||||||
|
inspected.width,
|
||||||
|
inspected.height,
|
||||||
|
intent.storage_provider,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await tx.query(
|
||||||
|
`UPDATE atlas.media_upload_intents SET status = 'ready', asset_id = $2,
|
||||||
|
completed_at = now(), error_code = NULL, error_message = NULL WHERE id = $1`,
|
||||||
|
[intentId, assetId],
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
status: 201,
|
||||||
|
body: {
|
||||||
|
data: {
|
||||||
|
asset: await this.requireAsset(tx, assetId),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
const code = apiErrorCode(error);
|
||||||
|
if (
|
||||||
|
code === 'UPLOAD_EXPIRED' ||
|
||||||
|
code === 'UPLOAD_OBJECT_MISSING' ||
|
||||||
|
code === 'UPLOAD_VALIDATION_FAILED'
|
||||||
|
) {
|
||||||
|
await this.persistTerminalIntentStatus(
|
||||||
|
workspaceId,
|
||||||
|
intentId,
|
||||||
|
code,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 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 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, 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],
|
||||||
|
);
|
||||||
|
if (!rows.rows[0])
|
||||||
|
throw new ApiError(404, 'RESOURCE_NOT_FOUND', '上传意图不存在');
|
||||||
|
return rows.rows[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
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 AND deleted_at IS NULL`,
|
||||||
|
[assetId],
|
||||||
|
);
|
||||||
|
if (!rows.rows[0])
|
||||||
|
throw new ApiError(404, 'RESOURCE_NOT_FOUND', '素材不存在');
|
||||||
|
const asset = rows.rows[0];
|
||||||
|
return {
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async inspectObject(intent: IntentRow) {
|
||||||
|
let head;
|
||||||
|
try {
|
||||||
|
head = await this.s3.send(
|
||||||
|
new HeadObjectCommand({
|
||||||
|
Bucket: intent.bucket,
|
||||||
|
Key: intent.object_key,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
throw new ApiError(
|
||||||
|
422,
|
||||||
|
'UPLOAD_OBJECT_MISSING',
|
||||||
|
'尚未找到已上传文件',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
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',
|
||||||
|
'上传文件大小不符合要求',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const declaredContentType = head.ContentType?.split(
|
||||||
|
';',
|
||||||
|
1,
|
||||||
|
)[0]?.toLowerCase();
|
||||||
|
if (declaredContentType !== intent.expected_content_type) {
|
||||||
|
throw new ApiError(
|
||||||
|
422,
|
||||||
|
'UPLOAD_VALIDATION_FAILED',
|
||||||
|
'上传文件声明类型不符合要求',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const object = await this.s3.send(
|
||||||
|
new GetObjectCommand({
|
||||||
|
Bucket: intent.bucket,
|
||||||
|
Key: intent.object_key,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
if (!object.Body)
|
||||||
|
throw new ApiError(
|
||||||
|
422,
|
||||||
|
'UPLOAD_OBJECT_MISSING',
|
||||||
|
'尚未找到已上传文件',
|
||||||
|
);
|
||||||
|
const chunks: Buffer[] = [];
|
||||||
|
let byteSize = 0;
|
||||||
|
for await (const chunk of object.Body as AsyncIterable<Uint8Array>) {
|
||||||
|
byteSize += chunk.byteLength;
|
||||||
|
// HeadObject 和 GetObject 之间对象可能被替换;读取时也必须有硬上限。
|
||||||
|
if (byteSize > byteLimit) {
|
||||||
|
throw new ApiError(
|
||||||
|
422,
|
||||||
|
'UPLOAD_VALIDATION_FAILED',
|
||||||
|
'上传文件大小不符合要求',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
chunks.push(Buffer.from(chunk));
|
||||||
|
}
|
||||||
|
const bytes = Buffer.concat(chunks, byteSize);
|
||||||
|
const detected = detectImage(bytes, intent.purpose === 'product_image' ? 10_000 : 8192);
|
||||||
|
if (
|
||||||
|
!detected ||
|
||||||
|
detected.contentType !== intent.expected_content_type
|
||||||
|
) {
|
||||||
|
throw new ApiError(
|
||||||
|
422,
|
||||||
|
'UPLOAD_VALIDATION_FAILED',
|
||||||
|
'上传文件类型或内容不符合要求',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
contentType: detected.contentType,
|
||||||
|
width: detected.width,
|
||||||
|
height: detected.height,
|
||||||
|
byteSize,
|
||||||
|
checksum: createHash('sha256').update(bytes).digest('hex'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 七牛图片从绑定域名签发短时下载凭证;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 () => {
|
||||||
|
try {
|
||||||
|
await this.s3.send(
|
||||||
|
new HeadBucketCommand({
|
||||||
|
Bucket: this.config.OBJECT_STORAGE_BUCKET,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
await this.s3.send(
|
||||||
|
new CreateBucketCommand({
|
||||||
|
Bucket: this.config.OBJECT_STORAGE_BUCKET,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return this.bucketReady;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async persistTerminalIntentStatus(
|
||||||
|
workspaceId: string,
|
||||||
|
intentId: string,
|
||||||
|
code:
|
||||||
|
| 'UPLOAD_EXPIRED'
|
||||||
|
| 'UPLOAD_OBJECT_MISSING'
|
||||||
|
| 'UPLOAD_VALIDATION_FAILED',
|
||||||
|
) {
|
||||||
|
await this.database.workspaceTransaction(workspaceId, async (tx) => {
|
||||||
|
const expired = code === 'UPLOAD_EXPIRED';
|
||||||
|
await tx.query(
|
||||||
|
`UPDATE atlas.media_upload_intents
|
||||||
|
SET status = $2, error_code = $3, error_message = $4, completed_at = now()
|
||||||
|
WHERE id = $1 AND status = 'pending'`,
|
||||||
|
[
|
||||||
|
intentId,
|
||||||
|
expired ? 'expired' : 'failed',
|
||||||
|
code,
|
||||||
|
expired ? '上传意图已过期' : '上传文件未通过安全校验',
|
||||||
|
],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function apiErrorCode(error: unknown): string | undefined {
|
||||||
|
if (!(error instanceof ApiError)) return undefined;
|
||||||
|
const response = error.getResponse();
|
||||||
|
if (typeof response !== 'object' || !response || !('error' in response))
|
||||||
|
return undefined;
|
||||||
|
const apiError = response.error;
|
||||||
|
return typeof apiError === 'object' &&
|
||||||
|
apiError &&
|
||||||
|
'code' in apiError &&
|
||||||
|
typeof apiError.code === 'string'
|
||||||
|
? apiError.code
|
||||||
|
: undefined;
|
||||||
|
}
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
|
import type { DeleteProductImageRequest, ProductImageDeletion, Operation } from '@atlas/contracts';
|
||||||
|
import type { Database, DatabaseTransaction } from '@atlas/database';
|
||||||
|
import { ApiError } from '../common/api-error.js';
|
||||||
|
import { IdempotencyService } from '../common/idempotency.service.js';
|
||||||
|
import { DATABASE } from '../database/database.module.js';
|
||||||
|
|
||||||
|
type StoredAsset = { id: string; deleted_at: string | null; storage_provider: string; bucket: string; object_key: string };
|
||||||
|
@Injectable()
|
||||||
|
export class ProductImageDeletionService {
|
||||||
|
constructor(@Inject(DATABASE) private readonly database: Database,
|
||||||
|
@Inject(IdempotencyService) private readonly idempotency: IdempotencyService) {}
|
||||||
|
|
||||||
|
/** 先锁素材和校验全部引用,再在同一事务禁用候选并登记可靠清理任务。 */
|
||||||
|
async create(workspaceId: string, userId: string, productId: string, key: string, input: DeleteProductImageRequest) {
|
||||||
|
return this.idempotency.run({ workspaceId, userId, method: 'POST', path: `/api/products/${productId}/image-deletions`, key, request: input }, async tx => {
|
||||||
|
const product = await tx.query('SELECT id FROM atlas.products WHERE id = $1', [productId]);
|
||||||
|
if (!product.rows.length) throw new ApiError(404, 'RESOURCE_NOT_FOUND', '商品不存在');
|
||||||
|
let assetId = input.asset_id ?? null;
|
||||||
|
if (input.collection_image_id) {
|
||||||
|
const source = await tx.query<{ asset_id: string | null; status: string }>(
|
||||||
|
'SELECT asset_id, status FROM atlas.collection_asset_transfers WHERE id = $1 AND product_id = $2 FOR UPDATE', [input.collection_image_id, productId]);
|
||||||
|
if (!source.rows.length) throw new ApiError(404, 'RESOURCE_NOT_FOUND', '采集图片不存在');
|
||||||
|
if (source.rows[0].status === 'running') throw new ApiError(409, 'MEDIA_ASSET_IN_USE', '图片正在转存,请完成后再删除');
|
||||||
|
assetId = source.rows[0].asset_id;
|
||||||
|
}
|
||||||
|
const existing = await tx.query<ProductImageDeletion>(
|
||||||
|
`SELECT id, status, asset_id, collection_image_id, error_code FROM atlas.product_image_deletions
|
||||||
|
WHERE product_id = $1 AND (asset_id = $2 OR collection_image_id = $3)`, [productId, assetId, input.collection_image_id ?? null]);
|
||||||
|
if (existing.rows.length) return { status: 202, body: { data: await this.withOperation(tx, existing.rows[0]) } };
|
||||||
|
let objects: Array<{ storage_provider: string; bucket: string; object_key: string }> = [];
|
||||||
|
if (assetId) {
|
||||||
|
const result = await tx.query<StoredAsset>('SELECT id, deleted_at, storage_provider, bucket, object_key FROM atlas.media_assets WHERE id = $1 FOR UPDATE', [assetId]);
|
||||||
|
const asset = result.rows[0];
|
||||||
|
if (!asset || asset.deleted_at) throw new ApiError(404, 'RESOURCE_NOT_FOUND', '图片不存在或已删除');
|
||||||
|
const owned = await tx.query(`SELECT 1 FROM atlas.media_upload_intents WHERE asset_id = $1 AND product_id = $2 AND purpose = 'product_image' AND status = 'ready'
|
||||||
|
UNION ALL SELECT 1 FROM atlas.product_image_imports WHERE asset_id = $1 AND product_id = $2
|
||||||
|
UNION ALL SELECT 1 FROM atlas.collection_asset_transfers WHERE asset_id = $1 AND product_id = $2
|
||||||
|
UNION ALL SELECT 1 FROM atlas.media_ai_edits WHERE output_asset_id = $1 AND product_id = $2
|
||||||
|
UNION ALL SELECT 1 FROM atlas.media_aspect_ratio_derivatives WHERE output_asset_id = $1 AND product_id = $2
|
||||||
|
UNION ALL SELECT 1 FROM atlas.media_watermark_derivatives WHERE output_asset_id = $1 AND product_id = $2 LIMIT 1`, [assetId, productId]);
|
||||||
|
if (!owned.rows.length) throw new ApiError(404, 'RESOURCE_NOT_FOUND', '图片不属于当前商品');
|
||||||
|
await this.requireUnused(tx, assetId, productId);
|
||||||
|
const uploads = await tx.query<{ storage_provider: string; bucket: string; object_key: string }>(
|
||||||
|
"SELECT storage_provider, bucket, object_key FROM atlas.media_upload_intents WHERE asset_id = $1 AND status = 'ready'", [assetId]);
|
||||||
|
objects = [asset, ...uploads.rows].map(({storage_provider, bucket, object_key}) => ({storage_provider, bucket, object_key}));
|
||||||
|
objects = objects.filter((v, i) => objects.findIndex(o => o.storage_provider === v.storage_provider && o.bucket === v.bucket && o.object_key === v.object_key) === i);
|
||||||
|
await tx.query('UPDATE atlas.media_assets SET deleted_at = now(), deleted_checksum_sha256 = checksum_sha256, checksum_sha256 = NULL WHERE id = $1', [assetId]);
|
||||||
|
await tx.query('UPDATE atlas.collection_asset_transfers SET deleted_at = now() WHERE asset_id = $1 AND product_id = $2', [assetId, productId]);
|
||||||
|
}
|
||||||
|
if (input.collection_image_id) await tx.query(
|
||||||
|
"UPDATE atlas.collection_asset_transfers SET deleted_at = now(), status = CASE WHEN status IN ('queued', 'pending_selection') THEN 'cancelled'::atlas.processing_status ELSE status END WHERE id = $1", [input.collection_image_id]);
|
||||||
|
const id = randomUUID();
|
||||||
|
await tx.query(`INSERT INTO atlas.operations (id, workspace_id, kind, resource_type, resource_id, created_by_user_id, status, completed_at)
|
||||||
|
VALUES ($1, current_setting('app.workspace_id')::uuid, 'product_image_deletion', 'product_image_deletion', $1, $2,
|
||||||
|
$3::atlas.operation_status, CASE WHEN $3 = 'succeeded' THEN now() ELSE NULL END)`, [id, userId, objects.length ? 'queued' : 'succeeded']);
|
||||||
|
const deletion = await tx.query<ProductImageDeletion>(`INSERT INTO atlas.product_image_deletions
|
||||||
|
(id, workspace_id, product_id, asset_id, collection_image_id, objects, status, completed_at)
|
||||||
|
VALUES ($1, current_setting('app.workspace_id')::uuid, $2, $3, $4, $5::jsonb, $6,
|
||||||
|
CASE WHEN $6 = 'succeeded' THEN now() ELSE NULL END)
|
||||||
|
RETURNING id, status, asset_id, collection_image_id, error_code`,
|
||||||
|
[id, productId, assetId, input.collection_image_id ?? null, JSON.stringify(objects), objects.length ? 'queued' : 'succeeded']);
|
||||||
|
if (objects.length) await this.enqueue(tx, id, randomUUID());
|
||||||
|
return { status: 202, body: { data: await this.withOperation(tx, deletion.rows[0]) } };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
async pending(workspaceId: string, productId: string) {
|
||||||
|
return this.database.workspaceTransaction(workspaceId, async tx => {
|
||||||
|
const rows = await tx.query<ProductImageDeletion>("SELECT id, status, asset_id, collection_image_id, error_code FROM atlas.product_image_deletions WHERE product_id = $1 AND status <> 'succeeded' ORDER BY created_at DESC LIMIT 100", [productId]);
|
||||||
|
return Promise.all(rows.rows.map(row => this.withOperation(tx, row)));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
async read(workspaceId: string, productId: string, id: string) {
|
||||||
|
return this.database.workspaceTransaction(workspaceId, async tx => {
|
||||||
|
const result = await tx.query<ProductImageDeletion>('SELECT id, status, asset_id, collection_image_id, error_code FROM atlas.product_image_deletions WHERE id = $1 AND product_id = $2', [id, productId]);
|
||||||
|
if (!result.rows.length) throw new ApiError(404, 'RESOURCE_NOT_FOUND', '图片删除记录不存在');
|
||||||
|
return this.withOperation(tx, result.rows[0]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
async retry(workspaceId: string, userId: string, productId: string, id: string, key: string) {
|
||||||
|
return this.idempotency.run({workspaceId, userId, method: 'POST', path: `/api/products/${productId}/image-deletions/${id}/retry`, key, request: {}}, async tx => {
|
||||||
|
const row = await tx.query<ProductImageDeletion>('SELECT id, status, asset_id, collection_image_id, error_code FROM atlas.product_image_deletions WHERE id = $1 AND product_id = $2 FOR UPDATE', [id, productId]);
|
||||||
|
if (!row.rows.length) throw new ApiError(404, 'RESOURCE_NOT_FOUND', '图片删除记录不存在');
|
||||||
|
if (row.rows[0].status === 'failed') {
|
||||||
|
await tx.query("UPDATE atlas.product_image_deletions SET status = 'queued', error_code = NULL, completed_at = NULL WHERE id = $1", [id]);
|
||||||
|
await tx.query("UPDATE atlas.operations SET status = 'queued', error_code = NULL, error_message = NULL, completed_at = NULL WHERE id = $1", [id]);
|
||||||
|
await this.enqueue(tx, id, randomUUID());
|
||||||
|
row.rows[0].status = 'queued'; row.rows[0].error_code = null;
|
||||||
|
}
|
||||||
|
return {status: 202, body: {data: await this.withOperation(tx, row.rows[0])}};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
private async withOperation(tx: DatabaseTransaction, row: ProductImageDeletion): Promise<ProductImageDeletion> {
|
||||||
|
const result = await tx.query<Operation>(`SELECT id, kind, status, created_at::text, completed_at::text,
|
||||||
|
CASE WHEN error_code IS NULL THEN NULL ELSE jsonb_build_object('code', error_code, 'message', error_message) END AS error
|
||||||
|
FROM atlas.operations WHERE id = $1`, [row.id]);
|
||||||
|
const operation = result.rows[0];
|
||||||
|
return { ...row, operation: { ...operation,
|
||||||
|
created_at: new Date(operation.created_at).toISOString(),
|
||||||
|
completed_at: operation.completed_at ? new Date(operation.completed_at).toISOString() : null,
|
||||||
|
} };
|
||||||
|
}
|
||||||
|
private async enqueue(tx: DatabaseTransaction, id: string, attempt: string) {
|
||||||
|
await tx.query(`INSERT INTO atlas.outbox_events (workspace_id, aggregate_type, aggregate_id, event_type, event_payload, deduplication_key)
|
||||||
|
VALUES (current_setting('app.workspace_id')::uuid, 'product_image_deletion', $1, 'media.asset_delete.requested', jsonb_build_object('deletion_id', $1::uuid), $2)`, [id, attempt]);
|
||||||
|
}
|
||||||
|
private async requireUnused(tx: DatabaseTransaction, assetId: string, productId: string) {
|
||||||
|
const refs = await tx.query(`SELECT 1 FROM atlas.product_variant_listing_media WHERE asset_id = $1
|
||||||
|
UNION ALL SELECT 1 FROM atlas.watermark_template_versions WHERE image_asset_id = $1
|
||||||
|
UNION ALL SELECT 1 FROM atlas.users WHERE avatar_asset_id = $1
|
||||||
|
UNION ALL SELECT 1 FROM atlas.media_upload_intents WHERE asset_id = $1 AND product_id IS DISTINCT FROM $2::uuid
|
||||||
|
UNION ALL SELECT 1 FROM atlas.product_image_imports WHERE asset_id = $1 AND product_id IS DISTINCT FROM $2::uuid
|
||||||
|
UNION ALL SELECT 1 FROM atlas.collection_asset_transfers WHERE asset_id = $1 AND product_id <> $2
|
||||||
|
UNION ALL SELECT 1 FROM atlas.media_ai_edits WHERE (output_asset_id = $1 AND product_id IS DISTINCT FROM $2::uuid)
|
||||||
|
OR (status IN ('queued','running') AND (source_asset_id = $1 OR $1 = ANY(reference_asset_ids)))
|
||||||
|
UNION ALL SELECT 1 FROM atlas.media_aspect_ratio_derivatives WHERE (output_asset_id = $1 AND product_id IS DISTINCT FROM $2::uuid)
|
||||||
|
OR (status IN ('queued','running') AND source_asset_id = $1)
|
||||||
|
UNION ALL SELECT 1 FROM atlas.media_watermark_derivatives WHERE (output_asset_id = $1 AND product_id IS DISTINCT FROM $2::uuid)
|
||||||
|
OR (status IN ('queued','running') AND source_asset_id = $1)
|
||||||
|
UNION ALL SELECT 1 FROM atlas.product_image_suite_references r JOIN atlas.product_image_suites s ON s.id = r.suite_id
|
||||||
|
WHERE r.asset_id = $1 AND s.status IN ('queued','running') LIMIT 1`, [assetId, productId]);
|
||||||
|
if (refs.rows.length) throw new ApiError(409, 'MEDIA_ASSET_IN_USE', '图片已被 SKU、其他商品或图片任务使用,请先移除引用并保存后再删除');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
|
import type { ProductImageDerivativeRecord } from '@atlas/contracts';
|
||||||
|
import type { Database } from '@atlas/database';
|
||||||
|
import { ApiError } from '../common/api-error.js';
|
||||||
|
import { DATABASE } from '../database/database.module.js';
|
||||||
|
import { MediaService } from './media.service.js';
|
||||||
|
|
||||||
|
type HistoryRow = {
|
||||||
|
id: string;
|
||||||
|
kind: ProductImageDerivativeRecord['kind'];
|
||||||
|
source_asset_id: string;
|
||||||
|
output_asset_id: string;
|
||||||
|
created_at: Date | string;
|
||||||
|
content_type: string;
|
||||||
|
byte_size: string;
|
||||||
|
width_px: number | null;
|
||||||
|
height_px: number | null;
|
||||||
|
asset_created_at: Date | string;
|
||||||
|
};
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ProductImageDerivativeHistoryService {
|
||||||
|
public constructor(
|
||||||
|
@Inject(DATABASE) private readonly database: Database,
|
||||||
|
@Inject(MediaService) private readonly media: MediaService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/** 只列当前商品成功的派生图;预览地址每次读取时重新签发。 */
|
||||||
|
public async list(workspaceId: string, productId: string): Promise<ProductImageDerivativeRecord[]> {
|
||||||
|
const rows = await this.database.workspaceTransaction(workspaceId, async (tx) => {
|
||||||
|
const product = await tx.query<{ id: string }>(
|
||||||
|
'SELECT id FROM atlas.products WHERE id = $1', [productId],
|
||||||
|
);
|
||||||
|
if (!product.rows[0]) throw new ApiError(404, 'RESOURCE_NOT_FOUND', '商品不存在');
|
||||||
|
const result = await tx.query<HistoryRow>(
|
||||||
|
`SELECT d.id, d.kind, d.source_asset_id, d.output_asset_id, d.created_at,
|
||||||
|
a.content_type, a.byte_size::text, a.width_px, a.height_px,
|
||||||
|
a.created_at AS asset_created_at
|
||||||
|
FROM (
|
||||||
|
SELECT id, 'aspect_ratio_3_4'::text AS kind, source_asset_id,
|
||||||
|
output_asset_id, created_at, completed_at
|
||||||
|
FROM atlas.media_aspect_ratio_derivatives
|
||||||
|
WHERE product_id = $1 AND status = 'succeeded'
|
||||||
|
UNION ALL
|
||||||
|
SELECT id, 'watermark'::text AS kind, source_asset_id,
|
||||||
|
output_asset_id, created_at, completed_at
|
||||||
|
FROM atlas.media_watermark_derivatives
|
||||||
|
WHERE product_id = $1 AND status = 'succeeded'
|
||||||
|
) d
|
||||||
|
JOIN atlas.media_assets a ON a.id = d.output_asset_id AND a.deleted_at IS NULL
|
||||||
|
ORDER BY d.completed_at DESC, d.id DESC
|
||||||
|
LIMIT 100`,
|
||||||
|
[productId],
|
||||||
|
);
|
||||||
|
return result.rows;
|
||||||
|
});
|
||||||
|
return Promise.all(rows.map(async (row) => {
|
||||||
|
const access = await this.media.accessUrl(workspaceId, row.output_asset_id);
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
kind: row.kind,
|
||||||
|
source_asset_id: row.source_asset_id,
|
||||||
|
created_at: isoTime(row.created_at),
|
||||||
|
output_asset: {
|
||||||
|
id: row.output_asset_id,
|
||||||
|
content_type: row.content_type,
|
||||||
|
byte_size: Number(row.byte_size),
|
||||||
|
width_px: row.width_px,
|
||||||
|
height_px: row.height_px,
|
||||||
|
created_at: isoTime(row.asset_created_at),
|
||||||
|
preview_url: access.url,
|
||||||
|
preview_url_expires_at: access.expires_at,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isoTime(value: Date | string) {
|
||||||
|
return value instanceof Date ? value.toISOString() : String(value);
|
||||||
|
}
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
import { lookup } from 'node:dns/promises';
|
||||||
|
import { request as httpsRequest } from 'node:https';
|
||||||
|
import type { IncomingMessage } from 'node:http';
|
||||||
|
import { isIP } from 'node:net';
|
||||||
|
import { ApiError } from '../common/api-error.js';
|
||||||
|
import { detectImage } from './media-image-validation.js';
|
||||||
|
|
||||||
|
const MAX_BYTES = 5 * 1024 * 1024;
|
||||||
|
|
||||||
|
function invalidImage(): never {
|
||||||
|
throw new ApiError(
|
||||||
|
422,
|
||||||
|
'PRODUCT_IMAGE_IMPORT_INVALID',
|
||||||
|
'网络图片链接无效或图片不符合要求',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function publicIpv4(address: string) {
|
||||||
|
if (isIP(address) !== 4) return false;
|
||||||
|
const [a, b, c] = address.split('.').map(Number);
|
||||||
|
return !(
|
||||||
|
a === 0 ||
|
||||||
|
a === 10 ||
|
||||||
|
a === 127 ||
|
||||||
|
a >= 224 ||
|
||||||
|
(a === 100 && b >= 64 && b <= 127) ||
|
||||||
|
(a === 169 && b === 254) ||
|
||||||
|
(a === 172 && b >= 16 && b <= 31) ||
|
||||||
|
(a === 192 &&
|
||||||
|
(b === 168 || (b === 0 && c === 0) || (b === 0 && c === 2))) ||
|
||||||
|
(a === 198 && (b === 18 || b === 19 || (b === 51 && c === 100))) ||
|
||||||
|
(a === 203 && b === 0 && c === 113)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 每次重定向重新解析域名,并将 HTTPS 连接固定到已检查的公网 IPv4。 */
|
||||||
|
async function requestPublicImage(url: URL): Promise<IncomingMessage> {
|
||||||
|
if (
|
||||||
|
url.protocol !== 'https:' ||
|
||||||
|
url.username ||
|
||||||
|
url.password ||
|
||||||
|
url.port ||
|
||||||
|
isIP(url.hostname)
|
||||||
|
)
|
||||||
|
invalidImage();
|
||||||
|
const addresses = await lookup(url.hostname, {
|
||||||
|
all: true,
|
||||||
|
family: 4,
|
||||||
|
}).catch(() => []);
|
||||||
|
if (
|
||||||
|
!addresses.length ||
|
||||||
|
addresses.some((item) => !publicIpv4(item.address))
|
||||||
|
)
|
||||||
|
invalidImage();
|
||||||
|
const address = addresses[0].address;
|
||||||
|
return new Promise<IncomingMessage>((resolve, reject) => {
|
||||||
|
const request = httpsRequest(
|
||||||
|
url,
|
||||||
|
{
|
||||||
|
lookup: (_hostname, _options, callback) =>
|
||||||
|
callback(null, address, 4),
|
||||||
|
headers: {
|
||||||
|
Accept: 'image/jpeg,image/png',
|
||||||
|
'User-Agent': 'Atlas-Image-Importer/0.1',
|
||||||
|
},
|
||||||
|
timeout: 15_000,
|
||||||
|
},
|
||||||
|
resolve,
|
||||||
|
);
|
||||||
|
request.on('error', reject);
|
||||||
|
request.on('timeout', () =>
|
||||||
|
request.destroy(new Error('网络图片下载超时')),
|
||||||
|
);
|
||||||
|
request.end();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 仅导入公开 HTTPS 的 JPG/PNG,硬限制 5 MiB 并验证文件内容。 */
|
||||||
|
export async function downloadProductImage(source: string) {
|
||||||
|
let url = new URL(source);
|
||||||
|
for (let redirect = 0; redirect < 4; redirect += 1) {
|
||||||
|
let response: IncomingMessage;
|
||||||
|
try {
|
||||||
|
response = await requestPublicImage(url);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof ApiError) throw error;
|
||||||
|
invalidImage();
|
||||||
|
}
|
||||||
|
if ([301, 302, 303, 307, 308].includes(response.statusCode ?? 0)) {
|
||||||
|
const location = response.headers.location;
|
||||||
|
response.resume();
|
||||||
|
if (!location) invalidImage();
|
||||||
|
url = new URL(location, url);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
response.statusCode !== 200 ||
|
||||||
|
Number(response.headers['content-length'] ?? 0) > MAX_BYTES
|
||||||
|
) {
|
||||||
|
response.resume();
|
||||||
|
invalidImage();
|
||||||
|
}
|
||||||
|
const chunks: Buffer[] = [];
|
||||||
|
let total = 0;
|
||||||
|
try {
|
||||||
|
for await (const chunk of response) {
|
||||||
|
total += chunk.length;
|
||||||
|
if (total > MAX_BYTES) {
|
||||||
|
response.destroy();
|
||||||
|
invalidImage();
|
||||||
|
}
|
||||||
|
chunks.push(Buffer.from(chunk));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof ApiError) throw error;
|
||||||
|
invalidImage();
|
||||||
|
}
|
||||||
|
const bytes = Buffer.concat(chunks, total);
|
||||||
|
const detected = detectImage(bytes, 10_000);
|
||||||
|
if (
|
||||||
|
!detected ||
|
||||||
|
!['image/jpeg', 'image/png'].includes(detected.contentType)
|
||||||
|
)
|
||||||
|
invalidImage();
|
||||||
|
const declared = response.headers['content-type']
|
||||||
|
?.split(';', 1)[0]
|
||||||
|
?.toLowerCase();
|
||||||
|
if (declared && declared !== detected.contentType) invalidImage();
|
||||||
|
return {
|
||||||
|
bytes,
|
||||||
|
contentType: detected.contentType,
|
||||||
|
width: detected.width,
|
||||||
|
height: detected.height,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
invalidImage();
|
||||||
|
}
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
|
import type { ListProductImageTasksQuery, ProductImageTask } from '@atlas/contracts';
|
||||||
|
import type { Database } from '@atlas/database';
|
||||||
|
import { ApiError } from '../common/api-error.js';
|
||||||
|
import { DATABASE } from '../database/database.module.js';
|
||||||
|
|
||||||
|
type AiEditTaskRow = {
|
||||||
|
id: string;
|
||||||
|
status: ProductImageTask['status'];
|
||||||
|
provider_task_id: string | null;
|
||||||
|
output_asset_id: string | null;
|
||||||
|
error_code: string | null;
|
||||||
|
provider_error_raw: string | null;
|
||||||
|
failure_source: ProductImageTask['failure_source'];
|
||||||
|
failure_stage: ProductImageTask['failure_stage'];
|
||||||
|
failure_detail: string | null;
|
||||||
|
created_at: Date | string;
|
||||||
|
completed_at: Date | string | null;
|
||||||
|
kind?: ProductImageTask['kind'];
|
||||||
|
total?: number;
|
||||||
|
completed?: number;
|
||||||
|
output_asset_ids?: string[];
|
||||||
|
active_output_asset_ids?: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ProductImageTaskService {
|
||||||
|
public constructor(@Inject(DATABASE) private readonly database: Database) {}
|
||||||
|
|
||||||
|
/** 聚合单图与整套任务;套图子任务不重复出现在商品任务列表。 */
|
||||||
|
public async list(workspaceId: string, productId: string, query: ListProductImageTasksQuery) {
|
||||||
|
return this.database.workspaceTransaction(workspaceId, async (tx) => {
|
||||||
|
const product = await tx.query<{ id: string }>(
|
||||||
|
'SELECT id FROM atlas.products WHERE id = $1', [productId],
|
||||||
|
);
|
||||||
|
if (!product.rows[0]) throw new ApiError(404, 'RESOURCE_NOT_FOUND', '商品不存在');
|
||||||
|
|
||||||
|
const params: unknown[] = [productId];
|
||||||
|
let cursorFilter = '';
|
||||||
|
if (query.cursor) {
|
||||||
|
const cursor = decodeCursor(query.cursor);
|
||||||
|
params.push(cursor.active, cursor.at, cursor.id);
|
||||||
|
cursorFilter = `AND ((e.status IN ('queued', 'running'))::int, e.created_at, e.id)
|
||||||
|
< ($2::int, $3::timestamptz, $4::uuid)`;
|
||||||
|
}
|
||||||
|
params.push(query.limit + 1);
|
||||||
|
const edits = await tx.query<AiEditTaskRow>(
|
||||||
|
`SELECT e.*, ARRAY(SELECT a.id FROM unnest(e.output_asset_ids) WITH ORDINALITY refs(id, position)
|
||||||
|
JOIN atlas.media_assets a ON a.id = refs.id AND a.deleted_at IS NULL ORDER BY refs.position) AS active_output_asset_ids FROM (
|
||||||
|
SELECT e.id, e.product_id, e.status, e.provider_task_id, e.output_asset_id, e.error_code,
|
||||||
|
o.result ->> 'provider_error_raw' AS provider_error_raw,
|
||||||
|
o.result ->> 'failure_source' AS failure_source,
|
||||||
|
o.result ->> 'failure_stage' AS failure_stage,
|
||||||
|
o.result ->> 'failure_detail' AS failure_detail,
|
||||||
|
e.created_at, e.completed_at, 'single_ai_edit' AS kind, 1 AS total,
|
||||||
|
(e.status = 'succeeded')::int AS completed,
|
||||||
|
CASE WHEN e.output_asset_id IS NULL THEN '{}'::uuid[] ELSE ARRAY[e.output_asset_id] END AS output_asset_ids
|
||||||
|
FROM atlas.media_ai_edits e
|
||||||
|
LEFT JOIN atlas.operations o ON o.id = e.id AND o.workspace_id = e.workspace_id
|
||||||
|
WHERE e.suite_id IS NULL
|
||||||
|
UNION ALL
|
||||||
|
SELECT s.id, s.product_id, s.status, NULL::text, NULL::uuid, s.error_code,
|
||||||
|
failure.result ->> 'provider_error_raw', failure.result ->> 'failure_source',
|
||||||
|
failure.result ->> 'failure_stage', failure.result ->> 'failure_detail',
|
||||||
|
s.created_at, s.completed_at, 'ai_suite', jsonb_array_length(s.shots),
|
||||||
|
progress.completed::int, progress.outputs
|
||||||
|
FROM atlas.product_image_suites s
|
||||||
|
LEFT JOIN LATERAL (SELECT count(*) FILTER (WHERE status = 'succeeded') AS completed,
|
||||||
|
coalesce(array_agg(output_asset_id ORDER BY suite_position) FILTER (WHERE output_asset_id IS NOT NULL), '{}'::uuid[]) AS outputs
|
||||||
|
FROM atlas.media_ai_edits WHERE suite_id = s.id) progress ON true
|
||||||
|
LEFT JOIN LATERAL (SELECT o.result FROM atlas.media_ai_edits child JOIN atlas.operations o ON o.id = child.id
|
||||||
|
AND o.workspace_id = child.workspace_id WHERE child.suite_id = s.id AND child.status = 'failed'
|
||||||
|
ORDER BY child.suite_position LIMIT 1) failure ON true
|
||||||
|
WHERE s.purpose = 'generate'
|
||||||
|
) e
|
||||||
|
WHERE e.product_id = $1 ${cursorFilter}
|
||||||
|
ORDER BY (e.status IN ('queued', 'running')) DESC, e.created_at DESC, e.id DESC
|
||||||
|
LIMIT $${params.length}`, params,
|
||||||
|
);
|
||||||
|
const hasMore = edits.rows.length > query.limit;
|
||||||
|
const rows = edits.rows.slice(0, query.limit);
|
||||||
|
const last = rows.at(-1);
|
||||||
|
const data: ProductImageTask[] = rows.map((row) => ({
|
||||||
|
id: row.id,
|
||||||
|
kind: row.kind ?? 'single_ai_edit',
|
||||||
|
status: row.status,
|
||||||
|
phase: row.status === 'running' && row.provider_task_id ? 'waiting_provider' as const : null,
|
||||||
|
total: row.total ?? 1,
|
||||||
|
completed: row.completed ?? (row.status === 'succeeded' ? 1 : 0),
|
||||||
|
output_asset_ids: row.active_output_asset_ids ?? row.output_asset_ids ?? (row.output_asset_id ? [row.output_asset_id] : []),
|
||||||
|
error_code: row.error_code,
|
||||||
|
failure_source: ['failed', 'partial'].includes(row.status) ? row.failure_source ?? null : null,
|
||||||
|
failure_stage: ['failed', 'partial'].includes(row.status) ? row.failure_stage ?? null : null,
|
||||||
|
failure_detail: ['failed', 'partial'].includes(row.status) ? row.failure_detail ?? null : null,
|
||||||
|
provider_error_raw: ['failed', 'partial'].includes(row.status) ? row.provider_error_raw ?? null : null,
|
||||||
|
created_at: iso(row.created_at),
|
||||||
|
completed_at: row.completed_at ? iso(row.completed_at) : null,
|
||||||
|
}));
|
||||||
|
return {
|
||||||
|
data,
|
||||||
|
meta: {
|
||||||
|
has_more: hasMore,
|
||||||
|
next_cursor: hasMore && last ? encodeCursor(last) : null,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function iso(value: Date | string): string {
|
||||||
|
return value instanceof Date ? value.toISOString() : value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function encodeCursor(row: AiEditTaskRow): string {
|
||||||
|
return Buffer.from(JSON.stringify({
|
||||||
|
active: row.status === 'queued' || row.status === 'running' ? 1 : 0,
|
||||||
|
at: iso(row.created_at), id: row.id,
|
||||||
|
})).toString('base64url');
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodeCursor(value: string): { active: number; at: string; id: string } {
|
||||||
|
try {
|
||||||
|
const decoded = JSON.parse(Buffer.from(value, 'base64url').toString('utf8')) as Record<string, unknown>;
|
||||||
|
if ((decoded.active === 0 || decoded.active === 1)
|
||||||
|
&& typeof decoded.at === 'string' && !Number.isNaN(Date.parse(decoded.at))
|
||||||
|
&& typeof decoded.id === 'string' && /^[0-9a-f-]{36}$/i.test(decoded.id)) {
|
||||||
|
return { active: decoded.active, at: decoded.at, id: decoded.id };
|
||||||
|
}
|
||||||
|
} catch { /* 非法游标按字段错误返回。 */ }
|
||||||
|
throw new ApiError(422, 'VALIDATION_FAILED', '分页游标无效');
|
||||||
|
}
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
|
import type { CreateWatermarkDerivativeRequest, WatermarkDerivative } from '@atlas/contracts';
|
||||||
|
import type { Database } from '@atlas/database';
|
||||||
|
import { ApiError } from '../common/api-error.js';
|
||||||
|
import { IdempotencyService } from '../common/idempotency.service.js';
|
||||||
|
import { DATABASE } from '../database/database.module.js';
|
||||||
|
import { MediaService } from './media.service.js';
|
||||||
|
|
||||||
|
type DerivativeRow = {
|
||||||
|
id: string;
|
||||||
|
status: WatermarkDerivative['status'];
|
||||||
|
source_asset_id: string;
|
||||||
|
watermark_template_id: string;
|
||||||
|
watermark_template_version: number;
|
||||||
|
output_asset_id: string | null;
|
||||||
|
error_code: string | null;
|
||||||
|
content_type: string | null;
|
||||||
|
byte_size: string | null;
|
||||||
|
width_px: number | null;
|
||||||
|
height_px: number | null;
|
||||||
|
asset_created_at: Date | string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class WatermarkDerivativeService {
|
||||||
|
public constructor(
|
||||||
|
@Inject(DATABASE) private readonly database: Database,
|
||||||
|
@Inject(IdempotencyService) private readonly idempotency: IdempotencyService,
|
||||||
|
@Inject(MediaService) private readonly media: MediaService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/** 创建任务时固定模板版本,并与进度和 Outbox 一起提交。 */
|
||||||
|
public async create(
|
||||||
|
workspaceId: string,
|
||||||
|
userId: string,
|
||||||
|
assetId: string,
|
||||||
|
key: string,
|
||||||
|
input: CreateWatermarkDerivativeRequest,
|
||||||
|
) {
|
||||||
|
return this.idempotency.run({
|
||||||
|
workspaceId,
|
||||||
|
userId,
|
||||||
|
method: 'POST',
|
||||||
|
path: `/api/media-assets/${assetId}/watermark-derivatives`,
|
||||||
|
key,
|
||||||
|
request: input,
|
||||||
|
}, async (tx) => {
|
||||||
|
const source = await tx.query<{ id: string }>(
|
||||||
|
`SELECT id FROM atlas.media_assets
|
||||||
|
WHERE id = $1 AND deleted_at IS NULL AND content_type IN ('image/jpeg', 'image/png', 'image/webp')
|
||||||
|
AND byte_size <= 20971520 AND width_px BETWEEN 1 AND 10000
|
||||||
|
AND height_px BETWEEN 1 AND 10000`,
|
||||||
|
[assetId],
|
||||||
|
);
|
||||||
|
if (!source.rows[0]) throw new ApiError(404, 'RESOURCE_NOT_FOUND', '图片素材不存在或不可处理');
|
||||||
|
if (input.product_id) {
|
||||||
|
const product = await tx.query<{ id: string }>(
|
||||||
|
'SELECT id FROM atlas.products WHERE id = $1',
|
||||||
|
[input.product_id],
|
||||||
|
);
|
||||||
|
if (!product.rows[0]) throw new ApiError(404, 'RESOURCE_NOT_FOUND', '商品不存在');
|
||||||
|
}
|
||||||
|
const template = await tx.query<{ id: string; current_version: number }>(
|
||||||
|
`SELECT id, current_version FROM atlas.watermark_templates
|
||||||
|
WHERE id = $1 AND status = 'active' AND deleted_at IS NULL`,
|
||||||
|
[input.watermark_template_id],
|
||||||
|
);
|
||||||
|
if (!template.rows[0]) throw new ApiError(404, 'RESOURCE_NOT_FOUND', '水印模板不存在');
|
||||||
|
const version = template.rows[0].current_version;
|
||||||
|
const created = await tx.query<{ id: string }>(
|
||||||
|
`INSERT INTO atlas.media_watermark_derivatives
|
||||||
|
(workspace_id, product_id, source_asset_id, watermark_template_id,
|
||||||
|
watermark_template_version, created_by_user_id)
|
||||||
|
VALUES (current_setting('app.workspace_id')::uuid, $1, $2, $3, $4, $5)
|
||||||
|
RETURNING id`,
|
||||||
|
[input.product_id ?? null, assetId, input.watermark_template_id, version, userId],
|
||||||
|
);
|
||||||
|
const id = created.rows[0].id;
|
||||||
|
await tx.query(
|
||||||
|
`INSERT INTO atlas.operations
|
||||||
|
(id, workspace_id, kind, resource_type, resource_id, created_by_user_id)
|
||||||
|
VALUES ($1, current_setting('app.workspace_id')::uuid,
|
||||||
|
'media_watermark_derivative', 'media_derivative', $1, $2)`,
|
||||||
|
[id, userId],
|
||||||
|
);
|
||||||
|
await tx.query(
|
||||||
|
`INSERT INTO atlas.outbox_events
|
||||||
|
(workspace_id, aggregate_type, aggregate_id, event_type, event_payload, deduplication_key)
|
||||||
|
VALUES (current_setting('app.workspace_id')::uuid, 'media_derivative', $1,
|
||||||
|
'media.watermark_derivative.requested',
|
||||||
|
jsonb_build_object('derivative_id', $1::uuid), $1::text)`,
|
||||||
|
[id],
|
||||||
|
);
|
||||||
|
return { status: 202, body: { data: {
|
||||||
|
id,
|
||||||
|
status: 'queued',
|
||||||
|
source_asset_id: assetId,
|
||||||
|
watermark_template_id: input.watermark_template_id,
|
||||||
|
watermark_template_version: version,
|
||||||
|
output_asset: null,
|
||||||
|
error_code: null,
|
||||||
|
} satisfies WatermarkDerivative } };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 成功结果读取时才签发短时预览地址。 */
|
||||||
|
public async read(workspaceId: string, derivativeId: string): Promise<WatermarkDerivative> {
|
||||||
|
const row = await this.database.workspaceTransaction(workspaceId, async (tx) => {
|
||||||
|
const result = await tx.query<DerivativeRow>(
|
||||||
|
`SELECT d.id, d.status, d.source_asset_id, d.watermark_template_id,
|
||||||
|
d.watermark_template_version, d.output_asset_id, d.error_code,
|
||||||
|
a.content_type, a.byte_size::text, a.width_px, a.height_px,
|
||||||
|
a.created_at AS asset_created_at
|
||||||
|
FROM atlas.media_watermark_derivatives d
|
||||||
|
LEFT JOIN atlas.media_assets a ON a.id = d.output_asset_id AND a.deleted_at IS NULL
|
||||||
|
WHERE d.id = $1`,
|
||||||
|
[derivativeId],
|
||||||
|
);
|
||||||
|
return result.rows[0] ?? null;
|
||||||
|
});
|
||||||
|
if (!row) throw new ApiError(404, 'RESOURCE_NOT_FOUND', '水印处理任务不存在');
|
||||||
|
const access = row.output_asset_id && row.content_type
|
||||||
|
? await this.media.accessUrl(workspaceId, row.output_asset_id)
|
||||||
|
: null;
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
status: row.status,
|
||||||
|
source_asset_id: row.source_asset_id,
|
||||||
|
watermark_template_id: row.watermark_template_id,
|
||||||
|
watermark_template_version: row.watermark_template_version,
|
||||||
|
error_code: row.error_code,
|
||||||
|
output_asset: row.output_asset_id && access ? {
|
||||||
|
id: row.output_asset_id,
|
||||||
|
content_type: row.content_type!,
|
||||||
|
byte_size: Number(row.byte_size),
|
||||||
|
width_px: row.width_px,
|
||||||
|
height_px: row.height_px,
|
||||||
|
created_at: row.asset_created_at instanceof Date
|
||||||
|
? row.asset_created_at.toISOString() : String(row.asset_created_at),
|
||||||
|
preview_url: access.url,
|
||||||
|
preview_url_expires_at: access.expires_at,
|
||||||
|
} : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,191 @@
|
|||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
Headers,
|
||||||
|
Inject,
|
||||||
|
Param,
|
||||||
|
Post,
|
||||||
|
Query,
|
||||||
|
Req,
|
||||||
|
Res,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import {
|
||||||
|
ApiAcceptedResponse,
|
||||||
|
ApiBearerAuth,
|
||||||
|
ApiOkResponse,
|
||||||
|
ApiOperation,
|
||||||
|
ApiTags,
|
||||||
|
} from '@nestjs/swagger';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import {
|
||||||
|
listOnlineProductsQuerySchema,
|
||||||
|
listOnlineProductSyncRunsQuerySchema,
|
||||||
|
onlineProductSyncRequestSchema,
|
||||||
|
type Workspace,
|
||||||
|
} from '@atlas/contracts';
|
||||||
|
import type { AuthenticatedRequest } from '../auth/auth.guard.js';
|
||||||
|
import { ApiError } from '../common/api-error.js';
|
||||||
|
import { CurrentWorkspace } from '../workspace/current-workspace.decorator.js';
|
||||||
|
import { OnlineProductsService } from './online-products.service.js';
|
||||||
|
|
||||||
|
type HttpResponse = { status: (status: number) => HttpResponse };
|
||||||
|
|
||||||
|
@ApiTags('Online products')
|
||||||
|
@ApiBearerAuth('access-token')
|
||||||
|
@Controller('platform-listings')
|
||||||
|
export class OnlineProductsController {
|
||||||
|
public constructor(
|
||||||
|
@Inject(OnlineProductsService)
|
||||||
|
private readonly service: OnlineProductsService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
@ApiOperation({ summary: '按店铺和 SKU 查询 Ozon 商品镜像' })
|
||||||
|
@ApiOkResponse({ description: '商品列表及最近同步状态' })
|
||||||
|
public list(
|
||||||
|
@Headers('x-atlas-platform-id') header: string | undefined,
|
||||||
|
@CurrentWorkspace() workspace: Workspace,
|
||||||
|
@Query() query: unknown,
|
||||||
|
) {
|
||||||
|
return this.service.list(
|
||||||
|
workspace.id,
|
||||||
|
platformId(header),
|
||||||
|
parse(listOnlineProductsQuerySchema, query),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':listingId')
|
||||||
|
@ApiOperation({ summary: '读取一个在线 SKU 的业务摘要' })
|
||||||
|
public async get(
|
||||||
|
@Headers('x-atlas-platform-id') header: string | undefined,
|
||||||
|
@CurrentWorkspace() workspace: Workspace,
|
||||||
|
@Param('listingId') listingId: string,
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
data: await this.service.get(
|
||||||
|
workspace.id,
|
||||||
|
platformId(header),
|
||||||
|
uuid('listing_id', listingId),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ApiTags('Online product sync')
|
||||||
|
@ApiBearerAuth('access-token')
|
||||||
|
@Controller('stores/:storeId/platform-product-sync-runs')
|
||||||
|
export class StoreOnlineProductSyncController {
|
||||||
|
public constructor(
|
||||||
|
@Inject(OnlineProductsService)
|
||||||
|
private readonly service: OnlineProductsService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
@ApiOperation({ summary: '手动发起该店铺的全量 Ozon 商品同步' })
|
||||||
|
@ApiAcceptedResponse({ description: '已创建异步同步任务' })
|
||||||
|
public async create(
|
||||||
|
@Headers('x-atlas-platform-id') header: string | undefined,
|
||||||
|
@Headers('idempotency-key') key: string | undefined,
|
||||||
|
@Param('storeId') storeId: string,
|
||||||
|
@Body() body: unknown,
|
||||||
|
@Req() request: AuthenticatedRequest,
|
||||||
|
@CurrentWorkspace() workspace: Workspace,
|
||||||
|
@Res({ passthrough: true }) response: HttpResponse,
|
||||||
|
) {
|
||||||
|
parse(onlineProductSyncRequestSchema, body ?? {});
|
||||||
|
const result = await this.service.createSync(
|
||||||
|
workspace.id,
|
||||||
|
request.auth.userId,
|
||||||
|
platformId(header),
|
||||||
|
uuid('store_id', storeId),
|
||||||
|
requireKey(key),
|
||||||
|
);
|
||||||
|
response.status(result.status);
|
||||||
|
return result.body;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
@ApiOperation({ summary: '查询店铺同步历史' })
|
||||||
|
public list(
|
||||||
|
@Headers('x-atlas-platform-id') header: string | undefined,
|
||||||
|
@CurrentWorkspace() workspace: Workspace,
|
||||||
|
@Param('storeId') storeId: string,
|
||||||
|
@Query() query: unknown,
|
||||||
|
) {
|
||||||
|
return this.service.listRuns(
|
||||||
|
workspace.id,
|
||||||
|
platformId(header),
|
||||||
|
uuid('store_id', storeId),
|
||||||
|
parse(listOnlineProductSyncRunsQuerySchema, query),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ApiTags('Online product sync')
|
||||||
|
@ApiBearerAuth('access-token')
|
||||||
|
@Controller('platform-product-sync-runs')
|
||||||
|
export class OnlineProductSyncRunsController {
|
||||||
|
public constructor(
|
||||||
|
@Inject(OnlineProductsService)
|
||||||
|
private readonly service: OnlineProductsService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
@Get(':runId')
|
||||||
|
@ApiOperation({ summary: '查询一次在线商品同步的进度和结果' })
|
||||||
|
public async get(
|
||||||
|
@Headers('x-atlas-platform-id') header: string | undefined,
|
||||||
|
@CurrentWorkspace() workspace: Workspace,
|
||||||
|
@Param('runId') runId: string,
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
data: await this.service.getRun(
|
||||||
|
workspace.id,
|
||||||
|
platformId(header),
|
||||||
|
uuid('sync_run_id', runId),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function platformId(value: string | undefined): string {
|
||||||
|
if (!value)
|
||||||
|
throw new ApiError(
|
||||||
|
400,
|
||||||
|
'PLATFORM_CONTEXT_REQUIRED',
|
||||||
|
'请求缺少 X-Atlas-Platform-Id',
|
||||||
|
);
|
||||||
|
return uuid('X-Atlas-Platform-Id', value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function requireKey(value: string | undefined): string {
|
||||||
|
if (!value)
|
||||||
|
throw new ApiError(
|
||||||
|
400,
|
||||||
|
'IDEMPOTENCY_KEY_REQUIRED',
|
||||||
|
'请求缺少 Idempotency-Key',
|
||||||
|
);
|
||||||
|
return uuid('Idempotency-Key', value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function uuid(field: string, value: string): string {
|
||||||
|
if (z.uuid().safeParse(value).success) return value;
|
||||||
|
throw new ApiError(422, 'VALIDATION_FAILED', '请求参数不符合要求', [
|
||||||
|
{ field, code: 'INVALID_FIELD', message: `${field} 必须是 UUID` },
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parse<T>(schema: z.ZodType<T>, value: unknown): T {
|
||||||
|
const result = schema.safeParse(value);
|
||||||
|
if (result.success) return result.data;
|
||||||
|
throw new ApiError(
|
||||||
|
422,
|
||||||
|
'VALIDATION_FAILED',
|
||||||
|
'请求参数不符合要求',
|
||||||
|
result.error.issues.map((issue) => ({
|
||||||
|
field: issue.path.join('.'),
|
||||||
|
code: 'INVALID_FIELD',
|
||||||
|
message: issue.message,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { IdempotencyService } from '../common/idempotency.service.js';
|
||||||
|
import {
|
||||||
|
OnlineProductsController,
|
||||||
|
OnlineProductSyncRunsController,
|
||||||
|
StoreOnlineProductSyncController,
|
||||||
|
} from './online-products.controller.js';
|
||||||
|
import { OnlineProductsService } from './online-products.service.js';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [
|
||||||
|
OnlineProductsController,
|
||||||
|
StoreOnlineProductSyncController,
|
||||||
|
OnlineProductSyncRunsController,
|
||||||
|
],
|
||||||
|
providers: [OnlineProductsService, IdempotencyService],
|
||||||
|
})
|
||||||
|
export class OnlineProductsModule {}
|
||||||
@@ -0,0 +1,415 @@
|
|||||||
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
|
import type { Database, DatabaseTransaction } from '@atlas/database';
|
||||||
|
import type {
|
||||||
|
ListOnlineProductsQuery,
|
||||||
|
ListOnlineProductSyncRunsQuery,
|
||||||
|
OnlineProduct,
|
||||||
|
OnlineProductSyncRun,
|
||||||
|
OnlineProductsMeta,
|
||||||
|
} from '@atlas/contracts';
|
||||||
|
import { ApiError } from '../common/api-error.js';
|
||||||
|
import { IdempotencyService } from '../common/idempotency.service.js';
|
||||||
|
import { DATABASE } from '../database/database.module.js';
|
||||||
|
|
||||||
|
type StoreRow = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
platform_id: string;
|
||||||
|
status: string;
|
||||||
|
credential_status: string;
|
||||||
|
};
|
||||||
|
type ListingRow = Omit<
|
||||||
|
OnlineProduct,
|
||||||
|
| 'image'
|
||||||
|
| 'stock_summary'
|
||||||
|
| 'last_synced_at'
|
||||||
|
| 'missing_since'
|
||||||
|
| 'archived_at'
|
||||||
|
> & {
|
||||||
|
image_summary: OnlineProduct['image'];
|
||||||
|
stock_summary: OnlineProduct['stock_summary'];
|
||||||
|
last_synced_at: Date | string;
|
||||||
|
missing_since: Date | string | null;
|
||||||
|
archived_at: Date | string | null;
|
||||||
|
updated_at: Date | string;
|
||||||
|
};
|
||||||
|
type RunRow = Omit<
|
||||||
|
OnlineProductSyncRun,
|
||||||
|
'created_at' | 'started_at' | 'completed_at' | 'error'
|
||||||
|
> & {
|
||||||
|
created_at: Date | string;
|
||||||
|
started_at: Date | string | null;
|
||||||
|
completed_at: Date | string | null;
|
||||||
|
error_payload: { code?: string; message?: string } | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const listingSelect = `SELECT l.id, l.store_id, l.platform_id, l.remote_product_id, l.offer_id,
|
||||||
|
l.remote_sku, l.name, l.currency_code, round(l.selling_price, 2)::numeric(18,2)::text AS selling_price,
|
||||||
|
round(l.list_price, 2)::numeric(18,2)::text AS list_price, l.visible, l.remote_state, l.moderation_status,
|
||||||
|
l.validation_status, l.stock_summary, l.image_summary, l.error_payload AS errors,
|
||||||
|
l.visibility_details, l.source, l.last_synced_at, l.missing_since, l.archived_at, l.updated_at,
|
||||||
|
CASE WHEN origin.id IS NULL THEN NULL ELSE jsonb_build_object(
|
||||||
|
'collection_item_id', product.collection_item_id, 'product_id', origin.product_id,
|
||||||
|
'variant_id', origin.product_variant_id) END AS atlas_origin
|
||||||
|
FROM atlas.platform_listings l
|
||||||
|
LEFT JOIN atlas.product_variant_platform_listings origin ON origin.id = l.atlas_variant_listing_id
|
||||||
|
LEFT JOIN atlas.products product ON product.id = origin.product_id`;
|
||||||
|
|
||||||
|
const runSelect = `SELECT id, store_id, status, trigger_source, cursor_started, cursor_finished,
|
||||||
|
listed_count, upserted_count, failed_count, created_at, started_at, completed_at, error_payload
|
||||||
|
FROM atlas.platform_product_sync_runs`;
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class OnlineProductsService {
|
||||||
|
public constructor(
|
||||||
|
@Inject(DATABASE) private readonly database: Database,
|
||||||
|
@Inject(IdempotencyService)
|
||||||
|
private readonly idempotency: IdempotencyService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public list(
|
||||||
|
workspaceId: string,
|
||||||
|
platformId: string,
|
||||||
|
query: ListOnlineProductsQuery,
|
||||||
|
) {
|
||||||
|
return this.database.workspaceTransaction(workspaceId, async (tx) => {
|
||||||
|
const store = await this.resolveStore(
|
||||||
|
tx,
|
||||||
|
platformId,
|
||||||
|
query.store_id,
|
||||||
|
);
|
||||||
|
const params: unknown[] = [store.id, platformId];
|
||||||
|
const filters = ['l.store_id = $1', 'l.platform_id = $2'];
|
||||||
|
if (!query.include_archived) filters.push('l.archived_at IS NULL');
|
||||||
|
if (query.q) {
|
||||||
|
params.push(`%${query.q.replace(/[\\%_]/g, '\\$&')}%`);
|
||||||
|
filters.push(
|
||||||
|
`(l.name ILIKE $${params.length} ESCAPE '\\' OR l.offer_id ILIKE $${params.length} ESCAPE '\\' OR l.remote_product_id ILIKE $${params.length} ESCAPE '\\' OR l.remote_sku ILIKE $${params.length} ESCAPE '\\')`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (query.visibility !== 'all') {
|
||||||
|
filters.push(
|
||||||
|
query.visibility === 'unknown'
|
||||||
|
? 'l.visible IS NULL'
|
||||||
|
: `l.visible = ${query.visibility === 'visible' ? 'true' : 'false'}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (query.source) {
|
||||||
|
params.push(query.source);
|
||||||
|
filters.push(`l.source = $${params.length}`);
|
||||||
|
}
|
||||||
|
if (query.moderation_status) {
|
||||||
|
params.push(query.moderation_status);
|
||||||
|
filters.push(`l.moderation_status = $${params.length}`);
|
||||||
|
}
|
||||||
|
if (query.cursor) {
|
||||||
|
const cursor = decodeCursor(query.cursor);
|
||||||
|
params.push(cursor.at, cursor.id);
|
||||||
|
filters.push(
|
||||||
|
`(l.updated_at, l.id) < ($${params.length - 1}::timestamptz, $${params.length}::uuid)`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
params.push(query.limit + 1);
|
||||||
|
const result = await tx.query<ListingRow>(
|
||||||
|
`${listingSelect} WHERE ${filters.join(' AND ')}
|
||||||
|
ORDER BY l.updated_at DESC, l.id DESC LIMIT $${params.length}`,
|
||||||
|
params,
|
||||||
|
);
|
||||||
|
const hasMore = result.rows.length > query.limit;
|
||||||
|
const rows = result.rows.slice(0, query.limit);
|
||||||
|
const last = rows.at(-1);
|
||||||
|
const sync = await tx.query<{
|
||||||
|
last_successful_sync_at: Date | string | null;
|
||||||
|
last_sync_status: OnlineProductsMeta['last_sync_status'];
|
||||||
|
}>(
|
||||||
|
`SELECT (SELECT completed_at FROM atlas.platform_product_sync_runs
|
||||||
|
WHERE store_id = $1 AND status = 'succeeded' ORDER BY completed_at DESC LIMIT 1) AS last_successful_sync_at,
|
||||||
|
(SELECT status FROM atlas.platform_product_sync_runs
|
||||||
|
WHERE store_id = $1 ORDER BY created_at DESC, id DESC LIMIT 1) AS last_sync_status`,
|
||||||
|
[store.id],
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
data: rows.map(toListing),
|
||||||
|
meta: {
|
||||||
|
next_cursor:
|
||||||
|
hasMore && last
|
||||||
|
? encodeCursor(last.updated_at, last.id)
|
||||||
|
: null,
|
||||||
|
has_more: hasMore,
|
||||||
|
store: { id: store.id, name: store.name },
|
||||||
|
last_successful_sync_at: iso(
|
||||||
|
sync.rows[0]?.last_successful_sync_at ?? null,
|
||||||
|
),
|
||||||
|
last_sync_status: sync.rows[0]?.last_sync_status ?? null,
|
||||||
|
} satisfies OnlineProductsMeta,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public get(workspaceId: string, platformId: string, listingId: string) {
|
||||||
|
return this.database.workspaceTransaction(workspaceId, async (tx) => {
|
||||||
|
await this.assertPlatform(tx, platformId);
|
||||||
|
const result = await tx.query<ListingRow>(
|
||||||
|
`${listingSelect} WHERE l.id = $1 AND l.platform_id = $2`,
|
||||||
|
[listingId, platformId],
|
||||||
|
);
|
||||||
|
if (!result.rows[0])
|
||||||
|
throw new ApiError(404, 'LISTING_NOT_FOUND', '在线商品不存在');
|
||||||
|
return toListing(result.rows[0]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public async createSync(
|
||||||
|
workspaceId: string,
|
||||||
|
userId: string,
|
||||||
|
platformId: string,
|
||||||
|
storeId: string,
|
||||||
|
key: string,
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
return await this.idempotency.run(
|
||||||
|
{
|
||||||
|
workspaceId,
|
||||||
|
userId,
|
||||||
|
method: 'POST',
|
||||||
|
path: `/api/stores/${storeId}/platform-product-sync-runs`,
|
||||||
|
key,
|
||||||
|
request: {
|
||||||
|
reason: 'manual_refresh',
|
||||||
|
platform_id: platformId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async (tx) => {
|
||||||
|
const store = await this.resolveStore(
|
||||||
|
tx,
|
||||||
|
platformId,
|
||||||
|
storeId,
|
||||||
|
);
|
||||||
|
const platform = await tx.query<{ code: string }>(
|
||||||
|
`SELECT code FROM atlas.platforms WHERE id = $1`,
|
||||||
|
[platformId],
|
||||||
|
);
|
||||||
|
if (platform.rows[0]?.code.toUpperCase() !== 'OZON')
|
||||||
|
throw new ApiError(
|
||||||
|
422,
|
||||||
|
'PLATFORM_UNAVAILABLE',
|
||||||
|
'当前平台暂不支持商品同步',
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
store.status !== 'active' ||
|
||||||
|
store.credential_status !== 'valid'
|
||||||
|
)
|
||||||
|
throw new ApiError(
|
||||||
|
422,
|
||||||
|
'STORE_CREDENTIAL_UNAVAILABLE',
|
||||||
|
'店铺凭据暂不可用于同步',
|
||||||
|
);
|
||||||
|
const secret = await tx.query<{ id: string }>(
|
||||||
|
`SELECT id FROM atlas.store_secrets WHERE store_id = $1 AND kind = 'seller_api_key' AND revoked_at IS NULL`,
|
||||||
|
[store.id],
|
||||||
|
);
|
||||||
|
if (!secret.rows[0])
|
||||||
|
throw new ApiError(
|
||||||
|
422,
|
||||||
|
'STORE_CREDENTIAL_UNAVAILABLE',
|
||||||
|
'店铺凭据暂不可用于同步',
|
||||||
|
);
|
||||||
|
const active = await tx.query<{ id: string }>(
|
||||||
|
`SELECT id FROM atlas.platform_product_sync_runs WHERE store_id = $1 AND status IN ('queued', 'running') FOR UPDATE`,
|
||||||
|
[store.id],
|
||||||
|
);
|
||||||
|
if (active.rows[0])
|
||||||
|
throw new ApiError(
|
||||||
|
409,
|
||||||
|
'SYNC_ALREADY_RUNNING',
|
||||||
|
'该店铺已有正在进行的商品同步',
|
||||||
|
[{ sync_run_id: active.rows[0].id }],
|
||||||
|
);
|
||||||
|
const created = await tx.query<RunRow>(
|
||||||
|
`INSERT INTO atlas.platform_product_sync_runs (workspace_id, store_id, platform_id, trigger_source)
|
||||||
|
VALUES (current_setting('app.workspace_id')::uuid, $1, $2, 'manual') RETURNING id, store_id,
|
||||||
|
status, trigger_source, cursor_started, cursor_finished, listed_count, upserted_count,
|
||||||
|
failed_count, created_at, started_at, completed_at, error_payload`,
|
||||||
|
[store.id, platformId],
|
||||||
|
);
|
||||||
|
const run = toRun(created.rows[0]);
|
||||||
|
await tx.query(
|
||||||
|
`INSERT INTO atlas.outbox_events (workspace_id, aggregate_type, aggregate_id, event_type, event_payload, deduplication_key)
|
||||||
|
VALUES (current_setting('app.workspace_id')::uuid, 'platform_product_sync_run', $1,
|
||||||
|
'platform_product_sync.requested', $2, $1::uuid::text)`,
|
||||||
|
[
|
||||||
|
run.id,
|
||||||
|
{
|
||||||
|
sync_run_id: run.id,
|
||||||
|
store_id: store.id,
|
||||||
|
platform_id: platformId,
|
||||||
|
secret_id: secret.rows[0].id,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
);
|
||||||
|
return { status: 202, body: { data: { sync_run: run } } };
|
||||||
|
},
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
if (
|
||||||
|
typeof error === 'object' &&
|
||||||
|
error !== null &&
|
||||||
|
'constraint' in error &&
|
||||||
|
error.constraint === 'ux_platform_product_sync_runs_active'
|
||||||
|
) {
|
||||||
|
const active = await this.database.workspaceTransaction(
|
||||||
|
workspaceId,
|
||||||
|
(tx) =>
|
||||||
|
tx.query<{ id: string }>(
|
||||||
|
`SELECT id FROM atlas.platform_product_sync_runs WHERE store_id = $1 AND status IN ('queued', 'running') ORDER BY created_at DESC LIMIT 1`,
|
||||||
|
[storeId],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
throw new ApiError(
|
||||||
|
409,
|
||||||
|
'SYNC_ALREADY_RUNNING',
|
||||||
|
'该店铺已有正在进行的商品同步',
|
||||||
|
active.rows[0] ? [{ sync_run_id: active.rows[0].id }] : [],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public getRun(workspaceId: string, platformId: string, runId: string) {
|
||||||
|
return this.database.workspaceTransaction(workspaceId, async (tx) => {
|
||||||
|
await this.assertPlatform(tx, platformId);
|
||||||
|
const result = await tx.query<RunRow>(
|
||||||
|
`${runSelect} WHERE id = $1 AND platform_id = $2`,
|
||||||
|
[runId, platformId],
|
||||||
|
);
|
||||||
|
if (!result.rows[0])
|
||||||
|
throw new ApiError(404, 'SYNC_RUN_NOT_FOUND', '同步记录不存在');
|
||||||
|
return toRun(result.rows[0]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public listRuns(
|
||||||
|
workspaceId: string,
|
||||||
|
platformId: string,
|
||||||
|
storeId: string,
|
||||||
|
query: ListOnlineProductSyncRunsQuery,
|
||||||
|
) {
|
||||||
|
return this.database.workspaceTransaction(workspaceId, async (tx) => {
|
||||||
|
await this.resolveStore(tx, platformId, storeId);
|
||||||
|
const cursor = query.cursor ? decodeCursor(query.cursor) : null;
|
||||||
|
const result = await tx.query<RunRow>(
|
||||||
|
`${runSelect} WHERE store_id = $1 AND platform_id = $2
|
||||||
|
AND ($3::timestamptz IS NULL OR (created_at, id) < ($3::timestamptz, $4::uuid))
|
||||||
|
ORDER BY created_at DESC, id DESC LIMIT $5`,
|
||||||
|
[
|
||||||
|
storeId,
|
||||||
|
platformId,
|
||||||
|
cursor?.at ?? null,
|
||||||
|
cursor?.id ?? null,
|
||||||
|
query.limit + 1,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
const hasMore = result.rows.length > query.limit;
|
||||||
|
const rows = result.rows.slice(0, query.limit);
|
||||||
|
const last = rows.at(-1);
|
||||||
|
return {
|
||||||
|
data: rows.map(toRun),
|
||||||
|
meta: {
|
||||||
|
next_cursor:
|
||||||
|
hasMore && last
|
||||||
|
? encodeCursor(last.created_at, last.id)
|
||||||
|
: null,
|
||||||
|
has_more: hasMore,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async assertPlatform(tx: DatabaseTransaction, platformId: string) {
|
||||||
|
const platform = await tx.query<{ id: string }>(
|
||||||
|
`SELECT id FROM atlas.platforms WHERE id = $1 AND status = 'active'`,
|
||||||
|
[platformId],
|
||||||
|
);
|
||||||
|
if (!platform.rows[0])
|
||||||
|
throw new ApiError(422, 'PLATFORM_UNAVAILABLE', '当前平台不可用');
|
||||||
|
}
|
||||||
|
|
||||||
|
private async resolveStore(
|
||||||
|
tx: DatabaseTransaction,
|
||||||
|
platformId: string,
|
||||||
|
storeId?: string,
|
||||||
|
): Promise<StoreRow> {
|
||||||
|
await this.assertPlatform(tx, platformId);
|
||||||
|
const result = await tx.query<StoreRow>(
|
||||||
|
`SELECT id, name, platform_id, status, credential_status FROM atlas.stores
|
||||||
|
WHERE platform_id = $1 AND deleted_at IS NULL AND ${storeId ? 'id = $2' : 'is_default = true'}`,
|
||||||
|
storeId ? [platformId, storeId] : [platformId],
|
||||||
|
);
|
||||||
|
if (!result.rows[0])
|
||||||
|
throw new ApiError(
|
||||||
|
storeId ? 404 : 422,
|
||||||
|
storeId ? 'STORE_NOT_FOUND' : 'DEFAULT_STORE_REQUIRED',
|
||||||
|
storeId ? '店铺不存在' : '请先设置当前平台的默认店铺',
|
||||||
|
);
|
||||||
|
return result.rows[0];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toListing(row: ListingRow): OnlineProduct {
|
||||||
|
const { image_summary, updated_at: _updatedAt, ...rest } = row;
|
||||||
|
return {
|
||||||
|
...rest,
|
||||||
|
stock_summary: row.stock_summary ?? { total: 0, has_data: false },
|
||||||
|
image: image_summary ?? { url: null, width_px: null, height_px: null },
|
||||||
|
errors: row.errors ?? [],
|
||||||
|
visibility_details: row.visibility_details ?? {},
|
||||||
|
last_synced_at: iso(row.last_synced_at)!,
|
||||||
|
missing_since: iso(row.missing_since),
|
||||||
|
archived_at: iso(row.archived_at),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function toRun(row: RunRow): OnlineProductSyncRun {
|
||||||
|
const { error_payload, ...rest } = row;
|
||||||
|
return {
|
||||||
|
...rest,
|
||||||
|
created_at: iso(row.created_at)!,
|
||||||
|
started_at: iso(row.started_at),
|
||||||
|
completed_at: iso(row.completed_at),
|
||||||
|
error: error_payload
|
||||||
|
? {
|
||||||
|
code: error_payload.code ?? 'SYNC_FAILED',
|
||||||
|
message: error_payload.message ?? '商品同步失败',
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function iso(value: Date | string | null): string | null {
|
||||||
|
return value ? new Date(value).toISOString() : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function encodeCursor(at: Date | string, id: string): string {
|
||||||
|
return Buffer.from(JSON.stringify({ at: iso(at), id })).toString(
|
||||||
|
'base64url',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodeCursor(value: string): { at: string; id: string } {
|
||||||
|
try {
|
||||||
|
const decoded = JSON.parse(
|
||||||
|
Buffer.from(value, 'base64url').toString('utf8'),
|
||||||
|
) as { at?: unknown; id?: unknown };
|
||||||
|
if (
|
||||||
|
typeof decoded.at === 'string' &&
|
||||||
|
!Number.isNaN(Date.parse(decoded.at)) &&
|
||||||
|
typeof decoded.id === 'string' &&
|
||||||
|
/^[0-9a-f-]{36}$/i.test(decoded.id)
|
||||||
|
)
|
||||||
|
return { at: decoded.at, id: decoded.id };
|
||||||
|
} catch {
|
||||||
|
/* 非法游标统一转成字段校验错误。 */
|
||||||
|
}
|
||||||
|
throw new ApiError(422, 'VALIDATION_FAILED', '分页游标无效');
|
||||||
|
}
|
||||||
@@ -0,0 +1,424 @@
|
|||||||
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
|
import type { Database, DatabaseTransaction } from '@atlas/database';
|
||||||
|
import { ApiError } from '../common/api-error.js';
|
||||||
|
import { APP_CONFIG, type AppConfig } from '../config/app-config.module.js';
|
||||||
|
import { DATABASE } from '../database/database.module.js';
|
||||||
|
import { encryptStoreSecret } from '../stores/store-secret.crypto.js';
|
||||||
|
import {
|
||||||
|
enqueuePlatformCatalogRun,
|
||||||
|
type PlatformCatalogRunRow,
|
||||||
|
} from '../platform-catalog/platform-catalog-run.js';
|
||||||
|
import type {
|
||||||
|
AttributeBatchInput, RefreshPolicyInput, SourceInput, SyncInput,
|
||||||
|
} from './platform-catalog-admin.models.js';
|
||||||
|
|
||||||
|
type BatchRow = {
|
||||||
|
id: string;
|
||||||
|
platform_id: string;
|
||||||
|
mode: AttributeBatchInput['mode'];
|
||||||
|
status: string;
|
||||||
|
total_count: number;
|
||||||
|
created_at: Date;
|
||||||
|
started_at: Date | null;
|
||||||
|
completed_at: Date | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class PlatformCatalogAdminCommands {
|
||||||
|
public constructor(
|
||||||
|
@Inject(DATABASE) private readonly database: Database,
|
||||||
|
@Inject(APP_CONFIG) private readonly config: AppConfig,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public async configureSource(platformId: string, userId: string, input: SourceInput) {
|
||||||
|
const encrypted = encryptStoreSecret(input.api_key, this.config.STORE_SECRET_ENCRYPTION_KEY);
|
||||||
|
return this.database.transaction(async (tx) => {
|
||||||
|
const platform = await tx.query<{ code: string }>(
|
||||||
|
'SELECT code FROM atlas.platforms WHERE id = $1 FOR UPDATE',
|
||||||
|
[platformId],
|
||||||
|
);
|
||||||
|
if (!platform.rows[0]) throw new ApiError(404, 'PLATFORM_NOT_FOUND', '平台不存在');
|
||||||
|
if (platform.rows[0].code.toUpperCase() !== 'OZON') {
|
||||||
|
throw new ApiError(422, 'PLATFORM_CATALOG_UNSUPPORTED', '该平台尚未接入目录同步');
|
||||||
|
}
|
||||||
|
const existing = await tx.query<{
|
||||||
|
client_id: string; secret_fingerprint: string; revision: number;
|
||||||
|
}>(
|
||||||
|
`SELECT client_id, secret_fingerprint, revision
|
||||||
|
FROM atlas.platform_catalog_sources WHERE platform_id = $1 FOR UPDATE`,
|
||||||
|
[platformId],
|
||||||
|
);
|
||||||
|
const unchanged = existing.rows[0]?.client_id === input.client_id &&
|
||||||
|
existing.rows[0]?.secret_fingerprint === encrypted.fingerprint;
|
||||||
|
const revision = unchanged ? existing.rows[0].revision : (existing.rows[0]?.revision ?? 0) + 1;
|
||||||
|
if (!unchanged) {
|
||||||
|
await tx.query(
|
||||||
|
`INSERT INTO atlas.platform_catalog_sources
|
||||||
|
(platform_id, client_id, secret_ciphertext, secret_nonce,
|
||||||
|
secret_fingerprint, encryption_key_version, revision, updated_by_user_id)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||||
|
ON CONFLICT (platform_id) DO UPDATE
|
||||||
|
SET client_id = EXCLUDED.client_id,
|
||||||
|
secret_ciphertext = EXCLUDED.secret_ciphertext,
|
||||||
|
secret_nonce = EXCLUDED.secret_nonce,
|
||||||
|
secret_fingerprint = EXCLUDED.secret_fingerprint,
|
||||||
|
encryption_key_version = EXCLUDED.encryption_key_version,
|
||||||
|
revision = EXCLUDED.revision,
|
||||||
|
updated_by_user_id = EXCLUDED.updated_by_user_id,
|
||||||
|
updated_at = now()`,
|
||||||
|
[platformId, input.client_id, encrypted.ciphertext, encrypted.nonce,
|
||||||
|
encrypted.fingerprint, this.config.STORE_SECRET_ENCRYPTION_KEY_VERSION,
|
||||||
|
revision, userId],
|
||||||
|
);
|
||||||
|
// 旧来源的任务即使已进队列,也不能拿新凭据执行。
|
||||||
|
await tx.query(
|
||||||
|
`UPDATE atlas.platform_catalog_sync_runs
|
||||||
|
SET status = 'cancelled', completed_at = now(),
|
||||||
|
error_code = 'CATALOG_SOURCE_REPLACED', error_message = '同步来源已更新'
|
||||||
|
WHERE platform_id = $1 AND status IN ('queued', 'running')`,
|
||||||
|
[platformId],
|
||||||
|
);
|
||||||
|
await tx.query(
|
||||||
|
`UPDATE atlas.platform_catalog_attribute_batches
|
||||||
|
SET status = 'cancelled', completed_at = now()
|
||||||
|
WHERE platform_id = $1 AND status IN ('queued', 'running')`,
|
||||||
|
[platformId],
|
||||||
|
);
|
||||||
|
await enqueuePlatformCatalogRun(tx, {
|
||||||
|
platformId, scope: 'tree', scopeKey: 'tree', sourceRevision: revision,
|
||||||
|
trigger: 'source_configured', priority: 'high', userId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
configured: true,
|
||||||
|
client_id_hint: `••••${input.client_id.slice(-4)}`,
|
||||||
|
revision,
|
||||||
|
changed: !unchanged,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public async sync(platformId: string, userId: string, idempotencyKey: string, input: SyncInput) {
|
||||||
|
return this.database.transaction(async (tx) => {
|
||||||
|
const platform = await tx.query<{ code: string }>(
|
||||||
|
'SELECT code FROM atlas.platforms WHERE id = $1 FOR UPDATE',
|
||||||
|
[platformId],
|
||||||
|
);
|
||||||
|
if (!platform.rows[0]) throw new ApiError(404, 'PLATFORM_NOT_FOUND', '平台不存在');
|
||||||
|
if (platform.rows[0].code.toUpperCase() !== 'OZON') {
|
||||||
|
throw new ApiError(422, 'PLATFORM_CATALOG_UNSUPPORTED', '该平台尚未接入目录同步');
|
||||||
|
}
|
||||||
|
const source = await tx.query<{ revision: number }>(
|
||||||
|
'SELECT revision FROM atlas.platform_catalog_sources WHERE platform_id = $1 FOR UPDATE',
|
||||||
|
[platformId],
|
||||||
|
);
|
||||||
|
if (!source.rows[0]) {
|
||||||
|
throw new ApiError(422, 'CATALOG_SOURCE_REQUIRED', '请先配置平台目录同步来源');
|
||||||
|
}
|
||||||
|
const target = await this.resolveTarget(tx, platformId, input);
|
||||||
|
const prior = await tx.query<PlatformCatalogRunRow>(
|
||||||
|
`SELECT run.id, run.platform_id, run.scope, run.scope_key, run.status,
|
||||||
|
run.trigger_kind, run.priority, run.created_at
|
||||||
|
FROM atlas.platform_catalog_sync_requests request
|
||||||
|
JOIN atlas.platform_catalog_sync_runs run ON run.id = request.run_id
|
||||||
|
WHERE request.requested_by_user_id = $1 AND request.idempotency_key = $2`,
|
||||||
|
[userId, idempotencyKey],
|
||||||
|
);
|
||||||
|
if (prior.rows[0]) {
|
||||||
|
if (prior.rows[0].platform_id !== platformId || prior.rows[0].scope_key !== target.scopeKey) {
|
||||||
|
throw new ApiError(409, 'IDEMPOTENCY_KEY_REUSED', '幂等键已用于其他同步请求');
|
||||||
|
}
|
||||||
|
return presentRun(prior.rows[0]);
|
||||||
|
}
|
||||||
|
const run = await enqueuePlatformCatalogRun(tx, {
|
||||||
|
platformId,
|
||||||
|
scope: input.scope,
|
||||||
|
scopeKey: target.scopeKey,
|
||||||
|
categoryId: target.categoryId,
|
||||||
|
attributeId: target.attributeId,
|
||||||
|
sourceRevision: source.rows[0].revision,
|
||||||
|
trigger: 'manual',
|
||||||
|
priority: input.scope === 'tree'
|
||||||
|
? 'high'
|
||||||
|
: await resourcePriority(tx, platformId, input.scope, target.scopeKey),
|
||||||
|
userId,
|
||||||
|
idempotencyKey,
|
||||||
|
});
|
||||||
|
await tx.query(
|
||||||
|
`INSERT INTO atlas.platform_catalog_sync_requests
|
||||||
|
(requested_by_user_id, idempotency_key, platform_id, scope_key, run_id)
|
||||||
|
VALUES ($1, $2, $3, $4, $5)`,
|
||||||
|
[userId, idempotencyKey, platformId, target.scopeKey, run.id],
|
||||||
|
);
|
||||||
|
return presentRun(run);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public async updateRefreshPolicy(
|
||||||
|
platformId: string,
|
||||||
|
userId: string,
|
||||||
|
input: RefreshPolicyInput,
|
||||||
|
) {
|
||||||
|
return this.database.transaction(async (tx) => {
|
||||||
|
await this.requireSupportedPlatform(tx, platformId);
|
||||||
|
const previous = await ensurePolicy(tx, platformId);
|
||||||
|
if (samePolicy(previous, input)) return previous;
|
||||||
|
const result = await tx.query<{
|
||||||
|
attribute_fresh_days: number; attribute_expire_days: number;
|
||||||
|
dictionary_fresh_days: number; dictionary_expire_days: number;
|
||||||
|
updated_at: Date;
|
||||||
|
}>(
|
||||||
|
`INSERT INTO atlas.platform_catalog_refresh_policies
|
||||||
|
(platform_id, attribute_fresh_days, attribute_expire_days,
|
||||||
|
dictionary_fresh_days, dictionary_expire_days, updated_by_user_id)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6)
|
||||||
|
ON CONFLICT (platform_id) DO UPDATE
|
||||||
|
SET attribute_fresh_days = EXCLUDED.attribute_fresh_days,
|
||||||
|
attribute_expire_days = EXCLUDED.attribute_expire_days,
|
||||||
|
dictionary_fresh_days = EXCLUDED.dictionary_fresh_days,
|
||||||
|
dictionary_expire_days = EXCLUDED.dictionary_expire_days,
|
||||||
|
updated_by_user_id = EXCLUDED.updated_by_user_id,
|
||||||
|
updated_at = now()
|
||||||
|
RETURNING attribute_fresh_days, attribute_expire_days,
|
||||||
|
dictionary_fresh_days, dictionary_expire_days, updated_at`,
|
||||||
|
[platformId, input.attribute_fresh_days, input.attribute_expire_days,
|
||||||
|
input.dictionary_fresh_days, input.dictionary_expire_days, userId],
|
||||||
|
);
|
||||||
|
const next = result.rows[0];
|
||||||
|
await tx.query(
|
||||||
|
`INSERT INTO atlas.platform_catalog_refresh_policy_revisions
|
||||||
|
(platform_id, changed_by_user_id, previous_policy, next_policy)
|
||||||
|
VALUES ($1, $2, $3::jsonb, $4::jsonb)`,
|
||||||
|
[platformId, userId, JSON.stringify(policyValues(previous)),
|
||||||
|
JSON.stringify(policyValues(next))],
|
||||||
|
);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public async createAttributeBatch(
|
||||||
|
platformId: string,
|
||||||
|
userId: string,
|
||||||
|
idempotencyKey: string,
|
||||||
|
input: AttributeBatchInput,
|
||||||
|
) {
|
||||||
|
return this.database.transaction(async (tx) => {
|
||||||
|
await this.requireSupportedPlatform(tx, platformId);
|
||||||
|
const source = await tx.query<{ revision: number }>(
|
||||||
|
`SELECT revision FROM atlas.platform_catalog_sources
|
||||||
|
WHERE platform_id = $1 FOR UPDATE`,
|
||||||
|
[platformId],
|
||||||
|
);
|
||||||
|
if (!source.rows[0]) {
|
||||||
|
throw new ApiError(422, 'CATALOG_SOURCE_REQUIRED', '请先配置平台目录同步来源');
|
||||||
|
}
|
||||||
|
const prior = await tx.query<BatchRow>(
|
||||||
|
`SELECT id, platform_id, mode, status, total_count,
|
||||||
|
created_at, started_at, completed_at
|
||||||
|
FROM atlas.platform_catalog_attribute_batches
|
||||||
|
WHERE requested_by_user_id = $1 AND idempotency_key = $2`,
|
||||||
|
[userId, idempotencyKey],
|
||||||
|
);
|
||||||
|
if (prior.rows[0]) {
|
||||||
|
if (prior.rows[0].platform_id !== platformId || prior.rows[0].mode !== input.mode) {
|
||||||
|
throw new ApiError(409, 'IDEMPOTENCY_KEY_REUSED', '幂等键已用于其他属性同步批次');
|
||||||
|
}
|
||||||
|
return presentBatch(tx, prior.rows[0]);
|
||||||
|
}
|
||||||
|
const active = await tx.query<{ id: string }>(
|
||||||
|
`SELECT id FROM atlas.platform_catalog_attribute_batches
|
||||||
|
WHERE platform_id = $1 AND status IN ('queued', 'running') LIMIT 1`,
|
||||||
|
[platformId],
|
||||||
|
);
|
||||||
|
if (active.rows[0]) {
|
||||||
|
throw new ApiError(
|
||||||
|
409,
|
||||||
|
'CATALOG_ATTRIBUTE_BATCH_ACTIVE',
|
||||||
|
`已有类型属性批次正在执行:${active.rows[0].id}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const policy = await ensurePolicy(tx, platformId);
|
||||||
|
const candidates = await tx.query<{ id: string }>(
|
||||||
|
`SELECT category.id
|
||||||
|
FROM atlas.platform_product_categories category
|
||||||
|
LEFT JOIN atlas.platform_catalog_resource_states state
|
||||||
|
ON state.platform_id = category.platform_id
|
||||||
|
AND state.scope_key = 'attributes:' || category.id
|
||||||
|
WHERE category.platform_id = $1 AND category.is_active
|
||||||
|
AND category.external_type_id IS NOT NULL AND category.external_type_id <> '0'
|
||||||
|
AND (
|
||||||
|
$2 = 'all'
|
||||||
|
OR state.last_success_at IS NULL
|
||||||
|
OR ($2 = 'missing_expired'
|
||||||
|
AND state.last_success_at < now() - make_interval(days => $3))
|
||||||
|
OR ($2 = 'missing_stale_expired'
|
||||||
|
AND state.last_success_at <= now() - make_interval(days => $4))
|
||||||
|
)
|
||||||
|
ORDER BY category.id`,
|
||||||
|
[platformId, input.mode, policy.attribute_expire_days,
|
||||||
|
policy.attribute_fresh_days],
|
||||||
|
);
|
||||||
|
if (!candidates.rows.length) {
|
||||||
|
throw new ApiError(
|
||||||
|
422,
|
||||||
|
'CATALOG_ATTRIBUTE_BATCH_EMPTY',
|
||||||
|
'当前没有符合条件的商品类型',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const inserted = await tx.query<BatchRow>(
|
||||||
|
`INSERT INTO atlas.platform_catalog_attribute_batches
|
||||||
|
(platform_id, mode, source_revision, attribute_fresh_days,
|
||||||
|
attribute_expire_days, requested_by_user_id, idempotency_key, total_count)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||||
|
RETURNING id, platform_id, mode, status, total_count,
|
||||||
|
created_at, started_at, completed_at`,
|
||||||
|
[platformId, input.mode, source.rows[0].revision,
|
||||||
|
policy.attribute_fresh_days, policy.attribute_expire_days,
|
||||||
|
userId, idempotencyKey, candidates.rows.length],
|
||||||
|
);
|
||||||
|
const batch = inserted.rows[0];
|
||||||
|
await tx.query(
|
||||||
|
`INSERT INTO atlas.platform_catalog_attribute_batch_items
|
||||||
|
(batch_id, platform_id, category_id)
|
||||||
|
SELECT $1, $2, category_id
|
||||||
|
FROM unnest($3::uuid[]) AS category_id`,
|
||||||
|
[batch.id, platformId, candidates.rows.map((candidate) => candidate.id)],
|
||||||
|
);
|
||||||
|
return presentBatch(tx, batch);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async requireSupportedPlatform(tx: DatabaseTransaction, platformId: string) {
|
||||||
|
const platform = await tx.query<{ code: string }>(
|
||||||
|
'SELECT code FROM atlas.platforms WHERE id = $1 FOR UPDATE',
|
||||||
|
[platformId],
|
||||||
|
);
|
||||||
|
if (!platform.rows[0]) throw new ApiError(404, 'PLATFORM_NOT_FOUND', '平台不存在');
|
||||||
|
if (platform.rows[0].code.toUpperCase() !== 'OZON') {
|
||||||
|
throw new ApiError(422, 'PLATFORM_CATALOG_UNSUPPORTED', '该平台尚未接入目录同步');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async resolveTarget(tx: DatabaseTransaction, platformId: string, input: SyncInput) {
|
||||||
|
if (input.scope === 'tree') return { scopeKey: 'tree' };
|
||||||
|
if (input.scope === 'attributes') {
|
||||||
|
const category = await tx.query<{ id: string }>(
|
||||||
|
`SELECT id FROM atlas.platform_product_categories
|
||||||
|
WHERE id = $1 AND platform_id = $2 AND is_active
|
||||||
|
AND external_type_id IS NOT NULL AND external_type_id <> '0'`,
|
||||||
|
[input.category_id, platformId],
|
||||||
|
);
|
||||||
|
if (!category.rows[0]) throw new ApiError(404, 'CATEGORY_NOT_FOUND', '商品类型不存在');
|
||||||
|
return { scopeKey: `attributes:${input.category_id}`, categoryId: input.category_id };
|
||||||
|
}
|
||||||
|
const attribute = await tx.query<{ platform_product_category_id: string }>(
|
||||||
|
`SELECT platform_product_category_id
|
||||||
|
FROM atlas.platform_product_category_attributes
|
||||||
|
WHERE id = $1 AND platform_id = $2 AND is_active AND dictionary_id IS NOT NULL`,
|
||||||
|
[input.attribute_id, platformId],
|
||||||
|
);
|
||||||
|
if (!attribute.rows[0]) {
|
||||||
|
throw new ApiError(404, 'ATTRIBUTE_NOT_FOUND', '字典属性不存在');
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
scopeKey: `dictionary:${input.attribute_id}`,
|
||||||
|
categoryId: attribute.rows[0].platform_product_category_id,
|
||||||
|
attributeId: input.attribute_id,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function presentRun(run: PlatformCatalogRunRow) {
|
||||||
|
return {
|
||||||
|
id: run.id,
|
||||||
|
platform_id: run.platform_id,
|
||||||
|
scope: run.scope,
|
||||||
|
status: run.status,
|
||||||
|
trigger: run.trigger_kind,
|
||||||
|
priority: run.priority,
|
||||||
|
created_at: run.created_at,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensurePolicy(tx: DatabaseTransaction, platformId: string) {
|
||||||
|
const result = await tx.query<{
|
||||||
|
attribute_fresh_days: number; attribute_expire_days: number;
|
||||||
|
dictionary_fresh_days: number; dictionary_expire_days: number;
|
||||||
|
updated_at: Date;
|
||||||
|
}>(
|
||||||
|
`INSERT INTO atlas.platform_catalog_refresh_policies (platform_id)
|
||||||
|
VALUES ($1) ON CONFLICT (platform_id) DO UPDATE SET platform_id = EXCLUDED.platform_id
|
||||||
|
RETURNING attribute_fresh_days, attribute_expire_days,
|
||||||
|
dictionary_fresh_days, dictionary_expire_days, updated_at`,
|
||||||
|
[platformId],
|
||||||
|
);
|
||||||
|
return result.rows[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
type PolicyValues = Pick<RefreshPolicyInput,
|
||||||
|
'attribute_fresh_days' | 'attribute_expire_days' |
|
||||||
|
'dictionary_fresh_days' | 'dictionary_expire_days'>;
|
||||||
|
|
||||||
|
function samePolicy(current: PolicyValues, next: PolicyValues) {
|
||||||
|
return current.attribute_fresh_days === next.attribute_fresh_days &&
|
||||||
|
current.attribute_expire_days === next.attribute_expire_days &&
|
||||||
|
current.dictionary_fresh_days === next.dictionary_fresh_days &&
|
||||||
|
current.dictionary_expire_days === next.dictionary_expire_days;
|
||||||
|
}
|
||||||
|
|
||||||
|
function policyValues(policy: PolicyValues): PolicyValues {
|
||||||
|
return {
|
||||||
|
attribute_fresh_days: policy.attribute_fresh_days,
|
||||||
|
attribute_expire_days: policy.attribute_expire_days,
|
||||||
|
dictionary_fresh_days: policy.dictionary_fresh_days,
|
||||||
|
dictionary_expire_days: policy.dictionary_expire_days,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resourcePriority(
|
||||||
|
tx: DatabaseTransaction,
|
||||||
|
platformId: string,
|
||||||
|
scope: 'attributes' | 'dictionary',
|
||||||
|
scopeKey: string,
|
||||||
|
): Promise<'high' | 'low'> {
|
||||||
|
const policy = await ensurePolicy(tx, platformId);
|
||||||
|
const state = await tx.query<{ high_priority: boolean }>(
|
||||||
|
`SELECT state.last_success_at IS NULL
|
||||||
|
OR state.last_success_at < now() - make_interval(days => $3)
|
||||||
|
AS high_priority
|
||||||
|
FROM (SELECT 1) marker
|
||||||
|
LEFT JOIN atlas.platform_catalog_resource_states state
|
||||||
|
ON state.platform_id = $1 AND state.scope_key = $2`,
|
||||||
|
[platformId, scopeKey, scope === 'attributes'
|
||||||
|
? policy.attribute_expire_days
|
||||||
|
: policy.dictionary_expire_days],
|
||||||
|
);
|
||||||
|
return state.rows[0]?.high_priority === true ? 'high' : 'low';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function presentBatch(tx: DatabaseTransaction, batch: BatchRow) {
|
||||||
|
const counts = await tx.query<{
|
||||||
|
pending_count: string; running_count: string; succeeded_count: string;
|
||||||
|
failed_count: string; cancelled_count: string;
|
||||||
|
}>(
|
||||||
|
`SELECT
|
||||||
|
count(*) FILTER (WHERE item.run_id IS NULL OR run.status = 'queued')::text AS pending_count,
|
||||||
|
count(*) FILTER (WHERE run.status = 'running')::text AS running_count,
|
||||||
|
count(*) FILTER (WHERE run.status = 'succeeded')::text AS succeeded_count,
|
||||||
|
count(*) FILTER (WHERE run.status = 'failed')::text AS failed_count,
|
||||||
|
count(*) FILTER (WHERE run.status = 'cancelled')::text AS cancelled_count
|
||||||
|
FROM atlas.platform_catalog_attribute_batch_items item
|
||||||
|
LEFT JOIN atlas.platform_catalog_sync_runs run ON run.id = item.run_id
|
||||||
|
WHERE item.batch_id = $1`,
|
||||||
|
[batch.id],
|
||||||
|
);
|
||||||
|
const progress = counts.rows[0];
|
||||||
|
return {
|
||||||
|
...batch,
|
||||||
|
pending_count: Number(progress?.pending_count ?? batch.total_count),
|
||||||
|
running_count: Number(progress?.running_count ?? 0),
|
||||||
|
succeeded_count: Number(progress?.succeeded_count ?? 0),
|
||||||
|
failed_count: Number(progress?.failed_count ?? 0),
|
||||||
|
cancelled_count: Number(progress?.cancelled_count ?? 0),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,211 @@
|
|||||||
|
import {
|
||||||
|
Body, Controller, Get, Headers, Inject, Param, Post, Put, Query,
|
||||||
|
Req, UseGuards, HttpCode,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
|
||||||
|
import type { Database } from '@atlas/database';
|
||||||
|
import type { AuthenticatedRequest } from '../auth/auth.guard.js';
|
||||||
|
import { ApiError } from '../common/api-error.js';
|
||||||
|
import { DATABASE } from '../database/database.module.js';
|
||||||
|
import { PlatformCatalogAdminCommands } from './platform-catalog-admin.commands.js';
|
||||||
|
import {
|
||||||
|
attributeBatchInputSchema, parseInput, parseListLimit, parseUuid,
|
||||||
|
refreshPolicyInputSchema, sourceInputSchema, syncInputSchema,
|
||||||
|
} from './platform-catalog-admin.models.js';
|
||||||
|
import { PlatformCatalogAdminReads } from './platform-catalog-admin.reads.js';
|
||||||
|
import { hasSystemAdminGrant, SystemAdminGuard } from './system-admin.guard.js';
|
||||||
|
|
||||||
|
@ApiTags('System')
|
||||||
|
@ApiBearerAuth('access-token')
|
||||||
|
@Controller('system')
|
||||||
|
export class SystemAccessController {
|
||||||
|
public constructor(@Inject(DATABASE) private readonly database: Database) {}
|
||||||
|
|
||||||
|
@Get('access')
|
||||||
|
public async access(@Req() request: AuthenticatedRequest) {
|
||||||
|
return { data: { is_system_admin: await hasSystemAdminGrant(this.database, request.auth.userId) } };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ApiTags('System platform catalog')
|
||||||
|
@ApiBearerAuth('access-token')
|
||||||
|
@UseGuards(SystemAdminGuard)
|
||||||
|
@Controller('system/platform-catalog')
|
||||||
|
export class PlatformCatalogAdminController {
|
||||||
|
public constructor(
|
||||||
|
@Inject(PlatformCatalogAdminReads) private readonly reads: PlatformCatalogAdminReads,
|
||||||
|
@Inject(PlatformCatalogAdminCommands) private readonly commands: PlatformCatalogAdminCommands,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
@Get('platforms')
|
||||||
|
public async platforms() {
|
||||||
|
return { data: await this.reads.platforms() };
|
||||||
|
}
|
||||||
|
|
||||||
|
@Put('platforms/:platformId/source')
|
||||||
|
public async configureSource(
|
||||||
|
@Param('platformId') platformId: string,
|
||||||
|
@Req() request: AuthenticatedRequest,
|
||||||
|
@Body() body: unknown,
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
data: await this.commands.configureSource(
|
||||||
|
parseUuid(platformId, 'platform_id'),
|
||||||
|
request.auth.userId,
|
||||||
|
parseInput(sourceInputSchema, body),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('platforms/:platformId/categories')
|
||||||
|
public async categories(@Param('platformId') platformId: string) {
|
||||||
|
return { data: await this.reads.categories(parseUuid(platformId, 'platform_id')) };
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('platforms/:platformId/refresh-policy')
|
||||||
|
public async refreshPolicy(@Param('platformId') platformId: string) {
|
||||||
|
return {
|
||||||
|
data: await this.reads.refreshPolicy(parseUuid(platformId, 'platform_id')),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@Put('platforms/:platformId/refresh-policy')
|
||||||
|
public async updateRefreshPolicy(
|
||||||
|
@Param('platformId') platformId: string,
|
||||||
|
@Req() request: AuthenticatedRequest,
|
||||||
|
@Body() body: unknown,
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
data: await this.commands.updateRefreshPolicy(
|
||||||
|
parseUuid(platformId, 'platform_id'),
|
||||||
|
request.auth.userId,
|
||||||
|
parseInput(refreshPolicyInputSchema, body),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('platforms/:platformId/categories/:categoryId/attributes')
|
||||||
|
public async attributes(
|
||||||
|
@Param('platformId') platformId: string,
|
||||||
|
@Param('categoryId') categoryId: string,
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
data: await this.reads.attributes(
|
||||||
|
parseUuid(platformId, 'platform_id'),
|
||||||
|
parseUuid(categoryId, 'category_id'),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('platforms/:platformId/attributes/:attributeId/values')
|
||||||
|
public async values(
|
||||||
|
@Param('platformId') platformId: string,
|
||||||
|
@Param('attributeId') attributeId: string,
|
||||||
|
@Query('q') keyword: string | undefined,
|
||||||
|
@Query('page') pageValue: string | undefined,
|
||||||
|
) {
|
||||||
|
const page = pageValue === undefined ? 1 : Number(pageValue);
|
||||||
|
if (!Number.isInteger(page) || page < 1 || page > 100000) {
|
||||||
|
throw new ApiError(400, 'VALIDATION_FAILED', 'page 必须为正整数');
|
||||||
|
}
|
||||||
|
if (keyword && keyword.length > 120) {
|
||||||
|
throw new ApiError(400, 'VALIDATION_FAILED', '搜索词不能超过 120 字符');
|
||||||
|
}
|
||||||
|
return this.reads.values(
|
||||||
|
parseUuid(platformId, 'platform_id'),
|
||||||
|
parseUuid(attributeId, 'attribute_id'),
|
||||||
|
keyword,
|
||||||
|
page,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('platforms/:platformId/syncs')
|
||||||
|
@HttpCode(202)
|
||||||
|
public async sync(
|
||||||
|
@Param('platformId') platformId: string,
|
||||||
|
@Headers('idempotency-key') idempotencyKey: string | undefined,
|
||||||
|
@Req() request: AuthenticatedRequest,
|
||||||
|
@Body() body: unknown,
|
||||||
|
) {
|
||||||
|
if (!idempotencyKey) {
|
||||||
|
throw new ApiError(400, 'IDEMPOTENCY_KEY_REQUIRED', '请提供 Idempotency-Key');
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
data: await this.commands.sync(
|
||||||
|
parseUuid(platformId, 'platform_id'),
|
||||||
|
request.auth.userId,
|
||||||
|
parseUuid(idempotencyKey, 'Idempotency-Key'),
|
||||||
|
parseInput(syncInputSchema, body),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('platforms/:platformId/syncs')
|
||||||
|
public async syncs(
|
||||||
|
@Param('platformId') platformId: string,
|
||||||
|
@Query('limit') limit: string | undefined,
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
data: await this.reads.runs(
|
||||||
|
parseUuid(platformId, 'platform_id'),
|
||||||
|
parseListLimit(limit),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('platforms/:platformId/attribute-sync-summary')
|
||||||
|
public async attributeSyncSummary(@Param('platformId') platformId: string) {
|
||||||
|
return {
|
||||||
|
data: await this.reads.attributeSyncSummary(
|
||||||
|
parseUuid(platformId, 'platform_id'),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('platforms/:platformId/attribute-sync-batches')
|
||||||
|
@HttpCode(202)
|
||||||
|
public async createAttributeBatch(
|
||||||
|
@Param('platformId') platformId: string,
|
||||||
|
@Headers('idempotency-key') idempotencyKey: string | undefined,
|
||||||
|
@Req() request: AuthenticatedRequest,
|
||||||
|
@Body() body: unknown,
|
||||||
|
) {
|
||||||
|
if (!idempotencyKey) {
|
||||||
|
throw new ApiError(400, 'IDEMPOTENCY_KEY_REQUIRED', '请提供 Idempotency-Key');
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
data: await this.commands.createAttributeBatch(
|
||||||
|
parseUuid(platformId, 'platform_id'),
|
||||||
|
request.auth.userId,
|
||||||
|
parseUuid(idempotencyKey, 'Idempotency-Key'),
|
||||||
|
parseInput(attributeBatchInputSchema, body),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('platforms/:platformId/attribute-sync-batches')
|
||||||
|
public async attributeBatches(
|
||||||
|
@Param('platformId') platformId: string,
|
||||||
|
@Query('limit') limit: string | undefined,
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
data: await this.reads.attributeBatches(
|
||||||
|
parseUuid(platformId, 'platform_id'),
|
||||||
|
parseListLimit(limit),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('platforms/:platformId/attribute-sync-batches/:batchId')
|
||||||
|
public async attributeBatch(
|
||||||
|
@Param('platformId') platformId: string,
|
||||||
|
@Param('batchId') batchId: string,
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
data: await this.reads.attributeBatch(
|
||||||
|
parseUuid(platformId, 'platform_id'),
|
||||||
|
parseUuid(batchId, 'batch_id'),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
import { ApiError } from '../common/api-error.js';
|
||||||
|
|
||||||
|
export const sourceInputSchema = z.object({
|
||||||
|
client_id: z.string().trim().min(1).max(128),
|
||||||
|
api_key: z.string().trim().min(1).max(2048),
|
||||||
|
}).strict();
|
||||||
|
|
||||||
|
export const syncInputSchema = z.discriminatedUnion('scope', [
|
||||||
|
z.object({ scope: z.literal('tree') }).strict(),
|
||||||
|
z.object({ scope: z.literal('attributes'), category_id: z.uuid() }).strict(),
|
||||||
|
z.object({ scope: z.literal('dictionary'), attribute_id: z.uuid() }).strict(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const refreshPolicyInputSchema = z.object({
|
||||||
|
attribute_fresh_days: z.number().int().min(1).max(365),
|
||||||
|
attribute_expire_days: z.number().int().min(1).max(365),
|
||||||
|
dictionary_fresh_days: z.number().int().min(1).max(365),
|
||||||
|
dictionary_expire_days: z.number().int().min(1).max(365),
|
||||||
|
}).strict().superRefine((value, context) => {
|
||||||
|
if (value.attribute_expire_days <= value.attribute_fresh_days) {
|
||||||
|
context.addIssue({
|
||||||
|
code: 'custom',
|
||||||
|
path: ['attribute_expire_days'],
|
||||||
|
message: '类型属性过期天数必须大于新鲜天数',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (value.dictionary_expire_days <= value.dictionary_fresh_days) {
|
||||||
|
context.addIssue({
|
||||||
|
code: 'custom',
|
||||||
|
path: ['dictionary_expire_days'],
|
||||||
|
message: '属性字典过期天数必须大于新鲜天数',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export const attributeBatchInputSchema = z.object({
|
||||||
|
mode: z.enum(['missing_expired', 'missing_stale_expired', 'all']),
|
||||||
|
}).strict();
|
||||||
|
|
||||||
|
export type SyncInput = z.infer<typeof syncInputSchema>;
|
||||||
|
export type SourceInput = z.infer<typeof sourceInputSchema>;
|
||||||
|
export type RefreshPolicyInput = z.infer<typeof refreshPolicyInputSchema>;
|
||||||
|
export type AttributeBatchInput = z.infer<typeof attributeBatchInputSchema>;
|
||||||
|
|
||||||
|
export function parseInput<T>(schema: z.ZodType<T>, input: unknown): T {
|
||||||
|
const parsed = schema.safeParse(input);
|
||||||
|
if (parsed.success) return parsed.data;
|
||||||
|
throw new ApiError(400, 'VALIDATION_FAILED', '请求参数不合法');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseUuid(value: string, field: string): string {
|
||||||
|
if (z.uuid().safeParse(value).success) return value;
|
||||||
|
throw new ApiError(400, 'VALIDATION_FAILED', `${field} 不是有效 UUID`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseListLimit(value: unknown, defaultValue = 20): number {
|
||||||
|
if (value === undefined) return defaultValue;
|
||||||
|
const count = Number(value);
|
||||||
|
if (Number.isInteger(count) && count >= 1 && count <= 50) return count;
|
||||||
|
throw new ApiError(400, 'VALIDATION_FAILED', 'limit 必须在 1 到 50 之间');
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import {
|
||||||
|
PlatformCatalogAdminController, SystemAccessController,
|
||||||
|
} from './platform-catalog-admin.controller.js';
|
||||||
|
import { PlatformCatalogAdminCommands } from './platform-catalog-admin.commands.js';
|
||||||
|
import { PlatformCatalogAdminReads } from './platform-catalog-admin.reads.js';
|
||||||
|
import { SystemAdminGuard } from './system-admin.guard.js';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [SystemAccessController, PlatformCatalogAdminController],
|
||||||
|
providers: [SystemAdminGuard, PlatformCatalogAdminCommands, PlatformCatalogAdminReads],
|
||||||
|
})
|
||||||
|
export class PlatformCatalogAdminModule {}
|
||||||
@@ -0,0 +1,328 @@
|
|||||||
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
|
import type { Database } from '@atlas/database';
|
||||||
|
import { ApiError } from '../common/api-error.js';
|
||||||
|
import { DATABASE } from '../database/database.module.js';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class PlatformCatalogAdminReads {
|
||||||
|
public constructor(@Inject(DATABASE) private readonly database: Database) {}
|
||||||
|
|
||||||
|
public async platforms() {
|
||||||
|
const rows = await this.database.query<{
|
||||||
|
id: string; code: string; name: string; status: string;
|
||||||
|
client_id: string | null; source_updated_at: Date | null;
|
||||||
|
category_count: string; last_tree_sync_at: Date | null;
|
||||||
|
next_tree_sync_at: Date | null; last_error: string | null;
|
||||||
|
}>(
|
||||||
|
`SELECT p.id, p.code, p.name, p.status,
|
||||||
|
source.client_id, source.updated_at AS source_updated_at,
|
||||||
|
(SELECT count(*) FROM atlas.platform_product_categories category
|
||||||
|
WHERE category.platform_id = p.id AND category.is_active)::text AS category_count,
|
||||||
|
tree_state.last_success_at AS last_tree_sync_at,
|
||||||
|
tree_state.last_success_at + interval '7 days' AS next_tree_sync_at,
|
||||||
|
tree_state.last_error_message AS last_error
|
||||||
|
FROM atlas.platforms p
|
||||||
|
LEFT JOIN atlas.platform_catalog_sources source ON source.platform_id = p.id
|
||||||
|
LEFT JOIN atlas.platform_catalog_resource_states tree_state
|
||||||
|
ON tree_state.platform_id = p.id AND tree_state.scope_key = 'tree'
|
||||||
|
ORDER BY CASE WHEN upper(p.code) = 'OZON' THEN 0 ELSE 1 END, p.name`,
|
||||||
|
);
|
||||||
|
return rows.rows.map((row) => ({
|
||||||
|
id: row.id,
|
||||||
|
code: row.code,
|
||||||
|
name: row.name,
|
||||||
|
status: row.status,
|
||||||
|
source: {
|
||||||
|
configured: row.client_id !== null,
|
||||||
|
client_id_hint: row.client_id ? `••••${row.client_id.slice(-4)}` : null,
|
||||||
|
updated_at: row.source_updated_at,
|
||||||
|
},
|
||||||
|
summary: {
|
||||||
|
category_count: Number(row.category_count),
|
||||||
|
last_tree_sync_at: row.last_tree_sync_at,
|
||||||
|
next_tree_sync_at: row.next_tree_sync_at,
|
||||||
|
last_error: row.last_error,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
public async categories(platformId: string) {
|
||||||
|
await this.requirePlatform(platformId);
|
||||||
|
const result = await this.database.query(
|
||||||
|
`SELECT category.id, category.parent_id, category.external_category_id,
|
||||||
|
category.external_type_id, category.name, category.path,
|
||||||
|
category.is_active, category.synced_at,
|
||||||
|
state.last_success_at AS attribute_last_success_at,
|
||||||
|
state.last_error_message AS attribute_last_error,
|
||||||
|
state.last_error_at AS attribute_last_error_at,
|
||||||
|
CASE
|
||||||
|
WHEN category.external_type_id IS NULL OR category.external_type_id = '0'
|
||||||
|
THEN NULL
|
||||||
|
WHEN state.last_success_at IS NULL THEN 'missing'
|
||||||
|
WHEN state.last_success_at > now() -
|
||||||
|
make_interval(days => COALESCE(policy.attribute_fresh_days, 14))
|
||||||
|
THEN 'fresh'
|
||||||
|
WHEN state.last_success_at >= now() -
|
||||||
|
make_interval(days => COALESCE(policy.attribute_expire_days, 21))
|
||||||
|
THEN 'stale'
|
||||||
|
ELSE 'expired'
|
||||||
|
END AS attribute_cache_status
|
||||||
|
FROM atlas.platform_product_categories category
|
||||||
|
LEFT JOIN atlas.platform_catalog_resource_states state
|
||||||
|
ON state.platform_id = category.platform_id
|
||||||
|
AND state.scope_key = 'attributes:' || category.id
|
||||||
|
LEFT JOIN atlas.platform_catalog_refresh_policies policy
|
||||||
|
ON policy.platform_id = category.platform_id
|
||||||
|
WHERE category.platform_id = $1 ORDER BY path, id LIMIT 20000`,
|
||||||
|
[platformId],
|
||||||
|
);
|
||||||
|
return result.rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async refreshPolicy(platformId: string) {
|
||||||
|
await this.requirePlatform(platformId);
|
||||||
|
const result = await this.database.query<{
|
||||||
|
attribute_fresh_days: number; attribute_expire_days: number;
|
||||||
|
dictionary_fresh_days: number; dictionary_expire_days: number;
|
||||||
|
updated_at: Date | null;
|
||||||
|
}>(
|
||||||
|
`SELECT COALESCE(policy.attribute_fresh_days, 14)::int AS attribute_fresh_days,
|
||||||
|
COALESCE(policy.attribute_expire_days, 21)::int AS attribute_expire_days,
|
||||||
|
COALESCE(policy.dictionary_fresh_days, 14)::int AS dictionary_fresh_days,
|
||||||
|
COALESCE(policy.dictionary_expire_days, 21)::int AS dictionary_expire_days,
|
||||||
|
policy.updated_at
|
||||||
|
FROM atlas.platforms platform
|
||||||
|
LEFT JOIN atlas.platform_catalog_refresh_policies policy
|
||||||
|
ON policy.platform_id = platform.id
|
||||||
|
WHERE platform.id = $1`,
|
||||||
|
[platformId],
|
||||||
|
);
|
||||||
|
return result.rows[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
public async attributeSyncSummary(platformId: string) {
|
||||||
|
await this.requirePlatform(platformId);
|
||||||
|
const result = await this.database.query<{
|
||||||
|
total: string; fresh: string; stale: string; expired: string; missing: string;
|
||||||
|
active_batch_id: string | null; evaluated_at: Date;
|
||||||
|
}>(
|
||||||
|
`WITH policy AS (
|
||||||
|
SELECT COALESCE(refresh.attribute_fresh_days, 14)::int AS fresh_days,
|
||||||
|
COALESCE(refresh.attribute_expire_days, 21)::int AS expire_days
|
||||||
|
FROM atlas.platforms platform
|
||||||
|
LEFT JOIN atlas.platform_catalog_refresh_policies refresh
|
||||||
|
ON refresh.platform_id = platform.id
|
||||||
|
WHERE platform.id = $1
|
||||||
|
), resources AS (
|
||||||
|
SELECT state.last_success_at, policy.fresh_days, policy.expire_days
|
||||||
|
FROM atlas.platform_product_categories category
|
||||||
|
CROSS JOIN policy
|
||||||
|
LEFT JOIN atlas.platform_catalog_resource_states state
|
||||||
|
ON state.platform_id = category.platform_id
|
||||||
|
AND state.scope_key = 'attributes:' || category.id
|
||||||
|
WHERE category.platform_id = $1 AND category.is_active
|
||||||
|
AND category.external_type_id IS NOT NULL AND category.external_type_id <> '0'
|
||||||
|
)
|
||||||
|
SELECT count(*)::text AS total,
|
||||||
|
count(*) FILTER (
|
||||||
|
WHERE last_success_at > now() - make_interval(days => fresh_days)
|
||||||
|
)::text AS fresh,
|
||||||
|
count(*) FILTER (
|
||||||
|
WHERE last_success_at <= now() - make_interval(days => fresh_days)
|
||||||
|
AND last_success_at >= now() - make_interval(days => expire_days)
|
||||||
|
)::text AS stale,
|
||||||
|
count(*) FILTER (
|
||||||
|
WHERE last_success_at < now() - make_interval(days => expire_days)
|
||||||
|
)::text AS expired,
|
||||||
|
count(*) FILTER (WHERE last_success_at IS NULL)::text AS missing,
|
||||||
|
(SELECT id FROM atlas.platform_catalog_attribute_batches batch
|
||||||
|
WHERE batch.platform_id = $1 AND batch.status IN ('queued', 'running')
|
||||||
|
LIMIT 1) AS active_batch_id,
|
||||||
|
now() AS evaluated_at
|
||||||
|
FROM resources`,
|
||||||
|
[platformId],
|
||||||
|
);
|
||||||
|
const row = result.rows[0];
|
||||||
|
return {
|
||||||
|
total: Number(row?.total ?? 0),
|
||||||
|
fresh: Number(row?.fresh ?? 0),
|
||||||
|
stale: Number(row?.stale ?? 0),
|
||||||
|
expired: Number(row?.expired ?? 0),
|
||||||
|
missing: Number(row?.missing ?? 0),
|
||||||
|
active_batch_id: row?.active_batch_id ?? null,
|
||||||
|
evaluated_at: row?.evaluated_at ?? new Date(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public async attributes(platformId: string, categoryId: string) {
|
||||||
|
await this.requireCategory(platformId, categoryId);
|
||||||
|
const result = await this.database.query(
|
||||||
|
`SELECT attribute.id, attribute.platform_attribute_id, attribute.complex_id,
|
||||||
|
attribute.name, attribute.description, attribute.group_id, attribute.group_name,
|
||||||
|
attribute.value_type, attribute.is_required, attribute.is_aspect,
|
||||||
|
attribute.is_collection, attribute.max_value_count, attribute.dictionary_id,
|
||||||
|
attribute.category_dependent, attribute.is_active, attribute.synced_at,
|
||||||
|
schema_state.last_success_at,
|
||||||
|
CASE
|
||||||
|
WHEN schema_state.last_success_at IS NULL THEN 'missing'
|
||||||
|
WHEN schema_state.last_success_at > now() -
|
||||||
|
make_interval(days => COALESCE(policy.attribute_fresh_days, 14))
|
||||||
|
THEN 'fresh'
|
||||||
|
WHEN schema_state.last_success_at >= now() -
|
||||||
|
make_interval(days => COALESCE(policy.attribute_expire_days, 21))
|
||||||
|
THEN 'stale'
|
||||||
|
ELSE 'expired'
|
||||||
|
END AS cache_status,
|
||||||
|
schema_state.last_error_message,
|
||||||
|
schema_state.last_error_at,
|
||||||
|
dictionary_state.last_success_at AS dictionary_last_success_at,
|
||||||
|
CASE
|
||||||
|
WHEN attribute.dictionary_id IS NULL THEN NULL
|
||||||
|
WHEN dictionary_state.last_success_at IS NULL THEN 'missing'
|
||||||
|
WHEN dictionary_state.last_success_at > now() -
|
||||||
|
make_interval(days => COALESCE(policy.dictionary_fresh_days, 14))
|
||||||
|
THEN 'fresh'
|
||||||
|
WHEN dictionary_state.last_success_at >= now() -
|
||||||
|
make_interval(days => COALESCE(policy.dictionary_expire_days, 21))
|
||||||
|
THEN 'stale'
|
||||||
|
ELSE 'expired'
|
||||||
|
END AS dictionary_cache_status,
|
||||||
|
dictionary_state.is_complete AS dictionary_is_complete,
|
||||||
|
dictionary_state.last_error_message AS dictionary_last_error_message,
|
||||||
|
dictionary_state.last_error_at AS dictionary_last_error_at,
|
||||||
|
(SELECT count(*)::int FROM atlas.platform_catalog_dictionary_values value
|
||||||
|
WHERE value.attribute_id = attribute.id AND value.is_active) AS dictionary_value_count
|
||||||
|
FROM atlas.platform_product_category_attributes attribute
|
||||||
|
LEFT JOIN atlas.platform_catalog_resource_states schema_state
|
||||||
|
ON schema_state.platform_id = attribute.platform_id
|
||||||
|
AND schema_state.scope_key = 'attributes:' || attribute.platform_product_category_id
|
||||||
|
LEFT JOIN atlas.platform_catalog_resource_states dictionary_state
|
||||||
|
ON dictionary_state.platform_id = attribute.platform_id
|
||||||
|
AND dictionary_state.scope_key = 'dictionary:' || attribute.id
|
||||||
|
LEFT JOIN atlas.platform_catalog_refresh_policies policy
|
||||||
|
ON policy.platform_id = attribute.platform_id
|
||||||
|
WHERE attribute.platform_id = $1 AND attribute.platform_product_category_id = $2
|
||||||
|
ORDER BY attribute.is_required DESC, attribute.group_name NULLS LAST,
|
||||||
|
attribute.name, attribute.platform_attribute_id`,
|
||||||
|
[platformId, categoryId],
|
||||||
|
);
|
||||||
|
return result.rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async values(
|
||||||
|
platformId: string,
|
||||||
|
attributeId: string,
|
||||||
|
keyword: string | undefined,
|
||||||
|
page: number,
|
||||||
|
) {
|
||||||
|
const attribute = await this.database.query<{ dictionary_id: string | null }>(
|
||||||
|
`SELECT dictionary_id FROM atlas.platform_product_category_attributes
|
||||||
|
WHERE id = $1 AND platform_id = $2 AND is_active`,
|
||||||
|
[attributeId, platformId],
|
||||||
|
);
|
||||||
|
if (!attribute.rows[0]) throw new ApiError(404, 'ATTRIBUTE_NOT_FOUND', '属性不存在');
|
||||||
|
if (!attribute.rows[0].dictionary_id) {
|
||||||
|
throw new ApiError(422, 'ATTRIBUTE_DICTIONARY_UNAVAILABLE', '该属性没有字典候选值');
|
||||||
|
}
|
||||||
|
const q = keyword?.trim() ?? '';
|
||||||
|
const [items, total, lastRun] = await Promise.all([
|
||||||
|
this.database.query(
|
||||||
|
`SELECT id, external_value_id, value, info, picture
|
||||||
|
FROM atlas.platform_catalog_dictionary_values
|
||||||
|
WHERE attribute_id = $1 AND is_active
|
||||||
|
AND ($2 = '' OR value ILIKE '%' || $2 || '%' OR external_value_id = $2)
|
||||||
|
ORDER BY value, external_value_id LIMIT 100 OFFSET $3`,
|
||||||
|
[attributeId, q, (page - 1) * 100],
|
||||||
|
),
|
||||||
|
this.database.query<{ count: string }>(
|
||||||
|
`SELECT count(*)::text AS count FROM atlas.platform_catalog_dictionary_values
|
||||||
|
WHERE attribute_id = $1 AND is_active
|
||||||
|
AND ($2 = '' OR value ILIKE '%' || $2 || '%' OR external_value_id = $2)`,
|
||||||
|
[attributeId, q],
|
||||||
|
),
|
||||||
|
this.database.query<{ is_complete: boolean }>(
|
||||||
|
`SELECT is_complete FROM atlas.platform_catalog_resource_states
|
||||||
|
WHERE platform_id = $1 AND scope_key = $2`,
|
||||||
|
[platformId, `dictionary:${attributeId}`],
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
return {
|
||||||
|
data: items.rows,
|
||||||
|
meta: {
|
||||||
|
page,
|
||||||
|
page_size: 100,
|
||||||
|
total: Number(total.rows[0]?.count ?? 0),
|
||||||
|
is_complete: lastRun.rows[0]?.is_complete ?? false,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public async runs(platformId: string, limit: number) {
|
||||||
|
await this.requirePlatform(platformId);
|
||||||
|
const result = await this.database.query(
|
||||||
|
`SELECT id, platform_id, scope, scope_key, category_id, attribute_id,
|
||||||
|
trigger_kind AS trigger, priority, status, result, error_code, error_message,
|
||||||
|
attempt_count, created_at, started_at, completed_at
|
||||||
|
FROM atlas.platform_catalog_sync_runs
|
||||||
|
WHERE platform_id = $1 ORDER BY created_at DESC LIMIT $2`,
|
||||||
|
[platformId, limit],
|
||||||
|
);
|
||||||
|
return result.rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async attributeBatches(platformId: string, limit: number) {
|
||||||
|
await this.requirePlatform(platformId);
|
||||||
|
const result = await this.database.query(
|
||||||
|
`${batchProgressSelect}
|
||||||
|
WHERE batch.platform_id = $1
|
||||||
|
GROUP BY batch.id
|
||||||
|
ORDER BY batch.created_at DESC LIMIT $2`,
|
||||||
|
[platformId, limit],
|
||||||
|
);
|
||||||
|
return result.rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async attributeBatch(platformId: string, batchId: string) {
|
||||||
|
await this.requirePlatform(platformId);
|
||||||
|
const result = await this.database.query(
|
||||||
|
`${batchProgressSelect}
|
||||||
|
WHERE batch.platform_id = $1 AND batch.id = $2
|
||||||
|
GROUP BY batch.id`,
|
||||||
|
[platformId, batchId],
|
||||||
|
);
|
||||||
|
if (!result.rows[0]) {
|
||||||
|
throw new ApiError(404, 'CATALOG_ATTRIBUTE_BATCH_NOT_FOUND', '类型属性同步批次不存在');
|
||||||
|
}
|
||||||
|
return result.rows[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
public async requirePlatform(platformId: string) {
|
||||||
|
const result = await this.database.query<{ code: string }>(
|
||||||
|
'SELECT code FROM atlas.platforms WHERE id = $1',
|
||||||
|
[platformId],
|
||||||
|
);
|
||||||
|
if (!result.rows[0]) throw new ApiError(404, 'PLATFORM_NOT_FOUND', '平台不存在');
|
||||||
|
return result.rows[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
public async requireCategory(platformId: string, categoryId: string) {
|
||||||
|
const result = await this.database.query(
|
||||||
|
`SELECT 1 FROM atlas.platform_product_categories WHERE id = $1 AND platform_id = $2`,
|
||||||
|
[categoryId, platformId],
|
||||||
|
);
|
||||||
|
if (!result.rows[0]) throw new ApiError(404, 'CATEGORY_NOT_FOUND', '类目不属于当前平台');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const batchProgressSelect = `
|
||||||
|
SELECT batch.id, batch.platform_id, batch.mode, batch.status, batch.total_count,
|
||||||
|
count(*) FILTER (WHERE item.run_id IS NULL OR run.status = 'queued')::int
|
||||||
|
AS pending_count,
|
||||||
|
count(*) FILTER (WHERE run.status = 'running')::int AS running_count,
|
||||||
|
count(*) FILTER (WHERE run.status = 'succeeded')::int AS succeeded_count,
|
||||||
|
count(*) FILTER (WHERE run.status = 'failed')::int AS failed_count,
|
||||||
|
count(*) FILTER (WHERE run.status = 'cancelled')::int AS cancelled_count,
|
||||||
|
batch.created_at, batch.started_at, batch.completed_at
|
||||||
|
FROM atlas.platform_catalog_attribute_batches batch
|
||||||
|
JOIN atlas.platform_catalog_attribute_batch_items item ON item.batch_id = batch.id
|
||||||
|
LEFT JOIN atlas.platform_catalog_sync_runs run ON run.id = item.run_id`;
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { CanActivate, ExecutionContext, Inject, Injectable } from '@nestjs/common';
|
||||||
|
import type { Database } from '@atlas/database';
|
||||||
|
import type { AuthenticatedRequest } from '../auth/auth.guard.js';
|
||||||
|
import { ApiError } from '../common/api-error.js';
|
||||||
|
import { DATABASE } from '../database/database.module.js';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class SystemAdminGuard implements CanActivate {
|
||||||
|
public constructor(@Inject(DATABASE) private readonly database: Database) {}
|
||||||
|
|
||||||
|
public async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||||
|
const request = context.switchToHttp().getRequest<AuthenticatedRequest>();
|
||||||
|
if (await hasSystemAdminGrant(this.database, request.auth.userId)) return true;
|
||||||
|
throw new ApiError(403, 'SYSTEM_ADMIN_REQUIRED', '需要系统管理员权限');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 授权实时查库;撤销后旧 JWT 不能继续使用后台接口。 */
|
||||||
|
export async function hasSystemAdminGrant(database: Database, userId: string): Promise<boolean> {
|
||||||
|
const result = await database.query(
|
||||||
|
`SELECT 1 FROM atlas.system_admin_grants grant_row
|
||||||
|
JOIN atlas.users user_row ON user_row.id = grant_row.user_id
|
||||||
|
WHERE grant_row.user_id = $1 AND grant_row.revoked_at IS NULL
|
||||||
|
AND user_row.status = 'active'`,
|
||||||
|
[userId],
|
||||||
|
);
|
||||||
|
return !!result.rowCount;
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import type { DatabaseTransaction } from '@atlas/database';
|
||||||
|
|
||||||
|
export type PlatformCatalogRunRow = {
|
||||||
|
id: string;
|
||||||
|
platform_id: string;
|
||||||
|
scope: string;
|
||||||
|
scope_key: string;
|
||||||
|
status: string;
|
||||||
|
trigger_kind: string;
|
||||||
|
priority: 'high' | 'low';
|
||||||
|
created_at: Date;
|
||||||
|
};
|
||||||
|
|
||||||
|
type EnqueuePlatformCatalogRunInput = {
|
||||||
|
platformId: string;
|
||||||
|
scope: 'tree' | 'attributes' | 'dictionary' | 'dictionary_search';
|
||||||
|
scopeKey: string;
|
||||||
|
categoryId?: string;
|
||||||
|
attributeId?: string;
|
||||||
|
searchKey?: string;
|
||||||
|
sourceRevision: number;
|
||||||
|
trigger: 'manual' | 'scheduled' | 'weekly' | 'source_configured' |
|
||||||
|
'admin_batch' | 'user_access' | 'publish';
|
||||||
|
priority: 'high' | 'low';
|
||||||
|
userId?: string;
|
||||||
|
idempotencyKey?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 同一事务写入平台任务和 Outbox;同一作用域的活动任务直接复用。 */
|
||||||
|
export async function enqueuePlatformCatalogRun(
|
||||||
|
tx: DatabaseTransaction,
|
||||||
|
input: EnqueuePlatformCatalogRunInput,
|
||||||
|
): Promise<PlatformCatalogRunRow> {
|
||||||
|
const active = await tx.query<PlatformCatalogRunRow>(
|
||||||
|
`SELECT id, platform_id, scope, scope_key, status, trigger_kind, priority, created_at
|
||||||
|
FROM atlas.platform_catalog_sync_runs
|
||||||
|
WHERE platform_id = $1 AND scope_key = $2 AND status IN ('queued', 'running')
|
||||||
|
LIMIT 1`,
|
||||||
|
[input.platformId, input.scopeKey],
|
||||||
|
);
|
||||||
|
if (active.rows[0]) {
|
||||||
|
if (input.priority === 'high' && active.rows[0].priority === 'low') {
|
||||||
|
const promoted = await tx.query<PlatformCatalogRunRow>(
|
||||||
|
`UPDATE atlas.platform_catalog_sync_runs
|
||||||
|
SET priority = 'high', promoted_at = now()
|
||||||
|
WHERE id = $1 AND status = 'queued'
|
||||||
|
RETURNING id, platform_id, scope, scope_key, status,
|
||||||
|
trigger_kind, priority, created_at`,
|
||||||
|
[active.rows[0].id],
|
||||||
|
);
|
||||||
|
return promoted.rows[0] ?? active.rows[0];
|
||||||
|
}
|
||||||
|
return active.rows[0];
|
||||||
|
}
|
||||||
|
const inserted = await tx.query<PlatformCatalogRunRow>(
|
||||||
|
`INSERT INTO atlas.platform_catalog_sync_runs
|
||||||
|
(platform_id, scope, scope_key, category_id, attribute_id, search_key,
|
||||||
|
trigger_kind, priority, source_revision, requested_by_user_id, idempotency_key)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
|
||||||
|
RETURNING id, platform_id, scope, scope_key, status, trigger_kind, priority, created_at`,
|
||||||
|
[input.platformId, input.scope, input.scopeKey, input.categoryId ?? null,
|
||||||
|
input.attributeId ?? null, input.searchKey ?? null, input.trigger, input.priority,
|
||||||
|
input.sourceRevision, input.userId ?? null, input.idempotencyKey ?? null],
|
||||||
|
);
|
||||||
|
const run = inserted.rows[0];
|
||||||
|
await tx.query(
|
||||||
|
`INSERT INTO atlas.platform_catalog_outbox_events (run_id, platform_id) VALUES ($1, $2)`,
|
||||||
|
[run.id, input.platformId],
|
||||||
|
);
|
||||||
|
return run;
|
||||||
|
}
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
import type { CommissionImportCategory } from '@atlas/contracts';
|
||||||
|
import { ApiError } from '../common/api-error.js';
|
||||||
|
import { D } from './pricing-engine.js';
|
||||||
|
|
||||||
|
const sourceId = z
|
||||||
|
.union([
|
||||||
|
z.number().int().positive().max(Number.MAX_SAFE_INTEGER),
|
||||||
|
z.string().regex(/^[1-9]\d{0,17}$/),
|
||||||
|
])
|
||||||
|
.transform(String);
|
||||||
|
const leaf = z
|
||||||
|
.object({
|
||||||
|
id: z.union([z.string(), z.number()]),
|
||||||
|
label: z.string().max(200),
|
||||||
|
value: z.string().max(50),
|
||||||
|
})
|
||||||
|
.strict();
|
||||||
|
const category = z
|
||||||
|
.object({
|
||||||
|
id: z.union([z.string(), z.number()]),
|
||||||
|
label: z.string().trim().min(1).max(200),
|
||||||
|
value: z.string().max(200),
|
||||||
|
cate_id: sourceId,
|
||||||
|
children: z.array(leaf).length(3),
|
||||||
|
})
|
||||||
|
.strict();
|
||||||
|
const root = category
|
||||||
|
.omit({ children: true })
|
||||||
|
.extend({ children: z.array(category).min(1).max(2000) });
|
||||||
|
const documentSchema = z
|
||||||
|
.object({
|
||||||
|
code: z.literal(1),
|
||||||
|
msg: z.string(),
|
||||||
|
data: z.array(root).min(1).max(100),
|
||||||
|
})
|
||||||
|
.strict();
|
||||||
|
const fail = (message: string): never => {
|
||||||
|
throw new ApiError(422, 'COMMISSION_IMPORT_INVALID', message);
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 仅在导入时解析来源文字;报价读取结构化区间,不解析展示标签。 */
|
||||||
|
export function parseCommissionDocument(
|
||||||
|
document: unknown,
|
||||||
|
): CommissionImportCategory[] {
|
||||||
|
if (Buffer.byteLength(JSON.stringify(document) ?? '') > 2 * 1024 * 1024)
|
||||||
|
fail('JSON 不能超过 2 MiB');
|
||||||
|
const parsed = documentSchema.safeParse(document);
|
||||||
|
if (!parsed.success)
|
||||||
|
return fail(
|
||||||
|
'JSON 格式不正确:应包含一级类目、二级类目和每类完整的三个价格档位',
|
||||||
|
);
|
||||||
|
const ids = new Set<string>();
|
||||||
|
const result: CommissionImportCategory[] = [];
|
||||||
|
for (const group of parsed.data.data) {
|
||||||
|
if (ids.has(group.cate_id)) fail(`类目 ID 重复:${group.cate_id}`);
|
||||||
|
ids.add(group.cate_id);
|
||||||
|
for (const item of group.children) {
|
||||||
|
if (ids.has(item.cate_id)) fail(`类目 ID 重复:${item.cate_id}`);
|
||||||
|
ids.add(item.cate_id);
|
||||||
|
const bands = item.children.map((tier, index) => {
|
||||||
|
const value = /^([123]),(\d{1,2}(?:\.\d{1,2})?)$/.exec(
|
||||||
|
tier.value,
|
||||||
|
);
|
||||||
|
const prefix = [
|
||||||
|
'售价 ≤ 1500₽',
|
||||||
|
'1500₽ < 售价 ≤ 5000₽',
|
||||||
|
'售价 > 5000₽',
|
||||||
|
][index];
|
||||||
|
const label = /^(.*?)\s*\((\d{1,2}(?:\.\d{1,2})?)%\)$/.exec(
|
||||||
|
tier.label,
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
!value ||
|
||||||
|
!label ||
|
||||||
|
value[1] !== String(index + 1) ||
|
||||||
|
label[1].trim() !== prefix ||
|
||||||
|
!D(value[2]).eq(label[2])
|
||||||
|
)
|
||||||
|
return fail(
|
||||||
|
`${item.label}:档位或标签百分比与 value 不一致`,
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
min_price: ['0', '1500', '5000'][index],
|
||||||
|
max_price: ['1500', '5000', null][index],
|
||||||
|
min_inclusive: false,
|
||||||
|
max_inclusive: index < 2,
|
||||||
|
commission_rate: D(value[2]).div(100).toFixed(6),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
result.push({
|
||||||
|
source_category_id: item.cate_id,
|
||||||
|
parent_source_id: group.cate_id,
|
||||||
|
name: item.label,
|
||||||
|
parent_name: group.label,
|
||||||
|
bands,
|
||||||
|
status: 'eligible',
|
||||||
|
issues: [],
|
||||||
|
change: 'added',
|
||||||
|
mapped_types: 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (result.length > 2000) fail('二级类目不能超过 2,000 个');
|
||||||
|
return result;
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
Headers,
|
||||||
|
Inject,
|
||||||
|
Param,
|
||||||
|
Post,
|
||||||
|
Query,
|
||||||
|
Req,
|
||||||
|
Res,
|
||||||
|
UseGuards,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import {
|
||||||
|
commissionImportRequestSchema,
|
||||||
|
commissionImportPublishSchema,
|
||||||
|
type Workspace,
|
||||||
|
} from '@atlas/contracts';
|
||||||
|
import { CurrentWorkspace } from '../workspace/current-workspace.decorator.js';
|
||||||
|
import type { AuthenticatedRequest } from '../auth/auth.guard.js';
|
||||||
|
import { ApiError } from '../common/api-error.js';
|
||||||
|
import { SystemAdminGuard } from '../platform-catalog-admin/system-admin.guard.js';
|
||||||
|
import { CommissionImportService } from './commission-import.service.js';
|
||||||
|
function parse<T>(schema: z.ZodType<T>, value: unknown): T {
|
||||||
|
const r = schema.safeParse(value);
|
||||||
|
if (!r.success)
|
||||||
|
throw new ApiError(
|
||||||
|
422,
|
||||||
|
'VALIDATION_ERROR',
|
||||||
|
'请检查平台、版本名称、来源、采集日期及确认项',
|
||||||
|
r.error.issues.map((i) => ({
|
||||||
|
field: i.path.join('.'),
|
||||||
|
message: i.message,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
return r.data;
|
||||||
|
}
|
||||||
|
@ApiTags('System commissions')
|
||||||
|
@ApiBearerAuth('access-token')
|
||||||
|
@UseGuards(SystemAdminGuard)
|
||||||
|
@Controller('system/pricing/commissions')
|
||||||
|
export class CommissionImportController {
|
||||||
|
constructor(
|
||||||
|
@Inject(CommissionImportService)
|
||||||
|
private readonly service: CommissionImportService,
|
||||||
|
) {}
|
||||||
|
@Get() async list(@Query('platform_id') id: string) {
|
||||||
|
return { data: await this.service.list(parse(z.uuid(), id)) };
|
||||||
|
}
|
||||||
|
@Get(':id') async detail(@Param('id') id: string) {
|
||||||
|
return { data: await this.service.detail(parse(z.uuid(), id)) };
|
||||||
|
}
|
||||||
|
@Post('preview') async preview(@Body() body: unknown) {
|
||||||
|
return {
|
||||||
|
data: await this.service.preview(
|
||||||
|
parse(commissionImportRequestSchema, body),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
@Post('imports') async publish(
|
||||||
|
@Body() body: unknown,
|
||||||
|
@Req() req: AuthenticatedRequest,
|
||||||
|
@CurrentWorkspace() w: Workspace,
|
||||||
|
@Headers('idempotency-key') key: string | undefined,
|
||||||
|
@Res({ passthrough: true }) res: { status: (n: number) => unknown },
|
||||||
|
) {
|
||||||
|
if (!key)
|
||||||
|
throw new ApiError(
|
||||||
|
400,
|
||||||
|
'IDEMPOTENCY_KEY_REQUIRED',
|
||||||
|
'需要 Idempotency-Key',
|
||||||
|
);
|
||||||
|
const r = await this.service.publish(
|
||||||
|
w.id,
|
||||||
|
req.auth.userId,
|
||||||
|
parse(z.uuid(), key),
|
||||||
|
parse(commissionImportPublishSchema, body),
|
||||||
|
);
|
||||||
|
res.status(r.status);
|
||||||
|
return r.body;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,347 @@
|
|||||||
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
import type { Database, DatabaseTransaction } from '@atlas/database';
|
||||||
|
import type {
|
||||||
|
CommissionImportRequest,
|
||||||
|
CommissionImportPublish,
|
||||||
|
CommissionImportCategory,
|
||||||
|
CommissionImportPreview,
|
||||||
|
CommissionImportVersion,
|
||||||
|
CommissionImportDetail,
|
||||||
|
} from '@atlas/contracts';
|
||||||
|
import { DATABASE } from '../database/database.module.js';
|
||||||
|
import { IdempotencyService } from '../common/idempotency.service.js';
|
||||||
|
import { ApiError } from '../common/api-error.js';
|
||||||
|
import { fingerprint } from './pricing-engine.js';
|
||||||
|
import { parseCommissionDocument } from './commission-import-parser.js';
|
||||||
|
type Tx = DatabaseTransaction;
|
||||||
|
type CatalogRow = {
|
||||||
|
id: string;
|
||||||
|
external_category_id: string;
|
||||||
|
external_type_id: string;
|
||||||
|
parent_external_id: string | null;
|
||||||
|
is_active: boolean;
|
||||||
|
};
|
||||||
|
const columns =
|
||||||
|
'id,platform_id,version,source_reference,captured_on::text,activated_at,is_current,summary';
|
||||||
|
const inputFields = (v: CommissionImportRequest): CommissionImportRequest => ({
|
||||||
|
platform_id: v.platform_id,
|
||||||
|
version: v.version,
|
||||||
|
source_reference: v.source_reference,
|
||||||
|
captured_on: v.captured_on,
|
||||||
|
document: v.document,
|
||||||
|
});
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class CommissionImportService {
|
||||||
|
constructor(
|
||||||
|
@Inject(DATABASE) private readonly db: Database,
|
||||||
|
@Inject(IdempotencyService)
|
||||||
|
private readonly idempotency: IdempotencyService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async list(platformId: string): Promise<CommissionImportVersion[]> {
|
||||||
|
return (
|
||||||
|
await this.db.query<CommissionImportVersion>(
|
||||||
|
`SELECT ${columns} FROM atlas.commission_imports WHERE platform_id=$1 ORDER BY activated_at DESC,id`,
|
||||||
|
[platformId],
|
||||||
|
)
|
||||||
|
).rows;
|
||||||
|
}
|
||||||
|
async detail(id: string): Promise<CommissionImportDetail> {
|
||||||
|
const row = (
|
||||||
|
await this.db.query<CommissionImportDetail>(
|
||||||
|
`SELECT ${columns},categories FROM atlas.commission_imports WHERE id=$1`,
|
||||||
|
[id],
|
||||||
|
)
|
||||||
|
).rows[0];
|
||||||
|
if (!row)
|
||||||
|
throw new ApiError(
|
||||||
|
404,
|
||||||
|
'COMMISSION_IMPORT_NOT_FOUND',
|
||||||
|
'佣金版本不存在',
|
||||||
|
);
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
async preview(input: CommissionImportRequest) {
|
||||||
|
return this.db.transaction(
|
||||||
|
async (tx) => (await this.inspect(tx, input)).preview,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
private async inspect(
|
||||||
|
tx: Tx,
|
||||||
|
input: CommissionImportRequest,
|
||||||
|
lock = false,
|
||||||
|
) {
|
||||||
|
const platform = (
|
||||||
|
await tx.query<{ code: string }>(
|
||||||
|
`SELECT code FROM atlas.platforms WHERE id=$1 ${lock ? 'FOR UPDATE' : ''}`,
|
||||||
|
[input.platform_id],
|
||||||
|
)
|
||||||
|
).rows[0];
|
||||||
|
if (platform?.code.toUpperCase() !== 'OZON')
|
||||||
|
throw new ApiError(
|
||||||
|
422,
|
||||||
|
'COMMISSION_PLATFORM_UNSUPPORTED',
|
||||||
|
'当前仅支持 Ozon 类目佣金',
|
||||||
|
);
|
||||||
|
if (input.captured_on > new Date().toISOString().slice(0, 10))
|
||||||
|
throw new ApiError(
|
||||||
|
422,
|
||||||
|
'COMMISSION_IMPORT_INVALID',
|
||||||
|
'采集日期不能晚于今天',
|
||||||
|
);
|
||||||
|
const categories = parseCommissionDocument(input.document);
|
||||||
|
const rows = (
|
||||||
|
await tx.query<CatalogRow>(
|
||||||
|
`SELECT c.id,c.external_category_id,c.external_type_id,c.is_active,p.external_category_id AS parent_external_id
|
||||||
|
FROM atlas.platform_product_categories c LEFT JOIN atlas.platform_product_categories p ON p.id=c.parent_id
|
||||||
|
WHERE c.platform_id=$1 ORDER BY c.id ${lock ? 'FOR SHARE OF c' : ''}`,
|
||||||
|
[input.platform_id],
|
||||||
|
)
|
||||||
|
).rows;
|
||||||
|
const previous = (
|
||||||
|
await tx.query<{
|
||||||
|
id: string;
|
||||||
|
categories: CommissionImportCategory[];
|
||||||
|
}>(
|
||||||
|
`SELECT id,categories FROM atlas.commission_imports WHERE platform_id=$1 AND is_current`,
|
||||||
|
[input.platform_id],
|
||||||
|
)
|
||||||
|
).rows[0];
|
||||||
|
const old = new Map(
|
||||||
|
(previous?.categories ?? [])
|
||||||
|
.filter((c) => c.status === 'eligible')
|
||||||
|
.map((c) => [c.source_category_id, c]),
|
||||||
|
);
|
||||||
|
const mappings = new Map<string, string[]>();
|
||||||
|
for (const c of categories) {
|
||||||
|
const matching = rows.filter(
|
||||||
|
(r) => r.external_category_id === c.source_category_id,
|
||||||
|
);
|
||||||
|
const parent = matching.filter(
|
||||||
|
(r) => !r.external_type_id || r.external_type_id === '0',
|
||||||
|
);
|
||||||
|
const types = matching.filter(
|
||||||
|
(r) =>
|
||||||
|
r.is_active &&
|
||||||
|
r.external_type_id &&
|
||||||
|
r.external_type_id !== '0',
|
||||||
|
);
|
||||||
|
if (parent.length !== 1)
|
||||||
|
c.issues.push('平台目录中没有唯一对应的类目');
|
||||||
|
else if (parent[0].parent_external_id !== c.parent_source_id)
|
||||||
|
c.issues.push('来源一级父类目与平台目录不一致');
|
||||||
|
if (!types.length) c.issues.push('没有可映射的有效商品类型');
|
||||||
|
c.status = c.issues.length ? 'excluded' : 'eligible';
|
||||||
|
c.mapped_types = c.status === 'eligible' ? types.length : 0;
|
||||||
|
const before = old.get(c.source_category_id);
|
||||||
|
c.change = !before
|
||||||
|
? 'added'
|
||||||
|
: fingerprint(before.bands) === fingerprint(c.bands)
|
||||||
|
? 'unchanged'
|
||||||
|
: 'changed';
|
||||||
|
if (c.status === 'eligible')
|
||||||
|
mappings.set(
|
||||||
|
c.source_category_id,
|
||||||
|
types.map((t) => t.id),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const eligible = categories.filter((c) => c.status === 'eligible');
|
||||||
|
const summary = {
|
||||||
|
categories: categories.length,
|
||||||
|
rules: categories.reduce((n, c) => n + c.bands.length, 0),
|
||||||
|
eligible: eligible.length,
|
||||||
|
excluded: categories.length - eligible.length,
|
||||||
|
mapped_types: eligible.reduce((n, c) => n + c.mapped_types, 0),
|
||||||
|
removed: [...old.keys()].filter((id) => !mappings.has(id)).length,
|
||||||
|
};
|
||||||
|
const preview: CommissionImportPreview = {
|
||||||
|
categories,
|
||||||
|
summary,
|
||||||
|
previous_import_id: previous?.id ?? null,
|
||||||
|
preview_hash: fingerprint({
|
||||||
|
input: inputFields(input),
|
||||||
|
categories,
|
||||||
|
mappings: [...mappings],
|
||||||
|
previous: previous?.id ?? null,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
return { preview, mappings };
|
||||||
|
}
|
||||||
|
async publish(
|
||||||
|
workspaceId: string,
|
||||||
|
userId: string,
|
||||||
|
key: string,
|
||||||
|
input: CommissionImportPublish,
|
||||||
|
) {
|
||||||
|
return this.idempotency.run(
|
||||||
|
{
|
||||||
|
workspaceId,
|
||||||
|
userId,
|
||||||
|
key,
|
||||||
|
method: 'POST',
|
||||||
|
path: '/api/system/pricing/commissions/imports',
|
||||||
|
request: input,
|
||||||
|
},
|
||||||
|
async (tx) => {
|
||||||
|
// 发布时重新查询有效管理员授权,避免仅信任旧 JWT。
|
||||||
|
const grant = await tx.query(
|
||||||
|
`SELECT g.user_id FROM atlas.system_admin_grants g JOIN atlas.users u ON u.id=g.user_id WHERE g.user_id=$1 AND g.revoked_at IS NULL AND u.status='active' FOR SHARE OF u`,
|
||||||
|
[userId],
|
||||||
|
);
|
||||||
|
if (!grant.rowCount)
|
||||||
|
throw new ApiError(
|
||||||
|
403,
|
||||||
|
'SYSTEM_ADMIN_REQUIRED',
|
||||||
|
'需要系统管理员权限',
|
||||||
|
);
|
||||||
|
const { preview, mappings } = await this.inspect(
|
||||||
|
tx,
|
||||||
|
input,
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
if (preview.preview_hash !== input.preview_hash)
|
||||||
|
throw new ApiError(
|
||||||
|
409,
|
||||||
|
'COMMISSION_PREVIEW_STALE',
|
||||||
|
'目录、当前版本或输入已变化,请重新预览',
|
||||||
|
);
|
||||||
|
if (!preview.summary.eligible)
|
||||||
|
throw new ApiError(
|
||||||
|
422,
|
||||||
|
'COMMISSION_IMPORT_INVALID',
|
||||||
|
'没有可启用的类目,请核对异常项',
|
||||||
|
);
|
||||||
|
const contentHash = fingerprint(input.document);
|
||||||
|
if (
|
||||||
|
(
|
||||||
|
await tx.query(
|
||||||
|
`SELECT id FROM atlas.commission_imports WHERE platform_id=$1 AND (content_hash=$2 OR version=$3)`,
|
||||||
|
[input.platform_id, contentHash, input.version],
|
||||||
|
)
|
||||||
|
).rowCount
|
||||||
|
)
|
||||||
|
throw new ApiError(
|
||||||
|
409,
|
||||||
|
'COMMISSION_IMPORT_EXISTS',
|
||||||
|
'此版本名称或 JSON 内容已导入',
|
||||||
|
);
|
||||||
|
const typeIds = [...mappings.values()].flat();
|
||||||
|
if (
|
||||||
|
(
|
||||||
|
await tx.query(
|
||||||
|
`SELECT id FROM atlas.platform_product_category_commission_mappings WHERE platform_id=$1 AND platform_product_category_id=ANY($2::uuid[]) AND fulfillment='rfbs' AND market_code='RU' AND status='active' AND (effective_to IS NULL OR effective_to>now()) AND import_id IS NULL FOR SHARE`,
|
||||||
|
[input.platform_id, typeIds],
|
||||||
|
)
|
||||||
|
).rowCount
|
||||||
|
)
|
||||||
|
throw new ApiError(
|
||||||
|
409,
|
||||||
|
'COMMISSION_MAPPING_CONFLICT',
|
||||||
|
'部分类型已有其他来源的有效佣金映射,请先核对',
|
||||||
|
);
|
||||||
|
if (preview.previous_import_id) {
|
||||||
|
await tx.query(
|
||||||
|
`UPDATE atlas.commission_category_rules SET status='archived',effective_to=now() WHERE import_id=$1 AND effective_to IS NULL`,
|
||||||
|
[preview.previous_import_id],
|
||||||
|
);
|
||||||
|
await tx.query(
|
||||||
|
`UPDATE atlas.platform_product_category_commission_mappings SET status='archived',effective_to=now() WHERE import_id=$1 AND effective_to IS NULL`,
|
||||||
|
[preview.previous_import_id],
|
||||||
|
);
|
||||||
|
await tx.query(
|
||||||
|
`UPDATE atlas.commission_imports SET is_current=false WHERE id=$1`,
|
||||||
|
[preview.previous_import_id],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const id = randomUUID();
|
||||||
|
const saved = (
|
||||||
|
await tx.query<CommissionImportVersion>(
|
||||||
|
`INSERT INTO atlas.commission_imports(id,platform_id,version,source_reference,captured_on,document,content_hash,categories,summary,activated_by) VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10) RETURNING ${columns}`,
|
||||||
|
[
|
||||||
|
id,
|
||||||
|
input.platform_id,
|
||||||
|
input.version,
|
||||||
|
input.source_reference,
|
||||||
|
input.captured_on,
|
||||||
|
JSON.stringify(input.document),
|
||||||
|
contentHash,
|
||||||
|
JSON.stringify(preview.categories),
|
||||||
|
JSON.stringify(preview.summary),
|
||||||
|
userId,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
).rows[0];
|
||||||
|
const roots = new Map<string, string>();
|
||||||
|
for (const c of preview.categories.filter(
|
||||||
|
(c) => c.status === 'eligible',
|
||||||
|
)) {
|
||||||
|
let rootId = roots.get(c.parent_source_id);
|
||||||
|
if (!rootId) {
|
||||||
|
rootId = await this.category(
|
||||||
|
tx,
|
||||||
|
input.platform_id,
|
||||||
|
c.parent_source_id,
|
||||||
|
c.parent_name,
|
||||||
|
`毛子ERP / ${c.parent_name} (${c.parent_source_id})`,
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
roots.set(c.parent_source_id, rootId);
|
||||||
|
}
|
||||||
|
const categoryId = await this.category(
|
||||||
|
tx,
|
||||||
|
input.platform_id,
|
||||||
|
c.source_category_id,
|
||||||
|
c.name,
|
||||||
|
`毛子ERP / ${c.parent_name} / ${c.name} (${c.source_category_id})`,
|
||||||
|
rootId,
|
||||||
|
);
|
||||||
|
await tx.query(
|
||||||
|
`INSERT INTO atlas.commission_category_rules(commission_category_id,fulfillment,market_code,currency_code,min_price,max_price,min_inclusive,max_inclusive,commission_rate,source,version,status,effective_from,import_id)
|
||||||
|
SELECT $1,'rfbs','RU','RUB',min_price::numeric,max_price::numeric,min_inclusive,max_inclusive,commission_rate::numeric,'competitor_capture',$2,'active',now(),$3 FROM jsonb_to_recordset($4::jsonb) AS b(min_price text,max_price text,min_inclusive boolean,max_inclusive boolean,commission_rate text)`,
|
||||||
|
[
|
||||||
|
categoryId,
|
||||||
|
input.version,
|
||||||
|
id,
|
||||||
|
JSON.stringify(c.bands),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
await tx.query(
|
||||||
|
`INSERT INTO atlas.platform_product_category_commission_mappings(platform_id,platform_product_category_id,commission_category_id,fulfillment,market_code,priority,source,status,effective_from,import_id)
|
||||||
|
SELECT $1,unnest($2::uuid[]),$3,'rfbs','RU',100,'competitor_capture','active',now(),$4`,
|
||||||
|
[
|
||||||
|
input.platform_id,
|
||||||
|
mappings.get(c.source_category_id),
|
||||||
|
categoryId,
|
||||||
|
id,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return { status: 201, body: { data: saved } };
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
private async category(
|
||||||
|
tx: Tx,
|
||||||
|
platform: string,
|
||||||
|
key: string,
|
||||||
|
name: string,
|
||||||
|
path: string,
|
||||||
|
parent: string | null,
|
||||||
|
): Promise<string> {
|
||||||
|
const existing = (
|
||||||
|
await tx.query<{ id: string }>(
|
||||||
|
`SELECT id FROM atlas.commission_categories WHERE platform_id=$1 AND source_category_key=$2`,
|
||||||
|
[platform, `maozierp:${key}`],
|
||||||
|
)
|
||||||
|
).rows[0];
|
||||||
|
if (existing) return existing.id;
|
||||||
|
return (
|
||||||
|
await tx.query<{ id: string }>(
|
||||||
|
`INSERT INTO atlas.commission_categories(platform_id,parent_id,source_category_key,name,path,source,status) VALUES($1,$2,$3,$4,$5,'competitor_capture','active') RETURNING id`,
|
||||||
|
[platform, parent, `maozierp:${key}`, name, path],
|
||||||
|
)
|
||||||
|
).rows[0].id;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import type { DatabaseTransaction } from '@atlas/database';
|
||||||
|
import type { CommissionRule } from './pricing-engine.js';
|
||||||
|
|
||||||
|
/** 查询与正式报价共用同一组已启用规则,避免页面显示和实际算价使用不同费率。 */
|
||||||
|
export async function readCommissionRules(
|
||||||
|
tx: DatabaseTransaction,
|
||||||
|
platformId: string,
|
||||||
|
categoryId: string,
|
||||||
|
fulfillment: string,
|
||||||
|
) {
|
||||||
|
const mapping = (
|
||||||
|
await tx.query<{ commission_category_id: string }>(
|
||||||
|
`SELECT * FROM atlas.platform_product_category_commission_mappings WHERE platform_id=$1 AND platform_product_category_id=$2 AND fulfillment=$3 AND market_code='RU' AND status='active' AND effective_from<=now() AND (effective_to IS NULL OR effective_to>now()) ORDER BY priority,effective_from DESC,id LIMIT 1 FOR SHARE`,
|
||||||
|
[platformId, categoryId, fulfillment],
|
||||||
|
)
|
||||||
|
).rows[0];
|
||||||
|
const commissions = mapping
|
||||||
|
? (
|
||||||
|
await tx.query<CommissionRule>(
|
||||||
|
`SELECT r.*,c.name AS category_name FROM atlas.commission_category_rules r JOIN atlas.commission_categories c ON c.id=r.commission_category_id
|
||||||
|
WHERE r.commission_category_id=$1 AND r.fulfillment=$2 AND r.market_code='RU' AND r.currency_code IN ('CNY','RUB')
|
||||||
|
AND r.status='active' AND r.effective_from<=now() AND (r.effective_to IS NULL OR r.effective_to>now()) ORDER BY r.min_price NULLS FIRST,r.id FOR SHARE OF r,c`,
|
||||||
|
[mapping.commission_category_id, fulfillment],
|
||||||
|
)
|
||||||
|
).rows
|
||||||
|
: [];
|
||||||
|
return { mapping, commissions };
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
Headers,
|
||||||
|
Inject,
|
||||||
|
Param,
|
||||||
|
Post,
|
||||||
|
Query,
|
||||||
|
Req,
|
||||||
|
UseGuards,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
|
||||||
|
import { pricingRateReviewRequestSchema } from '@atlas/contracts';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import type { AuthenticatedRequest } from '../auth/auth.guard.js';
|
||||||
|
import { ApiError } from '../common/api-error.js';
|
||||||
|
import { SystemAdminGuard } from '../platform-catalog-admin/system-admin.guard.js';
|
||||||
|
import { PricingAdminService } from './pricing-admin.service.js';
|
||||||
|
|
||||||
|
function uuid(value: string, field: string): string {
|
||||||
|
if (!z.uuid().safeParse(value).success)
|
||||||
|
throw new ApiError(400, 'VALIDATION_ERROR', `${field} 无效`);
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
@ApiTags('System pricing')
|
||||||
|
@ApiBearerAuth('access-token')
|
||||||
|
@UseGuards(SystemAdminGuard)
|
||||||
|
@Controller('system/pricing/rate-cards')
|
||||||
|
export class PricingAdminController {
|
||||||
|
constructor(
|
||||||
|
@Inject(PricingAdminService)
|
||||||
|
private readonly service: PricingAdminService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
async list(@Query('platform_id') platformId: string) {
|
||||||
|
return {
|
||||||
|
data: await this.service.list(uuid(platformId, 'platform_id')),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':id')
|
||||||
|
async detail(@Param('id') id: string) {
|
||||||
|
return { data: await this.service.detail(uuid(id, 'rate_card_id')) };
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':id/reviews')
|
||||||
|
async approve(
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Headers('idempotency-key') key: string | undefined,
|
||||||
|
@Body() body: unknown,
|
||||||
|
@Req() request: AuthenticatedRequest,
|
||||||
|
) {
|
||||||
|
if (!key)
|
||||||
|
throw new ApiError(
|
||||||
|
400,
|
||||||
|
'IDEMPOTENCY_KEY_REQUIRED',
|
||||||
|
'需要 Idempotency-Key',
|
||||||
|
);
|
||||||
|
const parsed = pricingRateReviewRequestSchema.safeParse(body);
|
||||||
|
if (!parsed.success)
|
||||||
|
throw new ApiError(
|
||||||
|
422,
|
||||||
|
'VALIDATION_ERROR',
|
||||||
|
'请检查审核证据与账单样本',
|
||||||
|
parsed.error.issues.map((issue) => ({
|
||||||
|
field: issue.path.join('.'),
|
||||||
|
message: issue.message,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
data: await this.service.approve(
|
||||||
|
uuid(id, 'rate_card_id'),
|
||||||
|
request.auth.userId,
|
||||||
|
uuid(key, 'Idempotency-Key'),
|
||||||
|
parsed.data,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,352 @@
|
|||||||
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
|
import type { Database, DatabaseTransaction } from '@atlas/database';
|
||||||
|
import type {
|
||||||
|
PricingRateCardAdmin,
|
||||||
|
PricingRateReviewRequest,
|
||||||
|
} from '@atlas/contracts';
|
||||||
|
import { DATABASE } from '../database/database.module.js';
|
||||||
|
import { ApiError } from '../common/api-error.js';
|
||||||
|
import {
|
||||||
|
calculateLogisticsFee,
|
||||||
|
D,
|
||||||
|
fingerprint,
|
||||||
|
logisticsMerchandiseFits,
|
||||||
|
type LogisticsRule,
|
||||||
|
} from './pricing-engine.js';
|
||||||
|
|
||||||
|
type Tx = DatabaseTransaction;
|
||||||
|
type CardRow = {
|
||||||
|
id: string;
|
||||||
|
platform_id: string;
|
||||||
|
provider_code: string;
|
||||||
|
version: string;
|
||||||
|
source_name: string;
|
||||||
|
source_uri: string | null;
|
||||||
|
status: string;
|
||||||
|
fulfillment: string;
|
||||||
|
market_code: string;
|
||||||
|
effective_from: Date;
|
||||||
|
reviewed_at: Date | null;
|
||||||
|
reviewer_name: string | null;
|
||||||
|
};
|
||||||
|
type TypeRow = PricingRateCardAdmin['types'][number] & Record<string, unknown>;
|
||||||
|
type RuleRow = LogisticsRule & {
|
||||||
|
product_type_code: string;
|
||||||
|
transport_method_name: string;
|
||||||
|
status: string;
|
||||||
|
product_type_name: string;
|
||||||
|
transport_name: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class PricingAdminService {
|
||||||
|
constructor(@Inject(DATABASE) private readonly db: Database) {}
|
||||||
|
|
||||||
|
async list(platformId: string): Promise<PricingRateCardAdmin[]> {
|
||||||
|
const ids = await this.db.query<{ id: string }>(
|
||||||
|
`SELECT id FROM atlas.logistics_rate_cards WHERE platform_id=$1 AND fulfillment='rfbs' AND market_code='RU' ORDER BY created_at DESC`,
|
||||||
|
[platformId],
|
||||||
|
);
|
||||||
|
const cards: PricingRateCardAdmin[] = [];
|
||||||
|
for (const row of ids.rows) cards.push(await this.detail(row.id));
|
||||||
|
return cards;
|
||||||
|
}
|
||||||
|
|
||||||
|
async detail(id: string): Promise<PricingRateCardAdmin> {
|
||||||
|
return this.db.transaction((tx) => this.read(tx, id));
|
||||||
|
}
|
||||||
|
|
||||||
|
private async read(tx: Tx, id: string): Promise<PricingRateCardAdmin> {
|
||||||
|
const card = (
|
||||||
|
await tx.query<CardRow>(
|
||||||
|
`SELECT c.id,c.platform_id,p.code AS provider_code,c.version,c.source_name,c.source_uri,c.status,c.fulfillment,c.market_code,c.effective_from,c.reviewed_at,u.display_name AS reviewer_name
|
||||||
|
FROM atlas.logistics_rate_cards c JOIN atlas.logistics_providers p ON p.id=c.provider_id
|
||||||
|
LEFT JOIN atlas.users u ON u.id=c.reviewed_by_user_id WHERE c.id=$1`,
|
||||||
|
[id],
|
||||||
|
)
|
||||||
|
).rows[0];
|
||||||
|
if (!card)
|
||||||
|
throw new ApiError(404, 'RATE_CARD_NOT_FOUND', '物流费率卡不存在');
|
||||||
|
const types = (
|
||||||
|
await tx.query<TypeRow>(
|
||||||
|
`SELECT id,code::text,min_merchandise_value::text,max_merchandise_value::text,min_actual_weight_kg::text,max_actual_weight_kg::text,billing_basis::text,
|
||||||
|
merchandise_currency_code,min_value_inclusive,max_value_inclusive,min_actual_weight_inclusive,max_sum_dimensions_cm::text,max_longest_side_cm::text,max_second_longest_side_cm::text,volume_divisor::text,volume_condition_sum_gt_cm::text,volume_round_scale,billing_round_scale,max_volume_weight_kg::text,volume_limit_round_scale
|
||||||
|
FROM atlas.logistics_product_types WHERE rate_card_id=$1 ORDER BY code`,
|
||||||
|
[id],
|
||||||
|
)
|
||||||
|
).rows;
|
||||||
|
const rules = (
|
||||||
|
await tx.query<RuleRow>(
|
||||||
|
`SELECT r.id,r.product_type_id,t.code::text AS product_type_code,t.name AS product_type_name,r.transport_method_id,m.name AS transport_method_name,m.name AS transport_name,
|
||||||
|
r.fixed_fee_cny::text,r.per_billable_kg_fee_cny::text,r.min_charge_cny::text,r.surcharge_rules,r.status::text,
|
||||||
|
t.merchandise_currency_code,t.min_merchandise_value::text,t.max_merchandise_value::text,t.min_value_inclusive,t.max_value_inclusive,
|
||||||
|
t.min_actual_weight_kg::text,t.min_actual_weight_inclusive,t.max_actual_weight_kg::text,t.max_sum_dimensions_cm::text,t.max_longest_side_cm::text,t.max_second_longest_side_cm::text,
|
||||||
|
t.billing_basis::text,t.volume_divisor::text,t.volume_condition_sum_gt_cm::text,t.volume_round_scale,t.billing_round_scale,t.max_volume_weight_kg::text,t.volume_limit_round_scale,
|
||||||
|
r.updated_at AS rule_updated_at,t.updated_at AS type_updated_at,m.updated_at AS transport_updated_at,m.status::text AS transport_status
|
||||||
|
FROM atlas.logistics_rate_rules r JOIN atlas.logistics_product_types t ON t.id=r.product_type_id
|
||||||
|
JOIN atlas.logistics_transport_methods m ON m.id=r.transport_method_id AND m.rate_card_id=t.rate_card_id
|
||||||
|
WHERE t.rate_card_id=$1 ORDER BY t.code,m.code`,
|
||||||
|
[id],
|
||||||
|
)
|
||||||
|
).rows;
|
||||||
|
const review =
|
||||||
|
(
|
||||||
|
await tx.query<{
|
||||||
|
source_reference: string;
|
||||||
|
review_note: string;
|
||||||
|
samples: PricingRateReviewRequest['samples'];
|
||||||
|
reviewed_at: Date;
|
||||||
|
}>(
|
||||||
|
`SELECT source_reference,review_note,samples,reviewed_at FROM atlas.logistics_rate_card_reviews WHERE rate_card_id=$1`,
|
||||||
|
[id],
|
||||||
|
)
|
||||||
|
).rows[0] ?? null;
|
||||||
|
const { reviewer_name: reviewerName, ...publicCard } = card;
|
||||||
|
return {
|
||||||
|
...publicCard,
|
||||||
|
effective_from: card.effective_from.toISOString(),
|
||||||
|
reviewed_at: card.reviewed_at?.toISOString() ?? null,
|
||||||
|
reviewer_name: reviewerName,
|
||||||
|
rules_hash: fingerprint({
|
||||||
|
card: {
|
||||||
|
id: card.id,
|
||||||
|
version: card.version,
|
||||||
|
source_name: card.source_name,
|
||||||
|
source_uri: card.source_uri,
|
||||||
|
},
|
||||||
|
types,
|
||||||
|
rules,
|
||||||
|
}),
|
||||||
|
types,
|
||||||
|
rules: rules.map((r) => ({
|
||||||
|
id: r.id,
|
||||||
|
product_type_id: r.product_type_id,
|
||||||
|
product_type_code: r.product_type_code,
|
||||||
|
transport_method_id: r.transport_method_id,
|
||||||
|
transport_method_name: r.transport_method_name,
|
||||||
|
fixed_fee_cny: r.fixed_fee_cny,
|
||||||
|
per_billable_kg_fee_cny: r.per_billable_kg_fee_cny,
|
||||||
|
min_charge_cny: r.min_charge_cny,
|
||||||
|
status: r.status,
|
||||||
|
})),
|
||||||
|
review: review && {
|
||||||
|
...review,
|
||||||
|
reviewed_at: review.reviewed_at.toISOString(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async approve(
|
||||||
|
id: string,
|
||||||
|
userId: string,
|
||||||
|
key: string,
|
||||||
|
input: PricingRateReviewRequest,
|
||||||
|
) {
|
||||||
|
return this.db.transaction(async (tx) => {
|
||||||
|
await tx.query(
|
||||||
|
`SELECT pg_advisory_xact_lock(hashtextextended($1,0))`,
|
||||||
|
[`pricing-review:${userId}:${key}`],
|
||||||
|
);
|
||||||
|
const card = (
|
||||||
|
await tx.query<{
|
||||||
|
status: string;
|
||||||
|
provider_code: string;
|
||||||
|
platform_code: string;
|
||||||
|
}>(
|
||||||
|
`SELECT c.status,p.code AS provider_code,platform.code AS platform_code
|
||||||
|
FROM atlas.logistics_rate_cards c JOIN atlas.logistics_providers p ON p.id=c.provider_id
|
||||||
|
JOIN atlas.platforms platform ON platform.id=c.platform_id WHERE c.id=$1 FOR UPDATE OF c`,
|
||||||
|
[id],
|
||||||
|
)
|
||||||
|
).rows[0];
|
||||||
|
if (!card)
|
||||||
|
throw new ApiError(
|
||||||
|
404,
|
||||||
|
'RATE_CARD_NOT_FOUND',
|
||||||
|
'物流费率卡不存在',
|
||||||
|
);
|
||||||
|
const requestHash = fingerprint(input);
|
||||||
|
const replay = (
|
||||||
|
await tx.query<{ rate_card_id: string; request_hash: string }>(
|
||||||
|
`SELECT rate_card_id,request_hash FROM atlas.logistics_rate_card_reviews WHERE reviewer_user_id=$1 AND idempotency_key=$2`,
|
||||||
|
[userId, key],
|
||||||
|
)
|
||||||
|
).rows[0];
|
||||||
|
if (replay) {
|
||||||
|
if (
|
||||||
|
replay.rate_card_id !== id ||
|
||||||
|
replay.request_hash !== requestHash
|
||||||
|
)
|
||||||
|
throw new ApiError(
|
||||||
|
409,
|
||||||
|
'IDEMPOTENCY_KEY_CONFLICT',
|
||||||
|
'幂等键已用于其他审核请求',
|
||||||
|
);
|
||||||
|
return this.read(tx, id);
|
||||||
|
}
|
||||||
|
if (card.status !== 'draft')
|
||||||
|
throw new ApiError(
|
||||||
|
409,
|
||||||
|
'RATE_CARD_NOT_DRAFT',
|
||||||
|
'费率卡已审核或不可审核,请刷新',
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
card.provider_code.toUpperCase() !== 'CEL' ||
|
||||||
|
card.platform_code.toUpperCase() !== 'OZON'
|
||||||
|
)
|
||||||
|
throw new ApiError(
|
||||||
|
422,
|
||||||
|
'RATE_CARD_REVIEW_UNSUPPORTED',
|
||||||
|
'当前审核流程仅支持 Ozon CEL 费率',
|
||||||
|
);
|
||||||
|
const detail = await this.read(tx, id);
|
||||||
|
if (detail.rules_hash !== input.rules_hash)
|
||||||
|
throw new ApiError(
|
||||||
|
409,
|
||||||
|
'RATE_CARD_RULES_CHANGED',
|
||||||
|
'费率规则已变化,请重新查看并复核',
|
||||||
|
);
|
||||||
|
const types = new Set(detail.types.map((type) => type.id));
|
||||||
|
const typeCodes = new Set(detail.types.map((type) => type.code));
|
||||||
|
if (
|
||||||
|
types.size !== 6 ||
|
||||||
|
[
|
||||||
|
'ExtraSmall',
|
||||||
|
'Budget',
|
||||||
|
'Small',
|
||||||
|
'Big',
|
||||||
|
'PremiumSmall',
|
||||||
|
'PremiumBig',
|
||||||
|
].some((code) => !typeCodes.has(code)) ||
|
||||||
|
detail.rules.length !== 16 ||
|
||||||
|
detail.rules.some((r) => r.status !== 'draft')
|
||||||
|
)
|
||||||
|
throw new ApiError(
|
||||||
|
422,
|
||||||
|
'RATE_CARD_INCOMPLETE',
|
||||||
|
'CEL 费率应包含六种产品类型和十六条待审核规则',
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
new Date(input.effective_from).getTime() >
|
||||||
|
Date.now() + 365 * 86400000
|
||||||
|
)
|
||||||
|
throw new ApiError(
|
||||||
|
422,
|
||||||
|
'RATE_CARD_EFFECTIVE_DATE_INVALID',
|
||||||
|
'生效日期不能晚于一年后',
|
||||||
|
);
|
||||||
|
const fullRules = (
|
||||||
|
await tx.query<RuleRow>(
|
||||||
|
`SELECT r.*,t.code::text AS product_type_code,t.name AS product_type_name,m.name AS transport_name,
|
||||||
|
t.merchandise_currency_code,t.min_merchandise_value::text,t.max_merchandise_value::text,t.min_value_inclusive,t.max_value_inclusive,
|
||||||
|
t.min_actual_weight_kg::text,t.min_actual_weight_inclusive,t.max_actual_weight_kg::text,t.max_sum_dimensions_cm::text,t.max_longest_side_cm::text,t.max_second_longest_side_cm::text,
|
||||||
|
t.billing_basis::text,t.volume_divisor::text,t.volume_condition_sum_gt_cm::text,t.volume_round_scale,t.billing_round_scale,t.max_volume_weight_kg::text,t.volume_limit_round_scale,m.status::text AS transport_status
|
||||||
|
FROM atlas.logistics_rate_rules r JOIN atlas.logistics_product_types t ON t.id=r.product_type_id
|
||||||
|
JOIN atlas.logistics_transport_methods m ON m.id=r.transport_method_id WHERE t.rate_card_id=$1 FOR SHARE OF r,t,m`,
|
||||||
|
[id],
|
||||||
|
)
|
||||||
|
).rows;
|
||||||
|
if (
|
||||||
|
fullRules.some(
|
||||||
|
(rule) =>
|
||||||
|
rule.surcharge_rules.length > 0 ||
|
||||||
|
(rule as RuleRow & { transport_status: string })
|
||||||
|
.transport_status !== 'active',
|
||||||
|
)
|
||||||
|
)
|
||||||
|
throw new ApiError(
|
||||||
|
422,
|
||||||
|
'RATE_CARD_UNSUPPORTED_RULE',
|
||||||
|
'存在尚未支持的附加费或停用运输方式',
|
||||||
|
);
|
||||||
|
const covered = new Set<string>();
|
||||||
|
const invoiceReferences = new Set<string>();
|
||||||
|
for (const [index, sample] of input.samples.entries()) {
|
||||||
|
const invoiceReference = sample.invoice_reference
|
||||||
|
.trim()
|
||||||
|
.toLocaleLowerCase();
|
||||||
|
if (invoiceReferences.has(invoiceReference))
|
||||||
|
throw new ApiError(
|
||||||
|
422,
|
||||||
|
'RATE_CARD_SAMPLE_DUPLICATE',
|
||||||
|
'账单编号不能重复使用',
|
||||||
|
);
|
||||||
|
invoiceReferences.add(invoiceReference);
|
||||||
|
const rule = fullRules.find((r) => r.id === sample.rule_id);
|
||||||
|
if (!rule)
|
||||||
|
throw new ApiError(
|
||||||
|
422,
|
||||||
|
'RATE_CARD_SAMPLE_INVALID',
|
||||||
|
`第 ${index + 1} 条账单不属于当前费率卡`,
|
||||||
|
);
|
||||||
|
covered.add(rule.product_type_id);
|
||||||
|
if (
|
||||||
|
!logisticsMerchandiseFits(
|
||||||
|
rule,
|
||||||
|
sample.merchandise_value_cny,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
throw new ApiError(
|
||||||
|
422,
|
||||||
|
'RATE_CARD_SAMPLE_INVALID',
|
||||||
|
`第 ${index + 1} 条账单货值不在该产品类型范围`,
|
||||||
|
);
|
||||||
|
if (D(sample.shipping_weight_g).lt(1))
|
||||||
|
throw new ApiError(
|
||||||
|
422,
|
||||||
|
'RATE_CARD_SAMPLE_INVALID',
|
||||||
|
`第 ${index + 1} 条账单重量至少 1 g`,
|
||||||
|
);
|
||||||
|
const fee = calculateLogisticsFee(rule, {
|
||||||
|
shipping_weight_g: sample.shipping_weight_g,
|
||||||
|
length_mm: sample.length_mm,
|
||||||
|
width_mm: sample.width_mm,
|
||||||
|
height_mm: sample.height_mm,
|
||||||
|
status: 'confirmed',
|
||||||
|
});
|
||||||
|
if (
|
||||||
|
'reason' in fee ||
|
||||||
|
fee.logistics
|
||||||
|
.sub(sample.billed_logistics_cny)
|
||||||
|
.abs()
|
||||||
|
.gt('0.01')
|
||||||
|
)
|
||||||
|
throw new ApiError(
|
||||||
|
422,
|
||||||
|
'RATE_CARD_SAMPLE_MISMATCH',
|
||||||
|
`第 ${index + 1} 条账单与费率计算不一致`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if ([...types].some((type) => !covered.has(type)))
|
||||||
|
throw new ApiError(
|
||||||
|
422,
|
||||||
|
'RATE_CARD_SAMPLE_INCOMPLETE',
|
||||||
|
'每种 CEL 产品类型至少需要一条真实账单样本',
|
||||||
|
);
|
||||||
|
await tx.query(
|
||||||
|
`UPDATE atlas.logistics_rate_rules r SET status='active',updated_at=now() FROM atlas.logistics_product_types t WHERE r.product_type_id=t.id AND t.rate_card_id=$1`,
|
||||||
|
[id],
|
||||||
|
);
|
||||||
|
await tx.query(
|
||||||
|
`UPDATE atlas.logistics_rate_cards SET status='active',effective_from=$2,reviewed_by_user_id=$3,reviewed_at=now(),updated_at=now() WHERE id=$1`,
|
||||||
|
[id, input.effective_from, userId],
|
||||||
|
);
|
||||||
|
await tx.query(
|
||||||
|
`INSERT INTO atlas.logistics_rate_card_reviews(rate_card_id,reviewer_user_id,idempotency_key,request_hash,rules_hash,source_reference,review_note,samples,effective_from)
|
||||||
|
VALUES($1,$2,$3,$4,$5,$6,$7,$8::jsonb,$9)`,
|
||||||
|
[
|
||||||
|
id,
|
||||||
|
userId,
|
||||||
|
key,
|
||||||
|
requestHash,
|
||||||
|
input.rules_hash,
|
||||||
|
input.source_reference,
|
||||||
|
input.review_note,
|
||||||
|
JSON.stringify(input.samples),
|
||||||
|
input.effective_from,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
return this.read(tx, id);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,309 @@
|
|||||||
|
import { normalizePricingInput } from '@atlas/contracts';
|
||||||
|
import { randomUUID, createHash } from 'node:crypto';
|
||||||
|
import { Decimal } from 'decimal.js';
|
||||||
|
import type { PricingOption, PricingPreviewRequest } from '@atlas/contracts';
|
||||||
|
|
||||||
|
Decimal.set({ precision: 40, rounding: Decimal.ROUND_HALF_UP });
|
||||||
|
export const D = (value: Decimal.Value) => new Decimal(value);
|
||||||
|
export function fingerprint(value: unknown): string {
|
||||||
|
const sort = (v: unknown): unknown =>
|
||||||
|
v instanceof Date
|
||||||
|
? v.toISOString()
|
||||||
|
: Array.isArray(v)
|
||||||
|
? v.map(sort)
|
||||||
|
: v && typeof v === 'object'
|
||||||
|
? Object.fromEntries(
|
||||||
|
Object.entries(v)
|
||||||
|
.sort(([a], [b]) => a.localeCompare(b))
|
||||||
|
.map(([k, x]) => [k, sort(x)]),
|
||||||
|
)
|
||||||
|
: v;
|
||||||
|
return createHash('sha256')
|
||||||
|
.update(JSON.stringify(sort(value)))
|
||||||
|
.digest('hex');
|
||||||
|
}
|
||||||
|
export type LogisticsRule = {
|
||||||
|
id: string;
|
||||||
|
product_type_id: string;
|
||||||
|
transport_method_id: string;
|
||||||
|
transport_name: string;
|
||||||
|
product_type_name: string;
|
||||||
|
fixed_fee_cny: string;
|
||||||
|
per_billable_kg_fee_cny: string;
|
||||||
|
min_charge_cny: string | null;
|
||||||
|
surcharge_rules: unknown[];
|
||||||
|
merchandise_currency_code: string;
|
||||||
|
min_merchandise_value: string | null;
|
||||||
|
max_merchandise_value: string | null;
|
||||||
|
min_value_inclusive: boolean;
|
||||||
|
max_value_inclusive: boolean;
|
||||||
|
min_actual_weight_kg: string;
|
||||||
|
min_actual_weight_inclusive: boolean;
|
||||||
|
max_actual_weight_kg: string | null;
|
||||||
|
max_sum_dimensions_cm: string | null;
|
||||||
|
max_longest_side_cm: string | null;
|
||||||
|
max_second_longest_side_cm: string | null;
|
||||||
|
billing_basis: string;
|
||||||
|
volume_divisor: string | null;
|
||||||
|
volume_condition_sum_gt_cm: string | null;
|
||||||
|
volume_round_scale: number | null;
|
||||||
|
billing_round_scale: number | null;
|
||||||
|
max_volume_weight_kg: string | null;
|
||||||
|
volume_limit_round_scale: number | null;
|
||||||
|
};
|
||||||
|
export type CommissionRule = {
|
||||||
|
category_name?: string;
|
||||||
|
source?: string;
|
||||||
|
effective_from?: string | Date;
|
||||||
|
id: string | null;
|
||||||
|
currency_code: string;
|
||||||
|
min_price: string | null;
|
||||||
|
max_price: string | null;
|
||||||
|
min_inclusive: boolean;
|
||||||
|
max_inclusive: boolean;
|
||||||
|
commission_rate: string;
|
||||||
|
version: string;
|
||||||
|
};
|
||||||
|
const inRange = (
|
||||||
|
n: Decimal,
|
||||||
|
min: string | null,
|
||||||
|
max: string | null,
|
||||||
|
includeMin: boolean,
|
||||||
|
includeMax: boolean,
|
||||||
|
) =>
|
||||||
|
(min === null || (includeMin ? n.gte(min) : n.gt(min))) &&
|
||||||
|
(max === null || (includeMax ? n.lte(max) : n.lt(max)));
|
||||||
|
const ceil = (n: Decimal, scale: number | null) =>
|
||||||
|
scale === null ? n : n.toDecimalPlaces(scale, Decimal.ROUND_CEIL);
|
||||||
|
export const normalizeInput = normalizePricingInput;
|
||||||
|
/** 审核样本与正式报价共用包装适用性和运费计算,避免两套规则产生不同结果。 */
|
||||||
|
export function calculateLogisticsFee(
|
||||||
|
r: LogisticsRule,
|
||||||
|
packageSpec: PricingPreviewRequest['package'],
|
||||||
|
): { billable: Decimal; logistics: Decimal } | { reason: string } {
|
||||||
|
if (r.surcharge_rules.length)
|
||||||
|
return { reason: '此规则含尚未支持的附加费,需完成审核后使用' };
|
||||||
|
const weight = D(packageSpec.shipping_weight_g).div(1000);
|
||||||
|
const sides = [
|
||||||
|
packageSpec.length_mm,
|
||||||
|
packageSpec.width_mm,
|
||||||
|
packageSpec.height_mm,
|
||||||
|
]
|
||||||
|
.map((v) => D(v).div(10))
|
||||||
|
.sort((a, b) => b.cmp(a));
|
||||||
|
const sum = sides.reduce((a, b) => a.add(b), D(0));
|
||||||
|
if (
|
||||||
|
!inRange(
|
||||||
|
weight,
|
||||||
|
r.min_actual_weight_kg,
|
||||||
|
r.max_actual_weight_kg,
|
||||||
|
r.min_actual_weight_inclusive,
|
||||||
|
true,
|
||||||
|
) ||
|
||||||
|
(r.max_sum_dimensions_cm && sum.gt(r.max_sum_dimensions_cm)) ||
|
||||||
|
(r.max_longest_side_cm && sides[0].gt(r.max_longest_side_cm)) ||
|
||||||
|
(r.max_second_longest_side_cm &&
|
||||||
|
sides[1].gt(r.max_second_longest_side_cm))
|
||||||
|
)
|
||||||
|
return { reason: '包装重量或尺寸超出物流范围' };
|
||||||
|
let billable = weight;
|
||||||
|
if (r.billing_basis !== 'actual_only') {
|
||||||
|
if (!r.volume_divisor || D(r.volume_divisor).lte(0))
|
||||||
|
return { reason: '体积重规则无效' };
|
||||||
|
const volume = sides
|
||||||
|
.reduce((a, b) => a.mul(b), D(1))
|
||||||
|
.div(r.volume_divisor);
|
||||||
|
if (
|
||||||
|
r.max_volume_weight_kg &&
|
||||||
|
ceil(volume, r.volume_limit_round_scale).gt(r.max_volume_weight_kg)
|
||||||
|
)
|
||||||
|
return { reason: '体积重超限' };
|
||||||
|
if (
|
||||||
|
r.billing_basis === 'max_actual_volume' ||
|
||||||
|
(r.volume_condition_sum_gt_cm &&
|
||||||
|
sum.gt(r.volume_condition_sum_gt_cm))
|
||||||
|
)
|
||||||
|
billable = Decimal.max(weight, ceil(volume, r.volume_round_scale));
|
||||||
|
}
|
||||||
|
billable = ceil(billable, r.billing_round_scale);
|
||||||
|
const logistics = Decimal.max(
|
||||||
|
billable.mul(r.per_billable_kg_fee_cny).add(r.fixed_fee_cny),
|
||||||
|
r.min_charge_cny ?? '0',
|
||||||
|
);
|
||||||
|
return { billable, logistics };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function logisticsMerchandiseFits(
|
||||||
|
r: LogisticsRule,
|
||||||
|
valueCny: string,
|
||||||
|
): boolean {
|
||||||
|
return (
|
||||||
|
r.merchandise_currency_code === 'CNY' &&
|
||||||
|
inRange(
|
||||||
|
D(valueCny),
|
||||||
|
r.min_merchandise_value,
|
||||||
|
r.max_merchandise_value,
|
||||||
|
r.min_value_inclusive,
|
||||||
|
r.max_value_inclusive,
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
/** 枚举自洽候选;先舍入目标币种售价,再回验佣金档与货值档,避免跨档误报。 */
|
||||||
|
export function calculatePricing(
|
||||||
|
input: PricingPreviewRequest,
|
||||||
|
currency: string,
|
||||||
|
fx: string,
|
||||||
|
rules: LogisticsRule[],
|
||||||
|
commissions: CommissionRule[],
|
||||||
|
) {
|
||||||
|
if (!['CNY', 'RUB'].includes(currency))
|
||||||
|
throw new Error('门店币种仅支持 CNY / RUB');
|
||||||
|
const p = input.pricing,
|
||||||
|
exchange = D(fx),
|
||||||
|
purchase = D(input.purchase_cost_cny),
|
||||||
|
domestic = D(p.domestic_shipping_and_label_fee_cny);
|
||||||
|
const options: PricingOption[] = [],
|
||||||
|
unavailable_options: Array<{
|
||||||
|
transport_method_id: string;
|
||||||
|
reason: string;
|
||||||
|
}> = [];
|
||||||
|
for (const transport of p.transport_method_ids) {
|
||||||
|
const reasons = new Set<string>();
|
||||||
|
for (const r of rules.filter(
|
||||||
|
(r) => r.transport_method_id === transport,
|
||||||
|
)) {
|
||||||
|
const fee = calculateLogisticsFee(r, input.package);
|
||||||
|
if ('reason' in fee) {
|
||||||
|
reasons.add(fee.reason);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const { billable, logistics } = fee;
|
||||||
|
for (const c of commissions) {
|
||||||
|
const totalRate = D(c.commission_rate)
|
||||||
|
.add(p.ad_rate)
|
||||||
|
.add(p.last_mile_rate)
|
||||||
|
.add(p.withdrawal_rate)
|
||||||
|
.add(p.other_rate);
|
||||||
|
if (totalRate.gte(1)) {
|
||||||
|
reasons.add('总扣费率达到或超过 100%');
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const factor = currency === 'RUB' ? exchange : D(1);
|
||||||
|
const sale =
|
||||||
|
p.mode === 'fixed_sale'
|
||||||
|
? D(p.selling_price!)
|
||||||
|
: purchase
|
||||||
|
.mul(D(1).add(p.desired_markup_rate))
|
||||||
|
.add(logistics)
|
||||||
|
.add(domestic)
|
||||||
|
.div(D(1).sub(totalRate))
|
||||||
|
.mul(factor);
|
||||||
|
const rounded = sale.toDecimalPlaces(
|
||||||
|
2,
|
||||||
|
p.mode === 'fixed_sale'
|
||||||
|
? Decimal.ROUND_HALF_UP
|
||||||
|
: Decimal.ROUND_CEIL,
|
||||||
|
),
|
||||||
|
cny = rounded.div(factor),
|
||||||
|
rub = cny.mul(exchange);
|
||||||
|
if (
|
||||||
|
rounded.lte(0) ||
|
||||||
|
!inRange(
|
||||||
|
c.currency_code === 'RUB' ? rub : cny,
|
||||||
|
c.min_price,
|
||||||
|
c.max_price,
|
||||||
|
c.min_inclusive,
|
||||||
|
c.max_inclusive,
|
||||||
|
) ||
|
||||||
|
!inRange(
|
||||||
|
r.merchandise_currency_code === 'RUB' ? rub : cny,
|
||||||
|
r.min_merchandise_value,
|
||||||
|
r.max_merchandise_value,
|
||||||
|
r.min_value_inclusive,
|
||||||
|
r.max_value_inclusive,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
reasons.add('售价不在该物流货值区间或佣金档内');
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const profit = cny
|
||||||
|
.mul(D(1).sub(totalRate))
|
||||||
|
.sub(purchase)
|
||||||
|
.sub(logistics)
|
||||||
|
.sub(domestic),
|
||||||
|
list = rounded
|
||||||
|
.div(D(1).sub(p.list_price_discount_rate))
|
||||||
|
.toDecimalPlaces(2, Decimal.ROUND_CEIL);
|
||||||
|
if (
|
||||||
|
[
|
||||||
|
rounded,
|
||||||
|
list,
|
||||||
|
rub,
|
||||||
|
list.div(factor).mul(exchange),
|
||||||
|
profit.mul(exchange),
|
||||||
|
logistics,
|
||||||
|
].some((v) => v.abs().gte('100000000000000'))
|
||||||
|
) {
|
||||||
|
reasons.add('金额超过可保存范围,请调整费率或输入');
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
options.push({
|
||||||
|
id: randomUUID(),
|
||||||
|
can_confirm: profit.gte(0),
|
||||||
|
transport_method_id: transport,
|
||||||
|
transport_name: r.transport_name,
|
||||||
|
product_type_id: r.product_type_id,
|
||||||
|
product_type_name: r.product_type_name,
|
||||||
|
logistics_rate_rule_id: r.id,
|
||||||
|
commission_rule_id: c.id,
|
||||||
|
commission_rate: c.commission_rate,
|
||||||
|
...(c.id ? {commission_details: {
|
||||||
|
category_name: c.category_name ?? '',currency_code:c.currency_code,version:c.version,source:c.source ?? 'manual_rule',
|
||||||
|
min_price:c.min_price,max_price:c.max_price,min_inclusive:c.min_inclusive,max_inclusive:c.max_inclusive,commission_rate:c.commission_rate,
|
||||||
|
}} : {}),
|
||||||
|
selling_price: rounded.toFixed(2),
|
||||||
|
list_price: list.toFixed(2),
|
||||||
|
currency_code: currency,
|
||||||
|
selling_price_rub: rub.toFixed(4),
|
||||||
|
list_price_rub: list.div(factor).mul(exchange).toFixed(4),
|
||||||
|
merchandise_value_cny: cny.toFixed(4),
|
||||||
|
profit_cny: profit.toFixed(2),
|
||||||
|
profit_rub: profit.mul(exchange).toFixed(4),
|
||||||
|
margin_rate: profit.div(cny).toFixed(6),
|
||||||
|
billable_weight_kg: billable.toFixed(3),
|
||||||
|
fees: {
|
||||||
|
logistics_cny: logistics.toFixed(4),
|
||||||
|
domestic_cny: domestic.toFixed(4),
|
||||||
|
commission_cny: cny.mul(c.commission_rate).toFixed(4),
|
||||||
|
ad_cny: cny.mul(p.ad_rate).toFixed(4),
|
||||||
|
last_mile_cny: cny.mul(p.last_mile_rate).toFixed(4),
|
||||||
|
withdrawal_cny: cny.mul(p.withdrawal_rate).toFixed(4),
|
||||||
|
other_cny: cny.mul(p.other_rate).toFixed(4),
|
||||||
|
},
|
||||||
|
warnings: [
|
||||||
|
...(profit.lt(0) ? ['亏损报价不能确认'] : []),
|
||||||
|
...(profit.lt(purchase.mul(p.desired_markup_rate))
|
||||||
|
? ['未达到目标加价利润']
|
||||||
|
: []),
|
||||||
|
...(!c.id ? ['使用临时佣金率'] : []),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!options.some((o) => o.transport_method_id === transport))
|
||||||
|
unavailable_options.push({
|
||||||
|
transport_method_id: transport,
|
||||||
|
reason: [...reasons].join(';') || '没有可用规则',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
options.sort((a, b) => D(a.selling_price).cmp(b.selling_price));
|
||||||
|
const recommended =
|
||||||
|
options.find(
|
||||||
|
(o) => o.transport_method_id === p.default_transport_method_id,
|
||||||
|
) ?? (!p.default_transport_method_id ? options[0] : undefined);
|
||||||
|
return {
|
||||||
|
options,
|
||||||
|
unavailable_options,
|
||||||
|
recommended_option_id: recommended?.id ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,199 @@
|
|||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Delete,
|
||||||
|
Get,
|
||||||
|
Headers,
|
||||||
|
Inject,
|
||||||
|
Param,
|
||||||
|
Patch,
|
||||||
|
Post,
|
||||||
|
Query,
|
||||||
|
Req,
|
||||||
|
Res,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import {
|
||||||
|
pricingPreviewRequestSchema,
|
||||||
|
pricingPolicyRequestSchema,
|
||||||
|
type Workspace,
|
||||||
|
} from '@atlas/contracts';
|
||||||
|
import { CurrentWorkspace } from '../workspace/current-workspace.decorator.js';
|
||||||
|
import type { AuthenticatedRequest } from '../auth/auth.guard.js';
|
||||||
|
import { ApiError } from '../common/api-error.js';
|
||||||
|
import { PricingService } from './pricing.service.js';
|
||||||
|
type Response = { status: (status: number) => unknown };
|
||||||
|
function parse<T>(schema: z.ZodType<T>, value: unknown): T {
|
||||||
|
const result = schema.safeParse(value);
|
||||||
|
if (!result.success)
|
||||||
|
throw new ApiError(
|
||||||
|
422,
|
||||||
|
'VALIDATION_ERROR',
|
||||||
|
'请检查定价输入',
|
||||||
|
result.error.issues.map((i) => ({
|
||||||
|
field: i.path.join('.'),
|
||||||
|
message: i.message,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
return result.data;
|
||||||
|
}
|
||||||
|
function key(value: string | undefined) {
|
||||||
|
if (!value || value.length > 200)
|
||||||
|
throw new ApiError(
|
||||||
|
400,
|
||||||
|
'IDEMPOTENCY_KEY_REQUIRED',
|
||||||
|
'需要 Idempotency-Key',
|
||||||
|
);
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
@ApiTags('Pricing')
|
||||||
|
@ApiBearerAuth('access-token')
|
||||||
|
@Controller()
|
||||||
|
export class PricingController {
|
||||||
|
constructor(
|
||||||
|
@Inject(PricingService) private readonly service: PricingService,
|
||||||
|
) {}
|
||||||
|
@Get('pricing/commission-rules') async commissionRules(
|
||||||
|
@CurrentWorkspace() w: Workspace,
|
||||||
|
@Query('store_id') store: string,
|
||||||
|
@Query('platform_product_category_id') category: string | undefined,
|
||||||
|
@Query('description_category_id') external: string | undefined,
|
||||||
|
) {
|
||||||
|
if (Boolean(category) === Boolean(external)) throw new ApiError(422,'VALIDATION_ERROR','请指定一种类目 ID');
|
||||||
|
return {data:await this.service.commissionRules(w.id,parse(z.uuid(),store),category ? parse(z.uuid(),category) : undefined,external ? parse(z.string().regex(/^[1-9]\d{0,17}$/),external) : undefined)};
|
||||||
|
}
|
||||||
|
@Get('pricing/categories') async categories(
|
||||||
|
@CurrentWorkspace() w: Workspace,
|
||||||
|
@Query('store_id') store: string,
|
||||||
|
@Query('q') q: string | undefined,
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
data: await this.service.categories(
|
||||||
|
w.id,
|
||||||
|
parse(z.uuid(), store),
|
||||||
|
parse(z.string().max(200), q ?? ''),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
@Get('pricing/catalog') async catalog(
|
||||||
|
@CurrentWorkspace() w: Workspace,
|
||||||
|
@Query('store_id') store: string,
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
data: await this.service.catalog(w.id, parse(z.uuid(), store)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
@Get('stores/:id/pricing-policies') async policies(
|
||||||
|
@CurrentWorkspace() w: Workspace,
|
||||||
|
@Param('id') id: string,
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
data: (await this.service.catalog(w.id, parse(z.uuid(), id)))
|
||||||
|
.policies,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
@Post('pricing/quote-previews') async preview(
|
||||||
|
@CurrentWorkspace() w: Workspace,
|
||||||
|
@Req() req: AuthenticatedRequest,
|
||||||
|
@Headers('idempotency-key') k: string | undefined,
|
||||||
|
@Body() body: unknown,
|
||||||
|
@Res({ passthrough: true }) res: Response,
|
||||||
|
) {
|
||||||
|
const r = await this.service.preview(
|
||||||
|
w.id,
|
||||||
|
req.auth.userId,
|
||||||
|
key(k),
|
||||||
|
parse(pricingPreviewRequestSchema, body),
|
||||||
|
);
|
||||||
|
res.status(r.status);
|
||||||
|
return r.body;
|
||||||
|
}
|
||||||
|
@Post('stores/:id/pricing-policies') async create(
|
||||||
|
@CurrentWorkspace() w: Workspace,
|
||||||
|
@Req() req: AuthenticatedRequest,
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Headers('idempotency-key') k: string | undefined,
|
||||||
|
@Body() body: unknown,
|
||||||
|
@Res({ passthrough: true }) res: Response,
|
||||||
|
) {
|
||||||
|
const r = await this.service.writePolicy(
|
||||||
|
w.id,
|
||||||
|
req.auth.userId,
|
||||||
|
key(k),
|
||||||
|
parse(z.uuid(), id),
|
||||||
|
null,
|
||||||
|
'create',
|
||||||
|
parse(pricingPolicyRequestSchema, body),
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
res.status(r.status);
|
||||||
|
return r.body;
|
||||||
|
}
|
||||||
|
@Patch('pricing-policies/:id') async update(
|
||||||
|
@CurrentWorkspace() w: Workspace,
|
||||||
|
@Req() req: AuthenticatedRequest,
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Headers('idempotency-key') k: string | undefined,
|
||||||
|
@Headers('if-match') match: string | undefined,
|
||||||
|
@Body() body: unknown,
|
||||||
|
) {
|
||||||
|
if (!match || !/^(?:[1-9]\d*|"[1-9]\d*")$/.test(match))
|
||||||
|
throw new ApiError(
|
||||||
|
428,
|
||||||
|
'PRECONDITION_REQUIRED',
|
||||||
|
'需要方案版本 If-Match',
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
await this.service.writePolicy(
|
||||||
|
w.id,
|
||||||
|
req.auth.userId,
|
||||||
|
key(k),
|
||||||
|
null,
|
||||||
|
parse(z.uuid(), id),
|
||||||
|
'update',
|
||||||
|
parse(pricingPolicyRequestSchema, body),
|
||||||
|
Number(match.replaceAll('"', '')),
|
||||||
|
)
|
||||||
|
).body;
|
||||||
|
}
|
||||||
|
@Post('pricing-policies/:id/set-default') async defaultPolicy(
|
||||||
|
@CurrentWorkspace() w: Workspace,
|
||||||
|
@Req() req: AuthenticatedRequest,
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Headers('idempotency-key') k: string | undefined,
|
||||||
|
@Res({ passthrough: true }) res: Response,
|
||||||
|
) {
|
||||||
|
const r = await this.service.writePolicy(
|
||||||
|
w.id,
|
||||||
|
req.auth.userId,
|
||||||
|
key(k),
|
||||||
|
null,
|
||||||
|
parse(z.uuid(), id),
|
||||||
|
'default',
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
res.status(r.status);
|
||||||
|
return r.body;
|
||||||
|
}
|
||||||
|
@Delete('pricing-policies/:id') async archive(
|
||||||
|
@CurrentWorkspace() w: Workspace,
|
||||||
|
@Req() req: AuthenticatedRequest,
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Headers('idempotency-key') k: string | undefined,
|
||||||
|
) {
|
||||||
|
return (
|
||||||
|
await this.service.writePolicy(
|
||||||
|
w.id,
|
||||||
|
req.auth.userId,
|
||||||
|
key(k),
|
||||||
|
null,
|
||||||
|
parse(z.uuid(), id),
|
||||||
|
'archive',
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
)
|
||||||
|
).body;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { CommissionImportController } from './commission-import.controller.js';
|
||||||
|
import { CommissionImportService } from './commission-import.service.js';
|
||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { IdempotencyService } from '../common/idempotency.service.js';
|
||||||
|
import { PricingController } from './pricing.controller.js';
|
||||||
|
import { PricingService } from './pricing.service.js';
|
||||||
|
import { PricingAdminController } from './pricing-admin.controller.js';
|
||||||
|
import { PricingAdminService } from './pricing-admin.service.js';
|
||||||
|
import { SystemAdminGuard } from '../platform-catalog-admin/system-admin.guard.js';
|
||||||
|
@Module({
|
||||||
|
controllers: [PricingController, PricingAdminController, CommissionImportController],
|
||||||
|
providers: [
|
||||||
|
PricingService,
|
||||||
|
CommissionImportService,
|
||||||
|
PricingAdminService,
|
||||||
|
SystemAdminGuard,
|
||||||
|
IdempotencyService,
|
||||||
|
],
|
||||||
|
exports: [PricingService],
|
||||||
|
})
|
||||||
|
export class PricingModule {}
|
||||||
@@ -0,0 +1,867 @@
|
|||||||
|
import { readCommissionRules } from './commission-rules.js';
|
||||||
|
import type { CommissionRulesResult } from '@atlas/contracts';
|
||||||
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
import type { Database, DatabaseTransaction } from '@atlas/database';
|
||||||
|
import type {
|
||||||
|
PricingCatalog,
|
||||||
|
PricingPolicy,
|
||||||
|
PricingPolicyRequest,
|
||||||
|
PricingPreview,
|
||||||
|
PricingPreviewRequest,
|
||||||
|
PricingParameters,
|
||||||
|
SaveProductEditorDraftRequest,
|
||||||
|
} from '@atlas/contracts';
|
||||||
|
import { pricingPreviewRequestSchema } from '@atlas/contracts';
|
||||||
|
import { DATABASE } from '../database/database.module.js';
|
||||||
|
import { APP_CONFIG, type AppConfig } from '../config/app-config.module.js';
|
||||||
|
import { ApiError } from '../common/api-error.js';
|
||||||
|
import { IdempotencyService } from '../common/idempotency.service.js';
|
||||||
|
import {
|
||||||
|
calculatePricing,
|
||||||
|
D,
|
||||||
|
fingerprint,
|
||||||
|
normalizeInput,
|
||||||
|
type CommissionRule,
|
||||||
|
type LogisticsRule,
|
||||||
|
} from './pricing-engine.js';
|
||||||
|
|
||||||
|
type Tx = DatabaseTransaction;
|
||||||
|
type Store = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
platform_id: string;
|
||||||
|
currency_code: string;
|
||||||
|
};
|
||||||
|
type PolicyRow = {
|
||||||
|
id: string;
|
||||||
|
store_id: string;
|
||||||
|
name: string;
|
||||||
|
note: string | null;
|
||||||
|
revision: number;
|
||||||
|
is_default: boolean;
|
||||||
|
rate_card_id: string;
|
||||||
|
default_transport_method_id: string | null;
|
||||||
|
domestic_shipping_and_label_fee_cny: string;
|
||||||
|
ad_rate: string;
|
||||||
|
last_mile_rate: string;
|
||||||
|
withdrawal_rate: string;
|
||||||
|
other_rate: string;
|
||||||
|
desired_markup_rate: string;
|
||||||
|
list_price_discount_rate: string;
|
||||||
|
allow_commission_override: boolean;
|
||||||
|
fx_strategy: string;
|
||||||
|
fixed_cny_to_rub_rate: string | null;
|
||||||
|
transport_ids: string[];
|
||||||
|
};
|
||||||
|
const active = `status='active' AND effective_from<=now() AND (effective_to IS NULL OR effective_to>now())`;
|
||||||
|
@Injectable()
|
||||||
|
export class PricingService {
|
||||||
|
constructor(
|
||||||
|
@Inject(DATABASE) private readonly db: Database,
|
||||||
|
@Inject(IdempotencyService)
|
||||||
|
private readonly idempotency: IdempotencyService,
|
||||||
|
@Inject(APP_CONFIG) private readonly config: AppConfig,
|
||||||
|
) {}
|
||||||
|
private get usableCardCondition() {
|
||||||
|
const reviewed = `(${active} AND reviewed_at IS NOT NULL AND reviewed_by_user_id IS NOT NULL)`;
|
||||||
|
return this.config.pricingAllowDraftRates
|
||||||
|
? `(${reviewed} OR status='draft')`
|
||||||
|
: reviewed;
|
||||||
|
}
|
||||||
|
async store(tx: Tx, id: string): Promise<Store> {
|
||||||
|
const r = await tx.query<Store>(
|
||||||
|
`SELECT id,name,platform_id,currency_code FROM atlas.stores WHERE id=$1 AND deleted_at IS NULL`,
|
||||||
|
[id],
|
||||||
|
);
|
||||||
|
if (!r.rows[0])
|
||||||
|
throw new ApiError(404, 'STORE_NOT_FOUND', '门店不存在');
|
||||||
|
if (!['CNY', 'RUB'].includes(r.rows[0].currency_code))
|
||||||
|
throw new ApiError(
|
||||||
|
422,
|
||||||
|
'PRICING_CURRENCY_UNSUPPORTED',
|
||||||
|
'定价仅支持 CNY / RUB 门店',
|
||||||
|
);
|
||||||
|
return r.rows[0];
|
||||||
|
}
|
||||||
|
async policies(tx: Tx, storeId: string): Promise<PricingPolicy[]> {
|
||||||
|
const result = await tx.query<PolicyRow>(
|
||||||
|
`SELECT p.*,ARRAY(SELECT transport_method_id FROM atlas.pricing_policy_transport_methods t WHERE t.pricing_policy_id=p.id AND is_enabled ORDER BY sort_order,transport_method_id) AS transport_ids FROM atlas.pricing_policies p WHERE p.store_id=$1 AND p.status='active' ORDER BY p.is_default DESC,p.name`,
|
||||||
|
[storeId],
|
||||||
|
);
|
||||||
|
return result.rows.map((p) => ({
|
||||||
|
id: p.id,
|
||||||
|
store_id: p.store_id,
|
||||||
|
name: p.name,
|
||||||
|
note: p.note,
|
||||||
|
revision: p.revision,
|
||||||
|
is_default: p.is_default,
|
||||||
|
allow_commission_override: p.allow_commission_override,
|
||||||
|
parameters: {
|
||||||
|
pricing_policy_id: p.id,
|
||||||
|
rate_card_id: p.rate_card_id,
|
||||||
|
transport_method_ids: p.transport_ids,
|
||||||
|
default_transport_method_id: p.default_transport_method_id,
|
||||||
|
domestic_shipping_and_label_fee_cny:
|
||||||
|
p.domestic_shipping_and_label_fee_cny,
|
||||||
|
ad_rate: p.ad_rate,
|
||||||
|
last_mile_rate: p.last_mile_rate,
|
||||||
|
withdrawal_rate: p.withdrawal_rate,
|
||||||
|
other_rate: p.other_rate,
|
||||||
|
desired_markup_rate: p.desired_markup_rate,
|
||||||
|
list_price_discount_rate: p.list_price_discount_rate,
|
||||||
|
mode: 'target_markup',
|
||||||
|
selling_price: null,
|
||||||
|
temporary_commission_rate: null,
|
||||||
|
fx:
|
||||||
|
p.fx_strategy === 'fixed'
|
||||||
|
? {
|
||||||
|
mode: 'policy_fixed',
|
||||||
|
rate: p.fixed_cny_to_rub_rate!,
|
||||||
|
}
|
||||||
|
: { mode: 'system' },
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
async commissionRules(workspaceId: string, storeId: string, categoryId?: string, externalId?: string): Promise<CommissionRulesResult> {
|
||||||
|
return this.db.workspaceTransaction(workspaceId, async tx => {
|
||||||
|
const store = await this.store(tx, storeId);
|
||||||
|
const categories = (await tx.query<{id:string}>(`SELECT id FROM atlas.platform_product_categories WHERE platform_id=$1 AND is_active AND ($2::uuid IS NULL OR id=$2) AND ($3::text IS NULL OR external_category_id=$3) ORDER BY id`, [store.platform_id, categoryId ?? null, externalId ?? null])).rows;
|
||||||
|
if (!categories.length) throw new ApiError(422, 'PRICING_CATEGORY_REQUIRED', '请选择当前平台的有效类目');
|
||||||
|
let resolvedCategoryId = categories[0].id;
|
||||||
|
// 外部类目下可以有多个类型,但不能将相互冲突的映射折叠成一个费率。
|
||||||
|
if (externalId) {
|
||||||
|
const mapped = await tx.query<{id:string; platform_product_category_id:string}>(`SELECT DISTINCT commission_category_id AS id,platform_product_category_id FROM atlas.platform_product_category_commission_mappings WHERE platform_id=$1 AND platform_product_category_id=ANY($2::uuid[]) AND fulfillment='rfbs' AND market_code='RU' AND status='active' AND effective_from<=now() AND (effective_to IS NULL OR effective_to>now())`,[store.platform_id,categories.map(c=>c.id)]);
|
||||||
|
if (new Set(mapped.rows.map(row => row.id)).size>1) throw new ApiError(422,'COMMISSION_MAPPING_CONFLICT','此类目下的商品类型使用不同规则,请指定具体商品类型');
|
||||||
|
resolvedCategoryId = mapped.rows[0]?.platform_product_category_id ?? resolvedCategoryId;
|
||||||
|
}
|
||||||
|
const {commissions} = await readCommissionRules(tx,store.platform_id,resolvedCategoryId,'rfbs');
|
||||||
|
if (!commissions.length) throw new ApiError(422,'PRICING_COMMISSION_UNAVAILABLE','该类目没有有效佣金规则');
|
||||||
|
const first=commissions[0];
|
||||||
|
return {category_name:first.category_name ?? '',currency_code:first.currency_code,version:first.version,source:first.source ?? 'manual_rule',effective_from:new Date(first.effective_from!).toISOString(),bands:commissions.map(c=>({id:c.id!,min_price:c.min_price,max_price:c.max_price,min_inclusive:c.min_inclusive,max_inclusive:c.max_inclusive,commission_rate:c.commission_rate}))};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
async categories(workspaceId: string, storeId: string, q: string) {
|
||||||
|
return this.db.workspaceTransaction(workspaceId, async (tx) => {
|
||||||
|
const store = await this.store(tx, storeId);
|
||||||
|
return (
|
||||||
|
await tx.query<{ id: string; name: string; path: string }>(
|
||||||
|
`SELECT id,name,path FROM atlas.platform_product_categories WHERE platform_id=$1 AND is_active AND (name ILIKE $2 OR path ILIKE $2) ORDER BY path LIMIT 50`,
|
||||||
|
[store.platform_id, `%${q}%`],
|
||||||
|
)
|
||||||
|
).rows;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
async exchange(tx: Tx) {
|
||||||
|
const r = await tx.query<{
|
||||||
|
id: string;
|
||||||
|
rate: string;
|
||||||
|
effective_on: string;
|
||||||
|
stale: boolean;
|
||||||
|
}>(
|
||||||
|
`SELECT id,rate::text,effective_on::text,(effective_on::timestamp AT TIME ZONE 'Europe/Moscow')<now()-interval '72 hours' AS stale FROM atlas.exchange_rates WHERE base_currency_code='CNY' AND quote_currency_code='RUB' AND source='cbr' AND kind='official_reference' ORDER BY effective_on DESC,fetched_at DESC LIMIT 1`,
|
||||||
|
);
|
||||||
|
return r.rows[0] ?? null;
|
||||||
|
}
|
||||||
|
async catalog(workspace: string, storeId: string): Promise<PricingCatalog> {
|
||||||
|
return this.db.workspaceTransaction(workspace, async (tx) => {
|
||||||
|
const store = await this.store(tx, storeId);
|
||||||
|
const cards = await tx.query<{
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
version: string;
|
||||||
|
development_only: boolean;
|
||||||
|
transports: Array<{ id: string; name: string }>;
|
||||||
|
}>(
|
||||||
|
`SELECT c.id,c.source_name AS name,c.version,(c.status='draft') AS development_only,coalesce((SELECT jsonb_agg(jsonb_build_object('id',t.id,'name',t.name) ORDER BY t.name) FROM atlas.logistics_transport_methods t WHERE t.rate_card_id=c.id AND t.status='active'),'[]') AS transports FROM atlas.logistics_rate_cards c WHERE c.platform_id=$1 AND c.fulfillment='rfbs' AND c.market_code='RU' AND ${this.usableCardCondition} ORDER BY c.status='active' DESC,c.effective_from DESC`,
|
||||||
|
[store.platform_id],
|
||||||
|
);
|
||||||
|
const pendingCards = await tx.query<{
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
version: string;
|
||||||
|
status: 'draft';
|
||||||
|
}>(
|
||||||
|
`SELECT id,source_name AS name,version,status FROM atlas.logistics_rate_cards WHERE platform_id=$1 AND fulfillment='rfbs' AND market_code='RU' AND status='draft' ORDER BY created_at DESC`,
|
||||||
|
[store.platform_id],
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
store,
|
||||||
|
rate_cards: cards.rows,
|
||||||
|
pending_rate_cards: pendingCards.rows,
|
||||||
|
policies: await this.policies(tx, storeId),
|
||||||
|
exchange_rate: await this.exchange(tx),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
/** 保存确认时复读规则;草稿卡是否参与新报价由配置控制。 */
|
||||||
|
async rules(tx: Tx, input: PricingPreviewRequest, store: Store) {
|
||||||
|
const card = (
|
||||||
|
await tx.query<{ id: string; version: string; status: string; updated_at: Date }>(
|
||||||
|
`SELECT * FROM atlas.logistics_rate_cards WHERE id=$1 AND platform_id=$2 AND fulfillment=$3 AND market_code='RU' AND ${this.usableCardCondition} FOR SHARE`,
|
||||||
|
[
|
||||||
|
input.pricing.rate_card_id,
|
||||||
|
store.platform_id,
|
||||||
|
input.fulfillment,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
).rows[0];
|
||||||
|
if (!card)
|
||||||
|
throw new ApiError(
|
||||||
|
422,
|
||||||
|
'PRICING_RULES_UNAVAILABLE',
|
||||||
|
'没有可用的物流费率,请检查费率卡状态和草稿卡使用配置',
|
||||||
|
);
|
||||||
|
const category = await tx.query(
|
||||||
|
`SELECT id FROM atlas.platform_product_categories WHERE id=$1 AND platform_id=$2 AND is_active`,
|
||||||
|
[input.platform_product_category_id, store.platform_id],
|
||||||
|
);
|
||||||
|
if (!category.rowCount)
|
||||||
|
throw new ApiError(
|
||||||
|
422,
|
||||||
|
'PRICING_CATEGORY_REQUIRED',
|
||||||
|
'请选择当前平台的有效类目',
|
||||||
|
);
|
||||||
|
if (input.pricing.pricing_policy_id)
|
||||||
|
await tx.query(
|
||||||
|
'SELECT id FROM atlas.pricing_policies WHERE id=$1 FOR SHARE',
|
||||||
|
[input.pricing.pricing_policy_id],
|
||||||
|
);
|
||||||
|
const policy = input.pricing.pricing_policy_id
|
||||||
|
? (await this.policies(tx, store.id)).find(
|
||||||
|
(p) => p.id === input.pricing.pricing_policy_id,
|
||||||
|
)
|
||||||
|
: null;
|
||||||
|
if (
|
||||||
|
input.pricing.pricing_policy_id &&
|
||||||
|
(!policy || policy.parameters.rate_card_id !== card.id)
|
||||||
|
)
|
||||||
|
throw new ApiError(
|
||||||
|
422,
|
||||||
|
'PRICING_POLICY_UNAVAILABLE',
|
||||||
|
'方案不可用或费率卡不匹配',
|
||||||
|
);
|
||||||
|
const logistics = (
|
||||||
|
await tx.query<LogisticsRule>(
|
||||||
|
`SELECT t.*,r.id,r.product_type_id,r.transport_method_id,m.name AS transport_name,t.name AS product_type_name,r.fixed_fee_cny,r.per_billable_kg_fee_cny,r.min_charge_cny,r.surcharge_rules,r.updated_at AS rule_updated_at,m.updated_at AS transport_updated_at FROM atlas.logistics_rate_rules r JOIN atlas.logistics_product_types t ON t.id=r.product_type_id JOIN atlas.logistics_transport_methods m ON m.id=r.transport_method_id AND m.rate_card_id=t.rate_card_id WHERE t.rate_card_id=$1 AND r.status=$3 AND m.status='active' AND m.id=ANY($2::uuid[]) ORDER BY r.id FOR SHARE OF r,t,m`,
|
||||||
|
[card.id, input.pricing.transport_method_ids, card.status],
|
||||||
|
)
|
||||||
|
).rows;
|
||||||
|
const official = await readCommissionRules(tx, store.platform_id, input.platform_product_category_id, input.fulfillment);
|
||||||
|
const mapping = official.mapping;
|
||||||
|
let commissions = official.commissions;
|
||||||
|
if (
|
||||||
|
!commissions.length &&
|
||||||
|
input.pricing.temporary_commission_rate !== null &&
|
||||||
|
(!policy || policy.allow_commission_override)
|
||||||
|
)
|
||||||
|
commissions = [
|
||||||
|
{
|
||||||
|
id: null,
|
||||||
|
currency_code: 'RUB',
|
||||||
|
min_price: null,
|
||||||
|
max_price: null,
|
||||||
|
min_inclusive: true,
|
||||||
|
max_inclusive: true,
|
||||||
|
commission_rate: input.pricing.temporary_commission_rate,
|
||||||
|
version: 'temporary',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
if (!commissions.length)
|
||||||
|
throw new ApiError(
|
||||||
|
422,
|
||||||
|
'PRICING_COMMISSION_UNAVAILABLE',
|
||||||
|
'该类目尚无有效佣金规则;请补充已核实的临时佣金率,并使用允许临时佣金的方案或本次自定义参数',
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
commissions.some((c) => c.id) &&
|
||||||
|
input.pricing.temporary_commission_rate !== null
|
||||||
|
)
|
||||||
|
throw new ApiError(
|
||||||
|
422,
|
||||||
|
'PRICING_COMMISSION_OVERRIDE_FORBIDDEN',
|
||||||
|
'已有正式佣金规则,不能临时覆盖',
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
card,
|
||||||
|
policy,
|
||||||
|
logistics,
|
||||||
|
commissions,
|
||||||
|
hash: fingerprint({
|
||||||
|
card,
|
||||||
|
policy,
|
||||||
|
logistics,
|
||||||
|
commissions,
|
||||||
|
mapping,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
async preview(
|
||||||
|
workspaceId: string,
|
||||||
|
userId: string,
|
||||||
|
key: string,
|
||||||
|
input: PricingPreviewRequest,
|
||||||
|
) {
|
||||||
|
return this.idempotency.run(
|
||||||
|
{
|
||||||
|
workspaceId,
|
||||||
|
userId,
|
||||||
|
key,
|
||||||
|
method: 'POST',
|
||||||
|
path: '/api/pricing/quote-previews',
|
||||||
|
request: input,
|
||||||
|
},
|
||||||
|
async (tx) => {
|
||||||
|
const store = await this.store(tx, input.store_id);
|
||||||
|
if (input.binding) {
|
||||||
|
const binding = input.binding;
|
||||||
|
const profile = (
|
||||||
|
await tx.query(
|
||||||
|
`SELECT id FROM atlas.product_platform_profiles WHERE id=$1 AND platform_id=$2`,
|
||||||
|
[binding.profile_id, store.platform_id],
|
||||||
|
)
|
||||||
|
).rows[0];
|
||||||
|
if (!profile)
|
||||||
|
throw new ApiError(
|
||||||
|
404,
|
||||||
|
'PROFILE_NOT_FOUND',
|
||||||
|
'商品发布配置不存在',
|
||||||
|
);
|
||||||
|
if (binding.variant_id) {
|
||||||
|
const listing = (
|
||||||
|
await tx.query<{ id: string; status: string; store_id: string }>(
|
||||||
|
`SELECT l.id,l.status,l.store_id FROM atlas.product_variant_platform_listings l JOIN atlas.product_variants v ON v.id=l.product_variant_id WHERE l.profile_id=$1 AND l.product_variant_id=$2 AND v.deleted_at IS NULL`,
|
||||||
|
[binding.profile_id, binding.variant_id],
|
||||||
|
)
|
||||||
|
).rows[0];
|
||||||
|
if (!listing)
|
||||||
|
throw new ApiError(
|
||||||
|
422,
|
||||||
|
'PRICING_VARIANT_UNAVAILABLE',
|
||||||
|
'SKU 不存在或已删除',
|
||||||
|
);
|
||||||
|
if (listing.status === 'imported' && listing.store_id !== store.id)
|
||||||
|
throw new ApiError(
|
||||||
|
422,
|
||||||
|
'PRICING_VARIANT_STORE_MISMATCH',
|
||||||
|
'已上架 SKU 请按首次导入店铺试算',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const rules = await this.rules(tx, input, store),
|
||||||
|
exchange = await this.exchange(tx);
|
||||||
|
let fx: string, origin: string;
|
||||||
|
if (input.pricing.fx.mode === 'policy_fixed') {
|
||||||
|
const fixed = rules.policy?.parameters.fx;
|
||||||
|
if (fixed?.mode !== 'policy_fixed' || !fixed.rate)
|
||||||
|
throw new ApiError(
|
||||||
|
422,
|
||||||
|
'PRICING_FX_REQUIRED',
|
||||||
|
'方案没有固定汇率',
|
||||||
|
);
|
||||||
|
fx = fixed.rate;
|
||||||
|
origin = 'policy_fixed';
|
||||||
|
} else if (input.pricing.fx.mode === 'one_time_override') {
|
||||||
|
fx = input.pricing.fx.rate!;
|
||||||
|
origin = 'quote_override';
|
||||||
|
} else {
|
||||||
|
if (!exchange)
|
||||||
|
throw new ApiError(
|
||||||
|
422,
|
||||||
|
'PRICING_FX_UNAVAILABLE',
|
||||||
|
'尚无 CBR 汇率,请稍后重试或明确填写本次汇率',
|
||||||
|
);
|
||||||
|
fx = D(exchange.rate).toDecimalPlaces(8).toString();
|
||||||
|
origin = 'system_rate';
|
||||||
|
}
|
||||||
|
const normalized = normalizeInput(input),
|
||||||
|
id = randomUUID(),
|
||||||
|
expires = new Date(Date.now() + 15 * 60_000).toISOString();
|
||||||
|
const result: PricingPreview = {
|
||||||
|
preview_id: id,
|
||||||
|
expires_at: expires,
|
||||||
|
input_hash: fingerprint(normalized),
|
||||||
|
...calculatePricing(
|
||||||
|
normalized,
|
||||||
|
store.currency_code,
|
||||||
|
fx,
|
||||||
|
rules.logistics,
|
||||||
|
rules.commissions,
|
||||||
|
),
|
||||||
|
context: {
|
||||||
|
currency_code: store.currency_code,
|
||||||
|
fx_rate: fx,
|
||||||
|
fx_origin: origin,
|
||||||
|
rule_version: rules.card.version,
|
||||||
|
development_only: rules.card.status === 'draft',
|
||||||
|
exchange_rate:
|
||||||
|
origin === 'system_rate' ? exchange : null,
|
||||||
|
warnings:
|
||||||
|
origin === 'system_rate' && exchange?.stale
|
||||||
|
? ['CBR 汇率超过 72 小时,请确认参考日期']
|
||||||
|
: [],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
await tx.query(
|
||||||
|
`INSERT INTO atlas.pricing_previews(id,workspace_id,user_id,store_id,platform_id,input,input_hash,rules_hash,result,expires_at) VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)`,
|
||||||
|
[
|
||||||
|
id,
|
||||||
|
workspaceId,
|
||||||
|
userId,
|
||||||
|
store.id,
|
||||||
|
store.platform_id,
|
||||||
|
JSON.stringify(normalized),
|
||||||
|
result.input_hash,
|
||||||
|
rules.hash,
|
||||||
|
JSON.stringify(result),
|
||||||
|
expires,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
return { status: 201, body: { data: result } };
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
async writePolicy(
|
||||||
|
workspaceId: string,
|
||||||
|
userId: string,
|
||||||
|
key: string,
|
||||||
|
storeId: string | null,
|
||||||
|
id: string | null,
|
||||||
|
action: 'create' | 'update' | 'default' | 'archive',
|
||||||
|
input: PricingPolicyRequest | null,
|
||||||
|
revision: number | null,
|
||||||
|
) {
|
||||||
|
return this.idempotency
|
||||||
|
.run(
|
||||||
|
{
|
||||||
|
workspaceId,
|
||||||
|
userId,
|
||||||
|
key,
|
||||||
|
method:
|
||||||
|
action === 'update'
|
||||||
|
? 'PATCH'
|
||||||
|
: action === 'archive'
|
||||||
|
? 'DELETE'
|
||||||
|
: 'POST',
|
||||||
|
path: id
|
||||||
|
? `/api/pricing-policies/${id}/${action}`
|
||||||
|
: `/api/stores/${storeId}/pricing-policies`,
|
||||||
|
request: { input, revision },
|
||||||
|
},
|
||||||
|
async (tx) => {
|
||||||
|
let current:
|
||||||
|
{ store_id: string; revision: number } | undefined;
|
||||||
|
if (id) {
|
||||||
|
current = (
|
||||||
|
await tx.query<{
|
||||||
|
store_id: string;
|
||||||
|
revision: number;
|
||||||
|
}>(
|
||||||
|
`SELECT store_id,revision FROM atlas.pricing_policies WHERE id=$1 AND status='active'`,
|
||||||
|
[id],
|
||||||
|
)
|
||||||
|
).rows[0];
|
||||||
|
if (!current)
|
||||||
|
throw new ApiError(
|
||||||
|
404,
|
||||||
|
'PRICING_POLICY_NOT_FOUND',
|
||||||
|
'方案不存在',
|
||||||
|
);
|
||||||
|
storeId = current.store_id;
|
||||||
|
}
|
||||||
|
const store = await this.store(tx, storeId!);
|
||||||
|
await tx.query(
|
||||||
|
`SELECT id FROM atlas.stores WHERE id=$1 FOR UPDATE`,
|
||||||
|
[store.id],
|
||||||
|
);
|
||||||
|
if (id)
|
||||||
|
current = (
|
||||||
|
await tx.query<{
|
||||||
|
store_id: string;
|
||||||
|
revision: number;
|
||||||
|
}>(
|
||||||
|
`SELECT store_id,revision FROM atlas.pricing_policies WHERE id=$1 AND status='active' FOR UPDATE`,
|
||||||
|
[id],
|
||||||
|
)
|
||||||
|
).rows[0];
|
||||||
|
if (id && !current)
|
||||||
|
throw new ApiError(
|
||||||
|
404,
|
||||||
|
'PRICING_POLICY_NOT_FOUND',
|
||||||
|
'方案不存在',
|
||||||
|
);
|
||||||
|
if (action === 'update' && current?.revision !== revision)
|
||||||
|
throw new ApiError(
|
||||||
|
409,
|
||||||
|
'VERSION_CONFLICT',
|
||||||
|
'方案已更新,请刷新后重试',
|
||||||
|
);
|
||||||
|
if (action === 'default') {
|
||||||
|
await tx.query(
|
||||||
|
`UPDATE atlas.pricing_policies SET is_default=false,revision=revision+1,updated_at=now() WHERE store_id=$1 AND is_default AND id<>$2`,
|
||||||
|
[store.id, id],
|
||||||
|
);
|
||||||
|
await tx.query(
|
||||||
|
`UPDATE atlas.pricing_policies SET is_default=true,revision=revision+1,updated_at=now() WHERE id=$1`,
|
||||||
|
[id],
|
||||||
|
);
|
||||||
|
} else if (action === 'archive')
|
||||||
|
await tx.query(
|
||||||
|
`UPDATE atlas.pricing_policies SET status='archived',is_default=false,revision=revision+1,updated_at=now() WHERE id=$1`,
|
||||||
|
[id],
|
||||||
|
);
|
||||||
|
else {
|
||||||
|
const p = input!.parameters;
|
||||||
|
if (p.fx.mode === 'policy_fixed' && !p.fx.rate)
|
||||||
|
throw new ApiError(
|
||||||
|
422,
|
||||||
|
'PRICING_FX_REQUIRED',
|
||||||
|
'固定汇率不能为空',
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
p.default_transport_method_id &&
|
||||||
|
!p.transport_method_ids.includes(
|
||||||
|
p.default_transport_method_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
throw new ApiError(
|
||||||
|
422,
|
||||||
|
'PRICING_TRANSPORT_INVALID',
|
||||||
|
'默认运输方式必须包含在比较列表中',
|
||||||
|
);
|
||||||
|
const card = (
|
||||||
|
await tx.query(
|
||||||
|
`SELECT id FROM atlas.logistics_rate_cards WHERE id=$1 AND platform_id=$2 AND fulfillment='rfbs' AND market_code='RU' AND ${this.usableCardCondition}`,
|
||||||
|
[p.rate_card_id, store.platform_id],
|
||||||
|
)
|
||||||
|
).rowCount;
|
||||||
|
const transports = (
|
||||||
|
await tx.query(
|
||||||
|
`SELECT id FROM atlas.logistics_transport_methods WHERE rate_card_id=$1 AND status='active' AND id=ANY($2::uuid[])`,
|
||||||
|
[p.rate_card_id, p.transport_method_ids],
|
||||||
|
)
|
||||||
|
).rowCount;
|
||||||
|
if (
|
||||||
|
!card ||
|
||||||
|
transports !== new Set(p.transport_method_ids).size
|
||||||
|
)
|
||||||
|
throw new ApiError(
|
||||||
|
422,
|
||||||
|
'PRICING_RULES_UNAVAILABLE',
|
||||||
|
'费率卡或运输方式不可用',
|
||||||
|
);
|
||||||
|
const values = [
|
||||||
|
input!.name,
|
||||||
|
input!.note,
|
||||||
|
p.rate_card_id,
|
||||||
|
p.default_transport_method_id,
|
||||||
|
p.domestic_shipping_and_label_fee_cny,
|
||||||
|
p.ad_rate,
|
||||||
|
p.last_mile_rate,
|
||||||
|
p.withdrawal_rate,
|
||||||
|
p.other_rate,
|
||||||
|
p.desired_markup_rate,
|
||||||
|
p.list_price_discount_rate,
|
||||||
|
input!.allow_commission_override,
|
||||||
|
p.fx.mode === 'policy_fixed' ? 'fixed' : 'system',
|
||||||
|
p.fx.mode === 'policy_fixed' ? p.fx.rate : null,
|
||||||
|
];
|
||||||
|
if (action === 'create') {
|
||||||
|
id = randomUUID();
|
||||||
|
await tx.query(
|
||||||
|
`INSERT INTO atlas.pricing_policies(name,note,rate_card_id,default_transport_method_id,domestic_shipping_and_label_fee_cny,ad_rate,last_mile_rate,withdrawal_rate,other_rate,desired_markup_rate,list_price_discount_rate,allow_commission_override,fx_strategy,fixed_cny_to_rub_rate,id,workspace_id,store_id,platform_id) VALUES(${values.map((_, i) => `$${i + 1}`).join(',')},$15,$16,$17,$18)`,
|
||||||
|
[
|
||||||
|
...values,
|
||||||
|
id,
|
||||||
|
workspaceId,
|
||||||
|
store.id,
|
||||||
|
store.platform_id,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
} else
|
||||||
|
await tx.query(
|
||||||
|
`UPDATE atlas.pricing_policies SET name=$1,note=$2,rate_card_id=$3,default_transport_method_id=$4,domestic_shipping_and_label_fee_cny=$5,ad_rate=$6,last_mile_rate=$7,withdrawal_rate=$8,other_rate=$9,desired_markup_rate=$10,list_price_discount_rate=$11,allow_commission_override=$12,fx_strategy=$13,fixed_cny_to_rub_rate=$14,revision=revision+1,updated_at=now() WHERE id=$15`,
|
||||||
|
[...values, id],
|
||||||
|
);
|
||||||
|
await tx.query(
|
||||||
|
`DELETE FROM atlas.pricing_policy_transport_methods WHERE pricing_policy_id=$1`,
|
||||||
|
[id],
|
||||||
|
);
|
||||||
|
for (const [i, t] of [
|
||||||
|
...new Set(p.transport_method_ids),
|
||||||
|
].entries())
|
||||||
|
await tx.query(
|
||||||
|
`INSERT INTO atlas.pricing_policy_transport_methods(workspace_id,pricing_policy_id,transport_method_id,sort_order) VALUES($1,$2,$3,$4)`,
|
||||||
|
[workspaceId, id, t, i],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
status: action === 'create' ? 201 : 200,
|
||||||
|
body: {
|
||||||
|
data:
|
||||||
|
action === 'archive'
|
||||||
|
? { id, archived: true }
|
||||||
|
: (await this.policies(tx, store.id)).find(
|
||||||
|
(p) => p.id === id,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.catch((error: unknown) => {
|
||||||
|
if (
|
||||||
|
error &&
|
||||||
|
typeof error === 'object' &&
|
||||||
|
'code' in error &&
|
||||||
|
error.code === '23505'
|
||||||
|
)
|
||||||
|
throw new ApiError(
|
||||||
|
409,
|
||||||
|
'PRICING_POLICY_NAME_CONFLICT',
|
||||||
|
'此门店已存在同名方案,请使用其他名称',
|
||||||
|
);
|
||||||
|
throw error;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
/** 必须在 editor-draft 的现有事务中调用,任何确认失败使商品修改一起回滚。 */
|
||||||
|
async persistDraft(
|
||||||
|
tx: Tx,
|
||||||
|
workspaceId: string,
|
||||||
|
userId: string,
|
||||||
|
profileId: string,
|
||||||
|
input: SaveProductEditorDraftRequest,
|
||||||
|
created: Array<{ client_key: string; id: string }>,
|
||||||
|
) {
|
||||||
|
for (const v of input.variants) {
|
||||||
|
const variantId =
|
||||||
|
v.id ?? created.find((c) => c.client_key === v.client_key)!.id;
|
||||||
|
const listing = (
|
||||||
|
await tx.query<{
|
||||||
|
id: string;
|
||||||
|
status: string;
|
||||||
|
store_id: string;
|
||||||
|
pricing_draft: PricingParameters | null;
|
||||||
|
pricing_quote_id: string | null;
|
||||||
|
}>(
|
||||||
|
`SELECT id,status,store_id,pricing_draft,pricing_quote_id FROM atlas.product_variant_platform_listings WHERE profile_id=$1 AND product_variant_id=$2 FOR UPDATE`,
|
||||||
|
[profileId, variantId],
|
||||||
|
)
|
||||||
|
).rows[0];
|
||||||
|
// 已上架 SKU 只有显式应用新报价时更新本地记录,普通草稿保存保留历史报价。
|
||||||
|
if (listing.status === 'imported' && !v.pricing_selection) continue;
|
||||||
|
const state = (
|
||||||
|
await tx.query<{
|
||||||
|
product_id: string;
|
||||||
|
platform_id: string;
|
||||||
|
store_id: string;
|
||||||
|
currency_code: string;
|
||||||
|
category_id: string;
|
||||||
|
fulfillment: 'rfbs';
|
||||||
|
purchase_cost_cny: string;
|
||||||
|
package: PricingPreviewRequest['package'];
|
||||||
|
}>(
|
||||||
|
`SELECT v.product_id,p.platform_id,s.id AS store_id,s.currency_code,p.platform_product_category_id AS category_id,p.fulfillment,v.purchase_cost_cny::text,jsonb_build_object('shipping_weight_g',coalesce(v.shipping_weight_g,g.default_shipping_weight_g)::text,'length_mm',coalesce(v.package_length_mm,g.default_package_length_mm)::text,'width_mm',coalesce(v.package_width_mm,g.default_package_width_mm)::text,'height_mm',coalesce(v.package_height_mm,g.default_package_height_mm)::text,'status',coalesce(v.package_spec_status,g.package_spec_status)) AS package FROM atlas.product_variants v JOIN atlas.products g ON g.id=v.product_id JOIN atlas.product_platform_profiles p ON p.id=$1 JOIN atlas.stores s ON s.id=coalesce($3::uuid,p.target_store_id) WHERE v.id=$2`,
|
||||||
|
[
|
||||||
|
profileId,
|
||||||
|
variantId,
|
||||||
|
listing.status === 'imported' ? listing.store_id : null,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
).rows[0];
|
||||||
|
const params =
|
||||||
|
v.pricing_draft === undefined
|
||||||
|
? listing.pricing_draft
|
||||||
|
: v.pricing_draft;
|
||||||
|
const parsed = pricingPreviewRequestSchema.safeParse({
|
||||||
|
store_id: state.store_id,
|
||||||
|
platform_product_category_id: state.category_id,
|
||||||
|
fulfillment: state.fulfillment,
|
||||||
|
purchase_cost_cny:
|
||||||
|
state.purchase_cost_cny === null
|
||||||
|
? null
|
||||||
|
: D(state.purchase_cost_cny).toFixed(2),
|
||||||
|
package: state.package,
|
||||||
|
pricing: params,
|
||||||
|
binding: {
|
||||||
|
profile_id: profileId,
|
||||||
|
variant_id: v.id,
|
||||||
|
client_key: v.id ? null : v.client_key,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const normalized = parsed.success
|
||||||
|
? normalizeInput(parsed.data)
|
||||||
|
: null;
|
||||||
|
const fail = () => {
|
||||||
|
throw new ApiError(
|
||||||
|
409,
|
||||||
|
'PRICING_PREVIEW_STALE',
|
||||||
|
`${v.display_name ?? 'SKU'} 的报价已失效,请重新试算或仅保存草稿`,
|
||||||
|
[
|
||||||
|
{
|
||||||
|
field: `variants.${variantId}.pricing_selection`,
|
||||||
|
code: 'PRICING_PREVIEW_STALE',
|
||||||
|
message: '报价输入、规则或有效期已变化',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
);
|
||||||
|
};
|
||||||
|
if (!v.pricing_selection) {
|
||||||
|
// 旧报价保留供审计,当前引用仅在输入和归属仍一致时保持有效。
|
||||||
|
const old = listing.pricing_quote_id
|
||||||
|
? (
|
||||||
|
await tx.query<{
|
||||||
|
input_snapshot: {
|
||||||
|
input: PricingPreviewRequest;
|
||||||
|
context: PricingPreview['context'];
|
||||||
|
};
|
||||||
|
}>(
|
||||||
|
`SELECT input_snapshot FROM atlas.pricing_quotes WHERE id=$1`,
|
||||||
|
[listing.pricing_quote_id],
|
||||||
|
)
|
||||||
|
).rows[0]
|
||||||
|
: null;
|
||||||
|
const same =
|
||||||
|
normalized &&
|
||||||
|
old &&
|
||||||
|
old.input_snapshot.context.currency_code ===
|
||||||
|
state.currency_code &&
|
||||||
|
fingerprint({ ...normalized, binding: null }) ===
|
||||||
|
fingerprint({
|
||||||
|
...old.input_snapshot.input,
|
||||||
|
binding: null,
|
||||||
|
});
|
||||||
|
await tx.query(
|
||||||
|
`UPDATE atlas.product_variant_platform_listings SET pricing_draft=$2,pricing_valid=$3,currency_code=$4,pricing_quote_id=CASE WHEN store_id=$5 THEN pricing_quote_id ELSE NULL END WHERE id=$1`,
|
||||||
|
[
|
||||||
|
listing.id,
|
||||||
|
params ? JSON.stringify(params) : null,
|
||||||
|
Boolean(same),
|
||||||
|
state.currency_code,
|
||||||
|
old?.input_snapshot.input.store_id ?? state.store_id,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!normalized) fail();
|
||||||
|
const selection = v.pricing_selection;
|
||||||
|
const preview = (
|
||||||
|
await tx.query<{
|
||||||
|
input: PricingPreviewRequest;
|
||||||
|
input_hash: string;
|
||||||
|
rules_hash: string;
|
||||||
|
result: PricingPreview;
|
||||||
|
valid: boolean;
|
||||||
|
}>(
|
||||||
|
`SELECT input,input_hash,rules_hash,result,expires_at>now() AS valid FROM atlas.pricing_previews WHERE id=$1 AND user_id=$2 FOR SHARE`,
|
||||||
|
[selection.preview_id, userId],
|
||||||
|
)
|
||||||
|
).rows[0];
|
||||||
|
if (
|
||||||
|
!preview ||
|
||||||
|
!preview.valid ||
|
||||||
|
selection.input_hash !== preview.input_hash ||
|
||||||
|
fingerprint(normalized) !== preview.input_hash
|
||||||
|
)
|
||||||
|
fail();
|
||||||
|
const store = await this.store(tx, state.store_id);
|
||||||
|
let currentRules: Awaited<ReturnType<PricingService['rules']>>;
|
||||||
|
try {
|
||||||
|
currentRules = await this.rules(tx, normalized!, store);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof ApiError && error.getStatus() === 422)
|
||||||
|
return fail();
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
currentRules.hash !== preview.rules_hash ||
|
||||||
|
preview.result.context.currency_code !== state.currency_code
|
||||||
|
)
|
||||||
|
fail();
|
||||||
|
const option = preview.result.options.find(
|
||||||
|
(o) => o.id === selection.option_id,
|
||||||
|
);
|
||||||
|
if (!option || !option.can_confirm) fail();
|
||||||
|
const o = option!,
|
||||||
|
quoteId = randomUUID(),
|
||||||
|
selectedId = randomUUID();
|
||||||
|
const savedInput = {
|
||||||
|
...normalized!,
|
||||||
|
binding: {
|
||||||
|
profile_id: profileId,
|
||||||
|
variant_id: variantId,
|
||||||
|
client_key: null,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
await tx.query(
|
||||||
|
`INSERT INTO atlas.pricing_quotes(id,workspace_id,store_id,platform_id,pricing_policy_id,product_variant_id,product_id,profile_id,platform_product_category_id,fulfillment,purchase_cost_cny,package_weight_kg,package_length_cm,package_width_cm,package_height_cm,fx_cny_to_rub,fx_rate_origin,input_snapshot,status,selected_option_id,calculated_at,confirmed_at,created_by_user_id,input_hash) VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,'confirmed',$19,now(),now(),$20,$21)`,
|
||||||
|
[
|
||||||
|
quoteId,
|
||||||
|
workspaceId,
|
||||||
|
state.store_id,
|
||||||
|
state.platform_id,
|
||||||
|
params!.pricing_policy_id,
|
||||||
|
variantId,
|
||||||
|
state.product_id,
|
||||||
|
profileId,
|
||||||
|
state.category_id,
|
||||||
|
state.fulfillment,
|
||||||
|
state.purchase_cost_cny,
|
||||||
|
D(state.package.shipping_weight_g).div(1000).toString(),
|
||||||
|
D(state.package.length_mm).div(10).toString(),
|
||||||
|
D(state.package.width_mm).div(10).toString(),
|
||||||
|
D(state.package.height_mm).div(10).toString(),
|
||||||
|
preview.result.context.fx_rate,
|
||||||
|
preview.result.context.fx_origin,
|
||||||
|
JSON.stringify({
|
||||||
|
input: savedInput,
|
||||||
|
context: preview.result.context,
|
||||||
|
rules: currentRules,
|
||||||
|
}),
|
||||||
|
selectedId,
|
||||||
|
userId,
|
||||||
|
fingerprint(savedInput),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
for (const candidate of preview.result.options) {
|
||||||
|
const f = candidate.fees,
|
||||||
|
fx = D(preview.result.context.fx_rate);
|
||||||
|
await tx.query(
|
||||||
|
`INSERT INTO atlas.pricing_quote_options(id,workspace_id,quote_id,logistics_rate_rule_id,commission_rule_id,logistics_product_type_id,transport_method_id,merchandise_value_cny,selling_price_rub,list_price_rub,billable_weight_kg,logistics_fee_cny,commission_fee_rub,domestic_fee_cny,ad_fee_rub,last_mile_fee_rub,withdrawal_fee_rub,other_fee_rub,profit_rub,calculation_snapshot) VALUES(${Array.from({ length: 20 }, (_, i) => `$${i + 1}`).join(',')})`,
|
||||||
|
[
|
||||||
|
candidate.id === o.id ? selectedId : randomUUID(),
|
||||||
|
workspaceId,
|
||||||
|
quoteId,
|
||||||
|
candidate.logistics_rate_rule_id,
|
||||||
|
candidate.commission_rule_id,
|
||||||
|
candidate.product_type_id,
|
||||||
|
candidate.transport_method_id,
|
||||||
|
candidate.merchandise_value_cny,
|
||||||
|
candidate.selling_price_rub,
|
||||||
|
candidate.list_price_rub,
|
||||||
|
candidate.billable_weight_kg,
|
||||||
|
f.logistics_cny,
|
||||||
|
fx.mul(f.commission_cny).toFixed(4),
|
||||||
|
f.domestic_cny,
|
||||||
|
fx.mul(f.ad_cny).toFixed(4),
|
||||||
|
fx.mul(f.last_mile_cny).toFixed(4),
|
||||||
|
fx.mul(f.withdrawal_cny).toFixed(4),
|
||||||
|
fx.mul(f.other_cny).toFixed(4),
|
||||||
|
candidate.profit_rub,
|
||||||
|
JSON.stringify(candidate),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await tx.query(
|
||||||
|
`UPDATE atlas.product_variant_platform_listings SET pricing_quote_id=$2,pricing_draft=$3,pricing_valid=true,selling_price=$4,list_price=$5,currency_code=$6 WHERE id=$1`,
|
||||||
|
[
|
||||||
|
listing.id,
|
||||||
|
quoteId,
|
||||||
|
JSON.stringify(params),
|
||||||
|
o.selling_price,
|
||||||
|
o.list_price,
|
||||||
|
o.currency_code,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,233 @@
|
|||||||
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
|
import type { ProductAiTask, ProductEditorSnapshot } from '@atlas/contracts';
|
||||||
|
import type { Database } from '@atlas/database';
|
||||||
|
import { ApiError } from '../common/api-error.js';
|
||||||
|
import { IdempotencyService } from '../common/idempotency.service.js';
|
||||||
|
import { DATABASE } from '../database/database.module.js';
|
||||||
|
import { ProductEditorService } from './product-editor.service.js';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ProductAiService {
|
||||||
|
public constructor(
|
||||||
|
@Inject(DATABASE) private readonly database: Database,
|
||||||
|
@Inject(IdempotencyService)
|
||||||
|
private readonly idempotency: IdempotencyService,
|
||||||
|
@Inject(ProductEditorService)
|
||||||
|
private readonly editor: ProductEditorService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/** 固化当前编辑快照并在同一事务写入任务与 Outbox。 */
|
||||||
|
async create(
|
||||||
|
workspaceId: string,
|
||||||
|
userId: string,
|
||||||
|
profileId: string,
|
||||||
|
platformId: string,
|
||||||
|
variantId: string | null,
|
||||||
|
key: string,
|
||||||
|
hint: string | undefined,
|
||||||
|
) {
|
||||||
|
const profile = await this.database.workspaceTransaction(
|
||||||
|
workspaceId,
|
||||||
|
async (tx) => {
|
||||||
|
const row = await tx.query<{ product_id: string }>(
|
||||||
|
`SELECT product_id FROM atlas.product_platform_profiles
|
||||||
|
WHERE id = $1 AND platform_id = $2`,
|
||||||
|
[profileId, platformId],
|
||||||
|
);
|
||||||
|
if (!row.rows[0])
|
||||||
|
throw new ApiError(
|
||||||
|
404,
|
||||||
|
'PROFILE_NOT_FOUND',
|
||||||
|
'商品发布配置不存在',
|
||||||
|
);
|
||||||
|
return row.rows[0];
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const snapshot = await this.editor.read(
|
||||||
|
workspaceId,
|
||||||
|
profile.product_id,
|
||||||
|
platformId,
|
||||||
|
);
|
||||||
|
if (snapshot.profile.id !== profileId)
|
||||||
|
throw new ApiError(
|
||||||
|
409,
|
||||||
|
'PROFILE_CHANGED',
|
||||||
|
'商品发布配置已变化,请刷新页面',
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
variantId &&
|
||||||
|
!snapshot.variants.some((variant) => variant.id === variantId)
|
||||||
|
)
|
||||||
|
throw new ApiError(404, 'VARIANT_NOT_FOUND', 'SKU 不属于当前商品');
|
||||||
|
const kind = variantId ? 'sku_copy' : 'autofill';
|
||||||
|
const path = variantId
|
||||||
|
? `/api/product-platform-profiles/${profileId}/variants/${variantId}/ai/copy`
|
||||||
|
: `/api/product-platform-profiles/${profileId}/ai/autofills`;
|
||||||
|
return this.idempotency.run(
|
||||||
|
{
|
||||||
|
workspaceId,
|
||||||
|
userId,
|
||||||
|
method: 'POST',
|
||||||
|
path,
|
||||||
|
key,
|
||||||
|
request: { platform_id: platformId, hint: hint ?? null },
|
||||||
|
},
|
||||||
|
async (tx) => {
|
||||||
|
const current = await tx.query<{
|
||||||
|
product_revision: number;
|
||||||
|
profile_revision: number;
|
||||||
|
source_snapshot_revision: number;
|
||||||
|
}>(
|
||||||
|
`SELECT product.revision AS product_revision,
|
||||||
|
profile.revision AS profile_revision,
|
||||||
|
item.source_snapshot_revision
|
||||||
|
FROM atlas.product_platform_profiles profile
|
||||||
|
JOIN atlas.products product ON product.id = profile.product_id
|
||||||
|
JOIN atlas.collection_items item ON item.id = product.collection_item_id
|
||||||
|
WHERE profile.id = $1 AND profile.platform_id = $2
|
||||||
|
FOR UPDATE OF profile, product, item`,
|
||||||
|
[profileId, platformId],
|
||||||
|
);
|
||||||
|
const row = current.rows[0];
|
||||||
|
if (
|
||||||
|
!row ||
|
||||||
|
Number(row.product_revision) !==
|
||||||
|
snapshot.product.revision ||
|
||||||
|
Number(row.profile_revision) !==
|
||||||
|
snapshot.profile.revision ||
|
||||||
|
Number(row.source_snapshot_revision) !==
|
||||||
|
snapshot.source_material.source_snapshot_revision
|
||||||
|
) {
|
||||||
|
throw new ApiError(
|
||||||
|
409,
|
||||||
|
'AI_INPUT_CHANGED',
|
||||||
|
'商品资料已变化,请重新点击 AI 填报',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const input = taskInput(snapshot, variantId, hint);
|
||||||
|
const inserted = await tx.query<{ id: string }>(
|
||||||
|
`INSERT INTO atlas.product_ai_tasks
|
||||||
|
(workspace_id, product_id, profile_id, variant_id, kind, input_snapshot, created_by_user_id)
|
||||||
|
VALUES (current_setting('app.workspace_id')::uuid, $1, $2, $3, $4, $5::jsonb, $6)
|
||||||
|
RETURNING id`,
|
||||||
|
[
|
||||||
|
profile.product_id,
|
||||||
|
profileId,
|
||||||
|
variantId,
|
||||||
|
kind,
|
||||||
|
JSON.stringify(input),
|
||||||
|
userId,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
const taskId = inserted.rows[0].id;
|
||||||
|
await tx.query(
|
||||||
|
`INSERT INTO atlas.outbox_events
|
||||||
|
(workspace_id, aggregate_type, aggregate_id, event_type, event_payload, deduplication_key)
|
||||||
|
VALUES (current_setting('app.workspace_id')::uuid, 'product_ai_task', $1,
|
||||||
|
'product_ai_task.requested', $2::jsonb, $3)`,
|
||||||
|
[
|
||||||
|
taskId,
|
||||||
|
JSON.stringify({ task_id: taskId }),
|
||||||
|
`product_ai_task:${taskId}`,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
status: 202,
|
||||||
|
body: { data: { ai_task_id: taskId, status: 'queued' } },
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 只返回已校验结果和输入版本,不暴露采集快照与模型提示词。 */
|
||||||
|
async read(
|
||||||
|
workspaceId: string,
|
||||||
|
taskId: string,
|
||||||
|
platformId: string,
|
||||||
|
): Promise<ProductAiTask> {
|
||||||
|
return this.database.workspaceTransaction(workspaceId, async (tx) => {
|
||||||
|
const result = await tx.query<{
|
||||||
|
id: string;
|
||||||
|
kind: 'autofill' | 'sku_copy' | 'attribute_fill';
|
||||||
|
status: ProductAiTask['status'];
|
||||||
|
variant_id: string | null;
|
||||||
|
input_snapshot: Record<string, unknown>;
|
||||||
|
result: ProductAiTask['result'];
|
||||||
|
error_code: string | null;
|
||||||
|
error_message: string | null;
|
||||||
|
}>(
|
||||||
|
`SELECT task.id, task.kind, task.status, task.variant_id,
|
||||||
|
task.input_snapshot, task.result, task.error_code, task.error_message
|
||||||
|
FROM atlas.product_ai_tasks task
|
||||||
|
JOIN atlas.product_platform_profiles profile ON profile.id = task.profile_id
|
||||||
|
WHERE task.id = $1 AND profile.platform_id = $2`,
|
||||||
|
[taskId, platformId],
|
||||||
|
);
|
||||||
|
const row = result.rows[0];
|
||||||
|
if (!row)
|
||||||
|
throw new ApiError(404, 'AI_TASK_NOT_FOUND', 'AI 任务不存在');
|
||||||
|
const input = row.input_snapshot as {
|
||||||
|
revision?: ProductAiTask['input_revision'];
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
kind: row.kind,
|
||||||
|
status: row.status,
|
||||||
|
input_revision: input.revision ?? {
|
||||||
|
product: 0,
|
||||||
|
profile: 0,
|
||||||
|
source_snapshot: 0,
|
||||||
|
},
|
||||||
|
variant_id: row.variant_id,
|
||||||
|
result:
|
||||||
|
row.status === 'succeeded' || row.status === 'partial'
|
||||||
|
? row.result
|
||||||
|
: null,
|
||||||
|
error_code: row.error_code,
|
||||||
|
error_message: row.error_message,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function taskInput(
|
||||||
|
snapshot: ProductEditorSnapshot,
|
||||||
|
variantId: string | null,
|
||||||
|
hint?: string,
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
revision: {
|
||||||
|
product: snapshot.product.revision,
|
||||||
|
profile: snapshot.profile.revision,
|
||||||
|
source_snapshot: snapshot.source_material.source_snapshot_revision,
|
||||||
|
},
|
||||||
|
platform_id: snapshot.profile.platform_id,
|
||||||
|
product_id: snapshot.product.id,
|
||||||
|
profile_id: snapshot.profile.id,
|
||||||
|
variant_id: variantId,
|
||||||
|
hint: hint ?? null,
|
||||||
|
source_material: snapshot.source_material,
|
||||||
|
common: snapshot.product.common,
|
||||||
|
profile: {
|
||||||
|
title: snapshot.profile.title,
|
||||||
|
tags: snapshot.profile.tags,
|
||||||
|
description: snapshot.profile.description,
|
||||||
|
category: snapshot.profile.category,
|
||||||
|
target_store_id: snapshot.profile.target_store.id,
|
||||||
|
},
|
||||||
|
variants: snapshot.variants.map((variant) => ({
|
||||||
|
id: variant.id,
|
||||||
|
display_name: variant.display_name,
|
||||||
|
source_sku: variant.source_sku,
|
||||||
|
specification_values: variant.specification_values,
|
||||||
|
listing: {
|
||||||
|
status: variant.listing.status,
|
||||||
|
offer_id: variant.listing.offer_id,
|
||||||
|
},
|
||||||
|
title: variant.title,
|
||||||
|
tags: variant.tags,
|
||||||
|
description: variant.description,
|
||||||
|
package_spec: variant.effective_package_spec,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import type { ProductAttributeSchema } from '@atlas/contracts';
|
||||||
|
|
||||||
|
type AttributeDefinition = Omit<ProductAttributeSchema, 'editor_field'>;
|
||||||
|
type EditorField = Exclude<ProductAttributeSchema['editor_field'], null>;
|
||||||
|
|
||||||
|
/** Ozon 固定属性 ID 到编辑器内建字段的唯一映射,不使用任何名称或翻译文本判断。 */
|
||||||
|
const editorFieldByAttributeId: Readonly<Record<string, EditorField>> = {
|
||||||
|
'85': 'brand',
|
||||||
|
'9048': 'model',
|
||||||
|
'8229': 'category',
|
||||||
|
'23171': 'tags',
|
||||||
|
'11254': 'rich_content',
|
||||||
|
'4191': 'description',
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 仅依据 Ozon 属性 ID 将类目属性绑定到编辑器内建字段。 */
|
||||||
|
export function editorFieldForAttributeId(
|
||||||
|
platformAttributeId: string,
|
||||||
|
): ProductAttributeSchema['editor_field'] {
|
||||||
|
return editorFieldByAttributeId[platformAttributeId] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 为数据库中的原始类目属性补充前端和校验共用的内建字段绑定。 */
|
||||||
|
export function bindEditorFields(
|
||||||
|
definitions: AttributeDefinition[],
|
||||||
|
): ProductAttributeSchema[] {
|
||||||
|
return definitions.map((definition) => ({
|
||||||
|
...definition,
|
||||||
|
editor_field: editorFieldForAttributeId(
|
||||||
|
definition.platform_attribute_id,
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import type {
|
||||||
|
ProductCatalogCacheMetadata,
|
||||||
|
ProductCatalogCacheStatus,
|
||||||
|
} from '@atlas/contracts';
|
||||||
|
|
||||||
|
export type ProductCatalogResourceState = {
|
||||||
|
last_success_at: Date | string | null;
|
||||||
|
is_complete: boolean;
|
||||||
|
fresh_days: number;
|
||||||
|
expire_days: number;
|
||||||
|
refresh_run_id: string | null;
|
||||||
|
refresh_status: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 统一实现 14/21 天边界,供所有商品目录读取与发布预检复用。 */
|
||||||
|
export function productCatalogCacheStatus(
|
||||||
|
lastSuccessAt: Date | string | null,
|
||||||
|
freshDays: number,
|
||||||
|
expireDays: number,
|
||||||
|
now = Date.now(),
|
||||||
|
): ProductCatalogCacheStatus {
|
||||||
|
if (!lastSuccessAt) return 'missing';
|
||||||
|
const age = now - new Date(lastSuccessAt).getTime();
|
||||||
|
const day = 86_400_000;
|
||||||
|
if (age < freshDays * day) return 'fresh';
|
||||||
|
if (age <= expireDays * day) return 'stale';
|
||||||
|
return 'expired';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function productCatalogResourceStatus(
|
||||||
|
state: ProductCatalogResourceState,
|
||||||
|
): ProductCatalogCacheStatus {
|
||||||
|
return productCatalogCacheStatus(
|
||||||
|
state.last_success_at,
|
||||||
|
state.fresh_days,
|
||||||
|
state.expire_days,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** cache 元数据在编辑读取与发布预检之间保持同一形状和边界计算。 */
|
||||||
|
export function productCatalogCacheMetadata(
|
||||||
|
state: ProductCatalogResourceState,
|
||||||
|
status = productCatalogResourceStatus(state),
|
||||||
|
): ProductCatalogCacheMetadata {
|
||||||
|
const last = state.last_success_at ? new Date(state.last_success_at) : null;
|
||||||
|
return {
|
||||||
|
status,
|
||||||
|
last_success_at: last?.toISOString() ?? null,
|
||||||
|
fresh_until: last
|
||||||
|
? new Date(
|
||||||
|
last.getTime() + state.fresh_days * 86_400_000,
|
||||||
|
).toISOString()
|
||||||
|
: null,
|
||||||
|
expires_at: last
|
||||||
|
? new Date(
|
||||||
|
last.getTime() + state.expire_days * 86_400_000,
|
||||||
|
).toISOString()
|
||||||
|
: null,
|
||||||
|
is_complete: state.is_complete,
|
||||||
|
refresh_run_id: state.refresh_run_id,
|
||||||
|
refresh_status: state.refresh_status,
|
||||||
|
retry_after_ms:
|
||||||
|
status === 'expired' || status === 'missing' ? 1_000 : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function productCatalogStateWithRun(
|
||||||
|
state: ProductCatalogResourceState,
|
||||||
|
run: { id: string; status: string },
|
||||||
|
): ProductCatalogResourceState {
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
refresh_run_id: run.id,
|
||||||
|
refresh_status: run.status,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,657 @@
|
|||||||
|
import { createHash } from 'node:crypto';
|
||||||
|
import type { ProductAttributeSchema } from '@atlas/contracts';
|
||||||
|
import type { Database, DatabaseTransaction } from '@atlas/database';
|
||||||
|
import { ApiError } from '../common/api-error.js';
|
||||||
|
import {
|
||||||
|
enqueuePlatformCatalogRun,
|
||||||
|
type PlatformCatalogRunRow,
|
||||||
|
} from '../platform-catalog/platform-catalog-run.js';
|
||||||
|
import {
|
||||||
|
productCatalogCacheMetadata as cacheMetadata,
|
||||||
|
productCatalogResourceStatus as cacheStatus,
|
||||||
|
type ProductCatalogResourceState as ResourceState,
|
||||||
|
} from './product-editor.catalog-cache.js';
|
||||||
|
import type { ProfileRow } from './product-editor.models.js';
|
||||||
|
|
||||||
|
type DictionaryCursor = { value: string; value_id: string; query: string };
|
||||||
|
type RefreshScope = 'attributes' | 'dictionary' | 'dictionary_search';
|
||||||
|
type RunRow = PlatformCatalogRunRow;
|
||||||
|
|
||||||
|
type CatalogAccess = {
|
||||||
|
requireProfileForPlatform: (
|
||||||
|
transaction: DatabaseTransaction,
|
||||||
|
profileId: string,
|
||||||
|
platformId: string,
|
||||||
|
) => Promise<ProfileRow>;
|
||||||
|
requireActiveCategory: (
|
||||||
|
transaction: DatabaseTransaction,
|
||||||
|
platformId: string,
|
||||||
|
categoryId: string,
|
||||||
|
) => Promise<unknown>;
|
||||||
|
attributeSchemaForCategory: (
|
||||||
|
transaction: DatabaseTransaction,
|
||||||
|
platformId: string,
|
||||||
|
categoryId: string,
|
||||||
|
) => Promise<ProductAttributeSchema[]>;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 游标只保存排序位置,类目和属性归属仍由每次请求重新校验。 */
|
||||||
|
function parseDictionaryCursor(
|
||||||
|
cursor: string | undefined,
|
||||||
|
): DictionaryCursor | null {
|
||||||
|
if (!cursor) return null;
|
||||||
|
if (cursor.length > 2_000)
|
||||||
|
throw new ApiError(400, 'VALIDATION_FAILED', '字典游标不合法');
|
||||||
|
try {
|
||||||
|
const parsed: unknown = JSON.parse(
|
||||||
|
Buffer.from(cursor, 'base64url').toString('utf8'),
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
parsed &&
|
||||||
|
typeof parsed === 'object' &&
|
||||||
|
typeof (parsed as DictionaryCursor).value === 'string' &&
|
||||||
|
typeof (parsed as DictionaryCursor).value_id === 'string' &&
|
||||||
|
typeof (parsed as DictionaryCursor).query === 'string'
|
||||||
|
)
|
||||||
|
return parsed as DictionaryCursor;
|
||||||
|
} catch {
|
||||||
|
// 无效游标统一返回参数错误。
|
||||||
|
}
|
||||||
|
throw new ApiError(400, 'VALIDATION_FAILED', '字典游标不合法');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 商品编辑器只读取平台级目录缓存。stale 立即返回并后台刷新;expired/missing
|
||||||
|
* 只创建平台 Worker 任务,短暂等待后仍未完成则以 202 告知前端继续轮询。
|
||||||
|
*/
|
||||||
|
export class ProductEditorCatalogOperations {
|
||||||
|
public constructor(
|
||||||
|
private readonly database: Database,
|
||||||
|
private readonly access: CatalogAccess,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async categoryOptions(
|
||||||
|
workspaceId: string,
|
||||||
|
profileId: string,
|
||||||
|
platformId: string,
|
||||||
|
search: string | undefined,
|
||||||
|
) {
|
||||||
|
return this.database.workspaceTransaction(
|
||||||
|
workspaceId,
|
||||||
|
async (transaction) => {
|
||||||
|
await this.access.requireProfileForPlatform(
|
||||||
|
transaction,
|
||||||
|
profileId,
|
||||||
|
platformId,
|
||||||
|
);
|
||||||
|
const categories = await transaction.query<{
|
||||||
|
id: string;
|
||||||
|
parent_id: string | null;
|
||||||
|
external_category_id: string;
|
||||||
|
external_type_id: string | null;
|
||||||
|
name: string;
|
||||||
|
path: string;
|
||||||
|
is_active: boolean;
|
||||||
|
}>(
|
||||||
|
`SELECT id, parent_id, external_category_id, external_type_id, name, path, is_active
|
||||||
|
FROM atlas.platform_product_categories
|
||||||
|
WHERE platform_id = $1
|
||||||
|
ORDER BY path, name, id
|
||||||
|
LIMIT 10000`,
|
||||||
|
[platformId],
|
||||||
|
);
|
||||||
|
// 搜索由 Cascader 在完整平台树上执行,保持父子路径完整。
|
||||||
|
void search;
|
||||||
|
return categories.rows;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async attributeSchema(
|
||||||
|
workspaceId: string,
|
||||||
|
userId: string,
|
||||||
|
profileId: string,
|
||||||
|
platformId: string,
|
||||||
|
categoryId?: string,
|
||||||
|
) {
|
||||||
|
const prepared = await this.readAttributeSchema(
|
||||||
|
workspaceId,
|
||||||
|
userId,
|
||||||
|
profileId,
|
||||||
|
platformId,
|
||||||
|
categoryId,
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
if (!prepared.waitForRunId)
|
||||||
|
return { status: 200, body: { data: prepared.result } };
|
||||||
|
await this.waitForRun(prepared.waitForRunId);
|
||||||
|
const completed = await this.readAttributeSchema(
|
||||||
|
workspaceId,
|
||||||
|
userId,
|
||||||
|
profileId,
|
||||||
|
platformId,
|
||||||
|
categoryId,
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
const usable =
|
||||||
|
completed.result.cache.status === 'fresh' ||
|
||||||
|
completed.result.cache.status === 'stale';
|
||||||
|
return { status: usable ? 200 : 202, body: { data: completed.result } };
|
||||||
|
}
|
||||||
|
|
||||||
|
async attributeDictionary(
|
||||||
|
workspaceId: string,
|
||||||
|
userId: string,
|
||||||
|
profileId: string,
|
||||||
|
platformId: string,
|
||||||
|
attributeId: string,
|
||||||
|
search: string | undefined,
|
||||||
|
cursor: string | undefined,
|
||||||
|
categoryId?: string,
|
||||||
|
) {
|
||||||
|
const after = parseDictionaryCursor(cursor);
|
||||||
|
const prepared = await this.readDictionary(
|
||||||
|
workspaceId,
|
||||||
|
userId,
|
||||||
|
profileId,
|
||||||
|
platformId,
|
||||||
|
attributeId,
|
||||||
|
search,
|
||||||
|
after,
|
||||||
|
categoryId,
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
if (!prepared.waitForRunId)
|
||||||
|
return { status: 200, body: { data: prepared.result } };
|
||||||
|
await this.waitForRun(prepared.waitForRunId);
|
||||||
|
const completed = await this.readDictionary(
|
||||||
|
workspaceId,
|
||||||
|
userId,
|
||||||
|
profileId,
|
||||||
|
platformId,
|
||||||
|
attributeId,
|
||||||
|
search,
|
||||||
|
after,
|
||||||
|
categoryId,
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
const usable =
|
||||||
|
completed.result.cache.status === 'fresh' ||
|
||||||
|
completed.result.cache.status === 'stale';
|
||||||
|
return { status: usable ? 200 : 202, body: { data: completed.result } };
|
||||||
|
}
|
||||||
|
|
||||||
|
async requestAttributeSchemaSync(
|
||||||
|
workspaceId: string,
|
||||||
|
userId: string,
|
||||||
|
profileId: string,
|
||||||
|
platformId: string,
|
||||||
|
key: string,
|
||||||
|
categoryId: string,
|
||||||
|
) {
|
||||||
|
const run = await this.database.workspaceTransaction(
|
||||||
|
workspaceId,
|
||||||
|
async (transaction) => {
|
||||||
|
await this.access.requireProfileForPlatform(
|
||||||
|
transaction,
|
||||||
|
profileId,
|
||||||
|
platformId,
|
||||||
|
);
|
||||||
|
await this.access.requireActiveCategory(
|
||||||
|
transaction,
|
||||||
|
platformId,
|
||||||
|
categoryId,
|
||||||
|
);
|
||||||
|
return this.enqueueRequestedRefresh(transaction, key, {
|
||||||
|
platformId,
|
||||||
|
userId,
|
||||||
|
scope: 'attributes',
|
||||||
|
scopeKey: `attributes:${categoryId}`,
|
||||||
|
categoryId,
|
||||||
|
priority: 'high',
|
||||||
|
trigger: 'user_access',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return { status: 202, body: { data: { run } } };
|
||||||
|
}
|
||||||
|
|
||||||
|
async requestAttributeDictionarySync(
|
||||||
|
workspaceId: string,
|
||||||
|
userId: string,
|
||||||
|
profileId: string,
|
||||||
|
platformId: string,
|
||||||
|
key: string,
|
||||||
|
categoryId: string,
|
||||||
|
attributeId: string,
|
||||||
|
query: string,
|
||||||
|
) {
|
||||||
|
const needle = query.trim();
|
||||||
|
if (needle.length === 1)
|
||||||
|
throw new ApiError(
|
||||||
|
422,
|
||||||
|
'DICTIONARY_SEARCH_REQUIRED',
|
||||||
|
'请至少输入 2 个字符后再从 Ozon 搜索字典值',
|
||||||
|
);
|
||||||
|
const run = await this.database.workspaceTransaction(
|
||||||
|
workspaceId,
|
||||||
|
async (transaction) => {
|
||||||
|
await this.access.requireProfileForPlatform(
|
||||||
|
transaction,
|
||||||
|
profileId,
|
||||||
|
platformId,
|
||||||
|
);
|
||||||
|
await this.access.requireActiveCategory(
|
||||||
|
transaction,
|
||||||
|
platformId,
|
||||||
|
categoryId,
|
||||||
|
);
|
||||||
|
await this.requireDictionaryAttribute(
|
||||||
|
transaction,
|
||||||
|
platformId,
|
||||||
|
categoryId,
|
||||||
|
attributeId,
|
||||||
|
);
|
||||||
|
const searchScope = needle.length >= 2;
|
||||||
|
const normalized = normalizeSearch(needle);
|
||||||
|
return this.enqueueRequestedRefresh(transaction, key, {
|
||||||
|
platformId,
|
||||||
|
userId,
|
||||||
|
scope: searchScope ? 'dictionary_search' : 'dictionary',
|
||||||
|
scopeKey: searchScope
|
||||||
|
? dictionarySearchScopeKey(attributeId, normalized)
|
||||||
|
: `dictionary:${attributeId}`,
|
||||||
|
categoryId,
|
||||||
|
attributeId,
|
||||||
|
searchKey: searchScope ? normalized : undefined,
|
||||||
|
priority: 'high',
|
||||||
|
trigger: 'user_access',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return { status: 202, body: { data: { run } } };
|
||||||
|
}
|
||||||
|
|
||||||
|
private async readAttributeSchema(
|
||||||
|
workspaceId: string,
|
||||||
|
userId: string,
|
||||||
|
profileId: string,
|
||||||
|
platformId: string,
|
||||||
|
categoryId: string | undefined,
|
||||||
|
enqueue: boolean,
|
||||||
|
) {
|
||||||
|
return this.database.workspaceTransaction(
|
||||||
|
workspaceId,
|
||||||
|
async (transaction) => {
|
||||||
|
const profile = await this.access.requireProfileForPlatform(
|
||||||
|
transaction,
|
||||||
|
profileId,
|
||||||
|
platformId,
|
||||||
|
);
|
||||||
|
const selectedCategoryId =
|
||||||
|
categoryId ?? profile.platform_product_category_id;
|
||||||
|
if (!selectedCategoryId)
|
||||||
|
throw new ApiError(
|
||||||
|
422,
|
||||||
|
'CATEGORY_REQUIRED',
|
||||||
|
'请先选择产品类目',
|
||||||
|
);
|
||||||
|
await this.access.requireActiveCategory(
|
||||||
|
transaction,
|
||||||
|
platformId,
|
||||||
|
selectedCategoryId,
|
||||||
|
);
|
||||||
|
const state = await this.resourceState(
|
||||||
|
transaction,
|
||||||
|
platformId,
|
||||||
|
'attributes',
|
||||||
|
`attributes:${selectedCategoryId}`,
|
||||||
|
);
|
||||||
|
const status = cacheStatus(state);
|
||||||
|
let run: RunRow | null = null;
|
||||||
|
if (enqueue && status !== 'fresh') {
|
||||||
|
run = await this.enqueueRefresh(transaction, {
|
||||||
|
platformId,
|
||||||
|
userId,
|
||||||
|
scope: 'attributes',
|
||||||
|
scopeKey: `attributes:${selectedCategoryId}`,
|
||||||
|
categoryId: selectedCategoryId,
|
||||||
|
priority: status === 'stale' ? 'low' : 'high',
|
||||||
|
trigger: 'user_access',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const effectiveState = run
|
||||||
|
? {
|
||||||
|
...state,
|
||||||
|
refresh_run_id: run.id,
|
||||||
|
refresh_status: run.status,
|
||||||
|
}
|
||||||
|
: state;
|
||||||
|
const usable = status === 'fresh' || status === 'stale';
|
||||||
|
return {
|
||||||
|
result: {
|
||||||
|
items: usable
|
||||||
|
? await this.access.attributeSchemaForCategory(
|
||||||
|
transaction,
|
||||||
|
platformId,
|
||||||
|
selectedCategoryId,
|
||||||
|
)
|
||||||
|
: [],
|
||||||
|
cache: cacheMetadata(effectiveState, status),
|
||||||
|
},
|
||||||
|
waitForRunId: enqueue && !usable ? (run?.id ?? null) : null,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async readDictionary(
|
||||||
|
workspaceId: string,
|
||||||
|
userId: string,
|
||||||
|
profileId: string,
|
||||||
|
platformId: string,
|
||||||
|
attributeId: string,
|
||||||
|
search: string | undefined,
|
||||||
|
after: DictionaryCursor | null,
|
||||||
|
categoryId: string | undefined,
|
||||||
|
enqueue: boolean,
|
||||||
|
) {
|
||||||
|
return this.database.workspaceTransaction(
|
||||||
|
workspaceId,
|
||||||
|
async (transaction) => {
|
||||||
|
const profile = await this.access.requireProfileForPlatform(
|
||||||
|
transaction,
|
||||||
|
profileId,
|
||||||
|
platformId,
|
||||||
|
);
|
||||||
|
const selectedCategoryId =
|
||||||
|
categoryId ?? profile.platform_product_category_id;
|
||||||
|
if (!selectedCategoryId)
|
||||||
|
throw new ApiError(
|
||||||
|
422,
|
||||||
|
'CATEGORY_REQUIRED',
|
||||||
|
'请先选择产品类目',
|
||||||
|
);
|
||||||
|
await this.access.requireActiveCategory(
|
||||||
|
transaction,
|
||||||
|
platformId,
|
||||||
|
selectedCategoryId,
|
||||||
|
);
|
||||||
|
await this.requireDictionaryAttribute(
|
||||||
|
transaction,
|
||||||
|
platformId,
|
||||||
|
selectedCategoryId,
|
||||||
|
attributeId,
|
||||||
|
);
|
||||||
|
const needle = search?.trim() ?? '';
|
||||||
|
if (after && after.query !== needle)
|
||||||
|
throw new ApiError(
|
||||||
|
400,
|
||||||
|
'VALIDATION_FAILED',
|
||||||
|
'字典游标与搜索条件不匹配',
|
||||||
|
);
|
||||||
|
const values = await this.dictionaryValues(
|
||||||
|
transaction,
|
||||||
|
attributeId,
|
||||||
|
needle,
|
||||||
|
after,
|
||||||
|
);
|
||||||
|
const dictionaryState = await this.resourceState(
|
||||||
|
transaction,
|
||||||
|
platformId,
|
||||||
|
'dictionary',
|
||||||
|
`dictionary:${attributeId}`,
|
||||||
|
);
|
||||||
|
const normalized = normalizeSearch(needle);
|
||||||
|
const searchScopeKey = dictionarySearchScopeKey(
|
||||||
|
attributeId,
|
||||||
|
normalized,
|
||||||
|
);
|
||||||
|
const searchState =
|
||||||
|
needle.length >= 2 && !dictionaryState.is_complete
|
||||||
|
? await this.resourceState(
|
||||||
|
transaction,
|
||||||
|
platformId,
|
||||||
|
'dictionary_search',
|
||||||
|
searchScopeKey,
|
||||||
|
)
|
||||||
|
: null;
|
||||||
|
// 部分字典本地未命中时补齐查询词;一旦建立过该词作用域,后续继续
|
||||||
|
// 使用它的新鲜度,避免搜索成功写入值后又退回整个字典的状态。
|
||||||
|
const usesSearchScope =
|
||||||
|
!!searchState &&
|
||||||
|
(values.rows.length === 0 ||
|
||||||
|
searchState.last_success_at !== null);
|
||||||
|
const scope: RefreshScope = usesSearchScope
|
||||||
|
? 'dictionary_search'
|
||||||
|
: 'dictionary';
|
||||||
|
const scopeKey = usesSearchScope
|
||||||
|
? searchScopeKey
|
||||||
|
: `dictionary:${attributeId}`;
|
||||||
|
const state = usesSearchScope ? searchState : dictionaryState;
|
||||||
|
const status = cacheStatus(state);
|
||||||
|
let run: RunRow | null = null;
|
||||||
|
if (enqueue && status !== 'fresh') {
|
||||||
|
run = await this.enqueueRefresh(transaction, {
|
||||||
|
platformId,
|
||||||
|
userId,
|
||||||
|
scope,
|
||||||
|
scopeKey,
|
||||||
|
categoryId: selectedCategoryId,
|
||||||
|
attributeId,
|
||||||
|
searchKey: usesSearchScope ? normalized : undefined,
|
||||||
|
priority: status === 'stale' ? 'low' : 'high',
|
||||||
|
trigger: 'user_access',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const effectiveState = run
|
||||||
|
? {
|
||||||
|
...state,
|
||||||
|
refresh_run_id: run.id,
|
||||||
|
refresh_status: run.status,
|
||||||
|
}
|
||||||
|
: state;
|
||||||
|
const usable = status === 'fresh' || status === 'stale';
|
||||||
|
const items = usable ? values.rows.slice(0, 200) : [];
|
||||||
|
const last = items.at(-1);
|
||||||
|
return {
|
||||||
|
result: {
|
||||||
|
items,
|
||||||
|
next_cursor:
|
||||||
|
usable && values.rows.length > 200 && last
|
||||||
|
? Buffer.from(
|
||||||
|
JSON.stringify({
|
||||||
|
...last,
|
||||||
|
query: needle,
|
||||||
|
}),
|
||||||
|
).toString('base64url')
|
||||||
|
: null,
|
||||||
|
cache: cacheMetadata(effectiveState, status),
|
||||||
|
},
|
||||||
|
waitForRunId: enqueue && !usable ? (run?.id ?? null) : null,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async dictionaryValues(
|
||||||
|
transaction: DatabaseTransaction,
|
||||||
|
attributeId: string,
|
||||||
|
needle: string,
|
||||||
|
after: DictionaryCursor | null,
|
||||||
|
) {
|
||||||
|
return transaction.query<{ value_id: string; value: string }>(
|
||||||
|
`SELECT external_value_id AS value_id, value
|
||||||
|
FROM atlas.platform_catalog_dictionary_values
|
||||||
|
WHERE attribute_id = $1 AND is_active
|
||||||
|
AND ($2 = '' OR value ILIKE '%' || $2 || '%')
|
||||||
|
AND ($3::text IS NULL OR (value, external_value_id) > ($3, $4))
|
||||||
|
ORDER BY value, external_value_id
|
||||||
|
LIMIT 201`,
|
||||||
|
[
|
||||||
|
attributeId,
|
||||||
|
needle,
|
||||||
|
after?.value ?? null,
|
||||||
|
after?.value_id ?? null,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async requireDictionaryAttribute(
|
||||||
|
transaction: DatabaseTransaction,
|
||||||
|
platformId: string,
|
||||||
|
categoryId: string,
|
||||||
|
attributeId: string,
|
||||||
|
) {
|
||||||
|
const attribute = await transaction.query<{ id: string }>(
|
||||||
|
`SELECT id FROM atlas.platform_product_category_attributes
|
||||||
|
WHERE id = $1 AND platform_id = $2 AND platform_product_category_id = $3
|
||||||
|
AND is_active AND dictionary_id IS NOT NULL`,
|
||||||
|
[attributeId, platformId, categoryId],
|
||||||
|
);
|
||||||
|
if (!attribute.rows[0])
|
||||||
|
throw new ApiError(
|
||||||
|
422,
|
||||||
|
'ATTRIBUTE_DICTIONARY_INVALID',
|
||||||
|
'字典不属于当前类目的属性',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async resourceState(
|
||||||
|
transaction: DatabaseTransaction,
|
||||||
|
platformId: string,
|
||||||
|
scope: RefreshScope,
|
||||||
|
scopeKey: string,
|
||||||
|
): Promise<ResourceState> {
|
||||||
|
const result = await transaction.query<ResourceState>(
|
||||||
|
`SELECT state.last_success_at, coalesce(state.is_complete, false) AS is_complete,
|
||||||
|
CASE WHEN $2 = 'attributes' THEN policy.attribute_fresh_days
|
||||||
|
ELSE policy.dictionary_fresh_days END AS fresh_days,
|
||||||
|
CASE WHEN $2 = 'attributes' THEN policy.attribute_expire_days
|
||||||
|
ELSE policy.dictionary_expire_days END AS expire_days,
|
||||||
|
run.id AS refresh_run_id, run.status AS refresh_status
|
||||||
|
FROM (SELECT 1) marker
|
||||||
|
JOIN atlas.platform_catalog_refresh_policies policy ON policy.platform_id = $1
|
||||||
|
LEFT JOIN atlas.platform_catalog_resource_states state
|
||||||
|
ON state.platform_id = $1 AND state.scope_key = $3
|
||||||
|
LEFT JOIN LATERAL (
|
||||||
|
SELECT id, status FROM atlas.platform_catalog_sync_runs
|
||||||
|
WHERE platform_id = $1 AND scope_key = $3
|
||||||
|
AND status IN ('queued', 'running')
|
||||||
|
ORDER BY created_at DESC LIMIT 1
|
||||||
|
) run ON true`,
|
||||||
|
[platformId, scope, scopeKey],
|
||||||
|
);
|
||||||
|
if (!result.rows[0])
|
||||||
|
throw new ApiError(
|
||||||
|
422,
|
||||||
|
'CATALOG_POLICY_REQUIRED',
|
||||||
|
'平台目录刷新策略尚未初始化',
|
||||||
|
);
|
||||||
|
return result.rows[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
private async enqueueRefresh(
|
||||||
|
transaction: DatabaseTransaction,
|
||||||
|
input: {
|
||||||
|
platformId: string;
|
||||||
|
userId: string;
|
||||||
|
scope: RefreshScope;
|
||||||
|
scopeKey: string;
|
||||||
|
categoryId: string;
|
||||||
|
attributeId?: string;
|
||||||
|
searchKey?: string;
|
||||||
|
priority: 'high' | 'low';
|
||||||
|
trigger: 'user_access';
|
||||||
|
},
|
||||||
|
): Promise<RunRow> {
|
||||||
|
const source = await transaction.query<{ revision: number }>(
|
||||||
|
`SELECT revision FROM atlas.platform_catalog_sources
|
||||||
|
WHERE platform_id = $1 FOR UPDATE`,
|
||||||
|
[input.platformId],
|
||||||
|
);
|
||||||
|
if (!source.rows[0])
|
||||||
|
throw new ApiError(
|
||||||
|
422,
|
||||||
|
'CATALOG_SOURCE_REQUIRED',
|
||||||
|
'平台目录同步来源尚未配置',
|
||||||
|
);
|
||||||
|
return enqueuePlatformCatalogRun(transaction, {
|
||||||
|
platformId: input.platformId,
|
||||||
|
scope: input.scope,
|
||||||
|
scopeKey: input.scopeKey,
|
||||||
|
categoryId: input.categoryId,
|
||||||
|
attributeId: input.attributeId,
|
||||||
|
searchKey: input.searchKey,
|
||||||
|
sourceRevision: source.rows[0].revision,
|
||||||
|
trigger: input.trigger,
|
||||||
|
priority: input.priority,
|
||||||
|
userId: input.userId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 手动兼容入口把幂等键映射到平台 run,即使复用活动任务也可安全重放。 */
|
||||||
|
private async enqueueRequestedRefresh(
|
||||||
|
transaction: DatabaseTransaction,
|
||||||
|
key: string,
|
||||||
|
input: Parameters<ProductEditorCatalogOperations['enqueueRefresh']>[1],
|
||||||
|
) {
|
||||||
|
const prior = await transaction.query<PlatformCatalogRunRow>(
|
||||||
|
`SELECT run.id, run.platform_id, run.scope, run.scope_key, run.status,
|
||||||
|
run.trigger_kind, run.priority, run.created_at
|
||||||
|
FROM atlas.platform_catalog_sync_requests request
|
||||||
|
JOIN atlas.platform_catalog_sync_runs run ON run.id = request.run_id
|
||||||
|
WHERE request.requested_by_user_id = $1 AND request.idempotency_key = $2`,
|
||||||
|
[input.userId, key],
|
||||||
|
);
|
||||||
|
if (prior.rows[0]) {
|
||||||
|
if (
|
||||||
|
prior.rows[0].platform_id !== input.platformId ||
|
||||||
|
prior.rows[0].scope_key !== input.scopeKey
|
||||||
|
) {
|
||||||
|
throw new ApiError(
|
||||||
|
409,
|
||||||
|
'IDEMPOTENCY_KEY_REUSED',
|
||||||
|
'幂等键已用于其他目录刷新请求',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return prior.rows[0];
|
||||||
|
}
|
||||||
|
const run = await this.enqueueRefresh(transaction, input);
|
||||||
|
await transaction.query(
|
||||||
|
`INSERT INTO atlas.platform_catalog_sync_requests
|
||||||
|
(requested_by_user_id, idempotency_key, platform_id, scope_key, run_id)
|
||||||
|
VALUES ($1, $2, $3, $4, $5)`,
|
||||||
|
[input.userId, key, input.platformId, input.scopeKey, run.id],
|
||||||
|
);
|
||||||
|
return run;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 最多等待 5 秒;API 不直连 Ozon,只观察 Worker 写入的任务终态。 */
|
||||||
|
private async waitForRun(runId: string) {
|
||||||
|
const deadline = Date.now() + 5_000;
|
||||||
|
while (Date.now() < deadline) {
|
||||||
|
const result = await this.database.query<{ status: string }>(
|
||||||
|
`SELECT status FROM atlas.platform_catalog_sync_runs WHERE id = $1`,
|
||||||
|
[runId],
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
!result.rows[0] ||
|
||||||
|
['succeeded', 'failed', 'cancelled'].includes(
|
||||||
|
result.rows[0].status,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return;
|
||||||
|
await new Promise<void>((resolve) => setTimeout(resolve, 250));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeSearch(value: string) {
|
||||||
|
return value.trim().toLocaleLowerCase('zh-CN').replace(/\s+/g, ' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
function dictionarySearchScopeKey(attributeId: string, search: string) {
|
||||||
|
const hash = createHash('sha256').update(search).digest('hex').slice(0, 24);
|
||||||
|
return `dictionary_search:${attributeId}:${hash}`;
|
||||||
|
}
|
||||||
@@ -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')
|
||||||
|
: [];
|
||||||
|
}
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
import {
|
||||||
|
Inject,
|
||||||
|
Injectable,
|
||||||
|
OnModuleDestroy,
|
||||||
|
OnModuleInit,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { createHmac } from 'node:crypto';
|
||||||
|
import { Redis } from 'ioredis';
|
||||||
|
import { ApiError } from '../common/api-error.js';
|
||||||
|
import { APP_CONFIG, type AppConfig } from '../config/app-config.module.js';
|
||||||
|
|
||||||
|
type AuthAction = 'login' | 'register' | 'refresh' | 'change_password';
|
||||||
|
type Limit = { ip: number; email?: number; user?: number };
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class AuthRateLimiter implements OnModuleInit, OnModuleDestroy {
|
||||||
|
private readonly redis: Redis;
|
||||||
|
|
||||||
|
public constructor(@Inject(APP_CONFIG) private readonly config: AppConfig) {
|
||||||
|
// Redis 让限流计数在多个 API 实例间共享,不会随单个实例重启清零。
|
||||||
|
this.redis = new Redis(config.REDIS_URL, {
|
||||||
|
enableOfflineQueue: false,
|
||||||
|
lazyConnect: true,
|
||||||
|
maxRetriesPerRequest: 1,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public async onModuleInit(): Promise<void> {
|
||||||
|
// 在开始接收认证请求前完成连接,避免首个请求命中 Redis 连接竞态而被误判为 503。
|
||||||
|
await this.redis.connect();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async consume(
|
||||||
|
action: AuthAction,
|
||||||
|
identifiers: { ip: string | null; email?: string; userId?: string },
|
||||||
|
): Promise<void> {
|
||||||
|
const limit = this.limitFor(action);
|
||||||
|
await this.consumeBucket(
|
||||||
|
action,
|
||||||
|
'ip',
|
||||||
|
identifiers.ip ?? 'unknown',
|
||||||
|
limit.ip,
|
||||||
|
);
|
||||||
|
if (identifiers.email && limit.email) {
|
||||||
|
await this.consumeBucket(
|
||||||
|
action,
|
||||||
|
'email',
|
||||||
|
identifiers.email,
|
||||||
|
limit.email,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (identifiers.userId && limit.user) {
|
||||||
|
await this.consumeBucket(
|
||||||
|
action,
|
||||||
|
'user',
|
||||||
|
identifiers.userId,
|
||||||
|
limit.user,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async onModuleDestroy(): Promise<void> {
|
||||||
|
if (this.redis.status !== 'ready') {
|
||||||
|
this.redis.disconnect();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await this.redis.quit();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async consumeBucket(
|
||||||
|
action: AuthAction,
|
||||||
|
dimension: 'ip' | 'email' | 'user',
|
||||||
|
identifier: string,
|
||||||
|
limit: number,
|
||||||
|
): Promise<void> {
|
||||||
|
const digest = createHmac(
|
||||||
|
'sha256',
|
||||||
|
this.config.AUTH_RATE_LIMIT_KEY_SECRET,
|
||||||
|
)
|
||||||
|
.update(identifier)
|
||||||
|
.digest('base64url');
|
||||||
|
// Redis key 只保存 IP/邮箱的 HMAC,不暴露可被日志或运维工具读取的原文。
|
||||||
|
const key = `atlas:auth-rate-limit:${action}:${dimension}:${digest}`;
|
||||||
|
try {
|
||||||
|
// INCR 与首次 EXPIRE 必须原子执行,避免并发下产生永不过期的限流 key。
|
||||||
|
const count = await this.redis.eval(
|
||||||
|
"local value = redis.call('INCR', KEYS[1]); if value == 1 then redis.call('EXPIRE', KEYS[1], ARGV[1]); end; return value;",
|
||||||
|
1,
|
||||||
|
key,
|
||||||
|
this.config.AUTH_RATE_LIMIT_WINDOW_SECONDS,
|
||||||
|
);
|
||||||
|
if (Number(count) > limit) {
|
||||||
|
throw new ApiError(
|
||||||
|
429,
|
||||||
|
'AUTH_RATE_LIMITED',
|
||||||
|
'请求过于频繁,请稍后重试',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof ApiError) throw error;
|
||||||
|
// 认证限流故障时必须拒绝请求;静默跳过会扩大暴力破解风险。
|
||||||
|
throw new ApiError(
|
||||||
|
503,
|
||||||
|
'AUTH_RATE_LIMIT_UNAVAILABLE',
|
||||||
|
'认证服务暂时不可用',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private limitFor(action: AuthAction): Limit {
|
||||||
|
if (action === 'login') {
|
||||||
|
return {
|
||||||
|
ip: this.config.AUTH_LOGIN_IP_LIMIT,
|
||||||
|
email: this.config.AUTH_LOGIN_EMAIL_LIMIT,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (action === 'register') {
|
||||||
|
return {
|
||||||
|
ip: this.config.AUTH_REGISTER_IP_LIMIT,
|
||||||
|
email: this.config.AUTH_REGISTER_EMAIL_LIMIT,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (action === 'change_password') {
|
||||||
|
return {
|
||||||
|
ip: this.config.AUTH_CHANGE_PASSWORD_IP_LIMIT,
|
||||||
|
user: this.config.AUTH_CHANGE_PASSWORD_USER_LIMIT,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { ip: this.config.AUTH_REFRESH_IP_LIMIT };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { Global, Module } from '@nestjs/common';
|
||||||
|
import { AuthRateLimiter } from './auth-rate-limiter.service.js';
|
||||||
|
|
||||||
|
@Global()
|
||||||
|
@Module({ providers: [AuthRateLimiter], exports: [AuthRateLimiter] })
|
||||||
|
export class SecurityModule {}
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
|
import type { Database, DatabaseTransaction } from '@atlas/database';
|
||||||
|
import { ApiError } from '../common/api-error.js';
|
||||||
|
import { DATABASE } from '../database/database.module.js';
|
||||||
|
|
||||||
|
type PlatformRow = {
|
||||||
|
id: string;
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
capabilities: Record<string, unknown>;
|
||||||
|
supported_currencies: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class PlatformsService {
|
||||||
|
public constructor(@Inject(DATABASE) private readonly database: Database) {}
|
||||||
|
|
||||||
|
public async list() {
|
||||||
|
const rows = await this.database.query<PlatformRow>(
|
||||||
|
`SELECT p.id, p.code, p.name, p.capabilities,
|
||||||
|
coalesce(array_agg(pcs.currency_code ORDER BY pcs.currency_code)
|
||||||
|
FILTER (WHERE pcs.is_enabled AND c.is_enabled), ARRAY[]::char(3)[]) AS supported_currencies
|
||||||
|
FROM atlas.platforms p
|
||||||
|
LEFT JOIN atlas.platform_currency_support pcs ON pcs.platform_id = p.id
|
||||||
|
LEFT JOIN atlas.currencies c ON c.code = pcs.currency_code
|
||||||
|
WHERE p.status = 'active'
|
||||||
|
AND coalesce((p.capabilities->>'can_be_header_context')::boolean, false)
|
||||||
|
GROUP BY p.id
|
||||||
|
ORDER BY CASE WHEN p.code = 'OZON' THEN 0 ELSE 1 END, p.name`,
|
||||||
|
);
|
||||||
|
return rows.rows.map((platform) => ({
|
||||||
|
id: platform.id,
|
||||||
|
code: platform.code,
|
||||||
|
name: platform.name,
|
||||||
|
supported_currencies: platform.supported_currencies,
|
||||||
|
// 能力未配置时使用 null,而不是把 Ozon 的市场或语言假设扩散到通用模型。
|
||||||
|
default_market_code: stringCapability(
|
||||||
|
platform.capabilities,
|
||||||
|
'default_market_code',
|
||||||
|
),
|
||||||
|
default_locale: stringCapability(
|
||||||
|
platform.capabilities,
|
||||||
|
'default_locale',
|
||||||
|
),
|
||||||
|
default_currency_code: stringCapability(
|
||||||
|
platform.capabilities,
|
||||||
|
'default_currency_code',
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
public async context(workspaceId: string, platformId: string) {
|
||||||
|
return this.database.workspaceTransaction(
|
||||||
|
workspaceId,
|
||||||
|
async (transaction) => {
|
||||||
|
const platform = await this.requireActivePlatform(
|
||||||
|
transaction,
|
||||||
|
platformId,
|
||||||
|
);
|
||||||
|
const stores = await transaction.query<{
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
status: string;
|
||||||
|
credential_status: string;
|
||||||
|
count: string;
|
||||||
|
}>(
|
||||||
|
`SELECT s.id, s.name, s.status, s.credential_status,
|
||||||
|
count(*) OVER ()::text AS count
|
||||||
|
FROM atlas.stores s
|
||||||
|
WHERE s.platform_id = $1 AND s.deleted_at IS NULL AND s.is_default
|
||||||
|
LIMIT 1`,
|
||||||
|
[platformId],
|
||||||
|
);
|
||||||
|
// 默认门店不存在时仍要正确返回总数,不能用默认行的窗口函数猜测。
|
||||||
|
const count = await transaction.query<{ count: string }>(
|
||||||
|
`SELECT count(*)::text AS count FROM atlas.stores
|
||||||
|
WHERE platform_id = $1 AND deleted_at IS NULL`,
|
||||||
|
[platformId],
|
||||||
|
);
|
||||||
|
const defaultStore = stores.rows[0];
|
||||||
|
return {
|
||||||
|
platform: {
|
||||||
|
id: platform.id,
|
||||||
|
code: platform.code,
|
||||||
|
name: platform.name,
|
||||||
|
},
|
||||||
|
default_store: defaultStore
|
||||||
|
? {
|
||||||
|
id: defaultStore.id,
|
||||||
|
name: defaultStore.name,
|
||||||
|
status: defaultStore.status,
|
||||||
|
credential_status: defaultStore.credential_status,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
store_count: Number(count.rows[0]?.count ?? 0),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async requireActivePlatform(
|
||||||
|
transaction: DatabaseTransaction,
|
||||||
|
platformId: string,
|
||||||
|
) {
|
||||||
|
const rows = await transaction.query<{
|
||||||
|
id: string;
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
}>(
|
||||||
|
`SELECT id, code, name FROM atlas.platforms WHERE id = $1 AND status = 'active'`,
|
||||||
|
[platformId],
|
||||||
|
);
|
||||||
|
if (!rows.rows[0]) {
|
||||||
|
throw new ApiError(
|
||||||
|
422,
|
||||||
|
'PLATFORM_UNAVAILABLE',
|
||||||
|
'平台不存在或已停用',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return rows.rows[0];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function stringCapability(
|
||||||
|
capabilities: Record<string, unknown>,
|
||||||
|
key: string,
|
||||||
|
): string | null {
|
||||||
|
const value = capabilities[key];
|
||||||
|
return typeof value === 'string' ? value : null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import {
|
||||||
|
createDecipheriv,
|
||||||
|
createCipheriv,
|
||||||
|
createHash,
|
||||||
|
createHmac,
|
||||||
|
randomBytes,
|
||||||
|
} from 'node:crypto';
|
||||||
|
|
||||||
|
export type EncryptedStoreSecret = {
|
||||||
|
ciphertext: Buffer;
|
||||||
|
nonce: Buffer;
|
||||||
|
fingerprint: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 密钥只在此边界短暂以明文存在。数据库保存 AES-GCM 密文和随机 nonce,
|
||||||
|
* fingerprint 是不可逆 HMAC,仅用于安全地识别同一份密钥。
|
||||||
|
*/
|
||||||
|
export function encryptStoreSecret(
|
||||||
|
plaintext: string,
|
||||||
|
keyMaterial: string,
|
||||||
|
): EncryptedStoreSecret {
|
||||||
|
const key = createHash('sha256').update(keyMaterial).digest();
|
||||||
|
const nonce = randomBytes(12);
|
||||||
|
const cipher = createCipheriv('aes-256-gcm', key, nonce);
|
||||||
|
const ciphertext = Buffer.concat([
|
||||||
|
cipher.update(plaintext, 'utf8'),
|
||||||
|
cipher.final(),
|
||||||
|
cipher.getAuthTag(),
|
||||||
|
]);
|
||||||
|
return {
|
||||||
|
ciphertext,
|
||||||
|
nonce,
|
||||||
|
fingerprint: createHmac('sha256', key)
|
||||||
|
.update(plaintext)
|
||||||
|
.digest('base64url'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 仅供受信任的后台 Worker 使用;调用方不得记录返回值。 */
|
||||||
|
export function decryptStoreSecret(
|
||||||
|
ciphertextWithTag: Buffer,
|
||||||
|
nonce: Buffer,
|
||||||
|
keyMaterial: string,
|
||||||
|
): string {
|
||||||
|
const authenticationTagLength = 16;
|
||||||
|
if (ciphertextWithTag.length <= authenticationTagLength) {
|
||||||
|
throw new Error('Invalid encrypted store secret');
|
||||||
|
}
|
||||||
|
const key = createHash('sha256').update(keyMaterial).digest();
|
||||||
|
const ciphertext = ciphertextWithTag.subarray(0, -authenticationTagLength);
|
||||||
|
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');
|
||||||
|
}
|
||||||
@@ -0,0 +1,382 @@
|
|||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Delete,
|
||||||
|
Get,
|
||||||
|
Headers,
|
||||||
|
HttpCode,
|
||||||
|
Inject,
|
||||||
|
Param,
|
||||||
|
Patch,
|
||||||
|
Post,
|
||||||
|
Query,
|
||||||
|
Req,
|
||||||
|
Res,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import {
|
||||||
|
ApiAcceptedResponse,
|
||||||
|
ApiBearerAuth,
|
||||||
|
ApiNoContentResponse,
|
||||||
|
ApiOkResponse,
|
||||||
|
ApiOperation,
|
||||||
|
ApiTags,
|
||||||
|
} from '@nestjs/swagger';
|
||||||
|
import {
|
||||||
|
createStoreRequestSchema,
|
||||||
|
listStoresQuerySchema,
|
||||||
|
updateStoreRequestSchema,
|
||||||
|
} from '@atlas/contracts';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { ApiError } from '../common/api-error.js';
|
||||||
|
import type { AuthenticatedRequest } from '../auth/auth.guard.js';
|
||||||
|
import { CurrentWorkspace } from '../workspace/current-workspace.decorator.js';
|
||||||
|
import type { Workspace } from '@atlas/contracts';
|
||||||
|
import { PlatformsService } from './platforms.service.js';
|
||||||
|
import { StoresService } from './stores.service.js';
|
||||||
|
|
||||||
|
type HttpResponse = { status: (status: number) => HttpResponse };
|
||||||
|
|
||||||
|
@ApiTags('Platforms')
|
||||||
|
@ApiBearerAuth('access-token')
|
||||||
|
@Controller()
|
||||||
|
export class PlatformsController {
|
||||||
|
// tsx/esbuild 不生成 design:paramtypes 元数据,控制器依赖必须显式 @Inject(与 auth.controller 一致)
|
||||||
|
public constructor(
|
||||||
|
@Inject(PlatformsService)
|
||||||
|
private readonly platformsService: PlatformsService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
@Get('platforms')
|
||||||
|
@ApiOperation({ summary: '获取已启用的平台及非敏感能力目录' })
|
||||||
|
@ApiOkResponse({ description: '平台目录' })
|
||||||
|
public async list() {
|
||||||
|
return { data: await this.platformsService.list() };
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('platform-context')
|
||||||
|
@ApiOperation({ summary: '获取页头所选平台的默认门店上下文' })
|
||||||
|
@ApiOkResponse({ description: '平台、默认门店和门店总数' })
|
||||||
|
public async context(
|
||||||
|
@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.platformsService.context(
|
||||||
|
workspace.id,
|
||||||
|
requireUuid('X-Atlas-Platform-Id', platformId),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ApiTags('Stores')
|
||||||
|
@ApiBearerAuth('access-token')
|
||||||
|
@Controller('stores')
|
||||||
|
export class StoresController {
|
||||||
|
public constructor(
|
||||||
|
@Inject(StoresService) private readonly storesService: StoresService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
@ApiOperation({ summary: '按平台分页列出当前工作空间的门店' })
|
||||||
|
@ApiOkResponse({ description: '门店列表与不透明游标' })
|
||||||
|
public async list(
|
||||||
|
@Query() query: unknown,
|
||||||
|
@Headers('x-atlas-platform-id') platformHeader: string | undefined,
|
||||||
|
@CurrentWorkspace() workspace: Workspace,
|
||||||
|
) {
|
||||||
|
if (platformHeader) {
|
||||||
|
throw new ApiError(
|
||||||
|
400,
|
||||||
|
'PLATFORM_CONTEXT_CONFLICT',
|
||||||
|
'门店管理列表不能同时使用平台 header 和 platform_id 查询参数',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return this.storesService.list(
|
||||||
|
workspace.id,
|
||||||
|
parse(listStoresQuerySchema, query),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':storeId')
|
||||||
|
@ApiOperation({ summary: '读取单个门店' })
|
||||||
|
public async get(
|
||||||
|
@Param('storeId') storeId: string,
|
||||||
|
@CurrentWorkspace() workspace: Workspace,
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
data: await this.storesService.get(
|
||||||
|
workspace.id,
|
||||||
|
requireUuid('store_id', storeId),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
@ApiOperation({ summary: '创建门店连接,并请求异步凭据验证' })
|
||||||
|
@ApiAcceptedResponse({ description: '已加密保存凭据,并创建验证操作' })
|
||||||
|
public async create(
|
||||||
|
@Body() body: unknown,
|
||||||
|
@Headers('idempotency-key') idempotencyKey: string | undefined,
|
||||||
|
@Req() request: AuthenticatedRequest,
|
||||||
|
@CurrentWorkspace() workspace: Workspace,
|
||||||
|
@Res({ passthrough: true }) response: HttpResponse,
|
||||||
|
) {
|
||||||
|
return withStatus(
|
||||||
|
response,
|
||||||
|
await this.storesService.create(
|
||||||
|
workspace.id,
|
||||||
|
request.auth.userId,
|
||||||
|
requireIdempotencyKey(idempotencyKey),
|
||||||
|
parse(createStoreRequestSchema, body),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch(':storeId')
|
||||||
|
@ApiOperation({ summary: '按版本编辑门店;更新密钥会重新请求验证' })
|
||||||
|
@ApiOkResponse({ description: '普通字段已更新' })
|
||||||
|
@ApiAcceptedResponse({ description: '新密钥已保存,并创建验证操作' })
|
||||||
|
public async update(
|
||||||
|
@Param('storeId') storeId: string,
|
||||||
|
@Body() body: unknown,
|
||||||
|
@Headers('idempotency-key') idempotencyKey: string | undefined,
|
||||||
|
@Headers('if-match') ifMatch: string | undefined,
|
||||||
|
@Req() request: AuthenticatedRequest,
|
||||||
|
@CurrentWorkspace() workspace: Workspace,
|
||||||
|
@Res({ passthrough: true }) response: HttpResponse,
|
||||||
|
) {
|
||||||
|
return withStatus(
|
||||||
|
response,
|
||||||
|
await this.storesService.update(
|
||||||
|
workspace.id,
|
||||||
|
request.auth.userId,
|
||||||
|
requireUuid('store_id', storeId),
|
||||||
|
requireIdempotencyKey(idempotencyKey),
|
||||||
|
parseRevision(ifMatch),
|
||||||
|
parse(updateStoreRequestSchema, body),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':storeId/set-default')
|
||||||
|
@ApiOperation({ summary: '设为当前平台默认门店' })
|
||||||
|
public async setDefault(
|
||||||
|
@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.setDefault(
|
||||||
|
workspace.id,
|
||||||
|
request.auth.userId,
|
||||||
|
requireUuid('store_id', storeId),
|
||||||
|
requireIdempotencyKey(idempotencyKey),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':storeId/credential-verifications')
|
||||||
|
@ApiOperation({ summary: '手动请求重新验证门店凭据' })
|
||||||
|
@ApiAcceptedResponse({ description: '凭据验证操作已入队' })
|
||||||
|
public async credentialVerification(
|
||||||
|
@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.requestCredentialVerification(
|
||||||
|
workspace.id,
|
||||||
|
request.auth.userId,
|
||||||
|
requireUuid('store_id', storeId),
|
||||||
|
requireIdempotencyKey(idempotencyKey),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@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: '仓库同步操作已入队' })
|
||||||
|
public async warehouseSync(
|
||||||
|
@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.requestWarehouseSync(
|
||||||
|
workspace.id,
|
||||||
|
request.auth.userId,
|
||||||
|
requireUuid('store_id', storeId),
|
||||||
|
requireIdempotencyKey(idempotencyKey),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':storeId/warehouses')
|
||||||
|
@ApiOperation({ summary: '读取最近成功同步的仓库摘要' })
|
||||||
|
public async warehouses(
|
||||||
|
@Param('storeId') storeId: string,
|
||||||
|
@CurrentWorkspace() workspace: Workspace,
|
||||||
|
) {
|
||||||
|
return this.storesService.warehouses(
|
||||||
|
workspace.id,
|
||||||
|
requireUuid('store_id', storeId),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete(':storeId')
|
||||||
|
@HttpCode(204)
|
||||||
|
@ApiOperation({ summary: '软删除门店连接并撤销当前密钥' })
|
||||||
|
@ApiNoContentResponse({ description: '门店已删除' })
|
||||||
|
public async remove(
|
||||||
|
@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.remove(
|
||||||
|
workspace.id,
|
||||||
|
request.auth.userId,
|
||||||
|
requireUuid('store_id', storeId),
|
||||||
|
requireIdempotencyKey(idempotencyKey),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ApiTags('Operations')
|
||||||
|
@ApiBearerAuth('access-token')
|
||||||
|
@Controller('operations')
|
||||||
|
export class OperationsController {
|
||||||
|
public constructor(
|
||||||
|
@Inject(StoresService) private readonly storesService: StoresService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
@Get(':operationId')
|
||||||
|
@ApiOperation({ summary: '读取当前工作空间的异步操作状态' })
|
||||||
|
public async get(
|
||||||
|
@Param('operationId') operationId: string,
|
||||||
|
@CurrentWorkspace() workspace: Workspace,
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
data: await this.storesService.operation(
|
||||||
|
workspace.id,
|
||||||
|
requireUuid('operation_id', operationId),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function withStatus(
|
||||||
|
response: HttpResponse,
|
||||||
|
result: { status: number; body: unknown },
|
||||||
|
) {
|
||||||
|
response.status(result.status);
|
||||||
|
return result.body;
|
||||||
|
}
|
||||||
|
|
||||||
|
function requireIdempotencyKey(value: string | undefined): string {
|
||||||
|
if (!value) {
|
||||||
|
throw new ApiError(
|
||||||
|
400,
|
||||||
|
'IDEMPOTENCY_KEY_REQUIRED',
|
||||||
|
'请求缺少 Idempotency-Key',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return requireUuid('Idempotency-Key', value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function requireUuid(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 parseRevision(value: string | undefined): number {
|
||||||
|
const match = value?.match(/^"([1-9]\d*)"$/);
|
||||||
|
if (!match) {
|
||||||
|
throw new ApiError(
|
||||||
|
400,
|
||||||
|
'IF_MATCH_REQUIRED',
|
||||||
|
'请求必须携带形如 "1" 的 If-Match',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return Number(match[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
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,15 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { IdempotencyService } from '../common/idempotency.service.js';
|
||||||
|
import {
|
||||||
|
OperationsController,
|
||||||
|
PlatformsController,
|
||||||
|
StoresController,
|
||||||
|
} from './stores.controller.js';
|
||||||
|
import { PlatformsService } from './platforms.service.js';
|
||||||
|
import { StoresService } from './stores.service.js';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [PlatformsController, StoresController, OperationsController],
|
||||||
|
providers: [IdempotencyService, PlatformsService, StoresService],
|
||||||
|
})
|
||||||
|
export class StoresModule {}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,106 @@
|
|||||||
|
import {
|
||||||
|
Body, Controller, Delete, Get, Headers, HttpCode, Inject, Param, Patch, Post, Put, Query, Req,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { ApiBearerAuth, ApiCreatedResponse, ApiNoContentResponse, ApiOkResponse, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||||
|
import {
|
||||||
|
bindStoreWatermarkRequestSchema, createWatermarkTemplateRequestSchema,
|
||||||
|
listWatermarkTemplatesQuerySchema, updateWatermarkTemplateRequestSchema, type Workspace,
|
||||||
|
} from '@atlas/contracts';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { ApiError } from '../common/api-error.js';
|
||||||
|
import type { AuthenticatedRequest } from '../auth/auth.guard.js';
|
||||||
|
import { CurrentWorkspace } from '../workspace/current-workspace.decorator.js';
|
||||||
|
import { WatermarksService } from './watermarks.service.js';
|
||||||
|
|
||||||
|
@ApiTags('Watermarks')
|
||||||
|
@ApiBearerAuth('access-token')
|
||||||
|
@Controller()
|
||||||
|
export class WatermarksController {
|
||||||
|
public constructor(@Inject(WatermarksService) private readonly watermarks: WatermarksService) {}
|
||||||
|
|
||||||
|
@Get('watermark-fonts')
|
||||||
|
@ApiOperation({ summary: '读取当前渲染服务可用字体' })
|
||||||
|
public async fonts() { return { data: await this.watermarks.fonts() }; }
|
||||||
|
|
||||||
|
@Get('watermark-templates')
|
||||||
|
@ApiOperation({ summary: '分页读取当前工作空间活动水印模板' })
|
||||||
|
public async list(@Query() query: unknown, @CurrentWorkspace() workspace: Workspace) {
|
||||||
|
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) {
|
||||||
|
return { data: await this.watermarks.get(workspace.id, uuid('template_id', templateId)) };
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('watermark-templates')
|
||||||
|
@ApiCreatedResponse({ description: '已创建水印模板及首个渲染版本' })
|
||||||
|
public async create(@Body() body: unknown, @Headers('idempotency-key') key: string | undefined,
|
||||||
|
@Req() request: AuthenticatedRequest, @CurrentWorkspace() workspace: Workspace) {
|
||||||
|
return (await this.watermarks.create(workspace.id, request.auth.userId, idempotencyKey(key),
|
||||||
|
parse(createWatermarkTemplateRequestSchema, body))).body;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch('watermark-templates/:templateId')
|
||||||
|
@ApiOkResponse({ description: '已更新水印模板' })
|
||||||
|
public async update(@Param('templateId') templateId: string, @Body() body: unknown,
|
||||||
|
@Headers('idempotency-key') key: string | undefined, @Headers('if-match') ifMatch: string | undefined,
|
||||||
|
@Req() request: AuthenticatedRequest, @CurrentWorkspace() workspace: Workspace) {
|
||||||
|
return (await this.watermarks.update(workspace.id, request.auth.userId, uuid('template_id', templateId),
|
||||||
|
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: '已软删除模板并解除当前店铺绑定' })
|
||||||
|
public async remove(@Param('templateId') templateId: string, @Headers('idempotency-key') key: string | undefined,
|
||||||
|
@Req() request: AuthenticatedRequest, @CurrentWorkspace() workspace: Workspace) {
|
||||||
|
await this.watermarks.remove(workspace.id, request.auth.userId, uuid('template_id', templateId), idempotencyKey(key));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Put('stores/:storeId/watermark-binding')
|
||||||
|
@ApiOkResponse({ description: '已更新店铺推荐水印' })
|
||||||
|
public async bindStore(@Param('storeId') storeId: string, @Body() body: unknown,
|
||||||
|
@Headers('idempotency-key') key: string | undefined, @Req() request: AuthenticatedRequest,
|
||||||
|
@CurrentWorkspace() workspace: Workspace) {
|
||||||
|
const input = parse(bindStoreWatermarkRequestSchema, body) as { template_id: string | null };
|
||||||
|
return (await this.watermarks.bindStore(workspace.id, request.auth.userId, uuid('store_id', storeId),
|
||||||
|
idempotencyKey(key), input.template_id)).body;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parse<T extends { safeParse: (input: unknown) => { success: boolean; data?: unknown; error?: { issues: Array<{ path: PropertyKey[]; message: string }> } } }>(schema: T, input: unknown) {
|
||||||
|
const result = schema.safeParse(input);
|
||||||
|
if (result.success) return result.data as ReturnType<T['safeParse']> extends { data: infer Data } ? Data : never;
|
||||||
|
throw new ApiError(422, 'VALIDATION_FAILED', '请求参数不符合要求',
|
||||||
|
result.error?.issues.map((issue) => ({ field: issue.path.join('.'), code: 'INVALID_FIELD', message: issue.message })));
|
||||||
|
}
|
||||||
|
function uuid(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 idempotencyKey(value: string | undefined) {
|
||||||
|
if (!value) throw new ApiError(400, 'IDEMPOTENCY_KEY_REQUIRED', '请求缺少 Idempotency-Key');
|
||||||
|
return uuid('Idempotency-Key', value);
|
||||||
|
}
|
||||||
|
function revision(value: string | undefined) {
|
||||||
|
const match = value?.match(/^"([1-9]\d*)"$/);
|
||||||
|
if (!match) throw new ApiError(400, 'IF_MATCH_REQUIRED', '请求缺少或格式错误的 If-Match');
|
||||||
|
return Number(match[1]);
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { IdempotencyService } from '../common/idempotency.service.js';
|
||||||
|
import { WatermarksController } from './watermarks.controller.js';
|
||||||
|
import { WatermarksService } from './watermarks.service.js';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [WatermarksController],
|
||||||
|
providers: [IdempotencyService, WatermarksService],
|
||||||
|
})
|
||||||
|
export class WatermarksModule {}
|
||||||
@@ -0,0 +1,639 @@
|
|||||||
|
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,
|
||||||
|
UpdateWatermarkTemplateRequest,
|
||||||
|
} 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 TemplateRow = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
kind: 'text' | 'image';
|
||||||
|
is_default: boolean;
|
||||||
|
current_version: number;
|
||||||
|
created_at: Date | string;
|
||||||
|
updated_at: Date | string;
|
||||||
|
revision: number;
|
||||||
|
text_content: string | null;
|
||||||
|
image_asset_id: string | null;
|
||||||
|
font_code: string | null;
|
||||||
|
font_size_px: number | null;
|
||||||
|
opacity: string;
|
||||||
|
scale_ratio: string | null;
|
||||||
|
image_size_px: number | null;
|
||||||
|
border_radius_px: number;
|
||||||
|
position: string;
|
||||||
|
position_x: string;
|
||||||
|
position_y: string;
|
||||||
|
};
|
||||||
|
type TemplateConfig = Record<string, unknown>;
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class WatermarksService {
|
||||||
|
public constructor(
|
||||||
|
@Inject(IdempotencyService)
|
||||||
|
private readonly idempotency: IdempotencyService,
|
||||||
|
@Inject(DATABASE) private readonly database: Database,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public async fonts() {
|
||||||
|
const fonts = await this.database.query<{
|
||||||
|
code: string;
|
||||||
|
display_name: string;
|
||||||
|
}>(
|
||||||
|
`SELECT code, display_name FROM atlas.watermark_fonts
|
||||||
|
WHERE status = 'active' ORDER BY display_name, code`,
|
||||||
|
);
|
||||||
|
return fonts.rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async list(workspaceId: string, query: ListWatermarkTemplatesQuery) {
|
||||||
|
const cursor = query.cursor ? decodeCursor(query.cursor) : null;
|
||||||
|
return this.database.workspaceTransaction(workspaceId, async (tx) => {
|
||||||
|
const rows = await tx.query<TemplateRow>(
|
||||||
|
`${templateSelect}
|
||||||
|
WHERE template.status = 'active' AND template.deleted_at IS NULL
|
||||||
|
AND ($1::timestamptz IS NULL OR (template.created_at, template.id) < ($1::timestamptz, $2::uuid))
|
||||||
|
ORDER BY template.created_at DESC, template.id DESC LIMIT $3`,
|
||||||
|
[
|
||||||
|
cursor?.createdAt ?? null,
|
||||||
|
cursor?.id ?? null,
|
||||||
|
query.limit + 1,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
const items = rows.rows.slice(0, query.limit).map(toTemplate);
|
||||||
|
const last = items.at(-1);
|
||||||
|
return {
|
||||||
|
data: items,
|
||||||
|
meta: {
|
||||||
|
has_more: rows.rows.length > query.limit,
|
||||||
|
next_cursor:
|
||||||
|
rows.rows.length > query.limit && last
|
||||||
|
? encodeCursor(last.created_at, last.id)
|
||||||
|
: null,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public async get(workspaceId: string, templateId: string) {
|
||||||
|
return this.database.workspaceTransaction(workspaceId, (tx) =>
|
||||||
|
this.requireTemplate(tx, templateId),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
idempotencyKey: string,
|
||||||
|
input: CreateWatermarkTemplateRequest,
|
||||||
|
) {
|
||||||
|
return this.idempotency.run(
|
||||||
|
{
|
||||||
|
workspaceId,
|
||||||
|
userId,
|
||||||
|
method: 'POST',
|
||||||
|
path: '/api/watermark-templates',
|
||||||
|
key: idempotencyKey,
|
||||||
|
request: input,
|
||||||
|
},
|
||||||
|
async (tx) => {
|
||||||
|
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`,
|
||||||
|
[input.name, input.kind],
|
||||||
|
);
|
||||||
|
await this.insertVersion(
|
||||||
|
tx,
|
||||||
|
inserted.rows[0].id,
|
||||||
|
1,
|
||||||
|
input.kind,
|
||||||
|
config,
|
||||||
|
userId,
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
status: 201,
|
||||||
|
body: {
|
||||||
|
data: await this.requireTemplate(
|
||||||
|
tx,
|
||||||
|
inserted.rows[0].id,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async update(
|
||||||
|
workspaceId: string,
|
||||||
|
userId: string,
|
||||||
|
templateId: string,
|
||||||
|
idempotencyKey: string,
|
||||||
|
revision: number,
|
||||||
|
input: UpdateWatermarkTemplateRequest,
|
||||||
|
) {
|
||||||
|
return this.idempotency.run(
|
||||||
|
{
|
||||||
|
workspaceId,
|
||||||
|
userId,
|
||||||
|
method: 'PATCH',
|
||||||
|
path: `/api/watermark-templates/${templateId}`,
|
||||||
|
key: idempotencyKey,
|
||||||
|
request: { if_match: revision, ...input },
|
||||||
|
},
|
||||||
|
async (tx) => {
|
||||||
|
const current = await this.lockTemplate(tx, templateId);
|
||||||
|
if (current.revision !== revision)
|
||||||
|
throw versionConflict(current.revision);
|
||||||
|
const nextKind = input.kind ?? current.kind;
|
||||||
|
if (
|
||||||
|
input.kind &&
|
||||||
|
input.kind !== current.kind &&
|
||||||
|
!input.config
|
||||||
|
) {
|
||||||
|
throw new ApiError(
|
||||||
|
422,
|
||||||
|
'VALIDATION_FAILED',
|
||||||
|
'切换水印类型时必须提交新类型完整配置',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// 同类型编辑允许只提交变化字段;切换类型时旧类型字段不会通过新类型校验。
|
||||||
|
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;
|
||||||
|
await tx.query(
|
||||||
|
`UPDATE atlas.watermark_templates SET name = COALESCE($1, name), kind = $2,
|
||||||
|
current_version = current_version + CASE WHEN $3 THEN 1 ELSE 0 END,
|
||||||
|
revision = revision + 1, updated_at = now() WHERE id = $4`,
|
||||||
|
[
|
||||||
|
input.name ?? null,
|
||||||
|
nextKind,
|
||||||
|
renderingChanged,
|
||||||
|
templateId,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
if (renderingChanged) {
|
||||||
|
await this.insertVersion(
|
||||||
|
tx,
|
||||||
|
templateId,
|
||||||
|
current.current_version + 1,
|
||||||
|
nextKind,
|
||||||
|
nextConfig,
|
||||||
|
userId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
status: 200,
|
||||||
|
body: { data: await this.requireTemplate(tx, templateId) },
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async remove(
|
||||||
|
workspaceId: string,
|
||||||
|
userId: string,
|
||||||
|
templateId: string,
|
||||||
|
idempotencyKey: string,
|
||||||
|
) {
|
||||||
|
return this.idempotency.run(
|
||||||
|
{
|
||||||
|
workspaceId,
|
||||||
|
userId,
|
||||||
|
method: 'DELETE',
|
||||||
|
path: `/api/watermark-templates/${templateId}`,
|
||||||
|
key: idempotencyKey,
|
||||||
|
request: {},
|
||||||
|
},
|
||||||
|
async (tx) => {
|
||||||
|
await this.lockTemplate(tx, templateId);
|
||||||
|
await tx.query(
|
||||||
|
`UPDATE atlas.store_watermark_bindings SET unbound_at = now()
|
||||||
|
WHERE template_id = $1 AND unbound_at IS NULL`,
|
||||||
|
[templateId],
|
||||||
|
);
|
||||||
|
await tx.query(
|
||||||
|
`UPDATE atlas.watermark_templates SET status = 'archived', deleted_at = now(), is_default = false,
|
||||||
|
revision = revision + 1, updated_at = now() WHERE id = $1`,
|
||||||
|
[templateId],
|
||||||
|
);
|
||||||
|
return { status: 204, body: null };
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async bindStore(
|
||||||
|
workspaceId: string,
|
||||||
|
userId: string,
|
||||||
|
storeId: string,
|
||||||
|
idempotencyKey: string,
|
||||||
|
templateId: string | null,
|
||||||
|
) {
|
||||||
|
return this.idempotency.run(
|
||||||
|
{
|
||||||
|
workspaceId,
|
||||||
|
userId,
|
||||||
|
method: 'PUT',
|
||||||
|
path: `/api/stores/${storeId}/watermark-binding`,
|
||||||
|
key: idempotencyKey,
|
||||||
|
request: { template_id: templateId },
|
||||||
|
},
|
||||||
|
async (tx) => {
|
||||||
|
const store = await tx.query<{ id: string }>(
|
||||||
|
`SELECT id FROM atlas.stores WHERE id = $1 AND deleted_at IS NULL FOR UPDATE`,
|
||||||
|
[storeId],
|
||||||
|
);
|
||||||
|
if (!store.rows[0])
|
||||||
|
throw new ApiError(404, 'RESOURCE_NOT_FOUND', '门店不存在');
|
||||||
|
if (templateId) await this.requireTemplate(tx, templateId);
|
||||||
|
await tx.query(
|
||||||
|
`UPDATE atlas.store_watermark_bindings SET unbound_at = now()
|
||||||
|
WHERE store_id = $1 AND unbound_at IS NULL`,
|
||||||
|
[storeId],
|
||||||
|
);
|
||||||
|
if (templateId) {
|
||||||
|
await tx.query(
|
||||||
|
`INSERT INTO atlas.store_watermark_bindings
|
||||||
|
(workspace_id, store_id, template_id, bound_by_user_id)
|
||||||
|
VALUES (current_setting('app.workspace_id')::uuid, $1, $2, $3)`,
|
||||||
|
[storeId, templateId, userId],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const result = templateId
|
||||||
|
? await this.requireTemplate(tx, templateId)
|
||||||
|
: null;
|
||||||
|
return {
|
||||||
|
status: 200,
|
||||||
|
body: {
|
||||||
|
data: {
|
||||||
|
watermark_template: result
|
||||||
|
? { id: result.id, name: result.name }
|
||||||
|
: null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async lockTemplate(
|
||||||
|
tx: DatabaseTransaction,
|
||||||
|
id: string,
|
||||||
|
): Promise<TemplateRow> {
|
||||||
|
const rows = await tx.query<TemplateRow>(
|
||||||
|
`${templateSelect} WHERE template.id = $1
|
||||||
|
AND template.status = 'active' AND template.deleted_at IS NULL FOR UPDATE`,
|
||||||
|
[id],
|
||||||
|
);
|
||||||
|
if (!rows.rows[0])
|
||||||
|
throw new ApiError(404, 'RESOURCE_NOT_FOUND', '水印模板不存在');
|
||||||
|
return rows.rows[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
private async requireTemplate(tx: DatabaseTransaction, id: string) {
|
||||||
|
const rows = await tx.query<TemplateRow>(
|
||||||
|
`${templateSelect} WHERE template.id = $1
|
||||||
|
AND template.status = 'active' AND template.deleted_at IS NULL`,
|
||||||
|
[id],
|
||||||
|
);
|
||||||
|
if (!rows.rows[0])
|
||||||
|
throw new ApiError(404, 'RESOURCE_NOT_FOUND', '水印模板不存在');
|
||||||
|
return toTemplate(rows.rows[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async validateConfig(
|
||||||
|
tx: DatabaseTransaction,
|
||||||
|
kind: 'text' | 'image',
|
||||||
|
config: TemplateConfig,
|
||||||
|
) {
|
||||||
|
if (
|
||||||
|
typeof config.opacity !== 'string' ||
|
||||||
|
typeof config.position !== 'string' ||
|
||||||
|
!validCoordinate(config.position_x) ||
|
||||||
|
!validCoordinate(config.position_y)
|
||||||
|
) {
|
||||||
|
throw new ApiError(422, 'VALIDATION_FAILED', '水印配置不完整');
|
||||||
|
}
|
||||||
|
if (kind === 'text') {
|
||||||
|
const fontCode = config.font_code;
|
||||||
|
if (
|
||||||
|
typeof fontCode !== 'string' ||
|
||||||
|
typeof config.text_content !== 'string' ||
|
||||||
|
typeof config.font_size_px !== 'number'
|
||||||
|
) {
|
||||||
|
throw new ApiError(
|
||||||
|
422,
|
||||||
|
'VALIDATION_FAILED',
|
||||||
|
'文字水印配置不完整',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const font = await tx.query(
|
||||||
|
`SELECT code FROM atlas.watermark_fonts WHERE code = $1 AND status = 'active'`,
|
||||||
|
[fontCode],
|
||||||
|
);
|
||||||
|
if (!font.rows[0])
|
||||||
|
throw new ApiError(
|
||||||
|
422,
|
||||||
|
'WATERMARK_FONT_UNAVAILABLE',
|
||||||
|
'所选字体不可用',
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const assetId = config.image_asset_id;
|
||||||
|
if (
|
||||||
|
typeof assetId !== 'string' ||
|
||||||
|
typeof config.scale_ratio !== 'string' ||
|
||||||
|
!validImageSize(config.image_size_px) ||
|
||||||
|
!validBorderRadius(config.border_radius_px)
|
||||||
|
) {
|
||||||
|
throw new ApiError(422, 'VALIDATION_FAILED', '图片水印配置不完整');
|
||||||
|
}
|
||||||
|
const asset = await tx.query<{ id: string }>(
|
||||||
|
`SELECT id FROM atlas.media_assets WHERE id = $1
|
||||||
|
AND content_type IN ('image/png', 'image/jpeg', 'image/webp')`,
|
||||||
|
[assetId],
|
||||||
|
);
|
||||||
|
if (!asset.rows[0])
|
||||||
|
throw new ApiError(
|
||||||
|
422,
|
||||||
|
'WATERMARK_IMAGE_UNAVAILABLE',
|
||||||
|
'水印图片不存在或不属于当前工作空间',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async insertVersion(
|
||||||
|
tx: DatabaseTransaction,
|
||||||
|
templateId: string,
|
||||||
|
version: number,
|
||||||
|
kind: 'text' | 'image',
|
||||||
|
config: TemplateConfig,
|
||||||
|
userId: string,
|
||||||
|
) {
|
||||||
|
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_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,
|
||||||
|
kind,
|
||||||
|
config.text_content ?? null,
|
||||||
|
config.image_asset_id ?? null,
|
||||||
|
config.font_code ?? null,
|
||||||
|
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.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.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) {
|
||||||
|
return {
|
||||||
|
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),
|
||||||
|
updated_at: iso(row.updated_at),
|
||||||
|
revision: row.revision,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
function configFromRow(row: TemplateRow): TemplateConfig {
|
||||||
|
return row.kind === 'text'
|
||||||
|
? {
|
||||||
|
text_content: row.text_content,
|
||||||
|
font_code: row.font_code,
|
||||||
|
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(
|
||||||
|
kind: 'text' | 'image',
|
||||||
|
config: TemplateConfig,
|
||||||
|
): TemplateConfig {
|
||||||
|
return kind === 'text'
|
||||||
|
? {
|
||||||
|
text_content: config.text_content,
|
||||||
|
font_code: config.font_code,
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
function versionConflict(currentRevision: number) {
|
||||||
|
return new ApiError(409, 'VERSION_CONFLICT', '水印模板已被其他操作更新', [
|
||||||
|
{
|
||||||
|
field: 'If-Match',
|
||||||
|
code: 'VERSION_CONFLICT',
|
||||||
|
message: `当前版本为 ${currentRevision}`,
|
||||||
|
current_revision: currentRevision,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
function encodeCursor(createdAt: string, id: string) {
|
||||||
|
return Buffer.from(JSON.stringify({ createdAt, id })).toString('base64url');
|
||||||
|
}
|
||||||
|
function decodeCursor(cursor: string): { createdAt: string; id: string } {
|
||||||
|
try {
|
||||||
|
const value = JSON.parse(
|
||||||
|
Buffer.from(cursor, 'base64url').toString('utf8'),
|
||||||
|
) as { createdAt?: unknown; id?: unknown };
|
||||||
|
if (
|
||||||
|
typeof value.createdAt !== 'string' ||
|
||||||
|
Number.isNaN(Date.parse(value.createdAt)) ||
|
||||||
|
typeof value.id !== 'string' ||
|
||||||
|
!/^[0-9a-f-]{36}$/i.test(value.id)
|
||||||
|
)
|
||||||
|
throw new Error();
|
||||||
|
return { createdAt: value.createdAt, id: value.id };
|
||||||
|
} catch {
|
||||||
|
throw new ApiError(422, 'VALIDATION_FAILED', '分页游标无效');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { createParamDecorator, type ExecutionContext } from '@nestjs/common';
|
||||||
|
import type { Workspace } from '@atlas/contracts';
|
||||||
|
import type { WorkspaceAuthenticatedRequest } from './workspace-context.guard.js';
|
||||||
|
|
||||||
|
// 受保护接口经全局工作空间守卫验证后,只能从这里读取当前工作空间。
|
||||||
|
export const CurrentWorkspace = createParamDecorator(
|
||||||
|
(_data: unknown, context: ExecutionContext): Workspace => {
|
||||||
|
const request = context
|
||||||
|
.switchToHttp()
|
||||||
|
.getRequest<WorkspaceAuthenticatedRequest>();
|
||||||
|
return request.auth.workspace;
|
||||||
|
},
|
||||||
|
);
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import {
|
||||||
|
CanActivate,
|
||||||
|
ExecutionContext,
|
||||||
|
Inject,
|
||||||
|
Injectable,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import type { Workspace } from '@atlas/contracts';
|
||||||
|
import type { Database } from '@atlas/database';
|
||||||
|
import { Reflector } from '@nestjs/core';
|
||||||
|
import { ApiError } from '../common/api-error.js';
|
||||||
|
import { IS_PUBLIC_ENDPOINT } from '../common/public.decorator.js';
|
||||||
|
import { DATABASE } from '../database/database.module.js';
|
||||||
|
import type { AuthenticatedRequest } from '../auth/auth.guard.js';
|
||||||
|
|
||||||
|
export type WorkspaceAuthenticatedRequest = AuthenticatedRequest & {
|
||||||
|
auth: AuthenticatedRequest['auth'] & { workspace: Workspace };
|
||||||
|
};
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class WorkspaceContextGuard implements CanActivate {
|
||||||
|
public constructor(
|
||||||
|
@Inject(Reflector) private readonly reflector: Reflector,
|
||||||
|
@Inject(DATABASE) private readonly database: Database,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||||
|
if (
|
||||||
|
this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_ENDPOINT, [
|
||||||
|
context.getHandler(),
|
||||||
|
context.getClass(),
|
||||||
|
])
|
||||||
|
) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const request = context
|
||||||
|
.switchToHttp()
|
||||||
|
.getRequest<AuthenticatedRequest>();
|
||||||
|
// 当前一期每位用户只有一个个人工作空间;后续多人协作时此处将按显式选择的工作空间校验 membership。
|
||||||
|
const workspace = await this.findCurrentWorkspace(request.auth.userId);
|
||||||
|
if (!workspace) {
|
||||||
|
throw new ApiError(
|
||||||
|
403,
|
||||||
|
'WORKSPACE_MEMBERSHIP_REQUIRED',
|
||||||
|
'当前账户没有可用工作空间',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// 后续控制器和服务只使用此处验证过的上下文,绝不相信客户端传入 workspace_id。
|
||||||
|
request.auth.workspace = workspace;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async findCurrentWorkspace(
|
||||||
|
userId: string,
|
||||||
|
): Promise<Workspace | null> {
|
||||||
|
// 排序规则是首期“唯一有效工作空间”的临时选择策略,不是未来的工作空间切换协议。
|
||||||
|
const result = await this.database.query<Workspace>(
|
||||||
|
`SELECT w.id, w.name, m.role 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`,
|
||||||
|
[userId],
|
||||||
|
);
|
||||||
|
return result.rows[0] ?? null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,430 @@
|
|||||||
|
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';
|
||||||
|
|
||||||
|
type SuccessResponse<T> = { data: T };
|
||||||
|
type ErrorResponse = {
|
||||||
|
error: {
|
||||||
|
code: string;
|
||||||
|
message: string;
|
||||||
|
request_id: string;
|
||||||
|
details?: Array<{ field: string; code: string; message: string }>;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
type SessionPayload = {
|
||||||
|
access_token: string;
|
||||||
|
access_token_expires_in: number;
|
||||||
|
user: {
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
display_name: string | null;
|
||||||
|
avatar_asset_id: string | null;
|
||||||
|
revision: number;
|
||||||
|
};
|
||||||
|
workspace: { id: string; name: string; role: string };
|
||||||
|
};
|
||||||
|
|
||||||
|
const password = 'correct-horse-battery-staple-123';
|
||||||
|
const testUsers: Array<{ userId: string; workspaceId: string }> = [];
|
||||||
|
let app: NestExpressApplication;
|
||||||
|
let database: Database;
|
||||||
|
let baseUrl: string;
|
||||||
|
|
||||||
|
function email(label: string): string {
|
||||||
|
return `${label}-${randomUUID()}@example.test`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function request(
|
||||||
|
path: string,
|
||||||
|
init: RequestInit = {},
|
||||||
|
): Promise<{ response: Response; body: unknown }> {
|
||||||
|
const response = await fetch(`${baseUrl}${path}`, {
|
||||||
|
...init,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...init.headers,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
response,
|
||||||
|
body: response.status === 204 ? undefined : await response.json(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function expectSuccess<T>(body: unknown): SuccessResponse<T> {
|
||||||
|
assert.ok(body && typeof body === 'object' && 'data' in body);
|
||||||
|
return body as SuccessResponse<T>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function expectError(body: unknown): ErrorResponse {
|
||||||
|
assert.ok(body && typeof body === 'object' && 'error' in body);
|
||||||
|
return body as ErrorResponse;
|
||||||
|
}
|
||||||
|
|
||||||
|
function refreshCookie(response: Response): string {
|
||||||
|
const cookie = response.headers.get('set-cookie');
|
||||||
|
assert.ok(cookie, '认证成功响应必须写入 refresh cookie');
|
||||||
|
assert.match(cookie, /HttpOnly/i);
|
||||||
|
const match = cookie.match(/atlas_refresh=[^;]+/);
|
||||||
|
assert.ok(match, '响应必须包含 atlas_refresh cookie');
|
||||||
|
return match[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('认证 HTTP 集成测试', () => {
|
||||||
|
beforeAll(async () => {
|
||||||
|
Object.assign(process.env, {
|
||||||
|
NODE_ENV: 'test',
|
||||||
|
SWAGGER_ENABLED: 'false',
|
||||||
|
AUTH_LOGIN_IP_LIMIT: '100',
|
||||||
|
AUTH_LOGIN_EMAIL_LIMIT: '2',
|
||||||
|
AUTH_REGISTER_IP_LIMIT: '100',
|
||||||
|
AUTH_REGISTER_EMAIL_LIMIT: '100',
|
||||||
|
AUTH_REFRESH_IP_LIMIT: '100',
|
||||||
|
});
|
||||||
|
const application = await createApplication();
|
||||||
|
app = application.app;
|
||||||
|
database = app.get<Database>(DATABASE);
|
||||||
|
await app.listen(0, '127.0.0.1');
|
||||||
|
const address = app.getHttpServer().address() as AddressInfo;
|
||||||
|
baseUrl = `http://127.0.0.1:${address.port}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
for (const user of testUsers) {
|
||||||
|
await database.workspaceTransaction(
|
||||||
|
user.workspaceId,
|
||||||
|
async (tx) => {
|
||||||
|
await tx.query(
|
||||||
|
'DELETE FROM atlas.idempotency_requests WHERE workspace_id = $1',
|
||||||
|
[user.workspaceId],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
await database.query(
|
||||||
|
'DELETE FROM atlas.workspace_memberships WHERE user_id = $1',
|
||||||
|
[user.userId],
|
||||||
|
);
|
||||||
|
await database.query('DELETE FROM atlas.workspaces WHERE id = $1', [
|
||||||
|
user.workspaceId,
|
||||||
|
]);
|
||||||
|
await database.query('DELETE FROM atlas.users WHERE id = $1', [
|
||||||
|
user.userId,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('健康与 readiness 端点保持公开', async () => {
|
||||||
|
const health = await request('/health');
|
||||||
|
assert.equal(health.response.status, 200);
|
||||||
|
assert.deepEqual(health.body, { status: 'ok', service: 'api' });
|
||||||
|
|
||||||
|
const readiness = await request('/ready');
|
||||||
|
assert.equal(readiness.response.status, 200);
|
||||||
|
assert.deepEqual(readiness.body, { status: 'ready', service: 'api' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('注册、重复邮箱和参数校验遵守响应契约', async () => {
|
||||||
|
const registrationEmail = email('register');
|
||||||
|
const registration = await request('/api/auth/register', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({
|
||||||
|
email: registrationEmail,
|
||||||
|
password,
|
||||||
|
password_confirmation: password,
|
||||||
|
display_name: '集成测试用户',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
assert.equal(registration.response.status, 201);
|
||||||
|
const payload = expectSuccess<SessionPayload>(registration.body).data;
|
||||||
|
assert.equal(payload.user.email, registrationEmail);
|
||||||
|
assert.equal(payload.workspace.role, 'owner');
|
||||||
|
assert.ok(payload.access_token);
|
||||||
|
assert.ok(payload.access_token_expires_in > 0);
|
||||||
|
refreshCookie(registration.response);
|
||||||
|
testUsers.push({
|
||||||
|
userId: payload.user.id,
|
||||||
|
workspaceId: payload.workspace.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
const duplicate = await request('/api/auth/register', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({
|
||||||
|
email: registrationEmail,
|
||||||
|
password,
|
||||||
|
password_confirmation: password,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
assert.equal(duplicate.response.status, 409);
|
||||||
|
assert.equal(
|
||||||
|
expectError(duplicate.body).error.code,
|
||||||
|
'EMAIL_ALREADY_REGISTERED',
|
||||||
|
);
|
||||||
|
|
||||||
|
const invalid = await request('/api/auth/register', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({
|
||||||
|
email: 'not-an-email',
|
||||||
|
password,
|
||||||
|
password_confirmation: password,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
assert.equal(invalid.response.status, 422);
|
||||||
|
assert.equal(expectError(invalid.body).error.code, 'VALIDATION_FAILED');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('登录、刷新、退出和当前用户读取可形成完整会话闭环', async () => {
|
||||||
|
const accountEmail = email('session');
|
||||||
|
const registration = await request('/api/auth/register', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({
|
||||||
|
email: accountEmail,
|
||||||
|
password,
|
||||||
|
password_confirmation: password,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const registered = expectSuccess<SessionPayload>(
|
||||||
|
registration.body,
|
||||||
|
).data;
|
||||||
|
testUsers.push({
|
||||||
|
userId: registered.user.id,
|
||||||
|
workspaceId: registered.workspace.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
const login = await request('/api/auth/login', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ email: accountEmail, password }),
|
||||||
|
});
|
||||||
|
assert.equal(login.response.status, 200);
|
||||||
|
const loggedIn = expectSuccess<SessionPayload>(login.body).data;
|
||||||
|
const loginRefreshCookie = refreshCookie(login.response);
|
||||||
|
|
||||||
|
const currentUser = await request('/api/auth/me', {
|
||||||
|
headers: { Authorization: `Bearer ${loggedIn.access_token}` },
|
||||||
|
});
|
||||||
|
assert.equal(currentUser.response.status, 200);
|
||||||
|
const me = expectSuccess<{
|
||||||
|
user: { id: string };
|
||||||
|
workspace: { id: string };
|
||||||
|
}>(currentUser.body).data;
|
||||||
|
assert.equal(me.user.id, registered.user.id);
|
||||||
|
assert.equal(me.workspace.id, registered.workspace.id);
|
||||||
|
|
||||||
|
const refresh = await request('/api/auth/refresh', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { Cookie: loginRefreshCookie },
|
||||||
|
});
|
||||||
|
assert.equal(refresh.response.status, 200);
|
||||||
|
const refreshed = expectSuccess<{
|
||||||
|
access_token: string;
|
||||||
|
access_token_expires_in: number;
|
||||||
|
}>(refresh.body).data;
|
||||||
|
assert.ok(refreshed.access_token);
|
||||||
|
const refreshedCookie = refreshCookie(refresh.response);
|
||||||
|
|
||||||
|
const reusedRefresh = await request('/api/auth/refresh', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { Cookie: loginRefreshCookie },
|
||||||
|
});
|
||||||
|
assert.equal(reusedRefresh.response.status, 401);
|
||||||
|
assert.equal(
|
||||||
|
expectError(reusedRefresh.body).error.code,
|
||||||
|
'SESSION_EXPIRED',
|
||||||
|
);
|
||||||
|
|
||||||
|
const logout = await request('/api/auth/logout', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${refreshed.access_token}`,
|
||||||
|
Cookie: refreshedCookie,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
assert.equal(logout.response.status, 204);
|
||||||
|
assert.equal(logout.body, undefined);
|
||||||
|
|
||||||
|
const revoked = await request('/api/auth/me', {
|
||||||
|
headers: { Authorization: `Bearer ${refreshed.access_token}` },
|
||||||
|
});
|
||||||
|
assert.equal(revoked.response.status, 401);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('拒绝错误密码、无效访问令牌和超过邮箱限额的登录请求', async () => {
|
||||||
|
const accountEmail = email('rejected');
|
||||||
|
const wrongPassword = await request('/api/auth/login', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ email: accountEmail, password }),
|
||||||
|
});
|
||||||
|
assert.equal(wrongPassword.response.status, 401);
|
||||||
|
const wrongPasswordError = expectError(wrongPassword.body).error;
|
||||||
|
assert.equal(wrongPasswordError.code, 'INVALID_CREDENTIALS');
|
||||||
|
assert.equal(
|
||||||
|
wrongPasswordError.request_id,
|
||||||
|
wrongPassword.response.headers.get('x-request-id'),
|
||||||
|
);
|
||||||
|
|
||||||
|
const invalidToken = await request('/api/auth/me', {
|
||||||
|
headers: { Authorization: 'Bearer invalid-access-token' },
|
||||||
|
});
|
||||||
|
assert.equal(invalidToken.response.status, 401);
|
||||||
|
assert.equal(expectError(invalidToken.body).error.code, 'HTTP_ERROR');
|
||||||
|
|
||||||
|
const rateLimitedEmail = email('rate-limit');
|
||||||
|
for (const expectedStatus of [401, 401, 429]) {
|
||||||
|
const response = await request('/api/auth/login', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ email: rateLimitedEmail, password }),
|
||||||
|
});
|
||||||
|
assert.equal(response.response.status, expectedStatus);
|
||||||
|
if (expectedStatus === 429) {
|
||||||
|
assert.equal(
|
||||||
|
expectError(response.body).error.code,
|
||||||
|
'AUTH_RATE_LIMITED',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('更新资料遵守版本与幂等约束,改密会撤销旧会话并轮换当前会话', async () => {
|
||||||
|
const accountEmail = email('profile-password');
|
||||||
|
const registration = await request('/api/auth/register', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({
|
||||||
|
email: accountEmail,
|
||||||
|
password,
|
||||||
|
password_confirmation: password,
|
||||||
|
display_name: '原始名称',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const registered = expectSuccess<SessionPayload>(
|
||||||
|
registration.body,
|
||||||
|
).data;
|
||||||
|
testUsers.push({
|
||||||
|
userId: registered.user.id,
|
||||||
|
workspaceId: registered.workspace.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
const profileKey = randomUUID();
|
||||||
|
const update = await request('/api/auth/me', {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${registered.access_token}`,
|
||||||
|
'If-Match': `"${registered.user.revision}"`,
|
||||||
|
'Idempotency-Key': profileKey,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ display_name: '更新后的名称' }),
|
||||||
|
});
|
||||||
|
assert.equal(update.response.status, 200);
|
||||||
|
const updatedUser = expectSuccess<SessionPayload['user']>(
|
||||||
|
update.body,
|
||||||
|
).data;
|
||||||
|
assert.equal(updatedUser.display_name, '更新后的名称');
|
||||||
|
assert.equal(updatedUser.avatar_asset_id, null);
|
||||||
|
assert.equal(updatedUser.revision, registered.user.revision + 1);
|
||||||
|
|
||||||
|
const replay = await request('/api/auth/me', {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${registered.access_token}`,
|
||||||
|
'If-Match': `"${registered.user.revision}"`,
|
||||||
|
'Idempotency-Key': profileKey,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ display_name: '更新后的名称' }),
|
||||||
|
});
|
||||||
|
assert.equal(replay.response.status, 200);
|
||||||
|
assert.deepEqual(replay.body, update.body);
|
||||||
|
|
||||||
|
const stale = await request('/api/auth/me', {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${registered.access_token}`,
|
||||||
|
'If-Match': `"${registered.user.revision}"`,
|
||||||
|
'Idempotency-Key': randomUUID(),
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ display_name: '过期修改' }),
|
||||||
|
});
|
||||||
|
assert.equal(stale.response.status, 409);
|
||||||
|
assert.equal(expectError(stale.body).error.code, 'VERSION_CONFLICT');
|
||||||
|
|
||||||
|
const invalidAvatar = await request('/api/auth/me', {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${registered.access_token}`,
|
||||||
|
'If-Match': `"${updatedUser.revision}"`,
|
||||||
|
'Idempotency-Key': randomUUID(),
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ avatar_asset_id: randomUUID() }),
|
||||||
|
});
|
||||||
|
assert.equal(invalidAvatar.response.status, 422);
|
||||||
|
assert.equal(
|
||||||
|
expectError(invalidAvatar.body).error.code,
|
||||||
|
'AVATAR_ASSET_UNAVAILABLE',
|
||||||
|
);
|
||||||
|
|
||||||
|
const secondLogin = await request('/api/auth/login', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ email: accountEmail, password }),
|
||||||
|
});
|
||||||
|
const otherSession = expectSuccess<SessionPayload>(
|
||||||
|
secondLogin.body,
|
||||||
|
).data;
|
||||||
|
|
||||||
|
const changed = await request('/api/auth/change-password', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { Authorization: `Bearer ${registered.access_token}` },
|
||||||
|
body: JSON.stringify({
|
||||||
|
old_password: password,
|
||||||
|
password: 'new-correct-horse-battery-staple-456',
|
||||||
|
password_confirmation: 'new-correct-horse-battery-staple-456',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
assert.equal(changed.response.status, 200);
|
||||||
|
const rotated = expectSuccess<{
|
||||||
|
access_token: string;
|
||||||
|
access_token_expires_in: number;
|
||||||
|
}>(changed.body).data;
|
||||||
|
assert.ok(rotated.access_token);
|
||||||
|
refreshCookie(changed.response);
|
||||||
|
|
||||||
|
const formerCurrent = await request('/api/auth/me', {
|
||||||
|
headers: { Authorization: `Bearer ${registered.access_token}` },
|
||||||
|
});
|
||||||
|
assert.equal(formerCurrent.response.status, 401);
|
||||||
|
const revokedOther = await request('/api/auth/me', {
|
||||||
|
headers: { Authorization: `Bearer ${otherSession.access_token}` },
|
||||||
|
});
|
||||||
|
assert.equal(revokedOther.response.status, 401);
|
||||||
|
const current = await request('/api/auth/me', {
|
||||||
|
headers: { Authorization: `Bearer ${rotated.access_token}` },
|
||||||
|
});
|
||||||
|
assert.equal(current.response.status, 200);
|
||||||
|
|
||||||
|
const oldPasswordChange = await request('/api/auth/change-password', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { Authorization: `Bearer ${rotated.access_token}` },
|
||||||
|
body: JSON.stringify({
|
||||||
|
old_password: password,
|
||||||
|
password: 'another-correct-horse-battery-staple-789',
|
||||||
|
password_confirmation:
|
||||||
|
'another-correct-horse-battery-staple-789',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
assert.equal(oldPasswordChange.response.status, 422);
|
||||||
|
assert.equal(
|
||||||
|
expectError(oldPasswordChange.body).error.code,
|
||||||
|
'VALIDATION_FAILED',
|
||||||
|
);
|
||||||
|
const newPasswordLogin = await request('/api/auth/login', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({
|
||||||
|
email: accountEmail,
|
||||||
|
password: 'new-correct-horse-battery-staple-456',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
assert.equal(newPasswordLogin.response.status, 200);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
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 userId = ''; let workspaceId = ''; let token = ''; let platformId = '';
|
||||||
|
async function request(path: string, init: RequestInit = {}) { const response = await fetch(`${baseUrl}${path}`, { ...init, headers: { 'Content-Type': 'application/json', ...init.headers } }); return { response, body: response.status === 204 ? undefined : await response.json() as any }; }
|
||||||
|
function headers(extra: Record<string, string> = {}) { return { Authorization: `Bearer ${token}`, ...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 application = await createApplication(); app = application.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: `collection-${randomUUID()}@example.test`, password: 'correct-horse-battery-staple-123', password_confirmation: 'correct-horse-battery-staple-123' }) }); assert.equal(registration.response.status, 201); token = registration.body.data.access_token; userId = registration.body.data.user.id; workspaceId = registration.body.data.workspace.id; const platforms = await request('/api/platforms', { headers: headers() }); platformId = platforms.body.data[0].id; });
|
||||||
|
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.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 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 key = randomUUID(); const created = await request('/api/collection-items', { method: 'POST', headers: headers({ 'Idempotency-Key': key }), body: JSON.stringify({ title: '手动商品', note: '初始备注' }) }); assert.equal(created.response.status, 201); const item = created.body.data.collection_item; assert.equal(item.variant_summary.count, 1); const replay = await request('/api/collection-items', { method: 'POST', headers: headers({ 'Idempotency-Key': key }), body: JSON.stringify({ title: '手动商品', note: '初始备注' }) }); assert.deepEqual(replay.body, created.body); const listed = await request('/api/collection-items', { headers: headers({ 'X-Atlas-Platform-Id': platformId }) }); assert.equal(listed.response.status, 200); assert.equal(listed.body.data[0].id, item.id); const updated = await request(`/api/collection-items/${item.id}`, { method: 'PATCH', headers: headers({ 'Idempotency-Key': randomUUID(), 'If-Match': `"${item.revision}"` }), body: JSON.stringify({ note: '已修改' }) }); assert.equal(updated.response.status, 200); const stale = await request(`/api/collection-items/${item.id}`, { method: 'PATCH', headers: headers({ 'Idempotency-Key': randomUUID(), 'If-Match': `"${item.revision}"` }), body: JSON.stringify({ note: '过期写入' }) }); assert.equal(stale.response.status, 409); const removed = await request('/api/collection-items/bulk-delete', { method: 'POST', headers: headers({ 'Idempotency-Key': randomUUID() }), body: JSON.stringify({ ids: [item.id] }) }); assert.equal(removed.response.status, 202); assert.equal(removed.body.data.deleted_count, 1); });
|
||||||
|
});
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -0,0 +1,328 @@
|
|||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
import {
|
||||||
|
createApplicationDatabase,
|
||||||
|
type Database,
|
||||||
|
type DatabaseTransaction,
|
||||||
|
} from '@atlas/database';
|
||||||
|
import {
|
||||||
|
createImageSuiteSchema,
|
||||||
|
imageSuiteInputSchema,
|
||||||
|
type ImageSuite,
|
||||||
|
} from '@atlas/contracts';
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { IdempotencyService } from '../src/common/idempotency.service.js';
|
||||||
|
import { ImageSuiteService } from '../src/media/image-suite.service.js';
|
||||||
|
import { MediaAiEditService } from '../src/media/media-ai-edit.service.js';
|
||||||
|
import { ProductImageTaskService } from '../src/media/product-image-task.service.js';
|
||||||
|
|
||||||
|
/** 所有实体和 Outbox 都只存在于最终回滚的事务,验证不调用实际收费生图。 */
|
||||||
|
describe.skipIf(!process.env.DATABASE_URL)(
|
||||||
|
'套图数据库隔离、幂等与状态汇总',
|
||||||
|
() => {
|
||||||
|
it('创建/部分失败/取消/重试保留成功图片,FK 与 RLS 拒绝越权引用', async () => {
|
||||||
|
const db = createApplicationDatabase(process.env.DATABASE_URL!);
|
||||||
|
const rollback = Symbol('rollback');
|
||||||
|
try {
|
||||||
|
await db.transaction(async (tx) => {
|
||||||
|
const workspaceId = randomUUID(),
|
||||||
|
otherWorkspace = randomUUID(),
|
||||||
|
userId = randomUUID();
|
||||||
|
await tx.query(
|
||||||
|
"INSERT INTO atlas.users (id, email, password_hash) VALUES ($1, $2, 'test-only')",
|
||||||
|
[userId, `${userId}@example.test`],
|
||||||
|
);
|
||||||
|
for (const id of [workspaceId, otherWorkspace])
|
||||||
|
await tx.query(
|
||||||
|
"INSERT INTO atlas.workspaces (id, name, slug) VALUES ($1::uuid, '套图测试', $1::text)",
|
||||||
|
[id],
|
||||||
|
);
|
||||||
|
const inWorkspace = async (id: string) =>
|
||||||
|
tx.query(
|
||||||
|
"SELECT set_config('app.workspace_id', $1, true)",
|
||||||
|
[id],
|
||||||
|
);
|
||||||
|
await inWorkspace(workspaceId);
|
||||||
|
const productId = randomUUID(),
|
||||||
|
variantId = randomUUID(),
|
||||||
|
itemId = randomUUID(),
|
||||||
|
assetId = randomUUID(),
|
||||||
|
outputId = randomUUID();
|
||||||
|
await tx.query(
|
||||||
|
"INSERT INTO atlas.collection_items (id, workspace_id, source_kind, source_item_key) VALUES ($1::uuid, $2, 'manual', $1::text)",
|
||||||
|
[itemId, workspaceId],
|
||||||
|
);
|
||||||
|
await tx.query(
|
||||||
|
'INSERT INTO atlas.products (id, workspace_id, collection_item_id) VALUES ($1, $2, $3)',
|
||||||
|
[productId, workspaceId, itemId],
|
||||||
|
);
|
||||||
|
await tx.query(
|
||||||
|
'INSERT INTO atlas.product_variants (id, workspace_id, product_id) VALUES ($1, $2, $3)',
|
||||||
|
[variantId, workspaceId, productId],
|
||||||
|
);
|
||||||
|
const asset = async (id: string, ws: string) =>
|
||||||
|
tx.query(
|
||||||
|
`INSERT INTO atlas.media_assets
|
||||||
|
(id, workspace_id, storage_provider, bucket, object_key, content_type, byte_size, checksum_sha256, width_px, height_px)
|
||||||
|
VALUES ($1::uuid, $2, 'local_minio', 'test', $1::text, 'image/jpeg', 100, $1::text, 100, 100)`,
|
||||||
|
[id, ws],
|
||||||
|
);
|
||||||
|
await asset(assetId, workspaceId);
|
||||||
|
await asset(outputId, workspaceId);
|
||||||
|
await inWorkspace(otherWorkspace);
|
||||||
|
const foreignAsset = randomUUID();
|
||||||
|
await asset(foreignAsset, otherWorkspace);
|
||||||
|
await inWorkspace(workspaceId);
|
||||||
|
let queryQueue: Promise<void> = Promise.resolve();
|
||||||
|
const scoped = {
|
||||||
|
workspaceTransaction: <T>(
|
||||||
|
ws: string,
|
||||||
|
work: (
|
||||||
|
transaction: DatabaseTransaction,
|
||||||
|
) => Promise<T>,
|
||||||
|
) => {
|
||||||
|
const result = queryQueue.then(async () => {
|
||||||
|
await inWorkspace(ws);
|
||||||
|
return work(tx);
|
||||||
|
});
|
||||||
|
queryQueue = result.then(
|
||||||
|
() => undefined,
|
||||||
|
() => undefined,
|
||||||
|
);
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
} as Database;
|
||||||
|
const idempotency = new IdempotencyService(scoped, {
|
||||||
|
IDEMPOTENCY_KEY_SECRET: 'suite-test-secret',
|
||||||
|
} as never);
|
||||||
|
const edits = new MediaAiEditService(scoped, idempotency, {
|
||||||
|
accessUrl: async () => ({
|
||||||
|
url: 'https://example.test/preview',
|
||||||
|
expires_at: '2099-01-01T00:00:00Z',
|
||||||
|
}),
|
||||||
|
} as never);
|
||||||
|
const suites = new ImageSuiteService(
|
||||||
|
scoped,
|
||||||
|
idempotency,
|
||||||
|
edits,
|
||||||
|
);
|
||||||
|
const input = imageSuiteInputSchema.parse({
|
||||||
|
variant_id: variantId,
|
||||||
|
reference_asset_ids: [assetId],
|
||||||
|
product_name: '台灯',
|
||||||
|
});
|
||||||
|
const shots = ['white', 'scene', 'selling'].map(
|
||||||
|
(category, i) => ({
|
||||||
|
id: randomUUID(),
|
||||||
|
category,
|
||||||
|
type: ['white_background', 'lifestyle', 'detail'][
|
||||||
|
i
|
||||||
|
],
|
||||||
|
theme: `方案${i}`,
|
||||||
|
description: '依据真实底图展示商品',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const request = createImageSuiteSchema.parse({
|
||||||
|
...input,
|
||||||
|
shots,
|
||||||
|
});
|
||||||
|
const key = randomUUID();
|
||||||
|
const created = await suites.create(
|
||||||
|
workspaceId,
|
||||||
|
userId,
|
||||||
|
productId,
|
||||||
|
key,
|
||||||
|
request,
|
||||||
|
'generate',
|
||||||
|
);
|
||||||
|
const id = (created.body as { data: ImageSuite }).data.id;
|
||||||
|
expect(
|
||||||
|
(
|
||||||
|
await suites.create(
|
||||||
|
workspaceId,
|
||||||
|
userId,
|
||||||
|
productId,
|
||||||
|
key,
|
||||||
|
request,
|
||||||
|
'generate',
|
||||||
|
)
|
||||||
|
).body,
|
||||||
|
).toEqual(created.body);
|
||||||
|
await expect(
|
||||||
|
suites.create(
|
||||||
|
workspaceId,
|
||||||
|
userId,
|
||||||
|
productId,
|
||||||
|
key,
|
||||||
|
{ ...request, product_name: '不同请求' },
|
||||||
|
'generate',
|
||||||
|
),
|
||||||
|
).rejects.toMatchObject({
|
||||||
|
status: 409,
|
||||||
|
response: { error: { code: 'IDEMPOTENCY_KEY_REUSED' } },
|
||||||
|
});
|
||||||
|
const children = (
|
||||||
|
await tx.query<{ id: string }>(
|
||||||
|
'SELECT id FROM atlas.media_ai_edits WHERE suite_id = $1 ORDER BY suite_position',
|
||||||
|
[id],
|
||||||
|
)
|
||||||
|
).rows;
|
||||||
|
expect(children).toHaveLength(3);
|
||||||
|
expect(
|
||||||
|
(
|
||||||
|
await tx.query(
|
||||||
|
'SELECT id FROM atlas.outbox_events WHERE aggregate_id = ANY($1::uuid[])',
|
||||||
|
[children.map((child) => child.id)],
|
||||||
|
)
|
||||||
|
).rows,
|
||||||
|
).toHaveLength(3);
|
||||||
|
await tx.query(
|
||||||
|
"UPDATE atlas.media_ai_edits SET status = 'succeeded', output_asset_id = $2 WHERE id = $1",
|
||||||
|
[children[0].id, outputId],
|
||||||
|
);
|
||||||
|
await tx.query(
|
||||||
|
"UPDATE atlas.media_ai_edits SET status = 'failed', error_code = 'TEST_FAILURE' WHERE id = $1",
|
||||||
|
[children[1].id],
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
(await suites.read(workspaceId, productId, id)).status,
|
||||||
|
).toBe('running');
|
||||||
|
await suites.action(
|
||||||
|
workspaceId,
|
||||||
|
userId,
|
||||||
|
productId,
|
||||||
|
id,
|
||||||
|
randomUUID(),
|
||||||
|
'cancel',
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
(await suites.read(workspaceId, productId, id)).status,
|
||||||
|
).toBe('cancelled');
|
||||||
|
await suites.action(
|
||||||
|
workspaceId,
|
||||||
|
userId,
|
||||||
|
productId,
|
||||||
|
id,
|
||||||
|
randomUUID(),
|
||||||
|
'retry',
|
||||||
|
);
|
||||||
|
const retried = await suites.read(
|
||||||
|
workspaceId,
|
||||||
|
productId,
|
||||||
|
id,
|
||||||
|
);
|
||||||
|
expect(retried.items.map((item) => item.status)).toEqual([
|
||||||
|
'succeeded',
|
||||||
|
'queued',
|
||||||
|
'queued',
|
||||||
|
]);
|
||||||
|
expect(retried.items[0].output_asset?.id).toBe(outputId);
|
||||||
|
const attempts = (
|
||||||
|
await tx.query<{ generation_attempt: number }>(
|
||||||
|
'SELECT generation_attempt FROM atlas.media_ai_edits WHERE suite_id = $1 ORDER BY suite_position',
|
||||||
|
[id],
|
||||||
|
)
|
||||||
|
).rows;
|
||||||
|
expect(
|
||||||
|
attempts.map((item) => item.generation_attempt),
|
||||||
|
).toEqual([1, 2, 2]);
|
||||||
|
await tx.query(
|
||||||
|
"UPDATE atlas.media_ai_edits SET status = 'failed', error_code = 'TEST_FAILURE' WHERE id = ANY($1::uuid[])",
|
||||||
|
[children.slice(1).map((child) => child.id)],
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
(await suites.read(workspaceId, productId, id)).status,
|
||||||
|
).toBe('partial');
|
||||||
|
const tasks = await new ProductImageTaskService(
|
||||||
|
scoped,
|
||||||
|
).list(workspaceId, productId, { limit: 20 });
|
||||||
|
expect(tasks.data).toMatchObject([
|
||||||
|
{
|
||||||
|
id,
|
||||||
|
kind: 'ai_suite',
|
||||||
|
status: 'partial',
|
||||||
|
total: 3,
|
||||||
|
completed: 1,
|
||||||
|
output_asset_ids: [outputId],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
expect(tasks.data).toHaveLength(1);
|
||||||
|
await expect(
|
||||||
|
suites.read(otherWorkspace, productId, id),
|
||||||
|
).rejects.toMatchObject({ status: 404 });
|
||||||
|
await expect(
|
||||||
|
suites.create(
|
||||||
|
workspaceId,
|
||||||
|
userId,
|
||||||
|
productId,
|
||||||
|
randomUUID(),
|
||||||
|
{ ...input, reference_asset_ids: [foreignAsset] },
|
||||||
|
'plan',
|
||||||
|
),
|
||||||
|
).rejects.toMatchObject({ status: 404 });
|
||||||
|
const forbidden = async (
|
||||||
|
sql: string,
|
||||||
|
params: unknown[],
|
||||||
|
code: string,
|
||||||
|
) => {
|
||||||
|
await tx.query('SAVEPOINT forbidden_write');
|
||||||
|
try {
|
||||||
|
await tx.query(sql, params);
|
||||||
|
throw new Error('本应拒绝的写入成功');
|
||||||
|
} catch (error) {
|
||||||
|
expect((error as { code: string }).code).toBe(code);
|
||||||
|
} finally {
|
||||||
|
await tx.query(
|
||||||
|
'ROLLBACK TO SAVEPOINT forbidden_write',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
await forbidden(
|
||||||
|
'UPDATE atlas.product_image_suites SET input = input || \'{"color":"篡改"}\'::jsonb WHERE id = $1',
|
||||||
|
[id],
|
||||||
|
'23514',
|
||||||
|
);
|
||||||
|
await forbidden(
|
||||||
|
'UPDATE atlas.media_ai_edits SET suite_position = NULL WHERE id = $1',
|
||||||
|
[children[0].id],
|
||||||
|
'23514',
|
||||||
|
);
|
||||||
|
await forbidden(
|
||||||
|
'UPDATE atlas.media_ai_edits SET source_asset_id = $2 WHERE id = $1',
|
||||||
|
[children[0].id, foreignAsset],
|
||||||
|
'23514',
|
||||||
|
);
|
||||||
|
const foreignReferenceParent = await suites.create(
|
||||||
|
workspaceId,
|
||||||
|
userId,
|
||||||
|
productId,
|
||||||
|
randomUUID(),
|
||||||
|
input,
|
||||||
|
'plan',
|
||||||
|
);
|
||||||
|
const foreignParentId = (
|
||||||
|
foreignReferenceParent.body as { data: ImageSuite }
|
||||||
|
).data.id;
|
||||||
|
await forbidden(
|
||||||
|
'UPDATE atlas.product_image_suite_references SET asset_id = $2 WHERE suite_id = $1',
|
||||||
|
[foreignParentId, foreignAsset],
|
||||||
|
'23514',
|
||||||
|
);
|
||||||
|
await inWorkspace(otherWorkspace);
|
||||||
|
expect(
|
||||||
|
(
|
||||||
|
await tx.query(
|
||||||
|
'SELECT id FROM atlas.product_image_suites WHERE id = $1',
|
||||||
|
[id],
|
||||||
|
)
|
||||||
|
).rows,
|
||||||
|
).toHaveLength(0);
|
||||||
|
throw rollback;
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (error !== rollback) throw error;
|
||||||
|
} finally {
|
||||||
|
await db.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
@@ -0,0 +1,291 @@
|
|||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
import {
|
||||||
|
createImageSuiteSchema,
|
||||||
|
imageSuiteInputSchema,
|
||||||
|
imageSuitePlanInputSchema,
|
||||||
|
} from '@atlas/contracts';
|
||||||
|
import { ImageSuiteService } from '../src/media/image-suite.service.js';
|
||||||
|
|
||||||
|
const input = () =>
|
||||||
|
imageSuiteInputSchema.parse({
|
||||||
|
variant_id: randomUUID(),
|
||||||
|
reference_asset_ids: [randomUUID()],
|
||||||
|
product_name: '台灯',
|
||||||
|
});
|
||||||
|
const shot = () => ({
|
||||||
|
id: randomUUID(),
|
||||||
|
category: 'white',
|
||||||
|
type: 'white_background',
|
||||||
|
theme: '白底主图',
|
||||||
|
description: '展示商品完整外观',
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('套图共享契约与资源边界', () => {
|
||||||
|
it('规划默认四张,接受 1–20 张预算,生成接口不接收规划预算字段', () => {
|
||||||
|
const valid = input();
|
||||||
|
expect(imageSuitePlanInputSchema.parse(valid).image_count).toBe(4);
|
||||||
|
expect(
|
||||||
|
imageSuitePlanInputSchema.parse({ ...valid, image_count: 6 })
|
||||||
|
.image_count,
|
||||||
|
).toBe(6);
|
||||||
|
for (const image_count of [0, 21, 1.2])
|
||||||
|
expect(
|
||||||
|
imageSuitePlanInputSchema.safeParse({ ...valid, image_count })
|
||||||
|
.success,
|
||||||
|
).toBe(false);
|
||||||
|
expect(
|
||||||
|
createImageSuiteSchema.safeParse({
|
||||||
|
...valid,
|
||||||
|
image_count: 4,
|
||||||
|
shots: [shot()],
|
||||||
|
}).success,
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
it('规划保存预算并只创建规划 Outbox,不创建生图子任务', async () => {
|
||||||
|
const request = imageSuitePlanInputSchema.parse({
|
||||||
|
...input(),
|
||||||
|
image_count: 6,
|
||||||
|
});
|
||||||
|
const suiteId = randomUUID(),
|
||||||
|
productId = randomUUID();
|
||||||
|
const query = vi.fn(async (sql: string, values: unknown[] = []) => {
|
||||||
|
if (sql.startsWith('SELECT id FROM atlas.product_variants'))
|
||||||
|
return { rows: [{ id: request.variant_id }] };
|
||||||
|
if (sql.startsWith('SELECT id FROM atlas.media_assets'))
|
||||||
|
return {
|
||||||
|
rows: request.reference_asset_ids.map((id) => ({ id })),
|
||||||
|
};
|
||||||
|
if (sql.startsWith('INSERT INTO atlas.product_image_suites'))
|
||||||
|
return {
|
||||||
|
rows: [
|
||||||
|
{
|
||||||
|
id: suiteId,
|
||||||
|
product_id: productId,
|
||||||
|
variant_id: request.variant_id,
|
||||||
|
purpose: 'plan',
|
||||||
|
status: 'queued',
|
||||||
|
input: JSON.parse(values[3] as string),
|
||||||
|
shots: [],
|
||||||
|
created_at: new Date(),
|
||||||
|
completed_at: null,
|
||||||
|
error_code: null,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
return { rows: [] };
|
||||||
|
});
|
||||||
|
const service = new ImageSuiteService(
|
||||||
|
{} as never,
|
||||||
|
{
|
||||||
|
run: async (
|
||||||
|
_context: unknown,
|
||||||
|
work: (tx: { query: typeof query }) => Promise<unknown>,
|
||||||
|
) => work({ query }),
|
||||||
|
} as never,
|
||||||
|
{} as never,
|
||||||
|
);
|
||||||
|
const result = await service.create(
|
||||||
|
randomUUID(),
|
||||||
|
randomUUID(),
|
||||||
|
productId,
|
||||||
|
randomUUID(),
|
||||||
|
request,
|
||||||
|
'plan',
|
||||||
|
);
|
||||||
|
expect(result).toMatchObject({
|
||||||
|
status: 202,
|
||||||
|
body: {
|
||||||
|
data: { purpose: 'plan', input: { image_count: 6 }, shots: [] },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
query.mock.calls.some(([sql]) =>
|
||||||
|
sql.includes('INSERT INTO atlas.media_ai_edits'),
|
||||||
|
),
|
||||||
|
).toBe(false);
|
||||||
|
expect(
|
||||||
|
query.mock.calls.find(([sql]) =>
|
||||||
|
sql.includes('INSERT INTO atlas.outbox_events'),
|
||||||
|
)?.[1],
|
||||||
|
).toContain('image_suite.plan.requested');
|
||||||
|
});
|
||||||
|
it('拒绝重复底图、超过 8 张底图、超过分类数量和无实物尺寸的尺寸图', () => {
|
||||||
|
const valid = input();
|
||||||
|
expect(
|
||||||
|
imageSuiteInputSchema.safeParse({
|
||||||
|
...valid,
|
||||||
|
reference_asset_ids: Array.from({ length: 9 }, randomUUID),
|
||||||
|
}).success,
|
||||||
|
).toBe(false);
|
||||||
|
expect(
|
||||||
|
imageSuiteInputSchema.safeParse({
|
||||||
|
...valid,
|
||||||
|
reference_asset_ids: [
|
||||||
|
...valid.reference_asset_ids,
|
||||||
|
...valid.reference_asset_ids,
|
||||||
|
],
|
||||||
|
}).success,
|
||||||
|
).toBe(false);
|
||||||
|
expect(
|
||||||
|
createImageSuiteSchema.safeParse({
|
||||||
|
...valid,
|
||||||
|
shots: Array.from({ length: 6 }, shot),
|
||||||
|
}).success,
|
||||||
|
).toBe(false);
|
||||||
|
expect(
|
||||||
|
createImageSuiteSchema.safeParse({
|
||||||
|
...valid,
|
||||||
|
shots: [{ ...shot(), category: 'other', type: 'dimensions' }],
|
||||||
|
}).success,
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
it('逐张底图必须来自已选图库,并保留主体图,类型必须匹配分类', () => {
|
||||||
|
const valid = input();
|
||||||
|
expect(
|
||||||
|
createImageSuiteSchema.safeParse({
|
||||||
|
...valid,
|
||||||
|
shots: [{ ...shot(), reference_asset_ids: [randomUUID()] }],
|
||||||
|
}).success,
|
||||||
|
).toBe(false);
|
||||||
|
expect(
|
||||||
|
createImageSuiteSchema.safeParse({
|
||||||
|
...valid,
|
||||||
|
shots: [{ ...shot(), category: 'scene' }],
|
||||||
|
}).success,
|
||||||
|
).toBe(false);
|
||||||
|
expect(
|
||||||
|
createImageSuiteSchema.safeParse({
|
||||||
|
...valid,
|
||||||
|
shots: [shot()],
|
||||||
|
ratio: '4:3',
|
||||||
|
language: 'zh',
|
||||||
|
}).success,
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
it('目标 SKU 不属于当前商品时,在产生任何任务前拒绝请求', async () => {
|
||||||
|
const query = vi.fn(async () => ({ rows: [] }));
|
||||||
|
const run = vi.fn(
|
||||||
|
async (
|
||||||
|
_input: unknown,
|
||||||
|
work: (tx: { query: typeof query }) => Promise<unknown>,
|
||||||
|
) => work({ query }),
|
||||||
|
);
|
||||||
|
const service = new ImageSuiteService(
|
||||||
|
{} as never,
|
||||||
|
{ run } as never,
|
||||||
|
{} as never,
|
||||||
|
);
|
||||||
|
await expect(
|
||||||
|
service.create(
|
||||||
|
randomUUID(),
|
||||||
|
randomUUID(),
|
||||||
|
randomUUID(),
|
||||||
|
randomUUID(),
|
||||||
|
input(),
|
||||||
|
'plan',
|
||||||
|
),
|
||||||
|
).rejects.toMatchObject({ status: 404 });
|
||||||
|
expect(query).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
it('任一底图不可见时拒绝,不产生套图及 Outbox', async () => {
|
||||||
|
const query = vi.fn(async (sql: string) => ({
|
||||||
|
rows: sql.includes('atlas.product_variants')
|
||||||
|
? [{ id: 'variant' }]
|
||||||
|
: [],
|
||||||
|
}));
|
||||||
|
const run = vi.fn(
|
||||||
|
async (
|
||||||
|
_input: unknown,
|
||||||
|
work: (tx: { query: typeof query }) => Promise<unknown>,
|
||||||
|
) => work({ query }),
|
||||||
|
);
|
||||||
|
const service = new ImageSuiteService(
|
||||||
|
{} as never,
|
||||||
|
{ run } as never,
|
||||||
|
{} as never,
|
||||||
|
);
|
||||||
|
await expect(
|
||||||
|
service.create(
|
||||||
|
randomUUID(),
|
||||||
|
randomUUID(),
|
||||||
|
randomUUID(),
|
||||||
|
randomUUID(),
|
||||||
|
input(),
|
||||||
|
'plan',
|
||||||
|
),
|
||||||
|
).rejects.toMatchObject({ status: 404 });
|
||||||
|
expect(query).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
it('默认逐张用图保留主体,每张最多 3 张,整套轮换覆盖已选 8 张底图', async () => {
|
||||||
|
const request = createImageSuiteSchema.parse({
|
||||||
|
...input(),
|
||||||
|
reference_asset_ids: Array.from({ length: 8 }, randomUUID),
|
||||||
|
shots: Array.from({ length: 8 }, (_, index) => ({
|
||||||
|
...shot(),
|
||||||
|
category: ['white', 'scene', 'selling', 'other'][index % 4],
|
||||||
|
type: ['white_background', 'lifestyle', 'detail', 'custom'][
|
||||||
|
index % 4
|
||||||
|
],
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
const used: string[][] = [];
|
||||||
|
const query = vi.fn(async (sql: string, values: unknown[] = []) => {
|
||||||
|
if (sql.startsWith('SELECT id FROM atlas.product_variants'))
|
||||||
|
return { rows: [{ id: request.variant_id }] };
|
||||||
|
if (sql.startsWith('SELECT id FROM atlas.media_assets'))
|
||||||
|
return {
|
||||||
|
rows: request.reference_asset_ids.map((id) => ({ id })),
|
||||||
|
};
|
||||||
|
if (sql.includes('INSERT INTO atlas.product_image_suites'))
|
||||||
|
return {
|
||||||
|
rows: [
|
||||||
|
{
|
||||||
|
id: randomUUID(),
|
||||||
|
product_id: randomUUID(),
|
||||||
|
variant_id: request.variant_id,
|
||||||
|
purpose: 'generate',
|
||||||
|
status: 'queued',
|
||||||
|
input: request,
|
||||||
|
shots: request.shots,
|
||||||
|
created_at: new Date(),
|
||||||
|
completed_at: null,
|
||||||
|
error_code: null,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
if (sql.includes('INSERT INTO atlas.media_ai_edits'))
|
||||||
|
used.push([values[5] as string, ...(values[6] as string[])]);
|
||||||
|
return { rows: [] };
|
||||||
|
});
|
||||||
|
const service = new ImageSuiteService(
|
||||||
|
{} as never,
|
||||||
|
{
|
||||||
|
run: async (
|
||||||
|
_request: unknown,
|
||||||
|
work: (tx: { query: typeof query }) => Promise<unknown>,
|
||||||
|
) => work({ query }),
|
||||||
|
} as never,
|
||||||
|
{} as never,
|
||||||
|
);
|
||||||
|
await service.create(
|
||||||
|
randomUUID(),
|
||||||
|
randomUUID(),
|
||||||
|
randomUUID(),
|
||||||
|
randomUUID(),
|
||||||
|
request,
|
||||||
|
'generate',
|
||||||
|
);
|
||||||
|
expect(used).toHaveLength(8);
|
||||||
|
expect(
|
||||||
|
used.every(
|
||||||
|
(refs) =>
|
||||||
|
refs.length <= 3 &&
|
||||||
|
refs[0] === request.reference_asset_ids[0],
|
||||||
|
),
|
||||||
|
).toBe(true);
|
||||||
|
expect(new Set(used.flat())).toEqual(
|
||||||
|
new Set(request.reference_asset_ids),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
import { createMediaAiEditRequestSchema } from '@atlas/contracts';
|
||||||
|
import { MediaAiEditService } from '../src/media/media-ai-edit.service.js';
|
||||||
|
|
||||||
|
const workspaceId = '30cf54b7-06a3-4e5f-a5fc-d74165115094';
|
||||||
|
const sourceId = '664d0d73-3d39-4a98-ac0a-5c0e521d8998';
|
||||||
|
const referenceId = '50ea00b7-6675-4cc7-b7a2-d45f05c07100';
|
||||||
|
const editId = '68db226f-3fcb-4579-ab9e-4932b1d84067';
|
||||||
|
const productId = '733d0d73-3d39-4a98-ac0a-5c0e521d8998';
|
||||||
|
|
||||||
|
describe('单张 AI 编辑任务', () => {
|
||||||
|
it('验证主图和辅图都属于当前工作空间后,原子写入任务、进度和 Outbox', async () => {
|
||||||
|
const statements: string[] = [];
|
||||||
|
const query = vi.fn(async (sql: string) => {
|
||||||
|
statements.push(sql);
|
||||||
|
if (sql.includes('SELECT id FROM atlas.products')) return { rows: [{ id: productId }] };
|
||||||
|
if (sql.includes('SELECT id FROM atlas.media_assets'))
|
||||||
|
return { rows: [{ id: sourceId }, { id: referenceId }] };
|
||||||
|
if (sql.includes('RETURNING id')) return { rows: [{ id: editId }] };
|
||||||
|
return { rows: [] };
|
||||||
|
});
|
||||||
|
const run = vi.fn(async (_input: unknown, work: (tx: { query: typeof query }) => Promise<unknown>) => work({ query }));
|
||||||
|
const service = new MediaAiEditService(
|
||||||
|
{} as ConstructorParameters<typeof MediaAiEditService>[0],
|
||||||
|
{ run } as unknown as ConstructorParameters<typeof MediaAiEditService>[1],
|
||||||
|
{} as ConstructorParameters<typeof MediaAiEditService>[2],
|
||||||
|
);
|
||||||
|
const input = createMediaAiEditRequestSchema.parse({ product_id: productId, reference_asset_ids: [referenceId], background: 'reference' });
|
||||||
|
const result = await service.create(workspaceId, 'user-id', sourceId, 'key', input);
|
||||||
|
expect(result).toMatchObject({ status: 202, body: { data: { id: editId, status: 'queued' } } });
|
||||||
|
expect(run).toHaveBeenCalledOnce();
|
||||||
|
expect(statements.some((sql) => sql.includes('INSERT INTO atlas.media_ai_edits'))).toBe(true);
|
||||||
|
expect(statements.some((sql) => sql.includes('INSERT INTO atlas.operations'))).toBe(true);
|
||||||
|
expect(statements.some((sql) => sql.includes('INSERT INTO atlas.outbox_events'))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('任一辅助图不在当前工作空间时,不创建任务', async () => {
|
||||||
|
const query = vi.fn(async (sql: string) => ({ rows: [{ id: sql.includes('atlas.products') ? productId : sourceId }] }));
|
||||||
|
const run = vi.fn(async (_input: unknown, work: (tx: { query: typeof query }) => Promise<unknown>) => work({ query }));
|
||||||
|
const service = new MediaAiEditService(
|
||||||
|
{} as ConstructorParameters<typeof MediaAiEditService>[0],
|
||||||
|
{ run } as unknown as ConstructorParameters<typeof MediaAiEditService>[1],
|
||||||
|
{} as ConstructorParameters<typeof MediaAiEditService>[2],
|
||||||
|
);
|
||||||
|
const input = createMediaAiEditRequestSchema.parse({ product_id: productId, reference_asset_ids: [referenceId] });
|
||||||
|
await expect(service.create(workspaceId, 'user-id', sourceId, 'key', input))
|
||||||
|
.rejects.toMatchObject({ status: 404 });
|
||||||
|
expect(query).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('仅扩图不能带辅图或尺寸标注,参考背景必须带辅图', () => {
|
||||||
|
expect(createMediaAiEditRequestSchema.safeParse({ product_id: productId, mode: 'outpaint', reference_asset_ids: [referenceId] }).success).toBe(false);
|
||||||
|
expect(createMediaAiEditRequestSchema.safeParse({ product_id: productId, background: 'reference' }).success).toBe(false);
|
||||||
|
expect(createMediaAiEditRequestSchema.safeParse({ product_id: productId, generate_size_chart: true }).success).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('读取失败任务时返回记录的失败来源、步骤和诊断', async () => {
|
||||||
|
const query = vi.fn(async (sql: string) => {
|
||||||
|
expect(sql).toContain('o.workspace_id = e.workspace_id');
|
||||||
|
return { rows: [{
|
||||||
|
id: editId, status: 'failed', source_asset_id: sourceId,
|
||||||
|
output_asset_id: null, error_code: 'AI_IMAGE_GENERATION_FAILED',
|
||||||
|
failure_source: 'atlas', failure_stage: 'result_processing',
|
||||||
|
failure_detail: 'Pcore 结果图片无效',
|
||||||
|
}] };
|
||||||
|
});
|
||||||
|
const database = { workspaceTransaction: vi.fn(async (_workspace: string, work: (tx: { query: typeof query }) => Promise<unknown>) => work({ query })) };
|
||||||
|
const service = new MediaAiEditService(
|
||||||
|
database as unknown as ConstructorParameters<typeof MediaAiEditService>[0],
|
||||||
|
{} as ConstructorParameters<typeof MediaAiEditService>[1],
|
||||||
|
{} as ConstructorParameters<typeof MediaAiEditService>[2],
|
||||||
|
);
|
||||||
|
const result = await service.read(workspaceId, editId);
|
||||||
|
expect(result).toMatchObject({
|
||||||
|
failure_source: 'atlas', failure_stage: 'result_processing',
|
||||||
|
failure_detail: 'Pcore 结果图片无效',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('AI 生图记录按当前商品筛选并重新签发短时预览地址', async () => {
|
||||||
|
const query = vi.fn(async (_sql: string, values: unknown[]) => {
|
||||||
|
expect(values).toEqual([productId]);
|
||||||
|
return { rows: [{
|
||||||
|
id: editId, status: 'succeeded', source_asset_id: sourceId,
|
||||||
|
output_asset_id: referenceId, error_code: null,
|
||||||
|
content_type: 'image/jpeg', byte_size: '1234', width_px: 1152,
|
||||||
|
height_px: 1536, asset_created_at: new Date('2026-09-26T00:00:00Z'),
|
||||||
|
}] };
|
||||||
|
});
|
||||||
|
const database = { workspaceTransaction: vi.fn(async (_workspace: string, work: (tx: { query: typeof query }) => Promise<unknown>) => work({ query })) };
|
||||||
|
const accessUrl = vi.fn(async () => ({ url: 'https://example.com/short-lived', expires_at: '2026-09-26T01:00:00Z' }));
|
||||||
|
const service = new MediaAiEditService(
|
||||||
|
database as unknown as ConstructorParameters<typeof MediaAiEditService>[0],
|
||||||
|
{} as ConstructorParameters<typeof MediaAiEditService>[1],
|
||||||
|
{ accessUrl } as unknown as ConstructorParameters<typeof MediaAiEditService>[2],
|
||||||
|
);
|
||||||
|
const items = await service.list(workspaceId, productId);
|
||||||
|
expect(items).toMatchObject([{ id: editId, output_asset: { id: referenceId, preview_url: 'https://example.com/short-lived' } }]);
|
||||||
|
expect(accessUrl).toHaveBeenCalledWith(workspaceId, referenceId);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
import { AspectRatioDerivativeService } from '../src/media/aspect-ratio-derivative.service.js';
|
||||||
|
|
||||||
|
const workspaceId = '30cf54b7-06a3-4e5f-a5fc-d74165115094';
|
||||||
|
const sourceAssetId = '664d0d73-3d39-4a98-ac0a-5c0e521d8998';
|
||||||
|
const derivativeId = '68db226f-3fcb-4579-ab9e-4932b1d84067';
|
||||||
|
const productId = '733d0d73-3d39-4a98-ac0a-5c0e521d8998';
|
||||||
|
|
||||||
|
describe('一键 3:4 派生任务', () => {
|
||||||
|
it('在同一个租户事务写入派生记录、操作进度和 Outbox', async () => {
|
||||||
|
const statements: string[] = [];
|
||||||
|
const query = vi.fn(async (sql: string) => {
|
||||||
|
statements.push(sql);
|
||||||
|
if (sql.includes('SELECT id FROM atlas.media_assets')) return { rows: [{ id: sourceAssetId }] };
|
||||||
|
if (sql.includes('RETURNING id')) return { rows: [{ id: derivativeId }] };
|
||||||
|
return { rows: [] };
|
||||||
|
});
|
||||||
|
const run = vi.fn(async (_input: unknown, work: (tx: { query: typeof query }) => Promise<unknown>) =>
|
||||||
|
work({ query }));
|
||||||
|
const service = new AspectRatioDerivativeService(
|
||||||
|
{} as ConstructorParameters<typeof AspectRatioDerivativeService>[0],
|
||||||
|
{ run } as unknown as ConstructorParameters<typeof AspectRatioDerivativeService>[1],
|
||||||
|
{} as ConstructorParameters<typeof AspectRatioDerivativeService>[2],
|
||||||
|
);
|
||||||
|
const result = await service.create(
|
||||||
|
workspaceId,
|
||||||
|
'user-id',
|
||||||
|
sourceAssetId,
|
||||||
|
'idempotency-key',
|
||||||
|
{ preset: '3:4', fit: 'contain' },
|
||||||
|
);
|
||||||
|
expect(result).toMatchObject({
|
||||||
|
status: 202,
|
||||||
|
body: { data: { id: derivativeId, status: 'queued', source_asset_id: sourceAssetId } },
|
||||||
|
});
|
||||||
|
expect(run).toHaveBeenCalledOnce();
|
||||||
|
expect(statements.some((sql) => sql.includes('INSERT INTO atlas.media_aspect_ratio_derivatives'))).toBe(true);
|
||||||
|
expect(statements.some((sql) => sql.includes('INSERT INTO atlas.operations'))).toBe(true);
|
||||||
|
expect(statements.some((sql) => sql.includes('INSERT INTO atlas.outbox_events'))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('缺少或跨工作空间的源素材不会创建任务', async () => {
|
||||||
|
const query = vi.fn(async () => ({ rows: [] }));
|
||||||
|
const run = vi.fn(async (_input: unknown, work: (tx: { query: typeof query }) => Promise<unknown>) =>
|
||||||
|
work({ query }));
|
||||||
|
const service = new AspectRatioDerivativeService(
|
||||||
|
{} as ConstructorParameters<typeof AspectRatioDerivativeService>[0],
|
||||||
|
{ run } as unknown as ConstructorParameters<typeof AspectRatioDerivativeService>[1],
|
||||||
|
{} as ConstructorParameters<typeof AspectRatioDerivativeService>[2],
|
||||||
|
);
|
||||||
|
await expect(service.create(
|
||||||
|
workspaceId,
|
||||||
|
'user-id',
|
||||||
|
sourceAssetId,
|
||||||
|
'idempotency-key',
|
||||||
|
{ preset: '3:4', fit: 'contain' },
|
||||||
|
)).rejects.toMatchObject({ status: 404 });
|
||||||
|
expect(query).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('将当前工作空间的商品关联到新派生任务', async () => {
|
||||||
|
const query = vi.fn(async (sql: string, values?: unknown[]) => {
|
||||||
|
if (sql.includes('SELECT id FROM atlas.media_assets')) return { rows: [{ id: sourceAssetId }] };
|
||||||
|
if (sql.includes('SELECT id FROM atlas.products')) return { rows: [{ id: productId }] };
|
||||||
|
if (sql.includes('INSERT INTO atlas.media_aspect_ratio_derivatives')) {
|
||||||
|
expect(values).toEqual([productId, sourceAssetId, 'user-id']);
|
||||||
|
return { rows: [{ id: derivativeId }] };
|
||||||
|
}
|
||||||
|
return { rows: [] };
|
||||||
|
});
|
||||||
|
const run = vi.fn(async (_input: unknown, work: (tx: { query: typeof query }) => Promise<unknown>) => work({ query }));
|
||||||
|
const service = new AspectRatioDerivativeService(
|
||||||
|
{} as ConstructorParameters<typeof AspectRatioDerivativeService>[0],
|
||||||
|
{ run } as unknown as ConstructorParameters<typeof AspectRatioDerivativeService>[1],
|
||||||
|
{} as ConstructorParameters<typeof AspectRatioDerivativeService>[2],
|
||||||
|
);
|
||||||
|
await service.create(workspaceId, 'user-id', sourceAssetId, 'key',
|
||||||
|
{ preset: '3:4', fit: 'contain', product_id: productId });
|
||||||
|
expect(query.mock.calls.some(([sql]) => String(sql).includes('INSERT INTO atlas.media_aspect_ratio_derivatives'))).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
import { createHash } from 'node:crypto';
|
||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
import { MediaService } from '../src/media/media.service.js';
|
||||||
|
|
||||||
|
vi.mock('../src/media/product-image-download.js', async (importOriginal) => {
|
||||||
|
const original =
|
||||||
|
await importOriginal<
|
||||||
|
typeof import('../src/media/product-image-download.js')
|
||||||
|
>();
|
||||||
|
return {
|
||||||
|
...original,
|
||||||
|
downloadProductImage: vi.fn(async () => ({
|
||||||
|
bytes: Buffer.from('validated-image'),
|
||||||
|
contentType: 'image/png',
|
||||||
|
width: 800,
|
||||||
|
height: 800,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('网络商品图片导入', () => {
|
||||||
|
it('复用同工作空间素材并返回正式 ID 与短时预览地址', async () => {
|
||||||
|
const productId = '733d0d73-3d39-4a98-ac0a-5c0e521d8998';
|
||||||
|
const assetId = '7c910684-7070-4f36-8a7b-b10bd91acd79';
|
||||||
|
const bytes = Buffer.from('validated-image');
|
||||||
|
const checksum = createHash('sha256').update(bytes).digest('hex');
|
||||||
|
const query = vi.fn(async (sql: string, args?: unknown[]) => {
|
||||||
|
if (sql.includes('FROM atlas.products')) {
|
||||||
|
expect(args).toEqual([productId]);
|
||||||
|
return { rows: [{ id: productId }] };
|
||||||
|
}
|
||||||
|
if (sql.includes('WHERE checksum_sha256 = $1')) {
|
||||||
|
expect(args).toEqual([checksum]);
|
||||||
|
return { rows: [{ id: assetId }] };
|
||||||
|
}
|
||||||
|
if (sql.includes('INSERT INTO atlas.product_image_imports')) {
|
||||||
|
expect(args?.[0]).toBe('https://example.com/photo.png');
|
||||||
|
expect(args?.[1]).toBe(assetId);
|
||||||
|
expect(args?.[3]).toBe(productId);
|
||||||
|
expect(sql).toContain('ON CONFLICT (workspace_id, product_id, source_url)');
|
||||||
|
return { rows: [] };
|
||||||
|
}
|
||||||
|
if (sql.includes('SELECT id, content_type')) {
|
||||||
|
return {
|
||||||
|
rows: [
|
||||||
|
{
|
||||||
|
id: assetId,
|
||||||
|
content_type: 'image/png',
|
||||||
|
byte_size: '15',
|
||||||
|
width_px: 800,
|
||||||
|
height_px: 800,
|
||||||
|
created_at: new Date('2026-09-24T00:00:00Z'),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (sql.includes('SELECT storage_provider, bucket, object_key')) {
|
||||||
|
return {
|
||||||
|
rows: [
|
||||||
|
{
|
||||||
|
storage_provider: 'local_minio',
|
||||||
|
bucket: 'test',
|
||||||
|
object_key: 'product-imports/existing.png',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
throw new Error(`未知 SQL: ${sql}`);
|
||||||
|
});
|
||||||
|
const run = vi.fn(
|
||||||
|
async (
|
||||||
|
request: { path: string; key: string },
|
||||||
|
callback: (tx: { query: typeof query }) => Promise<unknown>,
|
||||||
|
) => {
|
||||||
|
expect(request.path).toBe('/api/media-assets/product-imports');
|
||||||
|
expect(request.key).toBe('idempotency-key');
|
||||||
|
return callback({ query });
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const service = new MediaService(
|
||||||
|
{ run } as unknown as ConstructorParameters<typeof MediaService>[0],
|
||||||
|
{} as ConstructorParameters<typeof MediaService>[1],
|
||||||
|
{
|
||||||
|
OBJECT_STORAGE_ENDPOINT: 'http://127.0.0.1:9000',
|
||||||
|
OBJECT_STORAGE_REGION: 'us-east-1',
|
||||||
|
OBJECT_STORAGE_BUCKET: 'test',
|
||||||
|
objectStorageForcePathStyle: true,
|
||||||
|
objectStorageAccessKey: 'test',
|
||||||
|
objectStorageSecretKey: 'test',
|
||||||
|
} as ConstructorParameters<typeof MediaService>[2],
|
||||||
|
);
|
||||||
|
const result = await service.importProductImage(
|
||||||
|
'workspace-id',
|
||||||
|
'user-id',
|
||||||
|
'idempotency-key',
|
||||||
|
{ url: 'https://example.com/photo.png', product_id: productId },
|
||||||
|
);
|
||||||
|
const body = result.body as {
|
||||||
|
data: { id: string; source_url: string; preview_url: string };
|
||||||
|
};
|
||||||
|
expect(result.status).toBe(201);
|
||||||
|
expect(body.data).toMatchObject({
|
||||||
|
id: assetId,
|
||||||
|
source_url: 'https://example.com/photo.png',
|
||||||
|
});
|
||||||
|
expect(body.data.preview_url).toContain('product-imports/existing.png');
|
||||||
|
expect(query).toHaveBeenCalledTimes(5);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,354 @@
|
|||||||
|
const productId = '733d0d73-3d39-4a98-ac0a-5c0e521d8998';
|
||||||
|
import { createHash } from 'node:crypto';
|
||||||
|
import { GetObjectCommand, HeadObjectCommand } from '@aws-sdk/client-s3';
|
||||||
|
import {
|
||||||
|
createMediaAccessUrlRequestSchema,
|
||||||
|
createMediaUploadIntentRequestSchema,
|
||||||
|
importProductImageRequestSchema,
|
||||||
|
} from '@atlas/contracts';
|
||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
import { MediaService } from '../src/media/media.service.js';
|
||||||
|
import {
|
||||||
|
downloadProductImage,
|
||||||
|
publicIpv4,
|
||||||
|
} from '../src/media/product-image-download.js';
|
||||||
|
|
||||||
|
function serviceWithObject(bytes: Buffer) {
|
||||||
|
const service = new MediaService(
|
||||||
|
{} as ConstructorParameters<typeof MediaService>[0],
|
||||||
|
{} as ConstructorParameters<typeof MediaService>[1],
|
||||||
|
{
|
||||||
|
OBJECT_STORAGE_ENDPOINT: 'http://127.0.0.1:9000',
|
||||||
|
OBJECT_STORAGE_REGION: 'us-east-1',
|
||||||
|
OBJECT_STORAGE_BUCKET: 'test',
|
||||||
|
objectStorageForcePathStyle: true,
|
||||||
|
objectStorageAccessKey: 'test',
|
||||||
|
objectStorageSecretKey: 'test',
|
||||||
|
} as ConstructorParameters<typeof MediaService>[2],
|
||||||
|
);
|
||||||
|
const send = vi.fn(async (command: unknown) => {
|
||||||
|
if (command instanceof HeadObjectCommand) {
|
||||||
|
return { ContentLength: bytes.length, ContentType: 'image/png' };
|
||||||
|
}
|
||||||
|
if (command instanceof GetObjectCommand) {
|
||||||
|
return {
|
||||||
|
Body: (async function* () {
|
||||||
|
yield bytes;
|
||||||
|
})(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
throw new Error('未知 S3 命令');
|
||||||
|
});
|
||||||
|
Object.assign(service, { s3: { send } });
|
||||||
|
return service as unknown as {
|
||||||
|
inspectObject: (intent: Record<string, unknown>) => Promise<{
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
checksum: string;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function png(width: number, height: number) {
|
||||||
|
const bytes = Buffer.alloc(24);
|
||||||
|
Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]).copy(bytes);
|
||||||
|
bytes.writeUInt32BE(width, 16);
|
||||||
|
bytes.writeUInt32BE(height, 20);
|
||||||
|
return bytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('商品图片上传策略', () => {
|
||||||
|
it('下载地址签名 attachment 响应头,预览地址保持内联显示', async () => {
|
||||||
|
const assetId = '7c910684-7070-4f36-8a7b-b10bd91acd79';
|
||||||
|
const query = vi.fn(async () => ({
|
||||||
|
rows: [{ bucket: 'test', object_key: 'uploads/test.png', content_type: 'image/png' }],
|
||||||
|
}));
|
||||||
|
const workspaceTransaction = vi.fn(async (
|
||||||
|
_workspaceId: string,
|
||||||
|
callback: (tx: { query: typeof query }) => Promise<unknown>,
|
||||||
|
) => callback({ query }));
|
||||||
|
const service = new MediaService(
|
||||||
|
{} as ConstructorParameters<typeof MediaService>[0],
|
||||||
|
{ workspaceTransaction } as unknown as ConstructorParameters<typeof MediaService>[1],
|
||||||
|
{
|
||||||
|
OBJECT_STORAGE_ENDPOINT: 'http://127.0.0.1:9000',
|
||||||
|
OBJECT_STORAGE_REGION: 'cn-east-1',
|
||||||
|
OBJECT_STORAGE_BUCKET: 'test',
|
||||||
|
objectStorageForcePathStyle: true,
|
||||||
|
objectStorageAccessKey: 'test',
|
||||||
|
objectStorageSecretKey: 'test',
|
||||||
|
} as ConstructorParameters<typeof MediaService>[2],
|
||||||
|
);
|
||||||
|
expect(createMediaAccessUrlRequestSchema.safeParse({ purpose: 'download' }).success).toBe(true);
|
||||||
|
const preview = await service.accessUrl('workspace-id', assetId);
|
||||||
|
const download = await service.accessUrl('workspace-id', assetId, 'download');
|
||||||
|
expect(new URL(preview.url).searchParams.has('response-content-disposition')).toBe(false);
|
||||||
|
expect(new URL(preview.url).searchParams.get('X-Amz-Credential')).toContain('/cn-east-1/');
|
||||||
|
expect(new URL(download.url).searchParams.get('response-content-disposition'))
|
||||||
|
.toBe(`attachment; filename="atlas-image-${assetId}.png"`);
|
||||||
|
expect(workspaceTransaction).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('开发环境为旧 MinIO 素材签发本地预览地址', async () => {
|
||||||
|
const query = vi.fn(async () => ({
|
||||||
|
rows: [{
|
||||||
|
storage_provider: 'local_minio',
|
||||||
|
bucket: 'atlas-assets',
|
||||||
|
object_key: 'collections/test/image',
|
||||||
|
content_type: 'image/jpeg',
|
||||||
|
}],
|
||||||
|
}));
|
||||||
|
const database = {
|
||||||
|
workspaceTransaction: async (_workspaceId: string, work: (tx: { query: typeof query }) => Promise<unknown>) => work({ query }),
|
||||||
|
};
|
||||||
|
const service = new MediaService(
|
||||||
|
{} as ConstructorParameters<typeof MediaService>[0],
|
||||||
|
database as unknown as ConstructorParameters<typeof MediaService>[1],
|
||||||
|
{
|
||||||
|
NODE_ENV: 'development',
|
||||||
|
OBJECT_STORAGE_PROVIDER: 'qiniu_kodo',
|
||||||
|
OBJECT_STORAGE_ENDPOINT: 'https://s3.cn-east-1.qiniucs.com',
|
||||||
|
OBJECT_STORAGE_REGION: 'cn-east-1',
|
||||||
|
OBJECT_STORAGE_BUCKET: 'atlas-dev-assets',
|
||||||
|
objectStorageForcePathStyle: true,
|
||||||
|
objectStorageAccessKey: 'test',
|
||||||
|
objectStorageSecretKey: 'test',
|
||||||
|
LEGACY_MINIO_ENDPOINT: 'http://localhost:9000',
|
||||||
|
MINIO_ROOT_USER: 'local-test',
|
||||||
|
MINIO_ROOT_PASSWORD: 'local-secret',
|
||||||
|
} as ConstructorParameters<typeof MediaService>[2],
|
||||||
|
);
|
||||||
|
const preview = await service.accessUrl('workspace-id', 'asset-id');
|
||||||
|
expect(new URL(preview.url).host).toBe('localhost:9000');
|
||||||
|
expect(new URL(preview.url).pathname).toBe('/atlas-assets/collections/test/image');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('七牛素材使用绑定域名签发预览和附件下载链接', async () => {
|
||||||
|
const assetId = '7c910684-7070-4f36-8a7b-b10bd91acd79';
|
||||||
|
const query = vi.fn(async () => ({
|
||||||
|
rows: [{
|
||||||
|
storage_provider: 'qiniu_kodo',
|
||||||
|
bucket: 'atlas-dev-assets',
|
||||||
|
object_key: 'collections/workspace/image',
|
||||||
|
content_type: 'image/jpeg',
|
||||||
|
}],
|
||||||
|
}));
|
||||||
|
const database = {
|
||||||
|
workspaceTransaction: async (_workspaceId: string, work: (tx: { query: typeof query }) => Promise<unknown>) => work({ query }),
|
||||||
|
};
|
||||||
|
const service = new MediaService(
|
||||||
|
{} as ConstructorParameters<typeof MediaService>[0],
|
||||||
|
database as unknown as ConstructorParameters<typeof MediaService>[1],
|
||||||
|
{
|
||||||
|
OBJECT_STORAGE_ENDPOINT: 'https://s3.cn-east-1.qiniucs.com',
|
||||||
|
OBJECT_STORAGE_REGION: 'cn-east-1',
|
||||||
|
OBJECT_STORAGE_BUCKET: 'atlas-dev-assets',
|
||||||
|
objectStorageForcePathStyle: true,
|
||||||
|
objectStorageAccessKey: 's3-key',
|
||||||
|
objectStorageSecretKey: 's3-secret',
|
||||||
|
qiniuDownloadOrigin: 'https://img-dev.atlas.joeyone.cn',
|
||||||
|
qiniuDownloadAccessKey: 'download-key',
|
||||||
|
qiniuDownloadSecretKey: 'download-secret',
|
||||||
|
} as ConstructorParameters<typeof MediaService>[2],
|
||||||
|
);
|
||||||
|
const preview = await service.accessUrl('workspace-id', assetId);
|
||||||
|
const download = await service.accessUrl('workspace-id', assetId, 'download');
|
||||||
|
const previewUrl = new URL(preview.url);
|
||||||
|
const downloadUrl = new URL(download.url);
|
||||||
|
expect(previewUrl.origin).toBe('https://img-dev.atlas.joeyone.cn');
|
||||||
|
expect(previewUrl.pathname).toBe('/collections/workspace/image');
|
||||||
|
expect(previewUrl.searchParams.has('X-Amz-Signature')).toBe(false);
|
||||||
|
expect(previewUrl.searchParams.has('attname')).toBe(false);
|
||||||
|
expect(previewUrl.searchParams.get('token')).toMatch(/^download-key:/);
|
||||||
|
expect(preview.url.split('&').at(-1)).toMatch(/^token=/);
|
||||||
|
expect(downloadUrl.searchParams.get('attname')).toBe(`atlas-image-${assetId}.jpg`);
|
||||||
|
expect(download.url.split('&').at(-1)).toMatch(/^token=/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('创建商品图片上传意图时持久化用途并签发独立对象路径', async () => {
|
||||||
|
const query = vi.fn(async (_sql: string, _params?: unknown[]) => ({
|
||||||
|
rows: [{ id: productId }],
|
||||||
|
}));
|
||||||
|
const run = vi.fn(
|
||||||
|
async (
|
||||||
|
_request: unknown,
|
||||||
|
callback: (tx: { query: typeof query }) => Promise<unknown>,
|
||||||
|
) => callback({ query }),
|
||||||
|
);
|
||||||
|
const service = new MediaService(
|
||||||
|
{ run } as unknown as ConstructorParameters<typeof MediaService>[0],
|
||||||
|
{} as ConstructorParameters<typeof MediaService>[1],
|
||||||
|
{
|
||||||
|
OBJECT_STORAGE_ENDPOINT: 'http://127.0.0.1:9000',
|
||||||
|
OBJECT_STORAGE_REGION: 'us-east-1',
|
||||||
|
OBJECT_STORAGE_BUCKET: 'test',
|
||||||
|
objectStorageForcePathStyle: true,
|
||||||
|
objectStorageAccessKey: 'test',
|
||||||
|
objectStorageSecretKey: 'test',
|
||||||
|
} as ConstructorParameters<typeof MediaService>[2],
|
||||||
|
);
|
||||||
|
Object.assign(service, { ensureBucket: async () => undefined });
|
||||||
|
const result = await service.createIntent(
|
||||||
|
'workspace-id',
|
||||||
|
'user-id',
|
||||||
|
'key',
|
||||||
|
{
|
||||||
|
purpose: 'product_image',
|
||||||
|
product_id: productId,
|
||||||
|
filename: 'sku.png',
|
||||||
|
content_type: 'image/png',
|
||||||
|
byte_size: 1024,
|
||||||
|
checksum_sha256: 'a'.repeat(64),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const args = query.mock.calls.find(([sql]) => sql.includes('INSERT INTO atlas.media_upload_intents'))?.[1] as unknown[];
|
||||||
|
expect(args[10]).toBe(productId);
|
||||||
|
expect(args[1]).toBe('product_image');
|
||||||
|
expect(args[3]).toContain('/product-images/');
|
||||||
|
const body = result.body as { data: { upload: { url: string } } };
|
||||||
|
expect(body.data.upload.url).toContain('product-images');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('商品图允许 20 MiB,水印图仍限制 5 MiB', () => {
|
||||||
|
const request = {
|
||||||
|
filename: 'sku.png',
|
||||||
|
content_type: 'image/png',
|
||||||
|
byte_size: 6 * 1024 * 1024,
|
||||||
|
checksum_sha256: 'a'.repeat(64),
|
||||||
|
};
|
||||||
|
expect(
|
||||||
|
createMediaUploadIntentRequestSchema.safeParse({
|
||||||
|
...request,
|
||||||
|
purpose: 'product_image',
|
||||||
|
product_id: productId,
|
||||||
|
}).success,
|
||||||
|
).toBe(true);
|
||||||
|
expect(
|
||||||
|
createMediaUploadIntentRequestSchema.safeParse({
|
||||||
|
...request,
|
||||||
|
purpose: 'watermark_image',
|
||||||
|
}).success,
|
||||||
|
).toBe(false);
|
||||||
|
expect(
|
||||||
|
createMediaUploadIntentRequestSchema.safeParse({
|
||||||
|
...request,
|
||||||
|
purpose: 'product_image',
|
||||||
|
product_id: productId,
|
||||||
|
byte_size: 21 * 1024 * 1024,
|
||||||
|
}).success,
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('商品上传和网络导入必须绑定商品,水印仍无需商品', () => {
|
||||||
|
const request = { filename: 'image.png', content_type: 'image/png', byte_size: 1024, checksum_sha256: 'a'.repeat(64) };
|
||||||
|
expect(createMediaUploadIntentRequestSchema.safeParse({ ...request, purpose: 'product_image' }).success).toBe(false);
|
||||||
|
expect(createMediaUploadIntentRequestSchema.safeParse({ ...request, purpose: 'watermark_image' }).success).toBe(true);
|
||||||
|
expect(createMediaUploadIntentRequestSchema.safeParse({ ...request, purpose: 'watermark_image', product_id: productId }).success).toBe(false);
|
||||||
|
expect(importProductImageRequestSchema.safeParse({ url: 'https://example.test/image.png' }).success).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('商品图校验接受 9000px,水印图仍拒绝超过 8192px', async () => {
|
||||||
|
const bytes = png(9000, 9000);
|
||||||
|
const service = serviceWithObject(bytes);
|
||||||
|
const intent = {
|
||||||
|
bucket: 'test',
|
||||||
|
object_key: 'product.png',
|
||||||
|
expected_content_type: 'image/png',
|
||||||
|
purpose: 'product_image',
|
||||||
|
};
|
||||||
|
await expect(service.inspectObject(intent)).resolves.toMatchObject({
|
||||||
|
width: 9000,
|
||||||
|
height: 9000,
|
||||||
|
checksum: createHash('sha256').update(bytes).digest('hex'),
|
||||||
|
});
|
||||||
|
await expect(
|
||||||
|
service.inspectObject({ ...intent, purpose: 'watermark_image' }),
|
||||||
|
).rejects.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('上传图片列表仅查询当前工作空间已完成的商品素材', async () => {
|
||||||
|
const query = vi.fn(async (sql: string) => {
|
||||||
|
expect(sql).toContain("i.purpose = 'product_image'");
|
||||||
|
expect(sql).toContain("i.status = 'ready'");
|
||||||
|
expect(sql).toContain('i.product_id = $1');
|
||||||
|
return {
|
||||||
|
rows: [
|
||||||
|
{
|
||||||
|
id: '7c910684-7070-4f36-8a7b-b10bd91acd79',
|
||||||
|
content_type: 'image/png',
|
||||||
|
byte_size: '24',
|
||||||
|
width_px: 800,
|
||||||
|
height_px: 800,
|
||||||
|
created_at: new Date('2026-09-24T00:00:00Z'),
|
||||||
|
bucket: 'test',
|
||||||
|
object_key: 'uploads/test.png',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
});
|
||||||
|
const workspaceTransaction = vi.fn(
|
||||||
|
async (
|
||||||
|
workspaceId: string,
|
||||||
|
callback: (tx: { query: typeof query }) => Promise<unknown>,
|
||||||
|
) => {
|
||||||
|
expect(workspaceId).toBe('workspace-id');
|
||||||
|
return callback({ query });
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const service = new MediaService(
|
||||||
|
{} as ConstructorParameters<typeof MediaService>[0],
|
||||||
|
{ workspaceTransaction } as unknown as ConstructorParameters<
|
||||||
|
typeof MediaService
|
||||||
|
>[1],
|
||||||
|
{
|
||||||
|
OBJECT_STORAGE_ENDPOINT: 'http://127.0.0.1:9000',
|
||||||
|
OBJECT_STORAGE_REGION: 'us-east-1',
|
||||||
|
OBJECT_STORAGE_BUCKET: 'test',
|
||||||
|
objectStorageForcePathStyle: true,
|
||||||
|
objectStorageAccessKey: 'test',
|
||||||
|
objectStorageSecretKey: 'test',
|
||||||
|
} as ConstructorParameters<typeof MediaService>[2],
|
||||||
|
);
|
||||||
|
const uploads = await service.productUploads('workspace-id', productId);
|
||||||
|
expect(uploads).toHaveLength(1);
|
||||||
|
expect(uploads[0]).toMatchObject({
|
||||||
|
id: '7c910684-7070-4f36-8a7b-b10bd91acd79',
|
||||||
|
byte_size: 24,
|
||||||
|
});
|
||||||
|
expect(uploads[0].preview_url).toContain('uploads/test.png');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('网络导入只允许 HTTPS 图片链接,拒绝内网地址', async () => {
|
||||||
|
expect(
|
||||||
|
importProductImageRequestSchema.safeParse({
|
||||||
|
url: 'https://example.com/image.jpg',
|
||||||
|
product_id: productId,
|
||||||
|
}).success,
|
||||||
|
).toBe(true);
|
||||||
|
expect(
|
||||||
|
importProductImageRequestSchema.safeParse({
|
||||||
|
url: 'http://example.com/image.jpg',
|
||||||
|
}).success,
|
||||||
|
).toBe(false);
|
||||||
|
expect(
|
||||||
|
importProductImageRequestSchema.safeParse({
|
||||||
|
url: 'https://example.com/image.svg',
|
||||||
|
}).success,
|
||||||
|
).toBe(false);
|
||||||
|
expect(publicIpv4('8.8.8.8')).toBe(true);
|
||||||
|
for (const address of [
|
||||||
|
'127.0.0.1',
|
||||||
|
'10.1.2.3',
|
||||||
|
'172.16.0.1',
|
||||||
|
'192.168.1.1',
|
||||||
|
'169.254.1.1',
|
||||||
|
'100.64.0.1',
|
||||||
|
'224.0.0.1',
|
||||||
|
]) {
|
||||||
|
expect(publicIpv4(address)).toBe(false);
|
||||||
|
}
|
||||||
|
await expect(
|
||||||
|
downloadProductImage('https://127.0.0.1/image.jpg'),
|
||||||
|
).rejects.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
import type { AddressInfo } from 'node:net';
|
||||||
|
import type { Database } from '@atlas/database';
|
||||||
|
import type { NestExpressApplication } from '@nestjs/platform-express';
|
||||||
|
import { afterAll, beforeAll, describe, it } from 'vitest';
|
||||||
|
import { DATABASE } from '../src/database/database.module.js';
|
||||||
|
import { createApplication } from '../src/main.js';
|
||||||
|
|
||||||
|
let app: NestExpressApplication;
|
||||||
|
let database: Database;
|
||||||
|
let baseUrl = '';
|
||||||
|
let workspaceId = '';
|
||||||
|
let userId = '';
|
||||||
|
let token = '';
|
||||||
|
|
||||||
|
describe('水印派生 API 集成测试', () => {
|
||||||
|
beforeAll(async () => {
|
||||||
|
Object.assign(process.env, {
|
||||||
|
NODE_ENV: 'test', SWAGGER_ENABLED: 'false',
|
||||||
|
AUTH_REGISTER_IP_LIMIT: '100', AUTH_REGISTER_EMAIL_LIMIT: '100',
|
||||||
|
});
|
||||||
|
const created = await createApplication();
|
||||||
|
app = created.app;
|
||||||
|
database = app.get<Database>(DATABASE);
|
||||||
|
await app.listen(0, '127.0.0.1');
|
||||||
|
baseUrl = `http://127.0.0.1:${(app.getHttpServer().address() as AddressInfo).port}`;
|
||||||
|
const response = await fetch(`${baseUrl}/api/auth/register`, {
|
||||||
|
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
email: `watermark-derivative-${randomUUID()}@example.test`,
|
||||||
|
password: 'correct-horse-battery-staple-123',
|
||||||
|
password_confirmation: 'correct-horse-battery-staple-123',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
assert.equal(response.status, 201);
|
||||||
|
const body = await response.json() as { data: {
|
||||||
|
user: { id: string }; workspace: { id: string }; access_token: string;
|
||||||
|
} };
|
||||||
|
userId = body.data.user.id;
|
||||||
|
workspaceId = body.data.workspace.id;
|
||||||
|
token = body.data.access_token;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
if (workspaceId) {
|
||||||
|
await database.workspaceTransaction(workspaceId, async (tx) => {
|
||||||
|
await tx.query('DELETE FROM atlas.idempotency_requests WHERE workspace_id = $1', [workspaceId]);
|
||||||
|
await tx.query('DELETE FROM atlas.outbox_events WHERE workspace_id = $1', [workspaceId]);
|
||||||
|
await tx.query('DELETE FROM atlas.operations WHERE workspace_id = $1', [workspaceId]);
|
||||||
|
await tx.query('DELETE FROM atlas.media_aspect_ratio_derivatives WHERE workspace_id = $1', [workspaceId]);
|
||||||
|
await tx.query('DELETE FROM atlas.media_watermark_derivatives WHERE workspace_id = $1', [workspaceId]);
|
||||||
|
await tx.query('DELETE FROM atlas.watermark_template_versions WHERE workspace_id = $1', [workspaceId]);
|
||||||
|
await tx.query('DELETE FROM atlas.watermark_templates WHERE workspace_id = $1', [workspaceId]);
|
||||||
|
await tx.query('DELETE FROM atlas.media_assets WHERE workspace_id = $1', [workspaceId]);
|
||||||
|
await tx.query('DELETE FROM atlas.products WHERE workspace_id = $1', [workspaceId]);
|
||||||
|
await tx.query('DELETE FROM atlas.collection_items WHERE workspace_id = $1', [workspaceId]);
|
||||||
|
});
|
||||||
|
await database.query('DELETE FROM atlas.workspace_memberships WHERE user_id = $1', [userId]);
|
||||||
|
await database.query('DELETE FROM atlas.workspaces WHERE id = $1', [workspaceId]);
|
||||||
|
await database.query('DELETE FROM atlas.users WHERE id = $1', [userId]);
|
||||||
|
}
|
||||||
|
await app?.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('HTTP 创建任务锁定模板版本,结果查询保持工作空间边界', async () => {
|
||||||
|
const { assetId, templateId, productId } = await database.workspaceTransaction(workspaceId, async (tx) => {
|
||||||
|
const item = await tx.query<{ id: string }>(
|
||||||
|
`INSERT INTO atlas.collection_items (workspace_id, source_kind, source_item_key)
|
||||||
|
VALUES ($1, 'manual', $2) RETURNING id`,
|
||||||
|
[workspaceId, `watermark-history-${randomUUID()}`],
|
||||||
|
);
|
||||||
|
const product = await tx.query<{ id: string }>(
|
||||||
|
`INSERT INTO atlas.products (workspace_id, collection_item_id)
|
||||||
|
VALUES ($1, $2) RETURNING id`,
|
||||||
|
[workspaceId, item.rows[0].id],
|
||||||
|
);
|
||||||
|
const asset = await tx.query<{ id: string }>(
|
||||||
|
`INSERT INTO atlas.media_assets
|
||||||
|
(workspace_id, storage_provider, bucket, object_key, content_type,
|
||||||
|
byte_size, checksum_sha256, width_px, height_px)
|
||||||
|
VALUES ($1, 'local_minio', 'atlas-media', $2, 'image/jpeg', 100,
|
||||||
|
$3, 120, 100) RETURNING id`,
|
||||||
|
[workspaceId, `test/watermark-${randomUUID()}.jpg`, randomUUID().replaceAll('-', '').padEnd(64, '0')],
|
||||||
|
);
|
||||||
|
const template = await tx.query<{ id: string }>(
|
||||||
|
`INSERT INTO atlas.watermark_templates (workspace_id, name, kind)
|
||||||
|
VALUES ($1, $2, 'text') RETURNING id`,
|
||||||
|
[workspaceId, `集成测试水印 ${randomUUID()}`],
|
||||||
|
);
|
||||||
|
await tx.query(
|
||||||
|
`INSERT INTO atlas.watermark_template_versions
|
||||||
|
(workspace_id, template_id, version, kind, text_content, font_code,
|
||||||
|
font_size_px, opacity, position, position_x, position_y, created_by_user_id)
|
||||||
|
VALUES ($1, $2, 1, 'text', 'Atlas', 'NotoSans', 20, 0.5,
|
||||||
|
'bottom_right', 1, 1, $3)`,
|
||||||
|
[workspaceId, template.rows[0].id, userId],
|
||||||
|
);
|
||||||
|
return { assetId: asset.rows[0].id, templateId: template.rows[0].id, productId: product.rows[0].id };
|
||||||
|
});
|
||||||
|
const key = randomUUID();
|
||||||
|
const create = () => fetch(`${baseUrl}/api/media-assets/${assetId}/watermark-derivatives`, {
|
||||||
|
method: 'POST', headers: {
|
||||||
|
Authorization: `Bearer ${token}`, 'Content-Type': 'application/json', 'Idempotency-Key': key,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ watermark_template_id: templateId, product_id: productId }),
|
||||||
|
});
|
||||||
|
const response = await create();
|
||||||
|
assert.equal(response.status, 202);
|
||||||
|
const body = await response.json() as { data: {
|
||||||
|
id: string; watermark_template_id: string; watermark_template_version: number;
|
||||||
|
} };
|
||||||
|
assert.equal(body.data.watermark_template_id, templateId);
|
||||||
|
assert.equal(body.data.watermark_template_version, 1);
|
||||||
|
const replay = await create();
|
||||||
|
assert.equal(replay.status, 202);
|
||||||
|
assert.equal((await replay.json() as { data: { id: string } }).data.id, body.data.id);
|
||||||
|
const read = await fetch(`${baseUrl}/api/media-watermark-derivatives/${body.data.id}`, {
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
});
|
||||||
|
assert.equal(read.status, 200);
|
||||||
|
assert.equal((await read.json() as { data: { id: string } }).data.id, body.data.id);
|
||||||
|
const stored = await database.workspaceTransaction(workspaceId, async (tx) =>
|
||||||
|
tx.query<{ product_id: string }>(
|
||||||
|
'SELECT product_id FROM atlas.media_watermark_derivatives WHERE id = $1',
|
||||||
|
[body.data.id],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
assert.equal(stored.rows[0]?.product_id, productId);
|
||||||
|
|
||||||
|
const outputs = await database.workspaceTransaction(workspaceId, async (tx) => {
|
||||||
|
const ids: string[] = [];
|
||||||
|
for (const kind of ['watermark', 'ratio']) {
|
||||||
|
const asset = await tx.query<{ id: string }>(
|
||||||
|
`INSERT INTO atlas.media_assets
|
||||||
|
(workspace_id, storage_provider, bucket, object_key, content_type,
|
||||||
|
byte_size, checksum_sha256, width_px, height_px)
|
||||||
|
VALUES ($1, 'local_minio', 'atlas-media', $2, 'image/jpeg', 100,
|
||||||
|
$3, 1200, 1600) RETURNING id`,
|
||||||
|
[workspaceId, `test/${kind}-${randomUUID()}.jpg`, randomUUID().replaceAll('-', '').padEnd(64, '0')],
|
||||||
|
);
|
||||||
|
ids.push(asset.rows[0].id);
|
||||||
|
}
|
||||||
|
await tx.query(
|
||||||
|
`INSERT INTO atlas.media_watermark_derivatives
|
||||||
|
(workspace_id, product_id, source_asset_id, watermark_template_id,
|
||||||
|
watermark_template_version, output_asset_id, status, completed_at)
|
||||||
|
VALUES ($1, $2, $3, $4, 1, $5, 'succeeded', now())`,
|
||||||
|
[workspaceId, productId, assetId, templateId, ids[0]],
|
||||||
|
);
|
||||||
|
await tx.query(
|
||||||
|
`INSERT INTO atlas.media_aspect_ratio_derivatives
|
||||||
|
(workspace_id, product_id, source_asset_id, output_asset_id, status, completed_at)
|
||||||
|
VALUES ($1, $2, $3, $4, 'succeeded', now())`,
|
||||||
|
[workspaceId, productId, assetId, ids[1]],
|
||||||
|
);
|
||||||
|
return ids;
|
||||||
|
});
|
||||||
|
const history = await fetch(`${baseUrl}/api/products/${productId}/image-derivatives`, {
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
});
|
||||||
|
assert.equal(history.status, 200);
|
||||||
|
const records = (await history.json() as { data: Array<{
|
||||||
|
kind: string; output_asset: { id: string; preview_url: string };
|
||||||
|
}> }).data;
|
||||||
|
assert.deepEqual(new Set(records.map((record) => record.kind)), new Set(['watermark', 'aspect_ratio_3_4']));
|
||||||
|
assert.deepEqual(new Set(records.map((record) => record.output_asset.id)), new Set(outputs));
|
||||||
|
assert.ok(records.every((record) => record.output_asset.preview_url));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
import { WatermarkDerivativeService } from '../src/media/watermark-derivative.service.js';
|
||||||
|
|
||||||
|
const workspaceId = '30cf54b7-06a3-4e5f-a5fc-d74165115094';
|
||||||
|
const sourceAssetId = '664d0d73-3d39-4a98-ac0a-5c0e521d8998';
|
||||||
|
const templateId = '0878262e-6c38-4f3b-953a-2084ef297a33';
|
||||||
|
const derivativeId = '68db226f-3fcb-4579-ab9e-4932b1d84067';
|
||||||
|
const productId = '733d0d73-3d39-4a98-ac0a-5c0e521d8998';
|
||||||
|
|
||||||
|
function serviceWithQuery(query: ReturnType<typeof vi.fn>) {
|
||||||
|
const run = vi.fn(async (_input: unknown, work: (tx: { query: typeof query }) => Promise<unknown>) =>
|
||||||
|
work({ query }));
|
||||||
|
const service = new WatermarkDerivativeService(
|
||||||
|
{} as ConstructorParameters<typeof WatermarkDerivativeService>[0],
|
||||||
|
{ run } as unknown as ConstructorParameters<typeof WatermarkDerivativeService>[1],
|
||||||
|
{} as ConstructorParameters<typeof WatermarkDerivativeService>[2],
|
||||||
|
);
|
||||||
|
return { service, run };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('水印派生任务', () => {
|
||||||
|
it('固定当前模板版本,并在同一个事务创建任务、操作和 Outbox', async () => {
|
||||||
|
const statements: string[] = [];
|
||||||
|
const query = vi.fn(async (sql: string) => {
|
||||||
|
statements.push(sql);
|
||||||
|
if (sql.includes('SELECT id FROM atlas.media_assets')) return { rows: [{ id: sourceAssetId }] };
|
||||||
|
if (sql.includes('FROM atlas.watermark_templates')) return { rows: [{ id: templateId, current_version: 4 }] };
|
||||||
|
if (sql.includes('RETURNING id')) return { rows: [{ id: derivativeId }] };
|
||||||
|
return { rows: [] };
|
||||||
|
});
|
||||||
|
const { service, run } = serviceWithQuery(query);
|
||||||
|
const result = await service.create(workspaceId, 'user-id', sourceAssetId, 'idempotency-key',
|
||||||
|
{ watermark_template_id: templateId });
|
||||||
|
expect(result).toMatchObject({ status: 202, body: { data: {
|
||||||
|
id: derivativeId, watermark_template_id: templateId, watermark_template_version: 4,
|
||||||
|
} } });
|
||||||
|
expect(run).toHaveBeenCalledOnce();
|
||||||
|
expect(statements.some((sql) => sql.includes('INSERT INTO atlas.media_watermark_derivatives'))).toBe(true);
|
||||||
|
expect(statements.some((sql) => sql.includes('INSERT INTO atlas.operations'))).toBe(true);
|
||||||
|
expect(statements.some((sql) => sql.includes('INSERT INTO atlas.outbox_events'))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('拒绝不可用模板且不创建派生任务', async () => {
|
||||||
|
const query = vi.fn(async (sql: string) => sql.includes('SELECT id FROM atlas.media_assets')
|
||||||
|
? { rows: [{ id: sourceAssetId }] } : { rows: [] });
|
||||||
|
const { service } = serviceWithQuery(query);
|
||||||
|
await expect(service.create(workspaceId, 'user-id', sourceAssetId, 'idempotency-key',
|
||||||
|
{ watermark_template_id: templateId })).rejects.toMatchObject({ status: 404 });
|
||||||
|
expect(query.mock.calls.every(([sql]) => !String(sql).includes('INSERT INTO'))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('将当前工作空间的商品关联到水印结果', async () => {
|
||||||
|
const query = vi.fn(async (sql: string, values?: unknown[]) => {
|
||||||
|
if (sql.includes('SELECT id FROM atlas.media_assets')) return { rows: [{ id: sourceAssetId }] };
|
||||||
|
if (sql.includes('SELECT id FROM atlas.products')) return { rows: [{ id: productId }] };
|
||||||
|
if (sql.includes('FROM atlas.watermark_templates')) return { rows: [{ id: templateId, current_version: 4 }] };
|
||||||
|
if (sql.includes('INSERT INTO atlas.media_watermark_derivatives')) {
|
||||||
|
expect(values).toEqual([productId, sourceAssetId, templateId, 4, 'user-id']);
|
||||||
|
return { rows: [{ id: derivativeId }] };
|
||||||
|
}
|
||||||
|
return { rows: [] };
|
||||||
|
});
|
||||||
|
const { service } = serviceWithQuery(query);
|
||||||
|
await service.create(workspaceId, 'user-id', sourceAssetId, 'key',
|
||||||
|
{ watermark_template_id: templateId, product_id: productId });
|
||||||
|
expect(query.mock.calls.some(([sql]) => String(sql).includes('INSERT INTO atlas.media_watermark_derivatives'))).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,258 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
import type { AddressInfo } from 'node:net';
|
||||||
|
import type { Database } from '@atlas/database';
|
||||||
|
import type { NestExpressApplication } from '@nestjs/platform-express';
|
||||||
|
import { afterAll, beforeAll, describe, it } from 'vitest';
|
||||||
|
import { DATABASE } from '../src/database/database.module.js';
|
||||||
|
import { createApplication } from '../src/main.js';
|
||||||
|
|
||||||
|
let app: NestExpressApplication;
|
||||||
|
let database: Database;
|
||||||
|
let baseUrl: string;
|
||||||
|
let token = '';
|
||||||
|
let workspaceId = '';
|
||||||
|
let userId = '';
|
||||||
|
let platformId = '';
|
||||||
|
let storeId = '';
|
||||||
|
let listingId = '';
|
||||||
|
let runId = '';
|
||||||
|
|
||||||
|
async function request(path: string, init: RequestInit = {}) {
|
||||||
|
const response = await fetch(`${baseUrl}${path}`, {
|
||||||
|
...init,
|
||||||
|
headers: { 'Content-Type': 'application/json', ...init.headers },
|
||||||
|
});
|
||||||
|
return { response, body: (await response.json()) as Record<string, any> };
|
||||||
|
}
|
||||||
|
|
||||||
|
function auth(extra: Record<string, string> = {}) {
|
||||||
|
return {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
'X-Atlas-Platform-Id': platformId,
|
||||||
|
...extra,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('在线商品 HTTP 契约', () => {
|
||||||
|
beforeAll(async () => {
|
||||||
|
Object.assign(process.env, {
|
||||||
|
NODE_ENV: 'test',
|
||||||
|
SWAGGER_ENABLED: 'false',
|
||||||
|
AUTH_REGISTER_IP_LIMIT: '100',
|
||||||
|
AUTH_REGISTER_EMAIL_LIMIT: '100',
|
||||||
|
});
|
||||||
|
const created = await createApplication();
|
||||||
|
app = created.app;
|
||||||
|
database = app.get<Database>(DATABASE);
|
||||||
|
await app.listen(0, '127.0.0.1');
|
||||||
|
baseUrl = `http://127.0.0.1:${(app.getHttpServer().address() as AddressInfo).port}`;
|
||||||
|
const registered = await request('/api/auth/register', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({
|
||||||
|
email: `online-${randomUUID()}@example.test`,
|
||||||
|
password: 'correct-horse-battery-staple-123',
|
||||||
|
password_confirmation: 'correct-horse-battery-staple-123',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
assert.equal(registered.response.status, 201);
|
||||||
|
token = registered.body.data.access_token;
|
||||||
|
workspaceId = registered.body.data.workspace.id;
|
||||||
|
userId = registered.body.data.user.id;
|
||||||
|
const platforms = await request('/api/platforms', {
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
});
|
||||||
|
platformId = platforms.body.data.find(
|
||||||
|
(platform: { code: string }) => platform.code === 'OZON',
|
||||||
|
).id;
|
||||||
|
storeId = randomUUID();
|
||||||
|
await database.workspaceTransaction(workspaceId, async (tx) => {
|
||||||
|
await tx.query(
|
||||||
|
`INSERT INTO atlas.stores (id, workspace_id, platform_id, name, platform_client_id, currency_code, is_default, status, credential_status)
|
||||||
|
VALUES ($1, $2, $3, '测试门店', $4, 'RUB', true, 'active', 'valid')`,
|
||||||
|
[storeId, workspaceId, platformId, `online-http-${storeId}`],
|
||||||
|
);
|
||||||
|
const run = await tx.query<{ id: string }>(
|
||||||
|
`INSERT INTO atlas.platform_product_sync_runs
|
||||||
|
(workspace_id, store_id, platform_id, trigger_source, status, completed_at)
|
||||||
|
VALUES ($1, $2, $3, 'manual', 'succeeded', now()) RETURNING id`,
|
||||||
|
[workspaceId, storeId, platformId],
|
||||||
|
);
|
||||||
|
runId = run.rows[0].id;
|
||||||
|
const listing = await tx.query<{ id: string }>(
|
||||||
|
`INSERT INTO atlas.platform_listings
|
||||||
|
(workspace_id, store_id, platform_id, remote_product_id, offer_id, remote_sku, name,
|
||||||
|
currency_code, selling_price, visible, moderation_status, stock_summary,
|
||||||
|
image_summary, source, last_sync_run_id, last_synced_at, raw_payload)
|
||||||
|
VALUES ($1, $2, $3, '123456', 'test-offer', '654321', '测试商品',
|
||||||
|
'RUB', 99, true, 'approved', '{"total":0,"has_data":true}',
|
||||||
|
'{"url":null,"width_px":null,"height_px":null}', 'external_sync', $4, now(), '{}') RETURNING id`,
|
||||||
|
[workspaceId, storeId, platformId, runId],
|
||||||
|
);
|
||||||
|
listingId = listing.rows[0].id;
|
||||||
|
await tx.query(
|
||||||
|
`INSERT INTO atlas.platform_listings
|
||||||
|
(workspace_id, store_id, platform_id, remote_product_id, offer_id, name,
|
||||||
|
visible, stock_summary, image_summary, source, last_sync_run_id, last_synced_at,
|
||||||
|
missing_since, archived_at, raw_payload)
|
||||||
|
VALUES ($1, $2, $3, '123457', 'archived-offer', '归档商品',
|
||||||
|
NULL, '{"total":0,"has_data":false}', '{}', 'external_sync', $4,
|
||||||
|
now(), now() - interval '8 days', now(), '{}')`,
|
||||||
|
[workspaceId, storeId, platformId, runId],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
if (workspaceId && database) {
|
||||||
|
await database.workspaceTransaction(workspaceId, async (tx) => {
|
||||||
|
await tx.query(
|
||||||
|
'DELETE FROM atlas.platform_listing_sync_snapshots WHERE workspace_id = $1',
|
||||||
|
[workspaceId],
|
||||||
|
);
|
||||||
|
await tx.query(
|
||||||
|
'DELETE FROM atlas.platform_listings WHERE workspace_id = $1',
|
||||||
|
[workspaceId],
|
||||||
|
);
|
||||||
|
await tx.query(
|
||||||
|
'DELETE FROM atlas.platform_product_sync_runs WHERE workspace_id = $1',
|
||||||
|
[workspaceId],
|
||||||
|
);
|
||||||
|
await tx.query(
|
||||||
|
'DELETE FROM atlas.idempotency_requests WHERE workspace_id = $1',
|
||||||
|
[workspaceId],
|
||||||
|
);
|
||||||
|
await tx.query(
|
||||||
|
'DELETE FROM atlas.outbox_events WHERE workspace_id = $1',
|
||||||
|
[workspaceId],
|
||||||
|
);
|
||||||
|
await tx.query(
|
||||||
|
'DELETE FROM atlas.store_secrets WHERE workspace_id = $1',
|
||||||
|
[workspaceId],
|
||||||
|
);
|
||||||
|
await tx.query(
|
||||||
|
'DELETE FROM atlas.stores WHERE workspace_id = $1',
|
||||||
|
[workspaceId],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
await database.query(
|
||||||
|
'DELETE FROM atlas.workspace_memberships WHERE user_id = $1',
|
||||||
|
[userId],
|
||||||
|
);
|
||||||
|
await database.query('DELETE FROM atlas.workspaces WHERE id = $1', [
|
||||||
|
workspaceId,
|
||||||
|
]);
|
||||||
|
await database.query('DELETE FROM atlas.users WHERE id = $1', [
|
||||||
|
userId,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
if (app) await app.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('列表、详情与同步历史按平台店铺返回镜像,不返回原始响应', async () => {
|
||||||
|
const listed = await request(
|
||||||
|
`/api/platform-listings?store_id=${storeId}&q=test-offer`,
|
||||||
|
{ headers: auth() },
|
||||||
|
);
|
||||||
|
assert.equal(listed.response.status, 200);
|
||||||
|
assert.equal(listed.body.data.length, 1);
|
||||||
|
assert.equal(listed.body.data[0].visible, true);
|
||||||
|
assert.equal(listed.body.data[0].selling_price, '99.00');
|
||||||
|
assert.deepEqual(listed.body.data[0].stock_summary, {
|
||||||
|
total: 0,
|
||||||
|
has_data: true,
|
||||||
|
});
|
||||||
|
assert.equal(listed.body.meta.store.id, storeId);
|
||||||
|
assert.equal(listed.body.meta.last_sync_status, 'succeeded');
|
||||||
|
assert.equal('raw_payload' in listed.body.data[0], false);
|
||||||
|
const detail = await request(`/api/platform-listings/${listingId}`, {
|
||||||
|
headers: auth(),
|
||||||
|
});
|
||||||
|
assert.equal(detail.response.status, 200);
|
||||||
|
assert.equal(detail.body.data.offer_id, 'test-offer');
|
||||||
|
const runs = await request(
|
||||||
|
`/api/stores/${storeId}/platform-product-sync-runs`,
|
||||||
|
{ headers: auth() },
|
||||||
|
);
|
||||||
|
assert.equal(runs.response.status, 200);
|
||||||
|
assert.equal(runs.body.data[0].id, runId);
|
||||||
|
const run = await request(`/api/platform-product-sync-runs/${runId}`, {
|
||||||
|
headers: auth(),
|
||||||
|
});
|
||||||
|
assert.equal(run.response.status, 200);
|
||||||
|
assert.equal(run.body.data.status, 'succeeded');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('筛选未知可见性与缺少平台头的请求按契约处理', async () => {
|
||||||
|
const unknown = await request(
|
||||||
|
`/api/platform-listings?store_id=${storeId}&visibility=unknown`,
|
||||||
|
{ headers: auth() },
|
||||||
|
);
|
||||||
|
assert.equal(unknown.response.status, 200);
|
||||||
|
assert.equal(unknown.body.data.length, 0);
|
||||||
|
const archived = await request(
|
||||||
|
`/api/platform-listings?store_id=${storeId}&visibility=unknown&include_archived=true`,
|
||||||
|
{ headers: auth() },
|
||||||
|
);
|
||||||
|
assert.equal(archived.response.status, 200);
|
||||||
|
assert.equal(archived.body.data.length, 1);
|
||||||
|
assert.equal(archived.body.data[0].offer_id, 'archived-offer');
|
||||||
|
const missingHeader = await request('/api/platform-listings', {
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
});
|
||||||
|
assert.equal(missingHeader.response.status, 400);
|
||||||
|
assert.equal(
|
||||||
|
missingHeader.body.error.code,
|
||||||
|
'PLATFORM_CONTEXT_REQUIRED',
|
||||||
|
);
|
||||||
|
const noCredentials = await request(
|
||||||
|
`/api/stores/${storeId}/platform-product-sync-runs`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
headers: auth({ 'Idempotency-Key': randomUUID() }),
|
||||||
|
body: JSON.stringify({ reason: 'manual_refresh' }),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
assert.equal(noCredentials.response.status, 422);
|
||||||
|
assert.equal(
|
||||||
|
noCredentials.body.error.code,
|
||||||
|
'STORE_CREDENTIAL_UNAVAILABLE',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('触发同步写入 Outbox,同一幂等键复用任务,并发请求返回冲突', async () => {
|
||||||
|
await database.workspaceTransaction(workspaceId, (tx) =>
|
||||||
|
tx.query(
|
||||||
|
`INSERT INTO atlas.store_secrets
|
||||||
|
(workspace_id, store_id, kind, secret_ciphertext, secret_nonce,
|
||||||
|
encryption_key_version, fingerprint, status)
|
||||||
|
VALUES ($1, $2, 'seller_api_key', $3, $4, 'v1', $5, 'valid')`,
|
||||||
|
[workspaceId, storeId, Buffer.alloc(32), Buffer.alloc(12), randomUUID()],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const key = randomUUID();
|
||||||
|
const post = (idempotencyKey: string) =>
|
||||||
|
request(`/api/stores/${storeId}/platform-product-sync-runs`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: auth({ 'Idempotency-Key': idempotencyKey }),
|
||||||
|
body: JSON.stringify({ reason: 'manual_refresh' }),
|
||||||
|
});
|
||||||
|
const created = await post(key);
|
||||||
|
assert.equal(created.response.status, 202);
|
||||||
|
assert.equal(created.body.data.sync_run.status, 'queued');
|
||||||
|
const repeated = await post(key);
|
||||||
|
assert.equal(repeated.response.status, 202);
|
||||||
|
assert.equal(repeated.body.data.sync_run.id, created.body.data.sync_run.id);
|
||||||
|
const concurrent = await post(randomUUID());
|
||||||
|
assert.equal(concurrent.response.status, 409);
|
||||||
|
assert.equal(concurrent.body.error.code, 'SYNC_ALREADY_RUNNING');
|
||||||
|
const events = await database.workspaceTransaction(workspaceId, (tx) =>
|
||||||
|
tx.query<{ count: string }>(
|
||||||
|
`SELECT count(*)::text AS count FROM atlas.outbox_events
|
||||||
|
WHERE aggregate_id = $1 AND event_type = 'platform_product_sync.requested'`,
|
||||||
|
[created.body.data.sync_run.id],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
assert.equal(events.rows[0].count, '1');
|
||||||
|
});
|
||||||
|
});
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user