Spring Boot单体应用多角色架构设计:Intern与Secret Agent模式实战
最近在开发一个宠物医疗管理系统时遇到了一个有趣且典型的架构设计挑战如何在一个单体应用中清晰地分离并管理面向外部用户宠物主人的公开功能和面向内部员工兽医、管理员的私有功能。这不仅仅是权限控制更涉及到路由隔离、数据视图、业务逻辑乃至前端组件的差异化处理。本文将分享一套基于 Spring Boot 的实战方案我将其核心思想概括为“Intern实习生与 Secret Agent秘密特工”模式旨在构建一个边界清晰、易于维护的医院管理系统。无论你是正在学习 Spring Security 和模块化设计的学生还是需要处理类似多角色、多租户业务场景的开发者这套从设计到落地的完整流程都能为你提供直接参考。1. 项目背景与核心概念为何需要“Intern”与“Secret Agent”在传统的动物医院管理系统中通常会将所有功能堆砌在一起通过简单的角色判断如ROLE_USER,ROLE_ADMIN来控制菜单和接口访问。这种方式在初期看似简单但随着业务增长会带来诸多问题代码耦合严重公开预约和内部病历管理的代码混杂难以独立部署或升级。权限模型复杂一个ROLE_ADMIN可能拥有过多权限细粒度控制困难。前端路由混乱用户端和员工端的页面组件、API 路径没有物理隔离容易误操作。数据安全性风险一个疏忽的查询可能将内部数据暴露给外部接口。“Intern Secret Agent”模式正是为了解决这些问题而提出的一种架构隐喻Intern实习生/公开端代表面向宠物主人的公开服务。就像实习生只能在前台接待、处理公开信息一样这部分功能权限有限只能访问预约、查看公开公告、查询基础宠物信息等。Secret Agent秘密特工/内部端代表医院内部员工使用的管理系统。如同特工拥有高级权限和秘密通道这部分功能可以处理病历、药品库存、财务数据、员工排班等核心敏感业务。该模式的核心目标是实现物理或逻辑上的应用拆分即使部署在同一个War/Jar包中也要在路由、服务层、数据访问层建立起清晰的边界。2. 技术栈与环境准备本文将基于以下技术栈进行演示。请确保你的开发环境已就绪。后端框架Spring Boot 2.7.x (或 3.x注意部分配置差异)Spring SecuritySpring Data JPA数据库MySQL 8.0 (也可用H2内存数据库测试)前端模板Thymeleaf (用于服务端渲染演示分离概念实际项目可为前后端分离)构建工具Maven 或 GradleIDEIntelliJ IDEA 或 EclipseJava版本JDK 11 或 17项目初始化 你可以通过 Spring Initializr 快速生成项目勾选以下依赖Spring Web,Spring Security,Spring Data JPA,MySQL Driver,Thymeleaf,Lombok。生成的pom.xml关键依赖部分如下dependencies dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-security/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-data-jpa/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-thymeleaf/artifactId /dependency dependency groupIdcom.mysql/groupId artifactIdmysql-connector-j/artifactId scoperuntime/scope /dependency dependency groupIdorg.projectlombok/groupId artifactIdlombok/artifactId optionaltrue/optional /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-test/artifactId scopetest/scope /dependency dependency groupIdorg.springframework.security/groupId artifactIdspring-security-test/artifactId scopetest/scope /dependency /dependencies数据库配置(application.yml)spring: datasource: url: jdbc:mysql://localhost:3306/animal_hospital?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai username: root password: yourpassword driver-class-name: com.mysql.cj.jdbc.Driver jpa: hibernate: ddl-auto: update # 初次测试可用生产环境建议使用 validate 或 none配合SQL脚本 show-sql: true properties: hibernate: dialect: org.hibernate.dialect.MySQL8Dialect format_sql: true # 自定义配置用于区分路由前缀 app: public-prefix: /pub # Intern端前缀 internal-prefix: /internal # Secret Agent端前缀3. 核心架构设计与模块划分我们将在同一个Spring Boot应用中通过包结构和配置进行逻辑模块划分。项目结构规划src/main/java/com/animalhospital/ ├── AnimalHospitalApplication.java ├── config/ # 配置类 │ ├── SecurityConfig.java │ └── WebMvcConfig.java ├── common/ # 公共组件 │ ├── entity/ # 公共实体如User │ ├── repository/ # 公共仓库 │ └── util/ # 工具类 ├── publicweb/ # Intern模块 (公开端) │ ├── controller/ # 控制器 RequestMapping(/pub/...) │ ├── service/ # 服务层 │ ├── dto/ # 数据传输对象 │ └── vo/ # 视图对象 ├── internalweb/ # Secret Agent模块 (内部端) │ ├── controller/ # 控制器 RequestMapping(/internal/...) │ ├── service/ # 服务层 │ └── dto/ └── templates/ # 模板文件 ├── pub/ # 公开端页面 └── internal/ # 内部端页面这种结构的关键在于路由前缀隔离通过app.public-prefix和app.internal-prefix配置所有公开端控制器映射到/pub/**内部端映射到/internal/**。包物理隔离publicweb和internalweb包彼此独立减少意外引用。模板目录隔离Thymeleaf 模板也按目录分开便于管理和授权。4. 实现“Intern”公开端功能公开端主要服务于宠物主人功能相对简单。4.1 实体与仓库首先定义一些核心实体。注意有些实体是公共的如Pet有些则是内部专用的如MedicalRecord。// 文件路径src/main/java/com/animalhospital/common/entity/User.java package com.animalhospital.common.entity; import lombok.Data; import javax.persistence.*; import java.util.List; Entity Data Table(name sys_user) public class User { Id GeneratedValue(strategy GenerationType.IDENTITY) private Long id; private String username; private String password; private String email; private String phone; Enumerated(EnumType.STRING) private UserType type; // 枚举PUBLIC_CLIENT, INTERNAL_STAFF, INTERNAL_ADMIN // 省略 getter/setter (由Lombok Data 生成) } // 用户类型枚举 package com.animalhospital.common.entity; public enum UserType { PUBLIC_CLIENT, // 公众客户 (Intern端用户) INTERNAL_STAFF, // 内部员工 (Secret Agent端-普通员工) INTERNAL_ADMIN // 内部管理员 (Secret Agent端-管理员) }// 文件路径src/main/java/com/animalhospital/common/entity/Pet.java package com.animalhospital.common.entity; import lombok.Data; import javax.persistence.*; Entity Data Table(name pet) public class Pet { Id GeneratedValue(strategy GenerationType.IDENTITY) private Long id; private String name; private String species; // 物种狗、猫等 private String breed; // 品种 private Integer age; ManyToOne JoinColumn(name owner_id) private User owner; // 关联宠物主人 // 注意病历记录 MedicalRecord 不应在此直接关联避免公开端直接访问 }// 文件路径src/main/java/com/animalhospital/publicweb/repository/PublicPetRepository.java package com.animalhospital.publicweb.repository; import com.animalhospital.common.entity.Pet; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; import java.util.List; Repository public interface PublicPetRepository extends JpaRepositoryPet, Long { // 公开端只能查询自己名下的宠物 Query(SELECT p FROM Pet p WHERE p.owner.id :ownerId) ListPet findByOwnerId(Param(ownerId) Long ownerId); }4.2 控制器与服务层公开端的控制器统一使用/pub前缀。// 文件路径src/main/java/com/animalhospital/publicweb/controller/PublicAppointmentController.java package com.animalhospital.publicweb.controller; import com.animalhospital.publicweb.service.PublicAppointmentService; import com.animalhospital.publicweb.vo.AppointmentRequestVO; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Controller; import org.springframework.ui.Model; import org.springframework.web.bind.annotation.*; import java.security.Principal; Controller RequestMapping(/pub/appointment) // 关键路由前缀 /pub RequiredArgsConstructor public class PublicAppointmentController { private final PublicAppointmentService appointmentService; GetMapping(/book) public String showBookingPage(Model model) { // 提供可预约的时间段等信息 model.addAttribute(availableSlots, appointmentService.getAvailableSlots()); return pub/appointment-booking; // 模板路径对应 templates/pub/ } PostMapping(/submit) public String submitAppointment(ModelAttribute AppointmentRequestVO requestVO, Principal principal) { // principal.getName() 获取当前登录用户名 appointmentService.createAppointment(requestVO, principal.getName()); return redirect:/pub/appointment/success; } GetMapping(/my) public String listMyAppointments(Model model, Principal principal) { model.addAttribute(appointments, appointmentService.getAppointmentsByUser(principal.getName())); return pub/appointment-list; } }// 文件路径src/main/java/com/animalhospital/publicweb/service/PublicAppointmentService.java package com.animalhospital.publicweb.service; import com.animalhospital.common.entity.User; import com.animalhospital.common.repository.UserRepository; import com.animalhospital.publicweb.vo.AppointmentRequestVO; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.util.List; Service Transactional(readOnly true) RequiredArgsConstructor public class PublicAppointmentService { private final UserRepository userRepository; // 其他依赖... Transactional public void createAppointment(AppointmentRequestVO vo, String username) { // 1. 根据username查找用户必须是PUBLIC_CLIENT类型 User client userRepository.findByUsernameAndType(username, UserType.PUBLIC_CLIENT) .orElseThrow(() - new RuntimeException(客户不存在或类型错误)); // 2. 业务逻辑创建预约单关联客户和宠物需验证宠物属于该客户 // 3. 保存到数据库 // ... 具体实现 } public ListObject getAppointmentsByUser(String username) { // 只返回该用户的预约 // ... 具体实现 return List.of(); } public ListString getAvailableSlots() { // 从公共资源中获取可预约时段 return List.of(09:00, 10:00, 14:00); } }4.3 公开端页面模板!-- 文件路径src/main/resources/templates/pub/appointment-booking.html -- !DOCTYPE html html xmlns:thhttp://www.thymeleaf.org head meta charsetUTF-8 title预约挂号 - 爱心动物医院/title link relstylesheet href/pub/css/style.css /head body header th:replacepub/fragments/header :: header/header main classcontainer h1在线预约/h1 form th:action{/pub/appointment/submit} methodpost div classform-group label选择宠物/label select namepetId required !-- 从模型数据中动态加载用户宠物 -- option th:eachpet : ${pets} th:value${pet.id} th:text${pet.name}/option /select /div div classform-group label预约时间/label select nametimeSlot required option th:eachslot : ${availableSlots} th:value${slot} th:text${slot}/option /select /div button typesubmit提交预约/button /form /main footer th:replacepub/fragments/footer :: footer/footer /body /html5. 实现“Secret Agent”内部端功能内部端功能复杂权限要求高我们通过Spring Security进行精细控制。5.1 安全配置与路由隔离这是实现模式的核心通过SecurityConfig配置将/pub/**和/internal/**的访问规则完全分开。// 文件路径src/main/java/com/animalhospital/config/SecurityConfig.java package com.animalhospital.config; import com.animalhospital.common.entity.UserType; import lombok.RequiredArgsConstructor; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.web.SecurityFilterChain; Configuration EnableWebSecurity RequiredArgsConstructor public class SecurityConfig { Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http .authorizeHttpRequests(authz - authz // 公开端路由允许所有用户包括未登录访问首页、预约页等 .antMatchers(/, /pub/**, /css/**, /js/**, /images/**).permitAll() // 内部端路由必须认证且用户类型必须是内部员工或管理员 .antMatchers(/internal/**).hasAnyAuthority( UserType.INTERNAL_STAFF.name(), UserType.INTERNAL_ADMIN.name() ) // 更细粒度的控制可以在控制器方法上用 PreAuthorize 实现 .anyRequest().authenticated() ) .formLogin(form - form .loginPage(/login) // 自定义登录页 .defaultSuccessUrl(/internal/dashboard) // 内部员工登录后跳转到内部仪表盘 .permitAll() ) .logout(logout - logout .logoutSuccessUrl(/) .permitAll() ) // 记住我等功能按需添加 .rememberMe(); return http.build(); } Bean public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); } }5.2 内部端控制器与高级权限内部端控制器使用/internal前缀并且可以结合PreAuthorize进行方法级权限控制。// 文件路径src/main/java/com/animalhospital/internalweb/controller/MedicalRecordController.java package com.animalhospital.internalweb.controller; import com.animalhospital.internalweb.service.MedicalRecordService; import com.animalhospital.internalweb.vo.MedicalRecordVO; import lombok.RequiredArgsConstructor; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.stereotype.Controller; import org.springframework.ui.Model; import org.springframework.web.bind.annotation.*; import java.util.List; Controller RequestMapping(/internal/medical) RequiredArgsConstructor public class MedicalRecordController { private final MedicalRecordService recordService; // 查看病历列表所有内部员工都可以 GetMapping(/records) public String listRecords(Model model) { ListMedicalRecordVO records recordService.getAllRecords(); model.addAttribute(records, records); return internal/medical-record-list; } // 查看病历详情所有内部员工都可以 GetMapping(/record/{id}) public String viewRecord(PathVariable Long id, Model model) { MedicalRecordVO record recordService.getRecordById(id); model.addAttribute(record, record); return internal/medical-record-detail; } // 创建病历需要 INTERNAL_STAFF 及以上权限 GetMapping(/record/new) PreAuthorize(hasAuthority(INTERNAL_STAFF)) public String showCreateForm(Model model) { // 初始化表单数据 return internal/medical-record-form; } PostMapping(/record) PreAuthorize(hasAuthority(INTERNAL_STAFF)) public String createRecord(ModelAttribute MedicalRecordVO vo) { recordService.createRecord(vo); return redirect:/internal/medical/records; } // 删除病历仅管理员可操作 PostMapping(/record/{id}/delete) PreAuthorize(hasAuthority(INTERNAL_ADMIN)) public String deleteRecord(PathVariable Long id) { recordService.deleteRecord(id); return redirect:/internal/medical/records; } }5.3 内部服务层与数据隔离内部服务在查询时必须注意数据边界。例如查询病历时虽然不限制员工看所有病历业务需要但绝不能通过公开端接口暴露。// 文件路径src/main/java/com/animalhospital/internalweb/service/MedicalRecordService.java package com.animalhospital.internalweb.service; import com.animalhospital.common.entity.MedicalRecord; import com.animalhospital.common.entity.Pet; import com.animalhospital.common.repository.MedicalRecordRepository; import com.animalhospital.common.repository.PetRepository; import com.animalhospital.internalweb.vo.MedicalRecordVO; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import javax.persistence.EntityNotFoundException; import java.util.List; import java.util.stream.Collectors; Service Transactional(readOnly true) RequiredArgsConstructor public class MedicalRecordService { private final MedicalRecordRepository medicalRecordRepository; private final PetRepository petRepository; public ListMedicalRecordVO getAllRecords() { // 内部系统可以查看所有病历但返回的VO对象应过滤掉极度敏感信息如成本、内部备注等 return medicalRecordRepository.findAll().stream() .map(this::convertToVO) .collect(Collectors.toList()); } public MedicalRecordVO getRecordById(Long id) { MedicalRecord record medicalRecordRepository.findById(id) .orElseThrow(() - new EntityNotFoundException(病历不存在)); return convertToVO(record); } Transactional public void createRecord(MedicalRecordVO vo) { // 创建病历时需要关联宠物。这里假设前端已做好校验。 Pet pet petRepository.findById(vo.getPetId()) .orElseThrow(() - new RuntimeException(宠物不存在)); MedicalRecord record new MedicalRecord(); // 设置属性... record.setPet(pet); record.setDiagnosis(vo.getDiagnosis()); record.setPrescription(vo.getPrescription()); // 设置创建医生等信息从SecurityContext获取 medicalRecordRepository.save(record); } Transactional PreAuthorize(hasAuthority(INTERNAL_ADMIN)) // 服务层也可加注解但通常控制器层已控制 public void deleteRecord(Long id) { medicalRecordRepository.deleteById(id); } private MedicalRecordVO convertToVO(MedicalRecord record) { // 转换实体为VO可控制暴露的字段 MedicalRecordVO vo new MedicalRecordVO(); vo.setId(record.getId()); vo.setPetName(record.getPet().getName()); vo.setDiagnosis(record.getDiagnosis()); vo.setVisitDate(record.getVisitDate()); // 注意不暴露内部成本字段 record.getInternalCost() return vo; } }6. 核心问题与排查思路在实际整合“Intern”与“Secret Agent”模块时你可能会遇到以下典型问题。问题现象可能原因排查步骤与解决方案访问/internal/dashboard被重定向到登录页登录后仍无权限1. 用户类型不正确。2.SecurityConfig中/internal/**的权限配置有误。3. 用户权限未正确加载。1. 检查数据库用户type字段是否为INTERNAL_STAFF或INTERNAL_ADMIN。2. 检查SecurityConfig的antMatchers(“/internal/**”).hasAnyAuthority(“INTERNAL_STAFF”, “INTERNAL_ADMIN”)。3. 实现UserDetailsService确保从数据库加载用户时其GrantedAuthority包含UserType.name()。公开端页面能访问到内部端的API数据1. 控制器路由前缀配置错误或遗漏。2. 服务层没有做数据归属校验。1. 确保所有内部端控制器类上有RequestMapping(“/internal/xxx”)。2. 在公开端服务方法中必须传入当前用户ID并在SQL或业务逻辑中验证数据归属如WHERE owner_id :currentUserId。静态资源CSS, JS在加了安全配置后无法加载Spring Security 拦截了静态资源请求。在SecurityConfig的antMatchers中为静态资源路径添加.permitAll()例如.antMatchers(“/css/**”, “/js/**”, “/images/**”).permitAll()。PreAuthorize注解不生效1. 未在配置类上启用全局方法安全。2. 注解中的权限字符串与用户的实际权限不匹配。1. 在主配置类或安全配置类上添加EnableGlobalMethodSecurity(prePostEnabled true)。2. 调试查看SecurityContextHolder.getContext().getAuthentication().getAuthorities()的内容确保与注解字符串一致。分模块后公共实体在模块中无法注入或扫描不到Spring 组件扫描范围未覆盖新模块。1. 确保主应用类SpringBootApplication所在的包是顶级包如com.animalhospital其下的子包会被自动扫描。2. 或者在模块的配置类上使用ComponentScan明确指定扫描路径。7. 最佳实践与工程建议将“Intern Secret Agent”模式应用到生产环境需要考虑更多工程化细节。API 版本管理与前缀设计对于前后端分离项目可以将路由前缀升级为API路径的一部分如/api/v1/pub/appointments和/api/v1/internal/medical-records。这为未来公开端和内部端独立部署或采用不同技术栈留有余地。服务层接口抽象为PublicPetService和InternalPetService定义统一的接口IPetService但提供不同的实现。这符合接口隔离原则使依赖关系更清晰。public interface IPetService { Pet getPetById(Long id); } Service(“publicPetService”) public class PublicPetServiceImpl implements IPetService { // 实现只能查自己的宠物 } Service(“internalPetService”) public class InternalPetServiceImpl implements IPetService { // 实现可查所有宠物 }数据权限与行级安全对于内部系统不同角色的员工能看到的数据也应不同如医生只看自己负责的病例。这需要在服务层或Repository层实现更复杂的过滤逻辑可以考虑使用Spring Security JPA Specification或MyBatis-Plus 数据权限插件。配置外部化与多环境将app.public-prefix和app.internal-prefix等配置放入application-{profile}.yml中。在开发、测试、生产环境中前缀甚至可以不同如生产环境内部端使用更复杂的路径。日志与审计为内部端的所有重要操作增、删、改敏感数据添加详细的审计日志记录操作人、时间、IP和具体内容。可以使用Spring AOP或注解轻松实现。公开端和内部端的日志最好也分开配置便于监控和问题排查。前端工程化如果是前后端分离可以创建两个独立的前端项目public-web和internal-admin分别对接不同的后端API前缀。它们可以独立开发、构建和部署。如果使用单体前端则可以通过路由守卫如Vue Router的beforeEach来模拟后端的权限控制根据用户角色动态加载菜单和重定向。测试策略单元测试分别测试publicweb和internalweb包下的服务类。集成测试使用SpringBootTest和WithMockUser注解模拟不同角色用户测试控制器的访问权限。Test WithMockUser(authorities “PUBLIC_CLIENT”) public void testPublicEndpointAccess() { // 测试以公开客户身份访问 /pub/** 应成功访问 /internal/** 应被拒绝 }通过以上设计和实践“Intern Secret Agent”模式不仅能帮你清晰构建动物医院管理系统其核心思想——通过路由、包结构、权限三重隔离来划分系统边界——可以广泛应用于任何需要区分“外部用户服务”和“内部运营管理”的Web应用例如电商平台用户端/商家后台、在线教育学生端/教师端、CMS系统前台展示/后台管理等。关键在于前期做好规划并在代码层面严格执行这种分离这将极大提升项目的可维护性、安全性和可扩展性。

相关新闻

最新新闻

日新闻

周新闻

月新闻