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

42 KiB
Raw Permalink Blame 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)

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)

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)

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

-- 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应用实体

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应用接口授权实体

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接口字典实体

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传输对象

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视图对象

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

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

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

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接口

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实现

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

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

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配置类

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 缓存操作

// 写入缓存
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

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接口测试通过

文档结束