基于Java的招标管理系统实战:状态机、并发控制与POI导出
发布时间:2026/9/29 19:35:33来源:尧图网络
简介面向Java毕业设计与课设场景的招标管理系统基于Java语言与Spring框架构建覆盖招标公示、投标公示、招标发布、服务商管理等核心业务适合需要完成毕设论文或学习企业级Web开发的学习者。资源包共372个文件约65.48MB其中java源码与jsp页面展示业务逻辑和前端交互class编译文件与jar依赖库支撑项目运行xml、properties等配置文件帮助理解Spring的IoC与AOP机制js/css则可借鉴前端设计。通过阅读源码和配置可清晰掌握MVC分层架构、数据库表设计以及前后端联调流程。已有302人浏览学习。内容提供完整可运行的项目工程既能作为毕业设计论文的实践支撑又能在此基础上扩展创新功能如招标数据分析和审核自动化对提升综合开发能力很有帮助同时也是重复率较低、便于二次开发的毕业设计选题。1. 从公告发布到中标归档基于 Java 的招标管理系统到底在管什么去年我在一家做企业采购服务的公司帮客户搭内部系统运营总监周三晚上十一点发来语音招标公告里评分条款打错了明天早上八点开标两百多家供应商已经下载了标书。这种事故在纯靠 Excel 加 QQ 群管理的招标流程里几乎每周都会发生。基于 Java 的招标管理系统要解决的就是把公告发布、供应商报名、评标打分、中标结果归档这一整条链路从纸质和表格里搬进系统用状态机约束流程、用角色权限隔离数据让每一步操作都留下记录。这套系统特别适合两类人一是做 Java 毕业设计、需要完整业务闭环和论文素材的学生二是企业内部想低成本落地招标流程的团队。下面从数据库设计讲起落到 Spring Boot 实现、POI 导出和真实踩坑全程给出可抄的代码。2. 核心数据模型与状态机先让流程在数据库里闭环2.1 五张核心业务表从招标项目到供应商报名招标管理系统的复杂度不在代码而在表结构。表设计一旦没想清楚后面加字段、改状态、做统计都会变成灾难。我的做法是先定五张核心表招标项目表、投标报名表、评标记录表、中标结果表、流转日志表外加用户、角色、权限这三张权限表。业务围绕这五张表转动权限围绕那三张表隔离。先看招标项目表这是整个系统的主表几乎所有流程都挂在它下面CREATE TABLE tender_project ( id bigint NOT NULL AUTO_INCREMENT COMMENT 主键, project_no varchar(32) NOT NULL COMMENT 招标编号全局唯一, project_name varchar(128) NOT NULL COMMENT 项目名称, content_desc text COMMENT 招标内容说明, budget_amount decimal(12,2) DEFAULT NULL COMMENT 预算金额, publish_time datetime DEFAULT NULL COMMENT 公告发布时间, end_time datetime DEFAULT NULL COMMENT 报名截止时间, open_time datetime DEFAULT NULL COMMENT 开标时间, status tinyint NOT NULL DEFAULT 0 COMMENT 状态0草稿 1已发布 2报名截止 3评标中 4已完成 5流标, create_by bigint NOT NULL COMMENT 创建人ID, create_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, update_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, version int NOT NULL DEFAULT 0 COMMENT 乐观锁版本号, PRIMARY KEY (id), UNIQUE KEY uk_project_no (project_no), KEY idx_status (status), KEY idx_create_by (create_by) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT招标项目表;这里有两个点必须注意project_no要建唯一索引因为招标编号是业务上的天然主键系统里多人协作时很容易重复生成version字段是给乐观锁用的后面讲并发报名时会反复用到它。status字段用 tinyint 而不是 varchar目的是省空间、查询快状态含义在 Java 枚举里统一管理不要散落在业务代码各处。投标报名表是第二张关键表。一个供应商对同一个项目只能报名一次这个约束必须由数据库兜底不能只靠代码判断CREATE TABLE tender_bid ( id bigint NOT NULL AUTO_INCREMENT, project_id bigint NOT NULL COMMENT 项目ID, supplier_id bigint NOT NULL COMMENT 供应商用户ID, bid_file_url varchar(256) DEFAULT NULL COMMENT 标书文件路径, bid_amount decimal(12,2) DEFAULT NULL COMMENT 投标报价, score decimal(5,2) DEFAULT NULL COMMENT 评标得分评标阶段回填, rank_no int DEFAULT NULL COMMENT 排名, status tinyint NOT NULL DEFAULT 0 COMMENT 0已报名 1入围 2中标 3未中标, create_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_project_supplier (project_id, supplier_id), KEY idx_supplier_id (supplier_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT投标报名表;uk_project_supplier这个唯一索引是防重复报名的最后一道防线。即使代码里有并发漏洞数据库层面也会把第二条插入直接拒绝。评标记录表存每个专家对每个供应商的维度打分中标结果表存最终中标供应商和成交金额流转日志表记录每一次状态变更的操作人、变更前后值和时间这三张表结构相对简单但索引设计同样遵循业务查询字段优先的原则。状态码在设计初就要固定下来前后端和数据库用同一套数字这里给出我常用的对照关系状态值业务含义可执行操作0草稿编辑、删除、发布1已发布报名、截止报名、流标2报名截止开始评标、流标3评标中录入评分、完成评标4已完成查看归档、重新招标5流标编辑后重新发布、归档2.2 状态流转不是改个字段状态机校验与流转日志新手最容易犯的错误是把状态流转做成一个update接口前端传什么值就改什么值。这样做的后果是草稿状态的项目可以直接被改成已完成评标中的项目突然回到报名截止数据全乱论文答辩时一问就露馅。正确做法是在 Service 层加一个状态机校验非法流转直接抛异常。我用 Java 枚举加一个Map来定义合法流转关系代码短、易维护public enum TenderStatus { DRAFT(0, 草稿), PUBLISHED(1, 已发布), BIDDING_CLOSED(2, 报名截止), REVIEWING(3, 评标中), COMPLETED(4, 已完成), FAILED(5, 流标); private final int code; private final String desc; private static final MapTenderStatus, SetTenderStatus ALLOWED_TRANSITIONS new EnumMap(TenderStatus.class); static { ALLOWED_TRANSITIONS.put(DRAFT, EnumSet.of(PUBLISHED, FAILED, DRAFT)); ALLOWED_TRANSITIONS.put(PUBLISHED, EnumSet.of(BIDDING_CLOSED, FAILED)); ALLOWED_TRANSITIONS.put(BIDDING_CLOSED, EnumSet.of(REVIEWING, FAILED)); ALLOWED_TRANSITIONS.put(REVIEWING, EnumSet.of(COMPLETED, FAILED)); ALLOWED_TRANSITIONS.put(COMPLETED, EnumSet.of()); ALLOWED_TRANSITIONS.put(FAILED, EnumSet.of(DRAFT, PUBLISHED)); } public boolean canTransitionTo(TenderStatus target) { SetTenderStatus allowed ALLOWED_TRANSITIONS.get(this); return allowed ! null allowed.contains(target); } }每次变更状态前调用一次校验方法不通过就抛业务异常。这里要注意一个细节FAILED状态允许回到DRAFT或PUBLISHED对应的是流标后重新招标这个真实业务场景很多系统把这个流转漏掉导致项目一旦流标就永远卡死。DRAFT允许转到自身是为了支持保存草稿这种重复操作。状态校验只是第一步流转日志才是论文里的亮点。每当状态变更同时插入一条日志记录格式类似将项目从 [草稿] 变更为 [已发布]操作人admin。这个日志表不光是为了审计也是答辩时展示系统完整性的重要功能点。我给状态变更方法套上Transactional保证状态更新和日志插入要么都成功、要么都回滚绝不允许出现状态改了但日志没记上的情况。3. 基于 Spring Boot 把招标流程跑起来从依赖到三个核心接口3.1 项目骨架与 RBAC 权限模型为什么必须用注解做接口隔离这套系统我用 Spring Boot 加 MyBatis-Plus 落地。如果你的课题指定 SSMSpring SpringMVC MyBatis核心逻辑完全一样把自动配置换成手动注入即可。先看pom.xml的核心依赖需要注意的依赖都加了注释dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-security/artifactId /dependency dependency groupIdcom.baomidou/groupId artifactIdmybatis-plus-boot-starter/artifactId version3.5.3/version /dependency dependency groupIdmysql/groupId artifactIdmysql-connector-java/artifactId scoperuntime/scope /dependency !-- POI 用于导出 Word/Excel版本选 4.1.2 或 5.x -- dependency groupIdorg.apache.poi/groupId artifactIdpoi-ooxml/artifactId version5.2.3/version /dependency dependency groupIdorg.projectlombok/groupId artifactIdlombok/artifactId optionaltrue/optional /dependency注意poi-ooxml版本不要和项目里的其他依赖冲突4.1.2 和 5.2.3 这两个版本我都在项目里用过5.x 对 JDK 版本要求更高如果跑在 JDK8 上选 4.1.2 更稳。Spring Security 是必须的不要因为它配置麻烦就省掉后面讲的数据权限和接口防越权全靠它。权限模型用经典 RBAC用户表、角色表、权限表中间各挂一张关联表。角色定了三个ADMIN系统管理员、EXPERT评标专家、SUPPLIER供应商。为什么权限要拆这么细因为招标场景里专家的权限非常敏感——专家只能看到自己负责评标的项目绝不能看到供应商的报价信息否则就有串通风险。Spring Security 的配置核心是开启方法级权限注解然后在 Controller 或 Service 上直接控制Configuration EnableWebSecurity EnableGlobalMethodSecurity(prePostEnabled true) public class SecurityConfig { Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http.csrf().disable() .authorizeHttpRequests(auth - auth .requestMatchers(/api/auth/**, /api/tender/project/list).permitAll() .anyRequest().authenticated() ) .formLogin().disable(); return http.build(); } }配置里有两点值得解释。csrf().disable()在前后端分离接口模式下可以关掉因为接口用 JWT 或 Token 校验身份不需要 CSRF Token但如果你做的是服务端渲染的 JSP 页面这个必须保留。requestMatchers里放行的接口要刻意收紧——只有招标公告列表这种公开信息允许匿名访问报名、评标等接口一律走认证。3.2 发布招标与报名接口唯一索引、乐观锁和事务缺一不可发布招标公告的逻辑不复杂校验参数后把状态从草稿变成已发布。真正考验功底的是报名接口它同时涉及并发和事务。报名时要做三件事校验项目确实处于已发布状态、插入投标报名记录、如果报名人数达到上限则把项目状态推到报名截止。这三步必须在一个事务里且要防并发。看报名接口的 Service 实现核心代码Transactional(rollbackFor Exception.class) public BidRecord submitBid(BidSubmitRequest req, Long supplierId) { // 1. 查项目必须存在且状态为 PUBLISHED TenderProject project projectMapper.selectById(req.getProjectId()); if (project null) { throw new BizException(项目不存在); } if (project.getStatus() ! TenderStatus.PUBLISHED.getCode()) { throw new BizException(项目当前不在报名期); } // 2. 乐观锁更新状态前先检查版本号version 字段自动带上 int updated projectMapper.update(null, new LambdaUpdateWrapperTenderProject() .eq(TenderProject::getId, project.getId()) .eq(TenderProject::getVersion, project.getVersion()) .set(TenderProject::getVersion, project.getVersion() 1)); if (updated 0) { throw new BizException(项目刚被其他人更新请刷新后重试); } // 3. 插入报名记录数据库唯一索引兜底防重复 BidRecord bid new BidRecord(); bid.setProjectId(req.getProjectId()); bid.setSupplierId(supplierId); bid.setBidAmount(req.getBidAmount()); // 此处若并发重复插入MySQL 会抛 DuplicateKeyException bidMapper.insert(bid); return bid; }这段代码的逻辑顺序是固定的先查后改再插顺序不能乱。先用乐观锁更新version字段是为了抢占流程控制权——只有抢到版本号的请求才允许继续操作其他并发请求会因为updated 0直接失败。eq(TenderProject::getVersion, project.getVersion())是 MyBatis-Plus 的 Lambda 写法意思是在UPDATE语句的WHERE条件里带上版本号匹配。最后的bidMapper.insert如果撞上唯一索引MySQL 会抛DuplicateKeyExceptionService 层捕获后转成您已报名该项目的友好提示。这里暴露了一个新手普遍忽略的问题很多人只做代码层判断就以为防住了重复实际上两个请求同时通过代码检查、同时执行insert是常事数据库唯一索引才是最后那道锁。我做这套系统时先写了代码判断压测时一秒内并发 50 个报名请求插进去了 5 条重复记录加上唯一索引之后才彻底解决。3.3 评标与中标结果落地分数计算和结果归档评标模块的权限是三个角色里最敏感的。我的实现是专家登录后只能看到分配给自己的项目列表打分时每个维度写入tender_review表分数只在全部专家打完并确认后才会汇总。汇总逻辑用加权平均权重配置存在参数表里这样评标办法变了不用改代码。中标结果生成的核心代码PreAuthorize(hasAnyRole(ADMIN,EXPERT)) public TenderResult generateResult(Long projectId) { // 按总分排序取第一若并列第一按报价低者胜出 ListBidRecord bids bidMapper.selectList(new LambdaQueryWrapperBidRecord() .eq(BidRecord::getProjectId, projectId) .orderByDesc(BidRecord::getScore) .orderByAsc(BidRecord::getBidAmount)); if (bids.isEmpty()) { throw new BizException(该项目无有效投标记录); } BidRecord winner bids.get(0); winner.setStatus(2); // 2中标 bidMapper.updateById(winner); TenderResult result new TenderResult(); result.setProjectId(projectId); result.setSupplierId(winner.getSupplierId()); result.setBidAmount(winner.getBidAmount()); result.setCreateTime(LocalDateTime.now()); resultMapper.insert(result); // 项目转入已完成 changeStatus(projectId, TenderStatus.COMPLETED); return result; }PreAuthorize(hasAnyRole(ADMIN,EXPERT))这行注解直接卡在方法上防止供应商角色通过猜测接口地址调用这个功能。这比在方法内部手动判断角色更安全——就算你写的业务代码里有遗漏的分支注解也会在进入方法前拦截。排序用orderByDesc(score)再orderByAsc(bidAmount)解决分数并列的问题这是评标里最常见的商务规则别漏了。中标结果落库后别忘了配套生成中标通知书这个放到下一章讲 POI 导出时一起处理。4. POI 导出把公告、中标通知书、评标汇总做成能交付的文档4.1 Word 模板导出招标公告XWPFDocument 实操与占位符的坑很多毕设系统做到评标完成就结束了导出功能永远是加分项但它恰恰是答辩时最容易被追问的部分。招标管理系统里最常导出的文档是招标公告、中标通知书和评标汇总表。我的做法是准备 Word 模板用占位符替换的方式生成正式文档而不是在代码里从零创建整个文档——从零创建的样式极丑而且代码量巨大。XWPFDocument 操作 docx 模板的核心逻辑如下public void exportAnnouncement(TenderProject project, String outputPath) throws Exception { // 读取模板文件 try (XWPFDocument doc new XWPFDocument( new FileInputStream(templates/announcement_template.docx)); FileOutputStream fos new FileOutputStream(outputPath)) { // 替换正文段落中的占位符 for (XWPFParagraph paragraph : doc.getParagraphs()) { String text paragraph.getText(); if (text ! null text.contains(${)) { String replaced text .replace(${projectNo}, project.getProjectNo()) .replace(${projectName}, project.getProjectName()) .replace(${openTime}, formatTime(project.getOpenTime())); // 关键先删除原有 Run再新增一个 Run 写入完整文本 for (int i paragraph.getRuns().size() - 1; i 0; i--) { paragraph.removeRun(i); } paragraph.insertNewRun(0).setText(replaced); } } // 替换表格单元格中的占位符 for (XWPFTable table : doc.getTables()) { for (XWPFTableRow row : table.getRows()) { for (XWPFTableCell cell : row.getTableCells()) { if (cell.getText().contains(${budgetAmount})) { cell.removeParagraph(0); cell.setText(String.valueOf(project.getBudgetAmount())); } } } } doc.write(fos); } }这段代码里最值得注意的是先删 Run 再新增 Run的操作。paragraph.getRuns()返回的是一个文本片段列表Word 在渲染时经常把一句话切成多个 Run占位符${projectNo}可能被拆成${proj和ectNo}两段直接对单个 Run 做replace会替换不干净。删掉所有 Run 再插入一个完整 Run是绕过这个问题的通用做法。我在第一个版本就是直接遍历 Run 替换结果导出的文档里残留了一半占位符后来改成整段重建才正常。formatTime是我自己写的日期格式化方法统一输出yyyy-MM-dd HH:mm:ss格式避免不同环境下时间格式不一致。4.2 Excel 评标汇总与图表POI 到底能不能生成图表搜索java poi word 能生成图表吗的人特别多我直接回答POI 的 XWPFChart 和 XSSFChart 确实支持在文档里插入柱状图、折线图但 API 设计得非常别扭——必须先往模板里埋一个图表对象再通过XWPFChart去修改它的数据纯代码从零创建图表几乎不可用。我的建议是毕设里需要图表的地方用 Excel 导出加普通数据表格图表部分用前端 ECharts 展示比在 Word 里塞图表稳得多。Excel 导出评标汇总用SXSSFWorkbook这是 POI 提供的流式写入版本专为大文件设计。普通XSSFWorkbook会把所有行都放在内存里导出几千行没问题导出几万行直接 OOMpublic void exportReviewSummary(Long projectId, HttpServletResponse response) throws Exception { // 100 表示内存中最多保留 100 行其余写入临时文件 SXSSFWorkbook workbook new SXSSFWorkbook(100); SXSSFSheet sheet workbook.createSheet(评标汇总); // 表头 String[] headers {排名, 供应商名称, 报价, 商务分, 技术分, 总分}; SXSSFRow headerRow sheet.createRow(0); for (int i 0; i headers.length; i) { headerRow.createCell(i).setCellValue(headers[i]); } // 查询评标结果按总分倒序 ListBidRecord bids bidMapper.selectList(new LambdaQueryWrapperBidRecord() .eq(BidRecord::getProjectId, projectId) .orderByDesc(BidRecord::getScore)); int rowNum 1; for (BidRecord bid : bids) { SXSSFRow row sheet.createRow(rowNum); row.createCell(0).setCellValue(rowNum - 1); // 排名 row.createCell(1).setCellValue(bid.getSupplierName()); // 供应商名称 row.createCell(2).setCellValue(bid.getBidAmount().doubleValue()); row.createCell(3).setCellValue(bid.getBizScore().doubleValue()); row.createCell(4).setCellValue(bid.getTechScore().doubleValue()); row.createCell(5).setCellValue(bid.getScore().doubleValue()); // 自动调整列宽 sheet.autoSizeColumn(i, true); } // 表格样式数据行加边框总分列加粗 CellStyle style workbook.createCellStyle(); style.setBorderTop(BorderStyle.THIN); style.setBorderBottom(BorderStyle.THIN); style.setBorderLeft(BorderStyle.THIN); style.setBorderRight(BorderStyle.THIN); response.setContentType(application/vnd.openxmlformats-officedocument.spreadsheetml.sheet); response.setHeader(Content-Disposition, attachment; filenamereview.xlsx); workbook.write(response.getOutputStream()); workbook.dispose(); // 释放临时文件 }new SXSSFWorkbook(100)的100是窗口大小——内存里只维护最近 100 行更早的行会刷到磁盘临时文件这是大数据量导出的关键参数。导出到浏览器时不要用FileOutputStream再手动传文件直接workbook.write(response.getOutputStream())输出流省一层磁盘 IO。autoSizeColumn在数据量大的时候会比较慢它要统计每列最大宽度只对表头调用即可。最后务必调用dispose()清理临时文件不然服务器 /tmp 目录会被撑爆。4.3 导出参数与格式边界版本兼容、字体和数字格式POI 一个最大的隐性边界是文件格式版本。XWPFDocument只能操作.docxOOXML 格式对老式的.doc文件没有任何办法——那是另外一套二进制格式需要HWPF支持但 HWPF 的 API 和文档都极其残缺。所以模板文件一定要用.docx用 WPS 或 Word 另存为时注意选择格式。还有一个常见翻车点是字体丢失。模板文件在 Windows 上用宋体、仿宋_GB2312排版部署到 Linux 服务器后导出的文档在浏览器下载再打开字体全部变成默认等线体。原因很简单服务器操作系统没有安装这些中文字体POI 写入的是字体名称渲染时系统找不到就回退。解决方案是在服务器上安装字体包或者模板里统一用微软雅黑宋体这类跨平台兼容性更好的字体。这个坑属于典型的本地好好的上服务器就变丑提前知道能省半天排查时间。5. 常见问题与避坑六条实测记录从并发重复到时间错乱5.1 报名接口被并发打穿重复数据是怎么进来的现象压测时 50 个并发报名请求数据库里插入了 6 条相同project_id supplier_id的记录。原因Service 层先查再插的逻辑里存在时间窗口两个请求同时通过未报名检查同时执行 insert代码层面的判断形同虚设。解决加数据库唯一索引uk_project_supplier并在 Service 层捕获DuplicateKeyException转成业务提示。从那以后我对防重复的要求变成了代码判断 数据库约束双层兜底任何一层失效另外一层还能挡住。5.2 导出 1 万行评标记录导致内存溢出现象评标记录导出功能在测试环境数据量小没问题生产环境导 1 万行评标明细JVM 直接OutOfMemoryError。原因用了XSSFWorkbook所有行都在内存里创建对象再写入1 万行乘以每行 10 个单元格内存轻松冲上几百 MB。解决换成SXSSFWorkbook(100)内存里只留 100 行其余落临时文件实际内存占用下降了 90%。如果你还要导出包含大量图片的标书文件则不能走 POI改成文件流直接下载不要文档化。5.3 时间差八小时LocalDateTime 与 MySQL 时区现象系统在本地运行一切正常部署到云服务器后所有报名截止时间都比正常时间早了 8 小时。原因MySQL 连接串里的serverTimezone没配默认用了服务器的 UTC 时区而 Java 侧的LocalDateTime不带时区概念数据库存进去的时间被 MySQL 做了时区转换。解决JDBC 连接串显式加serverTimezoneAsia/ShanghaiuseLegacyDatetimeCodefalse同时数据库连接的setCharacterEncodingutf8mb4也要带上否则中文乱码和时间错乱会一起出现。检查方法很简单SELECT NOW()看数据库当前时间对不对。5.4 状态被绕过UPDATE 直接把草稿改成已完成现象测试人员用接口工具直接调updateProject接口传入status4草稿项目直接变成已完成状态机形同虚设。原因状态变更逻辑写在 Controller 里的普通 update 方法上任何知道接口路径的人都可以直接改状态。解决把所有状态变更收敛到一个changeStatus方法方法内强制走枚举校验并且只允许通过POST /api/tender/project/status这一个接口提交其他接口一律不暴露status字段的更新能力。这个坑暴露了一个设计原则业务状态流转是操作而非字段赋值只能通过专用接口触发。5.5 Word 模板导出后打不开或提示文件损坏现象导出的中标通知书 docx 文件在本地用 WPS 打开提示文件损坏但用 Office 却正常。原因模板文件本身是 WPS 生成的.doc但扩展名改成了.docxPOI 读的是内部结构写入后文件格式就乱了也可能是模板里存在域代码、书签等复杂元素POI 写入时破坏了结构。解决用 Word 软件重新另存为干净的.docx模板模板里不要放页眉页脚里的动态域如果业务必须保留复杂页眉则模板简化成主体内容页眉在导出后用程序再添加一次。5.6 权限注解失效为什么供应商还能调用评标接口现象PreAuthorize注解加上了但供应商角色登录后仍然能调用评标结果生成接口。原因EnableGlobalMethodSecurity(prePostEnabled true)忘了开注解只是装饰品另一个常见原因是 SecurityFilterChain 里把接口误放行了。解决确认配置类里的prePostEnabled为true然后在对应接口打上PreAuthorize(hasRole(SUPPLIER))反向验证——如果供应商身份反而被拦截说明配置已生效。注解失效是 Spring Security 使用里最隐蔽的问题排查顺序固定先看配置类再看角色名是否带ROLE_前缀最后看用户权限是否真的被加载。6. 进阶让招标系统扛住真实场景的三个加固习惯6.1 慢查询定位与索引补充先看日志再建索引系统上线后不要急着加索引先打开慢查询日志看真实数据。MySQL 开启方式一行命令mysql -uroot -p -e set global slow_query_logON; set global long_query_time1;long_query_time1表示超过 1 秒的 SQL 会被记录。跑两天业务后查看慢日志通常暴露的是两类问题一类是tender_bid表按supplier_id查报名记录时全表扫描另一类是评标汇总查询在project_id status上没有组合索引。针对前者加普通索引针对后者加组合索引idx_project_status(project_id, status)这比盲目给所有字段都加索引高效得多因为索引也是存储空间。6.2 数据一致性事务、乐观锁、幂等键三项缺一不可这套系统里最容易出现数据不一致的地方是报名和评标结果生成。我的习惯是给每个写操作定义三个层面的保护Transactional保证原子性乐观锁version字段防止并发覆盖幂等键防止重复提交。幂等键的实现思路是前端生成一个request_id后端在收到请求时先查这个request_id是否已处理过处理过就直接返回上次结果。这个机制在供应商重复点击提交报名按钮时特别有用比单纯防重名来得彻底。6.3 一套可落地的自测清单半小时检查到位检查项操作方法预期结果状态机防护用草稿项目直接调完成接口被拦截提示非法流转越权访问供应商 Token 调评标接口403权限注解生效并发报名10 线程同时报名同一项目仅 1 条成功其余提示已报名时间显示比较接口返回时间与本地时间一致无 8 小时偏移导出内存导出 1 万行汇总观察 JVM 内存内存平稳无 OOM文档格式下载导出文件用 WPS 和 Office 各打开一次均可正常打开这份清单我在每次交付前都完整走一遍。做这套招标系统时最深刻的教训是功能跑通只是第一步并发、权限和状态机才是系统能不能真正交付的分水岭。毕设答辩时老师也最喜欢从这三个方向追问提前把自测结果整理进论文的测试章节比临时编测试数据有说服力得多。从那以后我每次接新的 Java 管理系统项目第一件事都是先过一遍鉴权配置和状态流转校验确认这两层没有漏洞才开始写业务代码。这套思路和核心代码我都整理进了下载包有需要的同学拿源码后照着这篇文章把表结构和接口跑一遍再结合自己课题的业务细节做扩展会比从零开始省下大量调试时间。希望帮到你。本文还有配套的精品资源点击获取
网站建设高端定制企业官网