免费获取学习方案
ARTICLE DETAIL

资讯详情

深耕编程基础知识与建站技术分享的一线实战洞察。

工厂模式+策略模式:SpringBoot统一多端登录架构实战

工厂模式+策略模式:SpringBoot统一多端登录架构实战 先交代个背景吧。我接手过好几个中后台项目登录这块的代码基本都是哪需要就在哪写一份PC 端在 controller 里写一套小程序端在自己的 service 里写一套App 端又单独搞了一个拦截器。表面上看功能都能跑但只要你敢加一个登录方式或者动一下 token 生成规则改完就能体会到什么叫牵一发动全身。更难受的是三个端的登录逻辑经常出现微妙的偏差——A 端校验了账号状态B 端漏了B 端登录成功了会刷新 tokenC 端又不会。最后你根本分不清到底是设计如此还是写漏了。后来我把这套多端登录统一重构成基于 SpringBoot 的工厂模式 策略模式方案才算把这些问题彻底压住。本文就围绕这个方案从痛点分析、设计原理、核心代码、Token 设计到扩展实战完整拆解一遍我是怎么落地这套统一多端登录的希望能给正在被登录逻辑折磨的团队一个可参考的思路。1. 痛点拆解多端登录为什么容易写成一坨屎山代码写成一坨不一定是写代码的人水平不行更多时候是演进太快、只求能跑。多端登录尤其容易出问题因为它在业务上天然有两个分叉点不同端用什么凭证登录手机号、openid、验证码以及登录成功后怎么维护会话token 规则、过期策略、踢人逻辑。1.1 典型的三段式代码长什么样我见过太多这样的写法在 Controller 里同时处理三种登录逻辑一个大方法从上写到下if-else 层层嵌套。PostMapping(/login) public Result login(RequestBody LoginRequest request) { if (pc.equals(request.getClientType())) { // 校验账号密码 User user userService.checkPassword(request.getUsername(), request.getPassword()); if (user null) { return Result.error(用户名或密码错误); } // 生成token String token UUID.randomUUID().toString().replace(-, ); redisTemplate.opsForValue().set(login:token: user.getId(), token, 24, TimeUnit.HOURS); return Result.success(token); } else if (mini.equals(request.getClientType())) { // 小程序登录微信code换openid String openid wechatService.code2Openid(request.getCode()); // 查用户、没有就注册 // 生成token // ... } else if (app.equals(request.getClientType())) { // App手机号验证码登录 // ... } return Result.error(不支持的登录类型); }这段代码的问题一眼就能看出来Controller 承担了所有逻辑方法越来越长每个分支内做的事情其实差不多但细节完全独立无法复用新增登录方式时只能继续往后叠 else-if代码行数从 100 行变成 300 行然后变成 500 行。1.2 表面问题是代码长深层问题是三个不一致等你真正去维护这段代码时会发现问题的核心不是长而是不一致。第一个不一致是校验逻辑不一致。账号密码登录需要校验用户状态禁用、删除小程序登录可能会漏掉这层校验App 登录可能用了另一套状态判断标准。同一个用户在 PC 端被禁止登录了在 App 端居然还能正常进来。第二个不一致是Token 规则不一致。有的端生成 token 用的 UUID有的端直接把用户 ID 塞进 token有的端 token 有效期是 2 小时有的是 7 天。这些差异通常没有任何文档说明纯粹是谁写就按谁的习惯来。第三个不一致是会话隔离不一致。如果 PC 端和 App 端共用同一个 token key用户在一个端登录会把另一个端踢下线。三个端在设计和产品预期上通常是多端可以同时在线同一端的登录才需要互斥。没做隔离的话这个需求根本没法实现。1.3 从设计模式视角重新审视这个问题用设计模式的话说登录逻辑本身就是一组算法族同一种业务意图登录不同端有不同的算法实现账号密码、code 换 openid、验证码校验。算法族之间可以互相替换而且未来大概率会新增扫码、免密、人脸这就是典型的策略模式应用场景。同时调用方Controller需要根据 clientType 参数来选择合适的策略这个选择逻辑如果继续用 if-else 写等于把策略模式的优势又废掉了。所以还需要一个工厂专门负责给我一个类型我返回对应的策略实例。把这两者结合起来就是本文要讲的方案策略接口统一登录行为 工厂类统一管理策略实例 Controller 面向策略接口编程。2. 方案设计策略模式管怎么登录工厂模式管选哪个登录设计模式最忌讳生搬硬套。很多人一听到策略模式第一反应是写一堆策略类然后还是用 if-else 去 new这基本等于白做。要把这个方案设计好得先理清两个模式各自该管什么。2.1 策略模式的职责边界只解决怎么做策略模式解决的是同一个行为多种实现方式的问题。在登录场景里这个行为是执行登录实现方式是各个端不同的登录凭证校验逻辑。我把这层抽象为一个接口接口只定义两件事当前策略支持什么登录类型执行登录并返回结果。public interface LoginStrategy { /** 当前策略支持的登录类型 */ LoginType getLoginType(); /** 执行登录 */ LoginResult login(LoginRequest request); }有的文章会在这个接口里再加一个解析请求的方法比如从 request 里把 openid 抽取出来。我实测下来不建议这么做因为你很难用一个统一的方法定义从 request 里拿什么字段字段不同解析逻辑天然就不同。接口定义得越宽泛实现的约束就越弱最后还是靠 if-else 在策略内部判断没必要。2.2 工厂模式的职责边界只解决选哪个策略接口定义好了接下来要解决的是调用方 clientType 传入mini我该用哪个策略类最朴素的实现是写一个静态方法放一个 switch 判断public static LoginStrategy getStrategy(String clientType) { switch (clientType) { case pc: return new AccountPasswordLoginStrategy(); case mini: return new MiniProgramLoginStrategy(); case app: return new AppLoginStrategy(); default: throw new LoginException(不支持的登录类型); } }这种写法虽然简单但每次新增策略都要改工厂类依然是修改不是扩展。我更推荐把工厂类做成一个策略注册表启动时自动收集所有 LoginStrategy 类型的 Spring Bean按 getLoginType() 的返回值存进一个 Map调用时从 Map 里取取不到就抛异常。Component public class LoginStrategyFactory { private final MapLoginType, LoginStrategy strategyMap new EnumMap(LoginType.class); public LoginStrategyFactory(ListLoginStrategy strategies) { for (LoginStrategy strategy : strategies) { strategyMap.put(strategy.getLoginType(), strategy); } } public LoginStrategy getStrategy(LoginType loginType) { LoginStrategy strategy strategyMap.get(loginType); if (strategy null) { throw new LoginException(不支持的登录类型: loginType); } return strategy; } }这样做的效果是新增登录方式时只需要新增一个实现 LoginStrategy 的 Spring Bean工厂类不用动Controller 不用动真正的对扩展开放对修改关闭。2.3 为什么选择枚举 Map 注册而不是if-else 链这里我踩过一个有意思的坑。一开始我用的是字符串作为 Map 的 key类似于strategyMap.put(pc, new AccountPasswordLoginStrategy())。后来发现 clientType 的来源不可控前端传PC、Pc、pc都有可能每次都要做大小写归一化还很容易漏。后来我改成了 LoginType 枚举把 clientType 的字符串映射和策略类的绑定关系彻底固化public enum LoginType { PC(pc, 电脑端), MINI(mini, 微信小程序), APP(app, 移动App), WECHAT_QR(wechat_qr, PC微信扫码); private final String code; private final String desc; // 构造方法、getter... }Controller 接收前端传参后先通过LoginType.fromCode(clientType)做一次归一化前端传PC、Pc、pc都能统一归一为 PC。这样工厂里就只用枚举做 key类型安全代码可读性也高很多。提示如果你的业务里客户端的类型是存在数据库里的枚举的 code 一定要和数据库存的值保持一致否则这里就会多一层映射逻辑。尽量从源头统一。2.4 抽象类的价值把公共流程和差异逻辑解耦策略接口定义了做什么但不同登录策略之间其实有很强的公共流程校验参数、执行登录、加载用户、生成 token、记录日志、返回结果。每一步在三个端里的实现都不完全一样但流程骨架是一样的。这就是抽象类的用武之地。我定义了一个 AbstractLoginStrategy 实现 LoginStrategy 接口把流程骨架用模板方法模式固定下来只把如何校验并获取用户这个最差异化的点留给子类实现。public abstract class AbstractLoginStrategy implements LoginStrategy { Autowired protected UserService userService; Autowired protected TokenService tokenService; Autowired protected LoginLogService loginLogService; Override public LoginResult login(LoginRequest request) { // 1. 参数校验 validateParams(request); // 2. 执行登录获取用户 User user doLogin(request); // 3. 校验用户状态 checkUserStatus(user); // 4. 生成token String token tokenService.createToken(user.getId(), getLoginType()); // 5. 记录日志 loginLogService.record(user.getId(), getLoginType(), request.getClientIp()); // 6. 返回结果 return LoginResult.of(user, token); } /** 由子类实现参数校验 */ protected abstract void validateParams(LoginRequest request); /** 由子类实现执行登录返回用户 */ protected abstract User doLogin(LoginRequest request); /** 由子类实现当前策略对应的登录类型 */ Override public abstract LoginType getLoginType(); }这个方法的价值非常大。它把三个端的公共流程收敛到了一个地方任何人只需要看这个抽象类就能快速理解登录一次到底经历了哪些步骤。子类之间永远不会出现A 校验了用户状态B 没有这种偏差因为校验步骤在父类里已经被固化了。后面我会详细讲每个子类怎么写。3. 核心代码落地从策略接口到工厂装配的完整实现基础设计说完了下面进入实战环节。这一节我会把整套代码的完整细节写出来包括策略接口怎么定义、抽象类怎么封装、三个具体策略怎么实现、Controller 怎么接入工厂以及登录成功后怎么返回多端差异信息。3.1 统一请求与响应模型先解决参数长什么样写策略之前得先定好入参和出参的模型。多端登录的请求参数差别很大PC 端是 username/password小程序是 codeApp 是 phone/captcha后续的扫码可能是 scene/ticket。所以请求模型不能写死字段我采用通用字段 扩展字段的方式。Data public class LoginRequest { /** 登录类型: pc/mini/app/wechat_qr */ private String clientType; /** 账号PC端 */ private String username; /** 密码PC端 */ private String password; /** 微信code小程序端 */ private String wxCode; /** 手机号App端 */ private String phone; /** 验证码App端 */ private String captcha; /** 扫码登录临时凭证扫码端 */ private String loginTicket; /** IP地址在拦截器里注入 */ private String clientIp; /** 扩展字段放一些不便于建模的参数 */ private MapString, Object extra; }这种模型的好处是前端传参简单后端各取所需。坏处是字段一多会有参数模型庞杂的感觉但登录请求本身字段就不多在可控范围内。响应模型则统一为用户基本信息 token 端特有信息Data public class LoginResult { private Long userId; private String username; private String nickname; private String avatar; private String token; /** 端特有信息比如小程序的sessionKey需要前端解密用 */ private MapString, Object extra; public static LoginResult of(User user, String token) { LoginResult result new LoginResult(); result.setUserId(user.getId()); result.setUsername(user.getUsername()); result.setNickname(user.getNickname()); result.setAvatar(user.getAvatar()); result.setToken(token); return result; } }有的端登录成功后确实需要返回额外信息比如小程序端可能要返回 sessionKey 给前端用于解密手机号App 端可能要返回 refreshToken。这些信息放在 extra map 里即可各策略自行填充。3.2 三种具体策略实现账号密码、小程序、App有了抽象类和模型具体策略的代码就会非常简洁。账号密码登录策略Component public class AccountPasswordLoginStrategy extends AbstractLoginStrategy { Override public LoginType getLoginType() { return LoginType.PC; } Override protected void validateParams(LoginRequest request) { if (!StringUtils.hasText(request.getUsername()) || !StringUtils.hasText(request.getPassword())) { throw new LoginException(用户名和密码不能为空); } } Override protected User doLogin(LoginRequest request) { User user userService.findByUsername(request.getUsername()); if (user null || !passwordEncoder.matches(request.getPassword(), user.getPassword())) { throw new LoginException(用户名或密码错误); } return user; } }小程序登录策略Component public class MiniProgramLoginStrategy extends AbstractLoginStrategy { Override public LoginType getLoginType() { return LoginType.MINI; } Override protected void validateParams(LoginRequest request) { if (!StringUtils.hasText(request.getWxCode())) { throw new LoginException(wxCode不能为空); } } Override protected User doLogin(LoginRequest request) { // 用code换取openid WechatSession session wechatClient.code2Session(request.getWxCode()); String openid session.getOpenid(); // 根据openid查用户 User user userService.findByOpenid(openid); if (user null) { // 如果是新用户可以自动注册也可以返回提示让前端走注册流程 user userService.registerByOpenid(openid); } // 可选把sessionKey存起来 return user; } }App 验证码登录策略Component public class AppLoginStrategy extends AbstractLoginStrategy { Override public LoginType getLoginType() { return LoginType.APP; } Override protected void validateParams(LoginRequest request) { if (!StringUtils.hasText(request.getPhone()) || !StringUtils.hasText(request.getCaptcha())) { throw new LoginException(手机号和验证码不能为空); } } Override protected User doLogin(LoginRequest request) { // 校验验证码 boolean valid captchaService.validate(request.getPhone(), request.getCaptcha()); if (!valid) { throw new LoginException(验证码错误或已过期); } User user userService.findByPhone(request.getPhone()); if (user null) { user userService.registerByPhone(request.getPhone()); } return user; } }你看每个策略类只负责自己的差异化逻辑公共的校验用户状态、生成 token、记日志全在父类里。如果哪天产品要求登录后必须记录设备信息你只需要在父类里加一行代码三个端同时生效。3.3 自动装配的注意事项构造函数注入优于字段注入我在工厂构造函数里注入了ListLoginStrategy这是 Spring 的一个特性当构造函数参数是一个 List 时Spring 会把容器中所有该类型的 Bean 都注入进来顺序可以加 Order 控制。这里有一个非常关键的坑假设我写了三个策略类分别用 Component 注册为 Spring Bean工厂构造函数注入ListLoginStrategy此时 Spring 容器里已经有这三个 Bean 了那么注入的 List 就包含这三个实例。但是如果你不小心在某个策略类上没有加 Component 或 Service它就不会被注入工厂 Map 里就少了这个策略调用时就会抛不支持的登录类型。排查这类问题的方法我会在第 6 节的坑位清单里详细讲。另一种常见的装配方式是使用MapString, LoginStrategy注入key 是 Bean 名称。但我实测下来还是推荐 List 注入 自行 put 到 EnumMap因为 Bean 名称在 Spring 中默认是类名首字母小写依赖这个惯例写代码时间长了容易出幺蛾子。注意如果你的项目里存在多个同类型策略被 AOP 代理比如加了 Transactional注入进来的 List 里将是代理对象而不是原始对象。代理对象的 getLoginType() 方法还会走增强逻辑必须确保该方法没有拦截问题否则工厂 Map 里会得到 null key。这个细节我在第 6 节会展开说。3.4 Controller 层只有一个方法面向工厂编程Controller 层就非常简单了所有人的实现都收敛到一个入口RestController RequestMapping(/api/auth) public class AuthController { private final LoginStrategyFactory loginStrategyFactory; public AuthController(LoginStrategyFactory loginStrategyFactory) { this.loginStrategyFactory loginStrategyFactory; } PostMapping(/login) public ResultLoginResult login(RequestBody Valid LoginRequest request) { // 将前端传的clientType字符串转换为枚举 LoginType loginType LoginType.fromCode(request.getClientType()); if (loginType null) { throw new LoginException(不支持的登录类型: request.getClientType()); } // 从工厂获取策略并执行登录 LoginStrategy strategy loginStrategyFactory.getStrategy(loginType); LoginResult result strategy.login(request); return Result.success(result); } }整个 Controller 只有 15 行左右。新增登录端时Controller 一行都不用改新增一个策略类、枚举加一个值就完事了。这里我还要强调一个设计细节clientType 转枚举这步放在 Controller 层而不是工厂层目的是让工厂层只接受合法的 LoginType把非法值提前拦截在外层。如果你把字符串传进工厂那工厂里就得再兜一层判断职责就不纯粹了。3.5 User 状态校验如何设计才不会漏把校验用户状态收敛到父类里是这个方案最容易出效果的设计之一。我在第 1 节里提到过三个端经常出现A 端禁用了用户B 端还能登录的问题。在父类中统一校验后彻底根治protected void checkUserStatus(User user) { if (user null) { throw new LoginException(用户不存在); } if (user.getStatus() ! null user.getStatus() 1) { throw new LoginException(账号已被禁用); } if (user.getDeleted() ! null user.getDeleted()) { throw new LoginException(账号已被注销); } }如果有某个端确实需要跳过用户状态校验比如游客登录可以重写 checkUserStatus 方法Override protected void checkUserStatus(User user) { // 游客端不校验账号状态 }这种父类默认强制、子类按需放宽的设计肯定比默认不校验、谁想起来谁加要安全得多。我建议所有继承 AbstractLoginStrategy 的策略类除非有明确业务原因否则必须保留父类的状态校验。4. 统一 Token 设计与多端登录态隔离登录策略只是解决了验证身份这一步真正登录成功之后怎么让三个端保持会话状态并且互不干扰又是一个常见的重灾区。我做统一多端登录时Token 方案是一开始就定好的Redis Token 按端隔离的 key 设计 可配置过期时间。4.1 为什么不用 Session而用 Redis Token很多老项目喜欢用 HttpSession 保存登录态这种方案在单体应用内部跑一跑还行但一旦多端都要接入就会出现三个致命问题Session 默认存在单机内存应用多实例部署时请求被负载均衡转发到没有会话的实例上就直接 401。要解决就得引入 Spring Session Redis等于还是回到 Redis。Session 的会话 ID 存在 Cookie 里小程序和 App 端没有 Cookie 机制处理起来很别扭。Session 天然和服务器绑定过期管理、主动踢人、多端同时在线这些需求实现起来很费劲。用 Redis Token 方案好处是无状态服务端只需要在请求拦截器里校验 token 是否存在即可天然支持水平扩展带过期时间可以灵活设置登录有效期按 key 设计天然支持多端隔离和强制下线。4.2 Token 的 Key 结构与价值Token 的 key 设计是整个多端登录能否隔离好的关键。我采用的格式是login:token:{userId}:{clientType}举个例子用户 ID 为 1001在 PC 端登录后Redis 里的 key 是login:token:1001:pcvalue 是 token 字符串TTL 是 7 天。在小程序端登录后key 是login:token:1001:minivalue 是另一个 token 字符串TTL 是 2 天。这种 key 结构直接实现了两个效果多端隔离不同端互不影响PC 端登录不会搞掉小程序端的会话小程序端也不会把 App 端踢下线。同端互斥同一端再次登录时因为 key 是同一个新的 token 会直接覆盖旧 token旧设备上的 token 立即失效。这正好满足同一个账号在同一个端只能一个设备在线的常见需求。如果产品要求同一个账号在同一个端允许多设备同时在线只需把 key 改成login:token:{userId}:{clientType}:{random}或者用 value 存储一个 Set 集合。所以 key 设计为用户维度 端维度将来要调整也只是加一层粒度的问题。4.3 TokenService 的实现细节Token 的生成规则我建议直接用 UUID 去掉横线的形式不要去拼用户 ID、时间戳因为 token 本身就是一个不透明字符串它在 Redis 里已经关联了用户信息没必要在 token 本身里编码任何业务信息。编码越多泄露的风险越大。Service public class TokenService { private final StringRedisTemplate redisTemplate; private static final String TOKEN_KEY_PREFIX login:token:; private static final Duration DEFAULT_EXPIRE Duration.ofDays(7); public String createToken(Long userId, LoginType loginType) { // 生成一个随机的token值 String token UUID.randomUUID().toString().replace(-, ); // key中包含用户ID和端类型 String key buildTokenKey(userId, loginType); // 设置过期时间这里可以按端配置 Duration expire getExpireByLoginType(loginType); redisTemplate.opsForValue().set(key, token, expire); return token; } public Long getUserIdByToken(String token, LoginType loginType) { // 直接遍历所有用户ID不现实这里需要在token里关联userId // 方案一token在生成时以 login:token:{userId}:{clientType} 为keyvalue为token // 校验时无法通过token反推userId所以要另一套映射 // 我在项目里更推荐方案二login:tokenValue:{token} 存 userId同时保留上面这个key用来做踢人 String userIdStr redisTemplate.opsForValue().get(login:tokenValue: token); return userIdStr null ? null : Long.valueOf(userIdStr); } // ... }这个代码里我透露了一个重要的细节整个 Token 体系最少需要两组 key。第一组 key 是login:token:{userId}:{clientType}为了支持踢人和同端互斥。通过用户 ID 端类型可以精准地把某个用户在某端的登录态删掉。第二组 key 是login:tokenValue:{token}为了支持请求校验。请求拦截器拿到一个 token 字符串后不可能遍历所有用户去查这个 token 属于谁只能通过login:tokenValue:{token}直接反查用户 ID。这两组 key 在登录成功时需要同时写入在退出登录或踢人时需要同时删除。我用一张表把这两个 key 的关系整理清楚KeyValue作用过期时间login:token:{userId}:{clientType}token 字符串支持踢人、同端互斥与 token 一致login:tokenValue:{token}userId 字符串支持请求校验时反查用户与 token 一致我见过不少项目只做了第一组 key结果请求拦截器校验时只能先模糊匹配 keylogin:token:*再用 Java 遍历所有 key性能极差关键是不优雅。所以如果你要做在线用户管理、强制下线这类功能这两组 key 都得维护好。4.4 请求拦截器多端登录态如何统一校验有了 TokenService拦截器的逻辑就非常清晰了Component public class LoginInterceptor implements HandlerInterceptor { private final TokenService tokenService; Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { // 放行登录接口 if (request.getRequestURI().contains(/auth/login)) { return true; } String token request.getHeader(Authorization); String clientType request.getHeader(Client-Type); if (!StringUtils.hasText(token) || !StringUtils.hasText(clientType)) { throw new UnauthorizedException(未登录); } LoginType loginType LoginType.fromCode(clientType); if (loginType null) { throw new UnauthorizedException(非法客户端); } Long userId tokenService.getUserIdByToken(token); if (userId null) { throw new UnauthorizedException(登录已过期请重新登录); } // 将用户信息放入上下文 UserContext.set(userId, loginType); return true; } }注意这里我仍然把 clientType 作为请求头传递而不是塞进 token 里解析。原因很简单多端登录的核心思想就是同一套 token 体系、不同端使用不同的 key 隔离请求端告诉后端我是谁后端按领域逻辑校验你的 token 是否在对应 key 下有效。如果要求后端从 token 解析出端类型等于把整套隔离逻辑耦合在 token 的格式上以后要改 token 生成规则就非常痛苦。5. 扩展实战新增PC 端微信扫码登录只写一个类就够了设计模式有没有用不是看代码结构多漂亮而是看加需求的时候改动有多大。下面我以一个真实的扩展需求为例给现有系统新增PC 端微信扫码登录。看起来这是一个不小的需求PC 端点登录弹出一个二维码用户用微信扫一扫手机上确认PC 端自动登录成功。这涉及到扫码、轮询、确认、登录四个环节。但如果你的统一登录框架已经搭好了新增这个端的核心代码量会比你想象小得多。5.1 扫码登录的完整流程设计与状态机先捋一下扫码登录的技术流程PC 请求后端生成二维码后端生成一个唯一 ticket存入 Redis状态为待扫描TTL 为 5 分钟。PC 将 ticket 拼成二维码 URL展示给用户。用户用微信扫码微信小程序/公众号端携带 ticket 请求后端后端将 ticket 状态改为已扫描待确认。用户手机端点击确认登录小程序端携带 ticket 请求后端确认接口后端校验当前微信用户与 ticket 的绑定关系把 ticket 状态改为已确认。PC 端轮询后端根据 ticket 查状态发现已确认后后端返回登录 tokenPC 端完成登录。这个流程里第 1 步生成二维码和后端登录本身不是同一件事。生成二维码可以被看作一个获取登录预凭证的动作最终真正登录时策略里只需要拿 ticket 去 Redis 里查询用户信息即可。5.2 新增的策略类只专注如何用 ticket 换登录态我新增一个 WechatQrLoginStrategyComponent public class WechatQrLoginStrategy extends AbstractLoginStrategy { Override public LoginType getLoginType() { return LoginType.WECHAT_QR; } Override protected void validateParams(LoginRequest request) { if (!StringUtils.hasText(request.getLoginTicket())) { throw new LoginException(登录凭证不能为空); } } Override protected User doLogin(LoginRequest request) { String ticket request.getLoginTicket(); // 从Redis中查询ticket对应的用户信息扫码确认时写入 Object userIdObj redisTemplate.opsForValue().get(scan:ticket: ticket); if (userIdObj null) { throw new LoginException(二维码已过期请重新扫码); } Long userId Long.valueOf(userIdObj.toString()); // 删除ticket防止重复使用 redisTemplate.delete(scan:ticket: ticket); User user userService.getById(userId); if (user null) { throw new LoginException(用户不存在); } return user; } }然后只需要在 LoginType 枚举中加一个值WECHAT_QR(wechat_qr, PC微信扫码)就完成了。Controller、工厂、抽象类、TokenService、拦截器一律不用动。5.3 为什么这个扩展这么轻因为整个方案的正确性都建立在登录流程的骨架相同这个前提上。扫码登录和账号密码登录在骨架层面都是参数校验 → 获得用户 → 校验用户状态 → 生成 token → 记日志 → 返回结果。唯一不同的只是如何获得用户这一步账号密码靠密码匹配扫码靠 ticket 查 Redis。所以新增这个端时我完全不需要关注 token 怎么生成也不需要关注登录日志怎么记、用户状态怎么校验、返回结构长什么样。这些已经被抽象类封装好了我只写差异部分。这就是我在前文反复强调的策略模式的核心收益不在代码量少而在变更的局部化。新需求来了你只需要思考这个需求和现有逻辑的差异在哪里然后把差异写进一个新类里。其余的东西天然复用而且不会因为你的改动出现回归。6. 实战中踩过的坑策略失效、类型判断、日志与异常方案再好落地时总会遇到各种意想不到的问题。这一节我把实际踩过的坑和排查思路整理出来你在实施时大概率也会碰到。6.1 策略类被 Spring 代理后getLoginType() 返回了 null这是最容易踩、也最隐蔽的一个坑。我在第 3.3 节提到过 Spring 会注入代理对象的问题。真实场景是这样的我给某个策略类的login方法上加了一个自定义切面注解用于统计登录成功率。结果切面在目标方法执行前会经过 CGLIB 代理代理对象调用getLoginType()时方法内部如果依赖了某个成员变量而这个成员变量没有被正确初始化就容易返回 null。更常见的场景是策略类里加了缓存注解CacheableSpring 通过 AOP 生成代理对象。注入到工厂里的ListLoginStrategy实际是代理对象。如果切面表达式写得太宽把getLoginType()也拦截了那这个方法的行为可能和预期不一致。排查思路在工厂 put 的时候打日志把策略类的真实类型和登录类型都打出来。for (LoginStrategy strategy : strategies) { LoginType type strategy.getLoginType(); // 打印类型信息 log.info(Register login strategy: type{}, class{}, proxy{}, type, strategy.getClass().getName(), AopUtils.isAopProxy(strategy)); if (type null) { throw new IllegalStateException(LoginStrategy.getLoginType() 返回 null: strategy.getClass()); } strategyMap.put(type, strategy); }解决方案如果确实有策略类需要 AOP确保getLoginType()和getLoginType()对应的字段不被切面拦截或者不在代理方法内读取成员变量。最稳妥的做法是在常规场景下不要把切面加在策略类上而是加在更高层如 Controller 或者抽象父类的入口方法上。6.2 clientType 的取值混乱前端传值不稳定我前面说了用 LoginType 枚举来做归一化但实际操作中还会遇到一个衍生问题前端集成方可能把 clientType 传成wx、weixin、wechat、miniApp等千奇百怪的值。如果只靠LoginType.fromCode(clientType)严格匹配会直接拒绝这些请求导致线上故障。我的做法是提供一个比较宽松的解析方法先精确匹配找不到再去别名列表里找。public static LoginType fromCode(String code) { if (!StringUtils.hasText(code)) { return null; } // 先精确匹配 for (LoginType type : values()) { if (type.code.equals(code)) { return type; } } // 再匹配别名 String lowerCode code.toLowerCase(); for (LoginType type : values()) { for (String alias : type.aliases) { if (alias.equals(lowerCode)) { return type; } } } return null; }同时在 Controller 里一旦发现解析结果为 null日志里得把原始 clientType 打全方便前端排查。我在项目里还做了一个上报专门统计哪些非法 clientType 出现频率高集中推动前端修复。6.3 登录日志里的用户身份一不小心就打成了匿名用户日志是一个很不起眼但特别影响排查效率的问题。如果你在登录策略里打日志用的是用户查询出来后这件事的上下文那么不同策略里输出日志的用户标识字段可能不一致有的打 userId、有的打 username、有的打 phone。我更推荐在抽象类里统一日志格式每个环节都带上当前登录类型和特征值Slf4j public abstract class AbstractLoginStrategy implements LoginStrategy { Override public LoginResult login(LoginRequest request) { LoginType loginType getLoginType(); log.info([{}] 登录开始, request{}, loginType.getCode(), request); User user null; try { validateParams(request); user doLogin(request); checkUserStatus(user); String token tokenService.createToken(user.getId(), loginType); loginLogService.record(user.getId(), loginType, request.getClientIp()); log.info([{}] 登录成功, userId{}, loginType.getCode(), user.getId()); return LoginResult.of(user, token); } catch (Exception e) { // 注意这里不要打用户的密码或验证码等敏感信息 log.error([{}] 登录失败, loginType.getCode(), e); throw e; } } }这样不管哪个端登录失败日志里都能看到哪个端 什么错误排障效率会高非常多。但记住一个原则——日志里永远不要出现密码、完整验证码、完整 token。我见过同事把整个 request 对象打出来结果验证码直接进了日志文件这是个安全漏洞。6.4 强制下线/踢人接口的实现细节有了统一框架实现强制下线也简单了。管理员在后台点击强制下线按钮实际就是调用 TokenService 的注销方法public void logout(Long userId, LoginType loginType) { // 删除 按用户维度 的 key使同端登录失效 String userKey buildTokenKey(userId, loginType); String token redisTemplate.opsForValue().get(userKey); if (StringUtils.hasText(token)) { // 删除 token 反查 key使请求校验失效 redisTemplate.delete(login:tokenValue: token); } redisTemplate.delete(userKey); }这里有一个细节如果你只删了login:token:{userId}:{clientType}没有删login:tokenValue:{token}那么用户拿着旧 token 请求时拦截器用login:tokenValue:{token}反查用户 ID 依然能查到登录态依然有效。所以两个 key 必须一起删而且顺序上先删反查 key、再删主 key避免业务线程在中间态又刷新了 token。6.5 多端并发登录时的你把我踢了问题最后说一个产品层面很容易扯皮的问题。在有同端互斥的规则下如果同一个账号在 PC 端 A 浏览器登录了又在 PC 端 B 浏览器登录B 会把 A 踢下线。有些用户会反馈我明明在 A 设备上还开着页面怎么就突然退出登录了。解决的思路有两种提示型踢人A 设备下次请求时拦截器发现 token 失效返回一个特殊的已被踢下线错误码前端弹窗提示您的账号在另一台设备登录而不是简单地显示登录已过期。允许并发 手动下线同端允许多设备同时在线但用户可以在安全中心查看在线设备并手动下线。我建议在项目前期就明确产品要哪种方案因为这个规则直接影响 TokenService 的 key 粒度设计。如果你一开始就是login:token:{userId}:{clientType}这种单值结构想改成同端多设备在线就得把 value 改成集合改动就不小了。所以多端登录的方案设计一定要先和产品对齐同一端到底允许几个设备在线。结语一点实际的体会这套基于工厂 策略模式的统一多端登录我已经在两个项目里完整落地过一次是从零开始搭建一次是把老代码重构过来。最大的感受是设计模式从来不是为了用而用而是为了克制某些不可控的膨胀。登录逻辑是一个典型的会持续生长的领域今天有 PC、小程序、App明天就可能有扫码、免密、微信公众号。如果你的代码结构在一开始就没有为这种变化留出空间后面每一次新增都是对系统的再一次伤害。如果你正在维护一套登录逻辑散落各处、改一个端要用半天确认会不会影响其他端的代码我强烈建议抽出一天时间做一次这样的重构。方向就按这篇文章说的来抽象一个 LoginStrategy 接口写一个 AbstractLoginStrategy 抽走公共流程用工厂类按 LoginType 枚举管理全部策略Token 体系用双 key 支持多端隔离。重构完之后你会发现后续加登录方式真的只需要写一个类和一行枚举那种舒爽感是真实存在的。提示本文中的所有代码示例都是基于实际项目简化而来你在落地时需要根据自己的用户体系、缓存中间件、异常处理框架做适量调整。但方案的核心思路——策略接口、抽象模板、工厂注册、双 key Token 隔离——是完全可以通用的。
返回列表