1. 为什么是 SpringBoot + Vue3 + MyBatis + MySQL 这套组合
先说个结论:养老保险管理系统这种业务,技术栈选型从来不是越新越好,而是越"稳"越好。这套系统我用 SpringBoot 做后端、Vue3 做前端、MyBatis 做数据持久层、MySQL 存数据,前后端完全分离,整个项目跑下来非常顺,特别适合做毕设、面试项目,或者给中小型单位做内部管理系统参考。
你可能会问,市面上那么多框架,为什么偏偏是这四样?我拆开说。
SpringBoot 解决的是"后端工程怎么搭"的问题。它对 Spring 那一大堆 XML 配置做了自动装配,内嵌 Tomcat,打成一个 jar 包就能跑。对养老保险这种 CRUD 密集型的业务系统来说,SpringBoot 提供的 Starter 机制能帮你省掉大量重复配置,比如数据源、事务、Jackson 序列化这些,基本都是引入依赖就生效。而且国内 Java 招聘市场对 SpringBoot 的接受度极高,项目写这个技术栈,无论找工作还是做毕设答辩,都站得住脚。
Vue3 解决的是"前端交互怎么做"的问题。为什么不用 Vue2?Vue3 的 Composition API 在逻辑复用上的优势太明显了——养老保险系统里有参保登记、缴费管理、待遇发放、账户查询这些功能页面,页面之间有很多公共逻辑(比如分页、表单校验、字典数据回显),用 Composition API 可以很干净地抽出 useTable、useForm 这类组合式函数。而且 Vue3 搭配 Vite 开发,冷启动速度和热更新体验比 Vue2 + Webpack 强太多了,开发效率完全不在一个量级。你要是用过 Vue2 里那种 mixins 到处混的写法,再切到 Vue3 的 setup 语法,基本就回不去了。
MyBatis 解决的是"SQL 怎么管理"的问题。MyBatis 特有的动态 SQL 在处理养老金的复杂条件查询时太顺手了——比如参保人查询可能要组合"姓名、身份证号、参保状态、参保时间区间"好几个条件,用 if 标签可以非常灵活地拼接 SQL 片段。而且相比 JPA,MyBatis 对 SQL 的可控性更强,DBA 审起 SQL 来也方便,像养老金这类对数据准确性要求极高的系统,SQL 写在自己的掌控范围内更让人放心。MyBatis 缓存机制(一级缓存、二级缓存)也值得单独拿出来说,后面我会详细展开。
MySQL 解决的是"数据往哪存"的问题。轻量、免费、社区活跃,8.0 版本之后窗口函数、CTE(公共表表达式)这些能力也跟上来了,对养老系统的各种统计报表(比如按月/按险种统计参保人数)很有帮助。单一 MySQL 实例对于绝大多数内部管理系统来说性能绰绰有余,不需要一上来就上分布式数据库。
注意:这套系统里我说的"前后端分离",是指前端工程和后端工程完全独立——前端用 Vite 起开发服务(比如 5173 端口),后端独立跑在 8080 端口,两者通过 HTTP 接口 + JSON 数据交互,前端代码、后端代码分目录存放,可以分别部署到 Nginx 和服务器上的独立进程。
这套组合的定位非常清晰:给凡是"表单 + 列表 + 权限 + 报表"类型的业务系统提供一个可以直接抄作业的样板工程。它不像微服务那么重,但足够让你理解企业级项目的组织方式:统一的返回结果封装、全局异常处理、JWT 登录鉴权、前端路由守卫、Pinia 状态管理……这些不是一个 Demo,而是一套可扩展的骨架。
2. 养老保险系统的业务拆解:参保、缴费、发放、账户四条主线
聊技术之前,得先把这个系统的"业务底盘"说清楚。养老保险管理系统,表面看是"人员信息增删改查",但真正落地的时候,你会发现业务逻辑远不止这么简单。
2.1 核心业务模块清单
一个完整的养老保险管理系统,至少要覆盖下面这些模块:
- 参保管理:单位参保登记、个人参保登记、参保状态变更(参保/停保/断缴/恢复)、参保信息修改。这块是所有业务的数据源头,身份证号是唯一业务主键。
- 缴费管理:按月/按年度生成缴费计划、记录实际缴费流水、单位缴费与个人缴费分账处理、欠费与补缴管理。
- 待遇发放:退休人员养老金计算、按月生成待遇发放名册、发放记录留痕。
- 个人账户管理:个人账户建立、缴费记录计入个人账户、账户利息结转(按当地政策年利率计息)。
- 系统管理:用户管理、角色管理、菜单权限管理、操作日志、数据字典。
这些模块之间的逻辑关系很简单:先有参保,才能缴费;缴费记录进个人账户,也作为待遇计算的基数;满足退休条件后,进入待遇发放环节。
2.2 数据库表设计的关键决策
基于上面的业务拆解,我设计的核心表结构如下(只列关键字段):
| 表名 | 关键字段 | 作用说明 |
|---|---|---|
| person | id, id_card, name, gender, birth_date, phone, address, status | 参保人基础信息表,id_card 做唯一索引 |
| unit | id, unit_name, credit_code, contact, phone, address | 参保单位表 |
| person_unit | id, person_id, unit_id, start_date, end_date, status | 参保人与单位的关联关系表,处理"一个人在多个单位参保过"的情况 |
| insurance_record | id, person_id, type, related_unit_id, insurance_date, status | 参保登记记录表,type 区分首次参保/恢复参保 |
| payment_plan | id, person_id, plan_month, base_amount, personal_ratio, unit_ratio, due_amount | 缴费计划表,按人按月生成 |
| payment_record | id, person_id, plan_id, paid_month, pay_amount, pay_date, pay_channel, status | 实际缴费流水表 |
| account_detail | id, person_id, change_type, amount, balance_after, change_date, remark | 个人账户流水表,每次金额变动写入一行 |
| pension_record | id, person_id, benefit_year, benefit_month, amount, status, issue_date | 待遇发放记录表 |
| sys_user | id, username, password, real_name, status | 系统用户表(登录账号) |
| sys_role | id, role_name, role_code, remark | 角色表 |
| sys_user_role | user_id, role_id | 用户-角色关联 |
| sys_menu | id, parent_id, menu_name, path, component, icon, sort | 菜单表 |
| sys_role_menu | role_id, menu_id | 角色-菜单权限关联 |
其中有两张表的设计需要特别展开说。
第一张是person_unit。很多没做过社保业务的人会把"单位 ID"直接塞进 person 表,但这在真实业务里是行不通的——一个人可能先在 A 单位参保,后来换到 B 单位,中间还可能自己以灵活就业身份参保。如果把单位 ID 直接挂在 person 表上,历史记录就全丢了。所以单位关联关系必须单独成表,记录时间区间,查询"某人某年某月在哪个单位参保"就非常方便。
第二张是account_detail。养老保险的个人账户,本质上是一个"只增不减(特殊情况除外)"的资金账户。把每次计入、结息、调整都写成一条流水,余额实时计算,这是最稳妥的设计模式。好处显而易见:对账方便,出问题能追溯,月底对账直接 SUM 流水就能算出总余额,不用维护一个可能被改坏"余额字段"。
2.3 权限模型怎么设计
这个系统的权限我用的是最经典的 RBAC(基于角色的访问控制)模型:用户-角色-菜单三层。用户拥有若干个角色,角色关联若干个菜单,菜单对应前端路由和后端接口权限标识。
实现时的关键点是:后端接口必须二次校验权限,不能只靠前端隐藏按钮。前端路由守卫控制的是"页面能不能进",但接口层面的权限校验需要后端配合。我的做法是在 SpringBoot 中定义一个@RequiresPermission("system:user:add")这样的自定义注解,加在 Controller 方法上,拦截器里解析当前用户角色拥有的权限标识列表,做匹配校验。这样即使有人直接拿着 token 去调接口,也过不了这一关。
3. 后端落地的关键一环:SpringBoot 配置与 MyBatis 的实战配合
3.1 SpringBoot 工程结构怎么摆
我个人习惯把后端工程按功能分包,而不是按技术类型分包:
com.example.pension ├── common │ ├── result // 统一的返回结果封装 Result<T> │ ├── exception // 自定义异常 + 全局异常处理器 │ ├── constant // 常量定义 │ └── utils // 工具类(JWT工具、日期工具等) ├── config // 配置类(CORS配置、MyBatis配置、拦截器注册等) ├── controller // 控制器层 ├── service │ ├── impl ├── mapper // MyBatis Mapper接口 ├── entity // 实体类 ├── dto // 请求/响应数据传输对象 └── vo // 视图对象(给前端返回的DTO)这样分包的好处是,从 controller 进来,每一层职责非常清晰,找代码快。如果按技术类型分包(entity 全放一起、mapper 全放一起),一个业务模块跨好几个包,改一个功能跳来跳去很浪费时间。
3.2 SpringBoot 核心配置的坑与调优
我先贴一份我的application.yml中比较关键的部分:
spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/pension_system? useUnicode=true&characterEncoding=utf8& serverTimezone=Asia/Shanghai& useSSL=false&allowPublicKeyRetrieval=true username: root password: your_password jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: GMT+8 mybatis: mapper-locations: classpath:mapper/*.xml type-aliases-package: com.example.pension.entity configuration: map-underscore-to-camel-case: true log-impl: org.apache.ibatis.logging.stdout.StdOutImpl这里有几个坑,逐个说。
数据库连接 URL 里的参数绝对不能省。serverTimezone=Asia/Shanghai必须加,不加的话 MySQL 8.x 驱动会报 CST 时区的错(尽管报错信息让人看不懂,实际就是驱动拿不到正确的时区)。allowPublicKeyRetrieval=true是 MySQL 8.0 用 caching_sha2_password 加密插件时需要的参数,不加的话连数据库会报Public Key Retrieval is not allowed。
map-underscore-to-camel-case必须打开。数据库字段一般是id_card、real_name这种下划线命名,Java 实体是idCard、realName驼峰命名。不开启这个配置的话,你得给每个字段写 resultMap 映射,非常繁琐。打开之后,MyBatis 自动帮你把下划线转驼峰,省一大半 XML 代码。
log-impl: StdOutImpl是开发环境排错利器。它会让 MyBatis 在控制台把每条 SQL 及参数直接打印出来。你可以在开发阶段打开,上线前关掉(或者换成 logback 输出到文件)。热搜词里提到的 "idea mybatis log free" 这个插件也是干这个的,能从 MyBatis 日志里把带参数的完整 SQL 还原出来,复制到 Navicat 里直接执行排查,效果比看控制台原生日志更直观。
3.3 MyBatis 动态 SQL:复杂养老业务查询的救星
养老金业务里最常见的查询场景是"综合条件查询",比如在参保人管理页面,用户可能同时按姓名、身份证号、参保状态、所属单位、参保时间范围来筛选。每个条件都可能为空,全为空则查全部。这种需求,MyBatis 的<where>+<if>是标准解法:
<select id="selectPersonList" resultType="com.example.pension.vo.PersonVO"> SELECT p.id, p.id_card, p.name, p.birth_date, p.phone, p.status, u.unit_name FROM person p LEFT JOIN person_unit pu ON p.id = pu.person_id AND pu.status = 'ACTIVE' LEFT JOIN unit u ON pu.unit_id = u.id <where> <if test="name != null and name != ''"> AND p.name LIKE CONCAT('%', #{name}, '%') </if> <if test="idCard != null and idCard != ''"> AND p.id_card = #{idCard} </if> <if test="status != null and status != ''"> AND p.status = #{status} </if> <if test="unitId != null"> AND u.id = #{unitId} </if> <if test="startDate != null"> AND p.insurance_start_date >= #{startDate} </if> <if test="endDate != null"> AND p.insurance_start_date <= #{endDate} </if> </where> ORDER BY p.create_time DESC LIMIT #{offset}, #{pageSize} </select><where>标签会自动处理掉第一个条件前面的 AND,不用写WHERE 1=1这种很丑的写法。>=和<=是因为 XML 中<和>需要转义,实际执行的 SQL 是>=和<=。这点如果忘了,XML 解析直接报错,新手经常在这里卡半天。
3.4 MyBatis 缓存机制:用对了提效,用错了出大事
MyBatis 的缓存是面试题里的常客,也是实际项目中容易被忽略的点。
一级缓存是 SqlSession 级别的缓存,默认开启,同一个 SqlSession 内执行相同的 SQL 会直接命中缓存。但要注意,Spring 管理的 Service 方法中,每次执行 Mapper 方法可能都会新开或复用不同的 SqlSession,所以在 SpringBoot + MyBatis 的实际项目中,一级缓存的作用很有限。
二级缓存是 Mapper 级别的缓存,多个 SqlSession 共享,需要手动开启。但在我做养老系统的时候,明确不开启二级缓存。原因有三:
- 养老保险系统对数据准确性要求极高,二级缓存的失效机制在关联查询多的情况下容易出问题(一个表的更新不会自动清掉另一个表相关的缓存项);
- 数据变动频繁,脏读风险大;
- 都走 Redis 做缓存了(如果真要缓存的话),MyBatis 二级缓存显得鸡肋。
如果你要做面试项目展示,可以在一个查询频率高且数据基本不变的数据字典表上开启二级缓存,作为亮点讲。但业务数据表,我的建议始终是:别开。
3.5 SpringBoot + MyBatis 当表不存在自动建表
这个是我在实际部署时遇到的一个需求——新环境部署时,希望系统首次启动能自动把表结构建好,省得手动导入 SQL 文件。网上讨论 SpringBoot + MyBatis 做动态建表的方案不少,我用的是一种稳妥的折中方案:项目启动时执行一个初始化 SQL 脚本文件。
在application.yml中配置 Spring 的 SQL 初始化:
spring: sql: init: mode: always schema-locations: classpath:db/schema.sql encoding: utf-8注意:schema.sql里的建表语句都要写成CREATE TABLE IF NOT EXISTS,这样已有的表不会重复创建,不存在的表才建,起到典型的幂等效果。数据初始化脚本可以用>import axios from 'axios' import { ElMessage } from 'element-plus' import { useUserStore } from '@/store/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 401 request.interceptors.response.use( response => { const res = response.data // 后端统一返回 { code: 200, msg: 'success', data: ... } if (res.code === 200) { return res.data } ElMessage.error(res.msg || '请求失败') return Promise.reject(new Error(res.msg)) }, error => { if (error.response?.status === 401) { const userStore = useUserStore() userStore.clearToken() router.push('/login') ElMessage.warning('登录状态已过期,请重新登录') } else { ElMessage.error(error.message || '网络异常') } return Promise.reject(error) } ) export default request
这个封装有几个关键点:统一拿后端返回的code字段判断业务成功与否;HTTP 401 统一跳登录页并清 token;前端不关心中间层具体路径,开发环境用 Vite 代理转发到后端。
配套地,在vite.config.js中配置代理:
server: { proxy: { '/api': { target: 'http://localhost:8080', changeOrigin: true } } }这样前端代码里请求地址写/api/person/list就够了,不用写死后端地址,换环境部署只需要改代理配置。
4.2 Pinia 与 Vuex 对比:为什么选 Pinia
Vue3 官方推荐的状态管理库已经是 Pinia 了。Vuex 为 Vue3 写的 4.x 版本能用,但 Pinia 天然为 Composition API 设计,类型推断更好,没有 mutations 那一层冗余设计,直接在 store 里定义 actions 改 state,代码量少一半。
在养老系统里,我开了三个 store:
userStore:登录用户信息、token、权限标识列表;appStore:侧边栏折叠状态、全局加载状态;dictStore:各类数据字典(参保状态、缴费类型、险种类型等)。
其中dictStore很有讲究。字典数据是系统里的高频冗余数据——前端下拉框要展示"参保状态",数据库里存的是ACTIVE、STOPPED这种编码,具体显示成"参保中"、"已停保"是字典表的事。我做一个dictStore,登录后一次性拉取所有字典到内存,页面上直接dictStore.getLabel('personStatus', row.status)就能拿到展示文字,不需要每个页面单独请求。这比每个下拉框都发一次接口查询要高效得多。
4.3 路由守卫:登录校验 + 权限控制 + 动态注册
路由守卫是前后端分离项目的"大门保安"。我的路由设计分两层:
静态路由:login、404、403 这几个页面,所有人可访问。动态路由:系统菜单对应的业务页面,登录后根据用户角色,从后端获取可访问的路由表,用router.addRoute()动态注册。
核心伪代码如下:
router.beforeEach(async (to, from, next) => { const userStore = useUserStore() if (!userStore.token) { // 未登录只能进 login if (to.path === '/login') { next() } else { next('/login?redirect=' + to.fullPath) } } else { // 已登录,但还没拿到用户信息和菜单 if (!userStore.userInfo) { try { await userStore.fetchUserInfo() // 获取基本信息 const menuRoutes = await userStore.fetchMenus() // 获取动态菜单 menuRoutes.forEach(route => router.addRoute(route)) // 防止刷新页之后 addRoute 还没完成导致 404 next({ ...to, replace: true }) } catch (e) { userStore.clearToken() next('/login') } } else { next() } } })这里有一个必须处理的 bug:刷新页面时,Pinia 里的动态路由数据会丢失,得重新拉取菜单并 addRoute。如果不做next({ ...to, replace: true })这一步,用户刷新之后大概率会被 404 页面拦住。
4.4 组合式函数抽公共逻辑:useTable 的实战写法
Vue3 相比 Vue2 最大的优势之一,就是可以通过组合式函数抽离公共逻辑。做后台管理系统,"列表页面"的套路出奇一致:搜索表单 + 表格 + 分页。以前每个页面都写一份分页、加载、搜索逻辑,用了useTable之后就清爽多了:
// composables/useTable.js import { ref, reactive } from 'vue' export function useTable(fetchData, options = {}) { const loading = ref(false) const dataList = ref([]) const total = ref(0) const queryParams = reactive(options.queryParams || {}) const pagination = reactive({ currentPage: 1, pageSize: 10 }) async function loadData() { loading.value = true try { const params = { pageNum: pagination.currentPage, pageSize: pagination.pageSize, ...queryParams } const res = await fetchData(params) dataList.value = res.records total.value = res.total } finally { loading.value = false } } function handleSearch() { pagination.currentPage = 1 loadData() } function handleReset() { Object.keys(queryParams).forEach(key => { queryParams[key] = undefined }) handleSearch() } function handlePageChange(page, pageSize) { pagination.currentPage = page pagination.pageSize = pageSize loadData() } return { loading, dataList, total, pagination, queryParams, loadData, handleSearch, handleReset, handlePageChange } }页面上只要这样用:
const { loading, dataList, total, pagination, queryParams, handleSearch, handleReset, handlePageChange } = useTable(personApi.getPageList)省掉的重复代码量十分可观。这也是面试时和组织者聊 Vue3 的最佳素材之一——"你用组合式函数抽了什么公共逻辑"比"你会用 v-model"要加分得多。
5. MySQL 使用细节与养老系统的数据安全设计
5.1 MySQL 8.0 安装配置里的高频问题
MySQL 这块网上的安装教程一搜一大堆,但实际装的时候还是有几个坑反复出现。我先说一个最重要的:8.0 默认的认证插件是 caching_sha2_password,而很多旧版客户端和部分连接池版本不认识它。如果你用 5.x 的 JDBC 驱动连 8.0 的库,或者拿很老的 Navicat 连,大概率会报Authentication plugin 'caching_sha2_password' cannot be loaded。
解决办法有两个:要么升级驱动/客户端到支持 8.0 的版本;要么在 MySQL 里把用户认证插件改回 mysql_native_password:
ALTER USER 'root'@'localhost' IDENTIFIED WITH mysql_native_password BY '你的密码'; FLUSH PRIVILEGES;我的建议是以升级客户端为主,因为 mysql_native_password 在新版本 MySQL 中已被标记为弃用,将来某天可能会移除。改认证插件只能是应急方案。
另一个常见的坑是 MySQL 8.0 安装完后 root 账号的密码规则。如果你设了一个简单密码(比如123456),MySQL 可能会拒绝创建,因为它默认要求中强度以上的密码策略(validate_password 组件)。这种情况可以临时把密码策略调低:
SET GLOBAL validate_password.policy = LOW;具体参数名在不同版本里略有差异(有的叫validate_password_policy),你执行的时候可以先用SHOW VARIABLES LIKE 'validate_password%';看一下实际名字。
5.2 存储过程在养老金计算里的用武之地
热搜词里提到了 mysql 存储过程,养老系统里正好有一个典型的适用场景——按人员缴费月数、账户余额、当地上年度社平工资等参数批量计算养老金。
这类计算的特点是:涉及多张表数据的读取、多步计算、结果写入另一张表,而且一旦参数确定(比如退休审批完成),过程要保证整体执行、不能说算到一半停了。用存储过程可以把这一串逻辑包成一个数据库操作:
-- 简化示例:批量生成某月养老金发放记录 CREATE PROCEDURE generate_pension_record(IN calc_month VARCHAR(7)) BEGIN DECLARE done INT DEFAULT FALSE; DECLARE p_person_id BIGINT; DECLARE cur CURSOR FOR SELECT id FROM person WHERE status = 'RETIRED' AND id NOT IN ( SELECT person_id FROM pension_record WHERE benefit_month = calc_month ); DECLARE CONTINUE HANDLER FOR NOT FOUND SET done = TRUE; OPEN cur; read_loop: LOOP FETCH cur INTO p_person_id; IF done THEN LEAVE read_loop; END IF; -- 根据个人账户余额、缴费年限等计算金额 INSERT INTO pension_record(person_id, benefit_year, benefit_month, amount, status, issue_date) SELECT p_person_id, LEFT(calc_month, 4), calc_month, -- 具体的计算逻辑,这里简化了 (SELECT ...), 'WAIT_CONFIRM', NOW(); END LOOP; CLOSE cur; END;当然,业务计算放存储过程会带来一定维护成本(版本管理不方便、难以调试),所以我只在"纯 SQL 能高效完成且事务性强"的场景用存储过程,复杂的业务计算还是放 Java Service 层做。这个平衡点你也要自己把握。
5.3 这系统里的数据安全机制
养老金的钱不是小数目,系统在数据安全上不能裸奔。我落地了几条硬措施:
- 密码不能明文存储:系统用户表的密码用 BCrypt 加密(Spring Security 自带 BCryptPasswordEncoder),同一个密码每次加密结果都不一样,数据库泄露也没法反推。千万别用 MD5,MD5 加盐也尽量别用了,BCrypt 是更稳的选择。
- 关键操作留审计日志:待遇金额修改、用户权限变更、数据导出这几种高风险操作,必须记录操作人、操作时间、变更详情。出了问题能追责到具体人。
- 数据库权限分级管理:应用连接数据库的账号,只授 SELECT/INSERT/UPDATE/DELETE 权限,不授 DDL 权限(建表/删表那种),这样即使应用被拖库,攻击者也没法拉系统表改数据。
- 金额字段用 DECIMAL,不用 DOUBLE:
DECIMAL(10,2)才能保证金额计算不出现 0.1 + 0.2 = 0.30000000000000004 这种浮点精度问题。这不是教条,这是会真实出现的工伤事故。
6. 联调与部署环节:从本地跑通到服务器上线的完整链路
6.1 IDEA 与开发环境里那些"不是问题的问题"
开发环境里最容易让人烦躁的,往往不是业务逻辑,而是一堆环境层面的小毛病。
JDK 环境变量是第一个坎。Java 8 在最新版的 IDEA 里已经不太好使了,建议直接上 JDK 17(SpringBoot 3.x 要求 JDK 17+,如果你的 SpringBoot 是 2.x,JDK 8 也够用)。配置 JAVA_HOME 时候注意,Windows 上若环境变量不生效,大概率是 Path 里 C:\Program Files\Common Files\Oracle\Java\javapath 这个默认项抢了优先级。解决办法是把%JAVA_HOME%\bin挪到 Path 最前面,或者直接把 Oracle 那个项删掉。
SpringBoot 版本太高的问题在社区里也经常被提。SpringBoot 3.x 用的是 javax 还是 jakarta 命名空间有大变化(Servlet API 从 javax.servlet 迁到 jakarta.servlet),很多旧教程和三方库可能没跟上。我建议目前走稳妥路线:SpringBoot 2.7.x + JDK 8/11 + MyBatis(非 mybatis-plus),这套组合经过大量生产环境验证,出问题你搜索解决方案基本上是"一搜一个准"。如果你非要上 SpringBoot 3.x,就得接受配套生态可能踩坑的事实。
IDEA 的 MyBatis 插件里,我认为最实用的是 MyBatisX——它提供了 Mapper 接口和 XML 之间的快速跳转,还能一键生成基本的 CRUD 代码。开发效率能提升不少。
6.2 前后端联调时最常碰到的问题
联调阶段出的问题,十有八九集中在下面三件事:
跨域问题。虽然我在 Vite 里配了代理解决开发环境的跨域,但部署到线上之后前后端如果不在同一个域名下,还是会遇到跨域。后端那边我加了全局 CORS 配置:
@Configuration public class CorsConfig { @Bean public CorsFilter corsFilter() { CorsConfiguration config = new CorsConfiguration(); config.addAllowedOriginPattern("*"); config.addAllowedMethod("*"); config.addAllowedHeader("*"); config.setAllowCredentials(true); UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); source.registerCorsConfiguration("/**", config); return new CorsFilter(source); } }注意一个细节:allowedOrigins("*")和allowCredentials(true)不能同时用,浏览器会直接拒绝。得用allowedOriginPatterns("*")来代替,这是很多人踩过的坑。
时间格式问题。后端返回LocalDateTime,前端不处理的话默认是一串带 T 的 ISO 字符串(比如2024-06-01T10:30:00),很丑也不符合国内习惯。后端在 Jackson 里统一配置date-format为yyyy-MM-dd HH:mm:ss,前端拿到就是格式化好的字符串。反过来前端传日期参数给后端时,用时间戳或约定的格式传,避免歧义。
字段命名不一致。前端习惯驼峰,后端实体也是驼峰,但数据库是下划线。如果你把查询结果直接放到 Map 里返回给前端(有些偷懒的写法),拿到的 key 就是下划线命名,前端模板里怎么点都点不出来。我在代码规范里定了一条:所有返回给前端的 VO/DTO 字段一律驼峰命名,禁止直接返回 Map。
6.3 部署方案:Nginx + jar 包
最后上线,我的部署方案是经典的前后端分离部署:
- 后端:SpringBoot 打 jar 包,丢到服务器上
nohup java -jar pension-backend.jar --spring.profiles.active=prod &跑起来,占用 8080 端口。生产环境配置放application-prod.yml,数据库密码通过环境变量注入,不写死在代码库里。 - 前端:
npm run build之后生成 dist 目录,丢到 Nginx 的 html 目录下。Nginx 配置里做好两件事:一是/路径指向静态文件,二是/api路径反向代理到后端 8080 端口:
server { listen 80; server_name your-domain.com; root /usr/share/nginx/html; index index.html; location / { try_files $uri $uri/ /index.html; # 防止前端路由刷新404 } 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; } }try_files $uri $uri/ /index.html这行非常关键。Vue3 前端用的 history 路由模式,如果不配这段,用户直接访问http://your-domain.com/person/list这种带路径的地址,刷新一次就是 Nginx 404。配了这段之后,所有找不到的路径都回退到 index.html,让前端路由接管。
6.4 线上日志与异常排查
后端日志我配了 logback 按天滚动输出,异常堆栈单独记到一个文件里。排查线上问题时,最常用的命令是:
tail -200f /logs/pension/error.log日志里出现频率最高的几类异常,我提前列在这里,遇到不用慌:
Deadlock found:两条更新 SQL 的更新顺序不一致导致死锁。解决思路是让所有事务按相同的顺序更新同一组记录(比如先按 person_id 排序后再更新)。Data too long for column:字段长度不够,一般是 real_name 存了 20 个字超了 varchar(20),或传参和表结构对不上。Out of Memory:JVM 堆内存不足,先调-Xmx参数,如果还不行就排查是不是分了页查全表之类的问题。
7. 这个项目后续还能往哪些方向扩展
系统做到这个程度,已经是一个完整可跑的闭环了。但如果你想让它成为一个更有竞争力的项目(尤其是面试作品),我建议按下面的方向做增量扩展,按投入产出比排序:
方向一:引入 Redis 做缓存与验证码存储。登录验证码、字典数据、用户 Token(配合 Redis 实现主动下线)都能用 Redis 落地。面试聊天时可以自然引出"缓存一致性怎么保证""缓存穿透/击穿/雪崩怎么应对"这些经典话题。
方向二:加入消息队列做异步通知。养老金发放成功后,通过 MQ 给参保人发送短信通知(模拟),或者每月缴费计划生成完毕之后异步推送提醒。RabbitMQ 或 RocketMQ 都行。这个点能体现你考虑了系统的高可用和削峰场景。
方向三:引入定时任务框架。将每月月底自动生成下月缴费计划、每月初给欠费单位发提醒、每年年中做一次待遇基数调整,用 Spring 自带的@Scheduled或集成 XXL-Job 分布式的都行。如果你在项目里用到了这个,面试官基本都会让你展开讲讲任务幂等和失败重试的处理。
方向四:报表导出增强。用 EasyExcel 做参保缴费明细分页导出、月度统计报表导出,这是业务系统里需求最刚性、又能展示代码功力的功能。
我做这个项目最大的体会是:选型不在多,在于贴合场景。养老保险管理系统不是一个热门赛道,但它把"权限模型、业务流转、复杂查询、数据一致性"这些常见的后端核心问题都覆盖了,学到的能力完全能平移复用到任何企业管理系统上。
这套源码我会继续维护,下一步自己也在计划重新梳理一下代码里的注释规范,以及把数据库初始化脚本整理得更加傻瓜化——毕竟这套系统最大的意义不是展示技术有多炫,而是让拿到源码的人少走两步弯路,能更快地把工程跑起来,然后专注于学自己想学的那一层。