feat(api): 开发平台、门店相关代码
This commit is contained in:
@@ -25,6 +25,12 @@ API_PORT=8200
|
|||||||
AUTH_JWT_SECRET=replace-with-a-random-secret-of-at-least-32-characters
|
AUTH_JWT_SECRET=replace-with-a-random-secret-of-at-least-32-characters
|
||||||
# Separate HMAC secret used to pseudonymize IP/email values in Redis rate-limit keys.
|
# Separate HMAC secret used to pseudonymize IP/email values in Redis rate-limit keys.
|
||||||
AUTH_RATE_LIMIT_KEY_SECRET=replace-with-a-different-random-secret-of-at-least-32-characters
|
AUTH_RATE_LIMIT_KEY_SECRET=replace-with-a-different-random-secret-of-at-least-32-characters
|
||||||
|
# AES-GCM 密钥材料,用来加密门店 API Key;只保存在服务端,轮换时变更版本号但保留旧密钥解密能力。
|
||||||
|
STORE_SECRET_ENCRYPTION_KEY=replace-with-a-random-secret-of-at-least-32-characters
|
||||||
|
# 当前门店密钥使用的加密密钥版本,会随密钥轮换递增。
|
||||||
|
STORE_SECRET_ENCRYPTION_KEY_VERSION=v1
|
||||||
|
# 对幂等请求内容生成 HMAC 摘要的独立密钥,避免数据库保存原始请求体或 API Key。
|
||||||
|
IDEMPOTENCY_KEY_SECRET=replace-with-a-different-random-secret-of-at-least-32-characters
|
||||||
# Server-side minimum password length. The browser must not weaken this rule.
|
# Server-side minimum password length. The browser must not weaken this rule.
|
||||||
AUTH_PASSWORD_MIN_LENGTH=12
|
AUTH_PASSWORD_MIN_LENGTH=12
|
||||||
# Maximum JSON or URL-encoded request size in bytes. Media files must use object-storage direct upload.
|
# Maximum JSON or URL-encoded request size in bytes. Media files must use object-storage direct upload.
|
||||||
|
|||||||
@@ -5,9 +5,10 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "tsc -p tsconfig.json",
|
"build": "tsc -p tsconfig.json",
|
||||||
"dev": "tsx watch src/main.ts",
|
"dev": "node scripts/dev.mjs",
|
||||||
"start": "node dist/main.js",
|
"start": "node dist/main.js",
|
||||||
"typecheck": "tsc -p tsconfig.json --noEmit"
|
"typecheck": "tsc -p tsconfig.json --noEmit && tsc -p tsconfig.test.json --noEmit",
|
||||||
|
"test": "vitest run"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@atlas/contracts": "workspace:*",
|
"@atlas/contracts": "workspace:*",
|
||||||
@@ -24,9 +25,11 @@
|
|||||||
"zod": "4.5.4"
|
"zod": "4.5.4"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@nestjs/testing": "12.0.1",
|
||||||
"@types/express": "5.0.6",
|
"@types/express": "5.0.6",
|
||||||
"@types/node": "26.3.0",
|
"@types/node": "26.3.0",
|
||||||
"tsx": "4.23.13",
|
"tsx": "4.23.13",
|
||||||
"typescript": "7.0.2"
|
"typescript": "7.0.2",
|
||||||
|
"vitest": "^4.1.11"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import { spawn } from 'node:child_process';
|
||||||
|
import { existsSync, readFileSync } from 'node:fs';
|
||||||
|
import { dirname, resolve } from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
|
const scriptDirectory = dirname(fileURLToPath(import.meta.url));
|
||||||
|
const repositoryRoot = resolve(scriptDirectory, '../../..');
|
||||||
|
const environmentFile = resolve(repositoryRoot, '.env');
|
||||||
|
const localEnvironment = existsSync(environmentFile)
|
||||||
|
? parseEnvironment(readFileSync(environmentFile, 'utf8'))
|
||||||
|
: {};
|
||||||
|
const apiPort = process.env.API_PORT ?? localEnvironment.API_PORT ?? '8200';
|
||||||
|
const swaggerEnabled =
|
||||||
|
(process.env.SWAGGER_ENABLED ??
|
||||||
|
localEnvironment.SWAGGER_ENABLED ??
|
||||||
|
'true') === 'true';
|
||||||
|
const nodeEnvironment =
|
||||||
|
process.env.NODE_ENV ?? localEnvironment.NODE_ENV ?? 'development';
|
||||||
|
const swaggerUrl = `http://localhost:${apiPort}/api-docs`;
|
||||||
|
const tsxCommand = process.platform === 'win32' ? 'tsx.cmd' : 'tsx';
|
||||||
|
const apiProcess = spawn(tsxCommand, ['watch', 'src/main.ts'], {
|
||||||
|
shell: process.platform === 'win32',
|
||||||
|
stdio: 'inherit',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (
|
||||||
|
process.platform === 'darwin' &&
|
||||||
|
nodeEnvironment === 'development' &&
|
||||||
|
swaggerEnabled
|
||||||
|
) {
|
||||||
|
void openSwaggerAfterApiIsReady();
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const signal of ['SIGINT', 'SIGTERM']) {
|
||||||
|
process.on(signal, () => apiProcess.kill(signal));
|
||||||
|
}
|
||||||
|
|
||||||
|
apiProcess.on('exit', (code, signal) => {
|
||||||
|
if (signal) process.exitCode = 1;
|
||||||
|
else process.exitCode = code ?? 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
function parseEnvironment(content) {
|
||||||
|
return Object.fromEntries(
|
||||||
|
content
|
||||||
|
.split(/\r?\n/)
|
||||||
|
.map((line) => line.match(/^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/))
|
||||||
|
.filter(Boolean)
|
||||||
|
.map((match) => [match[1], match[2]]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openSwaggerAfterApiIsReady() {
|
||||||
|
const deadline = Date.now() + 30_000;
|
||||||
|
while (Date.now() < deadline) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`http://localhost:${apiPort}/health`);
|
||||||
|
if (response.ok) {
|
||||||
|
// 开发启动器进程不会随 tsx watch 的热重启重建,因此只会打开一次。
|
||||||
|
spawn('open', [swaggerUrl], {
|
||||||
|
detached: true,
|
||||||
|
stdio: 'ignore',
|
||||||
|
}).unref();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// API 仍在启动或热重启,继续等待到超时为止。
|
||||||
|
}
|
||||||
|
await new Promise((resolveDelay) => setTimeout(resolveDelay, 250));
|
||||||
|
}
|
||||||
|
console.warn(`Swagger 未在 30 秒内就绪:${swaggerUrl}`);
|
||||||
|
}
|
||||||
@@ -2,20 +2,24 @@ import { Controller, Get, Inject, Module } from '@nestjs/common';
|
|||||||
import type { Database } from '@atlas/database';
|
import type { Database } from '@atlas/database';
|
||||||
import { AuthModule } from './auth/auth.module.js';
|
import { AuthModule } from './auth/auth.module.js';
|
||||||
import { ApiError } from './common/api-error.js';
|
import { ApiError } from './common/api-error.js';
|
||||||
|
import { Public } from './common/public.decorator.js';
|
||||||
import { AppConfigModule } from './config/app-config.module.js';
|
import { AppConfigModule } from './config/app-config.module.js';
|
||||||
import { DATABASE, DatabaseModule } from './database/database.module.js';
|
import { DATABASE, DatabaseModule } from './database/database.module.js';
|
||||||
import { SecurityModule } from './security/security.module.js';
|
import { SecurityModule } from './security/security.module.js';
|
||||||
|
import { StoresModule } from './stores/stores.module.js';
|
||||||
|
|
||||||
@Controller()
|
@Controller()
|
||||||
class HealthController {
|
class HealthController {
|
||||||
public constructor(@Inject(DATABASE) private readonly database: Database) {}
|
public constructor(@Inject(DATABASE) private readonly database: Database) {}
|
||||||
|
|
||||||
@Get('health')
|
@Get('health')
|
||||||
|
@Public()
|
||||||
check() {
|
check() {
|
||||||
return { status: 'ok', service: 'api' };
|
return { status: 'ok', service: 'api' };
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('ready')
|
@Get('ready')
|
||||||
|
@Public()
|
||||||
async ready() {
|
async ready() {
|
||||||
try {
|
try {
|
||||||
await this.database.query('SELECT 1');
|
await this.database.query('SELECT 1');
|
||||||
@@ -31,7 +35,13 @@ class HealthController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [AppConfigModule, DatabaseModule, SecurityModule, AuthModule],
|
imports: [
|
||||||
|
AppConfigModule,
|
||||||
|
DatabaseModule,
|
||||||
|
SecurityModule,
|
||||||
|
AuthModule,
|
||||||
|
StoresModule,
|
||||||
|
],
|
||||||
controllers: [HealthController],
|
controllers: [HealthController],
|
||||||
})
|
})
|
||||||
export class AppModule {}
|
export class AppModule {}
|
||||||
|
|||||||
@@ -3,10 +3,10 @@ import {
|
|||||||
Controller,
|
Controller,
|
||||||
Get,
|
Get,
|
||||||
HttpCode,
|
HttpCode,
|
||||||
|
Inject,
|
||||||
Post,
|
Post,
|
||||||
Req,
|
Req,
|
||||||
Res,
|
Res,
|
||||||
UseGuards,
|
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import {
|
import {
|
||||||
ApiBearerAuth,
|
ApiBearerAuth,
|
||||||
@@ -18,12 +18,19 @@ import {
|
|||||||
ApiUnauthorizedResponse,
|
ApiUnauthorizedResponse,
|
||||||
} from '@nestjs/swagger';
|
} from '@nestjs/swagger';
|
||||||
import { createHash } from 'node:crypto';
|
import { createHash } from 'node:crypto';
|
||||||
import { loginRequestSchema, registerRequestSchema } from '@atlas/contracts';
|
import {
|
||||||
|
loginRequestSchema,
|
||||||
|
registerRequestSchema,
|
||||||
|
type Workspace,
|
||||||
|
} from '@atlas/contracts';
|
||||||
import { ApiError } from '../common/api-error.js';
|
import { ApiError } from '../common/api-error.js';
|
||||||
import { AccessTokenGuard, type AuthenticatedRequest } from './auth.guard.js';
|
import { Public } from '../common/public.decorator.js';
|
||||||
|
import type { AuthenticatedRequest } from './auth.guard.js';
|
||||||
import { AuthService, type RequestMetadata } from './auth.service.js';
|
import { AuthService, type RequestMetadata } from './auth.service.js';
|
||||||
|
import { CurrentWorkspace } from '../workspace/current-workspace.decorator.js';
|
||||||
|
|
||||||
const refreshCookie = 'atlas_refresh';
|
const refreshCookie = 'atlas_refresh';
|
||||||
|
// 控制器只负责 HTTP 边界;账户、会话与工作空间的一致性由 AuthService 的事务保证。
|
||||||
type HttpRequest = {
|
type HttpRequest = {
|
||||||
header: (name: string) => string | undefined;
|
header: (name: string) => string | undefined;
|
||||||
ip?: string;
|
ip?: string;
|
||||||
@@ -40,9 +47,12 @@ type HttpResponse = {
|
|||||||
@ApiTags('Authentication')
|
@ApiTags('Authentication')
|
||||||
@Controller('auth')
|
@Controller('auth')
|
||||||
export class AuthController {
|
export class AuthController {
|
||||||
public constructor(private readonly authService: AuthService) {}
|
public constructor(
|
||||||
|
@Inject(AuthService) private readonly authService: AuthService,
|
||||||
|
) {}
|
||||||
|
|
||||||
@Post('register')
|
@Post('register')
|
||||||
|
@Public()
|
||||||
@ApiOperation({ summary: '注册账户并创建个人工作空间' })
|
@ApiOperation({ summary: '注册账户并创建个人工作空间' })
|
||||||
@ApiBody({
|
@ApiBody({
|
||||||
schema: {
|
schema: {
|
||||||
@@ -68,6 +78,8 @@ export class AuthController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post('login')
|
@Post('login')
|
||||||
|
@Public()
|
||||||
|
@HttpCode(200)
|
||||||
@ApiOperation({ summary: '使用邮箱和密码登录' })
|
@ApiOperation({ summary: '使用邮箱和密码登录' })
|
||||||
@ApiBody({
|
@ApiBody({
|
||||||
schema: {
|
schema: {
|
||||||
@@ -91,12 +103,15 @@ export class AuthController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post('refresh')
|
@Post('refresh')
|
||||||
|
@Public()
|
||||||
|
@HttpCode(200)
|
||||||
@ApiOperation({ summary: '轮换 refresh cookie 并获取新的 access token' })
|
@ApiOperation({ summary: '轮换 refresh cookie 并获取新的 access token' })
|
||||||
public async refresh(
|
public async refresh(
|
||||||
@Req() request: HttpRequest,
|
@Req() request: HttpRequest,
|
||||||
@Res({ passthrough: true }) response: HttpResponse,
|
@Res({ passthrough: true }) response: HttpResponse,
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
|
// refresh token 不进入 JSON body,仅从浏览器自动发送的 HttpOnly cookie 读取。
|
||||||
const result = await this.authService.refresh(
|
const result = await this.authService.refresh(
|
||||||
readCookie(request.header('cookie'), refreshCookie),
|
readCookie(request.header('cookie'), refreshCookie),
|
||||||
metadata(request),
|
metadata(request),
|
||||||
@@ -117,7 +132,6 @@ export class AuthController {
|
|||||||
|
|
||||||
@Post('logout')
|
@Post('logout')
|
||||||
@HttpCode(204)
|
@HttpCode(204)
|
||||||
@UseGuards(AccessTokenGuard)
|
|
||||||
@ApiBearerAuth('access-token')
|
@ApiBearerAuth('access-token')
|
||||||
@ApiOperation({ summary: '撤销当前登录会话' })
|
@ApiOperation({ summary: '撤销当前登录会话' })
|
||||||
@ApiNoContentResponse({ description: '会话已撤销' })
|
@ApiNoContentResponse({ description: '会话已撤销' })
|
||||||
@@ -131,13 +145,17 @@ export class AuthController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Get('me')
|
@Get('me')
|
||||||
@UseGuards(AccessTokenGuard)
|
|
||||||
@ApiBearerAuth('access-token')
|
@ApiBearerAuth('access-token')
|
||||||
@ApiOperation({ summary: '获取当前用户、工作空间和页头初始上下文' })
|
@ApiOperation({ summary: '获取当前用户、工作空间和页头初始上下文' })
|
||||||
@ApiOkResponse({ description: '当前身份和工作空间' })
|
@ApiOkResponse({ description: '当前身份和工作空间' })
|
||||||
@ApiUnauthorizedResponse({ description: 'access token 无效或已过期' })
|
@ApiUnauthorizedResponse({ description: 'access token 无效或已过期' })
|
||||||
public async me(@Req() request: AuthenticatedRequest) {
|
public async me(
|
||||||
return { data: await this.authService.me(request.auth.userId) };
|
@Req() request: AuthenticatedRequest,
|
||||||
|
@CurrentWorkspace() workspace: Workspace,
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
data: await this.authService.me(request.auth.userId, workspace),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -178,6 +196,7 @@ function metadata(request: HttpRequest): RequestMetadata {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
function cookieOptions() {
|
function cookieOptions() {
|
||||||
|
// cookie 路径限制在认证接口,避免无关请求携带长期会话凭证。
|
||||||
return {
|
return {
|
||||||
httpOnly: true,
|
httpOnly: true,
|
||||||
secure: process.env.NODE_ENV === 'production',
|
secure: process.env.NODE_ENV === 'production',
|
||||||
@@ -198,6 +217,7 @@ function clearRefreshCookie(response: HttpResponse) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
function readCookie(header: string | undefined, name: string) {
|
function readCookie(header: string | undefined, name: string) {
|
||||||
|
// 只解析本服务需要的单个 cookie,避免引入与认证无关的 cookie 状态。
|
||||||
return header
|
return header
|
||||||
?.split(';')
|
?.split(';')
|
||||||
.map((part) => part.trim())
|
.map((part) => part.trim())
|
||||||
|
|||||||
@@ -13,10 +13,11 @@ const scrypt = promisify(scryptCallback) as (
|
|||||||
keyLength: number,
|
keyLength: number,
|
||||||
options: { N: number; r: number; p: number; maxmem: number },
|
options: { N: number; r: number; p: number; maxmem: number },
|
||||||
) => Promise<Buffer>;
|
) => Promise<Buffer>;
|
||||||
|
// 使用较高成本的 scrypt 参数,控制离线破解密码哈希时的时间与内存成本。
|
||||||
const scryptOptions = { N: 16_384, r: 8, p: 1, maxmem: 32 * 1024 * 1024 };
|
const scryptOptions = { N: 16_384, r: 8, p: 1, maxmem: 32 * 1024 * 1024 };
|
||||||
|
|
||||||
export async function hashPassword(password: string): Promise<string> {
|
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 salt = randomBytes(16).toString('base64url');
|
||||||
const derived = (await scrypt(password, salt, 64, scryptOptions)) as Buffer;
|
const derived = (await scrypt(password, salt, 64, scryptOptions)) as Buffer;
|
||||||
return `scrypt$${salt}$${derived.toString('base64url')}`;
|
return `scrypt$${salt}$${derived.toString('base64url')}`;
|
||||||
@@ -40,6 +41,7 @@ export function createOpaqueToken(): string {
|
|||||||
return randomBytes(48).toString('base64url');
|
return randomBytes(48).toString('base64url');
|
||||||
}
|
}
|
||||||
export function tokenHash(token: string): string {
|
export function tokenHash(token: string): string {
|
||||||
|
// refresh token 只以摘要形式存库;服务端无需保存可直接用于登录的原文。
|
||||||
return createHash('sha256').update(token).digest('base64url');
|
return createHash('sha256').update(token).digest('base64url');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -53,7 +55,7 @@ export function signAccessToken(
|
|||||||
claims: AccessTokenClaims,
|
claims: AccessTokenClaims,
|
||||||
secret: string,
|
secret: string,
|
||||||
): string {
|
): string {
|
||||||
// Access tokens are short-lived; refresh sessions remain revocable in PostgreSQL.
|
// access token 是短期自包含凭证;真正可撤销的会话状态仍以 PostgreSQL 为准。
|
||||||
const header = Buffer.from(
|
const header = Buffer.from(
|
||||||
JSON.stringify({ alg: 'HS256', typ: 'JWT' }),
|
JSON.stringify({ alg: 'HS256', typ: 'JWT' }),
|
||||||
).toString('base64url');
|
).toString('base64url');
|
||||||
@@ -73,6 +75,7 @@ export function verifyAccessToken(
|
|||||||
.update(`${header}.${payload}`)
|
.update(`${header}.${payload}`)
|
||||||
.digest();
|
.digest();
|
||||||
const actual = Buffer.from(signature, 'base64url');
|
const actual = Buffer.from(signature, 'base64url');
|
||||||
|
// 恒定时间比较可避免根据签名比对耗时推测正确前缀。
|
||||||
if (actual.length !== expected.length || !timingSafeEqual(actual, expected))
|
if (actual.length !== expected.length || !timingSafeEqual(actual, expected))
|
||||||
return null;
|
return null;
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -1,18 +1,43 @@
|
|||||||
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
|
import {
|
||||||
|
CanActivate,
|
||||||
|
ExecutionContext,
|
||||||
|
Inject,
|
||||||
|
Injectable,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { Reflector } from '@nestjs/core';
|
||||||
|
import type { Workspace } from '@atlas/contracts';
|
||||||
|
import { IS_PUBLIC_ENDPOINT } from '../common/public.decorator.js';
|
||||||
import { AuthService } from './auth.service.js';
|
import { AuthService } from './auth.service.js';
|
||||||
|
|
||||||
export type AuthenticatedRequest = {
|
export type AuthenticatedRequest = {
|
||||||
header: (name: string) => string | undefined;
|
header: (name: string) => string | undefined;
|
||||||
auth: { userId: string; sessionId: string };
|
auth: {
|
||||||
|
userId: string;
|
||||||
|
sessionId: string;
|
||||||
|
workspace?: Workspace;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class AccessTokenGuard implements CanActivate {
|
export class AccessTokenGuard implements CanActivate {
|
||||||
public constructor(private readonly authService: AuthService) {}
|
public constructor(
|
||||||
|
@Inject(Reflector) private readonly reflector: Reflector,
|
||||||
|
@Inject(AuthService) private readonly authService: AuthService,
|
||||||
|
) {}
|
||||||
public async canActivate(context: ExecutionContext) {
|
public async canActivate(context: ExecutionContext) {
|
||||||
|
// 公开端点必须显式声明,避免新路由因忘记添加守卫而意外暴露。
|
||||||
|
if (
|
||||||
|
this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_ENDPOINT, [
|
||||||
|
context.getHandler(),
|
||||||
|
context.getClass(),
|
||||||
|
])
|
||||||
|
) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
const request = context
|
const request = context
|
||||||
.switchToHttp()
|
.switchToHttp()
|
||||||
.getRequest<AuthenticatedRequest>();
|
.getRequest<AuthenticatedRequest>();
|
||||||
|
// 只在此处解析 Authorization,后续守卫和控制器共享已验证的会话身份。
|
||||||
request.auth = await this.authService.authenticate(
|
request.auth = await this.authService.authenticate(
|
||||||
request.header('authorization'),
|
request.header('authorization'),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,10 +1,18 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
import { APP_GUARD } from '@nestjs/core';
|
||||||
import { AuthController } from './auth.controller.js';
|
import { AuthController } from './auth.controller.js';
|
||||||
import { AccessTokenGuard } from './auth.guard.js';
|
import { AccessTokenGuard } from './auth.guard.js';
|
||||||
import { AuthService } from './auth.service.js';
|
import { AuthService } from './auth.service.js';
|
||||||
|
import { WorkspaceContextGuard } from '../workspace/workspace-context.guard.js';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
controllers: [AuthController],
|
controllers: [AuthController],
|
||||||
providers: [AuthService, AccessTokenGuard],
|
providers: [
|
||||||
|
AuthService,
|
||||||
|
AccessTokenGuard,
|
||||||
|
WorkspaceContextGuard,
|
||||||
|
{ provide: APP_GUARD, useExisting: AccessTokenGuard },
|
||||||
|
{ provide: APP_GUARD, useExisting: WorkspaceContextGuard },
|
||||||
|
],
|
||||||
})
|
})
|
||||||
export class AuthModule {}
|
export class AuthModule {}
|
||||||
|
|||||||
@@ -33,12 +33,14 @@ type WorkspaceRow = { id: string; name: string; role: Workspace['role'] };
|
|||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class AuthService {
|
export class AuthService {
|
||||||
|
// access token 短期存在于浏览器内存;refresh session 的有效期更长且可服务端撤销。
|
||||||
private readonly accessTtlSeconds = 900;
|
private readonly accessTtlSeconds = 900;
|
||||||
private readonly refreshTtlDays = 30;
|
private readonly refreshTtlDays = 30;
|
||||||
|
|
||||||
public constructor(
|
public constructor(
|
||||||
@Inject(DATABASE) private readonly database: Database,
|
@Inject(DATABASE) private readonly database: Database,
|
||||||
@Inject(APP_CONFIG) private readonly config: AppConfig,
|
@Inject(APP_CONFIG) private readonly config: AppConfig,
|
||||||
|
@Inject(AuthRateLimiter)
|
||||||
private readonly rateLimiter: AuthRateLimiter,
|
private readonly rateLimiter: AuthRateLimiter,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@@ -56,12 +58,12 @@ export class AuthService {
|
|||||||
`密码至少需要 ${this.config.AUTH_PASSWORD_MIN_LENGTH} 个字符`,
|
`密码至少需要 ${this.config.AUTH_PASSWORD_MIN_LENGTH} 个字符`,
|
||||||
);
|
);
|
||||||
const email = input.email.trim().toLowerCase();
|
const email = input.email.trim().toLowerCase();
|
||||||
// Apply the shared limiter before spending CPU on password hashing or querying account existence.
|
// 限流必须早于密码哈希和用户查询,避免攻击者消耗 CPU 或探测账户状态。
|
||||||
await this.rateLimiter.consume('register', { ip: metadata.ip, email });
|
await this.rateLimiter.consume('register', { ip: metadata.ip, email });
|
||||||
const passwordHash = await hashPassword(input.password);
|
const passwordHash = await hashPassword(input.password);
|
||||||
try {
|
try {
|
||||||
return await this.database.transaction(async (transaction) => {
|
return await this.database.transaction(async (transaction) => {
|
||||||
// A user must never exist without its initial tenant and owner membership.
|
// 用户、个人工作空间和 owner membership 必须原子创建,不能留下半成品租户。
|
||||||
const user = await insertUser(
|
const user = await insertUser(
|
||||||
transaction,
|
transaction,
|
||||||
email,
|
email,
|
||||||
@@ -100,7 +102,7 @@ export class AuthService {
|
|||||||
|
|
||||||
public async login(input: LoginRequest, metadata: RequestMetadata) {
|
public async login(input: LoginRequest, metadata: RequestMetadata) {
|
||||||
const email = input.email.trim().toLowerCase();
|
const email = input.email.trim().toLowerCase();
|
||||||
// Use the same normalized value for the limiter and citext account lookup.
|
// 限流与 citext 查询使用同一规范化邮箱,避免大小写绕过限流桶。
|
||||||
await this.rateLimiter.consume('login', { ip: metadata.ip, email });
|
await this.rateLimiter.consume('login', { ip: metadata.ip, email });
|
||||||
const result = await this.database.query<UserRow>(
|
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',
|
'SELECT id, email, display_name, email_verified_at, created_at, status, password_hash FROM atlas.users WHERE email = $1',
|
||||||
@@ -133,7 +135,7 @@ export class AuthService {
|
|||||||
refreshToken: string | undefined,
|
refreshToken: string | undefined,
|
||||||
metadata: RequestMetadata,
|
metadata: RequestMetadata,
|
||||||
) {
|
) {
|
||||||
// Refresh has no trusted email until its opaque token is validated, so it is IP-scoped only.
|
// refresh token 验证前没有可信邮箱,只能按 IP 维度限流。
|
||||||
await this.rateLimiter.consume('refresh', { ip: metadata.ip });
|
await this.rateLimiter.consume('refresh', { ip: metadata.ip });
|
||||||
if (!refreshToken)
|
if (!refreshToken)
|
||||||
throw new ApiError(401, 'SESSION_EXPIRED', '登录会话已过期');
|
throw new ApiError(401, 'SESSION_EXPIRED', '登录会话已过期');
|
||||||
@@ -149,7 +151,7 @@ export class AuthService {
|
|||||||
const user = sessions.rows[0];
|
const user = sessions.rows[0];
|
||||||
if (!user || user.status !== 'active')
|
if (!user || user.status !== 'active')
|
||||||
throw new ApiError(401, 'SESSION_EXPIRED', '登录会话已过期');
|
throw new ApiError(401, 'SESSION_EXPIRED', '登录会话已过期');
|
||||||
// Row locking makes a refresh token single-use even when two browser requests arrive together.
|
// 行锁保证并发 refresh 时旧 token 只能成功使用一次。
|
||||||
await transaction.query(
|
await transaction.query(
|
||||||
'UPDATE atlas.user_sessions SET revoked_at = now() WHERE id = $1',
|
'UPDATE atlas.user_sessions SET revoked_at = now() WHERE id = $1',
|
||||||
[user.session_id],
|
[user.session_id],
|
||||||
@@ -180,6 +182,7 @@ export class AuthService {
|
|||||||
? verifyAccessToken(token, this.config.AUTH_JWT_SECRET)
|
? verifyAccessToken(token, this.config.AUTH_JWT_SECRET)
|
||||||
: null;
|
: null;
|
||||||
if (!claims) throw new UnauthorizedException();
|
if (!claims) throw new UnauthorizedException();
|
||||||
|
// JWT 签名有效仍不足够;必须确认对应 session 未撤销且未过期。
|
||||||
const active = await this.database.query(
|
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()',
|
'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],
|
[claims.sid, claims.sub],
|
||||||
@@ -188,8 +191,8 @@ export class AuthService {
|
|||||||
return { userId: claims.sub, sessionId: claims.sid };
|
return { userId: claims.sub, sessionId: claims.sid };
|
||||||
}
|
}
|
||||||
|
|
||||||
public async me(userId: string) {
|
public async me(userId: string, workspace: Workspace) {
|
||||||
const workspace = await this.findWorkspace(userId);
|
// workspace 由全局守卫解析,避免控制器或服务各自选择租户造成边界不一致。
|
||||||
const users = await this.database.query<UserRow>(
|
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',
|
'SELECT id, email, display_name, email_verified_at, created_at, status, password_hash FROM atlas.users WHERE id = $1',
|
||||||
[userId],
|
[userId],
|
||||||
@@ -233,6 +236,7 @@ export class AuthService {
|
|||||||
workspace: WorkspaceRow,
|
workspace: WorkspaceRow,
|
||||||
metadata: RequestMetadata,
|
metadata: RequestMetadata,
|
||||||
) {
|
) {
|
||||||
|
// refresh token 仅通过 HttpOnly cookie 交给浏览器,响应体只暴露短期 access token。
|
||||||
const refreshToken = createOpaqueToken();
|
const refreshToken = createOpaqueToken();
|
||||||
const session = await transaction.query<{ id: string }>(
|
const session = await transaction.query<{ id: string }>(
|
||||||
`INSERT INTO atlas.user_sessions (user_id, refresh_token_hash, expires_at, ip_hash, user_agent)
|
`INSERT INTO atlas.user_sessions (user_id, refresh_token_hash, expires_at, ip_hash, user_agent)
|
||||||
@@ -284,6 +288,7 @@ function isUniqueViolation(error: unknown): error is { code: string } {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
function publicUser(user: UserRow): AuthUser {
|
function publicUser(user: UserRow): AuthUser {
|
||||||
|
// 白名单映射防止 password_hash 等内部字段随查询结果泄露给客户端。
|
||||||
return {
|
return {
|
||||||
id: user.id,
|
id: user.id,
|
||||||
email: user.email,
|
email: user.email,
|
||||||
@@ -298,6 +303,7 @@ async function insertUser(
|
|||||||
passwordHash: string,
|
passwordHash: string,
|
||||||
displayName: string | null,
|
displayName: string | null,
|
||||||
) {
|
) {
|
||||||
|
// slug 使用用户 UUID,显示名称变化不会影响个人工作空间的稳定标识。
|
||||||
const result = await transaction.query<UserRow>(
|
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',
|
'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],
|
[email, passwordHash, displayName],
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ export class ApiErrorFilter implements ExceptionFilter {
|
|||||||
}>();
|
}>();
|
||||||
const requestId = request.requestId ?? `req_${randomUUID()}`;
|
const requestId = request.requestId ?? `req_${randomUUID()}`;
|
||||||
if (exception instanceof ApiError) {
|
if (exception instanceof ApiError) {
|
||||||
|
// 已知业务错误保留其稳定 code 与 details,方便前端精确处理。
|
||||||
const body = exception.getResponse() as {
|
const body = exception.getResponse() as {
|
||||||
error: Record<string, unknown>;
|
error: Record<string, unknown>;
|
||||||
};
|
};
|
||||||
@@ -33,6 +34,7 @@ export class ApiErrorFilter implements ExceptionFilter {
|
|||||||
.json({ error: { ...body.error, request_id: requestId } });
|
.json({ error: { ...body.error, request_id: requestId } });
|
||||||
}
|
}
|
||||||
if (exception instanceof HttpException) {
|
if (exception instanceof HttpException) {
|
||||||
|
// Nest 内部异常不泄露原始 message,避免暴露认证或解析实现细节。
|
||||||
return response.status(exception.getStatus()).json({
|
return response.status(exception.getStatus()).json({
|
||||||
error: {
|
error: {
|
||||||
code: 'HTTP_ERROR',
|
code: 'HTTP_ERROR',
|
||||||
@@ -50,6 +52,7 @@ export class ApiErrorFilter implements ExceptionFilter {
|
|||||||
path: request.originalUrl,
|
path: request.originalUrl,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
// 未预期异常只返回通用信息,request_id 用于从结构化日志定位根因。
|
||||||
return response.status(500).json({
|
return response.status(500).json({
|
||||||
error: {
|
error: {
|
||||||
code: 'INTERNAL_ERROR',
|
code: 'INTERNAL_ERROR',
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { HttpException, HttpStatus } from '@nestjs/common';
|
import { HttpException, HttpStatus } from '@nestjs/common';
|
||||||
|
|
||||||
export class ApiError extends HttpException {
|
export class ApiError extends HttpException {
|
||||||
|
// 业务代码只能通过此类型抛出可预期错误,最终由全局过滤器统一封装为 error 信封。
|
||||||
public constructor(
|
public constructor(
|
||||||
status: HttpStatus,
|
status: HttpStatus,
|
||||||
code: string,
|
code: string,
|
||||||
|
|||||||
@@ -0,0 +1,133 @@
|
|||||||
|
import { createHmac } from 'node:crypto';
|
||||||
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
|
import type { Database, DatabaseTransaction } from '@atlas/database';
|
||||||
|
import { APP_CONFIG, type AppConfig } from '../config/app-config.module.js';
|
||||||
|
import { DATABASE } from '../database/database.module.js';
|
||||||
|
import { ApiError } from './api-error.js';
|
||||||
|
|
||||||
|
type IdempotentResponse = { status: number; body: unknown };
|
||||||
|
type IdempotencyInput = {
|
||||||
|
workspaceId: string;
|
||||||
|
userId: string;
|
||||||
|
method: 'POST' | 'PUT' | 'PATCH' | 'DELETE';
|
||||||
|
path: string;
|
||||||
|
key: string;
|
||||||
|
request: unknown;
|
||||||
|
};
|
||||||
|
type IdempotencyRow = {
|
||||||
|
request_fingerprint: string;
|
||||||
|
status: 'in_progress' | 'completed';
|
||||||
|
response_status: number | null;
|
||||||
|
response_body: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class IdempotencyService {
|
||||||
|
public constructor(
|
||||||
|
@Inject(DATABASE) private readonly database: Database,
|
||||||
|
@Inject(APP_CONFIG) private readonly config: AppConfig,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 幂等记录与业务写入共用同一个工作空间事务。只有两者都提交,重试才会得到可靠的历史响应。
|
||||||
|
*/
|
||||||
|
public async run(
|
||||||
|
input: IdempotencyInput,
|
||||||
|
work: (transaction: DatabaseTransaction) => Promise<IdempotentResponse>,
|
||||||
|
): Promise<IdempotentResponse> {
|
||||||
|
const fingerprint = createHmac(
|
||||||
|
'sha256',
|
||||||
|
this.config.IDEMPOTENCY_KEY_SECRET,
|
||||||
|
)
|
||||||
|
.update(canonicalize(input.request))
|
||||||
|
.digest('base64url');
|
||||||
|
|
||||||
|
return this.database.workspaceTransaction(
|
||||||
|
input.workspaceId,
|
||||||
|
async (transaction) => {
|
||||||
|
const inserted = await transaction.query<IdempotencyRow>(
|
||||||
|
`INSERT INTO atlas.idempotency_requests
|
||||||
|
(workspace_id, user_id, http_method, request_path, idempotency_key, request_fingerprint)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6)
|
||||||
|
ON CONFLICT (workspace_id, user_id, http_method, request_path, idempotency_key) DO NOTHING
|
||||||
|
RETURNING request_fingerprint, status, response_status, response_body`,
|
||||||
|
[
|
||||||
|
input.workspaceId,
|
||||||
|
input.userId,
|
||||||
|
input.method,
|
||||||
|
input.path,
|
||||||
|
input.key,
|
||||||
|
fingerprint,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
if (!inserted.rows[0]) {
|
||||||
|
// 冲突插入会等待首个事务结束;随后行锁确保只能读取完整的持久化结果。
|
||||||
|
const existing = await transaction.query<IdempotencyRow>(
|
||||||
|
`SELECT request_fingerprint, status, response_status, response_body
|
||||||
|
FROM atlas.idempotency_requests
|
||||||
|
WHERE workspace_id = $1 AND user_id = $2 AND http_method = $3
|
||||||
|
AND request_path = $4 AND idempotency_key = $5
|
||||||
|
FOR UPDATE`,
|
||||||
|
[
|
||||||
|
input.workspaceId,
|
||||||
|
input.userId,
|
||||||
|
input.method,
|
||||||
|
input.path,
|
||||||
|
input.key,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
const row = existing.rows[0];
|
||||||
|
if (!row || row.request_fingerprint !== fingerprint) {
|
||||||
|
throw new ApiError(
|
||||||
|
409,
|
||||||
|
'IDEMPOTENCY_KEY_REUSED',
|
||||||
|
'幂等键已用于不同请求',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (row.status !== 'completed' || !row.response_status) {
|
||||||
|
throw new ApiError(
|
||||||
|
409,
|
||||||
|
'IDEMPOTENCY_REQUEST_IN_PROGRESS',
|
||||||
|
'相同请求仍在处理中',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
status: row.response_status,
|
||||||
|
body: row.response_body,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await work(transaction);
|
||||||
|
await transaction.query(
|
||||||
|
`UPDATE atlas.idempotency_requests
|
||||||
|
SET status = 'completed', response_status = $1, response_body = $2,
|
||||||
|
completed_at = now()
|
||||||
|
WHERE workspace_id = $3 AND user_id = $4 AND http_method = $5
|
||||||
|
AND request_path = $6 AND idempotency_key = $7`,
|
||||||
|
[
|
||||||
|
response.status,
|
||||||
|
response.body,
|
||||||
|
input.workspaceId,
|
||||||
|
input.userId,
|
||||||
|
input.method,
|
||||||
|
input.path,
|
||||||
|
input.key,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
return response;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 规范化排序后再签名,使 JSON 字段顺序不会改变同一请求的身份。 */
|
||||||
|
function canonicalize(value: unknown): string {
|
||||||
|
if (value === null || typeof value !== 'object')
|
||||||
|
return JSON.stringify(value);
|
||||||
|
if (Array.isArray(value)) return `[${value.map(canonicalize).join(',')}]`;
|
||||||
|
const record = value as Record<string, unknown>;
|
||||||
|
return `{${Object.keys(record)
|
||||||
|
.sort()
|
||||||
|
.map((key) => `${JSON.stringify(key)}:${canonicalize(record[key])}`)
|
||||||
|
.join(',')}}`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { SetMetadata } from '@nestjs/common';
|
||||||
|
|
||||||
|
export const IS_PUBLIC_ENDPOINT = 'atlas:is-public-endpoint';
|
||||||
|
|
||||||
|
// 全局认证守卫只跳过显式标记的端点,避免新接口因遗漏守卫而意外公开。
|
||||||
|
export const Public = () => SetMetadata(IS_PUBLIC_ENDPOINT, true);
|
||||||
@@ -19,8 +19,7 @@ function findEnvironmentFile(): string {
|
|||||||
return environmentFile ?? resolve(process.cwd(), '.env');
|
return environmentFile ?? resolve(process.cwd(), '.env');
|
||||||
}
|
}
|
||||||
|
|
||||||
// This schema is the only supported source for API runtime settings. Defaults are
|
// 此 schema 是 API 运行配置的唯一来源;默认值只允许用于不含秘密的本地开发场景。
|
||||||
// intentionally limited to non-secret local-development values.
|
|
||||||
const environmentSchema = z.object({
|
const environmentSchema = z.object({
|
||||||
NODE_ENV: z
|
NODE_ENV: z
|
||||||
.enum(['development', 'test', 'production'])
|
.enum(['development', 'test', 'production'])
|
||||||
@@ -31,6 +30,13 @@ const environmentSchema = z.object({
|
|||||||
REDIS_URL: z.url(),
|
REDIS_URL: z.url(),
|
||||||
AUTH_JWT_SECRET: z.string().min(32),
|
AUTH_JWT_SECRET: z.string().min(32),
|
||||||
AUTH_RATE_LIMIT_KEY_SECRET: z.string().min(32),
|
AUTH_RATE_LIMIT_KEY_SECRET: z.string().min(32),
|
||||||
|
STORE_SECRET_ENCRYPTION_KEY: z.string().min(32),
|
||||||
|
STORE_SECRET_ENCRYPTION_KEY_VERSION: z
|
||||||
|
.string()
|
||||||
|
.min(1)
|
||||||
|
.max(64)
|
||||||
|
.default('v1'),
|
||||||
|
IDEMPOTENCY_KEY_SECRET: z.string().min(32),
|
||||||
AUTH_PASSWORD_MIN_LENGTH: z.coerce
|
AUTH_PASSWORD_MIN_LENGTH: z.coerce
|
||||||
.number()
|
.number()
|
||||||
.int()
|
.int()
|
||||||
@@ -83,7 +89,7 @@ function validateEnvironment(
|
|||||||
): Environment {
|
): Environment {
|
||||||
const result = environmentSchema.safeParse(environment);
|
const result = environmentSchema.safeParse(environment);
|
||||||
if (result.success) return result.data;
|
if (result.success) return result.data;
|
||||||
// Fail before Nest opens a listener; a half-configured API is unsafe to run.
|
// 在 Nest 开始监听前失败,避免半配置 API 对外提供不安全服务。
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Invalid environment configuration: ${result.error.issues.map((issue) => issue.path.join('.')).join(', ')}`,
|
`Invalid environment configuration: ${result.error.issues.map((issue) => issue.path.join('.')).join(', ')}`,
|
||||||
);
|
);
|
||||||
@@ -92,7 +98,7 @@ function validateEnvironment(
|
|||||||
function createAppConfig(environment: Environment): AppConfig {
|
function createAppConfig(environment: Environment): AppConfig {
|
||||||
return {
|
return {
|
||||||
...environment,
|
...environment,
|
||||||
// Express only trusts forwarding headers when this explicit allowlist is non-empty.
|
// 只有显式给出代理网段时才信任转发头,避免客户端伪造来源 IP。
|
||||||
trustedProxyCidrs: environment.TRUSTED_PROXY_CIDRS.split(',')
|
trustedProxyCidrs: environment.TRUSTED_PROXY_CIDRS.split(',')
|
||||||
.map((value) => value.trim())
|
.map((value) => value.trim())
|
||||||
.filter(Boolean),
|
.filter(Boolean),
|
||||||
@@ -106,15 +112,14 @@ function createAppConfig(environment: Environment): AppConfig {
|
|||||||
ConfigModule.forRoot({
|
ConfigModule.forRoot({
|
||||||
isGlobal: true,
|
isGlobal: true,
|
||||||
cache: true,
|
cache: true,
|
||||||
// Turbo runs this workspace's scripts from apps/api, while the
|
// Turbo 从 apps/api 运行脚本,但共享开发环境文件位于仓库根目录。
|
||||||
// shared development environment lives at the repository root.
|
|
||||||
envFilePath: findEnvironmentFile(),
|
envFilePath: findEnvironmentFile(),
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
providers: [
|
providers: [
|
||||||
{
|
{
|
||||||
provide: APP_CONFIG,
|
provide: APP_CONFIG,
|
||||||
// Export a normalized immutable config object so services never read process.env directly.
|
// 导出规范化配置,业务服务不得散落读取 process.env。
|
||||||
useFactory: (): AppConfig =>
|
useFactory: (): AppConfig =>
|
||||||
createAppConfig(validateEnvironment(process.env)),
|
createAppConfig(validateEnvironment(process.env)),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ export const DATABASE = Symbol('DATABASE');
|
|||||||
{
|
{
|
||||||
provide: DATABASE,
|
provide: DATABASE,
|
||||||
inject: [APP_CONFIG],
|
inject: [APP_CONFIG],
|
||||||
|
// 业务代码只能拿到受限应用连接,迁移连接串绝不注入 API 容器。
|
||||||
useFactory: (config: AppConfig): Database =>
|
useFactory: (config: AppConfig): Database =>
|
||||||
createApplicationDatabase(config.DATABASE_URL),
|
createApplicationDatabase(config.DATABASE_URL),
|
||||||
},
|
},
|
||||||
@@ -19,6 +20,7 @@ export const DATABASE = Symbol('DATABASE');
|
|||||||
export class DatabaseModule implements OnModuleDestroy {
|
export class DatabaseModule implements OnModuleDestroy {
|
||||||
public constructor(@Inject(DATABASE) private readonly database: Database) {}
|
public constructor(@Inject(DATABASE) private readonly database: Database) {}
|
||||||
public async onModuleDestroy() {
|
public async onModuleDestroy() {
|
||||||
|
// Nest 关闭时主动释放连接池,避免测试和热重启遗留连接。
|
||||||
await this.database.close();
|
await this.database.close();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ const severity: Record<LogLevel, number> = {
|
|||||||
log: 2,
|
log: 2,
|
||||||
debug: 3,
|
debug: 3,
|
||||||
};
|
};
|
||||||
|
// 按字段名递归脱敏,日志中不得出现会话、密码、密钥或授权头原文。
|
||||||
const sensitiveKeys = /authorization|cookie|password|secret|token|api[_-]?key/i;
|
const sensitiveKeys = /authorization|cookie|password|secret|token|api[_-]?key/i;
|
||||||
|
|
||||||
export class AppLogger implements LoggerService {
|
export class AppLogger implements LoggerService {
|
||||||
@@ -40,6 +41,7 @@ export class AppLogger implements LoggerService {
|
|||||||
message: typeof message === 'string' ? message : sanitize(message),
|
message: typeof message === 'string' ? message : sanitize(message),
|
||||||
context: sanitize(context),
|
context: sanitize(context),
|
||||||
};
|
};
|
||||||
|
// 每行独立 JSON,方便日志采集系统按字段索引 request_id、状态码等上下文。
|
||||||
const output = JSON.stringify(entry);
|
const output = JSON.stringify(entry);
|
||||||
if (level === 'error') console.error(output);
|
if (level === 'error') console.error(output);
|
||||||
else console.log(output);
|
else console.log(output);
|
||||||
|
|||||||
+38
-45
@@ -1,6 +1,7 @@
|
|||||||
import 'reflect-metadata';
|
import 'reflect-metadata';
|
||||||
import { execFile } from 'node:child_process';
|
|
||||||
import { randomUUID } from 'node:crypto';
|
import { randomUUID } from 'node:crypto';
|
||||||
|
import { resolve } from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
import { NestFactory } from '@nestjs/core';
|
import { NestFactory } from '@nestjs/core';
|
||||||
import { NestExpressApplication } from '@nestjs/platform-express';
|
import { NestExpressApplication } from '@nestjs/platform-express';
|
||||||
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
|
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
|
||||||
@@ -16,31 +17,10 @@ type RequestWithId = Request & {
|
|||||||
requestId?: string;
|
requestId?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
function openSwaggerInDevelopment(config: AppConfig, logger: AppLogger): void {
|
export async function createApplication() {
|
||||||
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,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function bootstrap() {
|
|
||||||
const app = await NestFactory.create<NestExpressApplication>(AppModule, {
|
const app = await NestFactory.create<NestExpressApplication>(AppModule, {
|
||||||
bodyParser: false,
|
bodyParser: false,
|
||||||
// Keep Nest's bootstrap errors visible until AppLogger is installed below.
|
// 自定义日志器安装前保留 Nest 启动日志,避免配置错误没有可见诊断。
|
||||||
logger: ['error', 'warn', 'log'],
|
logger: ['error', 'warn', 'log'],
|
||||||
});
|
});
|
||||||
const config = app.get<AppConfig>(APP_CONFIG);
|
const config = app.get<AppConfig>(APP_CONFIG);
|
||||||
@@ -48,7 +28,7 @@ async function bootstrap() {
|
|||||||
app.useLogger(logger);
|
app.useLogger(logger);
|
||||||
|
|
||||||
if (config.trustedProxyCidrs.length > 0) {
|
if (config.trustedProxyCidrs.length > 0) {
|
||||||
// Never trust forwarded headers unless the deployment names its proxy networks explicitly.
|
// 仅在部署明确声明代理网段时信任转发头,避免伪造客户端 IP。
|
||||||
app.getHttpAdapter()
|
app.getHttpAdapter()
|
||||||
.getInstance()
|
.getInstance()
|
||||||
.set('trust proxy', config.trustedProxyCidrs);
|
.set('trust proxy', config.trustedProxyCidrs);
|
||||||
@@ -68,6 +48,7 @@ async function bootstrap() {
|
|||||||
],
|
],
|
||||||
exposedHeaders: ['X-Request-Id'],
|
exposedHeaders: ['X-Request-Id'],
|
||||||
});
|
});
|
||||||
|
// Helmet、body 限制、请求 ID 与全局错误过滤器必须在业务路由前注册。
|
||||||
app.use(helmet({ contentSecurityPolicy: false }));
|
app.use(helmet({ contentSecurityPolicy: false }));
|
||||||
app.useBodyParser('json', { limit: config.API_BODY_LIMIT_BYTES });
|
app.useBodyParser('json', { limit: config.API_BODY_LIMIT_BYTES });
|
||||||
app.useBodyParser('urlencoded', {
|
app.useBodyParser('urlencoded', {
|
||||||
@@ -79,6 +60,7 @@ async function bootstrap() {
|
|||||||
const typedRequest = request;
|
const typedRequest = request;
|
||||||
const typedResponse = response;
|
const typedResponse = response;
|
||||||
const suppliedId = typedRequest.headers['x-request-id'];
|
const suppliedId = typedRequest.headers['x-request-id'];
|
||||||
|
// 合法调用方提供的 request ID 可贯穿上游链路;其他情况由服务端生成。
|
||||||
const requestId =
|
const requestId =
|
||||||
typeof suppliedId === 'string' &&
|
typeof suppliedId === 'string' &&
|
||||||
/^[A-Za-z0-9_-]{8,128}$/.test(suppliedId)
|
/^[A-Za-z0-9_-]{8,128}$/.test(suppliedId)
|
||||||
@@ -104,6 +86,7 @@ async function bootstrap() {
|
|||||||
app.enableShutdownHooks();
|
app.enableShutdownHooks();
|
||||||
|
|
||||||
if (config.swaggerEnabled) {
|
if (config.swaggerEnabled) {
|
||||||
|
// Swagger 仅描述运行时已注册路由;冻结接口契约仍以 docs/api 为准。
|
||||||
const swaggerConfig = new DocumentBuilder()
|
const swaggerConfig = new DocumentBuilder()
|
||||||
.setTitle('Atlas Seller Kit API')
|
.setTitle('Atlas Seller Kit API')
|
||||||
.setDescription('Atlas 卖家工作台 HTTP API 文档')
|
.setDescription('Atlas 卖家工作台 HTTP API 文档')
|
||||||
@@ -122,30 +105,40 @@ async function bootstrap() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return { app, config, logger };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function bootstrap() {
|
||||||
|
const { app, config, logger } = await createApplication();
|
||||||
await app.listen(config.API_PORT);
|
await app.listen(config.API_PORT);
|
||||||
logger.log('api_started', {
|
logger.log('api_started', {
|
||||||
port: config.API_PORT,
|
port: config.API_PORT,
|
||||||
swagger_enabled: config.swaggerEnabled,
|
swagger_enabled: config.swaggerEnabled,
|
||||||
});
|
});
|
||||||
openSwaggerInDevelopment(config, logger);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void bootstrap().catch((error: unknown) => {
|
// 测试复用应用工厂时不自动监听固定端口,只有直接运行入口才启动服务。
|
||||||
const details =
|
if (
|
||||||
error instanceof Error
|
process.argv[1] &&
|
||||||
? {
|
resolve(process.argv[1]) === fileURLToPath(import.meta.url)
|
||||||
name: error.name,
|
) {
|
||||||
message: error.message,
|
void bootstrap().catch((error: unknown) => {
|
||||||
stack: error.stack,
|
const details =
|
||||||
}
|
error instanceof Error
|
||||||
: error;
|
? {
|
||||||
console.error(
|
name: error.name,
|
||||||
JSON.stringify({
|
message: error.message,
|
||||||
timestamp: new Date().toISOString(),
|
stack: error.stack,
|
||||||
level: 'error',
|
}
|
||||||
message: 'api_startup_failed',
|
: error;
|
||||||
error: details,
|
console.error(
|
||||||
}),
|
JSON.stringify({
|
||||||
);
|
timestamp: new Date().toISOString(),
|
||||||
process.exitCode = 1;
|
level: 'error',
|
||||||
});
|
message: 'api_startup_failed',
|
||||||
|
error: details,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
process.exitCode = 1;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,4 +1,9 @@
|
|||||||
import { Inject, Injectable, OnModuleDestroy } from '@nestjs/common';
|
import {
|
||||||
|
Inject,
|
||||||
|
Injectable,
|
||||||
|
OnModuleDestroy,
|
||||||
|
OnModuleInit,
|
||||||
|
} from '@nestjs/common';
|
||||||
import { createHmac } from 'node:crypto';
|
import { createHmac } from 'node:crypto';
|
||||||
import { Redis } from 'ioredis';
|
import { Redis } from 'ioredis';
|
||||||
import { ApiError } from '../common/api-error.js';
|
import { ApiError } from '../common/api-error.js';
|
||||||
@@ -8,17 +13,23 @@ type AuthAction = 'login' | 'register' | 'refresh';
|
|||||||
type Limit = { ip: number; email?: number };
|
type Limit = { ip: number; email?: number };
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class AuthRateLimiter implements OnModuleDestroy {
|
export class AuthRateLimiter implements OnModuleInit, OnModuleDestroy {
|
||||||
private readonly redis: Redis;
|
private readonly redis: Redis;
|
||||||
|
|
||||||
public constructor(@Inject(APP_CONFIG) private readonly config: AppConfig) {
|
public constructor(@Inject(APP_CONFIG) private readonly config: AppConfig) {
|
||||||
// Redis makes counters shared across API processes instead of resetting per instance.
|
// Redis 让限流计数在多个 API 实例间共享,不会随单个实例重启清零。
|
||||||
this.redis = new Redis(config.REDIS_URL, {
|
this.redis = new Redis(config.REDIS_URL, {
|
||||||
enableOfflineQueue: false,
|
enableOfflineQueue: false,
|
||||||
|
lazyConnect: true,
|
||||||
maxRetriesPerRequest: 1,
|
maxRetriesPerRequest: 1,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async onModuleInit(): Promise<void> {
|
||||||
|
// 在开始接收认证请求前完成连接,避免首个请求命中 Redis 连接竞态而被误判为 503。
|
||||||
|
await this.redis.connect();
|
||||||
|
}
|
||||||
|
|
||||||
public async consume(
|
public async consume(
|
||||||
action: AuthAction,
|
action: AuthAction,
|
||||||
identifiers: { ip: string | null; email?: string },
|
identifiers: { ip: string | null; email?: string },
|
||||||
@@ -41,6 +52,10 @@ export class AuthRateLimiter implements OnModuleDestroy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public async onModuleDestroy(): Promise<void> {
|
public async onModuleDestroy(): Promise<void> {
|
||||||
|
if (this.redis.status !== 'ready') {
|
||||||
|
this.redis.disconnect();
|
||||||
|
return;
|
||||||
|
}
|
||||||
await this.redis.quit();
|
await this.redis.quit();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -56,10 +71,10 @@ export class AuthRateLimiter implements OnModuleDestroy {
|
|||||||
)
|
)
|
||||||
.update(identifier)
|
.update(identifier)
|
||||||
.digest('base64url');
|
.digest('base64url');
|
||||||
// The raw IP/email never becomes part of a Redis key or a loggable identifier.
|
// Redis key 只保存 IP/邮箱的 HMAC,不暴露可被日志或运维工具读取的原文。
|
||||||
const key = `atlas:auth-rate-limit:${action}:${dimension}:${digest}`;
|
const key = `atlas:auth-rate-limit:${action}:${dimension}:${digest}`;
|
||||||
try {
|
try {
|
||||||
// INCR + first-write EXPIRE must be atomic or concurrent requests could leave a key unexpired.
|
// INCR 与首次 EXPIRE 必须原子执行,避免并发下产生永不过期的限流 key。
|
||||||
const count = await this.redis.eval(
|
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;",
|
"local value = redis.call('INCR', KEYS[1]); if value == 1 then redis.call('EXPIRE', KEYS[1], ARGV[1]); end; return value;",
|
||||||
1,
|
1,
|
||||||
@@ -75,7 +90,7 @@ export class AuthRateLimiter implements OnModuleDestroy {
|
|||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof ApiError) throw error;
|
if (error instanceof ApiError) throw error;
|
||||||
// Authentication fails closed: silently bypassing a broken shared limiter enables brute-force attacks.
|
// 认证限流故障时必须拒绝请求;静默跳过会扩大暴力破解风险。
|
||||||
throw new ApiError(
|
throw new ApiError(
|
||||||
503,
|
503,
|
||||||
'AUTH_RATE_LIMIT_UNAVAILABLE',
|
'AUTH_RATE_LIMIT_UNAVAILABLE',
|
||||||
|
|||||||
@@ -0,0 +1,125 @@
|
|||||||
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
|
import type { Database, DatabaseTransaction } from '@atlas/database';
|
||||||
|
import { ApiError } from '../common/api-error.js';
|
||||||
|
import { DATABASE } from '../database/database.module.js';
|
||||||
|
|
||||||
|
type PlatformRow = {
|
||||||
|
id: string;
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
capabilities: Record<string, unknown>;
|
||||||
|
supported_currencies: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class PlatformsService {
|
||||||
|
public constructor(@Inject(DATABASE) private readonly database: Database) {}
|
||||||
|
|
||||||
|
public async list() {
|
||||||
|
const rows = await this.database.query<PlatformRow>(
|
||||||
|
`SELECT p.id, p.code, p.name, p.capabilities,
|
||||||
|
coalesce(array_agg(pcs.currency_code ORDER BY pcs.currency_code)
|
||||||
|
FILTER (WHERE pcs.is_enabled AND c.is_enabled), ARRAY[]::char(3)[]) AS supported_currencies
|
||||||
|
FROM atlas.platforms p
|
||||||
|
LEFT JOIN atlas.platform_currency_support pcs ON pcs.platform_id = p.id
|
||||||
|
LEFT JOIN atlas.currencies c ON c.code = pcs.currency_code
|
||||||
|
WHERE p.status = 'active'
|
||||||
|
GROUP BY p.id
|
||||||
|
ORDER BY CASE WHEN p.code = 'OZON' THEN 0 ELSE 1 END, p.name`,
|
||||||
|
);
|
||||||
|
return rows.rows.map((platform) => ({
|
||||||
|
id: platform.id,
|
||||||
|
code: platform.code,
|
||||||
|
name: platform.name,
|
||||||
|
supported_currencies: platform.supported_currencies,
|
||||||
|
// 能力未配置时使用 null,而不是把 Ozon 的市场或语言假设扩散到通用模型。
|
||||||
|
default_market_code: stringCapability(
|
||||||
|
platform.capabilities,
|
||||||
|
'default_market_code',
|
||||||
|
),
|
||||||
|
default_locale: stringCapability(
|
||||||
|
platform.capabilities,
|
||||||
|
'default_locale',
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
public async context(workspaceId: string, platformId: string) {
|
||||||
|
return this.database.workspaceTransaction(
|
||||||
|
workspaceId,
|
||||||
|
async (transaction) => {
|
||||||
|
const platform = await this.requireActivePlatform(
|
||||||
|
transaction,
|
||||||
|
platformId,
|
||||||
|
);
|
||||||
|
const stores = await transaction.query<{
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
status: string;
|
||||||
|
credential_status: string;
|
||||||
|
count: string;
|
||||||
|
}>(
|
||||||
|
`SELECT s.id, s.name, s.status, s.credential_status,
|
||||||
|
count(*) OVER ()::text AS count
|
||||||
|
FROM atlas.stores s
|
||||||
|
WHERE s.platform_id = $1 AND s.deleted_at IS NULL AND s.is_default
|
||||||
|
LIMIT 1`,
|
||||||
|
[platformId],
|
||||||
|
);
|
||||||
|
// 默认门店不存在时仍要正确返回总数,不能用默认行的窗口函数猜测。
|
||||||
|
const count = await transaction.query<{ count: string }>(
|
||||||
|
`SELECT count(*)::text AS count FROM atlas.stores
|
||||||
|
WHERE platform_id = $1 AND deleted_at IS NULL`,
|
||||||
|
[platformId],
|
||||||
|
);
|
||||||
|
const defaultStore = stores.rows[0];
|
||||||
|
return {
|
||||||
|
platform: {
|
||||||
|
id: platform.id,
|
||||||
|
code: platform.code,
|
||||||
|
name: platform.name,
|
||||||
|
},
|
||||||
|
default_store: defaultStore
|
||||||
|
? {
|
||||||
|
id: defaultStore.id,
|
||||||
|
name: defaultStore.name,
|
||||||
|
status: defaultStore.status,
|
||||||
|
credential_status: defaultStore.credential_status,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
store_count: Number(count.rows[0]?.count ?? 0),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async requireActivePlatform(
|
||||||
|
transaction: DatabaseTransaction,
|
||||||
|
platformId: string,
|
||||||
|
) {
|
||||||
|
const rows = await transaction.query<{
|
||||||
|
id: string;
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
}>(
|
||||||
|
`SELECT id, code, name FROM atlas.platforms WHERE id = $1 AND status = 'active'`,
|
||||||
|
[platformId],
|
||||||
|
);
|
||||||
|
if (!rows.rows[0]) {
|
||||||
|
throw new ApiError(
|
||||||
|
422,
|
||||||
|
'PLATFORM_UNAVAILABLE',
|
||||||
|
'平台不存在或已停用',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return rows.rows[0];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function stringCapability(
|
||||||
|
capabilities: Record<string, unknown>,
|
||||||
|
key: string,
|
||||||
|
): string | null {
|
||||||
|
const value = capabilities[key];
|
||||||
|
return typeof value === 'string' ? value : null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import {
|
||||||
|
createCipheriv,
|
||||||
|
createHash,
|
||||||
|
createHmac,
|
||||||
|
randomBytes,
|
||||||
|
} from 'node:crypto';
|
||||||
|
|
||||||
|
export type EncryptedStoreSecret = {
|
||||||
|
ciphertext: Buffer;
|
||||||
|
nonce: Buffer;
|
||||||
|
fingerprint: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 密钥只在此边界短暂以明文存在。数据库保存 AES-GCM 密文和随机 nonce,
|
||||||
|
* fingerprint 是不可逆 HMAC,仅用于安全地识别同一份密钥。
|
||||||
|
*/
|
||||||
|
export function encryptStoreSecret(
|
||||||
|
plaintext: string,
|
||||||
|
keyMaterial: string,
|
||||||
|
): EncryptedStoreSecret {
|
||||||
|
const key = createHash('sha256').update(keyMaterial).digest();
|
||||||
|
const nonce = randomBytes(12);
|
||||||
|
const cipher = createCipheriv('aes-256-gcm', key, nonce);
|
||||||
|
const ciphertext = Buffer.concat([
|
||||||
|
cipher.update(plaintext, 'utf8'),
|
||||||
|
cipher.final(),
|
||||||
|
cipher.getAuthTag(),
|
||||||
|
]);
|
||||||
|
return {
|
||||||
|
ciphertext,
|
||||||
|
nonce,
|
||||||
|
fingerprint: createHmac('sha256', key)
|
||||||
|
.update(plaintext)
|
||||||
|
.digest('base64url'),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,331 @@
|
|||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Delete,
|
||||||
|
Get,
|
||||||
|
Headers,
|
||||||
|
HttpCode,
|
||||||
|
Param,
|
||||||
|
Patch,
|
||||||
|
Post,
|
||||||
|
Query,
|
||||||
|
Req,
|
||||||
|
Res,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import {
|
||||||
|
ApiAcceptedResponse,
|
||||||
|
ApiBearerAuth,
|
||||||
|
ApiNoContentResponse,
|
||||||
|
ApiOkResponse,
|
||||||
|
ApiOperation,
|
||||||
|
ApiTags,
|
||||||
|
} from '@nestjs/swagger';
|
||||||
|
import {
|
||||||
|
createStoreRequestSchema,
|
||||||
|
listStoresQuerySchema,
|
||||||
|
updateStoreRequestSchema,
|
||||||
|
} from '@atlas/contracts';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { ApiError } from '../common/api-error.js';
|
||||||
|
import type { AuthenticatedRequest } from '../auth/auth.guard.js';
|
||||||
|
import { CurrentWorkspace } from '../workspace/current-workspace.decorator.js';
|
||||||
|
import type { Workspace } from '@atlas/contracts';
|
||||||
|
import { PlatformsService } from './platforms.service.js';
|
||||||
|
import { StoresService } from './stores.service.js';
|
||||||
|
|
||||||
|
type HttpResponse = { status: (status: number) => HttpResponse };
|
||||||
|
|
||||||
|
@ApiTags('Platforms')
|
||||||
|
@ApiBearerAuth('access-token')
|
||||||
|
@Controller()
|
||||||
|
export class PlatformsController {
|
||||||
|
public constructor(private readonly platformsService: PlatformsService) {}
|
||||||
|
|
||||||
|
@Get('platforms')
|
||||||
|
@ApiOperation({ summary: '获取已启用的平台及非敏感能力目录' })
|
||||||
|
@ApiOkResponse({ description: '平台目录' })
|
||||||
|
public async list() {
|
||||||
|
return { data: await this.platformsService.list() };
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('platform-context')
|
||||||
|
@ApiOperation({ summary: '获取页头所选平台的默认门店上下文' })
|
||||||
|
@ApiOkResponse({ description: '平台、默认门店和门店总数' })
|
||||||
|
public async context(
|
||||||
|
@Headers('x-atlas-platform-id') platformId: string | undefined,
|
||||||
|
@CurrentWorkspace() workspace: Workspace,
|
||||||
|
) {
|
||||||
|
if (!platformId) {
|
||||||
|
throw new ApiError(
|
||||||
|
400,
|
||||||
|
'PLATFORM_CONTEXT_REQUIRED',
|
||||||
|
'请求缺少 X-Atlas-Platform-Id',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
data: await this.platformsService.context(
|
||||||
|
workspace.id,
|
||||||
|
requireUuid('X-Atlas-Platform-Id', platformId),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ApiTags('Stores')
|
||||||
|
@ApiBearerAuth('access-token')
|
||||||
|
@Controller('stores')
|
||||||
|
export class StoresController {
|
||||||
|
public constructor(private readonly storesService: StoresService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
@ApiOperation({ summary: '按平台分页列出当前工作空间的门店' })
|
||||||
|
@ApiOkResponse({ description: '门店列表与不透明游标' })
|
||||||
|
public async list(
|
||||||
|
@Query() query: unknown,
|
||||||
|
@Headers('x-atlas-platform-id') platformHeader: string | undefined,
|
||||||
|
@CurrentWorkspace() workspace: Workspace,
|
||||||
|
) {
|
||||||
|
if (platformHeader) {
|
||||||
|
throw new ApiError(
|
||||||
|
400,
|
||||||
|
'PLATFORM_CONTEXT_CONFLICT',
|
||||||
|
'门店管理列表不能同时使用平台 header 和 platform_id 查询参数',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return this.storesService.list(
|
||||||
|
workspace.id,
|
||||||
|
parse(listStoresQuerySchema, query),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':storeId')
|
||||||
|
@ApiOperation({ summary: '读取单个门店' })
|
||||||
|
public async get(
|
||||||
|
@Param('storeId') storeId: string,
|
||||||
|
@CurrentWorkspace() workspace: Workspace,
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
data: await this.storesService.get(
|
||||||
|
workspace.id,
|
||||||
|
requireUuid('store_id', storeId),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
@ApiOperation({ summary: '创建门店连接,并请求异步凭据验证' })
|
||||||
|
@ApiAcceptedResponse({ description: '已加密保存凭据,并创建验证操作' })
|
||||||
|
public async create(
|
||||||
|
@Body() body: unknown,
|
||||||
|
@Headers('idempotency-key') idempotencyKey: string | undefined,
|
||||||
|
@Req() request: AuthenticatedRequest,
|
||||||
|
@CurrentWorkspace() workspace: Workspace,
|
||||||
|
@Res({ passthrough: true }) response: HttpResponse,
|
||||||
|
) {
|
||||||
|
return withStatus(
|
||||||
|
response,
|
||||||
|
await this.storesService.create(
|
||||||
|
workspace.id,
|
||||||
|
request.auth.userId,
|
||||||
|
requireIdempotencyKey(idempotencyKey),
|
||||||
|
parse(createStoreRequestSchema, body),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch(':storeId')
|
||||||
|
@ApiOperation({ summary: '按版本编辑门店;更新密钥会重新请求验证' })
|
||||||
|
@ApiOkResponse({ description: '普通字段已更新' })
|
||||||
|
@ApiAcceptedResponse({ description: '新密钥已保存,并创建验证操作' })
|
||||||
|
public async update(
|
||||||
|
@Param('storeId') storeId: string,
|
||||||
|
@Body() body: unknown,
|
||||||
|
@Headers('idempotency-key') idempotencyKey: string | undefined,
|
||||||
|
@Headers('if-match') ifMatch: string | undefined,
|
||||||
|
@Req() request: AuthenticatedRequest,
|
||||||
|
@CurrentWorkspace() workspace: Workspace,
|
||||||
|
@Res({ passthrough: true }) response: HttpResponse,
|
||||||
|
) {
|
||||||
|
return withStatus(
|
||||||
|
response,
|
||||||
|
await this.storesService.update(
|
||||||
|
workspace.id,
|
||||||
|
request.auth.userId,
|
||||||
|
requireUuid('store_id', storeId),
|
||||||
|
requireIdempotencyKey(idempotencyKey),
|
||||||
|
parseRevision(ifMatch),
|
||||||
|
parse(updateStoreRequestSchema, body),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':storeId/set-default')
|
||||||
|
@ApiOperation({ summary: '设为当前平台默认门店' })
|
||||||
|
public async setDefault(
|
||||||
|
@Param('storeId') storeId: string,
|
||||||
|
@Headers('idempotency-key') idempotencyKey: string | undefined,
|
||||||
|
@Req() request: AuthenticatedRequest,
|
||||||
|
@CurrentWorkspace() workspace: Workspace,
|
||||||
|
@Res({ passthrough: true }) response: HttpResponse,
|
||||||
|
) {
|
||||||
|
return withStatus(
|
||||||
|
response,
|
||||||
|
await this.storesService.setDefault(
|
||||||
|
workspace.id,
|
||||||
|
request.auth.userId,
|
||||||
|
requireUuid('store_id', storeId),
|
||||||
|
requireIdempotencyKey(idempotencyKey),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':storeId/credential-verifications')
|
||||||
|
@ApiOperation({ summary: '手动请求重新验证门店凭据' })
|
||||||
|
@ApiAcceptedResponse({ description: '凭据验证操作已入队' })
|
||||||
|
public async credentialVerification(
|
||||||
|
@Param('storeId') storeId: string,
|
||||||
|
@Headers('idempotency-key') idempotencyKey: string | undefined,
|
||||||
|
@Req() request: AuthenticatedRequest,
|
||||||
|
@CurrentWorkspace() workspace: Workspace,
|
||||||
|
@Res({ passthrough: true }) response: HttpResponse,
|
||||||
|
) {
|
||||||
|
return withStatus(
|
||||||
|
response,
|
||||||
|
await this.storesService.requestCredentialVerification(
|
||||||
|
workspace.id,
|
||||||
|
request.auth.userId,
|
||||||
|
requireUuid('store_id', storeId),
|
||||||
|
requireIdempotencyKey(idempotencyKey),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':storeId/warehouse-syncs')
|
||||||
|
@ApiOperation({ summary: '请求异步同步门店仓库' })
|
||||||
|
@ApiAcceptedResponse({ description: '仓库同步操作已入队' })
|
||||||
|
public async warehouseSync(
|
||||||
|
@Param('storeId') storeId: string,
|
||||||
|
@Headers('idempotency-key') idempotencyKey: string | undefined,
|
||||||
|
@Req() request: AuthenticatedRequest,
|
||||||
|
@CurrentWorkspace() workspace: Workspace,
|
||||||
|
@Res({ passthrough: true }) response: HttpResponse,
|
||||||
|
) {
|
||||||
|
return withStatus(
|
||||||
|
response,
|
||||||
|
await this.storesService.requestWarehouseSync(
|
||||||
|
workspace.id,
|
||||||
|
request.auth.userId,
|
||||||
|
requireUuid('store_id', storeId),
|
||||||
|
requireIdempotencyKey(idempotencyKey),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':storeId/warehouses')
|
||||||
|
@ApiOperation({ summary: '读取最近成功同步的仓库摘要' })
|
||||||
|
public async warehouses(
|
||||||
|
@Param('storeId') storeId: string,
|
||||||
|
@CurrentWorkspace() workspace: Workspace,
|
||||||
|
) {
|
||||||
|
return this.storesService.warehouses(
|
||||||
|
workspace.id,
|
||||||
|
requireUuid('store_id', storeId),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete(':storeId')
|
||||||
|
@HttpCode(204)
|
||||||
|
@ApiOperation({ summary: '软删除门店连接并撤销当前密钥' })
|
||||||
|
@ApiNoContentResponse({ description: '门店已删除' })
|
||||||
|
public async remove(
|
||||||
|
@Param('storeId') storeId: string,
|
||||||
|
@Headers('idempotency-key') idempotencyKey: string | undefined,
|
||||||
|
@Req() request: AuthenticatedRequest,
|
||||||
|
@CurrentWorkspace() workspace: Workspace,
|
||||||
|
@Res({ passthrough: true }) response: HttpResponse,
|
||||||
|
) {
|
||||||
|
return withStatus(
|
||||||
|
response,
|
||||||
|
await this.storesService.remove(
|
||||||
|
workspace.id,
|
||||||
|
request.auth.userId,
|
||||||
|
requireUuid('store_id', storeId),
|
||||||
|
requireIdempotencyKey(idempotencyKey),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ApiTags('Operations')
|
||||||
|
@ApiBearerAuth('access-token')
|
||||||
|
@Controller('operations')
|
||||||
|
export class OperationsController {
|
||||||
|
public constructor(private readonly storesService: StoresService) {}
|
||||||
|
|
||||||
|
@Get(':operationId')
|
||||||
|
@ApiOperation({ summary: '读取当前工作空间的异步操作状态' })
|
||||||
|
public async get(
|
||||||
|
@Param('operationId') operationId: string,
|
||||||
|
@CurrentWorkspace() workspace: Workspace,
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
data: await this.storesService.operation(
|
||||||
|
workspace.id,
|
||||||
|
requireUuid('operation_id', operationId),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function withStatus(
|
||||||
|
response: HttpResponse,
|
||||||
|
result: { status: number; body: unknown },
|
||||||
|
) {
|
||||||
|
response.status(result.status);
|
||||||
|
return result.body;
|
||||||
|
}
|
||||||
|
|
||||||
|
function requireIdempotencyKey(value: string | undefined): string {
|
||||||
|
if (!value) {
|
||||||
|
throw new ApiError(
|
||||||
|
400,
|
||||||
|
'IDEMPOTENCY_KEY_REQUIRED',
|
||||||
|
'请求缺少 Idempotency-Key',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return requireUuid('Idempotency-Key', value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function requireUuid(field: string, value: string): string {
|
||||||
|
if (z.uuid().safeParse(value).success) return value;
|
||||||
|
throw new ApiError(422, 'VALIDATION_FAILED', '请求参数不符合要求', [
|
||||||
|
{ field, code: 'INVALID_FIELD', message: `${field} 必须是 UUID` },
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseRevision(value: string | undefined): number {
|
||||||
|
const match = value?.match(/^"([1-9]\d*)"$/);
|
||||||
|
if (!match) {
|
||||||
|
throw new ApiError(
|
||||||
|
400,
|
||||||
|
'IF_MATCH_REQUIRED',
|
||||||
|
'请求必须携带形如 "1" 的 If-Match',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return Number(match[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parse<T>(schema: z.ZodType<T>, value: unknown): T {
|
||||||
|
const result = schema.safeParse(value);
|
||||||
|
if (result.success) return result.data;
|
||||||
|
throw new ApiError(
|
||||||
|
422,
|
||||||
|
'VALIDATION_FAILED',
|
||||||
|
'请求参数不符合要求',
|
||||||
|
result.error.issues.map((issue) => ({
|
||||||
|
field: issue.path.join('.'),
|
||||||
|
code: 'INVALID_FIELD',
|
||||||
|
message: issue.message,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { IdempotencyService } from '../common/idempotency.service.js';
|
||||||
|
import {
|
||||||
|
OperationsController,
|
||||||
|
PlatformsController,
|
||||||
|
StoresController,
|
||||||
|
} from './stores.controller.js';
|
||||||
|
import { PlatformsService } from './platforms.service.js';
|
||||||
|
import { StoresService } from './stores.service.js';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [PlatformsController, StoresController, OperationsController],
|
||||||
|
providers: [IdempotencyService, PlatformsService, StoresService],
|
||||||
|
})
|
||||||
|
export class StoresModule {}
|
||||||
@@ -0,0 +1,729 @@
|
|||||||
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
|
import type {
|
||||||
|
CreateStoreRequest,
|
||||||
|
ListStoresQuery,
|
||||||
|
Operation,
|
||||||
|
Store,
|
||||||
|
UpdateStoreRequest,
|
||||||
|
} from '@atlas/contracts';
|
||||||
|
import type { Database, DatabaseTransaction } from '@atlas/database';
|
||||||
|
import { ApiError } from '../common/api-error.js';
|
||||||
|
import { IdempotencyService } from '../common/idempotency.service.js';
|
||||||
|
import { APP_CONFIG, type AppConfig } from '../config/app-config.module.js';
|
||||||
|
import { DATABASE } from '../database/database.module.js';
|
||||||
|
import { encryptStoreSecret } from './store-secret.crypto.js';
|
||||||
|
|
||||||
|
type StoreRow = Omit<Store, keyof { created_at: never; updated_at: never }> & {
|
||||||
|
credential_checked_at: Date | string | null;
|
||||||
|
api_key_expires_at: Date | string | null;
|
||||||
|
product_limit_checked_at: Date | string | null;
|
||||||
|
warehouses_synced_at: Date | string | null;
|
||||||
|
created_at: Date | string;
|
||||||
|
updated_at: Date | string;
|
||||||
|
};
|
||||||
|
type StoreLockRow = {
|
||||||
|
id: string;
|
||||||
|
platform_id: string;
|
||||||
|
revision: number;
|
||||||
|
is_default: boolean;
|
||||||
|
status: string;
|
||||||
|
};
|
||||||
|
type OperationRow = {
|
||||||
|
id: string;
|
||||||
|
kind: string;
|
||||||
|
status: string;
|
||||||
|
created_at: Date | string;
|
||||||
|
completed_at: Date | string | null;
|
||||||
|
error_code: string | null;
|
||||||
|
error_message: string | null;
|
||||||
|
};
|
||||||
|
type WarehouseRow = {
|
||||||
|
id: string;
|
||||||
|
platform_warehouse_id: string;
|
||||||
|
name: string;
|
||||||
|
fulfillment: string;
|
||||||
|
is_rfbs: boolean | null;
|
||||||
|
status: string;
|
||||||
|
synced_at: Date | string;
|
||||||
|
};
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class StoresService {
|
||||||
|
public constructor(
|
||||||
|
private readonly idempotency: IdempotencyService,
|
||||||
|
@Inject(DATABASE) private readonly database: Database,
|
||||||
|
@Inject(APP_CONFIG) private readonly config: AppConfig,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public async list(workspaceId: string, query: ListStoresQuery) {
|
||||||
|
const cursor = query.cursor ? decodeCursor(query.cursor) : null;
|
||||||
|
return this.database.workspaceTransaction(
|
||||||
|
workspaceId,
|
||||||
|
async (transaction: DatabaseTransaction) => {
|
||||||
|
const rows = await transaction.query<StoreRow>(
|
||||||
|
`${storeSelect}
|
||||||
|
WHERE s.platform_id = $1 AND s.deleted_at IS NULL
|
||||||
|
AND ($2::timestamptz IS NULL OR (s.created_at, s.id) < ($2::timestamptz, $3::uuid))
|
||||||
|
GROUP BY s.id, binding.value
|
||||||
|
ORDER BY s.created_at DESC, s.id DESC
|
||||||
|
LIMIT $4`,
|
||||||
|
[
|
||||||
|
query.platform_id,
|
||||||
|
cursor?.createdAt ?? null,
|
||||||
|
cursor?.id ?? null,
|
||||||
|
query.limit + 1,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
const hasMore = rows.rows.length > query.limit;
|
||||||
|
const items = rows.rows.slice(0, query.limit).map(toStore);
|
||||||
|
const last = items.at(-1);
|
||||||
|
return {
|
||||||
|
data: items,
|
||||||
|
meta: {
|
||||||
|
next_cursor:
|
||||||
|
hasMore && last
|
||||||
|
? encodeCursor(last.created_at, last.id)
|
||||||
|
: null,
|
||||||
|
has_more: hasMore,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async get(workspaceId: string, storeId: string): Promise<Store> {
|
||||||
|
return this.database.workspaceTransaction(
|
||||||
|
workspaceId,
|
||||||
|
async (transaction: DatabaseTransaction) =>
|
||||||
|
this.requireStore(transaction, storeId),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async create(
|
||||||
|
workspaceId: string,
|
||||||
|
userId: string,
|
||||||
|
idempotencyKey: string,
|
||||||
|
input: CreateStoreRequest,
|
||||||
|
) {
|
||||||
|
return this.idempotency.run(
|
||||||
|
{
|
||||||
|
workspaceId,
|
||||||
|
userId,
|
||||||
|
method: 'POST',
|
||||||
|
path: '/api/stores',
|
||||||
|
key: idempotencyKey,
|
||||||
|
request: input,
|
||||||
|
},
|
||||||
|
async (transaction) => {
|
||||||
|
await this.assertPlatformCurrency(
|
||||||
|
transaction,
|
||||||
|
input.platform_id,
|
||||||
|
input.currency_code,
|
||||||
|
);
|
||||||
|
// 同一平台的默认店铺修改串行化,避免两个并发创建都错误地判断自己是首店。
|
||||||
|
await lockPlatform(transaction, workspaceId, input.platform_id);
|
||||||
|
const existing = await transaction.query<{ id: string }>(
|
||||||
|
`SELECT id FROM atlas.stores
|
||||||
|
WHERE platform_id = $1 AND platform_client_id = $2
|
||||||
|
LIMIT 1`,
|
||||||
|
[input.platform_id, input.client_id],
|
||||||
|
);
|
||||||
|
if (existing.rows[0]) {
|
||||||
|
throw new ApiError(
|
||||||
|
409,
|
||||||
|
'STORE_CONNECTION_ALREADY_EXISTS',
|
||||||
|
'该平台账户已绑定为门店',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const currentStores = await transaction.query<{
|
||||||
|
count: string;
|
||||||
|
}>(
|
||||||
|
`SELECT count(*)::text AS count FROM atlas.stores
|
||||||
|
WHERE platform_id = $1 AND deleted_at IS NULL`,
|
||||||
|
[input.platform_id],
|
||||||
|
);
|
||||||
|
const becomesDefault =
|
||||||
|
Number(currentStores.rows[0]?.count ?? 0) === 0 ||
|
||||||
|
input.is_default;
|
||||||
|
if (becomesDefault) {
|
||||||
|
await transaction.query(
|
||||||
|
`UPDATE atlas.stores SET is_default = false, revision = revision + 1, updated_at = now()
|
||||||
|
WHERE platform_id = $1 AND is_default AND deleted_at IS NULL`,
|
||||||
|
[input.platform_id],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const created = await transaction.query<{ id: string }>(
|
||||||
|
`INSERT INTO atlas.stores
|
||||||
|
(workspace_id, platform_id, name, platform_client_id, currency_code, is_default, note)
|
||||||
|
VALUES (current_setting('app.workspace_id')::uuid, $1, $2, $3, $4, $5, $6)
|
||||||
|
RETURNING id`,
|
||||||
|
[
|
||||||
|
input.platform_id,
|
||||||
|
input.name,
|
||||||
|
input.client_id,
|
||||||
|
input.currency_code,
|
||||||
|
becomesDefault,
|
||||||
|
input.note ?? null,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
const storeId = created.rows[0].id;
|
||||||
|
await this.replaceApiKey(transaction, storeId, input.api_key);
|
||||||
|
const operation = await this.createOperation(
|
||||||
|
transaction,
|
||||||
|
userId,
|
||||||
|
storeId,
|
||||||
|
'store_credential_verification',
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
status: 202,
|
||||||
|
body: {
|
||||||
|
data: {
|
||||||
|
store: await this.requireStore(
|
||||||
|
transaction,
|
||||||
|
storeId,
|
||||||
|
),
|
||||||
|
operation,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async update(
|
||||||
|
workspaceId: string,
|
||||||
|
userId: string,
|
||||||
|
storeId: string,
|
||||||
|
idempotencyKey: string,
|
||||||
|
revision: number,
|
||||||
|
input: UpdateStoreRequest,
|
||||||
|
) {
|
||||||
|
return this.idempotency.run(
|
||||||
|
{
|
||||||
|
workspaceId,
|
||||||
|
userId,
|
||||||
|
method: 'PATCH',
|
||||||
|
path: `/api/stores/${storeId}`,
|
||||||
|
key: idempotencyKey,
|
||||||
|
request: { if_match: revision, ...input },
|
||||||
|
},
|
||||||
|
async (transaction) => {
|
||||||
|
const current = await this.lockStore(transaction, storeId);
|
||||||
|
this.assertRevision(current, revision);
|
||||||
|
if (input.currency_code) {
|
||||||
|
await this.assertPlatformCurrency(
|
||||||
|
transaction,
|
||||||
|
current.platform_id,
|
||||||
|
input.currency_code,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const replacingKey = input.api_key !== undefined;
|
||||||
|
await transaction.query(
|
||||||
|
`UPDATE atlas.stores
|
||||||
|
SET name = COALESCE($1, name), currency_code = COALESCE($2, currency_code),
|
||||||
|
note = CASE WHEN $3 THEN $4 ELSE note END,
|
||||||
|
status = CASE WHEN $5 THEN 'verifying'::atlas.store_status ELSE status END,
|
||||||
|
credential_status = CASE WHEN $5 THEN 'unknown'::atlas.credential_status ELSE credential_status END,
|
||||||
|
last_error_code = CASE WHEN $5 THEN NULL ELSE last_error_code END,
|
||||||
|
last_error_message = CASE WHEN $5 THEN NULL ELSE last_error_message END,
|
||||||
|
last_error_at = CASE WHEN $5 THEN NULL ELSE last_error_at END,
|
||||||
|
revision = revision + 1, updated_at = now()
|
||||||
|
WHERE id = $6`,
|
||||||
|
[
|
||||||
|
input.name ?? null,
|
||||||
|
input.currency_code ?? null,
|
||||||
|
Object.hasOwn(input, 'note'),
|
||||||
|
input.note ?? null,
|
||||||
|
replacingKey,
|
||||||
|
storeId,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
if (input.api_key !== undefined) {
|
||||||
|
await this.replaceApiKey(
|
||||||
|
transaction,
|
||||||
|
storeId,
|
||||||
|
input.api_key,
|
||||||
|
);
|
||||||
|
const operation = await this.createOperation(
|
||||||
|
transaction,
|
||||||
|
userId,
|
||||||
|
storeId,
|
||||||
|
'store_credential_verification',
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
status: 202,
|
||||||
|
body: {
|
||||||
|
data: {
|
||||||
|
store: await this.requireStore(
|
||||||
|
transaction,
|
||||||
|
storeId,
|
||||||
|
),
|
||||||
|
operation,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
status: 200,
|
||||||
|
body: {
|
||||||
|
data: await this.requireStore(transaction, storeId),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async setDefault(
|
||||||
|
workspaceId: string,
|
||||||
|
userId: string,
|
||||||
|
storeId: string,
|
||||||
|
idempotencyKey: string,
|
||||||
|
) {
|
||||||
|
return this.idempotency.run(
|
||||||
|
{
|
||||||
|
workspaceId,
|
||||||
|
userId,
|
||||||
|
method: 'POST',
|
||||||
|
path: `/api/stores/${storeId}/set-default`,
|
||||||
|
key: idempotencyKey,
|
||||||
|
request: {},
|
||||||
|
},
|
||||||
|
async (transaction) => {
|
||||||
|
const current = await this.lockStore(transaction, storeId);
|
||||||
|
await lockPlatform(
|
||||||
|
transaction,
|
||||||
|
workspaceId,
|
||||||
|
current.platform_id,
|
||||||
|
);
|
||||||
|
await transaction.query(
|
||||||
|
`UPDATE atlas.stores SET is_default = false, revision = revision + 1, updated_at = now()
|
||||||
|
WHERE platform_id = $1 AND is_default AND deleted_at IS NULL AND id <> $2`,
|
||||||
|
[current.platform_id, storeId],
|
||||||
|
);
|
||||||
|
if (!current.is_default) {
|
||||||
|
await transaction.query(
|
||||||
|
`UPDATE atlas.stores SET is_default = true, revision = revision + 1, updated_at = now()
|
||||||
|
WHERE id = $1`,
|
||||||
|
[storeId],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
status: 200,
|
||||||
|
body: {
|
||||||
|
data: await this.requireStore(transaction, storeId),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async requestCredentialVerification(
|
||||||
|
workspaceId: string,
|
||||||
|
userId: string,
|
||||||
|
storeId: string,
|
||||||
|
idempotencyKey: string,
|
||||||
|
) {
|
||||||
|
return this.requestOperation(
|
||||||
|
workspaceId,
|
||||||
|
userId,
|
||||||
|
storeId,
|
||||||
|
idempotencyKey,
|
||||||
|
'credential-verifications',
|
||||||
|
'store_credential_verification',
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async requestWarehouseSync(
|
||||||
|
workspaceId: string,
|
||||||
|
userId: string,
|
||||||
|
storeId: string,
|
||||||
|
idempotencyKey: string,
|
||||||
|
) {
|
||||||
|
return this.requestOperation(
|
||||||
|
workspaceId,
|
||||||
|
userId,
|
||||||
|
storeId,
|
||||||
|
idempotencyKey,
|
||||||
|
'warehouse-syncs',
|
||||||
|
'store_warehouse_sync',
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async remove(
|
||||||
|
workspaceId: string,
|
||||||
|
userId: string,
|
||||||
|
storeId: string,
|
||||||
|
idempotencyKey: string,
|
||||||
|
) {
|
||||||
|
return this.idempotency.run(
|
||||||
|
{
|
||||||
|
workspaceId,
|
||||||
|
userId,
|
||||||
|
method: 'DELETE',
|
||||||
|
path: `/api/stores/${storeId}`,
|
||||||
|
key: idempotencyKey,
|
||||||
|
request: {},
|
||||||
|
},
|
||||||
|
async (transaction) => {
|
||||||
|
const current = await this.lockStore(transaction, storeId);
|
||||||
|
if (current.is_default) {
|
||||||
|
const replacement = await transaction.query<{ id: string }>(
|
||||||
|
`SELECT id FROM atlas.stores
|
||||||
|
WHERE platform_id = $1 AND id <> $2 AND deleted_at IS NULL LIMIT 1`,
|
||||||
|
[current.platform_id, storeId],
|
||||||
|
);
|
||||||
|
if (replacement.rows[0]) {
|
||||||
|
throw new ApiError(
|
||||||
|
422,
|
||||||
|
'DEFAULT_STORE_REPLACEMENT_REQUIRED',
|
||||||
|
'删除默认门店前必须先选择同平台的其他默认门店',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await transaction.query(
|
||||||
|
`UPDATE atlas.stores
|
||||||
|
SET deleted_at = now(), status = 'deleted', is_default = false,
|
||||||
|
revision = revision + 1, updated_at = now()
|
||||||
|
WHERE id = $1`,
|
||||||
|
[storeId],
|
||||||
|
);
|
||||||
|
// 删除连接后立刻撤销可用密钥,避免后台任务或未来恢复流程继续使用它。
|
||||||
|
await transaction.query(
|
||||||
|
`UPDATE atlas.store_secrets SET revoked_at = now(), updated_at = now()
|
||||||
|
WHERE store_id = $1 AND revoked_at IS NULL`,
|
||||||
|
[storeId],
|
||||||
|
);
|
||||||
|
return { status: 204, body: null };
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async warehouses(workspaceId: string, storeId: string) {
|
||||||
|
return this.database.workspaceTransaction(
|
||||||
|
workspaceId,
|
||||||
|
async (transaction: DatabaseTransaction) => {
|
||||||
|
const store = await this.requireStore(transaction, storeId);
|
||||||
|
const rows = await transaction.query<WarehouseRow>(
|
||||||
|
`SELECT id, platform_warehouse_id, name, fulfillment, is_rfbs, status, synced_at
|
||||||
|
FROM atlas.store_warehouses WHERE store_id = $1 ORDER BY name, id`,
|
||||||
|
[storeId],
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
data: rows.rows.map((row) => ({
|
||||||
|
...row,
|
||||||
|
synced_at: toIso(row.synced_at),
|
||||||
|
})),
|
||||||
|
meta: { warehouses_synced_at: store.warehouses_synced_at },
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async operation(workspaceId: string, operationId: string) {
|
||||||
|
return this.database.workspaceTransaction(
|
||||||
|
workspaceId,
|
||||||
|
async (transaction: DatabaseTransaction) => {
|
||||||
|
const rows = await transaction.query<OperationRow>(
|
||||||
|
`SELECT id, kind, status, created_at, completed_at, error_code, error_message
|
||||||
|
FROM atlas.operations WHERE id = $1`,
|
||||||
|
[operationId],
|
||||||
|
);
|
||||||
|
if (!rows.rows[0]) throw notFound('操作');
|
||||||
|
return toOperation(rows.rows[0]);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async requestOperation(
|
||||||
|
workspaceId: string,
|
||||||
|
userId: string,
|
||||||
|
storeId: string,
|
||||||
|
idempotencyKey: string,
|
||||||
|
pathSuffix: string,
|
||||||
|
kind: string,
|
||||||
|
requiresValidCredential: boolean,
|
||||||
|
) {
|
||||||
|
return this.idempotency.run(
|
||||||
|
{
|
||||||
|
workspaceId,
|
||||||
|
userId,
|
||||||
|
method: 'POST',
|
||||||
|
path: `/api/stores/${storeId}/${pathSuffix}`,
|
||||||
|
key: idempotencyKey,
|
||||||
|
request: {},
|
||||||
|
},
|
||||||
|
async (transaction) => {
|
||||||
|
const store = await this.requireStore(transaction, storeId);
|
||||||
|
if (store.status === 'disabled') {
|
||||||
|
throw new ApiError(422, 'STORE_DISABLED', '门店已停用');
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
requiresValidCredential &&
|
||||||
|
store.credential_status !== 'valid'
|
||||||
|
) {
|
||||||
|
throw new ApiError(
|
||||||
|
422,
|
||||||
|
'STORE_CREDENTIAL_UNAVAILABLE',
|
||||||
|
'门店凭据尚未验证通过,无法同步仓库',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const operation = await this.createOperation(
|
||||||
|
transaction,
|
||||||
|
userId,
|
||||||
|
storeId,
|
||||||
|
kind,
|
||||||
|
);
|
||||||
|
return { status: 202, body: { data: { operation } } };
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async assertPlatformCurrency(
|
||||||
|
transaction: DatabaseTransaction,
|
||||||
|
platformId: string,
|
||||||
|
currencyCode: string,
|
||||||
|
) {
|
||||||
|
const platform = await transaction.query<{ id: string }>(
|
||||||
|
`SELECT id FROM atlas.platforms WHERE id = $1 AND status = 'active'`,
|
||||||
|
[platformId],
|
||||||
|
);
|
||||||
|
if (!platform.rows[0]) {
|
||||||
|
throw new ApiError(
|
||||||
|
422,
|
||||||
|
'PLATFORM_UNAVAILABLE',
|
||||||
|
'平台不存在或已停用',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const supported = await transaction.query<{ currency_code: string }>(
|
||||||
|
`SELECT pcs.currency_code FROM atlas.platform_currency_support pcs
|
||||||
|
JOIN atlas.currencies c ON c.code = pcs.currency_code
|
||||||
|
WHERE pcs.platform_id = $1 AND pcs.currency_code = $2
|
||||||
|
AND pcs.is_enabled AND c.is_enabled`,
|
||||||
|
[platformId, currencyCode],
|
||||||
|
);
|
||||||
|
if (!supported.rows[0]) {
|
||||||
|
throw new ApiError(
|
||||||
|
422,
|
||||||
|
'PLATFORM_CURRENCY_UNSUPPORTED',
|
||||||
|
'该平台不支持此默认计价币种',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async lockStore(transaction: DatabaseTransaction, storeId: string) {
|
||||||
|
const rows = await transaction.query<StoreLockRow>(
|
||||||
|
`SELECT id, platform_id, revision, is_default, status
|
||||||
|
FROM atlas.stores WHERE id = $1 AND deleted_at IS NULL FOR UPDATE`,
|
||||||
|
[storeId],
|
||||||
|
);
|
||||||
|
if (!rows.rows[0]) throw notFound('门店');
|
||||||
|
return rows.rows[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
private assertRevision(current: StoreLockRow, expected: number) {
|
||||||
|
if (current.revision !== expected) {
|
||||||
|
throw new ApiError(
|
||||||
|
409,
|
||||||
|
'VERSION_CONFLICT',
|
||||||
|
'门店已被其他操作更新',
|
||||||
|
[
|
||||||
|
{
|
||||||
|
field: 'If-Match',
|
||||||
|
code: 'VERSION_CONFLICT',
|
||||||
|
message: `当前版本为 ${current.revision}`,
|
||||||
|
current_revision: current.revision,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async replaceApiKey(
|
||||||
|
transaction: DatabaseTransaction,
|
||||||
|
storeId: string,
|
||||||
|
apiKey: string,
|
||||||
|
) {
|
||||||
|
const secret = encryptStoreSecret(
|
||||||
|
apiKey,
|
||||||
|
this.config.STORE_SECRET_ENCRYPTION_KEY,
|
||||||
|
);
|
||||||
|
const existing = await transaction.query<{ id: string }>(
|
||||||
|
`SELECT id FROM atlas.store_secrets
|
||||||
|
WHERE store_id = $1 AND kind = 'seller_api_key' AND fingerprint = $2
|
||||||
|
FOR UPDATE`,
|
||||||
|
[storeId, secret.fingerprint],
|
||||||
|
);
|
||||||
|
await transaction.query(
|
||||||
|
`UPDATE atlas.store_secrets SET revoked_at = now(), updated_at = now()
|
||||||
|
WHERE store_id = $1 AND kind = 'seller_api_key' AND revoked_at IS NULL`,
|
||||||
|
[storeId],
|
||||||
|
);
|
||||||
|
if (existing.rows[0]) {
|
||||||
|
// 同一密钥可被重新提交用于验证;恢复历史行避免违反防重指纹唯一约束。
|
||||||
|
await transaction.query(
|
||||||
|
`UPDATE atlas.store_secrets
|
||||||
|
SET secret_ciphertext = $1, secret_nonce = $2, encryption_key_version = $3,
|
||||||
|
status = 'unknown', revoked_at = NULL, updated_at = now()
|
||||||
|
WHERE id = $4`,
|
||||||
|
[
|
||||||
|
secret.ciphertext,
|
||||||
|
secret.nonce,
|
||||||
|
this.config.STORE_SECRET_ENCRYPTION_KEY_VERSION,
|
||||||
|
existing.rows[0].id,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await transaction.query(
|
||||||
|
`INSERT INTO atlas.store_secrets
|
||||||
|
(workspace_id, store_id, kind, secret_ciphertext, secret_nonce, encryption_key_version, fingerprint)
|
||||||
|
VALUES (current_setting('app.workspace_id')::uuid, $1, 'seller_api_key', $2, $3, $4, $5)`,
|
||||||
|
[
|
||||||
|
storeId,
|
||||||
|
secret.ciphertext,
|
||||||
|
secret.nonce,
|
||||||
|
this.config.STORE_SECRET_ENCRYPTION_KEY_VERSION,
|
||||||
|
secret.fingerprint,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async createOperation(
|
||||||
|
transaction: DatabaseTransaction,
|
||||||
|
userId: string,
|
||||||
|
storeId: string,
|
||||||
|
kind: string,
|
||||||
|
): Promise<Operation> {
|
||||||
|
const created = await transaction.query<OperationRow>(
|
||||||
|
`INSERT INTO atlas.operations
|
||||||
|
(workspace_id, kind, resource_type, resource_id, created_by_user_id)
|
||||||
|
VALUES (current_setting('app.workspace_id')::uuid, $1, 'store', $2, $3)
|
||||||
|
RETURNING id, kind, status, created_at, completed_at, error_code, error_message`,
|
||||||
|
[kind, storeId, userId],
|
||||||
|
);
|
||||||
|
const operation = toOperation(created.rows[0]);
|
||||||
|
await transaction.query(
|
||||||
|
`INSERT INTO atlas.outbox_events
|
||||||
|
(workspace_id, aggregate_type, aggregate_id, event_type, event_payload, deduplication_key)
|
||||||
|
VALUES (current_setting('app.workspace_id')::uuid, 'store', $1, $2, $3, $4)`,
|
||||||
|
[
|
||||||
|
storeId,
|
||||||
|
`${kind}.requested`,
|
||||||
|
{ store_id: storeId, operation_id: operation.id },
|
||||||
|
operation.id,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
return operation;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async requireStore(
|
||||||
|
transaction: DatabaseTransaction,
|
||||||
|
storeId: string,
|
||||||
|
): Promise<Store> {
|
||||||
|
const rows = await transaction.query<StoreRow>(
|
||||||
|
`${storeSelect}
|
||||||
|
WHERE s.id = $1 AND s.deleted_at IS NULL
|
||||||
|
GROUP BY s.id, binding.value`,
|
||||||
|
[storeId],
|
||||||
|
);
|
||||||
|
if (!rows.rows[0]) throw notFound('门店');
|
||||||
|
return toStore(rows.rows[0]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const storeSelect = `
|
||||||
|
SELECT s.id, s.platform_id, s.name, s.platform_client_id, s.currency_code,
|
||||||
|
s.is_default, s.note, s.status, s.credential_status, s.credential_checked_at,
|
||||||
|
CASE WHEN s.last_error_code IS NULL THEN NULL
|
||||||
|
ELSE jsonb_build_object('code', s.last_error_code, 'message', s.last_error_message, 'at', s.last_error_at)
|
||||||
|
END AS last_error,
|
||||||
|
s.api_key_expires_at, s.product_limit, s.product_limit_checked_at,
|
||||||
|
count(sw.id)::integer AS warehouse_count, max(sw.synced_at) AS warehouses_synced_at,
|
||||||
|
binding.value AS watermark_template, s.created_at, s.updated_at, s.revision
|
||||||
|
FROM atlas.stores s
|
||||||
|
LEFT JOIN atlas.store_warehouses sw ON sw.store_id = s.id
|
||||||
|
LEFT JOIN LATERAL (
|
||||||
|
SELECT jsonb_build_object('id', wt.id, 'name', wt.name) AS value
|
||||||
|
FROM atlas.store_watermark_bindings wb
|
||||||
|
JOIN atlas.watermark_templates wt ON wt.id = wb.template_id
|
||||||
|
WHERE wb.store_id = s.id AND wb.unbound_at IS NULL
|
||||||
|
LIMIT 1
|
||||||
|
) binding ON true`;
|
||||||
|
|
||||||
|
function toStore(row: StoreRow): Store {
|
||||||
|
return {
|
||||||
|
...row,
|
||||||
|
credential_checked_at: toIso(row.credential_checked_at),
|
||||||
|
api_key_expires_at: toIso(row.api_key_expires_at),
|
||||||
|
product_limit_checked_at: toIso(row.product_limit_checked_at),
|
||||||
|
warehouses_synced_at: toIso(row.warehouses_synced_at),
|
||||||
|
created_at: toRequiredIso(row.created_at),
|
||||||
|
updated_at: toRequiredIso(row.updated_at),
|
||||||
|
cookie_status: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function toOperation(row: OperationRow): Operation {
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
kind: row.kind,
|
||||||
|
status: row.status,
|
||||||
|
created_at: toRequiredIso(row.created_at),
|
||||||
|
completed_at: toIso(row.completed_at),
|
||||||
|
error:
|
||||||
|
row.error_code && row.error_message
|
||||||
|
? { code: row.error_code, message: row.error_message }
|
||||||
|
: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function toIso(value: Date | string | null): string | null {
|
||||||
|
if (value === null) return null;
|
||||||
|
return value instanceof Date ? value.toISOString() : value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toRequiredIso(value: Date | string): string {
|
||||||
|
return value instanceof Date ? value.toISOString() : value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function notFound(resource: string) {
|
||||||
|
return new ApiError(404, 'RESOURCE_NOT_FOUND', `${resource}不存在`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function lockPlatform(
|
||||||
|
transaction: DatabaseTransaction,
|
||||||
|
workspaceId: string,
|
||||||
|
platformId: string,
|
||||||
|
) {
|
||||||
|
await transaction.query(
|
||||||
|
`SELECT pg_advisory_xact_lock(hashtextextended($1, 0))`,
|
||||||
|
[`${workspaceId}:${platformId}`],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function encodeCursor(createdAt: string, id: string): string {
|
||||||
|
return Buffer.from(JSON.stringify({ createdAt, id })).toString('base64url');
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodeCursor(cursor: string): { createdAt: string; id: string } {
|
||||||
|
try {
|
||||||
|
const value = JSON.parse(
|
||||||
|
Buffer.from(cursor, 'base64url').toString('utf8'),
|
||||||
|
) as { createdAt?: unknown; id?: unknown };
|
||||||
|
if (
|
||||||
|
typeof value.createdAt !== 'string' ||
|
||||||
|
Number.isNaN(Date.parse(value.createdAt)) ||
|
||||||
|
typeof value.id !== 'string' ||
|
||||||
|
!/^[0-9a-f]{8}-[0-9a-f-]{27}$/i.test(value.id)
|
||||||
|
) {
|
||||||
|
throw new Error('invalid cursor');
|
||||||
|
}
|
||||||
|
return { createdAt: value.createdAt, id: value.id };
|
||||||
|
} catch {
|
||||||
|
throw new ApiError(422, 'VALIDATION_FAILED', '分页游标无效', [
|
||||||
|
{ field: 'cursor', code: 'INVALID_FIELD', message: '分页游标无效' },
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { createParamDecorator, type ExecutionContext } from '@nestjs/common';
|
||||||
|
import type { Workspace } from '@atlas/contracts';
|
||||||
|
import type { WorkspaceAuthenticatedRequest } from './workspace-context.guard.js';
|
||||||
|
|
||||||
|
// 受保护接口经全局工作空间守卫验证后,只能从这里读取当前工作空间。
|
||||||
|
export const CurrentWorkspace = createParamDecorator(
|
||||||
|
(_data: unknown, context: ExecutionContext): Workspace => {
|
||||||
|
const request = context
|
||||||
|
.switchToHttp()
|
||||||
|
.getRequest<WorkspaceAuthenticatedRequest>();
|
||||||
|
return request.auth.workspace;
|
||||||
|
},
|
||||||
|
);
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import {
|
||||||
|
CanActivate,
|
||||||
|
ExecutionContext,
|
||||||
|
Inject,
|
||||||
|
Injectable,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import type { Workspace } from '@atlas/contracts';
|
||||||
|
import type { Database } from '@atlas/database';
|
||||||
|
import { Reflector } from '@nestjs/core';
|
||||||
|
import { ApiError } from '../common/api-error.js';
|
||||||
|
import { IS_PUBLIC_ENDPOINT } from '../common/public.decorator.js';
|
||||||
|
import { DATABASE } from '../database/database.module.js';
|
||||||
|
import type { AuthenticatedRequest } from '../auth/auth.guard.js';
|
||||||
|
|
||||||
|
export type WorkspaceAuthenticatedRequest = AuthenticatedRequest & {
|
||||||
|
auth: AuthenticatedRequest['auth'] & { workspace: Workspace };
|
||||||
|
};
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class WorkspaceContextGuard implements CanActivate {
|
||||||
|
public constructor(
|
||||||
|
@Inject(Reflector) private readonly reflector: Reflector,
|
||||||
|
@Inject(DATABASE) private readonly database: Database,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||||
|
if (
|
||||||
|
this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_ENDPOINT, [
|
||||||
|
context.getHandler(),
|
||||||
|
context.getClass(),
|
||||||
|
])
|
||||||
|
) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const request = context
|
||||||
|
.switchToHttp()
|
||||||
|
.getRequest<AuthenticatedRequest>();
|
||||||
|
// 当前一期每位用户只有一个个人工作空间;后续多人协作时此处将按显式选择的工作空间校验 membership。
|
||||||
|
const workspace = await this.findCurrentWorkspace(request.auth.userId);
|
||||||
|
if (!workspace) {
|
||||||
|
throw new ApiError(
|
||||||
|
403,
|
||||||
|
'WORKSPACE_MEMBERSHIP_REQUIRED',
|
||||||
|
'当前账户没有可用工作空间',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// 后续控制器和服务只使用此处验证过的上下文,绝不相信客户端传入 workspace_id。
|
||||||
|
request.auth.workspace = workspace;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async findCurrentWorkspace(
|
||||||
|
userId: string,
|
||||||
|
): Promise<Workspace | null> {
|
||||||
|
// 排序规则是首期“唯一有效工作空间”的临时选择策略,不是未来的工作空间切换协议。
|
||||||
|
const result = await this.database.query<Workspace>(
|
||||||
|
`SELECT w.id, w.name, m.role FROM atlas.workspace_memberships m JOIN atlas.workspaces w ON w.id = m.workspace_id WHERE m.user_id = $1 AND w.status = 'active' ORDER BY m.joined_at LIMIT 1`,
|
||||||
|
[userId],
|
||||||
|
);
|
||||||
|
return result.rows[0] ?? null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,276 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
import type { AddressInfo } from 'node:net';
|
||||||
|
import type { Database } from '@atlas/database';
|
||||||
|
import type { NestExpressApplication } from '@nestjs/platform-express';
|
||||||
|
import { afterAll, beforeAll, describe, it } from 'vitest';
|
||||||
|
import { DATABASE } from '../src/database/database.module.js';
|
||||||
|
import { createApplication } from '../src/main.js';
|
||||||
|
|
||||||
|
type SuccessResponse<T> = { data: T };
|
||||||
|
type ErrorResponse = {
|
||||||
|
error: {
|
||||||
|
code: string;
|
||||||
|
message: string;
|
||||||
|
request_id: string;
|
||||||
|
details?: Array<{ field: string; code: string; message: string }>;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
type SessionPayload = {
|
||||||
|
access_token: string;
|
||||||
|
access_token_expires_in: number;
|
||||||
|
user: { id: string; email: string };
|
||||||
|
workspace: { id: string; name: string; role: string };
|
||||||
|
};
|
||||||
|
|
||||||
|
const password = 'correct-horse-battery-staple-123';
|
||||||
|
const testUsers: Array<{ userId: string; workspaceId: string }> = [];
|
||||||
|
let app: NestExpressApplication;
|
||||||
|
let database: Database;
|
||||||
|
let baseUrl: string;
|
||||||
|
|
||||||
|
function email(label: string): string {
|
||||||
|
return `${label}-${randomUUID()}@example.test`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function request(
|
||||||
|
path: string,
|
||||||
|
init: RequestInit = {},
|
||||||
|
): Promise<{ response: Response; body: unknown }> {
|
||||||
|
const response = await fetch(`${baseUrl}${path}`, {
|
||||||
|
...init,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...init.headers,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
response,
|
||||||
|
body: response.status === 204 ? undefined : await response.json(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function expectSuccess<T>(body: unknown): SuccessResponse<T> {
|
||||||
|
assert.ok(body && typeof body === 'object' && 'data' in body);
|
||||||
|
return body as SuccessResponse<T>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function expectError(body: unknown): ErrorResponse {
|
||||||
|
assert.ok(body && typeof body === 'object' && 'error' in body);
|
||||||
|
return body as ErrorResponse;
|
||||||
|
}
|
||||||
|
|
||||||
|
function refreshCookie(response: Response): string {
|
||||||
|
const cookie = response.headers.get('set-cookie');
|
||||||
|
assert.ok(cookie, '认证成功响应必须写入 refresh cookie');
|
||||||
|
assert.match(cookie, /HttpOnly/i);
|
||||||
|
const match = cookie.match(/atlas_refresh=[^;]+/);
|
||||||
|
assert.ok(match, '响应必须包含 atlas_refresh cookie');
|
||||||
|
return match[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('认证 HTTP 集成测试', () => {
|
||||||
|
beforeAll(async () => {
|
||||||
|
Object.assign(process.env, {
|
||||||
|
NODE_ENV: 'test',
|
||||||
|
SWAGGER_ENABLED: 'false',
|
||||||
|
AUTH_LOGIN_IP_LIMIT: '100',
|
||||||
|
AUTH_LOGIN_EMAIL_LIMIT: '2',
|
||||||
|
AUTH_REGISTER_IP_LIMIT: '100',
|
||||||
|
AUTH_REGISTER_EMAIL_LIMIT: '100',
|
||||||
|
AUTH_REFRESH_IP_LIMIT: '100',
|
||||||
|
});
|
||||||
|
const application = await createApplication();
|
||||||
|
app = application.app;
|
||||||
|
database = app.get<Database>(DATABASE);
|
||||||
|
await app.listen(0, '127.0.0.1');
|
||||||
|
const address = app.getHttpServer().address() as AddressInfo;
|
||||||
|
baseUrl = `http://127.0.0.1:${address.port}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
for (const user of testUsers) {
|
||||||
|
await database.query(
|
||||||
|
'DELETE FROM atlas.workspace_memberships WHERE user_id = $1',
|
||||||
|
[user.userId],
|
||||||
|
);
|
||||||
|
await database.query('DELETE FROM atlas.workspaces WHERE id = $1', [
|
||||||
|
user.workspaceId,
|
||||||
|
]);
|
||||||
|
await database.query('DELETE FROM atlas.users WHERE id = $1', [
|
||||||
|
user.userId,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('健康与 readiness 端点保持公开', async () => {
|
||||||
|
const health = await request('/health');
|
||||||
|
assert.equal(health.response.status, 200);
|
||||||
|
assert.deepEqual(health.body, { status: 'ok', service: 'api' });
|
||||||
|
|
||||||
|
const readiness = await request('/ready');
|
||||||
|
assert.equal(readiness.response.status, 200);
|
||||||
|
assert.deepEqual(readiness.body, { status: 'ready', service: 'api' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('注册、重复邮箱和参数校验遵守响应契约', async () => {
|
||||||
|
const registrationEmail = email('register');
|
||||||
|
const registration = await request('/api/auth/register', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({
|
||||||
|
email: registrationEmail,
|
||||||
|
password,
|
||||||
|
password_confirmation: password,
|
||||||
|
display_name: '集成测试用户',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
assert.equal(registration.response.status, 201);
|
||||||
|
const payload = expectSuccess<SessionPayload>(registration.body).data;
|
||||||
|
assert.equal(payload.user.email, registrationEmail);
|
||||||
|
assert.equal(payload.workspace.role, 'owner');
|
||||||
|
assert.ok(payload.access_token);
|
||||||
|
assert.ok(payload.access_token_expires_in > 0);
|
||||||
|
refreshCookie(registration.response);
|
||||||
|
testUsers.push({
|
||||||
|
userId: payload.user.id,
|
||||||
|
workspaceId: payload.workspace.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
const duplicate = await request('/api/auth/register', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({
|
||||||
|
email: registrationEmail,
|
||||||
|
password,
|
||||||
|
password_confirmation: password,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
assert.equal(duplicate.response.status, 409);
|
||||||
|
assert.equal(
|
||||||
|
expectError(duplicate.body).error.code,
|
||||||
|
'EMAIL_ALREADY_REGISTERED',
|
||||||
|
);
|
||||||
|
|
||||||
|
const invalid = await request('/api/auth/register', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({
|
||||||
|
email: 'not-an-email',
|
||||||
|
password,
|
||||||
|
password_confirmation: password,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
assert.equal(invalid.response.status, 422);
|
||||||
|
assert.equal(expectError(invalid.body).error.code, 'VALIDATION_FAILED');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('登录、刷新、退出和当前用户读取可形成完整会话闭环', async () => {
|
||||||
|
const accountEmail = email('session');
|
||||||
|
const registration = await request('/api/auth/register', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({
|
||||||
|
email: accountEmail,
|
||||||
|
password,
|
||||||
|
password_confirmation: password,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const registered = expectSuccess<SessionPayload>(
|
||||||
|
registration.body,
|
||||||
|
).data;
|
||||||
|
testUsers.push({
|
||||||
|
userId: registered.user.id,
|
||||||
|
workspaceId: registered.workspace.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
const login = await request('/api/auth/login', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ email: accountEmail, password }),
|
||||||
|
});
|
||||||
|
assert.equal(login.response.status, 200);
|
||||||
|
const loggedIn = expectSuccess<SessionPayload>(login.body).data;
|
||||||
|
const loginRefreshCookie = refreshCookie(login.response);
|
||||||
|
|
||||||
|
const currentUser = await request('/api/auth/me', {
|
||||||
|
headers: { Authorization: `Bearer ${loggedIn.access_token}` },
|
||||||
|
});
|
||||||
|
assert.equal(currentUser.response.status, 200);
|
||||||
|
const me = expectSuccess<{
|
||||||
|
user: { id: string };
|
||||||
|
workspace: { id: string };
|
||||||
|
}>(currentUser.body).data;
|
||||||
|
assert.equal(me.user.id, registered.user.id);
|
||||||
|
assert.equal(me.workspace.id, registered.workspace.id);
|
||||||
|
|
||||||
|
const refresh = await request('/api/auth/refresh', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { Cookie: loginRefreshCookie },
|
||||||
|
});
|
||||||
|
assert.equal(refresh.response.status, 200);
|
||||||
|
const refreshed = expectSuccess<{
|
||||||
|
access_token: string;
|
||||||
|
access_token_expires_in: number;
|
||||||
|
}>(refresh.body).data;
|
||||||
|
assert.ok(refreshed.access_token);
|
||||||
|
const refreshedCookie = refreshCookie(refresh.response);
|
||||||
|
|
||||||
|
const reusedRefresh = await request('/api/auth/refresh', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { Cookie: loginRefreshCookie },
|
||||||
|
});
|
||||||
|
assert.equal(reusedRefresh.response.status, 401);
|
||||||
|
assert.equal(
|
||||||
|
expectError(reusedRefresh.body).error.code,
|
||||||
|
'SESSION_EXPIRED',
|
||||||
|
);
|
||||||
|
|
||||||
|
const logout = await request('/api/auth/logout', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${refreshed.access_token}`,
|
||||||
|
Cookie: refreshedCookie,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
assert.equal(logout.response.status, 204);
|
||||||
|
assert.equal(logout.body, undefined);
|
||||||
|
|
||||||
|
const revoked = await request('/api/auth/me', {
|
||||||
|
headers: { Authorization: `Bearer ${refreshed.access_token}` },
|
||||||
|
});
|
||||||
|
assert.equal(revoked.response.status, 401);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('拒绝错误密码、无效访问令牌和超过邮箱限额的登录请求', async () => {
|
||||||
|
const accountEmail = email('rejected');
|
||||||
|
const wrongPassword = await request('/api/auth/login', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ email: accountEmail, password }),
|
||||||
|
});
|
||||||
|
assert.equal(wrongPassword.response.status, 401);
|
||||||
|
const wrongPasswordError = expectError(wrongPassword.body).error;
|
||||||
|
assert.equal(wrongPasswordError.code, 'INVALID_CREDENTIALS');
|
||||||
|
assert.equal(
|
||||||
|
wrongPasswordError.request_id,
|
||||||
|
wrongPassword.response.headers.get('x-request-id'),
|
||||||
|
);
|
||||||
|
|
||||||
|
const invalidToken = await request('/api/auth/me', {
|
||||||
|
headers: { Authorization: 'Bearer invalid-access-token' },
|
||||||
|
});
|
||||||
|
assert.equal(invalidToken.response.status, 401);
|
||||||
|
assert.equal(expectError(invalidToken.body).error.code, 'HTTP_ERROR');
|
||||||
|
|
||||||
|
const rateLimitedEmail = email('rate-limit');
|
||||||
|
for (const expectedStatus of [401, 401, 429]) {
|
||||||
|
const response = await request('/api/auth/login', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ email: rateLimitedEmail, password }),
|
||||||
|
});
|
||||||
|
assert.equal(response.response.status, expectedStatus);
|
||||||
|
if (expectedStatus === 429) {
|
||||||
|
assert.equal(
|
||||||
|
expectError(response.body).error.code,
|
||||||
|
'AUTH_RATE_LIMITED',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,311 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
import type { AddressInfo } from 'node:net';
|
||||||
|
import type { Database } from '@atlas/database';
|
||||||
|
import type { NestExpressApplication } from '@nestjs/platform-express';
|
||||||
|
import { afterAll, beforeAll, describe, it } from 'vitest';
|
||||||
|
import { DATABASE } from '../src/database/database.module.js';
|
||||||
|
import { createApplication } from '../src/main.js';
|
||||||
|
|
||||||
|
type SuccessResponse<T> = { data: T };
|
||||||
|
type ErrorResponse = { error: { code: string } };
|
||||||
|
type Session = {
|
||||||
|
access_token: string;
|
||||||
|
user: { id: string };
|
||||||
|
workspace: { id: string };
|
||||||
|
};
|
||||||
|
type Store = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
is_default: boolean;
|
||||||
|
status: string;
|
||||||
|
credential_status: string;
|
||||||
|
revision: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const password = 'correct-horse-battery-staple-123';
|
||||||
|
const testUsers: Array<{ userId: string; workspaceId: string }> = [];
|
||||||
|
let app: NestExpressApplication;
|
||||||
|
let database: Database;
|
||||||
|
let baseUrl: string;
|
||||||
|
|
||||||
|
function email(label: string) {
|
||||||
|
return `${label}-${randomUUID()}@example.test`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function request(path: string, init: RequestInit = {}) {
|
||||||
|
const response = await fetch(`${baseUrl}${path}`, {
|
||||||
|
...init,
|
||||||
|
headers: { 'Content-Type': 'application/json', ...init.headers },
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
response,
|
||||||
|
body: response.status === 204 ? undefined : await response.json(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function success<T>(body: unknown): SuccessResponse<T> {
|
||||||
|
assert.ok(body && typeof body === 'object' && 'data' in body);
|
||||||
|
return body as SuccessResponse<T>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function error(body: unknown): ErrorResponse {
|
||||||
|
assert.ok(body && typeof body === 'object' && 'error' in body);
|
||||||
|
return body as ErrorResponse;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function register(label: string): Promise<Session> {
|
||||||
|
const result = await request('/api/auth/register', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({
|
||||||
|
email: email(label),
|
||||||
|
password,
|
||||||
|
password_confirmation: password,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
assert.equal(result.response.status, 201);
|
||||||
|
const session = success<Session>(result.body).data;
|
||||||
|
testUsers.push({
|
||||||
|
userId: session.user.id,
|
||||||
|
workspaceId: session.workspace.id,
|
||||||
|
});
|
||||||
|
return session;
|
||||||
|
}
|
||||||
|
|
||||||
|
function headers(session: Session, idempotencyKey?: string) {
|
||||||
|
return {
|
||||||
|
Authorization: `Bearer ${session.access_token}`,
|
||||||
|
...(idempotencyKey ? { 'Idempotency-Key': idempotencyKey } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('门店 HTTP 集成测试', () => {
|
||||||
|
beforeAll(async () => {
|
||||||
|
Object.assign(process.env, {
|
||||||
|
NODE_ENV: 'test',
|
||||||
|
SWAGGER_ENABLED: 'false',
|
||||||
|
AUTH_LOGIN_IP_LIMIT: '100',
|
||||||
|
AUTH_LOGIN_EMAIL_LIMIT: '100',
|
||||||
|
AUTH_REGISTER_IP_LIMIT: '100',
|
||||||
|
AUTH_REGISTER_EMAIL_LIMIT: '100',
|
||||||
|
AUTH_REFRESH_IP_LIMIT: '100',
|
||||||
|
});
|
||||||
|
const application = await createApplication();
|
||||||
|
app = application.app;
|
||||||
|
database = app.get<Database>(DATABASE);
|
||||||
|
await app.listen(0, '127.0.0.1');
|
||||||
|
const address = app.getHttpServer().address() as AddressInfo;
|
||||||
|
baseUrl = `http://127.0.0.1:${address.port}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
for (const user of testUsers) {
|
||||||
|
// 门店测试会创建操作和 Outbox;先在工作空间事务内清理这些受 RLS 保护的记录。
|
||||||
|
await database.workspaceTransaction(
|
||||||
|
user.workspaceId,
|
||||||
|
async (tx) => {
|
||||||
|
await tx.query(
|
||||||
|
'DELETE FROM atlas.idempotency_requests WHERE workspace_id = $1',
|
||||||
|
[user.workspaceId],
|
||||||
|
);
|
||||||
|
await tx.query(
|
||||||
|
'DELETE FROM atlas.outbox_events WHERE workspace_id = $1',
|
||||||
|
[user.workspaceId],
|
||||||
|
);
|
||||||
|
await tx.query(
|
||||||
|
'DELETE FROM atlas.operations WHERE workspace_id = $1',
|
||||||
|
[user.workspaceId],
|
||||||
|
);
|
||||||
|
await tx.query(
|
||||||
|
'DELETE FROM atlas.stores WHERE workspace_id = $1',
|
||||||
|
[user.workspaceId],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
await database.query(
|
||||||
|
'DELETE FROM atlas.workspace_memberships WHERE user_id = $1',
|
||||||
|
[user.userId],
|
||||||
|
);
|
||||||
|
await database.query('DELETE FROM atlas.workspaces WHERE id = $1', [
|
||||||
|
user.workspaceId,
|
||||||
|
]);
|
||||||
|
await database.query('DELETE FROM atlas.users WHERE id = $1', [
|
||||||
|
user.userId,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('创建门店时加密保存凭据、创建操作,并能安全重放相同请求', async () => {
|
||||||
|
const owner = await register('stores-owner');
|
||||||
|
const platforms = await request('/api/platforms', {
|
||||||
|
headers: headers(owner),
|
||||||
|
});
|
||||||
|
assert.equal(platforms.response.status, 200);
|
||||||
|
const platform = success<
|
||||||
|
Array<{
|
||||||
|
id: string;
|
||||||
|
code: string;
|
||||||
|
default_market_code: string | null;
|
||||||
|
default_locale: string | null;
|
||||||
|
}>
|
||||||
|
>(platforms.body).data.find((item) => item.code === 'OZON');
|
||||||
|
assert.ok(platform);
|
||||||
|
assert.equal(platform.default_market_code, 'RU');
|
||||||
|
assert.equal(platform.default_locale, 'ru-RU');
|
||||||
|
|
||||||
|
const idempotencyKey = randomUUID();
|
||||||
|
const createBody = {
|
||||||
|
platform_id: platform.id,
|
||||||
|
name: '测试 Ozon 主店',
|
||||||
|
client_id: `client-${randomUUID()}`,
|
||||||
|
api_key: 'secret-only-in-request',
|
||||||
|
currency_code: 'RUB',
|
||||||
|
is_default: false,
|
||||||
|
note: '首家门店仍应成为默认门店',
|
||||||
|
};
|
||||||
|
const created = await request('/api/stores', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: headers(owner, idempotencyKey),
|
||||||
|
body: JSON.stringify(createBody),
|
||||||
|
});
|
||||||
|
assert.equal(created.response.status, 202);
|
||||||
|
const createdData = success<{
|
||||||
|
store: Store & { platform_client_id: string };
|
||||||
|
operation: { id: string; status: string };
|
||||||
|
}>(created.body).data;
|
||||||
|
assert.equal(createdData.store.is_default, true);
|
||||||
|
assert.equal(createdData.store.status, 'verifying');
|
||||||
|
assert.equal(createdData.store.credential_status, 'unknown');
|
||||||
|
assert.equal(
|
||||||
|
createdData.store.platform_client_id,
|
||||||
|
createBody.client_id,
|
||||||
|
);
|
||||||
|
assert.equal(createdData.operation.status, 'queued');
|
||||||
|
assert.equal(
|
||||||
|
JSON.stringify(created.body).includes(createBody.api_key),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
|
||||||
|
const replay = await request('/api/stores', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: headers(owner, idempotencyKey),
|
||||||
|
body: JSON.stringify(createBody),
|
||||||
|
});
|
||||||
|
assert.equal(replay.response.status, 202);
|
||||||
|
assert.deepEqual(replay.body, created.body);
|
||||||
|
|
||||||
|
const mismatchedReplay = await request('/api/stores', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: headers(owner, idempotencyKey),
|
||||||
|
body: JSON.stringify({ ...createBody, name: '不同请求' }),
|
||||||
|
});
|
||||||
|
assert.equal(mismatchedReplay.response.status, 409);
|
||||||
|
assert.equal(
|
||||||
|
error(mismatchedReplay.body).error.code,
|
||||||
|
'IDEMPOTENCY_KEY_REUSED',
|
||||||
|
);
|
||||||
|
|
||||||
|
const context = await request('/api/platform-context', {
|
||||||
|
headers: {
|
||||||
|
...headers(owner),
|
||||||
|
'X-Atlas-Platform-Id': platform.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
assert.equal(context.response.status, 200);
|
||||||
|
assert.equal(
|
||||||
|
success<{ store_count: number; default_store: { id: string } }>(
|
||||||
|
context.body,
|
||||||
|
).data.store_count,
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
|
||||||
|
const operation = await request(
|
||||||
|
`/api/operations/${createdData.operation.id}`,
|
||||||
|
{ headers: headers(owner) },
|
||||||
|
);
|
||||||
|
assert.equal(operation.response.status, 200);
|
||||||
|
assert.equal(
|
||||||
|
success<{ status: string }>(operation.body).data.status,
|
||||||
|
'queued',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('更新版本、切换默认门店、删除和工作空间隔离都遵守契约', async () => {
|
||||||
|
const owner = await register('stores-lifecycle');
|
||||||
|
const otherOwner = await register('stores-isolation');
|
||||||
|
const platforms = await request('/api/platforms', {
|
||||||
|
headers: headers(owner),
|
||||||
|
});
|
||||||
|
const platform = success<Array<{ id: string; code: string }>>(
|
||||||
|
platforms.body,
|
||||||
|
).data.find((item) => item.code === 'OZON');
|
||||||
|
assert.ok(platform);
|
||||||
|
|
||||||
|
const first = await createStore(owner, platform.id, '第一家门店');
|
||||||
|
const updated = await request(`/api/stores/${first.id}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: {
|
||||||
|
...headers(owner, randomUUID()),
|
||||||
|
'If-Match': `"${first.revision}"`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ name: '第一家门店(已修改)' }),
|
||||||
|
});
|
||||||
|
assert.equal(updated.response.status, 200);
|
||||||
|
const updatedStore = success<Store>(updated.body).data;
|
||||||
|
assert.equal(updatedStore.revision, first.revision + 1);
|
||||||
|
|
||||||
|
const stale = await request(`/api/stores/${first.id}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: {
|
||||||
|
...headers(owner, randomUUID()),
|
||||||
|
'If-Match': `"${first.revision}"`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ name: '过期修改' }),
|
||||||
|
});
|
||||||
|
assert.equal(stale.response.status, 409);
|
||||||
|
assert.equal(error(stale.body).error.code, 'VERSION_CONFLICT');
|
||||||
|
|
||||||
|
const second = await createStore(owner, platform.id, '第二家门店');
|
||||||
|
const makeDefault = await request(
|
||||||
|
`/api/stores/${second.id}/set-default`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
headers: headers(owner, randomUUID()),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
assert.equal(makeDefault.response.status, 200);
|
||||||
|
assert.equal(success<Store>(makeDefault.body).data.is_default, true);
|
||||||
|
|
||||||
|
const foreignRead = await request(`/api/stores/${first.id}`, {
|
||||||
|
headers: headers(otherOwner),
|
||||||
|
});
|
||||||
|
assert.equal(foreignRead.response.status, 404);
|
||||||
|
|
||||||
|
const deleted = await request(`/api/stores/${first.id}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: headers(owner, randomUUID()),
|
||||||
|
});
|
||||||
|
assert.equal(deleted.response.status, 204);
|
||||||
|
const absent = await request(`/api/stores/${first.id}`, {
|
||||||
|
headers: headers(owner),
|
||||||
|
});
|
||||||
|
assert.equal(absent.response.status, 404);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
async function createStore(owner: Session, platformId: string, name: string) {
|
||||||
|
const result = await request('/api/stores', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: headers(owner, randomUUID()),
|
||||||
|
body: JSON.stringify({
|
||||||
|
platform_id: platformId,
|
||||||
|
name,
|
||||||
|
client_id: `client-${randomUUID()}`,
|
||||||
|
api_key: `key-${randomUUID()}`,
|
||||||
|
currency_code: 'RUB',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
assert.equal(result.response.status, 202);
|
||||||
|
return success<{ store: Store }>(result.body).data.store;
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"extends": "./tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"rootDir": ".",
|
||||||
|
"noEmit": true
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts", "test/**/*.ts", "vitest.config.ts"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { defineConfig } from 'vitest/config';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
test: {
|
||||||
|
include: ['test/**/*.test.ts'],
|
||||||
|
fileParallelism: false,
|
||||||
|
testTimeout: 30_000,
|
||||||
|
},
|
||||||
|
});
|
||||||
+4
-4
@@ -1,6 +1,6 @@
|
|||||||
# Atlas HTTP API 设计
|
# Atlas HTTP API 设计
|
||||||
|
|
||||||
> 状态:第一批可评审契约。本文档描述预期的 NestJS HTTP API,不代表接口已经实现。接口统一以前缀 `/api` 提供,JSON 字段使用 `snake_case`,时间使用 ISO 8601 UTC 字符串,ID 使用 UUID。
|
> 状态:第一批契约已冻结。认证、平台目录/上下文、门店基础管理和操作查询已实现;其余模块仍是设计契约。接口统一以前缀 `/api` 提供,JSON 字段使用 `snake_case`,时间使用 ISO 8601 UTC 字符串,ID 使用 UUID。
|
||||||
|
|
||||||
API 启动后会在 `/api-docs` 提供 Swagger UI,并在其 OpenAPI 文档中标记已实现的接口。Swagger 是运行时参考,不替代本目录的冻结契约。
|
API 启动后会在 `/api-docs` 提供 Swagger UI,并在其 OpenAPI 文档中标记已实现的接口。Swagger 是运行时参考,不替代本目录的冻结契约。
|
||||||
|
|
||||||
@@ -10,9 +10,9 @@ API 启动后会在 `/api-docs` 提供 Swagger UI,并在其 OpenAPI 文档中
|
|||||||
|
|
||||||
| 文档 | 状态 | 范围 |
|
| 文档 | 状态 | 范围 |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| [`conventions.md`](./conventions.md) | 已设计 | 鉴权、全局上下文、响应、分页、错误、并发与异步任务。 |
|
| [`conventions.md`](./conventions.md) | 部分已实现 | 鉴权、全局上下文、响应、分页、错误、并发与异步任务。 |
|
||||||
| [`auth.md`](./auth.md) | 已设计 | 邮箱注册、登录、刷新会话、退出、当前用户。 |
|
| [`auth.md`](./auth.md) | 已实现 | 邮箱注册、登录、刷新会话、退出、当前用户。 |
|
||||||
| [`stores-and-platforms.md`](./stores-and-platforms.md) | 已设计 | 平台目录、页头上下文、店铺授权、凭据校验、仓库、默认店铺。 |
|
| [`stores-and-platforms.md`](./stores-and-platforms.md) | 部分已实现 | 平台目录、页头上下文、店铺授权、门店 CRUD、默认店铺、操作查询与仓库读取;平台实际验证与同步 Worker 待实现。 |
|
||||||
| [`watermarks-and-assets.md`](./watermarks-and-assets.md) | 已设计 | 素材直传、短时访问、水印模板、字体、店铺推荐水印。 |
|
| [`watermarks-and-assets.md`](./watermarks-and-assets.md) | 已设计 | 素材直传、短时访问、水印模板、字体、店铺推荐水印。 |
|
||||||
| [`pricing.md`](./pricing.md) | 已设计 | 汇率、物流价卡、即时试算、定价方案、报价快照衔接。 |
|
| [`pricing.md`](./pricing.md) | 已设计 | 汇率、物流价卡、即时试算、定价方案、报价快照衔接。 |
|
||||||
| [`collection-and-extension.md`](./collection-and-extension.md) | 已设计 | 插件授权、采集写入、采集箱查询、素材转存与状态。 |
|
| [`collection-and-extension.md`](./collection-and-extension.md) | 已设计 | 插件授权、采集写入、采集箱查询、素材转存与状态。 |
|
||||||
|
|||||||
+3
-1
@@ -68,11 +68,13 @@
|
|||||||
|
|
||||||
成功响应与注册相同,刷新 token 写入新的 `HttpOnly` cookie。失败统一返回 `401 INVALID_CREDENTIALS`;禁用账户返回 `403 ACCOUNT_DISABLED`。接口应按 IP 与邮箱摘要双维度限流,但不保存明文密码或将其记入日志。
|
成功响应与注册相同,刷新 token 写入新的 `HttpOnly` cookie。失败统一返回 `401 INVALID_CREDENTIALS`;禁用账户返回 `403 ACCOUNT_DISABLED`。接口应按 IP 与邮箱摘要双维度限流,但不保存明文密码或将其记入日志。
|
||||||
|
|
||||||
|
成功返回 `200 OK`、`Set-Cookie` refresh token 及上述响应体。
|
||||||
|
|
||||||
## 4. 刷新与退出
|
## 4. 刷新与退出
|
||||||
|
|
||||||
`POST /api/auth/refresh`
|
`POST /api/auth/refresh`
|
||||||
|
|
||||||
请求不需要 body,浏览器自动携带 refresh cookie。服务端轮换 refresh token、撤销旧 session 并返回新的短期 access token:
|
请求不需要 body,浏览器自动携带 refresh cookie。服务端轮换 refresh token、撤销旧 session 并以 `200 OK` 返回新的短期 access token:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{ "data": { "access_token": "eyJ…", "access_token_expires_in": 900 } }
|
{ "data": { "access_token": "eyJ…", "access_token_expires_in": 900 } }
|
||||||
|
|||||||
@@ -8,7 +8,9 @@
|
|||||||
Authorization: Bearer <short-lived-access-token>
|
Authorization: Bearer <short-lived-access-token>
|
||||||
```
|
```
|
||||||
|
|
||||||
访问令牌只携带用户会话标识,不以客户端传入的 `workspace_id` 判定数据归属。服务端解析用户、选择首期唯一的有效工作空间、验证成员关系后,在每一个数据库事务中执行 `SET LOCAL app.workspace_id`。RLS 仅是第二道防线,业务层仍须验证资源归属。
|
访问令牌只携带用户会话标识,不以客户端传入的 `workspace_id` 判定数据归属。全局认证守卫先验证 session,全局工作空间守卫再解析首期唯一的有效工作空间并验证成员关系;受保护处理器从 `request.auth.workspace` 或 `@CurrentWorkspace()` 取得已验证的 `workspaceId` 与角色。业务 SQL 必须将该 `workspaceId` 传给短生命周期的 `workspaceTransaction`,由其在每一个数据库事务中执行 `SET LOCAL app.workspace_id`。RLS 仅是第二道防线,业务层仍须验证资源归属。
|
||||||
|
|
||||||
|
`/health`、`/ready`、注册、登录与 refresh 是显式标记的公开端点。新接口默认受上述两个全局守卫保护;不得因便利而自行跳过,除非先在契约中说明公开理由。
|
||||||
|
|
||||||
Web 登录后的 refresh token 放在 `HttpOnly`、`Secure`(生产)、`SameSite=Lax` 的 cookie 中,永不出现在 JSON、日志或前端持久化存储。access token 由前端仅在内存中短暂持有;插件使用独立授权码流程,留待采集插件接口文档定义。
|
Web 登录后的 refresh token 放在 `HttpOnly`、`Secure`(生产)、`SameSite=Lax` 的 cookie 中,永不出现在 JSON、日志或前端持久化存储。access token 由前端仅在内存中短暂持有;插件使用独立授权码流程,留待采集插件接口文档定义。
|
||||||
|
|
||||||
|
|||||||
+9
-10
@@ -6,7 +6,7 @@
|
|||||||
|
|
||||||
这是一个全新的云端产品,不是 `ozon-seller-kit` 的本地版重构。目标用户可拥有多个门店,每个门店只能绑定一个平台;平台自身可支持多个市场、国家、语言和币种。因此语言、币种、类目和发布规则不能写死为 Ozon 或中文/俄文。
|
这是一个全新的云端产品,不是 `ozon-seller-kit` 的本地版重构。目标用户可拥有多个门店,每个门店只能绑定一个平台;平台自身可支持多个市场、国家、语言和币种。因此语言、币种、类目和发布规则不能写死为 Ozon 或中文/俄文。
|
||||||
|
|
||||||
当前已完成 pnpm monorepo 初始化:包含 Web、NestJS API、BullMQ Worker、WXT 浏览器扩展、共享 API 契约、数据库包、本地基础设施 Compose 与依赖锁文件。API 仅实现健康检查;Web 已具备完整前端框架(布局、路由、主题)和登录/注册页面,业务页面均为占位;Worker 与扩展为可运行骨架。尚未实现业务模块、迁移执行器、数据库 schema、鉴权或实际部署。不要从旧项目复制 SQLite 模型、无鉴权 API、旧 `/trial/:id` 或商品编辑页组件。可选择性复用已经验证过的纯计价公式、采集扩展思路和 AI 任务经验,但必须先适配多租户和云端边界。
|
当前已完成 pnpm monorepo 初始化:包含 Web、NestJS API、BullMQ Worker、WXT 浏览器扩展、共享 API 契约、数据库包、本地基础设施 Compose 与依赖锁文件。API 已实现健康/readiness、集中配置与安全基础、认证、个人工作空间上下文、平台目录/上下文、门店基础管理和操作查询;真实平台验证与同步 Worker 尚未接入。Web 已具备完整前端框架(布局、路由、主题)和登录/注册页面,业务页面均为占位;Worker 与扩展为可运行骨架。不要从旧项目复制 SQLite 模型、无鉴权 API、旧 `/trial/:id` 或商品编辑页组件。可选择性复用已经验证过的纯计价公式、采集扩展思路和 AI 任务经验,但必须先适配多租户和云端边界。
|
||||||
|
|
||||||
当前运行时基线:Node 24.20.0、pnpm 11.25.0、PostgreSQL 18.6、Redis 7.4.11、MinIO `RELEASE.2025-09-07T16-13-09Z`;Web 默认端口为 `8100`,API 默认端口为 `8200`,扩展 dev server 默认端口为 `8110`(仅本地开发用),Worker 无 HTTP 端口。完整目录、依赖边界与版本表见 [`architecture/project-structure.md`](../architecture/project-structure.md) 和 [`architecture/technology-and-deployment.md`](../architecture/technology-and-deployment.md)。
|
当前运行时基线:Node 24.20.0、pnpm 11.25.0、PostgreSQL 18.6、Redis 7.4.11、MinIO `RELEASE.2025-09-07T16-13-09Z`;Web 默认端口为 `8100`,API 默认端口为 `8200`,扩展 dev server 默认端口为 `8110`(仅本地开发用),Worker 无 HTTP 端口。完整目录、依赖边界与版本表见 [`architecture/project-structure.md`](../architecture/project-structure.md) 和 [`architecture/technology-and-deployment.md`](../architecture/technology-and-deployment.md)。
|
||||||
|
|
||||||
@@ -82,26 +82,25 @@ User → Workspace → Membership / Role
|
|||||||
|
|
||||||
## 5. 已冻结的第一阶段 API 契约
|
## 5. 已冻结的第一阶段 API 契约
|
||||||
|
|
||||||
第一阶段 HTTP API 的接口范围已讨论完成,当前状态为**设计冻结、业务接口尚未实现**。仓库已有 NestJS API 启动骨架及 `GET /health`,它不构成任何已实现的业务契约。后续编码以 [`docs/api/`](../api/README.md) 为唯一 HTTP 契约来源;旧 [`product/api.md`](../product/api.md) 仅作历史背景,不能作为实现依据。
|
第一阶段 HTTP API 的接口范围已讨论完成,当前状态为**设计冻结、部分业务接口已实现**。认证、平台目录/上下文、创建/查询/编辑/默认/删除门店、异步操作读取及仓库摘要读取已有实现与集成测试;门店凭据验证、仓库同步的实际平台调用仍待 Worker 消费 Outbox 后完成。后续编码以 [`docs/api/`](../api/README.md) 为唯一 HTTP 契约来源;旧 [`product/api.md`](../product/api.md) 仅作历史背景,不能作为实现依据。
|
||||||
|
|
||||||
- 覆盖账户认证、平台/门店、水印与素材、定价、浏览器采集/采集箱、商品编辑/首次发布、在线商品读取与同步。
|
- 覆盖账户认证、平台/门店、水印与素材、定价、浏览器采集/采集箱、商品编辑/首次发布、在线商品读取与同步。
|
||||||
- 所有接口遵循统一鉴权、工作空间隔离、页头平台上下文、金额字符串、错误格式、`Idempotency-Key`、`If-Match` 和异步 `operation` 约定。
|
- 所有接口遵循统一鉴权、工作空间隔离、页头平台上下文、金额字符串、错误格式、`Idempotency-Key`、`If-Match` 和异步 `operation` 约定。
|
||||||
- **响应信封是强制契约:** 成功单资源或同步动作返回 `{ "data": T }`,列表返回 `{ "data": T[], "meta": PaginationMeta }`,失败返回 `{ "error": ApiError }`,`204 No Content` 不返回响应体。项目不采用完整 JSON:API;任何调用方不得自行增加嵌套 `data` 或以 `data: null` 表示错误。完整规则见 [`api/conventions.md`](../api/conventions.md)。
|
- **响应信封是强制契约:** 成功单资源或同步动作返回 `{ "data": T }`,列表返回 `{ "data": T[], "meta": PaginationMeta }`,失败返回 `{ "error": ApiError }`,`204 No Content` 不返回响应体。项目不采用完整 JSON:API;任何调用方不得自行增加嵌套 `data` 或以 `data: null` 表示错误。完整规则见 [`api/conventions.md`](../api/conventions.md)。
|
||||||
- 关键异步动作必须在同一事务写入业务记录、`operations`(如需用户可见进度)和 `outbox_events`;Dispatcher 至少一次投递 BullMQ,Worker 必须幂等。
|
- 关键异步动作必须在同一事务写入业务记录、`operations`(如需用户可见进度)和 `outbox_events`;Dispatcher 至少一次投递 BullMQ,Worker 必须幂等。
|
||||||
- DDL 已通过临时隔离 PostgreSQL 实例执行验证;API 契约已完成文档评审。这不表示 NestJS 模块、迁移执行器或 Worker 已实现。
|
- DDL 已通过本地隔离 PostgreSQL 执行验证;迁移执行器、认证/工作空间基础、首个幂等与 Outbox 写入闭环以及上述门店 HTTP 模块已实现。Outbox Dispatcher 与平台 Worker 仍未实现。
|
||||||
|
|
||||||
仍需在对应模块开始编码前完成的 P1/P2 决策和数据模型补丁,集中记录在 [`reviews/implementation-readiness-open-items.md`](../reviews/implementation-readiness-open-items.md)。
|
仍需在对应模块开始编码前完成的 P1/P2 决策和数据模型补丁,集中记录在 [`reviews/implementation-readiness-open-items.md`](../reviews/implementation-readiness-open-items.md)。
|
||||||
|
|
||||||
## 6. 下一步建议
|
## 6. 下一步建议
|
||||||
|
|
||||||
项目初始化已完成;下一步进入实现准备,而不是继续扩大 API 范围:
|
当前优先完成门店纵向闭环,而不是继续扩展商品接口:
|
||||||
|
|
||||||
1. 以 `001_foundation.sql`、`002_product-domain.sql` 建立可重复执行的迁移执行器,并按数据库角色/RLS 约束验证本地环境。
|
1. 实现 Outbox Dispatcher 与 Worker 的首个消费链路:将 `store_credential_verification` 投递到 Worker,解密密钥后调用平台适配器,回写门店/密钥状态;失败信息必须脱敏。
|
||||||
2. 在 API 建立配置校验、认证、工作空间上下文和事务包装器;任何工作空间查询前固定 `SET LOCAL app.workspace_id`。
|
2. 在凭据验证通过后实现仓库同步 Worker、幂等 upsert 和状态回写,并补充失败重试、死信与集成测试。
|
||||||
3. 建立 `operations`、幂等请求和 Outbox Dispatcher 的第一个集成测试闭环。
|
3. Web 接入真实认证和门店管理 API:登录/注册、会话刷新、平台上下文、门店列表/创建/编辑/默认切换;前端不得保存 API Key 或 refresh token。
|
||||||
4. 在开始商品编辑/在线同步模块前,关闭评审文档中的 P1 事项:同商品归属约束、未映射属性持久化、乐观锁 Repository 规则、平台能力 Schema;并确认在线商品归档策略和 Client ID 编辑边界。
|
4. 接着实现素材直传和水印管理,再开始采集箱与商品编辑;浏览器扩展仅在采集 API 稳定后实现实际解析器。
|
||||||
5. 优先实现账户、平台上下文、门店、素材上传的纵向最小闭环,再接入采集箱和商品编辑;浏览器扩展仅在采集 API 稳定后实现实际解析器。
|
5. 在开始商品编辑/在线同步模块前,关闭评审文档中的 P1 事项:同商品归属约束、未映射属性持久化、乐观锁 Repository 规则、平台能力 Schema;并确认在线商品归档策略和 Client ID 编辑边界。
|
||||||
6. Web 侧:认证 API 就绪后把登录/注册接入真实 `/api/auth/*`,补主布局路由守卫与刷新后的会话恢复,auth 类型迁移至 `@atlas/contracts`。
|
|
||||||
|
|
||||||
## 7. 文档导航
|
## 7. 文档导航
|
||||||
|
|
||||||
|
|||||||
+27
-26
@@ -1,28 +1,29 @@
|
|||||||
{
|
{
|
||||||
"name": "atlas-seller-kit",
|
"name": "atlas-seller-kit",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"packageManager": "pnpm@11.25.0",
|
"packageManager": "pnpm@11.25.0",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=24.20.0 <25",
|
"node": ">=24.20.0 <25",
|
||||||
"pnpm": ">=11.25.0 <12"
|
"pnpm": ">=11.25.0 <12"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "turbo run build",
|
"build": "turbo run build",
|
||||||
"dev": "turbo run dev",
|
"dev": "turbo run dev",
|
||||||
"dev:backend": "turbo run dev --filter=@atlas/api --filter=@atlas/worker",
|
"dev:backend": "turbo run dev --filter=@atlas/api --filter=@atlas/worker",
|
||||||
"dev:frontend": "turbo run dev --filter=@atlas/web",
|
"dev:frontend": "turbo run dev --filter=@atlas/web",
|
||||||
"lint": "turbo run lint",
|
"lint": "turbo run lint",
|
||||||
"typecheck": "turbo run typecheck",
|
"typecheck": "turbo run typecheck",
|
||||||
"test": "turbo run test",
|
"test": "turbo run test",
|
||||||
"format": "prettier --config ./prettier.config.mjs --write .",
|
"test:api": "pnpm --filter @atlas/api test",
|
||||||
"format:check": "prettier --config ./prettier.config.mjs --check .",
|
"format": "prettier --write .",
|
||||||
"infra:up": "docker compose --env-file .env -f infra/compose.yaml up -d postgres redis minio",
|
"format:check": "prettier --check .",
|
||||||
"infra:down": "docker compose --env-file .env -f infra/compose.yaml down"
|
"infra:up": "docker compose --env-file .env -f infra/compose.yaml up -d postgres redis minio",
|
||||||
},
|
"infra:down": "docker compose --env-file .env -f infra/compose.yaml down"
|
||||||
"devDependencies": {
|
},
|
||||||
"prettier": "3.9.6",
|
"devDependencies": {
|
||||||
"turbo": "2.10.12",
|
"prettier": "3.9.6",
|
||||||
"typescript": "7.0.2"
|
"turbo": "2.10.12",
|
||||||
}
|
"typescript": "7.0.2"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,2 +1,3 @@
|
|||||||
export * from './collection';
|
export * from './collection';
|
||||||
export * from './auth';
|
export * from './auth';
|
||||||
|
export * from './stores';
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
const uuidSchema = z.uuid();
|
||||||
|
const currencyCodeSchema = z
|
||||||
|
.string()
|
||||||
|
.regex(/^[A-Z]{3}$/, '必须是 ISO 4217 大写币种代码');
|
||||||
|
|
||||||
|
/** 创建门店时唯一允许提交明文 API Key 的请求契约。 */
|
||||||
|
export const createStoreRequestSchema = z
|
||||||
|
.object({
|
||||||
|
platform_id: uuidSchema,
|
||||||
|
name: z.string().trim().min(1).max(120),
|
||||||
|
client_id: z.string().trim().min(1).max(120),
|
||||||
|
api_key: z.string().min(1).max(8_192),
|
||||||
|
currency_code: currencyCodeSchema,
|
||||||
|
is_default: z.boolean().default(false),
|
||||||
|
note: z.string().trim().max(2_000).nullable().optional(),
|
||||||
|
})
|
||||||
|
.strict();
|
||||||
|
|
||||||
|
/** 修改门店不允许变更平台或平台账户,避免授权连接身份被悄然替换。 */
|
||||||
|
export const updateStoreRequestSchema = z
|
||||||
|
.object({
|
||||||
|
name: z.string().trim().min(1).max(120).optional(),
|
||||||
|
currency_code: currencyCodeSchema.optional(),
|
||||||
|
note: z.string().trim().max(2_000).nullable().optional(),
|
||||||
|
api_key: z.string().min(1).max(8_192).optional(),
|
||||||
|
})
|
||||||
|
.strict()
|
||||||
|
.refine((value) => Object.keys(value).length > 0, {
|
||||||
|
message: '至少需要提交一个可修改字段',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const listStoresQuerySchema = z
|
||||||
|
.object({
|
||||||
|
platform_id: uuidSchema,
|
||||||
|
cursor: z.string().min(1).optional(),
|
||||||
|
limit: z.coerce.number().int().min(1).max(100).default(20),
|
||||||
|
})
|
||||||
|
.strict();
|
||||||
|
|
||||||
|
export type CreateStoreRequest = z.infer<typeof createStoreRequestSchema>;
|
||||||
|
export type UpdateStoreRequest = z.infer<typeof updateStoreRequestSchema>;
|
||||||
|
export type ListStoresQuery = z.infer<typeof listStoresQuerySchema>;
|
||||||
|
|
||||||
|
export type Store = {
|
||||||
|
id: string;
|
||||||
|
platform_id: string;
|
||||||
|
name: string;
|
||||||
|
platform_client_id: string;
|
||||||
|
currency_code: string;
|
||||||
|
is_default: boolean;
|
||||||
|
note: string | null;
|
||||||
|
status: string;
|
||||||
|
credential_status: string;
|
||||||
|
credential_checked_at: string | null;
|
||||||
|
last_error: { code: string; message: string; at: string } | null;
|
||||||
|
api_key_expires_at: string | null;
|
||||||
|
product_limit: number | null;
|
||||||
|
product_limit_checked_at: string | null;
|
||||||
|
warehouse_count: number;
|
||||||
|
warehouses_synced_at: string | null;
|
||||||
|
cookie_status: null;
|
||||||
|
watermark_template: { id: string; name: string } | null;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
revision: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Operation = {
|
||||||
|
id: string;
|
||||||
|
kind: string;
|
||||||
|
status: string;
|
||||||
|
created_at: string;
|
||||||
|
completed_at: string | null;
|
||||||
|
error: { code: string; message: string } | null;
|
||||||
|
};
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
-- 平台默认上下文属于平台能力,不属于门店;此迁移同时修正已初始化的本地数据库。
|
||||||
|
SET search_path TO atlas, public;
|
||||||
|
|
||||||
|
UPDATE platforms
|
||||||
|
SET capabilities = capabilities || jsonb_build_object(
|
||||||
|
'default_market_code', 'RU',
|
||||||
|
'default_locale', 'ru-RU'
|
||||||
|
),
|
||||||
|
updated_at = now()
|
||||||
|
WHERE code = 'OZON';
|
||||||
@@ -8,6 +8,7 @@ const migrationsDirectory = join(
|
|||||||
dirname(fileURLToPath(import.meta.url)),
|
dirname(fileURLToPath(import.meta.url)),
|
||||||
'../migrations',
|
'../migrations',
|
||||||
);
|
);
|
||||||
|
// 迁移只能使用拥有 schema/table 的专用角色,绝不允许复用 API 的最小权限连接。
|
||||||
const connectionString = process.env.MIGRATOR_DATABASE_URL;
|
const connectionString = process.env.MIGRATOR_DATABASE_URL;
|
||||||
|
|
||||||
if (!connectionString) {
|
if (!connectionString) {
|
||||||
@@ -24,6 +25,7 @@ async function migrate() {
|
|||||||
.sort();
|
.sort();
|
||||||
const client = await pool.connect();
|
const client = await pool.connect();
|
||||||
try {
|
try {
|
||||||
|
// 迁移账本与业务 DDL 同库保存,用于阻止已应用 migration 被悄悄改写。
|
||||||
await client.query('CREATE SCHEMA IF NOT EXISTS atlas');
|
await client.query('CREATE SCHEMA IF NOT EXISTS atlas');
|
||||||
await client.query(`CREATE TABLE IF NOT EXISTS atlas.schema_migrations (
|
await client.query(`CREATE TABLE IF NOT EXISTS atlas.schema_migrations (
|
||||||
filename TEXT PRIMARY KEY,
|
filename TEXT PRIMARY KEY,
|
||||||
@@ -41,6 +43,7 @@ async function migrate() {
|
|||||||
[filename],
|
[filename],
|
||||||
);
|
);
|
||||||
if (applied.rowCount) {
|
if (applied.rowCount) {
|
||||||
|
// 已应用文件的内容变化意味着历史与当前 schema 可能不一致,必须人工处理。
|
||||||
if (applied.rows[0].checksum !== checksum)
|
if (applied.rows[0].checksum !== checksum)
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Applied migration was modified: ${filename}`,
|
`Applied migration was modified: ${filename}`,
|
||||||
@@ -49,6 +52,7 @@ async function migrate() {
|
|||||||
}
|
}
|
||||||
await client.query('BEGIN');
|
await client.query('BEGIN');
|
||||||
try {
|
try {
|
||||||
|
// 单个文件原子提交;失败时不会留下半份 DDL 或 migration 账本记录。
|
||||||
await client.query(sql);
|
await client.query(sql);
|
||||||
await client.query(
|
await client.query(
|
||||||
'INSERT INTO atlas.schema_migrations (filename, checksum) VALUES ($1, $2)',
|
'INSERT INTO atlas.schema_migrations (filename, checksum) VALUES ($1, $2)',
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ export class Database {
|
|||||||
const client = await this.pool.connect();
|
const client = await this.pool.connect();
|
||||||
try {
|
try {
|
||||||
await client.query('BEGIN');
|
await client.query('BEGIN');
|
||||||
// atlas_app_login is NOINHERIT by design; application DML always uses the least-privileged group role.
|
// atlas_app_login 是 NOINHERIT 登录角色;应用 DML 必须降为最小权限的 atlas_app。
|
||||||
await client.query('SET LOCAL ROLE atlas_app');
|
await client.query('SET LOCAL ROLE atlas_app');
|
||||||
const result = await work(client);
|
const result = await work(client);
|
||||||
await client.query('COMMIT');
|
await client.query('COMMIT');
|
||||||
@@ -24,13 +24,13 @@ export class Database {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Every tenant-scoped query must run through this method. */
|
/** 每个带工作空间边界的业务读写都必须通过此方法执行。 */
|
||||||
public async workspaceTransaction<T>(
|
public async workspaceTransaction<T>(
|
||||||
workspaceId: string,
|
workspaceId: string,
|
||||||
work: (transaction: DatabaseTransaction) => Promise<T>,
|
work: (transaction: DatabaseTransaction) => Promise<T>,
|
||||||
): Promise<T> {
|
): Promise<T> {
|
||||||
return this.transaction(async (transaction) => {
|
return this.transaction(async (transaction) => {
|
||||||
// SET LOCAL is scoped to this transaction and cannot leak a tenant id through the pool.
|
// SET LOCAL 仅作用于当前事务,连接归还池后不会泄漏租户 ID。
|
||||||
await transaction.query(
|
await transaction.query(
|
||||||
"SELECT set_config('app.workspace_id', $1, true)",
|
"SELECT set_config('app.workspace_id', $1, true)",
|
||||||
[workspaceId],
|
[workspaceId],
|
||||||
@@ -43,7 +43,7 @@ export class Database {
|
|||||||
text: string,
|
text: string,
|
||||||
values?: unknown[],
|
values?: unknown[],
|
||||||
) {
|
) {
|
||||||
// Even non-tenant reads use the restricted role; callers cannot accidentally use the login role's privileges.
|
// 即使不带租户上下文的查询也使用受限角色,调用方不会误用登录角色权限。
|
||||||
return this.transaction((transaction) =>
|
return this.transaction((transaction) =>
|
||||||
transaction.query<Row>(text, values),
|
transaction.query<Row>(text, values),
|
||||||
);
|
);
|
||||||
|
|||||||
Generated
+4559
-5678
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user