Compare commits
5 Commits
403f6cfa27
...
ada02e9135
| Author | SHA1 | Date | |
|---|---|---|---|
| ada02e9135 | |||
| 35fcf8776e | |||
| df40ddde8c | |||
| 037928c778 | |||
| 195b6d25e9 |
+68
-7
@@ -1,12 +1,73 @@
|
||||
POSTGRES_USER=atlas_app_login
|
||||
POSTGRES_PASSWORD=change-me-local-only
|
||||
# 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
|
||||
REDIS_URL=redis://localhost:6379
|
||||
MINIO_ROOT_USER=atlas_minio_local
|
||||
MINIO_ROOT_PASSWORD=change-me-local-only
|
||||
MINIO_PORT=9000
|
||||
MINIO_CONSOLE_PORT=9001
|
||||
|
||||
# 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
|
||||
# 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
|
||||
|
||||
# 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
|
||||
|
||||
# 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
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"singleQuote": true,
|
||||
"trailingComma": "all",
|
||||
"bracketSpacing": true,
|
||||
"arrowParens": "always",
|
||||
"printWidth": 80,
|
||||
"tabWidth": 4,
|
||||
"useTabs": false
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"singleQuote": true,
|
||||
"trailingComma": "all",
|
||||
"bracketSpacing": true,
|
||||
"arrowParens": "always",
|
||||
"printWidth": 80,
|
||||
"tabWidth": 4,
|
||||
"useTabs": false
|
||||
}
|
||||
+14
-4
@@ -5,21 +5,31 @@
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "tsc -p tsconfig.json",
|
||||
"dev": "tsx watch src/main.ts",
|
||||
"dev": "node scripts/dev.mjs",
|
||||
"start": "node dist/main.js",
|
||||
"typecheck": "tsc -p tsconfig.json --noEmit"
|
||||
"typecheck": "tsc -p tsconfig.json --noEmit && tsc -p tsconfig.test.json --noEmit",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@atlas/contracts": "workspace:*",
|
||||
"@atlas/database": "workspace:*",
|
||||
"@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"
|
||||
"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"
|
||||
"typescript": "7.0.2",
|
||||
"vitest": "^4.1.11"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import { spawn } from 'node:child_process';
|
||||
import { existsSync, readFileSync } from 'node:fs';
|
||||
import { dirname, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const scriptDirectory = dirname(fileURLToPath(import.meta.url));
|
||||
const repositoryRoot = resolve(scriptDirectory, '../../..');
|
||||
const environmentFile = resolve(repositoryRoot, '.env');
|
||||
const localEnvironment = existsSync(environmentFile)
|
||||
? parseEnvironment(readFileSync(environmentFile, 'utf8'))
|
||||
: {};
|
||||
const apiPort = process.env.API_PORT ?? localEnvironment.API_PORT ?? '8200';
|
||||
const swaggerEnabled =
|
||||
(process.env.SWAGGER_ENABLED ??
|
||||
localEnvironment.SWAGGER_ENABLED ??
|
||||
'true') === 'true';
|
||||
const nodeEnvironment =
|
||||
process.env.NODE_ENV ?? localEnvironment.NODE_ENV ?? 'development';
|
||||
const swaggerUrl = `http://localhost:${apiPort}/api-docs`;
|
||||
const tsxCommand = process.platform === 'win32' ? 'tsx.cmd' : 'tsx';
|
||||
const apiProcess = spawn(tsxCommand, ['watch', 'src/main.ts'], {
|
||||
shell: process.platform === 'win32',
|
||||
stdio: 'inherit',
|
||||
});
|
||||
|
||||
if (
|
||||
process.platform === 'darwin' &&
|
||||
nodeEnvironment === 'development' &&
|
||||
swaggerEnabled
|
||||
) {
|
||||
void openSwaggerAfterApiIsReady();
|
||||
}
|
||||
|
||||
for (const signal of ['SIGINT', 'SIGTERM']) {
|
||||
process.on(signal, () => apiProcess.kill(signal));
|
||||
}
|
||||
|
||||
apiProcess.on('exit', (code, signal) => {
|
||||
if (signal) process.exitCode = 1;
|
||||
else process.exitCode = code ?? 0;
|
||||
});
|
||||
|
||||
function parseEnvironment(content) {
|
||||
return Object.fromEntries(
|
||||
content
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.match(/^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/))
|
||||
.filter(Boolean)
|
||||
.map((match) => [match[1], match[2]]),
|
||||
);
|
||||
}
|
||||
|
||||
async function openSwaggerAfterApiIsReady() {
|
||||
const deadline = Date.now() + 30_000;
|
||||
while (Date.now() < deadline) {
|
||||
try {
|
||||
const response = await fetch(`http://localhost:${apiPort}/health`);
|
||||
if (response.ok) {
|
||||
// 开发启动器进程不会随 tsx watch 的热重启重建,因此只会打开一次。
|
||||
spawn('open', [swaggerUrl], {
|
||||
detached: true,
|
||||
stdio: 'ignore',
|
||||
}).unref();
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// API 仍在启动或热重启,继续等待到超时为止。
|
||||
}
|
||||
await new Promise((resolveDelay) => setTimeout(resolveDelay, 250));
|
||||
}
|
||||
console.warn(`Swagger 未在 30 秒内就绪:${swaggerUrl}`);
|
||||
}
|
||||
@@ -1,12 +1,47 @@
|
||||
import { Controller, Get, Module } from '@nestjs/common';
|
||||
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';
|
||||
|
||||
@Controller('health')
|
||||
@Controller()
|
||||
class HealthController {
|
||||
@Get()
|
||||
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({ controllers: [HealthController] })
|
||||
@Module({
|
||||
imports: [
|
||||
AppConfigModule,
|
||||
DatabaseModule,
|
||||
SecurityModule,
|
||||
AuthModule,
|
||||
StoresModule,
|
||||
],
|
||||
controllers: [HealthController],
|
||||
})
|
||||
export class AppModule {}
|
||||
|
||||
@@ -0,0 +1,226 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
HttpCode,
|
||||
Inject,
|
||||
Post,
|
||||
Req,
|
||||
Res,
|
||||
} from '@nestjs/common';
|
||||
import {
|
||||
ApiBearerAuth,
|
||||
ApiBody,
|
||||
ApiNoContentResponse,
|
||||
ApiOkResponse,
|
||||
ApiOperation,
|
||||
ApiTags,
|
||||
ApiUnauthorizedResponse,
|
||||
} from '@nestjs/swagger';
|
||||
import { createHash } from 'node:crypto';
|
||||
import {
|
||||
loginRequestSchema,
|
||||
registerRequestSchema,
|
||||
type Workspace,
|
||||
} from '@atlas/contracts';
|
||||
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),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
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 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;
|
||||
}
|
||||
}
|
||||
@@ -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,18 @@
|
||||
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';
|
||||
|
||||
@Module({
|
||||
controllers: [AuthController],
|
||||
providers: [
|
||||
AuthService,
|
||||
AccessTokenGuard,
|
||||
WorkspaceContextGuard,
|
||||
{ provide: APP_GUARD, useExisting: AccessTokenGuard },
|
||||
{ provide: APP_GUARD, useExisting: WorkspaceContextGuard },
|
||||
],
|
||||
})
|
||||
export class AuthModule {}
|
||||
@@ -0,0 +1,346 @@
|
||||
import { Inject, Injectable, UnauthorizedException } from '@nestjs/common';
|
||||
import type {
|
||||
AuthSessionResponse,
|
||||
AuthUser,
|
||||
LoginRequest,
|
||||
RegisterRequest,
|
||||
Workspace,
|
||||
} from '@atlas/contracts';
|
||||
import type { Database, DatabaseTransaction } from '@atlas/database';
|
||||
import { ApiError } from '../common/api-error.js';
|
||||
import { APP_CONFIG, type AppConfig } from '../config/app-config.module.js';
|
||||
import { DATABASE } from '../database/database.module.js';
|
||||
import { 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;
|
||||
email_verified_at: Date | null;
|
||||
created_at: Date;
|
||||
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,
|
||||
) {}
|
||||
|
||||
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, email_verified_at, created_at, 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.email_verified_at, u.created_at, 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,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
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, email_verified_at, created_at, 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,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
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'
|
||||
);
|
||||
}
|
||||
function publicUser(user: UserRow): AuthUser {
|
||||
// 白名单映射防止 password_hash 等内部字段随查询结果泄露给客户端。
|
||||
return {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
display_name: user.display_name,
|
||||
email_verified_at: user.email_verified_at?.toISOString() ?? null,
|
||||
created_at: user.created_at.toISOString(),
|
||||
};
|
||||
}
|
||||
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, email_verified_at, created_at, 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,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,129 @@
|
||||
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),
|
||||
WEB_ORIGIN: z.url().default('http://localhost:8100'),
|
||||
DATABASE_URL: z.url(),
|
||||
REDIS_URL: z.url(),
|
||||
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),
|
||||
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;
|
||||
};
|
||||
|
||||
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 {
|
||||
return {
|
||||
...environment,
|
||||
// 只有显式给出代理网段时才信任转发头,避免客户端伪造来源 IP。
|
||||
trustedProxyCidrs: environment.TRUSTED_PROXY_CIDRS.split(',')
|
||||
.map((value) => value.trim())
|
||||
.filter(Boolean),
|
||||
swaggerEnabled: environment.SWAGGER_ENABLED === '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,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;
|
||||
}
|
||||
+137
-5
@@ -1,12 +1,144 @@
|
||||
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';
|
||||
|
||||
async function bootstrap() {
|
||||
const app = await NestFactory.create(AppModule);
|
||||
app.enableCors({ origin: process.env.WEB_ORIGIN ?? 'http://localhost:8100' });
|
||||
await app.listen(Number(process.env.API_PORT ?? 8200));
|
||||
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: config.WEB_ORIGIN,
|
||||
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 };
|
||||
}
|
||||
|
||||
void bootstrap();
|
||||
export async function bootstrap() {
|
||||
const { app, config, logger } = await createApplication();
|
||||
await app.listen(config.API_PORT);
|
||||
logger.log('api_started', {
|
||||
port: config.API_PORT,
|
||||
swagger_enabled: config.swaggerEnabled,
|
||||
});
|
||||
}
|
||||
|
||||
// 测试复用应用工厂时不自动监听固定端口,只有直接运行入口才启动服务。
|
||||
if (
|
||||
process.argv[1] &&
|
||||
resolve(process.argv[1]) === fileURLToPath(import.meta.url)
|
||||
) {
|
||||
void bootstrap().catch((error: unknown) => {
|
||||
const details =
|
||||
error instanceof Error
|
||||
? {
|
||||
name: error.name,
|
||||
message: error.message,
|
||||
stack: error.stack,
|
||||
}
|
||||
: error;
|
||||
console.error(
|
||||
JSON.stringify({
|
||||
timestamp: new Date().toISOString(),
|
||||
level: 'error',
|
||||
message: 'api_startup_failed',
|
||||
error: details,
|
||||
}),
|
||||
);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
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';
|
||||
type Limit = { ip: number; email?: 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 },
|
||||
): 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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',
|
||||
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,
|
||||
};
|
||||
}
|
||||
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,125 @@
|
||||
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'
|
||||
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',
|
||||
),
|
||||
}));
|
||||
}
|
||||
|
||||
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,37 @@
|
||||
import {
|
||||
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'),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,331 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Headers,
|
||||
HttpCode,
|
||||
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 {
|
||||
public constructor(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(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/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(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 {}
|
||||
@@ -0,0 +1,729 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import type {
|
||||
CreateStoreRequest,
|
||||
ListStoresQuery,
|
||||
Operation,
|
||||
Store,
|
||||
UpdateStoreRequest,
|
||||
} 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 { encryptStoreSecret } from './store-secret.crypto.js';
|
||||
|
||||
type StoreRow = Omit<Store, keyof { created_at: never; updated_at: never }> & {
|
||||
credential_checked_at: Date | string | null;
|
||||
api_key_expires_at: Date | string | null;
|
||||
product_limit_checked_at: Date | string | null;
|
||||
warehouses_synced_at: Date | string | null;
|
||||
created_at: Date | string;
|
||||
updated_at: Date | string;
|
||||
};
|
||||
type StoreLockRow = {
|
||||
id: string;
|
||||
platform_id: string;
|
||||
revision: number;
|
||||
is_default: boolean;
|
||||
status: string;
|
||||
};
|
||||
type OperationRow = {
|
||||
id: string;
|
||||
kind: string;
|
||||
status: string;
|
||||
created_at: Date | string;
|
||||
completed_at: Date | string | null;
|
||||
error_code: string | null;
|
||||
error_message: string | null;
|
||||
};
|
||||
type WarehouseRow = {
|
||||
id: string;
|
||||
platform_warehouse_id: string;
|
||||
name: string;
|
||||
fulfillment: string;
|
||||
is_rfbs: boolean | null;
|
||||
status: string;
|
||||
synced_at: Date | string;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class StoresService {
|
||||
public constructor(
|
||||
private readonly idempotency: IdempotencyService,
|
||||
@Inject(DATABASE) private readonly database: Database,
|
||||
@Inject(APP_CONFIG) private readonly config: AppConfig,
|
||||
) {}
|
||||
|
||||
public async list(workspaceId: string, query: ListStoresQuery) {
|
||||
const cursor = query.cursor ? decodeCursor(query.cursor) : null;
|
||||
return this.database.workspaceTransaction(
|
||||
workspaceId,
|
||||
async (transaction: DatabaseTransaction) => {
|
||||
const rows = await transaction.query<StoreRow>(
|
||||
`${storeSelect}
|
||||
WHERE s.platform_id = $1 AND s.deleted_at IS NULL
|
||||
AND ($2::timestamptz IS NULL OR (s.created_at, s.id) < ($2::timestamptz, $3::uuid))
|
||||
GROUP BY s.id, binding.value
|
||||
ORDER BY s.created_at DESC, s.id DESC
|
||||
LIMIT $4`,
|
||||
[
|
||||
query.platform_id,
|
||||
cursor?.createdAt ?? null,
|
||||
cursor?.id ?? null,
|
||||
query.limit + 1,
|
||||
],
|
||||
);
|
||||
const hasMore = rows.rows.length > query.limit;
|
||||
const items = rows.rows.slice(0, query.limit).map(toStore);
|
||||
const last = items.at(-1);
|
||||
return {
|
||||
data: items,
|
||||
meta: {
|
||||
next_cursor:
|
||||
hasMore && last
|
||||
? encodeCursor(last.created_at, last.id)
|
||||
: null,
|
||||
has_more: hasMore,
|
||||
},
|
||||
};
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
public async get(workspaceId: string, storeId: string): Promise<Store> {
|
||||
return this.database.workspaceTransaction(
|
||||
workspaceId,
|
||||
async (transaction: DatabaseTransaction) =>
|
||||
this.requireStore(transaction, storeId),
|
||||
);
|
||||
}
|
||||
|
||||
public async create(
|
||||
workspaceId: string,
|
||||
userId: string,
|
||||
idempotencyKey: string,
|
||||
input: CreateStoreRequest,
|
||||
) {
|
||||
return this.idempotency.run(
|
||||
{
|
||||
workspaceId,
|
||||
userId,
|
||||
method: 'POST',
|
||||
path: '/api/stores',
|
||||
key: idempotencyKey,
|
||||
request: input,
|
||||
},
|
||||
async (transaction) => {
|
||||
await this.assertPlatformCurrency(
|
||||
transaction,
|
||||
input.platform_id,
|
||||
input.currency_code,
|
||||
);
|
||||
// 同一平台的默认店铺修改串行化,避免两个并发创建都错误地判断自己是首店。
|
||||
await lockPlatform(transaction, workspaceId, input.platform_id);
|
||||
const existing = await transaction.query<{ id: string }>(
|
||||
`SELECT id FROM atlas.stores
|
||||
WHERE platform_id = $1 AND platform_client_id = $2
|
||||
LIMIT 1`,
|
||||
[input.platform_id, input.client_id],
|
||||
);
|
||||
if (existing.rows[0]) {
|
||||
throw new ApiError(
|
||||
409,
|
||||
'STORE_CONNECTION_ALREADY_EXISTS',
|
||||
'该平台账户已绑定为门店',
|
||||
);
|
||||
}
|
||||
const currentStores = await transaction.query<{
|
||||
count: string;
|
||||
}>(
|
||||
`SELECT count(*)::text AS count FROM atlas.stores
|
||||
WHERE platform_id = $1 AND deleted_at IS NULL`,
|
||||
[input.platform_id],
|
||||
);
|
||||
const becomesDefault =
|
||||
Number(currentStores.rows[0]?.count ?? 0) === 0 ||
|
||||
input.is_default;
|
||||
if (becomesDefault) {
|
||||
await transaction.query(
|
||||
`UPDATE atlas.stores SET is_default = false, revision = revision + 1, updated_at = now()
|
||||
WHERE platform_id = $1 AND is_default AND deleted_at IS NULL`,
|
||||
[input.platform_id],
|
||||
);
|
||||
}
|
||||
const created = await transaction.query<{ id: string }>(
|
||||
`INSERT INTO atlas.stores
|
||||
(workspace_id, platform_id, name, platform_client_id, currency_code, is_default, note)
|
||||
VALUES (current_setting('app.workspace_id')::uuid, $1, $2, $3, $4, $5, $6)
|
||||
RETURNING id`,
|
||||
[
|
||||
input.platform_id,
|
||||
input.name,
|
||||
input.client_id,
|
||||
input.currency_code,
|
||||
becomesDefault,
|
||||
input.note ?? null,
|
||||
],
|
||||
);
|
||||
const storeId = created.rows[0].id;
|
||||
await this.replaceApiKey(transaction, storeId, input.api_key);
|
||||
const operation = await this.createOperation(
|
||||
transaction,
|
||||
userId,
|
||||
storeId,
|
||||
'store_credential_verification',
|
||||
);
|
||||
return {
|
||||
status: 202,
|
||||
body: {
|
||||
data: {
|
||||
store: await this.requireStore(
|
||||
transaction,
|
||||
storeId,
|
||||
),
|
||||
operation,
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
public async update(
|
||||
workspaceId: string,
|
||||
userId: string,
|
||||
storeId: string,
|
||||
idempotencyKey: string,
|
||||
revision: number,
|
||||
input: UpdateStoreRequest,
|
||||
) {
|
||||
return this.idempotency.run(
|
||||
{
|
||||
workspaceId,
|
||||
userId,
|
||||
method: 'PATCH',
|
||||
path: `/api/stores/${storeId}`,
|
||||
key: idempotencyKey,
|
||||
request: { if_match: revision, ...input },
|
||||
},
|
||||
async (transaction) => {
|
||||
const current = await this.lockStore(transaction, storeId);
|
||||
this.assertRevision(current, revision);
|
||||
if (input.currency_code) {
|
||||
await this.assertPlatformCurrency(
|
||||
transaction,
|
||||
current.platform_id,
|
||||
input.currency_code,
|
||||
);
|
||||
}
|
||||
const replacingKey = input.api_key !== undefined;
|
||||
await transaction.query(
|
||||
`UPDATE atlas.stores
|
||||
SET name = COALESCE($1, name), currency_code = COALESCE($2, currency_code),
|
||||
note = CASE WHEN $3 THEN $4 ELSE note END,
|
||||
status = CASE WHEN $5 THEN 'verifying'::atlas.store_status ELSE status END,
|
||||
credential_status = CASE WHEN $5 THEN 'unknown'::atlas.credential_status ELSE credential_status END,
|
||||
last_error_code = CASE WHEN $5 THEN NULL ELSE last_error_code END,
|
||||
last_error_message = CASE WHEN $5 THEN NULL ELSE last_error_message END,
|
||||
last_error_at = CASE WHEN $5 THEN NULL ELSE last_error_at END,
|
||||
revision = revision + 1, updated_at = now()
|
||||
WHERE id = $6`,
|
||||
[
|
||||
input.name ?? null,
|
||||
input.currency_code ?? null,
|
||||
Object.hasOwn(input, 'note'),
|
||||
input.note ?? null,
|
||||
replacingKey,
|
||||
storeId,
|
||||
],
|
||||
);
|
||||
if (input.api_key !== undefined) {
|
||||
await this.replaceApiKey(
|
||||
transaction,
|
||||
storeId,
|
||||
input.api_key,
|
||||
);
|
||||
const operation = await this.createOperation(
|
||||
transaction,
|
||||
userId,
|
||||
storeId,
|
||||
'store_credential_verification',
|
||||
);
|
||||
return {
|
||||
status: 202,
|
||||
body: {
|
||||
data: {
|
||||
store: await this.requireStore(
|
||||
transaction,
|
||||
storeId,
|
||||
),
|
||||
operation,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
return {
|
||||
status: 200,
|
||||
body: {
|
||||
data: await this.requireStore(transaction, storeId),
|
||||
},
|
||||
};
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
public async setDefault(
|
||||
workspaceId: string,
|
||||
userId: string,
|
||||
storeId: string,
|
||||
idempotencyKey: string,
|
||||
) {
|
||||
return this.idempotency.run(
|
||||
{
|
||||
workspaceId,
|
||||
userId,
|
||||
method: 'POST',
|
||||
path: `/api/stores/${storeId}/set-default`,
|
||||
key: idempotencyKey,
|
||||
request: {},
|
||||
},
|
||||
async (transaction) => {
|
||||
const current = await this.lockStore(transaction, storeId);
|
||||
await lockPlatform(
|
||||
transaction,
|
||||
workspaceId,
|
||||
current.platform_id,
|
||||
);
|
||||
await transaction.query(
|
||||
`UPDATE atlas.stores SET is_default = false, revision = revision + 1, updated_at = now()
|
||||
WHERE platform_id = $1 AND is_default AND deleted_at IS NULL AND id <> $2`,
|
||||
[current.platform_id, storeId],
|
||||
);
|
||||
if (!current.is_default) {
|
||||
await transaction.query(
|
||||
`UPDATE atlas.stores SET is_default = true, revision = revision + 1, updated_at = now()
|
||||
WHERE id = $1`,
|
||||
[storeId],
|
||||
);
|
||||
}
|
||||
return {
|
||||
status: 200,
|
||||
body: {
|
||||
data: await this.requireStore(transaction, storeId),
|
||||
},
|
||||
};
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
public async requestCredentialVerification(
|
||||
workspaceId: string,
|
||||
userId: string,
|
||||
storeId: string,
|
||||
idempotencyKey: string,
|
||||
) {
|
||||
return this.requestOperation(
|
||||
workspaceId,
|
||||
userId,
|
||||
storeId,
|
||||
idempotencyKey,
|
||||
'credential-verifications',
|
||||
'store_credential_verification',
|
||||
false,
|
||||
);
|
||||
}
|
||||
|
||||
public async requestWarehouseSync(
|
||||
workspaceId: string,
|
||||
userId: string,
|
||||
storeId: string,
|
||||
idempotencyKey: string,
|
||||
) {
|
||||
return this.requestOperation(
|
||||
workspaceId,
|
||||
userId,
|
||||
storeId,
|
||||
idempotencyKey,
|
||||
'warehouse-syncs',
|
||||
'store_warehouse_sync',
|
||||
true,
|
||||
);
|
||||
}
|
||||
|
||||
public async remove(
|
||||
workspaceId: string,
|
||||
userId: string,
|
||||
storeId: string,
|
||||
idempotencyKey: string,
|
||||
) {
|
||||
return this.idempotency.run(
|
||||
{
|
||||
workspaceId,
|
||||
userId,
|
||||
method: 'DELETE',
|
||||
path: `/api/stores/${storeId}`,
|
||||
key: idempotencyKey,
|
||||
request: {},
|
||||
},
|
||||
async (transaction) => {
|
||||
const current = await this.lockStore(transaction, storeId);
|
||||
if (current.is_default) {
|
||||
const replacement = await transaction.query<{ id: string }>(
|
||||
`SELECT id FROM atlas.stores
|
||||
WHERE platform_id = $1 AND id <> $2 AND deleted_at IS NULL LIMIT 1`,
|
||||
[current.platform_id, storeId],
|
||||
);
|
||||
if (replacement.rows[0]) {
|
||||
throw new ApiError(
|
||||
422,
|
||||
'DEFAULT_STORE_REPLACEMENT_REQUIRED',
|
||||
'删除默认门店前必须先选择同平台的其他默认门店',
|
||||
);
|
||||
}
|
||||
}
|
||||
await transaction.query(
|
||||
`UPDATE atlas.stores
|
||||
SET deleted_at = now(), status = 'deleted', is_default = false,
|
||||
revision = revision + 1, updated_at = now()
|
||||
WHERE id = $1`,
|
||||
[storeId],
|
||||
);
|
||||
// 删除连接后立刻撤销可用密钥,避免后台任务或未来恢复流程继续使用它。
|
||||
await transaction.query(
|
||||
`UPDATE atlas.store_secrets SET revoked_at = now(), updated_at = now()
|
||||
WHERE store_id = $1 AND revoked_at IS NULL`,
|
||||
[storeId],
|
||||
);
|
||||
return { status: 204, body: null };
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
public async warehouses(workspaceId: string, storeId: string) {
|
||||
return this.database.workspaceTransaction(
|
||||
workspaceId,
|
||||
async (transaction: DatabaseTransaction) => {
|
||||
const store = await this.requireStore(transaction, storeId);
|
||||
const rows = await transaction.query<WarehouseRow>(
|
||||
`SELECT id, platform_warehouse_id, name, fulfillment, is_rfbs, status, synced_at
|
||||
FROM atlas.store_warehouses WHERE store_id = $1 ORDER BY name, id`,
|
||||
[storeId],
|
||||
);
|
||||
return {
|
||||
data: rows.rows.map((row) => ({
|
||||
...row,
|
||||
synced_at: toIso(row.synced_at),
|
||||
})),
|
||||
meta: { warehouses_synced_at: store.warehouses_synced_at },
|
||||
};
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
public async operation(workspaceId: string, operationId: string) {
|
||||
return this.database.workspaceTransaction(
|
||||
workspaceId,
|
||||
async (transaction: DatabaseTransaction) => {
|
||||
const rows = await transaction.query<OperationRow>(
|
||||
`SELECT id, kind, status, created_at, completed_at, error_code, error_message
|
||||
FROM atlas.operations WHERE id = $1`,
|
||||
[operationId],
|
||||
);
|
||||
if (!rows.rows[0]) throw notFound('操作');
|
||||
return toOperation(rows.rows[0]);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
private async requestOperation(
|
||||
workspaceId: string,
|
||||
userId: string,
|
||||
storeId: string,
|
||||
idempotencyKey: string,
|
||||
pathSuffix: string,
|
||||
kind: string,
|
||||
requiresValidCredential: boolean,
|
||||
) {
|
||||
return this.idempotency.run(
|
||||
{
|
||||
workspaceId,
|
||||
userId,
|
||||
method: 'POST',
|
||||
path: `/api/stores/${storeId}/${pathSuffix}`,
|
||||
key: idempotencyKey,
|
||||
request: {},
|
||||
},
|
||||
async (transaction) => {
|
||||
const store = await this.requireStore(transaction, storeId);
|
||||
if (store.status === 'disabled') {
|
||||
throw new ApiError(422, 'STORE_DISABLED', '门店已停用');
|
||||
}
|
||||
if (
|
||||
requiresValidCredential &&
|
||||
store.credential_status !== 'valid'
|
||||
) {
|
||||
throw new ApiError(
|
||||
422,
|
||||
'STORE_CREDENTIAL_UNAVAILABLE',
|
||||
'门店凭据尚未验证通过,无法同步仓库',
|
||||
);
|
||||
}
|
||||
const operation = await this.createOperation(
|
||||
transaction,
|
||||
userId,
|
||||
storeId,
|
||||
kind,
|
||||
);
|
||||
return { status: 202, body: { data: { operation } } };
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
private async assertPlatformCurrency(
|
||||
transaction: DatabaseTransaction,
|
||||
platformId: string,
|
||||
currencyCode: string,
|
||||
) {
|
||||
const platform = await transaction.query<{ id: string }>(
|
||||
`SELECT id FROM atlas.platforms WHERE id = $1 AND status = 'active'`,
|
||||
[platformId],
|
||||
);
|
||||
if (!platform.rows[0]) {
|
||||
throw new ApiError(
|
||||
422,
|
||||
'PLATFORM_UNAVAILABLE',
|
||||
'平台不存在或已停用',
|
||||
);
|
||||
}
|
||||
const supported = await transaction.query<{ currency_code: string }>(
|
||||
`SELECT pcs.currency_code FROM atlas.platform_currency_support pcs
|
||||
JOIN atlas.currencies c ON c.code = pcs.currency_code
|
||||
WHERE pcs.platform_id = $1 AND pcs.currency_code = $2
|
||||
AND pcs.is_enabled AND c.is_enabled`,
|
||||
[platformId, currencyCode],
|
||||
);
|
||||
if (!supported.rows[0]) {
|
||||
throw new ApiError(
|
||||
422,
|
||||
'PLATFORM_CURRENCY_UNSUPPORTED',
|
||||
'该平台不支持此默认计价币种',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async lockStore(transaction: DatabaseTransaction, storeId: string) {
|
||||
const rows = await transaction.query<StoreLockRow>(
|
||||
`SELECT id, platform_id, revision, is_default, status
|
||||
FROM atlas.stores WHERE id = $1 AND deleted_at IS NULL FOR UPDATE`,
|
||||
[storeId],
|
||||
);
|
||||
if (!rows.rows[0]) throw notFound('门店');
|
||||
return rows.rows[0];
|
||||
}
|
||||
|
||||
private assertRevision(current: StoreLockRow, expected: number) {
|
||||
if (current.revision !== expected) {
|
||||
throw new ApiError(
|
||||
409,
|
||||
'VERSION_CONFLICT',
|
||||
'门店已被其他操作更新',
|
||||
[
|
||||
{
|
||||
field: 'If-Match',
|
||||
code: 'VERSION_CONFLICT',
|
||||
message: `当前版本为 ${current.revision}`,
|
||||
current_revision: current.revision,
|
||||
},
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async replaceApiKey(
|
||||
transaction: DatabaseTransaction,
|
||||
storeId: string,
|
||||
apiKey: string,
|
||||
) {
|
||||
const secret = encryptStoreSecret(
|
||||
apiKey,
|
||||
this.config.STORE_SECRET_ENCRYPTION_KEY,
|
||||
);
|
||||
const existing = await transaction.query<{ id: string }>(
|
||||
`SELECT id FROM atlas.store_secrets
|
||||
WHERE store_id = $1 AND kind = 'seller_api_key' AND fingerprint = $2
|
||||
FOR UPDATE`,
|
||||
[storeId, secret.fingerprint],
|
||||
);
|
||||
await transaction.query(
|
||||
`UPDATE atlas.store_secrets SET revoked_at = now(), updated_at = now()
|
||||
WHERE store_id = $1 AND kind = 'seller_api_key' AND revoked_at IS NULL`,
|
||||
[storeId],
|
||||
);
|
||||
if (existing.rows[0]) {
|
||||
// 同一密钥可被重新提交用于验证;恢复历史行避免违反防重指纹唯一约束。
|
||||
await transaction.query(
|
||||
`UPDATE atlas.store_secrets
|
||||
SET secret_ciphertext = $1, secret_nonce = $2, encryption_key_version = $3,
|
||||
status = 'unknown', revoked_at = NULL, updated_at = now()
|
||||
WHERE id = $4`,
|
||||
[
|
||||
secret.ciphertext,
|
||||
secret.nonce,
|
||||
this.config.STORE_SECRET_ENCRYPTION_KEY_VERSION,
|
||||
existing.rows[0].id,
|
||||
],
|
||||
);
|
||||
return;
|
||||
}
|
||||
await transaction.query(
|
||||
`INSERT INTO atlas.store_secrets
|
||||
(workspace_id, store_id, kind, secret_ciphertext, secret_nonce, encryption_key_version, fingerprint)
|
||||
VALUES (current_setting('app.workspace_id')::uuid, $1, 'seller_api_key', $2, $3, $4, $5)`,
|
||||
[
|
||||
storeId,
|
||||
secret.ciphertext,
|
||||
secret.nonce,
|
||||
this.config.STORE_SECRET_ENCRYPTION_KEY_VERSION,
|
||||
secret.fingerprint,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
private async createOperation(
|
||||
transaction: DatabaseTransaction,
|
||||
userId: string,
|
||||
storeId: string,
|
||||
kind: string,
|
||||
): Promise<Operation> {
|
||||
const created = await transaction.query<OperationRow>(
|
||||
`INSERT INTO atlas.operations
|
||||
(workspace_id, kind, resource_type, resource_id, created_by_user_id)
|
||||
VALUES (current_setting('app.workspace_id')::uuid, $1, 'store', $2, $3)
|
||||
RETURNING id, kind, status, created_at, completed_at, error_code, error_message`,
|
||||
[kind, storeId, userId],
|
||||
);
|
||||
const operation = toOperation(created.rows[0]);
|
||||
await transaction.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, 'store', $1, $2, $3, $4)`,
|
||||
[
|
||||
storeId,
|
||||
`${kind}.requested`,
|
||||
{ store_id: storeId, operation_id: operation.id },
|
||||
operation.id,
|
||||
],
|
||||
);
|
||||
return operation;
|
||||
}
|
||||
|
||||
private async requireStore(
|
||||
transaction: DatabaseTransaction,
|
||||
storeId: string,
|
||||
): Promise<Store> {
|
||||
const rows = await transaction.query<StoreRow>(
|
||||
`${storeSelect}
|
||||
WHERE s.id = $1 AND s.deleted_at IS NULL
|
||||
GROUP BY s.id, binding.value`,
|
||||
[storeId],
|
||||
);
|
||||
if (!rows.rows[0]) throw notFound('门店');
|
||||
return toStore(rows.rows[0]);
|
||||
}
|
||||
}
|
||||
|
||||
const storeSelect = `
|
||||
SELECT s.id, s.platform_id, s.name, s.platform_client_id, s.currency_code,
|
||||
s.is_default, s.note, s.status, s.credential_status, s.credential_checked_at,
|
||||
CASE WHEN s.last_error_code IS NULL THEN NULL
|
||||
ELSE jsonb_build_object('code', s.last_error_code, 'message', s.last_error_message, 'at', s.last_error_at)
|
||||
END AS last_error,
|
||||
s.api_key_expires_at, s.product_limit, s.product_limit_checked_at,
|
||||
count(sw.id)::integer AS warehouse_count, max(sw.synced_at) AS warehouses_synced_at,
|
||||
binding.value AS watermark_template, s.created_at, s.updated_at, s.revision
|
||||
FROM atlas.stores s
|
||||
LEFT JOIN atlas.store_warehouses sw ON sw.store_id = s.id
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT jsonb_build_object('id', wt.id, 'name', wt.name) AS value
|
||||
FROM atlas.store_watermark_bindings wb
|
||||
JOIN atlas.watermark_templates wt ON wt.id = wb.template_id
|
||||
WHERE wb.store_id = s.id AND wb.unbound_at IS NULL
|
||||
LIMIT 1
|
||||
) binding ON true`;
|
||||
|
||||
function toStore(row: StoreRow): Store {
|
||||
return {
|
||||
...row,
|
||||
credential_checked_at: toIso(row.credential_checked_at),
|
||||
api_key_expires_at: toIso(row.api_key_expires_at),
|
||||
product_limit_checked_at: toIso(row.product_limit_checked_at),
|
||||
warehouses_synced_at: toIso(row.warehouses_synced_at),
|
||||
created_at: toRequiredIso(row.created_at),
|
||||
updated_at: toRequiredIso(row.updated_at),
|
||||
cookie_status: null,
|
||||
};
|
||||
}
|
||||
|
||||
function toOperation(row: OperationRow): Operation {
|
||||
return {
|
||||
id: row.id,
|
||||
kind: row.kind,
|
||||
status: row.status,
|
||||
created_at: toRequiredIso(row.created_at),
|
||||
completed_at: toIso(row.completed_at),
|
||||
error:
|
||||
row.error_code && row.error_message
|
||||
? { code: row.error_code, message: row.error_message }
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
function toIso(value: Date | string | null): string | null {
|
||||
if (value === null) return null;
|
||||
return value instanceof Date ? value.toISOString() : value;
|
||||
}
|
||||
|
||||
function toRequiredIso(value: Date | string): string {
|
||||
return value instanceof Date ? value.toISOString() : value;
|
||||
}
|
||||
|
||||
function notFound(resource: string) {
|
||||
return new ApiError(404, 'RESOURCE_NOT_FOUND', `${resource}不存在`);
|
||||
}
|
||||
|
||||
async function lockPlatform(
|
||||
transaction: DatabaseTransaction,
|
||||
workspaceId: string,
|
||||
platformId: string,
|
||||
) {
|
||||
await transaction.query(
|
||||
`SELECT pg_advisory_xact_lock(hashtextextended($1, 0))`,
|
||||
[`${workspaceId}:${platformId}`],
|
||||
);
|
||||
}
|
||||
|
||||
function encodeCursor(createdAt: string, id: string): 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]{8}-[0-9a-f-]{27}$/i.test(value.id)
|
||||
) {
|
||||
throw new Error('invalid cursor');
|
||||
}
|
||||
return { createdAt: value.createdAt, id: value.id };
|
||||
} catch {
|
||||
throw new ApiError(422, 'VALIDATION_FAILED', '分页游标无效', [
|
||||
{ field: 'cursor', code: 'INVALID_FIELD', message: '分页游标无效' },
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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,276 @@
|
||||
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 };
|
||||
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.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',
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,311 @@
|
||||
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('更新版本、切换默认门店、删除和工作空间隔离都遵守契约', 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,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,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',
|
||||
};
|
||||
@@ -5,8 +5,21 @@
|
||||
justify-content: center;
|
||||
padding: 24px;
|
||||
background:
|
||||
<<<<<<< HEAD
|
||||
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%),
|
||||
=======
|
||||
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%
|
||||
),
|
||||
>>>>>>> feat-0.2.0
|
||||
var(--bg-color);
|
||||
}
|
||||
|
||||
|
||||
@@ -12,18 +12,66 @@
|
||||
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 {
|
||||
@@ -38,19 +86,19 @@
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
height: 64px;
|
||||
height: 50px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.collapse-trigger,
|
||||
.mobile-menu-trigger {
|
||||
font-size: 18px;
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
color: #1f2937;
|
||||
border-radius: 4px;
|
||||
@@ -58,8 +106,8 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@@ -68,32 +116,34 @@
|
||||
background: #f9fafb;
|
||||
}
|
||||
|
||||
.page-title-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: #1f2937;
|
||||
line-height: 1.2;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.page-subtitle {
|
||||
.page-breadcrumb {
|
||||
font-size: 14px;
|
||||
color: #6b7280;
|
||||
font-style: italic;
|
||||
line-height: 1.2;
|
||||
line-height: 1;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.page-breadcrumb .ant-breadcrumb-link {
|
||||
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: 24px;
|
||||
padding: 20px;
|
||||
background: #f3f4f6;
|
||||
min-height: calc(100vh - 64px);
|
||||
min-height: calc(100vh - 50px);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
@@ -104,12 +154,4 @@
|
||||
.main-header {
|
||||
padding: 0 16px !important;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.page-subtitle {
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Outlet, useLocation } from 'react-router-dom';
|
||||
import { Drawer, Layout } from 'antd';
|
||||
import { Breadcrumb, Drawer, Layout } from 'antd';
|
||||
import { MenuFoldOutlined, MenuUnfoldOutlined } from '@ant-design/icons';
|
||||
import SidebarMenu from './SidebarMenu';
|
||||
import { getPageInfo } from './menuConfig';
|
||||
import { getBreadcrumbItems } from './menuConfig';
|
||||
import './MainLayout.css';
|
||||
|
||||
const { Header, Sider, Content } = Layout;
|
||||
@@ -14,7 +14,18 @@ const MainLayout = () => {
|
||||
const [isMobile, setIsMobile] = useState(false);
|
||||
const location = useLocation();
|
||||
|
||||
const pageInfo = useMemo(() => getPageInfo(location.pathname), [location.pathname]);
|
||||
const breadcrumbItems = useMemo(
|
||||
() =>
|
||||
getBreadcrumbItems(location.pathname).map((crumb) => ({
|
||||
title: (
|
||||
<span className="breadcrumb-crumb">
|
||||
{crumb.icon}
|
||||
<span>{crumb.label}</span>
|
||||
</span>
|
||||
),
|
||||
})),
|
||||
[location.pathname]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const checkMobile = () => {
|
||||
@@ -45,8 +56,11 @@ const MainLayout = () => {
|
||||
}}
|
||||
>
|
||||
<div className="sidebar-header">
|
||||
<div className="logo">{collapsed ? 'A' : 'Atlas Kit'}</div>
|
||||
{!collapsed && <div className="logo-subtitle">商品工作台</div>}
|
||||
<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>
|
||||
@@ -81,14 +95,18 @@ const MainLayout = () => {
|
||||
/>
|
||||
)}
|
||||
{!isMobile && (
|
||||
<div className="collapse-trigger" onClick={() => setCollapsed(!collapsed)}>
|
||||
<div
|
||||
className="collapse-trigger"
|
||||
onClick={() => setCollapsed(!collapsed)}
|
||||
>
|
||||
{collapsed ? <MenuUnfoldOutlined /> : <MenuFoldOutlined />}
|
||||
</div>
|
||||
)}
|
||||
<div className="page-title-group">
|
||||
<div className="page-title">{pageInfo.title}</div>
|
||||
<div className="page-subtitle">{pageInfo.subtitle}</div>
|
||||
</div>
|
||||
<Breadcrumb
|
||||
className="page-breadcrumb"
|
||||
separator=">"
|
||||
items={breadcrumbItems}
|
||||
/>
|
||||
</div>
|
||||
</Header>
|
||||
|
||||
|
||||
@@ -26,8 +26,20 @@
|
||||
.sidebar-menu .ant-menu-item .ant-menu-title-content {
|
||||
font-size: 15px !important;
|
||||
font-weight: 600 !important;
|
||||
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
/* 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 {
|
||||
@@ -58,6 +70,14 @@
|
||||
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 {
|
||||
@@ -67,7 +87,8 @@
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.sidebar-menu .ant-menu-item:hover .ant-menu-item-icon {
|
||||
/* hover 放大只作用于非选中项;选中项图标固定 translateX(8px),hover 时不得变 transform 导致跳动 */
|
||||
.sidebar-menu .ant-menu-item:hover:not(.ant-menu-item-selected) .ant-menu-item-icon {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
@@ -88,6 +109,14 @@
|
||||
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;
|
||||
@@ -107,14 +136,17 @@
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* 折叠时去掉左侧 3px 选中边框(会挤偏图标),选中态用高亮背景区分 */
|
||||
/* 折叠时去掉左侧 3px 选中边框(会挤偏图标);只过渡宽度,避免 none 突变 */
|
||||
.sidebar-menu.ant-menu-inline-collapsed .ant-menu-item-selected {
|
||||
border-left: none !important;
|
||||
border-left-width: 0 !important;
|
||||
}
|
||||
|
||||
/* 折叠时隐藏 label 占位(antd 折叠动画保留 opacity 占位,会把图标挤离中心) */
|
||||
.sidebar-menu.ant-menu-inline-collapsed .ant-menu-item .ant-menu-title-content {
|
||||
display: none !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 类渲染,同样居中 */
|
||||
@@ -124,10 +156,6 @@
|
||||
padding-right: 0 !important;
|
||||
}
|
||||
|
||||
.sidebar-menu.ant-menu-vertical .ant-menu-item .ant-menu-title-content {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* 折叠态 flex 子元素居中(display:flex 已生效,缺 justify-content) */
|
||||
.sidebar-menu.ant-menu-inline-collapsed .ant-menu-item {
|
||||
justify-content: center !important;
|
||||
|
||||
@@ -14,6 +14,7 @@ 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,
|
||||
@@ -23,12 +24,12 @@ const SidebarMenu = ({ collapsed, onMenuClick }: SidebarMenuProps) => {
|
||||
return [
|
||||
{
|
||||
type: 'group' as const,
|
||||
label: collapsed ? '' : '主要功能',
|
||||
label: '主要功能',
|
||||
className: 'menu-section-title',
|
||||
children,
|
||||
},
|
||||
];
|
||||
}, [collapsed]);
|
||||
}, []);
|
||||
|
||||
const selectedKeys = useMemo(() => {
|
||||
const path = location.pathname;
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import {
|
||||
CalculatorOutlined,
|
||||
CodeSandboxOutlined,
|
||||
EditOutlined,
|
||||
GlobalOutlined,
|
||||
InboxOutlined,
|
||||
PictureOutlined,
|
||||
@@ -12,14 +14,10 @@ export interface RouteMenuConfig {
|
||||
path: string;
|
||||
/** 菜单键值(与 path 相同) */
|
||||
key: string;
|
||||
/** 菜单图标 */
|
||||
/** 菜单图标(菜单与页头面包屑共用) */
|
||||
icon: ReactNode;
|
||||
/** 菜单标签 */
|
||||
label: string;
|
||||
/** 页面标题 */
|
||||
title: string;
|
||||
/** 页面副标题 */
|
||||
subtitle: string;
|
||||
}
|
||||
|
||||
/** 左侧一级菜单,顺序与 docs/requirements/account-store-watermark.md 的导航约定一致 */
|
||||
@@ -29,52 +27,46 @@ export const routeMenuConfig: RouteMenuConfig[] = [
|
||||
key: '/online-products',
|
||||
icon: <GlobalOutlined />,
|
||||
label: '在线商品',
|
||||
title: '在线商品',
|
||||
subtitle: '按 SKU 查看平台已上架商品与同步状态',
|
||||
},
|
||||
{
|
||||
path: '/collection',
|
||||
key: '/collection',
|
||||
icon: <InboxOutlined />,
|
||||
label: '采集箱',
|
||||
title: '采集箱',
|
||||
subtitle: '查看已采集的商品,进入编辑上架',
|
||||
},
|
||||
{
|
||||
path: '/shops',
|
||||
key: '/shops',
|
||||
icon: <ShopOutlined />,
|
||||
label: '店铺管理',
|
||||
title: '店铺管理',
|
||||
subtitle: '绑定平台店铺授权与水印',
|
||||
},
|
||||
{
|
||||
path: '/watermarks',
|
||||
key: '/watermarks',
|
||||
icon: <PictureOutlined />,
|
||||
label: '水印管理',
|
||||
title: '水印管理',
|
||||
subtitle: '维护水印模板并绑定到店铺',
|
||||
},
|
||||
{
|
||||
path: '/pricing',
|
||||
key: '/pricing',
|
||||
icon: <CalculatorOutlined />,
|
||||
label: '定价工具',
|
||||
title: '定价工具',
|
||||
subtitle: '独立试算与物流计价规则',
|
||||
},
|
||||
];
|
||||
|
||||
/** 根据路径获取页面标题信息 */
|
||||
export const getPageInfo = (path: string): { title: string; subtitle: string } => {
|
||||
if (path === '/' || path === '') {
|
||||
const first = routeMenuConfig[0];
|
||||
return { title: first.title, subtitle: first.subtitle };
|
||||
}
|
||||
export interface BreadcrumbCrumb {
|
||||
icon: ReactNode;
|
||||
label: string;
|
||||
}
|
||||
|
||||
/** 页头面包屑:一级菜单页显示单级;商品 AI 编辑页不在菜单中,显示「商品 > 商品编辑」两级 */
|
||||
export const getBreadcrumbItems = (path: string): BreadcrumbCrumb[] => {
|
||||
if (path.startsWith('/products/')) {
|
||||
return { title: '商品编辑', subtitle: '商品 AI 编辑页:SPU/SKU、媒体、定价与发布' };
|
||||
return [
|
||||
{ icon: <CodeSandboxOutlined />, label: '商品' },
|
||||
{ icon: <EditOutlined />, label: '商品编辑' },
|
||||
];
|
||||
}
|
||||
const config = routeMenuConfig.find((item) => path.startsWith(item.path));
|
||||
return config ? { title: config.title, subtitle: config.subtitle } : getPageInfo('/');
|
||||
const config = routeMenuConfig.find((item) => path.startsWith(item.path)) ?? routeMenuConfig[0];
|
||||
return [{ icon: config.icon, label: config.label }];
|
||||
};
|
||||
|
||||
@@ -6,5 +6,5 @@ import App from './App';
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
</StrictMode>
|
||||
);
|
||||
|
||||
@@ -65,7 +65,11 @@ const LoginPage = () => {
|
||||
autoFocus
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="password" label="密码" rules={[{ required: true, message: '请输入密码' }]}>
|
||||
<Form.Item
|
||||
name="password"
|
||||
label="密码"
|
||||
rules={[{ required: true, message: '请输入密码' }]}
|
||||
>
|
||||
<Input.Password
|
||||
prefix={<LockOutlined style={{ color: 'var(--text-secondary)' }} />}
|
||||
placeholder="请输入密码"
|
||||
|
||||
@@ -69,7 +69,7 @@ apiClient.interceptors.response.use(
|
||||
window.location.assign('/login');
|
||||
}
|
||||
return Promise.reject(error);
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
export const api = {
|
||||
|
||||
@@ -3,9 +3,12 @@ import IORedis from 'ioredis';
|
||||
|
||||
// 显式构造客户端实例传给 BullMQ:原生 ESM(pnpm start)下 BullMQ 无法按 connection
|
||||
// 选项自动加载 ioredis;maxRetriesPerRequest: null 是 BullMQ Worker 的要求
|
||||
const connection = new IORedis(process.env.REDIS_URL ?? 'redis://localhost:6379', {
|
||||
const connection = new IORedis(
|
||||
process.env.REDIS_URL ?? 'redis://localhost:6379',
|
||||
{
|
||||
maxRetriesPerRequest: null,
|
||||
});
|
||||
},
|
||||
);
|
||||
const dispatchQueue = new Queue('atlas-dispatch', { connection });
|
||||
|
||||
async function bootstrap() {
|
||||
|
||||
+20
-19
@@ -6,7 +6,7 @@
|
||||
|
||||
| 类型 | 位置 | 内容 |
|
||||
|---|---|---|
|
||||
| 项目 | [`project/`](./project/) | 产品定位、交接与已确认的长期约束 |
|
||||
| 项目 | [`project/`](./project/) | 产品定位、交接、协作约定与已确认的长期约束 |
|
||||
| 产品需求 | [`requirements/`](./requirements/) | 页面、流程、字段和待确认事项 |
|
||||
| 基础架构 | [`architecture/`](./architecture/) | 多租户、平台、门店、水印、物流和定价的数据设计及 DDL |
|
||||
| 商品领域设计 | [`product/`](./product/) | 已有的多 SKU 商品编辑页、领域模型与 API 草案 |
|
||||
@@ -18,21 +18,22 @@
|
||||
|
||||
## 当前阅读顺序
|
||||
|
||||
1. [`project/HANDOFF.md`](./project/HANDOFF.md):项目边界和已经确认的商品规则。
|
||||
2. [`requirements/collector-browser-extension.md`](./requirements/collector-browser-extension.md):来源商品采集、采集箱、多 SKU、插件认证和 Ozon Seller 网页观察实验。
|
||||
3. [`requirements/collection-box-and-online-products.md`](./requirements/collection-box-and-online-products.md):采集箱、上架后编辑限制、按 SKU 展示的在线商品和 Ozon 异步同步需求。
|
||||
4. [`requirements/account-store-watermark.md`](./requirements/account-store-watermark.md):账户、门店和水印需求。
|
||||
5. [`requirements/pricing-tool.md`](./requirements/pricing-tool.md):独立试算、SKU 定价方案和物流规则需求。
|
||||
6. [`requirements/cel-ozon-rfbs-rates-review.md`](./requirements/cel-ozon-rfbs-rates-review.md):从 CEL Ozon-rFBS 资费表提取的待审核规则。
|
||||
7. [`architecture/foundation-data-model.md`](./architecture/foundation-data-model.md):基础模块数据模型和 PostgreSQL DDL。
|
||||
8. [`architecture/exchange-rates.md`](./architecture/exchange-rates.md):系统汇率、CBR、免费备用接口、更新任务和报价快照规则。
|
||||
9. [`architecture/product-domain-data-model.md`](./architecture/product-domain-data-model.md):采集箱、商品编辑、发布任务和在线商品镜像的数据库设计与 SQL。
|
||||
10. [`architecture/technology-and-deployment.md`](./architecture/technology-and-deployment.md):已确认的 NestJS、PostgreSQL、Redis/BullMQ、MinIO、七牛 Kodo 与部署策略。
|
||||
11. [`architecture/database-security.md`](./architecture/database-security.md):迁移/应用数据库角色、RLS 强制策略与部署职责。
|
||||
12. [`architecture/transactional-outbox-and-idempotency.md`](./architecture/transactional-outbox-and-idempotency.md):关键写请求去重、事务 Outbox、BullMQ 投递与验收规则。
|
||||
13. [`guides/object-storage-object-key.md`](./guides/object-storage-object-key.md):对象存储、`object_key`、来源 URL 与预签名 URL 的使用边界。
|
||||
14. [`guides/minio-local-deployment.md`](./guides/minio-local-deployment.md):MinIO 简介、本地 Docker 部署与应用侧使用方法。
|
||||
15. [`guides/postgresql-local-development.md`](./guides/postgresql-local-development.md):原生 PostgreSQL / Docker Compose 本地数据库、角色、迁移与建表。
|
||||
16. [`api/README.md`](./api/README.md):HTTP API 统一约定及第一批账户、门店、水印接口。
|
||||
17. [`reviews/implementation-readiness-open-items.md`](./reviews/implementation-readiness-open-items.md):实现前必须闭环的数据库/API 事项与已确认约束。
|
||||
18. 后续按模块补全定价、采集、商品编辑、发布和在线商品接口。
|
||||
1. [`project/development-collaboration-contract.md`](./project/development-collaboration-contract.md):**所有人工与 AI 开发者必须遵守的协作、领域所有权、API 与数据约束。**
|
||||
2. [`project/HANDOFF.md`](./project/HANDOFF.md):项目边界和已经确认的商品规则。
|
||||
3. [`requirements/collector-browser-extension.md`](./requirements/collector-browser-extension.md):来源商品采集、采集箱、多 SKU、插件认证和 Ozon Seller 网页观察实验。
|
||||
4. [`requirements/collection-box-and-online-products.md`](./requirements/collection-box-and-online-products.md):采集箱、上架后编辑限制、按 SKU 展示的在线商品和 Ozon 异步同步需求。
|
||||
5. [`requirements/account-store-watermark.md`](./requirements/account-store-watermark.md):账户、门店和水印需求。
|
||||
6. [`requirements/pricing-tool.md`](./requirements/pricing-tool.md):独立试算、SKU 定价方案和物流规则需求。
|
||||
7. [`requirements/cel-ozon-rfbs-rates-review.md`](./requirements/cel-ozon-rfbs-rates-review.md):从 CEL Ozon-rFBS 资费表提取的待审核规则。
|
||||
8. [`architecture/foundation-data-model.md`](./architecture/foundation-data-model.md):基础模块数据模型和 PostgreSQL DDL。
|
||||
9. [`architecture/exchange-rates.md`](./architecture/exchange-rates.md):系统汇率、CBR、免费备用接口、更新任务和报价快照规则。
|
||||
10. [`architecture/product-domain-data-model.md`](./architecture/product-domain-data-model.md):采集箱、商品编辑、发布任务和在线商品镜像的数据库设计与 SQL。
|
||||
11. [`architecture/technology-and-deployment.md`](./architecture/technology-and-deployment.md):已确认的 NestJS、PostgreSQL、Redis/BullMQ、MinIO、七牛 Kodo 与部署策略。
|
||||
12. [`architecture/database-security.md`](./architecture/database-security.md):迁移/应用数据库角色、RLS 强制策略与部署职责。
|
||||
13. [`architecture/transactional-outbox-and-idempotency.md`](./architecture/transactional-outbox-and-idempotency.md):关键写请求去重、事务 Outbox、BullMQ 投递与验收规则。
|
||||
14. [`guides/object-storage-object-key.md`](./guides/object-storage-object-key.md):对象存储、`object_key`、来源 URL 与预签名 URL 的使用边界。
|
||||
15. [`guides/minio-local-deployment.md`](./guides/minio-local-deployment.md):MinIO 简介、本地 Docker 部署与应用侧使用方法。
|
||||
16. [`guides/postgresql-local-development.md`](./guides/postgresql-local-development.md):原生 PostgreSQL / Docker Compose 本地数据库、角色、迁移与建表。
|
||||
17. [`api/README.md`](./api/README.md):HTTP API 统一约定及第一批账户、门店、水印接口。
|
||||
18. [`reviews/implementation-readiness-open-items.md`](./reviews/implementation-readiness-open-items.md):实现前必须闭环的数据库/API 事项与已确认约束。
|
||||
19. 后续按模块补全定价、采集、商品编辑、发布和在线商品接口。
|
||||
|
||||
+8
-4
@@ -1,14 +1,18 @@
|
||||
# Atlas HTTP API 设计
|
||||
|
||||
> 状态:第一批可评审契约。本文档描述预期的 NestJS HTTP API,不代表接口已经实现。接口统一以前缀 `/api` 提供,JSON 字段使用 `snake_case`,时间使用 ISO 8601 UTC 字符串,ID 使用 UUID。
|
||||
> 状态:第一批契约已冻结。认证、平台目录/上下文、门店基础管理和操作查询已实现;其余模块仍是设计契约。接口统一以前缀 `/api` 提供,JSON 字段使用 `snake_case`,时间使用 ISO 8601 UTC 字符串,ID 使用 UUID。
|
||||
|
||||
API 启动后会在 `/api-docs` 提供 Swagger UI,并在其 OpenAPI 文档中标记已实现的接口。Swagger 是运行时参考,不替代本目录的冻结契约。
|
||||
|
||||
运行状态端点不使用 `/api` 前缀:`GET /health` 只表明进程存活,`GET /ready` 会校验应用数据库连接,失败时返回 `503`。生产环境由环境变量控制 API 端口、CORS 来源、日志级别、请求体上限、认证限流及可信反向代理列表;缺少或格式错误的必需安全配置会阻止 API 启动。
|
||||
|
||||
## 1. 文档路线
|
||||
|
||||
| 文档 | 状态 | 范围 |
|
||||
|---|---|---|
|
||||
| [`conventions.md`](./conventions.md) | 已设计 | 鉴权、全局上下文、响应、分页、错误、并发与异步任务。 |
|
||||
| [`auth.md`](./auth.md) | 已设计 | 邮箱注册、登录、刷新会话、退出、当前用户。 |
|
||||
| [`stores-and-platforms.md`](./stores-and-platforms.md) | 已设计 | 平台目录、页头上下文、店铺授权、凭据校验、仓库、默认店铺。 |
|
||||
| [`conventions.md`](./conventions.md) | 部分已实现 | 鉴权、全局上下文、响应、分页、错误、并发与异步任务。 |
|
||||
| [`auth.md`](./auth.md) | 已实现 | 邮箱注册、登录、刷新会话、退出、当前用户。 |
|
||||
| [`stores-and-platforms.md`](./stores-and-platforms.md) | 部分已实现 | 平台目录、页头上下文、店铺授权、门店 CRUD、默认店铺、操作查询与仓库读取;平台实际验证与同步 Worker 待实现。 |
|
||||
| [`watermarks-and-assets.md`](./watermarks-and-assets.md) | 已设计 | 素材直传、短时访问、水印模板、字体、店铺推荐水印。 |
|
||||
| [`pricing.md`](./pricing.md) | 已设计 | 汇率、物流价卡、即时试算、定价方案、报价快照衔接。 |
|
||||
| [`collection-and-extension.md`](./collection-and-extension.md) | 已设计 | 插件授权、采集写入、采集箱查询、素材转存与状态。 |
|
||||
|
||||
+3
-1
@@ -68,11 +68,13 @@
|
||||
|
||||
成功响应与注册相同,刷新 token 写入新的 `HttpOnly` cookie。失败统一返回 `401 INVALID_CREDENTIALS`;禁用账户返回 `403 ACCOUNT_DISABLED`。接口应按 IP 与邮箱摘要双维度限流,但不保存明文密码或将其记入日志。
|
||||
|
||||
成功返回 `200 OK`、`Set-Cookie` refresh token 及上述响应体。
|
||||
|
||||
## 4. 刷新与退出
|
||||
|
||||
`POST /api/auth/refresh`
|
||||
|
||||
请求不需要 body,浏览器自动携带 refresh cookie。服务端轮换 refresh token、撤销旧 session 并返回新的短期 access token:
|
||||
请求不需要 body,浏览器自动携带 refresh cookie。服务端轮换 refresh token、撤销旧 session 并以 `200 OK` 返回新的短期 access token:
|
||||
|
||||
```json
|
||||
{ "data": { "access_token": "eyJ…", "access_token_expires_in": 900 } }
|
||||
|
||||
+22
-1
@@ -8,7 +8,9 @@
|
||||
Authorization: Bearer <short-lived-access-token>
|
||||
```
|
||||
|
||||
访问令牌只携带用户会话标识,不以客户端传入的 `workspace_id` 判定数据归属。服务端解析用户、选择首期唯一的有效工作空间、验证成员关系后,在每一个数据库事务中执行 `SET LOCAL app.workspace_id`。RLS 仅是第二道防线,业务层仍须验证资源归属。
|
||||
访问令牌只携带用户会话标识,不以客户端传入的 `workspace_id` 判定数据归属。全局认证守卫先验证 session,全局工作空间守卫再解析首期唯一的有效工作空间并验证成员关系;受保护处理器从 `request.auth.workspace` 或 `@CurrentWorkspace()` 取得已验证的 `workspaceId` 与角色。业务 SQL 必须将该 `workspaceId` 传给短生命周期的 `workspaceTransaction`,由其在每一个数据库事务中执行 `SET LOCAL app.workspace_id`。RLS 仅是第二道防线,业务层仍须验证资源归属。
|
||||
|
||||
`/health`、`/ready`、注册、登录与 refresh 是显式标记的公开端点。新接口默认受上述两个全局守卫保护;不得因便利而自行跳过,除非先在契约中说明公开理由。
|
||||
|
||||
Web 登录后的 refresh token 放在 `HttpOnly`、`Secure`(生产)、`SameSite=Lax` 的 cookie 中,永不出现在 JSON、日志或前端持久化存储。access token 由前端仅在内存中短暂持有;插件使用独立授权码流程,留待采集插件接口文档定义。
|
||||
|
||||
@@ -29,6 +31,25 @@ X-Atlas-Platform-Id: 57ad…-uuid
|
||||
|
||||
## 3. 响应、分页和字段格式
|
||||
|
||||
### 3.1 强制响应信封
|
||||
|
||||
本项目不采用完整 JSON:API 规范,但所有业务 HTTP 接口必须使用以下响应信封;这是
|
||||
前端、服务端、Worker 和扩展共同遵守的强制契约:
|
||||
|
||||
| 场景 | HTTP 响应体 |
|
||||
|---|---|
|
||||
| 成功的单资源或同步动作 | `{ "data": T }` |
|
||||
| 成功的列表 | `{ "data": T[], "meta": PaginationMeta }` |
|
||||
| 失败 | `{ "error": ApiError }` |
|
||||
| `204 No Content` | 无响应体 |
|
||||
|
||||
`data` 只能位于顶层且只能出现一次;不得返回 `{ "data": { "data": ... } }`,也不得以
|
||||
`{ "data": null }` 表示失败。HTTP 状态码仍表达请求结果,错误必须使用本文件第 4 节的
|
||||
`error` 信封,不能混入成功响应。`meta` 只承载分页、游标或与整个响应相关的元信息,不能
|
||||
替代资源字段。
|
||||
|
||||
### 3.2 成功与列表响应
|
||||
|
||||
成功响应统一为:
|
||||
|
||||
```json
|
||||
|
||||
@@ -12,6 +12,22 @@
|
||||
|
||||
`atlas_app_login` 和 `atlas_app` 都必须是 `NOBYPASSRLS`,也不能是 PostgreSQL 超级用户。生产环境不得把 `atlas_migrator` 的连接串写入 API 或 Worker 的环境变量。
|
||||
|
||||
### 角色链与请求上下文
|
||||
|
||||
三层角色链是固定的,不能简化为“应用直接使用迁移账号”:
|
||||
|
||||
```text
|
||||
atlas_migrator ──拥有──> atlas schema 与业务表
|
||||
|
||||
atlas_app_login ──登录身份,NOINHERIT──> SET LOCAL ROLE atlas_app
|
||||
atlas_app ──无登录权限组──────────> 最小 DML 与序列权限
|
||||
```
|
||||
|
||||
API 和 Worker 只以 `atlas_app_login` 建立连接。每笔业务事务必须先执行
|
||||
`SET LOCAL ROLE atlas_app`,再设置 `SET LOCAL app.workspace_id`。前者收窄为
|
||||
应用所需的最小数据库权限,后者为 RLS 策略提供当前工作空间上下文;任何一步缺失,
|
||||
都不应使跨工作空间数据变得可见。
|
||||
|
||||
## 2. 一次性数据库初始化
|
||||
|
||||
以下由云数据库管理员或基础设施脚本执行;它不属于 NestJS 的业务 migration,因为托管 PostgreSQL 常不允许应用账户创建角色。密码应从密钥管理服务注入,示例中不填写真实密码。
|
||||
@@ -22,6 +38,7 @@ CREATE ROLE atlas_app NOLOGIN NOINHERIT NOBYPASSRLS;
|
||||
CREATE ROLE atlas_app_login LOGIN NOINHERIT NOBYPASSRLS PASSWORD '<managed-secret>';
|
||||
|
||||
GRANT atlas_app TO atlas_app_login;
|
||||
GRANT CREATE ON DATABASE atlas TO atlas_migrator;
|
||||
CREATE SCHEMA IF NOT EXISTS atlas AUTHORIZATION atlas_migrator;
|
||||
|
||||
GRANT USAGE ON SCHEMA atlas TO atlas_app;
|
||||
@@ -34,7 +51,7 @@ ALTER DEFAULT PRIVILEGES FOR ROLE atlas_migrator IN SCHEMA atlas
|
||||
GRANT USAGE, SELECT ON SEQUENCES TO atlas_app;
|
||||
```
|
||||
|
||||
首次 DDL 与后续 migration 都以 `atlas_migrator` 执行,确保新表的所有者一致。若云服务统一由管理员创建 schema,可将 `atlas_migrator` 设为 schema/表所有者或由管理员代为执行 migration;无论哪种方式,应用登录角色都不得成为表所有者。
|
||||
首次 DDL 与后续 migration 都以 `atlas_migrator` 执行,确保新表的所有者一致。迁移角色拥有数据库级 `CREATE` 的唯一目的,是创建其拥有的 `atlas` schema;它不授予 API 或 Worker 任何额外权限。若云服务统一由管理员创建 schema,可将 `atlas_migrator` 设为 schema/表所有者或由管理员代为执行 migration;无论哪种方式,应用登录角色都不得成为表所有者。
|
||||
|
||||
## 3. RLS 的运行方式
|
||||
|
||||
|
||||
+11
-11
@@ -1,12 +1,12 @@
|
||||
# Atlas Seller Kit 交接说明
|
||||
|
||||
> 更新:2026-09-02。此文是新 AI 对话、架构讨论与开发工作的首读入口。
|
||||
> 更新:2026-09-03。此文是新 AI 对话、架构讨论与开发工作的首读入口。开始开发前,必须先阅读并遵守 [`development-collaboration-contract.md`](./development-collaboration-contract.md)。
|
||||
|
||||
## 1. 项目定位
|
||||
|
||||
这是一个全新的云端产品,不是 `ozon-seller-kit` 的本地版重构。目标用户可拥有多个门店,每个门店只能绑定一个平台;平台自身可支持多个市场、国家、语言和币种。因此语言、币种、类目和发布规则不能写死为 Ozon 或中文/俄文。
|
||||
|
||||
当前已完成 pnpm monorepo 初始化:包含 Web、NestJS API、BullMQ Worker、WXT 浏览器扩展、共享 API 契约、数据库包、本地基础设施 Compose 与依赖锁文件。API 仅实现健康检查;Web 已具备完整前端框架(布局、路由、主题)和登录/注册页面,业务页面均为占位;Worker 与扩展为可运行骨架。尚未实现业务模块、迁移执行器、数据库 schema、鉴权或实际部署。不要从旧项目复制 SQLite 模型、无鉴权 API、旧 `/trial/:id` 或商品编辑页组件。可选择性复用已经验证过的纯计价公式、采集扩展思路和 AI 任务经验,但必须先适配多租户和云端边界。
|
||||
当前已完成 pnpm monorepo 初始化:包含 Web、NestJS API、BullMQ Worker、WXT 浏览器扩展、共享 API 契约、数据库包、本地基础设施 Compose 与依赖锁文件。API 已实现健康/readiness、集中配置与安全基础、认证、个人工作空间上下文、平台目录/上下文、门店基础管理和操作查询;真实平台验证与同步 Worker 尚未接入。Web 已具备完整前端框架(布局、路由、主题)和登录/注册页面,业务页面均为占位;Worker 与扩展为可运行骨架。不要从旧项目复制 SQLite 模型、无鉴权 API、旧 `/trial/:id` 或商品编辑页组件。可选择性复用已经验证过的纯计价公式、采集扩展思路和 AI 任务经验,但必须先适配多租户和云端边界。
|
||||
|
||||
当前运行时基线:Node 24.20.0、pnpm 11.25.0、PostgreSQL 18.6、Redis 7.4.11、MinIO `RELEASE.2025-09-07T16-13-09Z`;Web 默认端口为 `8100`,API 默认端口为 `8200`,扩展 dev server 默认端口为 `8110`(仅本地开发用),Worker 无 HTTP 端口。完整目录、依赖边界与版本表见 [`architecture/project-structure.md`](../architecture/project-structure.md) 和 [`architecture/technology-and-deployment.md`](../architecture/technology-and-deployment.md)。
|
||||
|
||||
@@ -82,25 +82,25 @@ User → Workspace → Membership / Role
|
||||
|
||||
## 5. 已冻结的第一阶段 API 契约
|
||||
|
||||
第一阶段 HTTP API 的接口范围已讨论完成,当前状态为**设计冻结、业务接口尚未实现**。仓库已有 NestJS API 启动骨架及 `GET /health`,它不构成任何已实现的业务契约。后续编码以 [`docs/api/`](../api/README.md) 为唯一 HTTP 契约来源;旧 [`product/api.md`](../product/api.md) 仅作历史背景,不能作为实现依据。
|
||||
第一阶段 HTTP API 的接口范围已讨论完成,当前状态为**设计冻结、部分业务接口已实现**。认证、平台目录/上下文、创建/查询/编辑/默认/删除门店、异步操作读取及仓库摘要读取已有实现与集成测试;门店凭据验证、仓库同步的实际平台调用仍待 Worker 消费 Outbox 后完成。后续编码以 [`docs/api/`](../api/README.md) 为唯一 HTTP 契约来源;旧 [`product/api.md`](../product/api.md) 仅作历史背景,不能作为实现依据。
|
||||
|
||||
- 覆盖账户认证、平台/门店、水印与素材、定价、浏览器采集/采集箱、商品编辑/首次发布、在线商品读取与同步。
|
||||
- 所有接口遵循统一鉴权、工作空间隔离、页头平台上下文、金额字符串、错误格式、`Idempotency-Key`、`If-Match` 和异步 `operation` 约定。
|
||||
- **响应信封是强制契约:** 成功单资源或同步动作返回 `{ "data": T }`,列表返回 `{ "data": T[], "meta": PaginationMeta }`,失败返回 `{ "error": ApiError }`,`204 No Content` 不返回响应体。项目不采用完整 JSON:API;任何调用方不得自行增加嵌套 `data` 或以 `data: null` 表示错误。完整规则见 [`api/conventions.md`](../api/conventions.md)。
|
||||
- 关键异步动作必须在同一事务写入业务记录、`operations`(如需用户可见进度)和 `outbox_events`;Dispatcher 至少一次投递 BullMQ,Worker 必须幂等。
|
||||
- DDL 已通过临时隔离 PostgreSQL 实例执行验证;API 契约已完成文档评审。这不表示 NestJS 模块、迁移执行器或 Worker 已实现。
|
||||
- DDL 已通过本地隔离 PostgreSQL 执行验证;迁移执行器、认证/工作空间基础、首个幂等与 Outbox 写入闭环以及上述门店 HTTP 模块已实现。Outbox Dispatcher 与平台 Worker 仍未实现。
|
||||
|
||||
仍需在对应模块开始编码前完成的 P1/P2 决策和数据模型补丁,集中记录在 [`reviews/implementation-readiness-open-items.md`](../reviews/implementation-readiness-open-items.md)。
|
||||
|
||||
## 6. 下一步建议
|
||||
|
||||
项目初始化已完成;下一步进入实现准备,而不是继续扩大 API 范围:
|
||||
当前优先完成门店纵向闭环,而不是继续扩展商品接口:
|
||||
|
||||
1. 以 `001_foundation.sql`、`002_product-domain.sql` 建立可重复执行的迁移执行器,并按数据库角色/RLS 约束验证本地环境。
|
||||
2. 在 API 建立配置校验、认证、工作空间上下文和事务包装器;任何工作空间查询前固定 `SET LOCAL app.workspace_id`。
|
||||
3. 建立 `operations`、幂等请求和 Outbox Dispatcher 的第一个集成测试闭环。
|
||||
4. 在开始商品编辑/在线同步模块前,关闭评审文档中的 P1 事项:同商品归属约束、未映射属性持久化、乐观锁 Repository 规则、平台能力 Schema;并确认在线商品归档策略和 Client ID 编辑边界。
|
||||
5. 优先实现账户、平台上下文、门店、素材上传的纵向最小闭环,再接入采集箱和商品编辑;浏览器扩展仅在采集 API 稳定后实现实际解析器。
|
||||
6. Web 侧:认证 API 就绪后把登录/注册接入真实 `/api/auth/*`,补主布局路由守卫与刷新后的会话恢复,auth 类型迁移至 `@atlas/contracts`。
|
||||
1. 实现 Outbox Dispatcher 与 Worker 的首个消费链路:将 `store_credential_verification` 投递到 Worker,解密密钥后调用平台适配器,回写门店/密钥状态;失败信息必须脱敏。
|
||||
2. 在凭据验证通过后实现仓库同步 Worker、幂等 upsert 和状态回写,并补充失败重试、死信与集成测试。
|
||||
3. Web 接入真实认证和门店管理 API:登录/注册、会话刷新、平台上下文、门店列表/创建/编辑/默认切换;前端不得保存 API Key 或 refresh token。
|
||||
4. 接着实现素材直传和水印管理,再开始采集箱与商品编辑;浏览器扩展仅在采集 API 稳定后实现实际解析器。
|
||||
5. 在开始商品编辑/在线同步模块前,关闭评审文档中的 P1 事项:同商品归属约束、未映射属性持久化、乐观锁 Repository 规则、平台能力 Schema;并确认在线商品归档策略和 Client ID 编辑边界。
|
||||
|
||||
## 7. 文档导航
|
||||
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
# 多 AI 开发协作约定(强制)
|
||||
|
||||
> 生效日期:2026-09-03。适用于本仓库所有人工与 AI 开发者。除非仓库维护者明确批准并同时更新本文档,任何实现不得违反本约定。
|
||||
|
||||
## 1. 目的与优先级
|
||||
|
||||
本约定使前端、服务端、Worker 和浏览器扩展能够独立推进,并以小范围、可验证的改动持续集成。
|
||||
|
||||
发生冲突时,按以下顺序处理:
|
||||
|
||||
1. 本文档的协作、所有权与变更流程约束。
|
||||
2. `docs/project/HANDOFF.md` 的产品边界与云端架构底线。
|
||||
3. `docs/api/` 的 HTTP API 契约,特别是 `docs/api/conventions.md`。
|
||||
4. `docs/architecture/` 的数据模型、SQL、RLS、Outbox 与部署约束。
|
||||
5. 模块需求文档。
|
||||
6. 当前代码的既有行为。
|
||||
|
||||
较低优先级内容与较高优先级冲突时,不得通过代码“兼容”或自行猜测解决;必须先修订上游文档并取得维护者确认。
|
||||
|
||||
## 2. 领域所有权
|
||||
|
||||
| 领域 | 负责人 | 可修改范围 | 交付责任 |
|
||||
|---|---|---|---|
|
||||
| 服务端与数据 | 后端 AI | `apps/api/`、`apps/worker/`、`packages/database/`、`packages/contracts/`、服务端相关文档 | 迁移、RLS、认证、API、异步任务、平台适配和集成测试 |
|
||||
| 前端体验 | 前端 AI | `apps/web/`、前端相关文档 | 页面、组件、路由、交互、可访问性、加载/空/错误状态与前端测试 |
|
||||
| 浏览器扩展 | 后端 AI(采集 API 稳定前只维护骨架) | `apps/extension/` | 扩展认证、采集、净化上传与 API 集成 |
|
||||
| 共享契约 | 后端 AI维护,前端 AI共同评审 | `packages/contracts/`、`docs/api/` | 对外类型、DTO、枚举、错误码与接口样例保持一致 |
|
||||
| 最终集成 | 仓库维护者指定;默认后端 AI | 根配置、锁文件、跨应用变更 | 合并顺序、契约兼容性与端到端验收 |
|
||||
|
||||
任何人修改非自己负责的领域前,必须在任务说明中写明原因、影响文件和接口影响;未经明确授权不得改动该领域的业务实现。
|
||||
|
||||
## 3. API 与共享类型约束
|
||||
|
||||
1. `docs/api/` 是唯一的 HTTP API 行为来源;实现前必须先有对应契约,或在同一个经过评审的变更中先更新契约。
|
||||
2. 接口路径、字段名、必填性、枚举、金额字符串格式、分页、错误格式、`Idempotency-Key`、`If-Match` 和异步 `operation` 规则不得由客户端自行推断或扩展。
|
||||
3. 已冻结接口的破坏性变更必须先由维护者批准,并同时更新契约、`@atlas/contracts`、服务端测试和调用端。
|
||||
4. 稳定的请求、响应、枚举和错误类型必须放入 `@atlas/contracts`;`apps/web` 不得长期维护其副本。允许在后端 API 尚未交付时使用明确标注为临时的 mock/adapter,接入接口时必须删除或替换。
|
||||
5. 前端遇到契约缺口时,提交具体问题(页面/用户动作/所需字段/建议响应),不得私自新增后端字段或改变服务端语义。
|
||||
|
||||
## 4. 数据与安全硬约束
|
||||
|
||||
1. PostgreSQL 是业务事实来源。所有业务数据必须有 `workspace_id`,或能经强制外键安全追溯到工作空间;查询必须在事务中设置 `SET LOCAL app.workspace_id`。
|
||||
2. 数据库约束、RLS 与迁移是安全边界;不得只依赖 API 服务层校验。迁移必须可重复执行、可在全新本地环境验证。
|
||||
3. 关键可重试写入必须实现幂等;触发异步工作的事务必须同时写入 Transactional Outbox。Redis/BullMQ 不得成为业务事实的唯一来源,Worker 必须幂等。
|
||||
4. access token 不得写入浏览器持久化存储;Cookie、API Key、预签名 URL 等秘密不得写进日志、Outbox 载荷、fixture 或提交进仓库。
|
||||
5. 不得从旧 `ozon-seller-kit` 复制 SQLite、多店铺无隔离、无鉴权 API 或旧商品编辑实现。复用逻辑前必须适配多租户与云端边界。
|
||||
|
||||
## 5. 工作方式与集成规则
|
||||
|
||||
1. 一个任务只完成一个可验收的垂直能力,明确列出:目标、负责人、允许改动范围、依赖的 API/数据表、验收标准和是否修改契约。
|
||||
2. 每个开发者使用独立分支或 worktree;提交应小而单一。不要在无关任务中格式化全仓、升级依赖、修改根脚本或重新生成锁文件。
|
||||
3. 在同一时间,只有最终集成负责人可以合并跨领域改动、根配置、`pnpm-lock.yaml` 和共享契约的冲突解决。
|
||||
4. 合并前至少运行受影响工作区的 typecheck 与 build;新增或修改 API 时必须增加服务端测试,并在任务结果中提供请求/响应示例或测试覆盖说明。
|
||||
5. 未完成的 P0/P1 设计事项以 `docs/reviews/implementation-readiness-open-items.md` 为准。任务触及其中事项时,必须先关闭该事项或获得维护者明确的范围豁免。
|
||||
|
||||
## 6. 首个建议任务:账户与工作空间基础闭环(M0)
|
||||
|
||||
**目标:** 交付第一个真实的、可由前端接入的认证闭环:注册、登录、刷新会话、登出、读取当前用户与个人工作空间。
|
||||
|
||||
### 后端 AI 负责
|
||||
|
||||
- 建立可重复执行的基础迁移执行器,并落地认证所需的基础表、数据库角色/RLS 约束与本地验证。
|
||||
- 按 `docs/api/auth.md` 实现认证模块、密码处理、refresh cookie、access token、请求工作空间上下文及统一错误响应。
|
||||
- 注册成功时,在同一事务创建用户、个人工作空间和唯一 `owner` 成员。
|
||||
- 将稳定 DTO、枚举和错误类型加入 `@atlas/contracts`,并添加 API/数据库集成测试。
|
||||
- 不实现门店、素材、采集、商品、发布或 Worker 业务;这些不属于 M0 范围。
|
||||
|
||||
### 前端 AI 负责
|
||||
|
||||
- 保持现有 `/login` 和 `/register` 的视觉及表单体验,按 `docs/api/auth.md` 接入真实接口。
|
||||
- 使用共享契约替换本地认证类型;完成应用启动时的会话恢复、主布局路由守卫、401 后的受控跳转和加载/错误状态。
|
||||
- 不修改认证接口的字段、错误语义、Cookie 策略或 token 存储边界;需要变化时按第 3 节提出契约问题。
|
||||
|
||||
### M0 验收标准
|
||||
|
||||
1. 新用户可注册、自动拥有个人工作空间,并能登录后读取当前身份。
|
||||
2. access token 仅存于内存;刷新页面可通过 HttpOnly refresh cookie 恢复会话;登出后刷新 cookie 失效。
|
||||
3. 未认证请求与已过期会话返回契约定义的统一错误,前端不会陷入重试循环。
|
||||
4. 工作空间隔离通过数据库集成测试验证;跨工作空间读取或写入被拒绝。
|
||||
5. API、Web、共享契约的 typecheck 和 production build 均通过。
|
||||
|
||||
## 7. M0 完成后的顺序
|
||||
|
||||
M0 合并后,按以下顺序建立纵向闭环:门店与平台上下文 → 素材上传与媒体处理 → 采集箱 → 商品编辑与 SKU → 定价 → 首次发布与在线商品同步。不得为了前端页面便利跳过前置的认证、工作空间隔离或数据边界。
|
||||
+6
-1
@@ -1,5 +1,10 @@
|
||||
# 本地基础设施
|
||||
|
||||
复制根目录 `.env.example` 为 `.env` 后,执行 `pnpm infra:up` 启动 PostgreSQL、Redis 和 MinIO。镜像使用已验证的明确版本标签;升级前应先在本地或 staging 验证,再显式更新标签。
|
||||
复制根目录 `.env.example` 为 `.env` 后,执行 `pnpm infra:up` 启动 PostgreSQL、Redis 和 MinIO。该命令会显式加载根目录 `.env`,避免因 Compose 文件位于 `infra/` 而回退到默认端口或密码。镜像使用已验证的明确版本标签;升级前应先在本地或 staging 验证,再显式更新标签。
|
||||
|
||||
应用进程由 `pnpm dev` 在宿主机启动:Web 使用 Vite 热更新,API 与 Worker 使用各自的 watch 模式。生产环境的 API、Worker 和反向代理容器编排将在部署运行手册中补充;PostgreSQL 和对象存储继续遵守现有架构文档的托管服务边界。
|
||||
首次执行 `pnpm infra:up` 时,PostgreSQL 会以本地管理员初始化,并创建三个独立角色:`atlas_migrator`(仅迁移)、`atlas_app`(无登录权限组)与 `atlas_app_login`(API/Worker 登录)。
|
||||
|
||||
执行迁移只能使用 `MIGRATOR_DATABASE_URL`:`pnpm --filter @atlas/database migrate`。API 和 Worker 只能使用 `DATABASE_URL`,应用事务会切换到 `atlas_app` 并按请求设置工作空间上下文。
|
||||
|
||||
如已有旧的本地 PostgreSQL volume,初始化脚本不会再次运行;请不要让 API 使用管理员或 migrator 连接串。需要重建本地开发数据时,先确认没有需要保留的数据,再移除该项目的 PostgreSQL volume 后重新执行 `pnpm infra:up`。
|
||||
|
||||
+9
-4
@@ -4,15 +4,20 @@ services:
|
||||
postgres:
|
||||
image: postgres:18.6-alpine
|
||||
environment:
|
||||
POSTGRES_USER: ${POSTGRES_USER:-atlas_app_login}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-change-me-local-only}
|
||||
POSTGRES_USER: ${POSTGRES_ADMIN_USER:-atlas_admin}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_ADMIN_PASSWORD:-change-me-local-only}
|
||||
POSTGRES_DB: ${POSTGRES_DB:-atlas}
|
||||
ATLAS_MIGRATOR_PASSWORD: ${ATLAS_MIGRATOR_PASSWORD:-change-me-local-only}
|
||||
ATLAS_APP_PASSWORD: ${ATLAS_APP_PASSWORD:-change-me-local-only}
|
||||
ports:
|
||||
- '${POSTGRES_PORT:-5432}:5432'
|
||||
volumes:
|
||||
- postgres-data:/var/lib/postgresql/data
|
||||
# PostgreSQL 18 将集群数据放在版本化子目录,volume 必须挂到父目录。
|
||||
- postgres-data:/var/lib/postgresql
|
||||
- ./postgres/init:/docker-entrypoint-initdb.d:ro
|
||||
healthcheck:
|
||||
test: ['CMD-SHELL', 'pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB']
|
||||
test:
|
||||
['CMD-SHELL', 'pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB']
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 20
|
||||
|
||||
Executable
+19
@@ -0,0 +1,19 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
: "${ATLAS_MIGRATOR_PASSWORD:?ATLAS_MIGRATOR_PASSWORD is required}"
|
||||
: "${ATLAS_APP_PASSWORD:?ATLAS_APP_PASSWORD is required}"
|
||||
|
||||
psql --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" --set ON_ERROR_STOP=1 \
|
||||
--set database_name="$POSTGRES_DB" --set migrator_password="$ATLAS_MIGRATOR_PASSWORD" --set app_password="$ATLAS_APP_PASSWORD" <<-'SQL'
|
||||
CREATE ROLE atlas_migrator LOGIN NOINHERIT NOBYPASSRLS PASSWORD :'migrator_password';
|
||||
CREATE ROLE atlas_app NOLOGIN NOINHERIT NOBYPASSRLS;
|
||||
CREATE ROLE atlas_app_login LOGIN NOINHERIT NOBYPASSRLS PASSWORD :'app_password';
|
||||
GRANT atlas_app TO atlas_app_login;
|
||||
-- 仅允许迁移角色创建 atlas schema;应用登录角色不拥有数据库级建库权限。
|
||||
GRANT CREATE ON DATABASE :"database_name" TO atlas_migrator;
|
||||
CREATE SCHEMA atlas AUTHORIZATION atlas_migrator;
|
||||
GRANT USAGE ON SCHEMA atlas TO atlas_app;
|
||||
ALTER DEFAULT PRIVILEGES FOR ROLE atlas_migrator IN SCHEMA atlas GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO atlas_app;
|
||||
ALTER DEFAULT PRIVILEGES FOR ROLE atlas_migrator IN SCHEMA atlas GRANT USAGE, SELECT ON SEQUENCES TO atlas_app;
|
||||
SQL
|
||||
+6
-3
@@ -9,14 +9,17 @@
|
||||
},
|
||||
"scripts": {
|
||||
"build": "turbo run build",
|
||||
"dev": "turbo run dev --parallel",
|
||||
"dev": "turbo run dev",
|
||||
"dev:backend": "turbo run dev --filter=@atlas/api --filter=@atlas/worker",
|
||||
"dev:frontend": "turbo run dev --filter=@atlas/web",
|
||||
"lint": "turbo run lint",
|
||||
"typecheck": "turbo run typecheck",
|
||||
"test": "turbo run test",
|
||||
"test:api": "pnpm --filter @atlas/api test",
|
||||
"format": "prettier --write .",
|
||||
"format:check": "prettier --check .",
|
||||
"infra:up": "docker compose -f infra/compose.yaml up -d postgres redis minio",
|
||||
"infra:down": "docker compose -f infra/compose.yaml down"
|
||||
"infra:up": "docker compose --env-file .env -f infra/compose.yaml up -d postgres redis minio",
|
||||
"infra:down": "docker compose --env-file .env -f infra/compose.yaml down"
|
||||
},
|
||||
"devDependencies": {
|
||||
"prettier": "3.9.6",
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const registerRequestSchema = z.object({
|
||||
email: z.string().trim().email().max(254),
|
||||
password: z.string().min(1),
|
||||
password_confirmation: z.string().min(1),
|
||||
display_name: z.string().trim().max(80).nullable().optional(),
|
||||
});
|
||||
export const loginRequestSchema = z.object({
|
||||
email: z.string().trim().email().max(254),
|
||||
password: z.string().min(1),
|
||||
});
|
||||
|
||||
export const authUserSchema = z.object({
|
||||
id: z.uuid(),
|
||||
email: z.string().email(),
|
||||
display_name: z.string().nullable(),
|
||||
email_verified_at: z.iso.datetime().nullable(),
|
||||
created_at: z.iso.datetime(),
|
||||
});
|
||||
export const workspaceSchema = z.object({
|
||||
id: z.uuid(),
|
||||
name: z.string(),
|
||||
role: z.enum(['owner', 'admin', 'editor', 'viewer']),
|
||||
});
|
||||
export const authSessionResponseSchema = z.object({
|
||||
access_token: z.string(),
|
||||
access_token_expires_in: z.number().int().positive(),
|
||||
user: authUserSchema,
|
||||
workspace: workspaceSchema,
|
||||
});
|
||||
export type RegisterRequest = z.infer<typeof registerRequestSchema>;
|
||||
export type LoginRequest = z.infer<typeof loginRequestSchema>;
|
||||
export type AuthUser = z.infer<typeof authUserSchema>;
|
||||
export type Workspace = z.infer<typeof workspaceSchema>;
|
||||
export type AuthSessionResponse = z.infer<typeof authSessionResponseSchema>;
|
||||
@@ -21,4 +21,6 @@ export const collectedProductPayloadSchema = z.object({
|
||||
evidence: z.array(z.unknown()),
|
||||
});
|
||||
|
||||
export type CollectedProductPayload = z.infer<typeof collectedProductPayloadSchema>;
|
||||
export type CollectedProductPayload = z.infer<
|
||||
typeof collectedProductPayloadSchema
|
||||
>;
|
||||
|
||||
@@ -1 +1,3 @@
|
||||
export * from './collection';
|
||||
export * from './auth';
|
||||
export * from './stores';
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
const uuidSchema = z.uuid();
|
||||
const currencyCodeSchema = z
|
||||
.string()
|
||||
.regex(/^[A-Z]{3}$/, '必须是 ISO 4217 大写币种代码');
|
||||
|
||||
/** 创建门店时唯一允许提交明文 API Key 的请求契约。 */
|
||||
export const createStoreRequestSchema = z
|
||||
.object({
|
||||
platform_id: uuidSchema,
|
||||
name: z.string().trim().min(1).max(120),
|
||||
client_id: z.string().trim().min(1).max(120),
|
||||
api_key: z.string().min(1).max(8_192),
|
||||
currency_code: currencyCodeSchema,
|
||||
is_default: z.boolean().default(false),
|
||||
note: z.string().trim().max(2_000).nullable().optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
/** 修改门店不允许变更平台或平台账户,避免授权连接身份被悄然替换。 */
|
||||
export const updateStoreRequestSchema = z
|
||||
.object({
|
||||
name: z.string().trim().min(1).max(120).optional(),
|
||||
currency_code: currencyCodeSchema.optional(),
|
||||
note: z.string().trim().max(2_000).nullable().optional(),
|
||||
api_key: z.string().min(1).max(8_192).optional(),
|
||||
})
|
||||
.strict()
|
||||
.refine((value) => Object.keys(value).length > 0, {
|
||||
message: '至少需要提交一个可修改字段',
|
||||
});
|
||||
|
||||
export const listStoresQuerySchema = z
|
||||
.object({
|
||||
platform_id: uuidSchema,
|
||||
cursor: z.string().min(1).optional(),
|
||||
limit: z.coerce.number().int().min(1).max(100).default(20),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export type CreateStoreRequest = z.infer<typeof createStoreRequestSchema>;
|
||||
export type UpdateStoreRequest = z.infer<typeof updateStoreRequestSchema>;
|
||||
export type ListStoresQuery = z.infer<typeof listStoresQuerySchema>;
|
||||
|
||||
export type Store = {
|
||||
id: string;
|
||||
platform_id: string;
|
||||
name: string;
|
||||
platform_client_id: string;
|
||||
currency_code: string;
|
||||
is_default: boolean;
|
||||
note: string | null;
|
||||
status: string;
|
||||
credential_status: string;
|
||||
credential_checked_at: string | null;
|
||||
last_error: { code: string; message: string; at: string } | null;
|
||||
api_key_expires_at: string | null;
|
||||
product_limit: number | null;
|
||||
product_limit_checked_at: string | null;
|
||||
warehouse_count: number;
|
||||
warehouses_synced_at: string | null;
|
||||
cookie_status: null;
|
||||
watermark_template: { id: string; name: string } | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
revision: number;
|
||||
};
|
||||
|
||||
export type Operation = {
|
||||
id: string;
|
||||
kind: string;
|
||||
status: string;
|
||||
created_at: string;
|
||||
completed_at: string | null;
|
||||
error: { code: string; message: string } | null;
|
||||
};
|
||||
@@ -0,0 +1,678 @@
|
||||
-- Atlas Seller Kit: cloud foundation schema
|
||||
-- PostgreSQL 15+ recommended. Run through a migration tool, not from the browser.
|
||||
|
||||
CREATE EXTENSION IF NOT EXISTS citext;
|
||||
CREATE EXTENSION IF NOT EXISTS pgcrypto;
|
||||
CREATE SCHEMA IF NOT EXISTS atlas;
|
||||
SET search_path TO atlas, public;
|
||||
|
||||
CREATE TYPE membership_role AS ENUM ('owner', 'admin', 'editor', 'viewer');
|
||||
CREATE TYPE account_status AS ENUM ('active', 'disabled', 'pending_review');
|
||||
CREATE TYPE platform_status AS ENUM ('active', 'disabled');
|
||||
CREATE TYPE store_status AS ENUM ('verifying', 'active', 'credential_invalid', 'sync_error', 'disabled', 'deleted');
|
||||
CREATE TYPE credential_status AS ENUM ('unknown', 'valid', 'invalid', 'expired', 'rotation_required');
|
||||
CREATE TYPE secret_kind AS ENUM ('seller_api_key', 'browser_cookie');
|
||||
CREATE TYPE watermark_kind AS ENUM ('text', 'image');
|
||||
CREATE TYPE watermark_position AS ENUM ('top_left', 'top_center', 'top_right', 'middle_left', 'center', 'middle_right', 'bottom_left', 'bottom_center', 'bottom_right');
|
||||
CREATE TYPE record_status AS ENUM ('draft', 'active', 'archived', 'superseded');
|
||||
CREATE TYPE rule_source AS ENUM ('manual_rule', 'competitor_capture', 'platform_sync', 'product_observed');
|
||||
CREATE TYPE fulfillment_mode AS ENUM ('rfbs', 'fbs', 'fbo', 'fbp', 'unknown');
|
||||
CREATE TYPE billing_weight_basis AS ENUM ('actual_only', 'max_actual_volume', 'conditional_volume');
|
||||
CREATE TYPE quote_status AS ENUM ('draft', 'calculated', 'confirmed', 'invalid', 'superseded');
|
||||
CREATE TYPE exchange_rate_source AS ENUM ('cbr', 'exchange_rate_api_open', 'frankfurter', 'manual');
|
||||
CREATE TYPE exchange_rate_kind AS ENUM ('official_reference', 'market_reference', 'manual');
|
||||
CREATE TYPE fx_rate_origin AS ENUM ('system_rate', 'policy_fixed', 'quote_override');
|
||||
CREATE TYPE operation_status AS ENUM ('queued', 'running', 'succeeded', 'failed', 'cancelled');
|
||||
CREATE TYPE media_upload_status AS ENUM ('pending', 'uploaded', 'validating', 'ready', 'failed', 'expired');
|
||||
CREATE TYPE idempotency_request_status AS ENUM ('in_progress', 'completed');
|
||||
CREATE TYPE outbox_event_status AS ENUM ('pending', 'dispatching', 'dispatched', 'dead_letter');
|
||||
|
||||
CREATE TABLE users (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
email CITEXT NOT NULL UNIQUE CHECK (length(email) <= 254 AND position('@' IN email) > 1),
|
||||
password_hash TEXT NOT NULL,
|
||||
display_name TEXT,
|
||||
status account_status NOT NULL DEFAULT 'active',
|
||||
email_verified_at TIMESTAMPTZ,
|
||||
last_login_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE workspaces (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
name TEXT NOT NULL,
|
||||
slug CITEXT NOT NULL UNIQUE,
|
||||
status record_status NOT NULL DEFAULT 'active',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE workspace_memberships (
|
||||
workspace_id UUID NOT NULL REFERENCES workspaces(id) ON DELETE RESTRICT,
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE RESTRICT,
|
||||
role membership_role NOT NULL DEFAULT 'editor',
|
||||
joined_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (workspace_id, user_id)
|
||||
);
|
||||
|
||||
CREATE TABLE user_sessions (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
refresh_token_hash TEXT NOT NULL UNIQUE,
|
||||
expires_at TIMESTAMPTZ NOT NULL,
|
||||
revoked_at TIMESTAMPTZ,
|
||||
ip_hash TEXT,
|
||||
user_agent TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
CHECK (expires_at > created_at)
|
||||
);
|
||||
|
||||
CREATE TABLE platforms (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
code CITEXT NOT NULL UNIQUE,
|
||||
name TEXT NOT NULL,
|
||||
status platform_status NOT NULL DEFAULT 'active',
|
||||
capabilities JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE currencies (
|
||||
code CHAR(3) PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
symbol TEXT NOT NULL,
|
||||
decimal_places SMALLINT NOT NULL DEFAULT 2 CHECK (decimal_places BETWEEN 0 AND 6),
|
||||
is_enabled BOOLEAN NOT NULL DEFAULT true
|
||||
);
|
||||
|
||||
CREATE TABLE platform_currency_support (
|
||||
platform_id UUID NOT NULL REFERENCES platforms(id) ON DELETE CASCADE,
|
||||
currency_code CHAR(3) NOT NULL REFERENCES currencies(code),
|
||||
is_enabled BOOLEAN NOT NULL DEFAULT true,
|
||||
PRIMARY KEY (platform_id, currency_code)
|
||||
);
|
||||
|
||||
-- Global immutable rate history. rate always means one unit of base_currency_code equals rate units of quote_currency_code.
|
||||
CREATE TABLE exchange_rates (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
base_currency_code CHAR(3) NOT NULL REFERENCES currencies(code),
|
||||
quote_currency_code CHAR(3) NOT NULL REFERENCES currencies(code),
|
||||
rate NUMERIC(24,10) NOT NULL CHECK (rate > 0),
|
||||
kind exchange_rate_kind NOT NULL,
|
||||
source exchange_rate_source NOT NULL,
|
||||
source_reference TEXT,
|
||||
effective_on DATE NOT NULL,
|
||||
observed_at TIMESTAMPTZ,
|
||||
fetched_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
raw_payload JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
CHECK (base_currency_code <> quote_currency_code),
|
||||
UNIQUE (base_currency_code, quote_currency_code, kind, source, effective_on)
|
||||
);
|
||||
CREATE INDEX ix_exchange_rates_current_lookup
|
||||
ON exchange_rates (base_currency_code, quote_currency_code, kind, effective_on DESC, fetched_at DESC);
|
||||
|
||||
CREATE TABLE stores (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
workspace_id UUID NOT NULL REFERENCES workspaces(id) ON DELETE RESTRICT,
|
||||
platform_id UUID NOT NULL REFERENCES platforms(id) ON DELETE RESTRICT,
|
||||
name TEXT NOT NULL,
|
||||
platform_client_id TEXT NOT NULL,
|
||||
currency_code CHAR(3) NOT NULL REFERENCES currencies(code),
|
||||
is_default BOOLEAN NOT NULL DEFAULT false,
|
||||
note TEXT,
|
||||
status store_status NOT NULL DEFAULT 'verifying',
|
||||
credential_status credential_status NOT NULL DEFAULT 'unknown',
|
||||
credential_checked_at TIMESTAMPTZ,
|
||||
last_error_code TEXT,
|
||||
last_error_message TEXT,
|
||||
last_error_at TIMESTAMPTZ,
|
||||
api_key_expires_at TIMESTAMPTZ,
|
||||
product_limit INTEGER CHECK (product_limit IS NULL OR product_limit >= 0),
|
||||
product_limit_checked_at TIMESTAMPTZ,
|
||||
deleted_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
revision INTEGER NOT NULL DEFAULT 1 CHECK (revision > 0),
|
||||
UNIQUE (workspace_id, id),
|
||||
UNIQUE (workspace_id, id, platform_id),
|
||||
UNIQUE (workspace_id, platform_id, platform_client_id)
|
||||
);
|
||||
CREATE UNIQUE INDEX ux_stores_one_default_per_platform
|
||||
ON stores (workspace_id, platform_id)
|
||||
WHERE is_default AND deleted_at IS NULL;
|
||||
CREATE INDEX ix_stores_workspace_platform ON stores (workspace_id, platform_id, created_at DESC);
|
||||
|
||||
CREATE TABLE store_secrets (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
workspace_id UUID NOT NULL,
|
||||
store_id UUID NOT NULL,
|
||||
kind secret_kind NOT NULL,
|
||||
secret_ciphertext BYTEA NOT NULL,
|
||||
secret_nonce BYTEA NOT NULL,
|
||||
encryption_key_version TEXT NOT NULL,
|
||||
fingerprint TEXT NOT NULL,
|
||||
status credential_status NOT NULL DEFAULT 'unknown',
|
||||
expires_at TIMESTAMPTZ,
|
||||
last_verified_at TIMESTAMPTZ,
|
||||
revoked_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
FOREIGN KEY (workspace_id, store_id) REFERENCES stores(workspace_id, id) ON DELETE CASCADE,
|
||||
UNIQUE (workspace_id, store_id, kind, fingerprint)
|
||||
);
|
||||
CREATE UNIQUE INDEX ux_store_secrets_one_current_kind
|
||||
ON store_secrets (workspace_id, store_id, kind)
|
||||
WHERE revoked_at IS NULL;
|
||||
|
||||
CREATE TABLE store_warehouses (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
workspace_id UUID NOT NULL,
|
||||
store_id UUID NOT NULL,
|
||||
platform_warehouse_id TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
fulfillment fulfillment_mode NOT NULL DEFAULT 'unknown',
|
||||
is_rfbs BOOLEAN,
|
||||
status TEXT NOT NULL,
|
||||
payload JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
synced_at TIMESTAMPTZ NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
FOREIGN KEY (workspace_id, store_id) REFERENCES stores(workspace_id, id) ON DELETE CASCADE,
|
||||
UNIQUE (workspace_id, store_id, platform_warehouse_id)
|
||||
);
|
||||
|
||||
-- Durable API-visible operation state. Queue job ids are auxiliary and never the only record.
|
||||
CREATE TABLE operations (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
workspace_id UUID NOT NULL REFERENCES workspaces(id) ON DELETE RESTRICT,
|
||||
kind TEXT NOT NULL,
|
||||
status operation_status NOT NULL DEFAULT 'queued',
|
||||
resource_type TEXT,
|
||||
resource_id UUID,
|
||||
result JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
error_code TEXT,
|
||||
error_message TEXT,
|
||||
attempt_count INTEGER NOT NULL DEFAULT 0 CHECK (attempt_count >= 0),
|
||||
created_by_user_id UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
started_at TIMESTAMPTZ,
|
||||
completed_at TIMESTAMPTZ,
|
||||
CHECK (completed_at IS NULL OR completed_at >= created_at),
|
||||
UNIQUE (workspace_id, id)
|
||||
);
|
||||
CREATE INDEX ix_operations_workspace_status ON operations (workspace_id, status, created_at DESC);
|
||||
|
||||
-- Durable replay record for authenticated, retryable business writes.
|
||||
-- request_fingerprint is an HMAC of canonical request content, never a raw request body.
|
||||
CREATE TABLE idempotency_requests (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
workspace_id UUID NOT NULL REFERENCES workspaces(id) ON DELETE RESTRICT,
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE RESTRICT,
|
||||
http_method TEXT NOT NULL CHECK (http_method IN ('POST', 'PUT', 'PATCH', 'DELETE')),
|
||||
request_path TEXT NOT NULL CHECK (request_path ~ '^/api/'),
|
||||
idempotency_key UUID NOT NULL,
|
||||
request_fingerprint TEXT NOT NULL,
|
||||
status idempotency_request_status NOT NULL DEFAULT 'in_progress',
|
||||
response_status SMALLINT,
|
||||
response_body JSONB,
|
||||
resource_type TEXT,
|
||||
resource_id UUID,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
completed_at TIMESTAMPTZ,
|
||||
expires_at TIMESTAMPTZ NOT NULL DEFAULT now() + INTERVAL '48 hours',
|
||||
CHECK (expires_at > created_at),
|
||||
CHECK (
|
||||
(status = 'in_progress' AND completed_at IS NULL AND response_status IS NULL)
|
||||
OR (status = 'completed' AND completed_at IS NOT NULL AND response_status BETWEEN 200 AND 599)
|
||||
),
|
||||
UNIQUE (workspace_id, user_id, http_method, request_path, idempotency_key),
|
||||
UNIQUE (workspace_id, id)
|
||||
);
|
||||
CREATE INDEX ix_idempotency_requests_expiry
|
||||
ON idempotency_requests (expires_at);
|
||||
|
||||
-- Transactional Outbox. A dispatcher makes these events available to BullMQ only after their business transaction commits.
|
||||
-- event_payload contains identifiers and immutable snapshots only; it must not contain credentials or access URLs.
|
||||
CREATE TABLE outbox_events (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
workspace_id UUID NOT NULL REFERENCES workspaces(id) ON DELETE RESTRICT,
|
||||
aggregate_type TEXT NOT NULL,
|
||||
aggregate_id UUID NOT NULL,
|
||||
event_type TEXT NOT NULL,
|
||||
event_payload JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
deduplication_key TEXT,
|
||||
status outbox_event_status NOT NULL DEFAULT 'pending',
|
||||
attempt_count INTEGER NOT NULL DEFAULT 0 CHECK (attempt_count >= 0),
|
||||
next_attempt_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
locked_by TEXT,
|
||||
locked_until TIMESTAMPTZ,
|
||||
dispatched_at TIMESTAMPTZ,
|
||||
last_error_code TEXT,
|
||||
last_error_message TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
CHECK (
|
||||
(status = 'dispatching' AND locked_by IS NOT NULL AND locked_until IS NOT NULL)
|
||||
OR (status <> 'dispatching' AND locked_by IS NULL AND locked_until IS NULL)
|
||||
),
|
||||
CHECK (dispatched_at IS NULL OR status = 'dispatched'),
|
||||
UNIQUE (workspace_id, id)
|
||||
);
|
||||
CREATE INDEX ix_outbox_events_dispatch
|
||||
ON outbox_events (workspace_id, status, next_attempt_at, created_at)
|
||||
WHERE status IN ('pending', 'dispatching');
|
||||
CREATE INDEX ix_outbox_events_aggregate
|
||||
ON outbox_events (workspace_id, aggregate_type, aggregate_id, created_at DESC);
|
||||
|
||||
CREATE TABLE media_assets (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
workspace_id UUID NOT NULL REFERENCES workspaces(id) ON DELETE RESTRICT,
|
||||
storage_provider TEXT NOT NULL, -- local_minio / qiniu_kodo; provider configuration is server-side only.
|
||||
bucket TEXT NOT NULL,
|
||||
object_key TEXT NOT NULL,
|
||||
content_type TEXT NOT NULL,
|
||||
byte_size BIGINT NOT NULL CHECK (byte_size > 0),
|
||||
checksum_sha256 TEXT NOT NULL,
|
||||
width_px INTEGER CHECK (width_px IS NULL OR width_px > 0),
|
||||
height_px INTEGER CHECK (height_px IS NULL OR height_px > 0),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
UNIQUE (workspace_id, id),
|
||||
UNIQUE (workspace_id, storage_provider, bucket, object_key),
|
||||
UNIQUE (workspace_id, checksum_sha256)
|
||||
);
|
||||
|
||||
-- A short-lived, durable record for browser direct uploads. object_key is never returned as public file identity.
|
||||
CREATE TABLE media_upload_intents (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
workspace_id UUID NOT NULL REFERENCES workspaces(id) ON DELETE RESTRICT,
|
||||
purpose TEXT NOT NULL,
|
||||
storage_provider TEXT NOT NULL,
|
||||
bucket TEXT NOT NULL,
|
||||
object_key TEXT NOT NULL,
|
||||
expected_content_type TEXT NOT NULL,
|
||||
expected_byte_size BIGINT NOT NULL CHECK (expected_byte_size > 0),
|
||||
expected_checksum_sha256 TEXT NOT NULL,
|
||||
status media_upload_status NOT NULL DEFAULT 'pending',
|
||||
asset_id UUID,
|
||||
error_code TEXT,
|
||||
error_message TEXT,
|
||||
expires_at TIMESTAMPTZ NOT NULL,
|
||||
created_by_user_id UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
completed_at TIMESTAMPTZ,
|
||||
FOREIGN KEY (workspace_id, asset_id) REFERENCES media_assets(workspace_id, id) ON DELETE RESTRICT,
|
||||
CHECK (expires_at > created_at),
|
||||
CHECK (completed_at IS NULL OR completed_at >= created_at),
|
||||
CHECK ((status = 'ready') = (asset_id IS NOT NULL)),
|
||||
UNIQUE (workspace_id, id),
|
||||
UNIQUE (storage_provider, bucket, object_key)
|
||||
);
|
||||
CREATE INDEX ix_media_upload_intents_expiry ON media_upload_intents (status, expires_at);
|
||||
|
||||
CREATE TABLE watermark_fonts (
|
||||
code CITEXT PRIMARY KEY,
|
||||
display_name TEXT NOT NULL,
|
||||
status record_status NOT NULL DEFAULT 'active',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE watermark_templates (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
workspace_id UUID NOT NULL REFERENCES workspaces(id) ON DELETE RESTRICT,
|
||||
name TEXT NOT NULL,
|
||||
kind watermark_kind NOT NULL,
|
||||
current_version INTEGER NOT NULL DEFAULT 1 CHECK (current_version > 0),
|
||||
status record_status NOT NULL DEFAULT 'active',
|
||||
deleted_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
revision INTEGER NOT NULL DEFAULT 1 CHECK (revision > 0),
|
||||
UNIQUE (workspace_id, id),
|
||||
UNIQUE (workspace_id, name)
|
||||
);
|
||||
|
||||
CREATE TABLE watermark_template_versions (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
workspace_id UUID NOT NULL,
|
||||
template_id UUID NOT NULL,
|
||||
version INTEGER NOT NULL CHECK (version > 0),
|
||||
kind watermark_kind NOT NULL,
|
||||
text_content TEXT,
|
||||
image_asset_id UUID,
|
||||
font_code CITEXT REFERENCES watermark_fonts(code),
|
||||
font_size_px SMALLINT CHECK (font_size_px BETWEEN 12 AND 60),
|
||||
opacity NUMERIC(5,4) NOT NULL CHECK (opacity BETWEEN 0 AND 1),
|
||||
scale_ratio NUMERIC(7,4) CHECK (scale_ratio > 0),
|
||||
position watermark_position NOT NULL,
|
||||
created_by_user_id UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
FOREIGN KEY (workspace_id, template_id) REFERENCES watermark_templates(workspace_id, id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (workspace_id, image_asset_id) REFERENCES media_assets(workspace_id, id) ON DELETE RESTRICT,
|
||||
UNIQUE (workspace_id, id),
|
||||
UNIQUE (workspace_id, template_id, version),
|
||||
CHECK (
|
||||
(kind = 'text' AND text_content IS NOT NULL AND font_code IS NOT NULL AND font_size_px IS NOT NULL AND image_asset_id IS NULL)
|
||||
OR (kind = 'image' AND image_asset_id IS NOT NULL AND scale_ratio IS NOT NULL AND text_content IS NULL)
|
||||
)
|
||||
);
|
||||
|
||||
CREATE TABLE store_watermark_bindings (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
workspace_id UUID NOT NULL,
|
||||
store_id UUID NOT NULL,
|
||||
template_id UUID NOT NULL,
|
||||
bound_by_user_id UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||
bound_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
unbound_at TIMESTAMPTZ,
|
||||
FOREIGN KEY (workspace_id, store_id) REFERENCES stores(workspace_id, id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (workspace_id, template_id) REFERENCES watermark_templates(workspace_id, id) ON DELETE RESTRICT,
|
||||
CHECK (unbound_at IS NULL OR unbound_at >= bound_at)
|
||||
);
|
||||
CREATE UNIQUE INDEX ux_store_watermark_one_active
|
||||
ON store_watermark_bindings (workspace_id, store_id)
|
||||
WHERE unbound_at IS NULL;
|
||||
|
||||
CREATE TABLE platform_product_categories (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
platform_id UUID NOT NULL REFERENCES platforms(id) ON DELETE CASCADE,
|
||||
parent_id UUID REFERENCES platform_product_categories(id) ON DELETE RESTRICT,
|
||||
external_category_id TEXT NOT NULL,
|
||||
external_type_id TEXT,
|
||||
name TEXT NOT NULL,
|
||||
path TEXT NOT NULL,
|
||||
is_active BOOLEAN NOT NULL DEFAULT true,
|
||||
source_payload JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
synced_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
UNIQUE (platform_id, id),
|
||||
UNIQUE (platform_id, external_category_id, external_type_id)
|
||||
);
|
||||
|
||||
CREATE TABLE commission_categories (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
platform_id UUID NOT NULL REFERENCES platforms(id) ON DELETE CASCADE,
|
||||
parent_id UUID REFERENCES commission_categories(id) ON DELETE RESTRICT,
|
||||
source_category_key TEXT,
|
||||
name TEXT NOT NULL,
|
||||
path TEXT NOT NULL,
|
||||
source rule_source NOT NULL,
|
||||
status record_status NOT NULL DEFAULT 'draft',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
UNIQUE (platform_id, id),
|
||||
UNIQUE (platform_id, path)
|
||||
);
|
||||
|
||||
CREATE TABLE commission_category_rules (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
commission_category_id UUID NOT NULL REFERENCES commission_categories(id) ON DELETE CASCADE,
|
||||
fulfillment fulfillment_mode NOT NULL DEFAULT 'unknown',
|
||||
market_code TEXT NOT NULL DEFAULT 'RU',
|
||||
currency_code CHAR(3) NOT NULL REFERENCES currencies(code),
|
||||
min_price NUMERIC(18,4),
|
||||
max_price NUMERIC(18,4),
|
||||
min_inclusive BOOLEAN NOT NULL DEFAULT false,
|
||||
max_inclusive BOOLEAN NOT NULL DEFAULT true,
|
||||
commission_rate NUMERIC(7,6) NOT NULL CHECK (commission_rate >= 0 AND commission_rate < 1),
|
||||
source rule_source NOT NULL,
|
||||
version TEXT NOT NULL,
|
||||
status record_status NOT NULL DEFAULT 'draft',
|
||||
effective_from TIMESTAMPTZ NOT NULL,
|
||||
effective_to TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
CHECK (min_price IS NULL OR min_price >= 0),
|
||||
CHECK (max_price IS NULL OR max_price >= 0),
|
||||
CHECK (min_price IS NULL OR max_price IS NULL OR min_price <= max_price),
|
||||
CHECK (effective_to IS NULL OR effective_to > effective_from)
|
||||
);
|
||||
|
||||
CREATE TABLE platform_product_category_commission_mappings (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
platform_id UUID NOT NULL REFERENCES platforms(id) ON DELETE CASCADE,
|
||||
platform_product_category_id UUID NOT NULL,
|
||||
commission_category_id UUID NOT NULL,
|
||||
fulfillment fulfillment_mode NOT NULL DEFAULT 'unknown',
|
||||
market_code TEXT NOT NULL DEFAULT 'RU',
|
||||
priority SMALLINT NOT NULL DEFAULT 100 CHECK (priority >= 0),
|
||||
source rule_source NOT NULL DEFAULT 'manual_rule',
|
||||
status record_status NOT NULL DEFAULT 'draft',
|
||||
effective_from TIMESTAMPTZ NOT NULL,
|
||||
effective_to TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
FOREIGN KEY (platform_id, platform_product_category_id)
|
||||
REFERENCES platform_product_categories(platform_id, id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (platform_id, commission_category_id)
|
||||
REFERENCES commission_categories(platform_id, id) ON DELETE RESTRICT,
|
||||
CHECK (effective_to IS NULL OR effective_to > effective_from)
|
||||
);
|
||||
CREATE INDEX ix_category_commission_mapping_lookup
|
||||
ON platform_product_category_commission_mappings (platform_product_category_id, fulfillment, market_code, status, effective_from DESC, priority);
|
||||
|
||||
CREATE TABLE logistics_providers (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
code CITEXT NOT NULL UNIQUE,
|
||||
name TEXT NOT NULL,
|
||||
status record_status NOT NULL DEFAULT 'active',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE logistics_rate_cards (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
provider_id UUID NOT NULL REFERENCES logistics_providers(id) ON DELETE RESTRICT,
|
||||
platform_id UUID NOT NULL REFERENCES platforms(id) ON DELETE RESTRICT,
|
||||
fulfillment fulfillment_mode NOT NULL,
|
||||
market_code TEXT NOT NULL DEFAULT 'RU',
|
||||
version TEXT NOT NULL,
|
||||
source_name TEXT NOT NULL,
|
||||
source_uri TEXT,
|
||||
status record_status NOT NULL DEFAULT 'draft',
|
||||
effective_from TIMESTAMPTZ NOT NULL,
|
||||
effective_to TIMESTAMPTZ,
|
||||
reviewed_by_user_id UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||
reviewed_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
UNIQUE (platform_id, id),
|
||||
UNIQUE (provider_id, platform_id, fulfillment, market_code, version),
|
||||
CHECK (effective_to IS NULL OR effective_to > effective_from)
|
||||
);
|
||||
|
||||
CREATE TABLE logistics_product_types (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
rate_card_id UUID NOT NULL REFERENCES logistics_rate_cards(id) ON DELETE CASCADE,
|
||||
code CITEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
merchandise_currency_code CHAR(3) NOT NULL REFERENCES currencies(code),
|
||||
min_merchandise_value NUMERIC(18,4),
|
||||
max_merchandise_value NUMERIC(18,4),
|
||||
min_value_inclusive BOOLEAN NOT NULL DEFAULT false,
|
||||
max_value_inclusive BOOLEAN NOT NULL DEFAULT true,
|
||||
min_actual_weight_kg NUMERIC(12,3) NOT NULL DEFAULT 0.001 CHECK (min_actual_weight_kg >= 0.001),
|
||||
max_actual_weight_kg NUMERIC(12,3),
|
||||
max_sum_dimensions_cm NUMERIC(12,3),
|
||||
max_longest_side_cm NUMERIC(12,3),
|
||||
max_second_longest_side_cm NUMERIC(12,3),
|
||||
billing_basis billing_weight_basis NOT NULL,
|
||||
volume_divisor NUMERIC(12,3),
|
||||
volume_condition_sum_gt_cm NUMERIC(12,3),
|
||||
volume_round_scale SMALLINT CHECK (volume_round_scale BETWEEN 0 AND 6),
|
||||
billing_round_scale SMALLINT CHECK (billing_round_scale BETWEEN 0 AND 6),
|
||||
max_volume_weight_kg NUMERIC(12,3),
|
||||
volume_limit_round_scale SMALLINT CHECK (volume_limit_round_scale BETWEEN 0 AND 6),
|
||||
return_policy JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
UNIQUE (rate_card_id, code),
|
||||
CHECK (max_merchandise_value IS NULL OR max_merchandise_value >= 0),
|
||||
CHECK (min_merchandise_value IS NULL OR min_merchandise_value >= 0),
|
||||
CHECK (min_merchandise_value IS NULL OR max_merchandise_value IS NULL OR min_merchandise_value <= max_merchandise_value),
|
||||
CHECK (max_actual_weight_kg IS NULL OR max_actual_weight_kg >= min_actual_weight_kg),
|
||||
CHECK ((billing_basis = 'actual_only' AND volume_divisor IS NULL) OR (billing_basis <> 'actual_only' AND volume_divisor IS NOT NULL))
|
||||
);
|
||||
|
||||
CREATE TABLE logistics_transport_methods (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
rate_card_id UUID NOT NULL REFERENCES logistics_rate_cards(id) ON DELETE CASCADE,
|
||||
code CITEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
speed_label TEXT,
|
||||
min_transit_days SMALLINT CHECK (min_transit_days >= 0),
|
||||
max_transit_days SMALLINT CHECK (max_transit_days >= min_transit_days),
|
||||
status record_status NOT NULL DEFAULT 'active',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
UNIQUE (rate_card_id, code)
|
||||
);
|
||||
|
||||
CREATE TABLE logistics_rate_rules (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
product_type_id UUID NOT NULL REFERENCES logistics_product_types(id) ON DELETE CASCADE,
|
||||
transport_method_id UUID NOT NULL REFERENCES logistics_transport_methods(id) ON DELETE CASCADE,
|
||||
fixed_fee_cny NUMERIC(18,4) NOT NULL DEFAULT 0 CHECK (fixed_fee_cny >= 0),
|
||||
per_billable_kg_fee_cny NUMERIC(18,4) NOT NULL CHECK (per_billable_kg_fee_cny >= 0),
|
||||
min_charge_cny NUMERIC(18,4) CHECK (min_charge_cny IS NULL OR min_charge_cny >= 0),
|
||||
surcharge_rules JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||
status record_status NOT NULL DEFAULT 'active',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
UNIQUE (product_type_id, transport_method_id)
|
||||
);
|
||||
|
||||
CREATE TABLE pricing_policies (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
workspace_id UUID NOT NULL,
|
||||
store_id UUID NOT NULL,
|
||||
platform_id UUID NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
note TEXT,
|
||||
status record_status NOT NULL DEFAULT 'active',
|
||||
is_default BOOLEAN NOT NULL DEFAULT false,
|
||||
rate_card_id UUID NOT NULL,
|
||||
default_transport_method_id UUID REFERENCES logistics_transport_methods(id) ON DELETE RESTRICT,
|
||||
domestic_shipping_and_label_fee_cny NUMERIC(18,4) NOT NULL DEFAULT 0 CHECK (domestic_shipping_and_label_fee_cny >= 0),
|
||||
ad_rate NUMERIC(7,6) NOT NULL DEFAULT 0 CHECK (ad_rate >= 0 AND ad_rate < 1),
|
||||
last_mile_rate NUMERIC(7,6) NOT NULL DEFAULT 0 CHECK (last_mile_rate >= 0 AND last_mile_rate < 1),
|
||||
withdrawal_rate NUMERIC(7,6) NOT NULL DEFAULT 0 CHECK (withdrawal_rate >= 0 AND withdrawal_rate < 1),
|
||||
other_rate NUMERIC(7,6) NOT NULL DEFAULT 0 CHECK (other_rate >= 0 AND other_rate < 1),
|
||||
desired_markup_rate NUMERIC(7,6) NOT NULL DEFAULT 0 CHECK (desired_markup_rate >= 0),
|
||||
list_price_discount_rate NUMERIC(7,6) NOT NULL DEFAULT 0 CHECK (list_price_discount_rate >= 0 AND list_price_discount_rate < 1),
|
||||
allow_commission_override BOOLEAN NOT NULL DEFAULT false,
|
||||
fx_strategy TEXT NOT NULL DEFAULT 'system' CHECK (fx_strategy IN ('system', 'fixed', 'override_allowed')),
|
||||
fixed_cny_to_rub_rate NUMERIC(18,8),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
revision INTEGER NOT NULL DEFAULT 1 CHECK (revision > 0),
|
||||
FOREIGN KEY (workspace_id, store_id, platform_id) REFERENCES stores(workspace_id, id, platform_id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (platform_id, rate_card_id) REFERENCES logistics_rate_cards(platform_id, id) ON DELETE RESTRICT,
|
||||
UNIQUE (workspace_id, id),
|
||||
UNIQUE (workspace_id, id, store_id, platform_id),
|
||||
UNIQUE (workspace_id, store_id, name),
|
||||
CHECK ((fx_strategy = 'fixed' AND fixed_cny_to_rub_rate > 0) OR (fx_strategy <> 'fixed' AND fixed_cny_to_rub_rate IS NULL))
|
||||
);
|
||||
CREATE UNIQUE INDEX ux_pricing_policies_one_default
|
||||
ON pricing_policies (workspace_id, store_id)
|
||||
WHERE is_default AND status = 'active';
|
||||
|
||||
CREATE TABLE pricing_policy_transport_methods (
|
||||
workspace_id UUID NOT NULL,
|
||||
pricing_policy_id UUID NOT NULL,
|
||||
transport_method_id UUID NOT NULL REFERENCES logistics_transport_methods(id) ON DELETE RESTRICT,
|
||||
is_enabled BOOLEAN NOT NULL DEFAULT true,
|
||||
sort_order SMALLINT NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (workspace_id, pricing_policy_id, transport_method_id),
|
||||
FOREIGN KEY (workspace_id, pricing_policy_id) REFERENCES pricing_policies(workspace_id, id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE pricing_quotes (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
workspace_id UUID NOT NULL REFERENCES workspaces(id) ON DELETE RESTRICT,
|
||||
store_id UUID NOT NULL,
|
||||
platform_id UUID NOT NULL,
|
||||
pricing_policy_id UUID,
|
||||
product_variant_id UUID, -- Logical FK; add real FK in the product/SKU migration.
|
||||
platform_product_category_id UUID,
|
||||
fulfillment fulfillment_mode NOT NULL,
|
||||
purchase_cost_cny NUMERIC(18,4) NOT NULL CHECK (purchase_cost_cny >= 0),
|
||||
package_weight_kg NUMERIC(12,3) NOT NULL CHECK (package_weight_kg >= 0.001),
|
||||
package_length_cm NUMERIC(12,3) NOT NULL CHECK (package_length_cm > 0),
|
||||
package_width_cm NUMERIC(12,3) NOT NULL CHECK (package_width_cm > 0),
|
||||
package_height_cm NUMERIC(12,3) NOT NULL CHECK (package_height_cm > 0),
|
||||
fx_cny_to_rub NUMERIC(18,8) NOT NULL CHECK (fx_cny_to_rub > 0),
|
||||
fx_rate_origin fx_rate_origin NOT NULL DEFAULT 'system_rate',
|
||||
exchange_rate_id UUID REFERENCES exchange_rates(id) ON DELETE SET NULL,
|
||||
input_snapshot JSONB NOT NULL,
|
||||
status quote_status NOT NULL DEFAULT 'draft',
|
||||
selected_option_id UUID,
|
||||
calculated_at TIMESTAMPTZ,
|
||||
confirmed_at TIMESTAMPTZ,
|
||||
created_by_user_id UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
FOREIGN KEY (workspace_id, store_id, platform_id) REFERENCES stores(workspace_id, id, platform_id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (workspace_id, pricing_policy_id, store_id, platform_id)
|
||||
REFERENCES pricing_policies(workspace_id, id, store_id, platform_id) ON DELETE RESTRICT,
|
||||
FOREIGN KEY (platform_id, platform_product_category_id)
|
||||
REFERENCES platform_product_categories(platform_id, id) ON DELETE RESTRICT,
|
||||
UNIQUE (workspace_id, id)
|
||||
);
|
||||
|
||||
CREATE TABLE pricing_quote_options (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
workspace_id UUID NOT NULL,
|
||||
quote_id UUID NOT NULL,
|
||||
logistics_rate_rule_id UUID REFERENCES logistics_rate_rules(id) ON DELETE SET NULL,
|
||||
commission_rule_id UUID REFERENCES commission_category_rules(id) ON DELETE SET NULL,
|
||||
logistics_product_type_id UUID REFERENCES logistics_product_types(id) ON DELETE SET NULL,
|
||||
transport_method_id UUID REFERENCES logistics_transport_methods(id) ON DELETE SET NULL,
|
||||
merchandise_value_cny NUMERIC(18,4) NOT NULL CHECK (merchandise_value_cny >= 0),
|
||||
selling_price_rub NUMERIC(18,4) NOT NULL CHECK (selling_price_rub >= 0),
|
||||
list_price_rub NUMERIC(18,4) NOT NULL CHECK (list_price_rub >= selling_price_rub),
|
||||
billable_weight_kg NUMERIC(12,3) NOT NULL CHECK (billable_weight_kg >= 0.001),
|
||||
logistics_fee_cny NUMERIC(18,4) NOT NULL CHECK (logistics_fee_cny >= 0),
|
||||
commission_fee_rub NUMERIC(18,4) NOT NULL CHECK (commission_fee_rub >= 0),
|
||||
domestic_fee_cny NUMERIC(18,4) NOT NULL CHECK (domestic_fee_cny >= 0),
|
||||
ad_fee_rub NUMERIC(18,4) NOT NULL CHECK (ad_fee_rub >= 0),
|
||||
last_mile_fee_rub NUMERIC(18,4) NOT NULL CHECK (last_mile_fee_rub >= 0),
|
||||
withdrawal_fee_rub NUMERIC(18,4) NOT NULL CHECK (withdrawal_fee_rub >= 0),
|
||||
other_fee_rub NUMERIC(18,4) NOT NULL CHECK (other_fee_rub >= 0),
|
||||
profit_rub NUMERIC(18,4) NOT NULL,
|
||||
calculation_snapshot JSONB NOT NULL,
|
||||
is_valid BOOLEAN NOT NULL DEFAULT true,
|
||||
validation_errors JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
FOREIGN KEY (workspace_id, quote_id) REFERENCES pricing_quotes(workspace_id, id) ON DELETE CASCADE,
|
||||
UNIQUE (workspace_id, quote_id, transport_method_id, logistics_product_type_id, commission_rule_id)
|
||||
);
|
||||
ALTER TABLE pricing_quotes
|
||||
ADD CONSTRAINT fk_pricing_quotes_selected_option
|
||||
FOREIGN KEY (selected_option_id) REFERENCES pricing_quote_options(id) ON DELETE SET NULL;
|
||||
|
||||
-- Tenant isolation. The API must set app.workspace_id with SET LOCAL after authorization.
|
||||
CREATE OR REPLACE FUNCTION app_current_workspace_id() RETURNS UUID
|
||||
LANGUAGE SQL STABLE AS $$
|
||||
SELECT NULLIF(current_setting('app.workspace_id', true), '')::UUID
|
||||
$$;
|
||||
|
||||
DO $$
|
||||
DECLARE table_name TEXT;
|
||||
BEGIN
|
||||
FOREACH table_name IN ARRAY ARRAY[
|
||||
'stores', 'store_secrets', 'store_warehouses', 'operations', 'idempotency_requests', 'outbox_events',
|
||||
'media_assets', 'media_upload_intents',
|
||||
'watermark_templates', 'watermark_template_versions', 'store_watermark_bindings',
|
||||
'pricing_policies', 'pricing_policy_transport_methods', 'pricing_quotes', 'pricing_quote_options'
|
||||
]
|
||||
LOOP
|
||||
EXECUTE format('ALTER TABLE %I ENABLE ROW LEVEL SECURITY', table_name);
|
||||
EXECUTE format('ALTER TABLE %I FORCE ROW LEVEL SECURITY', table_name);
|
||||
EXECUTE format(
|
||||
'CREATE POLICY workspace_isolation ON %I USING (workspace_id = app_current_workspace_id()) WITH CHECK (workspace_id = app_current_workspace_id())',
|
||||
table_name
|
||||
);
|
||||
END LOOP;
|
||||
END $$;
|
||||
@@ -0,0 +1,17 @@
|
||||
-- Global reference data required by the initial authenticated application shell.
|
||||
SET search_path TO atlas, public;
|
||||
|
||||
INSERT INTO currencies (code, name, symbol, decimal_places)
|
||||
VALUES ('CNY', 'Chinese Yuan', '¥', 2), ('RUB', 'Russian Ruble', '₽', 2)
|
||||
ON CONFLICT (code) DO NOTHING;
|
||||
|
||||
INSERT INTO platforms (code, name)
|
||||
VALUES ('OZON', 'Ozon')
|
||||
ON CONFLICT (code) DO NOTHING;
|
||||
|
||||
INSERT INTO platform_currency_support (platform_id, currency_code)
|
||||
SELECT id, currency_code
|
||||
FROM platforms
|
||||
CROSS JOIN (VALUES ('RUB'::CHAR(3))) AS supported(currency_code)
|
||||
WHERE code = 'OZON'
|
||||
ON CONFLICT (platform_id, currency_code) DO NOTHING;
|
||||
@@ -0,0 +1,10 @@
|
||||
-- 平台默认上下文属于平台能力,不属于门店;此迁移同时修正已初始化的本地数据库。
|
||||
SET search_path TO atlas, public;
|
||||
|
||||
UPDATE platforms
|
||||
SET capabilities = capabilities || jsonb_build_object(
|
||||
'default_market_code', 'RU',
|
||||
'default_locale', 'ru-RU'
|
||||
),
|
||||
updated_at = now()
|
||||
WHERE code = 'OZON';
|
||||
@@ -2,7 +2,12 @@
|
||||
"name": "@atlas/database",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"scripts": {
|
||||
"build": "tsc -p tsconfig.json",
|
||||
"migrate": "tsx src/migrate.ts",
|
||||
"typecheck": "tsc -p tsconfig.json --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
@@ -11,6 +16,7 @@
|
||||
"devDependencies": {
|
||||
"@types/node": "26.3.0",
|
||||
"@types/pg": "8.23.1",
|
||||
"tsx": "4.23.13",
|
||||
"typescript": "7.0.2"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,2 @@
|
||||
/**
|
||||
* 数据库访问层的唯一入口。
|
||||
* 后续在此封装迁移连接与应用连接,应用事务必须先设置 app.workspace_id。
|
||||
*/
|
||||
export const databasePackageName = '@atlas/database';
|
||||
export { Database, createApplicationDatabase } from './pool.js';
|
||||
export type { DatabaseTransaction } from './pool.js';
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import { readdir, readFile } from 'node:fs/promises';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { Pool } from 'pg';
|
||||
|
||||
const migrationsDirectory = join(
|
||||
dirname(fileURLToPath(import.meta.url)),
|
||||
'../migrations',
|
||||
);
|
||||
// 迁移只能使用拥有 schema/table 的专用角色,绝不允许复用 API 的最小权限连接。
|
||||
const connectionString = process.env.MIGRATOR_DATABASE_URL;
|
||||
|
||||
if (!connectionString) {
|
||||
throw new Error(
|
||||
'MIGRATOR_DATABASE_URL is required; never run migrations with the application connection.',
|
||||
);
|
||||
}
|
||||
|
||||
const pool = new Pool({ connectionString, max: 1 });
|
||||
|
||||
async function migrate() {
|
||||
const files = (await readdir(migrationsDirectory))
|
||||
.filter((file) => /^\d+_.+\.sql$/.test(file))
|
||||
.sort();
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
// 迁移账本与业务 DDL 同库保存,用于阻止已应用 migration 被悄悄改写。
|
||||
await client.query('CREATE SCHEMA IF NOT EXISTS atlas');
|
||||
await client.query(`CREATE TABLE IF NOT EXISTS atlas.schema_migrations (
|
||||
filename TEXT PRIMARY KEY,
|
||||
checksum TEXT NOT NULL,
|
||||
applied_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
)`);
|
||||
for (const filename of files) {
|
||||
const sql = await readFile(
|
||||
join(migrationsDirectory, filename),
|
||||
'utf8',
|
||||
);
|
||||
const checksum = createHash('sha256').update(sql).digest('hex');
|
||||
const applied = await client.query<{ checksum: string }>(
|
||||
'SELECT checksum FROM atlas.schema_migrations WHERE filename = $1',
|
||||
[filename],
|
||||
);
|
||||
if (applied.rowCount) {
|
||||
// 已应用文件的内容变化意味着历史与当前 schema 可能不一致,必须人工处理。
|
||||
if (applied.rows[0].checksum !== checksum)
|
||||
throw new Error(
|
||||
`Applied migration was modified: ${filename}`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
await client.query('BEGIN');
|
||||
try {
|
||||
// 单个文件原子提交;失败时不会留下半份 DDL 或 migration 账本记录。
|
||||
await client.query(sql);
|
||||
await client.query(
|
||||
'INSERT INTO atlas.schema_migrations (filename, checksum) VALUES ($1, $2)',
|
||||
[filename, checksum],
|
||||
);
|
||||
await client.query('COMMIT');
|
||||
console.info(`Applied migration ${filename}`);
|
||||
} catch (error) {
|
||||
await client.query('ROLLBACK');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
client.release();
|
||||
await pool.end();
|
||||
}
|
||||
}
|
||||
|
||||
void migrate();
|
||||
@@ -0,0 +1,59 @@
|
||||
import { Pool, type PoolClient, type QueryResultRow } from 'pg';
|
||||
|
||||
export type DatabaseTransaction = Pick<PoolClient, 'query'>;
|
||||
|
||||
export class Database {
|
||||
public constructor(private readonly pool: Pool) {}
|
||||
|
||||
public async transaction<T>(
|
||||
work: (transaction: DatabaseTransaction) => Promise<T>,
|
||||
): Promise<T> {
|
||||
const client = await this.pool.connect();
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
// atlas_app_login 是 NOINHERIT 登录角色;应用 DML 必须降为最小权限的 atlas_app。
|
||||
await client.query('SET LOCAL ROLE atlas_app');
|
||||
const result = await work(client);
|
||||
await client.query('COMMIT');
|
||||
return result;
|
||||
} catch (error) {
|
||||
await client.query('ROLLBACK');
|
||||
throw error;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
/** 每个带工作空间边界的业务读写都必须通过此方法执行。 */
|
||||
public async workspaceTransaction<T>(
|
||||
workspaceId: string,
|
||||
work: (transaction: DatabaseTransaction) => Promise<T>,
|
||||
): Promise<T> {
|
||||
return this.transaction(async (transaction) => {
|
||||
// SET LOCAL 仅作用于当前事务,连接归还池后不会泄漏租户 ID。
|
||||
await transaction.query(
|
||||
"SELECT set_config('app.workspace_id', $1, true)",
|
||||
[workspaceId],
|
||||
);
|
||||
return work(transaction);
|
||||
});
|
||||
}
|
||||
|
||||
public async query<Row extends QueryResultRow = QueryResultRow>(
|
||||
text: string,
|
||||
values?: unknown[],
|
||||
) {
|
||||
// 即使不带租户上下文的查询也使用受限角色,调用方不会误用登录角色权限。
|
||||
return this.transaction((transaction) =>
|
||||
transaction.query<Row>(text, values),
|
||||
);
|
||||
}
|
||||
|
||||
public async close() {
|
||||
await this.pool.end();
|
||||
}
|
||||
}
|
||||
|
||||
export function createApplicationDatabase(connectionString: string): Database {
|
||||
return new Database(new Pool({ connectionString, max: 10 }));
|
||||
}
|
||||
Generated
+449
@@ -23,22 +23,46 @@ importers:
|
||||
'@atlas/contracts':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/contracts
|
||||
'@atlas/database':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/database
|
||||
'@nestjs/common':
|
||||
specifier: 12.0.1
|
||||
version: 12.0.1(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||
'@nestjs/config':
|
||||
specifier: 12.0.0
|
||||
version: 12.0.0(@nestjs/common@12.0.1(reflect-metadata@0.2.2)(rxjs@7.8.2))(rxjs@7.8.2)
|
||||
'@nestjs/core':
|
||||
specifier: 12.0.1
|
||||
version: 12.0.1(@nestjs/common@12.0.1(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@12.0.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||
'@nestjs/platform-express':
|
||||
specifier: 12.0.1
|
||||
version: 12.0.1(@nestjs/common@12.0.1(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@12.0.1)
|
||||
'@nestjs/swagger':
|
||||
specifier: 12.0.1
|
||||
version: 12.0.1(@nestjs/common@12.0.1(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@12.0.1)(reflect-metadata@0.2.2)
|
||||
helmet:
|
||||
specifier: 8.3.0
|
||||
version: 8.3.0
|
||||
ioredis:
|
||||
specifier: 6.0.0
|
||||
version: 6.0.0
|
||||
reflect-metadata:
|
||||
specifier: 0.2.2
|
||||
version: 0.2.2
|
||||
rxjs:
|
||||
specifier: 7.8.2
|
||||
version: 7.8.2
|
||||
zod:
|
||||
specifier: 4.5.4
|
||||
version: 4.5.4
|
||||
devDependencies:
|
||||
'@nestjs/testing':
|
||||
specifier: 12.0.1
|
||||
version: 12.0.1(@nestjs/common@12.0.1(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@12.0.1)(@nestjs/platform-express@12.0.1)
|
||||
'@types/express':
|
||||
specifier: 5.0.6
|
||||
version: 5.0.6
|
||||
'@types/node':
|
||||
specifier: 26.3.0
|
||||
version: 26.3.0
|
||||
@@ -48,6 +72,9 @@ importers:
|
||||
typescript:
|
||||
specifier: 7.0.2
|
||||
version: 7.0.2
|
||||
vitest:
|
||||
specifier: ^4.1.11
|
||||
version: 4.1.11(@types/node@26.3.0)(vite@8.2.2(@types/node@26.3.0)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13))
|
||||
|
||||
apps/extension:
|
||||
dependencies:
|
||||
@@ -161,6 +188,9 @@ importers:
|
||||
'@types/pg':
|
||||
specifier: 8.23.1
|
||||
version: 8.23.1
|
||||
tsx:
|
||||
specifier: 4.23.13
|
||||
version: 4.23.13
|
||||
typescript:
|
||||
specifier: 7.0.2
|
||||
version: 7.0.2
|
||||
@@ -430,6 +460,9 @@ packages:
|
||||
resolution: {integrity: sha512-Z7C/xXCiGWsg0KuKsHTKJxbWhpI3Vs5GwLfOean7MGyVFGqdRgBbAjOCh6u4bbjPc/8MJ2pZmK/0DLdCbivLDA==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
'@microsoft/tsdoc@0.16.0':
|
||||
resolution: {integrity: sha512-xgAyonlVVS+q7Vc7qLW0UrJU7rSFcETRWsqdXZtjzRU8dF+6CkozTK4V4y1LwOX7j8r/vHphjDeMeGI4tNGeGA==}
|
||||
|
||||
'@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.4':
|
||||
resolution: {integrity: sha512-LCkGo6JDfaBhgST7UpPWgNgLINpcpabaHfyz5OBx75nUYxBsaEPxjnyNjWpeb/xBup/682QnBfRBy2/LvPutZQ==}
|
||||
cpu: [arm64]
|
||||
@@ -473,6 +506,12 @@ packages:
|
||||
class-validator:
|
||||
optional: true
|
||||
|
||||
'@nestjs/config@12.0.0':
|
||||
resolution: {integrity: sha512-pG5+fFfCo3cdgVkLJLvvhUGfxEjwzv9hIAQ1aJVHvHlHVniDWfwX5pR/utgoAkJzSikv2oTR1BmIF03FXdi42Q==}
|
||||
peerDependencies:
|
||||
'@nestjs/common': ^11.0.0 || ^12.0.0
|
||||
rxjs: ^7.1.0
|
||||
|
||||
'@nestjs/core@12.0.1':
|
||||
resolution: {integrity: sha512-rU6tAi8vDdyzHgN0iW0J4UJvziroOqzHqzlqL7phOSPizaXVEePfv+OUOsdJEOh0Qd14mslc3udOheLrAEcZow==}
|
||||
engines: {node: '>= 20'}
|
||||
@@ -491,12 +530,57 @@ packages:
|
||||
'@nestjs/websockets':
|
||||
optional: true
|
||||
|
||||
'@nestjs/mapped-types@12.0.0':
|
||||
resolution: {integrity: sha512-aX7lxZcqXldtnSr0bgT2ZPQvpTpIwBMW1GMX29o4s3q02vmq6JoTYuPW0GwYXa8BiFMRX7WbGZVDA703pNqOMQ==}
|
||||
engines: {node: '>=20.19.0'}
|
||||
peerDependencies:
|
||||
'@nestjs/common': ^10.0.0 || ^11.0.0 || ^12.0.0
|
||||
class-transformer: ^0.4.0 || ^0.5.0
|
||||
class-validator: ^0.13.0 || ^0.14.0 || ^0.15.0
|
||||
reflect-metadata: ^0.1.12 || ^0.2.0
|
||||
peerDependenciesMeta:
|
||||
class-transformer:
|
||||
optional: true
|
||||
class-validator:
|
||||
optional: true
|
||||
|
||||
'@nestjs/platform-express@12.0.1':
|
||||
resolution: {integrity: sha512-nF8bRgROptMyixYBfiJUAsjj2o9k13zxAjUKYaqHTPk8mTSd+gk7hXz6NykXNrfHWmMEYC9RttQlMUdMHNTbuA==}
|
||||
peerDependencies:
|
||||
'@nestjs/common': ^12.0.0
|
||||
'@nestjs/core': ^12.0.0
|
||||
|
||||
'@nestjs/swagger@12.0.1':
|
||||
resolution: {integrity: sha512-fQ0WyutSSHedO9HzSkOFKFaRygYNGBno5EggcZljs+KL8tHzPPTO5oyJpaf3BoOKFGGsN/0B13EbIpH2pkPx8g==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
peerDependencies:
|
||||
'@fastify/static': ^8.0.0 || ^9.0.0 || ^10.0.0
|
||||
'@nestjs/common': ^12.0.0
|
||||
'@nestjs/core': ^12.0.0
|
||||
class-transformer: '*'
|
||||
class-validator: '*'
|
||||
reflect-metadata: ^0.1.12 || ^0.2.0
|
||||
peerDependenciesMeta:
|
||||
'@fastify/static':
|
||||
optional: true
|
||||
class-transformer:
|
||||
optional: true
|
||||
class-validator:
|
||||
optional: true
|
||||
|
||||
'@nestjs/testing@12.0.1':
|
||||
resolution: {integrity: sha512-prf3fsUaoKS6t04XcQt5aSTp68Po1GxUcbEBPv+3iqAef07hKQEO15r0jmmwA+rWZfONYT1UcV3YIylR++/tCg==}
|
||||
peerDependencies:
|
||||
'@nestjs/common': ^12.0.0
|
||||
'@nestjs/core': ^12.0.0
|
||||
'@nestjs/microservices': ^12.0.0
|
||||
'@nestjs/platform-express': ^12.0.0
|
||||
peerDependenciesMeta:
|
||||
'@nestjs/microservices':
|
||||
optional: true
|
||||
'@nestjs/platform-express':
|
||||
optional: true
|
||||
|
||||
'@oxc-project/types@0.147.0':
|
||||
resolution: {integrity: sha512-IJ3s6ltHLp45S0bh7phkX+gJO7A1Wuz2EaqpAhb8WjqDwbzMiWKHhyyT42tskaWjEYXtHtVCPpnBJVT9+dcRLg==}
|
||||
|
||||
@@ -882,6 +966,9 @@ packages:
|
||||
'@rolldown/pluginutils@1.0.1':
|
||||
resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==}
|
||||
|
||||
'@scarf/scarf@1.4.0':
|
||||
resolution: {integrity: sha512-xxeapPiUXdZAE3che6f3xogoJPeZgig6omHEy1rIY5WVsB3H2BHNnZH+gHG6x91SCWyQCzWGsuL2Hh3ClO5/qQ==}
|
||||
|
||||
'@standard-schema/spec@1.1.0':
|
||||
resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==}
|
||||
|
||||
@@ -926,9 +1013,27 @@ packages:
|
||||
cpu: [arm64]
|
||||
os: [win32]
|
||||
|
||||
'@types/body-parser@1.19.6':
|
||||
resolution: {integrity: sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==}
|
||||
|
||||
'@types/chai@5.2.3':
|
||||
resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==}
|
||||
|
||||
'@types/connect@3.4.38':
|
||||
resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==}
|
||||
|
||||
'@types/deep-eql@4.0.2':
|
||||
resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==}
|
||||
|
||||
'@types/estree@1.0.9':
|
||||
resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==}
|
||||
|
||||
'@types/express-serve-static-core@5.1.3':
|
||||
resolution: {integrity: sha512-dPfW8NFiOF4wOHc7+N/QSxlY9cfSsenewGbAz8C8U/MULPd/YZ27LvJUIlzaXie7e6Ove9YunJGgC9tbHD2cKw==}
|
||||
|
||||
'@types/express@5.0.6':
|
||||
resolution: {integrity: sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==}
|
||||
|
||||
'@types/filesystem@0.0.36':
|
||||
resolution: {integrity: sha512-vPDXOZuannb9FZdxgHnqSwAG/jvdGM8Wq+6N4D/d80z+D4HWH+bItqsZaVRQykAn6WEVeEkLm2oQigyHtgb0RA==}
|
||||
|
||||
@@ -938,12 +1043,21 @@ packages:
|
||||
'@types/har-format@1.2.16':
|
||||
resolution: {integrity: sha512-fluxdy7ryD3MV6h8pTfTYpy/xQzCFC7m89nOH9y94cNqJ1mDIDPut7MnRHI3F6qRmh/cT2fUjG1MLdCNb4hE9A==}
|
||||
|
||||
'@types/http-errors@2.0.5':
|
||||
resolution: {integrity: sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==}
|
||||
|
||||
'@types/node@26.3.0':
|
||||
resolution: {integrity: sha512-L3fgrnchriRC2ExBflb8j4uZZURHZfQsmQeyVzhjcHW4kkwVyo8/0h1B2MVzMTrYUJYu6G7EWs14hW/L9putqw==}
|
||||
|
||||
'@types/pg@8.23.1':
|
||||
resolution: {integrity: sha512-fKVHpikPdg4GKks3JuLEhvwSyvwzF23hnabPy6DD8ljVbC7+6J5dQzdv4arV6jqq57djnMgs1HKBxX4P8aBI3A==}
|
||||
|
||||
'@types/qs@6.15.1':
|
||||
resolution: {integrity: sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw==}
|
||||
|
||||
'@types/range-parser@1.2.7':
|
||||
resolution: {integrity: sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==}
|
||||
|
||||
'@types/react-dom@19.2.5':
|
||||
resolution: {integrity: sha512-fMPwH9v7r/pp43yUd2/Mbiex5KouJwwR3dzHkhLREUC6764VyDsqxhAxv6OFEYR1RhjOyD1naqba8ECDBe7ZQg==}
|
||||
peerDependencies:
|
||||
@@ -952,6 +1066,12 @@ packages:
|
||||
'@types/react@19.2.18':
|
||||
resolution: {integrity: sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==}
|
||||
|
||||
'@types/send@1.2.1':
|
||||
resolution: {integrity: sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==}
|
||||
|
||||
'@types/serve-static@2.2.0':
|
||||
resolution: {integrity: sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==}
|
||||
|
||||
'@typescript/typescript-aix-ppc64@7.0.2':
|
||||
resolution: {integrity: sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==}
|
||||
engines: {node: '>=16.20.0'}
|
||||
@@ -1088,6 +1208,35 @@ packages:
|
||||
oxc-transform-react:
|
||||
optional: true
|
||||
|
||||
'@vitest/expect@4.1.11':
|
||||
resolution: {integrity: sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw==}
|
||||
|
||||
'@vitest/mocker@4.1.11':
|
||||
resolution: {integrity: sha512-2XJVD55d1o5AZous5CCGKS74g/riOj9odEt2bQpCVZeblHyHdnMeFl4jl0XjU21stf4mbjUkew2eXQZt65g5CQ==}
|
||||
peerDependencies:
|
||||
msw: ^2.4.9
|
||||
vite: ^6.0.0 || ^7.0.0 || ^8.0.0
|
||||
peerDependenciesMeta:
|
||||
msw:
|
||||
optional: true
|
||||
vite:
|
||||
optional: true
|
||||
|
||||
'@vitest/pretty-format@4.1.11':
|
||||
resolution: {integrity: sha512-yiZzPbGTS9Sr/JpFl8zHrcIkAofNbFV6k21vIgQN/cY/oxZeXhJv5sc/MBJ5jFKWmWs+oJHw0UXLZjmf931+Vw==}
|
||||
|
||||
'@vitest/runner@4.1.11':
|
||||
resolution: {integrity: sha512-LztvUgdwMNJMIkj3hQnnxiC2Xy1zNxq928W/xhjCLaNCzqTZOudjwbQf6v9IntZGPw132i2Lq2rgTRZHD3JHNw==}
|
||||
|
||||
'@vitest/snapshot@4.1.11':
|
||||
resolution: {integrity: sha512-pN7ikn1ON7h8ee4gIAp4AzyK+zBtJPzVbqOgu5LCEh4VaJVbPQcgYQYJIMGQPXVeJJq1fnfazis7a5pFNPahog==}
|
||||
|
||||
'@vitest/spy@4.1.11':
|
||||
resolution: {integrity: sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA==}
|
||||
|
||||
'@vitest/utils@4.1.11':
|
||||
resolution: {integrity: sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ==}
|
||||
|
||||
'@webext-core/fake-browser@2.0.1':
|
||||
resolution: {integrity: sha512-4x5z1z8F0KU8ShF4ForXJ8qnA8oZTy7HYjI91Mbpdzp3H3hU9HR6TNPUxfWtSpFz2FwgEircuJKQg0qFIn6RLg==}
|
||||
|
||||
@@ -1139,6 +1288,13 @@ packages:
|
||||
append-field@1.0.0:
|
||||
resolution: {integrity: sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==}
|
||||
|
||||
argparse@2.0.1:
|
||||
resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==}
|
||||
|
||||
assertion-error@2.0.1:
|
||||
resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
asynckit@0.4.0:
|
||||
resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==}
|
||||
|
||||
@@ -1202,6 +1358,10 @@ packages:
|
||||
resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
chai@6.2.2:
|
||||
resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
chokidar@5.0.0:
|
||||
resolution: {integrity: sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==}
|
||||
engines: {node: '>= 20.19.0'}
|
||||
@@ -1261,6 +1421,9 @@ packages:
|
||||
resolution: {integrity: sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
convert-source-map@2.0.0:
|
||||
resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==}
|
||||
|
||||
cookie-signature@1.2.2:
|
||||
resolution: {integrity: sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==}
|
||||
engines: {node: '>=6.6.0'}
|
||||
@@ -1404,6 +1567,9 @@ packages:
|
||||
resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
es-module-lexer@2.3.2:
|
||||
resolution: {integrity: sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==}
|
||||
|
||||
es-object-atoms@1.1.2:
|
||||
resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==}
|
||||
engines: {node: '>= 0.4'}
|
||||
@@ -1412,6 +1578,9 @@ packages:
|
||||
resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
es-toolkit@1.51.0:
|
||||
resolution: {integrity: sha512-zC2lQGkM7QX+Gm6iM3+WIdZJzthsEd14LvRNJneSO2hzyz/zNBENR8+YXWo1cKxgPBtV6ksPYHELbcwBRzmdCw==}
|
||||
|
||||
esbuild@0.28.2:
|
||||
resolution: {integrity: sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -1435,6 +1604,10 @@ packages:
|
||||
resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==}
|
||||
engines: {node: '>= 0.6'}
|
||||
|
||||
expect-type@1.4.0:
|
||||
resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==}
|
||||
engines: {node: '>=12.0.0'}
|
||||
|
||||
express@5.2.1:
|
||||
resolution: {integrity: sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==}
|
||||
engines: {node: '>= 18'}
|
||||
@@ -1527,6 +1700,10 @@ packages:
|
||||
resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
helmet@8.3.0:
|
||||
resolution: {integrity: sha512-Qgpiaws3Sm30Av8Eah6sjMCZZwjlBu+E68rhpCWBshY1lb09HtLwj5GviX0OyQIn+ulUS0iX0AxN5n3tLZzz1w==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
|
||||
hookable@6.1.1:
|
||||
resolution: {integrity: sha512-U9LYDy1CwhMCnprUfeAZWZGByVbhd54hwepegYTK7Pi5NvqEj63ifz5z+xukznehT7i6NIZRu89Ay1AZmRsLEQ==}
|
||||
|
||||
@@ -1595,6 +1772,10 @@ packages:
|
||||
js-tokens@10.0.0:
|
||||
resolution: {integrity: sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==}
|
||||
|
||||
js-yaml@5.4.1:
|
||||
resolution: {integrity: sha512-28R/k+NAjeuf7+CKlTxWZVExJGwVVLwY06DgEnOMz2gEpfNkDcD7QvyiVPT0xy0XXhU8vHsd4Ot42OOPdJG7dQ==}
|
||||
hasBin: true
|
||||
|
||||
json2mq@0.2.0:
|
||||
resolution: {integrity: sha512-SzoRg7ux5DWTII9J2qkrZrqV1gt+rTaoufMxEzXbS26Uid0NwaJd123HcoB80TgubEppxxIGdNxCx50fEoEWQA==}
|
||||
|
||||
@@ -1701,6 +1882,9 @@ packages:
|
||||
resolution: {integrity: sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
magic-string@0.30.21:
|
||||
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
|
||||
|
||||
magic-string@1.2.3:
|
||||
resolution: {integrity: sha512-Bpb0W2TbLKOZ7vJnOUnVRGq3WL2p+ISV29M6hYPL1AFCpyKZpdr5ytiXoTSSxRVhg8YW7f65+6gbG8WG6PCa/g==}
|
||||
|
||||
@@ -1793,6 +1977,10 @@ packages:
|
||||
resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
obug@2.1.4:
|
||||
resolution: {integrity: sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==}
|
||||
engines: {node: '>=12.20.0'}
|
||||
|
||||
ohash@2.0.12:
|
||||
resolution: {integrity: sha512-65S/5gk9YSsaRjcyf7Nfa6h/d3E8/1gslpXfI4W7Dxn/oap8IKRuNT5VXkLQ1YFKIEg4apRY4Pj6aiwFzrDdmw==}
|
||||
|
||||
@@ -2037,6 +2225,9 @@ packages:
|
||||
resolution: {integrity: sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
siginfo@2.0.0:
|
||||
resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==}
|
||||
|
||||
source-map-js@1.2.1:
|
||||
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
@@ -2049,6 +2240,9 @@ packages:
|
||||
resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==}
|
||||
engines: {node: '>= 10.x'}
|
||||
|
||||
stackback@0.0.2:
|
||||
resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==}
|
||||
|
||||
standard-as-callback@2.1.0:
|
||||
resolution: {integrity: sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==}
|
||||
|
||||
@@ -2056,6 +2250,9 @@ packages:
|
||||
resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==}
|
||||
engines: {node: '>= 0.8'}
|
||||
|
||||
std-env@4.2.0:
|
||||
resolution: {integrity: sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==}
|
||||
|
||||
streamsearch@1.1.0:
|
||||
resolution: {integrity: sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==}
|
||||
engines: {node: '>=10.0.0'}
|
||||
@@ -2088,6 +2285,9 @@ packages:
|
||||
resolution: {integrity: sha512-XpWNthvezZnWp0u7/UL8rBbBOnq2Qx39fw+0RNMC/+eotOd81glzsmIWVH0ejarhTeDQihxOKSbjm2XXFo5a8w==}
|
||||
engines: {node: '>= 14'}
|
||||
|
||||
swagger-ui-dist@5.32.14:
|
||||
resolution: {integrity: sha512-nOA2pSQhcmODMUQZpJHYKNuwniDUqcOWGNaSCOoZv12FdOSJ9JxV95HtyRGNMqEBj6h6lCNTy20TgZDYTSuUIg==}
|
||||
|
||||
tasuku@2.3.0:
|
||||
resolution: {integrity: sha512-9Jtk+XAnttslCw4i9RceSZGr2PT5TLn0vUyWnoP2SdlSYzkiYRY6kB5qnPi5IQ/Em8e4oBow1rpaA39iijTIrA==}
|
||||
|
||||
@@ -2098,6 +2298,9 @@ packages:
|
||||
tiny-open@1.3.0:
|
||||
resolution: {integrity: sha512-GUFS8yjJZq0oWqCKCJVHcBgMpmi2WEGXY1le3E5ncR0DsgTII5uUyxtfk8/vGyDDGBE42UYHR6Ocvlk8mkXSRg==}
|
||||
|
||||
tinybench@2.9.0:
|
||||
resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==}
|
||||
|
||||
tinyexec@1.3.0:
|
||||
resolution: {integrity: sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -2106,6 +2309,10 @@ packages:
|
||||
resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==}
|
||||
engines: {node: '>=12.0.0'}
|
||||
|
||||
tinyrainbow@3.1.1:
|
||||
resolution: {integrity: sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==}
|
||||
engines: {node: '>=14.0.0'}
|
||||
|
||||
toidentifier@1.0.1:
|
||||
resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==}
|
||||
engines: {node: '>=0.6'}
|
||||
@@ -2262,9 +2469,55 @@ packages:
|
||||
yaml:
|
||||
optional: true
|
||||
|
||||
vitest@4.1.11:
|
||||
resolution: {integrity: sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw==}
|
||||
engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0}
|
||||
hasBin: true
|
||||
peerDependencies:
|
||||
'@edge-runtime/vm': '*'
|
||||
'@opentelemetry/api': ^1.9.0
|
||||
'@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0
|
||||
'@vitest/browser-playwright': 4.1.11
|
||||
'@vitest/browser-preview': 4.1.11
|
||||
'@vitest/browser-webdriverio': 4.1.11
|
||||
'@vitest/coverage-istanbul': 4.1.11
|
||||
'@vitest/coverage-v8': 4.1.11
|
||||
'@vitest/ui': 4.1.11
|
||||
happy-dom: '*'
|
||||
jsdom: '*'
|
||||
vite: ^6.0.0 || ^7.0.0 || ^8.0.0
|
||||
peerDependenciesMeta:
|
||||
'@edge-runtime/vm':
|
||||
optional: true
|
||||
'@opentelemetry/api':
|
||||
optional: true
|
||||
'@types/node':
|
||||
optional: true
|
||||
'@vitest/browser-playwright':
|
||||
optional: true
|
||||
'@vitest/browser-preview':
|
||||
optional: true
|
||||
'@vitest/browser-webdriverio':
|
||||
optional: true
|
||||
'@vitest/coverage-istanbul':
|
||||
optional: true
|
||||
'@vitest/coverage-v8':
|
||||
optional: true
|
||||
'@vitest/ui':
|
||||
optional: true
|
||||
happy-dom:
|
||||
optional: true
|
||||
jsdom:
|
||||
optional: true
|
||||
|
||||
webpack-virtual-modules@0.6.2:
|
||||
resolution: {integrity: sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==}
|
||||
|
||||
why-is-node-running@2.3.0:
|
||||
resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==}
|
||||
engines: {node: '>=8'}
|
||||
hasBin: true
|
||||
|
||||
wrap-ansi@7.0.0:
|
||||
resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==}
|
||||
engines: {node: '>=10'}
|
||||
@@ -2494,6 +2747,8 @@ snapshots:
|
||||
|
||||
'@lukeed/csprng@1.1.0': {}
|
||||
|
||||
'@microsoft/tsdoc@0.16.0': {}
|
||||
|
||||
'@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.4':
|
||||
optional: true
|
||||
|
||||
@@ -2525,6 +2780,15 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@nestjs/config@12.0.0(@nestjs/common@12.0.1(reflect-metadata@0.2.2)(rxjs@7.8.2))(rxjs@7.8.2)':
|
||||
dependencies:
|
||||
'@nestjs/common': 12.0.1(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||
'@standard-schema/spec': 1.1.0
|
||||
dotenv: 17.4.2
|
||||
dotenv-expand: 13.0.0
|
||||
es-toolkit: 1.51.0
|
||||
rxjs: 7.8.2
|
||||
|
||||
'@nestjs/core@12.0.1(@nestjs/common@12.0.1(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@12.0.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)':
|
||||
dependencies:
|
||||
'@nestjs/common': 12.0.1(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||
@@ -2538,6 +2802,11 @@ snapshots:
|
||||
optionalDependencies:
|
||||
'@nestjs/platform-express': 12.0.1(@nestjs/common@12.0.1(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@12.0.1)
|
||||
|
||||
'@nestjs/mapped-types@12.0.0(@nestjs/common@12.0.1(reflect-metadata@0.2.2)(rxjs@7.8.2))(reflect-metadata@0.2.2)':
|
||||
dependencies:
|
||||
'@nestjs/common': 12.0.1(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||
reflect-metadata: 0.2.2
|
||||
|
||||
'@nestjs/platform-express@12.0.1(@nestjs/common@12.0.1(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@12.0.1)':
|
||||
dependencies:
|
||||
'@nestjs/common': 12.0.1(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||
@@ -2550,6 +2819,27 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@nestjs/swagger@12.0.1(@nestjs/common@12.0.1(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@12.0.1)(reflect-metadata@0.2.2)':
|
||||
dependencies:
|
||||
'@microsoft/tsdoc': 0.16.0
|
||||
'@nestjs/common': 12.0.1(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||
'@nestjs/core': 12.0.1(@nestjs/common@12.0.1(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@12.0.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||
'@nestjs/mapped-types': 12.0.0(@nestjs/common@12.0.1(reflect-metadata@0.2.2)(rxjs@7.8.2))(reflect-metadata@0.2.2)
|
||||
'@standard-schema/spec': 1.1.0
|
||||
es-toolkit: 1.51.0
|
||||
js-yaml: 5.4.1
|
||||
path-to-regexp: 8.4.2
|
||||
reflect-metadata: 0.2.2
|
||||
swagger-ui-dist: 5.32.14
|
||||
|
||||
'@nestjs/testing@12.0.1(@nestjs/common@12.0.1(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@12.0.1)(@nestjs/platform-express@12.0.1)':
|
||||
dependencies:
|
||||
'@nestjs/common': 12.0.1(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||
'@nestjs/core': 12.0.1(@nestjs/common@12.0.1(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@12.0.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||
tslib: 2.8.1
|
||||
optionalDependencies:
|
||||
'@nestjs/platform-express': 12.0.1(@nestjs/common@12.0.1(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@12.0.1)
|
||||
|
||||
'@oxc-project/types@0.147.0': {}
|
||||
|
||||
'@rc-component/async-validator@6.0.0':
|
||||
@@ -2948,6 +3238,8 @@ snapshots:
|
||||
|
||||
'@rolldown/pluginutils@1.0.1': {}
|
||||
|
||||
'@scarf/scarf@1.4.0': {}
|
||||
|
||||
'@standard-schema/spec@1.1.0': {}
|
||||
|
||||
'@tokenizer/inflate@0.4.1':
|
||||
@@ -2979,8 +3271,37 @@ snapshots:
|
||||
'@turbo/windows-arm64@2.10.12':
|
||||
optional: true
|
||||
|
||||
'@types/body-parser@1.19.6':
|
||||
dependencies:
|
||||
'@types/connect': 3.4.38
|
||||
'@types/node': 26.3.0
|
||||
|
||||
'@types/chai@5.2.3':
|
||||
dependencies:
|
||||
'@types/deep-eql': 4.0.2
|
||||
assertion-error: 2.0.1
|
||||
|
||||
'@types/connect@3.4.38':
|
||||
dependencies:
|
||||
'@types/node': 26.3.0
|
||||
|
||||
'@types/deep-eql@4.0.2': {}
|
||||
|
||||
'@types/estree@1.0.9': {}
|
||||
|
||||
'@types/express-serve-static-core@5.1.3':
|
||||
dependencies:
|
||||
'@types/node': 26.3.0
|
||||
'@types/qs': 6.15.1
|
||||
'@types/range-parser': 1.2.7
|
||||
'@types/send': 1.2.1
|
||||
|
||||
'@types/express@5.0.6':
|
||||
dependencies:
|
||||
'@types/body-parser': 1.19.6
|
||||
'@types/express-serve-static-core': 5.1.3
|
||||
'@types/serve-static': 2.2.0
|
||||
|
||||
'@types/filesystem@0.0.36':
|
||||
dependencies:
|
||||
'@types/filewriter': 0.0.33
|
||||
@@ -2989,6 +3310,8 @@ snapshots:
|
||||
|
||||
'@types/har-format@1.2.16': {}
|
||||
|
||||
'@types/http-errors@2.0.5': {}
|
||||
|
||||
'@types/node@26.3.0':
|
||||
dependencies:
|
||||
undici-types: 8.3.0
|
||||
@@ -2999,6 +3322,10 @@ snapshots:
|
||||
pg-protocol: 1.16.0
|
||||
pg-types: 2.2.0
|
||||
|
||||
'@types/qs@6.15.1': {}
|
||||
|
||||
'@types/range-parser@1.2.7': {}
|
||||
|
||||
'@types/react-dom@19.2.5(@types/react@19.2.18)':
|
||||
dependencies:
|
||||
'@types/react': 19.2.18
|
||||
@@ -3007,6 +3334,15 @@ snapshots:
|
||||
dependencies:
|
||||
csstype: 3.2.3
|
||||
|
||||
'@types/send@1.2.1':
|
||||
dependencies:
|
||||
'@types/node': 26.3.0
|
||||
|
||||
'@types/serve-static@2.2.0':
|
||||
dependencies:
|
||||
'@types/http-errors': 2.0.5
|
||||
'@types/node': 26.3.0
|
||||
|
||||
'@typescript/typescript-aix-ppc64@7.0.2':
|
||||
optional: true
|
||||
|
||||
@@ -3072,6 +3408,47 @@ snapshots:
|
||||
'@rolldown/pluginutils': 1.0.1
|
||||
vite: 8.2.2(@types/node@26.3.0)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)
|
||||
|
||||
'@vitest/expect@4.1.11':
|
||||
dependencies:
|
||||
'@standard-schema/spec': 1.1.0
|
||||
'@types/chai': 5.2.3
|
||||
'@vitest/spy': 4.1.11
|
||||
'@vitest/utils': 4.1.11
|
||||
chai: 6.2.2
|
||||
tinyrainbow: 3.1.1
|
||||
|
||||
'@vitest/mocker@4.1.11(vite@8.2.2(@types/node@26.3.0)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13))':
|
||||
dependencies:
|
||||
'@vitest/spy': 4.1.11
|
||||
estree-walker: 3.0.3
|
||||
magic-string: 0.30.21
|
||||
optionalDependencies:
|
||||
vite: 8.2.2(@types/node@26.3.0)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)
|
||||
|
||||
'@vitest/pretty-format@4.1.11':
|
||||
dependencies:
|
||||
tinyrainbow: 3.1.1
|
||||
|
||||
'@vitest/runner@4.1.11':
|
||||
dependencies:
|
||||
'@vitest/utils': 4.1.11
|
||||
pathe: 2.0.3
|
||||
|
||||
'@vitest/snapshot@4.1.11':
|
||||
dependencies:
|
||||
'@vitest/pretty-format': 4.1.11
|
||||
'@vitest/utils': 4.1.11
|
||||
magic-string: 0.30.21
|
||||
pathe: 2.0.3
|
||||
|
||||
'@vitest/spy@4.1.11': {}
|
||||
|
||||
'@vitest/utils@4.1.11':
|
||||
dependencies:
|
||||
'@vitest/pretty-format': 4.1.11
|
||||
convert-source-map: 2.0.0
|
||||
tinyrainbow: 3.1.1
|
||||
|
||||
'@webext-core/fake-browser@2.0.1':
|
||||
dependencies:
|
||||
'@wxt-dev/browser': 0.2.7
|
||||
@@ -3181,6 +3558,10 @@ snapshots:
|
||||
|
||||
append-field@1.0.0: {}
|
||||
|
||||
argparse@2.0.1: {}
|
||||
|
||||
assertion-error@2.0.1: {}
|
||||
|
||||
asynckit@0.4.0: {}
|
||||
|
||||
axios@1.20.0(debug@4.4.3):
|
||||
@@ -3257,6 +3638,8 @@ snapshots:
|
||||
call-bind-apply-helpers: 1.0.2
|
||||
get-intrinsic: 1.3.0
|
||||
|
||||
chai@6.2.2: {}
|
||||
|
||||
chokidar@5.0.0:
|
||||
dependencies:
|
||||
readdirp: 5.1.1
|
||||
@@ -3304,6 +3687,8 @@ snapshots:
|
||||
|
||||
content-type@2.1.0: {}
|
||||
|
||||
convert-source-map@2.0.0: {}
|
||||
|
||||
cookie-signature@1.2.2: {}
|
||||
|
||||
cookie@0.7.2: {}
|
||||
@@ -3417,6 +3802,8 @@ snapshots:
|
||||
|
||||
es-errors@1.3.0: {}
|
||||
|
||||
es-module-lexer@2.3.2: {}
|
||||
|
||||
es-object-atoms@1.1.2:
|
||||
dependencies:
|
||||
es-errors: 1.3.0
|
||||
@@ -3428,6 +3815,8 @@ snapshots:
|
||||
has-tostringtag: 1.0.2
|
||||
hasown: 2.0.4
|
||||
|
||||
es-toolkit@1.51.0: {}
|
||||
|
||||
esbuild@0.28.2:
|
||||
optionalDependencies:
|
||||
'@esbuild/aix-ppc64': 0.28.2
|
||||
@@ -3469,6 +3858,8 @@ snapshots:
|
||||
|
||||
etag@1.8.1: {}
|
||||
|
||||
expect-type@1.4.0: {}
|
||||
|
||||
express@5.2.1:
|
||||
dependencies:
|
||||
accepts: 2.0.0
|
||||
@@ -3587,6 +3978,8 @@ snapshots:
|
||||
dependencies:
|
||||
function-bind: 1.1.2
|
||||
|
||||
helmet@8.3.0: {}
|
||||
|
||||
hookable@6.1.1: {}
|
||||
|
||||
html-escaper@3.0.3: {}
|
||||
@@ -3654,6 +4047,10 @@ snapshots:
|
||||
|
||||
js-tokens@10.0.0: {}
|
||||
|
||||
js-yaml@5.4.1:
|
||||
dependencies:
|
||||
argparse: 2.0.1
|
||||
|
||||
json2mq@0.2.0:
|
||||
dependencies:
|
||||
string-convert: 0.2.1
|
||||
@@ -3729,6 +4126,10 @@ snapshots:
|
||||
|
||||
luxon@3.7.2: {}
|
||||
|
||||
magic-string@0.30.21:
|
||||
dependencies:
|
||||
'@jridgewell/sourcemap-codec': 1.6.0
|
||||
|
||||
magic-string@1.2.3:
|
||||
dependencies:
|
||||
'@jridgewell/sourcemap-codec': 1.6.0
|
||||
@@ -3820,6 +4221,8 @@ snapshots:
|
||||
|
||||
object-inspect@1.13.4: {}
|
||||
|
||||
obug@2.1.4: {}
|
||||
|
||||
ohash@2.0.12: {}
|
||||
|
||||
on-finished@2.4.1:
|
||||
@@ -4092,16 +4495,22 @@ snapshots:
|
||||
side-channel-map: 1.0.1
|
||||
side-channel-weakmap: 1.0.2
|
||||
|
||||
siginfo@2.0.0: {}
|
||||
|
||||
source-map-js@1.2.1: {}
|
||||
|
||||
source-map@0.7.6: {}
|
||||
|
||||
split2@4.2.0: {}
|
||||
|
||||
stackback@0.0.2: {}
|
||||
|
||||
standard-as-callback@2.1.0: {}
|
||||
|
||||
statuses@2.0.2: {}
|
||||
|
||||
std-env@4.2.0: {}
|
||||
|
||||
streamsearch@1.1.0: {}
|
||||
|
||||
string-convert@0.2.1: {}
|
||||
@@ -4132,12 +4541,18 @@ snapshots:
|
||||
|
||||
superlock@1.3.5: {}
|
||||
|
||||
swagger-ui-dist@5.32.14:
|
||||
dependencies:
|
||||
'@scarf/scarf': 1.4.0
|
||||
|
||||
tasuku@2.3.0: {}
|
||||
|
||||
throttle-debounce@5.0.2: {}
|
||||
|
||||
tiny-open@1.3.0: {}
|
||||
|
||||
tinybench@2.9.0: {}
|
||||
|
||||
tinyexec@1.3.0: {}
|
||||
|
||||
tinyglobby@0.2.17:
|
||||
@@ -4145,6 +4560,8 @@ snapshots:
|
||||
fdir: 6.5.0(picomatch@4.0.7)
|
||||
picomatch: 4.0.7
|
||||
|
||||
tinyrainbow@3.1.1: {}
|
||||
|
||||
toidentifier@1.0.1: {}
|
||||
|
||||
token-types@6.1.2:
|
||||
@@ -4281,8 +4698,40 @@ snapshots:
|
||||
jiti: 2.7.0
|
||||
tsx: 4.23.13
|
||||
|
||||
vitest@4.1.11(@types/node@26.3.0)(vite@8.2.2(@types/node@26.3.0)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)):
|
||||
dependencies:
|
||||
'@vitest/expect': 4.1.11
|
||||
'@vitest/mocker': 4.1.11(vite@8.2.2(@types/node@26.3.0)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13))
|
||||
'@vitest/pretty-format': 4.1.11
|
||||
'@vitest/runner': 4.1.11
|
||||
'@vitest/snapshot': 4.1.11
|
||||
'@vitest/spy': 4.1.11
|
||||
'@vitest/utils': 4.1.11
|
||||
es-module-lexer: 2.3.2
|
||||
expect-type: 1.4.0
|
||||
magic-string: 0.30.21
|
||||
obug: 2.1.4
|
||||
pathe: 2.0.3
|
||||
picomatch: 4.0.7
|
||||
std-env: 4.2.0
|
||||
tinybench: 2.9.0
|
||||
tinyexec: 1.3.0
|
||||
tinyglobby: 0.2.17
|
||||
tinyrainbow: 3.1.1
|
||||
vite: 8.2.2(@types/node@26.3.0)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)
|
||||
why-is-node-running: 2.3.0
|
||||
optionalDependencies:
|
||||
'@types/node': 26.3.0
|
||||
transitivePeerDependencies:
|
||||
- msw
|
||||
|
||||
webpack-virtual-modules@0.6.2: {}
|
||||
|
||||
why-is-node-running@2.3.0:
|
||||
dependencies:
|
||||
siginfo: 2.0.0
|
||||
stackback: 0.0.2
|
||||
|
||||
wrap-ansi@7.0.0:
|
||||
dependencies:
|
||||
ansi-styles: 4.3.0
|
||||
|
||||
@@ -3,5 +3,6 @@ packages:
|
||||
- packages/*
|
||||
|
||||
allowBuilds:
|
||||
'@scarf/scarf': false
|
||||
esbuild: true
|
||||
msgpackr-extract: false
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
export default {
|
||||
singleQuote: true,
|
||||
trailingComma: 'all',
|
||||
printWidth: 100,
|
||||
};
|
||||
Reference in New Issue
Block a user