去年接了个让我印象挺深的活儿:给一所职业技术院校做校园物资招标竞标系统。当时他们把厚厚一沓纸质报价单和Excel表搬到我面前,说想要一个能在线发公告、在线投标、自动评标排名的系统。需求听起来不复杂,但真正动手之后才发现,里面全是订单并发、时间窗控制、上传安全这些坑。
用Java + Spring Boot + Vue把这套系统完整跑通之后,我一度想把它写成篇硬核复盘。因为这类系统不光是毕业设计的热门选题,也是很多中小型单位内部采购数字化的刚需。今天这篇就当作我在社区里的个人检修纪要,把从数据建模到前端鉴权、再到打包部署遇到的细节和踩过的坑,一次讲清楚。
1. 校园物资招标的原有痛点,以及我为什么最终锁定了Spring Boot + Vue
1.1 纸质流程带来的管理死角
校园里的物资招标,往往不是一次几十万的大工程,而是教学耗材、实训设备、办公用品、体育器材这一类小额采购。以前常见的做法是:采购部门在公告栏贴一张打印出来的《询价公告》,几家公司看到之后把报价单通过快递或者邮件发过来,截止时间到了再由几个人拆开信封手工记录。
这套流程最大的问题不是“慢”,而是缺少留痕和校验。快递有没有按时到?报价单上的盖章是不是有效?同一家公司是不是拆成多个信封重复报价?这些问题全靠人工盯,盯一次两次还行,一年几十场招标下来,纯粹是赌运气。而一旦出现争议,复盘的时候连一份完整的电子材料都拿不出来。
我做系统之前先去和他们开过一次需求会,最核心的一句话是:“我们要的是每个环节都有记录,截止时间踩死为止,报价表不许有人改”。这句话基本就定义了系统的边界:公告管理、在线投标、截止时间自动封标、评标结果留痕。
1.2 技术选型:为什么不是PHP、不是Flask,而是Spring Boot + Vue
需求摆在那,技术选型其实不是玄学。我当时给的理由非常直接:
- 学校机房的服务器是Windows Server,没有复杂运维,需要打一个Jar包就能跑。
- 维护系统的可能是后来的老师或学生,Java生态的招聘和博客资料最多,接手成本最低。
- 招标系统会被反复改流程、加字段,Vue的组件化思路比传统模板渲染改起来舒服太多。
Spring Boot选的是2.7.x版本,配Java 8。为什么不用Spring Boot 3.0?这个问题我在项目里纠结过,Spring Boot 3要求JDK 17,校园服务器上的Java环境并不一定跟得上,而且很多老依赖在Spring Boot 3下还没完全踩平坑。为了求稳,我选了Spring Boot 2.7.x + JDK 8这套已经被验证过无数次的组合。Maven做项目构建,依赖管理干净利落,这也是绝大多数Spring Boot项目的主流姿势。
Vue这边我毫不犹豫选了Vue 2 + Element UI。我知道现在一堆人在等Vue 3的组件库生态,但在实际交付场景里,Element UI的成熟度和轮子数量仍然是最稳的。如果你是自己做毕业设计,只要不是冲着Vue 3新特性去,Vue 2这套组合依然能帮你用最短时间搭出后台管理系统。
提示:技术选型首先是约束条件下的妥协,不是最新的就是对的。Spring Boot 2.7.x + JDK 8 + Vue 2 + Element UI的组合,能让系统在低配服务器上稳定跑起来,这就是最大的优势。
2. 数据建模思路:从纸质单据拆成核心表,其实就靠一张状态流转图
2.1 权限角色设计:供应商不应该和评审老师坐同一张表
开始建表之前,我先把使用对象梳理成了四类角色:
| 角色 | 权限边界 |
|---|---|
| 管理员(采购办) | 发布招标公告、管理供应商名单、审核投标、发起评审、定标 |
| 供应商(投标企业) | 查看公告、在线投标、修改未截止前的投标、查看自己的中标结果 |
| 评审专家 | 只能看到分配给自己的标书、打分、写评语 |
| 普通师生(围观角色) | 浏览公告和中标结果,不能进投标管理界面 |
很多类似系统喜欢把角色塞进一个枚举字段里,简单是简单,但后面涉及接口鉴权和数据隔离时会很难受。我选择了role字段配合菜单权限校验,后端在Controller上方用自定义注解控制访问,前端再用路由守卫兜一层。两层权限叠加之后,普通用户即使故意拼接API路径也拿不到供应商的投标数据。
用户表里加了个is_approved字段专门管理供应商审核状态。新注册的企业不能立刻投标,要等管理员在后台审核营业执照后才激活账号,这个动作能过滤掉不少来捣乱的账号。
2.2 招标单的状态机:从草稿到废标,每一步都要卡死
物资招标最怕的事情是什么?是投标截止之后还有人通过后台改数据。所以招标单tender表里我加了一个status字段,并且完全按照状态机推进:
- 0 草稿:管理员填了基本信息,还没发布,谁都看不见。
- 1 公告中:发布时间到了,前端展示公告,但投标功能还没打开。
- 2 投标中:投标开始时间到了,供应商可以上传报价。
- 3 评审中:投标截止,系统自动锁定所有投标记录,进入评审打分环节。
- 4 已定标:管理员/评审组选定了中标供应商。
- 5 已废标:因为投标人数不足三家、预算超支等原因宣布流标。
状态之间不允许多级跳转,不允许从“投标中”直接到“已定标”,必须经过“评审中”。这个约束不是靠脑子记,是在后端代码里写了一个状态转换校验方法,任何非法的status变更直接抛业务异常。
招标单的核心字段大致是这样设计的:
CREATE TABLE tender ( id BIGINT PRIMARY KEY AUTO_INCREMENT, title VARCHAR(200) NOT NULL, category VARCHAR(100), budget DECIMAL(12,2), supplier_type VARCHAR(50), announce_time DATETIME, bid_start_time DATETIME, bid_end_time DATETIME, evaluate_start_time DATETIME, status TINYINT DEFAULT 0, winner_supplier_id BIGINT, created_by BIGINT, created_time DATETIME DEFAULT NOW(), updated_time DATETIME DEFAULT NOW() );预算字段一定要用DECIMAL,不用FLOAT和DOUBLE。关于金额精度的问题,Java面试题里经常考,但实际开发里翻车的例子更多——两个浮点数相减可能得到0.30000000000000004,这在报价对比场景里是不可接受的。
2.3 投标记录表:唯一索引就是防重复投标的第一道防线
投标记录bids表我花了最多心思,也是整张表设计的重点:
CREATE TABLE bid ( id BIGINT PRIMARY KEY AUTO_INCREMENT, tender_id BIGINT NOT NULL, supplier_id BIGINT NOT NULL, bid_amount DECIMAL(12,2) NOT NULL, bid_content TEXT, attachment_url VARCHAR(500), file_hash VARCHAR(128), status TINYINT DEFAULT 0, created_time DATETIME DEFAULT NOW(), UNIQUE KEY uk_tender_supplier (tender_id, supplier_id) );UNIQUE KEY (tender_id, supplier_id)这一行至关重要。数据库层面的唯一约束能在高并发下挡住“同一家供应商对同一个标投两次”的情况。你可能会说前端按钮我早就disabled了,但后端接口如果被脚本直接调用,前端拦不拦根本没有意义。真正能兜底的只有数据库约束和事务控制。
另外我还存了一个file_hash字段,是标书文件的SHA-256值。这不是为了做加密,而是为了防止供应商拿同一个文件换个名字重复投递,评审阶段比对哈希就能快速识别疑似重复标书。
投标记录不做“修改后旧记录覆盖”的操作,而是把修改动作拆成“撤回旧投标 + 新增新投标”两条业务日志。这样每个历史版本都可追溯,万一出现纠纷,拉出操作日志就能还原场景。
3. 后端落地细节:Spring Boot里的时间窗、并发控制和事务边界
3.1 用枚举和Service层把状态流转变成强约束
后端我是按Controller -> Service -> Mapper的分层结构去写的,Entity由MyBatis-Plus自动映射。这块我想认真强调Service层的入参校验逻辑,因为它决定了系统的严谨度。
我定义了一个TenderStatusEnum,包含了上一小节说的五种状态。Service里有个核心方法叫changeStatus:
public void changeStatus(Long tenderId, TenderStatusEnum from, TenderStatusEnum to) { Tender tender = tenderMapper.selectById(tenderId); if (!from.getCode().equals(tender.getStatus())) { throw new BusinessException("当前状态不允许该操作"); } tender.setStatus(to.getCode()); tenderMapper.updateById(tender); }有人会说,直接用update ... where status=?不就行了?对,那条SQL确实能并发兜底,但业务层面我希望错误提示更友好,所以选择了“先查再比再改”的常规写法。为了堵住并发漏洞,最后还加了一条乐观锁:更新时用UPDATE语句,条件里带原来的status值,更新条数为0就表示状态已经被别人改了,直接抛异常。
这套组合拳下来,管理员再怎么快速双击“定标”按钮,也只会执行一次真正有效的状态变更。
3.2 投标截止时间前的并发冲刺
线下开标节选的时候,经常有供应商在最后一分钟踩着点交材料。线上系统也一样,最后几分钟的并发请求反而是最高的。我当时给投标接口做过一个压测,在没有任何保护的情况下,同一秒塞进来二十个投标请求,至少有四五个会出现重复投递或数据覆盖。
解决思路有三层:
第一层是数据库唯一索引,前面已经说了,这是物理层面的最后防线。
第二层是Service层的事务注解。投标这个方法我用了Spring的@Transactional(rollbackFor = Exception.class),同时在这个事务内部先做了“检查投标时间窗”的逻辑。注意,检查时间窗这一步不能放在事务外面,否则并发下隔离级别不一致,会出现“事务A查完时间还没结束,事务B提交后,事务A又插入了一条迟到投标”这种怪异现象。
第三层是行级锁。对于“同一标段并发频繁”的场景,我先执行一条:
SELECT id FROM tender WHERE id = #{tenderId} FOR UPDATE;这条语句会把对应招标单的记录锁住,后面的并发票必须排队等前一个事务提交。对单表操作来说,这个粒度足够,也不需要引入Redisson分布式锁。如果以后系统要拓展成多实例部署,再把锁迁移到Redis去,但那是后话。
3.3 定时任务自动处理截止和开标
状态机里有一件事不能完全靠人工:投标截止时间到了之后,系统要把所有未提交的投标锁住,并把招标单状态从“投标中”推进到“评审中”。这个动作不能等管理员手动点,不然又等于给了人工干预的空间。
我用Spring自带的@Scheduled做了一个每分钟扫描一次的定时任务:
@Scheduled(cron = "0 * * * * ?") public void autoCloseTenders() { // 扫描 status=2 且 bid_end_time <= now 的招标单 List<Tender> tenders = tenderMapper.selectAutoCloseList(new Date()); for (Tender tender : tenders) { tender.setStatus(3); tenderMapper.updateById(tender); // 生成评审小组通知记录 notifyService.createEvaluateNotification(tender.getId()); } }别小看这个Job,它最大的价值是“把时间变成机器判断,而不是人判断”。只要服务器时间准确,截止时间一到,投标操作就自然失效。
提示:定时任务的服务器时间一定要校准。后面我会专门提到时区问题,这里先记住——生产环境里所有时间参数务必统一使用DateTime类型,避免DATE只存日期不带时分秒导致状态切换提前或延后。
4. 前端Vue实践:管理员端、供应商端和动态路由权限
4.1 Vue Router动态路由到底解决了什么问题
前端我用了Vue Router 3.x,并配合Vuex(项目里也可以换成Pinia,但Vue 2配Vuex是最成熟的选择)存储用户登录信息。
在做路由配置时,我的方案不是一次性把所有路由全部注册,而是根据用户角色动态生成路由表。登录成功之后,后端接口返回当前用户的角色和菜单权限,前端遍历权限列表后调用router.addRoutes去动态挂载对应模块的路由。
这有什么好处?最直接的好处是:一个供应商账号即使手动在地址栏输入/admin/dashboard,路由也匹配不到对应组件,页面会直接落到404,而不是把后台界面暴露出来。路由层面的隔离虽然不如后端接口鉴权那么硬核,但至少挡住了90%的“好奇操作”。
核心实现思路如下:
const baseRoutes = [ { path: '/login', component: Login }, { path: '/register', component: Register }, { path: '/', redirect: '/home' } ]; const adminRoutes = [ { path: '/admin/tender-list', component: AdminTenderList, meta: { roles: ['ADMIN'] } } ]; const supplierRoutes = [ { path: '/supplier/bid-form/:id', component: SupplierBidForm, meta: { roles: ['SUPPLIER'] } } ];菜单渲染也是基于这个角色路由表,管理员进入后台只看到招标管理、供应商审核、评审管理;供应商进入前台只看到公告列表、我的投标、中标结果。
4.2 投标倒计时:轮询还是WebSocket?
面对“投标截止倒计时”这种展示,通常有两种方案:WebSocket实时推送,或者setInterval定时轮询。
我选了后者。原因很实际:校园网络环境不稳定,WebSocket长连接经常断线重连,重连逻辑一旦写得不好,反而给人“系统卡了五分钟”的错觉。而轮询哪怕断一次也能在下一次轮询时把数据拉回来,体验反而稳定。
我在投标页面写了一个倒计时组件,每隔1秒拉取服务器时间并计算剩余毫秒数。注意这里有个小坑:不要用前端本地时间直接减掉后端截止时间,因为用户电脑的系统时间可能是错的,必须用服务器返回的时间戳做基准。
this.timer = setInterval(() => { const remain = this.serverDeadline - Date.now(); if (remain <= 0) { this.handleTimeout(); clearInterval(this.timer); } else { this.remainText = formatRemain(remain); } }, 1000);组件销毁时记得清理定时器,在beforeDestroy钩子里调用clearInterval。这个细节我写进项目复盘时标红了,因为有个同事的页面跳转了定时器还在跑,最后把服务器负载活活抬高了5%。
4.3 标书上传的交互设计
标书上传我拆成了“选文件 -> 本地校验 -> 传MinIO -> 回显数据库地址”四步。Vue端用Element UI的Upload组件配合自定义上传方法,而不是直接把文件post到业务后端,目的是减少后端Tomcat连接被大文件占用的时间。
前端大致逻辑是这样的:先把文件的md5算出来,然后向后端申请一个上传凭证,拿到预签名URL后用PUT方式直接传文件到MinIO。这样业务服务器只负责生成凭证、记录元数据,完全不参与文件字节流的搬运,大文件并发上传时后端压力会小很多。
5. 文件存储方案:MinIO在Spring Boot项目里的落地方式
5.1 为什么没有把文件存在服务器本地
之前有人问我,你怎么不把文件直接存在项目目录的upload文件夹里?我反问了一句:你把文件存在服务器本地,以后服务器硬盘坏了怎么办?采购资料、投标文件、定标附件这些东西都是要留档几年的,总不能靠定期手动备份。
我选择了MinIO,理由可以列得很清楚:
- 部署简单,一个docker命令就能跑起来,适合校园机房的轻量环境。
- 兼容S3协议,以后想迁到阿里云OSS或腾讯云COS,只需要换客户端配置,代码层面改动很小。
- 自带Web管理界面,管理员可以直观看到桶里的文件对象,排查问题方便。
5.2 预签名上传:业务服务器不沾文件流
用MinIO时最常见的错误写法是:上传接口收到MultipartFile,然后调用minioClient.putObject,把文件流转发到MinIO。这种方式会把业务服务器和存储服务器串行耦在一起,文件一多久拖垮接口。
正确做法是用预签名URL。流程是这样的:
- 前端先调后端接口,带上文件名、文件大小、所属业务单号。
- 后端校验权限、校验文件后缀名和大小,然后生成一个有效期五分钟的PUT预签名URL。
- 前端用这个URL直接上传文件到MinIO。
- 上传完成后,前端再调用后端接口,把文件对象ID和业务单据关联起来。
后端生成凭证的核心代码:
String presignedObjectUrl = minioClient.getPresignedObjectUrl( GetPresignedObjectUrlArgs.builder() .method(Method.PUT) .bucket(bucketName) .object(objectKey) .expiry(300) .build() );整个过程业务服务器只做了“发凭证”和“登记元数据”两件事,文件流走了MinIO自己的通道,并发上传时业务服务器一点不堵。
5.3 附件和业务单据的关联策略
文件上传成功之后,不能只把URL扔进业务表的attachment_url字段里,这样后续做权限控制会很憋屈。我建了一张附件表file_record,字段包括:业务类型(tender/bid/award)、业务ID、文件名、存储路径、上传人、文件MD5、上传时间。
后端在下载附件时,先确认当前登录用户对该业务ID是否有查看权限:比如供应商只能下载自己的标书,管理员可以下载全部标书,评审专家只能下载被分配给自己的标书。这样就算有人拿到了附件的URL,没权限也调不通下载接口。行级权限在这个场景里体现得很充分。
6. 部署、安全和踩坑记录汇总
6.1 前后端合并打成单Jar包部署,省掉跨域麻烦
这套系统最终交付时的部署方式让我比较满意:把Vue执行npm run build之后生成的dist目录,整个拷贝到Spring Boot的src/main/resources/static下,重新打包成一个可执行Jar。
这样做的好处不用多讲:
- 部署时只需要一个Jar包和一个MinIO服务,不需要单独部署Nginx,也不用配前后端分离的CORS跨域。
- 接口地址和静态资源地址天然同源,前端请求不需要关心BASE_URL切换问题。
- 对学校机房这种运维水平参差不齐的环境极其友好,双击就能启动。
Jar包启动时记得指定当前路径,不然static静态资源可能读取不到。
6.2 时区问题:一次差点让投标提前两小时的乌龙
有一次,测试同事半路喊我,说为什么投标状态提前两个小时变成了“已截止”?我当时脑子嗡了一下,第一反应就是定时任务是不是写坏了。
排查半天发现,问题出在服务器MySQL连接的时区参数上。数据库里用的DATETIME存储正常,但连接字符串里没有加serverTimezone=Asia/Shanghai,JDBC驱动读出来时把时间当成了UTC,往前拨了8个小时。结果就是系统中显示投标截止时间是下午四点,实际数据库判断的截止时间却是下午两点。
从那之后,我在每个JDBC连接串里都固定写上了serverTimezone=Asia/Shanghai,同时在Spring Boot配置文件的jackson时区也统一设置成GMT+8:
spring: jackson: time-zone: GMT+8时间类问题一定要在项目初始化阶段就全局统一处理,拖到上线前再来排查,光日志能让你翻到怀疑人生。
6.3 并发投标时遇到的重复提交问题
上线之后第一次实训采购,就出现了两个不同供应商,同一秒内提交了投标请求,结果只有一条记录成功,另一条提示“投标失败,请重试”。
这个结果虽然符合预期,但体验不够好。问题在于我完全依靠数据库唯一约束来兜底,用户拿到的报错太粗糙。后来我做了两处优化:
第一,在Service层插入投标前增加一次校验,查询当前供应商是否已有投标记录,有就直接返回友好提示“您已投标,如需修改请先撤回”。
第二,把唯一的key冲突异常单独catch住,翻译成“不能重复投标”的业务提示。
双层校验虽仍然不是分布式百百锁,但对付校园场景的并发量级绰绰有余。这里也想提一句,Java面试里喜欢问“怎么保证数据一致性” ,在这个项目里最标准的回答就是:前端按钮置灰 + 后端幂等校验 + 数据库唯一约束。三层全上,几乎不可能出现脏数据。
6.4 安全漏洞自查:普通用户绕过菜单访问接口怎么办
只做前端路由权限只是面子工程。有一次我自测时发现,只要你登录了普通师生账号,手动拼一个/api/supplier/bid/create接口,居然能打穿到新增投标逻辑。这个漏洞当时吓出了我一身冷汗。
修复方案是后端拦截器。我写了一个LoginAuthInterceptor,在preHandle里统一检查请求路径和登录态,再用HandlerMethod上的自定义注解来判断该接口允许哪些角色访问:
public class RoleInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { // 白名单接口直接放行,例如登录、注册、公告列表 // 检查登录token,校验角色 } }在Controller的敏感接口上加上@RequireRole("ADMIN")或@RequireRole("SUPPLIER"),同时管理端接口统一加上/admin前缀,前端普通用户就算猜出了接口路径,也会因为角色不对而被拦下来。
6.5 Spring Boot版本过高和依赖冲突的教训
项目初建时,我一度用上了当时最新版的Spring Boot 2.7.9,结果引入某个第三方依赖时出现冲突,反复排查后发现是传递依赖版本不对。后来干脆在pom.xml里锁定了一组自己验证过的版本组合,并加上dependencyManagement统一管理。Maven构建时加skipTests,构建速度也能快一些。
如果你也想做类似项目,我的建议是:用Spring Initializr生成基础工程,选择Spring Boot 2.7.x + MyBatis-Plus 3.5.x + Hutool,这几个版本的组合我在多个项目里都验证过,构建和运行都比较顺畅。不要盲目追新,稳定压倒一切。
7. 这套系统后续可以怎么扩展,以及我给自己留的优化清单
7.1 从“能用”到“好用”的几个方向
目前的系统已经闭环了招标、投标、评审、定标的主流程,但真要拿到更大范围去用,还能继续补几个模块。
比如消息通知。现在供应商要自己每天盯公告列表,很容易漏掉重要标讯。可以加一个站内信 + 邮件模板,当招标公告发布、截止前24小时、定标结果公布时自动推送,体验立刻提升一个档次。
再比如电子签章。现在定标结果只是系统里的一条记录,如果需要生成正式的成交通知书,可以对接电子签章服务,自动把中标通知书盖章并归档成PDF。这个扩展在高校采购审计场景里是硬需求。
另外是预算执行统计。校园物资采购通常有年初预算盘子,系统里可以增加一个采购计划表,把每个项目的预算和实际中标金额关联起来,形成简单的执行率报表。这个功能做出来,你就能从“做系统的人”升级成“懂业务的人”。
7.2 给正在做类似毕设或项目的人三点实在建议
第一,先把状态机画清楚再动手写代码。招标、投标、评审、定标、废标这五个状态之间的流转关系,决定了你的数据库设计和接口设计。状态机没想明白,后面改起来都是牵一发动全身。
第二,涉及钱和时间的字段,数据库层面一定要有兜底约束。预算用DECIMAL,时间用DATETIME,投标人数不足三家的自动废标逻辑不要放在前端判断。
第三,接口鉴权和数据隔离一定要在第一个Controller出来的时候就顺手做掉,不要拖到最后统一补。我这次就是前期偷了懒,后期补拦截器时发现一堆接口要来回调整,反而浪费了更多时间。
最后说个实际项目里的小体会:这种校园内部的招标竞标系统,业务量并不高,真正的价值在于“流程严谨”和“数据可审计”。技术难点不在某个高深算法,而在事务边界、时间边界、权限边界这三个边界的控制。把这几个边界控制好,系统的信任感就建立起来了。MiniO的接入、Vue打包进Spring Boot、动态路由鉴权这些内容,都只是实现边界的工具,边界本身才是核心竞争力。