Compare commits
47 Commits
feat-0.1.0
...
da7342b466
| Author | SHA1 | Date | |
|---|---|---|---|
| da7342b466 | |||
| 1b03ea5871 | |||
| 2c4e94ab3d | |||
| 05a00d46b9 | |||
| 946af649dd | |||
| 130a005231 | |||
| 319887726e | |||
| ceb644a3f9 | |||
| 4c0f3a4a79 | |||
| 87db74fb89 | |||
| edf7939963 | |||
| f635359216 | |||
| e277754588 | |||
| 009082d0f4 | |||
| c2523fe349 | |||
| 70d9d5b92a | |||
| 66ca8b496c | |||
| 5bdf1645d2 | |||
| 1b0cdc33aa | |||
| d199c4d6df | |||
| aa3076c434 | |||
| eecec5e0d0 | |||
| c0473d607a | |||
| 27b9e07b62 | |||
| 6879e7a8b7 | |||
| 9164fc962e | |||
| 24a25dcbd1 | |||
| 5bf5f80b34 | |||
| 46d40b184b | |||
| 446baf5939 | |||
| 8655b7805f | |||
| 3ab999d0b2 | |||
| cc61967e3b | |||
| dc39e3e4e5 | |||
| 2d435143ab | |||
| 06a3a4da75 | |||
| a5b89ddb1d | |||
| 46d98ab11e | |||
| ffa86f8d55 | |||
| ada02e9135 | |||
| 35fcf8776e | |||
| df40ddde8c | |||
| 037928c778 | |||
| 195b6d25e9 | |||
| 403f6cfa27 | |||
| 09e5207c83 | |||
| 1a2caba2b3 |
@@ -0,0 +1,86 @@
|
|||||||
|
# Local PostgreSQL bootstrap administrator. It creates roles but must never be used by API or Worker.
|
||||||
|
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_PORT=5432
|
||||||
|
|
||||||
|
# Password for the schema-owning migrator role; only the migration runner may use it.
|
||||||
|
ATLAS_MIGRATOR_PASSWORD=change-me-local-only
|
||||||
|
# Password for the least-privileged API/Worker login role.
|
||||||
|
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
|
||||||
|
|
||||||
|
# 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
|
||||||
|
# Local WXT browser-extension development port.
|
||||||
|
EXTENSION_PORT=8110
|
||||||
+12
@@ -11,7 +11,19 @@
|
|||||||
# Common future runtime/build artifacts
|
# Common future runtime/build artifacts
|
||||||
node_modules/
|
node_modules/
|
||||||
dist/
|
dist/
|
||||||
|
.output/
|
||||||
.venv/
|
.venv/
|
||||||
__pycache__/
|
__pycache__/
|
||||||
*.py[cod]
|
*.py[cod]
|
||||||
*.log
|
*.log
|
||||||
|
node_modules/
|
||||||
|
.pnpm-store/
|
||||||
|
.turbo/
|
||||||
|
dist/
|
||||||
|
coverage/
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
*.log
|
||||||
|
.DS_Store
|
||||||
|
data/
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
node_modules/
|
||||||
|
.turbo/
|
||||||
|
dist/
|
||||||
|
apps/extension/.output/
|
||||||
|
apps/extension/.wxt/
|
||||||
|
docs/**
|
||||||
|
!docs/architecture/project-structure.md
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"singleQuote": true,
|
||||||
|
"trailingComma": "all",
|
||||||
|
"bracketSpacing": true,
|
||||||
|
"arrowParens": "always",
|
||||||
|
"printWidth": 80,
|
||||||
|
"tabWidth": 4,
|
||||||
|
"useTabs": false
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
# AGENTS.md — 工作区工作约定
|
||||||
|
|
||||||
|
本工作区(`atlas-seller-kit-frontend` worktree,`zcode/frontend` 分支)的 AI 会话必须遵守以下规则,优先级高于会话内的自行判断。
|
||||||
|
|
||||||
|
## 1. 只改前端
|
||||||
|
|
||||||
|
- 只修改 `apps/web/` 及前端相关文档(即协作约定中「前端体验」领域,见 `docs/project/development-collaboration-contract.md` §2)。
|
||||||
|
- `apps/api/`、`apps/worker/`、`packages/database/`、`packages/contracts/` 等服务端领域的代码一律不改;发现问题时在回复中说明,由维护者/后端处理。
|
||||||
|
|
||||||
|
## 2. 不要启动服务端和 Worker
|
||||||
|
|
||||||
|
- API(NestJS)和 Worker 由维护者自行在其他地方启动,AI 不需要也不应该启动、重启或杀死它们。
|
||||||
|
- 开发/验证时若检测到 API 或 Worker 未运行,直接在回复中说明即可,不要尝试代为启动。
|
||||||
|
- 前端 vite dev server 属于前端领域,可为验证目的临时启动,用完后应停掉,并避免占用维护者已在使用的端口(如 5173/8200)。
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
SELECT
|
||||||
|
u.id,
|
||||||
|
u.email,
|
||||||
|
u.display_name,
|
||||||
|
u.status,
|
||||||
|
u.created_at,
|
||||||
|
u.last_login_at,
|
||||||
|
w.id AS workspace_id,
|
||||||
|
w.name AS workspace_name,
|
||||||
|
m.role AS workspace_role
|
||||||
|
FROM atlas.users u
|
||||||
|
LEFT JOIN atlas.workspace_memberships m ON m.user_id = u.id
|
||||||
|
LEFT JOIN atlas.workspaces w ON w.id = m.workspace_id
|
||||||
|
ORDER BY u.created_at DESC;
|
||||||
|
|
||||||
|
SELECT code, name, capabilities
|
||||||
|
FROM atlas.platforms
|
||||||
|
WHERE code = 'OZON';
|
||||||
|
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
SET search_path TO atlas, public;
|
||||||
|
|
||||||
|
INSERT INTO platform_currency_support (platform_id, currency_code)
|
||||||
|
SELECT id, 'CNY'::CHAR(3)
|
||||||
|
FROM platforms
|
||||||
|
WHERE code = 'OZON'
|
||||||
|
ON CONFLICT (platform_id, currency_code) DO UPDATE
|
||||||
|
SET is_enabled = true;
|
||||||
|
|
||||||
|
UPDATE platforms
|
||||||
|
SET capabilities = capabilities || jsonb_build_object(
|
||||||
|
'default_currency_code', 'CNY'
|
||||||
|
),
|
||||||
|
updated_at = now()
|
||||||
|
WHERE code = 'OZON';
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
@@ -6,7 +6,9 @@ Atlas Seller Kit 是面向多用户、多门店、多平台与多国家市场的
|
|||||||
|
|
||||||
## 当前状态
|
## 当前状态
|
||||||
|
|
||||||
仅完成产品与架构设计,尚未开始应用代码开发。开始新 AI 对话或开发前,请先阅读 [项目交接](./docs/project/HANDOFF.md),再阅读 [文档导航](./docs/README.md)。
|
项目已完成 monorepo 与本地基础设施初始化,并实现认证、个人工作空间、平台目录/上下文和门店管理 HTTP API。Web 已提供登录/注册、主布局、页头平台选择器和接入真实 API 的店铺管理页;其余业务页面仍为占位。门店凭据验证已接入 Outbox Dispatcher 与 Worker;仓库同步仍待实现。
|
||||||
|
|
||||||
|
开始新 AI 对话或开发前,请先阅读 [项目交接](./docs/project/HANDOFF.md),再阅读 [文档导航](./docs/README.md)。
|
||||||
|
|
||||||
## 设计目标
|
## 设计目标
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"singleQuote": true,
|
||||||
|
"trailingComma": "all",
|
||||||
|
"bracketSpacing": true,
|
||||||
|
"arrowParens": "always",
|
||||||
|
"printWidth": 80,
|
||||||
|
"tabWidth": 4,
|
||||||
|
"useTabs": false
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
{
|
||||||
|
"name": "@atlas/api",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc -p tsconfig.json",
|
||||||
|
"dev": "node scripts/dev.mjs",
|
||||||
|
"start": "node dist/main.js",
|
||||||
|
"typecheck": "tsc -p tsconfig.json --noEmit && tsc -p tsconfig.test.json --noEmit",
|
||||||
|
"test": "vitest run"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@atlas/contracts": "workspace:*",
|
||||||
|
"@atlas/database": "workspace:*",
|
||||||
|
"@aws-sdk/client-s3": "3.1015.0",
|
||||||
|
"@aws-sdk/s3-request-presigner": "3.1015.0",
|
||||||
|
"@nestjs/common": "12.0.1",
|
||||||
|
"@nestjs/config": "12.0.0",
|
||||||
|
"@nestjs/core": "12.0.1",
|
||||||
|
"@nestjs/platform-express": "12.0.1",
|
||||||
|
"@nestjs/swagger": "12.0.1",
|
||||||
|
"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",
|
||||||
|
"tsx": "4.23.13",
|
||||||
|
"typescript": "7.0.2",
|
||||||
|
"vitest": "^4.1.11"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import { spawn } from 'node:child_process';
|
||||||
|
import { existsSync, readFileSync } from 'node:fs';
|
||||||
|
import { dirname, resolve } from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
|
const scriptDirectory = dirname(fileURLToPath(import.meta.url));
|
||||||
|
const repositoryRoot = resolve(scriptDirectory, '../../..');
|
||||||
|
const environmentFile = resolve(repositoryRoot, '.env');
|
||||||
|
const localEnvironment = existsSync(environmentFile)
|
||||||
|
? parseEnvironment(readFileSync(environmentFile, 'utf8'))
|
||||||
|
: {};
|
||||||
|
const apiPort = process.env.API_PORT ?? localEnvironment.API_PORT ?? '8200';
|
||||||
|
const swaggerEnabled =
|
||||||
|
(process.env.SWAGGER_ENABLED ??
|
||||||
|
localEnvironment.SWAGGER_ENABLED ??
|
||||||
|
'true') === 'true';
|
||||||
|
const nodeEnvironment =
|
||||||
|
process.env.NODE_ENV ?? localEnvironment.NODE_ENV ?? 'development';
|
||||||
|
const swaggerUrl = `http://localhost:${apiPort}/api-docs`;
|
||||||
|
const tsxCommand = process.platform === 'win32' ? 'tsx.cmd' : 'tsx';
|
||||||
|
const apiProcess = spawn(tsxCommand, ['watch', 'src/main.ts'], {
|
||||||
|
shell: process.platform === 'win32',
|
||||||
|
stdio: 'inherit',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (
|
||||||
|
process.platform === 'darwin' &&
|
||||||
|
nodeEnvironment === 'development' &&
|
||||||
|
swaggerEnabled
|
||||||
|
) {
|
||||||
|
void openSwaggerAfterApiIsReady();
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const signal of ['SIGINT', 'SIGTERM']) {
|
||||||
|
process.on(signal, () => apiProcess.kill(signal));
|
||||||
|
}
|
||||||
|
|
||||||
|
apiProcess.on('exit', (code, signal) => {
|
||||||
|
if (signal) process.exitCode = 1;
|
||||||
|
else process.exitCode = code ?? 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
function parseEnvironment(content) {
|
||||||
|
return Object.fromEntries(
|
||||||
|
content
|
||||||
|
.split(/\r?\n/)
|
||||||
|
.map((line) => line.match(/^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/))
|
||||||
|
.filter(Boolean)
|
||||||
|
.map((match) => [match[1], match[2]]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openSwaggerAfterApiIsReady() {
|
||||||
|
const deadline = Date.now() + 30_000;
|
||||||
|
while (Date.now() < deadline) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`http://localhost:${apiPort}/health`);
|
||||||
|
if (response.ok) {
|
||||||
|
// 开发启动器进程不会随 tsx watch 的热重启重建,因此只会打开一次。
|
||||||
|
spawn('open', [swaggerUrl], {
|
||||||
|
detached: true,
|
||||||
|
stdio: 'ignore',
|
||||||
|
}).unref();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// API 仍在启动或热重启,继续等待到超时为止。
|
||||||
|
}
|
||||||
|
await new Promise((resolveDelay) => setTimeout(resolveDelay, 250));
|
||||||
|
}
|
||||||
|
console.warn(`Swagger 未在 30 秒内就绪:${swaggerUrl}`);
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
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';
|
||||||
|
|
||||||
|
@Controller()
|
||||||
|
class HealthController {
|
||||||
|
public constructor(@Inject(DATABASE) private readonly database: Database) {}
|
||||||
|
|
||||||
|
@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({
|
||||||
|
imports: [
|
||||||
|
AppConfigModule,
|
||||||
|
DatabaseModule,
|
||||||
|
SecurityModule,
|
||||||
|
AuthModule,
|
||||||
|
StoresModule,
|
||||||
|
WatermarksModule,
|
||||||
|
MediaModule,
|
||||||
|
CollectionModule,
|
||||||
|
ExtensionModule,
|
||||||
|
ProductEditorModule,
|
||||||
|
],
|
||||||
|
controllers: [HealthController],
|
||||||
|
})
|
||||||
|
export class AppModule {}
|
||||||
@@ -0,0 +1,323 @@
|
|||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
Headers,
|
||||||
|
HttpCode,
|
||||||
|
Inject,
|
||||||
|
Patch,
|
||||||
|
Post,
|
||||||
|
Req,
|
||||||
|
Res,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import {
|
||||||
|
ApiBearerAuth,
|
||||||
|
ApiBody,
|
||||||
|
ApiNoContentResponse,
|
||||||
|
ApiOkResponse,
|
||||||
|
ApiOperation,
|
||||||
|
ApiTags,
|
||||||
|
ApiUnauthorizedResponse,
|
||||||
|
} from '@nestjs/swagger';
|
||||||
|
import { createHash } from 'node:crypto';
|
||||||
|
import {
|
||||||
|
changePasswordRequestSchema,
|
||||||
|
loginRequestSchema,
|
||||||
|
registerRequestSchema,
|
||||||
|
updateCurrentUserRequestSchema,
|
||||||
|
type Workspace,
|
||||||
|
} from '@atlas/contracts';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { ApiError } from '../common/api-error.js';
|
||||||
|
import { Public } from '../common/public.decorator.js';
|
||||||
|
import type { AuthenticatedRequest } from './auth.guard.js';
|
||||||
|
import { AuthService, type RequestMetadata } from './auth.service.js';
|
||||||
|
import { CurrentWorkspace } from '../workspace/current-workspace.decorator.js';
|
||||||
|
|
||||||
|
const refreshCookie = 'atlas_refresh';
|
||||||
|
// 控制器只负责 HTTP 边界;账户、会话与工作空间的一致性由 AuthService 的事务保证。
|
||||||
|
type HttpRequest = {
|
||||||
|
header: (name: string) => string | undefined;
|
||||||
|
ip?: string;
|
||||||
|
};
|
||||||
|
type HttpResponse = {
|
||||||
|
cookie: (
|
||||||
|
name: string,
|
||||||
|
value: string,
|
||||||
|
options: Record<string, unknown>,
|
||||||
|
) => void;
|
||||||
|
clearCookie: (name: string, options: Record<string, unknown>) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
@ApiTags('Authentication')
|
||||||
|
@Controller('auth')
|
||||||
|
export class AuthController {
|
||||||
|
public constructor(
|
||||||
|
@Inject(AuthService) private readonly authService: AuthService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
@Post('register')
|
||||||
|
@Public()
|
||||||
|
@ApiOperation({ summary: '注册账户并创建个人工作空间' })
|
||||||
|
@ApiBody({
|
||||||
|
schema: {
|
||||||
|
example: {
|
||||||
|
email: 'owner@example.com',
|
||||||
|
password: 'a-strong-password',
|
||||||
|
password_confirmation: 'a-strong-password',
|
||||||
|
display_name: 'Joey',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
public async register(
|
||||||
|
@Body() body: unknown,
|
||||||
|
@Req() request: HttpRequest,
|
||||||
|
@Res({ passthrough: true }) response: HttpResponse,
|
||||||
|
) {
|
||||||
|
const result = await this.authService.register(
|
||||||
|
parse(registerRequestSchema, body),
|
||||||
|
metadata(request),
|
||||||
|
);
|
||||||
|
setRefreshCookie(response, result.refreshToken);
|
||||||
|
return { data: result.payload };
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('login')
|
||||||
|
@Public()
|
||||||
|
@HttpCode(200)
|
||||||
|
@ApiOperation({ summary: '使用邮箱和密码登录' })
|
||||||
|
@ApiBody({
|
||||||
|
schema: {
|
||||||
|
example: {
|
||||||
|
email: 'owner@example.com',
|
||||||
|
password: 'a-strong-password',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
public async login(
|
||||||
|
@Body() body: unknown,
|
||||||
|
@Req() request: HttpRequest,
|
||||||
|
@Res({ passthrough: true }) response: HttpResponse,
|
||||||
|
) {
|
||||||
|
const result = await this.authService.login(
|
||||||
|
parse(loginRequestSchema, body),
|
||||||
|
metadata(request),
|
||||||
|
);
|
||||||
|
setRefreshCookie(response, result.refreshToken);
|
||||||
|
return { data: result.payload };
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('refresh')
|
||||||
|
@Public()
|
||||||
|
@HttpCode(200)
|
||||||
|
@ApiOperation({ summary: '轮换 refresh cookie 并获取新的 access token' })
|
||||||
|
public async refresh(
|
||||||
|
@Req() request: HttpRequest,
|
||||||
|
@Res({ passthrough: true }) response: HttpResponse,
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
// refresh token 不进入 JSON body,仅从浏览器自动发送的 HttpOnly cookie 读取。
|
||||||
|
const result = await this.authService.refresh(
|
||||||
|
readCookie(request.header('cookie'), refreshCookie),
|
||||||
|
metadata(request),
|
||||||
|
);
|
||||||
|
setRefreshCookie(response, result.refreshToken);
|
||||||
|
return {
|
||||||
|
data: {
|
||||||
|
access_token: result.payload.access_token,
|
||||||
|
access_token_expires_in:
|
||||||
|
result.payload.access_token_expires_in,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
clearRefreshCookie(response);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('logout')
|
||||||
|
@HttpCode(204)
|
||||||
|
@ApiBearerAuth('access-token')
|
||||||
|
@ApiOperation({ summary: '撤销当前登录会话' })
|
||||||
|
@ApiNoContentResponse({ description: '会话已撤销' })
|
||||||
|
@ApiUnauthorizedResponse({ description: 'access token 无效或已过期' })
|
||||||
|
public async logout(
|
||||||
|
@Req() request: AuthenticatedRequest,
|
||||||
|
@Res({ passthrough: true }) response: HttpResponse,
|
||||||
|
) {
|
||||||
|
await this.authService.logout(request.auth.sessionId);
|
||||||
|
clearRefreshCookie(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('me')
|
||||||
|
@ApiBearerAuth('access-token')
|
||||||
|
@ApiOperation({ summary: '获取当前用户、工作空间和页头初始上下文' })
|
||||||
|
@ApiOkResponse({ description: '当前身份和工作空间' })
|
||||||
|
@ApiUnauthorizedResponse({ description: 'access token 无效或已过期' })
|
||||||
|
public async me(
|
||||||
|
@Req() request: AuthenticatedRequest,
|
||||||
|
@CurrentWorkspace() workspace: Workspace,
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
data: await this.authService.me(request.auth.userId, workspace),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch('me')
|
||||||
|
@ApiBearerAuth('access-token')
|
||||||
|
@ApiOperation({ summary: '按版本更新当前账户的显示名称或头像素材引用' })
|
||||||
|
@ApiBody({
|
||||||
|
schema: {
|
||||||
|
example: {
|
||||||
|
display_name: 'Joey',
|
||||||
|
avatar_asset_id: 'b2b592e8-96b0-4d2b-afc4-2c6462f2065b',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
public async updateProfile(
|
||||||
|
@Body() body: unknown,
|
||||||
|
@Headers('if-match') ifMatch: string | undefined,
|
||||||
|
@Headers('idempotency-key') idempotencyKey: string | undefined,
|
||||||
|
@Req() request: AuthenticatedRequest,
|
||||||
|
@CurrentWorkspace() workspace: Workspace,
|
||||||
|
) {
|
||||||
|
return (
|
||||||
|
await this.authService.updateProfile(
|
||||||
|
workspace,
|
||||||
|
request.auth.userId,
|
||||||
|
requireIdempotencyKey(idempotencyKey),
|
||||||
|
parseRevision(ifMatch),
|
||||||
|
parse(updateCurrentUserRequestSchema, body),
|
||||||
|
)
|
||||||
|
).body;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('change-password')
|
||||||
|
@HttpCode(200)
|
||||||
|
@ApiBearerAuth('access-token')
|
||||||
|
@ApiOperation({ summary: '修改密码、撤销旧会话并轮换当前会话' })
|
||||||
|
@ApiBody({
|
||||||
|
schema: {
|
||||||
|
example: {
|
||||||
|
old_password: 'old-strong-password',
|
||||||
|
password: 'new-strong-password',
|
||||||
|
password_confirmation: 'new-strong-password',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
public async changePassword(
|
||||||
|
@Body() body: unknown,
|
||||||
|
@Req() request: AuthenticatedRequest & HttpRequest,
|
||||||
|
@CurrentWorkspace() workspace: Workspace,
|
||||||
|
@Res({ passthrough: true }) response: HttpResponse,
|
||||||
|
) {
|
||||||
|
const result = await this.authService.changePassword(
|
||||||
|
workspace,
|
||||||
|
request.auth.userId,
|
||||||
|
request.auth.sessionId,
|
||||||
|
parse(changePasswordRequestSchema, body),
|
||||||
|
metadata(request),
|
||||||
|
);
|
||||||
|
setRefreshCookie(response, result.refreshToken);
|
||||||
|
return {
|
||||||
|
data: {
|
||||||
|
access_token: result.payload.access_token,
|
||||||
|
access_token_expires_in: result.payload.access_token_expires_in,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parse<
|
||||||
|
T extends {
|
||||||
|
safeParse: (value: unknown) => {
|
||||||
|
success: boolean;
|
||||||
|
data?: unknown;
|
||||||
|
error?: { issues: Array<{ path: PropertyKey[]; message: string }> };
|
||||||
|
};
|
||||||
|
},
|
||||||
|
>(schema: T, value: unknown) {
|
||||||
|
const result = schema.safeParse(value);
|
||||||
|
if (result.success)
|
||||||
|
return result.data as ReturnType<T['safeParse']> extends {
|
||||||
|
data: infer Data;
|
||||||
|
}
|
||||||
|
? Data
|
||||||
|
: never;
|
||||||
|
throw new ApiError(
|
||||||
|
422,
|
||||||
|
'VALIDATION_FAILED',
|
||||||
|
'请求参数不符合要求',
|
||||||
|
result.error?.issues.map((issue) => ({
|
||||||
|
field: issue.path.join('.'),
|
||||||
|
code: 'INVALID_FIELD',
|
||||||
|
message: issue.message,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
function metadata(request: HttpRequest): RequestMetadata {
|
||||||
|
return {
|
||||||
|
ip: request.ip ?? null,
|
||||||
|
ipHash: request.ip
|
||||||
|
? createHash('sha256').update(request.ip).digest('base64url')
|
||||||
|
: null,
|
||||||
|
userAgent: request.header('user-agent') ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
function cookieOptions() {
|
||||||
|
// cookie 路径限制在认证接口,避免无关请求携带长期会话凭证。
|
||||||
|
return {
|
||||||
|
httpOnly: true,
|
||||||
|
secure: process.env.NODE_ENV === 'production',
|
||||||
|
sameSite: 'lax' as const,
|
||||||
|
path: '/api/auth',
|
||||||
|
maxAge: 30 * 24 * 60 * 60 * 1000,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
function setRefreshCookie(response: HttpResponse, token: string) {
|
||||||
|
response.cookie(refreshCookie, token, cookieOptions());
|
||||||
|
}
|
||||||
|
function clearRefreshCookie(response: HttpResponse) {
|
||||||
|
response.clearCookie(refreshCookie, {
|
||||||
|
httpOnly: true,
|
||||||
|
secure: process.env.NODE_ENV === 'production',
|
||||||
|
sameSite: 'lax',
|
||||||
|
path: '/api/auth',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function readCookie(header: string | undefined, name: string) {
|
||||||
|
// 只解析本服务需要的单个 cookie,避免引入与认证无关的 cookie 状态。
|
||||||
|
return header
|
||||||
|
?.split(';')
|
||||||
|
.map((part) => part.trim())
|
||||||
|
.find((part) => part.startsWith(`${name}=`))
|
||||||
|
?.slice(name.length + 1);
|
||||||
|
}
|
||||||
|
function requireIdempotencyKey(value: string | undefined): string {
|
||||||
|
if (!value) {
|
||||||
|
throw new ApiError(
|
||||||
|
400,
|
||||||
|
'IDEMPOTENCY_KEY_REQUIRED',
|
||||||
|
'请求缺少 Idempotency-Key',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (z.uuid().safeParse(value).success) return value;
|
||||||
|
throw new ApiError(422, 'VALIDATION_FAILED', '请求参数不符合要求', [
|
||||||
|
{
|
||||||
|
field: 'Idempotency-Key',
|
||||||
|
code: 'INVALID_FIELD',
|
||||||
|
message: 'Idempotency-Key 必须是 UUID',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
function parseRevision(value: string | undefined): number {
|
||||||
|
const match = value?.match(/^"([1-9]\d*)"$/);
|
||||||
|
if (!match) {
|
||||||
|
throw new ApiError(
|
||||||
|
400,
|
||||||
|
'IF_MATCH_REQUIRED',
|
||||||
|
'请求必须携带形如 "1" 的 If-Match',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return Number(match[1]);
|
||||||
|
}
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
import {
|
||||||
|
createHash,
|
||||||
|
createHmac,
|
||||||
|
randomBytes,
|
||||||
|
scrypt as scryptCallback,
|
||||||
|
timingSafeEqual,
|
||||||
|
} from 'node:crypto';
|
||||||
|
import { promisify } from 'node:util';
|
||||||
|
|
||||||
|
const scrypt = promisify(scryptCallback) as (
|
||||||
|
password: string,
|
||||||
|
salt: string,
|
||||||
|
keyLength: number,
|
||||||
|
options: { N: number; r: number; p: number; maxmem: number },
|
||||||
|
) => Promise<Buffer>;
|
||||||
|
// 使用较高成本的 scrypt 参数,控制离线破解密码哈希时的时间与内存成本。
|
||||||
|
const scryptOptions = { N: 16_384, r: 8, p: 1, maxmem: 32 * 1024 * 1024 };
|
||||||
|
|
||||||
|
export async function hashPassword(password: string): Promise<string> {
|
||||||
|
// 每个密码使用独立随机盐,数据库泄露时不能直接复用预计算结果。
|
||||||
|
const salt = randomBytes(16).toString('base64url');
|
||||||
|
const derived = (await scrypt(password, salt, 64, scryptOptions)) as Buffer;
|
||||||
|
return `scrypt$${salt}$${derived.toString('base64url')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function verifyPassword(
|
||||||
|
password: string,
|
||||||
|
encoded: string,
|
||||||
|
): Promise<boolean> {
|
||||||
|
const [algorithm, salt, expected] = encoded.split('$');
|
||||||
|
if (algorithm !== 'scrypt' || !salt || !expected) return false;
|
||||||
|
const actual = (await scrypt(password, salt, 64, scryptOptions)) as Buffer;
|
||||||
|
const expectedBuffer = Buffer.from(expected, 'base64url');
|
||||||
|
return (
|
||||||
|
expectedBuffer.length === actual.length &&
|
||||||
|
timingSafeEqual(expectedBuffer, actual)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createOpaqueToken(): string {
|
||||||
|
return randomBytes(48).toString('base64url');
|
||||||
|
}
|
||||||
|
export function tokenHash(token: string): string {
|
||||||
|
// refresh token 只以摘要形式存库;服务端无需保存可直接用于登录的原文。
|
||||||
|
return createHash('sha256').update(token).digest('base64url');
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AccessTokenClaims = {
|
||||||
|
sub: string;
|
||||||
|
sid: string;
|
||||||
|
exp: number;
|
||||||
|
iat: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ExtensionAccessTokenClaims = AccessTokenClaims & { typ: 'extension'; wid: string };
|
||||||
|
export function signAccessToken(
|
||||||
|
claims: AccessTokenClaims,
|
||||||
|
secret: string,
|
||||||
|
): string {
|
||||||
|
// access token 是短期自包含凭证;真正可撤销的会话状态仍以 PostgreSQL 为准。
|
||||||
|
const header = Buffer.from(
|
||||||
|
JSON.stringify({ alg: 'HS256', typ: 'JWT' }),
|
||||||
|
).toString('base64url');
|
||||||
|
const payload = Buffer.from(JSON.stringify(claims)).toString('base64url');
|
||||||
|
const signature = createHmac('sha256', secret)
|
||||||
|
.update(`${header}.${payload}`)
|
||||||
|
.digest('base64url');
|
||||||
|
return `${header}.${payload}.${signature}`;
|
||||||
|
}
|
||||||
|
export function verifyAccessToken(
|
||||||
|
token: string,
|
||||||
|
secret: string,
|
||||||
|
): AccessTokenClaims | null {
|
||||||
|
const [header, payload, signature] = token.split('.');
|
||||||
|
if (!header || !payload || !signature) return null;
|
||||||
|
const expected = createHmac('sha256', secret)
|
||||||
|
.update(`${header}.${payload}`)
|
||||||
|
.digest();
|
||||||
|
const actual = Buffer.from(signature, 'base64url');
|
||||||
|
// 恒定时间比较可避免根据签名比对耗时推测正确前缀。
|
||||||
|
if (actual.length !== expected.length || !timingSafeEqual(actual, expected))
|
||||||
|
return null;
|
||||||
|
try {
|
||||||
|
const claims = JSON.parse(
|
||||||
|
Buffer.from(payload, 'base64url').toString('utf8'),
|
||||||
|
) as AccessTokenClaims;
|
||||||
|
return typeof claims.sub === 'string' &&
|
||||||
|
typeof claims.sid === 'string' &&
|
||||||
|
typeof claims.exp === 'number' &&
|
||||||
|
claims.exp > Math.floor(Date.now() / 1000)
|
||||||
|
? claims
|
||||||
|
: null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Extension tokens have an explicit audience so they can never be accepted by
|
||||||
|
* the normal Web/API guard, even though both are signed by the same key. */
|
||||||
|
export function signExtensionAccessToken(
|
||||||
|
claims: ExtensionAccessTokenClaims,
|
||||||
|
secret: string,
|
||||||
|
): string {
|
||||||
|
const header = Buffer.from(JSON.stringify({ alg: 'HS256', typ: 'JWT' })).toString('base64url');
|
||||||
|
const payload = Buffer.from(JSON.stringify(claims)).toString('base64url');
|
||||||
|
const signature = createHmac('sha256', secret).update(`${header}.${payload}`).digest('base64url');
|
||||||
|
return `${header}.${payload}.${signature}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function verifyExtensionAccessToken(token: string, secret: string): ExtensionAccessTokenClaims | null {
|
||||||
|
const claims = verifyAccessToken(token, secret) as Partial<ExtensionAccessTokenClaims> | null;
|
||||||
|
return claims?.typ === 'extension' ? claims as ExtensionAccessTokenClaims : null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import {
|
||||||
|
CanActivate,
|
||||||
|
ExecutionContext,
|
||||||
|
Inject,
|
||||||
|
Injectable,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { Reflector } from '@nestjs/core';
|
||||||
|
import type { Workspace } from '@atlas/contracts';
|
||||||
|
import { IS_PUBLIC_ENDPOINT } from '../common/public.decorator.js';
|
||||||
|
import { AuthService } from './auth.service.js';
|
||||||
|
|
||||||
|
export type AuthenticatedRequest = {
|
||||||
|
header: (name: string) => string | undefined;
|
||||||
|
auth: {
|
||||||
|
userId: string;
|
||||||
|
sessionId: string;
|
||||||
|
workspace?: Workspace;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class AccessTokenGuard implements CanActivate {
|
||||||
|
public constructor(
|
||||||
|
@Inject(Reflector) private readonly reflector: Reflector,
|
||||||
|
@Inject(AuthService) private readonly authService: AuthService,
|
||||||
|
) {}
|
||||||
|
public async canActivate(context: ExecutionContext) {
|
||||||
|
// 公开端点必须显式声明,避免新路由因忘记添加守卫而意外暴露。
|
||||||
|
if (
|
||||||
|
this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_ENDPOINT, [
|
||||||
|
context.getHandler(),
|
||||||
|
context.getClass(),
|
||||||
|
])
|
||||||
|
) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const request = context
|
||||||
|
.switchToHttp()
|
||||||
|
.getRequest<AuthenticatedRequest>();
|
||||||
|
// 只在此处解析 Authorization,后续守卫和控制器共享已验证的会话身份。
|
||||||
|
request.auth = await this.authService.authenticate(
|
||||||
|
request.header('authorization'),
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { APP_GUARD } from '@nestjs/core';
|
||||||
|
import { AuthController } from './auth.controller.js';
|
||||||
|
import { AccessTokenGuard } from './auth.guard.js';
|
||||||
|
import { AuthService } from './auth.service.js';
|
||||||
|
import { WorkspaceContextGuard } from '../workspace/workspace-context.guard.js';
|
||||||
|
import { IdempotencyService } from '../common/idempotency.service.js';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [AuthController],
|
||||||
|
providers: [
|
||||||
|
AuthService,
|
||||||
|
IdempotencyService,
|
||||||
|
AccessTokenGuard,
|
||||||
|
WorkspaceContextGuard,
|
||||||
|
{ provide: APP_GUARD, useExisting: AccessTokenGuard },
|
||||||
|
{ provide: APP_GUARD, useExisting: WorkspaceContextGuard },
|
||||||
|
],
|
||||||
|
exports: [AuthService, IdempotencyService],
|
||||||
|
})
|
||||||
|
export class AuthModule {}
|
||||||
@@ -0,0 +1,532 @@
|
|||||||
|
import { Inject, Injectable, UnauthorizedException } from '@nestjs/common';
|
||||||
|
import type {
|
||||||
|
AuthSessionResponse,
|
||||||
|
AuthUser,
|
||||||
|
ChangePasswordRequest,
|
||||||
|
LoginRequest,
|
||||||
|
RegisterRequest,
|
||||||
|
UpdateCurrentUserRequest,
|
||||||
|
Workspace,
|
||||||
|
} from '@atlas/contracts';
|
||||||
|
import type { Database, DatabaseTransaction } from '@atlas/database';
|
||||||
|
import { ApiError } from '../common/api-error.js';
|
||||||
|
import { IdempotencyService } from '../common/idempotency.service.js';
|
||||||
|
import { APP_CONFIG, type AppConfig } from '../config/app-config.module.js';
|
||||||
|
import { DATABASE } from '../database/database.module.js';
|
||||||
|
import { AuthRateLimiter } from '../security/auth-rate-limiter.service.js';
|
||||||
|
import {
|
||||||
|
createOpaqueToken,
|
||||||
|
hashPassword,
|
||||||
|
signAccessToken,
|
||||||
|
tokenHash,
|
||||||
|
verifyAccessToken,
|
||||||
|
verifyPassword,
|
||||||
|
} from './auth.crypto.js';
|
||||||
|
|
||||||
|
type UserRow = {
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
display_name: string | null;
|
||||||
|
avatar_asset_id: string | null;
|
||||||
|
email_verified_at: Date | null;
|
||||||
|
created_at: Date;
|
||||||
|
revision: number;
|
||||||
|
status: 'active' | 'disabled' | 'pending_review';
|
||||||
|
password_hash: string;
|
||||||
|
};
|
||||||
|
type WorkspaceRow = { id: string; name: string; role: Workspace['role'] };
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class AuthService {
|
||||||
|
// access token 短期存在于浏览器内存;refresh session 的有效期更长且可服务端撤销。
|
||||||
|
private readonly accessTtlSeconds = 900;
|
||||||
|
private readonly refreshTtlDays = 30;
|
||||||
|
|
||||||
|
public constructor(
|
||||||
|
@Inject(DATABASE) private readonly database: Database,
|
||||||
|
@Inject(APP_CONFIG) private readonly config: AppConfig,
|
||||||
|
@Inject(AuthRateLimiter)
|
||||||
|
private readonly rateLimiter: AuthRateLimiter,
|
||||||
|
@Inject(IdempotencyService)
|
||||||
|
private readonly idempotency: IdempotencyService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public async register(input: RegisterRequest, metadata: RequestMetadata) {
|
||||||
|
if (input.password !== input.password_confirmation)
|
||||||
|
throw validationError(
|
||||||
|
'password_confirmation',
|
||||||
|
'PASSWORD_CONFIRMATION_MISMATCH',
|
||||||
|
'两次输入的密码不一致',
|
||||||
|
);
|
||||||
|
if (input.password.length < this.config.AUTH_PASSWORD_MIN_LENGTH)
|
||||||
|
throw validationError(
|
||||||
|
'password',
|
||||||
|
'PASSWORD_TOO_SHORT',
|
||||||
|
`密码至少需要 ${this.config.AUTH_PASSWORD_MIN_LENGTH} 个字符`,
|
||||||
|
);
|
||||||
|
const email = input.email.trim().toLowerCase();
|
||||||
|
// 限流必须早于密码哈希和用户查询,避免攻击者消耗 CPU 或探测账户状态。
|
||||||
|
await this.rateLimiter.consume('register', { ip: metadata.ip, email });
|
||||||
|
const passwordHash = await hashPassword(input.password);
|
||||||
|
try {
|
||||||
|
return await this.database.transaction(async (transaction) => {
|
||||||
|
// 用户、个人工作空间和 owner membership 必须原子创建,不能留下半成品租户。
|
||||||
|
const user = await insertUser(
|
||||||
|
transaction,
|
||||||
|
email,
|
||||||
|
passwordHash,
|
||||||
|
input.display_name?.trim() || null,
|
||||||
|
);
|
||||||
|
const workspace = await createPersonalWorkspace(
|
||||||
|
transaction,
|
||||||
|
user.id,
|
||||||
|
user.display_name ?? email,
|
||||||
|
);
|
||||||
|
return this.createSessionResponse(
|
||||||
|
transaction,
|
||||||
|
user,
|
||||||
|
workspace,
|
||||||
|
metadata,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
} catch (error: unknown) {
|
||||||
|
if (isUniqueViolation(error))
|
||||||
|
throw new ApiError(
|
||||||
|
409,
|
||||||
|
'EMAIL_ALREADY_REGISTERED',
|
||||||
|
'该邮箱已注册',
|
||||||
|
[
|
||||||
|
{
|
||||||
|
field: 'email',
|
||||||
|
code: 'EMAIL_ALREADY_REGISTERED',
|
||||||
|
message: '该邮箱已注册',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async login(input: LoginRequest, metadata: RequestMetadata) {
|
||||||
|
const email = input.email.trim().toLowerCase();
|
||||||
|
// 限流与 citext 查询使用同一规范化邮箱,避免大小写绕过限流桶。
|
||||||
|
await this.rateLimiter.consume('login', { ip: metadata.ip, email });
|
||||||
|
const result = await this.database.query<UserRow>(
|
||||||
|
'SELECT id, email, display_name, avatar_asset_id, email_verified_at, created_at, revision, status, password_hash FROM atlas.users WHERE email = $1',
|
||||||
|
[email],
|
||||||
|
);
|
||||||
|
const user = result.rows[0];
|
||||||
|
if (
|
||||||
|
!user ||
|
||||||
|
!(await verifyPassword(input.password, user.password_hash))
|
||||||
|
)
|
||||||
|
throw new ApiError(401, 'INVALID_CREDENTIALS', '邮箱或密码错误');
|
||||||
|
if (user.status === 'disabled')
|
||||||
|
throw new ApiError(403, 'ACCOUNT_DISABLED', '账户已停用');
|
||||||
|
const workspace = await this.findWorkspace(user.id);
|
||||||
|
return this.database.transaction(async (transaction) => {
|
||||||
|
await transaction.query(
|
||||||
|
'UPDATE atlas.users SET last_login_at = now() WHERE id = $1',
|
||||||
|
[user.id],
|
||||||
|
);
|
||||||
|
return this.createSessionResponse(
|
||||||
|
transaction,
|
||||||
|
user,
|
||||||
|
workspace,
|
||||||
|
metadata,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public async refresh(
|
||||||
|
refreshToken: string | undefined,
|
||||||
|
metadata: RequestMetadata,
|
||||||
|
) {
|
||||||
|
// refresh token 验证前没有可信邮箱,只能按 IP 维度限流。
|
||||||
|
await this.rateLimiter.consume('refresh', { ip: metadata.ip });
|
||||||
|
if (!refreshToken)
|
||||||
|
throw new ApiError(401, 'SESSION_EXPIRED', '登录会话已过期');
|
||||||
|
return this.database.transaction(async (transaction) => {
|
||||||
|
const sessions = await transaction.query<
|
||||||
|
UserRow & { session_id: string }
|
||||||
|
>(
|
||||||
|
`SELECT u.id, u.email, u.display_name, u.avatar_asset_id, u.email_verified_at, u.created_at, u.revision, u.status, u.password_hash, s.id AS session_id
|
||||||
|
FROM atlas.user_sessions s JOIN atlas.users u ON u.id = s.user_id
|
||||||
|
WHERE s.refresh_token_hash = $1 AND s.revoked_at IS NULL AND s.expires_at > now() FOR UPDATE`,
|
||||||
|
[tokenHash(refreshToken)],
|
||||||
|
);
|
||||||
|
const user = sessions.rows[0];
|
||||||
|
if (!user || user.status !== 'active')
|
||||||
|
throw new ApiError(401, 'SESSION_EXPIRED', '登录会话已过期');
|
||||||
|
// 行锁保证并发 refresh 时旧 token 只能成功使用一次。
|
||||||
|
await transaction.query(
|
||||||
|
'UPDATE atlas.user_sessions SET revoked_at = now() WHERE id = $1',
|
||||||
|
[user.session_id],
|
||||||
|
);
|
||||||
|
const workspace = await findWorkspaceInTransaction(
|
||||||
|
transaction,
|
||||||
|
user.id,
|
||||||
|
);
|
||||||
|
return this.createSessionResponse(
|
||||||
|
transaction,
|
||||||
|
user,
|
||||||
|
workspace,
|
||||||
|
metadata,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Resolves an existing Web refresh cookie without rotating it. This is used
|
||||||
|
* only to complete an interactive extension authorization request in the
|
||||||
|
* user's browser; the cookie itself is never returned to the extension. */
|
||||||
|
public async resolveWebRefreshSession(refreshToken: string | undefined) {
|
||||||
|
if (!refreshToken) return null;
|
||||||
|
return this.database.transaction(async (transaction) => {
|
||||||
|
const sessions = await transaction.query<UserRow & { session_id: string }>(
|
||||||
|
`SELECT u.id, u.email, u.display_name, u.avatar_asset_id, u.email_verified_at, u.created_at,
|
||||||
|
u.revision, u.status, u.password_hash, s.id AS session_id
|
||||||
|
FROM atlas.user_sessions s JOIN atlas.users u ON u.id = s.user_id
|
||||||
|
WHERE s.refresh_token_hash = $1 AND s.revoked_at IS NULL AND s.expires_at > now()`,
|
||||||
|
[tokenHash(refreshToken)],
|
||||||
|
);
|
||||||
|
const user = sessions.rows[0];
|
||||||
|
if (!user || user.status !== 'active') return null;
|
||||||
|
const workspace = await findWorkspaceInTransaction(transaction, user.id);
|
||||||
|
return { userId: user.id, workspace };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public async logout(sessionId: string) {
|
||||||
|
await this.database.query(
|
||||||
|
'UPDATE atlas.user_sessions SET revoked_at = now() WHERE id = $1 AND revoked_at IS NULL',
|
||||||
|
[sessionId],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async authenticate(accessToken: string | undefined) {
|
||||||
|
const token = accessToken?.replace(/^Bearer\s+/i, '');
|
||||||
|
const claims = token
|
||||||
|
? verifyAccessToken(token, this.config.AUTH_JWT_SECRET)
|
||||||
|
: null;
|
||||||
|
if (!claims) throw new UnauthorizedException();
|
||||||
|
// JWT 签名有效仍不足够;必须确认对应 session 未撤销且未过期。
|
||||||
|
const active = await this.database.query(
|
||||||
|
'SELECT 1 FROM atlas.user_sessions WHERE id = $1 AND user_id = $2 AND revoked_at IS NULL AND expires_at > now()',
|
||||||
|
[claims.sid, claims.sub],
|
||||||
|
);
|
||||||
|
if (!active.rowCount) throw new UnauthorizedException();
|
||||||
|
return { userId: claims.sub, sessionId: claims.sid };
|
||||||
|
}
|
||||||
|
|
||||||
|
public async me(userId: string, workspace: Workspace) {
|
||||||
|
// workspace 由全局守卫解析,避免控制器或服务各自选择租户造成边界不一致。
|
||||||
|
const users = await this.database.query<UserRow>(
|
||||||
|
'SELECT id, email, display_name, avatar_asset_id, email_verified_at, created_at, revision, status, password_hash FROM atlas.users WHERE id = $1',
|
||||||
|
[userId],
|
||||||
|
);
|
||||||
|
const user = users.rows[0];
|
||||||
|
if (!user) throw new UnauthorizedException();
|
||||||
|
const platforms = await this.database.workspaceTransaction(
|
||||||
|
workspace.id,
|
||||||
|
async (transaction) => {
|
||||||
|
const rows = await transaction.query<{
|
||||||
|
id: string;
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
}>(
|
||||||
|
"SELECT id, code, name FROM atlas.platforms WHERE status = 'active' ORDER BY CASE WHEN code = 'OZON' THEN 0 ELSE 1 END, name",
|
||||||
|
);
|
||||||
|
return rows.rows;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
user: publicUser(user),
|
||||||
|
workspace,
|
||||||
|
platforms,
|
||||||
|
suggested_context: {
|
||||||
|
platform_id: platforms[0]?.id ?? null,
|
||||||
|
default_store: null,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public async updateProfile(
|
||||||
|
workspace: Workspace,
|
||||||
|
userId: string,
|
||||||
|
idempotencyKey: string,
|
||||||
|
revision: number,
|
||||||
|
input: UpdateCurrentUserRequest,
|
||||||
|
) {
|
||||||
|
return this.idempotency.run(
|
||||||
|
{
|
||||||
|
workspaceId: workspace.id,
|
||||||
|
userId,
|
||||||
|
method: 'PATCH',
|
||||||
|
path: '/api/auth/me',
|
||||||
|
key: idempotencyKey,
|
||||||
|
request: { if_match: revision, ...input },
|
||||||
|
},
|
||||||
|
async (transaction) => {
|
||||||
|
const user = await findUserInTransaction(
|
||||||
|
transaction,
|
||||||
|
userId,
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
if (user.revision !== revision) {
|
||||||
|
throw versionConflict(user.revision);
|
||||||
|
}
|
||||||
|
if (input.avatar_asset_id) {
|
||||||
|
// 头像只能引用当前工作空间内已完成入库的素材,不能接受任意对象路径或 URL。
|
||||||
|
const asset = await transaction.query<{ id: string }>(
|
||||||
|
`SELECT id FROM atlas.media_assets WHERE id = $1`,
|
||||||
|
[input.avatar_asset_id],
|
||||||
|
);
|
||||||
|
if (!asset.rows[0]) {
|
||||||
|
throw new ApiError(
|
||||||
|
422,
|
||||||
|
'AVATAR_ASSET_UNAVAILABLE',
|
||||||
|
'头像素材不存在或不属于当前工作空间',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const updated = await transaction.query<UserRow>(
|
||||||
|
`UPDATE atlas.users
|
||||||
|
SET display_name = CASE WHEN $1 THEN $2 ELSE display_name END,
|
||||||
|
avatar_asset_id = CASE WHEN $3 THEN $4::uuid ELSE avatar_asset_id END,
|
||||||
|
revision = revision + 1, updated_at = now()
|
||||||
|
WHERE id = $5
|
||||||
|
RETURNING id, email, display_name, avatar_asset_id, email_verified_at,
|
||||||
|
created_at, revision, status, password_hash`,
|
||||||
|
[
|
||||||
|
Object.hasOwn(input, 'display_name'),
|
||||||
|
input.display_name ?? null,
|
||||||
|
Object.hasOwn(input, 'avatar_asset_id'),
|
||||||
|
input.avatar_asset_id ?? null,
|
||||||
|
userId,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
status: 200,
|
||||||
|
body: { data: publicUser(updated.rows[0]) },
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async changePassword(
|
||||||
|
workspace: Workspace,
|
||||||
|
userId: string,
|
||||||
|
sessionId: string,
|
||||||
|
input: ChangePasswordRequest,
|
||||||
|
metadata: RequestMetadata,
|
||||||
|
) {
|
||||||
|
if (input.password !== input.password_confirmation) {
|
||||||
|
throw validationError(
|
||||||
|
'password_confirmation',
|
||||||
|
'PASSWORD_CONFIRMATION_MISMATCH',
|
||||||
|
'两次输入的密码不一致',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (input.password.length < this.config.AUTH_PASSWORD_MIN_LENGTH) {
|
||||||
|
throw validationError(
|
||||||
|
'password',
|
||||||
|
'PASSWORD_TOO_SHORT',
|
||||||
|
`密码至少需要 ${this.config.AUTH_PASSWORD_MIN_LENGTH} 个字符`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// 修改密码按已认证用户和来源 IP 双维度限流,避免已泄露会话被用于高频猜测旧密码。
|
||||||
|
await this.rateLimiter.consume('change_password', {
|
||||||
|
ip: metadata.ip,
|
||||||
|
userId,
|
||||||
|
});
|
||||||
|
return this.database.transaction(async (transaction) => {
|
||||||
|
const session = await transaction.query<{ id: string }>(
|
||||||
|
`SELECT id FROM atlas.user_sessions
|
||||||
|
WHERE id = $1 AND user_id = $2 AND revoked_at IS NULL AND expires_at > now()
|
||||||
|
FOR UPDATE`,
|
||||||
|
[sessionId, userId],
|
||||||
|
);
|
||||||
|
if (!session.rows[0]) throw new UnauthorizedException();
|
||||||
|
const user = await findUserInTransaction(transaction, userId, true);
|
||||||
|
if (user.status !== 'active') {
|
||||||
|
throw new ApiError(403, 'ACCOUNT_DISABLED', '账户已停用');
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
!(await verifyPassword(input.old_password, user.password_hash))
|
||||||
|
) {
|
||||||
|
throw validationError(
|
||||||
|
'old_password',
|
||||||
|
'CURRENT_PASSWORD_INVALID',
|
||||||
|
'当前密码错误',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const passwordHash = await hashPassword(input.password);
|
||||||
|
const updated = await transaction.query<UserRow>(
|
||||||
|
`UPDATE atlas.users SET password_hash = $1, updated_at = now()
|
||||||
|
WHERE id = $2
|
||||||
|
RETURNING id, email, display_name, avatar_asset_id, email_verified_at,
|
||||||
|
created_at, revision, status, password_hash`,
|
||||||
|
[passwordHash, userId],
|
||||||
|
);
|
||||||
|
// 先撤销所有旧 session,再创建新 session;旧 access token 会立即失效。
|
||||||
|
await transaction.query(
|
||||||
|
`UPDATE atlas.user_sessions SET revoked_at = now()
|
||||||
|
WHERE user_id = $1 AND revoked_at IS NULL`,
|
||||||
|
[userId],
|
||||||
|
);
|
||||||
|
return this.createSessionResponse(
|
||||||
|
transaction,
|
||||||
|
updated.rows[0],
|
||||||
|
workspace,
|
||||||
|
metadata,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async findWorkspace(userId: string) {
|
||||||
|
const result = await this.database.query<WorkspaceRow>(
|
||||||
|
`SELECT w.id, w.name, m.role FROM atlas.workspace_memberships m JOIN atlas.workspaces w ON w.id = m.workspace_id WHERE m.user_id = $1 AND w.status = 'active' ORDER BY m.joined_at LIMIT 1`,
|
||||||
|
[userId],
|
||||||
|
);
|
||||||
|
return requireWorkspace(result.rows[0]);
|
||||||
|
}
|
||||||
|
private async createSessionResponse(
|
||||||
|
transaction: DatabaseTransaction,
|
||||||
|
user: UserRow,
|
||||||
|
workspace: WorkspaceRow,
|
||||||
|
metadata: RequestMetadata,
|
||||||
|
) {
|
||||||
|
// refresh token 仅通过 HttpOnly cookie 交给浏览器,响应体只暴露短期 access token。
|
||||||
|
const refreshToken = createOpaqueToken();
|
||||||
|
const session = await transaction.query<{ id: string }>(
|
||||||
|
`INSERT INTO atlas.user_sessions (user_id, refresh_token_hash, expires_at, ip_hash, user_agent)
|
||||||
|
VALUES ($1, $2, now() + ($3 || ' days')::interval, $4, $5) RETURNING id`,
|
||||||
|
[
|
||||||
|
user.id,
|
||||||
|
tokenHash(refreshToken),
|
||||||
|
this.refreshTtlDays,
|
||||||
|
metadata.ipHash,
|
||||||
|
metadata.userAgent,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
const accessToken = signAccessToken(
|
||||||
|
{
|
||||||
|
sub: user.id,
|
||||||
|
sid: session.rows[0].id,
|
||||||
|
iat: now,
|
||||||
|
exp: now + this.accessTtlSeconds,
|
||||||
|
},
|
||||||
|
this.config.AUTH_JWT_SECRET,
|
||||||
|
);
|
||||||
|
const payload: AuthSessionResponse = {
|
||||||
|
access_token: accessToken,
|
||||||
|
access_token_expires_in: this.accessTtlSeconds,
|
||||||
|
user: publicUser(user),
|
||||||
|
workspace,
|
||||||
|
};
|
||||||
|
return { payload, refreshToken };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type RequestMetadata = {
|
||||||
|
ip: string | null;
|
||||||
|
ipHash: string | null;
|
||||||
|
userAgent: string | null;
|
||||||
|
};
|
||||||
|
function validationError(field: string, code: string, message: string) {
|
||||||
|
return new ApiError(422, 'VALIDATION_FAILED', '请求参数不符合要求', [
|
||||||
|
{ field, code, message },
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
function isUniqueViolation(error: unknown): error is { code: string } {
|
||||||
|
return (
|
||||||
|
typeof error === 'object' &&
|
||||||
|
error !== null &&
|
||||||
|
'code' in error &&
|
||||||
|
(error as { code: string }).code === '23505'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
async function findUserInTransaction(
|
||||||
|
transaction: DatabaseTransaction,
|
||||||
|
userId: string,
|
||||||
|
lock: boolean,
|
||||||
|
): Promise<UserRow> {
|
||||||
|
const result = await transaction.query<UserRow>(
|
||||||
|
`SELECT id, email, display_name, avatar_asset_id, email_verified_at, created_at,
|
||||||
|
revision, status, password_hash
|
||||||
|
FROM atlas.users WHERE id = $1${lock ? ' FOR UPDATE' : ''}`,
|
||||||
|
[userId],
|
||||||
|
);
|
||||||
|
if (!result.rows[0]) throw new UnauthorizedException();
|
||||||
|
return result.rows[0];
|
||||||
|
}
|
||||||
|
function versionConflict(currentRevision: number) {
|
||||||
|
return new ApiError(409, 'VERSION_CONFLICT', '账户资料已被其他操作更新', [
|
||||||
|
{
|
||||||
|
field: 'If-Match',
|
||||||
|
code: 'VERSION_CONFLICT',
|
||||||
|
message: `当前版本为 ${currentRevision}`,
|
||||||
|
current_revision: currentRevision,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
function publicUser(user: UserRow): AuthUser {
|
||||||
|
// 白名单映射防止 password_hash 等内部字段随查询结果泄露给客户端。
|
||||||
|
return {
|
||||||
|
id: user.id,
|
||||||
|
email: user.email,
|
||||||
|
display_name: user.display_name,
|
||||||
|
avatar_asset_id: user.avatar_asset_id,
|
||||||
|
email_verified_at: user.email_verified_at?.toISOString() ?? null,
|
||||||
|
created_at: user.created_at.toISOString(),
|
||||||
|
revision: user.revision,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
async function insertUser(
|
||||||
|
transaction: DatabaseTransaction,
|
||||||
|
email: string,
|
||||||
|
passwordHash: string,
|
||||||
|
displayName: string | null,
|
||||||
|
) {
|
||||||
|
// slug 使用用户 UUID,显示名称变化不会影响个人工作空间的稳定标识。
|
||||||
|
const result = await transaction.query<UserRow>(
|
||||||
|
'INSERT INTO atlas.users (email, password_hash, display_name) VALUES ($1, $2, $3) RETURNING id, email, display_name, avatar_asset_id, email_verified_at, created_at, revision, status, password_hash',
|
||||||
|
[email, passwordHash, displayName],
|
||||||
|
);
|
||||||
|
return result.rows[0];
|
||||||
|
}
|
||||||
|
async function createPersonalWorkspace(
|
||||||
|
transaction: DatabaseTransaction,
|
||||||
|
userId: string,
|
||||||
|
label: string,
|
||||||
|
) {
|
||||||
|
const workspace = await transaction.query<{ id: string; name: string }>(
|
||||||
|
'INSERT INTO atlas.workspaces (name, slug) VALUES ($1, $2) RETURNING id, name',
|
||||||
|
[`${label} 的工作空间`, `personal-${userId}`],
|
||||||
|
);
|
||||||
|
await transaction.query(
|
||||||
|
"INSERT INTO atlas.workspace_memberships (workspace_id, user_id, role) VALUES ($1, $2, 'owner')",
|
||||||
|
[workspace.rows[0].id, userId],
|
||||||
|
);
|
||||||
|
return { ...workspace.rows[0], role: 'owner' as const };
|
||||||
|
}
|
||||||
|
async function findWorkspaceInTransaction(
|
||||||
|
transaction: DatabaseTransaction,
|
||||||
|
userId: string,
|
||||||
|
): Promise<WorkspaceRow> {
|
||||||
|
const result = await transaction.query<WorkspaceRow>(
|
||||||
|
`SELECT w.id, w.name, m.role FROM atlas.workspace_memberships m JOIN atlas.workspaces w ON w.id = m.workspace_id WHERE m.user_id = $1 AND w.status = 'active' ORDER BY m.joined_at LIMIT 1`,
|
||||||
|
[userId],
|
||||||
|
);
|
||||||
|
return requireWorkspace(result.rows[0]);
|
||||||
|
}
|
||||||
|
function requireWorkspace(workspace: WorkspaceRow | undefined): WorkspaceRow {
|
||||||
|
if (!workspace)
|
||||||
|
throw new ApiError(
|
||||||
|
403,
|
||||||
|
'WORKSPACE_MEMBERSHIP_REQUIRED',
|
||||||
|
'当前账户没有可用工作空间',
|
||||||
|
);
|
||||||
|
return workspace;
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
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));
|
||||||
|
}
|
||||||
|
@Get(':id') async detail(@Param('id') id: string, @CurrentWorkspace() workspace: Workspace) {
|
||||||
|
return { data: await this.collection.detail(workspace.id, requireUuid('collection_item_id', id)) };
|
||||||
|
}
|
||||||
|
@Post() async create(@Body() body: unknown, @Headers('idempotency-key') key: string | undefined, @Req() request: AuthenticatedRequest, @CurrentWorkspace() workspace: Workspace, @Res({ passthrough: true }) response: Response) {
|
||||||
|
const result = await this.collection.create(workspace.id, request.auth.userId, requireKey(key), parse(createCollectionItemRequestSchema, body)); response.status(result.status); return result.body;
|
||||||
|
}
|
||||||
|
@Patch(':id') async update(@Param('id') id: string, @Body() body: unknown, @Headers('idempotency-key') key: string | undefined, @Headers('if-match') match: string | undefined, @Req() request: AuthenticatedRequest, @CurrentWorkspace() workspace: Workspace) {
|
||||||
|
return (await this.collection.update(workspace.id, request.auth.userId, requireUuid('collection_item_id', id), requireKey(key), revision(match), parse(updateCollectionItemRequestSchema, body))).body;
|
||||||
|
}
|
||||||
|
@Post('bulk-delete') async bulkDelete(@Body() body: unknown, @Headers('idempotency-key') key: string | undefined, @Req() request: AuthenticatedRequest, @CurrentWorkspace() workspace: Workspace, @Res({ passthrough: true }) response: Response) {
|
||||||
|
const result = await this.collection.bulkDelete(workspace.id, request.auth.userId, requireKey(key), parse(bulkDeleteCollectionItemsRequestSchema, body)); response.status(result.status); return result.body;
|
||||||
|
}
|
||||||
|
@Post('clear') async clear(@Body() body: unknown, @Headers('idempotency-key') key: string | undefined, @Req() request: AuthenticatedRequest, @CurrentWorkspace() workspace: Workspace, @Res({ passthrough: true }) response: Response) {
|
||||||
|
const result = await this.collection.clear(workspace.id, request.auth.userId, requireKey(key), parse(clearCollectionItemsRequestSchema, body)); response.status(result.status); return result.body;
|
||||||
|
}
|
||||||
|
@Post(':id/asset-retries') async retry(@Param('id') id: string, @Body() body: unknown, @Headers('idempotency-key') key: string | undefined, @Req() request: AuthenticatedRequest, @CurrentWorkspace() workspace: Workspace, @Res({ passthrough: true }) response: Response) {
|
||||||
|
const result = await this.collection.retryAssets(workspace.id, request.auth.userId, requireUuid('collection_item_id', id), requireKey(key), parse(retryCollectionAssetsRequestSchema, body)); response.status(result.status); return result.body;
|
||||||
|
}
|
||||||
|
@Post(':id/asset-materializations') async materialize(@Param('id') id: string, @Body() body: unknown, @Headers('idempotency-key') key: string | undefined, @Req() request: AuthenticatedRequest, @CurrentWorkspace() workspace: Workspace, @Res({ passthrough: true }) response: Response) {
|
||||||
|
const result = await this.collection.materializeAssets(workspace.id, request.auth.userId, requireUuid('collection_item_id', id), requireKey(key), parse(materializeCollectionAssetsRequestSchema, body)); response.status(result.status); return result.body;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function parse<T extends { safeParse: (input: unknown) => { success: boolean; data?: unknown; error?: { issues: Array<{ path: PropertyKey[]; message: string }> } } }>(schema: T, input: unknown) { const result = schema.safeParse(input); if (result.success) return result.data as never; throw new ApiError(422, 'VALIDATION_FAILED', '请求参数不符合要求', result.error?.issues.map((x) => ({ field: x.path.join('.'), code: 'INVALID_FIELD', message: x.message }))); }
|
||||||
|
function requireUuid(field: string, value: string) { if (z.uuid().safeParse(value).success) return value; throw new ApiError(422, 'VALIDATION_FAILED', '请求参数不符合要求', [{ field, code: 'INVALID_FIELD', message: `${field} 必须是 UUID` }]); }
|
||||||
|
function requireKey(value: string | undefined) { if (!value) throw new ApiError(400, 'IDEMPOTENCY_KEY_REQUIRED', '请求缺少 Idempotency-Key'); return requireUuid('Idempotency-Key', value); }
|
||||||
|
function revision(value: string | undefined) { const valueMatch = value?.match(/^"([1-9]\d*)"$/); if (!valueMatch) throw new ApiError(400, 'IF_MATCH_REQUIRED', '请求缺少或格式错误的 If-Match'); return Number(valueMatch[1]); }
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
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,67 @@
|
|||||||
|
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 } };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
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}` } } };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
async detail(workspaceId: string, id: string) {
|
||||||
|
return this.database.workspaceTransaction(workspaceId, async (tx) => {
|
||||||
|
const item = await this.requireItem(tx, id);
|
||||||
|
const source = await tx.query<{ normalized_payload: unknown; raw_evidence: unknown; parser_version: string | null; collected_at: Date | string | null; source_snapshot_revision: string | number }>('SELECT normalized_payload, raw_evidence, parser_version, collected_at, source_snapshot_revision FROM atlas.collection_items WHERE id = $1 AND deleted_at IS NULL', [id]);
|
||||||
|
const variants = await tx.query<{ id: string; source_variant_key: string | null; source_sku: string | null; display_name: string | null; sort_order: number }>('SELECT id, source_variant_key, source_sku, display_name, sort_order FROM atlas.product_variants WHERE product_id = $1 AND deleted_at IS NULL ORDER BY sort_order, id', [item.product_id]);
|
||||||
|
const transfers = await tx.query<{ id: string; source_variant_id: string | null; media_type: 'image' | 'video'; poster_url: string | null; role: string; source_url: string; status: string; asset_id: string | null; error_payload: unknown; created_at: Date | string; completed_at: Date | string | null }>(`SELECT id, source_variant_id, media_type::text AS media_type, poster_url, role::text AS role, source_url, status::text AS status, asset_id, error_payload, created_at, completed_at FROM atlas.collection_asset_transfers WHERE collection_item_id = $1 AND source_snapshot_revision = $2 ORDER BY coalesce((error_payload->>'sort_order')::integer, 0), created_at, id`, [id, source.rows[0]?.source_snapshot_revision ?? 1]);
|
||||||
|
return { ...item, normalized_payload: source.rows[0]?.normalized_payload ?? {}, raw_evidence: source.rows[0]?.raw_evidence ?? {}, parser_version: source.rows[0]?.parser_version ?? null, collected_at: source.rows[0]?.collected_at ? iso(source.rows[0].collected_at) : null, variants: variants.rows, asset_transfers: transfers.rows.map((transfer) => ({ ...transfer, created_at: iso(transfer.created_at), completed_at: transfer.completed_at ? iso(transfer.completed_at) : null })) };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
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.status IN ('failed', 'cancelled') RETURNING c.source_snapshot_revision`, [id, input.transfer_ids]); if (result.rowCount !== input.transfer_ids.length) throw new ApiError(422, 'ASSET_RETRY_INVALID', '只能重试当前采集快照中失败或已取消的素材任务'); const operation = await this.operation(tx, userId, 'collection_asset_retry', id); await this.event(tx, id, 'collection.assets.retry.requested', { operation_id: operation.id, collection_item_id: id, source_snapshot_revision: Number(result.rows[0].source_snapshot_revision) }); return { status: 202, body: { data: { operation } } }; }); }
|
||||||
|
async materializeAssets(workspaceId: string, userId: string, id: string, key: string, input: MaterializeCollectionAssetsRequest) { return this.idempotency.run({ workspaceId, userId, method: 'POST', path: `/api/collection-items/${id}/asset-materializations`, key, request: input }, async (tx) => { await this.requireItem(tx, id); const result = await tx.query<{ source_snapshot_revision: string | number }>(`UPDATE atlas.collection_asset_transfers t SET status = 'queued', error_payload = NULL, completed_at = NULL, updated_at = now() FROM atlas.collection_items c WHERE t.collection_item_id = c.id AND t.collection_item_id = $1 AND t.id = ANY($2::uuid[]) AND t.source_snapshot_revision = c.source_snapshot_revision AND t.status = 'pending_selection' RETURNING c.source_snapshot_revision`, [id, input.transfer_ids]); if (result.rowCount !== input.transfer_ids.length) throw new ApiError(422, 'ASSET_MATERIALIZATION_INVALID', '只能转存当前采集快照中尚未转存的来源媒体'); const operation = await this.operation(tx, userId, 'collection_asset_materialization', id); await this.event(tx, operation.id, 'collection.assets.materialize.requested', { operation_id: operation.id, collection_item_id: id, source_snapshot_revision: Number(result.rows[0].source_snapshot_revision) }); return { status: 202, body: { data: { operation } } }; }); }
|
||||||
|
private async removeMany(workspaceId: string, userId: string, key: string, path: string, input: unknown, clear: boolean) { return this.idempotency.run({ workspaceId, userId, method: 'POST', path, key, request: input }, async (tx) => { const requested = input as { ids?: string[]; expected_active_count?: number }; const count = await tx.query<{ count: string }>('SELECT count(*)::text AS count FROM atlas.collection_items WHERE deleted_at IS NULL'); if (clear && Number(count.rows[0].count) !== requested.expected_active_count) throw new ApiError(409, 'COLLECTION_COUNT_CHANGED', '采集箱数量已变化,请重新确认'); const deleted = await tx.query<{ id: string }>(clear ? `UPDATE atlas.collection_items SET status = 'deleted', deleted_at = now(), revision = revision + 1, updated_at = now() WHERE deleted_at IS NULL RETURNING id` : `UPDATE atlas.collection_items SET status = 'deleted', deleted_at = now(), revision = revision + 1, updated_at = now() WHERE id = ANY($1::uuid[]) AND deleted_at IS NULL RETURNING id`, clear ? [] : [requested.ids]); if (!clear && deleted.rows.length !== requested.ids?.length) throw new ApiError(404, 'RESOURCE_NOT_FOUND', '部分采集记录不存在'); const operation = await this.operation(tx, userId, 'collection_cleanup', deleted.rows[0]?.id ?? null); await this.event(tx, operation.id, 'collection.cleanup.requested', { operation_id: operation.id, collection_item_ids: deleted.rows.map((row) => row.id) }); return { status: 202, body: { data: { deleted_count: deleted.rows.length, operation } } }; }); }
|
||||||
|
private async operation(tx: DatabaseTransaction, userId: string, kind: string, resourceId: string | null) { const row = await tx.query<{ id: string; status: string; created_at: Date | string }>(`INSERT INTO atlas.operations (workspace_id, kind, resource_type, resource_id, created_by_user_id) VALUES (current_setting('app.workspace_id')::uuid, $1, 'collection_item', $2, $3) RETURNING id, status, created_at`, [kind, resourceId, userId]); return { id: row.rows[0].id, kind, status: row.rows[0].status, created_at: iso(row.rows[0].created_at), completed_at: null, error: null }; }
|
||||||
|
private async event(tx: DatabaseTransaction, aggregateId: string, type: string, payload: unknown) { await tx.query(`INSERT INTO atlas.outbox_events (workspace_id, aggregate_type, aggregate_id, event_type, event_payload, deduplication_key) VALUES (current_setting('app.workspace_id')::uuid, 'collection_item', $1, $2, $3, $4)`, [aggregateId, type, payload, `${type}:${aggregateId}`]); }
|
||||||
|
private async requireHeaderPlatform(tx: DatabaseTransaction, id: string) { const row = await tx.query(`SELECT id FROM atlas.platforms WHERE id = $1 AND status = 'active' AND coalesce((capabilities->>'can_be_header_context')::boolean, false)`, [id]); if (!row.rows[0]) throw new ApiError(422, 'PLATFORM_UNAVAILABLE', '当前平台不可用于 ERP 上下文'); }
|
||||||
|
private async requireItem(tx: DatabaseTransaction, id: string) { const row = await tx.query<Row>(`${selectRow} WHERE c.id = $1 AND c.deleted_at IS NULL ${collectionGroupBy}`, [id]); if (!row.rows[0]) throw new ApiError(404, 'RESOURCE_NOT_FOUND', '采集记录不存在'); return toItem(row.rows[0]); }
|
||||||
|
}
|
||||||
|
const selectRow = `SELECT c.id, product.id AS product_id, c.source_kind, p.code AS platform_code, c.source_url, c.source_title, c.source_min_price::text, c.source_max_price::text, c.source_currency_code, c.note, c.status, c.created_at, c.updated_at, c.revision, count(DISTINCT v.id)::text AS variant_count, min(t.status) AS transfer_status FROM atlas.collection_items c JOIN atlas.products product ON product.collection_item_id = c.id LEFT JOIN atlas.platforms p ON p.id = c.source_platform_id LEFT JOIN atlas.product_variants v ON v.product_id = product.id AND v.deleted_at IS NULL LEFT JOIN atlas.collection_asset_transfers t ON t.collection_item_id = c.id AND t.source_snapshot_revision = c.source_snapshot_revision`;
|
||||||
|
const collectionGroupBy = 'GROUP BY c.id, product.id, p.code';
|
||||||
|
function toItem(row: Row) { return { id: row.id, product_id: row.product_id, source: { kind: row.source_kind, platform_code: row.platform_code, url: row.source_url }, title: row.source_title, thumbnail: { asset_id: null, preview_url: null, status: row.transfer_status ?? 'none' }, source_price: { min: row.source_min_price, max: row.source_max_price, currency_code: row.source_currency_code }, variant_summary: { count: Number(row.variant_count), needs_review: row.status === 'needs_review' }, note: row.note, collection_status: row.status, listing_summary: { status: 'not_listed' as const, detail: '等待首次上架', last_error: null }, created_at: iso(row.created_at), updated_at: iso(row.updated_at), revision: row.revision }; }
|
||||||
|
function iso(value: Date | string) { return value instanceof Date ? value.toISOString() : value; }
|
||||||
|
function encodeCursor(at: string, id: string) { return Buffer.from(JSON.stringify({ at, id })).toString('base64url'); }
|
||||||
|
function decodeCursor(cursor: string): { at: string; id: string } { try { const value = JSON.parse(Buffer.from(cursor, 'base64url').toString('utf8')) as { at?: string; id?: string }; if (!value.at || !value.id || Number.isNaN(Date.parse(value.at))) throw new Error(); return { at: value.at, id: value.id }; } catch { throw new ApiError(422, 'VALIDATION_FAILED', '分页游标无效'); } }
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import {
|
||||||
|
ArgumentsHost,
|
||||||
|
Catch,
|
||||||
|
ExceptionFilter,
|
||||||
|
HttpException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
import { ApiError } from './api-error.js';
|
||||||
|
import { AppLogger } from '../logging/app-logger.js';
|
||||||
|
|
||||||
|
@Catch()
|
||||||
|
export class ApiErrorFilter implements ExceptionFilter {
|
||||||
|
public constructor(private readonly logger: AppLogger) {}
|
||||||
|
|
||||||
|
public catch(exception: unknown, host: ArgumentsHost) {
|
||||||
|
const response = host.switchToHttp().getResponse<{
|
||||||
|
status: (status: number) => {
|
||||||
|
json: (body: unknown) => unknown;
|
||||||
|
};
|
||||||
|
}>();
|
||||||
|
const request = host.switchToHttp().getRequest<{
|
||||||
|
requestId?: string;
|
||||||
|
method?: string;
|
||||||
|
originalUrl?: string;
|
||||||
|
}>();
|
||||||
|
const requestId = request.requestId ?? `req_${randomUUID()}`;
|
||||||
|
if (exception instanceof ApiError) {
|
||||||
|
// 已知业务错误保留其稳定 code 与 details,方便前端精确处理。
|
||||||
|
const body = exception.getResponse() as {
|
||||||
|
error: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
return response
|
||||||
|
.status(exception.getStatus())
|
||||||
|
.json({ error: { ...body.error, request_id: requestId } });
|
||||||
|
}
|
||||||
|
if (exception instanceof HttpException) {
|
||||||
|
// Nest 内部异常不泄露原始 message,避免暴露认证或解析实现细节。
|
||||||
|
return response.status(exception.getStatus()).json({
|
||||||
|
error: {
|
||||||
|
code: 'HTTP_ERROR',
|
||||||
|
message: '请求无法处理',
|
||||||
|
request_id: requestId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
this.logger.error(
|
||||||
|
'unhandled_exception',
|
||||||
|
exception instanceof Error ? exception.stack : undefined,
|
||||||
|
{
|
||||||
|
request_id: requestId,
|
||||||
|
method: request.method,
|
||||||
|
path: request.originalUrl,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
// 未预期异常只返回通用信息,request_id 用于从结构化日志定位根因。
|
||||||
|
return response.status(500).json({
|
||||||
|
error: {
|
||||||
|
code: 'INTERNAL_ERROR',
|
||||||
|
message: '服务器暂时无法处理请求',
|
||||||
|
request_id: requestId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { HttpException, HttpStatus } from '@nestjs/common';
|
||||||
|
|
||||||
|
export class ApiError extends HttpException {
|
||||||
|
// 业务代码只能通过此类型抛出可预期错误,最终由全局过滤器统一封装为 error 信封。
|
||||||
|
public constructor(
|
||||||
|
status: HttpStatus,
|
||||||
|
code: string,
|
||||||
|
message: string,
|
||||||
|
details?: unknown[],
|
||||||
|
) {
|
||||||
|
super({ error: { code, message, details } }, status);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
import { createHmac } from 'node:crypto';
|
||||||
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
|
import type { Database, DatabaseTransaction } from '@atlas/database';
|
||||||
|
import { APP_CONFIG, type AppConfig } from '../config/app-config.module.js';
|
||||||
|
import { DATABASE } from '../database/database.module.js';
|
||||||
|
import { ApiError } from './api-error.js';
|
||||||
|
|
||||||
|
type IdempotentResponse = { status: number; body: unknown };
|
||||||
|
type IdempotencyInput = {
|
||||||
|
workspaceId: string;
|
||||||
|
userId: string;
|
||||||
|
method: 'POST' | 'PUT' | 'PATCH' | 'DELETE';
|
||||||
|
path: string;
|
||||||
|
key: string;
|
||||||
|
request: unknown;
|
||||||
|
};
|
||||||
|
type IdempotencyRow = {
|
||||||
|
request_fingerprint: string;
|
||||||
|
status: 'in_progress' | 'completed';
|
||||||
|
response_status: number | null;
|
||||||
|
response_body: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class IdempotencyService {
|
||||||
|
public constructor(
|
||||||
|
@Inject(DATABASE) private readonly database: Database,
|
||||||
|
@Inject(APP_CONFIG) private readonly config: AppConfig,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 幂等记录与业务写入共用同一个工作空间事务。只有两者都提交,重试才会得到可靠的历史响应。
|
||||||
|
*/
|
||||||
|
public async run(
|
||||||
|
input: IdempotencyInput,
|
||||||
|
work: (transaction: DatabaseTransaction) => Promise<IdempotentResponse>,
|
||||||
|
): Promise<IdempotentResponse> {
|
||||||
|
const fingerprint = createHmac(
|
||||||
|
'sha256',
|
||||||
|
this.config.IDEMPOTENCY_KEY_SECRET,
|
||||||
|
)
|
||||||
|
.update(canonicalize(input.request))
|
||||||
|
.digest('base64url');
|
||||||
|
|
||||||
|
return this.database.workspaceTransaction(
|
||||||
|
input.workspaceId,
|
||||||
|
async (transaction) => {
|
||||||
|
const inserted = await transaction.query<IdempotencyRow>(
|
||||||
|
`INSERT INTO atlas.idempotency_requests
|
||||||
|
(workspace_id, user_id, http_method, request_path, idempotency_key, request_fingerprint)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6)
|
||||||
|
ON CONFLICT (workspace_id, user_id, http_method, request_path, idempotency_key) DO NOTHING
|
||||||
|
RETURNING request_fingerprint, status, response_status, response_body`,
|
||||||
|
[
|
||||||
|
input.workspaceId,
|
||||||
|
input.userId,
|
||||||
|
input.method,
|
||||||
|
input.path,
|
||||||
|
input.key,
|
||||||
|
fingerprint,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
if (!inserted.rows[0]) {
|
||||||
|
// 冲突插入会等待首个事务结束;随后行锁确保只能读取完整的持久化结果。
|
||||||
|
const existing = await transaction.query<IdempotencyRow>(
|
||||||
|
`SELECT request_fingerprint, status, response_status, response_body
|
||||||
|
FROM atlas.idempotency_requests
|
||||||
|
WHERE workspace_id = $1 AND user_id = $2 AND http_method = $3
|
||||||
|
AND request_path = $4 AND idempotency_key = $5
|
||||||
|
FOR UPDATE`,
|
||||||
|
[
|
||||||
|
input.workspaceId,
|
||||||
|
input.userId,
|
||||||
|
input.method,
|
||||||
|
input.path,
|
||||||
|
input.key,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
const row = existing.rows[0];
|
||||||
|
if (!row || row.request_fingerprint !== fingerprint) {
|
||||||
|
throw new ApiError(
|
||||||
|
409,
|
||||||
|
'IDEMPOTENCY_KEY_REUSED',
|
||||||
|
'幂等键已用于不同请求',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (row.status !== 'completed' || !row.response_status) {
|
||||||
|
throw new ApiError(
|
||||||
|
409,
|
||||||
|
'IDEMPOTENCY_REQUEST_IN_PROGRESS',
|
||||||
|
'相同请求仍在处理中',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
status: row.response_status,
|
||||||
|
body: row.response_body,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await work(transaction);
|
||||||
|
await transaction.query(
|
||||||
|
`UPDATE atlas.idempotency_requests
|
||||||
|
SET status = 'completed', response_status = $1, response_body = $2,
|
||||||
|
completed_at = now()
|
||||||
|
WHERE workspace_id = $3 AND user_id = $4 AND http_method = $5
|
||||||
|
AND request_path = $6 AND idempotency_key = $7`,
|
||||||
|
[
|
||||||
|
response.status,
|
||||||
|
response.body,
|
||||||
|
input.workspaceId,
|
||||||
|
input.userId,
|
||||||
|
input.method,
|
||||||
|
input.path,
|
||||||
|
input.key,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
return response;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 规范化排序后再签名,使 JSON 字段顺序不会改变同一请求的身份。 */
|
||||||
|
function canonicalize(value: unknown): string {
|
||||||
|
if (value === null || typeof value !== 'object')
|
||||||
|
return JSON.stringify(value);
|
||||||
|
if (Array.isArray(value)) return `[${value.map(canonicalize).join(',')}]`;
|
||||||
|
const record = value as Record<string, unknown>;
|
||||||
|
return `{${Object.keys(record)
|
||||||
|
.sort()
|
||||||
|
.map((key) => `${JSON.stringify(key)}:${canonicalize(record[key])}`)
|
||||||
|
.join(',')}}`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { SetMetadata } from '@nestjs/common';
|
||||||
|
|
||||||
|
export const IS_PUBLIC_ENDPOINT = 'atlas:is-public-endpoint';
|
||||||
|
|
||||||
|
// 全局认证守卫只跳过显式标记的端点,避免新接口因遗漏守卫而意外公开。
|
||||||
|
export const Public = () => SetMetadata(IS_PUBLIC_ENDPOINT, true);
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import type { DatabaseTransaction } from '@atlas/database';
|
||||||
|
import { ApiError } from './api-error.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared optimistic-lock conflict response. Versioned writes still keep their
|
||||||
|
* domain-specific SET clauses, but they must use a conditional `revision =
|
||||||
|
* expected` update and this helper when no row was affected.
|
||||||
|
*/
|
||||||
|
const resources = {
|
||||||
|
collection_item: {
|
||||||
|
table: 'atlas.collection_items',
|
||||||
|
label: '采集记录',
|
||||||
|
active: ' AND deleted_at IS NULL',
|
||||||
|
},
|
||||||
|
product: { table: 'atlas.products', label: '商品', active: '' },
|
||||||
|
product_platform_profile: {
|
||||||
|
table: 'atlas.product_platform_profiles',
|
||||||
|
label: '商品发布配置',
|
||||||
|
active: '',
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export async function throwRevisionConflict(
|
||||||
|
transaction: DatabaseTransaction,
|
||||||
|
resource: keyof typeof resources,
|
||||||
|
id: string,
|
||||||
|
expected: number,
|
||||||
|
): Promise<never> {
|
||||||
|
const target = resources[resource];
|
||||||
|
const current = await transaction.query<{ revision: number }>(
|
||||||
|
`SELECT revision FROM ${target.table} WHERE id = $1${target.active}`,
|
||||||
|
[id],
|
||||||
|
);
|
||||||
|
if (!current.rows[0])
|
||||||
|
throw new ApiError(404, 'RESOURCE_NOT_FOUND', `${target.label}不存在`);
|
||||||
|
if (current.rows[0].revision !== expected) {
|
||||||
|
throw new ApiError(409, 'VERSION_CONFLICT', `${target.label}已被更新`, [
|
||||||
|
{
|
||||||
|
field: 'If-Match',
|
||||||
|
code: 'VERSION_CONFLICT',
|
||||||
|
message: `当前版本为 ${current.rows[0].revision}`,
|
||||||
|
current_revision: current.rows[0].revision,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
// A conditional write with a matching version affecting no rows is a
|
||||||
|
// resource-state error (for example, a concurrent soft delete), not a
|
||||||
|
// successful retry.
|
||||||
|
throw new ApiError(409, 'VERSION_CONFLICT', `${target.label}状态已变化`);
|
||||||
|
}
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
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'),
|
||||||
|
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_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(),
|
||||||
|
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 type AppConfig = Omit<
|
||||||
|
Environment,
|
||||||
|
'TRUSTED_PROXY_CIDRS' | 'SWAGGER_ENABLED'
|
||||||
|
> & {
|
||||||
|
trustedProxyCidrs: string[];
|
||||||
|
swaggerEnabled: boolean;
|
||||||
|
objectStorageAccessKey: string;
|
||||||
|
objectStorageSecretKey: string;
|
||||||
|
objectStorageForcePathStyle: 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',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...environment,
|
||||||
|
// 只有显式给出代理网段时才信任转发头,避免客户端伪造来源 IP。
|
||||||
|
trustedProxyCidrs: environment.TRUSTED_PROXY_CIDRS.split(',')
|
||||||
|
.map((value) => value.trim())
|
||||||
|
.filter(Boolean),
|
||||||
|
swaggerEnabled: environment.SWAGGER_ENABLED === 'true',
|
||||||
|
objectStorageAccessKey,
|
||||||
|
objectStorageSecretKey,
|
||||||
|
objectStorageForcePathStyle:
|
||||||
|
environment.OBJECT_STORAGE_FORCE_PATH_STYLE === 'true',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@Global()
|
||||||
|
@Module({
|
||||||
|
imports: [
|
||||||
|
ConfigModule.forRoot({
|
||||||
|
isGlobal: true,
|
||||||
|
cache: true,
|
||||||
|
// Turbo 从 apps/api 运行脚本,但共享开发环境文件位于仓库根目录。
|
||||||
|
envFilePath: findEnvironmentFile(),
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
providers: [
|
||||||
|
{
|
||||||
|
provide: APP_CONFIG,
|
||||||
|
// 导出规范化配置,业务服务不得散落读取 process.env。
|
||||||
|
useFactory: (): AppConfig =>
|
||||||
|
createAppConfig(validateEnvironment(process.env)),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
exports: [APP_CONFIG],
|
||||||
|
})
|
||||||
|
export class AppConfigModule {}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { Global, Inject, Module, OnModuleDestroy } from '@nestjs/common';
|
||||||
|
import { createApplicationDatabase, type Database } from '@atlas/database';
|
||||||
|
import { APP_CONFIG, type AppConfig } from '../config/app-config.module.js';
|
||||||
|
|
||||||
|
export const DATABASE = Symbol('DATABASE');
|
||||||
|
|
||||||
|
@Global()
|
||||||
|
@Module({
|
||||||
|
providers: [
|
||||||
|
{
|
||||||
|
provide: DATABASE,
|
||||||
|
inject: [APP_CONFIG],
|
||||||
|
// 业务代码只能拿到受限应用连接,迁移连接串绝不注入 API 容器。
|
||||||
|
useFactory: (config: AppConfig): Database =>
|
||||||
|
createApplicationDatabase(config.DATABASE_URL),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
exports: [DATABASE],
|
||||||
|
})
|
||||||
|
export class DatabaseModule implements OnModuleDestroy {
|
||||||
|
public constructor(@Inject(DATABASE) private readonly database: Database) {}
|
||||||
|
public async onModuleDestroy() {
|
||||||
|
// Nest 关闭时主动释放连接池,避免测试和热重启遗留连接。
|
||||||
|
await this.database.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
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,22 @@
|
|||||||
|
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,7 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { AuthModule } from '../auth/auth.module.js';
|
||||||
|
import { ExtensionAccessGuard } from './extension-auth.guard.js';
|
||||||
|
import { ExtensionController } from './extension.controller.js';
|
||||||
|
import { ExtensionService } from './extension.service.js';
|
||||||
|
@Module({ imports: [AuthModule], controllers: [ExtensionController], providers: [ExtensionService, ExtensionAccessGuard] })
|
||||||
|
export class ExtensionModule {}
|
||||||
@@ -0,0 +1,488 @@
|
|||||||
|
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)
|
||||||
|
VALUES (current_setting('app.workspace_id')::uuid, $1, $2) RETURNING id`,
|
||||||
|
[item.rows[0].id, payload.common.title?.value ?? null],
|
||||||
|
);
|
||||||
|
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,
|
||||||
|
deleted_at = NULL, updated_at = now()
|
||||||
|
WHERE id = $4`,
|
||||||
|
[
|
||||||
|
variant.sourceSku ?? null,
|
||||||
|
variant.displayName,
|
||||||
|
index,
|
||||||
|
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)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6) RETURNING id`,
|
||||||
|
[
|
||||||
|
workspaceId,
|
||||||
|
productId,
|
||||||
|
variant.sourceVariantKey,
|
||||||
|
variant.sourceSku ?? null,
|
||||||
|
variant.displayName,
|
||||||
|
index,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
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;
|
||||||
|
}>(
|
||||||
|
'SELECT status, source_title FROM atlas.collection_items WHERE id = $1',
|
||||||
|
[collectionItemId],
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
collection_item: {
|
||||||
|
id: collectionItemId,
|
||||||
|
status: status.rows[0]?.status ?? 'received',
|
||||||
|
source_title: status.rows[0]?.source_title ?? null,
|
||||||
|
variant_count: 0,
|
||||||
|
},
|
||||||
|
product_id: productId,
|
||||||
|
reused,
|
||||||
|
refreshed,
|
||||||
|
editor_entry_path: `/collection-test/${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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
import 'reflect-metadata';
|
||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
import { resolve } from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
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 { 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';
|
||||||
|
|
||||||
|
type RequestWithId = Request & {
|
||||||
|
requestId?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
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 }));
|
||||||
|
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 };
|
||||||
|
}
|
||||||
|
|
||||||
|
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,132 @@
|
|||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Headers,
|
||||||
|
Inject,
|
||||||
|
Param,
|
||||||
|
Post,
|
||||||
|
Req,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import {
|
||||||
|
ApiBearerAuth,
|
||||||
|
ApiCreatedResponse,
|
||||||
|
ApiOkResponse,
|
||||||
|
ApiTags,
|
||||||
|
} from '@nestjs/swagger';
|
||||||
|
import {
|
||||||
|
completeMediaUploadIntentRequestSchema,
|
||||||
|
createMediaAccessUrlRequestSchema,
|
||||||
|
createMediaUploadIntentRequestSchema,
|
||||||
|
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';
|
||||||
|
|
||||||
|
@ApiTags('Media')
|
||||||
|
@ApiBearerAuth('access-token')
|
||||||
|
@Controller()
|
||||||
|
export class MediaController {
|
||||||
|
public constructor(
|
||||||
|
@Inject(MediaService) private readonly media: MediaService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
@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,
|
||||||
|
) {
|
||||||
|
parse(createMediaAccessUrlRequestSchema, body);
|
||||||
|
return {
|
||||||
|
data: await this.media.accessUrl(
|
||||||
|
workspace.id,
|
||||||
|
uuid('asset_id', assetId),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parse<
|
||||||
|
T extends {
|
||||||
|
safeParse: (input: unknown) => {
|
||||||
|
success: boolean;
|
||||||
|
data?: unknown;
|
||||||
|
error?: { issues: Array<{ path: PropertyKey[]; message: string }> };
|
||||||
|
};
|
||||||
|
},
|
||||||
|
>(schema: T, input: unknown) {
|
||||||
|
const result = schema.safeParse(input);
|
||||||
|
if (result.success)
|
||||||
|
return result.data as ReturnType<T['safeParse']> extends {
|
||||||
|
data: infer Data;
|
||||||
|
}
|
||||||
|
? Data
|
||||||
|
: never;
|
||||||
|
throw new ApiError(
|
||||||
|
422,
|
||||||
|
'VALIDATION_FAILED',
|
||||||
|
'请求参数不符合要求',
|
||||||
|
result.error?.issues.map((issue) => ({
|
||||||
|
field: issue.path.join('.'),
|
||||||
|
code: 'INVALID_FIELD',
|
||||||
|
message: issue.message,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
function uuid(field: string, value: string) {
|
||||||
|
if (z.uuid().safeParse(value).success) return value;
|
||||||
|
throw new ApiError(422, 'VALIDATION_FAILED', '请求参数不符合要求', [
|
||||||
|
{ field, code: 'INVALID_FIELD', message: `${field} 必须是 UUID` },
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
function idempotencyKey(value: string | undefined) {
|
||||||
|
if (!value)
|
||||||
|
throw new ApiError(
|
||||||
|
400,
|
||||||
|
'IDEMPOTENCY_KEY_REQUIRED',
|
||||||
|
'请求缺少 Idempotency-Key',
|
||||||
|
);
|
||||||
|
return uuid('Idempotency-Key', value);
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { IdempotencyService } from '../common/idempotency.service.js';
|
||||||
|
import { MediaController } from './media.controller.js';
|
||||||
|
import { MediaService } from './media.service.js';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [MediaController],
|
||||||
|
providers: [IdempotencyService, MediaService],
|
||||||
|
exports: [MediaService],
|
||||||
|
})
|
||||||
|
export class MediaModule {}
|
||||||
@@ -0,0 +1,537 @@
|
|||||||
|
import { createHash, 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,
|
||||||
|
CreateMediaUploadIntentRequest,
|
||||||
|
} 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';
|
||||||
|
|
||||||
|
type IntentRow = {
|
||||||
|
id: string;
|
||||||
|
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 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: 'us-east-1',
|
||||||
|
forcePathStyle: config.objectStorageForcePathStyle,
|
||||||
|
credentials: {
|
||||||
|
accessKeyId: config.objectStorageAccessKey,
|
||||||
|
secretAccessKey: config.objectStorageSecretKey,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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) => {
|
||||||
|
await this.ensureBucket();
|
||||||
|
const id = randomUUID();
|
||||||
|
const objectKey = `uploads/${workspaceId}/watermark-images/${id}`;
|
||||||
|
const expiresAt = new Date(Date.now() + 10 * 60_000);
|
||||||
|
await tx.query(
|
||||||
|
`INSERT INTO atlas.media_upload_intents
|
||||||
|
(id, workspace_id, purpose, storage_provider, bucket, object_key, expected_content_type,
|
||||||
|
expected_byte_size, expected_checksum_sha256, expires_at, created_by_user_id)
|
||||||
|
VALUES ($1, current_setting('app.workspace_id')::uuid, 'watermark_image', 'local_minio',
|
||||||
|
$2, $3, $4, $5, $6, $7, $8)`,
|
||||||
|
[
|
||||||
|
id,
|
||||||
|
this.config.OBJECT_STORAGE_BUCKET,
|
||||||
|
objectKey,
|
||||||
|
input.content_type,
|
||||||
|
input.byte_size,
|
||||||
|
input.checksum_sha256,
|
||||||
|
expiresAt,
|
||||||
|
userId,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
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, 'local_minio', $2, $3, $4, $5, $6, $7, $8)`,
|
||||||
|
[
|
||||||
|
assetId,
|
||||||
|
intent.bucket,
|
||||||
|
intent.object_key,
|
||||||
|
inspected.contentType,
|
||||||
|
inspected.byteSize,
|
||||||
|
inspected.checksum,
|
||||||
|
inspected.width,
|
||||||
|
inspected.height,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
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) {
|
||||||
|
return this.database.workspaceTransaction(workspaceId, async (tx) => {
|
||||||
|
const asset = await tx.query<{
|
||||||
|
bucket: string;
|
||||||
|
object_key: string;
|
||||||
|
}>(
|
||||||
|
`SELECT bucket, object_key FROM atlas.media_assets WHERE id = $1`,
|
||||||
|
[assetId],
|
||||||
|
);
|
||||||
|
if (!asset.rows[0])
|
||||||
|
throw new ApiError(404, 'RESOURCE_NOT_FOUND', '素材不存在');
|
||||||
|
const expiresAt = new Date(Date.now() + 10 * 60_000);
|
||||||
|
const url = await getSignedUrl(
|
||||||
|
this.s3,
|
||||||
|
new GetObjectCommand({
|
||||||
|
Bucket: asset.rows[0].bucket,
|
||||||
|
Key: asset.rows[0].object_key,
|
||||||
|
}),
|
||||||
|
{ expiresIn: 600 },
|
||||||
|
);
|
||||||
|
return { url, expires_at: expiresAt.toISOString() };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async lockIntent(
|
||||||
|
tx: DatabaseTransaction,
|
||||||
|
intentId: string,
|
||||||
|
): Promise<IntentRow> {
|
||||||
|
const rows = await tx.query<IntentRow>(
|
||||||
|
`SELECT id, storage_provider, bucket, object_key, expected_content_type, expected_byte_size,
|
||||||
|
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`,
|
||||||
|
[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',
|
||||||
|
'尚未找到已上传文件',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!head.ContentLength || head.ContentLength > 5 * 1024 * 1024) {
|
||||||
|
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 > 5 * 1024 * 1024) {
|
||||||
|
throw new ApiError(
|
||||||
|
422,
|
||||||
|
'UPLOAD_VALIDATION_FAILED',
|
||||||
|
'上传文件大小不符合要求',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
chunks.push(Buffer.from(chunk));
|
||||||
|
}
|
||||||
|
const bytes = Buffer.concat(chunks, byteSize);
|
||||||
|
const detected = detectImage(bytes);
|
||||||
|
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'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
function detectImage(bytes: Buffer): {
|
||||||
|
contentType: 'image/png' | 'image/jpeg' | 'image/webp';
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
} | null {
|
||||||
|
if (
|
||||||
|
bytes.length >= 24 &&
|
||||||
|
bytes
|
||||||
|
.subarray(0, 8)
|
||||||
|
.equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]))
|
||||||
|
) {
|
||||||
|
return dimensions(
|
||||||
|
'image/png',
|
||||||
|
bytes.readUInt32BE(16),
|
||||||
|
bytes.readUInt32BE(20),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
bytes.length >= 12 &&
|
||||||
|
bytes.subarray(0, 4).toString() === 'RIFF' &&
|
||||||
|
bytes.subarray(8, 12).toString() === 'WEBP'
|
||||||
|
) {
|
||||||
|
const type = bytes.subarray(12, 16).toString();
|
||||||
|
if (type === 'VP8X' && bytes.length >= 30) {
|
||||||
|
return dimensions(
|
||||||
|
'image/webp',
|
||||||
|
1 + bytes.readUIntLE(24, 3),
|
||||||
|
1 + bytes.readUIntLE(27, 3),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
type === 'VP8 ' &&
|
||||||
|
bytes.length >= 30 &&
|
||||||
|
bytes[23] === 0x9d &&
|
||||||
|
bytes[24] === 0x01 &&
|
||||||
|
bytes[25] === 0x2a
|
||||||
|
) {
|
||||||
|
return dimensions(
|
||||||
|
'image/webp',
|
||||||
|
bytes.readUInt16LE(26) & 0x3fff,
|
||||||
|
bytes.readUInt16LE(28) & 0x3fff,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (bytes.length > 4 && bytes[0] === 0xff && bytes[1] === 0xd8) {
|
||||||
|
for (let offset = 2; offset + 9 < bytes.length;) {
|
||||||
|
if (bytes[offset] !== 0xff) return null;
|
||||||
|
const marker = bytes[offset + 1];
|
||||||
|
const length = bytes.readUInt16BE(offset + 2);
|
||||||
|
if (length < 2 || offset + 2 + length > bytes.length) return null;
|
||||||
|
if (
|
||||||
|
[
|
||||||
|
0xc0, 0xc1, 0xc2, 0xc3, 0xc5, 0xc6, 0xc7, 0xc9, 0xca, 0xcb,
|
||||||
|
0xcd, 0xce, 0xcf,
|
||||||
|
].includes(marker)
|
||||||
|
) {
|
||||||
|
return dimensions(
|
||||||
|
'image/jpeg',
|
||||||
|
bytes.readUInt16BE(offset + 7),
|
||||||
|
bytes.readUInt16BE(offset + 5),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
offset += 2 + length;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
function dimensions(
|
||||||
|
contentType: 'image/png' | 'image/jpeg' | 'image/webp',
|
||||||
|
width: number,
|
||||||
|
height: number,
|
||||||
|
) {
|
||||||
|
return width >= 1 && height >= 1 && width <= 8192 && height <= 8192
|
||||||
|
? { contentType, width, height }
|
||||||
|
: null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,243 @@
|
|||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
Headers,
|
||||||
|
Inject,
|
||||||
|
Param,
|
||||||
|
Post,
|
||||||
|
Put,
|
||||||
|
Query,
|
||||||
|
Req,
|
||||||
|
Res,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import {
|
||||||
|
saveProductEditorDraftRequestSchema,
|
||||||
|
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';
|
||||||
|
|
||||||
|
type Response = { status: (value: number) => Response };
|
||||||
|
|
||||||
|
@Controller()
|
||||||
|
export class ProductEditorController {
|
||||||
|
public constructor(
|
||||||
|
@Inject(ProductEditorService)
|
||||||
|
private readonly editor: ProductEditorService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
@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),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('product-platform-profiles/:profileId/category-options')
|
||||||
|
async categoryOptions(
|
||||||
|
@Param('profileId') profileId: string,
|
||||||
|
@Query('q') query: string | undefined,
|
||||||
|
@Query('store_id') storeId: string | undefined,
|
||||||
|
@Headers('x-atlas-platform-id') platformId: string | undefined,
|
||||||
|
@CurrentWorkspace() workspace: Workspace,
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
data: await this.editor.categoryOptions(
|
||||||
|
workspace.id,
|
||||||
|
requiredUuid('profile_id', profileId),
|
||||||
|
requiredUuid('X-Atlas-Platform-Id', platformId),
|
||||||
|
query,
|
||||||
|
optionalUuid('store_id', storeId),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('product-platform-profiles/:profileId/attribute-schema')
|
||||||
|
async attributeSchema(
|
||||||
|
@Param('profileId') profileId: string,
|
||||||
|
@Query('category_id') categoryId: string | undefined,
|
||||||
|
@Query('store_id') storeId: string | undefined,
|
||||||
|
@Headers('x-atlas-platform-id') platformId: string | undefined,
|
||||||
|
@CurrentWorkspace() workspace: Workspace,
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
data: await this.editor.attributeSchema(
|
||||||
|
workspace.id,
|
||||||
|
requiredUuid('profile_id', profileId),
|
||||||
|
requiredUuid('X-Atlas-Platform-Id', platformId),
|
||||||
|
optionalUuid('category_id', categoryId),
|
||||||
|
optionalUuid('store_id', storeId),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('product-platform-profiles/:profileId/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/:dictionaryId',
|
||||||
|
)
|
||||||
|
async attributeDictionary(
|
||||||
|
@Param('profileId') profileId: string,
|
||||||
|
@Param('dictionaryId') dictionaryId: string,
|
||||||
|
@Query('q') query: string | undefined,
|
||||||
|
@Query('category_id') categoryId: string | undefined,
|
||||||
|
@Query('store_id') storeId: string | undefined,
|
||||||
|
@Headers('x-atlas-platform-id') platformId: string | undefined,
|
||||||
|
@CurrentWorkspace() workspace: Workspace,
|
||||||
|
) {
|
||||||
|
if (!dictionaryId.trim() || dictionaryId.length > 200)
|
||||||
|
throw new ApiError(400, 'VALIDATION_FAILED', '字典标识不合法');
|
||||||
|
return {
|
||||||
|
data: await this.editor.attributeDictionary(
|
||||||
|
workspace.id,
|
||||||
|
requiredUuid('profile_id', profileId),
|
||||||
|
requiredUuid('X-Atlas-Platform-Id', platformId),
|
||||||
|
dictionaryId,
|
||||||
|
query,
|
||||||
|
optionalUuid('category_id', categoryId),
|
||||||
|
optionalUuid('store_id', storeId),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(
|
||||||
|
'product-platform-profiles/:profileId/attribute-dictionaries/:dictionaryId/syncs',
|
||||||
|
)
|
||||||
|
async attributeDictionarySync(
|
||||||
|
@Param('profileId') profileId: string,
|
||||||
|
@Param('dictionaryId') dictionaryId: 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,
|
||||||
|
) {
|
||||||
|
if (!dictionaryId.trim() || dictionaryId.length > 200)
|
||||||
|
throw new ApiError(400, 'VALIDATION_FAILED', '字典标识不合法');
|
||||||
|
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),
|
||||||
|
dictionaryId,
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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` },
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function optionalUuid(field: string, value: string | undefined) {
|
||||||
|
if (value === undefined || value === '') return undefined;
|
||||||
|
return requiredUuid(field, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
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,10 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { IdempotencyService } from '../common/idempotency.service.js';
|
||||||
|
import { ProductEditorController } from './product-editor.controller.js';
|
||||||
|
import { ProductEditorService } from './product-editor.service.js';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [ProductEditorController],
|
||||||
|
providers: [IdempotencyService, ProductEditorService],
|
||||||
|
})
|
||||||
|
export class ProductEditorModule {}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,131 @@
|
|||||||
|
import {
|
||||||
|
Inject,
|
||||||
|
Injectable,
|
||||||
|
OnModuleDestroy,
|
||||||
|
OnModuleInit,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { createHmac } from 'node:crypto';
|
||||||
|
import { Redis } from 'ioredis';
|
||||||
|
import { ApiError } from '../common/api-error.js';
|
||||||
|
import { APP_CONFIG, type AppConfig } from '../config/app-config.module.js';
|
||||||
|
|
||||||
|
type AuthAction = 'login' | 'register' | 'refresh' | 'change_password';
|
||||||
|
type Limit = { ip: number; email?: number; user?: number };
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class AuthRateLimiter implements OnModuleInit, OnModuleDestroy {
|
||||||
|
private readonly redis: Redis;
|
||||||
|
|
||||||
|
public constructor(@Inject(APP_CONFIG) private readonly config: AppConfig) {
|
||||||
|
// Redis 让限流计数在多个 API 实例间共享,不会随单个实例重启清零。
|
||||||
|
this.redis = new Redis(config.REDIS_URL, {
|
||||||
|
enableOfflineQueue: false,
|
||||||
|
lazyConnect: true,
|
||||||
|
maxRetriesPerRequest: 1,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public async onModuleInit(): Promise<void> {
|
||||||
|
// 在开始接收认证请求前完成连接,避免首个请求命中 Redis 连接竞态而被误判为 503。
|
||||||
|
await this.redis.connect();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async consume(
|
||||||
|
action: AuthAction,
|
||||||
|
identifiers: { ip: string | null; email?: string; userId?: string },
|
||||||
|
): Promise<void> {
|
||||||
|
const limit = this.limitFor(action);
|
||||||
|
await this.consumeBucket(
|
||||||
|
action,
|
||||||
|
'ip',
|
||||||
|
identifiers.ip ?? 'unknown',
|
||||||
|
limit.ip,
|
||||||
|
);
|
||||||
|
if (identifiers.email && limit.email) {
|
||||||
|
await this.consumeBucket(
|
||||||
|
action,
|
||||||
|
'email',
|
||||||
|
identifiers.email,
|
||||||
|
limit.email,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (identifiers.userId && limit.user) {
|
||||||
|
await this.consumeBucket(
|
||||||
|
action,
|
||||||
|
'user',
|
||||||
|
identifiers.userId,
|
||||||
|
limit.user,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async onModuleDestroy(): Promise<void> {
|
||||||
|
if (this.redis.status !== 'ready') {
|
||||||
|
this.redis.disconnect();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await this.redis.quit();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async consumeBucket(
|
||||||
|
action: AuthAction,
|
||||||
|
dimension: 'ip' | 'email' | 'user',
|
||||||
|
identifier: string,
|
||||||
|
limit: number,
|
||||||
|
): Promise<void> {
|
||||||
|
const digest = createHmac(
|
||||||
|
'sha256',
|
||||||
|
this.config.AUTH_RATE_LIMIT_KEY_SECRET,
|
||||||
|
)
|
||||||
|
.update(identifier)
|
||||||
|
.digest('base64url');
|
||||||
|
// Redis key 只保存 IP/邮箱的 HMAC,不暴露可被日志或运维工具读取的原文。
|
||||||
|
const key = `atlas:auth-rate-limit:${action}:${dimension}:${digest}`;
|
||||||
|
try {
|
||||||
|
// INCR 与首次 EXPIRE 必须原子执行,避免并发下产生永不过期的限流 key。
|
||||||
|
const count = await this.redis.eval(
|
||||||
|
"local value = redis.call('INCR', KEYS[1]); if value == 1 then redis.call('EXPIRE', KEYS[1], ARGV[1]); end; return value;",
|
||||||
|
1,
|
||||||
|
key,
|
||||||
|
this.config.AUTH_RATE_LIMIT_WINDOW_SECONDS,
|
||||||
|
);
|
||||||
|
if (Number(count) > limit) {
|
||||||
|
throw new ApiError(
|
||||||
|
429,
|
||||||
|
'AUTH_RATE_LIMITED',
|
||||||
|
'请求过于频繁,请稍后重试',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof ApiError) throw error;
|
||||||
|
// 认证限流故障时必须拒绝请求;静默跳过会扩大暴力破解风险。
|
||||||
|
throw new ApiError(
|
||||||
|
503,
|
||||||
|
'AUTH_RATE_LIMIT_UNAVAILABLE',
|
||||||
|
'认证服务暂时不可用',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private limitFor(action: AuthAction): Limit {
|
||||||
|
if (action === 'login') {
|
||||||
|
return {
|
||||||
|
ip: this.config.AUTH_LOGIN_IP_LIMIT,
|
||||||
|
email: this.config.AUTH_LOGIN_EMAIL_LIMIT,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (action === 'register') {
|
||||||
|
return {
|
||||||
|
ip: this.config.AUTH_REGISTER_IP_LIMIT,
|
||||||
|
email: this.config.AUTH_REGISTER_EMAIL_LIMIT,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (action === 'change_password') {
|
||||||
|
return {
|
||||||
|
ip: this.config.AUTH_CHANGE_PASSWORD_IP_LIMIT,
|
||||||
|
user: this.config.AUTH_CHANGE_PASSWORD_USER_LIMIT,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { ip: this.config.AUTH_REFRESH_IP_LIMIT };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { Global, Module } from '@nestjs/common';
|
||||||
|
import { AuthRateLimiter } from './auth-rate-limiter.service.js';
|
||||||
|
|
||||||
|
@Global()
|
||||||
|
@Module({ providers: [AuthRateLimiter], exports: [AuthRateLimiter] })
|
||||||
|
export class SecurityModule {}
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
|
import type { Database, DatabaseTransaction } from '@atlas/database';
|
||||||
|
import { ApiError } from '../common/api-error.js';
|
||||||
|
import { DATABASE } from '../database/database.module.js';
|
||||||
|
|
||||||
|
type PlatformRow = {
|
||||||
|
id: string;
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
capabilities: Record<string, unknown>;
|
||||||
|
supported_currencies: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class PlatformsService {
|
||||||
|
public constructor(@Inject(DATABASE) private readonly database: Database) {}
|
||||||
|
|
||||||
|
public async list() {
|
||||||
|
const rows = await this.database.query<PlatformRow>(
|
||||||
|
`SELECT p.id, p.code, p.name, p.capabilities,
|
||||||
|
coalesce(array_agg(pcs.currency_code ORDER BY pcs.currency_code)
|
||||||
|
FILTER (WHERE pcs.is_enabled AND c.is_enabled), ARRAY[]::char(3)[]) AS supported_currencies
|
||||||
|
FROM atlas.platforms p
|
||||||
|
LEFT JOIN atlas.platform_currency_support pcs ON pcs.platform_id = p.id
|
||||||
|
LEFT JOIN atlas.currencies c ON c.code = pcs.currency_code
|
||||||
|
WHERE p.status = 'active'
|
||||||
|
AND coalesce((p.capabilities->>'can_be_header_context')::boolean, false)
|
||||||
|
GROUP BY p.id
|
||||||
|
ORDER BY CASE WHEN p.code = 'OZON' THEN 0 ELSE 1 END, p.name`,
|
||||||
|
);
|
||||||
|
return rows.rows.map((platform) => ({
|
||||||
|
id: platform.id,
|
||||||
|
code: platform.code,
|
||||||
|
name: platform.name,
|
||||||
|
supported_currencies: platform.supported_currencies,
|
||||||
|
// 能力未配置时使用 null,而不是把 Ozon 的市场或语言假设扩散到通用模型。
|
||||||
|
default_market_code: stringCapability(
|
||||||
|
platform.capabilities,
|
||||||
|
'default_market_code',
|
||||||
|
),
|
||||||
|
default_locale: stringCapability(
|
||||||
|
platform.capabilities,
|
||||||
|
'default_locale',
|
||||||
|
),
|
||||||
|
default_currency_code: stringCapability(
|
||||||
|
platform.capabilities,
|
||||||
|
'default_currency_code',
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
public async context(workspaceId: string, platformId: string) {
|
||||||
|
return this.database.workspaceTransaction(
|
||||||
|
workspaceId,
|
||||||
|
async (transaction) => {
|
||||||
|
const platform = await this.requireActivePlatform(
|
||||||
|
transaction,
|
||||||
|
platformId,
|
||||||
|
);
|
||||||
|
const stores = await transaction.query<{
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
status: string;
|
||||||
|
credential_status: string;
|
||||||
|
count: string;
|
||||||
|
}>(
|
||||||
|
`SELECT s.id, s.name, s.status, s.credential_status,
|
||||||
|
count(*) OVER ()::text AS count
|
||||||
|
FROM atlas.stores s
|
||||||
|
WHERE s.platform_id = $1 AND s.deleted_at IS NULL AND s.is_default
|
||||||
|
LIMIT 1`,
|
||||||
|
[platformId],
|
||||||
|
);
|
||||||
|
// 默认门店不存在时仍要正确返回总数,不能用默认行的窗口函数猜测。
|
||||||
|
const count = await transaction.query<{ count: string }>(
|
||||||
|
`SELECT count(*)::text AS count FROM atlas.stores
|
||||||
|
WHERE platform_id = $1 AND deleted_at IS NULL`,
|
||||||
|
[platformId],
|
||||||
|
);
|
||||||
|
const defaultStore = stores.rows[0];
|
||||||
|
return {
|
||||||
|
platform: {
|
||||||
|
id: platform.id,
|
||||||
|
code: platform.code,
|
||||||
|
name: platform.name,
|
||||||
|
},
|
||||||
|
default_store: defaultStore
|
||||||
|
? {
|
||||||
|
id: defaultStore.id,
|
||||||
|
name: defaultStore.name,
|
||||||
|
status: defaultStore.status,
|
||||||
|
credential_status: defaultStore.credential_status,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
store_count: Number(count.rows[0]?.count ?? 0),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async requireActivePlatform(
|
||||||
|
transaction: DatabaseTransaction,
|
||||||
|
platformId: string,
|
||||||
|
) {
|
||||||
|
const rows = await transaction.query<{
|
||||||
|
id: string;
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
}>(
|
||||||
|
`SELECT id, code, name FROM atlas.platforms WHERE id = $1 AND status = 'active'`,
|
||||||
|
[platformId],
|
||||||
|
);
|
||||||
|
if (!rows.rows[0]) {
|
||||||
|
throw new ApiError(
|
||||||
|
422,
|
||||||
|
'PLATFORM_UNAVAILABLE',
|
||||||
|
'平台不存在或已停用',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return rows.rows[0];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function stringCapability(
|
||||||
|
capabilities: Record<string, unknown>,
|
||||||
|
key: string,
|
||||||
|
): string | null {
|
||||||
|
const value = capabilities[key];
|
||||||
|
return typeof value === 'string' ? value : null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import {
|
||||||
|
createDecipheriv,
|
||||||
|
createCipheriv,
|
||||||
|
createHash,
|
||||||
|
createHmac,
|
||||||
|
randomBytes,
|
||||||
|
} from 'node:crypto';
|
||||||
|
|
||||||
|
export type EncryptedStoreSecret = {
|
||||||
|
ciphertext: Buffer;
|
||||||
|
nonce: Buffer;
|
||||||
|
fingerprint: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 密钥只在此边界短暂以明文存在。数据库保存 AES-GCM 密文和随机 nonce,
|
||||||
|
* fingerprint 是不可逆 HMAC,仅用于安全地识别同一份密钥。
|
||||||
|
*/
|
||||||
|
export function encryptStoreSecret(
|
||||||
|
plaintext: string,
|
||||||
|
keyMaterial: string,
|
||||||
|
): EncryptedStoreSecret {
|
||||||
|
const key = createHash('sha256').update(keyMaterial).digest();
|
||||||
|
const nonce = randomBytes(12);
|
||||||
|
const cipher = createCipheriv('aes-256-gcm', key, nonce);
|
||||||
|
const ciphertext = Buffer.concat([
|
||||||
|
cipher.update(plaintext, 'utf8'),
|
||||||
|
cipher.final(),
|
||||||
|
cipher.getAuthTag(),
|
||||||
|
]);
|
||||||
|
return {
|
||||||
|
ciphertext,
|
||||||
|
nonce,
|
||||||
|
fingerprint: createHmac('sha256', key)
|
||||||
|
.update(plaintext)
|
||||||
|
.digest('base64url'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 仅供受信任的后台 Worker 使用;调用方不得记录返回值。 */
|
||||||
|
export function decryptStoreSecret(
|
||||||
|
ciphertextWithTag: Buffer,
|
||||||
|
nonce: Buffer,
|
||||||
|
keyMaterial: string,
|
||||||
|
): string {
|
||||||
|
const authenticationTagLength = 16;
|
||||||
|
if (ciphertextWithTag.length <= authenticationTagLength) {
|
||||||
|
throw new Error('Invalid encrypted store secret');
|
||||||
|
}
|
||||||
|
const key = createHash('sha256').update(keyMaterial).digest();
|
||||||
|
const ciphertext = ciphertextWithTag.subarray(0, -authenticationTagLength);
|
||||||
|
const tag = ciphertextWithTag.subarray(-authenticationTagLength);
|
||||||
|
const decipher = createDecipheriv('aes-256-gcm', key, nonce);
|
||||||
|
decipher.setAuthTag(tag);
|
||||||
|
return Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString(
|
||||||
|
'utf8',
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,378 @@
|
|||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Delete,
|
||||||
|
Get,
|
||||||
|
Headers,
|
||||||
|
HttpCode,
|
||||||
|
Inject,
|
||||||
|
Param,
|
||||||
|
Patch,
|
||||||
|
Post,
|
||||||
|
Query,
|
||||||
|
Req,
|
||||||
|
Res,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import {
|
||||||
|
ApiAcceptedResponse,
|
||||||
|
ApiBearerAuth,
|
||||||
|
ApiNoContentResponse,
|
||||||
|
ApiOkResponse,
|
||||||
|
ApiOperation,
|
||||||
|
ApiTags,
|
||||||
|
} from '@nestjs/swagger';
|
||||||
|
import {
|
||||||
|
createStoreRequestSchema,
|
||||||
|
listStoresQuerySchema,
|
||||||
|
updateStoreRequestSchema,
|
||||||
|
} from '@atlas/contracts';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { ApiError } from '../common/api-error.js';
|
||||||
|
import type { AuthenticatedRequest } from '../auth/auth.guard.js';
|
||||||
|
import { CurrentWorkspace } from '../workspace/current-workspace.decorator.js';
|
||||||
|
import type { Workspace } from '@atlas/contracts';
|
||||||
|
import { PlatformsService } from './platforms.service.js';
|
||||||
|
import { StoresService } from './stores.service.js';
|
||||||
|
|
||||||
|
type HttpResponse = { status: (status: number) => HttpResponse };
|
||||||
|
|
||||||
|
@ApiTags('Platforms')
|
||||||
|
@ApiBearerAuth('access-token')
|
||||||
|
@Controller()
|
||||||
|
export class PlatformsController {
|
||||||
|
// tsx/esbuild 不生成 design:paramtypes 元数据,控制器依赖必须显式 @Inject(与 auth.controller 一致)
|
||||||
|
public constructor(
|
||||||
|
@Inject(PlatformsService)
|
||||||
|
private readonly platformsService: PlatformsService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
@Get('platforms')
|
||||||
|
@ApiOperation({ summary: '获取已启用的平台及非敏感能力目录' })
|
||||||
|
@ApiOkResponse({ description: '平台目录' })
|
||||||
|
public async list() {
|
||||||
|
return { data: await this.platformsService.list() };
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('platform-context')
|
||||||
|
@ApiOperation({ summary: '获取页头所选平台的默认门店上下文' })
|
||||||
|
@ApiOkResponse({ description: '平台、默认门店和门店总数' })
|
||||||
|
public async context(
|
||||||
|
@Headers('x-atlas-platform-id') platformId: string | undefined,
|
||||||
|
@CurrentWorkspace() workspace: Workspace,
|
||||||
|
) {
|
||||||
|
if (!platformId) {
|
||||||
|
throw new ApiError(
|
||||||
|
400,
|
||||||
|
'PLATFORM_CONTEXT_REQUIRED',
|
||||||
|
'请求缺少 X-Atlas-Platform-Id',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
data: await this.platformsService.context(
|
||||||
|
workspace.id,
|
||||||
|
requireUuid('X-Atlas-Platform-Id', platformId),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ApiTags('Stores')
|
||||||
|
@ApiBearerAuth('access-token')
|
||||||
|
@Controller('stores')
|
||||||
|
export class StoresController {
|
||||||
|
public constructor(
|
||||||
|
@Inject(StoresService) private readonly storesService: StoresService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
@ApiOperation({ summary: '按平台分页列出当前工作空间的门店' })
|
||||||
|
@ApiOkResponse({ description: '门店列表与不透明游标' })
|
||||||
|
public async list(
|
||||||
|
@Query() query: unknown,
|
||||||
|
@Headers('x-atlas-platform-id') platformHeader: string | undefined,
|
||||||
|
@CurrentWorkspace() workspace: Workspace,
|
||||||
|
) {
|
||||||
|
if (platformHeader) {
|
||||||
|
throw new ApiError(
|
||||||
|
400,
|
||||||
|
'PLATFORM_CONTEXT_CONFLICT',
|
||||||
|
'门店管理列表不能同时使用平台 header 和 platform_id 查询参数',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return this.storesService.list(
|
||||||
|
workspace.id,
|
||||||
|
parse(listStoresQuerySchema, query),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':storeId')
|
||||||
|
@ApiOperation({ summary: '读取单个门店' })
|
||||||
|
public async get(
|
||||||
|
@Param('storeId') storeId: string,
|
||||||
|
@CurrentWorkspace() workspace: Workspace,
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
data: await this.storesService.get(
|
||||||
|
workspace.id,
|
||||||
|
requireUuid('store_id', storeId),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
@ApiOperation({ summary: '创建门店连接,并请求异步凭据验证' })
|
||||||
|
@ApiAcceptedResponse({ description: '已加密保存凭据,并创建验证操作' })
|
||||||
|
public async create(
|
||||||
|
@Body() body: unknown,
|
||||||
|
@Headers('idempotency-key') idempotencyKey: string | undefined,
|
||||||
|
@Req() request: AuthenticatedRequest,
|
||||||
|
@CurrentWorkspace() workspace: Workspace,
|
||||||
|
@Res({ passthrough: true }) response: HttpResponse,
|
||||||
|
) {
|
||||||
|
return withStatus(
|
||||||
|
response,
|
||||||
|
await this.storesService.create(
|
||||||
|
workspace.id,
|
||||||
|
request.auth.userId,
|
||||||
|
requireIdempotencyKey(idempotencyKey),
|
||||||
|
parse(createStoreRequestSchema, body),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch(':storeId')
|
||||||
|
@ApiOperation({ summary: '按版本编辑门店;更新密钥会重新请求验证' })
|
||||||
|
@ApiOkResponse({ description: '普通字段已更新' })
|
||||||
|
@ApiAcceptedResponse({ description: '新密钥已保存,并创建验证操作' })
|
||||||
|
public async update(
|
||||||
|
@Param('storeId') storeId: string,
|
||||||
|
@Body() body: unknown,
|
||||||
|
@Headers('idempotency-key') idempotencyKey: string | undefined,
|
||||||
|
@Headers('if-match') ifMatch: string | undefined,
|
||||||
|
@Req() request: AuthenticatedRequest,
|
||||||
|
@CurrentWorkspace() workspace: Workspace,
|
||||||
|
@Res({ passthrough: true }) response: HttpResponse,
|
||||||
|
) {
|
||||||
|
return withStatus(
|
||||||
|
response,
|
||||||
|
await this.storesService.update(
|
||||||
|
workspace.id,
|
||||||
|
request.auth.userId,
|
||||||
|
requireUuid('store_id', storeId),
|
||||||
|
requireIdempotencyKey(idempotencyKey),
|
||||||
|
parseRevision(ifMatch),
|
||||||
|
parse(updateStoreRequestSchema, body),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':storeId/set-default')
|
||||||
|
@ApiOperation({ summary: '设为当前平台默认门店' })
|
||||||
|
public async setDefault(
|
||||||
|
@Param('storeId') storeId: string,
|
||||||
|
@Headers('idempotency-key') idempotencyKey: string | undefined,
|
||||||
|
@Req() request: AuthenticatedRequest,
|
||||||
|
@CurrentWorkspace() workspace: Workspace,
|
||||||
|
@Res({ passthrough: true }) response: HttpResponse,
|
||||||
|
) {
|
||||||
|
return withStatus(
|
||||||
|
response,
|
||||||
|
await this.storesService.setDefault(
|
||||||
|
workspace.id,
|
||||||
|
request.auth.userId,
|
||||||
|
requireUuid('store_id', storeId),
|
||||||
|
requireIdempotencyKey(idempotencyKey),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':storeId/credential-verifications')
|
||||||
|
@ApiOperation({ summary: '手动请求重新验证门店凭据' })
|
||||||
|
@ApiAcceptedResponse({ description: '凭据验证操作已入队' })
|
||||||
|
public async credentialVerification(
|
||||||
|
@Param('storeId') storeId: string,
|
||||||
|
@Headers('idempotency-key') idempotencyKey: string | undefined,
|
||||||
|
@Req() request: AuthenticatedRequest,
|
||||||
|
@CurrentWorkspace() workspace: Workspace,
|
||||||
|
@Res({ passthrough: true }) response: HttpResponse,
|
||||||
|
) {
|
||||||
|
return withStatus(
|
||||||
|
response,
|
||||||
|
await this.storesService.requestCredentialVerification(
|
||||||
|
workspace.id,
|
||||||
|
request.auth.userId,
|
||||||
|
requireUuid('store_id', storeId),
|
||||||
|
requireIdempotencyKey(idempotencyKey),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':storeId/category-tree-syncs')
|
||||||
|
@ApiOperation({ summary: '同步刷新门店类目树' })
|
||||||
|
@ApiOkResponse({ description: 'Ozon 类目树已成功写入门店缓存' })
|
||||||
|
public async categoryTreeSync(
|
||||||
|
@Param('storeId') storeId: string,
|
||||||
|
@Headers('idempotency-key') idempotencyKey: string | undefined,
|
||||||
|
@Req() request: AuthenticatedRequest,
|
||||||
|
@CurrentWorkspace() workspace: Workspace,
|
||||||
|
@Res({ passthrough: true }) response: HttpResponse,
|
||||||
|
) {
|
||||||
|
return withStatus(
|
||||||
|
response,
|
||||||
|
await this.storesService.requestCategoryTreeSync(
|
||||||
|
workspace.id,
|
||||||
|
request.auth.userId,
|
||||||
|
requireUuid('store_id', storeId),
|
||||||
|
requireIdempotencyKey(idempotencyKey),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('default/category-tree-debug')
|
||||||
|
@ApiOperation({ summary: '读取当前平台默认门店的 Ozon 原始类目树(调试)' })
|
||||||
|
public async defaultCategoryTreeDebug(
|
||||||
|
@Headers('x-atlas-platform-id') platformId: string | undefined,
|
||||||
|
@CurrentWorkspace() workspace: Workspace,
|
||||||
|
) {
|
||||||
|
if (!platformId) {
|
||||||
|
throw new ApiError(400, 'PLATFORM_CONTEXT_REQUIRED', '请求缺少 X-Atlas-Platform-Id');
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
data: await this.storesService.fetchDefaultCategoryTreeDebug(
|
||||||
|
workspace.id,
|
||||||
|
requireUuid('X-Atlas-Platform-Id', platformId),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':storeId/warehouse-syncs')
|
||||||
|
@ApiOperation({ summary: '请求异步同步门店仓库' })
|
||||||
|
@ApiAcceptedResponse({ description: '仓库同步操作已入队' })
|
||||||
|
public async warehouseSync(
|
||||||
|
@Param('storeId') storeId: string,
|
||||||
|
@Headers('idempotency-key') idempotencyKey: string | undefined,
|
||||||
|
@Req() request: AuthenticatedRequest,
|
||||||
|
@CurrentWorkspace() workspace: Workspace,
|
||||||
|
@Res({ passthrough: true }) response: HttpResponse,
|
||||||
|
) {
|
||||||
|
return withStatus(
|
||||||
|
response,
|
||||||
|
await this.storesService.requestWarehouseSync(
|
||||||
|
workspace.id,
|
||||||
|
request.auth.userId,
|
||||||
|
requireUuid('store_id', storeId),
|
||||||
|
requireIdempotencyKey(idempotencyKey),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':storeId/warehouses')
|
||||||
|
@ApiOperation({ summary: '读取最近成功同步的仓库摘要' })
|
||||||
|
public async warehouses(
|
||||||
|
@Param('storeId') storeId: string,
|
||||||
|
@CurrentWorkspace() workspace: Workspace,
|
||||||
|
) {
|
||||||
|
return this.storesService.warehouses(
|
||||||
|
workspace.id,
|
||||||
|
requireUuid('store_id', storeId),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete(':storeId')
|
||||||
|
@HttpCode(204)
|
||||||
|
@ApiOperation({ summary: '软删除门店连接并撤销当前密钥' })
|
||||||
|
@ApiNoContentResponse({ description: '门店已删除' })
|
||||||
|
public async remove(
|
||||||
|
@Param('storeId') storeId: string,
|
||||||
|
@Headers('idempotency-key') idempotencyKey: string | undefined,
|
||||||
|
@Req() request: AuthenticatedRequest,
|
||||||
|
@CurrentWorkspace() workspace: Workspace,
|
||||||
|
@Res({ passthrough: true }) response: HttpResponse,
|
||||||
|
) {
|
||||||
|
return withStatus(
|
||||||
|
response,
|
||||||
|
await this.storesService.remove(
|
||||||
|
workspace.id,
|
||||||
|
request.auth.userId,
|
||||||
|
requireUuid('store_id', storeId),
|
||||||
|
requireIdempotencyKey(idempotencyKey),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ApiTags('Operations')
|
||||||
|
@ApiBearerAuth('access-token')
|
||||||
|
@Controller('operations')
|
||||||
|
export class OperationsController {
|
||||||
|
public constructor(
|
||||||
|
@Inject(StoresService) private readonly storesService: StoresService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
@Get(':operationId')
|
||||||
|
@ApiOperation({ summary: '读取当前工作空间的异步操作状态' })
|
||||||
|
public async get(
|
||||||
|
@Param('operationId') operationId: string,
|
||||||
|
@CurrentWorkspace() workspace: Workspace,
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
data: await this.storesService.operation(
|
||||||
|
workspace.id,
|
||||||
|
requireUuid('operation_id', operationId),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function withStatus(
|
||||||
|
response: HttpResponse,
|
||||||
|
result: { status: number; body: unknown },
|
||||||
|
) {
|
||||||
|
response.status(result.status);
|
||||||
|
return result.body;
|
||||||
|
}
|
||||||
|
|
||||||
|
function requireIdempotencyKey(value: string | undefined): string {
|
||||||
|
if (!value) {
|
||||||
|
throw new ApiError(
|
||||||
|
400,
|
||||||
|
'IDEMPOTENCY_KEY_REQUIRED',
|
||||||
|
'请求缺少 Idempotency-Key',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return requireUuid('Idempotency-Key', value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function requireUuid(field: string, value: string): string {
|
||||||
|
if (z.uuid().safeParse(value).success) return value;
|
||||||
|
throw new ApiError(422, 'VALIDATION_FAILED', '请求参数不符合要求', [
|
||||||
|
{ field, code: 'INVALID_FIELD', message: `${field} 必须是 UUID` },
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseRevision(value: string | undefined): number {
|
||||||
|
const match = value?.match(/^"([1-9]\d*)"$/);
|
||||||
|
if (!match) {
|
||||||
|
throw new ApiError(
|
||||||
|
400,
|
||||||
|
'IF_MATCH_REQUIRED',
|
||||||
|
'请求必须携带形如 "1" 的 If-Match',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return Number(match[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parse<T>(schema: z.ZodType<T>, value: unknown): T {
|
||||||
|
const result = schema.safeParse(value);
|
||||||
|
if (result.success) return result.data;
|
||||||
|
throw new ApiError(
|
||||||
|
422,
|
||||||
|
'VALIDATION_FAILED',
|
||||||
|
'请求参数不符合要求',
|
||||||
|
result.error.issues.map((issue) => ({
|
||||||
|
field: issue.path.join('.'),
|
||||||
|
code: 'INVALID_FIELD',
|
||||||
|
message: issue.message,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { IdempotencyService } from '../common/idempotency.service.js';
|
||||||
|
import {
|
||||||
|
OperationsController,
|
||||||
|
PlatformsController,
|
||||||
|
StoresController,
|
||||||
|
} from './stores.controller.js';
|
||||||
|
import { PlatformsService } from './platforms.service.js';
|
||||||
|
import { StoresService } from './stores.service.js';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [PlatformsController, StoresController, OperationsController],
|
||||||
|
providers: [IdempotencyService, PlatformsService, StoresService],
|
||||||
|
})
|
||||||
|
export class StoresModule {}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,91 @@
|
|||||||
|
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/: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;
|
||||||
|
}
|
||||||
|
|
||||||
|
@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,478 @@
|
|||||||
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
|
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';
|
||||||
|
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;
|
||||||
|
position: 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 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) => {
|
||||||
|
await this.validateConfig(tx, input.kind, input.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,
|
||||||
|
input.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, {
|
||||||
|
...configFromRow(current),
|
||||||
|
...(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(),
|
||||||
|
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'
|
||||||
|
) {
|
||||||
|
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'
|
||||||
|
) {
|
||||||
|
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, created_by_user_id)
|
||||||
|
VALUES (current_setting('app.workspace_id')::uuid, $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)`,
|
||||||
|
[
|
||||||
|
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,
|
||||||
|
userId,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const templateSelect = `SELECT template.id, template.name, template.kind, template.current_version,
|
||||||
|
template.created_at, template.updated_at, template.revision, version.text_content, version.image_asset_id,
|
||||||
|
version.font_code, version.font_size_px, version.opacity::text AS opacity, version.scale_ratio::text AS scale_ratio,
|
||||||
|
version.position FROM atlas.watermark_templates template JOIN atlas.watermark_template_versions version
|
||||||
|
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,
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
image_asset_id: row.image_asset_id,
|
||||||
|
scale_ratio: row.scale_ratio,
|
||||||
|
opacity: row.opacity,
|
||||||
|
position: row.position,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
image_asset_id: config.image_asset_id,
|
||||||
|
scale_ratio: config.scale_ratio,
|
||||||
|
opacity: config.opacity,
|
||||||
|
position: config.position,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
function iso(value: Date | string) {
|
||||||
|
return value instanceof Date ? value.toISOString() : value;
|
||||||
|
}
|
||||||
|
function versionConflict(currentRevision: number) {
|
||||||
|
return new ApiError(409, 'VERSION_CONFLICT', '水印模板已被其他操作更新', [
|
||||||
|
{
|
||||||
|
field: 'If-Match',
|
||||||
|
code: 'VERSION_CONFLICT',
|
||||||
|
message: `当前版本为 ${currentRevision}`,
|
||||||
|
current_revision: currentRevision,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
function encodeCursor(createdAt: string, id: string) {
|
||||||
|
return Buffer.from(JSON.stringify({ createdAt, id })).toString('base64url');
|
||||||
|
}
|
||||||
|
function decodeCursor(cursor: string): { createdAt: string; id: string } {
|
||||||
|
try {
|
||||||
|
const value = JSON.parse(
|
||||||
|
Buffer.from(cursor, 'base64url').toString('utf8'),
|
||||||
|
) as { createdAt?: unknown; id?: unknown };
|
||||||
|
if (
|
||||||
|
typeof value.createdAt !== 'string' ||
|
||||||
|
Number.isNaN(Date.parse(value.createdAt)) ||
|
||||||
|
typeof value.id !== 'string' ||
|
||||||
|
!/^[0-9a-f-]{36}$/i.test(value.id)
|
||||||
|
)
|
||||||
|
throw new Error();
|
||||||
|
return { createdAt: value.createdAt, id: value.id };
|
||||||
|
} catch {
|
||||||
|
throw new ApiError(422, 'VALIDATION_FAILED', '分页游标无效');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { createParamDecorator, type ExecutionContext } from '@nestjs/common';
|
||||||
|
import type { Workspace } from '@atlas/contracts';
|
||||||
|
import type { WorkspaceAuthenticatedRequest } from './workspace-context.guard.js';
|
||||||
|
|
||||||
|
// 受保护接口经全局工作空间守卫验证后,只能从这里读取当前工作空间。
|
||||||
|
export const CurrentWorkspace = createParamDecorator(
|
||||||
|
(_data: unknown, context: ExecutionContext): Workspace => {
|
||||||
|
const request = context
|
||||||
|
.switchToHttp()
|
||||||
|
.getRequest<WorkspaceAuthenticatedRequest>();
|
||||||
|
return request.auth.workspace;
|
||||||
|
},
|
||||||
|
);
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import {
|
||||||
|
CanActivate,
|
||||||
|
ExecutionContext,
|
||||||
|
Inject,
|
||||||
|
Injectable,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import type { Workspace } from '@atlas/contracts';
|
||||||
|
import type { Database } from '@atlas/database';
|
||||||
|
import { Reflector } from '@nestjs/core';
|
||||||
|
import { ApiError } from '../common/api-error.js';
|
||||||
|
import { IS_PUBLIC_ENDPOINT } from '../common/public.decorator.js';
|
||||||
|
import { DATABASE } from '../database/database.module.js';
|
||||||
|
import type { AuthenticatedRequest } from '../auth/auth.guard.js';
|
||||||
|
|
||||||
|
export type WorkspaceAuthenticatedRequest = AuthenticatedRequest & {
|
||||||
|
auth: AuthenticatedRequest['auth'] & { workspace: Workspace };
|
||||||
|
};
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class WorkspaceContextGuard implements CanActivate {
|
||||||
|
public constructor(
|
||||||
|
@Inject(Reflector) private readonly reflector: Reflector,
|
||||||
|
@Inject(DATABASE) private readonly database: Database,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||||
|
if (
|
||||||
|
this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_ENDPOINT, [
|
||||||
|
context.getHandler(),
|
||||||
|
context.getClass(),
|
||||||
|
])
|
||||||
|
) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const request = context
|
||||||
|
.switchToHttp()
|
||||||
|
.getRequest<AuthenticatedRequest>();
|
||||||
|
// 当前一期每位用户只有一个个人工作空间;后续多人协作时此处将按显式选择的工作空间校验 membership。
|
||||||
|
const workspace = await this.findCurrentWorkspace(request.auth.userId);
|
||||||
|
if (!workspace) {
|
||||||
|
throw new ApiError(
|
||||||
|
403,
|
||||||
|
'WORKSPACE_MEMBERSHIP_REQUIRED',
|
||||||
|
'当前账户没有可用工作空间',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// 后续控制器和服务只使用此处验证过的上下文,绝不相信客户端传入 workspace_id。
|
||||||
|
request.auth.workspace = workspace;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async findCurrentWorkspace(
|
||||||
|
userId: string,
|
||||||
|
): Promise<Workspace | null> {
|
||||||
|
// 排序规则是首期“唯一有效工作空间”的临时选择策略,不是未来的工作空间切换协议。
|
||||||
|
const result = await this.database.query<Workspace>(
|
||||||
|
`SELECT w.id, w.name, m.role FROM atlas.workspace_memberships m JOIN atlas.workspaces w ON w.id = m.workspace_id WHERE m.user_id = $1 AND w.status = 'active' ORDER BY m.joined_at LIMIT 1`,
|
||||||
|
[userId],
|
||||||
|
);
|
||||||
|
return result.rows[0] ?? null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,430 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
import type { AddressInfo } from 'node:net';
|
||||||
|
import type { Database } from '@atlas/database';
|
||||||
|
import type { NestExpressApplication } from '@nestjs/platform-express';
|
||||||
|
import { afterAll, beforeAll, describe, it } from 'vitest';
|
||||||
|
import { DATABASE } from '../src/database/database.module.js';
|
||||||
|
import { createApplication } from '../src/main.js';
|
||||||
|
|
||||||
|
type SuccessResponse<T> = { data: T };
|
||||||
|
type ErrorResponse = {
|
||||||
|
error: {
|
||||||
|
code: string;
|
||||||
|
message: string;
|
||||||
|
request_id: string;
|
||||||
|
details?: Array<{ field: string; code: string; message: string }>;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
type SessionPayload = {
|
||||||
|
access_token: string;
|
||||||
|
access_token_expires_in: number;
|
||||||
|
user: {
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
display_name: string | null;
|
||||||
|
avatar_asset_id: string | null;
|
||||||
|
revision: number;
|
||||||
|
};
|
||||||
|
workspace: { id: string; name: string; role: string };
|
||||||
|
};
|
||||||
|
|
||||||
|
const password = 'correct-horse-battery-staple-123';
|
||||||
|
const testUsers: Array<{ userId: string; workspaceId: string }> = [];
|
||||||
|
let app: NestExpressApplication;
|
||||||
|
let database: Database;
|
||||||
|
let baseUrl: string;
|
||||||
|
|
||||||
|
function email(label: string): string {
|
||||||
|
return `${label}-${randomUUID()}@example.test`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function request(
|
||||||
|
path: string,
|
||||||
|
init: RequestInit = {},
|
||||||
|
): Promise<{ response: Response; body: unknown }> {
|
||||||
|
const response = await fetch(`${baseUrl}${path}`, {
|
||||||
|
...init,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...init.headers,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
response,
|
||||||
|
body: response.status === 204 ? undefined : await response.json(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function expectSuccess<T>(body: unknown): SuccessResponse<T> {
|
||||||
|
assert.ok(body && typeof body === 'object' && 'data' in body);
|
||||||
|
return body as SuccessResponse<T>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function expectError(body: unknown): ErrorResponse {
|
||||||
|
assert.ok(body && typeof body === 'object' && 'error' in body);
|
||||||
|
return body as ErrorResponse;
|
||||||
|
}
|
||||||
|
|
||||||
|
function refreshCookie(response: Response): string {
|
||||||
|
const cookie = response.headers.get('set-cookie');
|
||||||
|
assert.ok(cookie, '认证成功响应必须写入 refresh cookie');
|
||||||
|
assert.match(cookie, /HttpOnly/i);
|
||||||
|
const match = cookie.match(/atlas_refresh=[^;]+/);
|
||||||
|
assert.ok(match, '响应必须包含 atlas_refresh cookie');
|
||||||
|
return match[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('认证 HTTP 集成测试', () => {
|
||||||
|
beforeAll(async () => {
|
||||||
|
Object.assign(process.env, {
|
||||||
|
NODE_ENV: 'test',
|
||||||
|
SWAGGER_ENABLED: 'false',
|
||||||
|
AUTH_LOGIN_IP_LIMIT: '100',
|
||||||
|
AUTH_LOGIN_EMAIL_LIMIT: '2',
|
||||||
|
AUTH_REGISTER_IP_LIMIT: '100',
|
||||||
|
AUTH_REGISTER_EMAIL_LIMIT: '100',
|
||||||
|
AUTH_REFRESH_IP_LIMIT: '100',
|
||||||
|
});
|
||||||
|
const application = await createApplication();
|
||||||
|
app = application.app;
|
||||||
|
database = app.get<Database>(DATABASE);
|
||||||
|
await app.listen(0, '127.0.0.1');
|
||||||
|
const address = app.getHttpServer().address() as AddressInfo;
|
||||||
|
baseUrl = `http://127.0.0.1:${address.port}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
for (const user of testUsers) {
|
||||||
|
await database.workspaceTransaction(
|
||||||
|
user.workspaceId,
|
||||||
|
async (tx) => {
|
||||||
|
await tx.query(
|
||||||
|
'DELETE FROM atlas.idempotency_requests WHERE workspace_id = $1',
|
||||||
|
[user.workspaceId],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
await database.query(
|
||||||
|
'DELETE FROM atlas.workspace_memberships WHERE user_id = $1',
|
||||||
|
[user.userId],
|
||||||
|
);
|
||||||
|
await database.query('DELETE FROM atlas.workspaces WHERE id = $1', [
|
||||||
|
user.workspaceId,
|
||||||
|
]);
|
||||||
|
await database.query('DELETE FROM atlas.users WHERE id = $1', [
|
||||||
|
user.userId,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('健康与 readiness 端点保持公开', async () => {
|
||||||
|
const health = await request('/health');
|
||||||
|
assert.equal(health.response.status, 200);
|
||||||
|
assert.deepEqual(health.body, { status: 'ok', service: 'api' });
|
||||||
|
|
||||||
|
const readiness = await request('/ready');
|
||||||
|
assert.equal(readiness.response.status, 200);
|
||||||
|
assert.deepEqual(readiness.body, { status: 'ready', service: 'api' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('注册、重复邮箱和参数校验遵守响应契约', async () => {
|
||||||
|
const registrationEmail = email('register');
|
||||||
|
const registration = await request('/api/auth/register', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({
|
||||||
|
email: registrationEmail,
|
||||||
|
password,
|
||||||
|
password_confirmation: password,
|
||||||
|
display_name: '集成测试用户',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
assert.equal(registration.response.status, 201);
|
||||||
|
const payload = expectSuccess<SessionPayload>(registration.body).data;
|
||||||
|
assert.equal(payload.user.email, registrationEmail);
|
||||||
|
assert.equal(payload.workspace.role, 'owner');
|
||||||
|
assert.ok(payload.access_token);
|
||||||
|
assert.ok(payload.access_token_expires_in > 0);
|
||||||
|
refreshCookie(registration.response);
|
||||||
|
testUsers.push({
|
||||||
|
userId: payload.user.id,
|
||||||
|
workspaceId: payload.workspace.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
const duplicate = await request('/api/auth/register', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({
|
||||||
|
email: registrationEmail,
|
||||||
|
password,
|
||||||
|
password_confirmation: password,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
assert.equal(duplicate.response.status, 409);
|
||||||
|
assert.equal(
|
||||||
|
expectError(duplicate.body).error.code,
|
||||||
|
'EMAIL_ALREADY_REGISTERED',
|
||||||
|
);
|
||||||
|
|
||||||
|
const invalid = await request('/api/auth/register', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({
|
||||||
|
email: 'not-an-email',
|
||||||
|
password,
|
||||||
|
password_confirmation: password,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
assert.equal(invalid.response.status, 422);
|
||||||
|
assert.equal(expectError(invalid.body).error.code, 'VALIDATION_FAILED');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('登录、刷新、退出和当前用户读取可形成完整会话闭环', async () => {
|
||||||
|
const accountEmail = email('session');
|
||||||
|
const registration = await request('/api/auth/register', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({
|
||||||
|
email: accountEmail,
|
||||||
|
password,
|
||||||
|
password_confirmation: password,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const registered = expectSuccess<SessionPayload>(
|
||||||
|
registration.body,
|
||||||
|
).data;
|
||||||
|
testUsers.push({
|
||||||
|
userId: registered.user.id,
|
||||||
|
workspaceId: registered.workspace.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
const login = await request('/api/auth/login', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ email: accountEmail, password }),
|
||||||
|
});
|
||||||
|
assert.equal(login.response.status, 200);
|
||||||
|
const loggedIn = expectSuccess<SessionPayload>(login.body).data;
|
||||||
|
const loginRefreshCookie = refreshCookie(login.response);
|
||||||
|
|
||||||
|
const currentUser = await request('/api/auth/me', {
|
||||||
|
headers: { Authorization: `Bearer ${loggedIn.access_token}` },
|
||||||
|
});
|
||||||
|
assert.equal(currentUser.response.status, 200);
|
||||||
|
const me = expectSuccess<{
|
||||||
|
user: { id: string };
|
||||||
|
workspace: { id: string };
|
||||||
|
}>(currentUser.body).data;
|
||||||
|
assert.equal(me.user.id, registered.user.id);
|
||||||
|
assert.equal(me.workspace.id, registered.workspace.id);
|
||||||
|
|
||||||
|
const refresh = await request('/api/auth/refresh', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { Cookie: loginRefreshCookie },
|
||||||
|
});
|
||||||
|
assert.equal(refresh.response.status, 200);
|
||||||
|
const refreshed = expectSuccess<{
|
||||||
|
access_token: string;
|
||||||
|
access_token_expires_in: number;
|
||||||
|
}>(refresh.body).data;
|
||||||
|
assert.ok(refreshed.access_token);
|
||||||
|
const refreshedCookie = refreshCookie(refresh.response);
|
||||||
|
|
||||||
|
const reusedRefresh = await request('/api/auth/refresh', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { Cookie: loginRefreshCookie },
|
||||||
|
});
|
||||||
|
assert.equal(reusedRefresh.response.status, 401);
|
||||||
|
assert.equal(
|
||||||
|
expectError(reusedRefresh.body).error.code,
|
||||||
|
'SESSION_EXPIRED',
|
||||||
|
);
|
||||||
|
|
||||||
|
const logout = await request('/api/auth/logout', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${refreshed.access_token}`,
|
||||||
|
Cookie: refreshedCookie,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
assert.equal(logout.response.status, 204);
|
||||||
|
assert.equal(logout.body, undefined);
|
||||||
|
|
||||||
|
const revoked = await request('/api/auth/me', {
|
||||||
|
headers: { Authorization: `Bearer ${refreshed.access_token}` },
|
||||||
|
});
|
||||||
|
assert.equal(revoked.response.status, 401);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('拒绝错误密码、无效访问令牌和超过邮箱限额的登录请求', async () => {
|
||||||
|
const accountEmail = email('rejected');
|
||||||
|
const wrongPassword = await request('/api/auth/login', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ email: accountEmail, password }),
|
||||||
|
});
|
||||||
|
assert.equal(wrongPassword.response.status, 401);
|
||||||
|
const wrongPasswordError = expectError(wrongPassword.body).error;
|
||||||
|
assert.equal(wrongPasswordError.code, 'INVALID_CREDENTIALS');
|
||||||
|
assert.equal(
|
||||||
|
wrongPasswordError.request_id,
|
||||||
|
wrongPassword.response.headers.get('x-request-id'),
|
||||||
|
);
|
||||||
|
|
||||||
|
const invalidToken = await request('/api/auth/me', {
|
||||||
|
headers: { Authorization: 'Bearer invalid-access-token' },
|
||||||
|
});
|
||||||
|
assert.equal(invalidToken.response.status, 401);
|
||||||
|
assert.equal(expectError(invalidToken.body).error.code, 'HTTP_ERROR');
|
||||||
|
|
||||||
|
const rateLimitedEmail = email('rate-limit');
|
||||||
|
for (const expectedStatus of [401, 401, 429]) {
|
||||||
|
const response = await request('/api/auth/login', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ email: rateLimitedEmail, password }),
|
||||||
|
});
|
||||||
|
assert.equal(response.response.status, expectedStatus);
|
||||||
|
if (expectedStatus === 429) {
|
||||||
|
assert.equal(
|
||||||
|
expectError(response.body).error.code,
|
||||||
|
'AUTH_RATE_LIMITED',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('更新资料遵守版本与幂等约束,改密会撤销旧会话并轮换当前会话', async () => {
|
||||||
|
const accountEmail = email('profile-password');
|
||||||
|
const registration = await request('/api/auth/register', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({
|
||||||
|
email: accountEmail,
|
||||||
|
password,
|
||||||
|
password_confirmation: password,
|
||||||
|
display_name: '原始名称',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const registered = expectSuccess<SessionPayload>(
|
||||||
|
registration.body,
|
||||||
|
).data;
|
||||||
|
testUsers.push({
|
||||||
|
userId: registered.user.id,
|
||||||
|
workspaceId: registered.workspace.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
const profileKey = randomUUID();
|
||||||
|
const update = await request('/api/auth/me', {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${registered.access_token}`,
|
||||||
|
'If-Match': `"${registered.user.revision}"`,
|
||||||
|
'Idempotency-Key': profileKey,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ display_name: '更新后的名称' }),
|
||||||
|
});
|
||||||
|
assert.equal(update.response.status, 200);
|
||||||
|
const updatedUser = expectSuccess<SessionPayload['user']>(
|
||||||
|
update.body,
|
||||||
|
).data;
|
||||||
|
assert.equal(updatedUser.display_name, '更新后的名称');
|
||||||
|
assert.equal(updatedUser.avatar_asset_id, null);
|
||||||
|
assert.equal(updatedUser.revision, registered.user.revision + 1);
|
||||||
|
|
||||||
|
const replay = await request('/api/auth/me', {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${registered.access_token}`,
|
||||||
|
'If-Match': `"${registered.user.revision}"`,
|
||||||
|
'Idempotency-Key': profileKey,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ display_name: '更新后的名称' }),
|
||||||
|
});
|
||||||
|
assert.equal(replay.response.status, 200);
|
||||||
|
assert.deepEqual(replay.body, update.body);
|
||||||
|
|
||||||
|
const stale = await request('/api/auth/me', {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${registered.access_token}`,
|
||||||
|
'If-Match': `"${registered.user.revision}"`,
|
||||||
|
'Idempotency-Key': randomUUID(),
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ display_name: '过期修改' }),
|
||||||
|
});
|
||||||
|
assert.equal(stale.response.status, 409);
|
||||||
|
assert.equal(expectError(stale.body).error.code, 'VERSION_CONFLICT');
|
||||||
|
|
||||||
|
const invalidAvatar = await request('/api/auth/me', {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${registered.access_token}`,
|
||||||
|
'If-Match': `"${updatedUser.revision}"`,
|
||||||
|
'Idempotency-Key': randomUUID(),
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ avatar_asset_id: randomUUID() }),
|
||||||
|
});
|
||||||
|
assert.equal(invalidAvatar.response.status, 422);
|
||||||
|
assert.equal(
|
||||||
|
expectError(invalidAvatar.body).error.code,
|
||||||
|
'AVATAR_ASSET_UNAVAILABLE',
|
||||||
|
);
|
||||||
|
|
||||||
|
const secondLogin = await request('/api/auth/login', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ email: accountEmail, password }),
|
||||||
|
});
|
||||||
|
const otherSession = expectSuccess<SessionPayload>(
|
||||||
|
secondLogin.body,
|
||||||
|
).data;
|
||||||
|
|
||||||
|
const changed = await request('/api/auth/change-password', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { Authorization: `Bearer ${registered.access_token}` },
|
||||||
|
body: JSON.stringify({
|
||||||
|
old_password: password,
|
||||||
|
password: 'new-correct-horse-battery-staple-456',
|
||||||
|
password_confirmation: 'new-correct-horse-battery-staple-456',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
assert.equal(changed.response.status, 200);
|
||||||
|
const rotated = expectSuccess<{
|
||||||
|
access_token: string;
|
||||||
|
access_token_expires_in: number;
|
||||||
|
}>(changed.body).data;
|
||||||
|
assert.ok(rotated.access_token);
|
||||||
|
refreshCookie(changed.response);
|
||||||
|
|
||||||
|
const formerCurrent = await request('/api/auth/me', {
|
||||||
|
headers: { Authorization: `Bearer ${registered.access_token}` },
|
||||||
|
});
|
||||||
|
assert.equal(formerCurrent.response.status, 401);
|
||||||
|
const revokedOther = await request('/api/auth/me', {
|
||||||
|
headers: { Authorization: `Bearer ${otherSession.access_token}` },
|
||||||
|
});
|
||||||
|
assert.equal(revokedOther.response.status, 401);
|
||||||
|
const current = await request('/api/auth/me', {
|
||||||
|
headers: { Authorization: `Bearer ${rotated.access_token}` },
|
||||||
|
});
|
||||||
|
assert.equal(current.response.status, 200);
|
||||||
|
|
||||||
|
const oldPasswordChange = await request('/api/auth/change-password', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { Authorization: `Bearer ${rotated.access_token}` },
|
||||||
|
body: JSON.stringify({
|
||||||
|
old_password: password,
|
||||||
|
password: 'another-correct-horse-battery-staple-789',
|
||||||
|
password_confirmation:
|
||||||
|
'another-correct-horse-battery-staple-789',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
assert.equal(oldPasswordChange.response.status, 422);
|
||||||
|
assert.equal(
|
||||||
|
expectError(oldPasswordChange.body).error.code,
|
||||||
|
'VALIDATION_FAILED',
|
||||||
|
);
|
||||||
|
const newPasswordLogin = await request('/api/auth/login', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({
|
||||||
|
email: accountEmail,
|
||||||
|
password: 'new-correct-horse-battery-staple-456',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
assert.equal(newPasswordLogin.response.status, 200);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
import type { AddressInfo } from 'node:net';
|
||||||
|
import type { Database } from '@atlas/database';
|
||||||
|
import type { NestExpressApplication } from '@nestjs/platform-express';
|
||||||
|
import { afterAll, beforeAll, describe, it } from 'vitest';
|
||||||
|
import { DATABASE } from '../src/database/database.module.js';
|
||||||
|
import { createApplication } from '../src/main.js';
|
||||||
|
|
||||||
|
let app: NestExpressApplication; let database: Database; let baseUrl: string; let userId = ''; let workspaceId = ''; let token = ''; let platformId = '';
|
||||||
|
async function request(path: string, init: RequestInit = {}) { const response = await fetch(`${baseUrl}${path}`, { ...init, headers: { 'Content-Type': 'application/json', ...init.headers } }); return { response, body: response.status === 204 ? undefined : await response.json() as any }; }
|
||||||
|
function headers(extra: Record<string, string> = {}) { return { Authorization: `Bearer ${token}`, ...extra }; }
|
||||||
|
describe('采集箱 HTTP 集成测试', () => {
|
||||||
|
beforeAll(async () => { Object.assign(process.env, { NODE_ENV: 'test', SWAGGER_ENABLED: 'false', AUTH_REGISTER_IP_LIMIT: '100', AUTH_REGISTER_EMAIL_LIMIT: '100' }); const application = await createApplication(); app = application.app; database = app.get<Database>(DATABASE); await app.listen(0, '127.0.0.1'); baseUrl = `http://127.0.0.1:${(app.getHttpServer().address() as AddressInfo).port}`; const registration = await request('/api/auth/register', { method: 'POST', body: JSON.stringify({ email: `collection-${randomUUID()}@example.test`, password: 'correct-horse-battery-staple-123', password_confirmation: 'correct-horse-battery-staple-123' }) }); assert.equal(registration.response.status, 201); token = registration.body.data.access_token; userId = registration.body.data.user.id; workspaceId = registration.body.data.workspace.id; const platforms = await request('/api/platforms', { headers: headers() }); platformId = platforms.body.data[0].id; });
|
||||||
|
afterAll(async () => { await database.workspaceTransaction(workspaceId, async (tx) => { await tx.query('DELETE FROM atlas.idempotency_requests WHERE workspace_id = $1', [workspaceId]); await tx.query('DELETE FROM atlas.outbox_events WHERE workspace_id = $1', [workspaceId]); await tx.query('DELETE FROM atlas.operations WHERE workspace_id = $1', [workspaceId]); await tx.query('DELETE FROM atlas.product_variants WHERE workspace_id = $1', [workspaceId]); await tx.query('DELETE FROM atlas.products WHERE workspace_id = $1', [workspaceId]); await tx.query('DELETE FROM atlas.collection_items WHERE workspace_id = $1', [workspaceId]); }); await database.query('DELETE FROM atlas.workspace_memberships WHERE user_id = $1', [userId]); await database.query('DELETE FROM atlas.workspaces WHERE id = $1', [workspaceId]); await database.query('DELETE FROM atlas.users WHERE id = $1', [userId]); await app.close(); });
|
||||||
|
it('创建、查询、乐观锁更新与软删除采集草稿', async () => { const key = randomUUID(); const created = await request('/api/collection-items', { method: 'POST', headers: headers({ 'Idempotency-Key': key }), body: JSON.stringify({ title: '手动商品', note: '初始备注' }) }); assert.equal(created.response.status, 201); const item = created.body.data.collection_item; assert.equal(item.variant_summary.count, 1); const replay = await request('/api/collection-items', { method: 'POST', headers: headers({ 'Idempotency-Key': key }), body: JSON.stringify({ title: '手动商品', note: '初始备注' }) }); assert.deepEqual(replay.body, created.body); const listed = await request('/api/collection-items', { headers: headers({ 'X-Atlas-Platform-Id': platformId }) }); assert.equal(listed.response.status, 200); assert.equal(listed.body.data[0].id, item.id); const updated = await request(`/api/collection-items/${item.id}`, { method: 'PATCH', headers: headers({ 'Idempotency-Key': randomUUID(), 'If-Match': `"${item.revision}"` }), body: JSON.stringify({ note: '已修改' }) }); assert.equal(updated.response.status, 200); const stale = await request(`/api/collection-items/${item.id}`, { method: 'PATCH', headers: headers({ 'Idempotency-Key': randomUUID(), 'If-Match': `"${item.revision}"` }), body: JSON.stringify({ note: '过期写入' }) }); assert.equal(stale.response.status, 409); const removed = await request('/api/collection-items/bulk-delete', { method: 'POST', headers: headers({ 'Idempotency-Key': randomUUID() }), body: JSON.stringify({ ids: [item.id] }) }); assert.equal(removed.response.status, 202); assert.equal(removed.body.data.deleted_count, 1); });
|
||||||
|
});
|
||||||
@@ -0,0 +1,434 @@
|
|||||||
|
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 = '';
|
||||||
|
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;
|
||||||
|
const 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 first = await request('/api/extension/materials', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${webAccessToken}`,
|
||||||
|
'Idempotency-Key': randomUUID(),
|
||||||
|
},
|
||||||
|
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 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/${first.body.data.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);
|
||||||
|
// 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 = '人工确认商品名'
|
||||||
|
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.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.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.8000');
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,420 @@
|
|||||||
|
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 } };
|
||||||
|
type Session = {
|
||||||
|
access_token: string;
|
||||||
|
user: { id: string };
|
||||||
|
workspace: { id: string };
|
||||||
|
};
|
||||||
|
type Store = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
is_default: boolean;
|
||||||
|
status: string;
|
||||||
|
credential_status: string;
|
||||||
|
revision: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
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) {
|
||||||
|
return `${label}-${randomUUID()}@example.test`;
|
||||||
|
}
|
||||||
|
|
||||||
|
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(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function success<T>(body: unknown): SuccessResponse<T> {
|
||||||
|
assert.ok(body && typeof body === 'object' && 'data' in body);
|
||||||
|
return body as SuccessResponse<T>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function error(body: unknown): ErrorResponse {
|
||||||
|
assert.ok(body && typeof body === 'object' && 'error' in body);
|
||||||
|
return body as ErrorResponse;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function register(label: string): Promise<Session> {
|
||||||
|
const result = await request('/api/auth/register', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({
|
||||||
|
email: email(label),
|
||||||
|
password,
|
||||||
|
password_confirmation: password,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
assert.equal(result.response.status, 201);
|
||||||
|
const session = success<Session>(result.body).data;
|
||||||
|
testUsers.push({
|
||||||
|
userId: session.user.id,
|
||||||
|
workspaceId: session.workspace.id,
|
||||||
|
});
|
||||||
|
return session;
|
||||||
|
}
|
||||||
|
|
||||||
|
function headers(session: Session, idempotencyKey?: string) {
|
||||||
|
return {
|
||||||
|
Authorization: `Bearer ${session.access_token}`,
|
||||||
|
...(idempotencyKey ? { 'Idempotency-Key': idempotencyKey } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('门店 HTTP 集成测试', () => {
|
||||||
|
beforeAll(async () => {
|
||||||
|
Object.assign(process.env, {
|
||||||
|
NODE_ENV: 'test',
|
||||||
|
SWAGGER_ENABLED: 'false',
|
||||||
|
AUTH_LOGIN_IP_LIMIT: '100',
|
||||||
|
AUTH_LOGIN_EMAIL_LIMIT: '100',
|
||||||
|
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) {
|
||||||
|
// 门店测试会创建操作和 Outbox;先在工作空间事务内清理这些受 RLS 保护的记录。
|
||||||
|
await database.workspaceTransaction(
|
||||||
|
user.workspaceId,
|
||||||
|
async (tx) => {
|
||||||
|
await tx.query(
|
||||||
|
'DELETE FROM atlas.idempotency_requests WHERE workspace_id = $1',
|
||||||
|
[user.workspaceId],
|
||||||
|
);
|
||||||
|
await tx.query(
|
||||||
|
'DELETE FROM atlas.outbox_events WHERE workspace_id = $1',
|
||||||
|
[user.workspaceId],
|
||||||
|
);
|
||||||
|
await tx.query(
|
||||||
|
'DELETE FROM atlas.operations WHERE workspace_id = $1',
|
||||||
|
[user.workspaceId],
|
||||||
|
);
|
||||||
|
await tx.query(
|
||||||
|
'DELETE FROM atlas.stores 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('创建门店时加密保存凭据、创建操作,并能安全重放相同请求', async () => {
|
||||||
|
const owner = await register('stores-owner');
|
||||||
|
const platforms = await request('/api/platforms', {
|
||||||
|
headers: headers(owner),
|
||||||
|
});
|
||||||
|
assert.equal(platforms.response.status, 200);
|
||||||
|
const platform = success<
|
||||||
|
Array<{
|
||||||
|
id: string;
|
||||||
|
code: string;
|
||||||
|
default_market_code: string | null;
|
||||||
|
default_locale: string | null;
|
||||||
|
}>
|
||||||
|
>(platforms.body).data.find((item) => item.code === 'OZON');
|
||||||
|
assert.ok(platform);
|
||||||
|
assert.equal(platform.default_market_code, 'RU');
|
||||||
|
assert.equal(platform.default_locale, 'ru-RU');
|
||||||
|
|
||||||
|
const idempotencyKey = randomUUID();
|
||||||
|
const createBody = {
|
||||||
|
platform_id: platform.id,
|
||||||
|
name: '测试 Ozon 主店',
|
||||||
|
client_id: `client-${randomUUID()}`,
|
||||||
|
api_key: 'secret-only-in-request',
|
||||||
|
currency_code: 'RUB',
|
||||||
|
is_default: false,
|
||||||
|
note: '首家门店仍应成为默认门店',
|
||||||
|
};
|
||||||
|
const created = await request('/api/stores', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: headers(owner, idempotencyKey),
|
||||||
|
body: JSON.stringify(createBody),
|
||||||
|
});
|
||||||
|
assert.equal(created.response.status, 202);
|
||||||
|
const createdData = success<{
|
||||||
|
store: Store & { platform_client_id: string };
|
||||||
|
operation: { id: string; status: string };
|
||||||
|
}>(created.body).data;
|
||||||
|
assert.equal(createdData.store.is_default, true);
|
||||||
|
assert.equal(createdData.store.status, 'verifying');
|
||||||
|
assert.equal(createdData.store.credential_status, 'unknown');
|
||||||
|
assert.equal(
|
||||||
|
createdData.store.platform_client_id,
|
||||||
|
createBody.client_id,
|
||||||
|
);
|
||||||
|
assert.equal(createdData.operation.status, 'queued');
|
||||||
|
assert.equal(
|
||||||
|
JSON.stringify(created.body).includes(createBody.api_key),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
|
||||||
|
const replay = await request('/api/stores', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: headers(owner, idempotencyKey),
|
||||||
|
body: JSON.stringify(createBody),
|
||||||
|
});
|
||||||
|
assert.equal(replay.response.status, 202);
|
||||||
|
assert.deepEqual(replay.body, created.body);
|
||||||
|
|
||||||
|
const mismatchedReplay = await request('/api/stores', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: headers(owner, idempotencyKey),
|
||||||
|
body: JSON.stringify({ ...createBody, name: '不同请求' }),
|
||||||
|
});
|
||||||
|
assert.equal(mismatchedReplay.response.status, 409);
|
||||||
|
assert.equal(
|
||||||
|
error(mismatchedReplay.body).error.code,
|
||||||
|
'IDEMPOTENCY_KEY_REUSED',
|
||||||
|
);
|
||||||
|
|
||||||
|
const context = await request('/api/platform-context', {
|
||||||
|
headers: {
|
||||||
|
...headers(owner),
|
||||||
|
'X-Atlas-Platform-Id': platform.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
assert.equal(context.response.status, 200);
|
||||||
|
assert.equal(
|
||||||
|
success<{ store_count: number; default_store: { id: string } }>(
|
||||||
|
context.body,
|
||||||
|
).data.store_count,
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
|
||||||
|
const operation = await request(
|
||||||
|
`/api/operations/${createdData.operation.id}`,
|
||||||
|
{ headers: headers(owner) },
|
||||||
|
);
|
||||||
|
assert.equal(operation.response.status, 200);
|
||||||
|
assert.equal(
|
||||||
|
success<{ status: string }>(operation.body).data.status,
|
||||||
|
'queued',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('同步刷新门店类目:等待 Ozon 返回并完整落库后才成功', async () => {
|
||||||
|
const owner = await register('stores-category-refresh');
|
||||||
|
const platforms = await request('/api/platforms', {
|
||||||
|
headers: headers(owner),
|
||||||
|
});
|
||||||
|
const platform = success<Array<{ id: string; code: string }>>(
|
||||||
|
platforms.body,
|
||||||
|
).data.find((item) => item.code === 'OZON');
|
||||||
|
assert.ok(platform);
|
||||||
|
const store = await createStore(owner, platform.id, '类目刷新测试店');
|
||||||
|
await database.workspaceTransaction(owner.workspace.id, async (tx) => {
|
||||||
|
await tx.query(
|
||||||
|
`UPDATE atlas.stores
|
||||||
|
SET status = 'active', credential_status = 'valid'
|
||||||
|
WHERE id = $1`,
|
||||||
|
[store.id],
|
||||||
|
);
|
||||||
|
await tx.query(
|
||||||
|
`UPDATE atlas.store_secrets SET status = 'valid'
|
||||||
|
WHERE store_id = $1 AND kind = 'seller_api_key' AND revoked_at IS NULL`,
|
||||||
|
[store.id],
|
||||||
|
);
|
||||||
|
await tx.query(
|
||||||
|
`INSERT INTO atlas.store_product_categories
|
||||||
|
(workspace_id, store_id, platform_id, external_category_id, external_type_id,
|
||||||
|
name, path, synced_at)
|
||||||
|
VALUES (current_setting('app.workspace_id')::uuid, $1, $2,
|
||||||
|
'cached-category', 'cached-type', '已有缓存', '已有缓存', now())`,
|
||||||
|
[store.id, platform.id],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const originalFetch = globalThis.fetch;
|
||||||
|
let ozonCalls = 0;
|
||||||
|
globalThis.fetch = async (input, init) => {
|
||||||
|
if (String(input).startsWith('https://api-seller.ozon.ru/')) {
|
||||||
|
ozonCalls += 1;
|
||||||
|
assert.equal(init?.method, 'POST');
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
result: [
|
||||||
|
{
|
||||||
|
description_category_id: 17028750,
|
||||||
|
category_name: '汽车脚垫和车座套',
|
||||||
|
disabled: false,
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
type_name: '汽车罩',
|
||||||
|
type_id: 93587,
|
||||||
|
disabled: false,
|
||||||
|
children: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
{ status: 200, headers: { 'Content-Type': 'application/json' } },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return originalFetch(input, init);
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
const idempotencyKey = randomUUID();
|
||||||
|
const first = await request(`/api/stores/${store.id}/category-tree-syncs`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: headers(owner, idempotencyKey),
|
||||||
|
});
|
||||||
|
assert.equal(first.response.status, 200);
|
||||||
|
assert.deepEqual(
|
||||||
|
success<{ category_count: number; active_category_count: number }>(first.body).data,
|
||||||
|
{ category_count: 2, active_category_count: 1 },
|
||||||
|
);
|
||||||
|
|
||||||
|
const replay = await request(`/api/stores/${store.id}/category-tree-syncs`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: headers(owner, idempotencyKey),
|
||||||
|
});
|
||||||
|
assert.equal(replay.response.status, 200);
|
||||||
|
assert.deepEqual(replay.body, first.body);
|
||||||
|
assert.equal(ozonCalls, 1);
|
||||||
|
|
||||||
|
await database.workspaceTransaction(owner.workspace.id, async (tx) => {
|
||||||
|
const categories = await tx.query<{
|
||||||
|
external_category_id: string;
|
||||||
|
external_type_id: string;
|
||||||
|
name: string;
|
||||||
|
path: string;
|
||||||
|
is_active: boolean;
|
||||||
|
}>(
|
||||||
|
`SELECT external_category_id, external_type_id, name, path, is_active
|
||||||
|
FROM atlas.store_product_categories
|
||||||
|
WHERE store_id = $1 ORDER BY path`,
|
||||||
|
[store.id],
|
||||||
|
);
|
||||||
|
assert.deepEqual(categories.rows.filter((row) => row.is_active), [
|
||||||
|
{
|
||||||
|
external_category_id: '17028750',
|
||||||
|
external_type_id: '93587',
|
||||||
|
name: '汽车罩',
|
||||||
|
path: '汽车脚垫和车座套 › 汽车罩',
|
||||||
|
is_active: true,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
globalThis.fetch = originalFetch;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('更新版本、切换默认门店、删除和工作空间隔离都遵守契约', async () => {
|
||||||
|
const owner = await register('stores-lifecycle');
|
||||||
|
const otherOwner = await register('stores-isolation');
|
||||||
|
const platforms = await request('/api/platforms', {
|
||||||
|
headers: headers(owner),
|
||||||
|
});
|
||||||
|
const platform = success<Array<{ id: string; code: string }>>(
|
||||||
|
platforms.body,
|
||||||
|
).data.find((item) => item.code === 'OZON');
|
||||||
|
assert.ok(platform);
|
||||||
|
|
||||||
|
const first = await createStore(owner, platform.id, '第一家门店');
|
||||||
|
const updated = await request(`/api/stores/${first.id}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: {
|
||||||
|
...headers(owner, randomUUID()),
|
||||||
|
'If-Match': `"${first.revision}"`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ name: '第一家门店(已修改)' }),
|
||||||
|
});
|
||||||
|
assert.equal(updated.response.status, 200);
|
||||||
|
const updatedStore = success<Store>(updated.body).data;
|
||||||
|
assert.equal(updatedStore.revision, first.revision + 1);
|
||||||
|
|
||||||
|
const stale = await request(`/api/stores/${first.id}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: {
|
||||||
|
...headers(owner, randomUUID()),
|
||||||
|
'If-Match': `"${first.revision}"`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ name: '过期修改' }),
|
||||||
|
});
|
||||||
|
assert.equal(stale.response.status, 409);
|
||||||
|
assert.equal(error(stale.body).error.code, 'VERSION_CONFLICT');
|
||||||
|
|
||||||
|
const second = await createStore(owner, platform.id, '第二家门店');
|
||||||
|
const makeDefault = await request(
|
||||||
|
`/api/stores/${second.id}/set-default`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
headers: headers(owner, randomUUID()),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
assert.equal(makeDefault.response.status, 200);
|
||||||
|
assert.equal(success<Store>(makeDefault.body).data.is_default, true);
|
||||||
|
|
||||||
|
const foreignRead = await request(`/api/stores/${first.id}`, {
|
||||||
|
headers: headers(otherOwner),
|
||||||
|
});
|
||||||
|
assert.equal(foreignRead.response.status, 404);
|
||||||
|
|
||||||
|
const deleted = await request(`/api/stores/${first.id}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: headers(owner, randomUUID()),
|
||||||
|
});
|
||||||
|
assert.equal(deleted.response.status, 204);
|
||||||
|
const absent = await request(`/api/stores/${first.id}`, {
|
||||||
|
headers: headers(owner),
|
||||||
|
});
|
||||||
|
assert.equal(absent.response.status, 404);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
async function createStore(owner: Session, platformId: string, name: string) {
|
||||||
|
const result = await request('/api/stores', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: headers(owner, randomUUID()),
|
||||||
|
body: JSON.stringify({
|
||||||
|
platform_id: platformId,
|
||||||
|
name,
|
||||||
|
client_id: `client-${randomUUID()}`,
|
||||||
|
api_key: `key-${randomUUID()}`,
|
||||||
|
currency_code: 'RUB',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
assert.equal(result.response.status, 202);
|
||||||
|
return success<{ store: Store }>(result.body).data.store;
|
||||||
|
}
|
||||||
@@ -0,0 +1,250 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
import type { AddressInfo } from 'node:net';
|
||||||
|
import type { Database } from '@atlas/database';
|
||||||
|
import type { NestExpressApplication } from '@nestjs/platform-express';
|
||||||
|
import { afterAll, beforeAll, describe, it } from 'vitest';
|
||||||
|
import { DATABASE } from '../src/database/database.module.js';
|
||||||
|
import { createApplication } from '../src/main.js';
|
||||||
|
import { OutboxDispatcher } from '../../worker/src/outbox-dispatcher.js';
|
||||||
|
import { flattenCategories } from '../../worker/src/ozon-catalog-sync.js';
|
||||||
|
import { scheduleStoreCategoryTreeSync } from '../../worker/src/store-category-tree-scheduler.js';
|
||||||
|
|
||||||
|
let app: NestExpressApplication;
|
||||||
|
let database: Database;
|
||||||
|
let baseUrl = '';
|
||||||
|
let workspaceId = '';
|
||||||
|
let userId = '';
|
||||||
|
let token = '';
|
||||||
|
let platformId = '';
|
||||||
|
let storeId = '';
|
||||||
|
let secretId = '';
|
||||||
|
|
||||||
|
async function request(path: string, init: RequestInit = {}) {
|
||||||
|
const response = await fetch(`${baseUrl}${path}`, {
|
||||||
|
...init,
|
||||||
|
headers: { 'Content-Type': 'application/json', ...init.headers },
|
||||||
|
});
|
||||||
|
return { response, body: (await response.json()) as any };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function scheduleWithStoreLock() {
|
||||||
|
return database.workspaceTransaction(workspaceId, async (transaction) => {
|
||||||
|
await transaction.query(
|
||||||
|
'SELECT id FROM atlas.stores WHERE id = $1 FOR UPDATE',
|
||||||
|
[storeId],
|
||||||
|
);
|
||||||
|
return scheduleStoreCategoryTreeSync(transaction, {
|
||||||
|
storeId,
|
||||||
|
platformId,
|
||||||
|
secretId,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('门店类目树 Worker 调度集成测试', () => {
|
||||||
|
it('类型叶子继承父类目 ID,并使用 type_name 作为可选项名称', () => {
|
||||||
|
const records = flattenCategories([
|
||||||
|
{
|
||||||
|
description_category_id: 17027495,
|
||||||
|
category_name: '汽车用品',
|
||||||
|
disabled: false,
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
description_category_id: 17028750,
|
||||||
|
category_name: '汽车脚垫和车座套',
|
||||||
|
disabled: false,
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
type_name: '汽车罩',
|
||||||
|
type_id: 93587,
|
||||||
|
disabled: false,
|
||||||
|
children: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
assert.deepEqual(
|
||||||
|
records.map((record) => ({
|
||||||
|
category: record.external_category_id,
|
||||||
|
type: record.external_type_id,
|
||||||
|
name: record.name,
|
||||||
|
path: record.path,
|
||||||
|
parent: record.parent_key,
|
||||||
|
active: record.is_active,
|
||||||
|
})),
|
||||||
|
[
|
||||||
|
{
|
||||||
|
category: '17027495', type: '0', name: '汽车用品', path: '汽车用品',
|
||||||
|
parent: null, active: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
category: '17028750', type: '0', name: '汽车脚垫和车座套',
|
||||||
|
path: '汽车用品 › 汽车脚垫和车座套', parent: '17027495:0', active: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
category: '17028750', type: '93587', name: '汽车罩',
|
||||||
|
path: '汽车用品 › 汽车脚垫和车座套 › 汽车罩', parent: '17028750:0', active: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
Object.assign(process.env, {
|
||||||
|
NODE_ENV: 'test',
|
||||||
|
SWAGGER_ENABLED: 'false',
|
||||||
|
AUTH_REGISTER_IP_LIMIT: '100',
|
||||||
|
AUTH_REGISTER_EMAIL_LIMIT: '100',
|
||||||
|
});
|
||||||
|
const created = await createApplication();
|
||||||
|
app = created.app;
|
||||||
|
database = app.get<Database>(DATABASE);
|
||||||
|
await app.listen(0, '127.0.0.1');
|
||||||
|
baseUrl = `http://127.0.0.1:${(app.getHttpServer().address() as AddressInfo).port}`;
|
||||||
|
const registration = await request('/api/auth/register', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({
|
||||||
|
email: `catalog-worker-${randomUUID()}@example.test`,
|
||||||
|
password: 'correct-horse-battery-staple-123',
|
||||||
|
password_confirmation: 'correct-horse-battery-staple-123',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
assert.equal(registration.response.status, 201);
|
||||||
|
workspaceId = registration.body.data.workspace.id;
|
||||||
|
userId = registration.body.data.user.id;
|
||||||
|
token = registration.body.data.access_token;
|
||||||
|
const platforms = await request('/api/platforms', {
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
});
|
||||||
|
platformId = platforms.body.data[0].id;
|
||||||
|
const seeded = await database.workspaceTransaction(workspaceId, async (tx) => {
|
||||||
|
const store = await tx.query<{ id: string }>(
|
||||||
|
`INSERT INTO atlas.stores
|
||||||
|
(workspace_id, platform_id, name, platform_client_id, currency_code,
|
||||||
|
is_default, status, credential_status)
|
||||||
|
VALUES (current_setting('app.workspace_id')::uuid, $1, '类目 Worker 测试店',
|
||||||
|
$2, 'RUB', true, 'active', 'valid')
|
||||||
|
RETURNING id`,
|
||||||
|
[platformId, `catalog-worker-${randomUUID()}`],
|
||||||
|
);
|
||||||
|
const secret = await tx.query<{ id: string }>(
|
||||||
|
`INSERT INTO atlas.store_secrets
|
||||||
|
(workspace_id, store_id, kind, secret_ciphertext, secret_nonce,
|
||||||
|
encryption_key_version, fingerprint, status)
|
||||||
|
VALUES (current_setting('app.workspace_id')::uuid, $1, 'seller_api_key',
|
||||||
|
$2, $3, 'test', $4, 'valid')
|
||||||
|
RETURNING id`,
|
||||||
|
[
|
||||||
|
store.rows[0].id,
|
||||||
|
Buffer.from('catalog-worker-api-key-must-not-leak'),
|
||||||
|
Buffer.from('catalog-worker-nonce'),
|
||||||
|
randomUUID(),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
return { storeId: store.rows[0].id, secretId: secret.rows[0].id };
|
||||||
|
});
|
||||||
|
storeId = seeded.storeId;
|
||||||
|
secretId = seeded.secretId;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await database.workspaceTransaction(workspaceId, async (tx) => {
|
||||||
|
await tx.query('DELETE FROM atlas.outbox_events WHERE workspace_id = $1', [workspaceId]);
|
||||||
|
await tx.query('DELETE FROM atlas.operations WHERE workspace_id = $1', [workspaceId]);
|
||||||
|
await tx.query('DELETE FROM atlas.store_product_categories WHERE workspace_id = $1', [workspaceId]);
|
||||||
|
await tx.query('DELETE FROM atlas.stores WHERE workspace_id = $1', [workspaceId]);
|
||||||
|
});
|
||||||
|
await database.query('DELETE FROM atlas.workspace_memberships WHERE user_id = $1', [userId]);
|
||||||
|
await database.query('DELETE FROM atlas.workspaces WHERE id = $1', [workspaceId]);
|
||||||
|
await database.query('DELETE FROM atlas.users WHERE id = $1', [userId]);
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('空缓存的并发验证只创建一个无密钥的树同步任务', async () => {
|
||||||
|
const results = await Promise.all([scheduleWithStoreLock(), scheduleWithStoreLock()]);
|
||||||
|
assert.equal(results.filter((result) => result.scheduled).length, 1);
|
||||||
|
assert.ok(results.some((result) => !result.scheduled && result.reason === 'already_queued'));
|
||||||
|
await database.workspaceTransaction(workspaceId, async (tx) => {
|
||||||
|
const events = await tx.query<{ event_payload: unknown }>(
|
||||||
|
`SELECT event_payload FROM atlas.outbox_events
|
||||||
|
WHERE aggregate_id = $1 AND event_type = 'store_category_tree_sync.requested'`,
|
||||||
|
[storeId],
|
||||||
|
);
|
||||||
|
assert.equal(events.rows.length, 1);
|
||||||
|
const payload = JSON.stringify(events.rows[0].event_payload);
|
||||||
|
assert.ok(!payload.includes('catalog-worker-api-key-must-not-leak'));
|
||||||
|
assert.ok(!payload.includes('catalog-worker-nonce'));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('已有已同步的门店缓存时跳过 Ozon 请求调度', async () => {
|
||||||
|
await database.workspaceTransaction(workspaceId, async (tx) => {
|
||||||
|
await tx.query(
|
||||||
|
`UPDATE atlas.operations SET status = 'succeeded', completed_at = now()
|
||||||
|
WHERE kind = 'store_category_tree_sync' AND resource_id = $1`,
|
||||||
|
[storeId],
|
||||||
|
);
|
||||||
|
await tx.query(
|
||||||
|
`INSERT INTO atlas.store_product_categories
|
||||||
|
(workspace_id, store_id, platform_id, external_category_id, external_type_id,
|
||||||
|
name, path, synced_at)
|
||||||
|
VALUES (current_setting('app.workspace_id')::uuid, $1, $2,
|
||||||
|
'worker-cache', 'worker-type', '已缓存类目', '已缓存类目', now())`,
|
||||||
|
[storeId, platformId],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
const result = await scheduleWithStoreLock();
|
||||||
|
assert.deepEqual(result, { scheduled: false, reason: 'cache_available' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('为 Ozon 暂时失败配置五次指数退避', async () => {
|
||||||
|
const enqueued: Array<{ name: string; options: Record<string, unknown> }> = [];
|
||||||
|
const dispatcher = new OutboxDispatcher(
|
||||||
|
{
|
||||||
|
query: async () => ({ rows: [{ id: workspaceId }] }),
|
||||||
|
workspaceTransaction: async (_workspaceId: string, work: any) =>
|
||||||
|
work({
|
||||||
|
query: async (sql: string) =>
|
||||||
|
sql.includes('RETURNING event.id')
|
||||||
|
? {
|
||||||
|
rows: [
|
||||||
|
{
|
||||||
|
id: randomUUID(),
|
||||||
|
workspace_id: workspaceId,
|
||||||
|
event_type: 'store_category_tree_sync.requested',
|
||||||
|
event_payload: {
|
||||||
|
store_id: storeId,
|
||||||
|
secret_id: secretId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
: { rows: [] },
|
||||||
|
}),
|
||||||
|
} as never,
|
||||||
|
{
|
||||||
|
add: async (name: string, _data: unknown, options: Record<string, unknown>) => {
|
||||||
|
enqueued.push({ name, options });
|
||||||
|
},
|
||||||
|
} as never,
|
||||||
|
'worker-test',
|
||||||
|
);
|
||||||
|
await dispatcher.dispatchBatch();
|
||||||
|
assert.deepEqual(enqueued, [
|
||||||
|
{
|
||||||
|
name: 'store_category_tree_sync.requested',
|
||||||
|
options: {
|
||||||
|
jobId: enqueued[0]?.options.jobId,
|
||||||
|
attempts: 5,
|
||||||
|
backoff: { type: 'exponential', delay: 5_000 },
|
||||||
|
removeOnComplete: 1_000,
|
||||||
|
removeOnFail: 1_000,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"rootDir": "src",
|
||||||
|
"outDir": "dist",
|
||||||
|
"types": ["node"],
|
||||||
|
"experimentalDecorators": true,
|
||||||
|
"emitDecoratorMetadata": true
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"extends": "./tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"rootDir": "..",
|
||||||
|
"noEmit": true
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts", "test/**/*.ts", "vitest.config.ts"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { defineConfig } from 'vitest/config';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
test: {
|
||||||
|
include: ['test/**/*.test.ts'],
|
||||||
|
fileParallelism: false,
|
||||||
|
testTimeout: 30_000,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"lib": [
|
||||||
|
"ESNext",
|
||||||
|
"DOM",
|
||||||
|
"DOM.Iterable"
|
||||||
|
],
|
||||||
|
"target": "ESNext",
|
||||||
|
"module": "Preserve",
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"strict": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"noUncheckedIndexedAccess": true,
|
||||||
|
"noImplicitOverride": true,
|
||||||
|
"paths": {
|
||||||
|
"@": [
|
||||||
|
".."
|
||||||
|
],
|
||||||
|
"@/*": [
|
||||||
|
"../*"
|
||||||
|
],
|
||||||
|
"~": [
|
||||||
|
".."
|
||||||
|
],
|
||||||
|
"~/*": [
|
||||||
|
"../*"
|
||||||
|
],
|
||||||
|
"@@": [
|
||||||
|
".."
|
||||||
|
],
|
||||||
|
"@@/*": [
|
||||||
|
"../*"
|
||||||
|
],
|
||||||
|
"~~": [
|
||||||
|
".."
|
||||||
|
],
|
||||||
|
"~~/*": [
|
||||||
|
"../*"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"../**/*",
|
||||||
|
"./wxt.d.ts"
|
||||||
|
],
|
||||||
|
"exclude": [
|
||||||
|
"../**/node_modules",
|
||||||
|
"../.output"
|
||||||
|
]
|
||||||
|
}
|
||||||
+15
@@ -0,0 +1,15 @@
|
|||||||
|
// Generated by wxt
|
||||||
|
interface ImportMetaEnv {
|
||||||
|
readonly MANIFEST_VERSION: 2 | 3;
|
||||||
|
readonly BROWSER: string;
|
||||||
|
readonly CHROME: boolean;
|
||||||
|
readonly FIREFOX: boolean;
|
||||||
|
readonly SAFARI: boolean;
|
||||||
|
readonly EDGE: boolean;
|
||||||
|
readonly OPERA: boolean;
|
||||||
|
readonly COMMAND: "build" | "serve";
|
||||||
|
readonly ENTRYPOINT: string;
|
||||||
|
}
|
||||||
|
interface ImportMeta {
|
||||||
|
readonly env: ImportMetaEnv
|
||||||
|
}
|
||||||
Vendored
+81
@@ -0,0 +1,81 @@
|
|||||||
|
// Generated by wxt
|
||||||
|
import "wxt/browser";
|
||||||
|
|
||||||
|
declare module "wxt/browser" {
|
||||||
|
/**
|
||||||
|
* See https://developer.chrome.com/docs/extensions/reference/i18n/#method-getMessage
|
||||||
|
*/
|
||||||
|
interface GetMessageOptions {
|
||||||
|
/**
|
||||||
|
* See https://developer.chrome.com/docs/extensions/reference/i18n/#method-getMessage
|
||||||
|
*/
|
||||||
|
escapeLt?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WxtI18n extends I18n.Static {
|
||||||
|
/**
|
||||||
|
* The extension or app ID; you might use this string to construct URLs for resources inside the extension. Even non-localized extensions can use this message.
|
||||||
|
* Note: You can't use this message in a manifest file.
|
||||||
|
*
|
||||||
|
* "<browser.runtime.id>"
|
||||||
|
*/
|
||||||
|
getMessage(
|
||||||
|
messageName: "@@extension_id",
|
||||||
|
substitutions?: string | string[],
|
||||||
|
options?: GetMessageOptions,
|
||||||
|
): string;
|
||||||
|
/**
|
||||||
|
* "<browser.i18n.getUiLocale()>"
|
||||||
|
*/
|
||||||
|
getMessage(
|
||||||
|
messageName: "@@ui_locale",
|
||||||
|
substitutions?: string | string[],
|
||||||
|
options?: GetMessageOptions,
|
||||||
|
): string;
|
||||||
|
/**
|
||||||
|
* The text direction for the current locale, either "ltr" for left-to-right languages such as English or "rtl" for right-to-left languages such as Japanese.
|
||||||
|
*
|
||||||
|
* "<ltr|rtl>"
|
||||||
|
*/
|
||||||
|
getMessage(
|
||||||
|
messageName: "@@bidi_dir",
|
||||||
|
substitutions?: string | string[],
|
||||||
|
options?: GetMessageOptions,
|
||||||
|
): string;
|
||||||
|
/**
|
||||||
|
* If the @@bidi_dir is "ltr", then this is "rtl"; otherwise, it's "ltr".
|
||||||
|
*
|
||||||
|
* "<rtl|ltr>"
|
||||||
|
*/
|
||||||
|
getMessage(
|
||||||
|
messageName: "@@bidi_reversed_dir",
|
||||||
|
substitutions?: string | string[],
|
||||||
|
options?: GetMessageOptions,
|
||||||
|
): string;
|
||||||
|
/**
|
||||||
|
* If the @@bidi_dir is "ltr", then this is "left"; otherwise, it's "right".
|
||||||
|
*
|
||||||
|
* "<left|right>"
|
||||||
|
*/
|
||||||
|
getMessage(
|
||||||
|
messageName: "@@bidi_start_edge",
|
||||||
|
substitutions?: string | string[],
|
||||||
|
options?: GetMessageOptions,
|
||||||
|
): string;
|
||||||
|
/**
|
||||||
|
* If the @@bidi_dir is "ltr", then this is "right"; otherwise, it's "left".
|
||||||
|
*
|
||||||
|
* "<right|left>"
|
||||||
|
*/
|
||||||
|
getMessage(
|
||||||
|
messageName: "@@bidi_end_edge",
|
||||||
|
substitutions?: string | string[],
|
||||||
|
options?: GetMessageOptions,
|
||||||
|
): string;
|
||||||
|
getMessage(
|
||||||
|
messageName: "@@extension_id" | "@@ui_locale" | "@@bidi_dir" | "@@bidi_reversed_dir" | "@@bidi_start_edge" | "@@bidi_end_edge",
|
||||||
|
substitutions?: string | string[],
|
||||||
|
options?: GetMessageOptions,
|
||||||
|
): string;
|
||||||
|
}
|
||||||
|
}
|
||||||
+21
@@ -0,0 +1,21 @@
|
|||||||
|
// Generated by wxt
|
||||||
|
// Types for the #import virtual module
|
||||||
|
declare module '#imports' {
|
||||||
|
export { browser, Browser } from 'wxt/browser';
|
||||||
|
export { storage, WxtStorage, WxtStorageItem, StorageArea, StorageItemKey, StorageAreaChanges, MigrationError } from 'wxt/utils/storage';
|
||||||
|
export { getAppConfig, useAppConfig } from 'wxt/utils/app-config';
|
||||||
|
export { ContentScriptContext, WxtWindowEventMap } from 'wxt/utils/content-script-context';
|
||||||
|
export { createIframeUi, IframeContentScriptUi, IframeContentScriptUiOptions } from 'wxt/utils/content-script-ui/iframe';
|
||||||
|
export { createIntegratedUi, IntegratedContentScriptUi, IntegratedContentScriptUiOptions } from 'wxt/utils/content-script-ui/integrated';
|
||||||
|
export { createShadowRootUi, ShadowRootContentScriptUi, ShadowRootContentScriptUiOptions } from 'wxt/utils/content-script-ui/shadow-root';
|
||||||
|
export { ContentScriptUi, ContentScriptUiOptions, ContentScriptOverlayAlignment, ContentScriptAppendMode, ContentScriptInlinePositioningOptions, ContentScriptOverlayPositioningOptions, ContentScriptModalPositioningOptions, ContentScriptPositioningOptions, ContentScriptAnchoredOptions, AutoMountOptions, StopAutoMount, AutoMount } from 'wxt/utils/content-script-ui/types';
|
||||||
|
export { defineAppConfig, WxtAppConfig } from 'wxt/utils/define-app-config';
|
||||||
|
export { defineBackground } from 'wxt/utils/define-background';
|
||||||
|
export { defineContentScript } from 'wxt/utils/define-content-script';
|
||||||
|
export { defineUnlistedScript } from 'wxt/utils/define-unlisted-script';
|
||||||
|
export { defineWxtPlugin } from 'wxt/utils/define-wxt-plugin';
|
||||||
|
export { injectScript, ScriptPublicPath, InjectScriptOptions } from 'wxt/utils/inject-script';
|
||||||
|
export { InvalidMatchPattern, MatchPattern } from 'wxt/utils/match-patterns';
|
||||||
|
export { useState, useCallback, useMemo, useEffect, useRef, useContext, useReducer } from 'react';
|
||||||
|
export { fakeBrowser } from 'wxt/testing/fake-browser';
|
||||||
|
}
|
||||||
+58
@@ -0,0 +1,58 @@
|
|||||||
|
// Generated by wxt
|
||||||
|
export {}
|
||||||
|
declare global {
|
||||||
|
const ContentScriptContext: typeof import('wxt/utils/content-script-context').ContentScriptContext
|
||||||
|
const InvalidMatchPattern: typeof import('wxt/utils/match-patterns').InvalidMatchPattern
|
||||||
|
const MatchPattern: typeof import('wxt/utils/match-patterns').MatchPattern
|
||||||
|
const browser: typeof import('wxt/browser').browser
|
||||||
|
const createIframeUi: typeof import('wxt/utils/content-script-ui/iframe').createIframeUi
|
||||||
|
const createIntegratedUi: typeof import('wxt/utils/content-script-ui/integrated').createIntegratedUi
|
||||||
|
const createShadowRootUi: typeof import('wxt/utils/content-script-ui/shadow-root').createShadowRootUi
|
||||||
|
const defineAppConfig: typeof import('wxt/utils/define-app-config').defineAppConfig
|
||||||
|
const defineBackground: typeof import('wxt/utils/define-background').defineBackground
|
||||||
|
const defineContentScript: typeof import('wxt/utils/define-content-script').defineContentScript
|
||||||
|
const defineUnlistedScript: typeof import('wxt/utils/define-unlisted-script').defineUnlistedScript
|
||||||
|
const defineWxtPlugin: typeof import('wxt/utils/define-wxt-plugin').defineWxtPlugin
|
||||||
|
const fakeBrowser: typeof import('wxt/testing/fake-browser').fakeBrowser
|
||||||
|
const getAppConfig: typeof import('wxt/utils/app-config').getAppConfig
|
||||||
|
const injectScript: typeof import('wxt/utils/inject-script').injectScript
|
||||||
|
const storage: typeof import('wxt/utils/storage').storage
|
||||||
|
const useAppConfig: typeof import('wxt/utils/app-config').useAppConfig
|
||||||
|
const useCallback: typeof import('react').useCallback
|
||||||
|
const useContext: typeof import('react').useContext
|
||||||
|
const useEffect: typeof import('react').useEffect
|
||||||
|
const useMemo: typeof import('react').useMemo
|
||||||
|
const useReducer: typeof import('react').useReducer
|
||||||
|
const useRef: typeof import('react').useRef
|
||||||
|
const useState: typeof import('react').useState
|
||||||
|
}
|
||||||
|
// for type re-export
|
||||||
|
declare global {
|
||||||
|
// @ts-ignore
|
||||||
|
export type { Browser } from 'wxt/browser'
|
||||||
|
import('wxt/browser')
|
||||||
|
// @ts-ignore
|
||||||
|
export type { WxtStorage, WxtStorageItem, StorageArea, StorageItemKey, StorageAreaChanges, MigrationError } from 'wxt/utils/storage'
|
||||||
|
import('wxt/utils/storage')
|
||||||
|
// @ts-ignore
|
||||||
|
export type { WxtWindowEventMap } from 'wxt/utils/content-script-context'
|
||||||
|
import('wxt/utils/content-script-context')
|
||||||
|
// @ts-ignore
|
||||||
|
export type { IframeContentScriptUi, IframeContentScriptUiOptions } from 'wxt/utils/content-script-ui/iframe'
|
||||||
|
import('wxt/utils/content-script-ui/iframe')
|
||||||
|
// @ts-ignore
|
||||||
|
export type { IntegratedContentScriptUi, IntegratedContentScriptUiOptions } from 'wxt/utils/content-script-ui/integrated'
|
||||||
|
import('wxt/utils/content-script-ui/integrated')
|
||||||
|
// @ts-ignore
|
||||||
|
export type { ShadowRootContentScriptUi, ShadowRootContentScriptUiOptions } from 'wxt/utils/content-script-ui/shadow-root'
|
||||||
|
import('wxt/utils/content-script-ui/shadow-root')
|
||||||
|
// @ts-ignore
|
||||||
|
export type { ContentScriptUi, ContentScriptUiOptions, ContentScriptOverlayAlignment, ContentScriptAppendMode, ContentScriptInlinePositioningOptions, ContentScriptOverlayPositioningOptions, ContentScriptModalPositioningOptions, ContentScriptPositioningOptions, ContentScriptAnchoredOptions, AutoMountOptions, StopAutoMount, AutoMount } from 'wxt/utils/content-script-ui/types'
|
||||||
|
import('wxt/utils/content-script-ui/types')
|
||||||
|
// @ts-ignore
|
||||||
|
export type { WxtAppConfig } from 'wxt/utils/define-app-config'
|
||||||
|
import('wxt/utils/define-app-config')
|
||||||
|
// @ts-ignore
|
||||||
|
export type { ScriptPublicPath, InjectScriptOptions } from 'wxt/utils/inject-script'
|
||||||
|
import('wxt/utils/inject-script')
|
||||||
|
}
|
||||||
Vendored
+17
@@ -0,0 +1,17 @@
|
|||||||
|
// Generated by wxt
|
||||||
|
import "wxt/browser";
|
||||||
|
|
||||||
|
declare module "wxt/browser" {
|
||||||
|
export type PublicPath =
|
||||||
|
| ""
|
||||||
|
| "/"
|
||||||
|
| "/background.js"
|
||||||
|
| "/content-scripts/collector.js"
|
||||||
|
| "/content-scripts/ozon-page-json.js"
|
||||||
|
| "/content-scripts/source-page-state.js"
|
||||||
|
type HtmlPublicPath = Extract<PublicPath, `${string}.html`>
|
||||||
|
export interface WxtRuntime {
|
||||||
|
getURL(path: PublicPath): string;
|
||||||
|
getURL(path: `${HtmlPublicPath}${string}`): string;
|
||||||
|
}
|
||||||
|
}
|
||||||
Vendored
+8
@@ -0,0 +1,8 @@
|
|||||||
|
// Generated by wxt
|
||||||
|
/// <reference types="wxt/vite-builder-env" />
|
||||||
|
/// <reference types="@wxt-dev/module-react" />
|
||||||
|
/// <reference path="./types/paths.d.ts" />
|
||||||
|
/// <reference path="./types/i18n.d.ts" />
|
||||||
|
/// <reference path="./types/globals.d.ts" />
|
||||||
|
/// <reference path="./types/imports-module.d.ts" />
|
||||||
|
/// <reference path="./types/imports.d.ts" />
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { getSession, login, openErp, submit } from '../lib/api.js';
|
||||||
|
export default defineBackground(() => {
|
||||||
|
browser.runtime.onMessage.addListener(async (message: { type?: string; payload?: unknown; path?: string }) => {
|
||||||
|
if (message?.type === 'atlas.session') return { session: await getSession() };
|
||||||
|
if (message?.type === 'atlas.login') return { session: await login() };
|
||||||
|
if (message?.type === 'atlas.collect') return { result: await submit(message.payload as import('@atlas/contracts').CollectedProductPayload) };
|
||||||
|
if (message?.type === 'atlas.open') { await openErp(message.path); return { ok: true }; }
|
||||||
|
return undefined;
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,179 @@
|
|||||||
|
import {
|
||||||
|
collectCurrentProduct,
|
||||||
|
supportedProductPlatform,
|
||||||
|
} from '../lib/source-parsers.js';
|
||||||
|
import type { OzonPageJsonFetcher } from '../lib/ozon-parser.js';
|
||||||
|
import type { MarketplacePageStateFetcher } from '../lib/source-parsers.js';
|
||||||
|
|
||||||
|
export default defineContentScript({
|
||||||
|
matches: [
|
||||||
|
'*://www.ozon.ru/*',
|
||||||
|
'*://www.ozon.kz/*',
|
||||||
|
'*://www.ozon.by/*',
|
||||||
|
'*://detail.1688.com/*',
|
||||||
|
'*://item.taobao.com/*',
|
||||||
|
'*://detail.tmall.com/*',
|
||||||
|
],
|
||||||
|
runAt: 'document_idle',
|
||||||
|
main() {
|
||||||
|
let mount: HTMLDivElement | null = null;
|
||||||
|
let shadow: ShadowRoot | null = null;
|
||||||
|
let observedUrl = location.href;
|
||||||
|
|
||||||
|
const render = async () => {
|
||||||
|
if (!supportedProductPlatform()) {
|
||||||
|
mount?.remove();
|
||||||
|
mount = null;
|
||||||
|
shadow = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!mount) {
|
||||||
|
mount = document.createElement('div');
|
||||||
|
mount.style.cssText =
|
||||||
|
'position:fixed;right:22px;bottom:80px;z-index:2147483647';
|
||||||
|
document.documentElement.append(mount);
|
||||||
|
shadow = mount.attachShadow({ mode: 'open' });
|
||||||
|
}
|
||||||
|
const root = shadow!;
|
||||||
|
const session = await browser.runtime
|
||||||
|
.sendMessage({ type: 'atlas.session' })
|
||||||
|
.then((value: { session?: unknown }) => value.session)
|
||||||
|
.catch(() => null);
|
||||||
|
root.innerHTML = `<style>:host{all:initial}.box{width:156px;padding:12px;background:#fff;border-radius:12px;box-shadow:0 8px 28px #0003;font:14px -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;color:#1f2937}.head{display:flex;justify-content:space-between;align-items:center;font-weight:700;margin-bottom:10px}.dot{width:9px;height:9px;border-radius:50%;background:#ffd43b;border:0}.btn{display:block;width:100%;border:0;border-radius:18px;padding:9px 8px;margin:7px 0;background:#2878f0;color:#fff;font-weight:600;cursor:pointer}.btn.primary{background:#ff5353}.link{display:block;width:100%;border:0;background:transparent;color:#1668dc;cursor:pointer;padding:5px}.hint{font-size:12px;color:#6b7280;text-align:center;margin-top:6px}</style><div class="box"><div class="head"><span>Atlas ERP</span><button class="dot" aria-label="收起"></button></div>${session ? '<button class="btn primary" data-action="edit">编辑上架</button><button class="btn" data-action="collect">采集商品</button><button class="link" data-action="erp">进入 ERP</button>' : '<button class="btn primary" data-action="login">登录 Atlas ERP</button>'}<div class="hint" data-hint></div></div>`;
|
||||||
|
root.querySelector('.dot')?.addEventListener('click', () => {
|
||||||
|
mount?.remove();
|
||||||
|
mount = null;
|
||||||
|
shadow = null;
|
||||||
|
});
|
||||||
|
root.querySelectorAll<HTMLButtonElement>('[data-action]').forEach(
|
||||||
|
(button) =>
|
||||||
|
button.addEventListener('click', () =>
|
||||||
|
void action(button.dataset.action ?? '', root),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const action = async (kind: string, root: ShadowRoot) => {
|
||||||
|
const hint = root.querySelector<HTMLElement>('[data-hint]')!;
|
||||||
|
try {
|
||||||
|
if (kind === 'login') {
|
||||||
|
hint.textContent = '正在登录…';
|
||||||
|
await browser.runtime.sendMessage({ type: 'atlas.login' });
|
||||||
|
await render();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (kind === 'erp') {
|
||||||
|
await browser.runtime.sendMessage({ type: 'atlas.open', path: '/' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
hint.textContent = '正在采集多 SKU…';
|
||||||
|
const payload = await collectCurrentProduct(
|
||||||
|
document,
|
||||||
|
location.href,
|
||||||
|
supportedProductPlatform() === 'ozon'
|
||||||
|
? requestOzonPageJson
|
||||||
|
: undefined,
|
||||||
|
supportedProductPlatform() && supportedProductPlatform() !== 'ozon'
|
||||||
|
? requestMarketplacePageState
|
||||||
|
: undefined,
|
||||||
|
);
|
||||||
|
const response = await browser.runtime.sendMessage({
|
||||||
|
type: 'atlas.collect',
|
||||||
|
payload,
|
||||||
|
});
|
||||||
|
if (kind === 'edit') {
|
||||||
|
await browser.runtime.sendMessage({
|
||||||
|
type: 'atlas.open',
|
||||||
|
path: response.result.data.editor_entry_path,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
hint.textContent =
|
||||||
|
kind === 'edit' ? '已打开采集数据测试页' : '已进入采集箱';
|
||||||
|
} catch (error) {
|
||||||
|
hint.textContent =
|
||||||
|
error instanceof Error ? error.message : '采集失败,请重试';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
void render();
|
||||||
|
setInterval(() => {
|
||||||
|
if (location.href !== observedUrl) {
|
||||||
|
observedUrl = location.href;
|
||||||
|
void render();
|
||||||
|
}
|
||||||
|
}, 700);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const requestOzonPageJson: OzonPageJsonFetcher = ({ kind, sku }) =>
|
||||||
|
new Promise((resolve, reject) => {
|
||||||
|
const requestId = crypto.randomUUID();
|
||||||
|
const timeout = window.setTimeout(() => {
|
||||||
|
window.removeEventListener('message', receive);
|
||||||
|
reject(new Error('Ozon 页面状态读取超时'));
|
||||||
|
}, 15_000);
|
||||||
|
const receive = (event: MessageEvent<unknown>) => {
|
||||||
|
if (event.source !== window || !isBridgeResponse(event.data, requestId)) return;
|
||||||
|
window.clearTimeout(timeout);
|
||||||
|
window.removeEventListener('message', receive);
|
||||||
|
if (event.data.ok) resolve(event.data.data);
|
||||||
|
else reject(new Error(event.data.error));
|
||||||
|
};
|
||||||
|
window.addEventListener('message', receive);
|
||||||
|
window.postMessage(
|
||||||
|
{
|
||||||
|
source: 'atlas-collector',
|
||||||
|
type: 'ozon-page-json.request',
|
||||||
|
requestId,
|
||||||
|
kind,
|
||||||
|
sku,
|
||||||
|
},
|
||||||
|
location.origin,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const requestMarketplacePageState: MarketplacePageStateFetcher = (platform) =>
|
||||||
|
new Promise((resolve) => {
|
||||||
|
const requestId = crypto.randomUUID();
|
||||||
|
const timeout = window.setTimeout(() => {
|
||||||
|
window.removeEventListener('message', receive);
|
||||||
|
resolve({});
|
||||||
|
}, 3_000);
|
||||||
|
const receive = (event: MessageEvent<unknown>) => {
|
||||||
|
if (event.source !== window || !isMarketplaceStateResponse(event.data, requestId)) return;
|
||||||
|
window.clearTimeout(timeout);
|
||||||
|
window.removeEventListener('message', receive);
|
||||||
|
resolve(event.data.data);
|
||||||
|
};
|
||||||
|
window.addEventListener('message', receive);
|
||||||
|
window.postMessage({ source: 'atlas-collector', type: 'source-page-state.request', requestId, platform }, location.origin);
|
||||||
|
});
|
||||||
|
|
||||||
|
function isBridgeResponse(
|
||||||
|
value: unknown,
|
||||||
|
requestId: string,
|
||||||
|
): value is {
|
||||||
|
source: 'atlas-collector';
|
||||||
|
type: 'ozon-page-json.response';
|
||||||
|
requestId: string;
|
||||||
|
ok: boolean;
|
||||||
|
data?: unknown;
|
||||||
|
error: string;
|
||||||
|
} {
|
||||||
|
if (!value || typeof value !== 'object' || Array.isArray(value)) return false;
|
||||||
|
const message = value as Record<string, unknown>;
|
||||||
|
return (
|
||||||
|
message.source === 'atlas-collector' &&
|
||||||
|
message.type === 'ozon-page-json.response' &&
|
||||||
|
message.requestId === requestId &&
|
||||||
|
typeof message.ok === 'boolean' &&
|
||||||
|
(message.ok || typeof message.error === 'string')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isMarketplaceStateResponse(value: unknown, requestId: string): value is { source: 'atlas-collector'; type: 'source-page-state.response'; requestId: string; data: unknown } {
|
||||||
|
if (!value || typeof value !== 'object' || Array.isArray(value)) return false;
|
||||||
|
const message = value as Record<string, unknown>;
|
||||||
|
return message.source === 'atlas-collector' && message.type === 'source-page-state.response'
|
||||||
|
&& message.requestId === requestId && !!message.data && typeof message.data === 'object';
|
||||||
|
}
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
/**
|
||||||
|
* Runs in Ozon's MAIN world so the request has exactly the same origin and
|
||||||
|
* session context as the page. It accepts only Atlas' two fixed, numeric-SKU
|
||||||
|
* routes and returns a small widget-state allowlist; it never exposes cookies,
|
||||||
|
* request headers or arbitrary fetch capability to the isolated script.
|
||||||
|
*/
|
||||||
|
export default defineContentScript({
|
||||||
|
matches: ['*://www.ozon.ru/*', '*://www.ozon.kz/*', '*://www.ozon.by/*'],
|
||||||
|
world: 'MAIN',
|
||||||
|
runAt: 'document_idle',
|
||||||
|
main() {
|
||||||
|
window.addEventListener('message', (event) => {
|
||||||
|
if (event.source !== window || !isRequest(event.data)) return;
|
||||||
|
void respond(event.data);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
type Request = {
|
||||||
|
source: 'atlas-collector';
|
||||||
|
type: 'ozon-page-json.request';
|
||||||
|
requestId: string;
|
||||||
|
kind: 'modal-aspects' | 'product' | 'product-detail';
|
||||||
|
sku: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function isRequest(value: unknown): value is Request {
|
||||||
|
if (!value || typeof value !== 'object' || Array.isArray(value))
|
||||||
|
return false;
|
||||||
|
const data = value as Record<string, unknown>;
|
||||||
|
return (
|
||||||
|
data.source === 'atlas-collector' &&
|
||||||
|
data.type === 'ozon-page-json.request' &&
|
||||||
|
typeof data.requestId === 'string' &&
|
||||||
|
/^[a-zA-Z0-9-]{16,120}$/.test(data.requestId) &&
|
||||||
|
(data.kind === 'modal-aspects' ||
|
||||||
|
data.kind === 'product' ||
|
||||||
|
data.kind === 'product-detail') &&
|
||||||
|
typeof data.sku === 'string' &&
|
||||||
|
/^\d{4,}$/.test(data.sku)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function respond(request: Request) {
|
||||||
|
try {
|
||||||
|
const pagePath =
|
||||||
|
request.kind === 'modal-aspects'
|
||||||
|
? `/modal/aspectsNew?product_id=${request.sku}`
|
||||||
|
: request.kind === 'product-detail'
|
||||||
|
? `/product/${request.sku}/?layout_container=pdpPage2column&layout_page_index=2`
|
||||||
|
: `/product/${request.sku}/`;
|
||||||
|
const response = await fetch(
|
||||||
|
`/api/entrypoint-api.bx/page/json/v2?url=${encodeURIComponent(pagePath)}`,
|
||||||
|
{ credentials: 'same-origin' },
|
||||||
|
);
|
||||||
|
if (!response.ok)
|
||||||
|
throw new Error(`Ozon 页面状态请求失败(HTTP ${response.status})`);
|
||||||
|
const value = await response.json();
|
||||||
|
window.postMessage(
|
||||||
|
{
|
||||||
|
source: 'atlas-collector',
|
||||||
|
type: 'ozon-page-json.response',
|
||||||
|
requestId: request.requestId,
|
||||||
|
ok: true,
|
||||||
|
data: allowlistedPageState(value),
|
||||||
|
},
|
||||||
|
location.origin,
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
window.postMessage(
|
||||||
|
{
|
||||||
|
source: 'atlas-collector',
|
||||||
|
type: 'ozon-page-json.response',
|
||||||
|
requestId: request.requestId,
|
||||||
|
ok: false,
|
||||||
|
error:
|
||||||
|
error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: 'Ozon 页面状态请求失败',
|
||||||
|
},
|
||||||
|
location.origin,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function allowlistedPageState(value: unknown) {
|
||||||
|
if (!value || typeof value !== 'object' || Array.isArray(value))
|
||||||
|
return { widgetStates: {} };
|
||||||
|
const states = (value as { widgetStates?: unknown }).widgetStates;
|
||||||
|
if (!states || typeof states !== 'object' || Array.isArray(states))
|
||||||
|
return { widgetStates: {} };
|
||||||
|
const widgetStates: Record<string, unknown> = {};
|
||||||
|
for (const [name, state] of Object.entries(
|
||||||
|
states as Record<string, unknown>,
|
||||||
|
)) {
|
||||||
|
if (
|
||||||
|
/^web(Aspects|AspectsModal|ProductHeading|Price|Gallery|Description|RichContent|Features|ShortCharacteristics|DetailedCharacteristics|Characteristics)-/.test(
|
||||||
|
name,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
widgetStates[name] = state;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { widgetStates };
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
/**
|
||||||
|
* A deliberately narrow MAIN-world bridge for the mature 1688/Taobao/Tmall
|
||||||
|
* structured collectors. It never exposes cookies, headers, arbitrary window
|
||||||
|
* keys, or a generic fetch primitive; only public product-state projections.
|
||||||
|
*/
|
||||||
|
export default defineContentScript({
|
||||||
|
matches: [
|
||||||
|
'*://detail.1688.com/*',
|
||||||
|
'*://item.taobao.com/*',
|
||||||
|
'*://detail.tmall.com/*',
|
||||||
|
],
|
||||||
|
world: 'MAIN',
|
||||||
|
runAt: 'document_idle',
|
||||||
|
main() {
|
||||||
|
window.addEventListener('message', (event) => {
|
||||||
|
if (event.source !== window || !isRequest(event.data)) return;
|
||||||
|
const data = event.data.platform === '1688'
|
||||||
|
? project1688((window as unknown as Record<string, unknown>).context)
|
||||||
|
: projectTaobao(window as unknown as Record<string, unknown>);
|
||||||
|
window.postMessage({
|
||||||
|
source: 'atlas-collector',
|
||||||
|
type: 'source-page-state.response',
|
||||||
|
requestId: event.data.requestId,
|
||||||
|
data,
|
||||||
|
}, location.origin);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
type Request = { source: 'atlas-collector'; type: 'source-page-state.request'; requestId: string; platform: '1688' | 'taobao' | 'tmall' };
|
||||||
|
function isRequest(value: unknown): value is Request {
|
||||||
|
if (!value || typeof value !== 'object' || Array.isArray(value)) return false;
|
||||||
|
const data = value as Record<string, unknown>;
|
||||||
|
return data.source === 'atlas-collector' && data.type === 'source-page-state.request'
|
||||||
|
&& typeof data.requestId === 'string' && /^[a-zA-Z0-9-]{16,120}$/.test(data.requestId)
|
||||||
|
&& (data.platform === '1688' || data.platform === 'taobao' || data.platform === 'tmall');
|
||||||
|
}
|
||||||
|
|
||||||
|
const safeClone = (value: unknown) => { try { return JSON.parse(JSON.stringify(value)); } catch { return undefined; } };
|
||||||
|
function find(value: unknown, key: string): unknown {
|
||||||
|
const pending: unknown[] = [value];
|
||||||
|
const seen = new Set<object>();
|
||||||
|
let visited = 0;
|
||||||
|
while (pending.length && visited < 300_000) {
|
||||||
|
const current = pending.shift();
|
||||||
|
if (!current || typeof current !== 'object' || seen.has(current)) continue;
|
||||||
|
seen.add(current);
|
||||||
|
visited += 1;
|
||||||
|
const record = current as Record<string, unknown>;
|
||||||
|
if (key in record) return record[key];
|
||||||
|
pending.push(...Object.values(record));
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
function project1688(context: unknown) {
|
||||||
|
const projected: Record<string, unknown> = {};
|
||||||
|
for (const key of ['gallery', 'tempModel', 'skuModel', 'tradeModel', 'skuMapOriginal', 'pieceWeightScaleInfo', 'productAttributes']) {
|
||||||
|
const value = find(context, key);
|
||||||
|
if (value !== undefined) projected[key] = safeClone(value);
|
||||||
|
}
|
||||||
|
return projected;
|
||||||
|
}
|
||||||
|
function projectTaobao(windowState: Record<string, unknown>) {
|
||||||
|
const ice = windowState.__ICE_APP_CONTEXT__ as Record<string, unknown> | undefined;
|
||||||
|
const result = ice?.loaderData && typeof ice.loaderData === 'object'
|
||||||
|
? (((ice.loaderData as Record<string, unknown>).home as Record<string, unknown> | undefined)?.data as Record<string, unknown> | undefined)?.res
|
||||||
|
: undefined;
|
||||||
|
return { product: safeClone(result) };
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import type { CollectedProductPayload } from '@atlas/contracts';
|
||||||
|
|
||||||
|
const apiOrigin = (import.meta.env.ATLAS_API_ORIGIN as string | undefined) ?? 'http://localhost:8200';
|
||||||
|
const webOrigin = (import.meta.env.ATLAS_WEB_ORIGIN as string | undefined) ?? 'http://localhost:8100';
|
||||||
|
type Session = { authenticated: true; workspace: { id: string; available: boolean }; can_collect: boolean };
|
||||||
|
const storageKey = 'atlas.extension.web-token.v1';
|
||||||
|
|
||||||
|
async function loadToken() { return (await browser.storage.local.get(storageKey))[storageKey] as string | undefined; }
|
||||||
|
async function saveToken(token: string) { await browser.storage.local.set({ [storageKey]: token }); }
|
||||||
|
async function clearTokens() { await browser.storage.local.remove(storageKey); }
|
||||||
|
async function request(path: string, options: RequestInit = {}, token?: string) {
|
||||||
|
const response = await fetch(`${apiOrigin}/api${path}`, { ...options, headers: { Accept: 'application/json', ...(options.headers ?? {}), ...(token ? { Authorization: `Bearer ${token}` } : {}) } });
|
||||||
|
if (!response.ok) { const body = await response.json().catch(() => null) as { error?: { message?: string } } | null; throw Object.assign(new Error(body?.error?.message ?? `请求失败(HTTP ${response.status})`), { status: response.status }); }
|
||||||
|
return response.status === 204 ? undefined : response.json() as Promise<{ data: unknown }>;
|
||||||
|
}
|
||||||
|
async function activeAccessToken() {
|
||||||
|
const cached = await loadToken();
|
||||||
|
const usableCached = await validateAccessToken(cached);
|
||||||
|
if (usableCached) return usableCached;
|
||||||
|
|
||||||
|
// 用户可能先在 Atlas Web 登录、再打开来源商品页。此时插件还没有自己的
|
||||||
|
// 缓存,仍应直接从已打开的 Atlas 标签页同步,而不是错误地展示“登录”。
|
||||||
|
const synchronized = await syncAccessTokenFromWebTabs();
|
||||||
|
return validateAccessToken(synchronized);
|
||||||
|
}
|
||||||
|
export async function getSession(): Promise<Session | null> { const token = await activeAccessToken(); if (!token) return null; return (await request('/extension/session', {}, token) as { data: Session }).data; }
|
||||||
|
export async function login() {
|
||||||
|
const currentSession = await getSession();
|
||||||
|
if (currentSession) return currentSession;
|
||||||
|
|
||||||
|
const tab = await browser.tabs.create({ url: webOrigin, active: true });
|
||||||
|
if (tab.id === undefined) throw new Error('无法打开 Atlas ERP 登录页');
|
||||||
|
const deadline = Date.now() + 3 * 60 * 1_000;
|
||||||
|
while (Date.now() < deadline) {
|
||||||
|
const token = await readWebAccessToken(tab.id);
|
||||||
|
if (token) {
|
||||||
|
await saveToken(token);
|
||||||
|
const session = await getSession();
|
||||||
|
if (session) return session;
|
||||||
|
await clearTokens();
|
||||||
|
}
|
||||||
|
await delay(2_000);
|
||||||
|
}
|
||||||
|
throw new Error('登录超时,请在 Atlas ERP 网页完成登录后重试');
|
||||||
|
}
|
||||||
|
export async function submit(payload: CollectedProductPayload) { const token = await activeAccessToken(); if (!token) throw new Error('请先登录 Atlas ERP'); return request('/extension/materials', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Idempotency-Key': crypto.randomUUID() }, body: JSON.stringify(payload) }, token) as Promise<{ data: { editor_entry_path: string; product_id: string; collection_item: { id: string }; reused: boolean } }>; }
|
||||||
|
export async function openErp(path = '/') { await browser.tabs.create({ url: new URL(path, webOrigin).toString() }); }
|
||||||
|
async function readWebAccessToken(tabId: number) {
|
||||||
|
try {
|
||||||
|
const result = await browser.scripting.executeScript({ target: { tabId }, func: () => localStorage.getItem('atlas.extension.web-access') });
|
||||||
|
const raw = result[0]?.result;
|
||||||
|
if (typeof raw !== 'string') return null;
|
||||||
|
const parsed = JSON.parse(raw) as { accessToken?: unknown };
|
||||||
|
return typeof parsed.accessToken === 'string' && parsed.accessToken.length > 0 ? parsed.accessToken : null;
|
||||||
|
} catch { return null; }
|
||||||
|
}
|
||||||
|
async function syncAccessTokenFromWebTabs() {
|
||||||
|
const tabs = await browser.tabs.query({ url: `${webOrigin}/*` });
|
||||||
|
// tabs.query 返回的顺序未承诺。倒序优先尝试最近的标签页(Chrome 通常最后返回它)。
|
||||||
|
for (const tab of tabs.reverse()) {
|
||||||
|
if (tab.id === undefined) continue;
|
||||||
|
const token = await readWebAccessToken(tab.id);
|
||||||
|
if (token) {
|
||||||
|
await saveToken(token);
|
||||||
|
return token;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
async function validateAccessToken(token: string | undefined | null) {
|
||||||
|
if (!token) return null;
|
||||||
|
try {
|
||||||
|
await request('/extension/session', {}, token);
|
||||||
|
return token;
|
||||||
|
} catch (error) {
|
||||||
|
if ((error as { status?: number }).status === 401) {
|
||||||
|
await clearTokens();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function delay(milliseconds: number) { return new Promise<void>((resolve) => setTimeout(resolve, milliseconds)); }
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,337 @@
|
|||||||
|
import type { CollectedProductPayload } from '@atlas/contracts';
|
||||||
|
|
||||||
|
type SupportedMediaPlatform = 'ozon' | '1688' | 'taobao' | 'tmall';
|
||||||
|
type DomMediaPlatform = Exclude<SupportedMediaPlatform, 'ozon'>;
|
||||||
|
type Asset = CollectedProductPayload['assets'][number];
|
||||||
|
|
||||||
|
type ImageGroup = {
|
||||||
|
role: Asset['role'];
|
||||||
|
relation: Asset['relation'];
|
||||||
|
selectors: string[];
|
||||||
|
sourceProps?: Array<
|
||||||
|
| 'data-lazyload-src'
|
||||||
|
| 'data-src'
|
||||||
|
| 'srcset'
|
||||||
|
| 'currentSrc'
|
||||||
|
| 'src'
|
||||||
|
| 'backgroundImage'
|
||||||
|
>;
|
||||||
|
excludeWithin?: string[];
|
||||||
|
minWidth?: number;
|
||||||
|
minHeight?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* These profiles are ported from the proven collector extension. The crucial
|
||||||
|
* invariant is that images always come from a product widget, never from a
|
||||||
|
* page-wide `document.images` scan (which also includes logos, ads and
|
||||||
|
* recommendation cards).
|
||||||
|
*/
|
||||||
|
const DOM_IMAGE_PROFILES: Record<DomMediaPlatform, ImageGroup[]> = {
|
||||||
|
'1688': [
|
||||||
|
{
|
||||||
|
role: 'source_main',
|
||||||
|
relation: 'inherited_product_default',
|
||||||
|
selectors: [
|
||||||
|
'[data-module="od_picture_gallery"] img',
|
||||||
|
'.module-od-picture-gallery img',
|
||||||
|
'#recyclerview .detail-gallery-turn-wrapper .detail-gallery-img',
|
||||||
|
'#screen .od-gallery-turn-item-wrapper .od-gallery-img',
|
||||||
|
'#content .od-scroller-item .v-image-cover',
|
||||||
|
'#content .od-picture-gallery-list .v-image-cover',
|
||||||
|
'#dt-tab img',
|
||||||
|
'.detail-gallery-turn img.detail-gallery-img',
|
||||||
|
'.img-list-wrapper img.od-gallery-img',
|
||||||
|
],
|
||||||
|
minWidth: 200,
|
||||||
|
minHeight: 200,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
role: 'source_sku',
|
||||||
|
relation: 'axis_value',
|
||||||
|
selectors: [
|
||||||
|
'[data-module="od_sku_selection"] img',
|
||||||
|
'.module-od-sku-selection img',
|
||||||
|
'.pc-sku-wrapper .prop-item-inner-wrapper',
|
||||||
|
'.sku-item-wrapper',
|
||||||
|
'.specification-cell',
|
||||||
|
'.sku-filter-button',
|
||||||
|
'.expand-view-item',
|
||||||
|
'.feature-item img',
|
||||||
|
],
|
||||||
|
sourceProps: ['backgroundImage'],
|
||||||
|
minWidth: 20,
|
||||||
|
minHeight: 20,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
role: 'source_detail',
|
||||||
|
relation: 'product_candidate',
|
||||||
|
selectors: [
|
||||||
|
'#detail img',
|
||||||
|
'.de-description-detail img',
|
||||||
|
'#detailContentContainer img',
|
||||||
|
'.html-description img',
|
||||||
|
],
|
||||||
|
minWidth: 300,
|
||||||
|
minHeight: 100,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
taobao: [
|
||||||
|
{
|
||||||
|
role: 'source_main',
|
||||||
|
relation: 'inherited_product_default',
|
||||||
|
selectors: [
|
||||||
|
'[class*="picGallery--"] img',
|
||||||
|
'#picGalleryEle img',
|
||||||
|
'[class*="thumbnailPic--"]',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
role: 'source_sku',
|
||||||
|
relation: 'axis_value',
|
||||||
|
selectors: [
|
||||||
|
'[class*="valueItem--"]',
|
||||||
|
'[class*="valueItemImgWrap--"]',
|
||||||
|
],
|
||||||
|
minWidth: 20,
|
||||||
|
minHeight: 20,
|
||||||
|
},
|
||||||
|
taobaoDetailProfile(),
|
||||||
|
],
|
||||||
|
tmall: [
|
||||||
|
{
|
||||||
|
role: 'source_main',
|
||||||
|
relation: 'inherited_product_default',
|
||||||
|
selectors: [
|
||||||
|
'[class*="picGallery--"] img',
|
||||||
|
'#picGalleryEle img',
|
||||||
|
'[class*="thumbnailPic--"]',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
role: 'source_sku',
|
||||||
|
relation: 'axis_value',
|
||||||
|
selectors: [
|
||||||
|
'[class*="valueItem--"]',
|
||||||
|
'[class*="valueItemImgWrap--"]',
|
||||||
|
],
|
||||||
|
minWidth: 20,
|
||||||
|
minHeight: 20,
|
||||||
|
},
|
||||||
|
taobaoDetailProfile(),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
function taobaoDetailProfile(): ImageGroup {
|
||||||
|
return {
|
||||||
|
role: 'source_detail',
|
||||||
|
relation: 'product_candidate',
|
||||||
|
selectors: [
|
||||||
|
'[class*="tabDetailWrap--"] img',
|
||||||
|
'[class*="detailInfo--"] img',
|
||||||
|
],
|
||||||
|
// The old collector verified that review photos and recommendation
|
||||||
|
// cards can be large enough to pass a size-only filter.
|
||||||
|
excludeWithin: [
|
||||||
|
'[class*="Comment--"]',
|
||||||
|
'[class*="comments--"]',
|
||||||
|
'[class*="userInfo--"]',
|
||||||
|
'[class*="rate"]',
|
||||||
|
'[class*="RecommendInfo--"]',
|
||||||
|
'[data-spm="recommends"]',
|
||||||
|
'[class*="recommend--"]',
|
||||||
|
'[class*="cardPic--"]',
|
||||||
|
],
|
||||||
|
minWidth: 300,
|
||||||
|
minHeight: 100,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function collectPlatformDomAssets(
|
||||||
|
platform: DomMediaPlatform,
|
||||||
|
root: Document,
|
||||||
|
pageUrl: string,
|
||||||
|
): Asset[] {
|
||||||
|
const assets: Asset[] = [];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
for (const group of DOM_IMAGE_PROFILES[platform]) {
|
||||||
|
for (const selector of group.selectors) {
|
||||||
|
for (const element of Array.from(
|
||||||
|
root.querySelectorAll<HTMLElement>(selector),
|
||||||
|
)) {
|
||||||
|
if (
|
||||||
|
group.excludeWithin?.some((excluded) =>
|
||||||
|
element.closest(excluded),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
continue;
|
||||||
|
if (!passesDimensions(element, group)) continue;
|
||||||
|
const url = imageUrlFromElement(
|
||||||
|
element,
|
||||||
|
group.sourceProps,
|
||||||
|
pageUrl,
|
||||||
|
platform,
|
||||||
|
);
|
||||||
|
if (!url) continue;
|
||||||
|
// Keep the old collector's group-level de-duplication: an
|
||||||
|
// aspect cover may legitimately also be a gallery image.
|
||||||
|
const key = `${group.role}:${dedupeImageUrl(url, platform)}`;
|
||||||
|
if (seen.has(key)) continue;
|
||||||
|
seen.add(key);
|
||||||
|
assets.push({
|
||||||
|
url,
|
||||||
|
mediaType: 'image',
|
||||||
|
role: group.role,
|
||||||
|
relation: group.relation,
|
||||||
|
sortOrder: assets.length,
|
||||||
|
evidenceRef: `${platform}:dom:${group.role}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return assets;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Restores the original CDN URL before payload storage and de-duplication. */
|
||||||
|
export function normaliseImageUrl(
|
||||||
|
raw: string,
|
||||||
|
pageUrl: string,
|
||||||
|
platform: SupportedMediaPlatform,
|
||||||
|
): string | undefined {
|
||||||
|
let url: URL;
|
||||||
|
try {
|
||||||
|
url = new URL(raw, pageUrl);
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
if (url.protocol !== 'https:' && url.protocol !== 'http:') return undefined;
|
||||||
|
|
||||||
|
if (platform === 'ozon') {
|
||||||
|
// Ozon thumbnails use /wc50/, /wc100/, /c600/, etc. The same media
|
||||||
|
// without this segment is its original CDN resource.
|
||||||
|
url.pathname = url.pathname
|
||||||
|
.replace(/\/(?:wc|c)\d+\//i, '/')
|
||||||
|
.replace(/\/{2,}/g, '/');
|
||||||
|
} else {
|
||||||
|
// Alibaba image URLs commonly append a transform after the file
|
||||||
|
// extension (e.g. image.jpg_400x400.jpg). Keep the original image.
|
||||||
|
const original = url.pathname.match(
|
||||||
|
/^(.+?\.(?:jpe?g|png|gif|bmp|heic|webp|avif))_/i,
|
||||||
|
);
|
||||||
|
if (original) url.pathname = original[1];
|
||||||
|
}
|
||||||
|
for (const parameter of ['width', 'height', 'size', 'quality', 'w', 'h']) {
|
||||||
|
url.searchParams.delete(parameter);
|
||||||
|
}
|
||||||
|
return url.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function dedupeImageUrl(url: string, platform: SupportedMediaPlatform) {
|
||||||
|
const normalised = normaliseImageUrl(url, url, platform) ?? url;
|
||||||
|
try {
|
||||||
|
const parsed = new URL(normalised);
|
||||||
|
parsed.search = '';
|
||||||
|
parsed.hash = '';
|
||||||
|
return parsed.toString();
|
||||||
|
} catch {
|
||||||
|
return normalised;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function imageUrlFromElement(
|
||||||
|
element: HTMLElement,
|
||||||
|
sourceProps: ImageGroup['sourceProps'] = [
|
||||||
|
'data-lazyload-src',
|
||||||
|
'data-src',
|
||||||
|
'srcset',
|
||||||
|
'currentSrc',
|
||||||
|
'src',
|
||||||
|
'backgroundImage',
|
||||||
|
],
|
||||||
|
pageUrl: string,
|
||||||
|
platform: SupportedMediaPlatform,
|
||||||
|
) {
|
||||||
|
const nestedTarget = element.matches('img, source')
|
||||||
|
? null
|
||||||
|
: element.querySelector<HTMLElement>('img, source');
|
||||||
|
const imageTarget = element.matches('img, source') ? element : nestedTarget;
|
||||||
|
for (const prop of sourceProps) {
|
||||||
|
// 1688 uses background images on the selected SKU option container.
|
||||||
|
// For every other property, prefer the actual nested image/source.
|
||||||
|
const target = prop === 'backgroundImage' ? element : imageTarget;
|
||||||
|
if (!target) continue;
|
||||||
|
let raw = '';
|
||||||
|
if (prop === 'srcset')
|
||||||
|
raw = bestSrcset(target.getAttribute('srcset') ?? '');
|
||||||
|
else if (prop === 'currentSrc')
|
||||||
|
raw = (target as HTMLImageElement).currentSrc ?? '';
|
||||||
|
else if (prop === 'backgroundImage') {
|
||||||
|
const style =
|
||||||
|
target.ownerDocument.defaultView?.getComputedStyle(target)
|
||||||
|
.backgroundImage ?? target.style.backgroundImage;
|
||||||
|
raw = style.match(/url\(["']?(.*?)["']?\)/i)?.[1] ?? '';
|
||||||
|
// Some 1688 SKU selectors directly match an <img> despite the
|
||||||
|
// group being background-image-first.
|
||||||
|
if (!raw && target.tagName === 'IMG') {
|
||||||
|
raw =
|
||||||
|
(target as HTMLImageElement).currentSrc ||
|
||||||
|
target.getAttribute('src') ||
|
||||||
|
'';
|
||||||
|
}
|
||||||
|
if (!raw && nestedTarget && nestedTarget !== target) {
|
||||||
|
const nestedStyle =
|
||||||
|
nestedTarget.ownerDocument.defaultView?.getComputedStyle(
|
||||||
|
nestedTarget,
|
||||||
|
).backgroundImage ?? nestedTarget.style.backgroundImage;
|
||||||
|
raw =
|
||||||
|
nestedStyle.match(/url\(["']?(.*?)["']?\)/i)?.[1] ??
|
||||||
|
((nestedTarget as HTMLImageElement).currentSrc ||
|
||||||
|
nestedTarget.getAttribute('src') ||
|
||||||
|
'');
|
||||||
|
}
|
||||||
|
} else raw = target.getAttribute(prop) ?? '';
|
||||||
|
const url = raw && normaliseImageUrl(raw, pageUrl, platform);
|
||||||
|
if (url) return url;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function passesDimensions(element: HTMLElement, group: ImageGroup) {
|
||||||
|
if (!group.minWidth && !group.minHeight) return true;
|
||||||
|
const image = element.matches('img')
|
||||||
|
? (element as HTMLImageElement)
|
||||||
|
: element.querySelector<HTMLImageElement>('img');
|
||||||
|
const measured = image ?? element;
|
||||||
|
const width = image?.naturalWidth || measured.offsetWidth || 0;
|
||||||
|
const height = image?.naturalHeight || measured.offsetHeight || 0;
|
||||||
|
// Browser snapshots and unrendered lazy images often report 0. As in the
|
||||||
|
// old collector, only reject when a real measured size proves it is tiny.
|
||||||
|
return !(
|
||||||
|
width > 0 &&
|
||||||
|
height > 0 &&
|
||||||
|
(width < (group.minWidth ?? 0) || height < (group.minHeight ?? 0))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function bestSrcset(srcset: string) {
|
||||||
|
let best = '';
|
||||||
|
let bestSize = -1;
|
||||||
|
for (const part of srcset
|
||||||
|
.split(',')
|
||||||
|
.map((value) => value.trim())
|
||||||
|
.filter(Boolean)) {
|
||||||
|
const [url, descriptor = ''] = part.split(/\s+/, 2);
|
||||||
|
const width = descriptor.match(/^(\d+)w$/)?.[1];
|
||||||
|
const density = descriptor.match(/^(\d+(?:\.\d+)?)x$/)?.[1];
|
||||||
|
const size = width
|
||||||
|
? Number(width)
|
||||||
|
: density
|
||||||
|
? Number(density) * 1_000
|
||||||
|
: 0;
|
||||||
|
if (size >= bestSize) {
|
||||||
|
best = url;
|
||||||
|
bestSize = size;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return best;
|
||||||
|
}
|
||||||
@@ -0,0 +1,617 @@
|
|||||||
|
import type { CollectedProductPayload } from '@atlas/contracts';
|
||||||
|
import {
|
||||||
|
collectOzonProduct,
|
||||||
|
isOzonProductPage,
|
||||||
|
type OzonPageJsonFetcher,
|
||||||
|
} from './ozon-parser.js';
|
||||||
|
import { collectPlatformDomAssets } from './product-media.js';
|
||||||
|
|
||||||
|
type SourcePlatform = '1688' | 'taobao' | 'tmall' | 'ozon';
|
||||||
|
type Json = Record<string, unknown>;
|
||||||
|
type Axis = CollectedProductPayload['variantAxes'][number];
|
||||||
|
type Variant = CollectedProductPayload['variants'][number];
|
||||||
|
|
||||||
|
export function supportedProductPlatform(
|
||||||
|
url = location.href,
|
||||||
|
): SourcePlatform | null {
|
||||||
|
if (isOzonProductPage(url)) return 'ozon';
|
||||||
|
if (/^https:\/\/detail\.1688\.com\/offer\/\d+\.html/.test(url))
|
||||||
|
return '1688';
|
||||||
|
if (/^https:\/\/item\.taobao\.com\/item\.htm/.test(url)) return 'taobao';
|
||||||
|
if (/^https:\/\/detail\.tmall\.com\/item\.htm/.test(url)) return 'tmall';
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns only the public, allowlisted product-state projection supplied by
|
||||||
|
* the platform-specific MAIN-world bridge. */
|
||||||
|
export type MarketplacePageStateFetcher = (
|
||||||
|
platform: Exclude<SourcePlatform, 'ozon'>,
|
||||||
|
) => Promise<unknown>;
|
||||||
|
|
||||||
|
export async function collectCurrentProduct(
|
||||||
|
root: Document = document,
|
||||||
|
pageUrl = location.href,
|
||||||
|
fetchOzonPageJson?: OzonPageJsonFetcher,
|
||||||
|
fetchMarketplacePageState?: MarketplacePageStateFetcher,
|
||||||
|
): Promise<CollectedProductPayload> {
|
||||||
|
const platform = supportedProductPlatform(pageUrl);
|
||||||
|
if (platform === 'ozon')
|
||||||
|
return collectOzonProduct(root, pageUrl, fetchOzonPageJson);
|
||||||
|
if (!platform) throw new Error('当前页面不是支持的商品详情页');
|
||||||
|
const pageState = fetchMarketplacePageState
|
||||||
|
? await fetchMarketplacePageState(platform)
|
||||||
|
: undefined;
|
||||||
|
return platform === '1688'
|
||||||
|
? collect1688(root, pageUrl, pageState)
|
||||||
|
: collectTaobao(root, pageUrl, platform, pageState);
|
||||||
|
}
|
||||||
|
|
||||||
|
function collect1688(root: Document, pageUrl: string, bridgedState?: unknown): CollectedProductPayload {
|
||||||
|
const state = [bridgedState, ...pageState(root)];
|
||||||
|
const skuMap = findFirst(state, 'skuMapOriginal');
|
||||||
|
const skuProps = findFirst(state, 'skuProps');
|
||||||
|
const axes = parse1688Axes(skuProps);
|
||||||
|
const evidence: CollectedProductPayload['evidence'] = [
|
||||||
|
{
|
||||||
|
ref: 'page:url',
|
||||||
|
kind: 'page',
|
||||||
|
path: 'location.href',
|
||||||
|
raw_value: pageUrl,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const variants: Variant[] = [];
|
||||||
|
const skuRows = Array.isArray(skuMap)
|
||||||
|
? skuMap.map((row) => [stringAt(isRecord(row) ? row : {}, ['specAttrs', 'propPath', 'skuId']) ?? '', row] as const)
|
||||||
|
: isRecord(skuMap)
|
||||||
|
? Object.entries(skuMap)
|
||||||
|
: [];
|
||||||
|
if (skuRows.length) {
|
||||||
|
evidence.push({
|
||||||
|
ref: '1688:sku-map',
|
||||||
|
kind: 'state',
|
||||||
|
path: 'skuMapOriginal',
|
||||||
|
raw_value: `组合记录 ${skuRows.length} 条`,
|
||||||
|
});
|
||||||
|
for (const [combination, row] of skuRows) {
|
||||||
|
if (!isRecord(row)) continue;
|
||||||
|
const sourceSku = stringAt(row, ['skuId', 'sku_id', 'skuCode']);
|
||||||
|
// A skuMapOriginal entry itself is a source-provided sellable-combination
|
||||||
|
// record. Never expand it with the Cartesian product of skuProps.
|
||||||
|
const sourceVariantKey = sourceSku ?? `1688:${combination}`;
|
||||||
|
if (!combination || combination === 'undefined') continue;
|
||||||
|
variants.push({
|
||||||
|
sourceVariantKey,
|
||||||
|
...(sourceSku ? { sourceSku } : {}),
|
||||||
|
displayName:
|
||||||
|
selectionsForCombination(combination, axes)
|
||||||
|
.map((item) => item.valueName)
|
||||||
|
.join(' / ') || `1688 SKU ${sourceSku ?? combination}`,
|
||||||
|
selections: selectionsForCombination(combination, axes),
|
||||||
|
...(moneyAt(row, ['discountPrice', 'price', 'salePrice'])
|
||||||
|
? {
|
||||||
|
sourcePrice: {
|
||||||
|
amount: moneyAt(row, [
|
||||||
|
'discountPrice',
|
||||||
|
'price',
|
||||||
|
'salePrice',
|
||||||
|
])!,
|
||||||
|
currencyCode: 'CNY',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
confidence: 'verified',
|
||||||
|
evidenceRefs: ['1688:sku-map'],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return basePayload(
|
||||||
|
'1688',
|
||||||
|
offerId(pageUrl),
|
||||||
|
root,
|
||||||
|
pageUrl,
|
||||||
|
axes,
|
||||||
|
uniqueVariants(variants),
|
||||||
|
evidence,
|
||||||
|
'1688-structured-2.0.0',
|
||||||
|
state,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectTaobao(
|
||||||
|
root: Document,
|
||||||
|
pageUrl: string,
|
||||||
|
platform: 'taobao' | 'tmall',
|
||||||
|
bridgedState?: unknown,
|
||||||
|
): CollectedProductPayload {
|
||||||
|
const state = [bridgedState, ...pageState(root)];
|
||||||
|
const skuBase = findFirst(state, 'skuBase');
|
||||||
|
const skuCore = findFirst(state, 'skuCore');
|
||||||
|
const axes = parseTaobaoAxes(isRecord(skuBase) ? skuBase.props : undefined);
|
||||||
|
const evidence: CollectedProductPayload['evidence'] = [
|
||||||
|
{
|
||||||
|
ref: 'page:url',
|
||||||
|
kind: 'page',
|
||||||
|
path: 'location.href',
|
||||||
|
raw_value: pageUrl,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const variants: Variant[] = [];
|
||||||
|
const skus =
|
||||||
|
isRecord(skuBase) && Array.isArray(skuBase.skus) ? skuBase.skus : [];
|
||||||
|
const sku2info =
|
||||||
|
isRecord(skuCore) && isRecord(skuCore.sku2info) ? skuCore.sku2info : {};
|
||||||
|
if (skus.length)
|
||||||
|
evidence.push({
|
||||||
|
ref: 'taobao:sku-base',
|
||||||
|
kind: 'state',
|
||||||
|
path: 'skuBase.skus',
|
||||||
|
raw_value: `组合记录 ${skus.length} 条`,
|
||||||
|
});
|
||||||
|
for (const sku of skus) {
|
||||||
|
if (!isRecord(sku)) continue;
|
||||||
|
const skuId = stringAt(sku, ['skuId', 'sku_id']);
|
||||||
|
const propPath = stringAt(sku, ['propPath', 'prop_path']);
|
||||||
|
// skuId + propPath is the required combination proof. Props alone only
|
||||||
|
// become axes and cause a single default SKU on the server.
|
||||||
|
if (!skuId || !propPath) continue;
|
||||||
|
const info = isRecord(sku2info[skuId]) ? sku2info[skuId] : {};
|
||||||
|
const selections = selectionsForPropPath(propPath, axes);
|
||||||
|
variants.push({
|
||||||
|
sourceVariantKey: skuId,
|
||||||
|
sourceSku: skuId,
|
||||||
|
displayName:
|
||||||
|
selections.map((item) => item.valueName).join(' / ') ||
|
||||||
|
`SKU ${skuId}`,
|
||||||
|
selections,
|
||||||
|
...(moneyAt(info, ['price', 'promotionPrice', 'discountPrice'])
|
||||||
|
? {
|
||||||
|
sourcePrice: {
|
||||||
|
amount: moneyAt(info, [
|
||||||
|
'price',
|
||||||
|
'promotionPrice',
|
||||||
|
'discountPrice',
|
||||||
|
])!,
|
||||||
|
currencyCode: 'CNY',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
confidence: 'verified',
|
||||||
|
evidenceRefs: ['taobao:sku-base'],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return basePayload(
|
||||||
|
platform,
|
||||||
|
itemId(pageUrl),
|
||||||
|
root,
|
||||||
|
pageUrl,
|
||||||
|
axes,
|
||||||
|
uniqueVariants(variants),
|
||||||
|
evidence,
|
||||||
|
`${platform}-structured-2.0.0`,
|
||||||
|
state,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function basePayload(
|
||||||
|
platform: Exclude<SourcePlatform, 'ozon'>,
|
||||||
|
itemKey: string,
|
||||||
|
root: Document,
|
||||||
|
pageUrl: string,
|
||||||
|
axes: Axis[],
|
||||||
|
variants: Variant[],
|
||||||
|
evidence: CollectedProductPayload['evidence'],
|
||||||
|
parserVersion: string,
|
||||||
|
states: unknown[],
|
||||||
|
): CollectedProductPayload {
|
||||||
|
const canonical =
|
||||||
|
root.querySelector<HTMLLinkElement>('link[rel="canonical"]')?.href ??
|
||||||
|
pageUrl.split('#')[0];
|
||||||
|
const facts = extractMarketplaceFacts(root, states, platform);
|
||||||
|
const title = facts.title ?? (
|
||||||
|
root.querySelector('h1')?.textContent?.replace(/\s+/g, ' ').trim() ||
|
||||||
|
root
|
||||||
|
.querySelector<HTMLMetaElement>('meta[property="og:title"]')
|
||||||
|
?.content?.trim()
|
||||||
|
);
|
||||||
|
const assets = collectPlatformDomAssets(platform, root, canonical);
|
||||||
|
evidence.push({
|
||||||
|
ref: `${platform}:dom:image-profile`,
|
||||||
|
kind: 'dom',
|
||||||
|
path: 'platform-scoped gallery / sku / detail selectors',
|
||||||
|
raw_value: `采集 ${assets.length} 张受限商品区域图片`,
|
||||||
|
});
|
||||||
|
const videoCount = addMarketplaceVideos(platform, states, assets, canonical);
|
||||||
|
if (videoCount) evidence.push({
|
||||||
|
ref: `${platform}:structured:videos`,
|
||||||
|
kind: 'state',
|
||||||
|
path: platform === '1688' ? 'gallery.fields.video|videos' : 'item.videos',
|
||||||
|
raw_value: `采集 ${videoCount} 个结构化商品视频`,
|
||||||
|
});
|
||||||
|
if (facts.attributes.length) evidence.push({ ref: `${platform}:structured:attributes`, kind: 'state', path: 'allowlisted product state parameter lists', raw_value: `采集 ${facts.attributes.length} 项结构化特征` });
|
||||||
|
if (facts.description) evidence.push({ ref: `${platform}:json-ld:description`, kind: 'json_ld', path: 'Product.description', raw_value: `采集 ${facts.description.length} 个字符描述` });
|
||||||
|
return {
|
||||||
|
source: {
|
||||||
|
platform,
|
||||||
|
itemKey,
|
||||||
|
canonicalUrl: canonical,
|
||||||
|
collectedAt: new Date().toISOString(),
|
||||||
|
parserVersion,
|
||||||
|
},
|
||||||
|
common: {
|
||||||
|
...(title
|
||||||
|
? {
|
||||||
|
title: {
|
||||||
|
value: title,
|
||||||
|
raw_value: title,
|
||||||
|
confidence: 'verified' as const,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
...(facts.price ? { sourcePrice: { amount: facts.price, currencyCode: 'CNY', raw_value: facts.price } } : {}),
|
||||||
|
...(facts.description ? { description: { value: facts.description, raw_value: facts.description, confidence: 'verified' as const } } : {}),
|
||||||
|
attributes: facts.attributes,
|
||||||
|
categories: facts.categories,
|
||||||
|
},
|
||||||
|
variantAxes: axes,
|
||||||
|
variants,
|
||||||
|
assets,
|
||||||
|
evidence,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The old collector's proven video sources are structured product state. Do
|
||||||
|
* not broaden this to page video elements: they also contain feeds and reviews. */
|
||||||
|
function addMarketplaceVideos(
|
||||||
|
platform: Exclude<SourcePlatform, 'ozon'>,
|
||||||
|
states: unknown[],
|
||||||
|
assets: CollectedProductPayload['assets'],
|
||||||
|
pageUrl: string,
|
||||||
|
) {
|
||||||
|
const gallery = findFirst(states, 'gallery');
|
||||||
|
const item = findFirst(states, 'item');
|
||||||
|
const galleryFields = isRecord(gallery) && isRecord(gallery.fields)
|
||||||
|
? gallery.fields
|
||||||
|
: gallery;
|
||||||
|
const raw = platform === '1688'
|
||||||
|
? [
|
||||||
|
...(isRecord(galleryFields) && Array.isArray(galleryFields.videos) ? galleryFields.videos : []),
|
||||||
|
...(isRecord(galleryFields) && galleryFields.video !== undefined ? [galleryFields.video] : []),
|
||||||
|
]
|
||||||
|
: isRecord(item) && Array.isArray(item.videos)
|
||||||
|
? item.videos
|
||||||
|
: [];
|
||||||
|
let added = 0;
|
||||||
|
const seen = new Set(assets.map((asset) => `video:${asset.url}`));
|
||||||
|
for (const entry of raw) {
|
||||||
|
if (assets.length >= 500) break;
|
||||||
|
const record = isRecord(entry) ? entry : undefined;
|
||||||
|
const url = directVideoUrl(
|
||||||
|
typeof entry === 'string' ? entry : stringAt(record ?? {}, ['url', 'videoUrl', 'src']),
|
||||||
|
pageUrl,
|
||||||
|
);
|
||||||
|
if (!url || seen.has(`video:${url}`)) continue;
|
||||||
|
seen.add(`video:${url}`);
|
||||||
|
const posterUrl = imageUrl(
|
||||||
|
stringAt(record ?? {}, ['videoThumbnailURL', 'thumbnailUrl', 'coverUrl', 'coverImage', 'poster']),
|
||||||
|
pageUrl,
|
||||||
|
);
|
||||||
|
assets.push({
|
||||||
|
url,
|
||||||
|
mediaType: 'video',
|
||||||
|
...(posterUrl ? { posterUrl } : {}),
|
||||||
|
role: 'source_main',
|
||||||
|
relation: 'product_candidate',
|
||||||
|
sortOrder: assets.length,
|
||||||
|
evidenceRef: `${platform}:structured:videos`,
|
||||||
|
});
|
||||||
|
added += 1;
|
||||||
|
}
|
||||||
|
return added;
|
||||||
|
}
|
||||||
|
|
||||||
|
function directVideoUrl(value: string | undefined, pageUrl: string) {
|
||||||
|
if (!value) return undefined;
|
||||||
|
try {
|
||||||
|
const url = new URL(value, pageUrl);
|
||||||
|
if (url.protocol !== 'https:' || /\.m3u8(?:$|[?#])/i.test(url.pathname)) return undefined;
|
||||||
|
return /\.(mp4|webm)(?:$|[?#])/i.test(url.pathname) ? url.href : undefined;
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function imageUrl(value: string | undefined, pageUrl: string) {
|
||||||
|
if (!value) return undefined;
|
||||||
|
try {
|
||||||
|
const url = new URL(value, pageUrl);
|
||||||
|
return url.protocol === 'https:' ? url.href : undefined;
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractMarketplaceFacts(root: Document, states: unknown[], platform: Exclude<SourcePlatform, 'ozon'>) {
|
||||||
|
const attributes: CollectedProductPayload['common']['attributes'] = [];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
for (const key of ['basicParamList', 'enhanceParamList', 'attributes', 'props']) {
|
||||||
|
const list = findFirst(states, key);
|
||||||
|
if (!Array.isArray(list)) continue;
|
||||||
|
for (const [index, entry] of list.entries()) {
|
||||||
|
if (!isRecord(entry)) continue;
|
||||||
|
const name = stringAt(entry, ['propertyName', 'name', 'key', 'title']);
|
||||||
|
const value = stringAt(entry, ['valueName', 'value', 'text']);
|
||||||
|
if (!name || !value || seen.has(`${name}\u0000${value}`)) continue;
|
||||||
|
seen.add(`${name}\u0000${value}`);
|
||||||
|
attributes.push({ key: `${platform}:${stringAt(entry, ['propertyId', 'id', 'key']) ?? index}`.slice(0, 200), name: name.slice(0, 300), value: value.slice(0, 2_000), raw_value: value.slice(0, 4_000) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const product = jsonLdProduct(root);
|
||||||
|
const item = findFirst(states, 'item');
|
||||||
|
const temp = findFirst(states, 'tempModel');
|
||||||
|
const tradeModel = findFirst(states, 'tradeModel');
|
||||||
|
const priceVO = findFirst(states, 'priceVO');
|
||||||
|
const price = moneyAt(isRecord(tradeModel) ? tradeModel : {}, ['minPrice', 'price'])
|
||||||
|
?? moneyAt(isRecord(priceVO) ? priceVO : {}, ['priceText', 'price', 'extraPrice'])
|
||||||
|
?? decimal(product?.offers && isRecord(product.offers) ? product.offers.price : undefined);
|
||||||
|
const categoryNames = Array.from(root.querySelectorAll<HTMLAnchorElement>('[class*="breadcrumb"] a, [class*="BreadCrumb"] a, nav a[href*="category"]')).map((link) => link.textContent?.replace(/\s+/g, ' ').trim() ?? '').filter(Boolean).slice(0, 50);
|
||||||
|
return {
|
||||||
|
title: (isRecord(item) ? stringAt(item, ['title', 'name']) : undefined) ?? (isRecord(temp) ? stringAt(temp, ['offerTitle']) : undefined) ?? (product ? stringAt(product, ['name']) : undefined),
|
||||||
|
price,
|
||||||
|
description: (product ? stringAt(product, ['description']) : undefined) ?? scopedDescription(root, platform),
|
||||||
|
attributes,
|
||||||
|
categories: [...new Set(categoryNames)].map((name) => ({ key: `${platform}:category:${name.toLowerCase()}`.slice(0, 300), name })),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function scopedDescription(root: Document, platform: Exclude<SourcePlatform, 'ozon'>) {
|
||||||
|
const selectors = platform === '1688'
|
||||||
|
? ['.de-description-detail', '#detailContentContainer', '.html-description']
|
||||||
|
: ['[class*="tabDetailWrap--"]', '[class*="detailInfo--"]'];
|
||||||
|
for (const selector of selectors) {
|
||||||
|
const value = root.querySelector(selector)?.textContent?.replace(/\s+/g, ' ').trim();
|
||||||
|
if (value && value.length >= 10) return value.slice(0, 100_000);
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function jsonLdProduct(root: Document): Json | undefined {
|
||||||
|
for (const script of Array.from(root.scripts)) {
|
||||||
|
if (script.type !== 'application/ld+json' || !script.textContent?.trim()) continue;
|
||||||
|
try { const found = findJsonLdProduct(JSON.parse(script.textContent)); if (found) return found; } catch { /* ignored */ }
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
function findJsonLdProduct(value: unknown): Json | undefined {
|
||||||
|
if (Array.isArray(value)) { for (const child of value) { const found = findJsonLdProduct(child); if (found) return found; } return undefined; }
|
||||||
|
if (!isRecord(value)) return undefined;
|
||||||
|
const type = value['@type'];
|
||||||
|
if (type === 'Product' || (Array.isArray(type) && type.includes('Product'))) return value;
|
||||||
|
return Array.isArray(value['@graph']) ? findJsonLdProduct(value['@graph']) : undefined;
|
||||||
|
}
|
||||||
|
function decimal(value: unknown) { const raw = typeof value === 'string' || typeof value === 'number' ? String(value).replace(',', '.') : ''; return /^\d+(\.\d{1,4})?$/.test(raw) ? raw : undefined; }
|
||||||
|
|
||||||
|
function pageState(root: Document) {
|
||||||
|
const all: unknown[] = [];
|
||||||
|
for (const script of Array.from(root.scripts)) {
|
||||||
|
const text = script.textContent?.trim() ?? '';
|
||||||
|
if (text.length < 2 || text.length > 5_000_000) continue;
|
||||||
|
if (script.type === 'application/ld+json') {
|
||||||
|
try {
|
||||||
|
all.push(JSON.parse(text));
|
||||||
|
} catch {
|
||||||
|
/* ignored evidence */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const marker of [
|
||||||
|
'skuMapOriginal',
|
||||||
|
'skuBase',
|
||||||
|
'skuCore',
|
||||||
|
'skuProps',
|
||||||
|
]) {
|
||||||
|
const object = objectAfterMarker(text, marker);
|
||||||
|
if (object) all.push(object);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return all;
|
||||||
|
}
|
||||||
|
function objectAfterMarker(text: string, marker: string): unknown | null {
|
||||||
|
const position = text.indexOf(marker);
|
||||||
|
if (position < 0) return null;
|
||||||
|
const start = text.slice(position).search(/[\[{]/);
|
||||||
|
if (start < 0) return null;
|
||||||
|
const first = position + start;
|
||||||
|
const open = text[first];
|
||||||
|
const close = open === '{' ? '}' : ']';
|
||||||
|
let depth = 0;
|
||||||
|
let quote = '';
|
||||||
|
let escaped = false;
|
||||||
|
for (
|
||||||
|
let index = first;
|
||||||
|
index < text.length && index - first < 2_000_000;
|
||||||
|
index += 1
|
||||||
|
) {
|
||||||
|
const char = text[index];
|
||||||
|
if (quote) {
|
||||||
|
if (escaped) escaped = false;
|
||||||
|
else if (char === '\\') escaped = true;
|
||||||
|
else if (char === quote) quote = '';
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (char === '"' || char === "'") {
|
||||||
|
quote = char;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (char === open) depth += 1;
|
||||||
|
if (char === close && --depth === 0) {
|
||||||
|
try {
|
||||||
|
return JSON.parse(text.slice(first, index + 1));
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
function findFirst(states: unknown[], key: string): unknown {
|
||||||
|
for (const state of states) {
|
||||||
|
const found = findKey(state, key);
|
||||||
|
if (found !== undefined) return found;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
function findKey(
|
||||||
|
value: unknown,
|
||||||
|
key: string,
|
||||||
|
seen = new Set<unknown>(),
|
||||||
|
): unknown {
|
||||||
|
if (!value || typeof value !== 'object' || seen.has(value))
|
||||||
|
return undefined;
|
||||||
|
seen.add(value);
|
||||||
|
if (isRecord(value) && key in value) return value[key];
|
||||||
|
for (const child of Array.isArray(value) ? value : Object.values(value)) {
|
||||||
|
const found = findKey(child, key, seen);
|
||||||
|
if (found !== undefined) return found;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
function parse1688Axes(value: unknown): Axis[] {
|
||||||
|
const entries = Array.isArray(value)
|
||||||
|
? value
|
||||||
|
: isRecord(value) && Array.isArray(value.skuProps)
|
||||||
|
? value.skuProps
|
||||||
|
: [];
|
||||||
|
return entries.flatMap((entry, axisIndex) => {
|
||||||
|
if (!isRecord(entry)) return [];
|
||||||
|
const axisKey =
|
||||||
|
stringAt(entry, ['prop', 'propId', 'id']) ?? `axis:${axisIndex}`;
|
||||||
|
const name = stringAt(entry, ['name', 'propName']) ?? axisKey;
|
||||||
|
const values = (
|
||||||
|
Array.isArray(entry.value)
|
||||||
|
? entry.value
|
||||||
|
: Array.isArray(entry.values)
|
||||||
|
? entry.values
|
||||||
|
: []
|
||||||
|
).flatMap((item, valueIndex) =>
|
||||||
|
isRecord(item)
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
key:
|
||||||
|
stringAt(item, ['valueId', 'vid', 'id']) ??
|
||||||
|
`${axisKey}:${valueIndex}`,
|
||||||
|
name:
|
||||||
|
stringAt(item, ['name', 'valueName']) ??
|
||||||
|
`${valueIndex + 1}`,
|
||||||
|
...(urlAt(item, [
|
||||||
|
'imageUrl',
|
||||||
|
'image',
|
||||||
|
'imageUrlLarge',
|
||||||
|
])
|
||||||
|
? {
|
||||||
|
image_url: urlAt(item, [
|
||||||
|
'imageUrl',
|
||||||
|
'image',
|
||||||
|
'imageUrlLarge',
|
||||||
|
])!,
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: [],
|
||||||
|
);
|
||||||
|
return values.length ? [{ key: axisKey, name, values }] : [];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function parseTaobaoAxes(value: unknown): Axis[] {
|
||||||
|
return (Array.isArray(value) ? value : []).flatMap((entry, index) => {
|
||||||
|
if (!isRecord(entry)) return [];
|
||||||
|
const key = stringAt(entry, ['pid', 'propId']) ?? `axis:${index}`;
|
||||||
|
const name = stringAt(entry, ['name', 'propName']) ?? key;
|
||||||
|
const values = (
|
||||||
|
Array.isArray(entry.values) ? entry.values : []
|
||||||
|
).flatMap((item, valueIndex) =>
|
||||||
|
isRecord(item)
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
key:
|
||||||
|
stringAt(item, ['vid', 'valueId']) ??
|
||||||
|
`${key}:${valueIndex}`,
|
||||||
|
name:
|
||||||
|
stringAt(item, ['name', 'valueName']) ??
|
||||||
|
`${valueIndex + 1}`,
|
||||||
|
...(urlAt(item, ['image', 'imageUrl'])
|
||||||
|
? {
|
||||||
|
image_url: urlAt(item, [
|
||||||
|
'image',
|
||||||
|
'imageUrl',
|
||||||
|
])!,
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: [],
|
||||||
|
);
|
||||||
|
return values.length ? [{ key, name, values }] : [];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function selectionsForPropPath(path: string, axes: Axis[]) {
|
||||||
|
return path.split(/[;,]/).flatMap((pair) => {
|
||||||
|
const [axisKey, valueKey] = pair.split(':');
|
||||||
|
const axis = axes.find((item) => item.key === axisKey);
|
||||||
|
const value = axis?.values.find((item) => item.key === valueKey);
|
||||||
|
return axis && value
|
||||||
|
? [{ axisKey, valueKey, valueName: value.name }]
|
||||||
|
: [];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function selectionsForCombination(combination: string, axes: Axis[]) {
|
||||||
|
const ids = combination
|
||||||
|
.split(/[;,&]/)
|
||||||
|
.map((value) => value.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
return axes.flatMap((axis) => {
|
||||||
|
const value = axis.values.find((item) => ids.includes(item.key));
|
||||||
|
return value
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
axisKey: axis.key,
|
||||||
|
valueKey: value.key,
|
||||||
|
valueName: value.name,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: [];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function stringAt(value: Json, keys: string[]) {
|
||||||
|
for (const key of keys)
|
||||||
|
if (typeof value[key] === 'string' || typeof value[key] === 'number')
|
||||||
|
return String(value[key]);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
function moneyAt(value: Json, keys: string[]) {
|
||||||
|
const raw = stringAt(value, keys)?.replace(/,/g, '.');
|
||||||
|
return raw && /^\d+(\.\d{1,4})?$/.test(raw) ? raw : undefined;
|
||||||
|
}
|
||||||
|
function urlAt(value: Json, keys: string[]) {
|
||||||
|
const raw = stringAt(value, keys);
|
||||||
|
return raw && /^https:\/\//.test(raw) ? raw : undefined;
|
||||||
|
}
|
||||||
|
function uniqueVariants(variants: Variant[]) {
|
||||||
|
const seen = new Set<string>();
|
||||||
|
return variants.filter(
|
||||||
|
(variant) =>
|
||||||
|
!seen.has(variant.sourceVariantKey) &&
|
||||||
|
!!seen.add(variant.sourceVariantKey),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
function offerId(url: string) {
|
||||||
|
return (
|
||||||
|
url.match(/\/offer\/(\d+)\.html/)?.[1] ??
|
||||||
|
`1688:${new URL(url).pathname}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
function itemId(url: string) {
|
||||||
|
return new URL(url).searchParams.get('id') ?? new URL(url).pathname;
|
||||||
|
}
|
||||||
|
function isRecord(value: unknown): value is Json {
|
||||||
|
return !!value && typeof value === 'object' && !Array.isArray(value);
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"name": "@atlas/extension",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"build": "wxt build",
|
||||||
|
"dev": "wxt",
|
||||||
|
"typecheck": "wxt prepare && tsc -p tsconfig.json --noEmit",
|
||||||
|
"test": "tsx --test test/*.test.mts",
|
||||||
|
"test:parsers": "tsx --test test/*.test.mts"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@atlas/contracts": "workspace:*",
|
||||||
|
"@wxt-dev/module-react": "1.2.2",
|
||||||
|
"react": "19.2.8",
|
||||||
|
"react-dom": "19.2.8"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/react": "19.2.18",
|
||||||
|
"@types/react-dom": "19.2.5",
|
||||||
|
"tsx": "4.23.13",
|
||||||
|
"typescript": "7.0.2",
|
||||||
|
"wxt": "0.21.4"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { readFile } from 'node:fs/promises';
|
||||||
|
import test from 'node:test';
|
||||||
|
|
||||||
|
import {
|
||||||
|
extractOzonCharacteristics,
|
||||||
|
extractOzonDescription,
|
||||||
|
extractOzonJsonLdDescription,
|
||||||
|
extractOzonGalleryImageUrls,
|
||||||
|
extractOzonGalleryVideos,
|
||||||
|
} from '../lib/ozon-parser.ts';
|
||||||
|
import { dedupeImageUrl, normaliseImageUrl } from '../lib/product-media.ts';
|
||||||
|
|
||||||
|
test('Ozon gallery fixture only returns product-gallery images', async () => {
|
||||||
|
const fixture = await readFile(
|
||||||
|
new URL('../../../packages/reference/ozon1.html', import.meta.url),
|
||||||
|
'utf8',
|
||||||
|
);
|
||||||
|
const state = fixture.match(
|
||||||
|
/id="state-webGallery-[^"]+"\s+data-state='([^']+)'/,
|
||||||
|
)?.[1];
|
||||||
|
assert.ok(state, 'Ozon gallery widget state must exist in the fixture');
|
||||||
|
|
||||||
|
const urls = extractOzonGalleryImageUrls(
|
||||||
|
JSON.parse(state),
|
||||||
|
'https://www.ozon.ru/product/ryukzak-5387005639/',
|
||||||
|
);
|
||||||
|
assert.equal(urls.length, 11);
|
||||||
|
assert.ok(urls.every((url) => /\/s3\/multimedia-/.test(url)));
|
||||||
|
assert.ok(
|
||||||
|
urls.every(
|
||||||
|
(url) => !/logo_ozon|payments-cdn|recommend|review/i.test(url),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Ozon short characteristics fixture returns its product parameters', async () => {
|
||||||
|
const fixture = await readFile(
|
||||||
|
new URL('../../../packages/reference/ozon1.html', import.meta.url),
|
||||||
|
'utf8',
|
||||||
|
);
|
||||||
|
const state = fixture.match(
|
||||||
|
/id="state-webShortCharacteristics-[^"]+"\s+data-state='([^']+)'/,
|
||||||
|
)?.[1];
|
||||||
|
assert.ok(state, 'Ozon short characteristics widget state must exist');
|
||||||
|
|
||||||
|
const attributes = extractOzonCharacteristics([
|
||||||
|
{
|
||||||
|
...JSON.parse(state),
|
||||||
|
__atlasWidgetName: 'webShortCharacteristics-fixture',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
assert.equal(attributes.length, 5);
|
||||||
|
assert.deepEqual(
|
||||||
|
attributes.slice(0, 3).map(({ name, value }) => ({ name, value })),
|
||||||
|
[
|
||||||
|
{ name: '类型', value: '背包' },
|
||||||
|
{ name: '原产国', value: '中国' },
|
||||||
|
{ name: '材料', value: 'Cordura 1000D' },
|
||||||
|
],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Ozon rich annotation converts to description text without images', () => {
|
||||||
|
const description = extractOzonDescription([
|
||||||
|
{
|
||||||
|
__atlasWidgetName: 'webDescription-fixture',
|
||||||
|
richAnnotationJson: JSON.stringify([
|
||||||
|
{ type: 'text', content: '耐用的背包' },
|
||||||
|
{ type: 'image', src: 'https://ir.ozone.ru/s3/example.jpg' },
|
||||||
|
{ type: 'text', text: '适合旅行' },
|
||||||
|
]),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
assert.equal(description?.value, '耐用的背包\n适合旅行');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Ozon JSON-LD Product description is the fallback when rich content is absent', () => {
|
||||||
|
const root = {
|
||||||
|
scripts: [{
|
||||||
|
type: 'application/ld+json',
|
||||||
|
textContent: JSON.stringify({
|
||||||
|
'@context': 'http://schema.org',
|
||||||
|
'@type': 'Product',
|
||||||
|
name: '喷雾器',
|
||||||
|
description: '来自 Ozon JSON-LD 的完整商品描述',
|
||||||
|
}),
|
||||||
|
}],
|
||||||
|
} as unknown as Document;
|
||||||
|
assert.equal(
|
||||||
|
extractOzonJsonLdDescription(root)?.value,
|
||||||
|
'来自 Ozon JSON-LD 的完整商品描述',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Ozon full characteristics merge with the five short header characteristics', () => {
|
||||||
|
const attributes = extractOzonCharacteristics([
|
||||||
|
{
|
||||||
|
__atlasWidgetName: 'webShortCharacteristics-fixture',
|
||||||
|
characteristics: [{ id: 'type', title: { textRs: [{ content: '类型' }] }, values: [{ text: '喷雾器' }] }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
__atlasWidgetName: 'webCharacteristics-fixture',
|
||||||
|
characteristics: [{ id: 'weight', title: { textRs: [{ content: '商品重量,克' }] }, values: [{ text: '385' }] }],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
assert.deepEqual(attributes.map(({ name, value }) => ({ name, value })), [
|
||||||
|
{ name: '类型', value: '喷雾器' },
|
||||||
|
{ name: '商品重量,克', value: '385' },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Ozon thumbnail variants normalise to one original URL', () => {
|
||||||
|
const original = 'https://ir.ozone.ru/s3/multimedia-1-9/13597160877.jpg';
|
||||||
|
const thumbnail =
|
||||||
|
'https://ir.ozone.ru/s3/multimedia-1-9/wc100/13597160877.jpg?width=100&quality=80';
|
||||||
|
assert.equal(normaliseImageUrl(thumbnail, original, 'ozon'), original);
|
||||||
|
assert.equal(
|
||||||
|
dedupeImageUrl(thumbnail, 'ozon'),
|
||||||
|
dedupeImageUrl(original, 'ozon'),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Ozon gallery only accepts direct product MP4/WebM videos and retains poster', () => {
|
||||||
|
assert.deepEqual(
|
||||||
|
extractOzonGalleryVideos(
|
||||||
|
{
|
||||||
|
videos: [
|
||||||
|
{
|
||||||
|
url: 'https://cdn.ozon.ru/product/demo.mp4',
|
||||||
|
coverImage: 'https://ir.ozone.ru/s3/multimedia-1/demo.jpg',
|
||||||
|
},
|
||||||
|
{ videoUrl: 'https://cdn.ozon.ru/product/demo.mp4' },
|
||||||
|
{ url: 'https://cdn.ozon.ru/product/stream.m3u8' },
|
||||||
|
{ url: 'blob:https://www.ozon.ru/opaque' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
'https://www.ozon.ru/product/demo-1/',
|
||||||
|
),
|
||||||
|
[{
|
||||||
|
url: 'https://cdn.ozon.ru/product/demo.mp4',
|
||||||
|
posterUrl: 'https://ir.ozone.ru/s3/multimedia-1/demo.jpg',
|
||||||
|
}],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Alibaba transformed image URLs normalise before de-duplication', () => {
|
||||||
|
const original = 'https://cbu01.alicdn.com/img/ibank/O1CN01.jpg';
|
||||||
|
const transformed = `${original}_400x400.jpg?width=400`;
|
||||||
|
assert.equal(normaliseImageUrl(transformed, original, '1688'), original);
|
||||||
|
});
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"noEmit": true
|
||||||
|
},
|
||||||
|
"include": ["**/*.ts", "**/*.tsx", ".wxt/**/*.ts"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { defineConfig } from 'wxt';
|
||||||
|
|
||||||
|
const atlasWebOrigin = new URL(process.env.ATLAS_WEB_ORIGIN ?? 'http://localhost:8100').origin;
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
modules: ['@wxt-dev/module-react'],
|
||||||
|
// 扩展 dev server 固定 8110:默认取 3000–3010 第一个空闲端口,会漂移且易与常见服务冲突
|
||||||
|
dev: {
|
||||||
|
server: {
|
||||||
|
port: Number(process.env.EXTENSION_PORT ?? 8110),
|
||||||
|
strictPort: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
manifest: {
|
||||||
|
name: 'Atlas Seller Kit Collector',
|
||||||
|
description: '将已确认的来源商品采集到 Atlas 工作空间。',
|
||||||
|
// Only the public key is committed. Chrome derives a stable extension ID
|
||||||
|
// from it for unpacked and self-hosted builds; never put a private key here.
|
||||||
|
key: 'MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAsPGP9lPew7Xf+xWAc7yIAp+eLjC5SxkqzMXV/9dyn/HtMsskSbVGuYbmg0MWJdi7g3fLe/sa16Q+xp59C+yt11vKGO3a9vho6SfKDnXzkiLBvxKILTBvymnNtk4U/O5LMPWU838TnOZNRIxICJdvp1rF0EO2V8mgOY6cBenXNha1WMYpjfl17ehaKxgrKTFOT63ynTgT6NVbf3sFwi0hFCDH59TfTu5w8iQbYpm+2C+N9cmWfCcnmxtz3TxL+PCtW+RQhfEH1AFCxKZe8JkWqe2XrxLvQHjBlmM4dSvrazA454NhvGkQD41hQqLkfNAooSxLby33t2LWkY7dJmVJDQIDAQAB',
|
||||||
|
// `tabs` 只用于查询已打开的 Atlas 标签页以同步登录态;不读取其他网页内容。
|
||||||
|
permissions: ['storage', 'scripting', 'tabs'],
|
||||||
|
host_permissions: [
|
||||||
|
'http://localhost:8200/*',
|
||||||
|
`${atlasWebOrigin}/*`,
|
||||||
|
'*://*.ozon.ru/*',
|
||||||
|
'*://*.ozon.kz/*',
|
||||||
|
'*://*.ozon.by/*',
|
||||||
|
'*://*.1688.com/*',
|
||||||
|
'*://*.taobao.com/*',
|
||||||
|
'*://*.tmall.com/*',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
# Web 前端交接:商品编辑器与门店类目
|
||||||
|
|
||||||
|
更新日期:2026-09-11
|
||||||
|
|
||||||
|
本文只覆盖 `apps/web/` 当前未提交的前端改动。开始继续开发前,请先查看仓库根目录的 `git status --short`;工作区同时存在服务端、Worker 和文档改动,它们不属于前端接手者的修改范围。
|
||||||
|
|
||||||
|
## 当前完成状态
|
||||||
|
|
||||||
|
### 商品编辑器
|
||||||
|
|
||||||
|
- 编辑页为 `/products/:id/editor`,主文件为 `src/pages/products/ProductEditPage.tsx`。
|
||||||
|
- 已接入门店级类目 Cascader:保存时只提交末级类目 ID;父级节点可展开,只有叶子节点可选。类目下拉菜单高度为 324px(相对 Ant Design 默认菜单高度提高 80%)。
|
||||||
|
- “更多特征”位于 **02 产品属性** Card 内,不再单独使用 Card;展开后按当前选定类目加载 schema,折叠再展开且类目未变时复用已加载的 schema。加载、空 schema、同步类目属性、字典值搜索、清除字段和旧属性告警均在该区处理。
|
||||||
|
- 动态属性字段按 schema 渲染:普通值为输入框、布尔值为选择框、字典值为可搜索下拉、多值为多选/标签输入;保存时会过滤空的可选属性值,避免提交无效空数组。
|
||||||
|
- “主题标签”和“JSON 富内容”属于产品属性的普通字段,不属于“更多特征”。
|
||||||
|
- 编辑页表单已去除页面级的字号、控件高度覆盖,恢复 Ant Design 主题默认规格。仍保留业务性宽度:型号/品牌/条码 170px,包装重量 150px,包装尺寸各 130px。
|
||||||
|
- 包装重量通过 `package-weight-field` 单独约束输入组宽度,避免 `addonAfter="g"` 与输入框分离;父表单的标签列为 190px、控件区域为可收缩 680px,避免控件被换到标签下一行。
|
||||||
|
|
||||||
|
### 组件拆分现状
|
||||||
|
|
||||||
|
已拆出的组件:
|
||||||
|
|
||||||
|
- `src/pages/products/components/ProductMainInfoSection.tsx`:主要信息、目标门店、类目、标题、品牌、型号、父商品包装规格。
|
||||||
|
- `src/pages/products/components/ProductOtherSection.tsx`:货源链接和备注。
|
||||||
|
- `src/pages/products/components/CollectedProductInfoPanel.tsx`:右侧“采集的商品信息”侧栏。
|
||||||
|
|
||||||
|
仍在 `ProductEditPage.tsx` 中的视图:产品属性/更多特征,以及变体设置。后续若继续组件化,应将它们分别迁为展示组件;父页面只保留快照读取、草稿保存、类目/schema/字典同步和状态编排。迁移时不要改变 `Form.List` 字段路径、乐观锁保存流程或动态属性的按需加载缓存条件。
|
||||||
|
|
||||||
|
### 门店与类目调试入口
|
||||||
|
|
||||||
|
- 店铺列表的“刷新门店类目”按钮在 `src/pages/shops/ShopsPage.tsx`;前端会等待请求完成才结束 loading,并展示成功或失败反馈。
|
||||||
|
- 左侧菜单新增“调试 → Ozon 类目树调试”,路由为 `/shops/category-tree-debug`。
|
||||||
|
- 调试页文件:`src/pages/shops/CategoryTreeDebugPage.tsx`、`CategoryTreeDebugPage.css`;服务封装为 `src/services/categoryTreeDebug.ts`。该页只负责调用已存在的前端 API 封装并以 Ant Design 树展示响应,不能在前端处理或持久化 API Key。
|
||||||
|
|
||||||
|
## 关键前端行为
|
||||||
|
|
||||||
|
1. 选定目标门店后才读取该门店的类目缓存;加载期间 Cascader 显示“正在读取该店铺的类目缓存”。
|
||||||
|
2. 切换类目会清空草稿属性;展开“更多特征”后才读取新类目的 schema。
|
||||||
|
3. 动态属性的字典搜索输入至少两个字符后,用户可显式选择“从 Ozon 搜索”;前端不传递或记录 API Key。
|
||||||
|
4. 页面 API、错误码和返回结构以 `docs/api/` 与 `@atlas/contracts` 为准。前端不要自行扩展接口字段或模拟同步完成状态。
|
||||||
|
|
||||||
|
## 已执行验证
|
||||||
|
|
||||||
|
本次前端改动已执行:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd apps/web
|
||||||
|
../../node_modules/.bin/tsc -p tsconfig.json --noEmit
|
||||||
|
git diff --check
|
||||||
|
```
|
||||||
|
|
||||||
|
两项均通过。后续对交互或样式作变更时,应在已启动的 Web 环境中回归:类目选择、更多特征展开/收起与加载状态、字典查询、包装重量同一行展示、SKU 展开编辑、货源链接按钮对齐,以及调试页的树形渲染。
|
||||||
|
|
||||||
|
## 前端边界
|
||||||
|
|
||||||
|
- 仅修改 `apps/web/` 及前端相关文档。
|
||||||
|
- 不启动、停止或修改 API/Worker;若类目刷新或 schema 请求失败,应先检查服务端运行状态与 API 契约,再交由服务端维护者处理。
|
||||||
|
- 不在浏览器持久化、日志或页面状态中保存 Ozon API Key。
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Atlas Seller Kit</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
{
|
||||||
|
"name": "@atlas/web",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc -b && vite build",
|
||||||
|
"dev": "vite",
|
||||||
|
"typecheck": "tsc -b --pretty false"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@ant-design/icons": "6.3.4",
|
||||||
|
"@atlas/contracts": "workspace:*",
|
||||||
|
"antd": "6.6.2",
|
||||||
|
"axios": "1.20.0",
|
||||||
|
"dayjs": "1.11.23",
|
||||||
|
"react": "19.2.8",
|
||||||
|
"react-dom": "19.2.8",
|
||||||
|
"react-router-dom": "7.18.3"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "26.3.0",
|
||||||
|
"@types/react": "19.2.18",
|
||||||
|
"@types/react-dom": "19.2.5",
|
||||||
|
"@vitejs/plugin-react": "6.1.1",
|
||||||
|
"typescript": "7.0.2",
|
||||||
|
"vite": "8.2.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
export default {
|
||||||
|
printWidth: 100,
|
||||||
|
tabWidth: 4,
|
||||||
|
useTabs: false,
|
||||||
|
semi: true,
|
||||||
|
singleQuote: true,
|
||||||
|
quoteProps: 'as-needed',
|
||||||
|
jsxSingleQuote: false,
|
||||||
|
trailingComma: 'es5',
|
||||||
|
bracketSpacing: true,
|
||||||
|
bracketSameLine: false,
|
||||||
|
arrowParens: 'always',
|
||||||
|
endOfLine: 'lf',
|
||||||
|
embeddedLanguageFormatting: 'auto',
|
||||||
|
};
|
||||||
@@ -0,0 +1,175 @@
|
|||||||
|
/* 全局应用级样式补充(主题变量见 index.css) */
|
||||||
|
|
||||||
|
/* ── 设置类页面(修改资料 / 修改密码):内容区居中卡片 ── */
|
||||||
|
.settings-page {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-card {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 560px;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 10px;
|
||||||
|
/* 四周柔和阴影,与内容区背景拉开层次 */
|
||||||
|
box-shadow: 0 0 24px 0 rgba(17, 24, 39, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-card-title {
|
||||||
|
padding: 20px 32px;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-card-body {
|
||||||
|
padding: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-field-label {
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar-upload-area {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar-upload-placeholder {
|
||||||
|
position: relative;
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar-upload-overlay {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 4px;
|
||||||
|
color: #6b7280;
|
||||||
|
font-size: 13px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: transparent;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar-upload-placeholder:hover .avatar-upload-overlay {
|
||||||
|
background: rgba(255, 255, 255, 0.55);
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar-preview {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar-upload-hint {
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.8;
|
||||||
|
margin: 12px 0 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-form {
|
||||||
|
max-width: 420px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 12px;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── 水印管理 ── */
|
||||||
|
.watermarks-load-more {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 3x3 水印位置九宫格 */
|
||||||
|
.position-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, 44px);
|
||||||
|
grid-auto-rows: 44px;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.position-grid-cell {
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: #f5f5f5;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.position-grid-cell:hover {
|
||||||
|
border-color: var(--primary-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.position-grid-cell.active {
|
||||||
|
border-color: var(--primary-color);
|
||||||
|
background: rgba(139, 92, 246, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 水印预览:模拟透明底图的棋盘格 */
|
||||||
|
.watermark-preview {
|
||||||
|
display: flex;
|
||||||
|
padding: 12px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
border: 1px dashed var(--border-color);
|
||||||
|
border-radius: 4px;
|
||||||
|
background-image: conic-gradient(#ececec 0 25%, #ffffff 0 50%, #ececec 0 75%, #ffffff 0);
|
||||||
|
background-size: 24px 24px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.watermark-form-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 12px;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── 水印图片上传框 ── */
|
||||||
|
.watermark-image-box {
|
||||||
|
width: 120px;
|
||||||
|
height: 120px;
|
||||||
|
border: 1px dashed var(--border-color);
|
||||||
|
border-radius: 8px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: pointer;
|
||||||
|
overflow: hidden;
|
||||||
|
transition: border-color 0.2s;
|
||||||
|
position: relative;
|
||||||
|
background: #fafafa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.watermark-image-box:hover {
|
||||||
|
border-color: var(--primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.watermark-image-box img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
.watermark-image-uploading {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: rgba(255, 255, 255, 0.7);
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { ConfigProvider, App as AntdApp } from 'antd';
|
||||||
|
import zhCN from 'antd/locale/zh_CN';
|
||||||
|
import { RouterProvider } from 'react-router-dom';
|
||||||
|
import { router } from './router';
|
||||||
|
import { SessionProvider } from '@/services/session';
|
||||||
|
import './App.css';
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
return (
|
||||||
|
<ConfigProvider
|
||||||
|
locale={zhCN}
|
||||||
|
theme={{
|
||||||
|
token: {
|
||||||
|
colorPrimary: '#8b5cf6',
|
||||||
|
// 全局圆角 10px,输入框/按钮/卡片等同步生效
|
||||||
|
borderRadius: 10,
|
||||||
|
// 全局正文字号沿用 Ant Design 默认值
|
||||||
|
fontSize: 14,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<AntdApp>
|
||||||
|
<SessionProvider>
|
||||||
|
<RouterProvider router={router} />
|
||||||
|
</SessionProvider>
|
||||||
|
</AntdApp>
|
||||||
|
</ConfigProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default App;
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { Card, Empty } from 'antd';
|
||||||
|
|
||||||
|
interface PagePlaceholderProps {
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 业务模块实现前的统一占位页 */
|
||||||
|
const PagePlaceholder = ({ title, description }: PagePlaceholderProps) => (
|
||||||
|
<Card>
|
||||||
|
<Empty
|
||||||
|
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||||
|
description={
|
||||||
|
<>
|
||||||
|
<div>{title}</div>
|
||||||
|
<div>{description}</div>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default PagePlaceholder;
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { Spin } from 'antd';
|
||||||
|
|
||||||
|
/** 应用启动会话恢复期间的全屏占位,避免守卫闪烁跳转 */
|
||||||
|
const SessionSplash = () => (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
minHeight: '100vh',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
background: 'var(--bg-color)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Spin size="large" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default SessionSplash;
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import type { CSSProperties } from 'react';
|
||||||
|
import { Typography } from 'antd';
|
||||||
|
import type { WatermarkPosition } from '@atlas/contracts';
|
||||||
|
import type { WatermarkTemplateConfig } from '@atlas/contracts';
|
||||||
|
|
||||||
|
/** 九宫格位置选项,顺序与编辑器 3x3 网格一致 */
|
||||||
|
export const WATERMARK_POSITION_OPTIONS: Array<{
|
||||||
|
value: WatermarkPosition;
|
||||||
|
label: string;
|
||||||
|
row: 0 | 1 | 2;
|
||||||
|
col: 0 | 1 | 2;
|
||||||
|
}> = [
|
||||||
|
{ value: 'top_left', label: '左上角', row: 0, col: 0 },
|
||||||
|
{ value: 'top_center', label: '上居中', row: 0, col: 1 },
|
||||||
|
{ value: 'top_right', label: '右上角', row: 0, col: 2 },
|
||||||
|
{ value: 'middle_left', label: '左居中', row: 1, col: 0 },
|
||||||
|
{ value: 'center', label: '正居中', row: 1, col: 1 },
|
||||||
|
{ value: 'middle_right', label: '右居中', row: 1, col: 2 },
|
||||||
|
{ value: 'bottom_left', label: '左下角', row: 2, col: 0 },
|
||||||
|
{ value: 'bottom_center', label: '下居中', row: 2, col: 1 },
|
||||||
|
{ value: 'bottom_right', label: '右下角', row: 2, col: 2 },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const watermarkPositionLabel = (position: string): string =>
|
||||||
|
WATERMARK_POSITION_OPTIONS.find((option) => option.value === position)?.label ?? position;
|
||||||
|
|
||||||
|
const POSITION_FLEX: Record<
|
||||||
|
WatermarkPosition,
|
||||||
|
Pick<CSSProperties, 'justifyContent' | 'alignItems'>
|
||||||
|
> = {
|
||||||
|
top_left: { justifyContent: 'flex-start', alignItems: 'flex-start' },
|
||||||
|
top_center: { justifyContent: 'center', alignItems: 'flex-start' },
|
||||||
|
top_right: { justifyContent: 'flex-end', alignItems: 'flex-start' },
|
||||||
|
middle_left: { justifyContent: 'flex-start', alignItems: 'center' },
|
||||||
|
center: { justifyContent: 'center', alignItems: 'center' },
|
||||||
|
middle_right: { justifyContent: 'flex-end', alignItems: 'center' },
|
||||||
|
bottom_left: { justifyContent: 'flex-start', alignItems: 'flex-end' },
|
||||||
|
bottom_center: { justifyContent: 'center', alignItems: 'flex-end' },
|
||||||
|
bottom_right: { justifyContent: 'flex-end', alignItems: 'flex-end' },
|
||||||
|
};
|
||||||
|
|
||||||
|
interface WatermarkPreviewProps {
|
||||||
|
/** 允许传入部分字段:编辑弹窗按当前表单值实时渲染 */
|
||||||
|
config: Partial<WatermarkTemplateConfig>;
|
||||||
|
width?: number;
|
||||||
|
height?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 按模板 config 在内置演示底图上实时渲染水印效果(docs/api/watermarks-and-assets.md §3.1),
|
||||||
|
* 不调用服务端渲染;图片使用响应中的短时 preview_url,过期时提示重新读取。
|
||||||
|
*/
|
||||||
|
const WatermarkPreview = ({ config, width = 320, height = 420 }: WatermarkPreviewProps) => {
|
||||||
|
const [imageBroken, setImageBroken] = useState(false);
|
||||||
|
const position = config.position ?? 'bottom_right';
|
||||||
|
const flex = POSITION_FLEX[position] ?? POSITION_FLEX.bottom_right;
|
||||||
|
const previewUrl = config.image_asset?.preview_url;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="watermark-preview"
|
||||||
|
style={{ width, height, ...flex, opacity: Number(config.opacity ?? '1') }}
|
||||||
|
>
|
||||||
|
{config.image_asset ? (
|
||||||
|
imageBroken || !previewUrl ? (
|
||||||
|
<Typography.Text type="secondary">图片预览已过期或不可用</Typography.Text>
|
||||||
|
) : (
|
||||||
|
<img
|
||||||
|
src={previewUrl}
|
||||||
|
alt="水印图片"
|
||||||
|
style={{
|
||||||
|
// scale_ratio 以底图短边为基准的视觉近似:1.0 ≈ 底图宽度的 60%
|
||||||
|
width: Math.round(Number(config.scale_ratio ?? '1') * width * 0.6),
|
||||||
|
maxWidth: '80%',
|
||||||
|
}}
|
||||||
|
onError={() => setImageBroken(true)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: config.font_size_px ?? 24,
|
||||||
|
fontWeight: 600,
|
||||||
|
color: '#111827',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
textShadow: '0 0 2px rgba(255,255,255,0.8)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{config.text_content || '文字水印'}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default WatermarkPreview;
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
/**
|
||||||
|
* 环境变量配置(Vite 仅暴露 VITE_ 前缀变量)
|
||||||
|
*/
|
||||||
|
|
||||||
|
interface EnvConfig {
|
||||||
|
/** API 基础路径:开发环境由 vite 代理转发到 NestJS API */
|
||||||
|
apiBaseUrl: string;
|
||||||
|
appName: string;
|
||||||
|
debug: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const envConfig: EnvConfig = {
|
||||||
|
apiBaseUrl: import.meta.env.VITE_API_BASE_URL ?? '/api',
|
||||||
|
appName: import.meta.env.VITE_APP_NAME ?? 'Atlas Seller Kit',
|
||||||
|
debug: import.meta.env.VITE_DEBUG === 'true',
|
||||||
|
};
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
:root {
|
||||||
|
--primary-color: #8b5cf6;
|
||||||
|
--primary-light: #a78bfa;
|
||||||
|
--primary-dark: #7c3aed;
|
||||||
|
/* 侧边菜单背景(纯色,中间调;想再调直接改这里) */
|
||||||
|
--sider-bg: #6d28d9;
|
||||||
|
--text-primary: #1f2937;
|
||||||
|
--text-secondary: #6b7280;
|
||||||
|
--bg-color: #f9fafb;
|
||||||
|
--card-bg: #ffffff;
|
||||||
|
--border-color: #e5e7eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
font-family:
|
||||||
|
-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Hiragino Sans GB',
|
||||||
|
'Microsoft YaHei', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans',
|
||||||
|
'Helvetica Neue', sans-serif;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
background-color: var(--bg-color);
|
||||||
|
color: var(--text-primary);
|
||||||
|
/* 全局默认字号 14px,与 antd 主题 token 的 fontSize 保持一致 */
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
#root {
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
.auth-page {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 24px;
|
||||||
|
background:
|
||||||
|
radial-gradient(1000px 500px at 15% -10%, rgba(139, 92, 246, 0.14), transparent 60%),
|
||||||
|
radial-gradient(900px 480px at 95% 110%, rgba(109, 40, 217, 0.1), transparent 55%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-panel {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 400px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-brand {
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-logo {
|
||||||
|
font-size: 26px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--primary-dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-logo-subtitle {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-card {
|
||||||
|
background: var(--card-bg);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 10px 30px rgba(17, 24, 39, 0.06);
|
||||||
|
padding: 32px 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-title {
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-subtitle {
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin: 4px 0 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-footer {
|
||||||
|
margin-top: 16px;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-footer a {
|
||||||
|
color: var(--primary-color);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.auth-card {
|
||||||
|
padding: 24px 20px;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { useEffect } from 'react';
|
||||||
|
import type { ReactNode } from 'react';
|
||||||
|
import './AuthLayout.css';
|
||||||
|
|
||||||
|
interface AuthLayoutProps {
|
||||||
|
/** 页面标题,如「登录」 */
|
||||||
|
title: string;
|
||||||
|
/** 标题下的一行说明 */
|
||||||
|
subtitle: string;
|
||||||
|
/** 卡片底部的切换入口 */
|
||||||
|
footer: ReactNode;
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 注册/登录共用的居中卡片布局,品牌视觉与 MainLayout 一致 */
|
||||||
|
const AuthLayout = ({ title, subtitle, footer, children }: AuthLayoutProps) => {
|
||||||
|
useEffect(() => {
|
||||||
|
document.title = `${title} - Atlas Seller Kit`;
|
||||||
|
}, [title]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="auth-page">
|
||||||
|
<div className="auth-panel">
|
||||||
|
<div className="auth-brand">
|
||||||
|
<div className="auth-logo">Atlas Kit</div>
|
||||||
|
<div className="auth-logo-subtitle">商品工作台</div>
|
||||||
|
</div>
|
||||||
|
<div className="auth-card">
|
||||||
|
<div className="auth-title">{title}</div>
|
||||||
|
<div className="auth-subtitle">{subtitle}</div>
|
||||||
|
{children}
|
||||||
|
<div className="auth-footer">{footer}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AuthLayout;
|
||||||
@@ -0,0 +1,225 @@
|
|||||||
|
.main-layout {
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.main-sider {
|
||||||
|
background: var(--sider-bg) !important;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-header {
|
||||||
|
padding: 24px 20px;
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
text-align: center;
|
||||||
|
transition: padding 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 折叠态收窄水平留白,与宽度动画同步 */
|
||||||
|
.ant-layout-sider-collapsed .sidebar-header {
|
||||||
|
padding: 20px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo {
|
||||||
|
position: relative;
|
||||||
|
height: 31px;
|
||||||
|
line-height: 31px;
|
||||||
|
font-size: 22px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: white;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 全称与缩写叠加存放,折叠时交叉淡入淡出 */
|
||||||
|
.logo-full,
|
||||||
|
.logo-short {
|
||||||
|
display: block;
|
||||||
|
white-space: nowrap;
|
||||||
|
transition:
|
||||||
|
opacity 0.2s ease,
|
||||||
|
transform 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo-short {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
opacity: 0;
|
||||||
|
transform: scale(0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-layout-sider-collapsed .logo-full {
|
||||||
|
opacity: 0;
|
||||||
|
transform: scale(0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-layout-sider-collapsed .logo-short {
|
||||||
|
opacity: 1;
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 副标题折叠时渐隐并收起占位高度 */
|
||||||
|
.logo-subtitle {
|
||||||
|
max-height: 18px;
|
||||||
|
overflow: hidden;
|
||||||
|
opacity: 1;
|
||||||
|
font-size: 12px;
|
||||||
|
color: rgba(255, 255, 255, 0.8);
|
||||||
|
transition:
|
||||||
|
opacity 0.2s ease,
|
||||||
|
max-height 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-layout-sider-collapsed .logo-subtitle {
|
||||||
|
max-height: 0;
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.main-header {
|
||||||
|
background: white !important;
|
||||||
|
padding: 0 20px 0 10px !important;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
box-shadow:
|
||||||
|
0 1px 3px 0 rgba(0, 0, 0, 0.1),
|
||||||
|
0 1px 2px 0 rgba(0, 0, 0, 0.06);
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 100;
|
||||||
|
height: 50px;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-left {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-right {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 页头平台选择器:图标 + 无边框下拉,视觉与用户触发区一致 */
|
||||||
|
.platform-selector {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 6px;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.platform-selector:hover {
|
||||||
|
background: #f3f4f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.platform-selector-icon {
|
||||||
|
color: var(--primary-color);
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 头像 + 用户名触发区:hover 有浅色背景反馈,暗示可下拉 */
|
||||||
|
.header-user-trigger {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s;
|
||||||
|
max-width: 240px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-user-trigger:hover {
|
||||||
|
background: #f3f4f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 用户名超长省略 */
|
||||||
|
.header-user-name {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1f2937;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.collapse-trigger,
|
||||||
|
.mobile-menu-trigger {
|
||||||
|
font-size: 16px;
|
||||||
|
cursor: pointer;
|
||||||
|
color: #1f2937;
|
||||||
|
border-radius: 4px;
|
||||||
|
transition: background 0.2s;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.collapse-trigger:hover,
|
||||||
|
.mobile-menu-trigger:hover {
|
||||||
|
background: #f9fafb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-breadcrumb {
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1;
|
||||||
|
white-space: nowrap;
|
||||||
|
/* 与 collapse-trigger 一致的 28px 盒子,内部垂直居中 */
|
||||||
|
min-height: 28px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 条目与分隔图标按垂直中心对齐,避免不同字号图标基线错位 */
|
||||||
|
.page-breadcrumb ol {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-breadcrumb li {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-breadcrumb .ant-breadcrumb-link {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
color: #1f2937;
|
||||||
|
}
|
||||||
|
|
||||||
|
.breadcrumb-crumb {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.breadcrumb-crumb .anticon {
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-breadcrumb .ant-breadcrumb-separator {
|
||||||
|
color: #9ca3af;
|
||||||
|
}
|
||||||
|
|
||||||
|
.main-content {
|
||||||
|
padding: 20px;
|
||||||
|
background: #f3f4f6;
|
||||||
|
min-height: calc(100vh - 50px);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.main-content {
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.main-header {
|
||||||
|
padding: 0 16px !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,189 @@
|
|||||||
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
import { Outlet, useLocation, useNavigate } from 'react-router-dom';
|
||||||
|
import { Avatar, Breadcrumb, Drawer, Dropdown, Layout } from 'antd';
|
||||||
|
import type { MenuProps } from 'antd';
|
||||||
|
import {
|
||||||
|
LockOutlined,
|
||||||
|
LogoutOutlined,
|
||||||
|
MenuFoldOutlined,
|
||||||
|
MenuUnfoldOutlined,
|
||||||
|
RightOutlined,
|
||||||
|
UserOutlined,
|
||||||
|
} from '@ant-design/icons';
|
||||||
|
import SidebarMenu from './SidebarMenu';
|
||||||
|
import PlatformSelector from './PlatformSelector';
|
||||||
|
import { getBreadcrumbItems } from './menuConfig';
|
||||||
|
import { useSession } from '@/services/session';
|
||||||
|
import './MainLayout.css';
|
||||||
|
|
||||||
|
const { Header, Sider, Content } = Layout;
|
||||||
|
|
||||||
|
const MainLayout = () => {
|
||||||
|
const [collapsed, setCollapsed] = useState(false);
|
||||||
|
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
||||||
|
const [isMobile, setIsMobile] = useState(false);
|
||||||
|
const [loggingOut, setLoggingOut] = useState(false);
|
||||||
|
const location = useLocation();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { session, logout } = useSession();
|
||||||
|
|
||||||
|
const breadcrumbItems = useMemo(
|
||||||
|
() =>
|
||||||
|
getBreadcrumbItems(location.pathname).map((crumb) => ({
|
||||||
|
title: (
|
||||||
|
<span className="breadcrumb-crumb">
|
||||||
|
{crumb.icon}
|
||||||
|
<span>{crumb.label}</span>
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
})),
|
||||||
|
[location.pathname]
|
||||||
|
);
|
||||||
|
|
||||||
|
const userDisplayName = session ? (session.user.display_name ?? session.user.email) : '';
|
||||||
|
const userInitial = userDisplayName.charAt(0).toUpperCase();
|
||||||
|
|
||||||
|
const handleLogout = async () => {
|
||||||
|
setLoggingOut(true);
|
||||||
|
try {
|
||||||
|
await logout();
|
||||||
|
} finally {
|
||||||
|
setLoggingOut(false);
|
||||||
|
navigate('/login', { replace: true });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUserMenuClick: MenuProps['onClick'] = ({ key }) => {
|
||||||
|
if (key === 'profile') {
|
||||||
|
navigate('/settings/profile');
|
||||||
|
} else if (key === 'password') {
|
||||||
|
navigate('/settings/password');
|
||||||
|
} else if (key === 'logout') {
|
||||||
|
void handleLogout();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const userMenuItems: MenuProps['items'] = [
|
||||||
|
{ key: 'profile', icon: <UserOutlined />, label: '修改资料' },
|
||||||
|
{ key: 'password', icon: <LockOutlined />, label: '修改密码' },
|
||||||
|
{ type: 'divider' },
|
||||||
|
{
|
||||||
|
key: 'logout',
|
||||||
|
icon: <LogoutOutlined />,
|
||||||
|
label: loggingOut ? '退出中…' : '退出登录',
|
||||||
|
disabled: loggingOut,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const checkMobile = () => {
|
||||||
|
setIsMobile(window.innerWidth < 768);
|
||||||
|
if (window.innerWidth >= 768) setMobileMenuOpen(false);
|
||||||
|
};
|
||||||
|
checkMobile();
|
||||||
|
window.addEventListener('resize', checkMobile);
|
||||||
|
return () => window.removeEventListener('resize', checkMobile);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Layout className="main-layout">
|
||||||
|
{!isMobile && (
|
||||||
|
<Sider
|
||||||
|
className="main-sider"
|
||||||
|
width={200}
|
||||||
|
collapsedWidth={60}
|
||||||
|
collapsed={collapsed}
|
||||||
|
theme="dark"
|
||||||
|
style={{
|
||||||
|
overflow: 'auto',
|
||||||
|
height: '100vh',
|
||||||
|
position: 'fixed',
|
||||||
|
left: 0,
|
||||||
|
top: 0,
|
||||||
|
bottom: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="sidebar-header">
|
||||||
|
<div className="logo">
|
||||||
|
<span className="logo-full">Atlas Kit</span>
|
||||||
|
<span className="logo-short">A</span>
|
||||||
|
</div>
|
||||||
|
<div className="logo-subtitle">商品工作台</div>
|
||||||
|
</div>
|
||||||
|
<SidebarMenu collapsed={collapsed} />
|
||||||
|
</Sider>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isMobile && (
|
||||||
|
<Drawer
|
||||||
|
title="Atlas Kit"
|
||||||
|
placement="left"
|
||||||
|
onClose={() => setMobileMenuOpen(false)}
|
||||||
|
open={mobileMenuOpen}
|
||||||
|
styles={{ body: { padding: 0, background: 'var(--sider-bg)' } }}
|
||||||
|
width={200}
|
||||||
|
>
|
||||||
|
<SidebarMenu collapsed={false} onMenuClick={() => setMobileMenuOpen(false)} />
|
||||||
|
</Drawer>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Layout
|
||||||
|
className="main-content-layout"
|
||||||
|
style={{
|
||||||
|
marginLeft: isMobile ? 0 : collapsed ? 60 : 200,
|
||||||
|
transition: 'margin-left 0.2s',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Header className="main-header">
|
||||||
|
<div className="header-left">
|
||||||
|
{isMobile && (
|
||||||
|
<MenuFoldOutlined
|
||||||
|
className="mobile-menu-trigger"
|
||||||
|
onClick={() => setMobileMenuOpen(true)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{!isMobile && (
|
||||||
|
<div
|
||||||
|
className="collapse-trigger"
|
||||||
|
onClick={() => setCollapsed(!collapsed)}
|
||||||
|
>
|
||||||
|
{collapsed ? <MenuUnfoldOutlined /> : <MenuFoldOutlined />}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<Breadcrumb
|
||||||
|
className="page-breadcrumb"
|
||||||
|
separator={<RightOutlined style={{ fontSize: 12 }} />}
|
||||||
|
items={breadcrumbItems}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{session && (
|
||||||
|
<div className="header-right">
|
||||||
|
<PlatformSelector />
|
||||||
|
<Dropdown
|
||||||
|
menu={{ items: userMenuItems, onClick: handleUserMenuClick }}
|
||||||
|
trigger={['hover']}
|
||||||
|
placement="bottomRight"
|
||||||
|
>
|
||||||
|
<div className="header-user-trigger" title={userDisplayName}>
|
||||||
|
<Avatar
|
||||||
|
size={28}
|
||||||
|
style={{ backgroundColor: 'var(--sider-bg)' }}
|
||||||
|
>
|
||||||
|
{userInitial}
|
||||||
|
</Avatar>
|
||||||
|
<span className="header-user-name">{userDisplayName}</span>
|
||||||
|
</div>
|
||||||
|
</Dropdown>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Header>
|
||||||
|
|
||||||
|
<Content className="main-content">
|
||||||
|
<Outlet />
|
||||||
|
</Content>
|
||||||
|
</Layout>
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default MainLayout;
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { GlobalOutlined } from '@ant-design/icons';
|
||||||
|
import { Select, Tooltip } from 'antd';
|
||||||
|
import { usePlatform } from '@/services/platform';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 页头平台选择器:采集编辑、发布等动作按页头当前平台取得发布配置与默认店铺
|
||||||
|
* (docs/requirements/collector-browser-extension.md 的页头平台上下文约定)。
|
||||||
|
* 首次使用默认选中目录第一家(Ozon 优先),选择结果仅作为非敏感 UI 偏好本地记忆。
|
||||||
|
*/
|
||||||
|
const PlatformSelector = () => {
|
||||||
|
const { platforms, currentPlatform, selectPlatform, context, contextLoading } = usePlatform();
|
||||||
|
|
||||||
|
if (platforms.length === 0) return null;
|
||||||
|
|
||||||
|
const tooltip = context?.default_store
|
||||||
|
? `当前平台:${currentPlatform?.name},默认店铺:${context.default_store.name}`
|
||||||
|
: '选择当前平台';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tooltip title={tooltip}>
|
||||||
|
<div className="platform-selector">
|
||||||
|
<GlobalOutlined className="platform-selector-icon" />
|
||||||
|
<Select
|
||||||
|
variant="borderless"
|
||||||
|
loading={contextLoading && !currentPlatform}
|
||||||
|
value={currentPlatform?.id}
|
||||||
|
onChange={selectPlatform}
|
||||||
|
options={platforms.map((platform) => ({
|
||||||
|
value: platform.id,
|
||||||
|
label: platform.name,
|
||||||
|
}))}
|
||||||
|
popupMatchSelectWidth={false}
|
||||||
|
aria-label="选择当前平台"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default PlatformSelector;
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
.sidebar-menu-container {
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-menu {
|
||||||
|
background: transparent !important;
|
||||||
|
border: none;
|
||||||
|
height: 100%;
|
||||||
|
overflow-y: auto;
|
||||||
|
overflow-x: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-menu .ant-menu-item {
|
||||||
|
margin: 0 !important;
|
||||||
|
padding: 12px 20px !important;
|
||||||
|
height: auto !important;
|
||||||
|
line-height: 1.5 !important;
|
||||||
|
transition: all 0.3s !important;
|
||||||
|
font-size: 15px !important;
|
||||||
|
font-weight: 600 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-menu .ant-menu-item .ant-menu-title-content {
|
||||||
|
font-size: 15px !important;
|
||||||
|
font-weight: 600 !important;
|
||||||
|
/* antd 默认只过渡 color,会覆盖下面的 width/opacity 过渡;必须 !important 提权 */
|
||||||
|
transition:
|
||||||
|
transform 0.3s cubic-bezier(0.4, 0, 0.2, 1),
|
||||||
|
width 0.2s ease,
|
||||||
|
opacity 0.2s ease,
|
||||||
|
margin 0.3s,
|
||||||
|
color 0.3s !important;
|
||||||
|
display: inline-block;
|
||||||
|
/* 显式宽度让折叠时 width→0 可插值,配合裁剪+渐隐平滑收起(auto→0 无法动画) */
|
||||||
|
width: 100%;
|
||||||
|
opacity: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
white-space: nowrap;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-menu .ant-menu-item:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.1) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-menu .ant-menu-item-selected {
|
||||||
|
background: rgba(255, 255, 255, 0.2) !important;
|
||||||
|
border-left: 3px solid white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-menu .ant-menu-item-selected .ant-menu-title-content {
|
||||||
|
transform: translateX(8px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-menu .ant-menu-item-selected:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.25) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-menu .ant-menu-item-selected:hover .ant-menu-title-content {
|
||||||
|
transform: translateX(8px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-menu .ant-menu-item-group-title {
|
||||||
|
padding: 12px 20px 8px !important;
|
||||||
|
font-size: 12px !important;
|
||||||
|
color: rgba(255, 255, 255, 0.7) !important;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
font-weight: 500;
|
||||||
|
/* 折叠时渐隐并收起占位高度(!important 防止被 antd 默认 transition 覆盖) */
|
||||||
|
max-height: 40px;
|
||||||
|
opacity: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
transition:
|
||||||
|
opacity 0.2s ease,
|
||||||
|
max-height 0.2s ease,
|
||||||
|
padding 0.2s ease !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-menu .ant-menu-item-icon {
|
||||||
|
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
font-size: 18px !important;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* hover 放大只作用于非选中项;选中项图标固定 translateX(8px),hover 时不得变 transform 导致跳动 */
|
||||||
|
.sidebar-menu .ant-menu-item:hover:not(.ant-menu-item-selected) .ant-menu-item-icon {
|
||||||
|
transform: scale(1.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-menu .ant-menu-item-selected .ant-menu-item-icon {
|
||||||
|
transform: translateX(8px);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.sidebar-menu .ant-menu-item {
|
||||||
|
padding: 14px 20px !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── 折叠态(60px):图标居中,覆盖展开态的左对齐 padding/位移 ── */
|
||||||
|
.sidebar-menu.ant-menu-inline-collapsed .ant-menu-item,
|
||||||
|
.sidebar-menu.ant-menu-inline-collapsed .ant-menu-item-group-title {
|
||||||
|
padding: 12px 0 !important;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 分组标题折叠时渐隐并完全收起,不占纵向空间 */
|
||||||
|
.sidebar-menu.ant-menu-inline-collapsed .ant-menu-item-group-title {
|
||||||
|
max-height: 0;
|
||||||
|
opacity: 0;
|
||||||
|
padding-top: 0 !important;
|
||||||
|
padding-bottom: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-menu.ant-menu-inline-collapsed .ant-menu-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-menu.ant-menu-inline-collapsed .ant-menu-item .ant-menu-title-content,
|
||||||
|
.sidebar-menu.ant-menu-inline-collapsed .ant-menu-item-selected .ant-menu-title-content {
|
||||||
|
transform: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-menu.ant-menu-inline-collapsed .ant-menu-item .ant-menu-item-icon,
|
||||||
|
.sidebar-menu.ant-menu-inline-collapsed .ant-menu-item:hover .ant-menu-item-icon,
|
||||||
|
.sidebar-menu.ant-menu-inline-collapsed .ant-menu-item-selected .ant-menu-item-icon {
|
||||||
|
transform: none !important;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 折叠时去掉左侧 3px 选中边框(会挤偏图标);只过渡宽度,避免 none 突变 */
|
||||||
|
.sidebar-menu.ant-menu-inline-collapsed .ant-menu-item-selected {
|
||||||
|
border-left-width: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 折叠时菜单文字平滑收起:width 100%→0 插值 + 裁剪 + 渐隐;
|
||||||
|
不用 display:none(无法过渡,文字瞬间消失) */
|
||||||
|
.sidebar-menu.ant-menu-inline-collapsed .ant-menu-item .ant-menu-title-content,
|
||||||
|
.sidebar-menu.ant-menu-vertical .ant-menu-item .ant-menu-title-content {
|
||||||
|
width: 0 !important;
|
||||||
|
opacity: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 兼容:部分版本折叠态用 vertical 类渲染,同样居中 */
|
||||||
|
.sidebar-menu.ant-menu-vertical .ant-menu-item,
|
||||||
|
.sidebar-menu.ant-menu-inline-collapsed .ant-menu-item {
|
||||||
|
padding-left: 0 !important;
|
||||||
|
padding-right: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 折叠态 flex 子元素居中(display:flex 已生效,缺 justify-content) */
|
||||||
|
.sidebar-menu.ant-menu-inline-collapsed .ant-menu-item {
|
||||||
|
justify-content: center !important;
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import { useMemo } from 'react';
|
||||||
|
import { useLocation, useNavigate } from 'react-router-dom';
|
||||||
|
import { Menu } from 'antd';
|
||||||
|
import type { MenuProps } from 'antd';
|
||||||
|
import { routeMenuConfig } from './menuConfig';
|
||||||
|
import './SidebarMenu.css';
|
||||||
|
|
||||||
|
interface SidebarMenuProps {
|
||||||
|
collapsed: boolean;
|
||||||
|
onMenuClick?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SidebarMenu = ({ collapsed, onMenuClick }: SidebarMenuProps) => {
|
||||||
|
const location = useLocation();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
// 分组标题保持常驻,折叠时的隐藏由 CSS 过渡完成,避免文字瞬间消失
|
||||||
|
const menuItems: MenuProps['items'] = useMemo(() => {
|
||||||
|
const children = routeMenuConfig.map((config) => ({
|
||||||
|
key: config.key,
|
||||||
|
icon: config.icon,
|
||||||
|
label: config.label,
|
||||||
|
}));
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
type: 'group' as const,
|
||||||
|
label: '主要功能',
|
||||||
|
className: 'menu-section-title',
|
||||||
|
children,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const selectedKeys = useMemo(() => {
|
||||||
|
const path = location.pathname;
|
||||||
|
if (path === '/' || path === '') {
|
||||||
|
return [routeMenuConfig[0].key];
|
||||||
|
}
|
||||||
|
const config = routeMenuConfig.find((item) => path.startsWith(item.path));
|
||||||
|
return [config ? config.key : path];
|
||||||
|
}, [location.pathname]);
|
||||||
|
|
||||||
|
const handleMenuClick: MenuProps['onClick'] = ({ key }) => {
|
||||||
|
navigate(key);
|
||||||
|
if (onMenuClick) onMenuClick();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="sidebar-menu-container">
|
||||||
|
<Menu
|
||||||
|
theme="dark"
|
||||||
|
mode="inline"
|
||||||
|
inlineCollapsed={collapsed}
|
||||||
|
selectedKeys={selectedKeys}
|
||||||
|
items={menuItems}
|
||||||
|
onClick={handleMenuClick}
|
||||||
|
className="sidebar-menu"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SidebarMenu;
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
import {
|
||||||
|
CalculatorOutlined,
|
||||||
|
BugOutlined,
|
||||||
|
CodeSandboxOutlined,
|
||||||
|
EditOutlined,
|
||||||
|
GlobalOutlined,
|
||||||
|
InboxOutlined,
|
||||||
|
LockOutlined,
|
||||||
|
PictureOutlined,
|
||||||
|
SettingOutlined,
|
||||||
|
ShopOutlined,
|
||||||
|
UserOutlined,
|
||||||
|
} from '@ant-design/icons';
|
||||||
|
import type { ReactNode } from 'react';
|
||||||
|
|
||||||
|
export interface RouteMenuConfig {
|
||||||
|
/** 路由路径 */
|
||||||
|
path: string;
|
||||||
|
/** 菜单键值(与 path 相同) */
|
||||||
|
key: string;
|
||||||
|
/** 菜单图标(菜单与页头面包屑共用) */
|
||||||
|
icon: ReactNode;
|
||||||
|
/** 菜单标签 */
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 左侧一级菜单,顺序与 docs/requirements/account-store-watermark.md 的导航约定一致 */
|
||||||
|
export const routeMenuConfig: RouteMenuConfig[] = [
|
||||||
|
{
|
||||||
|
path: '/online-products',
|
||||||
|
key: '/online-products',
|
||||||
|
icon: <GlobalOutlined />,
|
||||||
|
label: '在线商品',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/collection',
|
||||||
|
key: '/collection',
|
||||||
|
icon: <InboxOutlined />,
|
||||||
|
label: '采集箱',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// 放在 /shops 之前,避免前缀匹配时被“店铺管理”抢先选中。
|
||||||
|
path: '/shops/category-tree-debug',
|
||||||
|
key: '/shops/category-tree-debug',
|
||||||
|
icon: <BugOutlined />,
|
||||||
|
label: '调试',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/shops',
|
||||||
|
key: '/shops',
|
||||||
|
icon: <ShopOutlined />,
|
||||||
|
label: '店铺管理',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/watermarks',
|
||||||
|
key: '/watermarks',
|
||||||
|
icon: <PictureOutlined />,
|
||||||
|
label: '水印管理',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/pricing',
|
||||||
|
key: '/pricing',
|
||||||
|
icon: <CalculatorOutlined />,
|
||||||
|
label: '定价工具',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export interface BreadcrumbCrumb {
|
||||||
|
icon: ReactNode;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 页头面包屑:一级菜单页显示单级;不在菜单中的页面显示两级 */
|
||||||
|
export const getBreadcrumbItems = (path: string): BreadcrumbCrumb[] => {
|
||||||
|
if (path.startsWith('/products/')) {
|
||||||
|
return [
|
||||||
|
{ icon: <CodeSandboxOutlined />, label: '商品' },
|
||||||
|
{ icon: <EditOutlined />, label: '商品编辑' },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
if (path === '/shops/category-tree-debug') {
|
||||||
|
return [
|
||||||
|
{ icon: <ShopOutlined />, label: '店铺管理' },
|
||||||
|
{ icon: <BugOutlined />, label: 'Ozon 类目树调试' },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
if (path === '/settings/profile') {
|
||||||
|
return [
|
||||||
|
{ icon: <SettingOutlined />, label: '设置' },
|
||||||
|
{ icon: <UserOutlined />, label: '修改资料' },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
if (path === '/settings/password') {
|
||||||
|
return [
|
||||||
|
{ icon: <SettingOutlined />, label: '设置' },
|
||||||
|
{ icon: <LockOutlined />, label: '修改密码' },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
const config = routeMenuConfig.find((item) => path.startsWith(item.path)) ?? routeMenuConfig[0];
|
||||||
|
return [{ icon: config.icon, label: config.label }];
|
||||||
|
};
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { StrictMode } from 'react';
|
||||||
|
import { createRoot } from 'react-dom/client';
|
||||||
|
import './index.css';
|
||||||
|
import App from './App';
|
||||||
|
|
||||||
|
createRoot(document.getElementById('root')!).render(
|
||||||
|
<StrictMode>
|
||||||
|
<App />
|
||||||
|
</StrictMode>
|
||||||
|
);
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { Link, useLocation, useNavigate } from 'react-router-dom';
|
||||||
|
import { App as AntdApp, Button, Form, Input } from 'antd';
|
||||||
|
import { LockOutlined, MailOutlined } from '@ant-design/icons';
|
||||||
|
import axios from 'axios';
|
||||||
|
import AuthLayout from '@/layouts/AuthLayout';
|
||||||
|
import { apiErrorDetails, apiErrorMessage } from '@/services/api';
|
||||||
|
import { authApi } from '@/services/auth';
|
||||||
|
import { useSession } from '@/services/session';
|
||||||
|
import { emailFormatRule } from '@/utils/validators';
|
||||||
|
|
||||||
|
interface LoginFormValues {
|
||||||
|
email: string;
|
||||||
|
password: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 登录页,契约见 docs/api/auth.md §3 */
|
||||||
|
const LoginPage = () => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const location = useLocation();
|
||||||
|
const { message } = AntdApp.useApp();
|
||||||
|
const { applyAuthSession } = useSession();
|
||||||
|
const [form] = Form.useForm<LoginFormValues>();
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
|
||||||
|
const handleFinish = async (values: LoginFormValues) => {
|
||||||
|
setSubmitting(true);
|
||||||
|
try {
|
||||||
|
const session = await authApi.login({
|
||||||
|
email: values.email.trim(),
|
||||||
|
password: values.password,
|
||||||
|
});
|
||||||
|
applyAuthSession(session);
|
||||||
|
message.success('登录成功');
|
||||||
|
const continuation = new URLSearchParams(location.search).get('continue');
|
||||||
|
if (continuation) {
|
||||||
|
const target = new URL(continuation);
|
||||||
|
if (target.origin === (import.meta.env.VITE_API_PUBLIC_ORIGIN ?? 'http://localhost:8200') && target.pathname === '/api/extension/authorize') {
|
||||||
|
window.location.assign(target.toString());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 守卫记住的来源路径优先,默认进入在线商品首页
|
||||||
|
const from = (location.state as { from?: string } | null)?.from;
|
||||||
|
navigate(from && from.startsWith('/') ? from : '/online-products', {
|
||||||
|
replace: true,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
// 契约约定登录失败统一返回 401 INVALID_CREDENTIALS,不区分邮箱不存在或密码错误
|
||||||
|
if (axios.isAxiosError(error) && error.response?.status === 401) {
|
||||||
|
message.error('邮箱或密码不正确');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// 422 VALIDATION_FAILED 等带字段明细的错误,逐项落到对应表单项
|
||||||
|
const details = apiErrorDetails(error);
|
||||||
|
if (details.length > 0) {
|
||||||
|
form.setFields(
|
||||||
|
details.map((item) => ({
|
||||||
|
name: item.field as keyof LoginFormValues,
|
||||||
|
errors: [item.message],
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
message.error(apiErrorMessage(error));
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AuthLayout
|
||||||
|
title="登录"
|
||||||
|
subtitle="登录 Atlas Seller Kit,继续管理你的商品"
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
还没有账户?<Link to="/register">立即注册</Link>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Form<LoginFormValues>
|
||||||
|
form={form}
|
||||||
|
layout="vertical"
|
||||||
|
requiredMark={false}
|
||||||
|
onFinish={handleFinish}
|
||||||
|
>
|
||||||
|
<Form.Item
|
||||||
|
name="email"
|
||||||
|
label="邮箱"
|
||||||
|
rules={[{ required: true, message: '请输入邮箱' }, emailFormatRule]}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
prefix={<MailOutlined style={{ color: 'var(--text-secondary)' }} />}
|
||||||
|
placeholder="请输入邮箱"
|
||||||
|
autoComplete="email"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item
|
||||||
|
name="password"
|
||||||
|
label="密码"
|
||||||
|
rules={[{ required: true, message: '请输入密码' }]}
|
||||||
|
>
|
||||||
|
<Input.Password
|
||||||
|
prefix={<LockOutlined style={{ color: 'var(--text-secondary)' }} />}
|
||||||
|
placeholder="请输入密码"
|
||||||
|
autoComplete="current-password"
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item style={{ marginBottom: 0, marginTop: 8 }}>
|
||||||
|
<Button type="primary" htmlType="submit" block loading={submitting}>
|
||||||
|
登录
|
||||||
|
</Button>
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
</AuthLayout>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default LoginPage;
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user