后台服务
- 使用 TypeScript 和 Koa2 框架来构建一个 Web 应用程序
- 用户登录、登出、注册、重置密码、检查重置密码令牌、更新用户密码
- 获取文章列表、头部文章、热门文章、轮播文章、所有频道、所有目录、作者详情、作者文章、文章评论、用户评论、搜索功能、添加文章浏览量。
- 发送更改邮箱验证码、更新邮箱、昵称、性别、描述、地址、头像、添加文章评论、回复用户评论、添加投票。
- 获取和审核作者公司信息、获取和审核普通作者信息、获取和设置文章评论状态。
- 添加、更新、删除、获取所有作者信息、公司作者认证和审核。
- 添加、更新、删除频道、获取所有频道信息。
- 添加、更新、删除封面、批量添加和删除封面、添加、删除分类、查询分类。
- 添加、更新、删除目录、子目录、表单、获取所有目录、子目录、表单信息。
- 添加、更新、删除调查问卷。
- 添加、删除用户、获取所有用户信息、设置用户状态、查看用户密码。
- 添加、更新、删除视频、批量添加和删除视频、添加、删除分类、查询分类。
- 添加、更新、删除角色、获取所有角色和权限信息。
- 添加、更新、删除用户、获取所有用户和头像信息、重新发送 URL、解锁用户、更新用户状态、邮箱、手机号。
- 获取概览信息、文章趋势、频道、表单、用户、作者信息。
- 处理 GitHub OAuth 登录。
- 处理 Google OAuth 认证、成功和失败的回调
- 添加事件跟踪功能
中间件
tokenError 中间件
import jwt from "jsonwebtoken";import CONFIG from "../config";
export default function () { return async (ctx, next) => { const { url, headers } = ctx.request; const token = headers.authorization; const clientType = headers["client-type"];
// 不需要 token 校验的 URL 列表 const noVerifyTokenUrls = [ /^\/management\/blog\/auth*/, /^\/oauth*/, /^\/common*/, /^\/track*/, /^\/h5\/blog\/post*/, /^\/h5\/blog\/auth*/, ];
// 检查 URL 是否在无需验证列表中 const requiresToken = !noVerifyTokenUrls.some((reg) => reg.test(url));
if (!requiresToken) { await next(); return; } if (!token) { ctx.error(ctx, 401); return; }
try { const decoded = jwt.verify(token.split(" ")[1], CONFIG.tokenSecret); const tokenKey = getTokenKey(decoded, clientType, headers); const redisToken = await ctx.redisDB.get(tokenKey); if (!redisToken || isTokenExpired(decoded)) { await ctx.redisDB.destroy(tokenKey); throw new Error("Token expired"); } await next(); } catch (err) { console.log(err); ctx.error(ctx, 401); } };}
function getTokenKey(decoded, clientType, headers) { const clientId = headers["client-id"]; if (clientType === "h5") { return `${decoded.username}-${decoded.id}-${clientId}-${clientType}`; } else if (clientType === "management") { return `${decoded.email}-${decoded.name}-${decoded.id}-${clientId}-${clientType}`; }}
function isTokenExpired(decoded) { const now = Math.floor(Date.now() / 1000); return decoded.exp < now;}
- 处理 JWT 令牌验证的中间件。
- 检查请求 URL 是否需要验证令牌。
- 验证令牌的有效性和过期时间。
- 如果令牌无效或过期,返回 401 错误。
- 客户端持久存储 client id,来实现多点登录。
- 有白名单功能,不需要验证 token 的 URL。
ctx response 封装
"use strict";
import { Context } from "koa";import { errorCode } from "./code";
/** * response * @param ctx * @param data 数据 * @param code 错误码 * @param message 错误描述 */const response = (ctx: Context, data, code, message) => { ctx.body = { data, code, success: code === 200 ? true : false, message, };};
/** * response 成功 * @param ctx * @param data 数据 */export const success = (ctx: Context, header, data) => { response(ctx, data, 200, "SUCCESS");};
/** * response 异常 * @param ctx * @param code 错误码 */export const error = (ctx: Context, heaadr, code: string | number) => { let message = ""; if (typeof code === "string" && code.includes("#")) { let extra = code.split("#")[1]; code = Number(code.split("#")[0]); message = ctx.request.headers["language"] == "zh_CN" ? extra + " " + errorCode[code][0] : extra + " " + errorCode[code][1]; } else { message = ctx.request.headers["language"] == "zh_CN" ? errorCode[code][0] : errorCode[code][1]; } response(ctx, null, code, message);};
- 定义了一些用于在 Koa 应用中处理 HTTP 响应的工具函数。这些函数提供了一种标准化的方式来向客户端发送成功和错误响应。
- 国际化处理,根据请求头的 Accept-Language 来返回对应的语言。
draft-socket 中间件
import { Server as SocketServer } from "socket.io";import CONFIG from "../config";import RedisDB from "./redis-db";import * as redisMysql from "./redis-mysql";
const draftPostRedisKey = CONFIG.draftPostRedisKey;
export function initSocket(server: any): void { console.log("init websocket"); const socketHandle = new SocketServer(server); let redisDB = new RedisDB(CONFIG.db.redis); socketHandle.on("connection", function (socket: any) { console.log("socket connected"); // 离开编辑文章页面 socket.on("disconnect", function () { console.info("[%s] DISCONNECTED", socket.sid); }); // 进入新增文章页面,获取已保存的草稿(可以为空) socket.on("getDraftPost", async function () { let data = await redisDB.get(draftPostRedisKey); if (!data) { data = await redisMysql.getDraftPostFromMysql(); socket.emit("getDraftPost", data); await redisDB.set(draftPostRedisKey, data); } else { socket.emit("getDraftPost", data); } }); // 实时保存文章内容 socket.on("saveDraftPost", async function (data: any) { let res = await redisDB.set(draftPostRedisKey, data); socket.emit("saveDraftPost", res); }); // 保存后清空已保存的文章草稿 socket.on("clearDraftPost", async function () { await redisDB.destroy(draftPostRedisKey); await redisMysql.clearDraftPostOfMysql(); socket.emit("clearDraftPost", true); }); });}
- 文件用于初始化和处理 WebSocket 连接,主要功能是处理文章草稿的实时保存和获取
- connection:当客户端连接时触发,打印连接日志。
- disconnect:当客户端断开连接时触发,打印断开连接日志。
- getDraftPost:当客户端请求获取草稿文章时触发,先从 Redis 获取数据,如果没有则从 MySQL 获取并缓存到 Redis,然后将数据发送给客户端。
- saveDraftPost:当客户端请求保存草稿文章时触发,将数据保存到 Redis,并将保存结果发送给客户端。
- clearDraftPost:当客户端请求清空草稿文章时触发,删除 Redis 中的草稿数据,并清空 MySQL 中的草稿数据,然后将清空结果发送给客户端。
minio s3 中间件
- 雨云第三方对象存储服务,用于存储用户上传的文件
- 分别把不同类型的文件存储到 bucket 中,比如 file 类型,url,base64 等
- 先临时把文件存储到本地,然后再上传到 minio s3 中,最后删除本地文件
session 和 redis 中间件
import Redis from 'ioredis';import CONFIG from '../config';import { isEqual } from 'lodash';
const salt = CONFIG.db.db_salt;const saltBuffer = Buffer.from(salt, 'base64');
/** * Encrypt input password * * @param {string} password * @return {string} * @api public */class RedisDB { public redis: any; private redis2: any;
constructor(redisConfig: any) { this.redis = new Redis(redisConfig); }
async get<T>(key: string): Promise<T | null> { let data = await this.redis.get(key); return JSON.parse(data); }
async set<T>( key: string, data: T, maxAge = 7 * 24 * 60 * 60 * 1000, ): Promise<string> { try { // Use redis set EX to automatically drop expired sessions await this.redis.set(key, JSON.stringify(data), 'EX', maxAge / 1000); return Promise.resolve(key); } catch (e) { } return 'success'; }
async destroy(key: string): Promise<number> { // const md5_key = crypto // .pbkdf2Sync(key, saltBuffer, 10000, 64, "sha1") // .toString("base64"); return await this.redis.del(key); }
async update<T>(key: string, data: T): Promise<string> { try { // 获取当前 key 的剩余过期时间(以秒为单位) const ttl = await this.redis.ttl(key);
if (ttl > 0) { // 如果 key 有有效的剩余过期时间,使用它来设置新值 await this.redis.set(key, JSON.stringify(data), 'EX', ttl); } else { // 如果 key 没有设置过期时间或已过期,只设置新值 await this.redis.set(key, JSON.stringify(data)); } return Promise.resolve(key); } catch (e) { // 错误处理 console.error(e); throw e; // 或返回错误信息 } }
// 获取所有的value
async getAllValue(): Promise<any> { const keys = await this.redis.keys('*'); const values = await this.redis.mget(keys); return values.map((value: string) => { return JSON.parse(value); }); }
// 找出value对应的key
async getKeyByValue(value: any): Promise<string | null> { const keys = await this.redis.keys('*'); const values = await this.redis.mget(keys); const index = values.findIndex((val: string) => { return isEqual(JSON.parse(val), value); }); return keys[index]; }}
export default RedisDB;
- 文件定义了一个 RedisDB 类,该类提供了与 Redis 数据库交互的方法。该类包括设置、获取、更新和删除键的方法,以及处理 Redis 数据的其他实用方法
- salt 和 saltBuffer 用于加密目的
- get: 从 Redis 获取并解析一个值。
- set: 将一个值存储到 Redis,并可选设置过期时间。
- destroy: 从 Redis 删除一个键。
- update: 更新 Redis 中的一个值,同时保留其 TTL。
- getAllValue: 获取 Redis 中的所有值。
- getKeyByValue: 使用深度比较查找值对应的键。
mysql-async
import mysql from 'mysql2';import CONFIG from '../config';
const pool = mysql.createPool({ host: CONFIG.db.mysql.host, user: CONFIG.db.mysql.user, port: CONFIG.db.mysql.port, password: CONFIG.db.mysql.password, database: CONFIG.db.mysql.database, connectionLimit: CONFIG.db.mysql.connectionLimit,});
const query = function (sqls: string | string[], values?: any): Promise<any> { return new Promise((resolve, reject) => { pool.getConnection((err, connection) => { if (err) { console.log(err); return reject(err); } if (typeof sqls === 'string') { connection.query(sqls, values, (err, rows) => { connection.release(); if (err) { return reject(err); } else { return resolve(rows); } }); } else { // 开始事务 connection.beginTransaction((err) => { if (err) { connection.release(); return reject(err); } // 定义一个数组,用于保存多条 SQL 语句的执行结果 const results = []; // 遍历 SQL 语句数组,依次执行每条 SQL 语句 Promise.all( sqls.map((sql) => { return new Promise<void>((resolve, reject) => { connection.query(sql, values, (err, rows) => { if (err) { // 如果执行出错,回滚事务并释放连接 connection.rollback(() => { connection.release(); console.log(err.message, sql); reject(err); }); } else { // 将执行结果保存到数组中 results.push(rows); resolve(); } }); }); }) ) .then(() => { // 如果所有 SQL 语句都执行成功,提交事务并释放连接 connection.commit((err) => { if (err) { connection.rollback(() => { connection.release(); reject(err); }); } else { connection.release(); resolve(results); } }); }) .catch((err) => { // 如果有任何一条 SQL 语句执行失败,回滚事务并释放连接 connection.rollback(() => { connection.release(); reject(err); }); }); }); } }); });};
export default query;
- 用于与 MySQL 数据库进行异步交互的模块。该模块使用 mysql2 库创建了一个连接池,并提供了一个 query 函数来执行 SQL 查询。该函数支持单条 SQL 语句和事务处理。
- 包含导入,连接,查询,如果 sqls 是数组,则开启事务,依次执行每条 SQL 语句,并在所有语句成功后提交事务,否则回滚事务。
route decorator 中间件
- 定义了一些装饰器,用于简化路由和文件上传的处理,装饰器可以将路由和文件上传的逻辑从业务代码中分离出来,装饰器使得路由和文件上传的定义更加直观和清晰,便于理解和维护
- 导入:
reflect-metadata:用于反射元数据。 koa-multer:用于处理文件上传的中间件。 - 常量:
ROUTE_PREFIX:用于存储路由元数据的键。 HTTP_METHODS:定义了常见的 HTTP 方法。 - 接口:
RouteMeta:定义了路由元数据的结构,包括路径、方法和处理函数。 - 装饰器函数: Route:用于定义路由的装饰器,接受路径和方法作为参数。 Upload:用于处理文件上传的装饰器,接受字段名和目标路径作为参数。
发送邮件中间件
- 用于发送电子邮件。该函数使用 nodemailer 和 nodemailer-smtp-transport 库来配置和发送邮件