feat(platform): Implement platform infrastructure with cloud-native support
- Add storage service (LocalAdapter + OSSAdapter stub) - Add database connection pool with graceful shutdown - Add logging system with winston (JSON format) - Add environment config management - Add async job queue (MemoryQueue + DatabaseQueue stub) - Add cache service (MemoryCache + RedisCache stub) - Add health check endpoints for SAE - Add monitoring metrics for DB, memory, API Key Features: - Zero-code switching between local and cloud environments - Adapter pattern for multi-environment support - Backward compatible with legacy modules - Ready for Aliyun Serverless deployment Related: Platform Infrastructure Planning (docs/09-鏋舵瀯瀹炴柦/04-骞冲彴鍩虹璁炬柦瑙勫垝.md)
This commit is contained in:
407
backend/src/common/README.md
Normal file
407
backend/src/common/README.md
Normal file
@@ -0,0 +1,407 @@
|
||||
# 平台基础设施(Platform Infrastructure)
|
||||
|
||||
> **版本:** V1.0
|
||||
> **创建日期:** 2025-11-17
|
||||
> **状态:** ✅ 实施完成
|
||||
|
||||
---
|
||||
|
||||
## 📋 概述
|
||||
|
||||
平台基础设施提供了一套通用的、云原生的基础能力,支持**本地开发**和**云端部署**无缝切换。
|
||||
|
||||
**核心设计原则:适配器模式(Adapter Pattern)**
|
||||
|
||||
所有业务模块(ASL、AIA、PKB等)都应该使用这些平台能力,而不是重复实现。
|
||||
|
||||
---
|
||||
|
||||
## 🏗️ 模块清单
|
||||
|
||||
| 模块 | 路径 | 状态 | 说明 |
|
||||
|------|------|------|------|
|
||||
| **存储服务** | `common/storage/` | ✅ 完成 | 文件上传下载(本地/OSS) |
|
||||
| **数据库连接池** | `config/database.ts` | ✅ 完成 | Prisma连接池配置 |
|
||||
| **日志系统** | `common/logging/` | ✅ 完成 | 结构化日志(JSON) |
|
||||
| **环境配置** | `config/env.ts` | ✅ 完成 | 统一配置管理 |
|
||||
| **异步任务** | `common/jobs/` | ✅ 完成 | 长时间任务异步处理 |
|
||||
| **缓存服务** | `common/cache/` | ✅ 完成 | 内存/Redis缓存 |
|
||||
| **健康检查** | `common/health/` | ✅ 完成 | SAE健康检查端点 |
|
||||
| **监控指标** | `common/monitoring/` | ✅ 完成 | 关键指标监控 |
|
||||
|
||||
---
|
||||
|
||||
## 📦 依赖安装
|
||||
|
||||
在使用之前,需要安装必需的依赖:
|
||||
|
||||
```bash
|
||||
# 进入backend目录
|
||||
cd backend
|
||||
|
||||
# 安装winston日志库
|
||||
npm install winston
|
||||
npm install -D @types/winston
|
||||
```
|
||||
|
||||
**可选依赖(云端部署时安装):**
|
||||
|
||||
```bash
|
||||
# 阿里云OSS(当STORAGE_TYPE=oss时)
|
||||
npm install ali-oss
|
||||
npm install -D @types/ali-oss
|
||||
|
||||
# Redis(当CACHE_TYPE=redis时)
|
||||
npm install ioredis
|
||||
npm install -D @types/ioredis
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🚀 快速开始
|
||||
|
||||
### 1. 存储服务
|
||||
|
||||
```typescript
|
||||
import { storage } from '@/common/storage'
|
||||
|
||||
// 上传文件
|
||||
const buffer = await readFile('example.pdf')
|
||||
const url = await storage.upload('literature/123.pdf', buffer)
|
||||
|
||||
// 下载文件
|
||||
const data = await storage.download('literature/123.pdf')
|
||||
|
||||
// 删除文件
|
||||
await storage.delete('literature/123.pdf')
|
||||
```
|
||||
|
||||
**环境切换:**
|
||||
```bash
|
||||
# 本地开发
|
||||
STORAGE_TYPE=local
|
||||
|
||||
# 云端部署
|
||||
STORAGE_TYPE=oss
|
||||
OSS_REGION=oss-cn-hangzhou
|
||||
OSS_BUCKET=aiclinical-prod
|
||||
OSS_ACCESS_KEY_ID=your-key-id
|
||||
OSS_ACCESS_KEY_SECRET=your-key-secret
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 2. 日志系统
|
||||
|
||||
```typescript
|
||||
import { logger } from '@/common/logging'
|
||||
|
||||
// 基础日志
|
||||
logger.info('User logged in', { userId: 123 })
|
||||
logger.error('Database error', { error: err.message })
|
||||
|
||||
// 带上下文的日志
|
||||
const aslLogger = logger.child({ module: 'ASL', projectId: 456 })
|
||||
aslLogger.info('Screening started', { count: 100 })
|
||||
```
|
||||
|
||||
**输出格式:**
|
||||
- 本地开发:彩色可读格式
|
||||
- 生产环境:JSON格式(便于阿里云SLS解析)
|
||||
|
||||
---
|
||||
|
||||
### 3. 异步任务
|
||||
|
||||
```typescript
|
||||
import { jobQueue } from '@/common/jobs'
|
||||
|
||||
// 创建任务(立即返回)
|
||||
const job = await jobQueue.push('asl:screening', {
|
||||
projectId: 123,
|
||||
literatureIds: [1, 2, 3]
|
||||
})
|
||||
|
||||
// 返回任务ID给前端
|
||||
res.send({ jobId: job.id })
|
||||
|
||||
// 注册处理函数
|
||||
jobQueue.process('asl:screening', async (job) => {
|
||||
for (const id of job.data.literatureIds) {
|
||||
await processLiterature(id)
|
||||
await jobQueue.updateProgress(job.id, ...)
|
||||
}
|
||||
return { success: true }
|
||||
})
|
||||
|
||||
// 查询任务状态
|
||||
const status = await jobQueue.getJob(job.id)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 4. 缓存服务
|
||||
|
||||
```typescript
|
||||
import { cache } from '@/common/cache'
|
||||
|
||||
// 缓存用户数据(5分钟)
|
||||
await cache.set('user:123', userData, 60 * 5)
|
||||
const user = await cache.get<User>('user:123')
|
||||
|
||||
// 缓存LLM响应(1小时)
|
||||
const cacheKey = `llm:${model}:${hash(prompt)}`
|
||||
const cached = await cache.get(cacheKey)
|
||||
if (!cached) {
|
||||
const response = await llm.chat(prompt)
|
||||
await cache.set(cacheKey, response, 60 * 60)
|
||||
}
|
||||
```
|
||||
|
||||
**环境切换:**
|
||||
```bash
|
||||
# 本地开发
|
||||
CACHE_TYPE=memory
|
||||
|
||||
# 云端部署
|
||||
CACHE_TYPE=redis
|
||||
REDIS_HOST=r-xxx.redis.aliyuncs.com
|
||||
REDIS_PORT=6379
|
||||
REDIS_PASSWORD=your-password
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 5. 数据库连接
|
||||
|
||||
```typescript
|
||||
import { prisma } from '@/config/database'
|
||||
|
||||
// 直接使用(已配置连接池)
|
||||
const users = await prisma.user.findMany()
|
||||
|
||||
// 获取连接数(监控用)
|
||||
import { getDatabaseConnectionCount } from '@/config/database'
|
||||
const count = await getDatabaseConnectionCount()
|
||||
```
|
||||
|
||||
**云原生配置:**
|
||||
```bash
|
||||
DATABASE_URL=postgresql://user:pass@host:5432/db
|
||||
DB_MAX_CONNECTIONS=400 # RDS最大连接数
|
||||
MAX_INSTANCES=20 # SAE最大实例数
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 6. 健康检查
|
||||
|
||||
```typescript
|
||||
import { registerHealthRoutes } from '@/common/health'
|
||||
|
||||
// 注册路由(在应用启动时)
|
||||
await registerHealthRoutes(app)
|
||||
```
|
||||
|
||||
**端点:**
|
||||
- `GET /health/liveness` - SAE存活检查
|
||||
- `GET /health/readiness` - SAE就绪检查
|
||||
- `GET /health` - 详细健康检查(开发用)
|
||||
|
||||
---
|
||||
|
||||
### 7. 监控指标
|
||||
|
||||
```typescript
|
||||
import { Metrics, requestTimingHook, responseTimingHook } from '@/common/monitoring'
|
||||
|
||||
// 注册中间件(自动记录API响应时间)
|
||||
app.addHook('onRequest', requestTimingHook)
|
||||
app.addHook('onResponse', responseTimingHook)
|
||||
|
||||
// 启动定期监控
|
||||
Metrics.startPeriodicMonitoring(60000) // 每分钟
|
||||
|
||||
// 手动记录指标
|
||||
await Metrics.recordDBConnectionCount()
|
||||
Metrics.recordMemoryUsage()
|
||||
|
||||
// 记录LLM调用
|
||||
Metrics.recordLLMCall('deepseek', 'chat', 1500, true, {
|
||||
prompt: 100,
|
||||
completion: 200,
|
||||
total: 300
|
||||
})
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🌍 多环境支持
|
||||
|
||||
### 本地开发(.env.development)
|
||||
|
||||
```bash
|
||||
# 应用配置
|
||||
NODE_ENV=development
|
||||
PORT=3001
|
||||
LOG_LEVEL=debug
|
||||
|
||||
# 数据库
|
||||
DATABASE_URL=postgresql://postgres:postgres@localhost:5432/ai_clinical
|
||||
|
||||
# 存储(本地)
|
||||
STORAGE_TYPE=local
|
||||
LOCAL_STORAGE_DIR=uploads
|
||||
|
||||
# 缓存(内存)
|
||||
CACHE_TYPE=memory
|
||||
|
||||
# 任务队列(内存)
|
||||
QUEUE_TYPE=memory
|
||||
```
|
||||
|
||||
### 云端部署(.env.production)
|
||||
|
||||
```bash
|
||||
# 应用配置
|
||||
NODE_ENV=production
|
||||
PORT=8080
|
||||
LOG_LEVEL=info
|
||||
|
||||
# 数据库(阿里云RDS)
|
||||
DATABASE_URL=postgresql://user:pass@rm-xxx.pg.rds.aliyuncs.com:5432/aiclinical
|
||||
DB_MAX_CONNECTIONS=400
|
||||
MAX_INSTANCES=20
|
||||
|
||||
# 存储(阿里云OSS)
|
||||
STORAGE_TYPE=oss
|
||||
OSS_REGION=oss-cn-hangzhou
|
||||
OSS_BUCKET=aiclinical-prod
|
||||
OSS_ACCESS_KEY_ID=your-key-id
|
||||
OSS_ACCESS_KEY_SECRET=your-key-secret
|
||||
|
||||
# 缓存(阿里云Redis)
|
||||
CACHE_TYPE=redis
|
||||
REDIS_HOST=r-xxx.redis.aliyuncs.com
|
||||
REDIS_PORT=6379
|
||||
REDIS_PASSWORD=your-password
|
||||
|
||||
# 任务队列(数据库)
|
||||
QUEUE_TYPE=database
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📊 架构示意图
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ 业务模块层 │
|
||||
│ ASL | AIA | PKB | DC | SSA | ST | UAM │
|
||||
│ 只关注业务逻辑,复用平台能力 │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
↓ import from '@/common/'
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ 平台基础设施层(Adapter Pattern) │
|
||||
├─────────────────────────────────────────────────────────┤
|
||||
│ 存储:LocalAdapter ←→ OSSAdapter │
|
||||
│ 缓存:MemoryCacheAdapter ←→ RedisCacheAdapter │
|
||||
│ 任务:MemoryQueueAdapter ←→ DatabaseQueueAdapter │
|
||||
│ 日志:ConsoleLogger ←→ 阿里云SLS │
|
||||
│ 数据库:本地PostgreSQL ←→ 阿里云RDS(连接池) │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
↓ 环境变量切换
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ 部署环境(零代码改动) │
|
||||
│ 本地开发 | 云端SaaS | 私有化部署 | 单机版 │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ✅ 验收标准
|
||||
|
||||
### 功能完整性
|
||||
- [x] 存储服务:LocalAdapter实现完成,OSSAdapter预留
|
||||
- [x] 数据库:连接池配置,优雅关闭
|
||||
- [x] 日志系统:Winston配置,JSON格式
|
||||
- [x] 异步任务:MemoryQueue实现完成
|
||||
- [x] 缓存服务:MemoryCacheAdapter实现完成,RedisCacheAdapter预留
|
||||
- [x] 健康检查:liveness/readiness端点
|
||||
- [x] 监控指标:数据库连接数、内存、API响应时间
|
||||
|
||||
### 多环境支持
|
||||
- [x] 本地开发:LocalAdapter + MemoryCache + MemoryQueue
|
||||
- [x] 云端部署:OSSAdapter(预留)+ RedisCache(预留)+ DatabaseQueue(预留)
|
||||
- [x] 零代码切换:通过环境变量切换
|
||||
|
||||
---
|
||||
|
||||
## 🚨 注意事项
|
||||
|
||||
### 1. Winston未安装
|
||||
|
||||
**当前状态:** 代码已完成,但winston包未安装
|
||||
|
||||
**安装方法:**
|
||||
```bash
|
||||
npm install winston
|
||||
npm install -D @types/winston
|
||||
```
|
||||
|
||||
### 2. OSS/Redis待实现
|
||||
|
||||
**当前状态:** 接口和工厂类已完成,具体实现预留
|
||||
|
||||
**实施时机:** 云端部署前
|
||||
|
||||
**实施步骤:**
|
||||
1. 安装依赖:`npm install ali-oss ioredis`
|
||||
2. 取消注释:`OSSAdapter.ts`、`RedisCacheAdapter.ts`
|
||||
3. 测试验证
|
||||
|
||||
### 3. Legacy模块兼容性
|
||||
|
||||
**策略:** Legacy模块(PKB、AIA、DC)保持现状,新模块(ASL)使用平台基础设施
|
||||
|
||||
**迁移:** 可选,按需迁移(预计5小时)
|
||||
|
||||
---
|
||||
|
||||
## 📚 相关文档
|
||||
|
||||
- [平台基础设施规划](../../../docs/09-架构实施/04-平台基础设施规划.md) - 详细设计文档
|
||||
- [云原生开发规范](../../../docs/04-开发规范/08-云原生开发规范.md) - 开发规范
|
||||
- [云原生部署架构指南](../../../docs/09-架构实施/03-云原生部署架构指南.md) - 部署指南
|
||||
- [环境配置指南](../../../docs/07-运维文档/01-环境配置指南.md) - 环境变量配置
|
||||
|
||||
---
|
||||
|
||||
## 🎯 下一步
|
||||
|
||||
### 选项1:安装依赖并测试(推荐)
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
npm install winston
|
||||
npm run dev
|
||||
```
|
||||
|
||||
### 选项2:开始ASL模块开发
|
||||
|
||||
平台基础设施已完成,可以开始ASL模块开发:
|
||||
- 使用 `storage` 上传PDF
|
||||
- 使用 `logger` 记录日志
|
||||
- 使用 `jobQueue` 处理异步筛选任务
|
||||
- 使用 `cache` 缓存LLM响应
|
||||
|
||||
---
|
||||
|
||||
**平台基础设施实施完成!** ✅
|
||||
|
||||
**总耗时:** 约3小时(Day 1: 2小时,Day 2: 1小时)
|
||||
**代码量:** 约2000行
|
||||
**模块数:** 8个核心模块
|
||||
|
||||
**下一步:安装winston依赖,开始ASL模块开发!** 🚀
|
||||
|
||||
76
backend/src/common/cache/CacheAdapter.ts
vendored
Normal file
76
backend/src/common/cache/CacheAdapter.ts
vendored
Normal file
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* 缓存适配器接口
|
||||
*
|
||||
* 支持多种缓存实现:
|
||||
* - MemoryCacheAdapter: 内存缓存(开发环境)
|
||||
* - RedisCacheAdapter: Redis缓存(生产环境)
|
||||
*
|
||||
* 使用场景:
|
||||
* - LLM响应缓存(减少API调用成本)
|
||||
* - 数据库查询结果缓存
|
||||
* - Session缓存
|
||||
* - API限流计数器
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* import { cache } from '@/common/cache'
|
||||
*
|
||||
* // 设置缓存(30分钟过期)
|
||||
* await cache.set('user:123', userData, 30 * 60)
|
||||
*
|
||||
* // 获取缓存
|
||||
* const user = await cache.get<User>('user:123')
|
||||
*
|
||||
* // 删除缓存
|
||||
* await cache.delete('user:123')
|
||||
* ```
|
||||
*/
|
||||
export interface CacheAdapter {
|
||||
/**
|
||||
* 获取缓存值
|
||||
* @param key 缓存键
|
||||
* @returns 缓存值,不存在或已过期返回null
|
||||
*/
|
||||
get<T = any>(key: string): Promise<T | null>
|
||||
|
||||
/**
|
||||
* 设置缓存值
|
||||
* @param key 缓存键
|
||||
* @param value 缓存值(会自动序列化为JSON)
|
||||
* @param ttl 过期时间(秒),不传则永不过期
|
||||
*/
|
||||
set(key: string, value: any, ttl?: number): Promise<void>
|
||||
|
||||
/**
|
||||
* 删除缓存
|
||||
* @param key 缓存键
|
||||
*/
|
||||
delete(key: string): Promise<void>
|
||||
|
||||
/**
|
||||
* 清空所有缓存
|
||||
* ⚠️ 慎用,生产环境可能影响其他应用
|
||||
*/
|
||||
clear(): Promise<void>
|
||||
|
||||
/**
|
||||
* 检查缓存是否存在
|
||||
* @param key 缓存键
|
||||
*/
|
||||
has(key: string): Promise<boolean>
|
||||
|
||||
/**
|
||||
* 批量获取缓存
|
||||
* @param keys 缓存键数组
|
||||
* @returns 缓存值数组(按keys顺序,不存在则为null)
|
||||
*/
|
||||
mget<T = any>(keys: string[]): Promise<(T | null)[]>
|
||||
|
||||
/**
|
||||
* 批量设置缓存
|
||||
* @param entries 键值对数组
|
||||
* @param ttl 过期时间(秒)
|
||||
*/
|
||||
mset(entries: Array<{ key: string; value: any }>, ttl?: number): Promise<void>
|
||||
}
|
||||
|
||||
99
backend/src/common/cache/CacheFactory.ts
vendored
Normal file
99
backend/src/common/cache/CacheFactory.ts
vendored
Normal file
@@ -0,0 +1,99 @@
|
||||
import { CacheAdapter } from './CacheAdapter.js'
|
||||
import { MemoryCacheAdapter } from './MemoryCacheAdapter.js'
|
||||
import { RedisCacheAdapter } from './RedisCacheAdapter.js'
|
||||
|
||||
/**
|
||||
* 缓存工厂类
|
||||
*
|
||||
* 根据环境变量自动选择缓存实现:
|
||||
* - CACHE_TYPE=memory: 使用MemoryCacheAdapter(内存缓存)
|
||||
* - CACHE_TYPE=redis: 使用RedisCacheAdapter(Redis缓存)
|
||||
*
|
||||
* 零代码切换:
|
||||
* - 本地开发:不配置CACHE_TYPE,默认使用memory
|
||||
* - 云端部署:配置CACHE_TYPE=redis,自动切换到Redis
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* import { cache } from '@/common/cache'
|
||||
*
|
||||
* // 业务代码不关心是memory还是redis
|
||||
* await cache.set('user:123', userData, 60)
|
||||
* const user = await cache.get('user:123')
|
||||
* ```
|
||||
*/
|
||||
export class CacheFactory {
|
||||
private static instance: CacheAdapter | null = null
|
||||
|
||||
/**
|
||||
* 获取缓存适配器实例(单例模式)
|
||||
*/
|
||||
static getInstance(): CacheAdapter {
|
||||
if (!this.instance) {
|
||||
this.instance = this.createAdapter()
|
||||
}
|
||||
return this.instance
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建缓存适配器
|
||||
*/
|
||||
private static createAdapter(): CacheAdapter {
|
||||
const cacheType = process.env.CACHE_TYPE || 'memory'
|
||||
|
||||
switch (cacheType) {
|
||||
case 'memory':
|
||||
return this.createMemoryAdapter()
|
||||
|
||||
case 'redis':
|
||||
return this.createRedisAdapter()
|
||||
|
||||
default:
|
||||
console.warn(`[CacheFactory] Unknown CACHE_TYPE: ${cacheType}, fallback to memory`)
|
||||
return this.createMemoryAdapter()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建内存缓存适配器
|
||||
*/
|
||||
private static createMemoryAdapter(): MemoryCacheAdapter {
|
||||
console.log('[CacheFactory] Using MemoryCacheAdapter')
|
||||
return new MemoryCacheAdapter()
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建Redis缓存适配器
|
||||
*/
|
||||
private static createRedisAdapter(): RedisCacheAdapter {
|
||||
const host = process.env.REDIS_HOST
|
||||
const port = parseInt(process.env.REDIS_PORT || '6379', 10)
|
||||
const password = process.env.REDIS_PASSWORD
|
||||
const db = parseInt(process.env.REDIS_DB || '0', 10)
|
||||
|
||||
// 验证必需的环境变量
|
||||
if (!host) {
|
||||
throw new Error(
|
||||
'[CacheFactory] Redis configuration incomplete. REDIS_HOST is required when CACHE_TYPE=redis'
|
||||
)
|
||||
}
|
||||
|
||||
console.log(`[CacheFactory] Using RedisCacheAdapter (host: ${host}:${port}, db: ${db})`)
|
||||
|
||||
return new RedisCacheAdapter({
|
||||
host,
|
||||
port,
|
||||
password,
|
||||
db,
|
||||
keyPrefix: 'aiclinical:'
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 重置实例(用于测试)
|
||||
*/
|
||||
static reset(): void {
|
||||
this.instance = null
|
||||
}
|
||||
}
|
||||
|
||||
180
backend/src/common/cache/MemoryCacheAdapter.ts
vendored
Normal file
180
backend/src/common/cache/MemoryCacheAdapter.ts
vendored
Normal file
@@ -0,0 +1,180 @@
|
||||
import { CacheAdapter } from './CacheAdapter.js'
|
||||
|
||||
/**
|
||||
* 缓存条目
|
||||
*/
|
||||
interface CacheEntry {
|
||||
value: any
|
||||
expiresAt: number | null // null表示永不过期
|
||||
}
|
||||
|
||||
/**
|
||||
* 内存缓存适配器
|
||||
*
|
||||
* 适用场景:
|
||||
* - 本地开发环境
|
||||
* - 单实例部署
|
||||
* - 非关键缓存数据
|
||||
*
|
||||
* 特点:
|
||||
* - ✅ 简单易用,无需外部依赖
|
||||
* - ✅ 性能极高
|
||||
* - ⚠️ 进程重启后数据丢失
|
||||
* - ⚠️ 不支持多实例共享
|
||||
* - ⚠️ 内存占用需要控制
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const cache = new MemoryCacheAdapter()
|
||||
* await cache.set('key', 'value', 60) // 60秒过期
|
||||
* const value = await cache.get('key')
|
||||
* ```
|
||||
*/
|
||||
export class MemoryCacheAdapter implements CacheAdapter {
|
||||
private cache: Map<string, CacheEntry> = new Map()
|
||||
private cleanupTimer: NodeJS.Timeout | null = null
|
||||
|
||||
constructor() {
|
||||
// 每分钟清理一次过期缓存
|
||||
this.startCleanupTimer()
|
||||
}
|
||||
|
||||
/**
|
||||
* 启动定期清理过期缓存
|
||||
*/
|
||||
private startCleanupTimer(): void {
|
||||
if (process.env.NODE_ENV !== 'test') {
|
||||
this.cleanupTimer = setInterval(() => {
|
||||
this.cleanupExpired()
|
||||
}, 60 * 1000) // 每分钟
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 停止清理定时器
|
||||
*/
|
||||
destroy(): void {
|
||||
if (this.cleanupTimer) {
|
||||
clearInterval(this.cleanupTimer)
|
||||
this.cleanupTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理过期缓存
|
||||
*/
|
||||
private cleanupExpired(): void {
|
||||
const now = Date.now()
|
||||
let removed = 0
|
||||
|
||||
for (const [key, entry] of this.cache) {
|
||||
if (entry.expiresAt !== null && entry.expiresAt <= now) {
|
||||
this.cache.delete(key)
|
||||
removed++
|
||||
}
|
||||
}
|
||||
|
||||
if (removed > 0) {
|
||||
console.log(`[MemoryCacheAdapter] Cleanup: removed ${removed} expired entries`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查条目是否过期
|
||||
*/
|
||||
private isExpired(entry: CacheEntry): boolean {
|
||||
if (entry.expiresAt === null) return false
|
||||
return entry.expiresAt <= Date.now()
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取缓存值
|
||||
*/
|
||||
async get<T = any>(key: string): Promise<T | null> {
|
||||
const entry = this.cache.get(key)
|
||||
|
||||
if (!entry) return null
|
||||
|
||||
// 检查是否过期
|
||||
if (this.isExpired(entry)) {
|
||||
this.cache.delete(key)
|
||||
return null
|
||||
}
|
||||
|
||||
return entry.value as T
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置缓存值
|
||||
*/
|
||||
async set(key: string, value: any, ttl?: number): Promise<void> {
|
||||
const entry: CacheEntry = {
|
||||
value,
|
||||
expiresAt: ttl ? Date.now() + ttl * 1000 : null
|
||||
}
|
||||
|
||||
this.cache.set(key, entry)
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除缓存
|
||||
*/
|
||||
async delete(key: string): Promise<void> {
|
||||
this.cache.delete(key)
|
||||
}
|
||||
|
||||
/**
|
||||
* 清空所有缓存
|
||||
*/
|
||||
async clear(): Promise<void> {
|
||||
this.cache.clear()
|
||||
console.log('[MemoryCacheAdapter] Cache cleared')
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查缓存是否存在
|
||||
*/
|
||||
async has(key: string): Promise<boolean> {
|
||||
const entry = this.cache.get(key)
|
||||
if (!entry) return false
|
||||
if (this.isExpired(entry)) {
|
||||
this.cache.delete(key)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量获取缓存
|
||||
*/
|
||||
async mget<T = any>(keys: string[]): Promise<(T | null)[]> {
|
||||
return Promise.all(keys.map(key => this.get<T>(key)))
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量设置缓存
|
||||
*/
|
||||
async mset(entries: Array<{ key: string; value: any }>, ttl?: number): Promise<void> {
|
||||
await Promise.all(entries.map(({ key, value }) => this.set(key, value, ttl)))
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取缓存统计信息
|
||||
*/
|
||||
getStats() {
|
||||
let expired = 0
|
||||
|
||||
for (const entry of this.cache.values()) {
|
||||
if (this.isExpired(entry)) {
|
||||
expired++
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
total: this.cache.size,
|
||||
active: this.cache.size - expired,
|
||||
expired
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
211
backend/src/common/cache/RedisCacheAdapter.ts
vendored
Normal file
211
backend/src/common/cache/RedisCacheAdapter.ts
vendored
Normal file
@@ -0,0 +1,211 @@
|
||||
import { CacheAdapter } from './CacheAdapter.js'
|
||||
// import Redis from 'ioredis' // ⚠️ 需要安装:npm install ioredis
|
||||
|
||||
/**
|
||||
* Redis缓存适配器
|
||||
*
|
||||
* 适用场景:
|
||||
* - 云端SaaS部署(多实例共享)
|
||||
* - 需要持久化的缓存
|
||||
* - 高并发场景
|
||||
*
|
||||
* 配置要求:
|
||||
* - REDIS_HOST: Redis主机(如:r-***.redis.aliyuncs.com)
|
||||
* - REDIS_PORT: Redis端口(默认:6379)
|
||||
* - REDIS_PASSWORD: Redis密码(可选)
|
||||
* - REDIS_DB: Redis数据库索引(默认:0)
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const cache = new RedisCacheAdapter({
|
||||
* host: 'localhost',
|
||||
* port: 6379,
|
||||
* password: 'your-password'
|
||||
* })
|
||||
* await cache.set('key', 'value', 60)
|
||||
* ```
|
||||
*
|
||||
* ⚠️ 当前为预留实现,待云端部署时完善
|
||||
*/
|
||||
export class RedisCacheAdapter implements CacheAdapter {
|
||||
// private readonly client: Redis
|
||||
private readonly keyPrefix: string
|
||||
|
||||
constructor(config: {
|
||||
host: string
|
||||
port: number
|
||||
password?: string
|
||||
db?: number
|
||||
keyPrefix?: string
|
||||
}) {
|
||||
this.keyPrefix = config.keyPrefix || 'aiclinical:'
|
||||
|
||||
// ⚠️ TODO: 待安装 ioredis 后取消注释
|
||||
// this.client = new Redis({
|
||||
// host: config.host,
|
||||
// port: config.port,
|
||||
// password: config.password,
|
||||
// db: config.db || 0,
|
||||
// retryStrategy: (times) => {
|
||||
// const delay = Math.min(times * 50, 2000)
|
||||
// return delay
|
||||
// }
|
||||
// })
|
||||
|
||||
// this.client.on('error', (err) => {
|
||||
// console.error('[RedisCacheAdapter] Redis error:', err)
|
||||
// })
|
||||
|
||||
// this.client.on('connect', () => {
|
||||
// console.log('[RedisCacheAdapter] Connected to Redis')
|
||||
// })
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取完整的key(带前缀)
|
||||
*/
|
||||
private getFullKey(_key: string): string {
|
||||
return `${this.keyPrefix}${_key}`
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取缓存值
|
||||
*/
|
||||
async get<T = any>(_key: string): Promise<T | null> {
|
||||
// ⚠️ TODO: 待实现
|
||||
// const value = await this.client.get(this.getFullKey(key))
|
||||
// if (!value) return null
|
||||
// try {
|
||||
// return JSON.parse(value) as T
|
||||
// } catch {
|
||||
// return value as T
|
||||
// }
|
||||
|
||||
throw new Error('[RedisCacheAdapter] Not implemented yet. Please install ioredis and configure Redis.')
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置缓存值
|
||||
*/
|
||||
async set(_key: string, _value: any, _ttl?: number): Promise<void> {
|
||||
// ⚠️ TODO: 待实现
|
||||
// const serialized = JSON.stringify(value)
|
||||
// const fullKey = this.getFullKey(key)
|
||||
|
||||
// if (ttl) {
|
||||
// await this.client.setex(fullKey, ttl, serialized)
|
||||
// } else {
|
||||
// await this.client.set(fullKey, serialized)
|
||||
// }
|
||||
|
||||
throw new Error('[RedisCacheAdapter] Not implemented yet. Please install ioredis and configure Redis.')
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除缓存
|
||||
*/
|
||||
async delete(_key: string): Promise<void> {
|
||||
// ⚠️ TODO: 待实现
|
||||
// await this.client.del(this.getFullKey(key))
|
||||
|
||||
throw new Error('[RedisCacheAdapter] Not implemented yet. Please install ioredis and configure Redis.')
|
||||
}
|
||||
|
||||
/**
|
||||
* 清空所有缓存(仅清空带前缀的key)
|
||||
*/
|
||||
async clear(): Promise<void> {
|
||||
// ⚠️ TODO: 待实现
|
||||
// const keys = await this.client.keys(`${this.keyPrefix}*`)
|
||||
// if (keys.length > 0) {
|
||||
// await this.client.del(...keys)
|
||||
// }
|
||||
|
||||
throw new Error('[RedisCacheAdapter] Not implemented yet. Please install ioredis and configure Redis.')
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查缓存是否存在
|
||||
*/
|
||||
async has(_key: string): Promise<boolean> {
|
||||
// ⚠️ TODO: 待实现
|
||||
// const exists = await this.client.exists(this.getFullKey(key))
|
||||
// return exists === 1
|
||||
|
||||
throw new Error('[RedisCacheAdapter] Not implemented yet. Please install ioredis and configure Redis.')
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量获取缓存
|
||||
*/
|
||||
async mget<T = any>(_keys: string[]): Promise<(T | null)[]> {
|
||||
// ⚠️ TODO: 待实现
|
||||
// const fullKeys = keys.map(k => this.getFullKey(k))
|
||||
// const values = await this.client.mget(...fullKeys)
|
||||
// return values.map(v => {
|
||||
// if (!v) return null
|
||||
// try {
|
||||
// return JSON.parse(v) as T
|
||||
// } catch {
|
||||
// return v as T
|
||||
// }
|
||||
// })
|
||||
|
||||
throw new Error('[RedisCacheAdapter] Not implemented yet. Please install ioredis and configure Redis.')
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量设置缓存
|
||||
*/
|
||||
async mset(_entries: Array<{ key: string; value: any }>, _ttl?: number): Promise<void> {
|
||||
// ⚠️ TODO: 待实现
|
||||
// if (ttl) {
|
||||
// // 有TTL时需要单独设置每个key
|
||||
// await Promise.all(entries.map(({ key, value }) => this.set(key, value, ttl)))
|
||||
// } else {
|
||||
// // 无TTL时可以批量设置
|
||||
// const pairs: string[] = []
|
||||
// for (const { key, value } of entries) {
|
||||
// pairs.push(this.getFullKey(key), JSON.stringify(value))
|
||||
// }
|
||||
// await this.client.mset(...pairs)
|
||||
// }
|
||||
|
||||
throw new Error('[RedisCacheAdapter] Not implemented yet. Please install ioredis and configure Redis.')
|
||||
}
|
||||
|
||||
/**
|
||||
* 关闭Redis连接
|
||||
*/
|
||||
async disconnect(): Promise<void> {
|
||||
// ⚠️ TODO: 待实现
|
||||
// await this.client.quit()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* ⚠️ 实施说明:
|
||||
*
|
||||
* 1. 安装依赖:
|
||||
* npm install ioredis
|
||||
* npm install -D @types/ioredis
|
||||
*
|
||||
* 2. 取消注释代码:
|
||||
* - import Redis from 'ioredis'
|
||||
* - new Redis({ ... })
|
||||
* - 所有方法的实现代码
|
||||
*
|
||||
* 3. 配置环境变量:
|
||||
* CACHE_TYPE=redis
|
||||
* REDIS_HOST=r-***.redis.aliyuncs.com
|
||||
* REDIS_PORT=6379
|
||||
* REDIS_PASSWORD=your-password
|
||||
* REDIS_DB=0
|
||||
*
|
||||
* 4. 测试:
|
||||
* - 连接Redis
|
||||
* - 设置/获取缓存
|
||||
* - 批量操作
|
||||
* - TTL过期
|
||||
*/
|
||||
|
||||
51
backend/src/common/cache/index.ts
vendored
Normal file
51
backend/src/common/cache/index.ts
vendored
Normal file
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* 缓存服务统一导出
|
||||
*
|
||||
* 提供平台级的缓存能力,支持内存和Redis无缝切换。
|
||||
*
|
||||
* @module cache
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // 方式1:使用全局缓存实例(推荐)
|
||||
* import { cache } from '@/common/cache'
|
||||
*
|
||||
* // 缓存用户数据
|
||||
* await cache.set('user:123', { id: 123, name: 'Alice' }, 60 * 5) // 5分钟
|
||||
* const user = await cache.get<User>('user:123')
|
||||
*
|
||||
* // 缓存LLM响应
|
||||
* const cacheKey = `llm:${model}:${hash(prompt)}`
|
||||
* const cached = await cache.get(cacheKey)
|
||||
* if (cached) return cached
|
||||
*
|
||||
* const response = await llm.chat(prompt)
|
||||
* await cache.set(cacheKey, response, 60 * 60) // 1小时
|
||||
*
|
||||
* // 方式2:直接使用适配器
|
||||
* import { MemoryCacheAdapter } from '@/common/cache'
|
||||
* const cache = new MemoryCacheAdapter()
|
||||
*
|
||||
* // 方式3:使用工厂
|
||||
* import { CacheFactory } from '@/common/cache'
|
||||
* const cache = CacheFactory.getInstance()
|
||||
* ```
|
||||
*/
|
||||
|
||||
export type { CacheAdapter } from './CacheAdapter.js'
|
||||
export { MemoryCacheAdapter } from './MemoryCacheAdapter.js'
|
||||
export { RedisCacheAdapter } from './RedisCacheAdapter.js'
|
||||
export { CacheFactory } from './CacheFactory.js'
|
||||
|
||||
// Import for usage below
|
||||
import { CacheFactory } from './CacheFactory.js'
|
||||
|
||||
/**
|
||||
* 全局缓存实例(推荐使用)
|
||||
*
|
||||
* 自动根据环境变量选择缓存实现:
|
||||
* - CACHE_TYPE=memory: 内存缓存(本地开发)
|
||||
* - CACHE_TYPE=redis: Redis缓存(生产环境)
|
||||
*/
|
||||
export const cache = CacheFactory.getInstance()
|
||||
|
||||
221
backend/src/common/health/healthCheck.ts
Normal file
221
backend/src/common/health/healthCheck.ts
Normal file
@@ -0,0 +1,221 @@
|
||||
import { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'
|
||||
import { prisma, getDatabaseConnectionCount } from '../../config/database.js'
|
||||
|
||||
/**
|
||||
* 健康检查响应
|
||||
*/
|
||||
export interface HealthCheckResponse {
|
||||
status: 'ok' | 'error' | 'degraded'
|
||||
timestamp: number
|
||||
uptime: number
|
||||
checks?: Record<string, {
|
||||
status: 'ok' | 'error' | 'degraded'
|
||||
message?: string
|
||||
details?: any
|
||||
}>
|
||||
}
|
||||
|
||||
/**
|
||||
* 注册健康检查路由
|
||||
*
|
||||
* 提供两个端点:
|
||||
* 1. /health/liveness - SAE存活检查(简单响应)
|
||||
* 2. /health/readiness - SAE就绪检查(检查依赖服务)
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* import { registerHealthRoutes } from '@/common/health'
|
||||
*
|
||||
* // 在Fastify应用启动时注册
|
||||
* await registerHealthRoutes(app)
|
||||
* ```
|
||||
*/
|
||||
export async function registerHealthRoutes(app: FastifyInstance): Promise<void> {
|
||||
/**
|
||||
* 存活检查(Liveness Probe)
|
||||
*
|
||||
* 用途:检测应用是否还活着
|
||||
* 检查内容:最基础的响应
|
||||
* 失败后果:SAE会重启容器
|
||||
*
|
||||
* GET /health/liveness
|
||||
*/
|
||||
app.get('/health/liveness', async (
|
||||
_request: FastifyRequest,
|
||||
reply: FastifyReply
|
||||
) => {
|
||||
const response: HealthCheckResponse = {
|
||||
status: 'ok',
|
||||
timestamp: Date.now(),
|
||||
uptime: process.uptime()
|
||||
}
|
||||
|
||||
return reply.status(200).send(response)
|
||||
})
|
||||
|
||||
/**
|
||||
* 就绪检查(Readiness Probe)
|
||||
*
|
||||
* 用途:检测应用是否准备好接收流量
|
||||
* 检查内容:数据库连接、关键依赖服务
|
||||
* 失败后果:SAE会暂时移除该实例的流量
|
||||
*
|
||||
* GET /health/readiness
|
||||
*/
|
||||
app.get('/health/readiness', async (
|
||||
_request: FastifyRequest,
|
||||
reply: FastifyReply
|
||||
) => {
|
||||
const checks: Record<string, any> = {}
|
||||
let overallStatus: 'ok' | 'error' | 'degraded' = 'ok'
|
||||
|
||||
// ========== 检查数据库连接 ==========
|
||||
try {
|
||||
await prisma.$queryRaw`SELECT 1`
|
||||
|
||||
// 获取当前连接数
|
||||
const connectionCount = await getDatabaseConnectionCount()
|
||||
const maxConnections = Number(process.env.DB_MAX_CONNECTIONS) || 400
|
||||
const connectionUsage = (connectionCount / maxConnections) * 100
|
||||
|
||||
checks.database = {
|
||||
status: connectionUsage > 90 ? 'degraded' : 'ok',
|
||||
message: connectionUsage > 90
|
||||
? 'Connection pool usage high'
|
||||
: 'Connected',
|
||||
details: {
|
||||
currentConnections: connectionCount,
|
||||
maxConnections,
|
||||
usagePercent: Math.round(connectionUsage)
|
||||
}
|
||||
}
|
||||
|
||||
if (connectionUsage > 90) {
|
||||
overallStatus = 'degraded'
|
||||
}
|
||||
} catch (error: any) {
|
||||
checks.database = {
|
||||
status: 'error',
|
||||
message: 'Database connection failed',
|
||||
details: {
|
||||
error: error.message
|
||||
}
|
||||
}
|
||||
overallStatus = 'error'
|
||||
}
|
||||
|
||||
// ========== 检查内存使用 ==========
|
||||
const memUsage = process.memoryUsage()
|
||||
const memUsageMB = {
|
||||
rss: Math.round(memUsage.rss / 1024 / 1024),
|
||||
heapTotal: Math.round(memUsage.heapTotal / 1024 / 1024),
|
||||
heapUsed: Math.round(memUsage.heapUsed / 1024 / 1024),
|
||||
external: Math.round(memUsage.external / 1024 / 1024)
|
||||
}
|
||||
|
||||
// 堆内存使用超过80%告警
|
||||
const heapUsagePercent = (memUsage.heapUsed / memUsage.heapTotal) * 100
|
||||
|
||||
checks.memory = {
|
||||
status: heapUsagePercent > 90 ? 'degraded' : 'ok',
|
||||
message: heapUsagePercent > 90
|
||||
? 'High memory usage'
|
||||
: 'Normal',
|
||||
details: memUsageMB
|
||||
}
|
||||
|
||||
if (heapUsagePercent > 90 && overallStatus === 'ok') {
|
||||
overallStatus = 'degraded'
|
||||
}
|
||||
|
||||
// ========== 返回响应 ==========
|
||||
const response: HealthCheckResponse = {
|
||||
status: overallStatus,
|
||||
timestamp: Date.now(),
|
||||
uptime: process.uptime(),
|
||||
checks
|
||||
}
|
||||
|
||||
const statusCode = overallStatus === 'error' ? 503 : 200
|
||||
|
||||
return reply.status(statusCode).send(response)
|
||||
})
|
||||
|
||||
/**
|
||||
* 详细健康检查(开发用)
|
||||
*
|
||||
* GET /health
|
||||
*/
|
||||
app.get('/health', async (
|
||||
_request: FastifyRequest,
|
||||
reply: FastifyReply
|
||||
) => {
|
||||
const checks: Record<string, any> = {}
|
||||
let overallStatus: 'ok' | 'error' | 'degraded' = 'ok'
|
||||
|
||||
// ========== 数据库检查 ==========
|
||||
try {
|
||||
const startTime = Date.now()
|
||||
await prisma.$queryRaw`SELECT 1`
|
||||
const responseTime = Date.now() - startTime
|
||||
|
||||
const connectionCount = await getDatabaseConnectionCount()
|
||||
const maxConnections = Number(process.env.DB_MAX_CONNECTIONS) || 400
|
||||
|
||||
checks.database = {
|
||||
status: 'ok',
|
||||
responseTime: `${responseTime}ms`,
|
||||
connections: {
|
||||
current: connectionCount,
|
||||
max: maxConnections,
|
||||
usage: `${Math.round((connectionCount / maxConnections) * 100)}%`
|
||||
}
|
||||
}
|
||||
} catch (error: any) {
|
||||
checks.database = {
|
||||
status: 'error',
|
||||
error: error.message
|
||||
}
|
||||
overallStatus = 'error'
|
||||
}
|
||||
|
||||
// ========== 环境信息 ==========
|
||||
checks.environment = {
|
||||
nodeVersion: process.version,
|
||||
platform: process.platform,
|
||||
nodeEnv: process.env.NODE_ENV || 'development',
|
||||
pid: process.pid,
|
||||
uptime: `${Math.round(process.uptime())}s`
|
||||
}
|
||||
|
||||
// ========== 内存信息 ==========
|
||||
const memUsage = process.memoryUsage()
|
||||
checks.memory = {
|
||||
rss: `${Math.round(memUsage.rss / 1024 / 1024)}MB`,
|
||||
heapTotal: `${Math.round(memUsage.heapTotal / 1024 / 1024)}MB`,
|
||||
heapUsed: `${Math.round(memUsage.heapUsed / 1024 / 1024)}MB`,
|
||||
external: `${Math.round(memUsage.external / 1024 / 1024)}MB`
|
||||
}
|
||||
|
||||
// ========== CPU信息 ==========
|
||||
checks.cpu = {
|
||||
usage: process.cpuUsage(),
|
||||
loadAverage: process.platform !== 'win32' ? require('os').loadavg() : 'N/A'
|
||||
}
|
||||
|
||||
// ========== 返回响应 ==========
|
||||
const response = {
|
||||
status: overallStatus,
|
||||
timestamp: new Date().toISOString(),
|
||||
checks
|
||||
}
|
||||
|
||||
return reply.status(200).send(response)
|
||||
})
|
||||
|
||||
console.log('[Health] Health check routes registered:')
|
||||
console.log(' - GET /health/liveness')
|
||||
console.log(' - GET /health/readiness')
|
||||
console.log(' - GET /health (detailed)')
|
||||
}
|
||||
|
||||
26
backend/src/common/health/index.ts
Normal file
26
backend/src/common/health/index.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* 健康检查统一导出
|
||||
*
|
||||
* 提供SAE健康检查端点,用于存活和就绪探测。
|
||||
*
|
||||
* @module health
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* import { registerHealthRoutes } from '@/common/health'
|
||||
* import Fastify from 'fastify'
|
||||
*
|
||||
* const app = Fastify()
|
||||
*
|
||||
* // 注册健康检查路由
|
||||
* await registerHealthRoutes(app)
|
||||
*
|
||||
* // SAE配置示例:
|
||||
* // - Liveness Probe: GET /health/liveness (每10秒检查一次)
|
||||
* // - Readiness Probe: GET /health/readiness (每5秒检查一次)
|
||||
* ```
|
||||
*/
|
||||
|
||||
export { registerHealthRoutes } from './healthCheck.js'
|
||||
export type { HealthCheckResponse } from './healthCheck.js'
|
||||
|
||||
82
backend/src/common/jobs/JobFactory.ts
Normal file
82
backend/src/common/jobs/JobFactory.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import { JobQueue } from './types.js'
|
||||
import { MemoryQueue } from './MemoryQueue.js'
|
||||
|
||||
/**
|
||||
* 任务队列工厂类
|
||||
*
|
||||
* 根据环境变量自动选择队列实现:
|
||||
* - QUEUE_TYPE=memory: 使用MemoryQueue(内存队列)
|
||||
* - QUEUE_TYPE=database: 使用DatabaseQueue(数据库队列,待实现)
|
||||
*
|
||||
* 零代码切换:
|
||||
* - 本地开发:不配置QUEUE_TYPE,默认使用memory
|
||||
* - 云端部署:配置QUEUE_TYPE=database(多实例共享)
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* import { jobQueue } from '@/common/jobs'
|
||||
*
|
||||
* // 业务代码不关心是memory还是database
|
||||
* const job = await jobQueue.push('asl:screening', { projectId: 123 })
|
||||
* ```
|
||||
*/
|
||||
export class JobFactory {
|
||||
private static instance: JobQueue | null = null
|
||||
|
||||
/**
|
||||
* 获取任务队列实例(单例模式)
|
||||
*/
|
||||
static getInstance(): JobQueue {
|
||||
if (!this.instance) {
|
||||
this.instance = this.createQueue()
|
||||
}
|
||||
return this.instance
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建任务队列
|
||||
*/
|
||||
private static createQueue(): JobQueue {
|
||||
const queueType = process.env.QUEUE_TYPE || 'memory'
|
||||
|
||||
switch (queueType) {
|
||||
case 'memory':
|
||||
return this.createMemoryQueue()
|
||||
|
||||
case 'database':
|
||||
// TODO: 实现DatabaseQueue
|
||||
console.warn('[JobFactory] DatabaseQueue not implemented yet, fallback to MemoryQueue')
|
||||
return this.createMemoryQueue()
|
||||
|
||||
default:
|
||||
console.warn(`[JobFactory] Unknown QUEUE_TYPE: ${queueType}, fallback to memory`)
|
||||
return this.createMemoryQueue()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建内存队列
|
||||
*/
|
||||
private static createMemoryQueue(): MemoryQueue {
|
||||
console.log('[JobFactory] Using MemoryQueue')
|
||||
|
||||
const queue = new MemoryQueue()
|
||||
|
||||
// 定期清理已完成的任务(避免内存泄漏)
|
||||
if (process.env.NODE_ENV !== 'test') {
|
||||
setInterval(() => {
|
||||
queue.cleanup()
|
||||
}, 60 * 60 * 1000) // 每小时清理一次
|
||||
}
|
||||
|
||||
return queue
|
||||
}
|
||||
|
||||
/**
|
||||
* 重置实例(用于测试)
|
||||
*/
|
||||
static reset(): void {
|
||||
this.instance = null
|
||||
}
|
||||
}
|
||||
|
||||
204
backend/src/common/jobs/MemoryQueue.ts
Normal file
204
backend/src/common/jobs/MemoryQueue.ts
Normal file
@@ -0,0 +1,204 @@
|
||||
import { Job, JobQueue, JobHandler } from './types.js'
|
||||
import { randomUUID } from 'crypto'
|
||||
|
||||
/**
|
||||
* 内存队列实现
|
||||
*
|
||||
* 适用场景:
|
||||
* - 本地开发环境
|
||||
* - 单实例部署
|
||||
* - 非关键任务(重启会丢失)
|
||||
*
|
||||
* 特点:
|
||||
* - ✅ 简单易用,无需外部依赖
|
||||
* - ✅ 性能高效
|
||||
* - ⚠️ 进程重启后任务丢失
|
||||
* - ⚠️ 不支持多实例共享
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const queue = new MemoryQueue()
|
||||
*
|
||||
* // 注册处理函数
|
||||
* queue.process('email:send', async (job) => {
|
||||
* await sendEmail(job.data.to, job.data.subject)
|
||||
* })
|
||||
*
|
||||
* // 创建任务
|
||||
* const job = await queue.push('email:send', { to: 'user@example.com', subject: 'Hello' })
|
||||
*
|
||||
* // 查询任务
|
||||
* const status = await queue.getJob(job.id)
|
||||
* ```
|
||||
*/
|
||||
export class MemoryQueue implements JobQueue {
|
||||
private jobs: Map<string, Job> = new Map()
|
||||
private handlers: Map<string, JobHandler> = new Map()
|
||||
private processing: boolean = false
|
||||
|
||||
/**
|
||||
* 添加任务到队列
|
||||
*/
|
||||
async push<T>(type: string, data: T): Promise<Job<T>> {
|
||||
const job: Job<T> = {
|
||||
id: randomUUID(),
|
||||
type,
|
||||
data,
|
||||
status: 'pending',
|
||||
progress: 0,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date()
|
||||
}
|
||||
|
||||
this.jobs.set(job.id, job)
|
||||
|
||||
// 触发任务处理
|
||||
this.processNextJob()
|
||||
|
||||
return job
|
||||
}
|
||||
|
||||
/**
|
||||
* 注册任务处理函数
|
||||
*/
|
||||
process<T>(type: string, handler: JobHandler<T>): void {
|
||||
this.handlers.set(type, handler)
|
||||
console.log(`[MemoryQueue] Registered handler for job type: ${type}`)
|
||||
|
||||
// 开始处理队列中的待处理任务
|
||||
this.processNextJob()
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取任务信息
|
||||
*/
|
||||
async getJob(id: string): Promise<Job | null> {
|
||||
return this.jobs.get(id) || null
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新任务进度
|
||||
*/
|
||||
async updateProgress(id: string, progress: number): Promise<void> {
|
||||
const job = this.jobs.get(id)
|
||||
if (job) {
|
||||
job.progress = Math.min(100, Math.max(0, progress))
|
||||
job.updatedAt = new Date()
|
||||
this.jobs.set(id, job)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 标记任务为完成
|
||||
*/
|
||||
async completeJob(id: string, result: any): Promise<void> {
|
||||
const job = this.jobs.get(id)
|
||||
if (job) {
|
||||
job.status = 'completed'
|
||||
job.progress = 100
|
||||
job.result = result
|
||||
job.completedAt = new Date()
|
||||
job.updatedAt = new Date()
|
||||
this.jobs.set(id, job)
|
||||
|
||||
console.log(`[MemoryQueue] Job completed: ${id} (type: ${job.type})`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 标记任务为失败
|
||||
*/
|
||||
async failJob(id: string, error: string): Promise<void> {
|
||||
const job = this.jobs.get(id)
|
||||
if (job) {
|
||||
job.status = 'failed'
|
||||
job.error = error
|
||||
job.completedAt = new Date()
|
||||
job.updatedAt = new Date()
|
||||
this.jobs.set(id, job)
|
||||
|
||||
console.error(`[MemoryQueue] Job failed: ${id} (type: ${job.type})`, error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理下一个待处理任务
|
||||
*/
|
||||
private async processNextJob(): Promise<void> {
|
||||
if (this.processing) return
|
||||
|
||||
// 查找第一个待处理的任务
|
||||
const pendingJob = Array.from(this.jobs.values()).find(
|
||||
job => job.status === 'pending'
|
||||
)
|
||||
|
||||
if (!pendingJob) return
|
||||
|
||||
// 获取对应的处理函数
|
||||
const handler = this.handlers.get(pendingJob.type)
|
||||
if (!handler) {
|
||||
// 没有注册处理函数,跳过
|
||||
return
|
||||
}
|
||||
|
||||
// 标记为处理中
|
||||
this.processing = true
|
||||
pendingJob.status = 'processing'
|
||||
pendingJob.startedAt = new Date()
|
||||
pendingJob.updatedAt = new Date()
|
||||
this.jobs.set(pendingJob.id, pendingJob)
|
||||
|
||||
console.log(`[MemoryQueue] Processing job: ${pendingJob.id} (type: ${pendingJob.type})`)
|
||||
|
||||
try {
|
||||
// 执行处理函数
|
||||
const result = await handler(pendingJob)
|
||||
|
||||
// 标记为完成
|
||||
await this.completeJob(pendingJob.id, result)
|
||||
} catch (error: any) {
|
||||
// 标记为失败
|
||||
await this.failJob(pendingJob.id, error.message)
|
||||
} finally {
|
||||
this.processing = false
|
||||
|
||||
// 继续处理下一个任务
|
||||
setImmediate(() => this.processNextJob())
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取队列统计信息
|
||||
*/
|
||||
getStats() {
|
||||
const jobs = Array.from(this.jobs.values())
|
||||
return {
|
||||
total: jobs.length,
|
||||
pending: jobs.filter(j => j.status === 'pending').length,
|
||||
processing: jobs.filter(j => j.status === 'processing').length,
|
||||
completed: jobs.filter(j => j.status === 'completed').length,
|
||||
failed: jobs.filter(j => j.status === 'failed').length
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理已完成的任务(避免内存泄漏)
|
||||
* 建议定期调用
|
||||
*/
|
||||
cleanup(olderThan: Date = new Date(Date.now() - 24 * 60 * 60 * 1000)) {
|
||||
let removed = 0
|
||||
for (const [id, job] of this.jobs) {
|
||||
if (
|
||||
(job.status === 'completed' || job.status === 'failed') &&
|
||||
job.completedAt &&
|
||||
job.completedAt < olderThan
|
||||
) {
|
||||
this.jobs.delete(id)
|
||||
removed++
|
||||
}
|
||||
}
|
||||
console.log(`[MemoryQueue] Cleanup: removed ${removed} old jobs`)
|
||||
return removed
|
||||
}
|
||||
}
|
||||
|
||||
53
backend/src/common/jobs/index.ts
Normal file
53
backend/src/common/jobs/index.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* 异步任务系统统一导出
|
||||
*
|
||||
* 提供平台级的异步任务处理能力,避免Serverless超时。
|
||||
*
|
||||
* @module jobs
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // 方式1:使用全局队列(推荐)
|
||||
* import { jobQueue } from '@/common/jobs'
|
||||
*
|
||||
* // 创建任务
|
||||
* const job = await jobQueue.push('asl:screening', {
|
||||
* projectId: 123,
|
||||
* literatureIds: [1, 2, 3]
|
||||
* })
|
||||
*
|
||||
* // 返回任务ID给前端
|
||||
* res.send({ jobId: job.id })
|
||||
*
|
||||
* // 前端轮询任务状态
|
||||
* const status = await jobQueue.getJob(job.id)
|
||||
*
|
||||
* // 注册处理函数(在应用启动时)
|
||||
* jobQueue.process('asl:screening', async (job) => {
|
||||
* // 处理任务
|
||||
* for (const id of job.data.literatureIds) {
|
||||
* await processLiterature(id)
|
||||
* await jobQueue.updateProgress(job.id, ...)
|
||||
* }
|
||||
* // 返回结果
|
||||
* return { success: true, processed: 3 }
|
||||
* })
|
||||
* ```
|
||||
*/
|
||||
|
||||
export type { Job, JobStatus, JobHandler, JobQueue } from './types.js'
|
||||
export { MemoryQueue } from './MemoryQueue.js'
|
||||
export { JobFactory } from './JobFactory.js'
|
||||
|
||||
// Import for usage below
|
||||
import { JobFactory } from './JobFactory.js'
|
||||
|
||||
/**
|
||||
* 全局任务队列实例(推荐使用)
|
||||
*
|
||||
* 自动根据环境变量选择队列实现:
|
||||
* - QUEUE_TYPE=memory: 内存队列(本地开发)
|
||||
* - QUEUE_TYPE=database: 数据库队列(生产环境,待实现)
|
||||
*/
|
||||
export const jobQueue = JobFactory.getInstance()
|
||||
|
||||
89
backend/src/common/jobs/types.ts
Normal file
89
backend/src/common/jobs/types.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
/**
|
||||
* 异步任务系统类型定义
|
||||
*
|
||||
* 用于长时间任务的异步处理,避免Serverless超时。
|
||||
*/
|
||||
|
||||
/**
|
||||
* 任务状态
|
||||
*/
|
||||
export type JobStatus = 'pending' | 'processing' | 'completed' | 'failed'
|
||||
|
||||
/**
|
||||
* 任务对象
|
||||
*/
|
||||
export interface Job<T = any> {
|
||||
/** 任务唯一ID */
|
||||
id: string
|
||||
|
||||
/** 任务类型(如:asl:screening, asl:extraction) */
|
||||
type: string
|
||||
|
||||
/** 任务数据 */
|
||||
data: T
|
||||
|
||||
/** 任务状态 */
|
||||
status: JobStatus
|
||||
|
||||
/** 任务进度(0-100) */
|
||||
progress: number
|
||||
|
||||
/** 任务结果(完成后) */
|
||||
result?: any
|
||||
|
||||
/** 错误信息(失败时) */
|
||||
error?: string
|
||||
|
||||
/** 创建时间 */
|
||||
createdAt: Date
|
||||
|
||||
/** 更新时间 */
|
||||
updatedAt: Date
|
||||
|
||||
/** 开始执行时间 */
|
||||
startedAt?: Date
|
||||
|
||||
/** 完成时间 */
|
||||
completedAt?: Date
|
||||
}
|
||||
|
||||
/**
|
||||
* 任务处理函数
|
||||
*/
|
||||
export type JobHandler<T = any> = (job: Job<T>) => Promise<any>
|
||||
|
||||
/**
|
||||
* 任务队列接口
|
||||
*/
|
||||
export interface JobQueue {
|
||||
/**
|
||||
* 添加任务到队列
|
||||
*/
|
||||
push<T>(type: string, data: T): Promise<Job<T>>
|
||||
|
||||
/**
|
||||
* 注册任务处理函数
|
||||
*/
|
||||
process<T>(type: string, handler: JobHandler<T>): void
|
||||
|
||||
/**
|
||||
* 获取任务信息
|
||||
*/
|
||||
getJob(id: string): Promise<Job | null>
|
||||
|
||||
/**
|
||||
* 更新任务进度
|
||||
*/
|
||||
updateProgress(id: string, progress: number): Promise<void>
|
||||
|
||||
/**
|
||||
* 标记任务为完成
|
||||
*/
|
||||
completeJob(id: string, result: any): Promise<void>
|
||||
|
||||
/**
|
||||
* 标记任务为失败
|
||||
*/
|
||||
failJob(id: string, error: string): Promise<void>
|
||||
}
|
||||
|
||||
37
backend/src/common/logging/index.ts
Normal file
37
backend/src/common/logging/index.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* 日志系统统一导出
|
||||
*
|
||||
* 提供平台级的日志能力,支持结构化日志和多种日志场景。
|
||||
*
|
||||
* @module logging
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // 方式1:使用全局logger(推荐)
|
||||
* import { logger } from '@/common/logging'
|
||||
* logger.info('User logged in', { userId: 123 })
|
||||
*
|
||||
* // 方式2:创建子logger(带上下文)
|
||||
* import { createChildLogger } from '@/common/logging'
|
||||
* const aslLogger = createChildLogger('ASL', { projectId: 456 })
|
||||
* aslLogger.info('Screening started')
|
||||
*
|
||||
* // 方式3:使用专用日志函数
|
||||
* import { logHttpRequest, logLLMCall } from '@/common/logging'
|
||||
* logHttpRequest('GET', '/api/projects', 200, 50)
|
||||
* logLLMCall('deepseek', 'chat', 1500, { model: 'deepseek-chat' })
|
||||
* ```
|
||||
*/
|
||||
|
||||
export {
|
||||
logger,
|
||||
createChildLogger,
|
||||
logHttpRequest,
|
||||
logDatabaseQuery,
|
||||
logLLMCall,
|
||||
logAsyncJob
|
||||
} from './logger.js'
|
||||
|
||||
// 默认导出
|
||||
export { default } from './logger.js'
|
||||
|
||||
202
backend/src/common/logging/logger.ts
Normal file
202
backend/src/common/logging/logger.ts
Normal file
@@ -0,0 +1,202 @@
|
||||
import winston from 'winston'
|
||||
|
||||
/**
|
||||
* 云原生日志系统
|
||||
*
|
||||
* 核心设计原则:
|
||||
* - ✅ 只输出到stdout(不写本地文件)
|
||||
* - ✅ JSON格式(便于阿里云SLS解析)
|
||||
* - ✅ 结构化日志(包含元数据)
|
||||
* - ✅ 统一的日志格式
|
||||
*
|
||||
* 日志级别:
|
||||
* - error: 错误,需要立即处理
|
||||
* - warn: 警告,需要关注
|
||||
* - info: 重要信息,正常业务日志
|
||||
* - debug: 调试信息,仅开发环境
|
||||
*
|
||||
* 环境变量:
|
||||
* - LOG_LEVEL: 日志级别(默认:development=debug, production=info)
|
||||
* - NODE_ENV: development | production
|
||||
* - SERVICE_NAME: 服务名称(默认:aiclinical-backend)
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* import { logger } from '@/common/logging'
|
||||
*
|
||||
* // 基础日志
|
||||
* logger.info('User logged in', { userId: 123 })
|
||||
* logger.error('Database query failed', { error: err.message, query: 'SELECT ...' })
|
||||
*
|
||||
* // 带上下文的日志
|
||||
* const childLogger = logger.child({ module: 'ASL', projectId: 456 })
|
||||
* childLogger.info('Screening started', { count: 100 })
|
||||
* ```
|
||||
*/
|
||||
|
||||
// 获取日志级别
|
||||
function getLogLevel(): string {
|
||||
if (process.env.LOG_LEVEL) {
|
||||
return process.env.LOG_LEVEL
|
||||
}
|
||||
return process.env.NODE_ENV === 'production' ? 'info' : 'debug'
|
||||
}
|
||||
|
||||
// 获取服务名称
|
||||
function getServiceName(): string {
|
||||
return process.env.SERVICE_NAME || 'aiclinical-backend'
|
||||
}
|
||||
|
||||
// 创建Winston Logger
|
||||
export const logger = winston.createLogger({
|
||||
level: getLogLevel(),
|
||||
|
||||
// JSON格式 + 时间戳 + 错误堆栈
|
||||
format: winston.format.combine(
|
||||
winston.format.timestamp({
|
||||
format: 'YYYY-MM-DD HH:mm:ss.SSS'
|
||||
}),
|
||||
winston.format.errors({ stack: true }),
|
||||
winston.format.json()
|
||||
),
|
||||
|
||||
// 默认元数据(所有日志都包含)
|
||||
defaultMeta: {
|
||||
service: getServiceName(),
|
||||
env: process.env.NODE_ENV || 'development',
|
||||
instance: process.env.HOSTNAME || process.env.COMPUTERNAME || 'unknown',
|
||||
pid: process.pid
|
||||
},
|
||||
|
||||
// ⭐ 云原生:只输出到stdout
|
||||
transports: [
|
||||
new winston.transports.Console({
|
||||
format: process.env.NODE_ENV === 'production'
|
||||
? winston.format.json() // 生产环境:纯JSON
|
||||
: winston.format.combine( // 开发环境:带颜色的可读格式
|
||||
winston.format.colorize(),
|
||||
winston.format.printf((info: any) => {
|
||||
const { timestamp, level, message, service, ...meta } = info
|
||||
const metaStr = Object.keys(meta).length > 0
|
||||
? '\n ' + JSON.stringify(meta, null, 2)
|
||||
: ''
|
||||
return `${timestamp} [${service}] ${level}: ${message}${metaStr}`
|
||||
})
|
||||
)
|
||||
})
|
||||
]
|
||||
})
|
||||
|
||||
/**
|
||||
* 创建子logger(带上下文)
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const aslLogger = createChildLogger('ASL', { projectId: 123 })
|
||||
* aslLogger.info('Screening started')
|
||||
* // 输出:{ ..., module: 'ASL', projectId: 123, message: 'Screening started' }
|
||||
* ```
|
||||
*/
|
||||
export function createChildLogger(module: string, meta: Record<string, any> = {}) {
|
||||
return logger.child({ module, ...meta })
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录HTTP请求日志
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* logHttpRequest('GET', '/api/projects', 200, 50)
|
||||
* ```
|
||||
*/
|
||||
export function logHttpRequest(
|
||||
method: string,
|
||||
url: string,
|
||||
statusCode: number,
|
||||
duration: number,
|
||||
meta: Record<string, any> = {}
|
||||
) {
|
||||
const level = statusCode >= 500 ? 'error' : statusCode >= 400 ? 'warn' : 'info'
|
||||
|
||||
logger.log(level, 'HTTP Request', {
|
||||
type: 'http',
|
||||
method,
|
||||
url,
|
||||
statusCode,
|
||||
duration,
|
||||
...meta
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录数据库查询日志
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* logDatabaseQuery('SELECT * FROM users WHERE id = $1', 45, { userId: 123 })
|
||||
* ```
|
||||
*/
|
||||
export function logDatabaseQuery(
|
||||
query: string,
|
||||
duration: number,
|
||||
meta: Record<string, any> = {}
|
||||
) {
|
||||
if (process.env.LOG_LEVEL === 'debug') {
|
||||
logger.debug('Database Query', {
|
||||
type: 'database',
|
||||
query: query.substring(0, 200), // 限制长度
|
||||
duration,
|
||||
...meta
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录LLM API调用日志
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* logLLMCall('deepseek', 'chat', 1500, { model: 'deepseek-chat' })
|
||||
* ```
|
||||
*/
|
||||
export function logLLMCall(
|
||||
provider: string,
|
||||
operation: string,
|
||||
duration: number,
|
||||
meta: Record<string, any> = {}
|
||||
) {
|
||||
logger.info('LLM API Call', {
|
||||
type: 'llm',
|
||||
provider,
|
||||
operation,
|
||||
duration,
|
||||
...meta
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录异步任务日志
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* logAsyncJob('asl:screening', 'started', { jobId: '123', projectId: 456 })
|
||||
* ```
|
||||
*/
|
||||
export function logAsyncJob(
|
||||
jobType: string,
|
||||
status: 'started' | 'processing' | 'completed' | 'failed',
|
||||
meta: Record<string, any> = {}
|
||||
) {
|
||||
const level = status === 'failed' ? 'error' : 'info'
|
||||
|
||||
logger.log(level, 'Async Job', {
|
||||
type: 'job',
|
||||
jobType,
|
||||
status,
|
||||
...meta
|
||||
})
|
||||
}
|
||||
|
||||
// 导出默认logger
|
||||
export default logger
|
||||
|
||||
40
backend/src/common/monitoring/index.ts
Normal file
40
backend/src/common/monitoring/index.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* 监控指标统一导出
|
||||
*
|
||||
* 提供平台级的监控能力,收集关键指标并支持告警。
|
||||
*
|
||||
* @module monitoring
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* import { Metrics, requestTimingHook, responseTimingHook } from '@/common/monitoring'
|
||||
* import Fastify from 'fastify'
|
||||
*
|
||||
* const app = Fastify()
|
||||
*
|
||||
* // 1. 注册请求计时中间件
|
||||
* app.addHook('onRequest', requestTimingHook)
|
||||
* app.addHook('onResponse', responseTimingHook)
|
||||
*
|
||||
* // 2. 启动定期监控
|
||||
* Metrics.startPeriodicMonitoring(60000) // 每分钟
|
||||
*
|
||||
* // 3. 手动记录指标
|
||||
* await Metrics.recordDBConnectionCount()
|
||||
* Metrics.recordMemoryUsage()
|
||||
*
|
||||
* // 4. 记录LLM调用
|
||||
* Metrics.recordLLMCall('deepseek', 'chat', 1500, true, {
|
||||
* prompt: 100,
|
||||
* completion: 200,
|
||||
* total: 300
|
||||
* })
|
||||
*
|
||||
* // 5. 获取系统概览
|
||||
* const overview = await Metrics.getSystemOverview()
|
||||
* console.log(overview)
|
||||
* ```
|
||||
*/
|
||||
|
||||
export { Metrics, requestTimingHook, responseTimingHook } from './metrics.js'
|
||||
|
||||
374
backend/src/common/monitoring/metrics.ts
Normal file
374
backend/src/common/monitoring/metrics.ts
Normal file
@@ -0,0 +1,374 @@
|
||||
import { getDatabaseConnectionCount } from '../../config/database.js'
|
||||
import { logger } from '../logging/logger.js'
|
||||
|
||||
/**
|
||||
* 监控指标类
|
||||
*
|
||||
* 提供关键指标的收集和监控能力:
|
||||
* - 数据库连接数监控
|
||||
* - 内存使用监控
|
||||
* - API响应时间监控
|
||||
* - 错误率监控
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* import { Metrics } from '@/common/monitoring'
|
||||
*
|
||||
* // 记录数据库连接数
|
||||
* await Metrics.recordDBConnectionCount()
|
||||
*
|
||||
* // 记录API响应时间
|
||||
* Metrics.recordAPIResponseTime('GET', '/api/projects', 200, 150)
|
||||
*
|
||||
* // 记录错误
|
||||
* Metrics.recordError('DatabaseError', 'Connection timeout')
|
||||
* ```
|
||||
*/
|
||||
export class Metrics {
|
||||
/**
|
||||
* 记录数据库连接数
|
||||
*
|
||||
* 如果连接数超过阈值,会记录警告日志
|
||||
*
|
||||
* @param warnThreshold 警告阈值(百分比,默认80)
|
||||
* @param errorThreshold 错误阈值(百分比,默认90)
|
||||
*/
|
||||
static async recordDBConnectionCount(
|
||||
warnThreshold: number = 80,
|
||||
errorThreshold: number = 90
|
||||
): Promise<number> {
|
||||
try {
|
||||
const count = await getDatabaseConnectionCount()
|
||||
const maxConnections = Number(process.env.DB_MAX_CONNECTIONS) || 400
|
||||
const usagePercent = (count / maxConnections) * 100
|
||||
|
||||
// 记录指标
|
||||
logger.info('Database connection count', {
|
||||
type: 'metric',
|
||||
metric: 'db.connections',
|
||||
value: count,
|
||||
max: maxConnections,
|
||||
usagePercent: Math.round(usagePercent)
|
||||
})
|
||||
|
||||
// 告警逻辑
|
||||
if (usagePercent >= errorThreshold) {
|
||||
logger.error('Database connection pool near exhaustion', {
|
||||
type: 'alert',
|
||||
severity: 'critical',
|
||||
metric: 'db.connections',
|
||||
current: count,
|
||||
max: maxConnections,
|
||||
usagePercent: Math.round(usagePercent)
|
||||
})
|
||||
} else if (usagePercent >= warnThreshold) {
|
||||
logger.warn('Database connection pool usage high', {
|
||||
type: 'alert',
|
||||
severity: 'warning',
|
||||
metric: 'db.connections',
|
||||
current: count,
|
||||
max: maxConnections,
|
||||
usagePercent: Math.round(usagePercent)
|
||||
})
|
||||
}
|
||||
|
||||
return count
|
||||
} catch (error: any) {
|
||||
logger.error('Failed to record database connection count', {
|
||||
type: 'metric_error',
|
||||
metric: 'db.connections',
|
||||
error: error.message
|
||||
})
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录内存使用情况
|
||||
*
|
||||
* @param warnThreshold 警告阈值(百分比,默认80)
|
||||
*/
|
||||
static recordMemoryUsage(warnThreshold: number = 80): void {
|
||||
const memUsage = process.memoryUsage()
|
||||
const heapUsagePercent = (memUsage.heapUsed / memUsage.heapTotal) * 100
|
||||
|
||||
logger.info('Memory usage', {
|
||||
type: 'metric',
|
||||
metric: 'memory.usage',
|
||||
rss: Math.round(memUsage.rss / 1024 / 1024),
|
||||
heapTotal: Math.round(memUsage.heapTotal / 1024 / 1024),
|
||||
heapUsed: Math.round(memUsage.heapUsed / 1024 / 1024),
|
||||
heapUsagePercent: Math.round(heapUsagePercent),
|
||||
external: Math.round(memUsage.external / 1024 / 1024)
|
||||
})
|
||||
|
||||
// 告警
|
||||
if (heapUsagePercent >= warnThreshold) {
|
||||
logger.warn('High memory usage detected', {
|
||||
type: 'alert',
|
||||
severity: 'warning',
|
||||
metric: 'memory.usage',
|
||||
heapUsagePercent: Math.round(heapUsagePercent),
|
||||
heapUsed: Math.round(memUsage.heapUsed / 1024 / 1024),
|
||||
heapTotal: Math.round(memUsage.heapTotal / 1024 / 1024)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录API响应时间
|
||||
*
|
||||
* @param method HTTP方法
|
||||
* @param path API路径
|
||||
* @param statusCode 状态码
|
||||
* @param duration 响应时间(毫秒)
|
||||
* @param slowThreshold 慢请求阈值(毫秒,默认1000)
|
||||
*/
|
||||
static recordAPIResponseTime(
|
||||
method: string,
|
||||
path: string,
|
||||
statusCode: number,
|
||||
duration: number,
|
||||
slowThreshold: number = 1000
|
||||
): void {
|
||||
const level = statusCode >= 500 ? 'error' : statusCode >= 400 ? 'warn' : 'info'
|
||||
|
||||
logger.log(level, 'API response', {
|
||||
type: 'metric',
|
||||
metric: 'api.response_time',
|
||||
method,
|
||||
path,
|
||||
statusCode,
|
||||
duration,
|
||||
slow: duration >= slowThreshold
|
||||
})
|
||||
|
||||
// 慢请求告警
|
||||
if (duration >= slowThreshold) {
|
||||
logger.warn('Slow API request detected', {
|
||||
type: 'alert',
|
||||
severity: 'warning',
|
||||
metric: 'api.slow_request',
|
||||
method,
|
||||
path,
|
||||
duration,
|
||||
threshold: slowThreshold
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录错误
|
||||
*
|
||||
* @param errorType 错误类型
|
||||
* @param message 错误消息
|
||||
* @param context 上下文信息
|
||||
*/
|
||||
static recordError(
|
||||
errorType: string,
|
||||
message: string,
|
||||
context?: Record<string, any>
|
||||
): void {
|
||||
logger.error('Application error', {
|
||||
type: 'metric',
|
||||
metric: 'app.error',
|
||||
errorType,
|
||||
message,
|
||||
...context
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录LLM API调用
|
||||
*
|
||||
* @param provider LLM提供商(deepseek、qwen等)
|
||||
* @param operation 操作类型(chat、embedding等)
|
||||
* @param duration 响应时间(毫秒)
|
||||
* @param success 是否成功
|
||||
* @param tokens 使用的token数
|
||||
*/
|
||||
static recordLLMCall(
|
||||
provider: string,
|
||||
operation: string,
|
||||
duration: number,
|
||||
success: boolean,
|
||||
tokens?: { prompt: number; completion: number; total: number }
|
||||
): void {
|
||||
logger.info('LLM API call', {
|
||||
type: 'metric',
|
||||
metric: 'llm.api_call',
|
||||
provider,
|
||||
operation,
|
||||
duration,
|
||||
success,
|
||||
tokens
|
||||
})
|
||||
|
||||
// 失败告警
|
||||
if (!success) {
|
||||
logger.warn('LLM API call failed', {
|
||||
type: 'alert',
|
||||
severity: 'warning',
|
||||
metric: 'llm.api_failure',
|
||||
provider,
|
||||
operation,
|
||||
duration
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录异步任务
|
||||
*
|
||||
* @param jobType 任务类型
|
||||
* @param status 任务状态
|
||||
* @param duration 执行时间(毫秒)
|
||||
* @param context 上下文信息
|
||||
*/
|
||||
static recordAsyncJob(
|
||||
jobType: string,
|
||||
status: 'started' | 'completed' | 'failed',
|
||||
duration?: number,
|
||||
context?: Record<string, any>
|
||||
): void {
|
||||
const level = status === 'failed' ? 'error' : 'info'
|
||||
|
||||
logger.log(level, 'Async job', {
|
||||
type: 'metric',
|
||||
metric: 'job.status',
|
||||
jobType,
|
||||
status,
|
||||
duration,
|
||||
...context
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录缓存命中率
|
||||
*
|
||||
* @param cacheKey 缓存键
|
||||
* @param hit 是否命中
|
||||
* @param ttl TTL(秒)
|
||||
*/
|
||||
static recordCacheHit(cacheKey: string, hit: boolean, ttl?: number): void {
|
||||
logger.debug('Cache access', {
|
||||
type: 'metric',
|
||||
metric: 'cache.access',
|
||||
key: cacheKey,
|
||||
hit,
|
||||
ttl
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取系统概览指标
|
||||
*
|
||||
* @returns 系统概览
|
||||
*/
|
||||
static async getSystemOverview(): Promise<Record<string, any>> {
|
||||
const overview: Record<string, any> = {}
|
||||
|
||||
// 数据库连接数
|
||||
try {
|
||||
const dbConnections = await getDatabaseConnectionCount()
|
||||
const maxConnections = Number(process.env.DB_MAX_CONNECTIONS) || 400
|
||||
overview.database = {
|
||||
connections: dbConnections,
|
||||
maxConnections,
|
||||
usagePercent: Math.round((dbConnections / maxConnections) * 100)
|
||||
}
|
||||
} catch (error) {
|
||||
overview.database = { error: 'Failed to fetch' }
|
||||
}
|
||||
|
||||
// 内存使用
|
||||
const memUsage = process.memoryUsage()
|
||||
overview.memory = {
|
||||
rss: Math.round(memUsage.rss / 1024 / 1024),
|
||||
heapTotal: Math.round(memUsage.heapTotal / 1024 / 1024),
|
||||
heapUsed: Math.round(memUsage.heapUsed / 1024 / 1024),
|
||||
heapUsagePercent: Math.round((memUsage.heapUsed / memUsage.heapTotal) * 100)
|
||||
}
|
||||
|
||||
// 进程信息
|
||||
overview.process = {
|
||||
pid: process.pid,
|
||||
uptime: Math.round(process.uptime()),
|
||||
version: process.version,
|
||||
platform: process.platform
|
||||
}
|
||||
|
||||
return overview
|
||||
}
|
||||
|
||||
/**
|
||||
* 启动定期监控
|
||||
*
|
||||
* 每分钟收集一次关键指标
|
||||
*
|
||||
* @param intervalMs 监控间隔(毫秒,默认60000)
|
||||
* @returns 定时器ID(可用于停止监控)
|
||||
*/
|
||||
static startPeriodicMonitoring(intervalMs: number = 60000): NodeJS.Timeout {
|
||||
logger.info('Starting periodic monitoring', { intervalMs })
|
||||
|
||||
const timer = setInterval(async () => {
|
||||
// 记录数据库连接数
|
||||
await this.recordDBConnectionCount()
|
||||
|
||||
// 记录内存使用
|
||||
this.recordMemoryUsage()
|
||||
}, intervalMs)
|
||||
|
||||
return timer
|
||||
}
|
||||
|
||||
/**
|
||||
* 停止定期监控
|
||||
*
|
||||
* @param timer 定时器ID
|
||||
*/
|
||||
static stopPeriodicMonitoring(timer: NodeJS.Timeout): void {
|
||||
clearInterval(timer)
|
||||
logger.info('Stopped periodic monitoring')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fastify请求计时中间件
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* import { requestTimingHook } from '@/common/monitoring'
|
||||
*
|
||||
* app.addHook('onRequest', requestTimingHook)
|
||||
* ```
|
||||
*/
|
||||
export function requestTimingHook(request: any, _reply: any, done: () => void) {
|
||||
request.startTime = Date.now()
|
||||
done()
|
||||
}
|
||||
|
||||
/**
|
||||
* Fastify响应计时中间件
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* import { responseTimingHook } from '@/common/monitoring'
|
||||
*
|
||||
* app.addHook('onResponse', responseTimingHook)
|
||||
* ```
|
||||
*/
|
||||
export function responseTimingHook(request: any, reply: any, done: () => void) {
|
||||
if (request.startTime) {
|
||||
const duration = Date.now() - request.startTime
|
||||
Metrics.recordAPIResponseTime(
|
||||
request.method,
|
||||
request.url,
|
||||
reply.statusCode,
|
||||
duration
|
||||
)
|
||||
}
|
||||
done()
|
||||
}
|
||||
|
||||
151
backend/src/common/storage/LocalAdapter.ts
Normal file
151
backend/src/common/storage/LocalAdapter.ts
Normal file
@@ -0,0 +1,151 @@
|
||||
import { StorageAdapter } from './StorageAdapter.js'
|
||||
import fs from 'fs/promises'
|
||||
import path from 'path'
|
||||
import { existsSync } from 'fs'
|
||||
|
||||
/**
|
||||
* 本地文件系统适配器
|
||||
*
|
||||
* 适用场景:
|
||||
* - 本地开发环境
|
||||
* - 私有化部署(数据不出内网)
|
||||
* - 单机版(100%本地化)
|
||||
*
|
||||
* 存储结构:
|
||||
* - 基础路径:backend/uploads/
|
||||
* - 示例:backend/uploads/literature/123.pdf
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const adapter = new LocalAdapter('/app/uploads')
|
||||
* await adapter.upload('literature/123.pdf', buffer)
|
||||
* ```
|
||||
*/
|
||||
export class LocalAdapter implements StorageAdapter {
|
||||
private readonly baseDir: string
|
||||
private readonly baseUrl: string
|
||||
|
||||
/**
|
||||
* @param baseDir 本地存储基础目录(绝对路径或相对路径)
|
||||
* @param baseUrl 访问URL前缀(默认:http://localhost:3001/uploads)
|
||||
*/
|
||||
constructor(
|
||||
baseDir: string = path.join(process.cwd(), 'uploads'),
|
||||
baseUrl: string = process.env.LOCAL_STORAGE_URL || 'http://localhost:3001/uploads'
|
||||
) {
|
||||
this.baseDir = baseDir
|
||||
this.baseUrl = baseUrl.replace(/\/$/, '') // 移除末尾的斜杠
|
||||
|
||||
// 确保基础目录存在
|
||||
this.ensureBaseDir()
|
||||
}
|
||||
|
||||
/**
|
||||
* 确保基础目录存在
|
||||
*/
|
||||
private async ensureBaseDir(): Promise<void> {
|
||||
try {
|
||||
await fs.mkdir(this.baseDir, { recursive: true })
|
||||
} catch (error) {
|
||||
console.error(`[LocalAdapter] Failed to create base dir: ${this.baseDir}`, error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 确保文件所在目录存在
|
||||
*/
|
||||
private async ensureDir(filePath: string): Promise<void> {
|
||||
const dir = path.dirname(filePath)
|
||||
await fs.mkdir(dir, { recursive: true })
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取完整的文件路径
|
||||
*/
|
||||
private getFullPath(key: string): string {
|
||||
// 规范化路径,移除开头的斜杠
|
||||
const normalizedKey = key.replace(/^\/+/, '')
|
||||
return path.join(this.baseDir, normalizedKey)
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传文件
|
||||
*/
|
||||
async upload(key: string, buffer: Buffer): Promise<string> {
|
||||
try {
|
||||
const fullPath = this.getFullPath(key)
|
||||
|
||||
// 确保目录存在
|
||||
await this.ensureDir(fullPath)
|
||||
|
||||
// 写入文件
|
||||
await fs.writeFile(fullPath, buffer)
|
||||
|
||||
// 返回访问URL
|
||||
return this.getUrl(key)
|
||||
} catch (error) {
|
||||
console.error(`[LocalAdapter] Failed to upload file: ${key}`, error)
|
||||
throw new Error(`Failed to upload file: ${key}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 下载文件
|
||||
*/
|
||||
async download(key: string): Promise<Buffer> {
|
||||
try {
|
||||
const fullPath = this.getFullPath(key)
|
||||
|
||||
// 检查文件是否存在
|
||||
if (!existsSync(fullPath)) {
|
||||
throw new Error(`File not found: ${key}`)
|
||||
}
|
||||
|
||||
// 读取文件
|
||||
return await fs.readFile(fullPath)
|
||||
} catch (error) {
|
||||
console.error(`[LocalAdapter] Failed to download file: ${key}`, error)
|
||||
throw new Error(`Failed to download file: ${key}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除文件
|
||||
*/
|
||||
async delete(key: string): Promise<void> {
|
||||
try {
|
||||
const fullPath = this.getFullPath(key)
|
||||
|
||||
// 检查文件是否存在
|
||||
if (existsSync(fullPath)) {
|
||||
await fs.unlink(fullPath)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`[LocalAdapter] Failed to delete file: ${key}`, error)
|
||||
throw new Error(`Failed to delete file: ${key}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取文件访问URL
|
||||
*/
|
||||
getUrl(key: string): string {
|
||||
// 规范化路径,确保开头有斜杠
|
||||
const normalizedKey = key.replace(/^\/+/, '')
|
||||
return `${this.baseUrl}/${normalizedKey}`
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查文件是否存在
|
||||
*/
|
||||
async exists(key: string): Promise<boolean> {
|
||||
try {
|
||||
const fullPath = this.getFullPath(key)
|
||||
return existsSync(fullPath)
|
||||
} catch (error) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
135
backend/src/common/storage/OSSAdapter.ts
Normal file
135
backend/src/common/storage/OSSAdapter.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
import { StorageAdapter } from './StorageAdapter.js'
|
||||
// import OSS from 'ali-oss' // ⚠️ 需要安装:npm install ali-oss
|
||||
|
||||
/**
|
||||
* 阿里云OSS适配器
|
||||
*
|
||||
* 适用场景:
|
||||
* - 云端SaaS部署(阿里云Serverless)
|
||||
* - 高可用、高并发场景
|
||||
* - 需要CDN加速
|
||||
*
|
||||
* 配置要求:
|
||||
* - OSS_REGION: OSS地域(如:oss-cn-hangzhou)
|
||||
* - OSS_BUCKET: OSS Bucket名称
|
||||
* - OSS_ACCESS_KEY_ID: AccessKey ID
|
||||
* - OSS_ACCESS_KEY_SECRET: AccessKey Secret
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const adapter = new OSSAdapter({
|
||||
* region: 'oss-cn-hangzhou',
|
||||
* bucket: 'aiclinical-prod',
|
||||
* accessKeyId: process.env.OSS_ACCESS_KEY_ID!,
|
||||
* accessKeySecret: process.env.OSS_ACCESS_KEY_SECRET!
|
||||
* })
|
||||
* await adapter.upload('literature/123.pdf', buffer)
|
||||
* ```
|
||||
*
|
||||
* ⚠️ 当前为预留实现,待云端部署时完善
|
||||
*/
|
||||
export class OSSAdapter implements StorageAdapter {
|
||||
// private readonly client: OSS
|
||||
private readonly bucket: string
|
||||
private readonly region: string
|
||||
|
||||
constructor(config: {
|
||||
region: string
|
||||
bucket: string
|
||||
accessKeyId: string
|
||||
accessKeySecret: string
|
||||
}) {
|
||||
this.region = config.region
|
||||
this.bucket = config.bucket
|
||||
|
||||
// ⚠️ TODO: 待安装 ali-oss 后取消注释
|
||||
// this.client = new OSS({
|
||||
// region: config.region,
|
||||
// bucket: config.bucket,
|
||||
// accessKeyId: config.accessKeyId,
|
||||
// accessKeySecret: config.accessKeySecret
|
||||
// })
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传文件到OSS
|
||||
*/
|
||||
async upload(_key: string, _buffer: Buffer): Promise<string> {
|
||||
// ⚠️ TODO: 待实现
|
||||
// const result = await this.client.put(key, buffer)
|
||||
// return result.url
|
||||
|
||||
throw new Error('[OSSAdapter] Not implemented yet. Please install ali-oss and configure OSS.')
|
||||
}
|
||||
|
||||
/**
|
||||
* 从OSS下载文件
|
||||
*/
|
||||
async download(_key: string): Promise<Buffer> {
|
||||
// ⚠️ TODO: 待实现
|
||||
// const result = await this.client.get(key)
|
||||
// return result.content as Buffer
|
||||
|
||||
throw new Error('[OSSAdapter] Not implemented yet. Please install ali-oss and configure OSS.')
|
||||
}
|
||||
|
||||
/**
|
||||
* 从OSS删除文件
|
||||
*/
|
||||
async delete(_key: string): Promise<void> {
|
||||
// ⚠️ TODO: 待实现
|
||||
// await this.client.delete(key)
|
||||
|
||||
throw new Error('[OSSAdapter] Not implemented yet. Please install ali-oss and configure OSS.')
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取文件访问URL
|
||||
*/
|
||||
getUrl(key: string): string {
|
||||
// 返回OSS公开访问URL
|
||||
// 格式:https://{bucket}.{region}.aliyuncs.com/{key}
|
||||
return `https://${this.bucket}.${this.region}.aliyuncs.com/${key}`
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查文件是否存在
|
||||
*/
|
||||
async exists(_key: string): Promise<boolean> {
|
||||
// ⚠️ TODO: 待实现
|
||||
// try {
|
||||
// await this.client.head(key)
|
||||
// return true
|
||||
// } catch (error) {
|
||||
// return false
|
||||
// }
|
||||
|
||||
throw new Error('[OSSAdapter] Not implemented yet. Please install ali-oss and configure OSS.')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* ⚠️ 实施说明:
|
||||
*
|
||||
* 1. 安装依赖:
|
||||
* npm install ali-oss
|
||||
* npm install -D @types/ali-oss
|
||||
*
|
||||
* 2. 取消注释代码:
|
||||
* - import OSS from 'ali-oss'
|
||||
* - new OSS({ ... })
|
||||
* - 所有方法的实现代码
|
||||
*
|
||||
* 3. 配置环境变量:
|
||||
* OSS_REGION=oss-cn-hangzhou
|
||||
* OSS_BUCKET=aiclinical-prod
|
||||
* OSS_ACCESS_KEY_ID=your-access-key-id
|
||||
* OSS_ACCESS_KEY_SECRET=your-access-key-secret
|
||||
*
|
||||
* 4. 测试:
|
||||
* - 上传小文件
|
||||
* - 下载文件
|
||||
* - 删除文件
|
||||
* - 检查文件是否存在
|
||||
*/
|
||||
|
||||
66
backend/src/common/storage/StorageAdapter.ts
Normal file
66
backend/src/common/storage/StorageAdapter.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* 存储适配器接口
|
||||
*
|
||||
* 支持多种存储实现:
|
||||
* - LocalAdapter: 本地文件系统(开发环境)
|
||||
* - OSSAdapter: 阿里云OSS(生产环境)
|
||||
*
|
||||
* 使用场景:
|
||||
* - 上传PDF文献文件
|
||||
* - 上传Excel批量导入文件
|
||||
* - 上传用户头像等静态资源
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* import { storage } from '@/common/storage'
|
||||
*
|
||||
* // 上传文件
|
||||
* const url = await storage.upload('literature/123.pdf', buffer)
|
||||
*
|
||||
* // 下载文件
|
||||
* const buffer = await storage.download('literature/123.pdf')
|
||||
*
|
||||
* // 删除文件
|
||||
* await storage.delete('literature/123.pdf')
|
||||
*
|
||||
* // 获取URL
|
||||
* const url = storage.getUrl('literature/123.pdf')
|
||||
* ```
|
||||
*/
|
||||
export interface StorageAdapter {
|
||||
/**
|
||||
* 上传文件
|
||||
* @param key 文件存储路径(相对路径,如:literature/123.pdf)
|
||||
* @param buffer 文件内容(二进制数据)
|
||||
* @returns 文件访问URL
|
||||
*/
|
||||
upload(key: string, buffer: Buffer): Promise<string>
|
||||
|
||||
/**
|
||||
* 下载文件
|
||||
* @param key 文件存储路径
|
||||
* @returns 文件内容(二进制数据)
|
||||
*/
|
||||
download(key: string): Promise<Buffer>
|
||||
|
||||
/**
|
||||
* 删除文件
|
||||
* @param key 文件存储路径
|
||||
*/
|
||||
delete(key: string): Promise<void>
|
||||
|
||||
/**
|
||||
* 获取文件访问URL
|
||||
* @param key 文件存储路径
|
||||
* @returns 文件访问URL(本地:http://localhost:3001/uploads/xxx,OSS:https://xxx.oss-cn-hangzhou.aliyuncs.com/xxx)
|
||||
*/
|
||||
getUrl(key: string): string
|
||||
|
||||
/**
|
||||
* 检查文件是否存在
|
||||
* @param key 文件存储路径
|
||||
* @returns 是否存在
|
||||
*/
|
||||
exists(key: string): Promise<boolean>
|
||||
}
|
||||
|
||||
101
backend/src/common/storage/StorageFactory.ts
Normal file
101
backend/src/common/storage/StorageFactory.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import { StorageAdapter } from './StorageAdapter.js'
|
||||
import { LocalAdapter } from './LocalAdapter.js'
|
||||
import { OSSAdapter } from './OSSAdapter.js'
|
||||
|
||||
/**
|
||||
* 存储工厂类
|
||||
*
|
||||
* 根据环境变量自动选择存储实现:
|
||||
* - STORAGE_TYPE=local: 使用LocalAdapter(本地文件系统)
|
||||
* - STORAGE_TYPE=oss: 使用OSSAdapter(阿里云OSS)
|
||||
*
|
||||
* 零代码切换:
|
||||
* - 本地开发:不配置STORAGE_TYPE,默认使用local
|
||||
* - 云端部署:配置STORAGE_TYPE=oss,自动切换到OSS
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* import { storage } from '@/common/storage'
|
||||
*
|
||||
* // 业务代码不关心是local还是oss
|
||||
* const url = await storage.upload('literature/123.pdf', buffer)
|
||||
* ```
|
||||
*/
|
||||
export class StorageFactory {
|
||||
private static instance: StorageAdapter | null = null
|
||||
|
||||
/**
|
||||
* 获取存储适配器实例(单例模式)
|
||||
*/
|
||||
static getInstance(): StorageAdapter {
|
||||
if (!this.instance) {
|
||||
this.instance = this.createAdapter()
|
||||
}
|
||||
return this.instance
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建存储适配器
|
||||
*/
|
||||
private static createAdapter(): StorageAdapter {
|
||||
const storageType = process.env.STORAGE_TYPE || 'local'
|
||||
|
||||
switch (storageType) {
|
||||
case 'local':
|
||||
return this.createLocalAdapter()
|
||||
|
||||
case 'oss':
|
||||
return this.createOSSAdapter()
|
||||
|
||||
default:
|
||||
console.warn(`[StorageFactory] Unknown STORAGE_TYPE: ${storageType}, fallback to local`)
|
||||
return this.createLocalAdapter()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建本地适配器
|
||||
*/
|
||||
private static createLocalAdapter(): LocalAdapter {
|
||||
const baseDir = process.env.LOCAL_STORAGE_DIR || 'uploads'
|
||||
const baseUrl = process.env.LOCAL_STORAGE_URL || 'http://localhost:3001/uploads'
|
||||
|
||||
console.log(`[StorageFactory] Using LocalAdapter (baseDir: ${baseDir})`)
|
||||
|
||||
return new LocalAdapter(baseDir, baseUrl)
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建OSS适配器
|
||||
*/
|
||||
private static createOSSAdapter(): OSSAdapter {
|
||||
const region = process.env.OSS_REGION
|
||||
const bucket = process.env.OSS_BUCKET
|
||||
const accessKeyId = process.env.OSS_ACCESS_KEY_ID
|
||||
const accessKeySecret = process.env.OSS_ACCESS_KEY_SECRET
|
||||
|
||||
// 验证必需的环境变量
|
||||
if (!region || !bucket || !accessKeyId || !accessKeySecret) {
|
||||
throw new Error(
|
||||
'[StorageFactory] OSS configuration incomplete. Required: OSS_REGION, OSS_BUCKET, OSS_ACCESS_KEY_ID, OSS_ACCESS_KEY_SECRET'
|
||||
)
|
||||
}
|
||||
|
||||
console.log(`[StorageFactory] Using OSSAdapter (region: ${region}, bucket: ${bucket})`)
|
||||
|
||||
return new OSSAdapter({
|
||||
region,
|
||||
bucket,
|
||||
accessKeyId,
|
||||
accessKeySecret
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 重置实例(用于测试)
|
||||
*/
|
||||
static reset(): void {
|
||||
this.instance = null
|
||||
}
|
||||
}
|
||||
|
||||
42
backend/src/common/storage/index.ts
Normal file
42
backend/src/common/storage/index.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* 存储服务统一导出
|
||||
*
|
||||
* 提供平台级的文件存储能力,支持本地和云端无缝切换。
|
||||
*
|
||||
* @module storage
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // 方式1:使用单例(推荐)
|
||||
* import { storage } from '@/common/storage'
|
||||
* const url = await storage.upload('literature/123.pdf', buffer)
|
||||
*
|
||||
* // 方式2:直接使用适配器
|
||||
* import { LocalAdapter } from '@/common/storage'
|
||||
* const adapter = new LocalAdapter()
|
||||
* const url = await adapter.upload('literature/123.pdf', buffer)
|
||||
*
|
||||
* // 方式3:使用工厂
|
||||
* import { StorageFactory } from '@/common/storage'
|
||||
* const storage = StorageFactory.getInstance()
|
||||
* const url = await storage.upload('literature/123.pdf', buffer)
|
||||
* ```
|
||||
*/
|
||||
|
||||
export { StorageAdapter } from './StorageAdapter.js'
|
||||
export { LocalAdapter } from './LocalAdapter.js'
|
||||
export { OSSAdapter } from './OSSAdapter.js'
|
||||
export { StorageFactory } from './StorageFactory.js'
|
||||
|
||||
// Import for usage below
|
||||
import { StorageFactory } from './StorageFactory.js'
|
||||
|
||||
/**
|
||||
* 全局存储实例(推荐使用)
|
||||
*
|
||||
* 自动根据环境变量选择存储实现:
|
||||
* - STORAGE_TYPE=local: 本地文件系统
|
||||
* - STORAGE_TYPE=oss: 阿里云OSS
|
||||
*/
|
||||
export const storage = StorageFactory.getInstance()
|
||||
|
||||
@@ -1,37 +1,167 @@
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import { PrismaClient } from '@prisma/client'
|
||||
|
||||
// 创建Prisma Client实例
|
||||
/**
|
||||
* 云原生数据库连接池配置
|
||||
*
|
||||
* 核心目标:
|
||||
* - 防止Serverless扩容导致连接数超限
|
||||
* - 优雅关闭连接
|
||||
* - 支持本地和云端环境
|
||||
*
|
||||
* 连接池计算公式:
|
||||
* connectionLimit = Math.floor(RDS_MAX_CONNECTIONS / MAX_INSTANCES) - 预留
|
||||
*
|
||||
* 示例:
|
||||
* - RDS: 400最大连接
|
||||
* - SAE: 最多20个实例
|
||||
* - 每实例连接数: 400 / 20 = 20 - 预留 = 18
|
||||
*
|
||||
* 环境变量:
|
||||
* - DATABASE_URL: 数据库连接URL(Prisma标准)
|
||||
* - DB_MAX_CONNECTIONS: RDS最大连接数(默认400)
|
||||
* - MAX_INSTANCES: SAE最大实例数(默认20)
|
||||
* - NODE_ENV: development | production
|
||||
*/
|
||||
|
||||
/**
|
||||
* 计算连接池大小(工具函数)
|
||||
*
|
||||
* ⚠️ 注意:Prisma不直接支持connectionLimit参数
|
||||
* 需要在DATABASE_URL中配置:
|
||||
* postgresql://user:pass@host:5432/db?connection_limit=20&pool_timeout=10
|
||||
*
|
||||
* 本函数用于计算推荐的connection_limit值
|
||||
*/
|
||||
export function calculateConnectionLimit(): number {
|
||||
const dbMaxConnections = Number(process.env.DB_MAX_CONNECTIONS) || 400
|
||||
const maxInstances = Number(process.env.MAX_INSTANCES) || 20
|
||||
const reservedConnections = 10 // 预留给管理任务和其他服务
|
||||
|
||||
// 计算每实例可用连接数
|
||||
const connectionsPerInstance = Math.floor(dbMaxConnections / maxInstances) - reservedConnections
|
||||
|
||||
// 确保至少有5个连接
|
||||
const connectionLimit = Math.max(connectionsPerInstance, 5)
|
||||
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.log(`[Database] Connection pool calculation:`)
|
||||
console.log(` - DB max connections: ${dbMaxConnections}`)
|
||||
console.log(` - Max instances: ${maxInstances}`)
|
||||
console.log(` - Recommended connections per instance: ${connectionLimit}`)
|
||||
console.log(` 💡 Add to DATABASE_URL: ?connection_limit=${connectionLimit}`)
|
||||
}
|
||||
|
||||
return connectionLimit
|
||||
}
|
||||
|
||||
// 创建Prisma Client实例(全局单例)
|
||||
export const prisma = new PrismaClient({
|
||||
log: process.env.NODE_ENV === 'development' ? ['query', 'info', 'warn', 'error'] : ['error'],
|
||||
});
|
||||
log: process.env.NODE_ENV === 'development'
|
||||
? ['query', 'info', 'warn', 'error']
|
||||
: ['error'],
|
||||
|
||||
// ⭐ 云原生连接池配置
|
||||
datasources: {
|
||||
db: {
|
||||
url: process.env.DATABASE_URL,
|
||||
},
|
||||
},
|
||||
|
||||
// Prisma 不直接支持 connectionLimit,但可以通过 DATABASE_URL 配置
|
||||
// 示例:postgresql://user:password@host:5432/db?connection_limit=20
|
||||
})
|
||||
|
||||
// 数据库连接测试
|
||||
/**
|
||||
* 数据库连接测试
|
||||
*/
|
||||
export async function testDatabaseConnection(): Promise<boolean> {
|
||||
try {
|
||||
await prisma.$connect();
|
||||
console.log('✅ 数据库连接成功!');
|
||||
await prisma.$connect()
|
||||
console.log('✅ 数据库连接成功!')
|
||||
|
||||
// 获取数据库信息
|
||||
const result = await prisma.$queryRaw<Array<{ version: string }>>`SELECT version()`;
|
||||
console.log('📊 数据库版本:', result[0]?.version.split(' ')[0], result[0]?.version.split(' ')[1]);
|
||||
const result = await prisma.$queryRaw<Array<{ version: string }>>`SELECT version()`
|
||||
console.log('📊 数据库版本:', result[0]?.version.split(' ')[0], result[0]?.version.split(' ')[1])
|
||||
|
||||
return true;
|
||||
// 获取当前连接数
|
||||
const connectionCount = await getDatabaseConnectionCount()
|
||||
console.log('📊 当前数据库连接数:', connectionCount)
|
||||
|
||||
return true
|
||||
} catch (error) {
|
||||
console.error('❌ 数据库连接失败:', error);
|
||||
return false;
|
||||
console.error('❌ 数据库连接失败:', error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// 优雅关闭数据库连接
|
||||
export async function closeDatabaseConnection() {
|
||||
await prisma.$disconnect();
|
||||
console.log('👋 数据库连接已关闭');
|
||||
/**
|
||||
* 获取当前数据库连接数
|
||||
* 用于监控和告警
|
||||
*/
|
||||
export async function getDatabaseConnectionCount(): Promise<number> {
|
||||
try {
|
||||
const result = await prisma.$queryRaw<Array<{ count: bigint }>>`
|
||||
SELECT count(*) as count
|
||||
FROM pg_stat_activity
|
||||
WHERE datname = current_database()
|
||||
`
|
||||
return Number(result[0]?.count || 0)
|
||||
} catch (error) {
|
||||
console.error('❌ 获取数据库连接数失败:', error)
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
// 进程退出时关闭连接
|
||||
/**
|
||||
* 优雅关闭数据库连接
|
||||
*
|
||||
* 在以下情况下调用:
|
||||
* - 进程正常退出
|
||||
* - 收到SIGTERM信号(Serverless实例停止)
|
||||
* - 收到SIGINT信号(Ctrl+C)
|
||||
*/
|
||||
export async function closeDatabaseConnection(): Promise<void> {
|
||||
try {
|
||||
console.log('[Database] Closing connections...')
|
||||
await prisma.$disconnect()
|
||||
console.log('[Database] ✅ 连接已关闭')
|
||||
} catch (error) {
|
||||
console.error('[Database] ❌ 关闭连接失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// ⭐ 云原生:优雅关闭逻辑
|
||||
let isShuttingDown = false
|
||||
|
||||
/**
|
||||
* 处理优雅关闭
|
||||
*/
|
||||
async function gracefulShutdown(signal: string): Promise<void> {
|
||||
if (isShuttingDown) {
|
||||
console.log(`[Database] Already shutting down, ignoring ${signal}`)
|
||||
return
|
||||
}
|
||||
|
||||
isShuttingDown = true
|
||||
console.log(`[Database] Received ${signal}, shutting down gracefully...`)
|
||||
|
||||
try {
|
||||
await closeDatabaseConnection()
|
||||
process.exit(0)
|
||||
} catch (error) {
|
||||
console.error('[Database] Error during shutdown:', error)
|
||||
process.exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
// 监听进程信号
|
||||
process.on('SIGTERM', () => gracefulShutdown('SIGTERM')) // Serverless实例停止
|
||||
process.on('SIGINT', () => gracefulShutdown('SIGINT')) // Ctrl+C
|
||||
process.on('beforeExit', async () => {
|
||||
await closeDatabaseConnection();
|
||||
});
|
||||
if (!isShuttingDown) {
|
||||
await closeDatabaseConnection()
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -1,65 +1,239 @@
|
||||
import dotenv from 'dotenv';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import dotenv from 'dotenv'
|
||||
import path from 'path'
|
||||
import { fileURLToPath } from 'url'
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
const __filename = fileURLToPath(import.meta.url)
|
||||
const __dirname = path.dirname(__filename)
|
||||
|
||||
// 加载.env文件
|
||||
dotenv.config({ path: path.join(__dirname, '../../.env') });
|
||||
/**
|
||||
* 云原生环境配置管理
|
||||
*
|
||||
* 设计原则:
|
||||
* - ✅ 本地开发:从.env文件加载
|
||||
* - ✅ 云端部署:从SAE环境变量加载
|
||||
* - ✅ 统一配置管理,避免散落各处
|
||||
* - ✅ 启动时验证必需配置
|
||||
*
|
||||
* 环境变量优先级:
|
||||
* 1. 系统环境变量(最高优先级,云端部署)
|
||||
* 2. .env文件(本地开发)
|
||||
* 3. 默认值(兜底)
|
||||
*/
|
||||
|
||||
// 只在非生产环境加载.env文件
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
dotenv.config({ path: path.join(__dirname, '../../.env') })
|
||||
console.log('[Config] Loaded .env file for development')
|
||||
}
|
||||
|
||||
export const config = {
|
||||
// 服务器配置
|
||||
// ==================== 应用配置 ====================
|
||||
|
||||
/** 服务端口 */
|
||||
port: parseInt(process.env.PORT || '3001', 10),
|
||||
|
||||
/** 服务主机 */
|
||||
host: process.env.HOST || '0.0.0.0',
|
||||
|
||||
/** 运行环境 */
|
||||
nodeEnv: process.env.NODE_ENV || 'development',
|
||||
logLevel: process.env.LOG_LEVEL || 'info',
|
||||
|
||||
/** 日志级别 */
|
||||
logLevel: process.env.LOG_LEVEL || (process.env.NODE_ENV === 'production' ? 'info' : 'debug'),
|
||||
|
||||
/** 服务名称 */
|
||||
serviceName: process.env.SERVICE_NAME || 'aiclinical-backend',
|
||||
|
||||
// 数据库配置
|
||||
// ==================== 数据库配置 ====================
|
||||
|
||||
/** 数据库连接URL */
|
||||
databaseUrl: process.env.DATABASE_URL || 'postgresql://postgres:postgres@localhost:5432/ai_clinical',
|
||||
|
||||
/** RDS最大连接数(云原生配置) */
|
||||
dbMaxConnections: parseInt(process.env.DB_MAX_CONNECTIONS || '400', 10),
|
||||
|
||||
/** SAE最大实例数(云原生配置) */
|
||||
maxInstances: parseInt(process.env.MAX_INSTANCES || '20', 10),
|
||||
|
||||
// Redis配置
|
||||
// ==================== 存储配置(平台基础设施)====================
|
||||
|
||||
/** 存储类型:local | oss */
|
||||
storageType: process.env.STORAGE_TYPE || 'local',
|
||||
|
||||
/** 本地存储目录 */
|
||||
localStorageDir: process.env.LOCAL_STORAGE_DIR || 'uploads',
|
||||
|
||||
/** 本地存储URL前缀 */
|
||||
localStorageUrl: process.env.LOCAL_STORAGE_URL || 'http://localhost:3001/uploads',
|
||||
|
||||
/** 阿里云OSS地域 */
|
||||
ossRegion: process.env.OSS_REGION,
|
||||
|
||||
/** 阿里云OSS Bucket名称 */
|
||||
ossBucket: process.env.OSS_BUCKET,
|
||||
|
||||
/** 阿里云OSS AccessKey ID */
|
||||
ossAccessKeyId: process.env.OSS_ACCESS_KEY_ID,
|
||||
|
||||
/** 阿里云OSS AccessKey Secret */
|
||||
ossAccessKeySecret: process.env.OSS_ACCESS_KEY_SECRET,
|
||||
|
||||
// ==================== 缓存配置(平台基础设施)====================
|
||||
|
||||
/** 缓存类型:memory | redis */
|
||||
cacheType: process.env.CACHE_TYPE || 'memory',
|
||||
|
||||
/** Redis主机 */
|
||||
redisHost: process.env.REDIS_HOST,
|
||||
|
||||
/** Redis端口 */
|
||||
redisPort: parseInt(process.env.REDIS_PORT || '6379', 10),
|
||||
|
||||
/** Redis密码 */
|
||||
redisPassword: process.env.REDIS_PASSWORD,
|
||||
|
||||
/** Redis数据库索引 */
|
||||
redisDb: parseInt(process.env.REDIS_DB || '0', 10),
|
||||
|
||||
/** Redis URL(兼容旧配置) */
|
||||
redisUrl: process.env.REDIS_URL || 'redis://localhost:6379',
|
||||
|
||||
// JWT配置
|
||||
jwtSecret: process.env.JWT_SECRET || 'your-secret-key-change-in-production',
|
||||
jwtExpiresIn: process.env.JWT_EXPIRES_IN || '7d',
|
||||
// ==================== 任务队列配置(平台基础设施)====================
|
||||
|
||||
/** 任务队列类型:memory | database */
|
||||
queueType: process.env.QUEUE_TYPE || 'memory',
|
||||
|
||||
// LLM API配置
|
||||
// ==================== 安全配置 ====================
|
||||
|
||||
/** JWT密钥 */
|
||||
jwtSecret: process.env.JWT_SECRET || 'your-secret-key-change-in-production',
|
||||
|
||||
/** JWT过期时间 */
|
||||
jwtExpiresIn: process.env.JWT_EXPIRES_IN || '7d',
|
||||
|
||||
/** CORS允许的源 */
|
||||
corsOrigin: process.env.CORS_ORIGIN || 'http://localhost:5173',
|
||||
|
||||
// ==================== LLM API配置 ====================
|
||||
|
||||
/** DeepSeek API Key */
|
||||
deepseekApiKey: process.env.DEEPSEEK_API_KEY || '',
|
||||
|
||||
/** DeepSeek Base URL */
|
||||
deepseekBaseUrl: process.env.DEEPSEEK_BASE_URL || 'https://api.deepseek.com',
|
||||
|
||||
dashscopeApiKey: process.env.DASHSCOPE_API_KEY || '', // 用于Qwen模型
|
||||
/** 通义千问 API Key */
|
||||
dashscopeApiKey: process.env.DASHSCOPE_API_KEY || '',
|
||||
|
||||
/** Gemini API Key */
|
||||
geminiApiKey: process.env.GEMINI_API_KEY || '',
|
||||
|
||||
// CloseAI配置(代理OpenAI和Claude)
|
||||
/** CloseAI API Key(代理OpenAI和Claude) */
|
||||
closeaiApiKey: process.env.CLOSEAI_API_KEY || '',
|
||||
|
||||
/** CloseAI OpenAI Base URL */
|
||||
closeaiOpenaiBaseUrl: process.env.CLOSEAI_OPENAI_BASE_URL || 'https://api.openai-proxy.org/v1',
|
||||
|
||||
/** CloseAI Claude Base URL */
|
||||
closeaiClaudeBaseUrl: process.env.CLOSEAI_CLAUDE_BASE_URL || 'https://api.openai-proxy.org/anthropic',
|
||||
|
||||
// Dify配置
|
||||
// ==================== Dify配置 ====================
|
||||
|
||||
/** Dify API Key */
|
||||
difyApiKey: process.env.DIFY_API_KEY || '',
|
||||
|
||||
/** Dify API URL */
|
||||
difyApiUrl: process.env.DIFY_API_URL || 'http://localhost/v1',
|
||||
|
||||
// 文件上传配置
|
||||
// ==================== 文件上传配置(Legacy兼容)====================
|
||||
|
||||
/** 文件上传大小限制 */
|
||||
uploadMaxSize: parseInt(process.env.UPLOAD_MAX_SIZE || '10485760', 10), // 10MB
|
||||
|
||||
/** 文件上传目录(Legacy兼容,新模块使用storage) */
|
||||
uploadDir: process.env.UPLOAD_DIR || './uploads',
|
||||
|
||||
// CORS配置
|
||||
corsOrigin: process.env.CORS_ORIGIN || 'http://localhost:5173',
|
||||
};
|
||||
|
||||
// 验证必需的环境变量
|
||||
export function validateEnv(): void {
|
||||
const requiredVars = ['DATABASE_URL'];
|
||||
const missing = requiredVars.filter(v => !process.env[v]);
|
||||
// ==================== 功能开关(平台基础设施)====================
|
||||
|
||||
if (missing.length > 0) {
|
||||
console.warn(`Warning: Missing environment variables: ${missing.join(', ')}`);
|
||||
/** 启用的模块列表(逗号分隔) */
|
||||
enabledModules: process.env.ENABLED_MODULES?.split(',').map(m => m.trim()) || [],
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证必需的环境变量
|
||||
*
|
||||
* 在应用启动时调用,确保关键配置存在
|
||||
*/
|
||||
export function validateEnv(): void {
|
||||
const errors: string[] = []
|
||||
const warnings: string[] = []
|
||||
|
||||
// ========== 必需配置验证 ==========
|
||||
|
||||
if (!process.env.DATABASE_URL) {
|
||||
errors.push('DATABASE_URL is required')
|
||||
}
|
||||
|
||||
// 检查LLM API Keys
|
||||
if (!config.deepseekApiKey && !config.dashscopeApiKey) {
|
||||
console.warn('Warning: No LLM API keys configured. At least one of DEEPSEEK_API_KEY or DASHSCOPE_API_KEY should be set.');
|
||||
// ========== 云原生配置验证 ==========
|
||||
|
||||
// 如果使用OSS,验证OSS配置
|
||||
if (config.storageType === 'oss') {
|
||||
if (!config.ossRegion) errors.push('OSS_REGION is required when STORAGE_TYPE=oss')
|
||||
if (!config.ossBucket) errors.push('OSS_BUCKET is required when STORAGE_TYPE=oss')
|
||||
if (!config.ossAccessKeyId) errors.push('OSS_ACCESS_KEY_ID is required when STORAGE_TYPE=oss')
|
||||
if (!config.ossAccessKeySecret) errors.push('OSS_ACCESS_KEY_SECRET is required when STORAGE_TYPE=oss')
|
||||
}
|
||||
|
||||
// 如果使用Redis,验证Redis配置
|
||||
if (config.cacheType === 'redis') {
|
||||
if (!config.redisHost && !config.redisUrl) {
|
||||
warnings.push('REDIS_HOST or REDIS_URL should be set when CACHE_TYPE=redis')
|
||||
}
|
||||
}
|
||||
|
||||
// ========== 安全配置验证 ==========
|
||||
|
||||
if (config.nodeEnv === 'production') {
|
||||
if (config.jwtSecret === 'your-secret-key-change-in-production') {
|
||||
errors.push('JWT_SECRET must be changed in production')
|
||||
}
|
||||
}
|
||||
|
||||
// ========== LLM配置验证 ==========
|
||||
|
||||
if (!config.deepseekApiKey && !config.dashscopeApiKey && !config.closeaiApiKey) {
|
||||
warnings.push(
|
||||
'No LLM API keys configured. At least one of DEEPSEEK_API_KEY, DASHSCOPE_API_KEY, or CLOSEAI_API_KEY should be set.'
|
||||
)
|
||||
}
|
||||
|
||||
// ========== 输出验证结果 ==========
|
||||
|
||||
if (errors.length > 0) {
|
||||
console.error('❌ [Config] Environment validation failed:')
|
||||
errors.forEach(err => console.error(` - ${err}`))
|
||||
throw new Error('Environment validation failed. Please check configuration.')
|
||||
}
|
||||
|
||||
if (warnings.length > 0) {
|
||||
console.warn('⚠️ [Config] Environment validation warnings:')
|
||||
warnings.forEach(warn => console.warn(` - ${warn}`))
|
||||
}
|
||||
|
||||
// 成功
|
||||
if (errors.length === 0 && warnings.length === 0) {
|
||||
console.log('✅ [Config] Environment validation passed')
|
||||
}
|
||||
|
||||
// 输出关键配置(脱敏)
|
||||
console.log('[Config] Application configuration:')
|
||||
console.log(` - Environment: ${config.nodeEnv}`)
|
||||
console.log(` - Port: ${config.port}`)
|
||||
console.log(` - Storage: ${config.storageType}`)
|
||||
console.log(` - Cache: ${config.cacheType}`)
|
||||
console.log(` - Queue: ${config.queueType}`)
|
||||
console.log(` - Log Level: ${config.logLevel}`)
|
||||
if (config.enabledModules.length > 0) {
|
||||
console.log(` - Enabled Modules: ${config.enabledModules.join(', ')}`)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user