## 注册 我们来到`UserRegisterController`,这里是C端用户注册的接口。我们来到注册接口最核心的代码: ``` User user = appConnectService.registerAndBindUser(userRegisterParam.getMobile(), userRegisterParam.getPassword(), null); ``` 在`registerAndBindUser`方法中,我们看到这里进行了注册,并且绑定了openid。 ```java @Override @Transactional(rollbackFor = Exception.class) public User registerAndBindUser(String mobile, String password, String tempUid) { ... // 新建用户 User user = new User(); user.setModifyTime(new Date()); user.setUserRegtime(new Date()); user.setUserRegip(IpHelper.getIpAddr()); user.setStatus(1); user.setUserMobile(mobile); if (StrUtil.isNotBlank(password)) { String decryptPassword = passwordManager.decryptPassword(password); PasswordUtil.check(decryptPassword); user.setLoginPassword(passwordEncoder.encode(decryptPassword)); } userMapper.insert(user); // 创建用户拓展信息 UserExtension userExtension = new UserExtension(); userExtensionMapper.insert(userExtension); //用户注册成功后发送等级提升事件 applicationContext.publishEvent(new LevelUpEvent(userExtension,1,0, Constant.PLATFORM_SHOP_ID, 1)); ... if (StrUtil.isNotBlank(tempUid)) { appConnectMapper.bindUserIdByTempUid(user.getUserId(), tempUid); } // 若开启通联,则同步创建通联个人会员 PaySettlementConfig paySettlementConfig = sysConfigService.getSysConfigObject(Constant.PAY_SETTLEMENT_CONFIG, PaySettlementConfig.class); if (Objects.equals(paySettlementConfig.getPaySettlementType(), PaySysType.ALLINPAY.value())) { CreateAllinpayMemberEvent event = new CreateAllinpayMemberEvent(); event.setUserIds(Collections.singletonList(user.getUserId())); if (StrUtil.isNotBlank(tempUid)) { AppConnect appConnect = appConnectMapper.getByTempUid(tempUid); AppConnectDTO appConnectDTO = new AppConnectDTO(appConnect.getUserId(), appConnect.getAppId(), appConnect.getTempUid()); event.setAppConnects(Collections.singletonList(appConnectDTO)); } applicationContext.publishEvent(event); } return user; } ``` 在这里我们可以看到进行了`user`、`user_extension`表数据的创建,并且如果存在tempUid会进行`user`表和`app_connect`表信息关联。最后如果有开通通联还会创建通联的会员。 ## C端用户登录 ### 微信环境登录 我们来到`SocialLoginController` ,这里是微信环境登录相关的接口。首先我们可以看到`/mp`接口。 再看看里面的代码: ```java @PostMapping("/mp") @Operation(summary = "公众号code登录", description = "通过公众号进行登录,只要在一进入到应用的时候调用微信登录的这个接口就行了,不要重复调用这个接口(不要在什么注册页面之类的地方再调用这个接口,打开应用的时候调用就已经足够了)" + "1.返回状态码 A04001 社交账号未绑定,当前端看到该异常时,将该值tempUid存起来,并在应该在合适的时间(比如在购买的时候跳),跳转到登录的页面" + "2.如果返回码是正常的登录成功,前端要保留原本已经存在的tempUid,如果有人切换登录的话,起码能够用这个tempUid重新获取openid进行绑定") public ServerResponseEntity mp(@RequestBody String code) throws WxErrorException { Integer socialType = SocialType.MP.value(); WxOAuth2AccessToken wxAccessToken = wxConfig.getWxMpService().getOAuth2Service().getAccessToken(code); AppConnect appConnect = appConnectService.getByBizUserId(wxAccessToken.getOpenId(), socialType); if (appConnect != null && appConnect.getUserId() != null) { return ServerResponseEntity.success(new TokenWithTempUidVO(appConnect.getTempUid(),getTokenVo(appConnect))); } String tempUid = IdUtil.simpleUUID(); WxOAuth2UserInfo wxUserInfo = wxConfig.getWxMpService().getOAuth2Service().getUserInfo(wxAccessToken, null); if (appConnect == null) { appConnect = new AppConnect(); } appConnect.setAppId(socialType); appConnect.setTempUid(tempUid); appConnect.setNickName(wxUserInfo.getNickname()); appConnect.setImageUrl(wxUserInfo.getHeadImgUrl()); appConnect.setBizUserId(wxUserInfo.getOpenid()); appConnect.setBizUnionid(wxUserInfo.getUnionId()); appConnectService.saveOrUpdate(appConnect); // 前端要保存这个tempUid return ServerResponseEntity.success(new TokenWithTempUidVO(appConnect.getTempUid(), null)); } ``` 可以看到这里前端将获取到的微信code(前端小程序是请求`wx.login`接口,公众号是由微信授权的网页)传给我们,然后我们通过这个code获取用户的openId保存到数据库。最后我们将`tempUid`等参数返回给前端。 这里为什么要一进来就获取openid并保存呢?是因为后续用户如果没有openid就没办法进行支付,所以我们一进来就获取了openid并保存到数据库。同理`/ma`是跟`/mp`一样的逻辑就不重复了。 我们接着来到`LoginController`,这里是C端用户统一登录的接口。首先我们看微信一键登录接口: ```java @PostMapping("/ma/login") @Operation(summary = "微信小程序一键登录" , description = "三合一登录(包含注册 + 绑定 + 登录)") public ServerResponseEntity maLogin(@Valid @RequestBody MaCodeAuthenticationDTO maCodeAuthenticationDTO) { WxMaPhoneNumberInfo newPhoneNoInfo; try { newPhoneNoInfo = wxConfig.getWxMaService().getUserService().getPhoneNoInfo(maCodeAuthenticationDTO.getCode()); } catch (WxErrorException e) { throw new YamiShopBindException(e.getMessage()); } // 没有区号的手机号,国外手机号会有区号 String mobile = newPhoneNoInfo.getPurePhoneNumber(); return threeInOneLogin(mobile, maCodeAuthenticationDTO.getTempUid(), SocialType.MA.value()); } ``` 可以看到我们先用微信的code获取到了用户的手机号,然后前端将上一步我们返回的`tempUid`又传给了我们,接着我们调用了`threeInOneLogin`方法。 ```java // 没有就注册 if (user == null) { if (Objects.equals(socialType,SocialType.MA.value()) || Objects.equals(socialType,SocialType.MP.value())) { appConnect = appConnectService.getByTempUid(tempUid); if (appConnect == null || appConnect.getUserId() != null) { return ServerResponseEntity.fail(ResponseEnum.TEMP_UID_ERROR); } } // 注册并绑定用户 user = appConnectService.registerAndBindUser(mobile, null, tempUid); } else { appConnect = checkAndGetAppConnect(user.getUserId(), socialType, tempUid); } ``` 如果当前用户没注册,在`registerAndBindUser`这里注册并根据`tempUid`将openid和当前用户进行了绑定 ```java @Override @Transactional(rollbackFor = Exception.class) public User registerAndBindUser(String mobile, String password, String tempUid) { ... // 新建用户 userMapper.insert(user); ... if (StrUtil.isNotBlank(tempUid)) { appConnectMapper.bindUserIdByTempUid(user.getUserId(), tempUid); } return user; } ``` 如果当前用户已经注册, 在`checkAndGetAppConnect`这里根据`tempUid`将openid和当前用户进行了绑定 ```java private AppConnect checkAndGetAppConnect(String userId, Integer socialType,String tempUid) { ... // 在SocialLoginController 当中,会返回一个tempUid用来换取openid的 AppConnect appConnect = appConnectService.getByTempUid(tempUid); // 二合一登录,啥意思呢? // 1. 绑定:将该账号和该微信的openId进行绑定 // 2. 登录:返回token登录成功 if (appConnect.getUserId() == null) { appConnectMapper.bindUserIdByTempUid(userId, appConnect.getTempUid()); } ... return appConnect; } ``` 最后微信环境的`账号密码登录`、`短信登录`跟上面的登录流程差不多,都是未注册就进行注册、绑定、登录三合一登录,或者注册了就进行绑定、登录二合一登录咯 ### 非微信环境登录 #### 短信登录 我们接着来到`LoginController`,我们看短信登录接口: ```java @PostMapping("/smsLogin") @Operation(summary = "短信登录" , description = "三合一登录(包含注册 + 绑定 + 登录)") public ServerResponseEntity smsLogin(@Valid @RequestBody SocialAuthenticationDTO authenticationDTO) { ... // 验证码登录 boolean validCode; try { validCode = smsLogService.checkValidCode(authenticationDTO.getUserName(), String.valueOf(authenticationDTO.getPassWord()), SendType.CAPTCHA , SceneTypeEnum.LOGIN.getValue()); } catch (YamiShopBindException e) { // 验证码校验过频繁,请稍后再试 throw new YamiShopBindException("yami.user.code.check.too.much"); } if (!validCode) { // 验证码有误或已过期 throw new YamiShopBindException("yami.user.code.error"); } // 如果没有注册的话,短信登录将会进行注册 // 在pc/小程序/公众号的登录,都有短信登录的方法。但是公众号/小程序的短信登录,登录了之后会见这个用户和公众号/小程序绑定一起(登录并绑定) return threeInOneLogin(authenticationDTO.getUserName(), authenticationDTO.getTempUid(), authenticationDTO.getSocialType()); } ``` 可以看到在验证码校验后,我们调用了`threeInOneLogin`方法跟上面说的登录流程一样,这里就不多赘述了。 #### 账号密码登录 我们接着来到`LoginController`,这里是C端用户统一登录的接口。首先我们看账号密码登录: ```java @PostMapping("/login") @Operation(summary = "账号密码(用于h5、pc登录)" , description = "通过账号/手机号/用户名密码登录,还要携带用户的类型,也就是用户所在的系统") public ServerResponseEntity login( @Valid @RequestBody AuthenticationDTO authenticationDTO) { String mobileOrUserName = authenticationDTO.getUserName(); User user = getUser(mobileOrUserName); String decryptPassword = passwordManager.decryptPassword(authenticationDTO.getPassWord()); // 半小时内密码输入错误十次,已限制登录30分钟 passwordCheckManager.checkPassword(SysTypeEnum.ORDINARY,user.getUserMobile(),decryptPassword, user.getLoginPassword()); return ServerResponseEntity.success(getTokenInfoVO(user, null)); } ``` 可以看到我们调用了`passwordManager.decryptPassword`方法来进行密码解密后返回用于后续密码校验 ``` /** * 用于aes签名的key,16位 */ @Value("${auth.password.signKey:-mall4j-password}") public String passwordSignKey; public String decryptPassword(String data) { SecureUtil.disableBouncyCastle(); AES aes = new AES(passwordSignKey.getBytes(StandardCharsets.UTF_8)); String decryptStr; String decryptPassword; try { decryptStr = aes.decryptStr(data); } catch (Exception e) { logger.error("Exception:", e); throw new YamiShopBindException("yami.password.manager.exception.aesDecodeError", e); } long currentTimeMillis = System.currentTimeMillis(); long timestamp = Long.parseLong(decryptStr.substring(0, 13)); // 签名时间大于十分钟,提示请求超时 if (timestamp + TEN_MINUTES < currentTimeMillis) { throw new YamiShopBindException("yami.password.manager.exception.outTime"); } decryptPassword = decryptStr.substring(13); return decryptPassword; } ``` 可以看到我们首先进行了时间戳的验证,如果时间戳超时,则提示请求超时。然后通过AES密钥进行解密,这里面使用了签名Key来进行AES加密后返回进行跟用户密码进行校对。 这个aes签名的key需要跟前端一致,前端也是用这个签名key将密码进行加密后传给后端,用于保障传输信息安全。 最后在`passwordCheckManager.checkPassword`方法里面的核心代码: ```java passwordEncoder.matches(rawPassword,encodedPassword); ``` 可以看到这里使用了`Spring`提供的密码加密工具,通过`matches`方法对密码进行校验。先通过`passwordManager.decryptPassword`解密获取明文密码`rawPassword`,再与前端加密后传入的`encodedPassword`进行比对,验证密码是否一致。 ## 管理端登录 我们来到`PlatformLoginController`,这里是平台端的登录接口。 ```java @PostMapping("/platformLogin") @Operation(summary = "账号密码 + 验证码登录(用于后台登录)" , description = "通过账号/手机号/用户名密码登录") public ServerResponseEntity login(@Valid @RequestBody CaptchaAuthenticationDTO captchaAuthenticationDTO) { // 登陆后台登录需要再校验一遍验证码 CaptchaVO captchaVO = new CaptchaVO(); captchaVO.setCaptchaVerification(captchaAuthenticationDTO.getCaptchaVerification()); ResponseModel response = captchaService.verification(captchaVO); if (!response.isSuccess()) { return ServerResponseEntity.showFailMsg(I18nMessage.getMessage("yami.user.code.error")); } SysUser sysUser = sysUserMapper.selectByUsername(captchaAuthenticationDTO.getUserName()); if (sysUser == null) { throw new YamiShopBindException("yami.user.account.error"); } String decryptPassword = passwordManager.decryptPassword(captchaAuthenticationDTO.getPassWord()); // 半小时内密码输入错误十次,已限制登录30分钟 passwordCheckManager.checkPassword(SysTypeEnum.MULTISHOP,captchaAuthenticationDTO.getUserName(), decryptPassword, sysUser.getPassword()); // 不是店铺超级管理员,并且是禁用状态,无法登录 if (sysUser.getUserId()!= Constant.SUPER_ADMIN_ID && Objects.equals(sysUser.getStatus(),0)) { // 未找到此用户信息 throw new YamiShopBindException("yami.platform.user.account.lock"); } UidInfoBO uidInfoBO = new UidInfoBO(SysTypeEnum.PLATFORM, sysUser.getUserId().toString(), Constant.PLATFORM_SHOP_ID); uidInfoBO.setAdmin(sysUser.getUserId()== Constant.SUPER_ADMIN_ID ? 1:0); // 存储token返回vo TokenInfoVO tokenInfoVO = tokenStore.storeAndGetVo(uidInfoBO); return ServerResponseEntity.success(tokenInfoVO); } ``` 可以看到这里跟C端账号密码登录的逻辑一样,只是这里登录的接口跟C端登录的接口不同,都是先进行密码验证,然后进行用户状态的验证,都校验通过时进行token存储并返回给前端。 ## 商家端 我们来到`MultishopLoginController`,这里有商家端的`/shopLogin`登录接口,我们来到`baseShopLogin`方法。 ``` @Override public TokenInfoVO baseShopLogin(CaptchaAuthenticationDTO authDTO) { // 登录类型校验 ShopLoginType shopLoginType = ShopLoginType.getInstance(authDTO.getShopLoginType()); if (Objects.isNull(shopLoginType)) { throw new YamiShopBindException("yami.shop.login.exception.loginTypeError"); } // 验证码校验 CaptchaVO captchaVO = new CaptchaVO(); captchaVO.setCaptchaVerification(authDTO.getCaptchaVerification()); ResponseModel response = captchaService.verification(captchaVO); if (!response.isSuccess()) { throw new YamiShopBindException("yami.user.code.error"); } // 登录 if (Objects.equals(shopLoginType, ShopLoginType.STATION)) { // 门店登录 return this.doStationLogin(authDTO); } else { // 商家 return this.doShopImLogin(authDTO); } } ``` 可以看到这里区分了门店或者品牌的登录,至于具体的实现我们可以看到`doShopImLogin`、`doStationLogin`方法。 ``` private TokenInfoVO doShopImLogin(CaptchaAuthenticationDTO authDTO) { // 商家账号校验 ShopEmployeeGetEvent event = new ShopEmployeeGetEvent(); event.setUsername(authDTO.getUserName()); event.setShopId(authDTO.getShopId()); applicationContext.publishEvent(event); ShopEmployeeVO shopEmployeeVO = event.getShopEmployeeVO(); if (Objects.isNull(shopEmployeeVO)) { throw new YamiShopBindException("yami.user.account.error"); } // 密码校验 String decryptPassword = passwordManager.decryptPassword(authDTO.getPassWord()); passwordCheckManager.checkPassword(SysTypeEnum.MULTISHOP,authDTO.getUserName(), decryptPassword, shopEmployeeVO.getPassword()); // 不是店铺超级管理员,并且是禁用状态,无法登录 if (!Objects.equals(PositionType.ADMIN.value(), shopEmployeeVO.getType()) && Objects.equals(shopEmployeeVO.getStatus(),0)) { throw new YamiShopBindException("yami.shop.user.account.lock"); } // 返回token UidInfoBO uidInfoBO = new UidInfoBO(SysTypeEnum.MULTISHOP, shopEmployeeVO.getEmployeeId().toString(), shopEmployeeVO.getShopId()); uidInfoBO.setAdmin(Objects.equals(PositionType.ADMIN.value(), shopEmployeeVO.getType())? 1 : 0); return tokenStore.storeAndGetVo(uidInfoBO); } ``` 可以看到这里跟上面的登录逻辑一样,这里就不多赘述了。 ## 授权 我们来到`AuthFilter`,这里是我们项目的整个授权入口,这里面会进行token的验证和权限的验证。 ```java @Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)throws IOException, ServletException { HttpServletRequest req = (HttpServletRequest) request; HttpServletResponse resp = (HttpServletResponse) response; String grantType = req.getHeader(SignUtils.GRANT_TYPE); // 通过签名访问 if (Objects.equals(grantType, SignUtils.GRANT_TYPE_VALUE)) { chain.doFilter(req, resp); return; } String requestUri = req.getRequestURI(); List excludePathPatterns = authConfigAdapter.excludePathPatterns(); AntPathMatcher pathMatcher = new AntPathMatcher(); // 如果匹配不需要授权的路径,就不需要校验是否需要授权 if (CollectionUtil.isNotEmpty(excludePathPatterns)) { for (String excludePathPattern : excludePathPatterns) { if (pathMatcher.match(excludePathPattern, requestUri)) { chain.doFilter(req, resp); return; } } } ... } ``` 可以看到会先进行如果是签名访问就直接放行,签名文档可以看同目录下的`mall4j第三方接口对接协议`, 然后会进行判断请求的接口是否需要授权,如果匹配不需要授权的路径,就不需要校验是否需要授权,授权路径配置三个端在对应服务的`ResourceServerAdapter`类。 api服务 ``` public class ResourceServerAdapter extends DefaultAuthConfigAdapter { public static final List EXCLUDE_PATH = Arrays.asList( "/p/score/scoreLevel/listLevels", "/p/station/userstation", "/p/wx/jsapi/createJsapiSignature", "/p/station/getStationInfo", "/p/station/oneRecentlyStation", "/p/area/getCurrentCity", "/p/distribution/user/getByCardNo" ); @Override public List pathPatterns() { return Arrays.asList("/p/*"); } @Override public List excludePathPatterns() { return EXCLUDE_PATH; } } ``` multishop服务 ``` @Component public class ResourceServerAdapter extends DefaultAuthConfigAdapter { public static final List EXCLUDE_PATH = Arrays.asList( "/webjars/**", "/swagger/**", "/v3/api-docs/**", "/doc.html", "/swagger-ui.html", "/swagger-resources/**", "/captcha/**", "/order/refund/result/**", "/sys/webConfig/getActivity", "/shop/shopUserRegister/**", "/shopLogin", "/actuator/health/liveness", "/actuator/health/readiness", "/sys/lang", "/notice/im/online", "/jst/auth/callBack", "/jst/auth/inventory/callBack", "/jst/order/deliveryCallBack", "/jst/order/cancelOrderCallBack", "/jst/order/confirmOrCancelRefundCallBack" ); @Override public List excludePathPatterns() { return EXCLUDE_PATH; } } ``` platform服务 ``` @Component public class ResourceServerAdapter extends DefaultAuthConfigAdapter { public static final List EXCLUDE_PATH = Arrays.asList( "/webjars/**", "/swagger/**", "/v3/api-docs/**", "/doc.html", "/swagger-ui.html", "/swagger-resources/**", "/captcha/**", "/sys/webConfig/getActivity", "/platformLogin", "/actuator/health/liveness", "/actuator/health/readiness", "/sys/public/config/**", "/sys/lang" ); @Override public List excludePathPatterns() { return EXCLUDE_PATH; } } ``` 可以看到这里的`pathPatterns()`方法返回的是需要授权的路径,`excludePathPatterns()`方法返回的是不需要授权的路径,如果没有`pathPatterns()`就是除`excludePathPatterns()`外都是需要授权的路径。 接下来我们继续看`AuthFilter`后半段代码,这里面会进行token的验证和权限的验证。 ```java String accessToken = req.getHeader(tokenName); // 也许需要登录,不登陆也能用的uri // 比如优惠券接口,登录的时候可以判断是否已经领取过 // 不能登录的时候会看所有的优惠券,等待领取的时候再登录 boolean mayAuth = pathMatcher.match(AuthConfigAdapter.MAYBE_AUTH_URI, requestUri); String uid = null; try { // 如果有token,就要获取token if (StrUtil.isNotBlank(accessToken)) { // 校验登录,并从缓存中取出用户信息 // token访问 uid = tokenStore.getUidByToken(accessToken); if (uid == null) { httpHandler.printServerResponseToWeb(ServerResponseEntity.fail(ResponseEnum.UNAUTHORIZED)); return; } } else if (!mayAuth) { // 返回前端未授权 httpHandler.printServerResponseToWeb(ServerResponseEntity.fail(ResponseEnum.UNAUTHORIZED)); return; } // 保存上下文 AuthUserContext.set(uid); chain.doFilter(req, resp); } catch (Exception e) { if (e instanceof YamiShopBindException) { httpHandler.printServerResponseToWeb((YamiShopBindException) e); return; } throw e; } finally { AuthUserContext.clean(); } ``` 可以看到我们对每次用户请求时的token进行校验,如果是存在的就校验通过,否则就返回前端未授权。 最后看我们的商家端和平台端部分接口会有这行代码 ```java @PreAuthorize("@pms.hasPermission('xxx')") ``` 我们这个在`Spring Security`的方法级别权限控制中,用来在方法执行前判断是否有指定权限。当有请求访问被该注解标记的接口(比如某个`@GetMapping`的接口)时,`Spring Security`在调用 Controller 方法之前,会拦截并执行 @PreAuthorize 表达式中的逻辑。 ```java public boolean hasPermission(String permission) { if (StrUtil.isBlank(permission)) { return false; } // 从上下文获取权限 Set perms = PmsContext.get(); // 从登录的用户获取权限 if (CollUtil.isEmpty(perms)) { GetPermissionEvent getPermissionEvent = new GetPermissionEvent(); applicationContext.publishEvent(getPermissionEvent); perms = getPermissionEvent.getPerms(); } // 框架处理会抛出异常在前端提示:服务器出了小差,请稍后重试 // 所以没有权限,就自行处理并抛出异常,且告诉用户缺失的菜单权限是哪些,同时避免服务器异常的提示 boolean hasPermission = perms .stream() .filter(StringUtils::hasText) .anyMatch(x -> PatternMatchUtils.simpleMatch(permission, x)); if(!hasPermission) { applicationContext.publishEvent(new PermissionErrorHandleEvent(permission, AuthUserContext.getShopId())); } return hasPermission; } ``` 这个`GetPermissionEvent`事件会从数据库中获取当前用户权限,然后返回进行判断。 ```java @Override @Cacheable(cacheNames = SysCacheNames.SYS_USER_PERMISSION, key = "#userId") public Set getUserPermissions(Long userId) { List permsList; //系统管理员,拥有最高权限 if(userId == Constant.SUPER_ADMIN_ID){ List menuList = sysMenuMapper.selectList(Wrappers.emptyWrapper()); permsList = menuList.stream().map(SysMenu::getPerms).collect(Collectors.toList()); }else{ permsList = sysUserMapper.queryAllPerms(userId); } return permsList.stream().flatMap((perms)->{ if (StrUtil.isBlank(perms)) { return null; } return Arrays.stream(perms.trim().split(StrUtil.COMMA)); } ).collect(Collectors.toSet()); } ``` 这段是获取平台端的用户权限,如果是商家端的话,会从数据库中获取当前商家用户权限。然后根据权限判断进行判断,如果没有权限会自行处理并抛出异常并告诉用户缺失的菜单权限是哪些。