系列文章:
- Nest.js Kafka 实现微服务
- Nest.js、gRPC 和 Emp.js 构建高性能的微前端和微服务架构-Client端
- Nest.js、gRPC 和 Emp.js 构建高性能的微前端和微服务架构-Server端
- 前端日志监控系统-上报SDK
- 前端日志监控系统-后端
背景
在内部项目中,微信授权登录功能的实现存在一些问题。首先,授权流程通常在页面加载后才触发,导致用户在授权成功后返回授权前的页面时,页面无法及时获取授权信息,从而引发一系列 Bug。其次,由于缺乏一套有效的会话管理机制(如 Session 或 Token),每次访问都需要重新授权,这不仅降低了用户体验,还增加了开发和维护的复杂性。此外,项目中缺乏统一的架构设计,每次新增功能都需要重新开发授权逻辑,导致开发效率低下且代码冗余。
为了解决这些问题,决定重新设计和实现微信授权登录流程。通过结合 Nest.js 和 Emp.js,我们构建了一个高效、可扩展的授权登录系统。Nest.js 作为后端服务,负责处理复杂的授权逻辑、会话管理以及接口聚合;而 Emp.js 作为微前端框架,简化了前端开发流程,提升了交互效率和代码可维护性。两者的结合不仅优化了授权流程,还通过模块化设计为后续功能扩展提供了统一的技术架构,显著提升了系统的稳定性和开发效率。
在这一方案中,BFF(Backend for Frontend)层 起到了关键作用。BFF 层作为前后端的桥梁,负责将复杂的后端逻辑抽象为前端友好的接口,同时聚合多个后端服务的数据,减少前端的请求复杂度。通过 BFF 层,我们实现了微信授权的统一管理,确保了前后端的高效协作和业务逻辑的清晰分离。这不仅提升了开发效率,还增强了系统的可维护性和扩展性。
本文将详细介绍这一实现过程,帮助开发者快速掌握微信授权登录技术,并为类似项目提供参考。通过本文,读者将了解如何设计一个无缝衔接的授权流程,解决页面加载与授权信息的同步问题,并通过会话管理机制避免重复授权,从而提升用户体验和开发效率。同时,本文还将分享如何通过模块化设计构建可复用的技术架构,为未来功能扩展奠定基础。
前言
在现代 Web 应用开发中,前后端分离架构已成为主流,但随着业务复杂度的增加,前端需要对接多个后端服务,导致开发效率降低、接口调用冗余、以及数据聚合困难等问题。为了解决这些问题,BFF(Backend for Frontend) 层应运而生。BFF 层作为前后端的桥梁,能够为前端提供定制化的接口,聚合多个后端服务的数据,并处理页面渲染、权限校验等逻辑,从而显著提升开发效率和用户体验。
本文将详细介绍如何基于 Nest.js 作为 BFF 层实现微信授权登录功能。Nest.js 是一个高效、模块化的 Node.js 框架,其强大的依赖注入机制和模块化设计使其成为构建 BFF 层的理想选择。通过 Nest.js,我们可以轻松实现微信授权登录的核心逻辑,包括用户授权、Token 获取、用户信息拉取等,同时支持页面渲染和接口聚合,为前端提供一体化的解决方案。
本文将涵盖以下内容:
- BFF 层的核心作用与优势:介绍 BFF 层在微信授权登录场景中的价值。
- 微信授权登录的流程与原理:解析微信 OAuth2.0 授权登录的实现机制。
- Nest.js 作为 BFF 层的实现方案:详细讲解如何使用 Nest.js 实现微信授权登录、接口聚合和页面渲染。
- 前后端协作的最佳实践:探讨如何通过 BFF 层优化前后端协作,提升开发效率。
- 完整代码示例:提供可运行的代码示例。
通过阅读本文,读者将掌握如何基于 Nest.js 构建一个高效、可扩展的 BFF 层,并实现微信授权登录功能。无论是从技术实现还是架构设计角度,本文都将为开发者提供有价值的参考。
1. BFF 层的核心作用与优势
1.1 BFF 层的定义与价值
BFF(Backend for Frontend) 层是为前端量身定制的后端服务,其主要作用是为前端提供定制化的接口和数据聚合能力,同时支持页面渲染、权限校验等逻辑。在微信授权登录场景中,BFF 层不仅负责处理授权流程,还能够在渲染页面前完成微信授权,确保页面加载时已具备用户授权信息,从而提升用户体验。
1.2 BFF 层的核心作用
- 接口聚合:BFF 层可以聚合多个后端服务的接口,减少前端请求次数,简化前端逻辑。
- 页面渲染:BFF 层支持在服务端渲染页面(SSR),能够在页面加载前完成微信授权,确保页面渲染时已包含用户授权信息。
- 授权管理:BFF 层统一管理微信授权流程,包括授权跳转、Token 获取、用户信息拉取等,避免前端直接处理复杂的授权逻辑。
- 会话管理:BFF 层维护用户的会话状态(如 Session 或 Token),避免每次访问都需要重新授权。
1.3 BFF 层的优势
- 提升用户体验:在页面渲染前完成授权,确保用户访问页面时已具备授权信息,避免页面加载后重新跳转授权。
- 简化前端逻辑:将复杂的授权流程和接口聚合逻辑封装在 BFF 层,前端只需关注页面交互和展示。
- 增强安全性:将敏感操作(如 Token 获取)放在后端,避免前端暴露关键信息。
- 提高开发效率:通过模块化设计,BFF 层可以为不同前端(如 Web、小程序、App)提供定制化的接口和页面渲染逻辑,减少重复开发。
- 统一技术架构:BFF 层为后续功能扩展提供了统一的技术架构,便于维护和扩展。
2. 微信授权登录
微信授权登录基于 OAuth2.0 协议,是一种用户身份验证机制,允许第三方应用通过微信用户的授权获取其基本信息(如昵称、头像等)。该功能广泛应用于 Web 应用、移动应用和小程序中,为用户提供便捷的登录方式,同时为开发者提供安全、可靠的身份验证方案。
2.1 测试公众号
在开发和测试微信授权登录功能时,使用 微信测试公众号 是一个理想的选择。测试公众号提供与正式公众号相同的接口能力,且无需经过微信官方审核,非常适合开发和调试。以下是申请和使用测试公众号的详细步骤:
1. 申请测试公众号
-
访问微信公众平台测试账号申请页面:
- 打开浏览器,访问 微信公众平台测试账号申请页面。
-
登录微信账号:
- 使用个人微信账号扫码登录。若无微信账号,需先注册。
-
获取测试公众号信息:
-
登录成功后,系统会自动生成一个测试公众号,并显示以下关键信息:
- AppID:测试公众号的唯一标识。
- AppSecret:用于接口调用的密钥。
- 测试号二维码:用于关注测试公众号。
-
-
关注测试公众号:
- 使用微信扫描测试号二维码,关注测试公众号。只有关注了测试公众号的用户才能进行授权登录测试。
2. 配置测试公众号
-
配置授权回调域名:
-
在测试公众号页面中,找到 接口配置信息 部分。
-
在 授权回调页面域名 中填写你的服务器域名(例如:
test.com)。 -
注意:
- 域名无需带
http://或https://。 - 域名必须是有效的、已备案的域名。
- 本地开发可使用内网穿透工具(如 ngrok)生成临时域名。
- 域名无需带
-
-
配置 JS 接口安全域名(可选):
- 如需使用微信的 JS-SDK 功能(如分享、拍照等),可在 JS 接口安全域名 中配置域名。
-
配置网页授权获取用户基本信息:
- 在测试公众号页面中,找到 网页账号 部分。
- 在 网页授权获取用户基本信息 中填写你的回调页面路径(例如:
/auth/callback)。
在完成测试公众号的申请和配置后,接下来我们将进入微信授权登录功能的开发阶段。
3、Nest.js 作为 BFF 层的实现方案
3.1 前端跳转至微信授权页面
用户通过浏览器访问 Node 服务提供的页面时,Node 服务会检测用户是否已授权或登录。若用户未授权或未登录,Node 服务将触发微信授权流程。
为此,我们创建了一个中间件来处理页面路由访问时的微信授权逻辑。该中间件的主要职责包括:
- 检查用户是否已授权。
- 在非微信环境中直接放行请求。
- 处理微信授权流程,包括重定向至微信授权页面和处理授权回调。
微信授权分为两种方式:
-
标准授权(
snsapi_userinfo) :- 用户需手动确认授权,通常会弹出授权提示框。
- 适用于需要获取用户详细信息的场景,如用户昵称、头像等。
-
静默授权(
snsapi_base) :- 无需用户手动确认,自动完成授权。
- 适用于仅需获取用户 OpenID 的场景,如用户身份识别。
这两种授权方式可根据业务需求灵活选择,以提升用户体验。
3.2 getMatchedDomain 方法的作用
当用户已在 A 公众号登录并访问某个页面时,若需授权 B 公众号,getMatchedDomain 方法通过路由与域名的映射关系,支持以下功能:
- 跨公众号用户身份识别:识别用户在 B 公众号的身份,实现跨平台用户匹配。
- 数据互通与业务整合:打通 A、B 公众号的数据,支持业务协同与资源共享。
- 统一用户体系:建立跨公众号的统一用户体系,提升运营效率与用户体验。
这种设计适用于多公众号协同运营的场景,但需严格遵守微信平台规则,确保用户隐私保护与数据安全。
private getMatchedDomain(originalUrl: string): string {
logger.log('获取匹配的授权域名');
for (const [route, domain] of ROUTE_DOMAIN_MAP.entries()) {
if (originalUrl.startsWith(route)) {
logger.log(`匹配到的域名: ${domain}`);
return domain;
}
}
logger.log('未匹配到任何域名');
return '';
}
3.3 中间件的核心功能
-
微信授权流程管理:
- 检查用户是否已授权(通过
code参数和authorized状态)。 - 若用户未授权,则根据当前路由和配置,构建微信授权 URL 并重定向用户至微信授权页面。
- 用户授权后,微信会回调至指定 URL,并附带
code参数,中间件通过code获取用户信息和access_token。
- 检查用户是否已授权(通过
-
跨公众号授权支持:
- 通过
getMatchedDomain方法,支持不同路由映射到不同的授权域名(如 A 公众号和 B 公众号)。 - 适用于多公众号协同运营的场景,实现跨公众号的用户身份识别和数据互通。
- 通过
-
静默授权与标准授权:
- 根据路由配置(
WECHAT_SILENT_AUTH_ROUTES),判断是否需要静默授权(snsapi_base)或标准授权(snsapi_userinfo)。 - 静默授权适用于无需用户手动确认的场景,标准授权适用于需要获取用户详细信息的场景。
- 根据路由配置(
-
用户信息与 Token 管理:
- 授权成功后,通过
wechatAuthService.getAccessToken获取用户信息和access_token。 - 将用户信息和
access_token存储到session和cookie中,方便后续请求使用。
- 授权成功后,通过
-
重定向与回调处理:
- 授权成功后,根据
redirectUrl参数或当前 URL,将用户重定向至目标页面。 - 支持在重定向 URL 中附加授权状态(
authorized=true),避免重复授权。
- 授权成功后,根据
3.4 中间件的主要流程
-
用户访问路由:
- 检查用户是否已登录(通过
req.session.user)。 - 若用户已登录,则直接放行。
- 检查用户是否已登录(通过
-
非微信环境处理:
- 若当前环境不是微信(通过
isWechat方法判断),则直接放行。
- 若当前环境不是微信(通过
-
授权状态检查:
- 检查请求中是否包含
code参数(微信授权回调时附带)。 - 若已授权(
authorized=true),则直接放行。
- 检查请求中是否包含
-
未授权处理:
- 若未授权,则根据当前路由获取匹配的授权域名(
getMatchedDomain)。 - 构建授权 URL(
buildAuthUrl),并根据路由配置选择授权方式(静默授权或标准授权)。 - 重定向用户至微信授权页面。
- 若未授权,则根据当前路由获取匹配的授权域名(
-
授权回调处理:
- 微信授权成功后,回调至指定 URL,并附带
code参数。 - 通过
code获取用户信息和access_token,并存储到session和cookie中。 - 根据
redirectUrl或当前 URL,重定向用户至目标页面。
- 微信授权成功后,回调至指定 URL,并附带
-
错误处理:
- 若授权流程中出现错误,记录错误日志并调用
next(error),交由后续中间件或错误处理器处理。
- 若授权流程中出现错误,记录错误日志并调用
3.5 中间件的作用
-
支持多公众号相互授权:
- 通过路由与域名的映射关系,支持不同公众号的授权逻辑,适用于多公众号协同运营的场景。
-
用户身份识别与数据互通:
- 通过微信授权获取用户信息,实现跨公众号的用户身份识别和数据共享。
-
静默授权与标准授权:
- 根据业务需求,灵活选择静默授权或标准授权,提升用户体验。
-
授权流程自动化:
- 自动处理微信授权流程,减少用户操作步骤,提升系统效率。
完整代码如下:
import { Injectable, NestMiddleware } from '@nestjs/common';
import { Request, Response, NextFunction } from 'express';
import { fillParams, goWechatUrl, isWechat } from '@app/utils/util';
import { ROUTE_DOMAIN_MAP } from '@app/constants/route-domain.constant';
import { wechatConfig } from '@app/config';
import { CacheService } from '@app/processors/redis/cache.service';
import { createLogger } from '@app/utils/logger';
import { WechatAuthService } from '@app/modules/wechatAuth/wechat-auth.service';
import { WECHAT_SILENT_AUTH_ROUTES } from '@app/constants/route-domain.constant';
// 创建一个日志记录器实例,用于记录日志信息
const logger = createLogger({
scope: 'WechatAuthMiddleware',
time: true,
});
/**
* 微信授权中间件
* 用于处理微信网页授权流程
*/
@Injectable()
export class WechatAuthMiddleware implements NestMiddleware {
constructor(
private readonly cacheService: CacheService,
private readonly wechatAuthService: WechatAuthService,
) {}
/**
* 获取当前路由对应的授权域名
* @param originalUrl - 请求的原始URL
* @returns 匹配的授权域名
*/
private getMatchedDomain(originalUrl: string): string {
logger.log('获取匹配的授权域名');
for (const [route, domain] of ROUTE_DOMAIN_MAP.entries()) {
if (originalUrl.startsWith(route)) {
logger.log(`匹配到的域名: ${domain}`);
return domain;
}
}
logger.log('未匹配到任何域名');
return '';
}
/**
* 构建授权URL
* @param req - 请求对象
* @param matchedDomain - 匹配的授权域名
* @returns 构建的授权URL
*/
private buildAuthUrl(req: Request, matchedDomain: string): string {
logger.log('开始构建授权URL');
const currentUrl = `${req.protocol}://${req.get('host')}${req.originalUrl}`;
logger.log(`当前URL: ${currentUrl}`);
if (!matchedDomain) {
logger.log('没有匹配的域名,返回当前URL');
return currentUrl;
}
// 构建授权URL
const authUrl = `${req.protocol}://${matchedDomain}${req.originalUrl}`;
logger.log(`构建的授权URL: ${authUrl}`);
// 填充参数到授权URL中
const redirectUrl = fillParams(
{
redirectUrl: encodeURIComponent(currentUrl),
},
authUrl,
);
// 记录授权URL
logger.log('最终授权URL:', redirectUrl);
return redirectUrl;
}
/**
* 处理微信授权重定向
* @param redirectUrl - 重定向的URL
* @returns 处理后的重定向URL
*/
private handleRedirect(redirectUrl: string): string {
logger.log('处理微信授权重定向');
const url = fillParams(
{
authorized: 'true',
},
redirectUrl,
['code', 'state'],
);
logger.log(`处理后的重定向URL: ${url}`);
return url;
}
async use(req: Request, res: Response, next: NextFunction) {
const user = req.session.user;
logger.info('user 用户信息', user);
if (user?.userId) {
logger.log('用户已存在,直接通过授权');
return next();
}
try {
const { appId } = wechatConfig;
logger.log('处理请求URL:', req.originalUrl);
// 1. 非微信环境直接通过
if (!isWechat(req)) {
logger.log('非微信环境,直接通过');
return next();
}
// 2. 检查授权状态
logger.log('检查授权状态');
const code = req.query.code as string;
const authorized = req.query.authorized as string;
// 3. 已授权直接通过
if (authorized === 'true') {
logger.log('已授权,直接通过');
return next();
}
// 4. 无code则重定向授权
if (!code) {
logger.log('无授权码,开始授权流程');
// 获取匹配的授权域名,这里可能同时静默授权两次不同的域名
const matchedDomain = this.getMatchedDomain(req.originalUrl);
// 构建授权URL
const authUrl = this.buildAuthUrl(req, matchedDomain);
// 判断是否为静默授权路由
const scope = WECHAT_SILENT_AUTH_ROUTES.includes(req.path)
? 'snsapi_base'
: 'snsapi_userinfo';
const redirectUrl = goWechatUrl(authUrl, appId, scope, 'STATE');
logger.log(`重定向到微信授权URL: ${redirectUrl}`);
return res.redirect(redirectUrl);
}
// 5. 处理授权回调
logger.log('处理授权回调');
// 获取重定向URL
const redirectUrl = req.query.redirectUrl as string;
const { user: userInfo, token } =
await this.wechatAuthService.getAccessToken(code);
res.cookie('jwt', token.accessToken, {
sameSite: true,
httpOnly: true,
});
res.cookie('userId', userInfo.userId);
req.session.user = userInfo;
logger.info(user, '获取用户信息成功===');
logger.info(token, '获取token成功===');
// 如果重定向URL存在,则处理授权回调
if (redirectUrl) {
const decodedRedirectUrl = decodeURIComponent(redirectUrl);
logger.log(`重定向URL存在,处理授权回调: ${decodedRedirectUrl}`);
return res.redirect(this.handleRedirect(decodedRedirectUrl));
} else {
// 如果重定向URL不存在,则重定向到当前URL
logger.log('重定向URL不存在,重定向到当前URL');
return res.redirect(this.handleRedirect(req.originalUrl));
}
} catch (error) {
logger.error('授权处理出错:', error); // 添加错误日志记录
next(error);
}
}
}
在授权逻辑中,已登录用户不会进行其他公众号的静默授权。需要根据具体业务逻辑自行处理此情况。
该中间件并非适用于所有场景,而是针对特定页面路由生效。通过路由与域名的映射关系,它能够灵活处理不同页面的授权需求,尤其适用于多公众号协同运营的场景。开发者需根据业务需求配置路由规则,确保授权逻辑的精准匹配。
在 Nest.js 中,可以通过 apply 方法将微信认证中间件(WechatAuthMiddleware)单独应用到指定的路由控制器(如 RouterController)。这种方式确保中间件仅对特定路由生效,避免不必要的授权流程,从而提升系统效率和用户体验。
// 单独应用微信认证中间件到RouterModule
consumer.apply(WechatAuthMiddleware).forRoutes(RouterController); // RouterModule中定义的路由
4、Redis 作为 Session 与缓存
4.1 Redis和 ioredis 区别
首先我们简单了解下ioredis 和 redis 是 Node.js 中两个常用的 Redis 客户端库,它们都用于与 Redis 服务器进行交互,但在功能、性能和用法上有一些区别。以下是它们的详细对比
| 特性 | redis 库 | ioredis 库 |
|---|---|---|
| API 风格 | 回调函数为主,需手动 Promise 化 | 原生支持 Promise,同时支持回调 |
| 集群支持 | 需要额外配置 | 原生支持 Redis 集群和哨兵模式 |
| 性能 | 较好 | 更优,适合高并发场景 |
| 功能丰富度 | 基础功能 | 支持更多高级功能(如管道、Lua 脚本) |
| 自动重连 | 不支持 | 支持 |
| 社区活跃度 | 较老,社区支持稳定 | 较新,社区活跃 |
| 学习曲线 | 较简单 | 稍复杂,但功能更强大 |
在本项目中使用的是ioredis, 因为ioredis 是一个更现代的 Redis 客户端库,功能更强大,性能更优,支持更多高级特性。
4.2 ioredis 模式
ioredis 是一个功能强大的 Redis 客户端库,支持多种 Redis 部署模式。以下是 ioredis 提供的主要模式及其特点:
1. 单节点模式(Standalone)
这是最简单的 Redis 部署模式,适用于单机或单实例 Redis 服务器。 特点:
- 直接连接到一个 Redis 实例。
- 适合小型应用或开发环境。
const Redis = require('ioredis');
const redis = new Redis({
host: '127.0.0.1', // Redis 服务器地址
port: 6379, // Redis 服务器端口
password: 'your_password', // 认证密码(可选)
});
redis.set('key', 'value').then(() => {
return redis.get('key');
}).then((result) => {
console.log('Get key:', result);
});
2. 主从复制模式(Master-Slave Replication)
主从复制模式通过将数据从主节点(Master)复制到从节点(Slave),实现读写分离和数据备份。
特点:
- 主节点负责写操作,从节点负责读操作。
- 从节点可以扩展读性能,并提供数据冗余。
const Redis = require('ioredis');
const redis = new Redis({
host: '127.0.0.1', // 主节点地址
port: 6379,
password: 'your_password',
});
const slaveRedis = new Redis({
host: '127.0.0.1', // 从节点地址
port: 6380,
password: 'your_password',
role: 'slave', // 指定为从节点
});
3. 哨兵模式(Sentinel)
哨兵模式用于实现 Redis 的高可用性(High Availability),通过哨兵节点监控主从节点的健康状态,并在主节点故障时自动进行故障转移。
特点:
- 自动故障检测和主从切换。
- 适合对高可用性要求较高的场景。
const Redis = require('ioredis');
const redis = new Redis({
sentinels: [
{ host: 'sentinel1.example.com', port: 26379 }, // 哨兵节点 1
{ host: 'sentinel2.example.com', port: 26379 }, // 哨兵节点 2
],
name: 'mymaster', // 主节点名称
password: 'your_password', // 主节点密码
sentinelPassword: 'sentinel_password', // 哨兵节点密码(可选)
});
4. 集群模式(Cluster)
Redis 集群模式通过分片(Sharding)将数据分布到多个节点上,支持水平扩展和高性能。
特点:
- 数据分片存储,支持大规模数据和高并发。
- 自动数据分片和节点管理。
- 适合需要高扩展性和高性能的场景。
const Redis = require('ioredis');
const redis = new Redis.Cluster([
{ host: '127.0.0.1', port: 7000 }, // 集群节点 1
{ host: '127.0.0.1', port: 7001 }, // 集群节点 2
{ host: '127.0.0.1', port: 7002 }, // 集群节点 3
], {
redisOptions: {
password: 'your_password', // 集群节点密码
},
});
5. 管道模式(Pipeline)
管道模式允许将多个 Redis 命令一次性发送到服务器,减少网络往返时间,提升性能。
特点:
- 批量执行命令,减少网络延迟。
- 适合需要批量操作的场景。
const Redis = require('ioredis');
const redis = new Redis();
const pipeline = redis.pipeline();
pipeline.set('key1', 'value1');
pipeline.set('key2', 'value2');
pipeline.exec().then((results) => {
console.log('Pipeline results:', results);
});
6. 事务模式(Transaction)
事务模式通过 MULTI 和 EXEC 命令实现原子性操作,确保多个命令按顺序执行。
特点:
- 保证命令的原子性。
- 适合需要事务支持的场景。
const Redis = require('ioredis');
const redis = new Redis();
redis.multi()
.set('key1', 'value1')
.set('key2', 'value2')
.exec().then((results) => {
console.log('Transaction results:', results);
});
7. 发布订阅模式(Pub/Sub)
发布订阅模式允许客户端订阅频道并接收消息,适用于消息通知和实时通信场景。
特点:
- 支持消息的发布和订阅。
- 适合实时通信和事件驱动的场景。
const Redis = require('ioredis');
const redis = new Redis();
// 订阅频道
redis.subscribe('news', (err) => {
if (err) console.error('Subscribe error:', err);
});
// 接收消息
redis.on('message', (channel, message) => {
console.log(`Received message from ${channel}: ${message}`);
});
// 发布消息
redis.publish('news', 'Hello, world!');
8. Lua 脚本支持
ioredis 支持通过 eval 命令执行 Lua 脚本,适用于需要复杂逻辑或原子性操作的场景。
特点:
- 在 Redis 服务器端执行 Lua 脚本。
- 适合需要复杂逻辑或原子性操作的场景。
const Redis = require('ioredis');
const redis = new Redis();
const script = `
return redis.call('set', KEYS[1], ARGV[1])
`;
redis.eval(script, 1, 'key', 'value').then((result) => {
console.log('Lua script result:', result);
});
根据业务需求选择合适的模式,可以充分发挥 Redis 的性能和功能优势。不过建议像BFF层业务使用哨兵模式比较好。
4.3 Redis 模块
缓存和 Session 是现代 Web 应用开发中不可或缺的技术。通过合理的设计和实现,它们能够显著提升系统性能、优化用户体验,并增强系统的可扩展性和稳定性。在后续的开发中,我们将探讨ioredis实现高效的缓存和 Session 管理。
1. 模块定义与全局声明
@Global()
@Module({
providers: [RedisService, CacheService],
exports: [RedisService, CacheService],
})
export class RedisCoreModule { ... }
@Global():声明为全局模块,其他模块无需显式导入即可使用其服务。RedisService:封装 Redis 连接与基础操作(如get/set)。CacheService:提供业务层缓存逻辑(如防雪崩、击穿、穿透)。
2. 同步配置(forRoot)
static forRoot(options: RedisModuleOptions): DynamicModule {
const redisOptionsProvider = { provide: getRedisOptionsToken(), useValue: options };
const redisConnectionProvider = { provide: getRedisConnectionToken(), useValue: createRedisConnection(options) };
return { module: RedisCoreModule, providers: [redisOptionsProvider, redisConnectionProvider], exports: [...] };
}
-
功能:通过同步方式配置 Redis 连接(适用于简单场景)。
-
核心方法:
createRedisConnection:根据配置创建 Redis 客户端实例(支持单机、哨兵、集群)。getRedisOptionsToken/getRedisConnectionToken:生成唯一 Token,避免依赖冲突。
3. 异步配置(forRootAsync)
static forRootAsync(options: RedisModuleAsyncOptions): DynamicModule {
const redisConnectionProvider = {
provide: getRedisConnectionToken(),
useFactory: (options: RedisModuleOptions) => createRedisConnection(options),
inject: [getRedisOptionsToken()],
};
return { module: RedisCoreModule, providers: [...this.createAsyncProviders(options), redisConnectionProvider] };
}
-
功能:支持从异步来源(如配置文件、远程服务)加载配置。
-
配置方式:
useClass:通过类工厂生成配置(如从ConfigService读取)。useFactory:自定义工厂函数动态生成配置。useExisting:复用已有的配置提供者。
4. 异步提供者工厂
public static createAsyncProviders(options: RedisModuleAsyncOptions): Provider[] {
if (options.useFactory || options.useExisting) {
return [this.createAsyncOptionsProvider(options)];
}
return [this.createAsyncOptionsProvider(options), { provide: options.useClass, useClass: options.useClass }];
}
- 作用:根据不同的异步配置方式(类、工厂、实例),生成对应的依赖注入提供者。
- 错误处理:验证配置有效性,防止无效的配置方式。
完整代码如下:
import {
RedisModuleAsyncOptions,
RedisModuleOptions,
RedisModuleOptionsFactory,
RedisSingleOptions,
} from '@app/interfaces/redis.interface';
import { DynamicModule, Global, Module, Provider } from '@nestjs/common';
import {
createRedisConnection,
getRedisConnectionToken,
getRedisOptionsToken,
} from './redis.util';
import { RedisService } from './redis.service';
import { createLogger } from '@app/utils/logger';
import { CacheService } from './cache.service';
// 创建日志记录器
const logger = createLogger({ scope: 'RedisCoreModule', time: true });
/**
* Redis核心模块
* 提供Redis连接和缓存服务
*/
@Global() // 声明为全局模块
@Module({
imports: [],
providers: [RedisService, CacheService], // 提供Redis服务和缓存服务
exports: [RedisService, CacheService], // 导出服务供其他模块使用
})
export class RedisCoreModule {
/**
* 同步方式初始化Redis模块
* @param options Redis配置选项
* @returns 动态模块配置
*/
static forRoot(options: RedisModuleOptions): DynamicModule {
// 打印配置日志
logger.info('初始化Redis模块配置', {
type: options.type,
...(options.type === 'single' && { url: options.url }), // 仅当单机模式时打印url
options: {
...options.options,
},
});
// 创建Redis配置提供器
const redisOptionsProvider: Provider = {
provide: getRedisOptionsToken(),
useValue: options,
};
// 创建Redis连接提供器
const redisConnectionProvider: Provider = {
provide: getRedisConnectionToken(),
useValue: createRedisConnection(options),
};
return {
module: RedisCoreModule,
providers: [redisOptionsProvider, redisConnectionProvider],
exports: [redisOptionsProvider, redisConnectionProvider],
};
}
/**
* 异步方式初始化Redis模块
* 支持依赖注入方式配置
* @param options 异步配置选项
* @returns 动态模块配置
*/
static forRootAsync(options: RedisModuleAsyncOptions): DynamicModule {
// 打印异步配置日志
logger.info('初始化异步Redis模块配置', {
useClass: options.useClass?.name,
useExisting: options.useExisting?.name,
useFactory: !!options.useFactory,
});
// 创建Redis连接提供器
const redisConnectionProvider: Provider = {
provide: getRedisConnectionToken(),
useFactory(options: RedisModuleOptions) {
// 打印最终生成的配置
logger.info('生成Redis连接配置', {
type: options.type,
...(options.type === 'single' && { url: options.url }), // 仅当单机模式时打印url
options: {
...options.options,
},
});
return createRedisConnection(options);
},
inject: [getRedisOptionsToken()], // 注入Redis配置
};
return {
module: RedisCoreModule,
imports: options.imports,
providers: [
...this.createAsyncProviders(options),
redisConnectionProvider,
],
exports: [redisConnectionProvider],
};
}
/**
* 创建异步配置提供器
* 支持useClass、useFactory、useExisting三种方式
* @param options 异步配置选项
* @returns 提供器数组
*/
public static createAsyncProviders(
options: RedisModuleAsyncOptions,
): Provider[] {
// 验证配置方式是否有效
if (!(options.useExisting || options.useFactory || options.useClass)) {
throw new Error(
'无效配置,提供器只提供useClass、useFactory、useExisting这三种自定义提供器',
);
}
// 使用已存在的提供器或工厂方法
if (options.useExisting || options.useFactory) {
return [this.createAsyncOptionsProvider(options)];
}
// 使用类提供器
if (!options.useClass) {
throw new Error(
'无效配置,提供器只提供useClass、useFactory、useExisting这三种自定义提供器',
);
}
return [
this.createAsyncOptionsProvider(options),
{ provide: options.useClass, useClass: options.useClass },
];
}
/**
* 创建异步配置选项提供器
* @param options 异步配置选项
* @returns 配置提供器
*/
public static createAsyncOptionsProvider(
options: RedisModuleAsyncOptions,
): Provider {
// 验证配置方式是否有效
if (!(options.useExisting || options.useFactory || options.useClass)) {
throw new Error(
'无效配置,提供器只提供useClass、useFactory、useExisting这三种自定义提供器',
);
}
// 使用工厂方法方式
if (options.useFactory) {
return {
provide: getRedisOptionsToken(),
useFactory: options.useFactory,
inject: options.inject || [],
};
}
// 使用类或已存在实例方式
return {
provide: getRedisOptionsToken(),
async useFactory(
optionsFactory: RedisModuleOptionsFactory,
): Promise<RedisModuleOptions> {
const config = await optionsFactory.createRedisModuleOptions();
// 打印生成的配置
logger.info('通过工厂方法生成Redis配置', {
type: config.type,
...(config.type === 'single' && {
url: (config as RedisSingleOptions).url,
}),
options: config.options,
});
return config;
},
inject: [options.useClass || options.useExisting] as never,
};
}
}
4.4 Redis 缓存服务
RedisService 类提供了与 Redis 交互的全面接口,包括设置、获取和操作缓存数据的方法,该 Redis 服务模块提供了以下核心功能:
-
基础缓存操作:
- 支持单键值对的设置(
set)和获取(get)。 - 支持批量设置(
mset)和批量获取(mget)。 - 支持键的删除(
del)和存在性检查(has)。
- 支持单键值对的设置(
-
分布式锁:
- 通过
getWithLock方法实现分布式锁,防止缓存击穿。 - 支持自定义锁超时时间、重试延迟和最大重试次数。
- 通过
-
高性能批量操作:
- 使用 Redis 管道技术(
pipelineExecute)实现批量操作,提升性能。
- 使用 Redis 管道技术(
-
数据序列化与反序列化:
- 自动将 JavaScript 对象序列化为 JSON 字符串存储。
- 从 Redis 中获取数据时自动反序列化为指定类型。
import { Injectable } from '@nestjs/common';
import { Redis } from 'ioredis';
import { createLogger } from '@app/utils/logger';
import { isNil, UNDEFINED } from '@app/constants/value.constant';
import { InjectRedis } from '@app/decorators/redis.decorator';
import { isDevEnv } from '@app/configs';
// 创建日志记录器,用于记录Redis服务相关日志
const logger = createLogger({ scope: 'RedisService', time: isDevEnv });
/**
* Redis服务类
* 提供Redis连接和缓存服务
*/
@Injectable()
export class RedisService {
public client: Redis; // 公开的Redis客户端实例
private readonly LOCK_TIMEOUT = 10; // 分布式锁默认超时时间(秒)
private readonly LOCK_RETRY_DELAY = 100; // 获取锁失败重试延迟(毫秒)
private readonly MAX_LOCK_RETRIES = 5; // 最大重试次数
constructor(@InjectRedis() private readonly redis: Redis) {
this.client = this.redis; // 将注入的Redis实例赋值给公共client
this.registerEventListeners(); // 注册Redis事件监听器
}
/**
* 注册Redis事件监听器,用于监控Redis连接状态
*/
private registerEventListeners() {
this.redis.on('connect', () => logger.info('[Redis] connecting...')); // 连接中
this.redis.on('reconnecting', () => logger.warn('[Redis] reconnecting...')); // 重连中
this.redis.on('ready', () => logger.info('[Redis] readied!')); // 连接就绪
this.redis.on('end', () => logger.error('[Redis] Client End!')); // 连接结束
this.redis.on(
'error',
(error) => logger.error('[Redis] Client Error!', error.message), // 错误处理
);
}
/**
* 序列化方法,将任意值转换为JSON字符串
* @param value 要序列化的值
* @returns 序列化后的JSON字符串
*/
private serialize(value: unknown): string {
return isNil(value) ? '' : JSON.stringify(value);
}
/**
* 反序列化方法,将JSON字符串转换为指定类型
* @param value 要反序列化的JSON字符串
* @returns 反序列化后的值
*/
private deserialize<T>(value: string | null): T | undefined {
return isNil(value) ? UNDEFINED : (JSON.parse(value) as T);
}
/**
* 带分布式锁的缓存获取方法
* 1. 先尝试获取缓存
* 2. 如果缓存不存在,则尝试获取分布式锁
* 3. 获取锁成功后执行回退函数获取数据并缓存
* 4. 释放锁并返回数据
*/
public async getWithLock<T>(
key: string,
fallback: () => Promise<T>,
ttl: number,
lockOptions?: {
timeout?: number;
retryDelay?: number;
maxRetries?: number;
},
): Promise<T> {
// 1. 尝试获取缓存值
const cached = await this.get<T>(key);
if (cached !== undefined) return cached;
// 2. 配置锁参数
const {
timeout = this.LOCK_TIMEOUT,
retryDelay = this.LOCK_RETRY_DELAY,
maxRetries = this.MAX_LOCK_RETRIES,
} = lockOptions || {};
const lockKey = `${key}:lock`;
let retryCount = 0;
// 3. 尝试获取分布式锁
while (retryCount < maxRetries) {
const locked = await this.redis.set(
lockKey,
'LOCKED',
'EX',
timeout,
'NX',
);
if (locked) {
try {
// 4. 二次校验缓存(防止等待期间已有数据)
const doubleCheck = await this.get<T>(key);
if (doubleCheck !== undefined) return doubleCheck;
// 5. 执行回退函数获取数据
const data = await fallback();
await this.set(key, data, ttl);
return data;
} finally {
// 6. 释放分布式锁
await this.redis.del(lockKey);
}
}
// 7. 未获取到锁时的处理
retryCount++;
await new Promise((resolve) => setTimeout(resolve, retryDelay));
}
throw new Error(`Failed to acquire lock after ${maxRetries} attempts`);
}
/**
* 高性能管道批量操作方法
* 使用Redis管道技术批量执行操作,提高性能
*/
public async pipelineExecute(
operations: Array<
| { type: 'SET'; key: string; value: any }
| { type: 'SETEX'; key: string; value: any; ttl: number }
>,
): Promise<void> {
const pipeline = this.redis.pipeline();
operations.forEach((op) => {
const serializedValue = this.serialize(op.value);
if (op.type === 'SET') {
pipeline.set(op.key, serializedValue);
} else {
pipeline.setex(op.key, op.ttl, serializedValue);
}
});
await pipeline.exec();
}
/**
* 设置键值对,可选TTL
* @param key 键
* @param value 值
* @param ttl 过期时间(秒)
*/
public async set(key: string, value: any, ttl?: number): Promise<void> {
const serialized = this.serialize(value);
if (ttl) {
await this.redis.setex(key, ttl, serialized);
} else {
await this.redis.set(key, serialized);
}
}
/**
* 获取键值,返回反序列化后的值
* @param key 键
* @returns 反序列化后的值
*/
public async get<T>(key: string): Promise<T | undefined> {
const value = await this.redis.get(key);
return this.deserialize<T>(value);
}
/**
* 批量设置键值对,可选TTL
* @param kvList 键值对列表
* @param ttl 过期时间(秒)
*/
public async mset(kvList: [string, any][], ttl?: number): Promise<void> {
if (ttl) {
await this.pipelineExecute(
kvList.map(([key, value]) => ({
type: 'SETEX',
key,
value,
ttl,
})),
);
} else {
await this.redis.mset(
kvList.map(([key, value]) => [key, this.serialize(value)]),
);
}
}
/**
* 批量获取键值
* @param keys 键列表
* @returns 反序列化后的值列表
*/
public mget(...keys: string[]): Promise<any[]> {
return this.redis
.mget(keys)
.then((values) => values.map((v) => this.deserialize(v)));
}
/**
* 批量删除键
* @param keys 键列表
*/
public async mdel(...keys: string[]): Promise<void> {
await this.redis.del(keys);
}
/**
* 删除单个键
* @param key 键
* @returns 删除是否成功
*/
public async del(key: string): Promise<boolean> {
const result = await this.redis.del(key);
return result > 0;
}
/**
* 检查键是否存在
* @param key 键
* @returns 键是否存在
*/
public async has(key: string): Promise<boolean> {
const count = await this.redis.exists(key);
return count !== 0;
}
/**
* 获取键的剩余生存时间
* @param key 键
* @returns 剩余生存时间(秒)
*/
public async ttl(key: string): Promise<number> {
return this.redis.ttl(key);
}
/**
* 根据模式匹配获取键列表
* @param pattern 模式
* @returns 键列表
*/
public async keys(pattern = '*'): Promise<string[]> {
return this.redis.keys(pattern);
}
/**
* 清空所有键
*/
public async clean(): Promise<void> {
const allKeys = await this.keys();
if (allKeys.length) {
await this.redis.del(allKeys);
}
}
}
通过代码开发,我们构建了一个高性能、高可用的 Redis 缓存服务模块。该模块支持分布式锁、批量操作、数据序列化等核心功能,能够有效提升系统的性能和可靠性
4.5 Session 会话
前面已经完成 Redis 连接和缓存的处理,现在要在这个基础上实现 Session 管理 。Session 可以用于维护用户的登录状态,避免用户每次访问都需要重新授权。以下是基于 Nest.js 和 Redis 实现 Session 管理的详细步骤
1. 安装依赖
npm install express-session connect-redis
- express-session:用于在 Express 或 Nest.js 中管理 Session。
- connect-redis:用于将 Session 存储到 Redis 中。
2. 配置 Session 中间件
在 AppModule 的 configure 方法中,通过 session 中间件配置了 Session,并将其应用到所有路由(forRoutes('*'))
import { MiddlewareConsumer, Module } from '@nestjs/common';
import { AppService } from './app.service';
import { RedisCoreModule } from './processors/redis/redis.module';
import { CONFIG, SESSION } from '@app/configs';
import { RedisService } from './processors/redis/redis.service';
import session from 'express-session';
import { RedisStore } from 'connect-redis';
import { OriginMiddleware } from './middlewares/origin.middleware';
import { CorsMiddleware } from './middlewares/cors.middleware';
import { WechatAuthMiddleware } from './middlewares/wechat.middleware';
import { RouterController } from './modules/router/router.controller';
import { DatabaseModule } from './processors/database/database.module';
import modules from './modules';
/**
* 应用程序主模块
* @export
* @class AppModule
*/
@Module({
imports: [
// Redis核心模块,用于处理缓存
RedisCoreModule.forRoot(CONFIG.redis),
DatabaseModule,
...modules,
],
controllers: [],
providers: [AppService],
})
export class AppModule {
constructor(private readonly redisService: RedisService) {}
/**
* 配置全局中间件
* @param {MiddlewareConsumer} consumer - 中间件消费者
*/
configure(consumer: MiddlewareConsumer) {
// 应用通用中间件到所有路由
consumer
.apply(
// 跨域资源共享中间件
CorsMiddleware,
// 来源验证中间件
OriginMiddleware,
// Session会话中间件
session({
// 使用Redis存储session
store: new RedisStore({
client: this.redisService.client,
}),
...SESSION,
}),
)
.forRoutes('*');
// 单独应用微信认证中间件到RouterModule
consumer.apply(WechatAuthMiddleware).forRoutes(RouterController); // RouterModule中定义的路由
}
}
扩展 Session 的相关配置
{
secret: 'wx-client-session-secret-das23-4241nsdf-%52132=-', // session密钥
name: 'sid', // cookie名称
saveUninitialized: false, // 是否自动保存未初始化的会话
resave: false, // 是否每次都重新保存会话
cookie: {
sameSite: true, // 限制第三方Cookie
httpOnly: true, // 仅允许服务端修改
maxAge: 7 * 24 * 60 * 60 * 1000, // cookie有效期为7天
},
rolling: true, // 每次请求时强制设置cookie,重置cookie过期时间
};
通过以上配置,Session 可以高效地存储用户登录状态,并通过 Redis 实现持久化和分布式支持。结合微信授权登录功能,可以实现一个完整的用户认证系统。
5、User 表和权限
在前文中我们已经获取微信授权信息(如用户 OpenID、昵称、头像等)的基础上,将这些信息保存到数据库中是一个非常重要的步骤。这样可以方便后续的用户信息维护、登录状态管理以及业务逻辑处理。以下是实现 将授权信息保存到数据库 的详细步骤。
5.1、创建用户实体
使用 @typegoose/typegoose 定义一个用户模型(User 模型),用于存储用户的基本信息。
-
Typegoose 使用:利用
@typegoose/typegoose库定义 Mongoose 模型,以便与 MongoDB 进行交互。 -
插件:
AutoIncrementID:自动递增userId字段,简化用户 ID 的管理。mongoose-paginate-v2:提供分页功能,便于查询大量用户数据时进行分页。
-
数据验证:使用
class-validator库对用户输入进行验证,确保数据的完整性和合法性。 -
字段定义:
userId:唯一标识符。account、password、openid等:存储用户的基本信息。create_at和update_at:记录用户信息的创建和更新时间。role、privilege:用于管理用户权限。
// 导入Typegoose的getProviderByTypegoose方法
import { getProviderByTypegoose } from '@app/transformers/model.transform';
// 导入Typegoose的AutoIncrementID插件
import { AutoIncrementID } from '@typegoose/auto-increment';
// 导入Typegoose的modelOptions, plugin, prop装饰器
import { modelOptions, plugin, prop } from '@typegoose/typegoose';
// 导入class-validator中的验证装饰器
import {
IsDefined,
IsOptional,
IsString,
IsNumber,
IsNotEmpty,
IsIn,
IsArray,
ValidateIf,
} from 'class-validator';
// 导入mongoose的分页插件
import paginate from 'mongoose-paginate-v2';
// 应用AutoIncrementID插件,用于自动递增userId字段
@plugin(AutoIncrementID, {
field: 'userId',
incrementBy: 1,
startAt: 1000000000,
trackerCollection: 'identitycounters',
trackerModelName: 'identitycounter',
})
// 应用分页插件
@plugin(paginate)
// 设置模型选项,包括转换为对象时的选项和时间戳配置
@modelOptions({
schemaOptions: {
toObject: { getters: true },
timestamps: {
createdAt: 'create_at',
updatedAt: 'update_at',
},
},
})
// 定义User类,表示用户模型
export class User {
// 用户ID,设置唯一索引
@prop({ unique: true })
userId: number;
// 用户账号,必填字段
@IsOptional()
@IsString()
@prop()
account?: string; // 账号可选
@IsOptional()
@IsString()
@prop({ select: false })
password?: string; // 密码可选
@ValidateIf((o) => !o.openid)
@IsNotEmpty({ message: '请输入您的账号或密码' })
@IsString()
@IsDefined()
@prop({ required: true })
openid?: string; // OpenID必填
// 用户头像,默认为null
@ValidateIf((o) => o.avatar !== null)
@IsString()
@IsOptional()
@prop({ default: null })
avatar?: string | null;
// 用户角色,默认为[0]
@ValidateIf((o) => o.role !== undefined)
@IsArray()
@IsNumber({}, { each: true })
@IsOptional()
@prop({ type: [Number], default: [0] })
role?: number[];
// 创建时间,默认当前时间,索引且不可变
@prop({ default: Date.now, index: true, immutable: true })
create_at?: Date;
// 更新时间,默认当前时间
@prop({ default: Date.now })
update_at?: Date;
// 用户昵称,可选字段
@ValidateIf((o) => o.nickname !== undefined)
@IsString()
@IsOptional()
@prop()
nickname?: string;
// 用户性别,默认为0
@IsNumber()
@IsIn([0, 1, 2], { message: '性别只能是0, 1或2' })
@prop({ default: 0 })
sex: number;
// 用户语言,可选字段
@ValidateIf((o) => o.language !== undefined)
@IsString()
@IsOptional()
@prop()
language?: string;
// 用户所在城市,可选字段
@ValidateIf((o) => o.city !== undefined)
@IsString()
@IsOptional()
@prop()
city?: string;
// 用户所在省份,可选字段
@ValidateIf((o) => o.province !== undefined)
@IsString()
@IsOptional()
@prop()
province?: string;
// 用户所在国家,可选字段
@ValidateIf((o) => o.country !== undefined)
@IsString()
@IsOptional()
@prop()
country?: string;
// 用户头像URL,可选字段
@ValidateIf((o) => o.headimgurl !== undefined)
@IsString()
@IsOptional()
@prop()
headimgurl?: string;
// 用户特权信息,默认为空数组
@IsArray()
@IsString({ each: true })
@prop({ type: () => [String], default: [] })
privilege: string[];
}
// 获取User模型的提供者
export const UserProvider = getProviderByTypegoose(User);
5.3、创建用户服务
定义 UserService,负责用户相关的业务逻辑,包括用户的登录、信息查询和验证
import { Injectable } from '@nestjs/common';
import { User } from './user.model';
import { Model } from 'mongoose';
import { InjectModel } from '@app/transformers/model.transform';
import { createLogger } from '@app/utils/logger';
import { AUTH } from '@app/configs';
import { JwtService } from '@nestjs/jwt';
import { AuthInfo } from '@app/interfaces/auth.interface';
const logger = createLogger({
scope: 'UserService',
time: true,
});
/**
* 用户服务
* 该服务负责处理用户相关的业务逻辑
*/
@Injectable()
export class UserService {
// 这里可以添加用户服务的相关方法
// 例如: 创建用户、获取用户信息、更新用户信息等
constructor(
@InjectModel(User) private authModel: Model<User>,
private readonly jwtService: JwtService,
) {}
/**
* 用户登录微信
* @param userData - 用户数据
* @returns 用户信息
*/
public async loginWx(userData: any): Promise<AuthInfo> {
// 根据用户的openId查找现有用户
let existingUser = await this.authModel.findOne({
openid: userData.openid,
});
// 如果没有找到现有用户,则创建一个新用户
if (!existingUser) {
existingUser = await this.authModel.create(userData);
}
// 如果用户创建失败,则抛出错误
if (!existingUser) {
throw new Error('用户创建失败');
}
// 根据用户userId、openId、nickname生成token
const token = this.generateToken(
existingUser.userId.toString() || '',
existingUser.openid || '',
existingUser.nickname || '',
);
logger.info('loginWx', existingUser);
return {
user: {
userId: existingUser.userId,
openid: existingUser.openid || '',
nickname: existingUser.nickname || '',
account: existingUser.account || '',
},
token,
};
}
/**
* 生成token
* @param userId - 用户ID
* @param openId - 用户openId
* @param nickname - 用户昵称
* @returns token
*/
private generateToken(
userId: string,
openId: string,
nickname: string,
): { accessToken: string; expiresIn: number } {
const token = {
accessToken: this.jwtService.sign({ userId, openId, nickname }),
expiresIn: AUTH.expiresIn as number,
};
return token;
}
/**
* 验证用户
* @param {ValidateUserRequest} { userId }
* @return {*}
* @memberof AuthService
*/
public async validateUser(userId: number) {
return await this.getFindUserId(userId);
}
/**
* 根据用户ID查找用户
* @param userId - 用户ID
* @returns 用户信息
*/
public async getFindUserId(userId: number) {
return this.authModel.findOne({ userId }).exec();
}
}
5.3 JwtStrategy
定义 JWT 策略,验证 JWT 并返回用户信息
import { AUTH } from '@app/configs';
import { Injectable } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { Strategy } from 'passport-jwt';
import { UserService } from './user.service';
import { Request } from 'express';
import { get } from 'lodash';
import { createLogger } from '@app/utils/logger';
const logger = createLogger({
scope: 'JwtStrategy',
time: true,
});
/**
* JWT策略
* 用于验证JWT并返回用户信息
*/
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
// 继承PassportStrategy
constructor(private readonly userService: UserService) {
// 构造函数注入UserService
super({
jwtFromRequest: (req: Request) => {
// 从请求中获取JWT
// 从cookie中获取token
const token = get(req, 'cookies.jwt');
logger.log(token, 'token');
return token || null; // 如果jwt为空,返回null以避免报错
},
secretOrKey: AUTH.jwtTokenSecret, // 设置JWT的密钥
});
}
/**
* 验证用户
* @param {*} payload - JWT载荷
* @return {*}
* @memberof JwtStrategy
*/
async validate(payload: any) {
// 验证JWT载荷
const res = await this.userService.validateUser(payload.data); // 调用用户服务验证用户
return res; // 返回验证结果
}
}
6、微信SDK配置
配置和获取微信 JS SDK 的相关信息,以便于在前端实现微信功能
/**
* 获取微信JS SDK配置
* @param {string} url - 当前页面的URL
* @returns {Promise<Object>} - 返回微信JS SDK的配置对象
*/
async getWxConfig(url: string): Promise<object> {
try {
// 尝试从缓存中获取access_token和ticket
const cachedConfig = await this.cacheService.get<string>(WX_CONFIG_TOKEN);
let accessToken: string;
let ticket: string;
if (cachedConfig) {
const tokenConfig = JSON.parse(cachedConfig); // 如果缓存存在,直接返回
accessToken = tokenConfig.accessToken;
ticket = tokenConfig.ticket;
} else {
// 获取access_token和JS API票据
accessToken = await this.getAccessConfigToken();
ticket = await this.getJsApiTicket(accessToken);
await this.cacheService.set(
WX_CONFIG_TOKEN,
JSON.stringify({ accessToken, ticket }),
7200,
);
}
// 生成随机字符串和时间戳
const nonceStr = Math.random().toString(36).substring(2, 15);
const timestamp = Math.floor(Date.now() / 1000);
// 生成签名
const signature = this.generateSignature(
ticket,
nonceStr,
timestamp,
url,
);
// 构建并返回配置对象
return { appId: this.appId, timestamp, nonceStr, signature };
} catch (error) {
logger.error(error, '获取微信JS SDK配置失败');
throw new Error('获取微信JS SDK配置失败: ' + error.message);
}
}
/**
* 获取微信配置的access_token
* 该方法通过调用微信API获取access_token,用于后续的API请求
* @returns {Promise<string>} - 返回获取到的access_token
*/
async getAccessConfigToken() {
const tokenUrl = `https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential&appid=${this.appId}&secret=${this.appSecret}`;
const tokenResponse = await axios.get(tokenUrl);
return tokenResponse.data.access_token;
}
/**
* 获取微信JS API票据
* 该方法通过调用微信API获取JS API票据,用于后续的API请求
* @param {string} accessToken - 用于获取JS API票据的access_token
* @returns {Promise<string>} - 返回获取到的JS API票据
*/
async getJsApiTicket(accessToken: string) {
const ticketUrl = `https://api.weixin.qq.com/cgi-bin/ticket/getticket?access_token=${accessToken}&type=jsapi`;
const ticketResponse = await axios.get(ticketUrl);
return ticketResponse.data.ticket;
}
/**
* 生成微信JS SDK签名
* @param {string} ticket - 微信JS API票据
* @param {string} nonceStr - 随机字符串
* @param {number} timestamp - 时间戳
* @param {string} url - 当前页面的URL
* @returns {string} - 返回生成的签名
*/
private generateSignature(
ticket: string,
nonceStr: string,
timestamp: number,
url: string,
): string {
const stringToSign = `jsapi_ticket=${ticket}&noncestr=${nonceStr}×tamp=${timestamp}&url=${url}`;
return crypto.createHash('sha1').update(stringToSign).digest('hex');
}
前端请求获取到微信JS SDK配置。
import http from '@src/services/http';
export function getWxConfig() {
http.get('/api/wechat-auth/wx-config', {}).then((res) => {
if (res.appId) {
window.wx.config({
appId: res.appId, // 必填,公众号的唯一标识
timestamp: res.timestamp, // 必填,生成签名的时间戳
nonceStr: res.nonceStr, // 必填,生成签名的随机串
signature: res.signature, // 必填,签名
jsApiList: [
'checkJsApi',
'onMenuShareTimeline',
'onMenuShareAppMessage',
'onMenuShareQQ',
'onMenuShareWeibo',
'onMenuShareQZone',
'hideMenuItems',
'showMenuItems',
'hideAllNonBaseMenuItem',
'showAllNonBaseMenuItem',
'translateVoice',
'startRecord',
'stopRecord',
'onVoiceRecordEnd',
'playVoice',
'onVoicePlayEnd',
'pauseVoice',
'stopVoice',
'uploadVoice',
'downloadVoice',
'chooseImage',
'previewImage',
'uploadImage',
'downloadImage',
'getNetworkType',
'openLocation',
'getLocation',
'hideOptionMenu',
'showOptionMenu',
'closeWindow',
'scanQRCode',
'chooseWXPay',
'openProductSpecificView',
'addCard',
'chooseCard',
'openCard',
], // 必填,需要使用的JS接口列表
});
}
});
}
通过本文的学习,读者将掌握如何基于 Nest.js 构建 BFF 层,并实现微信授权登录功能。希望本文能为开发者提供有价值的参考和启发!
本项目使用 Cursor 工具结合不同的大模型,可以根据提示生成相应代码、优化逻辑并添加注释,从而显著提升代码开发效率。通过自动化处理重复性任务,开发者能够更专注于核心功能的实现,减少手动编码的时间。这种智能化的工具不仅加快了开发进程,还提高了代码质量。 使用Cursor 感受:
- 强大的AI集成:自动补全、生成代码、解释代码、优化建议。
- 智能调试:AI帮助分析错误。
- 自然语言交互:用对话方式生成代码。
- 效率提升:减少重复工作,专注核心逻辑。
最后吐槽下,在当前 AI 技术迅速发展的背景下,前端开发的存在感确实有所减弱,项目中的话语权也相应降低。一些团队成员可能认为某些功能的实现非常简单,认为可以轻松通过 AI 查找答案,但往往忽视了项目的整体架构和复杂性。实际上,维护现有代码时,会面临许多不必要的逻辑和冗余的实现,这使得开发变得更加困难。尽管看似简单的功能,背后却可能隐藏着复杂的架构问题,让人不得不在已有的基础上不断堆砌代码,如一坨屎山。
7、2025年目标
-
1、完善项目框架:目前已具备移动端架构的开箱即用能力。
-
2、完成日志上报项目:开发基于 Nest.js 和 gRPC 的日志上报项目,并将其开源,以便社区使用。
-
3、学习新技术:深入学习 Rust、WebAssembly 和 AI,探索实现 ORC 功能的可能性。
-
4、提升基础知识:继续完善自己的基础知识,特别是在 nginx 方面的理解和应用。
-
5、撰写技术文章:计划撰写约 6 篇技术文章,分享学习和实践经验。
-
6、研究 AI 应用:系统学习与 AI 相关的知识,探索其在实际项目中的应用