# 盘古用户平台 - 应用管理模块后端技术方案 --- | 文档信息 | 内容 | |---------|------| | **文档版本** | V1.0 | | **模块名称** | 应用管理模块 - 后端 | | **编写团队 | pangu | | **创建日期** | 2026-01-31 | | **审核状态** | 待评审 | --- ## 1. 技术栈 | 技术 | 版本 | 用途 | |------|------|------| | JDK | 17+ | 运行环境 | | Spring Boot | 3.3.x | 应用框架 | | Spring Security | 6.x | 安全框架 | | MyBatis Plus | 3.5.x | ORM框架 | | MySQL | 8.0 | 数据库 | | Redis | 7.x | 缓存 | | Hutool | 5.x | 工具库 | --- ## 2. 模块结构 ### 2.1 包结构 ``` com.pangu.admin/ ├── controller/ │ └── ApplicationController.java # 应用管理控制器 ├── service/ │ ├── IApplicationService.java # 应用服务接口 │ └── impl/ │ └── ApplicationServiceImpl.java # 应用服务实现 ├── mapper/ │ ├── ApplicationMapper.java # 应用Mapper │ ├── AppApiMapper.java # 应用接口授权Mapper │ └── ApiDictMapper.java # 接口字典Mapper └── domain/ ├── entity/ │ ├── Application.java # 应用实体 │ ├── AppApi.java # 应用接口授权实体 │ └── ApiDict.java # 接口字典实体 ├── vo/ │ └── ApplicationVO.java # 应用视图对象 └── dto/ └── ApplicationDTO.java # 应用传输对象 com.pangu.open/ ├── controller/ │ └── OpenApiController.java # 开放API控制器 ├── config/ │ └── OpenApiConfig.java # 开放API配置 ├── interceptor/ │ └── ApiAuthInterceptor.java # API认证拦截器 └── service/ ├── IApiAuthService.java # API认证服务接口 └── impl/ └── ApiAuthServiceImpl.java # API认证服务实现 com.pangu.common/ └── util/ ├── SecretGenerator.java # 密钥生成工具 └── SignUtils.java # 签名工具 ``` ### 2.2 MyBatis Mapper XML ``` resources/mapper/ ├── ApplicationMapper.xml ├── AppApiMapper.xml └── ApiDictMapper.xml ``` --- ## 3. 数据库设计 ### 3.1 表结构 #### 3.1.1 应用表 (pg_application) ```sql CREATE TABLE `pg_application` ( `app_id` bigint NOT NULL AUTO_INCREMENT COMMENT '应用ID', `app_code` varchar(32) NOT NULL COMMENT '应用编码', `app_name` varchar(100) NOT NULL COMMENT '应用名称', `app_secret` varchar(64) NOT NULL COMMENT '应用密钥', `app_desc` varchar(500) DEFAULT NULL COMMENT '应用描述', `contact_person` varchar(50) DEFAULT NULL COMMENT '联系人', `contact_phone` varchar(20) DEFAULT NULL COMMENT '联系电话', `status` char(1) DEFAULT '0' COMMENT '状态(0正常 1停用)', `create_by` varchar(64) DEFAULT '' COMMENT '创建者', `create_time` datetime DEFAULT NULL COMMENT '创建时间', `update_by` varchar(64) DEFAULT '' COMMENT '更新者', `update_time` datetime DEFAULT NULL COMMENT '更新时间', `del_flag` char(1) DEFAULT '0' COMMENT '删除标志(0存在 1删除)', `remark` varchar(500) DEFAULT NULL COMMENT '备注', PRIMARY KEY (`app_id`), UNIQUE KEY `uk_app_code` (`app_code`), UNIQUE KEY `uk_app_name` (`app_name`, `del_flag`) ) ENGINE=InnoDB COMMENT='应用表'; ``` #### 3.1.2 应用接口授权表 (pg_app_api) ```sql CREATE TABLE `pg_app_api` ( `id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键ID', `app_id` bigint NOT NULL COMMENT '应用ID', `api_code` varchar(100) NOT NULL COMMENT '接口编码', `api_name` varchar(100) DEFAULT NULL COMMENT '接口名称', `api_path` varchar(200) NOT NULL COMMENT '接口路径', `create_by` varchar(64) DEFAULT '' COMMENT '创建者', `create_time` datetime DEFAULT NULL COMMENT '创建时间', PRIMARY KEY (`id`), UNIQUE KEY `uk_app_api` (`app_id`, `api_code`), KEY `idx_app_id` (`app_id`) ) ENGINE=InnoDB COMMENT='应用接口授权表'; ``` #### 3.1.3 API接口字典表 (pg_api_dict) ```sql CREATE TABLE `pg_api_dict` ( `api_id` bigint NOT NULL AUTO_INCREMENT COMMENT '接口ID', `api_code` varchar(100) NOT NULL COMMENT '接口编码', `api_name` varchar(100) NOT NULL COMMENT '接口名称', `api_path` varchar(200) NOT NULL COMMENT '接口路径', `api_method` varchar(10) DEFAULT 'GET' COMMENT '请求方法', `api_desc` varchar(500) DEFAULT NULL COMMENT '接口描述', `order_num` int DEFAULT 0 COMMENT '显示顺序', `status` char(1) DEFAULT '0' COMMENT '状态(0正常 1停用)', `create_by` varchar(64) DEFAULT '' COMMENT '创建者', `create_time` datetime DEFAULT NULL COMMENT '创建时间', `update_by` varchar(64) DEFAULT '' COMMENT '更新者', `update_time` datetime DEFAULT NULL COMMENT '更新时间', PRIMARY KEY (`api_id`), UNIQUE KEY `uk_api_code` (`api_code`) ) ENGINE=InnoDB COMMENT='API接口字典表'; ``` ### 3.2 初始化数据SQL ```sql -- API接口字典数据 INSERT INTO pg_api_dict (api_id, api_code, api_name, api_path, api_method, api_desc, order_num) VALUES (1, 'STUDENT_LIST', '查询学生信息', '/open/student/list', 'GET', '获取学生列表', 1), (2, 'SCHOOL_LIST', '查询学校信息', '/open/school/list', 'GET', '获取学校列表', 2), (3, 'GRADE_LIST', '查询年级信息', '/open/grade/list', 'GET', '获取年级列表', 3), (4, 'CLASS_LIST', '查询班级信息', '/open/class/list', 'GET', '获取班级列表', 4), (5, 'MEMBER_LIST', '查询会员信息', '/open/member/list', 'GET', '获取会员列表', 5), (6, 'REGION_TREE', '查询区域树', '/open/region/tree', 'GET', '获取区域树形结构', 6); -- 菜单数据 INSERT INTO sys_menu (menu_id, menu_name, parent_id, order_num, path, component, menu_type, visible, perms, icon) VALUES (2100, '应用管理', 0, 10, 'application', 'application/index', 'C', '0', 'system:application:list', 'app'); INSERT INTO sys_menu (menu_id, menu_name, parent_id, order_num, perms, menu_type) VALUES (2101, '应用查询', 2100, 1, 'system:application:query', 'F'), (2102, '应用新增', 2100, 2, 'system:application:add', 'F'), (2103, '应用修改', 2100, 3, 'system:application:edit', 'F'), (2104, '应用删除', 2100, 4, 'system:application:remove', 'F'), (2105, '密钥重置', 2100, 5, 'system:application:resetSecret', 'F'); ``` --- ## 4. 实体类设计 ### 4.1 Application.java(应用实体) ```java package com.pangu.admin.domain.entity; import com.baomidou.mybatisplus.annotation.*; import com.pangu.common.core.domain.BaseEntity; import lombok.Data; import lombok.EqualsAndHashCode; /** * 应用实体 * @author pangu */ @Data @EqualsAndHashCode(callSuper = true) @TableName("pg_application") public class Application extends BaseEntity { private static final long serialVersionUID = 1L; /** * 应用ID */ @TableId(type = IdType.AUTO) private Long appId; /** * 应用编码(格式:YY + 6位序号) */ private String appCode; /** * 应用名称 */ private String appName; /** * 应用密钥(32位随机字符串) */ private String appSecret; /** * 应用描述 */ private String appDesc; /** * 联系人 */ private String contactPerson; /** * 联系电话 */ private String contactPhone; /** * 状态(0正常 1停用) */ private String status; /** * 删除标志(0存在 1删除) */ @TableLogic private String delFlag; } ``` ### 4.2 AppApi.java(应用接口授权实体) ```java package com.pangu.admin.domain.entity; import com.baomidou.mybatisplus.annotation.*; import lombok.Data; import java.io.Serializable; import java.time.LocalDateTime; /** * 应用接口授权实体 * @author pangu */ @Data @TableName("pg_app_api") public class AppApi implements Serializable { private static final long serialVersionUID = 1L; /** * 主键ID */ @TableId(type = IdType.AUTO) private Long id; /** * 应用ID */ private Long appId; /** * 接口编码 */ private String apiCode; /** * 接口名称 */ private String apiName; /** * 接口路径 */ private String apiPath; /** * 创建者 */ private String createBy; /** * 创建时间 */ private LocalDateTime createTime; } ``` ### 4.3 ApiDict.java(接口字典实体) ```java package com.pangu.admin.domain.entity; import com.baomidou.mybatisplus.annotation.*; import com.pangu.common.core.domain.BaseEntity; import lombok.Data; import lombok.EqualsAndHashCode; /** * API接口字典实体 * @author pangu */ @Data @EqualsAndHashCode(callSuper = true) @TableName("pg_api_dict") public class ApiDict extends BaseEntity { private static final long serialVersionUID = 1L; /** * 接口ID */ @TableId(type = IdType.AUTO) private Long apiId; /** * 接口编码 */ private String apiCode; /** * 接口名称 */ private String apiName; /** * 接口路径 */ private String apiPath; /** * 请求方法 */ private String apiMethod; /** * 接口描述 */ private String apiDesc; /** * 显示顺序 */ private Integer orderNum; /** * 状态(0正常 1停用) */ private String status; } ``` ### 4.4 ApplicationDTO.java(传输对象) ```java package com.pangu.admin.domain.dto; import lombok.Data; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.Size; import java.io.Serializable; import java.util.List; /** * 应用传输对象 * @author pangu */ @Data public class ApplicationDTO implements Serializable { private static final long serialVersionUID = 1L; /** * 应用ID(编辑时必填) */ private Long appId; /** * 应用名称 */ @NotBlank(message = "应用名称不能为空") @Size(max = 100, message = "应用名称不能超过100个字符") private String appName; /** * 应用编码(查询条件) */ private String appCode; /** * 应用描述 */ @Size(max = 500, message = "应用描述不能超过500个字符") private String appDesc; /** * 联系人 */ @Size(max = 50, message = "联系人不能超过50个字符") private String contactPerson; /** * 联系电话 */ @Size(max = 20, message = "联系电话不能超过20个字符") private String contactPhone; /** * 状态(0正常 1停用) */ private String status; /** * 授权的接口编码列表 */ private List apiCodes; // ========== 查询条件 ========== /** * 分页页码 */ private Integer pageNum; /** * 分页大小 */ private Integer pageSize; } ``` ### 4.5 ApplicationVO.java(视图对象) ```java package com.pangu.admin.domain.vo; import lombok.Data; import java.io.Serializable; import java.time.LocalDateTime; import java.util.List; /** * 应用视图对象 * @author pangu */ @Data public class ApplicationVO implements Serializable { private static final long serialVersionUID = 1L; /** * 应用ID */ private Long appId; /** * 应用编码 */ private String appCode; /** * 应用名称 */ private String appName; /** * 应用描述 */ private String appDesc; /** * 联系人 */ private String contactPerson; /** * 联系电话 */ private String contactPhone; /** * 状态(0正常 1停用) */ private String status; /** * 创建者 */ private String createBy; /** * 创建时间 */ private LocalDateTime createTime; /** * 授权的接口列表 */ private List apis; /** * 接口信息 */ @Data public static class ApiInfo implements Serializable { private String apiCode; private String apiName; private String apiPath; } } ``` --- ## 5. Mapper层设计 ### 5.1 ApplicationMapper.java ```java package com.pangu.admin.mapper; import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.pangu.admin.domain.entity.Application; import org.apache.ibatis.annotations.Mapper; import org.apache.ibatis.annotations.Param; import org.apache.ibatis.annotations.Select; /** * 应用Mapper接口 * @author pangu */ @Mapper public interface ApplicationMapper extends BaseMapper { /** * 查询最大应用编码 */ @Select("SELECT MAX(app_code) FROM pg_application WHERE del_flag = '0'") String selectMaxAppCode(); /** * 根据应用编码查询应用 */ @Select("SELECT * FROM pg_application WHERE app_code = #{appCode} AND del_flag = '0'") Application selectByAppCode(@Param("appCode") String appCode); /** * 检查应用名称是否存在 */ @Select("SELECT COUNT(*) FROM pg_application WHERE app_name = #{appName} AND del_flag = '0' AND app_id != #{excludeId}") int checkAppNameExists(@Param("appName") String appName, @Param("excludeId") Long excludeId); } ``` ### 5.2 AppApiMapper.java ```java package com.pangu.admin.mapper; import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.pangu.admin.domain.entity.AppApi; import org.apache.ibatis.annotations.Delete; import org.apache.ibatis.annotations.Mapper; import org.apache.ibatis.annotations.Param; import org.apache.ibatis.annotations.Select; import java.util.List; /** * 应用接口授权Mapper接口 * @author pangu */ @Mapper public interface AppApiMapper extends BaseMapper { /** * 根据应用ID查询授权接口 */ @Select("SELECT * FROM pg_app_api WHERE app_id = #{appId}") List selectByAppId(@Param("appId") Long appId); /** * 根据应用ID删除授权接口 */ @Delete("DELETE FROM pg_app_api WHERE app_id = #{appId}") int deleteByAppId(@Param("appId") Long appId); /** * 检查应用是否有某接口权限 */ @Select("SELECT COUNT(*) FROM pg_app_api WHERE app_id = #{appId} AND api_path = #{apiPath}") int checkPermission(@Param("appId") Long appId, @Param("apiPath") String apiPath); /** * 根据应用编码查询授权的接口路径列表 */ @Select("SELECT aa.api_path FROM pg_app_api aa " + "INNER JOIN pg_application a ON aa.app_id = a.app_id " + "WHERE a.app_code = #{appCode} AND a.del_flag = '0'") List selectApiPathsByAppCode(@Param("appCode") String appCode); } ``` ### 5.3 ApiDictMapper.java ```java package com.pangu.admin.mapper; import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.pangu.admin.domain.entity.ApiDict; import org.apache.ibatis.annotations.Mapper; import org.apache.ibatis.annotations.Param; import org.apache.ibatis.annotations.Select; import java.util.List; /** * API接口字典Mapper接口 * @author pangu */ @Mapper public interface ApiDictMapper extends BaseMapper { /** * 查询启用的接口列表 */ @Select("SELECT * FROM pg_api_dict WHERE status = '0' ORDER BY order_num") List selectEnabledList(); /** * 根据接口编码列表查询 */ List selectByCodes(@Param("codes") List codes); } ``` --- ## 6. Service层设计 ### 6.1 IApplicationService.java(接口) ```java package com.pangu.admin.service; import com.pangu.admin.domain.dto.ApplicationDTO; import com.pangu.admin.domain.entity.ApiDict; import com.pangu.admin.domain.entity.Application; import com.pangu.admin.domain.vo.ApplicationVO; import java.util.List; import java.util.Map; /** * 应用管理服务接口 * @author pangu */ public interface IApplicationService { /** * 查询应用列表 * @param dto 查询条件 * @return 应用列表 */ List selectApplicationList(ApplicationDTO dto); /** * 查询应用详情 * @param appId 应用ID * @return 应用信息 */ ApplicationVO selectApplicationById(Long appId); /** * 新增应用 * @param dto 应用数据 * @return 包含appCode和appSecret的Map */ Map insertApplication(ApplicationDTO dto); /** * 修改应用 * @param dto 应用数据 * @return 影响行数 */ int updateApplication(ApplicationDTO dto); /** * 删除应用 * @param appId 应用ID * @return 影响行数 */ int deleteApplicationById(Long appId); /** * 重置应用密钥 * @param appId 应用ID * @return 新密钥 */ String resetAppSecret(Long appId); /** * 获取API接口字典列表 * @return 接口列表 */ List selectApiDictList(); /** * 根据应用编码查询应用(用于API认证) * @param appCode 应用编码 * @return 应用信息 */ Application selectByAppCode(String appCode); /** * 检查应用是否有接口权限 * @param appCode 应用编码 * @param apiPath 接口路径 * @return 是否有权限 */ boolean checkApiPermission(String appCode, String apiPath); } ``` ### 6.2 ApplicationServiceImpl.java(实现) ```java package com.pangu.admin.service.impl; import cn.hutool.core.collection.CollUtil; import cn.hutool.core.util.RandomUtil; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.pangu.admin.domain.dto.ApplicationDTO; import com.pangu.admin.domain.entity.ApiDict; import com.pangu.admin.domain.entity.AppApi; import com.pangu.admin.domain.entity.Application; import com.pangu.admin.domain.vo.ApplicationVO; import com.pangu.admin.mapper.ApiDictMapper; import com.pangu.admin.mapper.AppApiMapper; import com.pangu.admin.mapper.ApplicationMapper; import com.pangu.admin.service.IApplicationService; import com.pangu.common.core.exception.ServiceException; import com.pangu.common.redis.service.RedisService; import com.pangu.common.security.utils.SecurityUtils; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.time.LocalDateTime; import java.util.*; import java.util.concurrent.TimeUnit; import java.util.stream.Collectors; /** * 应用管理服务实现 * @author pangu */ @Slf4j @Service @RequiredArgsConstructor public class ApplicationServiceImpl implements IApplicationService { private final ApplicationMapper applicationMapper; private final AppApiMapper appApiMapper; private final ApiDictMapper apiDictMapper; private final RedisService redisService; /** 应用信息缓存前缀 */ private static final String CACHE_APP_INFO = "app:info:"; /** 应用授权接口缓存前缀 */ private static final String CACHE_APP_APIS = "app:apis:"; /** 缓存过期时间(分钟) */ private static final long CACHE_EXPIRE_MINUTES = 30; @Override public List selectApplicationList(ApplicationDTO dto) { LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); // 查询条件 if (dto.getAppName() != null && !dto.getAppName().isEmpty()) { wrapper.like(Application::getAppName, dto.getAppName()); } if (dto.getAppCode() != null && !dto.getAppCode().isEmpty()) { wrapper.like(Application::getAppCode, dto.getAppCode()); } if (dto.getStatus() != null && !dto.getStatus().isEmpty()) { wrapper.eq(Application::getStatus, dto.getStatus()); } wrapper.orderByDesc(Application::getCreateTime); List list = applicationMapper.selectList(wrapper); // 转换为VO并填充授权接口信息 return list.stream().map(this::toVO).collect(Collectors.toList()); } @Override public ApplicationVO selectApplicationById(Long appId) { Application app = applicationMapper.selectById(appId); if (app == null) { throw new ServiceException("应用不存在"); } return toVO(app); } @Override @Transactional(rollbackFor = Exception.class) public Map insertApplication(ApplicationDTO dto) { // 检查应用名称是否重复 if (applicationMapper.checkAppNameExists(dto.getAppName(), 0L) > 0) { throw new ServiceException("应用名称已存在"); } // 生成应用编码和密钥 String appCode = generateAppCode(); String appSecret = generateAppSecret(); // 构建实体 Application app = new Application(); app.setAppCode(appCode); app.setAppName(dto.getAppName()); app.setAppSecret(appSecret); app.setAppDesc(dto.getAppDesc()); app.setContactPerson(dto.getContactPerson()); app.setContactPhone(dto.getContactPhone()); app.setStatus(dto.getStatus() != null ? dto.getStatus() : "0"); app.setCreateBy(SecurityUtils.getUsername()); app.setCreateTime(LocalDateTime.now()); applicationMapper.insert(app); // 保存接口授权 saveAppApis(app.getAppId(), dto.getApiCodes()); log.info("新增应用成功,appCode={}, appName={}", appCode, dto.getAppName()); // 返回编码和密钥 Map result = new HashMap<>(); result.put("appCode", appCode); result.put("appSecret", appSecret); return result; } @Override @Transactional(rollbackFor = Exception.class) public int updateApplication(ApplicationDTO dto) { if (dto.getAppId() == null) { throw new ServiceException("应用ID不能为空"); } Application existApp = applicationMapper.selectById(dto.getAppId()); if (existApp == null) { throw new ServiceException("应用不存在"); } // 检查应用名称是否重复(排除自身) if (applicationMapper.checkAppNameExists(dto.getAppName(), dto.getAppId()) > 0) { throw new ServiceException("应用名称已存在"); } // 更新应用信息 Application app = new Application(); app.setAppId(dto.getAppId()); app.setAppName(dto.getAppName()); app.setAppDesc(dto.getAppDesc()); app.setContactPerson(dto.getContactPerson()); app.setContactPhone(dto.getContactPhone()); app.setStatus(dto.getStatus()); app.setUpdateBy(SecurityUtils.getUsername()); app.setUpdateTime(LocalDateTime.now()); int rows = applicationMapper.updateById(app); // 更新接口授权(先删后增) appApiMapper.deleteByAppId(dto.getAppId()); saveAppApis(dto.getAppId(), dto.getApiCodes()); // 清除缓存 clearAppCache(existApp.getAppCode()); log.info("修改应用成功,appId={}, appName={}", dto.getAppId(), dto.getAppName()); return rows; } @Override @Transactional(rollbackFor = Exception.class) public int deleteApplicationById(Long appId) { Application app = applicationMapper.selectById(appId); if (app == null) { throw new ServiceException("应用不存在"); } // 删除应用(逻辑删除) int rows = applicationMapper.deleteById(appId); // 删除接口授权 appApiMapper.deleteByAppId(appId); // 清除缓存 clearAppCache(app.getAppCode()); log.info("删除应用成功,appId={}, appCode={}", appId, app.getAppCode()); return rows; } @Override @Transactional(rollbackFor = Exception.class) public String resetAppSecret(Long appId) { Application app = applicationMapper.selectById(appId); if (app == null) { throw new ServiceException("应用不存在"); } // 生成新密钥 String newSecret = generateAppSecret(); // 更新密钥 Application updateApp = new Application(); updateApp.setAppId(appId); updateApp.setAppSecret(newSecret); updateApp.setUpdateBy(SecurityUtils.getUsername()); updateApp.setUpdateTime(LocalDateTime.now()); applicationMapper.updateById(updateApp); // 清除缓存 clearAppCache(app.getAppCode()); log.info("重置应用密钥成功,appId={}, appCode={}", appId, app.getAppCode()); return newSecret; } @Override public List selectApiDictList() { return apiDictMapper.selectEnabledList(); } @Override public Application selectByAppCode(String appCode) { // 先从缓存获取 String cacheKey = CACHE_APP_INFO + appCode; Application app = redisService.getCacheObject(cacheKey); if (app == null) { // 从数据库查询 app = applicationMapper.selectByAppCode(appCode); if (app != null) { // 放入缓存 redisService.setCacheObject(cacheKey, app, CACHE_EXPIRE_MINUTES, TimeUnit.MINUTES); } } return app; } @Override public boolean checkApiPermission(String appCode, String apiPath) { // 先从缓存获取授权接口列表 String cacheKey = CACHE_APP_APIS + appCode; Set apiPaths = redisService.getCacheObject(cacheKey); if (apiPaths == null) { // 从数据库查询 List paths = appApiMapper.selectApiPathsByAppCode(appCode); apiPaths = new HashSet<>(paths); // 放入缓存 redisService.setCacheObject(cacheKey, apiPaths, CACHE_EXPIRE_MINUTES, TimeUnit.MINUTES); } return apiPaths.contains(apiPath); } // ========== 私有方法 ========== /** * 生成应用编码 * 格式:YY + 6位序号(从000001开始) */ private String generateAppCode() { String maxCode = applicationMapper.selectMaxAppCode(); int nextSeq = 1; if (maxCode != null && maxCode.length() > 2) { try { nextSeq = Integer.parseInt(maxCode.substring(2)) + 1; } catch (NumberFormatException e) { log.warn("解析应用编码失败,使用默认序号1"); } } return String.format("YY%06d", nextSeq); } /** * 生成32位随机密钥 */ private String generateAppSecret() { return RandomUtil.randomString(32); } /** * 保存应用接口授权 */ private void saveAppApis(Long appId, List apiCodes) { if (CollUtil.isEmpty(apiCodes)) { return; } // 查询接口字典 List apiDicts = apiDictMapper.selectByCodes(apiCodes); Map dictMap = apiDicts.stream() .collect(Collectors.toMap(ApiDict::getApiCode, d -> d)); // 批量插入授权记录 String username = SecurityUtils.getUsername(); LocalDateTime now = LocalDateTime.now(); for (String apiCode : apiCodes) { ApiDict dict = dictMap.get(apiCode); if (dict != null) { AppApi appApi = new AppApi(); appApi.setAppId(appId); appApi.setApiCode(apiCode); appApi.setApiName(dict.getApiName()); appApi.setApiPath(dict.getApiPath()); appApi.setCreateBy(username); appApi.setCreateTime(now); appApiMapper.insert(appApi); } } } /** * 转换为VO */ private ApplicationVO toVO(Application app) { ApplicationVO vo = new ApplicationVO(); vo.setAppId(app.getAppId()); vo.setAppCode(app.getAppCode()); vo.setAppName(app.getAppName()); vo.setAppDesc(app.getAppDesc()); vo.setContactPerson(app.getContactPerson()); vo.setContactPhone(app.getContactPhone()); vo.setStatus(app.getStatus()); vo.setCreateBy(app.getCreateBy()); vo.setCreateTime(app.getCreateTime()); // 查询授权接口 List appApis = appApiMapper.selectByAppId(app.getAppId()); if (CollUtil.isNotEmpty(appApis)) { List apis = appApis.stream().map(api -> { ApplicationVO.ApiInfo info = new ApplicationVO.ApiInfo(); info.setApiCode(api.getApiCode()); info.setApiName(api.getApiName()); info.setApiPath(api.getApiPath()); return info; }).collect(Collectors.toList()); vo.setApis(apis); } return vo; } /** * 清除应用缓存 */ private void clearAppCache(String appCode) { redisService.deleteObject(CACHE_APP_INFO + appCode); redisService.deleteObject(CACHE_APP_APIS + appCode); } } ``` --- ## 7. Controller层设计 ### 7.1 ApplicationController.java ```java package com.pangu.admin.controller; import com.pangu.admin.domain.dto.ApplicationDTO; import com.pangu.admin.domain.entity.ApiDict; import com.pangu.admin.domain.vo.ApplicationVO; import com.pangu.admin.service.IApplicationService; import com.pangu.common.core.controller.BaseController; import com.pangu.common.core.domain.AjaxResult; import com.pangu.common.core.page.TableDataInfo; import com.pangu.common.log.annotation.Log; import com.pangu.common.log.enums.BusinessType; import lombok.RequiredArgsConstructor; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.*; import java.util.List; import java.util.Map; /** * 应用管理控制器 * @author pangu */ @RestController @RequestMapping("/api/application") @RequiredArgsConstructor public class ApplicationController extends BaseController { private final IApplicationService applicationService; /** * 查询应用列表 */ @PreAuthorize("hasPermission('system:application:list')") @GetMapping("/list") public TableDataInfo list(ApplicationDTO dto) { startPage(); List list = applicationService.selectApplicationList(dto); return getDataTable(list); } /** * 获取应用详情 */ @PreAuthorize("hasPermission('system:application:query')") @GetMapping("/{appId}") public AjaxResult getInfo(@PathVariable Long appId) { return success(applicationService.selectApplicationById(appId)); } /** * 新增应用 */ @PreAuthorize("hasPermission('system:application:add')") @Log(title = "应用管理", businessType = BusinessType.INSERT) @PostMapping public AjaxResult add(@Validated @RequestBody ApplicationDTO dto) { Map result = applicationService.insertApplication(dto); return success(result); } /** * 修改应用 */ @PreAuthorize("hasPermission('system:application:edit')") @Log(title = "应用管理", businessType = BusinessType.UPDATE) @PutMapping public AjaxResult edit(@Validated @RequestBody ApplicationDTO dto) { return toAjax(applicationService.updateApplication(dto)); } /** * 删除应用 */ @PreAuthorize("hasPermission('system:application:remove')") @Log(title = "应用管理", businessType = BusinessType.DELETE) @DeleteMapping("/{appId}") public AjaxResult remove(@PathVariable Long appId) { return toAjax(applicationService.deleteApplicationById(appId)); } /** * 重置应用密钥 */ @PreAuthorize("hasPermission('system:application:resetSecret')") @Log(title = "应用管理-重置密钥", businessType = BusinessType.UPDATE) @PutMapping("/resetSecret/{appId}") public AjaxResult resetSecret(@PathVariable Long appId) { String newSecret = applicationService.resetAppSecret(appId); return success("重置成功", Map.of("appSecret", newSecret)); } /** * 获取API接口列表(用于授权选择) */ @PreAuthorize("hasPermission('system:application:list')") @GetMapping("/apiList") public AjaxResult apiList() { List list = applicationService.selectApiDictList(); return success(list); } } ``` --- ## 8. 开放API认证拦截器 ### 8.1 ApiAuthInterceptor.java ```java package com.pangu.open.interceptor; import cn.hutool.crypto.digest.DigestUtil; import com.pangu.admin.domain.entity.Application; import com.pangu.admin.service.IApplicationService; import com.pangu.common.core.exception.ServiceException; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component; import org.springframework.web.servlet.HandlerInterceptor; import java.util.Map; import java.util.TreeMap; /** * 开放API认证拦截器 * 用于验证第三方应用的签名和权限 * @author pangu */ @Slf4j @Component @RequiredArgsConstructor public class ApiAuthInterceptor implements HandlerInterceptor { private final IApplicationService applicationService; /** 时间戳有效期(5分钟) */ private static final long TIMESTAMP_EXPIRE_MS = 5 * 60 * 1000; @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { // 获取请求头 String appId = request.getHeader("X-App-Id"); String timestamp = request.getHeader("X-Timestamp"); String sign = request.getHeader("X-Sign"); // 1. 验证必要参数 if (appId == null || timestamp == null || sign == null) { throw new ServiceException("缺少认证参数"); } // 2. 验证时间戳(防重放攻击) long reqTime; try { reqTime = Long.parseLong(timestamp); } catch (NumberFormatException e) { throw new ServiceException("时间戳格式错误"); } if (Math.abs(System.currentTimeMillis() - reqTime) > TIMESTAMP_EXPIRE_MS) { throw new ServiceException("请求已过期"); } // 3. 查询应用信息 Application app = applicationService.selectByAppCode(appId); if (app == null) { throw new ServiceException("应用不存在"); } if ("1".equals(app.getStatus())) { throw new ServiceException("应用已停用"); } // 4. 验证签名 String expectedSign = generateSign(request, app.getAppSecret()); if (!expectedSign.equalsIgnoreCase(sign)) { log.warn("签名验证失败,appId={}, expectedSign={}, actualSign={}", appId, expectedSign, sign); throw new ServiceException("签名验证失败"); } // 5. 检查接口权限 String apiPath = request.getRequestURI(); if (!applicationService.checkApiPermission(appId, apiPath)) { throw new ServiceException("无权访问该接口"); } log.info("API认证通过,appId={}, apiPath={}", appId, apiPath); return true; } /** * 生成签名 * 规则:将请求参数按ASCII排序后拼接,末尾追加appSecret,MD5加密 */ private String generateSign(HttpServletRequest request, String appSecret) { // 按参数名ASCII排序 Map params = new TreeMap<>(); request.getParameterMap().forEach((key, values) -> { if (values != null && values.length > 0) { params.put(key, values[0]); } }); // 拼接字符串 StringBuilder sb = new StringBuilder(); params.forEach((key, value) -> { if (sb.length() > 0) { sb.append("&"); } sb.append(key).append("=").append(value); }); sb.append("&appSecret=").append(appSecret); // MD5加密(大写) return DigestUtil.md5Hex(sb.toString()).toUpperCase(); } } ``` ### 8.2 OpenApiConfig.java(配置类) ```java package com.pangu.open.config; import com.pangu.open.interceptor.ApiAuthInterceptor; import lombok.RequiredArgsConstructor; import org.springframework.context.annotation.Configuration; import org.springframework.web.servlet.config.annotation.InterceptorRegistry; import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; /** * 开放API配置 * @author pangu */ @Configuration @RequiredArgsConstructor public class OpenApiConfig implements WebMvcConfigurer { private final ApiAuthInterceptor apiAuthInterceptor; @Override public void addInterceptors(InterceptorRegistry registry) { // 对/open/**路径的请求进行认证 registry.addInterceptor(apiAuthInterceptor) .addPathPatterns("/open/**"); } } ``` --- ## 9. 缓存设计 ### 9.1 缓存策略 | 缓存KEY | 数据类型 | 过期时间 | 说明 | |---------|---------|---------|------| | `app:info:{appCode}` | Application | 30分钟 | 应用基本信息 | | `app:apis:{appCode}` | Set | 30分钟 | 应用授权的接口路径 | ### 9.2 缓存操作 ```java // 写入缓存 redisService.setCacheObject(CACHE_APP_INFO + appCode, app, 30, TimeUnit.MINUTES); // 读取缓存 Application app = redisService.getCacheObject(CACHE_APP_INFO + appCode); // 删除缓存 redisService.deleteObject(CACHE_APP_INFO + appCode); ``` ### 9.3 缓存更新时机 | 操作 | 缓存处理 | |------|---------| | 新增应用 | 无需处理 | | 修改应用 | 删除该应用的缓存 | | 删除应用 | 删除该应用的缓存 | | 重置密钥 | 删除该应用的缓存 | --- ## 10. 单元测试 ### 10.1 ApplicationServiceTest.java ```java package com.pangu.admin.service; import com.pangu.admin.domain.dto.ApplicationDTO; import com.pangu.admin.domain.vo.ApplicationVO; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.transaction.annotation.Transactional; import java.util.Arrays; import java.util.List; import java.util.Map; import static org.junit.jupiter.api.Assertions.*; /** * 应用管理服务单元测试 * @author pangu */ @SpringBootTest @Transactional class ApplicationServiceTest { @Autowired private IApplicationService applicationService; @Test void testInsertApplication() { ApplicationDTO dto = new ApplicationDTO(); dto.setAppName("测试应用"); dto.setAppDesc("测试描述"); dto.setStatus("0"); dto.setApiCodes(Arrays.asList("SCHOOL_LIST", "GRADE_LIST")); Map result = applicationService.insertApplication(dto); assertNotNull(result.get("appCode")); assertNotNull(result.get("appSecret")); assertTrue(result.get("appCode").startsWith("YY")); assertEquals(32, result.get("appSecret").length()); } @Test void testSelectApplicationList() { ApplicationDTO dto = new ApplicationDTO(); dto.setStatus("0"); List list = applicationService.selectApplicationList(dto); assertNotNull(list); } @Test void testResetAppSecret() { // 先创建应用 ApplicationDTO dto = new ApplicationDTO(); dto.setAppName("重置密钥测试"); dto.setStatus("0"); Map result = applicationService.insertApplication(dto); // 获取应用ID ApplicationDTO query = new ApplicationDTO(); query.setAppCode(result.get("appCode")); List list = applicationService.selectApplicationList(query); Long appId = list.get(0).getAppId(); // 重置密钥 String oldSecret = result.get("appSecret"); String newSecret = applicationService.resetAppSecret(appId); assertNotNull(newSecret); assertEquals(32, newSecret.length()); assertNotEquals(oldSecret, newSecret); } @Test void testCheckApiPermission() { // 先创建应用并授权 ApplicationDTO dto = new ApplicationDTO(); dto.setAppName("权限测试应用"); dto.setStatus("0"); dto.setApiCodes(Arrays.asList("SCHOOL_LIST")); Map result = applicationService.insertApplication(dto); String appCode = result.get("appCode"); // 检查有权限的接口 assertTrue(applicationService.checkApiPermission(appCode, "/open/school/list")); // 检查无权限的接口 assertFalse(applicationService.checkApiPermission(appCode, "/open/student/list")); } } ``` --- ## 11. 开发检查清单 ### 11.1 开发前检查 - [ ] 阅读需求文档和技术方案 - [ ] 确认数据库表结构 - [ ] 确认接口设计 ### 11.2 开发中检查 - [ ] 数据库表创建完成 - [ ] 初始化数据插入完成 - [ ] 实体类开发完成 - [ ] Mapper层开发完成 - [ ] Service层开发完成 - [ ] Controller层开发完成 - [ ] 开放API拦截器开发完成 - [ ] 缓存处理完成 - [ ] 单元测试编写完成 ### 11.3 开发后检查 - [ ] 代码格式化 - [ ] 删除无用代码 - [ ] 添加必要注释 - [ ] 单元测试通过 - [ ] 接口文档更新 - [ ] Postman接口测试通过 --- *文档结束*