这篇文章不玩虚的,就是从开发者的角度,手把手带你从零到一把一个理财管理系统跑起来。文章很长、代码很全、部署步骤可以直接抄作业,适合那些不想看花架子、只想搞懂“怎么做”的读者。
1. 项目概述与技术选型
先说说为什么要写这个东西。很多人手里有点闲钱之后,第一反应是记账,但Excel记几天就放弃了,手机记账App用一段时间也觉得别扭——数据不在自己手里、没法自定义统计维度、想做个预算管理还得开会员。与其在别人的工具里将就,不如自己写一套。这件事听起来高大上,本质上就是一个典型的增删改查系统,只不过业务逻辑偏向资金流水管理,非常适合用来巩固技术栈、练手前后端分离项目,甚至直接改成家庭资产管理系统自己用。
这个项目定位是“从零搭建”,所以我不会假装你已经有了一套现成的基础设施,而是把每一步都摊开讲清楚,从数据库建表到后端接口再到前端页面,包括最后的服务器部署,全部走一遍。整个系统能做什么,先列个清单:
- 多账户管理:可创建现金、银行卡、某宝等不同账户,记录每笔收支时关联对应账户。
- 记账流水:支持收入、支出、转账三种类型,分类可以自定义。
- 统计报表:按月、按分类汇总收支,用图表展示资金流向和结余趋势。
- 预算管理:给每个分类设置月度预算,超支时界面直接标红提醒。
- 数据导出:流水数据一键导出Excel,方便年底做个人财务复盘。
- 多用户支持:不同用户的数据完全隔离,登录后只能看到自己的账目。
技术栈选用的是当下最主流、资料最多、对新手最友好的一套组合,后端Spring Boot + MyBatis-Plus,前端Vue 3 + Element Plus,数据库MySQL,部署用Nginx反代端口。这套组合的优点是:每一层都有海量的中文资料,遇到报错随便一搜就有答案;同时它又足够工程化,做毕业设计或者写进简历都不丢人。
有一点我想提前说清楚:这个项目去掉了消息队列、Redis缓存、分布式事务这些“为了复杂而复杂”的东西,原因很简单——个人理财系统的并发量撑死了也就几十个人同时用,引入一堆中间件不仅增加部署成本,还会让代码变得难以阅读。技术选型要匹配业务规模,这是很多教程容易忽略的地方。如果你后面想进阶,完全可以自己往这个框架上叠加新东西,我会在文末给你指几条扩展路线。
2. 系统设计与数据模型解析
2.1 整体模块划分
先把系统按功能边界拆开,在写代码前心里必须有这张图。我习惯把后端分成这样几个模块:用户认证模块负责注册、登录、Token发放和身份校验;账户管理模块维护用户名下的所有资金账户;交易记录模块是系统的核心,负责处理收入和支出的增删改查,以及账户余额的联动更新;分类管理模块处理收支分类的新增和修改;统计报表模块针对流水数据做按月按分类的聚合查询。
模块划分看似简单,实则需要提前想好边界。比如转账操作,它本质上是两条记录:一个账户减钱、另一个账户加钱,如果做成两个独立的收支记录,会导致统计报表里的“总支出”虚高。所以设计的时候我把交易类型分成三种,转账单独处理,统计时自动过滤转账记录,只统计收入和支出。这种业务细节必须在建模阶段就定下来,否则写到后面会发现报表数据怎么都对不上。
前端则按照路由和视图划分:登录注册页、仪表盘(Dashboard)展示总资产和收支趋势、流水管理页提供筛选和增删改操作、报表统计页用图表展示分类占比、设置页管理分类和账户。前后端通过JSON交互,后端统一返回{ code, message, data }的响应结构。
2.2 数据库表结构设计
直接看建表脚本。数据库名我用的finance_db,排序规则选了utf8mb4_general_ci,支持中文存储没有问题。用户表和核心业务表分开设计,方便未来扩展权限体系。
-- 用户表 CREATE TABLE `t_user` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, `username` varchar(50) NOT NULL COMMENT '登录名', `password` varchar(100) NOT NULL COMMENT 'BCrypt加密后的密码', `nickname` varchar(50) DEFAULT NULL COMMENT '昵称', `created_time` datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_username` (`username`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户表'; -- 账户表 CREATE TABLE `t_account` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, `user_id` bigint(20) NOT NULL COMMENT '所属用户', `name` varchar(50) NOT NULL COMMENT '账户名,如招行卡', `type` tinyint(4) DEFAULT '1' COMMENT '1现金 2银行卡 3某宝 4其他', `balance` decimal(15,2) DEFAULT '0.00' COMMENT '当前余额', `created_time` datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), KEY `idx_user_id` (`user_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='资金账户表'; -- 分类表 CREATE TABLE `t_category` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, `user_id` bigint(20) NOT NULL, `name` varchar(50) NOT NULL COMMENT '分类名称,如餐饮', `type` tinyint(4) NOT NULL COMMENT '1支出 2收入', PRIMARY KEY (`id`), KEY `idx_user` (`user_id`, `type`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='收支分类表'; -- 流水表 CREATE TABLE `t_transaction` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, `user_id` bigint(20) NOT NULL COMMENT '冗余用户ID,方便查询', `account_id` bigint(20) NOT NULL COMMENT '关联账户', `category_id` bigint(20) DEFAULT NULL COMMENT '收支分类,转账时为空', `type` tinyint(4) NOT NULL COMMENT '1收入 2支出 3转账', `amount` decimal(15,2) NOT NULL COMMENT '金额', `trade_time` datetime NOT NULL COMMENT '交易时间', `remark` varchar(255) DEFAULT NULL COMMENT '备注', `created_time` datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), KEY `idx_user_time` (`user_id`, `trade_time`), KEY `idx_account` (`account_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='交易流水表'; -- 预算表 CREATE TABLE `t_budget` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, `user_id` bigint(20) NOT NULL, `category_id` bigint(20) NOT NULL COMMENT '分类ID', `month` char(7) NOT NULL COMMENT '月份,如2025-03', `amount` decimal(15,2) NOT NULL COMMENT '月度预算金额', PRIMARY KEY (`id`), UNIQUE KEY `uk_user_cat_month` (`user_id`, `category_id`, `month`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='预算表';这套表结构有几个细节值得解释。流水表里的user_id是冗余字段,严格遵循范式其实可以只存account_id再联表查询用户,但实际开发中联表查询在数据量大之后非常影响性能,尤其是统计报表需要反复跨表聚合,冗余一个用户字段可以把很多查询简化成单表条件过滤,这个取舍是值得的。
金额字段一律用decimal(15,2)而不是float——这是财务系统的红线。浮点数在二进制中无法精确表示,比如0.1 + 0.2的结果不是正好0.3,哪怕只差一点点,日积月累汇总出来的报表数据就是错的。用decimal本质上是用字符串存储数字的方式保证了精度,虽然会牺牲一点点性能,但对财务业务来说正确性永远优先。
交易时间和创建时间也特意区分开了。trade_time是记账人填写的实际发生时间,created_time是系统当前时间。这个设计用来应对补记场景——比如你昨天在外面吃饭忘了记账,今天想起来补录一笔,这时候trade_time应该是昨天,created_time自动记录今天。如果只用一个时间字段,统计数据就会出现严重失真。
3. 后端核心代码实现
3.1 项目初始化和公共配置
后端我用的是Spring Boot 2.7版本,Java 8。创建项目的方式很多,我习惯直接用Spring Initializr生成基础骨架,然后手动添加依赖。核心依赖就这几个:
<dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-boot-starter</artifactId> <version>3.5.3</version> </dependency> <!-- MySQL驱动 --> <dependency> <groupId>com.mysql</groupId> <artifactId>mysql-connector-java</artifactId> <version>8.0.33</version> </dependency> <!-- 参数校验 --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-validation</artifactId> </dependency> <!-- JWT登录需要 --> <dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt-api</artifactId> <version>0.11.5</version> </dependency> <dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt-impl</artifactId> <version>0.11.5</version> <scope>runtime</scope> </dependency> <dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt-jackson</artifactId> <version>0.11.5</version> <scope>runtime</scope> </dependency> <!-- 密码加密 --> <dependency> <groupId>org.springframework.security</groupId> <artifactId>spring-security-crypto</artifactId> </dependency> </dependencies>application.yml里最关键的配置是数据源、MyBatis-Plus和Tomcat端口。注意MySQL的时区参数serverTimezone必须带上,否则默认时区和本地不一致,所有时间字段写入数据库都会偏8小时。
server: port: 8080 spring: datasource: url: jdbc:mysql://localhost:3306/finance_db?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai username: root password: your_password driver-class-name: com.mysql.cj.jdbc.Driver mybatis-plus: configuration: map-underscore-to-camel-case: true log-impl: org.apache.ibatis.logging.stdout.StdOutImpl global-config: db-config: logic-delete-field: deleted logic-delete-value: 1 logic-not-delete-value: 0map-underscore-to-camel-case开启后,数据库的created_time字段能自动映射到Java实体的createdTime属性,不需要写一堆@TableField注解,能省不少事。日志输出级别设为控制台打印SQL,开发阶段方便排查问题,生产环境务必关掉,不然日志文件撑爆磁盘的速度超乎你想象。
3.2 用户注册登录与JWT鉴权
协处理用户安全性这块,密码绝不能明文存储。我这里用的是Spring Security Crypto库里的BCryptPasswordEncoder,它是目前哈希强度比较合适的算法,自带随机盐值,同样密码每次加密结果都不同,可以抵御彩虹表攻击。注册逻辑这么写:
@PostMapping("/register") public Result<String> register(@RequestBody @Valid RegisterDTO dto) { // 校验用户名是否重复 Long count = userMapper.selectCount( new LambdaQueryWrapper<User>().eq(User::getUsername, dto.getUsername()) ); if (count > 0) { return Result.error("用户名已存在"); } User user = new User(); user.setUsername(dto.getUsername()); user.setPassword(passwordEncoder.encode(dto.getPassword())); // 重点:加密后再存 user.setNickname(dto.getNickname()); userMapper.insert(user); return Result.success("注册成功"); }登录成功之后签发JWT令牌,这个令牌就是后续所有请求的入场券。JWT的payload只存用户ID和用户名,不存密码等敏感信息,有效期设24小时。客户端拿到token后每次请求放到Authorization请求头里,后端通过拦截器解析并校验。
@Component public class JwtInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { // 放行预检请求 if ("OPTIONS".equals(request.getMethod())) { return true; } String token = request.getHeader("Authorization"); if (token == null || token.isEmpty()) { throw new BusinessException(401, "未登录或登录已过期"); } // 解析token,从Redis或者JWT中获取用户信息 Claims claims = JwtUtil.parseToken(token.replace("Bearer ", "")); UserHolder.set(claims.get("userId").toString()); return true; } }有个我自己踩过的坑:前端通过Axios发请求时,如果你指定了Authorization: Bearer xxx,浏览器会先发一个OPTIONS请求试探后端允不允许跨域,这个请求不会携带token。如果不做放行处理,前端所有请求都会报跨域错误,而日志里根本看不到业务异常,排查起来很头疼。所以上面代码里第一步先放行OPTIONS,这是必须写的。
3.3 核心记账接口设计
记账是整个系统的核心,先说清楚这背后的事务逻辑。
新增一笔支出时,操作分两步:先在t_transaction插入流水记录,再更新t_account表的余额。这两步必须放在同一个数据库事务里,否则流水写进去了余额没扣,或者余额扣了流水没记录,数据就对不上了。
@Transactional(rollbackFor = Exception.class) public void addTransaction(TransactionDTO dto) { Long userId = UserHolder.get(); // 1. 校验账户归属,防止越权操作 Account account = accountMapper.selectById(dto.getAccountId()); if (account == null || !account.getUserId().equals(userId)) { throw new BusinessException("账户不存在"); } // 2. 构造流水并插入 Transaction tx = new Transaction(); tx.setUserId(userId); tx.setAccountId(dto.getAccountId()); tx.setType(dto.getType()); tx.setAmount(dto.getAmount()); tx.setTradeTime(dto.getTradeTime()); tx.setRemark(dto.getRemark()); // 支出时categoryId必须存在且属于当前用户 if (dto.getType() == 2) { Category category = categoryMapper.selectById(dto.getCategoryId()); if (category == null || !category.getUserId().equals(userId)) { throw new BusinessException("分类不存在"); } tx.setCategoryId(dto.getCategoryId()); } transactionMapper.insert(tx); // 3. 联动更新余额 // 支出:balance = balance - amount // 收入:balance = balance + amount LambdaUpdateWrapper<Account> wrapper = new LambdaUpdateWrapper<>(); wrapper.eq(Account::getId, dto.getAccountId()); if (dto.getType() == 2) { wrapper.setSql("balance = balance - " + dto.getAmount()); } else if (dto.getType() == 1) { wrapper.setSql("balance = balance + " + dto.getAmount()); } accountMapper.update(null, wrapper); }联动更新余额的时候有一个细节:如果先查出账户余额,然后在内存里加减,再整体更新,高并发下会丢更新。我之前做过压力测试,两个线程同时扣款,最终余额只扣了一次。解决办法就是上面代码里用的SQL原生自增减:balance = balance - amount,把加减操作交给数据库完成,数据库在这类单行更新上天然有行锁,这个安全性远比Java代码层面处理要稳。
删除流水的逻辑更要谨慎处理。删除一条收入记录,意味着余额要反向扣减;删除一条支出记录,余额要反向增加。而且转账操作涉及两个账户,删除时两边余额都要联动调回来。部分教程只做了流水删除而忽略了余额回滚,导致账目越记越乱,最后只能删库重建,这种情况我就不多说了,自己体会。
3.4 统计报表SQL优化策略
统计报表这个需求刚看起来简单,要个分类汇总就行,但当你真的在百万行级流水表上执行GROUP BY聚合,速度完全没法看。我的方案是:普通查询用索引覆盖,热点统计走定时汇总表。
明细查询的优化思路是建立联合索引,让查询条件全部走索引扫描:
ALTER TABLE t_transaction ADD INDEX idx_user_time_type (user_id, trade_time, type);当用户要查“2025年3月的餐饮支出明细”,SQL的WHERE条件恰好命中了这个联合索引,数据库不需要回表扫描全表,查询时间从几百毫秒降到十几毫秒。这是成本最低、见效最快的优化方案。
分类汇总的统计写成这样:
public List<CategoryStatVO> getCategoryStat(String month) { Long userId = UserHolder.get(); return transactionMapper.selectCategoryStat(userId, month); }Mapper里就是一条带条件判断的联表聚合查询:
<select id="selectCategoryStat" resultType="com.example.vo.CategoryStatVO"> SELECT c.name AS categoryName, c.id AS categoryId, SUM(t.amount) AS totalAmount FROM t_transaction t INNER JOIN t_category c ON t.category_id = c.id WHERE t.user_id = #{userId} AND t.type = 2 AND DATE_FORMAT(t.trade_time, '%Y-%m') = #{month} GROUP BY c.id, c.name ORDER BY totalAmount DESC </select>DATE_FORMAT(t.trade_time, '%Y-%m')这种写法是对字段做函数运算,会让索引失效。数据量在几十万以内体感不明显,但到了百万级会明显拖慢速度。更稳健的做法是提前在Java代码里算出月份起止时间点,用BETWEEN区间查询,这样索引能正常生效。代码改成这样之后,执行计划从ALL变成了range,性能差距在数据量大时可以拉开一个数量级。我自己做过测试,在50万条流水的情况下,函数写法需要400毫秒,改写成区间查询后只用了20毫秒,这里面的差距不需要多解释了吧。
资产总览的SQL要统计所有账户的余额汇总、本月总收入和本月总支出。这里有个思路建议:账户余额可以直接从账户表汇总,但收支总额如果每次现算,会随着流水增长越来越慢。我的做法是每天凌晨执行一个定时任务,把前一天的汇总结果存入一张统计表,报表页面只查统计表。对个人系统来说,实时性要求没那么高,定时汇总带来的性能收益却非常明显。要用Spring自带的@Scheduled,在启动类加@EnableScheduling就行。
4. 前端页面与接口联调
4.1 Vue项目初始化和路由设计
前端基于Vue 3 + Vite + Element Plus搭建。用Vite创建项目后,需要安装几个关键依赖:vue-router管理路由、pinia管理全局状态、axios发送请求、echarts绘制图表、dayjs处理日期格式。
npm create vite@latest finance-web -- --template vue cd finance-web npm install npm install vue-router@4 pinia axios element-plus @element-plus/icons-vue echarts dayjs路由设计上,我把需要登录才能访问的页面统一包了一层AuthLayout,在路由守卫里检查是否有token,没有就重定向到登录页:
// 路由守卫 router.beforeEach((to, from, next) => { const token = localStorage.getItem('token') if (to.meta.requiresAuth && !token) { next('/login') } else if (to.path === '/login' && token) { next('/dashboard') } else { next() } })这样做的好处是权限逻辑集中在一处,新增页面时只需在路由配置里给meta加一个requiresAuth字段,不用在每一个页面组件里重复写登录判断。对于多个前端开发者协作的项目,这种约定能避免很多人为疏漏。需要特别注意的是,路由守卫只是用户体验层面的控制,真正拦住非法请求的是后端JWT拦截器——前端代码是暴露在浏览器里的,任何基于前端的权限控制都可以被绕过,这一点和安全相关,我先说明一下。
4.2 Axios封装和接口请求统一处理
Axios请求封装非常重要,一个好的封装能让前后端联调效率提升很多。我的做法是,用axios.create()初始化一个实例,设置基础路径/api和时间限制,然后在请求拦截器里带上token,响应拦截器统一处理错误码。
// api/request.js import axios from 'axios' import { ElMessage } from 'element-plus' import router from '../router' const service = axios.create({ baseURL: '/api', timeout: 15000 }) // 请求拦截器:自动携带token service.interceptors.request.use(config => { const token = localStorage.getItem('token') if (token) { config.headers.Authorization = `Bearer ${token}` } return config }) // 响应拦截器:统一处理错误 service.interceptors.response.use( response => { const res = response.data if (res.code !== 200) { ElMessage.error(res.message || '请求失败') return Promise.reject(new Error(res.message)) } return res.data }, error => { if (error.response && error.response.status === 401) { localStorage.removeItem('token') router.push('/login') } ElMessage.error(error.response?.data?.message || '网络异常') return Promise.reject(error) } ) export default service引入/api前缀后,开发环境要通过Vite的代理把请求转发到后端。在vite.config.js里配置:
server: { port: 5173, proxy: { '/api': { target: 'http://localhost:8080', changeOrigin: true } } }在生产环境,部署时Nginx也必须有对应的/api转发配置,否则前端页面打开后所有请求都会404。这个问题我在部署那一节里详细说,是新手最容易摔跤的地方之一。
4.3 仪表盘和报表页面的组件实现
报表页面我是用ECharts实现的。为了和Vue的数据响应式配合,我用watch监听筛选条件,条件变化时重新请求数据并更新图表。
折线图的配置项里特别注意xAxis的类型是category还是time。按月统计趋势图我用的category类型,因为月份本身是离散的,用time类型反而会产生连续坐标轴的间隙。分类占比饼图则需要注意数值格式化,ECharts默认显示原始数值,经过格式化后展示为“餐饮占比35%”这种可读性更强的文案。
const option = { tooltip: { trigger: 'axis' }, xAxis: { type: 'category', data: months }, yAxis: { type: 'value' }, series: [ { name: '收入', type: 'line', smooth: true, data: incomeList }, { name: '支出', type: 'line', smooth: true, data: expenseList } ] }这里有一个之前踩过的坑:ECharts在容器宽度为0的时候初始化,图表会显示成默认尺寸,宽高全是0。路由切换来回时特别常见。解决办法是初始化之前确认容器宽度不为零,或者直接给容器设置min-height。我习惯给图表容器一个固定高度如360px,并且在nextTick之后再初始化,这个顺序通常能避免大部分异常显现问题。
4.4 记账表单和校验逻辑
记账页面是最核心的交互页面,一个表单搞定,字段包括类型、账户、分类、金额、日期、备注。表单校验规则里有几个业务细节得提前想清楚:
第一点是金额必须大于0,且最多保留两位小数。第二点是支出类型下分类不能为空,转账类型下需要选择转入账户且不能和当前账户相同。第三点是交易日期不能晚于当前时间,防止用户手滑选了未来日期。
<el-form :model="form" :rules="rules" ref="formRef"> <el-form-item label="类型" prop="type"> <el-radio-group v-model="form.type"> <el-radio :value="1">收入</el-radio> <el-radio :value="2">支出</el-radio> <el-radio :value="3">转账</el-radio> </el-radio-group> </el-form-item> <el-form-item label="账户" prop="accountId"> <el-select v-model="form.accountId" placeholder="选择账户"> <el-option v-for="acc in accounts" :key="acc.id" :label="acc.name" :value="acc.id" /> </el-select> </el-form-item> <!-- 支出时显示分类选择 --> <el-form-item label="分类" prop="categoryId" v-if="form.type !== 3"> <el-select v-model="form.categoryId" placeholder="选择分类"> <el-option v-for="cat in categories" :key="cat.id" :label="cat.name" :value="cat.id" /> </el-select> </el-form-item> <!-- 转账时显示转入账户 --> <el-form-item label="转入账户" prop="transferAccountId" v-if="form.type === 3"> <el-select v-model="form.transferAccountId" placeholder="选择转入账户"> <el-option v-for="acc in otherAccounts" :key="acc.id" :label="acc.name" :value="acc.id" /> </el-select> </el-form-item> <el-form-item label="金额" prop="amount"> <el-input-number v-model="form.amount" :precision="2" :min="0.01" /> </el-form-item> </el-form>分类选择的数据需要根据当前选择的类型动态换:类型选了支出,下拉框里就显示支出分类;切到收入,自动换成收入分类。这里用computed对分类数组过滤就行。分类的默认数据可以在用户注册时自动创建——建几个常见的分类比如“餐饮”“交通”“购物”“工资”“奖金”,免得新用户注册完面对空页面不知道从哪里开始记账。这个细节能显著提升新用户的首次体验。
5. 完整部署步骤实录
5.1 本地环境准备清单
进入部署环节之前,先检查本地环境是否齐备。我列的这套清单都是开发这个项目的最低要求,缺哪个装哪个就行:
- JDK 1.8+:后端运行环境。验证方式:
java -version能输出8以上版本。 - MySQL 5.7+:数据库服务。我用的是8.0版本,注意8.0的认证插件和JDBC驱动版本需要匹配。
- Node.js 16+:前端构建工具链,Vite 4要求Node版本不能太低。
- Maven 3.6+:后端的依赖管理和打包工具。
以上环境安装完成后,先启动MySQL,执行前面贴的建表SQL脚本。执行方式没那么多讲究,用命令行或者客户端工具都行,关键是要确认几张表真的建出来了。然后修改后端application.yml里的数据库账号密码,改成你自己的真实配置。
启动后端的方式很简单,在项目根目录执行mvn spring-boot:run,看到控制台输出Started Application就说明启动成功。前端在项目根目录执行npm install安装依赖,然后npm run dev启动开发服务器,浏览器访问http://localhost:5173,注册一个账号,试着记一笔流水,本地联调就完成了。
5.2 后端打包与部署
本地联调通过后,开始部署上线。我用的是一台2核4G的云服务器,Linux系统,这个配置跑个人理财系统绰绰有余。
先打包前端:
cd finance-web npm run build执行完会在dist目录生成静态文件,这些就是后续要部署到Nginx里的网页资源。打包时间根据机器性能大概10秒到1分钟,如果构建报错,大多数情况是依赖版本冲突或Node版本过低,把错误信息贴到搜索引擎基本都能找到解决方案。
再打包后端:
cd finance-server mvn clean package -DskipTests生成的target目录下会有一个finance-0.0.1-SNAPSHOT.jar文件,这就是后端的所有内容。Spring Boot内置了Tomcat,所以不需要安装额外容器,直接在服务器上用java -jar就能运行。
上传到服务器后,我习惯放到/opt/finance/目录下,然后写一个启动脚本方便管理:
#!/bin/bash APP_NAME=finance-0.0.1-SNAPSHOT.jar case "$1" in start) nohup java -Xms256m -Xmx512m -jar /opt/finance/$APP_NAME --spring.profiles.active=prod > /opt/finance/log.log 2>&1 & echo "app started" ;; stop) PID=$(ps -ef | grep $APP_NAME | grep -v grep | awk '{print $2}') if [ -n "$PID" ]; then kill $PID echo "app stopped" fi ;; restart) $0 stop sleep 2 $0 start ;; esac-Xms256m -Xmx512m是JVM堆内存参数,对个人项目来说,512M上限足够用了。不设置的话JVM默认取物理内存的1/4,2核4G服务器上可能分配1G给Java进程,白白浪费资源。
5.3 Nginx反向代理与前端部署
Nginx在这里做两件事:托管前端静态文件,以及把/api请求反向代理到后端服务。配置不到20行:
server { listen 80; server_name your_domain_or_ip; # 前端静态文件 root /usr/share/nginx/html; index index.html; # 所有 /api 请求转发到后端8080端口 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; } # 解决前端history路由刷新404 location / { try_files $uri $uri/ /index.html; } # 静态资源缓存 location ~* \.(js|css|png|jpg|gif|svg)$ { expires 7d; add_header Cache-Control "public, no-transform"; } }proxy_pass的两种情况值得留意:写http://127.0.0.1:8080(不带斜杠)时,请求路径原样转发,/api/auth/login还是/api/auth/login,后端Controller的RequestMapping需要包含/api前缀;如果写成http://127.0.0.1:8080/(带斜杠),/api会被剥掉变成/auth/login。我用的是前者,后端统一加了/api前缀,这样更直观。
try_files $uri $uri/ /index.html这句是必须的。Vue Router开的是history模式,路由路径是前端管理的,浏览器直接访问/dashboard时Nginx并不知道这个路径不需要后端处理,如果没有这条配置就会返回404。try_files的作用是让Nginx先找对应的物理文件,找不到就返回index.html,由前端路由接管渲染。
5.4 远程数据库连接配置
部署后端的最后一个关键点:数据库连接地址不能写localhost,因为后端运行在服务器上,它连的是服务器上的MySQL。如果用本地开发时连的localhost:3306,部署后后端的localhost指的是云服务器自身,只要你的MySQL也装在同一台服务器上,这个配置反而不需要改。
如果你的MySQL在前置配置里开了远程访问权限,为了安全性建议不要暴露3306端口到公网。稳妥的做法是:数据库只监听内网地址,应用服务器通过内网IP连接。云服务商的安全组策略里只放行80和443端口就行,数据库端口只允许内网访问,别为了省事把3306也对外开放,这是安全红线。
6. 系统安全加固与常见问题排查
6.1 密码加密与接口防刷
安全这块必须有基础底线,但对个人项目来说做重了反而影响体验,把握住关键环节就够了。用户密码用BCrypt加密存储,登录令牌用JWT并设置过期时间,这些前面已经实现了。如果要给这个系统加锁,建议从这几个方向入手:
- 操作日志:记录用户的登录IP、登录时间、关键操作行为。出了事故能追溯,也能发现异常登录。
- 接口限流:登录接口可以做简单的时间窗口限制,比如同一个IP一分钟内最多尝试5次登录,暴力破解的难度会大幅上升。
- 越权防护:所有涉及用户数据的查询都要带上
userId条件,不能只凭前端传入的ID查询。这是多用户系统必须养成的编码习惯。
之前审过不少类似的入门项目,很多都存在严重的越权漏洞——把账户ID改成别人的数字,就能看到别人的账户。后端代码里必须在所有数据访问层强制校验归属,这也是我为什么在记账接口里反复强调账户归属校验的原因。前端隐藏按钮不算安全手段,真正的防护在服务端。
6.2 生产环境数据库备份策略
个人项目最容易忽略的就是数据库备份。数据是无价的,服务器随时可能故障、硬盘随时可能损坏,不备份的后果就是重开。我不希望读者辛辛苦苦记了几个月的账,最后因为没备份全没了。
每天凌晨3点自动备份,保留最近15天的备份文件。定时任务配置:
# /etc/crontab 添加一行 0 3 * * * /opt/finance/backup.sh备份脚本:
#!/bin/bash BACKUP_DIR=/opt/backup DATE=$(date +%Y%m%d_%H%M%S) MYSQL_PASSWORD=your_password # 导出全库SQL mysqldump -uroot -p$MYSQL_PASSWORD finance_db > $BACKUP_DIR/finance_$DATE.sql # 压缩文件,压缩后体积会小很多 gzip $BACKUP_DIR/finance_$DATE.sql # 清理15天前的备份 find $BACKUP_DIR -name "*.sql.gz" -mtime +15 -delete备份文件定期下载到本地,或者同步到对象存储服务。光在服务器上备份不叫备份,服务器整个报废的时候你会感谢这个习惯。恢复数据也很简单:gunzip finance_xxx.sql.gz && mysql -uroot -p finance_db < finance_xxx.sql,几分钟的事。
6.3 常见部署问题速查表
把我在这类项目里遇到过的典型问题整理成一张表,方便排查:
| 问题现象 | 可能原因 | 解决方式 |
|---|---|---|
| 前端页面能打开,但所有接口报404 | Nginx的/api代理配置缺失或前缀不一致 | 检查Nginx配置里location /api的proxy_pass,确认后端Controller带了/api前缀 |
| 刷新页面后404 | Vue路由使用history模式,Nginx缺少try_files配置 | 添加try_files $uri $uri/ /index.html; |
接口报Communications link failure | MySQL连接参数不对或数据库没启动 | systemctl status mysql看服务状态,检查连接地址、端口、账号密码 |
| 跨域报错CORS | 开发环境Nginx代理配置缺失 | 在Vite的proxy里配置正确的target地址 |
| 数据库中文乱码 | 连接参数没写characterEncoding=utf8 | 在JDBC URL加上?useUnicode=true&characterEncoding=utf8 |
| 后端启动报端口被占用 | 8080端口被其他程序占用 | `netstat -tlnp |
| 请求超时,服务无响应 | 内存不足或SQL查询慢 | free -h查内存,dmesg看是否OOM Kill,SQL执行计划用EXPLAIN分析 |
| JWT登录后反复跳到登录页 | token过期时间太短或服务器时间不准确 | 在JWT过期时间设置后检查一次,用date命令同步服务器时间 |
这里挑一个最常见的问题展开说——前端页面能打开但接口全部404。这通常是前后端部署割裂导致的:你在Nginx的location /里配置了前端路由,静态资源正常返回没问题,但前端请求的/api路径没有被任何location匹配到,于是Nginx把请求也当成了静态资源请求,找不到文件就返回了前端页面而不是后端数据。Vue Router把/api/auth/login这个路径当成前端路由处理了一下,返回了200,但body里是HTML不是JSON,前端解析失败报了一堆奇怪的错。排查步骤很简单:浏览器按F12打开Network,看接口请求的响应内容是HTML还是JSON。如果是HTML,检查Nginx里有没有location /api的配置,优先级问题就出在这里。
6.4 性能调优的实践方法
个人理财系统不需要过度性能调优,但几个关键配置值得做。
MySQL连接池配置,Spring Boot默认的HikariCP已经很优秀,只需要手动设置连接数的上下限,避免默认值在低配置服务器上创建过多连接。在配置文件里加上:
spring: datasource: hikari: maximum-pool-size: 10 minimum-idle: 5SQL层面有几个原则:大表一定记住加索引,查询条件尽量走联合索引;避免SELECT *,只查需要的字段;转账、记账这类涉及多表更新操作必须有事务;报表查询如果用定时汇总能扛的话就尽量别在SQL里做先聚合再子查询的复杂嵌套。实测下来,个人系统在50万条流水规模下,只要做好这些基础优化,接口响应都能保持在100毫秒以内,再往下压已经没有太大意义了。
7. 实操过程中总结的几点重要经验
项目从零到上线走完一遍,有几个体会值得讲出来,给刚开始动手的读者做参考。
第一,先画清楚表结构再写代码。我见过太多人上来先写接口,写到一半发现缺字段、缺表关联,回头改表结构改代码,来回折腾的时间和精力远超前期设计投入。这个项目如果让我重做,第一件事仍然是坐下来把ER图理清楚。数据模型是整个系统最核心的骨架,骨架对了,往上填业务逻辑只是时间问题。
第二,安全问题从第一天就要重视。前后端分离的项目,前端权限控制只是一层纸,真正守住安全底线的是后端。这个项目虽然小,但也是从注册登录、JWT鉴权、参数校验、越权防护一步步设计过来的,尽可能做到新增代码也按这套规范来。
第三,部署环节不是简单的最后一步。很多人在本地跑通了就以为项目完成,结果部署时被Nginx配置绕晕。建议从一开始就按“本地联调-打包构建-服务器部署”的完整流程调试,把每一步都严格落实,等真到了交付环节就会顺利很多。你自己的服务器环境配置和本地大概率有差异,别等最后一天才处理环境问题。
第四,关于数据备份的建议,虽然重复了很多次,但值得再说一次。哪怕是个人用的账本工具,数据丢了同样损失巨大。定时备份、异地保存、定期演练恢复,这些习惯越早养成越好。等真的需要恢复数据库的那一天,你会感谢几个月前写了备份脚本的自己。
最后如果还想继续往下扩展,给你几条清晰的技术进阶路线:要支持多人协作的家庭账本,可以在现有基础上增加共享账本表,引入WebSocket实时同步;要分析开源社区的金融数据,可以参考大厂的数据仓库分层架构,引入定时同步任务;要把系统转为微信小程序端,可以基于这套API重新写小程序前端适配;要做资产趋势预测,可以接入机器学习库做简单的时间序列预测。每一步都有充足的学习材料,而这个项目就是那条最稳固的起点。