作为一名长期从事Java全栈开发的工程师,我最近完成了一个前后端分离的个人理财系统。这个项目源于我自己的实际需求——市面上大多数理财工具要么功能过于简单,要么操作复杂,很难满足技术从业者对数据掌控和自定义分析的需求。经过两个月的开发和迭代,最终形成了一个功能完善、技术栈主流的解决方案。
这个系统采用SpringBoot+Vue.js的技术组合,后端使用MyBatis进行数据持久化,MySQL作为数据库,实现了完整的个人财务管理闭环。与常见的记账软件不同,本系统特别强化了数据可视化分析和多维度报表功能,让用户不仅能记录收支,更能深入理解自己的消费模式和财务状况。
在项目启动前,我对比了多种架构方案。传统单体架构虽然部署简单,但前端灵活性差,难以实现复杂的数据可视化交互。而前后端分离架构具有以下优势:
提示:选择MyBatis-Plus而非JPA是考虑到团队熟悉度,且需要编写复杂SQL进行财务分析。对于简单CRUD项目,JPA可能是更好选择。
根据个人理财的核心需求,设计了以下主要表结构:
sql复制CREATE TABLE `users` (
`user_id` bigint NOT NULL AUTO_INCREMENT,
`username` varchar(50) NOT NULL,
`password_hash` varchar(100) NOT NULL,
`email` varchar(100) NOT NULL,
`phone` varchar(20) DEFAULT NULL,
`register_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
`last_login` datetime DEFAULT NULL,
`status` tinyint NOT NULL DEFAULT '1',
PRIMARY KEY (`user_id`),
UNIQUE KEY `idx_username` (`username`),
UNIQUE KEY `idx_email` (`email`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
sql复制CREATE TABLE `financial_records` (
`record_id` bigint NOT NULL AUTO_INCREMENT,
`user_id` bigint NOT NULL,
`amount` decimal(10,2) NOT NULL,
`category` varchar(50) NOT NULL,
`sub_category` varchar(50) DEFAULT NULL,
`transaction_date` date NOT NULL,
`description` varchar(200) DEFAULT NULL,
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
`account_type` varchar(50) NOT NULL,
PRIMARY KEY (`record_id`),
KEY `idx_user_date` (`user_id`,`transaction_date`),
KEY `idx_category` (`category`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
sql复制CREATE TABLE `budgets` (
`budget_id` bigint NOT NULL AUTO_INCREMENT,
`user_id` bigint NOT NULL,
`category` varchar(50) NOT NULL,
`limit_amount` decimal(10,2) NOT NULL,
`current_amount` decimal(10,2) NOT NULL DEFAULT '0.00',
`start_date` date NOT NULL,
`end_date` date NOT NULL,
`update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
`is_notified` tinyint NOT NULL DEFAULT '0',
PRIMARY KEY (`budget_id`),
UNIQUE KEY `idx_user_category` (`user_id`,`category`,`start_date`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
索引设计:
数据类型选择:
分表考虑:
code复制src/main/java
├── config/ # 配置类
├── controller/ # 控制器
├── dto/ # 数据传输对象
├── entity/ # 实体类
├── exception/ # 异常处理
├── mapper/ # MyBatis映射接口
├── service/ # 业务服务
│ ├── impl/ # 服务实现
├── util/ # 工具类
└── Application.java # 启动类
采用JWT进行无状态认证,核心代码如下:
java复制// JWT工具类
public class JwtUtil {
private static final String SECRET_KEY = "your-256-bit-secret";
private static final long EXPIRATION_TIME = 864_000_000; // 10天
public static String generateToken(UserDetails userDetails) {
return Jwts.builder()
.setSubject(userDetails.getUsername())
.setIssuedAt(new Date())
.setExpiration(new Date(System.currentTimeMillis() + EXPIRATION_TIME))
.signWith(SignatureAlgorithm.HS256, SECRET_KEY)
.compact();
}
public static String extractUsername(String token) {
return Jwts.parser()
.setSigningKey(SECRET_KEY)
.parseClaimsJws(token)
.getBody()
.getSubject();
}
}
财务记录的核心业务逻辑包括添加记录、查询统计和预算检查:
java复制@Service
@RequiredArgsConstructor
public class RecordServiceImpl implements RecordService {
private final RecordMapper recordMapper;
private final BudgetService budgetService;
@Transactional
@Override
public void addRecord(RecordDTO recordDTO) {
FinancialRecord record = convertToEntity(recordDTO);
recordMapper.insert(record);
// 如果是支出,检查预算
if ("EXPENSE".equals(record.getCategory())) {
budgetService.checkBudget(record.getUserId(),
record.getSubCategory(),
record.getAmount());
}
}
@Override
public List<RecordVO> getRecordsByDateRange(Long userId, LocalDate start, LocalDate end) {
return recordMapper.selectByDateRange(userId, start, end);
}
@Override
public Map<String, BigDecimal> getCategorySummary(Long userId, LocalDate start, LocalDate end) {
return recordMapper.selectCategorySummary(userId, start, end);
}
}
预算服务会在每次添加支出记录时检查是否超出预算:
java复制@Service
@RequiredArgsConstructor
public class BudgetServiceImpl implements BudgetService {
private final BudgetMapper budgetMapper;
private final NotificationService notificationService;
@Override
public void checkBudget(Long userId, String category, BigDecimal amount) {
LocalDate today = LocalDate.now();
Budget budget = budgetMapper.selectCurrentBudget(userId, category, today);
if (budget != null) {
budget.setCurrentAmount(budget.getCurrentAmount().add(amount));
budgetMapper.updateById(budget);
// 检查是否超过预算的80%,触发提醒
if (budget.getCurrentAmount().compareTo(
budget.getLimitAmount().multiply(new BigDecimal("0.8"))) > 0
&& budget.getIsNotified() == 0) {
notificationService.sendBudgetWarning(userId, budget);
budget.setIsNotified(1);
budgetMapper.updateById(budget);
}
}
}
}
code复制src/
├── api/ # API请求封装
├── assets/ # 静态资源
├── components/ # 公共组件
├── composables/ # 组合式函数
├── router/ # 路由配置
├── stores/ # Pinia状态管理
├── styles/ # 全局样式
├── utils/ # 工具函数
├── views/ # 页面组件
└── main.js # 入口文件
使用Pinia管理应用状态,核心store设计:
javascript复制// stores/record.js
export const useRecordStore = defineStore('record', {
state: () => ({
records: [],
categories: ['餐饮', '购物', '交通', '娱乐', '医疗', '教育', '其他'],
isLoading: false
}),
actions: {
async fetchRecords(params) {
this.isLoading = true;
try {
const { data } = await getRecords(params);
this.records = data;
} finally {
this.isLoading = false;
}
},
async addRecord(newRecord) {
await addNewRecord(newRecord);
await this.fetchRecords(); // 刷新列表
}
},
getters: {
totalExpenses() {
return this.records
.filter(r => r.category === 'EXPENSE')
.reduce((sum, r) => sum + r.amount, 0);
}
}
});
使用ECharts实现消费趋势和分类占比图表:
vue复制<template>
<div ref="chartRef" style="width: 100%; height: 400px;"></div>
</template>
<script setup>
import { ref, onMounted, watch } from 'vue';
import * as echarts from 'echarts';
import { useRecordStore } from '@/stores/record';
const recordStore = useRecordStore();
const chartRef = ref(null);
let chart = null;
onMounted(() => {
chart = echarts.init(chartRef.value);
updateChart();
});
watch(() => recordStore.records, updateChart);
function updateChart() {
const option = {
tooltip: {
trigger: 'axis'
},
legend: {
data: ['支出', '收入']
},
xAxis: {
type: 'category',
data: ['1月', '2月', '3月', '4月', '5月', '6月']
},
yAxis: {
type: 'value'
},
series: [
{
name: '支出',
type: 'line',
data: recordStore.monthlyExpenses
},
{
name: '收入',
type: 'line',
data: recordStore.monthlyIncomes
}
]
};
chart.setOption(option);
}
</script>
使用Vuelidate进行表单验证:
vue复制<script setup>
import { useVuelidate } from '@vuelidate/core';
import { required, numeric, minValue } from '@vuelidate/validators';
const form = ref({
amount: '',
category: '',
date: new Date(),
description: ''
});
const rules = {
amount: { required, numeric, minValue: minValue(0.01) },
category: { required },
date: { required }
};
const v$ = useVuelidate(rules, form);
async function submitForm() {
const isValid = await v$.value.$validate();
if (!isValid) return;
// 提交逻辑
}
</script>
环境准备:
数据库初始化:
bash复制mysql -u root -p < schema.sql
应用打包:
bash复制mvn clean package
运行应用:
bash复制java -jar target/finance-system-0.0.1-SNAPSHOT.jar
生产环境建议:
环境准备:
安装依赖:
bash复制npm install
开发模式运行:
bash复制npm run dev
生产构建:
bash复制npm run build
部署静态资源:
nginx复制location /api {
proxy_pass http://backend:8080;
proxy_set_header Host $host;
}
问题现象:前端访问API时出现CORS错误
解决方案:
java复制@Configuration
public class CorsConfig implements WebMvcConfigurer {
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/**")
.allowedOrigins("*")
.allowedMethods("*")
.allowedHeaders("*");
}
}
问题现象:数据库中的时间与前端显示不一致
解决方案:
sql复制SET GLOBAL time_zone = '+8:00';
code复制spring.datasource.url=jdbc:mysql://localhost:3306/finance?useSSL=false&serverTimezone=Asia/Shanghai
javascript复制dayjs.extend(utc);
dayjs.extend(timezone);
const localTime = dayjs.utc(serverTime).tz('Asia/Shanghai').format('YYYY-MM-DD HH:mm:ss');
问题现象:报表查询速度慢
解决方案:
sql复制ALTER TABLE financial_records ADD INDEX idx_user_category (user_id, category);
java复制@Cacheable(value = "monthlyReport", key = "#userId + '-' + #year + '-' + #month")
public MonthlyReportVO getMonthlyReport(Long userId, int year, int month) {
// 查询逻辑
}
java复制PageHelper.startPage(pageNum, pageSize);
List<Record> records = recordMapper.selectByUser(userId);
return new PageInfo<>(records);
多币种支持:
数据导入导出:
移动端适配:
智能分析:
第三方集成:
在实际开发过程中,我遇到的最棘手的问题是预算提醒的实时性。最初采用定时任务检查的方式,但要么延迟太大,要么对数据库压力过大。最终解决方案是在每次支出记录添加时即时检查,并结合Redis缓存预算数据,既保证了实时性,又避免了频繁查询数据库。