ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

策略模式+工厂模式:重构多端登录逻辑的最佳实践

策略模式+工厂模式:重构多端登录逻辑的最佳实践 “每次加一个登录方式就改一遍Controller那段if-else是不是已经长到你不敢动了”如果你维护过稍微有点年头的 SpringBoot 项目大概率遇到过这种场景小程序端要手机号快捷登录管理后台要账号密码登录内部系统又要走LDAP或企业微信扫码App 端还得支持验证码登录。一开始图省事所有逻辑都塞在一个 login 接口里通过 type 字段做分支判断。等到登录方式超过三种方法体膨胀到两三百行每次改动都提心吊胆——改密码登录时怕影响小程序加新渠道时怕漏掉某个特殊逻辑。这就是典型的登录逻辑混乱。这篇博文要解决的问题很具体用工厂模式 策略模式把多端登录逻辑彻底拆开保证“不修改现有代码就能扩展新登录方式”同时让每种登录方式的核心逻辑各自独立、互不干扰。适合正在做多端应用、被登录逻辑困扰的后端开发也适合想搞懂这两个设计模式在真实项目中怎么落地的朋友。我尽量把设计思路、代码细节、踩坑记录都写清楚争取你看完就能直接改自己的项目。1. 先把“多端登录混乱”的问题彻底拆清楚1.1 业务场景还原你的登录逻辑到底乱在哪先说一个典型的业务模型。现在很多产品都是“一云多端”用户在小程序里用微信授权登录在手机App里用手机号加验证码登录在PC管理后台用账号密码登录还有一部分内部员工走企业微信扫码。表面上看都是“登录”其实每一端的认证方式、参数结构、校验规则、安全要求完全不一样。不假思索的写法长什么样大概是这样PostMapping(/login) public Result login(RequestParam Integer type, RequestParam String account, RequestParam String password, RequestParam(required false) String code, RequestParam(required false) String phone, ...) { if (type 1) { // 账号密码登录查用户、比对密码、生成token } else if (type 2) { // 短信验证码登录校验验证码、查手机号、生成token } else if (type 3) { // 小程序登录wx.login 换 openid、查绑定关系、生成token }... }这种代码最致命的问题有三个参数全堆在方法签名里。每种登录方式需要的参数不一样但接口必须定义一个万能入参结果就是字段越来越冗余前端传参混乱后端校验时还要写一堆“这个字段哪个方式才用得上”的判断。登录逻辑全耦合在一起。密码策略的修改会影响验证码登录验证码校验的调整会让小程序登录也跟着改改一个地方就要回归全部登录方式。扩展性等于零。要新增一个“游客登录”或“邮箱登录”只能继续加 else if代码越来越长越来越难读直到某天连原作者都不敢动了。1.2 为什么这种混乱“防不胜防”其实很少有人一开始就故意写烂代码。登录逻辑最初确实只有一个密码登录后来加小程序后来加验证码每次新增都想着“先加上再说以后有时间再重构”——结果“以后”永远不来。这是多端登录混乱的第一个原因演化式需求 vs 短期实现。第二个原因是登录逻辑本身包含大量隐性分支登录成功之后要生成token、要记录登录日志、要更新最近登录时间、要处理“账号被锁定”这种异常状态、要判断是否首次登录。这些后置操作在不同端、不同用户类型之间还有差异。如果全部平铺在 Controller 里逻辑复杂度是指数增长的。第三个原因是团队协作层面小程序端可能由一个组负责管理后台由另一个组负责大家都在改同一个 LoginController。Git 冲突不断代码互相覆盖谁也不敢保证自己合入的代码没有影响别人负责的登录方式。所以做统一多端登录核心目标不是“少写代码”而是建立一套可扩展、可隔离、可维护的登录处理框架。这正是策略模式 工厂模式的用武之地。2. 方案选型为什么是“策略模式工厂模式”而不是其他方案2.1 策略模式先把“怎么登录”拆开策略模式解决的是“一个行为有多种实现且可以在运行时动态选择”的问题。放到登录场景里就是“登录”这个行为有“账号密码登录”“短信验证码登录”“小程序登录”等不同策略每种策略的实现互不感知各自封装各自的算法。调用方不用关心具体怎么登录只需要传入一个可识别的标识例如loginTypepassword或loginTypesms策略容器就能给出对应的处理逻辑。生活化的类比餐厅里点餐甜品、主食、饮料各有各的做法。客人不需要跑到后厨告诉厨师“我要用锅炒、要用烤箱烤”只需要说“我要一份炒饭”厨师看到订单就知道该用哪个灶台。这里的“订单”就是登录类型标识“厨师”就是策略容器“不同菜品的做法”就是不同的策略实现。这样做的好处很直接新增登录方式时只需要新增一个策略类不碰任何现有代码。后面我会展示这个“零修改扩展”的落地过程。2.2 工厂模式解决“怎么拿到正确的策略”策略模式定义了一堆策略类但调用方怎么知道哪一类来处理最朴素的做法是再写一遍 if-elseif (password.equals(loginType)) { return passwordStrategy; }。这等于把分支判断换了个位置问题依然在。工厂模式就是来消灭这个分支的。它的职责只有一个根据传入的标识返回对应的策略实例。结合 Spring 的容器能力我们可以把每个策略类注册成 Spring Bean然后在工厂里维护一个“标识 - Bean”的映射。这样既没有 if-else也天然支持扩展——新增一个策略并注册为 Bean工厂自动就能找到它甚至连工厂类都不用改。可能有人会问那直接用 Spring 的依赖查找不行吗其实工厂模式的本质就是做一次“类型到实现”的映射Spring 容器本身就是一个大工厂。我们写一个策略工厂本质上是把分散的 Bean 按业务规则组织起来对外提供清晰、单一的方法接口调用方不用感知 Spring 细节。这在代码可读性和可测试性上都有优势。2.3 对比其他方案为什么不用模板方法模式或简单 if-else模板方法模式也常被拿来处理“同一流程的不同步骤”。比如定义登录流程骨架参数校验 - 认证 - 生成token - 返回结果然后让子类重写“认证”这一步。这个思路不差但它解决的问题和策略模式不一样。模板方法要求所有登录方式共享一个核心流程骨架可现实中的多端登录前置校验、认证输入、后置处理差异非常大——小程序登录没有“账号密码”的概念短信登录有额外的验证码校验步骤管理后台登录可能要校验IP白名单和二次验证。把差异巨大的逻辑塞进同一套流程骨架里要么模板方法被挖出大量空方法和钩子方法要么子类为了绕过模板逻辑搞出很多奇怪的重写。相比之下策略模式的每种登录完全独立天然适配“差异大于共性”的场景。至于纯 if-else只适用于登录方式不超过两种、且业务不会再增长的场景。一旦超过三种代码复杂度不是线性增长而是指数增长这点前面已经验证过了。还有一个考虑是策略模式 工厂模式的组合非常契合 SpringBoot 的自动装配思想。每个策略作为一个独立组件职责单一、可单测、可替换这符合依赖倒置原则。登录逻辑的统一入口只依赖抽象接口不依赖具体实现将来无论是改密码校验算法还是替换短信服务商都局限在单个策略内部不会震荡到全局。3. 核心代码落地从接口设计到工厂实现3.1 工程结构规划先看整体包结构。我习惯按功能域去组织而不是按技术层。登录相关的代码放在一个login包下com.example.project.login ├── controller │ └── LoginController.java ├── strategy │ ├── LoginStrategy.java │ ├── PasswordLoginStrategy.java │ ├── SmsLoginStrategy.java │ └── MiniAppLoginStrategy.java ├── factory │ └── LoginStrategyFactory.java ├── token │ └── TokenService.java └── model ├── LoginRequest.java └── LoginResponse.java这样划分的好处是登录相关的一切都在同一个包下新人接手时一眼就能知道“登录这边有什么组件”改动时也不需要跨好几个包去翻代码。特别是策略类都放在strategy包下新增登录方式时新文件的落位是明确的不用思考“我应该放哪个目录”。3.2 顶层策略接口设计策略接口是整个方案的核心契约。接口定义得是否合理直接决定后面扩展是否顺利。我的建议是接口先定义“输入”和“输出”两个模型方法不要贪多public interface LoginStrategy { /** * 当前策略支持的登录类型标识。 * 例如 password / sms / miniApp */ String getLoginType(); /** * 统一的登录入口。 * * param request 登录请求参数不同策略解析自己需要的部分 * return 登录响应包含token、用户信息等 */ LoginResponse login(LoginRequest request); }接口只有一个业务方法这很关键。有些方案会把接口拆成validate()、authenticate()、generateToken()多个方法看起来是把流程固化了其实对多端登录来说过度设计。因为不同策略对这些步骤的定义差距太大强行统一会让子类为了实现接口而增加很多无意义的空方法。只有一个login()方法每种策略内部想怎么编排自己的步骤都行。LoginRequest和LoginResponse是策略之间传递数据的载体。接口一定要能承载所有策略需要的字段但又不能把所有字段都声明出来。我的做法是LoginRequest里放“公共查询参数 扩展字段的兜底”public class LoginRequest { /** * 登录类型由 Spring 根据请求参数绑定 */ private String loginType; /** * 账号密码登录时使用 */ private String account; /** * 密码密码登录时使用 */ private String password; /** * 手机号短信登录时使用 */ private String phone; /** * 短信验证码短信登录时使用 */ private String smsCode; /** * 小程序登录临时凭证对应 wx.login() 返回的 code */ private String minAppCode; /** * 备用扩展字段承载未来新增登录方式所需的参数 */ private MapString, Object extraParams; }这里要补充说明一下有人会觉得把所有字段堆在同一个类里不优雅这我承认。但在实际项目中登录接口的入参本身就来自一个 HTTP 请求反序列化结果额外做一层“按策略拆分 DTO”的成本比较高收益有限。只要控制好边界——策略内部只从LoginRequest中取自己需要的字段不要依赖无关字段——这个模型在工程上就足够实用了。3.3 三种典型策略的实现先说最简单的账号密码登录。这是大多数系统的基础也是安全要求最高的策略之一。核心逻辑分三步按账号查用户、比对密码、生成 token。密码比对必须要用加密方式任何明文比对或可逆加密都是不可接受的。Component public class PasswordLoginStrategy implements LoginStrategy { private final UserMapper userMapper; private final PasswordEncoder passwordEncoder; private final TokenService tokenService; public PasswordLoginStrategy(UserMapper userMapper, PasswordEncoder passwordEncoder, TokenService tokenService) { this.userMapper userMapper; this.passwordEncoder passwordEncoder; this.tokenService tokenService; } Override public String getLoginType() { return password; } Override public LoginResponse login(LoginRequest request) { // 1. 参数校验 Assert.hasText(request.getAccount(), 账号不能为空); Assert.hasText(request.getPassword(), 密码不能为空); // 2. 查询用户 User user userMapper.selectByAccount(request.getAccount()); if (user null || !passwordEncoder.matches(request.getPassword(), user.getPassword())) { throw new LoginException(账号或密码错误); } // 3. 账号状态判断 if (user.getStatus() UserStatus.DISABLED) { throw new LoginException(账号已被禁用); } // 4. 生成 token 并返回 String token tokenService.createToken(user.getId(), getLoginType()); return LoginResponse.of(token, user); } }第二步短信验证码登录。和密码登录的区别在于第一步校验的不是密码而是验证码用户身份的唯一标识是手机号登录成功后往往还要处理“手机号未注册就自动创建用户”的逻辑。这也是多端登录实战中很常见的需求。Component public class SmsLoginStrategy implements LoginStrategy { private final SmsCodeService smsCodeService; private final UserMapper userMapper; private final TokenService tokenService; Override public String getLoginType() { return sms; } Override public LoginResponse login(LoginRequest request) { Assert.hasText(request.getPhone(), 手机号不能为空); Assert.hasText(request.getSmsCode(), 验证码不能为空); // 1. 校验验证码 boolean isValid smsCodeService.validate(request.getPhone(), request.getSmsCode()); if (!isValid) { throw new LoginException(验证码错误或已过期); } // 2. 查询或创建用户 User user userMapper.selectByPhone(request.getPhone()); if (user null) { user createUserByPhone(request.getPhone()); } // 3. 生成 token String token tokenService.createToken(user.getId(), getLoginType()); return LoginResponse.of(token, user); } }第三步小程序登录。小程序端的认证流程比前两者要复杂一些核心是wx.login()获取临时 code后端拿 code 调用微信服务换取 openid。这里要注意微信的 code 是一次性的而且有效期很短换到 openid 后要立即使用。另外小程序登录策略一般还要区分“新用户绑定手机号”和“老用户直接登录”两种流程。Component public class MiniAppLoginStrategy implements LoginStrategy { private final WxService wxService; private final UserMapper userMapper; private final TokenService tokenService; Override public String getLoginType() { return miniApp; } Override public LoginResponse login(LoginRequest request) { Assert.hasText(request.getMinAppCode(), 小程序授权码不能为空); // 1. 用 code 换 openid WxSession session wxService.code2Session(request.getMinAppCode()); if (session null || session.getOpenid() null) { throw new LoginException(小程序授权失败); } // 2. 查用户是否已绑定该 openid User user userMapper.selectByOpenId(session.getOpenid()); // 3. 首次登录通常需要绑定手机号 if (user null) { if (request.getPhone() null || request.getSmsCode() null) { throw new LoginException(首次登录请绑定手机号); } // 此处复用短信验证码校验 boolean smsValid smsCodeService.validate(request.getPhone(), request.getSmsCode()); if (!smsValid) { throw new LoginException(手机号验证码错误); } user createUserByOpenIdAndPhone(session.getOpenid(), request.getPhone()); } String token tokenService.createToken(user.getId(), getLoginType()); return LoginResponse.of(token, user); } }三个策略类放在一起对比能明显看出“独立性”密码登录里的密码校验逻辑不可能影响到短信登录的验证码校验小程序登录不管账号密码那套校验也不依赖短信登录的实现。这给后续维护带来了极大自由度。3.4 策略工厂消灭所有 if-else 的“调度中心”有了策略实现下一步就是工厂。这里有两种写法一种是自己维护一个 Map在构造方法或初始化时手动 put另一种是利用 Spring 的依赖注入把容器里所有的LoginStrategy接口实现自动收集到 Map 里。我强烈推荐第二种代码量最小且天然支持扩展。Component public class LoginStrategyFactory { private final MapString, LoginStrategy strategyMap; /** * Spring 在实例化工厂时会自动收集容器中所有 LoginStrategy 类型的 Bean * key 为 Bean 名称value 为 Bean 实例。 * 这里更推荐的做法每个策略在自己的 getLoginType() 返回标识 * Spring 注入后我们再重新归类。 */ public LoginStrategyFactory(ListLoginStrategy strategies) { strategyMap strategies.stream().collect( Collectors.toMap(LoginStrategy::getLoginType, Function.identity()) ); } public LoginStrategy getStrategy(String loginType) { LoginStrategy strategy strategyMap.get(loginType); if (strategy null) { throw new LoginException(不支持的登录类型: loginType); } return strategy; } }写代码时有两个细节要重点提如果开发过程中发现两个策略返回了相同的getLoginType()Collectors.toMap会抛出IllegalStateException。这是好事能在启动时就发现配置错误而不是等到运行时请求进来了才发现。但要注意如果你希望“后注册的覆盖先注册的”可以用toMap(keyMapper, valueMapper, (oldValue, newValue) - newValue)。如果某个策略没有注册为 Bean它就进不了这个 Map。工厂本身并不依赖具体策略类它只知道LoginStrategy接口这是整个扩展机制的基石。3.5 Controller入口只做“路由”不做业务有了策略工厂控制器的代码会变得非常薄。它只承担两个职责解析公共参数、从工厂拿到策略并执行。RestController public class LoginController { private final LoginStrategyFactory loginStrategyFactory; public LoginController(LoginStrategyFactory loginStrategyFactory) { this.loginStrategyFactory loginStrategyFactory; } PostMapping(/login) public ResultLoginResponse login(RequestBody LoginRequest request) { LoginStrategy strategy loginStrategyFactory.getStrategy(request.getLoginType()); LoginResponse response strategy.login(request); return Result.ok(response); } }注意这里没有出现任何 if-else也没有任何业务判断。进来的请求像一张订单餐厅前台把订单交给对应厨师至于这道菜怎么做前台完全不关心。这就是“统一入口 策略路由”的效果。3.6 Spring 注册加载的关键点很多第一次实现这个方案的人会卡在一个细节上策略工厂构造方法里接收的ListLoginStrategy到底怎么把 Bean 收集起来的这涉及 Spring 依赖注入的一个重要特性——收集注入。当你在构造方法参数或Autowired字段中声明List类型时Spring 会把容器中所有实现该接口的 Bean 自动注入到这个集合中。注入的顺序默认是按 Bean 名称排序的但这个顺序在策略工厂中并不重要因为我们最终会转化成Map以getLoginType()作为新的 key。为了保证每个策略类都能被 Spring 管理需要在每个策略类上标注ComponentSpring 扫描或通过其他方式注册为 Bean。我的习惯是统一使用Component并且尽量显式声明Component(passwordLoginStrategy)避免默认的短类名在引用时混淆。还有一个容易踩坑的点是 SpringBoot 的组件扫描范围。如果你的login.strategy包不在主程序类所在的包及其子包之下Component是扫描不到的。我在实际项目中就遇到过把策略类放在独立模块里忘了加ComponentScan导致启动后工厂 Map 为空的情况。排查了半天控制台没有任何报错只是每次调用都提示“不支持的登录类型”这是因为工厂里确实没有注册任何策略。4. 令牌统一、多端会话与安全细节不能省4.1 同一用户多端登录的 token 策略登录成功后的 token 管理是多端登录里最容易出问题的环节。假设同一个用户在手机 App、小程序、管理后台都登录了服务端应该怎么看待这三条会话有一种方案是“同端互踢、异端共存”同一个用户在同一端上的新登录会强制旧 token 失效但不同端各留各的会话。实现时需要在 token 里额外携带端信息。比如我生成 token 时会把getLoginType()拼进自定义字段或直接在 Redis key 上做区分login:token:{userId}:{loginType}:{token}后续请求进来时拦截器可以从 token 中解析出 userId 和 loginType然后拼出完整的 Redis key 去查询会话是否存在这样就能精确控制“哪个端踢哪个端”。另一种方案更严格只允许同一用户全局只有一个会话任何端的新登录都会挤掉老会话。这种方案适合内部管理系统、权限敏感的后台因为同一账号在管理后台重复登录确实应该被控制。方案没有绝对好坏但整个系统里必须统一。不要今天是异端共存明天改成全局唯一否则用户会不断遇到“莫名其妙被踢下线”的反馈。4.2 密码加密和验证码校验的经验密码加密这块使用 BCrypt 这类自带盐的 Hash 算法是起步要求。如果你是第一次用 Spring Security 的BCryptPasswordEncoder建议直接注入它来调用PasswordEncoder encoder new BCryptPasswordEncoder(); // 注册时 String encoded encoder.encode(rawPassword); // 登录校验时 boolean matched encoder.matches(rawPassword, encoded);千万别自己发明“MD5加盐两次”“AES可逆加密存库”这类方案看似聪明实则脆弱。一个重要原因是BCryptPasswordEncoder.matches()本身就是专门为“校验输入的密码是否等于已存储哈希”设计的它内置了加盐比对逻辑密码错误时也不会给出任何可观察的细微差异能有效防时序攻击。验证码校验的经验是验证码必须是一次性的。校验成功后立即从 Redis 删除防止重放攻击。另外验证码的过期时间不要太长一般五分钟就足够太长了容易被暴力撞库利用。还要注意验证码校验的“对比”动作不能在数据库里做要放 Redis 这类可以设置过期时间的高性能存储里。4.3 登录接口限流与防暴力破解登录接口天然是攻击者的目标。没有限流的登录接口等于给暴力撞库开了绿灯。常见的防护手段有基于 IP 限流同一个 IP 在短时间内如一分钟内请求登录接口超过阈值如 20 次就临时封禁。这个可以基于 Redis 计数实现。基于账号限流同一个账号五分钟内连续登录失败超过 5 次就锁定账号十分钟。锁定到期后自动解除管理员可以手动提前解锁。图形验证码或滑块验证触发条件一般是“前几次登录失败后出现”而不是每次都要求输入这样用户体验会更友好。这些限流逻辑放在独立的过滤器或 AOP 切面里不要写进策略类。策略类专注认证本身限流是横切关注点应该用统一的组件处理。这也是我在项目里要求“业务逻辑分层”的一个原因。5. 常见问题与排查技巧实录5.1 启动成功后调用接口报“不支持的登录类型”这个问题的原因通常是策略类没有被 Spring 扫描到或者策略类上的Component注解丢了。排查步骤很简单先看启动日志里策略类有没有被打印出来可以在策略类中加初始化日志在构造方法里打一行。再看工厂注入的 Map 长度在断点模式下查看strategyMap.size()是否为预期值。如果 Map 长度为 0重点检查包扫描路径。SpringBoot 主类默认扫描主类所在包及其子包策略类如果放在主类包之外就需要在启动类上手动添加ComponentScan或MapperScan一类注解。还有一个小坑是如果同一个策略类被Component声明了又在配置类里用了Bean返回同一个类型的实例Spring 会注册两个 Bean这时Map注入时可能因为 key 冲突而启动失败。所以一个策略类只保留一种注册方式不要双保险。5.2 新增了一个登录策略但工厂 Map 没有变化这个问题通常和 Spring 缓存有关尤其在 IDEA 里修改代码后没有热部署时最明显。先确认修改已经编译并生效。另外如果你改动的是策略类上的Component(xxx)名称Spring 容器启动时会重新扫描不会保留旧 Bean。如果确认代码没问题重建项目再启动一般就正常了。还有一种隐蔽情况策略类被 AOP 代理了。某些全局切面如日志切面、事务切面会为策略类生成代理对象代理会调用目标方法但getLoginType()如果是 final 方法或 static 方法代理可能拿不到正确值。建议getLoginType()做成普通实例方法不要添加 final 修饰符。5.3 策略内部调用别的策略方法时事务失效在账号密码登录里注册用户、更新登录时间、写入登录日志这些动作需要事务。问题来了如果PasswordLoginStrategy内部自调用自己的另一个Transactional方法或通过this.demo()方式调用事务是不会生效的因为 Spring 的事务代理只拦截外部进入的调用。解决办法有两种一是把需要事务的步骤提取到独立的 Service Bean如UserRegisterService在策略类中注入它让事务调用发生在两个 Bean 之间二是在策略类上直接标注Transactional前提是策略类本身被 Spring 托管且调用是从外部进入的即通过工厂拿到策略后调用login()时事务可以生效。我推荐第一种更明确且更不容易出错。在实际项目中策略类内部的事务还有一个特殊问题密码登录需要“校验失败时抛出异常回滚”这个异常抛出时如果已经被事务切面捕获回滚就不可靠。所以异常要用Transactional(rollbackFor Exception.class)显式声明回滚条件而不是依赖默认的运行时异常回滚。5.4 多端登录后 token 互相顶掉常见原因就是没有做“端的隔离”。前端传一个loginType进来后端生成 token 时没有把它作为区分维度Redis 里的 key 只用了userId导致 App 登录后小程序的 token 也被踢了。我的排查经验是先抓实际请求链路在 token 生成处看 userId 和 loginType 的值在 token 校验处看解析结果然后用 Redis 客户端手动查看 key 的完整结构。正常情况下不同端的 token 应该对应不同的 Redis key。如果没有就是 key 设计的问题需要把loginType拼进 key 或 token 自身。5.5 登录接口报错后客户端收到模糊信息无法定位异常处理也是一个经常被忽略的点。策略内部抛出的业务异常如果在全局异常处理器里没有针对性处理可能被笼统地转成“系统异常”返回这对前端排查极度不友好。我的做法是自定义一个LoginException在全局异常处理器中单独拦截ExceptionHandler(LoginException.class) public ResultVoid handleLoginException(LoginException e) { return Result.fail(e.getMessage()); }同时不要把所有异常细节都返回给前端。真正要记录的是栈信息返回给客户端的只是用户能看懂的业务描述。安全上也要注意——登录接口的错误提示如果太细致容易被攻击者用来枚举账号。比如“账号不存在”和“密码错误”是两个响应就会泄露账号有效性。建议统一成“账号或密码错误”。6. 生产环境扩展与团队协作建议6.1 新端接入时团队要遵守的“落地约定”把方案交给团队后光有代码不够还得有一个简单的约束规范。我习惯在开发文档里明确几条规则新增登录方式时在strategy包下新建一个类实现LoginStrategy接口。策略类的getLoginType()返回值必须在团队约定的枚举或常量类里登记避免前端传错字母导致 500。策略类中不要写日志切面之外的通用的登录后置逻辑比如“登录成功发短信”应该通过事件监听机制解耦。每个策略类单独写单元测试尤其是登录成功分支和登录失败分支。工厂的测试比较简单验证“给不同 type 能不能拿到正确的策略实例”即可。有了这个约定多端登录的扩展就可以安静地进行了。每个人的改动都局限在自己的策略文件里Git 冲突概率降到最低代码审查时也只需要关注这一个文件。6.2 和 Spring Security / Sa-Token 等框架的配合如果在项目中已经引入了安全框架策略模式的位置需要想清楚。以 Sa-Token 为例它的StpUtil.login(userId)负责创建会话前面的“用户名密码校验”往往放在自己的登录接口里。我们的策略模式正好可以覆盖“登录前置认证逻辑”策略内部只管是否通过认证通过后就调用StpUtil.login(userId, loginType)创建会话。策略返回的LoginResponse里可以带上 Sa-Token 的 token 信息后续接口拦截验证交给框架处理。如果用的是 Spring Security密码认证的AuthenticationManager本身就和策略模式有一部分重叠。合理的做法是把“密码校验”这一部分委托给策略Security 负责统一请求过滤和会话管理。这种职责划分避免了“两种认证体系互相打架”。6.3 登录后置流程用事件机制解耦登录成功后往往有一堆后续动作更新最后登录时间、发登录通知、记录登录设备、统计登录人数、风控行为采集。把这些全部写在策略类尾部会重新让策略变得臃肿。我的做法是登录成功后发布一个自定义的 Spring 事件public class LoginSuccessEvent extends ApplicationEvent { private final User user; private final String loginType; private final String ip; private final String userAgent; }策略类只负责在认证成功后publishEvent(event)具体的监听器各自异步或同步处理自己关心的内容。例如更新最后登录时间的监听器、写操作日志的监听器、同步第三方系统的监听器。这样做之后策略类内部的核心逻辑保持在十行以内新增的后置需求也不需要反复改策略代码。有一个需要注意的坑监听器默认是同步执行的如果你的后置逻辑比较慢比如发短信会拖慢登录接口响应时间。建议对非关键后置逻辑使用Async并把线程池配置好。但也要注意用了Async之后后置逻辑异常不会自动回滚主流程事务所以只放非关键操作。6.4 日志与监控多端登录场景下的可观测性多端登录的痛点之一是“用户说登录不了”但难以判断是哪一段出了问题。我在日志规范上做了两个强制要求一是每个策略类的关键步骤必须打印带有loginType和userId的日志格式统一为[login][password][userId123]方便用 grep 快速聚合二是在 token 创建和校验处埋点输出耗时和成功/失败计数接入 Prometheus 后做登录成功率的实时监控告警。实际做过这种监控之后你会发现大多数“登录异常”都可以被快速归类要么是验证码服务超时要么是微信接口网络波动要么是密码库偶发连接池耗尽。没有这些埋点排查全靠人工翻日志效率完全没法比。7. 我的一些额外体会最后再分享几个我自己的体会。这套方案我看过很多团队实现过效果差的往往是“只学了框架没学思想”——策略类是拆了但 Controller 里还是疯狂用 if-else 判断登录类型后再拿不同策略或者策略内部互相 new 其他策略类代码依然耦合得厉害。判断一套实现好不好我有一个简单标准新增一种登录方式时能不能保证不修改任何现有文件如果答案是“还要改工厂”“还要改 Controller”就说明抽象还没做彻底。还有一个体会是关于“过度设计”的。如果你的项目只有一种登录方式而且未来两三年也看不到第二种那策略模式就是过度设计。模式是为变化准备的没有变化就没有必要。但当你的产品进入多端阶段登录方式肉眼可见地要往三四种以上走尽早用这套方案成本是最低的。等代码已经乱到几百行再重构费用就不是写一套新架构的问题了而是要面对大量兼容性测试和脏数据的清理。从长期维护的角度看策略模式 工厂模式带来的不只是“代码好看了”而是“团队对登录模块的恐惧感消失了”。新人改代码时敢动了出问题时能秒级定位到具体策略文件了加新登录方式时可以做代码评审而不是全局大考了。我始终认为好的后端设计不是炫技而是让业务变化来临时代码依然稳得住、能扩展、修得动。希望这套思路对你的多端登录改造有实际帮助。
返回列表