feat(api): 开发auth、login等接口,规范格式化

This commit is contained in:
R524809
2026-09-03 13:11:06 +08:00
parent 195b6d25e9
commit 037928c778
82 changed files with 9199 additions and 5236 deletions
+62 -7
View File
@@ -1,12 +1,67 @@
POSTGRES_USER=atlas_app_login
POSTGRES_PASSWORD=change-me-local-only
# Local PostgreSQL bootstrap administrator. It creates roles but must never be used by API or Worker.
POSTGRES_ADMIN_USER=atlas_admin
POSTGRES_ADMIN_PASSWORD=change-me-local-only
# Database name and host port exposed by local Docker Compose.
POSTGRES_DB=atlas
POSTGRES_PORT=5432
REDIS_URL=redis://localhost:6379
MINIO_ROOT_USER=atlas_minio_local
MINIO_ROOT_PASSWORD=change-me-local-only
MINIO_PORT=9000
MINIO_CONSOLE_PORT=9001
# Password for the schema-owning migrator role; only the migration runner may use it.
ATLAS_MIGRATOR_PASSWORD=change-me-local-only
# Password for the least-privileged API/Worker login role.
ATLAS_APP_PASSWORD=change-me-local-only
# API/Worker application connection. This role has no table ownership and is constrained by RLS.
DATABASE_URL=postgresql://atlas_app_login:change-me-local-only@localhost:5432/atlas
# Migration-only connection. Do not put this value in production API or Worker environments.
MIGRATOR_DATABASE_URL=postgresql://atlas_migrator:change-me-local-only@localhost:5432/atlas
# Runtime mode controls production-only safeguards such as Secure cookies.
NODE_ENV=development
# Browser origin allowed to send credentialed requests to this API.
WEB_ORIGIN=http://localhost:8100
# HTTP listener port for the NestJS API.
API_PORT=8200
# HMAC signing secret for short-lived access JWTs; replace with a unique 32+ character secret.
AUTH_JWT_SECRET=replace-with-a-random-secret-of-at-least-32-characters
# Separate HMAC secret used to pseudonymize IP/email values in Redis rate-limit keys.
AUTH_RATE_LIMIT_KEY_SECRET=replace-with-a-different-random-secret-of-at-least-32-characters
# Server-side minimum password length. The browser must not weaken this rule.
AUTH_PASSWORD_MIN_LENGTH=12
# Maximum JSON or URL-encoded request size in bytes. Media files must use object-storage direct upload.
API_BODY_LIMIT_BYTES=1048576
# Shared fixed-window duration for authentication rate limits, in seconds.
AUTH_RATE_LIMIT_WINDOW_SECONDS=900
# Maximum login attempts per IP inside the window.
AUTH_LOGIN_IP_LIMIT=10
# Maximum login attempts per normalized email inside the window.
AUTH_LOGIN_EMAIL_LIMIT=5
# Maximum registration attempts per IP inside the window.
AUTH_REGISTER_IP_LIMIT=5
# Maximum registration attempts per normalized email inside the window.
AUTH_REGISTER_EMAIL_LIMIT=3
# Maximum refresh attempts per IP inside the window; refresh tokens have no trusted email before validation.
AUTH_REFRESH_IP_LIMIT=30
# Comma-separated trusted reverse-proxy IPs/CIDRs. Keep empty for direct local access; never set to true.
TRUSTED_PROXY_CIDRS=
# Whether to expose Swagger UI at /api-docs.
SWAGGER_ENABLED=true
# Minimum structured-log severity: error, warn, log, or debug.
LOG_LEVEL=log
# Redis endpoint used by BullMQ and shared authentication rate limits.
REDIS_URL=redis://localhost:6379
# Local MinIO root account; it is only for Docker bootstrap, not browser credentials.
MINIO_ROOT_USER=atlas_minio_local
# Local MinIO root password; replace outside disposable local development.
MINIO_ROOT_PASSWORD=change-me-local-only
# S3-compatible API port for local MinIO.
MINIO_PORT=9000
# MinIO administrative console port.
MINIO_CONSOLE_PORT=9001
# Local Vite frontend port.
WEB_PORT=8100
# Local WXT browser-extension development port.
EXTENSION_PORT=8110
+9
View File
@@ -0,0 +1,9 @@
{
"singleQuote": true,
"trailingComma": "all",
"bracketSpacing": true,
"arrowParens": "always",
"printWidth": 80,
"tabWidth": 4,
"useTabs": false
}
+9
View File
@@ -0,0 +1,9 @@
{
"singleQuote": true,
"trailingComma": "all",
"bracketSpacing": true,
"arrowParens": "always",
"printWidth": 80,
"tabWidth": 4,
"useTabs": false
}
+30 -23
View File
@@ -1,25 +1,32 @@
{
"name": "@atlas/api",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"build": "tsc -p tsconfig.json",
"dev": "tsx watch src/main.ts",
"start": "node dist/main.js",
"typecheck": "tsc -p tsconfig.json --noEmit"
},
"dependencies": {
"@atlas/contracts": "workspace:*",
"@nestjs/common": "12.0.1",
"@nestjs/core": "12.0.1",
"@nestjs/platform-express": "12.0.1",
"reflect-metadata": "0.2.2",
"rxjs": "7.8.2"
},
"devDependencies": {
"@types/node": "26.3.0",
"tsx": "4.23.13",
"typescript": "7.0.2"
}
"name": "@atlas/api",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"build": "tsc -p tsconfig.json",
"dev": "tsx watch src/main.ts",
"start": "node dist/main.js",
"typecheck": "tsc -p tsconfig.json --noEmit"
},
"dependencies": {
"@atlas/contracts": "workspace:*",
"@atlas/database": "workspace:*",
"@nestjs/common": "12.0.1",
"@nestjs/config": "12.0.0",
"@nestjs/core": "12.0.1",
"@nestjs/platform-express": "12.0.1",
"@nestjs/swagger": "12.0.1",
"helmet": "8.3.0",
"ioredis": "6.0.0",
"reflect-metadata": "0.2.2",
"rxjs": "7.8.2",
"zod": "4.5.4"
},
"devDependencies": {
"@types/express": "5.0.6",
"@types/node": "26.3.0",
"tsx": "4.23.13",
"typescript": "7.0.2"
}
}
+32 -7
View File
@@ -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 {
@Get()
check() {
return { status: 'ok', service: 'api' };
}
public constructor(@Inject(DATABASE) private readonly database: Database) {}
@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 {}
+206
View File
@@ -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);
}
+91
View File
@@ -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;
}
}
+21
View File
@@ -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;
}
}
+10
View File
@@ -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 {}
+340
View File
@@ -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;
}
+61
View File
@@ -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,
},
});
}
}
+12
View File
@@ -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);
}
}
+124
View File
@@ -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 {}
+24
View File
@@ -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();
}
}
+60
View File
@@ -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
View File
@@ -1,12 +1,151 @@
import 'reflect-metadata';
import { execFile } from 'node:child_process';
import { randomUUID } from 'node:crypto';
import { NestFactory } from '@nestjs/core';
import { NestExpressApplication } from '@nestjs/platform-express';
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
import type { NextFunction, Request, Response } from 'express';
import helmet from 'helmet';
import { AppModule } from './app.module.js';
import { ApiErrorFilter } from './common/api-error.filter.js';
import { APP_CONFIG, type AppConfig } from './config/app-config.module.js';
import { AppLogger } from './logging/app-logger.js';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.enableCors({ origin: process.env.WEB_ORIGIN ?? 'http://localhost:8100' });
await app.listen(Number(process.env.API_PORT ?? 8200));
type RequestWithId = Request & {
requestId?: string;
};
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 };
}
}
+6
View File
@@ -0,0 +1,6 @@
import { Global, Module } from '@nestjs/common';
import { AuthRateLimiter } from './auth-rate-limiter.service.js';
@Global()
@Module({ providers: [AuthRateLimiter], exports: [AuthRateLimiter] })
export class SecurityModule {}
+9 -9
View File
@@ -1,11 +1,11 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "dist",
"types": ["node"],
"experimentalDecorators": true,
"emitDecoratorMetadata": true
},
"include": ["src/**/*.ts"]
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "dist",
"types": ["node"],
"experimentalDecorators": true,
"emitDecoratorMetadata": true
},
"include": ["src/**/*.ts"]
}
+1 -1
View File
@@ -1,3 +1,3 @@
export default defineBackground(() => {
console.info('Atlas collector background started.');
console.info('Atlas collector background started.');
});
+21 -21
View File
@@ -1,23 +1,23 @@
{
"name": "@atlas/extension",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"build": "wxt build",
"dev": "wxt",
"typecheck": "wxt prepare && tsc -p tsconfig.json --noEmit"
},
"dependencies": {
"@atlas/contracts": "workspace:*",
"@wxt-dev/module-react": "1.2.2",
"react": "19.2.8",
"react-dom": "19.2.8"
},
"devDependencies": {
"@types/react": "19.2.18",
"@types/react-dom": "19.2.5",
"typescript": "7.0.2",
"wxt": "0.21.4"
}
"name": "@atlas/extension",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"build": "wxt build",
"dev": "wxt",
"typecheck": "wxt prepare && tsc -p tsconfig.json --noEmit"
},
"dependencies": {
"@atlas/contracts": "workspace:*",
"@wxt-dev/module-react": "1.2.2",
"react": "19.2.8",
"react-dom": "19.2.8"
},
"devDependencies": {
"@types/react": "19.2.18",
"@types/react-dom": "19.2.5",
"typescript": "7.0.2",
"wxt": "0.21.4"
}
}
+6 -6
View File
@@ -1,8 +1,8 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"jsx": "react-jsx",
"noEmit": true
},
"include": ["**/*.ts", "**/*.tsx", ".wxt/**/*.ts"]
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"jsx": "react-jsx",
"noEmit": true
},
"include": ["**/*.ts", "**/*.tsx", ".wxt/**/*.ts"]
}
+20 -20
View File
@@ -1,25 +1,25 @@
import { defineConfig } from 'wxt';
export default defineConfig({
modules: ['@wxt-dev/module-react'],
// 扩展 dev server 固定 8110:默认取 3000–3010 第一个空闲端口,会漂移且易与常见服务冲突
dev: {
server: {
port: Number(process.env.EXTENSION_PORT ?? 8110),
strictPort: true,
modules: ['@wxt-dev/module-react'],
// 扩展 dev server 固定 8110:默认取 3000–3010 第一个空闲端口,会漂移且易与常见服务冲突
dev: {
server: {
port: Number(process.env.EXTENSION_PORT ?? 8110),
strictPort: true,
},
},
manifest: {
name: 'Atlas Seller Kit Collector',
description: '将已确认的来源商品采集到 Atlas 工作空间。',
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/*',
],
},
});
+9 -9
View File
@@ -1,12 +1,12 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Atlas Seller Kit</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Atlas Seller Kit</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+25 -25
View File
@@ -1,27 +1,27 @@
{
"name": "@atlas/web",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"build": "tsc -b && vite build",
"dev": "vite",
"typecheck": "tsc -b --pretty false"
},
"dependencies": {
"@ant-design/icons": "6.3.4",
"antd": "6.6.2",
"axios": "1.20.0",
"react": "19.2.8",
"react-dom": "19.2.8",
"react-router-dom": "7.18.3"
},
"devDependencies": {
"@types/node": "26.3.0",
"@types/react": "19.2.18",
"@types/react-dom": "19.2.5",
"@vitejs/plugin-react": "6.1.1",
"typescript": "7.0.2",
"vite": "8.2.2"
}
"name": "@atlas/web",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"build": "tsc -b && vite build",
"dev": "vite",
"typecheck": "tsc -b --pretty false"
},
"dependencies": {
"@ant-design/icons": "6.3.4",
"antd": "6.6.2",
"axios": "1.20.0",
"react": "19.2.8",
"react-dom": "19.2.8",
"react-router-dom": "7.18.3"
},
"devDependencies": {
"@types/node": "26.3.0",
"@types/react": "19.2.18",
"@types/react-dom": "19.2.5",
"@vitejs/plugin-react": "6.1.1",
"typescript": "7.0.2",
"vite": "8.2.2"
}
}
+19
View File
@@ -0,0 +1,19 @@
/**
* @see https://prettier.io/docs/en/configuration.html
* @type {import("prettier").Config}
*/
export default {
printWidth: 100,
tabWidth: 4,
useTabs: false,
semi: true,
singleQuote: true,
quoteProps: 'as-needed',
jsxSingleQuote: false,
trailingComma: 'es5',
bracketSpacing: true,
jsxBracketSameLine: false,
arrowParens: 'always',
endOfLine: 'lf',
embeddedLanguageFormatting: 'auto',
};
+19 -19
View File
@@ -5,25 +5,25 @@ import { router } from './router';
import './App.css';
function App() {
return (
<ConfigProvider
locale={zhCN}
theme={{
token: {
colorPrimary: '#8b5cf6',
borderRadius: 8,
// 全局默认字号 14px;输入类控件默认高度在 32px 基础上同步加高一点
fontSize: 14,
controlHeight: 34,
controlHeightSM: 26,
},
}}
>
<AntdApp>
<RouterProvider router={router} />
</AntdApp>
</ConfigProvider>
);
return (
<ConfigProvider
locale={zhCN}
theme={{
token: {
colorPrimary: '#8b5cf6',
borderRadius: 8,
// 全局默认字号 14px;输入类控件默认高度在 32px 基础上同步加高一点
fontSize: 14,
controlHeight: 34,
controlHeightSM: 26,
},
}}
>
<AntdApp>
<RouterProvider router={router} />
</AntdApp>
</ConfigProvider>
);
}
export default App;
+13 -13
View File
@@ -1,23 +1,23 @@
import { Card, Empty } from 'antd';
interface PagePlaceholderProps {
title: string;
description: string;
title: string;
description: string;
}
/** 业务模块实现前的统一占位页 */
const PagePlaceholder = ({ title, description }: PagePlaceholderProps) => (
<Card>
<Empty
image={Empty.PRESENTED_IMAGE_SIMPLE}
description={
<>
<div>{title}</div>
<div>{description}</div>
</>
}
/>
</Card>
<Card>
<Empty
image={Empty.PRESENTED_IMAGE_SIMPLE}
description={
<>
<div>{title}</div>
<div>{description}</div>
</>
}
/>
</Card>
);
export default PagePlaceholder;
+7 -7
View File
@@ -3,14 +3,14 @@
*/
interface EnvConfig {
/** API 基础路径:开发环境由 vite 代理转发到 NestJS API */
apiBaseUrl: string;
appName: string;
debug: boolean;
/** API 基础路径:开发环境由 vite 代理转发到 NestJS API */
apiBaseUrl: string;
appName: string;
debug: boolean;
}
export const envConfig: EnvConfig = {
apiBaseUrl: import.meta.env.VITE_API_BASE_URL ?? '/api',
appName: import.meta.env.VITE_APP_NAME ?? 'Atlas Seller Kit',
debug: import.meta.env.VITE_DEBUG === 'true',
apiBaseUrl: import.meta.env.VITE_API_BASE_URL ?? '/api',
appName: import.meta.env.VITE_APP_NAME ?? 'Atlas Seller Kit',
debug: import.meta.env.VITE_DEBUG === 'true',
};
+26 -26
View File
@@ -1,37 +1,37 @@
:root {
--primary-color: #8b5cf6;
--primary-light: #a78bfa;
--primary-dark: #7c3aed;
/* 侧边菜单背景(纯色,中间调;想再调直接改这里) */
--sider-bg: #6d28d9;
--text-primary: #1f2937;
--text-secondary: #6b7280;
--bg-color: #f9fafb;
--card-bg: #ffffff;
--border-color: #e5e7eb;
--primary-color: #8b5cf6;
--primary-light: #a78bfa;
--primary-dark: #7c3aed;
/* 侧边菜单背景(纯色,中间调;想再调直接改这里) */
--sider-bg: #6d28d9;
--text-primary: #1f2937;
--text-secondary: #6b7280;
--bg-color: #f9fafb;
--card-bg: #ffffff;
--border-color: #e5e7eb;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
margin: 0;
font-family:
-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Hiragino Sans GB',
'Microsoft YaHei', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans',
'Helvetica Neue', sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
background-color: var(--bg-color);
color: var(--text-primary);
/* 全局默认字号 14px,与 antd 主题 token 的 fontSize 保持一致 */
font-size: 14px;
line-height: 1.6;
margin: 0;
font-family:
-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC',
'Hiragino Sans GB', 'Microsoft YaHei', 'Roboto', 'Oxygen', 'Ubuntu',
'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
background-color: var(--bg-color);
color: var(--text-primary);
/* 全局默认字号 14px,与 antd 主题 token 的 fontSize 保持一致 */
font-size: 14px;
line-height: 1.6;
}
#root {
min-height: 100vh;
min-height: 100vh;
}
+47 -39
View File
@@ -1,71 +1,79 @@
.auth-page {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
background:
radial-gradient(1000px 500px at 15% -10%, rgba(139, 92, 246, 0.14), transparent 60%),
radial-gradient(900px 480px at 95% 110%, rgba(109, 40, 217, 0.1), transparent 55%),
var(--bg-color);
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
background:
radial-gradient(
1000px 500px at 15% -10%,
rgba(139, 92, 246, 0.14),
transparent 60%
),
radial-gradient(
900px 480px at 95% 110%,
rgba(109, 40, 217, 0.1),
transparent 55%
),
var(--bg-color);
}
.auth-panel {
width: 100%;
max-width: 400px;
width: 100%;
max-width: 400px;
}
.auth-brand {
text-align: center;
margin-bottom: 24px;
text-align: center;
margin-bottom: 24px;
}
.auth-logo {
font-size: 26px;
font-weight: 700;
color: var(--primary-dark);
font-size: 26px;
font-weight: 700;
color: var(--primary-dark);
}
.auth-logo-subtitle {
font-size: 13px;
color: var(--text-secondary);
margin-top: 2px;
font-size: 13px;
color: var(--text-secondary);
margin-top: 2px;
}
.auth-card {
background: var(--card-bg);
border: 1px solid var(--border-color);
border-radius: 12px;
box-shadow: 0 10px 30px rgba(17, 24, 39, 0.06);
padding: 32px 28px;
background: var(--card-bg);
border: 1px solid var(--border-color);
border-radius: 12px;
box-shadow: 0 10px 30px rgba(17, 24, 39, 0.06);
padding: 32px 28px;
}
.auth-title {
font-size: 20px;
font-weight: 600;
color: var(--text-primary);
font-size: 20px;
font-weight: 600;
color: var(--text-primary);
}
.auth-subtitle {
font-size: 14px;
color: var(--text-secondary);
margin: 4px 0 20px;
font-size: 14px;
color: var(--text-secondary);
margin: 4px 0 20px;
}
.auth-footer {
margin-top: 16px;
text-align: center;
font-size: 14px;
color: var(--text-secondary);
margin-top: 16px;
text-align: center;
font-size: 14px;
color: var(--text-secondary);
}
.auth-footer a {
color: var(--primary-color);
font-weight: 500;
color: var(--primary-color);
font-weight: 500;
}
@media (max-width: 768px) {
.auth-card {
padding: 24px 20px;
}
.auth-card {
padding: 24px 20px;
}
}
+25 -25
View File
@@ -3,37 +3,37 @@ import type { ReactNode } from 'react';
import './AuthLayout.css';
interface AuthLayoutProps {
/** 页面标题,如「登录」 */
title: string;
/** 标题下的一行说明 */
subtitle: string;
/** 卡片底部的切换入口 */
footer: ReactNode;
children: ReactNode;
/** 页面标题,如「登录」 */
title: string;
/** 标题下的一行说明 */
subtitle: string;
/** 卡片底部的切换入口 */
footer: ReactNode;
children: ReactNode;
}
/** 注册/登录共用的居中卡片布局,品牌视觉与 MainLayout 一致 */
const AuthLayout = ({ title, subtitle, footer, children }: AuthLayoutProps) => {
useEffect(() => {
document.title = `${title} - Atlas Seller Kit`;
}, [title]);
useEffect(() => {
document.title = `${title} - Atlas Seller Kit`;
}, [title]);
return (
<div className="auth-page">
<div className="auth-panel">
<div className="auth-brand">
<div className="auth-logo">Atlas Kit</div>
<div className="auth-logo-subtitle">商品工作台</div>
return (
<div className="auth-page">
<div className="auth-panel">
<div className="auth-brand">
<div className="auth-logo">Atlas Kit</div>
<div className="auth-logo-subtitle">商品工作台</div>
</div>
<div className="auth-card">
<div className="auth-title">{title}</div>
<div className="auth-subtitle">{subtitle}</div>
{children}
<div className="auth-footer">{footer}</div>
</div>
</div>
</div>
<div className="auth-card">
<div className="auth-title">{title}</div>
<div className="auth-subtitle">{subtitle}</div>
{children}
<div className="auth-footer">{footer}</div>
</div>
</div>
</div>
);
);
};
export default AuthLayout;
+69 -69
View File
@@ -1,115 +1,115 @@
.main-layout {
min-height: 100vh;
min-height: 100vh;
}
.main-sider {
background: var(--sider-bg) !important;
display: flex;
flex-direction: column;
background: var(--sider-bg) !important;
display: flex;
flex-direction: column;
}
.sidebar-header {
padding: 24px 20px;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
text-align: center;
padding: 24px 20px;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
text-align: center;
}
.logo {
font-size: 22px;
font-weight: 700;
color: white;
margin-bottom: 4px;
font-size: 22px;
font-weight: 700;
color: white;
margin-bottom: 4px;
}
.logo-subtitle {
font-size: 12px;
color: rgba(255, 255, 255, 0.8);
font-size: 12px;
color: rgba(255, 255, 255, 0.8);
}
.main-header {
background: white !important;
padding: 0 24px !important;
display: flex;
align-items: center;
justify-content: space-between;
box-shadow:
0 1px 3px 0 rgba(0, 0, 0, 0.1),
0 1px 2px 0 rgba(0, 0, 0, 0.06);
position: sticky;
top: 0;
z-index: 100;
height: 64px;
line-height: 1;
background: white !important;
padding: 0 24px !important;
display: flex;
align-items: center;
justify-content: space-between;
box-shadow:
0 1px 3px 0 rgba(0, 0, 0, 0.1),
0 1px 2px 0 rgba(0, 0, 0, 0.06);
position: sticky;
top: 0;
z-index: 100;
height: 64px;
line-height: 1;
}
.header-left {
display: flex;
align-items: center;
gap: 16px;
display: flex;
align-items: center;
gap: 16px;
}
.collapse-trigger,
.mobile-menu-trigger {
font-size: 18px;
cursor: pointer;
color: #1f2937;
border-radius: 4px;
transition: background 0.2s;
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
flex-shrink: 0;
font-size: 18px;
cursor: pointer;
color: #1f2937;
border-radius: 4px;
transition: background 0.2s;
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
flex-shrink: 0;
}
.collapse-trigger:hover,
.mobile-menu-trigger:hover {
background: #f9fafb;
background: #f9fafb;
}
.page-title-group {
display: flex;
flex-direction: column;
justify-content: center;
line-height: 1.4;
display: flex;
flex-direction: column;
justify-content: center;
line-height: 1.4;
}
.page-title {
font-size: 20px;
font-weight: 600;
color: #1f2937;
line-height: 1.2;
margin-bottom: 4px;
font-size: 20px;
font-weight: 600;
color: #1f2937;
line-height: 1.2;
margin-bottom: 4px;
}
.page-subtitle {
font-size: 14px;
color: #6b7280;
font-style: italic;
line-height: 1.2;
font-size: 14px;
color: #6b7280;
font-style: italic;
line-height: 1.2;
}
.main-content {
padding: 24px;
background: #f3f4f6;
min-height: calc(100vh - 64px);
padding: 24px;
background: #f3f4f6;
min-height: calc(100vh - 64px);
}
@media (max-width: 768px) {
.main-content {
padding: 16px;
}
.main-content {
padding: 16px;
}
.main-header {
padding: 0 16px !important;
}
.main-header {
padding: 0 16px !important;
}
.page-title {
font-size: 18px;
}
.page-title {
font-size: 18px;
}
.page-subtitle {
font-size: 12px;
}
.page-subtitle {
font-size: 12px;
}
}
+104 -83
View File
@@ -9,95 +9,116 @@ import './MainLayout.css';
const { Header, Sider, Content } = Layout;
const MainLayout = () => {
const [collapsed, setCollapsed] = useState(false);
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
const [isMobile, setIsMobile] = useState(false);
const location = useLocation();
const [collapsed, setCollapsed] = useState(false);
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
const [isMobile, setIsMobile] = useState(false);
const location = useLocation();
const pageInfo = useMemo(() => getPageInfo(location.pathname), [location.pathname]);
const pageInfo = useMemo(
() => getPageInfo(location.pathname),
[location.pathname],
);
useEffect(() => {
const checkMobile = () => {
setIsMobile(window.innerWidth < 768);
if (window.innerWidth >= 768) setMobileMenuOpen(false);
};
checkMobile();
window.addEventListener('resize', checkMobile);
return () => window.removeEventListener('resize', checkMobile);
}, []);
useEffect(() => {
const checkMobile = () => {
setIsMobile(window.innerWidth < 768);
if (window.innerWidth >= 768) setMobileMenuOpen(false);
};
checkMobile();
window.addEventListener('resize', checkMobile);
return () => window.removeEventListener('resize', checkMobile);
}, []);
return (
<Layout className="main-layout">
{!isMobile && (
<Sider
className="main-sider"
width={200}
collapsedWidth={60}
collapsed={collapsed}
theme="dark"
style={{
overflow: 'auto',
height: '100vh',
position: 'fixed',
left: 0,
top: 0,
bottom: 0,
}}
>
<div className="sidebar-header">
<div className="logo">{collapsed ? 'A' : 'Atlas Kit'}</div>
{!collapsed && <div className="logo-subtitle">商品工作台</div>}
</div>
<SidebarMenu collapsed={collapsed} />
</Sider>
)}
{isMobile && (
<Drawer
title="Atlas Kit"
placement="left"
onClose={() => setMobileMenuOpen(false)}
open={mobileMenuOpen}
styles={{ body: { padding: 0, background: 'var(--sider-bg)' } }}
width={200}
>
<SidebarMenu collapsed={false} onMenuClick={() => setMobileMenuOpen(false)} />
</Drawer>
)}
<Layout
className="main-content-layout"
style={{
marginLeft: isMobile ? 0 : collapsed ? 60 : 200,
transition: 'margin-left 0.2s',
}}
>
<Header className="main-header">
<div className="header-left">
{isMobile && (
<MenuFoldOutlined
className="mobile-menu-trigger"
onClick={() => setMobileMenuOpen(true)}
/>
)}
return (
<Layout className="main-layout">
{!isMobile && (
<div className="collapse-trigger" onClick={() => setCollapsed(!collapsed)}>
{collapsed ? <MenuUnfoldOutlined /> : <MenuFoldOutlined />}
</div>
<Sider
className="main-sider"
width={200}
collapsedWidth={60}
collapsed={collapsed}
theme="dark"
style={{
overflow: 'auto',
height: '100vh',
position: 'fixed',
left: 0,
top: 0,
bottom: 0,
}}
>
<div className="sidebar-header">
<div className="logo">
{collapsed ? 'A' : 'Atlas Kit'}
</div>
{!collapsed && (
<div className="logo-subtitle">商品工作台</div>
)}
</div>
<SidebarMenu collapsed={collapsed} />
</Sider>
)}
<div className="page-title-group">
<div className="page-title">{pageInfo.title}</div>
<div className="page-subtitle">{pageInfo.subtitle}</div>
</div>
</div>
</Header>
<Content className="main-content">
<Outlet />
</Content>
</Layout>
</Layout>
);
{isMobile && (
<Drawer
title="Atlas Kit"
placement="left"
onClose={() => setMobileMenuOpen(false)}
open={mobileMenuOpen}
styles={{
body: { padding: 0, background: 'var(--sider-bg)' },
}}
width={200}
>
<SidebarMenu
collapsed={false}
onMenuClick={() => setMobileMenuOpen(false)}
/>
</Drawer>
)}
<Layout
className="main-content-layout"
style={{
marginLeft: isMobile ? 0 : collapsed ? 60 : 200,
transition: 'margin-left 0.2s',
}}
>
<Header className="main-header">
<div className="header-left">
{isMobile && (
<MenuFoldOutlined
className="mobile-menu-trigger"
onClick={() => setMobileMenuOpen(true)}
/>
)}
{!isMobile && (
<div
className="collapse-trigger"
onClick={() => setCollapsed(!collapsed)}
>
{collapsed ? (
<MenuUnfoldOutlined />
) : (
<MenuFoldOutlined />
)}
</div>
)}
<div className="page-title-group">
<div className="page-title">{pageInfo.title}</div>
<div className="page-subtitle">
{pageInfo.subtitle}
</div>
</div>
</div>
</Header>
<Content className="main-content">
<Outlet />
</Content>
</Layout>
</Layout>
);
};
export default MainLayout;
+65 -59
View File
@@ -1,134 +1,140 @@
.sidebar-menu-container {
flex: 1;
overflow: hidden;
display: flex;
flex-direction: column;
flex: 1;
overflow: hidden;
display: flex;
flex-direction: column;
}
.sidebar-menu {
background: transparent !important;
border: none;
height: 100%;
overflow-y: auto;
overflow-x: hidden;
background: transparent !important;
border: none;
height: 100%;
overflow-y: auto;
overflow-x: hidden;
}
.sidebar-menu .ant-menu-item {
margin: 0 !important;
padding: 12px 20px !important;
height: auto !important;
line-height: 1.5 !important;
transition: all 0.3s !important;
font-size: 15px !important;
font-weight: 600 !important;
margin: 0 !important;
padding: 12px 20px !important;
height: auto !important;
line-height: 1.5 !important;
transition: all 0.3s !important;
font-size: 15px !important;
font-weight: 600 !important;
}
.sidebar-menu .ant-menu-item .ant-menu-title-content {
font-size: 15px !important;
font-weight: 600 !important;
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
display: inline-block;
font-size: 15px !important;
font-weight: 600 !important;
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
display: inline-block;
}
.sidebar-menu .ant-menu-item:hover {
background: rgba(255, 255, 255, 0.1) !important;
background: rgba(255, 255, 255, 0.1) !important;
}
.sidebar-menu .ant-menu-item-selected {
background: rgba(255, 255, 255, 0.2) !important;
border-left: 3px solid white;
background: rgba(255, 255, 255, 0.2) !important;
border-left: 3px solid white;
}
.sidebar-menu .ant-menu-item-selected .ant-menu-title-content {
transform: translateX(8px);
transform: translateX(8px);
}
.sidebar-menu .ant-menu-item-selected:hover {
background: rgba(255, 255, 255, 0.25) !important;
background: rgba(255, 255, 255, 0.25) !important;
}
.sidebar-menu .ant-menu-item-selected:hover .ant-menu-title-content {
transform: translateX(8px);
transform: translateX(8px);
}
.sidebar-menu .ant-menu-item-group-title {
padding: 12px 20px 8px !important;
font-size: 12px !important;
color: rgba(255, 255, 255, 0.7) !important;
text-transform: uppercase;
letter-spacing: 1px;
font-weight: 500;
padding: 12px 20px 8px !important;
font-size: 12px !important;
color: rgba(255, 255, 255, 0.7) !important;
text-transform: uppercase;
letter-spacing: 1px;
font-weight: 500;
}
.sidebar-menu .ant-menu-item-icon {
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
font-size: 18px !important;
display: inline-flex;
align-items: center;
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
font-size: 18px !important;
display: inline-flex;
align-items: center;
}
.sidebar-menu .ant-menu-item:hover .ant-menu-item-icon {
transform: scale(1.05);
transform: scale(1.05);
}
.sidebar-menu .ant-menu-item-selected .ant-menu-item-icon {
transform: translateX(8px);
transform: translateX(8px);
}
@media (max-width: 768px) {
.sidebar-menu .ant-menu-item {
padding: 14px 20px !important;
}
.sidebar-menu .ant-menu-item {
padding: 14px 20px !important;
}
}
/* ── 折叠态(60px):图标居中,覆盖展开态的左对齐 padding/位移 ── */
.sidebar-menu.ant-menu-inline-collapsed .ant-menu-item,
.sidebar-menu.ant-menu-inline-collapsed .ant-menu-item-group-title {
padding: 12px 0 !important;
text-align: center;
padding: 12px 0 !important;
text-align: center;
}
.sidebar-menu.ant-menu-inline-collapsed .ant-menu-item {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
display: flex;
align-items: center;
justify-content: center;
width: 100%;
}
.sidebar-menu.ant-menu-inline-collapsed .ant-menu-item .ant-menu-title-content,
.sidebar-menu.ant-menu-inline-collapsed .ant-menu-item-selected .ant-menu-title-content {
transform: none !important;
.sidebar-menu.ant-menu-inline-collapsed
.ant-menu-item-selected
.ant-menu-title-content {
transform: none !important;
}
.sidebar-menu.ant-menu-inline-collapsed .ant-menu-item .ant-menu-item-icon,
.sidebar-menu.ant-menu-inline-collapsed .ant-menu-item:hover .ant-menu-item-icon,
.sidebar-menu.ant-menu-inline-collapsed .ant-menu-item-selected .ant-menu-item-icon {
transform: none !important;
margin: 0;
.sidebar-menu.ant-menu-inline-collapsed
.ant-menu-item:hover
.ant-menu-item-icon,
.sidebar-menu.ant-menu-inline-collapsed
.ant-menu-item-selected
.ant-menu-item-icon {
transform: none !important;
margin: 0;
}
/* 折叠时去掉左侧 3px 选中边框(会挤偏图标),选中态用高亮背景区分 */
.sidebar-menu.ant-menu-inline-collapsed .ant-menu-item-selected {
border-left: none !important;
border-left: none !important;
}
/* 折叠时隐藏 label 占位(antd 折叠动画保留 opacity 占位,会把图标挤离中心) */
.sidebar-menu.ant-menu-inline-collapsed .ant-menu-item .ant-menu-title-content {
display: none !important;
display: none !important;
}
/* 兼容:部分版本折叠态用 vertical 类渲染,同样居中 */
.sidebar-menu.ant-menu-vertical .ant-menu-item,
.sidebar-menu.ant-menu-inline-collapsed .ant-menu-item {
padding-left: 0 !important;
padding-right: 0 !important;
padding-left: 0 !important;
padding-right: 0 !important;
}
.sidebar-menu.ant-menu-vertical .ant-menu-item .ant-menu-title-content {
display: none !important;
display: none !important;
}
/* 折叠态 flex 子元素居中(display:flex 已生效,缺 justify-content) */
.sidebar-menu.ant-menu-inline-collapsed .ant-menu-item {
justify-content: center !important;
justify-content: center !important;
}
+46 -44
View File
@@ -6,57 +6,59 @@ import { routeMenuConfig } from './menuConfig';
import './SidebarMenu.css';
interface SidebarMenuProps {
collapsed: boolean;
onMenuClick?: () => void;
collapsed: boolean;
onMenuClick?: () => void;
}
const SidebarMenu = ({ collapsed, onMenuClick }: SidebarMenuProps) => {
const location = useLocation();
const navigate = useNavigate();
const location = useLocation();
const navigate = useNavigate();
const menuItems: MenuProps['items'] = useMemo(() => {
const children = routeMenuConfig.map((config) => ({
key: config.key,
icon: config.icon,
label: config.label,
}));
return [
{
type: 'group' as const,
label: collapsed ? '' : '主要功能',
className: 'menu-section-title',
children,
},
];
}, [collapsed]);
const menuItems: MenuProps['items'] = useMemo(() => {
const children = routeMenuConfig.map((config) => ({
key: config.key,
icon: config.icon,
label: config.label,
}));
return [
{
type: 'group' as const,
label: collapsed ? '' : '主要功能',
className: 'menu-section-title',
children,
},
];
}, [collapsed]);
const selectedKeys = useMemo(() => {
const path = location.pathname;
if (path === '/' || path === '') {
return [routeMenuConfig[0].key];
}
const config = routeMenuConfig.find((item) => path.startsWith(item.path));
return [config ? config.key : path];
}, [location.pathname]);
const selectedKeys = useMemo(() => {
const path = location.pathname;
if (path === '/' || path === '') {
return [routeMenuConfig[0].key];
}
const config = routeMenuConfig.find((item) =>
path.startsWith(item.path),
);
return [config ? config.key : path];
}, [location.pathname]);
const handleMenuClick: MenuProps['onClick'] = ({ key }) => {
navigate(key);
if (onMenuClick) onMenuClick();
};
const handleMenuClick: MenuProps['onClick'] = ({ key }) => {
navigate(key);
if (onMenuClick) onMenuClick();
};
return (
<div className="sidebar-menu-container">
<Menu
theme="dark"
mode="inline"
inlineCollapsed={collapsed}
selectedKeys={selectedKeys}
items={menuItems}
onClick={handleMenuClick}
className="sidebar-menu"
/>
</div>
);
return (
<div className="sidebar-menu-container">
<Menu
theme="dark"
mode="inline"
inlineCollapsed={collapsed}
selectedKeys={selectedKeys}
items={menuItems}
onClick={handleMenuClick}
className="sidebar-menu"
/>
</div>
);
};
export default SidebarMenu;
+74 -67
View File
@@ -1,80 +1,87 @@
import {
CalculatorOutlined,
GlobalOutlined,
InboxOutlined,
PictureOutlined,
ShopOutlined,
CalculatorOutlined,
GlobalOutlined,
InboxOutlined,
PictureOutlined,
ShopOutlined,
} from '@ant-design/icons';
import type { ReactNode } from 'react';
export interface RouteMenuConfig {
/** 路由路径 */
path: string;
/** 菜单键值(与 path 相同) */
key: string;
/** 菜单图标 */
icon: ReactNode;
/** 菜单标签 */
label: string;
/** 页面标题 */
title: string;
/** 页面副标题 */
subtitle: string;
/** 路由路径 */
path: string;
/** 菜单键值(与 path 相同) */
key: string;
/** 菜单图标 */
icon: ReactNode;
/** 菜单标签 */
label: string;
/** 页面标题 */
title: string;
/** 页面副标题 */
subtitle: string;
}
/** 左侧一级菜单,顺序与 docs/requirements/account-store-watermark.md 的导航约定一致 */
export const routeMenuConfig: RouteMenuConfig[] = [
{
path: '/online-products',
key: '/online-products',
icon: <GlobalOutlined />,
label: '在线商品',
title: '在线商品',
subtitle: '按 SKU 查看平台已上架商品与同步状态',
},
{
path: '/collection',
key: '/collection',
icon: <InboxOutlined />,
label: '采集箱',
title: '采集箱',
subtitle: '查看已采集的商品,进入编辑上架',
},
{
path: '/shops',
key: '/shops',
icon: <ShopOutlined />,
label: '店铺管理',
title: '店铺管理',
subtitle: '绑定平台店铺授权与水印',
},
{
path: '/watermarks',
key: '/watermarks',
icon: <PictureOutlined />,
label: '水印管理',
title: '水印管理',
subtitle: '维护水印模板并绑定到店铺',
},
{
path: '/pricing',
key: '/pricing',
icon: <CalculatorOutlined />,
label: '定价工具',
title: '定价工具',
subtitle: '独立试算与物流计价规则',
},
{
path: '/online-products',
key: '/online-products',
icon: <GlobalOutlined />,
label: '在线商品',
title: '在线商品',
subtitle: '按 SKU 查看平台已上架商品与同步状态',
},
{
path: '/collection',
key: '/collection',
icon: <InboxOutlined />,
label: '采集箱',
title: '采集箱',
subtitle: '查看已采集的商品,进入编辑上架',
},
{
path: '/shops',
key: '/shops',
icon: <ShopOutlined />,
label: '店铺管理',
title: '店铺管理',
subtitle: '绑定平台店铺授权与水印',
},
{
path: '/watermarks',
key: '/watermarks',
icon: <PictureOutlined />,
label: '水印管理',
title: '水印管理',
subtitle: '维护水印模板并绑定到店铺',
},
{
path: '/pricing',
key: '/pricing',
icon: <CalculatorOutlined />,
label: '定价工具',
title: '定价工具',
subtitle: '独立试算与物流计价规则',
},
];
/** 根据路径获取页面标题信息 */
export const getPageInfo = (path: string): { title: string; subtitle: string } => {
if (path === '/' || path === '') {
const first = routeMenuConfig[0];
return { title: first.title, subtitle: first.subtitle };
}
if (path.startsWith('/products/')) {
return { title: '商品编辑', subtitle: '商品 AI 编辑页:SPU/SKU、媒体、定价与发布' };
}
const config = routeMenuConfig.find((item) => path.startsWith(item.path));
return config ? { title: config.title, subtitle: config.subtitle } : getPageInfo('/');
export const getPageInfo = (
path: string,
): { title: string; subtitle: string } => {
if (path === '/' || path === '') {
const first = routeMenuConfig[0];
return { title: first.title, subtitle: first.subtitle };
}
if (path.startsWith('/products/')) {
return {
title: '商品编辑',
subtitle: '商品 AI 编辑页:SPU/SKU、媒体、定价与发布',
};
}
const config = routeMenuConfig.find((item) => path.startsWith(item.path));
return config
? { title: config.title, subtitle: config.subtitle }
: getPageInfo('/');
};
+3 -3
View File
@@ -4,7 +4,7 @@ import './index.css';
import App from './App';
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
<StrictMode>
<App />
</StrictMode>,
);
+87 -63
View File
@@ -10,76 +10,100 @@ import { authToken } from '@/services/authToken';
import { emailFormatRule } from '@/utils/validators';
interface LoginFormValues {
email: string;
password: string;
email: string;
password: string;
}
/** 登录页,契约见 docs/api/auth.md §3 */
const LoginPage = () => {
const navigate = useNavigate();
const { message } = AntdApp.useApp();
const [submitting, setSubmitting] = useState(false);
const navigate = useNavigate();
const { message } = AntdApp.useApp();
const [submitting, setSubmitting] = useState(false);
const handleFinish = async (values: LoginFormValues) => {
setSubmitting(true);
try {
const session = await authApi.login({
email: values.email.trim(),
password: values.password,
});
authToken.set(session.access_token);
message.success('登录成功');
navigate('/online-products', { replace: true });
} catch (error) {
// 契约约定登录失败统一返回 401 INVALID_CREDENTIALS,不区分邮箱不存在或密码错误
if (axios.isAxiosError(error) && error.response?.status === 401) {
message.error('邮箱或密码不正确');
} else {
message.error(apiErrorMessage(error));
}
} finally {
setSubmitting(false);
}
};
const handleFinish = async (values: LoginFormValues) => {
setSubmitting(true);
try {
const session = await authApi.login({
email: values.email.trim(),
password: values.password,
});
authToken.set(session.access_token);
message.success('登录成功');
navigate('/online-products', { replace: true });
} catch (error) {
// 契约约定登录失败统一返回 401 INVALID_CREDENTIALS,不区分邮箱不存在或密码错误
if (axios.isAxiosError(error) && error.response?.status === 401) {
message.error('邮箱或密码不正确');
} else {
message.error(apiErrorMessage(error));
}
} finally {
setSubmitting(false);
}
};
return (
<AuthLayout
title="登录"
subtitle="登录 Atlas Seller Kit,继续管理你的商品"
footer={
<>
还没有账户?<Link to="/register">立即注册</Link>
</>
}
>
<Form<LoginFormValues> layout="vertical" requiredMark={false} onFinish={handleFinish}>
<Form.Item
name="email"
label="邮箱"
rules={[{ required: true, message: '请输入邮箱' }, emailFormatRule]}
return (
<AuthLayout
title="登录"
subtitle="登录 Atlas Seller Kit,继续管理你的商品"
footer={
<>
还没有账户?<Link to="/register">立即注册</Link>
</>
}
>
<Input
prefix={<MailOutlined style={{ color: 'var(--text-secondary)' }} />}
placeholder="请输入邮箱"
autoComplete="email"
autoFocus
/>
</Form.Item>
<Form.Item name="password" label="密码" rules={[{ required: true, message: '请输入密码' }]}>
<Input.Password
prefix={<LockOutlined style={{ color: 'var(--text-secondary)' }} />}
placeholder="请输入密码"
autoComplete="current-password"
/>
</Form.Item>
<Form.Item style={{ marginBottom: 0, marginTop: 8 }}>
<Button type="primary" htmlType="submit" block loading={submitting}>
登录
</Button>
</Form.Item>
</Form>
</AuthLayout>
);
<Form<LoginFormValues>
layout="vertical"
requiredMark={false}
onFinish={handleFinish}
>
<Form.Item
name="email"
label="邮箱"
rules={[
{ required: true, message: '请输入邮箱' },
emailFormatRule,
]}
>
<Input
prefix={
<MailOutlined
style={{ color: 'var(--text-secondary)' }}
/>
}
placeholder="请输入邮箱"
autoComplete="email"
autoFocus
/>
</Form.Item>
<Form.Item
name="password"
label="密码"
rules={[{ required: true, message: '请输入密码' }]}
>
<Input.Password
prefix={
<LockOutlined
style={{ color: 'var(--text-secondary)' }}
/>
}
placeholder="请输入密码"
autoComplete="current-password"
/>
</Form.Item>
<Form.Item style={{ marginBottom: 0, marginTop: 8 }}>
<Button
type="primary"
htmlType="submit"
block
loading={submitting}
>
登录
</Button>
</Form.Item>
</Form>
</AuthLayout>
);
};
export default LoginPage;
+144 -113
View File
@@ -10,126 +10,157 @@ import { authToken } from '@/services/authToken';
import { emailFormatRule } from '@/utils/validators';
interface RegisterFormValues {
display_name?: string;
email: string;
password: string;
password_confirmation: string;
display_name?: string;
email: string;
password: string;
password_confirmation: string;
}
/** 注册页,契约见 docs/api/auth.md §2;密码长度与复杂度由服务端唯一校验,前端不自行放宽或收窄 */
const RegisterPage = () => {
const navigate = useNavigate();
const { message } = AntdApp.useApp();
const [form] = Form.useForm<RegisterFormValues>();
const [submitting, setSubmitting] = useState(false);
const navigate = useNavigate();
const { message } = AntdApp.useApp();
const [form] = Form.useForm<RegisterFormValues>();
const [submitting, setSubmitting] = useState(false);
const handleFinish = async (values: RegisterFormValues) => {
setSubmitting(true);
try {
const session = await authApi.register({
email: values.email.trim(),
password: values.password,
password_confirmation: values.password_confirmation,
display_name: values.display_name?.trim() || undefined,
});
// 注册成功即在同一事务创建用户、个人工作空间和登录会话
authToken.set(session.access_token);
message.success('注册成功,已进入你的工作空间');
navigate('/online-products', { replace: true });
} catch (error) {
// 重复邮箱返回 409 EMAIL_ALREADY_REGISTERED,落到邮箱字段上提示
if (axios.isAxiosError(error) && error.response?.status === 409) {
form.setFields([{ name: 'email', errors: ['该邮箱已注册,请直接登录'] }]);
} else {
message.error(apiErrorMessage(error));
}
} finally {
setSubmitting(false);
}
};
const handleFinish = async (values: RegisterFormValues) => {
setSubmitting(true);
try {
const session = await authApi.register({
email: values.email.trim(),
password: values.password,
password_confirmation: values.password_confirmation,
display_name: values.display_name?.trim() || undefined,
});
// 注册成功即在同一事务创建用户、个人工作空间和登录会话
authToken.set(session.access_token);
message.success('注册成功,已进入你的工作空间');
navigate('/online-products', { replace: true });
} catch (error) {
// 重复邮箱返回 409 EMAIL_ALREADY_REGISTERED,落到邮箱字段上提示
if (axios.isAxiosError(error) && error.response?.status === 409) {
form.setFields([
{ name: 'email', errors: ['该邮箱已注册,请直接登录'] },
]);
} else {
message.error(apiErrorMessage(error));
}
} finally {
setSubmitting(false);
}
};
return (
<AuthLayout
title="注册"
subtitle="创建账户后自动创建个人工作空间,注册即登录"
footer={
<>
已有账户?<Link to="/login">直接登录</Link>
</>
}
>
<Form<RegisterFormValues>
form={form}
layout="vertical"
requiredMark={false}
onFinish={handleFinish}
>
<Form.Item
name="display_name"
label="显示名称"
extra="选填,最多 80 个字符"
rules={[{ max: 80, message: '显示名称最多 80 个字符' }]}
return (
<AuthLayout
title="注册"
subtitle="创建账户后自动创建个人工作空间,注册即登录"
footer={
<>
已有账户?<Link to="/login">直接登录</Link>
</>
}
>
<Input
prefix={<UserOutlined style={{ color: 'var(--text-secondary)' }} />}
placeholder="选填"
maxLength={80}
autoComplete="nickname"
/>
</Form.Item>
<Form.Item
name="email"
label="邮箱"
rules={[{ required: true, message: '请输入邮箱' }, emailFormatRule]}
>
<Input
prefix={<MailOutlined style={{ color: 'var(--text-secondary)' }} />}
placeholder="请输入邮箱"
autoComplete="email"
/>
</Form.Item>
<Form.Item
name="password"
label="密码"
extra="长度与复杂度规则以服务端安全校验为准"
rules={[{ required: true, message: '请输入密码' }]}
>
<Input.Password
prefix={<LockOutlined style={{ color: 'var(--text-secondary)' }} />}
placeholder="请输入密码"
autoComplete="new-password"
/>
</Form.Item>
<Form.Item
name="password_confirmation"
label="确认密码"
dependencies={['password']}
rules={[
{ required: true, message: '请再次输入密码' },
({ getFieldValue }) => ({
validator(_, value) {
if (!value || getFieldValue('password') === value) {
return Promise.resolve();
}
return Promise.reject(new Error('两次输入的密码不一致'));
},
}),
]}
>
<Input.Password
prefix={<LockOutlined style={{ color: 'var(--text-secondary)' }} />}
placeholder="请再次输入密码"
autoComplete="new-password"
/>
</Form.Item>
<Form.Item style={{ marginBottom: 0, marginTop: 8 }}>
<Button type="primary" htmlType="submit" block loading={submitting}>
创建账户
</Button>
</Form.Item>
</Form>
</AuthLayout>
);
<Form<RegisterFormValues>
form={form}
layout="vertical"
requiredMark={false}
onFinish={handleFinish}
>
<Form.Item
name="display_name"
label="显示名称"
extra="选填,最多 80 个字符"
rules={[{ max: 80, message: '显示名称最多 80 个字符' }]}
>
<Input
prefix={
<UserOutlined
style={{ color: 'var(--text-secondary)' }}
/>
}
placeholder="选填"
maxLength={80}
autoComplete="nickname"
/>
</Form.Item>
<Form.Item
name="email"
label="邮箱"
rules={[
{ required: true, message: '请输入邮箱' },
emailFormatRule,
]}
>
<Input
prefix={
<MailOutlined
style={{ color: 'var(--text-secondary)' }}
/>
}
placeholder="请输入邮箱"
autoComplete="email"
/>
</Form.Item>
<Form.Item
name="password"
label="密码"
extra="长度与复杂度规则以服务端安全校验为准"
rules={[{ required: true, message: '请输入密码' }]}
>
<Input.Password
prefix={
<LockOutlined
style={{ color: 'var(--text-secondary)' }}
/>
}
placeholder="请输入密码"
autoComplete="new-password"
/>
</Form.Item>
<Form.Item
name="password_confirmation"
label="确认密码"
dependencies={['password']}
rules={[
{ required: true, message: '请再次输入密码' },
({ getFieldValue }) => ({
validator(_, value) {
if (
!value ||
getFieldValue('password') === value
) {
return Promise.resolve();
}
return Promise.reject(
new Error('两次输入的密码不一致'),
);
},
}),
]}
>
<Input.Password
prefix={
<LockOutlined
style={{ color: 'var(--text-secondary)' }}
/>
}
placeholder="请再次输入密码"
autoComplete="new-password"
/>
</Form.Item>
<Form.Item style={{ marginBottom: 0, marginTop: 8 }}>
<Button
type="primary"
htmlType="submit"
block
loading={submitting}
>
创建账户
</Button>
</Form.Item>
</Form>
</AuthLayout>
);
};
export default RegisterPage;
@@ -2,7 +2,7 @@ import PagePlaceholder from '@/components/PagePlaceholder';
/** 采集箱:查看已采集的商品,进入编辑上架(业务模块尚未实现) */
const CollectionPage = () => (
<PagePlaceholder title="采集箱" description="查看已采集的商品,进入编辑上架,模块开发中。" />
<PagePlaceholder title="采集箱" description="查看已采集的商品,进入编辑上架,模块开发中。" />
);
export default CollectionPage;
@@ -2,10 +2,10 @@ import PagePlaceholder from '@/components/PagePlaceholder';
/** 在线商品:按 SKU 查看平台已上架商品与同步状态(业务模块尚未实现) */
const OnlineProductsPage = () => (
<PagePlaceholder
title="在线商品"
description="按 SKU 查看平台已上架商品与同步状态,模块开发中。"
/>
<PagePlaceholder
title="在线商品"
description="按 SKU 查看平台已上架商品与同步状态,模块开发中。"
/>
);
export default OnlineProductsPage;
+4 -1
View File
@@ -2,7 +2,10 @@ import PagePlaceholder from '@/components/PagePlaceholder';
/** 定价工具:独立试算与物流计价规则(业务模块尚未实现) */
const PricingPage = () => (
<PagePlaceholder title="定价工具" description="独立试算与物流计价规则,模块开发中。" />
<PagePlaceholder
title="定价工具"
description="独立试算与物流计价规则,模块开发中。"
/>
);
export default PricingPage;
@@ -3,14 +3,14 @@ import PagePlaceholder from '@/components/PagePlaceholder';
/** 商品 AI 编辑页:编辑 SPU/SKU、媒体、定价与首次发布(业务模块尚未实现) */
const ProductEditPage = () => {
const { id } = useParams<{ id: string }>();
const { id } = useParams<{ id: string }>();
return (
<PagePlaceholder
title={`商品编辑(${id ?? '未知 ID'})`}
description="商品 AI 编辑页:SPU/SKU、媒体、定价与发布,模块开发中。"
/>
);
return (
<PagePlaceholder
title={`商品编辑(${id ?? '未知 ID'})`}
description="商品 AI 编辑页:SPU/SKU、媒体、定价与发布,模块开发中。"
/>
);
};
export default ProductEditPage;
+4 -1
View File
@@ -2,7 +2,10 @@ import PagePlaceholder from '@/components/PagePlaceholder';
/** 店铺管理:绑定平台店铺授权与水印(业务模块尚未实现) */
const ShopsPage = () => (
<PagePlaceholder title="店铺管理" description="绑定平台店铺授权与水印,模块开发中。" />
<PagePlaceholder
title="店铺管理"
description="绑定平台店铺授权与水印,模块开发中。"
/>
);
export default ShopsPage;
@@ -2,7 +2,10 @@ import PagePlaceholder from '@/components/PagePlaceholder';
/** 水印管理:维护水印模板并绑定到店铺(业务模块尚未实现) */
const WatermarksPage = () => (
<PagePlaceholder title="水印管理" description="维护水印模板并绑定到店铺,模块开发中。" />
<PagePlaceholder
title="水印管理"
description="维护水印模板并绑定到店铺,模块开发中。"
/>
);
export default WatermarksPage;
+21 -18
View File
@@ -10,22 +10,25 @@ import ShopsPage from '@/pages/shops/ShopsPage';
import WatermarksPage from '@/pages/watermarks/WatermarksPage';
export const router = createBrowserRouter([
{
path: '/',
element: <MainLayout />,
children: [
{ index: true, element: <Navigate to="/online-products" replace /> },
{ path: 'online-products', element: <OnlineProductsPage /> },
{ path: 'collection', element: <CollectionPage /> },
{ path: 'shops', element: <ShopsPage /> },
{ path: 'watermarks', element: <WatermarksPage /> },
{ path: 'pricing', element: <PricingPage /> },
// 商品 AI 编辑页:从采集箱/在线商品进入,不在左侧菜单中出现
{ path: 'products/:id', element: <ProductEditPage /> },
],
},
// 认证页在主布局之外;API 侧会话体系实现后再加路由守卫
{ path: 'login', element: <LoginPage /> },
{ path: 'register', element: <RegisterPage /> },
{ path: '*', element: <Navigate to="/" replace /> },
{
path: '/',
element: <MainLayout />,
children: [
{
index: true,
element: <Navigate to="/online-products" replace />,
},
{ path: 'online-products', element: <OnlineProductsPage /> },
{ path: 'collection', element: <CollectionPage /> },
{ path: 'shops', element: <ShopsPage /> },
{ path: 'watermarks', element: <WatermarksPage /> },
{ path: 'pricing', element: <PricingPage /> },
// 商品 AI 编辑页:从采集箱/在线商品进入,不在左侧菜单中出现
{ path: 'products/:id', element: <ProductEditPage /> },
],
},
// 认证页在主布局之外;API 侧会话体系实现后再加路由守卫
{ path: 'login', element: <LoginPage /> },
{ path: 'register', element: <RegisterPage /> },
{ path: '*', element: <Navigate to="/" replace /> },
]);
+86 -66
View File
@@ -1,102 +1,122 @@
import axios from 'axios';
import type { AxiosInstance, AxiosRequestConfig, InternalAxiosRequestConfig } from 'axios';
import type {
AxiosInstance,
AxiosRequestConfig,
InternalAxiosRequestConfig,
} from 'axios';
import { envConfig } from '@/config/env';
import { authToken } from '@/services/authToken';
declare module 'axios' {
export interface AxiosRequestConfig {
/** 401 刷新重放标记,防止无限重试 */
_retried?: boolean;
}
export interface AxiosRequestConfig {
/** 401 刷新重放标记,防止无限重试 */
_retried?: boolean;
}
}
const apiClient: AxiosInstance = axios.create({
baseURL: envConfig.apiBaseUrl,
timeout: 30000,
// 不设全局 Content-Type:axios 1.x 遇到「默认 JSON 头 + FormData」会把表单转成 JSON 发送,
// 导致素材上传等表单接口解析失败。对象请求体 axios 会自动补 JSON 头。
baseURL: envConfig.apiBaseUrl,
timeout: 30000,
// 不设全局 Content-Type:axios 1.x 遇到「默认 JSON 头 + FormData」会把表单转成 JSON 发送,
// 导致素材上传等表单接口解析失败。对象请求体 axios 会自动补 JSON 头。
});
// access token 仅在内存中短暂持有(docs/api/conventions.md §1),每次请求动态附加 Bearer 头
apiClient.interceptors.request.use((config: InternalAxiosRequestConfig) => {
const token = authToken.get();
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
const token = authToken.get();
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});
// 认证接口自身的失败不进入刷新流程
const AUTH_PATHS = ['/auth/login', '/auth/register', '/auth/refresh', '/auth/logout'];
const AUTH_PATHS = [
'/auth/login',
'/auth/register',
'/auth/refresh',
'/auth/logout',
];
let refreshTask: Promise<string | null> | null = null;
/** 并发 401 只触发一次刷新请求;刷新依赖 HttpOnly refresh cookie */
function refreshAccessToken(): Promise<string | null> {
refreshTask ??= apiClient
.post<{ access_token: string }>('/auth/refresh')
.then((res) => {
authToken.set(res.data.access_token);
return res.data.access_token;
})
.catch(() => {
authToken.clear();
return null;
})
.finally(() => {
refreshTask = null;
});
return refreshTask;
refreshTask ??= apiClient
.post<{ access_token: string }>('/auth/refresh')
.then((res) => {
authToken.set(res.data.access_token);
return res.data.access_token;
})
.catch(() => {
authToken.clear();
return null;
})
.finally(() => {
refreshTask = null;
});
return refreshTask;
}
apiClient.interceptors.response.use(
(response) => response,
async (error) => {
if (envConfig.debug) {
console.error('请求失败:', error.response?.data || error.message);
}
const config = error.config as AxiosRequestConfig | undefined;
const url = config?.url ?? '';
const isAuthPath = AUTH_PATHS.some((path) => url.includes(path));
// access token 过期时用 refresh cookie 换新 token 并重放一次;
// 刷新失败说明会话已失效,整页跳转登录页(后续可换成路由内跳转并保留来源)
if (error.response?.status === 401 && config && !config._retried && !isAuthPath) {
config._retried = true;
const token = await refreshAccessToken();
if (token) {
return apiClient.request(config);
}
window.location.assign('/login');
}
return Promise.reject(error);
},
(response) => response,
async (error) => {
if (envConfig.debug) {
console.error('请求失败:', error.response?.data || error.message);
}
const config = error.config as AxiosRequestConfig | undefined;
const url = config?.url ?? '';
const isAuthPath = AUTH_PATHS.some((path) => url.includes(path));
// access token 过期时用 refresh cookie 换新 token 并重放一次;
// 刷新失败说明会话已失效,整页跳转登录页(后续可换成路由内跳转并保留来源)
if (
error.response?.status === 401 &&
config &&
!config._retried &&
!isAuthPath
) {
config._retried = true;
const token = await refreshAccessToken();
if (token) {
return apiClient.request(config);
}
window.location.assign('/login');
}
return Promise.reject(error);
},
);
export const api = {
get: <T = unknown>(url: string, config?: AxiosRequestConfig) =>
apiClient.get<T>(url, config).then((res) => res.data),
get: <T = unknown>(url: string, config?: AxiosRequestConfig) =>
apiClient.get<T>(url, config).then((res) => res.data),
post: <T = unknown>(url: string, data?: unknown, config?: AxiosRequestConfig) =>
apiClient.post<T>(url, data, config).then((res) => res.data),
post: <T = unknown>(
url: string,
data?: unknown,
config?: AxiosRequestConfig,
) => apiClient.post<T>(url, data, config).then((res) => res.data),
patch: <T = unknown>(url: string, data?: unknown, config?: AxiosRequestConfig) =>
apiClient.patch<T>(url, data, config).then((res) => res.data),
patch: <T = unknown>(
url: string,
data?: unknown,
config?: AxiosRequestConfig,
) => apiClient.patch<T>(url, data, config).then((res) => res.data),
delete: <T = unknown>(url: string, config?: AxiosRequestConfig) =>
apiClient.delete<T>(url, config).then((res) => res.data),
delete: <T = unknown>(url: string, config?: AxiosRequestConfig) =>
apiClient.delete<T>(url, config).then((res) => res.data),
};
/** 统一错误格式为 { error: { code, message, request_id } }(docs/api/conventions.md),提取可展示信息 */
export function apiErrorMessage(error: unknown): string {
if (axios.isAxiosError(error)) {
const data = error.response?.data as
{ error?: { message?: unknown }; message?: unknown } | undefined;
if (data?.error?.message) return String(data.error.message);
if (data?.message) return String(data.message);
if (error.response) return `请求失败(HTTP ${error.response.status})`;
return '网络连接失败,请稍后重试';
}
return error instanceof Error ? error.message : String(error);
if (axios.isAxiosError(error)) {
const data = error.response?.data as
{ error?: { message?: unknown }; message?: unknown } | undefined;
if (data?.error?.message) return String(data.error.message);
if (data?.message) return String(data.message);
if (error.response) return `请求失败(HTTP ${error.response.status})`;
return '网络连接失败,请稍后重试';
}
return error instanceof Error ? error.message : String(error);
}
export default apiClient;
+12 -5
View File
@@ -1,13 +1,20 @@
import { api } from '@/services/api';
import type { AuthSession, LoginPayload, MeResult, RegisterPayload } from '@/types/auth';
import type {
AuthSession,
LoginPayload,
MeResult,
RegisterPayload,
} from '@/types/auth';
/** 账户与会话接口,契约见 docs/api/auth.md */
export const authApi = {
register: (payload: RegisterPayload) => api.post<AuthSession>('/auth/register', payload),
register: (payload: RegisterPayload) =>
api.post<AuthSession>('/auth/register', payload),
login: (payload: LoginPayload) => api.post<AuthSession>('/auth/login', payload),
login: (payload: LoginPayload) =>
api.post<AuthSession>('/auth/login', payload),
logout: () => api.post<void>('/auth/logout'),
logout: () => api.post<void>('/auth/logout'),
me: () => api.get<MeResult>('/auth/me'),
me: () => api.get<MeResult>('/auth/me'),
};
+7 -7
View File
@@ -6,11 +6,11 @@
let accessToken: string | null = null;
export const authToken = {
get: () => accessToken,
set: (token: string) => {
accessToken = token;
},
clear: () => {
accessToken = null;
},
get: () => accessToken,
set: (token: string) => {
accessToken = token;
},
clear: () => {
accessToken = null;
},
};
+28 -28
View File
@@ -4,52 +4,52 @@
*/
export interface AuthUser {
id: string;
email: string;
display_name: string | null;
email_verified_at: string | null;
created_at?: string;
id: string;
email: string;
display_name: string | null;
email_verified_at: string | null;
created_at?: string;
}
export interface AuthWorkspace {
id: string;
name: string;
role: string;
id: string;
name: string;
role: string;
}
/** 注册 / 登录成功返回的会话(access token 短期有效,refresh token 在 HttpOnly cookie 中) */
export interface AuthSession {
access_token: string;
access_token_expires_in: number;
user: AuthUser;
workspace: AuthWorkspace;
access_token: string;
access_token_expires_in: number;
user: AuthUser;
workspace: AuthWorkspace;
}
export interface RegisterPayload {
email: string;
password: string;
password_confirmation: string;
display_name?: string;
email: string;
password: string;
password_confirmation: string;
display_name?: string;
}
export interface LoginPayload {
email: string;
password: string;
email: string;
password: string;
}
export interface PlatformSummary {
id: string;
code: string;
name: string;
id: string;
code: string;
name: string;
}
/** GET /api/auth/me 的返回;default_store 摘要字段以后续门店接口文档为准 */
export interface MeResult {
user: AuthUser;
workspace: AuthWorkspace;
platforms: PlatformSummary[];
suggested_context: {
platform_id: string;
default_store: { id: string; name: string } | null;
};
user: AuthUser;
workspace: AuthWorkspace;
platforms: PlatformSummary[];
suggested_context: {
platform_id: string;
default_store: { id: string; name: string } | null;
};
}
+4 -4
View File
@@ -2,8 +2,8 @@ import type { Rule } from 'antd/es/form';
/** 邮箱格式校验:仅在非空时校验,避免与 required 规则在空值时同时报错(antd v6 行为) */
export const emailFormatRule: Rule = {
validator: (_, value: string) =>
!value || /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)
? Promise.resolve()
: Promise.reject(new Error('邮箱格式不正确')),
validator: (_, value: string) =>
!value || /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)
? Promise.resolve()
: Promise.reject(new Error('邮箱格式不正确')),
};
+19 -19
View File
@@ -1,21 +1,21 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"composite": true,
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.tsbuildinfo",
// Web 走 Vite 打包,使用 bundler 解析;覆盖 base 中面向 NestJS 的 Node16 模块设置
"module": "ESNext",
"moduleResolution": "bundler",
"moduleDetection": "force",
"isolatedModules": true,
"useDefineForClassFields": true,
"lib": ["ES2023", "DOM", "DOM.Iterable"],
"jsx": "react-jsx",
"noEmit": true,
"allowImportingTsExtensions": false,
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src", "vite.config.ts"]
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"composite": true,
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.tsbuildinfo",
// Web 走 Vite 打包,使用 bundler 解析;覆盖 base 中面向 NestJS 的 Node16 模块设置
"module": "ESNext",
"moduleResolution": "bundler",
"moduleDetection": "force",
"isolatedModules": true,
"useDefineForClassFields": true,
"lib": ["ES2023", "DOM", "DOM.Iterable"],
"jsx": "react-jsx",
"noEmit": true,
"allowImportingTsExtensions": false,
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src", "vite.config.ts"]
}
+14 -14
View File
@@ -4,20 +4,20 @@ import react from '@vitejs/plugin-react';
// 前端开发服务器:/api 代理到本地 NestJS API(apps/api,默认 8200)
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url)),
plugins: [react()],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url)),
},
},
},
server: {
port: Number(process.env.WEB_PORT ?? 8100),
strictPort: true,
proxy: {
'/api': {
target: process.env.API_ORIGIN ?? 'http://localhost:8200',
changeOrigin: true,
},
server: {
port: Number(process.env.WEB_PORT ?? 8100),
strictPort: true,
proxy: {
'/api': {
target: process.env.API_ORIGIN ?? 'http://localhost:8200',
changeOrigin: true,
},
},
},
},
});
+19 -19
View File
@@ -1,21 +1,21 @@
{
"name": "@atlas/worker",
"version": "0.1.0",
"private": true,
"scripts": {
"build": "tsc -p tsconfig.json",
"dev": "tsx watch src/main.ts",
"start": "node dist/main.js",
"typecheck": "tsc -p tsconfig.json --noEmit"
},
"dependencies": {
"@atlas/contracts": "workspace:*",
"bullmq": "6.3.4",
"ioredis": "6.0.0"
},
"devDependencies": {
"@types/node": "26.3.0",
"tsx": "4.23.13",
"typescript": "7.0.2"
}
"name": "@atlas/worker",
"version": "0.1.0",
"private": true,
"scripts": {
"build": "tsc -p tsconfig.json",
"dev": "tsx watch src/main.ts",
"start": "node dist/main.js",
"typecheck": "tsc -p tsconfig.json --noEmit"
},
"dependencies": {
"@atlas/contracts": "workspace:*",
"bullmq": "6.3.4",
"ioredis": "6.0.0"
},
"devDependencies": {
"@types/node": "26.3.0",
"tsx": "4.23.13",
"typescript": "7.0.2"
}
}
+11 -8
View File
@@ -3,22 +3,25 @@ import IORedis from 'ioredis';
// 显式构造客户端实例传给 BullMQ:原生 ESM(pnpm start)下 BullMQ 无法按 connection
// 选项自动加载 ioredis;maxRetriesPerRequest: null 是 BullMQ Worker 的要求
const connection = new IORedis(process.env.REDIS_URL ?? 'redis://localhost:6379', {
maxRetriesPerRequest: null,
});
const connection = new IORedis(
process.env.REDIS_URL ?? 'redis://localhost:6379',
{
maxRetriesPerRequest: null,
},
);
const dispatchQueue = new Queue('atlas-dispatch', { connection });
async function bootstrap() {
await dispatchQueue.waitUntilReady();
console.info(
'Atlas Worker is ready; it will consume transactional-outbox events after that module exists.',
);
await dispatchQueue.waitUntilReady();
console.info(
'Atlas Worker is ready; it will consume transactional-outbox events after that module exists.',
);
}
void bootstrap();
async function shutdown() {
await dispatchQueue.close();
await dispatchQueue.close();
}
process.once('SIGINT', () => void shutdown());
+7 -7
View File
@@ -1,9 +1,9 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "dist",
"types": ["node"]
},
"include": ["src/**/*.ts"]
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "dist",
"types": ["node"]
},
"include": ["src/**/*.ts"]
}
+4
View File
@@ -2,6 +2,10 @@
> 状态:第一批可评审契约。本文档描述预期的 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. 文档路线
| 文档 | 状态 | 范围 |
+19
View File
@@ -29,6 +29,25 @@ X-Atlas-Platform-Id: 57ad…-uuid
## 3. 响应、分页和字段格式
### 3.1 强制响应信封
本项目不采用完整 JSON:API 规范,但所有业务 HTTP 接口必须使用以下响应信封;这是
前端、服务端、Worker 和扩展共同遵守的强制契约:
| 场景 | HTTP 响应体 |
|---|---|
| 成功的单资源或同步动作 | `{ "data": T }` |
| 成功的列表 | `{ "data": T[], "meta": PaginationMeta }` |
| 失败 | `{ "error": ApiError }` |
| `204 No Content` | 无响应体 |
`data` 只能位于顶层且只能出现一次;不得返回 `{ "data": { "data": ... } }`,也不得以
`{ "data": null }` 表示失败。HTTP 状态码仍表达请求结果,错误必须使用本文件第 4 节的
`error` 信封,不能混入成功响应。`meta` 只承载分页、游标或与整个响应相关的元信息,不能
替代资源字段。
### 3.2 成功与列表响应
成功响应统一为:
```json
+18 -1
View File
@@ -12,6 +12,22 @@
`atlas_app_login` 和 `atlas_app` 都必须是 `NOBYPASSRLS`,也不能是 PostgreSQL 超级用户。生产环境不得把 `atlas_migrator` 的连接串写入 API 或 Worker 的环境变量。
### 角色链与请求上下文
三层角色链是固定的,不能简化为“应用直接使用迁移账号”:
```text
atlas_migrator ──拥有──> atlas schema 与业务表
atlas_app_login ──登录身份,NOINHERIT──> SET LOCAL ROLE atlas_app
atlas_app ──无登录权限组──────────> 最小 DML 与序列权限
```
API 和 Worker 只以 `atlas_app_login` 建立连接。每笔业务事务必须先执行
`SET LOCAL ROLE atlas_app`,再设置 `SET LOCAL app.workspace_id`。前者收窄为
应用所需的最小数据库权限,后者为 RLS 策略提供当前工作空间上下文;任何一步缺失,
都不应使跨工作空间数据变得可见。
## 2. 一次性数据库初始化
以下由云数据库管理员或基础设施脚本执行;它不属于 NestJS 的业务 migration,因为托管 PostgreSQL 常不允许应用账户创建角色。密码应从密钥管理服务注入,示例中不填写真实密码。
@@ -22,6 +38,7 @@ CREATE ROLE atlas_app NOLOGIN NOINHERIT NOBYPASSRLS;
CREATE ROLE atlas_app_login LOGIN NOINHERIT NOBYPASSRLS PASSWORD '<managed-secret>';
GRANT atlas_app TO atlas_app_login;
GRANT CREATE ON DATABASE atlas TO atlas_migrator;
CREATE SCHEMA IF NOT EXISTS atlas AUTHORIZATION atlas_migrator;
GRANT USAGE ON SCHEMA atlas TO atlas_app;
@@ -34,7 +51,7 @@ ALTER DEFAULT PRIVILEGES FOR ROLE atlas_migrator IN SCHEMA atlas
GRANT USAGE, SELECT ON SEQUENCES TO atlas_app;
```
首次 DDL 与后续 migration 都以 `atlas_migrator` 执行,确保新表的所有者一致。若云服务统一由管理员创建 schema,可将 `atlas_migrator` 设为 schema/表所有者或由管理员代为执行 migration;无论哪种方式,应用登录角色都不得成为表所有者。
首次 DDL 与后续 migration 都以 `atlas_migrator` 执行,确保新表的所有者一致。迁移角色拥有数据库级 `CREATE` 的唯一目的,是创建其拥有的 `atlas` schema;它不授予 API 或 Worker 任何额外权限。若云服务统一由管理员创建 schema,可将 `atlas_migrator` 设为 schema/表所有者或由管理员代为执行 migration;无论哪种方式,应用登录角色都不得成为表所有者。
## 3. RLS 的运行方式
+1
View File
@@ -86,6 +86,7 @@ User → Workspace → Membership / Role
- 覆盖账户认证、平台/门店、水印与素材、定价、浏览器采集/采集箱、商品编辑/首次发布、在线商品读取与同步。
- 所有接口遵循统一鉴权、工作空间隔离、页头平台上下文、金额字符串、错误格式、`Idempotency-Key`、`If-Match` 和异步 `operation` 约定。
- **响应信封是强制契约:** 成功单资源或同步动作返回 `{ "data": T }`,列表返回 `{ "data": T[], "meta": PaginationMeta }`,失败返回 `{ "error": ApiError }`,`204 No Content` 不返回响应体。项目不采用完整 JSON:API;任何调用方不得自行增加嵌套 `data` 或以 `data: null` 表示错误。完整规则见 [`api/conventions.md`](../api/conventions.md)。
- 关键异步动作必须在同一事务写入业务记录、`operations`(如需用户可见进度)和 `outbox_events`;Dispatcher 至少一次投递 BullMQ,Worker 必须幂等。
- DDL 已通过临时隔离 PostgreSQL 实例执行验证;API 契约已完成文档评审。这不表示 NestJS 模块、迁移执行器或 Worker 已实现。
+6 -1
View File
@@ -1,5 +1,10 @@
# 本地基础设施
复制根目录 `.env.example` 为 `.env` 后,执行 `pnpm infra:up` 启动 PostgreSQL、Redis 和 MinIO。镜像使用已验证的明确版本标签;升级前应先在本地或 staging 验证,再显式更新标签。
复制根目录 `.env.example` 为 `.env` 后,执行 `pnpm infra:up` 启动 PostgreSQL、Redis 和 MinIO。该命令会显式加载根目录 `.env`,避免因 Compose 文件位于 `infra/` 而回退到默认端口或密码。镜像使用已验证的明确版本标签;升级前应先在本地或 staging 验证,再显式更新标签。
应用进程由 `pnpm dev` 在宿主机启动:Web 使用 Vite 热更新,API 与 Worker 使用各自的 watch 模式。生产环境的 API、Worker 和反向代理容器编排将在部署运行手册中补充;PostgreSQL 和对象存储继续遵守现有架构文档的托管服务边界。
首次执行 `pnpm infra:up` 时,PostgreSQL 会以本地管理员初始化,并创建三个独立角色:`atlas_migrator`(仅迁移)、`atlas_app`(无登录权限组)与 `atlas_app_login`(API/Worker 登录)。
执行迁移只能使用 `MIGRATOR_DATABASE_URL`:`pnpm --filter @atlas/database migrate`。API 和 Worker 只能使用 `DATABASE_URL`,应用事务会切换到 `atlas_app` 并按请求设置工作空间上下文。
如已有旧的本地 PostgreSQL volume,初始化脚本不会再次运行;请不要让 API 使用管理员或 migrator 连接串。需要重建本地开发数据时,先确认没有需要保留的数据,再移除该项目的 PostgreSQL volume 后重新执行 `pnpm infra:up`。
+42 -37
View File
@@ -1,44 +1,49 @@
name: atlas-seller-kit
services:
postgres:
image: postgres:18.6-alpine
environment:
POSTGRES_USER: ${POSTGRES_USER:-atlas_app_login}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-change-me-local-only}
POSTGRES_DB: ${POSTGRES_DB:-atlas}
ports:
- '${POSTGRES_PORT:-5432}:5432'
volumes:
- postgres-data:/var/lib/postgresql/data
healthcheck:
test: ['CMD-SHELL', 'pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB']
interval: 5s
timeout: 5s
retries: 20
postgres:
image: postgres:18.6-alpine
environment:
POSTGRES_USER: ${POSTGRES_ADMIN_USER:-atlas_admin}
POSTGRES_PASSWORD: ${POSTGRES_ADMIN_PASSWORD:-change-me-local-only}
POSTGRES_DB: ${POSTGRES_DB:-atlas}
ATLAS_MIGRATOR_PASSWORD: ${ATLAS_MIGRATOR_PASSWORD:-change-me-local-only}
ATLAS_APP_PASSWORD: ${ATLAS_APP_PASSWORD:-change-me-local-only}
ports:
- '${POSTGRES_PORT:-5432}:5432'
volumes:
# PostgreSQL 18 将集群数据放在版本化子目录,volume 必须挂到父目录。
- postgres-data:/var/lib/postgresql
- ./postgres/init:/docker-entrypoint-initdb.d:ro
healthcheck:
test:
['CMD-SHELL', 'pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB']
interval: 5s
timeout: 5s
retries: 20
redis:
image: redis:7.4.11-alpine3.21
ports:
- '6379:6379'
healthcheck:
test: ['CMD', 'redis-cli', 'ping']
interval: 5s
timeout: 5s
retries: 20
redis:
image: redis:7.4.11-alpine3.21
ports:
- '6379:6379'
healthcheck:
test: ['CMD', 'redis-cli', 'ping']
interval: 5s
timeout: 5s
retries: 20
minio:
image: minio/minio:RELEASE.2025-09-07T16-13-09Z
command: server /data --console-address ':9001'
environment:
MINIO_ROOT_USER: ${MINIO_ROOT_USER:-atlas_minio_local}
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:-change-me-local-only}
ports:
- '${MINIO_PORT:-9000}:9000'
- '${MINIO_CONSOLE_PORT:-9001}:9001'
volumes:
- minio-data:/data
minio:
image: minio/minio:RELEASE.2025-09-07T16-13-09Z
command: server /data --console-address ':9001'
environment:
MINIO_ROOT_USER: ${MINIO_ROOT_USER:-atlas_minio_local}
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:-change-me-local-only}
ports:
- '${MINIO_PORT:-9000}:9000'
- '${MINIO_CONSOLE_PORT:-9001}:9001'
volumes:
- minio-data:/data
volumes:
postgres-data:
minio-data:
postgres-data:
minio-data:
+19
View File
@@ -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
View File
@@ -9,14 +9,16 @@
},
"scripts": {
"build": "turbo run build",
"dev": "turbo run dev --parallel",
"dev": "turbo run dev",
"dev:backend": "turbo run dev --filter=@atlas/api --filter=@atlas/worker",
"dev:frontend": "turbo run dev --filter=@atlas/web",
"lint": "turbo run lint",
"typecheck": "turbo run typecheck",
"test": "turbo run test",
"format": "prettier --write .",
"format:check": "prettier --check .",
"infra:up": "docker compose -f infra/compose.yaml up -d postgres redis minio",
"infra:down": "docker compose -f infra/compose.yaml down"
"format": "prettier --config ./prettier.config.mjs --write .",
"format:check": "prettier --config ./prettier.config.mjs --check .",
"infra:up": "docker compose --env-file .env -f infra/compose.yaml up -d postgres redis minio",
"infra:down": "docker compose --env-file .env -f infra/compose.yaml down"
},
"devDependencies": {
"prettier": "3.9.6",
+18 -18
View File
@@ -1,20 +1,20 @@
{
"name": "@atlas/contracts",
"version": "0.1.0",
"private": true,
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"files": [
"dist"
],
"scripts": {
"build": "tsc -p tsconfig.json",
"typecheck": "tsc -p tsconfig.json --noEmit"
},
"dependencies": {
"zod": "4.5.4"
},
"devDependencies": {
"typescript": "7.0.2"
}
"name": "@atlas/contracts",
"version": "0.1.0",
"private": true,
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"files": [
"dist"
],
"scripts": {
"build": "tsc -p tsconfig.json",
"typecheck": "tsc -p tsconfig.json --noEmit"
},
"dependencies": {
"zod": "4.5.4"
},
"devDependencies": {
"typescript": "7.0.2"
}
}
+36
View File
@@ -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>;
+17 -15
View File
@@ -5,20 +5,22 @@ import { z } from 'zod';
* 领域字段会随采集模块实现逐步补全,不能在扩展侧绕过此契约直接构造 API 请求。
*/
export const collectedProductPayloadSchema = z.object({
source: z.object({
platform: z.enum(['ozon', '1688', 'taobao', 'tmall']),
itemKey: z.string().min(1),
canonicalUrl: z.url(),
collectedAt: z.iso.datetime(),
parserVersion: z.string().min(1),
}),
common: z.object({
title: z.string().min(1).optional(),
}),
variantAxes: z.array(z.unknown()),
variants: z.array(z.unknown()),
assets: z.array(z.unknown()),
evidence: z.array(z.unknown()),
source: z.object({
platform: z.enum(['ozon', '1688', 'taobao', 'tmall']),
itemKey: z.string().min(1),
canonicalUrl: z.url(),
collectedAt: z.iso.datetime(),
parserVersion: z.string().min(1),
}),
common: z.object({
title: z.string().min(1).optional(),
}),
variantAxes: z.array(z.unknown()),
variants: z.array(z.unknown()),
assets: z.array(z.unknown()),
evidence: z.array(z.unknown()),
});
export type CollectedProductPayload = z.infer<typeof collectedProductPayloadSchema>;
export type CollectedProductPayload = z.infer<
typeof collectedProductPayloadSchema
>;
+1
View File
@@ -1 +1,2 @@
export * from './collection';
export * from './auth';
+6 -6
View File
@@ -1,8 +1,8 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "dist"
},
"include": ["src/**/*.ts"]
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "dist"
},
"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;
+20 -14
View File
@@ -1,16 +1,22 @@
{
"name": "@atlas/database",
"version": "0.1.0",
"private": true,
"scripts": {
"typecheck": "tsc -p tsconfig.json --noEmit"
},
"dependencies": {
"pg": "8.23.0"
},
"devDependencies": {
"@types/node": "26.3.0",
"@types/pg": "8.23.1",
"typescript": "7.0.2"
}
"name": "@atlas/database",
"version": "0.1.0",
"private": true,
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"scripts": {
"build": "tsc -p tsconfig.json",
"migrate": "tsx src/migrate.ts",
"typecheck": "tsc -p tsconfig.json --noEmit"
},
"dependencies": {
"pg": "8.23.0"
},
"devDependencies": {
"@types/node": "26.3.0",
"@types/pg": "8.23.1",
"tsx": "4.23.13",
"typescript": "7.0.2"
}
}
+2 -5
View File
@@ -1,5 +1,2 @@
/**
* 数据库访问层的唯一入口。
* 后续在此封装迁移连接与应用连接,应用事务必须先设置 app.workspace_id。
*/
export const databasePackageName = '@atlas/database';
export { Database, createApplicationDatabase } from './pool.js';
export type { DatabaseTransaction } from './pool.js';
+70
View File
@@ -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();
+59
View File
@@ -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 }));
}
+6 -6
View File
@@ -1,8 +1,8 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "dist"
},
"include": ["src/**/*.ts"]
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "dist"
},
"include": ["src/**/*.ts"]
}
+5685 -4117
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -3,5 +3,6 @@ packages:
- packages/*
allowBuilds:
'@scarf/scarf': false
esbuild: true
msgpackr-extract: false
-5
View File
@@ -1,5 +0,0 @@
export default {
singleQuote: true,
trailingComma: 'all',
printWidth: 100,
};
+12 -12
View File
@@ -1,14 +1,14 @@
{
"compilerOptions": {
"target": "ES2023",
"module": "Node16",
"moduleResolution": "Node16",
"strict": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"skipLibCheck": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true
}
"compilerOptions": {
"target": "ES2023",
"module": "Node16",
"moduleResolution": "Node16",
"strict": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"skipLibCheck": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true
}
}
+21 -21
View File
@@ -1,24 +1,24 @@
{
"$schema": "https://turbo.build/schema.json",
"tasks": {
"build": {
"dependsOn": ["^build"],
"outputs": ["dist/**", ".output/**"]
},
"dev": {
"cache": false,
"persistent": true
},
"lint": {
"dependsOn": ["^lint"]
},
"typecheck": {
"dependsOn": ["^typecheck"],
"outputs": []
},
"test": {
"dependsOn": ["^build"],
"outputs": ["coverage/**"]
"$schema": "https://turbo.build/schema.json",
"tasks": {
"build": {
"dependsOn": ["^build"],
"outputs": ["dist/**", ".output/**"]
},
"dev": {
"cache": false,
"persistent": true
},
"lint": {
"dependsOn": ["^lint"]
},
"typecheck": {
"dependsOn": ["^typecheck"],
"outputs": []
},
"test": {
"dependsOn": ["^build"],
"outputs": ["coverage/**"]
}
}
}
}