ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Java招标管理系统:Spring Boot全链路实战

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证书模拟

  1. 修改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。

  2. 执行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'验证开标事件是否已注册。

  3. 生成测试用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取值:
    <select id="selectByDept" resultType="BiddingProject"> SELECT * FROM t_bidding_project WHERE dept_id = #{_parameter.deptId} AND status IN ('NOTICE_PUBLISHED', 'BIDDING_OPENED') </select>
    这样即使黑客篡改前端请求参数,SQL里的dept_id仍来自可信Session。

5.3 行级权限的边界在哪里?三个必须守住的底线

  1. 导出功能必须二次鉴权:/export/project接口不能只校验@RequiresPermissions("tender:export"),必须在Service层再次调用DataPermissionInterceptor的逻辑,检查当前用户是否有权导出这批数据。
  2. 统计报表需聚合脱敏:/report/department-bid-count返回各科室招标数,但不能返回具体项目名称和金额,避免通过聚合反推敏感信息。
  3. 日志记录完整操作链:每次数据查询必须记录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报告红不红。红了就停下手头所有事,先补测试。毕设不是交代码,是交一份经得起推敲的工程证据。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进