ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

Java招投标系统源码解析:状态机设计与Spring Boot实践

Java招投标系统源码解析:状态机设计与Spring Boot实践 简介面向企业招投标管理场景的Java源码项目适合需要快速搭建信息化招投标平台的开发者、项目经理或相关业务人员。系统覆盖公告管理、投标管理、评标管理、系统管理、系统维护五大模块内置管理员、招标专员、投标专员、评标专员四种角色及完整的招标—投标—评标—中标公告流程可直接用于技术学习、二次开发或业务流程演示。压缩包约5.79MB共2000个文件以png图片、css样式、java源码、js脚本为主另有少量xml配置、gif动图、properties配置及说明文档前端资源与后端代码结构清晰。已有2250人下载学习适合具备一定Java Web基础、希望理解招投标业务闭环并据此扩展权限控制或评审功能的读者可从中获取完整角色权限设计、核心流程实现及界面布局参考。1. 这套 Java 招投标管理系统源码.zip先理顺状态机再启动我拿到一份 Java 招投标管理系统源码.zip 时不会急着解压、改端口、跑起来看页面。先找三个东西招标、投标、评标的状态流转在哪一层实现投标文件由谁校验评审打分是后端算还是前端算。这三个问题决定它是能二次开发的业务骨架还是一堆演示用的 CRUD 页面。招投标管理的业务链路很长。公开招标要经过公告审批、供应商报名、标书递交、开标、专家评审、定标公示中间还夹着保证金、回避规则和黑名单。落到 Java 技术栈一般是 Spring Boot 做服务端、MySQL 存业务数据、对象存储存标书文件再配合定时任务扛投标高峰。这套源码对两类人最有用一类刚走完 Java 学习路线想找一个业务有深度、能写进简历的项目一类在做企业采购系统二次开发要改审批流、截止逻辑或评分权重。两者都要求你看得懂状态机是否严谨而不是只看页面。2. 招投标业务流程与数据库模型先立起状态机再写代码做这类系统第一步永远是梳理业务状态。页面再多核心状态迁移的数量也有限。公开招标的项目记录要经历草稿、审批中、已发布、接收投标、截止、开标完成、评标中、定标完成。其中“截止”节点最容易出错如果投标窗口的时间逻辑写在前端用户把本地时钟改慢就能延迟投递这是合规漏洞。状态机必须放服务端用一次服务端判断确保投标窗口的权威性。现实中很多源码把状态做成普通字符串到处用 if 判断一旦流程增加“作废”或“重新招标”就会改出问题。我一般会把状态位收敛成 Java 枚举枚举里明确每个状态允许进入的动作并在 Service 层做统一校验。这样做的好处是写接口的人不需要关心整条流程只用调一个方法就能完成状态迁移。2.1 招标、投标、开标、评标的环节拆解公开招标主链路是公告草稿 → 审批中 → 已发布 → 接收投标 → 截止 → 开标完成 → 评审中 → 定标。投标截止后又常见“流标”和“重新招标”两个分支它们本质上是新的流程实例而不是在旧状态上随便改标识。一个项目从发布到定标的常用状态集中在下表。业务动作前置状态目标状态必须校验的内容招标人撤回公告已发布撤回中→已撤回走审批流且无已提交标书供应商投标接收投标待确认→已提交未过截止时间、文件摘要一致定时任务关闭接收投标截止数据库时间 ≥ bid_end_time专家提交评分评审中已完成当前专家未重复提交、任务未锁定撤回公告这个动作最容易做错。很多代码直接把项目状态从“已发布”改成“已作废”跳过了审批。如果供应商已经买了标书甚至已经制作完文件这种静默变更会引起投诉。所以我建议撤回也必须走审批单审批通过后才进入“已撤回”并且要在公告栏留一条操作日志。2.1.1 状态字段用整数还是用枚举数据库里我建议存 int 或 varchar 状态码Java 层用 enum 做映射不把枚举名字直接写进库避免改字段名导致历史数据失效。比如 BiddingStatus 枚举里的 RECEIVING 对应状态码 30DB 里存 30返回前端时再翻译成中文。这样评审专家看历史数据不用关心代码改名报表统计也能直接用状态码过滤。另一个容易忽略的是“撤回”状态的独特性。撤回必须新增独立状态码而不是复用“作废”。作废通常表示项目本身有问题撤回则可能是采购计划调整。二者在后续审计里的含义不同混用会导致统计口径混乱。2.2 招投标管理的核心数据表与 DDL 设计围绕“一个项目、多个供应商、一次评审”这条主线最少要建四张表招标项目表、供应商信息表、投标记录表、评审打分表。下面是 MySQL 8.x 建表的可复用写法核心是状态码、时间边界和唯一索引。CREATE TABLE tender_project ( id BIGINT AUTO_INCREMENT PRIMARY KEY, tender_no VARCHAR(32) NOT NULL COMMENT 招标编号, tender_name VARCHAR(200) NOT NULL COMMENT 项目名称, category_code VARCHAR(32) COMMENT 采购目录编码, budget_amount DECIMAL(16,2) COMMENT 预算金额, bid_start_time DATETIME COMMENT 投标开始时间, bid_end_time DATETIME COMMENT 投标截止时间, state TINYINT NOT NULL DEFAULT 0 COMMENT 状态0草稿 10审批 20发布 30接收 40截止 50评审 60定标, creator_id BIGINT COMMENT 创建人ID, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, KEY idx_tender_no (tender_no), KEY idx_state_end_time (state, bid_end_time) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;idx_state_end_time这个联合索引是给定时任务用的扫描“当前处于接收状态且已到截止时间”的批量记录时可以走索引下推。tender_no要加唯一索引一个招标编号就是一则公告的对外凭证同一编号反复发布会造成供应商困惑。时间字段比状态字段更容易在业务上出错。投标截止时间是严格业务时间不能用应用服务器本地时间要统一用数据库时间或 NTP 同步过的服务器时间。开发环境单机无所谓到了多节点部署每台机器的时钟漂移会导致同一秒内有的节点允许投递、有的拒绝。2.2.1 投标记录与文件摘要在线投标的常见实现是前端先把标书传到文件服务拿到 file_token 后调投标签收接口。投标记录表里存文件元数据和 SHA-256 摘要摘要由前端计算、随参数提交服务器收到文件后再次计算对比不一致直接拒绝。这样即使传输过程被改动也能在落库前发现。CREATE TABLE bid_record ( id BIGINT AUTO_INCREMENT PRIMARY KEY, bid_no VARCHAR(32) NOT NULL COMMENT 投标单号, tender_id BIGINT NOT NULL COMMENT 项目ID, supplier_id BIGINT NOT NULL COMMENT 供应商ID, file_token VARCHAR(128) NOT NULL COMMENT 文件服务返回的Token, file_sha256 CHAR(64) NOT NULL COMMENT 文件SHA-256摘要, total_price DECIMAL(16,2) NOT NULL COMMENT 投标总价, bid_state TINYINT NOT NULL DEFAULT 0 COMMENT 0待确认 10已提交 20已撤标 30失败, submit_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT 服务器接收时间, UNIQUE KEY uk_tender_supplier (tender_id, supplier_id), KEY idx_supplier (supplier_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;uk_tender_supplier这个唯一索引用来防重同一供应商对同一项目只能有一条有效投标记录。投标文件不要直接塞进 MySQL BLOB 字段几十上百 MB 的文件会拖垮备份和查询。文件放对象存储或本地磁盘MySQL 只存元数据这是招投标系统里最值得坚持的一条设计。2.3 从源码判断业务成熟度的三个检查点拿到别人的 zip 源码后我一般只翻三个地方第一看状态流转有没有集中配置第二看投标文件怎么校验第三看评分是前端算还是后端算。如果打分逻辑出现在 JavaScript 里基本不能用于正式评审——登录用户改一下请求参数分数就变了。成熟源码通常还会包含操作日志表记录谁在什么时间把状态从 A 改成 B。没这个表审计和追溯都会很被动。最后补充一点真正上线还要把供应商信息表、保证金账户表、黑名单表加进去招标主表和投标记录表只是骨架。3. 用 Spring Boot 搭招投标工程骨架模块划分与认证隔离3.1 先按业务边界拆 Maven 模块一套可维护的招投标系统最怕所有代码堆在一个模块。我一般按业务域拆五块common放工具和通用返回体system管用户、权限、组织tender管公告和项目状态review管专家评审和打分file管标书存取和摘要校验。模块之间的关系要单向依赖不能循环引用。模块主要职责依赖关系tender招标公告、撤回、供应商报名system、filereview评审任务、专家打分、汇总tender、systemfile文件上传下载、SHA 校验commonsystem用户、权限、组织commoncommon工具类、统一结果体、异常无创建 Spring Initializr 工程时父 pom 的 packaging 要设为 pomMapperScan的包路径写精细不要扫整个应用根路径。否则多个模块的 Mapper 接口被同时扫描分页插件或事务管理器会抓到不想管的对象表现出一些很难排查的启动异常。3.1.1 依赖版本选择别全信初始化器Spring Boot 3.2 之后默认要求 JDK 17MyBatis-Plus 也要用适配 Boot 3 的版本。老教程里的mybatis-plus-boot-starter在 Boot 3 下常报数据源初始化错误。如果源码里是 Java 8 的老 pom我建议不要直接升级而是新建一个 Boot 3 工程把业务代码迁过去比在旧依赖上打补丁快。看过 MyBatis 源码的人会发现Mapper 注册时 statement id 是包名加方法名拼出来的。模块拆分后两个模块如果出现同名接口且包名前缀相同运行时会有冲突。所以分模块之前要先把com.company.tender.mapper和com.company.review.mapper这类基础包名固定下来后面迁移会省很多事。3.2 用 Spring Security JWT 做角色隔离招投标平台至少有三类账号招标人、供应商、评审专家。评审专家在评审期间不能看到供应商名称否则评分会带人情分。源码里如果只有一张 user 表加一个 role 字段这类需求基本做不彻底。我一般把“登录用户”和“业务角色”拆开用户主表存账号和密码供应商、专家、招标人各自有扩展表通过 user_id 关联。JWT 生成时把用户 ID 放 subject角色和供应商 ID 放进 claims。这样后续 Service 层可以直接从安全上下文拿业务维度不必每层接口都回查数据库。但要注意 token 里的旧角色信息不会实时失效所以过滤器里仍要查一次用户状态。String token Jwts.builder() .subject(String.valueOf(user.getId())) .claim(role, user.getBizRole()) .claim(supplierId, user.getSupplierId() null ? : user.getSupplierId()) .issuedAt(new Date()) .expiration(new Date(System.currentTimeMillis() 7200 * 1000)) .signWith(secretKey) .compact();这段代码里的 claim 是自定义声明supplierId在供应商账号里才存在专家和招标人为空字符串。注意生成的过期时间不能太长。招投标业务里专家临时被调离项目最长半天就要生效token 过期时间超过一天就有权限残留风险。3.3 专家的屏蔽逻辑放在数据层“评审专家看不到供应商名称”这个需求不能在 Controller 返回后再删字段。评分要按供应商维度汇总页面又不能显示名称常见做法分两段专家端查询接口只返回标书记录 ID、项目和评分输入框不 join 供应商表到了汇总环节再按标书 ID 关联供应商信息计算总分并进入公示。数据在源头就没出库是最可靠的隔离方式。一个很容易被忽略的细节是评审列表里经常夹带“标书附件下载”按钮但附件文件名的元数据里可能包含供应商简称。文件下载接口要单独做权限校验只允许评审专家下载并且下载时把文件名改成随机串。否则数据层隐藏了名称文件名又把信息泄漏出去了。4. 投标提交、文件校验与评分汇总核心 Service 怎么实现4.1 投标提交接口的防并发实现在线投标的接口要防三件事重复提交、截止后提交、并发下状态判断失效。重复提交靠数据库唯一索引兜底截止判断靠数据库时间并发下状态判断靠悲观锁串行化。下面是一段我在单体项目里常用的投标签收实现。Transactional(rollbackFor Exception.class) public BidSubmitResult submitBid(BidSubmitRequest req) { // 1. 悲观锁锁住项目行防止并发下两个请求同时读到“接收中”状态 TenderProject project tenderProjectMapper.selectByIdForUpdate(req.getTenderId()); if (project.getState() ! BiddingStatus.RECEIVING.getCode()) { throw new BizException(当前不在投标接收期); } // 2. 截止时间以数据库时间为准 if (project.getBidEndTime().isBefore(LocalDateTime.now())) { throw new BizException(投标已截止); } // 3. 先写待确认记录文件异步校验通过后再置为已提交 BidRecord record new BidRecord(); record.setTenderId(project.getId()); record.setSupplierId(req.getSupplierId()); record.setFileToken(req.getFileToken()); record.setFileSha256(req.getFileSha256()); record.setTotalPrice(req.getTotalPrice()); record.setBidState(BidState.PENDING_CONFIRM.getCode()); bidRecordMapper.insert(record); return new BidSubmitResult(record.getId()); }这段代码里有三个关键决策。第一是selectByIdForUpdate用行锁把同一项目的投标操作串行化两个供应商同时提交时后一个会等前一个事务结束再判断当前状态。第二是截止时间主体是数据库时间前端传什么时间都不参与判定。第三是投标记录先落为“待确认”异步校验通过后才变成“已提交”给系统留了补偿空间。4.1.1 并发高峰下的锁粒度行锁看起来稳定但如果投标截止前半小时有大量供应商同时点击提交同一个项目 ID 会被锁成单车道。对大体量项目我一般把锁从“项目行锁”调整为“只锁投标记录插入”先大胆插入靠唯一索引去重再用一条 update 语句把状态从前置位推向下一位。这种方式对状态机的严谨度要求更高二次开发时一定要注意回滚。投标状态迁移过程中出现的异常场景建议按下表预埋处理逻辑。投标状态触发动作补偿策略待确认文件异步校验失败状态置为失败允许供应商重新上传已提交供应商确认标书只允许提交撤标申请不允许修改价格截止定时任务批量置位后续提交直接拒绝并记录请求日志4.2 专家评分汇总权重计算不要留在前端每一份标书通常分技术分和商务分。技术分由评委打分商务分根据报价计算两者按权重合并。这些权重必须保存在后端配置表里由系统管理员维护专家端只能提交明细分数。前端只展示比例不参与计算。技术分常见的算法是去掉最高最低再取平均防止个别极端分数主导结果。商务分则要根据基准价计算常见口径是“评标基准价 所有有效报价均值”也有地方用“均值下浮一定比例”。这两种口径必须做成可配置项不能写死在代码里。public BigDecimal calcTechScore(ListBigDecimal scores) { if (scores.size() 3) { return scores.stream() .reduce(BigDecimal.ZERO, BigDecimal::add) .divide(BigDecimal.valueOf(scores.size()), 2, RoundingMode.HALF_UP); } return scores.stream() .sorted() .skip(1) .limit(scores.size() - 2L) .reduce(BigDecimal.ZERO, BigDecimal::add) .divide(BigDecimal.valueOf(scores.size() - 2L), 2, RoundingMode.HALF_UP); }sorted()对 BigDecimal 自然排序从低到高跳过第一条和最后一条后取中间值求和。注意要在最终均值时才做两位小数截断如果每个分数都先四舍五入再加总累计误差可能在排名边缘造成名次翻转这在评审结果里是非常严重的口径问题。4.3 评分落库与任务状态的一致性专家提交评分时要同时更新评审任务表里的完成人数。当完成人数等于应到评委数自动触发一次汇总计算。这里建议用一条 SQL 原子更新不要先查完成人数再在 Java 里判断否则两个请求同时提交最后两份评分会出现重复汇总。评分汇总完成后要额外生成一条定标记录保存每个供应商的分项明细和最终排名而不是只更新项目状态。因为评审结果要公示公示期间数据不能随源表变动。把快照独立存下来后续投诉复核时也可以直接查这份快照。5. 上线前压测、慢 SQL 排查与 zip 源码改造后的验收技巧5.1 投标高峰的异步化改造招投标系统真正的流量高峰只在投标截止前几小时。文件上传不要做同步转码和同步校验建议引入消息队列把“文件转存 SHA 校验 结果通知”拆成异步消费。单体项目不想引入中间件也可以用 Spring 的Async加数据库待处理标记表每五分钟扫一次重试。RocketMQ 或 RabbitMQ 能彻底解决削峰但部署成本高任务表实现简单可靠投递要靠补偿机制兜底。我建议前期先上数据库任务表观察一段时间的积压量再决定要不要上队列。5.2 压测时重点看三个指标第一是投标接口的 95 分位延迟第二是文件服务的内存占用第三是评分汇总 SQL 的执行计划。压测要走完整鉴权链路不能绕过 Spring Security 直接调 Mapper 方法否则压出来的数据没有参考价值。ab -n 20000 -c 200 -T application/json -p bid.json http://localhost:8080/api/bid/submit-n是总请求数-c是并发连接数-p指定 POST body 文件。压测时观察 ab 输出的95%指标正常情况下应该低于 300ms。如果超过 1s先去看投标接口里是否有文件流处理逻辑阻塞了业务线程。5.3 慢 SQL 排查与 EXPLAIN 的查看要点压测后开启慢查询日志把超过 1 秒的 SQL 全部抓出来。SET GLOBAL slow_query_log ON; SET GLOBAL long_query_time 1;之后再执行SHOW VARIABLES LIKE slow_query_log_file;拿到日志路径。招投标系统里最常见慢查询有两类一类是查某供应商历史投标次数另一类是按时间区间聚合公告报表。前者建(supplier_id, tender_id)联合索引后者把统计逻辑迁移到天级汇总表避免在主表上跑聚合。5.4 一套低成本高覆盖的验收流程拿到 zip 包导入工程后用三个供应商账号、三个专家账号跑完整链路发布公告、两家供应商投高低价、专家提交一个 100 分和一个 20 分的极端分然后观察三点投标截止后 1 秒内提交是否被拒开标前专家端能否看到任何供应商名称最终中标人是否按权重规则算出来。这套操作比读十遍源码更能暴露状态机的漏洞。下载的压缩包如果解压时提示invalid zip archive: could not find eocd先别怀疑工具优先考虑文件是否下载完整从源头重新获取即可。源码跑通后把操作日志和 requestId 贯穿整个调用链首次全流程跑通时保存一份时间轴记录后续每次改动只需要对比时间轴上的状态变更点和耗时就能快速定位是哪一步拖慢了整个招投标链路。本文还有配套的精品资源点击获取
返回列表