Compare commits
31 Commits
feat-0.1.0
...
66ca8b496c
| Author | SHA1 | Date | |
|---|---|---|---|
| 66ca8b496c | |||
| 5bdf1645d2 | |||
| 1b0cdc33aa | |||
| d199c4d6df | |||
| aa3076c434 | |||
| eecec5e0d0 | |||
| c0473d607a | |||
| 27b9e07b62 | |||
| 6879e7a8b7 | |||
| 9164fc962e | |||
| 24a25dcbd1 | |||
| 5bf5f80b34 | |||
| 46d40b184b | |||
| 446baf5939 | |||
| 8655b7805f | |||
| 3ab999d0b2 | |||
| cc61967e3b | |||
| dc39e3e4e5 | |||
| 2d435143ab | |||
| 06a3a4da75 | |||
| a5b89ddb1d | |||
| 46d98ab11e | |||
| ffa86f8d55 | |||
| ada02e9135 | |||
| 35fcf8776e | |||
| df40ddde8c | |||
| 037928c778 | |||
| 195b6d25e9 | |||
| 403f6cfa27 | |||
| 09e5207c83 | |||
| 1a2caba2b3 |
@@ -0,0 +1,77 @@
|
||||
# Local PostgreSQL bootstrap administrator. It creates roles but must never be used by API or Worker.
|
||||
POSTGRES_ADMIN_USER=atlas_admin
|
||||
POSTGRES_ADMIN_PASSWORD=change-me-local-only
|
||||
# Database name and host port exposed by local Docker Compose.
|
||||
POSTGRES_DB=atlas
|
||||
POSTGRES_PORT=5432
|
||||
|
||||
# Password for the schema-owning migrator role; only the migration runner may use it.
|
||||
ATLAS_MIGRATOR_PASSWORD=change-me-local-only
|
||||
# Password for the least-privileged API/Worker login role.
|
||||
ATLAS_APP_PASSWORD=change-me-local-only
|
||||
# API/Worker application connection. This role has no table ownership and is constrained by RLS.
|
||||
DATABASE_URL=postgresql://atlas_app_login:change-me-local-only@localhost:5432/atlas
|
||||
# Migration-only connection. Do not put this value in production API or Worker environments.
|
||||
MIGRATOR_DATABASE_URL=postgresql://atlas_migrator:change-me-local-only@localhost:5432/atlas
|
||||
|
||||
# Runtime mode controls production-only safeguards such as Secure cookies.
|
||||
NODE_ENV=development
|
||||
# Browser origin allowed to send credentialed requests to this API.
|
||||
WEB_ORIGIN=http://localhost:8100
|
||||
# HTTP listener port for the NestJS API.
|
||||
API_PORT=8200
|
||||
|
||||
# HMAC signing secret for short-lived access JWTs; replace with a unique 32+ character secret.
|
||||
AUTH_JWT_SECRET=replace-with-a-random-secret-of-at-least-32-characters
|
||||
# Separate HMAC secret used to pseudonymize IP/email values in Redis rate-limit keys.
|
||||
AUTH_RATE_LIMIT_KEY_SECRET=replace-with-a-different-random-secret-of-at-least-32-characters
|
||||
# AES-GCM 密钥材料,用来加密门店 API Key;只保存在服务端,轮换时变更版本号但保留旧密钥解密能力。
|
||||
STORE_SECRET_ENCRYPTION_KEY=replace-with-a-random-secret-of-at-least-32-characters
|
||||
# 当前门店密钥使用的加密密钥版本,会随密钥轮换递增。
|
||||
STORE_SECRET_ENCRYPTION_KEY_VERSION=v1
|
||||
# 对幂等请求内容生成 HMAC 摘要的独立密钥,避免数据库保存原始请求体或 API Key。
|
||||
IDEMPOTENCY_KEY_SECRET=replace-with-a-different-random-secret-of-at-least-32-characters
|
||||
# Server-side minimum password length. The browser must not weaken this rule.
|
||||
AUTH_PASSWORD_MIN_LENGTH=12
|
||||
# Maximum JSON or URL-encoded request size in bytes. Media files must use object-storage direct upload.
|
||||
API_BODY_LIMIT_BYTES=1048576
|
||||
# Shared fixed-window duration for authentication rate limits, in seconds.
|
||||
AUTH_RATE_LIMIT_WINDOW_SECONDS=900
|
||||
# Maximum login attempts per IP inside the window.
|
||||
AUTH_LOGIN_IP_LIMIT=10
|
||||
# Maximum login attempts per normalized email inside the window.
|
||||
AUTH_LOGIN_EMAIL_LIMIT=5
|
||||
# Maximum registration attempts per IP inside the window.
|
||||
AUTH_REGISTER_IP_LIMIT=5
|
||||
# Maximum registration attempts per normalized email inside the window.
|
||||
AUTH_REGISTER_EMAIL_LIMIT=3
|
||||
# Maximum refresh attempts per IP inside the window; refresh tokens have no trusted email before validation.
|
||||
AUTH_REFRESH_IP_LIMIT=30
|
||||
# 密码修改在同一限流窗口内允许的每 IP 最大次数。
|
||||
AUTH_CHANGE_PASSWORD_IP_LIMIT=10
|
||||
# 密码修改在同一限流窗口内允许的每已认证用户最大次数。
|
||||
AUTH_CHANGE_PASSWORD_USER_LIMIT=5
|
||||
|
||||
# Comma-separated trusted reverse-proxy IPs/CIDRs. Keep empty for direct local access; never set to true.
|
||||
TRUSTED_PROXY_CIDRS=
|
||||
# Whether to expose Swagger UI at /api-docs.
|
||||
SWAGGER_ENABLED=true
|
||||
# Minimum structured-log severity: error, warn, log, or debug.
|
||||
LOG_LEVEL=log
|
||||
|
||||
# Redis endpoint used by BullMQ and shared authentication rate limits.
|
||||
REDIS_URL=redis://localhost:6379
|
||||
|
||||
# Local MinIO root account; it is only for Docker bootstrap, not browser credentials.
|
||||
MINIO_ROOT_USER=atlas_minio_local
|
||||
# Local MinIO root password; replace outside disposable local development.
|
||||
MINIO_ROOT_PASSWORD=change-me-local-only
|
||||
# S3-compatible API port for local MinIO.
|
||||
MINIO_PORT=9000
|
||||
# MinIO administrative console port.
|
||||
MINIO_CONSOLE_PORT=9001
|
||||
|
||||
# Local Vite frontend port.
|
||||
WEB_PORT=8100
|
||||
# Local WXT browser-extension development port.
|
||||
EXTENSION_PORT=8110
|
||||
+12
@@ -11,7 +11,19 @@
|
||||
# Common future runtime/build artifacts
|
||||
node_modules/
|
||||
dist/
|
||||
.output/
|
||||
.venv/
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*.log
|
||||
node_modules/
|
||||
.pnpm-store/
|
||||
.turbo/
|
||||
dist/
|
||||
coverage/
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
*.log
|
||||
.DS_Store
|
||||
data/
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
node_modules/
|
||||
.turbo/
|
||||
dist/
|
||||
apps/extension/.output/
|
||||
apps/extension/.wxt/
|
||||
docs/**
|
||||
!docs/architecture/project-structure.md
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"singleQuote": true,
|
||||
"trailingComma": "all",
|
||||
"bracketSpacing": true,
|
||||
"arrowParens": "always",
|
||||
"printWidth": 80,
|
||||
"tabWidth": 4,
|
||||
"useTabs": false
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"singleQuote": true,
|
||||
"trailingComma": "all",
|
||||
"bracketSpacing": true,
|
||||
"arrowParens": "always",
|
||||
"printWidth": 80,
|
||||
"tabWidth": 4,
|
||||
"useTabs": false
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"name": "@atlas/api",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "tsc -p tsconfig.json",
|
||||
"dev": "node scripts/dev.mjs",
|
||||
"start": "node dist/main.js",
|
||||
"typecheck": "tsc -p tsconfig.json --noEmit && tsc -p tsconfig.test.json --noEmit",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@atlas/contracts": "workspace:*",
|
||||
"@atlas/database": "workspace:*",
|
||||
"@nestjs/common": "12.0.1",
|
||||
"@nestjs/config": "12.0.0",
|
||||
"@nestjs/core": "12.0.1",
|
||||
"@nestjs/platform-express": "12.0.1",
|
||||
"@nestjs/swagger": "12.0.1",
|
||||
"helmet": "8.3.0",
|
||||
"ioredis": "6.0.0",
|
||||
"reflect-metadata": "0.2.2",
|
||||
"rxjs": "7.8.2",
|
||||
"zod": "4.5.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nestjs/testing": "12.0.1",
|
||||
"@types/express": "5.0.6",
|
||||
"@types/node": "26.3.0",
|
||||
"tsx": "4.23.13",
|
||||
"typescript": "7.0.2",
|
||||
"vitest": "^4.1.11"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { spawn } from 'node:child_process';
|
||||
import { existsSync, readFileSync } from 'node:fs';
|
||||
import { dirname, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const scriptDirectory = dirname(fileURLToPath(import.meta.url));
|
||||
const repositoryRoot = resolve(scriptDirectory, '../../..');
|
||||
const environmentFile = resolve(repositoryRoot, '.env');
|
||||
const localEnvironment = existsSync(environmentFile)
|
||||
? parseEnvironment(readFileSync(environmentFile, 'utf8'))
|
||||
: {};
|
||||
const apiPort = process.env.API_PORT ?? localEnvironment.API_PORT ?? '8200';
|
||||
const swaggerEnabled =
|
||||
(process.env.SWAGGER_ENABLED ??
|
||||
localEnvironment.SWAGGER_ENABLED ??
|
||||
'true') === 'true';
|
||||
const nodeEnvironment =
|
||||
process.env.NODE_ENV ?? localEnvironment.NODE_ENV ?? 'development';
|
||||
const swaggerUrl = `http://localhost:${apiPort}/api-docs`;
|
||||
const tsxCommand = process.platform === 'win32' ? 'tsx.cmd' : 'tsx';
|
||||
const apiProcess = spawn(tsxCommand, ['watch', 'src/main.ts'], {
|
||||
shell: process.platform === 'win32',
|
||||
stdio: 'inherit',
|
||||
});
|
||||
|
||||
if (
|
||||
process.platform === 'darwin' &&
|
||||
nodeEnvironment === 'development' &&
|
||||
swaggerEnabled
|
||||
) {
|
||||
void openSwaggerAfterApiIsReady();
|
||||
}
|
||||
|
||||
for (const signal of ['SIGINT', 'SIGTERM']) {
|
||||
process.on(signal, () => apiProcess.kill(signal));
|
||||
}
|
||||
|
||||
apiProcess.on('exit', (code, signal) => {
|
||||
if (signal) process.exitCode = 1;
|
||||
else process.exitCode = code ?? 0;
|
||||
});
|
||||
|
||||
function parseEnvironment(content) {
|
||||
return Object.fromEntries(
|
||||
content
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.match(/^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/))
|
||||
.filter(Boolean)
|
||||
.map((match) => [match[1], match[2]]),
|
||||
);
|
||||
}
|
||||
|
||||
async function openSwaggerAfterApiIsReady() {
|
||||
const deadline = Date.now() + 30_000;
|
||||
while (Date.now() < deadline) {
|
||||
try {
|
||||
const response = await fetch(`http://localhost:${apiPort}/health`);
|
||||
if (response.ok) {
|
||||
// 开发启动器进程不会随 tsx watch 的热重启重建,因此只会打开一次。
|
||||
spawn('open', [swaggerUrl], {
|
||||
detached: true,
|
||||
stdio: 'ignore',
|
||||
}).unref();
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// API 仍在启动或热重启,继续等待到超时为止。
|
||||
}
|
||||
await new Promise((resolveDelay) => setTimeout(resolveDelay, 250));
|
||||
}
|
||||
console.warn(`Swagger 未在 30 秒内就绪:${swaggerUrl}`);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { Controller, Get, Inject, Module } from '@nestjs/common';
|
||||
import type { Database } from '@atlas/database';
|
||||
import { AuthModule } from './auth/auth.module.js';
|
||||
import { ApiError } from './common/api-error.js';
|
||||
import { Public } from './common/public.decorator.js';
|
||||
import { AppConfigModule } from './config/app-config.module.js';
|
||||
import { DATABASE, DatabaseModule } from './database/database.module.js';
|
||||
import { SecurityModule } from './security/security.module.js';
|
||||
import { StoresModule } from './stores/stores.module.js';
|
||||
|
||||
@Controller()
|
||||
class HealthController {
|
||||
public constructor(@Inject(DATABASE) private readonly database: Database) {}
|
||||
|
||||
@Get('health')
|
||||
@Public()
|
||||
check() {
|
||||
return { status: 'ok', service: 'api' };
|
||||
}
|
||||
|
||||
@Get('ready')
|
||||
@Public()
|
||||
async ready() {
|
||||
try {
|
||||
await this.database.query('SELECT 1');
|
||||
return { status: 'ready', service: 'api' };
|
||||
} catch {
|
||||
throw new ApiError(
|
||||
503,
|
||||
'DEPENDENCY_UNAVAILABLE',
|
||||
'服务依赖暂不可用',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
AppConfigModule,
|
||||
DatabaseModule,
|
||||
SecurityModule,
|
||||
AuthModule,
|
||||
StoresModule,
|
||||
],
|
||||
controllers: [HealthController],
|
||||
})
|
||||
export class AppModule {}
|
||||
@@ -0,0 +1,323 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
Headers,
|
||||
HttpCode,
|
||||
Inject,
|
||||
Patch,
|
||||
Post,
|
||||
Req,
|
||||
Res,
|
||||
} from '@nestjs/common';
|
||||
import {
|
||||
ApiBearerAuth,
|
||||
ApiBody,
|
||||
ApiNoContentResponse,
|
||||
ApiOkResponse,
|
||||
ApiOperation,
|
||||
ApiTags,
|
||||
ApiUnauthorizedResponse,
|
||||
} from '@nestjs/swagger';
|
||||
import { createHash } from 'node:crypto';
|
||||
import {
|
||||
changePasswordRequestSchema,
|
||||
loginRequestSchema,
|
||||
registerRequestSchema,
|
||||
updateCurrentUserRequestSchema,
|
||||
type Workspace,
|
||||
} from '@atlas/contracts';
|
||||
import { z } from 'zod';
|
||||
import { ApiError } from '../common/api-error.js';
|
||||
import { Public } from '../common/public.decorator.js';
|
||||
import type { AuthenticatedRequest } from './auth.guard.js';
|
||||
import { AuthService, type RequestMetadata } from './auth.service.js';
|
||||
import { CurrentWorkspace } from '../workspace/current-workspace.decorator.js';
|
||||
|
||||
const refreshCookie = 'atlas_refresh';
|
||||
// 控制器只负责 HTTP 边界;账户、会话与工作空间的一致性由 AuthService 的事务保证。
|
||||
type HttpRequest = {
|
||||
header: (name: string) => string | undefined;
|
||||
ip?: string;
|
||||
};
|
||||
type HttpResponse = {
|
||||
cookie: (
|
||||
name: string,
|
||||
value: string,
|
||||
options: Record<string, unknown>,
|
||||
) => void;
|
||||
clearCookie: (name: string, options: Record<string, unknown>) => void;
|
||||
};
|
||||
|
||||
@ApiTags('Authentication')
|
||||
@Controller('auth')
|
||||
export class AuthController {
|
||||
public constructor(
|
||||
@Inject(AuthService) private readonly authService: AuthService,
|
||||
) {}
|
||||
|
||||
@Post('register')
|
||||
@Public()
|
||||
@ApiOperation({ summary: '注册账户并创建个人工作空间' })
|
||||
@ApiBody({
|
||||
schema: {
|
||||
example: {
|
||||
email: 'owner@example.com',
|
||||
password: 'a-strong-password',
|
||||
password_confirmation: 'a-strong-password',
|
||||
display_name: 'Joey',
|
||||
},
|
||||
},
|
||||
})
|
||||
public async register(
|
||||
@Body() body: unknown,
|
||||
@Req() request: HttpRequest,
|
||||
@Res({ passthrough: true }) response: HttpResponse,
|
||||
) {
|
||||
const result = await this.authService.register(
|
||||
parse(registerRequestSchema, body),
|
||||
metadata(request),
|
||||
);
|
||||
setRefreshCookie(response, result.refreshToken);
|
||||
return { data: result.payload };
|
||||
}
|
||||
|
||||
@Post('login')
|
||||
@Public()
|
||||
@HttpCode(200)
|
||||
@ApiOperation({ summary: '使用邮箱和密码登录' })
|
||||
@ApiBody({
|
||||
schema: {
|
||||
example: {
|
||||
email: 'owner@example.com',
|
||||
password: 'a-strong-password',
|
||||
},
|
||||
},
|
||||
})
|
||||
public async login(
|
||||
@Body() body: unknown,
|
||||
@Req() request: HttpRequest,
|
||||
@Res({ passthrough: true }) response: HttpResponse,
|
||||
) {
|
||||
const result = await this.authService.login(
|
||||
parse(loginRequestSchema, body),
|
||||
metadata(request),
|
||||
);
|
||||
setRefreshCookie(response, result.refreshToken);
|
||||
return { data: result.payload };
|
||||
}
|
||||
|
||||
@Post('refresh')
|
||||
@Public()
|
||||
@HttpCode(200)
|
||||
@ApiOperation({ summary: '轮换 refresh cookie 并获取新的 access token' })
|
||||
public async refresh(
|
||||
@Req() request: HttpRequest,
|
||||
@Res({ passthrough: true }) response: HttpResponse,
|
||||
) {
|
||||
try {
|
||||
// refresh token 不进入 JSON body,仅从浏览器自动发送的 HttpOnly cookie 读取。
|
||||
const result = await this.authService.refresh(
|
||||
readCookie(request.header('cookie'), refreshCookie),
|
||||
metadata(request),
|
||||
);
|
||||
setRefreshCookie(response, result.refreshToken);
|
||||
return {
|
||||
data: {
|
||||
access_token: result.payload.access_token,
|
||||
access_token_expires_in:
|
||||
result.payload.access_token_expires_in,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
clearRefreshCookie(response);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@Post('logout')
|
||||
@HttpCode(204)
|
||||
@ApiBearerAuth('access-token')
|
||||
@ApiOperation({ summary: '撤销当前登录会话' })
|
||||
@ApiNoContentResponse({ description: '会话已撤销' })
|
||||
@ApiUnauthorizedResponse({ description: 'access token 无效或已过期' })
|
||||
public async logout(
|
||||
@Req() request: AuthenticatedRequest,
|
||||
@Res({ passthrough: true }) response: HttpResponse,
|
||||
) {
|
||||
await this.authService.logout(request.auth.sessionId);
|
||||
clearRefreshCookie(response);
|
||||
}
|
||||
|
||||
@Get('me')
|
||||
@ApiBearerAuth('access-token')
|
||||
@ApiOperation({ summary: '获取当前用户、工作空间和页头初始上下文' })
|
||||
@ApiOkResponse({ description: '当前身份和工作空间' })
|
||||
@ApiUnauthorizedResponse({ description: 'access token 无效或已过期' })
|
||||
public async me(
|
||||
@Req() request: AuthenticatedRequest,
|
||||
@CurrentWorkspace() workspace: Workspace,
|
||||
) {
|
||||
return {
|
||||
data: await this.authService.me(request.auth.userId, workspace),
|
||||
};
|
||||
}
|
||||
|
||||
@Patch('me')
|
||||
@ApiBearerAuth('access-token')
|
||||
@ApiOperation({ summary: '按版本更新当前账户的显示名称或头像素材引用' })
|
||||
@ApiBody({
|
||||
schema: {
|
||||
example: {
|
||||
display_name: 'Joey',
|
||||
avatar_asset_id: 'b2b592e8-96b0-4d2b-afc4-2c6462f2065b',
|
||||
},
|
||||
},
|
||||
})
|
||||
public async updateProfile(
|
||||
@Body() body: unknown,
|
||||
@Headers('if-match') ifMatch: string | undefined,
|
||||
@Headers('idempotency-key') idempotencyKey: string | undefined,
|
||||
@Req() request: AuthenticatedRequest,
|
||||
@CurrentWorkspace() workspace: Workspace,
|
||||
) {
|
||||
return (
|
||||
await this.authService.updateProfile(
|
||||
workspace,
|
||||
request.auth.userId,
|
||||
requireIdempotencyKey(idempotencyKey),
|
||||
parseRevision(ifMatch),
|
||||
parse(updateCurrentUserRequestSchema, body),
|
||||
)
|
||||
).body;
|
||||
}
|
||||
|
||||
@Post('change-password')
|
||||
@HttpCode(200)
|
||||
@ApiBearerAuth('access-token')
|
||||
@ApiOperation({ summary: '修改密码、撤销旧会话并轮换当前会话' })
|
||||
@ApiBody({
|
||||
schema: {
|
||||
example: {
|
||||
old_password: 'old-strong-password',
|
||||
password: 'new-strong-password',
|
||||
password_confirmation: 'new-strong-password',
|
||||
},
|
||||
},
|
||||
})
|
||||
public async changePassword(
|
||||
@Body() body: unknown,
|
||||
@Req() request: AuthenticatedRequest & HttpRequest,
|
||||
@CurrentWorkspace() workspace: Workspace,
|
||||
@Res({ passthrough: true }) response: HttpResponse,
|
||||
) {
|
||||
const result = await this.authService.changePassword(
|
||||
workspace,
|
||||
request.auth.userId,
|
||||
request.auth.sessionId,
|
||||
parse(changePasswordRequestSchema, body),
|
||||
metadata(request),
|
||||
);
|
||||
setRefreshCookie(response, result.refreshToken);
|
||||
return {
|
||||
data: {
|
||||
access_token: result.payload.access_token,
|
||||
access_token_expires_in: result.payload.access_token_expires_in,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function parse<
|
||||
T extends {
|
||||
safeParse: (value: unknown) => {
|
||||
success: boolean;
|
||||
data?: unknown;
|
||||
error?: { issues: Array<{ path: PropertyKey[]; message: string }> };
|
||||
};
|
||||
},
|
||||
>(schema: T, value: unknown) {
|
||||
const result = schema.safeParse(value);
|
||||
if (result.success)
|
||||
return result.data as ReturnType<T['safeParse']> extends {
|
||||
data: infer Data;
|
||||
}
|
||||
? Data
|
||||
: never;
|
||||
throw new ApiError(
|
||||
422,
|
||||
'VALIDATION_FAILED',
|
||||
'请求参数不符合要求',
|
||||
result.error?.issues.map((issue) => ({
|
||||
field: issue.path.join('.'),
|
||||
code: 'INVALID_FIELD',
|
||||
message: issue.message,
|
||||
})),
|
||||
);
|
||||
}
|
||||
function metadata(request: HttpRequest): RequestMetadata {
|
||||
return {
|
||||
ip: request.ip ?? null,
|
||||
ipHash: request.ip
|
||||
? createHash('sha256').update(request.ip).digest('base64url')
|
||||
: null,
|
||||
userAgent: request.header('user-agent') ?? null,
|
||||
};
|
||||
}
|
||||
function cookieOptions() {
|
||||
// cookie 路径限制在认证接口,避免无关请求携带长期会话凭证。
|
||||
return {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
sameSite: 'lax' as const,
|
||||
path: '/api/auth',
|
||||
maxAge: 30 * 24 * 60 * 60 * 1000,
|
||||
};
|
||||
}
|
||||
function setRefreshCookie(response: HttpResponse, token: string) {
|
||||
response.cookie(refreshCookie, token, cookieOptions());
|
||||
}
|
||||
function clearRefreshCookie(response: HttpResponse) {
|
||||
response.clearCookie(refreshCookie, {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
sameSite: 'lax',
|
||||
path: '/api/auth',
|
||||
});
|
||||
}
|
||||
function readCookie(header: string | undefined, name: string) {
|
||||
// 只解析本服务需要的单个 cookie,避免引入与认证无关的 cookie 状态。
|
||||
return header
|
||||
?.split(';')
|
||||
.map((part) => part.trim())
|
||||
.find((part) => part.startsWith(`${name}=`))
|
||||
?.slice(name.length + 1);
|
||||
}
|
||||
function requireIdempotencyKey(value: string | undefined): string {
|
||||
if (!value) {
|
||||
throw new ApiError(
|
||||
400,
|
||||
'IDEMPOTENCY_KEY_REQUIRED',
|
||||
'请求缺少 Idempotency-Key',
|
||||
);
|
||||
}
|
||||
if (z.uuid().safeParse(value).success) return value;
|
||||
throw new ApiError(422, 'VALIDATION_FAILED', '请求参数不符合要求', [
|
||||
{
|
||||
field: 'Idempotency-Key',
|
||||
code: 'INVALID_FIELD',
|
||||
message: 'Idempotency-Key 必须是 UUID',
|
||||
},
|
||||
]);
|
||||
}
|
||||
function parseRevision(value: string | undefined): number {
|
||||
const match = value?.match(/^"([1-9]\d*)"$/);
|
||||
if (!match) {
|
||||
throw new ApiError(
|
||||
400,
|
||||
'IF_MATCH_REQUIRED',
|
||||
'请求必须携带形如 "1" 的 If-Match',
|
||||
);
|
||||
}
|
||||
return Number(match[1]);
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import {
|
||||
createHash,
|
||||
createHmac,
|
||||
randomBytes,
|
||||
scrypt as scryptCallback,
|
||||
timingSafeEqual,
|
||||
} from 'node:crypto';
|
||||
import { promisify } from 'node:util';
|
||||
|
||||
const scrypt = promisify(scryptCallback) as (
|
||||
password: string,
|
||||
salt: string,
|
||||
keyLength: number,
|
||||
options: { N: number; r: number; p: number; maxmem: number },
|
||||
) => Promise<Buffer>;
|
||||
// 使用较高成本的 scrypt 参数,控制离线破解密码哈希时的时间与内存成本。
|
||||
const scryptOptions = { N: 16_384, r: 8, p: 1, maxmem: 32 * 1024 * 1024 };
|
||||
|
||||
export async function hashPassword(password: string): Promise<string> {
|
||||
// 每个密码使用独立随机盐,数据库泄露时不能直接复用预计算结果。
|
||||
const salt = randomBytes(16).toString('base64url');
|
||||
const derived = (await scrypt(password, salt, 64, scryptOptions)) as Buffer;
|
||||
return `scrypt$${salt}$${derived.toString('base64url')}`;
|
||||
}
|
||||
|
||||
export async function verifyPassword(
|
||||
password: string,
|
||||
encoded: string,
|
||||
): Promise<boolean> {
|
||||
const [algorithm, salt, expected] = encoded.split('$');
|
||||
if (algorithm !== 'scrypt' || !salt || !expected) return false;
|
||||
const actual = (await scrypt(password, salt, 64, scryptOptions)) as Buffer;
|
||||
const expectedBuffer = Buffer.from(expected, 'base64url');
|
||||
return (
|
||||
expectedBuffer.length === actual.length &&
|
||||
timingSafeEqual(expectedBuffer, actual)
|
||||
);
|
||||
}
|
||||
|
||||
export function createOpaqueToken(): string {
|
||||
return randomBytes(48).toString('base64url');
|
||||
}
|
||||
export function tokenHash(token: string): string {
|
||||
// refresh token 只以摘要形式存库;服务端无需保存可直接用于登录的原文。
|
||||
return createHash('sha256').update(token).digest('base64url');
|
||||
}
|
||||
|
||||
export type AccessTokenClaims = {
|
||||
sub: string;
|
||||
sid: string;
|
||||
exp: number;
|
||||
iat: number;
|
||||
};
|
||||
export function signAccessToken(
|
||||
claims: AccessTokenClaims,
|
||||
secret: string,
|
||||
): string {
|
||||
// access token 是短期自包含凭证;真正可撤销的会话状态仍以 PostgreSQL 为准。
|
||||
const header = Buffer.from(
|
||||
JSON.stringify({ alg: 'HS256', typ: 'JWT' }),
|
||||
).toString('base64url');
|
||||
const payload = Buffer.from(JSON.stringify(claims)).toString('base64url');
|
||||
const signature = createHmac('sha256', secret)
|
||||
.update(`${header}.${payload}`)
|
||||
.digest('base64url');
|
||||
return `${header}.${payload}.${signature}`;
|
||||
}
|
||||
export function verifyAccessToken(
|
||||
token: string,
|
||||
secret: string,
|
||||
): AccessTokenClaims | null {
|
||||
const [header, payload, signature] = token.split('.');
|
||||
if (!header || !payload || !signature) return null;
|
||||
const expected = createHmac('sha256', secret)
|
||||
.update(`${header}.${payload}`)
|
||||
.digest();
|
||||
const actual = Buffer.from(signature, 'base64url');
|
||||
// 恒定时间比较可避免根据签名比对耗时推测正确前缀。
|
||||
if (actual.length !== expected.length || !timingSafeEqual(actual, expected))
|
||||
return null;
|
||||
try {
|
||||
const claims = JSON.parse(
|
||||
Buffer.from(payload, 'base64url').toString('utf8'),
|
||||
) as AccessTokenClaims;
|
||||
return typeof claims.sub === 'string' &&
|
||||
typeof claims.sid === 'string' &&
|
||||
typeof claims.exp === 'number' &&
|
||||
claims.exp > Math.floor(Date.now() / 1000)
|
||||
? claims
|
||||
: null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import {
|
||||
CanActivate,
|
||||
ExecutionContext,
|
||||
Inject,
|
||||
Injectable,
|
||||
} from '@nestjs/common';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import type { Workspace } from '@atlas/contracts';
|
||||
import { IS_PUBLIC_ENDPOINT } from '../common/public.decorator.js';
|
||||
import { AuthService } from './auth.service.js';
|
||||
|
||||
export type AuthenticatedRequest = {
|
||||
header: (name: string) => string | undefined;
|
||||
auth: {
|
||||
userId: string;
|
||||
sessionId: string;
|
||||
workspace?: Workspace;
|
||||
};
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class AccessTokenGuard implements CanActivate {
|
||||
public constructor(
|
||||
@Inject(Reflector) private readonly reflector: Reflector,
|
||||
@Inject(AuthService) private readonly authService: AuthService,
|
||||
) {}
|
||||
public async canActivate(context: ExecutionContext) {
|
||||
// 公开端点必须显式声明,避免新路由因忘记添加守卫而意外暴露。
|
||||
if (
|
||||
this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_ENDPOINT, [
|
||||
context.getHandler(),
|
||||
context.getClass(),
|
||||
])
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
const request = context
|
||||
.switchToHttp()
|
||||
.getRequest<AuthenticatedRequest>();
|
||||
// 只在此处解析 Authorization,后续守卫和控制器共享已验证的会话身份。
|
||||
request.auth = await this.authService.authenticate(
|
||||
request.header('authorization'),
|
||||
);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { APP_GUARD } from '@nestjs/core';
|
||||
import { AuthController } from './auth.controller.js';
|
||||
import { AccessTokenGuard } from './auth.guard.js';
|
||||
import { AuthService } from './auth.service.js';
|
||||
import { WorkspaceContextGuard } from '../workspace/workspace-context.guard.js';
|
||||
import { IdempotencyService } from '../common/idempotency.service.js';
|
||||
|
||||
@Module({
|
||||
controllers: [AuthController],
|
||||
providers: [
|
||||
AuthService,
|
||||
IdempotencyService,
|
||||
AccessTokenGuard,
|
||||
WorkspaceContextGuard,
|
||||
{ provide: APP_GUARD, useExisting: AccessTokenGuard },
|
||||
{ provide: APP_GUARD, useExisting: WorkspaceContextGuard },
|
||||
],
|
||||
})
|
||||
export class AuthModule {}
|
||||
@@ -0,0 +1,512 @@
|
||||
import { Inject, Injectable, UnauthorizedException } from '@nestjs/common';
|
||||
import type {
|
||||
AuthSessionResponse,
|
||||
AuthUser,
|
||||
ChangePasswordRequest,
|
||||
LoginRequest,
|
||||
RegisterRequest,
|
||||
UpdateCurrentUserRequest,
|
||||
Workspace,
|
||||
} from '@atlas/contracts';
|
||||
import type { Database, DatabaseTransaction } from '@atlas/database';
|
||||
import { ApiError } from '../common/api-error.js';
|
||||
import { IdempotencyService } from '../common/idempotency.service.js';
|
||||
import { APP_CONFIG, type AppConfig } from '../config/app-config.module.js';
|
||||
import { DATABASE } from '../database/database.module.js';
|
||||
import { AuthRateLimiter } from '../security/auth-rate-limiter.service.js';
|
||||
import {
|
||||
createOpaqueToken,
|
||||
hashPassword,
|
||||
signAccessToken,
|
||||
tokenHash,
|
||||
verifyAccessToken,
|
||||
verifyPassword,
|
||||
} from './auth.crypto.js';
|
||||
|
||||
type UserRow = {
|
||||
id: string;
|
||||
email: string;
|
||||
display_name: string | null;
|
||||
avatar_asset_id: string | null;
|
||||
email_verified_at: Date | null;
|
||||
created_at: Date;
|
||||
revision: number;
|
||||
status: 'active' | 'disabled' | 'pending_review';
|
||||
password_hash: string;
|
||||
};
|
||||
type WorkspaceRow = { id: string; name: string; role: Workspace['role'] };
|
||||
|
||||
@Injectable()
|
||||
export class AuthService {
|
||||
// access token 短期存在于浏览器内存;refresh session 的有效期更长且可服务端撤销。
|
||||
private readonly accessTtlSeconds = 900;
|
||||
private readonly refreshTtlDays = 30;
|
||||
|
||||
public constructor(
|
||||
@Inject(DATABASE) private readonly database: Database,
|
||||
@Inject(APP_CONFIG) private readonly config: AppConfig,
|
||||
@Inject(AuthRateLimiter)
|
||||
private readonly rateLimiter: AuthRateLimiter,
|
||||
@Inject(IdempotencyService)
|
||||
private readonly idempotency: IdempotencyService,
|
||||
) {}
|
||||
|
||||
public async register(input: RegisterRequest, metadata: RequestMetadata) {
|
||||
if (input.password !== input.password_confirmation)
|
||||
throw validationError(
|
||||
'password_confirmation',
|
||||
'PASSWORD_CONFIRMATION_MISMATCH',
|
||||
'两次输入的密码不一致',
|
||||
);
|
||||
if (input.password.length < this.config.AUTH_PASSWORD_MIN_LENGTH)
|
||||
throw validationError(
|
||||
'password',
|
||||
'PASSWORD_TOO_SHORT',
|
||||
`密码至少需要 ${this.config.AUTH_PASSWORD_MIN_LENGTH} 个字符`,
|
||||
);
|
||||
const email = input.email.trim().toLowerCase();
|
||||
// 限流必须早于密码哈希和用户查询,避免攻击者消耗 CPU 或探测账户状态。
|
||||
await this.rateLimiter.consume('register', { ip: metadata.ip, email });
|
||||
const passwordHash = await hashPassword(input.password);
|
||||
try {
|
||||
return await this.database.transaction(async (transaction) => {
|
||||
// 用户、个人工作空间和 owner membership 必须原子创建,不能留下半成品租户。
|
||||
const user = await insertUser(
|
||||
transaction,
|
||||
email,
|
||||
passwordHash,
|
||||
input.display_name?.trim() || null,
|
||||
);
|
||||
const workspace = await createPersonalWorkspace(
|
||||
transaction,
|
||||
user.id,
|
||||
user.display_name ?? email,
|
||||
);
|
||||
return this.createSessionResponse(
|
||||
transaction,
|
||||
user,
|
||||
workspace,
|
||||
metadata,
|
||||
);
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
if (isUniqueViolation(error))
|
||||
throw new ApiError(
|
||||
409,
|
||||
'EMAIL_ALREADY_REGISTERED',
|
||||
'该邮箱已注册',
|
||||
[
|
||||
{
|
||||
field: 'email',
|
||||
code: 'EMAIL_ALREADY_REGISTERED',
|
||||
message: '该邮箱已注册',
|
||||
},
|
||||
],
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
public async login(input: LoginRequest, metadata: RequestMetadata) {
|
||||
const email = input.email.trim().toLowerCase();
|
||||
// 限流与 citext 查询使用同一规范化邮箱,避免大小写绕过限流桶。
|
||||
await this.rateLimiter.consume('login', { ip: metadata.ip, email });
|
||||
const result = await this.database.query<UserRow>(
|
||||
'SELECT id, email, display_name, avatar_asset_id, email_verified_at, created_at, revision, status, password_hash FROM atlas.users WHERE email = $1',
|
||||
[email],
|
||||
);
|
||||
const user = result.rows[0];
|
||||
if (
|
||||
!user ||
|
||||
!(await verifyPassword(input.password, user.password_hash))
|
||||
)
|
||||
throw new ApiError(401, 'INVALID_CREDENTIALS', '邮箱或密码错误');
|
||||
if (user.status === 'disabled')
|
||||
throw new ApiError(403, 'ACCOUNT_DISABLED', '账户已停用');
|
||||
const workspace = await this.findWorkspace(user.id);
|
||||
return this.database.transaction(async (transaction) => {
|
||||
await transaction.query(
|
||||
'UPDATE atlas.users SET last_login_at = now() WHERE id = $1',
|
||||
[user.id],
|
||||
);
|
||||
return this.createSessionResponse(
|
||||
transaction,
|
||||
user,
|
||||
workspace,
|
||||
metadata,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
public async refresh(
|
||||
refreshToken: string | undefined,
|
||||
metadata: RequestMetadata,
|
||||
) {
|
||||
// refresh token 验证前没有可信邮箱,只能按 IP 维度限流。
|
||||
await this.rateLimiter.consume('refresh', { ip: metadata.ip });
|
||||
if (!refreshToken)
|
||||
throw new ApiError(401, 'SESSION_EXPIRED', '登录会话已过期');
|
||||
return this.database.transaction(async (transaction) => {
|
||||
const sessions = await transaction.query<
|
||||
UserRow & { session_id: string }
|
||||
>(
|
||||
`SELECT u.id, u.email, u.display_name, u.avatar_asset_id, u.email_verified_at, u.created_at, u.revision, u.status, u.password_hash, s.id AS session_id
|
||||
FROM atlas.user_sessions s JOIN atlas.users u ON u.id = s.user_id
|
||||
WHERE s.refresh_token_hash = $1 AND s.revoked_at IS NULL AND s.expires_at > now() FOR UPDATE`,
|
||||
[tokenHash(refreshToken)],
|
||||
);
|
||||
const user = sessions.rows[0];
|
||||
if (!user || user.status !== 'active')
|
||||
throw new ApiError(401, 'SESSION_EXPIRED', '登录会话已过期');
|
||||
// 行锁保证并发 refresh 时旧 token 只能成功使用一次。
|
||||
await transaction.query(
|
||||
'UPDATE atlas.user_sessions SET revoked_at = now() WHERE id = $1',
|
||||
[user.session_id],
|
||||
);
|
||||
const workspace = await findWorkspaceInTransaction(
|
||||
transaction,
|
||||
user.id,
|
||||
);
|
||||
return this.createSessionResponse(
|
||||
transaction,
|
||||
user,
|
||||
workspace,
|
||||
metadata,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
public async logout(sessionId: string) {
|
||||
await this.database.query(
|
||||
'UPDATE atlas.user_sessions SET revoked_at = now() WHERE id = $1 AND revoked_at IS NULL',
|
||||
[sessionId],
|
||||
);
|
||||
}
|
||||
|
||||
public async authenticate(accessToken: string | undefined) {
|
||||
const token = accessToken?.replace(/^Bearer\s+/i, '');
|
||||
const claims = token
|
||||
? verifyAccessToken(token, this.config.AUTH_JWT_SECRET)
|
||||
: null;
|
||||
if (!claims) throw new UnauthorizedException();
|
||||
// JWT 签名有效仍不足够;必须确认对应 session 未撤销且未过期。
|
||||
const active = await this.database.query(
|
||||
'SELECT 1 FROM atlas.user_sessions WHERE id = $1 AND user_id = $2 AND revoked_at IS NULL AND expires_at > now()',
|
||||
[claims.sid, claims.sub],
|
||||
);
|
||||
if (!active.rowCount) throw new UnauthorizedException();
|
||||
return { userId: claims.sub, sessionId: claims.sid };
|
||||
}
|
||||
|
||||
public async me(userId: string, workspace: Workspace) {
|
||||
// workspace 由全局守卫解析,避免控制器或服务各自选择租户造成边界不一致。
|
||||
const users = await this.database.query<UserRow>(
|
||||
'SELECT id, email, display_name, avatar_asset_id, email_verified_at, created_at, revision, status, password_hash FROM atlas.users WHERE id = $1',
|
||||
[userId],
|
||||
);
|
||||
const user = users.rows[0];
|
||||
if (!user) throw new UnauthorizedException();
|
||||
const platforms = await this.database.workspaceTransaction(
|
||||
workspace.id,
|
||||
async (transaction) => {
|
||||
const rows = await transaction.query<{
|
||||
id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
}>(
|
||||
"SELECT id, code, name FROM atlas.platforms WHERE status = 'active' ORDER BY CASE WHEN code = 'OZON' THEN 0 ELSE 1 END, name",
|
||||
);
|
||||
return rows.rows;
|
||||
},
|
||||
);
|
||||
return {
|
||||
user: publicUser(user),
|
||||
workspace,
|
||||
platforms,
|
||||
suggested_context: {
|
||||
platform_id: platforms[0]?.id ?? null,
|
||||
default_store: null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
public async updateProfile(
|
||||
workspace: Workspace,
|
||||
userId: string,
|
||||
idempotencyKey: string,
|
||||
revision: number,
|
||||
input: UpdateCurrentUserRequest,
|
||||
) {
|
||||
return this.idempotency.run(
|
||||
{
|
||||
workspaceId: workspace.id,
|
||||
userId,
|
||||
method: 'PATCH',
|
||||
path: '/api/auth/me',
|
||||
key: idempotencyKey,
|
||||
request: { if_match: revision, ...input },
|
||||
},
|
||||
async (transaction) => {
|
||||
const user = await findUserInTransaction(
|
||||
transaction,
|
||||
userId,
|
||||
true,
|
||||
);
|
||||
if (user.revision !== revision) {
|
||||
throw versionConflict(user.revision);
|
||||
}
|
||||
if (input.avatar_asset_id) {
|
||||
// 头像只能引用当前工作空间内已完成入库的素材,不能接受任意对象路径或 URL。
|
||||
const asset = await transaction.query<{ id: string }>(
|
||||
`SELECT id FROM atlas.media_assets WHERE id = $1`,
|
||||
[input.avatar_asset_id],
|
||||
);
|
||||
if (!asset.rows[0]) {
|
||||
throw new ApiError(
|
||||
422,
|
||||
'AVATAR_ASSET_UNAVAILABLE',
|
||||
'头像素材不存在或不属于当前工作空间',
|
||||
);
|
||||
}
|
||||
}
|
||||
const updated = await transaction.query<UserRow>(
|
||||
`UPDATE atlas.users
|
||||
SET display_name = CASE WHEN $1 THEN $2 ELSE display_name END,
|
||||
avatar_asset_id = CASE WHEN $3 THEN $4::uuid ELSE avatar_asset_id END,
|
||||
revision = revision + 1, updated_at = now()
|
||||
WHERE id = $5
|
||||
RETURNING id, email, display_name, avatar_asset_id, email_verified_at,
|
||||
created_at, revision, status, password_hash`,
|
||||
[
|
||||
Object.hasOwn(input, 'display_name'),
|
||||
input.display_name ?? null,
|
||||
Object.hasOwn(input, 'avatar_asset_id'),
|
||||
input.avatar_asset_id ?? null,
|
||||
userId,
|
||||
],
|
||||
);
|
||||
return {
|
||||
status: 200,
|
||||
body: { data: publicUser(updated.rows[0]) },
|
||||
};
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
public async changePassword(
|
||||
workspace: Workspace,
|
||||
userId: string,
|
||||
sessionId: string,
|
||||
input: ChangePasswordRequest,
|
||||
metadata: RequestMetadata,
|
||||
) {
|
||||
if (input.password !== input.password_confirmation) {
|
||||
throw validationError(
|
||||
'password_confirmation',
|
||||
'PASSWORD_CONFIRMATION_MISMATCH',
|
||||
'两次输入的密码不一致',
|
||||
);
|
||||
}
|
||||
if (input.password.length < this.config.AUTH_PASSWORD_MIN_LENGTH) {
|
||||
throw validationError(
|
||||
'password',
|
||||
'PASSWORD_TOO_SHORT',
|
||||
`密码至少需要 ${this.config.AUTH_PASSWORD_MIN_LENGTH} 个字符`,
|
||||
);
|
||||
}
|
||||
// 修改密码按已认证用户和来源 IP 双维度限流,避免已泄露会话被用于高频猜测旧密码。
|
||||
await this.rateLimiter.consume('change_password', {
|
||||
ip: metadata.ip,
|
||||
userId,
|
||||
});
|
||||
return this.database.transaction(async (transaction) => {
|
||||
const session = await transaction.query<{ id: string }>(
|
||||
`SELECT id FROM atlas.user_sessions
|
||||
WHERE id = $1 AND user_id = $2 AND revoked_at IS NULL AND expires_at > now()
|
||||
FOR UPDATE`,
|
||||
[sessionId, userId],
|
||||
);
|
||||
if (!session.rows[0]) throw new UnauthorizedException();
|
||||
const user = await findUserInTransaction(transaction, userId, true);
|
||||
if (user.status !== 'active') {
|
||||
throw new ApiError(403, 'ACCOUNT_DISABLED', '账户已停用');
|
||||
}
|
||||
if (
|
||||
!(await verifyPassword(input.old_password, user.password_hash))
|
||||
) {
|
||||
throw validationError(
|
||||
'old_password',
|
||||
'CURRENT_PASSWORD_INVALID',
|
||||
'当前密码错误',
|
||||
);
|
||||
}
|
||||
const passwordHash = await hashPassword(input.password);
|
||||
const updated = await transaction.query<UserRow>(
|
||||
`UPDATE atlas.users SET password_hash = $1, updated_at = now()
|
||||
WHERE id = $2
|
||||
RETURNING id, email, display_name, avatar_asset_id, email_verified_at,
|
||||
created_at, revision, status, password_hash`,
|
||||
[passwordHash, userId],
|
||||
);
|
||||
// 先撤销所有旧 session,再创建新 session;旧 access token 会立即失效。
|
||||
await transaction.query(
|
||||
`UPDATE atlas.user_sessions SET revoked_at = now()
|
||||
WHERE user_id = $1 AND revoked_at IS NULL`,
|
||||
[userId],
|
||||
);
|
||||
return this.createSessionResponse(
|
||||
transaction,
|
||||
updated.rows[0],
|
||||
workspace,
|
||||
metadata,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
private async findWorkspace(userId: string) {
|
||||
const result = await this.database.query<WorkspaceRow>(
|
||||
`SELECT w.id, w.name, m.role FROM atlas.workspace_memberships m JOIN atlas.workspaces w ON w.id = m.workspace_id WHERE m.user_id = $1 AND w.status = 'active' ORDER BY m.joined_at LIMIT 1`,
|
||||
[userId],
|
||||
);
|
||||
return requireWorkspace(result.rows[0]);
|
||||
}
|
||||
private async createSessionResponse(
|
||||
transaction: DatabaseTransaction,
|
||||
user: UserRow,
|
||||
workspace: WorkspaceRow,
|
||||
metadata: RequestMetadata,
|
||||
) {
|
||||
// refresh token 仅通过 HttpOnly cookie 交给浏览器,响应体只暴露短期 access token。
|
||||
const refreshToken = createOpaqueToken();
|
||||
const session = await transaction.query<{ id: string }>(
|
||||
`INSERT INTO atlas.user_sessions (user_id, refresh_token_hash, expires_at, ip_hash, user_agent)
|
||||
VALUES ($1, $2, now() + ($3 || ' days')::interval, $4, $5) RETURNING id`,
|
||||
[
|
||||
user.id,
|
||||
tokenHash(refreshToken),
|
||||
this.refreshTtlDays,
|
||||
metadata.ipHash,
|
||||
metadata.userAgent,
|
||||
],
|
||||
);
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const accessToken = signAccessToken(
|
||||
{
|
||||
sub: user.id,
|
||||
sid: session.rows[0].id,
|
||||
iat: now,
|
||||
exp: now + this.accessTtlSeconds,
|
||||
},
|
||||
this.config.AUTH_JWT_SECRET,
|
||||
);
|
||||
const payload: AuthSessionResponse = {
|
||||
access_token: accessToken,
|
||||
access_token_expires_in: this.accessTtlSeconds,
|
||||
user: publicUser(user),
|
||||
workspace,
|
||||
};
|
||||
return { payload, refreshToken };
|
||||
}
|
||||
}
|
||||
|
||||
export type RequestMetadata = {
|
||||
ip: string | null;
|
||||
ipHash: string | null;
|
||||
userAgent: string | null;
|
||||
};
|
||||
function validationError(field: string, code: string, message: string) {
|
||||
return new ApiError(422, 'VALIDATION_FAILED', '请求参数不符合要求', [
|
||||
{ field, code, message },
|
||||
]);
|
||||
}
|
||||
function isUniqueViolation(error: unknown): error is { code: string } {
|
||||
return (
|
||||
typeof error === 'object' &&
|
||||
error !== null &&
|
||||
'code' in error &&
|
||||
(error as { code: string }).code === '23505'
|
||||
);
|
||||
}
|
||||
async function findUserInTransaction(
|
||||
transaction: DatabaseTransaction,
|
||||
userId: string,
|
||||
lock: boolean,
|
||||
): Promise<UserRow> {
|
||||
const result = await transaction.query<UserRow>(
|
||||
`SELECT id, email, display_name, avatar_asset_id, email_verified_at, created_at,
|
||||
revision, status, password_hash
|
||||
FROM atlas.users WHERE id = $1${lock ? ' FOR UPDATE' : ''}`,
|
||||
[userId],
|
||||
);
|
||||
if (!result.rows[0]) throw new UnauthorizedException();
|
||||
return result.rows[0];
|
||||
}
|
||||
function versionConflict(currentRevision: number) {
|
||||
return new ApiError(409, 'VERSION_CONFLICT', '账户资料已被其他操作更新', [
|
||||
{
|
||||
field: 'If-Match',
|
||||
code: 'VERSION_CONFLICT',
|
||||
message: `当前版本为 ${currentRevision}`,
|
||||
current_revision: currentRevision,
|
||||
},
|
||||
]);
|
||||
}
|
||||
function publicUser(user: UserRow): AuthUser {
|
||||
// 白名单映射防止 password_hash 等内部字段随查询结果泄露给客户端。
|
||||
return {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
display_name: user.display_name,
|
||||
avatar_asset_id: user.avatar_asset_id,
|
||||
email_verified_at: user.email_verified_at?.toISOString() ?? null,
|
||||
created_at: user.created_at.toISOString(),
|
||||
revision: user.revision,
|
||||
};
|
||||
}
|
||||
async function insertUser(
|
||||
transaction: DatabaseTransaction,
|
||||
email: string,
|
||||
passwordHash: string,
|
||||
displayName: string | null,
|
||||
) {
|
||||
// slug 使用用户 UUID,显示名称变化不会影响个人工作空间的稳定标识。
|
||||
const result = await transaction.query<UserRow>(
|
||||
'INSERT INTO atlas.users (email, password_hash, display_name) VALUES ($1, $2, $3) RETURNING id, email, display_name, avatar_asset_id, email_verified_at, created_at, revision, status, password_hash',
|
||||
[email, passwordHash, displayName],
|
||||
);
|
||||
return result.rows[0];
|
||||
}
|
||||
async function createPersonalWorkspace(
|
||||
transaction: DatabaseTransaction,
|
||||
userId: string,
|
||||
label: string,
|
||||
) {
|
||||
const workspace = await transaction.query<{ id: string; name: string }>(
|
||||
'INSERT INTO atlas.workspaces (name, slug) VALUES ($1, $2) RETURNING id, name',
|
||||
[`${label} 的工作空间`, `personal-${userId}`],
|
||||
);
|
||||
await transaction.query(
|
||||
"INSERT INTO atlas.workspace_memberships (workspace_id, user_id, role) VALUES ($1, $2, 'owner')",
|
||||
[workspace.rows[0].id, userId],
|
||||
);
|
||||
return { ...workspace.rows[0], role: 'owner' as const };
|
||||
}
|
||||
async function findWorkspaceInTransaction(
|
||||
transaction: DatabaseTransaction,
|
||||
userId: string,
|
||||
): Promise<WorkspaceRow> {
|
||||
const result = await transaction.query<WorkspaceRow>(
|
||||
`SELECT w.id, w.name, m.role FROM atlas.workspace_memberships m JOIN atlas.workspaces w ON w.id = m.workspace_id WHERE m.user_id = $1 AND w.status = 'active' ORDER BY m.joined_at LIMIT 1`,
|
||||
[userId],
|
||||
);
|
||||
return requireWorkspace(result.rows[0]);
|
||||
}
|
||||
function requireWorkspace(workspace: WorkspaceRow | undefined): WorkspaceRow {
|
||||
if (!workspace)
|
||||
throw new ApiError(
|
||||
403,
|
||||
'WORKSPACE_MEMBERSHIP_REQUIRED',
|
||||
'当前账户没有可用工作空间',
|
||||
);
|
||||
return workspace;
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import {
|
||||
ArgumentsHost,
|
||||
Catch,
|
||||
ExceptionFilter,
|
||||
HttpException,
|
||||
} from '@nestjs/common';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { ApiError } from './api-error.js';
|
||||
import { AppLogger } from '../logging/app-logger.js';
|
||||
|
||||
@Catch()
|
||||
export class ApiErrorFilter implements ExceptionFilter {
|
||||
public constructor(private readonly logger: AppLogger) {}
|
||||
|
||||
public catch(exception: unknown, host: ArgumentsHost) {
|
||||
const response = host.switchToHttp().getResponse<{
|
||||
status: (status: number) => {
|
||||
json: (body: unknown) => unknown;
|
||||
};
|
||||
}>();
|
||||
const request = host.switchToHttp().getRequest<{
|
||||
requestId?: string;
|
||||
method?: string;
|
||||
originalUrl?: string;
|
||||
}>();
|
||||
const requestId = request.requestId ?? `req_${randomUUID()}`;
|
||||
if (exception instanceof ApiError) {
|
||||
// 已知业务错误保留其稳定 code 与 details,方便前端精确处理。
|
||||
const body = exception.getResponse() as {
|
||||
error: Record<string, unknown>;
|
||||
};
|
||||
return response
|
||||
.status(exception.getStatus())
|
||||
.json({ error: { ...body.error, request_id: requestId } });
|
||||
}
|
||||
if (exception instanceof HttpException) {
|
||||
// Nest 内部异常不泄露原始 message,避免暴露认证或解析实现细节。
|
||||
return response.status(exception.getStatus()).json({
|
||||
error: {
|
||||
code: 'HTTP_ERROR',
|
||||
message: '请求无法处理',
|
||||
request_id: requestId,
|
||||
},
|
||||
});
|
||||
}
|
||||
this.logger.error(
|
||||
'unhandled_exception',
|
||||
exception instanceof Error ? exception.stack : undefined,
|
||||
{
|
||||
request_id: requestId,
|
||||
method: request.method,
|
||||
path: request.originalUrl,
|
||||
},
|
||||
);
|
||||
// 未预期异常只返回通用信息,request_id 用于从结构化日志定位根因。
|
||||
return response.status(500).json({
|
||||
error: {
|
||||
code: 'INTERNAL_ERROR',
|
||||
message: '服务器暂时无法处理请求',
|
||||
request_id: requestId,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { HttpException, HttpStatus } from '@nestjs/common';
|
||||
|
||||
export class ApiError extends HttpException {
|
||||
// 业务代码只能通过此类型抛出可预期错误,最终由全局过滤器统一封装为 error 信封。
|
||||
public constructor(
|
||||
status: HttpStatus,
|
||||
code: string,
|
||||
message: string,
|
||||
details?: unknown[],
|
||||
) {
|
||||
super({ error: { code, message, details } }, status);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
import { createHmac } from 'node:crypto';
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import type { Database, DatabaseTransaction } from '@atlas/database';
|
||||
import { APP_CONFIG, type AppConfig } from '../config/app-config.module.js';
|
||||
import { DATABASE } from '../database/database.module.js';
|
||||
import { ApiError } from './api-error.js';
|
||||
|
||||
type IdempotentResponse = { status: number; body: unknown };
|
||||
type IdempotencyInput = {
|
||||
workspaceId: string;
|
||||
userId: string;
|
||||
method: 'POST' | 'PUT' | 'PATCH' | 'DELETE';
|
||||
path: string;
|
||||
key: string;
|
||||
request: unknown;
|
||||
};
|
||||
type IdempotencyRow = {
|
||||
request_fingerprint: string;
|
||||
status: 'in_progress' | 'completed';
|
||||
response_status: number | null;
|
||||
response_body: unknown;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class IdempotencyService {
|
||||
public constructor(
|
||||
@Inject(DATABASE) private readonly database: Database,
|
||||
@Inject(APP_CONFIG) private readonly config: AppConfig,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 幂等记录与业务写入共用同一个工作空间事务。只有两者都提交,重试才会得到可靠的历史响应。
|
||||
*/
|
||||
public async run(
|
||||
input: IdempotencyInput,
|
||||
work: (transaction: DatabaseTransaction) => Promise<IdempotentResponse>,
|
||||
): Promise<IdempotentResponse> {
|
||||
const fingerprint = createHmac(
|
||||
'sha256',
|
||||
this.config.IDEMPOTENCY_KEY_SECRET,
|
||||
)
|
||||
.update(canonicalize(input.request))
|
||||
.digest('base64url');
|
||||
|
||||
return this.database.workspaceTransaction(
|
||||
input.workspaceId,
|
||||
async (transaction) => {
|
||||
const inserted = await transaction.query<IdempotencyRow>(
|
||||
`INSERT INTO atlas.idempotency_requests
|
||||
(workspace_id, user_id, http_method, request_path, idempotency_key, request_fingerprint)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
ON CONFLICT (workspace_id, user_id, http_method, request_path, idempotency_key) DO NOTHING
|
||||
RETURNING request_fingerprint, status, response_status, response_body`,
|
||||
[
|
||||
input.workspaceId,
|
||||
input.userId,
|
||||
input.method,
|
||||
input.path,
|
||||
input.key,
|
||||
fingerprint,
|
||||
],
|
||||
);
|
||||
if (!inserted.rows[0]) {
|
||||
// 冲突插入会等待首个事务结束;随后行锁确保只能读取完整的持久化结果。
|
||||
const existing = await transaction.query<IdempotencyRow>(
|
||||
`SELECT request_fingerprint, status, response_status, response_body
|
||||
FROM atlas.idempotency_requests
|
||||
WHERE workspace_id = $1 AND user_id = $2 AND http_method = $3
|
||||
AND request_path = $4 AND idempotency_key = $5
|
||||
FOR UPDATE`,
|
||||
[
|
||||
input.workspaceId,
|
||||
input.userId,
|
||||
input.method,
|
||||
input.path,
|
||||
input.key,
|
||||
],
|
||||
);
|
||||
const row = existing.rows[0];
|
||||
if (!row || row.request_fingerprint !== fingerprint) {
|
||||
throw new ApiError(
|
||||
409,
|
||||
'IDEMPOTENCY_KEY_REUSED',
|
||||
'幂等键已用于不同请求',
|
||||
);
|
||||
}
|
||||
if (row.status !== 'completed' || !row.response_status) {
|
||||
throw new ApiError(
|
||||
409,
|
||||
'IDEMPOTENCY_REQUEST_IN_PROGRESS',
|
||||
'相同请求仍在处理中',
|
||||
);
|
||||
}
|
||||
return {
|
||||
status: row.response_status,
|
||||
body: row.response_body,
|
||||
};
|
||||
}
|
||||
|
||||
const response = await work(transaction);
|
||||
await transaction.query(
|
||||
`UPDATE atlas.idempotency_requests
|
||||
SET status = 'completed', response_status = $1, response_body = $2,
|
||||
completed_at = now()
|
||||
WHERE workspace_id = $3 AND user_id = $4 AND http_method = $5
|
||||
AND request_path = $6 AND idempotency_key = $7`,
|
||||
[
|
||||
response.status,
|
||||
response.body,
|
||||
input.workspaceId,
|
||||
input.userId,
|
||||
input.method,
|
||||
input.path,
|
||||
input.key,
|
||||
],
|
||||
);
|
||||
return response;
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** 规范化排序后再签名,使 JSON 字段顺序不会改变同一请求的身份。 */
|
||||
function canonicalize(value: unknown): string {
|
||||
if (value === null || typeof value !== 'object')
|
||||
return JSON.stringify(value);
|
||||
if (Array.isArray(value)) return `[${value.map(canonicalize).join(',')}]`;
|
||||
const record = value as Record<string, unknown>;
|
||||
return `{${Object.keys(record)
|
||||
.sort()
|
||||
.map((key) => `${JSON.stringify(key)}:${canonicalize(record[key])}`)
|
||||
.join(',')}}`;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { SetMetadata } from '@nestjs/common';
|
||||
|
||||
export const IS_PUBLIC_ENDPOINT = 'atlas:is-public-endpoint';
|
||||
|
||||
// 全局认证守卫只跳过显式标记的端点,避免新接口因遗漏守卫而意外公开。
|
||||
export const Public = () => SetMetadata(IS_PUBLIC_ENDPOINT, true);
|
||||
@@ -0,0 +1,141 @@
|
||||
import { existsSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import { z } from 'zod';
|
||||
|
||||
export const APP_CONFIG = Symbol('APP_CONFIG');
|
||||
|
||||
function findEnvironmentFile(): string {
|
||||
const directories = [
|
||||
process.cwd(),
|
||||
resolve(process.cwd(), '..'),
|
||||
resolve(process.cwd(), '../..'),
|
||||
];
|
||||
const environmentFile = directories
|
||||
.map((directory) => resolve(directory, '.env'))
|
||||
.find((file) => existsSync(file));
|
||||
|
||||
return environmentFile ?? resolve(process.cwd(), '.env');
|
||||
}
|
||||
|
||||
// 此 schema 是 API 运行配置的唯一来源;默认值只允许用于不含秘密的本地开发场景。
|
||||
const environmentSchema = z.object({
|
||||
NODE_ENV: z
|
||||
.enum(['development', 'test', 'production'])
|
||||
.default('development'),
|
||||
API_PORT: z.coerce.number().int().min(1).max(65_535).default(8200),
|
||||
WEB_ORIGIN: z.url().default('http://localhost:8100'),
|
||||
DATABASE_URL: z.url(),
|
||||
REDIS_URL: z.url(),
|
||||
AUTH_JWT_SECRET: z.string().min(32),
|
||||
AUTH_RATE_LIMIT_KEY_SECRET: z.string().min(32),
|
||||
STORE_SECRET_ENCRYPTION_KEY: z.string().min(32),
|
||||
STORE_SECRET_ENCRYPTION_KEY_VERSION: z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(64)
|
||||
.default('v1'),
|
||||
IDEMPOTENCY_KEY_SECRET: z.string().min(32),
|
||||
AUTH_PASSWORD_MIN_LENGTH: z.coerce
|
||||
.number()
|
||||
.int()
|
||||
.min(12)
|
||||
.max(256)
|
||||
.default(12),
|
||||
API_BODY_LIMIT_BYTES: z.coerce
|
||||
.number()
|
||||
.int()
|
||||
.min(1_024)
|
||||
.max(10 * 1024 * 1024)
|
||||
.default(1_048_576),
|
||||
AUTH_RATE_LIMIT_WINDOW_SECONDS: z.coerce
|
||||
.number()
|
||||
.int()
|
||||
.min(1)
|
||||
.max(86_400)
|
||||
.default(900),
|
||||
AUTH_LOGIN_IP_LIMIT: z.coerce.number().int().min(1).max(1000).default(10),
|
||||
AUTH_LOGIN_EMAIL_LIMIT: z.coerce.number().int().min(1).max(1000).default(5),
|
||||
AUTH_REGISTER_IP_LIMIT: z.coerce.number().int().min(1).max(1000).default(5),
|
||||
AUTH_REGISTER_EMAIL_LIMIT: z.coerce
|
||||
.number()
|
||||
.int()
|
||||
.min(1)
|
||||
.max(1000)
|
||||
.default(3),
|
||||
AUTH_REFRESH_IP_LIMIT: z.coerce
|
||||
.number()
|
||||
.int()
|
||||
.min(1)
|
||||
.max(10_000)
|
||||
.default(30),
|
||||
AUTH_CHANGE_PASSWORD_IP_LIMIT: z.coerce
|
||||
.number()
|
||||
.int()
|
||||
.min(1)
|
||||
.max(1_000)
|
||||
.default(10),
|
||||
AUTH_CHANGE_PASSWORD_USER_LIMIT: z.coerce
|
||||
.number()
|
||||
.int()
|
||||
.min(1)
|
||||
.max(1_000)
|
||||
.default(5),
|
||||
TRUSTED_PROXY_CIDRS: z.string().default(''),
|
||||
SWAGGER_ENABLED: z.enum(['true', 'false']).default('true'),
|
||||
LOG_LEVEL: z.enum(['error', 'warn', 'log', 'debug']).default('log'),
|
||||
});
|
||||
|
||||
type Environment = z.infer<typeof environmentSchema>;
|
||||
export type AppConfig = Omit<
|
||||
Environment,
|
||||
'TRUSTED_PROXY_CIDRS' | 'SWAGGER_ENABLED'
|
||||
> & {
|
||||
trustedProxyCidrs: string[];
|
||||
swaggerEnabled: boolean;
|
||||
};
|
||||
|
||||
function validateEnvironment(
|
||||
environment: Record<string, unknown>,
|
||||
): Environment {
|
||||
const result = environmentSchema.safeParse(environment);
|
||||
if (result.success) return result.data;
|
||||
// 在 Nest 开始监听前失败,避免半配置 API 对外提供不安全服务。
|
||||
throw new Error(
|
||||
`Invalid environment configuration: ${result.error.issues.map((issue) => issue.path.join('.')).join(', ')}`,
|
||||
);
|
||||
}
|
||||
|
||||
function createAppConfig(environment: Environment): AppConfig {
|
||||
return {
|
||||
...environment,
|
||||
// 只有显式给出代理网段时才信任转发头,避免客户端伪造来源 IP。
|
||||
trustedProxyCidrs: environment.TRUSTED_PROXY_CIDRS.split(',')
|
||||
.map((value) => value.trim())
|
||||
.filter(Boolean),
|
||||
swaggerEnabled: environment.SWAGGER_ENABLED === 'true',
|
||||
};
|
||||
}
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
imports: [
|
||||
ConfigModule.forRoot({
|
||||
isGlobal: true,
|
||||
cache: true,
|
||||
// Turbo 从 apps/api 运行脚本,但共享开发环境文件位于仓库根目录。
|
||||
envFilePath: findEnvironmentFile(),
|
||||
}),
|
||||
],
|
||||
providers: [
|
||||
{
|
||||
provide: APP_CONFIG,
|
||||
// 导出规范化配置,业务服务不得散落读取 process.env。
|
||||
useFactory: (): AppConfig =>
|
||||
createAppConfig(validateEnvironment(process.env)),
|
||||
},
|
||||
],
|
||||
exports: [APP_CONFIG],
|
||||
})
|
||||
export class AppConfigModule {}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { Global, Inject, Module, OnModuleDestroy } from '@nestjs/common';
|
||||
import { createApplicationDatabase, type Database } from '@atlas/database';
|
||||
import { APP_CONFIG, type AppConfig } from '../config/app-config.module.js';
|
||||
|
||||
export const DATABASE = Symbol('DATABASE');
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [
|
||||
{
|
||||
provide: DATABASE,
|
||||
inject: [APP_CONFIG],
|
||||
// 业务代码只能拿到受限应用连接,迁移连接串绝不注入 API 容器。
|
||||
useFactory: (config: AppConfig): Database =>
|
||||
createApplicationDatabase(config.DATABASE_URL),
|
||||
},
|
||||
],
|
||||
exports: [DATABASE],
|
||||
})
|
||||
export class DatabaseModule implements OnModuleDestroy {
|
||||
public constructor(@Inject(DATABASE) private readonly database: Database) {}
|
||||
public async onModuleDestroy() {
|
||||
// Nest 关闭时主动释放连接池,避免测试和热重启遗留连接。
|
||||
await this.database.close();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import type { LoggerService } from '@nestjs/common';
|
||||
|
||||
type LogLevel = 'error' | 'warn' | 'log' | 'debug';
|
||||
const severity: Record<LogLevel, number> = {
|
||||
error: 0,
|
||||
warn: 1,
|
||||
log: 2,
|
||||
debug: 3,
|
||||
};
|
||||
// 按字段名递归脱敏,日志中不得出现会话、密码、密钥或授权头原文。
|
||||
const sensitiveKeys = /authorization|cookie|password|secret|token|api[_-]?key/i;
|
||||
|
||||
export class AppLogger implements LoggerService {
|
||||
public constructor(private readonly minimumLevel: LogLevel) {}
|
||||
|
||||
public log(message: unknown, context?: unknown): void {
|
||||
this.write('log', message, context);
|
||||
}
|
||||
|
||||
public error(message: unknown, stack?: string, context?: unknown): void {
|
||||
this.write('error', message, { stack, context });
|
||||
}
|
||||
|
||||
public warn(message: unknown, context?: unknown): void {
|
||||
this.write('warn', message, context);
|
||||
}
|
||||
|
||||
public debug(message: unknown, context?: unknown): void {
|
||||
this.write('debug', message, context);
|
||||
}
|
||||
|
||||
public verbose(message: unknown, context?: unknown): void {
|
||||
this.debug(message, context);
|
||||
}
|
||||
|
||||
private write(level: LogLevel, message: unknown, context?: unknown): void {
|
||||
if (severity[level] > severity[this.minimumLevel]) return;
|
||||
const entry = {
|
||||
timestamp: new Date().toISOString(),
|
||||
level,
|
||||
message: typeof message === 'string' ? message : sanitize(message),
|
||||
context: sanitize(context),
|
||||
};
|
||||
// 每行独立 JSON,方便日志采集系统按字段索引 request_id、状态码等上下文。
|
||||
const output = JSON.stringify(entry);
|
||||
if (level === 'error') console.error(output);
|
||||
else console.log(output);
|
||||
}
|
||||
}
|
||||
|
||||
function sanitize(value: unknown): unknown {
|
||||
if (Array.isArray(value)) return value.map(sanitize);
|
||||
if (value && typeof value === 'object') {
|
||||
return Object.fromEntries(
|
||||
Object.entries(value).map(([key, child]) => [
|
||||
key,
|
||||
sensitiveKeys.test(key) ? '[REDACTED]' : sanitize(child),
|
||||
]),
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
import 'reflect-metadata';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import { NestExpressApplication } from '@nestjs/platform-express';
|
||||
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
|
||||
import type { NextFunction, Request, Response } from 'express';
|
||||
import helmet from 'helmet';
|
||||
|
||||
import { AppModule } from './app.module.js';
|
||||
import { ApiErrorFilter } from './common/api-error.filter.js';
|
||||
import { APP_CONFIG, type AppConfig } from './config/app-config.module.js';
|
||||
import { AppLogger } from './logging/app-logger.js';
|
||||
|
||||
type RequestWithId = Request & {
|
||||
requestId?: string;
|
||||
};
|
||||
|
||||
export async function createApplication() {
|
||||
const app = await NestFactory.create<NestExpressApplication>(AppModule, {
|
||||
bodyParser: false,
|
||||
// 自定义日志器安装前保留 Nest 启动日志,避免配置错误没有可见诊断。
|
||||
logger: ['error', 'warn', 'log'],
|
||||
});
|
||||
const config = app.get<AppConfig>(APP_CONFIG);
|
||||
const logger = new AppLogger(config.LOG_LEVEL);
|
||||
app.useLogger(logger);
|
||||
|
||||
if (config.trustedProxyCidrs.length > 0) {
|
||||
// 仅在部署明确声明代理网段时信任转发头,避免伪造客户端 IP。
|
||||
app.getHttpAdapter()
|
||||
.getInstance()
|
||||
.set('trust proxy', config.trustedProxyCidrs);
|
||||
}
|
||||
|
||||
app.enableCors({
|
||||
origin: config.WEB_ORIGIN,
|
||||
credentials: true,
|
||||
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
|
||||
allowedHeaders: [
|
||||
'Authorization',
|
||||
'Content-Type',
|
||||
'Idempotency-Key',
|
||||
'If-Match',
|
||||
'X-Atlas-Platform-Id',
|
||||
'X-Request-Id',
|
||||
],
|
||||
exposedHeaders: ['X-Request-Id'],
|
||||
});
|
||||
// Helmet、body 限制、请求 ID 与全局错误过滤器必须在业务路由前注册。
|
||||
app.use(helmet({ contentSecurityPolicy: false }));
|
||||
app.useBodyParser('json', { limit: config.API_BODY_LIMIT_BYTES });
|
||||
app.useBodyParser('urlencoded', {
|
||||
limit: config.API_BODY_LIMIT_BYTES,
|
||||
extended: true,
|
||||
});
|
||||
app.use(
|
||||
(request: RequestWithId, response: Response, next: NextFunction) => {
|
||||
const typedRequest = request;
|
||||
const typedResponse = response;
|
||||
const suppliedId = typedRequest.headers['x-request-id'];
|
||||
// 合法调用方提供的 request ID 可贯穿上游链路;其他情况由服务端生成。
|
||||
const requestId =
|
||||
typeof suppliedId === 'string' &&
|
||||
/^[A-Za-z0-9_-]{8,128}$/.test(suppliedId)
|
||||
? suppliedId
|
||||
: `req_${randomUUID()}`;
|
||||
const startedAt = performance.now();
|
||||
typedRequest.requestId = requestId;
|
||||
typedResponse.setHeader('X-Request-Id', requestId);
|
||||
typedResponse.on('finish', () => {
|
||||
logger.log('http_request_completed', {
|
||||
request_id: requestId,
|
||||
method: typedRequest.method,
|
||||
path: typedRequest.originalUrl,
|
||||
status_code: typedResponse.statusCode,
|
||||
duration_ms: Math.round(performance.now() - startedAt),
|
||||
});
|
||||
});
|
||||
next();
|
||||
},
|
||||
);
|
||||
app.useGlobalFilters(new ApiErrorFilter(logger));
|
||||
app.setGlobalPrefix('api', { exclude: ['health', 'ready'] });
|
||||
app.enableShutdownHooks();
|
||||
|
||||
if (config.swaggerEnabled) {
|
||||
// Swagger 仅描述运行时已注册路由;冻结接口契约仍以 docs/api 为准。
|
||||
const swaggerConfig = new DocumentBuilder()
|
||||
.setTitle('Atlas Seller Kit API')
|
||||
.setDescription('Atlas 卖家工作台 HTTP API 文档')
|
||||
.setVersion('0.1.0')
|
||||
.addBearerAuth(
|
||||
{ type: 'http', scheme: 'bearer', bearerFormat: 'JWT' },
|
||||
'access-token',
|
||||
)
|
||||
.build();
|
||||
const swaggerDocument = SwaggerModule.createDocument(
|
||||
app,
|
||||
swaggerConfig,
|
||||
);
|
||||
SwaggerModule.setup('api-docs', app, swaggerDocument, {
|
||||
swaggerOptions: { persistAuthorization: true },
|
||||
});
|
||||
}
|
||||
|
||||
return { app, config, logger };
|
||||
}
|
||||
|
||||
export async function bootstrap() {
|
||||
const { app, config, logger } = await createApplication();
|
||||
await app.listen(config.API_PORT);
|
||||
logger.log('api_started', {
|
||||
port: config.API_PORT,
|
||||
swagger_enabled: config.swaggerEnabled,
|
||||
});
|
||||
}
|
||||
|
||||
// 测试复用应用工厂时不自动监听固定端口,只有直接运行入口才启动服务。
|
||||
if (
|
||||
process.argv[1] &&
|
||||
resolve(process.argv[1]) === fileURLToPath(import.meta.url)
|
||||
) {
|
||||
void bootstrap().catch((error: unknown) => {
|
||||
const details =
|
||||
error instanceof Error
|
||||
? {
|
||||
name: error.name,
|
||||
message: error.message,
|
||||
stack: error.stack,
|
||||
}
|
||||
: error;
|
||||
console.error(
|
||||
JSON.stringify({
|
||||
timestamp: new Date().toISOString(),
|
||||
level: 'error',
|
||||
message: 'api_startup_failed',
|
||||
error: details,
|
||||
}),
|
||||
);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import {
|
||||
Inject,
|
||||
Injectable,
|
||||
OnModuleDestroy,
|
||||
OnModuleInit,
|
||||
} from '@nestjs/common';
|
||||
import { createHmac } from 'node:crypto';
|
||||
import { Redis } from 'ioredis';
|
||||
import { ApiError } from '../common/api-error.js';
|
||||
import { APP_CONFIG, type AppConfig } from '../config/app-config.module.js';
|
||||
|
||||
type AuthAction = 'login' | 'register' | 'refresh' | 'change_password';
|
||||
type Limit = { ip: number; email?: number; user?: number };
|
||||
|
||||
@Injectable()
|
||||
export class AuthRateLimiter implements OnModuleInit, OnModuleDestroy {
|
||||
private readonly redis: Redis;
|
||||
|
||||
public constructor(@Inject(APP_CONFIG) private readonly config: AppConfig) {
|
||||
// Redis 让限流计数在多个 API 实例间共享,不会随单个实例重启清零。
|
||||
this.redis = new Redis(config.REDIS_URL, {
|
||||
enableOfflineQueue: false,
|
||||
lazyConnect: true,
|
||||
maxRetriesPerRequest: 1,
|
||||
});
|
||||
}
|
||||
|
||||
public async onModuleInit(): Promise<void> {
|
||||
// 在开始接收认证请求前完成连接,避免首个请求命中 Redis 连接竞态而被误判为 503。
|
||||
await this.redis.connect();
|
||||
}
|
||||
|
||||
public async consume(
|
||||
action: AuthAction,
|
||||
identifiers: { ip: string | null; email?: string; userId?: string },
|
||||
): Promise<void> {
|
||||
const limit = this.limitFor(action);
|
||||
await this.consumeBucket(
|
||||
action,
|
||||
'ip',
|
||||
identifiers.ip ?? 'unknown',
|
||||
limit.ip,
|
||||
);
|
||||
if (identifiers.email && limit.email) {
|
||||
await this.consumeBucket(
|
||||
action,
|
||||
'email',
|
||||
identifiers.email,
|
||||
limit.email,
|
||||
);
|
||||
}
|
||||
if (identifiers.userId && limit.user) {
|
||||
await this.consumeBucket(
|
||||
action,
|
||||
'user',
|
||||
identifiers.userId,
|
||||
limit.user,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public async onModuleDestroy(): Promise<void> {
|
||||
if (this.redis.status !== 'ready') {
|
||||
this.redis.disconnect();
|
||||
return;
|
||||
}
|
||||
await this.redis.quit();
|
||||
}
|
||||
|
||||
private async consumeBucket(
|
||||
action: AuthAction,
|
||||
dimension: 'ip' | 'email' | 'user',
|
||||
identifier: string,
|
||||
limit: number,
|
||||
): Promise<void> {
|
||||
const digest = createHmac(
|
||||
'sha256',
|
||||
this.config.AUTH_RATE_LIMIT_KEY_SECRET,
|
||||
)
|
||||
.update(identifier)
|
||||
.digest('base64url');
|
||||
// Redis key 只保存 IP/邮箱的 HMAC,不暴露可被日志或运维工具读取的原文。
|
||||
const key = `atlas:auth-rate-limit:${action}:${dimension}:${digest}`;
|
||||
try {
|
||||
// INCR 与首次 EXPIRE 必须原子执行,避免并发下产生永不过期的限流 key。
|
||||
const count = await this.redis.eval(
|
||||
"local value = redis.call('INCR', KEYS[1]); if value == 1 then redis.call('EXPIRE', KEYS[1], ARGV[1]); end; return value;",
|
||||
1,
|
||||
key,
|
||||
this.config.AUTH_RATE_LIMIT_WINDOW_SECONDS,
|
||||
);
|
||||
if (Number(count) > limit) {
|
||||
throw new ApiError(
|
||||
429,
|
||||
'AUTH_RATE_LIMITED',
|
||||
'请求过于频繁,请稍后重试',
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof ApiError) throw error;
|
||||
// 认证限流故障时必须拒绝请求;静默跳过会扩大暴力破解风险。
|
||||
throw new ApiError(
|
||||
503,
|
||||
'AUTH_RATE_LIMIT_UNAVAILABLE',
|
||||
'认证服务暂时不可用',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private limitFor(action: AuthAction): Limit {
|
||||
if (action === 'login') {
|
||||
return {
|
||||
ip: this.config.AUTH_LOGIN_IP_LIMIT,
|
||||
email: this.config.AUTH_LOGIN_EMAIL_LIMIT,
|
||||
};
|
||||
}
|
||||
if (action === 'register') {
|
||||
return {
|
||||
ip: this.config.AUTH_REGISTER_IP_LIMIT,
|
||||
email: this.config.AUTH_REGISTER_EMAIL_LIMIT,
|
||||
};
|
||||
}
|
||||
if (action === 'change_password') {
|
||||
return {
|
||||
ip: this.config.AUTH_CHANGE_PASSWORD_IP_LIMIT,
|
||||
user: this.config.AUTH_CHANGE_PASSWORD_USER_LIMIT,
|
||||
};
|
||||
}
|
||||
return { ip: this.config.AUTH_REFRESH_IP_LIMIT };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
import { AuthRateLimiter } from './auth-rate-limiter.service.js';
|
||||
|
||||
@Global()
|
||||
@Module({ providers: [AuthRateLimiter], exports: [AuthRateLimiter] })
|
||||
export class SecurityModule {}
|
||||
@@ -0,0 +1,125 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import type { Database, DatabaseTransaction } from '@atlas/database';
|
||||
import { ApiError } from '../common/api-error.js';
|
||||
import { DATABASE } from '../database/database.module.js';
|
||||
|
||||
type PlatformRow = {
|
||||
id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
capabilities: Record<string, unknown>;
|
||||
supported_currencies: string[];
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class PlatformsService {
|
||||
public constructor(@Inject(DATABASE) private readonly database: Database) {}
|
||||
|
||||
public async list() {
|
||||
const rows = await this.database.query<PlatformRow>(
|
||||
`SELECT p.id, p.code, p.name, p.capabilities,
|
||||
coalesce(array_agg(pcs.currency_code ORDER BY pcs.currency_code)
|
||||
FILTER (WHERE pcs.is_enabled AND c.is_enabled), ARRAY[]::char(3)[]) AS supported_currencies
|
||||
FROM atlas.platforms p
|
||||
LEFT JOIN atlas.platform_currency_support pcs ON pcs.platform_id = p.id
|
||||
LEFT JOIN atlas.currencies c ON c.code = pcs.currency_code
|
||||
WHERE p.status = 'active'
|
||||
GROUP BY p.id
|
||||
ORDER BY CASE WHEN p.code = 'OZON' THEN 0 ELSE 1 END, p.name`,
|
||||
);
|
||||
return rows.rows.map((platform) => ({
|
||||
id: platform.id,
|
||||
code: platform.code,
|
||||
name: platform.name,
|
||||
supported_currencies: platform.supported_currencies,
|
||||
// 能力未配置时使用 null,而不是把 Ozon 的市场或语言假设扩散到通用模型。
|
||||
default_market_code: stringCapability(
|
||||
platform.capabilities,
|
||||
'default_market_code',
|
||||
),
|
||||
default_locale: stringCapability(
|
||||
platform.capabilities,
|
||||
'default_locale',
|
||||
),
|
||||
}));
|
||||
}
|
||||
|
||||
public async context(workspaceId: string, platformId: string) {
|
||||
return this.database.workspaceTransaction(
|
||||
workspaceId,
|
||||
async (transaction) => {
|
||||
const platform = await this.requireActivePlatform(
|
||||
transaction,
|
||||
platformId,
|
||||
);
|
||||
const stores = await transaction.query<{
|
||||
id: string;
|
||||
name: string;
|
||||
status: string;
|
||||
credential_status: string;
|
||||
count: string;
|
||||
}>(
|
||||
`SELECT s.id, s.name, s.status, s.credential_status,
|
||||
count(*) OVER ()::text AS count
|
||||
FROM atlas.stores s
|
||||
WHERE s.platform_id = $1 AND s.deleted_at IS NULL AND s.is_default
|
||||
LIMIT 1`,
|
||||
[platformId],
|
||||
);
|
||||
// 默认门店不存在时仍要正确返回总数,不能用默认行的窗口函数猜测。
|
||||
const count = await transaction.query<{ count: string }>(
|
||||
`SELECT count(*)::text AS count FROM atlas.stores
|
||||
WHERE platform_id = $1 AND deleted_at IS NULL`,
|
||||
[platformId],
|
||||
);
|
||||
const defaultStore = stores.rows[0];
|
||||
return {
|
||||
platform: {
|
||||
id: platform.id,
|
||||
code: platform.code,
|
||||
name: platform.name,
|
||||
},
|
||||
default_store: defaultStore
|
||||
? {
|
||||
id: defaultStore.id,
|
||||
name: defaultStore.name,
|
||||
status: defaultStore.status,
|
||||
credential_status: defaultStore.credential_status,
|
||||
}
|
||||
: null,
|
||||
store_count: Number(count.rows[0]?.count ?? 0),
|
||||
};
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
private async requireActivePlatform(
|
||||
transaction: DatabaseTransaction,
|
||||
platformId: string,
|
||||
) {
|
||||
const rows = await transaction.query<{
|
||||
id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
}>(
|
||||
`SELECT id, code, name FROM atlas.platforms WHERE id = $1 AND status = 'active'`,
|
||||
[platformId],
|
||||
);
|
||||
if (!rows.rows[0]) {
|
||||
throw new ApiError(
|
||||
422,
|
||||
'PLATFORM_UNAVAILABLE',
|
||||
'平台不存在或已停用',
|
||||
);
|
||||
}
|
||||
return rows.rows[0];
|
||||
}
|
||||
}
|
||||
|
||||
function stringCapability(
|
||||
capabilities: Record<string, unknown>,
|
||||
key: string,
|
||||
): string | null {
|
||||
const value = capabilities[key];
|
||||
return typeof value === 'string' ? value : null;
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import {
|
||||
createCipheriv,
|
||||
createHash,
|
||||
createHmac,
|
||||
randomBytes,
|
||||
} from 'node:crypto';
|
||||
|
||||
export type EncryptedStoreSecret = {
|
||||
ciphertext: Buffer;
|
||||
nonce: Buffer;
|
||||
fingerprint: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* 密钥只在此边界短暂以明文存在。数据库保存 AES-GCM 密文和随机 nonce,
|
||||
* fingerprint 是不可逆 HMAC,仅用于安全地识别同一份密钥。
|
||||
*/
|
||||
export function encryptStoreSecret(
|
||||
plaintext: string,
|
||||
keyMaterial: string,
|
||||
): EncryptedStoreSecret {
|
||||
const key = createHash('sha256').update(keyMaterial).digest();
|
||||
const nonce = randomBytes(12);
|
||||
const cipher = createCipheriv('aes-256-gcm', key, nonce);
|
||||
const ciphertext = Buffer.concat([
|
||||
cipher.update(plaintext, 'utf8'),
|
||||
cipher.final(),
|
||||
cipher.getAuthTag(),
|
||||
]);
|
||||
return {
|
||||
ciphertext,
|
||||
nonce,
|
||||
fingerprint: createHmac('sha256', key)
|
||||
.update(plaintext)
|
||||
.digest('base64url'),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,340 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Headers,
|
||||
HttpCode,
|
||||
Inject,
|
||||
Param,
|
||||
Patch,
|
||||
Post,
|
||||
Query,
|
||||
Req,
|
||||
Res,
|
||||
} from '@nestjs/common';
|
||||
import {
|
||||
ApiAcceptedResponse,
|
||||
ApiBearerAuth,
|
||||
ApiNoContentResponse,
|
||||
ApiOkResponse,
|
||||
ApiOperation,
|
||||
ApiTags,
|
||||
} from '@nestjs/swagger';
|
||||
import {
|
||||
createStoreRequestSchema,
|
||||
listStoresQuerySchema,
|
||||
updateStoreRequestSchema,
|
||||
} from '@atlas/contracts';
|
||||
import { z } from 'zod';
|
||||
import { ApiError } from '../common/api-error.js';
|
||||
import type { AuthenticatedRequest } from '../auth/auth.guard.js';
|
||||
import { CurrentWorkspace } from '../workspace/current-workspace.decorator.js';
|
||||
import type { Workspace } from '@atlas/contracts';
|
||||
import { PlatformsService } from './platforms.service.js';
|
||||
import { StoresService } from './stores.service.js';
|
||||
|
||||
type HttpResponse = { status: (status: number) => HttpResponse };
|
||||
|
||||
@ApiTags('Platforms')
|
||||
@ApiBearerAuth('access-token')
|
||||
@Controller()
|
||||
export class PlatformsController {
|
||||
// tsx/esbuild 不生成 design:paramtypes 元数据,控制器依赖必须显式 @Inject(与 auth.controller 一致)
|
||||
public constructor(
|
||||
@Inject(PlatformsService)
|
||||
private readonly platformsService: PlatformsService,
|
||||
) {}
|
||||
|
||||
@Get('platforms')
|
||||
@ApiOperation({ summary: '获取已启用的平台及非敏感能力目录' })
|
||||
@ApiOkResponse({ description: '平台目录' })
|
||||
public async list() {
|
||||
return { data: await this.platformsService.list() };
|
||||
}
|
||||
|
||||
@Get('platform-context')
|
||||
@ApiOperation({ summary: '获取页头所选平台的默认门店上下文' })
|
||||
@ApiOkResponse({ description: '平台、默认门店和门店总数' })
|
||||
public async context(
|
||||
@Headers('x-atlas-platform-id') platformId: string | undefined,
|
||||
@CurrentWorkspace() workspace: Workspace,
|
||||
) {
|
||||
if (!platformId) {
|
||||
throw new ApiError(
|
||||
400,
|
||||
'PLATFORM_CONTEXT_REQUIRED',
|
||||
'请求缺少 X-Atlas-Platform-Id',
|
||||
);
|
||||
}
|
||||
return {
|
||||
data: await this.platformsService.context(
|
||||
workspace.id,
|
||||
requireUuid('X-Atlas-Platform-Id', platformId),
|
||||
),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@ApiTags('Stores')
|
||||
@ApiBearerAuth('access-token')
|
||||
@Controller('stores')
|
||||
export class StoresController {
|
||||
public constructor(
|
||||
@Inject(StoresService) private readonly storesService: StoresService,
|
||||
) {}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: '按平台分页列出当前工作空间的门店' })
|
||||
@ApiOkResponse({ description: '门店列表与不透明游标' })
|
||||
public async list(
|
||||
@Query() query: unknown,
|
||||
@Headers('x-atlas-platform-id') platformHeader: string | undefined,
|
||||
@CurrentWorkspace() workspace: Workspace,
|
||||
) {
|
||||
if (platformHeader) {
|
||||
throw new ApiError(
|
||||
400,
|
||||
'PLATFORM_CONTEXT_CONFLICT',
|
||||
'门店管理列表不能同时使用平台 header 和 platform_id 查询参数',
|
||||
);
|
||||
}
|
||||
return this.storesService.list(
|
||||
workspace.id,
|
||||
parse(listStoresQuerySchema, query),
|
||||
);
|
||||
}
|
||||
|
||||
@Get(':storeId')
|
||||
@ApiOperation({ summary: '读取单个门店' })
|
||||
public async get(
|
||||
@Param('storeId') storeId: string,
|
||||
@CurrentWorkspace() workspace: Workspace,
|
||||
) {
|
||||
return {
|
||||
data: await this.storesService.get(
|
||||
workspace.id,
|
||||
requireUuid('store_id', storeId),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
@Post()
|
||||
@ApiOperation({ summary: '创建门店连接,并请求异步凭据验证' })
|
||||
@ApiAcceptedResponse({ description: '已加密保存凭据,并创建验证操作' })
|
||||
public async create(
|
||||
@Body() body: unknown,
|
||||
@Headers('idempotency-key') idempotencyKey: string | undefined,
|
||||
@Req() request: AuthenticatedRequest,
|
||||
@CurrentWorkspace() workspace: Workspace,
|
||||
@Res({ passthrough: true }) response: HttpResponse,
|
||||
) {
|
||||
return withStatus(
|
||||
response,
|
||||
await this.storesService.create(
|
||||
workspace.id,
|
||||
request.auth.userId,
|
||||
requireIdempotencyKey(idempotencyKey),
|
||||
parse(createStoreRequestSchema, body),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@Patch(':storeId')
|
||||
@ApiOperation({ summary: '按版本编辑门店;更新密钥会重新请求验证' })
|
||||
@ApiOkResponse({ description: '普通字段已更新' })
|
||||
@ApiAcceptedResponse({ description: '新密钥已保存,并创建验证操作' })
|
||||
public async update(
|
||||
@Param('storeId') storeId: string,
|
||||
@Body() body: unknown,
|
||||
@Headers('idempotency-key') idempotencyKey: string | undefined,
|
||||
@Headers('if-match') ifMatch: string | undefined,
|
||||
@Req() request: AuthenticatedRequest,
|
||||
@CurrentWorkspace() workspace: Workspace,
|
||||
@Res({ passthrough: true }) response: HttpResponse,
|
||||
) {
|
||||
return withStatus(
|
||||
response,
|
||||
await this.storesService.update(
|
||||
workspace.id,
|
||||
request.auth.userId,
|
||||
requireUuid('store_id', storeId),
|
||||
requireIdempotencyKey(idempotencyKey),
|
||||
parseRevision(ifMatch),
|
||||
parse(updateStoreRequestSchema, body),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@Post(':storeId/set-default')
|
||||
@ApiOperation({ summary: '设为当前平台默认门店' })
|
||||
public async setDefault(
|
||||
@Param('storeId') storeId: string,
|
||||
@Headers('idempotency-key') idempotencyKey: string | undefined,
|
||||
@Req() request: AuthenticatedRequest,
|
||||
@CurrentWorkspace() workspace: Workspace,
|
||||
@Res({ passthrough: true }) response: HttpResponse,
|
||||
) {
|
||||
return withStatus(
|
||||
response,
|
||||
await this.storesService.setDefault(
|
||||
workspace.id,
|
||||
request.auth.userId,
|
||||
requireUuid('store_id', storeId),
|
||||
requireIdempotencyKey(idempotencyKey),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@Post(':storeId/credential-verifications')
|
||||
@ApiOperation({ summary: '手动请求重新验证门店凭据' })
|
||||
@ApiAcceptedResponse({ description: '凭据验证操作已入队' })
|
||||
public async credentialVerification(
|
||||
@Param('storeId') storeId: string,
|
||||
@Headers('idempotency-key') idempotencyKey: string | undefined,
|
||||
@Req() request: AuthenticatedRequest,
|
||||
@CurrentWorkspace() workspace: Workspace,
|
||||
@Res({ passthrough: true }) response: HttpResponse,
|
||||
) {
|
||||
return withStatus(
|
||||
response,
|
||||
await this.storesService.requestCredentialVerification(
|
||||
workspace.id,
|
||||
request.auth.userId,
|
||||
requireUuid('store_id', storeId),
|
||||
requireIdempotencyKey(idempotencyKey),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@Post(':storeId/warehouse-syncs')
|
||||
@ApiOperation({ summary: '请求异步同步门店仓库' })
|
||||
@ApiAcceptedResponse({ description: '仓库同步操作已入队' })
|
||||
public async warehouseSync(
|
||||
@Param('storeId') storeId: string,
|
||||
@Headers('idempotency-key') idempotencyKey: string | undefined,
|
||||
@Req() request: AuthenticatedRequest,
|
||||
@CurrentWorkspace() workspace: Workspace,
|
||||
@Res({ passthrough: true }) response: HttpResponse,
|
||||
) {
|
||||
return withStatus(
|
||||
response,
|
||||
await this.storesService.requestWarehouseSync(
|
||||
workspace.id,
|
||||
request.auth.userId,
|
||||
requireUuid('store_id', storeId),
|
||||
requireIdempotencyKey(idempotencyKey),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@Get(':storeId/warehouses')
|
||||
@ApiOperation({ summary: '读取最近成功同步的仓库摘要' })
|
||||
public async warehouses(
|
||||
@Param('storeId') storeId: string,
|
||||
@CurrentWorkspace() workspace: Workspace,
|
||||
) {
|
||||
return this.storesService.warehouses(
|
||||
workspace.id,
|
||||
requireUuid('store_id', storeId),
|
||||
);
|
||||
}
|
||||
|
||||
@Delete(':storeId')
|
||||
@HttpCode(204)
|
||||
@ApiOperation({ summary: '软删除门店连接并撤销当前密钥' })
|
||||
@ApiNoContentResponse({ description: '门店已删除' })
|
||||
public async remove(
|
||||
@Param('storeId') storeId: string,
|
||||
@Headers('idempotency-key') idempotencyKey: string | undefined,
|
||||
@Req() request: AuthenticatedRequest,
|
||||
@CurrentWorkspace() workspace: Workspace,
|
||||
@Res({ passthrough: true }) response: HttpResponse,
|
||||
) {
|
||||
return withStatus(
|
||||
response,
|
||||
await this.storesService.remove(
|
||||
workspace.id,
|
||||
request.auth.userId,
|
||||
requireUuid('store_id', storeId),
|
||||
requireIdempotencyKey(idempotencyKey),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ApiTags('Operations')
|
||||
@ApiBearerAuth('access-token')
|
||||
@Controller('operations')
|
||||
export class OperationsController {
|
||||
public constructor(
|
||||
@Inject(StoresService) private readonly storesService: StoresService,
|
||||
) {}
|
||||
|
||||
@Get(':operationId')
|
||||
@ApiOperation({ summary: '读取当前工作空间的异步操作状态' })
|
||||
public async get(
|
||||
@Param('operationId') operationId: string,
|
||||
@CurrentWorkspace() workspace: Workspace,
|
||||
) {
|
||||
return {
|
||||
data: await this.storesService.operation(
|
||||
workspace.id,
|
||||
requireUuid('operation_id', operationId),
|
||||
),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function withStatus(
|
||||
response: HttpResponse,
|
||||
result: { status: number; body: unknown },
|
||||
) {
|
||||
response.status(result.status);
|
||||
return result.body;
|
||||
}
|
||||
|
||||
function requireIdempotencyKey(value: string | undefined): string {
|
||||
if (!value) {
|
||||
throw new ApiError(
|
||||
400,
|
||||
'IDEMPOTENCY_KEY_REQUIRED',
|
||||
'请求缺少 Idempotency-Key',
|
||||
);
|
||||
}
|
||||
return requireUuid('Idempotency-Key', value);
|
||||
}
|
||||
|
||||
function requireUuid(field: string, value: string): string {
|
||||
if (z.uuid().safeParse(value).success) return value;
|
||||
throw new ApiError(422, 'VALIDATION_FAILED', '请求参数不符合要求', [
|
||||
{ field, code: 'INVALID_FIELD', message: `${field} 必须是 UUID` },
|
||||
]);
|
||||
}
|
||||
|
||||
function parseRevision(value: string | undefined): number {
|
||||
const match = value?.match(/^"([1-9]\d*)"$/);
|
||||
if (!match) {
|
||||
throw new ApiError(
|
||||
400,
|
||||
'IF_MATCH_REQUIRED',
|
||||
'请求必须携带形如 "1" 的 If-Match',
|
||||
);
|
||||
}
|
||||
return Number(match[1]);
|
||||
}
|
||||
|
||||
function parse<T>(schema: z.ZodType<T>, value: unknown): T {
|
||||
const result = schema.safeParse(value);
|
||||
if (result.success) return result.data;
|
||||
throw new ApiError(
|
||||
422,
|
||||
'VALIDATION_FAILED',
|
||||
'请求参数不符合要求',
|
||||
result.error.issues.map((issue) => ({
|
||||
field: issue.path.join('.'),
|
||||
code: 'INVALID_FIELD',
|
||||
message: issue.message,
|
||||
})),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { IdempotencyService } from '../common/idempotency.service.js';
|
||||
import {
|
||||
OperationsController,
|
||||
PlatformsController,
|
||||
StoresController,
|
||||
} from './stores.controller.js';
|
||||
import { PlatformsService } from './platforms.service.js';
|
||||
import { StoresService } from './stores.service.js';
|
||||
|
||||
@Module({
|
||||
controllers: [PlatformsController, StoresController, OperationsController],
|
||||
providers: [IdempotencyService, PlatformsService, StoresService],
|
||||
})
|
||||
export class StoresModule {}
|
||||
@@ -0,0 +1,730 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import type {
|
||||
CreateStoreRequest,
|
||||
ListStoresQuery,
|
||||
Operation,
|
||||
Store,
|
||||
UpdateStoreRequest,
|
||||
} from '@atlas/contracts';
|
||||
import type { Database, DatabaseTransaction } from '@atlas/database';
|
||||
import { ApiError } from '../common/api-error.js';
|
||||
import { IdempotencyService } from '../common/idempotency.service.js';
|
||||
import { APP_CONFIG, type AppConfig } from '../config/app-config.module.js';
|
||||
import { DATABASE } from '../database/database.module.js';
|
||||
import { encryptStoreSecret } from './store-secret.crypto.js';
|
||||
|
||||
type StoreRow = Omit<Store, keyof { created_at: never; updated_at: never }> & {
|
||||
credential_checked_at: Date | string | null;
|
||||
api_key_expires_at: Date | string | null;
|
||||
product_limit_checked_at: Date | string | null;
|
||||
warehouses_synced_at: Date | string | null;
|
||||
created_at: Date | string;
|
||||
updated_at: Date | string;
|
||||
};
|
||||
type StoreLockRow = {
|
||||
id: string;
|
||||
platform_id: string;
|
||||
revision: number;
|
||||
is_default: boolean;
|
||||
status: string;
|
||||
};
|
||||
type OperationRow = {
|
||||
id: string;
|
||||
kind: string;
|
||||
status: string;
|
||||
created_at: Date | string;
|
||||
completed_at: Date | string | null;
|
||||
error_code: string | null;
|
||||
error_message: string | null;
|
||||
};
|
||||
type WarehouseRow = {
|
||||
id: string;
|
||||
platform_warehouse_id: string;
|
||||
name: string;
|
||||
fulfillment: string;
|
||||
is_rfbs: boolean | null;
|
||||
status: string;
|
||||
synced_at: Date | string;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class StoresService {
|
||||
public constructor(
|
||||
@Inject(IdempotencyService)
|
||||
private readonly idempotency: IdempotencyService,
|
||||
@Inject(DATABASE) private readonly database: Database,
|
||||
@Inject(APP_CONFIG) private readonly config: AppConfig,
|
||||
) {}
|
||||
|
||||
public async list(workspaceId: string, query: ListStoresQuery) {
|
||||
const cursor = query.cursor ? decodeCursor(query.cursor) : null;
|
||||
return this.database.workspaceTransaction(
|
||||
workspaceId,
|
||||
async (transaction: DatabaseTransaction) => {
|
||||
const rows = await transaction.query<StoreRow>(
|
||||
`${storeSelect}
|
||||
WHERE s.platform_id = $1 AND s.deleted_at IS NULL
|
||||
AND ($2::timestamptz IS NULL OR (s.created_at, s.id) < ($2::timestamptz, $3::uuid))
|
||||
GROUP BY s.id, binding.value
|
||||
ORDER BY s.created_at DESC, s.id DESC
|
||||
LIMIT $4`,
|
||||
[
|
||||
query.platform_id,
|
||||
cursor?.createdAt ?? null,
|
||||
cursor?.id ?? null,
|
||||
query.limit + 1,
|
||||
],
|
||||
);
|
||||
const hasMore = rows.rows.length > query.limit;
|
||||
const items = rows.rows.slice(0, query.limit).map(toStore);
|
||||
const last = items.at(-1);
|
||||
return {
|
||||
data: items,
|
||||
meta: {
|
||||
next_cursor:
|
||||
hasMore && last
|
||||
? encodeCursor(last.created_at, last.id)
|
||||
: null,
|
||||
has_more: hasMore,
|
||||
},
|
||||
};
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
public async get(workspaceId: string, storeId: string): Promise<Store> {
|
||||
return this.database.workspaceTransaction(
|
||||
workspaceId,
|
||||
async (transaction: DatabaseTransaction) =>
|
||||
this.requireStore(transaction, storeId),
|
||||
);
|
||||
}
|
||||
|
||||
public async create(
|
||||
workspaceId: string,
|
||||
userId: string,
|
||||
idempotencyKey: string,
|
||||
input: CreateStoreRequest,
|
||||
) {
|
||||
return this.idempotency.run(
|
||||
{
|
||||
workspaceId,
|
||||
userId,
|
||||
method: 'POST',
|
||||
path: '/api/stores',
|
||||
key: idempotencyKey,
|
||||
request: input,
|
||||
},
|
||||
async (transaction) => {
|
||||
await this.assertPlatformCurrency(
|
||||
transaction,
|
||||
input.platform_id,
|
||||
input.currency_code,
|
||||
);
|
||||
// 同一平台的默认店铺修改串行化,避免两个并发创建都错误地判断自己是首店。
|
||||
await lockPlatform(transaction, workspaceId, input.platform_id);
|
||||
const existing = await transaction.query<{ id: string }>(
|
||||
`SELECT id FROM atlas.stores
|
||||
WHERE platform_id = $1 AND platform_client_id = $2
|
||||
LIMIT 1`,
|
||||
[input.platform_id, input.client_id],
|
||||
);
|
||||
if (existing.rows[0]) {
|
||||
throw new ApiError(
|
||||
409,
|
||||
'STORE_CONNECTION_ALREADY_EXISTS',
|
||||
'该平台账户已绑定为门店',
|
||||
);
|
||||
}
|
||||
const currentStores = await transaction.query<{
|
||||
count: string;
|
||||
}>(
|
||||
`SELECT count(*)::text AS count FROM atlas.stores
|
||||
WHERE platform_id = $1 AND deleted_at IS NULL`,
|
||||
[input.platform_id],
|
||||
);
|
||||
const becomesDefault =
|
||||
Number(currentStores.rows[0]?.count ?? 0) === 0 ||
|
||||
input.is_default;
|
||||
if (becomesDefault) {
|
||||
await transaction.query(
|
||||
`UPDATE atlas.stores SET is_default = false, revision = revision + 1, updated_at = now()
|
||||
WHERE platform_id = $1 AND is_default AND deleted_at IS NULL`,
|
||||
[input.platform_id],
|
||||
);
|
||||
}
|
||||
const created = await transaction.query<{ id: string }>(
|
||||
`INSERT INTO atlas.stores
|
||||
(workspace_id, platform_id, name, platform_client_id, currency_code, is_default, note)
|
||||
VALUES (current_setting('app.workspace_id')::uuid, $1, $2, $3, $4, $5, $6)
|
||||
RETURNING id`,
|
||||
[
|
||||
input.platform_id,
|
||||
input.name,
|
||||
input.client_id,
|
||||
input.currency_code,
|
||||
becomesDefault,
|
||||
input.note ?? null,
|
||||
],
|
||||
);
|
||||
const storeId = created.rows[0].id;
|
||||
await this.replaceApiKey(transaction, storeId, input.api_key);
|
||||
const operation = await this.createOperation(
|
||||
transaction,
|
||||
userId,
|
||||
storeId,
|
||||
'store_credential_verification',
|
||||
);
|
||||
return {
|
||||
status: 202,
|
||||
body: {
|
||||
data: {
|
||||
store: await this.requireStore(
|
||||
transaction,
|
||||
storeId,
|
||||
),
|
||||
operation,
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
public async update(
|
||||
workspaceId: string,
|
||||
userId: string,
|
||||
storeId: string,
|
||||
idempotencyKey: string,
|
||||
revision: number,
|
||||
input: UpdateStoreRequest,
|
||||
) {
|
||||
return this.idempotency.run(
|
||||
{
|
||||
workspaceId,
|
||||
userId,
|
||||
method: 'PATCH',
|
||||
path: `/api/stores/${storeId}`,
|
||||
key: idempotencyKey,
|
||||
request: { if_match: revision, ...input },
|
||||
},
|
||||
async (transaction) => {
|
||||
const current = await this.lockStore(transaction, storeId);
|
||||
this.assertRevision(current, revision);
|
||||
if (input.currency_code) {
|
||||
await this.assertPlatformCurrency(
|
||||
transaction,
|
||||
current.platform_id,
|
||||
input.currency_code,
|
||||
);
|
||||
}
|
||||
const replacingKey = input.api_key !== undefined;
|
||||
await transaction.query(
|
||||
`UPDATE atlas.stores
|
||||
SET name = COALESCE($1, name), currency_code = COALESCE($2, currency_code),
|
||||
note = CASE WHEN $3 THEN $4 ELSE note END,
|
||||
status = CASE WHEN $5 THEN 'verifying'::atlas.store_status ELSE status END,
|
||||
credential_status = CASE WHEN $5 THEN 'unknown'::atlas.credential_status ELSE credential_status END,
|
||||
last_error_code = CASE WHEN $5 THEN NULL ELSE last_error_code END,
|
||||
last_error_message = CASE WHEN $5 THEN NULL ELSE last_error_message END,
|
||||
last_error_at = CASE WHEN $5 THEN NULL ELSE last_error_at END,
|
||||
revision = revision + 1, updated_at = now()
|
||||
WHERE id = $6`,
|
||||
[
|
||||
input.name ?? null,
|
||||
input.currency_code ?? null,
|
||||
Object.hasOwn(input, 'note'),
|
||||
input.note ?? null,
|
||||
replacingKey,
|
||||
storeId,
|
||||
],
|
||||
);
|
||||
if (input.api_key !== undefined) {
|
||||
await this.replaceApiKey(
|
||||
transaction,
|
||||
storeId,
|
||||
input.api_key,
|
||||
);
|
||||
const operation = await this.createOperation(
|
||||
transaction,
|
||||
userId,
|
||||
storeId,
|
||||
'store_credential_verification',
|
||||
);
|
||||
return {
|
||||
status: 202,
|
||||
body: {
|
||||
data: {
|
||||
store: await this.requireStore(
|
||||
transaction,
|
||||
storeId,
|
||||
),
|
||||
operation,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
return {
|
||||
status: 200,
|
||||
body: {
|
||||
data: await this.requireStore(transaction, storeId),
|
||||
},
|
||||
};
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
public async setDefault(
|
||||
workspaceId: string,
|
||||
userId: string,
|
||||
storeId: string,
|
||||
idempotencyKey: string,
|
||||
) {
|
||||
return this.idempotency.run(
|
||||
{
|
||||
workspaceId,
|
||||
userId,
|
||||
method: 'POST',
|
||||
path: `/api/stores/${storeId}/set-default`,
|
||||
key: idempotencyKey,
|
||||
request: {},
|
||||
},
|
||||
async (transaction) => {
|
||||
const current = await this.lockStore(transaction, storeId);
|
||||
await lockPlatform(
|
||||
transaction,
|
||||
workspaceId,
|
||||
current.platform_id,
|
||||
);
|
||||
await transaction.query(
|
||||
`UPDATE atlas.stores SET is_default = false, revision = revision + 1, updated_at = now()
|
||||
WHERE platform_id = $1 AND is_default AND deleted_at IS NULL AND id <> $2`,
|
||||
[current.platform_id, storeId],
|
||||
);
|
||||
if (!current.is_default) {
|
||||
await transaction.query(
|
||||
`UPDATE atlas.stores SET is_default = true, revision = revision + 1, updated_at = now()
|
||||
WHERE id = $1`,
|
||||
[storeId],
|
||||
);
|
||||
}
|
||||
return {
|
||||
status: 200,
|
||||
body: {
|
||||
data: await this.requireStore(transaction, storeId),
|
||||
},
|
||||
};
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
public async requestCredentialVerification(
|
||||
workspaceId: string,
|
||||
userId: string,
|
||||
storeId: string,
|
||||
idempotencyKey: string,
|
||||
) {
|
||||
return this.requestOperation(
|
||||
workspaceId,
|
||||
userId,
|
||||
storeId,
|
||||
idempotencyKey,
|
||||
'credential-verifications',
|
||||
'store_credential_verification',
|
||||
false,
|
||||
);
|
||||
}
|
||||
|
||||
public async requestWarehouseSync(
|
||||
workspaceId: string,
|
||||
userId: string,
|
||||
storeId: string,
|
||||
idempotencyKey: string,
|
||||
) {
|
||||
return this.requestOperation(
|
||||
workspaceId,
|
||||
userId,
|
||||
storeId,
|
||||
idempotencyKey,
|
||||
'warehouse-syncs',
|
||||
'store_warehouse_sync',
|
||||
true,
|
||||
);
|
||||
}
|
||||
|
||||
public async remove(
|
||||
workspaceId: string,
|
||||
userId: string,
|
||||
storeId: string,
|
||||
idempotencyKey: string,
|
||||
) {
|
||||
return this.idempotency.run(
|
||||
{
|
||||
workspaceId,
|
||||
userId,
|
||||
method: 'DELETE',
|
||||
path: `/api/stores/${storeId}`,
|
||||
key: idempotencyKey,
|
||||
request: {},
|
||||
},
|
||||
async (transaction) => {
|
||||
const current = await this.lockStore(transaction, storeId);
|
||||
if (current.is_default) {
|
||||
const replacement = await transaction.query<{ id: string }>(
|
||||
`SELECT id FROM atlas.stores
|
||||
WHERE platform_id = $1 AND id <> $2 AND deleted_at IS NULL LIMIT 1`,
|
||||
[current.platform_id, storeId],
|
||||
);
|
||||
if (replacement.rows[0]) {
|
||||
throw new ApiError(
|
||||
422,
|
||||
'DEFAULT_STORE_REPLACEMENT_REQUIRED',
|
||||
'删除默认门店前必须先选择同平台的其他默认门店',
|
||||
);
|
||||
}
|
||||
}
|
||||
await transaction.query(
|
||||
`UPDATE atlas.stores
|
||||
SET deleted_at = now(), status = 'deleted', is_default = false,
|
||||
revision = revision + 1, updated_at = now()
|
||||
WHERE id = $1`,
|
||||
[storeId],
|
||||
);
|
||||
// 删除连接后立刻撤销可用密钥,避免后台任务或未来恢复流程继续使用它。
|
||||
await transaction.query(
|
||||
`UPDATE atlas.store_secrets SET revoked_at = now(), updated_at = now()
|
||||
WHERE store_id = $1 AND revoked_at IS NULL`,
|
||||
[storeId],
|
||||
);
|
||||
return { status: 204, body: null };
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
public async warehouses(workspaceId: string, storeId: string) {
|
||||
return this.database.workspaceTransaction(
|
||||
workspaceId,
|
||||
async (transaction: DatabaseTransaction) => {
|
||||
const store = await this.requireStore(transaction, storeId);
|
||||
const rows = await transaction.query<WarehouseRow>(
|
||||
`SELECT id, platform_warehouse_id, name, fulfillment, is_rfbs, status, synced_at
|
||||
FROM atlas.store_warehouses WHERE store_id = $1 ORDER BY name, id`,
|
||||
[storeId],
|
||||
);
|
||||
return {
|
||||
data: rows.rows.map((row) => ({
|
||||
...row,
|
||||
synced_at: toIso(row.synced_at),
|
||||
})),
|
||||
meta: { warehouses_synced_at: store.warehouses_synced_at },
|
||||
};
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
public async operation(workspaceId: string, operationId: string) {
|
||||
return this.database.workspaceTransaction(
|
||||
workspaceId,
|
||||
async (transaction: DatabaseTransaction) => {
|
||||
const rows = await transaction.query<OperationRow>(
|
||||
`SELECT id, kind, status, created_at, completed_at, error_code, error_message
|
||||
FROM atlas.operations WHERE id = $1`,
|
||||
[operationId],
|
||||
);
|
||||
if (!rows.rows[0]) throw notFound('操作');
|
||||
return toOperation(rows.rows[0]);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
private async requestOperation(
|
||||
workspaceId: string,
|
||||
userId: string,
|
||||
storeId: string,
|
||||
idempotencyKey: string,
|
||||
pathSuffix: string,
|
||||
kind: string,
|
||||
requiresValidCredential: boolean,
|
||||
) {
|
||||
return this.idempotency.run(
|
||||
{
|
||||
workspaceId,
|
||||
userId,
|
||||
method: 'POST',
|
||||
path: `/api/stores/${storeId}/${pathSuffix}`,
|
||||
key: idempotencyKey,
|
||||
request: {},
|
||||
},
|
||||
async (transaction) => {
|
||||
const store = await this.requireStore(transaction, storeId);
|
||||
if (store.status === 'disabled') {
|
||||
throw new ApiError(422, 'STORE_DISABLED', '门店已停用');
|
||||
}
|
||||
if (
|
||||
requiresValidCredential &&
|
||||
store.credential_status !== 'valid'
|
||||
) {
|
||||
throw new ApiError(
|
||||
422,
|
||||
'STORE_CREDENTIAL_UNAVAILABLE',
|
||||
'门店凭据尚未验证通过,无法同步仓库',
|
||||
);
|
||||
}
|
||||
const operation = await this.createOperation(
|
||||
transaction,
|
||||
userId,
|
||||
storeId,
|
||||
kind,
|
||||
);
|
||||
return { status: 202, body: { data: { operation } } };
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
private async assertPlatformCurrency(
|
||||
transaction: DatabaseTransaction,
|
||||
platformId: string,
|
||||
currencyCode: string,
|
||||
) {
|
||||
const platform = await transaction.query<{ id: string }>(
|
||||
`SELECT id FROM atlas.platforms WHERE id = $1 AND status = 'active'`,
|
||||
[platformId],
|
||||
);
|
||||
if (!platform.rows[0]) {
|
||||
throw new ApiError(
|
||||
422,
|
||||
'PLATFORM_UNAVAILABLE',
|
||||
'平台不存在或已停用',
|
||||
);
|
||||
}
|
||||
const supported = await transaction.query<{ currency_code: string }>(
|
||||
`SELECT pcs.currency_code FROM atlas.platform_currency_support pcs
|
||||
JOIN atlas.currencies c ON c.code = pcs.currency_code
|
||||
WHERE pcs.platform_id = $1 AND pcs.currency_code = $2
|
||||
AND pcs.is_enabled AND c.is_enabled`,
|
||||
[platformId, currencyCode],
|
||||
);
|
||||
if (!supported.rows[0]) {
|
||||
throw new ApiError(
|
||||
422,
|
||||
'PLATFORM_CURRENCY_UNSUPPORTED',
|
||||
'该平台不支持此默认计价币种',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async lockStore(transaction: DatabaseTransaction, storeId: string) {
|
||||
const rows = await transaction.query<StoreLockRow>(
|
||||
`SELECT id, platform_id, revision, is_default, status
|
||||
FROM atlas.stores WHERE id = $1 AND deleted_at IS NULL FOR UPDATE`,
|
||||
[storeId],
|
||||
);
|
||||
if (!rows.rows[0]) throw notFound('门店');
|
||||
return rows.rows[0];
|
||||
}
|
||||
|
||||
private assertRevision(current: StoreLockRow, expected: number) {
|
||||
if (current.revision !== expected) {
|
||||
throw new ApiError(
|
||||
409,
|
||||
'VERSION_CONFLICT',
|
||||
'门店已被其他操作更新',
|
||||
[
|
||||
{
|
||||
field: 'If-Match',
|
||||
code: 'VERSION_CONFLICT',
|
||||
message: `当前版本为 ${current.revision}`,
|
||||
current_revision: current.revision,
|
||||
},
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async replaceApiKey(
|
||||
transaction: DatabaseTransaction,
|
||||
storeId: string,
|
||||
apiKey: string,
|
||||
) {
|
||||
const secret = encryptStoreSecret(
|
||||
apiKey,
|
||||
this.config.STORE_SECRET_ENCRYPTION_KEY,
|
||||
);
|
||||
const existing = await transaction.query<{ id: string }>(
|
||||
`SELECT id FROM atlas.store_secrets
|
||||
WHERE store_id = $1 AND kind = 'seller_api_key' AND fingerprint = $2
|
||||
FOR UPDATE`,
|
||||
[storeId, secret.fingerprint],
|
||||
);
|
||||
await transaction.query(
|
||||
`UPDATE atlas.store_secrets SET revoked_at = now(), updated_at = now()
|
||||
WHERE store_id = $1 AND kind = 'seller_api_key' AND revoked_at IS NULL`,
|
||||
[storeId],
|
||||
);
|
||||
if (existing.rows[0]) {
|
||||
// 同一密钥可被重新提交用于验证;恢复历史行避免违反防重指纹唯一约束。
|
||||
await transaction.query(
|
||||
`UPDATE atlas.store_secrets
|
||||
SET secret_ciphertext = $1, secret_nonce = $2, encryption_key_version = $3,
|
||||
status = 'unknown', revoked_at = NULL, updated_at = now()
|
||||
WHERE id = $4`,
|
||||
[
|
||||
secret.ciphertext,
|
||||
secret.nonce,
|
||||
this.config.STORE_SECRET_ENCRYPTION_KEY_VERSION,
|
||||
existing.rows[0].id,
|
||||
],
|
||||
);
|
||||
return;
|
||||
}
|
||||
await transaction.query(
|
||||
`INSERT INTO atlas.store_secrets
|
||||
(workspace_id, store_id, kind, secret_ciphertext, secret_nonce, encryption_key_version, fingerprint)
|
||||
VALUES (current_setting('app.workspace_id')::uuid, $1, 'seller_api_key', $2, $3, $4, $5)`,
|
||||
[
|
||||
storeId,
|
||||
secret.ciphertext,
|
||||
secret.nonce,
|
||||
this.config.STORE_SECRET_ENCRYPTION_KEY_VERSION,
|
||||
secret.fingerprint,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
private async createOperation(
|
||||
transaction: DatabaseTransaction,
|
||||
userId: string,
|
||||
storeId: string,
|
||||
kind: string,
|
||||
): Promise<Operation> {
|
||||
const created = await transaction.query<OperationRow>(
|
||||
`INSERT INTO atlas.operations
|
||||
(workspace_id, kind, resource_type, resource_id, created_by_user_id)
|
||||
VALUES (current_setting('app.workspace_id')::uuid, $1, 'store', $2, $3)
|
||||
RETURNING id, kind, status, created_at, completed_at, error_code, error_message`,
|
||||
[kind, storeId, userId],
|
||||
);
|
||||
const operation = toOperation(created.rows[0]);
|
||||
await transaction.query(
|
||||
`INSERT INTO atlas.outbox_events
|
||||
(workspace_id, aggregate_type, aggregate_id, event_type, event_payload, deduplication_key)
|
||||
VALUES (current_setting('app.workspace_id')::uuid, 'store', $1, $2, $3, $4)`,
|
||||
[
|
||||
storeId,
|
||||
`${kind}.requested`,
|
||||
{ store_id: storeId, operation_id: operation.id },
|
||||
operation.id,
|
||||
],
|
||||
);
|
||||
return operation;
|
||||
}
|
||||
|
||||
private async requireStore(
|
||||
transaction: DatabaseTransaction,
|
||||
storeId: string,
|
||||
): Promise<Store> {
|
||||
const rows = await transaction.query<StoreRow>(
|
||||
`${storeSelect}
|
||||
WHERE s.id = $1 AND s.deleted_at IS NULL
|
||||
GROUP BY s.id, binding.value`,
|
||||
[storeId],
|
||||
);
|
||||
if (!rows.rows[0]) throw notFound('门店');
|
||||
return toStore(rows.rows[0]);
|
||||
}
|
||||
}
|
||||
|
||||
const storeSelect = `
|
||||
SELECT s.id, s.platform_id, s.name, s.platform_client_id, s.currency_code,
|
||||
s.is_default, s.note, s.status, s.credential_status, s.credential_checked_at,
|
||||
CASE WHEN s.last_error_code IS NULL THEN NULL
|
||||
ELSE jsonb_build_object('code', s.last_error_code, 'message', s.last_error_message, 'at', s.last_error_at)
|
||||
END AS last_error,
|
||||
s.api_key_expires_at, s.product_limit, s.product_limit_checked_at,
|
||||
count(sw.id)::integer AS warehouse_count, max(sw.synced_at) AS warehouses_synced_at,
|
||||
binding.value AS watermark_template, s.created_at, s.updated_at, s.revision
|
||||
FROM atlas.stores s
|
||||
LEFT JOIN atlas.store_warehouses sw ON sw.store_id = s.id
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT jsonb_build_object('id', wt.id, 'name', wt.name) AS value
|
||||
FROM atlas.store_watermark_bindings wb
|
||||
JOIN atlas.watermark_templates wt ON wt.id = wb.template_id
|
||||
WHERE wb.store_id = s.id AND wb.unbound_at IS NULL
|
||||
LIMIT 1
|
||||
) binding ON true`;
|
||||
|
||||
function toStore(row: StoreRow): Store {
|
||||
return {
|
||||
...row,
|
||||
credential_checked_at: toIso(row.credential_checked_at),
|
||||
api_key_expires_at: toIso(row.api_key_expires_at),
|
||||
product_limit_checked_at: toIso(row.product_limit_checked_at),
|
||||
warehouses_synced_at: toIso(row.warehouses_synced_at),
|
||||
created_at: toRequiredIso(row.created_at),
|
||||
updated_at: toRequiredIso(row.updated_at),
|
||||
cookie_status: null,
|
||||
};
|
||||
}
|
||||
|
||||
function toOperation(row: OperationRow): Operation {
|
||||
return {
|
||||
id: row.id,
|
||||
kind: row.kind,
|
||||
status: row.status,
|
||||
created_at: toRequiredIso(row.created_at),
|
||||
completed_at: toIso(row.completed_at),
|
||||
error:
|
||||
row.error_code && row.error_message
|
||||
? { code: row.error_code, message: row.error_message }
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
function toIso(value: Date | string | null): string | null {
|
||||
if (value === null) return null;
|
||||
return value instanceof Date ? value.toISOString() : value;
|
||||
}
|
||||
|
||||
function toRequiredIso(value: Date | string): string {
|
||||
return value instanceof Date ? value.toISOString() : value;
|
||||
}
|
||||
|
||||
function notFound(resource: string) {
|
||||
return new ApiError(404, 'RESOURCE_NOT_FOUND', `${resource}不存在`);
|
||||
}
|
||||
|
||||
async function lockPlatform(
|
||||
transaction: DatabaseTransaction,
|
||||
workspaceId: string,
|
||||
platformId: string,
|
||||
) {
|
||||
await transaction.query(
|
||||
`SELECT pg_advisory_xact_lock(hashtextextended($1, 0))`,
|
||||
[`${workspaceId}:${platformId}`],
|
||||
);
|
||||
}
|
||||
|
||||
function encodeCursor(createdAt: string, id: string): string {
|
||||
return Buffer.from(JSON.stringify({ createdAt, id })).toString('base64url');
|
||||
}
|
||||
|
||||
function decodeCursor(cursor: string): { createdAt: string; id: string } {
|
||||
try {
|
||||
const value = JSON.parse(
|
||||
Buffer.from(cursor, 'base64url').toString('utf8'),
|
||||
) as { createdAt?: unknown; id?: unknown };
|
||||
if (
|
||||
typeof value.createdAt !== 'string' ||
|
||||
Number.isNaN(Date.parse(value.createdAt)) ||
|
||||
typeof value.id !== 'string' ||
|
||||
!/^[0-9a-f]{8}-[0-9a-f-]{27}$/i.test(value.id)
|
||||
) {
|
||||
throw new Error('invalid cursor');
|
||||
}
|
||||
return { createdAt: value.createdAt, id: value.id };
|
||||
} catch {
|
||||
throw new ApiError(422, 'VALIDATION_FAILED', '分页游标无效', [
|
||||
{ field: 'cursor', code: 'INVALID_FIELD', message: '分页游标无效' },
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { createParamDecorator, type ExecutionContext } from '@nestjs/common';
|
||||
import type { Workspace } from '@atlas/contracts';
|
||||
import type { WorkspaceAuthenticatedRequest } from './workspace-context.guard.js';
|
||||
|
||||
// 受保护接口经全局工作空间守卫验证后,只能从这里读取当前工作空间。
|
||||
export const CurrentWorkspace = createParamDecorator(
|
||||
(_data: unknown, context: ExecutionContext): Workspace => {
|
||||
const request = context
|
||||
.switchToHttp()
|
||||
.getRequest<WorkspaceAuthenticatedRequest>();
|
||||
return request.auth.workspace;
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,63 @@
|
||||
import {
|
||||
CanActivate,
|
||||
ExecutionContext,
|
||||
Inject,
|
||||
Injectable,
|
||||
} from '@nestjs/common';
|
||||
import type { Workspace } from '@atlas/contracts';
|
||||
import type { Database } from '@atlas/database';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import { ApiError } from '../common/api-error.js';
|
||||
import { IS_PUBLIC_ENDPOINT } from '../common/public.decorator.js';
|
||||
import { DATABASE } from '../database/database.module.js';
|
||||
import type { AuthenticatedRequest } from '../auth/auth.guard.js';
|
||||
|
||||
export type WorkspaceAuthenticatedRequest = AuthenticatedRequest & {
|
||||
auth: AuthenticatedRequest['auth'] & { workspace: Workspace };
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class WorkspaceContextGuard implements CanActivate {
|
||||
public constructor(
|
||||
@Inject(Reflector) private readonly reflector: Reflector,
|
||||
@Inject(DATABASE) private readonly database: Database,
|
||||
) {}
|
||||
|
||||
public async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
if (
|
||||
this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_ENDPOINT, [
|
||||
context.getHandler(),
|
||||
context.getClass(),
|
||||
])
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const request = context
|
||||
.switchToHttp()
|
||||
.getRequest<AuthenticatedRequest>();
|
||||
// 当前一期每位用户只有一个个人工作空间;后续多人协作时此处将按显式选择的工作空间校验 membership。
|
||||
const workspace = await this.findCurrentWorkspace(request.auth.userId);
|
||||
if (!workspace) {
|
||||
throw new ApiError(
|
||||
403,
|
||||
'WORKSPACE_MEMBERSHIP_REQUIRED',
|
||||
'当前账户没有可用工作空间',
|
||||
);
|
||||
}
|
||||
// 后续控制器和服务只使用此处验证过的上下文,绝不相信客户端传入 workspace_id。
|
||||
request.auth.workspace = workspace;
|
||||
return true;
|
||||
}
|
||||
|
||||
private async findCurrentWorkspace(
|
||||
userId: string,
|
||||
): Promise<Workspace | null> {
|
||||
// 排序规则是首期“唯一有效工作空间”的临时选择策略,不是未来的工作空间切换协议。
|
||||
const result = await this.database.query<Workspace>(
|
||||
`SELECT w.id, w.name, m.role FROM atlas.workspace_memberships m JOIN atlas.workspaces w ON w.id = m.workspace_id WHERE m.user_id = $1 AND w.status = 'active' ORDER BY m.joined_at LIMIT 1`,
|
||||
[userId],
|
||||
);
|
||||
return result.rows[0] ?? null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,430 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import type { AddressInfo } from 'node:net';
|
||||
import type { Database } from '@atlas/database';
|
||||
import type { NestExpressApplication } from '@nestjs/platform-express';
|
||||
import { afterAll, beforeAll, describe, it } from 'vitest';
|
||||
import { DATABASE } from '../src/database/database.module.js';
|
||||
import { createApplication } from '../src/main.js';
|
||||
|
||||
type SuccessResponse<T> = { data: T };
|
||||
type ErrorResponse = {
|
||||
error: {
|
||||
code: string;
|
||||
message: string;
|
||||
request_id: string;
|
||||
details?: Array<{ field: string; code: string; message: string }>;
|
||||
};
|
||||
};
|
||||
type SessionPayload = {
|
||||
access_token: string;
|
||||
access_token_expires_in: number;
|
||||
user: {
|
||||
id: string;
|
||||
email: string;
|
||||
display_name: string | null;
|
||||
avatar_asset_id: string | null;
|
||||
revision: number;
|
||||
};
|
||||
workspace: { id: string; name: string; role: string };
|
||||
};
|
||||
|
||||
const password = 'correct-horse-battery-staple-123';
|
||||
const testUsers: Array<{ userId: string; workspaceId: string }> = [];
|
||||
let app: NestExpressApplication;
|
||||
let database: Database;
|
||||
let baseUrl: string;
|
||||
|
||||
function email(label: string): string {
|
||||
return `${label}-${randomUUID()}@example.test`;
|
||||
}
|
||||
|
||||
async function request(
|
||||
path: string,
|
||||
init: RequestInit = {},
|
||||
): Promise<{ response: Response; body: unknown }> {
|
||||
const response = await fetch(`${baseUrl}${path}`, {
|
||||
...init,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...init.headers,
|
||||
},
|
||||
});
|
||||
return {
|
||||
response,
|
||||
body: response.status === 204 ? undefined : await response.json(),
|
||||
};
|
||||
}
|
||||
|
||||
function expectSuccess<T>(body: unknown): SuccessResponse<T> {
|
||||
assert.ok(body && typeof body === 'object' && 'data' in body);
|
||||
return body as SuccessResponse<T>;
|
||||
}
|
||||
|
||||
function expectError(body: unknown): ErrorResponse {
|
||||
assert.ok(body && typeof body === 'object' && 'error' in body);
|
||||
return body as ErrorResponse;
|
||||
}
|
||||
|
||||
function refreshCookie(response: Response): string {
|
||||
const cookie = response.headers.get('set-cookie');
|
||||
assert.ok(cookie, '认证成功响应必须写入 refresh cookie');
|
||||
assert.match(cookie, /HttpOnly/i);
|
||||
const match = cookie.match(/atlas_refresh=[^;]+/);
|
||||
assert.ok(match, '响应必须包含 atlas_refresh cookie');
|
||||
return match[0];
|
||||
}
|
||||
|
||||
describe('认证 HTTP 集成测试', () => {
|
||||
beforeAll(async () => {
|
||||
Object.assign(process.env, {
|
||||
NODE_ENV: 'test',
|
||||
SWAGGER_ENABLED: 'false',
|
||||
AUTH_LOGIN_IP_LIMIT: '100',
|
||||
AUTH_LOGIN_EMAIL_LIMIT: '2',
|
||||
AUTH_REGISTER_IP_LIMIT: '100',
|
||||
AUTH_REGISTER_EMAIL_LIMIT: '100',
|
||||
AUTH_REFRESH_IP_LIMIT: '100',
|
||||
});
|
||||
const application = await createApplication();
|
||||
app = application.app;
|
||||
database = app.get<Database>(DATABASE);
|
||||
await app.listen(0, '127.0.0.1');
|
||||
const address = app.getHttpServer().address() as AddressInfo;
|
||||
baseUrl = `http://127.0.0.1:${address.port}`;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
for (const user of testUsers) {
|
||||
await database.workspaceTransaction(
|
||||
user.workspaceId,
|
||||
async (tx) => {
|
||||
await tx.query(
|
||||
'DELETE FROM atlas.idempotency_requests WHERE workspace_id = $1',
|
||||
[user.workspaceId],
|
||||
);
|
||||
},
|
||||
);
|
||||
await database.query(
|
||||
'DELETE FROM atlas.workspace_memberships WHERE user_id = $1',
|
||||
[user.userId],
|
||||
);
|
||||
await database.query('DELETE FROM atlas.workspaces WHERE id = $1', [
|
||||
user.workspaceId,
|
||||
]);
|
||||
await database.query('DELETE FROM atlas.users WHERE id = $1', [
|
||||
user.userId,
|
||||
]);
|
||||
}
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it('健康与 readiness 端点保持公开', async () => {
|
||||
const health = await request('/health');
|
||||
assert.equal(health.response.status, 200);
|
||||
assert.deepEqual(health.body, { status: 'ok', service: 'api' });
|
||||
|
||||
const readiness = await request('/ready');
|
||||
assert.equal(readiness.response.status, 200);
|
||||
assert.deepEqual(readiness.body, { status: 'ready', service: 'api' });
|
||||
});
|
||||
|
||||
it('注册、重复邮箱和参数校验遵守响应契约', async () => {
|
||||
const registrationEmail = email('register');
|
||||
const registration = await request('/api/auth/register', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
email: registrationEmail,
|
||||
password,
|
||||
password_confirmation: password,
|
||||
display_name: '集成测试用户',
|
||||
}),
|
||||
});
|
||||
assert.equal(registration.response.status, 201);
|
||||
const payload = expectSuccess<SessionPayload>(registration.body).data;
|
||||
assert.equal(payload.user.email, registrationEmail);
|
||||
assert.equal(payload.workspace.role, 'owner');
|
||||
assert.ok(payload.access_token);
|
||||
assert.ok(payload.access_token_expires_in > 0);
|
||||
refreshCookie(registration.response);
|
||||
testUsers.push({
|
||||
userId: payload.user.id,
|
||||
workspaceId: payload.workspace.id,
|
||||
});
|
||||
|
||||
const duplicate = await request('/api/auth/register', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
email: registrationEmail,
|
||||
password,
|
||||
password_confirmation: password,
|
||||
}),
|
||||
});
|
||||
assert.equal(duplicate.response.status, 409);
|
||||
assert.equal(
|
||||
expectError(duplicate.body).error.code,
|
||||
'EMAIL_ALREADY_REGISTERED',
|
||||
);
|
||||
|
||||
const invalid = await request('/api/auth/register', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
email: 'not-an-email',
|
||||
password,
|
||||
password_confirmation: password,
|
||||
}),
|
||||
});
|
||||
assert.equal(invalid.response.status, 422);
|
||||
assert.equal(expectError(invalid.body).error.code, 'VALIDATION_FAILED');
|
||||
});
|
||||
|
||||
it('登录、刷新、退出和当前用户读取可形成完整会话闭环', async () => {
|
||||
const accountEmail = email('session');
|
||||
const registration = await request('/api/auth/register', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
email: accountEmail,
|
||||
password,
|
||||
password_confirmation: password,
|
||||
}),
|
||||
});
|
||||
const registered = expectSuccess<SessionPayload>(
|
||||
registration.body,
|
||||
).data;
|
||||
testUsers.push({
|
||||
userId: registered.user.id,
|
||||
workspaceId: registered.workspace.id,
|
||||
});
|
||||
|
||||
const login = await request('/api/auth/login', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ email: accountEmail, password }),
|
||||
});
|
||||
assert.equal(login.response.status, 200);
|
||||
const loggedIn = expectSuccess<SessionPayload>(login.body).data;
|
||||
const loginRefreshCookie = refreshCookie(login.response);
|
||||
|
||||
const currentUser = await request('/api/auth/me', {
|
||||
headers: { Authorization: `Bearer ${loggedIn.access_token}` },
|
||||
});
|
||||
assert.equal(currentUser.response.status, 200);
|
||||
const me = expectSuccess<{
|
||||
user: { id: string };
|
||||
workspace: { id: string };
|
||||
}>(currentUser.body).data;
|
||||
assert.equal(me.user.id, registered.user.id);
|
||||
assert.equal(me.workspace.id, registered.workspace.id);
|
||||
|
||||
const refresh = await request('/api/auth/refresh', {
|
||||
method: 'POST',
|
||||
headers: { Cookie: loginRefreshCookie },
|
||||
});
|
||||
assert.equal(refresh.response.status, 200);
|
||||
const refreshed = expectSuccess<{
|
||||
access_token: string;
|
||||
access_token_expires_in: number;
|
||||
}>(refresh.body).data;
|
||||
assert.ok(refreshed.access_token);
|
||||
const refreshedCookie = refreshCookie(refresh.response);
|
||||
|
||||
const reusedRefresh = await request('/api/auth/refresh', {
|
||||
method: 'POST',
|
||||
headers: { Cookie: loginRefreshCookie },
|
||||
});
|
||||
assert.equal(reusedRefresh.response.status, 401);
|
||||
assert.equal(
|
||||
expectError(reusedRefresh.body).error.code,
|
||||
'SESSION_EXPIRED',
|
||||
);
|
||||
|
||||
const logout = await request('/api/auth/logout', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${refreshed.access_token}`,
|
||||
Cookie: refreshedCookie,
|
||||
},
|
||||
});
|
||||
assert.equal(logout.response.status, 204);
|
||||
assert.equal(logout.body, undefined);
|
||||
|
||||
const revoked = await request('/api/auth/me', {
|
||||
headers: { Authorization: `Bearer ${refreshed.access_token}` },
|
||||
});
|
||||
assert.equal(revoked.response.status, 401);
|
||||
});
|
||||
|
||||
it('拒绝错误密码、无效访问令牌和超过邮箱限额的登录请求', async () => {
|
||||
const accountEmail = email('rejected');
|
||||
const wrongPassword = await request('/api/auth/login', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ email: accountEmail, password }),
|
||||
});
|
||||
assert.equal(wrongPassword.response.status, 401);
|
||||
const wrongPasswordError = expectError(wrongPassword.body).error;
|
||||
assert.equal(wrongPasswordError.code, 'INVALID_CREDENTIALS');
|
||||
assert.equal(
|
||||
wrongPasswordError.request_id,
|
||||
wrongPassword.response.headers.get('x-request-id'),
|
||||
);
|
||||
|
||||
const invalidToken = await request('/api/auth/me', {
|
||||
headers: { Authorization: 'Bearer invalid-access-token' },
|
||||
});
|
||||
assert.equal(invalidToken.response.status, 401);
|
||||
assert.equal(expectError(invalidToken.body).error.code, 'HTTP_ERROR');
|
||||
|
||||
const rateLimitedEmail = email('rate-limit');
|
||||
for (const expectedStatus of [401, 401, 429]) {
|
||||
const response = await request('/api/auth/login', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ email: rateLimitedEmail, password }),
|
||||
});
|
||||
assert.equal(response.response.status, expectedStatus);
|
||||
if (expectedStatus === 429) {
|
||||
assert.equal(
|
||||
expectError(response.body).error.code,
|
||||
'AUTH_RATE_LIMITED',
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('更新资料遵守版本与幂等约束,改密会撤销旧会话并轮换当前会话', async () => {
|
||||
const accountEmail = email('profile-password');
|
||||
const registration = await request('/api/auth/register', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
email: accountEmail,
|
||||
password,
|
||||
password_confirmation: password,
|
||||
display_name: '原始名称',
|
||||
}),
|
||||
});
|
||||
const registered = expectSuccess<SessionPayload>(
|
||||
registration.body,
|
||||
).data;
|
||||
testUsers.push({
|
||||
userId: registered.user.id,
|
||||
workspaceId: registered.workspace.id,
|
||||
});
|
||||
|
||||
const profileKey = randomUUID();
|
||||
const update = await request('/api/auth/me', {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
Authorization: `Bearer ${registered.access_token}`,
|
||||
'If-Match': `"${registered.user.revision}"`,
|
||||
'Idempotency-Key': profileKey,
|
||||
},
|
||||
body: JSON.stringify({ display_name: '更新后的名称' }),
|
||||
});
|
||||
assert.equal(update.response.status, 200);
|
||||
const updatedUser = expectSuccess<SessionPayload['user']>(
|
||||
update.body,
|
||||
).data;
|
||||
assert.equal(updatedUser.display_name, '更新后的名称');
|
||||
assert.equal(updatedUser.avatar_asset_id, null);
|
||||
assert.equal(updatedUser.revision, registered.user.revision + 1);
|
||||
|
||||
const replay = await request('/api/auth/me', {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
Authorization: `Bearer ${registered.access_token}`,
|
||||
'If-Match': `"${registered.user.revision}"`,
|
||||
'Idempotency-Key': profileKey,
|
||||
},
|
||||
body: JSON.stringify({ display_name: '更新后的名称' }),
|
||||
});
|
||||
assert.equal(replay.response.status, 200);
|
||||
assert.deepEqual(replay.body, update.body);
|
||||
|
||||
const stale = await request('/api/auth/me', {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
Authorization: `Bearer ${registered.access_token}`,
|
||||
'If-Match': `"${registered.user.revision}"`,
|
||||
'Idempotency-Key': randomUUID(),
|
||||
},
|
||||
body: JSON.stringify({ display_name: '过期修改' }),
|
||||
});
|
||||
assert.equal(stale.response.status, 409);
|
||||
assert.equal(expectError(stale.body).error.code, 'VERSION_CONFLICT');
|
||||
|
||||
const invalidAvatar = await request('/api/auth/me', {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
Authorization: `Bearer ${registered.access_token}`,
|
||||
'If-Match': `"${updatedUser.revision}"`,
|
||||
'Idempotency-Key': randomUUID(),
|
||||
},
|
||||
body: JSON.stringify({ avatar_asset_id: randomUUID() }),
|
||||
});
|
||||
assert.equal(invalidAvatar.response.status, 422);
|
||||
assert.equal(
|
||||
expectError(invalidAvatar.body).error.code,
|
||||
'AVATAR_ASSET_UNAVAILABLE',
|
||||
);
|
||||
|
||||
const secondLogin = await request('/api/auth/login', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ email: accountEmail, password }),
|
||||
});
|
||||
const otherSession = expectSuccess<SessionPayload>(
|
||||
secondLogin.body,
|
||||
).data;
|
||||
|
||||
const changed = await request('/api/auth/change-password', {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${registered.access_token}` },
|
||||
body: JSON.stringify({
|
||||
old_password: password,
|
||||
password: 'new-correct-horse-battery-staple-456',
|
||||
password_confirmation: 'new-correct-horse-battery-staple-456',
|
||||
}),
|
||||
});
|
||||
assert.equal(changed.response.status, 200);
|
||||
const rotated = expectSuccess<{
|
||||
access_token: string;
|
||||
access_token_expires_in: number;
|
||||
}>(changed.body).data;
|
||||
assert.ok(rotated.access_token);
|
||||
refreshCookie(changed.response);
|
||||
|
||||
const formerCurrent = await request('/api/auth/me', {
|
||||
headers: { Authorization: `Bearer ${registered.access_token}` },
|
||||
});
|
||||
assert.equal(formerCurrent.response.status, 401);
|
||||
const revokedOther = await request('/api/auth/me', {
|
||||
headers: { Authorization: `Bearer ${otherSession.access_token}` },
|
||||
});
|
||||
assert.equal(revokedOther.response.status, 401);
|
||||
const current = await request('/api/auth/me', {
|
||||
headers: { Authorization: `Bearer ${rotated.access_token}` },
|
||||
});
|
||||
assert.equal(current.response.status, 200);
|
||||
|
||||
const oldPasswordChange = await request('/api/auth/change-password', {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${rotated.access_token}` },
|
||||
body: JSON.stringify({
|
||||
old_password: password,
|
||||
password: 'another-correct-horse-battery-staple-789',
|
||||
password_confirmation:
|
||||
'another-correct-horse-battery-staple-789',
|
||||
}),
|
||||
});
|
||||
assert.equal(oldPasswordChange.response.status, 422);
|
||||
assert.equal(
|
||||
expectError(oldPasswordChange.body).error.code,
|
||||
'VALIDATION_FAILED',
|
||||
);
|
||||
const newPasswordLogin = await request('/api/auth/login', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
email: accountEmail,
|
||||
password: 'new-correct-horse-battery-staple-456',
|
||||
}),
|
||||
});
|
||||
assert.equal(newPasswordLogin.response.status, 200);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,311 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import type { AddressInfo } from 'node:net';
|
||||
import type { Database } from '@atlas/database';
|
||||
import type { NestExpressApplication } from '@nestjs/platform-express';
|
||||
import { afterAll, beforeAll, describe, it } from 'vitest';
|
||||
import { DATABASE } from '../src/database/database.module.js';
|
||||
import { createApplication } from '../src/main.js';
|
||||
|
||||
type SuccessResponse<T> = { data: T };
|
||||
type ErrorResponse = { error: { code: string } };
|
||||
type Session = {
|
||||
access_token: string;
|
||||
user: { id: string };
|
||||
workspace: { id: string };
|
||||
};
|
||||
type Store = {
|
||||
id: string;
|
||||
name: string;
|
||||
is_default: boolean;
|
||||
status: string;
|
||||
credential_status: string;
|
||||
revision: number;
|
||||
};
|
||||
|
||||
const password = 'correct-horse-battery-staple-123';
|
||||
const testUsers: Array<{ userId: string; workspaceId: string }> = [];
|
||||
let app: NestExpressApplication;
|
||||
let database: Database;
|
||||
let baseUrl: string;
|
||||
|
||||
function email(label: string) {
|
||||
return `${label}-${randomUUID()}@example.test`;
|
||||
}
|
||||
|
||||
async function request(path: string, init: RequestInit = {}) {
|
||||
const response = await fetch(`${baseUrl}${path}`, {
|
||||
...init,
|
||||
headers: { 'Content-Type': 'application/json', ...init.headers },
|
||||
});
|
||||
return {
|
||||
response,
|
||||
body: response.status === 204 ? undefined : await response.json(),
|
||||
};
|
||||
}
|
||||
|
||||
function success<T>(body: unknown): SuccessResponse<T> {
|
||||
assert.ok(body && typeof body === 'object' && 'data' in body);
|
||||
return body as SuccessResponse<T>;
|
||||
}
|
||||
|
||||
function error(body: unknown): ErrorResponse {
|
||||
assert.ok(body && typeof body === 'object' && 'error' in body);
|
||||
return body as ErrorResponse;
|
||||
}
|
||||
|
||||
async function register(label: string): Promise<Session> {
|
||||
const result = await request('/api/auth/register', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
email: email(label),
|
||||
password,
|
||||
password_confirmation: password,
|
||||
}),
|
||||
});
|
||||
assert.equal(result.response.status, 201);
|
||||
const session = success<Session>(result.body).data;
|
||||
testUsers.push({
|
||||
userId: session.user.id,
|
||||
workspaceId: session.workspace.id,
|
||||
});
|
||||
return session;
|
||||
}
|
||||
|
||||
function headers(session: Session, idempotencyKey?: string) {
|
||||
return {
|
||||
Authorization: `Bearer ${session.access_token}`,
|
||||
...(idempotencyKey ? { 'Idempotency-Key': idempotencyKey } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
describe('门店 HTTP 集成测试', () => {
|
||||
beforeAll(async () => {
|
||||
Object.assign(process.env, {
|
||||
NODE_ENV: 'test',
|
||||
SWAGGER_ENABLED: 'false',
|
||||
AUTH_LOGIN_IP_LIMIT: '100',
|
||||
AUTH_LOGIN_EMAIL_LIMIT: '100',
|
||||
AUTH_REGISTER_IP_LIMIT: '100',
|
||||
AUTH_REGISTER_EMAIL_LIMIT: '100',
|
||||
AUTH_REFRESH_IP_LIMIT: '100',
|
||||
});
|
||||
const application = await createApplication();
|
||||
app = application.app;
|
||||
database = app.get<Database>(DATABASE);
|
||||
await app.listen(0, '127.0.0.1');
|
||||
const address = app.getHttpServer().address() as AddressInfo;
|
||||
baseUrl = `http://127.0.0.1:${address.port}`;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
for (const user of testUsers) {
|
||||
// 门店测试会创建操作和 Outbox;先在工作空间事务内清理这些受 RLS 保护的记录。
|
||||
await database.workspaceTransaction(
|
||||
user.workspaceId,
|
||||
async (tx) => {
|
||||
await tx.query(
|
||||
'DELETE FROM atlas.idempotency_requests WHERE workspace_id = $1',
|
||||
[user.workspaceId],
|
||||
);
|
||||
await tx.query(
|
||||
'DELETE FROM atlas.outbox_events WHERE workspace_id = $1',
|
||||
[user.workspaceId],
|
||||
);
|
||||
await tx.query(
|
||||
'DELETE FROM atlas.operations WHERE workspace_id = $1',
|
||||
[user.workspaceId],
|
||||
);
|
||||
await tx.query(
|
||||
'DELETE FROM atlas.stores WHERE workspace_id = $1',
|
||||
[user.workspaceId],
|
||||
);
|
||||
},
|
||||
);
|
||||
await database.query(
|
||||
'DELETE FROM atlas.workspace_memberships WHERE user_id = $1',
|
||||
[user.userId],
|
||||
);
|
||||
await database.query('DELETE FROM atlas.workspaces WHERE id = $1', [
|
||||
user.workspaceId,
|
||||
]);
|
||||
await database.query('DELETE FROM atlas.users WHERE id = $1', [
|
||||
user.userId,
|
||||
]);
|
||||
}
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it('创建门店时加密保存凭据、创建操作,并能安全重放相同请求', async () => {
|
||||
const owner = await register('stores-owner');
|
||||
const platforms = await request('/api/platforms', {
|
||||
headers: headers(owner),
|
||||
});
|
||||
assert.equal(platforms.response.status, 200);
|
||||
const platform = success<
|
||||
Array<{
|
||||
id: string;
|
||||
code: string;
|
||||
default_market_code: string | null;
|
||||
default_locale: string | null;
|
||||
}>
|
||||
>(platforms.body).data.find((item) => item.code === 'OZON');
|
||||
assert.ok(platform);
|
||||
assert.equal(platform.default_market_code, 'RU');
|
||||
assert.equal(platform.default_locale, 'ru-RU');
|
||||
|
||||
const idempotencyKey = randomUUID();
|
||||
const createBody = {
|
||||
platform_id: platform.id,
|
||||
name: '测试 Ozon 主店',
|
||||
client_id: `client-${randomUUID()}`,
|
||||
api_key: 'secret-only-in-request',
|
||||
currency_code: 'RUB',
|
||||
is_default: false,
|
||||
note: '首家门店仍应成为默认门店',
|
||||
};
|
||||
const created = await request('/api/stores', {
|
||||
method: 'POST',
|
||||
headers: headers(owner, idempotencyKey),
|
||||
body: JSON.stringify(createBody),
|
||||
});
|
||||
assert.equal(created.response.status, 202);
|
||||
const createdData = success<{
|
||||
store: Store & { platform_client_id: string };
|
||||
operation: { id: string; status: string };
|
||||
}>(created.body).data;
|
||||
assert.equal(createdData.store.is_default, true);
|
||||
assert.equal(createdData.store.status, 'verifying');
|
||||
assert.equal(createdData.store.credential_status, 'unknown');
|
||||
assert.equal(
|
||||
createdData.store.platform_client_id,
|
||||
createBody.client_id,
|
||||
);
|
||||
assert.equal(createdData.operation.status, 'queued');
|
||||
assert.equal(
|
||||
JSON.stringify(created.body).includes(createBody.api_key),
|
||||
false,
|
||||
);
|
||||
|
||||
const replay = await request('/api/stores', {
|
||||
method: 'POST',
|
||||
headers: headers(owner, idempotencyKey),
|
||||
body: JSON.stringify(createBody),
|
||||
});
|
||||
assert.equal(replay.response.status, 202);
|
||||
assert.deepEqual(replay.body, created.body);
|
||||
|
||||
const mismatchedReplay = await request('/api/stores', {
|
||||
method: 'POST',
|
||||
headers: headers(owner, idempotencyKey),
|
||||
body: JSON.stringify({ ...createBody, name: '不同请求' }),
|
||||
});
|
||||
assert.equal(mismatchedReplay.response.status, 409);
|
||||
assert.equal(
|
||||
error(mismatchedReplay.body).error.code,
|
||||
'IDEMPOTENCY_KEY_REUSED',
|
||||
);
|
||||
|
||||
const context = await request('/api/platform-context', {
|
||||
headers: {
|
||||
...headers(owner),
|
||||
'X-Atlas-Platform-Id': platform.id,
|
||||
},
|
||||
});
|
||||
assert.equal(context.response.status, 200);
|
||||
assert.equal(
|
||||
success<{ store_count: number; default_store: { id: string } }>(
|
||||
context.body,
|
||||
).data.store_count,
|
||||
1,
|
||||
);
|
||||
|
||||
const operation = await request(
|
||||
`/api/operations/${createdData.operation.id}`,
|
||||
{ headers: headers(owner) },
|
||||
);
|
||||
assert.equal(operation.response.status, 200);
|
||||
assert.equal(
|
||||
success<{ status: string }>(operation.body).data.status,
|
||||
'queued',
|
||||
);
|
||||
});
|
||||
|
||||
it('更新版本、切换默认门店、删除和工作空间隔离都遵守契约', async () => {
|
||||
const owner = await register('stores-lifecycle');
|
||||
const otherOwner = await register('stores-isolation');
|
||||
const platforms = await request('/api/platforms', {
|
||||
headers: headers(owner),
|
||||
});
|
||||
const platform = success<Array<{ id: string; code: string }>>(
|
||||
platforms.body,
|
||||
).data.find((item) => item.code === 'OZON');
|
||||
assert.ok(platform);
|
||||
|
||||
const first = await createStore(owner, platform.id, '第一家门店');
|
||||
const updated = await request(`/api/stores/${first.id}`, {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
...headers(owner, randomUUID()),
|
||||
'If-Match': `"${first.revision}"`,
|
||||
},
|
||||
body: JSON.stringify({ name: '第一家门店(已修改)' }),
|
||||
});
|
||||
assert.equal(updated.response.status, 200);
|
||||
const updatedStore = success<Store>(updated.body).data;
|
||||
assert.equal(updatedStore.revision, first.revision + 1);
|
||||
|
||||
const stale = await request(`/api/stores/${first.id}`, {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
...headers(owner, randomUUID()),
|
||||
'If-Match': `"${first.revision}"`,
|
||||
},
|
||||
body: JSON.stringify({ name: '过期修改' }),
|
||||
});
|
||||
assert.equal(stale.response.status, 409);
|
||||
assert.equal(error(stale.body).error.code, 'VERSION_CONFLICT');
|
||||
|
||||
const second = await createStore(owner, platform.id, '第二家门店');
|
||||
const makeDefault = await request(
|
||||
`/api/stores/${second.id}/set-default`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: headers(owner, randomUUID()),
|
||||
},
|
||||
);
|
||||
assert.equal(makeDefault.response.status, 200);
|
||||
assert.equal(success<Store>(makeDefault.body).data.is_default, true);
|
||||
|
||||
const foreignRead = await request(`/api/stores/${first.id}`, {
|
||||
headers: headers(otherOwner),
|
||||
});
|
||||
assert.equal(foreignRead.response.status, 404);
|
||||
|
||||
const deleted = await request(`/api/stores/${first.id}`, {
|
||||
method: 'DELETE',
|
||||
headers: headers(owner, randomUUID()),
|
||||
});
|
||||
assert.equal(deleted.response.status, 204);
|
||||
const absent = await request(`/api/stores/${first.id}`, {
|
||||
headers: headers(owner),
|
||||
});
|
||||
assert.equal(absent.response.status, 404);
|
||||
});
|
||||
});
|
||||
|
||||
async function createStore(owner: Session, platformId: string, name: string) {
|
||||
const result = await request('/api/stores', {
|
||||
method: 'POST',
|
||||
headers: headers(owner, randomUUID()),
|
||||
body: JSON.stringify({
|
||||
platform_id: platformId,
|
||||
name,
|
||||
client_id: `client-${randomUUID()}`,
|
||||
api_key: `key-${randomUUID()}`,
|
||||
currency_code: 'RUB',
|
||||
}),
|
||||
});
|
||||
assert.equal(result.response.status, 202);
|
||||
return success<{ store: Store }>(result.body).data.store;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "dist",
|
||||
"types": ["node"],
|
||||
"experimentalDecorators": true,
|
||||
"emitDecoratorMetadata": true
|
||||
},
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": ".",
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["src/**/*.ts", "test/**/*.ts", "vitest.config.ts"]
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
include: ['test/**/*.test.ts'],
|
||||
fileParallelism: false,
|
||||
testTimeout: 30_000,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"lib": [
|
||||
"ESNext",
|
||||
"DOM",
|
||||
"DOM.Iterable"
|
||||
],
|
||||
"target": "ESNext",
|
||||
"module": "Preserve",
|
||||
"moduleDetection": "force",
|
||||
"moduleResolution": "Bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"noEmit": true,
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"noImplicitOverride": true,
|
||||
"paths": {
|
||||
"@": [
|
||||
".."
|
||||
],
|
||||
"@/*": [
|
||||
"../*"
|
||||
],
|
||||
"~": [
|
||||
".."
|
||||
],
|
||||
"~/*": [
|
||||
"../*"
|
||||
],
|
||||
"@@": [
|
||||
".."
|
||||
],
|
||||
"@@/*": [
|
||||
"../*"
|
||||
],
|
||||
"~~": [
|
||||
".."
|
||||
],
|
||||
"~~/*": [
|
||||
"../*"
|
||||
]
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
"../**/*",
|
||||
"./wxt.d.ts"
|
||||
],
|
||||
"exclude": [
|
||||
"../**/node_modules",
|
||||
"../.output"
|
||||
]
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
// Generated by wxt
|
||||
interface ImportMetaEnv {
|
||||
readonly MANIFEST_VERSION: 2 | 3;
|
||||
readonly BROWSER: string;
|
||||
readonly CHROME: boolean;
|
||||
readonly FIREFOX: boolean;
|
||||
readonly SAFARI: boolean;
|
||||
readonly EDGE: boolean;
|
||||
readonly OPERA: boolean;
|
||||
readonly COMMAND: "build" | "serve";
|
||||
readonly ENTRYPOINT: string;
|
||||
}
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv
|
||||
}
|
||||
Vendored
+81
@@ -0,0 +1,81 @@
|
||||
// Generated by wxt
|
||||
import "wxt/browser";
|
||||
|
||||
declare module "wxt/browser" {
|
||||
/**
|
||||
* See https://developer.chrome.com/docs/extensions/reference/i18n/#method-getMessage
|
||||
*/
|
||||
interface GetMessageOptions {
|
||||
/**
|
||||
* See https://developer.chrome.com/docs/extensions/reference/i18n/#method-getMessage
|
||||
*/
|
||||
escapeLt?: boolean
|
||||
}
|
||||
|
||||
export interface WxtI18n extends I18n.Static {
|
||||
/**
|
||||
* The extension or app ID; you might use this string to construct URLs for resources inside the extension. Even non-localized extensions can use this message.
|
||||
* Note: You can't use this message in a manifest file.
|
||||
*
|
||||
* "<browser.runtime.id>"
|
||||
*/
|
||||
getMessage(
|
||||
messageName: "@@extension_id",
|
||||
substitutions?: string | string[],
|
||||
options?: GetMessageOptions,
|
||||
): string;
|
||||
/**
|
||||
* "<browser.i18n.getUiLocale()>"
|
||||
*/
|
||||
getMessage(
|
||||
messageName: "@@ui_locale",
|
||||
substitutions?: string | string[],
|
||||
options?: GetMessageOptions,
|
||||
): string;
|
||||
/**
|
||||
* The text direction for the current locale, either "ltr" for left-to-right languages such as English or "rtl" for right-to-left languages such as Japanese.
|
||||
*
|
||||
* "<ltr|rtl>"
|
||||
*/
|
||||
getMessage(
|
||||
messageName: "@@bidi_dir",
|
||||
substitutions?: string | string[],
|
||||
options?: GetMessageOptions,
|
||||
): string;
|
||||
/**
|
||||
* If the @@bidi_dir is "ltr", then this is "rtl"; otherwise, it's "ltr".
|
||||
*
|
||||
* "<rtl|ltr>"
|
||||
*/
|
||||
getMessage(
|
||||
messageName: "@@bidi_reversed_dir",
|
||||
substitutions?: string | string[],
|
||||
options?: GetMessageOptions,
|
||||
): string;
|
||||
/**
|
||||
* If the @@bidi_dir is "ltr", then this is "left"; otherwise, it's "right".
|
||||
*
|
||||
* "<left|right>"
|
||||
*/
|
||||
getMessage(
|
||||
messageName: "@@bidi_start_edge",
|
||||
substitutions?: string | string[],
|
||||
options?: GetMessageOptions,
|
||||
): string;
|
||||
/**
|
||||
* If the @@bidi_dir is "ltr", then this is "right"; otherwise, it's "left".
|
||||
*
|
||||
* "<right|left>"
|
||||
*/
|
||||
getMessage(
|
||||
messageName: "@@bidi_end_edge",
|
||||
substitutions?: string | string[],
|
||||
options?: GetMessageOptions,
|
||||
): string;
|
||||
getMessage(
|
||||
messageName: "@@extension_id" | "@@ui_locale" | "@@bidi_dir" | "@@bidi_reversed_dir" | "@@bidi_start_edge" | "@@bidi_end_edge",
|
||||
substitutions?: string | string[],
|
||||
options?: GetMessageOptions,
|
||||
): string;
|
||||
}
|
||||
}
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
// Generated by wxt
|
||||
// Types for the #import virtual module
|
||||
declare module '#imports' {
|
||||
export { browser, Browser } from 'wxt/browser';
|
||||
export { storage, WxtStorage, WxtStorageItem, StorageArea, StorageItemKey, StorageAreaChanges, MigrationError } from 'wxt/utils/storage';
|
||||
export { getAppConfig, useAppConfig } from 'wxt/utils/app-config';
|
||||
export { ContentScriptContext, WxtWindowEventMap } from 'wxt/utils/content-script-context';
|
||||
export { createIframeUi, IframeContentScriptUi, IframeContentScriptUiOptions } from 'wxt/utils/content-script-ui/iframe';
|
||||
export { createIntegratedUi, IntegratedContentScriptUi, IntegratedContentScriptUiOptions } from 'wxt/utils/content-script-ui/integrated';
|
||||
export { createShadowRootUi, ShadowRootContentScriptUi, ShadowRootContentScriptUiOptions } from 'wxt/utils/content-script-ui/shadow-root';
|
||||
export { ContentScriptUi, ContentScriptUiOptions, ContentScriptOverlayAlignment, ContentScriptAppendMode, ContentScriptInlinePositioningOptions, ContentScriptOverlayPositioningOptions, ContentScriptModalPositioningOptions, ContentScriptPositioningOptions, ContentScriptAnchoredOptions, AutoMountOptions, StopAutoMount, AutoMount } from 'wxt/utils/content-script-ui/types';
|
||||
export { defineAppConfig, WxtAppConfig } from 'wxt/utils/define-app-config';
|
||||
export { defineBackground } from 'wxt/utils/define-background';
|
||||
export { defineContentScript } from 'wxt/utils/define-content-script';
|
||||
export { defineUnlistedScript } from 'wxt/utils/define-unlisted-script';
|
||||
export { defineWxtPlugin } from 'wxt/utils/define-wxt-plugin';
|
||||
export { injectScript, ScriptPublicPath, InjectScriptOptions } from 'wxt/utils/inject-script';
|
||||
export { InvalidMatchPattern, MatchPattern } from 'wxt/utils/match-patterns';
|
||||
export { useState, useCallback, useMemo, useEffect, useRef, useContext, useReducer } from 'react';
|
||||
export { fakeBrowser } from 'wxt/testing/fake-browser';
|
||||
}
|
||||
+58
@@ -0,0 +1,58 @@
|
||||
// Generated by wxt
|
||||
export {}
|
||||
declare global {
|
||||
const ContentScriptContext: typeof import('wxt/utils/content-script-context').ContentScriptContext
|
||||
const InvalidMatchPattern: typeof import('wxt/utils/match-patterns').InvalidMatchPattern
|
||||
const MatchPattern: typeof import('wxt/utils/match-patterns').MatchPattern
|
||||
const browser: typeof import('wxt/browser').browser
|
||||
const createIframeUi: typeof import('wxt/utils/content-script-ui/iframe').createIframeUi
|
||||
const createIntegratedUi: typeof import('wxt/utils/content-script-ui/integrated').createIntegratedUi
|
||||
const createShadowRootUi: typeof import('wxt/utils/content-script-ui/shadow-root').createShadowRootUi
|
||||
const defineAppConfig: typeof import('wxt/utils/define-app-config').defineAppConfig
|
||||
const defineBackground: typeof import('wxt/utils/define-background').defineBackground
|
||||
const defineContentScript: typeof import('wxt/utils/define-content-script').defineContentScript
|
||||
const defineUnlistedScript: typeof import('wxt/utils/define-unlisted-script').defineUnlistedScript
|
||||
const defineWxtPlugin: typeof import('wxt/utils/define-wxt-plugin').defineWxtPlugin
|
||||
const fakeBrowser: typeof import('wxt/testing/fake-browser').fakeBrowser
|
||||
const getAppConfig: typeof import('wxt/utils/app-config').getAppConfig
|
||||
const injectScript: typeof import('wxt/utils/inject-script').injectScript
|
||||
const storage: typeof import('wxt/utils/storage').storage
|
||||
const useAppConfig: typeof import('wxt/utils/app-config').useAppConfig
|
||||
const useCallback: typeof import('react').useCallback
|
||||
const useContext: typeof import('react').useContext
|
||||
const useEffect: typeof import('react').useEffect
|
||||
const useMemo: typeof import('react').useMemo
|
||||
const useReducer: typeof import('react').useReducer
|
||||
const useRef: typeof import('react').useRef
|
||||
const useState: typeof import('react').useState
|
||||
}
|
||||
// for type re-export
|
||||
declare global {
|
||||
// @ts-ignore
|
||||
export type { Browser } from 'wxt/browser'
|
||||
import('wxt/browser')
|
||||
// @ts-ignore
|
||||
export type { WxtStorage, WxtStorageItem, StorageArea, StorageItemKey, StorageAreaChanges, MigrationError } from 'wxt/utils/storage'
|
||||
import('wxt/utils/storage')
|
||||
// @ts-ignore
|
||||
export type { WxtWindowEventMap } from 'wxt/utils/content-script-context'
|
||||
import('wxt/utils/content-script-context')
|
||||
// @ts-ignore
|
||||
export type { IframeContentScriptUi, IframeContentScriptUiOptions } from 'wxt/utils/content-script-ui/iframe'
|
||||
import('wxt/utils/content-script-ui/iframe')
|
||||
// @ts-ignore
|
||||
export type { IntegratedContentScriptUi, IntegratedContentScriptUiOptions } from 'wxt/utils/content-script-ui/integrated'
|
||||
import('wxt/utils/content-script-ui/integrated')
|
||||
// @ts-ignore
|
||||
export type { ShadowRootContentScriptUi, ShadowRootContentScriptUiOptions } from 'wxt/utils/content-script-ui/shadow-root'
|
||||
import('wxt/utils/content-script-ui/shadow-root')
|
||||
// @ts-ignore
|
||||
export type { ContentScriptUi, ContentScriptUiOptions, ContentScriptOverlayAlignment, ContentScriptAppendMode, ContentScriptInlinePositioningOptions, ContentScriptOverlayPositioningOptions, ContentScriptModalPositioningOptions, ContentScriptPositioningOptions, ContentScriptAnchoredOptions, AutoMountOptions, StopAutoMount, AutoMount } from 'wxt/utils/content-script-ui/types'
|
||||
import('wxt/utils/content-script-ui/types')
|
||||
// @ts-ignore
|
||||
export type { WxtAppConfig } from 'wxt/utils/define-app-config'
|
||||
import('wxt/utils/define-app-config')
|
||||
// @ts-ignore
|
||||
export type { ScriptPublicPath, InjectScriptOptions } from 'wxt/utils/inject-script'
|
||||
import('wxt/utils/inject-script')
|
||||
}
|
||||
Vendored
+14
@@ -0,0 +1,14 @@
|
||||
// Generated by wxt
|
||||
import "wxt/browser";
|
||||
|
||||
declare module "wxt/browser" {
|
||||
export type PublicPath =
|
||||
| ""
|
||||
| "/"
|
||||
| "/background.js"
|
||||
type HtmlPublicPath = Extract<PublicPath, `${string}.html`>
|
||||
export interface WxtRuntime {
|
||||
getURL(path: PublicPath): string;
|
||||
getURL(path: `${HtmlPublicPath}${string}`): string;
|
||||
}
|
||||
}
|
||||
Vendored
+8
@@ -0,0 +1,8 @@
|
||||
// Generated by wxt
|
||||
/// <reference types="wxt/vite-builder-env" />
|
||||
/// <reference types="@wxt-dev/module-react" />
|
||||
/// <reference path="./types/paths.d.ts" />
|
||||
/// <reference path="./types/i18n.d.ts" />
|
||||
/// <reference path="./types/globals.d.ts" />
|
||||
/// <reference path="./types/imports-module.d.ts" />
|
||||
/// <reference path="./types/imports.d.ts" />
|
||||
@@ -0,0 +1,3 @@
|
||||
export default defineBackground(() => {
|
||||
console.info('Atlas collector background started.');
|
||||
});
|
||||
@@ -0,0 +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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"jsx": "react-jsx",
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["**/*.ts", "**/*.tsx", ".wxt/**/*.ts"]
|
||||
}
|
||||
@@ -0,0 +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,
|
||||
},
|
||||
},
|
||||
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/*',
|
||||
],
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Atlas Seller Kit</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"name": "@atlas/web",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "tsc -b && vite build",
|
||||
"dev": "vite",
|
||||
"typecheck": "tsc -b --pretty false"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ant-design/icons": "6.3.4",
|
||||
"@atlas/contracts": "workspace:*",
|
||||
"antd": "6.6.2",
|
||||
"axios": "1.20.0",
|
||||
"react": "19.2.8",
|
||||
"react-dom": "19.2.8",
|
||||
"react-router-dom": "7.18.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "26.3.0",
|
||||
"@types/react": "19.2.18",
|
||||
"@types/react-dom": "19.2.5",
|
||||
"@vitejs/plugin-react": "6.1.1",
|
||||
"typescript": "7.0.2",
|
||||
"vite": "8.2.2"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
export default {
|
||||
printWidth: 100,
|
||||
tabWidth: 4,
|
||||
useTabs: false,
|
||||
semi: true,
|
||||
singleQuote: true,
|
||||
quoteProps: 'as-needed',
|
||||
jsxSingleQuote: false,
|
||||
trailingComma: 'es5',
|
||||
bracketSpacing: true,
|
||||
bracketSameLine: false,
|
||||
arrowParens: 'always',
|
||||
endOfLine: 'lf',
|
||||
embeddedLanguageFormatting: 'auto',
|
||||
};
|
||||
@@ -0,0 +1,88 @@
|
||||
/* 全局应用级样式补充(主题变量见 index.css) */
|
||||
|
||||
/* ── 设置类页面(修改资料 / 修改密码):内容区居中卡片 ── */
|
||||
.settings-page {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.settings-card {
|
||||
width: 100%;
|
||||
max-width: 560px;
|
||||
background: #fff;
|
||||
border-radius: 10px;
|
||||
/* 四周柔和阴影,与内容区背景拉开层次 */
|
||||
box-shadow: 0 0 24px 0 rgba(17, 24, 39, 0.12);
|
||||
}
|
||||
|
||||
.settings-card-title {
|
||||
padding: 20px 32px;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.settings-card-body {
|
||||
padding: 32px;
|
||||
}
|
||||
|
||||
.settings-field-label {
|
||||
font-size: 14px;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.avatar-upload-area {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.avatar-upload-placeholder {
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.avatar-upload-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
color: #6b7280;
|
||||
font-size: 13px;
|
||||
border-radius: 50%;
|
||||
background: transparent;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.avatar-upload-placeholder:hover .avatar-upload-overlay {
|
||||
background: rgba(255, 255, 255, 0.55);
|
||||
}
|
||||
|
||||
.avatar-preview {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.avatar-upload-hint {
|
||||
text-align: center;
|
||||
color: var(--text-secondary);
|
||||
font-size: 12px;
|
||||
line-height: 1.8;
|
||||
margin: 12px 0 24px;
|
||||
}
|
||||
|
||||
.profile-form {
|
||||
max-width: 420px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.settings-actions {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { ConfigProvider, App as AntdApp } from 'antd';
|
||||
import zhCN from 'antd/locale/zh_CN';
|
||||
import { RouterProvider } from 'react-router-dom';
|
||||
import { router } from './router';
|
||||
import { SessionProvider } from '@/services/session';
|
||||
import './App.css';
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<ConfigProvider
|
||||
locale={zhCN}
|
||||
theme={{
|
||||
token: {
|
||||
colorPrimary: '#8b5cf6',
|
||||
// 全局圆角 10px,输入框/按钮/卡片等同步生效
|
||||
borderRadius: 10,
|
||||
// 全局正文字号 14px;输入类控件经 components 单独放大
|
||||
fontSize: 14,
|
||||
controlHeight: 34,
|
||||
controlHeightSM: 26,
|
||||
},
|
||||
components: {
|
||||
// 文本框:文本 16px、高度同步加高;按钮与表格/菜单等保持全局默认
|
||||
Input: { fontSize: 16, controlHeight: 40 },
|
||||
Select: { fontSize: 16, controlHeight: 40 },
|
||||
InputNumber: { fontSize: 16, controlHeight: 40 },
|
||||
DatePicker: { fontSize: 16, controlHeight: 40 },
|
||||
},
|
||||
}}
|
||||
>
|
||||
<AntdApp>
|
||||
<SessionProvider>
|
||||
<RouterProvider router={router} />
|
||||
</SessionProvider>
|
||||
</AntdApp>
|
||||
</ConfigProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
@@ -0,0 +1,23 @@
|
||||
import { Card, Empty } from 'antd';
|
||||
|
||||
interface PagePlaceholderProps {
|
||||
title: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
/** 业务模块实现前的统一占位页 */
|
||||
const PagePlaceholder = ({ title, description }: PagePlaceholderProps) => (
|
||||
<Card>
|
||||
<Empty
|
||||
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||
description={
|
||||
<>
|
||||
<div>{title}</div>
|
||||
<div>{description}</div>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</Card>
|
||||
);
|
||||
|
||||
export default PagePlaceholder;
|
||||
@@ -0,0 +1,18 @@
|
||||
import { Spin } from 'antd';
|
||||
|
||||
/** 应用启动会话恢复期间的全屏占位,避免守卫闪烁跳转 */
|
||||
const SessionSplash = () => (
|
||||
<div
|
||||
style={{
|
||||
minHeight: '100vh',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
background: 'var(--bg-color)',
|
||||
}}
|
||||
>
|
||||
<Spin size="large" />
|
||||
</div>
|
||||
);
|
||||
|
||||
export default SessionSplash;
|
||||
@@ -0,0 +1,16 @@
|
||||
/**
|
||||
* 环境变量配置(Vite 仅暴露 VITE_ 前缀变量)
|
||||
*/
|
||||
|
||||
interface EnvConfig {
|
||||
/** API 基础路径:开发环境由 vite 代理转发到 NestJS API */
|
||||
apiBaseUrl: string;
|
||||
appName: string;
|
||||
debug: boolean;
|
||||
}
|
||||
|
||||
export const envConfig: EnvConfig = {
|
||||
apiBaseUrl: import.meta.env.VITE_API_BASE_URL ?? '/api',
|
||||
appName: import.meta.env.VITE_APP_NAME ?? 'Atlas Seller Kit',
|
||||
debug: import.meta.env.VITE_DEBUG === 'true',
|
||||
};
|
||||
@@ -0,0 +1,37 @@
|
||||
:root {
|
||||
--primary-color: #8b5cf6;
|
||||
--primary-light: #a78bfa;
|
||||
--primary-dark: #7c3aed;
|
||||
/* 侧边菜单背景(纯色,中间调;想再调直接改这里) */
|
||||
--sider-bg: #6d28d9;
|
||||
--text-primary: #1f2937;
|
||||
--text-secondary: #6b7280;
|
||||
--bg-color: #f9fafb;
|
||||
--card-bg: #ffffff;
|
||||
--border-color: #e5e7eb;
|
||||
}
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family:
|
||||
-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Hiragino Sans GB',
|
||||
'Microsoft YaHei', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans',
|
||||
'Helvetica Neue', sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
background-color: var(--bg-color);
|
||||
color: var(--text-primary);
|
||||
/* 全局默认字号 14px,与 antd 主题 token 的 fontSize 保持一致 */
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
#root {
|
||||
min-height: 100vh;
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
.auth-page {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 24px;
|
||||
background:
|
||||
<<<<<<< HEAD
|
||||
radial-gradient(1000px 500px at 15% -10%, rgba(139, 92, 246, 0.14), transparent 60%),
|
||||
radial-gradient(900px 480px at 95% 110%, rgba(109, 40, 217, 0.1), transparent 55%),
|
||||
======= radial-gradient(1000px 500px at 15% -10%, rgba(139, 92, 246, 0.14), transparent 60%),
|
||||
radial-gradient(900px 480px at 95% 110%, rgba(109, 40, 217, 0.1), transparent 55%),
|
||||
>>>>>>> feat-0.2.0 var(--bg-color);
|
||||
}
|
||||
|
||||
.auth-panel {
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
}
|
||||
|
||||
.auth-brand {
|
||||
text-align: center;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.auth-logo {
|
||||
font-size: 26px;
|
||||
font-weight: 700;
|
||||
color: var(--primary-dark);
|
||||
}
|
||||
|
||||
.auth-logo-subtitle {
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.auth-card {
|
||||
background: var(--card-bg);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 10px 30px rgba(17, 24, 39, 0.06);
|
||||
padding: 32px 28px;
|
||||
}
|
||||
|
||||
.auth-title {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.auth-subtitle {
|
||||
font-size: 14px;
|
||||
color: var(--text-secondary);
|
||||
margin: 4px 0 20px;
|
||||
}
|
||||
|
||||
.auth-footer {
|
||||
margin-top: 16px;
|
||||
text-align: center;
|
||||
font-size: 14px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.auth-footer a {
|
||||
color: var(--primary-color);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.auth-card {
|
||||
padding: 24px 20px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { useEffect } from 'react';
|
||||
import type { ReactNode } from 'react';
|
||||
import './AuthLayout.css';
|
||||
|
||||
interface AuthLayoutProps {
|
||||
/** 页面标题,如「登录」 */
|
||||
title: string;
|
||||
/** 标题下的一行说明 */
|
||||
subtitle: string;
|
||||
/** 卡片底部的切换入口 */
|
||||
footer: ReactNode;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
/** 注册/登录共用的居中卡片布局,品牌视觉与 MainLayout 一致 */
|
||||
const AuthLayout = ({ title, subtitle, footer, children }: AuthLayoutProps) => {
|
||||
useEffect(() => {
|
||||
document.title = `${title} - Atlas Seller Kit`;
|
||||
}, [title]);
|
||||
|
||||
return (
|
||||
<div className="auth-page">
|
||||
<div className="auth-panel">
|
||||
<div className="auth-brand">
|
||||
<div className="auth-logo">Atlas Kit</div>
|
||||
<div className="auth-logo-subtitle">商品工作台</div>
|
||||
</div>
|
||||
<div className="auth-card">
|
||||
<div className="auth-title">{title}</div>
|
||||
<div className="auth-subtitle">{subtitle}</div>
|
||||
{children}
|
||||
<div className="auth-footer">{footer}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AuthLayout;
|
||||
@@ -0,0 +1,225 @@
|
||||
.main-layout {
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.main-sider {
|
||||
background: var(--sider-bg) !important;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.sidebar-header {
|
||||
padding: 24px 20px;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||
text-align: center;
|
||||
transition: padding 0.2s ease;
|
||||
}
|
||||
|
||||
/* 折叠态收窄水平留白,与宽度动画同步 */
|
||||
.ant-layout-sider-collapsed .sidebar-header {
|
||||
padding: 20px 8px;
|
||||
}
|
||||
|
||||
.logo {
|
||||
position: relative;
|
||||
height: 31px;
|
||||
line-height: 31px;
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
color: white;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
/* 全称与缩写叠加存放,折叠时交叉淡入淡出 */
|
||||
.logo-full,
|
||||
.logo-short {
|
||||
display: block;
|
||||
white-space: nowrap;
|
||||
transition:
|
||||
opacity 0.2s ease,
|
||||
transform 0.2s ease;
|
||||
}
|
||||
|
||||
.logo-short {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
opacity: 0;
|
||||
transform: scale(0.5);
|
||||
}
|
||||
|
||||
.ant-layout-sider-collapsed .logo-full {
|
||||
opacity: 0;
|
||||
transform: scale(0.5);
|
||||
}
|
||||
|
||||
.ant-layout-sider-collapsed .logo-short {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
|
||||
/* 副标题折叠时渐隐并收起占位高度 */
|
||||
.logo-subtitle {
|
||||
max-height: 18px;
|
||||
overflow: hidden;
|
||||
opacity: 1;
|
||||
font-size: 12px;
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
transition:
|
||||
opacity 0.2s ease,
|
||||
max-height 0.2s ease;
|
||||
}
|
||||
|
||||
.ant-layout-sider-collapsed .logo-subtitle {
|
||||
max-height: 0;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.main-header {
|
||||
background: white !important;
|
||||
padding: 0 20px 0 10px !important;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
box-shadow:
|
||||
0 1px 3px 0 rgba(0, 0, 0, 0.1),
|
||||
0 1px 2px 0 rgba(0, 0, 0, 0.06);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
height: 50px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
/* 页头平台选择器:图标 + 无边框下拉,视觉与用户触发区一致 */
|
||||
.platform-selector {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 6px;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.platform-selector:hover {
|
||||
background: #f3f4f6;
|
||||
}
|
||||
|
||||
.platform-selector-icon {
|
||||
color: var(--primary-color);
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
/* 头像 + 用户名触发区:hover 有浅色背景反馈,暗示可下拉 */
|
||||
.header-user-trigger {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 4px 8px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
max-width: 240px;
|
||||
}
|
||||
|
||||
.header-user-trigger:hover {
|
||||
background: #f3f4f6;
|
||||
}
|
||||
|
||||
/* 用户名超长省略 */
|
||||
.header-user-name {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #1f2937;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.collapse-trigger,
|
||||
.mobile-menu-trigger {
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
color: #1f2937;
|
||||
border-radius: 4px;
|
||||
transition: background 0.2s;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.collapse-trigger:hover,
|
||||
.mobile-menu-trigger:hover {
|
||||
background: #f9fafb;
|
||||
}
|
||||
|
||||
.page-breadcrumb {
|
||||
font-size: 14px;
|
||||
line-height: 1;
|
||||
white-space: nowrap;
|
||||
/* 与 collapse-trigger 一致的 28px 盒子,内部垂直居中 */
|
||||
min-height: 28px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* 条目与分隔图标按垂直中心对齐,避免不同字号图标基线错位 */
|
||||
.page-breadcrumb ol {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.page-breadcrumb li {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.page-breadcrumb .ant-breadcrumb-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
.breadcrumb-crumb {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.breadcrumb-crumb .anticon {
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.page-breadcrumb .ant-breadcrumb-separator {
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.main-content {
|
||||
padding: 20px;
|
||||
background: #f3f4f6;
|
||||
min-height: calc(100vh - 50px);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.main-content {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.main-header {
|
||||
padding: 0 16px !important;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Outlet, useLocation, useNavigate } from 'react-router-dom';
|
||||
import { Avatar, Breadcrumb, Drawer, Dropdown, Layout } from 'antd';
|
||||
import type { MenuProps } from 'antd';
|
||||
import {
|
||||
LockOutlined,
|
||||
LogoutOutlined,
|
||||
MenuFoldOutlined,
|
||||
MenuUnfoldOutlined,
|
||||
RightOutlined,
|
||||
UserOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import SidebarMenu from './SidebarMenu';
|
||||
import PlatformSelector from './PlatformSelector';
|
||||
import { getBreadcrumbItems } from './menuConfig';
|
||||
import { useSession } from '@/services/session';
|
||||
import './MainLayout.css';
|
||||
|
||||
const { Header, Sider, Content } = Layout;
|
||||
|
||||
const MainLayout = () => {
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
||||
const [isMobile, setIsMobile] = useState(false);
|
||||
const [loggingOut, setLoggingOut] = useState(false);
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const { session, logout } = useSession();
|
||||
|
||||
const breadcrumbItems = useMemo(
|
||||
() =>
|
||||
getBreadcrumbItems(location.pathname).map((crumb) => ({
|
||||
title: (
|
||||
<span className="breadcrumb-crumb">
|
||||
{crumb.icon}
|
||||
<span>{crumb.label}</span>
|
||||
</span>
|
||||
),
|
||||
})),
|
||||
[location.pathname]
|
||||
);
|
||||
|
||||
const userDisplayName = session ? (session.user.display_name ?? session.user.email) : '';
|
||||
const userInitial = userDisplayName.charAt(0).toUpperCase();
|
||||
|
||||
const handleLogout = async () => {
|
||||
setLoggingOut(true);
|
||||
try {
|
||||
await logout();
|
||||
} finally {
|
||||
setLoggingOut(false);
|
||||
navigate('/login', { replace: true });
|
||||
}
|
||||
};
|
||||
|
||||
const handleUserMenuClick: MenuProps['onClick'] = ({ key }) => {
|
||||
if (key === 'profile') {
|
||||
navigate('/settings/profile');
|
||||
} else if (key === 'password') {
|
||||
navigate('/settings/password');
|
||||
} else if (key === 'logout') {
|
||||
void handleLogout();
|
||||
}
|
||||
};
|
||||
|
||||
const userMenuItems: MenuProps['items'] = [
|
||||
{ key: 'profile', icon: <UserOutlined />, label: '修改资料' },
|
||||
{ key: 'password', icon: <LockOutlined />, label: '修改密码' },
|
||||
{ type: 'divider' },
|
||||
{
|
||||
key: 'logout',
|
||||
icon: <LogoutOutlined />,
|
||||
label: loggingOut ? '退出中…' : '退出登录',
|
||||
disabled: loggingOut,
|
||||
},
|
||||
];
|
||||
|
||||
useEffect(() => {
|
||||
const checkMobile = () => {
|
||||
setIsMobile(window.innerWidth < 768);
|
||||
if (window.innerWidth >= 768) setMobileMenuOpen(false);
|
||||
};
|
||||
checkMobile();
|
||||
window.addEventListener('resize', checkMobile);
|
||||
return () => window.removeEventListener('resize', checkMobile);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Layout className="main-layout">
|
||||
{!isMobile && (
|
||||
<Sider
|
||||
className="main-sider"
|
||||
width={200}
|
||||
collapsedWidth={60}
|
||||
collapsed={collapsed}
|
||||
theme="dark"
|
||||
style={{
|
||||
overflow: 'auto',
|
||||
height: '100vh',
|
||||
position: 'fixed',
|
||||
left: 0,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
}}
|
||||
>
|
||||
<div className="sidebar-header">
|
||||
<div className="logo">
|
||||
<span className="logo-full">Atlas Kit</span>
|
||||
<span className="logo-short">A</span>
|
||||
</div>
|
||||
<div className="logo-subtitle">商品工作台</div>
|
||||
</div>
|
||||
<SidebarMenu collapsed={collapsed} />
|
||||
</Sider>
|
||||
)}
|
||||
|
||||
{isMobile && (
|
||||
<Drawer
|
||||
title="Atlas Kit"
|
||||
placement="left"
|
||||
onClose={() => setMobileMenuOpen(false)}
|
||||
open={mobileMenuOpen}
|
||||
styles={{ body: { padding: 0, background: 'var(--sider-bg)' } }}
|
||||
width={200}
|
||||
>
|
||||
<SidebarMenu collapsed={false} onMenuClick={() => setMobileMenuOpen(false)} />
|
||||
</Drawer>
|
||||
)}
|
||||
|
||||
<Layout
|
||||
className="main-content-layout"
|
||||
style={{
|
||||
marginLeft: isMobile ? 0 : collapsed ? 60 : 200,
|
||||
transition: 'margin-left 0.2s',
|
||||
}}
|
||||
>
|
||||
<Header className="main-header">
|
||||
<div className="header-left">
|
||||
{isMobile && (
|
||||
<MenuFoldOutlined
|
||||
className="mobile-menu-trigger"
|
||||
onClick={() => setMobileMenuOpen(true)}
|
||||
/>
|
||||
)}
|
||||
{!isMobile && (
|
||||
<div
|
||||
className="collapse-trigger"
|
||||
onClick={() => setCollapsed(!collapsed)}
|
||||
>
|
||||
{collapsed ? <MenuUnfoldOutlined /> : <MenuFoldOutlined />}
|
||||
</div>
|
||||
)}
|
||||
<Breadcrumb
|
||||
className="page-breadcrumb"
|
||||
separator={<RightOutlined style={{ fontSize: 12 }} />}
|
||||
items={breadcrumbItems}
|
||||
/>
|
||||
</div>
|
||||
{session && (
|
||||
<div className="header-right">
|
||||
<PlatformSelector />
|
||||
<Dropdown
|
||||
menu={{ items: userMenuItems, onClick: handleUserMenuClick }}
|
||||
trigger={['hover']}
|
||||
placement="bottomRight"
|
||||
>
|
||||
<div className="header-user-trigger" title={userDisplayName}>
|
||||
<Avatar
|
||||
size={28}
|
||||
style={{ backgroundColor: 'var(--sider-bg)' }}
|
||||
>
|
||||
{userInitial}
|
||||
</Avatar>
|
||||
<span className="header-user-name">{userDisplayName}</span>
|
||||
</div>
|
||||
</Dropdown>
|
||||
</div>
|
||||
)}
|
||||
</Header>
|
||||
|
||||
<Content className="main-content">
|
||||
<Outlet />
|
||||
</Content>
|
||||
</Layout>
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
|
||||
export default MainLayout;
|
||||
@@ -0,0 +1,46 @@
|
||||
import { GlobalOutlined } from '@ant-design/icons';
|
||||
import { Select, Tooltip } from 'antd';
|
||||
import { usePlatform } from '@/services/platform';
|
||||
|
||||
/**
|
||||
* 页头平台选择器:采集编辑、发布等动作按页头当前平台取得发布配置与默认店铺
|
||||
* (docs/requirements/collector-browser-extension.md 的页头平台上下文约定)。
|
||||
* 首次使用默认选中目录第一家(Ozon 优先),选择结果仅作为非敏感 UI 偏好本地记忆。
|
||||
*/
|
||||
const PlatformSelector = () => {
|
||||
const {
|
||||
platforms,
|
||||
currentPlatform,
|
||||
selectPlatform,
|
||||
context,
|
||||
contextLoading,
|
||||
} = usePlatform();
|
||||
|
||||
if (platforms.length === 0) return null;
|
||||
|
||||
const tooltip = context?.default_store
|
||||
? `当前平台:${currentPlatform?.name},默认店铺:${context.default_store.name}`
|
||||
: '选择当前平台';
|
||||
|
||||
return (
|
||||
<Tooltip title={tooltip}>
|
||||
<div className="platform-selector">
|
||||
<GlobalOutlined className="platform-selector-icon" />
|
||||
<Select
|
||||
variant="borderless"
|
||||
loading={contextLoading && !currentPlatform}
|
||||
value={currentPlatform?.id}
|
||||
onChange={selectPlatform}
|
||||
options={platforms.map((platform) => ({
|
||||
value: platform.id,
|
||||
label: platform.name,
|
||||
}))}
|
||||
popupMatchSelectWidth={false}
|
||||
aria-label="选择当前平台"
|
||||
/>
|
||||
</div>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
|
||||
export default PlatformSelector;
|
||||
@@ -0,0 +1,162 @@
|
||||
.sidebar-menu-container {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.sidebar-menu {
|
||||
background: transparent !important;
|
||||
border: none;
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.sidebar-menu .ant-menu-item {
|
||||
margin: 0 !important;
|
||||
padding: 12px 20px !important;
|
||||
height: auto !important;
|
||||
line-height: 1.5 !important;
|
||||
transition: all 0.3s !important;
|
||||
font-size: 15px !important;
|
||||
font-weight: 600 !important;
|
||||
}
|
||||
|
||||
.sidebar-menu .ant-menu-item .ant-menu-title-content {
|
||||
font-size: 15px !important;
|
||||
font-weight: 600 !important;
|
||||
/* antd 默认只过渡 color,会覆盖下面的 width/opacity 过渡;必须 !important 提权 */
|
||||
transition:
|
||||
transform 0.3s cubic-bezier(0.4, 0, 0.2, 1),
|
||||
width 0.2s ease,
|
||||
opacity 0.2s ease,
|
||||
margin 0.3s,
|
||||
color 0.3s !important;
|
||||
display: inline-block;
|
||||
/* 显式宽度让折叠时 width→0 可插值,配合裁剪+渐隐平滑收起(auto→0 无法动画) */
|
||||
width: 100%;
|
||||
opacity: 1;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.sidebar-menu .ant-menu-item:hover {
|
||||
background: rgba(255, 255, 255, 0.1) !important;
|
||||
}
|
||||
|
||||
.sidebar-menu .ant-menu-item-selected {
|
||||
background: rgba(255, 255, 255, 0.2) !important;
|
||||
border-left: 3px solid white;
|
||||
}
|
||||
|
||||
.sidebar-menu .ant-menu-item-selected .ant-menu-title-content {
|
||||
transform: translateX(8px);
|
||||
}
|
||||
|
||||
.sidebar-menu .ant-menu-item-selected:hover {
|
||||
background: rgba(255, 255, 255, 0.25) !important;
|
||||
}
|
||||
|
||||
.sidebar-menu .ant-menu-item-selected:hover .ant-menu-title-content {
|
||||
transform: translateX(8px);
|
||||
}
|
||||
|
||||
.sidebar-menu .ant-menu-item-group-title {
|
||||
padding: 12px 20px 8px !important;
|
||||
font-size: 12px !important;
|
||||
color: rgba(255, 255, 255, 0.7) !important;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
font-weight: 500;
|
||||
/* 折叠时渐隐并收起占位高度(!important 防止被 antd 默认 transition 覆盖) */
|
||||
max-height: 40px;
|
||||
opacity: 1;
|
||||
overflow: hidden;
|
||||
transition:
|
||||
opacity 0.2s ease,
|
||||
max-height 0.2s ease,
|
||||
padding 0.2s ease !important;
|
||||
}
|
||||
|
||||
.sidebar-menu .ant-menu-item-icon {
|
||||
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
font-size: 18px !important;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* hover 放大只作用于非选中项;选中项图标固定 translateX(8px),hover 时不得变 transform 导致跳动 */
|
||||
.sidebar-menu .ant-menu-item:hover:not(.ant-menu-item-selected) .ant-menu-item-icon {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.sidebar-menu .ant-menu-item-selected .ant-menu-item-icon {
|
||||
transform: translateX(8px);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.sidebar-menu .ant-menu-item {
|
||||
padding: 14px 20px !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── 折叠态(60px):图标居中,覆盖展开态的左对齐 padding/位移 ── */
|
||||
.sidebar-menu.ant-menu-inline-collapsed .ant-menu-item,
|
||||
.sidebar-menu.ant-menu-inline-collapsed .ant-menu-item-group-title {
|
||||
padding: 12px 0 !important;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* 分组标题折叠时渐隐并完全收起,不占纵向空间 */
|
||||
.sidebar-menu.ant-menu-inline-collapsed .ant-menu-item-group-title {
|
||||
max-height: 0;
|
||||
opacity: 0;
|
||||
padding-top: 0 !important;
|
||||
padding-bottom: 0 !important;
|
||||
}
|
||||
|
||||
.sidebar-menu.ant-menu-inline-collapsed .ant-menu-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.sidebar-menu.ant-menu-inline-collapsed .ant-menu-item .ant-menu-title-content,
|
||||
.sidebar-menu.ant-menu-inline-collapsed .ant-menu-item-selected .ant-menu-title-content {
|
||||
transform: none !important;
|
||||
}
|
||||
|
||||
.sidebar-menu.ant-menu-inline-collapsed .ant-menu-item .ant-menu-item-icon,
|
||||
.sidebar-menu.ant-menu-inline-collapsed .ant-menu-item:hover .ant-menu-item-icon,
|
||||
.sidebar-menu.ant-menu-inline-collapsed .ant-menu-item-selected .ant-menu-item-icon {
|
||||
transform: none !important;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* 折叠时去掉左侧 3px 选中边框(会挤偏图标);只过渡宽度,避免 none 突变 */
|
||||
.sidebar-menu.ant-menu-inline-collapsed .ant-menu-item-selected {
|
||||
border-left-width: 0 !important;
|
||||
}
|
||||
|
||||
/* 折叠时菜单文字平滑收起:width 100%→0 插值 + 裁剪 + 渐隐;
|
||||
不用 display:none(无法过渡,文字瞬间消失) */
|
||||
.sidebar-menu.ant-menu-inline-collapsed .ant-menu-item .ant-menu-title-content,
|
||||
.sidebar-menu.ant-menu-vertical .ant-menu-item .ant-menu-title-content {
|
||||
width: 0 !important;
|
||||
opacity: 0 !important;
|
||||
}
|
||||
|
||||
/* 兼容:部分版本折叠态用 vertical 类渲染,同样居中 */
|
||||
.sidebar-menu.ant-menu-vertical .ant-menu-item,
|
||||
.sidebar-menu.ant-menu-inline-collapsed .ant-menu-item {
|
||||
padding-left: 0 !important;
|
||||
padding-right: 0 !important;
|
||||
}
|
||||
|
||||
/* 折叠态 flex 子元素居中(display:flex 已生效,缺 justify-content) */
|
||||
.sidebar-menu.ant-menu-inline-collapsed .ant-menu-item {
|
||||
justify-content: center !important;
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import { Menu } from 'antd';
|
||||
import type { MenuProps } from 'antd';
|
||||
import { routeMenuConfig } from './menuConfig';
|
||||
import './SidebarMenu.css';
|
||||
|
||||
interface SidebarMenuProps {
|
||||
collapsed: boolean;
|
||||
onMenuClick?: () => void;
|
||||
}
|
||||
|
||||
const SidebarMenu = ({ collapsed, onMenuClick }: SidebarMenuProps) => {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
|
||||
// 分组标题保持常驻,折叠时的隐藏由 CSS 过渡完成,避免文字瞬间消失
|
||||
const menuItems: MenuProps['items'] = useMemo(() => {
|
||||
const children = routeMenuConfig.map((config) => ({
|
||||
key: config.key,
|
||||
icon: config.icon,
|
||||
label: config.label,
|
||||
}));
|
||||
return [
|
||||
{
|
||||
type: 'group' as const,
|
||||
label: '主要功能',
|
||||
className: 'menu-section-title',
|
||||
children,
|
||||
},
|
||||
];
|
||||
}, []);
|
||||
|
||||
const selectedKeys = useMemo(() => {
|
||||
const path = location.pathname;
|
||||
if (path === '/' || path === '') {
|
||||
return [routeMenuConfig[0].key];
|
||||
}
|
||||
const config = routeMenuConfig.find((item) => path.startsWith(item.path));
|
||||
return [config ? config.key : path];
|
||||
}, [location.pathname]);
|
||||
|
||||
const handleMenuClick: MenuProps['onClick'] = ({ key }) => {
|
||||
navigate(key);
|
||||
if (onMenuClick) onMenuClick();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="sidebar-menu-container">
|
||||
<Menu
|
||||
theme="dark"
|
||||
mode="inline"
|
||||
inlineCollapsed={collapsed}
|
||||
selectedKeys={selectedKeys}
|
||||
items={menuItems}
|
||||
onClick={handleMenuClick}
|
||||
className="sidebar-menu"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SidebarMenu;
|
||||
@@ -0,0 +1,87 @@
|
||||
import {
|
||||
CalculatorOutlined,
|
||||
CodeSandboxOutlined,
|
||||
EditOutlined,
|
||||
GlobalOutlined,
|
||||
InboxOutlined,
|
||||
LockOutlined,
|
||||
PictureOutlined,
|
||||
SettingOutlined,
|
||||
ShopOutlined,
|
||||
UserOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
export interface RouteMenuConfig {
|
||||
/** 路由路径 */
|
||||
path: string;
|
||||
/** 菜单键值(与 path 相同) */
|
||||
key: string;
|
||||
/** 菜单图标(菜单与页头面包屑共用) */
|
||||
icon: ReactNode;
|
||||
/** 菜单标签 */
|
||||
label: string;
|
||||
}
|
||||
|
||||
/** 左侧一级菜单,顺序与 docs/requirements/account-store-watermark.md 的导航约定一致 */
|
||||
export const routeMenuConfig: RouteMenuConfig[] = [
|
||||
{
|
||||
path: '/online-products',
|
||||
key: '/online-products',
|
||||
icon: <GlobalOutlined />,
|
||||
label: '在线商品',
|
||||
},
|
||||
{
|
||||
path: '/collection',
|
||||
key: '/collection',
|
||||
icon: <InboxOutlined />,
|
||||
label: '采集箱',
|
||||
},
|
||||
{
|
||||
path: '/shops',
|
||||
key: '/shops',
|
||||
icon: <ShopOutlined />,
|
||||
label: '店铺管理',
|
||||
},
|
||||
{
|
||||
path: '/watermarks',
|
||||
key: '/watermarks',
|
||||
icon: <PictureOutlined />,
|
||||
label: '水印管理',
|
||||
},
|
||||
{
|
||||
path: '/pricing',
|
||||
key: '/pricing',
|
||||
icon: <CalculatorOutlined />,
|
||||
label: '定价工具',
|
||||
},
|
||||
];
|
||||
|
||||
export interface BreadcrumbCrumb {
|
||||
icon: ReactNode;
|
||||
label: string;
|
||||
}
|
||||
|
||||
/** 页头面包屑:一级菜单页显示单级;不在菜单中的页面显示两级 */
|
||||
export const getBreadcrumbItems = (path: string): BreadcrumbCrumb[] => {
|
||||
if (path.startsWith('/products/')) {
|
||||
return [
|
||||
{ icon: <CodeSandboxOutlined />, label: '商品' },
|
||||
{ icon: <EditOutlined />, label: '商品编辑' },
|
||||
];
|
||||
}
|
||||
if (path === '/settings/profile') {
|
||||
return [
|
||||
{ icon: <SettingOutlined />, label: '设置' },
|
||||
{ icon: <UserOutlined />, label: '修改资料' },
|
||||
];
|
||||
}
|
||||
if (path === '/settings/password') {
|
||||
return [
|
||||
{ icon: <SettingOutlined />, label: '设置' },
|
||||
{ icon: <LockOutlined />, label: '修改密码' },
|
||||
];
|
||||
}
|
||||
const config = routeMenuConfig.find((item) => path.startsWith(item.path)) ?? routeMenuConfig[0];
|
||||
return [{ icon: config.icon, label: config.label }];
|
||||
};
|
||||
@@ -0,0 +1,10 @@
|
||||
import { StrictMode } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import './index.css';
|
||||
import App from './App';
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>
|
||||
);
|
||||
@@ -0,0 +1,112 @@
|
||||
import { useState } from 'react';
|
||||
import { Link, useLocation, useNavigate } from 'react-router-dom';
|
||||
import { App as AntdApp, Button, Form, Input } from 'antd';
|
||||
import { LockOutlined, MailOutlined } from '@ant-design/icons';
|
||||
import axios from 'axios';
|
||||
import AuthLayout from '@/layouts/AuthLayout';
|
||||
import { apiErrorDetails, apiErrorMessage } from '@/services/api';
|
||||
import { authApi } from '@/services/auth';
|
||||
import { useSession } from '@/services/session';
|
||||
import { emailFormatRule } from '@/utils/validators';
|
||||
|
||||
interface LoginFormValues {
|
||||
email: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
/** 登录页,契约见 docs/api/auth.md §3 */
|
||||
const LoginPage = () => {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const { message } = AntdApp.useApp();
|
||||
const { applyAuthSession } = useSession();
|
||||
const [form] = Form.useForm<LoginFormValues>();
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
const handleFinish = async (values: LoginFormValues) => {
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const session = await authApi.login({
|
||||
email: values.email.trim(),
|
||||
password: values.password,
|
||||
});
|
||||
applyAuthSession(session);
|
||||
message.success('登录成功');
|
||||
// 守卫记住的来源路径优先,默认进入在线商品首页
|
||||
const from = (location.state as { from?: string } | null)?.from;
|
||||
navigate(from && from.startsWith('/') ? from : '/online-products', {
|
||||
replace: true,
|
||||
});
|
||||
} catch (error) {
|
||||
// 契约约定登录失败统一返回 401 INVALID_CREDENTIALS,不区分邮箱不存在或密码错误
|
||||
if (axios.isAxiosError(error) && error.response?.status === 401) {
|
||||
message.error('邮箱或密码不正确');
|
||||
return;
|
||||
}
|
||||
// 422 VALIDATION_FAILED 等带字段明细的错误,逐项落到对应表单项
|
||||
const details = apiErrorDetails(error);
|
||||
if (details.length > 0) {
|
||||
form.setFields(
|
||||
details.map((item) => ({
|
||||
name: item.field as keyof LoginFormValues,
|
||||
errors: [item.message],
|
||||
}))
|
||||
);
|
||||
} else {
|
||||
message.error(apiErrorMessage(error));
|
||||
}
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthLayout
|
||||
title="登录"
|
||||
subtitle="登录 Atlas Seller Kit,继续管理你的商品"
|
||||
footer={
|
||||
<>
|
||||
还没有账户?<Link to="/register">立即注册</Link>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Form<LoginFormValues>
|
||||
form={form}
|
||||
layout="vertical"
|
||||
requiredMark={false}
|
||||
onFinish={handleFinish}
|
||||
>
|
||||
<Form.Item
|
||||
name="email"
|
||||
label="邮箱"
|
||||
rules={[{ required: true, message: '请输入邮箱' }, emailFormatRule]}
|
||||
>
|
||||
<Input
|
||||
prefix={<MailOutlined style={{ color: 'var(--text-secondary)' }} />}
|
||||
placeholder="请输入邮箱"
|
||||
autoComplete="email"
|
||||
autoFocus
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="password"
|
||||
label="密码"
|
||||
rules={[{ required: true, message: '请输入密码' }]}
|
||||
>
|
||||
<Input.Password
|
||||
prefix={<LockOutlined style={{ color: 'var(--text-secondary)' }} />}
|
||||
placeholder="请输入密码"
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item style={{ marginBottom: 0, marginTop: 8 }}>
|
||||
<Button type="primary" htmlType="submit" block loading={submitting}>
|
||||
登录
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</AuthLayout>
|
||||
);
|
||||
};
|
||||
|
||||
export default LoginPage;
|
||||
@@ -0,0 +1,147 @@
|
||||
import { useState } from 'react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { App as AntdApp, Button, Form, Input } from 'antd';
|
||||
import { LockOutlined, MailOutlined, UserOutlined } from '@ant-design/icons';
|
||||
import axios from 'axios';
|
||||
import AuthLayout from '@/layouts/AuthLayout';
|
||||
import { apiErrorDetails, apiErrorMessage } from '@/services/api';
|
||||
import { authApi } from '@/services/auth';
|
||||
import { useSession } from '@/services/session';
|
||||
import { emailFormatRule } from '@/utils/validators';
|
||||
|
||||
interface RegisterFormValues {
|
||||
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 { applyAuthSession } = useSession();
|
||||
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,
|
||||
});
|
||||
// 注册成功即在同一事务创建用户、个人工作空间和登录会话
|
||||
applyAuthSession(session);
|
||||
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: ['该邮箱已注册,请直接登录'] }]);
|
||||
return;
|
||||
}
|
||||
// 422 VALIDATION_FAILED 等带字段明细的错误,逐项落到对应表单项
|
||||
const details = apiErrorDetails(error);
|
||||
if (details.length > 0) {
|
||||
form.setFields(
|
||||
details.map((item) => ({
|
||||
name: item.field as keyof RegisterFormValues,
|
||||
errors: [item.message],
|
||||
}))
|
||||
);
|
||||
} 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 个字符' }]}
|
||||
>
|
||||
<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="至少 12 个字符;长度与复杂度以服务端安全校验为准"
|
||||
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;
|
||||
@@ -0,0 +1,8 @@
|
||||
import PagePlaceholder from '@/components/PagePlaceholder';
|
||||
|
||||
/** 采集箱:查看已采集的商品,进入编辑上架(业务模块尚未实现) */
|
||||
const CollectionPage = () => (
|
||||
<PagePlaceholder title="采集箱" description="查看已采集的商品,进入编辑上架,模块开发中。" />
|
||||
);
|
||||
|
||||
export default CollectionPage;
|
||||
@@ -0,0 +1,11 @@
|
||||
import PagePlaceholder from '@/components/PagePlaceholder';
|
||||
|
||||
/** 在线商品:按 SKU 查看平台已上架商品与同步状态(业务模块尚未实现) */
|
||||
const OnlineProductsPage = () => (
|
||||
<PagePlaceholder
|
||||
title="在线商品"
|
||||
description="按 SKU 查看平台已上架商品与同步状态,模块开发中。"
|
||||
/>
|
||||
);
|
||||
|
||||
export default OnlineProductsPage;
|
||||
@@ -0,0 +1,8 @@
|
||||
import PagePlaceholder from '@/components/PagePlaceholder';
|
||||
|
||||
/** 定价工具:独立试算与物流计价规则(业务模块尚未实现) */
|
||||
const PricingPage = () => (
|
||||
<PagePlaceholder title="定价工具" description="独立试算与物流计价规则,模块开发中。" />
|
||||
);
|
||||
|
||||
export default PricingPage;
|
||||
@@ -0,0 +1,16 @@
|
||||
import { useParams } from 'react-router-dom';
|
||||
import PagePlaceholder from '@/components/PagePlaceholder';
|
||||
|
||||
/** 商品 AI 编辑页:编辑 SPU/SKU、媒体、定价与首次发布(业务模块尚未实现) */
|
||||
const ProductEditPage = () => {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
|
||||
return (
|
||||
<PagePlaceholder
|
||||
title={`商品编辑(${id ?? '未知 ID'})`}
|
||||
description="商品 AI 编辑页:SPU/SKU、媒体、定价与发布,模块开发中。"
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProductEditPage;
|
||||
@@ -0,0 +1,128 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { App as AntdApp, Button, Form, Input } from 'antd';
|
||||
import axios from 'axios';
|
||||
import { authApi } from '@/services/auth';
|
||||
import { authToken } from '@/services/authToken';
|
||||
import { apiErrorDetails, apiErrorMessage } from '@/services/api';
|
||||
|
||||
interface PasswordFormValues {
|
||||
old_password: string;
|
||||
password: string;
|
||||
password_confirmation: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 修改密码页,契约见 docs/api/auth.md(POST /api/auth/change-password)。
|
||||
* 成功后所有旧 session 失效:立即用响应中的新 access token 替换内存值
|
||||
* (本设备经新的 refresh cookie 保持登录),清理表单状态后回到首页。
|
||||
*/
|
||||
const PasswordSettingsPage = () => {
|
||||
const { message } = AntdApp.useApp();
|
||||
const navigate = useNavigate();
|
||||
const [form] = Form.useForm<PasswordFormValues>();
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
const handleFinish = async (values: PasswordFormValues) => {
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const result = await authApi.changePassword({
|
||||
old_password: values.old_password,
|
||||
password: values.password,
|
||||
password_confirmation: values.password_confirmation,
|
||||
});
|
||||
authToken.set(result.access_token);
|
||||
form.resetFields();
|
||||
message.success('密码已修改');
|
||||
navigate('/online-products', { replace: true });
|
||||
} catch (error) {
|
||||
if (axios.isAxiosError(error) && error.response?.status === 401) {
|
||||
message.error('原密码不正确');
|
||||
} else {
|
||||
const details = apiErrorDetails(error);
|
||||
if (details.length > 0) {
|
||||
form.setFields(
|
||||
details.map((item) => ({
|
||||
name: item.field as keyof PasswordFormValues,
|
||||
errors: [item.message],
|
||||
}))
|
||||
);
|
||||
} else {
|
||||
message.error(apiErrorMessage(error));
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="settings-page">
|
||||
<div className="settings-card">
|
||||
<div className="settings-card-title">修改密码</div>
|
||||
<div className="settings-card-body">
|
||||
<Form<PasswordFormValues>
|
||||
form={form}
|
||||
layout="vertical"
|
||||
onFinish={handleFinish}
|
||||
className="profile-form"
|
||||
>
|
||||
<Form.Item
|
||||
name="old_password"
|
||||
label="原密码"
|
||||
rules={[{ required: true, message: '请输入原密码' }]}
|
||||
>
|
||||
<Input.Password
|
||||
placeholder="请输入原密码"
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="password"
|
||||
label="新密码"
|
||||
extra="至少 12 个字符;长度与复杂度以服务端安全校验为准"
|
||||
rules={[
|
||||
{ required: true, message: '请输入新密码' },
|
||||
{ min: 12, message: '新密码至少需要 12 个字符' },
|
||||
]}
|
||||
>
|
||||
<Input.Password
|
||||
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
|
||||
placeholder="请再次输入新密码"
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</Form.Item>
|
||||
<div className="settings-actions">
|
||||
<Button type="primary" htmlType="submit" loading={submitting}>
|
||||
保存修改
|
||||
</Button>
|
||||
<Button onClick={() => navigate('/')}>取消</Button>
|
||||
</div>
|
||||
</Form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PasswordSettingsPage;
|
||||
@@ -0,0 +1,114 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { App as AntdApp, Avatar, Button, Form, Input } from 'antd';
|
||||
import axios from 'axios';
|
||||
import { authApi } from '@/services/auth';
|
||||
import { useSession } from '@/services/session';
|
||||
import { apiErrorDetails, apiErrorMessage } from '@/services/api';
|
||||
|
||||
interface ProfileFormValues {
|
||||
display_name: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 修改资料页,契约见 docs/api/auth.md(PATCH /api/auth/me,If-Match + Idempotency-Key)。
|
||||
* 头像持久化依赖素材预签名直传接口(尚未交付):当前仅展示默认头像的禁用态,不提供上传。
|
||||
*/
|
||||
const ProfileSettingsPage = () => {
|
||||
const { message } = AntdApp.useApp();
|
||||
const navigate = useNavigate();
|
||||
const { session, applyProfileUpdate } = useSession();
|
||||
const [form] = Form.useForm<ProfileFormValues>();
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const displayName = session?.user.display_name ?? session?.user.email ?? '';
|
||||
const initial = useMemo(() => displayName.charAt(0).toUpperCase(), [displayName]);
|
||||
|
||||
if (!session) return null;
|
||||
|
||||
const handleSave = async (values: ProfileFormValues) => {
|
||||
setSaving(true);
|
||||
try {
|
||||
const user = await authApi.updateProfile(
|
||||
{ display_name: values.display_name.trim() },
|
||||
// 幂等键每次提交重新生成;以提交时的 revision 做乐观锁
|
||||
{ revision: session.user.revision, idempotencyKey: crypto.randomUUID() }
|
||||
);
|
||||
applyProfileUpdate(user);
|
||||
message.success('资料已更新');
|
||||
} catch (error) {
|
||||
// 乐观锁版本过期:服务端返回 409 与当前 revision,提示刷新后重试
|
||||
if (axios.isAxiosError(error) && error.response?.status === 409) {
|
||||
message.error('资料已被其他操作更新,请刷新页面获取最新资料后重试');
|
||||
} else {
|
||||
const details = apiErrorDetails(error);
|
||||
if (details.length > 0) {
|
||||
form.setFields(
|
||||
details
|
||||
.filter((item) => item.field !== 'If-Match')
|
||||
.map((item) => ({
|
||||
name: item.field as keyof ProfileFormValues,
|
||||
errors: [item.message],
|
||||
}))
|
||||
);
|
||||
} else {
|
||||
message.error(apiErrorMessage(error));
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
form.setFieldValue('display_name', session.user.display_name ?? '');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="settings-page">
|
||||
<div className="settings-card">
|
||||
<div className="settings-card-title">修改个人信息</div>
|
||||
<div className="settings-card-body">
|
||||
<div className="settings-field-label">头像</div>
|
||||
<div className="avatar-upload-area">
|
||||
<Avatar size={160} style={{ backgroundColor: 'var(--sider-bg)' }}>
|
||||
{initial}
|
||||
</Avatar>
|
||||
</div>
|
||||
<div className="avatar-upload-hint">头像上传即将支持,敬请期待</div>
|
||||
|
||||
<Form<ProfileFormValues>
|
||||
form={form}
|
||||
layout="vertical"
|
||||
initialValues={{ display_name: session.user.display_name ?? '' }}
|
||||
onFinish={handleSave}
|
||||
className="profile-form"
|
||||
>
|
||||
<Form.Item
|
||||
name="display_name"
|
||||
label="昵称"
|
||||
required
|
||||
rules={[{ required: true, message: '请输入昵称' }]}
|
||||
>
|
||||
<Input
|
||||
maxLength={80}
|
||||
showCount
|
||||
placeholder="请输入昵称"
|
||||
autoComplete="nickname"
|
||||
/>
|
||||
</Form.Item>
|
||||
<div className="settings-actions">
|
||||
<Button type="primary" htmlType="submit" loading={saving}>
|
||||
保存修改
|
||||
</Button>
|
||||
<Button onClick={handleReset}>重 置</Button>
|
||||
<Button onClick={() => navigate('/')}>取消</Button>
|
||||
</div>
|
||||
</Form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProfileSettingsPage;
|
||||
@@ -0,0 +1,11 @@
|
||||
.shops-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.shops-load-more {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-top: 16px;
|
||||
}
|
||||
@@ -0,0 +1,623 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
App as AntdApp,
|
||||
Button,
|
||||
Card,
|
||||
Dropdown,
|
||||
Empty,
|
||||
Form,
|
||||
Input,
|
||||
Modal,
|
||||
Select,
|
||||
Space,
|
||||
Switch,
|
||||
Table,
|
||||
Tag,
|
||||
Tooltip,
|
||||
Typography,
|
||||
} from 'antd';
|
||||
import type { MenuProps, TableProps } from 'antd';
|
||||
import {
|
||||
ApiOutlined,
|
||||
CloudDownloadOutlined,
|
||||
DeleteOutlined,
|
||||
MoreOutlined,
|
||||
PlusOutlined,
|
||||
ReloadOutlined,
|
||||
WarningFilled,
|
||||
} from '@ant-design/icons';
|
||||
import axios from 'axios';
|
||||
import type { Store } from '@atlas/contracts';
|
||||
import { apiErrorCode, apiErrorMessage } from '@/services/api';
|
||||
import { storesApi } from '@/services/stores';
|
||||
import { usePlatform } from '@/services/platform';
|
||||
import './ShopsPage.css';
|
||||
|
||||
const STORE_STATUS_MAP: Record<string, { color: string; text: string }> = {
|
||||
verifying: { color: 'gold', text: '验证中' },
|
||||
active: { color: 'green', text: '正常' },
|
||||
credential_invalid: { color: 'red', text: '凭据无效' },
|
||||
sync_error: { color: 'orange', text: '同步异常' },
|
||||
disabled: { color: 'default', text: '已停用' },
|
||||
deleted: { color: 'default', text: '已删除' },
|
||||
};
|
||||
|
||||
const CREDENTIAL_STATUS_MAP: Record<string, { color: string; text: string }> = {
|
||||
unknown: { color: 'default', text: '未验证' },
|
||||
valid: { color: 'green', text: '凭据有效' },
|
||||
invalid: { color: 'red', text: '凭据无效' },
|
||||
expired: { color: 'red', text: '已过期' },
|
||||
rotation_required: { color: 'gold', text: '待轮换' },
|
||||
};
|
||||
|
||||
function formatDateTime(value: string | null): string {
|
||||
if (!value) return '—';
|
||||
const date = new Date(value);
|
||||
return Number.isNaN(date.getTime())
|
||||
? '—'
|
||||
: date.toLocaleString('zh-CN', { hour12: false });
|
||||
}
|
||||
|
||||
interface CreateFormValues {
|
||||
name: string;
|
||||
client_id: string;
|
||||
api_key: string;
|
||||
currency_code: string;
|
||||
is_default: boolean;
|
||||
note?: string;
|
||||
}
|
||||
|
||||
interface EditFormValues {
|
||||
name: string;
|
||||
currency_code: string;
|
||||
note?: string;
|
||||
api_key?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 店铺管理页,契约见 docs/api/stores-and-platforms.md。
|
||||
* 列表按页头当前平台过滤(platform_id 查询参数);创建与密钥替换为异步验证,
|
||||
* Worker 消费验证操作前店铺会停留在「验证中」状态。
|
||||
*/
|
||||
const ShopsPage = () => {
|
||||
const { message, modal } = AntdApp.useApp();
|
||||
const {
|
||||
currentPlatform,
|
||||
platforms,
|
||||
error: platformError,
|
||||
reload,
|
||||
refreshContext,
|
||||
} = usePlatform();
|
||||
|
||||
const [stores, setStores] = useState<Store[]>([]);
|
||||
const [nextCursor, setNextCursor] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [loadingMore, setLoadingMore] = useState(false);
|
||||
const [pendingStoreId, setPendingStoreId] = useState<string | null>(null);
|
||||
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [createForm] = Form.useForm<CreateFormValues>();
|
||||
|
||||
const [editingStore, setEditingStore] = useState<Store | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [editForm] = Form.useForm<EditFormValues>();
|
||||
|
||||
const platformId = currentPlatform?.id ?? null;
|
||||
const currencyOptions = useMemo(
|
||||
() =>
|
||||
(currentPlatform?.supported_currencies ?? []).map((code) => ({
|
||||
value: code,
|
||||
label: code,
|
||||
})),
|
||||
[currentPlatform]
|
||||
);
|
||||
|
||||
const loadStores = useCallback(
|
||||
async (targetPlatformId: string) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const result = await storesApi.list(targetPlatformId);
|
||||
setStores(result.data);
|
||||
setNextCursor(result.meta.next_cursor);
|
||||
} catch (err) {
|
||||
message.error(apiErrorMessage(err));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[message]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!platformId) return;
|
||||
void loadStores(platformId);
|
||||
}, [platformId, loadStores]);
|
||||
|
||||
const loadMore = async () => {
|
||||
if (!platformId || !nextCursor) return;
|
||||
setLoadingMore(true);
|
||||
try {
|
||||
const result = await storesApi.list(platformId, nextCursor);
|
||||
setStores((prev) => [...prev, ...result.data]);
|
||||
setNextCursor(result.meta.next_cursor);
|
||||
} catch (err) {
|
||||
message.error(apiErrorMessage(err));
|
||||
} finally {
|
||||
setLoadingMore(false);
|
||||
}
|
||||
};
|
||||
|
||||
/** 任何变更后同时刷新列表与页头默认门店摘要 */
|
||||
const refreshAll = useCallback(async () => {
|
||||
if (!platformId) return;
|
||||
await loadStores(platformId);
|
||||
await refreshContext();
|
||||
}, [platformId, loadStores, refreshContext]);
|
||||
|
||||
const openCreate = () => {
|
||||
createForm.resetFields();
|
||||
createForm.setFieldsValue({ is_default: stores.length === 0 });
|
||||
setCreateOpen(true);
|
||||
};
|
||||
|
||||
const handleCreate = async (values: CreateFormValues) => {
|
||||
if (!currentPlatform) return;
|
||||
setCreating(true);
|
||||
try {
|
||||
await storesApi.create({
|
||||
platform_id: currentPlatform.id,
|
||||
name: values.name.trim(),
|
||||
client_id: values.client_id.trim(),
|
||||
api_key: values.api_key,
|
||||
currency_code: values.currency_code,
|
||||
is_default: values.is_default,
|
||||
note: values.note?.trim() ? values.note.trim() : null,
|
||||
});
|
||||
message.success('店铺已创建,凭据验证进行中,稍后可刷新查看结果');
|
||||
setCreateOpen(false);
|
||||
await refreshAll();
|
||||
} catch (err) {
|
||||
message.error(apiErrorMessage(err));
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const openEdit = (store: Store) => {
|
||||
setEditingStore(store);
|
||||
editForm.setFieldsValue({
|
||||
name: store.name,
|
||||
currency_code: store.currency_code,
|
||||
note: store.note ?? '',
|
||||
});
|
||||
};
|
||||
|
||||
const handleEditSave = async (values: EditFormValues) => {
|
||||
if (!editingStore) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
const result = await storesApi.update(
|
||||
editingStore.id,
|
||||
{
|
||||
name: values.name.trim(),
|
||||
currency_code: values.currency_code,
|
||||
note: values.note?.trim() ? values.note.trim() : null,
|
||||
// 留空表示保留旧密钥;填写则替换并重新触发验证
|
||||
...(values.api_key ? { api_key: values.api_key } : {}),
|
||||
},
|
||||
{ revision: editingStore.revision }
|
||||
);
|
||||
if (result && 'operation' in result) {
|
||||
message.success('已保存,新密钥验证进行中,稍后可刷新查看结果');
|
||||
} else {
|
||||
message.success('店铺已保存');
|
||||
}
|
||||
setEditingStore(null);
|
||||
await refreshAll();
|
||||
} catch (err) {
|
||||
// 乐观锁版本过期:提示后刷新列表,用户重新编辑拿到新 revision
|
||||
if (axios.isAxiosError(err) && err.response?.status === 409) {
|
||||
message.error('店铺信息已被其他操作更新,列表已刷新,请重新编辑');
|
||||
setEditingStore(null);
|
||||
await refreshAll();
|
||||
} else {
|
||||
message.error(apiErrorMessage(err));
|
||||
}
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const withPending = async (store: Store, action: () => Promise<unknown>) => {
|
||||
setPendingStoreId(store.id);
|
||||
try {
|
||||
await action();
|
||||
await refreshAll();
|
||||
} catch (err) {
|
||||
message.error(apiErrorMessage(err));
|
||||
} finally {
|
||||
setPendingStoreId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSetDefault = (store: Store) =>
|
||||
withPending(store, async () => {
|
||||
await storesApi.setDefault(store.id);
|
||||
message.success(`已将「${store.name}」设为默认店铺`);
|
||||
});
|
||||
|
||||
const handleVerify = (store: Store) =>
|
||||
withPending(store, async () => {
|
||||
await storesApi.requestCredentialVerification(store.id);
|
||||
message.success('已发起凭据验证,稍后可刷新查看结果');
|
||||
});
|
||||
|
||||
const handleSyncWarehouses = (store: Store) =>
|
||||
withPending(store, async () => {
|
||||
await storesApi.requestWarehouseSync(store.id);
|
||||
message.success('已发起仓库同步,稍后可刷新查看结果');
|
||||
});
|
||||
|
||||
const handleDelete = (store: Store) => {
|
||||
modal.confirm({
|
||||
title: '删除店铺',
|
||||
content: `仅删除 Atlas 内的店铺连接,不影响平台后台。确定删除「${store.name}」吗?`,
|
||||
okText: '删除',
|
||||
okButtonProps: { danger: true },
|
||||
cancelText: '取消',
|
||||
onOk: () =>
|
||||
withPending(store, async () => {
|
||||
try {
|
||||
await storesApi.remove(store.id);
|
||||
message.success('店铺已删除');
|
||||
} catch (err) {
|
||||
if (apiErrorCode(err) === 'DEFAULT_STORE_REPLACEMENT_REQUIRED') {
|
||||
message.error(
|
||||
'该店铺是默认店铺,请先将同平台的其他店铺设为默认,再删除'
|
||||
);
|
||||
return;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}),
|
||||
});
|
||||
};
|
||||
|
||||
const rowMenu = (store: Store): MenuProps['items'] => [
|
||||
{
|
||||
key: 'set-default',
|
||||
icon: <ApiOutlined />,
|
||||
label: '设为默认店铺',
|
||||
disabled: store.is_default,
|
||||
},
|
||||
{ key: 'verify', icon: <ReloadOutlined />, label: '重新验证凭据' },
|
||||
{ key: 'sync', icon: <CloudDownloadOutlined />, label: '同步仓库' },
|
||||
{ type: 'divider' },
|
||||
{ key: 'delete', icon: <DeleteOutlined />, label: '删除店铺', danger: true },
|
||||
];
|
||||
|
||||
const handleRowMenuClick =
|
||||
(store: Store): MenuProps['onClick'] =>
|
||||
({ key }) => {
|
||||
if (key === 'set-default') void handleSetDefault(store);
|
||||
else if (key === 'verify') void handleVerify(store);
|
||||
else if (key === 'sync') void handleSyncWarehouses(store);
|
||||
else if (key === 'delete') handleDelete(store);
|
||||
};
|
||||
|
||||
const columns: TableProps<Store>['columns'] = [
|
||||
{
|
||||
title: '店铺名称',
|
||||
dataIndex: 'name',
|
||||
width: 220,
|
||||
render: (_, store) => (
|
||||
<Space size={6}>
|
||||
<span>{store.name}</span>
|
||||
{store.is_default && <Tag color="purple">默认</Tag>}
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'Client ID',
|
||||
dataIndex: 'platform_client_id',
|
||||
width: 150,
|
||||
render: (value: string) => (
|
||||
<Typography.Text copyable={{ text: value }}>{value}</Typography.Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '币种',
|
||||
dataIndex: 'currency_code',
|
||||
width: 80,
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
width: 200,
|
||||
render: (_, store) => {
|
||||
const status = STORE_STATUS_MAP[store.status] ?? {
|
||||
color: 'default',
|
||||
text: store.status,
|
||||
};
|
||||
const credential = CREDENTIAL_STATUS_MAP[store.credential_status] ?? {
|
||||
color: 'default',
|
||||
text: store.credential_status,
|
||||
};
|
||||
return (
|
||||
<Space size={4} wrap>
|
||||
<Tag color={status.color}>{status.text}</Tag>
|
||||
<Tag color={credential.color}>{credential.text}</Tag>
|
||||
{store.last_error && (
|
||||
<Tooltip
|
||||
title={`${store.last_error.code}:${store.last_error.message}`}
|
||||
>
|
||||
<WarningFilled style={{ color: '#faad14' }} />
|
||||
</Tooltip>
|
||||
)}
|
||||
</Space>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '仓库',
|
||||
dataIndex: 'warehouse_count',
|
||||
width: 90,
|
||||
render: (count: number, store) => (
|
||||
<Tooltip
|
||||
title={
|
||||
store.warehouses_synced_at
|
||||
? `同步于 ${formatDateTime(store.warehouses_synced_at)}`
|
||||
: '尚未同步仓库'
|
||||
}
|
||||
>
|
||||
<span>{store.warehouses_synced_at ? count : '—'}</span>
|
||||
</Tooltip>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '水印模板',
|
||||
dataIndex: 'watermark_template',
|
||||
width: 130,
|
||||
render: (template: Store['watermark_template']) =>
|
||||
template ? (
|
||||
template.name
|
||||
) : (
|
||||
<Typography.Text type="secondary">未绑定</Typography.Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '备注',
|
||||
dataIndex: 'note',
|
||||
ellipsis: true,
|
||||
render: (note: string | null) =>
|
||||
note ? (
|
||||
<Typography.Text ellipsis>{note}</Typography.Text>
|
||||
) : (
|
||||
<Typography.Text type="secondary">—</Typography.Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '更新时间',
|
||||
dataIndex: 'updated_at',
|
||||
width: 170,
|
||||
render: (value: string) => (
|
||||
<Typography.Text type="secondary">
|
||||
{formatDateTime(value)}
|
||||
</Typography.Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'actions',
|
||||
width: 140,
|
||||
fixed: 'right',
|
||||
render: (_, store) => (
|
||||
<Space size={4}>
|
||||
<Button type="link" size="small" onClick={() => openEdit(store)}>
|
||||
编辑
|
||||
</Button>
|
||||
<Dropdown
|
||||
menu={{ items: rowMenu(store), onClick: handleRowMenuClick(store) }}
|
||||
trigger={['click']}
|
||||
>
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
icon={<MoreOutlined />}
|
||||
loading={pendingStoreId === store.id}
|
||||
/>
|
||||
</Dropdown>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
if (platformError) {
|
||||
return (
|
||||
<Card className="shops-page">
|
||||
<Empty description={`平台目录加载失败:${platformError}`}>
|
||||
<Button onClick={reload}>重试</Button>
|
||||
</Empty>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="shops-page">
|
||||
<Card
|
||||
title={`店铺管理${currentPlatform ? ` · ${currentPlatform.name}` : ''}`}
|
||||
extra={
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<PlusOutlined />}
|
||||
disabled={!currentPlatform}
|
||||
onClick={openCreate}
|
||||
>
|
||||
添加店铺
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
{platforms.length === 0 && !currentPlatform ? (
|
||||
<Empty description="暂无可用平台" />
|
||||
) : (
|
||||
<>
|
||||
<Table
|
||||
rowKey="id"
|
||||
columns={columns}
|
||||
dataSource={stores}
|
||||
loading={loading}
|
||||
pagination={false}
|
||||
scroll={{ x: 1100 }}
|
||||
locale={{
|
||||
emptyText: currentPlatform
|
||||
? `当前平台还没有店铺,点击右上角「添加店铺」完成 ${currentPlatform.name} 授权`
|
||||
: '请先在页头选择平台',
|
||||
}}
|
||||
/>
|
||||
{nextCursor && (
|
||||
<div className="shops-load-more">
|
||||
<Button loading={loadingMore} onClick={() => void loadMore()}>
|
||||
加载更多
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<Modal
|
||||
title={`添加${currentPlatform?.name ?? ''}店铺`}
|
||||
open={createOpen}
|
||||
onCancel={() => setCreateOpen(false)}
|
||||
onOk={() => createForm.submit()}
|
||||
confirmLoading={creating}
|
||||
okText="创建并验证"
|
||||
cancelText="取消"
|
||||
destroyOnHidden
|
||||
maskClosable={false}
|
||||
>
|
||||
<Form<CreateFormValues>
|
||||
form={createForm}
|
||||
layout="vertical"
|
||||
onFinish={handleCreate}
|
||||
>
|
||||
<Form.Item
|
||||
name="name"
|
||||
label="店铺名称"
|
||||
required
|
||||
rules={[{ required: true, whitespace: true, message: '请输入店铺名称' }]}
|
||||
>
|
||||
<Input maxLength={120} placeholder="例如:Ozon 俄罗斯主店" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="client_id"
|
||||
label="Client ID"
|
||||
required
|
||||
rules={[{ required: true, whitespace: true, message: '请输入平台 Client ID' }]}
|
||||
>
|
||||
<Input maxLength={120} placeholder="平台后台的 Client ID" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="api_key"
|
||||
label="API Key"
|
||||
required
|
||||
rules={[{ required: true, whitespace: true, message: '请输入平台 API Key' }]}
|
||||
extra="仅用于服务端加密保存与凭据验证,不会出现在日志或响应中"
|
||||
>
|
||||
{/* 契约上限 8192(packages/contracts/src/stores.ts),不自行收紧 */}
|
||||
<Input.Password
|
||||
maxLength={8192}
|
||||
placeholder="平台后台的 API Key"
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="currency_code"
|
||||
label="默认计价币种"
|
||||
required
|
||||
rules={[{ required: true, message: '请选择币种' }]}
|
||||
>
|
||||
<Select options={currencyOptions} placeholder="选择币种" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="is_default"
|
||||
label="设为默认店铺"
|
||||
valuePropName="checked"
|
||||
>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="note"
|
||||
label="备注"
|
||||
rules={[{ max: 2000, message: '备注不能超过 2000 字' }]}
|
||||
>
|
||||
<Input.TextArea rows={2} maxLength={2000} placeholder="选填" />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
title={editingStore ? `编辑店铺:${editingStore.name}` : '编辑店铺'}
|
||||
open={!!editingStore}
|
||||
onCancel={() => setEditingStore(null)}
|
||||
onOk={() => editForm.submit()}
|
||||
confirmLoading={saving}
|
||||
okText="保存"
|
||||
cancelText="取消"
|
||||
destroyOnHidden
|
||||
maskClosable={false}
|
||||
>
|
||||
{editingStore && (
|
||||
<Form<EditFormValues>
|
||||
form={editForm}
|
||||
layout="vertical"
|
||||
onFinish={handleEditSave}
|
||||
>
|
||||
<Form.Item
|
||||
name="name"
|
||||
label="店铺名称"
|
||||
required
|
||||
rules={[{ required: true, message: '请输入店铺名称' }]}
|
||||
>
|
||||
<Input maxLength={120} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="currency_code"
|
||||
label="默认计价币种"
|
||||
required
|
||||
rules={[{ required: true, message: '请选择币种' }]}
|
||||
>
|
||||
<Select options={currencyOptions} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="note"
|
||||
label="备注"
|
||||
rules={[{ max: 2000, message: '备注不能超过 2000 字' }]}
|
||||
>
|
||||
<Input.TextArea rows={2} maxLength={2000} placeholder="选填" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="api_key"
|
||||
label="替换 API Key"
|
||||
extra="留空保留现有密钥;填写后将替换并重新验证凭据"
|
||||
>
|
||||
<Input.Password
|
||||
maxLength={8192}
|
||||
placeholder="不修改请留空"
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item label="Client ID">
|
||||
<Typography.Text type="secondary">
|
||||
{editingStore.platform_client_id}(平台与账户不可修改)
|
||||
</Typography.Text>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
)}
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ShopsPage;
|
||||
@@ -0,0 +1,8 @@
|
||||
import PagePlaceholder from '@/components/PagePlaceholder';
|
||||
|
||||
/** 水印管理:维护水印模板并绑定到店铺(业务模块尚未实现) */
|
||||
const WatermarksPage = () => (
|
||||
<PagePlaceholder title="水印管理" description="维护水印模板并绑定到店铺,模块开发中。" />
|
||||
);
|
||||
|
||||
export default WatermarksPage;
|
||||
@@ -0,0 +1,86 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { Navigate, useLocation, createBrowserRouter } from 'react-router-dom';
|
||||
import MainLayout from '@/layouts/MainLayout';
|
||||
import SessionSplash from '@/components/SessionSplash';
|
||||
import CollectionPage from '@/pages/collection/CollectionPage';
|
||||
import LoginPage from '@/pages/auth/LoginPage';
|
||||
import OnlineProductsPage from '@/pages/online-products/OnlineProductsPage';
|
||||
import PasswordSettingsPage from '@/pages/settings/PasswordSettingsPage';
|
||||
import PricingPage from '@/pages/pricing/PricingPage';
|
||||
import ProductEditPage from '@/pages/products/ProductEditPage';
|
||||
import ProfileSettingsPage from '@/pages/settings/ProfileSettingsPage';
|
||||
import RegisterPage from '@/pages/auth/RegisterPage';
|
||||
import ShopsPage from '@/pages/shops/ShopsPage';
|
||||
import WatermarksPage from '@/pages/watermarks/WatermarksPage';
|
||||
import { PlatformProvider } from '@/services/platform';
|
||||
import { useSession } from '@/services/session';
|
||||
|
||||
/** 主布局守卫:会话恢复中显示启动屏;未认证跳登录页并记住来源路径 */
|
||||
function RequireSession({ children }: { children: ReactNode }) {
|
||||
const { status } = useSession();
|
||||
const location = useLocation();
|
||||
|
||||
if (status === 'restoring') {
|
||||
return <SessionSplash />;
|
||||
}
|
||||
if (status === 'anonymous') {
|
||||
return <Navigate to="/login" replace state={{ from: location.pathname }} />;
|
||||
}
|
||||
return children;
|
||||
}
|
||||
|
||||
/** 认证页互斥:已登录访问 /login、/register 时回到来源页或主布局 */
|
||||
function RedirectIfAuthenticated({ children }: { children: ReactNode }) {
|
||||
const { status } = useSession();
|
||||
const location = useLocation();
|
||||
|
||||
if (status === 'authenticated') {
|
||||
const from = (location.state as { from?: string } | null)?.from;
|
||||
return <Navigate to={from && from.startsWith('/') ? from : '/'} replace />;
|
||||
}
|
||||
return children;
|
||||
}
|
||||
|
||||
export const router = createBrowserRouter([
|
||||
{
|
||||
path: '/',
|
||||
element: (
|
||||
<RequireSession>
|
||||
{/* 平台上下文仅在登录后可用,随主布局挂载 */}
|
||||
<PlatformProvider>
|
||||
<MainLayout />
|
||||
</PlatformProvider>
|
||||
</RequireSession>
|
||||
),
|
||||
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 /> },
|
||||
// 个人中心设置页:从页头用户菜单进入,不在左侧菜单中出现
|
||||
{ path: 'settings/profile', element: <ProfileSettingsPage /> },
|
||||
{ path: 'settings/password', element: <PasswordSettingsPage /> },
|
||||
],
|
||||
},
|
||||
{
|
||||
path: 'login',
|
||||
element: (
|
||||
<RedirectIfAuthenticated>
|
||||
<LoginPage />
|
||||
</RedirectIfAuthenticated>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: 'register',
|
||||
element: (
|
||||
<RedirectIfAuthenticated>
|
||||
<RegisterPage />
|
||||
</RedirectIfAuthenticated>
|
||||
),
|
||||
},
|
||||
{ path: '*', element: <Navigate to="/" replace /> },
|
||||
]);
|
||||
@@ -0,0 +1,162 @@
|
||||
import axios from 'axios';
|
||||
import type {
|
||||
AxiosInstance,
|
||||
AxiosRequestConfig,
|
||||
AxiosResponse,
|
||||
InternalAxiosRequestConfig,
|
||||
} from 'axios';
|
||||
import { envConfig } from '@/config/env';
|
||||
import { authToken } from '@/services/authToken';
|
||||
|
||||
declare module 'axios' {
|
||||
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 头。
|
||||
});
|
||||
|
||||
// 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 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('/auth/refresh')
|
||||
.then((res) => {
|
||||
const token = unwrapEnvelope<{ access_token: string }>(res).access_token;
|
||||
authToken.set(token);
|
||||
return 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);
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* 统一解包强制响应信封:成功为 { data: T },204 No Content 无响应体
|
||||
* (docs/api/conventions.md §3.1)。服务层泛型 T 指业务负载本身;
|
||||
* 列表接口使用 getList 保留分页 meta。
|
||||
*/
|
||||
function unwrapEnvelope<T>(response: AxiosResponse): T {
|
||||
const body = response.data as { data?: unknown } | undefined;
|
||||
if (body === null || body === undefined) {
|
||||
return undefined as T;
|
||||
}
|
||||
return body.data as T;
|
||||
}
|
||||
|
||||
/** 列表响应信封为 { data: T[], meta: PaginationMeta }(docs/api/conventions.md §3),meta 需要原样保留 */
|
||||
function unwrapListEnvelope<T, M>(response: AxiosResponse): { data: T[]; meta: M } {
|
||||
const body = response.data as { data?: unknown; meta?: unknown } | undefined;
|
||||
return {
|
||||
data: Array.isArray(body?.data) ? (body.data as T[]) : [],
|
||||
meta: (body?.meta ?? {}) as M,
|
||||
};
|
||||
}
|
||||
|
||||
export const api = {
|
||||
get: <T = unknown>(url: string, config?: AxiosRequestConfig) =>
|
||||
apiClient.get<T>(url, config).then((res) => unwrapEnvelope<T>(res)),
|
||||
|
||||
getList: <T = unknown, M = unknown>(url: string, config?: AxiosRequestConfig) =>
|
||||
apiClient.get(url, config).then((res) => unwrapListEnvelope<T, M>(res)),
|
||||
|
||||
post: <T = unknown>(url: string, data?: unknown, config?: AxiosRequestConfig) =>
|
||||
apiClient.post<T>(url, data, config).then((res) => unwrapEnvelope<T>(res)),
|
||||
|
||||
patch: <T = unknown>(url: string, data?: unknown, config?: AxiosRequestConfig) =>
|
||||
apiClient.patch<T>(url, data, config).then((res) => unwrapEnvelope<T>(res)),
|
||||
|
||||
delete: <T = unknown>(url: string, config?: AxiosRequestConfig) =>
|
||||
apiClient.delete<T>(url, config).then((res) => unwrapEnvelope<T>(res)),
|
||||
};
|
||||
|
||||
/** 统一错误格式为 { 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);
|
||||
}
|
||||
|
||||
/** 提取业务错误码(如 VERSION_CONFLICT、DEFAULT_STORE_REPLACEMENT_REQUIRED),无则返回 null */
|
||||
export function apiErrorCode(error: unknown): string | null {
|
||||
if (!axios.isAxiosError(error)) return null;
|
||||
const code = (error.response?.data as { error?: { code?: unknown } } | undefined)?.error
|
||||
?.code;
|
||||
return typeof code === 'string' ? code : null;
|
||||
}
|
||||
|
||||
export interface ApiErrorDetail {
|
||||
field: string;
|
||||
code: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
/** 提取错误信封中的字段级明细 details: [{ field, code, message }](docs/api/conventions.md §4) */
|
||||
export function apiErrorDetails(error: unknown): ApiErrorDetail[] {
|
||||
if (!axios.isAxiosError(error)) return [];
|
||||
const details = (error.response?.data as { error?: { details?: unknown } } | undefined)?.error
|
||||
?.details;
|
||||
if (!Array.isArray(details)) return [];
|
||||
return details.filter(
|
||||
(item): item is ApiErrorDetail =>
|
||||
!!item &&
|
||||
typeof item === 'object' &&
|
||||
typeof (item as ApiErrorDetail).field === 'string' &&
|
||||
typeof (item as ApiErrorDetail).message === 'string'
|
||||
);
|
||||
}
|
||||
|
||||
export default apiClient;
|
||||
@@ -0,0 +1,48 @@
|
||||
import type {
|
||||
AuthMeResponse,
|
||||
AuthRefreshResponse,
|
||||
AuthSessionResponse,
|
||||
AuthUser,
|
||||
ChangePasswordRequest,
|
||||
LoginRequest,
|
||||
RegisterRequest,
|
||||
UpdateCurrentUserRequest,
|
||||
} from '@atlas/contracts';
|
||||
import { api } from '@/services/api';
|
||||
|
||||
export interface UpdateProfileOptions {
|
||||
/** 提交时的乐观锁版本,取自当前会话 user.revision */
|
||||
revision: number;
|
||||
/** 幂等键,重试安全;成功响应携带新 revision */
|
||||
idempotencyKey: string;
|
||||
}
|
||||
|
||||
/** 账户与会话接口,契约见 docs/api/auth.md;类型全部来自 @atlas/contracts */
|
||||
export const authApi = {
|
||||
register: (payload: RegisterRequest) =>
|
||||
api.post<AuthSessionResponse>('/auth/register', payload),
|
||||
|
||||
login: (payload: LoginRequest) => api.post<AuthSessionResponse>('/auth/login', payload),
|
||||
|
||||
refresh: () => api.post<AuthRefreshResponse>('/auth/refresh'),
|
||||
|
||||
logout: () => api.post<void>('/auth/logout'),
|
||||
|
||||
me: () => api.get<AuthMeResponse>('/auth/me'),
|
||||
|
||||
/**
|
||||
* 修改资料:If-Match 乐观锁 + Idempotency-Key 幂等(docs/api/conventions.md §5)。
|
||||
* 版本过期返回 409 VERSION_CONFLICT(details.current_revision 为服务端当前版本)。
|
||||
*/
|
||||
updateProfile: (payload: UpdateCurrentUserRequest, options: UpdateProfileOptions) =>
|
||||
api.patch<AuthUser>('/auth/me', payload, {
|
||||
headers: {
|
||||
'If-Match': `"${options.revision}"`,
|
||||
'Idempotency-Key': options.idempotencyKey,
|
||||
},
|
||||
}),
|
||||
|
||||
/** 修改密码:成功后所有旧 session 失效;响应携带新 access token,必须立即替换内存值 */
|
||||
changePassword: (payload: ChangePasswordRequest) =>
|
||||
api.post<AuthRefreshResponse>('/auth/change-password', payload),
|
||||
};
|
||||
@@ -0,0 +1,16 @@
|
||||
/**
|
||||
* access token 仅在内存中短暂持有(docs/api/conventions.md §1),
|
||||
* 不得写入 localStorage 等持久化存储;页面刷新后由 /auth/refresh 通过
|
||||
* HttpOnly cookie 恢复会话(接入会话恢复逻辑时实现)。
|
||||
*/
|
||||
let accessToken: string | null = null;
|
||||
|
||||
export const authToken = {
|
||||
get: () => accessToken,
|
||||
set: (token: string) => {
|
||||
accessToken = token;
|
||||
},
|
||||
clear: () => {
|
||||
accessToken = null;
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,145 @@
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import type { ReactNode } from 'react';
|
||||
import type { Platform, PlatformContext } from '@atlas/contracts';
|
||||
import { api, apiErrorMessage } from '@/services/api';
|
||||
|
||||
/** 平台选择的本地记忆键:仅保存平台 id 这一非敏感 UI 偏好 */
|
||||
const PLATFORM_STORAGE_KEY = 'atlas.platform_id';
|
||||
|
||||
interface PlatformContextValue {
|
||||
/** 已启用的平台目录;加载失败时为空数组并置 error */
|
||||
platforms: Platform[];
|
||||
/** 页头当前选中的平台;目录未加载完成或为空时为 null */
|
||||
currentPlatform: Platform | null;
|
||||
/** 目录加载失败信息;非空时提供重试入口 */
|
||||
error: string | null;
|
||||
/** 当前平台的默认门店上下文(GET /api/platform-context) */
|
||||
context: PlatformContext | null;
|
||||
contextLoading: boolean;
|
||||
selectPlatform: (platformId: string) => void;
|
||||
/** 门店变更后刷新默认门店摘要 */
|
||||
refreshContext: () => Promise<void>;
|
||||
/** 目录加载失败后重试 */
|
||||
reload: () => void;
|
||||
}
|
||||
|
||||
const PlatformContextContext = createContext<PlatformContextValue | null>(null);
|
||||
|
||||
/** 平台上下文:页头平台选择器的数据源,门店等模块据此确定当前平台(目录接口将 Ozon 排在最前) */
|
||||
export function PlatformProvider({ children }: { children: ReactNode }) {
|
||||
const [platforms, setPlatforms] = useState<Platform[]>([]);
|
||||
const [selectedId, setSelectedId] = useState<string | null>(() =>
|
||||
localStorage.getItem(PLATFORM_STORAGE_KEY)
|
||||
);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [context, setContext] = useState<PlatformContext | null>(null);
|
||||
const [contextLoading, setContextLoading] = useState(false);
|
||||
// 快速切换平台时丢弃过期响应与 loading:只接受最后一次请求的结果
|
||||
const contextRequestIdRef = useRef(0);
|
||||
|
||||
const loadPlatforms = useCallback(async () => {
|
||||
setError(null);
|
||||
try {
|
||||
const list = await api.get<Platform[]>('/platforms');
|
||||
setPlatforms(list);
|
||||
// 已保存的选择失效(平台下线或首次使用)时回退到目录第一家(Ozon 优先)
|
||||
setSelectedId((prev) =>
|
||||
prev && list.some((item) => item.id === prev) ? prev : (list[0]?.id ?? null)
|
||||
);
|
||||
} catch (err) {
|
||||
setError(apiErrorMessage(err));
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void loadPlatforms();
|
||||
}, [loadPlatforms]);
|
||||
|
||||
const currentPlatform = useMemo(
|
||||
() => platforms.find((item) => item.id === selectedId) ?? null,
|
||||
[platforms, selectedId]
|
||||
);
|
||||
|
||||
const loadContext = useCallback(async (platformId: string) => {
|
||||
const requestId = ++contextRequestIdRef.current;
|
||||
setContextLoading(true);
|
||||
try {
|
||||
const next = await api.get<PlatformContext>('/platform-context', {
|
||||
headers: { 'X-Atlas-Platform-Id': platformId },
|
||||
});
|
||||
if (contextRequestIdRef.current !== requestId) return;
|
||||
setContext(next);
|
||||
} catch {
|
||||
// 上下文读取失败不阻塞页面:门店页等业务接口自会报错
|
||||
if (contextRequestIdRef.current !== requestId) return;
|
||||
setContext(null);
|
||||
} finally {
|
||||
if (contextRequestIdRef.current === requestId) {
|
||||
setContextLoading(false);
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!currentPlatform) {
|
||||
setContext(null);
|
||||
return;
|
||||
}
|
||||
void loadContext(currentPlatform.id);
|
||||
}, [currentPlatform, loadContext]);
|
||||
|
||||
const selectPlatform = useCallback((platformId: string) => {
|
||||
setSelectedId(platformId);
|
||||
localStorage.setItem(PLATFORM_STORAGE_KEY, platformId);
|
||||
}, []);
|
||||
|
||||
const refreshContext = useCallback(async () => {
|
||||
if (!currentPlatform) return;
|
||||
await loadContext(currentPlatform.id);
|
||||
}, [currentPlatform, loadContext]);
|
||||
|
||||
const value = useMemo(
|
||||
() => ({
|
||||
platforms,
|
||||
currentPlatform,
|
||||
error,
|
||||
context,
|
||||
contextLoading,
|
||||
selectPlatform,
|
||||
refreshContext,
|
||||
reload: () => void loadPlatforms(),
|
||||
}),
|
||||
[
|
||||
platforms,
|
||||
currentPlatform,
|
||||
error,
|
||||
context,
|
||||
contextLoading,
|
||||
selectPlatform,
|
||||
refreshContext,
|
||||
loadPlatforms,
|
||||
]
|
||||
);
|
||||
|
||||
return (
|
||||
<PlatformContextContext.Provider value={value}>
|
||||
{children}
|
||||
</PlatformContextContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function usePlatform(): PlatformContextValue {
|
||||
const context = useContext(PlatformContextContext);
|
||||
if (!context) {
|
||||
throw new Error('usePlatform 必须在 PlatformProvider 内使用');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import type { ReactNode } from 'react';
|
||||
import axios from 'axios';
|
||||
import type { AuthMeResponse, AuthSessionResponse } from '@atlas/contracts';
|
||||
import { authApi } from '@/services/auth';
|
||||
import { authToken } from '@/services/authToken';
|
||||
|
||||
export type SessionStatus = 'restoring' | 'authenticated' | 'anonymous';
|
||||
|
||||
export interface SessionState {
|
||||
user: AuthMeResponse['user'];
|
||||
workspace: AuthMeResponse['workspace'];
|
||||
}
|
||||
|
||||
interface SessionContextValue {
|
||||
status: SessionStatus;
|
||||
session: SessionState | null;
|
||||
/** 登录 / 注册成功后写入会话 */
|
||||
applyAuthSession: (payload: AuthSessionResponse) => void;
|
||||
/** 修改资料成功后更新本地会话中的用户信息 */
|
||||
applyProfileUpdate: (user: SessionState['user']) => void;
|
||||
/** 撤销服务端会话并清除本地状态;网络失败也保证本地登出 */
|
||||
logout: () => Promise<void>;
|
||||
}
|
||||
|
||||
const SessionContext = createContext<SessionContextValue | null>(null);
|
||||
|
||||
// 模块级去重:StrictMode 双挂载或并发恢复时只发一次 refresh(refresh 会轮换会话,重复调用会互相失效)
|
||||
let restoreTask: Promise<void> | null = null;
|
||||
|
||||
/** 会话上下文:应用启动时经 HttpOnly refresh cookie 恢复会话,登录/注册/登出统一在此维护状态 */
|
||||
export function SessionProvider({ children }: { children: ReactNode }) {
|
||||
const [status, setStatus] = useState<SessionStatus>('restoring');
|
||||
const [session, setSession] = useState<SessionState | null>(null);
|
||||
const mountedRef = useRef(true);
|
||||
|
||||
useEffect(() => {
|
||||
mountedRef.current = true;
|
||||
return () => {
|
||||
mountedRef.current = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
restoreTask ??= (async () => {
|
||||
try {
|
||||
const refreshed = await authApi.refresh();
|
||||
authToken.set(refreshed.access_token);
|
||||
const me = await authApi.me();
|
||||
if (!mountedRef.current) return;
|
||||
setSession({ user: me.user, workspace: me.workspace });
|
||||
setStatus('authenticated');
|
||||
} catch {
|
||||
if (!mountedRef.current) return;
|
||||
authToken.clear();
|
||||
setStatus('anonymous');
|
||||
} finally {
|
||||
restoreTask = null;
|
||||
}
|
||||
})();
|
||||
void restoreTask;
|
||||
}, []);
|
||||
|
||||
const applyAuthSession = useCallback((payload: AuthSessionResponse) => {
|
||||
authToken.set(payload.access_token);
|
||||
setSession({ user: payload.user, workspace: payload.workspace });
|
||||
setStatus('authenticated');
|
||||
}, []);
|
||||
|
||||
const applyProfileUpdate = useCallback((user: SessionState['user']) => {
|
||||
setSession((prev) => (prev ? { ...prev, user } : prev));
|
||||
}, []);
|
||||
|
||||
const logout = useCallback(async () => {
|
||||
try {
|
||||
await authApi.logout();
|
||||
} catch (error) {
|
||||
// access token 已过期时先刷新一次再重试,确保服务端会话被撤销;
|
||||
// 仍失败则仅做本地登出,等待服务端会话自然过期
|
||||
if (axios.isAxiosError(error) && error.response?.status === 401) {
|
||||
const refreshed = await authApi.refresh().catch(() => null);
|
||||
if (refreshed) {
|
||||
authToken.set(refreshed.access_token);
|
||||
await authApi.logout().catch(() => undefined);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
authToken.clear();
|
||||
setSession(null);
|
||||
setStatus('anonymous');
|
||||
}
|
||||
}, []);
|
||||
|
||||
const value = useMemo(
|
||||
() => ({ status, session, applyAuthSession, applyProfileUpdate, logout }),
|
||||
[status, session, applyAuthSession, applyProfileUpdate, logout]
|
||||
);
|
||||
|
||||
return <SessionContext.Provider value={value}>{children}</SessionContext.Provider>;
|
||||
}
|
||||
|
||||
export function useSession(): SessionContextValue {
|
||||
const context = useContext(SessionContext);
|
||||
if (!context) {
|
||||
throw new Error('useSession 必须在 SessionProvider 内使用');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import type {
|
||||
CreateStoreRequest,
|
||||
CursorPaginationMeta,
|
||||
Store,
|
||||
StoreOperationResponse,
|
||||
UpdateStoreRequest,
|
||||
} from '@atlas/contracts';
|
||||
import { api } from '@/services/api';
|
||||
|
||||
export interface StoreListResult {
|
||||
data: Store[];
|
||||
meta: CursorPaginationMeta;
|
||||
}
|
||||
|
||||
export interface UpdateStoreOptions {
|
||||
/** 提交时的乐观锁版本,取自门店资源 revision */
|
||||
revision: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 门店管理接口,契约见 docs/api/stores-and-platforms.md。
|
||||
* 列表必须用 platform_id 查询参数(管理列表禁止携带 X-Atlas-Platform-Id header)。
|
||||
*/
|
||||
export const storesApi = {
|
||||
list: (platformId: string, cursor?: string) =>
|
||||
api.getList<Store, CursorPaginationMeta>('/stores', {
|
||||
params: { platform_id: platformId, limit: 20, ...(cursor ? { cursor } : {}) },
|
||||
}),
|
||||
|
||||
get: (storeId: string) => api.get<Store>(`/stores/${storeId}`),
|
||||
|
||||
/** 创建后店铺处于 verifying 状态,凭据验证由后台异步执行 */
|
||||
create: (payload: CreateStoreRequest) =>
|
||||
api.post<StoreOperationResponse>('/stores', payload, {
|
||||
headers: { 'Idempotency-Key': crypto.randomUUID() },
|
||||
}),
|
||||
|
||||
/**
|
||||
* 编辑:If-Match 乐观锁。未传 api_key 返回更新后的店铺;
|
||||
* 传入新 api_key 返回 202 与验证 operation(响应仍是 { store, operation })。
|
||||
*/
|
||||
update: (
|
||||
storeId: string,
|
||||
payload: UpdateStoreRequest,
|
||||
options: UpdateStoreOptions
|
||||
) =>
|
||||
api.patch<Store | StoreOperationResponse>(`/stores/${storeId}`, payload, {
|
||||
headers: {
|
||||
'If-Match': `"${options.revision}"`,
|
||||
'Idempotency-Key': crypto.randomUUID(),
|
||||
},
|
||||
}),
|
||||
|
||||
setDefault: (storeId: string) =>
|
||||
api.post<Store>(`/stores/${storeId}/set-default`, undefined, {
|
||||
headers: { 'Idempotency-Key': crypto.randomUUID() },
|
||||
}),
|
||||
|
||||
requestCredentialVerification: (storeId: string) =>
|
||||
api.post<StoreOperationResponse>(`/stores/${storeId}/credential-verifications`, undefined, {
|
||||
headers: { 'Idempotency-Key': crypto.randomUUID() },
|
||||
}),
|
||||
|
||||
requestWarehouseSync: (storeId: string) =>
|
||||
api.post<StoreOperationResponse>(`/stores/${storeId}/warehouse-syncs`, undefined, {
|
||||
headers: { 'Idempotency-Key': crypto.randomUUID() },
|
||||
}),
|
||||
|
||||
/** 删除当前默认店铺时同平台还有其他店铺会返回 422 DEFAULT_STORE_REPLACEMENT_REQUIRED */
|
||||
remove: (storeId: string) =>
|
||||
api.delete<void>(`/stores/${storeId}`, {
|
||||
headers: { 'Idempotency-Key': crypto.randomUUID() },
|
||||
}),
|
||||
};
|
||||
@@ -0,0 +1,9 @@
|
||||
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('邮箱格式不正确')),
|
||||
};
|
||||
Vendored
+3
@@ -0,0 +1,3 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
declare module '*.css';
|
||||
@@ -0,0 +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"]
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { fileURLToPath, URL } from 'node:url';
|
||||
import { defineConfig } from 'vite';
|
||||
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)),
|
||||
},
|
||||
},
|
||||
server: {
|
||||
port: Number(process.env.WEB_PORT ?? 8100),
|
||||
strictPort: true,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: process.env.API_ORIGIN ?? 'http://localhost:8200',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { Queue } from 'bullmq';
|
||||
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 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.',
|
||||
);
|
||||
}
|
||||
|
||||
void bootstrap();
|
||||
|
||||
async function shutdown() {
|
||||
await dispatchQueue.close();
|
||||
}
|
||||
|
||||
process.once('SIGINT', () => void shutdown());
|
||||
process.once('SIGTERM', () => void shutdown());
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "dist",
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
+21
-19
@@ -6,10 +6,11 @@
|
||||
|
||||
| 类型 | 位置 | 内容 |
|
||||
|---|---|---|
|
||||
| 项目 | [`project/`](./project/) | 产品定位、交接与已确认的长期约束 |
|
||||
| 项目 | [`project/`](./project/) | 产品定位、交接、协作约定与已确认的长期约束 |
|
||||
| 产品需求 | [`requirements/`](./requirements/) | 页面、流程、字段和待确认事项 |
|
||||
| 基础架构 | [`architecture/`](./architecture/) | 多租户、平台、门店、水印、物流和定价的数据设计及 DDL |
|
||||
| 商品领域设计 | [`product/`](./product/) | 已有的多 SKU 商品编辑页、领域模型与 API 草案 |
|
||||
| 项目结构 | [`architecture/project-structure.md`](./architecture/project-structure.md) | pnpm monorepo 的目录、依赖关系与本地运行边界。 |
|
||||
| 外部平台资料 | [`integrations/`](./integrations/) | Ozon Seller API 的整理资料与适配依据 |
|
||||
| 学习指南 | [`guides/`](./guides/) | 项目所用技术概念与设计原则的入门说明 |
|
||||
| HTTP API 设计 | [`api/`](./api/) | 可评审的服务端接口契约,按模块逐步补全。 |
|
||||
@@ -17,21 +18,22 @@
|
||||
|
||||
## 当前阅读顺序
|
||||
|
||||
1. [`project/HANDOFF.md`](./project/HANDOFF.md):项目边界和已经确认的商品规则。
|
||||
2. [`requirements/collector-browser-extension.md`](./requirements/collector-browser-extension.md):来源商品采集、采集箱、多 SKU、插件认证和 Ozon Seller 网页观察实验。
|
||||
3. [`requirements/collection-box-and-online-products.md`](./requirements/collection-box-and-online-products.md):采集箱、上架后编辑限制、按 SKU 展示的在线商品和 Ozon 异步同步需求。
|
||||
4. [`requirements/account-store-watermark.md`](./requirements/account-store-watermark.md):账户、门店和水印需求。
|
||||
5. [`requirements/pricing-tool.md`](./requirements/pricing-tool.md):独立试算、SKU 定价方案和物流规则需求。
|
||||
6. [`requirements/cel-ozon-rfbs-rates-review.md`](./requirements/cel-ozon-rfbs-rates-review.md):从 CEL Ozon-rFBS 资费表提取的待审核规则。
|
||||
7. [`architecture/foundation-data-model.md`](./architecture/foundation-data-model.md):基础模块数据模型和 PostgreSQL DDL。
|
||||
8. [`architecture/exchange-rates.md`](./architecture/exchange-rates.md):系统汇率、CBR、免费备用接口、更新任务和报价快照规则。
|
||||
9. [`architecture/product-domain-data-model.md`](./architecture/product-domain-data-model.md):采集箱、商品编辑、发布任务和在线商品镜像的数据库设计与 SQL。
|
||||
10. [`architecture/technology-and-deployment.md`](./architecture/technology-and-deployment.md):已确认的 NestJS、PostgreSQL、Redis/BullMQ、MinIO、七牛 Kodo 与部署策略。
|
||||
11. [`architecture/database-security.md`](./architecture/database-security.md):迁移/应用数据库角色、RLS 强制策略与部署职责。
|
||||
12. [`architecture/transactional-outbox-and-idempotency.md`](./architecture/transactional-outbox-and-idempotency.md):关键写请求去重、事务 Outbox、BullMQ 投递与验收规则。
|
||||
13. [`guides/object-storage-object-key.md`](./guides/object-storage-object-key.md):对象存储、`object_key`、来源 URL 与预签名 URL 的使用边界。
|
||||
14. [`guides/minio-local-deployment.md`](./guides/minio-local-deployment.md):MinIO 简介、本地 Docker 部署与应用侧使用方法。
|
||||
15. [`guides/postgresql-local-development.md`](./guides/postgresql-local-development.md):原生 PostgreSQL / Docker Compose 本地数据库、角色、迁移与建表。
|
||||
16. [`api/README.md`](./api/README.md):HTTP API 统一约定及第一批账户、门店、水印接口。
|
||||
17. [`reviews/implementation-readiness-open-items.md`](./reviews/implementation-readiness-open-items.md):实现前必须闭环的数据库/API 事项与已确认约束。
|
||||
18. 后续按模块补全定价、采集、商品编辑、发布和在线商品接口。
|
||||
1. [`project/development-collaboration-contract.md`](./project/development-collaboration-contract.md):**所有人工与 AI 开发者必须遵守的协作、领域所有权、API 与数据约束。**
|
||||
2. [`project/HANDOFF.md`](./project/HANDOFF.md):项目边界和已经确认的商品规则。
|
||||
3. [`requirements/collector-browser-extension.md`](./requirements/collector-browser-extension.md):来源商品采集、采集箱、多 SKU、插件认证和 Ozon Seller 网页观察实验。
|
||||
4. [`requirements/collection-box-and-online-products.md`](./requirements/collection-box-and-online-products.md):采集箱、上架后编辑限制、按 SKU 展示的在线商品和 Ozon 异步同步需求。
|
||||
5. [`requirements/account-store-watermark.md`](./requirements/account-store-watermark.md):账户、门店和水印需求。
|
||||
6. [`requirements/pricing-tool.md`](./requirements/pricing-tool.md):独立试算、SKU 定价方案和物流规则需求。
|
||||
7. [`requirements/cel-ozon-rfbs-rates-review.md`](./requirements/cel-ozon-rfbs-rates-review.md):从 CEL Ozon-rFBS 资费表提取的待审核规则。
|
||||
8. [`architecture/foundation-data-model.md`](./architecture/foundation-data-model.md):基础模块数据模型和 PostgreSQL DDL。
|
||||
9. [`architecture/exchange-rates.md`](./architecture/exchange-rates.md):系统汇率、CBR、免费备用接口、更新任务和报价快照规则。
|
||||
10. [`architecture/product-domain-data-model.md`](./architecture/product-domain-data-model.md):采集箱、商品编辑、发布任务和在线商品镜像的数据库设计与 SQL。
|
||||
11. [`architecture/technology-and-deployment.md`](./architecture/technology-and-deployment.md):已确认的 NestJS、PostgreSQL、Redis/BullMQ、MinIO、七牛 Kodo 与部署策略。
|
||||
12. [`architecture/database-security.md`](./architecture/database-security.md):迁移/应用数据库角色、RLS 强制策略与部署职责。
|
||||
13. [`architecture/transactional-outbox-and-idempotency.md`](./architecture/transactional-outbox-and-idempotency.md):关键写请求去重、事务 Outbox、BullMQ 投递与验收规则。
|
||||
14. [`guides/object-storage-object-key.md`](./guides/object-storage-object-key.md):对象存储、`object_key`、来源 URL 与预签名 URL 的使用边界。
|
||||
15. [`guides/minio-local-deployment.md`](./guides/minio-local-deployment.md):MinIO 简介、本地 Docker 部署与应用侧使用方法。
|
||||
16. [`guides/postgresql-local-development.md`](./guides/postgresql-local-development.md):原生 PostgreSQL / Docker Compose 本地数据库、角色、迁移与建表。
|
||||
17. [`api/README.md`](./api/README.md):HTTP API 统一约定及第一批账户、门店、水印接口。
|
||||
18. [`reviews/implementation-readiness-open-items.md`](./reviews/implementation-readiness-open-items.md):实现前必须闭环的数据库/API 事项与已确认约束。
|
||||
19. 后续按模块补全定价、采集、商品编辑、发布和在线商品接口。
|
||||
|
||||
+8
-4
@@ -1,14 +1,18 @@
|
||||
# Atlas HTTP API 设计
|
||||
|
||||
> 状态:第一批可评审契约。本文档描述预期的 NestJS HTTP API,不代表接口已经实现。接口统一以前缀 `/api` 提供,JSON 字段使用 `snake_case`,时间使用 ISO 8601 UTC 字符串,ID 使用 UUID。
|
||||
> 状态:第一批契约已冻结。认证、平台目录/上下文、门店基础管理和操作查询已实现;其余模块仍是设计契约。接口统一以前缀 `/api` 提供,JSON 字段使用 `snake_case`,时间使用 ISO 8601 UTC 字符串,ID 使用 UUID。
|
||||
|
||||
API 启动后会在 `/api-docs` 提供 Swagger UI,并在其 OpenAPI 文档中标记已实现的接口。Swagger 是运行时参考,不替代本目录的冻结契约。
|
||||
|
||||
运行状态端点不使用 `/api` 前缀:`GET /health` 只表明进程存活,`GET /ready` 会校验应用数据库连接,失败时返回 `503`。生产环境由环境变量控制 API 端口、CORS 来源、日志级别、请求体上限、认证限流及可信反向代理列表;缺少或格式错误的必需安全配置会阻止 API 启动。
|
||||
|
||||
## 1. 文档路线
|
||||
|
||||
| 文档 | 状态 | 范围 |
|
||||
|---|---|---|
|
||||
| [`conventions.md`](./conventions.md) | 已设计 | 鉴权、全局上下文、响应、分页、错误、并发与异步任务。 |
|
||||
| [`auth.md`](./auth.md) | 已设计 | 邮箱注册、登录、刷新会话、退出、当前用户。 |
|
||||
| [`stores-and-platforms.md`](./stores-and-platforms.md) | 已设计 | 平台目录、页头上下文、店铺授权、凭据校验、仓库、默认店铺。 |
|
||||
| [`conventions.md`](./conventions.md) | 部分已实现 | 鉴权、全局上下文、响应、分页、错误、并发与异步任务。 |
|
||||
| [`auth.md`](./auth.md) | 已实现 | 邮箱注册、登录、刷新会话、退出、当前用户。 |
|
||||
| [`stores-and-platforms.md`](./stores-and-platforms.md) | 部分已实现 | 平台目录、页头上下文、店铺授权、门店 CRUD、默认店铺、操作查询与仓库读取;平台实际验证与同步 Worker 待实现。 |
|
||||
| [`watermarks-and-assets.md`](./watermarks-and-assets.md) | 已设计 | 素材直传、短时访问、水印模板、字体、店铺推荐水印。 |
|
||||
| [`pricing.md`](./pricing.md) | 已设计 | 汇率、物流价卡、即时试算、定价方案、报价快照衔接。 |
|
||||
| [`collection-and-extension.md`](./collection-and-extension.md) | 已设计 | 插件授权、采集写入、采集箱查询、素材转存与状态。 |
|
||||
|
||||
+44
-6
@@ -10,8 +10,10 @@
|
||||
"id": "uuid",
|
||||
"email": "owner@example.com",
|
||||
"display_name": null,
|
||||
"avatar_asset_id": null,
|
||||
"email_verified_at": null,
|
||||
"created_at": "2026-09-02T08:00:00Z"
|
||||
"created_at": "2026-09-02T08:00:00Z",
|
||||
"revision": 1
|
||||
},
|
||||
"workspace": {
|
||||
"id": "uuid",
|
||||
@@ -21,7 +23,7 @@
|
||||
}
|
||||
```
|
||||
|
||||
`email` 返回规范化后的地址;密码散列、会话哈希、手机号及任何风控信息永不返回。
|
||||
`email` 返回规范化后的地址;`avatar_asset_id` 是当前头像的素材引用,普通账户响应不返回对象存储 URL。密码散列、会话哈希、手机号及任何风控信息永不返回。
|
||||
|
||||
## 2. 注册
|
||||
|
||||
@@ -50,7 +52,7 @@
|
||||
"data": {
|
||||
"access_token": "eyJ…",
|
||||
"access_token_expires_in": 900,
|
||||
"user": { "id": "uuid", "email": "owner@example.com", "display_name": "Joey", "email_verified_at": null, "created_at": "2026-09-02T08:00:00Z" },
|
||||
"user": { "id": "uuid", "email": "owner@example.com", "display_name": "Joey", "avatar_asset_id": null, "email_verified_at": null, "created_at": "2026-09-02T08:00:00Z", "revision": 1 },
|
||||
"workspace": { "id": "uuid", "name": "Joey 的工作空间", "role": "owner" }
|
||||
}
|
||||
}
|
||||
@@ -68,11 +70,13 @@
|
||||
|
||||
成功响应与注册相同,刷新 token 写入新的 `HttpOnly` cookie。失败统一返回 `401 INVALID_CREDENTIALS`;禁用账户返回 `403 ACCOUNT_DISABLED`。接口应按 IP 与邮箱摘要双维度限流,但不保存明文密码或将其记入日志。
|
||||
|
||||
成功返回 `200 OK`、`Set-Cookie` refresh token 及上述响应体。
|
||||
|
||||
## 4. 刷新与退出
|
||||
|
||||
`POST /api/auth/refresh`
|
||||
|
||||
请求不需要 body,浏览器自动携带 refresh cookie。服务端轮换 refresh token、撤销旧 session 并返回新的短期 access token:
|
||||
请求不需要 body,浏览器自动携带 refresh cookie。服务端轮换 refresh token、撤销旧 session 并以 `200 OK` 返回新的短期 access token:
|
||||
|
||||
```json
|
||||
{ "data": { "access_token": "eyJ…", "access_token_expires_in": 900 } }
|
||||
@@ -84,7 +88,41 @@
|
||||
|
||||
要求 Bearer access token;撤销当前 refresh session、清除 refresh cookie,返回 `204 No Content`。首期不提供“退出所有设备”。
|
||||
|
||||
## 5. 当前用户与初始页头数据
|
||||
## 5. 修改账户资料
|
||||
|
||||
`PATCH /api/auth/me`
|
||||
|
||||
Headers:`If-Match: "{revision}"` 与 `Idempotency-Key: {uuid}` 必填。
|
||||
|
||||
```json
|
||||
{ "display_name": "Joey", "avatar_asset_id": "uuid" }
|
||||
```
|
||||
|
||||
两个字段至少提交一个;传 `null` 可清除对应资料。成功返回 `200` 与更新后的 `user` 资源,`revision` 加一。`avatar_asset_id` 必须是当前工作空间中已完成素材校验的图片资产;接口不接受 URL、对象存储 key 或 base64 内容。版本过期返回 `409 VERSION_CONFLICT`,相同幂等键遵循统一重放规则。
|
||||
|
||||
头像上传复用 `POST /api/media/upload-intents` 的预签名直传和完成校验流程,并使用 `purpose: "user_avatar"`;在素材模块实现前,头像只能保持 `null`,不能以 base64 上传作为替代通道。
|
||||
|
||||
## 6. 修改密码
|
||||
|
||||
`POST /api/auth/change-password`
|
||||
|
||||
```json
|
||||
{
|
||||
"old_password": "old-strong-password",
|
||||
"password": "new-strong-password",
|
||||
"password_confirmation": "new-strong-password"
|
||||
}
|
||||
```
|
||||
|
||||
此接口要求 Bearer access token,并按来源 IP 和当前用户双维度限流。服务端在一个事务内锁定当前会话和用户、校验旧密码、更新密码散列、撤销该用户全部旧 session,并创建新的当前 session。成功返回 `200`、新的 `HttpOnly` refresh cookie 和:
|
||||
|
||||
```json
|
||||
{ "data": { "access_token": "eyJ…", "access_token_expires_in": 900 } }
|
||||
```
|
||||
|
||||
旧 access token 和其他设备的 session 会立即失效。此接口不使用 `Idempotency-Key`:密码变更及 session 轮换不能安全地将 access token 持久化为幂等回放响应;客户端收到网络中断后应以新旧密码重新登录或刷新会话,不得盲目自动重试。
|
||||
|
||||
## 7. 当前用户与初始页头数据
|
||||
|
||||
`GET /api/auth/me`
|
||||
|
||||
@@ -93,7 +131,7 @@
|
||||
```json
|
||||
{
|
||||
"data": {
|
||||
"user": { "id": "uuid", "email": "owner@example.com", "display_name": "Joey", "email_verified_at": null },
|
||||
"user": { "id": "uuid", "email": "owner@example.com", "display_name": "Joey", "avatar_asset_id": null, "email_verified_at": null, "revision": 1 },
|
||||
"workspace": { "id": "uuid", "name": "Joey 的工作空间", "role": "owner" },
|
||||
"platforms": [{ "id": "uuid", "code": "OZON", "name": "Ozon" }],
|
||||
"suggested_context": {
|
||||
|
||||
+23
-1
@@ -8,7 +8,9 @@
|
||||
Authorization: Bearer <short-lived-access-token>
|
||||
```
|
||||
|
||||
访问令牌只携带用户会话标识,不以客户端传入的 `workspace_id` 判定数据归属。服务端解析用户、选择首期唯一的有效工作空间、验证成员关系后,在每一个数据库事务中执行 `SET LOCAL app.workspace_id`。RLS 仅是第二道防线,业务层仍须验证资源归属。
|
||||
访问令牌只携带用户会话标识,不以客户端传入的 `workspace_id` 判定数据归属。全局认证守卫先验证 session,全局工作空间守卫再解析首期唯一的有效工作空间并验证成员关系;受保护处理器从 `request.auth.workspace` 或 `@CurrentWorkspace()` 取得已验证的 `workspaceId` 与角色。业务 SQL 必须将该 `workspaceId` 传给短生命周期的 `workspaceTransaction`,由其在每一个数据库事务中执行 `SET LOCAL app.workspace_id`。RLS 仅是第二道防线,业务层仍须验证资源归属。
|
||||
|
||||
`/health`、`/ready`、注册、登录与 refresh 是显式标记的公开端点。新接口默认受上述两个全局守卫保护;不得因便利而自行跳过,除非先在契约中说明公开理由。
|
||||
|
||||
Web 登录后的 refresh token 放在 `HttpOnly`、`Secure`(生产)、`SameSite=Lax` 的 cookie 中,永不出现在 JSON、日志或前端持久化存储。access token 由前端仅在内存中短暂持有;插件使用独立授权码流程,留待采集插件接口文档定义。
|
||||
|
||||
@@ -29,6 +31,25 @@ X-Atlas-Platform-Id: 57ad…-uuid
|
||||
|
||||
## 3. 响应、分页和字段格式
|
||||
|
||||
### 3.1 强制响应信封
|
||||
|
||||
本项目不采用完整 JSON:API 规范,但所有业务 HTTP 接口必须使用以下响应信封;这是
|
||||
前端、服务端、Worker 和扩展共同遵守的强制契约:
|
||||
|
||||
| 场景 | HTTP 响应体 |
|
||||
|---|---|
|
||||
| 成功的单资源或同步动作 | `{ "data": T }` |
|
||||
| 成功的列表 | `{ "data": T[], "meta": PaginationMeta }` |
|
||||
| 失败 | `{ "error": ApiError }` |
|
||||
| `204 No Content` | 无响应体 |
|
||||
|
||||
`data` 只能位于顶层且只能出现一次;不得返回 `{ "data": { "data": ... } }`,也不得以
|
||||
`{ "data": null }` 表示失败。HTTP 状态码仍表达请求结果,错误必须使用本文件第 4 节的
|
||||
`error` 信封,不能混入成功响应。`meta` 只承载分页、游标或与整个响应相关的元信息,不能
|
||||
替代资源字段。
|
||||
|
||||
### 3.2 成功与列表响应
|
||||
|
||||
成功响应统一为:
|
||||
|
||||
```json
|
||||
@@ -79,6 +100,7 @@ X-Atlas-Platform-Id: 57ad…-uuid
|
||||
## 5. 写操作的幂等、并发和异步
|
||||
|
||||
- 创建店铺、上传意图、删除水印模板等可重试写操作应携带 `Idempotency-Key: <UUID>`。服务端按“当前用户 + 工作空间 + HTTP 方法 + 规范化实际路径 + key”保存至少 24 小时(首期默认 48 小时);请求摘要保存为服务端 HMAC,而非明文请求体。相同键、相同请求返回已保存响应;相同键、不同请求返回 `409 IDEMPOTENCY_KEY_REUSED`;原请求仍在执行返回 `409 IDEMPOTENCY_REQUEST_IN_PROGRESS`。
|
||||
- `PATCH /api/auth/me` 同样要求幂等键。`POST /api/auth/change-password` 是例外:其成功响应包含新 access token,不能将该 token 持久化到幂等回放表;客户端不得自动重试此操作。
|
||||
- 更新可编辑资源时,读取响应携带 `revision`;客户端在 `PATCH`/`PUT` 中提交 `If-Match: "<revision>"`。版本已变化返回 `409 VERSION_CONFLICT` 和最新 `current_revision` 摘要。`revision` 是并发控制版本;不要把水印的渲染 `current_version` 当作它。
|
||||
- 耗时动作(店铺凭据验证、仓库同步、媒体处理)先返回 `202 Accepted`,并在资源中返回 `operation`。客户端轮询 `GET /api/operations/{operation_id}`;后续可追加 SSE,不改变资源接口。
|
||||
- 所有启动异步工作的业务写入,必须在同一 PostgreSQL 事务中写入业务资源/`operation` 和 `outbox_events`;独立 Dispatcher 再投递 BullMQ。BullMQ 和 Redis 不得作为任务存在或最终业务结果的唯一事实。
|
||||
|
||||
@@ -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 的运行方式
|
||||
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
# 项目结构与运行边界
|
||||
|
||||
> 状态:初始化骨架已创建并完成版本基线锁定。本文描述仓库的代码归属、构建关系和本地运行边界;业务规则仍以项目交接、API 契约和数据模型文档为准。
|
||||
|
||||
## 1. 目录结构
|
||||
|
||||
```text
|
||||
atlas-seller-kit/
|
||||
├── apps/ # 可独立启动、构建和部署的入口
|
||||
│ ├── web/ # React + Vite 卖家工作台
|
||||
│ ├── api/ # NestJS:HTTP、鉴权、事务与任务创建
|
||||
│ ├── worker/ # BullMQ:Outbox 投递、媒体、AI、同步、发布
|
||||
│ └── extension/ # WXT 浏览器采集扩展
|
||||
├── packages/ # 跨应用的稳定复用单元
|
||||
│ ├── contracts/ # Zod 契约、DTO、共享类型
|
||||
│ └── database/ # SQL migrations、迁移/应用连接基础设施
|
||||
├── infra/
|
||||
│ ├── compose.yaml # local PostgreSQL、Redis、MinIO
|
||||
│ └── README.md # 本地基础设施启动说明
|
||||
├── docs/ # 产品、架构、API 与评审记录
|
||||
├── package.json # 根命令、Node/pnpm 版本约束
|
||||
├── .nvmrc # 本地 Node 24 LTS 版本
|
||||
├── pnpm-workspace.yaml # workspace 边界
|
||||
├── turbo.json # 构建、检查、测试任务图
|
||||
└── tsconfig.base.json # 最小共享 TypeScript 编译约定
|
||||
```
|
||||
|
||||
`apps` 只能依赖明确的 `packages`,不能横向导入另一个应用的源码。这样 API、Worker 和扩展即使独立部署,也始终共享同一份契约。反过来,`packages` 不得依赖任何 `apps`。
|
||||
|
||||
## 2. 代码与服务关系图
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
subgraph Repository[Atlas Seller Kit · pnpm workspace]
|
||||
subgraph Applications[apps:部署与运行入口]
|
||||
WEB[web\nReact / Vite]
|
||||
API[api\nNestJS HTTP API]
|
||||
WORKER[worker\nBullMQ processors]
|
||||
EXT[extension\nWXT Collector]
|
||||
end
|
||||
|
||||
subgraph Shared[packages:跨应用稳定边界]
|
||||
CONTRACTS[contracts\nZod · DTO · payload types]
|
||||
DB[database\nSQL migrations · DB access]
|
||||
end
|
||||
|
||||
subgraph LocalInfra[infra/compose.yaml:本地依赖]
|
||||
PG[(PostgreSQL)]
|
||||
REDIS[(Redis)]
|
||||
MINIO[(MinIO)]
|
||||
end
|
||||
end
|
||||
|
||||
WEB -->|HTTPS /api| API
|
||||
EXT -->|Bearer API request| API
|
||||
API --> CONTRACTS
|
||||
EXT --> CONTRACTS
|
||||
API --> DB
|
||||
WORKER --> DB
|
||||
API -->|transactional outbox| PG
|
||||
API --> REDIS
|
||||
WORKER -->|consume / retry| REDIS
|
||||
WORKER --> PG
|
||||
WORKER --> MINIO
|
||||
```
|
||||
|
||||
## 3. 运行时责任与数据流
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant U as 用户
|
||||
participant E as 浏览器扩展
|
||||
participant W as Web
|
||||
participant A as API
|
||||
participant P as PostgreSQL
|
||||
participant R as Redis / BullMQ
|
||||
participant K as Worker
|
||||
participant O as MinIO / Kodo
|
||||
|
||||
U->>E: 在来源商品页采集
|
||||
E->>A: 共享契约校验后的采集载荷
|
||||
A->>P: 同一事务写业务记录、operation、outbox event
|
||||
A-->>E: 采集记录 / 编辑入口
|
||||
K->>P: Dispatcher 领取未投递 outbox event
|
||||
K->>R: 投递或消费可重试任务
|
||||
K->>O: 下载、处理、转存素材
|
||||
K->>P: 保存任务状态、结果与错误
|
||||
U->>W: 查看进度、编辑、发布
|
||||
W->>A: 鉴权后的 HTTP 请求
|
||||
A->>P: SET LOCAL app.workspace_id 后读写
|
||||
```
|
||||
|
||||
业务事实、审计、操作状态和 Outbox 只在 PostgreSQL 持久化;Redis/BullMQ 负责排队、限流和重试,不能成为唯一事实来源。local 使用 MinIO,staging/production 使用隔离的七牛 Kodo Bucket。
|
||||
|
||||
## 4. 依赖规则
|
||||
|
||||
| 位置 | 可以依赖 | 不应依赖 |
|
||||
|---|---|---|
|
||||
| `apps/web` | `packages/contracts`、前端库 | API/Worker 源码、数据库直连 |
|
||||
| `apps/api` | `packages/contracts`、`packages/database` | Web/扩展源码 |
|
||||
| `apps/worker` | `packages/contracts`、`packages/database` | Web/扩展源码、浏览器会话 |
|
||||
| `apps/extension` | `packages/contracts` | API 内部模块、ERP localStorage token |
|
||||
| `packages/contracts` | Zod 等无运行环境依赖库 | NestJS、React、数据库驱动 |
|
||||
| `packages/database` | PostgreSQL 驱动、迁移工具 | HTTP/UI/平台适配逻辑 |
|
||||
|
||||
当前只抽取了已经确认会跨端复用的采集契约和数据库边界。认证、商品、定价或 Ozon 适配器尚不单独建共享包,等首个纵向闭环出现稳定复用关系后再提取,避免过早形成空壳“公共库”。
|
||||
|
||||
## 5. 本地开发入口
|
||||
|
||||
运行时基线:Node 24.20.0(见 `.nvmrc`)、pnpm 11.25.0、Web `8100`、API `8200`、扩展 dev server `8110`(仅本地开发用)。Worker 是队列消费者,不对浏览器提供 HTTP 服务,因此没有端口号。
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
pnpm install
|
||||
pnpm infra:up # PostgreSQL 18.6、Redis 7.4、MinIO
|
||||
pnpm dev # Web、API、Worker、Extension 并行 watch
|
||||
```
|
||||
|
||||
常用验证命令:`pnpm build`、`pnpm typecheck`、`pnpm format:check`。首次实现数据库迁移执行器时,必须将 `atlas_migrator` 和 `atlas_app_login` 的连接串分开配置,并在集成测试中验证 RLS 隔离。
|
||||
|
||||
本地 Docker 镜像使用明确标签而非 `latest`;完整依赖版本表见 [技术与部署方案](./technology-and-deployment.md)。
|
||||
|
||||
## 6. 接下来的实现顺序
|
||||
|
||||
1. 在 `packages/database` 实现只接受 migrator 连接串的可重复 SQL migration runner,并导入已评审的 `001_foundation.sql`、`002_product-domain.sql`。
|
||||
2. 在 API 建立配置校验、认证、工作空间上下文和事务包装器;任何工作空间查询前固定 `SET LOCAL app.workspace_id`。
|
||||
3. 建立 `operations`、幂等请求和 Outbox Dispatcher 的第一个集成测试闭环。
|
||||
4. 再实现平台/门店、素材上传和 Web 对应页面;扩展仅在 API 采集入口稳定后接入实际解析器。
|
||||
|
||||
相关约束见:[技术与部署方案](./technology-and-deployment.md)、[数据库安全](./database-security.md)、[Outbox 与幂等](./transactional-outbox-and-idempotency.md)、[项目交接](../project/HANDOFF.md)。
|
||||
@@ -4,18 +4,24 @@
|
||||
|
||||
## 1. 已确认技术选型
|
||||
|
||||
| 层级 | 选型 | 职责 |
|
||||
|---|---|---|
|
||||
| 前端 | React 19、TypeScript、Vite、Ant Design、React Router、Axios | 延续 `ozon-seller-kit/studio` 的前端技术路线,构建卖家工作台。 |
|
||||
| 主后端 | NestJS、TypeScript | 认证、门店、商品/SKU、定价、平台适配、任务编排和 HTTP API。 |
|
||||
| 关系数据库 | PostgreSQL | 业务事实来源:用户、隐藏工作空间、门店、商品、报价、任务记录和审计。 |
|
||||
| 缓存与队列 | Redis、BullMQ | 待执行任务、延迟/重试、进度和限流;不作为业务事实来源。 |
|
||||
| 对象存储(本地) | MinIO | 本地开发存放图片和文件,使用 S3 兼容接口。 |
|
||||
| 层级 | 选型 | 当前版本 / 镜像 | 职责 |
|
||||
|---|---|---|---|
|
||||
| 前端 | React、TypeScript、Vite、Ant Design、React Router、Axios | React 19.2.8;TypeScript 7.0.2;Vite 8.2.2;Ant Design 6.6.2;Router 7.18.3;Axios 1.20.0 | 延续 `ozon-seller-kit/studio` 的前端技术路线,构建卖家工作台。 |
|
||||
| 主后端 | NestJS、TypeScript | NestJS 12.0.1;TypeScript 7.0.2 | 认证、门店、商品/SKU、定价、平台适配、任务编排和 HTTP API。 |
|
||||
| 关系数据库 | PostgreSQL | `postgres:18.6-alpine`(local) | 业务事实来源:用户、隐藏工作空间、门店、商品、报价、任务记录和审计。 |
|
||||
| 缓存与队列 | Redis、BullMQ | `redis:7.4.11-alpine3.21`(local);BullMQ 6.3.4 | 待执行任务、延迟/重试、进度和限流;不作为业务事实来源。 |
|
||||
| 对象存储(本地) | MinIO | `minio/minio:RELEASE.2025-09-07T16-13-09Z` | 本地开发存放图片和文件,使用 S3 兼容接口。 |
|
||||
| 对象存储(生产) | 七牛云 Kodo | 生产及共享测试环境存放图片和文件。Kodo 的基础 S3 兼容能力用于上传、下载、删除和短时访问链接。 |
|
||||
| 运行与编排 | Docker、Docker Compose | 本地一键启动依赖;首期服务器部署 API、Worker、Redis 和反向代理。 |
|
||||
| 浏览器扩展 | WXT、React | WXT 0.21.4;`@wxt-dev/module-react` 1.2.2;React 19.2.8 | 在来源商品详情页运行采集器。 |
|
||||
| 运行时与构建 | Node.js、pnpm、Turborepo、Prettier | Node 24.20.0;pnpm 11.25.0;Turbo 2.10.12;Prettier 3.9.6 | 固定本地/CI 运行时,管理 workspace、任务编排和格式校验。 |
|
||||
| 运行与编排 | Docker、Docker Compose | Docker Compose local 基础依赖 | 本地一键启动依赖;首期服务器部署 API、Worker、Redis 和反向代理。 |
|
||||
|
||||
不将旧项目的 FastAPI 服务作为新项目运行时依赖;旧项目的 Ozon 资料、计价规则、采集经验和前端技术路线可作为参考或选择性复用。
|
||||
|
||||
代码仓库采用 pnpm workspace monorepo:Web、API、Worker 与浏览器扩展位于独立 `apps/*`,跨端 API 契约和数据库迁移基础设施位于 `packages/*`。目录和依赖边界见 [项目结构与运行边界](./project-structure.md)。
|
||||
|
||||
版本由根目录 `package.json`、`.nvmrc` 与 `infra/compose.yaml` 锁定;镜像不使用 `latest`。pnpm 11 启用供应链保护:仅允许 `esbuild` 执行安装构建脚本,`msgpackr-extract` 保持禁用。
|
||||
|
||||
## 2. 服务边界
|
||||
|
||||
```text
|
||||
@@ -112,7 +118,9 @@ workspaces/{workspaceId}/
|
||||
|
||||
## 7. Docker 与首期部署
|
||||
|
||||
本地 Docker Compose 至少编排:`web`、`api`、`worker`、`postgres`、`redis`、`minio`。前端开发也可单独使用 Vite 热更新,并将 `/api` 代理至本地 API。
|
||||
当前仓库的 `infra/compose.yaml` 只编排本地基础依赖:`postgres`、`redis`、`minio`;`web`、`api`、`worker` 与 `extension` 通过 `pnpm dev` 在宿主机并行 watch。Web 默认监听 `8100`,API 默认监听 `8200`,Web 的 `/api` 代理目标为 `http://localhost:8200`;扩展的 WXT dev server 固定监听 `8110`(`EXTENSION_PORT` 可覆盖,仅本地开发用);Worker 不监听 HTTP 端口。
|
||||
|
||||
后续若将本地应用进程也容器化,Compose 至少应覆盖:`web`、`api`、`worker`、`postgres`、`redis`、`minio`。前端开发也可单独使用 Vite 热更新,并将 `/api` 代理至本地 API。
|
||||
|
||||
首期云端 Docker Compose 编排:`api`、`worker`、`redis`、`reverse-proxy`;PostgreSQL 使用云托管实例,图片使用七牛 Kodo。每个环境使用独立 `.env`/密钥管理配置,密钥不提交 Git、不进入前端构建产物和日志。
|
||||
|
||||
|
||||
@@ -0,0 +1,369 @@
|
||||
# Vitest 入门:快速理解并上手前端自动化测试
|
||||
|
||||
> 面向 Atlas Seller Kit 的学习文档。本文解释 Vitest 是什么、为什么前端项目选它,以及如何在 `apps/web` 中安装、配置并写出第一批测试:断言、生命周期、mock、React 组件测试与覆盖率。读完即可在项目里独立写单测。
|
||||
|
||||
## 1. 一句话理解 Vitest
|
||||
|
||||
**Vitest = 测试运行器 + 断言库 + mock 工具,三合一,且构建在 Vite 之上。**
|
||||
|
||||
一个测试文件只涉及三个 API,10 秒即可看懂结构:
|
||||
|
||||
```ts
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
describe('被测对象', () => {
|
||||
it('某个行为', () => {
|
||||
expect(1 + 1).toBe(2);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- `describe`:把一组相关测试分组;
|
||||
- `it`(别名 `test`):一条测试用例,失败即该用例不通过;
|
||||
- `expect(实际值).断言(期望值)`:断言,全部断言通过用例才算通过。
|
||||
|
||||
运行器负责找到所有 `*.test.ts` 文件、执行、输出报告;断言库就是 `expect` 那一族方法;mock 工具是 `vi.*`,用来"替换掉"你不想在测试里真实执行的依赖(网络请求、定时器、localStorage 等)。
|
||||
|
||||
## 2. 为什么选 Vitest 而不是 Jest
|
||||
|
||||
Vitest 的 API 有意做成和 Jest 兼容(`describe/it/expect/vi`),从 Jest 迁移基本只改配置。差别在于底座:
|
||||
|
||||
| 维度 | Vitest | Jest |
|
||||
|---|---|---|
|
||||
| 构建/转换 | 复用 Vite 管线 | 自带转换器(需要额外配置 TS/JSX/别名) |
|
||||
| `@` 别名、插件 | `vite.config.ts` 里已有的直接继承 | 要在 `jest.config` 里再抄一遍 |
|
||||
| ESM / TS | 开箱即用,无需 `transform` 配置 | ESM 支持至今仍偏繁琐 |
|
||||
| 冷启动与增量速度 | 快(与 dev server 同源,watch 模式只重跑受影响文件) | 相对慢 |
|
||||
| 生态 | 与 Vite 生态(本项目技术栈)天然一致 | 生态最大,但多面向 webpack 时代 |
|
||||
|
||||
对本项目还有一条硬约束:`apps/web` 用的是 **Vite 8**,需要 **Vitest 4.1+**(Vite 8 支持自 4.1 引入;4.1 起 Vitest 直接使用项目里已安装的 Vite,版本天然对齐)。安装时不用刻意锁旧版,装最新稳定版即可。
|
||||
|
||||
## 3. 在 apps/web 中安装与配置
|
||||
|
||||
### 3.1 安装
|
||||
|
||||
```bash
|
||||
cd apps/web
|
||||
# 必装
|
||||
pnpm add -D vitest
|
||||
# 组件测试三件套(jsdom 提供 DOM 环境,后文 §8)
|
||||
pnpm add -D jsdom @testing-library/react @testing-library/user-event @testing-library/jest-dom
|
||||
# 覆盖率(后文 §9)
|
||||
pnpm add -D @vitest/coverage-v8
|
||||
```
|
||||
|
||||
### 3.2 配置:直接写在 vite.config.ts 里
|
||||
|
||||
Vitest 默认读取 `vite.config.ts`——这意味着 `@` 别名、React 插件**零配置直接继承**,这是选它的最大红利。只需加一个 `test` 字段:
|
||||
|
||||
```ts
|
||||
/// <reference types="vitest/config" />
|
||||
import { fileURLToPath, URL } from 'node:url';
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
resolve: {
|
||||
alias: { '@': fileURLToPath(new URL('./src', import.meta.url)) },
|
||||
},
|
||||
server: {
|
||||
/* …原有配置不动… */
|
||||
},
|
||||
test: {
|
||||
// Web 项目统一用 jsdom,纯逻辑测试在 jsdom 里跑也完全没问题
|
||||
environment: 'jsdom',
|
||||
// 每次测试前加载的公共准备代码(jest-dom 扩展、polyfill),见 §8
|
||||
setupFiles: ['./src/test/setup.ts'],
|
||||
coverage: {
|
||||
include: ['src/**'],
|
||||
reporter: ['text', 'html'],
|
||||
},
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
顶部那行 `/// <reference types="vitest/config" />` 是给 TypeScript 的类型提示,让 `test` 字段获得补全和类型检查,不影响运行。
|
||||
|
||||
> ⚠️ 不要另建 `vitest.config.ts`:一旦它存在,`vite.config.ts` 会被**整体忽略(不合并)**,`@` 别名就会在测试里失效。本项目统一用 vite.config.ts 的 `test` 字段。
|
||||
|
||||
### 3.3 添加 npm scripts
|
||||
|
||||
`apps/web/package.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"scripts": {
|
||||
"test": "vitest",
|
||||
"test:run": "vitest run",
|
||||
"test:coverage": "vitest run --coverage"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- `pnpm test`:watch 模式,改代码自动重跑相关测试,适合开发时一直开着;
|
||||
- `pnpm test:run`:跑一遍就退出,适合 CI 和提交前检查。
|
||||
|
||||
monorepo 里从仓库根目录执行:
|
||||
|
||||
```bash
|
||||
pnpm --filter @atlas/web test:run
|
||||
```
|
||||
|
||||
## 4. 写第一个测试
|
||||
|
||||
### 4.1 文件放哪、叫什么
|
||||
|
||||
Vitest 默认扫描两类文件:
|
||||
|
||||
- 与被测文件同目录的 `*.test.ts(x)` / `*.spec.ts(x)`,如 `src/utils/format.test.ts`;
|
||||
- `__tests__/` 目录下的测试文件。
|
||||
|
||||
**本项目约定:与被测文件同目录同名加 `.test`**,如 `api.ts` 的测试叫 `api.test.ts`。就近放置,改哪个文件一眼就能看到它的测试。
|
||||
|
||||
### 4.2 用项目里的真实函数练手
|
||||
|
||||
`src/services/api.ts` 的 `apiErrorMessage` 是纯函数、无副作用,非常适合第一个测试(`src/services/api.test.ts`):
|
||||
|
||||
```ts
|
||||
import { AxiosError } from 'axios';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { apiErrorMessage } from '@/services/api';
|
||||
|
||||
describe('apiErrorMessage', () => {
|
||||
it('Error 对象返回其 message', () => {
|
||||
expect(apiErrorMessage(new Error('token 过期'))).toBe('token 过期');
|
||||
});
|
||||
|
||||
it('字符串原样返回', () => {
|
||||
expect(apiErrorMessage('boom')).toBe('boom');
|
||||
});
|
||||
|
||||
it('HTTP 错误优先展示后端 error.message', () => {
|
||||
const error = new AxiosError(
|
||||
'Bad Request',
|
||||
'ERR_BAD_REQUEST',
|
||||
undefined,
|
||||
undefined,
|
||||
{ status: 400, data: { error: { message: '用户名已存在' } } } as never
|
||||
);
|
||||
expect(apiErrorMessage(error)).toBe('用户名已存在');
|
||||
});
|
||||
|
||||
it('无响应体时展示状态码', () => {
|
||||
const error = new AxiosError('Bad Request', 'ERR_BAD_REQUEST', undefined, undefined, {
|
||||
status: 400,
|
||||
} as never);
|
||||
expect(apiErrorMessage(error)).toBe('请求失败(HTTP 400)');
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
运行 `pnpm test:run`,看到绿色 ✓ 即通过。故意把某个期望值改错再跑一次,观察失败报告——它会把实际值和期望值的差异打印出来,这是 Vitest 体验最好的部分之一。
|
||||
|
||||
## 5. 常用断言速查
|
||||
|
||||
| 断言 | 含义 |
|
||||
|---|---|
|
||||
| `toBe(x)` | 严格相等(`===`,适合原始值、同一引用) |
|
||||
| `toEqual(x)` | 深度相等(对象/数组逐字段比较) |
|
||||
| `toStrictEqual(x)` | 深度相等 + 检查 class 与 `undefined` 字段 |
|
||||
| `toBeTruthy()` / `toBeFalsy()` | 真 / 假 |
|
||||
| `toBeNull()` / `toBeUndefined()` / `toBeDefined()` | null / undefined / 非 undefined |
|
||||
| `toContain(x)` | 包含(数组元素或字符串子串) |
|
||||
| `toHaveLength(n)` | `.length` 等于 n |
|
||||
| `toMatch(regex)` / `toMatchObject(obj)` | 匹配正则 / 部分字段匹配 |
|
||||
| `toThrow(msg?)` | 抛出异常 |
|
||||
| `toHaveBeenCalledWith(...)` | mock 函数以指定参数被调用(§7) |
|
||||
|
||||
两个惯用法:
|
||||
|
||||
```ts
|
||||
// 异步断言:await + resolves/rejects
|
||||
await expect(authApi.login(payload)).rejects.toThrow();
|
||||
|
||||
// 取反:任意断言前加 .not
|
||||
expect(list).not.toContain('deleted');
|
||||
```
|
||||
|
||||
## 6. 生命周期与批量用例
|
||||
|
||||
```ts
|
||||
import { beforeEach, describe, it } from 'vitest';
|
||||
|
||||
describe('购物车', () => {
|
||||
// 每条 it 前执行:适合"重置状态",保证用例互不影响
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
// 同族还有 afterAll / beforeAll / afterEach,作用域限定在当前 describe 内
|
||||
it('加入商品', () => { /* … */ });
|
||||
it('移除商品', () => { /* … */ });
|
||||
});
|
||||
```
|
||||
|
||||
同一段逻辑测多组输入用 `it.each`,避免复制粘贴:
|
||||
|
||||
```ts
|
||||
it.each([
|
||||
['admin', true],
|
||||
['', false],
|
||||
[null, false],
|
||||
])('isValidRole(%s) → %s', (role, expected) => {
|
||||
expect(isValidRole(role)).toBe(expected);
|
||||
});
|
||||
```
|
||||
|
||||
## 7. Mock:隔离依赖的三种姿势
|
||||
|
||||
测试的核心纪律是**只测当前单元的逻辑,把外部依赖换成假的**。`vi` 提供三个层级的工具:
|
||||
|
||||
### 7.1 `vi.fn()`:造一个可断言的假函数
|
||||
|
||||
```ts
|
||||
const onSuccess = vi.fn();
|
||||
handle({ onSuccess });
|
||||
expect(onSuccess).toHaveBeenCalledOnce();
|
||||
expect(onSuccess).toHaveBeenCalledWith(expect.objectContaining({ id: 'p1' }));
|
||||
```
|
||||
|
||||
### 7.2 `vi.mock()`:整个模块替换掉(最常用)
|
||||
|
||||
测试 `authApi` 时不应真的发 HTTP 请求,把 `@/services/api` 整体换掉:
|
||||
|
||||
```ts
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { authApi } from '@/services/auth';
|
||||
import { api } from '@/services/api';
|
||||
|
||||
// 工厂函数返回"假模块":auth.ts 只导入了 api,替换它即可
|
||||
vi.mock('@/services/api', () => ({
|
||||
api: { post: vi.fn(), get: vi.fn(), patch: vi.fn(), delete: vi.fn() },
|
||||
}));
|
||||
|
||||
const mockedPost = vi.mocked(api.post);
|
||||
|
||||
describe('authApi', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks(); // 每条用例前清空调用记录,防止互相污染
|
||||
});
|
||||
|
||||
it('logout 调用 POST /auth/logout', async () => {
|
||||
mockedPost.mockResolvedValueOnce(undefined);
|
||||
|
||||
await authApi.logout();
|
||||
|
||||
expect(api.post).toHaveBeenCalledWith('/auth/logout');
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
两个关键机制:
|
||||
|
||||
- **提升(hoisting)**:`vi.mock` 调用会被提升到文件最顶部,先于任何 import 生效,所以"先 import 后 mock"的写法是安全的;但代价是**工厂函数里不能引用外部变量**(会报错),确有需要用 `vi.hoisted()` 声明。
|
||||
- `vi.mocked(api.post)` 只是给类型加了 mock 能力,指向的就是假模块里的那个 `vi.fn()`。
|
||||
|
||||
### 7.3 `vi.spyOn`:只监听(或临时替换)对象上的某个方法
|
||||
|
||||
```ts
|
||||
const spy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
// …执行会打日志的代码…
|
||||
expect(spy).toHaveBeenCalled();
|
||||
spy.mockRestore(); // 还原,避免影响其他用例
|
||||
```
|
||||
|
||||
其他高频工具一行记:
|
||||
|
||||
```ts
|
||||
vi.useFakeTimers(); // 冻结时间,setTimeout/Date 可控(记得配 vi.useRealTimers() 还原)
|
||||
vi.stubGlobal('fetch', vi.fn()); // 替换全局对象(fetch、localStorage 等)
|
||||
```
|
||||
|
||||
## 8. 测试 React 组件
|
||||
|
||||
组件测试用 Testing Library:**像用户一样操作页面(渲染→点击→断言看到的内容),而不是断言内部状态**。它在 jsdom 里渲染真实 React 组件(React 19 需要 `@testing-library/react` ≥ 16,安装最新版即可)。
|
||||
|
||||
公共准备代码 `src/test/setup.ts`(对应 §3.2 的 `setupFiles`):
|
||||
|
||||
```ts
|
||||
import '@testing-library/jest-dom/vitest'; // 提供 toBeInTheDocument 等DOM断言
|
||||
|
||||
// antd 依赖 matchMedia,jsdom 未实现,需打桩
|
||||
window.matchMedia ??= ((query: string) => ({
|
||||
matches: false,
|
||||
media: query,
|
||||
addEventListener: () => {},
|
||||
removeEventListener: () => {},
|
||||
})) as typeof window.matchMedia;
|
||||
```
|
||||
|
||||
一个组件测试的样子:
|
||||
|
||||
```tsx
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
function ConfirmButton({ onConfirm }: { onConfirm: () => void }) {
|
||||
return <button onClick={onConfirm}>确认删除</button>;
|
||||
}
|
||||
|
||||
describe('ConfirmButton', () => {
|
||||
it('点击后触发 onConfirm', async () => {
|
||||
const onConfirm = vi.fn();
|
||||
render(<ConfirmButton onConfirm={onConfirm} />);
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: '确认删除' }));
|
||||
|
||||
expect(onConfirm).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
要点:
|
||||
|
||||
- `render` 渲染组件,`screen.getByRole/getByText/getByLabelText` 按用户视角查询元素(查不到会抛错,用 `queryBy*` 断言"不存在");
|
||||
- `userEvent` 模拟真实交互(点击、输入),必须是 `await`;
|
||||
- 测页面级组件(如 `LoginPage`)时,若内部用到 Router/Session 上下文,需要先包一层 Provider 或 mock 对应模块——这也是组件测试的主要成本所在,建议**只对关键交互路径写组件测试**。
|
||||
|
||||
## 9. 日常运行与覆盖率
|
||||
|
||||
```bash
|
||||
pnpm test # watch 模式(开发时开着)
|
||||
pnpm test:run # 单次运行
|
||||
pnpm test:run src/services # 只跑路径匹配的文件
|
||||
pnpm test:run -t 'logout' # 只跑名字含 logout 的用例
|
||||
pnpm test:run --related # 只测本次 git 变更涉及的文件(提交前自查很好用)
|
||||
pnpm test:coverage # 覆盖率报告(终端表格 + html/coverage/index.html)
|
||||
pnpm test -- --ui # 浏览器图形界面(需 pnpm add -D @vitest/ui)
|
||||
```
|
||||
|
||||
覆盖率数字只是参考:`utils`、`services` 这类逻辑层应追求高覆盖;UI 组件靠少量关键交互测试兜底即可,不必为数字凑行数。
|
||||
|
||||
## 10. 在本项目的落地建议
|
||||
|
||||
1. **优先测两层**:`src/utils/` 纯函数(成本最低、收益最高);`src/services/` 服务层——mock 掉 `@/services/api` 后,401 刷新重放、信封解包(`unwrapEnvelope`)、`apiErrorMessage` 这些核心逻辑都能稳定验证。
|
||||
2. **组件测试抓大放小**:登录/注册表单校验与提交、路由跳转等关键路径值得写;纯展示组件不强制。
|
||||
3. **会话相关逻辑先打桩再测**:`api.ts` 刷新失败会执行 `window.location.assign('/login')`,jsdom 里 location 不可随意改写,测试时用 `vi.stubGlobal` 或把跳转逻辑抽成可注入的函数。
|
||||
4. **接入流程**:本地开发开 `pnpm test`;提交前 `pnpm test:run --related`;CI 里跑 `test:coverage`。后续可在 `turbo.json` 中加 `test` 任务,实现根目录 `turbo run test` 一键跑全部 workspace。
|
||||
5. **与 typecheck 分工**:`tsc` 管"类型对不对",Vitest 管"行为对不对",两者互补,提交前都跑。
|
||||
|
||||
## 11. 常见坑速查
|
||||
|
||||
| 症状 | 原因与解法 |
|
||||
|---|---|
|
||||
| 测试里 `@/...` 导入报错"找不到模块" | 建了 `vitest.config.ts` 导致 `vite.config.ts` 被整体忽略。删掉它,统一写在 `vite.config.ts` 的 `test` 字段(见 §3.2) |
|
||||
| `vi.mock` 工厂里引用了外部变量报错 | `vi.mock` 会被提升到文件顶部,工厂不能闭包引用外部变量;需要共享值时用 `vi.hoisted()` |
|
||||
| antd 组件测试报 `matchMedia is not a function` | jsdom 未实现该 API,在 `src/test/setup.ts` 里打桩(见 §8) |
|
||||
| `toBeInTheDocument` 类型报错 | 缺 `import '@testing-library/jest-dom/vitest'`,确认 setup 文件已配置 |
|
||||
| 测 `setTimeout`/节流时用例"卡住"或时序不稳 | 用 `vi.useFakeTimers()` 冻结时间并手动 `vi.advanceTimersByTime()` |
|
||||
| 覆盖率命令报错找不到 provider | 覆盖率是可选依赖:`pnpm add -D @vitest/coverage-v8` |
|
||||
| 想让 `describe/it/expect` 免导入直接用 | 配置 `test.globals: true`,并在 tsconfig 的 `compilerOptions.types` 加 `"vitest/globals"`。本项目建议保持显式导入(不折腾 tsconfig,IDE 跳转也更准) |
|
||||
|
||||
## 12. 参考资料
|
||||
|
||||
- [Vitest 官方文档](https://vitest.dev/guide/)(Getting Started / Guide / API 三栏足够日常使用)
|
||||
- [Vitest 4.1 发布公告](https://vitest.dev/blog/vitest-4-1.html)(Vite 8 支持与版本对齐说明)
|
||||
- [Testing Library 官方文档](https://testing-library.com/docs/react-testing-library/intro/)(查询优先级与 userEvent 用法)
|
||||
- 本项目接口契约:`docs/api/auth.md`、`docs/api/conventions.md`
|
||||
+52
-10
@@ -1,12 +1,51 @@
|
||||
# Atlas Seller Kit 交接说明
|
||||
|
||||
> 更新:2026-09-02。此文是新 AI 对话、架构讨论与开发工作的首读入口。
|
||||
> 更新:2026-09-03。此文是新 AI 对话、架构讨论与开发工作的首读入口。开始开发前,必须先阅读并遵守 [`development-collaboration-contract.md`](./development-collaboration-contract.md)。
|
||||
|
||||
## 1. 项目定位
|
||||
|
||||
这是一个全新的云端产品,不是 `ozon-seller-kit` 的本地版重构。目标用户可拥有多个门店,每个门店只能绑定一个平台;平台自身可支持多个市场、国家、语言和币种。因此语言、币种、类目和发布规则不能写死为 Ozon 或中文/俄文。
|
||||
|
||||
当前项目还没有业务代码、数据库、部署或依赖。不要从旧项目复制 SQLite 模型、无鉴权 API、旧 `/trial/:id` 或商品编辑页组件。可选择性复用已经验证过的纯计价公式、采集扩展思路和 AI 任务经验,但必须先适配多租户和云端边界。
|
||||
当前已完成 pnpm monorepo 初始化:包含 Web、NestJS API、BullMQ Worker、WXT 浏览器扩展、共享 API 契约、数据库包、本地基础设施 Compose 与依赖锁文件。API 已实现健康/readiness、集中配置与安全基础、认证、个人工作空间上下文、平台目录/上下文、门店基础管理和操作查询;真实平台验证与同步 Worker 尚未接入。Web 已具备完整前端框架(布局、路由、主题)、登录/注册页、页头平台选择器与店铺管理页(已接入真实 API),其余业务页面为占位;Worker 与扩展为可运行骨架。不要从旧项目复制 SQLite 模型、无鉴权 API、旧 `/trial/:id` 或商品编辑页组件。可选择性复用已经验证过的纯计价公式、采集扩展思路和 AI 任务经验,但必须先适配多租户和云端边界。
|
||||
|
||||
当前运行时基线:Node 24.20.0、pnpm 11.25.0、PostgreSQL 18.6、Redis 7.4.11、MinIO `RELEASE.2025-09-07T16-13-09Z`;Web 默认端口为 `8100`,API 默认端口为 `8200`,扩展 dev server 默认端口为 `8110`(仅本地开发用),Worker 无 HTTP 端口。完整目录、依赖边界与版本表见 [`architecture/project-structure.md`](../architecture/project-structure.md) 和 [`architecture/technology-and-deployment.md`](../architecture/technology-and-deployment.md)。
|
||||
|
||||
### 1.1 开发进度(2026-09-02)
|
||||
|
||||
今日完成三项工作,全部通过 typecheck、生产构建,并经浏览器或启动实测:
|
||||
|
||||
**Web 前端框架(`apps/web`)**
|
||||
|
||||
- 参照 `ozon-seller-kit/studio` 搭建:紫色主题(`colorPrimary #8b5cf6`、圆角 8)、`index.css` CSS 变量、可折叠侧边栏 + 吸顶页头 + 移动端 Drawer 的 `MainLayout`、`@` 路径别名、`StrictMode` 入口。
|
||||
- 主题显式 `fontSize: 14`;输入类控件默认高度 32→34(`controlHeight`)、小尺寸 24→26(`controlHeightSM`);body 字号同步 14px。
|
||||
- 依赖使用最新稳定版:React 19.2.8、antd 6.6.2,新增 `@ant-design/icons` 6.3.4;沿用仓库精确版本号约定。
|
||||
- 左侧菜单按 [`requirements/account-store-watermark.md`](../requirements/account-store-watermark.md) 的导航约定:在线商品(默认首页)、采集箱、店铺管理、水印管理、定价工具,均为占位页;另有菜单外的商品 AI 编辑页 `/products/:id`。
|
||||
- 基础设施:`services/api.ts`(axios 封装 + 统一错误提取)、`config/env.ts`(`VITE_` 变量)、统一占位组件 `components/PagePlaceholder`。
|
||||
- Web tsconfig 覆盖 base 的 `module: Node16` 为 `ESNext + bundler`(base 面向 NestJS;Node16 会要求相对导入带扩展名);TypeScript 7 已移除 `baseUrl`,`paths` 直接相对 tsconfig 解析为 `"@/*": ["./src/*"]`。
|
||||
|
||||
**注册与登录页面(契约:[`api/auth.md`](../api/auth.md))**
|
||||
|
||||
- `/login`、`/register` 两页共用 `AuthLayout`(品牌视觉与主布局一致)。注册字段:显示名称(选填 ≤80)、邮箱、密码、确认密码;登录失败统一 401 文案,注册重复邮箱 409 落到邮箱字段提示。
|
||||
- 会话基础:access token 仅内存持有(`services/authToken.ts`),不进入任何持久化存储;`api.ts` 拦截器自动附加 Bearer 头,401 时用 refresh cookie 换新 token 并重放一次(并发去重),刷新失败整页跳转 `/login`。
|
||||
- 实测发现并修复:antd v6 中空值会同时触发 `required` 与 `type: 'email'` 两条错误,已改为非空才校验格式的 `emailFormatRule`(`utils/validators.ts`)。
|
||||
- 浏览器实测通过:表单校验、页面互链、API 未启动时的错误提示路径。密码复杂度按契约不在前端硬编码,仅提示以服务端校验为准。
|
||||
- 留待 API 认证模块就绪后:接入真实 `/api/auth/*` 调用、主布局路由守卫、刷新页面后的会话恢复;auth 类型届时迁移至 `@atlas/contracts`。
|
||||
|
||||
**Worker 与扩展骨架修复**
|
||||
|
||||
- Worker:`ioredis` 是 BullMQ 的可选 peer 依赖,pnpm 严格隔离下必须由应用显式声明,已新增 `ioredis 6.0.0`;`main.ts` 改为显式构造 IORedis 实例并设置 `maxRetriesPerRequest: null`(原生 ESM 运行必需,也是后续 BullMQ Worker 的硬性要求)。`tsx` dev 与 `node dist/main.js` 两种运行方式均验证可连接本地 Redis。
|
||||
- 扩展:WXT dev server 从默认的 3000–3010 漂移改为固定 `8110`(`EXTENSION_PORT` 可覆盖,`strictPort` 开启);`.output/` 构建产物已加入 `.gitignore` 并移出 git 跟踪。端口约定同步更新至交接、部署与项目结构三份文档及 `.env.example`。
|
||||
|
||||
### 1.2 开发进度(2026-09-03 下午):店铺管理与平台上下文前端
|
||||
|
||||
**Web 接入平台上下文与门店管理 API(契约:[`api/stores-and-platforms.md`](../api/stores-and-platforms.md))**
|
||||
|
||||
- 页头新增平台选择器:`services/platform.tsx` 的 `PlatformProvider` 加载 `GET /api/platforms` 目录与 `GET /api/platform-context`(`X-Atlas-Platform-Id` header),默认取目录第一家(Ozon 优先);选择结果仅在 localStorage 保存平台 id(非敏感 UI 偏好)。页头组件 `layouts/PlatformSelector.tsx`,tooltip 展示当前平台默认店铺。
|
||||
- 店铺管理页 `pages/shops/ShopsPage.tsx` 覆盖已实现的门店接口:游标分页列表(“加载更多”)、创建(202 + operation,提示“验证中”)、编辑(`If-Match` 乐观锁,409 时刷新列表并提示重新编辑)、设为默认、手动重新验证凭据、仓库同步(凭据未验证时展示 422 脱敏错误)、软删除(`DEFAULT_STORE_REPLACEMENT_REQUIRED` 转为可操作提示)。API Key 只存在于提交瞬间的内存表单,不写入任何存储。凭据验证与仓库同步的实际执行仍依赖未实现的 Outbox Dispatcher + Worker,店铺会停留在“验证中”。
|
||||
- 基础设施:`services/api.ts` 新增 `getList`(保留 `{ data, meta }` 分页信封)与 `apiErrorCode`(业务错误码提取);`services/stores.ts` 封装门店接口并按契约生成 `Idempotency-Key`。
|
||||
- `@atlas/contracts` 补充只读响应类型 `Platform`、`PlatformContext`、`CursorPaginationMeta`、`StoreOperationResponse`(前端 AI 按冻结契约补类型,无行为变更,后端请复核)。
|
||||
- 修复 API 阻断性 bug(跨领域改动,原因:阻断本功能联调;影响 `apps/api/src/stores/stores.controller.ts`):tsx/esbuild 不生成 `design:paramtypes` 装饰器元数据,控制器裸类型构造参数在 dev 运行时注入 `undefined`,`GET /api/platforms` 返回 500。已改为与 `auth.controller.ts` 一致的显式 `@Inject(...)`;服务层原本就使用显式 token,不受影响。
|
||||
- 浏览器实测(全新迁移的本地 PostgreSQL 18 + Redis 7):注册 → 平台选择器 → 创建/编辑/设默认/删除保护/同步 422 错误路径 → 刷新后会话恢复与列表持久化,全部通过;contracts/api/web typecheck 与 web production build 通过。
|
||||
|
||||
## 2. 已确认产品决策
|
||||
|
||||
@@ -54,24 +93,27 @@ User → Workspace → Membership / Role
|
||||
|
||||
## 5. 已冻结的第一阶段 API 契约
|
||||
|
||||
第一阶段 HTTP API 的接口范围已讨论完成,当前状态为**设计冻结、尚未实现**。后续编码以 [`docs/api/`](../api/README.md) 为唯一 HTTP 契约来源;旧 [`product/api.md`](../product/api.md) 仅作历史背景,不能作为实现依据。
|
||||
第一阶段 HTTP API 的接口范围已讨论完成,当前状态为**设计冻结、部分业务接口已实现**。认证、平台目录/上下文、创建/查询/编辑/默认/删除门店、异步操作读取及仓库摘要读取已有实现与集成测试;门店凭据验证、仓库同步的实际平台调用仍待 Worker 消费 Outbox 后完成。后续编码以 [`docs/api/`](../api/README.md) 为唯一 HTTP 契约来源;旧 [`product/api.md`](../product/api.md) 仅作历史背景,不能作为实现依据。
|
||||
|
||||
- 覆盖账户认证、平台/门店、水印与素材、定价、浏览器采集/采集箱、商品编辑/首次发布、在线商品读取与同步。
|
||||
- 所有接口遵循统一鉴权、工作空间隔离、页头平台上下文、金额字符串、错误格式、`Idempotency-Key`、`If-Match` 和异步 `operation` 约定。
|
||||
- **响应信封是强制契约:** 成功单资源或同步动作返回 `{ "data": T }`,列表返回 `{ "data": T[], "meta": PaginationMeta }`,失败返回 `{ "error": ApiError }`,`204 No Content` 不返回响应体。项目不采用完整 JSON:API;任何调用方不得自行增加嵌套 `data` 或以 `data: null` 表示错误。完整规则见 [`api/conventions.md`](../api/conventions.md)。
|
||||
- 关键异步动作必须在同一事务写入业务记录、`operations`(如需用户可见进度)和 `outbox_events`;Dispatcher 至少一次投递 BullMQ,Worker 必须幂等。
|
||||
- DDL 已通过临时隔离 PostgreSQL 实例执行验证;API 契约已完成文档评审。这不表示 NestJS 模块、迁移执行器或 Worker 已实现。
|
||||
- DDL 已通过本地隔离 PostgreSQL 执行验证;迁移执行器、认证/工作空间基础、首个幂等与 Outbox 写入闭环以及上述门店 HTTP 模块已实现。Outbox Dispatcher 与平台 Worker 仍未实现。
|
||||
|
||||
仍需在对应模块开始编码前完成的 P1/P2 决策和数据模型补丁,集中记录在 [`reviews/implementation-readiness-open-items.md`](../reviews/implementation-readiness-open-items.md)。
|
||||
|
||||
## 6. 下一步建议
|
||||
|
||||
下一步进入项目初始化与实现准备,而不是继续扩大 API 范围:
|
||||
当前优先完成门店纵向闭环,而不是继续扩展商品接口:
|
||||
|
||||
1. 创建 NestJS 后端、React 前端、迁移工具、Redis/BullMQ 和本地 Docker Compose 骨架。
|
||||
2. 以 `001_foundation.sql`、`002_product-domain.sql` 建立可重复执行的迁移,并按数据库角色/RLS 约束验证本地环境。
|
||||
3. 在开始商品编辑/在线同步模块前,先关闭评审文档中的 P1 事项:同商品归属约束、未映射属性持久化、乐观锁 Repository 规则、平台能力 Schema;并确认在线商品归档策略和 Client ID 编辑边界。
|
||||
4. 优先实现账户、平台上下文、门店、素材上传、操作任务、幂等/Outbox Dispatcher 的纵向最小闭环,再接入采集箱和商品编辑。
|
||||
5. 为浏览器采集准备真实多 SKU 解析测试集,并继续限定 Ozon Seller 网页观察实验的字段口径,见 [`requirements/collector-browser-extension.md`](../requirements/collector-browser-extension.md)。
|
||||
1. 实现 Outbox Dispatcher 与 Worker 的首个消费链路:将 `store_credential_verification` 投递到 Worker,解密密钥后调用平台适配器,回写门店/密钥状态;失败信息必须脱敏。
|
||||
2. 在凭据验证通过后实现仓库同步 Worker、幂等 upsert 和状态回写,并补充失败重试、死信与集成测试。
|
||||
3. ~~Web 接入真实认证和门店管理 API~~ 已基本完成:登录/注册、会话刷新、平台上下文、门店列表/创建/编辑/默认切换/删除均已上线(见 1.2);剩余水印绑定入口待素材/水印模块交付后接入。前端不得保存 API Key 或 refresh token。
|
||||
4. 接着实现素材直传和水印管理,再开始采集箱与商品编辑;浏览器扩展仅在采集 API 稳定后实现实际解析器。
|
||||
5. 在开始商品编辑/在线同步模块前,关闭评审文档中的 P1 事项:同商品归属约束、未映射属性持久化、乐观锁 Repository 规则、平台能力 Schema;并确认在线商品归档策略和 Client ID 编辑边界。
|
||||
|
||||
账户资料的已确认补充:`users.avatar_asset_id` 只保存素材引用,绝不保存公开 URL;`users.revision` 用于 `PATCH /api/auth/me` 的 `If-Match`。资料更新已实现幂等与版本冲突检测;修改密码已实现旧密码校验、按 IP/用户限流、撤销旧会话并轮换新会话。头像上传必须复用素材模块的预签名直传与校验流程,不引入 base64 直传;素材模块尚未实现时头像只能为 `null`。
|
||||
|
||||
## 7. 文档导航
|
||||
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
# 多 AI 开发协作约定(强制)
|
||||
|
||||
> 生效日期:2026-09-03。适用于本仓库所有人工与 AI 开发者。除非仓库维护者明确批准并同时更新本文档,任何实现不得违反本约定。
|
||||
|
||||
## 1. 目的与优先级
|
||||
|
||||
本约定使前端、服务端、Worker 和浏览器扩展能够独立推进,并以小范围、可验证的改动持续集成。
|
||||
|
||||
发生冲突时,按以下顺序处理:
|
||||
|
||||
1. 本文档的协作、所有权与变更流程约束。
|
||||
2. `docs/project/HANDOFF.md` 的产品边界与云端架构底线。
|
||||
3. `docs/api/` 的 HTTP API 契约,特别是 `docs/api/conventions.md`。
|
||||
4. `docs/architecture/` 的数据模型、SQL、RLS、Outbox 与部署约束。
|
||||
5. 模块需求文档。
|
||||
6. 当前代码的既有行为。
|
||||
|
||||
较低优先级内容与较高优先级冲突时,不得通过代码“兼容”或自行猜测解决;必须先修订上游文档并取得维护者确认。
|
||||
|
||||
## 2. 领域所有权
|
||||
|
||||
| 领域 | 负责人 | 可修改范围 | 交付责任 |
|
||||
|---|---|---|---|
|
||||
| 服务端与数据 | 后端 AI | `apps/api/`、`apps/worker/`、`packages/database/`、`packages/contracts/`、服务端相关文档 | 迁移、RLS、认证、API、异步任务、平台适配和集成测试 |
|
||||
| 前端体验 | 前端 AI | `apps/web/`、前端相关文档 | 页面、组件、路由、交互、可访问性、加载/空/错误状态与前端测试 |
|
||||
| 浏览器扩展 | 后端 AI(采集 API 稳定前只维护骨架) | `apps/extension/` | 扩展认证、采集、净化上传与 API 集成 |
|
||||
| 共享契约 | 后端 AI维护,前端 AI共同评审 | `packages/contracts/`、`docs/api/` | 对外类型、DTO、枚举、错误码与接口样例保持一致 |
|
||||
| 最终集成 | 仓库维护者指定;默认后端 AI | 根配置、锁文件、跨应用变更 | 合并顺序、契约兼容性与端到端验收 |
|
||||
|
||||
任何人修改非自己负责的领域前,必须在任务说明中写明原因、影响文件和接口影响;未经明确授权不得改动该领域的业务实现。
|
||||
|
||||
## 3. API 与共享类型约束
|
||||
|
||||
1. `docs/api/` 是唯一的 HTTP API 行为来源;实现前必须先有对应契约,或在同一个经过评审的变更中先更新契约。
|
||||
2. 接口路径、字段名、必填性、枚举、金额字符串格式、分页、错误格式、`Idempotency-Key`、`If-Match` 和异步 `operation` 规则不得由客户端自行推断或扩展。
|
||||
3. 已冻结接口的破坏性变更必须先由维护者批准,并同时更新契约、`@atlas/contracts`、服务端测试和调用端。
|
||||
4. 稳定的请求、响应、枚举和错误类型必须放入 `@atlas/contracts`;`apps/web` 不得长期维护其副本。允许在后端 API 尚未交付时使用明确标注为临时的 mock/adapter,接入接口时必须删除或替换。
|
||||
5. 前端遇到契约缺口时,提交具体问题(页面/用户动作/所需字段/建议响应),不得私自新增后端字段或改变服务端语义。
|
||||
|
||||
## 4. 数据与安全硬约束
|
||||
|
||||
1. PostgreSQL 是业务事实来源。所有业务数据必须有 `workspace_id`,或能经强制外键安全追溯到工作空间;查询必须在事务中设置 `SET LOCAL app.workspace_id`。
|
||||
2. 数据库约束、RLS 与迁移是安全边界;不得只依赖 API 服务层校验。迁移必须可重复执行、可在全新本地环境验证。
|
||||
3. 关键可重试写入必须实现幂等;触发异步工作的事务必须同时写入 Transactional Outbox。Redis/BullMQ 不得成为业务事实的唯一来源,Worker 必须幂等。
|
||||
4. access token 不得写入浏览器持久化存储;Cookie、API Key、预签名 URL 等秘密不得写进日志、Outbox 载荷、fixture 或提交进仓库。
|
||||
5. 不得从旧 `ozon-seller-kit` 复制 SQLite、多店铺无隔离、无鉴权 API 或旧商品编辑实现。复用逻辑前必须适配多租户与云端边界。
|
||||
|
||||
## 5. 工作方式与集成规则
|
||||
|
||||
1. 一个任务只完成一个可验收的垂直能力,明确列出:目标、负责人、允许改动范围、依赖的 API/数据表、验收标准和是否修改契约。
|
||||
2. 每个开发者使用独立分支或 worktree;提交应小而单一。不要在无关任务中格式化全仓、升级依赖、修改根脚本或重新生成锁文件。
|
||||
3. 在同一时间,只有最终集成负责人可以合并跨领域改动、根配置、`pnpm-lock.yaml` 和共享契约的冲突解决。
|
||||
4. 合并前至少运行受影响工作区的 typecheck 与 build;新增或修改 API 时必须增加服务端测试,并在任务结果中提供请求/响应示例或测试覆盖说明。
|
||||
5. 未完成的 P0/P1 设计事项以 `docs/reviews/implementation-readiness-open-items.md` 为准。任务触及其中事项时,必须先关闭该事项或获得维护者明确的范围豁免。
|
||||
|
||||
## 6. 首个建议任务:账户与工作空间基础闭环(M0)
|
||||
|
||||
**目标:** 交付第一个真实的、可由前端接入的认证闭环:注册、登录、刷新会话、登出、读取当前用户与个人工作空间。
|
||||
|
||||
### 后端 AI 负责
|
||||
|
||||
- 建立可重复执行的基础迁移执行器,并落地认证所需的基础表、数据库角色/RLS 约束与本地验证。
|
||||
- 按 `docs/api/auth.md` 实现认证模块、密码处理、refresh cookie、access token、请求工作空间上下文及统一错误响应。
|
||||
- 注册成功时,在同一事务创建用户、个人工作空间和唯一 `owner` 成员。
|
||||
- 将稳定 DTO、枚举和错误类型加入 `@atlas/contracts`,并添加 API/数据库集成测试。
|
||||
- 不实现门店、素材、采集、商品、发布或 Worker 业务;这些不属于 M0 范围。
|
||||
|
||||
### 前端 AI 负责
|
||||
|
||||
- 保持现有 `/login` 和 `/register` 的视觉及表单体验,按 `docs/api/auth.md` 接入真实接口。
|
||||
- 使用共享契约替换本地认证类型;完成应用启动时的会话恢复、主布局路由守卫、401 后的受控跳转和加载/错误状态。
|
||||
- 不修改认证接口的字段、错误语义、Cookie 策略或 token 存储边界;需要变化时按第 3 节提出契约问题。
|
||||
|
||||
### M0 验收标准
|
||||
|
||||
1. 新用户可注册、自动拥有个人工作空间,并能登录后读取当前身份。
|
||||
2. access token 仅存于内存;刷新页面可通过 HttpOnly refresh cookie 恢复会话;登出后刷新 cookie 失效。
|
||||
3. 未认证请求与已过期会话返回契约定义的统一错误,前端不会陷入重试循环。
|
||||
4. 工作空间隔离通过数据库集成测试验证;跨工作空间读取或写入被拒绝。
|
||||
5. API、Web、共享契约的 typecheck 和 production build 均通过。
|
||||
|
||||
## 7. M0 完成后的顺序
|
||||
|
||||
M0 合并后,按以下顺序建立纵向闭环:门店与平台上下文 → 素材上传与媒体处理 → 采集箱 → 商品编辑与 SKU → 定价 → 首次发布与在线商品同步。不得为了前端页面便利跳过前置的认证、工作空间隔离或数据边界。
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user