做项目申报系统这个活儿最初其实是被人拉去救火的。某单位之前用的是Excel表格加邮件的方式收项目申报材料每到申报季几百份材料在邮箱里堆着文件名五花八门有叫《新建文件夹》的有叫《最终版》的还有叫《最终版2》的。审批人一个个下载、一个个登记眼神都快看瞎了。当时我就想与其这样难受不如做一个正经的前后端分离项目申报系统用SpringBootVueMyBatisMySQL这套组合把申报—审核—归档的流程彻底规范化。这个系统做完以后我再从零搭类似的信息管理类项目基本就是套路活了。这篇文章就把整个项目的设计思路、核心代码、数据库设计和部署流程拆开讲讲适合正在做毕设的、想练手前后端分离的同学也适合公司内部想做一套小系统但预算不多的小团队。全文没有藏私踩过的坑全写出来了。1. 项目申报系统的需求本质与技术选型1.1 申报系统到底在管理什么角色与流程的拆解很多人一听“项目申报系统”就觉得是个CRUD其实不只是。先理清楚它的业务本质后面代码写起来才顺。一个申报系统里有三种人对应三种诉求申报人要能提交项目材料、查看审批进度、收到驳回原因、修改后再次提交。审核人要能看到待审列表、给出通过或驳回意见最好还能分层级审批。管理员要能创建申报批次、设定起止时间、维护用户、导出统计结果。核心流程是一条状态机申报人发起一个项目申请经过初审、终审最后通过归档。每个环节都可能被打回打回之后申报人要重新修改再提交状态又要回到待审状态。这套流程如果只用Excel管理状态一变就全乱套了因为这本质是一个有生命周期的流程不是简单地登记几条记录。所以做这个系统第一件事不是写代码而是把状态流转画清楚。我实际建模的时候整理出来五个核心状态草稿、待初审、初审通过、待终审、终审通过或终审驳回。驳回之后回到草稿或待提交状态重新提交后再次进入审核流程。状态定下来表结构、接口设计就都有了骨架。1.2 为什么是SpringBootVueMyBatisMySQL这套组合这套技术栈在2025年看起来不算花哨但它恰好解决了这类中小型管理系统的全部痛点。我选它不是因为追求新而是求稳。SpringBoot现在几乎就是Java后端的事实标准内嵌Tomcat、自动配置、起步依赖一套下来不需要自己写一堆XML配置。Vue做前端也是同理组件化开发、数据双向绑定写管理端页面效率极高尤其是和Element UI现在更多用Element Plus配合表单、分页、表格这些现成组件直接搬。MyBatis我承认确实要写XML里的SQL比JPA那种自动生成SQL的方式多不少功夫但换来的是对SQL完全可控面试和工作中也最常用尤其适合需要写复杂联表查询的申报统计类需求。MySQL不用多说轻量、免费、可靠管理系统的数据量根本压不垮它。还有一个现实原因这套组合的资料太多踩坑成本极低。我做的时候遇到一个异常搜索一下基本到处都有解决方案这一点在自研项目里能省下大量无效时间。1.3 前后端分离工程的目录结构与协作模式前后端分离不只是把代码放在两个目录里这么简单关键在于开发期各跑各的、联调期接口统一、部署期静态分离。我当时的工程结构是这样的project-application-system ├── backend │ ├── src │ │ ├── main │ │ │ ├── java/com/example/projectapply │ │ │ │ ├── controller │ │ │ │ ├── service │ │ │ │ ├── mapper │ │ │ │ ├── entity │ │ │ │ ├── dto │ │ │ │ ├── config │ │ │ │ └── common │ │ │ └── resources │ │ │ ├── mapper │ │ │ ├── application.yml │ │ │ └── sql/init.sql │ │ └── test │ └── pom.xml ├── frontend │ ├── src │ │ ├── api │ │ ├── router │ │ ├── store │ │ ├── views │ │ ├── components │ │ └── utils │ ├── package.json │ └── vite.config.js └── deploy ├── nginx.conf └── project-apply.sql后端按 controller、service、mapper 三层划分controller只做参数接收和结果包装业务逻辑全部下沉到service层mapper只负责SQL。前端按api、router、views、components、utils划分api目录统一放接口请求方法views放页面组件所有可复用的UI片段抽到components。开发的时候后端跑在8080端口前端Vite开发服务器跑在5173端口通过Vite的proxy代理把请求转发到后端去。部署的时候前端构建成静态文件交给Nginx后端打成jar包用java -jar启动同一个Nginx里既托管前端静态页面又用反向代理转发/api接口。这套协作模式后续所有类似项目我都在复用前端可以独立换人写后端API保持不变就行。2. 后端SpringBoot核心模块精讲2.1 工程初始化与常用配置创建后端工程我用的是Spring Initializr这个官方初始化工具选版本的时候注意一下不用刻意追新。当时用的SpringBoot 2.7.x配合JDK 1.8稳定压倒一切。现在回头看SpringBoot 3.x需要JDK17如果你部署的服务器是老环境很可能不支持所以为了普适性选了2.7版本。依赖方面核心就这几个spring-boot-starter-web、mybatis-spring-boot-starter、mysql-connector-java、lombok。如果要做鉴权和文件上传再额外引入一个JWT库我用的是java-jwt和 spring-boot-starter-validation。application.yml配置是最容易翻车的地方我贴一份实测可用的server: port: 8080 spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/project_apply?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai username: root password: your_password servlet: multipart: max-file-size: 20MB max-request-size: 50MB mybatis: mapper-locations: classpath:mapper/*.xml type-aliases-package: com.example.projectapply.entity configuration: map-underscore-to-camel-case: true log-impl: org.apache.ibatis.logging.stdout.StdOutImpl jwt: secret: my-secret-key-change-it expire-hours: 24这里有一个关键设置map-underscore-to-camel-case: true。意思是数据库里user_name这样的下划线命名自动映射成Java类的userName属性。如果不打开这个开关你会发现查出来的字段全是null排查半天找不到原因。这个坑我踩过印象特别深。数据源URL里serverTimezoneAsia/Shanghai也是必须的否则新版本MySQL驱动会因为时区问题直接报连接错误。如果你用的MySQL是8.x必须带这个参数除非你把驱动降级到5.x版本但我不建议这么做。2.2 用户登录与JWT鉴权的实现思路登录认证我选了JWT方案而不是Session方案。原因很简单前后端分离之后前端可能是PC浏览器也可能是手机端Session天然不适合跨端共享而且Nginx做负载均衡的时候多后端实例的Session同步也是个麻烦事。JWT把用户信息加密存到Token里后端不需要保存会话状态天生就是无状态设计。用户表密码这一块我强烈建议用BCrypt加密。Spring Security里自带BCryptPasswordEncoder如果你没引入Spring Security单独引一个spring-security-crypto依赖也行。密码一定不能明文存数据库这个底线不能破。我见过不少“练手项目”直接把密码存成明文看着人麻了。JWT相关代码一个核心的工具类加上一个拦截器就够Component public class JwtUtil { Value(${jwt.secret}) private String secret; Value(${jwt.expire-hours}) private int expireHours; public String generateToken(Long userId, String username, String role) { long expireMillis expireHours * 3600 * 1000L; return JWT.create() .withClaim(userId, userId) .withClaim(username, username) .withClaim(role, role) .withExpiresAt(new Date(System.currentTimeMillis() expireMillis)) .sign(Algorithm.HMAC256(secret)); } public DecodedJWT verify(String token) { return JWT.require(Algorithm.HMAC256(secret)).build().verify(token); } }然后写一个拦截器去校验请求头里的Authorization字段。放行路径要区分好登录接口、注册接口放行其余接口拦截。拦截到非法Token直接返回401前端收到401就跳转登录页整个流程就闭环了。有一个容易忽略的点拦截器里放行静态资源、OPTIONS预检请求。跨域请求会先发一个OPTIONS如果你拦截器把OPTIONS也拦了前端所有请求都会报跨域错误因为预检没通过。我第一次写这个拦截器就吃了这个亏后来在拦截器开头加了一段if (OPTIONS.equals(request.getMethod())) return true;。2.3 申报批次、申报单与审核流的状态机设计申报系统的核心业务是围绕着申报批次和申报单这两个实体来展开的。申报批次就是管理员创建的一次申报活动比如“2025年第一季度科技创新项目申报”有开始时间、结束时间、申报说明。批次的存在让系统支持多轮申报不用每次改代码。申报单则是用户在某个批次下创建的申请记录包含项目名称、项目负责人、项目类型、预算金额、项目简介等核心字段。每张申报单会产生多条审核记录记录审核人、审核时间、审核意见、审核结果——这一张审核记录表就是审计追踪的关键。我当时的状态机设计是状态值含义可流转到0草稿1提交1待初审2通过或 5驳回2初审通过3提交终审或 5驳回3待终审4通过或 5驳回4终审通过流程结束5已驳回1重新提交状态流转全部在Service层控制不搞花活。比如提交申报单的接口先查当前状态只允许草稿状态或者驳回状态提交否则直接抛异常拒绝操作。这样在代码层面就杜绝了越级操作的可能性。这里有个细节驳回之后我在申报单上记录一个rejectReason字段同时每次通过或驳回都往审核记录表插入一条数据。前端显示进度的时候把审核记录倒序展示出来用户看到的就是一条完整的审核轨迹谁在什么时候说了什么话明明白白。这个设计看似简单但非常提升使用体验。2.4 附件上传与本地存储方案项目申报基本都要带附件申报书、预算表、营业执照扫描件等等。附件上传部分我采用了最简单可靠的方案本地磁盘存储 Nginx静态映射。没有引入OSS因为这个量级完全没必要。后台上传接口用MultipartFile接收文件然后把文件写到配置好的目录里public String upload(MultipartFile file) { if (file.isEmpty()) { throw new BusinessException(文件不能为空); } String originalFilename file.getOriginalFilename(); String ext StringUtils.getFilenameExtension(originalFilename); String newFileName UUID.randomUUID().toString().replace(-, ) . ext; File dir new File(fileStoragePath); if (!dir.exists()) { dir.mkdirs(); } File target new File(dir, newFileName); try { file.transferTo(target); } catch (IOException e) { throw new BusinessException(文件保存失败); } return /uploads/ newFileName; }为什么要用UUID重新命名两个原因。第一避免文件名冲突两个人都传一个叫“申报书.docx”的文件第二个直接把第一个覆盖了第二防止路径穿越攻击原始文件名里如果带../这种路径符号直接拼接路径会出大问题。用户查看附件的时候前端从接口拿到的是/uploads/xxx.docx这样的相对路径部署后由Nginx直接静态托管这个目录。还有一个容易被忽视的点文件类型校验不能只靠前端。前端上传组件限定了扩展名可别人绕过前端直接调接口传一个.exe那就出事了。后台上传接口里要校验扩展名白名单只允许doc、docx、pdf、jpg、png、zip这几个常见格式。别问我是怎么知道要加这个校验的问就是被坑过。3. 前端Vue实现从页面骨架到接口对接3.1 前端工程搭建与目录划分前端我用的Vue 3组合式API Vite构建工具UI组件库选了Element Plus。Vite比起Webpack最大的感受就是快冷启动几乎秒开热更新也跟手。遇到Vue 2的老项目组合式API这套东西你可能得先适应下但本质是一样的ref定义响应式数据computed做计算属性watch监听状态变化setup函数里组织逻辑。我平时写页面基本就这几样不需要什么高深技巧。目录设计上最重要的两个文件夹是api和views。api下按业务模块拆分文件比如user.js放登录注册相关接口project.js放申报单相关接口。所有请求方法统一从utils封装的request模块导出不许页面里直接写axios调用这是规范问题也是后续好维护的关键。3.2 路由权限控制与登录态管理前端路由用Vue Router每个人登录进去看到的菜单应该是不同的。管理员能看到“用户管理”和“批次管理”申报人只能看到“我的申报”审核人看到的是“审核任务”。我的实现分两步第一步后端登录接口返回的JWT里用claim带上角色字段。前端登录成功后把Token和角色信息存到localStorage里方便刷新页面后恢复登录态。第二步路由配置里给需要保护的路由统一加上meta: { requiresAuth: true }然后写一个全局前置守卫router.beforeEach((to, from, next) { const token localStorage.getItem(token) if (to.meta.requiresAuth !token) { next(/login) } else if (to.meta.role to.meta.role ! localStorage.getItem(role)) { next(/403) } else { next() } })这套逻辑简单直接。角色判断虽然做不到后端那种绝对安全但作为前端体验控制已经足够。真正的权限校验一定是在后端接口上做的比如审核接口必须验证当前用户角色是审核人这一点不能只靠前端。3.3 申报表单动态渲染与文件上传申报表单是整个系统交互最密集的地方。项目类型不同表单字段也不同比如“软科学”类项目要填政策依据“硬件研制”类要填设备清单。如果每种类型都单独写一个页面组件后期加一个项目类型就要写一大堆重复代码。我的方案是用动态表单配置。表单定义放在前端一个配置文件里每个字段配置好类型input、textarea、select、datepicker等、校验规则、是否必填。页面渲染时用一个循环组件把配置遍历出来再套上el-form的校验逻辑。文件上传用的是Element Plus的el-upload组件关键在于设置action为后端上传接口的地址同时带上Token请求头。上传成功后把后端返回的文件相对路径保存到一个隐藏字段里提交申报单时这个路径跟着业务数据一起传给后端入库。还有一处细节申报单保存区分了“保存草稿”和“提交审核”两个动作。保存草稿调用保存接口只要求部分字段合法提交审核则要走完整字段校验。后端也对应做了区分处理草稿状态不会进到审核人的待办列表里。这个“草稿-提交”的分离设计在实际使用中特别重要用户真实输入一份材料往往要分好几次不可能要求一次填完。3.4 axios封装与统一错误处理axios封装如果做得好前端代码能少写很多重复的错误处理。我的request模块大概是这样的结构import axios from axios const request axios.create({ baseURL: /api, timeout: 10000 }) request.interceptors.request.use(config { const token localStorage.getItem(token) if (token) { config.headers.Authorization Bearer ${token} } return config }) request.interceptors.response.use( response { const res response.data if (res.code ! 200) { if (res.code 401) { localStorage.removeItem(token) location.href /login } return Promise.reject(new Error(res.msg || 请求失败)) } return res.data }, error { const status error.response error.response.status if (status 401) { localStorage.removeItem(token) location.href /login } return Promise.reject(error) } )统一响应格式是后端和前端约好的{ code: 200, msg: success, data: {...} }成功时code为200失败时code是业务错误码。这样前端拦截器里可以统一判断业务是否成功。后端我写了全局异常处理器把业务异常、参数校验异常、未知异常分别包装成统一格式返回不让Tomcat默认的报错页面露出系统信息。这套封装做完后面写具体页面时请求一个接口只需要关心成功后的data错误提示全部自动处理。开发效率提升很明显。4. 数据库设计实战五张表打通申报业务4.1 核心表结构与SQL数据库是整个系统的地基表设计对了后面写代码顺风顺水。我当时设计了五张核心表用户表、申报批次表、申报单表、审核记录表、附件表。另外还要有一个角色表或者直接用字符串字段存角色。考虑到系统规模不大我在用户表里直接用字符串存角色省掉一张关联表。用户表CREATE TABLE sys_user ( id bigint(20) NOT NULL AUTO_INCREMENT, username varchar(50) NOT NULL COMMENT 用户名, password varchar(100) NOT NULL COMMENT BCrypt加密密码, real_name varchar(50) DEFAULT NULL COMMENT 姓名, role varchar(20) NOT NULL COMMENT admin/auditor/applicant, status tinyint(4) NOT NULL DEFAULT 1 COMMENT 1正常 0禁用, create_time datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_username (username) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT用户表;申报批次表CREATE TABLE apply_batch ( id bigint(20) NOT NULL AUTO_INCREMENT, batch_name varchar(100) NOT NULL COMMENT 批次名称, start_time datetime DEFAULT NULL, end_time datetime DEFAULT NULL, description text COMMENT 申报说明, status tinyint(4) NOT NULL DEFAULT 1 COMMENT 1启用 0停用, create_time datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT申报批次表;申报单表CREATE TABLE apply_project ( id bigint(20) NOT NULL AUTO_INCREMENT, batch_id bigint(20) NOT NULL COMMENT 所属批次, applicant_id bigint(20) NOT NULL COMMENT 申报人用户ID, project_name varchar(200) NOT NULL COMMENT 项目名称, project_type varchar(50) DEFAULT NULL COMMENT 项目类型, budget decimal(12,2) DEFAULT NULL COMMENT 预算金额, summary text COMMENT 项目简介, status tinyint(4) NOT NULL DEFAULT 0 COMMENT 0草稿 1待初审 2初审通过 3待终审 4终审通过 5已驳回, reject_reason varchar(500) DEFAULT NULL COMMENT 驳回原因, submit_time datetime DEFAULT NULL COMMENT 提交时间, create_time datetime DEFAULT CURRENT_TIMESTAMP, update_time datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY idx_batch_id (batch_id), KEY idx_applicant_id (applicant_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT申报单表;审核记录表、附件表的建表语句不再完整贴出来了但核心字段要说一下。审核记录表id、申报单ID、审核人ID、审核结论通过/驳回、审核意见、审核时间加一个序号字段表示第几轮审核。附件表id、申报单ID、附件名、存储路径、上传时间、上传人ID加一个附件类型字段区分“申报书”“预算表”还是“其他材料”。4.2 字段设计的几个决策细节有几个设计决策值得展开说说。第一金额字段用decimal绝对不用float或double。这是经典的浮点精度坑用户填一个0.1float存入后可能是0.100000001打印出来看到一长串小数财务必疯。decimal没有这个问题。第二所有表都带上create_time和update_time这样出问题可以查数据变更轨迹。update_time用了ON UPDATE CURRENT_TIMESTAMP也就是数据一旦更新时间字段自动刷新省去应用层手动维护。第三状态字段我用的tinyint而不是字符串枚举。因为Java端定义枚举常量、数据库端只存数字两者互相映射。比如0代表草稿1代表待初审。这种做法的好处是数据库存储更紧凑索引性能也更好但是必须在代码里写清楚0是草稿、1是待初审否则时间久了看SQL的人会一头雾水。第四外键约束我一张表都没建。这不是说外键不靠谱而是这类业务系统里删除用户、删除批次等操作经常需要软删除或者批量调整物理外键会让操作变得异常痛苦。关联关系靠应用层维护SQL查询时用JOIN关联这基本是当前商业项目的主流做法。前提是你的代码逻辑够严谨别出现删了父表数据却留下子表孤立记录的情况。4.3 常用查询SQL与MyBatis映射技巧列表页的查询通常是多条件组合的比如审核人要查“某个批次下状态是待初审的申报单”。这个需求在MyBatis的XML里写动态SQL是最舒服的因为老手都知道用一个通用型where标签配合if判断可以省掉无数条重复SQL。select idselectProjectPage resultTypecom.example.projectapply.entity.ApplyProject SELECT p.*, u.username AS applicantName, b.batch_name AS batchName FROM apply_project p LEFT JOIN sys_user u ON p.applicant_id u.id LEFT JOIN apply_batch b ON p.batch_id b.id where if testbatchId ! null AND p.batch_id #{batchId} /if if teststatus ! null AND p.status #{status} /if if testapplicantId ! null AND p.applicant_id #{applicantId} /if if testkeyword ! null and keyword ! AND p.project_name LIKE CONCAT(%, #{keyword}, %) /if /where ORDER BY p.create_time DESC /select这里有个细节我之前写过喷血的由于我用了type-aliases-package所以resultType可以直接写entity.ApplyProject类的简单类名但写动态SQL时test标签里判断字段是否为空这种如果参数类型是包装类要用! null别用isNotNull否则会报错。这个坑说多了都是泪。列表查询为了显示权限范围内的数据还要区分角色。比如申报人登录后只能看到自己的申报单所以要传applicantId进来审核人登录后只能看到状态为待审核的数据。这个逻辑可以放在SQL里过滤也可以放在Service层根据角色拼接查询条件。我实测下来放在Service层更灵活因为一个查询接口往往不止一种角色在用。5. 源码部署全流程实录5.1 环境准备与版本选择部署这件事看起来很简单实际翻车率极高。团队里好几个同事在本地写得好好的拿到服务器上一跑全是坑。我先说环境再说完整步骤。服务器推荐Linux我用的是Ubuntu 22.04当然CentOS 7也同样适用只是安装命令有细微差别。需要装的东西有JDKOpenJDK 1.8、MySQL 8.x、Nginx。前端Node环境不需要在服务器上装因为我们是在本地构建完再传dist上去服务器只需要Web服务器即可。版本对应关系要注意这是比较容易迷糊的地方组件版本说明JDK1.8SpringBoot 2.7要求3.x需要17MySQL8.0需要8.0以上的驱动Nginx1.18稳定版即可Maven3.6仅构建时需要Node16仅构建前端时本地需要这里我特别提醒一下MySQL 5.7和8.0的选择会影响驱动配置。如果你服务器装的是5.7pom里用的mysql-connector-java 8.x驱动也能兼容但URL里的时区参数、编码参数建议保持一致这我上面已经贴过了。5.2 SpringBoot后端打包部署后端打包前先确认application.yml里的数据库连接是服务器地址而不是本地的localhost。很多人打包部署后报数据库连接失败一查配置文件写的还是localhost:3306然后MySQL装了、账号密码也对却怎么都连不上最后发现root账号只允许本地登录或者忘了创建数据库用户。一步错步步错。打包命令就一行mvn clean package -DskipTests如果打包过程中Maven下载依赖特别慢大概率是没配国内镜像源。在~/.m2/settings.xml里加阿里云镜像地址能极大提速。构建完成后target目录下会生成一个xxx.jar。这个jar可以直接拷贝到任意装有JRE的服务器上运行。启动命令有几种我推荐用nohup把日志输出到文件防止离屏阻塞nohup java -jar project-apply.jar --spring.profiles.activeprod app.log 21 如果项目拆分成了环境和配置生产环境的配置文件要单独准备。为了安全生产环境数据库的密码不要明文写在application.yml里可以通过启动命令传入环境变量覆盖默认值比如java -jar project-apply.jar --spring.datasource.password${DB_PASSWORD}日志文件很重要出问题时第一件事就是看日志。我习惯在启动命令里加上-Xms256m -Xmx512m参数固定JVM内存避免服务器内存不足导致OOM或者因为动态扩容导致性能波动。对一个小系统来说512MB堆内存绰绰有余。5.3 Vue前端构建与Nginx配置前端构建前先检查vite.config.js里的两项配置代理和生产环境API地址。export default defineConfig({ plugins: [vue()], server: { port: 5173, proxy: { /api: { target: http://localhost:8080, changeOrigin: true } } } })开发环境走proxy保证本地调试顺便解决跨域生产环境构建时前端代码里的请求路径是/api这种相对路径由Nginx统一转发到后端服务。所以前端代码里尽量不要写死http://localhost:8080这种绝对路径否则打包后到服务器上请求会找不到后端。这个坑相当常见。构建命令npm run build构建完成后在dist目录下生成静态资源文件把整个dist目录拷贝到服务器上比如放到/var/www/project-apply/下然后写Nginx配置。server { listen 80; server_name your-domain.com; # 前端静态资源 root /var/www/project-apply; index index.html; # 后端接口转发 location /api/ { proxy_pass http://127.0.0.1:8080; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } # 附件上传目录 location /uploads/ { alias /data/uploads/; } # 前端路由history模式必配 location / { try_files $uri $uri/ /index.html; } }这里我特别标注一下try_files $uri $uri/ /index.html;这一行。Vue Router默认用history模式路由不是真实的文件路径比如访问/project/detail/123Nginx发现服务器上没有这个文件就会去尝试找/project/detail/123/index.html还是没有最后回退到index.html。没有这行配置前端路由刷新直接404。每次部署如果忘了配我就得被测试追着打。location /uploads/里我用了alias而不是root两者的差别是alias会把/uploads/替换为/data/uploads/而root会在/data/uploads/后面再拼/uploads/。用错的话404没跑。5.4 跨域与反向代理配置前面提过部署后前端走Nginx、后端独立跑在8080端口浏览器访问的页面是从Nginx加载的请求接口是请求Nginx上的/api再由Nginx向后端转发其实就不会触发跨域问题。那开发环境的跨域是怎么解决的靠Vite的proxy。那生产环境还有没有可能跨域有。如果你前端没走Nginx而是单独部署了一个静态服务器比如用Node的http-server那浏览器直接请求后端8080就会跨域。所以后端也要配上CORS支持算双保险。在SpringBoot里加一个配置类就行Configuration public class CorsConfig implements WebMvcConfigurer { Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping(/**) .allowedOriginPatterns(*) .allowedMethods(*) .allowedHeaders(*) .allowCredentials(true) .maxAge(3600); } }注意allowCredentials(true)和allowedOriginPatterns(*)的组合在SpringBoot 2.4以上的版本里是允许的2.4之前只能用allowedOrigins(*)。如果这个方法写着写着报跨域记得检查SpringBoot的版本。5.5 部署后的验证清单每次部署完我习惯按这个清单走一遍避免漏问题浏览器打开首页确认页面能正常加载用申报人账号登录创建一个申报单保存草稿提交申报单确认列表状态变成待初审用审核人账号登录确认能看到这条待审数据审核通过/驳回再用申报人账号刷新确认状态变化上传一个超过20MB的文件确认会被拒绝且提示友好刷新一个非首页的路由地址确认不会404。这七步跑完系统基本可用。其余细枝末节的问题平时排查就行。6. 常见问题排查与避坑实录6.1 跨域问题排查跨域报错信息里一定有Access-Control-Allow-Origin这个词。遇到这个先分清是开发环境还是生产环境。开发环境检查Vite的proxy配置有没有生效浏览器Network面板里看请求地址生产环境检查Nginx的proxy_pass是否写得正确以及后端CORS配置是否允许了前端域名。实在不行就在后端CORS配置里把allowedOriginPatterns改成*但注意这是开发期临时方案生产有安全风险。6.2 上传文件大小限制上传超过大小直接被拒绝报错是FileSizeLimitExceededException。这个限制有两层SpringBoot的spring.servlet.multipart.max-file-size以及Nginx的client_max_body_size。如果你发现SpringBoot配了20MBNginx默认只有1MB那么超过1MB的文件就直接被Nginx拦住了根本不进后端。所以两层都要配。Nginx配置里加一行client_max_body_size 50m;。6.3 数据库乱码与时区问题乱码几乎都是字符集不对。MySQL连接URL里加characterEncodingutf8同时数据库表本身要建在utf8mb4上。如果这两项都对还有乱码查一查服务器默认编码locale是C之类的话Java读取外部文件就可能乱码。时区问题之前说了URL里加serverTimezoneAsia/Shanghai即可否则连接时候报错“The server time zone value Öйú±ê׼ʱ¼ä”这个报错本身就是乱码。6.4 MyBatis映射报错映射报错最常见的是两种。第一种是Invalid bound statement (not found)意思是mapper接口找到了但对应的XML没映射上。检查两点mapper-locations路径是否和XML文件实际位置一致XML里的namespace是否和接口全限定名一致。第二种是查询结果字段全是null多半是map-underscore-to-camel-case没开或者你自己在resultMap里映射字段写的和数据库列名不一致。6.5 部署后刷新404问题这个问题我刚才说过了就是Nginx少了try_files $uri $uri/ /index.html;。如果你访问首页正常但一进二级路由刷新就白屏九成是这个原因。很多Vue项目单独部署时都遇到加一行配置重启Nginx就解决。6.6 时间字段与序列化问题日期字段前后端对接容易出鬼后端返回的日期是2025-02-17T10:00:00这种带T的格式前端显示不好看或者返回的是时间戳前端也不会转。解决方法是后端统一配置JSON序列化格式。SpringBoot可以在application.yml里加spring: jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: GMT8这样接口返回的日期格式统一成“2025-02-17 10:00:00”前端拿来直接显示零代码转换。这个配置很实用强烈建议加上。写在最后的体会这套系统前前后后我改了三个版本才稳定下来。第一版把Excel里的字段直接搬到页面表单里字段多得能打仗结果用户根本不填。第二版加了草稿保存功能用户明显就没那么焦虑了。第三版把附件大小和类型限制做好才算真正接得住生产压力。做这类系统技术永远是第二位的对用户工作流的理解才是第一位。如果再让我做一个类似系统我大概率会把附件存储直接换到云存储上毕竟服务器磁盘有限运维也省心前端的表单配置也会考虑抽象成后端返回的动态配置这样管理员不通过前端发版就能修改申报字段。不过这些都是后话了先把眼前这套跑稳比什么都强。最后分享一个小细节代码里所有的状态常量一定要用有语义的枚举名代替裸数字比如APPLY_STATUS_DRAFT而不是STATUS_0。一次两次觉得写注释麻烦半年后回头看代码你会感谢当初这个决定。