news 2026/10/2 18:23:52

微服务架构下的学生荣誉证书管理系统设计与实战

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
微服务架构下的学生荣誉证书管理系统设计与实战

在学校里,但凡接触过“学生荣誉证书管理”这件事的人都知道,它远比想象中麻烦。各类比赛获奖、评优评先、奖学金凭证,纸质的容易丢,Excel汇总又难查,盖章审批流程全靠人肉催。如果只是做一个单机版的管理系统,数据孤岛、并发瓶颈、后续扩展难这些问题很快就会冒出来。所以这个项目从一开始就定了调子:用微服务架构来做,技术栈锁定SpringBoot + Vue + SpringCloud全家桶,把证书的申报、审核、生成、查询、统计全链路都搬到分布式体系上。这篇博文,我就把整个项目的拆解思路、架构设计、核心代码实现、以及实操中踩过的坑,完整写出来,给正在做同类毕设或中小型微服务项目的同学做个参考。

先说清楚这套系统到底解决什么问题。传统的单体应用,所有模块挤在一个进程里,代码越堆越臃肿,改一个证书模板功能都怕影响申报模块。而学生荣誉证书管理系统天然适合微服务拆分:证书申报、审核流程、文件存储、用户权限、消息通知,每个模块边界清晰,独立部署、独立扩展。再加上SpringCloud生态里的注册中心、网关、配置中心、熔断降级组件,一套完整的分布式治理方案就齐了。前端用Vue做SPA单页应用,配合动态路由和权限控制,体验上比传统多页面跳转顺滑得多。

这套项目表面上是一个“管理系统”,实际上是把微服务领域里最常碰到的几个硬骨头——服务拆分粒度、分布式事务、分布式锁、跨服务鉴权、文件统一存储——全部过了一遍。这也是我当初选它当作微服务实战项目的原因。接下来我会按照实际开发的顺序,把架构设计、核心模块、关键代码、问题排查一条线讲透,你可以直接当一份开发笔记来用。

1. 项目整体设计与拆分思路

1.1 为什么非要上微服务,而不是老老实实用单体

很多人一听到微服务就头大,觉得是过度设计。但放在这个项目场景里,微服务带来的好处是实打实的。

首先是模块化治理。我最初用单体写过一版,随着功能增加,Controller越写越厚,一个HonorController里既有申报接口又有审核接口还有统计接口,改一处就要重新编译整个项目,测试回归成本很高。拆成微服务之后,荣誉申报服务只关心申报单的读写,审核服务只关心流程流转,互不干扰。

其次是独立扩展。学校里学期末是申报高峰期,审核人员多、证书生成请求密集,而日常查询统计的压力不大。单体架构下没法只给某个模块扩容,只能整体scale。微服务架构下,我可以单独把honor-service和certificate-service的实例数拉起来,user-service保持少量实例即可,资源利用率差距非常明显。

然后是团队协作。这个系统如果是毕设或课程设计,通常一个人开发,但如果是企业级项目,多团队并行开发,微服务的好处就更显著:每个小组只管自己的服务,接口通过Swagger文档对接,互不阻塞。从git分支管理到CI/CD流水线,都是独立的,哪怕一个服务挂了也不影响编译和发布其他服务。

当然,微服务的代价是基础设施复杂度上来了——服务注册发现、配置中心、网关、分布式事务、链路追踪,每一个都是新课题。所以我的建议是:如果你的项目仅仅是“增删改查+单机部署”,别硬上微服务;但如果你想要一份“简历上拿得出手的架构经验”,或者项目确实有多团队、高并发、模块独立部署的诉求,那这套架构就值得认真做。

1.2 服务拆分粒度:从一个完整业务域出发

服务怎么拆,是微服务项目里争论最多的问题。拆得太粗,退化成“单体+多个模块”;拆得太细,服务间调用链变得极长,运维和排错都会很痛苦。我的拆分原则是:按业务域的强弱边界拆,而不是按代码层拆。

证书管理系统最终拆成了四个核心服务加两个基础设施:

服务名职责范围关键依赖
user-service学生、教师、管理员账号管理,登录鉴权,角色权限控制Nacos, Redis, MySQL
honor-service荣誉申报单CRUD、申报状态流转、评审流程Nacos, Redis, OpenFeign
certificate-service证书模板管理、证书生成、PDF合成、电子章嵌入Nacos, MinIO, OpenFeign
gateway-server统一入口、路由转发、JWT Token校验、限流Nacos, Redis
基础设施MySQL(业务数据)、Redis(缓存/分布式锁/Token存储)、MinIO(文件存储)—

这套拆分背后有几个核心考量:

  • 用户服务独立出来,是因为它会被所有服务调用,属于公共基础服务。证书生成时需要知道申报人信息、审核时需要知道审核人角色,如果把这些逻辑塞进每个业务服务里就重复了。
  • 荣誉申报和证书生成分开,是因为它们的负载特征完全不同。申报是写多读多、强一致要求,证书生成是典型的CPU密集和IO密集任务(渲染PDF、压图片),隔离之后不会互相拖垮。
  • 网关单独拆服务,把跨横切面的东西(JWT校验、灰度路由、限流)收拢到入口处,业务服务不需要重复写鉴权代码。

顺带说一句,服务拆分后的通信我统一用的是OpenFeign同步调用,没上消息队列做异步。原因很简单:项目规模还没到需要异步解耦的程度,同步调用排查问题直观,事务边界也更容易控制。后面如果证书生成变慢,再引入MQ异步削峰也来得及。

2. 技术选型与核心组件解析

2.1 为什么是SpringBoot + Vue + SpringCloud这个组合,缺一不可

这个组合在当前微服务技术栈里几乎是“标准答案”。SpringBoot负责快速构建服务,固定了依赖版本和自动化配置,让开发者不用再操心各种XML配置;SpringCloud一套卫星组件(Nacos、Gateway、Feign、Sentinel)把分布式治理问题全部覆盖;Vue则承担前端交互层的复杂度,双向绑定和组件化让复杂的表单流程写起来不那么痛苦。

版本搭配上,我踩过不少坑。SpringBoot和SpringCloud之间的版本是强约束关系,SpringCloud的每个发行版本对应一个SpringBoot的版本区间。我用的是SpringBoot 2.7.18配合SpringCloud 2021.0.5(Jubilee)和SpringCloud Alibaba 2021.0.5.0。这套搭配非常成熟,网上资料也多,踩坑了容易找到解决方案。如果你用SpringBoot 3.x,就得切SpringCloud 2022.0.x以上,Java也要至少17,很多老的教程和依赖坐标都对不上了,新手不建议直接上最新版。

前端Vue部分我选的Vue 2.7 + Element UI。虽然Vue 3已经普及,但Element UI对Vue 3用户要换Element Plus,而且很多现成的后台管理模板还是Vue 2的,“拿过来改”的成本更低。如果你没有历史包袱,直接从Vue 3 + Vite + Element Plus起步也没问题,二选一即可,不影响后端接口设计。

2.2 注册中心、配置中心、网关:微服务治理的三驾马车

2.2.1 Nacos:注册中心+配置中心二合一

注册中心我选了Nacos而不是Eureka或Consul,主要原因是一个组件同时解决服务发现和配置管理两个问题,少维护一套中间件。Nacos的服务端安装很简单,解压后bin目录下执行启动脚本就行,但它默认启动在standalone单机模式,生产上要用集群模式配合MySQL存储配置。开发和毕设阶段单机完全够用。

服务注册的核心配置就三件事:服务名、注册地址、命名空间。

spring: application: name: honor-service cloud: nacos: discovery: server-addr: 127.0.0.1:8848 namespace: dev config: server-addr: 127.0.0.1:8848 file-extension: yaml namespace: dev group: DEFAULT_GROUP

这里有一个非常重要的经验:不同环境一定要用命名空间(namespace)隔离。开发环境、测试环境、生产环境共用同一个Nacos集群时,如果不做命名空间隔离,服务之间会互相注册到对方环境里,导致调用链错乱。我在命名空间上吃过亏,一次把dev环境配置改到了生产命名空间,直接导致线上服务被重新刷新配置。血的教训。

2.2.2 Spring Cloud Gateway:统一流量入口

网关我用的是Spring Cloud Gateway,相比Zuul它是响应式编程模型,性能更好,而且Spring Cloud官方主推。网关层我做了三件事:路由转发、JWT鉴权、限流。

路由配置的核心逻辑是:根据请求路径前缀把流量分发到对应的服务。比如/api/user/**转发到user-service,/api/honor/**转发到honor-service。

spring: cloud: gateway: routes: - id: user-service uri: lb://user-service predicates: - Path=/api/user/** filters: - StripPrefix=1 - id: honor-service uri: lb://honor-service predicates: - Path=/api/honor/** filters: - StripPrefix=1

注意lb://前缀,表示走负载均衡,Gateway会从Nacos上拉取服务实例列表,配合默认的负载均衡策略把请求分发到不同实例上。StripPrefix=1是把路径前缀里的第一段去掉,比如/api/honor/submit转发到honor-service的时候会变成/honor-service/submit,如果后端Controller的RequestMapping带了/honor前缀,那你得调整StripPrefix的层数,这个很容易配错,要多留意实际转发的路径。

JWT鉴权我在Gateway层做了个全局过滤器,解析请求头里的Authorization字段,验证Token的签名和有效期,解析出用户ID和角色流转到Header里,后端服务通过X-User-Id和X-User-Role请求头获取当前操作人信息,不用每个服务都连Redis查一遍用户状态。

2.2.3 OpenFeign:服务间通信

服务间同步调用我统一用OpenFeign,声明式HTTP客户端,写起来像调用本地接口一样。比如honor-service需要查用户信息时,只需要声明一个FeignClient接口:

@FeignClient(name = "user-service", fallback = UserClientFallback.class) public interface UserClient { @GetMapping("/api/user/info/{id}") R<UserInfoVO> getUserInfo(@PathVariable("id") Long id); }

Feign这里有个坑:接口方法上的@GetMapping路径必须和提供方Controller的路径完全一致,如果提供方在Gateway上有路径重写,而Feign是直连服务,路径可能不一样。我当时就遇到了/api/前缀不一致导致404的问题,排查了很久才发现是路径前缀没对齐。另外调用超时时间默认只有1秒,服务响应稍微慢一点就会报Read timed out,需要配置延长超时时间:

feign: client: config: default: connectTimeout: 5000 readTimeout: 10000

2.3 Redis在系统里的三个关键角色

Redis在系统里不止当缓存用,它还承担了分布式锁和Token存储这两个关键任务。

作为缓存,热点数据——比如荣誉类别字典、审核配置项、高频查询的学生获奖列表——都做了缓存,有效降低了MySQL的压力。我设置了合理的过期时间和缓存更新策略,避免缓存雪崩和穿透。

作为分布式锁,证书生成的幂等控制、审核状态的并发修改,都用Redis锁来保证同一时刻只有一个实例在处理。后面会详细展开。

作为Token存储,登录成功之后,JWT的jti字段作为key,用户信息作为value,设置过期时间,实现服务端主动失效Token的能力。比如管理员封禁某个用户时,直接删掉Redis里的Token,该用户的登录态立刻失效,这在单体应用里做不到。

3. 核心业务链路设计与实现

3.1 三条主流程:申报、审核、生成证书

整个系统的核心业务可以抽象成三条主流程:

学生申报流程:学生登录后填写荣誉申报单,选择荣誉类别(学科竞赛、文体活动、评优评先等)、填写获奖名称、获奖等级、比赛时间、上传佐证材料(获奖证书照片、官方文件截图),提交后进入待审核状态。这里的关键点是申报单一旦提交就不能随便修改,只能撤回重新编辑,所以我在状态流转上做了严格控制。

教师/管理员审核流程:审核角色看到待审核列表,逐条审查佐证材料,给出通过或驳回意见。驳回时必须填写原因,方便学生知道哪里有问题。审核通过后,申报单状态流转到“待生成证书”。

证书生成与发放流程:审核通过的申报单进入待生成池,系统根据荣誉类别自动匹配证书模板,把学生姓名、荣誉名称、等级、颁发单位、日期等填充进模板,生成PDF文件,并盖上电子章,最后上传到MinIO对象存储,把访问URL回写到申报单里,学生就可以在系统里查看、下载。

3.2 状态机设计:申报单的生命周期管理

申报单的状态我用一个整数字段管理:0=草稿、1=待审核、2=审核通过、3=已驳回、4=已撤回、5=证书已生成。本来想引入专门的状态机框架,但考虑到业务不是特别复杂,用硬编码状态加合法迁移校验就够了,将来要扩展也可以无缝切换。

关键是状态流转要防止非法操作。比如“待审核”状态不能直接跳到“证书已生成”,“已驳回”之后只能重新提交,不能直接改回“审核通过”。我用了一个简单的状态转移判断方法:

private static final Map<Integer, Set<Integer>> TRANSITION_MAP = Map.of( 0, Set.of(1, 4), 1, Set.of(2, 3, 4), 2, Set.of(5), 3, Set.of(1), 4, Set.of(1), 5, Set.of() ); public void validateTransition(int from, int to) { Set<Integer> allowed = TRANSITION_MAP.get(from); if (allowed == null || !allowed.contains(to)) { throw new BusinessException("非法的状态流转:" + from + " -> " + to); } }

这套状态机看起来简单,但好处在于:所有修改状态的地方都必须先过validateTransition,哪怕未来有新的角色加入,也不会因为漏了某个校验而出现脏数据。

3.3 分布式锁:防止证书重复生成

证书生成是一个典型的幂等性敏感操作。学生提交申报单之后,如果点击了两次“生成证书”,或者管理员不小心重复触发了批量生成任务,理论上不应该生成两份证书,但网络超时重试、并发请求这些情况如果不做控制,很容易生成重复记录。

传统单体应用里加synchronized或者数据库锁就能解决,但微服务架构下honor-service和certificate-service可能各有多实例部署,普通的锁没法跨实例生效,必须用Redis分布式锁。

我采用了Redisson框架实现分布式锁,关键代码:

@Autowired private RedissonClient redissonClient; public void generateCertificate(Long honorId) { String lockKey = "cert:lock:" + honorId; RLock lock = redissonClient.getLock(lockKey); boolean locked = false; try { locked = lock.tryLock(3, 30, TimeUnit.SECONDS); if (!locked) { throw new BusinessException("系统繁忙,请稍后重试"); } // 检查证书是否已生成,已生成则不再重复处理 boolean existed = checkCertificateExisted(honorId); if (existed) { return; } doGenerate(honorId); } catch (InterruptedException e) { Thread.currentThread().interrupt(); throw new BusinessException("证书生成被中断"); } finally { if (locked) { lock.unlock(); } } }

用tryLock(3, 30, TimeUnit.SECONDS)的含义是:最多等待3秒获取锁,锁自动释放时间为30秒。等待时间不要太长,否则用户会等很久;自动释放时间要合理,确保正常流程能在锁过期前完成。这里有个细节:Redisson的看门狗机制默认会自动续期,但tryLock(long waitTime, long leaseTime, TimeUnit unit)如果显式传了leaseTime,看门狗就不会续期了。我的证书生成正常在5秒内完成,30秒的leaseTime足够用,所以显式传leaseTime是合理的。

还有一个不能漏的点:拿到锁后必须再次检查业务状态。因为有可能另一个实例在几毫秒前已经生成过证书,只是你加锁等待后才知道。这种“加锁后二次查询”是分布式锁防重复执行的标准姿势,漏了这一步,锁就白加了。

3.4 分布式事务:审核通过后,数据怎么保持一致

证书生成这个链路涉及多个服务、多张表:honor-service要更新申报单状态为“证书已生成”,certificate-service要插入证书记录,还要把文件上传到MinIO。任何一个环节失败,都会导致数据不一致。

我采用的事务方案是本地消息表 + 定时任务补偿,没有引入Seata这类重量级分布式事务框架。原因是:这个场景的事务一致性要求是“最终一致”,而不是强一致,而且证书生成是异步任务,不需要用户同步等待结果,所以没必要为了它引入复杂的分布式事务中间件,增加部署和运维负担。

具体流程是:

  1. certificate-service收到生成请求后,先在本地事务里插入一条certificate_record记录,状态为CREATING。
  2. 同步调用MinIO上传PDF文件。
  3. 上传成功,把certificate_record状态更新为SUCCESS,同时通过Feign回调honor-service更新申报单状态。
  4. 如果第3步失败(比如Feign调用超时),certificate_record停留在CREATING状态,定时任务每5分钟扫描一次超过10分钟还处于CREATING的记录,重试或者标记失败。

定时任务用的Spring@Scheduled注解,加上一个分布式锁防止多个实例重复扫描同一条记录:

@Scheduled(cron = "0 */5 * * * ?") public void retryFailedCertificates() { String lockKey = "cert:retry:lock"; RLock lock = redissonClient.getLock(lockKey); if (lock.tryLock()) { try { List<CertificateRecord> pendingList = recordMapper.selectList( new LambdaQueryWrapper<CertificateRecord>() .eq(CertificateRecord::getStatus, "CREATING") .lt(CertificateRecord::getCreateTime, LocalDateTime.now().minusMinutes(10)) .last("limit 50")); for (CertificateRecord record : pendingList) { try { processOne(record); } catch (Exception e) { log.error("重试处理失败, recordId={}", record.getId(), e); } } } finally { lock.unlock(); } } }

这样的设计避免了引入消息队列也能达到最终一致性,而且每一步失败都有重试兜底,日志里也都能看到处理情况。调试和排查问题比看MQ消费积压日志要直观得多。

4. 实操:从零搭建一套可运行的微服务骨架

4.1 创建Maven多模块工程,理清依赖关系

微服务项目我强烈推荐用Maven多模块结构管理,父工程只做依赖版本管理,具体业务代码写在各个子模块里。骨架长这样:

honor-cert-system/ ├── pom.xml (父POM,统一管理依赖版本) ├── common/ (公共模块:统一返回结构、异常处理、工具类) ├── gateway-server/ (网关服务) ├── user-service/ (用户服务) ├── honor-service/ (荣誉申报服务) ├── certificate-service/ (证书生成服务) └── sql/ (数据库初始化脚本)

父POM里最关键的是用dependencyManagement锁定版本,这样子模块写依赖时不用带版本号,不容易出现版本冲突。我的核心依赖管理长这样:

<properties> <java.version>1.8</java.version> <spring-boot.version>2.7.18</spring-boot.version> <spring-cloud.version>2021.0.5</spring-cloud.version> <spring-cloud-alibaba.version>2021.0.5.0</spring-cloud-alibaba.version> <mybatis-plus.version>3.5.3.1</mybatis-plus.version> <hutool.version>5.8.18</hutool.version> </properties> <dependencyManagement> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-dependencies</artifactId> <version>${spring-boot.version}</version> <type>pom</type> <scope>import</scope> </dependency> <dependency> <groupId>org.springframework.cloud</groupId> <artifactId>spring-cloud-dependencies</artifactId> <version>${spring-cloud.version}</version> <type>pom</type> <scope>import</scope> </dependency> <dependency> <groupId>com.alibaba.cloud</groupId> <artifactId>spring-cloud-alibaba-dependencies</artifactId> <version>${spring-cloud-alibaba.version}</version> <type>pom</type> <scope>import</scope> </dependency> </dependencies> </dependencyManagement>

Java版本我锁的是1.8,一个重要原因是SpringBoot 2.7.18是最后一个支持JDK8的版本,很多学校的服务器环境还是JDK8,这样部署兼容性最好。如果你想上JDK17以上,那就得用SpringBoot 3.x + SpringCloud 2022.0.x,整套依赖版本要重调,建议新手还是从JDK8这套开始。

4.2 数据库设计:三张核心表的作用与关系

数据库设计是整个系统的地基。我设计了用户表(含角色区分)、荣誉申报表、证书记录表、荣誉类别字典表、审核记录表,其中三张核心表的作用和关联,直接理解为一条业务流水线是最清晰的:

sys_user保存学生和教师/管理员账号基本信息,关键字段有role(角色)、student_no(学号)、college(学院)、major(专业)。学生和教师公用一张表,通过role区分,避免建一堆表造成查询麻烦。

honor_application是申报单主表,记录荣誉类别、奖项名称、获奖等级、获奖时间、佐证材料URL、当前状态、申报人ID。这条表是整个业务流转的主战场,状态字段贯穿申报、审核、生成证书全生命周期。

certificate_record是证书记录表,记录证书编号、申报单ID、模板类型、PDF文件URL、生成时间、状态。证书编号我设计了独立的生成规则:HONOR + 年份 + 6位流水号,比如HONOR2026000123,用数据库自增id拼出来,但要注意并发下不能重复。MySQL的自增id本身是原子操作,所以只要生成的时机正确,编号不会重复,不需要额外加锁。

三个表之间的关系我用外键逻辑关联(不建物理外键),honor_application.user_id关联sys_user.id,certificate_record.application_id关联honor_application.id。不建物理外键的原因有两个:一是微服务拆库之后不同服务的表可能不在同一个库里,没法建外键;二是物理外键对写入性能有影响,而且删除数据时容易绊脚。逻辑关联加上应用层校验足够。

关于数据库的细节,如果你用的是8.x版本的MySQL,连接驱动要选择com.mysql.cj.jdbc.Driver,URL里建议带上serverTimezone=Asia/Shanghai,否则时间字段会差8小时。这个坑我见过无数次,先提个醒。

4.3 文件存储:MinIO接入SpringBoot

证书的佐证材料和最终生成的PDF,我都没直接存到MySQL里,而是统一放到MinIO对象存储。MinIO的部署很简单,Docker一行命令就能起一个实例:

docker run -d --name minio \ -p 9000:9000 -p 9001:9001 \ -e MINIO_ROOT_USER=minioadmin \ -e MINIO_ROOT_PASSWORD=minioadmin123 \ -v /data/minio:/data \ minio/minio:latest server /data --console-address ":9001"

SpringBoot接入MinIO,最核心的是引入SDK并在服务启动时初始化客户端:

@Configuration public class MinioConfig { @Value("${minio.endpoint}") private String endpoint; @Value("${minio.access-key}") private String accessKey; @Value("${minio.secret-key}") private String secretKey; @Bean public MinioClient minioClient() { return MinioClient.builder() .endpoint(endpoint) .credentials(accessKey, secretKey) .build(); } }

上传文件的代码简化后是这样:

public String uploadFile(MultipartFile file) { String originalFilename = file.getOriginalFilename(); String suffix = originalFilename.substring(originalFilename.lastIndexOf(".")); String objectName = "certificate/" + UUID.randomUUID() + suffix; PutObjectArgs args = PutObjectArgs.builder() .bucket("honor-cert") .object(objectName) .stream(file.getInputStream(), file.getSize(), -1) .contentType(file.getContentType()) .build(); minioClient.putObject(args); return endpoint + "/honor-cert/" + objectName; }

用UUID做文件名可以避免重名覆盖,同时按业务目录(certificate/、material/)划分,后续做生命周期管理会很方便。注意上传前要先确认桶存在,不存在就makeBucket,桶的权限策略要设置为readonly,这样生成的文件URL可以直接通过浏览器访问。如果你不想暴露MinIO的9000端口,也可以用Nginx反代一下,URL就更规整。

MinIO接入SpringBoot还有一个隐藏好处:后续如果要迁移到阿里云OSS或者腾讯云COS,接口逻辑几乎不用改,只换SDK实现即可。这也是为什么我坚持不在业务代码里直接操作文件系统。

4.4 前端:Vue动态路由与权限控制

前端我用Vue Router的动态路由配置,根据用户角色动态注册路由表。学生登录后只能看到“我的申报”、“证书查询”,教师能看到“待审核列表”,管理员多了“用户管理”、“模板管理”。实现方式是在router.addRoutes(roleRouters[role])里动态注入路由定义。

这里要强调一个安全红线:前端动态路由只是用户体验层面的控制,真正的权限校验必须在后端做。我在后端每个接口的Controller方法上都加了@PreAuthorize("hasRole('ADMIN')")之类的注解,Gateway层JWT也会解析角色,前端隐藏菜单只是“眼不见为净”,后端拦截才是真正的安全边界。

Vue项目还有两个操作细节:一是在main.js里配置axios拦截器,统一带上Authorization请求头;二是用beforeEach路由守卫做页面跳转前的Token过期判断。这两个做好了,前端整个登录态管理就闭环了。

// 路由守卫示例 router.beforeEach((to, from, next) => { const token = localStorage.getItem('token'); if (to.path !== '/login' && !token) { next('/login'); } else if (token && !hasRoute(to.path)) { refreshRouter().then(() => next({ ...to, replace: true })); } else { next(); } });

4.5 登录认证:从Login接口到一次完整请求的链路

整个认证链路是:前端提交用户名密码,请求打到Gateway,转发到user-service的/api/user/login接口。user-service校验账号密码后,生成JWT Token和Refresh Token,把Token和用户基本信息存入Redis,返回Token给前端。

生成JWT的时候,我用jjwt库,核心代码:

private String generateToken(UserLoginDTO user) { long expMillis = System.currentTimeMillis() + 7200_000; // 2小时过期 return Jwts.builder() .setSubject(user.getUsername()) .claim("userId", user.getId()) .claim("role", user.getRole()) .setExpiration(new Date(expMillis)) .signWith(SignatureAlgorithm.HS256, secretKey) .compact(); }

Token过期时间我设的是2小时,安全性和体验的平衡。时间太短频繁重新登录烦人,时间太长风险大。如果要做“记住我”功能,可以考虑用RefreshToken刷新的方式,但项目暂时没做这个,够用就行。

前端拿到Token后,每次请求都在axios拦截器里附上:

service.interceptors.request.use(config => { const token = localStorage.getItem('token'); if (token) { config.headers['Authorization'] = 'Bearer ' + token; } return config; });

Gateway的JWT全局过滤器负责解析Authorization头,验证签名和有效期,然后把userId和role放进请求头传递给下游服务。后端服务从请求头里取这两个值,就等于拿到了“当前操作者身份”,不需要再查库。

这里有个细节值得注意:JWT的密钥一定要通过Nacos配置中心下发,不要硬编码在代码里,也不要放在本地application.yml里提交到git仓库。密钥一旦泄露,攻击者就能自行签发Token,整个系统的认证防线就形同虚设了。

5. 常见问题与排查方案

做完微服务项目,你一定会遇到各种稀奇古怪的问题。我把自己调试过程中最容易踩的几个坑整理成表格,按“症状-原因-解法”列出来,遇到类似问题可以快速定位。

症状根本原因解决方案
服务注册不上Nacos,日志报连接超时Nacos服务没启动,或server-addr配置错了先telnet测试8848端口,确认服务端状态
Feign调用报404,但直连服务正常调用路径和提供方Controller不匹配,或StripPrefix参数不对打开Feign日志,比对实际URL
前端跨域报错Gateway和前端域名不同,缺少跨域配置在Gateway添加Cors配置,设置allowedOrigins
证书重复生成分布式锁被跳过,或锁内没做二次幂等检查检查锁获取路径,确认加锁后再查状态
PDF中文乱码字体缺失,渲染模板默认用了英文字体引入中文字体文件(如宋体、黑体)并注册到渲染引擎
上传文件成功但下载链接打不开MinIO桶权限是private修改桶策略为readonly或生成预签名URL
修改了Nacos配置不生效配置中心没加@RefreshScope在需要动态刷新的Bean上添加@RefreshScope注解
数据库日期少8小时连接串没指定serverTimezoneJDBC URL加serverTimezone=Asia/Shanghai
Nacos版本太高,启动服务报错SpringCloud Alibaba与Nacos Server版本不匹配对齐版本:SpringCloud Alibaba 2021.0.5.0对应Nacos 2.x
Vue页面刷新后404前端路由是history模式,开发服务器没配置fallback后端或nginx配置try_files重定向到index.html

下面挑三个我印象最深的展开讲,每个都折腾了不少时间。

问题一:Nacos和SpringCloud Alibaba版本不配套

我最早用的是Nacos Server 2.2.0,SpringCloud Alibaba用的是2021.0.1.0,服务启动后反复报错,一直重启失败。后来查资料发现,SpringCloud Alibaba 2021.0.1.0对应的Nacos Client版本是1.4.2,和Nacos Server 2.2.0有兼容性问题。解决办法是把SpringCloud Alibaba升到2021.0.5.0,或者换Nacos Server 2.1.0。我的经验是:Nacos Server尽量用2.x稳定版,SpringCloud Alibaba版本别追最新,选当前SpringCloud版本对应的release版本,具体对照表在Spring Cloud Alibaba官方wiki里有,先查清楚再动工。

问题二:MinIO跨域+CORS配置

前端通过浏览器直连MinIO上传文件时,会遇到跨域错误。我在MinIO控制台里给所在桶配置了CORS规则,AllowedOrigins填*,AllowedMethods选GET PUT POST DELETE。但后来发现前端还是报跨域,排查半天才明白:文件先上传到honor-service的接口,再由后端转存到MinIO,所以前端的跨域是发生在调用honor-service接口时,不是MinIO直连。需要在Gateway层做跨域配置,给前端放行。搞清楚链路之后,问题迎刃而解。

spring: cloud: gateway: globalcors: cors-configurations: '[/**]': allowedOrigins: "http://localhost:8080" allowedMethods: "*" allowedHeaders: "*" allowCredentials: true

问题三:SpringBoot版本太高导致依赖冲突

有朋友问我SpringBoot 3.2能不能做成这个项目,我直接说不建议。原因不是做不了,而是很多配套依赖没有跟上。比如Redisson在SpringBoot 3.x下需要用专门的redisson-spring-boot-starter对接Jakarta Servlet API;minio的SDK虽然兼容,但某些内嵌依赖的旧版本在SpringBoot 3.x会出现冲突。如果非要上SpringBoot 3.x,建议把所有第三方依赖都换到兼容Jakarta的最新版本,而且你找网上资料时会发现很多还是旧版API的写法。作为演示或学习项目,我宁可用成熟稳定的2.7.18,把精力放在业务逻辑上,不跟框架版本较劲。

jakarta.servlet.http.HttpServletRequest和旧版javax.servlet的区别,这也是SpringBoot 3迁移最闹心的地方,动手升级之前先确认自己有没有精力处理这类问题。

5.2 事务与分布式锁相关的三个隐蔽坑

分布式锁看似简单,但细节决定成败。第一个坑是锁粒度。我的证书生成锁key是cert:lock:{honorId},只锁当前申报单,这样不同申报单的证书生成可以并发跑,性能不会被拖垮。如果把锁key写死成cert:lock:all,整个系统同一时间只能生成一份证书,高峰期就会大量排队。

第二个坑是锁超时时间设置不当。如果leaseTime设得太短(比如5秒),而证书生成逻辑实际运行了8秒(比如MinIO上传慢、PDF渲染慢),锁会自动释放,其他请求就能进入锁内拿到同一个honorId,导致重复生成。我在设计时把leaseTime设为30秒,并配合锁内二次幂等检查,双保险。

第三个坑是锁的释放只能由持有者释放。Redisson的lock.unlock()只会释放当前线程持有的锁,不存在误删别人锁的问题,但如果你用的是手写SETNX+DEL的简易锁,一定要先比较value再删除,否则可能把别人刚获取的锁删掉,破坏互斥性。这也是我直接用Redisson的原因,框架帮我处理好了这些细节。

5.3 审核并发修改:如何保证同一张申报单不被重复处理

两个审核员同时打开同一个申报单,一个点了“通过”,另一个点了“驳回”,数据库里最终状态以谁为准?如果不做控制,后提交的覆盖先提交的,就会发生状态错乱。我用的是数据库乐观锁方案:申报单表加一个version字段,更新时使用带版本条件的SQL,配合状态迁移校验,形成双重保护。

int updated = honorApplicationMapper.update( null, new LambdaUpdateWrapper<HonorApplication>() .eq(HonorApplication::getId, honorId) .eq(HonorApplication::getVersion, version) .set(HonorApplication::getStatus, targetStatus) .set(HonorApplication::getVersion, version + 1) ); if (updated == 0) { throw new BusinessException("数据已被其他操作员修改,请刷新后重试"); }

这里updated == 0说明版本号不匹配,也就是有人抢先更新了,直接拒绝本次操作。乐观锁在低并发场景下性能最好,避免了数据库悲观锁的锁等待开销,非常适合审核流程这种“读多写少、冲突偶发”的场景。

6. 性能优化与后续扩展空间

6.1 当前系统已经做了哪些性能优化

  • MySQL端:审核列表和申报列表加了联合索引(status, create_time),这是最常用的查询条件组合;列表查询用MyBatis-Plus分页插件,限制每页最多20条。
  • Redis端:荣誉类别字典、学院列表这类不常变的数据,缓存1小时;高频查询的学生获奖列表,缓存5分钟,采用“缓存优先、失效回源DB再回填缓存”的标准流程。
  • 服务端:Feign连接池开启,复用HTTP连接,减少每次调用的握手开销;服务之间调用关闭了不必要的日志打印,降低IO压力。
  • 文件端:证书PDF生成后,前端直接走MinIO的CDN或Nginx静态访问,不经过后端应用服务器,大大减轻带宽压力。

6.2 如果要继续迭代,可以往哪些方向走

这个项目做到当前阶段,已经能覆盖学校荣誉证书管理的核心需求。但基于我对这类系统的理解,还有几个可以明确的进化方向。

第一个是引入消息队列做异步解耦。目前证书生成是同步链路,请求量大了之后,前端等得时间会比较长。如果引入RabbitMQ或RocketMQ,申报审核通过后只发一条消息到MQ,certificate-service作为消费者慢慢生成证书,用户的请求立刻返回“证书生成中”,体验会好很多。同时这个改动跟现有的本地消息表最终一致性方案并不冲突,可以把本地消息表改造为真正的MQ生产者。

第二个是引入Seata处理强一致事务场景。如果后续要支持学籍异动、证书补办这类强一致业务,本地消息表的方案就不够看了。Seata的AT模式接入成本不算高,但要注意它对数据库、全局锁都有要求,小项目慎用。

第三个是补充完整的监控告警体系。微服务集群起来之后,没有监控就是“睁眼瞎”。Spring Boot Admin可以看每个服务的健康状态和基础指标;Micrometer + Prometheus + Grafana可以画出JVM内存、QPS、响应时间的完整曲线;SkyWalking做分布式链路追踪,排查跨服务调用的延迟问题非常直观。这些工具在毕设里属于加分项,在真实项目里则是必备项。

第四个是KindEditor或富文本编辑器接入通知公告模块。荣誉证书系统往往伴随着“获奖名单公示”场景,目前只有审核通过后学生自己查看,如果能加入公示公告模块,让全校师生在线查看公示内容,系统的完整性会更高。

写在最后的一些体会

整个项目从零搭建到跑通完整业务链路,我自己最大的体会是:微服务架构真正的难点不在“写代码”,而在“做决策”。服务怎么拆、事务怎么保证、缓存怎么设计、版本怎么选,每个决策背后都有技术选型的逻辑和代价。如果只是照着教程抄代码,写完也不理解为什么这么写,那遇到线上问题依然无从下手。

这个项目我前前后后调了将近三周,中间推翻过一次服务拆分方案,从5个服务缩减到4个服务,把原先拆出来的“消息服务”合并回了user-service,因为它的业务逻辑太少,独立部署的收益远小于维护成本。这个经历想特别提醒你:别为了微服务而微服务,拆分的标准永远是“独立演进的价值”,而不是“看起来像微服务”。

证书PDF生成那一块,我一开始用模板引擎直接渲染HTML转PDF,中文乱码、样式错位的问题反反复复搞了一两天。后来换成固定的PDF模板填充表单域的方式,配合注册中文字体,一次就稳定了。经验是:在特定格式文档生成场景下,成熟的模板方案远比通用的HTML渲染方案靠谱。

如果你正在做类似的项目,或者正准备把单体应用改造成微服务架构,可以把这篇里的模块划分、表设计、事务方案、锁机制直接拿去做参考。遇到问题时,优先看日志,其次看版本兼容,最后才怀疑业务逻辑——这是我调微服务项目总结出来的排错顺序。希望这份笔记能帮你少踩几个坑。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/2 18:22:40

vLLM启动参数深度解析:显存分配、KV Cache与吞吐调优实战

搞大模型推理的人&#xff0c;迟早会面对一个绕不开的问题&#xff1a;同一个模型&#xff0c;同样的GPU&#xff0c;为什么别人能跑出每秒几百token&#xff0c;自己却连启动都报错&#xff1f;大部分差异&#xff0c;其实都藏在vLLM启动模型的参数设置里。 vLLM是目前生产环…

作者头像 李华
网站建设 2026/10/2 18:22:36

Spring Boot进销存毕设项目实战:从源码到跑通的完整指南

简介&#xff1a;面向高校计算机专业毕业设计的基于 Spring Boot 的进销存管理系统源码包&#xff0c;适合 Java 开发者、应届毕业生参考学习。系统以企业物资流与资金流为主线&#xff0c;覆盖采购订单持久化、采购入库与退货、商品入库出库、库存查询、销售订单添加与发货、销…

作者头像 李华
网站建设 2026/10/2 18:21:46

SpringBoot自定义logback日志配置实战:核心概念与完整方案

在SpringBoot项目里做自定义logback日志配置&#xff0c;几乎是每个后端开发迟早要面对的事情。很多人一开始觉得“SpringBoot不是自带日志吗&#xff0c;直接sout不行吗”&#xff0c;等到生产环境出了问题没法排查、日志文件把磁盘撑爆、或者想按业务维度把日志分开统计的时候…

作者头像 李华
网站建设 2026/10/2 18:20:21

智能车锥桶识别的嵌入式视觉闭环设计

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/2 18:19:50

零代码AI应用平台落地实践:从工作流编排到智能客服搭建

最近跟几个做SaaS的老朋友聊天&#xff0c;大家不约而同都在折腾同一件事——怎么把手里的AI能力包装成客户能直接用的产品。有的还在用最原始的方式接API、写前端、调prompt&#xff0c;开发周期按周算&#xff1b;有的已经换了思路&#xff0c;直接在零代码AI应用平台上搭&am…

作者头像 李华
网站建设 2026/10/2 18:19:21

微信内置浏览器抓包调试:ADB+Chrome DevTools远程调试实战指南

做微信内置浏览器的抓包调试&#xff0c;我前前后后折腾过不少次&#xff0c;踩过的坑比写出来的代码还多。先说说这件事的典型场景&#xff1a;你在开发H5页面&#xff0c;PC浏览器里一切正常&#xff0c;一到微信里打开就出问题——接口报错、白屏、数据不对&#xff0c;最要…

作者头像 李华