commit 733ed4bb869e38c3741cd04d3ad676049c1c2215 Author: wangshuo Date: Sun Aug 30 14:30:09 2026 +0800 feat: 初始化收支管理系统 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4dd2f4e --- /dev/null +++ b/.gitignore @@ -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 diff --git a/README.md b/README.md new file mode 100644 index 0000000..6c77e64 --- /dev/null +++ b/README.md @@ -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/ # 页面组件 +``` diff --git a/build.gradle b/build.gradle new file mode 100644 index 0000000..1b86ba5 --- /dev/null +++ b/build.gradle @@ -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/**' + } +} diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 0000000..a547bf3 --- /dev/null +++ b/frontend/.gitignore @@ -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? diff --git a/frontend/README.md b/frontend/README.md new file mode 100644 index 0000000..33895ab --- /dev/null +++ b/frontend/README.md @@ -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 ` + + diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..9b04134 --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,1432 @@ +{ + "name": "frontend", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "frontend", + "version": "0.0.0", + "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" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmmirror.com/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmmirror.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.8", + "resolved": "https://registry.npmmirror.com/@babel/parser/-/parser-7.29.8.tgz", + "integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.8" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.8", + "resolved": "https://registry.npmmirror.com/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@css-render/plugin-bem": { + "version": "0.15.14", + "resolved": "https://registry.npmmirror.com/@css-render/plugin-bem/-/plugin-bem-0.15.14.tgz", + "integrity": "sha512-QK513CJ7yEQxm/P3EwsI+d+ha8kSOcjGvD6SevM41neEMxdULE+18iuQK6tEChAWMOQNQPLG/Rw3Khb69r5neg==", + "license": "MIT", + "peerDependencies": { + "css-render": "~0.15.14" + } + }, + "node_modules/@css-render/vue3-ssr": { + "version": "0.15.14", + "resolved": "https://registry.npmmirror.com/@css-render/vue3-ssr/-/vue3-ssr-0.15.14.tgz", + "integrity": "sha512-//8027GSbxE9n3QlD73xFY6z4ZbHbvrOVB7AO6hsmrEzGbg+h2A09HboUyDgu+xsmj7JnvJD39Irt+2D0+iV8g==", + "license": "MIT", + "peerDependencies": { + "vue": "^3.0.11" + } + }, + "node_modules/@emotion/hash": { + "version": "0.8.0", + "resolved": "https://registry.npmmirror.com/@emotion/hash/-/hash-0.8.0.tgz", + "integrity": "sha512-kBJtf7PH6aWwZ6fka3zQ0p6SBYzx4fl1LoZXE2RrnYST9Xljm7WfKJrU4g/Xr3Beg72MLrp1AWNUmuYJTL7Cow==", + "license": "MIT" + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmmirror.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "license": "MIT" + }, + "node_modules/@juggle/resize-observer": { + "version": "3.4.0", + "resolved": "https://registry.npmmirror.com/@juggle/resize-observer/-/resize-observer-3.4.0.tgz", + "integrity": "sha512-dfLbk+PwWvFzSxwk3n5ySL0hfBog779o8h68wK/7/APo/7cgyWp5jcXockbxdk5kFRkbeXWm4Fbi9FrdN381sA==", + "license": "Apache-2.0" + }, + "node_modules/@oxc-project/types": { + "version": "0.147.0", + "resolved": "https://registry.npmmirror.com/@oxc-project/types/-/types-0.147.0.tgz", + "integrity": "sha512-IJ3s6ltHLp45S0bh7phkX+gJO7A1Wuz2EaqpAhb8WjqDwbzMiWKHhyyT42tskaWjEYXtHtVCPpnBJVT9+dcRLg==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@rolldown/binding-android-arm-eabi": { + "version": "1.2.6", + "resolved": "https://registry.npmmirror.com/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.6.tgz", + "integrity": "sha512-b+jTcARdTiFLI6jB4a5XjTm0RWd6KcRfQj/I2356fxUZemiho9zQLxo0RtCuMDAyKcLo6cEltkgbQp6d1+sjjQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.2.6", + "resolved": "https://registry.npmmirror.com/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.6.tgz", + "integrity": "sha512-lkWU8ZJaRk9q3CIEY1Tc7vIFALp3Xw5NfGJo2hQg5oIqNgxWi1zI+IiDEK3r70BF5Dzol1tcXsnzsRc8NLhG+Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.2.6", + "resolved": "https://registry.npmmirror.com/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.6.tgz", + "integrity": "sha512-dgR56NYnvAszm7Ob1B2/Vn0e8bUQYZH2UjVaMMtMVOCKFSfjhfLmuA/9+O+F+ajUdG6B/bSssrKW6JJYASa8jA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.2.6", + "resolved": "https://registry.npmmirror.com/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.6.tgz", + "integrity": "sha512-vpVxFvUCFioJqug7OTvqptkc4yb8UX0AwfDmJpaR/0sWz+BUmqSVAf7c8JkUgnN8YLspb4a/N6NhTyMAmdyQ7Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.2.6", + "resolved": "https://registry.npmmirror.com/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.6.tgz", + "integrity": "sha512-h1wG6Y6K3JlRswxsI64qQJqBAy4vrLuHgRbc8CZMGSWTOFRY6ghMApM1NKzB2I0n5xV1fjkE18SuVl2QpLeNpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.2.6", + "resolved": "https://registry.npmmirror.com/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.6.tgz", + "integrity": "sha512-tbCiqub0q2MVWJKgF5PoAlNWCtQydiOYSLIkd8sByqK/6MMYLJRcSXSYodqYtd0O+Fw7QaVmKKlS4oL94YRZ0w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.2.6", + "resolved": "https://registry.npmmirror.com/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.6.tgz", + "integrity": "sha512-oxK9+baEBPhZG5HB4URY+uU04zJWeZlH6Tb9rB5DK4DF9XR1uXNLXt5Q5ZsugTKayNCNLhkcwz/ye74hRI98dg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.2.6", + "resolved": "https://registry.npmmirror.com/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.6.tgz", + "integrity": "sha512-muWCk27FVBEZtv0MsK8gnfSmgczA8KQ0uRVJbTABKhkRfQc38aUrcb7fhi3BNiyseFmgcRsoMfQsSNJ+DbZdSw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.2.6", + "resolved": "https://registry.npmmirror.com/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.6.tgz", + "integrity": "sha512-eWDoSfU7Co2qj3vgB3Dt4lj1mG6CoWbcJQkRMP3XJplyCMtuaq3LHvPFjS9QIPvMGWVadJC04Xiy0IdcVPtnwQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.2.6", + "resolved": "https://registry.npmmirror.com/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.6.tgz", + "integrity": "sha512-2bWNjRSIayvupRKxXUY2tWG9fYdoUlTqWywHRvE8Eq3GvuQ+f2HeIkve697fIt+IQs/PV8yFsdWuhp1aJ1PdnA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.2.6", + "resolved": "https://registry.npmmirror.com/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.6.tgz", + "integrity": "sha512-KekI0gS0wLxe1UBSQSjenBVwou/JkcQPDzBPICGZjxUv9k3RteHDPBQaiOicZUFKRIH2wKEimGwVpnJsbPzu7w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.2.6", + "resolved": "https://registry.npmmirror.com/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.6.tgz", + "integrity": "sha512-TvtPnfVr+HtyGiDmPK4VWmlNm7QhNNAcK5Q9A7aOXsI8545yCyaoMaicXrFZ72JzeYjaUVk7yT243zT0jzjFKQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.2.6", + "resolved": "https://registry.npmmirror.com/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.6.tgz", + "integrity": "sha512-iOo0VEay2XFhaCcH0sps5XIimkSuOnNaZrf6+ZkoSOQBJPKNU48RkmJv0/lSpipexu5P+ouFgafe5IGr/DiQfg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.2.6", + "resolved": "https://registry.npmmirror.com/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.6.tgz", + "integrity": "sha512-y5NTmmasMS455JlOCO4ZM9krIchv3Mvm1crL1iUPGOPgEzSkves9n0SdC5Sjz6+qWDFhd8/JpfWMH8NSWNHe+A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.2.6", + "resolved": "https://registry.npmmirror.com/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.6.tgz", + "integrity": "sha512-np8iZSLfXlAD4kWhiyq/u0Yt8oZDtRQ8lGhQaCXo2rl37KNjeU0GjJuwr4P3oeZ++ROfofsKNBqR5LTO8aXyWQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/lodash": { + "version": "4.17.25", + "resolved": "https://registry.npmmirror.com/@types/lodash/-/lodash-4.17.25.tgz", + "integrity": "sha512-+K1NIO8I+F9/wNulfVvu23QYd0Pe9/OCqRrim4NoYIf1VoEDL90Ve4ClzpyqBLc7NpGGWRvYNCKZ1BE/Jpf8dQ==", + "license": "MIT" + }, + "node_modules/@types/lodash-es": { + "version": "4.17.12", + "resolved": "https://registry.npmmirror.com/@types/lodash-es/-/lodash-es-4.17.12.tgz", + "integrity": "sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==", + "license": "MIT", + "dependencies": { + "@types/lodash": "*" + } + }, + "node_modules/@types/node": { + "version": "24.13.3", + "resolved": "https://registry.npmmirror.com/@types/node/-/node-24.13.3.tgz", + "integrity": "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.18.0" + } + }, + "node_modules/@vicons/ionicons5": { + "version": "0.13.0", + "license": "MIT" + }, + "node_modules/@vitejs/plugin-vue": { + "version": "6.0.8", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0", + "vue": "^3.2.25" + } + }, + "node_modules/@volar/language-core": { + "version": "2.4.28", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/source-map": "2.4.28" + } + }, + "node_modules/@volar/source-map": { + "version": "2.4.28", + "dev": true, + "license": "MIT" + }, + "node_modules/@volar/typescript": { + "version": "2.4.28", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/language-core": "2.4.28", + "path-browserify": "^1.0.1", + "vscode-uri": "^3.0.8" + } + }, + "node_modules/@vue/compiler-core": { + "version": "3.5.41", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.8", + "@vue/shared": "3.5.41", + "entities": "^7.0.1", + "estree-walker": "^2.0.2", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-dom": { + "version": "3.5.41", + "license": "MIT", + "dependencies": { + "@vue/compiler-core": "3.5.41", + "@vue/shared": "3.5.41" + } + }, + "node_modules/@vue/compiler-sfc": { + "version": "3.5.41", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.8", + "@vue/compiler-core": "3.5.41", + "@vue/compiler-dom": "3.5.41", + "@vue/compiler-ssr": "3.5.41", + "@vue/shared": "3.5.41", + "estree-walker": "^2.0.2", + "magic-string": "^0.30.21", + "postcss": "^8.5.19", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-ssr": { + "version": "3.5.41", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.41", + "@vue/shared": "3.5.41" + } + }, + "node_modules/@vue/devtools-api": { + "version": "8.2.1", + "license": "MIT", + "peer": true, + "dependencies": { + "@vue/devtools-kit": "^8.2.1" + } + }, + "node_modules/@vue/devtools-kit": { + "version": "8.2.1", + "license": "MIT", + "peer": true, + "dependencies": { + "@vue/devtools-shared": "^8.2.1", + "birpc": "^2.6.1", + "hookable": "^5.5.3", + "perfect-debounce": "^2.0.0" + } + }, + "node_modules/@vue/devtools-shared": { + "version": "8.2.1", + "license": "MIT", + "peer": true + }, + "node_modules/@vue/language-core": { + "version": "3.3.11", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/language-core": "2.4.28", + "@vue/compiler-dom": "^3.5.0", + "@vue/shared": "^3.5.0", + "alien-signals": "^3.2.1", + "muggle-string": "^0.4.1", + "path-browserify": "^1.0.1", + "picomatch": "^4.0.4" + } + }, + "node_modules/@vue/reactivity": { + "version": "3.5.41", + "license": "MIT", + "dependencies": { + "@vue/shared": "3.5.41" + } + }, + "node_modules/@vue/runtime-core": { + "version": "3.5.41", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.41", + "@vue/shared": "3.5.41" + } + }, + "node_modules/@vue/runtime-dom": { + "version": "3.5.41", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.41", + "@vue/runtime-core": "3.5.41", + "@vue/shared": "3.5.41", + "csstype": "^3.2.3" + } + }, + "node_modules/@vue/server-renderer": { + "version": "3.5.41", + "license": "MIT", + "dependencies": { + "@vue/compiler-ssr": "3.5.41", + "@vue/runtime-dom": "3.5.41", + "@vue/shared": "3.5.41" + } + }, + "node_modules/@vue/shared": { + "version": "3.5.41", + "license": "MIT" + }, + "node_modules/@vue/tsconfig": { + "version": "0.9.1", + "dev": true, + "license": "MIT", + "peerDependencies": { + "typescript": ">= 5.8", + "vue": "^3.4.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + }, + "vue": { + "optional": true + } + } + }, + "node_modules/agent-base": { + "version": "6.0.2", + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/alien-signals": { + "version": "3.2.1", + "dev": true, + "license": "MIT" + }, + "node_modules/async-validator": { + "version": "4.2.5", + "license": "MIT" + }, + "node_modules/asynckit": { + "version": "0.4.0", + "license": "MIT" + }, + "node_modules/axios": { + "version": "1.20.0", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.16.0", + "form-data": "^4.0.6", + "https-proxy-agent": "^5.0.1", + "proxy-from-env": "^2.1.0" + } + }, + "node_modules/birpc": { + "version": "2.9.0", + "license": "MIT", + "peer": true, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/css-render": { + "version": "0.15.14", + "license": "MIT", + "dependencies": { + "@emotion/hash": "~0.8.0", + "csstype": "~3.0.5" + } + }, + "node_modules/css-render/node_modules/csstype": { + "version": "3.0.11", + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.2.3", + "license": "MIT" + }, + "node_modules/date-fns": { + "version": "4.4.0", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/kossnocorp" + } + }, + "node_modules/date-fns-tz": { + "version": "3.2.0", + "license": "MIT", + "peerDependencies": { + "date-fns": "^3.0.0 || ^4.0.0" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/echarts": { + "version": "6.1.0", + "license": "Apache-2.0", + "dependencies": { + "tslib": "2.3.0", + "zrender": "6.1.0" + } + }, + "node_modules/entities": { + "version": "7.0.1", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "license": "MIT" + }, + "node_modules/evtd": { + "version": "0.2.4", + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/follow-redirects": { + "version": "1.16.0", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.6", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.4", + "mime-types": "^2.1.35" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/highlight.js": { + "version": "11.12.0", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/hookable": { + "version": "5.5.3", + "license": "MIT", + "peer": true + }, + "node_modules/https-proxy-agent": { + "version": "5.0.1", + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/lightningcss": { + "version": "1.33.0", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lodash": { + "version": "4.18.1", + "license": "MIT" + }, + "node_modules/lodash-es": { + "version": "4.18.1", + "license": "MIT" + }, + "node_modules/magic-string": { + "version": "0.30.21", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "license": "MIT" + }, + "node_modules/muggle-string": { + "version": "0.4.1", + "dev": true, + "license": "MIT" + }, + "node_modules/naive-ui": { + "version": "2.45.2", + "license": "MIT", + "dependencies": { + "@css-render/plugin-bem": "^0.15.14", + "@css-render/vue3-ssr": "^0.15.14", + "@types/lodash": "^4.17.20", + "@types/lodash-es": "^4.17.12", + "async-validator": "^4.2.5", + "css-render": "^0.15.14", + "csstype": "^3.1.3", + "date-fns": "^4.1.0", + "date-fns-tz": "^3.2.0", + "evtd": "^0.2.4", + "highlight.js": "^11.8.0", + "lodash": "^4.17.21", + "lodash-es": "^4.17.21", + "seemly": "^0.3.10", + "treemate": "^0.3.11", + "vdirs": "^0.1.8", + "vooks": "^0.2.12", + "vueuc": "^0.4.66" + }, + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "vue": "^3.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.18", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/nostics": { + "version": "1.2.0", + "license": "MIT" + }, + "node_modules/path-browserify": { + "version": "1.0.1", + "dev": true, + "license": "MIT" + }, + "node_modules/perfect-debounce": { + "version": "2.1.0", + "license": "MIT", + "peer": true + }, + "node_modules/picocolors": { + "version": "1.1.1", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.7", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pinia": { + "version": "4.0.3", + "license": "MIT", + "dependencies": { + "nostics": "^1.1.4" + }, + "funding": { + "url": "https://github.com/sponsors/posva" + }, + "peerDependencies": { + "@vue/devtools-api": "^8.1.5", + "typescript": ">=5.6.0", + "vue": "^3.5.11" + }, + "peerDependenciesMeta": { + "@vue/devtools-api": { + "optional": false + }, + "typescript": { + "optional": true + } + } + }, + "node_modules/postcss": { + "version": "8.5.26", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.17", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/proxy-from-env": { + "version": "2.1.0", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/rolldown": { + "version": "1.2.6", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.147.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm-eabi": "1.2.6", + "@rolldown/binding-android-arm64": "1.2.6", + "@rolldown/binding-darwin-arm64": "1.2.6", + "@rolldown/binding-darwin-x64": "1.2.6", + "@rolldown/binding-freebsd-x64": "1.2.6", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.6", + "@rolldown/binding-linux-arm64-gnu": "1.2.6", + "@rolldown/binding-linux-arm64-musl": "1.2.6", + "@rolldown/binding-linux-ppc64-gnu": "1.2.6", + "@rolldown/binding-linux-s390x-gnu": "1.2.6", + "@rolldown/binding-linux-x64-gnu": "1.2.6", + "@rolldown/binding-linux-x64-musl": "1.2.6", + "@rolldown/binding-openharmony-arm64": "1.2.6", + "@rolldown/binding-win32-arm64-msvc": "1.2.6", + "@rolldown/binding-win32-x64-msvc": "1.2.6" + } + }, + "node_modules/seemly": { + "version": "0.3.10", + "license": "MIT" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/treemate": { + "version": "0.3.11", + "license": "MIT" + }, + "node_modules/tslib": { + "version": "2.3.0", + "license": "0BSD" + }, + "node_modules/typescript": { + "version": "6.0.3", + "devOptional": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "7.18.2", + "dev": true, + "license": "MIT" + }, + "node_modules/vdirs": { + "version": "0.1.8", + "license": "MIT", + "dependencies": { + "evtd": "^0.2.2" + }, + "peerDependencies": { + "vue": "^3.0.11" + } + }, + "node_modules/vite": { + "version": "8.2.2", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.33.0", + "picomatch": "^4.0.5", + "postcss": "^8.5.26", + "rolldown": "~1.2.4", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.4.0 || ^0.5.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vooks": { + "version": "0.2.12", + "license": "MIT", + "dependencies": { + "evtd": "^0.2.2" + }, + "peerDependencies": { + "vue": "^3.0.0" + } + }, + "node_modules/vscode-uri": { + "version": "3.2.0", + "dev": true, + "license": "MIT" + }, + "node_modules/vue": { + "version": "3.5.41", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.41", + "@vue/compiler-sfc": "3.5.41", + "@vue/runtime-dom": "3.5.41", + "@vue/server-renderer": "3.5.41", + "@vue/shared": "3.5.41" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/vue-echarts": { + "version": "8.1.0", + "license": "MIT", + "peerDependencies": { + "echarts": "^6.0.0", + "vue": "^3.3.0" + } + }, + "node_modules/vue-router": { + "version": "4.6.4", + "license": "MIT", + "dependencies": { + "@vue/devtools-api": "^6.6.4" + }, + "funding": { + "url": "https://github.com/sponsors/posva" + }, + "peerDependencies": { + "vue": "^3.5.0" + } + }, + "node_modules/vue-router/node_modules/@vue/devtools-api": { + "version": "6.6.4", + "license": "MIT" + }, + "node_modules/vue-tsc": { + "version": "3.3.11", + "resolved": "https://registry.npmmirror.com/vue-tsc/-/vue-tsc-3.3.11.tgz", + "integrity": "sha512-gOb0B9rtU2+f1dszwPqSH5kAieIF9ReeLhD3kSRNHv5WZZUQz/JdVXW0RTdqhNTMlQkqKzrTTviqKr/4FYZraQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/typescript": "2.4.28", + "@vue/language-core": "3.3.11" + }, + "bin": { + "vue-tsc": "bin/vue-tsc.js" + }, + "peerDependencies": { + "typescript": ">=5.0.0" + } + }, + "node_modules/vueuc": { + "version": "0.4.66", + "license": "MIT", + "dependencies": { + "@css-render/vue3-ssr": "^0.15.10", + "@juggle/resize-observer": "^3.3.1", + "css-render": "^0.15.10", + "evtd": "^0.2.4", + "seemly": "^0.3.6", + "vdirs": "^0.1.4", + "vooks": "^0.2.4" + }, + "peerDependencies": { + "vue": "^3.0.11" + } + }, + "node_modules/zrender": { + "version": "6.1.0", + "license": "BSD-3-Clause", + "dependencies": { + "tslib": "2.3.0" + } + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..7c770e5 --- /dev/null +++ b/frontend/package.json @@ -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" + } +} diff --git a/frontend/public/favicon.svg b/frontend/public/favicon.svg new file mode 100644 index 0000000..65e2eb1 --- /dev/null +++ b/frontend/public/favicon.svg @@ -0,0 +1,27 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/src/App.vue b/frontend/src/App.vue new file mode 100644 index 0000000..d5da4da --- /dev/null +++ b/frontend/src/App.vue @@ -0,0 +1,27 @@ + + + + + diff --git a/frontend/src/api/category.ts b/frontend/src/api/category.ts new file mode 100644 index 0000000..3c57c44 --- /dev/null +++ b/frontend/src/api/category.ts @@ -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}`) +} diff --git a/frontend/src/api/request.ts b/frontend/src/api/request.ts new file mode 100644 index 0000000..3c17cc0 --- /dev/null +++ b/frontend/src/api/request.ts @@ -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 diff --git a/frontend/src/api/statistics.ts b/frontend/src/api/statistics.ts new file mode 100644 index 0000000..5da958b --- /dev/null +++ b/frontend/src/api/statistics.ts @@ -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 }) +} diff --git a/frontend/src/api/transaction.ts b/frontend/src/api/transaction.ts new file mode 100644 index 0000000..7748bdf --- /dev/null +++ b/frontend/src/api/transaction.ts @@ -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}`, + }, + }) +} diff --git a/frontend/src/api/user.ts b/frontend/src/api/user.ts new file mode 100644 index 0000000..8cfd268 --- /dev/null +++ b/frontend/src/api/user.ts @@ -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') +} diff --git a/frontend/src/main.ts b/frontend/src/main.ts new file mode 100644 index 0000000..7e55308 --- /dev/null +++ b/frontend/src/main.ts @@ -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 diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts new file mode 100644 index 0000000..474af32 --- /dev/null +++ b/frontend/src/router/index.ts @@ -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 diff --git a/frontend/src/store/user.ts b/frontend/src/store/user.ts new file mode 100644 index 0000000..2cec3c3 --- /dev/null +++ b/frontend/src/store/user.ts @@ -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(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 } +}) diff --git a/frontend/src/style.css b/frontend/src/style.css new file mode 100644 index 0000000..cca57b3 --- /dev/null +++ b/frontend/src/style.css @@ -0,0 +1,5 @@ +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} diff --git a/frontend/src/views/CategoryManage.vue b/frontend/src/views/CategoryManage.vue new file mode 100644 index 0000000..400d81a --- /dev/null +++ b/frontend/src/views/CategoryManage.vue @@ -0,0 +1,171 @@ + + + diff --git a/frontend/src/views/Dashboard.vue b/frontend/src/views/Dashboard.vue new file mode 100644 index 0000000..41604e4 --- /dev/null +++ b/frontend/src/views/Dashboard.vue @@ -0,0 +1,144 @@ + + + diff --git a/frontend/src/views/Layout.vue b/frontend/src/views/Layout.vue new file mode 100644 index 0000000..07892b6 --- /dev/null +++ b/frontend/src/views/Layout.vue @@ -0,0 +1,86 @@ + + + diff --git a/frontend/src/views/Login.vue b/frontend/src/views/Login.vue new file mode 100644 index 0000000..e8897b9 --- /dev/null +++ b/frontend/src/views/Login.vue @@ -0,0 +1,68 @@ + + + + + diff --git a/frontend/src/views/Register.vue b/frontend/src/views/Register.vue new file mode 100644 index 0000000..f1a1b06 --- /dev/null +++ b/frontend/src/views/Register.vue @@ -0,0 +1,90 @@ + + + + + diff --git a/frontend/src/views/TransactionList.vue b/frontend/src/views/TransactionList.vue new file mode 100644 index 0000000..5407487 --- /dev/null +++ b/frontend/src/views/TransactionList.vue @@ -0,0 +1,430 @@ + + + diff --git a/frontend/tsconfig.app.json b/frontend/tsconfig.app.json new file mode 100644 index 0000000..d72aa75 --- /dev/null +++ b/frontend/tsconfig.app.json @@ -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"] +} diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000..1ffef60 --- /dev/null +++ b/frontend/tsconfig.json @@ -0,0 +1,7 @@ +{ + "files": [], + "references": [ + { "path": "./tsconfig.app.json" }, + { "path": "./tsconfig.node.json" } + ] +} diff --git a/frontend/tsconfig.node.json b/frontend/tsconfig.node.json new file mode 100644 index 0000000..8455dcb --- /dev/null +++ b/frontend/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"] +} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts new file mode 100644 index 0000000..7e1a1d1 --- /dev/null +++ b/frontend/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, + }, + }, + }, +}) diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..5ed10c6 --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -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 diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..9d21a21 --- /dev/null +++ b/gradlew.bat @@ -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 diff --git a/settings.gradle b/settings.gradle new file mode 100644 index 0000000..c091d71 --- /dev/null +++ b/settings.gradle @@ -0,0 +1 @@ +rootProject.name = 'my-app' diff --git a/src/main/java/com/example/finance/MyApplication.java b/src/main/java/com/example/finance/MyApplication.java new file mode 100644 index 0000000..dadbbee --- /dev/null +++ b/src/main/java/com/example/finance/MyApplication.java @@ -0,0 +1,22 @@ +package com.example.finance; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +/** + * 应用程序主启动类。 + *

Spring Boot 应用的入口点,负责引导和启动整个应用程序。

+ */ +@SpringBootApplication +public class MyApplication { + + /** + * 应用程序主入口方法。 + * + * @param args 命令行启动参数 + */ + public static void main(String[] args) { + // 启动 Spring Boot 应用 + SpringApplication.run(MyApplication.class, args); + } +} diff --git a/src/main/java/com/example/finance/common/BusinessException.java b/src/main/java/com/example/finance/common/BusinessException.java new file mode 100644 index 0000000..f6a83c8 --- /dev/null +++ b/src/main/java/com/example/finance/common/BusinessException.java @@ -0,0 +1,41 @@ +package com.example.finance.common; + +/** + * 业务异常类。 + *

用于表示业务逻辑中的可预期异常,携带自定义状态码,便于全局异常处理器返回对应的错误信息。

+ */ +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; + } +} diff --git a/src/main/java/com/example/finance/common/GlobalExceptionHandler.java b/src/main/java/com/example/finance/common/GlobalExceptionHandler.java new file mode 100644 index 0000000..c790c16 --- /dev/null +++ b/src/main/java/com/example/finance/common/GlobalExceptionHandler.java @@ -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; + +/** + * 全局异常处理器。 + *

统一捕获并处理控制器层抛出的各类异常,将其转换为标准化的 JSON 响应返回给前端。

+ */ +@RestControllerAdvice +public class GlobalExceptionHandler { + + /** + * 处理业务异常。 + * + * @param e 业务异常对象 + * @return 包含错误状态码和消息的响应结果 + */ + @ExceptionHandler(BusinessException.class) + public Result handleBusinessException(BusinessException e) { + return Result.error(e.getCode(), e.getMessage()); + } + + /** + * 处理参数校验异常。 + *

当请求参数校验不通过时,提取第一个校验错误信息并返回。

+ * + * @param e 参数校验异常对象 + * @return 包含 400 状态码和校验错误消息的响应结果 + */ + @ExceptionHandler(MethodArgumentNotValidException.class) + public Result 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 handleException(Exception e) { + // 打印异常堆栈信息,便于排查问题 + e.printStackTrace(); + return Result.error(500, "服务器内部错误: " + e.getMessage()); + } +} diff --git a/src/main/java/com/example/finance/common/PageResult.java b/src/main/java/com/example/finance/common/PageResult.java new file mode 100644 index 0000000..056e91a --- /dev/null +++ b/src/main/java/com/example/finance/common/PageResult.java @@ -0,0 +1,39 @@ +package com.example.finance.common; + +import lombok.Data; + +import java.util.List; + +/** + * 分页查询结果封装类。 + *

用于封装分页查询的返回结果,包含数据列表、总记录数、当前页码和每页大小。

+ * + * @param 分页记录数据的泛型类型 + */ +@Data +public class PageResult { + + /** 当前页的数据列表 */ + private List records; + /** 总记录数 */ + private long total; + /** 当前页码 */ + private long page; + /** 每页大小 */ + private long size; + + /** + * 构造分页结果。 + * + * @param records 当前页的数据列表 + * @param total 总记录数 + * @param page 当前页码 + * @param size 每页大小 + */ + public PageResult(List records, long total, long page, long size) { + this.records = records; + this.total = total; + this.page = page; + this.size = size; + } +} diff --git a/src/main/java/com/example/finance/common/Result.java b/src/main/java/com/example/finance/common/Result.java new file mode 100644 index 0000000..48a3929 --- /dev/null +++ b/src/main/java/com/example/finance/common/Result.java @@ -0,0 +1,71 @@ +package com.example.finance.common; + +import lombok.Data; + +/** + * 统一响应结果封装类。 + *

用于封装所有接口返回的数据,包含状态码、消息和数据体,便于前端统一处理。

+ * + * @param 响应数据的泛型类型 + */ +@Data +public class Result { + + /** 响应状态码 */ + private int code; + /** 响应消息 */ + private String message; + /** 响应数据 */ + private T data; + + /** + * 返回成功结果(携带数据)。 + * + * @param data 响应数据 + * @param 数据类型 + * @return 包含数据的成功响应结果 + */ + public static Result success(T data) { + Result result = new Result<>(); + result.setCode(200); + result.setMessage("success"); + result.setData(data); + return result; + } + + /** + * 返回成功结果(不携带数据)。 + * + * @param 数据类型 + * @return 不包含数据的成功响应结果 + */ + public static Result success() { + return success(null); + } + + /** + * 返回错误结果(自定义状态码和消息)。 + * + * @param code 错误状态码 + * @param message 错误消息 + * @param 数据类型 + * @return 错误响应结果 + */ + public static Result error(int code, String message) { + Result result = new Result<>(); + result.setCode(code); + result.setMessage(message); + return result; + } + + /** + * 返回错误结果(使用默认状态码 500)。 + * + * @param message 错误消息 + * @param 数据类型 + * @return 错误响应结果 + */ + public static Result error(String message) { + return error(500, message); + } +} diff --git a/src/main/java/com/example/finance/config/JwtConfig.java b/src/main/java/com/example/finance/config/JwtConfig.java new file mode 100644 index 0000000..ad99a3f --- /dev/null +++ b/src/main/java/com/example/finance/config/JwtConfig.java @@ -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 配置类。 + *

从配置文件中读取以 "jwt" 为前缀的 JWT 相关配置项,包括密钥和过期时间。

+ */ +@Data +@Component +@ConfigurationProperties(prefix = "jwt") +public class JwtConfig { + + /** JWT 签名密钥 */ + private String secret; + /** JWT 过期时间(毫秒) */ + private long expiration; +} diff --git a/src/main/java/com/example/finance/config/Knife4jConfig.java b/src/main/java/com/example/finance/config/Knife4jConfig.java new file mode 100644 index 0000000..0120a7f --- /dev/null +++ b/src/main/java/com/example/finance/config/Knife4jConfig.java @@ -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"))); + } +} diff --git a/src/main/java/com/example/finance/config/MybatisPlusConfig.java b/src/main/java/com/example/finance/config/MybatisPlusConfig.java new file mode 100644 index 0000000..9a5b077 --- /dev/null +++ b/src/main/java/com/example/finance/config/MybatisPlusConfig.java @@ -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 配置类。 + *

配置 MyBatis-Plus 的 Mapper 扫描路径和分页插件等。

+ */ +@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; + } +} diff --git a/src/main/java/com/example/finance/config/WebConfig.java b/src/main/java/com/example/finance/config/WebConfig.java new file mode 100644 index 0000000..358ca40 --- /dev/null +++ b/src/main/java/com/example/finance/config/WebConfig.java @@ -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 配置类。 + *

配置拦截器、跨域策略等 Web 层相关设置。

+ */ +@Configuration +public class WebConfig implements WebMvcConfigurer { + + private final JwtInterceptor jwtInterceptor; + + /** + * 构造方法,注入 JWT 拦截器。 + * + * @param jwtInterceptor JWT 拦截器实例 + */ + public WebConfig(JwtInterceptor jwtInterceptor) { + this.jwtInterceptor = jwtInterceptor; + } + + /** + * 配置拦截器规则。 + *

对 /api/** 路径下的请求启用 JWT 认证,排除登录和注册接口。

+ * + * @param registry 拦截器注册器 + */ + @Override + public void addInterceptors(InterceptorRegistry registry) { + registry.addInterceptor(jwtInterceptor) + .addPathPatterns("/api/**") + // 登录和注册接口不需要认证 + .excludePathPatterns("/api/user/login", "/api/user/register"); + } + + /** + * 配置跨域策略。 + *

允许所有来源的跨域请求,支持常用 HTTP 方法,并允许携带凭据。

+ * + * @param registry 跨域配置注册器 + */ + @Override + public void addCorsMappings(CorsRegistry registry) { + registry.addMapping("/**") + .allowedOriginPatterns("*") + .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS") + .allowedHeaders("*") + .allowCredentials(true) + .maxAge(3600); + } +} diff --git a/src/main/java/com/example/finance/controller/CategoryController.java b/src/main/java/com/example/finance/controller/CategoryController.java new file mode 100644 index 0000000..496d795 --- /dev/null +++ b/src/main/java/com/example/finance/controller/CategoryController.java @@ -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; + +/** + * 分类控制器 + * + *

提供分类的增删改查接口,支持按类型筛选

+ */ +@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(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 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 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 delete(HttpServletRequest request, + @Parameter(description = "分类ID") @PathVariable Long id) { + Long userId = (Long) request.getAttribute("userId"); + categoryService.delete(userId, id); + return Result.success(); + } +} diff --git a/src/main/java/com/example/finance/controller/StatisticsController.java b/src/main/java/com/example/finance/controller/StatisticsController.java new file mode 100644 index 0000000..3b163db --- /dev/null +++ b/src/main/java/com/example/finance/controller/StatisticsController.java @@ -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; + +/** + * 统计控制器 + * + *

提供月度统计、分类统计、趋势统计等数据统计接口

+ */ +@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 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> 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> trend(HttpServletRequest request, @Valid + @Parameter(description = "统计查询参数") StatisticsQuery query) { + Long userId = (Long) request.getAttribute("userId"); + return Result.success(statisticsService.getTrendStatistics(userId, query)); + } +} diff --git a/src/main/java/com/example/finance/controller/TransactionController.java b/src/main/java/com/example/finance/controller/TransactionController.java new file mode 100644 index 0000000..f635f96 --- /dev/null +++ b/src/main/java/com/example/finance/controller/TransactionController.java @@ -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; + +/** + * 交易记录控制器 + * + *

提供交易记录的分页查询、新增、更新、删除接口

+ */ +@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> 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 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 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 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 toggleStats(HttpServletRequest request, + @Parameter(description = "交易记录ID") @PathVariable Long id, + @RequestBody Map 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 data = transactionService.export(userId, query); + EasyExcel.write(response.getOutputStream(), TransactionExportVO.class) + .sheet("交易记录") + .doWrite(data); + } +} diff --git a/src/main/java/com/example/finance/controller/UserController.java b/src/main/java/com/example/finance/controller/UserController.java new file mode 100644 index 0000000..d550aaf --- /dev/null +++ b/src/main/java/com/example/finance/controller/UserController.java @@ -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; + +/** + * 用户控制器 + * + *

提供用户登录、注册、获取用户信息等接口

+ */ +@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 login(@RequestBody @Valid LoginRequest request) { + return Result.success(userService.login(request)); + } + + /** + * 用户注册 + * + * @param request 注册请求参数 + * @return 操作结果 + */ + @PostMapping("/register") + @Operation(summary = "用户注册", description = "新用户注册账号") + public Result register(@RequestBody @Valid RegisterRequest request) { + userService.register(request); + return Result.success(); + } + + /** + * 获取当前用户信息 + * + * @param request HTTP请求,从中获取用户ID + * @return 用户信息 + */ + @GetMapping("/info") + @Operation(summary = "获取当前用户信息", description = "获取已登录用户的详细信息") + public Result info(HttpServletRequest request) { + Long userId = (Long) request.getAttribute("userId"); + return Result.success(userService.getUserInfo(userId)); + } +} diff --git a/src/main/java/com/example/finance/dto/CategoryRequest.java b/src/main/java/com/example/finance/dto/CategoryRequest.java new file mode 100644 index 0000000..d85ff28 --- /dev/null +++ b/src/main/java/com/example/finance/dto/CategoryRequest.java @@ -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; +} diff --git a/src/main/java/com/example/finance/dto/LoginRequest.java b/src/main/java/com/example/finance/dto/LoginRequest.java new file mode 100644 index 0000000..5da0650 --- /dev/null +++ b/src/main/java/com/example/finance/dto/LoginRequest.java @@ -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; +} diff --git a/src/main/java/com/example/finance/dto/RegisterRequest.java b/src/main/java/com/example/finance/dto/RegisterRequest.java new file mode 100644 index 0000000..865c5ed --- /dev/null +++ b/src/main/java/com/example/finance/dto/RegisterRequest.java @@ -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; +} diff --git a/src/main/java/com/example/finance/dto/StatisticsQuery.java b/src/main/java/com/example/finance/dto/StatisticsQuery.java new file mode 100644 index 0000000..5df081b --- /dev/null +++ b/src/main/java/com/example/finance/dto/StatisticsQuery.java @@ -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; +} diff --git a/src/main/java/com/example/finance/dto/TransactionQuery.java b/src/main/java/com/example/finance/dto/TransactionQuery.java new file mode 100644 index 0000000..9144471 --- /dev/null +++ b/src/main/java/com/example/finance/dto/TransactionQuery.java @@ -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 types; + + /** 分类ID筛选,支持多选 */ + private List categoryIds; + + /** 查询起始时间 */ + private String startTime; + + /** 查询结束时间 */ + private String endTime; + + /** 支付方式筛选(WECHAT/ALIPAY/CASH/BANK),支持多选 */ + private List paymentMethods; + + /** 是否计入统计筛选:null-全部,true-仅计入,false-仅不计入 */ + private Boolean includeInStats; +} diff --git a/src/main/java/com/example/finance/dto/TransactionRequest.java b/src/main/java/com/example/finance/dto/TransactionRequest.java new file mode 100644 index 0000000..90c3f3b --- /dev/null +++ b/src/main/java/com/example/finance/dto/TransactionRequest.java @@ -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; +} diff --git a/src/main/java/com/example/finance/entity/Category.java b/src/main/java/com/example/finance/entity/Category.java new file mode 100644 index 0000000..6f09524 --- /dev/null +++ b/src/main/java/com/example/finance/entity/Category.java @@ -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; +} diff --git a/src/main/java/com/example/finance/entity/Transaction.java b/src/main/java/com/example/finance/entity/Transaction.java new file mode 100644 index 0000000..239b112 --- /dev/null +++ b/src/main/java/com/example/finance/entity/Transaction.java @@ -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; +} diff --git a/src/main/java/com/example/finance/entity/User.java b/src/main/java/com/example/finance/entity/User.java new file mode 100644 index 0000000..acf488d --- /dev/null +++ b/src/main/java/com/example/finance/entity/User.java @@ -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; +} diff --git a/src/main/java/com/example/finance/interceptor/JwtInterceptor.java b/src/main/java/com/example/finance/interceptor/JwtInterceptor.java new file mode 100644 index 0000000..0769f51 --- /dev/null +++ b/src/main/java/com/example/finance/interceptor/JwtInterceptor.java @@ -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 认证拦截器。 + *

在请求到达控制器之前,从 Authorization 请求头中提取并验证 JWT Token, + * 验证通过后将用户 ID 存入请求属性,验证失败则返回 401 响应。

+ */ +@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 认证。 + *

处理流程:放行 OPTIONS 预检请求 -> 提取 Token -> 验证 Token -> 存入用户 ID 或返回 401。

+ * + * @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 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; + } +} diff --git a/src/main/java/com/example/finance/mapper/CategoryMapper.java b/src/main/java/com/example/finance/mapper/CategoryMapper.java new file mode 100644 index 0000000..57337f2 --- /dev/null +++ b/src/main/java/com/example/finance/mapper/CategoryMapper.java @@ -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 { +} diff --git a/src/main/java/com/example/finance/mapper/TransactionMapper.java b/src/main/java/com/example/finance/mapper/TransactionMapper.java new file mode 100644 index 0000000..525a455 --- /dev/null +++ b/src/main/java/com/example/finance/mapper/TransactionMapper.java @@ -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 { +} diff --git a/src/main/java/com/example/finance/mapper/UserMapper.java b/src/main/java/com/example/finance/mapper/UserMapper.java new file mode 100644 index 0000000..08439e1 --- /dev/null +++ b/src/main/java/com/example/finance/mapper/UserMapper.java @@ -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 { +} diff --git a/src/main/java/com/example/finance/service/CategoryService.java b/src/main/java/com/example/finance/service/CategoryService.java new file mode 100644 index 0000000..732f702 --- /dev/null +++ b/src/main/java/com/example/finance/service/CategoryService.java @@ -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 list(Long userId); + + /** + * 按类型(收入/支出)获取用户的分类列表 + * + * @param userId 用户ID + * @param type 分类类型(如 INCOME、EXPENSE) + * @return 分类列表 + */ + List 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); +} diff --git a/src/main/java/com/example/finance/service/StatisticsService.java b/src/main/java/com/example/finance/service/StatisticsService.java new file mode 100644 index 0000000..d32a447 --- /dev/null +++ b/src/main/java/com/example/finance/service/StatisticsService.java @@ -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 getCategoryStatistics(Long userId, StatisticsQuery query); + + /** + * 获取每日收支趋势统计数据 + * + * @param userId 用户ID + * @param query 统计查询条件(年份、月份) + * @return 按日排列的趋势统计列表 + */ + List getTrendStatistics(Long userId, StatisticsQuery query); +} diff --git a/src/main/java/com/example/finance/service/TransactionService.java b/src/main/java/com/example/finance/service/TransactionService.java new file mode 100644 index 0000000..dafb0f3 --- /dev/null +++ b/src/main/java/com/example/finance/service/TransactionService.java @@ -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 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 export(Long userId, TransactionQuery query); + + /** + * 切换单条交易记录的"计入统计"标记 + * + * @param userId 用户ID + * @param id 交易记录ID + * @param includeInStats 新的标记值 + */ + void toggleStats(Long userId, Long id, Boolean includeInStats); +} diff --git a/src/main/java/com/example/finance/service/UserService.java b/src/main/java/com/example/finance/service/UserService.java new file mode 100644 index 0000000..856c027 --- /dev/null +++ b/src/main/java/com/example/finance/service/UserService.java @@ -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); +} diff --git a/src/main/java/com/example/finance/service/impl/CategoryServiceImpl.java b/src/main/java/com/example/finance/service/impl/CategoryServiceImpl.java new file mode 100644 index 0000000..39bfd06 --- /dev/null +++ b/src/main/java/com/example/finance/service/impl/CategoryServiceImpl.java @@ -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 list(Long userId) { + List categories = categoryMapper.selectList(new LambdaQueryWrapper() + .eq(Category::getUserId, userId) + .orderByAsc(Category::getSortOrder) + .orderByAsc(Category::getId)); + return categories.stream().map(this::toVO).collect(Collectors.toList()); + } + + /** + * {@inheritDoc} + * + * 按分类类型筛选用户分类列表,按排序字段和ID升序排列。 + */ + @Override + public List listByType(Long userId, String type) { + List categories = categoryMapper.selectList(new LambdaQueryWrapper() + .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; + } +} diff --git a/src/main/java/com/example/finance/service/impl/StatisticsServiceImpl.java b/src/main/java/com/example/finance/service/impl/StatisticsServiceImpl.java new file mode 100644 index 0000000..07103f1 --- /dev/null +++ b/src/main/java/com/example/finance/service/impl/StatisticsServiceImpl.java @@ -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 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 getCategoryStatistics(Long userId, StatisticsQuery query) { + List transactions = getMonthTransactions(userId, query); + + // 按分类ID分组 + Map> grouped = transactions.stream() + .collect(Collectors.groupingBy(Transaction::getCategoryId)); + + // 批量查询分类信息 + List categoryIds = new ArrayList<>(grouped.keySet()); + Map categoryMap = Map.of(); + if (!categoryIds.isEmpty()) { + List 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 finalCategoryMap = categoryMap; + return grouped.entrySet().stream() + .map(entry -> { + List 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 getTrendStatistics(Long userId, StatisticsQuery query) { + YearMonth yearMonth = YearMonth.of(query.getYear(), query.getMonth()); + int daysInMonth = yearMonth.lengthOfMonth(); + + // 初始化每日收入和支出为零 + Map incomeMap = new HashMap<>(); + Map expenseMap = new HashMap<>(); + for (int i = 1; i <= daysInMonth; i++) { + incomeMap.put(i, BigDecimal.ZERO); + expenseMap.put(i, BigDecimal.ZERO); + } + + // 累加每日交易金额 + List 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 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 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() + .eq(Transaction::getUserId, userId) + .ge(Transaction::getTransactionTime, startTime) + .le(Transaction::getTransactionTime, endTime) + // 仅统计计入统计的记录(排除借款、账户间转账等) + .and(w -> w.eq(Transaction::getIncludeInStats, true) + .or().isNull(Transaction::getIncludeInStats))); + } +} diff --git a/src/main/java/com/example/finance/service/impl/TransactionServiceImpl.java b/src/main/java/com/example/finance/service/impl/TransactionServiceImpl.java new file mode 100644 index 0000000..721145b --- /dev/null +++ b/src/main/java/com/example/finance/service/impl/TransactionServiceImpl.java @@ -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 page(Long userId, TransactionQuery query) { + LambdaQueryWrapper 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 page = new Page<>(query.getPage(), query.getSize()); + Page result = transactionMapper.selectPage(page, wrapper); + + // 批量查询关联的分类信息,避免 N+1 查询问题 + List categoryIds = result.getRecords().stream() + .map(Transaction::getCategoryId) + .distinct() + .collect(Collectors.toList()); + + Map categoryMap = Map.of(); + if (!categoryIds.isEmpty()) { + List categories = categoryMapper.selectBatchIds(categoryIds); + categoryMap = categories.stream().collect(Collectors.toMap(Category::getId, c -> c)); + } + + // 将交易记录转换为视图对象,并填充分类名称和图标 + Map finalCategoryMap = categoryMap; + List 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 export(Long userId, TransactionQuery query) { + LambdaQueryWrapper 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 transactions = transactionMapper.selectList(wrapper); + + // 批量查询关联的分类信息 + List categoryIds = transactions.stream() + .map(Transaction::getCategoryId) + .distinct() + .collect(Collectors.toList()); + + Map categoryMap = Map.of(); + if (!categoryIds.isEmpty()) { + List categories = categoryMapper.selectBatchIds(categoryIds); + categoryMap = categories.stream().collect(Collectors.toMap(Category::getId, c -> c)); + } + + // 转换为导出视图对象 + Map 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; + }; + } +} diff --git a/src/main/java/com/example/finance/service/impl/UserServiceImpl.java b/src/main/java/com/example/finance/service/impl/UserServiceImpl.java new file mode 100644 index 0000000..b89a0b5 --- /dev/null +++ b/src/main/java/com/example/finance/service/impl/UserServiceImpl.java @@ -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() + .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() + .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; + } +} diff --git a/src/main/java/com/example/finance/util/JwtUtil.java b/src/main/java/com/example/finance/util/JwtUtil.java new file mode 100644 index 0000000..8f82b65 --- /dev/null +++ b/src/main/java/com/example/finance/util/JwtUtil.java @@ -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 工具类。 + *

提供 JWT Token 的生成、解析、验证等功能。

+ */ +@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。 + *

将用户 ID 作为 subject,用户名作为自定义 claim,并设置签发时间和过期时间。

+ * + * @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; + } + } +} diff --git a/src/main/java/com/example/finance/vo/CategoryStatisticsVO.java b/src/main/java/com/example/finance/vo/CategoryStatisticsVO.java new file mode 100644 index 0000000..4c214f3 --- /dev/null +++ b/src/main/java/com/example/finance/vo/CategoryStatisticsVO.java @@ -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; +} diff --git a/src/main/java/com/example/finance/vo/CategoryVO.java b/src/main/java/com/example/finance/vo/CategoryVO.java new file mode 100644 index 0000000..d7b8450 --- /dev/null +++ b/src/main/java/com/example/finance/vo/CategoryVO.java @@ -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; +} diff --git a/src/main/java/com/example/finance/vo/LoginVO.java b/src/main/java/com/example/finance/vo/LoginVO.java new file mode 100644 index 0000000..1999dde --- /dev/null +++ b/src/main/java/com/example/finance/vo/LoginVO.java @@ -0,0 +1,16 @@ +package com.example.finance.vo; + +import lombok.Data; + +/** + * 登录响应视图对象,包含认证令牌和用户基本信息 + */ +@Data +public class LoginVO { + + /** JWT认证令牌 */ + private String token; + + /** 用户信息 */ + private UserVO user; +} diff --git a/src/main/java/com/example/finance/vo/MonthlyStatisticsVO.java b/src/main/java/com/example/finance/vo/MonthlyStatisticsVO.java new file mode 100644 index 0000000..a15062f --- /dev/null +++ b/src/main/java/com/example/finance/vo/MonthlyStatisticsVO.java @@ -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; +} diff --git a/src/main/java/com/example/finance/vo/TransactionExportVO.java b/src/main/java/com/example/finance/vo/TransactionExportVO.java new file mode 100644 index 0000000..b08d1fe --- /dev/null +++ b/src/main/java/com/example/finance/vo/TransactionExportVO.java @@ -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; +} diff --git a/src/main/java/com/example/finance/vo/TransactionVO.java b/src/main/java/com/example/finance/vo/TransactionVO.java new file mode 100644 index 0000000..4ed35ca --- /dev/null +++ b/src/main/java/com/example/finance/vo/TransactionVO.java @@ -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; +} diff --git a/src/main/java/com/example/finance/vo/TrendStatisticsVO.java b/src/main/java/com/example/finance/vo/TrendStatisticsVO.java new file mode 100644 index 0000000..0b6d354 --- /dev/null +++ b/src/main/java/com/example/finance/vo/TrendStatisticsVO.java @@ -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; +} diff --git a/src/main/java/com/example/finance/vo/UserVO.java b/src/main/java/com/example/finance/vo/UserVO.java new file mode 100644 index 0000000..a2e0134 --- /dev/null +++ b/src/main/java/com/example/finance/vo/UserVO.java @@ -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; +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml new file mode 100644 index 0000000..8726190 --- /dev/null +++ b/src/main/resources/application.yml @@ -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 diff --git a/src/main/resources/logback-spring.xml b/src/main/resources/logback-spring.xml new file mode 100644 index 0000000..a5472b0 --- /dev/null +++ b/src/main/resources/logback-spring.xml @@ -0,0 +1,34 @@ + + + + + + %d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n + UTF-8 + + + + + + logs/finance-app.log + + logs/finance-app.%d{yyyy-MM-dd}.log + 30 + + + %d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n + UTF-8 + + + + + + + + + + + + + + diff --git a/src/main/resources/sql/init.sql b/src/main/resources/sql/init.sql new file mode 100644 index 0000000..bc1dc3e --- /dev/null +++ b/src/main/resources/sql/init.sql @@ -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+交易时间联合索引' +); diff --git a/src/test/java/com/example/finance/MyApplicationTests.java b/src/test/java/com/example/finance/MyApplicationTests.java new file mode 100644 index 0000000..a72e07c --- /dev/null +++ b/src/test/java/com/example/finance/MyApplicationTests.java @@ -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() { + } +}