SpringBoot在线招标系统设计与实现:状态机、权限与核心模块拆解

发布时间:2026/10/10 4:16:33
SpringBoot在线招标系统设计与实现:状态机、权限与核心模块拆解
1. 这类系统真正要解决的是哪几件事如果你去翻各类毕设选题库或者接外包的需求文档会发现“基于SpringBoot的在线招标系统”几乎是长年霸榜的经典选题。但同样一个题目有人做出来是个能跑的学生管理系统换皮有人做出来是能直接拿去投标评审的完整业务系统差距往往不在技术栈而在对业务本质的理解。我先说一个容易被低估的事实在线招标系统本质不是“招标信息的增删改查”而是一套围绕“项目状态流转”构建的多角色协同工作流。招标方发标、供应商报名、资格审查、专家评标、中标公告每一步都由不同角色触发每一步都改变项目状态每一步又会约束下一步能做什么。如果只把数据库表和CRUD接口平铺出来系统做到一半就一定会乱。这篇文章我会从业务建模、数据表设计、权限边界、核心接口实现到部署交付的完整链路把一套可落地的SpringBoot在线招标系统拆开讲清楚。内容主要面向三类读者正在做相关毕设的在校生、想用SpringBoot快速搭建业务后端的开发者、以及需要给客户交付“能演示且能说清逻辑”的项目的同学。文章里的所有代码和设计思路都来自我实际带过的模拟项目X代码命名和业务字段做了通用化处理但整体结构和核心逻辑是经过验证的。先说结论把这个系统做扎实核心就四件事——状态机设计、角色权限、投标文件管理、评标打分流程。把这四件事的任何一个做到位系统就已经超过市面上大半的“课程设计水准”。下面逐个拆开讲。2. 技术选型SpringBoot生态里我为什么定这套组合2.1 后端只选SpringBoot但版本和依赖有讲究在线招标系统这类业务系统用SpringBoot是没什么悬念的。关键不在于“用不用SpringBoot”而在于几个配套选型怎么定。我常用的组合是SpringBoot 2.7.x MyBatis-Plus MySQL 8.0 Redis可选 Vue 3 Element Plus。SpringBoot 2.7.x 为什么不用3.x坦白说3.x 已经成熟但很多学校机房、客户服务器上装的JDK还是1.8而SpringBoot 3.x强制要求JDK 17及以上。如果是毕设答辩现场临时换电脑演示JDK版本兼容性是实实在在的坑。选2.7.x配合JDK 1.8部署到哪里都不慌。MyBatis-Plus相比原生MyBatis最大的价值是内置的IService和BaseMapper单表CRUD直接少写60%样板代码。招标系统的数据表虽然多但没有特别复杂的多表关联SQL——真正复杂的业务逻辑都在状态流转和权限校验里而不是在SQL里。这正好是MyBatis-Plus的舒适区。Redis在这个项目里是可选项。如果你做的版本允许用户重复提交投标文件或者需要给评标打分加防重复提交限制Redis的分布式锁会很有用。但单机部署、课堂演示场景完全可以先用数据库的唯一索引扛住后面再升级。2.2 前端选Vue 3但不建议自己从零搭脚手架很多做毕设的同学在前后端分离上栽跟头不是后端写不好是前端页面太耽误时间。招标系统前端的核心界面就六七个登录页、招标项目列表、项目详情、投标编辑页、评标打分页、后台管理页。我自己做这类项目直接采用Vue 3 Element Plus Vite的组合组件从Element Plus里挑现成的表格、表单、上传组件。Vite的启动速度和热更新比Webpack舒服太多对日常开发体验提升非常明显。有条件的建议直接用一些开源后台管理模板做二次开发。注意一下License但纯学习和毕设场景这类模板基本都能满足。重点是别把时间浪费在写菜单栏和面包屑上把精力留给真正的业务代码。2.3 为什么不用分布式微服务方案在线招标听起来好像很复杂但实际业务量级根本不需要微服务。单机SpringBoot MySQL把慢查询优化好支撑几百上千人同时在线没什么问题。微服务带来的注册中心、网关、服务间调用链路对这类系统是纯负资产——部署要多几台服务器演示时多几个可能挂掉的服务排查问题的难度翻倍。如果你的项目描述里出现了“高并发”“分布式”这些词建议先停下来想想招标系统的真实用户量是多少一个标段有几个投标人参与这里最表现力强的技术是数据库索引设计和缓存策略而不是服务拆分。3. 数据库设计围绕状态机建模而不是围绕页面建模3.1 核心表的拆分逻辑我见过不少在线招标系统的数据库设计第一版通常是一张巨大的“招标信息表”里面什么字段都有项目名称、预算金额、报名截止时间、投标文件路径、评标结果、中标人……然后每个功能页面都去这张表上取数最后表和接口全乱。正确的做法是按业务参与者拆表。招标系统核心表我通常拆成6张表名核心字段作用说明sys_userid, username, password, role_type系统所有账号用role_type区分招标方、投标方、评委、管理员biz_projectid, project_code, project_name, budget_amount, status, publish_time招标项目主表status记录项目所处阶段biz_bidid, project_id, bidder_id, bid_amount, bid_file_path, status, submit_time投标记录表一个项目对应多条投标记录biz_reviewid, project_id, bid_id, reviewer_id, score, comment评标记录表一个投标记录可有多条评标记录biz_qualificationid, project_id, company_id, qual_file_path, status, review_time资格审查记录表用于开标前检查投标资格biz_noticeid, project_id, notice_type, title, content, publish_time公告表涵盖招标公告、中标公告、变更公告等这6张表之间的核心关联是biz_project 一对多 biz_bidbiz_bid 一对多 biz_reviewbiz_project 一对多 biz_notice。biz_qualification 是独立审查流关联 project 和 company。我特别想强调的是 bz_project 的 status 字段。这个字段不能简单用 char 随意填而应该用枚举约束 状态流转校验。下面单独说。3.2 状态机是整张数据表设计的灵魂招标项目的生命周期通常是草稿 → 已发布 → 报名中 → 投标截止 → 资格审查中 → 评标中 → 已定标 → 已公告 → 已归档这些状态不是随意跳转的。草稿只能进已发布已发布才能进入报名中资格审查没结束不能直接跳到评标中。为了在数据库层面就能保证状态合理两种做法第一种是简单版status 字段用 TINYINT 或 VARCHAR后端每次更新时手动校验当前状态允许跳转到哪些目标状态。第二种是规范版状态流转用数据库表单独记录类似工作流引擎方案。但实际做下来对毕设和中小项目性价比偏低我自己通常在代码里配一张状态流转表。我推荐的做法是状态设计用枚举类在代码中定义每个状态的下一步合法集合写一个统一的状态校验方法。public enum ProjectStatus { DRAFT(草稿, Arrays.asList(PUBLISHED)), PUBLISHED(已发布, Arrays.asList(REGISTERING, WITHDRAWN)), REGISTERING(报名中, Arrays.asList(BID_OPEN, WITHDRAWN)), BID_OPEN(投标截止, Arrays.asList(QUALIFYING, ABORTED)), QUALIFYING(资格审查中, Arrays.asList(REVIEWING, ABORTED)), REVIEWING(评标中, Arrays.asList(AWARDED, ABORTED)), AWARDED(已定标, Arrays.asList(ANNOUNCED)), ANNOUNCED(已公告, Arrays.asList(ARCHIVED)), ARCHIVED(已归档, Arrays.asList()), WITHDRAWN(已撤回, Arrays.asList()), ABORTED(已作废, Arrays.asList()); private String desc; private ListString nextStates; // 省略构造函数和getter }一旦状态被这样约束很多业务边界问题就自动暴露了。比如“已发布状态不能直接改成评标中”“投标截止之前的投标记录不能进入评标打分列表”这些原来要在业务代码里反复 if-else 判断的逻辑治理掉一大半。3.3 投标记录表的唯一约束设计与乐观锁biz_bid 表有一个容易踩坑的点同一个投标方对同一个项目只能投一次标。这个约束不能只靠业务代码判断数据库层面要加联合唯一索引。ALTER TABLE biz_bid ADD UNIQUE INDEX uk_project_bidder (project_id, bidder_id);有了这个索引即使在秒杀的并发场景下两个人同时点击提交最终也只有一条能插入成功。在毕设答辩时这一条就能讲出“我考虑了并发下的数据一致性”的技术亮点。另外投标金额字段建议用 DECIMAL(12,2)不要用 DOUBLE 或 FLOAT。金额计算如果出现精度丢失评标汇总价格分时对不上账客户现场演示很难看。DECIMAL 虽然在计算性能上略低于浮点但财务语义的正确性远比那点性能重要。4. 多角色权限与越权防护这是评委眼中最值钱的代码4.1 角色设计的取舍RBAC 通用模型还是简化模型常见的后台管理系统权限设计有两大类一是经典的 RBAC用户-角色-权限角色关联菜单权限用户关联角色二是简化版直接用 role_type 字段区分。招标系统的角色边界非常清晰我建议采取一个折中方案Spring Security 基于注解的角色校验。不用把菜单权限做到数据库中配置化的程度但也不能只靠登录后前端隐藏菜单就完事。角色划分四类角色英文标识核心操作权限系统管理员ADMIN用户管理、项目管理全权限、数据维护招标方TENDERER发布项目、修改项目、查看投标列表、发布公告投标方BIDDER报名项目、提交投标文件、查看自己的投标记录评标专家REVIEWER查看被分配的项目、打分、填写评审意见4.2 越权漏洞很多系统在“垂直权限”上翻车说说“水平越权”这个最容易犯的错。很多系统只校验了角色没校验数据归属。比如投标方登录后修改投标文件的接口长这样PostMapping(/bid/update) public Result updateBid(RequestBody BidUpdateDTO dto) { Bid bid bidMapper.selectById(dto.getBidId()); // 直接修改没检查是不是本人 bidMapper.updateById(bid); return Result.ok(); }这个接口只要把 dto.getBidId() 换成别人的投标记录ID就能修改别人的投标内容。评委演示时故意换一个ID系统就暴露了。修复方式是在更新前加一条数据归属校验PostMapping(/bid/update) public Result updateBid(RequestBody BidUpdateDTO dto, AuthenticationPrincipal UserPrincipal user) { Bid bid bidMapper.selectById(dto.getBidId()); if (bid null || !bid.getBidderId().equals(user.getId())) { return Result.error(无权操作该投标记录); } bidMapper.updateById(bid); return Result.ok(); }再想一想还有哪些接口需要归属校验项目修改接口要校验操作者是不是项目发布人评标打分接口要校验评委的 expertise_type 是否匹配项目的 category公告发布接口要校验用户是否属于该项目招标方。把这些都逐一补上才叫完整的权限设计。4.3 密码存储和会话管理的基本盘密码存储不要用MD5或SHA——直接用 BCryptPasswordEncoder。它是Spring Security自带的加盐加密同样的密码每次加密结果都不同。很多同学在答辩时被问到“密码怎么存的”如果回答“MD5加密”基本会被追问到无地自容。换 BCrypt 只要两行代码PasswordEncoder encoder new BCryptPasswordEncoder(); String encodedPwd encoder.encode(rawPassword);登录认证方案有两种主流选择。传统做法是 Session CookieSpring Security 默认支持简单可靠前后端分离更常见的是 JWT。我的建议很实在如果是在校实验环境或课堂内网演示用Session最省心如果要部署到公网服务器且前端要区分Web和移动端用JWT方便后续扩展。我倾向 JWT因为它天然无状态Spring Security 配置一套 OncePerRequestFilter 就能实现。但注意 JWT 要设置合理过期时间3-4小时比较合适过期后强制重新登录避免 token 长时间有效导致的安全风险。5. 核心业务模块实现拆解从发标到中标5.1 招标发布模块文件上传不能只用一个 MultipartFile招标方发布项目时除了填写项目名称、预算金额、报名截止时间还需要上传招标文件。招标文件的文件类型、大小限制、存储路径都要设计好。上传接口不能做成简单的 MultipartFile 直接存一个固定路径要做三层设计文件类型白名单校验、大小限制、存储路径按业务隔离。Override public String storeFile(MultipartFile file, Long projectId) { // 1. 校验扩展名 String originalFilename file.getOriginalFilename(); String ext originalFilename.substring(originalFilename.lastIndexOf(.)); SetString allowedExt new HashSet(Arrays.asList(.pdf, .doc, .docx, .xls, .zip, .rar)); if (!allowedExt.contains(ext.toLowerCase())) { throw new BusinessException(不支持的文件类型: ext); } // 2. 校验大小5MB上限 if (file.getSize() 5 * 1024 * 1024) { throw new BusinessException(文件大小不能超过5MB); } // 3. 按项目ID建目录避免文件堆积 String dateDir new SimpleDateFormat(yyyyMMdd).format(new Date()); String projectDir upload/project/ projectId / dateDir; File dir new File(basePath, projectDir); if (!dir.exists()) { dir.mkdirs(); } String storeName UUID.randomUUID().toString().replace(-, ) _ originalFilename; file.transferTo(new File(dir, storeName)); return projectDir / storeName; }这里有一个需要注意的细节文件名里保留原始文件名但前面拼接UUID。这样既不会因为重名互相覆盖用户在系统里下载文件时又能看到自己的原名。路径入库时存相对路径不要存绝对路径——服务器迁移后绝对路径会全部失效。5.2 投标模块截止时间校验是业务安全底线投标提交接口是整套系统最容易被攻击的点。两个核心校验一、项目状态必须是“报名中”或“投标截止前”的可投状态。 二、当前时间必须在投标截止时间之前。这两条校验必须放在文件上传、数据更新之前否则就存在“先传文件后校验时间”的逻辑漏洞。// 投标提交的核心校验 Project project projectMapper.selectById(dto.getProjectId()); if (project null) { throw new BusinessException(项目不存在); } if (!project.getStatus().equals(ProjectStatus.REGISTERING.getCode())) { throw new BusinessException(当前项目不在报名中状态无法投标); } if (new Date().after(project.getBidDeadline())) { throw new BusinessException(投标截止时间已过无法提交); }前端在投标页面也要做倒计时提醒但真正的硬校验只能依赖后端。前端的时间可被用户修改后端的时间才可信。这条经验适用于系统里所有“截止时间”相关功能。5.3 评标打分模块一组计算规则的落地评标打分通常不是一个人打个总分那么简单。通用的规则是评标总得分 价格分 技术分 商务分或信誉分每部分有固定权重。价格分通常还有一个计算公式比如评标基准价法或最低价法。我实现过一套简化但完整的计价逻辑// 最低价法价格分计算示例 public BigDecimal calcPriceScore(BigDecimal basePrice, BigDecimal bidPrice, BigDecimal weight) { // 价格得分满分为 weight 对应的分数如40分 // 按公式得分 40 * (基准价 / 投标价) BigDecimal fullScore weight; // 如 40 BigDecimal ratio basePrice.divide(bidPrice, 4, RoundingMode.HALF_UP); BigDecimal score fullScore.multiply(ratio).setScale(2, RoundingMode.HALF_UP); return score; }具体用什么公式取决于用户需求但代码实现时要关注三个点除法保留位数足够至少4位、金额用 BigDecimal 不能除法除尽报错、结果设2位小数。做这套逻辑的单元测试时把边界情况列出来投标价等于基准价得满分、投标价是基准价两倍得一半分、基准价为零报参数错误等。5.4 中标公告与通知模块标评完招标方在中标候选名单中选定中标人系统生成中标公告。这个模块在业务上有个细节公告发布后落标方最好能看到“未中标”状态的提示但看不到中标人的具体报价或评标得分明细。竞争敏感数据的可见范围要严格控制。数据库里可以在 biz_bid 表增加一个 result_status 字段取值 0-未中标, 1-中标, 2-待定。公告发布后批量更新所有投标记录的结果状态同时在中标公告里只展示中标标段名称、中标金额、中标单位、公示起止时间不展示评委个人打分。6. 部署交付时最容易翻车的几个点6.1 文件上传目录的绝对路径与相对路径陷阱这是我在交付模拟项目X时真实踩过的坑。项目在本机跑得好好的部署到服务器之后上传文件全部报错。排查到最后发现是 application.yml 里写了本机的绝对路径file: upload-path: D:/workspace/upload改成相对路径并且启动时动态获取项目根目录后才稳定Value(${file.upload-path:./upload/}) private String uploadPath; Override public void afterPropertiesSet() throws Exception { Path path Paths.get(uploadPath).toAbsolutePath().normalize(); Files.createDirectories(path); }6.2 时区问题让截止时间提前8小时另一个常见坑是时区。MySQL 默认时区如果设置成 UTC而 SpringBoot 应用用的是 Asia/Shanghai数据库里的时间就会差8小时。表现为前端明明还有8小时才截止数据库已判定过期。建议在 JDBC 连接串里显式指定时区spring: datasource: url: jdbc:mysql://localhost:3306/bidding_system?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai注意 characterEncoding 和 serverTimezone 都要写。同时建议所有时间字段在数据库中都统一成 DATETIME 而不是 TIMESTAMP避免 MySQL 的 TIMESTAMP 2023年以前只能到2038年的问题虽然现在谈可能还早但规范起见。6.3 数据库字符集别让中文变成问号如果数据库表没有默认 utf8mb4 字符集保存中文招标文件内容时就会出现乱码或问号。建库时直接用CREATE DATABASE bidding_system DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;表结构设计里所有字符串字段都优先用 VARCHAR 而不是 TEXT必须用 TEXT 的大段内容如招标公告正文也要设置表的 charset。这个问题在代码环境里测不出来部署到Linux服务器上才会暴露。我的经验是每次建新表的DDL都手写 CHARACTER SET utf8mb4不要依赖数据库默认值。6.4 前后端联调时 Session 和跨域配置使用 JWT 方案时会少很多跨域头疼问题但用 Session 方案前端端口是8080后端8081跨域配置没做好登录请求永远拿不到Cookie。配置 CorsFilter 时要注意 allowCredentials 和 allowedOrigins 不能同时用通配符 *这会直接让浏览器拒绝携带凭证。Configuration public class CorsConfig { Bean public CorsFilter corsFilter() { CorsConfiguration config new CorsConfiguration(); config.addAllowedOriginPattern(*); config.setAllowCredentials(true); config.addAllowedHeader(*); config.addAllowedMethod(*); UrlBasedCorsConfigurationSource source new UrlBasedCorsConfigurationSource(); source.registerCorsConfiguration(/**, config); return new CorsFilter(source); } }利用 addAllowedOriginPattern 代替 addAllowedOriginSpringBoot 2.4 之后发布跨域凭证的组合才能正常通过浏览器校验。7. 除了代码交付物里更容易被忽视的三类文档一个完整的在线招标系统项目标题里既然带了“源码lw部署文档讲解”说明交付时代码只是工程的一部分。我在实际带学生做项目时发现最常见的丢分项其实在文档和演示脚本上。7.1 设计说明文档要画出核心流程图这里的流程图指的数据流和状态流。不需要用形形色色的建模软件画得非常规范但至少要把“投标截止后系统做什么”“资格审查通过后系统做什么”说清楚。如果审查论文的时候看不到状态流转图很容易被质疑“只是把几个页面拼在了一起”。用文字描述状态流转也可以比如一节标题为“基于状态机的项目流转控制”下面列草稿状态仅创建者可见可编辑、可删除已发布状态所有人可见不可编辑可撤回报名中状态投标方可提交投标文件每项目每投标方唯一投标截止状态不可再提交文件系统锁定投标列表资格审查中状态招标方可批量通过或不通过评标中状态评标专家可打分不可修改自己的已提交评分已定标状态招标方选择中标人系统生成公告7.2 部署文档要写不出歧义部署文档最容易出的问题是“只写步骤不写依赖”。一份能照着做成功的部署文档至少要有这几块内容环境要求JDK版本、MySQL版本、Node版本、初始化SQL执行方式、配置文件修改点列表、启动顺序、常见报错对照表。我常用一个表格来列常见问题如下现象原因解决办法前端页面白屏后端接口没启动或跨域配置错误先 curl 后端接口确认后端可用上传文件报错upload-path 目录无写权限chmod -R 755 upload 或改路径登录后无法保持会话Cookie 跨域 setAllowCredentials 配置有误检查 Cors 配置不能使用 *时间相差8小时数据库连接串缺 serverTimezone在 jdbc url 加 serverTimezoneAsia/Shanghai7.3 自带一份演示脚本很多人忽略的是给客户或答辩老师演示时先演示什么、再演示什么决定了对项目的第一印象。我的顺序通常是先以管理员登录看用户管理 → 切招标方发一个项目 → 切投标方提交投标文件 → 切招标方查看投标列表 → 切评委打分 → 切招标方定标 → 公告发布 → 前台查看中标公告。这样一条链路刚好覆盖状态机完整流转每一段都能展示一个角色视角。8. 我在实际开发中积累的几条体会项目做完之后复盘有几点值得反复强调的经验。第一先画状态流转图再写代码。如果拿到需求第一件事是建表大概率会漏掉业务约束。我后来做任何带审批流的系统都先花半小时把状态流转和每个状态的操作权限列出来再动数据库。动工前多花的时间往往能在后期调试中数倍省回来。第二给所有时间相关的字段一个唯一的命名规范。比如 create_time、update_time、submit_time、publish_time、bid_deadline。别一会儿用 createTime一会儿用 gmt_create甚至还有 cjsj创建时间的拼音缩写。统一命名后写SQL和联调时的沟通成本会低很多。第三不要把接口返回结构和状态码设计得随心所欲。我自己固定用统一返回结构 Result(code, message, data)code 用 200 表示成功400 表示业务错误401 未登录403 无权限500 系统异常。前端所有请求封装一层 axios 拦截器统一判断 code。这样在前后端联调时不需要依赖“看浏览器控制台猜错误”。第四答辩或交付前一定要自己走一遍全流程。我曾经在交付前发现一个严重bug评委打分后成绩汇总页面的价格分排序错误因为Decimal类型的比较用成了 compareTo 而不是减法后再判断正负。这种问题不实际走一遍很难暴露。关于在线招标系统的核心设计差不多就到这里。动手做的过程中如果遇到具体的状态流转设计或者权限校验问题欢迎评论区聊聊。