feat: 初始化收支管理系统
This commit is contained in:
+55
@@ -0,0 +1,55 @@
|
||||
# Gradle
|
||||
.gradle/
|
||||
build/
|
||||
!gradle/wrapper/gradle-wrapper.jar
|
||||
!gradle/wrapper/gradle-wrapper.properties
|
||||
|
||||
# Java
|
||||
*.class
|
||||
*.jar
|
||||
*.war
|
||||
*.ear
|
||||
*.log
|
||||
hs_err_pid*
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
*.iml
|
||||
*.iws
|
||||
*.ipr
|
||||
.vscode/
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Node
|
||||
frontend/node_modules/
|
||||
frontend/dist/
|
||||
frontend/.npm/
|
||||
|
||||
# Python
|
||||
__pycache__/
|
||||
*.pyc
|
||||
venv/
|
||||
.venv/
|
||||
|
||||
# Logs
|
||||
logs/
|
||||
*.log
|
||||
|
||||
# Local environment
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
# Build artifacts
|
||||
backend.log
|
||||
frontend/frontend.log
|
||||
distributions/
|
||||
|
||||
# Node extras
|
||||
frontend/node_modules1/
|
||||
frontend/node_modules*.zip
|
||||
@@ -0,0 +1,94 @@
|
||||
# 收支管理系统
|
||||
|
||||
个人收支管理系统,支持收入/支出记录管理、分类管理、月度统计与图表展示。
|
||||
|
||||
## 技术栈
|
||||
|
||||
| 层级 | 技术 |
|
||||
|---|---|
|
||||
| 后端 | Spring Boot 3.3.6 + Java 21 + MyBatis-Plus + MySQL |
|
||||
| 前端 | Vue 3 + TypeScript + Vite + Naive UI + ECharts |
|
||||
| 构建 | Gradle 8.13 (后端) / npm (前端) |
|
||||
| 认证 | JWT |
|
||||
| API 文档 | Knife4j (OpenAPI 3) |
|
||||
| JDK 版本 | 21.0.12.1 (Temurin LTS) |
|
||||
| Node.js 版本 | v24.0.0 |
|
||||
|
||||
## 前置条件
|
||||
|
||||
- JDK 21.0.12.1 Temurin LTS,安装在 `D:\venv\jdk-21`
|
||||
- Node.js v24.0.0
|
||||
|
||||
## 数据库初始化
|
||||
|
||||
首次运行前需建表,执行 `src/main/resources/sql/init.sql`,或运行 Python 脚本:
|
||||
|
||||
```bash
|
||||
pip install pymysql bcrypt
|
||||
python init_db.py
|
||||
python generate_test_data.py
|
||||
```
|
||||
|
||||
## 启动后端
|
||||
|
||||
```bash
|
||||
cd my-app
|
||||
set JAVA_HOME=D:\venv\jdk-21
|
||||
gradlew.bat bootRun
|
||||
```
|
||||
|
||||
- 后端地址:http://localhost:8080
|
||||
- API 文档:http://localhost:8080/doc.html
|
||||
|
||||
## 启动前端
|
||||
|
||||
```bash
|
||||
cd my-app/frontend
|
||||
npm run dev
|
||||
```
|
||||
|
||||
- 前端地址:http://localhost:5173
|
||||
|
||||
## 打包
|
||||
|
||||
将项目源代码打包为 tar.gz(不含构建产物和 node_modules):
|
||||
|
||||
```bash
|
||||
gradlew.bat archive
|
||||
```
|
||||
|
||||
输出文件:`build/distributions/finance-app-0.0.1-SNAPSHOT-{时间戳}.tar.gz`
|
||||
|
||||
## 测试账号
|
||||
|
||||
| 用户名 | 密码 |
|
||||
|---|---|
|
||||
| demo | 123456 |
|
||||
|
||||
## 项目结构
|
||||
|
||||
```
|
||||
my-app/
|
||||
├── build.gradle
|
||||
├── src/main/java/com/example/myapp/
|
||||
│ ├── controller/ # 接口层
|
||||
│ ├── service/ # 业务层
|
||||
│ ├── mapper/ # 数据访问层
|
||||
│ ├── entity/ # 实体类
|
||||
│ ├── dto/ # 请求对象
|
||||
│ ├── vo/ # 响应对象
|
||||
│ ├── config/ # 配置类
|
||||
│ ├── common/ # 公共类
|
||||
│ ├── interceptor/ # 拦截器
|
||||
│ └── util/ # 工具类
|
||||
├── src/main/resources/
|
||||
│ ├── application.yml
|
||||
│ ├── mapper/
|
||||
│ └── sql/init.sql
|
||||
└── frontend/ # Vue 3 前端
|
||||
└── src/
|
||||
├── api/ # API 请求封装
|
||||
├── router/ # 路由配置
|
||||
├── store/ # 状态管理
|
||||
└── views/ # 页面组件
|
||||
```
|
||||
@@ -0,0 +1,76 @@
|
||||
plugins {
|
||||
id 'java'
|
||||
id 'org.springframework.boot' version '3.3.6'
|
||||
id 'io.spring.dependency-management' version '1.1.7'
|
||||
}
|
||||
|
||||
group = 'com.example'
|
||||
version = '0.0.1-SNAPSHOT'
|
||||
|
||||
java {
|
||||
toolchain {
|
||||
languageVersion = JavaLanguageVersion.of(21)
|
||||
}
|
||||
}
|
||||
|
||||
configurations {
|
||||
compileOnly {
|
||||
extendsFrom annotationProcessor
|
||||
}
|
||||
}
|
||||
|
||||
repositories {
|
||||
mavenCentral()
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation 'org.springframework.boot:spring-boot-starter-web'
|
||||
implementation 'org.springframework.boot:spring-boot-starter-validation'
|
||||
implementation 'org.springframework.security:spring-security-crypto'
|
||||
|
||||
implementation 'com.baomidou:mybatis-plus-spring-boot3-starter:3.5.12'
|
||||
implementation 'com.baomidou:mybatis-plus-jsqlparser:3.5.12'
|
||||
runtimeOnly 'com.mysql:mysql-connector-j'
|
||||
|
||||
implementation 'com.github.xiaoymin:knife4j-openapi3-jakarta-spring-boot-starter:4.5.0'
|
||||
|
||||
implementation 'com.alibaba:easyexcel:4.0.3'
|
||||
|
||||
implementation 'io.jsonwebtoken:jjwt-api:0.12.6'
|
||||
runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.6'
|
||||
runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.6'
|
||||
|
||||
compileOnly 'org.projectlombok:lombok'
|
||||
annotationProcessor 'org.projectlombok:lombok'
|
||||
|
||||
developmentOnly 'org.springframework.boot:spring-boot-devtools'
|
||||
|
||||
testImplementation 'org.springframework.boot:spring-boot-starter-test'
|
||||
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
|
||||
}
|
||||
|
||||
tasks.named('test') {
|
||||
useJUnitPlatform()
|
||||
}
|
||||
|
||||
import java.time.LocalDateTime
|
||||
import java.time.format.DateTimeFormatter
|
||||
|
||||
task archive(type: Tar) {
|
||||
def timestamp = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMddHHmmss"))
|
||||
archiveFileName = "finance-app-${version}-${timestamp}.tar.gz"
|
||||
destinationDirectory = file("${projectDir}/distributions")
|
||||
compression = Compression.GZIP
|
||||
|
||||
from(projectDir) {
|
||||
exclude 'build/**'
|
||||
exclude '.gradle/**'
|
||||
exclude 'distributions/**'
|
||||
exclude 'frontend/node_modules/**'
|
||||
exclude 'frontend/node_modules*/**'
|
||||
exclude 'frontend/node_modules*.zip'
|
||||
exclude 'frontend/dist/**'
|
||||
exclude '**/*.log'
|
||||
exclude 'logs/**'
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
@@ -0,0 +1,5 @@
|
||||
# Vue 3 + TypeScript + Vite
|
||||
|
||||
This template should help get you started developing with Vue 3 and TypeScript in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
|
||||
|
||||
Learn more about the recommended Project Setup and IDE Support in the [Vue Docs TypeScript Guide](https://vuejs.org/guide/typescript/overview.html#project-setup).
|
||||
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>收支管理系统</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+1432
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vue-tsc -b && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@vicons/ionicons5": "^0.13.0",
|
||||
"axios": "^1.20.0",
|
||||
"echarts": "^6.1.0",
|
||||
"naive-ui": "^2.45.2",
|
||||
"pinia": "^4.0.3",
|
||||
"vue": "^3.5.41",
|
||||
"vue-echarts": "^8.1.0",
|
||||
"vue-router": "^4.6.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.13.3",
|
||||
"@vitejs/plugin-vue": "^6.0.8",
|
||||
"@vue/tsconfig": "^0.9.1",
|
||||
"typescript": "~6.0.2",
|
||||
"vite": "^8.2.2",
|
||||
"vue-tsc": "^3.3.11"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 48 48" fill="none">
|
||||
<!-- 卷尾巴 -->
|
||||
<path d="M6 24c-1-2 0-5 2-5s2 3 0 4-3 0-3-2" stroke="#863bff" stroke-width="2" stroke-linecap="round" fill="none"/>
|
||||
<!-- 身体 -->
|
||||
<ellipse cx="22" cy="28" rx="13" ry="10" fill="#863bff"/>
|
||||
<!-- 头部 -->
|
||||
<ellipse cx="36" cy="26" rx="6" ry="6.5" fill="#863bff"/>
|
||||
<!-- 鼻子 -->
|
||||
<ellipse cx="41" cy="27" rx="3" ry="2.5" fill="#6d28d9"/>
|
||||
<!-- 鼻孔 -->
|
||||
<circle cx="40.2" cy="26.8" r="0.6" fill="#fff"/>
|
||||
<circle cx="42" cy="26.8" r="0.6" fill="#fff"/>
|
||||
<!-- 左耳 -->
|
||||
<path d="M27 20 L29 13 L33 19 Z" fill="#6d28d9"/>
|
||||
<!-- 右耳 -->
|
||||
<path d="M33 18 L35 11 L38 17 Z" fill="#6d28d9"/>
|
||||
<!-- 投币口 -->
|
||||
<rect x="17" y="17.5" width="10" height="2" rx="1" fill="#6d28d9"/>
|
||||
<!-- 眼睛 -->
|
||||
<circle cx="34" cy="24" r="1.5" fill="#fff"/>
|
||||
<circle cx="34" cy="24" r="0.7" fill="#333"/>
|
||||
<!-- 四条腿 -->
|
||||
<rect x="12" y="36" width="4" height="6" rx="2" fill="#863bff"/>
|
||||
<rect x="18" y="36" width="4" height="6" rx="2" fill="#863bff"/>
|
||||
<rect x="25" y="36" width="4" height="6" rx="2" fill="#863bff"/>
|
||||
<rect x="31" y="36" width="4" height="6" rx="2" fill="#863bff"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
@@ -0,0 +1,27 @@
|
||||
<script setup lang="ts">
|
||||
// 根组件:提供 Naive UI 的全局配置(中文化)、消息提示和对话框上下文
|
||||
import { NMessageProvider, NDialogProvider, NConfigProvider } from 'naive-ui'
|
||||
import { zhCN, dateZhCN } from 'naive-ui' // 中文语言包和日期格式
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- 全局配置:设置中文语言和日期格式 -->
|
||||
<NConfigProvider :locale="zhCN" :date-locale="dateZhCN">
|
||||
<!-- 提供全局消息提示能力 -->
|
||||
<NMessageProvider>
|
||||
<!-- 提供全局对话框能力 -->
|
||||
<NDialogProvider>
|
||||
<router-view />
|
||||
</NDialogProvider>
|
||||
</NMessageProvider>
|
||||
</NConfigProvider>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,22 @@
|
||||
// 分类相关 API 接口:分类的增删改查
|
||||
import request from './request'
|
||||
|
||||
/** 获取分类列表,可按类型筛选 */
|
||||
export function getCategoryList(type?: string) {
|
||||
return request.get('/category/list', { params: type ? { type } : {} })
|
||||
}
|
||||
|
||||
/** 创建新分类 */
|
||||
export function createCategory(data: { name: string; type: string; icon?: string; sortOrder?: number }) {
|
||||
return request.post('/category', data)
|
||||
}
|
||||
|
||||
/** 更新指定分类 */
|
||||
export function updateCategory(id: number, data: { name: string; type: string; icon?: string; sortOrder?: number }) {
|
||||
return request.put(`/category/${id}`, data)
|
||||
}
|
||||
|
||||
/** 删除指定分类 */
|
||||
export function deleteCategory(id: number) {
|
||||
return request.delete(`/category/${id}`)
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
// HTTP 请求封装模块:基于 axios 创建请求实例,统一处理请求拦截和响应拦截
|
||||
import axios from 'axios'
|
||||
import { useUserStore } from '../store/user'
|
||||
import router from '../router'
|
||||
|
||||
// 创建 axios 实例,设置基础路径和超时时间
|
||||
const request = axios.create({
|
||||
baseURL: '/api', // API 基础路径
|
||||
timeout: 10000, // 请求超时时间(毫秒)
|
||||
})
|
||||
|
||||
// 请求拦截器:自动携带 Authorization 请求头
|
||||
request.interceptors.request.use((config) => {
|
||||
const userStore = useUserStore()
|
||||
if (userStore.token) {
|
||||
config.headers.Authorization = `Bearer ${userStore.token}` // 附加 JWT token
|
||||
}
|
||||
return config
|
||||
})
|
||||
|
||||
// 响应拦截器:统一处理响应数据和错误
|
||||
request.interceptors.response.use(
|
||||
(response) => {
|
||||
const data = response.data
|
||||
// 如果业务状态码不是 200,视为请求失败
|
||||
if (data.code !== 200) {
|
||||
return Promise.reject(new Error(data.message || '请求失败'))
|
||||
}
|
||||
return data
|
||||
},
|
||||
(error) => {
|
||||
// 401 未授权:自动退出登录并跳转到登录页
|
||||
if (error.response?.status === 401) {
|
||||
const userStore = useUserStore()
|
||||
userStore.logout()
|
||||
router.push('/login')
|
||||
}
|
||||
return Promise.reject(error)
|
||||
}
|
||||
)
|
||||
|
||||
export default request
|
||||
@@ -0,0 +1,17 @@
|
||||
// 统计数据相关 API 接口:月度统计、分类统计、趋势统计
|
||||
import request from './request'
|
||||
|
||||
/** 获取指定年月的收支汇总统计(总收入、总支出、结余、交易笔数) */
|
||||
export function getMonthlyStatistics(params: { year: number; month: number }) {
|
||||
return request.get('/statistics/monthly', { params })
|
||||
}
|
||||
|
||||
/** 获取指定年月的分类收支统计(用于饼图等) */
|
||||
export function getCategoryStatistics(params: { year: number; month: number }) {
|
||||
return request.get('/statistics/category', { params })
|
||||
}
|
||||
|
||||
/** 获取指定年月的每日收支趋势数据(用于折线图/柱状图) */
|
||||
export function getTrendStatistics(params: { year: number; month: number }) {
|
||||
return request.get('/statistics/trend', { params })
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
// 收支记录相关 API 接口:交易记录的增删改查(分页)
|
||||
import request from './request'
|
||||
import axios from 'axios'
|
||||
import { useUserStore } from '../store/user'
|
||||
|
||||
/** 分页获取交易记录列表,支持按类型、分类、支付方式、时间范围、计入统计多选筛选 */
|
||||
export function getTransactionPage(params: {
|
||||
page?: number
|
||||
size?: number
|
||||
types?: string[]
|
||||
categoryIds?: number[]
|
||||
paymentMethods?: string[]
|
||||
startTime?: string
|
||||
endTime?: string
|
||||
includeInStats?: boolean
|
||||
}) {
|
||||
return request.get('/transaction/page', { params })
|
||||
}
|
||||
|
||||
/** 创建新的交易记录 */
|
||||
export function createTransaction(data: {
|
||||
categoryId: number
|
||||
type: string
|
||||
amount: number
|
||||
paymentMethod?: string
|
||||
remark?: string
|
||||
transactionTime: string
|
||||
includeInStats?: boolean
|
||||
}) {
|
||||
return request.post('/transaction', data)
|
||||
}
|
||||
|
||||
/** 更新指定交易记录 */
|
||||
export function updateTransaction(id: number, data: {
|
||||
categoryId: number
|
||||
type: string
|
||||
amount: number
|
||||
paymentMethod?: string
|
||||
remark?: string
|
||||
transactionTime: string
|
||||
includeInStats?: boolean
|
||||
}) {
|
||||
return request.put(`/transaction/${id}`, data)
|
||||
}
|
||||
|
||||
/** 删除指定交易记录 */
|
||||
export function deleteTransaction(id: number) {
|
||||
return request.delete(`/transaction/${id}`)
|
||||
}
|
||||
|
||||
/** 切换指定交易记录的"计入统计"标记 */
|
||||
export function toggleTransactionStats(id: number, includeInStats: boolean) {
|
||||
return request.put(`/transaction/${id}/stats`, { includeInStats })
|
||||
}
|
||||
|
||||
/** 导出交易记录为 Excel 文件,支持按条件筛选 */
|
||||
export function exportTransaction(params: {
|
||||
types?: string[]
|
||||
categoryIds?: number[]
|
||||
paymentMethods?: string[]
|
||||
startTime?: string
|
||||
endTime?: string
|
||||
includeInStats?: boolean
|
||||
}) {
|
||||
const userStore = useUserStore()
|
||||
return axios.get('/api/transaction/export', {
|
||||
params,
|
||||
paramsSerializer: {
|
||||
indexes: null,
|
||||
},
|
||||
responseType: 'blob',
|
||||
headers: {
|
||||
Authorization: `Bearer ${userStore.token}`,
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
// 用户相关 API 接口:登录、注册、获取用户信息
|
||||
import request from './request'
|
||||
|
||||
/** 用户登录 */
|
||||
export function login(data: { username: string; password: string }) {
|
||||
return request.post('/user/login', data)
|
||||
}
|
||||
|
||||
/** 用户注册 */
|
||||
export function register(data: { username: string; password: string; nickname?: string }) {
|
||||
return request.post('/user/register', data)
|
||||
}
|
||||
|
||||
/** 获取当前登录用户的信息 */
|
||||
export function getUserInfo() {
|
||||
return request.get('/user/info')
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
// 应用入口文件:初始化 Vue 应用,挂载全局插件(状态管理、路由、UI 组件库)
|
||||
import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
import naive from 'naive-ui'
|
||||
import router from './router'
|
||||
import App from './App.vue'
|
||||
|
||||
// 创建 Vue 应用实例
|
||||
const app = createApp(App)
|
||||
app.use(createPinia()) // 注册 Pinia 状态管理
|
||||
app.use(router) // 注册路由
|
||||
app.use(naive) // 全局注册 Naive UI 组件库
|
||||
app.mount('#app') // 挂载到 DOM 元素 #app
|
||||
@@ -0,0 +1,55 @@
|
||||
// 路由配置文件:定义应用的所有路由及导航守卫
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import { useUserStore } from '../store/user'
|
||||
|
||||
// 创建路由实例,使用 HTML5 History 模式
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes: [
|
||||
{
|
||||
path: '/login',
|
||||
name: 'Login',
|
||||
component: () => import('../views/Login.vue'), // 登录页
|
||||
},
|
||||
{
|
||||
path: '/register',
|
||||
name: 'Register',
|
||||
component: () => import('../views/Register.vue'), // 注册页
|
||||
},
|
||||
{
|
||||
path: '/',
|
||||
component: () => import('../views/Layout.vue'), // 主布局(带侧边栏)
|
||||
redirect: '/dashboard', // 默认跳转到统计面板
|
||||
children: [
|
||||
{
|
||||
path: 'dashboard',
|
||||
name: 'Dashboard',
|
||||
component: () => import('../views/Dashboard.vue'), // 统计面板
|
||||
},
|
||||
{
|
||||
path: 'transactions',
|
||||
name: 'Transactions',
|
||||
component: () => import('../views/TransactionList.vue'), // 收支记录
|
||||
},
|
||||
{
|
||||
path: 'categories',
|
||||
name: 'Categories',
|
||||
component: () => import('../views/CategoryManage.vue'), // 分类管理
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
// 全局前置守卫:未登录时拦截跳转至登录页
|
||||
router.beforeEach((to, _from, next) => {
|
||||
const userStore = useUserStore()
|
||||
// 如果目标页面不是登录/注册页,且没有 token,则重定向到登录页
|
||||
if (to.path !== '/login' && to.path !== '/register' && !userStore.token) {
|
||||
next('/login')
|
||||
} else {
|
||||
next()
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -0,0 +1,43 @@
|
||||
// 用户状态管理模块:管理用户的登录状态(token、昵称、用户ID)
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import { getUserInfo } from '../api/user'
|
||||
|
||||
export const useUserStore = defineStore('user', () => {
|
||||
// 从 localStorage 恢复 token,若不存在则为空字符串
|
||||
const token = ref(localStorage.getItem('token') || '')
|
||||
const nickname = ref('') // 用户昵称
|
||||
const userId = ref<number | null>(null) // 用户 ID
|
||||
|
||||
/** 设置 token 并持久化到 localStorage */
|
||||
function setToken(t: string) {
|
||||
token.value = t
|
||||
localStorage.setItem('token', t)
|
||||
}
|
||||
|
||||
/** 设置用户基本信息(昵称和 ID) */
|
||||
function setUserInfo(nick: string, id: number) {
|
||||
nickname.value = nick
|
||||
userId.value = id
|
||||
}
|
||||
|
||||
/** 从后端获取当前登录用户的信息 */
|
||||
async function fetchUserInfo() {
|
||||
try {
|
||||
const res: any = await getUserInfo()
|
||||
setUserInfo(res.data.nickname, res.data.id)
|
||||
} catch (e) {
|
||||
console.error('获取用户信息失败', e)
|
||||
}
|
||||
}
|
||||
|
||||
/** 退出登录:清空状态并移除 localStorage 中的 token */
|
||||
function logout() {
|
||||
token.value = ''
|
||||
nickname.value = ''
|
||||
userId.value = null
|
||||
localStorage.removeItem('token')
|
||||
}
|
||||
|
||||
return { token, nickname, userId, setToken, setUserInfo, fetchUserInfo, logout }
|
||||
})
|
||||
@@ -0,0 +1,5 @@
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
<script setup lang="ts">
|
||||
// 分类管理组件:展示收入/支出分类列表,支持新增、编辑、删除分类
|
||||
import { ref, onMounted, computed, h } from 'vue'
|
||||
import { useMessage, useDialog, NButton, NSpace, NDataTable, NModal, NForm, NFormItem, NInput, NInputNumber, NSelect } from 'naive-ui'
|
||||
import type { DataTableColumns } from 'naive-ui'
|
||||
import { getCategoryList, createCategory, updateCategory, deleteCategory } from '../api/category'
|
||||
|
||||
const message = useMessage() // 消息提示
|
||||
const dialog = useDialog() // 对话框
|
||||
|
||||
/** 分类数据结构 */
|
||||
interface CategoryItem {
|
||||
id: number
|
||||
name: string
|
||||
type: string // INCOME 或 EXPENSE
|
||||
icon: string
|
||||
sortOrder: number
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
// 列表和弹窗状态
|
||||
const categories = ref<CategoryItem[]>([]) // 全部分类列表
|
||||
const loading = ref(false) // 列表加载状态
|
||||
const showModal = ref(false) // 新增/编辑弹窗显示状态
|
||||
const editingId = ref<number | null>(null) // 当前编辑的分类 ID(null 表示新增)
|
||||
const form = ref({ name: '', type: 'EXPENSE', icon: '', sortOrder: 0 }) // 表单数据
|
||||
|
||||
// 类型选项
|
||||
const typeOptions = [
|
||||
{ label: '支出', value: 'EXPENSE' },
|
||||
{ label: '收入', value: 'INCOME' },
|
||||
]
|
||||
|
||||
// 按类型过滤分类列表
|
||||
const incomeList = computed(() => categories.value.filter(c => c.type === 'INCOME')) // 收入分类
|
||||
const expenseList = computed(() => categories.value.filter(c => c.type === 'EXPENSE')) // 支出分类
|
||||
|
||||
/** 创建表格列配置 */
|
||||
function createColumns(): DataTableColumns<CategoryItem> {
|
||||
return [
|
||||
{ title: '名称', key: 'name' },
|
||||
{ title: '图标', key: 'icon' },
|
||||
{ title: '排序', key: 'sortOrder', width: 80 },
|
||||
{
|
||||
title: '操作', key: 'actions', width: 180,
|
||||
render(row) {
|
||||
// 编辑和删除按钮
|
||||
return [
|
||||
h(NSpace, null, {
|
||||
default: () => [
|
||||
h(NButton, { size: 'small', onClick: () => handleEdit(row) }, { default: () => '编辑' }),
|
||||
h(NButton, { size: 'small', type: 'error', onClick: () => handleDelete(row.id) }, { default: () => '删除' }),
|
||||
],
|
||||
}),
|
||||
]
|
||||
},
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
const columns = createColumns() // 表格列配置
|
||||
|
||||
/** 加载分类列表数据 */
|
||||
async function fetchData() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res: any = await getCategoryList()
|
||||
categories.value = res.data
|
||||
} catch (e: any) {
|
||||
message.error(e.message || '加载失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 打开新增分类弹窗,重置表单 */
|
||||
function handleAdd() {
|
||||
editingId.value = null
|
||||
form.value = { name: '', type: 'EXPENSE', icon: '', sortOrder: 0 }
|
||||
showModal.value = true
|
||||
}
|
||||
|
||||
/** 打开编辑分类弹窗,填充表单数据 */
|
||||
function handleEdit(row: CategoryItem) {
|
||||
editingId.value = row.id
|
||||
form.value = { name: row.name, type: row.type, icon: row.icon || '', sortOrder: row.sortOrder }
|
||||
showModal.value = true
|
||||
}
|
||||
|
||||
/** 提交表单:校验 -> 新增或更新 -> 刷新列表 */
|
||||
async function handleSubmit() {
|
||||
if (!form.value.name) {
|
||||
message.warning('请输入分类名称')
|
||||
return
|
||||
}
|
||||
try {
|
||||
if (editingId.value) {
|
||||
// 更新已有分类
|
||||
await updateCategory(editingId.value, form.value)
|
||||
message.success('修改成功')
|
||||
} else {
|
||||
// 创建新分类
|
||||
await createCategory(form.value)
|
||||
message.success('创建成功')
|
||||
}
|
||||
showModal.value = false
|
||||
await fetchData() // 刷新列表
|
||||
} catch (e: any) {
|
||||
message.error(e.message || '操作失败')
|
||||
}
|
||||
}
|
||||
|
||||
/** 删除分类:弹出确认对话框,确认后执行删除并刷新列表 */
|
||||
function handleDelete(id: number) {
|
||||
dialog.warning({
|
||||
title: '确认删除',
|
||||
content: '确定要删除这个分类吗?',
|
||||
positiveText: '删除',
|
||||
negativeText: '取消',
|
||||
onPositiveClick: async () => {
|
||||
try {
|
||||
await deleteCategory(id)
|
||||
message.success('删除成功')
|
||||
await fetchData()
|
||||
} catch (e: any) {
|
||||
message.error(e.message || '删除失败')
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// 组件挂载时加载分类数据
|
||||
onMounted(fetchData)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<!-- 顶部:标题和新增按钮 -->
|
||||
<NSpace justify="space-between" style="margin-bottom: 16px;">
|
||||
<h2 style="margin: 0;">分类管理</h2>
|
||||
<NButton type="primary" @click="handleAdd">新增分类</NButton>
|
||||
</NSpace>
|
||||
|
||||
<!-- 支出分类列表 -->
|
||||
<h3>支出分类</h3>
|
||||
<NDataTable :columns="columns" :data="expenseList" :loading="loading" :bordered="false" />
|
||||
|
||||
<!-- 收入分类列表 -->
|
||||
<h3 style="margin-top: 24px;">收入分类</h3>
|
||||
<NDataTable :columns="columns" :data="incomeList" :loading="loading" :bordered="false" />
|
||||
|
||||
<!-- 新增/编辑弹窗 -->
|
||||
<NModal v-model:show="showModal" preset="card" :title="editingId ? '编辑分类' : '新增分类'" style="width: 450px;">
|
||||
<NForm>
|
||||
<NFormItem label="分类名称">
|
||||
<NInput v-model:value="form.name" placeholder="请输入分类名称" />
|
||||
</NFormItem>
|
||||
<NFormItem label="类型">
|
||||
<NSelect v-model:value="form.type" :options="typeOptions" />
|
||||
</NFormItem>
|
||||
<NFormItem label="图标">
|
||||
<NInput v-model:value="form.icon" placeholder="可选,如 emoji" />
|
||||
</NFormItem>
|
||||
<NFormItem label="排序">
|
||||
<NInputNumber v-model:value="form.sortOrder" :min="0" />
|
||||
</NFormItem>
|
||||
<NButton type="primary" block @click="handleSubmit">确定</NButton>
|
||||
</NForm>
|
||||
</NModal>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,144 @@
|
||||
<script setup lang="ts">
|
||||
// 统计面板组件:展示月度收支汇总、分类占比饼图、每日收支趋势柱状图
|
||||
import { ref, onMounted, watch } from 'vue'
|
||||
import { NCard, NGrid, NGridItem, NStatistic, NSpace, NSelect } from 'naive-ui'
|
||||
import VChart from 'vue-echarts'
|
||||
import { use } from 'echarts/core'
|
||||
import { CanvasRenderer } from 'echarts/renderers'
|
||||
import { LineChart, PieChart, BarChart } from 'echarts/charts'
|
||||
import { TitleComponent, TooltipComponent, LegendComponent, GridComponent } from 'echarts/components'
|
||||
import { getMonthlyStatistics, getCategoryStatistics, getTrendStatistics } from '../api/statistics'
|
||||
|
||||
// 注册 ECharts 所需组件
|
||||
use([CanvasRenderer, LineChart, PieChart, BarChart, TitleComponent, TooltipComponent, LegendComponent, GridComponent])
|
||||
|
||||
const now = new Date()
|
||||
const selectedYear = ref(now.getFullYear()) // 选中的年份
|
||||
const selectedMonth = ref(now.getMonth() + 1) // 选中的月份
|
||||
|
||||
// 月度汇总数据(总收入、总支出、结余、交易笔数)
|
||||
const monthly = ref({ totalIncome: 0, totalExpense: 0, balance: 0, transactionCount: 0 })
|
||||
const categoryData = ref<any[]>([]) // 分类统计数据
|
||||
const trendData = ref<any[]>([]) // 每日趋势数据
|
||||
|
||||
// 年份和月份下拉选项
|
||||
const yearOptions = Array.from({ length: 10 }, (_, i) => ({ label: `${2020 + i}年`, value: 2020 + i }))
|
||||
const monthOptions = Array.from({ length: 12 }, (_, i) => ({ label: `${i + 1}月`, value: i + 1 }))
|
||||
|
||||
const pieOption = ref({}) // 饼图配置
|
||||
const trendOption = ref({}) // 趋势图配置
|
||||
|
||||
/** 更新支出分类饼图配置 */
|
||||
function updatePieChart() {
|
||||
pieOption.value = {
|
||||
title: { text: '支出分类占比', left: 'center' },
|
||||
tooltip: { trigger: 'item', formatter: '{b}: {c} ({d}%)' },
|
||||
series: [
|
||||
{
|
||||
type: 'pie',
|
||||
radius: ['40%', '70%'], // 环形图内外半径
|
||||
data: categoryData.value
|
||||
.filter((c: any) => c.type === 'EXPENSE') // 只展示支出类
|
||||
.map((c: any) => ({ name: c.categoryName, value: c.amount })),
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
/** 更新每日收支趋势柱状图配置 */
|
||||
function updateTrendChart() {
|
||||
trendOption.value = {
|
||||
title: { text: '每日收支趋势' },
|
||||
tooltip: { trigger: 'axis' },
|
||||
legend: { data: ['收入', '支出'], bottom: 0 },
|
||||
grid: { left: 60, right: 20, top: 40, bottom: 40 },
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: trendData.value.map((d: any) => d.date.substring(5)), // 只显示 MM-DD
|
||||
},
|
||||
yAxis: { type: 'value' },
|
||||
series: [
|
||||
{ name: '收入', type: 'bar', data: trendData.value.map((d: any) => d.income), color: '#18a058' },
|
||||
{ name: '支出', type: 'bar', data: trendData.value.map((d: any) => d.expense), color: '#d03050' },
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
/** 并行请求所有统计数据并更新图表 */
|
||||
async function fetchData() {
|
||||
const params = { year: selectedYear.value, month: selectedMonth.value }
|
||||
try {
|
||||
const [monthlyRes, categoryRes, trendRes]: any[] = await Promise.all([
|
||||
getMonthlyStatistics(params),
|
||||
getCategoryStatistics(params),
|
||||
getTrendStatistics(params),
|
||||
])
|
||||
monthly.value = monthlyRes.data
|
||||
categoryData.value = categoryRes.data
|
||||
trendData.value = trendRes.data
|
||||
updatePieChart()
|
||||
updateTrendChart()
|
||||
} catch (e: any) {
|
||||
console.error('加载统计数据失败', e)
|
||||
}
|
||||
}
|
||||
|
||||
// 监听年月变化,自动重新加载数据
|
||||
watch([selectedYear, selectedMonth], fetchData)
|
||||
onMounted(fetchData) // 组件挂载时加载数据
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<!-- 顶部:标题和年月选择器 -->
|
||||
<NSpace justify="space-between" align="center" style="margin-bottom: 20px;">
|
||||
<h2 style="margin: 0;">统计面板</h2>
|
||||
<NSpace>
|
||||
<NSelect v-model:value="selectedYear" :options="yearOptions" style="width: 100px;" />
|
||||
<NSelect v-model:value="selectedMonth" :options="monthOptions" style="width: 80px;" />
|
||||
</NSpace>
|
||||
</NSpace>
|
||||
|
||||
<!-- 统计卡片:总收入、总支出、结余、交易笔数 -->
|
||||
<NGrid :cols="4" :x-gap="16" :y-gap="16" style="margin-bottom: 20px;">
|
||||
<NGridItem>
|
||||
<NCard>
|
||||
<NStatistic label="总收入" :value="Number(monthly.totalIncome).toFixed(2)" style="color: #18a058;">
|
||||
<template #prefix>+</template>
|
||||
</NStatistic>
|
||||
</NCard>
|
||||
</NGridItem>
|
||||
<NGridItem>
|
||||
<NCard>
|
||||
<NStatistic label="总支出" :value="Number(monthly.totalExpense).toFixed(2)" style="color: #d03050;">
|
||||
<template #prefix>-</template>
|
||||
</NStatistic>
|
||||
</NCard>
|
||||
</NGridItem>
|
||||
<NGridItem>
|
||||
<NCard>
|
||||
<NStatistic label="结余" :value="Number(monthly.balance).toFixed(2)" />
|
||||
</NCard>
|
||||
</NGridItem>
|
||||
<NGridItem>
|
||||
<NCard>
|
||||
<NStatistic label="交易笔数" :value="monthly.transactionCount" />
|
||||
</NCard>
|
||||
</NGridItem>
|
||||
</NGrid>
|
||||
|
||||
<!-- 图表区域:饼图 + 趋势柱状图 -->
|
||||
<NGrid :cols="2" :x-gap="16" :y-gap="16">
|
||||
<NGridItem>
|
||||
<NCard>
|
||||
<VChart :option="pieOption" style="height: 350px;" autoresize />
|
||||
</NCard>
|
||||
</NGridItem>
|
||||
<NGridItem>
|
||||
<NCard>
|
||||
<VChart :option="trendOption" style="height: 350px;" autoresize />
|
||||
</NCard>
|
||||
</NGridItem>
|
||||
</NGrid>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,86 @@
|
||||
<script setup lang="ts">
|
||||
// 主布局组件:包含左侧导航菜单、顶部用户信息栏和内容区域
|
||||
import { h, computed } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { NLayout, NLayoutSider, NLayoutContent, NLayoutHeader, NMenu, NButton, NSpace, NIcon } from 'naive-ui'
|
||||
import type { MenuOption } from 'naive-ui'
|
||||
import { BarChartOutline, ListOutline, FolderOpenOutline, LogOutOutline } from '@vicons/ionicons5'
|
||||
import { useUserStore } from '../store/user'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const userStore = useUserStore()
|
||||
|
||||
// 根据当前路由路径计算菜单激活项
|
||||
const activeKey = computed(() => {
|
||||
const map: Record<string, string> = {
|
||||
'/dashboard': 'dashboard',
|
||||
'/transactions': 'transactions',
|
||||
'/categories': 'categories',
|
||||
}
|
||||
return map[route.path] || 'dashboard'
|
||||
})
|
||||
|
||||
/** 渲染菜单图标的高阶函数 */
|
||||
function renderIcon(icon: any) {
|
||||
return () => h(NIcon, null, { default: () => h(icon) })
|
||||
}
|
||||
|
||||
// 侧边栏菜单配置项
|
||||
const menuOptions: MenuOption[] = [
|
||||
{ label: '统计面板', key: 'dashboard', icon: renderIcon(BarChartOutline) },
|
||||
{ label: '收支记录', key: 'transactions', icon: renderIcon(ListOutline) },
|
||||
{ label: '分类管理', key: 'categories', icon: renderIcon(FolderOpenOutline) },
|
||||
]
|
||||
|
||||
/** 菜单点击事件:根据 key 导航到对应路由 */
|
||||
function handleMenuUpdate(key: string) {
|
||||
router.push(`/${key}`)
|
||||
}
|
||||
|
||||
/** 退出登录:清空用户状态并跳转到登录页 */
|
||||
function handleLogout() {
|
||||
userStore.logout()
|
||||
router.push('/login')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- 整体布局:左侧边栏 + 右侧内容区 -->
|
||||
<NLayout has-sider style="height: 100vh">
|
||||
<NLayoutSider
|
||||
bordered
|
||||
:width="200"
|
||||
content-style="padding: 12px;"
|
||||
>
|
||||
<!-- 系统标题 -->
|
||||
<div style="text-align: center; padding: 16px 0; font-size: 18px; font-weight: bold; color: #333;">
|
||||
收支管理
|
||||
</div>
|
||||
<!-- 导航菜单 -->
|
||||
<NMenu
|
||||
:value="activeKey"
|
||||
:options="menuOptions"
|
||||
@update:value="handleMenuUpdate"
|
||||
/>
|
||||
</NLayoutSider>
|
||||
<NLayout>
|
||||
<!-- 顶部栏:显示用户名和退出按钮 -->
|
||||
<NLayoutHeader bordered style="padding: 12px 24px; display: flex; justify-content: space-between; align-items: center;">
|
||||
<span style="font-size: 16px; font-weight: 500;">{{ userStore.nickname || '用户' }}</span>
|
||||
<NSpace>
|
||||
<NButton quaternary size="small" @click="handleLogout">
|
||||
<template #icon>
|
||||
<NIcon :component="LogOutOutline" />
|
||||
</template>
|
||||
退出
|
||||
</NButton>
|
||||
</NSpace>
|
||||
</NLayoutHeader>
|
||||
<!-- 子路由内容渲染区域 -->
|
||||
<NLayoutContent content-style="padding: 24px;" style="height: calc(100vh - 57px); overflow-y: auto;">
|
||||
<router-view />
|
||||
</NLayoutContent>
|
||||
</NLayout>
|
||||
</NLayout>
|
||||
</template>
|
||||
@@ -0,0 +1,68 @@
|
||||
<script setup lang="ts">
|
||||
// 登录页面组件:提供用户名/密码表单,验证后跳转至首页
|
||||
import { ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useMessage, NCard, NForm, NFormItem, NInput, NButton, NSpace } from 'naive-ui'
|
||||
import { login } from '../api/user'
|
||||
import { useUserStore } from '../store/user'
|
||||
|
||||
const router = useRouter()
|
||||
const message = useMessage() // 消息提示工具
|
||||
const userStore = useUserStore() // 用户状态管理
|
||||
|
||||
// 登录表单数据
|
||||
const form = ref({
|
||||
username: '',
|
||||
password: '',
|
||||
})
|
||||
const loading = ref(false) // 登录按钮加载状态
|
||||
|
||||
/** 处理登录操作:校验表单 -> 调用登录接口 -> 保存 token -> 跳转首页 */
|
||||
async function handleLogin() {
|
||||
if (!form.value.username || !form.value.password) {
|
||||
message.warning('请填写用户名和密码')
|
||||
return
|
||||
}
|
||||
loading.value = true
|
||||
try {
|
||||
const res: any = await login(form.value)
|
||||
userStore.setToken(res.data.token) // 保存 token
|
||||
userStore.setUserInfo(res.data.user.nickname, res.data.user.id) // 保存用户信息
|
||||
message.success('登录成功')
|
||||
router.push('/dashboard') // 跳转到统计面板
|
||||
} catch (e: any) {
|
||||
message.error(e.message || '登录失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="login-container">
|
||||
<NCard title="收支管理系统 - 登录" style="width: 400px" :bordered="true">
|
||||
<NForm>
|
||||
<NFormItem label="用户名">
|
||||
<NInput v-model:value="form.username" placeholder="请输入用户名" @keyup.enter="handleLogin" />
|
||||
</NFormItem>
|
||||
<NFormItem label="密码">
|
||||
<NInput v-model:value="form.password" type="password" show-password-on="click" placeholder="请输入密码" @keyup.enter="handleLogin" />
|
||||
</NFormItem>
|
||||
<NSpace vertical>
|
||||
<NButton type="primary" block :loading="loading" @click="handleLogin">登录</NButton>
|
||||
<NButton text type="info" @click="router.push('/register')">没有账号?去注册</NButton>
|
||||
</NSpace>
|
||||
</NForm>
|
||||
</NCard>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.login-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 100vh;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,90 @@
|
||||
<script setup lang="ts">
|
||||
// 注册页面组件:提供注册表单(用户名、昵称、密码、确认密码),校验后调用注册接口
|
||||
import { ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useMessage, NCard, NForm, NFormItem, NInput, NButton, NSpace } from 'naive-ui'
|
||||
import { register } from '../api/user'
|
||||
|
||||
const router = useRouter()
|
||||
const message = useMessage() // 消息提示工具
|
||||
|
||||
// 注册表单数据
|
||||
const form = ref({
|
||||
username: '',
|
||||
password: '',
|
||||
confirmPassword: '', // 确认密码
|
||||
nickname: '', // 昵称(可选)
|
||||
})
|
||||
const loading = ref(false) // 注册按钮加载状态
|
||||
|
||||
/** 处理注册操作:校验表单字段 -> 调用注册接口 -> 成功后跳转登录页 */
|
||||
async function handleRegister() {
|
||||
// 校验必填字段
|
||||
if (!form.value.username || !form.value.password) {
|
||||
message.warning('请填写用户名和密码')
|
||||
return
|
||||
}
|
||||
if (form.value.username.length < 3) {
|
||||
message.warning('用户名至少3个字符')
|
||||
return
|
||||
}
|
||||
if (form.value.password.length < 6) {
|
||||
message.warning('密码至少6个字符')
|
||||
return
|
||||
}
|
||||
// 校验两次密码是否一致
|
||||
if (form.value.password !== form.value.confirmPassword) {
|
||||
message.warning('两次密码不一致')
|
||||
return
|
||||
}
|
||||
loading.value = true
|
||||
try {
|
||||
await register({
|
||||
username: form.value.username,
|
||||
password: form.value.password,
|
||||
nickname: form.value.nickname || undefined, // 昵称为空时传 undefined
|
||||
})
|
||||
message.success('注册成功,请登录')
|
||||
router.push('/login') // 跳转到登录页
|
||||
} catch (e: any) {
|
||||
message.error(e.message || '注册失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="login-container">
|
||||
<NCard title="收支管理系统 - 注册" style="width: 400px" :bordered="true">
|
||||
<NForm>
|
||||
<NFormItem label="用户名">
|
||||
<NInput v-model:value="form.username" placeholder="3-50个字符" />
|
||||
</NFormItem>
|
||||
<NFormItem label="昵称">
|
||||
<NInput v-model:value="form.nickname" placeholder="可选" />
|
||||
</NFormItem>
|
||||
<NFormItem label="密码">
|
||||
<NInput v-model:value="form.password" type="password" show-password-on="click" placeholder="至少6个字符" />
|
||||
</NFormItem>
|
||||
<NFormItem label="确认密码">
|
||||
<NInput v-model:value="form.confirmPassword" type="password" show-password-on="click" placeholder="再次输入密码" />
|
||||
</NFormItem>
|
||||
<NSpace vertical>
|
||||
<NButton type="primary" block :loading="loading" @click="handleRegister">注册</NButton>
|
||||
<NButton text type="info" @click="router.push('/login')">已有账号?去登录</NButton>
|
||||
</NSpace>
|
||||
</NForm>
|
||||
</NCard>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.login-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 100vh;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,430 @@
|
||||
<script setup lang="ts">
|
||||
// 收支记录管理组件:展示交易记录列表(分页),支持新增、编辑、删除、多条件筛选
|
||||
import { ref, onMounted, h } from 'vue'
|
||||
import { useMessage, useDialog, NButton, NSpace, NDataTable, NTag, NModal, NForm, NFormItem, NInput, NInputNumber, NSelect, NDatePicker, NPagination, NCard, NSwitch } from 'naive-ui'
|
||||
import type { DataTableColumns } from 'naive-ui'
|
||||
import { getTransactionPage, createTransaction, updateTransaction, deleteTransaction, exportTransaction, toggleTransactionStats } from '../api/transaction'
|
||||
import { getCategoryList } from '../api/category'
|
||||
|
||||
const message = useMessage() // 消息提示
|
||||
const dialog = useDialog() // 对话框
|
||||
|
||||
/** 交易记录数据结构 */
|
||||
interface TransactionItem {
|
||||
id: number
|
||||
categoryId: number
|
||||
categoryName: string
|
||||
categoryIcon: string
|
||||
type: string // INCOME 或 EXPENSE
|
||||
amount: number
|
||||
paymentMethod: string
|
||||
remark: string
|
||||
transactionTime: string
|
||||
includeInStats: boolean
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
/** 分类选项数据结构 */
|
||||
interface CategoryOption {
|
||||
label: string
|
||||
value: number
|
||||
type: string
|
||||
}
|
||||
|
||||
// 列表数据和分页状态
|
||||
const transactions = ref<TransactionItem[]>([]) // 交易记录列表
|
||||
const total = ref(0) // 总记录数
|
||||
const page = ref(1) // 当前页码
|
||||
const pageSize = ref(20) // 每页条数
|
||||
const loading = ref(false) // 列表加载状态
|
||||
const showModal = ref(false) // 新增/编辑弹窗显示状态
|
||||
const editingId = ref<number | null>(null) // 当前编辑的记录 ID(null 表示新增)
|
||||
const categories = ref<CategoryOption[]>([]) // 分类选项列表
|
||||
|
||||
// 表单数据(新增/编辑共用)
|
||||
const form = ref({
|
||||
categoryId: null as number | null,
|
||||
type: 'EXPENSE',
|
||||
amount: null as number | null,
|
||||
paymentMethod: '',
|
||||
remark: '',
|
||||
transactionTime: Date.now(),
|
||||
includeInStats: true,
|
||||
})
|
||||
|
||||
// 类型选项
|
||||
const typeOptions = [
|
||||
{ label: '支出', value: 'EXPENSE' },
|
||||
{ label: '收入', value: 'INCOME' },
|
||||
]
|
||||
|
||||
// 支付方式选项
|
||||
const paymentOptions = [
|
||||
{ label: '微信', value: 'WECHAT' },
|
||||
{ label: '支付宝', value: 'ALIPAY' },
|
||||
{ label: '现金', value: 'CASH' },
|
||||
{ label: '银行卡', value: 'BANK' },
|
||||
]
|
||||
|
||||
// 筛选条件状态
|
||||
const filterType = ref<string[]>([]) // 类型筛选(多选)
|
||||
const filterCategory = ref<number[]>([]) // 分类筛选(多选)
|
||||
const filterPaymentMethod = ref<string[]>([]) // 支付方式筛选(多选)
|
||||
const filterDateRange = ref<[number, number] | null>(null) // 日期范围筛选(起止时间戳)
|
||||
const filterIncludeInStats = ref<string | null>(null) // 计入统计筛选:null-全部,'true'-计入,'false'-不计入
|
||||
|
||||
// 计入统计筛选下拉选项
|
||||
const includeInStatsOptions = [
|
||||
{ label: '计入', value: 'true' },
|
||||
{ label: '不计入', value: 'false' },
|
||||
]
|
||||
|
||||
/** 将时间戳格式化为 ISO 格式的日期时间字符串(YYYY-MM-DDTHH:mm:ss) */
|
||||
function formatTimestamp(ts: number, isEnd = false): string {
|
||||
const d = new Date(ts)
|
||||
const pad = (n: number) => String(n).padStart(2, '0')
|
||||
const date = `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
|
||||
const time = isEnd ? '23:59:59' : '00:00:00'
|
||||
return `${date}T${time}`
|
||||
}
|
||||
|
||||
/** 将支付方式编码转换为中文标签 */
|
||||
function getPaymentLabel(val: string) {
|
||||
const map: Record<string, string> = { WECHAT: '微信', ALIPAY: '支付宝', CASH: '现金', BANK: '银行卡' }
|
||||
return map[val] || val || '-'
|
||||
}
|
||||
|
||||
/** 创建表格列配置 */
|
||||
function createColumns(): DataTableColumns<TransactionItem> {
|
||||
return [
|
||||
{
|
||||
title: '类型', key: 'type', width: 90,
|
||||
render(row) {
|
||||
if (!row.includeInStats) {
|
||||
return h(NTag, { type: 'default', size: 'small' }, { default: () => '不计入' })
|
||||
}
|
||||
return h(NTag, { type: row.type === 'INCOME' ? 'success' : 'error', size: 'small' }, { default: () => row.type === 'INCOME' ? '收入' : '支出' })
|
||||
},
|
||||
},
|
||||
{ title: '分类', key: 'categoryName', width: 100 },
|
||||
{
|
||||
title: '金额', key: 'amount', width: 120,
|
||||
render(row) {
|
||||
if (!row.includeInStats) {
|
||||
return h('span', { style: { color: '#999', fontWeight: 'bold' } }, `${row.amount.toFixed(2)}`)
|
||||
}
|
||||
return h('span', { style: { color: row.type === 'INCOME' ? '#18a058' : '#d03050', fontWeight: 'bold' } },
|
||||
`${row.type === 'INCOME' ? '+' : '-'}${row.amount.toFixed(2)}`)
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '支付方式', key: 'paymentMethod', width: 100,
|
||||
render(row) { return getPaymentLabel(row.paymentMethod) },
|
||||
},
|
||||
{ title: '备注', key: 'remark', ellipsis: { tooltip: true } },
|
||||
{
|
||||
title: '交易时间', key: 'transactionTime', width: 170,
|
||||
render(row) { return row.transactionTime?.replace('T', ' ').substring(0, 16) },
|
||||
},
|
||||
{
|
||||
title: '创建时间', key: 'createdAt', width: 170,
|
||||
render(row) { return row.createdAt?.replace('T', ' ').substring(0, 16) },
|
||||
},
|
||||
{
|
||||
title: '计入统计', key: 'includeInStats', width: 100, align: 'center',
|
||||
render(row) {
|
||||
return h(NSwitch, {
|
||||
value: row.includeInStats,
|
||||
onUpdateValue: (v: boolean) => handleToggleStats(row, v),
|
||||
})
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '操作', key: 'actions', width: 160,
|
||||
render(row) {
|
||||
return h(NSpace, null, {
|
||||
default: () => [
|
||||
h(NButton, { size: 'small', onClick: () => handleEdit(row) }, { default: () => '编辑' }),
|
||||
h(NButton, { size: 'small', type: 'error', onClick: () => handleDelete(row.id) }, { default: () => '删除' }),
|
||||
],
|
||||
})
|
||||
},
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
const columns = createColumns() // 表格列配置
|
||||
|
||||
/** 加载分类列表 */
|
||||
async function fetchCategories() {
|
||||
try {
|
||||
const res: any = await getCategoryList()
|
||||
categories.value = res.data.map((c: any) => ({ label: c.name, value: c.id, type: c.type }))
|
||||
} catch (e) {
|
||||
// 静默处理
|
||||
}
|
||||
}
|
||||
|
||||
/** 加载交易记录列表(分页),携带所有筛选条件 */
|
||||
async function fetchData() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res: any = await getTransactionPage({
|
||||
page: page.value,
|
||||
size: pageSize.value,
|
||||
types: filterType.value.length > 0 ? filterType.value : undefined,
|
||||
categoryIds: filterCategory.value.length > 0 ? filterCategory.value : undefined,
|
||||
paymentMethods: filterPaymentMethod.value.length > 0 ? filterPaymentMethod.value : undefined,
|
||||
startTime: filterDateRange.value ? formatTimestamp(filterDateRange.value[0]) : undefined,
|
||||
endTime: filterDateRange.value ? formatTimestamp(filterDateRange.value[1], true) : undefined,
|
||||
includeInStats: filterIncludeInStats.value === null ? undefined : filterIncludeInStats.value === 'true',
|
||||
})
|
||||
transactions.value = res.data.records
|
||||
total.value = res.data.total
|
||||
} catch (e: any) {
|
||||
message.error(e.message || '加载失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 打开新增记录弹窗,重置表单 */
|
||||
function handleAdd() {
|
||||
editingId.value = null
|
||||
form.value = { categoryId: null, type: 'EXPENSE', amount: null, paymentMethod: '', remark: '', transactionTime: Date.now(), includeInStats: true }
|
||||
showModal.value = true
|
||||
}
|
||||
|
||||
/** 打开编辑记录弹窗,填充表单数据 */
|
||||
function handleEdit(row: TransactionItem) {
|
||||
editingId.value = row.id
|
||||
form.value = {
|
||||
categoryId: row.categoryId,
|
||||
type: row.type,
|
||||
amount: row.amount,
|
||||
paymentMethod: row.paymentMethod || '',
|
||||
remark: row.remark || '',
|
||||
transactionTime: new Date(row.transactionTime).getTime(),
|
||||
includeInStats: row.includeInStats ?? true,
|
||||
}
|
||||
showModal.value = true
|
||||
}
|
||||
|
||||
/** 提交表单:校验 -> 新增或更新 -> 刷新列表 */
|
||||
async function handleSubmit() {
|
||||
if (!form.value.categoryId) {
|
||||
message.warning('请选择分类')
|
||||
return
|
||||
}
|
||||
if (!form.value.amount || form.value.amount <= 0) {
|
||||
message.warning('请输入有效金额')
|
||||
return
|
||||
}
|
||||
const data = {
|
||||
categoryId: form.value.categoryId!,
|
||||
type: form.value.type,
|
||||
amount: form.value.amount!,
|
||||
paymentMethod: form.value.paymentMethod || undefined,
|
||||
remark: form.value.remark || undefined,
|
||||
transactionTime: new Date(form.value.transactionTime).toISOString().slice(0, 19),
|
||||
includeInStats: form.value.includeInStats,
|
||||
}
|
||||
try {
|
||||
if (editingId.value) {
|
||||
await updateTransaction(editingId.value, data)
|
||||
message.success('修改成功')
|
||||
} else {
|
||||
await createTransaction(data)
|
||||
message.success('创建成功')
|
||||
}
|
||||
showModal.value = false
|
||||
await fetchData()
|
||||
} catch (e: any) {
|
||||
message.error(e.message || '操作失败')
|
||||
}
|
||||
}
|
||||
|
||||
/** 删除记录:弹出确认对话框,确认后执行删除并刷新列表 */
|
||||
function handleDelete(id: number) {
|
||||
dialog.warning({
|
||||
title: '确认删除',
|
||||
content: '确定要删除这条记录吗?',
|
||||
positiveText: '删除',
|
||||
negativeText: '取消',
|
||||
onPositiveClick: async () => {
|
||||
try {
|
||||
await deleteTransaction(id)
|
||||
message.success('删除成功')
|
||||
await fetchData()
|
||||
} catch (e: any) {
|
||||
message.error(e.message || '删除失败')
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/** 翻页事件处理 */
|
||||
function handlePageChange(p: number) {
|
||||
page.value = p
|
||||
fetchData()
|
||||
}
|
||||
|
||||
/** 执行查询:重置页码并按当前筛选条件加载数据 */
|
||||
function handleSearch() {
|
||||
page.value = 1
|
||||
fetchData()
|
||||
}
|
||||
|
||||
/** 重置所有筛选条件并重新查询 */
|
||||
function handleReset() {
|
||||
filterType.value = []
|
||||
filterCategory.value = []
|
||||
filterPaymentMethod.value = []
|
||||
filterDateRange.value = null
|
||||
filterIncludeInStats.value = null
|
||||
page.value = 1
|
||||
fetchData()
|
||||
}
|
||||
|
||||
/** 在表格中直接切换"计入统计"开关 */
|
||||
async function handleToggleStats(row: TransactionItem, value: boolean) {
|
||||
try {
|
||||
await toggleTransactionStats(row.id, value)
|
||||
row.includeInStats = value
|
||||
message.success(value ? '已计入统计' : '已排除统计')
|
||||
} catch (e: any) {
|
||||
message.error(e.message || '操作失败')
|
||||
}
|
||||
}
|
||||
|
||||
/** 导出交易记录为 Excel 文件 */
|
||||
async function handleExport() {
|
||||
try {
|
||||
const res = await exportTransaction({
|
||||
types: filterType.value.length > 0 ? filterType.value : undefined,
|
||||
categoryIds: filterCategory.value.length > 0 ? filterCategory.value : undefined,
|
||||
paymentMethods: filterPaymentMethod.value.length > 0 ? filterPaymentMethod.value : undefined,
|
||||
startTime: filterDateRange.value ? formatTimestamp(filterDateRange.value[0]) : undefined,
|
||||
endTime: filterDateRange.value ? formatTimestamp(filterDateRange.value[1], true) : undefined,
|
||||
includeInStats: filterIncludeInStats.value === null ? undefined : filterIncludeInStats.value === 'true',
|
||||
})
|
||||
const blob = new Blob([res.data], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' })
|
||||
const url = window.URL.createObjectURL(blob)
|
||||
const link = document.createElement('a')
|
||||
link.href = url
|
||||
link.download = `交易记录_${new Date().toISOString().slice(0, 19).replace(/[:-]/g, '')}.xlsx`
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
document.body.removeChild(link)
|
||||
window.URL.revokeObjectURL(url)
|
||||
message.success('导出成功')
|
||||
} catch (e: any) {
|
||||
message.error(e.message || '导出失败')
|
||||
}
|
||||
}
|
||||
|
||||
// 组件挂载时加载分类和交易记录
|
||||
onMounted(() => {
|
||||
fetchCategories()
|
||||
fetchData()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<!-- 顶部:标题和新增按钮 -->
|
||||
<NSpace justify="space-between" style="margin-bottom: 16px;">
|
||||
<h2 style="margin: 0;">收支记录</h2>
|
||||
<NSpace>
|
||||
<NButton @click="handleExport">导出 Excel</NButton>
|
||||
<NButton type="primary" @click="handleAdd">新增记录</NButton>
|
||||
</NSpace>
|
||||
</NSpace>
|
||||
|
||||
<!-- 筛选条件区域 -->
|
||||
<NCard :bordered="false" style="margin-bottom: 16px;">
|
||||
<NSpace align="center" :wrap="true" :size="[12, 12]">
|
||||
<NSelect
|
||||
v-model:value="filterType"
|
||||
:options="typeOptions"
|
||||
multiple
|
||||
clearable
|
||||
:max-tag-count="2"
|
||||
placeholder="类型"
|
||||
style="width: 160px;"
|
||||
/>
|
||||
<NSelect
|
||||
v-model:value="filterCategory"
|
||||
:options="categories.filter(c => filterType.length === 0 || filterType.includes(c.type))"
|
||||
multiple
|
||||
clearable
|
||||
filterable
|
||||
:max-tag-count="2"
|
||||
placeholder="分类"
|
||||
style="width: 200px;"
|
||||
/>
|
||||
<NSelect
|
||||
v-model:value="filterPaymentMethod"
|
||||
:options="paymentOptions"
|
||||
multiple
|
||||
clearable
|
||||
:max-tag-count="2"
|
||||
placeholder="支付方式"
|
||||
style="width: 200px;"
|
||||
/>
|
||||
<NDatePicker
|
||||
v-model:value="filterDateRange"
|
||||
type="daterange"
|
||||
clearable
|
||||
style="width: 260px;"
|
||||
/>
|
||||
<NSelect
|
||||
v-model:value="filterIncludeInStats"
|
||||
:options="includeInStatsOptions"
|
||||
clearable
|
||||
placeholder="计入统计"
|
||||
style="width: 130px;"
|
||||
/>
|
||||
<NButton type="primary" @click="handleSearch">查询</NButton>
|
||||
<NButton @click="handleReset">重置</NButton>
|
||||
</NSpace>
|
||||
</NCard>
|
||||
|
||||
<!-- 交易记录数据表格 -->
|
||||
<NDataTable :columns="columns" :data="transactions" :loading="loading" :bordered="false" />
|
||||
|
||||
<!-- 分页控件 -->
|
||||
<NSpace justify="center" style="margin-top: 16px;">
|
||||
<NPagination v-model:page="page" :page-count="Math.ceil(total / pageSize)" @update:page="handlePageChange" />
|
||||
</NSpace>
|
||||
|
||||
<!-- 新增/编辑弹窗 -->
|
||||
<NModal v-model:show="showModal" preset="card" :title="editingId ? '编辑记录' : '新增记录'" style="width: 500px;">
|
||||
<NForm>
|
||||
<NFormItem label="类型">
|
||||
<NSelect v-model:value="form.type" :options="typeOptions" />
|
||||
</NFormItem>
|
||||
<NFormItem label="分类">
|
||||
<NSelect v-model:value="form.categoryId" :options="categories.filter(c => c.type === form.type)" placeholder="请选择分类" filterable />
|
||||
</NFormItem>
|
||||
<NFormItem label="金额">
|
||||
<NInputNumber v-model:value="form.amount" :min="0.01" :precision="2" style="width: 100%;" placeholder="请输入金额" />
|
||||
</NFormItem>
|
||||
<NFormItem label="支付方式">
|
||||
<NSelect v-model:value="form.paymentMethod" :options="paymentOptions" clearable placeholder="选择支付方式" />
|
||||
</NFormItem>
|
||||
<NFormItem label="交易时间">
|
||||
<NDatePicker v-model:value="form.transactionTime" type="datetime" style="width: 100%;" />
|
||||
</NFormItem>
|
||||
<NFormItem label="备注">
|
||||
<NInput v-model:value="form.remark" type="textarea" placeholder="可选备注说明" />
|
||||
</NFormItem>
|
||||
<NFormItem label="计入统计">
|
||||
<NSwitch v-model:value="form.includeInStats" />
|
||||
<span style="color: #999; margin-left: 8px; font-size: 12px;">关闭后不纳入月度统计(如借款、转账等)</span>
|
||||
</NFormItem>
|
||||
<NButton type="primary" block @click="handleSubmit">确定</NButton>
|
||||
</NForm>
|
||||
</NModal>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"extends": "@vue/tsconfig/tsconfig.dom.json",
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"types": ["vite/client"],
|
||||
"allowArbitraryExtensions": true,
|
||||
|
||||
/* Linting */
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"]
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||
"target": "es2023",
|
||||
"lib": ["ES2023"],
|
||||
"types": ["node"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"module": "nodenext",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
|
||||
/* Linting */
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import { defineConfig } from 'vite'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:8080',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
distributionUrl=https\://mirrors.cloud.tencent.com/gradle/gradle-8.13-bin.zip
|
||||
networkTimeout=120000
|
||||
validateDistributionUrl=true
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
Vendored
+94
@@ -0,0 +1,94 @@
|
||||
@rem
|
||||
@rem Copyright 2015 the original author or authors.
|
||||
@rem
|
||||
@rem Licensed under the Apache License, Version 2.0 (the "License");
|
||||
@rem you may not use this file except in compliance with the License.
|
||||
@rem You may obtain a copy of the License at
|
||||
@rem
|
||||
@rem https://www.apache.org/licenses/LICENSE-2.0
|
||||
@rem
|
||||
@rem Unless required by applicable law or agreed to in writing, software
|
||||
@rem distributed under the License is distributed on an "AS IS" BASIS,
|
||||
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
@rem See the License for the specific language governing permissions and
|
||||
@rem limitations under the License.
|
||||
@rem
|
||||
@rem SPDX-License-Identifier: Apache-2.0
|
||||
@rem
|
||||
|
||||
@if "%DEBUG%"=="" @echo off
|
||||
@rem ##########################################################################
|
||||
@rem
|
||||
@rem Gradle startup script for Windows
|
||||
@rem
|
||||
@rem ##########################################################################
|
||||
|
||||
@rem Set local scope for the variables with windows NT shell
|
||||
if "%OS%"=="Windows_NT" setlocal
|
||||
|
||||
set DIRNAME=%~dp0
|
||||
if "%DIRNAME%"=="" set DIRNAME=.
|
||||
@rem This is normally unused
|
||||
set APP_BASE_NAME=%~n0
|
||||
set APP_HOME=%DIRNAME%
|
||||
|
||||
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
|
||||
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
|
||||
|
||||
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
|
||||
|
||||
@rem Find java.exe
|
||||
if defined JAVA_HOME goto findJavaFromJavaHome
|
||||
|
||||
set JAVA_EXE=java.exe
|
||||
%JAVA_EXE% -version >NUL 2>&1
|
||||
if %ERRORLEVEL% equ 0 goto execute
|
||||
|
||||
echo. 1>&2
|
||||
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
|
||||
echo. 1>&2
|
||||
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||
echo location of your Java installation. 1>&2
|
||||
|
||||
goto fail
|
||||
|
||||
:findJavaFromJavaHome
|
||||
set JAVA_HOME=%JAVA_HOME:"=%
|
||||
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
|
||||
|
||||
if exist "%JAVA_EXE%" goto execute
|
||||
|
||||
echo. 1>&2
|
||||
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
|
||||
echo. 1>&2
|
||||
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||
echo location of your Java installation. 1>&2
|
||||
|
||||
goto fail
|
||||
|
||||
:execute
|
||||
@rem Setup the command line
|
||||
|
||||
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
|
||||
|
||||
|
||||
@rem Execute Gradle
|
||||
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
|
||||
|
||||
:end
|
||||
@rem End local scope for the variables with windows NT shell
|
||||
if %ERRORLEVEL% equ 0 goto mainEnd
|
||||
|
||||
:fail
|
||||
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
|
||||
rem the _cmd.exe /c_ return code!
|
||||
set EXIT_CODE=%ERRORLEVEL%
|
||||
if %EXIT_CODE% equ 0 set EXIT_CODE=1
|
||||
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
|
||||
exit /b %EXIT_CODE%
|
||||
|
||||
:mainEnd
|
||||
if "%OS%"=="Windows_NT" endlocal
|
||||
|
||||
:omega
|
||||
@@ -0,0 +1 @@
|
||||
rootProject.name = 'my-app'
|
||||
@@ -0,0 +1,22 @@
|
||||
package com.example.finance;
|
||||
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
|
||||
/**
|
||||
* 应用程序主启动类。
|
||||
* <p>Spring Boot 应用的入口点,负责引导和启动整个应用程序。</p>
|
||||
*/
|
||||
@SpringBootApplication
|
||||
public class MyApplication {
|
||||
|
||||
/**
|
||||
* 应用程序主入口方法。
|
||||
*
|
||||
* @param args 命令行启动参数
|
||||
*/
|
||||
public static void main(String[] args) {
|
||||
// 启动 Spring Boot 应用
|
||||
SpringApplication.run(MyApplication.class, args);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package com.example.finance.common;
|
||||
|
||||
/**
|
||||
* 业务异常类。
|
||||
* <p>用于表示业务逻辑中的可预期异常,携带自定义状态码,便于全局异常处理器返回对应的错误信息。</p>
|
||||
*/
|
||||
public class BusinessException extends RuntimeException {
|
||||
|
||||
/** 错误状态码 */
|
||||
private final int code;
|
||||
|
||||
/**
|
||||
* 构造业务异常(默认状态码 400)。
|
||||
*
|
||||
* @param message 异常消息
|
||||
*/
|
||||
public BusinessException(String message) {
|
||||
super(message);
|
||||
this.code = 400;
|
||||
}
|
||||
|
||||
/**
|
||||
* 构造业务异常(自定义状态码)。
|
||||
*
|
||||
* @param code 错误状态码
|
||||
* @param message 异常消息
|
||||
*/
|
||||
public BusinessException(int code, String message) {
|
||||
super(message);
|
||||
this.code = code;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取错误状态码。
|
||||
*
|
||||
* @return 错误状态码
|
||||
*/
|
||||
public int getCode() {
|
||||
return code;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package com.example.finance.common;
|
||||
|
||||
import org.springframework.web.bind.MethodArgumentNotValidException;
|
||||
import org.springframework.web.bind.annotation.ExceptionHandler;
|
||||
import org.springframework.web.bind.annotation.RestControllerAdvice;
|
||||
|
||||
/**
|
||||
* 全局异常处理器。
|
||||
* <p>统一捕获并处理控制器层抛出的各类异常,将其转换为标准化的 JSON 响应返回给前端。</p>
|
||||
*/
|
||||
@RestControllerAdvice
|
||||
public class GlobalExceptionHandler {
|
||||
|
||||
/**
|
||||
* 处理业务异常。
|
||||
*
|
||||
* @param e 业务异常对象
|
||||
* @return 包含错误状态码和消息的响应结果
|
||||
*/
|
||||
@ExceptionHandler(BusinessException.class)
|
||||
public Result<Void> handleBusinessException(BusinessException e) {
|
||||
return Result.error(e.getCode(), e.getMessage());
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理参数校验异常。
|
||||
* <p>当请求参数校验不通过时,提取第一个校验错误信息并返回。</p>
|
||||
*
|
||||
* @param e 参数校验异常对象
|
||||
* @return 包含 400 状态码和校验错误消息的响应结果
|
||||
*/
|
||||
@ExceptionHandler(MethodArgumentNotValidException.class)
|
||||
public Result<Void> handleValidationException(MethodArgumentNotValidException e) {
|
||||
// 提取第一个校验错误信息
|
||||
String message = e.getBindingResult().getAllErrors().get(0).getDefaultMessage();
|
||||
return Result.error(400, message);
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理未捕获的通用异常。
|
||||
*
|
||||
* @param e 异常对象
|
||||
* @return 包含 500 状态码和错误详情的响应结果
|
||||
*/
|
||||
@ExceptionHandler(Exception.class)
|
||||
public Result<Void> handleException(Exception e) {
|
||||
// 打印异常堆栈信息,便于排查问题
|
||||
e.printStackTrace();
|
||||
return Result.error(500, "服务器内部错误: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package com.example.finance.common;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 分页查询结果封装类。
|
||||
* <p>用于封装分页查询的返回结果,包含数据列表、总记录数、当前页码和每页大小。</p>
|
||||
*
|
||||
* @param <T> 分页记录数据的泛型类型
|
||||
*/
|
||||
@Data
|
||||
public class PageResult<T> {
|
||||
|
||||
/** 当前页的数据列表 */
|
||||
private List<T> records;
|
||||
/** 总记录数 */
|
||||
private long total;
|
||||
/** 当前页码 */
|
||||
private long page;
|
||||
/** 每页大小 */
|
||||
private long size;
|
||||
|
||||
/**
|
||||
* 构造分页结果。
|
||||
*
|
||||
* @param records 当前页的数据列表
|
||||
* @param total 总记录数
|
||||
* @param page 当前页码
|
||||
* @param size 每页大小
|
||||
*/
|
||||
public PageResult(List<T> records, long total, long page, long size) {
|
||||
this.records = records;
|
||||
this.total = total;
|
||||
this.page = page;
|
||||
this.size = size;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package com.example.finance.common;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* 统一响应结果封装类。
|
||||
* <p>用于封装所有接口返回的数据,包含状态码、消息和数据体,便于前端统一处理。</p>
|
||||
*
|
||||
* @param <T> 响应数据的泛型类型
|
||||
*/
|
||||
@Data
|
||||
public class Result<T> {
|
||||
|
||||
/** 响应状态码 */
|
||||
private int code;
|
||||
/** 响应消息 */
|
||||
private String message;
|
||||
/** 响应数据 */
|
||||
private T data;
|
||||
|
||||
/**
|
||||
* 返回成功结果(携带数据)。
|
||||
*
|
||||
* @param data 响应数据
|
||||
* @param <T> 数据类型
|
||||
* @return 包含数据的成功响应结果
|
||||
*/
|
||||
public static <T> Result<T> success(T data) {
|
||||
Result<T> result = new Result<>();
|
||||
result.setCode(200);
|
||||
result.setMessage("success");
|
||||
result.setData(data);
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 返回成功结果(不携带数据)。
|
||||
*
|
||||
* @param <T> 数据类型
|
||||
* @return 不包含数据的成功响应结果
|
||||
*/
|
||||
public static <T> Result<T> success() {
|
||||
return success(null);
|
||||
}
|
||||
|
||||
/**
|
||||
* 返回错误结果(自定义状态码和消息)。
|
||||
*
|
||||
* @param code 错误状态码
|
||||
* @param message 错误消息
|
||||
* @param <T> 数据类型
|
||||
* @return 错误响应结果
|
||||
*/
|
||||
public static <T> Result<T> error(int code, String message) {
|
||||
Result<T> result = new Result<>();
|
||||
result.setCode(code);
|
||||
result.setMessage(message);
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 返回错误结果(使用默认状态码 500)。
|
||||
*
|
||||
* @param message 错误消息
|
||||
* @param <T> 数据类型
|
||||
* @return 错误响应结果
|
||||
*/
|
||||
public static <T> Result<T> error(String message) {
|
||||
return error(500, message);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package com.example.finance.config;
|
||||
|
||||
import lombok.Data;
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* JWT 配置类。
|
||||
* <p>从配置文件中读取以 "jwt" 为前缀的 JWT 相关配置项,包括密钥和过期时间。</p>
|
||||
*/
|
||||
@Data
|
||||
@Component
|
||||
@ConfigurationProperties(prefix = "jwt")
|
||||
public class JwtConfig {
|
||||
|
||||
/** JWT 签名密钥 */
|
||||
private String secret;
|
||||
/** JWT 过期时间(毫秒) */
|
||||
private long expiration;
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package com.example.finance.config;
|
||||
|
||||
import io.swagger.v3.oas.models.OpenAPI;
|
||||
import io.swagger.v3.oas.models.info.Info;
|
||||
import io.swagger.v3.oas.models.Components;
|
||||
import io.swagger.v3.oas.models.security.SecurityRequirement;
|
||||
import io.swagger.v3.oas.models.security.SecurityScheme;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
@Configuration
|
||||
public class Knife4jConfig {
|
||||
|
||||
@Bean
|
||||
public OpenAPI customOpenAPI() {
|
||||
return new OpenAPI()
|
||||
.info(new Info()
|
||||
.title("收支管理系统 API")
|
||||
.version("1.0.0")
|
||||
.description("个人收支管理系统后端接口文档"))
|
||||
.addSecurityItem(new SecurityRequirement().addList("Bearer"))
|
||||
.components(new Components()
|
||||
.addSecuritySchemes("Bearer",
|
||||
new SecurityScheme()
|
||||
.type(SecurityScheme.Type.HTTP)
|
||||
.scheme("bearer")
|
||||
.bearerFormat("JWT")));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package com.example.finance.config;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.DbType;
|
||||
import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor;
|
||||
import com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor;
|
||||
import org.mybatis.spring.annotation.MapperScan;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
/**
|
||||
* MyBatis-Plus 配置类。
|
||||
* <p>配置 MyBatis-Plus 的 Mapper 扫描路径和分页插件等。</p>
|
||||
*/
|
||||
@Configuration
|
||||
@MapperScan("com.example.finance.mapper")
|
||||
public class MybatisPlusConfig {
|
||||
|
||||
/**
|
||||
* 注册 MyBatis-Plus 拦截器,配置 MySQL 分页插件。
|
||||
*
|
||||
* @return MyBatis-Plus 拦截器实例
|
||||
*/
|
||||
@Bean
|
||||
public MybatisPlusInterceptor mybatisPlusInterceptor() {
|
||||
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
|
||||
// 添加 MySQL 分页拦截器
|
||||
interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL));
|
||||
return interceptor;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package com.example.finance.config;
|
||||
|
||||
import com.example.finance.interceptor.JwtInterceptor;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.web.servlet.config.annotation.CorsRegistry;
|
||||
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
|
||||
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
|
||||
|
||||
/**
|
||||
* Web MVC 配置类。
|
||||
* <p>配置拦截器、跨域策略等 Web 层相关设置。</p>
|
||||
*/
|
||||
@Configuration
|
||||
public class WebConfig implements WebMvcConfigurer {
|
||||
|
||||
private final JwtInterceptor jwtInterceptor;
|
||||
|
||||
/**
|
||||
* 构造方法,注入 JWT 拦截器。
|
||||
*
|
||||
* @param jwtInterceptor JWT 拦截器实例
|
||||
*/
|
||||
public WebConfig(JwtInterceptor jwtInterceptor) {
|
||||
this.jwtInterceptor = jwtInterceptor;
|
||||
}
|
||||
|
||||
/**
|
||||
* 配置拦截器规则。
|
||||
* <p>对 /api/** 路径下的请求启用 JWT 认证,排除登录和注册接口。</p>
|
||||
*
|
||||
* @param registry 拦截器注册器
|
||||
*/
|
||||
@Override
|
||||
public void addInterceptors(InterceptorRegistry registry) {
|
||||
registry.addInterceptor(jwtInterceptor)
|
||||
.addPathPatterns("/api/**")
|
||||
// 登录和注册接口不需要认证
|
||||
.excludePathPatterns("/api/user/login", "/api/user/register");
|
||||
}
|
||||
|
||||
/**
|
||||
* 配置跨域策略。
|
||||
* <p>允许所有来源的跨域请求,支持常用 HTTP 方法,并允许携带凭据。</p>
|
||||
*
|
||||
* @param registry 跨域配置注册器
|
||||
*/
|
||||
@Override
|
||||
public void addCorsMappings(CorsRegistry registry) {
|
||||
registry.addMapping("/**")
|
||||
.allowedOriginPatterns("*")
|
||||
.allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS")
|
||||
.allowedHeaders("*")
|
||||
.allowCredentials(true)
|
||||
.maxAge(3600);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
package com.example.finance.controller;
|
||||
|
||||
import com.example.finance.common.Result;
|
||||
import com.example.finance.dto.CategoryRequest;
|
||||
import com.example.finance.service.CategoryService;
|
||||
import com.example.finance.vo.CategoryVO;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.Parameter;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.validation.Valid;
|
||||
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.PutMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 分类控制器
|
||||
*
|
||||
* <p>提供分类的增删改查接口,支持按类型筛选</p>
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/category")
|
||||
@Tag(name = "分类管理", description = "收支分类的增删改查相关接口")
|
||||
public class CategoryController {
|
||||
|
||||
private final CategoryService categoryService;
|
||||
|
||||
public CategoryController(CategoryService categoryService) {
|
||||
this.categoryService = categoryService;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取分类列表
|
||||
*
|
||||
* @param request HTTP请求,从中获取用户ID
|
||||
* @param type 分类类型(可选),用于按类型筛选
|
||||
* @return 分类列表
|
||||
*/
|
||||
@GetMapping("/list")
|
||||
@Operation(summary = "获取分类列表", description = "获取当前用户的全部分类,支持按类型筛选")
|
||||
public Result<List<CategoryVO>> list(HttpServletRequest request,
|
||||
@Parameter(description = "分类类型,可选值:income/expense") @RequestParam(required = false) String type) {
|
||||
Long userId = (Long) request.getAttribute("userId");
|
||||
if (type != null && !type.isEmpty()) {
|
||||
return Result.success(categoryService.listByType(userId, type));
|
||||
}
|
||||
return Result.success(categoryService.list(userId));
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建分类
|
||||
*
|
||||
* @param request HTTP请求,从中获取用户ID
|
||||
* @param categoryRequest 分类请求参数
|
||||
* @return 操作结果
|
||||
*/
|
||||
@PostMapping
|
||||
@Operation(summary = "创建分类", description = "为当前用户创建新的收支分类")
|
||||
public Result<Void> create(HttpServletRequest request, @RequestBody @Valid CategoryRequest categoryRequest) {
|
||||
Long userId = (Long) request.getAttribute("userId");
|
||||
categoryService.create(userId, categoryRequest);
|
||||
return Result.success();
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新分类
|
||||
*
|
||||
* @param request HTTP请求,从中获取用户ID
|
||||
* @param id 分类ID
|
||||
* @param categoryRequest 分类请求参数
|
||||
* @return 操作结果
|
||||
*/
|
||||
@PutMapping("/{id}")
|
||||
@Operation(summary = "更新分类", description = "根据分类ID更新分类信息")
|
||||
public Result<Void> update(HttpServletRequest request,
|
||||
@Parameter(description = "分类ID") @PathVariable Long id,
|
||||
@RequestBody @Valid CategoryRequest categoryRequest) {
|
||||
Long userId = (Long) request.getAttribute("userId");
|
||||
categoryService.update(userId, id, categoryRequest);
|
||||
return Result.success();
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除分类
|
||||
*
|
||||
* @param request HTTP请求,从中获取用户ID
|
||||
* @param id 分类ID
|
||||
* @return 操作结果
|
||||
*/
|
||||
@DeleteMapping("/{id}")
|
||||
@Operation(summary = "删除分类", description = "根据分类ID删除指定分类")
|
||||
public Result<Void> delete(HttpServletRequest request,
|
||||
@Parameter(description = "分类ID") @PathVariable Long id) {
|
||||
Long userId = (Long) request.getAttribute("userId");
|
||||
categoryService.delete(userId, id);
|
||||
return Result.success();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package com.example.finance.controller;
|
||||
|
||||
import com.example.finance.common.Result;
|
||||
import com.example.finance.dto.StatisticsQuery;
|
||||
import com.example.finance.service.StatisticsService;
|
||||
import com.example.finance.vo.CategoryStatisticsVO;
|
||||
import com.example.finance.vo.MonthlyStatisticsVO;
|
||||
import com.example.finance.vo.TrendStatisticsVO;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.Parameter;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.validation.Valid;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 统计控制器
|
||||
*
|
||||
* <p>提供月度统计、分类统计、趋势统计等数据统计接口</p>
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/statistics")
|
||||
@Tag(name = "数据统计", description = "收支数据的月度统计、分类统计及趋势分析接口")
|
||||
public class StatisticsController {
|
||||
|
||||
private final StatisticsService statisticsService;
|
||||
|
||||
public StatisticsController(StatisticsService statisticsService) {
|
||||
this.statisticsService = statisticsService;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取月度统计数据
|
||||
*
|
||||
* @param request HTTP请求,从中获取用户ID
|
||||
* @param query 统计查询参数
|
||||
* @return 月度统计结果
|
||||
*/
|
||||
@GetMapping("/monthly")
|
||||
@Operation(summary = "获取月度统计数据", description = "获取当前用户指定月份的收支统计汇总数据")
|
||||
public Result<MonthlyStatisticsVO> monthly(HttpServletRequest request, @Valid
|
||||
@Parameter(description = "统计查询参数") StatisticsQuery query) {
|
||||
Long userId = (Long) request.getAttribute("userId");
|
||||
return Result.success(statisticsService.getMonthlyStatistics(userId, query));
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取分类统计数据
|
||||
*
|
||||
* @param request HTTP请求,从中获取用户ID
|
||||
* @param query 统计查询参数
|
||||
* @return 分类统计列表
|
||||
*/
|
||||
@GetMapping("/category")
|
||||
@Operation(summary = "获取分类统计数据", description = "获取当前用户指定月份按分类维度的统计数据")
|
||||
public Result<List<CategoryStatisticsVO>> category(HttpServletRequest request, @Valid
|
||||
@Parameter(description = "统计查询参数") StatisticsQuery query) {
|
||||
Long userId = (Long) request.getAttribute("userId");
|
||||
return Result.success(statisticsService.getCategoryStatistics(userId, query));
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取趋势统计数据
|
||||
*
|
||||
* @param request HTTP请求,从中获取用户ID
|
||||
* @param query 统计查询参数
|
||||
* @return 趋势统计列表
|
||||
*/
|
||||
@GetMapping("/trend")
|
||||
@Operation(summary = "获取趋势统计数据", description = "获取当前用户指定时间范围内的收支趋势数据")
|
||||
public Result<List<TrendStatisticsVO>> trend(HttpServletRequest request, @Valid
|
||||
@Parameter(description = "统计查询参数") StatisticsQuery query) {
|
||||
Long userId = (Long) request.getAttribute("userId");
|
||||
return Result.success(statisticsService.getTrendStatistics(userId, query));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
package com.example.finance.controller;
|
||||
|
||||
import com.alibaba.excel.EasyExcel;
|
||||
import com.example.finance.common.PageResult;
|
||||
import com.example.finance.common.Result;
|
||||
import com.example.finance.dto.TransactionQuery;
|
||||
import com.example.finance.dto.TransactionRequest;
|
||||
import com.example.finance.service.TransactionService;
|
||||
import com.example.finance.vo.TransactionExportVO;
|
||||
import com.example.finance.vo.TransactionVO;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.Parameter;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import jakarta.validation.Valid;
|
||||
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.PutMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.URLEncoder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 交易记录控制器
|
||||
*
|
||||
* <p>提供交易记录的分页查询、新增、更新、删除接口</p>
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/transaction")
|
||||
@Tag(name = "交易记录管理", description = "收支交易记录的增删改查相关接口")
|
||||
public class TransactionController {
|
||||
|
||||
private final TransactionService transactionService;
|
||||
|
||||
public TransactionController(TransactionService transactionService) {
|
||||
this.transactionService = transactionService;
|
||||
}
|
||||
|
||||
/**
|
||||
* 分页查询交易记录
|
||||
*
|
||||
* @param request HTTP请求,从中获取用户ID
|
||||
* @param query 分页查询参数
|
||||
* @return 分页交易记录列表
|
||||
*/
|
||||
@GetMapping("/page")
|
||||
@Operation(summary = "分页查询交易记录", description = "根据查询条件分页获取当前用户的交易记录")
|
||||
public Result<PageResult<TransactionVO>> page(HttpServletRequest request,
|
||||
@Parameter(description = "分页查询参数") TransactionQuery query) {
|
||||
Long userId = (Long) request.getAttribute("userId");
|
||||
return Result.success(transactionService.page(userId, query));
|
||||
}
|
||||
|
||||
/**
|
||||
* 新增交易记录
|
||||
*
|
||||
* @param request HTTP请求,从中获取用户ID
|
||||
* @param transactionRequest 交易记录请求参数
|
||||
* @return 操作结果
|
||||
*/
|
||||
@PostMapping
|
||||
@Operation(summary = "新增交易记录", description = "为当前用户新增一条交易记录")
|
||||
public Result<Void> create(HttpServletRequest request, @RequestBody @Valid TransactionRequest transactionRequest) {
|
||||
Long userId = (Long) request.getAttribute("userId");
|
||||
transactionService.create(userId, transactionRequest);
|
||||
return Result.success();
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新交易记录
|
||||
*
|
||||
* @param request HTTP请求,从中获取用户ID
|
||||
* @param id 交易记录ID
|
||||
* @param transactionRequest 交易记录请求参数
|
||||
* @return 操作结果
|
||||
*/
|
||||
@PutMapping("/{id}")
|
||||
@Operation(summary = "更新交易记录", description = "根据交易记录ID更新交易信息")
|
||||
public Result<Void> update(HttpServletRequest request,
|
||||
@Parameter(description = "交易记录ID") @PathVariable Long id,
|
||||
@RequestBody @Valid TransactionRequest transactionRequest) {
|
||||
Long userId = (Long) request.getAttribute("userId");
|
||||
transactionService.update(userId, id, transactionRequest);
|
||||
return Result.success();
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除交易记录
|
||||
*
|
||||
* @param request HTTP请求,从中获取用户ID
|
||||
* @param id 交易记录ID
|
||||
* @return 操作结果
|
||||
*/
|
||||
@DeleteMapping("/{id}")
|
||||
@Operation(summary = "删除交易记录", description = "根据交易记录ID删除指定交易记录")
|
||||
public Result<Void> delete(HttpServletRequest request,
|
||||
@Parameter(description = "交易记录ID") @PathVariable Long id) {
|
||||
Long userId = (Long) request.getAttribute("userId");
|
||||
transactionService.delete(userId, id);
|
||||
return Result.success();
|
||||
}
|
||||
|
||||
/**
|
||||
* 切换交易记录的"计入统计"标记
|
||||
*
|
||||
* @param request HTTP请求,从中获取用户ID
|
||||
* @param id 交易记录ID
|
||||
* @param body 请求体,包含 includeInStats 字段
|
||||
* @return 操作结果
|
||||
*/
|
||||
@PutMapping("/{id}/stats")
|
||||
@Operation(summary = "切换计入统计标记", description = "切换交易记录是否计入统计(用于表格中开关)")
|
||||
public Result<Void> toggleStats(HttpServletRequest request,
|
||||
@Parameter(description = "交易记录ID") @PathVariable Long id,
|
||||
@RequestBody Map<String, Boolean> body) {
|
||||
Long userId = (Long) request.getAttribute("userId");
|
||||
Boolean includeInStats = body != null ? body.get("includeInStats") : null;
|
||||
transactionService.toggleStats(userId, id, includeInStats);
|
||||
return Result.success();
|
||||
}
|
||||
|
||||
/**
|
||||
* 导出交易记录为 Excel 文件
|
||||
*
|
||||
* @param request HTTP请求,从中获取用户ID
|
||||
* @param response HTTP响应,用于输出文件流
|
||||
* @param query 查询条件
|
||||
* @throws IOException 文件输出异常
|
||||
*/
|
||||
@GetMapping("/export")
|
||||
@Operation(summary = "导出交易记录", description = "根据查询条件导出交易记录为 Excel 文件")
|
||||
public void export(HttpServletRequest request,
|
||||
HttpServletResponse response,
|
||||
@Parameter(description = "查询条件") TransactionQuery query) throws IOException {
|
||||
Long userId = (Long) request.getAttribute("userId");
|
||||
|
||||
// 设置响应头
|
||||
response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
|
||||
response.setCharacterEncoding("utf-8");
|
||||
String fileName = URLEncoder.encode("交易记录_" + LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMdd_HHmmss")), StandardCharsets.UTF_8).replaceAll("\\+", "%20");
|
||||
response.setHeader("Content-Disposition", "attachment;filename=" + fileName + ".xlsx");
|
||||
|
||||
// 查询数据并写入 Excel
|
||||
List<TransactionExportVO> data = transactionService.export(userId, query);
|
||||
EasyExcel.write(response.getOutputStream(), TransactionExportVO.class)
|
||||
.sheet("交易记录")
|
||||
.doWrite(data);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package com.example.finance.controller;
|
||||
|
||||
import com.example.finance.common.Result;
|
||||
import com.example.finance.dto.LoginRequest;
|
||||
import com.example.finance.dto.RegisterRequest;
|
||||
import com.example.finance.service.UserService;
|
||||
import com.example.finance.vo.LoginVO;
|
||||
import com.example.finance.vo.UserVO;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.validation.Valid;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
/**
|
||||
* 用户控制器
|
||||
*
|
||||
* <p>提供用户登录、注册、获取用户信息等接口</p>
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/user")
|
||||
@Tag(name = "用户管理", description = "用户登录、注册及信息查询相关接口")
|
||||
public class UserController {
|
||||
|
||||
private final UserService userService;
|
||||
|
||||
public UserController(UserService userService) {
|
||||
this.userService = userService;
|
||||
}
|
||||
|
||||
/**
|
||||
* 用户登录
|
||||
*
|
||||
* @param request 登录请求参数
|
||||
* @return 登录结果,包含用户信息和令牌
|
||||
*/
|
||||
@PostMapping("/login")
|
||||
@Operation(summary = "用户登录", description = "通过用户名和密码进行登录认证")
|
||||
public Result<LoginVO> login(@RequestBody @Valid LoginRequest request) {
|
||||
return Result.success(userService.login(request));
|
||||
}
|
||||
|
||||
/**
|
||||
* 用户注册
|
||||
*
|
||||
* @param request 注册请求参数
|
||||
* @return 操作结果
|
||||
*/
|
||||
@PostMapping("/register")
|
||||
@Operation(summary = "用户注册", description = "新用户注册账号")
|
||||
public Result<Void> register(@RequestBody @Valid RegisterRequest request) {
|
||||
userService.register(request);
|
||||
return Result.success();
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前用户信息
|
||||
*
|
||||
* @param request HTTP请求,从中获取用户ID
|
||||
* @return 用户信息
|
||||
*/
|
||||
@GetMapping("/info")
|
||||
@Operation(summary = "获取当前用户信息", description = "获取已登录用户的详细信息")
|
||||
public Result<UserVO> info(HttpServletRequest request) {
|
||||
Long userId = (Long) request.getAttribute("userId");
|
||||
return Result.success(userService.getUserInfo(userId));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package com.example.finance.dto;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* 分类请求DTO,用于接收创建或更新分类时提交的信息
|
||||
*/
|
||||
@Data
|
||||
public class CategoryRequest {
|
||||
|
||||
/** 分类名称,不能为空 */
|
||||
@NotBlank(message = "分类名称不能为空")
|
||||
private String name;
|
||||
|
||||
/** 分类类型(收入/支出),不能为空 */
|
||||
@NotBlank(message = "分类类型不能为空")
|
||||
private String type;
|
||||
|
||||
/** 分类图标,可选 */
|
||||
private String icon;
|
||||
|
||||
/** 排序序号,可选 */
|
||||
private Integer sortOrder;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package com.example.finance.dto;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.Size;
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* 登录请求DTO,用于接收用户登录时提交的用户名和密码
|
||||
*/
|
||||
@Data
|
||||
public class LoginRequest {
|
||||
|
||||
/** 用户名,不能为空 */
|
||||
@NotBlank(message = "用户名不能为空")
|
||||
private String username;
|
||||
|
||||
/** 密码,不能为空 */
|
||||
@NotBlank(message = "密码不能为空")
|
||||
private String password;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package com.example.finance.dto;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.Size;
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* 注册请求DTO,用于接收用户注册时提交的信息
|
||||
*/
|
||||
@Data
|
||||
public class RegisterRequest {
|
||||
|
||||
/** 用户名,长度3-50个字符 */
|
||||
@NotBlank(message = "用户名不能为空")
|
||||
@Size(min = 3, max = 50, message = "用户名长度3-50个字符")
|
||||
private String username;
|
||||
|
||||
/** 密码,长度6-50个字符 */
|
||||
@NotBlank(message = "密码不能为空")
|
||||
@Size(min = 6, max = 50, message = "密码长度6-50个字符")
|
||||
private String password;
|
||||
|
||||
/** 昵称,可选 */
|
||||
private String nickname;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package com.example.finance.dto;
|
||||
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* 统计查询DTO,用于接收统计报表查询时的年月参数
|
||||
*/
|
||||
@Data
|
||||
public class StatisticsQuery {
|
||||
|
||||
/** 查询年份,不能为空 */
|
||||
@NotNull(message = "年份不能为空")
|
||||
private Integer year;
|
||||
|
||||
/** 查询月份,不能为空 */
|
||||
@NotNull(message = "月份不能为空")
|
||||
private Integer month;
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package com.example.finance.dto;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 交易查询DTO,用于接收交易记录分页查询的筛选条件
|
||||
*/
|
||||
@Data
|
||||
public class TransactionQuery {
|
||||
|
||||
/** 当前页码,默认第1页 */
|
||||
private Integer page = 1;
|
||||
|
||||
/** 每页条数,默认20条 */
|
||||
private Integer size = 20;
|
||||
|
||||
/** 交易类型筛选(收入/支出),支持多选 */
|
||||
private List<String> types;
|
||||
|
||||
/** 分类ID筛选,支持多选 */
|
||||
private List<Long> categoryIds;
|
||||
|
||||
/** 查询起始时间 */
|
||||
private String startTime;
|
||||
|
||||
/** 查询结束时间 */
|
||||
private String endTime;
|
||||
|
||||
/** 支付方式筛选(WECHAT/ALIPAY/CASH/BANK),支持多选 */
|
||||
private List<String> paymentMethods;
|
||||
|
||||
/** 是否计入统计筛选:null-全部,true-仅计入,false-仅不计入 */
|
||||
private Boolean includeInStats;
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package com.example.finance.dto;
|
||||
|
||||
import jakarta.validation.constraints.DecimalMin;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import lombok.Data;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* 交易请求DTO,用于接收创建或更新交易记录时提交的信息
|
||||
*/
|
||||
@Data
|
||||
public class TransactionRequest {
|
||||
|
||||
/** 分类ID,不能为空 */
|
||||
@NotNull(message = "分类不能为空")
|
||||
private Long categoryId;
|
||||
|
||||
/** 交易类型(收入/支出),不能为空 */
|
||||
@NotBlank(message = "类型不能为空")
|
||||
private String type;
|
||||
|
||||
/** 交易金额,必须大于0 */
|
||||
@NotNull(message = "金额不能为空")
|
||||
@DecimalMin(value = "0.01", message = "金额必须大于0")
|
||||
private BigDecimal amount;
|
||||
|
||||
/** 支付方式,可选 */
|
||||
private String paymentMethod;
|
||||
|
||||
/** 备注,可选 */
|
||||
private String remark;
|
||||
|
||||
/** 交易时间,不能为空 */
|
||||
@NotNull(message = "交易时间不能为空")
|
||||
private LocalDateTime transactionTime;
|
||||
|
||||
/** 是否计入统计,默认 true(借款、转账等可设为 false) */
|
||||
private Boolean includeInStats;
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package com.example.finance.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* 分类实体类,对应数据库表 t_category,用于记录收支分类信息
|
||||
*/
|
||||
@Data
|
||||
@TableName("t_category")
|
||||
public class Category {
|
||||
|
||||
/** 分类ID,主键自增 */
|
||||
@TableId(type = IdType.AUTO)
|
||||
private Long id;
|
||||
|
||||
/** 所属用户ID */
|
||||
private Long userId;
|
||||
|
||||
/** 分类名称 */
|
||||
private String name;
|
||||
|
||||
/** 分类类型(收入/支出) */
|
||||
private String type;
|
||||
|
||||
/** 分类图标 */
|
||||
private String icon;
|
||||
|
||||
/** 排序序号 */
|
||||
private Integer sortOrder;
|
||||
|
||||
/** 创建时间 */
|
||||
private LocalDateTime createdAt;
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package com.example.finance.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.Data;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* 交易记录实体类,对应数据库表 t_transaction,用于记录每笔收支交易
|
||||
*/
|
||||
@Data
|
||||
@TableName("t_transaction")
|
||||
public class Transaction {
|
||||
|
||||
/** 交易ID,主键自增 */
|
||||
@TableId(type = IdType.AUTO)
|
||||
private Long id;
|
||||
|
||||
/** 所属用户ID */
|
||||
private Long userId;
|
||||
|
||||
/** 所属分类ID */
|
||||
private Long categoryId;
|
||||
|
||||
/** 交易类型(收入/支出) */
|
||||
private String type;
|
||||
|
||||
/** 交易金额 */
|
||||
private BigDecimal amount;
|
||||
|
||||
/** 支付方式 */
|
||||
private String paymentMethod;
|
||||
|
||||
/** 备注 */
|
||||
private String remark;
|
||||
|
||||
/** 交易时间 */
|
||||
private LocalDateTime transactionTime;
|
||||
|
||||
/** 是否计入统计:true-计入,false-不计入(如借款、账户间转账等) */
|
||||
private Boolean includeInStats;
|
||||
|
||||
/** 创建时间 */
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
/** 更新时间 */
|
||||
private LocalDateTime updatedAt;
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package com.example.finance.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* 用户实体类,对应数据库表 t_user
|
||||
*/
|
||||
@Data
|
||||
@TableName("t_user")
|
||||
public class User {
|
||||
|
||||
/** 用户ID,主键自增 */
|
||||
@TableId(type = IdType.AUTO)
|
||||
private Long id;
|
||||
|
||||
/** 用户名 */
|
||||
private String username;
|
||||
|
||||
/** 密码哈希值 */
|
||||
private String passwordHash;
|
||||
|
||||
/** 昵称 */
|
||||
private String nickname;
|
||||
|
||||
/** 创建时间 */
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
/** 更新时间 */
|
||||
private LocalDateTime updatedAt;
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package com.example.finance.interceptor;
|
||||
|
||||
import com.example.finance.util.JwtUtil;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.servlet.HandlerInterceptor;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* JWT 认证拦截器。
|
||||
* <p>在请求到达控制器之前,从 Authorization 请求头中提取并验证 JWT Token,
|
||||
* 验证通过后将用户 ID 存入请求属性,验证失败则返回 401 响应。</p>
|
||||
*/
|
||||
@Component
|
||||
public class JwtInterceptor implements HandlerInterceptor {
|
||||
|
||||
private final JwtUtil jwtUtil;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
/**
|
||||
* 构造方法,注入 JWT 工具类和 JSON 序列化工具。
|
||||
*
|
||||
* @param jwtUtil JWT 工具类实例
|
||||
* @param objectMapper Jackson 对象映射器实例
|
||||
*/
|
||||
public JwtInterceptor(JwtUtil jwtUtil, ObjectMapper objectMapper) {
|
||||
this.jwtUtil = jwtUtil;
|
||||
this.objectMapper = objectMapper;
|
||||
}
|
||||
|
||||
/**
|
||||
* 请求预处理方法,在控制器执行前进行 JWT 认证。
|
||||
* <p>处理流程:放行 OPTIONS 预检请求 -> 提取 Token -> 验证 Token -> 存入用户 ID 或返回 401。</p>
|
||||
*
|
||||
* @param request HTTP 请求对象
|
||||
* @param response HTTP 响应对象
|
||||
* @param handler 处理器对象
|
||||
* @return 认证通过返回 true,否则返回 false
|
||||
* @throws Exception 处理过程中可能抛出的异常
|
||||
*/
|
||||
@Override
|
||||
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
|
||||
// 放行 CORS 预检请求
|
||||
if ("OPTIONS".equalsIgnoreCase(request.getMethod())) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 从 Authorization 请求头中提取 Token
|
||||
String token = request.getHeader("Authorization");
|
||||
if (token != null && token.startsWith("Bearer ")) {
|
||||
// 去除 "Bearer " 前缀
|
||||
token = token.substring(7);
|
||||
}
|
||||
|
||||
// Token 为空或验证不通过时,返回 401 未授权响应
|
||||
if (token == null || !jwtUtil.validateToken(token)) {
|
||||
response.setContentType("application/json;charset=UTF-8");
|
||||
response.setStatus(401);
|
||||
Map<String, Object> result = new HashMap<>();
|
||||
result.put("code", 401);
|
||||
result.put("message", "未登录或Token已过期");
|
||||
response.getWriter().write(objectMapper.writeValueAsString(result));
|
||||
return false;
|
||||
}
|
||||
|
||||
// Token 验证通过,将用户 ID 存入请求属性,供后续控制器使用
|
||||
Long userId = jwtUtil.getUserId(token);
|
||||
request.setAttribute("userId", userId);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package com.example.finance.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.example.finance.entity.Category;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
|
||||
/**
|
||||
* 分类Mapper接口,继承MyBatis-Plus的BaseMapper,提供分类表的基础CRUD操作
|
||||
*/
|
||||
@Mapper
|
||||
public interface CategoryMapper extends BaseMapper<Category> {
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package com.example.finance.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.example.finance.entity.Transaction;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
|
||||
/**
|
||||
* 交易记录Mapper接口,继承MyBatis-Plus的BaseMapper,提供交易记录表的基础CRUD操作
|
||||
*/
|
||||
@Mapper
|
||||
public interface TransactionMapper extends BaseMapper<Transaction> {
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package com.example.finance.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.example.finance.entity.User;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
|
||||
/**
|
||||
* 用户Mapper接口,继承MyBatis-Plus的BaseMapper,提供用户表的基础CRUD操作
|
||||
*/
|
||||
@Mapper
|
||||
public interface UserMapper extends BaseMapper<User> {
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package com.example.finance.service;
|
||||
|
||||
import com.example.finance.dto.CategoryRequest;
|
||||
import com.example.finance.vo.CategoryVO;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 分类服务接口,提供收支分类的增删改查操作。
|
||||
*/
|
||||
public interface CategoryService {
|
||||
|
||||
/**
|
||||
* 获取用户的所有分类列表
|
||||
*
|
||||
* @param userId 用户ID
|
||||
* @return 分类列表
|
||||
*/
|
||||
List<CategoryVO> list(Long userId);
|
||||
|
||||
/**
|
||||
* 按类型(收入/支出)获取用户的分类列表
|
||||
*
|
||||
* @param userId 用户ID
|
||||
* @param type 分类类型(如 INCOME、EXPENSE)
|
||||
* @return 分类列表
|
||||
*/
|
||||
List<CategoryVO> listByType(Long userId, String type);
|
||||
|
||||
/**
|
||||
* 创建新分类
|
||||
*
|
||||
* @param userId 用户ID
|
||||
* @param request 分类请求参数(名称、类型、图标等)
|
||||
*/
|
||||
void create(Long userId, CategoryRequest request);
|
||||
|
||||
/**
|
||||
* 更新分类信息
|
||||
*
|
||||
* @param userId 用户ID
|
||||
* @param id 分类ID
|
||||
* @param request 分类请求参数
|
||||
*/
|
||||
void update(Long userId, Long id, CategoryRequest request);
|
||||
|
||||
/**
|
||||
* 删除分类
|
||||
*
|
||||
* @param userId 用户ID
|
||||
* @param id 分类ID
|
||||
*/
|
||||
void delete(Long userId, Long id);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package com.example.finance.service;
|
||||
|
||||
import com.example.finance.dto.StatisticsQuery;
|
||||
import com.example.finance.vo.CategoryStatisticsVO;
|
||||
import com.example.finance.vo.MonthlyStatisticsVO;
|
||||
import com.example.finance.vo.TrendStatisticsVO;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 统计服务接口,提供月度收支统计、分类统计和趋势分析功能。
|
||||
*/
|
||||
public interface StatisticsService {
|
||||
|
||||
/**
|
||||
* 获取月度收支统计(总收入、总支出、结余、交易笔数)
|
||||
*
|
||||
* @param userId 用户ID
|
||||
* @param query 统计查询条件(年份、月份)
|
||||
* @return 月度统计结果
|
||||
*/
|
||||
MonthlyStatisticsVO getMonthlyStatistics(Long userId, StatisticsQuery query);
|
||||
|
||||
/**
|
||||
* 获取分类维度的统计数据(各分类的金额、笔数、占比)
|
||||
*
|
||||
* @param userId 用户ID
|
||||
* @param query 统计查询条件(年份、月份)
|
||||
* @return 分类统计列表
|
||||
*/
|
||||
List<CategoryStatisticsVO> getCategoryStatistics(Long userId, StatisticsQuery query);
|
||||
|
||||
/**
|
||||
* 获取每日收支趋势统计数据
|
||||
*
|
||||
* @param userId 用户ID
|
||||
* @param query 统计查询条件(年份、月份)
|
||||
* @return 按日排列的趋势统计列表
|
||||
*/
|
||||
List<TrendStatisticsVO> getTrendStatistics(Long userId, StatisticsQuery query);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package com.example.finance.service;
|
||||
|
||||
import com.example.finance.common.PageResult;
|
||||
import com.example.finance.dto.TransactionQuery;
|
||||
import com.example.finance.dto.TransactionRequest;
|
||||
import com.example.finance.vo.TransactionExportVO;
|
||||
import com.example.finance.vo.TransactionVO;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 交易记录服务接口,提供收支记录的分页查询、增删改操作。
|
||||
*/
|
||||
public interface TransactionService {
|
||||
|
||||
/**
|
||||
* 分页查询交易记录
|
||||
*
|
||||
* @param userId 用户ID
|
||||
* @param query 查询条件(类型、分类、时间范围、分页参数等)
|
||||
* @return 分页结果
|
||||
*/
|
||||
PageResult<TransactionVO> page(Long userId, TransactionQuery query);
|
||||
|
||||
/**
|
||||
* 创建交易记录
|
||||
*
|
||||
* @param userId 用户ID
|
||||
* @param request 交易请求参数(金额、分类、类型等)
|
||||
*/
|
||||
void create(Long userId, TransactionRequest request);
|
||||
|
||||
/**
|
||||
* 更新交易记录
|
||||
*
|
||||
* @param userId 用户ID
|
||||
* @param id 交易记录ID
|
||||
* @param request 交易请求参数
|
||||
*/
|
||||
void update(Long userId, Long id, TransactionRequest request);
|
||||
|
||||
/**
|
||||
* 删除交易记录
|
||||
*
|
||||
* @param userId 用户ID
|
||||
* @param id 交易记录ID
|
||||
*/
|
||||
void delete(Long userId, Long id);
|
||||
|
||||
/**
|
||||
* 导出交易记录
|
||||
*
|
||||
* @param userId 用户ID
|
||||
* @param query 查询条件(类型、分类、时间范围等)
|
||||
* @return 导出视图对象列表
|
||||
*/
|
||||
List<TransactionExportVO> export(Long userId, TransactionQuery query);
|
||||
|
||||
/**
|
||||
* 切换单条交易记录的"计入统计"标记
|
||||
*
|
||||
* @param userId 用户ID
|
||||
* @param id 交易记录ID
|
||||
* @param includeInStats 新的标记值
|
||||
*/
|
||||
void toggleStats(Long userId, Long id, Boolean includeInStats);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package com.example.finance.service;
|
||||
|
||||
import com.example.finance.dto.LoginRequest;
|
||||
import com.example.finance.dto.RegisterRequest;
|
||||
import com.example.finance.vo.LoginVO;
|
||||
import com.example.finance.vo.UserVO;
|
||||
|
||||
/**
|
||||
* 用户服务接口,提供用户登录、注册和信息查询等操作。
|
||||
*/
|
||||
public interface UserService {
|
||||
|
||||
/**
|
||||
* 用户登录
|
||||
*
|
||||
* @param request 登录请求参数(用户名、密码)
|
||||
* @return 登录结果,包含 Token 和用户信息
|
||||
*/
|
||||
LoginVO login(LoginRequest request);
|
||||
|
||||
/**
|
||||
* 用户注册
|
||||
*
|
||||
* @param request 注册请求参数(用户名、密码、昵称等)
|
||||
*/
|
||||
void register(RegisterRequest request);
|
||||
|
||||
/**
|
||||
* 获取用户信息
|
||||
*
|
||||
* @param userId 用户ID
|
||||
* @return 用户信息视图对象
|
||||
*/
|
||||
UserVO getUserInfo(Long userId);
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
package com.example.finance.service.impl;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.example.finance.common.BusinessException;
|
||||
import com.example.finance.dto.CategoryRequest;
|
||||
import com.example.finance.entity.Category;
|
||||
import com.example.finance.mapper.CategoryMapper;
|
||||
import com.example.finance.service.CategoryService;
|
||||
import com.example.finance.vo.CategoryVO;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* 分类服务实现类,处理收支分类的增删改查业务逻辑。
|
||||
*/
|
||||
@Service
|
||||
public class CategoryServiceImpl implements CategoryService {
|
||||
|
||||
private final CategoryMapper categoryMapper;
|
||||
|
||||
public CategoryServiceImpl(CategoryMapper categoryMapper) {
|
||||
this.categoryMapper = categoryMapper;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*
|
||||
* 查询用户所有分类,按排序字段和ID升序排列。
|
||||
*/
|
||||
@Override
|
||||
public List<CategoryVO> list(Long userId) {
|
||||
List<Category> categories = categoryMapper.selectList(new LambdaQueryWrapper<Category>()
|
||||
.eq(Category::getUserId, userId)
|
||||
.orderByAsc(Category::getSortOrder)
|
||||
.orderByAsc(Category::getId));
|
||||
return categories.stream().map(this::toVO).collect(Collectors.toList());
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*
|
||||
* 按分类类型筛选用户分类列表,按排序字段和ID升序排列。
|
||||
*/
|
||||
@Override
|
||||
public List<CategoryVO> listByType(Long userId, String type) {
|
||||
List<Category> categories = categoryMapper.selectList(new LambdaQueryWrapper<Category>()
|
||||
.eq(Category::getUserId, userId)
|
||||
.eq(Category::getType, type)
|
||||
.orderByAsc(Category::getSortOrder)
|
||||
.orderByAsc(Category::getId));
|
||||
return categories.stream().map(this::toVO).collect(Collectors.toList());
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*
|
||||
* 创建新分类,若未指定排序值则默认为0。
|
||||
*/
|
||||
@Override
|
||||
@Transactional
|
||||
public void create(Long userId, CategoryRequest request) {
|
||||
Category category = new Category();
|
||||
category.setUserId(userId);
|
||||
category.setName(request.getName());
|
||||
category.setType(request.getType());
|
||||
category.setIcon(request.getIcon());
|
||||
// 排序值未提供时默认为0
|
||||
category.setSortOrder(request.getSortOrder() != null ? request.getSortOrder() : 0);
|
||||
categoryMapper.insert(category);
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*
|
||||
* 更新分类信息,需校验分类归属当前用户。
|
||||
*/
|
||||
@Override
|
||||
@Transactional
|
||||
public void update(Long userId, Long id, CategoryRequest request) {
|
||||
Category category = categoryMapper.selectById(id);
|
||||
// 校验分类是否存在且属于当前用户
|
||||
if (category == null || !category.getUserId().equals(userId)) {
|
||||
throw new BusinessException("分类不存在");
|
||||
}
|
||||
category.setName(request.getName());
|
||||
category.setType(request.getType());
|
||||
category.setIcon(request.getIcon());
|
||||
if (request.getSortOrder() != null) {
|
||||
category.setSortOrder(request.getSortOrder());
|
||||
}
|
||||
categoryMapper.updateById(category);
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*
|
||||
* 删除分类,需校验分类归属当前用户。
|
||||
*/
|
||||
@Override
|
||||
@Transactional
|
||||
public void delete(Long userId, Long id) {
|
||||
Category category = categoryMapper.selectById(id);
|
||||
// 校验分类是否存在且属于当前用户
|
||||
if (category == null || !category.getUserId().equals(userId)) {
|
||||
throw new BusinessException("分类不存在");
|
||||
}
|
||||
categoryMapper.deleteById(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* 将分类实体转换为视图对象
|
||||
*
|
||||
* @param category 分类实体
|
||||
* @return 分类视图对象
|
||||
*/
|
||||
private CategoryVO toVO(Category category) {
|
||||
CategoryVO vo = new CategoryVO();
|
||||
vo.setId(category.getId());
|
||||
vo.setName(category.getName());
|
||||
vo.setType(category.getType());
|
||||
vo.setIcon(category.getIcon());
|
||||
vo.setSortOrder(category.getSortOrder());
|
||||
vo.setCreatedAt(category.getCreatedAt());
|
||||
return vo;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
package com.example.finance.service.impl;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.example.finance.dto.StatisticsQuery;
|
||||
import com.example.finance.entity.Category;
|
||||
import com.example.finance.entity.Transaction;
|
||||
import com.example.finance.mapper.CategoryMapper;
|
||||
import com.example.finance.mapper.TransactionMapper;
|
||||
import com.example.finance.service.StatisticsService;
|
||||
import com.example.finance.vo.CategoryStatisticsVO;
|
||||
import com.example.finance.vo.MonthlyStatisticsVO;
|
||||
import com.example.finance.vo.TrendStatisticsVO;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.math.RoundingMode;
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.LocalTime;
|
||||
import java.time.YearMonth;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* 统计服务实现类,提供月度收支汇总、分类占比分析和每日收支趋势统计等业务逻辑。
|
||||
*/
|
||||
@Service
|
||||
public class StatisticsServiceImpl implements StatisticsService {
|
||||
|
||||
private final TransactionMapper transactionMapper;
|
||||
private final CategoryMapper categoryMapper;
|
||||
|
||||
public StatisticsServiceImpl(TransactionMapper transactionMapper, CategoryMapper categoryMapper) {
|
||||
this.transactionMapper = transactionMapper;
|
||||
this.categoryMapper = categoryMapper;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*
|
||||
* 遍历指定月份的所有交易记录,分别累加收入和支出,计算结余和交易笔数。
|
||||
*/
|
||||
@Override
|
||||
public MonthlyStatisticsVO getMonthlyStatistics(Long userId, StatisticsQuery query) {
|
||||
List<Transaction> transactions = getMonthTransactions(userId, query);
|
||||
|
||||
MonthlyStatisticsVO vo = new MonthlyStatisticsVO();
|
||||
BigDecimal totalIncome = BigDecimal.ZERO;
|
||||
BigDecimal totalExpense = BigDecimal.ZERO;
|
||||
|
||||
// 按交易类型分别累加收入和支出
|
||||
for (Transaction t : transactions) {
|
||||
if ("INCOME".equals(t.getType())) {
|
||||
totalIncome = totalIncome.add(t.getAmount());
|
||||
} else {
|
||||
totalExpense = totalExpense.add(t.getAmount());
|
||||
}
|
||||
}
|
||||
|
||||
vo.setTotalIncome(totalIncome);
|
||||
vo.setTotalExpense(totalExpense);
|
||||
vo.setBalance(totalIncome.subtract(totalExpense));
|
||||
vo.setTransactionCount(transactions.size());
|
||||
return vo;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*
|
||||
* 按分类ID分组统计交易金额、笔数,并计算各分类占总金额的百分比,结果按金额降序排列。
|
||||
*/
|
||||
@Override
|
||||
public List<CategoryStatisticsVO> getCategoryStatistics(Long userId, StatisticsQuery query) {
|
||||
List<Transaction> transactions = getMonthTransactions(userId, query);
|
||||
|
||||
// 按分类ID分组
|
||||
Map<Long, List<Transaction>> grouped = transactions.stream()
|
||||
.collect(Collectors.groupingBy(Transaction::getCategoryId));
|
||||
|
||||
// 批量查询分类信息
|
||||
List<Long> categoryIds = new ArrayList<>(grouped.keySet());
|
||||
Map<Long, Category> categoryMap = Map.of();
|
||||
if (!categoryIds.isEmpty()) {
|
||||
List<Category> categories = categoryMapper.selectBatchIds(categoryIds);
|
||||
categoryMap = categories.stream().collect(Collectors.toMap(Category::getId, c -> c));
|
||||
}
|
||||
|
||||
// 计算总金额,用于后续计算百分比
|
||||
BigDecimal totalAmount = transactions.stream()
|
||||
.map(Transaction::getAmount)
|
||||
.reduce(BigDecimal.ZERO, BigDecimal::add);
|
||||
|
||||
// 构建每个分类的统计结果
|
||||
Map<Long, Category> finalCategoryMap = categoryMap;
|
||||
return grouped.entrySet().stream()
|
||||
.map(entry -> {
|
||||
List<Transaction> list = entry.getValue();
|
||||
Category category = finalCategoryMap.get(entry.getKey());
|
||||
BigDecimal amount = list.stream()
|
||||
.map(Transaction::getAmount)
|
||||
.reduce(BigDecimal.ZERO, BigDecimal::add);
|
||||
|
||||
CategoryStatisticsVO vo = new CategoryStatisticsVO();
|
||||
vo.setCategoryId(entry.getKey());
|
||||
vo.setCategoryName(category != null ? category.getName() : "未知");
|
||||
vo.setCategoryIcon(category != null ? category.getIcon() : "");
|
||||
vo.setType(category != null ? category.getType() : "");
|
||||
vo.setAmount(amount);
|
||||
vo.setCount(list.size());
|
||||
// 计算占比百分比,保留两位小数,四舍五入
|
||||
if (totalAmount.compareTo(BigDecimal.ZERO) > 0) {
|
||||
vo.setPercentage(amount.multiply(BigDecimal.valueOf(100))
|
||||
.divide(totalAmount, 2, RoundingMode.HALF_UP));
|
||||
} else {
|
||||
vo.setPercentage(BigDecimal.ZERO);
|
||||
}
|
||||
return vo;
|
||||
})
|
||||
// 按金额降序排列
|
||||
.sorted((a, b) -> b.getAmount().compareTo(a.getAmount()))
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*
|
||||
* 统计指定月份每天的收入和支出,返回按日期排列的趋势数据列表。
|
||||
*/
|
||||
@Override
|
||||
public List<TrendStatisticsVO> getTrendStatistics(Long userId, StatisticsQuery query) {
|
||||
YearMonth yearMonth = YearMonth.of(query.getYear(), query.getMonth());
|
||||
int daysInMonth = yearMonth.lengthOfMonth();
|
||||
|
||||
// 初始化每日收入和支出为零
|
||||
Map<Integer, BigDecimal> incomeMap = new HashMap<>();
|
||||
Map<Integer, BigDecimal> expenseMap = new HashMap<>();
|
||||
for (int i = 1; i <= daysInMonth; i++) {
|
||||
incomeMap.put(i, BigDecimal.ZERO);
|
||||
expenseMap.put(i, BigDecimal.ZERO);
|
||||
}
|
||||
|
||||
// 累加每日交易金额
|
||||
List<Transaction> transactions = getMonthTransactions(userId, query);
|
||||
for (Transaction t : transactions) {
|
||||
int day = t.getTransactionTime().getDayOfMonth();
|
||||
if ("INCOME".equals(t.getType())) {
|
||||
incomeMap.merge(day, t.getAmount(), BigDecimal::add);
|
||||
} else {
|
||||
expenseMap.merge(day, t.getAmount(), BigDecimal::add);
|
||||
}
|
||||
}
|
||||
|
||||
// 组装每日趋势数据
|
||||
List<TrendStatisticsVO> result = new ArrayList<>();
|
||||
for (int i = 1; i <= daysInMonth; i++) {
|
||||
TrendStatisticsVO vo = new TrendStatisticsVO();
|
||||
vo.setDate(String.format("%d-%02d-%02d", query.getYear(), query.getMonth(), i));
|
||||
vo.setIncome(incomeMap.get(i));
|
||||
vo.setExpense(expenseMap.get(i));
|
||||
result.add(vo);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询指定月份内用户的所有交易记录(仅包含计入统计的记录)
|
||||
*
|
||||
* @param userId 用户ID
|
||||
* @param query 统计查询条件(年份、月份)
|
||||
* @return 该月份的交易记录列表
|
||||
*/
|
||||
private List<Transaction> getMonthTransactions(Long userId, StatisticsQuery query) {
|
||||
YearMonth yearMonth = YearMonth.of(query.getYear(), query.getMonth());
|
||||
// 计算月份的起止时间
|
||||
LocalDateTime startTime = yearMonth.atDay(1).atStartOfDay();
|
||||
LocalDateTime endTime = yearMonth.atEndOfMonth().atTime(LocalTime.MAX);
|
||||
|
||||
return transactionMapper.selectList(new LambdaQueryWrapper<Transaction>()
|
||||
.eq(Transaction::getUserId, userId)
|
||||
.ge(Transaction::getTransactionTime, startTime)
|
||||
.le(Transaction::getTransactionTime, endTime)
|
||||
// 仅统计计入统计的记录(排除借款、账户间转账等)
|
||||
.and(w -> w.eq(Transaction::getIncludeInStats, true)
|
||||
.or().isNull(Transaction::getIncludeInStats)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,318 @@
|
||||
package com.example.finance.service.impl;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import com.example.finance.common.BusinessException;
|
||||
import com.example.finance.common.PageResult;
|
||||
import com.example.finance.dto.TransactionQuery;
|
||||
import com.example.finance.dto.TransactionRequest;
|
||||
import com.example.finance.entity.Category;
|
||||
import com.example.finance.entity.Transaction;
|
||||
import com.example.finance.mapper.CategoryMapper;
|
||||
import com.example.finance.mapper.TransactionMapper;
|
||||
import com.example.finance.service.TransactionService;
|
||||
import com.example.finance.vo.TransactionExportVO;
|
||||
import com.example.finance.vo.TransactionVO;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* 交易记录服务实现类,处理收支记录的分页查询、创建、更新和删除等业务逻辑。
|
||||
*/
|
||||
@Service
|
||||
public class TransactionServiceImpl implements TransactionService {
|
||||
|
||||
private final TransactionMapper transactionMapper;
|
||||
private final CategoryMapper categoryMapper;
|
||||
|
||||
public TransactionServiceImpl(TransactionMapper transactionMapper, CategoryMapper categoryMapper) {
|
||||
this.transactionMapper = transactionMapper;
|
||||
this.categoryMapper = categoryMapper;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*
|
||||
* 支持按类型、分类、时间范围进行条件查询,结果按交易时间降序排列,
|
||||
* 同时批量关联分类信息以填充分类名称和图标。
|
||||
*/
|
||||
@Override
|
||||
public PageResult<TransactionVO> page(Long userId, TransactionQuery query) {
|
||||
LambdaQueryWrapper<Transaction> wrapper = new LambdaQueryWrapper<>();
|
||||
wrapper.eq(Transaction::getUserId, userId);
|
||||
|
||||
// 按交易类型筛选(多选)
|
||||
if (query.getTypes() != null && !query.getTypes().isEmpty()) {
|
||||
wrapper.in(Transaction::getType, query.getTypes());
|
||||
}
|
||||
// 按分类ID筛选(多选)
|
||||
if (query.getCategoryIds() != null && !query.getCategoryIds().isEmpty()) {
|
||||
wrapper.in(Transaction::getCategoryId, query.getCategoryIds());
|
||||
}
|
||||
// 按起始时间筛选
|
||||
if (StringUtils.hasText(query.getStartTime())) {
|
||||
wrapper.ge(Transaction::getTransactionTime,
|
||||
LocalDateTime.parse(query.getStartTime(), DateTimeFormatter.ISO_LOCAL_DATE_TIME));
|
||||
}
|
||||
// 按结束时间筛选
|
||||
if (StringUtils.hasText(query.getEndTime())) {
|
||||
wrapper.le(Transaction::getTransactionTime,
|
||||
LocalDateTime.parse(query.getEndTime(), DateTimeFormatter.ISO_LOCAL_DATE_TIME));
|
||||
}
|
||||
// 按支付方式筛选(多选)
|
||||
if (query.getPaymentMethods() != null && !query.getPaymentMethods().isEmpty()) {
|
||||
wrapper.in(Transaction::getPaymentMethod, query.getPaymentMethods());
|
||||
}
|
||||
// 按是否计入统计筛选
|
||||
if (query.getIncludeInStats() != null) {
|
||||
wrapper.eq(Transaction::getIncludeInStats, query.getIncludeInStats());
|
||||
}
|
||||
|
||||
// 按交易时间降序排列
|
||||
wrapper.orderByDesc(Transaction::getTransactionTime);
|
||||
|
||||
Page<Transaction> page = new Page<>(query.getPage(), query.getSize());
|
||||
Page<Transaction> result = transactionMapper.selectPage(page, wrapper);
|
||||
|
||||
// 批量查询关联的分类信息,避免 N+1 查询问题
|
||||
List<Long> categoryIds = result.getRecords().stream()
|
||||
.map(Transaction::getCategoryId)
|
||||
.distinct()
|
||||
.collect(Collectors.toList());
|
||||
|
||||
Map<Long, Category> categoryMap = Map.of();
|
||||
if (!categoryIds.isEmpty()) {
|
||||
List<Category> categories = categoryMapper.selectBatchIds(categoryIds);
|
||||
categoryMap = categories.stream().collect(Collectors.toMap(Category::getId, c -> c));
|
||||
}
|
||||
|
||||
// 将交易记录转换为视图对象,并填充分类名称和图标
|
||||
Map<Long, Category> finalCategoryMap = categoryMap;
|
||||
List<TransactionVO> voList = result.getRecords().stream()
|
||||
.map(t -> toVO(t, finalCategoryMap.get(t.getCategoryId())))
|
||||
.collect(Collectors.toList());
|
||||
|
||||
return new PageResult<>(voList, result.getTotal(), result.getCurrent(), result.getSize());
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*
|
||||
* 创建交易记录前需校验分类是否存在且归属当前用户。
|
||||
*/
|
||||
@Override
|
||||
@Transactional
|
||||
public void create(Long userId, TransactionRequest request) {
|
||||
// 校验分类是否存在且属于当前用户
|
||||
Category category = categoryMapper.selectById(request.getCategoryId());
|
||||
if (category == null || !category.getUserId().equals(userId)) {
|
||||
throw new BusinessException("分类不存在");
|
||||
}
|
||||
|
||||
Transaction transaction = new Transaction();
|
||||
transaction.setUserId(userId);
|
||||
transaction.setCategoryId(request.getCategoryId());
|
||||
transaction.setType(request.getType());
|
||||
transaction.setAmount(request.getAmount());
|
||||
transaction.setPaymentMethod(request.getPaymentMethod());
|
||||
transaction.setRemark(request.getRemark());
|
||||
transaction.setTransactionTime(request.getTransactionTime());
|
||||
transaction.setIncludeInStats(request.getIncludeInStats() == null ? Boolean.TRUE : request.getIncludeInStats());
|
||||
transactionMapper.insert(transaction);
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*
|
||||
* 更新交易记录前需校验记录归属和分类归属。
|
||||
*/
|
||||
@Override
|
||||
@Transactional
|
||||
public void update(Long userId, Long id, TransactionRequest request) {
|
||||
// 校验交易记录是否存在且属于当前用户
|
||||
Transaction transaction = transactionMapper.selectById(id);
|
||||
if (transaction == null || !transaction.getUserId().equals(userId)) {
|
||||
throw new BusinessException("记录不存在");
|
||||
}
|
||||
|
||||
// 校验分类是否存在且属于当前用户
|
||||
Category category = categoryMapper.selectById(request.getCategoryId());
|
||||
if (category == null || !category.getUserId().equals(userId)) {
|
||||
throw new BusinessException("分类不存在");
|
||||
}
|
||||
|
||||
transaction.setCategoryId(request.getCategoryId());
|
||||
transaction.setType(request.getType());
|
||||
transaction.setAmount(request.getAmount());
|
||||
transaction.setPaymentMethod(request.getPaymentMethod());
|
||||
transaction.setRemark(request.getRemark());
|
||||
transaction.setTransactionTime(request.getTransactionTime());
|
||||
transaction.setIncludeInStats(request.getIncludeInStats() == null ? Boolean.TRUE : request.getIncludeInStats());
|
||||
transactionMapper.updateById(transaction);
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*
|
||||
* 删除交易记录前需校验记录归属当前用户。
|
||||
*/
|
||||
@Override
|
||||
@Transactional
|
||||
public void delete(Long userId, Long id) {
|
||||
Transaction transaction = transactionMapper.selectById(id);
|
||||
if (transaction == null || !transaction.getUserId().equals(userId)) {
|
||||
throw new BusinessException("记录不存在");
|
||||
}
|
||||
transactionMapper.deleteById(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*
|
||||
* 切换单条交易记录的"计入统计"标记,用于表格中直接开关。
|
||||
*/
|
||||
@Override
|
||||
@Transactional
|
||||
public void toggleStats(Long userId, Long id, Boolean includeInStats) {
|
||||
Transaction transaction = transactionMapper.selectById(id);
|
||||
if (transaction == null || !transaction.getUserId().equals(userId)) {
|
||||
throw new BusinessException("记录不存在");
|
||||
}
|
||||
transaction.setIncludeInStats(includeInStats == null ? Boolean.TRUE : includeInStats);
|
||||
transactionMapper.updateById(transaction);
|
||||
}
|
||||
|
||||
/**
|
||||
* 将交易实体和关联分类转换为视图对象
|
||||
*
|
||||
* @param t 交易实体
|
||||
* @param category 关联的分类实体(可为 null)
|
||||
* @return 交易视图对象
|
||||
*/
|
||||
private TransactionVO toVO(Transaction t, Category category) {
|
||||
TransactionVO vo = new TransactionVO();
|
||||
vo.setId(t.getId());
|
||||
vo.setCategoryId(t.getCategoryId());
|
||||
vo.setCategoryName(category != null ? category.getName() : "");
|
||||
vo.setCategoryIcon(category != null ? category.getIcon() : "");
|
||||
vo.setType(t.getType());
|
||||
vo.setAmount(t.getAmount());
|
||||
vo.setPaymentMethod(t.getPaymentMethod());
|
||||
vo.setRemark(t.getRemark());
|
||||
vo.setTransactionTime(t.getTransactionTime());
|
||||
vo.setIncludeInStats(t.getIncludeInStats() == null ? Boolean.TRUE : t.getIncludeInStats());
|
||||
vo.setCreatedAt(t.getCreatedAt());
|
||||
return vo;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*
|
||||
* 根据查询条件导出交易记录,不分页,返回所有匹配的记录。
|
||||
*/
|
||||
@Override
|
||||
public List<TransactionExportVO> export(Long userId, TransactionQuery query) {
|
||||
LambdaQueryWrapper<Transaction> wrapper = new LambdaQueryWrapper<>();
|
||||
wrapper.eq(Transaction::getUserId, userId);
|
||||
|
||||
// 按交易类型筛选(多选)
|
||||
if (query.getTypes() != null && !query.getTypes().isEmpty()) {
|
||||
wrapper.in(Transaction::getType, query.getTypes());
|
||||
}
|
||||
// 按分类ID筛选(多选)
|
||||
if (query.getCategoryIds() != null && !query.getCategoryIds().isEmpty()) {
|
||||
wrapper.in(Transaction::getCategoryId, query.getCategoryIds());
|
||||
}
|
||||
// 按起始时间筛选
|
||||
if (StringUtils.hasText(query.getStartTime())) {
|
||||
wrapper.ge(Transaction::getTransactionTime,
|
||||
LocalDateTime.parse(query.getStartTime(), DateTimeFormatter.ISO_LOCAL_DATE_TIME));
|
||||
}
|
||||
// 按结束时间筛选
|
||||
if (StringUtils.hasText(query.getEndTime())) {
|
||||
wrapper.le(Transaction::getTransactionTime,
|
||||
LocalDateTime.parse(query.getEndTime(), DateTimeFormatter.ISO_LOCAL_DATE_TIME));
|
||||
}
|
||||
// 按支付方式筛选(多选)
|
||||
if (query.getPaymentMethods() != null && !query.getPaymentMethods().isEmpty()) {
|
||||
wrapper.in(Transaction::getPaymentMethod, query.getPaymentMethods());
|
||||
}
|
||||
// 按是否计入统计筛选
|
||||
if (query.getIncludeInStats() != null) {
|
||||
wrapper.eq(Transaction::getIncludeInStats, query.getIncludeInStats());
|
||||
}
|
||||
|
||||
// 按交易时间降序排列
|
||||
wrapper.orderByDesc(Transaction::getTransactionTime);
|
||||
|
||||
List<Transaction> transactions = transactionMapper.selectList(wrapper);
|
||||
|
||||
// 批量查询关联的分类信息
|
||||
List<Long> categoryIds = transactions.stream()
|
||||
.map(Transaction::getCategoryId)
|
||||
.distinct()
|
||||
.collect(Collectors.toList());
|
||||
|
||||
Map<Long, Category> categoryMap = Map.of();
|
||||
if (!categoryIds.isEmpty()) {
|
||||
List<Category> categories = categoryMapper.selectBatchIds(categoryIds);
|
||||
categoryMap = categories.stream().collect(Collectors.toMap(Category::getId, c -> c));
|
||||
}
|
||||
|
||||
// 转换为导出视图对象
|
||||
Map<Long, Category> finalCategoryMap = categoryMap;
|
||||
return transactions.stream()
|
||||
.map(t -> toExportVO(t, finalCategoryMap.get(t.getCategoryId())))
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
/**
|
||||
* 将交易实体和关联分类转换为导出视图对象
|
||||
*
|
||||
* @param t 交易实体
|
||||
* @param category 关联的分类实体(可为 null)
|
||||
* @return 交易导出视图对象
|
||||
*/
|
||||
private TransactionExportVO toExportVO(Transaction t, Category category) {
|
||||
TransactionExportVO vo = new TransactionExportVO();
|
||||
Boolean include = t.getIncludeInStats() == null ? Boolean.TRUE : t.getIncludeInStats();
|
||||
if (include) {
|
||||
vo.setType(t.getType().equals("INCOME") ? "收入" : "支出");
|
||||
} else {
|
||||
vo.setType("不计入统计");
|
||||
}
|
||||
vo.setCategoryName(category != null ? category.getName() : "");
|
||||
vo.setAmount(String.format("%.2f", t.getAmount()));
|
||||
vo.setPaymentMethod(getPaymentMethodLabel(t.getPaymentMethod()));
|
||||
vo.setRemark(t.getRemark() != null ? t.getRemark() : "");
|
||||
vo.setTransactionTime(t.getTransactionTime() != null
|
||||
? t.getTransactionTime().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"))
|
||||
: "");
|
||||
vo.setIncludeInStats(include ? "是" : "否");
|
||||
return vo;
|
||||
}
|
||||
|
||||
/**
|
||||
* 将支付方式编码转换为中文标签
|
||||
*
|
||||
* @param code 支付方式编码
|
||||
* @return 中文标签
|
||||
*/
|
||||
private String getPaymentMethodLabel(String code) {
|
||||
if (code == null) return "";
|
||||
return switch (code) {
|
||||
case "WECHAT" -> "微信";
|
||||
case "ALIPAY" -> "支付宝";
|
||||
case "CASH" -> "现金";
|
||||
case "BANK" -> "银行卡";
|
||||
default -> code;
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
package com.example.finance.service.impl;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.example.finance.common.BusinessException;
|
||||
import com.example.finance.dto.LoginRequest;
|
||||
import com.example.finance.dto.RegisterRequest;
|
||||
import com.example.finance.entity.User;
|
||||
import com.example.finance.mapper.UserMapper;
|
||||
import com.example.finance.service.UserService;
|
||||
import com.example.finance.util.JwtUtil;
|
||||
import com.example.finance.vo.LoginVO;
|
||||
import com.example.finance.vo.UserVO;
|
||||
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
/**
|
||||
* 用户服务实现类,处理用户登录认证、注册和信息查询等业务逻辑。
|
||||
*/
|
||||
@Service
|
||||
public class UserServiceImpl implements UserService {
|
||||
|
||||
private final UserMapper userMapper;
|
||||
private final JwtUtil jwtUtil;
|
||||
private final BCryptPasswordEncoder passwordEncoder = new BCryptPasswordEncoder();
|
||||
|
||||
public UserServiceImpl(UserMapper userMapper, JwtUtil jwtUtil) {
|
||||
this.userMapper = userMapper;
|
||||
this.jwtUtil = jwtUtil;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*
|
||||
* 根据用户名查询用户,校验密码后生成 JWT Token 返回。
|
||||
*/
|
||||
@Override
|
||||
public LoginVO login(LoginRequest request) {
|
||||
// 根据用户名查询用户
|
||||
User user = userMapper.selectOne(new LambdaQueryWrapper<User>()
|
||||
.eq(User::getUsername, request.getUsername()));
|
||||
|
||||
if (user == null) {
|
||||
throw new BusinessException("用户名或密码错误");
|
||||
}
|
||||
|
||||
// 校验密码是否匹配
|
||||
if (!passwordEncoder.matches(request.getPassword(), user.getPasswordHash())) {
|
||||
throw new BusinessException("用户名或密码错误");
|
||||
}
|
||||
|
||||
// 生成 JWT Token
|
||||
String token = jwtUtil.generateToken(user.getId(), user.getUsername());
|
||||
|
||||
LoginVO vo = new LoginVO();
|
||||
vo.setToken(token);
|
||||
vo.setUser(toUserVO(user));
|
||||
return vo;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*
|
||||
* 校验用户名是否已存在,不存在则创建新用户并对密码进行 BCrypt 加密。
|
||||
*/
|
||||
@Override
|
||||
@Transactional
|
||||
public void register(RegisterRequest request) {
|
||||
// 检查用户名是否已被注册
|
||||
User existing = userMapper.selectOne(new LambdaQueryWrapper<User>()
|
||||
.eq(User::getUsername, request.getUsername()));
|
||||
|
||||
if (existing != null) {
|
||||
throw new BusinessException("用户名已存在");
|
||||
}
|
||||
|
||||
// 构建新用户并加密密码
|
||||
User user = new User();
|
||||
user.setUsername(request.getUsername());
|
||||
user.setPasswordHash(passwordEncoder.encode(request.getPassword()));
|
||||
// 如果未提供昵称,则使用用户名作为默认昵称
|
||||
user.setNickname(request.getNickname() != null ? request.getNickname() : request.getUsername());
|
||||
userMapper.insert(user);
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*
|
||||
* 根据用户ID查询用户信息,不存在则抛出异常。
|
||||
*/
|
||||
@Override
|
||||
public UserVO getUserInfo(Long userId) {
|
||||
User user = userMapper.selectById(userId);
|
||||
if (user == null) {
|
||||
throw new BusinessException("用户不存在");
|
||||
}
|
||||
return toUserVO(user);
|
||||
}
|
||||
|
||||
/**
|
||||
* 将用户实体转换为视图对象
|
||||
*
|
||||
* @param user 用户实体
|
||||
* @return 用户视图对象
|
||||
*/
|
||||
private UserVO toUserVO(User user) {
|
||||
UserVO vo = new UserVO();
|
||||
vo.setId(user.getId());
|
||||
vo.setUsername(user.getUsername());
|
||||
vo.setNickname(user.getNickname());
|
||||
vo.setCreatedAt(user.getCreatedAt());
|
||||
return vo;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
package com.example.finance.util;
|
||||
|
||||
import com.example.finance.config.JwtConfig;
|
||||
import io.jsonwebtoken.Claims;
|
||||
import io.jsonwebtoken.Jwts;
|
||||
import io.jsonwebtoken.security.Keys;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import javax.crypto.SecretKey;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Date;
|
||||
|
||||
/**
|
||||
* JWT 工具类。
|
||||
* <p>提供 JWT Token 的生成、解析、验证等功能。</p>
|
||||
*/
|
||||
@Component
|
||||
public class JwtUtil {
|
||||
|
||||
/** HMAC 签名密钥 */
|
||||
private final SecretKey key;
|
||||
/** Token 过期时间(毫秒) */
|
||||
private final long expiration;
|
||||
|
||||
/**
|
||||
* 构造方法,根据配置初始化签名密钥和过期时间。
|
||||
*
|
||||
* @param jwtConfig JWT 配置对象
|
||||
*/
|
||||
public JwtUtil(JwtConfig jwtConfig) {
|
||||
this.key = Keys.hmacShaKeyFor(jwtConfig.getSecret().getBytes(StandardCharsets.UTF_8));
|
||||
this.expiration = jwtConfig.getExpiration();
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成 JWT Token。
|
||||
* <p>将用户 ID 作为 subject,用户名作为自定义 claim,并设置签发时间和过期时间。</p>
|
||||
*
|
||||
* @param userId 用户 ID
|
||||
* @param username 用户名
|
||||
* @return 生成的 JWT Token 字符串
|
||||
*/
|
||||
public String generateToken(Long userId, String username) {
|
||||
Date now = new Date();
|
||||
Date expiryDate = new Date(now.getTime() + expiration);
|
||||
|
||||
return Jwts.builder()
|
||||
.subject(String.valueOf(userId))
|
||||
.claim("username", username)
|
||||
.issuedAt(now)
|
||||
.expiration(expiryDate)
|
||||
.signWith(key)
|
||||
.compact();
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析 JWT Token,提取声明信息。
|
||||
*
|
||||
* @param token JWT Token 字符串
|
||||
* @return Token 中的声明(Claims)对象
|
||||
*/
|
||||
public Claims parseToken(String token) {
|
||||
return Jwts.parser()
|
||||
.verifyWith(key)
|
||||
.build()
|
||||
.parseSignedClaims(token)
|
||||
.getPayload();
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 Token 中提取用户 ID。
|
||||
*
|
||||
* @param token JWT Token 字符串
|
||||
* @return 用户 ID
|
||||
*/
|
||||
public Long getUserId(String token) {
|
||||
Claims claims = parseToken(token);
|
||||
return Long.parseLong(claims.getSubject());
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证 Token 是否有效。
|
||||
*
|
||||
* @param token JWT Token 字符串
|
||||
* @return Token 有效返回 true,否则返回 false
|
||||
*/
|
||||
public boolean validateToken(String token) {
|
||||
try {
|
||||
parseToken(token);
|
||||
return true;
|
||||
} catch (Exception e) {
|
||||
// Token 解析失败(过期、签名无效等)
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package com.example.finance.vo;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
|
||||
/**
|
||||
* 分类统计视图对象,用于展示各分类的收支占比情况
|
||||
*/
|
||||
@Data
|
||||
public class CategoryStatisticsVO {
|
||||
|
||||
/** 分类ID */
|
||||
private Long categoryId;
|
||||
|
||||
/** 分类名称 */
|
||||
private String categoryName;
|
||||
|
||||
/** 分类图标 */
|
||||
private String categoryIcon;
|
||||
|
||||
/** 交易类型(收入/支出) */
|
||||
private String type;
|
||||
|
||||
/** 该分类下的总金额 */
|
||||
private BigDecimal amount;
|
||||
|
||||
/** 该分类金额占总金额的百分比 */
|
||||
private BigDecimal percentage;
|
||||
|
||||
/** 该分类下的交易笔数 */
|
||||
private Integer count;
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package com.example.finance.vo;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* 分类视图对象,用于向前端返回分类信息
|
||||
*/
|
||||
@Data
|
||||
public class CategoryVO {
|
||||
|
||||
/** 分类ID */
|
||||
private Long id;
|
||||
|
||||
/** 分类名称 */
|
||||
private String name;
|
||||
|
||||
/** 分类类型(收入/支出) */
|
||||
private String type;
|
||||
|
||||
/** 分类图标 */
|
||||
private String icon;
|
||||
|
||||
/** 排序序号 */
|
||||
private Integer sortOrder;
|
||||
|
||||
/** 创建时间 */
|
||||
private LocalDateTime createdAt;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package com.example.finance.vo;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* 登录响应视图对象,包含认证令牌和用户基本信息
|
||||
*/
|
||||
@Data
|
||||
public class LoginVO {
|
||||
|
||||
/** JWT认证令牌 */
|
||||
private String token;
|
||||
|
||||
/** 用户信息 */
|
||||
private UserVO user;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package com.example.finance.vo;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
|
||||
/**
|
||||
* 月度统计视图对象,用于展示某月的收支汇总信息
|
||||
*/
|
||||
@Data
|
||||
public class MonthlyStatisticsVO {
|
||||
|
||||
/** 当月总收入 */
|
||||
private BigDecimal totalIncome;
|
||||
|
||||
/** 当月总支出 */
|
||||
private BigDecimal totalExpense;
|
||||
|
||||
/** 当月结余(收入减支出) */
|
||||
private BigDecimal balance;
|
||||
|
||||
/** 当月交易笔数 */
|
||||
private Integer transactionCount;
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package com.example.finance.vo;
|
||||
|
||||
import com.alibaba.excel.annotation.ExcelProperty;
|
||||
import com.alibaba.excel.annotation.write.style.ColumnWidth;
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* 交易记录导出视图对象,用于 Excel 导出
|
||||
*/
|
||||
@Data
|
||||
public class TransactionExportVO {
|
||||
|
||||
@ExcelProperty("交易类型")
|
||||
@ColumnWidth(12)
|
||||
private String type;
|
||||
|
||||
@ExcelProperty("分类名称")
|
||||
@ColumnWidth(15)
|
||||
private String categoryName;
|
||||
|
||||
@ExcelProperty("金额")
|
||||
@ColumnWidth(12)
|
||||
private String amount;
|
||||
|
||||
@ExcelProperty("支付方式")
|
||||
@ColumnWidth(12)
|
||||
private String paymentMethod;
|
||||
|
||||
@ExcelProperty("备注")
|
||||
@ColumnWidth(30)
|
||||
private String remark;
|
||||
|
||||
@ExcelProperty("交易时间")
|
||||
@ColumnWidth(20)
|
||||
private String transactionTime;
|
||||
|
||||
@ExcelProperty("计入统计")
|
||||
@ColumnWidth(10)
|
||||
private String includeInStats;
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package com.example.finance.vo;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* 交易记录视图对象,用于向前端返回交易详情(包含关联的分类信息)
|
||||
*/
|
||||
@Data
|
||||
public class TransactionVO {
|
||||
|
||||
/** 交易ID */
|
||||
private Long id;
|
||||
|
||||
/** 分类ID */
|
||||
private Long categoryId;
|
||||
|
||||
/** 分类名称 */
|
||||
private String categoryName;
|
||||
|
||||
/** 分类图标 */
|
||||
private String categoryIcon;
|
||||
|
||||
/** 交易类型(收入/支出) */
|
||||
private String type;
|
||||
|
||||
/** 交易金额 */
|
||||
private BigDecimal amount;
|
||||
|
||||
/** 支付方式 */
|
||||
private String paymentMethod;
|
||||
|
||||
/** 备注 */
|
||||
private String remark;
|
||||
|
||||
/** 交易时间 */
|
||||
private LocalDateTime transactionTime;
|
||||
|
||||
/** 是否计入统计 */
|
||||
private Boolean includeInStats;
|
||||
|
||||
/** 创建时间 */
|
||||
private LocalDateTime createdAt;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package com.example.finance.vo;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
|
||||
/**
|
||||
* 趋势统计视图对象,用于展示按日/月的收支趋势变化
|
||||
*/
|
||||
@Data
|
||||
public class TrendStatisticsVO {
|
||||
|
||||
/** 日期(如具体某天或某月) */
|
||||
private String date;
|
||||
|
||||
/** 当日/当月收入 */
|
||||
private BigDecimal income;
|
||||
|
||||
/** 当日/当月支出 */
|
||||
private BigDecimal expense;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package com.example.finance.vo;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* 用户视图对象,用于向前端返回用户基本信息(不包含敏感数据)
|
||||
*/
|
||||
@Data
|
||||
public class UserVO {
|
||||
|
||||
/** 用户ID */
|
||||
private Long id;
|
||||
|
||||
/** 用户名 */
|
||||
private String username;
|
||||
|
||||
/** 昵称 */
|
||||
private String nickname;
|
||||
|
||||
/** 创建时间 */
|
||||
private LocalDateTime createdAt;
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
spring:
|
||||
application:
|
||||
name: my-app
|
||||
datasource:
|
||||
url: ${DB_URL}
|
||||
username: ${DB_USERNAME}
|
||||
password: ${DB_PASSWORD}
|
||||
driver-class-name: com.mysql.cj.jdbc.Driver
|
||||
|
||||
mybatis-plus:
|
||||
mapper-locations: classpath:mapper/*.xml
|
||||
type-aliases-package: com.example.finance.entity
|
||||
global-config:
|
||||
db-config:
|
||||
id-type: auto
|
||||
configuration:
|
||||
map-underscore-to-camel-case: true
|
||||
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
|
||||
|
||||
jwt:
|
||||
secret: finance-app-secret-key-2026-a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6
|
||||
expiration: 86400000
|
||||
|
||||
springdoc:
|
||||
api-docs:
|
||||
enabled: true
|
||||
path: /v3/api-docs
|
||||
swagger-ui:
|
||||
enabled: true
|
||||
path: /swagger-ui.html
|
||||
packages-to-scan: com.example.finance.controller
|
||||
|
||||
knife4j:
|
||||
enable: true
|
||||
setting:
|
||||
language: zh_cn
|
||||
|
||||
server:
|
||||
port: 8080
|
||||
@@ -0,0 +1,34 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<configuration>
|
||||
<!-- 控制台输出 -->
|
||||
<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
|
||||
<encoder>
|
||||
<pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern>
|
||||
<charset>UTF-8</charset>
|
||||
</encoder>
|
||||
</appender>
|
||||
|
||||
<!-- 文件输出 - 按天滚动 -->
|
||||
<appender name="FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
|
||||
<file>logs/finance-app.log</file>
|
||||
<rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
|
||||
<fileNamePattern>logs/finance-app.%d{yyyy-MM-dd}.log</fileNamePattern>
|
||||
<maxHistory>30</maxHistory>
|
||||
</rollingPolicy>
|
||||
<encoder>
|
||||
<pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern>
|
||||
<charset>UTF-8</charset>
|
||||
</encoder>
|
||||
</appender>
|
||||
|
||||
<!-- 应用日志级别 -->
|
||||
<logger name="com.example.finance" level="INFO"/>
|
||||
<logger name="org.springframework" level="INFO"/>
|
||||
<logger name="org.mybatis" level="INFO"/>
|
||||
|
||||
<!-- 根日志级别 -->
|
||||
<root level="INFO">
|
||||
<appender-ref ref="CONSOLE"/>
|
||||
<appender-ref ref="FILE"/>
|
||||
</root>
|
||||
</configuration>
|
||||
@@ -0,0 +1,41 @@
|
||||
-- 用户表:存储系统用户的基本信息和登录凭证
|
||||
CREATE TABLE IF NOT EXISTS t_user (
|
||||
id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '用户ID,主键自增',
|
||||
username VARCHAR(50) NOT NULL UNIQUE COMMENT '用户名,唯一标识,用于登录',
|
||||
password_hash VARCHAR(255) NOT NULL COMMENT '密码哈希值,使用BCrypt加密存储',
|
||||
nickname VARCHAR(50) COMMENT '用户昵称,显示名称',
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间'
|
||||
);
|
||||
|
||||
-- 分类表:存储收支分类信息,支持用户自定义
|
||||
CREATE TABLE IF NOT EXISTS t_category (
|
||||
id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '分类ID,主键自增',
|
||||
user_id BIGINT NOT NULL COMMENT '所属用户ID',
|
||||
name VARCHAR(50) NOT NULL COMMENT '分类名称',
|
||||
type VARCHAR(10) NOT NULL COMMENT '分类类型:INCOME-收入,EXPENSE-支出',
|
||||
icon VARCHAR(50) COMMENT '分类图标,可选',
|
||||
sort_order INT NOT NULL DEFAULT 0 COMMENT '排序序号,数值越小越靠前',
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
INDEX idx_user_id (user_id) COMMENT '用户ID索引',
|
||||
INDEX idx_user_type (user_id, type) COMMENT '用户ID+类型联合索引'
|
||||
);
|
||||
|
||||
-- 收支记录表:存储每一笔收入或支出的详细信息
|
||||
CREATE TABLE IF NOT EXISTS t_transaction (
|
||||
id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '记录ID,主键自增',
|
||||
user_id BIGINT NOT NULL COMMENT '所属用户ID',
|
||||
category_id BIGINT NOT NULL COMMENT '所属分类ID',
|
||||
type VARCHAR(10) NOT NULL COMMENT '记录类型:INCOME-收入,EXPENSE-支出',
|
||||
amount DECIMAL(12, 2) NOT NULL COMMENT '金额,精确到分',
|
||||
payment_method VARCHAR(30) COMMENT '支付方式:WECHAT-微信,ALIPAY-支付宝,CASH-现金,BANK-银行卡',
|
||||
remark VARCHAR(255) COMMENT '备注说明',
|
||||
transaction_time DATETIME NOT NULL COMMENT '交易发生时间',
|
||||
include_in_stats TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否计入统计:1-计入,0-不计入(如借款、转账等)',
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||
INDEX idx_user_id (user_id) COMMENT '用户ID索引',
|
||||
INDEX idx_category_id (category_id) COMMENT '分类ID索引',
|
||||
INDEX idx_transaction_time (transaction_time) COMMENT '交易时间索引',
|
||||
INDEX idx_user_time (user_id, transaction_time) COMMENT '用户ID+交易时间联合索引'
|
||||
);
|
||||
@@ -0,0 +1,15 @@
|
||||
package com.example.finance;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
|
||||
/**
|
||||
* 应用启动测试
|
||||
*/
|
||||
@SpringBootTest
|
||||
class MyApplicationTests {
|
||||
|
||||
@Test
|
||||
void contextLoads() {
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user