70 Commits

Author SHA1 Message Date
R524809 33299cfd7e docs: 后续开发方向 2026-10-08 17:37:10 +08:00
joey5628 06dbd817f0 feat(edit): 优化图片导入ozon问题 2026-10-07 21:49:28 +08:00
joey5628 79b310efe7 feat(edit): 编辑上报细节优化 2026-10-06 18:30:05 +08:00
joey5628 e20cd7547f feat(edit): AI套图生成、图片切七牛 2026-10-04 20:47:47 +08:00
R524809 696112e74d docs: 域名、证书配置文档 2026-09-30 17:48:03 +08:00
R524809 45871584b3 feat(edit): 编辑页上报ozon 开发 2026-09-30 15:57:54 +08:00
R524809 0a303414d8 feat(edit): 定价试算开发、SKU 样式功能修改 2026-09-29 17:50:19 +08:00
R524809 84b230e69a feat(pricing): 开发定价方案 2026-09-28 17:34:33 +08:00
joey5628 a29dd92676 feat(ai-image): AI生图开发 2026-09-27 22:40:52 +08:00
R524809 e670d06ca6 feat: 完成 SKU 图片选择及素材导入后端 2026-09-24 17:46:34 +08:00
R524809 d24ce8f4e0 feat: 编辑页 SKU 图标默认值优化 2026-09-23 17:39:05 +08:00
R524809 b3958166dd feat(ai-edit): 开发 AI 填报功能 2026-09-23 17:18:09 +08:00
R524809 95a2a37f1b feat(edit|extension): 编辑页类目采集开发 2026-09-22 13:47:24 +08:00
R524809 2b06ef0692 feat(edit): 采集插件、编辑页SKU 属性动态展示、颜色展示开发 2026-09-22 10:11:57 +08:00
R524809 05747d6f22 feat(edit): 商品编辑页优化 2026-09-21 17:44:50 +08:00
R524809 75fa2b60f3 feat(edit): 编辑页样式修改 2026-09-20 17:44:40 +08:00
R524809 e0e07be4ea docs: 更新文档 2026-09-20 15:23:44 +08:00
R524809 880e441bc6 feat(category): 类目树、类目属性、属性字典维护系统构建完成 2026-09-20 15:11:32 +08:00
R524809 388429dbd0 feat(category): 类目树、属性、属性字典维护,新增系统管理员管理类目数据 2026-09-18 18:55:16 +08:00
R524809 fcddb11da0 feat(edit): 编辑页 SKU 展示样式修改 2026-09-18 14:39:40 +08:00
R524809 df3fb0c754 feat(edit): 编辑页样式修改 2026-09-18 13:38:00 +08:00
R524809 5684cbd517 feat(edit): 编辑页样式优化 2026-09-17 17:47:18 +08:00
R524809 52ef75f719 feat:编辑页响应式样式修改 2026-09-16 17:21:33 +08:00
R524809 a66b07652b Merge branch 'zcode/frontend' into feat-0.2.0 2026-09-15 18:56:22 +08:00
R524809 7f68aad9f3 feat(edit): 服务端代码拆分、优化 2026-09-15 18:56:13 +08:00
R524809 3156a7516f feat(edit): 编辑页面前端样式修改 2026-09-15 18:55:46 +08:00
R524809 da7342b466 feat(edit): 编辑页开发 2026-09-11 17:28:58 +08:00
R524809 1b03ea5871 feat(edit): 页面表单高度优化 2026-09-11 10:38:46 +08:00
R524809 2c4e94ab3d fix(edit): 类目问题修改 2026-09-11 10:36:25 +08:00
joey5628 05a00d46b9 feat(edit): 编辑页类目树逻辑开发 2026-09-10 22:26:52 +08:00
R524809 946af649dd feat(edit): 商品编辑页开发 2026-09-10 17:45:46 +08:00
R524809 130a005231 feat(product-edit):商品编辑页初版 2026-09-09 15:11:58 +08:00
R524809 319887726e Update seller store experience 2026-09-08 16:19:38 +08:00
R524809 ceb644a3f9 Expand seller store functionality 2026-09-07 17:51:11 +08:00
joey5628 4c0f3a4a79 feat(extension): 采集插件功能开发 2026-09-06 22:32:39 +08:00
R524809 87db74fb89 feat(collection): 采集箱开发 2026-09-04 17:32:28 +08:00
R524809 edf7939963 feat(watermark): 水印前后端代码修改 2026-09-04 16:48:32 +08:00
R524809 f635359216 feat(api-watermark): 水印接口优化 2026-09-04 15:58:53 +08:00
R524809 e277754588 feat(web-water): 水印设置页面开发 2026-09-04 15:57:16 +08:00
R524809 009082d0f4 feat(api-water): 支持上传水印图片 2026-09-04 14:27:59 +08:00
R524809 c2523fe349 feat(api-watermark): 添加水印管理相关接口 2026-09-04 12:45:27 +08:00
R524809 70d9d5b92a feat(store): 添加门店、门店验证 2026-09-04 10:27:29 +08:00
joey5628 66ca8b496c feat(web): 页面样式修改 2026-09-03 23:08:14 +08:00
joey5628 5bdf1645d2 fix(web): ant-breadcrumb-link 改为 inline-flex 垂直居中
仅 align-items 不生效(block 布局),补 display: inline-flex,
面包屑每一层(nav/ol/li/link/crumb)均为 flex 垂直居中。
2026-09-03 23:07:26 +08:00
joey5628 1b0cdc33aa fix(web): 面包屑导航盒子高度对齐 collapse-trigger 的 28px
.page-breadcrumb 设为 28px 高并在内部垂直居中,与折叠按钮
盒子边界完全一致。
2026-09-03 23:00:50 +08:00
joey5628 d199c4d6df fix(web): 面包屑条目与分隔图标垂直居中对齐
不同字号图标(15px 条目图标/12px 分隔箭头/14px 文字)在行内
基线对齐时视觉中心错位,改为 flex 垂直居中。
2026-09-03 22:56:25 +08:00
joey5628 aa3076c434 style(web): 面包屑 RightOutlined 分隔图标调整为 12px 2026-09-03 22:51:42 +08:00
joey5628 eecec5e0d0 feat(web): 面包屑分隔符改用 RightOutlined 图标 2026-09-03 22:48:44 +08:00
joey5628 c0473d607a revert(web): 按钮恢复默认字号高度内边距,全局圆角调整为 10px
移除 Button 组件 token 覆盖(字体 14px、高度 34/26px、内边距 15/7px
恢复 antd 默认);--ant-border-radius 12→10px,与设置卡片 10px 一致。
输入类控件 16px/40px 保持不变。
2026-09-03 22:45:02 +08:00
joey5628 27b9e07b62 style(web): 设置卡片圆角改为 10px
卡片是自定义 CSS,与全局 12px 控件圆角区分为 10px。
2026-09-03 22:42:45 +08:00
joey5628 6879e7a8b7 feat(web): 按钮内边距调整为 20px,全局圆角调整为 12px
设置卡片自定义 CSS 圆角同步 8→12px。
2026-09-03 22:41:18 +08:00
joey5628 9164fc962e feat(web): 按钮横向内边距加大
Button 组件 token paddingInline 15→22、paddingInlineSM 7→14,
配合 40px 高度与 16px 字号整体更协调。
2026-09-03 22:21:57 +08:00
joey5628 24a25dcbd1 feat(web): 输入控件与按钮放大、设置卡片收窄加阴影、设置页增加取消按钮
- 主题 components:Input/Select/InputNumber/DatePicker/Button 文本
  16px、高度 40px(小号按钮 32px);表格/菜单等正文保持 14px
- 设置卡片 max-width 720→560px,四周柔和阴影
- 修改密码页保存按钮右侧、修改资料页重置按钮右侧增加取消按钮,
  点击回首页
2026-09-03 22:13:11 +08:00
joey5628 5bf5f80b34 fix(web): 必填表单项恢复 Label 前红色星号
店铺创建/编辑弹窗与个人资料、修改密码表单误设了
requiredMark={false},必填项没有必填标识。移除该属性恢复
antd 默认必填星号(可选字段不受影响)。
2026-09-03 22:04:05 +08:00
joey5628 46d40b184b feat(web): 店铺必填项拦截纯空格输入,API Key 放宽到契约上限
- 店铺名称/Client ID/API Key 的 required 规则加 whitespace: true,
  与服务端 trim 后非空校验对齐
- API Key 输入 maxLength 200 → 8192(packages/contracts 契约上限),
  避免误拦较长的真实平台密钥
- 浏览器实测:空提交与纯空格提交均展示四条必填错误且不发请求,
  正常填写创建成功
2026-09-03 22:00:33 +08:00
joey5628 446baf5939 feat(web): 店铺管理页与页头平台选择器接入真实 API
- 页头平台选择器:PlatformProvider 加载平台目录与平台上下文
  (X-Atlas-Platform-Id header),平台 id 仅作为非敏感 UI 偏好存
  localStorage,默认取目录第一家(Ozon 优先)
- 店铺管理页覆盖 docs/api/stores-and-platforms.md 已实现接口:
  游标分页列表、创建(202+operation)、编辑(If-Match 乐观锁、
  409 刷新提示)、设为默认、重新验证凭据、仓库同步(422 脱敏
  错误展示)、软删除(DEFAULT_STORE_REPLACEMENT_REQUIRED 提示)
- services/api.ts 新增 getList(保留 {data, meta} 分页信封)与
  apiErrorCode;services/stores.ts 按契约生成 Idempotency-Key
- @atlas/contracts 补充只读响应类型 Platform / PlatformContext /
  CursorPaginationMeta / StoreOperationResponse(无行为变更)
- API Key 仅存在于提交瞬间的内存表单,不落任何存储
- 浏览器实测:注册→平台切换→创建/编辑/设默认/删除保护/同步
  错误路径→刷新会话恢复全部通过;typecheck + production build 通过
2026-09-03 21:40:02 +08:00
joey5628 8655b7805f fix(api): stores 控制器显式 @Inject 服务依赖
tsx/esbuild 不生成 design:paramtypes 装饰器元数据,裸类型构造参数在
dev 运行时注入 undefined,GET /api/platforms 返回 500。改为与
auth.controller.ts 一致的显式 @Inject(...)。

跨领域改动说明:前端联调平台上下文时发现该阻断性 bug,修复方式
沿用仓库既有模式,不影响服务层(原本即显式 token 注入)。
2026-09-03 21:39:53 +08:00
R524809 3ab999d0b2 Merge branch 'feat-0.2.0' into zcode/frontend 2026-09-03 17:12:51 +08:00
R524809 cc61967e3b feat(web-auth): 前端修改资料、修改密码优化 2026-09-03 17:12:36 +08:00
R524809 dc39e3e4e5 chore(package): 添加清楚端口占用的快捷命令 2026-09-03 17:11:44 +08:00
R524809 2d435143ab Merge branch 'feat-0.2.0' into zcode/frontend 2026-09-03 16:57:04 +08:00
R524809 06a3a4da75 feat(web): 开发账户修改、密码修改功能 2026-09-03 16:56:53 +08:00
R524809 a5b89ddb1d feat(api): 账户资料与密码安全 2026-09-03 16:56:20 +08:00
R524809 46d98ab11e feat(merge): 合入主分支 2026-09-03 16:10:15 +08:00
R524809 ffa86f8d55 feat(web): 登录、注册功能 2026-09-03 16:06:13 +08:00
R524809 ada02e9135 feat(api): 开发平台、门店相关代码 2026-09-03 16:04:44 +08:00
R524809 35fcf8776e feat: 合并主分支后端代码 2026-09-03 13:16:08 +08:00
R524809 df40ddde8c feat(web): 初始化前端项目 2026-09-03 13:11:38 +08:00
R524809 037928c778 feat(api): 开发auth、login等接口,规范格式化 2026-09-03 13:11:06 +08:00
R524809 195b6d25e9 docs: add mandatory AI collaboration contract 2026-09-03 09:19:29 +08:00
464 changed files with 166391 additions and 2615 deletions
+103 -7
View File
@@ -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=
+9
View File
@@ -0,0 +1,9 @@
{
"singleQuote": true,
"trailingComma": "all",
"bracketSpacing": true,
"arrowParens": "always",
"printWidth": 80,
"tabWidth": 4,
"useTabs": false
}
+18
View File
@@ -0,0 +1,18 @@
# AGENTS.md — 工作区工作约定
本工作区的 AI 会话必须遵守以下规则,优先级高于会话内的自行判断。
## 1. 可修改全部项目代码
- 可按任务需要修改 `apps/web/`、`apps/api/`、`apps/worker/`、`apps/extension/`、`packages/` 及相关文档。
- 修改应遵守 `docs/project/development-collaboration-contract.md` 中的领域边界、API 契约、数据安全与验证要求;跨领域改动须明确说明影响范围。
## 2. 服务端、Worker 与前端验证
- API(NestJS)和 Worker 可在开发、联调或验证需要时由 AI 启动、重启或停止;操作前应先确认目标进程与端口,避免误杀无关进程。
- API 或 Worker 已由维护者启动时,可在本任务需要时重启,但应在完成后说明实际操作和运行状态。
- 前端 Vite dev server 可为验证目的临时启动,用完后应停掉,并避免占用维护者已在使用的端口(如 5173/8200)。
## 3. 代码注释
- 新增或修改的代码注释必须使用中文;注释应说明职责、边界或不易从代码直接看出的业务约束,避免逐行复述实现。
+38
View File
@@ -0,0 +1,38 @@
SELECT
u.id,
u.email,
u.display_name,
u.status,
u.created_at,
u.last_login_at,
w.id AS workspace_id,
w.name AS workspace_name,
m.role AS workspace_role
FROM atlas.users u
LEFT JOIN atlas.workspace_memberships m ON m.user_id = u.id
LEFT JOIN atlas.workspaces w ON w.id = m.workspace_id
ORDER BY u.created_at DESC;
SELECT code, name, capabilities
FROM atlas.platforms
WHERE code = 'OZON';
BEGIN;
SET search_path TO atlas, public;
INSERT INTO platform_currency_support (platform_id, currency_code)
SELECT id, 'CNY'::CHAR(3)
FROM platforms
WHERE code = 'OZON'
ON CONFLICT (platform_id, currency_code) DO UPDATE
SET is_enabled = true;
UPDATE platforms
SET capabilities = capabilities || jsonb_build_object(
'default_currency_code', 'CNY'
),
updated_at = now()
WHERE code = 'OZON';
COMMIT;
+3 -1
View File
@@ -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)。
## 设计目标 ## 设计目标
+9
View File
@@ -0,0 +1,9 @@
{
"singleQuote": true,
"trailingComma": "all",
"bracketSpacing": true,
"arrowParens": "always",
"printWidth": 80,
"tabWidth": 4,
"useTabs": false
}
+38 -23
View File
@@ -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"
}
} }
+72
View File
@@ -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}`);
}
+58 -7
View File
@@ -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 {}
+323
View File
@@ -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]);
}
+113
View File
@@ -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;
}
+46
View File
@@ -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;
}
}
+21
View File
@@ -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 {}
+532
View File
@@ -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', '分页游标无效');
}
}
+64
View File
@@ -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,
},
});
}
}
+13
View File
@@ -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);
}
}
+133
View File
@@ -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(',')}}`;
}
+6
View File
@@ -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);
+50
View File
@@ -0,0 +1,50 @@
import type { DatabaseTransaction } from '@atlas/database';
import { ApiError } from './api-error.js';
/**
* Shared optimistic-lock conflict response. Versioned writes still keep their
* domain-specific SET clauses, but they must use a conditional `revision =
* expected` update and this helper when no row was affected.
*/
const resources = {
collection_item: {
table: 'atlas.collection_items',
label: '采集记录',
active: ' AND deleted_at IS NULL',
},
product: { table: 'atlas.products', label: '商品', active: '' },
product_platform_profile: {
table: 'atlas.product_platform_profiles',
label: '商品发布配置',
active: '',
},
} as const;
export async function throwRevisionConflict(
transaction: DatabaseTransaction,
resource: keyof typeof resources,
id: string,
expected: number,
): Promise<never> {
const target = resources[resource];
const current = await transaction.query<{ revision: number }>(
`SELECT revision FROM ${target.table} WHERE id = $1${target.active}`,
[id],
);
if (!current.rows[0])
throw new ApiError(404, 'RESOURCE_NOT_FOUND', `${target.label}不存在`);
if (current.rows[0].revision !== expected) {
throw new ApiError(409, 'VERSION_CONFLICT', `${target.label}已被更新`, [
{
field: 'If-Match',
code: 'VERSION_CONFLICT',
message: `当前版本为 ${current.rows[0].revision}`,
current_revision: current.rows[0].revision,
},
]);
}
// A conditional write with a matching version affecting no rows is a
// resource-state error (for example, a concurrent soft delete), not a
// successful retry.
throw new ApiError(409, 'VERSION_CONFLICT', `${target.label}状态已变化`);
}
+217
View File
@@ -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 {}
+26
View File
@@ -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 {}
+508
View File
@@ -0,0 +1,508 @@
import { Inject, Injectable } from '@nestjs/common';
import type { CollectedProductPayload } from '@atlas/contracts';
import type { Database } from '@atlas/database';
import { ApiError } from '../common/api-error.js';
import { IdempotencyService } from '../common/idempotency.service.js';
import { DATABASE } from '../database/database.module.js';
@Injectable()
export class ExtensionService {
public constructor(
@Inject(DATABASE) private readonly database: Database,
@Inject(IdempotencyService)
private readonly idempotency: IdempotencyService,
) {}
public async ingest(
workspaceId: string,
userId: string,
key: string,
payload: CollectedProductPayload,
) {
return this.idempotency.run(
{
workspaceId,
userId,
method: 'POST',
path: '/api/extension/materials',
key,
request: payload,
},
async (tx) => {
const platform = await tx.query<{ id: string; code: string }>(
`SELECT id, code FROM atlas.platforms
WHERE code = $1 AND status = 'active' AND coalesce((capabilities->>'can_be_source')::boolean, false)`,
[payload.source.platform.toUpperCase()],
);
if (!platform.rows[0])
throw new ApiError(
422,
'SOURCE_PLATFORM_UNAVAILABLE',
'该来源平台当前不可采集',
);
const existing = await this.findExistingSourceItem(
tx,
workspaceId,
platform.rows[0].id,
payload,
);
if (existing) {
await this.replaceSourceSnapshot(
tx,
workspaceId,
existing.id,
existing.product_id,
payload,
);
return {
status: 200,
body: {
data: await this.materialResponse(
tx,
existing.id,
existing.product_id,
true,
true,
),
},
};
}
const { status, sourcePrice } = sourceState(payload);
const item = await tx.query<{ id: string }>(
`INSERT INTO atlas.collection_items
(workspace_id, source_kind, source_platform_id, source_item_key, source_url, source_title, source_currency_code, source_min_price, source_max_price, status, raw_evidence, normalized_payload, parser_version, collected_at)
VALUES (current_setting('app.workspace_id')::uuid, 'platform', $1, $2, $3, $4, $5, $6, $6, $7::atlas.collection_item_status, $8, $9, $10, $11) RETURNING id`,
[
platform.rows[0].id,
payload.source.itemKey,
payload.source.canonicalUrl,
payload.common.title?.value ?? null,
sourcePrice?.currencyCode ?? null,
sourcePrice?.amount ?? null,
status,
JSON.stringify(payload.evidence),
JSON.stringify(payload),
payload.source.parserVersion,
payload.source.collectedAt,
],
);
const product = await tx.query<{ id: string }>(
`INSERT INTO atlas.products (workspace_id, collection_item_id, source_title, purchase_url)
VALUES (current_setting('app.workspace_id')::uuid, $1, $2, $3) RETURNING id`,
[
item.rows[0].id,
payload.common.title?.value ?? null,
payload.source.canonicalUrl,
],
);
await this.writeSourceSnapshotRows(
tx,
workspaceId,
item.rows[0].id,
product.rows[0].id,
1,
payload,
false,
);
return {
status: 201,
body: {
data: await this.materialResponse(
tx,
item.rows[0].id,
product.rows[0].id,
false,
false,
),
},
};
},
);
}
/**
* Ozon has no public, stable SPU ID. The entered URL SKU remains the source
* identity for audit purposes, while a re-collection from any sibling URL
* reuses the same item when its real source SKU set intersects this one.
*/
private async findExistingSourceItem(
tx: { query: Database['query'] },
workspaceId: string,
platformId: string,
payload: CollectedProductPayload,
): Promise<{ id: string; product_id: string } | null> {
const bySourceKey = await tx.query<{ id: string; product_id: string }>(
`SELECT c.id, p.id AS product_id
FROM atlas.collection_items c
JOIN atlas.products p ON p.collection_item_id = c.id
WHERE c.source_platform_id = $1
AND c.source_item_key = $2
AND c.deleted_at IS NULL`,
[platformId, payload.source.itemKey],
);
if (bySourceKey.rows[0]) return bySourceKey.rows[0];
if (payload.source.platform !== 'ozon') return null;
const sourceSkus = [
...new Set(
payload.variants
.map(
(variant) =>
variant.sourceSku ?? variant.sourceVariantKey,
)
.filter((sku) => sku.trim().length > 0),
),
];
if (!sourceSkus.length) return null;
// Different sibling URLs have different source_item_key values. Locking
// each real SKU in sorted order makes two concurrent first collections
// of the same public variant group observe one another's snapshot.
for (const sourceSku of sourceSkus.sort()) {
await tx.query(
`SELECT pg_advisory_xact_lock(hashtextextended($1::text, 0))`,
[`${workspaceId}:${platformId}:${sourceSku}`],
);
}
const byVariantOverlap = await tx.query<{
id: string;
product_id: string;
}>(
`SELECT DISTINCT c.id, p.id AS product_id
FROM atlas.collection_items c
JOIN atlas.products p ON p.collection_item_id = c.id
JOIN atlas.product_variants v ON v.product_id = p.id
WHERE c.source_platform_id = $1
AND c.deleted_at IS NULL
AND v.deleted_at IS NULL
AND coalesce(v.source_sku, v.source_variant_key) = ANY($2::text[])
LIMIT 2`,
[platformId, sourceSkus],
);
if (byVariantOverlap.rows.length > 1) {
throw new ApiError(
409,
'SOURCE_VARIANT_GROUP_AMBIGUOUS',
'发现多条历史采集记录包含相同 Ozon SKU,请先在采集箱处理重复记录',
);
}
return byVariantOverlap.rows[0] ?? null;
}
private async replaceSourceSnapshot(
tx: { query: Database['query'] },
workspaceId: string,
collectionItemId: string,
productId: string,
payload: CollectedProductPayload,
) {
const { status, sourcePrice } = sourceState(payload);
// Once a platform profile exists, product_variants are no longer a
// disposable collection projection: they may carry user edits and are
// referenced by platform listings. A re-collection refreshes the
// source evidence/candidates, but must not overwrite that draft or
// sever a SKU's publishing history.
const profileCount = await tx.query<{ count: string }>(
`SELECT count(*)::text AS count
FROM atlas.product_platform_profiles
WHERE product_id = $1`,
[productId],
);
const preserveEditorDraft =
Number(profileCount.rows[0]?.count ?? 0) > 0;
// 不删除旧候选/任务记录:运行中的 Worker 可能已拿到它。取消并保留审计记录,
// 新快照在查询和 Worker 中均按 source_snapshot_revision 隔离。
await tx.query(
`UPDATE atlas.collection_asset_transfers SET status = 'cancelled', completed_at = now(), updated_at = now()
WHERE collection_item_id = $1 AND status IN ('pending_selection', 'queued', 'running')`,
[collectionItemId],
);
const item = await tx.query<{
source_snapshot_revision: string | number;
}>(
`UPDATE atlas.collection_items
SET source_url = $1, source_title = $2, source_currency_code = $3, source_min_price = $4, source_max_price = $4,
status = $5::atlas.collection_item_status, raw_evidence = $6, normalized_payload = $7, parser_version = $8,
collected_at = $9, source_snapshot_revision = source_snapshot_revision + 1, revision = revision + 1, updated_at = now()
WHERE id = $10 AND deleted_at IS NULL RETURNING source_snapshot_revision`,
[
payload.source.canonicalUrl,
payload.common.title?.value ?? null,
sourcePrice?.currencyCode ?? null,
sourcePrice?.amount ?? null,
status,
JSON.stringify(payload.evidence),
JSON.stringify(payload),
payload.source.parserVersion,
payload.source.collectedAt,
collectionItemId,
],
);
const snapshotRevision = Number(item.rows[0]?.source_snapshot_revision);
if (!Number.isSafeInteger(snapshotRevision) || snapshotRevision < 1)
throw new Error('采集来源快照版本更新失败');
if (!preserveEditorDraft) {
await tx.query(
`UPDATE atlas.products
SET source_title = $1, updated_at = now(), revision = revision + 1
WHERE id = $2`,
[payload.common.title?.value ?? null, productId],
);
}
await this.writeSourceSnapshotRows(
tx,
workspaceId,
collectionItemId,
productId,
snapshotRevision,
payload,
preserveEditorDraft,
);
return snapshotRevision;
}
private async writeSourceSnapshotRows(
tx: { query: Database['query'] },
workspaceId: string,
collectionItemId: string,
productId: string,
snapshotRevision: number,
payload: CollectedProductPayload,
preserveEditorDraft: boolean,
) {
const verifiedVariants = payload.variants.filter(
(variant) => variant.sourceVariantKey.trim().length > 0,
);
const variantRows = verifiedVariants.length
? verifiedVariants
: [
{
sourceVariantKey: null,
sourceSku: undefined,
displayName: '默认 SKU',
},
];
const sourceKeys = variantRows
.map((variant) => variant.sourceVariantKey)
.filter((key): key is string => !!key);
const existing = await tx.query<{
id: string;
source_variant_key: string | null;
deleted_at: Date | string | null;
}>(
`SELECT id, source_variant_key, deleted_at
FROM atlas.product_variants
WHERE product_id = $1
AND (source_variant_key = ANY($2::text[]) OR source_variant_key IS NULL)
ORDER BY sort_order, id`,
[productId, sourceKeys],
);
const existingBySourceKey = new Map(
existing.rows
.filter((variant) => variant.source_variant_key)
.map((variant) => [variant.source_variant_key!, variant]),
);
// A capture without a stable source key has exactly one synthetic
// default SKU. Reuse an existing unkeyed row when possible; this keeps
// its platform mapping rather than attempting a duplicate insert.
const existingDefault = existing.rows.find(
(variant) => variant.source_variant_key === null,
);
const variants = new Map<string, string>();
const retainedVariantIds: string[] = [];
for (let index = 0; index < variantRows.length; index += 1) {
const variant = variantRows[index];
const matched = variant.sourceVariantKey
? existingBySourceKey.get(variant.sourceVariantKey)
: existingDefault;
let variantId: string;
if (matched) {
variantId = matched.id;
// A user may have deliberately removed an Atlas SKU after
// editing began. Keep that decision; its source evidence is
// still present in normalized_payload, but it is not silently
// revived into the editable/publishable SKU set.
if (!preserveEditorDraft) {
await tx.query(
`UPDATE atlas.product_variants
SET source_sku = $1, display_name = $2, sort_order = $3,
purchase_cost_cny = COALESCE(ROUND($4::numeric, 2), purchase_cost_cny),
deleted_at = NULL, updated_at = now()
WHERE id = $5`,
[
variant.sourceSku ?? null,
variant.displayName,
index,
sourcePurchaseCost(payload.source.platform, variant),
variantId,
],
);
}
} else {
const inserted = await tx.query<{ id: string }>(
`INSERT INTO atlas.product_variants
(workspace_id, product_id, source_variant_key, source_sku, display_name, sort_order, purchase_cost_cny)
VALUES ($1, $2, $3, $4, $5, $6, ROUND($7::numeric, 2)) RETURNING id`,
[
workspaceId,
productId,
variant.sourceVariantKey,
variant.sourceSku ?? null,
variant.displayName,
index,
sourcePurchaseCost(payload.source.platform, variant),
],
);
variantId = inserted.rows[0].id;
await this.ensureProfileListingsForVariant(
tx,
productId,
variantId,
);
}
retainedVariantIds.push(variantId);
if (variant.sourceVariantKey)
variants.set(variant.sourceVariantKey, variantId);
}
// Before an editor profile exists, variants are still just the latest
// source projection, so a missing source SKU can be safely hidden. Do
// not physically delete it: the partial source-key unique index does
// not exclude soft-deleted rows, and a later refresh may legitimately
// rediscover the same stable key. After editing begins, omission from a
// source refresh must never act as a user-requested SKU deletion.
if (!preserveEditorDraft) {
await tx.query(
`UPDATE atlas.product_variants
SET deleted_at = now(), updated_at = now()
WHERE product_id = $1 AND deleted_at IS NULL
AND NOT (id = ANY($2::uuid[]))`,
[productId, retainedVariantIds],
);
}
for (const asset of payload.assets) {
await tx.query(
`INSERT INTO atlas.collection_asset_transfers (workspace_id, collection_item_id, product_id, source_variant_id, media_type, poster_url, role, source_url, status, error_payload, source_snapshot_revision)
VALUES ($1, $2, $3, $4, $5::atlas.collection_media_type, $6, $7::atlas.media_candidate_role, $8, 'pending_selection', $9, $10)`,
[
workspaceId,
collectionItemId,
productId,
asset.variantSourceKey
? (variants.get(asset.variantSourceKey) ?? null)
: null,
asset.mediaType,
asset.posterUrl ?? null,
asset.role,
asset.url,
JSON.stringify({
relation: asset.relation,
referer_origin: asset.refererOrigin ?? null,
evidence_ref: asset.evidenceRef ?? null,
sort_order: asset.sortOrder,
}),
snapshotRevision,
],
);
}
// Collecting records source-media candidates only. A later explicit
// selection queues the media and creates the materialisation event.
await tx.query(
`UPDATE atlas.collection_items SET status = CASE WHEN status = 'needs_review' THEN status ELSE 'ready_for_edit' END, updated_at = now() WHERE id = $1 AND source_snapshot_revision = $2`,
[collectionItemId, snapshotRevision],
);
}
private async ensureProfileListingsForVariant(
tx: { query: Database['query'] },
productId: string,
variantId: string,
) {
// A source refresh may discover a new real SKU after a user has
// already entered one or more platform editors. Every existing profile
// needs a fresh, explicitly not-listed mapping for that SKU; imported
// mappings for other SKU rows are untouched.
await tx.query(
`INSERT INTO atlas.product_variant_platform_listings
(workspace_id, product_id, profile_id, platform_id, product_variant_id, store_id, currency_code)
SELECT current_setting('app.workspace_id')::uuid, $1, p.id, p.platform_id,
$2, p.target_store_id, s.currency_code
FROM atlas.product_platform_profiles p
JOIN atlas.stores s ON s.id = p.target_store_id
WHERE p.product_id = $1
ON CONFLICT (workspace_id, profile_id, product_variant_id) DO NOTHING`,
[productId, variantId],
);
}
private async materialResponse(
tx: { query: Database['query'] },
collectionItemId: string,
productId: string,
reused: boolean,
refreshed: boolean,
) {
const count = await tx.query<{
total: string;
pending_selection: string;
queued: string;
succeeded: string;
failed: string;
}>(
`SELECT count(*)::text AS total, count(*) FILTER (WHERE t.status = 'pending_selection')::text AS pending_selection, count(*) FILTER (WHERE t.status = 'queued')::text AS queued, count(*) FILTER (WHERE t.status = 'succeeded')::text AS succeeded, count(*) FILTER (WHERE t.status = 'failed')::text AS failed FROM atlas.collection_asset_transfers t JOIN atlas.collection_items c ON c.id = t.collection_item_id WHERE t.collection_item_id = $1 AND t.source_snapshot_revision = c.source_snapshot_revision`,
[collectionItemId],
);
const status = await tx.query<{
status: string;
source_title: string | null;
variant_count: string;
}>(
`SELECT status, source_title,
(SELECT count(*)::text FROM atlas.product_variants WHERE product_id=$2 AND deleted_at IS NULL) AS variant_count
FROM atlas.collection_items WHERE id=$1`,
[collectionItemId, productId],
);
return {
collection_item: {
id: collectionItemId,
status: status.rows[0]?.status ?? 'received',
source_title: status.rows[0]?.source_title ?? null,
variant_count: Number(status.rows[0]?.variant_count ?? 0),
},
product_id: productId,
reused,
refreshed,
editor_entry_path: `/products/${productId}/editor?collection_item_id=${collectionItemId}`,
materialization: {
total: Number(count.rows[0]?.total ?? 0),
pending_selection: Number(
count.rows[0]?.pending_selection ?? 0,
),
queued: Number(count.rows[0]?.queued ?? 0),
succeeded: Number(count.rows[0]?.succeeded ?? 0),
failed: Number(count.rows[0]?.failed ?? 0),
},
};
}
}
function sourceState(payload: CollectedProductPayload) {
const verifiedVariants = payload.variants.filter(
(variant) => variant.sourceVariantKey.trim().length > 0,
);
return {
status:
verifiedVariants.length || !payload.variantAxes.length
? 'ready_for_edit'
: 'needs_review',
sourcePrice: payload.common.sourcePrice,
};
}
/** 仅将 1688 明确标为人民币的逐 SKU 来源价写入采购价。 */
function sourcePurchaseCost(
platform: CollectedProductPayload['source']['platform'],
variant: CollectedProductPayload['variants'][number] | { sourceVariantKey: null },
) {
return platform === '1688' && 'sourcePrice' in variant && variant.sourcePrice?.currencyCode === 'CNY'
? variant.sourcePrice.amount
: null;
}
+62
View File
@@ -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
View File
@@ -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,
};
}
}
+441
View File
@@ -0,0 +1,441 @@
import { randomUUID } from 'node:crypto';
import { Inject, Injectable } from '@nestjs/common';
import {
createImageSuiteSchema,
imageSuitePlanErrorMessages,
createMediaAiEditRequestSchema,
type CreateImageSuiteRequest,
type ImageSuite,
type ImageSuiteInput,
type ImageSuitePlanInput,
type ImageSuiteShot,
type ListProductImageTasksQuery,
} from '@atlas/contracts';
import type { Database, DatabaseTransaction } from '@atlas/database';
import { ApiError } from '../common/api-error.js';
import { IdempotencyService } from '../common/idempotency.service.js';
import { DATABASE } from '../database/database.module.js';
import { MediaAiEditService } from './media-ai-edit.service.js';
type SuiteRow = Omit<
ImageSuite,
'items' | 'operation' | 'created_at' | 'completed_at'
> & { created_at: Date | string; completed_at: Date | string | null };
const iso = (value: Date | string) =>
value instanceof Date ? value.toISOString() : value;
@Injectable()
export class ImageSuiteService {
public constructor(
@Inject(DATABASE) private readonly database: Database,
@Inject(IdempotencyService)
private readonly idempotency: IdempotencyService,
@Inject(MediaAiEditService) private readonly edits: MediaAiEditService,
) {}
public async create(
workspaceId: string,
userId: string,
productId: string,
key: string,
request:
ImageSuiteInput | ImageSuitePlanInput | CreateImageSuiteRequest,
purpose: 'plan' | 'generate',
) {
const path = `/api/products/${productId}/ai/${purpose === 'plan' ? 'suite-plans' : 'image-suites'}`;
return this.idempotency.run(
{ workspaceId, userId, method: 'POST', path, key, request },
async (tx) => {
const variant = await tx.query<{ id: string }>(
'SELECT id FROM atlas.product_variants WHERE id = $1 AND product_id = $2 AND deleted_at IS NULL',
[request.variant_id, productId],
);
if (!variant.rows[0])
throw new ApiError(
404,
'RESOURCE_NOT_FOUND',
'目标 SKU 不存在或不属于此商品,请先保存新 SKU',
);
const assets = await tx.query<{ id: string }>(
`SELECT id FROM atlas.media_assets WHERE id = ANY($1::uuid[]) AND deleted_at IS NULL
AND content_type IN ('image/jpeg', 'image/png', 'image/webp')
AND byte_size <= 20971520 AND width_px BETWEEN 1 AND 10000 AND height_px BETWEEN 1 AND 10000`,
[request.reference_asset_ids],
);
if (assets.rows.length !== request.reference_asset_ids.length)
throw new ApiError(
404,
'RESOURCE_NOT_FOUND',
'参考素材不存在或不可处理',
);
const shots =
purpose === 'generate'
? createImageSuiteSchema.parse(request).shots
: [];
const { shots: _shots, ...input } =
request as CreateImageSuiteRequest;
const created = await tx.query<SuiteRow>(
`INSERT INTO atlas.product_image_suites (workspace_id, product_id, variant_id, purpose, input, shots, created_by_user_id)
VALUES (current_setting('app.workspace_id')::uuid, $1, $2, $3, $4::jsonb, $5::jsonb, $6) RETURNING *`,
[
productId,
input.variant_id,
purpose,
JSON.stringify(input),
JSON.stringify(shots),
userId,
],
);
const row = created.rows[0];
for (const [
position,
assetId,
] of input.reference_asset_ids.entries())
await tx.query(
`INSERT INTO atlas.product_image_suite_references (workspace_id, suite_id, asset_id, position)
VALUES (current_setting('app.workspace_id')::uuid, $1, $2, $3)`,
[row.id, assetId, position],
);
await this.operation(
tx,
row.id,
purpose === 'plan' ? 'image_suite_plan' : 'image_suite',
userId,
);
if (purpose === 'plan')
await this.outbox(
tx,
'image_suite.plan.requested',
row.id,
{ suite_id: row.id },
);
else
for (const [index, shot] of shots.entries()) {
const supporting = input.reference_asset_ids.slice(1);
// 默认方案轮换全部辅助底图,避免后面的参考角度始终未被使用。
const selected = shot.reference_asset_ids ?? [
input.reference_asset_ids[0],
...Array.from(
{ length: Math.min(2, supporting.length) },
(_, offset) =>
supporting[
(index + offset) % supporting.length
],
),
];
const settings = createMediaAiEditRequestSchema.parse({
product_id: productId,
reference_asset_ids: selected.slice(1),
mode: 'edit',
ratio: input.ratio,
target_language: input.language,
background:
shot.category === 'white' ? 'white' : 'auto',
style_set: input.style_set,
style_prompt: input.style_prompt,
prompt: `${shot.theme}\n${shot.description}\n资料依据:${shot.evidence}`,
suite_context: {
type: shot.type,
product_name: input.product_name,
color: input.color,
dimensions: input.dimensions,
weight: input.weight,
collected_info: input.collected_info,
selling_points: input.selling_points,
},
});
const id = randomUUID();
await tx.query(
`INSERT INTO atlas.media_ai_edits (id, workspace_id, product_id, suite_id, suite_shot_id, suite_position,
source_asset_id, reference_asset_ids, settings, created_by_user_id)
VALUES ($1, current_setting('app.workspace_id')::uuid, $2, $3, $4, $5, $6, $7::uuid[], $8::jsonb, $9)`,
[
id,
productId,
row.id,
shot.id,
index,
selected[0],
selected.slice(1),
JSON.stringify(settings),
userId,
],
);
await this.operation(tx, id, 'media_ai_edit', userId);
await this.outbox(tx, 'media.ai_edit.requested', id, {
edit_id: id,
});
}
return { status: 202, body: { data: this.resource(row) } };
},
);
}
public async read(
workspaceId: string,
productId: string,
id: string,
): Promise<ImageSuite> {
const { row, children } = await this.database.workspaceTransaction(
workspaceId,
async (tx) => {
const row = await this.find(tx, productId, id);
const children = await tx.query<{
id: string;
suite_shot_id: string;
}>(
'SELECT id, suite_shot_id FROM atlas.media_ai_edits WHERE suite_id = $1 ORDER BY suite_position',
[id],
);
return { row, children: children.rows };
},
);
return {
...this.resource(row),
items: await Promise.all(
children.map(async (child) => ({
...(await this.edits.read(workspaceId, child.id)),
shot_id: child.suite_shot_id,
edit_id: child.id,
})),
),
};
}
public async list(
workspaceId: string,
productId: string,
query: ListProductImageTasksQuery,
) {
let cursor: { at: string; id: string } | null = null;
if (query.cursor) {
try {
cursor = JSON.parse(
Buffer.from(query.cursor, 'base64url').toString(),
);
if (
!cursor ||
typeof cursor.at !== 'string' ||
typeof cursor.id !== 'string' ||
!Number.isFinite(Date.parse(cursor.at)) ||
!/^[0-9a-f-]{36}$/i.test(cursor.id)
)
throw new Error();
} catch {
throw new ApiError(422, 'VALIDATION_FAILED', '分页游标无效');
}
}
const rows = await this.database.workspaceTransaction(
workspaceId,
async (tx) => {
const found = await tx.query<{ id: string }>(
'SELECT id FROM atlas.products WHERE id = $1',
[productId],
);
if (!found.rows[0])
throw new ApiError(404, 'RESOURCE_NOT_FOUND', '商品不存在');
return (
await tx.query<SuiteRow>(
`SELECT * FROM atlas.product_image_suites WHERE product_id = $1
AND ($2::timestamptz IS NULL OR (created_at, id) < ($2::timestamptz, $3::uuid))
ORDER BY created_at DESC, id DESC LIMIT $4`,
[
productId,
cursor?.at ?? null,
cursor?.id ?? null,
query.limit + 1,
],
)
).rows;
},
);
const page = rows.slice(0, query.limit),
last = page.at(-1),
hasMore = rows.length > query.limit;
return {
data: page.map((row) => this.resource(row)),
meta: {
has_more: hasMore,
next_cursor:
hasMore && last
? Buffer.from(
JSON.stringify({
at: iso(last.created_at),
id: last.id,
}),
).toString('base64url')
: null,
},
};
}
public async action(
workspaceId: string,
userId: string,
productId: string,
id: string,
key: string,
action: 'cancel' | 'retry',
) {
return this.idempotency.run(
{
workspaceId,
userId,
method: 'POST',
path: `/api/products/${productId}/ai/image-suites/${id}/${action}`,
key,
request: {},
},
async (tx) => {
// 与 Worker 一致先锁子任务再锁父任务,避免取消与结果回写形成相反锁序。
const children = await tx.query<{ id: string; status: string }>(
'SELECT id, status FROM atlas.media_ai_edits WHERE suite_id = $1 ORDER BY id FOR UPDATE',
[id],
);
const row = await this.find(tx, productId, id, true);
if (action === 'cancel') {
if (['queued', 'running'].includes(row.status)) {
await tx.query(
"UPDATE atlas.product_image_suites SET status = 'cancelled', completed_at = now() WHERE id = $1",
[id],
);
await tx.query(
"UPDATE atlas.media_ai_edits SET status = 'cancelled', completed_at = now() WHERE suite_id = $1 AND status IN ('queued', 'running')",
[id],
);
await tx.query(
"UPDATE atlas.operations SET status = 'cancelled', completed_at = now() WHERE (id = $1 OR id = ANY($2::uuid[])) AND status IN ('queued', 'running')",
[id, children.rows.map((child) => child.id)],
);
}
} else {
if (
row.purpose !== 'generate' ||
['queued', 'running'].includes(row.status)
)
throw new ApiError(
409,
'INVALID_TASK_STATE',
'当前任务不能重试,请等待生成完成',
);
const failed = children.rows.filter((child) =>
['failed', 'cancelled'].includes(child.status),
);
if (failed.length) {
await tx.query(
"UPDATE atlas.product_image_suites SET status = 'queued', error_code = NULL, completed_at = NULL WHERE id = $1",
[id],
);
await tx.query(
"UPDATE atlas.operations SET status = 'queued', error_code = NULL, error_message = NULL, completed_at = NULL WHERE id = $1",
[id],
);
for (const child of failed) {
const attempt = await tx.query<{
generation_attempt: number;
}>(
"UPDATE atlas.media_ai_edits SET status = 'queued', provider_task_id = NULL, error_code = NULL, completed_at = NULL, started_at = NULL, created_at = now(), generation_attempt = generation_attempt + 1 WHERE id = $1 RETURNING generation_attempt",
[child.id],
);
await tx.query(
"UPDATE atlas.operations SET status = 'queued', result = '{}'::jsonb, error_code = NULL, error_message = NULL, completed_at = NULL WHERE id = $1",
[child.id],
);
await this.outbox(
tx,
'media.ai_edit.requested',
child.id,
{
edit_id: child.id,
attempt: attempt.rows[0].generation_attempt,
},
randomUUID(),
);
}
}
}
return {
status: 200,
body: {
data: this.resource(await this.find(tx, productId, id)),
},
};
},
);
}
private async find(
tx: DatabaseTransaction,
productId: string,
id: string,
lock = false,
) {
const result = await tx.query<SuiteRow>(
`SELECT * FROM atlas.product_image_suites WHERE id = $1 AND product_id = $2 ${lock ? 'FOR UPDATE' : ''}`,
[id, productId],
);
if (!result.rows[0])
throw new ApiError(404, 'RESOURCE_NOT_FOUND', '套图任务不存在');
return result.rows[0];
}
private resource(row: SuiteRow): ImageSuite {
return {
id: row.id,
product_id: row.product_id,
variant_id: row.variant_id,
purpose: row.purpose,
status: row.status,
input: row.input,
shots: row.shots as ImageSuiteShot[],
items: [],
error_code: row.error_code,
created_at: iso(row.created_at),
completed_at: row.completed_at ? iso(row.completed_at) : null,
operation: {
id: row.id,
kind:
row.purpose === 'plan' ? 'image_suite_plan' : 'image_suite',
status: row.status === 'partial' ? 'failed' : row.status,
created_at: iso(row.created_at),
completed_at: row.completed_at ? iso(row.completed_at) : null,
error: row.error_code
? {
code: row.error_code,
message:
row.purpose === 'plan'
? (imageSuitePlanErrorMessages[
row.error_code
] ??
imageSuitePlanErrorMessages.AI_SUITE_PLAN_FAILED)
: '部分图片未完成,可重试失败项',
}
: null,
},
};
}
private async operation(
tx: DatabaseTransaction,
id: string,
kind: string,
userId: string,
) {
await tx.query(
`INSERT INTO atlas.operations (id, workspace_id, kind, resource_type, resource_id, created_by_user_id)
VALUES ($1, current_setting('app.workspace_id')::uuid, $2, $2, $1, $3)`,
[id, kind, userId],
);
}
private async outbox(
tx: DatabaseTransaction,
event: string,
id: string,
payload: Record<string, string | number>,
dedup = id,
) {
await tx.query(
`INSERT INTO atlas.outbox_events (workspace_id, aggregate_type, aggregate_id, event_type, event_payload, deduplication_key)
VALUES (current_setting('app.workspace_id')::uuid, 'image_suite', $1, $2, $3::jsonb, $4)`,
[id, event, JSON.stringify(payload), dedup],
);
}
}
+166
View File
@@ -0,0 +1,166 @@
import { Inject, Injectable } from '@nestjs/common';
import type { MediaAiEdit, MediaAiEditSettings } from '@atlas/contracts';
import type { Database } from '@atlas/database';
import { ApiError } from '../common/api-error.js';
import { IdempotencyService } from '../common/idempotency.service.js';
import { DATABASE } from '../database/database.module.js';
import { MediaService } from './media.service.js';
type EditRow = {
id: string;
status: MediaAiEdit['status'];
source_asset_id: string;
output_asset_id: string | null;
error_code: string | null;
failure_source?: MediaAiEdit['failure_source'];
failure_stage?: MediaAiEdit['failure_stage'];
failure_detail?: string | null;
content_type: string | null;
byte_size: string | null;
width_px: number | null;
height_px: number | null;
asset_created_at: Date | string | null;
};
@Injectable()
export class MediaAiEditService {
public constructor(
@Inject(DATABASE) private readonly database: Database,
@Inject(IdempotencyService) private readonly idempotency: IdempotencyService,
@Inject(MediaService) private readonly media: MediaService,
) {}
/** 创建任务前确认主图和辅图均为本工作空间已校验的正式素材。 */
public async create(workspaceId: string, userId: string, sourceId: string, key: string, settings: MediaAiEditSettings) {
return this.idempotency.run({
workspaceId, userId, method: 'POST', path: `/api/media-assets/${sourceId}/ai-edits`,
key, request: settings,
}, async (tx) => {
const allIds = [sourceId, ...settings.reference_asset_ids];
const product = await tx.query<{ id: string }>(
'SELECT id FROM atlas.products WHERE id = $1', [settings.product_id],
);
if (!product.rows[0]) throw new ApiError(404, 'RESOURCE_NOT_FOUND', '商品不存在');
if (new Set(allIds).size !== allIds.length)
throw new ApiError(422, 'INVALID_REFERENCE_IMAGE', '主图和辅助图不能重复');
const assets = await tx.query<{ id: string }>(
`SELECT id FROM atlas.media_assets WHERE id = ANY($1::uuid[]) AND deleted_at IS NULL
AND content_type IN ('image/jpeg', 'image/png', 'image/webp')
AND byte_size <= 20971520 AND width_px BETWEEN 1 AND 10000
AND height_px BETWEEN 1 AND 10000`, [allIds],
);
if (assets.rows.length !== allIds.length)
throw new ApiError(404, 'RESOURCE_NOT_FOUND', '图片素材不存在或不可处理');
const created = await tx.query<{ id: string }>(
`INSERT INTO atlas.media_ai_edits
(workspace_id, product_id, source_asset_id, reference_asset_ids, settings, created_by_user_id)
VALUES (current_setting('app.workspace_id')::uuid, $1, $2, $3::uuid[], $4::jsonb, $5)
RETURNING id`,
[settings.product_id, sourceId, settings.reference_asset_ids, JSON.stringify(settings), userId],
);
const id = created.rows[0].id;
await tx.query(
`INSERT INTO atlas.operations (id, workspace_id, kind, resource_type, resource_id, created_by_user_id)
VALUES ($1, current_setting('app.workspace_id')::uuid, 'media_ai_edit', 'media_ai_edit', $1, $2)`,
[id, userId],
);
await tx.query(
`INSERT INTO atlas.outbox_events
(workspace_id, aggregate_type, aggregate_id, event_type, event_payload, deduplication_key)
VALUES (current_setting('app.workspace_id')::uuid, 'media_ai_edit', $1,
'media.ai_edit.requested', jsonb_build_object('edit_id', $1::uuid), $1::text)`, [id],
);
return { status: 202, body: { data: {
id, status: 'queued', source_asset_id: sourceId, output_asset: null, error_code: null,
failure_source: null, failure_stage: null, failure_detail: null,
} satisfies MediaAiEdit } };
});
}
public async read(workspaceId: string, id: string): Promise<MediaAiEdit> {
const row = await this.database.workspaceTransaction(workspaceId, async (tx) => {
const result = await tx.query<EditRow>(
`SELECT e.id, e.status, e.source_asset_id, e.output_asset_id, e.error_code,
o.result ->> 'failure_source' AS failure_source,
o.result ->> 'failure_stage' AS failure_stage,
o.result ->> 'failure_detail' AS failure_detail,
a.content_type, a.byte_size::text, a.width_px, a.height_px,
a.created_at AS asset_created_at
FROM atlas.media_ai_edits e
LEFT JOIN atlas.operations o ON o.id = e.id AND o.workspace_id = e.workspace_id
LEFT JOIN atlas.media_assets a ON a.id = e.output_asset_id AND a.deleted_at IS NULL
WHERE e.id = $1`, [id],
);
return result.rows[0] ?? null;
});
if (!row) throw new ApiError(404, 'RESOURCE_NOT_FOUND', 'AI 编辑任务不存在');
const access = row.output_asset_id && row.content_type ? await this.media.accessUrl(workspaceId, row.output_asset_id) : null;
return {
id: row.id, status: row.status, source_asset_id: row.source_asset_id, error_code: row.error_code,
failure_source: row.status === 'failed' ? row.failure_source ?? null : null,
failure_stage: row.status === 'failed' ? row.failure_stage ?? null : null,
failure_detail: row.status === 'failed' ? row.failure_detail ?? null : null,
output_asset: row.output_asset_id && access ? {
id: row.output_asset_id, content_type: row.content_type!, byte_size: Number(row.byte_size),
width_px: row.width_px, height_px: row.height_px,
created_at: row.asset_created_at instanceof Date
? row.asset_created_at.toISOString() : String(row.asset_created_at),
preview_url: access.url, preview_url_expires_at: access.expires_at,
} : null,
};
}
/** 右侧素材库展示最近完成的生成图,预览链接每次读取时重新签发。 */
public async list(workspaceId: string, productId: string): Promise<MediaAiEdit[]> {
const rows = await this.database.workspaceTransaction(workspaceId, async (tx) => {
const result = await tx.query<EditRow>(
`SELECT e.id, e.status, e.source_asset_id, e.output_asset_id, e.error_code,
a.content_type, a.byte_size::text, a.width_px, a.height_px,
a.created_at AS asset_created_at
FROM atlas.media_ai_edits e
JOIN atlas.media_assets a ON a.id = e.output_asset_id AND a.deleted_at IS NULL
WHERE e.status = 'succeeded' AND e.product_id = $1
ORDER BY e.completed_at DESC LIMIT 100`, [productId],
);
return result.rows;
});
return Promise.all(rows.map(async (row) => {
const access = await this.media.accessUrl(workspaceId, row.output_asset_id!);
return {
id: row.id, status: 'succeeded' as const, source_asset_id: row.source_asset_id,
error_code: null,
failure_source: null, failure_stage: null, failure_detail: null,
output_asset: {
id: row.output_asset_id!, content_type: row.content_type!,
byte_size: Number(row.byte_size), width_px: row.width_px, height_px: row.height_px,
created_at: row.asset_created_at instanceof Date
? row.asset_created_at.toISOString() : String(row.asset_created_at),
preview_url: access.url, preview_url_expires_at: access.expires_at,
},
};
}));
}
/** 取消是工作空间内的幂等状态变更;已完成结果不回滚。 */
public async cancel(workspaceId: string, userId: string, id: string, key: string) {
return this.idempotency.run({
workspaceId, userId, method: 'POST', path: `/api/media-ai-edits/${id}/cancel`, key, request: {},
}, async (tx) => {
const existing = await tx.query<{ status: MediaAiEdit['status'] }>(
'SELECT status FROM atlas.media_ai_edits WHERE id = $1 FOR UPDATE', [id],
);
if (!existing.rows[0]) throw new ApiError(404, 'RESOURCE_NOT_FOUND', 'AI 编辑任务不存在');
if (existing.rows[0].status === 'queued' || existing.rows[0].status === 'running') {
await tx.query(
`UPDATE atlas.media_ai_edits SET status = 'cancelled', completed_at = now() WHERE id = $1`, [id],
);
await tx.query(
`UPDATE atlas.operations SET status = 'cancelled', completed_at = now()
WHERE id = $1 AND status IN ('queued', 'running')`, [id],
);
}
return { status: 200, body: { data: { id, status: existing.rows[0].status === 'queued'
|| existing.rows[0].status === 'running' ? 'cancelled' : existing.rows[0].status } } };
});
}
}
@@ -0,0 +1,87 @@
export function detectImage(
bytes: Buffer,
maxDimension = 8192,
): {
contentType: 'image/png' | 'image/jpeg' | 'image/webp';
width: number;
height: number;
} | null {
if (
bytes.length >= 24 &&
bytes
.subarray(0, 8)
.equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]))
) {
return dimensions(
'image/png',
bytes.readUInt32BE(16),
bytes.readUInt32BE(20),
maxDimension,
);
}
if (
bytes.length >= 12 &&
bytes.subarray(0, 4).toString() === 'RIFF' &&
bytes.subarray(8, 12).toString() === 'WEBP'
) {
const type = bytes.subarray(12, 16).toString();
if (type === 'VP8X' && bytes.length >= 30) {
return dimensions(
'image/webp',
1 + bytes.readUIntLE(24, 3),
1 + bytes.readUIntLE(27, 3),
maxDimension,
);
}
if (
type === 'VP8 ' &&
bytes.length >= 30 &&
bytes[23] === 0x9d &&
bytes[24] === 0x01 &&
bytes[25] === 0x2a
) {
return dimensions(
'image/webp',
bytes.readUInt16LE(26) & 0x3fff,
bytes.readUInt16LE(28) & 0x3fff,
maxDimension,
);
}
}
if (bytes.length > 4 && bytes[0] === 0xff && bytes[1] === 0xd8) {
for (let offset = 2; offset + 9 < bytes.length;) {
if (bytes[offset] !== 0xff) return null;
const marker = bytes[offset + 1];
const length = bytes.readUInt16BE(offset + 2);
if (length < 2 || offset + 2 + length > bytes.length) return null;
if (
[
0xc0, 0xc1, 0xc2, 0xc3, 0xc5, 0xc6, 0xc7, 0xc9, 0xca, 0xcb,
0xcd, 0xce, 0xcf,
].includes(marker)
) {
return dimensions(
'image/jpeg',
bytes.readUInt16BE(offset + 7),
bytes.readUInt16BE(offset + 5),
maxDimension,
);
}
offset += 2 + length;
}
}
return null;
}
function dimensions(
contentType: 'image/png' | 'image/jpeg' | 'image/webp',
width: number,
height: number,
maxDimension: number,
) {
return width >= 1 &&
height >= 1 &&
width <= maxDimension &&
height <= maxDimension
? { contentType, width, height }
: null;
}
+367
View File
@@ -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);
}
+18
View File
@@ -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 {}
+687
View File
@@ -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;
}
}
+28
View File
@@ -0,0 +1,28 @@
import type { DatabaseTransaction } from '@atlas/database';
import type { CommissionRule } from './pricing-engine.js';
/** 查询与正式报价共用同一组已启用规则,避免页面显示和实际算价使用不同费率。 */
export async function readCommissionRules(
tx: DatabaseTransaction,
platformId: string,
categoryId: string,
fulfillment: string,
) {
const mapping = (
await tx.query<{ commission_category_id: string }>(
`SELECT * FROM atlas.platform_product_category_commission_mappings WHERE platform_id=$1 AND platform_product_category_id=$2 AND fulfillment=$3 AND market_code='RU' AND status='active' AND effective_from<=now() AND (effective_to IS NULL OR effective_to>now()) ORDER BY priority,effective_from DESC,id LIMIT 1 FOR SHARE`,
[platformId, categoryId, fulfillment],
)
).rows[0];
const commissions = mapping
? (
await tx.query<CommissionRule>(
`SELECT r.*,c.name AS category_name FROM atlas.commission_category_rules r JOIN atlas.commission_categories c ON c.id=r.commission_category_id
WHERE r.commission_category_id=$1 AND r.fulfillment=$2 AND r.market_code='RU' AND r.currency_code IN ('CNY','RUB')
AND r.status='active' AND r.effective_from<=now() AND (r.effective_to IS NULL OR r.effective_to>now()) ORDER BY r.min_price NULLS FIRST,r.id FOR SHARE OF r,c`,
[mapping.commission_category_id, fulfillment],
)
).rows
: [];
return { mapping, commissions };
}
@@ -0,0 +1,82 @@
import {
Body,
Controller,
Get,
Headers,
Inject,
Param,
Post,
Query,
Req,
UseGuards,
} from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import { pricingRateReviewRequestSchema } from '@atlas/contracts';
import { z } from 'zod';
import type { AuthenticatedRequest } from '../auth/auth.guard.js';
import { ApiError } from '../common/api-error.js';
import { SystemAdminGuard } from '../platform-catalog-admin/system-admin.guard.js';
import { PricingAdminService } from './pricing-admin.service.js';
function uuid(value: string, field: string): string {
if (!z.uuid().safeParse(value).success)
throw new ApiError(400, 'VALIDATION_ERROR', `${field} 无效`);
return value;
}
@ApiTags('System pricing')
@ApiBearerAuth('access-token')
@UseGuards(SystemAdminGuard)
@Controller('system/pricing/rate-cards')
export class PricingAdminController {
constructor(
@Inject(PricingAdminService)
private readonly service: PricingAdminService,
) {}
@Get()
async list(@Query('platform_id') platformId: string) {
return {
data: await this.service.list(uuid(platformId, 'platform_id')),
};
}
@Get(':id')
async detail(@Param('id') id: string) {
return { data: await this.service.detail(uuid(id, 'rate_card_id')) };
}
@Post(':id/reviews')
async approve(
@Param('id') id: string,
@Headers('idempotency-key') key: string | undefined,
@Body() body: unknown,
@Req() request: AuthenticatedRequest,
) {
if (!key)
throw new ApiError(
400,
'IDEMPOTENCY_KEY_REQUIRED',
'需要 Idempotency-Key',
);
const parsed = pricingRateReviewRequestSchema.safeParse(body);
if (!parsed.success)
throw new ApiError(
422,
'VALIDATION_ERROR',
'请检查审核证据与账单样本',
parsed.error.issues.map((issue) => ({
field: issue.path.join('.'),
message: issue.message,
})),
);
return {
data: await this.service.approve(
uuid(id, 'rate_card_id'),
request.auth.userId,
uuid(key, 'Idempotency-Key'),
parsed.data,
),
};
}
}
@@ -0,0 +1,352 @@
import { Inject, Injectable } from '@nestjs/common';
import type { Database, DatabaseTransaction } from '@atlas/database';
import type {
PricingRateCardAdmin,
PricingRateReviewRequest,
} from '@atlas/contracts';
import { DATABASE } from '../database/database.module.js';
import { ApiError } from '../common/api-error.js';
import {
calculateLogisticsFee,
D,
fingerprint,
logisticsMerchandiseFits,
type LogisticsRule,
} from './pricing-engine.js';
type Tx = DatabaseTransaction;
type CardRow = {
id: string;
platform_id: string;
provider_code: string;
version: string;
source_name: string;
source_uri: string | null;
status: string;
fulfillment: string;
market_code: string;
effective_from: Date;
reviewed_at: Date | null;
reviewer_name: string | null;
};
type TypeRow = PricingRateCardAdmin['types'][number] & Record<string, unknown>;
type RuleRow = LogisticsRule & {
product_type_code: string;
transport_method_name: string;
status: string;
product_type_name: string;
transport_name: string;
};
@Injectable()
export class PricingAdminService {
constructor(@Inject(DATABASE) private readonly db: Database) {}
async list(platformId: string): Promise<PricingRateCardAdmin[]> {
const ids = await this.db.query<{ id: string }>(
`SELECT id FROM atlas.logistics_rate_cards WHERE platform_id=$1 AND fulfillment='rfbs' AND market_code='RU' ORDER BY created_at DESC`,
[platformId],
);
const cards: PricingRateCardAdmin[] = [];
for (const row of ids.rows) cards.push(await this.detail(row.id));
return cards;
}
async detail(id: string): Promise<PricingRateCardAdmin> {
return this.db.transaction((tx) => this.read(tx, id));
}
private async read(tx: Tx, id: string): Promise<PricingRateCardAdmin> {
const card = (
await tx.query<CardRow>(
`SELECT c.id,c.platform_id,p.code AS provider_code,c.version,c.source_name,c.source_uri,c.status,c.fulfillment,c.market_code,c.effective_from,c.reviewed_at,u.display_name AS reviewer_name
FROM atlas.logistics_rate_cards c JOIN atlas.logistics_providers p ON p.id=c.provider_id
LEFT JOIN atlas.users u ON u.id=c.reviewed_by_user_id WHERE c.id=$1`,
[id],
)
).rows[0];
if (!card)
throw new ApiError(404, 'RATE_CARD_NOT_FOUND', '物流费率卡不存在');
const types = (
await tx.query<TypeRow>(
`SELECT id,code::text,min_merchandise_value::text,max_merchandise_value::text,min_actual_weight_kg::text,max_actual_weight_kg::text,billing_basis::text,
merchandise_currency_code,min_value_inclusive,max_value_inclusive,min_actual_weight_inclusive,max_sum_dimensions_cm::text,max_longest_side_cm::text,max_second_longest_side_cm::text,volume_divisor::text,volume_condition_sum_gt_cm::text,volume_round_scale,billing_round_scale,max_volume_weight_kg::text,volume_limit_round_scale
FROM atlas.logistics_product_types WHERE rate_card_id=$1 ORDER BY code`,
[id],
)
).rows;
const rules = (
await tx.query<RuleRow>(
`SELECT r.id,r.product_type_id,t.code::text AS product_type_code,t.name AS product_type_name,r.transport_method_id,m.name AS transport_method_name,m.name AS transport_name,
r.fixed_fee_cny::text,r.per_billable_kg_fee_cny::text,r.min_charge_cny::text,r.surcharge_rules,r.status::text,
t.merchandise_currency_code,t.min_merchandise_value::text,t.max_merchandise_value::text,t.min_value_inclusive,t.max_value_inclusive,
t.min_actual_weight_kg::text,t.min_actual_weight_inclusive,t.max_actual_weight_kg::text,t.max_sum_dimensions_cm::text,t.max_longest_side_cm::text,t.max_second_longest_side_cm::text,
t.billing_basis::text,t.volume_divisor::text,t.volume_condition_sum_gt_cm::text,t.volume_round_scale,t.billing_round_scale,t.max_volume_weight_kg::text,t.volume_limit_round_scale,
r.updated_at AS rule_updated_at,t.updated_at AS type_updated_at,m.updated_at AS transport_updated_at,m.status::text AS transport_status
FROM atlas.logistics_rate_rules r JOIN atlas.logistics_product_types t ON t.id=r.product_type_id
JOIN atlas.logistics_transport_methods m ON m.id=r.transport_method_id AND m.rate_card_id=t.rate_card_id
WHERE t.rate_card_id=$1 ORDER BY t.code,m.code`,
[id],
)
).rows;
const review =
(
await tx.query<{
source_reference: string;
review_note: string;
samples: PricingRateReviewRequest['samples'];
reviewed_at: Date;
}>(
`SELECT source_reference,review_note,samples,reviewed_at FROM atlas.logistics_rate_card_reviews WHERE rate_card_id=$1`,
[id],
)
).rows[0] ?? null;
const { reviewer_name: reviewerName, ...publicCard } = card;
return {
...publicCard,
effective_from: card.effective_from.toISOString(),
reviewed_at: card.reviewed_at?.toISOString() ?? null,
reviewer_name: reviewerName,
rules_hash: fingerprint({
card: {
id: card.id,
version: card.version,
source_name: card.source_name,
source_uri: card.source_uri,
},
types,
rules,
}),
types,
rules: rules.map((r) => ({
id: r.id,
product_type_id: r.product_type_id,
product_type_code: r.product_type_code,
transport_method_id: r.transport_method_id,
transport_method_name: r.transport_method_name,
fixed_fee_cny: r.fixed_fee_cny,
per_billable_kg_fee_cny: r.per_billable_kg_fee_cny,
min_charge_cny: r.min_charge_cny,
status: r.status,
})),
review: review && {
...review,
reviewed_at: review.reviewed_at.toISOString(),
},
};
}
async approve(
id: string,
userId: string,
key: string,
input: PricingRateReviewRequest,
) {
return this.db.transaction(async (tx) => {
await tx.query(
`SELECT pg_advisory_xact_lock(hashtextextended($1,0))`,
[`pricing-review:${userId}:${key}`],
);
const card = (
await tx.query<{
status: string;
provider_code: string;
platform_code: string;
}>(
`SELECT c.status,p.code AS provider_code,platform.code AS platform_code
FROM atlas.logistics_rate_cards c JOIN atlas.logistics_providers p ON p.id=c.provider_id
JOIN atlas.platforms platform ON platform.id=c.platform_id WHERE c.id=$1 FOR UPDATE OF c`,
[id],
)
).rows[0];
if (!card)
throw new ApiError(
404,
'RATE_CARD_NOT_FOUND',
'物流费率卡不存在',
);
const requestHash = fingerprint(input);
const replay = (
await tx.query<{ rate_card_id: string; request_hash: string }>(
`SELECT rate_card_id,request_hash FROM atlas.logistics_rate_card_reviews WHERE reviewer_user_id=$1 AND idempotency_key=$2`,
[userId, key],
)
).rows[0];
if (replay) {
if (
replay.rate_card_id !== id ||
replay.request_hash !== requestHash
)
throw new ApiError(
409,
'IDEMPOTENCY_KEY_CONFLICT',
'幂等键已用于其他审核请求',
);
return this.read(tx, id);
}
if (card.status !== 'draft')
throw new ApiError(
409,
'RATE_CARD_NOT_DRAFT',
'费率卡已审核或不可审核,请刷新',
);
if (
card.provider_code.toUpperCase() !== 'CEL' ||
card.platform_code.toUpperCase() !== 'OZON'
)
throw new ApiError(
422,
'RATE_CARD_REVIEW_UNSUPPORTED',
'当前审核流程仅支持 Ozon CEL 费率',
);
const detail = await this.read(tx, id);
if (detail.rules_hash !== input.rules_hash)
throw new ApiError(
409,
'RATE_CARD_RULES_CHANGED',
'费率规则已变化,请重新查看并复核',
);
const types = new Set(detail.types.map((type) => type.id));
const typeCodes = new Set(detail.types.map((type) => type.code));
if (
types.size !== 6 ||
[
'ExtraSmall',
'Budget',
'Small',
'Big',
'PremiumSmall',
'PremiumBig',
].some((code) => !typeCodes.has(code)) ||
detail.rules.length !== 16 ||
detail.rules.some((r) => r.status !== 'draft')
)
throw new ApiError(
422,
'RATE_CARD_INCOMPLETE',
'CEL 费率应包含六种产品类型和十六条待审核规则',
);
if (
new Date(input.effective_from).getTime() >
Date.now() + 365 * 86400000
)
throw new ApiError(
422,
'RATE_CARD_EFFECTIVE_DATE_INVALID',
'生效日期不能晚于一年后',
);
const fullRules = (
await tx.query<RuleRow>(
`SELECT r.*,t.code::text AS product_type_code,t.name AS product_type_name,m.name AS transport_name,
t.merchandise_currency_code,t.min_merchandise_value::text,t.max_merchandise_value::text,t.min_value_inclusive,t.max_value_inclusive,
t.min_actual_weight_kg::text,t.min_actual_weight_inclusive,t.max_actual_weight_kg::text,t.max_sum_dimensions_cm::text,t.max_longest_side_cm::text,t.max_second_longest_side_cm::text,
t.billing_basis::text,t.volume_divisor::text,t.volume_condition_sum_gt_cm::text,t.volume_round_scale,t.billing_round_scale,t.max_volume_weight_kg::text,t.volume_limit_round_scale,m.status::text AS transport_status
FROM atlas.logistics_rate_rules r JOIN atlas.logistics_product_types t ON t.id=r.product_type_id
JOIN atlas.logistics_transport_methods m ON m.id=r.transport_method_id WHERE t.rate_card_id=$1 FOR SHARE OF r,t,m`,
[id],
)
).rows;
if (
fullRules.some(
(rule) =>
rule.surcharge_rules.length > 0 ||
(rule as RuleRow & { transport_status: string })
.transport_status !== 'active',
)
)
throw new ApiError(
422,
'RATE_CARD_UNSUPPORTED_RULE',
'存在尚未支持的附加费或停用运输方式',
);
const covered = new Set<string>();
const invoiceReferences = new Set<string>();
for (const [index, sample] of input.samples.entries()) {
const invoiceReference = sample.invoice_reference
.trim()
.toLocaleLowerCase();
if (invoiceReferences.has(invoiceReference))
throw new ApiError(
422,
'RATE_CARD_SAMPLE_DUPLICATE',
'账单编号不能重复使用',
);
invoiceReferences.add(invoiceReference);
const rule = fullRules.find((r) => r.id === sample.rule_id);
if (!rule)
throw new ApiError(
422,
'RATE_CARD_SAMPLE_INVALID',
`第 ${index + 1} 条账单不属于当前费率卡`,
);
covered.add(rule.product_type_id);
if (
!logisticsMerchandiseFits(
rule,
sample.merchandise_value_cny,
)
)
throw new ApiError(
422,
'RATE_CARD_SAMPLE_INVALID',
`第 ${index + 1} 条账单货值不在该产品类型范围`,
);
if (D(sample.shipping_weight_g).lt(1))
throw new ApiError(
422,
'RATE_CARD_SAMPLE_INVALID',
`第 ${index + 1} 条账单重量至少 1 g`,
);
const fee = calculateLogisticsFee(rule, {
shipping_weight_g: sample.shipping_weight_g,
length_mm: sample.length_mm,
width_mm: sample.width_mm,
height_mm: sample.height_mm,
status: 'confirmed',
});
if (
'reason' in fee ||
fee.logistics
.sub(sample.billed_logistics_cny)
.abs()
.gt('0.01')
)
throw new ApiError(
422,
'RATE_CARD_SAMPLE_MISMATCH',
`第 ${index + 1} 条账单与费率计算不一致`,
);
}
if ([...types].some((type) => !covered.has(type)))
throw new ApiError(
422,
'RATE_CARD_SAMPLE_INCOMPLETE',
'每种 CEL 产品类型至少需要一条真实账单样本',
);
await tx.query(
`UPDATE atlas.logistics_rate_rules r SET status='active',updated_at=now() FROM atlas.logistics_product_types t WHERE r.product_type_id=t.id AND t.rate_card_id=$1`,
[id],
);
await tx.query(
`UPDATE atlas.logistics_rate_cards SET status='active',effective_from=$2,reviewed_by_user_id=$3,reviewed_at=now(),updated_at=now() WHERE id=$1`,
[id, input.effective_from, userId],
);
await tx.query(
`INSERT INTO atlas.logistics_rate_card_reviews(rate_card_id,reviewer_user_id,idempotency_key,request_hash,rules_hash,source_reference,review_note,samples,effective_from)
VALUES($1,$2,$3,$4,$5,$6,$7,$8::jsonb,$9)`,
[
id,
userId,
key,
requestHash,
input.rules_hash,
input.source_reference,
input.review_note,
JSON.stringify(input.samples),
input.effective_from,
],
);
return this.read(tx, id);
});
}
}
+309
View File
@@ -0,0 +1,309 @@
import { normalizePricingInput } from '@atlas/contracts';
import { randomUUID, createHash } from 'node:crypto';
import { Decimal } from 'decimal.js';
import type { PricingOption, PricingPreviewRequest } from '@atlas/contracts';
Decimal.set({ precision: 40, rounding: Decimal.ROUND_HALF_UP });
export const D = (value: Decimal.Value) => new Decimal(value);
export function fingerprint(value: unknown): string {
const sort = (v: unknown): unknown =>
v instanceof Date
? v.toISOString()
: Array.isArray(v)
? v.map(sort)
: v && typeof v === 'object'
? Object.fromEntries(
Object.entries(v)
.sort(([a], [b]) => a.localeCompare(b))
.map(([k, x]) => [k, sort(x)]),
)
: v;
return createHash('sha256')
.update(JSON.stringify(sort(value)))
.digest('hex');
}
export type LogisticsRule = {
id: string;
product_type_id: string;
transport_method_id: string;
transport_name: string;
product_type_name: string;
fixed_fee_cny: string;
per_billable_kg_fee_cny: string;
min_charge_cny: string | null;
surcharge_rules: unknown[];
merchandise_currency_code: string;
min_merchandise_value: string | null;
max_merchandise_value: string | null;
min_value_inclusive: boolean;
max_value_inclusive: boolean;
min_actual_weight_kg: string;
min_actual_weight_inclusive: boolean;
max_actual_weight_kg: string | null;
max_sum_dimensions_cm: string | null;
max_longest_side_cm: string | null;
max_second_longest_side_cm: string | null;
billing_basis: string;
volume_divisor: string | null;
volume_condition_sum_gt_cm: string | null;
volume_round_scale: number | null;
billing_round_scale: number | null;
max_volume_weight_kg: string | null;
volume_limit_round_scale: number | null;
};
export type CommissionRule = {
category_name?: string;
source?: string;
effective_from?: string | Date;
id: string | null;
currency_code: string;
min_price: string | null;
max_price: string | null;
min_inclusive: boolean;
max_inclusive: boolean;
commission_rate: string;
version: string;
};
const inRange = (
n: Decimal,
min: string | null,
max: string | null,
includeMin: boolean,
includeMax: boolean,
) =>
(min === null || (includeMin ? n.gte(min) : n.gt(min))) &&
(max === null || (includeMax ? n.lte(max) : n.lt(max)));
const ceil = (n: Decimal, scale: number | null) =>
scale === null ? n : n.toDecimalPlaces(scale, Decimal.ROUND_CEIL);
export const normalizeInput = normalizePricingInput;
/** 审核样本与正式报价共用包装适用性和运费计算,避免两套规则产生不同结果。 */
export function calculateLogisticsFee(
r: LogisticsRule,
packageSpec: PricingPreviewRequest['package'],
): { billable: Decimal; logistics: Decimal } | { reason: string } {
if (r.surcharge_rules.length)
return { reason: '此规则含尚未支持的附加费,需完成审核后使用' };
const weight = D(packageSpec.shipping_weight_g).div(1000);
const sides = [
packageSpec.length_mm,
packageSpec.width_mm,
packageSpec.height_mm,
]
.map((v) => D(v).div(10))
.sort((a, b) => b.cmp(a));
const sum = sides.reduce((a, b) => a.add(b), D(0));
if (
!inRange(
weight,
r.min_actual_weight_kg,
r.max_actual_weight_kg,
r.min_actual_weight_inclusive,
true,
) ||
(r.max_sum_dimensions_cm && sum.gt(r.max_sum_dimensions_cm)) ||
(r.max_longest_side_cm && sides[0].gt(r.max_longest_side_cm)) ||
(r.max_second_longest_side_cm &&
sides[1].gt(r.max_second_longest_side_cm))
)
return { reason: '包装重量或尺寸超出物流范围' };
let billable = weight;
if (r.billing_basis !== 'actual_only') {
if (!r.volume_divisor || D(r.volume_divisor).lte(0))
return { reason: '体积重规则无效' };
const volume = sides
.reduce((a, b) => a.mul(b), D(1))
.div(r.volume_divisor);
if (
r.max_volume_weight_kg &&
ceil(volume, r.volume_limit_round_scale).gt(r.max_volume_weight_kg)
)
return { reason: '体积重超限' };
if (
r.billing_basis === 'max_actual_volume' ||
(r.volume_condition_sum_gt_cm &&
sum.gt(r.volume_condition_sum_gt_cm))
)
billable = Decimal.max(weight, ceil(volume, r.volume_round_scale));
}
billable = ceil(billable, r.billing_round_scale);
const logistics = Decimal.max(
billable.mul(r.per_billable_kg_fee_cny).add(r.fixed_fee_cny),
r.min_charge_cny ?? '0',
);
return { billable, logistics };
}
export function logisticsMerchandiseFits(
r: LogisticsRule,
valueCny: string,
): boolean {
return (
r.merchandise_currency_code === 'CNY' &&
inRange(
D(valueCny),
r.min_merchandise_value,
r.max_merchandise_value,
r.min_value_inclusive,
r.max_value_inclusive,
)
);
}
/** 枚举自洽候选;先舍入目标币种售价,再回验佣金档与货值档,避免跨档误报。 */
export function calculatePricing(
input: PricingPreviewRequest,
currency: string,
fx: string,
rules: LogisticsRule[],
commissions: CommissionRule[],
) {
if (!['CNY', 'RUB'].includes(currency))
throw new Error('门店币种仅支持 CNY / RUB');
const p = input.pricing,
exchange = D(fx),
purchase = D(input.purchase_cost_cny),
domestic = D(p.domestic_shipping_and_label_fee_cny);
const options: PricingOption[] = [],
unavailable_options: Array<{
transport_method_id: string;
reason: string;
}> = [];
for (const transport of p.transport_method_ids) {
const reasons = new Set<string>();
for (const r of rules.filter(
(r) => r.transport_method_id === transport,
)) {
const fee = calculateLogisticsFee(r, input.package);
if ('reason' in fee) {
reasons.add(fee.reason);
continue;
}
const { billable, logistics } = fee;
for (const c of commissions) {
const totalRate = D(c.commission_rate)
.add(p.ad_rate)
.add(p.last_mile_rate)
.add(p.withdrawal_rate)
.add(p.other_rate);
if (totalRate.gte(1)) {
reasons.add('总扣费率达到或超过 100%');
continue;
}
const factor = currency === 'RUB' ? exchange : D(1);
const sale =
p.mode === 'fixed_sale'
? D(p.selling_price!)
: purchase
.mul(D(1).add(p.desired_markup_rate))
.add(logistics)
.add(domestic)
.div(D(1).sub(totalRate))
.mul(factor);
const rounded = sale.toDecimalPlaces(
2,
p.mode === 'fixed_sale'
? Decimal.ROUND_HALF_UP
: Decimal.ROUND_CEIL,
),
cny = rounded.div(factor),
rub = cny.mul(exchange);
if (
rounded.lte(0) ||
!inRange(
c.currency_code === 'RUB' ? rub : cny,
c.min_price,
c.max_price,
c.min_inclusive,
c.max_inclusive,
) ||
!inRange(
r.merchandise_currency_code === 'RUB' ? rub : cny,
r.min_merchandise_value,
r.max_merchandise_value,
r.min_value_inclusive,
r.max_value_inclusive,
)
) {
reasons.add('售价不在该物流货值区间或佣金档内');
continue;
}
const profit = cny
.mul(D(1).sub(totalRate))
.sub(purchase)
.sub(logistics)
.sub(domestic),
list = rounded
.div(D(1).sub(p.list_price_discount_rate))
.toDecimalPlaces(2, Decimal.ROUND_CEIL);
if (
[
rounded,
list,
rub,
list.div(factor).mul(exchange),
profit.mul(exchange),
logistics,
].some((v) => v.abs().gte('100000000000000'))
) {
reasons.add('金额超过可保存范围,请调整费率或输入');
continue;
}
options.push({
id: randomUUID(),
can_confirm: profit.gte(0),
transport_method_id: transport,
transport_name: r.transport_name,
product_type_id: r.product_type_id,
product_type_name: r.product_type_name,
logistics_rate_rule_id: r.id,
commission_rule_id: c.id,
commission_rate: c.commission_rate,
...(c.id ? {commission_details: {
category_name: c.category_name ?? '',currency_code:c.currency_code,version:c.version,source:c.source ?? 'manual_rule',
min_price:c.min_price,max_price:c.max_price,min_inclusive:c.min_inclusive,max_inclusive:c.max_inclusive,commission_rate:c.commission_rate,
}} : {}),
selling_price: rounded.toFixed(2),
list_price: list.toFixed(2),
currency_code: currency,
selling_price_rub: rub.toFixed(4),
list_price_rub: list.div(factor).mul(exchange).toFixed(4),
merchandise_value_cny: cny.toFixed(4),
profit_cny: profit.toFixed(2),
profit_rub: profit.mul(exchange).toFixed(4),
margin_rate: profit.div(cny).toFixed(6),
billable_weight_kg: billable.toFixed(3),
fees: {
logistics_cny: logistics.toFixed(4),
domestic_cny: domestic.toFixed(4),
commission_cny: cny.mul(c.commission_rate).toFixed(4),
ad_cny: cny.mul(p.ad_rate).toFixed(4),
last_mile_cny: cny.mul(p.last_mile_rate).toFixed(4),
withdrawal_cny: cny.mul(p.withdrawal_rate).toFixed(4),
other_cny: cny.mul(p.other_rate).toFixed(4),
},
warnings: [
...(profit.lt(0) ? ['亏损报价不能确认'] : []),
...(profit.lt(purchase.mul(p.desired_markup_rate))
? ['未达到目标加价利润']
: []),
...(!c.id ? ['使用临时佣金率'] : []),
],
});
}
}
if (!options.some((o) => o.transport_method_id === transport))
unavailable_options.push({
transport_method_id: transport,
reason: [...reasons].join(';') || '没有可用规则',
});
}
options.sort((a, b) => D(a.selling_price).cmp(b.selling_price));
const recommended =
options.find(
(o) => o.transport_method_id === p.default_transport_method_id,
) ?? (!p.default_transport_method_id ? options[0] : undefined);
return {
options,
unavailable_options,
recommended_option_id: recommended?.id ?? null,
};
}
+199
View File
@@ -0,0 +1,199 @@
import {
Body,
Controller,
Delete,
Get,
Headers,
Inject,
Param,
Patch,
Post,
Query,
Req,
Res,
} from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import { z } from 'zod';
import {
pricingPreviewRequestSchema,
pricingPolicyRequestSchema,
type Workspace,
} from '@atlas/contracts';
import { CurrentWorkspace } from '../workspace/current-workspace.decorator.js';
import type { AuthenticatedRequest } from '../auth/auth.guard.js';
import { ApiError } from '../common/api-error.js';
import { PricingService } from './pricing.service.js';
type Response = { status: (status: number) => unknown };
function parse<T>(schema: z.ZodType<T>, value: unknown): T {
const result = schema.safeParse(value);
if (!result.success)
throw new ApiError(
422,
'VALIDATION_ERROR',
'请检查定价输入',
result.error.issues.map((i) => ({
field: i.path.join('.'),
message: i.message,
})),
);
return result.data;
}
function key(value: string | undefined) {
if (!value || value.length > 200)
throw new ApiError(
400,
'IDEMPOTENCY_KEY_REQUIRED',
'需要 Idempotency-Key',
);
return value;
}
@ApiTags('Pricing')
@ApiBearerAuth('access-token')
@Controller()
export class PricingController {
constructor(
@Inject(PricingService) private readonly service: PricingService,
) {}
@Get('pricing/commission-rules') async commissionRules(
@CurrentWorkspace() w: Workspace,
@Query('store_id') store: string,
@Query('platform_product_category_id') category: string | undefined,
@Query('description_category_id') external: string | undefined,
) {
if (Boolean(category) === Boolean(external)) throw new ApiError(422,'VALIDATION_ERROR','请指定一种类目 ID');
return {data:await this.service.commissionRules(w.id,parse(z.uuid(),store),category ? parse(z.uuid(),category) : undefined,external ? parse(z.string().regex(/^[1-9]\d{0,17}$/),external) : undefined)};
}
@Get('pricing/categories') async categories(
@CurrentWorkspace() w: Workspace,
@Query('store_id') store: string,
@Query('q') q: string | undefined,
) {
return {
data: await this.service.categories(
w.id,
parse(z.uuid(), store),
parse(z.string().max(200), q ?? ''),
),
};
}
@Get('pricing/catalog') async catalog(
@CurrentWorkspace() w: Workspace,
@Query('store_id') store: string,
) {
return {
data: await this.service.catalog(w.id, parse(z.uuid(), store)),
};
}
@Get('stores/:id/pricing-policies') async policies(
@CurrentWorkspace() w: Workspace,
@Param('id') id: string,
) {
return {
data: (await this.service.catalog(w.id, parse(z.uuid(), id)))
.policies,
};
}
@Post('pricing/quote-previews') async preview(
@CurrentWorkspace() w: Workspace,
@Req() req: AuthenticatedRequest,
@Headers('idempotency-key') k: string | undefined,
@Body() body: unknown,
@Res({ passthrough: true }) res: Response,
) {
const r = await this.service.preview(
w.id,
req.auth.userId,
key(k),
parse(pricingPreviewRequestSchema, body),
);
res.status(r.status);
return r.body;
}
@Post('stores/:id/pricing-policies') async create(
@CurrentWorkspace() w: Workspace,
@Req() req: AuthenticatedRequest,
@Param('id') id: string,
@Headers('idempotency-key') k: string | undefined,
@Body() body: unknown,
@Res({ passthrough: true }) res: Response,
) {
const r = await this.service.writePolicy(
w.id,
req.auth.userId,
key(k),
parse(z.uuid(), id),
null,
'create',
parse(pricingPolicyRequestSchema, body),
null,
);
res.status(r.status);
return r.body;
}
@Patch('pricing-policies/:id') async update(
@CurrentWorkspace() w: Workspace,
@Req() req: AuthenticatedRequest,
@Param('id') id: string,
@Headers('idempotency-key') k: string | undefined,
@Headers('if-match') match: string | undefined,
@Body() body: unknown,
) {
if (!match || !/^(?:[1-9]\d*|"[1-9]\d*")$/.test(match))
throw new ApiError(
428,
'PRECONDITION_REQUIRED',
'需要方案版本 If-Match',
);
return (
await this.service.writePolicy(
w.id,
req.auth.userId,
key(k),
null,
parse(z.uuid(), id),
'update',
parse(pricingPolicyRequestSchema, body),
Number(match.replaceAll('"', '')),
)
).body;
}
@Post('pricing-policies/:id/set-default') async defaultPolicy(
@CurrentWorkspace() w: Workspace,
@Req() req: AuthenticatedRequest,
@Param('id') id: string,
@Headers('idempotency-key') k: string | undefined,
@Res({ passthrough: true }) res: Response,
) {
const r = await this.service.writePolicy(
w.id,
req.auth.userId,
key(k),
null,
parse(z.uuid(), id),
'default',
null,
null,
);
res.status(r.status);
return r.body;
}
@Delete('pricing-policies/:id') async archive(
@CurrentWorkspace() w: Workspace,
@Req() req: AuthenticatedRequest,
@Param('id') id: string,
@Headers('idempotency-key') k: string | undefined,
) {
return (
await this.service.writePolicy(
w.id,
req.auth.userId,
key(k),
null,
parse(z.uuid(), id),
'archive',
null,
null,
)
).body;
}
}
+21
View File
@@ -0,0 +1,21 @@
import { CommissionImportController } from './commission-import.controller.js';
import { CommissionImportService } from './commission-import.service.js';
import { Module } from '@nestjs/common';
import { IdempotencyService } from '../common/idempotency.service.js';
import { PricingController } from './pricing.controller.js';
import { PricingService } from './pricing.service.js';
import { PricingAdminController } from './pricing-admin.controller.js';
import { PricingAdminService } from './pricing-admin.service.js';
import { SystemAdminGuard } from '../platform-catalog-admin/system-admin.guard.js';
@Module({
controllers: [PricingController, PricingAdminController, CommissionImportController],
providers: [
PricingService,
CommissionImportService,
PricingAdminService,
SystemAdminGuard,
IdempotencyService,
],
exports: [PricingService],
})
export class PricingModule {}
+867
View File
@@ -0,0 +1,867 @@
import { readCommissionRules } from './commission-rules.js';
import type { CommissionRulesResult } from '@atlas/contracts';
import { Inject, Injectable } from '@nestjs/common';
import { randomUUID } from 'node:crypto';
import type { Database, DatabaseTransaction } from '@atlas/database';
import type {
PricingCatalog,
PricingPolicy,
PricingPolicyRequest,
PricingPreview,
PricingPreviewRequest,
PricingParameters,
SaveProductEditorDraftRequest,
} from '@atlas/contracts';
import { pricingPreviewRequestSchema } from '@atlas/contracts';
import { DATABASE } from '../database/database.module.js';
import { APP_CONFIG, type AppConfig } from '../config/app-config.module.js';
import { ApiError } from '../common/api-error.js';
import { IdempotencyService } from '../common/idempotency.service.js';
import {
calculatePricing,
D,
fingerprint,
normalizeInput,
type CommissionRule,
type LogisticsRule,
} from './pricing-engine.js';
type Tx = DatabaseTransaction;
type Store = {
id: string;
name: string;
platform_id: string;
currency_code: string;
};
type PolicyRow = {
id: string;
store_id: string;
name: string;
note: string | null;
revision: number;
is_default: boolean;
rate_card_id: string;
default_transport_method_id: string | null;
domestic_shipping_and_label_fee_cny: string;
ad_rate: string;
last_mile_rate: string;
withdrawal_rate: string;
other_rate: string;
desired_markup_rate: string;
list_price_discount_rate: string;
allow_commission_override: boolean;
fx_strategy: string;
fixed_cny_to_rub_rate: string | null;
transport_ids: string[];
};
const active = `status='active' AND effective_from<=now() AND (effective_to IS NULL OR effective_to>now())`;
@Injectable()
export class PricingService {
constructor(
@Inject(DATABASE) private readonly db: Database,
@Inject(IdempotencyService)
private readonly idempotency: IdempotencyService,
@Inject(APP_CONFIG) private readonly config: AppConfig,
) {}
private get usableCardCondition() {
const reviewed = `(${active} AND reviewed_at IS NOT NULL AND reviewed_by_user_id IS NOT NULL)`;
return this.config.pricingAllowDraftRates
? `(${reviewed} OR status='draft')`
: reviewed;
}
async store(tx: Tx, id: string): Promise<Store> {
const r = await tx.query<Store>(
`SELECT id,name,platform_id,currency_code FROM atlas.stores WHERE id=$1 AND deleted_at IS NULL`,
[id],
);
if (!r.rows[0])
throw new ApiError(404, 'STORE_NOT_FOUND', '门店不存在');
if (!['CNY', 'RUB'].includes(r.rows[0].currency_code))
throw new ApiError(
422,
'PRICING_CURRENCY_UNSUPPORTED',
'定价仅支持 CNY / RUB 门店',
);
return r.rows[0];
}
async policies(tx: Tx, storeId: string): Promise<PricingPolicy[]> {
const result = await tx.query<PolicyRow>(
`SELECT p.*,ARRAY(SELECT transport_method_id FROM atlas.pricing_policy_transport_methods t WHERE t.pricing_policy_id=p.id AND is_enabled ORDER BY sort_order,transport_method_id) AS transport_ids FROM atlas.pricing_policies p WHERE p.store_id=$1 AND p.status='active' ORDER BY p.is_default DESC,p.name`,
[storeId],
);
return result.rows.map((p) => ({
id: p.id,
store_id: p.store_id,
name: p.name,
note: p.note,
revision: p.revision,
is_default: p.is_default,
allow_commission_override: p.allow_commission_override,
parameters: {
pricing_policy_id: p.id,
rate_card_id: p.rate_card_id,
transport_method_ids: p.transport_ids,
default_transport_method_id: p.default_transport_method_id,
domestic_shipping_and_label_fee_cny:
p.domestic_shipping_and_label_fee_cny,
ad_rate: p.ad_rate,
last_mile_rate: p.last_mile_rate,
withdrawal_rate: p.withdrawal_rate,
other_rate: p.other_rate,
desired_markup_rate: p.desired_markup_rate,
list_price_discount_rate: p.list_price_discount_rate,
mode: 'target_markup',
selling_price: null,
temporary_commission_rate: null,
fx:
p.fx_strategy === 'fixed'
? {
mode: 'policy_fixed',
rate: p.fixed_cny_to_rub_rate!,
}
: { mode: 'system' },
},
}));
}
async commissionRules(workspaceId: string, storeId: string, categoryId?: string, externalId?: string): Promise<CommissionRulesResult> {
return this.db.workspaceTransaction(workspaceId, async tx => {
const store = await this.store(tx, storeId);
const categories = (await tx.query<{id:string}>(`SELECT id FROM atlas.platform_product_categories WHERE platform_id=$1 AND is_active AND ($2::uuid IS NULL OR id=$2) AND ($3::text IS NULL OR external_category_id=$3) ORDER BY id`, [store.platform_id, categoryId ?? null, externalId ?? null])).rows;
if (!categories.length) throw new ApiError(422, 'PRICING_CATEGORY_REQUIRED', '请选择当前平台的有效类目');
let resolvedCategoryId = categories[0].id;
// 外部类目下可以有多个类型,但不能将相互冲突的映射折叠成一个费率。
if (externalId) {
const mapped = await tx.query<{id:string; platform_product_category_id:string}>(`SELECT DISTINCT commission_category_id AS id,platform_product_category_id FROM atlas.platform_product_category_commission_mappings WHERE platform_id=$1 AND platform_product_category_id=ANY($2::uuid[]) AND fulfillment='rfbs' AND market_code='RU' AND status='active' AND effective_from<=now() AND (effective_to IS NULL OR effective_to>now())`,[store.platform_id,categories.map(c=>c.id)]);
if (new Set(mapped.rows.map(row => row.id)).size>1) throw new ApiError(422,'COMMISSION_MAPPING_CONFLICT','此类目下的商品类型使用不同规则,请指定具体商品类型');
resolvedCategoryId = mapped.rows[0]?.platform_product_category_id ?? resolvedCategoryId;
}
const {commissions} = await readCommissionRules(tx,store.platform_id,resolvedCategoryId,'rfbs');
if (!commissions.length) throw new ApiError(422,'PRICING_COMMISSION_UNAVAILABLE','该类目没有有效佣金规则');
const first=commissions[0];
return {category_name:first.category_name ?? '',currency_code:first.currency_code,version:first.version,source:first.source ?? 'manual_rule',effective_from:new Date(first.effective_from!).toISOString(),bands:commissions.map(c=>({id:c.id!,min_price:c.min_price,max_price:c.max_price,min_inclusive:c.min_inclusive,max_inclusive:c.max_inclusive,commission_rate:c.commission_rate}))};
});
}
async categories(workspaceId: string, storeId: string, q: string) {
return this.db.workspaceTransaction(workspaceId, async (tx) => {
const store = await this.store(tx, storeId);
return (
await tx.query<{ id: string; name: string; path: string }>(
`SELECT id,name,path FROM atlas.platform_product_categories WHERE platform_id=$1 AND is_active AND (name ILIKE $2 OR path ILIKE $2) ORDER BY path LIMIT 50`,
[store.platform_id, `%${q}%`],
)
).rows;
});
}
async exchange(tx: Tx) {
const r = await tx.query<{
id: string;
rate: string;
effective_on: string;
stale: boolean;
}>(
`SELECT id,rate::text,effective_on::text,(effective_on::timestamp AT TIME ZONE 'Europe/Moscow')<now()-interval '72 hours' AS stale FROM atlas.exchange_rates WHERE base_currency_code='CNY' AND quote_currency_code='RUB' AND source='cbr' AND kind='official_reference' ORDER BY effective_on DESC,fetched_at DESC LIMIT 1`,
);
return r.rows[0] ?? null;
}
async catalog(workspace: string, storeId: string): Promise<PricingCatalog> {
return this.db.workspaceTransaction(workspace, async (tx) => {
const store = await this.store(tx, storeId);
const cards = await tx.query<{
id: string;
name: string;
version: string;
development_only: boolean;
transports: Array<{ id: string; name: string }>;
}>(
`SELECT c.id,c.source_name AS name,c.version,(c.status='draft') AS development_only,coalesce((SELECT jsonb_agg(jsonb_build_object('id',t.id,'name',t.name) ORDER BY t.name) FROM atlas.logistics_transport_methods t WHERE t.rate_card_id=c.id AND t.status='active'),'[]') AS transports FROM atlas.logistics_rate_cards c WHERE c.platform_id=$1 AND c.fulfillment='rfbs' AND c.market_code='RU' AND ${this.usableCardCondition} ORDER BY c.status='active' DESC,c.effective_from DESC`,
[store.platform_id],
);
const pendingCards = await tx.query<{
id: string;
name: string;
version: string;
status: 'draft';
}>(
`SELECT id,source_name AS name,version,status FROM atlas.logistics_rate_cards WHERE platform_id=$1 AND fulfillment='rfbs' AND market_code='RU' AND status='draft' ORDER BY created_at DESC`,
[store.platform_id],
);
return {
store,
rate_cards: cards.rows,
pending_rate_cards: pendingCards.rows,
policies: await this.policies(tx, storeId),
exchange_rate: await this.exchange(tx),
};
});
}
/** 保存确认时复读规则;草稿卡是否参与新报价由配置控制。 */
async rules(tx: Tx, input: PricingPreviewRequest, store: Store) {
const card = (
await tx.query<{ id: string; version: string; status: string; updated_at: Date }>(
`SELECT * FROM atlas.logistics_rate_cards WHERE id=$1 AND platform_id=$2 AND fulfillment=$3 AND market_code='RU' AND ${this.usableCardCondition} FOR SHARE`,
[
input.pricing.rate_card_id,
store.platform_id,
input.fulfillment,
],
)
).rows[0];
if (!card)
throw new ApiError(
422,
'PRICING_RULES_UNAVAILABLE',
'没有可用的物流费率,请检查费率卡状态和草稿卡使用配置',
);
const category = await tx.query(
`SELECT id FROM atlas.platform_product_categories WHERE id=$1 AND platform_id=$2 AND is_active`,
[input.platform_product_category_id, store.platform_id],
);
if (!category.rowCount)
throw new ApiError(
422,
'PRICING_CATEGORY_REQUIRED',
'请选择当前平台的有效类目',
);
if (input.pricing.pricing_policy_id)
await tx.query(
'SELECT id FROM atlas.pricing_policies WHERE id=$1 FOR SHARE',
[input.pricing.pricing_policy_id],
);
const policy = input.pricing.pricing_policy_id
? (await this.policies(tx, store.id)).find(
(p) => p.id === input.pricing.pricing_policy_id,
)
: null;
if (
input.pricing.pricing_policy_id &&
(!policy || policy.parameters.rate_card_id !== card.id)
)
throw new ApiError(
422,
'PRICING_POLICY_UNAVAILABLE',
'方案不可用或费率卡不匹配',
);
const logistics = (
await tx.query<LogisticsRule>(
`SELECT t.*,r.id,r.product_type_id,r.transport_method_id,m.name AS transport_name,t.name AS product_type_name,r.fixed_fee_cny,r.per_billable_kg_fee_cny,r.min_charge_cny,r.surcharge_rules,r.updated_at AS rule_updated_at,m.updated_at AS transport_updated_at FROM atlas.logistics_rate_rules r JOIN atlas.logistics_product_types t ON t.id=r.product_type_id JOIN atlas.logistics_transport_methods m ON m.id=r.transport_method_id AND m.rate_card_id=t.rate_card_id WHERE t.rate_card_id=$1 AND r.status=$3 AND m.status='active' AND m.id=ANY($2::uuid[]) ORDER BY r.id FOR SHARE OF r,t,m`,
[card.id, input.pricing.transport_method_ids, card.status],
)
).rows;
const official = await readCommissionRules(tx, store.platform_id, input.platform_product_category_id, input.fulfillment);
const mapping = official.mapping;
let commissions = official.commissions;
if (
!commissions.length &&
input.pricing.temporary_commission_rate !== null &&
(!policy || policy.allow_commission_override)
)
commissions = [
{
id: null,
currency_code: 'RUB',
min_price: null,
max_price: null,
min_inclusive: true,
max_inclusive: true,
commission_rate: input.pricing.temporary_commission_rate,
version: 'temporary',
},
];
if (!commissions.length)
throw new ApiError(
422,
'PRICING_COMMISSION_UNAVAILABLE',
'该类目尚无有效佣金规则;请补充已核实的临时佣金率,并使用允许临时佣金的方案或本次自定义参数',
);
if (
commissions.some((c) => c.id) &&
input.pricing.temporary_commission_rate !== null
)
throw new ApiError(
422,
'PRICING_COMMISSION_OVERRIDE_FORBIDDEN',
'已有正式佣金规则,不能临时覆盖',
);
return {
card,
policy,
logistics,
commissions,
hash: fingerprint({
card,
policy,
logistics,
commissions,
mapping,
}),
};
}
async preview(
workspaceId: string,
userId: string,
key: string,
input: PricingPreviewRequest,
) {
return this.idempotency.run(
{
workspaceId,
userId,
key,
method: 'POST',
path: '/api/pricing/quote-previews',
request: input,
},
async (tx) => {
const store = await this.store(tx, input.store_id);
if (input.binding) {
const binding = input.binding;
const profile = (
await tx.query(
`SELECT id FROM atlas.product_platform_profiles WHERE id=$1 AND platform_id=$2`,
[binding.profile_id, store.platform_id],
)
).rows[0];
if (!profile)
throw new ApiError(
404,
'PROFILE_NOT_FOUND',
'商品发布配置不存在',
);
if (binding.variant_id) {
const listing = (
await tx.query<{ id: string; status: string; store_id: string }>(
`SELECT l.id,l.status,l.store_id FROM atlas.product_variant_platform_listings l JOIN atlas.product_variants v ON v.id=l.product_variant_id WHERE l.profile_id=$1 AND l.product_variant_id=$2 AND v.deleted_at IS NULL`,
[binding.profile_id, binding.variant_id],
)
).rows[0];
if (!listing)
throw new ApiError(
422,
'PRICING_VARIANT_UNAVAILABLE',
'SKU 不存在或已删除',
);
if (listing.status === 'imported' && listing.store_id !== store.id)
throw new ApiError(
422,
'PRICING_VARIANT_STORE_MISMATCH',
'已上架 SKU 请按首次导入店铺试算',
);
}
}
const rules = await this.rules(tx, input, store),
exchange = await this.exchange(tx);
let fx: string, origin: string;
if (input.pricing.fx.mode === 'policy_fixed') {
const fixed = rules.policy?.parameters.fx;
if (fixed?.mode !== 'policy_fixed' || !fixed.rate)
throw new ApiError(
422,
'PRICING_FX_REQUIRED',
'方案没有固定汇率',
);
fx = fixed.rate;
origin = 'policy_fixed';
} else if (input.pricing.fx.mode === 'one_time_override') {
fx = input.pricing.fx.rate!;
origin = 'quote_override';
} else {
if (!exchange)
throw new ApiError(
422,
'PRICING_FX_UNAVAILABLE',
'尚无 CBR 汇率,请稍后重试或明确填写本次汇率',
);
fx = D(exchange.rate).toDecimalPlaces(8).toString();
origin = 'system_rate';
}
const normalized = normalizeInput(input),
id = randomUUID(),
expires = new Date(Date.now() + 15 * 60_000).toISOString();
const result: PricingPreview = {
preview_id: id,
expires_at: expires,
input_hash: fingerprint(normalized),
...calculatePricing(
normalized,
store.currency_code,
fx,
rules.logistics,
rules.commissions,
),
context: {
currency_code: store.currency_code,
fx_rate: fx,
fx_origin: origin,
rule_version: rules.card.version,
development_only: rules.card.status === 'draft',
exchange_rate:
origin === 'system_rate' ? exchange : null,
warnings:
origin === 'system_rate' && exchange?.stale
? ['CBR 汇率超过 72 小时,请确认参考日期']
: [],
},
};
await tx.query(
`INSERT INTO atlas.pricing_previews(id,workspace_id,user_id,store_id,platform_id,input,input_hash,rules_hash,result,expires_at) VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)`,
[
id,
workspaceId,
userId,
store.id,
store.platform_id,
JSON.stringify(normalized),
result.input_hash,
rules.hash,
JSON.stringify(result),
expires,
],
);
return { status: 201, body: { data: result } };
},
);
}
async writePolicy(
workspaceId: string,
userId: string,
key: string,
storeId: string | null,
id: string | null,
action: 'create' | 'update' | 'default' | 'archive',
input: PricingPolicyRequest | null,
revision: number | null,
) {
return this.idempotency
.run(
{
workspaceId,
userId,
key,
method:
action === 'update'
? 'PATCH'
: action === 'archive'
? 'DELETE'
: 'POST',
path: id
? `/api/pricing-policies/${id}/${action}`
: `/api/stores/${storeId}/pricing-policies`,
request: { input, revision },
},
async (tx) => {
let current:
{ store_id: string; revision: number } | undefined;
if (id) {
current = (
await tx.query<{
store_id: string;
revision: number;
}>(
`SELECT store_id,revision FROM atlas.pricing_policies WHERE id=$1 AND status='active'`,
[id],
)
).rows[0];
if (!current)
throw new ApiError(
404,
'PRICING_POLICY_NOT_FOUND',
'方案不存在',
);
storeId = current.store_id;
}
const store = await this.store(tx, storeId!);
await tx.query(
`SELECT id FROM atlas.stores WHERE id=$1 FOR UPDATE`,
[store.id],
);
if (id)
current = (
await tx.query<{
store_id: string;
revision: number;
}>(
`SELECT store_id,revision FROM atlas.pricing_policies WHERE id=$1 AND status='active' FOR UPDATE`,
[id],
)
).rows[0];
if (id && !current)
throw new ApiError(
404,
'PRICING_POLICY_NOT_FOUND',
'方案不存在',
);
if (action === 'update' && current?.revision !== revision)
throw new ApiError(
409,
'VERSION_CONFLICT',
'方案已更新,请刷新后重试',
);
if (action === 'default') {
await tx.query(
`UPDATE atlas.pricing_policies SET is_default=false,revision=revision+1,updated_at=now() WHERE store_id=$1 AND is_default AND id<>$2`,
[store.id, id],
);
await tx.query(
`UPDATE atlas.pricing_policies SET is_default=true,revision=revision+1,updated_at=now() WHERE id=$1`,
[id],
);
} else if (action === 'archive')
await tx.query(
`UPDATE atlas.pricing_policies SET status='archived',is_default=false,revision=revision+1,updated_at=now() WHERE id=$1`,
[id],
);
else {
const p = input!.parameters;
if (p.fx.mode === 'policy_fixed' && !p.fx.rate)
throw new ApiError(
422,
'PRICING_FX_REQUIRED',
'固定汇率不能为空',
);
if (
p.default_transport_method_id &&
!p.transport_method_ids.includes(
p.default_transport_method_id,
)
)
throw new ApiError(
422,
'PRICING_TRANSPORT_INVALID',
'默认运输方式必须包含在比较列表中',
);
const card = (
await tx.query(
`SELECT id FROM atlas.logistics_rate_cards WHERE id=$1 AND platform_id=$2 AND fulfillment='rfbs' AND market_code='RU' AND ${this.usableCardCondition}`,
[p.rate_card_id, store.platform_id],
)
).rowCount;
const transports = (
await tx.query(
`SELECT id FROM atlas.logistics_transport_methods WHERE rate_card_id=$1 AND status='active' AND id=ANY($2::uuid[])`,
[p.rate_card_id, p.transport_method_ids],
)
).rowCount;
if (
!card ||
transports !== new Set(p.transport_method_ids).size
)
throw new ApiError(
422,
'PRICING_RULES_UNAVAILABLE',
'费率卡或运输方式不可用',
);
const values = [
input!.name,
input!.note,
p.rate_card_id,
p.default_transport_method_id,
p.domestic_shipping_and_label_fee_cny,
p.ad_rate,
p.last_mile_rate,
p.withdrawal_rate,
p.other_rate,
p.desired_markup_rate,
p.list_price_discount_rate,
input!.allow_commission_override,
p.fx.mode === 'policy_fixed' ? 'fixed' : 'system',
p.fx.mode === 'policy_fixed' ? p.fx.rate : null,
];
if (action === 'create') {
id = randomUUID();
await tx.query(
`INSERT INTO atlas.pricing_policies(name,note,rate_card_id,default_transport_method_id,domestic_shipping_and_label_fee_cny,ad_rate,last_mile_rate,withdrawal_rate,other_rate,desired_markup_rate,list_price_discount_rate,allow_commission_override,fx_strategy,fixed_cny_to_rub_rate,id,workspace_id,store_id,platform_id) VALUES(${values.map((_, i) => `$${i + 1}`).join(',')},$15,$16,$17,$18)`,
[
...values,
id,
workspaceId,
store.id,
store.platform_id,
],
);
} else
await tx.query(
`UPDATE atlas.pricing_policies SET name=$1,note=$2,rate_card_id=$3,default_transport_method_id=$4,domestic_shipping_and_label_fee_cny=$5,ad_rate=$6,last_mile_rate=$7,withdrawal_rate=$8,other_rate=$9,desired_markup_rate=$10,list_price_discount_rate=$11,allow_commission_override=$12,fx_strategy=$13,fixed_cny_to_rub_rate=$14,revision=revision+1,updated_at=now() WHERE id=$15`,
[...values, id],
);
await tx.query(
`DELETE FROM atlas.pricing_policy_transport_methods WHERE pricing_policy_id=$1`,
[id],
);
for (const [i, t] of [
...new Set(p.transport_method_ids),
].entries())
await tx.query(
`INSERT INTO atlas.pricing_policy_transport_methods(workspace_id,pricing_policy_id,transport_method_id,sort_order) VALUES($1,$2,$3,$4)`,
[workspaceId, id, t, i],
);
}
return {
status: action === 'create' ? 201 : 200,
body: {
data:
action === 'archive'
? { id, archived: true }
: (await this.policies(tx, store.id)).find(
(p) => p.id === id,
),
},
};
},
)
.catch((error: unknown) => {
if (
error &&
typeof error === 'object' &&
'code' in error &&
error.code === '23505'
)
throw new ApiError(
409,
'PRICING_POLICY_NAME_CONFLICT',
'此门店已存在同名方案,请使用其他名称',
);
throw error;
});
}
/** 必须在 editor-draft 的现有事务中调用,任何确认失败使商品修改一起回滚。 */
async persistDraft(
tx: Tx,
workspaceId: string,
userId: string,
profileId: string,
input: SaveProductEditorDraftRequest,
created: Array<{ client_key: string; id: string }>,
) {
for (const v of input.variants) {
const variantId =
v.id ?? created.find((c) => c.client_key === v.client_key)!.id;
const listing = (
await tx.query<{
id: string;
status: string;
store_id: string;
pricing_draft: PricingParameters | null;
pricing_quote_id: string | null;
}>(
`SELECT id,status,store_id,pricing_draft,pricing_quote_id FROM atlas.product_variant_platform_listings WHERE profile_id=$1 AND product_variant_id=$2 FOR UPDATE`,
[profileId, variantId],
)
).rows[0];
// 已上架 SKU 只有显式应用新报价时更新本地记录,普通草稿保存保留历史报价。
if (listing.status === 'imported' && !v.pricing_selection) continue;
const state = (
await tx.query<{
product_id: string;
platform_id: string;
store_id: string;
currency_code: string;
category_id: string;
fulfillment: 'rfbs';
purchase_cost_cny: string;
package: PricingPreviewRequest['package'];
}>(
`SELECT v.product_id,p.platform_id,s.id AS store_id,s.currency_code,p.platform_product_category_id AS category_id,p.fulfillment,v.purchase_cost_cny::text,jsonb_build_object('shipping_weight_g',coalesce(v.shipping_weight_g,g.default_shipping_weight_g)::text,'length_mm',coalesce(v.package_length_mm,g.default_package_length_mm)::text,'width_mm',coalesce(v.package_width_mm,g.default_package_width_mm)::text,'height_mm',coalesce(v.package_height_mm,g.default_package_height_mm)::text,'status',coalesce(v.package_spec_status,g.package_spec_status)) AS package FROM atlas.product_variants v JOIN atlas.products g ON g.id=v.product_id JOIN atlas.product_platform_profiles p ON p.id=$1 JOIN atlas.stores s ON s.id=coalesce($3::uuid,p.target_store_id) WHERE v.id=$2`,
[
profileId,
variantId,
listing.status === 'imported' ? listing.store_id : null,
],
)
).rows[0];
const params =
v.pricing_draft === undefined
? listing.pricing_draft
: v.pricing_draft;
const parsed = pricingPreviewRequestSchema.safeParse({
store_id: state.store_id,
platform_product_category_id: state.category_id,
fulfillment: state.fulfillment,
purchase_cost_cny:
state.purchase_cost_cny === null
? null
: D(state.purchase_cost_cny).toFixed(2),
package: state.package,
pricing: params,
binding: {
profile_id: profileId,
variant_id: v.id,
client_key: v.id ? null : v.client_key,
},
});
const normalized = parsed.success
? normalizeInput(parsed.data)
: null;
const fail = () => {
throw new ApiError(
409,
'PRICING_PREVIEW_STALE',
`${v.display_name ?? 'SKU'} 的报价已失效,请重新试算或仅保存草稿`,
[
{
field: `variants.${variantId}.pricing_selection`,
code: 'PRICING_PREVIEW_STALE',
message: '报价输入、规则或有效期已变化',
},
],
);
};
if (!v.pricing_selection) {
// 旧报价保留供审计,当前引用仅在输入和归属仍一致时保持有效。
const old = listing.pricing_quote_id
? (
await tx.query<{
input_snapshot: {
input: PricingPreviewRequest;
context: PricingPreview['context'];
};
}>(
`SELECT input_snapshot FROM atlas.pricing_quotes WHERE id=$1`,
[listing.pricing_quote_id],
)
).rows[0]
: null;
const same =
normalized &&
old &&
old.input_snapshot.context.currency_code ===
state.currency_code &&
fingerprint({ ...normalized, binding: null }) ===
fingerprint({
...old.input_snapshot.input,
binding: null,
});
await tx.query(
`UPDATE atlas.product_variant_platform_listings SET pricing_draft=$2,pricing_valid=$3,currency_code=$4,pricing_quote_id=CASE WHEN store_id=$5 THEN pricing_quote_id ELSE NULL END WHERE id=$1`,
[
listing.id,
params ? JSON.stringify(params) : null,
Boolean(same),
state.currency_code,
old?.input_snapshot.input.store_id ?? state.store_id,
],
);
continue;
}
if (!normalized) fail();
const selection = v.pricing_selection;
const preview = (
await tx.query<{
input: PricingPreviewRequest;
input_hash: string;
rules_hash: string;
result: PricingPreview;
valid: boolean;
}>(
`SELECT input,input_hash,rules_hash,result,expires_at>now() AS valid FROM atlas.pricing_previews WHERE id=$1 AND user_id=$2 FOR SHARE`,
[selection.preview_id, userId],
)
).rows[0];
if (
!preview ||
!preview.valid ||
selection.input_hash !== preview.input_hash ||
fingerprint(normalized) !== preview.input_hash
)
fail();
const store = await this.store(tx, state.store_id);
let currentRules: Awaited<ReturnType<PricingService['rules']>>;
try {
currentRules = await this.rules(tx, normalized!, store);
} catch (error) {
if (error instanceof ApiError && error.getStatus() === 422)
return fail();
throw error;
}
if (
currentRules.hash !== preview.rules_hash ||
preview.result.context.currency_code !== state.currency_code
)
fail();
const option = preview.result.options.find(
(o) => o.id === selection.option_id,
);
if (!option || !option.can_confirm) fail();
const o = option!,
quoteId = randomUUID(),
selectedId = randomUUID();
const savedInput = {
...normalized!,
binding: {
profile_id: profileId,
variant_id: variantId,
client_key: null,
},
};
await tx.query(
`INSERT INTO atlas.pricing_quotes(id,workspace_id,store_id,platform_id,pricing_policy_id,product_variant_id,product_id,profile_id,platform_product_category_id,fulfillment,purchase_cost_cny,package_weight_kg,package_length_cm,package_width_cm,package_height_cm,fx_cny_to_rub,fx_rate_origin,input_snapshot,status,selected_option_id,calculated_at,confirmed_at,created_by_user_id,input_hash) VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,'confirmed',$19,now(),now(),$20,$21)`,
[
quoteId,
workspaceId,
state.store_id,
state.platform_id,
params!.pricing_policy_id,
variantId,
state.product_id,
profileId,
state.category_id,
state.fulfillment,
state.purchase_cost_cny,
D(state.package.shipping_weight_g).div(1000).toString(),
D(state.package.length_mm).div(10).toString(),
D(state.package.width_mm).div(10).toString(),
D(state.package.height_mm).div(10).toString(),
preview.result.context.fx_rate,
preview.result.context.fx_origin,
JSON.stringify({
input: savedInput,
context: preview.result.context,
rules: currentRules,
}),
selectedId,
userId,
fingerprint(savedInput),
],
);
for (const candidate of preview.result.options) {
const f = candidate.fees,
fx = D(preview.result.context.fx_rate);
await tx.query(
`INSERT INTO atlas.pricing_quote_options(id,workspace_id,quote_id,logistics_rate_rule_id,commission_rule_id,logistics_product_type_id,transport_method_id,merchandise_value_cny,selling_price_rub,list_price_rub,billable_weight_kg,logistics_fee_cny,commission_fee_rub,domestic_fee_cny,ad_fee_rub,last_mile_fee_rub,withdrawal_fee_rub,other_fee_rub,profit_rub,calculation_snapshot) VALUES(${Array.from({ length: 20 }, (_, i) => `$${i + 1}`).join(',')})`,
[
candidate.id === o.id ? selectedId : randomUUID(),
workspaceId,
quoteId,
candidate.logistics_rate_rule_id,
candidate.commission_rule_id,
candidate.product_type_id,
candidate.transport_method_id,
candidate.merchandise_value_cny,
candidate.selling_price_rub,
candidate.list_price_rub,
candidate.billable_weight_kg,
f.logistics_cny,
fx.mul(f.commission_cny).toFixed(4),
f.domestic_cny,
fx.mul(f.ad_cny).toFixed(4),
fx.mul(f.last_mile_cny).toFixed(4),
fx.mul(f.withdrawal_cny).toFixed(4),
fx.mul(f.other_cny).toFixed(4),
candidate.profit_rub,
JSON.stringify(candidate),
],
);
}
await tx.query(
`UPDATE atlas.product_variant_platform_listings SET pricing_quote_id=$2,pricing_draft=$3,pricing_valid=true,selling_price=$4,list_price=$5,currency_code=$6 WHERE id=$1`,
[
listing.id,
quoteId,
JSON.stringify(params),
o.selling_price,
o.list_price,
o.currency_code,
],
);
}
}
}
@@ -0,0 +1,233 @@
import { Inject, Injectable } from '@nestjs/common';
import type { ProductAiTask, ProductEditorSnapshot } from '@atlas/contracts';
import type { Database } from '@atlas/database';
import { ApiError } from '../common/api-error.js';
import { IdempotencyService } from '../common/idempotency.service.js';
import { DATABASE } from '../database/database.module.js';
import { ProductEditorService } from './product-editor.service.js';
@Injectable()
export class ProductAiService {
public constructor(
@Inject(DATABASE) private readonly database: Database,
@Inject(IdempotencyService)
private readonly idempotency: IdempotencyService,
@Inject(ProductEditorService)
private readonly editor: ProductEditorService,
) {}
/** 固化当前编辑快照并在同一事务写入任务与 Outbox。 */
async create(
workspaceId: string,
userId: string,
profileId: string,
platformId: string,
variantId: string | null,
key: string,
hint: string | undefined,
) {
const profile = await this.database.workspaceTransaction(
workspaceId,
async (tx) => {
const row = await tx.query<{ product_id: string }>(
`SELECT product_id FROM atlas.product_platform_profiles
WHERE id = $1 AND platform_id = $2`,
[profileId, platformId],
);
if (!row.rows[0])
throw new ApiError(
404,
'PROFILE_NOT_FOUND',
'商品发布配置不存在',
);
return row.rows[0];
},
);
const snapshot = await this.editor.read(
workspaceId,
profile.product_id,
platformId,
);
if (snapshot.profile.id !== profileId)
throw new ApiError(
409,
'PROFILE_CHANGED',
'商品发布配置已变化,请刷新页面',
);
if (
variantId &&
!snapshot.variants.some((variant) => variant.id === variantId)
)
throw new ApiError(404, 'VARIANT_NOT_FOUND', 'SKU 不属于当前商品');
const kind = variantId ? 'sku_copy' : 'autofill';
const path = variantId
? `/api/product-platform-profiles/${profileId}/variants/${variantId}/ai/copy`
: `/api/product-platform-profiles/${profileId}/ai/autofills`;
return this.idempotency.run(
{
workspaceId,
userId,
method: 'POST',
path,
key,
request: { platform_id: platformId, hint: hint ?? null },
},
async (tx) => {
const current = await tx.query<{
product_revision: number;
profile_revision: number;
source_snapshot_revision: number;
}>(
`SELECT product.revision AS product_revision,
profile.revision AS profile_revision,
item.source_snapshot_revision
FROM atlas.product_platform_profiles profile
JOIN atlas.products product ON product.id = profile.product_id
JOIN atlas.collection_items item ON item.id = product.collection_item_id
WHERE profile.id = $1 AND profile.platform_id = $2
FOR UPDATE OF profile, product, item`,
[profileId, platformId],
);
const row = current.rows[0];
if (
!row ||
Number(row.product_revision) !==
snapshot.product.revision ||
Number(row.profile_revision) !==
snapshot.profile.revision ||
Number(row.source_snapshot_revision) !==
snapshot.source_material.source_snapshot_revision
) {
throw new ApiError(
409,
'AI_INPUT_CHANGED',
'商品资料已变化,请重新点击 AI 填报',
);
}
const input = taskInput(snapshot, variantId, hint);
const inserted = await tx.query<{ id: string }>(
`INSERT INTO atlas.product_ai_tasks
(workspace_id, product_id, profile_id, variant_id, kind, input_snapshot, created_by_user_id)
VALUES (current_setting('app.workspace_id')::uuid, $1, $2, $3, $4, $5::jsonb, $6)
RETURNING id`,
[
profile.product_id,
profileId,
variantId,
kind,
JSON.stringify(input),
userId,
],
);
const taskId = inserted.rows[0].id;
await tx.query(
`INSERT INTO atlas.outbox_events
(workspace_id, aggregate_type, aggregate_id, event_type, event_payload, deduplication_key)
VALUES (current_setting('app.workspace_id')::uuid, 'product_ai_task', $1,
'product_ai_task.requested', $2::jsonb, $3)`,
[
taskId,
JSON.stringify({ task_id: taskId }),
`product_ai_task:${taskId}`,
],
);
return {
status: 202,
body: { data: { ai_task_id: taskId, status: 'queued' } },
};
},
);
}
/** 只返回已校验结果和输入版本,不暴露采集快照与模型提示词。 */
async read(
workspaceId: string,
taskId: string,
platformId: string,
): Promise<ProductAiTask> {
return this.database.workspaceTransaction(workspaceId, async (tx) => {
const result = await tx.query<{
id: string;
kind: 'autofill' | 'sku_copy' | 'attribute_fill';
status: ProductAiTask['status'];
variant_id: string | null;
input_snapshot: Record<string, unknown>;
result: ProductAiTask['result'];
error_code: string | null;
error_message: string | null;
}>(
`SELECT task.id, task.kind, task.status, task.variant_id,
task.input_snapshot, task.result, task.error_code, task.error_message
FROM atlas.product_ai_tasks task
JOIN atlas.product_platform_profiles profile ON profile.id = task.profile_id
WHERE task.id = $1 AND profile.platform_id = $2`,
[taskId, platformId],
);
const row = result.rows[0];
if (!row)
throw new ApiError(404, 'AI_TASK_NOT_FOUND', 'AI 任务不存在');
const input = row.input_snapshot as {
revision?: ProductAiTask['input_revision'];
};
return {
id: row.id,
kind: row.kind,
status: row.status,
input_revision: input.revision ?? {
product: 0,
profile: 0,
source_snapshot: 0,
},
variant_id: row.variant_id,
result:
row.status === 'succeeded' || row.status === 'partial'
? row.result
: null,
error_code: row.error_code,
error_message: row.error_message,
};
});
}
}
function taskInput(
snapshot: ProductEditorSnapshot,
variantId: string | null,
hint?: string,
) {
return {
revision: {
product: snapshot.product.revision,
profile: snapshot.profile.revision,
source_snapshot: snapshot.source_material.source_snapshot_revision,
},
platform_id: snapshot.profile.platform_id,
product_id: snapshot.product.id,
profile_id: snapshot.profile.id,
variant_id: variantId,
hint: hint ?? null,
source_material: snapshot.source_material,
common: snapshot.product.common,
profile: {
title: snapshot.profile.title,
tags: snapshot.profile.tags,
description: snapshot.profile.description,
category: snapshot.profile.category,
target_store_id: snapshot.profile.target_store.id,
},
variants: snapshot.variants.map((variant) => ({
id: variant.id,
display_name: variant.display_name,
source_sku: variant.source_sku,
specification_values: variant.specification_values,
listing: {
status: variant.listing.status,
offer_id: variant.listing.offer_id,
},
title: variant.title,
tags: variant.tags,
description: variant.description,
package_spec: variant.effective_package_spec,
})),
};
}
@@ -0,0 +1,33 @@
import type { ProductAttributeSchema } from '@atlas/contracts';
type AttributeDefinition = Omit<ProductAttributeSchema, 'editor_field'>;
type EditorField = Exclude<ProductAttributeSchema['editor_field'], null>;
/** Ozon 固定属性 ID 到编辑器内建字段的唯一映射,不使用任何名称或翻译文本判断。 */
const editorFieldByAttributeId: Readonly<Record<string, EditorField>> = {
'85': 'brand',
'9048': 'model',
'8229': 'category',
'23171': 'tags',
'11254': 'rich_content',
'4191': 'description',
};
/** 仅依据 Ozon 属性 ID 将类目属性绑定到编辑器内建字段。 */
export function editorFieldForAttributeId(
platformAttributeId: string,
): ProductAttributeSchema['editor_field'] {
return editorFieldByAttributeId[platformAttributeId] ?? null;
}
/** 为数据库中的原始类目属性补充前端和校验共用的内建字段绑定。 */
export function bindEditorFields(
definitions: AttributeDefinition[],
): ProductAttributeSchema[] {
return definitions.map((definition) => ({
...definition,
editor_field: editorFieldForAttributeId(
definition.platform_attribute_id,
),
}));
}
@@ -0,0 +1,76 @@
import type {
ProductCatalogCacheMetadata,
ProductCatalogCacheStatus,
} from '@atlas/contracts';
export type ProductCatalogResourceState = {
last_success_at: Date | string | null;
is_complete: boolean;
fresh_days: number;
expire_days: number;
refresh_run_id: string | null;
refresh_status: string | null;
};
/** 统一实现 14/21 天边界,供所有商品目录读取与发布预检复用。 */
export function productCatalogCacheStatus(
lastSuccessAt: Date | string | null,
freshDays: number,
expireDays: number,
now = Date.now(),
): ProductCatalogCacheStatus {
if (!lastSuccessAt) return 'missing';
const age = now - new Date(lastSuccessAt).getTime();
const day = 86_400_000;
if (age < freshDays * day) return 'fresh';
if (age <= expireDays * day) return 'stale';
return 'expired';
}
export function productCatalogResourceStatus(
state: ProductCatalogResourceState,
): ProductCatalogCacheStatus {
return productCatalogCacheStatus(
state.last_success_at,
state.fresh_days,
state.expire_days,
);
}
/** cache 元数据在编辑读取与发布预检之间保持同一形状和边界计算。 */
export function productCatalogCacheMetadata(
state: ProductCatalogResourceState,
status = productCatalogResourceStatus(state),
): ProductCatalogCacheMetadata {
const last = state.last_success_at ? new Date(state.last_success_at) : null;
return {
status,
last_success_at: last?.toISOString() ?? null,
fresh_until: last
? new Date(
last.getTime() + state.fresh_days * 86_400_000,
).toISOString()
: null,
expires_at: last
? new Date(
last.getTime() + state.expire_days * 86_400_000,
).toISOString()
: null,
is_complete: state.is_complete,
refresh_run_id: state.refresh_run_id,
refresh_status: state.refresh_status,
retry_after_ms:
status === 'expired' || status === 'missing' ? 1_000 : null,
};
}
export function productCatalogStateWithRun(
state: ProductCatalogResourceState,
run: { id: string; status: string },
): ProductCatalogResourceState {
return {
...state,
refresh_run_id: run.id,
refresh_status: run.status,
};
}
@@ -0,0 +1,657 @@
import { createHash } from 'node:crypto';
import type { ProductAttributeSchema } from '@atlas/contracts';
import type { Database, DatabaseTransaction } from '@atlas/database';
import { ApiError } from '../common/api-error.js';
import {
enqueuePlatformCatalogRun,
type PlatformCatalogRunRow,
} from '../platform-catalog/platform-catalog-run.js';
import {
productCatalogCacheMetadata as cacheMetadata,
productCatalogResourceStatus as cacheStatus,
type ProductCatalogResourceState as ResourceState,
} from './product-editor.catalog-cache.js';
import type { ProfileRow } from './product-editor.models.js';
type DictionaryCursor = { value: string; value_id: string; query: string };
type RefreshScope = 'attributes' | 'dictionary' | 'dictionary_search';
type RunRow = PlatformCatalogRunRow;
type CatalogAccess = {
requireProfileForPlatform: (
transaction: DatabaseTransaction,
profileId: string,
platformId: string,
) => Promise<ProfileRow>;
requireActiveCategory: (
transaction: DatabaseTransaction,
platformId: string,
categoryId: string,
) => Promise<unknown>;
attributeSchemaForCategory: (
transaction: DatabaseTransaction,
platformId: string,
categoryId: string,
) => Promise<ProductAttributeSchema[]>;
};
/** 游标只保存排序位置,类目和属性归属仍由每次请求重新校验。 */
function parseDictionaryCursor(
cursor: string | undefined,
): DictionaryCursor | null {
if (!cursor) return null;
if (cursor.length > 2_000)
throw new ApiError(400, 'VALIDATION_FAILED', '字典游标不合法');
try {
const parsed: unknown = JSON.parse(
Buffer.from(cursor, 'base64url').toString('utf8'),
);
if (
parsed &&
typeof parsed === 'object' &&
typeof (parsed as DictionaryCursor).value === 'string' &&
typeof (parsed as DictionaryCursor).value_id === 'string' &&
typeof (parsed as DictionaryCursor).query === 'string'
)
return parsed as DictionaryCursor;
} catch {
// 无效游标统一返回参数错误。
}
throw new ApiError(400, 'VALIDATION_FAILED', '字典游标不合法');
}
/**
* 商品编辑器只读取平台级目录缓存。stale 立即返回并后台刷新;expired/missing
* 只创建平台 Worker 任务,短暂等待后仍未完成则以 202 告知前端继续轮询。
*/
export class ProductEditorCatalogOperations {
public constructor(
private readonly database: Database,
private readonly access: CatalogAccess,
) {}
async categoryOptions(
workspaceId: string,
profileId: string,
platformId: string,
search: string | undefined,
) {
return this.database.workspaceTransaction(
workspaceId,
async (transaction) => {
await this.access.requireProfileForPlatform(
transaction,
profileId,
platformId,
);
const categories = await transaction.query<{
id: string;
parent_id: string | null;
external_category_id: string;
external_type_id: string | null;
name: string;
path: string;
is_active: boolean;
}>(
`SELECT id, parent_id, external_category_id, external_type_id, name, path, is_active
FROM atlas.platform_product_categories
WHERE platform_id = $1
ORDER BY path, name, id
LIMIT 10000`,
[platformId],
);
// 搜索由 Cascader 在完整平台树上执行,保持父子路径完整。
void search;
return categories.rows;
},
);
}
async attributeSchema(
workspaceId: string,
userId: string,
profileId: string,
platformId: string,
categoryId?: string,
) {
const prepared = await this.readAttributeSchema(
workspaceId,
userId,
profileId,
platformId,
categoryId,
true,
);
if (!prepared.waitForRunId)
return { status: 200, body: { data: prepared.result } };
await this.waitForRun(prepared.waitForRunId);
const completed = await this.readAttributeSchema(
workspaceId,
userId,
profileId,
platformId,
categoryId,
false,
);
const usable =
completed.result.cache.status === 'fresh' ||
completed.result.cache.status === 'stale';
return { status: usable ? 200 : 202, body: { data: completed.result } };
}
async attributeDictionary(
workspaceId: string,
userId: string,
profileId: string,
platformId: string,
attributeId: string,
search: string | undefined,
cursor: string | undefined,
categoryId?: string,
) {
const after = parseDictionaryCursor(cursor);
const prepared = await this.readDictionary(
workspaceId,
userId,
profileId,
platformId,
attributeId,
search,
after,
categoryId,
true,
);
if (!prepared.waitForRunId)
return { status: 200, body: { data: prepared.result } };
await this.waitForRun(prepared.waitForRunId);
const completed = await this.readDictionary(
workspaceId,
userId,
profileId,
platformId,
attributeId,
search,
after,
categoryId,
false,
);
const usable =
completed.result.cache.status === 'fresh' ||
completed.result.cache.status === 'stale';
return { status: usable ? 200 : 202, body: { data: completed.result } };
}
async requestAttributeSchemaSync(
workspaceId: string,
userId: string,
profileId: string,
platformId: string,
key: string,
categoryId: string,
) {
const run = await this.database.workspaceTransaction(
workspaceId,
async (transaction) => {
await this.access.requireProfileForPlatform(
transaction,
profileId,
platformId,
);
await this.access.requireActiveCategory(
transaction,
platformId,
categoryId,
);
return this.enqueueRequestedRefresh(transaction, key, {
platformId,
userId,
scope: 'attributes',
scopeKey: `attributes:${categoryId}`,
categoryId,
priority: 'high',
trigger: 'user_access',
});
},
);
return { status: 202, body: { data: { run } } };
}
async requestAttributeDictionarySync(
workspaceId: string,
userId: string,
profileId: string,
platformId: string,
key: string,
categoryId: string,
attributeId: string,
query: string,
) {
const needle = query.trim();
if (needle.length === 1)
throw new ApiError(
422,
'DICTIONARY_SEARCH_REQUIRED',
'请至少输入 2 个字符后再从 Ozon 搜索字典值',
);
const run = await this.database.workspaceTransaction(
workspaceId,
async (transaction) => {
await this.access.requireProfileForPlatform(
transaction,
profileId,
platformId,
);
await this.access.requireActiveCategory(
transaction,
platformId,
categoryId,
);
await this.requireDictionaryAttribute(
transaction,
platformId,
categoryId,
attributeId,
);
const searchScope = needle.length >= 2;
const normalized = normalizeSearch(needle);
return this.enqueueRequestedRefresh(transaction, key, {
platformId,
userId,
scope: searchScope ? 'dictionary_search' : 'dictionary',
scopeKey: searchScope
? dictionarySearchScopeKey(attributeId, normalized)
: `dictionary:${attributeId}`,
categoryId,
attributeId,
searchKey: searchScope ? normalized : undefined,
priority: 'high',
trigger: 'user_access',
});
},
);
return { status: 202, body: { data: { run } } };
}
private async readAttributeSchema(
workspaceId: string,
userId: string,
profileId: string,
platformId: string,
categoryId: string | undefined,
enqueue: boolean,
) {
return this.database.workspaceTransaction(
workspaceId,
async (transaction) => {
const profile = await this.access.requireProfileForPlatform(
transaction,
profileId,
platformId,
);
const selectedCategoryId =
categoryId ?? profile.platform_product_category_id;
if (!selectedCategoryId)
throw new ApiError(
422,
'CATEGORY_REQUIRED',
'请先选择产品类目',
);
await this.access.requireActiveCategory(
transaction,
platformId,
selectedCategoryId,
);
const state = await this.resourceState(
transaction,
platformId,
'attributes',
`attributes:${selectedCategoryId}`,
);
const status = cacheStatus(state);
let run: RunRow | null = null;
if (enqueue && status !== 'fresh') {
run = await this.enqueueRefresh(transaction, {
platformId,
userId,
scope: 'attributes',
scopeKey: `attributes:${selectedCategoryId}`,
categoryId: selectedCategoryId,
priority: status === 'stale' ? 'low' : 'high',
trigger: 'user_access',
});
}
const effectiveState = run
? {
...state,
refresh_run_id: run.id,
refresh_status: run.status,
}
: state;
const usable = status === 'fresh' || status === 'stale';
return {
result: {
items: usable
? await this.access.attributeSchemaForCategory(
transaction,
platformId,
selectedCategoryId,
)
: [],
cache: cacheMetadata(effectiveState, status),
},
waitForRunId: enqueue && !usable ? (run?.id ?? null) : null,
};
},
);
}
private async readDictionary(
workspaceId: string,
userId: string,
profileId: string,
platformId: string,
attributeId: string,
search: string | undefined,
after: DictionaryCursor | null,
categoryId: string | undefined,
enqueue: boolean,
) {
return this.database.workspaceTransaction(
workspaceId,
async (transaction) => {
const profile = await this.access.requireProfileForPlatform(
transaction,
profileId,
platformId,
);
const selectedCategoryId =
categoryId ?? profile.platform_product_category_id;
if (!selectedCategoryId)
throw new ApiError(
422,
'CATEGORY_REQUIRED',
'请先选择产品类目',
);
await this.access.requireActiveCategory(
transaction,
platformId,
selectedCategoryId,
);
await this.requireDictionaryAttribute(
transaction,
platformId,
selectedCategoryId,
attributeId,
);
const needle = search?.trim() ?? '';
if (after && after.query !== needle)
throw new ApiError(
400,
'VALIDATION_FAILED',
'字典游标与搜索条件不匹配',
);
const values = await this.dictionaryValues(
transaction,
attributeId,
needle,
after,
);
const dictionaryState = await this.resourceState(
transaction,
platformId,
'dictionary',
`dictionary:${attributeId}`,
);
const normalized = normalizeSearch(needle);
const searchScopeKey = dictionarySearchScopeKey(
attributeId,
normalized,
);
const searchState =
needle.length >= 2 && !dictionaryState.is_complete
? await this.resourceState(
transaction,
platformId,
'dictionary_search',
searchScopeKey,
)
: null;
// 部分字典本地未命中时补齐查询词;一旦建立过该词作用域,后续继续
// 使用它的新鲜度,避免搜索成功写入值后又退回整个字典的状态。
const usesSearchScope =
!!searchState &&
(values.rows.length === 0 ||
searchState.last_success_at !== null);
const scope: RefreshScope = usesSearchScope
? 'dictionary_search'
: 'dictionary';
const scopeKey = usesSearchScope
? searchScopeKey
: `dictionary:${attributeId}`;
const state = usesSearchScope ? searchState : dictionaryState;
const status = cacheStatus(state);
let run: RunRow | null = null;
if (enqueue && status !== 'fresh') {
run = await this.enqueueRefresh(transaction, {
platformId,
userId,
scope,
scopeKey,
categoryId: selectedCategoryId,
attributeId,
searchKey: usesSearchScope ? normalized : undefined,
priority: status === 'stale' ? 'low' : 'high',
trigger: 'user_access',
});
}
const effectiveState = run
? {
...state,
refresh_run_id: run.id,
refresh_status: run.status,
}
: state;
const usable = status === 'fresh' || status === 'stale';
const items = usable ? values.rows.slice(0, 200) : [];
const last = items.at(-1);
return {
result: {
items,
next_cursor:
usable && values.rows.length > 200 && last
? Buffer.from(
JSON.stringify({
...last,
query: needle,
}),
).toString('base64url')
: null,
cache: cacheMetadata(effectiveState, status),
},
waitForRunId: enqueue && !usable ? (run?.id ?? null) : null,
};
},
);
}
private async dictionaryValues(
transaction: DatabaseTransaction,
attributeId: string,
needle: string,
after: DictionaryCursor | null,
) {
return transaction.query<{ value_id: string; value: string }>(
`SELECT external_value_id AS value_id, value
FROM atlas.platform_catalog_dictionary_values
WHERE attribute_id = $1 AND is_active
AND ($2 = '' OR value ILIKE '%' || $2 || '%')
AND ($3::text IS NULL OR (value, external_value_id) > ($3, $4))
ORDER BY value, external_value_id
LIMIT 201`,
[
attributeId,
needle,
after?.value ?? null,
after?.value_id ?? null,
],
);
}
private async requireDictionaryAttribute(
transaction: DatabaseTransaction,
platformId: string,
categoryId: string,
attributeId: string,
) {
const attribute = await transaction.query<{ id: string }>(
`SELECT id FROM atlas.platform_product_category_attributes
WHERE id = $1 AND platform_id = $2 AND platform_product_category_id = $3
AND is_active AND dictionary_id IS NOT NULL`,
[attributeId, platformId, categoryId],
);
if (!attribute.rows[0])
throw new ApiError(
422,
'ATTRIBUTE_DICTIONARY_INVALID',
'字典不属于当前类目的属性',
);
}
private async resourceState(
transaction: DatabaseTransaction,
platformId: string,
scope: RefreshScope,
scopeKey: string,
): Promise<ResourceState> {
const result = await transaction.query<ResourceState>(
`SELECT state.last_success_at, coalesce(state.is_complete, false) AS is_complete,
CASE WHEN $2 = 'attributes' THEN policy.attribute_fresh_days
ELSE policy.dictionary_fresh_days END AS fresh_days,
CASE WHEN $2 = 'attributes' THEN policy.attribute_expire_days
ELSE policy.dictionary_expire_days END AS expire_days,
run.id AS refresh_run_id, run.status AS refresh_status
FROM (SELECT 1) marker
JOIN atlas.platform_catalog_refresh_policies policy ON policy.platform_id = $1
LEFT JOIN atlas.platform_catalog_resource_states state
ON state.platform_id = $1 AND state.scope_key = $3
LEFT JOIN LATERAL (
SELECT id, status FROM atlas.platform_catalog_sync_runs
WHERE platform_id = $1 AND scope_key = $3
AND status IN ('queued', 'running')
ORDER BY created_at DESC LIMIT 1
) run ON true`,
[platformId, scope, scopeKey],
);
if (!result.rows[0])
throw new ApiError(
422,
'CATALOG_POLICY_REQUIRED',
'平台目录刷新策略尚未初始化',
);
return result.rows[0];
}
private async enqueueRefresh(
transaction: DatabaseTransaction,
input: {
platformId: string;
userId: string;
scope: RefreshScope;
scopeKey: string;
categoryId: string;
attributeId?: string;
searchKey?: string;
priority: 'high' | 'low';
trigger: 'user_access';
},
): Promise<RunRow> {
const source = await transaction.query<{ revision: number }>(
`SELECT revision FROM atlas.platform_catalog_sources
WHERE platform_id = $1 FOR UPDATE`,
[input.platformId],
);
if (!source.rows[0])
throw new ApiError(
422,
'CATALOG_SOURCE_REQUIRED',
'平台目录同步来源尚未配置',
);
return enqueuePlatformCatalogRun(transaction, {
platformId: input.platformId,
scope: input.scope,
scopeKey: input.scopeKey,
categoryId: input.categoryId,
attributeId: input.attributeId,
searchKey: input.searchKey,
sourceRevision: source.rows[0].revision,
trigger: input.trigger,
priority: input.priority,
userId: input.userId,
});
}
/** 手动兼容入口把幂等键映射到平台 run,即使复用活动任务也可安全重放。 */
private async enqueueRequestedRefresh(
transaction: DatabaseTransaction,
key: string,
input: Parameters<ProductEditorCatalogOperations['enqueueRefresh']>[1],
) {
const prior = await transaction.query<PlatformCatalogRunRow>(
`SELECT run.id, run.platform_id, run.scope, run.scope_key, run.status,
run.trigger_kind, run.priority, run.created_at
FROM atlas.platform_catalog_sync_requests request
JOIN atlas.platform_catalog_sync_runs run ON run.id = request.run_id
WHERE request.requested_by_user_id = $1 AND request.idempotency_key = $2`,
[input.userId, key],
);
if (prior.rows[0]) {
if (
prior.rows[0].platform_id !== input.platformId ||
prior.rows[0].scope_key !== input.scopeKey
) {
throw new ApiError(
409,
'IDEMPOTENCY_KEY_REUSED',
'幂等键已用于其他目录刷新请求',
);
}
return prior.rows[0];
}
const run = await this.enqueueRefresh(transaction, input);
await transaction.query(
`INSERT INTO atlas.platform_catalog_sync_requests
(requested_by_user_id, idempotency_key, platform_id, scope_key, run_id)
VALUES ($1, $2, $3, $4, $5)`,
[input.userId, key, input.platformId, input.scopeKey, run.id],
);
return run;
}
/** 最多等待 5 秒;API 不直连 Ozon,只观察 Worker 写入的任务终态。 */
private async waitForRun(runId: string) {
const deadline = Date.now() + 5_000;
while (Date.now() < deadline) {
const result = await this.database.query<{ status: string }>(
`SELECT status FROM atlas.platform_catalog_sync_runs WHERE id = $1`,
[runId],
);
if (
!result.rows[0] ||
['succeeded', 'failed', 'cancelled'].includes(
result.rows[0].status,
)
)
return;
await new Promise<void>((resolve) => setTimeout(resolve, 250));
}
}
}
function normalizeSearch(value: string) {
return value.trim().toLocaleLowerCase('zh-CN').replace(/\s+/g, ' ');
}
function dictionarySearchScopeKey(attributeId: string, search: string) {
const hash = createHash('sha256').update(search).digest('hex').slice(0, 24);
return `dictionary_search:${attributeId}:${hash}`;
}
@@ -0,0 +1,451 @@
import {
Body,
Controller,
Get,
Headers,
Inject,
Param,
Post,
Put,
Query,
Req,
Res,
} from '@nestjs/common';
import { z } from 'zod';
import {
productPublishPreflightRequestSchema,
createProductPublishJobRequestSchema,
requestProductAiTaskSchema,
saveProductEditorDraftRequestSchema,
saveProductSourceDescriptionRequestSchema,
type Workspace,
} from '@atlas/contracts';
import { ApiError } from '../common/api-error.js';
import type { AuthenticatedRequest } from '../auth/auth.guard.js';
import { CurrentWorkspace } from '../workspace/current-workspace.decorator.js';
import { ProductEditorService } from './product-editor.service.js';
import { ProductAiService } from './product-ai.service.js';
type Response = { status: (value: number) => Response };
@Controller()
export class ProductEditorController {
/** 注入所有商品编辑接口共用的工作流服务。 */
public constructor(
@Inject(ProductEditorService)
private readonly editor: ProductEditorService,
@Inject(ProductAiService)
private readonly productAi: ProductAiService,
) {}
/** 为采集记录创建或复用当前平台的商品编辑上下文。 */
@Post('collection-items/:collectionItemId/editor-contexts')
async enter(
@Param('collectionItemId') collectionItemId: string,
@Headers('x-atlas-platform-id') platformId: string | undefined,
@Headers('idempotency-key') key: string | undefined,
@Req() request: AuthenticatedRequest,
@CurrentWorkspace() workspace: Workspace,
@Res({ passthrough: true }) response: Response,
) {
const result = await this.editor.enter(
workspace.id,
request.auth.userId,
requiredUuid('collection_item_id', collectionItemId),
requiredUuid('X-Atlas-Platform-Id', platformId),
requiredUuid('Idempotency-Key', key),
);
response.status(result.status);
return result.body;
}
/** 返回当前平台下完整的商品编辑读模型。 */
@Get('products/:productId/editor')
async read(
@Param('productId') productId: string,
@Headers('x-atlas-platform-id') platformId: string | undefined,
@CurrentWorkspace() workspace: Workspace,
) {
return {
data: await this.editor.read(
workspace.id,
requiredUuid('product_id', productId),
requiredUuid('X-Atlas-Platform-Id', platformId),
),
};
}
/** 用户主动创建 SPU AI 填报任务。 */
@Post('product-platform-profiles/:profileId/ai/autofills')
async aiAutofill(
@Param('profileId') profileId: string,
@Body() body: unknown,
@Headers('x-atlas-platform-id') platformId: string | undefined,
@Headers('idempotency-key') key: string | undefined,
@Req() request: AuthenticatedRequest,
@CurrentWorkspace() workspace: Workspace,
@Res({ passthrough: true }) response: Response,
) {
const parsed = requestProductAiTaskSchema.safeParse(body ?? {});
if (!parsed.success)
throw new ApiError(
422,
'VALIDATION_FAILED',
'AI 请求参数不符合要求',
);
const result = await this.productAi.create(
workspace.id,
request.auth.userId,
requiredUuid('profile_id', profileId),
requiredUuid('X-Atlas-Platform-Id', platformId),
null,
requiredUuid('Idempotency-Key', key),
parsed.data.hint,
);
response.status(result.status);
return result.body;
}
/** 用户主动创建单个 SKU 的文案任务,不影响其他 SKU。 */
@Post('product-platform-profiles/:profileId/variants/:variantId/ai/copy')
async aiSkuCopy(
@Param('profileId') profileId: string,
@Param('variantId') variantId: string,
@Body() body: unknown,
@Headers('x-atlas-platform-id') platformId: string | undefined,
@Headers('idempotency-key') key: string | undefined,
@Req() request: AuthenticatedRequest,
@CurrentWorkspace() workspace: Workspace,
@Res({ passthrough: true }) response: Response,
) {
const parsed = requestProductAiTaskSchema.safeParse(body ?? {});
if (!parsed.success)
throw new ApiError(
422,
'VALIDATION_FAILED',
'AI 请求参数不符合要求',
);
const result = await this.productAi.create(
workspace.id,
request.auth.userId,
requiredUuid('profile_id', profileId),
requiredUuid('X-Atlas-Platform-Id', platformId),
requiredUuid('variant_id', variantId),
requiredUuid('Idempotency-Key', key),
parsed.data.hint,
);
response.status(result.status);
return result.body;
}
/** 查询当前工作空间和平台的 AI 任务状态。 */
@Get('product-ai-tasks/:taskId')
async aiTask(
@Param('taskId') taskId: string,
@Headers('x-atlas-platform-id') platformId: string | undefined,
@CurrentWorkspace() workspace: Workspace,
) {
return {
data: await this.productAi.read(
workspace.id,
requiredUuid('ai_task_id', taskId),
requiredUuid('X-Atlas-Platform-Id', platformId),
),
};
}
/** 返回当前平台共享的类目树,供编辑器选择。 */
@Get('product-platform-profiles/:profileId/category-options')
async categoryOptions(
@Param('profileId') profileId: string,
@Query('q') query: string | undefined,
@Headers('x-atlas-platform-id') platformId: string | undefined,
@CurrentWorkspace() workspace: Workspace,
) {
return {
data: await this.editor.categoryOptions(
workspace.id,
requiredUuid('profile_id', profileId),
requiredUuid('X-Atlas-Platform-Id', platformId),
query,
),
};
}
/** 返回所选类目的缓存动态属性定义。 */
@Get('product-platform-profiles/:profileId/attribute-schema')
async attributeSchema(
@Param('profileId') profileId: string,
@Query('category_id') categoryId: string | undefined,
@Headers('x-atlas-platform-id') platformId: string | undefined,
@Req() request: AuthenticatedRequest,
@CurrentWorkspace() workspace: Workspace,
@Res({ passthrough: true }) response: Response,
) {
const result = await this.editor.attributeSchema(
workspace.id,
request.auth.userId,
requiredUuid('profile_id', profileId),
requiredUuid('X-Atlas-Platform-Id', platformId),
optionalUuid('category_id', categoryId),
);
response.status(result.status);
return result.body;
}
/** 返回首次上架前的服务端发布就绪校验。 */
@Get('product-platform-profiles/:profileId/validation')
async validation(
@Param('profileId') profileId: string,
@Headers('x-atlas-platform-id') platformId: string | undefined,
@CurrentWorkspace() workspace: Workspace,
) {
return {
data: await this.editor.validation(
workspace.id,
requiredUuid('profile_id', profileId),
requiredUuid('X-Atlas-Platform-Id', platformId),
),
};
}
/** 定向刷新本次首次上架依赖的目录资源,并返回最终发布预检。 */
@Post('product-platform-profiles/:profileId/publish-preflights')
async publishPreflight(
@Param('profileId') profileId: string,
@Body() body: unknown,
@Headers('x-atlas-platform-id') platformId: string | undefined,
@Req() request: AuthenticatedRequest,
@CurrentWorkspace() workspace: Workspace,
@Res({ passthrough: true }) response: Response,
) {
const parsed = productPublishPreflightRequestSchema.safeParse(body);
if (!parsed.success) {
throw new ApiError(
422,
'VALIDATION_FAILED',
'请求参数不符合要求',
parsed.error.issues.map((issue) => ({
field: issue.path.join('.'),
code: 'INVALID_FIELD',
message: issue.message,
})),
);
}
const result = await this.editor.publishPreflight(
workspace.id,
request.auth.userId,
requiredUuid('profile_id', profileId),
requiredUuid('X-Atlas-Platform-Id', platformId),
parsed.data.variant_ids,
);
response.status(result.status);
return result.body;
}
/** 同一事务重新校验并创建首次发布批次。 */
@Post('product-platform-profiles/:profileId/publish-jobs')
async createPublishJob(@Param('profileId') profileId: string, @Body() body: unknown,
@Headers('x-atlas-platform-id') platformId: string | undefined, @Headers('idempotency-key') key: string | undefined,
@Headers('if-match') revision: string | undefined, @Req() request: AuthenticatedRequest,
@CurrentWorkspace() workspace: Workspace, @Res({ passthrough: true }) response: Response) {
const parsed = createProductPublishJobRequestSchema.safeParse(body);
if (!parsed.success) throw new ApiError(422, 'VALIDATION_FAILED', '请选择 1–50 个不同的待上架 SKU');
const result = await this.editor.createPublishJob(workspace.id, request.auth.userId, requiredUuid('profile_id', profileId), requiredUuid('X-Atlas-Platform-Id', platformId), requiredUuid('Idempotency-Key', key), parseEditorRevision(revision), parsed.data.variant_ids);
response.status(result.status); return result.body;
}
/** 恢复编辑页最近发布任务,刷新页面后也能查看进度。 */
@Get('product-platform-profiles/:profileId/publish-jobs')
async publishJobs(@Param('profileId') profileId: string, @Headers('x-atlas-platform-id') platformId: string | undefined, @CurrentWorkspace() workspace: Workspace) {
return {data: await this.editor.listPublishJobs(workspace.id, requiredUuid('profile_id', profileId), requiredUuid('X-Atlas-Platform-Id', platformId))};
}
/** 查询当前租户及平台下的逐 SKU 发布结果。 */
@Get('product-publish-jobs/:jobId')
async publishJob(@Param('jobId') jobId: string, @Headers('x-atlas-platform-id') platformId: string | undefined, @CurrentWorkspace() workspace: Workspace) {
return {data: await this.editor.readPublishJob(workspace.id, requiredUuid('publish_job_id', jobId), requiredUuid('X-Atlas-Platform-Id', platformId))};
}
/** 幂等地创建所选类目属性定义的刷新任务。 */
@Post('product-platform-profiles/:profileId/attribute-schema-syncs')
async attributeSchemaSync(
@Param('profileId') profileId: string,
@Query('category_id') categoryId: string | undefined,
@Headers('x-atlas-platform-id') platformId: string | undefined,
@Headers('idempotency-key') key: string | undefined,
@Req() request: AuthenticatedRequest,
@CurrentWorkspace() workspace: Workspace,
@Res({ passthrough: true }) response: Response,
) {
const result = await this.editor.requestAttributeSchemaSync(
workspace.id,
request.auth.userId,
requiredUuid('profile_id', profileId),
requiredUuid('X-Atlas-Platform-Id', platformId),
requiredUuid('Idempotency-Key', key),
requiredUuid('category_id', categoryId),
);
response.status(result.status);
return result.body;
}
/** 从本地缓存中查询属性字典值。 */
@Get(
'product-platform-profiles/:profileId/attribute-dictionaries/:attributeId',
)
async attributeDictionary(
@Param('profileId') profileId: string,
@Param('attributeId') attributeId: string,
@Query('q') query: string | undefined,
@Query('cursor') cursor: string | undefined,
@Query('category_id') categoryId: string | undefined,
@Headers('x-atlas-platform-id') platformId: string | undefined,
@Req() request: AuthenticatedRequest,
@CurrentWorkspace() workspace: Workspace,
@Res({ passthrough: true }) response: Response,
) {
const result = await this.editor.attributeDictionary(
workspace.id,
request.auth.userId,
requiredUuid('profile_id', profileId),
requiredUuid('X-Atlas-Platform-Id', platformId),
requiredUuid('attribute_id', attributeId),
query,
cursor,
optionalUuid('category_id', categoryId),
);
response.status(result.status);
return result.body;
}
/** 本地缓存不足时幂等地创建远程字典查询任务。 */
@Post(
'product-platform-profiles/:profileId/attribute-dictionaries/:attributeId/syncs',
)
async attributeDictionarySync(
@Param('profileId') profileId: string,
@Param('attributeId') attributeId: string,
@Query('category_id') categoryId: string | undefined,
@Query('q') query: string | undefined,
@Headers('x-atlas-platform-id') platformId: string | undefined,
@Headers('idempotency-key') key: string | undefined,
@Req() request: AuthenticatedRequest,
@CurrentWorkspace() workspace: Workspace,
@Res({ passthrough: true }) response: Response,
) {
const result = await this.editor.requestAttributeDictionarySync(
workspace.id,
request.auth.userId,
requiredUuid('profile_id', profileId),
requiredUuid('X-Atlas-Platform-Id', platformId),
requiredUuid('Idempotency-Key', key),
requiredUuid('category_id', categoryId),
requiredUuid('attribute_id', attributeId),
query ?? '',
);
response.status(result.status);
return result.body;
}
/** 校验并原子保存商品及其上架草稿。 */
@Put('products/:productId/editor-draft')
async save(
@Param('productId') productId: string,
@Body() body: unknown,
@Headers('x-atlas-platform-id') platformId: string | undefined,
@Headers('idempotency-key') key: string | undefined,
@Headers('if-match') ifMatch: string | undefined,
@Req() request: AuthenticatedRequest,
@CurrentWorkspace() workspace: Workspace,
) {
const parsed = saveProductEditorDraftRequestSchema.safeParse(body);
if (!parsed.success) {
throw new ApiError(
422,
'VALIDATION_FAILED',
'请求参数不符合要求',
parsed.error.issues.map((issue) => ({
field: issue.path.join('.'),
code: 'INVALID_FIELD',
message: issue.message,
})),
);
}
return (
await this.editor.save(
workspace.id,
request.auth.userId,
requiredUuid('product_id', productId),
requiredUuid('X-Atlas-Platform-Id', platformId),
requiredUuid('Idempotency-Key', key),
parseEditorRevision(ifMatch),
parsed.data,
)
).body;
}
/** 独立保存右侧采集面板的商品描述,不要求整页草稿处于可保存状态。 */
@Put('products/:productId/source-description')
async saveSourceDescription(
@Param('productId') productId: string,
@Body() body: unknown,
@Headers('x-atlas-platform-id') platformId: string | undefined,
@Headers('idempotency-key') key: string | undefined,
@Headers('if-match') ifMatch: string | undefined,
@Req() request: AuthenticatedRequest,
@CurrentWorkspace() workspace: Workspace,
) {
const parsed =
saveProductSourceDescriptionRequestSchema.safeParse(body);
if (!parsed.success) {
throw new ApiError(
422,
'VALIDATION_FAILED',
'请求参数不符合要求',
parsed.error.issues.map((issue) => ({
field: issue.path.join('.'),
code: 'INVALID_FIELD',
message: issue.message,
})),
);
}
return (
await this.editor.saveSourceDescription(
workspace.id,
request.auth.userId,
requiredUuid('product_id', productId),
requiredUuid('X-Atlas-Platform-Id', platformId),
requiredUuid('Idempotency-Key', key),
parseEditorRevision(ifMatch),
parsed.data,
)
).body;
}
}
/** 校验路由参数或请求头中的 UUID,并返回原始值。 */
function requiredUuid(field: string, value: string | undefined) {
if (value && z.uuid().safeParse(value).success) return value;
throw new ApiError(400, 'VALIDATION_FAILED', '请求参数不符合要求', [
{ field, code: 'INVALID_FIELD', message: `${field} 必须是 UUID` },
]);
}
/** 将缺省查询参数保留为 undefined;有值时校验其 UUID 格式。 */
function optionalUuid(field: string, value: string | undefined) {
if (value === undefined || value === '') return undefined;
return requiredUuid(field, value);
}
/** 解析 If-Match 中成对传递的商品和发布配置版本号。 */
function parseEditorRevision(value: string | undefined) {
const match = value?.match(/^"([1-9]\d*):([1-9]\d*)"$/);
if (!match)
throw new ApiError(
400,
'IF_MATCH_REQUIRED',
'请求缺少或格式错误的 If-Match',
);
return { product: Number(match[1]), profile: Number(match[2]) };
}
@@ -0,0 +1,76 @@
/** 商品编辑领域从数据库读取的商品行。 */
export type ProductRow = {
id: string;
collection_item_id: string;
revision: number;
source_title: string | null;
brand: string | null;
model: string | null;
barcode: string | null;
purchase_url: string | null;
source_url: string | null;
supply_note: string | null;
default_physical_length_mm: string | null;
default_physical_width_mm: string | null;
default_physical_height_mm: string | null;
default_net_weight_g: string | null;
default_package_length_mm: string | null;
default_package_width_mm: string | null;
default_package_height_mm: string | null;
default_shipping_weight_g: string | null;
package_weight_mode: 'common' | 'variant';
package_dimensions_mode: 'common' | 'variant';
physical_spec_status: 'missing' | 'ambiguous' | 'confirmed';
package_spec_status: 'missing' | 'ambiguous' | 'confirmed';
ai_source_material_overrides: unknown;
ai_source_material_base_revision: number | string | null;
normalized_payload: unknown;
source_snapshot_revision: number | string;
};
/** 商品在一个发布平台下的编辑配置行。 */
export type ProfileRow = {
id: string;
product_id: string;
revision: number;
platform_id: string;
market_code: string | null;
fulfillment: string;
title: string | null;
title_mode: 'common' | 'variant';
description: string | null;
tags: unknown;
platform_product_category_id: string | null;
store_product_category_id: string | null;
platform_type_id: string | null;
category_source: string | null;
category_name: string | null;
category_path: string | null;
target_store_id: string;
store_name: string;
currency_code: string;
};
/** 异步目录任务返回给客户端所需的操作行。 */
export type OperationRow = {
id: string;
kind: string;
status: string;
created_at: Date | string;
completed_at: Date | string | null;
error_code: string | null;
error_message: string | null;
};
/** 统一读取发布配置及其目标店铺与类目展示信息的查询片段。 */
export const profileSelect = `SELECT p.id, p.product_id, p.revision, p.platform_id, p.market_code,
p.fulfillment, p.title, p.title_mode, p.description, p.tags, p.platform_product_category_id,
p.store_product_category_id,
p.platform_type_id, p.category_source, category.name AS category_name,
category.path AS category_path, p.target_store_id, s.name AS store_name,
s.currency_code
FROM atlas.product_platform_profiles p
JOIN atlas.stores s ON s.id = p.target_store_id
LEFT JOIN atlas.platform_product_categories category
ON category.id = p.platform_product_category_id
AND category.platform_id = p.platform_id`;
@@ -0,0 +1,11 @@
import { Module } from '@nestjs/common';
import { IdempotencyService } from '../common/idempotency.service.js';
import { ProductEditorController } from './product-editor.controller.js';
import { ProductEditorService } from './product-editor.service.js';
import { ProductAiService } from './product-ai.service.js';
@Module({
controllers: [ProductEditorController],
providers: [IdempotencyService, ProductEditorService, ProductAiService],
})
export class ProductEditorModule {}
@@ -0,0 +1,508 @@
import type {
ProductAttributeSchema,
ProductCatalogCacheMetadata,
ProductCatalogRefreshRun,
ProductEditorValidation,
ProductPublishPreflight,
} from '@atlas/contracts';
import type { Database, DatabaseTransaction } from '@atlas/database';
import { ApiError } from '../common/api-error.js';
import {
enqueuePlatformCatalogRun,
type PlatformCatalogRunRow,
} from '../platform-catalog/platform-catalog-run.js';
import {
productCatalogCacheMetadata as metadata,
productCatalogResourceStatus as statusOf,
productCatalogStateWithRun as withRun,
type ProductCatalogResourceState as ResourceState,
} from './product-editor.catalog-cache.js';
import type { ProfileRow } from './product-editor.models.js';
type PublishPreflightAccess = {
requireProfileForPlatform: (
transaction: DatabaseTransaction,
profileId: string,
platformId: string,
) => Promise<ProfileRow>;
requireActiveCategory: (
transaction: DatabaseTransaction,
platformId: string,
categoryId: string,
) => Promise<unknown>;
attributeSchemaForCategory: (
transaction: DatabaseTransaction,
platformId: string,
categoryId: string,
) => Promise<ProductAttributeSchema[]>;
validation: (
workspaceId: string,
profileId: string,
platformId: string,
variantIds: string[],
) => Promise<ProductEditorValidation>;
};
type PreparedCatalog = {
schema: ProductCatalogCacheMetadata;
dictionaries: ProductPublishPreflight['catalog']['dictionaries'];
runs: PlatformCatalogRunRow[];
blockingRunIds: string[];
hasBlockingResources: boolean;
};
type UsedDictionary = {
attribute_id: string;
platform_attribute_id: string;
name: string;
};
/**
* 首次发布预检只刷新当前商品实际依赖的平台目录资源。
* API 只创建平台任务并观察 Worker 结果,绝不在请求进程直接调用 Ozon。
*/
export class ProductEditorPublishPreflightOperations {
public constructor(
private readonly database: Database,
private readonly access: PublishPreflightAccess,
) {}
async preflight(
workspaceId: string,
userId: string,
profileId: string,
platformId: string,
variantIds: string[],
): Promise<{ status: number; body: { data: ProductPublishPreflight } }> {
const deadline = Date.now() + 5_000;
const observedRuns = new Map<string, PlatformCatalogRunRow>();
let prepared = await this.prepare(
workspaceId,
userId,
profileId,
platformId,
variantIds,
true,
);
rememberRuns(observedRuns, prepared.runs);
// 第一轮通常等待 schema;成功后第二轮才知道本商品实际引用哪些字典。
for (
let pass = 0;
pass < 2 && prepared.blockingRunIds.length;
pass += 1
) {
const succeeded = await this.waitForRuns(
prepared.blockingRunIds,
deadline,
);
prepared = await this.prepare(
workspaceId,
userId,
profileId,
platformId,
variantIds,
succeeded && Date.now() < deadline,
);
rememberRuns(observedRuns, prepared.runs);
if (!succeeded) break;
}
const validation = await this.access.validation(
workspaceId,
profileId,
platformId,
variantIds,
);
const currentRuns = await this.currentRuns([...observedRuns.keys()]);
const refreshing = prepared.hasBlockingResources;
const result: ProductPublishPreflight = {
status: refreshing
? 'refreshing'
: validation.ready
? 'ready'
: 'blocked',
validation,
catalog: {
schema: prepared.schema,
dictionaries: prepared.dictionaries,
},
refresh_runs: currentRuns.map(toRefreshRun),
};
return {
status: refreshing ? 202 : 200,
body: { data: result },
};
}
private async prepare(
workspaceId: string,
userId: string,
profileId: string,
platformId: string,
variantIds: string[],
enqueue: boolean,
): Promise<PreparedCatalog> {
return this.database.workspaceTransaction(
workspaceId,
async (transaction) => {
const profile = await this.access.requireProfileForPlatform(
transaction,
profileId,
platformId,
);
if (!profile.platform_product_category_id)
throw new ApiError(
422,
'CATEGORY_REQUIRED',
'首次上架前请先选择产品类目',
);
await this.access.requireActiveCategory(
transaction,
platformId,
profile.platform_product_category_id,
);
await this.requirePublishableVariants(
transaction,
profile,
variantIds,
);
let sourceRevision: number | null = null;
const runs: PlatformCatalogRunRow[] = [];
const blockingRunIds: string[] = [];
const enqueueResource = async (input: {
scope: 'attributes' | 'dictionary';
scopeKey: string;
categoryId: string;
attributeId?: string;
priority: 'high' | 'low';
}) => {
if (sourceRevision === null) {
const source = await transaction.query<{
revision: number;
}>(
`SELECT revision FROM atlas.platform_catalog_sources
WHERE platform_id = $1 FOR UPDATE`,
[platformId],
);
if (!source.rows[0])
throw new ApiError(
422,
'CATALOG_SOURCE_REQUIRED',
'平台目录同步来源尚未配置',
);
sourceRevision = source.rows[0].revision;
}
const run = await enqueuePlatformCatalogRun(transaction, {
platformId,
scope: input.scope,
scopeKey: input.scopeKey,
categoryId: input.categoryId,
attributeId: input.attributeId,
sourceRevision,
trigger: 'publish',
priority: input.priority,
userId,
});
runs.push(run);
return run;
};
const categoryId = profile.platform_product_category_id;
let schemaState = await this.resourceState(
transaction,
platformId,
'attributes',
`attributes:${categoryId}`,
);
const schemaStatus = statusOf(schemaState);
if (enqueue && schemaStatus !== 'fresh') {
const run = await enqueueResource({
scope: 'attributes',
scopeKey: `attributes:${categoryId}`,
categoryId,
priority: schemaStatus === 'stale' ? 'low' : 'high',
});
schemaState = withRun(schemaState, run);
if (
schemaStatus === 'expired' ||
schemaStatus === 'missing'
)
blockingRunIds.push(run.id);
} else if (
schemaStatus === 'expired' ||
schemaStatus === 'missing'
) {
if (schemaState.refresh_run_id)
blockingRunIds.push(schemaState.refresh_run_id);
}
const schemaUsable =
schemaStatus === 'fresh' || schemaStatus === 'stale';
const dictionaries: ProductPublishPreflight['catalog']['dictionaries'] =
[];
if (schemaUsable) {
const schema = await this.access.attributeSchemaForCategory(
transaction,
platformId,
categoryId,
);
const used = await this.usedDictionaries(
transaction,
profileId,
variantIds,
schema,
);
for (const dictionary of used) {
let state = await this.resourceState(
transaction,
platformId,
'dictionary',
`dictionary:${dictionary.attribute_id}`,
);
const status = statusOf(state);
if (enqueue && status !== 'fresh') {
const run = await enqueueResource({
scope: 'dictionary',
scopeKey: `dictionary:${dictionary.attribute_id}`,
categoryId,
attributeId: dictionary.attribute_id,
priority: status === 'stale' ? 'low' : 'high',
});
state = withRun(state, run);
if (status === 'expired' || status === 'missing')
blockingRunIds.push(run.id);
} else if (
status === 'expired' ||
status === 'missing'
) {
if (state.refresh_run_id)
blockingRunIds.push(state.refresh_run_id);
}
dictionaries.push({
...dictionary,
cache: metadata(state, status),
});
}
}
return {
schema: metadata(schemaState, schemaStatus),
dictionaries,
runs,
blockingRunIds: [...new Set(blockingRunIds)],
hasBlockingResources:
!schemaUsable ||
dictionaries.some(
(item) =>
item.cache.status === 'expired' ||
item.cache.status === 'missing',
),
};
},
);
}
/** 发布入口只能选择当前 profile 下尚未首次导入的活动 SKU。 */
private async requirePublishableVariants(
transaction: DatabaseTransaction,
profile: ProfileRow,
variantIds: string[],
) {
const result = await transaction.query<{ id: string; status: string }>(
`SELECT variant.id, listing.status
FROM atlas.product_variants variant
JOIN atlas.product_variant_platform_listings listing
ON listing.product_variant_id = variant.id
AND listing.profile_id = $1
WHERE variant.product_id = $2 AND variant.deleted_at IS NULL
AND variant.id = ANY($3::uuid[])`,
[profile.id, profile.product_id, variantIds],
);
if (result.rows.length !== new Set(variantIds).size)
throw new ApiError(
422,
'PUBLISH_VARIANTS_INVALID',
'待发布 SKU 不属于当前商品,或已经被删除',
);
if (result.rows.some((item) => item.status === 'submitting'))
throw new ApiError(
409,
'SKU_IMPORT_IN_PROGRESS',
'所选 SKU 正在首次上架,请等待当前任务完成',
);
if (result.rows.some((item) => item.status === 'imported'))
throw new ApiError(
422,
'SKU_ALREADY_IMPORTED',
'所选 SKU 已经首次导入,不能再次上架',
);
if (
result.rows.some(
(item) => !['not_listed', 'failed'].includes(item.status),
)
)
throw new ApiError(
422,
'SKU_NOT_PUBLISHABLE',
'所选 SKU 当前状态不允许首次上架',
);
}
/** 从已确认草稿中提取本次 SKU 集合真正使用的字典属性。 */
private async usedDictionaries(
transaction: DatabaseTransaction,
profileId: string,
variantIds: string[],
schema: ProductAttributeSchema[],
): Promise<UsedDictionary[]> {
const definitions = new Map(
schema
.filter(
(item) =>
item.value_type === 'dictionary' &&
item.dictionary_id &&
!item.editor_field,
)
.map((item) => [
`${item.platform_attribute_id}\u0000${item.complex_id}`,
item,
]),
);
const attributes = await transaction.query<{
platform_attribute_id: string;
complex_id: string;
values: unknown;
}>(
`SELECT platform_attribute_id, complex_id, values
FROM atlas.product_attributes
WHERE profile_id = $1 AND confirmation_status = 'confirmed'
AND (scope = 'common' OR product_variant_id = ANY($2::uuid[]))`,
[profileId, variantIds],
);
const used = new Map<string, UsedDictionary>();
// 内建品牌等字典字段也需要平台候选值,不能绕过发布目录有效期检查。
for (const definition of schema.filter(item => item.editor_field && item.value_type === 'dictionary' && item.dictionary_id)) {
used.set(definition.attribute_id, {attribute_id: definition.attribute_id, platform_attribute_id: definition.platform_attribute_id, name: definition.name});
}
for (const attribute of attributes.rows) {
if (!hasStringValue(attribute.values)) continue;
const definition = definitions.get(
`${attribute.platform_attribute_id}\u0000${attribute.complex_id}`,
);
if (!definition || used.has(definition.attribute_id)) continue;
used.set(definition.attribute_id, {
attribute_id: definition.attribute_id,
platform_attribute_id: definition.platform_attribute_id,
name: definition.name,
});
}
return [...used.values()];
}
private async resourceState(
transaction: DatabaseTransaction,
platformId: string,
scope: 'attributes' | 'dictionary',
scopeKey: string,
): Promise<ResourceState> {
const result = await transaction.query<ResourceState>(
`SELECT state.last_success_at,
coalesce(state.is_complete, false) AS is_complete,
CASE WHEN $2 = 'attributes' THEN policy.attribute_fresh_days
ELSE policy.dictionary_fresh_days END AS fresh_days,
CASE WHEN $2 = 'attributes' THEN policy.attribute_expire_days
ELSE policy.dictionary_expire_days END AS expire_days,
run.id AS refresh_run_id, run.status AS refresh_status
FROM atlas.platform_catalog_refresh_policies policy
LEFT JOIN atlas.platform_catalog_resource_states state
ON state.platform_id = policy.platform_id AND state.scope_key = $3
LEFT JOIN LATERAL (
SELECT id, status
FROM atlas.platform_catalog_sync_runs
WHERE platform_id = $1 AND scope_key = $3
AND status IN ('queued', 'running')
ORDER BY created_at DESC LIMIT 1
) run ON true
WHERE policy.platform_id = $1`,
[platformId, scope, scopeKey],
);
if (!result.rows[0])
throw new ApiError(
422,
'CATALOG_POLICY_REQUIRED',
'平台目录刷新策略尚未初始化',
);
return result.rows[0];
}
/** 多个高优先级资源共用同一个五秒窗口,避免字典数量线性放大请求时长。 */
private async waitForRuns(runIds: string[], deadline: number) {
while (Date.now() < deadline) {
const result = await this.database.query<{
id: string;
status: string;
}>(
`SELECT id, status FROM atlas.platform_catalog_sync_runs
WHERE id = ANY($1::uuid[])`,
[runIds],
);
const statusById = new Map(
result.rows.map((item) => [item.id, item.status]),
);
const statuses = runIds.map(
(id) => statusById.get(id) ?? 'cancelled',
);
if (statuses.every((status) => status === 'succeeded')) return true;
if (
statuses.some((status) =>
['failed', 'cancelled'].includes(status),
)
)
return false;
await new Promise<void>((resolve) => setTimeout(resolve, 250));
}
return false;
}
/** 等待结束后重新读取任务状态,避免响应继续展示排队时的旧快照。 */
private async currentRuns(runIds: string[]) {
if (!runIds.length) return [];
const result = await this.database.query<PlatformCatalogRunRow>(
`SELECT id, platform_id, scope, scope_key, status, trigger_kind,
priority, created_at
FROM atlas.platform_catalog_sync_runs
WHERE id = ANY($1::uuid[])
ORDER BY created_at, id`,
[runIds],
);
return result.rows;
}
}
function rememberRuns(
target: Map<string, PlatformCatalogRunRow>,
runs: PlatformCatalogRunRow[],
) {
for (const run of runs) target.set(run.id, run);
}
function toRefreshRun(run: PlatformCatalogRunRow): ProductCatalogRefreshRun {
return {
id: run.id,
platform_id: run.platform_id,
scope: run.scope,
status: run.status,
trigger_kind: run.trigger_kind,
priority: run.priority,
created_at: new Date(run.created_at).toISOString(),
};
}
function hasStringValue(value: unknown) {
return (
Array.isArray(value) &&
value.some((item) => typeof item === 'string' && item.trim())
);
}
@@ -0,0 +1,515 @@
import { randomUUID } from 'node:crypto';
import { productAttributeValueRules } from '@atlas/contracts';
import type {
OzonPublishItemSnapshot,
ProductAttributeSchema,
ProductPublishJob,
ProductEditorValidation,
} from '@atlas/contracts';
import type { Database, DatabaseTransaction } from '@atlas/database';
import { ApiError } from '../common/api-error.js';
import type { IdempotencyService } from '../common/idempotency.service.js';
import type { ProfileRow, ProductRow } from './product-editor.models.js';
type Access = {
profile: (
tx: DatabaseTransaction,
id: string,
platformId: string,
) => Promise<ProfileRow>;
product: (
tx: DatabaseTransaction,
id: string,
lock?: boolean,
) => Promise<ProductRow>;
schema: (
tx: DatabaseTransaction,
platformId: string,
categoryId: string,
) => Promise<ProductAttributeSchema[]>;
validate: (
tx: DatabaseTransaction,
profileId: string,
platformId: string,
ids: string[],
) => Promise<ProductEditorValidation>;
};
type Listing = {
id: string;
variant_id: string;
display_name: string | null;
offer_id: string;
store_id: string;
status: string;
title: string | null;
tags: string[];
description: string | null;
selling_price: string;
list_price: string | null;
currency_code: string | null;
depth: string;
width: string;
height: string;
weight: string;
};
type Attribute = {
scope: string;
product_variant_id: string | null;
platform_attribute_id: string;
complex_id: string;
values: string[];
};
/** 发布创建只读取本地事实,所有远端访问由 Worker 执行。 */
export class ProductEditorPublishOperations {
constructor(
private readonly database: Database,
private readonly idempotency: IdempotencyService,
private readonly access: Access,
private readonly enabled: boolean,
) {}
async create(
workspaceId: string,
userId: string,
profileId: string,
platformId: string,
key: string,
expected: { product: number; profile: number },
ids: string[],
) {
return this.idempotency.run(
{
workspaceId,
userId,
method: 'POST',
path: `/api/product-platform-profiles/${profileId}/publish-jobs`,
key,
request: { platformId, expected, variant_ids: ids },
},
async (tx) => {
if (!this.enabled)
throw new ApiError(
503,
'OZON_PUBLISH_DISABLED',
'生产上架尚未开启,请先完成店铺 VAT 与图片交付验收',
);
if (
!ids.length ||
ids.length > 50 ||
new Set(ids).size !== ids.length
)
throw new ApiError(
422,
'PUBLISH_VARIANTS_INVALID',
'本次最多上架 50 个 SKU,请使用部分上架分批提交',
);
const initial = await this.access.profile(
tx,
profileId,
platformId,
);
const product = await this.access.product(
tx,
initial.product_id,
true,
);
await tx.query(
'SELECT id FROM atlas.product_platform_profiles WHERE id = $1 FOR UPDATE',
[profileId],
);
const profile = await this.access.profile(
tx,
profileId,
platformId,
);
if (
Number(product.revision) !== expected.product ||
Number(profile.revision) !== expected.profile
)
throw new ApiError(
409,
'VERSION_CONFLICT',
'商品资料已更新,请重新加载后上架',
);
const store = await tx.query<{ code: string }>(
`SELECT p.code FROM atlas.stores s JOIN atlas.platforms p ON p.id=s.platform_id WHERE s.id=$1 AND s.platform_id=$2 AND s.deleted_at IS NULL AND s.status='active' AND s.credential_status='valid' FOR SHARE OF s`,
[profile.target_store_id, platformId],
);
if (store.rows[0]?.code.toUpperCase() !== 'OZON')
throw new ApiError(
422,
'STORE_UNAVAILABLE',
'当前 Ozon 店铺不可用,请检查授权',
);
const category = await tx.query<{
external_category_id: string;
external_type_id: string;
}>(
`SELECT external_category_id, external_type_id FROM atlas.platform_product_categories WHERE id=$1 AND platform_id=$2 AND is_active FOR SHARE`,
[profile.platform_product_category_id, platformId],
);
if (!category.rows[0])
throw new ApiError(
422,
'CATEGORY_REQUIRED',
'请选择当前有效的 Ozon 产品类目',
);
const listings = await tx.query<Listing>(
`SELECT l.id, v.id AS variant_id, v.display_name, l.offer_id, l.store_id, l.status, l.title, l.tags, l.description, l.selling_price::text, l.list_price::text, l.currency_code,
coalesce(v.package_length_mm,p.default_package_length_mm)::text AS depth,
coalesce(v.package_width_mm,p.default_package_width_mm)::text AS width,
coalesce(v.package_height_mm,p.default_package_height_mm)::text AS height,
coalesce(v.shipping_weight_g,p.default_shipping_weight_g)::text AS weight
FROM atlas.product_variant_platform_listings l JOIN atlas.product_variants v ON v.id=l.product_variant_id JOIN atlas.products p ON p.id=v.product_id
WHERE l.profile_id=$1 AND v.product_id=$2 AND v.deleted_at IS NULL AND v.id=ANY($3::uuid[]) ORDER BY v.sort_order,v.id FOR UPDATE OF l,v`,
[profileId, product.id, ids],
);
if (listings.rows.length !== ids.length)
throw new ApiError(
422,
'PUBLISH_VARIANTS_INVALID',
'所选 SKU 不属于当前商品或已经删除',
);
if (listings.rows.some((l) => l.status === 'imported'))
throw new ApiError(
422,
'SKU_ALREADY_IMPORTED',
'所选 SKU 已在当前店铺导入,请前往在线商品查看',
);
if (listings.rows.some((l) => l.status === 'submitting'))
throw new ApiError(
409,
'SKU_IMPORT_IN_PROGRESS',
'所选 SKU 正在提交或等待平台对账,请等待任务完成',
);
if (
listings.rows.some(
(l) => l.store_id !== profile.target_store_id,
)
)
throw new ApiError(
409,
'PUBLISH_STORE_CHANGED',
'SKU 店铺与目标店铺不一致,请保存草稿后重试',
);
const validation = await this.access.validate(
tx,
profileId,
platformId,
ids,
);
if (!validation.ready)
throw new ApiError(
422,
'PUBLISH_VALIDATION_FAILED',
'请完善待上架商品资料',
[
...validation.profile.issues,
...validation.variants.flatMap((v) => v.issues),
],
);
const schema = await this.access.schema(
tx,
platformId,
profile.platform_product_category_id!,
);
const attributes = await tx.query<Attribute>(
`SELECT scope, product_variant_id, platform_attribute_id, complex_id, values FROM atlas.product_attributes WHERE profile_id=$1 AND confirmation_status='confirmed' AND (scope='common' OR product_variant_id=ANY($2::uuid[]))`,
[profileId, ids],
);
const dictionaries = await tx.query<{
attribute_id: string;
external_value_id: string;
value: string;
}>(
`SELECT attribute_id, external_value_id, value FROM atlas.platform_catalog_dictionary_values WHERE attribute_id=ANY($1::uuid[]) AND is_active`,
[
schema
.filter((d) => d.dictionary_id)
.map((d) => d.attribute_id),
],
);
const builtinDictionaryIds = schema
.filter(
(d) =>
d.editor_field &&
d.value_type === 'dictionary' &&
d.dictionary_id,
)
.map((d) => d.attribute_id);
if (builtinDictionaryIds.length) {
const states = await tx.query<{ usable: boolean }>(
`SELECT state.last_success_at IS NOT NULL AND state.last_success_at >= now()-make_interval(days=>policy.dictionary_expire_days) AS usable FROM unnest($2::uuid[]) used(id) JOIN atlas.platform_catalog_refresh_policies policy ON policy.platform_id=$1 LEFT JOIN atlas.platform_catalog_resource_states state ON state.platform_id=$1 AND state.scope_key='dictionary:' || used.id::text`,
[platformId, builtinDictionaryIds],
);
if (
states.rows.length !== builtinDictionaryIds.length ||
states.rows.some((s) => !s.usable)
)
throw new ApiError(
422,
'PUBLISH_VALIDATION_FAILED',
'品牌等内建字段的 Ozon 候选值需要更新,请重新预检',
);
}
const snapshots: OzonPublishItemSnapshot[] = [];
for (const listing of listings.rows) {
const media = await tx.query<
OzonPublishItemSnapshot['media'][number]
>(
`SELECT a.id AS asset_id,a.checksum_sha256,a.bucket,a.object_key,a.storage_provider,a.content_type FROM atlas.product_variant_listing_media m JOIN atlas.media_assets a ON a.id=m.asset_id WHERE m.listing_id=$1 ORDER BY m.sort_order`,
[listing.id],
);
if (!['not_listed', 'failed'].includes(listing.status))
throw new ApiError(
422,
'SKU_NOT_PUBLISHABLE',
'所选 SKU 当前状态不允许首次上架',
);
if (
!media.rows.length ||
media.rows.some(
(a) =>
![
'image/jpeg',
'image/png',
'image/webp',
].includes(a.content_type),
)
)
throw new ApiError(
422,
'PUBLISH_IMAGE_INVALID',
'SKU 图片必须为 JPG、PNG 或 WebP',
);
const mapped: OzonPublishItemSnapshot['attributes'] = [];
for (const definition of schema) {
if (
definition.editor_field === 'category' ||
definition.editor_field === 'rich_content'
)
continue;
let values: string[];
if (definition.editor_field) {
const fields = {
brand: product.brand,
model: product.model,
tags: (listing.tags?.length
? listing.tags
: profile.tags) as string[],
description:
listing.description ?? profile.description,
};
const field = fields[definition.editor_field];
values = Array.isArray(field)
? field
: field
? [field]
: [];
} else
values =
attributes.rows.find(
(a) =>
a.platform_attribute_id ===
definition.platform_attribute_id &&
a.complex_id ===
definition.complex_id &&
(definition.is_aspect
? a.product_variant_id ===
listing.variant_id
: a.scope === 'common'),
)?.values ?? [];
values = values
.filter((v) => typeof v === 'string' && v.trim())
.map((v) => v.trim());
// Ozon 主题标签用空格分隔后放在同一个属性值中,单值限制不代表只允许一个标签。
if (definition.editor_field === 'tags' && values.length)
values = [values.join(' ')];
const { maxValueCount } = productAttributeValueRules(definition);
if (values.length > maxValueCount)
throw new ApiError(
422,
'PUBLISH_VALIDATION_FAILED',
`${definition.name}最多 ${maxValueCount} 个值`,
[
{
field: `variants.${listing.variant_id}.attributes.${definition.platform_attribute_id}`,
code: 'ATTRIBUTE_VALUE_LIMIT',
message: `${definition.name}值数量超限`,
},
],
);
if (!values.length) continue;
mapped.push({
id: numberId(definition.platform_attribute_id),
complex_id: Number(definition.complex_id),
values: values.map((value) => {
if (definition.value_type !== 'dictionary')
return { value };
const entry = dictionaries.rows.find(
(d) =>
d.attribute_id ===
definition.attribute_id &&
(definition.editor_field
? d.value === value ||
d.external_value_id === value
: d.external_value_id === value),
);
if (!entry)
throw new ApiError(
422,
'PUBLISH_VALIDATION_FAILED',
`${definition.name}未匹配有效 Ozon 候选值`,
[
{
field: definition.editor_field
? `common.${definition.editor_field}`
: `variants.${listing.variant_id}.attributes.${definition.platform_attribute_id}`,
code: 'ATTRIBUTE_DICTIONARY_VALUE_INVALID',
message: `${definition.name}未匹配有效 Ozon 候选值`,
},
],
);
return {
dictionary_value_id: numberId(
entry.external_value_id,
),
value: entry.value,
};
}),
});
}
snapshots.push({
offer_id: listing.offer_id,
name: (profile.title_mode === 'variant'
? listing.title
: profile.title)!.trim(),
description_category_id: numberId(
category.rows[0].external_category_id,
),
type_id: numberId(category.rows[0].external_type_id),
// 首次上架以划线价预留折扣空间,后续实际售价由用户在 Ozon 调整。
price: listing.list_price!,
currency_code: profile.currency_code,
depth: Number(listing.depth),
width: Number(listing.width),
height: Number(listing.height),
weight: Number(listing.weight),
dimension_unit: 'mm',
weight_unit: 'g',
attributes: mapped,
media: media.rows,
});
}
const jobId = randomUUID();
await tx.query(
`INSERT INTO atlas.product_publish_jobs(id,workspace_id,product_id,profile_id,platform_id,store_id,product_revision,profile_revision,item_count) VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9)`,
[
jobId,
workspaceId,
product.id,
profileId,
platformId,
profile.target_store_id,
expected.product,
expected.profile,
ids.length,
],
);
for (let i = 0; i < listings.rows.length; i++) {
const listing = listings.rows[i];
await tx.query(
`INSERT INTO atlas.product_publish_job_items(workspace_id,job_id,product_id,profile_id,platform_id,store_id,listing_id,variant_id,display_name,offer_id,snapshot) VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11::jsonb)`,
[
workspaceId,
jobId,
product.id,
profileId,
platformId,
profile.target_store_id,
listing.id,
listing.variant_id,
listing.display_name,
listing.offer_id,
JSON.stringify(snapshots[i]),
],
);
}
await tx.query(
`UPDATE atlas.product_variant_platform_listings SET status='submitting',last_error=NULL,revision=revision+1,updated_at=now() WHERE id=ANY($1::uuid[])`,
[listings.rows.map((l) => l.id)],
);
await tx.query(
`INSERT INTO atlas.outbox_events(workspace_id,aggregate_type,aggregate_id,event_type,event_payload) VALUES($1,'product_publish_job',$2,'product_publish.requested',$3::jsonb)`,
[
workspaceId,
jobId,
JSON.stringify({ publish_job_id: jobId }),
],
);
return {
status: 202,
body: {
data: {
publish_job: await readJob(tx, jobId, platformId),
},
},
};
},
);
}
async read(workspaceId: string, id: string, platformId: string) {
return this.database.workspaceTransaction(workspaceId, (tx) =>
readJob(tx, id, platformId),
);
}
async list(workspaceId: string, profileId: string, platformId: string) {
return this.database.workspaceTransaction(workspaceId, async (tx) => {
await this.access.profile(tx, profileId, platformId);
const rows = await tx.query<{ id: string }>(
'SELECT id FROM atlas.product_publish_jobs WHERE profile_id=$1 AND platform_id=$2 ORDER BY created_at DESC,id DESC LIMIT 10',
[profileId, platformId],
);
const jobs = [];
for (const row of rows.rows)
jobs.push(await readJob(tx, row.id, platformId));
return jobs;
});
}
}
async function readJob(
tx: DatabaseTransaction,
id: string,
platformId: string,
): Promise<ProductPublishJob> {
const rows = await tx.query<ProductPublishJob>(
`SELECT id,profile_id,store_id,status,item_count,created_at,completed_at FROM atlas.product_publish_jobs WHERE id=$1 AND platform_id=$2`,
[id, platformId],
);
const job = rows.rows[0];
if (!job)
throw new ApiError(404, 'PUBLISH_JOB_NOT_FOUND', '发布任务不存在');
const items = await tx.query<ProductPublishJob['items'][number]>(
`SELECT id,variant_id,display_name,offer_id,status,platform_product_id,errors FROM atlas.product_publish_job_items WHERE job_id=$1 ORDER BY created_at,id`,
[id],
);
return {
...job,
created_at: new Date(job.created_at).toISOString(),
completed_at: job.completed_at
? new Date(job.completed_at).toISOString()
: null,
items: items.rows,
};
}
function numberId(value: string) {
const n = Number(value);
if (!Number.isSafeInteger(n) || n <= 0)
throw new ApiError(
422,
'OZON_ID_INVALID',
'平台目录标识无效,请刷新目录',
);
return n;
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,149 @@
import {
productAiSourceMaterialOverridesSchema,
productAiSourceMaterialSchema,
} from '@atlas/contracts';
import type {
ProductAiSourceField,
ProductAiSourceMaterial,
} from '@atlas/contracts';
type SourceMaterialProduct = {
source_title: string | null;
brand: string | null;
normalized_payload: unknown;
source_snapshot_revision: number | string;
ai_source_material_overrides: unknown;
ai_source_material_base_revision: number | string | null;
};
const productAiSourceFields: ProductAiSourceField[] = [
'title',
'description',
'price',
'brand',
'attributes',
'categories',
];
/** 计算包含用户覆盖字段的有效 AI 来源素材及其状态。 */
export function productAiSourceMaterial(product: SourceMaterialProduct) {
const latest = latestProductAiSourceMaterial(product);
const parsedOverrides = productAiSourceMaterialOverridesSchema.safeParse(
product.ai_source_material_overrides,
);
const overrides = parsedOverrides.success ? parsedOverrides.data : {};
const overriddenFields = productAiSourceFields.filter((field) =>
Object.prototype.hasOwnProperty.call(overrides, field),
);
const effective = productAiSourceMaterialSchema.parse({
...latest,
...overrides,
source_snapshot_revision: latest.source_snapshot_revision,
});
const baseRevision = product.ai_source_material_base_revision
? Number(product.ai_source_material_base_revision)
: null;
return {
effective,
state: {
latest,
overridden_fields: overriddenFields,
changed_fields: overriddenFields.filter(
(field) =>
JSON.stringify(effective[field]) !==
JSON.stringify(latest[field]),
),
base_source_snapshot_revision: baseRevision,
has_source_updates:
baseRevision !== null &&
baseRevision !== latest.source_snapshot_revision,
},
};
}
/** 仅提取用户明确覆盖的字段,供草稿持久化。 */
export function productAiSourceOverrides(
material: ProductAiSourceMaterial,
overriddenFields: ProductAiSourceField[],
) {
const overrides: Record<string, unknown> = {};
for (const field of overriddenFields) overrides[field] = material[field];
return productAiSourceMaterialOverridesSchema.parse(overrides);
}
/** 提取未经覆盖的最新采集快照,用于类目匹配和变更检测。 */
export function latestProductAiSourceMaterial(
product: SourceMaterialProduct,
): ProductAiSourceMaterial {
const payload = objectValue(product.normalized_payload);
const common = objectValue(payload?.common);
const title = objectValue(common?.title)?.value;
const description = objectValue(common?.description)?.value;
const sourcePrice = objectValue(common?.sourcePrice);
const attributes = Array.isArray(common?.attributes)
? common.attributes.flatMap((item) => {
const attribute = objectValue(item);
return typeof attribute?.key === 'string' &&
typeof attribute.name === 'string' &&
typeof attribute.value === 'string'
? [{ key: attribute.key, name: attribute.name, value: attribute.value }]
: [];
})
: [];
const categories = Array.isArray(common?.categories)
? common.categories.flatMap((item) => {
const category = objectValue(item);
return typeof category?.key === 'string' && typeof category.name === 'string'
? [{ key: category.key, name: category.name }]
: [];
})
: [];
const brand = attributes.find((attribute) =>
/brand|品牌/i.test(`${attribute.key} ${attribute.name}`),
)?.value;
const candidate: ProductAiSourceMaterial = {
title: typeof title === 'string' ? title : product.source_title,
description: typeof description === 'string' ? description : null,
price:
typeof sourcePrice?.amount === 'string' &&
typeof sourcePrice.currencyCode === 'string'
? { amount: sourcePrice.amount, currency_code: sourcePrice.currencyCode.toUpperCase() }
: null,
brand: brand ?? product.brand,
attributes,
categories,
source_snapshot_revision: Number(product.source_snapshot_revision),
};
const parsed = productAiSourceMaterialSchema.safeParse(candidate);
if (parsed.success) return parsed.data;
return {
title: product.source_title,
description: null,
price: null,
brand: product.brand,
attributes: [],
categories: [],
source_snapshot_revision: Math.max(1, Number(product.source_snapshot_revision) || 1),
};
}
/** 将未知 JSON 收窄为对象记录,供采集快照解析。 */
function objectValue(value: unknown): Record<string, unknown> | undefined {
return value && typeof value === 'object' && !Array.isArray(value)
? (value as Record<string, unknown>)
: undefined;
}
/**
* Ozon 公开详情页的后二、三级导航并不稳定;自动类目匹配只使用首级
* 面包屑和页面结构化特征中的 Type_0 值。该信号缺失时必须交给人工选择。
*/
export function capturedOzonCategorySignals(
source: ProductAiSourceMaterial,
): { rootCategoryName: string | null; typeName: string | null } {
const rootCategoryName = source.categories[0]?.name?.trim() || null;
const typeName = source.attributes.find((attribute) =>
/(?:^|:)type_0$/i.test(attribute.key.trim()),
)?.value.trim() || null;
return { rootCategoryName, typeName };
}
@@ -0,0 +1,126 @@
/**
* 编辑读模型共用的纯规格映射函数。
* 独立于工作流服务,便于单独审查继承规则。
*/
type ProductSpecSource = {
default_physical_length_mm: string | null;
default_physical_width_mm: string | null;
default_physical_height_mm: string | null;
default_net_weight_g: string | null;
default_package_length_mm: string | null;
default_package_width_mm: string | null;
default_package_height_mm: string | null;
default_shipping_weight_g: string | null;
physical_spec_status: 'missing' | 'ambiguous' | 'confirmed';
package_spec_status: 'missing' | 'ambiguous' | 'confirmed';
};
type VariantSpecSource = {
specification_values: unknown;
physical_length_mm: string | null;
physical_width_mm: string | null;
physical_height_mm: string | null;
net_weight_g: string | null;
physical_spec_status: 'missing' | 'ambiguous' | 'confirmed' | null;
package_length_mm: string | null;
package_width_mm: string | null;
package_height_mm: string | null;
shipping_weight_g: string | null;
package_spec_status: 'missing' | 'ambiguous' | 'confirmed' | null;
};
/** 将持久化的商品默认值映射为对外的实物规格。 */
export function productPhysicalSpec(product: ProductSpecSource) {
return {
length_mm: product.default_physical_length_mm,
width_mm: product.default_physical_width_mm,
height_mm: product.default_physical_height_mm,
net_weight_g: product.default_net_weight_g,
status: product.physical_spec_status,
};
}
/** 将持久化的商品默认值映射为对外的包装规格。 */
export function productPackageSpec(product: ProductSpecSource) {
return {
length_mm: product.default_package_length_mm,
width_mm: product.default_package_width_mm,
height_mm: product.default_package_height_mm,
shipping_weight_g: product.default_shipping_weight_g,
status: product.package_spec_status,
};
}
/** 映射 SKU 可空的实物规格覆盖值,不应用商品继承。 */
export function variantPhysicalSpec(variant: VariantSpecSource) {
return {
length_mm: variant.physical_length_mm,
width_mm: variant.physical_width_mm,
height_mm: variant.physical_height_mm,
net_weight_g: variant.net_weight_g,
status: variant.physical_spec_status,
};
}
/** 映射 SKU 可空的包装规格覆盖值,不应用商品继承。 */
export function variantPackageSpec(variant: VariantSpecSource) {
return {
length_mm: variant.package_length_mm,
width_mm: variant.package_width_mm,
height_mm: variant.package_height_mm,
shipping_weight_g: variant.shipping_weight_g,
status: variant.package_spec_status,
};
}
/** 计算 SKU 继承后的有效实物规格。 */
export function effectivePhysicalSpec(
product: ProductSpecSource,
variant: VariantSpecSource,
) {
const fallback = productPhysicalSpec(product);
const override = variantPhysicalSpec(variant);
return {
length_mm: override.length_mm ?? fallback.length_mm,
width_mm: override.width_mm ?? fallback.width_mm,
height_mm: override.height_mm ?? fallback.height_mm,
net_weight_g: override.net_weight_g ?? fallback.net_weight_g,
status: override.status ?? fallback.status,
};
}
/** 计算 SKU 继承后的有效包装规格。 */
export function effectivePackageSpec(
product: ProductSpecSource,
variant: VariantSpecSource,
) {
const fallback = productPackageSpec(product);
const override = variantPackageSpec(variant);
return {
length_mm: override.length_mm ?? fallback.length_mm,
width_mm: override.width_mm ?? fallback.width_mm,
height_mm: override.height_mm ?? fallback.height_mm,
shipping_weight_g:
override.shipping_weight_g ?? fallback.shipping_weight_g,
status: override.status ?? fallback.status,
};
}
/** 将不可信 JSON 过滤为稳定的来源 SKU 规格值结构。 */
export function specificationValues(value: unknown) {
if (!Array.isArray(value)) return [];
return value.filter(
(
item,
): item is {
axis_key: string;
value_key: string;
value_name: string;
} =>
!!item &&
typeof item === 'object' &&
typeof (item as { axis_key?: unknown }).axis_key === 'string' &&
typeof (item as { value_key?: unknown }).value_key === 'string' &&
typeof (item as { value_name?: unknown }).value_name === 'string',
);
}
@@ -0,0 +1,630 @@
import type {
ProductAttributeSchema,
ProductEditorValidation,
} from '@atlas/contracts';
import { ozonHashtagProblem } from '@atlas/contracts';
import { Decimal } from 'decimal.js';
import type { Database, DatabaseTransaction } from '@atlas/database';
import { ApiError } from '../common/api-error.js';
import type { ProductRow, ProfileRow } from './product-editor.models.js';
type ValidationAccess = {
requireProfileForPlatform: (
transaction: DatabaseTransaction,
profileId: string,
platformId: string,
) => Promise<ProfileRow>;
requireProduct: (
transaction: DatabaseTransaction,
productId: string,
) => Promise<ProductRow>;
attributeSchemaForCategory: (
transaction: DatabaseTransaction,
platformId: string,
categoryId: string,
) => Promise<ProductAttributeSchema[]>;
};
type VariantValidationRow = {
id: string;
display_name: string | null;
title: string | null;
tags: unknown;
listing_status: string;
offer_id: string | null;
selling_price: string | null;
list_price: string | null;
pricing_valid: boolean;
package_length_mm: string | null;
package_width_mm: string | null;
package_height_mm: string | null;
shipping_weight_g: string | null;
package_spec_status: string;
media_count: string;
};
type AttributeValidationRow = {
scope: 'common' | 'variant';
product_variant_id: string | null;
platform_attribute_id: string;
complex_id: string;
values: unknown;
confirmation_status: string;
};
type ScopedValidationIssue = {
scope: 'common' | 'variant';
variant_id: string | null;
issue: ProductEditorValidation['profile']['issues'][number];
};
/** 商品颜色是 SKU 维度的业务必填属性,即使部分 Ozon 类目将其标为选填。 */
function isRequiredVariantAttribute(definition: ProductAttributeSchema) {
return definition.is_required || definition.name.trim() === '商品颜色';
}
/**
* 计算商品草稿的首次上架就绪状态。
* 草稿保存保持宽松,这里的结果供发布前校验和页面问题汇总使用。
*/
export class ProductEditorValidationOperations {
/** 注入工作空间事务入口和编辑器统一访问边界。 */
public constructor(
private readonly database: Database,
private readonly access: ValidationAccess,
) {}
/** 按当前持久化草稿和 Ozon 类目 schema 返回结构化校验结果。 */
async validation(
workspaceId: string,
profileId: string,
platformId: string,
variantIds?: string[],
): Promise<ProductEditorValidation> {
return this.database.workspaceTransaction(
workspaceId,
async (transaction) => {
return this.validateTransaction(
transaction,
profileId,
platformId,
variantIds,
);
},
);
}
/** 创建发布任务必须在持有商品锁的同一事务内重算,防止校验与快照读到不同版本。 */
async validateTransaction(
transaction: DatabaseTransaction,
profileId: string,
platformId: string,
variantIds?: string[],
): Promise<ProductEditorValidation> {
const profile = await this.access.requireProfileForPlatform(
transaction,
profileId,
platformId,
);
const product = await this.access.requireProduct(
transaction,
profile.product_id,
);
const variants = await this.readVariants(
transaction,
profile.id,
profile.product_id,
variantIds,
);
if (variantIds && variants.length !== new Set(variantIds).size)
throw new ApiError(
422,
'PUBLISH_VARIANTS_INVALID',
'待发布 SKU 不属于当前商品,或已经被删除',
);
const attributes = await this.readAttributes(
transaction,
profile.id,
variantIds,
);
const schemaUsable = profile.platform_product_category_id
? await this.schemaUsable(
transaction,
profile.platform_id,
profile.platform_product_category_id,
)
: false;
const schema =
profile.platform_product_category_id && schemaUsable
? await this.access.attributeSchemaForCategory(
transaction,
profile.platform_id,
profile.platform_product_category_id,
)
: [];
const dictionaryIssues = schema.length
? await this.dictionaryIssues(
transaction,
profile.platform_id,
schema,
attributes,
)
: [];
const profileIssues = this.profileIssues(
product,
profile,
schema,
attributes,
);
profileIssues.push(
...dictionaryIssues
.filter((item) => item.scope === 'common')
.map((item) => item.issue),
);
const variantResults = variants.map((variant) =>
this.variantResult(
variant,
profile,
schema,
attributes,
dictionaryIssues
.filter(
(item) =>
item.scope === 'variant' &&
item.variant_id === variant.id,
)
.map((item) => item.issue),
),
);
const publishableVariants = variantResults.filter(
(variant) => variant.status !== 'not_publishable_again',
);
return {
ready:
profileIssues.length === 0 &&
publishableVariants.length > 0 &&
publishableVariants.every((variant) => variant.ready),
profile: {
ready: profileIssues.length === 0,
issues: profileIssues,
},
variants: variantResults,
};
}
/** 草稿就绪校验不得把超过过期阈值的旧 schema 当成当前 Ozon 规则。 */
private async schemaUsable(
transaction: DatabaseTransaction,
platformId: string,
categoryId: string,
) {
const result = await transaction.query<{ usable: boolean }>(
`SELECT state.last_success_at IS NOT NULL AND state.is_complete
AND state.last_success_at >= now() -
make_interval(days => policy.attribute_expire_days) AS usable
FROM atlas.platform_catalog_refresh_policies policy
LEFT JOIN atlas.platform_catalog_resource_states state
ON state.platform_id = policy.platform_id
AND state.scope_key = 'attributes:' || $2
WHERE policy.platform_id = $1`,
[platformId, categoryId],
);
return result.rows[0]?.usable === true;
}
/** 汇总商品级基础信息与通用类目属性问题。 */
private profileIssues(
product: ProductRow,
profile: ProfileRow,
schema: ProductAttributeSchema[],
attributes: AttributeValidationRow[],
): ProductEditorValidation['profile']['issues'] {
const issues: ProductEditorValidation['profile']['issues'] = [];
if (!product.source_title?.trim())
issues.push({
code: 'TITLE_REQUIRED',
field: 'common.source_title',
message: '请填写中文商品名',
});
if (profile.title_mode === 'common' && !profile.title?.trim())
issues.push({
code: 'PUBLISH_TITLE_REQUIRED',
field: 'profile.title',
message: '请填写俄文标题',
});
if (profile.title && [...profile.title].length > 500)
issues.push({
code: 'TITLE_TOO_LONG',
field: 'profile.title',
message: '俄文标题最多 500 字符',
});
if (profile.fulfillment !== 'rfbs')
issues.push({
code: 'FULFILLMENT_UNSUPPORTED',
field: 'profile.fulfillment',
message: '首次上架当前仅支持跨境 rFBS',
});
if (!product.model?.trim())
issues.push({
code: 'MODEL_REQUIRED',
field: 'common.model',
message: '请填写型号',
});
if (!product.brand?.trim())
issues.push({
code: 'BRAND_REQUIRED',
field: 'common.brand',
message: '请填写品牌',
});
if (!profile.platform_product_category_id)
issues.push({
code: 'CATEGORY_REQUIRED',
field: 'profile.platform_product_category_id',
message: '请选择产品类目',
});
if (profile.platform_product_category_id && !schema.length)
issues.push({
code: 'ATTRIBUTE_SCHEMA_REQUIRED',
field: 'attributes',
message: '当前类目的 Ozon 属性尚未同步',
});
for (const definition of schema.filter(
(item) => item.is_required && !item.is_aspect && !item.editor_field,
)) {
if (!hasConfirmedValue(attributes, 'common', null, definition))
issues.push({
code: 'OZON_ATTRIBUTE_REQUIRED',
field: `attributes.${definition.platform_attribute_id}.${definition.complex_id}`,
message: `请填写${definition.name}`,
platform_attribute_id: definition.platform_attribute_id,
});
}
return issues;
}
/** 计算单个 SKU 的发布资格与缺失项。 */
private variantResult(
variant: VariantValidationRow,
profile: ProfileRow,
schema: ProductAttributeSchema[],
attributes: AttributeValidationRow[],
dictionaryIssues: ProductEditorValidation['profile']['issues'] = [],
): ProductEditorValidation['variants'][number] {
if (variant.listing_status === 'imported')
return {
variant_id: variant.id,
display_name: variant.display_name,
status: 'not_publishable_again',
ready: false,
issues: [],
};
const issues: ProductEditorValidation['variants'][number]['issues'] = [
...dictionaryIssues,
];
// 只校验本次 SKU 实际使用的标签,独立覆盖时不受未使用的通用文案阻断。
const ownTags = stringValues(variant.tags);
const tagsProblem = ozonHashtagProblem(
ownTags.length ? ownTags : stringValues(profile.tags),
);
if (tagsProblem)
issues.push({
code: 'OZON_HASHTAG_INVALID',
field: ownTags.length
? `variants.${variant.id}.tags`
: 'profile.tags',
message: tagsProblem,
});
if (profile.title_mode === 'variant' && !variant.title?.trim())
issues.push({
code: 'PUBLISH_TITLE_REQUIRED',
field: `variants.${variant.id}.title`,
message: '请填写该 SKU 的俄文标题',
});
if (variant.listing_status === 'submitting')
issues.push({
code: 'SKU_IMPORT_IN_PROGRESS',
field: `variants.${variant.id}.listing`,
message: '该 SKU 正在首次上架,请等待当前任务完成',
});
if (variant.title && [...variant.title].length > 500)
issues.push({
code: 'TITLE_TOO_LONG',
field: `variants.${variant.id}.title`,
message: '俄文标题最多 500 字符',
});
if (variant.offer_id && [...variant.offer_id].length > 50)
issues.push({
code: 'OFFER_ID_TOO_LONG',
field: `variants.${variant.id}.offer_id`,
message: '卖家货号最多 50 字符',
});
if (Number(variant.media_count) > 30)
issues.push({
code: 'IMAGE_LIMIT_EXCEEDED',
field: `variants.${variant.id}.media_asset_ids`,
message: '每个 SKU 最多 30 张图片',
});
if (!variant.offer_id?.trim())
issues.push({
code: 'OFFER_ID_REQUIRED',
field: `variants.${variant.id}.offer_id`,
message: '请填写卖家货号',
});
if (!variant.pricing_valid)
issues.push({
code: 'PRICING_REQUIRED',
field: `variants.${variant.id}.pricing_selection`,
message: '请按当前采购价和包装确认报价',
});
const sellingPrice = variant.selling_price
? new Decimal(variant.selling_price)
: null;
const listPrice = variant.list_price ? new Decimal(variant.list_price) : null;
if (!sellingPrice?.isFinite() || sellingPrice.lte(0))
issues.push({
code: 'PRICE_REQUIRED',
field: `variants.${variant.id}.selling_price`,
message: '请先确认实际售价',
});
if (!listPrice?.isFinite() || listPrice.lte(0))
issues.push({
code: 'LIST_PRICE_REQUIRED',
field: `variants.${variant.id}.list_price`,
message: '请重新试算并确认划线价,首次上架将按划线价提交',
});
else if (sellingPrice?.isFinite() && listPrice.lt(sellingPrice))
issues.push({
code: 'LIST_PRICE_INVALID',
field: `variants.${variant.id}.list_price`,
message: '划线价不能低于实际售价,请重新确认报价',
});
if (
variant.package_spec_status !== 'confirmed' ||
!variant.package_length_mm ||
!variant.package_width_mm ||
!variant.package_height_mm ||
!variant.shipping_weight_g ||
![
variant.package_length_mm,
variant.package_width_mm,
variant.package_height_mm,
variant.shipping_weight_g,
].every(
(value) => Number.isInteger(Number(value)) && Number(value) > 0,
)
)
issues.push({
code: 'PACKAGE_SPEC_REQUIRED',
field: `variants.${variant.id}.package_spec`,
message: '请确认有效的包装重量和包装尺寸',
});
if (Number(variant.media_count) < 1)
issues.push({
code: 'IMAGE_REQUIRED',
field: `variants.${variant.id}.media_asset_ids`,
message: '请至少选择一张 SKU 图片',
});
for (const definition of schema.filter(
(item) =>
isRequiredVariantAttribute(item) &&
item.is_aspect &&
!item.editor_field,
)) {
if (
!hasConfirmedValue(
attributes,
'variant',
variant.id,
definition,
)
)
issues.push({
code: 'OZON_ATTRIBUTE_REQUIRED',
field: `variants.${variant.id}.attributes.${definition.platform_attribute_id}.${definition.complex_id}`,
message: `请填写${definition.name}`,
platform_attribute_id: definition.platform_attribute_id,
});
}
return {
variant_id: variant.id,
display_name: variant.display_name,
status: issues.length ? 'blocked' : 'ready',
ready: issues.length === 0,
issues,
};
}
/** 一次读取 SKU 的售价、有效包装规格和素材数量。 */
private async readVariants(
transaction: DatabaseTransaction,
profileId: string,
productId: string,
variantIds?: string[],
) {
const variants = await transaction.query<VariantValidationRow>(
`SELECT v.id, v.display_name, listing.title, listing.tags, listing.status AS listing_status,
listing.offer_id, listing.selling_price::text, listing.list_price::text, listing.pricing_valid,
coalesce(v.package_length_mm, product.default_package_length_mm)::text AS package_length_mm,
coalesce(v.package_width_mm, product.default_package_width_mm)::text AS package_width_mm,
coalesce(v.package_height_mm, product.default_package_height_mm)::text AS package_height_mm,
coalesce(v.shipping_weight_g, product.default_shipping_weight_g)::text AS shipping_weight_g,
coalesce(v.package_spec_status, product.package_spec_status) AS package_spec_status,
count(media.id)::text AS media_count
FROM atlas.product_variants v
JOIN atlas.products product ON product.id = v.product_id
JOIN atlas.product_variant_platform_listings listing
ON listing.product_variant_id = v.id AND listing.profile_id = $1
LEFT JOIN atlas.product_variant_listing_media media ON media.listing_id = listing.id
WHERE v.product_id = $2 AND v.deleted_at IS NULL
AND ($3::uuid[] IS NULL OR v.id = ANY($3::uuid[]))
GROUP BY v.id, v.display_name, listing.title, listing.tags, listing.status, listing.offer_id,
listing.selling_price, listing.list_price, listing.pricing_valid, product.default_package_length_mm,
product.default_package_width_mm, product.default_package_height_mm,
product.default_shipping_weight_g, product.package_spec_status
ORDER BY v.sort_order, v.id`,
[profileId, productId, variantIds ?? null],
);
return variants.rows;
}
/** 读取当前发布配置下已经持久化的动态属性值。 */
private async readAttributes(
transaction: DatabaseTransaction,
profileId: string,
variantIds?: string[],
) {
const attributes = await transaction.query<AttributeValidationRow>(
`SELECT scope, product_variant_id, platform_attribute_id, complex_id,
values, confirmation_status
FROM atlas.product_attributes
WHERE profile_id = $1
AND ($2::uuid[] IS NULL OR scope = 'common'
OR product_variant_id = ANY($2::uuid[]))`,
[profileId, variantIds ?? null],
);
return attributes.rows;
}
/** 发布校验只检查草稿实际引用的字典,并要求平台缓存仍在有效期内。 */
private async dictionaryIssues(
transaction: DatabaseTransaction,
platformId: string,
schema: ProductAttributeSchema[],
attributes: AttributeValidationRow[],
): Promise<ScopedValidationIssue[]> {
const definitions = new Map(
schema
.filter(
(item) =>
item.value_type === 'dictionary' &&
item.dictionary_id &&
!item.editor_field,
)
.map((item) => [
`${item.platform_attribute_id}\u0000${item.complex_id}`,
item,
]),
);
const used = attributes.flatMap((attribute) => {
if (attribute.confirmation_status !== 'confirmed') return [];
const values = stringValues(attribute.values).filter((value) =>
value.trim(),
);
const definition = definitions.get(
`${attribute.platform_attribute_id}\u0000${attribute.complex_id}`,
);
return definition && values.length
? [{ attribute, definition, values }]
: [];
});
const attributeIds = [
...new Set(used.map((item) => item.definition.attribute_id)),
];
if (!attributeIds.length) return [];
const states = await transaction.query<{
attribute_id: string;
usable: boolean;
}>(
`SELECT requested.attribute_id,
state.last_success_at IS NOT NULL
AND state.last_success_at >= now() -
make_interval(days => policy.dictionary_expire_days) AS usable
FROM unnest($2::uuid[]) requested(attribute_id)
JOIN atlas.platform_catalog_refresh_policies policy
ON policy.platform_id = $1
LEFT JOIN atlas.platform_catalog_resource_states state
ON state.platform_id = $1
AND state.scope_key = 'dictionary:' || requested.attribute_id::text`,
[platformId, attributeIds],
);
const usableByAttribute = new Map(
states.rows.map((item) => [item.attribute_id, item.usable]),
);
const values = await transaction.query<{
attribute_id: string;
value_id: string;
}>(
`SELECT attribute_id, external_value_id AS value_id
FROM atlas.platform_catalog_dictionary_values
WHERE attribute_id = ANY($1::uuid[]) AND is_active`,
[attributeIds],
);
const valueKeys = new Set(
values.rows.map(
(item) => `${item.attribute_id}\u0000${item.value_id}`,
),
);
const seen = new Set<string>();
const issues: ScopedValidationIssue[] = [];
for (const item of used) {
const field =
item.attribute.scope === 'variant'
? `variants.${item.attribute.product_variant_id}.attributes.${item.definition.platform_attribute_id}.${item.definition.complex_id}`
: `attributes.${item.definition.platform_attribute_id}.${item.definition.complex_id}`;
const issue = usableByAttribute.get(item.definition.attribute_id)
? item.values.every((value) =>
valueKeys.has(
`${item.definition.attribute_id}\u0000${value}`,
),
)
? null
: {
code: 'ATTRIBUTE_DICTIONARY_VALUE_INVALID',
field,
message: `${item.definition.name}包含已失效的 Ozon 候选值`,
platform_attribute_id:
item.definition.platform_attribute_id,
}
: {
code: 'ATTRIBUTE_DICTIONARY_REFRESH_PENDING',
field,
message: `${item.definition.name}的 Ozon 候选值需要更新`,
platform_attribute_id:
item.definition.platform_attribute_id,
};
if (!issue) continue;
const key = `${item.attribute.scope}\u0000${item.attribute.product_variant_id ?? ''}\u0000${issue.code}\u0000${field}`;
if (seen.has(key)) continue;
seen.add(key);
issues.push({
scope: item.attribute.scope,
variant_id: item.attribute.product_variant_id,
issue,
});
}
return issues;
}
}
/** 只有已确认且至少包含一个非空字符串的值才满足发布要求。 */
function hasConfirmedValue(
attributes: AttributeValidationRow[],
scope: 'common' | 'variant',
variantId: string | null,
definition: ProductAttributeSchema,
) {
return attributes.some(
(attribute) =>
attribute.scope === scope &&
attribute.product_variant_id === variantId &&
attribute.platform_attribute_id ===
definition.platform_attribute_id &&
attribute.complex_id === definition.complex_id &&
attribute.confirmation_status === 'confirmed' &&
stringValues(attribute.values).some((value) => value.trim()),
);
}
function stringValues(value: unknown): string[] {
return Array.isArray(value)
? value.filter((item): item is string => typeof item === 'string')
: [];
}
@@ -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 };
}
}
+6
View File
@@ -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 {}
+130
View File
@@ -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');
}
+382
View File
@@ -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,
})),
);
}
+15
View File
@@ -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;
}
}
+430
View File
@@ -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);
});
});
+17
View File
@@ -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); });
});
+466
View File
@@ -0,0 +1,466 @@
import { beforeEach, afterEach, describe, it, expect, vi } from 'vitest';
import { randomUUID, randomInt } from 'node:crypto';
import { loadEnvFile } from 'node:process';
import { Client } from 'pg';
import type { Database, DatabaseTransaction } from '@atlas/database';
import type {
CommissionImportRequest,
CommissionImportPublish,
} from '@atlas/contracts';
import { CommissionImportService } from '../src/pricing/commission-import.service.js';
import { parseCommissionDocument } from '../src/pricing/commission-import-parser.js';
import { IdempotencyService } from '../src/common/idempotency.service.js';
import { PricingService } from '../src/pricing/pricing.service.js';
import { fingerprint } from '../src/pricing/pricing-engine.js';
import type { AppConfig } from '../src/config/app-config.module.js';
loadEnvFile(new URL('../../../.env', import.meta.url));
const makeDocument = (
parent = '900000001',
child = '900000002',
rates = ['12.00', '14.00', '20.50'],
) => ({
code: 1,
msg: '获取成功',
data: [
{
id: 1,
label: '测试分组',
value: '测试分组',
cate_id: parent,
children: [
{
id: '1.1',
label: '测试类目',
value: '测试类目',
cate_id: child,
children: rates.map((rate, i) => ({
id: `1.1.${i + 1}`,
label: `${['售价 ≤ 1500₽', '1500₽ < 售价 ≤ 5000₽', '售价 > 5000₽'][i]} (${rate}%)`,
value: `${i + 1},${rate}`,
})),
},
],
},
],
});
describe('佣金来源解析', () => {
it('保留三个精确区间并将百分数转换为比例', () => {
const c = parseCommissionDocument(makeDocument())[0];
expect(c.bands.map((b) => b.commission_rate)).toEqual([
'0.120000',
'0.140000',
'0.205000',
]);
expect(
c.bands.map((b) => [
b.min_price,
b.max_price,
b.min_inclusive,
b.max_inclusive,
]),
).toEqual([
['0', '1500', false, true],
['1500', '5000', false, true],
['5000', null, false, false],
]);
});
it('拒绝标签和值不一致、漏档、重复类目及超大文件', () => {
const d = makeDocument();
d.data[0].children[0].children[0].value = '1,15.00';
expect(() => parseCommissionDocument(d)).toThrow();
expect(() => parseCommissionDocument(makeDocument('1', '1'))).toThrow();
const missing = makeDocument();
missing.data[0].children[0].children.pop();
expect(() => parseCommissionDocument(missing)).toThrow();
expect(() =>
parseCommissionDocument({ x: 'a'.repeat(2 * 1024 * 1024) }),
).toThrow();
});
});
// 所有业务数据在测试事务内,结束一律回滚;不会发布或替换用户的实际佣金版本。
describe('佣金导入 PostgreSQL 集成', () => {
let client: Client,
db: Database,
service: CommissionImportService,
pricing: PricingService;
let user: string,
workspace: string,
platform: string,
store: string,
leaf: string,
oldId: string,
oldRule: string;
let request: CommissionImportRequest;
let savepoint = 0;
beforeEach(async () => {
const connection = new URL(process.env.MIGRATOR_DATABASE_URL!);
connection.username = process.env.POSTGRES_ADMIN_USER!;
connection.password = process.env.POSTGRES_ADMIN_PASSWORD!;
client = new Client({ connectionString: connection.toString() });
await client.connect();
await client.query('BEGIN');
user = randomUUID();
workspace = randomUUID();
await client.query(
`INSERT INTO atlas.users(id,email,password_hash) VALUES($1,$2,'test-only')`,
[user, `${user}@example.test`],
);
await client.query(
`INSERT INTO atlas.workspaces(id,name,slug) VALUES($1,'佣金回滚测试',$2)`,
[workspace, workspace],
);
await client.query(
`INSERT INTO atlas.workspace_memberships(workspace_id,user_id,role) VALUES($1,$2,'owner')`,
[workspace, user],
);
await client.query(
`INSERT INTO atlas.system_admin_grants(user_id) VALUES($1)`,
[user],
);
await client.query('SET LOCAL ROLE atlas_app');
await client.query(`SELECT set_config('app.workspace_id',$1,true)`, [
workspace,
]);
platform = (
await client.query(
`SELECT id FROM atlas.platforms WHERE upper(code::text)='OZON'`,
)
).rows[0].id;
store = (
await client.query(
`INSERT INTO atlas.stores(workspace_id,platform_id,name,platform_client_id,currency_code,status,credential_status) VALUES($1,$2,'佣金测试',$3,'RUB','active','valid') RETURNING id`,
[workspace, platform, randomUUID()],
)
).rows[0].id;
const p = String(randomInt(100000000, 999999990)),
c = String(Number(p) + 1);
const parent = (
await client.query(
`INSERT INTO atlas.platform_product_categories(platform_id,external_category_id,external_type_id,name,path,is_active) VALUES($1,$2,'0','测试分组','测试分组',false) RETURNING id`,
[platform, p],
)
).rows[0].id;
const cat = (
await client.query(
`INSERT INTO atlas.platform_product_categories(platform_id,parent_id,external_category_id,external_type_id,name,path,is_active) VALUES($1,$2,$3,'0','测试类目','测试分组 / 测试类目',false) RETURNING id`,
[platform, parent, c],
)
).rows[0].id;
leaf = (
await client.query(
`INSERT INTO atlas.platform_product_categories(platform_id,parent_id,external_category_id,external_type_id,name,path) VALUES($1,$2,$3,'991','测试类型','测试类型') RETURNING id`,
[platform, cat, c],
)
).rows[0].id;
request = {
platform_id: platform,
version: `qa-${randomUUID()}`,
source_reference: '自动化测试,事务回滚',
captured_on: '2026-09-28',
document: makeDocument(p, c),
};
const oldCategory = parseCommissionDocument(
makeDocument(p, c, ['10.00', '11.00', '12.00']),
)[0];
oldCategory.mapped_types = 1;
await client.query(
`UPDATE atlas.commission_imports SET is_current=false WHERE platform_id=$1 AND is_current`,
[platform],
);
oldId = (
await client.query(
`INSERT INTO atlas.commission_imports(platform_id,version,source_reference,captured_on,document,content_hash,categories,summary,activated_by,activated_at) VALUES($1,$2,'旧测试版本','2026-09-27','{}',$3,$4,'{}',$5,now()-interval '1 day') RETURNING id`,
[
platform,
`old-${randomUUID()}`,
fingerprint(randomUUID()),
JSON.stringify([oldCategory]),
user,
],
)
).rows[0].id;
const cc = (
await client.query(
`INSERT INTO atlas.commission_categories(platform_id,source_category_key,name,path,source,status) VALUES($1,$2,'测试类目',$3,'competitor_capture','active') RETURNING id`,
[platform, `maozierp:${c}`, `test-${c}`],
)
).rows[0].id;
oldRule = (
await client.query(
`INSERT INTO atlas.commission_category_rules(commission_category_id,fulfillment,market_code,currency_code,commission_rate,source,version,status,effective_from,import_id) VALUES($1,'rfbs','RU','RUB',0.1,'competitor_capture','old','active',now()-interval '1 day',$2) RETURNING id`,
[cc, oldId],
)
).rows[0].id;
await client.query(
`INSERT INTO atlas.platform_product_category_commission_mappings(platform_id,platform_product_category_id,commission_category_id,fulfillment,source,status,effective_from,import_id) VALUES($1,$2,$3,'rfbs','competitor_capture','active',now()-interval '1 day',$4)`,
[platform, leaf, cc, oldId],
);
const transaction = async <T>(
work: (tx: DatabaseTransaction) => Promise<T>,
) => {
const sp = `operation_${++savepoint}`;
await client.query(`SAVEPOINT ${sp}`);
try {
const result = await work(client);
await client.query(`RELEASE SAVEPOINT ${sp}`);
return result;
} catch (e) {
await client.query(`ROLLBACK TO SAVEPOINT ${sp}`);
throw e;
}
};
db = {
transaction,
query: (sql: string, args: unknown[]) =>
transaction((tx) => tx.query(sql, args)),
workspaceTransaction: (
id: string,
work: (tx: DatabaseTransaction) => Promise<unknown>,
) =>
transaction(async (tx) => {
await tx.query(
`SELECT set_config('app.workspace_id',$1,true)`,
[id],
);
return work(tx);
}),
} as unknown as Database;
const config = {
IDEMPOTENCY_KEY_SECRET: 'commission-test-idempotency-secret',
pricingAllowDraftRates: false,
} as AppConfig;
const idem = new IdempotencyService(db, config);
service = new CommissionImportService(db, idem);
pricing = new PricingService(db, idem, config);
});
afterEach(async () => {
if (client) {
await client.query('ROLLBACK');
await client.end();
}
});
const publishInput = async (
s: CommissionImportService,
r: CommissionImportRequest,
): Promise<CommissionImportPublish> => ({
...r,
preview_hash: (await s.preview(r)).preview_hash,
confirm_scope: true,
});
it('预览差异,启用三档规则,按内部及外部类目查询;旧规则保留', async () => {
const p = await service.preview(request);
expect(p.summary.eligible).toBe(1);
expect(p.categories[0].change).toBe('changed');
const r = await service.publish(
workspace,
user,
randomUUID(),
await publishInput(service, request),
);
expect(r.status).toBe(201);
const rules = await pricing.commissionRules(workspace, store, leaf);
expect(rules.bands.map((b) => b.commission_rate)).toEqual([
'0.120000',
'0.140000',
'0.205000',
]);
expect(
(
await pricing.commissionRules(
workspace,
store,
undefined,
p.categories[0].source_category_id,
)
).bands,
).toEqual(rules.bands);
expect(
(
await client.query(
`SELECT status,commission_rate FROM atlas.commission_category_rules WHERE id=$1`,
[oldRule],
)
).rows[0],
).toEqual({ status: 'archived', commission_rate: '0.100000' });
expect((await service.preview(request)).preview_hash).not.toBe(
p.preview_hash,
);
});
it('相同幂等请求只发布一次,旧预览不能再启用', async () => {
const input = await publishInput(service, request),
key = randomUUID();
const a = await service.publish(workspace, user, key, input);
expect(await service.publish(workspace, user, key, input)).toEqual(
JSON.parse(JSON.stringify(a)),
);
await expect(
service.publish(workspace, user, randomUUID(), input),
).rejects.toMatchObject({
response: { error: { code: 'COMMISSION_PREVIEW_STALE' } },
});
const fresh = await publishInput(service, request);
await expect(
service.publish(workspace, user, randomUUID(), fresh),
).rejects.toMatchObject({
response: { error: { code: 'COMMISSION_IMPORT_EXISTS' } },
});
});
it('0% 佣金可以启用并用于类目查询', async () => {
const d = request.document as ReturnType<typeof makeDocument>;
d.data[0].children[0].children = makeDocument('1', '2', [
'0.00',
'0.00',
'0.00',
]).data[0].children[0].children;
const p = await service.preview(request);
expect(p.summary).toMatchObject({
eligible: 1,
excluded: 0,
mapped_types: 1,
});
await service.publish(
workspace,
user,
randomUUID(),
await publishInput(service, request),
);
expect(
(await pricing.commissionRules(workspace, store, leaf)).bands.map(
(b) => b.commission_rate,
),
).toEqual(['0.000000', '0.000000', '0.000000']);
});
it('错误父类目被提示;无可映射规则时拒绝空版本', async () => {
const d = request.document as ReturnType<typeof makeDocument>;
d.data[0].cate_id = '123';
expect(
(await service.preview(request)).categories[0].issues.join(),
).toContain('父类目');
await expect(
service.publish(
workspace,
user,
randomUUID(),
await publishInput(service, request),
),
).rejects.toMatchObject({
response: { error: { code: 'COMMISSION_IMPORT_INVALID' } },
});
});
it('目录变化后必须重新预览', async () => {
const input = await publishInput(service, request);
await client.query(
`UPDATE atlas.platform_product_categories SET is_active=false WHERE id=$1`,
[leaf],
);
await expect(
service.publish(workspace, user, randomUUID(), input),
).rejects.toMatchObject({
response: { error: { code: 'COMMISSION_PREVIEW_STALE' } },
});
});
it('撤销管理员授权阻止发布,其他工作空间不能读取门店规则', async () => {
const input = await publishInput(service, request);
await client.query('RESET ROLE');
await client.query(
`UPDATE atlas.system_admin_grants SET revoked_at=now() WHERE user_id=$1`,
[user],
);
await client.query('SET LOCAL ROLE atlas_app');
await expect(
service.publish(workspace, user, randomUUID(), input),
).rejects.toMatchObject({
response: { error: { code: 'SYSTEM_ADMIN_REQUIRED' } },
});
await expect(
pricing.commissionRules(randomUUID(), store, leaf),
).rejects.toMatchObject({
response: { error: { code: 'STORE_NOT_FOUND' } },
});
});
it('有目录异常时无需额外确认,仍可启用其他可用类目', async () => {
const d = request.document as ReturnType<typeof makeDocument>;
d.data[0].children.push(
makeDocument('1', '899999999').data[0].children[0],
);
const preview = await service.preview(request);
expect(preview.summary).toMatchObject({ eligible: 1, excluded: 1 });
const input = await publishInput(service, request);
await service.publish(workspace, user, randomUUID(), input);
expect(
(await pricing.commissionRules(workspace, store, leaf)).bands,
).toHaveLength(3);
expect(
(
await service.detail((await service.list(platform))[0].id)
).categories.filter((c) => c.status === 'excluded'),
).toHaveLength(1);
});
it('后续写入失败时旧版本保持有效', async () => {
const input = await publishInput(service, request);
const original = client.query.bind(client);
const spy = vi.spyOn(client, 'query').mockImplementation(((
sql: string,
args: unknown[],
) => {
if (
typeof sql === 'string' &&
sql.includes(
'INSERT INTO atlas.platform_product_category_commission_mappings',
)
)
throw new Error('模拟映射写入失败');
return original(sql, args);
}) as typeof client.query);
try {
await expect(
service.publish(workspace, user, randomUUID(), input),
).rejects.toThrow('模拟映射写入失败');
} finally {
spy.mockRestore();
}
expect(
(
await client.query(
'SELECT is_current FROM atlas.commission_imports WHERE id=$1',
[oldId],
)
).rows[0].is_current,
).toBe(true);
expect(
(
await client.query(
'SELECT status FROM atlas.commission_category_rules WHERE id=$1',
[oldRule],
)
).rows[0].status,
).toBe('active');
expect(
(
await client.query(
'SELECT id FROM atlas.commission_imports WHERE version=$1',
[request.version],
)
).rowCount,
).toBe(0);
});
it('数据库拒绝直接改写已启用的历史费率', async () => {
await expect(
db.transaction((tx) =>
tx.query(
`UPDATE atlas.commission_category_rules SET commission_rate=0.9 WHERE id=$1`,
[oldRule],
),
),
).rejects.toMatchObject({ code: '23514' });
expect(
(
await client.query(
`SELECT commission_rate FROM atlas.commission_category_rules WHERE id=$1`,
[oldRule],
)
).rows[0].commission_rate,
).toBe('0.100000');
});
});
+649
View File
@@ -0,0 +1,649 @@
import assert from 'node:assert/strict';
import { randomUUID } from 'node:crypto';
import type { AddressInfo } from 'node:net';
import type { Database } from '@atlas/database';
import type { NestExpressApplication } from '@nestjs/platform-express';
import { afterAll, beforeAll, describe, it } from 'vitest';
import { DATABASE } from '../src/database/database.module.js';
import { createApplication } from '../src/main.js';
import { PRODUCT_VARIANT_LIMIT } from '@atlas/contracts';
let app: NestExpressApplication;
let database: Database;
let baseUrl = '';
let workspaceId = '';
let userId = '';
let webAccessToken = '';
async function request(path: string, init: RequestInit = {}) {
const response = await fetch(`${baseUrl}${path}`, {
redirect: 'manual',
...init,
headers: { 'Content-Type': 'application/json', ...init.headers },
});
return {
response,
body:
response.status === 204 || response.status === 302
? undefined
: ((await response.json()) as any),
};
}
describe('扩展授权与采集 HTTP 集成测试', () => {
beforeAll(async () => {
Object.assign(process.env, {
NODE_ENV: 'test',
SWAGGER_ENABLED: 'false',
AUTH_REGISTER_IP_LIMIT: '100',
AUTH_REGISTER_EMAIL_LIMIT: '100',
});
const created = await createApplication();
app = created.app;
database = app.get<Database>(DATABASE);
await app.listen(0, '127.0.0.1');
baseUrl = `http://127.0.0.1:${(app.getHttpServer().address() as AddressInfo).port}`;
});
afterAll(async () => {
await database.workspaceTransaction(workspaceId, async (tx) => {
await tx.query(
'DELETE FROM atlas.idempotency_requests WHERE workspace_id = $1',
[workspaceId],
);
await tx.query(
'DELETE FROM atlas.outbox_events WHERE workspace_id = $1',
[workspaceId],
);
await tx.query(
'DELETE FROM atlas.operations WHERE workspace_id = $1',
[workspaceId],
);
await tx.query(
'DELETE FROM atlas.collection_asset_transfers WHERE workspace_id = $1',
[workspaceId],
);
await tx.query(
'DELETE FROM atlas.product_variant_platform_listings WHERE workspace_id = $1',
[workspaceId],
);
await tx.query(
'DELETE FROM atlas.product_platform_profiles WHERE workspace_id = $1',
[workspaceId],
);
await tx.query('DELETE FROM atlas.stores WHERE workspace_id = $1', [
workspaceId,
]);
await tx.query(
'DELETE FROM atlas.product_variants WHERE workspace_id = $1',
[workspaceId],
);
await tx.query(
'DELETE FROM atlas.products WHERE workspace_id = $1',
[workspaceId],
);
await tx.query(
'DELETE FROM atlas.collection_items WHERE workspace_id = $1',
[workspaceId],
);
await tx.query(
'DELETE FROM atlas.extension_authorization_codes WHERE workspace_id = $1',
[workspaceId],
);
await tx.query(
'DELETE FROM atlas.extension_sessions WHERE workspace_id = $1',
[workspaceId],
);
});
await database.query(
'DELETE FROM atlas.workspace_memberships WHERE user_id = $1',
[userId],
);
await database.query('DELETE FROM atlas.workspaces WHERE id = $1', [
workspaceId,
]);
await database.query('DELETE FROM atlas.users WHERE id = $1', [userId]);
await app.close();
});
it('未登录令牌返回可操作的扩展会话错误', async () => {
const session = await request('/api/extension/session');
assert.equal(session.response.status, 401);
assert.equal(session.body.error.code, 'SESSION_EXPIRED');
assert.equal(
session.body.error.message,
'Atlas ERP 登录已过期,请重新登录',
);
});
it('通过 Atlas Web access token 写入来源商品,并以新快照覆盖重复采集', async () => {
const registration = await request('/api/auth/register', {
method: 'POST',
body: JSON.stringify({
email: `extension-${randomUUID()}@example.test`,
password: 'correct-horse-battery-staple-123',
password_confirmation: 'correct-horse-battery-staple-123',
}),
});
assert.equal(registration.response.status, 201);
userId = registration.body.data.user.id;
workspaceId = registration.body.data.workspace.id;
webAccessToken = registration.body.data.access_token as string;
const session = await request('/api/extension/session', {
headers: { Authorization: `Bearer ${webAccessToken}` },
});
assert.equal(session.response.status, 200);
assert.equal(session.body.data.workspace.id, workspaceId);
const payload = {
source: {
platform: 'ozon',
itemKey: '123456789',
canonicalUrl: 'https://www.ozon.ru/product/demo-123456789/',
collectedAt: new Date().toISOString(),
parserVersion: 'test-1',
},
common: {
title: { value: '来源商品', confidence: 'verified' },
sourcePrice: { amount: '99.90', currencyCode: 'RUB' },
attributes: [],
categories: [],
},
variantAxes: [],
variants: [
{
sourceVariantKey: '123456789',
sourceSku: '123456789',
displayName: '默认 SKU',
selections: [],
confidence: 'verified',
evidenceRefs: ['page'],
},
],
assets: [
{
url: 'https://images.example.test/product.jpg',
role: 'source_main',
relation: 'product_candidate',
sortOrder: 0,
evidenceRef: 'page',
},
],
evidence: [{ ref: 'page', kind: 'page', path: 'test' }],
};
const firstKey = randomUUID();
const first = await request('/api/extension/materials', {
method: 'POST',
headers: {
Authorization: `Bearer ${webAccessToken}`,
'Idempotency-Key': firstKey,
},
body: JSON.stringify(payload),
});
assert.equal(first.response.status, 201);
assert.equal(first.body.data.reused, false);
assert.equal(first.body.data.collection_item.status, 'ready_for_edit');
assert.equal(first.body.data.materialization.pending_selection, 1);
assert.equal(first.body.data.materialization.queued, 0);
const editorEntry = new URL(first.body.data.editor_entry_path, 'http://atlas.example.test');
assert.equal(editorEntry.pathname, `/products/${first.body.data.product_id}/editor`);
assert.equal(editorEntry.searchParams.get('collection_item_id'), first.body.data.collection_item.id);
const replay = await request('/api/extension/materials', {
method: 'POST',
headers: {
Authorization: `Bearer ${webAccessToken}`,
'Idempotency-Key': firstKey,
},
body: JSON.stringify(payload),
});
assert.deepEqual(replay.body, first.body);
const profilesBeforeEntry = await database.workspaceTransaction(workspaceId, (tx) =>
tx.query('SELECT id FROM atlas.product_platform_profiles WHERE product_id=$1', [first.body.data.product_id]),
);
assert.equal(profilesBeforeEntry.rowCount, 0);
const source1688 = {
source: {
platform: '1688', itemKey: '1688-price-image-test',
canonicalUrl: 'https://detail.1688.com/offer/123456789.html',
collectedAt: new Date().toISOString(), parserVersion: '1688-test',
},
common: { attributes: [], categories: [] },
variantAxes: [],
variants: [{
sourceVariantKey: '6100454176961', sourceSku: '6100454176961',
displayName: '六代目卡卡西25cm', selections: [],
sourcePrice: { amount: '31.335', currencyCode: 'CNY' },
confidence: 'verified', evidenceRefs: ['1688:sku-map'],
}],
assets: [{
url: 'https://cbu01.alicdn.com/sku.jpg', role: 'source_sku',
relation: 'axis_value', variantSourceKey: '6100454176961',
sortOrder: 0, evidenceRef: '1688:sku-map',
}],
evidence: [{ ref: '1688:sku-map', kind: 'state', path: 'skuModel.skuInfoMap' }],
};
const captured1688 = await request('/api/extension/materials', {
method: 'POST',
headers: {
Authorization: `Bearer ${webAccessToken}`,
'Idempotency-Key': randomUUID(),
},
body: JSON.stringify(source1688),
});
assert.equal(captured1688.response.status, 201);
const associated1688 = await database.workspaceTransaction(workspaceId, async (tx) =>
tx.query<{ purchase_cost_cny: string; source_variant_id: string; variant_id: string }>(
`SELECT v.purchase_cost_cny::text, t.source_variant_id, v.id AS variant_id
FROM atlas.product_variants v
JOIN atlas.collection_asset_transfers t ON t.product_id = v.product_id
WHERE v.product_id = $1 AND t.role = 'source_sku'`,
[captured1688.body.data.product_id],
),
);
assert.equal(associated1688.rows[0]?.purchase_cost_cny, '31.3400');
assert.equal(associated1688.rows[0]?.source_variant_id, associated1688.rows[0]?.variant_id);
const refreshed1688 = await request('/api/extension/materials', {
method: 'POST',
headers: {
Authorization: `Bearer ${webAccessToken}`,
'Idempotency-Key': randomUUID(),
},
body: JSON.stringify({
...source1688,
variants: [{
...source1688.variants[0],
sourcePrice: { amount: '32.525', currencyCode: 'CNY' },
}],
}),
});
assert.equal(refreshed1688.response.status, 200);
const updatedCost = await database.workspaceTransaction(workspaceId, async (tx) =>
tx.query<{ purchase_cost_cny: string }>(
`SELECT purchase_cost_cny::text FROM atlas.product_variants WHERE product_id = $1`,
[captured1688.body.data.product_id],
),
);
assert.equal(updatedCost.rows[0]?.purchase_cost_cny, '32.5300');
const platforms = await request('/api/platforms', {
headers: { Authorization: `Bearer ${webAccessToken}` },
});
const targetPlatform = platforms.body.data.find(
(platform: { code: string }) => platform.code === 'OZON',
);
assert.ok(targetPlatform);
await database.workspaceTransaction(workspaceId, async (tx) => {
await tx.query(
`INSERT INTO atlas.stores
(workspace_id, platform_id, name, platform_client_id, currency_code,
is_default, status, credential_status)
VALUES (current_setting('app.workspace_id')::uuid, $1, '复采测试店铺',
$2, 'RUB', true, 'active', 'valid')`,
[targetPlatform.id, `refresh-${randomUUID()}`],
);
});
const editorContext = await request(
`/api/collection-items/${editorEntry.searchParams.get('collection_item_id')}/editor-contexts`,
{
method: 'POST',
headers: {
Authorization: `Bearer ${webAccessToken}`,
'X-Atlas-Platform-Id': targetPlatform.id,
'Idempotency-Key': randomUUID(),
},
},
);
assert.equal(editorContext.response.status, 201);
assert.equal(editorContext.body.data.product_id, first.body.data.product_id);
assert.equal(editorContext.body.data.editor_path, editorEntry.pathname);
const readEditor = (productId: string) => request(
`/api/products/${productId}/editor`,
{ headers: {
Authorization: `Bearer ${webAccessToken}`,
'X-Atlas-Platform-Id': targetPlatform.id,
} },
);
const initialEditor = await readEditor(first.body.data.product_id);
assert.equal(initialEditor.response.status, 200);
assert.equal(initialEditor.body.data.product.common.purchase_url, payload.source.canonicalUrl);
const sourceLink = await database.workspaceTransaction(workspaceId, (tx) => tx.query(
'SELECT purchase_url FROM atlas.products WHERE id=$1',
[captured1688.body.data.product_id],
));
assert.equal(sourceLink.rows[0].purchase_url, source1688.source.canonicalUrl);
await request(`/api/collection-items/${captured1688.body.data.collection_item.id}/editor-contexts`, {
method: 'POST',
headers: {
Authorization: `Bearer ${webAccessToken}`,
'X-Atlas-Platform-Id': targetPlatform.id,
'Idempotency-Key': randomUUID(),
},
});
// 模拟历史采集商品:原来只有采集记录保存了页面地址,商品货源字段为空。
await database.workspaceTransaction(workspaceId, (tx) => tx.query(
'UPDATE atlas.products SET purchase_url=NULL WHERE id=$1',
[captured1688.body.data.product_id],
));
const historicalEditor = await readEditor(captured1688.body.data.product_id);
assert.equal(historicalEditor.response.status, 200);
assert.equal(historicalEditor.body.data.product.common.purchase_url, source1688.source.canonicalUrl);
// Simulate real draft work before the source page is collected again.
// Re-collection must neither delete this SKU nor overwrite its Atlas
// fields / platform mapping just because a newer source snapshot arrives.
await database.workspaceTransaction(workspaceId, async (tx) => {
const variant = await tx.query<{ id: string }>(
`SELECT id FROM atlas.product_variants
WHERE product_id = $1 AND source_variant_key = '123456789'`,
[first.body.data.product_id],
);
await tx.query(
`UPDATE atlas.products SET source_title = '人工确认商品名', purchase_url = 'https://detail.1688.com/offer/888888888.html'
WHERE id = $1`,
[first.body.data.product_id],
);
await tx.query(
`UPDATE atlas.product_variants
SET display_name = '人工确认 SKU', purchase_cost_cny = '88.80'
WHERE id = $1`,
[variant.rows[0].id],
);
await tx.query(
`UPDATE atlas.product_variant_platform_listings
SET offer_id = 'manual-offer-001'
WHERE product_variant_id = $1`,
[variant.rows[0].id],
);
});
// A sibling Ozon URL has a different entered SKU. It must still reuse
// the item because the submitted real-SKU sets overlap at 123456789.
const refreshedPayload = {
...payload,
source: {
...payload.source,
itemKey: '987654321',
canonicalUrl: 'https://www.ozon.ru/product/demo-987654321/',
collectedAt: new Date(Date.now() + 1_000).toISOString(),
parserVersion: 'test-2',
},
common: {
...payload.common,
title: {
value: '第二次采集的中文标题',
confidence: 'verified',
},
sourcePrice: { amount: '109.90', currencyCode: 'RUB' },
},
variants: [
{
sourceVariantKey: '123456789',
sourceSku: '123456789',
displayName: '第一 SKU',
selections: [],
confidence: 'verified',
evidenceRefs: ['page'],
},
{
sourceVariantKey: '987654321',
sourceSku: '987654321',
displayName: '第二次采集 SKU',
selections: [],
confidence: 'verified',
evidenceRefs: ['page'],
},
],
assets: [
{
url: 'https://images.example.test/refreshed-product.jpg',
role: 'source_main',
relation: 'product_candidate',
sortOrder: 0,
evidenceRef: 'page',
},
{
url: 'https://videos.example.test/refreshed-product.mp4',
mediaType: 'video',
posterUrl:
'https://images.example.test/refreshed-video-poster.jpg',
role: 'source_main',
relation: 'product_candidate',
sortOrder: 1,
evidenceRef: 'page',
},
],
};
const second = await request('/api/extension/materials', {
method: 'POST',
headers: {
Authorization: `Bearer ${webAccessToken}`,
'Idempotency-Key': randomUUID(),
},
body: JSON.stringify(refreshedPayload),
});
assert.equal(second.response.status, 200);
assert.equal(second.body.data.reused, true);
assert.equal(second.body.data.refreshed, true);
assert.equal(second.body.data.product_id, first.body.data.product_id);
assert.equal(second.body.data.editor_entry_path, first.body.data.editor_entry_path);
assert.equal(
second.body.data.collection_item.id,
first.body.data.collection_item.id,
);
assert.equal(second.body.data.materialization.pending_selection, 2);
assert.equal(second.body.data.materialization.queued, 0);
const editorAfterRefresh = await request(
`/api/products/${first.body.data.product_id}/editor`,
{
headers: {
Authorization: `Bearer ${webAccessToken}`,
'X-Atlas-Platform-Id': targetPlatform.id,
},
},
);
assert.equal(editorAfterRefresh.response.status, 200);
assert.equal(
editorAfterRefresh.body.data.product.common.purchase_url,
'https://detail.1688.com/offer/888888888.html',
);
assert.equal(
editorAfterRefresh.body.data.product.common.source_title,
'人工确认商品名',
);
const originalVariant = editorAfterRefresh.body.data.variants.find(
(variant: { source_variant_key: string }) =>
variant.source_variant_key === '123456789',
);
assert.equal(originalVariant.display_name, '人工确认 SKU');
assert.equal(originalVariant.purchase_cost_cny, '88.80');
assert.equal(originalVariant.listing.offer_id, 'manual-offer-001');
const discoveredVariant = editorAfterRefresh.body.data.variants.find(
(variant: { source_variant_key: string }) =>
variant.source_variant_key === '987654321',
);
assert.ok(discoveredVariant);
assert.equal(discoveredVariant.listing.status, 'not_listed');
const inspection = await request(
`/api/collection-items/${first.body.data.collection_item.id}`,
{ headers: { Authorization: `Bearer ${webAccessToken}` } },
);
assert.equal(inspection.response.status, 200);
assert.equal(inspection.body.data.title, '第二次采集的中文标题');
assert.equal(
inspection.body.data.normalized_payload.source.parserVersion,
'test-2',
);
assert.equal(inspection.body.data.variants.length, 2);
assert.deepEqual(
inspection.body.data.variants.map(
(variant: { source_variant_key: string }) =>
variant.source_variant_key,
),
['123456789', '987654321'],
);
assert.equal(inspection.body.data.asset_transfers.length, 2);
assert.equal(
inspection.body.data.asset_transfers[0].source_url,
'https://images.example.test/refreshed-product.jpg',
);
assert.equal(
inspection.body.data.asset_transfers[0].status,
'pending_selection',
);
const videoTransfer = inspection.body.data.asset_transfers.find(
(transfer: { media_type: string }) =>
transfer.media_type === 'video',
);
assert.equal(
videoTransfer?.source_url,
'https://videos.example.test/refreshed-product.mp4',
);
assert.equal(
videoTransfer?.poster_url,
'https://images.example.test/refreshed-video-poster.jpg',
);
const eventsBeforeSelection = await database.workspaceTransaction(
workspaceId,
async (tx) =>
tx.query<{ count: string }>(
`SELECT count(*)::text AS count FROM atlas.outbox_events
WHERE event_type = 'collection.assets.materialize.requested'
AND event_payload->>'collection_item_id' = $1`,
[first.body.data.collection_item.id],
),
);
assert.equal(eventsBeforeSelection.rows[0]?.count, '0');
const materialization = await request(
`/api/collection-items/${first.body.data.collection_item.id}/asset-materializations`,
{
method: 'POST',
headers: {
Authorization: `Bearer ${webAccessToken}`,
'Idempotency-Key': randomUUID(),
},
body: JSON.stringify({
transfer_ids: [inspection.body.data.asset_transfers[0].id],
}),
},
);
assert.equal(materialization.response.status, 202);
const afterSelection = await request(
`/api/collection-items/${first.body.data.collection_item.id}`,
{ headers: { Authorization: `Bearer ${webAccessToken}` } },
);
assert.equal(afterSelection.response.status, 200);
assert.ok(
['queued', 'running', 'failed', 'succeeded'].includes(
afterSelection.body.data.asset_transfers[0].status,
),
);
const eventsAfterSelection = await database.workspaceTransaction(
workspaceId,
async (tx) =>
tx.query<{ count: string }>(
`SELECT count(*)::text AS count FROM atlas.outbox_events
WHERE event_type = 'collection.assets.materialize.requested'
AND event_payload->>'collection_item_id' = $1`,
[first.body.data.collection_item.id],
),
);
assert.equal(eventsAfterSelection.rows[0]?.count, '1');
const transferWorkspace = await database.workspaceTransaction(
workspaceId,
async (tx) =>
tx.query<{ workspace_id: string }>(
'SELECT workspace_id FROM atlas.collection_asset_transfers WHERE id = $1',
[inspection.body.data.asset_transfers[0].id],
),
);
assert.equal(transferWorkspace.rows[0]?.workspace_id, workspaceId);
});
it('完整采集并保存 1000 个 SKU,复采保持身份,超限请求原子拒绝', async () => {
const variants = Array.from({ length: PRODUCT_VARIANT_LIMIT }, (_, index) => ({
sourceVariantKey: String(8_000_000_000_000 + index),
sourceSku: String(8_000_000_000_000 + index),
displayName: `来源 SKU ${index}`,
selections: [], confidence: 'verified', evidenceRefs: [],
sourcePrice: { amount: '20.00', currencyCode: 'CNY' },
}));
const payload = {
source: {
platform: '1688', itemKey: `large-${randomUUID()}`,
canonicalUrl: 'https://detail.1688.com/offer/123456789.html',
collectedAt: new Date().toISOString(), parserVersion: 'large-sku-test',
},
common: { title: { value: '多 SKU 来源商品', confidence: 'verified' }, attributes: [], categories: [] },
variantAxes: [], variants, assets: [], evidence: [],
};
const capture = (body: unknown) => request('/api/extension/materials', {
method: 'POST',
headers: { Authorization: `Bearer ${webAccessToken}`, 'Idempotency-Key': randomUUID() },
body: JSON.stringify(body),
});
const captured = await capture(payload);
assert.equal(captured.response.status, 201, JSON.stringify(captured.body));
assert.equal(captured.body.data.collection_item.variant_count, PRODUCT_VARIANT_LIMIT);
const collectionId = captured.body.data.collection_item.id;
const productId = captured.body.data.product_id;
const platforms = await request('/api/platforms', { headers: { Authorization: `Bearer ${webAccessToken}` } });
const platformId = platforms.body.data.find((platform: { code: string }) => platform.code === 'OZON').id;
const context = await request(`/api/collection-items/${collectionId}/editor-contexts`, {
method: 'POST',
headers: {
Authorization: `Bearer ${webAccessToken}`, 'X-Atlas-Platform-Id': platformId,
'Idempotency-Key': randomUUID(),
},
});
assert.equal(context.response.status, 201, JSON.stringify(context.body));
const read = () => request(`/api/products/${productId}/editor`, {
headers: { Authorization: `Bearer ${webAccessToken}`, 'X-Atlas-Platform-Id': platformId },
});
const before = await read();
assert.equal(before.response.status, 200);
const snapshot = before.body.data;
assert.equal(snapshot.variants.length, PRODUCT_VARIANT_LIMIT);
assert.equal(snapshot.variants.at(-1).source_sku, variants.at(-1)!.sourceSku);
const draft = {
ai_source_material: snapshot.source_material,
ai_source_overridden_fields: [],
common: {
...snapshot.product.common,
default_physical_spec: snapshot.product.default_physical_spec,
default_package_spec: snapshot.product.default_package_spec,
},
profile: {
target_store_id: snapshot.profile.target_store.id,
fulfillment: snapshot.profile.fulfillment, title: null, description: null, tags: [],
platform_product_category_id: null,
},
variants: snapshot.variants.map((variant: any, index: number) => ({
id: variant.id, client_key: null,
display_name: index === PRODUCT_VARIANT_LIMIT - 1 ? '末尾 SKU 已编辑' : variant.display_name,
source_sku: variant.source_sku, specification_values: variant.specification_values,
purchase_cost_cny: variant.purchase_cost_cny, physical_spec: variant.physical_spec,
package_spec: variant.package_spec, tags: [], description: null,
offer_id: null, media_asset_ids: [],
})),
};
const saved = await request(`/api/products/${productId}/editor-draft`, {
method: 'PUT', headers: {
Authorization: `Bearer ${webAccessToken}`, 'X-Atlas-Platform-Id': platformId,
'Idempotency-Key': randomUUID(),
'If-Match': `"${snapshot.product.revision}:${snapshot.profile.revision}"`,
}, body: JSON.stringify(draft),
});
assert.equal(saved.response.status, 200, JSON.stringify(saved.body));
const after = await read();
assert.equal(after.body.data.variants.length, PRODUCT_VARIANT_LIMIT);
assert.equal(after.body.data.variants.at(-1).display_name, '末尾 SKU 已编辑');
const refreshed = await capture(payload);
assert.equal(refreshed.response.status, 200);
assert.equal(refreshed.body.data.product_id, productId);
assert.equal(refreshed.body.data.collection_item.variant_count, PRODUCT_VARIANT_LIMIT);
const reread = await read();
assert.deepEqual(reread.body.data.variants.map((v: any) => v.id), snapshot.variants.map((v: any) => v.id));
assert.equal(reread.body.data.variants.at(-1).display_name, '末尾 SKU 已编辑');
const revision = reread.body.data.source_material.source_snapshot_revision;
const rejected = await capture({ ...payload, variants: [...variants, { ...variants[0], sourceVariantKey: 'extra-sku' }] });
assert.equal(rejected.response.status, 422);
assert.equal(rejected.body.error.details[0].field, 'variants');
const unchanged = await read();
assert.equal(unchanged.body.data.source_material.source_snapshot_revision, revision);
assert.equal(unchanged.body.data.variants.length, PRODUCT_VARIANT_LIMIT);
}, 60_000);
});
+328
View File
@@ -0,0 +1,328 @@
import { randomUUID } from 'node:crypto';
import {
createApplicationDatabase,
type Database,
type DatabaseTransaction,
} from '@atlas/database';
import {
createImageSuiteSchema,
imageSuiteInputSchema,
type ImageSuite,
} from '@atlas/contracts';
import { describe, expect, it } from 'vitest';
import { IdempotencyService } from '../src/common/idempotency.service.js';
import { ImageSuiteService } from '../src/media/image-suite.service.js';
import { MediaAiEditService } from '../src/media/media-ai-edit.service.js';
import { ProductImageTaskService } from '../src/media/product-image-task.service.js';
/** 所有实体和 Outbox 都只存在于最终回滚的事务,验证不调用实际收费生图。 */
describe.skipIf(!process.env.DATABASE_URL)(
'套图数据库隔离、幂等与状态汇总',
() => {
it('创建/部分失败/取消/重试保留成功图片,FK 与 RLS 拒绝越权引用', async () => {
const db = createApplicationDatabase(process.env.DATABASE_URL!);
const rollback = Symbol('rollback');
try {
await db.transaction(async (tx) => {
const workspaceId = randomUUID(),
otherWorkspace = randomUUID(),
userId = randomUUID();
await tx.query(
"INSERT INTO atlas.users (id, email, password_hash) VALUES ($1, $2, 'test-only')",
[userId, `${userId}@example.test`],
);
for (const id of [workspaceId, otherWorkspace])
await tx.query(
"INSERT INTO atlas.workspaces (id, name, slug) VALUES ($1::uuid, '套图测试', $1::text)",
[id],
);
const inWorkspace = async (id: string) =>
tx.query(
"SELECT set_config('app.workspace_id', $1, true)",
[id],
);
await inWorkspace(workspaceId);
const productId = randomUUID(),
variantId = randomUUID(),
itemId = randomUUID(),
assetId = randomUUID(),
outputId = randomUUID();
await tx.query(
"INSERT INTO atlas.collection_items (id, workspace_id, source_kind, source_item_key) VALUES ($1::uuid, $2, 'manual', $1::text)",
[itemId, workspaceId],
);
await tx.query(
'INSERT INTO atlas.products (id, workspace_id, collection_item_id) VALUES ($1, $2, $3)',
[productId, workspaceId, itemId],
);
await tx.query(
'INSERT INTO atlas.product_variants (id, workspace_id, product_id) VALUES ($1, $2, $3)',
[variantId, workspaceId, productId],
);
const asset = async (id: string, ws: string) =>
tx.query(
`INSERT INTO atlas.media_assets
(id, workspace_id, storage_provider, bucket, object_key, content_type, byte_size, checksum_sha256, width_px, height_px)
VALUES ($1::uuid, $2, 'local_minio', 'test', $1::text, 'image/jpeg', 100, $1::text, 100, 100)`,
[id, ws],
);
await asset(assetId, workspaceId);
await asset(outputId, workspaceId);
await inWorkspace(otherWorkspace);
const foreignAsset = randomUUID();
await asset(foreignAsset, otherWorkspace);
await inWorkspace(workspaceId);
let queryQueue: Promise<void> = Promise.resolve();
const scoped = {
workspaceTransaction: <T>(
ws: string,
work: (
transaction: DatabaseTransaction,
) => Promise<T>,
) => {
const result = queryQueue.then(async () => {
await inWorkspace(ws);
return work(tx);
});
queryQueue = result.then(
() => undefined,
() => undefined,
);
return result;
},
} as Database;
const idempotency = new IdempotencyService(scoped, {
IDEMPOTENCY_KEY_SECRET: 'suite-test-secret',
} as never);
const edits = new MediaAiEditService(scoped, idempotency, {
accessUrl: async () => ({
url: 'https://example.test/preview',
expires_at: '2099-01-01T00:00:00Z',
}),
} as never);
const suites = new ImageSuiteService(
scoped,
idempotency,
edits,
);
const input = imageSuiteInputSchema.parse({
variant_id: variantId,
reference_asset_ids: [assetId],
product_name: '台灯',
});
const shots = ['white', 'scene', 'selling'].map(
(category, i) => ({
id: randomUUID(),
category,
type: ['white_background', 'lifestyle', 'detail'][
i
],
theme: `方案${i}`,
description: '依据真实底图展示商品',
}),
);
const request = createImageSuiteSchema.parse({
...input,
shots,
});
const key = randomUUID();
const created = await suites.create(
workspaceId,
userId,
productId,
key,
request,
'generate',
);
const id = (created.body as { data: ImageSuite }).data.id;
expect(
(
await suites.create(
workspaceId,
userId,
productId,
key,
request,
'generate',
)
).body,
).toEqual(created.body);
await expect(
suites.create(
workspaceId,
userId,
productId,
key,
{ ...request, product_name: '不同请求' },
'generate',
),
).rejects.toMatchObject({
status: 409,
response: { error: { code: 'IDEMPOTENCY_KEY_REUSED' } },
});
const children = (
await tx.query<{ id: string }>(
'SELECT id FROM atlas.media_ai_edits WHERE suite_id = $1 ORDER BY suite_position',
[id],
)
).rows;
expect(children).toHaveLength(3);
expect(
(
await tx.query(
'SELECT id FROM atlas.outbox_events WHERE aggregate_id = ANY($1::uuid[])',
[children.map((child) => child.id)],
)
).rows,
).toHaveLength(3);
await tx.query(
"UPDATE atlas.media_ai_edits SET status = 'succeeded', output_asset_id = $2 WHERE id = $1",
[children[0].id, outputId],
);
await tx.query(
"UPDATE atlas.media_ai_edits SET status = 'failed', error_code = 'TEST_FAILURE' WHERE id = $1",
[children[1].id],
);
expect(
(await suites.read(workspaceId, productId, id)).status,
).toBe('running');
await suites.action(
workspaceId,
userId,
productId,
id,
randomUUID(),
'cancel',
);
expect(
(await suites.read(workspaceId, productId, id)).status,
).toBe('cancelled');
await suites.action(
workspaceId,
userId,
productId,
id,
randomUUID(),
'retry',
);
const retried = await suites.read(
workspaceId,
productId,
id,
);
expect(retried.items.map((item) => item.status)).toEqual([
'succeeded',
'queued',
'queued',
]);
expect(retried.items[0].output_asset?.id).toBe(outputId);
const attempts = (
await tx.query<{ generation_attempt: number }>(
'SELECT generation_attempt FROM atlas.media_ai_edits WHERE suite_id = $1 ORDER BY suite_position',
[id],
)
).rows;
expect(
attempts.map((item) => item.generation_attempt),
).toEqual([1, 2, 2]);
await tx.query(
"UPDATE atlas.media_ai_edits SET status = 'failed', error_code = 'TEST_FAILURE' WHERE id = ANY($1::uuid[])",
[children.slice(1).map((child) => child.id)],
);
expect(
(await suites.read(workspaceId, productId, id)).status,
).toBe('partial');
const tasks = await new ProductImageTaskService(
scoped,
).list(workspaceId, productId, { limit: 20 });
expect(tasks.data).toMatchObject([
{
id,
kind: 'ai_suite',
status: 'partial',
total: 3,
completed: 1,
output_asset_ids: [outputId],
},
]);
expect(tasks.data).toHaveLength(1);
await expect(
suites.read(otherWorkspace, productId, id),
).rejects.toMatchObject({ status: 404 });
await expect(
suites.create(
workspaceId,
userId,
productId,
randomUUID(),
{ ...input, reference_asset_ids: [foreignAsset] },
'plan',
),
).rejects.toMatchObject({ status: 404 });
const forbidden = async (
sql: string,
params: unknown[],
code: string,
) => {
await tx.query('SAVEPOINT forbidden_write');
try {
await tx.query(sql, params);
throw new Error('本应拒绝的写入成功');
} catch (error) {
expect((error as { code: string }).code).toBe(code);
} finally {
await tx.query(
'ROLLBACK TO SAVEPOINT forbidden_write',
);
}
};
await forbidden(
'UPDATE atlas.product_image_suites SET input = input || \'{"color":"篡改"}\'::jsonb WHERE id = $1',
[id],
'23514',
);
await forbidden(
'UPDATE atlas.media_ai_edits SET suite_position = NULL WHERE id = $1',
[children[0].id],
'23514',
);
await forbidden(
'UPDATE atlas.media_ai_edits SET source_asset_id = $2 WHERE id = $1',
[children[0].id, foreignAsset],
'23514',
);
const foreignReferenceParent = await suites.create(
workspaceId,
userId,
productId,
randomUUID(),
input,
'plan',
);
const foreignParentId = (
foreignReferenceParent.body as { data: ImageSuite }
).data.id;
await forbidden(
'UPDATE atlas.product_image_suite_references SET asset_id = $2 WHERE suite_id = $1',
[foreignParentId, foreignAsset],
'23514',
);
await inWorkspace(otherWorkspace);
expect(
(
await tx.query(
'SELECT id FROM atlas.product_image_suites WHERE id = $1',
[id],
)
).rows,
).toHaveLength(0);
throw rollback;
});
} catch (error) {
if (error !== rollback) throw error;
} finally {
await db.close();
}
});
},
);
+291
View File
@@ -0,0 +1,291 @@
import { randomUUID } from 'node:crypto';
import { describe, expect, it, vi } from 'vitest';
import {
createImageSuiteSchema,
imageSuiteInputSchema,
imageSuitePlanInputSchema,
} from '@atlas/contracts';
import { ImageSuiteService } from '../src/media/image-suite.service.js';
const input = () =>
imageSuiteInputSchema.parse({
variant_id: randomUUID(),
reference_asset_ids: [randomUUID()],
product_name: '台灯',
});
const shot = () => ({
id: randomUUID(),
category: 'white',
type: 'white_background',
theme: '白底主图',
description: '展示商品完整外观',
});
describe('套图共享契约与资源边界', () => {
it('规划默认四张,接受 1–20 张预算,生成接口不接收规划预算字段', () => {
const valid = input();
expect(imageSuitePlanInputSchema.parse(valid).image_count).toBe(4);
expect(
imageSuitePlanInputSchema.parse({ ...valid, image_count: 6 })
.image_count,
).toBe(6);
for (const image_count of [0, 21, 1.2])
expect(
imageSuitePlanInputSchema.safeParse({ ...valid, image_count })
.success,
).toBe(false);
expect(
createImageSuiteSchema.safeParse({
...valid,
image_count: 4,
shots: [shot()],
}).success,
).toBe(false);
});
it('规划保存预算并只创建规划 Outbox,不创建生图子任务', async () => {
const request = imageSuitePlanInputSchema.parse({
...input(),
image_count: 6,
});
const suiteId = randomUUID(),
productId = randomUUID();
const query = vi.fn(async (sql: string, values: unknown[] = []) => {
if (sql.startsWith('SELECT id FROM atlas.product_variants'))
return { rows: [{ id: request.variant_id }] };
if (sql.startsWith('SELECT id FROM atlas.media_assets'))
return {
rows: request.reference_asset_ids.map((id) => ({ id })),
};
if (sql.startsWith('INSERT INTO atlas.product_image_suites'))
return {
rows: [
{
id: suiteId,
product_id: productId,
variant_id: request.variant_id,
purpose: 'plan',
status: 'queued',
input: JSON.parse(values[3] as string),
shots: [],
created_at: new Date(),
completed_at: null,
error_code: null,
},
],
};
return { rows: [] };
});
const service = new ImageSuiteService(
{} as never,
{
run: async (
_context: unknown,
work: (tx: { query: typeof query }) => Promise<unknown>,
) => work({ query }),
} as never,
{} as never,
);
const result = await service.create(
randomUUID(),
randomUUID(),
productId,
randomUUID(),
request,
'plan',
);
expect(result).toMatchObject({
status: 202,
body: {
data: { purpose: 'plan', input: { image_count: 6 }, shots: [] },
},
});
expect(
query.mock.calls.some(([sql]) =>
sql.includes('INSERT INTO atlas.media_ai_edits'),
),
).toBe(false);
expect(
query.mock.calls.find(([sql]) =>
sql.includes('INSERT INTO atlas.outbox_events'),
)?.[1],
).toContain('image_suite.plan.requested');
});
it('拒绝重复底图、超过 8 张底图、超过分类数量和无实物尺寸的尺寸图', () => {
const valid = input();
expect(
imageSuiteInputSchema.safeParse({
...valid,
reference_asset_ids: Array.from({ length: 9 }, randomUUID),
}).success,
).toBe(false);
expect(
imageSuiteInputSchema.safeParse({
...valid,
reference_asset_ids: [
...valid.reference_asset_ids,
...valid.reference_asset_ids,
],
}).success,
).toBe(false);
expect(
createImageSuiteSchema.safeParse({
...valid,
shots: Array.from({ length: 6 }, shot),
}).success,
).toBe(false);
expect(
createImageSuiteSchema.safeParse({
...valid,
shots: [{ ...shot(), category: 'other', type: 'dimensions' }],
}).success,
).toBe(false);
});
it('逐张底图必须来自已选图库,并保留主体图,类型必须匹配分类', () => {
const valid = input();
expect(
createImageSuiteSchema.safeParse({
...valid,
shots: [{ ...shot(), reference_asset_ids: [randomUUID()] }],
}).success,
).toBe(false);
expect(
createImageSuiteSchema.safeParse({
...valid,
shots: [{ ...shot(), category: 'scene' }],
}).success,
).toBe(false);
expect(
createImageSuiteSchema.safeParse({
...valid,
shots: [shot()],
ratio: '4:3',
language: 'zh',
}).success,
).toBe(true);
});
it('目标 SKU 不属于当前商品时,在产生任何任务前拒绝请求', async () => {
const query = vi.fn(async () => ({ rows: [] }));
const run = vi.fn(
async (
_input: unknown,
work: (tx: { query: typeof query }) => Promise<unknown>,
) => work({ query }),
);
const service = new ImageSuiteService(
{} as never,
{ run } as never,
{} as never,
);
await expect(
service.create(
randomUUID(),
randomUUID(),
randomUUID(),
randomUUID(),
input(),
'plan',
),
).rejects.toMatchObject({ status: 404 });
expect(query).toHaveBeenCalledOnce();
});
it('任一底图不可见时拒绝,不产生套图及 Outbox', async () => {
const query = vi.fn(async (sql: string) => ({
rows: sql.includes('atlas.product_variants')
? [{ id: 'variant' }]
: [],
}));
const run = vi.fn(
async (
_input: unknown,
work: (tx: { query: typeof query }) => Promise<unknown>,
) => work({ query }),
);
const service = new ImageSuiteService(
{} as never,
{ run } as never,
{} as never,
);
await expect(
service.create(
randomUUID(),
randomUUID(),
randomUUID(),
randomUUID(),
input(),
'plan',
),
).rejects.toMatchObject({ status: 404 });
expect(query).toHaveBeenCalledTimes(2);
});
it('默认逐张用图保留主体,每张最多 3 张,整套轮换覆盖已选 8 张底图', async () => {
const request = createImageSuiteSchema.parse({
...input(),
reference_asset_ids: Array.from({ length: 8 }, randomUUID),
shots: Array.from({ length: 8 }, (_, index) => ({
...shot(),
category: ['white', 'scene', 'selling', 'other'][index % 4],
type: ['white_background', 'lifestyle', 'detail', 'custom'][
index % 4
],
})),
});
const used: string[][] = [];
const query = vi.fn(async (sql: string, values: unknown[] = []) => {
if (sql.startsWith('SELECT id FROM atlas.product_variants'))
return { rows: [{ id: request.variant_id }] };
if (sql.startsWith('SELECT id FROM atlas.media_assets'))
return {
rows: request.reference_asset_ids.map((id) => ({ id })),
};
if (sql.includes('INSERT INTO atlas.product_image_suites'))
return {
rows: [
{
id: randomUUID(),
product_id: randomUUID(),
variant_id: request.variant_id,
purpose: 'generate',
status: 'queued',
input: request,
shots: request.shots,
created_at: new Date(),
completed_at: null,
error_code: null,
},
],
};
if (sql.includes('INSERT INTO atlas.media_ai_edits'))
used.push([values[5] as string, ...(values[6] as string[])]);
return { rows: [] };
});
const service = new ImageSuiteService(
{} as never,
{
run: async (
_request: unknown,
work: (tx: { query: typeof query }) => Promise<unknown>,
) => work({ query }),
} as never,
{} as never,
);
await service.create(
randomUUID(),
randomUUID(),
randomUUID(),
randomUUID(),
request,
'generate',
);
expect(used).toHaveLength(8);
expect(
used.every(
(refs) =>
refs.length <= 3 &&
refs[0] === request.reference_asset_ids[0],
),
).toBe(true);
expect(new Set(used.flat())).toEqual(
new Set(request.reference_asset_ids),
);
});
});
+101
View File
@@ -0,0 +1,101 @@
import { describe, expect, it, vi } from 'vitest';
import { createMediaAiEditRequestSchema } from '@atlas/contracts';
import { MediaAiEditService } from '../src/media/media-ai-edit.service.js';
const workspaceId = '30cf54b7-06a3-4e5f-a5fc-d74165115094';
const sourceId = '664d0d73-3d39-4a98-ac0a-5c0e521d8998';
const referenceId = '50ea00b7-6675-4cc7-b7a2-d45f05c07100';
const editId = '68db226f-3fcb-4579-ab9e-4932b1d84067';
const productId = '733d0d73-3d39-4a98-ac0a-5c0e521d8998';
describe('单张 AI 编辑任务', () => {
it('验证主图和辅图都属于当前工作空间后,原子写入任务、进度和 Outbox', async () => {
const statements: string[] = [];
const query = vi.fn(async (sql: string) => {
statements.push(sql);
if (sql.includes('SELECT id FROM atlas.products')) return { rows: [{ id: productId }] };
if (sql.includes('SELECT id FROM atlas.media_assets'))
return { rows: [{ id: sourceId }, { id: referenceId }] };
if (sql.includes('RETURNING id')) return { rows: [{ id: editId }] };
return { rows: [] };
});
const run = vi.fn(async (_input: unknown, work: (tx: { query: typeof query }) => Promise<unknown>) => work({ query }));
const service = new MediaAiEditService(
{} as ConstructorParameters<typeof MediaAiEditService>[0],
{ run } as unknown as ConstructorParameters<typeof MediaAiEditService>[1],
{} as ConstructorParameters<typeof MediaAiEditService>[2],
);
const input = createMediaAiEditRequestSchema.parse({ product_id: productId, reference_asset_ids: [referenceId], background: 'reference' });
const result = await service.create(workspaceId, 'user-id', sourceId, 'key', input);
expect(result).toMatchObject({ status: 202, body: { data: { id: editId, status: 'queued' } } });
expect(run).toHaveBeenCalledOnce();
expect(statements.some((sql) => sql.includes('INSERT INTO atlas.media_ai_edits'))).toBe(true);
expect(statements.some((sql) => sql.includes('INSERT INTO atlas.operations'))).toBe(true);
expect(statements.some((sql) => sql.includes('INSERT INTO atlas.outbox_events'))).toBe(true);
});
it('任一辅助图不在当前工作空间时,不创建任务', async () => {
const query = vi.fn(async (sql: string) => ({ rows: [{ id: sql.includes('atlas.products') ? productId : sourceId }] }));
const run = vi.fn(async (_input: unknown, work: (tx: { query: typeof query }) => Promise<unknown>) => work({ query }));
const service = new MediaAiEditService(
{} as ConstructorParameters<typeof MediaAiEditService>[0],
{ run } as unknown as ConstructorParameters<typeof MediaAiEditService>[1],
{} as ConstructorParameters<typeof MediaAiEditService>[2],
);
const input = createMediaAiEditRequestSchema.parse({ product_id: productId, reference_asset_ids: [referenceId] });
await expect(service.create(workspaceId, 'user-id', sourceId, 'key', input))
.rejects.toMatchObject({ status: 404 });
expect(query).toHaveBeenCalledTimes(2);
});
it('仅扩图不能带辅图或尺寸标注,参考背景必须带辅图', () => {
expect(createMediaAiEditRequestSchema.safeParse({ product_id: productId, mode: 'outpaint', reference_asset_ids: [referenceId] }).success).toBe(false);
expect(createMediaAiEditRequestSchema.safeParse({ product_id: productId, background: 'reference' }).success).toBe(false);
expect(createMediaAiEditRequestSchema.safeParse({ product_id: productId, generate_size_chart: true }).success).toBe(false);
});
it('读取失败任务时返回记录的失败来源、步骤和诊断', async () => {
const query = vi.fn(async (sql: string) => {
expect(sql).toContain('o.workspace_id = e.workspace_id');
return { rows: [{
id: editId, status: 'failed', source_asset_id: sourceId,
output_asset_id: null, error_code: 'AI_IMAGE_GENERATION_FAILED',
failure_source: 'atlas', failure_stage: 'result_processing',
failure_detail: 'Pcore 结果图片无效',
}] };
});
const database = { workspaceTransaction: vi.fn(async (_workspace: string, work: (tx: { query: typeof query }) => Promise<unknown>) => work({ query })) };
const service = new MediaAiEditService(
database as unknown as ConstructorParameters<typeof MediaAiEditService>[0],
{} as ConstructorParameters<typeof MediaAiEditService>[1],
{} as ConstructorParameters<typeof MediaAiEditService>[2],
);
const result = await service.read(workspaceId, editId);
expect(result).toMatchObject({
failure_source: 'atlas', failure_stage: 'result_processing',
failure_detail: 'Pcore 结果图片无效',
});
});
it('AI 生图记录按当前商品筛选并重新签发短时预览地址', async () => {
const query = vi.fn(async (_sql: string, values: unknown[]) => {
expect(values).toEqual([productId]);
return { rows: [{
id: editId, status: 'succeeded', source_asset_id: sourceId,
output_asset_id: referenceId, error_code: null,
content_type: 'image/jpeg', byte_size: '1234', width_px: 1152,
height_px: 1536, asset_created_at: new Date('2026-09-26T00:00:00Z'),
}] };
});
const database = { workspaceTransaction: vi.fn(async (_workspace: string, work: (tx: { query: typeof query }) => Promise<unknown>) => work({ query })) };
const accessUrl = vi.fn(async () => ({ url: 'https://example.com/short-lived', expires_at: '2026-09-26T01:00:00Z' }));
const service = new MediaAiEditService(
database as unknown as ConstructorParameters<typeof MediaAiEditService>[0],
{} as ConstructorParameters<typeof MediaAiEditService>[1],
{ accessUrl } as unknown as ConstructorParameters<typeof MediaAiEditService>[2],
);
const items = await service.list(workspaceId, productId);
expect(items).toMatchObject([{ id: editId, output_asset: { id: referenceId, preview_url: 'https://example.com/short-lived' } }]);
expect(accessUrl).toHaveBeenCalledWith(workspaceId, referenceId);
});
});
@@ -0,0 +1,81 @@
import { describe, expect, it, vi } from 'vitest';
import { AspectRatioDerivativeService } from '../src/media/aspect-ratio-derivative.service.js';
const workspaceId = '30cf54b7-06a3-4e5f-a5fc-d74165115094';
const sourceAssetId = '664d0d73-3d39-4a98-ac0a-5c0e521d8998';
const derivativeId = '68db226f-3fcb-4579-ab9e-4932b1d84067';
const productId = '733d0d73-3d39-4a98-ac0a-5c0e521d8998';
describe('一键 3:4 派生任务', () => {
it('在同一个租户事务写入派生记录、操作进度和 Outbox', async () => {
const statements: string[] = [];
const query = vi.fn(async (sql: string) => {
statements.push(sql);
if (sql.includes('SELECT id FROM atlas.media_assets')) return { rows: [{ id: sourceAssetId }] };
if (sql.includes('RETURNING id')) return { rows: [{ id: derivativeId }] };
return { rows: [] };
});
const run = vi.fn(async (_input: unknown, work: (tx: { query: typeof query }) => Promise<unknown>) =>
work({ query }));
const service = new AspectRatioDerivativeService(
{} as ConstructorParameters<typeof AspectRatioDerivativeService>[0],
{ run } as unknown as ConstructorParameters<typeof AspectRatioDerivativeService>[1],
{} as ConstructorParameters<typeof AspectRatioDerivativeService>[2],
);
const result = await service.create(
workspaceId,
'user-id',
sourceAssetId,
'idempotency-key',
{ preset: '3:4', fit: 'contain' },
);
expect(result).toMatchObject({
status: 202,
body: { data: { id: derivativeId, status: 'queued', source_asset_id: sourceAssetId } },
});
expect(run).toHaveBeenCalledOnce();
expect(statements.some((sql) => sql.includes('INSERT INTO atlas.media_aspect_ratio_derivatives'))).toBe(true);
expect(statements.some((sql) => sql.includes('INSERT INTO atlas.operations'))).toBe(true);
expect(statements.some((sql) => sql.includes('INSERT INTO atlas.outbox_events'))).toBe(true);
});
it('缺少或跨工作空间的源素材不会创建任务', async () => {
const query = vi.fn(async () => ({ rows: [] }));
const run = vi.fn(async (_input: unknown, work: (tx: { query: typeof query }) => Promise<unknown>) =>
work({ query }));
const service = new AspectRatioDerivativeService(
{} as ConstructorParameters<typeof AspectRatioDerivativeService>[0],
{ run } as unknown as ConstructorParameters<typeof AspectRatioDerivativeService>[1],
{} as ConstructorParameters<typeof AspectRatioDerivativeService>[2],
);
await expect(service.create(
workspaceId,
'user-id',
sourceAssetId,
'idempotency-key',
{ preset: '3:4', fit: 'contain' },
)).rejects.toMatchObject({ status: 404 });
expect(query).toHaveBeenCalledOnce();
});
it('将当前工作空间的商品关联到新派生任务', async () => {
const query = vi.fn(async (sql: string, values?: unknown[]) => {
if (sql.includes('SELECT id FROM atlas.media_assets')) return { rows: [{ id: sourceAssetId }] };
if (sql.includes('SELECT id FROM atlas.products')) return { rows: [{ id: productId }] };
if (sql.includes('INSERT INTO atlas.media_aspect_ratio_derivatives')) {
expect(values).toEqual([productId, sourceAssetId, 'user-id']);
return { rows: [{ id: derivativeId }] };
}
return { rows: [] };
});
const run = vi.fn(async (_input: unknown, work: (tx: { query: typeof query }) => Promise<unknown>) => work({ query }));
const service = new AspectRatioDerivativeService(
{} as ConstructorParameters<typeof AspectRatioDerivativeService>[0],
{ run } as unknown as ConstructorParameters<typeof AspectRatioDerivativeService>[1],
{} as ConstructorParameters<typeof AspectRatioDerivativeService>[2],
);
await service.create(workspaceId, 'user-id', sourceAssetId, 'key',
{ preset: '3:4', fit: 'contain', product_id: productId });
expect(query.mock.calls.some(([sql]) => String(sql).includes('INSERT INTO atlas.media_aspect_ratio_derivatives'))).toBe(true);
});
});
+109
View File
@@ -0,0 +1,109 @@
import { createHash } from 'node:crypto';
import { describe, expect, it, vi } from 'vitest';
import { MediaService } from '../src/media/media.service.js';
vi.mock('../src/media/product-image-download.js', async (importOriginal) => {
const original =
await importOriginal<
typeof import('../src/media/product-image-download.js')
>();
return {
...original,
downloadProductImage: vi.fn(async () => ({
bytes: Buffer.from('validated-image'),
contentType: 'image/png',
width: 800,
height: 800,
})),
};
});
describe('网络商品图片导入', () => {
it('复用同工作空间素材并返回正式 ID 与短时预览地址', async () => {
const productId = '733d0d73-3d39-4a98-ac0a-5c0e521d8998';
const assetId = '7c910684-7070-4f36-8a7b-b10bd91acd79';
const bytes = Buffer.from('validated-image');
const checksum = createHash('sha256').update(bytes).digest('hex');
const query = vi.fn(async (sql: string, args?: unknown[]) => {
if (sql.includes('FROM atlas.products')) {
expect(args).toEqual([productId]);
return { rows: [{ id: productId }] };
}
if (sql.includes('WHERE checksum_sha256 = $1')) {
expect(args).toEqual([checksum]);
return { rows: [{ id: assetId }] };
}
if (sql.includes('INSERT INTO atlas.product_image_imports')) {
expect(args?.[0]).toBe('https://example.com/photo.png');
expect(args?.[1]).toBe(assetId);
expect(args?.[3]).toBe(productId);
expect(sql).toContain('ON CONFLICT (workspace_id, product_id, source_url)');
return { rows: [] };
}
if (sql.includes('SELECT id, content_type')) {
return {
rows: [
{
id: assetId,
content_type: 'image/png',
byte_size: '15',
width_px: 800,
height_px: 800,
created_at: new Date('2026-09-24T00:00:00Z'),
},
],
};
}
if (sql.includes('SELECT storage_provider, bucket, object_key')) {
return {
rows: [
{
storage_provider: 'local_minio',
bucket: 'test',
object_key: 'product-imports/existing.png',
},
],
};
}
throw new Error(`未知 SQL: ${sql}`);
});
const run = vi.fn(
async (
request: { path: string; key: string },
callback: (tx: { query: typeof query }) => Promise<unknown>,
) => {
expect(request.path).toBe('/api/media-assets/product-imports');
expect(request.key).toBe('idempotency-key');
return callback({ query });
},
);
const service = new MediaService(
{ run } as unknown as ConstructorParameters<typeof MediaService>[0],
{} as ConstructorParameters<typeof MediaService>[1],
{
OBJECT_STORAGE_ENDPOINT: 'http://127.0.0.1:9000',
OBJECT_STORAGE_REGION: 'us-east-1',
OBJECT_STORAGE_BUCKET: 'test',
objectStorageForcePathStyle: true,
objectStorageAccessKey: 'test',
objectStorageSecretKey: 'test',
} as ConstructorParameters<typeof MediaService>[2],
);
const result = await service.importProductImage(
'workspace-id',
'user-id',
'idempotency-key',
{ url: 'https://example.com/photo.png', product_id: productId },
);
const body = result.body as {
data: { id: string; source_url: string; preview_url: string };
};
expect(result.status).toBe(201);
expect(body.data).toMatchObject({
id: assetId,
source_url: 'https://example.com/photo.png',
});
expect(body.data.preview_url).toContain('product-imports/existing.png');
expect(query).toHaveBeenCalledTimes(5);
});
});
@@ -0,0 +1,354 @@
const productId = '733d0d73-3d39-4a98-ac0a-5c0e521d8998';
import { createHash } from 'node:crypto';
import { GetObjectCommand, HeadObjectCommand } from '@aws-sdk/client-s3';
import {
createMediaAccessUrlRequestSchema,
createMediaUploadIntentRequestSchema,
importProductImageRequestSchema,
} from '@atlas/contracts';
import { describe, expect, it, vi } from 'vitest';
import { MediaService } from '../src/media/media.service.js';
import {
downloadProductImage,
publicIpv4,
} from '../src/media/product-image-download.js';
function serviceWithObject(bytes: Buffer) {
const service = new MediaService(
{} as ConstructorParameters<typeof MediaService>[0],
{} as ConstructorParameters<typeof MediaService>[1],
{
OBJECT_STORAGE_ENDPOINT: 'http://127.0.0.1:9000',
OBJECT_STORAGE_REGION: 'us-east-1',
OBJECT_STORAGE_BUCKET: 'test',
objectStorageForcePathStyle: true,
objectStorageAccessKey: 'test',
objectStorageSecretKey: 'test',
} as ConstructorParameters<typeof MediaService>[2],
);
const send = vi.fn(async (command: unknown) => {
if (command instanceof HeadObjectCommand) {
return { ContentLength: bytes.length, ContentType: 'image/png' };
}
if (command instanceof GetObjectCommand) {
return {
Body: (async function* () {
yield bytes;
})(),
};
}
throw new Error('未知 S3 命令');
});
Object.assign(service, { s3: { send } });
return service as unknown as {
inspectObject: (intent: Record<string, unknown>) => Promise<{
width: number;
height: number;
checksum: string;
}>;
};
}
function png(width: number, height: number) {
const bytes = Buffer.alloc(24);
Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]).copy(bytes);
bytes.writeUInt32BE(width, 16);
bytes.writeUInt32BE(height, 20);
return bytes;
}
describe('商品图片上传策略', () => {
it('下载地址签名 attachment 响应头,预览地址保持内联显示', async () => {
const assetId = '7c910684-7070-4f36-8a7b-b10bd91acd79';
const query = vi.fn(async () => ({
rows: [{ bucket: 'test', object_key: 'uploads/test.png', content_type: 'image/png' }],
}));
const workspaceTransaction = vi.fn(async (
_workspaceId: string,
callback: (tx: { query: typeof query }) => Promise<unknown>,
) => callback({ query }));
const service = new MediaService(
{} as ConstructorParameters<typeof MediaService>[0],
{ workspaceTransaction } as unknown as ConstructorParameters<typeof MediaService>[1],
{
OBJECT_STORAGE_ENDPOINT: 'http://127.0.0.1:9000',
OBJECT_STORAGE_REGION: 'cn-east-1',
OBJECT_STORAGE_BUCKET: 'test',
objectStorageForcePathStyle: true,
objectStorageAccessKey: 'test',
objectStorageSecretKey: 'test',
} as ConstructorParameters<typeof MediaService>[2],
);
expect(createMediaAccessUrlRequestSchema.safeParse({ purpose: 'download' }).success).toBe(true);
const preview = await service.accessUrl('workspace-id', assetId);
const download = await service.accessUrl('workspace-id', assetId, 'download');
expect(new URL(preview.url).searchParams.has('response-content-disposition')).toBe(false);
expect(new URL(preview.url).searchParams.get('X-Amz-Credential')).toContain('/cn-east-1/');
expect(new URL(download.url).searchParams.get('response-content-disposition'))
.toBe(`attachment; filename="atlas-image-${assetId}.png"`);
expect(workspaceTransaction).toHaveBeenCalledTimes(2);
});
it('开发环境为旧 MinIO 素材签发本地预览地址', async () => {
const query = vi.fn(async () => ({
rows: [{
storage_provider: 'local_minio',
bucket: 'atlas-assets',
object_key: 'collections/test/image',
content_type: 'image/jpeg',
}],
}));
const database = {
workspaceTransaction: async (_workspaceId: string, work: (tx: { query: typeof query }) => Promise<unknown>) => work({ query }),
};
const service = new MediaService(
{} as ConstructorParameters<typeof MediaService>[0],
database as unknown as ConstructorParameters<typeof MediaService>[1],
{
NODE_ENV: 'development',
OBJECT_STORAGE_PROVIDER: 'qiniu_kodo',
OBJECT_STORAGE_ENDPOINT: 'https://s3.cn-east-1.qiniucs.com',
OBJECT_STORAGE_REGION: 'cn-east-1',
OBJECT_STORAGE_BUCKET: 'atlas-dev-assets',
objectStorageForcePathStyle: true,
objectStorageAccessKey: 'test',
objectStorageSecretKey: 'test',
LEGACY_MINIO_ENDPOINT: 'http://localhost:9000',
MINIO_ROOT_USER: 'local-test',
MINIO_ROOT_PASSWORD: 'local-secret',
} as ConstructorParameters<typeof MediaService>[2],
);
const preview = await service.accessUrl('workspace-id', 'asset-id');
expect(new URL(preview.url).host).toBe('localhost:9000');
expect(new URL(preview.url).pathname).toBe('/atlas-assets/collections/test/image');
});
it('七牛素材使用绑定域名签发预览和附件下载链接', async () => {
const assetId = '7c910684-7070-4f36-8a7b-b10bd91acd79';
const query = vi.fn(async () => ({
rows: [{
storage_provider: 'qiniu_kodo',
bucket: 'atlas-dev-assets',
object_key: 'collections/workspace/image',
content_type: 'image/jpeg',
}],
}));
const database = {
workspaceTransaction: async (_workspaceId: string, work: (tx: { query: typeof query }) => Promise<unknown>) => work({ query }),
};
const service = new MediaService(
{} as ConstructorParameters<typeof MediaService>[0],
database as unknown as ConstructorParameters<typeof MediaService>[1],
{
OBJECT_STORAGE_ENDPOINT: 'https://s3.cn-east-1.qiniucs.com',
OBJECT_STORAGE_REGION: 'cn-east-1',
OBJECT_STORAGE_BUCKET: 'atlas-dev-assets',
objectStorageForcePathStyle: true,
objectStorageAccessKey: 's3-key',
objectStorageSecretKey: 's3-secret',
qiniuDownloadOrigin: 'https://img-dev.atlas.joeyone.cn',
qiniuDownloadAccessKey: 'download-key',
qiniuDownloadSecretKey: 'download-secret',
} as ConstructorParameters<typeof MediaService>[2],
);
const preview = await service.accessUrl('workspace-id', assetId);
const download = await service.accessUrl('workspace-id', assetId, 'download');
const previewUrl = new URL(preview.url);
const downloadUrl = new URL(download.url);
expect(previewUrl.origin).toBe('https://img-dev.atlas.joeyone.cn');
expect(previewUrl.pathname).toBe('/collections/workspace/image');
expect(previewUrl.searchParams.has('X-Amz-Signature')).toBe(false);
expect(previewUrl.searchParams.has('attname')).toBe(false);
expect(previewUrl.searchParams.get('token')).toMatch(/^download-key:/);
expect(preview.url.split('&').at(-1)).toMatch(/^token=/);
expect(downloadUrl.searchParams.get('attname')).toBe(`atlas-image-${assetId}.jpg`);
expect(download.url.split('&').at(-1)).toMatch(/^token=/);
});
it('创建商品图片上传意图时持久化用途并签发独立对象路径', async () => {
const query = vi.fn(async (_sql: string, _params?: unknown[]) => ({
rows: [{ id: productId }],
}));
const run = vi.fn(
async (
_request: unknown,
callback: (tx: { query: typeof query }) => Promise<unknown>,
) => callback({ query }),
);
const service = new MediaService(
{ run } as unknown as ConstructorParameters<typeof MediaService>[0],
{} as ConstructorParameters<typeof MediaService>[1],
{
OBJECT_STORAGE_ENDPOINT: 'http://127.0.0.1:9000',
OBJECT_STORAGE_REGION: 'us-east-1',
OBJECT_STORAGE_BUCKET: 'test',
objectStorageForcePathStyle: true,
objectStorageAccessKey: 'test',
objectStorageSecretKey: 'test',
} as ConstructorParameters<typeof MediaService>[2],
);
Object.assign(service, { ensureBucket: async () => undefined });
const result = await service.createIntent(
'workspace-id',
'user-id',
'key',
{
purpose: 'product_image',
product_id: productId,
filename: 'sku.png',
content_type: 'image/png',
byte_size: 1024,
checksum_sha256: 'a'.repeat(64),
},
);
const args = query.mock.calls.find(([sql]) => sql.includes('INSERT INTO atlas.media_upload_intents'))?.[1] as unknown[];
expect(args[10]).toBe(productId);
expect(args[1]).toBe('product_image');
expect(args[3]).toContain('/product-images/');
const body = result.body as { data: { upload: { url: string } } };
expect(body.data.upload.url).toContain('product-images');
});
it('商品图允许 20 MiB,水印图仍限制 5 MiB', () => {
const request = {
filename: 'sku.png',
content_type: 'image/png',
byte_size: 6 * 1024 * 1024,
checksum_sha256: 'a'.repeat(64),
};
expect(
createMediaUploadIntentRequestSchema.safeParse({
...request,
purpose: 'product_image',
product_id: productId,
}).success,
).toBe(true);
expect(
createMediaUploadIntentRequestSchema.safeParse({
...request,
purpose: 'watermark_image',
}).success,
).toBe(false);
expect(
createMediaUploadIntentRequestSchema.safeParse({
...request,
purpose: 'product_image',
product_id: productId,
byte_size: 21 * 1024 * 1024,
}).success,
).toBe(false);
});
it('商品上传和网络导入必须绑定商品,水印仍无需商品', () => {
const request = { filename: 'image.png', content_type: 'image/png', byte_size: 1024, checksum_sha256: 'a'.repeat(64) };
expect(createMediaUploadIntentRequestSchema.safeParse({ ...request, purpose: 'product_image' }).success).toBe(false);
expect(createMediaUploadIntentRequestSchema.safeParse({ ...request, purpose: 'watermark_image' }).success).toBe(true);
expect(createMediaUploadIntentRequestSchema.safeParse({ ...request, purpose: 'watermark_image', product_id: productId }).success).toBe(false);
expect(importProductImageRequestSchema.safeParse({ url: 'https://example.test/image.png' }).success).toBe(false);
});
it('商品图校验接受 9000px,水印图仍拒绝超过 8192px', async () => {
const bytes = png(9000, 9000);
const service = serviceWithObject(bytes);
const intent = {
bucket: 'test',
object_key: 'product.png',
expected_content_type: 'image/png',
purpose: 'product_image',
};
await expect(service.inspectObject(intent)).resolves.toMatchObject({
width: 9000,
height: 9000,
checksum: createHash('sha256').update(bytes).digest('hex'),
});
await expect(
service.inspectObject({ ...intent, purpose: 'watermark_image' }),
).rejects.toThrow();
});
it('上传图片列表仅查询当前工作空间已完成的商品素材', async () => {
const query = vi.fn(async (sql: string) => {
expect(sql).toContain("i.purpose = 'product_image'");
expect(sql).toContain("i.status = 'ready'");
expect(sql).toContain('i.product_id = $1');
return {
rows: [
{
id: '7c910684-7070-4f36-8a7b-b10bd91acd79',
content_type: 'image/png',
byte_size: '24',
width_px: 800,
height_px: 800,
created_at: new Date('2026-09-24T00:00:00Z'),
bucket: 'test',
object_key: 'uploads/test.png',
},
],
};
});
const workspaceTransaction = vi.fn(
async (
workspaceId: string,
callback: (tx: { query: typeof query }) => Promise<unknown>,
) => {
expect(workspaceId).toBe('workspace-id');
return callback({ query });
},
);
const service = new MediaService(
{} as ConstructorParameters<typeof MediaService>[0],
{ workspaceTransaction } as unknown as ConstructorParameters<
typeof MediaService
>[1],
{
OBJECT_STORAGE_ENDPOINT: 'http://127.0.0.1:9000',
OBJECT_STORAGE_REGION: 'us-east-1',
OBJECT_STORAGE_BUCKET: 'test',
objectStorageForcePathStyle: true,
objectStorageAccessKey: 'test',
objectStorageSecretKey: 'test',
} as ConstructorParameters<typeof MediaService>[2],
);
const uploads = await service.productUploads('workspace-id', productId);
expect(uploads).toHaveLength(1);
expect(uploads[0]).toMatchObject({
id: '7c910684-7070-4f36-8a7b-b10bd91acd79',
byte_size: 24,
});
expect(uploads[0].preview_url).toContain('uploads/test.png');
});
it('网络导入只允许 HTTPS 图片链接,拒绝内网地址', async () => {
expect(
importProductImageRequestSchema.safeParse({
url: 'https://example.com/image.jpg',
product_id: productId,
}).success,
).toBe(true);
expect(
importProductImageRequestSchema.safeParse({
url: 'http://example.com/image.jpg',
}).success,
).toBe(false);
expect(
importProductImageRequestSchema.safeParse({
url: 'https://example.com/image.svg',
}).success,
).toBe(false);
expect(publicIpv4('8.8.8.8')).toBe(true);
for (const address of [
'127.0.0.1',
'10.1.2.3',
'172.16.0.1',
'192.168.1.1',
'169.254.1.1',
'100.64.0.1',
'224.0.0.1',
]) {
expect(publicIpv4(address)).toBe(false);
}
await expect(
downloadProductImage('https://127.0.0.1/image.jpg'),
).rejects.toThrow();
});
});
+170
View File
@@ -0,0 +1,170 @@
import assert from 'node:assert/strict';
import { randomUUID } from 'node:crypto';
import type { AddressInfo } from 'node:net';
import type { Database } from '@atlas/database';
import type { NestExpressApplication } from '@nestjs/platform-express';
import { afterAll, beforeAll, describe, it } from 'vitest';
import { DATABASE } from '../src/database/database.module.js';
import { createApplication } from '../src/main.js';
let app: NestExpressApplication;
let database: Database;
let baseUrl = '';
let workspaceId = '';
let userId = '';
let token = '';
describe('水印派生 API 集成测试', () => {
beforeAll(async () => {
Object.assign(process.env, {
NODE_ENV: 'test', SWAGGER_ENABLED: 'false',
AUTH_REGISTER_IP_LIMIT: '100', AUTH_REGISTER_EMAIL_LIMIT: '100',
});
const created = await createApplication();
app = created.app;
database = app.get<Database>(DATABASE);
await app.listen(0, '127.0.0.1');
baseUrl = `http://127.0.0.1:${(app.getHttpServer().address() as AddressInfo).port}`;
const response = await fetch(`${baseUrl}/api/auth/register`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
email: `watermark-derivative-${randomUUID()}@example.test`,
password: 'correct-horse-battery-staple-123',
password_confirmation: 'correct-horse-battery-staple-123',
}),
});
assert.equal(response.status, 201);
const body = await response.json() as { data: {
user: { id: string }; workspace: { id: string }; access_token: string;
} };
userId = body.data.user.id;
workspaceId = body.data.workspace.id;
token = body.data.access_token;
});
afterAll(async () => {
if (workspaceId) {
await database.workspaceTransaction(workspaceId, async (tx) => {
await tx.query('DELETE FROM atlas.idempotency_requests WHERE workspace_id = $1', [workspaceId]);
await tx.query('DELETE FROM atlas.outbox_events WHERE workspace_id = $1', [workspaceId]);
await tx.query('DELETE FROM atlas.operations WHERE workspace_id = $1', [workspaceId]);
await tx.query('DELETE FROM atlas.media_aspect_ratio_derivatives WHERE workspace_id = $1', [workspaceId]);
await tx.query('DELETE FROM atlas.media_watermark_derivatives WHERE workspace_id = $1', [workspaceId]);
await tx.query('DELETE FROM atlas.watermark_template_versions WHERE workspace_id = $1', [workspaceId]);
await tx.query('DELETE FROM atlas.watermark_templates WHERE workspace_id = $1', [workspaceId]);
await tx.query('DELETE FROM atlas.media_assets WHERE workspace_id = $1', [workspaceId]);
await tx.query('DELETE FROM atlas.products WHERE workspace_id = $1', [workspaceId]);
await tx.query('DELETE FROM atlas.collection_items WHERE workspace_id = $1', [workspaceId]);
});
await database.query('DELETE FROM atlas.workspace_memberships WHERE user_id = $1', [userId]);
await database.query('DELETE FROM atlas.workspaces WHERE id = $1', [workspaceId]);
await database.query('DELETE FROM atlas.users WHERE id = $1', [userId]);
}
await app?.close();
});
it('HTTP 创建任务锁定模板版本,结果查询保持工作空间边界', async () => {
const { assetId, templateId, productId } = await database.workspaceTransaction(workspaceId, async (tx) => {
const item = await tx.query<{ id: string }>(
`INSERT INTO atlas.collection_items (workspace_id, source_kind, source_item_key)
VALUES ($1, 'manual', $2) RETURNING id`,
[workspaceId, `watermark-history-${randomUUID()}`],
);
const product = await tx.query<{ id: string }>(
`INSERT INTO atlas.products (workspace_id, collection_item_id)
VALUES ($1, $2) RETURNING id`,
[workspaceId, item.rows[0].id],
);
const asset = await tx.query<{ id: string }>(
`INSERT INTO atlas.media_assets
(workspace_id, storage_provider, bucket, object_key, content_type,
byte_size, checksum_sha256, width_px, height_px)
VALUES ($1, 'local_minio', 'atlas-media', $2, 'image/jpeg', 100,
$3, 120, 100) RETURNING id`,
[workspaceId, `test/watermark-${randomUUID()}.jpg`, randomUUID().replaceAll('-', '').padEnd(64, '0')],
);
const template = await tx.query<{ id: string }>(
`INSERT INTO atlas.watermark_templates (workspace_id, name, kind)
VALUES ($1, $2, 'text') RETURNING id`,
[workspaceId, `集成测试水印 ${randomUUID()}`],
);
await tx.query(
`INSERT INTO atlas.watermark_template_versions
(workspace_id, template_id, version, kind, text_content, font_code,
font_size_px, opacity, position, position_x, position_y, created_by_user_id)
VALUES ($1, $2, 1, 'text', 'Atlas', 'NotoSans', 20, 0.5,
'bottom_right', 1, 1, $3)`,
[workspaceId, template.rows[0].id, userId],
);
return { assetId: asset.rows[0].id, templateId: template.rows[0].id, productId: product.rows[0].id };
});
const key = randomUUID();
const create = () => fetch(`${baseUrl}/api/media-assets/${assetId}/watermark-derivatives`, {
method: 'POST', headers: {
Authorization: `Bearer ${token}`, 'Content-Type': 'application/json', 'Idempotency-Key': key,
},
body: JSON.stringify({ watermark_template_id: templateId, product_id: productId }),
});
const response = await create();
assert.equal(response.status, 202);
const body = await response.json() as { data: {
id: string; watermark_template_id: string; watermark_template_version: number;
} };
assert.equal(body.data.watermark_template_id, templateId);
assert.equal(body.data.watermark_template_version, 1);
const replay = await create();
assert.equal(replay.status, 202);
assert.equal((await replay.json() as { data: { id: string } }).data.id, body.data.id);
const read = await fetch(`${baseUrl}/api/media-watermark-derivatives/${body.data.id}`, {
headers: { Authorization: `Bearer ${token}` },
});
assert.equal(read.status, 200);
assert.equal((await read.json() as { data: { id: string } }).data.id, body.data.id);
const stored = await database.workspaceTransaction(workspaceId, async (tx) =>
tx.query<{ product_id: string }>(
'SELECT product_id FROM atlas.media_watermark_derivatives WHERE id = $1',
[body.data.id],
),
);
assert.equal(stored.rows[0]?.product_id, productId);
const outputs = await database.workspaceTransaction(workspaceId, async (tx) => {
const ids: string[] = [];
for (const kind of ['watermark', 'ratio']) {
const asset = await tx.query<{ id: string }>(
`INSERT INTO atlas.media_assets
(workspace_id, storage_provider, bucket, object_key, content_type,
byte_size, checksum_sha256, width_px, height_px)
VALUES ($1, 'local_minio', 'atlas-media', $2, 'image/jpeg', 100,
$3, 1200, 1600) RETURNING id`,
[workspaceId, `test/${kind}-${randomUUID()}.jpg`, randomUUID().replaceAll('-', '').padEnd(64, '0')],
);
ids.push(asset.rows[0].id);
}
await tx.query(
`INSERT INTO atlas.media_watermark_derivatives
(workspace_id, product_id, source_asset_id, watermark_template_id,
watermark_template_version, output_asset_id, status, completed_at)
VALUES ($1, $2, $3, $4, 1, $5, 'succeeded', now())`,
[workspaceId, productId, assetId, templateId, ids[0]],
);
await tx.query(
`INSERT INTO atlas.media_aspect_ratio_derivatives
(workspace_id, product_id, source_asset_id, output_asset_id, status, completed_at)
VALUES ($1, $2, $3, $4, 'succeeded', now())`,
[workspaceId, productId, assetId, ids[1]],
);
return ids;
});
const history = await fetch(`${baseUrl}/api/products/${productId}/image-derivatives`, {
headers: { Authorization: `Bearer ${token}` },
});
assert.equal(history.status, 200);
const records = (await history.json() as { data: Array<{
kind: string; output_asset: { id: string; preview_url: string };
}> }).data;
assert.deepEqual(new Set(records.map((record) => record.kind)), new Set(['watermark', 'aspect_ratio_3_4']));
assert.deepEqual(new Set(records.map((record) => record.output_asset.id)), new Set(outputs));
assert.ok(records.every((record) => record.output_asset.preview_url));
});
});
@@ -0,0 +1,68 @@
import { describe, expect, it, vi } from 'vitest';
import { WatermarkDerivativeService } from '../src/media/watermark-derivative.service.js';
const workspaceId = '30cf54b7-06a3-4e5f-a5fc-d74165115094';
const sourceAssetId = '664d0d73-3d39-4a98-ac0a-5c0e521d8998';
const templateId = '0878262e-6c38-4f3b-953a-2084ef297a33';
const derivativeId = '68db226f-3fcb-4579-ab9e-4932b1d84067';
const productId = '733d0d73-3d39-4a98-ac0a-5c0e521d8998';
function serviceWithQuery(query: ReturnType<typeof vi.fn>) {
const run = vi.fn(async (_input: unknown, work: (tx: { query: typeof query }) => Promise<unknown>) =>
work({ query }));
const service = new WatermarkDerivativeService(
{} as ConstructorParameters<typeof WatermarkDerivativeService>[0],
{ run } as unknown as ConstructorParameters<typeof WatermarkDerivativeService>[1],
{} as ConstructorParameters<typeof WatermarkDerivativeService>[2],
);
return { service, run };
}
describe('水印派生任务', () => {
it('固定当前模板版本,并在同一个事务创建任务、操作和 Outbox', async () => {
const statements: string[] = [];
const query = vi.fn(async (sql: string) => {
statements.push(sql);
if (sql.includes('SELECT id FROM atlas.media_assets')) return { rows: [{ id: sourceAssetId }] };
if (sql.includes('FROM atlas.watermark_templates')) return { rows: [{ id: templateId, current_version: 4 }] };
if (sql.includes('RETURNING id')) return { rows: [{ id: derivativeId }] };
return { rows: [] };
});
const { service, run } = serviceWithQuery(query);
const result = await service.create(workspaceId, 'user-id', sourceAssetId, 'idempotency-key',
{ watermark_template_id: templateId });
expect(result).toMatchObject({ status: 202, body: { data: {
id: derivativeId, watermark_template_id: templateId, watermark_template_version: 4,
} } });
expect(run).toHaveBeenCalledOnce();
expect(statements.some((sql) => sql.includes('INSERT INTO atlas.media_watermark_derivatives'))).toBe(true);
expect(statements.some((sql) => sql.includes('INSERT INTO atlas.operations'))).toBe(true);
expect(statements.some((sql) => sql.includes('INSERT INTO atlas.outbox_events'))).toBe(true);
});
it('拒绝不可用模板且不创建派生任务', async () => {
const query = vi.fn(async (sql: string) => sql.includes('SELECT id FROM atlas.media_assets')
? { rows: [{ id: sourceAssetId }] } : { rows: [] });
const { service } = serviceWithQuery(query);
await expect(service.create(workspaceId, 'user-id', sourceAssetId, 'idempotency-key',
{ watermark_template_id: templateId })).rejects.toMatchObject({ status: 404 });
expect(query.mock.calls.every(([sql]) => !String(sql).includes('INSERT INTO'))).toBe(true);
});
it('将当前工作空间的商品关联到水印结果', async () => {
const query = vi.fn(async (sql: string, values?: unknown[]) => {
if (sql.includes('SELECT id FROM atlas.media_assets')) return { rows: [{ id: sourceAssetId }] };
if (sql.includes('SELECT id FROM atlas.products')) return { rows: [{ id: productId }] };
if (sql.includes('FROM atlas.watermark_templates')) return { rows: [{ id: templateId, current_version: 4 }] };
if (sql.includes('INSERT INTO atlas.media_watermark_derivatives')) {
expect(values).toEqual([productId, sourceAssetId, templateId, 4, 'user-id']);
return { rows: [{ id: derivativeId }] };
}
return { rows: [] };
});
const { service } = serviceWithQuery(query);
await service.create(workspaceId, 'user-id', sourceAssetId, 'key',
{ watermark_template_id: templateId, product_id: productId });
expect(query.mock.calls.some(([sql]) => String(sql).includes('INSERT INTO atlas.media_watermark_derivatives'))).toBe(true);
});
});
+258
View File
@@ -0,0 +1,258 @@
import assert from 'node:assert/strict';
import { randomUUID } from 'node:crypto';
import type { AddressInfo } from 'node:net';
import type { Database } from '@atlas/database';
import type { NestExpressApplication } from '@nestjs/platform-express';
import { afterAll, beforeAll, describe, it } from 'vitest';
import { DATABASE } from '../src/database/database.module.js';
import { createApplication } from '../src/main.js';
let app: NestExpressApplication;
let database: Database;
let baseUrl: string;
let token = '';
let workspaceId = '';
let userId = '';
let platformId = '';
let storeId = '';
let listingId = '';
let runId = '';
async function request(path: string, init: RequestInit = {}) {
const response = await fetch(`${baseUrl}${path}`, {
...init,
headers: { 'Content-Type': 'application/json', ...init.headers },
});
return { response, body: (await response.json()) as Record<string, any> };
}
function auth(extra: Record<string, string> = {}) {
return {
Authorization: `Bearer ${token}`,
'X-Atlas-Platform-Id': platformId,
...extra,
};
}
describe('在线商品 HTTP 契约', () => {
beforeAll(async () => {
Object.assign(process.env, {
NODE_ENV: 'test',
SWAGGER_ENABLED: 'false',
AUTH_REGISTER_IP_LIMIT: '100',
AUTH_REGISTER_EMAIL_LIMIT: '100',
});
const created = await createApplication();
app = created.app;
database = app.get<Database>(DATABASE);
await app.listen(0, '127.0.0.1');
baseUrl = `http://127.0.0.1:${(app.getHttpServer().address() as AddressInfo).port}`;
const registered = await request('/api/auth/register', {
method: 'POST',
body: JSON.stringify({
email: `online-${randomUUID()}@example.test`,
password: 'correct-horse-battery-staple-123',
password_confirmation: 'correct-horse-battery-staple-123',
}),
});
assert.equal(registered.response.status, 201);
token = registered.body.data.access_token;
workspaceId = registered.body.data.workspace.id;
userId = registered.body.data.user.id;
const platforms = await request('/api/platforms', {
headers: { Authorization: `Bearer ${token}` },
});
platformId = platforms.body.data.find(
(platform: { code: string }) => platform.code === 'OZON',
).id;
storeId = randomUUID();
await database.workspaceTransaction(workspaceId, async (tx) => {
await tx.query(
`INSERT INTO atlas.stores (id, workspace_id, platform_id, name, platform_client_id, currency_code, is_default, status, credential_status)
VALUES ($1, $2, $3, '测试门店', $4, 'RUB', true, 'active', 'valid')`,
[storeId, workspaceId, platformId, `online-http-${storeId}`],
);
const run = await tx.query<{ id: string }>(
`INSERT INTO atlas.platform_product_sync_runs
(workspace_id, store_id, platform_id, trigger_source, status, completed_at)
VALUES ($1, $2, $3, 'manual', 'succeeded', now()) RETURNING id`,
[workspaceId, storeId, platformId],
);
runId = run.rows[0].id;
const listing = await tx.query<{ id: string }>(
`INSERT INTO atlas.platform_listings
(workspace_id, store_id, platform_id, remote_product_id, offer_id, remote_sku, name,
currency_code, selling_price, visible, moderation_status, stock_summary,
image_summary, source, last_sync_run_id, last_synced_at, raw_payload)
VALUES ($1, $2, $3, '123456', 'test-offer', '654321', '测试商品',
'RUB', 99, true, 'approved', '{"total":0,"has_data":true}',
'{"url":null,"width_px":null,"height_px":null}', 'external_sync', $4, now(), '{}') RETURNING id`,
[workspaceId, storeId, platformId, runId],
);
listingId = listing.rows[0].id;
await tx.query(
`INSERT INTO atlas.platform_listings
(workspace_id, store_id, platform_id, remote_product_id, offer_id, name,
visible, stock_summary, image_summary, source, last_sync_run_id, last_synced_at,
missing_since, archived_at, raw_payload)
VALUES ($1, $2, $3, '123457', 'archived-offer', '归档商品',
NULL, '{"total":0,"has_data":false}', '{}', 'external_sync', $4,
now(), now() - interval '8 days', now(), '{}')`,
[workspaceId, storeId, platformId, runId],
);
});
});
afterAll(async () => {
if (workspaceId && database) {
await database.workspaceTransaction(workspaceId, async (tx) => {
await tx.query(
'DELETE FROM atlas.platform_listing_sync_snapshots WHERE workspace_id = $1',
[workspaceId],
);
await tx.query(
'DELETE FROM atlas.platform_listings WHERE workspace_id = $1',
[workspaceId],
);
await tx.query(
'DELETE FROM atlas.platform_product_sync_runs WHERE workspace_id = $1',
[workspaceId],
);
await tx.query(
'DELETE FROM atlas.idempotency_requests WHERE workspace_id = $1',
[workspaceId],
);
await tx.query(
'DELETE FROM atlas.outbox_events WHERE workspace_id = $1',
[workspaceId],
);
await tx.query(
'DELETE FROM atlas.store_secrets WHERE workspace_id = $1',
[workspaceId],
);
await tx.query(
'DELETE FROM atlas.stores WHERE workspace_id = $1',
[workspaceId],
);
});
await database.query(
'DELETE FROM atlas.workspace_memberships WHERE user_id = $1',
[userId],
);
await database.query('DELETE FROM atlas.workspaces WHERE id = $1', [
workspaceId,
]);
await database.query('DELETE FROM atlas.users WHERE id = $1', [
userId,
]);
}
if (app) await app.close();
});
it('列表、详情与同步历史按平台店铺返回镜像,不返回原始响应', async () => {
const listed = await request(
`/api/platform-listings?store_id=${storeId}&q=test-offer`,
{ headers: auth() },
);
assert.equal(listed.response.status, 200);
assert.equal(listed.body.data.length, 1);
assert.equal(listed.body.data[0].visible, true);
assert.equal(listed.body.data[0].selling_price, '99.00');
assert.deepEqual(listed.body.data[0].stock_summary, {
total: 0,
has_data: true,
});
assert.equal(listed.body.meta.store.id, storeId);
assert.equal(listed.body.meta.last_sync_status, 'succeeded');
assert.equal('raw_payload' in listed.body.data[0], false);
const detail = await request(`/api/platform-listings/${listingId}`, {
headers: auth(),
});
assert.equal(detail.response.status, 200);
assert.equal(detail.body.data.offer_id, 'test-offer');
const runs = await request(
`/api/stores/${storeId}/platform-product-sync-runs`,
{ headers: auth() },
);
assert.equal(runs.response.status, 200);
assert.equal(runs.body.data[0].id, runId);
const run = await request(`/api/platform-product-sync-runs/${runId}`, {
headers: auth(),
});
assert.equal(run.response.status, 200);
assert.equal(run.body.data.status, 'succeeded');
});
it('筛选未知可见性与缺少平台头的请求按契约处理', async () => {
const unknown = await request(
`/api/platform-listings?store_id=${storeId}&visibility=unknown`,
{ headers: auth() },
);
assert.equal(unknown.response.status, 200);
assert.equal(unknown.body.data.length, 0);
const archived = await request(
`/api/platform-listings?store_id=${storeId}&visibility=unknown&include_archived=true`,
{ headers: auth() },
);
assert.equal(archived.response.status, 200);
assert.equal(archived.body.data.length, 1);
assert.equal(archived.body.data[0].offer_id, 'archived-offer');
const missingHeader = await request('/api/platform-listings', {
headers: { Authorization: `Bearer ${token}` },
});
assert.equal(missingHeader.response.status, 400);
assert.equal(
missingHeader.body.error.code,
'PLATFORM_CONTEXT_REQUIRED',
);
const noCredentials = await request(
`/api/stores/${storeId}/platform-product-sync-runs`,
{
method: 'POST',
headers: auth({ 'Idempotency-Key': randomUUID() }),
body: JSON.stringify({ reason: 'manual_refresh' }),
},
);
assert.equal(noCredentials.response.status, 422);
assert.equal(
noCredentials.body.error.code,
'STORE_CREDENTIAL_UNAVAILABLE',
);
});
it('触发同步写入 Outbox,同一幂等键复用任务,并发请求返回冲突', async () => {
await database.workspaceTransaction(workspaceId, (tx) =>
tx.query(
`INSERT INTO atlas.store_secrets
(workspace_id, store_id, kind, secret_ciphertext, secret_nonce,
encryption_key_version, fingerprint, status)
VALUES ($1, $2, 'seller_api_key', $3, $4, 'v1', $5, 'valid')`,
[workspaceId, storeId, Buffer.alloc(32), Buffer.alloc(12), randomUUID()],
),
);
const key = randomUUID();
const post = (idempotencyKey: string) =>
request(`/api/stores/${storeId}/platform-product-sync-runs`, {
method: 'POST',
headers: auth({ 'Idempotency-Key': idempotencyKey }),
body: JSON.stringify({ reason: 'manual_refresh' }),
});
const created = await post(key);
assert.equal(created.response.status, 202);
assert.equal(created.body.data.sync_run.status, 'queued');
const repeated = await post(key);
assert.equal(repeated.response.status, 202);
assert.equal(repeated.body.data.sync_run.id, created.body.data.sync_run.id);
const concurrent = await post(randomUUID());
assert.equal(concurrent.response.status, 409);
assert.equal(concurrent.body.error.code, 'SYNC_ALREADY_RUNNING');
const events = await database.workspaceTransaction(workspaceId, (tx) =>
tx.query<{ count: string }>(
`SELECT count(*)::text AS count FROM atlas.outbox_events
WHERE aggregate_id = $1 AND event_type = 'platform_product_sync.requested'`,
[created.body.data.sync_run.id],
),
);
assert.equal(events.rows[0].count, '1');
});
});

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