pangu-user-platform/docs/05-模块技术方案/应用管理-后端技术方案.md

1525 lines
42 KiB
Markdown
Raw Normal View History

# 盘古用户平台 - 应用管理模块后端技术方案
---
| 文档信息 | 内容 |
|---------|------|
| **文档版本** | 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<String> 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<ApiInfo> 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<Application> {
/**
* 查询最大应用编码
*/
@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<AppApi> {
/**
* 根据应用ID查询授权接口
*/
@Select("SELECT * FROM pg_app_api WHERE app_id = #{appId}")
List<AppApi> 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<String> 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<ApiDict> {
/**
* 查询启用的接口列表
*/
@Select("SELECT * FROM pg_api_dict WHERE status = '0' ORDER BY order_num")
List<ApiDict> selectEnabledList();
/**
* 根据接口编码列表查询
*/
List<ApiDict> selectByCodes(@Param("codes") List<String> 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<ApplicationVO> selectApplicationList(ApplicationDTO dto);
/**
* 查询应用详情
* @param appId 应用ID
* @return 应用信息
*/
ApplicationVO selectApplicationById(Long appId);
/**
* 新增应用
* @param dto 应用数据
* @return 包含appCode和appSecret的Map
*/
Map<String, String> 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<ApiDict> 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<ApplicationVO> selectApplicationList(ApplicationDTO dto) {
LambdaQueryWrapper<Application> 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<Application> 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<String, String> 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<String, String> 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<ApiDict> 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<String> apiPaths = redisService.getCacheObject(cacheKey);
if (apiPaths == null) {
// 从数据库查询
List<String> 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<String> apiCodes) {
if (CollUtil.isEmpty(apiCodes)) {
return;
}
// 查询接口字典
List<ApiDict> apiDicts = apiDictMapper.selectByCodes(apiCodes);
Map<String, ApiDict> 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<AppApi> appApis = appApiMapper.selectByAppId(app.getAppId());
if (CollUtil.isNotEmpty(appApis)) {
List<ApplicationVO.ApiInfo> 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<ApplicationVO> 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<String, String> 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<ApiDict> 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排序后拼接末尾追加appSecretMD5加密
*/
private String generateSign(HttpServletRequest request, String appSecret) {
// 按参数名ASCII排序
Map<String, String> 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<String> | 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<String, String> 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<ApplicationVO> list = applicationService.selectApplicationList(dto);
assertNotNull(list);
}
@Test
void testResetAppSecret() {
// 先创建应用
ApplicationDTO dto = new ApplicationDTO();
dto.setAppName("重置密钥测试");
dto.setStatus("0");
Map<String, String> result = applicationService.insertApplication(dto);
// 获取应用ID
ApplicationDTO query = new ApplicationDTO();
query.setAppCode(result.get("appCode"));
List<ApplicationVO> 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<String, String> 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接口测试通过
---
*文档结束*