feat: 合并主分支后端代码
This commit is contained in:
+62
-7
@@ -1,12 +1,67 @@
|
|||||||
POSTGRES_USER=atlas_app_login
|
# Local PostgreSQL bootstrap administrator. It creates roles but must never be used by API or Worker.
|
||||||
POSTGRES_PASSWORD=change-me-local-only
|
POSTGRES_ADMIN_USER=atlas_admin
|
||||||
|
POSTGRES_ADMIN_PASSWORD=change-me-local-only
|
||||||
|
# Database name and host port exposed by local Docker Compose.
|
||||||
POSTGRES_DB=atlas
|
POSTGRES_DB=atlas
|
||||||
POSTGRES_PORT=5432
|
POSTGRES_PORT=5432
|
||||||
REDIS_URL=redis://localhost:6379
|
|
||||||
MINIO_ROOT_USER=atlas_minio_local
|
# Password for the schema-owning migrator role; only the migration runner may use it.
|
||||||
MINIO_ROOT_PASSWORD=change-me-local-only
|
ATLAS_MIGRATOR_PASSWORD=change-me-local-only
|
||||||
MINIO_PORT=9000
|
# Password for the least-privileged API/Worker login role.
|
||||||
MINIO_CONSOLE_PORT=9001
|
ATLAS_APP_PASSWORD=change-me-local-only
|
||||||
|
# API/Worker application connection. This role has no table ownership and is constrained by RLS.
|
||||||
|
DATABASE_URL=postgresql://atlas_app_login:change-me-local-only@localhost:5432/atlas
|
||||||
|
# Migration-only connection. Do not put this value in production API or Worker environments.
|
||||||
|
MIGRATOR_DATABASE_URL=postgresql://atlas_migrator:change-me-local-only@localhost:5432/atlas
|
||||||
|
|
||||||
|
# Runtime mode controls production-only safeguards such as Secure cookies.
|
||||||
|
NODE_ENV=development
|
||||||
|
# Browser origin allowed to send credentialed requests to this API.
|
||||||
|
WEB_ORIGIN=http://localhost:8100
|
||||||
|
# HTTP listener port for the NestJS API.
|
||||||
API_PORT=8200
|
API_PORT=8200
|
||||||
|
|
||||||
|
# HMAC signing secret for short-lived access JWTs; replace with a unique 32+ character secret.
|
||||||
|
AUTH_JWT_SECRET=replace-with-a-random-secret-of-at-least-32-characters
|
||||||
|
# Separate HMAC secret used to pseudonymize IP/email values in Redis rate-limit keys.
|
||||||
|
AUTH_RATE_LIMIT_KEY_SECRET=replace-with-a-different-random-secret-of-at-least-32-characters
|
||||||
|
# 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
|
WEB_PORT=8100
|
||||||
|
# Local WXT browser-extension development port.
|
||||||
EXTENSION_PORT=8110
|
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
|
||||||
|
}
|
||||||
+30
-23
@@ -1,25 +1,32 @@
|
|||||||
{
|
{
|
||||||
"name": "@atlas/api",
|
"name": "@atlas/api",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "tsc -p tsconfig.json",
|
"build": "tsc -p tsconfig.json",
|
||||||
"dev": "tsx watch src/main.ts",
|
"dev": "tsx watch src/main.ts",
|
||||||
"start": "node dist/main.js",
|
"start": "node dist/main.js",
|
||||||
"typecheck": "tsc -p tsconfig.json --noEmit"
|
"typecheck": "tsc -p tsconfig.json --noEmit"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@atlas/contracts": "workspace:*",
|
"@atlas/contracts": "workspace:*",
|
||||||
"@nestjs/common": "12.0.1",
|
"@atlas/database": "workspace:*",
|
||||||
"@nestjs/core": "12.0.1",
|
"@nestjs/common": "12.0.1",
|
||||||
"@nestjs/platform-express": "12.0.1",
|
"@nestjs/config": "12.0.0",
|
||||||
"reflect-metadata": "0.2.2",
|
"@nestjs/core": "12.0.1",
|
||||||
"rxjs": "7.8.2"
|
"@nestjs/platform-express": "12.0.1",
|
||||||
},
|
"@nestjs/swagger": "12.0.1",
|
||||||
"devDependencies": {
|
"helmet": "8.3.0",
|
||||||
"@types/node": "26.3.0",
|
"ioredis": "6.0.0",
|
||||||
"tsx": "4.23.13",
|
"reflect-metadata": "0.2.2",
|
||||||
"typescript": "7.0.2"
|
"rxjs": "7.8.2",
|
||||||
}
|
"zod": "4.5.4"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/express": "5.0.6",
|
||||||
|
"@types/node": "26.3.0",
|
||||||
|
"tsx": "4.23.13",
|
||||||
|
"typescript": "7.0.2"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,37 @@
|
|||||||
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 { AppConfigModule } from './config/app-config.module.js';
|
||||||
|
import { DATABASE, DatabaseModule } from './database/database.module.js';
|
||||||
|
import { SecurityModule } from './security/security.module.js';
|
||||||
|
|
||||||
@Controller('health')
|
@Controller()
|
||||||
class HealthController {
|
class HealthController {
|
||||||
@Get()
|
public constructor(@Inject(DATABASE) private readonly database: Database) {}
|
||||||
check() {
|
|
||||||
return { status: 'ok', service: 'api' };
|
@Get('health')
|
||||||
}
|
check() {
|
||||||
|
return { status: 'ok', service: 'api' };
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('ready')
|
||||||
|
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],
|
||||||
|
controllers: [HealthController],
|
||||||
|
})
|
||||||
export class AppModule {}
|
export class AppModule {}
|
||||||
|
|||||||
@@ -0,0 +1,206 @@
|
|||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
HttpCode,
|
||||||
|
Post,
|
||||||
|
Req,
|
||||||
|
Res,
|
||||||
|
UseGuards,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import {
|
||||||
|
ApiBearerAuth,
|
||||||
|
ApiBody,
|
||||||
|
ApiNoContentResponse,
|
||||||
|
ApiOkResponse,
|
||||||
|
ApiOperation,
|
||||||
|
ApiTags,
|
||||||
|
ApiUnauthorizedResponse,
|
||||||
|
} from '@nestjs/swagger';
|
||||||
|
import { createHash } from 'node:crypto';
|
||||||
|
import { loginRequestSchema, registerRequestSchema } from '@atlas/contracts';
|
||||||
|
import { ApiError } from '../common/api-error.js';
|
||||||
|
import { AccessTokenGuard, type AuthenticatedRequest } from './auth.guard.js';
|
||||||
|
import { AuthService, type RequestMetadata } from './auth.service.js';
|
||||||
|
|
||||||
|
const refreshCookie = 'atlas_refresh';
|
||||||
|
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(private readonly authService: AuthService) {}
|
||||||
|
|
||||||
|
@Post('register')
|
||||||
|
@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')
|
||||||
|
@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')
|
||||||
|
@ApiOperation({ summary: '轮换 refresh cookie 并获取新的 access token' })
|
||||||
|
public async refresh(
|
||||||
|
@Req() request: HttpRequest,
|
||||||
|
@Res({ passthrough: true }) response: HttpResponse,
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
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)
|
||||||
|
@UseGuards(AccessTokenGuard)
|
||||||
|
@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')
|
||||||
|
@UseGuards(AccessTokenGuard)
|
||||||
|
@ApiBearerAuth('access-token')
|
||||||
|
@ApiOperation({ summary: '获取当前用户、工作空间和页头初始上下文' })
|
||||||
|
@ApiOkResponse({ description: '当前身份和工作空间' })
|
||||||
|
@ApiUnauthorizedResponse({ description: 'access token 无效或已过期' })
|
||||||
|
public async me(@Req() request: AuthenticatedRequest) {
|
||||||
|
return { data: await this.authService.me(request.auth.userId) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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() {
|
||||||
|
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) {
|
||||||
|
return header
|
||||||
|
?.split(';')
|
||||||
|
.map((part) => part.trim())
|
||||||
|
.find((part) => part.startsWith(`${name}=`))
|
||||||
|
?.slice(name.length + 1);
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
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>;
|
||||||
|
const scryptOptions = { N: 16_384, r: 8, p: 1, maxmem: 32 * 1024 * 1024 };
|
||||||
|
|
||||||
|
export async function hashPassword(password: string): Promise<string> {
|
||||||
|
// The random salt and expensive KDF keep a database leak from exposing passwords directly.
|
||||||
|
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 {
|
||||||
|
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 tokens are short-lived; refresh sessions remain revocable in 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,21 @@
|
|||||||
|
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
|
||||||
|
import { AuthService } from './auth.service.js';
|
||||||
|
|
||||||
|
export type AuthenticatedRequest = {
|
||||||
|
header: (name: string) => string | undefined;
|
||||||
|
auth: { userId: string; sessionId: string };
|
||||||
|
};
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class AccessTokenGuard implements CanActivate {
|
||||||
|
public constructor(private readonly authService: AuthService) {}
|
||||||
|
public async canActivate(context: ExecutionContext) {
|
||||||
|
const request = context
|
||||||
|
.switchToHttp()
|
||||||
|
.getRequest<AuthenticatedRequest>();
|
||||||
|
request.auth = await this.authService.authenticate(
|
||||||
|
request.header('authorization'),
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { AuthController } from './auth.controller.js';
|
||||||
|
import { AccessTokenGuard } from './auth.guard.js';
|
||||||
|
import { AuthService } from './auth.service.js';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [AuthController],
|
||||||
|
providers: [AuthService, AccessTokenGuard],
|
||||||
|
})
|
||||||
|
export class AuthModule {}
|
||||||
@@ -0,0 +1,340 @@
|
|||||||
|
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 {
|
||||||
|
private readonly accessTtlSeconds = 900;
|
||||||
|
private readonly refreshTtlDays = 30;
|
||||||
|
|
||||||
|
public constructor(
|
||||||
|
@Inject(DATABASE) private readonly database: Database,
|
||||||
|
@Inject(APP_CONFIG) private readonly config: AppConfig,
|
||||||
|
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();
|
||||||
|
// Apply the shared limiter before spending CPU on password hashing or querying account existence.
|
||||||
|
await this.rateLimiter.consume('register', { ip: metadata.ip, email });
|
||||||
|
const passwordHash = await hashPassword(input.password);
|
||||||
|
try {
|
||||||
|
return await this.database.transaction(async (transaction) => {
|
||||||
|
// A user must never exist without its initial tenant and 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();
|
||||||
|
// Use the same normalized value for the limiter and citext account lookup.
|
||||||
|
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 has no trusted email until its opaque token is validated, so it is IP-scoped only.
|
||||||
|
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', '登录会话已过期');
|
||||||
|
// Row locking makes a refresh token single-use even when two browser requests arrive together.
|
||||||
|
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();
|
||||||
|
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) {
|
||||||
|
const workspace = await this.findWorkspace(userId);
|
||||||
|
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,
|
||||||
|
) {
|
||||||
|
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 {
|
||||||
|
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,
|
||||||
|
) {
|
||||||
|
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,61 @@
|
|||||||
|
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) {
|
||||||
|
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) {
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return response.status(500).json({
|
||||||
|
error: {
|
||||||
|
code: 'INTERNAL_ERROR',
|
||||||
|
message: '服务器暂时无法处理请求',
|
||||||
|
request_id: requestId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { HttpException, HttpStatus } from '@nestjs/common';
|
||||||
|
|
||||||
|
export class ApiError extends HttpException {
|
||||||
|
public constructor(
|
||||||
|
status: HttpStatus,
|
||||||
|
code: string,
|
||||||
|
message: string,
|
||||||
|
details?: unknown[],
|
||||||
|
) {
|
||||||
|
super({ error: { code, message, details } }, status);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
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');
|
||||||
|
}
|
||||||
|
|
||||||
|
// This schema is the only supported source for API runtime settings. Defaults are
|
||||||
|
// intentionally limited to non-secret local-development values.
|
||||||
|
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),
|
||||||
|
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;
|
||||||
|
// Fail before Nest opens a listener; a half-configured API is unsafe to run.
|
||||||
|
throw new Error(
|
||||||
|
`Invalid environment configuration: ${result.error.issues.map((issue) => issue.path.join('.')).join(', ')}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function createAppConfig(environment: Environment): AppConfig {
|
||||||
|
return {
|
||||||
|
...environment,
|
||||||
|
// Express only trusts forwarding headers when this explicit allowlist is non-empty.
|
||||||
|
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 runs this workspace's scripts from apps/api, while the
|
||||||
|
// shared development environment lives at the repository root.
|
||||||
|
envFilePath: findEnvironmentFile(),
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
providers: [
|
||||||
|
{
|
||||||
|
provide: APP_CONFIG,
|
||||||
|
// Export a normalized immutable config object so services never read process.env directly.
|
||||||
|
useFactory: (): AppConfig =>
|
||||||
|
createAppConfig(validateEnvironment(process.env)),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
exports: [APP_CONFIG],
|
||||||
|
})
|
||||||
|
export class AppConfigModule {}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
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],
|
||||||
|
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() {
|
||||||
|
await this.database.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
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),
|
||||||
|
};
|
||||||
|
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;
|
||||||
|
}
|
||||||
+144
-5
@@ -1,12 +1,151 @@
|
|||||||
import 'reflect-metadata';
|
import 'reflect-metadata';
|
||||||
|
import { execFile } from 'node:child_process';
|
||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
import { NestFactory } from '@nestjs/core';
|
import { NestFactory } from '@nestjs/core';
|
||||||
|
import { NestExpressApplication } from '@nestjs/platform-express';
|
||||||
|
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
|
||||||
|
import type { NextFunction, Request, Response } from 'express';
|
||||||
|
import helmet from 'helmet';
|
||||||
|
|
||||||
import { AppModule } from './app.module.js';
|
import { AppModule } from './app.module.js';
|
||||||
|
import { ApiErrorFilter } from './common/api-error.filter.js';
|
||||||
|
import { APP_CONFIG, type AppConfig } from './config/app-config.module.js';
|
||||||
|
import { AppLogger } from './logging/app-logger.js';
|
||||||
|
|
||||||
async function bootstrap() {
|
type RequestWithId = Request & {
|
||||||
const app = await NestFactory.create(AppModule);
|
requestId?: string;
|
||||||
app.enableCors({ origin: process.env.WEB_ORIGIN ?? 'http://localhost:8100' });
|
};
|
||||||
await app.listen(Number(process.env.API_PORT ?? 8200));
|
|
||||||
|
function openSwaggerInDevelopment(config: AppConfig, logger: AppLogger): void {
|
||||||
|
if (
|
||||||
|
config.NODE_ENV !== 'development' ||
|
||||||
|
!config.swaggerEnabled ||
|
||||||
|
process.platform !== 'darwin'
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const swaggerUrl = `http://localhost:${config.API_PORT}/api-docs`;
|
||||||
|
// 只在本机开发时打开浏览器,生产环境和非 macOS 主机不执行外部进程。
|
||||||
|
execFile('open', [swaggerUrl], (error) => {
|
||||||
|
if (error) {
|
||||||
|
logger.warn('swagger_auto_open_failed', {
|
||||||
|
error: error.message,
|
||||||
|
url: swaggerUrl,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
void bootstrap();
|
async function bootstrap() {
|
||||||
|
const app = await NestFactory.create<NestExpressApplication>(AppModule, {
|
||||||
|
bodyParser: false,
|
||||||
|
// Keep Nest's bootstrap errors visible until AppLogger is installed below.
|
||||||
|
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) {
|
||||||
|
// Never trust forwarded headers unless the deployment names its proxy networks explicitly.
|
||||||
|
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'],
|
||||||
|
});
|
||||||
|
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'];
|
||||||
|
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) {
|
||||||
|
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 },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await app.listen(config.API_PORT);
|
||||||
|
logger.log('api_started', {
|
||||||
|
port: config.API_PORT,
|
||||||
|
swagger_enabled: config.swaggerEnabled,
|
||||||
|
});
|
||||||
|
openSwaggerInDevelopment(config, logger);
|
||||||
|
}
|
||||||
|
|
||||||
|
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,102 @@
|
|||||||
|
import { Inject, Injectable, OnModuleDestroy } 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 OnModuleDestroy {
|
||||||
|
private readonly redis: Redis;
|
||||||
|
|
||||||
|
public constructor(@Inject(APP_CONFIG) private readonly config: AppConfig) {
|
||||||
|
// Redis makes counters shared across API processes instead of resetting per instance.
|
||||||
|
this.redis = new Redis(config.REDIS_URL, {
|
||||||
|
enableOfflineQueue: false,
|
||||||
|
maxRetriesPerRequest: 1,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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> {
|
||||||
|
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');
|
||||||
|
// The raw IP/email never becomes part of a Redis key or a loggable identifier.
|
||||||
|
const key = `atlas:auth-rate-limit:${action}:${dimension}:${digest}`;
|
||||||
|
try {
|
||||||
|
// INCR + first-write EXPIRE must be atomic or concurrent requests could leave a key unexpired.
|
||||||
|
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;
|
||||||
|
// Authentication fails closed: silently bypassing a broken shared limiter enables brute-force attacks.
|
||||||
|
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 {}
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
{
|
{
|
||||||
"extends": "../../tsconfig.base.json",
|
"extends": "../../tsconfig.base.json",
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"rootDir": "src",
|
"rootDir": "src",
|
||||||
"outDir": "dist",
|
"outDir": "dist",
|
||||||
"types": ["node"],
|
"types": ["node"],
|
||||||
"experimentalDecorators": true,
|
"experimentalDecorators": true,
|
||||||
"emitDecoratorMetadata": true
|
"emitDecoratorMetadata": true
|
||||||
},
|
},
|
||||||
"include": ["src/**/*.ts"]
|
"include": ["src/**/*.ts"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
export default defineBackground(() => {
|
export default defineBackground(() => {
|
||||||
console.info('Atlas collector background started.');
|
console.info('Atlas collector background started.');
|
||||||
});
|
});
|
||||||
|
|||||||
+21
-21
@@ -1,23 +1,23 @@
|
|||||||
{
|
{
|
||||||
"name": "@atlas/extension",
|
"name": "@atlas/extension",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "wxt build",
|
"build": "wxt build",
|
||||||
"dev": "wxt",
|
"dev": "wxt",
|
||||||
"typecheck": "wxt prepare && tsc -p tsconfig.json --noEmit"
|
"typecheck": "wxt prepare && tsc -p tsconfig.json --noEmit"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@atlas/contracts": "workspace:*",
|
"@atlas/contracts": "workspace:*",
|
||||||
"@wxt-dev/module-react": "1.2.2",
|
"@wxt-dev/module-react": "1.2.2",
|
||||||
"react": "19.2.8",
|
"react": "19.2.8",
|
||||||
"react-dom": "19.2.8"
|
"react-dom": "19.2.8"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/react": "19.2.18",
|
"@types/react": "19.2.18",
|
||||||
"@types/react-dom": "19.2.5",
|
"@types/react-dom": "19.2.5",
|
||||||
"typescript": "7.0.2",
|
"typescript": "7.0.2",
|
||||||
"wxt": "0.21.4"
|
"wxt": "0.21.4"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
{
|
{
|
||||||
"extends": "../../tsconfig.base.json",
|
"extends": "../../tsconfig.base.json",
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"jsx": "react-jsx",
|
"jsx": "react-jsx",
|
||||||
"noEmit": true
|
"noEmit": true
|
||||||
},
|
},
|
||||||
"include": ["**/*.ts", "**/*.tsx", ".wxt/**/*.ts"]
|
"include": ["**/*.ts", "**/*.tsx", ".wxt/**/*.ts"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,25 +1,25 @@
|
|||||||
import { defineConfig } from 'wxt';
|
import { defineConfig } from 'wxt';
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
modules: ['@wxt-dev/module-react'],
|
modules: ['@wxt-dev/module-react'],
|
||||||
// 扩展 dev server 固定 8110:默认取 3000–3010 第一个空闲端口,会漂移且易与常见服务冲突
|
// 扩展 dev server 固定 8110:默认取 3000–3010 第一个空闲端口,会漂移且易与常见服务冲突
|
||||||
dev: {
|
dev: {
|
||||||
server: {
|
server: {
|
||||||
port: Number(process.env.EXTENSION_PORT ?? 8110),
|
port: Number(process.env.EXTENSION_PORT ?? 8110),
|
||||||
strictPort: true,
|
strictPort: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
manifest: {
|
||||||
|
name: 'Atlas Seller Kit Collector',
|
||||||
|
description: '将已确认的来源商品采集到 Atlas 工作空间。',
|
||||||
|
permissions: ['storage', 'identity'],
|
||||||
|
host_permissions: [
|
||||||
|
'*://*.ozon.ru/*',
|
||||||
|
'*://*.ozon.kz/*',
|
||||||
|
'*://*.ozon.by/*',
|
||||||
|
'*://*.1688.com/*',
|
||||||
|
'*://*.taobao.com/*',
|
||||||
|
'*://*.tmall.com/*',
|
||||||
|
],
|
||||||
},
|
},
|
||||||
},
|
|
||||||
manifest: {
|
|
||||||
name: 'Atlas Seller Kit Collector',
|
|
||||||
description: '将已确认的来源商品采集到 Atlas 工作空间。',
|
|
||||||
permissions: ['storage', 'identity'],
|
|
||||||
host_permissions: [
|
|
||||||
'*://*.ozon.ru/*',
|
|
||||||
'*://*.ozon.kz/*',
|
|
||||||
'*://*.ozon.by/*',
|
|
||||||
'*://*.1688.com/*',
|
|
||||||
'*://*.taobao.com/*',
|
|
||||||
'*://*.tmall.com/*',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -5,8 +5,21 @@
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
padding: 24px;
|
padding: 24px;
|
||||||
background:
|
background:
|
||||||
|
<<<<<<< HEAD
|
||||||
radial-gradient(1000px 500px at 15% -10%, rgba(139, 92, 246, 0.14), transparent 60%),
|
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(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);
|
var(--bg-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+19
-19
@@ -1,21 +1,21 @@
|
|||||||
{
|
{
|
||||||
"name": "@atlas/worker",
|
"name": "@atlas/worker",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "tsc -p tsconfig.json",
|
"build": "tsc -p tsconfig.json",
|
||||||
"dev": "tsx watch src/main.ts",
|
"dev": "tsx watch src/main.ts",
|
||||||
"start": "node dist/main.js",
|
"start": "node dist/main.js",
|
||||||
"typecheck": "tsc -p tsconfig.json --noEmit"
|
"typecheck": "tsc -p tsconfig.json --noEmit"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@atlas/contracts": "workspace:*",
|
"@atlas/contracts": "workspace:*",
|
||||||
"bullmq": "6.3.4",
|
"bullmq": "6.3.4",
|
||||||
"ioredis": "6.0.0"
|
"ioredis": "6.0.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "26.3.0",
|
"@types/node": "26.3.0",
|
||||||
"tsx": "4.23.13",
|
"tsx": "4.23.13",
|
||||||
"typescript": "7.0.2"
|
"typescript": "7.0.2"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+11
-8
@@ -3,22 +3,25 @@ import IORedis from 'ioredis';
|
|||||||
|
|
||||||
// 显式构造客户端实例传给 BullMQ:原生 ESM(pnpm start)下 BullMQ 无法按 connection
|
// 显式构造客户端实例传给 BullMQ:原生 ESM(pnpm start)下 BullMQ 无法按 connection
|
||||||
// 选项自动加载 ioredis;maxRetriesPerRequest: null 是 BullMQ Worker 的要求
|
// 选项自动加载 ioredis;maxRetriesPerRequest: null 是 BullMQ Worker 的要求
|
||||||
const connection = new IORedis(process.env.REDIS_URL ?? 'redis://localhost:6379', {
|
const connection = new IORedis(
|
||||||
maxRetriesPerRequest: null,
|
process.env.REDIS_URL ?? 'redis://localhost:6379',
|
||||||
});
|
{
|
||||||
|
maxRetriesPerRequest: null,
|
||||||
|
},
|
||||||
|
);
|
||||||
const dispatchQueue = new Queue('atlas-dispatch', { connection });
|
const dispatchQueue = new Queue('atlas-dispatch', { connection });
|
||||||
|
|
||||||
async function bootstrap() {
|
async function bootstrap() {
|
||||||
await dispatchQueue.waitUntilReady();
|
await dispatchQueue.waitUntilReady();
|
||||||
console.info(
|
console.info(
|
||||||
'Atlas Worker is ready; it will consume transactional-outbox events after that module exists.',
|
'Atlas Worker is ready; it will consume transactional-outbox events after that module exists.',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
void bootstrap();
|
void bootstrap();
|
||||||
|
|
||||||
async function shutdown() {
|
async function shutdown() {
|
||||||
await dispatchQueue.close();
|
await dispatchQueue.close();
|
||||||
}
|
}
|
||||||
|
|
||||||
process.once('SIGINT', () => void shutdown());
|
process.once('SIGINT', () => void shutdown());
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
{
|
{
|
||||||
"extends": "../../tsconfig.base.json",
|
"extends": "../../tsconfig.base.json",
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"rootDir": "src",
|
"rootDir": "src",
|
||||||
"outDir": "dist",
|
"outDir": "dist",
|
||||||
"types": ["node"]
|
"types": ["node"]
|
||||||
},
|
},
|
||||||
"include": ["src/**/*.ts"]
|
"include": ["src/**/*.ts"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,10 @@
|
|||||||
|
|
||||||
> 状态:第一批可评审契约。本文档描述预期的 NestJS HTTP API,不代表接口已经实现。接口统一以前缀 `/api` 提供,JSON 字段使用 `snake_case`,时间使用 ISO 8601 UTC 字符串,ID 使用 UUID。
|
> 状态:第一批可评审契约。本文档描述预期的 NestJS HTTP API,不代表接口已经实现。接口统一以前缀 `/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. 文档路线
|
## 1. 文档路线
|
||||||
|
|
||||||
| 文档 | 状态 | 范围 |
|
| 文档 | 状态 | 范围 |
|
||||||
|
|||||||
@@ -29,6 +29,25 @@ X-Atlas-Platform-Id: 57ad…-uuid
|
|||||||
|
|
||||||
## 3. 响应、分页和字段格式
|
## 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
|
```json
|
||||||
|
|||||||
@@ -12,6 +12,22 @@
|
|||||||
|
|
||||||
`atlas_app_login` 和 `atlas_app` 都必须是 `NOBYPASSRLS`,也不能是 PostgreSQL 超级用户。生产环境不得把 `atlas_migrator` 的连接串写入 API 或 Worker 的环境变量。
|
`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. 一次性数据库初始化
|
## 2. 一次性数据库初始化
|
||||||
|
|
||||||
以下由云数据库管理员或基础设施脚本执行;它不属于 NestJS 的业务 migration,因为托管 PostgreSQL 常不允许应用账户创建角色。密码应从密钥管理服务注入,示例中不填写真实密码。
|
以下由云数据库管理员或基础设施脚本执行;它不属于 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>';
|
CREATE ROLE atlas_app_login LOGIN NOINHERIT NOBYPASSRLS PASSWORD '<managed-secret>';
|
||||||
|
|
||||||
GRANT atlas_app TO atlas_app_login;
|
GRANT atlas_app TO atlas_app_login;
|
||||||
|
GRANT CREATE ON DATABASE atlas TO atlas_migrator;
|
||||||
CREATE SCHEMA IF NOT EXISTS atlas AUTHORIZATION atlas_migrator;
|
CREATE SCHEMA IF NOT EXISTS atlas AUTHORIZATION atlas_migrator;
|
||||||
|
|
||||||
GRANT USAGE ON SCHEMA atlas TO atlas_app;
|
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;
|
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 的运行方式
|
## 3. RLS 的运行方式
|
||||||
|
|
||||||
|
|||||||
@@ -86,6 +86,7 @@ User → Workspace → Membership / Role
|
|||||||
|
|
||||||
- 覆盖账户认证、平台/门店、水印与素材、定价、浏览器采集/采集箱、商品编辑/首次发布、在线商品读取与同步。
|
- 覆盖账户认证、平台/门店、水印与素材、定价、浏览器采集/采集箱、商品编辑/首次发布、在线商品读取与同步。
|
||||||
- 所有接口遵循统一鉴权、工作空间隔离、页头平台上下文、金额字符串、错误格式、`Idempotency-Key`、`If-Match` 和异步 `operation` 约定。
|
- 所有接口遵循统一鉴权、工作空间隔离、页头平台上下文、金额字符串、错误格式、`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 必须幂等。
|
- 关键异步动作必须在同一事务写入业务记录、`operations`(如需用户可见进度)和 `outbox_events`;Dispatcher 至少一次投递 BullMQ,Worker 必须幂等。
|
||||||
- DDL 已通过临时隔离 PostgreSQL 实例执行验证;API 契约已完成文档评审。这不表示 NestJS 模块、迁移执行器或 Worker 已实现。
|
- DDL 已通过临时隔离 PostgreSQL 实例执行验证;API 契约已完成文档评审。这不表示 NestJS 模块、迁移执行器或 Worker 已实现。
|
||||||
|
|
||||||
|
|||||||
+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 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`。
|
||||||
|
|||||||
+42
-37
@@ -1,44 +1,49 @@
|
|||||||
name: atlas-seller-kit
|
name: atlas-seller-kit
|
||||||
|
|
||||||
services:
|
services:
|
||||||
postgres:
|
postgres:
|
||||||
image: postgres:18.6-alpine
|
image: postgres:18.6-alpine
|
||||||
environment:
|
environment:
|
||||||
POSTGRES_USER: ${POSTGRES_USER:-atlas_app_login}
|
POSTGRES_USER: ${POSTGRES_ADMIN_USER:-atlas_admin}
|
||||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-change-me-local-only}
|
POSTGRES_PASSWORD: ${POSTGRES_ADMIN_PASSWORD:-change-me-local-only}
|
||||||
POSTGRES_DB: ${POSTGRES_DB:-atlas}
|
POSTGRES_DB: ${POSTGRES_DB:-atlas}
|
||||||
ports:
|
ATLAS_MIGRATOR_PASSWORD: ${ATLAS_MIGRATOR_PASSWORD:-change-me-local-only}
|
||||||
- '${POSTGRES_PORT:-5432}:5432'
|
ATLAS_APP_PASSWORD: ${ATLAS_APP_PASSWORD:-change-me-local-only}
|
||||||
volumes:
|
ports:
|
||||||
- postgres-data:/var/lib/postgresql/data
|
- '${POSTGRES_PORT:-5432}:5432'
|
||||||
healthcheck:
|
volumes:
|
||||||
test: ['CMD-SHELL', 'pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB']
|
# PostgreSQL 18 将集群数据放在版本化子目录,volume 必须挂到父目录。
|
||||||
interval: 5s
|
- postgres-data:/var/lib/postgresql
|
||||||
timeout: 5s
|
- ./postgres/init:/docker-entrypoint-initdb.d:ro
|
||||||
retries: 20
|
healthcheck:
|
||||||
|
test:
|
||||||
|
['CMD-SHELL', 'pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB']
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 20
|
||||||
|
|
||||||
redis:
|
redis:
|
||||||
image: redis:7.4.11-alpine3.21
|
image: redis:7.4.11-alpine3.21
|
||||||
ports:
|
ports:
|
||||||
- '6379:6379'
|
- '6379:6379'
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ['CMD', 'redis-cli', 'ping']
|
test: ['CMD', 'redis-cli', 'ping']
|
||||||
interval: 5s
|
interval: 5s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 20
|
retries: 20
|
||||||
|
|
||||||
minio:
|
minio:
|
||||||
image: minio/minio:RELEASE.2025-09-07T16-13-09Z
|
image: minio/minio:RELEASE.2025-09-07T16-13-09Z
|
||||||
command: server /data --console-address ':9001'
|
command: server /data --console-address ':9001'
|
||||||
environment:
|
environment:
|
||||||
MINIO_ROOT_USER: ${MINIO_ROOT_USER:-atlas_minio_local}
|
MINIO_ROOT_USER: ${MINIO_ROOT_USER:-atlas_minio_local}
|
||||||
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:-change-me-local-only}
|
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:-change-me-local-only}
|
||||||
ports:
|
ports:
|
||||||
- '${MINIO_PORT:-9000}:9000'
|
- '${MINIO_PORT:-9000}:9000'
|
||||||
- '${MINIO_CONSOLE_PORT:-9001}:9001'
|
- '${MINIO_CONSOLE_PORT:-9001}:9001'
|
||||||
volumes:
|
volumes:
|
||||||
- minio-data:/data
|
- minio-data:/data
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
postgres-data:
|
postgres-data:
|
||||||
minio-data:
|
minio-data:
|
||||||
|
|||||||
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
|
||||||
+7
-5
@@ -9,14 +9,16 @@
|
|||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "turbo run build",
|
"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",
|
"lint": "turbo run lint",
|
||||||
"typecheck": "turbo run typecheck",
|
"typecheck": "turbo run typecheck",
|
||||||
"test": "turbo run test",
|
"test": "turbo run test",
|
||||||
"format": "prettier --write .",
|
"format": "prettier --config ./prettier.config.mjs --write .",
|
||||||
"format:check": "prettier --check .",
|
"format:check": "prettier --config ./prettier.config.mjs --check .",
|
||||||
"infra:up": "docker compose -f infra/compose.yaml up -d postgres redis minio",
|
"infra:up": "docker compose --env-file .env -f infra/compose.yaml up -d postgres redis minio",
|
||||||
"infra:down": "docker compose -f infra/compose.yaml down"
|
"infra:down": "docker compose --env-file .env -f infra/compose.yaml down"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"prettier": "3.9.6",
|
"prettier": "3.9.6",
|
||||||
|
|||||||
@@ -1,20 +1,20 @@
|
|||||||
{
|
{
|
||||||
"name": "@atlas/contracts",
|
"name": "@atlas/contracts",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"main": "./dist/index.js",
|
"main": "./dist/index.js",
|
||||||
"types": "./dist/index.d.ts",
|
"types": "./dist/index.d.ts",
|
||||||
"files": [
|
"files": [
|
||||||
"dist"
|
"dist"
|
||||||
],
|
],
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "tsc -p tsconfig.json",
|
"build": "tsc -p tsconfig.json",
|
||||||
"typecheck": "tsc -p tsconfig.json --noEmit"
|
"typecheck": "tsc -p tsconfig.json --noEmit"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"zod": "4.5.4"
|
"zod": "4.5.4"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"typescript": "7.0.2"
|
"typescript": "7.0.2"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>;
|
||||||
@@ -5,20 +5,22 @@ import { z } from 'zod';
|
|||||||
* 领域字段会随采集模块实现逐步补全,不能在扩展侧绕过此契约直接构造 API 请求。
|
* 领域字段会随采集模块实现逐步补全,不能在扩展侧绕过此契约直接构造 API 请求。
|
||||||
*/
|
*/
|
||||||
export const collectedProductPayloadSchema = z.object({
|
export const collectedProductPayloadSchema = z.object({
|
||||||
source: z.object({
|
source: z.object({
|
||||||
platform: z.enum(['ozon', '1688', 'taobao', 'tmall']),
|
platform: z.enum(['ozon', '1688', 'taobao', 'tmall']),
|
||||||
itemKey: z.string().min(1),
|
itemKey: z.string().min(1),
|
||||||
canonicalUrl: z.url(),
|
canonicalUrl: z.url(),
|
||||||
collectedAt: z.iso.datetime(),
|
collectedAt: z.iso.datetime(),
|
||||||
parserVersion: z.string().min(1),
|
parserVersion: z.string().min(1),
|
||||||
}),
|
}),
|
||||||
common: z.object({
|
common: z.object({
|
||||||
title: z.string().min(1).optional(),
|
title: z.string().min(1).optional(),
|
||||||
}),
|
}),
|
||||||
variantAxes: z.array(z.unknown()),
|
variantAxes: z.array(z.unknown()),
|
||||||
variants: z.array(z.unknown()),
|
variants: z.array(z.unknown()),
|
||||||
assets: z.array(z.unknown()),
|
assets: z.array(z.unknown()),
|
||||||
evidence: z.array(z.unknown()),
|
evidence: z.array(z.unknown()),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type CollectedProductPayload = z.infer<typeof collectedProductPayloadSchema>;
|
export type CollectedProductPayload = z.infer<
|
||||||
|
typeof collectedProductPayloadSchema
|
||||||
|
>;
|
||||||
|
|||||||
@@ -1 +1,2 @@
|
|||||||
export * from './collection';
|
export * from './collection';
|
||||||
|
export * from './auth';
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
{
|
{
|
||||||
"extends": "../../tsconfig.base.json",
|
"extends": "../../tsconfig.base.json",
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"rootDir": "src",
|
"rootDir": "src",
|
||||||
"outDir": "dist"
|
"outDir": "dist"
|
||||||
},
|
},
|
||||||
"include": ["src/**/*.ts"]
|
"include": ["src/**/*.ts"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -1,16 +1,22 @@
|
|||||||
{
|
{
|
||||||
"name": "@atlas/database",
|
"name": "@atlas/database",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"type": "module",
|
||||||
"typecheck": "tsc -p tsconfig.json --noEmit"
|
"main": "./dist/index.js",
|
||||||
},
|
"types": "./dist/index.d.ts",
|
||||||
"dependencies": {
|
"scripts": {
|
||||||
"pg": "8.23.0"
|
"build": "tsc -p tsconfig.json",
|
||||||
},
|
"migrate": "tsx src/migrate.ts",
|
||||||
"devDependencies": {
|
"typecheck": "tsc -p tsconfig.json --noEmit"
|
||||||
"@types/node": "26.3.0",
|
},
|
||||||
"@types/pg": "8.23.1",
|
"dependencies": {
|
||||||
"typescript": "7.0.2"
|
"pg": "8.23.0"
|
||||||
}
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "26.3.0",
|
||||||
|
"@types/pg": "8.23.1",
|
||||||
|
"tsx": "4.23.13",
|
||||||
|
"typescript": "7.0.2"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,2 @@
|
|||||||
/**
|
export { Database, createApplicationDatabase } from './pool.js';
|
||||||
* 数据库访问层的唯一入口。
|
export type { DatabaseTransaction } from './pool.js';
|
||||||
* 后续在此封装迁移连接与应用连接,应用事务必须先设置 app.workspace_id。
|
|
||||||
*/
|
|
||||||
export const databasePackageName = '@atlas/database';
|
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
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',
|
||||||
|
);
|
||||||
|
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 {
|
||||||
|
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) {
|
||||||
|
if (applied.rows[0].checksum !== checksum)
|
||||||
|
throw new Error(
|
||||||
|
`Applied migration was modified: ${filename}`,
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
await client.query('BEGIN');
|
||||||
|
try {
|
||||||
|
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 is NOINHERIT by design; application DML always uses the least-privileged group role.
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Every tenant-scoped query must run through this method. */
|
||||||
|
public async workspaceTransaction<T>(
|
||||||
|
workspaceId: string,
|
||||||
|
work: (transaction: DatabaseTransaction) => Promise<T>,
|
||||||
|
): Promise<T> {
|
||||||
|
return this.transaction(async (transaction) => {
|
||||||
|
// SET LOCAL is scoped to this transaction and cannot leak a tenant id through the pool.
|
||||||
|
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[],
|
||||||
|
) {
|
||||||
|
// Even non-tenant reads use the restricted role; callers cannot accidentally use the login role's privileges.
|
||||||
|
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 }));
|
||||||
|
}
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
{
|
{
|
||||||
"extends": "../../tsconfig.base.json",
|
"extends": "../../tsconfig.base.json",
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"rootDir": "src",
|
"rootDir": "src",
|
||||||
"outDir": "dist"
|
"outDir": "dist"
|
||||||
},
|
},
|
||||||
"include": ["src/**/*.ts"]
|
"include": ["src/**/*.ts"]
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+5685
-4117
File diff suppressed because it is too large
Load Diff
@@ -3,5 +3,6 @@ packages:
|
|||||||
- packages/*
|
- packages/*
|
||||||
|
|
||||||
allowBuilds:
|
allowBuilds:
|
||||||
|
'@scarf/scarf': false
|
||||||
esbuild: true
|
esbuild: true
|
||||||
msgpackr-extract: false
|
msgpackr-extract: false
|
||||||
|
|||||||
@@ -1,5 +0,0 @@
|
|||||||
export default {
|
|
||||||
singleQuote: true,
|
|
||||||
trailingComma: 'all',
|
|
||||||
printWidth: 100,
|
|
||||||
};
|
|
||||||
+12
-12
@@ -1,14 +1,14 @@
|
|||||||
{
|
{
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"target": "ES2023",
|
"target": "ES2023",
|
||||||
"module": "Node16",
|
"module": "Node16",
|
||||||
"moduleResolution": "Node16",
|
"moduleResolution": "Node16",
|
||||||
"strict": true,
|
"strict": true,
|
||||||
"esModuleInterop": true,
|
"esModuleInterop": true,
|
||||||
"forceConsistentCasingInFileNames": true,
|
"forceConsistentCasingInFileNames": true,
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"declaration": true,
|
"declaration": true,
|
||||||
"declarationMap": true,
|
"declarationMap": true,
|
||||||
"sourceMap": true
|
"sourceMap": true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+21
-21
@@ -1,24 +1,24 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://turbo.build/schema.json",
|
"$schema": "https://turbo.build/schema.json",
|
||||||
"tasks": {
|
"tasks": {
|
||||||
"build": {
|
"build": {
|
||||||
"dependsOn": ["^build"],
|
"dependsOn": ["^build"],
|
||||||
"outputs": ["dist/**", ".output/**"]
|
"outputs": ["dist/**", ".output/**"]
|
||||||
},
|
},
|
||||||
"dev": {
|
"dev": {
|
||||||
"cache": false,
|
"cache": false,
|
||||||
"persistent": true
|
"persistent": true
|
||||||
},
|
},
|
||||||
"lint": {
|
"lint": {
|
||||||
"dependsOn": ["^lint"]
|
"dependsOn": ["^lint"]
|
||||||
},
|
},
|
||||||
"typecheck": {
|
"typecheck": {
|
||||||
"dependsOn": ["^typecheck"],
|
"dependsOn": ["^typecheck"],
|
||||||
"outputs": []
|
"outputs": []
|
||||||
},
|
},
|
||||||
"test": {
|
"test": {
|
||||||
"dependsOn": ["^build"],
|
"dependsOn": ["^build"],
|
||||||
"outputs": ["coverage/**"]
|
"outputs": ["coverage/**"]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user