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

1525 lines
42 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 盘古用户平台 - 应用管理模块后端技术方案
---
| 文档信息 | 内容 |
|---------|------|
| **文档版本** | 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接口测试通过
---
*文档结束*