☰
SpringBoot+Vue前后端分离项目申报系统设计与部署实战
2026/10/22 14:42:32 网站建设 项目流程

做项目申报系统这个活儿,最初其实是被人拉去救火的。某单位之前用的是Excel表格加邮件的方式收项目申报材料,每到申报季,几百份材料在邮箱里堆着,文件名五花八门,有叫《新建文件夹》的,有叫《最终版》的,还有叫《最终版2》的。审批人一个个下载、一个个登记,眼神都快看瞎了。当时我就想,与其这样难受,不如做一个正经的前后端分离项目申报系统,用SpringBoot+Vue+MyBatis+MySQL这套组合,把申报—审核—归档的流程彻底规范化。这个系统做完以后,我再从零搭类似的信息管理类项目,基本就是套路活了。

这篇文章就把整个项目的设计思路、核心代码、数据库设计和部署流程拆开讲讲,适合正在做毕设的、想练手前后端分离的同学,也适合公司内部想做一套小系统但预算不多的小团队。全文没有藏私,踩过的坑全写出来了。

1. 项目申报系统的需求本质与技术选型

1.1 申报系统到底在管理什么:角色与流程的拆解

很多人一听“项目申报系统”就觉得是个CRUD,其实不只是。先理清楚它的业务本质,后面代码写起来才顺。

一个申报系统里有三种人,对应三种诉求:

  • 申报人:要能提交项目材料、查看审批进度、收到驳回原因、修改后再次提交。
  • 审核人:要能看到待审列表、给出通过或驳回意见,最好还能分层级审批。
  • 管理员:要能创建申报批次、设定起止时间、维护用户、导出统计结果。

核心流程是一条状态机:申报人发起一个项目申请,经过初审、终审,最后通过归档。每个环节都可能被打回,打回之后申报人要重新修改再提交,状态又要回到待审状态。这套流程如果只用Excel管理,状态一变就全乱套了,因为这本质是一个有生命周期的流程,不是简单地登记几条记录。

所以做这个系统,第一件事不是写代码,而是把状态流转画清楚。我实际建模的时候,整理出来五个核心状态:草稿、待初审、初审通过、待终审、终审通过(或终审驳回)。驳回之后回到草稿或待提交状态,重新提交后再次进入审核流程。状态定下来,表结构、接口设计就都有了骨架。

1.2 为什么是SpringBoot+Vue+MyBatis+MySQL这套组合

这套技术栈在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?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/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里serverTimezone=Asia/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`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 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`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 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`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='申报单表';

审核记录表、附件表的建表语句不再完整贴出来了,但核心字段要说一下。审核记录表:id、申报单ID、审核人ID、审核结论(通过/驳回)、审核意见、审核时间,加一个序号字段表示第几轮审核。附件表:id、申报单ID、附件名、存储路径、上传时间、上传人ID,加一个附件类型字段区分“申报书”“预算表”还是“其他材料”。

4.2 字段设计的几个决策细节

有几个设计决策值得展开说说。

第一,金额字段用decimal,绝对不用float或double。这是经典的浮点精度坑,用户填一个0.1,float存入后可能是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 id="selectProjectPage" resultType="com.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 test="batchId != null"> AND p.batch_id = #{batchId} </if> <if test="status != null"> AND p.status = #{status} </if> <if test="applicantId != null"> AND p.applicant_id = #{applicantId} </if> <if test="keyword != 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也同样适用,只是安装命令有细微差别。需要装的东西有:JDK(OpenJDK 1.8)、MySQL 8.x、Nginx。前端Node环境不需要在服务器上装,因为我们是在本地构建完再传dist上去,服务器只需要Web服务器即可。

版本对应关系要注意,这是比较容易迷糊的地方:

组件版本说明
JDK1.8SpringBoot 2.7要求,3.x需要17
MySQL8.0需要8.0以上的驱动
Nginx1.18+稳定版即可
Maven3.6+仅构建时需要
Node16+仅构建前端时本地需要

这里我特别提醒一下,MySQL 5.7和8.0的选择会影响驱动配置。如果你服务器装的是5.7,pom里用的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.active=prod > app.log 2>&1 &

如果项目拆分成了环境和配置,生产环境的配置文件要单独准备。为了安全,生产环境数据库的密码不要明文写在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/123,Nginx发现服务器上没有这个文件,就会去尝试找/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配了20MB,Nginx默认只有1MB,那么超过1MB的文件就直接被Nginx拦住了,根本不进后端。所以两层都要配。Nginx配置里加一行client_max_body_size 50m;。

6.3 数据库乱码与时区问题

乱码几乎都是字符集不对。MySQL连接URL里加characterEncoding=utf8,同时数据库表本身要建在utf8mb4上。如果这两项都对还有乱码,查一查服务器默认编码,locale是C之类的话,Java读取外部文件就可能乱码。时区问题之前说了,URL里加serverTimezone=Asia/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: GMT+8

这样接口返回的日期格式统一成“2025-02-17 10:00:00”,前端拿来直接显示,零代码转换。这个配置很实用,强烈建议加上。

写在最后的体会

这套系统前前后后我改了三个版本才稳定下来。第一版把Excel里的字段直接搬到页面表单里,字段多得能打仗,结果用户根本不填。第二版加了草稿保存功能,用户明显就没那么焦虑了。第三版把附件大小和类型限制做好,才算真正接得住生产压力。做这类系统,技术永远是第二位的,对用户工作流的理解才是第一位。

如果再让我做一个类似系统,我大概率会把附件存储直接换到云存储上,毕竟服务器磁盘有限,运维也省心;前端的表单配置也会考虑抽象成后端返回的动态配置,这样管理员不通过前端发版就能修改申报字段。不过这些都是后话了,先把眼前这套跑稳比什么都强。

最后分享一个小细节:代码里所有的状态常量,一定要用有语义的枚举名代替裸数字,比如APPLY_STATUS_DRAFT而不是STATUS_0。一次两次觉得写注释麻烦,半年后回头看代码,你会感谢当初这个决定。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询