# 后端手册 >sql 及 ip解析db放在 sp-app resource目录中 请自行使用 ## 启动类 ``` java sp/sp-app/src/main/java/com/anji/sp/SpAppApplication.java ``` ## 公共模块(sp-common) ### 枚举 - `IsDeleteEnum` 是否删除枚举 * 处理数据逻辑删除状态枚举 - `UserStatus` 用户状态枚举 * 数据启用禁用状态枚举 ### 工具类 #### `AESUtil` AES加密解密工具类 登录pc 密码通过AES加密 后台拿到加密数据进行解密 得到真实密码 进行后续处理 ```java String content = "my password"; System.out.println("加密前:" + content); System.out.println("加密密钥和解密密钥:" + KEY); String encrypt = AESUtil.aesEncrypt(content, KEY); System.out.println("加密后:" + encrypt); String decrypt = AESUtil.aesDecrypt(encrypt, KEY); System.out.println("解密后:" + decrypt); ``` #### `APPVersionCheckUtil`版本校验工具类 - 通过拿到本地版本和线上版本进行对比 - 将本地版本和线上版本进行转数组并转int类型,比对每组的大小 ``` java String[] oldArray = oldVersion.replaceAll("[^0-9.]", "").split("[.]"); String[] newArray = newVersion.replaceAll("[^0-9.]", "").split("[.]"); for (int i = 0; i < length; i++) { if (Integer.parseInt(newArray[i]) > Integer.parseInt(oldArray[i])) { return 1; } else if (Integer.parseInt(newArray[i]) < Integer.parseInt(oldArray[i])) { return -1; } } // doSomthing // ... ``` #### `Constants`通用常量信息 包含 用户登录相关、应用相关静态常量 #### `RSAUtil` RSA加密解密工具类 主要是处理APP SDK 请求数据加密解密 ``` java // RSA加密 String data = "这是加密的json文件内容"; String encryptData = RSAUtil.encrypt(data, getPublicKey(publicKey)); System.out.println("加密后内容:" + encryptData); // RSA解密 String decryptData = RSAUtil.decrypt(encryptData, getPrivateKey(privateKey)); System.out.println("解密后内容:" + decryptData); // RSA签名 String sign = RSAUtil.sign(data, RSAUtil.getPrivateKey(privateKey)); System.out.println("签名:" + sign); // RSA验签 boolean result = RSAUtil.verify(data, getPublicKey(publicKey), sign); System.out.print("验签结果:" + result); ``` ## 权限模块(sp-auth) > 主要包含 用户登录处理、基础数据配置、菜单权限相关等 ### 自定义切面 #### `AuthorizeAspect` 用户菜单权限 通过切面,根据数据用户菜单关联表,赋予用户对应的菜单权限 ```java @Around("authorizePointCut()") public Object around(ProceedingJoinPoint point) throws Throwable { LoginUser loginUser = SecurityUtils.getLoginUser(); if (loginUser.getUser().getIsAdmin() != 1) { // 获得注解 PreSpAuthorize preSpAuthorize = getAnnotationLog(point); if (preSpAuthorize == null) { throw new AccessDeniedException("权限不足"); } String params = argsArrayToString(point.getArgs()); //解析请求参数是否含有appId long appId = JSONObject.parseObject(params).getLongValue("appId"); //权限码 String value = preSpAuthorize.value(); //SpUserVO spUserVO SpUserVO spUserVO = new SpUserVO(); spUserVO.setUserId(loginUser.getUser().getUserId()); spUserVO.setIsAdmin(loginUser.getUser().getIsAdmin()); Map> longSetMap = permissionService.selectUserMenuPerms(spUserVO); loginUser.setPermissions(longSetMap); Set strings = loginUser.getPermissions().get(appId); if (!hasPermissions(strings, value)) { throw new AccessDeniedException("权限不足"); } } //执行方法 return point.proceed(); } ``` #### `LogAspect` 用户操作行为日志 ```java protected void handleLog(final JoinPoint joinPoint, final Exception e, Object jsonResult, Long beginTime, Long endTime, Long time) { try { // 获得注解 Log controllerLog = getAnnotationLog(joinPoint); if (controllerLog == null) { return; } // 获取当前的用户 LoginUser loginUser = SecurityUtils.getLoginUser(); // *========数据库日志=========*// SpOperLogPO operLog = new SpOperLogPO(); operLog.setBeginTime(beginTime); operLog.setEndTime(endTime); operLog.setTime(time); // 返回参数 operLog.setJsonResult(JSON.toJSONString(jsonResult)); operLog.setOperUrl(ServletUtils.getRequest().getRequestURI()); if (loginUser != null) { operLog.setOperName(loginUser.getUsername()); } if (e != null) { operLog.setErrorMsg(StringUtils.substring(e.getMessage(), 0, 2000)); } // 设置方法名称 String className = joinPoint.getTarget().getClass().getName(); String methodName = joinPoint.getSignature().getName(); operLog.setMethod(className + "." + methodName + "()"); // 设置请求方式 operLog.setRequestMethod(ServletUtils.getRequest().getMethod()); // 处理设置注解上的参数 getControllerMethodDescription(joinPoint, controllerLog, operLog); operLog.setOperTime(new Date()); // 保存数据库 operLogMapper.insert(operLog); } catch (Exception exp) { // 记录本地异常日志 exp.printStackTrace(); } } ``` ### 配置 基础配置:包含解决跨域配置、数据库配置、异常配置、MybatisPlus配置、redission配置、安全认证配置、swagger配置等 #### `RedissonConfig` Redisson配置 由于`Redisson`分布式锁具有简单易用,且支持redis单实例、redis哨兵、redis cluster、redis master-slave等各种部署架构,是分布式锁的一种最佳选择。 ```java //单机 Config config = new Config(); config.useSingleServer() .setAddress("redis://" + host + ":" + port) .setPassword(password); return Redisson.create(config); ``` #### `SecurityConfig` 安全配置 `configure` 匹配所有请求路径 ``` java protected void configure(HttpSecurity httpSecurity) throws Exception { } ``` ### 接口层 包含登录、字典表、应用表、菜单表、角色菜单关联表、用户应用角色表等接口调用 #### `LoginController`登录 - 用户登录接口:`/login/v1` #### `SpApplicationController`应用接口 - 查询所有应用:`/select/v1` - 分页查询所有应用:`/selectByPage/v1` - 新建应用:`/insert/v1` - 更新应用名:`/update/v1` - 删除应用:`/delete/v1` #### `SpDictController`字典接口 - 分页进行查询字典表:`/selectByPage/v1` - 根据字典类型查询字典表:`/selectByType/v1` - 插入iOS或Android字典类型:`/insert/v1` - 检查iOS或Android字典类型的值是否可编辑或删除:`/checkVersion/v1` - 根据版本id更新iOS或Android字典类型的值:`/updateById/v1` - 删除iOS或Android字典类型的值:`/deleteById/v1` #### `SpMenuController`菜单接口 - 查询所有菜单:`/select/v1` - 根据父级id查询菜单:`/selectByParentId/v1` #### `SpRoleController`角色接口 - 查询所有角色:`/select/v1` - 根据角色id删除角色:`/deleteByRoleId/v1` - 新增角色及菜单:`/insertRoleAndMenu/v1` - 根据角色id更新角色:`/update/v1` - 根据项目id查询未加入对应应用管理的用户:`/selectNoJoinApplicationByAppId/v1` #### `SpRoleMenuController`角色菜单关联接口 - 根据角色roleId查询对应菜单:`/selectByRoleId/v1` - 更新角色菜单关联表:`/update/v1` - 根据角色id删除角色菜单表:`/delete/v1` #### `SpUserAppRoleController`用户应用角色关联 - 分页查询关联关系用户信息:`/selectByAppId/v1` - 新增用户应用角色关联:`/insert/v1` - 删除用户项目关联表数据:`/delete/v1` - 更改项目用户角色:`/update/v1` - 根据用户查询项目信息:`/selectAppInfo/v1` - 根据appId和userId 查询菜单信息:`/selectMenuPermissionsByAppIDAndUserId/v1` #### `UserController`用户接口 - 新增用户:`/addUser/v1` - 用户列表:`/queryByPage/v1` - 更新用户:`/updateUserById/v1` - 删除用户:`/deleteUserById/v1` ### 枚举 主要包含 返回应答码枚举类 ``` java SUCCESS("0000", "成功"), ERROR("0001", "操作失败"), EXCEPTION("9999", "服务器内部异常"), VERSION_EXIST("1101", "版本已存在"), VERSION_INSERT_FAILURE("1102", "添加失败"), APP_EXIST("1103", "项目已存在"), APP_NOT_EXIST("1103", "项目不存在"), NOT_OPERATION("1104", "该用户不是管理员,无法操作"), ... ``` ### 工具类 #### `IPUntils` 解析类 由于 spring boot打包后在服务器上无法读取resources下文件 本地和服务器使用`ip2region.db`解析在`getCityInfo`方法中需要配置对应的目录 ```java public static String getCityInfo(String ip) { //db 本地资源 db // String dbPath = IPUntils.class.getResource("/ip/ip2region.db").getPath(); String dbPath = "/app/ip2region.db"; File file = new File(dbPath); if (file.exists() == false) { log.info("Error: Invalid ip2region.db file"); return null; } // doSomething // ... } ``` #### `SecurityUtils`安全服务工具类 主要包含: - 获取用户信息 - 获取用户id - 获取用户账户 - 获取`Authentication` ## 版本管理模块(sp-version) ### 上传功能 接口: `/uploadFile/v1` - 根据上传的apk文件进行解析 - 文件写入使用`MultipartFil` - 文件路径后缀保存格式:包名+版本名+上传时间+版本号+appkey+.apk ### 版本管理 - 根据应用id查询所有版本信息:`/select/v1` - 版本新增:`/insert/v1` - 版本编辑:`/update/v1` - 根据id启用/禁用版本:`/enable/v1` - 版本删除:`/delete/v1` > 核心: >>`iOS`新增通过versionName进行对比 `Android`新增通过versionName和versionNumber进行对比 强制更新可用通过app版本号或者操作系统版本号进行比对 ```java if (APPVersionCheckUtil.compareAppVersion(versionInfo.getVersionName(), reqData.getVersionName()) < 1) { return ResponseModel.errorMsg("版本名不能低于" + versionInfo.getVersionName()); } if ("Android".contains(reqData.getPlatform()) && Integer.parseInt(versionInfo.getVersionNumber()) >= Integer.parseInt(reqData.getVersionNumber())) { return ResponseModel.errorMsg("版本号不能低于" + versionInfo.getVersionNumber()); } // app系统版本号入 1.0.0, 1.0.1... // 操作系统版本号入 Android8,Android9,Android10... ``` >灰度发布: >>灰度发布分为7天,从0开始到100%, 如果在7天之内某一天禁用灰度发布功能, 灰度已用、可用发布时间将会暂停,等再次开启时将继续计时 ```java /** * 更新版本时间 * @param vo * @return */ @Override public int updateSpVersionTime(SpVersionVO vo) { //返回自从GMT 1970-01-01 00:00:00到此date对象上时间的毫秒数 Long enableTimeStamp = vo.getEnableTime().getTime(); //当前时间戳 毫秒数 Long nowTimeStamp = System.currentTimeMillis(); //相差时间戳 Long gap = nowTimeStamp - enableTimeStamp; //已用时间 Long canaryReleaseUseTime = vo.getOldCanaryReleaseUseTime(); //新已用时间 Long newCanaryReleaseUseTime = canaryReleaseUseTime + gap; if (newCanaryReleaseUseTime < 0) { newCanaryReleaseUseTime = 0L; } vo.setCanaryReleaseUseTime(newCanaryReleaseUseTime); return updateAppVersion(vo); } ``` ## 公告管理模块(sp-notices) > 核心: >> 后台系统会根据当前时间判断公告时间是否过期,将用户提供在有效期内的公告信息 为移动端提供一个公告管理中心 ### 公告接口 - 分页根据应用id分页查询公告信息:`/select/v1` - 新增公告信息:`/insert/v1` - 编辑公告信息:`/update/v1` - 根据id启用/禁用公告信息:`/enable/v1` - 删除公告信息:`/delete/v1` ### 清除过期公告 ```XML update sp_notice set enable_flag = 0 where end_time sysdate() ``` ## APP调用模块(sp-app) > appsdk 调用移动服务平台的核心功能是版本更新和公告,故这部分单独抽离出来 通过RSA加密解码获取用户数据校验 ```java if (needDecrypt) { //------------------RSA解密------------------- log.info("spApplicationPO -- > {}", spApplicationPO); //解密 String decryptData = RSAUtil.decrypt(spAppReqDataVO.getSign(), RSAUtil.getPrivateKey(spApplicationPO.getPrivateKey())); log.info("解密decryptData -- > {}", decryptData); spAppLogVO = JSON.parseObject(decryptData, SpAppLogVO.class); log.info("vo -- > {}", vo); log.info("解密解析 spAppLogVO -- > {}", spAppLogVO); //解密时效 if (Objects.isNull(spAppLogVO)) { responseModel.setRepCodeEnum(RepCodeEnum.DATA_PARSING_INVALID); return responseModel; } //appKey不一致 if (!vo.getAppKey().equals(spAppLogVO.getAppKey())) { responseModel.setRepCodeEnum(RepCodeEnum.INVALID_FORMAT_APP_KEY); return responseModel; } //设备id不一致 if (!vo.getDeviceId().equals(spAppLogVO.getDeviceId())) { responseModel.setRepCodeEnum(RepCodeEnum.DATA_REQUEST_INVALID); return responseModel; } //----------------------------------------------------------- } ``` ### 初始化 - 接口 `/deviceInit` 初始化操作 异步保存初始化log ```java //校验 ResponseModel responseModel = checkReq(spAppReqDataVO, request, "1", "初始化"); //不通过返回 if (!responseModel.isSuccess()) { return responseModel; } SpAppReqVO reqVO = (SpAppReqVO) responseModel.getRepData(); int i = spAppLogMapper.insert(reqVO.getSpAppLogPO()); CompletableFuture.supplyAsync(() -> spAppDeviceService.updateDeviceInfo(reqVO.getSpAppLogPO())); if (i > 0) { return ResponseModel.success(); } return ResponseModel.errorMsg("初始化失败"); ``` ### 版本更新 - 接口 `/appVersion` 1. 校验appkey、平台类型、操作系统版本号、APP系统版本号、APP系统版本名是否存在 ``` java if (StringUtils.isEmpty(spAppLogPO.getPlatform())) { return ResponseModel.errorMsg("获取失败"); } if (StringUtils.isEmpty(spAppLogPO.getOsVersion())) { responseModel.setRepCodeEnum(RepCodeEnum.OS_VERSION_INVALID); return responseModel; } if ("Android".equals(spAppLogPO.getPlatform())) { if (StringUtils.isEmpty(spAppLogPO.getVersionCode())) { responseModel.setRepCodeEnum(RepCodeEnum.APP_VERSION_INVALID); return responseModel; } } if ("iOS".equals(spAppLogPO.getPlatform())) { if (StringUtils.isEmpty(spAppLogPO.getVersionName())) { responseModel.setRepCodeEnum(RepCodeEnum.APP_VERSION_INVALID); return responseModel; } } ``` 2. 根据APP系统版本号或者操作系统版本号进行比对是否进行版本更新 ```java if (Objects.nonNull(vo)) { SpVersionAppTempVO spVersionAppTempVO = new SpVersionAppTempVO(); BeanUtils.copyProperties(vo, spVersionAppTempVO); //os版本号 String osVersion = APPVersionCheckUtil.getOSVersion(spAppLogPO.getOsVersion()) + ""; SpVersionForAPPVO spVersionForAPPVO = new SpVersionForAPPVO(); BeanUtils.copyProperties(spVersionAppTempVO, spVersionForAPPVO); //版的数据中的版本号是否大于接口传过来的版本号 boolean showUpdate = false; if ("Android".equals(spAppLogPO.getPlatform())) { //数据中的versionNumber是否大于接口传过来的versionCode showUpdate = Integer.parseInt(vo.getVersionNumber().trim()) > Integer.parseInt(spAppLogPO.getVersionCode().trim()); } if ("iOS".equals(spAppLogPO.getPlatform())) { int v = APPVersionCheckUtil.compareAppVersion(spAppLogPO.getVersionName(), spVersionAppTempVO.getVersionName()); log.info("compareAppVersion {}", v); showUpdate = v > 0; } spVersionForAPPVO.setShowUpdate(showUpdate); //如果不需要更新 强制更新也为false 否则进行处理 if (!showUpdate) { spVersionForAPPVO.setMustUpdate(false); } else { //[1.1.1,1.1.2,1.1.3] if (Objects.isNull(vo.getNeedUpdateVersionList())) { vo.setNeedUpdateVersionList(new ArrayList<>()); } //[10,11,12] if (Objects.isNull(vo.getVersionConfigStrList())) { vo.setVersionConfigStrList(new ArrayList<>()); } spVersionForAPPVO.setMustUpdate(vo.getNeedUpdateVersionList().contains(spAppLogPO.getVersionName()) || vo.getVersionConfigStrList().contains(osVersion)); // spVersionForAPPVO.setMustUpdate(spVersionAppTempVO.getVersionConfig().contains(osVersion)); } return ResponseModel.successData(spVersionForAPPVO); } ``` 3. 处理灰度发布策略:(部分用户进行版本更新)根据当前版本APP用户数及灰度发布阶段计算出当前灰度发布可接收到版本更新 ```java /** * 处理灰度发布策略 * * @param spAppLogVO * @param vo * @return 是否可以发送数据 */ private boolean canaryReleaseConfig(SpAppLogVO spAppLogVO, SpVersionVO vo) { //1、接口信息及版本信息 判断 deviceId、appkey、platform是否存在 不存在 不返回信息 if (Objects.isNull(vo) || Objects.isNull(spAppLogVO) || StringUtils.isEmpty(spAppLogVO.getDeviceId()) || StringUtils.isEmpty(spAppLogVO.getAppKey()) || StringUtils.isEmpty(spAppLogVO.getPlatform())) { return false; } //2、Android if ("Android".equals(spAppLogVO.getPlatform())) { //数据中的versionNumber是否大于接口传过来的versionCode //app版本是否大于等于数据版本 跳过 返回信息 if (StringUtils.isNotEmpty(spAppLogVO.getVersionName()) && StringUtils.isNotEmpty(vo.getVersionName()) && Integer.parseInt(vo.getVersionNumber().trim()) <= Integer.parseInt(spAppLogVO.getVersionCode().trim())) { return true; } } // 3、iOS if ("iOS".equals(spAppLogVO.getPlatform())) { // APP版本>= 数据版本 跳过 if (StringUtils.isNotEmpty(spAppLogVO.getVersionName()) && StringUtils.isNotEmpty(vo.getVersionName()) && APPVersionCheckUtil.compareAppVersion(vo.getVersionName(), spAppLogVO.getVersionName()) > -1) { return true; } } //4、不开启灰度发布直接跳过 展示数据 //开启时间没有直接跳过 展示数据 //灰度发布时间超过7天直接跳过 展示数据 if (vo.getCanaryReleaseEnable() == UserStatus.DISABLE.getIntegerCode() || Objects.isNull(vo.getEnableTime()) || vo.getCanaryReleaseUseTime() > defaultTimeStamp) { return true; } //5、读取Redis中对应 appKey_versionName 为key是否包含对应deviceID String cacheKey = Constants.APP_VERSION_KEYS + spAppLogVO.getAppKey() + "_" + spAppLogVO.getPlatform() + "_" + vo.getVersionName(); RLock redissionLock = redissonClient.getLock(cacheKey); try { redissionLock.lock(30, TimeUnit.SECONDS); //返回自从GMT 1970-01-01 00:00:00到此date对象上时间的毫秒数 Long enableTimeStamp = vo.getEnableTime().getTime(); //当前时间戳 毫秒数 Long nowTimeStamp = System.currentTimeMillis(); //相差时间戳 Long gap = nowTimeStamp - enableTimeStamp; //已用时间 Long canaryReleaseUseTime = vo.getOldCanaryReleaseUseTime(); //新已用时间 Long newCanaryReleaseUseTime = canaryReleaseUseTime + gap; if (newCanaryReleaseUseTime < 0) { newCanaryReleaseUseTime = 0L; } //未用时间 Long nowGap = defaultTimeStamp - newCanaryReleaseUseTime; //如果小于0 代表灰度发布已结束 if (nowGap < 0) { spVersionService.updateSpVersionTime(vo); return true; } //灰度发布确认 if (Objects.nonNull(vo.getCanaryReleaseStageList()) && vo.getCanaryReleaseStageList().size() == 7) { //已用时间除以每天的时间戳 得到当前是第几天 int c = (new Long(newCanaryReleaseUseTime).intValue()) / defaultTimeStampOneDay; //1、拿到百分比 比如0.4 (数据库) double percentage = Integer.parseInt(vo.getCanaryReleaseStageList().get(c)) / 100.0; //2、如果大于1 全部发布 运行请求 if (percentage >= 1) { spVersionService.updateSpVersionTime(vo); return true; } //3、查询当前appKey所有deviceID(去重) count Long deviceIdCount = Long.valueOf(spAppDeviceService.selectCount(spAppLogVO)); //4、count * 0.4 取整 int reqCount = new Double((new Long(deviceIdCount).intValue()) * percentage).intValue(); //如果数值少于1,也代表全部 if (reqCount < 1) { spVersionService.updateSpVersionTime(vo); return true; } log.info("sql cacheKey: {}", cacheKey); log.info("sql deviceIdCount: {}", deviceIdCount); log.info("sql reqCount: {}", reqCount); QueryWrapper queryWrapper = new QueryWrapper<>(); queryWrapper.eq("app_key", spAppLogVO.getAppKey()); queryWrapper.eq("version_name", vo.getVersionName()); //查询灰度发布已经收到版本更新接口的用户设备唯一标识表 List spAppReleasePOS = spAppReleaseMapper.selectList(queryWrapper); log.info("sql spAppReleasePOS: {}", spAppReleasePOS); //将列表唯一标示转换为 string list List cacheList = spAppReleasePOS.stream().map(s -> s.getDeviceId()).collect(Collectors.toList()); log.info("sql cacheList: {}", cacheList); //为空代表没有数据需要添加 if (StringUtils.isEmpty(cacheList) || cacheList.size() == 0) { int i = insertReleasePO(spAppLogVO, vo); log.info("insertReleasePO: {}", i); spVersionService.updateSpVersionTime(vo); return true; } //如果不为空 判断 //6、如果包含 运行拿到数据 return 1 if (cacheList.contains(spAppLogVO.getDeviceId())) { return true; } //7、如果不包含 Redis中数据条数与灰度数目进行比较 r 和 c //8、如果 r > c return 0 不允许返回 if (cacheList.size() >= reqCount) { return false; } //9、如果 r < c 返回1 并保持到Redis中 int i = insertReleasePO(spAppLogVO, vo); log.info("insertReleasePO: {}", i); //10、最后将 newCanaryReleaseUseTime 更新到version数据表中 spVersionService.updateSpVersionTime(vo); return true; } return false; } finally { redissionLock.unlock(); } } ``` ### 公告 - 接口 `/appNotice` ```java //校验 ResponseModel responseModel = checkReq(spAppReqDataVO, request, "3", "公告信息"); //不通过返回 if (!responseModel.isSuccess()) { return responseModel; } SpAppReqVO reqVO = (SpAppReqVO) responseModel.getRepData(); SpApplicationPO spApplicationPO = reqVO.getSpApplicationPO(); //异步保存log CompletableFuture.supplyAsync(() -> spAppLogMapper.insert(reqVO.getSpAppLogPO())); CompletableFuture.supplyAsync(() -> spAppDeviceService.updateDeviceInfo(reqVO.getSpAppLogPO())); try { SpNoticeVO spNoticeVO = new SpNoticeVO(); spNoticeVO.setAppId(spApplicationPO.getAppId()); List notices = spNoticeService.getNotices(spNoticeVO); return ResponseModel.successData(notices); } catch (Exception e) { return ResponseModel.errorMsg(e.getMessage()); } ```