# nomo-api-starter **Repository Path**: nancoding/nomo-api-starter ## Basic Information - **Project Name**: nomo-api-starter - **Description**: nomo-api-starter 是使用Node.js + Express + MongoDB 技术栈构建的后端API接口应用,方便快速上手开发后端应用程序。 - **Primary Language**: Unknown - **License**: MIT - **Default Branch**: master - **Homepage**: None - **GVP Project**: No ## Statistics - **Stars**: 0 - **Forks**: 0 - **Created**: 2026-03-29 - **Last Updated**: 2026-09-12 ## Categories & Tags **Categories**: Uncategorized **Tags**: None ## README nomoex-api-starter 是使用Node.js + Express + MongoDB 技术栈构建的后端API接口应用,方便快速上手开发后端应用程序。 # 快速开始 ```bash # 安装依赖 pnpm install # 启动服务 pnpm start # 启动服务(开发模式) pnpm dev # 代码检查 pnpm lint # 代码格式化 pnpm format # 使用pm2启动服务(开发模式) pnpm pm2:start:dev # 使用pm2启动服务(生产模式) pnpm pm2:start:prod # 停止pm2服务 pnpm pm2:stop # 删除所有pm2服务 npx pm2 delete all # 重启pm2服务 pnpm pm2:restart # 查看pm2服务状态 pnpm pm2:status # 查看pm2服务日志 pnpm pm2:logs ``` # 项目架构 项目核心业务逻辑使用`路由层` + `控制器层` + `服务层` + `数据模型层`的四层架构模型和`中间件层` + `工具层` + `配置层` + `验证层` + `文档层`的辅助架构层次。 ## 核心业务逻辑四层架构 ### 1.路由层(Routes) **职责:** - 定义API端点和HTTP方法 - 应用中间件(认证、验证等) - 将请求路由到对应的控制器 **文件位置:**`src/routes` **示例:** ```js // src/routes/v1/auth.route.js import express from 'express' import validate from '../../middlewares/validate.js' import authValidation from '../../validations/auth.validation.js' import authController from '../../controllers/auth.controller.js' import auth from '../../middlewares/auth.js' const router = express.Router() router.post('/register', validate(authValidation.register), authController.register) router.post('/login', validate(authValidation.login), authController.login) // 其他路由... export default router ``` ### 2.控制器层(Controllers) **职责:** - 处理HTTP请求和响应 - 调用服务层方法执行业务逻辑 - 格式化响应数据 - 处理错误和异常 **文件位置:**`src/controllers` **示例:** ```js // src/controllers/auth.controller.js import httpStatus from 'http-status' import catchAsync from '../utils/catchAsync.js' import response from '../utils/response.js' import { authService, userService, tokenService, emailService } from '../services/index.js' const register = catchAsync(async (req, res) => { const user = await userService.createUser(req.body) const tokenData = await tokenService.generateAuthTokens(user) response.success(res, httpStatus.CREATED, '注册成功', { id: user.id, username: user.username, email: user.email, role: user.role, token: tokenData.token }) }) // 其他控制器方法... export default { register // 其他方法... } ``` ### 3.服务层(Services) **职责:** - 实现核心业务逻辑 - 处理数据操作和业务规则 - 协调多个模型的交互 - 提供可重用的业务功能 **文件位置:**`src/services` **示例:** ```js // src/services/user.service.js import httpStatus from 'http-status' import ApiError from '../utils/apiError.js' import User from '../models/user.model.js' const createUser = async (userBody) => { if (await User.isEmailTaken(userBody.email)) { throw new ApiError(httpStatus.BAD_REQUEST, '邮箱已注册') } return await User.create(userBody) } // 其他服务方法... export default { createUser // 其他方法... } ``` ### 4.数据模型层(Models) **职责:** - 定义数据结构和Schema - 实现数据验证和业务规则 - 提供数据访问方法 - 处理数据库操作 **文件位置:**`src/models` **示例:** ```js // src/models/user.model.js import mongoose from 'mongoose' import bcrypt from 'bcryptjs' import config from '../config/config.js' import { toJSON, paginate, softDelete, timestamp } from './plugins/index.js' import rolesConfig from '../config/roles.js' const userSchema = mongoose.Schema({ username: { type: String, required: true, trim: true }, email: { type: String, required: true, trim: true, unique: true, lowercase: true, validate: { validator: function (v) { return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v) }, message: '请输入有效的邮箱地址' } } // 其他字段... }) // 添加插件和方法... const User = mongoose.model('User', userSchema) export default User ``` ## 辅助架构 ### 1.中间件层(Middlewares) **职责:** - 处理请求前的预处理逻辑 - 实现认证、授权、验证等横切关注点 - 错误处理和日志记录 - 请求/响应转换 **文件位置:**`src/middlewares` **示例:** ```js // src/middlewares/validate.js import Joi from 'joi' import httpStatus from 'http-status' import pick from '../utils/pick.js' import ApiError from '../utils/apiError.js' const validate = (schema) => (req, res, next) => { const validSchema = pick(schema, ['param', 'query', 'body']) const object = pick(req, Object.keys(validSchema)) const { value, error } = Joi.compile(validSchema) .prefs({ errors: { label: 'key' }, abortEarly: false }) .validate(object) if (error) { const errorMessage = error.details.map((details) => details.message).join(', ') return next(new ApiError(httpStatus.BAD_REQUEST, errorMessage)) } Object.assign(req, value) return next() } export default validate ``` ### 2.工具层(Utils) **职责:** - 提供通用的工具函数 - 实现辅助功能和通用逻辑 - 封装第三方库的使用 **文件位置:**`src/utils` **示例:** ```js // src/utils/response.js import httpStatus from 'http-status' import { getCurrentTimestamp } from './time.js' import config from '../config/config.js' export const success = (res, data = null, message = '操作成功', code = httpStatus.OK) => { const response = { success: true, message, code } if (data !== null && data !== undefined) { response.data = data } if (config.env === 'development') { response.timestamp = getCurrentTimestamp() } return res.status(code).json(response) } // 其他工具函数... ``` ### 3.配置层(Config) **职责:** - 管理应用逻辑 - 加载和验证环境配置 - 提供结构化的配置对象 **文件位置:**`src/config` **示例:** ```js // src/config/config.js import dotenv from 'dotenv' import path from 'path' import Joi from 'joi' import { fileURLToPath } from 'url' const __filename = fileURLToPath(import.meta.url) const __dirname = path.dirname(__filename) // 根据环境变量加载对应的环境配置文件 const env = process.env.NODE_ENV dotenv.config({ path: path.join(__dirname, `../../.env.${env}`), silent: true }) // 环境变量的验证模式 const envVarsSchema = Joi.object() .keys({ APP_NAME: Joi.string().required().description('应用名称'), APP_VERSION: Joi.string().required().description('应用版本'), NODE_ENV: Joi.string().valid('production', 'development', 'test').required() // 其他配置项... }) .unknown() // 验证环境变量 const { value: envVars, error } = envVarsSchema.prefs({ errors: { label: 'key' } }).validate(process.env) if (error) { throw new Error(`环境变量验证失败: ${error.message}`) } // 导出配置对象 export default { app: { appName: envVars.APP_NAME, appVersion: envVars.APP_VERSION } // 其他配置... } ``` ### 4.验证层(Validations) **职责:** - 定义请求参数的验证规则 - 实现自定义验证逻辑 - 提供结构化的验证规则配置 **文件位置:**`src/validations` **示例:** ```js // src/validations/auth.validation.js import Joi from 'joi' import { password } from './custom.validation.js' const register = { body: Joi.object().keys({ email: Joi.string().required().email(), password: Joi.string().required().custom(password), username: Joi.string().required() }) } // 其他验证规则... export default { register // 其他规则... } ``` ## 架构特点 - **核心业务逻辑清晰**:四层架构专注于核心业务逻辑,职责明确 - **辅助层次完善**:辅助架构层次提供支持功能,增强系统能力 - **可维护性**:代码组织有序,易于理解和维护 - **可扩展性**:模块化设计,便于添加新功能 - **可测试性**:各层独立,易于编写单元测试 - **安全性**:统一的认证、授权和验证机制 - **性能优化**:合理的分层设计,避免不必要的计算和数据库操作 ## 数据流向 1. **客户端请求** → 路由层接收请求 2. **路由层** → 应用中间件(认证、验证等) 3. **中间件** → 控制器层处理请求 4. **控制器层** → 调用服务层执行业务逻辑 5. **服务层** → 调用模型层进行数据操作 6. **模型层** → 与数据库交互 7. **服务层** → 处理业务逻辑并返回结果 8. **控制器层** → 格式化响应数据 9. **路由层** → 返回响应给客户端 ## 技术栈 - **数据库**:`MongoDB (Mongoose)` - **认证**:`JWT` - **验证**:`Joi` - **日志**:`Winston` + `Winston Daily Rotate File` + `Morgan` - **环境管理**:`Dotenv` + `Cross-env` - **代码质量**:`OxFmt` + `OxLint` # 文件结构 ```bash nem/ ├── .husky/ # Husky配置目录 ├── .vscode/ # VS Code配置目录 ├── logs/ # 日志目录 ├── node_modules/ # 项目依赖目录 ├── src/ # 项目源代码目录 │ ├── config/ # 配置目录 │ ├── controllers/ # 控制器目录 │ ├── database/ # 数据库目录 │ ├── jobs/ # 任务目录 │ ├── middlewares/ # 中间件目录 │ ├── models/ # 模型目录 │ ├── routes/ # 路由目录 │ ├── services/ # 服务目录 │ ├── utils/ # 工具目录 │ ├── validations/ # 校验规则目录 │ ├── app.js # 主应用文件 │ └── index.js # 入口文件 ├── .env.example # 环境变量示例文件 ├── .gitignore # Git忽略文件配置文件 ├── .oxfmtrc.json # OxFmt配置JSON文件 ├── .oxlintrc.json # OxLint配置JSON文件 ├── LICENSE # 项目许可证文件 ├── package.json # 项目依赖配置文件 ├── pm2.config.js # PM2配置文件 └── README.md # 项目说明文档 ``` # 环境配置管理 使用`dotenv` + `cross-env` + `Joi`来管理和校验环境变量。 ## 环境配置文件 - `.env.development` - 开发环境配置 - `.env.production` - 生产环境配置 - `.env.example` - 配置文件模板,用于参考和创建新的配置文件 ## 配置加载逻辑 - 当应用启动时,会根据 `NODE_ENV` 环境变量加载对应的配置文件; - 所有配置项会被验证,确保必填项存在且格式正确; - 验证通过后,配置会被转换为结构化的配置对象,供应用使用。 ## 使用配置 ```js import config from './config/config.js' // 使用结构化的配置对象 const dbUrl = config.mongoose.url const jwtSecret = config.security.jwtSecret ``` # 日志系统 项目集成了完整的日志系统,使用 winston 和 winston-daily-rotate-file 实现了按日期分割日志、自动压缩、自动清理等功能,并集成了 morgan 记录 HTTP 请求日志。 ## 核心功能 1. **日志分级**:支持 debug、info、warn、error 四个级别 2. **按日期分割**:每天生成一个新的日志文件 3. **自动压缩**:旧日志自动压缩,节省存储空间 4. **自动清理**:保留14天的日志,自动清理过期日志 5. **颜色区分**:开发环境下控制台输出带颜色区分 6. **结构化日志**:JSON 格式存储,包含 requestId、level、type 等必要字段 7. **日志脱敏**:自动脱敏 password、token、secret 等敏感字段 8. **HTTP 请求日志**:使用 morgan 记录 HTTP 请求 9. **多环境支持**:开发环境同时输出到控制台,生产环境只输出到文件 ## 使用方法 ### 1. 在控制器中使用 ```javascript // src/controllers/user.controller.js import catchAsync from '../utils/catchAsync.js' import response from '../utils/response.js' import logger from '../config/logger.js' const getUser = catchAsync(async (req, res) => { const log = logger.getLogger(req.id) log.info('获取用户信息', { userId: req.params.id }) // 业务逻辑... log.info('用户信息获取成功', { user }) response.success(res, user, '获取用户信息成功') }) ``` ### 2. 在服务层使用 ```javascript // src/services/user.service.js import logger from '../config/logger.js' const getUserById = async (userId, requestId) => { const log = logger.getLogger(requestId) log.debug('服务层获取用户', { userId }) // 业务逻辑... log.debug('用户获取成功', { user }) return user } ``` ### 3. 记录错误 ```javascript // src/controllers/user.controller.js import catchAsync from '../utils/catchAsync.js' import response from '../utils/response.js' import logger from '../config/logger.js' const updateUser = catchAsync(async (req, res) => { const log = logger.getLogger(req.id) try { log.info('更新用户信息', { userId: req.params.id, updateData: req.body }) // 业务逻辑... log.info('用户信息更新成功', { user }) response.success(res, user, '用户信息更新成功') } catch (error) { log.error('更新用户信息失败', { error: error.message }) throw error } }) ``` # 第三方登录 # 请求日志 # 请求ID 项目集成了请求ID功能,用于跟踪和关联请求的整个生命周期,提高系统的可观测性和可维护性。 ## 核心功能 1. **唯一标识**:为每个请求生成唯一的标识符 2. **自动传递**:在请求处理过程中自动传递请求ID 3. **响应头包含**:在响应头中包含请求ID 4. **日志关联**:便于在日志中关联同一请求的所有操作 ## 技术实现 ### 依赖 项目使用 `express-request-id` 中间件实现请求ID功能: ```bash npm install express-request-id ``` ### 配置 在 `src/app.js` 文件中配置请求ID中间件: ```javascript // src/app.js import express from 'express' import requestId from 'express-request-id' const app = express() // 请求ID中间件 app.use(requestId()) // 其他中间件... ``` ## 使用方法 ### 1. 在控制器中使用 ```javascript // src/controllers/user.controller.js import catchAsync from '../utils/catchAsync.js' import response from '../utils/response.js' const getUser = catchAsync(async (req, res) => { console.log(`[${req.id}] 获取用户信息: ${req.params.id}`) // 业务逻辑... response.success(res, user, '获取用户信息成功') }) ``` ### 2. 在服务层使用 ```javascript // src/services/user.service.js const getUserById = async (userId, requestId) => { console.log(`[${requestId}] 服务层获取用户: ${userId}`) // 业务逻辑... return user } // 在控制器中调用 const getUser = catchAsync(async (req, res) => { const user = await userService.getUserById(req.params.id, req.id) // ... }) ``` ### 3. 在错误处理中使用 ```javascript // src/middlewares/error.js const errorHandler = (err, req, res, next) => { console.error(`[${req.id}] 错误:`, err) // 错误处理逻辑... res.status(statusCode).json({ success: false, message: err.message, requestId: req.id }) } ``` ## 响应示例 当客户端发送请求时,响应头中会包含 `X-Request-Id` 字段: ``` HTTP/1.1 200 OK X-Request-Id: 123e4567-e89b-12d3-a456-426614174000 Content-Type: application/json { "success": true, "message": "获取用户信息成功", "data": { "id": "123", "name": "John Doe" } } ``` ## 最佳实践 1. **始终使用请求ID**:在所有日志和错误处理中使用请求ID 2. **传递请求ID**:在服务层和其他模块中传递请求ID 3. **客户端使用**:客户端可以记录响应头中的请求ID,便于问题排查 4. **统一格式**:使用统一的日志格式,包含请求ID 5. **监控集成**:在监控系统中使用请求ID关联相关指标 ## 常见问题 ### 1. 请求ID不显示在日志中 - **原因**:没有在日志中包含请求ID - **解决方案**:确保在所有日志语句中包含 `req.id` ### 2. 服务层无法获取请求ID - **原因**:没有将请求ID传递给服务层 - **解决方案**:在调用服务层方法时传递 `req.id` 参数 ### 3. 响应中没有请求ID - **原因**:请求ID中间件配置不正确 - **解决方案**:确保在其他中间件之前使用 `app.use(requestId())` ## 示例日志 ``` [123e4567-e89b-12d3-a456-426614174000] 处理请求: GET /api/v1/users/123 [123e4567-e89b-12d3-a456-426614174000] 服务层获取用户: 123 [123e4567-e89b-12d3-a456-426614174000] 用户获取成功: { id: '123', name: 'John Doe' } [123e4567-e89b-12d3-a456-426614174000] 响应状态: 200 ``` 通过使用请求ID,您可以轻松追踪每个请求的完整处理过程,快速定位和解决问题,提高系统的可观测性和可维护性。 # 日志系统 项目集成了完整的日志系统,使用 winston、winston-daily-rotate-file 和 morgan 实现了按日期分割日志、自动压缩、自动清理等功能,并集成了 morgan 记录 HTTP 请求日志。 ## 核心功能 1. **日志分级**:支持 debug、info、warn、error 四个级别 2. **按日期分割**:每天生成一个新的日志文件 3. **自动压缩**:旧日志自动压缩,节省存储空间 4. **自动清理**:保留14天的日志,自动清理过期日志 5. **颜色区分**:开发环境下控制台输出带颜色区分 6. **结构化日志**:JSON 格式存储,包含 requestId、level、type 等必要字段 7. **日志脱敏**:自动脱敏 password、token、secret 等敏感字段 8. **HTTP 请求日志**:使用 morgan 记录 HTTP 请求,包含完整的请求信息 9. **多环境支持**:开发环境同时输出到控制台,生产环境只输出到文件 ## 技术实现 ### 依赖 - `winston`:日志记录库 - `winston-daily-rotate-file`:日志轮转插件 - `morgan`:HTTP 请求日志中间件 ### 配置 日志系统的配置文件位于 `src/config/logger.js`,包含以下主要配置: - **日志存储路径**:`logs` 目录 - **日志轮转配置**:按日期分割,每天生成一个新文件 - **压缩配置**:旧日志自动压缩 - **清理配置**:保留14天的日志 - **日志格式**:结构化 JSON 格式,包含必要字段 - **脱敏配置**:自动脱敏敏感字段 ### HTTP 请求日志中间件 HTTP 请求日志中间件位于 `src/middlewares/logger.js`,实现了以下功能: - **统一日志格式**:每条 HTTP 请求只生成一条日志,包含完整信息 - **请求体记录**:记录请求体内容(经过脱敏处理) - **用户代理记录**:记录客户端用户代理 - **响应信息记录**:记录响应状态码和响应时间 - **请求ID关联**:与请求ID关联,便于跟踪请求生命周期 ### 集成到应用 在 `src/app.js` 文件中集成日志中间件: ```javascript // src/app.js import express from 'express' import requestId from 'express-request-id' import loggerMiddleware from './middlewares/logger.js' const app = express() // 请求ID中间件 app.use(requestId()) // 其他中间件... // 日志中间件 app.use(loggerMiddleware.httpLogger) // 路由... ``` ## 使用方法 ### 1. 在控制器中使用 ```javascript // src/controllers/user.controller.js import catchAsync from '../utils/catchAsync.js' import response from '../utils/response.js' import logger from '../config/logger.js' const getUser = catchAsync(async (req, res) => { const log = logger.getLogger(req.id) log.info('获取用户信息', { userId: req.params.id }) // 业务逻辑... log.info('用户信息获取成功', { user }) response.success(res, user, '获取用户信息成功') }) ``` ### 2. 在服务层使用 ```javascript // src/services/user.service.js import logger from '../config/logger.js' const getUserById = async (userId, requestId) => { const log = logger.getLogger(requestId) log.debug('服务层获取用户', { userId }) // 业务逻辑... log.debug('用户获取成功', { user }) return user } ``` ### 3. 记录错误 ```javascript // src/controllers/user.controller.js import catchAsync from '../utils/catchAsync.js' import response from '../utils/response.js' import logger from '../config/logger.js' const updateUser = catchAsync(async (req, res) => { const log = logger.getLogger(req.id) try { log.info('更新用户信息', { userId: req.params.id, updateData: req.body }) // 业务逻辑... log.info('用户信息更新成功', { user }) response.success(res, user, '用户信息更新成功') } catch (error) { log.error('更新用户信息失败', { error: error.message }) throw error } }) ``` ## 日志文件结构 日志文件存储在 `logs` 目录中,按日期分割: ``` logs/ ├── all-2024-01-01.log # 所有级别的日志 ├── all-2024-01-02.log.gz # 压缩的旧日志 ├── error-2024-01-01.log # 错误级别的日志 └── error-2024-01-02.log.gz # 压缩的旧错误日志 ``` ## 日志格式 ### JSON 格式(文件) ```json { "level": "info", "type": "http", "message": "HTTP request", "requestId": "123e4567-e89b-12d3-a456-426614174000", "ip": "127.0.0.1", "method": "GET", "url": "/api/v1/users/123", "status": 200, "userAgent": "Mozilla/5.0...", "body": {}, "responseTime": "1.234", "timestamp": "2024-01-01T00:00:00.000Z" } ``` ### 控制台格式(开发环境) ``` [2024-01-01 00:00:00.000] [info] [123e4567-e89b-12d3-a456-426614174000] [http] HTTP request {"ip":"127.0.0.1","method":"GET","url":"/api/v1/users/123","status":200,"userAgent":"Mozilla/5.0...","body":{},"responseTime":"1.234"} ``` ## 最佳实践 1. **使用带请求ID的日志器**:使用 `logger.getLogger(req.id)` 获取带请求ID的日志器 2. **合理使用日志级别**: - `debug`:调试信息,仅开发环境可见 - `info`:正常的业务流程信息 - `warn`:警告信息,需要关注但不影响运行 - `error`:错误信息,需要立即处理 3. **结构化日志**:在日志中包含足够的上下文信息,便于分析 4. **日志脱敏**:避免在日志中记录敏感信息 5. **日志轮转**:不要修改日志轮转配置,保持默认的14天保留期 6. **监控日志**:定期检查日志,特别是错误日志 7. **性能考虑**:不要在高频调用的代码中记录过多日志 8. **统一格式**:确保所有日志使用统一的格式,便于分析和处理 # 错误处理 实现完善的错误处理机制,确保API能够返回统一、规范的错误响应。 ## 自定义错误类 项目使用`ApiError`类来表示应用程序中的错误,继承自`Error`类,包含以下属性: - statusCode:HTTP状态码 - message:错误消息 - isOperational:是否为可操作的错误 - stack:错误栈信息 ## 全局异常捕获 项目中使用两个中间件来处理错误: - `errorConverter`:将普通错误转换为`ApiError`实例; - `errorHandler`:处理所有错误并返回统一的错误响应。 ## 异步错误捕获 使用`catchAsync`工具函数捕获异步函数中的错误: ```js import catchAsync from '../utils/catchAsync.js' const register = catchAsync(async (req, res) => { // 业务逻辑 }) ``` ## 未捕获异常处理 注册全局未捕获异常处理器,捕获未处理的异常和`Promise`拒绝: - `uncaughtException`:捕获未处理的同步异常; - `unhandledRejection`:捕获未处理的`Promise`拒绝。 ## 404处理 对未知的API请求返回404错误: ```js app.use((req, res, next) => { next(new ApiError(httpStatus.NOT_FOUND, '未找到该资源')) }) ``` ## 使用示例 ```js // 1. 抛出自定义错误 import ApiError from '../utils/apiError.js' import httpStatus from 'http-status' if (!user) { throw new ApiError(httpStatus.NOT_FOUND, '用户不存在') } // 2. 使用错误响应工具 import response from '../utils/response.js' // 返回未授权错误 return response.unauthorized(res, '请先登录') // 返回禁止访问错误 return response.forbidden(res, '权限不足') // 返回未找到资源错误 return response.notFound(res, '资源不存在') // 3. 捕获异步错误 import catchAsync from '../utils/catchAsync.js' const getUser = catchAsync(async (req, res) => { const user = await userService.getUserById(req.params.userId) if (!user) { throw new ApiError(httpStatus.NOT_FOUND, '未查询到该用户') } response.success(res, user, '用户查询成功') }) ``` # 请求响应规范 项目实现了统一的请求响应规范,确保所有API返回格式一致,便于前端处理。 ## 响应工具函数 项目提供了以下响应工具函数: | 函数名 | 描述 | 参数 | | ----------------- | -------------- | -------------------------------------------------------- | | `success` | 成功响应 | res, code, message, data | | `error` | 错误响应 | res, code, message, data | | `pagination` | 分页响应 | res, code, message, records, total, pages, current, size | | `list` | 列表响应 | res, code, message, data, total | | `unauthorized` | 未授权响应 | res, message | | `forbidden` | 禁止访问响应 | res, message | | `notFound` | 未找到资源响应 | res, message | | `serverError` | 服务器错误响应 | res, message | | `tooManyRequests` | 访问频繁响应 | res, message | ## 响应格式 ```json // 1. 成功响应 { "code": 200, "message": "操作成功", "data": { /* 响应数据 */ }, "timestamp": "2026-04-07 14:00:00.000" /* 仅开发环境 */ } // 2. 错误响应 { "code": 400, "message": "操作失败", "data": { /* 错误详情 */ }, /* 仅开发环境 */ "timestamp": "2026-04-07 14:00:00.000" /* 仅开发环境 */ } // 3. 分页响应 { "success": true, "code": 200, "message": "查询成功", "data": { "records": [ /* 数据列表 */ ], "total": 100, "pages": 10, "current": 1, "size": 10 }, "timestamp": "2026-04-07 14:00:00.000" /* 仅开发环境 */ } // 4. 列表响应 { "code": 200, "message": "查询成功", "data": [ /* 数据列表 */ ], "timestamp": "2026-04-07 14:00:00.000" /* 仅开发环境 */ } ``` ## 使用示例 ```js // 1. 成功响应 import response from '../utils/response.js' import httpStatus from 'http-status' // 基本成功响应 response.success(res, httpStatus.CREATED, '用户创建成功', { user: userData }) // 无数据成功响应 response.success(res, httpStatus.NO_CONTENT, '操作成功', null) // 2. 错误响应 import response from '../utils/response.js' import httpStatus from 'http-status' // 基本错误响应 response.error(res, httpStatus.BAD_REQUEST, '参数错误') // 带错误详情的错误响应 response.error(res, httpStatus.BAD_REQUEST, '验证失败', { errors: validationErrors }) // 3. 分页响应 import response from '../utils/response.js' // 分页响应 (res, code, message, records, total, pages, current, size) response.pagination(res, httpStatus.OK, '用户列表查询成功', users, 100, 10, 1, 10) // 4. 列表响应 import response from '../utils/response.js' // 列表响应 (res, code, message, data, total) response.list(res, httpStatus.OK, '用户列表查询成功', users, 100) // 5. 特殊响应 import response from '../utils/response.js' // 未授权响应 response.unauthorized(res, '请先登录') // 禁止访问响应 response.forbidden(res, '权限不足') // 未找到资源响应 response.notFound(res, '用户不存在') // 服务器错误响应 response.serverError(res, '服务器内部错误') // 访问频繁响应 response.tooManyRequests(res, '请求过于频繁,请稍后再试') ``` ## 状态码规范 项目使用标准的HTTP状态码,常见状态码如下: | 状态码 | 描述 | 适用场景 | | ------ | --------------------- | ---------------- | | 200 | OK | 成功获取数据 | | 201 | Created | 成功创建资源 | | 204 | No Content | 成功但无数据返回 | | 400 | Bad Request | 请求参数错误 | | 401 | Unauthorized | 未授权访问 | | 403 | Forbidden | 禁止访问 | | 404 | Not Found | 资源不存在 | | 429 | Too Many Requests | 访问过于频繁 | | 500 | Internal Server Error | 服务器内部错误 | ## 自定义业务码 项目使用自定义业务码来提供更详细的错误分类,业务码定义在 `src/config/businessCode.js` 文件中: ### 业务码分类规则 | 业务码范围 | 类别 | 说明 | | ---------- | ---------- | -------------------- | | 0 | 成功 | 所有正常业务返回此码 | | 01xxx | 系统级错误 | 服务器内部错误 | | 10xxx | 用户/登录 | 用户认证相关错误 | | 11xxx | 权限 | 权限验证相关错误 | | 20xxx | 参数校验 | 请求参数相关错误 | | 30xxx | 数据资源 | 数据相关错误 | | 40xxx | 订单/支付 | 订单支付相关错误 | | 50xxx | 限流重复 | 请求限流相关错误 | | 60xxx | 文件 | 文件处理相关错误 | | 70xxx | 数据库 | 数据库操作相关错误 | | 80xxx | 第三方 | 第三方接口相关错误 | ### 业务码详细列表 #### 成功码 | 业务码 | 常量名 | 描述 | | ------ | ------- | -------- | | 0 | SUCCESS | 操作成功 | #### 系统级错误(01xxx) | 业务码 | 常量名 | 描述 | | ------ | -------------- | -------------------- | | 1001 | SYS_ERROR | 服务器内部错误 | | 1002 | SYS_BUSY | 服务繁忙,请稍后重试 | | 1003 | API_DEPRECATED | 接口已废弃 | #### 用户/登录/Token(10xxx) | 业务码 | 常量名 | 描述 | | ------ | ------------------- | --------------------- | | 10001 | NOT_LOGIN | 未登录,请先登录 | | 10002 | TOKEN_EXPIRED | Token过期,请重新登录 | | 10003 | TOKEN_INVALID | Token签名非法 | | 10004 | USER_NOT_EXIST | 用户不存在 | | 10005 | PASSWORD_WRONG | 密码错误 | | 10006 | USER_DISABLED | 用户账号已被禁用 | | 10007 | VERIFY_CODE_ERROR | 验证码错误 | | 10008 | VERIFY_CODE_EXPIRED | 验证码过期 | | 10009 | EMAIL_REGISTERED | 邮箱已被注册 | | 10010 | EMAIL_NO_REGISTER | 邮箱未注册 | #### 权限(11xxx) | 业务码 | 常量名 | 描述 | | ------ | --------------- | ---------------- | | 11001 | NO_PERMISSION | 无权限操作 | | 11003 | DATA_SCOPE_DENY | 数据归属校验失败 | #### 参数校验(20xxx) | 业务码 | 常量名 | 描述 | | ------ | -------------------- | ---------------------- | | 20001 | PARAM_EMPTY | 请求参数不能为空 | | 20002 | PARAM_FORMAT_ERROR | 请求参数格式错误 | | 20003 | PARAM_RANGE_ERROR | 请求参数范围错误 | | 20004 | PARAM_METHOD_INVALID | 请求方法不合法 | | 20005 | PARAM_INVALID | 非法请求参数(恶意参数) | #### 数据资源(30xxx) | 业务码 | 常量名 | 描述 | | ------ | -------------------- | -------------- | | 30001 | DATA_NOT_EXIST | 数据不存在 | | 30002 | DATA_ALREADY_EXIST | 数据已存在 | | 30003 | DATA_STATUS_INVALID | 数据状态无效 | | 30004 | DATA_STATUS_EXPIRED | 数据状态过期 | | 30005 | DATA_STATUS_DISABLED | 数据状态已禁用 | #### 订单/支付(40xxx) | 业务码 | 常量名 | 描述 | | ------ | -------------------------- | ---------------- | | 40001 | ORDER_NOT_EXIST | 订单不存在 | | 40002 | ORDER_STATUS_INVALID | 订单状态异常 | | 40003 | ORDER_BALANCE_INSUFFICIENT | 余额不足 | | 40004 | ORDER_PAYMENT_TIMEOUT | 支付超时 | | 40005 | ORDER_PAYMENT_REPEAT | 重复提交支付请求 | #### 限流重复(50xxx) | 业务码 | 常量名 | 描述 | | ------ | ------------------ | ------------------------ | | 50001 | REQ_FREQUENT | 操作过于频繁,请稍后再试 | | 50002 | REQ_FREQUENT_LIMIT | 今日操作次数已达上限 | | 50003 | IP_BANNED | IP已被临时封禁 | | 50004 | REPEAT_SUBMIT | 请勿重复提交 | #### 文件(60xxx) | 业务码 | 常量名 | 描述 | | ------ | ----------------- | ---------------- | | 60001 | FILE_EMPTY | 文件不能为空 | | 60002 | FILE_TYPE_INVALID | 文件类型无效 | | 60003 | FILE_SIZE_OVER | 文件大小超过限制 | | 60004 | FILE_UPLOAD_ERROR | 文件上传失败 | | 60005 | FILE_PARSE_ERROR | 文件解析错误 | #### 数据库(70xxx) | 业务码 | 常量名 | 描述 | | ------ | ------------------ | ------------------ | | 70001 | DB_QUERY_ERROR | 数据库查询失败 | | 70002 | DB_INSERT_ERROR | 数据库新增失败 | | 70003 | DB_UPDATE_ERROR | 数据库更新失败 | | 70004 | DB_DELETE_ERROR | 数据库删除失败 | | 70005 | DB_UNIQUE_CONFLICT | 数据库唯一索引冲突 | #### 第三方(80xxx) | 业务码 | 常量名 | 描述 | | ------ | --------------- | ------------------ | | 80001 | THIRD_API_ERROR | 第三方接口调用失败 | | 80002 | S3_OBJECT_ERROR | S3对象存储异常错误 | ### 业务码使用示例 ```js import { CODE, getCodeMsg } from '../config/businessCode.js' // 抛出业务错误 throw new ApiError(httpStatus.BAD_REQUEST, getCodeMsg(CODE.USER_NOT_EXIST), CODE.USER_NOT_EXIST) // 直接使用业务码 response.error(res, httpStatus.UNAUTHORIZED, getCodeMsg(CODE.NOT_LOGIN), CODE.NOT_LOGIN) ``` ## 最佳实践 - **统一使用响应工具**:所有API响应都应使用提供的响应工具函数,确保格式一致。 - **合理使用状态码**:根据实际情况选择合适的HTTP状态码,遵循RESTful API设计规范。 - **提供清晰的错误消息**:错误响应应包含清晰、具体的错误消息,便于前端理解和处理。 - **分页数据标准化**:对于列表数据,应使用分页响应格式,包含分页信息。 - **开发环境增强**:开发环境下,响应会包含时间戳等额外信息,便于调试。 - **安全性考虑**:生产环境下,错误响应不包含敏感的错误详情,仅返回必要的错误信息。 # 参数验证 项目使用 `Joi` 库实现了强大的参数验证功能,确保API接收到的数据符合预期格式和规则。 ## 验证架构 - 验证中间件(src/middlewares/validate.js) - 自定义验证规则(src/validations/custom.validation.js) - 验证规则定义(/ src/validations/\*\*\*.validation.js) ## 使用方法 在路由中使用验证中间件: ```js import express from 'express' import validate from '../../middlewares/validate.js' import authValidation from '../../validations/auth.validation.js' import authController from '../../controllers/auth.controller.js' const router = express.Router() router.post('/register', validate(authValidation.register), authController.register) router.post('/login', validate(authValidation.login), authController.login) // 其他路由... export default router ``` 验证规则可以包含三个部分: - `param`:验证`URL`参数; - `query`:验证查询参数; - `body`:验证请求体。 ```js const validationRule = { param: Joi.object({ /* URL参数验证规则 */ }), query: Joi.object({ /* 查询参数验证规则 */ }), body: Joi.object({ /* 请求体验证规则 */ }) } ``` ## 验证规则示例 ```js // 1. 基本类型验证 const schema = { body: Joi.object({ name: Joi.string().required(), age: Joi.number().integer().min(18).max(100), email: Joi.string().email().required(), isActive: Joi.boolean().default(true), tags: Joi.array().items(Joi.string()) }) } // 2. 自定义验证 const schema = { body: Joi.object({ password: Joi.string().required().custom(password), userId: Joi.string().custom(objectId) }) } // 3. 条件验证 const schema = { body: Joi.object({ type: Joi.string().valid('admin', 'user').required(), password: Joi.when('type', { is: 'admin', then: Joi.string().required().min(12), otherwise: Joi.string().required().min(8) }) }) } // 4. 嵌套对象验证 const schema = { body: Joi.object({ user: Joi.object({ name: Joi.string().required(), email: Joi.string().email().required(), address: Joi.object({ street: Joi.string().required(), city: Joi.string().required() }) }) }) } ``` ## 错误处理 验证失败时,验证中间件会自动生成错误响应,格式如下: ```json { "success": false, "message": "参数错误:邮箱必须是有效的电子邮件地址,密码长度必须至少为8个字符", "code": 400, "errors": { "stack": "错误栈信息" // 仅开发环境 }, "timestamp": "2026-04-07 14:00:00.000" // 仅开发环境 } ``` ## 最佳实践 - **验证所有输入**:对所有API的输入参数进行验证,包括URL参数、查询参数和请求体。 - **使用自定义验证**:对于特殊的验证需求(如密码强度、ObjectId格式等),使用自定义验证规则。 - **详细的错误信息**:提供清晰、具体的错误信息,便于前端理解和处理。 - **合理的默认值**:对于可选参数,设置合理的默认值。 - **验证顺序**:将验证中间件放在路由处理函数之前,确保在业务逻辑执行前验证参数。 - **安全考虑**:验证输入数据的长度和格式,防止注入攻击和DoS攻击。 - **文档同步**:确保验证规则与API文档保持同步。 # 身份认证 使用 `JWT (JSON Web Token)`实现身份认证,结合 `Passport.js` 进行身份验证。 ## 实现原理 - 登录流程:用户登录成功后,生成单个 JWT token,返回给前端存储 - 令牌验证:使用`passport.js`的JWT策略验证令牌有效性,并检查token是否在黑名单中 - 令牌传递:前端在请求头中携带 `Authorization: Bearer ` - 登出机制:将token加入黑名单,使其失效 ## 使用示例 ```js // 1. 保护需要认证的路由 import auth from '../middlewares/auth.js' router.post('/profile', auth(), userController.updateProfile) // 2. 保护需要特定权限的路由 router.post('/admin', auth('manageUsers'), adminController.dashboard) // 3. 保护需要特定角色的路由 router.post('/admin-only', auth({ roles: ['admin'] }), adminController.dashboard) // 4. 保护需要特定角色和权限的路由 router.post('/admin/manage-users', auth({ roles: ['admin'], rights: ['manageUsers'] }), adminController.manageUsers) // 5. 保护需要可选认证的路由 router.post('/visit', auth({ optional: true }), statisticsController.recordVisit) ``` # 授权(RBAC) 实现了基于角色的访问控制(RBAC),确保用户只能访问其权限范围内的资源。 ## 角色定义 ```js // src/config/roles.js const allRoles = { user: [], admin: [] } const roles = Object.keys(allRoles) const roleRights = new Map(Object.entries(allRoles)) export default { roles, roleRights } ``` ## 权限检查 项目提供了一个统一的`auth`中间件,支持多种验证方式: ```js // src/middlewares/auth.js /** * 认证和授权中间件 * 支持多种使用方式: * 1. auth() - 只验证登录状态 * 2. auth('right1', 'right2') - 验证登录状态和权限 * 3. auth({ roles: ['role1', 'role2'] }) - 验证登录状态和角色 * 4. auth({ rights: ['right1', 'right2'] }) - 验证登录状态和权限 * 5. auth({ roles: ['role1'], rights: ['right1'] }) - 验证登录状态、角色和权限 * 6. auth({ optional: true }) - 可选认证,登录用户记录用户ID,未登录用户记录IP */ const auth = (...args) => { // 实现逻辑 } export default auth ``` # 会员系统 实现了基于会员等级的访问控制,支持多种会员类型,管理员可调整用户会员状态。 ## 会员类型 | 类型 | 名称 | 有效期 | 价格(元) | 说明 | | --------- | -------- | ------ | ---------- | -------------------- | | normal | 普通用户 | - | 0 | 基础功能访问权限 | | monthly | 月度会员 | 30天 | 29 | 解锁全部高级功能 | | quarterly | 季度会员 | 90天 | 79 | 解锁全部高级功能 | | yearly | 年度会员 | 365天 | 259 | 解锁全部高级功能 | | permanent | 永久会员 | 永久 | 599 | 永久解锁全部高级功能 | ## 实现原理 - 会员状态存储在用户模型中,包含 `membership`(会员类型)和 `membershipExpireAt`(过期时间)字段 - 到期时间计算:开通会员后,到期时间设置为到期日的23:59:59 - 续费逻辑:如果用户已有未过期会员,新开通会员会延长有效期 - 管理员操作:只有管理员可以调整用户的会员状态 ## 会员权限验证 项目的`auth`中间件支持会员权限验证: ```js // src/middlewares/auth.js /** * 认证和授权中间件 * 支持多种使用方式: * 1. auth() - 只验证登录状态 * 2. auth('right1', 'right2') - 验证登录状态和权限 * 3. auth({ roles: ['role1', 'role2'] }) - 验证登录状态和角色 * 4. auth({ member: true }) - 验证登录状态和会员身份 */ // 保护需要会员身份的路由 router.get('/vip-content', auth({ member: true }), vipController.getContent) ``` ## API 接口 ### 获取当前用户会员状态 ``` GET /api/v1/users/membership/my ``` **权限**:登录用户 **响应示例**: ```json { "code": 200, "message": "获取会员状态成功", "data": { "membership": true, "expireAt": "2026-05-31T23:59:59.999Z" } } ``` **字段说明**: - `isMember`:是否为有效会员(布尔值) - `expireAt`:会员到期时间(ISO格式,null表示永久会员或非会员) ### 获取所有会员类型 ``` GET /api/v1/users/membership/types ``` **权限**:公开 **响应示例**: ```json { "code": 200, "message": "获取会员类型列表成功", "data": [ { "type": "monthly", "name": "月度会员", "duration": 30, "price": 29, "description": "解锁全部高级功能,有效期30天" }, { "type": "quarterly", "name": "季度会员", "duration": 90, "price": 79, "description": "解锁全部高级功能,有效期90天" } ] } ``` ### 管理员调整用户会员状态 ``` POST /api/v1/users/:userId/membership ``` **权限**:管理员 **请求体**: ```json { "membershipType": "monthly" } ``` **参数说明**: - `membershipType`:必填,会员类型(normal/monthly/quarterly/yearly/permanent) **响应示例**: ```json { "code": 200, "message": "会员状态更新成功", "data": { "isMember": true, "expireAt": "2026-05-31T23:59:59.999Z" } } ``` ## 登录返回会员状态 用户登录时会返回会员状态: ```json { "code": 200, "message": "登录成功", "data": { "id": "60d21b4667d0d8992e610c85", "username": "john_doe", "nickname": "John", "email": "john@example.com", "role": "user", "isMember": true, "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", "expires": "2026-04-29T10:30:00.000Z" } } ``` ## 配置说明 会员配置在 `src/config/roles.js` 中定义: ```js const membershipTypes = { NORMAL: 'normal', MONTHLY: 'monthly', QUARTERLY: 'quarterly', YEARLY: 'yearly', PERMANENT: 'permanent' } const membershipConfig = { normal: { name: '普通用户', duration: 0, price: 0, description: '基础功能访问权限' }, monthly: { name: '月度会员', duration: 30, price: 29, description: '解锁全部高级功能,有效期30天' }, quarterly: { name: '季度会员', duration: 90, price: 79, description: '解锁全部高级功能,有效期90天' }, yearly: { name: '年度会员', duration: 365, price: 259, description: '解锁全部高级功能,有效期365天' }, permanent: { name: '永久会员', duration: -1, price: 599, description: '永久解锁全部高级功能' } } ``` # 密码加密 使用 `bcryptjs` 对密码进行加密,确保密码安全存储。 ```js // src/models/user.model.js import bcrypt from 'bcryptjs' import config from '../config/config.js' // 密码哈希 userSchema.pre('save', async function (next) { if (this.isModified('password')) { this.password = await bcrypt.hash(this.password, config.security.saltRounds) } next() }) // 密码验证 userSchema.methods.isPasswordMatch = async function (password) { return await bcrypt.compare(password, this.password) } ``` # CORS 项目配置了 `CORS (Cross-Origin Resource Sharing)`,允许跨域请求。 ```js // src/config/security.js export const corsOptions = { origin: function (origin, callback) { // 在生产环境中,应该设置具体的域名 if (config.env === 'development' || config.env === 'test') { callback(null, true) } else { // 生产环境中设置允许的域名 const allowedOrigins = ['https://yourdomain.com'] if (allowedOrigins.indexOf(origin) !== -1 || !origin) { callback(null, true) } else { callback(new Error('CORS警告:该域名未被允许')) } } }, methods: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS'], allowedHeaders: ['Origin', 'Content-Type', 'Accept', 'Authorization', 'X-Requested-With'], credentials: true, maxAge: 86400 // 24小时 } ``` # 安全头 使用 `helmet` 中间件设置安全相关的 `HTTP` 头,防止常见的安全漏洞。 ```js // src/config/security.js export const helmetOptions = { // CSP内容安全策略 contentSecurityPolicy: { defaultSrc: ["'self'"], // 只允许从当前域名加载资源 scriptSrc: ["'self'", "'unsafe-inline'"], // 只允许从当前域名加载脚本,也允许内联脚本 styleSrc: ["'self'", "'unsafe-inline'"], // 只允许从当前域名加载样式,也允许内联样式 imgSrc: ["'self'", "'data:'", "'https:'"], // 只允许从当前域名加载图片,也允许从data:和https域名加载图片 fontSrc: ["'self'", "'https:'"], // 只允许从当前域名加载字体,也允许从data:和https域名加载字体 connectSrc: ["'self'"], // 只允许从当前域名加载连接 objectSrc: ["'none'"], // 不允许从任何域名加载对象 upgradeInsecureRequests: [] // 不允许升级不安全的请求 }, // HSTS配置,强制使用HTTPS HSTS: { maxAge: 31553600, // 1年期 includeSubDomains: true, // 包含子域名 preload: true // 预加载 }, // X-Content-Type-Options配置,防止浏览器将响应头中的内容类型作为MIME类型解析 xContentTypeOptions: true, // X-Frame-Options配置,防止点击劫持攻击 xFrameOptions: true, // X-XSS-Protection配置,防止XSS攻击 xXssProtection: true, // Referrer-Policy配置,控制浏览器如何发送Referer头 referrerPolicy: { policy: 'strict-origin-when-cross-origin' // 只发送当前域名的Referer头,跨域时发送发送当前域名的Referer头 }, // Permissions-Policy配置,控制浏览器哪些功能可以被访问 permissionsPolicy: { features: { accelerometer: ["'none'"], // 防止访问加速度计 ambientLightSensor: ["'none'"], // 防止访问环境光传感器 autoplay: ["'none'"], // 防止自动播放 battery: ["'none'"], // 防止访问电池信息 camera: ["'none'"], // 防止访问相机 displayCapture: ["'none'"], // 防止访问显示捕获 documentDomain: ["'none'"], // 防止访问文档域名 encryptedMedia: ["'none'"], // 防止访问加密媒体 fullscreen: ["'self'"], // 允许从当前域名加载全屏 geolocation: ["'none'"], // 防止访问位置信息 gyroscope: ["'none'"], // 防止访问陀螺仪 magnetometer: ["'none'"], // 防止访问磁力计 microphone: ["'none'"], // 防止访问麦克风 midi: ["'none'"], // 防止访问MIDI payment: ["'none'"], // 防止访问支付 pictureInPicture: ["'none'"], // 防止访问图片在图片中播放 syncXhr: ["'none'"], // 防止访问同步XMLHttpRequest usb: ["'none'"], // 防止访问USB设备 xrSpatialTracking: ["'none'"] // 防止访问XR空间跟踪 } } } ``` # 速率限制 使用 `express-rate-limit` 实现速率限制,防止暴力破解和`DoS`攻击。 ```js // src/config/rateLimiter.js import rateLimit from 'express-rate-limit' import config from './config.js' // 仅对认证路由进行速率限制 export const authRateLimit = rateLimit({ windowMs: config.security.authRateLimitWindowMs, max: config.security.authRateLimitMaxRequests, skipSuccessfulRequests: true // 跳过成功的请求 }) // 其他路由速率限制配置 export const generalRateLimit = rateLimit({ windowMs: config.security.generalRateLimitWindowMs, max: config.security.generalRateLimitMaxRequests }) ``` # `Mongoose` ## 模型定义 使用 `Mongoose Schema` 定义数据模型,支持字段验证、索引、钩子等功能。 ## 模型插件 项目集成了多个 Mongoose 模型插件,用于增强模型功能,提高开发效率。 ## 插件列表 | 插件名称 | 文件位置 | 功能描述 | | ------------ | ---------------------- | -------------------------------- | | `toJSON` | `toJSON.plugin.js` | 自定义 JSON 序列化,移除敏感字段 | | `timestamp` | `timestamp.plugin.js` | 自动添加创建和更新时间戳 | | `logs` | `logs.plugin.js` | 记录数据库操作日志 | | `membership` | `membership.plugin.js` | 自动检查会员过期并更新状态 | | `paginate` | `paginate.plugin.js` | 提供分页查询功能 | ## 插件使用方法 ### 1. 单个模型使用 在模型定义中应用插件: ```js // src/models/user.model.js import mongoose from 'mongoose' import { toJSON, timestamp, logs, membership, paginate } from './plugins/index.js' const userSchema = mongoose.Schema({ // 字段定义... }) // 应用插件 userSchema.plugin(toJSON) userSchema.plugin(timestamp) userSchema.plugin(logs, { // 可选配置 level: 'info', operations: ['create', 'update', 'delete'] }) userSchema.plugin(membership) userSchema.plugin(paginate) const User = mongoose.model('User', userSchema) export default User ``` ### 2. 全局使用 在数据库连接后,为所有模型全局应用插件: ```js // src/database/connection.js import mongoose from 'mongoose' import { toJSON, paginate, timestamp, logs } from '../models/plugins/index.js' // 连接数据库... // 全局应用插件 mongoose.plugin(toJSON) mongoose.plugin(timestamp) mongoose.plugin(logs, { level: 'info' }) ``` ## 插件详细说明 ### 1. toJSON 插件 **功能**:自定义 JSON 序列化,移除敏感字段,美化输出格式。 **主要特性**: - 移除 `__v` 版本字段 - 移除 `password` 等敏感字段 - 将 `_id` 转换为 `id` - 美化 JSON 输出格式 **使用方法**: ```js userSchema.plugin(toJSON) ``` ### 2. timestamp 插件 **功能**:自动为文档添加创建和更新时间戳。 **主要特性**: - 自动添加 `createdAt` 字段(创建时间) - 自动添加 `updatedAt` 字段(更新时间) - 在文档保存时自动更新时间戳 **使用方法**: ```js userSchema.plugin(timestamp) ``` ### 3. logs 插件 **功能**:记录数据库操作日志,提高系统可观测性。 **主要特性**: - 记录创建、更新、删除、查询等操作 - 记录操作前后的数据变化 - 自动脱敏敏感字段 - 可配置日志级别和操作类型 - 集成系统日志系统 **配置选项**: | 选项 | 类型 | 默认值 | 描述 | | ----------------- | ------- | --------------------------------------------------- | ------------------------------------ | | `enabled` | Boolean | `true` | 是否启用日志记录 | | `level` | String | `'info'` | 日志级别(debug, info, warn, error) | | `sensitiveFields` | Array | `['password', 'token', 'secret']` | 需要脱敏的敏感字段 | | `operations` | Array | `['create', 'update', 'delete', 'find', 'findOne']` | 需要记录的操作类型 | **使用方法**: ```js userSchema.plugin(logs, { level: 'debug', operations: ['create', 'update', 'delete'] }) ``` **日志格式**: ```json { "level": "info", "type": "database", "message": "Database create operation", "operation": "create", "model": "User", "data": { "id": "60a7b3c4d5e6f7g8h9i0j1k2", "data": { "username": "admin", "email": "admin@example.com", "password": "******" } }, "timestamp": "2024-01-01T00:00:00.000Z" } ``` ### 4. membership 插件 **功能**:自动检查用户会员是否过期,如果过期则自动更新会员状态。 **主要特性**: - 在查询用户时自动检查会员过期时间 - 会员过期后自动将 `membership` 设为 `normal` - 会员过期后自动将 `membershipExpireAt` 设为 `null` - 支持 `findOne`、`find`、`findOneAndUpdate` 查询操作 - 永久会员和普通用户不会被处理 **触发时机**: | 查询方法 | 触发时机 | | ------------------ | -------------- | | `findOne` | 查询单个用户后 | | `find` | 查询用户列表后 | | `findOneAndUpdate` | 更新单个用户后 | **检查逻辑**: 1. 如果用户是普通用户(`membership === 'normal'`),跳过检查 2. 如果用户是永久会员(`membership === 'permanent'`),跳过检查 3. 如果没有过期时间(`membershipExpireAt` 为 null),跳过检查 4. 如果当前时间超过过期时间,更新会员状态为普通用户 **使用方法**: ```js userSchema.plugin(membership) ``` **注意事项**: - 需要在用户模型中定义 `membership` 和 `membershipExpireAt` 字段 - 插件只会在查询时检查,不会主动定时任务检查 - 适合在用户访问系统时自动处理过期会员 ### 5. paginate 插件 **功能**:为模型提供分页查询功能,支持多字段排序、嵌套关联查询。 **主要特性**: - 提供 `Model.paginate()` 静态方法 - 支持多字段排序(逗号分隔) - 支持嵌套关联查询(populate) - 支持字段选择(select) - 返回完整的分页信息 **返回结果结构**: | 字段 | 类型 | 描述 | | -------------- | ------ | ---------------- | | `results` | Array | 当前页的数据列表 | | `page` | Number | 当前页码 | | `limit` | Number | 每页显示数量 | | `totalPages` | Number | 总页数 | | `totalResults` | Number | 总记录数 | **参数说明**: | 参数 | 类型 | 默认值 | 描述 | | ------------------ | ------------ | ----------- | -------------------------------------------- | | `filter` | Object | `{}` | MongoDB 查询条件 | | `options.sortBy` | String | `createdAt` | 排序字段,格式:`field:desc`,多个用逗号分隔 | | `options.populate` | String/Array | - | 关联查询字段,支持嵌套 | | `options.select` | String | - | 字段选择 | | `options.limit` | Number | `10` | 每页数量 | | `options.page` | Number | `1` | 当前页码 | **使用方法**: ```js userSchema.plugin(paginate) ``` **使用示例**: ```js // 基本分页查询 const result = await User.paginate({ role: 'user' }, { page: 1, limit: 10, sortBy: 'createdAt:desc' }) // 带关联查询的分页 const result = await User.paginate({}, { page: 1, limit: 10, populate: 'posts.comments', sortBy: 'createdAt:desc' }) // 返回结果 { results: [...], page: 1, limit: 10, totalPages: 5, totalResults: 45 } ``` ## 最佳实践 1. **按需使用**:根据模型的具体需求选择合适的插件 2. **合理配置**:根据实际情况配置插件选项,特别是日志插件 3. **性能考虑**:在高流量系统中,可考虑禁用或调整某些插件的配置 4. **统一应用**:对于通用插件(如 toJSON、timestamp),建议全局应用 5. **安全考虑**:确保敏感字段在 toJSON 和 logs 插件中都被正确处理 # 连接管理 ```js // src/index.js import { connectDB, getConnectionStatus, disconnectDB } from './database/connection.js' // 启动数据库连接 await connectDB() // 检查连接状态 const isConnected = getConnectionStatus() // 关闭连接 await disconnectDB() ``` # 数据校验 ## `Mongoose Schema` 校验 在模型层定义字段验证规则,确保数据符合预期格式。 ```js // 邮箱验证 email: { type: String, required: true, trim: true, unique: true, lowercase: true, validate: { validator: function (v) { return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v); }, message: '请输入有效的邮箱地址', }, }, // 密码验证 password: { type: String, required: true, trim: true, minlength: 8, maxlength: 20, validate: { validator: function (v) { return /^(?=.*[A-Za-z])(?=.*\d).{8,20}$/.test(v); }, message: '密码必须包含至少一个字母和一个数字', }, private: true, }, ``` ## 请求参数验证 在API请求层面验证输入参数,确保请求数据符合要求。 ```js // src/validations/user.validation.js import Joi from 'joi' import { password, objectId } from './custom.validation.js' const createUser = { body: Joi.object({ email: Joi.string().required().email(), password: Joi.string().required().custom(password), name: Joi.string().required(), role: Joi.string().required().valid('user', 'admin') }) } // 其他验证规则... export default { createUser // 其他规则... } ``` ## 业务逻辑验证 在服务层实现业务逻辑验证,确保数据操作符合业务规则。 ```js // src/services/user.service.js const createUser = async (userBody) => { if (await User.isEmailTaken(userBody.email)) { throw new ApiError(httpStatus.BAD_REQUEST, '邮箱已注册') } return await User.create(userBody) } ``` # 迁移工具 项目使用 `migrate-mongoose` 实现数据库迁移功能,用于管理数据库结构的变更。 ## 使用方法 **1. 创建新迁移**: ```bash pnpm migrate:create <迁移名称> ``` **2. 应用迁移**: ```bash pnpm migrate:up ``` **3. 回滚迁移**: ```bash pnpm migrate:down ``` **4. 查看迁移状态**: ```bash pnpm migrate:status ``` ## 迁移文件示例 ```js // migrations/20260408120000-add-user-role-field.js module.exports = { up: async (db) => { // 应用变更:添加role字段到users集合 await db.collection('users').updateMany({}, { $set: { role: 'user' } }) }, down: async (db) => { // 回滚变更:从users集合中移除role字段 await db.collection('users').updateMany({}, { $unset: { role: '' } }) } } ``` # 数据填充 项目实现了数据填充功能,用于在新部署时添加管理员用户和在开发环境中提供测试数据。 ## 填充脚本 **文件**:`src/database/seed.js` 1. **管理员用户创建**:在新部署时自动创建管理员用户 2. **测试数据生成**:在开发环境下创建测试用户数据 3. **数据清理**:在开发环境下清理旧的测试数据 ## 使用方法 **1. 基本填充(适用于生产环境)**: ```bash npm run seed ``` **2. 开发环境填充(包含测试数据)**: ```bash npm run seed:dev ``` ## 最佳实践 1. **使用索引**:为频繁查询的字段添加索引,提高查询性能 2. **合理使用聚合**:对于复杂查询,使用 Mongoose 的聚合管道 3. **批量操作**:对于大量数据操作,使用批量方法如 `insertMany`、`updateMany` 4. **事务处理**:对于需要保证一致性的操作,使用 Mongoose 的事务 5. **连接池管理**:合理配置连接池大小,避免连接过多或不足 6. **慢查询监控**:监控并优化慢查询,提高系统性能 7. **数据备份**:定期备份数据库,防止数据丢失 8. **数据迁移**:使用工具如 `mongoose-migrate` 管理数据库迁移 9. **数据验证**:在多个层面实现数据验证,确保数据质量 # 邮件服务 项目实现了完整的邮件服务,使用 `nodemailer` 库发送邮件,并集成了 BullMQ 任务队列来处理邮件发送任务,提高系统的可靠性和性能。 ## 核心功能 1. **基本邮件发送**:支持发送普通邮件 2. **重置密码邮件**:发送包含重置密码链接的邮件 3. **验证邮箱邮件**:发送包含邮箱验证链接的邮件 4. **任务队列集成**:使用 BullMQ 处理邮件发送任务,支持延迟发送和失败重试 ## 技术实现 ### 邮件服务配置 邮件服务的配置在 `src/config/config.js` 文件中,从环境变量中读取 SMTP 配置: ```javascript // src/config/config.js email: { smtp: { host: envVars.SMTP_HOST, port: envVars.SMTP_PORT, auth: { user: envVars.SMTP_USERNAME, pass: envVars.SMTP_PASSWORD, }, }, from: envVars.EMAIL_FROM, }, ``` ### 环境变量配置 需要在 `.env` 文件中配置以下环境变量: ```dotenv # SMTP 配置 SMTP_HOST=smtp.example.com SMTP_PORT=587 SMTP_USERNAME=your-email@example.com SMTP_PASSWORD=your-email-password EMAIL_FROM=your-email@example.com ``` ### 邮件服务 API #### 发送普通邮件 ```javascript import emailService from '../services/email.service.js' await emailService.sendEmail('recipient@example.com', '邮件主题', '邮件内容') ``` #### 发送重置密码邮件 ```javascript import emailService from '../services/email.service.js' await emailService.sendResetPasswordEmail('user@example.com', 'reset-token') ``` #### 发送验证邮箱邮件 ```javascript import emailService from '../services/email.service.js' await emailService.sendVerificationEmail('user@example.com', 'verify-token') ``` ### 最佳实践 1. **使用任务队列**:始终通过任务队列发送邮件,而不是直接调用邮件服务 2. **错误处理**:在调用邮件服务时实现适当的错误处理 3. **模板化**:考虑使用邮件模板系统,提高邮件内容的一致性和可维护性 4. **测试**:在开发环境中使用邮件测试服务,避免发送真实邮件 5. **监控**:监控邮件发送的成功率和失败率,及时发现问题 ### 常见问题 1. **邮件发送失败**: - 检查 SMTP 配置是否正确 - 检查网络连接是否正常 - 检查收件人邮箱地址是否有效 2. **邮件进入垃圾邮件**: - 确保邮件内容符合反垃圾邮件规则 - 使用已验证的发件人邮箱 - 避免使用敏感词汇 3. **任务队列失败**: - 检查 Redis 连接是否正常 - 检查任务队列配置是否正确 - 查看任务队列的错误日志 # 短信服务 # 文件上传 项目使用 Multer + client-s3 实现了文件上传功能,支持单文件和多文件上传,提供了完整的文件管理能力。 ## 核心功能 1. **文件上传**:支持单文件和多文件上传 2. **文件删除**:从S3存储桶中删除文件 3. **文件信息**:获取文件的详细信息 4. **预签名URL**:生成临时访问文件的预签名URL 5. **文件列表**:列出存储中的文件 6. **自动存储桶管理**:自动创建和配置存储桶 ## 端点说明 | 端点 | 方法 | 描述 | 认证 | | ---------------------- | ------ | ------------- | ---- | | `/files/upload` | `POST` | 上传单个文件 | 需要 | | `/files/uploads` | `POST` | 上传多个文件 | 需要 | | `/files/delete` | `POST` | 删除文件 | 需要 | | `/files/info` | `GET` | 获取文件信息 | 需要 | | `/files/presigned-url` | `GET` | 生成预签名URL | 需要 | | `/files/list` | `GET` | 列出文件 | 需要 | ## 使用方法 ### 1. 前端上传示例 **单文件上传**: ```html
``` **多文件上传**: ```html
``` **使用Fetch API**: ```javascript // 单文件上传 const formData = new FormData() formData.append('file', fileInput.files[0]) fetch('/api/v1/files/upload', { method: 'POST', headers: { Authorization: `Bearer ${token}` }, body: formData }) .then((response) => response.json()) .then((data) => console.log(data)) // 多文件上传 const formData = new FormData() Array.from(fileInput.files).forEach((file) => { formData.append('files', file) }) fetch('/api/v1/files/uploads', { method: 'POST', headers: { Authorization: `Bearer ${token}` }, body: formData }) .then((response) => response.json()) .then((data) => console.log(data)) ``` ### 2. 后端使用示例 **在服务层使用**: ```js // src/services/user.service.js import fileService from './file.service.js' const updateProfile = async (userId, updateData, profileImage) => { let profileImageUrl = updateData.profileImage // 如果有上传的头像文件 if (profileImage) { const uploadResult = await fileService.uploadFile(profileImage, 'avatars') profileImageUrl = uploadResult.fileUrl } // 更新用户信息 const user = await User.findByIdAndUpdate( userId, { ...updateData, profileImage: profileImageUrl }, { new: true } ) return user } ``` **在控制器层使用**: ```js // src/controllers/user.controller.js import catchAsync from '../utils/catchAsync.js' import response from '../utils/response.js' import userService from '../services/user.service.js' import fileService from '../services/file.service.js' const updateProfile = catchAsync(async (req, res) => { const user = await userService.updateProfile(req.user.id, req.body, req.file) response.success(res, user, '更新个人资料成功') }) // 路由中使用 router.post('/profile', auth(), fileService.getMulter().single('profileImage'), updateProfile) ``` ## 响应格式 **单文件上传响应**: ```json { "success": true, "message": "文件上传成功", "code": 200, "data": { "success": true, "fileName": "profile-123456.jpg", "objectName": "uploads/profile-123456.jpg", "fileUrl": "http://localhost:9000/nomoex/uploads/profile-123456.jpg", "size": 102400, "mimetype": "image/jpeg" } } ``` **多文件上传响应**: ```json { "success": true, "message": "文件上传成功", "code": 200, "data": [ { "success": true, "fileName": "file1-123456.jpg", "objectName": "uploads/file1-123456.jpg", "fileUrl": "http://localhost:9000/nomoex/uploads/file1-123456.jpg", "size": 102400, "mimetype": "image/jpeg" }, { "success": true, "fileName": "file2-789012.pdf", "objectName": "uploads/file2-789012.pdf", "fileUrl": "http://localhost:9000/nomoex/uploads/file2-789012.pdf", "size": 204800, "mimetype": "application/pdf" } ] } ``` ## 错误处理 ### 常见错误及解决方案 1. **文件上传失败**: - 检查 MinIO 服务是否正常运行 - 检查环境变量配置是否正确 - 检查存储桶是否存在且权限正确 - 检查文件大小是否超过限制(默认10MB) 2. **文件类型错误**: - 确保上传的文件类型在允许列表中(JPEG、PNG、GIF、WEBP) - 检查文件扩展名是否与实际文件类型匹配 3. **预签名URL生成失败**: - 检查文件是否存在 - 检查 S3 客户端配置是否正确 ## 最佳实践 1. **文件命名**: - 使用唯一的文件名,避免文件名冲突 - 按照业务逻辑组织文件目录结构 2. **文件类型验证**: - 在前端和后端都实现文件类型验证 - 限制允许上传的文件类型,提高安全性 3. **文件大小限制**: - 根据业务需求设置合理的文件大小限制 - 对于大文件,考虑使用分片上传 4. **文件访问控制**: - 对于敏感文件,使用预签名URL控制访问 - 定期清理不需要的文件,节省存储空间 5. **错误处理**: - 实现完善的错误处理,提供友好的错误提示 - 记录文件操作日志,便于排查问题 ## 安全考虑 1. **文件类型验证**: - 不要仅依赖文件扩展名,检查文件的 MIME 类型 - 实现文件内容验证,防止恶意文件上传 2. **文件大小限制**: - 设置合理的文件大小限制,防止 DoS 攻击 - 监控异常的文件上传行为 3. **存储安全**: - 使用强密码保护 MinIO 服务 - 定期备份存储的文件 - 考虑使用加密存储敏感文件 4. **访问控制**: - 实现基于角色的文件访问控制 - 对于敏感文件,使用预签名URL并设置合理的过期时间 ## 性能优化 1. **文件上传优化**: - 对于大文件,考虑使用分片上传 - 实现文件上传进度显示,提高用户体验 2. **存储优化**: - 实现文件压缩,减少存储空间 - 定期清理过期文件,释放存储空间 3. **缓存策略**: - 对于频繁访问的文件,实现缓存策略 - 使用 CDN 加速文件分发 4. **并发处理**: - 实现并发文件上传,提高上传速度 - 合理设置连接池大小,避免资源耗尽 # 缓存 项目集成了 `Redis` 缓存功能,用于提高系统性能和减轻数据库压力。 ## 核心功能 1. **连接管理**:自动处理 `Redis` 连接的建立和断开 2. **基本操作**:设置、获取、删除缓存 3. **批量操作**:批量删除缓存 4. **过期管理**:设置缓存过期时间,获取剩余过期时间 5. **状态检查**:检查缓存键是否存在 6. **缓存清空**:清除所有缓存 ## 使用方法 ### 1. 基本缓存操作 ```js import cacheService from '../services/cache.service.js' // 设置缓存 await cacheService.set('user:123', { id: 123, name: 'John Doe' }, 3600) // 获取缓存 const user = await cacheService.get('user:123') // 删除缓存 await cacheService.delete('user:123') // 检查缓存是否存在 const exists = await cacheService.exists('user:123') // 设置缓存过期时间 await cacheService.expire('user:123', 1800) // 获取缓存剩余过期时间 const ttl = await cacheService.ttl('user:123') ``` ### 2. 批量操作 ```js // 批量删除缓存 await cacheService.deleteMany(['user:123', 'user:456', 'user:789']) // 清除所有缓存 await cacheService.clear() ``` ### 3. 在服务层使用 ```js // src/services/user.service.js import User from '../models/user.model.js' import cacheService from './cache.service.js' const getUserById = async (userId) => { // 尝试从缓存获取 const cacheKey = `user:${userId}` const cachedUser = await cacheService.get(cacheKey) if (cachedUser) { return cachedUser } // 从数据库获取 const user = await User.findById(userId) // 设置缓存 if (user) { await cacheService.set(cacheKey, user, 3600) } return user } const updateUser = async (userId, updateData) => { const user = await User.findByIdAndUpdate(userId, updateData, { new: true }) // 更新缓存 if (user) { const cacheKey = `user:${userId}` await cacheService.set(cacheKey, user, 3600) } return user } const deleteUser = async (userId) => { const user = await User.findByIdAndDelete(userId) // 删除缓存 const cacheKey = `user:${userId}` await cacheService.delete(cacheKey) return user } ``` ### 4. 在控制器层使用 ```js // src/controllers/user.controller.js import catchAsync from '../utils/catchAsync.js' import response from '../utils/response.js' import userService from '../services/user.service.js' const getUser = catchAsync(async (req, res) => { const user = await userService.getUserById(req.params.id) response.success(res, user, '获取用户成功') }) ``` ## 缓存键命名规范 为了避免缓存键冲突,建议使用以下命名规范: ``` [资源类型]:[ID]:[操作] ``` **示例**: - `user:123` - 用户ID为123的用户信息 - `product:456:details` - 产品ID为456的详细信息 - `users:current:1:size:10` - 用户列表第1页,每页10条 ## 最佳实践 1. **合理设置过期时间**:根据数据的更新频率设置合适的过期时间 2. **缓存键命名规范**:使用统一的命名规范,避免键冲突 3. **缓存失效处理**:在数据更新时及时更新或删除相关缓存 4. **缓存穿透防护**:对不存在的数据也进行缓存,设置较短的过期时间 5. **缓存雪崩预防**:为缓存设置随机的过期时间,避免同时过期 6. **监控缓存使用**:定期监控缓存的使用情况,及时发现问题 7. **降级策略**:当缓存服务不可用时,能够优雅降级到直接从数据库获取数据 8. **避免缓存数据过大**:只缓存必要的数据,避免缓存过大的数据 # 任务队列 项目使用 BullMQ 实现了任务队列功能,用于处理异步任务,提高系统的可靠性和扩展性。 ## 核心功能 1. **队列管理**:创建、关闭、清空队列 2. **任务管理**:添加、取消、监控任务 3. **延迟任务**:支持添加延迟执行的任务 4. **重复任务**:支持添加定期重复执行的任务 5. **任务状态**:获取队列和任务的状态 6. **事件监听**:监听任务的各种事件 ## 任务处理器 **文件**:`src/jobs/` 目录 - **email.job.js**:邮件发送任务处理器 - `sendEmailJob`:发送普通邮件 - `sendResetPasswordEmailJob`:发送重置密码邮件 - `sendVerifyEmailJob`:发送验证邮箱邮件 ## 使用方法 ### 1. 初始化队列 ```js import queueService from '../services/queue.service.js' import { sendEmailJob } from '../jobs/email.job.js' // 初始化邮件队列 queueService.initQueue('email', sendEmailJob) ``` ### 2. 添加任务 ```js // 添加普通任务 await queueService.addJob('email', { to: 'user@example.com', subject: '测试邮件', html: '

这是一封测试邮件

' }) // 添加延迟任务(5分钟后执行) await queueService.addDelayedJob( 'email', { to: 'user@example.com', subject: '延迟测试邮件', html: '

这是一封延迟测试邮件

' }, 5 * 60 * 1000 ) // 添加重复任务(每天执行一次) await queueService.addRepeatableJob( 'email', { to: 'admin@example.com', subject: '每日报告', html: '

这是每日报告

' }, { cron: '0 0 * * *' // 每天凌晨执行 } ) ``` ### 3. 取消任务 ```js // 取消任务 await queueService.cancelJob('email', jobId) ``` ### 4. 清空队列 ```js // 清空队列 await queueService.clearQueue('email') ``` ### 5. 获取队列状态 ```js // 获取队列状态 const status = await queueService.getQueueStatus('email') console.log('队列状态:', status) ``` ### 6. 在服务层使用 ```js // src/services/email.service.js import queueService from './queue.service.js' const sendEmail = async (to, subject, html) => { try { // 添加到队列 const job = await queueService.addJob('email', { to, subject, html }) return { success: true, jobId: job.id } } catch (error) { // 队列失败时,直接发送邮件 // ... 直接发送邮件的逻辑 return { success: true } } } const sendResetPasswordEmail = async (email, token) => { await queueService.addJob('reset-password-email', { email, token }) } const sendVerificationEmail = async (email, token) => { await queueService.addJob('verify-email', { email, token }) } ``` ### 队列最佳实践 1. **队列命名规范**:使用清晰、描述性的队列名称 2. **任务数据最小化**:只传递必要的数据,避免传递大型对象 3. **错误处理**:实现完善的错误处理和重试机制 4. **任务超时**:设置合理的任务超时时间 5. **监控**:监控队列状态和任务执行情况 6. **死信队列**:处理失败的任务 7. **优先级**:为重要任务设置更高的优先级 8. **资源管理**:合理配置工作器数量和并发度 # 第三方登录 # 请求日志 项目集成了完整的请求日志功能,使用 morgan 中间件记录 HTTP 请求的详细信息,并与请求ID关联,便于跟踪和分析请求的整个生命周期。 ## 核心功能 1. **完整请求信息**:记录请求方法、路径、状态码、响应时间等信息 2. **请求体记录**:记录请求体内容(经过脱敏处理) 3. **用户代理记录**:记录客户端用户代理 4. **请求ID关联**:与请求ID关联,便于跟踪请求生命周期 5. **统一日志格式**:确保所有请求日志的格式一致,便于分析和处理 6. **结构化存储**:使用 JSON 格式存储,便于后续分析和处理 ## 技术实现 ### 依赖 - `morgan`:HTTP 请求日志中间件 - `express-request-id`:请求ID生成中间件 ### 集成到应用 在 `src/app.js` 文件中集成请求日志中间件: ```javascript // src/app.js import express from 'express' import requestId from 'express-request-id' import loggerMiddleware from './middlewares/logger.js' const app = express() // 请求ID中间件 app.use(requestId()) // 其他中间件... // 日志中间件 app.use(loggerMiddleware.httpLogger) // 路由... ``` ## 日志格式 ### JSON 格式(文件) ```json { "level": "info", "type": "http", "message": "HTTP request", "requestId": "123e4567-e89b-12d3-a456-426614174000", "ip": "127.0.0.1", "method": "GET", "url": "/api/v1/users/123", "status": 200, "userAgent": "Mozilla/5.0...", "body": {}, "responseTime": "1.234", "timestamp": "2024-01-01T00:00:00.000Z" } ``` ### 控制台格式(开发环境) ``` [2024-01-01 00:00:00.000] [info] [123e4567-e89b-12d3-a456-426614174000] [http] HTTP request {"ip":"127.0.0.1","method":"GET","url":"/api/v1/users/123","status":200,"userAgent":"Mozilla/5.0...","body":{},"responseTime":"1.234"} ``` ## 最佳实践 1. **启用请求日志**:确保在应用中启用请求日志中间件 2. **查看请求日志**:定期查看请求日志,了解系统的访问情况 3. **分析响应时间**:通过响应时间分析系统性能 4. **监控状态码**:关注异常状态码,及时发现问题 5. **使用请求ID**:通过请求ID关联相关日志,便于问题排查 6. **日志脱敏**:确保敏感信息被正确脱敏,保护用户隐私 7. **性能考虑**:在高流量系统中,可考虑调整日志级别,减少日志量 # 请求ID 项目使用 `express-request-id` 中间件为每个请求生成一个唯一的ID,用于日志记录和调试。 ## 实现 ```js // src/app.js import requestId from 'express-request-id' // 请求ID app.use(requestId()) ``` ## 使用 ### 1. 在控制器中使用 ```javascript // src/controllers/user.controller.js import catchAsync from '../utils/catchAsync.js' import response from '../utils/response.js' import logger from '../config/logger.js' const getUser = catchAsync(async (req, res) => { const log = logger.getLogger(req.id) log.info('获取用户信息', { userId: req.params.id }) // 业务逻辑... response.success(res, user, '获取用户信息成功') }) ``` ### 2. 在服务层使用 ```javascript // src/services/user.service.js import logger from '../config/logger.js' const getUserById = async (userId, requestId) => { const log = logger.getLogger(requestId) log.debug('服务层获取用户', { userId }) // 业务逻辑... log.debug('用户获取成功', { user }) return user } // 在控制器中调用 const getUser = catchAsync(async (req, res) => { const user = await userService.getUserById(req.params.id, req.id) // ... }) ``` ### 3. 在错误处理中使用 ```javascript // src/middlewares/error.js import logger from '../config/logger.js' const errorHandler = (err, req, res, next) => { const log = logger.getLogger(req.id) log.error('请求处理失败', { error: err.message, stack: err.stack }) // 错误处理逻辑... res.status(statusCode).json({ success: false, message: err.message, requestId: req.id }) } ``` ## 最佳实践 1. **始终使用请求ID**:在所有日志和错误处理中使用请求ID 2. **传递请求ID**:在服务层和其他模块中传递请求ID 3. **客户端使用**:客户端可以记录响应头中的请求ID,便于问题排查 4. **统一格式**:使用统一的日志格式,包含请求ID 5. **监控集成**:在监控系统中使用请求ID关联相关指标 ## 示例日志 ``` [2024-01-01 00:00:00.000] [info] [123e4567-e89b-12d3-a456-426614174000] [http] HTTP request {"ip":"127.0.0.1","method":"GET","url":"/api/v1/users/123","status":200,"userAgent":"Mozilla/5.0...","body":{},"responseTime":"1.234"} [2024-01-01 00:00:00.001] [info] [123e4567-e89b-12d3-a456-426614174000] [application] 获取用户信息 {"userId":"123"} [2024-01-01 00:00:00.002] [debug] [123e4567-e89b-12d3-a456-426614174000] [application] 服务层获取用户 {"userId":"123"} [2024-01-01 00:00:00.003] [debug] [123e4567-e89b-12d3-a456-426614174000] [application] 用户获取成功 {"user":{"id":"123","name":"John Doe"}} [2024-01-01 00:00:00.004] [info] [123e4567-e89b-12d3-a456-426614174000] [application] 用户信息获取成功 {"user":{"id":"123","name":"John Doe"}} ``` # 压缩 项目使用 `compression` 中间件实现响应内容的 Gzip 压缩,减少传输数据大小,提高API响应速度。 ```js // src/app.js import compression from 'compression' // 响应内容压缩 app.use(compression()) ``` # 静态文件服务 # 健康检查 项目实现了完善的健康检查功能,提供 `/health` 端点用于监控系统状态。 ## 功能特点 1. **多层探针**:提供存活探针、就绪探针和详细健康检查 2. **服务状态检查**:检查MongoDB、Redis、BullMQ、MinIO、PM2等服务状态 3. **系统资源监控**:检查系统运行时间、内存使用、CPU使用、磁盘使用等 4. **网络状态检查**:检查网络接口和连接状态 5. **容器/负载均衡检查**:检查容器信息、端口、进程ID等 6. **完整的健康状态报告**:返回详细的健康状态信息 7. **HTTP状态码反馈**:根据健康状态返回不同的HTTP状态码 ## 访问方式 **本地开发环境**: ```bash # 存活探针 - 最轻量级检查,只检查进程是否活着 GET http://localhost:3000/health/liveness # 就绪探针 - 检查服务是否可以接收流量 GET http://localhost:3000/health/readiness # 详细健康检查 - 包括所有健康检查,用于人工排查 GET http://localhost:3000/health/detailed ``` **生产环境**: ```bash GET https://api.nomoex.com/health/liveness GET https://api.nomoex.com/health/readiness GET https://api.nomoex.com/health/detailed ``` ## 响应格式 ### 存活响应 ```json { "success": true, "code": 200, "message": "服务运行正常", "data": { "status": "ok", "pid": 12345 }, "timestamp": "2026-04-11 21:30:00.000" } ``` ### 就绪响应 ```json { "success": true, "code": 200, "message": "服务就绪", "data": { "status": "ready", "services": { "database": "available", "redis": "connected", "queue": "connected", "minio": "connected", "pm2": "connected" }, "resources": { "disk": { "usage": "30%" }, "memory": { "usage": "45%" } } }, "timestamp": "2026-04-11 21:30:00.000" } ``` ### 详细健康检查响应 ```json { "success": true, "code": 200, "message": "健康检查详情", "data": { "status": "healthy", "basic": { "appName": "NomoEx API Starter", "appVersion": "1.0.0", "env": "development", "nodeVersion": "v24.14.0", "uptime": 34.1351072, "pid": 9976, "hostname": "DESKTOP-TDBO9OT", "port": 3000 }, "services": { "database": { "status": "connected" }, "redis": { "status": "connected", "details": {} }, "queue": { "status": "connected", "details": {} }, "minio": { "status": "connected", "details": {} }, "pm2": { "status": "connected", "details": {} } }, "resources": { "cpu": { "user": 843000, "system": 437000 }, "memory": { "rss": 120885248, "heapTotal": 56229888, "heapUsed": 53351192, "external": 22313658, "arrayBuffers": 18350271 }, "disk": { "total": 107374182400, "used": 32212254720, "free": 75161927680, "percent": 30 }, "load": [0, 0, 0] }, "container": { "hostname": "DESKTOP-TDBO9OT", "port": 3000, "pid": 9976, "clusterId": "single", "nodeId": "1" } }, "timestamp": "2026-04-11 21:30:00.000" } ``` # 分页功能 项目实现了强大的分页功能,在服务层使用手动分页方式,支持多种高级特性。 ## 核心功能 1. **基本分页**:支持页码和每页数量的控制 2. **多字段排序**:支持多个字段的排序,如 `createdAt:desc,name:asc` 3. **嵌套关联查询**:支持深度嵌套的关联查询,如 `user.profile.address` 4. **字段选择**:支持只返回指定的字段 5. **完整的分页信息**:返回当前页、每页数量、总页数、总结果数 ## 分页实现 项目使用手动分页方式,在服务层实现分页逻辑 **1. 基本分页查询**: ```js const { records, total, pages, current, size } = await userService.queryUsers( { role: 'user' }, // 查询条件 { current: 1, // 当前页码 size: 10, // 每页数量 sortBy: 'createdAt:desc' // 排序 } ) ``` **2. 带关联查询**: ```js const { records } = await userService.queryUsers( { role: 'user' }, { current: 1, size: 10, populate: 'profile' // 关联查询 } ) ``` **3. 带字段选择**: ```js const { records } = await userService.queryUsers( { role: 'user' }, { current: 1, size: 10, select: 'name email role' // 只返回指定字段 } ) ``` ## 分页响应 项目的 `response.js` 工具中提供了 `pagination` 函数,用于格式化分页响应: ```js // src/utils/response.js export const pagination = (res, code, message, records, total, pages, current, size) => { const response = { success: true, message, code, data: { records, total, pages, current, size } } if (config.env === 'development') { response.timestamp = getCurrentTimestamp() } return res.status(code).json(response) } ``` **响应格式**: ```json { "success": true, "message": "查询成功", "code": 200, "data": { "records": [ /* 数据列表 */ ], "total": 100, "pages": 10, "current": 1, "size": 10 }, "timestamp": "2026-04-09 10:00:00.000" /* 仅开发环境 */ } ``` ### 完整使用示例 **1. 控制器层**: ```js // src/controllers/user.controller.js import catchAsync from '../utils/catchAsync.js' import response from '../utils/response.js' import userService from '../services/user.service.js' const getUsers = catchAsync(async (req, res) => { // 从查询参数中获取过滤条件和分页选项 const filter = pick(req.query, ['name', 'role']) const options = pick(req.query, ['sortBy', 'size', 'current']) // 调用服务层进行分页查询 const result = await userService.queryUsers(filter, options) // 使用分页响应格式化器 response.pagination(res, httpStatus.OK, '查询成功', result.records, result.total, result.pages, result.current, result.size) }) ``` **2. 服务层**: ```js // src/services/user.service.js import User from '../models/user.model.js' const queryUsers = async (filter, options) => { let sort = options.sortBy ? options.sortBy : 'createdAt' const size = options.size || 10 const current = options.current || 1 const skip = (current - 1) * size const total = await User.countDocuments(filter) const records = await User.find(filter).sort(sort).skip(skip).limit(size) return { records, total, pages: Math.ceil(total / size), current, size } } export default { queryUsers // 其他方法... } ``` ### 分页参数 | 参数 | 类型 | 默认值 | 描述 | | ---------- | ------------ | ----------- | ---------------------------------------------------------------- | | `current` | number | 1 | 当前页码 | | `size` | number | 10 | 每页显示的结果数量 | | `sortBy` | string | "createdAt" | 排序字段,格式为 `field:asc` 或 `field:desc`,多个字段用逗号分隔 | | `populate` | string/array | - | 关联查询字段,多个字段用逗号分隔 | | `select` | string | - | 要返回的字段,多个字段用空格分隔 | ## 条件查询 项目实现了强大的条件查询功能,支持各种复杂的查询需求,包括字段过滤、范围查询、排序、关联查询等。 ## 实现原理 条件查询功能基于 `Mongoose` 的查询能力实现,通过以下步骤处理查询请求: 1. **参数提取**:从请求查询参数中提取过滤条件和查询选项 2. **条件构建**:根据提取的参数构建 MongoDB 查询条件 3. **查询执行**:执行查询并应用分页、排序、关联查询等操作 4. **结果格式化**:将查询结果格式化为标准响应格式 ## 查询参数 | 参数 | 类型 | 描述 | 示例 | | ---------- | ------------ | -------- | -------------------------------------------- | | `filter` | Object | 查询条件 | `{ role: 'user', name: { $regex: 'john' } }` | | `sortBy` | String | 排序字段 | `createdAt:desc,name:asc` | | `size` | Number | 每页数量 | `10` | | `current` | Number | 当前页码 | `1` | | `select` | String | 字段选择 | `name email role` | | `populate` | String/Array | 关联查询 | `profile` 或 `profile,posts` | ## 过滤条件 项目支持 MongoDB 的各种查询操作符,常用的过滤条件包括: ### 1. 基本等于查询 ```js // 精确匹配 { role: 'user' } // 匹配多个值 { role: { $in: ['user', 'admin'] } } ``` ### 2. 范围查询 ```js // 大于 { age: { $gt: 18 } } // 大于等于 { age: { $gte: 18 } } // 小于 { age: { $lt: 65 } } // 小于等于 { age: { $lte: 65 } } // 范围查询 { age: { $gte: 18, $lte: 65 } } ``` ### 3. 文本搜索 ```js // 模糊匹配 { name: { $regex: 'john', $options: 'i' } } // 包含 { tags: { $in: ['javascript', 'nodejs'] } } ``` ### 4. 逻辑操作 ```js // 与操作 { $and: [{ role: 'user' }, { age: { $gte: 18 } }] } // 或操作 { $or: [{ role: 'user' }, { role: 'admin' }] } // 非操作 { role: { $ne: 'admin' } } ``` ## 使用示例 ### 1. 服务层实现 ```js // src/services/user.service.js import User from '../models/user.model.js' const queryUsers = async (filter, options) => { return await User.paginate(filter, options) } export default { queryUsers // 其他方法... } ``` ### 2. 控制器层实现 ```js // src/controllers/user.controller.js import catchAsync from '../utils/catchAsync.js' import response from '../utils/response.js' import userService from '../services/user.service.js' import pick from '../utils/pick.js' const getUsers = catchAsync(async (req, res) => { // 从查询参数中提取过滤条件和选项 const filter = pick(req.query, ['name', 'role', 'email']) const options = pick(req.query, ['sortBy', 'limit', 'page', 'select', 'populate']) // 处理模糊搜索 if (filter.name) { filter.name = { $regex: filter.name, $options: 'i' } } if (filter.email) { filter.email = { $regex: filter.email, $options: 'i' } } // 执行查询 const result = await userService.queryUsers(filter, options) // 格式化响应 response.pagination(res, httpStatus.OK, '查询成功', result.results, result.totalResults, result.totalPages, result.page, result.limit) }) ``` ### 3. 前端请求示例 **基本查询**: ``` GET /users?role=user&page=1&limit=10&sortBy=createdAt:desc ``` **带搜索**: ``` GET /users?name=john&page=1&limit=10 ``` **带字段选择**: ``` GET /users?role=user&select=name email&page=1&limit=10 ``` **带关联查询**: ``` GET /users?role=user&populate=profile&page=1&limit=10 ``` ## 响应格式 ```json { "success": true, "message": "查询成功", "code": 200, "data": { "results": [ { "id": "60d0fe4f5311236168a109ca", "name": "John Doe", "email": "john@example.com", "role": "user" } ], "page": 1, "limit": 10, "total": 42 }, "timestamp": "2026-04-09 10:00:00.000" } ``` # 进程管理 项目使用 `PM2` 作为进程管理工具,用于管理应用的运行状态、监控和自动重启。项目使用 `pm2.config.cjs` 文件来配置 PM2。 ## 常用 PM2 命令 ```bash # 启动应用 pm start # 启动应用并指定配置文件 pm run pm2:start # 查看所有进程 pm run pm2:status # 查看应用日志(实时) pm run pm2:logs # 查看应用日志(指定行数) pm run pm2:logs -- --lines 100 # 重启应用 npm run pm2:restart # 停止应用 npm run pm2:stop # 停止所有应用 pm run pm2:stop -- all # 查看应用详情 pm run pm2:status -- nomoex-api-starter # 监控应用资源使用情况 pm run pm2:status -- monit # 重载应用(零停机) pm run pm2:restart -- --reload # 启动应用并在开机时自动启动 pm run pm2:start -- --watch --name "nomoex-api-starter" # 保存当前进程列表,开机自动启动 pm run pm2:start -- --save ``` ## 日志轮转 项目使用 `pm2-logrotate` 插件来轮转日志文件。 ### 安装插件 ```bash npm run pm2:start -- pm2 install pm2-logrotate ``` ### 配置日志轮转参数 ```bash # 设置日志文件最大大小为 50M npm run pm2:start -- pm2 set pm2-logrotate:max_size 50M # 设置每天凌晨进行轮转 npm run pm2:start -- pm2 set pm2-logrotate:rotateInterval "0 0 * * *" # 启用日志压缩 npm run pm2:start -- pm2 set pm2-logrotate:compress true # 保留 14 天的日志文件 npm run pm2:start -- pm2 set pm2-logrotate:retain 14 # 设置日志文件日期格式 npm run pm2:start -- pm2 set pm2-logrotate:dateFormat YYYY-MM-DD ``` ### 重启 pm2-logrotate 模块 ```bash # 重启 pm2-logrotate 模块 npm run pm2:start -- pm2 restart pm2-logrotate ``` ### 查看配置 ```bash # 查看当前配置 npm run pm2:start -- pm2 logrotate -s ``` ### 手动触发日志轮转 ```bash # 手动触发所有应用的日志轮转 npm run pm2:start -- pm2 trigger pm2-logrotate ``` ## 最佳实践 1. **使用集群模式**:在生产环境中使用 `cluster` 模式,充分利用多核CPU 2. **合理配置内存限制**:设置 `max_memory_restart` 防止内存泄漏导致应用崩溃 3. **启用健康检查**:配置健康检查,及时发现应用异常 4. **合理配置日志**:配置日志文件,方便排查问题 5. **使用环境变量**:通过 `env` 和 `env_*` 配置不同环境的变量 6. **定期备份**:定期备份应用数据和配置 7. **监控应用状态**:定期查看应用状态和日志,及时发现问题 # 代码规范 项目使用 `Oxlint` 和 `Oxfmt` 来确保代码质量和一致性,并通过 `husky` 和 `lint-staged` 在预提交时自动执行代码检查和格式化。 - **oxlint**:高性能的 JavaScript/TypeScript 代码检查工具(基于 Rust) - **oxfmt**:高性能的代码格式化工具(基于 Rust) - **husky**:Git 钩子工具,用于在提交时自动运行脚本 - **lint-staged**:仅对暂存文件运行代码检查和格式化 ### 配置文件 - **`.oxlintrc.json`**:oxlint 配置文件 - **`.oxfmtrc.json`**:oxfmt 配置文件 - **`.husky/pre-commit`**:预提交钩子脚本 ### 使用命令 ```bash # 运行 oxlint 检查 npm lint # 自动修复 oxlint 错误 npm lint:fix # 运行 oxfmt 格式化 npm format # 检查代码是否符合 oxfmt 格式 npm format:check ``` ### 预提交钩子 项目配置了 husky 和 lint-staged,在每次 `git commit` 时会自动执行以下操作: 1. 对暂存的文件运行 `pnpm run lint`(代码检查) 2. 对暂存的文件运行 `pnpm run format`(代码格式化) 这样可以确保提交到版本库的代码都符合代码质量标准和格式规范。 ### 代码风格指南 1. **缩进**:使用 4 个空格 2. **引号**:使用单引号 3. **分号**:使用分号 4. **行宽**:最大 150 字符 5. **尾随逗号**:使用 ES5 风格的尾随逗号 6. **箭头函数**:参数使用括号 7. **空格**:在括号和花括号之间使用空格 ### 最佳实践 1. **使用 const**:优先使用 `const` 声明变量 2. **避免未使用的变量**:及时清理未使用的变量 3. **处理回调错误**:正确处理回调函数中的错误 4. **使用箭头函数**:优先使用箭头函数 5. **避免调试代码**:生产环境中避免使用 `debugger` 6. **避免尾随空格**:保持代码整洁 # 优雅关闭 项目实现了完整的优雅关闭功能,确保在服务停止时能够正确处理各种资源的释放,避免数据丢失和资源泄漏。 优雅关闭是指在服务停止时,按照一定的顺序关闭各种资源,确保所有正在处理的请求能够完成,所有连接能够正确关闭,所有数据能够持久化。 ## 实现原理 项目通过以下步骤实现优雅关闭: 1. **监听信号**:监听 `SIGTERM` 和 `SIGINT` 信号 2. **关闭顺序**: - 断开数据库连接 - 断开缓存连接 - 关闭所有任务队列 - 关闭服务器 3. **错误处理**:对关闭过程中的错误进行适当的处理 ## 核心实现 ```javascript // src/index.js process.on('SIGTERM', async () => { console.info('❗️ 正在关闭服务器...') try { // 断开数据库连接 await disconnectDB() // 断开缓存连接 await cacheService.disconnect() // 断开任务队列连接 await queueService.closeAllQueues() } catch (error) { console.error('❌️ 断开连接时失败:', error) } server.close(() => { console.info('✅️ 服务器已关闭!') process.exit(0) }) }) ``` ## 触发方式 ### 在开发环境中 ```bash # 按 Ctrl+C 发送 SIGINT 信号 ^C ``` ### 在生产环境中 ```bash # 使用 PM2 停止服务(会发送 SIGTERM 信号) npm run pm2:stop # 或者使用 kill 命令发送 SIGTERM 信号 kill -15 ``` ## 最佳实践 1. **合理设置超时**:在生产环境中,可以设置适当的超时时间,确保服务能够在合理的时间内完成关闭 2. **监控关闭过程**:监控服务的关闭过程,确保所有资源都能正确释放 3. **日志记录**:记录关闭过程中的日志,便于排查问题 4. **测试**:定期测试优雅关闭功能,确保在紧急情况下能够正常工作 ## 注意事项 1. **数据库事务**:确保在关闭前完成所有数据库事务 2. **任务队列**:确保在关闭前处理完所有重要的任务 3. **缓存**:考虑是否需要在关闭前将缓存数据持久化 4. **连接池**:确保所有连接池都能正确关闭,避免资源泄漏 # Git Commit | 类型 | 简介 | 示例 | | -------- | -------- | ------------------------------- | | feat | 新增功能 | feat: 新增用户登录功能 | | fix | 修复问题 | fix: 修复用户登录中的问题 | | docs | 文档更新 | docs: 更新用户路由文档 | | style | 样式更新 | style: 更新样式 | | refactor | 代码重构 | refactor: 重构用户路由代码 | | perf | 性能优化 | perf: 优化用户保存到数据库性能 | | test | 测试 | test: 新增用户测试 | | build | 构建 | build: 编译打包 | | ci | 持续集成 | ci: 更新CI配置文件 | | chore | 其他 | chore: 构建过程或辅助工具的变动 | | revert | 回滚 | revert: 回滚到以前的提交 | | conflict | 解决冲突 | conflict: 解决合并冲突 | # WebSocket # 审计日志 # 数据导出 # 注意事项 ## 获取真实客户端IP地址 项目已集成 `request-ip` 中间件,自动从代理头解析真实客户端 IP。获取顺序如下: ``` req.clientIp → req.ip → req.connection.remoteAddress → 兜底 ``` ### 场景一:Node.js 直连(无反向代理) ``` 客户端 → Node.js (端口 3001) ``` 无需额外配置,`req.ip` 直接返回真实客户端 IP。 ### 场景二:Nginx 反向代理(生产环境推荐) ``` 客户端 → Nginx (80/443) → Node.js (3001) ``` **必须**在 Nginx 配置中添加以下两行,否则 Node.js 只能获取到 Nginx 自身 IP(如 `127.0.0.1`): ```nginx server { listen 80; server_name your-domain.com; # 前端静态文件 location / { root /path/to/frontend/dist; try_files $uri $uri/ /index.html; } # API 反向代理 location /api/ { proxy_pass http://127.0.0.1:3001; # 关键:转发真实客户端 IP proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Real-IP $remote_addr; # 超时设置 proxy_connect_timeout 60s; proxy_send_timeout 60s; proxy_read_timeout 60s; } } ``` ### 场景三:多级代理(CDN + Nginx) 如果使用了 CDN 或负载均衡,`X-Forwarded-For` 会包含多个 IP,格式为 `client, proxy1, proxy2`。 `request-ip` 中间件默认取第一个 IP(真实客户端),无需额外处理。如需自定义策略,可在 `app.js` 中配置: ```javascript app.use( requestIp.mw({ attributeName: 'clientIp', trustProxy: true }) ) ``` ### 验证码与 IP 绑定说明 当前验证码系统将令牌与客户端 IP 绑定(防止令牌被盗用)。因此 **验证码验证** 和 **登录/注册** 两个请求必须来自同一个 IP。 如果部署在反向代理后面,必须按上方配置正确转发 `X-Forwarded-For` 头,否则会出现 "验证码令牌IP不匹配" 错误。 ### 本地开发调试 在本地开发环境(如 `localhost:3001`),`req.ip` 和 `req.clientIp` 均返回 `::1` 或 `127.0.0.1`,不会影响功能。如需验证多 IP 场景,可使用 `--host 0.0.0.0` 启动服务后通过局域网 IP 访问。 ## S3 文件服务外部访问配置 项目集成了 S3 对象存储(默认使用 RustFS/MinIO 兼容协议),文件上传到 S3 后,需要通过公网可访问的 URL 提供给前端。由于 S3 内部使用 `localhost` 地址,返回的文件 URL 必须使用公网域名,否则浏览器会访问到用户本地。 ### 架构说明 ``` 服务器部署结构: ├── Nginx (80/443) → 公网入口 │ ├── /api/* → Node.js 后端 (8000) │ ├── /s3/* 或 file.* → S3 文件服务 (9000) │ └── /* → 前端静态文件 ├── Node.js (8000) → 后端 API └── S3/RustFS (9000) → 对象存储(仅内部访问) ``` ### 方式一:路径代理 `/s3/` 通过主域名的路径前缀 `/s3/` 反向代理到 S3 服务,文件 URL 为 `https://your-domain.com/s3/nomoex/avatars/xxx.webp`。 #### Nginx 配置 在现有 Nginx 配置的 `server` 块中添加: ```nginx server { listen 443 ssl; server_name your-domain.com; ssl_certificate /path/to/your-domain.com.pem; ssl_certificate_key /path/to/your-domain.com.key; # 前端静态文件 location / { root /path/to/frontend/dist; try_files $uri $uri/ /index.html; } # API 反向代理 location /api/ { proxy_pass http://127.0.0.1:8000; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Real-IP $remote_addr; } # S3 文件服务反向代理(关键) location /s3/ { proxy_pass http://127.0.0.1:9000/; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; # 大文件支持 client_max_body_size 100m; proxy_connect_timeout 60s; proxy_send_timeout 60s; proxy_read_timeout 60s; } } ``` **注意**:`proxy_pass` 末尾的 `/` 很重要,它会将 `/s3/nomoex/xxx` 转发为 `/nomoex/xxx`(去掉 `/s3` 前缀)。 #### 环境变量配置 在 `.env` 或服务器环境变量中添加: ```bash # S3 内部连接地址(后端 → S3,保持 localhost) S3_ENDPOINT=localhost S3_PORT=9000 # S3 外部访问地址(浏览器 → S3,使用公网域名) S3_PUBLIC_URL=https://your-domain.com/s3 ``` #### 后端 URL 生成 `file.service.js` 中生成文件 URL 时,使用 `S3_PUBLIC_URL` 代替 `config.s3.endpoint`: ```javascript const fileUrl = `${config.s3.publicUrl}/${this.bucketName}/${objectName}` ``` #### 优点 - **配置简单**:一个 Nginx location 块即可,无需额外域名 - **同域无跨域**:文件 URL 与前端同域,无 CORS 问题 - **HTTPS 加密**:复用主站 SSL 证书 - **不暴露端口**:9000 端口不对公网开放 #### 缺点 - **缓存竞争**:与主站共享域名,CDN 缓存可能互相影响 - **扩展性一般**:将来独立扩容文件服务时需要调整路径 - **Cookie 携带**:浏览器请求时会自动携带主站 Cookie(虽然静态资源通常不校验) --- ### 方式二:子域名代理 `file.xxx.com` 使用独立子域名 `file.xxx.com` 指向 S3 服务,文件 URL 为 `https://file.xxx.com/nomoex/avatars/xxx.webp`。 #### DNS 配置 在 DNS 服务商处添加解析记录: ``` 类型: A 主机记录: file 记录值: 你的服务器公网IP TTL: 默认 ``` #### Nginx 配置 新增一个独立的 `server` 块: ```nginx # 主站 server { listen 443 ssl; server_name your-domain.com; # ... 主站配置不变 } # 文件服务子域名 server { listen 443 ssl; server_name file.your-domain.com; ssl_certificate /path/to/file.your-domain.com.pem; ssl_certificate_key /path/to/file.your-domain.com.key; # 如果有通配符证书,可共用: # ssl_certificate /path/to/your-domain.com.pem; # ssl_certificate_key /path/to/your-domain.com.key; location / { proxy_pass http://127.0.0.1:9000; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; # 大文件支持 client_max_body_size 100m; proxy_connect_timeout 60s; proxy_send_timeout 60s; proxy_read_timeout 60s; } } ``` #### 环境变量配置 ```bash # S3 内部连接地址 S3_ENDPOINT=localhost S3_PORT=9000 # S3 外部访问地址(使用子域名) S3_PUBLIC_URL=https://file.your-domain.com ``` #### 后端 URL 生成 ```javascript const fileUrl = `${config.s3.publicUrl}/${this.bucketName}/${objectName}` ``` #### 优点 - **CDN 友好**:可以单独给 `file.xxx.com` 配置 CDN,静态资源加速效果更好 - **独立扩展**:文件量大时可独立扩容,不影响主站 - **安全隔离**:主站被攻破不会直接暴露文件服务 - **URL 简洁**:`file.xxx.com/bucket/file` 路径干净 - **SEO 友好**:搜索引擎对子域名的静态资源爬取更友好 #### 缺点 - **配置成本高**:需要额外域名 + SSL 证书 + DNS 解析 - **同根域 Cookie**:如果 Cookie 设置了 `domain=.xxx.com`,子域名仍会携带,需注意安全 - **证书管理**:多一个证书需要维护(通配符证书可缓解) --- ### 两种方式对比 | 维度 | 路径代理 `/s3/` | 子域名 `file.` | | ----------- | ------------------------ | -------------------------- | | 配置成本 | 低(一个 location) | 中(DNS + SSL + location) | | 性能 | 一般(同域缓存竞争) | 好(独立缓存/CDN) | | 安全性 | 中(路径隔离) | 高(域名隔离) | | 扩展性 | 一般 | 强 | | Cookie 携带 | 必然带 | 同根域也会带(需注意) | | 文件 URL | `xxx.com/s3/bucket/file` | `file.xxx.com/bucket/file` | | SSL 证书 | 复用主站 | 独立证书(或通配符) | | CDN 配置 | 不支持独立 CDN | 可独立配置 CDN | ### 选择建议 - **项目规模小、快速上线** → 选择方式一(路径代理),配置最简 - **长期运营、文件量大、需要 CDN** → 选择方式二(子域名),更专业 ### 注意事项 1. **S3 监听地址**:确保 S3 服务(RustFS/MinIO)绑定在 `0.0.0.0:9000` 而非 `127.0.0.1:9000`,否则 Nginx 无法反向代理 2. **预签名 URL**:如果使用私有存储(需签名访问),`getPresignedUrl` 生成的 URL 中的 host 部分需要对应修改为 `S3_PUBLIC_URL` 3. **大文件上传**:如果前端需要直接上传到大文件,建议通过后端中转,避免暴露 S3 管理接口 4. **跨域配置**:如果后端需要从 S3 直传(PUT 方式),需要配置 S3 的 CORS 策略允许对应域名