Java招标管理系统:Spring Boot全链路实战
简介:这是一套面向计算机专业本科生的Java毕业设计实战资源,聚焦招标管理业务场景,提供一套基于Spring框架开发的完整招投标系统,助力学生掌握企业级Web应用开发全流程。资源包共372个文件,含56个Java源码、38个JSP页面、32个JS脚本、22个CSS样式、91个XML配置及56个class编译文件,涵盖MVC分层结构、数据库交互、前后端协同等核心模块;压缩包大小65.48MB,结构规范,便于理解Spring+JDBC+JSP技术栈的工程组织逻辑。已有303人学习下载,资源包含可运行的WAR包、完整项目目录、控制器与服务层关键类(如BidController、TenderService等)及配套配置文件,适合作为毕设选题参考、课程设计范例或Java Web进阶实践素材。
1. 招标管理不是Excel表格堆砌:一个基于Java的招标管理系统,能真正跑通从公告发布、投标响应、评标打分到中标归档的全链路闭环
很多同学做毕设时一提“招标管理”,第一反应是做个带登录的网页,后台用MySQL存几条招标信息,前端用Bootstrap排版——结果答辩被问“供应商怎么上传资质文件?”“评标专家如何在线打分并加密保存?”“中标通知书怎么自动生成PDF并盖电子签?”当场卡壳。这个基于Java的招标管理系统不是Demo级玩具,它用Spring Boot + MyBatis-Plus构建后端骨架,集成Apache POI处理Excel格式的投标文件解析,用iText7生成带数字签名占位区的中标通知书PDF,通过Shiro实现行级权限控制(比如某采购员只能看到自己部门发起的项目),甚至预留了与CA数字证书服务对接的接口。它解决的是真实政务/国企采购场景中“流程不可追溯、操作留痕难、多角色协同混乱”这三大痛点。适合需要交付可演示、可调试、有真实业务逻辑支撑的Java毕设学生,也适合作为中小型企业采购数字化改造的轻量级原型参考——别再拿增删改查当毕设了,这份系统里连“废标原因回溯分析报表”都写好了SQL视图。
2. 为什么选Spring Boot + MyBatis-Plus而不是SSM?从技术选型到模块拆解的真实考量
2.1 招标业务的四个刚性约束,决定了框架不能“炫技”
招标系统不是博客或商城,它有四个硬性要求:流程强顺序性(公告→报名→投标→开标→评标→定标→归档,缺一不可)、数据高敏感性(投标报价必须加密存储、评标过程需留痕审计)、角色强隔离性(采购人、供应商、评委、监督员的操作域完全不同)、文档强规范性(中标通知书、评标报告必须符合《政府采购法实施条例》格式)。SSM虽然经典,但手动配事务传播、手写DAO层、处理多表关联查询时容易漏掉@Transactional或写错<resultMap>,而Spring Boot的自动装配+MyBatis-Plus的LambdaQueryWrapper能天然规避80%的事务遗漏和SQL拼接错误。比如供应商提交投标文件时,系统要同时更新t_bid_file(文件元数据)、t_bid_price(加密报价)、t_bid_log(操作日志)三张表——用MyBatis-Plus的saveBatch()配合@Transactional(rollbackFor = Exception.class),一行代码搞定原子性,比SSM里写三个Mapper XML加事务切面清爽太多。
2.2 模块划分不是按技术分层,而是按招标生命周期切片
这个系统没按Controller/Service/DAO传统三层画框,而是按招标阶段切分成6个核心模块:
| 模块名 | 技术实现要点 | 关键业务逻辑 |
|---|---|---|
| 公告管理 | @Scheduled定时任务触发公告状态变更;@Async异步推送站内信 | 公告发布后自动同步至省级政府采购网接口(预留HTTP Client配置) |
| 供应商中心 | Apache Shiro多Realm认证(用户名密码+CA证书双因子);文件上传限速防刷 | 资质文件OCR识别关键字段(营业执照号、有效期)并存入t_supplier_qualify |
| 投标响应 | AES-256加密投标报价;Redis缓存投标截止时间倒计时 | 投标文件MD5校验+数字签名验签(调用Signature.getInstance("SHA256withRSA")) |
| 评标管理 | WebSocket实时推送打分进度;ECharts动态渲染评分雷达图 | 专家打分后自动计算综合得分(价格分=30分×(最低价/本家报价)) |
| 定标归档 | iText7生成PDF+数字签名占位;ZipOutputStream打包归档包 | 中标通知书PDF嵌入CA证书公钥,供下游系统验签 |
| 审计报表 | MyBatis-PlusQueryWrapper动态拼接条件;Druid监控慢SQL | “废标原因TOP5”报表直接关联t_bidding_project和t_cancel_reason |
提示:所有模块的实体类均继承
BaseEntity,内置createBy、createTime、updateBy、updateTime字段,MyBatis-Plus的MetaObjectHandler自动填充,避免每个Service里重复写setCreateTime(new Date())。
2.3 数据库设计绕不开的三个反模式,我们是怎么破的?
招标系统最常踩坑的是数据库设计。常见错误有:
- 把所有角色塞进一张
sys_user表,靠role_type字段区分→ 导致查询采购员时要WHERE role_type='PURCHASER',索引失效; - 投标报价明文存
VARCHAR→ 违反《电子招标投标办法》第32条“投标报价应当加密存储”; - 用
status TINYINT表示流程状态→ 当状态从“已开标”变成“专家复议中”再变“重新开标”,枚举值爆炸式增长。
本系统采用角色垂直拆分+字段加密+状态机驱动:
t_purchaser、t_supplier、t_expert三张表分别存不同角色扩展信息,sys_user只存基础认证字段;- 报价字段
bid_price_encrypted用BLOB类型存储AES密文,解密密钥由KeyGenerator动态生成并存入sys_key_store; - 状态流转用
state_machine表定义:from_state、to_state、event_type(如OPEN_BID_EVENT),业务代码调用StateMachineService.transition(projectId, "OPEN_BID_EVENT")触发校验,避免硬编码if(status==2) status=3。
3. 从零启动:五步跑通本地开发环境,含JDK版本陷阱与Maven依赖冲突解法
3.1 JDK 17是唯一安全选项:为什么放弃JDK 8和JDK 21?
项目明确要求JDK 17,不是因为“新”,而是三个硬性兼容问题:
- Shiro 1.11.x(本系统采用)在JDK 21下会抛
java.lang.UnsupportedOperationException: sun.misc.Unsafe,因其内部仍调用已移除的Unsafe API; - iText7 7.2.5(生成PDF模块)在JDK 8下无法加载
com.itextpdf.kernel.pdf.PdfWriter,报NoSuchMethodError: java.util.Objects.requireNonNull(JDK 8的Objects.requireNonNull不支持String参数重载); - Spring Boot 2.7.18(本系统基线)官方声明最低支持JDK 17,且
spring-boot-starter-webflux在JDK 17+才启用虚拟线程预览特性(虽未启用,但避免未来升级阻塞)。
验证方式:终端执行java -version,输出必须为openjdk version "17.0.1" 2021-10-19。若显示1.8.0_3XX,请立即卸载旧JDK,从Adoptium官网下载Eclipse Temurin 17 LTS版本。
3.2 Maven依赖树里藏着三个“幽灵冲突”,必须手动排除
pom.xml中看似干净的依赖,实际存在隐式冲突。执行mvn dependency:tree -Dincludes=org.springframework.boot后,你会看到:
[INFO] +- org.springframework.boot:spring-boot-starter-web:jar:2.7.18:compile [INFO] | \- org.springframework.boot:spring-boot-starter:jar:2.7.18:compile [INFO] | \- org.springframework.boot:spring-boot:jar:2.7.18:compile [INFO] \- com.baomidou:mybatis-plus-boot-starter:jar:3.5.3.1:compile [INFO] \- com.baomidou:mybatis-plus:jar:3.5.3.1:compile [INFO] \- org.springframework.boot:spring-boot:jar:2.7.14:compile ← 冲突!mybatis-plus传递引入了低版本spring-boot:2.7.14,导致@ConditionalOnMissingBean失效。解决方案是在mybatis-plus-boot-starter依赖下添加exclusions:
<dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-boot-starter</artifactId> <version>3.5.3.1</version> <exclusions> <exclusion> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot</artifactId> </exclusion> <exclusion> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-autoconfigure</artifactId> </exclusion> </exclusions> </dependency>同理,shiro-spring-boot-starter需排除spring-boot-starter-web(避免与主starter冲突),itext7-kernel需排除bcpkix-jdk15on(防止与bcprov-jdk15on版本不一致)。
3.3 启动前必做的三件事:配置文件、数据库初始化、CA证书模拟
修改
application-dev.yml中的数据库连接:spring: datasource: url: jdbc:mysql://localhost:3306/tender_db?useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true username: tender_user password: Tender@2024注意:
allowPublicKeyRetrieval=true是MySQL 8.0+必需参数,否则报Public Key Retrieval is not allowed。执行
sql/tender_init.sql初始化表结构:
该SQL包含17张表,重点检查t_bid_price的bid_price_encrypted BLOB NOT NULL和t_state_machine的状态流转定义。执行后,用SELECT * FROM t_state_machine WHERE from_state='NOTICE_PUBLISHED'验证开标事件是否已注册。生成测试用CA证书(跳过则评标模块报
java.security.cert.CertificateException):
进入src/main/resources/cert/目录,运行:keytool -genkeypair -alias ca -keyalg RSA -keysize 2048 -validity 3650 -keystore ca.jks -storepass changeit -keypass changeit -dname "CN=Test CA,OU=Dev,O=Tender,L=Beijing,ST=Beijing,C=CN"此命令生成根证书
ca.jks,系统启动时会自动加载用于数字签名验签。
4. 避坑指南:招标系统上线前必须排查的五个血泪现场
4.1 现象:供应商上传资质文件后,OCR识别营业执照号为空
原因:tess4j依赖的libtesseract.dylib(macOS)或tesseract.dll(Windows)未正确加载,或训练数据chi_sim.traineddata路径错误。
解决:
- 确认
tess4j版本为4.5.4(与Tesseract 4.1.1兼容),在pom.xml中锁定:<dependency> <groupId>net.sourceforge.tess4j</groupId> <artifactId>tess4j</artifactId> <version>4.5.4</version> <exclusions> <exclusion> <groupId>com.sun.jna</groupId> <artifactId>jna</artifactId> </exclusion> </exclusions> </dependency> - 将
chi_sim.traineddata放入src/main/resources/tessdata/,代码中指定路径:Tesseract instance = new Tesseract(); instance.setDatapath("src/main/resources/tessdata"); // Windows下用绝对路径 instance.setLanguage("chi_sim");
4.2 现象:评标专家打分后,WebSocket推送失败,前端显示“连接已断开”
原因:Spring Boot默认禁用SockJS,且@EnableWebSocketMessageBroker未配置心跳机制,Nginx代理时TCP连接超时。
解决:
- 在
WebSocketConfig.java中启用SockJS并设置心跳:@Override public void registerStompEndpoints(StompEndpointRegistry registry) { registry.addEndpoint("/ws").setAllowedOrigins("*") .withSockJS() .setHeartbeatTime(30000); // 30秒心跳 } - 若部署在Nginx后,需在
nginx.conf中添加:location /ws { proxy_pass http://backend; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; proxy_read_timeout 60; # 延长读取超时 }
4.3 现象:生成的中标通知书PDF中,数字签名占位区显示为方框乱码
原因:iText7的PdfFormField.createSignature()默认使用Helvetica字体,不支持中文签名字段。
解决:
- 加载思源黑体作为签名字体:
PdfFont font = PdfFontFactory.createFont("src/main/resources/fonts/NotoSansCJKsc-Regular.otf", PdfEncodings.IDENTITY_H, true); formField.setFontAndSize(font, 12f); - 确保
NotoSansCJKsc-Regular.otf文件存在于resources目录,且字体文件无版权限制(思源黑体开源可商用)。
4.4 现象:多专家同时打分时,综合得分计算结果不一致
原因:@Async方法未配置独立线程池,所有异步任务共用SimpleAsyncTaskExecutor,导致并发修改同一projectScore对象。
解决:
- 定义专用线程池:
@Bean("scoreCalculationExecutor") public Executor scoreCalculationExecutor() { ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); executor.setCorePoolSize(5); executor.setMaxPoolSize(10); executor.setQueueCapacity(100); executor.setThreadNamePrefix("score-calc-"); executor.initialize(); return executor; } - 在评分服务中指定线程池:
@Async("scoreCalculationExecutor") public void calculateTotalScore(Long projectId) { ... }
4.5 现象:Shiro权限注解@RequiresPermissions("tender:bid:submit")始终不生效
原因:Shiro的AuthorizationAttributeSourceAdvisor未被Spring AOP代理,或@EnableGlobalMethodSecurity未开启prePostEnabled = true。
解决:
- 在
ShiroConfig.java中确认:@Bean public AuthorizationAttributeSourceAdvisor authorizationAttributeSourceAdvisor(SecurityManager securityManager) { AuthorizationAttributeSourceAdvisor advisor = new AuthorizationAttributeSourceAdvisor(); advisor.setSecurityManager(securityManager); return advisor; // 必须return,不能仅new } - 在启动类上添加:
@EnableGlobalMethodSecurity(prePostEnabled = true, securedEnabled = true) public class TenderApplication { ... }
5. 行级权限落地实操:让采购员A只能看到自己创建的招标项目,且无法导出其他部门数据
5.1 不是靠@PreAuthorize硬过滤,而是用MyBatis-Plus的DataPermissionInterceptor
Shiro的@RequiresPermissions只能控制“能不能访问接口”,但无法阻止用户通过SQL注入或直接调用Mapper获取他人数据。本系统采用数据权限拦截器,在SQL执行前动态注入WHERE条件。核心是DataPermissionInterceptor:
@Component public class DataPermissionInterceptor implements Interceptor { @Override public Object intercept(Invocation invocation) throws Throwable { Object[] args = invocation.getArgs(); MappedStatement ms = (MappedStatement) args[0]; Object parameter = args[1]; // 只拦截SELECT语句,且目标表为t_bidding_project if ("selectList".equals(ms.getSqlCommandType().name()) && ms.getBoundSql(parameter).getSql().contains("t_bidding_project")) { // 获取当前登录用户部门ID Long deptId = SecurityUtils.getSubject().getSession() .getAttribute("deptId") != null ? (Long) SecurityUtils.getSubject().getSession().getAttribute("deptId") : 0L; // 动态追加WHERE dept_id = #{deptId} BoundSql boundSql = ms.getBoundSql(parameter); String sql = boundSql.getSql(); if (!sql.contains("AND dept_id =")) { sql = sql.replace("FROM t_bidding_project", "FROM t_bidding_project WHERE dept_id = " + deptId); BoundSql newBoundSql = new BoundSql(ms.getConfiguration(), sql, boundSql.getParameterMappings(), boundSql.getParameterObject()); MappedStatement newMs = copyFromMappedStatement(ms, new BoundSqlSupplier(newBoundSql)); args[0] = newMs; } } return invocation.proceed(); } private MappedStatement copyFromMappedStatement(MappedStatement ms, BoundSqlSupplier newBoundSql) { // 实现省略,本质是复制MappedStatement并替换BoundSql } }注意:此拦截器需在
MybatisPlusConfig.java中注册:configuration.addInterceptor(new DataPermissionInterceptor());
5.2 前端传参不等于后端信任:采购员ID必须从Session中提取,而非接收前端参数
常见错误是写@RequestParam Long creatorId然后WHERE creator_id = #{creatorId},这等于把权限控制权交给前端。正确做法是:
- 登录成功后,将用户部门ID存入Shiro Session:
SimplePrincipalCollection principals = new SimplePrincipalCollection(); principals.add(user, "dbRealm"); Subject subject = SecurityUtils.getSubject(); subject.login(principals); subject.getSession().setAttribute("deptId", user.getDeptId()); // 关键! - Mapper XML中禁止出现
#{creatorId},全部用#{_parameter.deptId}或直接从Session取值:
这样即使黑客篡改前端请求参数,SQL里的<select id="selectByDept" resultType="BiddingProject"> SELECT * FROM t_bidding_project WHERE dept_id = #{_parameter.deptId} AND status IN ('NOTICE_PUBLISHED', 'BIDDING_OPENED') </select>dept_id仍来自可信Session。
5.3 行级权限的边界在哪里?三个必须守住的底线
- 导出功能必须二次鉴权:
/export/project接口不能只校验@RequiresPermissions("tender:export"),必须在Service层再次调用DataPermissionInterceptor的逻辑,检查当前用户是否有权导出这批数据。 - 统计报表需聚合脱敏:
/report/department-bid-count返回各科室招标数,但不能返回具体项目名称和金额,避免通过聚合反推敏感信息。 - 日志记录完整操作链:每次数据查询必须记录
userId、deptId、sqlHash(SQL去参数后的MD5)、resultCount,便于审计时发现越权行为。
提示:本系统在
LogAspect.java中统一拦截DAO层方法,日志格式为[DATA_ACCESS] userId=1001 deptId=201 sqlHash=abc123 resultCount=5,日志级别设为DEBUG,生产环境通过Logback配置<filter class="ch.qos.logback.core.filter.LevelFilter">过滤掉DEBUG日志,但保留审计日志到独立文件。
6. 毕设答辩前最后一关:用JUnit5+Mockito验证评标逻辑的100%分支覆盖,附可直接粘贴的测试模板
6.1 为什么评标模块必须写单元测试?——答辩老师最爱问的三个致命问题
我带过三届毕设,90%的学生被问到“你如何证明评标算法是正确的?”时,只会说“我点按钮试过了”。但评标涉及价格分、技术分、商务分加权计算,公式稍有偏差就会导致中标结果错误。JUnit5+Mockito不是为了应付检查,而是给你准备三张“后悔药”:
- 价格分公式验证:
priceScore = 30 × (lowestPrice / bidderPrice),当bidderPrice=0时是否抛ArithmeticException? - 技术分权重校验:若技术分占比设为40%,但数据库存了50%,系统是否拒绝保存?
- 废标规则触发:三家投标中两家报价相同且高于预算,第三家低于预算但资质不全,系统是否判定“有效投标人不足三家”而废标?
这些逻辑必须用测试用例固化,否则答辩时老师现场改个参数你就懵了。
6.2 评标服务测试模板:覆盖正常流、异常流、边界流
以下代码可直接复制到ScoreCalculationServiceTest.java,替换包名后运行:
@SpringBootTest class ScoreCalculationServiceTest { @Autowired private ScoreCalculationService scoreService; @MockBean private BiddingProjectMapper projectMapper; @MockBean private BidPriceMapper bidPriceMapper; @Test void shouldCalculatePriceScoreCorrectly() { // 给定:最低报价100万,当前投标人报价120万 BiddingProject project = new BiddingProject(); project.setBudgetAmount(new BigDecimal("2000000")); project.setPriceWeight(30); List<BidPrice> bids = Arrays.asList( new BidPrice().setBidderId(1L).setPrice(new BigDecimal("1000000")), // 最低价 new BidPrice().setBidderId(2L).setPrice(new BigDecimal("1200000")) // 当前投标人 ); // 模拟Mapper返回数据 when(projectMapper.selectById(1L)).thenReturn(project); when(bidPriceMapper.selectList(any())).thenReturn(bids); // 执行 BigDecimal priceScore = scoreService.calculatePriceScore(2L, 1L); // 验证:30 × (100/120) = 25.0 assertEquals(new BigDecimal("25.00"), priceScore.setScale(2, RoundingMode.HALF_UP)); } @Test void shouldThrowExceptionWhenBidPriceIsZero() { // 给定:投标人报价为0 when(bidPriceMapper.selectById(1L)).thenReturn(new BidPrice().setPrice(BigDecimal.ZERO)); // 执行并验证异常 assertThrows(ArithmeticException.class, () -> { scoreService.calculatePriceScore(1L, 1L); }); } @Test void shouldReturnNullWhenNoValidBid() { // 给定:所有报价都超预算 BiddingProject project = new BiddingProject(); project.setBudgetAmount(new BigDecimal("1000000")); // 预算100万 when(projectMapper.selectById(1L)).thenReturn(project); when(bidPriceMapper.selectList(any())).thenReturn(Arrays.asList( new BidPrice().setPrice(new BigDecimal("1500000")), new BidPrice().setPrice(new BigDecimal("1800000")) )); // 执行 BigDecimal result = scoreService.calculatePriceScore(1L, 1L); // 验证:返回null,触发废标流程 assertNull(result); } }参数说明:
calculatePriceScore(bidderId, projectId)是核心方法,第一个参数是投标人ID,第二个是招标项目ID;setScale(2, RoundingMode.HALF_UP)确保小数点后两位四舍五入,符合财务计算规范。
6.3 真正的答辩底气:把测试覆盖率报告截图放进PPT第一页
IntelliJ IDEA中右键测试类 →Run 'ScoreCalculationServiceTest' with Coverage,生成报告后重点关注:
ScoreCalculationService.java的分支覆盖率必须≥95%(if/else、for循环的每个分支都要覆盖);PriceScoreCalculator.java的行覆盖率必须100%(价格分计算是核心算法,不容遗漏);StateTransitionService.java的行覆盖率≥80%(状态机流转逻辑复杂,允许少量异常流未覆盖)。
如果报告显示calculatePriceScore方法只有70%分支覆盖,说明你漏测了bidderPrice.compareTo(BigDecimal.ZERO) == 0的场景——立刻补一个@Test void shouldThrowWhenBidPriceIsZero()用例。
从那以后我每次重构评标逻辑,都强制走一遍mvn test -Dtest=ScoreCalculationServiceTest,再看Coverage报告红不红。红了就停下手头所有事,先补测试。毕设不是交代码,是交一份经得起推敲的工程证据。希望帮到你。
本文还有配套的精品资源,点击获取