Express + TypeScript 搭建基础开发项目(附git仓库)

536 阅读13分钟

Express 是一个非常流行的 Node.js 框架,因其简洁和灵活性而备受欢迎。结合 TypeScript 的静态类型检查功能,可以显著提升代码的可维护性和开发效率。在本文中,我们将逐步介绍如何使用 Express 和 TypeScript 搭建一个基础开发项目,包括配置管理、日志模块、错误处理、数据库封装、路由模块、用户验证授权、缓存模块以及中间件支持等方面的内容。

仓库地址

git@express-ts-base-project

配置模块

1.1 配置管理

配置管理模块的主要目的是处理应用程序的各种配置,包括环境变量管理、配置文件结构设计以及加载和使用配置。下面是一个示例实现,展示了如何使用 dotenvyaml 文件管理配置。

目录结构
my-express-ts-app/
├── src/
│   ├── config/
│   │   ├── index.ts
│   │   └── config.ts
│   ├── logger/
│   │   └── index.ts
│   └── ...
├── .config/
│   ├── .dev.yaml
│   ├── .prod.yaml
│   └── .test.yaml
├── .env
└── ...
代码实现

1. 安装依赖

首先,确保安装了必要的依赖:

npm install dotenv yaml

2. 配置文件

.config 目录下创建不同环境的配置文件,例如 .dev.yaml, .prod.yaml, .test.yaml

3. 配置管理模块

src/config/config.ts 文件中实现配置管理逻辑:

import { readFileSync } from 'fs';
import { join } from 'path';
import { parse } from 'yaml';
import logger from '../logger';

// 定义基础配置接口
interface Config {
  APP: {
    name: string;
    port: number;
    host: string;
  };
  cors: CorsConfig;
  database: {
    host: string;
    port: number;
  };
}

// 跨域配置接口
interface CorsConfig {
  origin: boolean | string | RegExp | Array<string | RegExp>;
  methods: string[];
  allowedHeaders: string[];
  exposedHeaders?: string[];
  maxAge?: number;
  credentials?: boolean;
}

// 扩展配置接口,允许任意类型的额外属性
interface ExtendedConfig extends Config, Record<string, any> {}

// 配置缓存变量,用于存储已加载的配置
let configCache: ExtendedConfig | null = null;

// 获取当前环境变量,默认为 'dev'
export const getEnv = (): string => {
  return process.env.RUNNING_ENV ?? 'dev';
};

/**
 * 获取配置对象或特定配置类型
 * @param type - 配置类型的键
 * @returns 如果传入了type参数,则返回对应的配置类型,否则返回完整的配置对象
 */
export function getConfig(): ExtendedConfig;
export function getConfig<T extends keyof Config>(type: T): Config[T];
export function getConfig<T extends keyof Config>(
  type?: T
): Config[T] | ExtendedConfig {
  const environment = getEnv(); // 获取当前运行环境

  // 如果已经缓存了配置,直接返回缓存中的值
  if (configCache != null) {
    return type !== undefined ? configCache[type] : configCache;
  }

  try {
    // 根据环境变量构建配置文件路径
    const yamlPath = join(
      process.cwd(),
      `.config/.${environment}.yaml`
    );
    // 读取配置文件内容
    const file = readFileSync(yamlPath, 'utf8');
    // 解析YAML格式的配置文件
    const parsedConfig = parse(file) as ExtendedConfig;
    configCache = parsedConfig; // 将解析后的配置缓存起来
    return type !== undefined ? parsedConfig[type] : parsedConfig;
  } catch (error: any) {
    // 处理读取或解析配置文件时的错误
    logger.error(`Failed to read or parse the config file: ${error}`);
    process.exit(1); // 出错时终止程序,或者可以选择抛出异常或返回默认配置
  }
}

4. 环境变量管理

在项目根目录下创建 .env 文件,并添加环境变量:

RUNNING_ENV=dev

5. 使用配置模块

在应用的主文件(src/index.ts)中使用配置模块:

import express from 'express';
import { getConfig } from './config/config';
import logger from './logger';

const app = express();
const config = getConfig();

app.listen(config.APP.port, config.APP.host, () => {
  logger.info(`Server is running at http://${config.APP.host}:${config.APP.port}`);
});

日志模块

日志模块的主要目的是记录应用程序的各种日志信息,包括请求日志、错误日志和应用日志。我们可以使用 winstonmorgan 结合来实现这些功能。

2.1 请求日志

请求日志中间件(如 morgan)

我们可以使用 morgan 中间件来记录 HTTP 请求日志。

安装依赖

npm install morgan winston
npm i --save-dev @types/morgan

实现请求日志中间件

src/middlewares/loggerHandler.ts 文件中实现请求日志中间件:

import morgan, { StreamOptions } from "morgan";
import logger from "../logger";
import { Request } from "express";

// 创建一个流对象,用于将 morgan 的日志输出到 winston
const stream: StreamOptions = {
  write: (message: string) => logger.http(message.trim()),
};

// 自定义morgan格式
morgan.token("remote-addr", (req: Request) => req.clientIP);
morgan.token("user-agent", (req: Request) => req.headers["user-agent"] || "");
// morgan.token("req-headers", (req) => JSON.stringify(req.headers)); 如启动需要排除鉴权相关请求头
morgan.token(
  "ip-address",
  (req: Request) => req.headers["x-forwarded-for"]?.toString() || ""
);

// 配置 morgan 中间件
const morganMiddleware = morgan(
  ":remote-addr - :method :url :status :res[content-length] - :response-time ms :user-agent :ip-address",
  { stream }
);

export default morganMiddleware;

请求日志格式和存储

src/logger/index.ts 文件中配置 winston 日志记录器:

import { createLogger, transports, format, config } from 'winston';
import path from 'path';
import fs from 'fs';

// 确保日志目录存在,如果不存在则创建
const logDir = 'logs';
if (!fs.existsSync(logDir)) {
  fs.mkdirSync(logDir);
}

// 自定义日志级别
const customLevels = {
  levels: {
    error: 0,
    warn: 1,
    info: 2,
    http: 3,
    debug: 4,
  },
  colors: {
    error: 'red',
    warn: 'yellow',
    info: 'green',
    http: 'magenta',
    debug: 'blue',
  }
};

// 创建日志记录器
const logger = createLogger({
  levels: customLevels.levels,
  // 设置日志级别,根据环境变量决定日志级别
  // 在生产环境中记录警告及以上级别的日志,在其他环境中记录信息及以上级别的日志
  level: process.env.NODE_ENV === 'production' ? 'warn' : 'http',
  // 设置日志格式
  format: format.combine(
    // 添加时间戳
    format.timestamp(),
    // 自定义日志输出格式
    format.printf(({ timestamp, level, message }) => {
      return `${timestamp} [${level}]: ${message}`;
    })
  ),
  // 设置日志输出的传输方式
  transports: [
    // 控制台输出,添加颜色
    new transports.Console({
      format: format.combine(
        format.colorize({ all: true }), // 使用自定义颜色
        format.printf(({ timestamp, level, message }) => {
          return `${timestamp} [${level}]: ${message}`;
        })
      )
    }),
    // 错误日志文件输出,记录错误级别的日志
    new transports.File({
      filename: path.join(logDir, 'error.log'),
      level: 'error',
    }),
    // 组合日志文件输出,记录所有级别的日志
    new transports.File({ filename: path.join(logDir, 'combined.log') }),
    // HTTP 请求日志文件输出,记录HTTP级别的日志
    new transports.File({
      filename: path.join(logDir, 'http.log'),
      level: 'http',
    }),
  ],
});

// 添加颜色配置
config.addColors(customLevels.colors);

export default logger;

2.2 错误日志

错误日志捕获和存储

src/middlewares/errorHandler.ts 文件中实现错误日志捕获中间件:

import { Request, Response, NextFunction } from 'express';
import logger from '../logger';

// 错误处理中间件
const errorHandler = (err: Error, req: Request, res: Response, next: NextFunction) => {
  // 记录错误日志,包括请求方法、请求URL和错误信息
  logger.error(`${req.method} ${req.url} - ${err.message}`);

  // 返回500状态码和错误信息
  res.status(500).json({
    message: 'Internal Server Error',
    // 在开发环境中返回错误详细信息,在生产环境中隐藏详细信息
    error: process.env.NODE_ENV === 'development' ? err.message : undefined,
  });
};

export default errorHandler;
错误日志格式

src/logger/index.ts 文件中已经配置了错误日志的格式和存储位置,并通过 level 属性配置了不同级别的日志记录。我们可以通过 logger 的不同方法来记录不同级别的日志:

import logger from './logger';

logger.info('This is an info message');
logger.warn('This is a warning message');
logger.error('This is an error message');

使用日志模块

在应用的主文件( src/index.ts)中使用日志模块:

import express, { Request, Response } from "express";
import { getConfig } from "./config/config";
import morganMiddleware from "./middlewares/logger";
import errorHandler from "./middlewares/errorHandler";
import logger from "./logger";
import { DbMySqlConnectionError, DbRedisConnectionError } from "./errors";

const app = express();
const port = process.env.PORT || 3000;

const config = getConfig();

// 使用 morgan 中间件记录请求日志
app.use(morganMiddleware);

app.get("/", (req: Request, res: Response) => {
  logger.info("This is an info message");
  logger.warn("This is a warning message");
  logger.error("This is an error message");
  res.send("Hello, Object Storage System!");
});

// 使用错误处理中间件记录错误日志
app.use(errorHandler);
app.listen(port, () => {
  logger.info(`Server is running on http://localhost:${port}`);
});

通过以上步骤,我们实现了一个完整的日志模块,能够记录请求日志、错误日志和应用运行日志。这个模块包括了请求日志中间件、错误日志捕获和存储、应用日志记录以及不同的日志级别。

错误处理模块

3.1 全局错误处理

全局错误处理中间件用于捕获和处理应用程序中的所有错误,并返回统一的错误响应格式。

错误处理中间件

src/middlewares/errorHandler.ts 文件中实现更新错误处理中间件:

// src/middlewares/errorHandler.ts
import { Request, Response, NextFunction } from 'express';
import logger from '../logger';
import { CustomError } from '../errors/customError';

// 错误处理器中间件
const errorHandler = (err: any, req: Request, res: Response, next: NextFunction) => {
    // 检查错误是否是自定义错误类型
    if (err instanceof CustomError) {
        // 记录错误信息,包括请求方法、URL、错误信息和错误代码
        logger.error(`${req.method} ${req.url} - ${err.message} - Code: ${err.errorCode}`);
        
        // 根据错误代码返回不同的响应状态码和消息
        res.status(err.errorCode > 1000 ? 500 : err.errorCode).json({
            message: err.errorCode > 1000 ? 'Server error' : err.message,
            code: err.errorCode,
        });
    } else {
        // 如果不是自定义错误,记录通用错误信息
        logger.error(`${req.method} ${req.url} - ${err.message}`);
        
        // 返回通用的服务器错误响应
        res.status(500).json({
            message: 'Server error',
            code: 500,
        });
    }

    // 调用下一个中间件
    next();
};

export default errorHandler;
异步错误处理包装函数

src/utils/asyncWrap.ts中实现

import { Request, Response, NextFunction } from 'express';

/**
 * 异步包装函数,用于将异步中间件函数转换为符合 Express 标准的中间件函数。
 * @param fn 异步中间件函数
 * @returns 转换后的中间件函数
 */
const asyncWrap = (fn: (req: Request, res: Response, next: NextFunction, ...args: any[]) => Promise<any>) =>
    function asyncUtilWrap(req: Request, res: Response, next: NextFunction, ...args: any[]) {
        const fnReturn = fn(req, res, next, ...args);
        return Promise.resolve(fnReturn).catch(next);
    };

export default asyncWrap;
错误响应格式

错误响应格式包含错误消息和错误码:

{
    "message": "Error message",
    "code": 500
}

3.2 自定义错误类

自定义错误类用于定义不同类型的错误及其对应的错误码和消息。

自定义错误类设计

src/errors/customError.ts 文件中实现自定义错误类:

export class CustomError extends Error {
    // 定义一个公共的错误代码属性
    public errorCode: number;

    // 构造函数,接受错误代码和可选的错误信息
    constructor(errorCode: number, message?: string) {
        // 调用父类(Error)的构造函数,传递错误信息
        super(message);

        // 设置错误名称为当前类的名称
        this.name = this.constructor.name;

        // 设置错误代码
        this.errorCode = errorCode;

        // 捕获当前错误的堆栈信息,以便调试
        Error.captureStackTrace(this, this.constructor);
    }
}
错误类型和状态码

src/errors/index.ts 文件中实现具体的错误类型:

import { CustomError } from './customError';

// 服务器内部错误
export class ServerInternalError extends CustomError {
    constructor(errorCode: number = 600000, message?: string) {
        super(errorCode, message || 'Server internal error occurred.');
    }
}

// 数据库连接错误
export class DbConnectionError extends ServerInternalError {
    constructor(message?: string) {
        super(100101, message || 'Database connection error.');
    }
}

// MySQL 连接错误
export class DbMySqlConnectionError extends ServerInternalError {
    constructor(message?: string) {
        super(100101, message || 'MySQL connection error.');
    }
}

// Redis 连接错误
export class DbRedisConnectionError extends ServerInternalError {
    constructor(message?: string) {
        super(100201, message || 'Redis connection error.');
    }
}

// 数据库查询错误
export class DbQueryError extends ServerInternalError {
    constructor(message?: string) {
        super(600200, message || 'Database query error.');
    }
}

// 认证失败错误
export class AuthenticationError extends ServerInternalError {
    constructor(message?: string) {
        super(600300, message || 'Authentication failed.');
    }
}

// 授权失败错误
export class AuthorizationError extends ServerInternalError {
    constructor(message?: string) {
        super(600400, message || 'Authorization failed.');
    }
}

// 支付处理错误
export class PaymentProcessingError extends ServerInternalError {
    constructor(message?: string) {
        super(600500, message || 'Payment processing error.');
    }
}

// 订单处理错误
export class OrderProcessingError extends ServerInternalError {
    constructor(message?: string) {
        super(600600, message || 'Order processing error.');
    }
}

// 客户端错误
export class ClientError extends CustomError {
    constructor(errorCode: number, message?: string) {
        super(errorCode, message);
    }
}

// 错误的客户端请求
export class ClientBadRequestError extends ClientError {
    constructor(message?: string) {
        super(400, message || 'Bad request from client.');
    }
}

// 客户端被风控
export class ClientForbiddenByRiskControlError extends ClientError {
    constructor(message?: string) {
        super(403, message || 'Client is forbidden by risk control.');
    }
}

// 未经授权的客户端请求
export class ClientUnauthorizedError extends ClientError {
    constructor(message?: string) {
        super(401, message || 'Unauthorized client request.');
    }
}

// 客户端权限不足
export class ClientForbiddenError extends ClientError {
    constructor(message?: string) {
        super(403, message || 'Client does not have sufficient permissions.');
    }
}

// 客户端数据验证失败
export class ClientValidationError extends ClientError {
    constructor(message?: string) {
        super(422, message || 'Client data validation failed.');
    }
}

// 客户端请求超时
export class ClientTimeoutError extends ClientError {
    constructor(message?: string) {
        super(408, message || 'Client request timed out.');
    }
}

// 客户端提供的凭证无效
export class ClientInvalidCredentialsError extends ClientError {
    constructor(message?: string) {
        super(401, message || 'Invalid credentials provided by client.');
    }
}

// 客户端请求过多
export class ClientTooManyRequestsError extends ClientError {
    constructor(message?: string) {
        super(429, message || 'Too many requests from client.');
    }
}

// 客户端网络连接错误
export class ClientNetworkError extends ClientError {
    constructor(message?: string) {
        super(500, message || 'Client network connection error.');
    }
}

基础数据库封装

4.1 数据库连接管理

数据库连接配置

安装mongoose

npm i mongoose

src/config/database.ts 文件中定义 MongoDB 连接配置:

import mongoose, { ConnectOptions } from 'mongoose';

const mongoUrl = process.env.MONGO_URL || 'mongodb://localhost:27017/mydatabase';

const options: ConnectOptions = {
  connectTimeoutMS: 10000,
  socketTimeoutMS: 45000,
  maxPoolSize: 10,
  minPoolSize: 5,
  autoIndex: true,
  retryWrites: true,
  w: 'majority',
  readPreference: 'primary',
  authSource: 'admin',
};

export const initializeMongoose = async () => {
  try {
    await mongoose.connect(mongoUrl, options);
    console.log('MongoDB 连接成功...');
  } catch (err) {
    console.error('MongoDB 连接错误:', err);
  }

  mongoose.connection.on('connected', () => {
    console.log('Mongoose 已连接到数据库');
  });

  mongoose.connection.on('error', (err) => {
    console.error('Mongoose 连接错误:', err);
  });

  mongoose.connection.on('disconnected', () => {
    console.log('Mongoose 已断开与数据库的连接');
  });

  process.on('SIGINT', async () => {
    await mongoose.connection.close();
    console.log('应用程序终止,Mongoose 已断开连接');
    process.exit(0);
  });
};
数据库连接池管理

连接池管理已经在 ConnectOptions 中配置,通过 maxPoolSizeminPoolSize 参数进行管理。

4.2 ORM(对象关系映射)

由于使用的是 MongoDB,推荐使用 Mongoose 作为 ODM(对象文档映射)工具。

ORM 配置和使用

src/models/baseModel.ts 文件中实现基础的 Mongoose 模型封装:

import mongoose, {
  Schema,
  Document,
  Model,
  FilterQuery,
  QueryOptions,
} from "mongoose";

export interface IBaseDocument extends Document {
  deletedAt?: Date;
  createdAt: Date;
  updatedAt: Date;
  createdBy?: mongoose.Types.ObjectId;
  updatedBy?: mongoose.Types.ObjectId;
  version: number;
}

type ExtendedFilterQuery<T> = FilterQuery<T> & {
  deletedAt?: { $exists: boolean };
};

function toObjectId(
  id: mongoose.Types.ObjectId | string
): mongoose.Types.ObjectId {
  return typeof id === "string" ? new mongoose.Types.ObjectId(id) : id;
}

export const baseSchemaDict = {
  createdAt: {
    type: Date,
    default: Date.now,
  },
  updatedAt: {
    type: Date,
    default: Date.now,
  },
  deletedAt: {
    type: Date,
  },
  createdBy: {
    type: mongoose.Types.ObjectId,
    ref: "User",
  },
  updatedBy: {
    type: mongoose.Types.ObjectId,
    ref: "User",
  },
  version: {
    type: Number,
    default: 0,
  },
};

const baseSchema = new Schema<IBaseDocument>(baseSchemaDict);

baseSchema.pre<IBaseDocument>("save", function (next) {
  this.updatedAt = new Date();
  this.version += 1;
  next();
});

class BaseModel<T extends Document> {
  private model: Model<T>;

  constructor(
    modelName: string,
    schemaDef: mongoose.SchemaDefinition,
    options: mongoose.SchemaOptions = {}
  ) {
    const schema = new Schema(
      {
        ...schemaDef,
        ...baseSchema.obj,
      },
      options
    );

    this.model = mongoose.model<T>(modelName, schema);
  }

  protected getModel(): Model<T> {
    return this.model;
  }

  async create(
    doc: Partial<T>,
    userId?: mongoose.Types.ObjectId | null
  ): Promise<T> {
    if (userId) {
      (doc as any).createdBy = userId;
      (doc as any).updatedBy = userId;
    }
    return await this.model.create(doc);
  }

  async update(
    id: mongoose.Types.ObjectId | string,
    update: Partial<T>,
    userId: mongoose.Types.ObjectId
  ): Promise<T | null> {
    (update as any).updatedBy = userId;
    return await this.model.findByIdAndUpdate(toObjectId(id), update, { new: true }).exec();
  }

  async delete(
    id: mongoose.Types.ObjectId | string,
    userId?: mongoose.Types.ObjectId
  ): Promise<T | null> {
    return await this.model
      .findByIdAndUpdate(
        toObjectId(id),
        {
          deletedAt: new Date(),
          updatedBy: userId,
        },
        { new: true }
      )
      .exec();
  }

  async find(query: ExtendedFilterQuery<T>, options: QueryOptions = {}) {
    query.deletedAt = { $exists: false };
    return await this.model.find(query, options).exec();
  }

  async findById(
    id: mongoose.Types.ObjectId | string,
    options: QueryOptions = {}
  ): Promise<T | null> {
    const query: ExtendedFilterQuery<T> = {
      _id: toObjectId(id),
      deletedAt: { $exists: false },
    };
    return await this.model.findOne(query, options).exec();
  }

  async findOne(
    query: ExtendedFilterQuery<T>,
    options: QueryOptions = {}
  ): Promise<T | null> {
    query.deletedAt = { $exists: false };
    return await this.model.findOne(query, options).exec();
  }

  async findAllIncludingDeleted(
    query: FilterQuery<T>,
    options: QueryOptions = {}
  ) {
    return await this.model.find(query, options).exec();
  }
}

export default BaseModel;

更新config类型注解

import { readFileSync } from 'fs';
import { join } from 'path';
import { parse } from 'yaml';
import logger from '../logger';
import { ConnectOptions} from "mongoose"
// 定义数据库配置接口
interface DatabaseConfig {
  url: string;
  options: ConnectOptions
}

// 定义基础配置接口
interface Config {
  APP: {
    name: string;
    port: number;
    host: string;
  };
  cors: CorsConfig;
  database: DatabaseConfig;
}

路由模块

5.1 路由定义

路由文件结构

建议将路由文件结构组织成模块化的形式,以便于维护和扩展。可以按照功能模块划分路由,例如:

src/
├── routes/
│   ├── index.ts
│   ├── api/
│   │   ├── testRouter.ts
│   │   ├── userRouter.ts
│   │   └── productRouter.ts
├── controllers/
│   ├── testController.ts
│   ├── userController.ts
├── middlewares/
│   ├── authMiddleware.ts
│   └── errorMiddleware.ts
└── validators/
    └── userValidator.ts
路由定义和使用

src/routes/index.ts 文件中定义和初始化路由:

import { Express, Request, Response, Router } from "express";
import logger from "../logger";

interface RouterConf {
  path: string;
  router: Router;
  meta?: any;
}

const routerGroup: RouterConf[] = [];

function registerRouteGroup(app: Express, routes: RouterConf[]) {
  routes.forEach((route) => {
    app.use(route.path, route.router);
  });
}

function initRoutes(app: Express) {
  logger.info("Router initialization");

  app.get("/", (req: Request, res: Response) => {
    logger.info("This is an info message");
    logger.warn("This is a warning message");
    logger.error("This is an error message");
    res.send("Hello, Object Storage System!");
  });

  registerRouteGroup(app, routerGroup);
}

export default initRoutes;

通过以上步骤,我们实现了一个完整的路由模块,包括路由定义、路由中间件以及参数验证和错误处理。这个模块可以帮助我们更好地组织和管理路由,提高应用程序的可维护性和可靠性。

用户和验证授权

6.1 模型设计

安装 bcryptjs

npm install bcryptjs
npm i --save-dev @types/bcryptjs

src/models/userModel.ts 中定义用户模型:

import mongoose, { Schema, Document } from "mongoose";
import bcrypt from "bcryptjs";
import BaseModel, { IBaseDocument, baseSchemaDict } from "./baseModel"; // 引入更新后的 BaseModel

// 用户接口,包含字段和方法
export interface IUser extends IBaseDocument {
  username: string; // 用户名,必须唯一
  email: string; // 邮箱,必须唯一
  password: string; // 密码
  role: "guest" | "user" | "admin" | "super"; // 用户角色
  name?: string; // 姓名
  phoneNumber?: string; // 电话号码
  profilePicture?: string; // 头像URL
  dateOfBirth?: Date; // 出生日期
  address?: string; // 地址
  isActive: boolean; // 是否激活
  isVerified: boolean; // 是否验证
  lastLogin?: Date; // 最后登录时间
  passwordResetToken?: string; // 密码重置令牌
  passwordResetExpires?: Date; // 密码重置令牌过期时间
  loginAttempts: number; // 登录尝试次数
  lockUntil?: Date; // 锁定账户直到某个时间
  comparePassword(candidatePassword: string): Promise<boolean>; // 比较密码方法
}

// 用户 Schema 定义
const userSchemaDefinition: mongoose.SchemaDefinition = {
  username: { type: String, required: true, unique: true }, // 用户名,必须唯一
  email: { type: String, required: true, unique: true }, // 邮箱,必须唯一
  password: { type: String, required: true }, // 密码
  role: {
    type: String,
    enum: ["guest", "user", "admin", "super"], // 用户角色
    default: "guest", // 默认角色为 guest
  },
  name: { type: String }, // 姓名
  phoneNumber: { type: String }, // 电话号码
  profilePicture: { type: String }, // 头像URL
  dateOfBirth: { type: Date }, // 出生日期
  address: { type: String }, // 地址
  isActive: { type: Boolean, default: true }, // 是否激活,默认激活
  isVerified: { type: Boolean, default: false }, // 是否验证,默认未验证
  lastLogin: { type: Date }, // 最后登录时间
  passwordResetToken: { type: String }, // 密码重置令牌
  passwordResetExpires: { type: Date }, // 密码重置令牌过期时间
  loginAttempts: { type: Number, default: 0 }, // 登录尝试次数,默认0次
  lockUntil: { type: Date }, // 锁定账户直到某个时间
};

class UserModel extends BaseModel<IUser> {
  constructor() {
    super("User", userSchemaDefinition);

    // 比较密码方法
    this.getModel().schema.methods.comparePassword = function (
      candidatePassword: string
    ): Promise<boolean> {
      return bcrypt.compare(candidatePassword, this.password);
    };
  }

  // 单独的比较密码方法
  comparePassword(candidatePassword: string, hashPass: string) {
    return bcrypt.compare(candidatePassword, hashPass);
  }

  // 用户注册
  async register(userData: IUser) {
    const salt = await bcrypt.genSalt(10);
    userData.password = await bcrypt.hash(userData.password, salt);
    const user = new (this.getModel())(userData);
    await user.save();
    return user;
  }

  // 用户登录
  async login(email: string, password: string) {
    const user = await this.getModel().findOne({ email });
    if (!user) throw new Error("User not found");

    const isMatch = await user.comparePassword(password);
    if (!isMatch) throw new Error("Invalid password");

    user.lastLogin = new Date();
    await user.save();
    return user;
  }

  // 用户登出
  async logout(userId: string) {
    // 实现登出逻辑,例如销毁会话或令牌
  }

  // 根据ID获取用户
  async getUserById(userId: string) {
    return this.getModel().findById(userId);
  }

  // 更新用户信息
  async updateUser(userId: string, updateData: Partial<IUser>) {
    if (updateData.password) {
      const salt = await bcrypt.genSalt(10);
      updateData.password = await bcrypt.hash(updateData.password, salt);
    }
    updateData.updatedAt = new Date();
    return this.getModel().findByIdAndUpdate(userId, updateData, { new: true });
  }

  // 删除用户
  async deleteUser(userId: string) {
    return this.getModel().findByIdAndDelete(userId);
  }

  // 请求密码重置邮件token
  async requestPasswordReset(email: string) {
    const user = await this.getModel().findOne({ email });
    if (!user) throw new Error("User not found");

    // 生成密码重置令牌和过期时间
    const token = bcrypt.genSaltSync(10);
    user.passwordResetToken = token;
    user.passwordResetExpires = new Date(Date.now() + 3600000); // 1小时后过期
    await user.save();

    return token;
  }

  // 重置密码
  async resetPassword(token: string, newPassword: string) {
    const user = await this.getModel().findOne({
      passwordResetToken: token,
      passwordResetExpires: { $gt: new Date() },
    });
    if (!user) throw new Error("Token is invalid or has expired");

    const salt = await bcrypt.genSalt(10);
    user.password = await bcrypt.hash(newPassword, salt);
    user.passwordResetToken = undefined;
    user.passwordResetExpires = undefined;
    await user.save();
  }

  // 管理员重置用户密码
  async resetPasswordByAdmin(userId: string, newPassword?: string) {
    const user = await this.getModel().findById(userId);
    if (!user) throw new Error("User not found");

    // 如果没有指定新密码,则生成一个随机密码
    if (!newPassword) {
      newPassword = Math.random().toString(36).slice(-8);
    }

    const salt = await bcrypt.genSalt(10);
    user.password = await bcrypt.hash(newPassword, salt);
    await user.save();
    return newPassword;
  }

  // 用户自己重置密码
  async resetPasswordByUser(
    userId: string,
    oldPassword: string,
    newPassword: string
  ) {
    const user = await this.getModel().findById(userId);
    if (!user) throw new Error("User not found");

    const isMatch = await user.comparePassword(oldPassword);
    if (!isMatch) throw new Error("Invalid old password");

    const salt = await bcrypt.genSalt(10);
    user.password = await bcrypt.hash(newPassword, salt);
    await user.save();
  }
}

export default new UserModel();

6.2 角色和权限设计

src/types/user.ts 中定义角色类型和认证用户接口:

export type Role = 'guest' | 'user' | 'admin' | 'super';

export interface AuthUser {
  id: string;
  username: string;
  role: Role;
}

6.3 权限验证中间件

首先,安装 jsonwebtoken 包及其类型定义:

npm i jsonwebtoken
npm i --save-dev @types/jsonwebtoken

src/middlewares/authMiddleware.ts 中定义身份验证中间件:

import { Request, Response, NextFunction } from 'express';
import jwt from 'jsonwebtoken';
import { AuthUser } from '../types/user';

declare global {
  namespace Express {
    interface Request {
      user?: AuthUser;
    }
  }
}

export const authMiddleware = (req: Request, res: Response, next: NextFunction) => {
  const token = req.header('Authorization')?.replace('Bearer ', '') || (req.query.token as string);
  if (!token || Array.isArray(token)) {
    req.user = { id: '', username: '', role: 'guest' }; // 设置为游客身份
    return next();
  }

  try {
    const decoded = jwt.verify(token, 'your_jwt_secret') as AuthUser;
    req.user = decoded;
    next();
  } catch (error) {
    req.user = { id: '', username: '', role: 'guest' }; // 设置为游客身份
    next();
  }
};

src/middlewares/roleMiddleware.ts 中定义角色验证中间件:

import { Request, Response, NextFunction } from "express";
import { Role } from "../types/user";
import { ClientForbiddenError, ClientUnauthorizedError } from "../errors";

export const roleMiddleware = (requiredRole: Role | Role[]) => {
  return (req: Request, res: Response, next: NextFunction) => {
    if (!req.user) {
      throw new ClientUnauthorizedError();
    }

    if (typeof requiredRole === "string") {
      if (req.user.role !== requiredRole) {
        throw new ClientForbiddenError();
      }
    } else if (Array.isArray(requiredRole)) {
      if (!requiredRole.includes(req.user.role)) {
        throw new ClientForbiddenError();
      }
    }

    next();
  };
};

6.4 用户注册和登录

src/controllers/user/authController.ts 中定义用户注册和登录逻辑:

import { Request, Response } from "express";
import jwt from "jsonwebtoken";
import { ClientBadRequestError, ClientUnauthorizedError } from "../../errors";
import UserModel from "../../models/UserModel";
import { getConfig } from "../../config/config";

export const registerUser = async (req: Request, res: Response) => {
  const { username, email, password } = req.body;
    const user = await UserModel.create({
      username,
      email,
      password,
      role: "user",
    });
    res.status(201).json(user);
};

export const loginUser = async (req: Request, res: Response) => {
  const { email, password } = req.body;
    const user = await UserModel.findOne({ email });
    if (!user || !(await user.comparePassword(password))) {
      throw new ClientUnauthorizedError("Invalid email or password");
    }

    const token = jwt.sign(
      { id: user._id, username: user.username, role: user.role },
      getConfig().jwt_secret,
      { expiresIn: "15d" }
    );
    res.json({ token });
};

src/routes/api/authRouter.ts 中定义用户信息和密码重置路由:

import { Router } from "express";
import { registerUser, loginUser } from "../../controllers/user/authController";
import asyncWrap from "../../utils/asyncWrap";

const authRouter = Router();

authRouter.post("/register", asyncWrap(registerUser));
authRouter.post("/login", asyncWrap(loginUser));

export default authRouter;

6.5 用户信息

src/controllers/user/infoController.ts 中添加获取用户信息的逻辑:

import { Request, Response } from "express";
import UserModel from "../../models/UserModel";
import { ClientBadRequestError, ClientUnauthorizedError } from "../../errors";
import { getCache, setCache, delCache } from "../../utils/cache";

const CACHE_TTL = 3600; // 缓存时间,单位为秒

// 获取用户信息
export const getUserInfo = async (req: Request, res: Response) => {
  const cacheKey = `user:${req.user!.id}`;
  let user = await getCache(cacheKey);

  if (!user) {
    user = await UserModel.findById(req.user!.id as string);
    if (!user) {
      throw new ClientUnauthorizedError("User not found");
    }
    await setCache(cacheKey, user, CACHE_TTL);
  }

  res.json(user);
};

// 更新用户信息
export const updateUserInfo = async (req: Request, res: Response) => {
  const user = await UserModel.updateUser(req.user!.id, req.body);
  if (!user) {
    throw new ClientUnauthorizedError("User not found");
  }

  // 更新缓存
  const cacheKey = `user:${req.user!.id}`;
  await setCache(cacheKey, user, CACHE_TTL);

  res.json(user);
};

6.6 重置密码

src/controllers/user/securityController.ts 中添加重置密码的逻辑:

import { Request, Response } from "express";
import UserModel from "../../models/UserModel";
import { ClientBadRequestError } from "../../errors";
import { delCache } from "../../utils/cache";

// 管理员重置用户密码
export const resetPasswordByAdminHandler = async (
  req: Request,
  res: Response
) => {
  const { userId, newPassword } = req.body;
  const password = await UserModel.resetPasswordByAdmin(userId, newPassword);

  // 删除缓存
  const cacheKey = `user:${userId}`;
  await delCache(cacheKey);

  res.json({ message: "Password has been reset", password });
};

// 用户自己重置密码
export const resetPasswordByUserHandler = async (
  req: Request,
  res: Response
) => {
  const { userId, oldPassword, newPassword } = req.body;
  await UserModel.resetPasswordByUser(userId, oldPassword, newPassword);

  // 删除缓存
  const cacheKey = `user:${userId}`;
  await delCache(cacheKey);

  res.json({ message: "Password has been reset" });
};

src/routes/api/userRouter.ts 中定义用户信息和密码重置路由:

import { Router } from "express";
import {
  getUserInfo,
  updateUserInfo,
} from "../../controllers/user/infoController";
import { authMiddleware } from "../../middlewares/authMiddleware";
import { roleMiddleware } from "../../middlewares/roleMiddleware";

const userRouter = Router();

userRouter.get(
  "/me",
  authMiddleware,
  roleMiddleware(["admin", "super", "user"]),
  getUserInfo
);
userRouter.put(
  "/me",
  authMiddleware,
  roleMiddleware(["admin", "super", "user"]),
  updateUserInfo
);
userRouter.post(
  "/request-password-reset",
  authMiddleware,
  roleMiddleware(["admin", "super"]),
  requestPasswordReset
);
userRouter.post(
  "/reset-password",
  authMiddleware,
  roleMiddleware(["admin", "super", "user"]),
  resetPassword
);
export default userRouter;

src/routes/index.ts 中注册 userRouter

import userRouter from "./api/userRouter";

const routerGroup: RouterConf[] = [
    { path: "/api/auth", router: authRouter },
    { path: "/api/user", router: userRouter }
];

缓存模块

7.1 内存缓存(如 Redis)

Redis 配置和连接

首先,安装 ioredis

npm install ioredis

更新src/config/config.ts配置

// config.ts
import { readFileSync } from "fs";
import { join } from "path";
import { parse } from "yaml";
import logger from "../logger";
import { ConnectOptions } from "mongoose";
import { RedisOptions } from "ioredis";

// 定义数据库配置接口
interface DatabaseConfig {
  url: string;
  options: ConnectOptions;
}

// 定义基础配置接口
interface Config {
  APP: {
    name: string;
    port: number;
    host: string;
  };
  jwt_secret: string;
  cors: CorsConfig;
  rateLimit: RateLimitConfig;
  database: DatabaseConfig;
  redis: RedisOptions;
}
// 其他代码不变

然后,创建一个 Redis 客户端并进行配置。在 src/config/redis.ts 文件中:

import Redis from "ioredis";
import { getConfig } from "./config";

const redis = new Redis(getConfig().redis);

redis.on("connect", () => {
  console.log("Connected to Redis");
});

redis.on("error", (err) => {
  console.error("Redis error", err);
});

export default redis;
Redis 缓存使用

src/utils/cache.ts 文件中创建缓存工具:

// src/utils/cache.ts
import redis from '../config/redis';

export const setCache = async (key: string, value: any, ttl: number) => {
  await redis.set(key, JSON.stringify(value), 'EX', ttl);
};

export const getCache = async (key: string) => {
  const data = await redis.get(key);
  return data ? JSON.parse(data) : null;
};

export const delCache = async (key: string) => {
  await redis.del(key);
};

7.2 缓存策略和失效机制

缓存策略设计

缓存策略需要根据具体业务需求进行设计。常见的缓存策略包括:

  1. 读缓存:在读取数据时先检查缓存,如果缓存中有数据则直接返回,否则从数据库读取并缓存结果。
  2. 写缓存:在写入数据时同时更新缓存。
  3. 删除缓存:在删除数据时同时删除缓存。
缓存失效和更新机制

缓存失效和更新机制是保证缓存数据一致性的关键。常见的失效机制包括:

  1. TTL(Time-To-Live):为缓存数据设置一个过期时间,到期后自动失效。
  2. 主动失效:在数据更新或删除时主动删除或更新缓存。

src/utils/cache.ts 中,我们已经实现了 TTL 机制。在控制器中,我们通过在数据更新或删除时主动更新或删除缓存来实现主动失效。

7.3 更新用户相关接口以使用 Redis 缓存

获取用户信息

src/controllers/user/infoController.ts 中更新 getUserInfo 函数以使用缓存:

import { Request, Response } from "express";
import UserModel from "../../models/UserModel";
import { ClientBadRequestError, ClientUnauthorizedError } from "../../errors";
import { getCache, setCache, delCache } from "../../utils/cache";

const CACHE_TTL = 3600; // 缓存时间,单位为秒

// 获取用户信息
export const getUserInfo = async (req: Request, res: Response) => {
  try {
    const cacheKey = `user:${req.user!.id}`;
    let user = await getCache(cacheKey);

    if (!user) {
      user = await UserModel.findById(req.user!.id as string);
      if (!user) {
        throw new ClientUnauthorizedError("User not found");
      }
      await setCache(cacheKey, user, CACHE_TTL);
    }

    res.json(user);
  } catch (error: any) {
    throw new ClientBadRequestError(error.message);
  }
};

// 更新用户信息
export const updateUserInfo = async (req: Request, res: Response) => {
  try {
    const user = await UserModel.updateUser(req.user!.id, req.body);
    if (!user) {
      throw new ClientUnauthorizedError("User not found");
    }

    // 更新缓存
    const cacheKey = `user:${req.user!.id}`;
    await setCache(cacheKey, user, CACHE_TTL);

    res.json(user);
  } catch (error: any) {
    throw new ClientBadRequestError(error.message);
  }
};
用户登录

src/controllers/user/authController.ts 中更新 loginUser 函数以使用缓存:

import { Request, Response } from "express";
import jwt from "jsonwebtoken";
import bcrypt from "bcryptjs";
import {
  ClientBadRequestError,
  ClientForbiddenByRiskControlError,
  ClientUnauthorizedError,
} from "../../errors";
import UserModel from "../../models/UserModel";
import { getConfig } from "../../config/config";
import { setCache } from "../../utils/cache";
import { incrementLoginAttempts, resetLoginAttempts } from "../../cache/auth";

const CACHE_TTL = 3600; // 缓存时间,单位为秒
const MAX_LOGIN_ATTEMPTS = 10;
const LOGIN_ATTEMPTS_TTL = 24 * 60 * 60; // 1天,单位为秒

// 注册用户
export const registerUser = async (req: Request, res: Response) => {
  try {
    const { username, email, password } = req.body;

    // 加密密码
    const salt = await bcrypt.genSalt(10);
    const hashedPassword = await bcrypt.hash(password, salt);

    const user = await UserModel.create({
      username,
      email,
      password: hashedPassword,
      role: "user",
    });

    res.status(201).json(user);
  } catch (error: any) {
    throw new ClientBadRequestError(error.message);
  }
};

// 登录用户
export const loginUser = async (req: Request, res: Response) => {
  const { email, password } = req.body;
  const user = await UserModel.findOne({ email });

  if (!user) {
    throw new ClientUnauthorizedError("Invalid email or password");
  }

  const loginAttemptsKey = `loginAttempts:${email}`;
  const attempts = await incrementLoginAttempts(
    loginAttemptsKey,
    LOGIN_ATTEMPTS_TTL
  );

  if (attempts > MAX_LOGIN_ATTEMPTS) {
    console.log("风控");
    throw new ClientForbiddenByRiskControlError(
      "Too many login attempts. Please try again later."
    );
  }

  const isMatch = await bcrypt.compare(password, user.password);
  if (!isMatch) {
    throw new ClientUnauthorizedError("Invalid email or password");
  }

  // 重置登录尝试次数
  await resetLoginAttempts(loginAttemptsKey);

  user.lastLogin = new Date();
  await user.save();

  const token = jwt.sign(
    { id: user._id, username: user.username, role: user.role },
    getConfig().jwt_secret,
    { expiresIn: "15d" }
  );

  // 更新缓存
  const cacheKey = `user:${user._id}`;
  await setCache(cacheKey, user, CACHE_TTL);

  res.json({ token });
};
重置密码

src/controllers/user/securityController.ts 中更新重置密码的逻辑以使用缓存:

import { Request, Response } from "express";
import UserModel from "../../models/UserModel";
import { ClientBadRequestError } from "../../errors";
import { delCache } from "../../utils/cache";

// 管理员重置用户密码
export const resetPasswordByAdminHandler = async (req: Request, res: Response) => {
  try {
    const { userId, newPassword } = req.body;
    const password = await UserModel.resetPasswordByAdmin(userId, newPassword);

    // 删除缓存
    const cacheKey = `user:${userId}`;
    await delCache(cacheKey);

    res.json({ message: "Password has been reset", password });
  } catch (error: any) {
    throw new ClientBadRequestError(error.message);
  }
};

// 用户自己重置密码
export const resetPasswordByUserHandler = async (req: Request, res: Response) => {
  try {
    const { userId, oldPassword, newPassword } = req.body;
    await UserModel.resetPasswordByUser(userId, oldPassword, newPassword);

    // 删除缓存
    const cacheKey = `user:${userId}`;
    await delCache(cacheKey);

    res.json({ message: "Password has been reset" });
  } catch (error: any) {
    throw new ClientBadRequestError(error.message);
  }
};

通过以上步骤,我们实现了一个完整的缓存模块,并将其与用户相关的接口集成。这样可以提高应用程序的性能和响应速度,同时保证数据的一致性和可靠性。

中间件支持

8.1 请求解析(如 body-parser)

从 Express 4.16.0 开始,Express 自带了 body-parser 的功能,可以直接使用。

请求体解析配置和使用

src/middlewares/requestParser.ts 中:

import express, { Express } from "express";
import { getRealIp } from "../utils/ip";

declare global {
  namespace Express {
    interface Request {
      clientIP?: string;
    }
  }
}

export const requestParserMiddleware = (app: Express) => {
  app.use(express.json()); // 解析 JSON 格式的请求体
  app.use(express.urlencoded({ extended: true })); // 解析 URL 编码的请求体
  app.use((req, res, next) => {
    req.clientIP = getRealIp(req.headers);
    next()
  });
};
解析ipsrc/utils/ip.ts
export const getRealIp = (
  headers: any,
  ipHeaders: string[] = [
    "x-true-ip",
    "x-client-ip",
    "wl-proxy-client-ip",
    "remoteip",
    "x-real-ip",
    "x-forwarded-for",
  ]
): string | undefined => {
  for (const header of ipHeaders) {
    if (headers[header]) {
      let ip = headers[header];
      if (header === "x-forwarded-for") {
        // x-forwarded-for may contain multiple IPs, we need the first one
        ip = ip.split(",")[0].trim();
      }
      return ip;
    }
  }
  return undefined;
};

8.2 CORS 支持

安装 cors 中间件:

npm install cors
npm i --save-dev @types/cors
CORS 中间件配置和使用

src/middlewares/corsMiddleware.ts 中:

// corsMiddleware.ts
import cors from 'cors';
import { Express } from 'express';
import { getConfig } from '../config/config';

export const corsMiddleware = (app: Express) => {
  const corsOptions = getConfig().cors;
  app.use(cors(corsOptions));
};

8.3 请求限流

安装 express-rate-limit 中间件:

npm install express-rate-limit
请求限流工具和策略配置

src/middlewares/rateLimit.ts 中:

import rateLimit, { Options } from "express-rate-limit";
import { Express, Request } from "express";
import { getConfig } from "../config/config";

interface RateLimitOptions extends Options {
  customHeader?: string; // 自定义头部字段
}

export const rateLimitMiddleware = (app: Express) => {
  const { rateLimit: rateLimitConfig } = getConfig();

  const limiter = rateLimit({
    windowMs: rateLimitConfig.windowMs,
    max: rateLimitConfig.max,
    message: rateLimitConfig.message,
    keyGenerator: (req: Request): string => {
      // 从自定义头部获取 IP 地址
      const ip = rateLimitConfig.customHeader
        ? req.headers[rateLimitConfig.customHeader]
        : req.ip;
      return typeof ip === "string" ? ip : "";
    },
  });

  app.use(limiter);
};

8.4 安全头设置(如 helmet)

安装 helmet

npm install helmet
安全头中间件配置和使用

src/middlewares/helmet.ts 中:

import helmet from 'helmet';
import { Express } from 'express';

export const helmetMiddleware = (app: Express) => {
  app.use(helmet());
};

8.5 初始化中间件

src/middlewares/index.ts 中创建一个 initMiddleware 函数来注册所有中间件:

import { Express } from "express";
import { requestParserMiddleware } from "./requestParser";
import { rateLimitMiddleware } from "./rateLimit";
import { corsMiddleware } from "./corsMiddleware";
import morganMiddleware from "./loggerHandler";
import { helmetMiddleware } from "./helmet";

export const initMiddleware = (app: Express) => {
  // 注册安全头中间件
  helmetMiddleware(app);
  
  // 注册请求解析中间件
  requestParserMiddleware(app);
  
  // 使用 morgan 中间件记录请求日志
  app.use(morganMiddleware);
  
  // 注册 CORS 中间件
  corsMiddleware(app);

  // 注册请求限流中间件
  rateLimitMiddleware(app);
};

总结

通过以上步骤,我们成功地使用 Express 和 TypeScript 搭建了一个基础开发项目,并实现了配置管理、日志记录、错误处理、数据库封装、路由管理、用户验证授权、缓存和中间件等功能模块。这不仅为我们的项目提供了良好的架构和代码组织,还提高了代码的可读性和可维护性。希望本文能为正在学习或使用 Express 和 TypeScript 开发的你提供一些帮助和参考。未来,我们可以继续扩展和优化这个项目,添加更多高级功能和性能优化。