1. 项目概述与整体思路
1.1 这是一个什么系统
校园打印店永远是个神奇的地方。平时冷冷清清,一到期末、答辩季、考研季,排队能排到门外面去。更麻烦的是,U盘拷贝文件带来的病毒交叉感染,打印前才发现格式不对、页数不对,高峰期取件还经常拿错。其实这类场景的核心诉求很一致:让学生能提前上传文件、预约打印时间,打印店提前批量处理,最后凭取件码快速核销取件。
这里要聊的这套“校园打印店预约及取件系统”,就是围绕这条链路做的分布式微服务实现。技术栈是 SpringBoot + SpringCloud + Vue,后端按业务能力拆成多个微服务,前端用 Vue3 管理用户端、打印店管理端和取件端。学生可以在线注册登录、上传打印文件、选择纸张/份数/单双面/黑白彩色并预估价格、在线支付;打印店后台实时接收订单、排队打印、上报打印状态;打印完成后系统自动生成取件码并用通知触达用户,到店扫码或输入取件码即可取件。
这项目最典型的适用场景是三类人:第一类是准备做毕业设计或课程设计的同学,微服务、分布式事务、分布式锁这些关键词正好能覆盖答辩亮点;第二类是接了校园创业项目或小程序外包的开发者,需要一个能落地的业务闭环;第三类是想从单体项目往分布式架构进阶的后端工程师,拿一个真实业务练手比刷一百遍理论都管用。
项目本身不算“大厂级”,但麻雀虽小五脏俱全。路由网关、服务注册发现、远程调用、分布式事务、消息队列、分布式锁、对象存储、前端联调、Docker部署,这一套走下来,你对“微服务项目到底怎么落地”的认知会完整很多。
1.2 为什么不用单体架构
说实话,一个校园打印店如果只服务于一两千人的学院,单体项目完全够用。MySQL 单库、一台应用服务器、前端一个后台管理系统,性能也不会差到哪去。那为什么还要上微服务?
我的判断是:选择微服务应该同时满足两个理由,至少满足一个才值得拆。一是业务确有独立扩展的需求,二是明确带着技术进阶的目的。校园打印业务虽然规模不大,但有两个特点很适合微服务的思路:第一,文件上传和打印任务调度是典型的资源消耗型接口,高峰期所有学生同时传论文,文件服务单独拆出来可以做异步化、限流、对象存储,不会把订单和支付接口拖垮;第二,期末打印高峰是短时脉冲流量,服务拆开后可以单独给打印任务服务和文件服务多开副本,订单服务保持稳定水位,扩缩容粒度更细。
从技术学习角度来说,SpringCloud 生态本身就是当前国内企业后端的主流形态,Nacos 注册中心、Gateway 网关、OpenFeign 远程调用、Sentinel 限流降级,这些技能在简历上写出来是有说服力的。用这个项目把分布式的几个硬骨头都啃一遍,后面去面试微服务相关岗位,至少不会被问到哑口无言。
当然,我也在文末专门留了一节“关于复杂度的预算”,如果你只是要快速交差,可以只拆三四个服务。微服务不是银弹,这句忠告放在前面说,进可攻退可守。
1.3 系统角色与核心功能边界
整个系统的角色大致分三类:
- 学生/用户:注册登录、上传文件、选择打印参数、下单支付、查看打印进度、凭取件码取件;
- 打印店管理员:查看订单列表、查看打印机状态、处理异常订单、手动调整任务优先级;
- 系统管理员:管理打印机、管理定价策略、查看经营报表。
围绕这三个角色,业务上可以粗分出用户、文件、订单、支付、打印任务、取件、通知这几个核心领域。后面做微服务拆分时,这些领域边界就是服务边界的重要参考。一个很容易犯的错是:为了拆服务而拆,把一个订单状态机拆到了五个服务里,最后改一个状态要发三条消息,出了问题都不知道去哪查日志。先有的业务边界,后有的微服务拆分,顺序不能反。
2. 技术选型与微服务架构设计
2.1 技术栈总览
这个项目的技术栈,我直接给出一份“用过之后觉得稳”的清单,并且标注了版本组合建议。之所以要单独说版本,是因为SpringBoot、SpringCloud、SpringCloud Alibaba 三者的版本矩阵非常容易踩坑,网上各种教程版本对不上,抄过来 Nacos 注册不上、Gateway 路由不生效的问题有一大半都是版本错乱导致的。
| 组件 | 选型建议 | 版本建议 | 说明 |
|---|---|---|---|
| 基础框架 | SpringBoot | 2.7.18 | 稳定且兼容大多数中间件,Java 8/11 均支持 |
| 微服务框架 | SpringCloud | 2021.0.8 | 对应“Jubilee”版本 |
| 微服务组件 | SpringCloud Alibaba | 2021.0.5.0 | 与 Nacos 2.x、Sentinel 1.8.x 配套 |
| 注册/配置中心 | Nacos | 2.2.x | 同时承担注册中心和配置中心 |
| 网关 | Spring Cloud Gateway | 随 SpringCloud | 注意 gateway 基于 WebFlux,不要引入 spring-webmvc |
| 远程调用 | OpenFeign | 随 SpringCloud | 声明式 HTTP 客户端 |
| 数据库 | MySQL | 8.0+ | 执行业务数据 |
| 缓存/锁 | Redis | 6.x | 分布式锁、热点配置缓存 |
| 对象存储 | MinIO | RELEASE.2023+ | 文件存储,兼容 S3 API |
| 消息队列 | RabbitMQ | 3.10+ | 订单事件、通知事件解耦 |
| 分布式事务 | Seata | 1.6.1 | AT 模式,学习成本适中 |
| 限流降级 | Sentinel | 1.8.6 | 网关与核心服务接入 |
| 前端 | Vue3 + Vite + Element Plus + Pinia | 较新的稳定版 | 前后端分离 |
如果你预算紧张,可以把 Nacos 换成 Eureka、Seata 去掉换成消息队列最终一致性、MinIO 换成普通服务器磁盘目录,也能跑。但既然项目名字里带了 SpringCloud 和分布式,这几个重量级组件还是建议保留,它们是项目最大的亮点来源。
2.2 微服务拆分粒度:拆到什么程度才算合理
先看最终的服务列表。我按核心业务流拆成了 8 个微服务加 1 个网关,端口规划上也做了约定。
| 服务名 | 端口 | 核心职责 |
|---|---|---|
| gateway-service | 8000 | 统一入口、JWT 鉴权、路由转发、跨域、限流 |
| auth-service | 8100 | 登录注册、Token 签发与刷新 |
| user-service | 8200 | 用户资料、学生端信息维护 |
| file-service | 8300 | 文件上传下载、MinIO 对接、格式校验 |
| order-service | 8400 | 订单创建、价格计算、订单状态机 |
| payment-service | 8500 | 聚合支付对接(或模拟支付)、支付回调 |
| print-task-service | 8600 | 打印任务创建、打印机排队调度、状态上报 |
| pickup-service | 8700 | 取件码生成、取件核销、防重复取出 |
| notification-service | 8800 | 站内信/短信/邮件通知、WebSocket 推送 |
客户端(Vue Web/小程序) ↓ Nginx(可选,静态资源 + 反向代理) ↓ Spring Cloud Gateway(统一网关:鉴权 / 路由 / 限流) ↓ auth-service user-service file-service order-service payment-service print-task-service pickup-service notification-service ↓ MySQL Redis RabbitMQ MinIO Seata ↑ 注册中心 / 配置中心:Nacos这里要特别说一句:按领域划分是正路,按“一个页面拆一个服务”是灾难。比如“我的订单页”需要查订单、查文件、查取件码,如果这三个数据分散在三个服务里,前端一次请求要聚合三次。解决方式通常是加一个 BFF(Backend For Frontend)聚合层,或者在网关层做聚合。做毕设时,可以在 order-service 里冗余存储文件的基本信息和取件码,避免跨服务查询太频繁。这不是最优雅的,但是最实用的。
2.3 SpringCloud 组件矩阵与各自的角色
- Nacos:既做服务注册中心,又做配置中心。服务启动时先从 Nacos 拉 bootstrap 配置,再注册到 Nacos 集群,其他服务通过服务名在 Nacos 拿可用实例列表;
- Spring Cloud Gateway:所有请求统一从网关走,网关统一做 Token 校验、参数校验、跨域、限流,避免每个微服务都写一遍鉴权逻辑;路由规则用 serviceId 转发,例如
/api/order/**转到 order-service; - OpenFeign:服务间同步调用的首选,用接口 + 注解的方式完成远程 HTTP 调用,底层集成 Ribbon 负载均衡(新版本是 Spring Cloud LoadBalancer);
- Sentinel:对“上传文件”“创建订单”这种热点接口做限流,防止高峰期被打垮;服务之间 Feign 调用也要接 Sentinel 做降级,避免一个服务慢调用拖垮全链路。
组件选型上,说实话国外教程更常用 Eureka + Zuul 那套,但国内企业中 Nacos + Gateway + OpenFeign 的组合更主流、中文资料也更全。如果你把系统简历上写“熟悉 SpringCloud 生态,使用 Nacos 作为注册配置中心”,面试官不会觉得奇怪,反而更有共鸣。
3. 核心业务实现:预约、打印与取件
3.1 预约下单主流程设计
整个核心链路不复杂,但每一步都有值得展开的细节。先看主流程:
- 用户登录后上传打印文件,file-service 接收文件,落盘到 MinIO,返回 fileId;
- 用户选择打印参数(纸张、份数、单双面、色彩、装订等),前端把这些参数与 fileId 一起发给 order-service;
- order-service 做价格计算、创建“待支付”订单;
- 用户发起支付,payment-service 完成扣款(真实场景对接微信/支付宝,校内演示可做模拟支付);
- 支付成功后,order-service 将订单状态更新为“已支付”,并向消息队列发送“订单支付成功”事件;
- print-task-service 消费事件,创建打印任务,按打印机状态排入队;
- 打印完成后,print-task-service 更新任务状态,并调用 pickup-service 生成取件码,同时发送“可取件”通知;
- 用户到店取件,pickup-service 校验取件码并核销,订单最终状态变为“已取件”。
这个主流程里,订单状态机要优先设计好。我的建议是把订单状态定义成枚举:CREATED(待支付) →PAID(已支付) →QUEUED(排队中) →PRINTING(打印中) →PRINTED(已完成) →PICKED_UP(已取件),另外还有CANCELLED(已取消)、REFUNDING(退款中) 两个辅助状态。在代码里,状态流转必须用状态机或专门的 Service 方法控制,不要允许任意状态互相跳转,否则后面排查订单问题会发疯。
创建订单的伪代码大概长这样,分布式事务的注解先加上,后面细说:
@GlobalTransactional(rollbackFor = Exception.class) public OrderVO createOrder(OrderRequest request) { // 1. 远程获取文件信息 FileInfoVO file = fileClient.getFileById(request.getFileId()); // 2. 远程获取定价 PriceVO price = priceClient.calculatePrice(file.getPages(), request.getPrintParams()); // 3. 本地创建订单 OrderDO order = new OrderDO(); order.setOrderNo(generateOrderNo()); order.setUserId(request.getUserId()); order.setAmount(price.getAmount()); order.setStatus("CREATED"); orderMapper.insert(order); // 4. 发起支付(同步接口,支付成功才继续) paymentClient.pay(order.getOrderNo(), order.getAmount()); // 5. 更新订单状态 order.setStatus("PAID"); orderMapper.updateById(order); return OrderVO.from(order); }3.2 文件上传与 MinIO 对象存储设计
文件服务是打印业务的源头,这里我踩过不少坑,重点说几个。
第一,上传前必须做文件校验。不只是判断文件是否为空,还要校验扩展名、MIME 类型、文件大小。Word 文档、PDF 是主要打印对象,图片也算常见,但脚本文件、压缩包这类就该直接拦截。大小方面,单体项目一般 setMaxFileSize 限制 10MB,但到了微服务场景还要注意网关层的传递限制,否则前端文件传到了网关,网关转发时直接 413,你排查半天都没想到是网关的问题。
第二,上传到 MinIO 的路径规划要清晰。我用的格式是user-files/{userId}/{yyyyMM}/{uuid}.pdf,这样同一个用户的文件天然归档在一个前缀下,后面做定期清理和账单核对都方便。上传代码可以这样写:
@PostMapping("/upload") public Result<FileUploadVO> upload(@RequestParam("file") MultipartFile file, @RequestParam("userId") Long userId) { // 1. 基础校验 if (file.isEmpty()) { throw new BizException("上传文件不能为空!"); } String ext = getExt(file.getOriginalFilename()); if (!ALLOWED_EXTENSIONS.contains(ext)) { throw new BizException("不支持的文件类型"); } if (file.getSize() > MAX_FILE_SIZE) { throw new BizException("文件大小不能超过10MB"); } // 2. 构建对象名称 String datePath = LocalDate.now().format(DateTimeFormatter.ofPattern("yyyyMM")); String objectName = "user-files/" + userId + "/" + datePath + "/" + UUID.randomUUID() + ext; // 3. 上传到 MinIO minioClient.putObject(PutObjectArgs.builder() .bucket("print-storage") .object(objectName) .stream(file.getInputStream(), file.getSize(), -1) .contentType(file.getContentType()) .build()); // 4. 落库并返回 FileMetaDO meta = FileMetaDO.builder() .userId(userId) .objectName(objectName) .fileSize(file.getSize()) .fileName(file.getOriginalFilename()) .fileType(ext) .status("UPLOADED") .build(); fileMetaMapper.insert(meta); return Result.success(FileUploadVO.builder() .fileId(meta.getId()) .fileName(meta.getFileName()) .fileSize(meta.getFileSize()) .build()); }第三,打印服务怎么拿到文件?不建议把文件从 file-service 下载后再传给 print-task-service,这样文件会经过两次网络传输。更好的方式是 file-service 生成一个有效期为 15 分钟的 MinIO 预签名下载 URL,print-task-service 拿到 URL 后直接从 MinIO 拉取。代码很简单:
String url = minioClient.getPresignedObjectUrl( GetPresignedObjectUrlArgs.builder() .method(Method.GET) .bucket("print-storage") .object(objectName) .expiry(15, TimeUnit.MINUTES) .build());这个方案既避免了服务间大流量文件传输,也不会把 MinIO 的 AccessKey 暴露给前端。
3.3 打印任务调度与打印机状态上报
订单支付完成后,消息队列会触发打印任务的创建。这里我不建议“每来一个订单就立刻直接打印”,而是要先把任务放到一个打印任务池中,由调度器统一分配打印机。
原因很简单:有的打印机支持双面、有的只支持黑白,有的机器老旧、打印速度慢,盲目分配很容易出现一台打印机忙死、其他机器闲着的现象。简单版的做法是:任务池表记录每个任务的printerTypeRequirement,调度器定期扫描池子里的待处理任务,选择状态为IDLE(空闲)且匹配类型的打印机进行分配;任务分配到打印机后,状态改为PRINTING。
@Component public class PrintTaskScheduler { @Scheduled(fixedDelay = 5000) public void dispatch() { List<PrintTaskDO> pendingTasks = printTaskMapper.selectPendingList(); for (PrintTaskDO task : pendingTasks) { PrinterDO printer = selectAvailablePrinter(task); if (printer == null) { continue; } // 抢打印机分布式锁,防止并发分配 RLock lock = redissonClient.getLock("printer:lock:" + printer.getId()); if (lock.tryLock()) { try { printer.setStatus("BUSY"); printerMapper.updateById(printer); task.setPrinterId(printer.getId()); task.setStatus("PRINTING"); printTaskMapper.updateById(task); // 异步通知打印客户端开始处理 printGatewayClient.startPrint(task.getId()); } finally { lock.unlock(); } } } } }打印机侧的处理逻辑,真实项目里会对接 CUPS 或厂商 SDK,这里可以用一个“打印执行器”接口做模拟实现:读取文件、模拟按页输出、每打印几页回调一次进度。目的是把“打印机”这个物理设备抽象成一个微服务可调用的执行器,后续换成真实设备时只要替换实现即可。
3.4 取件码生成与防重复取件的关键实现
打印任务完成之后,就需要生成取件码并通知用户。取件码看似简单,但里面的细节比想象中多。
取件码生成规则建议用 6 位纯数字,但要剔除0和O、1和I这些易混淆字符,避免用户念错输错。生成后不要明文直接入库,存 SHA-256 哈希值即可。用户取件时输入取件码,系统先哈希再查库,这样即使数据库泄露,攻击者也拿不到可直接使用的取件码。
最关键的还是“防重复取件”。想一想这个场景:用户在店门口掏出手机,输入取件码后网络卡顿了一下,他以为没提交成功又点了一次;或者同时有两台自助取件终端,用户一左一右各扫了一次。如果核销逻辑没有做幂等控制,就会把同一个订单取两次件,最后打印店对账对不上。
第一层保护是数据库状态约束,核销 SQL 必须带上状态条件:
UPDATE pickup_record SET status = 'PICKED_UP', picked_time = NOW() WHERE pickup_code = #{pickupCodeHash} AND status = 'PRINTED'如果影响行数为 1,说明取件成功;如果为 0,说明要么取件码不存在,要么已经被取过。
但数据库状态约束在极端并发下仍然可能出现问题:两个事务同时读到PRINTED状态,都认为可以更新,其中一个被锁等待后更新失败还好,但业务上可能已经发出了两条“取件成功”的提示。所以还需要第二层保护,加分布式锁:
public PickupResult pickup(String pickupCode) { String codeHash = sha256(pickupCode); RLock lock = redissonClient.getLock("pickup:lock:" + codeHash); lock.lock(); try { // 双重检查:取件码状态 PickupRecordDO record = pickupRecordMapper.selectByCodeHash(codeHash); if (record == null) { return PickupResult.fail("取件码不存在"); } if ("PICKED_UP".equals(record.getStatus())) { return PickupResult.fail("该订单已被取件"); } // 执行核销,返回影响行数 int rows = pickupRecordMapper.markPickedUp(codeHash); if (rows == 1) { // 发送取件成功消息,通知订单服务关闭订单 mqTemplate.convertAndSend("order.pickedup", record.getOrderNo()); return PickupResult.success(); } return PickupResult.fail("取件码状态异常,请联系工作人员"); } finally { lock.unlock(); } }注意:锁的粒度是“取件码级别”,不是全局锁,否则所有用户取件都会被串行化。锁的 key 用取件码哈希值,能保证同一个取件码并发时互斥,不同取件码互不影响。分布式锁正确的释放逻辑必须放在 finally 里,并且要确认锁是自己加的,Redisson 的 lock/unlock 已经包含这些细节,所以能用现成库就别手写 SETNX。
4. 分布式场景下的关键问题与实战方案
4.1 分布式事务:订单、支付与打印任务的一致性
到了这一步,微服务项目才算真正碰到了分布式系统的硬骨头。下单、支付、创建打印任务跨了 order-service、payment-service、print-task-service 三个服务,怎么保证数据一致性?
方案一,Seata AT 模式。在发起方方法上加@GlobalTransactional,Seata 会协调所有参与事务的分支事务。AT 模式对业务代码侵入很小,核心原理是对 SQL 执行前后生成镜像,利用undo_log表进行回滚。搭建时需要单独部署 seata-server,业务库要建undo_log表,全局事务所需的global_table、branch_table由 seata-server 自己管理。
Seata 的配置有几个比较容易出错的地方,后面第 6.2 节会专门说。这里给出核心的 yml 配置片段:
seata: enabled: true application-id: order-service tx-service-group: print-store-group registry: type: nacos nacos: server-addr: 127.0.0.1:8848 namespace: public group: SEATA_GROUP username: nacos password: nacos config: type: nacos nacos: server-addr: 127.0.0.1:8848 group: SEATA_GROUP username: nacos password: nacos方案二,基于消息队列的最终一致性。下单和支付在同一个本地事务内完成,支付成功后向 RabbitMQ 发送“订单已支付”事件;print-task-service 消费该事件,创建打印任务。如果消费失败,通过定时任务扫描“已支付但未创建任务”的订单,重新发送事件或直接补偿创建。这个方案不依赖 Seata 基础设施,更轻量,也更容易理解。
我的实际建议是:“支付+订单更新”这两个动作务必要保证强一致,否则用户钱付了订单却是待支付状态,事故率会直接拉满。而“订单已支付”到“创建打印任务”之间,完全可以接受最终一致性,毕竟打印服务短暂延迟几分钟不会造成用户损失。真正生产环境我反而更推荐 MQ 方案,但如果你需要体现分布式事务的技术深度,Seata 在毕设答辩里是非常好的加分项,前提是你能把 AT 模式的原理讲清楚。
4.2 分布式锁:不只是 SETNX 那么简单
分布式锁在项目里用了两个场景:取件核销防重和打印机并发分配。很多教程带你手写分布式锁,用 SETNX 加锁、DEL 解锁,然后就没有然后了。实际用下来这种玩具锁有三个致命问题:
- 忘记设置过期时间,锁线程崩了,锁永远释放不了;
- 设置了过期时间但业务还没执行完,锁提前过期,其他线程趁虚而入;
- 误删别人的锁,比如线程 A 超时释放了锁,线程 B 拿到锁开始执行,A 的 finally 里直接 DEL key,把 B 的锁给删了。
正确做法是直接用 Redisson。Redisson 的RLock锁底层虽然也用 Lua 脚本完成原子加锁,但它的 watchDog 机制会自动续期,默认锁 30 秒,每 10 秒检查一次,如果业务没执行完就续期到 30 秒,有效避免“锁提前失效”的问题。释放锁也是基于线程标识判断,不是本线程的锁不会误删。
// 注入 RedissonClient RLock lock = redissonClient.getLock("pickup:lock:" + codeHash); lock.lock(); try { // 业务逻辑 } finally { lock.unlock(); }用 Redisson 时同样要注意一个问题:如果锁的粒度是全局的,高并发下所有请求排队,吞吐量急剧下降。所以在设计锁 key 时一定要带上业务维度,比如按单号加锁、按取件码加锁、按打印机 ID 加锁,不要图省事用一个全局限流锁。
4.3 服务间调用、超时控制与故障隔离
微服务架构里,服务间调用是正常现象,但也是最容易出故障的地方。订单服务依赖文件服务、价格服务、支付服务,任何一个下游慢响应,上游线程池都会被占满,最后像雪崩一样批量超时。
我的处理思路是三层防护:
第一层,Feign 超时与重试必须配置。默认的 Feign 超时很短,且不会自动重试。校内网络和服务器性能都不算顶级,我一般设置连接超时 2 秒、读取超时 5 秒,并且只在幂等接口上开启重试,比如查询类接口。注意下单、支付类接口不要开自动重试,否则重复下单就是事故。
feign: client: config: default: connectTimeout: 2000 readTimeout: 5000 retryer: maxAttempts: 3 interval: 1000第二层,Sentinel 对核心资源做流控与降级。比如订单服务的“创建订单”接口,直接用 QPS 阈值限制,超过阈值的请求快速失败返回“系统繁忙,请稍后再试”;对文件服务这样的 IO 密集型调用,可以用线程数限流,避免线程堆积。Feign 接口也可以接入 Sentinel 做 fallback,下游不可用时返回兜底数据。
第三层,消息队列解耦非核心链路。像“打印完成通知用户”“订单状态变更日志”这类非关键操作,全部通过 RabbitMQ 异步处理。这样即使 notification-service 挂了,也不影响用户取件主流程,等服务恢复后消息还能消费掉,不会丢。
5. 前端实现:Vue3 用户端与管理端
5.1 Vue3 工程化准备
前端技术栈我采用的是 Vue3 + Vite + TypeScript + Pinia + Element Plus。选 Vue3 而不是 Vue2 的原因很直接:Vue3 的组合式 API 写业务逻辑比 Options API 清晰太多,一个“预约下单”功能的所有状态、方法、计算属性可以组织在同一个setup里,另外 Element Plus 对 Vue3 的生态支持已经很成熟。
工程化上第一步是配置请求封装。Axios 实例统一设置baseURL,请求拦截器里从 Pinia 或 localStorage 取 Token 并加到 Header 中;响应拦截器统一处理 HTTP 401 跳转登录、业务错误码弹提示。这一步不做,后面每个页面都要处理 Token 和错误提示,代码会非常冗余。
// request.ts const service = axios.create({ baseURL: import.meta.env.VITE_API_BASE_URL }); service.interceptors.request.use(config => { const token = localStorage.getItem('token'); if (token) { config.headers.Authorization = `Bearer ${token}`; } return config; }); service.interceptors.response.use( response => { const data = response.data; if (data.code !== 0) { ElMessage.error(data.message); return Promise.reject(new Error(data.message)); } return data.data; }, error => { if (error.response.status === 401) { router.push('/login'); } return Promise.reject(error); } );路由设计建议做用户端和管理端的权限区分。用户端包含首页、文件管理、预约下单、订单列表、取件页;管理端包含订单管理、打印机管理、定价管理、统计报表。动态路由的方式是根据用户角色在登录后追加路由表,而不是把所有路由不加判断地挂载。
5.2 核心页面:预约下单与订单追踪
预约下单页是用户最核心的页面,布局上一般分成:左侧文件上传区、中间打印参数区、右侧价格展示与提交区。
文件上传我强烈建议用el-upload的http-request自定义上传方法,而不是默认的上传行为。原因有两个:一是默认上传方式不方便携带自定义请求头,Token 没法加;二是http-request可以拿到上传进度回调,实现进度条。
<el-upload :http-request="handleUpload" :show-file-list="true" :before-upload="beforeUpload" > <el-button type="primary">上传打印文件</el-button> </el-upload> const handleUpload = async (option: UploadRequestOptions) => { const formData = new FormData(); formData.append('file', option.file); formData.append('userId', userStore.userId); const config = { headers: { 'Content-Type': 'multipart/form-data' }, onUploadProgress: (e: ProgressEvent) => { progress.value = Math.round((e.loaded / e.total) * 100); } }; const data = await uploadFile(formData, config); fileId.value = data.fileId; };打印参数区用一个参数卡片组件就够了,包含纸张 A4/A3、颜色黑白/彩色、单双面、份数、装订选项。这些参数改变时,前端可以实时调价格接口重新计算订单金额,也可以直接拿着pages和参数算,但真实项目建议以后端为准,因为页数必须由文件服务解析出来,前端拿不到真实页数。
订单追踪页建议做成横向步骤条:待支付 → 已支付排队中 → 打印中 → 已完成待取件 → 已取件。步骤条的当前节点由订单状态字段映射而来,配合后端推送或轮询接口刷新。这里我不建议一开始就上 WebSocket,先用 5 秒轮询足够支撑校内规模,后面再升级成 SSE 或 WebSocket 也不迟。
5.3 取件核销页与打印店管理大屏
取件端的界面是越简单越好,核心就是一个输入框加一个核销按钮。用户输入 6 位取件码,调取件核销接口,根据返回结果展示“取件成功”或“取件码无效”。如果引入了摄像头扫码,直接对接一个扫码组件把解析结果填入输入框即可。
打印店管理端的大屏是另一个擅长展示的地方,主要看三个信息:当前打印队列数量、各台打印机状态(空闲/打印中/故障)、今日订单量和营收。数据接口分别来自 print-task-service 的队列统计和 order-service 的经营汇总,前端用定时器每 10 秒拉一次数据即可。大屏刷新的实时性要求不高,没必要上复杂的长连接方案。
6. 部署运维与踩坑记录
6.1 基于 Docker Compose 的一键部署
本地开发没问题之后,部署环节直接用 Docker Compose 编排中间件。下面这个最小集合包含了项目所有的外部依赖:
version: "3.8" services: mysql: image: mysql:8.0 environment: MYSQL_ROOT_PASSWORD: root123 MYSQL_DATABASE: print_store ports: - "3306:3306" volumes: - ./mysql/data:/var/lib/mysql redis: image: redis:6.2 ports: - "6379:6379" nacos: image: nacos/nacos-server:v2.2.3 environment: MODE: standalone NACOS_AUTH_ENABLE: "false" ports: - "8848:8848" - "9848:9848" minio: image: minio/minio:RELEASE.2023-06-28T08-31-34Z command: server /data --console-address ":9001" environment: MINIO_ROOT_USER: minioadmin MINIO_ROOT_PASSWORD: minioadmin ports: - "9000:9000" - "9001:9001" volumes: - ./minio/data:/data rabbitmq: image: rabbitmq:3.11-management environment: RABBITMQ_DEFAULT_USER: guest RABBITMQ_DEFAULT_PASS: guest ports: - "5672:5672" - "15672:15672" seata-server: image: seataio/seata-server:1.6.1 environment: SEATA_IP: 127.0.0.1 SEATA_PORT: 8091 STORE_MODE: db # 依赖 MySQL 存储,需自行初始化 global_table / branch_table / lock_table ports: - "8091:8091"各微服务模块打成 Docker 镜像之后,再用 docker-compose 统一编排。每个服务都通过环境变量传入 Nacos 地址和公共配置,避免把配置写死在镜像里。
6.2 高频故障与排查清单
| 症状 | 大概率原因 | 解决方式 |
|---|---|---|
| Nacos 页面能看到服务,但 Feign 调用 404 | 服务名写错或没走负载均衡 | 检查@FeignClient(name = "file-service")与 Nacos 注册名是否一致 |
| 上传文件超过 10MB 被拦截 | 网关或服务的 multipart 限制没打开 | 在 gateway 和 file-service 同时配置spring.codec.max-in-memory-size/spring.servlet.multipart.max-file-size |
Seata 报could not find branch table | 业务库没有创建undo_log表 | 在每个参与分布式事务的业务库执行 Seata 提供的undo_log.sql;还要确认事务分组与 seata server 一致 |
| Nacos 连接失败 | 版本矩阵不对或网络不通 | 先 telnet 测试 8848/9848 端口;确认 SpringCloud Alibaba 版本与 Nacos 2.x 兼容 |
| Redis 锁总是刚释放就被别的线程拿到 | 之前用了简单 SETNX 方案 | 换成 Redisson,并启用 watchDog 自动续期 |
| 跨域请求被拒绝 | 网关层 CORS 未配置或重复配置 | 只在网关统一加 CORS,服务端不要再单独配,防止响应头重复 |
| 取件码正确但提示“已被取件” | 并发核销未加分布式锁 | 检查 pickup 核销逻辑是否加了 Redisson 锁,并确认锁的 key 是取件码哈希 |
| 订单已支付但一直没创建打印任务 | 消息队列事件丢失或消费失败 | 检查 RabbitMQ 死信队列/异常日志;增加定时任务扫描补偿 |
这里再单独提一个常见但容易忽视的问题:Nacos 作为配置中心时,配置文件的 Data ID 命名要规范。我用的格式是服务名-环境.yaml,例如order-service-dev.yaml,并在bootstrap.yml中用${spring.application.name}-${spring.profiles.active}.yaml动态拼接。如果命名不规范,服务启动后要么拉不到配置,要么拉到的是别的服务的配置,问题非常隐蔽。
6.3 前端部署与 Nginx 配置
前端构建完产物后放在 Nginx 下,反向代理到网关。需要注意两个点:一是前端路由如果是 history 模式,Nginx 要配置 try_files 重定向到 index.html;二是/api开头的请求统一代理到网关 8000 端口。
server { listen 80; server_name print.example.com; root /usr/share/nginx/html; index index.html; location / { try_files $uri $uri/ /index.html; } location /api/ { proxy_pass http://gateway-service:8000/api/; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } client_max_body_size 20m; }client_max_body_size 20m这行必须加上,而且要在location /api/作用域内也生效。很多同学前端上传大论文文件时总是失败,F12 看到 413,就是 Nginx 默认 1MB 限制在拦截。
7. 项目总结与后续扩展建议
7.1 从单体迁移到微服务的节奏建议
如果你不是从一开始就打算做微服务,而是手头已经有一个单体校园打印项目想改造成微服务,我的建议是不要一次拆全部。第一步先把 file-service 独立出来,文件上传和对象存储是天然的服务边界,拆出来收益最大、风险最小;第二步拆 order-service 和 payment-service,这两个服务之间的事务边界最清晰;最后再考虑拆 print-task-service 和 pick-up-service。每拆一步都要保证系统可运行、可测试、可回滚。
反过来,如果是从头做一个毕设项目,也不必一上来就铺 8 个服务。我建议先按“单模块多包结构”把代码按领域分模块,比如user、order、file、print四个模块平铺在同一个 SpringBoot 应用里,跑通全部流程以后,再按模块边界把代码搬进 Maven 多模块微服务工程。这样写代码时不会因为跨服务调用和分布式事务乱了阵脚。
7.2 可以继续扩展的方向
这套系统的扩展空间其实很大。
- 打印机集群调度优化:引入打印机实时负载、错峰调度、故障自动剔除,做得像样一点可以成为另一个项目亮点;
- 小程序端:现有 Vue3 Web 端可以换成 uni-app 或原生微信小程序,覆盖更多学生使用场景;
- 打印统计分析:基于订单数据和打印机任务数据做校园打印热力图、高峰期预测,这个用 ECharts 就能做得很好看;
- 消息推送:从轮询升级到 SSE 或 WebSocket,取件通知实时触达,用户体验明显提升;
- 对接云打印服务平台:如果打印店想减少自建打印机成本,可以对接第三方云打印 API,把打印任务直接推给云端印刷厂,再通过快递柜或自提柜交付。
7.3 我的一点实际体会
最后说几句掏心窝的话。做这个项目最大的感受就是,微服务架构的能力不在于你能把多少个中间件整合进去,而在于你能不能把业务边界切得干净、把分布式问题控制在不失控的范围里。取件码这个需求看起来很小,但真正做到并发安全、幂等核销、状态闭环,背后需要分布式锁、数据库状态机、消息通知三层配合。小而美的业务点,反而最能体现工程素养。
我在实际联调中还发现一个印象深刻的细节:Unicode 文件名的上传文件在打印服务端下载时总是乱码,折腾了很久才发现是 MinIO 的 content-disposition 编码问题,而不是业务代码的问题。这类问题网上教程基本不会写,只有真正跑起来踩过坑才会知道。这也是我为什么坚持建议,如果条件允许,一定要把项目完整地部署起来,用真实文件、真实取件码走几轮全流程,而不是只把代码跑通就算了。
做毕设或者做项目汇报时,把这套系统从架构设计到部署运维完整讲一遍,面试官很难不被打动。如果后续你想把系统做得更贴近生产,可以继续引入 SkyWalking 链路追踪、ELK 日志平台、灰度发布等能力。到那时候回头再看现在的项目,你会发现微服务这条路,每一步都算数。