ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

SpringBoot+Vue3合同管理系统开发实战:从表设计到部署全解析

2026/9/24 18:39:55 拓冰建站 浏览量
SpringBoot+Vue3合同管理系统开发实战:从表设计到部署全解析 1. 项目全景为什么偏偏是这套“老熟人”技术栈做合同管理系统的人大多是先被业务折磨过才会想到要自己撸一套。我见过不少公司还在用Excel表格登记合同业务部门催款时翻半天找不着一份扫描件到期续签全靠行政的脑子记。这种场景下一套能管合同全生命周期的系统就是刚需。而这个项目选的SpringBoot2 Vue3 MyBatis-Plus MySQL8.0恰恰是当前国内中小型管理系统中最务实、最不容易翻车的组合。先说说这套技术栈为什么值得用。SpringBoot2到现在依然是生产环境里的绝对主力虽然SpringBoot3已经发布但大量企业存量项目、云厂商镜像、运维脚本都还停留在2.x时代招聘JD里写的也多是“熟悉SpringBoot2”。Vue3经过这几年的沉淀生态已经完全成熟配合Vite的开发体验比Vue2时代的webpack快了不止一个档次。MyBatis-Plus则解决了传统MyBatis最让人头疼的重复CRUD问题单表操作几乎不用写SQL复杂查询又保留了XML的自由度。MySQL8.0相比5.7在窗口函数、JSON能力、默认字符集上都有明显提升新项目没有理由不选。对于学生党、毕业设计、或者刚入门想搞一个完整前后端分离项目练手的人来说这套组合的最大价值在于每一个环节都有海量的社区资料可以查遇到问题搜一下基本都有答案不至于卡死在某个冷门坑里。这比追求“最新最潮”的技术选型要实际得多。合同管理这个业务本身也很有代表性。它不是简单的增删改查里面包含了状态流转草稿→审批→生效→到期/终止、主子表结构合同基本信息收款计划/付款节点、文件上传与关联、到期提醒、权限控制等常见业务模型。把这些搞清楚再去写其他管理系统比如固定资产、人事档案、项目立项基本就是换皮的事。整个项目的功能模块大致分为这样几块合同信息管理合同的新增、编辑、作废、删除支持按合同编号、名称、对方单位、状态等条件组合查询。合同审批流提交审批、审批通过/驳回记录每一步的审批意见和时间。收付款计划一个合同关联多个收付款节点到期的节点能在首页或列表里醒目标出。客户/供应商管理维护往来单位的基础信息避免每次录入合同时重复填公司名和税号。到期提醒基于合同的到期日期做前后端双重提醒避免业务上“忘了续签”这种低级事故。系统管理用户、角色、菜单、权限这里用的是经典的RBAC模型。接下来我会按实际开发的顺序从表结构设计、后端实现、前端页面到部署联调把整套系统的关键细节拆开讲一遍。重点不是贴完整源码而是告诉你每一步为什么这么设计、哪些地方容易踩坑、以及怎么排查问题。2. 合同的表结构设计状态机驱动与数据冗余的艺术数据库表设计是这类管理系统的地基。地基打不好后面写代码会处处别扭。合同管理系统最核心的表就是合同主表围绕它衍生出往来单位表、收付款计划表、审批记录表、附件表等。2.1 合同主表把高频查询字段直接冗余进来先看合同主表的字段设计思路。这里不列全部字段只挑关键的几个说明设计逻辑字段名类型说明idbigint主键用MyBatis-Plus的雪花IDcontract_novarchar(64)合同编号业务唯一键需要加唯一索引contract_namevarchar(255)合同名称contract_typetinyint合同类型采购/销售/框架协议等字典值party_a_namevarchar(255)甲方名称我方party_b_namevarchar(255)乙方名称对方单位total_amountdecimal(18,2)合同总金额tax_ratedecimal(5,2)税率sign_datedate签订日期start_datedate生效日期end_datedate到期日期statustinyint合同状态0草稿1审批中2已生效3已到期4已终止5已作废create_bybigint创建人IDcreate_timedatetime创建时间update_timedatetime更新时间deletedtinyint逻辑删除标记注意这里有几个设计决策合同状态单独用数字存不在表里直接用字符串。这样做虽然可读性稍差但配合前端字典翻译性能和扩展性都是最优的。合同类型、状态这类字段在列表页需要频繁筛选所以在contract_type和status上各建一个普通索引。2.2 合同类型和状态字典表与前后端枚举对齐比较关键的是contract_type和status这两个字段。我见过有人把它们做成外键关联字典表结果每次查询都要多表联查性能下来了不说代码也啰嗦。更务实的做法是数据库存数字后端用枚举类定义常量前端维护一份字典映射三处通过注释或文档保持一致。public enum ContractStatus { DRAFT(0, 草稿), APPROVING(1, 审批中), EFFECTIVE(2, 已生效), EXPIRED(3, 已到期), TERMINATED(4, 已终止), VOIDED(5, 已作废); private final int code; private final String desc; ContractStatus(int code, String desc) { this.code code; this.desc desc; } // getter... }前端对应的字典export const CONTRACT_STATUS { 0: { label: 草稿, tagType: info }, 1: { label: 审批中, tagType: warning }, 2: { label: 已生效, tagType: success }, 3: { label: 已到期, tagType: danger }, 4: { label: 已终止, tagType: info }, 5: { label: 已作废, tagType: danger } }2.3 收付款计划表主子表结构一对多收付款计划表是典型的子表。一个合同对应多期收付款节点每期有自己的金额、截止日期、实际完成日期和状态。这个表的查询条件几乎总是带着contract_id所以联合索引(contract_id, plan_date)基本是必须的。设计子表时有个容易忽略的细节删除和修改的操作策略。如果允许用户编辑已生效合同下的收付款计划那么每期计划的完成情况已收/已付金额怎么做历史追溯这里我建议做一个简单约束已完成的计划节点不允许修改金额只能备注调整原因。这个逻辑放在后端校验而不是前端隐藏按钮——前端隐藏只能防普通用户防不了直接调接口的人。2.4 审批记录表状态流转的可追溯性审批记录表记录每一次状态变更的来龙去脉CREATE TABLE contract_approval_record ( id bigint NOT NULL AUTO_INCREMENT, contract_id bigint NOT NULL COMMENT 合同ID, approval_user_id bigint NOT NULL COMMENT 审批人ID, approval_user_name varchar(64) NOT NULL COMMENT 审批人姓名, action tinyint NOT NULL COMMENT 动作1提交2通过3驳回, comment varchar(500) DEFAULT NULL COMMENT 审批意见, create_time datetime NOT NULL, PRIMARY KEY (id), KEY idx_contract_id (contract_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;注意一个细节把approval_user_name直接冗余存进去而不是审批的时候再去联查用户表。因为审批人可能离职、改姓名历史记录必须保留当时的快照。这与合同主表里存party_b_name的道理一样——业务表里存的是业务发生时刻的事实不是关联表的最新状态。3. 后端实现细节SpringBoot2与MyBatis-Plus的正确打开方式后端这部分的思路是按标准分层Controller → Service → Mapper → MySQL核心业务逻辑放ServiceController只做参数接收和结果封装。这样写的好处是方便单元测试也让代码目录结构一眼就能看懂。3.1 项目结构约定优于配置com.example.contract ├── controller │ ├── ContractController.java │ ├── ApprovalController.java │ └── SystemController.java ├── service │ ├── ContractService.java │ └── impl │ └── ContractServiceImpl.java ├── mapper │ ├── ContractMapper.java │ └── xml │ └── ContractMapper.xml ├── entity │ └── Contract.java ├── dto │ ├── ContractQueryDTO.java │ └── ApprovalDTO.java ├── vo │ └── ContractVO.java ├── common │ ├── Result.java │ ├── PageResult.java │ └── GlobalExceptionHandler.java └── config ├── MybatisPlusConfig.java └── WebConfig.java这里有个常被新手坑的地方mapper接口和xxxMapper.xml的路径配置。默认情况下Spring Boot只扫描classpath*:com/example/**/mapper/*.xml以下的内容如果你把XML放在src/main/java/com/example/mapper/xml目录下打包时会发现resources里根本没有这些XML。需要在pom.xml里加一段配置build resources resource directorysrc/main/java/directory includes include**/*.xml/include /includes /resource resource directorysrc/main/resources/directory /resource /resources /build或者在application.yml里指定mapper-locations两种方式随便选一种但一定要记得做不然启动后MyBatis报Invalid bound statement (not found)排查半天都不知道问题在哪。3.2 MyBatis-Plus的核心用法BaseMapper与条件构造器MyBatis-Plus最爽的一点是单表CRUD完全不用写SQL。继承BaseMapperT之后selectById、insert、updateById、deleteById都是现成的。public interface ContractMapper extends BaseMapperContract { // 多表查询时在这里定义自定义方法SQL写在ContractMapper.xml里 IPageContractVO selectContractPage(IPage? page, Param(dto) ContractQueryDTO dto); }条件构造器LambdaQueryWrapper是高频使用的对象它最大的好处是类型安全——字段名写成Lambda表达式编译期就能发现拼写错误比手写字符串字段名靠谱太多。举个例子列表页按合同名称模糊查询、按状态精确查询LambdaQueryWrapperContract wrapper Wrappers.lambdaQuery(); wrapper.like(StringUtils.hasText(dto.getContractName()), Contract::getContractName, dto.getContractName()) .eq(dto.getStatus() ! null, Contract::getStatus, dto.getStatus()) .orderByDesc(Contract::getCreateTime);第一个参数传boolean条件条件为false时这个like/eq不会拼进SQL。这个设计非常实用省去了大量if判断拼接SQL的重复代码。注意eq的第二个参数如果传null生成的SQL是status null这在MySQL中永远查不出数据因为null null结果为NULL所以一定要用eq(condition, column, val)的写法把condition传进去。3.3 分页插件与自动填充的正确姿势MyBatis-Plus使用分页需要显式配置分页插件否则selectPage返回的total永远是0。手动加一个配置类Configuration public class MybatisPlusConfig { Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor new MybatisPlusInterceptor(); interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); return interceptor; } }这里有个版本相关的坑PaginationInnerInterceptor构造函数在旧版本是new PaginationInnerInterceptor()新版本推荐显式传入数据库类型。MySQL和PostgreSQL生成的分页SQL不一样传错类型虽然也能跑但分页语句会变成数据库不认的方言报错很莫名。自动填充也就是create_time、update_time这种字段不要在业务代码里逐行set而是实现MetaObjectHandler接口统一处理Component public class MyMetaObjectHandler implements MetaObjectHandler { Override public void insertFill(MetaObject metaObject) { this.strictInsertFill(metaObject, createTime, LocalDateTime.class, LocalDateTime.now()); this.strictInsertFill(metaObject, updateTime, LocalDateTime.class, LocalDateTime.now()); } Override public void updateFill(MetaObject metaObject) { this.strictUpdateFill(metaObject, updateTime, LocalDateTime.class, LocalDateTime.now()); } }实体类对应字段上要加TableField(fill FieldFill.INSERT)和TableField(fill FieldFill.INSERT_UPDATE)注解。这样新增、修改时这些字段就会自动赋值不用每个Service方法里都写一遍。3.4 审批流的后端实现乐观锁与事务边界审批动作是合同系统里最有业务感的地方。提交审批、审批通过、审批驳回每一步本质上都是对合同状态的更新。为了防止并发情况下两个审批人同时操作同一份合同单纯在Controller里判断状态是不够的需要在SQL层面加乐观锁。MyBatis-Plus提供了Version注解做乐观锁public class Contract { Version private Integer version; }更新时MyBatis-Plus会自动在WHERE后面带上version ?执行UPDATE后通过受影响行数判断是否冲突。如果返回0说明版本号不匹配数据已经被别人改过了。Controller收到这个结果后直接提示“操作太频繁请刷新后重试”即可。有了乐观锁事务边界就简单了。审批通过这个动作需要做两件事更新合同状态草稿/审批中 → 已生效插入一条审批记录到contract_approval_record表这两个动作必须在同一个事务里。在Service实现类上直接加Transactional(rollbackFor Exception.class)就能保证原子性。注意rollbackFor一定要配因为Spring默认只对RuntimeException回滚检查异常比如IOException不会触发回滚数据会处于“状态改了但记录没写进去”的中间状态。3.5 到期提醒的实现思路定时任务与列表标记双保险到期提醒用Scheduled定时任务可以实现一个简单版本每天凌晨扫描一次合同表找出三天内到期且状态为“已生效”的合同给负责人发站内信或者邮件。定时任务在SpringBoot里开启很简单启动类上加EnableScheduling然后写个任务方法Component public class ContractRemindTask { Scheduled(cron 0 0 2 * * ?) public void remindExpiringContracts() { // 查询三天内到期且状态为生效的合同 // 按创建人分组发送站内通知 } }但这里有个细节定时任务只能解决“到点了提醒一次”的问题用户在系统里看到的列表页、详情页上仍然需要醒目的到期标记。所以我在合同的VO里加了expiringSoon这个计算字段由后端在查询时根据end_date和当前日期判断前端列表里对快到期的合同行显示红色标签。后端计算字段比前端拿到日期自己算更可靠因为前端各页面的时间处理逻辑很难保持统一。4. 前端Vue3构建后台管理端从Vite脚手架到页面组件化前端这块我一开始就没打算用iframe嵌套页面而是做一个真正的前后端分离应用。Vue3的项目结构相比Vue2更清晰尤其是script setup语法糖写起来比Options API简洁不少。4.1 Vite创建项目与基础配置创建Vue3 Vite项目npm create vitelatest contract-ui -- --template vue cd contract-ui npm install npm install vue-router4 pinia element-plus axios dayjs这里说一下为什么用Pinia而不用Vuex。Vuex是Vue2时代的标配到了Vue3虽然也能用但Pinia更轻量、TypeScript支持更好、去掉了mutations这个累赘的概念改起来也简单——定义store就是一个defineStore(xxx, () { ... })直接暴露响应式state和function。Element Plus是Vue3后台管理系统的首选UI库组件齐全表格、表单、弹窗、日期选择器都有。安装后建议全量引入别为了几个组件做按需加载开发阶段省心最重要。按需加载要用unplugin-vue-components配置起来还是要花一点时间的等以后项目跑顺了再优化也不迟。4.2 前端目录结构与路由权限设计前端目录组织src ├── api │ ├── contract.js │ ├── approval.js │ └── login.js ├── assets ├── components │ └── StatusTag.vue ├── layout │ └── Index.vue ├── router │ └── index.js ├── stores │ ├── user.js │ └── app.js ├── utils │ ├── request.js │ └── auth.js └── views ├── login ├── contract │ ├── List.vue │ ├── Edit.vue │ └── Detail.vue └── dashboard路由权限控制是这类系统逃不开的问题。思路很简单登录成功时后端返回当前用户的角色和权限码列表前端把权限码存到Pinia里路由配置里通过meta.roles或meta.perms标记哪些页面需要哪些权限然后在路由守卫里做拦截。router.beforeEach((to, from, next) { const userStore useUserStore() if (to.path ! /login !userStore.token) { next(/login) } else if (to.meta.perms !userStore.hasPerms(to.meta.perms)) { next(/403) } else { next() } })4.3 Axios封装统一处理Token、错误码和下载Axios封装是我每次写管理系统都会先做的一步。统一的request实例可以帮你省掉无数重复代码import axios from axios import { ElMessage } from element-plus import { useUserStore } from /stores/user import router from /router const request axios.create({ baseURL: /api, timeout: 10000 }) // 请求拦截器自动带token request.interceptors.request.use(config { const userStore useUserStore() if (userStore.token) { config.headers.Authorization Bearer ${userStore.token} } return config }) // 响应拦截器统一处理业务码和HTTP错误 request.interceptors.response.use( response { const res response.data if (res.code ! 200) { ElMessage.error(res.message || 请求失败) if (res.code 401) { router.push(/login) } return Promise.reject(new Error(res.message)) } return res }, error { ElMessage.error(error.message || 网络异常) return Promise.reject(error) } )这里有个关键设计后端约定的统一响应结构是{ code: 200, message: success, data: ... }。前端拦截器统一处理非200的业务码这样每个API页面里就不用重复写错误提示了。注意401这个状态如果后端的登录会话过期返回的HTTP状态码是401响应拦截器里直接跳登录页就行。4.4 登录页面与动态背景的实用写法热门搜索词里提到了“vue3 登录页面 点线动态的背景”这个确实给登录页增加了不少视觉记忆点。实现不复杂用Canvas画粒子效果template div classlogin-page canvas refcanvasRef classbg-canvas/canvas el-card classlogin-card !-- 登录表单... -- /el-card /div /template script setup import { ref, onMounted, onBeforeUnmount } from vue const canvasRef ref(null) let ctx null let animationId null const points [] function initParticles() { const canvas canvasRef.value ctx canvas.getContext(2d) canvas.width window.innerWidth canvas.height window.innerHeight // 生成本机数量的随机点每个点有自己的坐标和速度 for (let i 0; i 80; i) { points.push({ x: Math.random() * canvas.width, y: Math.random() * canvas.height, vx: (Math.random() - 0.5) * 0.5, vy: (Math.random() - 0.5) * 0.5 }) } animate() } function animate() { ctx.clearRect(0, 0, canvasRef.value.width, canvasRef.value.height) points.forEach(point { point.x point.vx point.y point.vy // 边界反弹 if (point.x 0 || point.x canvasRef.value.width) point.vx * -1 if (point.y 0 || point.y canvasRef.value.height) point.vy * -1 ctx.beginPath() ctx.arc(point.x, point.y, 2, 0, Math.PI * 2) ctx.fillStyle rgba(64, 158, 255, 0.6) ctx.fill() // 画连线 points.forEach(other { const dx point.x - other.x const dy point.y - other.y const dist Math.sqrt(dx * dx dy * dy) if (dist 120) { ctx.beginPath() ctx.moveTo(point.x, point.y) ctx.lineTo(other.x, other.y) ctx.strokeStyle rgba(64, 158, 255, ${1 - dist / 120}) ctx.stroke() } }) }) animationId requestAnimationFrame(animate) } onMounted(() { initParticles() window.addEventListener(resize, resetCanvas) }) onBeforeUnmount(() { cancelAnimationFrame(animationId) window.removeEventListener(resize, resetCanvas) }) /script这个效果的逻辑是随机撒点点之间距离小于某个阈值就连线透明度随距离变化。整体视觉轻盈适合登录页。唯一的性能注意点是点的数量别开太大80个点、120像素的连线阈值在普通机器上足够流畅。4.5 合同列表页表格、搜索、分页组合合同列表页是这类系统使用频率最高的页面交互逻辑值得认真设计。页面布局从上到下依次是搜索区合同编号、名称、状态、日期范围、操作按钮新增、导出、数据表格、分页器。搜索区和数据表格之间的数据流要理顺搜索条件存一个响应式对象每次点击查询按钮时把对象传给后端查询接口拿到结果后重新渲染表格。script setup import { ref, onMounted } from vue import { getContractPage } from /api/contract const queryForm ref({ contractNo: , contractName: , status: null, dateRange: [] }) const tableData ref([]) const total ref(0) const page ref(1) const size ref(10) async function fetchData() { const params { page: page.value, size: size.value, contractNo: queryForm.value.contractNo, contractName: queryForm.value.contractName, status: queryForm.value.status, startDate: queryForm.value.dateRange?.[0] ?? null, endDate: queryForm.value.dateRange?.[1] ?? null } const res await getContractPage(params) tableData.value res.data.records total.value res.data.total } onMounted(() fetchData()) /script状态列的展示建议用Element Plus的el-tag配合前面定义的CONTRACT_STATUS字典不同状态用不同颜色标识——草稿灰色、审批中橙色、已生效绿色、已到期红色、已终止/已作废暗色。这比单纯显示一个数字或文字直观得多。4.6 合同编辑页表单校验、主子表联动、日期处理编辑页是另一个重头戏。合同基本信息和收付款计划在一个页面里同时编辑子表用el-table内嵌编辑行的方式实现。这里有两个容易出问题的点第一日期控件。Element Plus的el-date-picker默认返回的是Date对象而传给后端需要的是字符串通常格式化成yyyy-MM-dd。建议在表单上提交流程里统一处理不要每个字段单独格式化写一个formatFormData函数统一遍历。第二主子表的数据校验。合同金额等于所有收付款计划的金额之和——这个校验在前后端都要做一遍。前端在点击提交时先遍历子表数据求和与主表金额比对后端在Service里再校验一次因为接口是可以被绕过的前端校验只能提升用户体验真正兜底必须靠后端。// 后端校验例子 ListPaymentPlan plans paymentPlanMapper.selectList( Wrappers.lambdaQuery(PaymentPlan.class) .eq(PaymentPlan::getContractId, contract.getId()) ); BigDecimal total plans.stream() .map(PaymentPlan::getAmount) .reduce(BigDecimal.ZERO, BigDecimal::add); if (total.compareTo(contract.getTotalAmount()) ! 0) { throw new BusinessException(收付款计划金额合计与合同总金额不一致); }写这段的时候有个小坑BigDecimal的比较要用compareTo不能用equals。因为equals比较的是数值和标度new BigDecimal(100.00).equals(new BigDecimal(100))返回false而compareTo只看数值100.00和100是相等的。用!或者比较BigDecimal更是大忌对象比较的是引用地址永远不相等。5. 环境搭建与联调排错MySQL8.0和前后端协作的实战坑这一章我专门讲环境搭建和联调过程中一定会遇到的问题。项目用的MySQL8.0和5.7虽然大体兼容但有几个细节不同前后端联调也有经典的三天一小坑、五天一大坑。5.1 MySQL8.0安装与配置字符集、时区、认证方式MySQL8.0安装本身不复杂Linux用apt install mysql-server或者yum install mysql-community-server都能装上Windows下直接下载installer一路下一步。真正容易栽跟头的是三个配置字符集8.0默认字符集已经是utf8mb4但如果你是从5.7升级上来的库或者建表时用的是DEFAULT CHARSETutf8那emoji和一些生僻字存进去就是乱码。建库时强烈建议显式指定CREATE DATABASE contract_system DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;这里utf8mb4_general_ci和utf8mb4_unicode_ci的差别在中文场景下基本无感选哪个都行。关键是别用utf8它在MySQL里实际上是utf8mb3不支持四字节字符。时区连接MySQL8.0的JDBC连接串里一定要带serverTimezoneAsia/Shanghai否则会报The server time zone value й׼ʱ is unrecognized。这个报错是中文乱码的时区信息去掉乱码本质就是“中国标准时间”这个值MySQL压根不认识。Windows的MySQL服务默认时区跟随系统但很多Linux上装完MySQL默认时区是UTC导致Java里LocalDateTime.now()插入的数据比北京时间早八个小时查出来想死的心都有。在application.yml里配合useSSLfalseallowPublicKeyRetrievaltrue也很重要。MySQL8.0的默认认证插件是caching_sha2_password老版本的JDBC驱动不认识这个插件会报Public Key Retrieval is not allowed。要么把连接串加上allowPublicKeyRetrievaltrue要么干脆在MySQL里把用户改成mysql_native_password。前者省事推荐前者。5.2 Docker部署MySQL8.0数据持久化与端口冲突用Docker部署MySQL8.0也挺常见很多开发机上不想装一堆原生服务一个容器全搞定docker run --name mysql8 \ -p 3306:3306 \ -e MYSQL_ROOT_PASSWORDyour_password \ -e TZAsia/Shanghai \ -v /data/mysql:/var/lib/mysql \ -d mysql:8.0 \ --character-set-serverutf8mb4 \ --collation-serverutf8mb4_general_ci这里-v /data/mysql:/var/lib/mysql一定要加不然容器删了数据全没。TZAsia/Shanghai是给容器系统设时区--character-set-server和--collation-server是MySQL启动参数优先级高于my.cnfdocker run时直接传最稳妥。5.3 MyBatis-Plus的SQL打印日志配置联调阶段最痛苦的事是前端传了条件后端返回了空列表但你不知道SQL到底执行了什么。MyBatis-Plus提供了一个简单的SQL日志打印方式mybatis-plus: configuration: log-impl: org.apache.ibatis.logging.stdout.StdOutImpl配完之后每次执行的SQL、参数、返回行数全部打到控制台。排查问题时先看SQL对不对再看参数绑定对不对这个顺序能帮你快速定位70%的查询问题。生产环境一定要关掉这个配置不然SQL日志会刷爆磁盘。5.4 联调经典坑LocalDateTime序列化后前端格式不对这是前后端分离项目里最经典的问题。SpringBoot默认用Jackson序列化LocalDateTime序列化出来的格式是2024-01-15T10:30:00中间带个T前端显示很难看。解决办法是加一个全局Jackson配置spring: jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: GMT8但要注意spring.jackson.date-format对LocalDateTime不生效它只管java.util.Date。要处理LocalDateTime的格式需要单独加配置Configuration public class JacksonConfig { Bean public Jackson2ObjectMapperBuilderCustomizer jacksonCustomizer() { return builder - { builder.serializers(new LocalDateTimeSerializer(DateTimeFormatter.ofPattern(yyyy-MM-dd HH:mm:ss))); builder.deserializers(new LocalDateTimeDeserializer(DateTimeFormatter.ofPattern(yyyy-MM-dd HH:mm:ss))); }; } }这个配置不加的话前端接到的日期是带T的ISO格式用Element Plus表格直接展示还好但要做日期格式化就比较别扭。加上之后前后端统一成yyyy-MM-dd HH:mm:ss所有涉及日期的展示和回传都清爽很多。5.5 联调二坑Long型雪花ID精度丢失还有一个极隐蔽的坑——前端的JavaScript Number类型最大安全整数是2^53-1即9007199254740991。MyBatis-Plus默认的雪花ID是19位数字超出安全范围后前端接收到的ID末尾几位会被截断或变成0。比如数据库里存的是1793642183825895424前端收到的可能是1793642183825895400。这个问题不影响列表展示但一旦你把这个被截断的ID传回后端做详情查询、编辑、删除就会查不到数据或者操作了错误的记录。安全做法是让后端把Long类型的ID统一序列化为字符串Configuration public class JacksonConfig { Bean public Jackson2ObjectMapperBuilderCustomizer jacksonCustomizer() { return builder - { builder.serializerByType(Long.class, ToStringSerializer.instance); builder.serializerByType(Long.TYPE, ToStringSerializer.instance); }; } }前端拿到ID的字符串形式传参时间接传给后端SpringMVC会自动把字符串转回Long完全无感。这个坑几乎每个前后端分离项目都会踩尽早配好省得后面查Bug查到怀疑人生。5.6 前后端联调三坑路由代理与Cookie开发环境下Vite默认监听5173端口后端在8080端口跨域是必须解决的问题。最简单的方式不是在Vue里开启代理而是在Vite配置里把/api路径代理到后端地址// vite.config.js export default defineConfig({ server: { proxy: { /api: { target: http://localhost:8080, changeOrigin: true, rewrite: path path.replace(/^\/api/, ) } } } })这里/api前缀只是为了区分请求来源防止前端项目里一些静态资源请求也被代理到后端。代理配置好之后前端请求/api/contract/page就会转发到后端/contract/page同时浏览器看到的还是同源的不存在跨域问题。如果你用了Cookie来维持登录状态changeOrigin: true是必须的它会把Host头改成localhost:8080保证后端能正确识别会话。如果不用Cookie而是用Token推荐这个选项影响不大但保留着没坏处。6. 项目部署与上线需要注意的细节写到这一个能跑的合同管理系统基本就成型了。最后聊聊部署上线的那些事很多项目跑到本地没问题一到服务器上就各种水土不服。6.1 后端打包与启动参数后端打包用的是SpringBoot Maven插件mvn clean package -DskipTests打出来的jar包通常有几十MB如果包含前后端分离的部署方式只需要这个jar包和一个Nginx配置。启动时最重要的参数是JVM内存配置特别是服务器内存只有2G的时候java -Xms256m -Xmx512m -jar contract-system.jar --spring.profiles.activeprod-Xms和-Xmx分别指定初始堆大小和最大堆大小设成一样的值可以避免运行中堆扩容带来的性能抖动。--spring.profiles.activeprod指定生产环境配置意味着application-prod.yml会被加载里面的数据源密码、日志级别跟开发环境完全隔离。6.2 前端打包与Nginx配置前端打包npm run build生成的dist目录就是纯静态文件。Nginx配置server { listen 80; server_name contract.example.com; root /var/www/contract-ui/dist; index index.html; location / { try_files $uri $uri/ /index.html; } location /api/ { proxy_pass http://localhost:8080/; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } }try_files那一行是单页应用路由的核心。刷新/contract/list页面时Nginx发现磁盘上没这个文件就回退到index.html由Vue Router接管并渲染对应页面。不加这一行刷新非首页路由就会404这个问题属于Vue Router 4 history模式部署的必备知识。6.3 数据库迁移与初始化上线前要导出开发环境的数据库结构在服务器上重建。可以用mysqldump只导出结构不导出数据mysqldump -u root -p --no-data contract_system schema.sql另外提一个建议数据库初始化脚本要纳入版本管理和代码一起提交。每次改动表结构都写一个新的migration_vX.sql并记录日期和变更内容。这听起来很麻烦但是多人协作、线上维护时唯一不会出错的表结构管理方式。我见过太多项目数据库结构全凭开发者的记忆换个人接手直接懵。6.4 生产环境的安全配置要点既然是系统管理密码不能明文存。可用Spring Security自带的BCryptPasswordEncoder来加密登录密码数据库里存的是密文即使数据库泄漏也不会密码裸奔。再一个是后端接口的越权问题。一个普通的合同列表接口登录用户可以传createBy1看别人的合同这就是水平越权。简单的做法是在Service层加上数据范围过滤// 如果是普通用户强制加上自己的ID条件 if (!isAdmin(currentUserId)) { wrapper.eq(Contract::getCreateBy, currentUserId); }接口层面管理员的接口打上RequirePermission(system:contract:delete)这类注解通过AOP拦截器校验权限码能有效防止用户通过猜接口地址获取不该有的操作权限。7. 一些刀子嘴豆腐心的体会写这套系统的过程中我最大的体会是业务理解比技术选型重要得多。技术栈选得再新如果合同状态流转的逻辑没有想清楚审批记录没有留痕到期提醒的实现只做了定时任务而忽略了列表页的直观标记那这套系统就算跑起来也是个半成品。相反只要核心业务流程理得顺——合同有哪些状态、每一步状态靠什么动作触发、数据表之间是什么关系——用SpringBoot2还是SpringBoot3、Vue3还是React都只是实现手段的区别。这也是为什么很多公司招人时业务理解能力和代码能力同等重要。如果你正准备拿这个项目作为毕业设计或者求职项目我建议在跑通基础功能之后自己动手做两件有价值的事第一把审批流从“单级审批”扩展成“多级审批”。现实中合同一般要经过业务负责人、财务、法务、总经理等多级审批每一级都可能通过或驳回。这个扩展需要新增一张审批流程配置表并调整审批状态机。这个改动量不大但面试时讲出来含金量比背一百个八股文都高。第二给列表查询加上导出Excel的功能。系统管理者的真实需求里导出报表是少不了的。用EasyExcel半小时就能接上但注意大数据量导出要走异步任务前端轮询导出状态不然请求超时或者内存溢出都很尴尬。最后说句实在话这种管理系统项目看着简单但把每个细节做好从表设计到前后端联调再到处境部署一整套走下来你的工程能力一定会有一个明显的提升。代码写不出来的时候别硬刚先查日志、再看SQL、最后看浏览器控制台排查问题的顺序对了问题就解决一半了。