feat(api): 开发auth、login等接口,规范格式化
This commit is contained in:
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"singleQuote": true,
|
||||
"trailingComma": "all",
|
||||
"bracketSpacing": true,
|
||||
"arrowParens": "always",
|
||||
"printWidth": 80,
|
||||
"tabWidth": 4,
|
||||
"useTabs": false
|
||||
}
|
||||
+30
-23
@@ -1,25 +1,32 @@
|
||||
{
|
||||
"name": "@atlas/api",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "tsc -p tsconfig.json",
|
||||
"dev": "tsx watch src/main.ts",
|
||||
"start": "node dist/main.js",
|
||||
"typecheck": "tsc -p tsconfig.json --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@atlas/contracts": "workspace:*",
|
||||
"@nestjs/common": "12.0.1",
|
||||
"@nestjs/core": "12.0.1",
|
||||
"@nestjs/platform-express": "12.0.1",
|
||||
"reflect-metadata": "0.2.2",
|
||||
"rxjs": "7.8.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "26.3.0",
|
||||
"tsx": "4.23.13",
|
||||
"typescript": "7.0.2"
|
||||
}
|
||||
"name": "@atlas/api",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "tsc -p tsconfig.json",
|
||||
"dev": "tsx watch src/main.ts",
|
||||
"start": "node dist/main.js",
|
||||
"typecheck": "tsc -p tsconfig.json --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@atlas/contracts": "workspace:*",
|
||||
"@atlas/database": "workspace:*",
|
||||
"@nestjs/common": "12.0.1",
|
||||
"@nestjs/config": "12.0.0",
|
||||
"@nestjs/core": "12.0.1",
|
||||
"@nestjs/platform-express": "12.0.1",
|
||||
"@nestjs/swagger": "12.0.1",
|
||||
"helmet": "8.3.0",
|
||||
"ioredis": "6.0.0",
|
||||
"reflect-metadata": "0.2.2",
|
||||
"rxjs": "7.8.2",
|
||||
"zod": "4.5.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/express": "5.0.6",
|
||||
"@types/node": "26.3.0",
|
||||
"tsx": "4.23.13",
|
||||
"typescript": "7.0.2"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,37 @@
|
||||
import { Controller, Get, Module } from '@nestjs/common';
|
||||
import { Controller, Get, Inject, Module } from '@nestjs/common';
|
||||
import type { Database } from '@atlas/database';
|
||||
import { AuthModule } from './auth/auth.module.js';
|
||||
import { ApiError } from './common/api-error.js';
|
||||
import { AppConfigModule } from './config/app-config.module.js';
|
||||
import { DATABASE, DatabaseModule } from './database/database.module.js';
|
||||
import { SecurityModule } from './security/security.module.js';
|
||||
|
||||
@Controller('health')
|
||||
@Controller()
|
||||
class HealthController {
|
||||
@Get()
|
||||
check() {
|
||||
return { status: 'ok', service: 'api' };
|
||||
}
|
||||
public constructor(@Inject(DATABASE) private readonly database: Database) {}
|
||||
|
||||
@Get('health')
|
||||
check() {
|
||||
return { status: 'ok', service: 'api' };
|
||||
}
|
||||
|
||||
@Get('ready')
|
||||
async ready() {
|
||||
try {
|
||||
await this.database.query('SELECT 1');
|
||||
return { status: 'ready', service: 'api' };
|
||||
} catch {
|
||||
throw new ApiError(
|
||||
503,
|
||||
'DEPENDENCY_UNAVAILABLE',
|
||||
'服务依赖暂不可用',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Module({ controllers: [HealthController] })
|
||||
@Module({
|
||||
imports: [AppConfigModule, DatabaseModule, SecurityModule, AuthModule],
|
||||
controllers: [HealthController],
|
||||
})
|
||||
export class AppModule {}
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
HttpCode,
|
||||
Post,
|
||||
Req,
|
||||
Res,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import {
|
||||
ApiBearerAuth,
|
||||
ApiBody,
|
||||
ApiNoContentResponse,
|
||||
ApiOkResponse,
|
||||
ApiOperation,
|
||||
ApiTags,
|
||||
ApiUnauthorizedResponse,
|
||||
} from '@nestjs/swagger';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { loginRequestSchema, registerRequestSchema } from '@atlas/contracts';
|
||||
import { ApiError } from '../common/api-error.js';
|
||||
import { AccessTokenGuard, type AuthenticatedRequest } from './auth.guard.js';
|
||||
import { AuthService, type RequestMetadata } from './auth.service.js';
|
||||
|
||||
const refreshCookie = 'atlas_refresh';
|
||||
type HttpRequest = {
|
||||
header: (name: string) => string | undefined;
|
||||
ip?: string;
|
||||
};
|
||||
type HttpResponse = {
|
||||
cookie: (
|
||||
name: string,
|
||||
value: string,
|
||||
options: Record<string, unknown>,
|
||||
) => void;
|
||||
clearCookie: (name: string, options: Record<string, unknown>) => void;
|
||||
};
|
||||
|
||||
@ApiTags('Authentication')
|
||||
@Controller('auth')
|
||||
export class AuthController {
|
||||
public constructor(private readonly authService: AuthService) {}
|
||||
|
||||
@Post('register')
|
||||
@ApiOperation({ summary: '注册账户并创建个人工作空间' })
|
||||
@ApiBody({
|
||||
schema: {
|
||||
example: {
|
||||
email: 'owner@example.com',
|
||||
password: 'a-strong-password',
|
||||
password_confirmation: 'a-strong-password',
|
||||
display_name: 'Joey',
|
||||
},
|
||||
},
|
||||
})
|
||||
public async register(
|
||||
@Body() body: unknown,
|
||||
@Req() request: HttpRequest,
|
||||
@Res({ passthrough: true }) response: HttpResponse,
|
||||
) {
|
||||
const result = await this.authService.register(
|
||||
parse(registerRequestSchema, body),
|
||||
metadata(request),
|
||||
);
|
||||
setRefreshCookie(response, result.refreshToken);
|
||||
return { data: result.payload };
|
||||
}
|
||||
|
||||
@Post('login')
|
||||
@ApiOperation({ summary: '使用邮箱和密码登录' })
|
||||
@ApiBody({
|
||||
schema: {
|
||||
example: {
|
||||
email: 'owner@example.com',
|
||||
password: 'a-strong-password',
|
||||
},
|
||||
},
|
||||
})
|
||||
public async login(
|
||||
@Body() body: unknown,
|
||||
@Req() request: HttpRequest,
|
||||
@Res({ passthrough: true }) response: HttpResponse,
|
||||
) {
|
||||
const result = await this.authService.login(
|
||||
parse(loginRequestSchema, body),
|
||||
metadata(request),
|
||||
);
|
||||
setRefreshCookie(response, result.refreshToken);
|
||||
return { data: result.payload };
|
||||
}
|
||||
|
||||
@Post('refresh')
|
||||
@ApiOperation({ summary: '轮换 refresh cookie 并获取新的 access token' })
|
||||
public async refresh(
|
||||
@Req() request: HttpRequest,
|
||||
@Res({ passthrough: true }) response: HttpResponse,
|
||||
) {
|
||||
try {
|
||||
const result = await this.authService.refresh(
|
||||
readCookie(request.header('cookie'), refreshCookie),
|
||||
metadata(request),
|
||||
);
|
||||
setRefreshCookie(response, result.refreshToken);
|
||||
return {
|
||||
data: {
|
||||
access_token: result.payload.access_token,
|
||||
access_token_expires_in:
|
||||
result.payload.access_token_expires_in,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
clearRefreshCookie(response);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@Post('logout')
|
||||
@HttpCode(204)
|
||||
@UseGuards(AccessTokenGuard)
|
||||
@ApiBearerAuth('access-token')
|
||||
@ApiOperation({ summary: '撤销当前登录会话' })
|
||||
@ApiNoContentResponse({ description: '会话已撤销' })
|
||||
@ApiUnauthorizedResponse({ description: 'access token 无效或已过期' })
|
||||
public async logout(
|
||||
@Req() request: AuthenticatedRequest,
|
||||
@Res({ passthrough: true }) response: HttpResponse,
|
||||
) {
|
||||
await this.authService.logout(request.auth.sessionId);
|
||||
clearRefreshCookie(response);
|
||||
}
|
||||
|
||||
@Get('me')
|
||||
@UseGuards(AccessTokenGuard)
|
||||
@ApiBearerAuth('access-token')
|
||||
@ApiOperation({ summary: '获取当前用户、工作空间和页头初始上下文' })
|
||||
@ApiOkResponse({ description: '当前身份和工作空间' })
|
||||
@ApiUnauthorizedResponse({ description: 'access token 无效或已过期' })
|
||||
public async me(@Req() request: AuthenticatedRequest) {
|
||||
return { data: await this.authService.me(request.auth.userId) };
|
||||
}
|
||||
}
|
||||
|
||||
function parse<
|
||||
T extends {
|
||||
safeParse: (value: unknown) => {
|
||||
success: boolean;
|
||||
data?: unknown;
|
||||
error?: { issues: Array<{ path: PropertyKey[]; message: string }> };
|
||||
};
|
||||
},
|
||||
>(schema: T, value: unknown) {
|
||||
const result = schema.safeParse(value);
|
||||
if (result.success)
|
||||
return result.data as ReturnType<T['safeParse']> extends {
|
||||
data: infer Data;
|
||||
}
|
||||
? Data
|
||||
: never;
|
||||
throw new ApiError(
|
||||
422,
|
||||
'VALIDATION_FAILED',
|
||||
'请求参数不符合要求',
|
||||
result.error?.issues.map((issue) => ({
|
||||
field: issue.path.join('.'),
|
||||
code: 'INVALID_FIELD',
|
||||
message: issue.message,
|
||||
})),
|
||||
);
|
||||
}
|
||||
function metadata(request: HttpRequest): RequestMetadata {
|
||||
return {
|
||||
ip: request.ip ?? null,
|
||||
ipHash: request.ip
|
||||
? createHash('sha256').update(request.ip).digest('base64url')
|
||||
: null,
|
||||
userAgent: request.header('user-agent') ?? null,
|
||||
};
|
||||
}
|
||||
function cookieOptions() {
|
||||
return {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
sameSite: 'lax' as const,
|
||||
path: '/api/auth',
|
||||
maxAge: 30 * 24 * 60 * 60 * 1000,
|
||||
};
|
||||
}
|
||||
function setRefreshCookie(response: HttpResponse, token: string) {
|
||||
response.cookie(refreshCookie, token, cookieOptions());
|
||||
}
|
||||
function clearRefreshCookie(response: HttpResponse) {
|
||||
response.clearCookie(refreshCookie, {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
sameSite: 'lax',
|
||||
path: '/api/auth',
|
||||
});
|
||||
}
|
||||
function readCookie(header: string | undefined, name: string) {
|
||||
return header
|
||||
?.split(';')
|
||||
.map((part) => part.trim())
|
||||
.find((part) => part.startsWith(`${name}=`))
|
||||
?.slice(name.length + 1);
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import {
|
||||
createHash,
|
||||
createHmac,
|
||||
randomBytes,
|
||||
scrypt as scryptCallback,
|
||||
timingSafeEqual,
|
||||
} from 'node:crypto';
|
||||
import { promisify } from 'node:util';
|
||||
|
||||
const scrypt = promisify(scryptCallback) as (
|
||||
password: string,
|
||||
salt: string,
|
||||
keyLength: number,
|
||||
options: { N: number; r: number; p: number; maxmem: number },
|
||||
) => Promise<Buffer>;
|
||||
const scryptOptions = { N: 16_384, r: 8, p: 1, maxmem: 32 * 1024 * 1024 };
|
||||
|
||||
export async function hashPassword(password: string): Promise<string> {
|
||||
// The random salt and expensive KDF keep a database leak from exposing passwords directly.
|
||||
const salt = randomBytes(16).toString('base64url');
|
||||
const derived = (await scrypt(password, salt, 64, scryptOptions)) as Buffer;
|
||||
return `scrypt$${salt}$${derived.toString('base64url')}`;
|
||||
}
|
||||
|
||||
export async function verifyPassword(
|
||||
password: string,
|
||||
encoded: string,
|
||||
): Promise<boolean> {
|
||||
const [algorithm, salt, expected] = encoded.split('$');
|
||||
if (algorithm !== 'scrypt' || !salt || !expected) return false;
|
||||
const actual = (await scrypt(password, salt, 64, scryptOptions)) as Buffer;
|
||||
const expectedBuffer = Buffer.from(expected, 'base64url');
|
||||
return (
|
||||
expectedBuffer.length === actual.length &&
|
||||
timingSafeEqual(expectedBuffer, actual)
|
||||
);
|
||||
}
|
||||
|
||||
export function createOpaqueToken(): string {
|
||||
return randomBytes(48).toString('base64url');
|
||||
}
|
||||
export function tokenHash(token: string): string {
|
||||
return createHash('sha256').update(token).digest('base64url');
|
||||
}
|
||||
|
||||
export type AccessTokenClaims = {
|
||||
sub: string;
|
||||
sid: string;
|
||||
exp: number;
|
||||
iat: number;
|
||||
};
|
||||
export function signAccessToken(
|
||||
claims: AccessTokenClaims,
|
||||
secret: string,
|
||||
): string {
|
||||
// Access tokens are short-lived; refresh sessions remain revocable in PostgreSQL.
|
||||
const header = Buffer.from(
|
||||
JSON.stringify({ alg: 'HS256', typ: 'JWT' }),
|
||||
).toString('base64url');
|
||||
const payload = Buffer.from(JSON.stringify(claims)).toString('base64url');
|
||||
const signature = createHmac('sha256', secret)
|
||||
.update(`${header}.${payload}`)
|
||||
.digest('base64url');
|
||||
return `${header}.${payload}.${signature}`;
|
||||
}
|
||||
export function verifyAccessToken(
|
||||
token: string,
|
||||
secret: string,
|
||||
): AccessTokenClaims | null {
|
||||
const [header, payload, signature] = token.split('.');
|
||||
if (!header || !payload || !signature) return null;
|
||||
const expected = createHmac('sha256', secret)
|
||||
.update(`${header}.${payload}`)
|
||||
.digest();
|
||||
const actual = Buffer.from(signature, 'base64url');
|
||||
if (actual.length !== expected.length || !timingSafeEqual(actual, expected))
|
||||
return null;
|
||||
try {
|
||||
const claims = JSON.parse(
|
||||
Buffer.from(payload, 'base64url').toString('utf8'),
|
||||
) as AccessTokenClaims;
|
||||
return typeof claims.sub === 'string' &&
|
||||
typeof claims.sid === 'string' &&
|
||||
typeof claims.exp === 'number' &&
|
||||
claims.exp > Math.floor(Date.now() / 1000)
|
||||
? claims
|
||||
: null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
|
||||
import { AuthService } from './auth.service.js';
|
||||
|
||||
export type AuthenticatedRequest = {
|
||||
header: (name: string) => string | undefined;
|
||||
auth: { userId: string; sessionId: string };
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class AccessTokenGuard implements CanActivate {
|
||||
public constructor(private readonly authService: AuthService) {}
|
||||
public async canActivate(context: ExecutionContext) {
|
||||
const request = context
|
||||
.switchToHttp()
|
||||
.getRequest<AuthenticatedRequest>();
|
||||
request.auth = await this.authService.authenticate(
|
||||
request.header('authorization'),
|
||||
);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AuthController } from './auth.controller.js';
|
||||
import { AccessTokenGuard } from './auth.guard.js';
|
||||
import { AuthService } from './auth.service.js';
|
||||
|
||||
@Module({
|
||||
controllers: [AuthController],
|
||||
providers: [AuthService, AccessTokenGuard],
|
||||
})
|
||||
export class AuthModule {}
|
||||
@@ -0,0 +1,340 @@
|
||||
import { Inject, Injectable, UnauthorizedException } from '@nestjs/common';
|
||||
import type {
|
||||
AuthSessionResponse,
|
||||
AuthUser,
|
||||
LoginRequest,
|
||||
RegisterRequest,
|
||||
Workspace,
|
||||
} from '@atlas/contracts';
|
||||
import type { Database, DatabaseTransaction } from '@atlas/database';
|
||||
import { ApiError } from '../common/api-error.js';
|
||||
import { APP_CONFIG, type AppConfig } from '../config/app-config.module.js';
|
||||
import { DATABASE } from '../database/database.module.js';
|
||||
import { AuthRateLimiter } from '../security/auth-rate-limiter.service.js';
|
||||
import {
|
||||
createOpaqueToken,
|
||||
hashPassword,
|
||||
signAccessToken,
|
||||
tokenHash,
|
||||
verifyAccessToken,
|
||||
verifyPassword,
|
||||
} from './auth.crypto.js';
|
||||
|
||||
type UserRow = {
|
||||
id: string;
|
||||
email: string;
|
||||
display_name: string | null;
|
||||
email_verified_at: Date | null;
|
||||
created_at: Date;
|
||||
status: 'active' | 'disabled' | 'pending_review';
|
||||
password_hash: string;
|
||||
};
|
||||
type WorkspaceRow = { id: string; name: string; role: Workspace['role'] };
|
||||
|
||||
@Injectable()
|
||||
export class AuthService {
|
||||
private readonly accessTtlSeconds = 900;
|
||||
private readonly refreshTtlDays = 30;
|
||||
|
||||
public constructor(
|
||||
@Inject(DATABASE) private readonly database: Database,
|
||||
@Inject(APP_CONFIG) private readonly config: AppConfig,
|
||||
private readonly rateLimiter: AuthRateLimiter,
|
||||
) {}
|
||||
|
||||
public async register(input: RegisterRequest, metadata: RequestMetadata) {
|
||||
if (input.password !== input.password_confirmation)
|
||||
throw validationError(
|
||||
'password_confirmation',
|
||||
'PASSWORD_CONFIRMATION_MISMATCH',
|
||||
'两次输入的密码不一致',
|
||||
);
|
||||
if (input.password.length < this.config.AUTH_PASSWORD_MIN_LENGTH)
|
||||
throw validationError(
|
||||
'password',
|
||||
'PASSWORD_TOO_SHORT',
|
||||
`密码至少需要 ${this.config.AUTH_PASSWORD_MIN_LENGTH} 个字符`,
|
||||
);
|
||||
const email = input.email.trim().toLowerCase();
|
||||
// Apply the shared limiter before spending CPU on password hashing or querying account existence.
|
||||
await this.rateLimiter.consume('register', { ip: metadata.ip, email });
|
||||
const passwordHash = await hashPassword(input.password);
|
||||
try {
|
||||
return await this.database.transaction(async (transaction) => {
|
||||
// A user must never exist without its initial tenant and owner membership.
|
||||
const user = await insertUser(
|
||||
transaction,
|
||||
email,
|
||||
passwordHash,
|
||||
input.display_name?.trim() || null,
|
||||
);
|
||||
const workspace = await createPersonalWorkspace(
|
||||
transaction,
|
||||
user.id,
|
||||
user.display_name ?? email,
|
||||
);
|
||||
return this.createSessionResponse(
|
||||
transaction,
|
||||
user,
|
||||
workspace,
|
||||
metadata,
|
||||
);
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
if (isUniqueViolation(error))
|
||||
throw new ApiError(
|
||||
409,
|
||||
'EMAIL_ALREADY_REGISTERED',
|
||||
'该邮箱已注册',
|
||||
[
|
||||
{
|
||||
field: 'email',
|
||||
code: 'EMAIL_ALREADY_REGISTERED',
|
||||
message: '该邮箱已注册',
|
||||
},
|
||||
],
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
public async login(input: LoginRequest, metadata: RequestMetadata) {
|
||||
const email = input.email.trim().toLowerCase();
|
||||
// Use the same normalized value for the limiter and citext account lookup.
|
||||
await this.rateLimiter.consume('login', { ip: metadata.ip, email });
|
||||
const result = await this.database.query<UserRow>(
|
||||
'SELECT id, email, display_name, email_verified_at, created_at, status, password_hash FROM atlas.users WHERE email = $1',
|
||||
[email],
|
||||
);
|
||||
const user = result.rows[0];
|
||||
if (
|
||||
!user ||
|
||||
!(await verifyPassword(input.password, user.password_hash))
|
||||
)
|
||||
throw new ApiError(401, 'INVALID_CREDENTIALS', '邮箱或密码错误');
|
||||
if (user.status === 'disabled')
|
||||
throw new ApiError(403, 'ACCOUNT_DISABLED', '账户已停用');
|
||||
const workspace = await this.findWorkspace(user.id);
|
||||
return this.database.transaction(async (transaction) => {
|
||||
await transaction.query(
|
||||
'UPDATE atlas.users SET last_login_at = now() WHERE id = $1',
|
||||
[user.id],
|
||||
);
|
||||
return this.createSessionResponse(
|
||||
transaction,
|
||||
user,
|
||||
workspace,
|
||||
metadata,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
public async refresh(
|
||||
refreshToken: string | undefined,
|
||||
metadata: RequestMetadata,
|
||||
) {
|
||||
// Refresh has no trusted email until its opaque token is validated, so it is IP-scoped only.
|
||||
await this.rateLimiter.consume('refresh', { ip: metadata.ip });
|
||||
if (!refreshToken)
|
||||
throw new ApiError(401, 'SESSION_EXPIRED', '登录会话已过期');
|
||||
return this.database.transaction(async (transaction) => {
|
||||
const sessions = await transaction.query<
|
||||
UserRow & { session_id: string }
|
||||
>(
|
||||
`SELECT u.id, u.email, u.display_name, u.email_verified_at, u.created_at, u.status, u.password_hash, s.id AS session_id
|
||||
FROM atlas.user_sessions s JOIN atlas.users u ON u.id = s.user_id
|
||||
WHERE s.refresh_token_hash = $1 AND s.revoked_at IS NULL AND s.expires_at > now() FOR UPDATE`,
|
||||
[tokenHash(refreshToken)],
|
||||
);
|
||||
const user = sessions.rows[0];
|
||||
if (!user || user.status !== 'active')
|
||||
throw new ApiError(401, 'SESSION_EXPIRED', '登录会话已过期');
|
||||
// Row locking makes a refresh token single-use even when two browser requests arrive together.
|
||||
await transaction.query(
|
||||
'UPDATE atlas.user_sessions SET revoked_at = now() WHERE id = $1',
|
||||
[user.session_id],
|
||||
);
|
||||
const workspace = await findWorkspaceInTransaction(
|
||||
transaction,
|
||||
user.id,
|
||||
);
|
||||
return this.createSessionResponse(
|
||||
transaction,
|
||||
user,
|
||||
workspace,
|
||||
metadata,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
public async logout(sessionId: string) {
|
||||
await this.database.query(
|
||||
'UPDATE atlas.user_sessions SET revoked_at = now() WHERE id = $1 AND revoked_at IS NULL',
|
||||
[sessionId],
|
||||
);
|
||||
}
|
||||
|
||||
public async authenticate(accessToken: string | undefined) {
|
||||
const token = accessToken?.replace(/^Bearer\s+/i, '');
|
||||
const claims = token
|
||||
? verifyAccessToken(token, this.config.AUTH_JWT_SECRET)
|
||||
: null;
|
||||
if (!claims) throw new UnauthorizedException();
|
||||
const active = await this.database.query(
|
||||
'SELECT 1 FROM atlas.user_sessions WHERE id = $1 AND user_id = $2 AND revoked_at IS NULL AND expires_at > now()',
|
||||
[claims.sid, claims.sub],
|
||||
);
|
||||
if (!active.rowCount) throw new UnauthorizedException();
|
||||
return { userId: claims.sub, sessionId: claims.sid };
|
||||
}
|
||||
|
||||
public async me(userId: string) {
|
||||
const workspace = await this.findWorkspace(userId);
|
||||
const users = await this.database.query<UserRow>(
|
||||
'SELECT id, email, display_name, email_verified_at, created_at, status, password_hash FROM atlas.users WHERE id = $1',
|
||||
[userId],
|
||||
);
|
||||
const user = users.rows[0];
|
||||
if (!user) throw new UnauthorizedException();
|
||||
const platforms = await this.database.workspaceTransaction(
|
||||
workspace.id,
|
||||
async (transaction) => {
|
||||
const rows = await transaction.query<{
|
||||
id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
}>(
|
||||
"SELECT id, code, name FROM atlas.platforms WHERE status = 'active' ORDER BY CASE WHEN code = 'OZON' THEN 0 ELSE 1 END, name",
|
||||
);
|
||||
return rows.rows;
|
||||
},
|
||||
);
|
||||
return {
|
||||
user: publicUser(user),
|
||||
workspace,
|
||||
platforms,
|
||||
suggested_context: {
|
||||
platform_id: platforms[0]?.id ?? null,
|
||||
default_store: null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private async findWorkspace(userId: string) {
|
||||
const result = await this.database.query<WorkspaceRow>(
|
||||
`SELECT w.id, w.name, m.role FROM atlas.workspace_memberships m JOIN atlas.workspaces w ON w.id = m.workspace_id WHERE m.user_id = $1 AND w.status = 'active' ORDER BY m.joined_at LIMIT 1`,
|
||||
[userId],
|
||||
);
|
||||
return requireWorkspace(result.rows[0]);
|
||||
}
|
||||
private async createSessionResponse(
|
||||
transaction: DatabaseTransaction,
|
||||
user: UserRow,
|
||||
workspace: WorkspaceRow,
|
||||
metadata: RequestMetadata,
|
||||
) {
|
||||
const refreshToken = createOpaqueToken();
|
||||
const session = await transaction.query<{ id: string }>(
|
||||
`INSERT INTO atlas.user_sessions (user_id, refresh_token_hash, expires_at, ip_hash, user_agent)
|
||||
VALUES ($1, $2, now() + ($3 || ' days')::interval, $4, $5) RETURNING id`,
|
||||
[
|
||||
user.id,
|
||||
tokenHash(refreshToken),
|
||||
this.refreshTtlDays,
|
||||
metadata.ipHash,
|
||||
metadata.userAgent,
|
||||
],
|
||||
);
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const accessToken = signAccessToken(
|
||||
{
|
||||
sub: user.id,
|
||||
sid: session.rows[0].id,
|
||||
iat: now,
|
||||
exp: now + this.accessTtlSeconds,
|
||||
},
|
||||
this.config.AUTH_JWT_SECRET,
|
||||
);
|
||||
const payload: AuthSessionResponse = {
|
||||
access_token: accessToken,
|
||||
access_token_expires_in: this.accessTtlSeconds,
|
||||
user: publicUser(user),
|
||||
workspace,
|
||||
};
|
||||
return { payload, refreshToken };
|
||||
}
|
||||
}
|
||||
|
||||
export type RequestMetadata = {
|
||||
ip: string | null;
|
||||
ipHash: string | null;
|
||||
userAgent: string | null;
|
||||
};
|
||||
function validationError(field: string, code: string, message: string) {
|
||||
return new ApiError(422, 'VALIDATION_FAILED', '请求参数不符合要求', [
|
||||
{ field, code, message },
|
||||
]);
|
||||
}
|
||||
function isUniqueViolation(error: unknown): error is { code: string } {
|
||||
return (
|
||||
typeof error === 'object' &&
|
||||
error !== null &&
|
||||
'code' in error &&
|
||||
(error as { code: string }).code === '23505'
|
||||
);
|
||||
}
|
||||
function publicUser(user: UserRow): AuthUser {
|
||||
return {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
display_name: user.display_name,
|
||||
email_verified_at: user.email_verified_at?.toISOString() ?? null,
|
||||
created_at: user.created_at.toISOString(),
|
||||
};
|
||||
}
|
||||
async function insertUser(
|
||||
transaction: DatabaseTransaction,
|
||||
email: string,
|
||||
passwordHash: string,
|
||||
displayName: string | null,
|
||||
) {
|
||||
const result = await transaction.query<UserRow>(
|
||||
'INSERT INTO atlas.users (email, password_hash, display_name) VALUES ($1, $2, $3) RETURNING id, email, display_name, email_verified_at, created_at, status, password_hash',
|
||||
[email, passwordHash, displayName],
|
||||
);
|
||||
return result.rows[0];
|
||||
}
|
||||
async function createPersonalWorkspace(
|
||||
transaction: DatabaseTransaction,
|
||||
userId: string,
|
||||
label: string,
|
||||
) {
|
||||
const workspace = await transaction.query<{ id: string; name: string }>(
|
||||
'INSERT INTO atlas.workspaces (name, slug) VALUES ($1, $2) RETURNING id, name',
|
||||
[`${label} 的工作空间`, `personal-${userId}`],
|
||||
);
|
||||
await transaction.query(
|
||||
"INSERT INTO atlas.workspace_memberships (workspace_id, user_id, role) VALUES ($1, $2, 'owner')",
|
||||
[workspace.rows[0].id, userId],
|
||||
);
|
||||
return { ...workspace.rows[0], role: 'owner' as const };
|
||||
}
|
||||
async function findWorkspaceInTransaction(
|
||||
transaction: DatabaseTransaction,
|
||||
userId: string,
|
||||
): Promise<WorkspaceRow> {
|
||||
const result = await transaction.query<WorkspaceRow>(
|
||||
`SELECT w.id, w.name, m.role FROM atlas.workspace_memberships m JOIN atlas.workspaces w ON w.id = m.workspace_id WHERE m.user_id = $1 AND w.status = 'active' ORDER BY m.joined_at LIMIT 1`,
|
||||
[userId],
|
||||
);
|
||||
return requireWorkspace(result.rows[0]);
|
||||
}
|
||||
function requireWorkspace(workspace: WorkspaceRow | undefined): WorkspaceRow {
|
||||
if (!workspace)
|
||||
throw new ApiError(
|
||||
403,
|
||||
'WORKSPACE_MEMBERSHIP_REQUIRED',
|
||||
'当前账户没有可用工作空间',
|
||||
);
|
||||
return workspace;
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import {
|
||||
ArgumentsHost,
|
||||
Catch,
|
||||
ExceptionFilter,
|
||||
HttpException,
|
||||
} from '@nestjs/common';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { ApiError } from './api-error.js';
|
||||
import { AppLogger } from '../logging/app-logger.js';
|
||||
|
||||
@Catch()
|
||||
export class ApiErrorFilter implements ExceptionFilter {
|
||||
public constructor(private readonly logger: AppLogger) {}
|
||||
|
||||
public catch(exception: unknown, host: ArgumentsHost) {
|
||||
const response = host.switchToHttp().getResponse<{
|
||||
status: (status: number) => {
|
||||
json: (body: unknown) => unknown;
|
||||
};
|
||||
}>();
|
||||
const request = host.switchToHttp().getRequest<{
|
||||
requestId?: string;
|
||||
method?: string;
|
||||
originalUrl?: string;
|
||||
}>();
|
||||
const requestId = request.requestId ?? `req_${randomUUID()}`;
|
||||
if (exception instanceof ApiError) {
|
||||
const body = exception.getResponse() as {
|
||||
error: Record<string, unknown>;
|
||||
};
|
||||
return response
|
||||
.status(exception.getStatus())
|
||||
.json({ error: { ...body.error, request_id: requestId } });
|
||||
}
|
||||
if (exception instanceof HttpException) {
|
||||
return response.status(exception.getStatus()).json({
|
||||
error: {
|
||||
code: 'HTTP_ERROR',
|
||||
message: '请求无法处理',
|
||||
request_id: requestId,
|
||||
},
|
||||
});
|
||||
}
|
||||
this.logger.error(
|
||||
'unhandled_exception',
|
||||
exception instanceof Error ? exception.stack : undefined,
|
||||
{
|
||||
request_id: requestId,
|
||||
method: request.method,
|
||||
path: request.originalUrl,
|
||||
},
|
||||
);
|
||||
return response.status(500).json({
|
||||
error: {
|
||||
code: 'INTERNAL_ERROR',
|
||||
message: '服务器暂时无法处理请求',
|
||||
request_id: requestId,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { HttpException, HttpStatus } from '@nestjs/common';
|
||||
|
||||
export class ApiError extends HttpException {
|
||||
public constructor(
|
||||
status: HttpStatus,
|
||||
code: string,
|
||||
message: string,
|
||||
details?: unknown[],
|
||||
) {
|
||||
super({ error: { code, message, details } }, status);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
import { existsSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import { z } from 'zod';
|
||||
|
||||
export const APP_CONFIG = Symbol('APP_CONFIG');
|
||||
|
||||
function findEnvironmentFile(): string {
|
||||
const directories = [
|
||||
process.cwd(),
|
||||
resolve(process.cwd(), '..'),
|
||||
resolve(process.cwd(), '../..'),
|
||||
];
|
||||
const environmentFile = directories
|
||||
.map((directory) => resolve(directory, '.env'))
|
||||
.find((file) => existsSync(file));
|
||||
|
||||
return environmentFile ?? resolve(process.cwd(), '.env');
|
||||
}
|
||||
|
||||
// This schema is the only supported source for API runtime settings. Defaults are
|
||||
// intentionally limited to non-secret local-development values.
|
||||
const environmentSchema = z.object({
|
||||
NODE_ENV: z
|
||||
.enum(['development', 'test', 'production'])
|
||||
.default('development'),
|
||||
API_PORT: z.coerce.number().int().min(1).max(65_535).default(8200),
|
||||
WEB_ORIGIN: z.url().default('http://localhost:8100'),
|
||||
DATABASE_URL: z.url(),
|
||||
REDIS_URL: z.url(),
|
||||
AUTH_JWT_SECRET: z.string().min(32),
|
||||
AUTH_RATE_LIMIT_KEY_SECRET: z.string().min(32),
|
||||
AUTH_PASSWORD_MIN_LENGTH: z.coerce
|
||||
.number()
|
||||
.int()
|
||||
.min(12)
|
||||
.max(256)
|
||||
.default(12),
|
||||
API_BODY_LIMIT_BYTES: z.coerce
|
||||
.number()
|
||||
.int()
|
||||
.min(1_024)
|
||||
.max(10 * 1024 * 1024)
|
||||
.default(1_048_576),
|
||||
AUTH_RATE_LIMIT_WINDOW_SECONDS: z.coerce
|
||||
.number()
|
||||
.int()
|
||||
.min(1)
|
||||
.max(86_400)
|
||||
.default(900),
|
||||
AUTH_LOGIN_IP_LIMIT: z.coerce.number().int().min(1).max(1000).default(10),
|
||||
AUTH_LOGIN_EMAIL_LIMIT: z.coerce.number().int().min(1).max(1000).default(5),
|
||||
AUTH_REGISTER_IP_LIMIT: z.coerce.number().int().min(1).max(1000).default(5),
|
||||
AUTH_REGISTER_EMAIL_LIMIT: z.coerce
|
||||
.number()
|
||||
.int()
|
||||
.min(1)
|
||||
.max(1000)
|
||||
.default(3),
|
||||
AUTH_REFRESH_IP_LIMIT: z.coerce
|
||||
.number()
|
||||
.int()
|
||||
.min(1)
|
||||
.max(10_000)
|
||||
.default(30),
|
||||
TRUSTED_PROXY_CIDRS: z.string().default(''),
|
||||
SWAGGER_ENABLED: z.enum(['true', 'false']).default('true'),
|
||||
LOG_LEVEL: z.enum(['error', 'warn', 'log', 'debug']).default('log'),
|
||||
});
|
||||
|
||||
type Environment = z.infer<typeof environmentSchema>;
|
||||
export type AppConfig = Omit<
|
||||
Environment,
|
||||
'TRUSTED_PROXY_CIDRS' | 'SWAGGER_ENABLED'
|
||||
> & {
|
||||
trustedProxyCidrs: string[];
|
||||
swaggerEnabled: boolean;
|
||||
};
|
||||
|
||||
function validateEnvironment(
|
||||
environment: Record<string, unknown>,
|
||||
): Environment {
|
||||
const result = environmentSchema.safeParse(environment);
|
||||
if (result.success) return result.data;
|
||||
// Fail before Nest opens a listener; a half-configured API is unsafe to run.
|
||||
throw new Error(
|
||||
`Invalid environment configuration: ${result.error.issues.map((issue) => issue.path.join('.')).join(', ')}`,
|
||||
);
|
||||
}
|
||||
|
||||
function createAppConfig(environment: Environment): AppConfig {
|
||||
return {
|
||||
...environment,
|
||||
// Express only trusts forwarding headers when this explicit allowlist is non-empty.
|
||||
trustedProxyCidrs: environment.TRUSTED_PROXY_CIDRS.split(',')
|
||||
.map((value) => value.trim())
|
||||
.filter(Boolean),
|
||||
swaggerEnabled: environment.SWAGGER_ENABLED === 'true',
|
||||
};
|
||||
}
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
imports: [
|
||||
ConfigModule.forRoot({
|
||||
isGlobal: true,
|
||||
cache: true,
|
||||
// Turbo runs this workspace's scripts from apps/api, while the
|
||||
// shared development environment lives at the repository root.
|
||||
envFilePath: findEnvironmentFile(),
|
||||
}),
|
||||
],
|
||||
providers: [
|
||||
{
|
||||
provide: APP_CONFIG,
|
||||
// Export a normalized immutable config object so services never read process.env directly.
|
||||
useFactory: (): AppConfig =>
|
||||
createAppConfig(validateEnvironment(process.env)),
|
||||
},
|
||||
],
|
||||
exports: [APP_CONFIG],
|
||||
})
|
||||
export class AppConfigModule {}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { Global, Inject, Module, OnModuleDestroy } from '@nestjs/common';
|
||||
import { createApplicationDatabase, type Database } from '@atlas/database';
|
||||
import { APP_CONFIG, type AppConfig } from '../config/app-config.module.js';
|
||||
|
||||
export const DATABASE = Symbol('DATABASE');
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [
|
||||
{
|
||||
provide: DATABASE,
|
||||
inject: [APP_CONFIG],
|
||||
useFactory: (config: AppConfig): Database =>
|
||||
createApplicationDatabase(config.DATABASE_URL),
|
||||
},
|
||||
],
|
||||
exports: [DATABASE],
|
||||
})
|
||||
export class DatabaseModule implements OnModuleDestroy {
|
||||
public constructor(@Inject(DATABASE) private readonly database: Database) {}
|
||||
public async onModuleDestroy() {
|
||||
await this.database.close();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import type { LoggerService } from '@nestjs/common';
|
||||
|
||||
type LogLevel = 'error' | 'warn' | 'log' | 'debug';
|
||||
const severity: Record<LogLevel, number> = {
|
||||
error: 0,
|
||||
warn: 1,
|
||||
log: 2,
|
||||
debug: 3,
|
||||
};
|
||||
const sensitiveKeys = /authorization|cookie|password|secret|token|api[_-]?key/i;
|
||||
|
||||
export class AppLogger implements LoggerService {
|
||||
public constructor(private readonly minimumLevel: LogLevel) {}
|
||||
|
||||
public log(message: unknown, context?: unknown): void {
|
||||
this.write('log', message, context);
|
||||
}
|
||||
|
||||
public error(message: unknown, stack?: string, context?: unknown): void {
|
||||
this.write('error', message, { stack, context });
|
||||
}
|
||||
|
||||
public warn(message: unknown, context?: unknown): void {
|
||||
this.write('warn', message, context);
|
||||
}
|
||||
|
||||
public debug(message: unknown, context?: unknown): void {
|
||||
this.write('debug', message, context);
|
||||
}
|
||||
|
||||
public verbose(message: unknown, context?: unknown): void {
|
||||
this.debug(message, context);
|
||||
}
|
||||
|
||||
private write(level: LogLevel, message: unknown, context?: unknown): void {
|
||||
if (severity[level] > severity[this.minimumLevel]) return;
|
||||
const entry = {
|
||||
timestamp: new Date().toISOString(),
|
||||
level,
|
||||
message: typeof message === 'string' ? message : sanitize(message),
|
||||
context: sanitize(context),
|
||||
};
|
||||
const output = JSON.stringify(entry);
|
||||
if (level === 'error') console.error(output);
|
||||
else console.log(output);
|
||||
}
|
||||
}
|
||||
|
||||
function sanitize(value: unknown): unknown {
|
||||
if (Array.isArray(value)) return value.map(sanitize);
|
||||
if (value && typeof value === 'object') {
|
||||
return Object.fromEntries(
|
||||
Object.entries(value).map(([key, child]) => [
|
||||
key,
|
||||
sensitiveKeys.test(key) ? '[REDACTED]' : sanitize(child),
|
||||
]),
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
+144
-5
@@ -1,12 +1,151 @@
|
||||
import 'reflect-metadata';
|
||||
import { execFile } from 'node:child_process';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import { NestExpressApplication } from '@nestjs/platform-express';
|
||||
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
|
||||
import type { NextFunction, Request, Response } from 'express';
|
||||
import helmet from 'helmet';
|
||||
|
||||
import { AppModule } from './app.module.js';
|
||||
import { ApiErrorFilter } from './common/api-error.filter.js';
|
||||
import { APP_CONFIG, type AppConfig } from './config/app-config.module.js';
|
||||
import { AppLogger } from './logging/app-logger.js';
|
||||
|
||||
async function bootstrap() {
|
||||
const app = await NestFactory.create(AppModule);
|
||||
app.enableCors({ origin: process.env.WEB_ORIGIN ?? 'http://localhost:8100' });
|
||||
await app.listen(Number(process.env.API_PORT ?? 8200));
|
||||
type RequestWithId = Request & {
|
||||
requestId?: string;
|
||||
};
|
||||
|
||||
function openSwaggerInDevelopment(config: AppConfig, logger: AppLogger): void {
|
||||
if (
|
||||
config.NODE_ENV !== 'development' ||
|
||||
!config.swaggerEnabled ||
|
||||
process.platform !== 'darwin'
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const swaggerUrl = `http://localhost:${config.API_PORT}/api-docs`;
|
||||
// 只在本机开发时打开浏览器,生产环境和非 macOS 主机不执行外部进程。
|
||||
execFile('open', [swaggerUrl], (error) => {
|
||||
if (error) {
|
||||
logger.warn('swagger_auto_open_failed', {
|
||||
error: error.message,
|
||||
url: swaggerUrl,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void bootstrap();
|
||||
async function bootstrap() {
|
||||
const app = await NestFactory.create<NestExpressApplication>(AppModule, {
|
||||
bodyParser: false,
|
||||
// Keep Nest's bootstrap errors visible until AppLogger is installed below.
|
||||
logger: ['error', 'warn', 'log'],
|
||||
});
|
||||
const config = app.get<AppConfig>(APP_CONFIG);
|
||||
const logger = new AppLogger(config.LOG_LEVEL);
|
||||
app.useLogger(logger);
|
||||
|
||||
if (config.trustedProxyCidrs.length > 0) {
|
||||
// Never trust forwarded headers unless the deployment names its proxy networks explicitly.
|
||||
app.getHttpAdapter()
|
||||
.getInstance()
|
||||
.set('trust proxy', config.trustedProxyCidrs);
|
||||
}
|
||||
|
||||
app.enableCors({
|
||||
origin: config.WEB_ORIGIN,
|
||||
credentials: true,
|
||||
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
|
||||
allowedHeaders: [
|
||||
'Authorization',
|
||||
'Content-Type',
|
||||
'Idempotency-Key',
|
||||
'If-Match',
|
||||
'X-Atlas-Platform-Id',
|
||||
'X-Request-Id',
|
||||
],
|
||||
exposedHeaders: ['X-Request-Id'],
|
||||
});
|
||||
app.use(helmet({ contentSecurityPolicy: false }));
|
||||
app.useBodyParser('json', { limit: config.API_BODY_LIMIT_BYTES });
|
||||
app.useBodyParser('urlencoded', {
|
||||
limit: config.API_BODY_LIMIT_BYTES,
|
||||
extended: true,
|
||||
});
|
||||
app.use(
|
||||
(request: RequestWithId, response: Response, next: NextFunction) => {
|
||||
const typedRequest = request;
|
||||
const typedResponse = response;
|
||||
const suppliedId = typedRequest.headers['x-request-id'];
|
||||
const requestId =
|
||||
typeof suppliedId === 'string' &&
|
||||
/^[A-Za-z0-9_-]{8,128}$/.test(suppliedId)
|
||||
? suppliedId
|
||||
: `req_${randomUUID()}`;
|
||||
const startedAt = performance.now();
|
||||
typedRequest.requestId = requestId;
|
||||
typedResponse.setHeader('X-Request-Id', requestId);
|
||||
typedResponse.on('finish', () => {
|
||||
logger.log('http_request_completed', {
|
||||
request_id: requestId,
|
||||
method: typedRequest.method,
|
||||
path: typedRequest.originalUrl,
|
||||
status_code: typedResponse.statusCode,
|
||||
duration_ms: Math.round(performance.now() - startedAt),
|
||||
});
|
||||
});
|
||||
next();
|
||||
},
|
||||
);
|
||||
app.useGlobalFilters(new ApiErrorFilter(logger));
|
||||
app.setGlobalPrefix('api', { exclude: ['health', 'ready'] });
|
||||
app.enableShutdownHooks();
|
||||
|
||||
if (config.swaggerEnabled) {
|
||||
const swaggerConfig = new DocumentBuilder()
|
||||
.setTitle('Atlas Seller Kit API')
|
||||
.setDescription('Atlas 卖家工作台 HTTP API 文档')
|
||||
.setVersion('0.1.0')
|
||||
.addBearerAuth(
|
||||
{ type: 'http', scheme: 'bearer', bearerFormat: 'JWT' },
|
||||
'access-token',
|
||||
)
|
||||
.build();
|
||||
const swaggerDocument = SwaggerModule.createDocument(
|
||||
app,
|
||||
swaggerConfig,
|
||||
);
|
||||
SwaggerModule.setup('api-docs', app, swaggerDocument, {
|
||||
swaggerOptions: { persistAuthorization: true },
|
||||
});
|
||||
}
|
||||
|
||||
await app.listen(config.API_PORT);
|
||||
logger.log('api_started', {
|
||||
port: config.API_PORT,
|
||||
swagger_enabled: config.swaggerEnabled,
|
||||
});
|
||||
openSwaggerInDevelopment(config, logger);
|
||||
}
|
||||
|
||||
void bootstrap().catch((error: unknown) => {
|
||||
const details =
|
||||
error instanceof Error
|
||||
? {
|
||||
name: error.name,
|
||||
message: error.message,
|
||||
stack: error.stack,
|
||||
}
|
||||
: error;
|
||||
console.error(
|
||||
JSON.stringify({
|
||||
timestamp: new Date().toISOString(),
|
||||
level: 'error',
|
||||
message: 'api_startup_failed',
|
||||
error: details,
|
||||
}),
|
||||
);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
import { Inject, Injectable, OnModuleDestroy } from '@nestjs/common';
|
||||
import { createHmac } from 'node:crypto';
|
||||
import { Redis } from 'ioredis';
|
||||
import { ApiError } from '../common/api-error.js';
|
||||
import { APP_CONFIG, type AppConfig } from '../config/app-config.module.js';
|
||||
|
||||
type AuthAction = 'login' | 'register' | 'refresh';
|
||||
type Limit = { ip: number; email?: number };
|
||||
|
||||
@Injectable()
|
||||
export class AuthRateLimiter implements OnModuleDestroy {
|
||||
private readonly redis: Redis;
|
||||
|
||||
public constructor(@Inject(APP_CONFIG) private readonly config: AppConfig) {
|
||||
// Redis makes counters shared across API processes instead of resetting per instance.
|
||||
this.redis = new Redis(config.REDIS_URL, {
|
||||
enableOfflineQueue: false,
|
||||
maxRetriesPerRequest: 1,
|
||||
});
|
||||
}
|
||||
|
||||
public async consume(
|
||||
action: AuthAction,
|
||||
identifiers: { ip: string | null; email?: string },
|
||||
): Promise<void> {
|
||||
const limit = this.limitFor(action);
|
||||
await this.consumeBucket(
|
||||
action,
|
||||
'ip',
|
||||
identifiers.ip ?? 'unknown',
|
||||
limit.ip,
|
||||
);
|
||||
if (identifiers.email && limit.email) {
|
||||
await this.consumeBucket(
|
||||
action,
|
||||
'email',
|
||||
identifiers.email,
|
||||
limit.email,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public async onModuleDestroy(): Promise<void> {
|
||||
await this.redis.quit();
|
||||
}
|
||||
|
||||
private async consumeBucket(
|
||||
action: AuthAction,
|
||||
dimension: 'ip' | 'email',
|
||||
identifier: string,
|
||||
limit: number,
|
||||
): Promise<void> {
|
||||
const digest = createHmac(
|
||||
'sha256',
|
||||
this.config.AUTH_RATE_LIMIT_KEY_SECRET,
|
||||
)
|
||||
.update(identifier)
|
||||
.digest('base64url');
|
||||
// The raw IP/email never becomes part of a Redis key or a loggable identifier.
|
||||
const key = `atlas:auth-rate-limit:${action}:${dimension}:${digest}`;
|
||||
try {
|
||||
// INCR + first-write EXPIRE must be atomic or concurrent requests could leave a key unexpired.
|
||||
const count = await this.redis.eval(
|
||||
"local value = redis.call('INCR', KEYS[1]); if value == 1 then redis.call('EXPIRE', KEYS[1], ARGV[1]); end; return value;",
|
||||
1,
|
||||
key,
|
||||
this.config.AUTH_RATE_LIMIT_WINDOW_SECONDS,
|
||||
);
|
||||
if (Number(count) > limit) {
|
||||
throw new ApiError(
|
||||
429,
|
||||
'AUTH_RATE_LIMITED',
|
||||
'请求过于频繁,请稍后重试',
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof ApiError) throw error;
|
||||
// Authentication fails closed: silently bypassing a broken shared limiter enables brute-force attacks.
|
||||
throw new ApiError(
|
||||
503,
|
||||
'AUTH_RATE_LIMIT_UNAVAILABLE',
|
||||
'认证服务暂时不可用',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private limitFor(action: AuthAction): Limit {
|
||||
if (action === 'login') {
|
||||
return {
|
||||
ip: this.config.AUTH_LOGIN_IP_LIMIT,
|
||||
email: this.config.AUTH_LOGIN_EMAIL_LIMIT,
|
||||
};
|
||||
}
|
||||
if (action === 'register') {
|
||||
return {
|
||||
ip: this.config.AUTH_REGISTER_IP_LIMIT,
|
||||
email: this.config.AUTH_REGISTER_EMAIL_LIMIT,
|
||||
};
|
||||
}
|
||||
return { ip: this.config.AUTH_REFRESH_IP_LIMIT };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
import { AuthRateLimiter } from './auth-rate-limiter.service.js';
|
||||
|
||||
@Global()
|
||||
@Module({ providers: [AuthRateLimiter], exports: [AuthRateLimiter] })
|
||||
export class SecurityModule {}
|
||||
@@ -1,11 +1,11 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "dist",
|
||||
"types": ["node"],
|
||||
"experimentalDecorators": true,
|
||||
"emitDecoratorMetadata": true
|
||||
},
|
||||
"include": ["src/**/*.ts"]
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "dist",
|
||||
"types": ["node"],
|
||||
"experimentalDecorators": true,
|
||||
"emitDecoratorMetadata": true
|
||||
},
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
export default defineBackground(() => {
|
||||
console.info('Atlas collector background started.');
|
||||
console.info('Atlas collector background started.');
|
||||
});
|
||||
|
||||
+21
-21
@@ -1,23 +1,23 @@
|
||||
{
|
||||
"name": "@atlas/extension",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "wxt build",
|
||||
"dev": "wxt",
|
||||
"typecheck": "wxt prepare && tsc -p tsconfig.json --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@atlas/contracts": "workspace:*",
|
||||
"@wxt-dev/module-react": "1.2.2",
|
||||
"react": "19.2.8",
|
||||
"react-dom": "19.2.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "19.2.18",
|
||||
"@types/react-dom": "19.2.5",
|
||||
"typescript": "7.0.2",
|
||||
"wxt": "0.21.4"
|
||||
}
|
||||
"name": "@atlas/extension",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "wxt build",
|
||||
"dev": "wxt",
|
||||
"typecheck": "wxt prepare && tsc -p tsconfig.json --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@atlas/contracts": "workspace:*",
|
||||
"@wxt-dev/module-react": "1.2.2",
|
||||
"react": "19.2.8",
|
||||
"react-dom": "19.2.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "19.2.18",
|
||||
"@types/react-dom": "19.2.5",
|
||||
"typescript": "7.0.2",
|
||||
"wxt": "0.21.4"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"jsx": "react-jsx",
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["**/*.ts", "**/*.tsx", ".wxt/**/*.ts"]
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"jsx": "react-jsx",
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["**/*.ts", "**/*.tsx", ".wxt/**/*.ts"]
|
||||
}
|
||||
|
||||
@@ -1,25 +1,25 @@
|
||||
import { defineConfig } from 'wxt';
|
||||
|
||||
export default defineConfig({
|
||||
modules: ['@wxt-dev/module-react'],
|
||||
// 扩展 dev server 固定 8110:默认取 3000–3010 第一个空闲端口,会漂移且易与常见服务冲突
|
||||
dev: {
|
||||
server: {
|
||||
port: Number(process.env.EXTENSION_PORT ?? 8110),
|
||||
strictPort: true,
|
||||
modules: ['@wxt-dev/module-react'],
|
||||
// 扩展 dev server 固定 8110:默认取 3000–3010 第一个空闲端口,会漂移且易与常见服务冲突
|
||||
dev: {
|
||||
server: {
|
||||
port: Number(process.env.EXTENSION_PORT ?? 8110),
|
||||
strictPort: true,
|
||||
},
|
||||
},
|
||||
manifest: {
|
||||
name: 'Atlas Seller Kit Collector',
|
||||
description: '将已确认的来源商品采集到 Atlas 工作空间。',
|
||||
permissions: ['storage', 'identity'],
|
||||
host_permissions: [
|
||||
'*://*.ozon.ru/*',
|
||||
'*://*.ozon.kz/*',
|
||||
'*://*.ozon.by/*',
|
||||
'*://*.1688.com/*',
|
||||
'*://*.taobao.com/*',
|
||||
'*://*.tmall.com/*',
|
||||
],
|
||||
},
|
||||
},
|
||||
manifest: {
|
||||
name: 'Atlas Seller Kit Collector',
|
||||
description: '将已确认的来源商品采集到 Atlas 工作空间。',
|
||||
permissions: ['storage', 'identity'],
|
||||
host_permissions: [
|
||||
'*://*.ozon.ru/*',
|
||||
'*://*.ozon.kz/*',
|
||||
'*://*.ozon.by/*',
|
||||
'*://*.1688.com/*',
|
||||
'*://*.taobao.com/*',
|
||||
'*://*.tmall.com/*',
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
+9
-9
@@ -1,12 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Atlas Seller Kit</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Atlas Seller Kit</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+25
-25
@@ -1,27 +1,27 @@
|
||||
{
|
||||
"name": "@atlas/web",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "tsc -b && vite build",
|
||||
"dev": "vite",
|
||||
"typecheck": "tsc -b --pretty false"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ant-design/icons": "6.3.4",
|
||||
"antd": "6.6.2",
|
||||
"axios": "1.20.0",
|
||||
"react": "19.2.8",
|
||||
"react-dom": "19.2.8",
|
||||
"react-router-dom": "7.18.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "26.3.0",
|
||||
"@types/react": "19.2.18",
|
||||
"@types/react-dom": "19.2.5",
|
||||
"@vitejs/plugin-react": "6.1.1",
|
||||
"typescript": "7.0.2",
|
||||
"vite": "8.2.2"
|
||||
}
|
||||
"name": "@atlas/web",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "tsc -b && vite build",
|
||||
"dev": "vite",
|
||||
"typecheck": "tsc -b --pretty false"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ant-design/icons": "6.3.4",
|
||||
"antd": "6.6.2",
|
||||
"axios": "1.20.0",
|
||||
"react": "19.2.8",
|
||||
"react-dom": "19.2.8",
|
||||
"react-router-dom": "7.18.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "26.3.0",
|
||||
"@types/react": "19.2.18",
|
||||
"@types/react-dom": "19.2.5",
|
||||
"@vitejs/plugin-react": "6.1.1",
|
||||
"typescript": "7.0.2",
|
||||
"vite": "8.2.2"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* @see https://prettier.io/docs/en/configuration.html
|
||||
* @type {import("prettier").Config}
|
||||
*/
|
||||
export default {
|
||||
printWidth: 100,
|
||||
tabWidth: 4,
|
||||
useTabs: false,
|
||||
semi: true,
|
||||
singleQuote: true,
|
||||
quoteProps: 'as-needed',
|
||||
jsxSingleQuote: false,
|
||||
trailingComma: 'es5',
|
||||
bracketSpacing: true,
|
||||
jsxBracketSameLine: false,
|
||||
arrowParens: 'always',
|
||||
endOfLine: 'lf',
|
||||
embeddedLanguageFormatting: 'auto',
|
||||
};
|
||||
+19
-19
@@ -5,25 +5,25 @@ import { router } from './router';
|
||||
import './App.css';
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<ConfigProvider
|
||||
locale={zhCN}
|
||||
theme={{
|
||||
token: {
|
||||
colorPrimary: '#8b5cf6',
|
||||
borderRadius: 8,
|
||||
// 全局默认字号 14px;输入类控件默认高度在 32px 基础上同步加高一点
|
||||
fontSize: 14,
|
||||
controlHeight: 34,
|
||||
controlHeightSM: 26,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<AntdApp>
|
||||
<RouterProvider router={router} />
|
||||
</AntdApp>
|
||||
</ConfigProvider>
|
||||
);
|
||||
return (
|
||||
<ConfigProvider
|
||||
locale={zhCN}
|
||||
theme={{
|
||||
token: {
|
||||
colorPrimary: '#8b5cf6',
|
||||
borderRadius: 8,
|
||||
// 全局默认字号 14px;输入类控件默认高度在 32px 基础上同步加高一点
|
||||
fontSize: 14,
|
||||
controlHeight: 34,
|
||||
controlHeightSM: 26,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<AntdApp>
|
||||
<RouterProvider router={router} />
|
||||
</AntdApp>
|
||||
</ConfigProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
|
||||
@@ -1,23 +1,23 @@
|
||||
import { Card, Empty } from 'antd';
|
||||
|
||||
interface PagePlaceholderProps {
|
||||
title: string;
|
||||
description: string;
|
||||
title: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
/** 业务模块实现前的统一占位页 */
|
||||
const PagePlaceholder = ({ title, description }: PagePlaceholderProps) => (
|
||||
<Card>
|
||||
<Empty
|
||||
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||
description={
|
||||
<>
|
||||
<div>{title}</div>
|
||||
<div>{description}</div>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</Card>
|
||||
<Card>
|
||||
<Empty
|
||||
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||
description={
|
||||
<>
|
||||
<div>{title}</div>
|
||||
<div>{description}</div>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</Card>
|
||||
);
|
||||
|
||||
export default PagePlaceholder;
|
||||
|
||||
@@ -3,14 +3,14 @@
|
||||
*/
|
||||
|
||||
interface EnvConfig {
|
||||
/** API 基础路径:开发环境由 vite 代理转发到 NestJS API */
|
||||
apiBaseUrl: string;
|
||||
appName: string;
|
||||
debug: boolean;
|
||||
/** API 基础路径:开发环境由 vite 代理转发到 NestJS API */
|
||||
apiBaseUrl: string;
|
||||
appName: string;
|
||||
debug: boolean;
|
||||
}
|
||||
|
||||
export const envConfig: EnvConfig = {
|
||||
apiBaseUrl: import.meta.env.VITE_API_BASE_URL ?? '/api',
|
||||
appName: import.meta.env.VITE_APP_NAME ?? 'Atlas Seller Kit',
|
||||
debug: import.meta.env.VITE_DEBUG === 'true',
|
||||
apiBaseUrl: import.meta.env.VITE_API_BASE_URL ?? '/api',
|
||||
appName: import.meta.env.VITE_APP_NAME ?? 'Atlas Seller Kit',
|
||||
debug: import.meta.env.VITE_DEBUG === 'true',
|
||||
};
|
||||
|
||||
+26
-26
@@ -1,37 +1,37 @@
|
||||
:root {
|
||||
--primary-color: #8b5cf6;
|
||||
--primary-light: #a78bfa;
|
||||
--primary-dark: #7c3aed;
|
||||
/* 侧边菜单背景(纯色,中间调;想再调直接改这里) */
|
||||
--sider-bg: #6d28d9;
|
||||
--text-primary: #1f2937;
|
||||
--text-secondary: #6b7280;
|
||||
--bg-color: #f9fafb;
|
||||
--card-bg: #ffffff;
|
||||
--border-color: #e5e7eb;
|
||||
--primary-color: #8b5cf6;
|
||||
--primary-light: #a78bfa;
|
||||
--primary-dark: #7c3aed;
|
||||
/* 侧边菜单背景(纯色,中间调;想再调直接改这里) */
|
||||
--sider-bg: #6d28d9;
|
||||
--text-primary: #1f2937;
|
||||
--text-secondary: #6b7280;
|
||||
--bg-color: #f9fafb;
|
||||
--card-bg: #ffffff;
|
||||
--border-color: #e5e7eb;
|
||||
}
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family:
|
||||
-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Hiragino Sans GB',
|
||||
'Microsoft YaHei', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans',
|
||||
'Helvetica Neue', sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
background-color: var(--bg-color);
|
||||
color: var(--text-primary);
|
||||
/* 全局默认字号 14px,与 antd 主题 token 的 fontSize 保持一致 */
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
margin: 0;
|
||||
font-family:
|
||||
-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC',
|
||||
'Hiragino Sans GB', 'Microsoft YaHei', 'Roboto', 'Oxygen', 'Ubuntu',
|
||||
'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
background-color: var(--bg-color);
|
||||
color: var(--text-primary);
|
||||
/* 全局默认字号 14px,与 antd 主题 token 的 fontSize 保持一致 */
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
#root {
|
||||
min-height: 100vh;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
@@ -1,71 +1,79 @@
|
||||
.auth-page {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 24px;
|
||||
background:
|
||||
radial-gradient(1000px 500px at 15% -10%, rgba(139, 92, 246, 0.14), transparent 60%),
|
||||
radial-gradient(900px 480px at 95% 110%, rgba(109, 40, 217, 0.1), transparent 55%),
|
||||
var(--bg-color);
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 24px;
|
||||
background:
|
||||
radial-gradient(
|
||||
1000px 500px at 15% -10%,
|
||||
rgba(139, 92, 246, 0.14),
|
||||
transparent 60%
|
||||
),
|
||||
radial-gradient(
|
||||
900px 480px at 95% 110%,
|
||||
rgba(109, 40, 217, 0.1),
|
||||
transparent 55%
|
||||
),
|
||||
var(--bg-color);
|
||||
}
|
||||
|
||||
.auth-panel {
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
}
|
||||
|
||||
.auth-brand {
|
||||
text-align: center;
|
||||
margin-bottom: 24px;
|
||||
text-align: center;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.auth-logo {
|
||||
font-size: 26px;
|
||||
font-weight: 700;
|
||||
color: var(--primary-dark);
|
||||
font-size: 26px;
|
||||
font-weight: 700;
|
||||
color: var(--primary-dark);
|
||||
}
|
||||
|
||||
.auth-logo-subtitle {
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
margin-top: 2px;
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.auth-card {
|
||||
background: var(--card-bg);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 10px 30px rgba(17, 24, 39, 0.06);
|
||||
padding: 32px 28px;
|
||||
background: var(--card-bg);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 10px 30px rgba(17, 24, 39, 0.06);
|
||||
padding: 32px 28px;
|
||||
}
|
||||
|
||||
.auth-title {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.auth-subtitle {
|
||||
font-size: 14px;
|
||||
color: var(--text-secondary);
|
||||
margin: 4px 0 20px;
|
||||
font-size: 14px;
|
||||
color: var(--text-secondary);
|
||||
margin: 4px 0 20px;
|
||||
}
|
||||
|
||||
.auth-footer {
|
||||
margin-top: 16px;
|
||||
text-align: center;
|
||||
font-size: 14px;
|
||||
color: var(--text-secondary);
|
||||
margin-top: 16px;
|
||||
text-align: center;
|
||||
font-size: 14px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.auth-footer a {
|
||||
color: var(--primary-color);
|
||||
font-weight: 500;
|
||||
color: var(--primary-color);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.auth-card {
|
||||
padding: 24px 20px;
|
||||
}
|
||||
.auth-card {
|
||||
padding: 24px 20px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,37 +3,37 @@ import type { ReactNode } from 'react';
|
||||
import './AuthLayout.css';
|
||||
|
||||
interface AuthLayoutProps {
|
||||
/** 页面标题,如「登录」 */
|
||||
title: string;
|
||||
/** 标题下的一行说明 */
|
||||
subtitle: string;
|
||||
/** 卡片底部的切换入口 */
|
||||
footer: ReactNode;
|
||||
children: ReactNode;
|
||||
/** 页面标题,如「登录」 */
|
||||
title: string;
|
||||
/** 标题下的一行说明 */
|
||||
subtitle: string;
|
||||
/** 卡片底部的切换入口 */
|
||||
footer: ReactNode;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
/** 注册/登录共用的居中卡片布局,品牌视觉与 MainLayout 一致 */
|
||||
const AuthLayout = ({ title, subtitle, footer, children }: AuthLayoutProps) => {
|
||||
useEffect(() => {
|
||||
document.title = `${title} - Atlas Seller Kit`;
|
||||
}, [title]);
|
||||
useEffect(() => {
|
||||
document.title = `${title} - Atlas Seller Kit`;
|
||||
}, [title]);
|
||||
|
||||
return (
|
||||
<div className="auth-page">
|
||||
<div className="auth-panel">
|
||||
<div className="auth-brand">
|
||||
<div className="auth-logo">Atlas Kit</div>
|
||||
<div className="auth-logo-subtitle">商品工作台</div>
|
||||
return (
|
||||
<div className="auth-page">
|
||||
<div className="auth-panel">
|
||||
<div className="auth-brand">
|
||||
<div className="auth-logo">Atlas Kit</div>
|
||||
<div className="auth-logo-subtitle">商品工作台</div>
|
||||
</div>
|
||||
<div className="auth-card">
|
||||
<div className="auth-title">{title}</div>
|
||||
<div className="auth-subtitle">{subtitle}</div>
|
||||
{children}
|
||||
<div className="auth-footer">{footer}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="auth-card">
|
||||
<div className="auth-title">{title}</div>
|
||||
<div className="auth-subtitle">{subtitle}</div>
|
||||
{children}
|
||||
<div className="auth-footer">{footer}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
);
|
||||
};
|
||||
|
||||
export default AuthLayout;
|
||||
|
||||
@@ -1,115 +1,115 @@
|
||||
.main-layout {
|
||||
min-height: 100vh;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.main-sider {
|
||||
background: var(--sider-bg) !important;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--sider-bg) !important;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.sidebar-header {
|
||||
padding: 24px 20px;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||
text-align: center;
|
||||
padding: 24px 20px;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.logo {
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
color: white;
|
||||
margin-bottom: 4px;
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
color: white;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.logo-subtitle {
|
||||
font-size: 12px;
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
font-size: 12px;
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
}
|
||||
|
||||
.main-header {
|
||||
background: white !important;
|
||||
padding: 0 24px !important;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
box-shadow:
|
||||
0 1px 3px 0 rgba(0, 0, 0, 0.1),
|
||||
0 1px 2px 0 rgba(0, 0, 0, 0.06);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
height: 64px;
|
||||
line-height: 1;
|
||||
background: white !important;
|
||||
padding: 0 24px !important;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
box-shadow:
|
||||
0 1px 3px 0 rgba(0, 0, 0, 0.1),
|
||||
0 1px 2px 0 rgba(0, 0, 0, 0.06);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
height: 64px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.collapse-trigger,
|
||||
.mobile-menu-trigger {
|
||||
font-size: 18px;
|
||||
cursor: pointer;
|
||||
color: #1f2937;
|
||||
border-radius: 4px;
|
||||
transition: background 0.2s;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
flex-shrink: 0;
|
||||
font-size: 18px;
|
||||
cursor: pointer;
|
||||
color: #1f2937;
|
||||
border-radius: 4px;
|
||||
transition: background 0.2s;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.collapse-trigger:hover,
|
||||
.mobile-menu-trigger:hover {
|
||||
background: #f9fafb;
|
||||
background: #f9fafb;
|
||||
}
|
||||
|
||||
.page-title-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
line-height: 1.4;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: #1f2937;
|
||||
line-height: 1.2;
|
||||
margin-bottom: 4px;
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: #1f2937;
|
||||
line-height: 1.2;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.page-subtitle {
|
||||
font-size: 14px;
|
||||
color: #6b7280;
|
||||
font-style: italic;
|
||||
line-height: 1.2;
|
||||
font-size: 14px;
|
||||
color: #6b7280;
|
||||
font-style: italic;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.main-content {
|
||||
padding: 24px;
|
||||
background: #f3f4f6;
|
||||
min-height: calc(100vh - 64px);
|
||||
padding: 24px;
|
||||
background: #f3f4f6;
|
||||
min-height: calc(100vh - 64px);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.main-content {
|
||||
padding: 16px;
|
||||
}
|
||||
.main-content {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.main-header {
|
||||
padding: 0 16px !important;
|
||||
}
|
||||
.main-header {
|
||||
padding: 0 16px !important;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 18px;
|
||||
}
|
||||
.page-title {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.page-subtitle {
|
||||
font-size: 12px;
|
||||
}
|
||||
.page-subtitle {
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,95 +9,116 @@ import './MainLayout.css';
|
||||
const { Header, Sider, Content } = Layout;
|
||||
|
||||
const MainLayout = () => {
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
||||
const [isMobile, setIsMobile] = useState(false);
|
||||
const location = useLocation();
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
||||
const [isMobile, setIsMobile] = useState(false);
|
||||
const location = useLocation();
|
||||
|
||||
const pageInfo = useMemo(() => getPageInfo(location.pathname), [location.pathname]);
|
||||
const pageInfo = useMemo(
|
||||
() => getPageInfo(location.pathname),
|
||||
[location.pathname],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const checkMobile = () => {
|
||||
setIsMobile(window.innerWidth < 768);
|
||||
if (window.innerWidth >= 768) setMobileMenuOpen(false);
|
||||
};
|
||||
checkMobile();
|
||||
window.addEventListener('resize', checkMobile);
|
||||
return () => window.removeEventListener('resize', checkMobile);
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
const checkMobile = () => {
|
||||
setIsMobile(window.innerWidth < 768);
|
||||
if (window.innerWidth >= 768) setMobileMenuOpen(false);
|
||||
};
|
||||
checkMobile();
|
||||
window.addEventListener('resize', checkMobile);
|
||||
return () => window.removeEventListener('resize', checkMobile);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Layout className="main-layout">
|
||||
{!isMobile && (
|
||||
<Sider
|
||||
className="main-sider"
|
||||
width={200}
|
||||
collapsedWidth={60}
|
||||
collapsed={collapsed}
|
||||
theme="dark"
|
||||
style={{
|
||||
overflow: 'auto',
|
||||
height: '100vh',
|
||||
position: 'fixed',
|
||||
left: 0,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
}}
|
||||
>
|
||||
<div className="sidebar-header">
|
||||
<div className="logo">{collapsed ? 'A' : 'Atlas Kit'}</div>
|
||||
{!collapsed && <div className="logo-subtitle">商品工作台</div>}
|
||||
</div>
|
||||
<SidebarMenu collapsed={collapsed} />
|
||||
</Sider>
|
||||
)}
|
||||
|
||||
{isMobile && (
|
||||
<Drawer
|
||||
title="Atlas Kit"
|
||||
placement="left"
|
||||
onClose={() => setMobileMenuOpen(false)}
|
||||
open={mobileMenuOpen}
|
||||
styles={{ body: { padding: 0, background: 'var(--sider-bg)' } }}
|
||||
width={200}
|
||||
>
|
||||
<SidebarMenu collapsed={false} onMenuClick={() => setMobileMenuOpen(false)} />
|
||||
</Drawer>
|
||||
)}
|
||||
|
||||
<Layout
|
||||
className="main-content-layout"
|
||||
style={{
|
||||
marginLeft: isMobile ? 0 : collapsed ? 60 : 200,
|
||||
transition: 'margin-left 0.2s',
|
||||
}}
|
||||
>
|
||||
<Header className="main-header">
|
||||
<div className="header-left">
|
||||
{isMobile && (
|
||||
<MenuFoldOutlined
|
||||
className="mobile-menu-trigger"
|
||||
onClick={() => setMobileMenuOpen(true)}
|
||||
/>
|
||||
)}
|
||||
return (
|
||||
<Layout className="main-layout">
|
||||
{!isMobile && (
|
||||
<div className="collapse-trigger" onClick={() => setCollapsed(!collapsed)}>
|
||||
{collapsed ? <MenuUnfoldOutlined /> : <MenuFoldOutlined />}
|
||||
</div>
|
||||
<Sider
|
||||
className="main-sider"
|
||||
width={200}
|
||||
collapsedWidth={60}
|
||||
collapsed={collapsed}
|
||||
theme="dark"
|
||||
style={{
|
||||
overflow: 'auto',
|
||||
height: '100vh',
|
||||
position: 'fixed',
|
||||
left: 0,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
}}
|
||||
>
|
||||
<div className="sidebar-header">
|
||||
<div className="logo">
|
||||
{collapsed ? 'A' : 'Atlas Kit'}
|
||||
</div>
|
||||
{!collapsed && (
|
||||
<div className="logo-subtitle">商品工作台</div>
|
||||
)}
|
||||
</div>
|
||||
<SidebarMenu collapsed={collapsed} />
|
||||
</Sider>
|
||||
)}
|
||||
<div className="page-title-group">
|
||||
<div className="page-title">{pageInfo.title}</div>
|
||||
<div className="page-subtitle">{pageInfo.subtitle}</div>
|
||||
</div>
|
||||
</div>
|
||||
</Header>
|
||||
|
||||
<Content className="main-content">
|
||||
<Outlet />
|
||||
</Content>
|
||||
</Layout>
|
||||
</Layout>
|
||||
);
|
||||
{isMobile && (
|
||||
<Drawer
|
||||
title="Atlas Kit"
|
||||
placement="left"
|
||||
onClose={() => setMobileMenuOpen(false)}
|
||||
open={mobileMenuOpen}
|
||||
styles={{
|
||||
body: { padding: 0, background: 'var(--sider-bg)' },
|
||||
}}
|
||||
width={200}
|
||||
>
|
||||
<SidebarMenu
|
||||
collapsed={false}
|
||||
onMenuClick={() => setMobileMenuOpen(false)}
|
||||
/>
|
||||
</Drawer>
|
||||
)}
|
||||
|
||||
<Layout
|
||||
className="main-content-layout"
|
||||
style={{
|
||||
marginLeft: isMobile ? 0 : collapsed ? 60 : 200,
|
||||
transition: 'margin-left 0.2s',
|
||||
}}
|
||||
>
|
||||
<Header className="main-header">
|
||||
<div className="header-left">
|
||||
{isMobile && (
|
||||
<MenuFoldOutlined
|
||||
className="mobile-menu-trigger"
|
||||
onClick={() => setMobileMenuOpen(true)}
|
||||
/>
|
||||
)}
|
||||
{!isMobile && (
|
||||
<div
|
||||
className="collapse-trigger"
|
||||
onClick={() => setCollapsed(!collapsed)}
|
||||
>
|
||||
{collapsed ? (
|
||||
<MenuUnfoldOutlined />
|
||||
) : (
|
||||
<MenuFoldOutlined />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="page-title-group">
|
||||
<div className="page-title">{pageInfo.title}</div>
|
||||
<div className="page-subtitle">
|
||||
{pageInfo.subtitle}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Header>
|
||||
|
||||
<Content className="main-content">
|
||||
<Outlet />
|
||||
</Content>
|
||||
</Layout>
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
|
||||
export default MainLayout;
|
||||
|
||||
@@ -1,134 +1,140 @@
|
||||
.sidebar-menu-container {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.sidebar-menu {
|
||||
background: transparent !important;
|
||||
border: none;
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
background: transparent !important;
|
||||
border: none;
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.sidebar-menu .ant-menu-item {
|
||||
margin: 0 !important;
|
||||
padding: 12px 20px !important;
|
||||
height: auto !important;
|
||||
line-height: 1.5 !important;
|
||||
transition: all 0.3s !important;
|
||||
font-size: 15px !important;
|
||||
font-weight: 600 !important;
|
||||
margin: 0 !important;
|
||||
padding: 12px 20px !important;
|
||||
height: auto !important;
|
||||
line-height: 1.5 !important;
|
||||
transition: all 0.3s !important;
|
||||
font-size: 15px !important;
|
||||
font-weight: 600 !important;
|
||||
}
|
||||
|
||||
.sidebar-menu .ant-menu-item .ant-menu-title-content {
|
||||
font-size: 15px !important;
|
||||
font-weight: 600 !important;
|
||||
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
display: inline-block;
|
||||
font-size: 15px !important;
|
||||
font-weight: 600 !important;
|
||||
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.sidebar-menu .ant-menu-item:hover {
|
||||
background: rgba(255, 255, 255, 0.1) !important;
|
||||
background: rgba(255, 255, 255, 0.1) !important;
|
||||
}
|
||||
|
||||
.sidebar-menu .ant-menu-item-selected {
|
||||
background: rgba(255, 255, 255, 0.2) !important;
|
||||
border-left: 3px solid white;
|
||||
background: rgba(255, 255, 255, 0.2) !important;
|
||||
border-left: 3px solid white;
|
||||
}
|
||||
|
||||
.sidebar-menu .ant-menu-item-selected .ant-menu-title-content {
|
||||
transform: translateX(8px);
|
||||
transform: translateX(8px);
|
||||
}
|
||||
|
||||
.sidebar-menu .ant-menu-item-selected:hover {
|
||||
background: rgba(255, 255, 255, 0.25) !important;
|
||||
background: rgba(255, 255, 255, 0.25) !important;
|
||||
}
|
||||
|
||||
.sidebar-menu .ant-menu-item-selected:hover .ant-menu-title-content {
|
||||
transform: translateX(8px);
|
||||
transform: translateX(8px);
|
||||
}
|
||||
|
||||
.sidebar-menu .ant-menu-item-group-title {
|
||||
padding: 12px 20px 8px !important;
|
||||
font-size: 12px !important;
|
||||
color: rgba(255, 255, 255, 0.7) !important;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
font-weight: 500;
|
||||
padding: 12px 20px 8px !important;
|
||||
font-size: 12px !important;
|
||||
color: rgba(255, 255, 255, 0.7) !important;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.sidebar-menu .ant-menu-item-icon {
|
||||
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
font-size: 18px !important;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
font-size: 18px !important;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.sidebar-menu .ant-menu-item:hover .ant-menu-item-icon {
|
||||
transform: scale(1.05);
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.sidebar-menu .ant-menu-item-selected .ant-menu-item-icon {
|
||||
transform: translateX(8px);
|
||||
transform: translateX(8px);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.sidebar-menu .ant-menu-item {
|
||||
padding: 14px 20px !important;
|
||||
}
|
||||
.sidebar-menu .ant-menu-item {
|
||||
padding: 14px 20px !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── 折叠态(60px):图标居中,覆盖展开态的左对齐 padding/位移 ── */
|
||||
.sidebar-menu.ant-menu-inline-collapsed .ant-menu-item,
|
||||
.sidebar-menu.ant-menu-inline-collapsed .ant-menu-item-group-title {
|
||||
padding: 12px 0 !important;
|
||||
text-align: center;
|
||||
padding: 12px 0 !important;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.sidebar-menu.ant-menu-inline-collapsed .ant-menu-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.sidebar-menu.ant-menu-inline-collapsed .ant-menu-item .ant-menu-title-content,
|
||||
.sidebar-menu.ant-menu-inline-collapsed .ant-menu-item-selected .ant-menu-title-content {
|
||||
transform: none !important;
|
||||
.sidebar-menu.ant-menu-inline-collapsed
|
||||
.ant-menu-item-selected
|
||||
.ant-menu-title-content {
|
||||
transform: none !important;
|
||||
}
|
||||
|
||||
.sidebar-menu.ant-menu-inline-collapsed .ant-menu-item .ant-menu-item-icon,
|
||||
.sidebar-menu.ant-menu-inline-collapsed .ant-menu-item:hover .ant-menu-item-icon,
|
||||
.sidebar-menu.ant-menu-inline-collapsed .ant-menu-item-selected .ant-menu-item-icon {
|
||||
transform: none !important;
|
||||
margin: 0;
|
||||
.sidebar-menu.ant-menu-inline-collapsed
|
||||
.ant-menu-item:hover
|
||||
.ant-menu-item-icon,
|
||||
.sidebar-menu.ant-menu-inline-collapsed
|
||||
.ant-menu-item-selected
|
||||
.ant-menu-item-icon {
|
||||
transform: none !important;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* 折叠时去掉左侧 3px 选中边框(会挤偏图标),选中态用高亮背景区分 */
|
||||
.sidebar-menu.ant-menu-inline-collapsed .ant-menu-item-selected {
|
||||
border-left: none !important;
|
||||
border-left: none !important;
|
||||
}
|
||||
|
||||
/* 折叠时隐藏 label 占位(antd 折叠动画保留 opacity 占位,会把图标挤离中心) */
|
||||
.sidebar-menu.ant-menu-inline-collapsed .ant-menu-item .ant-menu-title-content {
|
||||
display: none !important;
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* 兼容:部分版本折叠态用 vertical 类渲染,同样居中 */
|
||||
.sidebar-menu.ant-menu-vertical .ant-menu-item,
|
||||
.sidebar-menu.ant-menu-inline-collapsed .ant-menu-item {
|
||||
padding-left: 0 !important;
|
||||
padding-right: 0 !important;
|
||||
padding-left: 0 !important;
|
||||
padding-right: 0 !important;
|
||||
}
|
||||
|
||||
.sidebar-menu.ant-menu-vertical .ant-menu-item .ant-menu-title-content {
|
||||
display: none !important;
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* 折叠态 flex 子元素居中(display:flex 已生效,缺 justify-content) */
|
||||
.sidebar-menu.ant-menu-inline-collapsed .ant-menu-item {
|
||||
justify-content: center !important;
|
||||
justify-content: center !important;
|
||||
}
|
||||
|
||||
@@ -6,57 +6,59 @@ import { routeMenuConfig } from './menuConfig';
|
||||
import './SidebarMenu.css';
|
||||
|
||||
interface SidebarMenuProps {
|
||||
collapsed: boolean;
|
||||
onMenuClick?: () => void;
|
||||
collapsed: boolean;
|
||||
onMenuClick?: () => void;
|
||||
}
|
||||
|
||||
const SidebarMenu = ({ collapsed, onMenuClick }: SidebarMenuProps) => {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const menuItems: MenuProps['items'] = useMemo(() => {
|
||||
const children = routeMenuConfig.map((config) => ({
|
||||
key: config.key,
|
||||
icon: config.icon,
|
||||
label: config.label,
|
||||
}));
|
||||
return [
|
||||
{
|
||||
type: 'group' as const,
|
||||
label: collapsed ? '' : '主要功能',
|
||||
className: 'menu-section-title',
|
||||
children,
|
||||
},
|
||||
];
|
||||
}, [collapsed]);
|
||||
const menuItems: MenuProps['items'] = useMemo(() => {
|
||||
const children = routeMenuConfig.map((config) => ({
|
||||
key: config.key,
|
||||
icon: config.icon,
|
||||
label: config.label,
|
||||
}));
|
||||
return [
|
||||
{
|
||||
type: 'group' as const,
|
||||
label: collapsed ? '' : '主要功能',
|
||||
className: 'menu-section-title',
|
||||
children,
|
||||
},
|
||||
];
|
||||
}, [collapsed]);
|
||||
|
||||
const selectedKeys = useMemo(() => {
|
||||
const path = location.pathname;
|
||||
if (path === '/' || path === '') {
|
||||
return [routeMenuConfig[0].key];
|
||||
}
|
||||
const config = routeMenuConfig.find((item) => path.startsWith(item.path));
|
||||
return [config ? config.key : path];
|
||||
}, [location.pathname]);
|
||||
const selectedKeys = useMemo(() => {
|
||||
const path = location.pathname;
|
||||
if (path === '/' || path === '') {
|
||||
return [routeMenuConfig[0].key];
|
||||
}
|
||||
const config = routeMenuConfig.find((item) =>
|
||||
path.startsWith(item.path),
|
||||
);
|
||||
return [config ? config.key : path];
|
||||
}, [location.pathname]);
|
||||
|
||||
const handleMenuClick: MenuProps['onClick'] = ({ key }) => {
|
||||
navigate(key);
|
||||
if (onMenuClick) onMenuClick();
|
||||
};
|
||||
const handleMenuClick: MenuProps['onClick'] = ({ key }) => {
|
||||
navigate(key);
|
||||
if (onMenuClick) onMenuClick();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="sidebar-menu-container">
|
||||
<Menu
|
||||
theme="dark"
|
||||
mode="inline"
|
||||
inlineCollapsed={collapsed}
|
||||
selectedKeys={selectedKeys}
|
||||
items={menuItems}
|
||||
onClick={handleMenuClick}
|
||||
className="sidebar-menu"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div className="sidebar-menu-container">
|
||||
<Menu
|
||||
theme="dark"
|
||||
mode="inline"
|
||||
inlineCollapsed={collapsed}
|
||||
selectedKeys={selectedKeys}
|
||||
items={menuItems}
|
||||
onClick={handleMenuClick}
|
||||
className="sidebar-menu"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SidebarMenu;
|
||||
|
||||
@@ -1,80 +1,87 @@
|
||||
import {
|
||||
CalculatorOutlined,
|
||||
GlobalOutlined,
|
||||
InboxOutlined,
|
||||
PictureOutlined,
|
||||
ShopOutlined,
|
||||
CalculatorOutlined,
|
||||
GlobalOutlined,
|
||||
InboxOutlined,
|
||||
PictureOutlined,
|
||||
ShopOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
export interface RouteMenuConfig {
|
||||
/** 路由路径 */
|
||||
path: string;
|
||||
/** 菜单键值(与 path 相同) */
|
||||
key: string;
|
||||
/** 菜单图标 */
|
||||
icon: ReactNode;
|
||||
/** 菜单标签 */
|
||||
label: string;
|
||||
/** 页面标题 */
|
||||
title: string;
|
||||
/** 页面副标题 */
|
||||
subtitle: string;
|
||||
/** 路由路径 */
|
||||
path: string;
|
||||
/** 菜单键值(与 path 相同) */
|
||||
key: string;
|
||||
/** 菜单图标 */
|
||||
icon: ReactNode;
|
||||
/** 菜单标签 */
|
||||
label: string;
|
||||
/** 页面标题 */
|
||||
title: string;
|
||||
/** 页面副标题 */
|
||||
subtitle: string;
|
||||
}
|
||||
|
||||
/** 左侧一级菜单,顺序与 docs/requirements/account-store-watermark.md 的导航约定一致 */
|
||||
export const routeMenuConfig: RouteMenuConfig[] = [
|
||||
{
|
||||
path: '/online-products',
|
||||
key: '/online-products',
|
||||
icon: <GlobalOutlined />,
|
||||
label: '在线商品',
|
||||
title: '在线商品',
|
||||
subtitle: '按 SKU 查看平台已上架商品与同步状态',
|
||||
},
|
||||
{
|
||||
path: '/collection',
|
||||
key: '/collection',
|
||||
icon: <InboxOutlined />,
|
||||
label: '采集箱',
|
||||
title: '采集箱',
|
||||
subtitle: '查看已采集的商品,进入编辑上架',
|
||||
},
|
||||
{
|
||||
path: '/shops',
|
||||
key: '/shops',
|
||||
icon: <ShopOutlined />,
|
||||
label: '店铺管理',
|
||||
title: '店铺管理',
|
||||
subtitle: '绑定平台店铺授权与水印',
|
||||
},
|
||||
{
|
||||
path: '/watermarks',
|
||||
key: '/watermarks',
|
||||
icon: <PictureOutlined />,
|
||||
label: '水印管理',
|
||||
title: '水印管理',
|
||||
subtitle: '维护水印模板并绑定到店铺',
|
||||
},
|
||||
{
|
||||
path: '/pricing',
|
||||
key: '/pricing',
|
||||
icon: <CalculatorOutlined />,
|
||||
label: '定价工具',
|
||||
title: '定价工具',
|
||||
subtitle: '独立试算与物流计价规则',
|
||||
},
|
||||
{
|
||||
path: '/online-products',
|
||||
key: '/online-products',
|
||||
icon: <GlobalOutlined />,
|
||||
label: '在线商品',
|
||||
title: '在线商品',
|
||||
subtitle: '按 SKU 查看平台已上架商品与同步状态',
|
||||
},
|
||||
{
|
||||
path: '/collection',
|
||||
key: '/collection',
|
||||
icon: <InboxOutlined />,
|
||||
label: '采集箱',
|
||||
title: '采集箱',
|
||||
subtitle: '查看已采集的商品,进入编辑上架',
|
||||
},
|
||||
{
|
||||
path: '/shops',
|
||||
key: '/shops',
|
||||
icon: <ShopOutlined />,
|
||||
label: '店铺管理',
|
||||
title: '店铺管理',
|
||||
subtitle: '绑定平台店铺授权与水印',
|
||||
},
|
||||
{
|
||||
path: '/watermarks',
|
||||
key: '/watermarks',
|
||||
icon: <PictureOutlined />,
|
||||
label: '水印管理',
|
||||
title: '水印管理',
|
||||
subtitle: '维护水印模板并绑定到店铺',
|
||||
},
|
||||
{
|
||||
path: '/pricing',
|
||||
key: '/pricing',
|
||||
icon: <CalculatorOutlined />,
|
||||
label: '定价工具',
|
||||
title: '定价工具',
|
||||
subtitle: '独立试算与物流计价规则',
|
||||
},
|
||||
];
|
||||
|
||||
/** 根据路径获取页面标题信息 */
|
||||
export const getPageInfo = (path: string): { title: string; subtitle: string } => {
|
||||
if (path === '/' || path === '') {
|
||||
const first = routeMenuConfig[0];
|
||||
return { title: first.title, subtitle: first.subtitle };
|
||||
}
|
||||
if (path.startsWith('/products/')) {
|
||||
return { title: '商品编辑', subtitle: '商品 AI 编辑页:SPU/SKU、媒体、定价与发布' };
|
||||
}
|
||||
const config = routeMenuConfig.find((item) => path.startsWith(item.path));
|
||||
return config ? { title: config.title, subtitle: config.subtitle } : getPageInfo('/');
|
||||
export const getPageInfo = (
|
||||
path: string,
|
||||
): { title: string; subtitle: string } => {
|
||||
if (path === '/' || path === '') {
|
||||
const first = routeMenuConfig[0];
|
||||
return { title: first.title, subtitle: first.subtitle };
|
||||
}
|
||||
if (path.startsWith('/products/')) {
|
||||
return {
|
||||
title: '商品编辑',
|
||||
subtitle: '商品 AI 编辑页:SPU/SKU、媒体、定价与发布',
|
||||
};
|
||||
}
|
||||
const config = routeMenuConfig.find((item) => path.startsWith(item.path));
|
||||
return config
|
||||
? { title: config.title, subtitle: config.subtitle }
|
||||
: getPageInfo('/');
|
||||
};
|
||||
|
||||
@@ -4,7 +4,7 @@ import './index.css';
|
||||
import App from './App';
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
);
|
||||
|
||||
@@ -10,76 +10,100 @@ import { authToken } from '@/services/authToken';
|
||||
import { emailFormatRule } from '@/utils/validators';
|
||||
|
||||
interface LoginFormValues {
|
||||
email: string;
|
||||
password: string;
|
||||
email: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
/** 登录页,契约见 docs/api/auth.md §3 */
|
||||
const LoginPage = () => {
|
||||
const navigate = useNavigate();
|
||||
const { message } = AntdApp.useApp();
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const navigate = useNavigate();
|
||||
const { message } = AntdApp.useApp();
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
const handleFinish = async (values: LoginFormValues) => {
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const session = await authApi.login({
|
||||
email: values.email.trim(),
|
||||
password: values.password,
|
||||
});
|
||||
authToken.set(session.access_token);
|
||||
message.success('登录成功');
|
||||
navigate('/online-products', { replace: true });
|
||||
} catch (error) {
|
||||
// 契约约定登录失败统一返回 401 INVALID_CREDENTIALS,不区分邮箱不存在或密码错误
|
||||
if (axios.isAxiosError(error) && error.response?.status === 401) {
|
||||
message.error('邮箱或密码不正确');
|
||||
} else {
|
||||
message.error(apiErrorMessage(error));
|
||||
}
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
const handleFinish = async (values: LoginFormValues) => {
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const session = await authApi.login({
|
||||
email: values.email.trim(),
|
||||
password: values.password,
|
||||
});
|
||||
authToken.set(session.access_token);
|
||||
message.success('登录成功');
|
||||
navigate('/online-products', { replace: true });
|
||||
} catch (error) {
|
||||
// 契约约定登录失败统一返回 401 INVALID_CREDENTIALS,不区分邮箱不存在或密码错误
|
||||
if (axios.isAxiosError(error) && error.response?.status === 401) {
|
||||
message.error('邮箱或密码不正确');
|
||||
} else {
|
||||
message.error(apiErrorMessage(error));
|
||||
}
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthLayout
|
||||
title="登录"
|
||||
subtitle="登录 Atlas Seller Kit,继续管理你的商品"
|
||||
footer={
|
||||
<>
|
||||
还没有账户?<Link to="/register">立即注册</Link>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Form<LoginFormValues> layout="vertical" requiredMark={false} onFinish={handleFinish}>
|
||||
<Form.Item
|
||||
name="email"
|
||||
label="邮箱"
|
||||
rules={[{ required: true, message: '请输入邮箱' }, emailFormatRule]}
|
||||
return (
|
||||
<AuthLayout
|
||||
title="登录"
|
||||
subtitle="登录 Atlas Seller Kit,继续管理你的商品"
|
||||
footer={
|
||||
<>
|
||||
还没有账户?<Link to="/register">立即注册</Link>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Input
|
||||
prefix={<MailOutlined style={{ color: 'var(--text-secondary)' }} />}
|
||||
placeholder="请输入邮箱"
|
||||
autoComplete="email"
|
||||
autoFocus
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="password" label="密码" rules={[{ required: true, message: '请输入密码' }]}>
|
||||
<Input.Password
|
||||
prefix={<LockOutlined style={{ color: 'var(--text-secondary)' }} />}
|
||||
placeholder="请输入密码"
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item style={{ marginBottom: 0, marginTop: 8 }}>
|
||||
<Button type="primary" htmlType="submit" block loading={submitting}>
|
||||
登录
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</AuthLayout>
|
||||
);
|
||||
<Form<LoginFormValues>
|
||||
layout="vertical"
|
||||
requiredMark={false}
|
||||
onFinish={handleFinish}
|
||||
>
|
||||
<Form.Item
|
||||
name="email"
|
||||
label="邮箱"
|
||||
rules={[
|
||||
{ required: true, message: '请输入邮箱' },
|
||||
emailFormatRule,
|
||||
]}
|
||||
>
|
||||
<Input
|
||||
prefix={
|
||||
<MailOutlined
|
||||
style={{ color: 'var(--text-secondary)' }}
|
||||
/>
|
||||
}
|
||||
placeholder="请输入邮箱"
|
||||
autoComplete="email"
|
||||
autoFocus
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="password"
|
||||
label="密码"
|
||||
rules={[{ required: true, message: '请输入密码' }]}
|
||||
>
|
||||
<Input.Password
|
||||
prefix={
|
||||
<LockOutlined
|
||||
style={{ color: 'var(--text-secondary)' }}
|
||||
/>
|
||||
}
|
||||
placeholder="请输入密码"
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item style={{ marginBottom: 0, marginTop: 8 }}>
|
||||
<Button
|
||||
type="primary"
|
||||
htmlType="submit"
|
||||
block
|
||||
loading={submitting}
|
||||
>
|
||||
登录
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</AuthLayout>
|
||||
);
|
||||
};
|
||||
|
||||
export default LoginPage;
|
||||
|
||||
@@ -10,126 +10,157 @@ import { authToken } from '@/services/authToken';
|
||||
import { emailFormatRule } from '@/utils/validators';
|
||||
|
||||
interface RegisterFormValues {
|
||||
display_name?: string;
|
||||
email: string;
|
||||
password: string;
|
||||
password_confirmation: string;
|
||||
display_name?: string;
|
||||
email: string;
|
||||
password: string;
|
||||
password_confirmation: string;
|
||||
}
|
||||
|
||||
/** 注册页,契约见 docs/api/auth.md §2;密码长度与复杂度由服务端唯一校验,前端不自行放宽或收窄 */
|
||||
const RegisterPage = () => {
|
||||
const navigate = useNavigate();
|
||||
const { message } = AntdApp.useApp();
|
||||
const [form] = Form.useForm<RegisterFormValues>();
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const navigate = useNavigate();
|
||||
const { message } = AntdApp.useApp();
|
||||
const [form] = Form.useForm<RegisterFormValues>();
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
const handleFinish = async (values: RegisterFormValues) => {
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const session = await authApi.register({
|
||||
email: values.email.trim(),
|
||||
password: values.password,
|
||||
password_confirmation: values.password_confirmation,
|
||||
display_name: values.display_name?.trim() || undefined,
|
||||
});
|
||||
// 注册成功即在同一事务创建用户、个人工作空间和登录会话
|
||||
authToken.set(session.access_token);
|
||||
message.success('注册成功,已进入你的工作空间');
|
||||
navigate('/online-products', { replace: true });
|
||||
} catch (error) {
|
||||
// 重复邮箱返回 409 EMAIL_ALREADY_REGISTERED,落到邮箱字段上提示
|
||||
if (axios.isAxiosError(error) && error.response?.status === 409) {
|
||||
form.setFields([{ name: 'email', errors: ['该邮箱已注册,请直接登录'] }]);
|
||||
} else {
|
||||
message.error(apiErrorMessage(error));
|
||||
}
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
const handleFinish = async (values: RegisterFormValues) => {
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const session = await authApi.register({
|
||||
email: values.email.trim(),
|
||||
password: values.password,
|
||||
password_confirmation: values.password_confirmation,
|
||||
display_name: values.display_name?.trim() || undefined,
|
||||
});
|
||||
// 注册成功即在同一事务创建用户、个人工作空间和登录会话
|
||||
authToken.set(session.access_token);
|
||||
message.success('注册成功,已进入你的工作空间');
|
||||
navigate('/online-products', { replace: true });
|
||||
} catch (error) {
|
||||
// 重复邮箱返回 409 EMAIL_ALREADY_REGISTERED,落到邮箱字段上提示
|
||||
if (axios.isAxiosError(error) && error.response?.status === 409) {
|
||||
form.setFields([
|
||||
{ name: 'email', errors: ['该邮箱已注册,请直接登录'] },
|
||||
]);
|
||||
} else {
|
||||
message.error(apiErrorMessage(error));
|
||||
}
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthLayout
|
||||
title="注册"
|
||||
subtitle="创建账户后自动创建个人工作空间,注册即登录"
|
||||
footer={
|
||||
<>
|
||||
已有账户?<Link to="/login">直接登录</Link>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Form<RegisterFormValues>
|
||||
form={form}
|
||||
layout="vertical"
|
||||
requiredMark={false}
|
||||
onFinish={handleFinish}
|
||||
>
|
||||
<Form.Item
|
||||
name="display_name"
|
||||
label="显示名称"
|
||||
extra="选填,最多 80 个字符"
|
||||
rules={[{ max: 80, message: '显示名称最多 80 个字符' }]}
|
||||
return (
|
||||
<AuthLayout
|
||||
title="注册"
|
||||
subtitle="创建账户后自动创建个人工作空间,注册即登录"
|
||||
footer={
|
||||
<>
|
||||
已有账户?<Link to="/login">直接登录</Link>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Input
|
||||
prefix={<UserOutlined style={{ color: 'var(--text-secondary)' }} />}
|
||||
placeholder="选填"
|
||||
maxLength={80}
|
||||
autoComplete="nickname"
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="email"
|
||||
label="邮箱"
|
||||
rules={[{ required: true, message: '请输入邮箱' }, emailFormatRule]}
|
||||
>
|
||||
<Input
|
||||
prefix={<MailOutlined style={{ color: 'var(--text-secondary)' }} />}
|
||||
placeholder="请输入邮箱"
|
||||
autoComplete="email"
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="password"
|
||||
label="密码"
|
||||
extra="长度与复杂度规则以服务端安全校验为准"
|
||||
rules={[{ required: true, message: '请输入密码' }]}
|
||||
>
|
||||
<Input.Password
|
||||
prefix={<LockOutlined style={{ color: 'var(--text-secondary)' }} />}
|
||||
placeholder="请输入密码"
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="password_confirmation"
|
||||
label="确认密码"
|
||||
dependencies={['password']}
|
||||
rules={[
|
||||
{ required: true, message: '请再次输入密码' },
|
||||
({ getFieldValue }) => ({
|
||||
validator(_, value) {
|
||||
if (!value || getFieldValue('password') === value) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
return Promise.reject(new Error('两次输入的密码不一致'));
|
||||
},
|
||||
}),
|
||||
]}
|
||||
>
|
||||
<Input.Password
|
||||
prefix={<LockOutlined style={{ color: 'var(--text-secondary)' }} />}
|
||||
placeholder="请再次输入密码"
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item style={{ marginBottom: 0, marginTop: 8 }}>
|
||||
<Button type="primary" htmlType="submit" block loading={submitting}>
|
||||
创建账户
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</AuthLayout>
|
||||
);
|
||||
<Form<RegisterFormValues>
|
||||
form={form}
|
||||
layout="vertical"
|
||||
requiredMark={false}
|
||||
onFinish={handleFinish}
|
||||
>
|
||||
<Form.Item
|
||||
name="display_name"
|
||||
label="显示名称"
|
||||
extra="选填,最多 80 个字符"
|
||||
rules={[{ max: 80, message: '显示名称最多 80 个字符' }]}
|
||||
>
|
||||
<Input
|
||||
prefix={
|
||||
<UserOutlined
|
||||
style={{ color: 'var(--text-secondary)' }}
|
||||
/>
|
||||
}
|
||||
placeholder="选填"
|
||||
maxLength={80}
|
||||
autoComplete="nickname"
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="email"
|
||||
label="邮箱"
|
||||
rules={[
|
||||
{ required: true, message: '请输入邮箱' },
|
||||
emailFormatRule,
|
||||
]}
|
||||
>
|
||||
<Input
|
||||
prefix={
|
||||
<MailOutlined
|
||||
style={{ color: 'var(--text-secondary)' }}
|
||||
/>
|
||||
}
|
||||
placeholder="请输入邮箱"
|
||||
autoComplete="email"
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="password"
|
||||
label="密码"
|
||||
extra="长度与复杂度规则以服务端安全校验为准"
|
||||
rules={[{ required: true, message: '请输入密码' }]}
|
||||
>
|
||||
<Input.Password
|
||||
prefix={
|
||||
<LockOutlined
|
||||
style={{ color: 'var(--text-secondary)' }}
|
||||
/>
|
||||
}
|
||||
placeholder="请输入密码"
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="password_confirmation"
|
||||
label="确认密码"
|
||||
dependencies={['password']}
|
||||
rules={[
|
||||
{ required: true, message: '请再次输入密码' },
|
||||
({ getFieldValue }) => ({
|
||||
validator(_, value) {
|
||||
if (
|
||||
!value ||
|
||||
getFieldValue('password') === value
|
||||
) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
return Promise.reject(
|
||||
new Error('两次输入的密码不一致'),
|
||||
);
|
||||
},
|
||||
}),
|
||||
]}
|
||||
>
|
||||
<Input.Password
|
||||
prefix={
|
||||
<LockOutlined
|
||||
style={{ color: 'var(--text-secondary)' }}
|
||||
/>
|
||||
}
|
||||
placeholder="请再次输入密码"
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item style={{ marginBottom: 0, marginTop: 8 }}>
|
||||
<Button
|
||||
type="primary"
|
||||
htmlType="submit"
|
||||
block
|
||||
loading={submitting}
|
||||
>
|
||||
创建账户
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</AuthLayout>
|
||||
);
|
||||
};
|
||||
|
||||
export default RegisterPage;
|
||||
|
||||
@@ -2,7 +2,7 @@ import PagePlaceholder from '@/components/PagePlaceholder';
|
||||
|
||||
/** 采集箱:查看已采集的商品,进入编辑上架(业务模块尚未实现) */
|
||||
const CollectionPage = () => (
|
||||
<PagePlaceholder title="采集箱" description="查看已采集的商品,进入编辑上架,模块开发中。" />
|
||||
<PagePlaceholder title="采集箱" description="查看已采集的商品,进入编辑上架,模块开发中。" />
|
||||
);
|
||||
|
||||
export default CollectionPage;
|
||||
|
||||
@@ -2,10 +2,10 @@ import PagePlaceholder from '@/components/PagePlaceholder';
|
||||
|
||||
/** 在线商品:按 SKU 查看平台已上架商品与同步状态(业务模块尚未实现) */
|
||||
const OnlineProductsPage = () => (
|
||||
<PagePlaceholder
|
||||
title="在线商品"
|
||||
description="按 SKU 查看平台已上架商品与同步状态,模块开发中。"
|
||||
/>
|
||||
<PagePlaceholder
|
||||
title="在线商品"
|
||||
description="按 SKU 查看平台已上架商品与同步状态,模块开发中。"
|
||||
/>
|
||||
);
|
||||
|
||||
export default OnlineProductsPage;
|
||||
|
||||
@@ -2,7 +2,10 @@ import PagePlaceholder from '@/components/PagePlaceholder';
|
||||
|
||||
/** 定价工具:独立试算与物流计价规则(业务模块尚未实现) */
|
||||
const PricingPage = () => (
|
||||
<PagePlaceholder title="定价工具" description="独立试算与物流计价规则,模块开发中。" />
|
||||
<PagePlaceholder
|
||||
title="定价工具"
|
||||
description="独立试算与物流计价规则,模块开发中。"
|
||||
/>
|
||||
);
|
||||
|
||||
export default PricingPage;
|
||||
|
||||
@@ -3,14 +3,14 @@ import PagePlaceholder from '@/components/PagePlaceholder';
|
||||
|
||||
/** 商品 AI 编辑页:编辑 SPU/SKU、媒体、定价与首次发布(业务模块尚未实现) */
|
||||
const ProductEditPage = () => {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const { id } = useParams<{ id: string }>();
|
||||
|
||||
return (
|
||||
<PagePlaceholder
|
||||
title={`商品编辑(${id ?? '未知 ID'})`}
|
||||
description="商品 AI 编辑页:SPU/SKU、媒体、定价与发布,模块开发中。"
|
||||
/>
|
||||
);
|
||||
return (
|
||||
<PagePlaceholder
|
||||
title={`商品编辑(${id ?? '未知 ID'})`}
|
||||
description="商品 AI 编辑页:SPU/SKU、媒体、定价与发布,模块开发中。"
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProductEditPage;
|
||||
|
||||
@@ -2,7 +2,10 @@ import PagePlaceholder from '@/components/PagePlaceholder';
|
||||
|
||||
/** 店铺管理:绑定平台店铺授权与水印(业务模块尚未实现) */
|
||||
const ShopsPage = () => (
|
||||
<PagePlaceholder title="店铺管理" description="绑定平台店铺授权与水印,模块开发中。" />
|
||||
<PagePlaceholder
|
||||
title="店铺管理"
|
||||
description="绑定平台店铺授权与水印,模块开发中。"
|
||||
/>
|
||||
);
|
||||
|
||||
export default ShopsPage;
|
||||
|
||||
@@ -2,7 +2,10 @@ import PagePlaceholder from '@/components/PagePlaceholder';
|
||||
|
||||
/** 水印管理:维护水印模板并绑定到店铺(业务模块尚未实现) */
|
||||
const WatermarksPage = () => (
|
||||
<PagePlaceholder title="水印管理" description="维护水印模板并绑定到店铺,模块开发中。" />
|
||||
<PagePlaceholder
|
||||
title="水印管理"
|
||||
description="维护水印模板并绑定到店铺,模块开发中。"
|
||||
/>
|
||||
);
|
||||
|
||||
export default WatermarksPage;
|
||||
|
||||
@@ -10,22 +10,25 @@ import ShopsPage from '@/pages/shops/ShopsPage';
|
||||
import WatermarksPage from '@/pages/watermarks/WatermarksPage';
|
||||
|
||||
export const router = createBrowserRouter([
|
||||
{
|
||||
path: '/',
|
||||
element: <MainLayout />,
|
||||
children: [
|
||||
{ index: true, element: <Navigate to="/online-products" replace /> },
|
||||
{ path: 'online-products', element: <OnlineProductsPage /> },
|
||||
{ path: 'collection', element: <CollectionPage /> },
|
||||
{ path: 'shops', element: <ShopsPage /> },
|
||||
{ path: 'watermarks', element: <WatermarksPage /> },
|
||||
{ path: 'pricing', element: <PricingPage /> },
|
||||
// 商品 AI 编辑页:从采集箱/在线商品进入,不在左侧菜单中出现
|
||||
{ path: 'products/:id', element: <ProductEditPage /> },
|
||||
],
|
||||
},
|
||||
// 认证页在主布局之外;API 侧会话体系实现后再加路由守卫
|
||||
{ path: 'login', element: <LoginPage /> },
|
||||
{ path: 'register', element: <RegisterPage /> },
|
||||
{ path: '*', element: <Navigate to="/" replace /> },
|
||||
{
|
||||
path: '/',
|
||||
element: <MainLayout />,
|
||||
children: [
|
||||
{
|
||||
index: true,
|
||||
element: <Navigate to="/online-products" replace />,
|
||||
},
|
||||
{ path: 'online-products', element: <OnlineProductsPage /> },
|
||||
{ path: 'collection', element: <CollectionPage /> },
|
||||
{ path: 'shops', element: <ShopsPage /> },
|
||||
{ path: 'watermarks', element: <WatermarksPage /> },
|
||||
{ path: 'pricing', element: <PricingPage /> },
|
||||
// 商品 AI 编辑页:从采集箱/在线商品进入,不在左侧菜单中出现
|
||||
{ path: 'products/:id', element: <ProductEditPage /> },
|
||||
],
|
||||
},
|
||||
// 认证页在主布局之外;API 侧会话体系实现后再加路由守卫
|
||||
{ path: 'login', element: <LoginPage /> },
|
||||
{ path: 'register', element: <RegisterPage /> },
|
||||
{ path: '*', element: <Navigate to="/" replace /> },
|
||||
]);
|
||||
|
||||
@@ -1,102 +1,122 @@
|
||||
import axios from 'axios';
|
||||
import type { AxiosInstance, AxiosRequestConfig, InternalAxiosRequestConfig } from 'axios';
|
||||
import type {
|
||||
AxiosInstance,
|
||||
AxiosRequestConfig,
|
||||
InternalAxiosRequestConfig,
|
||||
} from 'axios';
|
||||
import { envConfig } from '@/config/env';
|
||||
import { authToken } from '@/services/authToken';
|
||||
|
||||
declare module 'axios' {
|
||||
export interface AxiosRequestConfig {
|
||||
/** 401 刷新重放标记,防止无限重试 */
|
||||
_retried?: boolean;
|
||||
}
|
||||
export interface AxiosRequestConfig {
|
||||
/** 401 刷新重放标记,防止无限重试 */
|
||||
_retried?: boolean;
|
||||
}
|
||||
}
|
||||
|
||||
const apiClient: AxiosInstance = axios.create({
|
||||
baseURL: envConfig.apiBaseUrl,
|
||||
timeout: 30000,
|
||||
// 不设全局 Content-Type:axios 1.x 遇到「默认 JSON 头 + FormData」会把表单转成 JSON 发送,
|
||||
// 导致素材上传等表单接口解析失败。对象请求体 axios 会自动补 JSON 头。
|
||||
baseURL: envConfig.apiBaseUrl,
|
||||
timeout: 30000,
|
||||
// 不设全局 Content-Type:axios 1.x 遇到「默认 JSON 头 + FormData」会把表单转成 JSON 发送,
|
||||
// 导致素材上传等表单接口解析失败。对象请求体 axios 会自动补 JSON 头。
|
||||
});
|
||||
|
||||
// access token 仅在内存中短暂持有(docs/api/conventions.md §1),每次请求动态附加 Bearer 头
|
||||
apiClient.interceptors.request.use((config: InternalAxiosRequestConfig) => {
|
||||
const token = authToken.get();
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
return config;
|
||||
const token = authToken.get();
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
return config;
|
||||
});
|
||||
|
||||
// 认证接口自身的失败不进入刷新流程
|
||||
const AUTH_PATHS = ['/auth/login', '/auth/register', '/auth/refresh', '/auth/logout'];
|
||||
const AUTH_PATHS = [
|
||||
'/auth/login',
|
||||
'/auth/register',
|
||||
'/auth/refresh',
|
||||
'/auth/logout',
|
||||
];
|
||||
|
||||
let refreshTask: Promise<string | null> | null = null;
|
||||
|
||||
/** 并发 401 只触发一次刷新请求;刷新依赖 HttpOnly refresh cookie */
|
||||
function refreshAccessToken(): Promise<string | null> {
|
||||
refreshTask ??= apiClient
|
||||
.post<{ access_token: string }>('/auth/refresh')
|
||||
.then((res) => {
|
||||
authToken.set(res.data.access_token);
|
||||
return res.data.access_token;
|
||||
})
|
||||
.catch(() => {
|
||||
authToken.clear();
|
||||
return null;
|
||||
})
|
||||
.finally(() => {
|
||||
refreshTask = null;
|
||||
});
|
||||
return refreshTask;
|
||||
refreshTask ??= apiClient
|
||||
.post<{ access_token: string }>('/auth/refresh')
|
||||
.then((res) => {
|
||||
authToken.set(res.data.access_token);
|
||||
return res.data.access_token;
|
||||
})
|
||||
.catch(() => {
|
||||
authToken.clear();
|
||||
return null;
|
||||
})
|
||||
.finally(() => {
|
||||
refreshTask = null;
|
||||
});
|
||||
return refreshTask;
|
||||
}
|
||||
|
||||
apiClient.interceptors.response.use(
|
||||
(response) => response,
|
||||
async (error) => {
|
||||
if (envConfig.debug) {
|
||||
console.error('请求失败:', error.response?.data || error.message);
|
||||
}
|
||||
const config = error.config as AxiosRequestConfig | undefined;
|
||||
const url = config?.url ?? '';
|
||||
const isAuthPath = AUTH_PATHS.some((path) => url.includes(path));
|
||||
// access token 过期时用 refresh cookie 换新 token 并重放一次;
|
||||
// 刷新失败说明会话已失效,整页跳转登录页(后续可换成路由内跳转并保留来源)
|
||||
if (error.response?.status === 401 && config && !config._retried && !isAuthPath) {
|
||||
config._retried = true;
|
||||
const token = await refreshAccessToken();
|
||||
if (token) {
|
||||
return apiClient.request(config);
|
||||
}
|
||||
window.location.assign('/login');
|
||||
}
|
||||
return Promise.reject(error);
|
||||
},
|
||||
(response) => response,
|
||||
async (error) => {
|
||||
if (envConfig.debug) {
|
||||
console.error('请求失败:', error.response?.data || error.message);
|
||||
}
|
||||
const config = error.config as AxiosRequestConfig | undefined;
|
||||
const url = config?.url ?? '';
|
||||
const isAuthPath = AUTH_PATHS.some((path) => url.includes(path));
|
||||
// access token 过期时用 refresh cookie 换新 token 并重放一次;
|
||||
// 刷新失败说明会话已失效,整页跳转登录页(后续可换成路由内跳转并保留来源)
|
||||
if (
|
||||
error.response?.status === 401 &&
|
||||
config &&
|
||||
!config._retried &&
|
||||
!isAuthPath
|
||||
) {
|
||||
config._retried = true;
|
||||
const token = await refreshAccessToken();
|
||||
if (token) {
|
||||
return apiClient.request(config);
|
||||
}
|
||||
window.location.assign('/login');
|
||||
}
|
||||
return Promise.reject(error);
|
||||
},
|
||||
);
|
||||
|
||||
export const api = {
|
||||
get: <T = unknown>(url: string, config?: AxiosRequestConfig) =>
|
||||
apiClient.get<T>(url, config).then((res) => res.data),
|
||||
get: <T = unknown>(url: string, config?: AxiosRequestConfig) =>
|
||||
apiClient.get<T>(url, config).then((res) => res.data),
|
||||
|
||||
post: <T = unknown>(url: string, data?: unknown, config?: AxiosRequestConfig) =>
|
||||
apiClient.post<T>(url, data, config).then((res) => res.data),
|
||||
post: <T = unknown>(
|
||||
url: string,
|
||||
data?: unknown,
|
||||
config?: AxiosRequestConfig,
|
||||
) => apiClient.post<T>(url, data, config).then((res) => res.data),
|
||||
|
||||
patch: <T = unknown>(url: string, data?: unknown, config?: AxiosRequestConfig) =>
|
||||
apiClient.patch<T>(url, data, config).then((res) => res.data),
|
||||
patch: <T = unknown>(
|
||||
url: string,
|
||||
data?: unknown,
|
||||
config?: AxiosRequestConfig,
|
||||
) => apiClient.patch<T>(url, data, config).then((res) => res.data),
|
||||
|
||||
delete: <T = unknown>(url: string, config?: AxiosRequestConfig) =>
|
||||
apiClient.delete<T>(url, config).then((res) => res.data),
|
||||
delete: <T = unknown>(url: string, config?: AxiosRequestConfig) =>
|
||||
apiClient.delete<T>(url, config).then((res) => res.data),
|
||||
};
|
||||
|
||||
/** 统一错误格式为 { error: { code, message, request_id } }(docs/api/conventions.md),提取可展示信息 */
|
||||
export function apiErrorMessage(error: unknown): string {
|
||||
if (axios.isAxiosError(error)) {
|
||||
const data = error.response?.data as
|
||||
{ error?: { message?: unknown }; message?: unknown } | undefined;
|
||||
if (data?.error?.message) return String(data.error.message);
|
||||
if (data?.message) return String(data.message);
|
||||
if (error.response) return `请求失败(HTTP ${error.response.status})`;
|
||||
return '网络连接失败,请稍后重试';
|
||||
}
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
if (axios.isAxiosError(error)) {
|
||||
const data = error.response?.data as
|
||||
{ error?: { message?: unknown }; message?: unknown } | undefined;
|
||||
if (data?.error?.message) return String(data.error.message);
|
||||
if (data?.message) return String(data.message);
|
||||
if (error.response) return `请求失败(HTTP ${error.response.status})`;
|
||||
return '网络连接失败,请稍后重试';
|
||||
}
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
export default apiClient;
|
||||
|
||||
@@ -1,13 +1,20 @@
|
||||
import { api } from '@/services/api';
|
||||
import type { AuthSession, LoginPayload, MeResult, RegisterPayload } from '@/types/auth';
|
||||
import type {
|
||||
AuthSession,
|
||||
LoginPayload,
|
||||
MeResult,
|
||||
RegisterPayload,
|
||||
} from '@/types/auth';
|
||||
|
||||
/** 账户与会话接口,契约见 docs/api/auth.md */
|
||||
export const authApi = {
|
||||
register: (payload: RegisterPayload) => api.post<AuthSession>('/auth/register', payload),
|
||||
register: (payload: RegisterPayload) =>
|
||||
api.post<AuthSession>('/auth/register', payload),
|
||||
|
||||
login: (payload: LoginPayload) => api.post<AuthSession>('/auth/login', payload),
|
||||
login: (payload: LoginPayload) =>
|
||||
api.post<AuthSession>('/auth/login', payload),
|
||||
|
||||
logout: () => api.post<void>('/auth/logout'),
|
||||
logout: () => api.post<void>('/auth/logout'),
|
||||
|
||||
me: () => api.get<MeResult>('/auth/me'),
|
||||
me: () => api.get<MeResult>('/auth/me'),
|
||||
};
|
||||
|
||||
@@ -6,11 +6,11 @@
|
||||
let accessToken: string | null = null;
|
||||
|
||||
export const authToken = {
|
||||
get: () => accessToken,
|
||||
set: (token: string) => {
|
||||
accessToken = token;
|
||||
},
|
||||
clear: () => {
|
||||
accessToken = null;
|
||||
},
|
||||
get: () => accessToken,
|
||||
set: (token: string) => {
|
||||
accessToken = token;
|
||||
},
|
||||
clear: () => {
|
||||
accessToken = null;
|
||||
},
|
||||
};
|
||||
|
||||
+28
-28
@@ -4,52 +4,52 @@
|
||||
*/
|
||||
|
||||
export interface AuthUser {
|
||||
id: string;
|
||||
email: string;
|
||||
display_name: string | null;
|
||||
email_verified_at: string | null;
|
||||
created_at?: string;
|
||||
id: string;
|
||||
email: string;
|
||||
display_name: string | null;
|
||||
email_verified_at: string | null;
|
||||
created_at?: string;
|
||||
}
|
||||
|
||||
export interface AuthWorkspace {
|
||||
id: string;
|
||||
name: string;
|
||||
role: string;
|
||||
id: string;
|
||||
name: string;
|
||||
role: string;
|
||||
}
|
||||
|
||||
/** 注册 / 登录成功返回的会话(access token 短期有效,refresh token 在 HttpOnly cookie 中) */
|
||||
export interface AuthSession {
|
||||
access_token: string;
|
||||
access_token_expires_in: number;
|
||||
user: AuthUser;
|
||||
workspace: AuthWorkspace;
|
||||
access_token: string;
|
||||
access_token_expires_in: number;
|
||||
user: AuthUser;
|
||||
workspace: AuthWorkspace;
|
||||
}
|
||||
|
||||
export interface RegisterPayload {
|
||||
email: string;
|
||||
password: string;
|
||||
password_confirmation: string;
|
||||
display_name?: string;
|
||||
email: string;
|
||||
password: string;
|
||||
password_confirmation: string;
|
||||
display_name?: string;
|
||||
}
|
||||
|
||||
export interface LoginPayload {
|
||||
email: string;
|
||||
password: string;
|
||||
email: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
export interface PlatformSummary {
|
||||
id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
/** GET /api/auth/me 的返回;default_store 摘要字段以后续门店接口文档为准 */
|
||||
export interface MeResult {
|
||||
user: AuthUser;
|
||||
workspace: AuthWorkspace;
|
||||
platforms: PlatformSummary[];
|
||||
suggested_context: {
|
||||
platform_id: string;
|
||||
default_store: { id: string; name: string } | null;
|
||||
};
|
||||
user: AuthUser;
|
||||
workspace: AuthWorkspace;
|
||||
platforms: PlatformSummary[];
|
||||
suggested_context: {
|
||||
platform_id: string;
|
||||
default_store: { id: string; name: string } | null;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2,8 +2,8 @@ import type { Rule } from 'antd/es/form';
|
||||
|
||||
/** 邮箱格式校验:仅在非空时校验,避免与 required 规则在空值时同时报错(antd v6 行为) */
|
||||
export const emailFormatRule: Rule = {
|
||||
validator: (_, value: string) =>
|
||||
!value || /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)
|
||||
? Promise.resolve()
|
||||
: Promise.reject(new Error('邮箱格式不正确')),
|
||||
validator: (_, value: string) =>
|
||||
!value || /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)
|
||||
? Promise.resolve()
|
||||
: Promise.reject(new Error('邮箱格式不正确')),
|
||||
};
|
||||
|
||||
+19
-19
@@ -1,21 +1,21 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.tsbuildinfo",
|
||||
// Web 走 Vite 打包,使用 bundler 解析;覆盖 base 中面向 NestJS 的 Node16 模块设置
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"moduleDetection": "force",
|
||||
"isolatedModules": true,
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2023", "DOM", "DOM.Iterable"],
|
||||
"jsx": "react-jsx",
|
||||
"noEmit": true,
|
||||
"allowImportingTsExtensions": false,
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src", "vite.config.ts"]
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.tsbuildinfo",
|
||||
// Web 走 Vite 打包,使用 bundler 解析;覆盖 base 中面向 NestJS 的 Node16 模块设置
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"moduleDetection": "force",
|
||||
"isolatedModules": true,
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2023", "DOM", "DOM.Iterable"],
|
||||
"jsx": "react-jsx",
|
||||
"noEmit": true,
|
||||
"allowImportingTsExtensions": false,
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src", "vite.config.ts"]
|
||||
}
|
||||
|
||||
+14
-14
@@ -4,20 +4,20 @@ import react from '@vitejs/plugin-react';
|
||||
|
||||
// 前端开发服务器:/api 代理到本地 NestJS API(apps/api,默认 8200)
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': fileURLToPath(new URL('./src', import.meta.url)),
|
||||
plugins: [react()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': fileURLToPath(new URL('./src', import.meta.url)),
|
||||
},
|
||||
},
|
||||
},
|
||||
server: {
|
||||
port: Number(process.env.WEB_PORT ?? 8100),
|
||||
strictPort: true,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: process.env.API_ORIGIN ?? 'http://localhost:8200',
|
||||
changeOrigin: true,
|
||||
},
|
||||
server: {
|
||||
port: Number(process.env.WEB_PORT ?? 8100),
|
||||
strictPort: true,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: process.env.API_ORIGIN ?? 'http://localhost:8200',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
+19
-19
@@ -1,21 +1,21 @@
|
||||
{
|
||||
"name": "@atlas/worker",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "tsc -p tsconfig.json",
|
||||
"dev": "tsx watch src/main.ts",
|
||||
"start": "node dist/main.js",
|
||||
"typecheck": "tsc -p tsconfig.json --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@atlas/contracts": "workspace:*",
|
||||
"bullmq": "6.3.4",
|
||||
"ioredis": "6.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "26.3.0",
|
||||
"tsx": "4.23.13",
|
||||
"typescript": "7.0.2"
|
||||
}
|
||||
"name": "@atlas/worker",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "tsc -p tsconfig.json",
|
||||
"dev": "tsx watch src/main.ts",
|
||||
"start": "node dist/main.js",
|
||||
"typecheck": "tsc -p tsconfig.json --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@atlas/contracts": "workspace:*",
|
||||
"bullmq": "6.3.4",
|
||||
"ioredis": "6.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "26.3.0",
|
||||
"tsx": "4.23.13",
|
||||
"typescript": "7.0.2"
|
||||
}
|
||||
}
|
||||
|
||||
+11
-8
@@ -3,22 +3,25 @@ import IORedis from 'ioredis';
|
||||
|
||||
// 显式构造客户端实例传给 BullMQ:原生 ESM(pnpm start)下 BullMQ 无法按 connection
|
||||
// 选项自动加载 ioredis;maxRetriesPerRequest: null 是 BullMQ Worker 的要求
|
||||
const connection = new IORedis(process.env.REDIS_URL ?? 'redis://localhost:6379', {
|
||||
maxRetriesPerRequest: null,
|
||||
});
|
||||
const connection = new IORedis(
|
||||
process.env.REDIS_URL ?? 'redis://localhost:6379',
|
||||
{
|
||||
maxRetriesPerRequest: null,
|
||||
},
|
||||
);
|
||||
const dispatchQueue = new Queue('atlas-dispatch', { connection });
|
||||
|
||||
async function bootstrap() {
|
||||
await dispatchQueue.waitUntilReady();
|
||||
console.info(
|
||||
'Atlas Worker is ready; it will consume transactional-outbox events after that module exists.',
|
||||
);
|
||||
await dispatchQueue.waitUntilReady();
|
||||
console.info(
|
||||
'Atlas Worker is ready; it will consume transactional-outbox events after that module exists.',
|
||||
);
|
||||
}
|
||||
|
||||
void bootstrap();
|
||||
|
||||
async function shutdown() {
|
||||
await dispatchQueue.close();
|
||||
await dispatchQueue.close();
|
||||
}
|
||||
|
||||
process.once('SIGINT', () => void shutdown());
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "dist",
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*.ts"]
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "dist",
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user