
这几年高校后勤和资产处的招标采购业务量越来越大从实验器材、办公设备到食堂物资动辄几十万的单子。我接过好几个类似的需求一开始业务方都会说就是个发公告、收标书的系统可真做下去才发现评标专家怎么抽、供应商资质怎么审、投标保证金怎么锁定、开标时间和截止时间怎么严格按规则执行这些问题每一个都比想象中复杂。如果一上来就按单体应用做等业务跑起来再拆就非常痛苦。这篇文章就围绕我最近做完的一套校园物资招标投标竞标系统来聊技术栈是SpringBoot Vue SpringCloud微服务分布式架构。我会把项目从需求拆解、服务划分、后端实现、前端对接到分布式环境下事务、锁、文件存储这些硬骨头的解决思路完整过一遍最后也分享几个部署和自测阶段踩过的坑。适合正在做毕业设计、课程设计或者想参考微服务落地方案的同学也适合刚接触SpringCloud组件、想看看真实项目怎么把它们串起来的朋友。1. 先想清楚校园招标系统到底该拆成多少微服务很多初学者看到SpringCloud微服务就兴奋恨不得把用户表都单独拆一个服务出来。我一个建议是微服务拆分不是越细越好而是要让每个服务有清晰的业务边界团队可以并行开发某个模块压力大了能单独扩容。这套系统业务链路长但并发量不算离谱所以我只拆了六个核心服务每个服务都能独立运行和部署。1.1 业务链路拆解先梳理一遍完整的招标投标竞标流程每个环节对应一个服务这是拆服务最可靠的方法招标人采购员发布招标公告说明采购清单、预算金额、投标截止时间、开标时间这个环节管的是项目与公告信息。供应商注册入库提交营业执照、资质证明审核通过后才能参与投标这个环节管的是供应商档案与资质审核。到了投标截止时间系统自动锁定投标入口供应商在此之前可以提交或撤回标书这段时间窗口必须严格按服务器时间执行。开标后进入评标阶段评标专家从专家库中抽取按规则打分系统自动汇总分数并生成中标候选人排序。定标后发布中标公告进入合同签订与履约阶段有些系统还要联动财务做保证金退还。每个环节对应一个服务拆分后的结构如下服务名核心职责关键数据auth-service登录认证、用户体系、角色权限系统用户、角色、菜单权限、JWT令牌supplier-service供应商注册、资质审核、档案管理供应商表、资质附件、审核记录bidding-service招标项目、公告发布、投标管理招标项目表、标书记录、投标时间窗口evaluation-service专家抽取、评标评分、结果汇总专家库、评分表、评标汇总记录notice-service站内通知、公告推送、消息服务通知模板、消息记录audit-service操作审计、日志留痕、合规追溯审计日志表这样拆完之后业务之间的通信通过OpenFeign远程调用服务注册与发现交给Nacos。六个服务看起来多但实际每个服务内部代码量都不大反而是把边界理清楚之后各模块改起来非常舒坦。1.2 微服务之间怎么通信服务间通信我用的是Spring Cloud OpenFeign这算是SpringCloud生态里的标配。设计上有一个原则必须守住服务之间只通过接口交互绝不能直接共享数据库表。比如evaluation-service要读取招标项目的基本信息不是去查bidding-service的数据库而是调用bidding-service提供的GET /api/bidding/{id}接口。这里有个细节值得说网关统一走/api前缀转发服务内部的Controller也从/api开头网关做路径转发的时候按服务名区分例如/api/bidding/**转发到bidding-service/api/supplier/**转发到supplier-service。这样前端拿到的就是一个统一的API入口不需要关心后端到底有几个服务。服务间调用链路还要考虑一个谁发起、谁授权的身份传递问题。登录用户在auth-service做了认证拿到JWT前端带着Token访问网关。网关解析Token后把用户信息放进请求头Feign调用下游服务时再把当前用户标识透传过去。如果不加这一步下游服务就不知道当前操作者是谁审计日志根本没法写。2. 后端SpringBoot业务服务怎么落地状态机设计是核心这一节直接进代码和设计。招标投标业务最怕的不是接口写得不好而是业务流程状态乱了。我在这套系统里把所有核心业务都改成了状态机驱动的方式代码结构更清晰也方便审计追溯。2.1 从数据库表设计开始以bidding-service为例核心表就两张tender_project招标项目表和bid_record投标记录表。tender_project的关键字段包括id、project_name、budget_amount、status、publish_time、bid_start_time、bid_end_time、open_time。status字段是一个int我用它表示项目的生命周期状态0草稿、1已发布、2投标中、3已截标、4评标中、5已定标、6已归档。为什么不用字符串状态省空间是一方面更重要的是状态流转可以用穷举法直接校验非法跳转直接拦截。我自己写了一个简单的状态流转校验核心逻辑如下private static final MapInteger, SetInteger ALLOWED_TRANSITIONS new HashMap(); static { ALLOWED_TRANSITIONS.put(0, Set.of(1)); // 草稿 - 发布 ALLOWED_TRANSITIONS.put(1, Set.of(2)); // 已发布 - 投标中 ALLOWED_TRANSITIONS.put(2, Set.of(3)); // 投标中 - 已截标 ALLOWED_TRANSITIONS.put(3, Set.of(4)); // 已截标 - 评标中 ALLOWED_TRANSITIONS.put(4, Set.of(5)); // 评标中 - 已定标 ALLOWED_TRANSITIONS.put(5, Set.of(6)); // 已定标 - 已归档 } public void ensureTransitionAllowed(Integer current, Integer target) { SetInteger allowed ALLOWED_TRANSITIONS.get(current); if (allowed null || !allowed.contains(target)) { throw new BizException(非法的状态流转 current - target); } }状态机的好处是业务逻辑不管从哪个入口进来都不可能绕过流程直接改成已定标。曾经有一个需求方说我们线下已经评完标了你直接把项目拖到已定标吧我的回复是系统必须保留完整流转记录线下评标可以补录评标分数但状态不能跳步。在校园这种重审计的场景里这个坚持非常必要。2.2 时间窗口的强制执行招标系统最硬核的规则是时间。开标时间一到投标入口必须关闭哪怕前端页面还在展示投标中后端也必须拒绝新标书。我用Spring的Scheduled定时任务加上数据库时间判断双保险处理Component public class TenderStatusSchedule { Resource private TenderProjectMapper tenderProjectMapper; Scheduled(cron 0 * * * * ?) // 每分钟执行一次 public void closeExpiredTenders() { ListTenderProject list tenderProjectMapper.selectOpeningProjects(); LocalDateTime now LocalDateTime.now(); for (TenderProject project : list) { if (now.isAfter(project.getBidEndTime())) { tenderProjectMapper.updateStatus(project.getId(), 3); } } } }定时任务负责兜底关闭但真正防住极端情况的是投标提交接口里再查一次时间。两个逻辑缺一不可定时任务处理的是状态同步接口校验处理的是并发下的准入控制。还有一点要注意时间一律用后端服务器时间不能信客户端传上来的时间客户端时间是可以改的。2.3 文件上传和附件管理的隐藏逻辑投标过程必然涉及附件招标文件、标书PDF、资质证明扫描件。这套系统里我用了MinIO做对象存储没用FastDFS也不建议新项目再碰FastDFS维护成本确实高。MinIO部署简单兼容S3协议后端集成的代码非常少。Configuration public class MinIOConfig { Bean public MinioClient minioClient() { return MinioClient.builder() .endpoint(http://127.0.0.1:9000) .credentials(admin, admin123456) .build(); } }上传后的文件路径按业务分类存数据库例如tender/2025/12/招标文件.pdf前端展示时通过网关统一走下载接口不直接暴露MinIO地址。这样等真有安全扫描的时候内网文件端口不会满天飞。另外校园系统里经常有需要预览PDF甚至播放视频的需求比如评标会议录屏MinIO配个访问策略就能支持后续扩展也方便。3. SpringCloud基础设施落地方案Nacos、Gateway、鉴权一条链路服务拆完了业务接口也写好了接下来就是把这些服务缝起来。SpringCloud的基础设施组件很多但真实项目里最常用的就是注册中心、配置中心、网关、远程调用这四件套。这套系统选型全部基于SpringCloud Alibaba版本踩过几次坑后面会单独说。3.1 注册中心与配置中心Nacos一肩挑Nacos同时承担了服务注册和配置管理两个角色省了一套组件。服务注册的配置很简单每个服务加依赖后在application.yml里指定服务名和Nacos地址spring: application: name: bidding-service cloud: nacos: discovery: server-addr: 127.0.0.1:8848这里有个实际体会配置中心一定要用上尤其是数据库连接串、MinIO地址、Redis地址这类环境相关的配置。我一开始图省事把数据库配置直接写在每个服务的application.yml里后来部署测试环境时发现六个服务要改六个文件改到怀疑人生。改用Nacos配置中心之后只需要在Nacos里维护一套application-common.yml所有服务共享按环境名区分即可。3.2 网关统一入口与跨域处理所有前端请求都先打到Spring Cloud Gateway由网关做路由转发、Token校验、限流。网关路由配置如下spring: cloud: gateway: routes: - id: auth-route uri: lb://auth-service predicates: - Path/api/auth/** - id: bidding-route uri: lb://bidding-service predicates: - Path/api/bidding/** - id: supplier-route uri: lb://supplier-service predicates: - Path/api/supplier/**跨域问题在微服务架构下特别容易踩坑。因为前端和后端分离Vue开发服务器默认跑在localhost:5173网关跑在8080浏览器会拦截非同源请求。解决方案是在网关统一配置CORS不要在业务服务里单独配否则重复配置反而会出现奇怪的跨域报错。网关里还统一做了一件事登录白名单校验。/api/auth/login、/api/auth/captcha这些接口放行其余接口全部校验JWT。网关校验通过后再把用户ID和角色信息放进请求头转发给下游服务。下游服务写审计日志的时候直接RequestHeader(X-User-Id)取不用自己去解析Token逻辑非常清爽。3.3 服务间调用的身份透传网关把Token校验完了OpenFeign调用下游服务时却不能把原来的Header转发过去。默认情况下Feign只带了很少的请求头需要写一个RequestInterceptor把请求头里的用户信息透传下去Component public class FeignAuthInterceptor implements RequestInterceptor { Override public void apply(RequestTemplate template) { ServletRequestAttributes attrs (ServletRequestAttributes) RequestContextHolder.getRequestAttributes(); if (attrs ! null) { HttpServletRequest request attrs.getRequest(); template.header(X-User-Id, request.getHeader(X-User-Id)); template.header(X-User-Name, request.getHeader(X-User-Name)); template.header(X-User-Roles, request.getHeader(X-User-Roles)); } } }这个拦截器最大的坑是异步线程里取不到RequestContextHolder的上下文因为线程切换导致ServletRequestAttributes为null。解决办法是用HystrixRequestContext或者直接通过显式参数传递用户信息我后来统一改成从请求头拿拿不到就置空不强行让拦截器抛异常。遇到需要异步处理的操作比如发送站内信就把用户ID作为业务参数传进去不让它依赖调用链上下文。4. 分布式环境下的三个硬骨头一致性、锁与审计追溯微服务架构里最让人头疼的不是接口开发而是原来单体环境里天然保证的一致性、并发安全、操作留痕分布式之后全都变得麻烦了。这套系统里我处理了三个典型问题单独拿出来分享。4.1 并发投标与Redis分布式锁的正确姿势投标这个动作很特殊一个供应商针对同一个标段只能投一次标多次提交会覆盖还是拒绝业务规则是同一个标段同一供应商只允许存在一条有效标书重复提交直接报错。单体环境里用数据库唯一索引就能解决但微服务架构下bidding-service可能多实例部署单纯靠数据库唯一索引容易在高并发下死锁或者等到数据库层报错时才察觉。我用的是Redis分布式锁Key设计为tender:bid:lock:{projectId}:{supplierId}服务器端对同一个供应商和标段加锁锁住的是校验是否已投标 插入标书记录这个完整操作。加锁代码用Redisson客户端直接写RLock lock redissonClient.getLock(tender:bid:lock: projectId : supplierId); boolean locked lock.tryLock(3, 30, TimeUnit.SECONDS); if (!locked) { throw new BizException(系统繁忙请稍后重试); } try { // 检查重复投标 - 插入标书记录 - 记录附件信息 } finally { lock.unlock(); }锁的粒度是关键。有的同学习惯锁整个标段比如Key只写tender:bid:lock:{projectId}这就意味着所有供应商投同一个标段时都会被串行化虽然也能保证数据不出错但性能浪费很严重。按projectId supplierId做Key锁粒度最小并发能力理论上不受影响。这个设计思路在处理热点资源时非常有价值。4.2 分布式事务保证金记录与投标状态的一致性问题投标过程中如果有保证金环节就涉及跨服务写数据bidding-service写入标书记录supplier-service冻结保证金。两个写操作分属不同库不能用本地事务解决。我把这个场景设计成先写业务数据再发送冻结保证金的可靠消息supplier-service消费消息后执行冻结。这里用的是异步最终一致而不是Seata强一致事务。为什么不用Seata因为保证金冻结涉及资金操作强一致事务会让第三方接口的调用时长被拖进全局事务里要么占用锁太久要么事务回滚时第三方那边无法回滚。最终一致的方案更适合长时间跨系统操作。选型的逻辑是如果两个写操作都在自己掌控的数据库里比如评标分数汇总和评标状态更新用Seata的AT模式没问题。但一旦涉及第三方系统支付网关、外部财务系统就老老实实用本地消息表加定时任务补偿别硬套强一致事务。4.3 审计日志用AOP统一记录操作流水校园招标是重审计场景核心操作必须能追溯到谁在什么时间对什么数据做了什么操作。我在audit-service里提供了统一的日志采集接口其他服务通过一个自定义注解AuditLog配合AOP切面完成操作记录。Target(ElementType.METHOD) Retention(RetentionPolicy.RUNTIME) public interface AuditLog { String module() default ; String action() default ; }切面里先记录方法入参、操作人、操作时间、IP地址方法执行完成后记录返回结果和是否成功。异常时记录异常信息接口响应时间也一并存上。这些日志落库后管理员可以在后端的审计管理页面按时间、操作人、模块维度检索。在设计上必须注意审计日志的写入不能影响主业务的性能所以我用线程池异步上报失败时打成WARN日志宁可审计数据延迟也不能让用户感觉到卡顿。评审系统里还有一个防串通需求供应商名单在开标前对评标专家隐藏专家打分的路由层面就要做权限隔离。这部分我用角色数据权限处理评标专家登录后只能看到分配给他的待评标项目不能看到投标名单管理员能看到全部视图但看不到专家打分详情。多角色数据隔离在校园系统里非常敏感写代码时一定要谨慎再谨慎。5. Vue前端与微服务对接动态路由、权限按钮与视频预览后端再完善最终面对业务方的是前端页面。Vue这边我用了Vue3 Vite Element Plus Pinia的组合。这套组合现在生态很成熟尤其Element Plus组件库做后台管理类系统效率非常高。5.1 登录与动态路由的设计前端登录后拿着JWT请求/api/auth/userinfo拿到用户信息和角色列表。然后一个关键动作是根据角色动态生成路由而不是在路由表里写死所有菜单。比如供应商登录后只显示投标相关菜单评标专家登录后只显示待评标任务管理员显示全部。动态路由实现的核心思路是路由表分成基础路由登录页、404页和业务路由需要权限的页面登录后通过router.addRoute()把有权限的路由逐条加进去。菜单也根据路由表的meta.title和meta.icon动态渲染避免菜单和路由两套配置不同步的问题。实现完之后有一个必须注意的点刷新页面时Vuex/Pinia里的用户信息和动态路由会丢失所以要在路由守卫里做刷新后重新拉取用户信息并重建路由的幂等逻辑。路由模式建议直接用hash模式。虽然有hash模式的URL会带个#不太美观但它有个巨大的优势后端不需要做未知路径的fallback重写开发部署都省心。尤其是后文要讲的Vue构建后放进SpringBoot部署hash模式直接兼容而history模式在SpringBoot里必须做转发配置。5.2 按钮级权限控制这套系统里的权限已经细到了按钮级别比如撤回标书按钮只有标书状态是已提交时才显示审核通过按钮只有管理员才有权限。后端接口肯定做了权限校验但前端按钮也得控制下来否则业务方看到不可用的按钮会觉得系统出Bug了。前端用自定义指令v-permission实现app.directive(permission, { mounted(el, binding) { const requiredPerms binding.value; const userPerms store.getters.permissions; if (!requiredPerms.some(p userPerms.includes(p))) { el.parentNode el.parentNode.removeChild(el); } } });模板里这样用el-button v-ifproject.status 2 typewarning clickonCloseTender截标/el-button el-button v-permission[supplier:bid:cancel] clickonCancelBid撤回标书/el-button按钮级权限做下来之后最大的感受是前端代码必须和后端接口文档里的权限点完全对齐否则就会出现页面有按钮、接口403或者接口能调通、页面没入口的混乱情况。我在项目里维护了一份权限对照表每个权限点标注前端按钮位置、后端接口路径、角色清单前后端都按这张表开发沟通成本直线下降。5.3 富文本公告与视频预览招标公告内容长、格式多前端用了富文本编辑器wangEditor后端用「HTML字符过滤 敏感词库过滤」双重清洗。这里有个安全细节很多项目会漏富文本允许自定义HTML如果不过滤就存库攻击者可以把script标签直接塞进公告里所有看公告的用户浏览器都会执行恶意脚本。所以服务端必须用白名单方式清洗HTML标签和属性只保留安全的排版标签再对文本内容过敏感词。评标会议录像或演示视频文件存在MinIOVue播放视频文件时我封装了一个简易播放器支持MP4直接播放如果是m3u8流媒体地址需要在前端引入hls.js库解析再喂给video标签这样不需要额外安装浏览器插件。校园网环境特殊有些大文件不适合全部做流媒体折中方案是小视频直接MP4播放大视频切片成HLS流前端统一走hls.js播放器。6. 本地联调、部署测试和那些绕不开的坑最后这一部分全是实操经验。微服务项目本地跑起来和单体完全不同六个服务要在IDE里同时启动端口、环境配置、依赖顺序都得弄好。我把自己反复踩过的坑集中说一下。6.1 六个服务本地怎么跑端口如何规划本地开发时我会在IDEA的Run Dashboard里同时启动六个服务端口规划必须固定下来否则前端代理配置和Nacos注册信息都会乱服务名端口auth-service8101supplier-service8102bidding-service8103evaluation-service8104notice-service8105audit-service8106gateway8080nacos8848redis6379minio9000一个被我反复强调的本地配置细节IDEA里给每个服务配置启动环境时要写清楚spring.profiles.activedev然后dev环境配置统一读取Nacos上的公共配置不要再在本地维护一套数据库连接。否则每台开发机都要同步配置文件新来的同事光连数据库就折腾一下午。6.2 SpringBoot与SpringCloud版本匹配的坑这套项目开搞时SpringBoot 3.x已经很普及了但SpringBoot 3对应SpringCloud版本要求非常高不小心就会启动直接报错。我自己记录了一组完全能用的版本组合组件名版本JDK17SpringBoot2.7.18SpringCloud2021.0.8SpringCloud Alibaba2021.0.5.0Nacos Server2.3.2这套组合是比较成熟的稳定搭配推荐新手直接跟着来。如果你偏要上SpringBoot 3.x必须确认Nacos客户端、Seata客户端、Sentinel等所有第三方组件都支持Jakarta命名空间否则启动时各种ClassNotFound。我没有否认新版好但校园类项目的开发周期有限优先用成熟组合减少排查基础组件兼容性问题的时间。6.3 Vue打包后放进SpringBoot部署这套系统的部署方式我选择了一个天然适配传统校园机房的方案前端构建后放入SpringBoot的src/main/resources/static目录作为一个单体Jar包直接跑。虽然微服务架构但交付给校园信息中心时他们更习惯一个Jar一个服务的运维方式不喜欢我教他们配Nginx。具体操作是在前端项目执行npm run build把生成的dist目录内容复制到网关服务的静态资源目录路由模式必须是hash模式因为history模式刷新页面时SpringBoot不知道如何匹配前端路由。如果要用history模式就加一个Controller做页面转发RequestMapping(value /{path:[^\\.]*}) public String forward() { return forward:/index.html; }只有/api开头的请求走网关转发其他路径全部交给前端路由。这种部署方式最适合没条件也没人愿意维护Nginx的校园环境。6.4 上线前我做的几项验证最近一个版本上线前我列了一个自测清单贴在项目群里要求前后端都照着过一遍专门挑几个容易出问题的场景写出来并发压测用JMeter模拟20个供应商同时投同一个标段看是否有重复投标数据。重点验证Redis分布式锁和数据库唯一索引双保险是不是同时生效。时间边界测试把投标截止时间改成两分钟后分别在前端反复刷新页面、直接调后端接口提交标书确认超过时间后接口一定返回已截止。权限交叉测试分别用管理员、供应商、评审专家三个账号登录同一页面确认菜单、按钮、数据范围都符合角色设定尤其是评标专家必须看不到供应商身份信息。审计日志抽查随机对一条投标记录做状态变更再到审计管理页面查该记录的操作流水确认操作人和时间点完整。这些验证都通过了才敢交给采购办公室试运行。校园场景的特殊性在于所有操作都可能被翻出来复核审计链路的完整性比功能花哨重要得多。最后再分享一个小技巧给做同类系统的人这套系统的核心流程代码我用了一个非常朴素的原则——每个状态流转方法都只做三件事校验前置条件、执行业务动作、推进状态。不要在一个方法里写超过30行核心逻辑超过就拆分。微服务项目最大的维护成本不在写代码而在几个月后改代码的时候还能快速定位到某个逻辑在哪个服务、哪个方法里。边界清晰、状态可追溯、权限有控制做到了这三点这套系统不管怎么演进都不会乱。