From a29a3979d7ebb61daf45f30510d66ea8cd2cd3ba Mon Sep 17 00:00:00 2001 From: handsomeAq Date: Mon, 21 Sep 2026 17:36:33 +0800 Subject: [PATCH] first commit --- .gitignore | 45 ++ BACKEND_FILES_CHECKLIST.md | 119 +++++ pom.xml | 165 +++++++ .../com/accounting/AccountingApplication.java | 22 + .../accounting/config/AliyunOcrConfig.java | 38 ++ .../config/JwtAuthenticationFilter.java | 66 +++ .../java/com/accounting/config/JwtConfig.java | 82 +++ .../com/accounting/config/K780Config.java | 15 + .../accounting/config/RestTemplateConfig.java | 18 + .../accounting/config/SchedulerConfig.java | 34 ++ .../com/accounting/config/SecurityConfig.java | 83 ++++ .../config/UserDetailsServiceImpl.java | 45 ++ .../controller/AccountController.java | 65 +++ .../accounting/controller/AuthController.java | 44 ++ .../accounting/controller/BillController.java | 97 ++++ .../controller/BudgetController.java | 80 +++ .../controller/CategoryController.java | 77 +++ .../controller/GoldPriceController.java | 140 ++++++ .../accounting/controller/OcrController.java | 59 +++ .../controller/StatisticsController.java | 74 +++ .../com/accounting/dto/AccountRequest.java | 13 + .../com/accounting/dto/AccountResponse.java | 26 + .../java/com/accounting/dto/AuthResponse.java | 25 + .../com/accounting/dto/BatchBillRequest.java | 19 + .../java/com/accounting/dto/BillRequest.java | 27 + .../java/com/accounting/dto/BillResponse.java | 27 + .../com/accounting/dto/BudgetRequest.java | 15 + .../com/accounting/dto/BudgetResponse.java | 23 + .../dto/BudgetSettlementResponse.java | 23 + .../com/accounting/dto/CategoryRequest.java | 25 + .../com/accounting/dto/CategoryResponse.java | 24 + .../com/accounting/dto/GoldPriceResponse.java | 30 ++ .../java/com/accounting/dto/K780Response.java | 16 + .../java/com/accounting/dto/K780Result.java | 60 +++ .../java/com/accounting/dto/LoginRequest.java | 22 + .../java/com/accounting/dto/OcrResponse.java | 35 ++ .../com/accounting/dto/RegisterRequest.java | 27 + .../accounting/dto/StatisticsResponse.java | 40 ++ .../java/com/accounting/entity/Account.java | 27 + src/main/java/com/accounting/entity/Bill.java | 40 ++ .../java/com/accounting/entity/Budget.java | 29 ++ .../java/com/accounting/entity/Category.java | 38 ++ .../java/com/accounting/entity/GoldPrice.java | 41 ++ .../java/com/accounting/entity/OcrRecord.java | 44 ++ src/main/java/com/accounting/entity/User.java | 34 ++ .../com/accounting/mapper/AccountMapper.java | 10 + .../com/accounting/mapper/BillMapper.java | 18 + .../com/accounting/mapper/BudgetMapper.java | 10 + .../com/accounting/mapper/CategoryMapper.java | 18 + .../accounting/mapper/GoldPriceMapper.java | 32 ++ .../accounting/mapper/OcrRecordMapper.java | 18 + .../com/accounting/mapper/UserMapper.java | 18 + .../schedule/GoldPriceScheduler.java | 33 ++ .../accounting/service/AccountService.java | 161 ++++++ .../com/accounting/service/AuthService.java | 86 ++++ .../com/accounting/service/BillService.java | 204 ++++++++ .../com/accounting/service/BudgetService.java | 191 +++++++ .../accounting/service/CategoryService.java | 118 +++++ .../accounting/service/GoldPriceService.java | 44 ++ .../com/accounting/service/K780ApiClient.java | 50 ++ .../com/accounting/service/OcrService.java | 76 +++ .../accounting/service/StatisticsService.java | 117 +++++ .../service/impl/GoldPriceServiceImpl.java | 220 +++++++++ .../java/com/accounting/util/FileUtil.java | 114 +++++ .../com/accounting/util/OcrAmountParser.java | 466 ++++++++++++++++++ src/main/resources/application.yml | 85 ++++ ...on_remove_gold_price_unique_constraint.sql | 5 + src/main/resources/db/schema.sql | 145 ++++++ src/main/resources/mapper/.gitkeep | 0 69 files changed, 4337 insertions(+) create mode 100644 .gitignore create mode 100644 BACKEND_FILES_CHECKLIST.md create mode 100644 pom.xml create mode 100644 src/main/java/com/accounting/AccountingApplication.java create mode 100644 src/main/java/com/accounting/config/AliyunOcrConfig.java create mode 100644 src/main/java/com/accounting/config/JwtAuthenticationFilter.java create mode 100644 src/main/java/com/accounting/config/JwtConfig.java create mode 100644 src/main/java/com/accounting/config/K780Config.java create mode 100644 src/main/java/com/accounting/config/RestTemplateConfig.java create mode 100644 src/main/java/com/accounting/config/SchedulerConfig.java create mode 100644 src/main/java/com/accounting/config/SecurityConfig.java create mode 100644 src/main/java/com/accounting/config/UserDetailsServiceImpl.java create mode 100644 src/main/java/com/accounting/controller/AccountController.java create mode 100644 src/main/java/com/accounting/controller/AuthController.java create mode 100644 src/main/java/com/accounting/controller/BillController.java create mode 100644 src/main/java/com/accounting/controller/BudgetController.java create mode 100644 src/main/java/com/accounting/controller/CategoryController.java create mode 100644 src/main/java/com/accounting/controller/GoldPriceController.java create mode 100644 src/main/java/com/accounting/controller/OcrController.java create mode 100644 src/main/java/com/accounting/controller/StatisticsController.java create mode 100644 src/main/java/com/accounting/dto/AccountRequest.java create mode 100644 src/main/java/com/accounting/dto/AccountResponse.java create mode 100644 src/main/java/com/accounting/dto/AuthResponse.java create mode 100644 src/main/java/com/accounting/dto/BatchBillRequest.java create mode 100644 src/main/java/com/accounting/dto/BillRequest.java create mode 100644 src/main/java/com/accounting/dto/BillResponse.java create mode 100644 src/main/java/com/accounting/dto/BudgetRequest.java create mode 100644 src/main/java/com/accounting/dto/BudgetResponse.java create mode 100644 src/main/java/com/accounting/dto/BudgetSettlementResponse.java create mode 100644 src/main/java/com/accounting/dto/CategoryRequest.java create mode 100644 src/main/java/com/accounting/dto/CategoryResponse.java create mode 100644 src/main/java/com/accounting/dto/GoldPriceResponse.java create mode 100644 src/main/java/com/accounting/dto/K780Response.java create mode 100644 src/main/java/com/accounting/dto/K780Result.java create mode 100644 src/main/java/com/accounting/dto/LoginRequest.java create mode 100644 src/main/java/com/accounting/dto/OcrResponse.java create mode 100644 src/main/java/com/accounting/dto/RegisterRequest.java create mode 100644 src/main/java/com/accounting/dto/StatisticsResponse.java create mode 100644 src/main/java/com/accounting/entity/Account.java create mode 100644 src/main/java/com/accounting/entity/Bill.java create mode 100644 src/main/java/com/accounting/entity/Budget.java create mode 100644 src/main/java/com/accounting/entity/Category.java create mode 100644 src/main/java/com/accounting/entity/GoldPrice.java create mode 100644 src/main/java/com/accounting/entity/OcrRecord.java create mode 100644 src/main/java/com/accounting/entity/User.java create mode 100644 src/main/java/com/accounting/mapper/AccountMapper.java create mode 100644 src/main/java/com/accounting/mapper/BillMapper.java create mode 100644 src/main/java/com/accounting/mapper/BudgetMapper.java create mode 100644 src/main/java/com/accounting/mapper/CategoryMapper.java create mode 100644 src/main/java/com/accounting/mapper/GoldPriceMapper.java create mode 100644 src/main/java/com/accounting/mapper/OcrRecordMapper.java create mode 100644 src/main/java/com/accounting/mapper/UserMapper.java create mode 100644 src/main/java/com/accounting/schedule/GoldPriceScheduler.java create mode 100644 src/main/java/com/accounting/service/AccountService.java create mode 100644 src/main/java/com/accounting/service/AuthService.java create mode 100644 src/main/java/com/accounting/service/BillService.java create mode 100644 src/main/java/com/accounting/service/BudgetService.java create mode 100644 src/main/java/com/accounting/service/CategoryService.java create mode 100644 src/main/java/com/accounting/service/GoldPriceService.java create mode 100644 src/main/java/com/accounting/service/K780ApiClient.java create mode 100644 src/main/java/com/accounting/service/OcrService.java create mode 100644 src/main/java/com/accounting/service/StatisticsService.java create mode 100644 src/main/java/com/accounting/service/impl/GoldPriceServiceImpl.java create mode 100644 src/main/java/com/accounting/util/FileUtil.java create mode 100644 src/main/java/com/accounting/util/OcrAmountParser.java create mode 100644 src/main/resources/application.yml create mode 100644 src/main/resources/db/migration_remove_gold_price_unique_constraint.sql create mode 100644 src/main/resources/db/schema.sql create mode 100644 src/main/resources/mapper/.gitkeep diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..aa62309 --- /dev/null +++ b/.gitignore @@ -0,0 +1,45 @@ +HELP.md +target/ +!.mvn/wrapper/maven-wrapper.jar +!**/src/main/**/target/ +!**/src/test/**/target/ + +### STS ### +.apt_generated +.classpath +.factorypath +.project +.settings +.springBeans +.sts4-cache + +### IntelliJ IDEA ### +.idea +*.iws +*.iml +*.ipr + +### NetBeans ### +/nbproject/private/ +/nbbuild/ +/dist/ +/nbdist/ +/.nb-gradle/ +build/ +!**/src/main/**/build/ +!**/src/test/**/build/ + +### VS Code ### +.vscode/ + +### 上传文件目录 ### +uploads/ + + + + + + + + + diff --git a/BACKEND_FILES_CHECKLIST.md b/BACKEND_FILES_CHECKLIST.md new file mode 100644 index 0000000..0a37515 --- /dev/null +++ b/BACKEND_FILES_CHECKLIST.md @@ -0,0 +1,119 @@ +# 后端文件清单 + +## 已创建和补全的文件 + +### 核心配置类 +- ✅ `AccountingApplication.java` - Spring Boot应用入口 +- ✅ `config/JwtConfig.java` - JWT配置和工具类 +- ✅ `config/JwtAuthenticationFilter.java` - JWT认证过滤器 +- ✅ `config/SecurityConfig.java` - Spring Security配置 +- ✅ `config/UserDetailsServiceImpl.java` - 用户详情服务实现 +- ✅ `config/AliyunOcrConfig.java` - 阿里云OCR配置 + +### 实体类(Entity) +- ✅ `entity/User.java` - 用户实体 +- ✅ `entity/Category.java` - 分类实体 +- ✅ `entity/Bill.java` - 账单实体 +- ✅ `entity/OcrRecord.java` - OCR记录实体 + +### 数据访问层(Mapper) +- ✅ `mapper/UserMapper.java` - 用户Mapper接口 +- ✅ `mapper/CategoryMapper.java` - 分类Mapper接口 +- ✅ `mapper/BillMapper.java` - 账单Mapper接口 +- ✅ `mapper/OcrRecordMapper.java` - OCR记录Mapper接口 + +### 业务逻辑层(Service) +- ✅ `service/AuthService.java` - 认证服务(注册、登录) +- ✅ `service/BillService.java` - 账单服务(CRUD操作) +- ✅ `service/CategoryService.java` - 分类服务(CRUD操作) +- ✅ `service/StatisticsService.java` - 统计服务(按日/周/月统计) +- ✅ `service/OcrService.java` - OCR识别服务 + +### 控制器层(Controller) +- ✅ `controller/AuthController.java` - 认证控制器(注册、登录接口) +- ✅ `controller/BillController.java` - 账单控制器(账单CRUD接口) +- ✅ `controller/CategoryController.java` - 分类控制器(分类CRUD接口) +- ✅ `controller/StatisticsController.java` - 统计控制器(统计查询接口) +- ✅ `controller/OcrController.java` - OCR控制器(OCR识别接口) + +### 数据传输对象(DTO) +- ✅ `dto/LoginRequest.java` - 登录请求DTO +- ✅ `dto/RegisterRequest.java` - 注册请求DTO +- ✅ `dto/AuthResponse.java` - 认证响应DTO +- ✅ `dto/BillRequest.java` - 账单请求DTO +- ✅ `dto/BillResponse.java` - 账单响应DTO +- ✅ `dto/CategoryRequest.java` - 分类请求DTO +- ✅ `dto/CategoryResponse.java` - 分类响应DTO +- ✅ `dto/StatisticsResponse.java` - 统计响应DTO +- ✅ `dto/OcrResponse.java` - OCR响应DTO + +### 工具类(Util) +- ✅ `util/FileUtil.java` - 文件上传工具类 +- ✅ `util/OcrAmountParser.java` - OCR金额解析工具类 + +### 配置文件 +- ✅ `pom.xml` - Maven依赖配置 +- ✅ `src/main/resources/application.yml` - 应用配置文件 +- ✅ `src/main/resources/db/schema.sql` - 数据库表结构SQL脚本 + +## 已修复的问题 + +1. ✅ **AuthService** - 清理了测试代码,移除了不必要的导入 +2. ✅ **JwtConfig** - 修复了JWT解析器API调用(使用parserBuilder) +3. ✅ **OcrService** - 修复了Base64编码问题(使用Base64字符串而不是InputStream) +4. ✅ **BillService** - 补全了完整的账单服务实现 +5. ✅ **BillController** - 补全了完整的账单控制器实现 +6. ✅ **CategoryController** - 补全了完整的分类控制器实现 +7. ✅ **StatisticsController** - 补全了完整的统计控制器实现 + +## 功能模块 + +### 1. 认证模块 +- 用户注册(密码加密存储) +- 用户登录(JWT token生成) +- JWT token验证和过滤 + +### 2. 账单管理模块 +- 创建账单 +- 更新账单 +- 删除账单 +- 查询账单列表(支持日期范围) +- 查询账单详情 + +### 3. 分类管理模块 +- 获取分类列表(系统预设+用户自定义) +- 创建自定义分类 +- 更新分类 +- 删除分类(系统分类不可删除) + +### 4. 统计模块 +- 按日期范围统计 +- 按周统计 +- 按月统计 +- 分类统计 +- 收入/支出统计 + +### 5. OCR识别模块 +- 图片上传 +- 阿里云OCR识别 +- 金额、商户、日期解析 +- OCR记录保存 + +## 注意事项 + +1. **数据库配置**:需要在`application.yml`中配置MySQL连接信息 +2. **阿里云OCR配置**:需要配置AccessKey和SecretKey(建议使用环境变量) +3. **文件上传路径**:确保`file.upload.path`配置的目录存在且有写权限 +4. **JWT密钥**:生产环境请修改`jwt.secret`配置 +5. **数据库初始化**:执行`schema.sql`创建数据库表和预设分类 + +## 下一步 + +1. 执行数据库脚本创建表结构 +2. 配置application.yml中的数据库和OCR信息 +3. 启动Spring Boot应用 +4. 访问API文档:http://localhost:8080/doc.html + + + + diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..ea22e28 --- /dev/null +++ b/pom.xml @@ -0,0 +1,165 @@ + + + 4.0.0 + + + org.springframework.boot + spring-boot-starter-parent + 3.1.5 + + + + com.accounting + accounting-backend + 1.0.0 + Accounting Backend + 记账应用后端服务 + + + 17 + 3.5.4 + 0.12.3 + 4.6.4 + 3.1.3 + 4.3.0 + 0.2.8 + + + + + + org.springframework.boot + spring-boot-starter-web + + + + + org.springframework.boot + spring-boot-starter-security + + + + + com.baomidou + mybatis-plus-boot-starter + ${mybatis-plus.version} + + + + + com.mysql + mysql-connector-j + runtime + + + + + io.jsonwebtoken + jjwt-api + ${jwt.version} + + + io.jsonwebtoken + jjwt-impl + ${jwt.version} + runtime + + + io.jsonwebtoken + jjwt-jackson + ${jwt.version} + runtime + + + + + com.aliyun + ocr_api20210707 + ${aliyun-sdk-ocr.version} + + + + com.aliyun + tea-openapi + 0.2.8 + + + com.aliyun + tea-console + 0.0.1 + + + com.aliyun + tea-util + 0.2.21 + + + + + org.projectlombok + lombok + true + + + + + org.springframework.boot + spring-boot-starter-validation + + + + + com.github.xiaoymin + knife4j-openapi3-jakarta-spring-boot-starter + ${knife4j.version} + + + + + commons-io + commons-io + 2.11.0 + + + + + com.alibaba.fastjson2 + fastjson2 + 2.0.43 + + + + + org.springframework.boot + spring-boot-starter-test + test + + + org.springframework.security + spring-security-test + test + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + org.projectlombok + lombok + + + + + + + + + diff --git a/src/main/java/com/accounting/AccountingApplication.java b/src/main/java/com/accounting/AccountingApplication.java new file mode 100644 index 0000000..88346f2 --- /dev/null +++ b/src/main/java/com/accounting/AccountingApplication.java @@ -0,0 +1,22 @@ +package com.accounting; + +import org.mybatis.spring.annotation.MapperScan; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +@MapperScan("com.accounting.mapper") +public class AccountingApplication { + public static void main(String[] args) { + SpringApplication.run(AccountingApplication.class, args); + } +} + + + + + + + + + diff --git a/src/main/java/com/accounting/config/AliyunOcrConfig.java b/src/main/java/com/accounting/config/AliyunOcrConfig.java new file mode 100644 index 0000000..697f341 --- /dev/null +++ b/src/main/java/com/accounting/config/AliyunOcrConfig.java @@ -0,0 +1,38 @@ +package com.accounting.config; + +import com.aliyun.ocr_api20210707.Client; +import com.aliyun.teaopenapi.models.Config; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class AliyunOcrConfig { + + @Value("${aliyun.ocr.access-key-id}") + private String accessKeyId; + + @Value("${aliyun.ocr.access-key-secret}") + private String accessKeySecret; + + @Value("${aliyun.ocr.endpoint}") + private String endpoint; + + @Bean + public Client ocrClient() throws Exception { + Config config = new Config() + .setAccessKeyId(accessKeyId) + .setAccessKeySecret(accessKeySecret) + .setEndpoint(endpoint); + return new Client(config); + } +} + + + + + + + + + diff --git a/src/main/java/com/accounting/config/JwtAuthenticationFilter.java b/src/main/java/com/accounting/config/JwtAuthenticationFilter.java new file mode 100644 index 0000000..463867b --- /dev/null +++ b/src/main/java/com/accounting/config/JwtAuthenticationFilter.java @@ -0,0 +1,66 @@ +package com.accounting.config; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.web.authentication.WebAuthenticationDetailsSource; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; + +@Component +public class JwtAuthenticationFilter extends OncePerRequestFilter { + + @Autowired + private JwtConfig jwtConfig; + + @Autowired + private UserDetailsService userDetailsService; + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) + throws ServletException, IOException { + + String authHeader = request.getHeader("Authorization"); + String token = null; + String username = null; + + if (authHeader != null && authHeader.startsWith("Bearer ")) { + token = authHeader.substring(7); + try { + username = jwtConfig.getUsernameFromToken(token); + } catch (Exception e) { + logger.error("JWT token解析失败", e); + } + } + + if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) { + UserDetails userDetails = userDetailsService.loadUserByUsername(username); + + if (jwtConfig.validateToken(token, username)) { + UsernamePasswordAuthenticationToken authenticationToken = + new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities()); + authenticationToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); + SecurityContextHolder.getContext().setAuthentication(authenticationToken); + } + } + + chain.doFilter(request, response); + } +} + + + + + + + + + diff --git a/src/main/java/com/accounting/config/JwtConfig.java b/src/main/java/com/accounting/config/JwtConfig.java new file mode 100644 index 0000000..792cfea --- /dev/null +++ b/src/main/java/com/accounting/config/JwtConfig.java @@ -0,0 +1,82 @@ +package com.accounting.config; + +import com.accounting.entity.Bill; +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.SignatureAlgorithm; +import io.jsonwebtoken.security.Keys; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import javax.crypto.SecretKey; +import java.util.Date; +import java.util.HashMap; +import java.util.Map; + +@Component +public class JwtConfig { + + @Value("${jwt.secret}") + private String secret; + + @Value("${jwt.expiration}") + private Long expiration; + + private SecretKey getSigningKey() { + return Keys.hmacShaKeyFor(secret.getBytes()); + } + + public String generateToken(String username) { + Map claims = new HashMap<>(); + claims.put("username", username); + return createToken(claims, username); + } + + private String createToken(Map claims, String subject) { + Date now = new Date(); + Date expiryDate = new Date(now.getTime() + expiration); + + return Jwts.builder() + .setClaims(claims) + .setSubject(subject) + .setIssuedAt(now) + .setExpiration(expiryDate) + .signWith(getSigningKey(), SignatureAlgorithm.HS512) + .compact(); + } + + public String getUsernameFromToken(String token) { + Claims claims = getClaimsFromToken(token); + return claims.getSubject(); + } + + public Date getExpirationDateFromToken(String token) { + Claims claims = getClaimsFromToken(token); + return claims.getExpiration(); + } + + + private Claims getClaimsFromToken(String token) { + return Jwts.parser() + .setSigningKey(getSigningKey()) + .build() + .parseClaimsJws(token) + .getBody(); + } + + public Boolean isTokenExpired(String token) { + Date expiration = getExpirationDateFromToken(token); + return expiration.before(new Date()); + } + + public Boolean validateToken(String token, String username) { + String tokenUsername = getUsernameFromToken(token); + return (tokenUsername.equals(username) && !isTokenExpired(token)); + } +} + + + + + + diff --git a/src/main/java/com/accounting/config/K780Config.java b/src/main/java/com/accounting/config/K780Config.java new file mode 100644 index 0000000..0a44447 --- /dev/null +++ b/src/main/java/com/accounting/config/K780Config.java @@ -0,0 +1,15 @@ +package com.accounting.config; + +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.context.annotation.Configuration; +import lombok.Data; + +@Data +@Configuration +@ConfigurationProperties(prefix = "k780") +public class K780Config { + private String apiUrl = "https://sapi.k780.com"; + private String appKey = "78346"; + private String sign = "1b502c535927b66d9b888a6d4701bf72"; + private int timeout = 5000; +} diff --git a/src/main/java/com/accounting/config/RestTemplateConfig.java b/src/main/java/com/accounting/config/RestTemplateConfig.java new file mode 100644 index 0000000..f7cad74 --- /dev/null +++ b/src/main/java/com/accounting/config/RestTemplateConfig.java @@ -0,0 +1,18 @@ +package com.accounting.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.client.SimpleClientHttpRequestFactory; +import org.springframework.web.client.RestTemplate; + +@Configuration +public class RestTemplateConfig { + + @Bean + public RestTemplate restTemplate() { + SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory(); + factory.setConnectTimeout(5000); + factory.setReadTimeout(5000); + return new RestTemplate(factory); + } +} diff --git a/src/main/java/com/accounting/config/SchedulerConfig.java b/src/main/java/com/accounting/config/SchedulerConfig.java new file mode 100644 index 0000000..796d41c --- /dev/null +++ b/src/main/java/com/accounting/config/SchedulerConfig.java @@ -0,0 +1,34 @@ +package com.accounting.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.lang.NonNull; +import org.springframework.scheduling.annotation.EnableScheduling; +import org.springframework.scheduling.annotation.SchedulingConfigurer; +import org.springframework.scheduling.config.ScheduledTaskRegistrar; + +import java.util.concurrent.Executor; +import java.util.concurrent.Executors; + +@Configuration +@EnableScheduling +public class SchedulerConfig implements SchedulingConfigurer { + + /** + * 配置定时任务为单线程执行,避免并发问题 + */ + @Override + public void configureTasks(@NonNull ScheduledTaskRegistrar taskRegistrar) { + taskRegistrar.setScheduler(taskExecutor()); + } + + @Bean(destroyMethod = "shutdown") + public Executor taskExecutor() { + return Executors.newSingleThreadScheduledExecutor(r -> { + Thread thread = new Thread(r, "gold-price-scheduler"); + thread.setDaemon(true); + return thread; + }); + } +} + diff --git a/src/main/java/com/accounting/config/SecurityConfig.java b/src/main/java/com/accounting/config/SecurityConfig.java new file mode 100644 index 0000000..9c4610f --- /dev/null +++ b/src/main/java/com/accounting/config/SecurityConfig.java @@ -0,0 +1,83 @@ +package com.accounting.config; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration; +import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.cors.CorsConfigurationSource; +import org.springframework.web.cors.UrlBasedCorsConfigurationSource; + +import java.util.Arrays; + +@Configuration +@EnableWebSecurity +@EnableMethodSecurity +public class SecurityConfig { + + @Autowired + private JwtAuthenticationFilter jwtAuthenticationFilter; + + @Bean + public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { + http + .csrf(csrf -> csrf.disable()) + .cors(cors -> cors.configurationSource(corsConfigurationSource())) + .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .authorizeHttpRequests(auth -> auth + .requestMatchers("/api/auth/**").permitAll() + .requestMatchers("/api/gold-price/**").permitAll() + .requestMatchers("/doc.html", "/swagger-ui/**", "/v3/api-docs/**", "/webjars/**").permitAll() + .anyRequest().authenticated() + ) + .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); + + return http.build(); + } + + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } + + @Bean + public AuthenticationManager authenticationManager(AuthenticationConfiguration authConfig) throws Exception { + return authConfig.getAuthenticationManager(); + } + + @Bean + public CorsConfigurationSource corsConfigurationSource() { + CorsConfiguration configuration = new CorsConfiguration(); + // 允许你的前端域名访问,根据实际情况修改 + configuration.setAllowedOrigins(Arrays.asList( + "https://accounting.aqroid.cn", + "http://localhost:5173" + )); + configuration.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "DELETE", "OPTIONS", "HEAD")); + configuration.setAllowedHeaders(Arrays.asList("*")); + configuration.setAllowCredentials(true); // 允许携带认证信息 + configuration.setMaxAge(3600L); + + UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); + source.registerCorsConfiguration("/**", configuration); + return source; + } +} + + + + + + + + + diff --git a/src/main/java/com/accounting/config/UserDetailsServiceImpl.java b/src/main/java/com/accounting/config/UserDetailsServiceImpl.java new file mode 100644 index 0000000..b1db621 --- /dev/null +++ b/src/main/java/com/accounting/config/UserDetailsServiceImpl.java @@ -0,0 +1,45 @@ +package com.accounting.config; + +import com.accounting.entity.User; +import com.accounting.mapper.UserMapper; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.stereotype.Service; + +import java.util.ArrayList; + +@Service +public class UserDetailsServiceImpl implements UserDetailsService { + + @Autowired + private UserMapper userMapper; + + @Override + public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { + User user = userMapper.selectOne( + new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper() + .eq(User::getUsername, username) + ); + + if (user == null) { + throw new UsernameNotFoundException("用户不存在: " + username); + } + + return new org.springframework.security.core.userdetails.User( + user.getUsername(), + user.getPassword(), + new ArrayList<>() + ); + } +} + + + + + + + + + diff --git a/src/main/java/com/accounting/controller/AccountController.java b/src/main/java/com/accounting/controller/AccountController.java new file mode 100644 index 0000000..df42df3 --- /dev/null +++ b/src/main/java/com/accounting/controller/AccountController.java @@ -0,0 +1,65 @@ +package com.accounting.controller; + +import com.accounting.dto.AccountRequest; +import com.accounting.dto.AccountResponse; +import com.accounting.entity.User; +import com.accounting.mapper.UserMapper; +import com.accounting.service.AccountService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.web.bind.annotation.*; + +@Tag(name = "账户管理", description = "账户管理接口") +@RestController +@RequestMapping("/api/accounts") +public class AccountController { + + @Autowired + private AccountService accountService; + + @Autowired + private UserMapper userMapper; + + @Operation(summary = "获取账户信息") + @GetMapping + public ResponseEntity getAccount(Authentication authentication) { + Long userId = getUserId(authentication); + AccountResponse response = accountService.getAccountBalance(userId); + return ResponseEntity.ok(response); + } + + @Operation(summary = "更新账户信息") + @PutMapping + public ResponseEntity updateAccount( + @RequestBody AccountRequest request, + Authentication authentication) { + Long userId = getUserId(authentication); + AccountResponse response = accountService.updateAccount(userId, request); + return ResponseEntity.ok(response); + } + + @Operation(summary = "更新账户余额") + @PutMapping("/balance") + public ResponseEntity updateAccountBalance( + @RequestBody AccountRequest request, + Authentication authentication) { + Long userId = getUserId(authentication); + AccountResponse response = accountService.updateAccountBalance(userId, request.getInitialBalance()); + return ResponseEntity.ok(response); + } + + private Long getUserId(Authentication authentication) { + UserDetails userDetails = (UserDetails) authentication.getPrincipal(); + String username = userDetails.getUsername(); + User user = userMapper.selectOne( + new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper() + .eq(User::getUsername, username) + ); + return user != null ? user.getId() : null; + } +} + diff --git a/src/main/java/com/accounting/controller/AuthController.java b/src/main/java/com/accounting/controller/AuthController.java new file mode 100644 index 0000000..3257032 --- /dev/null +++ b/src/main/java/com/accounting/controller/AuthController.java @@ -0,0 +1,44 @@ +package com.accounting.controller; + +import com.accounting.dto.AuthResponse; +import com.accounting.dto.LoginRequest; +import com.accounting.dto.RegisterRequest; +import com.accounting.service.AuthService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +@Tag(name = "认证管理", description = "用户注册、登录接口") +@RestController +@RequestMapping("/api/auth") +public class AuthController { + + @Autowired + private AuthService authService; + + @Operation(summary = "用户注册") + @PostMapping("/register") + public ResponseEntity register(@Valid @RequestBody RegisterRequest request) { + AuthResponse response = authService.register(request); + return ResponseEntity.ok(response); + } + + @Operation(summary = "用户登录") + @PostMapping("/login") + public ResponseEntity login(@Valid @RequestBody LoginRequest request) { + AuthResponse response = authService.login(request); + return ResponseEntity.ok(response); + } +} + + + + + + + + + diff --git a/src/main/java/com/accounting/controller/BillController.java b/src/main/java/com/accounting/controller/BillController.java new file mode 100644 index 0000000..2742aad --- /dev/null +++ b/src/main/java/com/accounting/controller/BillController.java @@ -0,0 +1,97 @@ +package com.accounting.controller; + +import com.accounting.dto.BillRequest; +import com.accounting.dto.BillResponse; +import com.accounting.dto.BatchBillRequest; +import com.accounting.entity.User; +import com.accounting.mapper.UserMapper; +import com.accounting.service.BillService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.format.annotation.DateTimeFormat; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.web.bind.annotation.*; + +import java.time.LocalDate; +import java.util.List; + +@Tag(name = "账单管理", description = "账单CRUD接口") +@RestController +@RequestMapping("/api/bills") +public class BillController { + + @Autowired + private BillService billService; + + @Autowired + private UserMapper userMapper; + + @Operation(summary = "创建账单") + @PostMapping + public ResponseEntity createBill(@Valid @RequestBody BillRequest request, Authentication authentication) { + Long userId = getUserId(authentication); + BillResponse response = billService.createBill(request, userId); + return ResponseEntity.ok(response); + } + + @Operation(summary = "批量创建账单") + @PostMapping("/batch") + public ResponseEntity> createBills(@Valid @RequestBody BatchBillRequest request, Authentication authentication) { + Long userId = getUserId(authentication); + List responses = billService.createBills(request, userId); + return ResponseEntity.ok(responses); + } + + @Operation(summary = "更新账单") + @PutMapping("/{id}") + public ResponseEntity updateBill(@PathVariable Long id, @Valid @RequestBody BillRequest request, Authentication authentication) { + Long userId = getUserId(authentication); + BillResponse response = billService.updateBill(id, request, userId); + return ResponseEntity.ok(response); + } + + @Operation(summary = "删除账单") + @DeleteMapping("/{id}") + public ResponseEntity deleteBill(@PathVariable Long id, Authentication authentication) { + Long userId = getUserId(authentication); + billService.deleteBill(id, userId); + return ResponseEntity.ok().build(); + } + + @Operation(summary = "获取账单详情") + @GetMapping("/{id}") + public ResponseEntity getBill(@PathVariable Long id, Authentication authentication) { + Long userId = getUserId(authentication); + BillResponse response = billService.getBill(id, userId); + return ResponseEntity.ok(response); + } + + @Operation(summary = "获取账单列表") + @GetMapping + public ResponseEntity> getBills( + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate startDate, + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate endDate, + Authentication authentication) { + Long userId = getUserId(authentication); + List bills = billService.getBills(userId, startDate, endDate); + return ResponseEntity.ok(bills); + } + + private Long getUserId(Authentication authentication) { + UserDetails userDetails = (UserDetails) authentication.getPrincipal(); + String username = userDetails.getUsername(); + User user = userMapper.selectOne( + new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper() + .eq(User::getUsername, username) + ); + return user != null ? user.getId() : null; + } +} + + + + diff --git a/src/main/java/com/accounting/controller/BudgetController.java b/src/main/java/com/accounting/controller/BudgetController.java new file mode 100644 index 0000000..f0a707f --- /dev/null +++ b/src/main/java/com/accounting/controller/BudgetController.java @@ -0,0 +1,80 @@ +package com.accounting.controller; + +import com.accounting.dto.BudgetRequest; +import com.accounting.dto.BudgetResponse; +import com.accounting.dto.BudgetSettlementResponse; +import com.accounting.entity.User; +import com.accounting.mapper.UserMapper; +import com.accounting.service.BudgetService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.web.bind.annotation.*; + +import java.time.LocalDate; + +@Tag(name = "预算管理", description = "预算管理接口") +@RestController +@RequestMapping("/api/budgets") +public class BudgetController { + + @Autowired + private BudgetService budgetService; + + @Autowired + private UserMapper userMapper; + + @Operation(summary = "获取当前月份的预算信息") + @GetMapping + public ResponseEntity getBudget(Authentication authentication) { + Long userId = getUserId(authentication); + LocalDate today = LocalDate.now(); + BudgetResponse response = budgetService.getBudgetWithStatistics(userId, today.getYear(), today.getMonthValue()); + return ResponseEntity.ok(response); + } + + @Operation(summary = "获取上月预算结算信息") + @GetMapping("/settlement") + public ResponseEntity getBudgetSettlement(Authentication authentication) { + Long userId = getUserId(authentication); + BudgetSettlementResponse response = budgetService.getLastMonthSettlement(userId); + return ResponseEntity.ok(response); + } + + @Operation(summary = "设置/更新预算") + @PutMapping + public ResponseEntity setBudget( + @RequestBody BudgetRequest request, + Authentication authentication) { + Long userId = getUserId(authentication); + BudgetResponse response = budgetService.setBudget(userId, request); + return ResponseEntity.ok(response); + } + + @Operation(summary = "更新当前月份预算") + @PutMapping("/current") + public ResponseEntity updateCurrentBudget( + @RequestBody BudgetRequest request, + Authentication authentication) { + Long userId = getUserId(authentication); + LocalDate today = LocalDate.now(); + request.setYear(today.getYear()); + request.setMonth(today.getMonthValue()); + BudgetResponse response = budgetService.setBudget(userId, request); + return ResponseEntity.ok(response); + } + + private Long getUserId(Authentication authentication) { + UserDetails userDetails = (UserDetails) authentication.getPrincipal(); + String username = userDetails.getUsername(); + User user = userMapper.selectOne( + new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper() + .eq(User::getUsername, username) + ); + return user != null ? user.getId() : null; + } +} + diff --git a/src/main/java/com/accounting/controller/CategoryController.java b/src/main/java/com/accounting/controller/CategoryController.java new file mode 100644 index 0000000..29ccd68 --- /dev/null +++ b/src/main/java/com/accounting/controller/CategoryController.java @@ -0,0 +1,77 @@ +package com.accounting.controller; + +import com.accounting.dto.CategoryRequest; +import com.accounting.dto.CategoryResponse; +import com.accounting.entity.User; +import com.accounting.mapper.UserMapper; +import com.accounting.service.CategoryService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@Tag(name = "分类管理", description = "分类CRUD接口") +@RestController +@RequestMapping("/api/categories") +public class CategoryController { + + @Autowired + private CategoryService categoryService; + + @Autowired + private UserMapper userMapper; + + @Operation(summary = "获取分类列表") + @GetMapping + public ResponseEntity> getCategories( + @RequestParam(required = false) Integer type, + Authentication authentication) { + Long userId = getUserId(authentication); + List categories = categoryService.getCategories(userId, type); + return ResponseEntity.ok(categories); + } + + @Operation(summary = "创建分类") + @PostMapping + public ResponseEntity createCategory(@Valid @RequestBody CategoryRequest request, Authentication authentication) { + Long userId = getUserId(authentication); + CategoryResponse response = categoryService.createCategory(request, userId); + return ResponseEntity.ok(response); + } + + @Operation(summary = "更新分类") + @PutMapping("/{id}") + public ResponseEntity updateCategory(@PathVariable Long id, @Valid @RequestBody CategoryRequest request, Authentication authentication) { + Long userId = getUserId(authentication); + CategoryResponse response = categoryService.updateCategory(id, request, userId); + return ResponseEntity.ok(response); + } + + @Operation(summary = "删除分类") + @DeleteMapping("/{id}") + public ResponseEntity deleteCategory(@PathVariable Long id, Authentication authentication) { + Long userId = getUserId(authentication); + categoryService.deleteCategory(id, userId); + return ResponseEntity.ok().build(); + } + + private Long getUserId(Authentication authentication) { + UserDetails userDetails = (UserDetails) authentication.getPrincipal(); + String username = userDetails.getUsername(); + User user = userMapper.selectOne( + new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper() + .eq(User::getUsername, username) + ); + return user != null ? user.getId() : null; + } +} + + + + diff --git a/src/main/java/com/accounting/controller/GoldPriceController.java b/src/main/java/com/accounting/controller/GoldPriceController.java new file mode 100644 index 0000000..77bc450 --- /dev/null +++ b/src/main/java/com/accounting/controller/GoldPriceController.java @@ -0,0 +1,140 @@ +package com.accounting.controller; + +import com.accounting.dto.GoldPriceResponse; +import com.accounting.service.GoldPriceService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +@Tag(name = "黄金价格", description = "黄金价格查询接口") +@RestController +@RequestMapping("/api/gold-price") +@RequiredArgsConstructor +public class GoldPriceController { + + private final GoldPriceService goldPriceService; + + private static final String DEFAULT_GOLD_ID = "1053"; + + @Operation(summary = "获取当前黄金价格") + @GetMapping("/current") + public ResponseEntity> getCurrentPrice( + @RequestParam(required = false, defaultValue = DEFAULT_GOLD_ID) String goldId) { + try { + GoldPriceResponse data = goldPriceService.getCurrentPrice(goldId); + Map response = new HashMap<>(); + response.put("code", 200); + if (data == null) { + response.put("message", "暂无黄金价格数据,请稍后再试"); + response.put("data", null); + } else { + response.put("message", "success"); + response.put("data", data); + } + return ResponseEntity.ok(response); + } catch (Exception e) { + Map response = new HashMap<>(); + response.put("code", 500); + response.put("message", e.getMessage()); + response.put("data", null); + return ResponseEntity.ok(response); + } + } + + @Operation(summary = "获取历史黄金价格") + @GetMapping("/history") + public ResponseEntity> getHistoryPrices( + @RequestParam(required = false, defaultValue = DEFAULT_GOLD_ID) String goldId, + @RequestParam(required = false, defaultValue = "30") Integer days) { + try { + List data = goldPriceService.getHistoryPrices(goldId, days); + Map response = new HashMap<>(); + response.put("code", 200); + response.put("message", "success"); + response.put("data", data); + return ResponseEntity.ok(response); + } catch (Exception e) { + Map response = new HashMap<>(); + response.put("code", 500); + response.put("message", e.getMessage()); + response.put("data", null); + return ResponseEntity.ok(response); + } + } + + @Operation(summary = "获取指定日期的黄金价格记录") + @GetMapping("/by-date") + public ResponseEntity> getPricesByDate( + @RequestParam(required = false, defaultValue = DEFAULT_GOLD_ID) String goldId, + @RequestParam(required = true) String date) { + try { + LocalDate localDate = LocalDate.parse(date, DateTimeFormatter.ofPattern("yyyy-MM-dd")); + List data = goldPriceService.getPricesByDate(goldId, localDate); + Map response = new HashMap<>(); + response.put("code", 200); + response.put("message", "success"); + response.put("data", data); + return ResponseEntity.ok(response); + } catch (Exception e) { + Map response = new HashMap<>(); + response.put("code", 500); + response.put("message", e.getMessage()); + response.put("data", null); + return ResponseEntity.ok(response); + } + } + + @Operation(summary = "获取最近有数据的日期") + @GetMapping("/latest-date") + public ResponseEntity> getLatestDate( + @RequestParam(required = false, defaultValue = DEFAULT_GOLD_ID) String goldId) { + try { + LocalDate latestDate = goldPriceService.getLatestDate(goldId); + Map response = new HashMap<>(); + response.put("code", 200); + response.put("message", "success"); + if (latestDate != null) { + response.put("data", latestDate.format(DateTimeFormatter.ofPattern("yyyy-MM-dd"))); + } else { + response.put("data", null); + } + return ResponseEntity.ok(response); + } catch (Exception e) { + Map response = new HashMap<>(); + response.put("code", 500); + response.put("message", e.getMessage()); + response.put("data", null); + return ResponseEntity.ok(response); + } + } + + @Operation(summary = "立即刷新黄金价格") + @PostMapping("/refresh") + public ResponseEntity> refreshPrice( + @RequestParam(required = false, defaultValue = DEFAULT_GOLD_ID) String goldId) { + try { + goldPriceService.refreshCurrentPrice(goldId); + // 刷新后返回最新的价格 + GoldPriceResponse data = goldPriceService.getCurrentPrice(goldId); + Map response = new HashMap<>(); + response.put("code", 200); + response.put("message", "刷新成功"); + response.put("data", data); + return ResponseEntity.ok(response); + } catch (Exception e) { + Map response = new HashMap<>(); + response.put("code", 500); + response.put("message", e.getMessage()); + response.put("data", null); + return ResponseEntity.ok(response); + } + } +} diff --git a/src/main/java/com/accounting/controller/OcrController.java b/src/main/java/com/accounting/controller/OcrController.java new file mode 100644 index 0000000..44a2f62 --- /dev/null +++ b/src/main/java/com/accounting/controller/OcrController.java @@ -0,0 +1,59 @@ +package com.accounting.controller; + +import com.accounting.dto.OcrResponse; +import com.accounting.entity.OcrRecord; +import com.accounting.entity.User; +import com.accounting.mapper.OcrRecordMapper; +import com.accounting.mapper.UserMapper; +import com.accounting.service.OcrService; +import com.accounting.util.OcrAmountParser; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; + +import java.util.List; + +@Tag(name = "OCR识别", description = "OCR识别账单接口") +@RestController +@RequestMapping("/api/ocr") +public class OcrController { + + @Autowired + private OcrService ocrService; + + @Autowired + private OcrRecordMapper ocrRecordMapper; + + @Autowired + private UserMapper userMapper; + + @Operation(summary = "识别图片中的账单信息") + @PostMapping("/recognize") + public List recognize(@RequestParam("file") MultipartFile file, Authentication authentication) throws Exception { + + // 获取当前用户ID + UserDetails userDetails = (UserDetails) authentication.getPrincipal(); + String username = userDetails.getUsername(); + User user = userMapper.selectOne( + new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper() + .eq(User::getUsername, username) + ); + + if (user == null) { + throw new RuntimeException("用户不存在"); + } + + Long userId = user.getId(); + + // 调用OCR服务 + List parseResults = ocrService.recognizeImage(file, userId); + return parseResults; + + + } +} + diff --git a/src/main/java/com/accounting/controller/StatisticsController.java b/src/main/java/com/accounting/controller/StatisticsController.java new file mode 100644 index 0000000..2a2e4f2 --- /dev/null +++ b/src/main/java/com/accounting/controller/StatisticsController.java @@ -0,0 +1,74 @@ +package com.accounting.controller; + +import com.accounting.dto.StatisticsResponse; +import com.accounting.entity.User; +import com.accounting.mapper.UserMapper; +import com.accounting.service.StatisticsService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.format.annotation.DateTimeFormat; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.web.bind.annotation.*; + +import java.time.LocalDate; + +@Tag(name = "统计查询", description = "统计查询接口") +@RestController +@RequestMapping("/api/statistics") +public class StatisticsController { + + @Autowired + private StatisticsService statisticsService; + + @Autowired + private UserMapper userMapper; + + @Operation(summary = "按日期范围统计") + @GetMapping("/daily") + public ResponseEntity getDailyStatistics( + @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate startDate, + @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate endDate, + Authentication authentication) { + Long userId = getUserId(authentication); + StatisticsResponse response = statisticsService.getDailyStatistics(userId, startDate, endDate); + return ResponseEntity.ok(response); + } + + @Operation(summary = "按周统计") + @GetMapping("/weekly") + public ResponseEntity getWeeklyStatistics( + @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate weekStart, + Authentication authentication) { + Long userId = getUserId(authentication); + StatisticsResponse response = statisticsService.getWeeklyStatistics(userId, weekStart); + return ResponseEntity.ok(response); + } + + @Operation(summary = "按月统计") + @GetMapping("/monthly") + public ResponseEntity getMonthlyStatistics( + @RequestParam int year, + @RequestParam int month, + Authentication authentication) { + Long userId = getUserId(authentication); + StatisticsResponse response = statisticsService.getMonthlyStatistics(userId, year, month); + return ResponseEntity.ok(response); + } + + private Long getUserId(Authentication authentication) { + UserDetails userDetails = (UserDetails) authentication.getPrincipal(); + String username = userDetails.getUsername(); + User user = userMapper.selectOne( + new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper() + .eq(User::getUsername, username) + ); + return user != null ? user.getId() : null; + } +} + + + + diff --git a/src/main/java/com/accounting/dto/AccountRequest.java b/src/main/java/com/accounting/dto/AccountRequest.java new file mode 100644 index 0000000..9c25cf3 --- /dev/null +++ b/src/main/java/com/accounting/dto/AccountRequest.java @@ -0,0 +1,13 @@ +package com.accounting.dto; + +import lombok.Data; + +import java.math.BigDecimal; + +@Data +public class AccountRequest { + private String name; + + private BigDecimal initialBalance; +} + diff --git a/src/main/java/com/accounting/dto/AccountResponse.java b/src/main/java/com/accounting/dto/AccountResponse.java new file mode 100644 index 0000000..855daeb --- /dev/null +++ b/src/main/java/com/accounting/dto/AccountResponse.java @@ -0,0 +1,26 @@ +package com.accounting.dto; + +import lombok.Data; + +import java.math.BigDecimal; +import java.time.LocalDateTime; + +@Data +public class AccountResponse { + private Long id; + + private String name; + + private BigDecimal initialBalance; + + private BigDecimal balance; // 计算后的余额(初始余额 + 收入 - 支出) + + private BigDecimal totalIncome; // 总收入 + + private BigDecimal totalExpense; // 总支出 + + private LocalDateTime createTime; + + private LocalDateTime updateTime; +} + diff --git a/src/main/java/com/accounting/dto/AuthResponse.java b/src/main/java/com/accounting/dto/AuthResponse.java new file mode 100644 index 0000000..9a4e240 --- /dev/null +++ b/src/main/java/com/accounting/dto/AuthResponse.java @@ -0,0 +1,25 @@ +package com.accounting.dto; + +import lombok.Data; + +@Data +public class AuthResponse { + private String token; + private String username; + private String nickname; + + public AuthResponse(String token, String username, String nickname) { + this.token = token; + this.username = username; + this.nickname = nickname; + } +} + + + + + + + + + diff --git a/src/main/java/com/accounting/dto/BatchBillRequest.java b/src/main/java/com/accounting/dto/BatchBillRequest.java new file mode 100644 index 0000000..51bc7ff --- /dev/null +++ b/src/main/java/com/accounting/dto/BatchBillRequest.java @@ -0,0 +1,19 @@ +package com.accounting.dto; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotEmpty; +import java.util.List; + +public class BatchBillRequest { + @NotEmpty(message = "账单列表不能为空") + @Valid + private List bills; + + public List getBills() { + return bills; + } + + public void setBills(List bills) { + this.bills = bills; + } +} \ No newline at end of file diff --git a/src/main/java/com/accounting/dto/BillRequest.java b/src/main/java/com/accounting/dto/BillRequest.java new file mode 100644 index 0000000..d26933b --- /dev/null +++ b/src/main/java/com/accounting/dto/BillRequest.java @@ -0,0 +1,27 @@ +package com.accounting.dto; + +import jakarta.validation.constraints.NotNull; +import lombok.Data; + +import java.math.BigDecimal; +import java.time.LocalDate; + +@Data +public class BillRequest { + @NotNull(message = "分类ID不能为空") + private Long categoryId; + + @NotNull(message = "金额不能为空") + private BigDecimal amount; + + private String description; + + private LocalDate billDate; + + private String imageUrl; + + /** + * 账单类型:1-支出,2-收入 + */ + private Integer type; +} diff --git a/src/main/java/com/accounting/dto/BillResponse.java b/src/main/java/com/accounting/dto/BillResponse.java new file mode 100644 index 0000000..1957b99 --- /dev/null +++ b/src/main/java/com/accounting/dto/BillResponse.java @@ -0,0 +1,27 @@ +package com.accounting.dto; + +import lombok.Data; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalDateTime; + +@Data +public class BillResponse { + private Long id; + private Long categoryId; + private String categoryName; + private String categoryIcon; + private BigDecimal amount; + private String description; + private LocalDate billDate; + private String imageUrl; + + /** + * 账单类型:1-支出,2-收入 + */ + private Integer type; + + private LocalDateTime createTime; + private LocalDateTime updateTime; +} diff --git a/src/main/java/com/accounting/dto/BudgetRequest.java b/src/main/java/com/accounting/dto/BudgetRequest.java new file mode 100644 index 0000000..6ef59b9 --- /dev/null +++ b/src/main/java/com/accounting/dto/BudgetRequest.java @@ -0,0 +1,15 @@ +package com.accounting.dto; + +import lombok.Data; + +import java.math.BigDecimal; + +@Data +public class BudgetRequest { + private Integer year; + + private Integer month; + + private BigDecimal amount; +} + diff --git a/src/main/java/com/accounting/dto/BudgetResponse.java b/src/main/java/com/accounting/dto/BudgetResponse.java new file mode 100644 index 0000000..2612e25 --- /dev/null +++ b/src/main/java/com/accounting/dto/BudgetResponse.java @@ -0,0 +1,23 @@ +package com.accounting.dto; + +import lombok.Data; + +import java.math.BigDecimal; + +@Data +public class BudgetResponse { + private Long id; + + private Integer year; + + private Integer month; + + private BigDecimal amount; // 预算金额 + + private BigDecimal usedAmount; // 已用金额(本月支出) + + private BigDecimal remainingAmount; // 剩余预算 + + private BigDecimal remainingDaily; // 剩余日均 +} + diff --git a/src/main/java/com/accounting/dto/BudgetSettlementResponse.java b/src/main/java/com/accounting/dto/BudgetSettlementResponse.java new file mode 100644 index 0000000..47215e0 --- /dev/null +++ b/src/main/java/com/accounting/dto/BudgetSettlementResponse.java @@ -0,0 +1,23 @@ +package com.accounting.dto; + +import lombok.Data; + +import java.math.BigDecimal; + +@Data +public class BudgetSettlementResponse { + private Integer year; // 年份 + + private Integer month; // 月份 + + private BigDecimal budgetAmount; // 预算金额 + + private BigDecimal actualExpense; // 实际支出 + + private Boolean isOverBudget; // 是否超支 + + private BigDecimal overAmount; // 超支金额(如果超支) + + private BigDecimal completionRate; // 完成率(实际支出/预算金额,如果预算为0则为null) +} + diff --git a/src/main/java/com/accounting/dto/CategoryRequest.java b/src/main/java/com/accounting/dto/CategoryRequest.java new file mode 100644 index 0000000..aaf4be2 --- /dev/null +++ b/src/main/java/com/accounting/dto/CategoryRequest.java @@ -0,0 +1,25 @@ +package com.accounting.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import lombok.Data; + +@Data +public class CategoryRequest { + @NotBlank(message = "分类名称不能为空") + private String name; + + private String icon; + + @NotNull(message = "类型不能为空") + private Integer type; // 1-支出,2-收入 +} + + + + + + + + + diff --git a/src/main/java/com/accounting/dto/CategoryResponse.java b/src/main/java/com/accounting/dto/CategoryResponse.java new file mode 100644 index 0000000..fc6e9ed --- /dev/null +++ b/src/main/java/com/accounting/dto/CategoryResponse.java @@ -0,0 +1,24 @@ +package com.accounting.dto; + +import lombok.Data; + +import java.time.LocalDateTime; + +@Data +public class CategoryResponse { + private Long id; + private String name; + private String icon; + private Integer type; + private Integer sortOrder; + private LocalDateTime createTime; +} + + + + + + + + + diff --git a/src/main/java/com/accounting/dto/GoldPriceResponse.java b/src/main/java/com/accounting/dto/GoldPriceResponse.java new file mode 100644 index 0000000..a990779 --- /dev/null +++ b/src/main/java/com/accounting/dto/GoldPriceResponse.java @@ -0,0 +1,30 @@ +package com.accounting.dto; + +import lombok.Data; + +import java.math.BigDecimal; + +@Data +public class GoldPriceResponse { + private String goldId; + + private String goldName; + + private BigDecimal price; + + private BigDecimal priceChange; + + private BigDecimal priceChangePercent; + + private BigDecimal highPrice; + + private BigDecimal lowPrice; + + private BigDecimal openPrice; + + private BigDecimal yesterdayClose; + + private String updateTime; + + private String priceDate; +} diff --git a/src/main/java/com/accounting/dto/K780Response.java b/src/main/java/com/accounting/dto/K780Response.java new file mode 100644 index 0000000..3251a68 --- /dev/null +++ b/src/main/java/com/accounting/dto/K780Response.java @@ -0,0 +1,16 @@ +package com.accounting.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Data; + +@Data +public class K780Response { + @JsonProperty("success") + private String success; + + @JsonProperty("result") + private K780Result result; + + @JsonProperty("msg") + private String message; +} \ No newline at end of file diff --git a/src/main/java/com/accounting/dto/K780Result.java b/src/main/java/com/accounting/dto/K780Result.java new file mode 100644 index 0000000..bfb5152 --- /dev/null +++ b/src/main/java/com/accounting/dto/K780Result.java @@ -0,0 +1,60 @@ +package com.accounting.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Data; + +import java.util.Map; + +@Data +public class K780Result { + @JsonProperty("dtQuery") + private String dtQuery; + + @JsonProperty("dtCount") + private String dtCount; + + @JsonProperty("dtList") + private Map dtList; + + @Data + public static class GoldPriceData { + @JsonProperty("goldid") + private String goldId; + + @JsonProperty("variety") + private String variety; + + @JsonProperty("varietynm") + private String varietynm; + + @JsonProperty("last_price") + private String lastPrice; + + @JsonProperty("buy_price") + private String buyPrice; + + @JsonProperty("sell_price") + private String sellPrice; + + @JsonProperty("open_price") + private String openPrice; + + @JsonProperty("yesy_price") + private String yesyPrice; + + @JsonProperty("high_price") + private String highPrice; + + @JsonProperty("low_price") + private String lowPrice; + + @JsonProperty("change_price") + private String changePrice; + + @JsonProperty("change_margin") + private String changeMargin; + + @JsonProperty("uptime") + private String uptime; + } +} \ No newline at end of file diff --git a/src/main/java/com/accounting/dto/LoginRequest.java b/src/main/java/com/accounting/dto/LoginRequest.java new file mode 100644 index 0000000..e9f5012 --- /dev/null +++ b/src/main/java/com/accounting/dto/LoginRequest.java @@ -0,0 +1,22 @@ +package com.accounting.dto; + +import jakarta.validation.constraints.NotBlank; +import lombok.Data; + +@Data +public class LoginRequest { + @NotBlank(message = "用户名不能为空") + private String username; + + @NotBlank(message = "密码不能为空") + private String password; +} + + + + + + + + + diff --git a/src/main/java/com/accounting/dto/OcrResponse.java b/src/main/java/com/accounting/dto/OcrResponse.java new file mode 100644 index 0000000..9e45660 --- /dev/null +++ b/src/main/java/com/accounting/dto/OcrResponse.java @@ -0,0 +1,35 @@ +package com.accounting.dto; + +import lombok.Data; + +import java.math.BigDecimal; +import java.time.LocalDate; + +@Data +public class OcrResponse { + private BigDecimal amount; + private String merchant; + private LocalDate date; + private BigDecimal confidence; + private String imageUrl; + private Long ocrRecordId; + + public OcrResponse(BigDecimal amount, String merchant, LocalDate date, + BigDecimal confidence, String imageUrl, Long ocrRecordId) { + this.amount = amount; + this.merchant = merchant; + this.date = date; + this.confidence = confidence; + this.imageUrl = imageUrl; + this.ocrRecordId = ocrRecordId; + } +} + + + + + + + + + diff --git a/src/main/java/com/accounting/dto/RegisterRequest.java b/src/main/java/com/accounting/dto/RegisterRequest.java new file mode 100644 index 0000000..cdb2664 --- /dev/null +++ b/src/main/java/com/accounting/dto/RegisterRequest.java @@ -0,0 +1,27 @@ +package com.accounting.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import lombok.Data; + +@Data +public class RegisterRequest { + @NotBlank(message = "用户名不能为空") + @Size(min = 3, max = 20, message = "用户名长度必须在3-20个字符之间") + private String username; + + @NotBlank(message = "密码不能为空") + @Size(min = 6, max = 20, message = "密码长度必须在6-20个字符之间") + private String password; + + private String nickname; +} + + + + + + + + + diff --git a/src/main/java/com/accounting/dto/StatisticsResponse.java b/src/main/java/com/accounting/dto/StatisticsResponse.java new file mode 100644 index 0000000..4603d00 --- /dev/null +++ b/src/main/java/com/accounting/dto/StatisticsResponse.java @@ -0,0 +1,40 @@ +package com.accounting.dto; + +import lombok.Data; + +import java.math.BigDecimal; +import java.util.List; + +@Data +public class StatisticsResponse { + private BigDecimal totalIncome; // 总收入 + private BigDecimal totalExpense; // 总支出 + private BigDecimal balance; // 余额 + private List categoryStatistics; // 分类统计 + private List dailyStatistics; // 每日统计 + + @Data + public static class CategoryStatistics { + private Long categoryId; + private String categoryName; + private String categoryIcon; + private BigDecimal amount; + private Integer type; + } + + @Data + public static class DailyStatistics { + private String date; + private BigDecimal income; + private BigDecimal expense; + } +} + + + + + + + + + diff --git a/src/main/java/com/accounting/entity/Account.java b/src/main/java/com/accounting/entity/Account.java new file mode 100644 index 0000000..e638f4e --- /dev/null +++ b/src/main/java/com/accounting/entity/Account.java @@ -0,0 +1,27 @@ +package com.accounting.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; + +@Data +@TableName("account") +public class Account { + @TableId(type = IdType.AUTO) + private Long id; + + private Long userId; + + private String name; + + private BigDecimal initialBalance; + + private LocalDateTime createTime; + + private LocalDateTime updateTime; +} + diff --git a/src/main/java/com/accounting/entity/Bill.java b/src/main/java/com/accounting/entity/Bill.java new file mode 100644 index 0000000..d1b02c2 --- /dev/null +++ b/src/main/java/com/accounting/entity/Bill.java @@ -0,0 +1,40 @@ +package com.accounting.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.LocalDate; +import java.time.LocalDateTime; + +@Data +@TableName("bill") +public class Bill { + @TableId(type = IdType.AUTO) + private Long id; + + private Long userId; + + private Long accountId; + + private Long categoryId; + + private BigDecimal amount; + + private String description; + + private LocalDate billDate; + + private String imageUrl; + + /** + * 账单类型:1-支出,2-收入 + */ + private Integer type; + + private LocalDateTime createTime; + + private LocalDateTime updateTime; +} diff --git a/src/main/java/com/accounting/entity/Budget.java b/src/main/java/com/accounting/entity/Budget.java new file mode 100644 index 0000000..e004322 --- /dev/null +++ b/src/main/java/com/accounting/entity/Budget.java @@ -0,0 +1,29 @@ +package com.accounting.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; + +@Data +@TableName("budget") +public class Budget { + @TableId(type = IdType.AUTO) + private Long id; + + private Long userId; + + private Integer year; + + private Integer month; + + private BigDecimal amount; + + private LocalDateTime createTime; + + private LocalDateTime updateTime; +} + diff --git a/src/main/java/com/accounting/entity/Category.java b/src/main/java/com/accounting/entity/Category.java new file mode 100644 index 0000000..7acf962 --- /dev/null +++ b/src/main/java/com/accounting/entity/Category.java @@ -0,0 +1,38 @@ +package com.accounting.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; + +@Data +@TableName("category") +public class Category { + @TableId(type = IdType.AUTO) + private Long id; + + private Long userId; // NULL表示系统预设分类 + + private String name; + + private String icon; + + private Integer type; // 1-支出,2-收入 + + private Integer sortOrder; + + private LocalDateTime createTime; + + private LocalDateTime updateTime; +} + + + + + + + + + diff --git a/src/main/java/com/accounting/entity/GoldPrice.java b/src/main/java/com/accounting/entity/GoldPrice.java new file mode 100644 index 0000000..22482a4 --- /dev/null +++ b/src/main/java/com/accounting/entity/GoldPrice.java @@ -0,0 +1,41 @@ +package com.accounting.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.LocalDate; +import java.time.LocalDateTime; + +@Data +@TableName("gold_price") +public class GoldPrice { + @TableId(type = IdType.AUTO) + private Long id; + + private String goldId; + + private String goldName; + + private BigDecimal price; + + private BigDecimal priceChange; + + private BigDecimal priceChangePercent; + + private BigDecimal highPrice; + + private BigDecimal lowPrice; + + private BigDecimal openPrice; + + private BigDecimal yesterdayClose; + + private LocalDateTime updateTime; + + private LocalDate priceDate; + + private LocalDateTime createTime; +} diff --git a/src/main/java/com/accounting/entity/OcrRecord.java b/src/main/java/com/accounting/entity/OcrRecord.java new file mode 100644 index 0000000..5dcc41c --- /dev/null +++ b/src/main/java/com/accounting/entity/OcrRecord.java @@ -0,0 +1,44 @@ +package com.accounting.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.LocalDate; +import java.time.LocalDateTime; + +@Data +@TableName("ocr_record") +public class OcrRecord { + @TableId(type = IdType.AUTO) + private Long id; + + private Long billId; // 识别成功后关联的账单ID + + private Long userId; + + private String imageUrl; + + private String ocrResult; // OCR识别结果(JSON格式) + + private BigDecimal parsedAmount; // 解析出的金额 + + private String parsedMerchant; // 解析出的商户名称 + + private LocalDateTime parsedDate; // 解析出的日期 + + private BigDecimal confidence; // 置信度 + + private LocalDateTime createTime; +} + + + + + + + + + diff --git a/src/main/java/com/accounting/entity/User.java b/src/main/java/com/accounting/entity/User.java new file mode 100644 index 0000000..e164711 --- /dev/null +++ b/src/main/java/com/accounting/entity/User.java @@ -0,0 +1,34 @@ +package com.accounting.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; + +@Data +@TableName("user") +public class User { + @TableId(type = IdType.AUTO) + private Long id; + + private String username; + + private String password; + + private String nickname; + + private LocalDateTime createTime; + + private LocalDateTime updateTime; +} + + + + + + + + + diff --git a/src/main/java/com/accounting/mapper/AccountMapper.java b/src/main/java/com/accounting/mapper/AccountMapper.java new file mode 100644 index 0000000..fe568a8 --- /dev/null +++ b/src/main/java/com/accounting/mapper/AccountMapper.java @@ -0,0 +1,10 @@ +package com.accounting.mapper; + +import com.accounting.entity.Account; +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface AccountMapper extends BaseMapper { +} + diff --git a/src/main/java/com/accounting/mapper/BillMapper.java b/src/main/java/com/accounting/mapper/BillMapper.java new file mode 100644 index 0000000..c1c889d --- /dev/null +++ b/src/main/java/com/accounting/mapper/BillMapper.java @@ -0,0 +1,18 @@ +package com.accounting.mapper; + +import com.accounting.entity.Bill; +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface BillMapper extends BaseMapper { +} + + + + + + + + + diff --git a/src/main/java/com/accounting/mapper/BudgetMapper.java b/src/main/java/com/accounting/mapper/BudgetMapper.java new file mode 100644 index 0000000..bd4cbdc --- /dev/null +++ b/src/main/java/com/accounting/mapper/BudgetMapper.java @@ -0,0 +1,10 @@ +package com.accounting.mapper; + +import com.accounting.entity.Budget; +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface BudgetMapper extends BaseMapper { +} + diff --git a/src/main/java/com/accounting/mapper/CategoryMapper.java b/src/main/java/com/accounting/mapper/CategoryMapper.java new file mode 100644 index 0000000..b798783 --- /dev/null +++ b/src/main/java/com/accounting/mapper/CategoryMapper.java @@ -0,0 +1,18 @@ +package com.accounting.mapper; + +import com.accounting.entity.Category; +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface CategoryMapper extends BaseMapper { +} + + + + + + + + + diff --git a/src/main/java/com/accounting/mapper/GoldPriceMapper.java b/src/main/java/com/accounting/mapper/GoldPriceMapper.java new file mode 100644 index 0000000..2c99ada --- /dev/null +++ b/src/main/java/com/accounting/mapper/GoldPriceMapper.java @@ -0,0 +1,32 @@ +package com.accounting.mapper; + +import com.accounting.entity.GoldPrice; +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +import java.time.LocalDate; +import java.util.List; + +@Mapper +public interface GoldPriceMapper extends BaseMapper { + + @Select("SELECT * FROM gold_price WHERE gold_id = #{goldId} ORDER BY price_date DESC LIMIT #{limit}") + List selectHistoryByGoldId(@Param("goldId") String goldId, @Param("limit") Integer limit); + + @Select("SELECT * FROM gold_price WHERE gold_id = #{goldId} AND price_date = #{date} ORDER BY update_time ASC") + List selectByDate(@Param("goldId") String goldId, @Param("date") LocalDate date); + + @Select("SELECT MAX(price_date) FROM gold_price WHERE gold_id = #{goldId}") + LocalDate selectLatestDate(@Param("goldId") String goldId); + + /** + * 检查指定时间点是否已存在数据(用于防重复插入) + * @param goldId 黄金品种ID + * @param updateTime 更新时间 + * @return 存在的记录数 + */ + @Select("SELECT COUNT(*) FROM gold_price WHERE gold_id = #{goldId} AND update_time = #{updateTime}") + int countByGoldIdAndUpdateTime(@Param("goldId") String goldId, @Param("updateTime") java.time.LocalDateTime updateTime); +} diff --git a/src/main/java/com/accounting/mapper/OcrRecordMapper.java b/src/main/java/com/accounting/mapper/OcrRecordMapper.java new file mode 100644 index 0000000..11e3dcb --- /dev/null +++ b/src/main/java/com/accounting/mapper/OcrRecordMapper.java @@ -0,0 +1,18 @@ +package com.accounting.mapper; + +import com.accounting.entity.OcrRecord; +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface OcrRecordMapper extends BaseMapper { +} + + + + + + + + + diff --git a/src/main/java/com/accounting/mapper/UserMapper.java b/src/main/java/com/accounting/mapper/UserMapper.java new file mode 100644 index 0000000..9555921 --- /dev/null +++ b/src/main/java/com/accounting/mapper/UserMapper.java @@ -0,0 +1,18 @@ +package com.accounting.mapper; + +import com.accounting.entity.User; +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface UserMapper extends BaseMapper { +} + + + + + + + + + diff --git a/src/main/java/com/accounting/schedule/GoldPriceScheduler.java b/src/main/java/com/accounting/schedule/GoldPriceScheduler.java new file mode 100644 index 0000000..bdec52e --- /dev/null +++ b/src/main/java/com/accounting/schedule/GoldPriceScheduler.java @@ -0,0 +1,33 @@ +package com.accounting.schedule; + +import com.accounting.service.GoldPriceService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +@RequiredArgsConstructor +public class GoldPriceScheduler { + + private static final String DEFAULT_GOLD_ID = "1053"; + + private final GoldPriceService goldPriceService; + + @Scheduled(cron = "0 */15 * * * ?", zone = "Asia/Shanghai") + public void fetchPeriodicPrice() { + runJob("periodic"); + } + + private void runJob(String tag) { + try { + log.info("Gold price scheduled job [{}] start", tag); + goldPriceService.refreshCurrentPrice(DEFAULT_GOLD_ID); + log.info("Gold price scheduled job [{}] success", tag); + } catch (Exception e) { + log.error("Gold price scheduled job [{}] failed", tag, e); + } + } +} + diff --git a/src/main/java/com/accounting/service/AccountService.java b/src/main/java/com/accounting/service/AccountService.java new file mode 100644 index 0000000..a92148d --- /dev/null +++ b/src/main/java/com/accounting/service/AccountService.java @@ -0,0 +1,161 @@ +package com.accounting.service; + +import com.accounting.dto.AccountRequest; +import com.accounting.dto.AccountResponse; +import com.accounting.entity.Account; +import com.accounting.entity.Bill; +import com.accounting.mapper.AccountMapper; +import com.accounting.mapper.BillMapper; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import org.springframework.beans.BeanUtils; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.math.BigDecimal; +import java.util.List; + +@Service +public class AccountService { + + @Autowired + private AccountMapper accountMapper; + + @Autowired + private BillMapper billMapper; + + /** + * 获取或创建用户账户(如果不存在则创建默认账户) + */ + @Transactional + public Account getOrCreateAccount(Long userId) { + Account account = accountMapper.selectOne( + new LambdaQueryWrapper() + .eq(Account::getUserId, userId) + ); + + if (account == null) { + account = new Account(); + account.setUserId(userId); + account.setName("默认账户"); + account.setInitialBalance(BigDecimal.ZERO); + accountMapper.insert(account); + } + + return account; + } + + /** + * 获取用户账户 + */ + public Account getAccount(Long userId) { + return accountMapper.selectOne( + new LambdaQueryWrapper() + .eq(Account::getUserId, userId) + ); + } + + /** + * 更新账户信息(主要是初始余额) + */ + @Transactional + public AccountResponse updateAccount(Long userId, AccountRequest request) { + Account account = getOrCreateAccount(userId); + + if (request.getName() != null) { + account.setName(request.getName()); + } + if (request.getInitialBalance() != null) { + account.setInitialBalance(request.getInitialBalance()); + } + + accountMapper.updateById(account); + + return getAccountBalance(userId); + } + + /** + * 更新账户余额(仅更新初始余额) + */ + @Transactional + public AccountResponse updateAccountBalance(Long userId, BigDecimal initialBalance) { + Account account = getOrCreateAccount(userId); + account.setInitialBalance(initialBalance); + accountMapper.updateById(account); + return getAccountBalance(userId); + } + + /** + * 计算账户余额(初始余额 + 收入总额 - 支出总额) + */ + public BigDecimal calculateBalance(Long accountId) { + Account account = accountMapper.selectById(accountId); + if (account == null) { + return BigDecimal.ZERO; + } + + BigDecimal initialBalance = account.getInitialBalance() != null ? account.getInitialBalance() : BigDecimal.ZERO; + + // 查询该账户的所有账单 + List bills = billMapper.selectList( + new LambdaQueryWrapper() + .eq(Bill::getAccountId, accountId) + ); + + BigDecimal totalIncome = BigDecimal.ZERO; + BigDecimal totalExpense = BigDecimal.ZERO; + + for (Bill bill : bills) { + if (bill.getType() != null && bill.getAmount() != null) { + if (bill.getType() == 2) { // 收入 + totalIncome = totalIncome.add(bill.getAmount()); + } else if (bill.getType() == 1) { // 支出 + totalExpense = totalExpense.add(bill.getAmount().abs()); + } + } + } + + return initialBalance.add(totalIncome).subtract(totalExpense); + } + + /** + * 获取账户余额信息(包含余额、总收入、总支出) + */ + public AccountResponse getAccountBalance(Long userId) { + Account account = getOrCreateAccount(userId); + + AccountResponse response = new AccountResponse(); + BeanUtils.copyProperties(account, response); + + BigDecimal balance = calculateBalance(account.getId()); + response.setBalance(balance); + + // 计算总收入 + List incomeBills = billMapper.selectList( + new LambdaQueryWrapper() + .eq(Bill::getAccountId, account.getId()) + .eq(Bill::getType, 2) // 收入 + ); + BigDecimal totalIncome = incomeBills.stream() + .map(Bill::getAmount) + .filter(amount -> amount != null) + .reduce(BigDecimal.ZERO, BigDecimal::add); + response.setTotalIncome(totalIncome); + + // 计算总支出 + List expenseBills = billMapper.selectList( + new LambdaQueryWrapper() + .eq(Bill::getAccountId, account.getId()) + .eq(Bill::getType, 1) // 支出 + ); + BigDecimal totalExpense = expenseBills.stream() + .map(Bill::getAmount) + .map(BigDecimal::abs) + .filter(amount -> amount != null) + .reduce(BigDecimal.ZERO, BigDecimal::add); + response.setTotalExpense(totalExpense); + + return response; + } +} + diff --git a/src/main/java/com/accounting/service/AuthService.java b/src/main/java/com/accounting/service/AuthService.java new file mode 100644 index 0000000..b180d22 --- /dev/null +++ b/src/main/java/com/accounting/service/AuthService.java @@ -0,0 +1,86 @@ +package com.accounting.service; + +import com.accounting.config.JwtConfig; +import com.accounting.dto.AuthResponse; +import com.accounting.dto.LoginRequest; +import com.accounting.dto.RegisterRequest; +import com.accounting.entity.User; +import com.accounting.mapper.UserMapper; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +public class AuthService { + + @Autowired + private UserMapper userMapper; + + @Autowired + private PasswordEncoder passwordEncoder; + + @Autowired + private JwtConfig jwtConfig; + + @Autowired + private AuthenticationManager authenticationManager; + + @Transactional + public AuthResponse register(RegisterRequest request) { + // 检查用户名是否已存在 + User existingUser = userMapper.selectOne( + new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper() + .eq(User::getUsername, request.getUsername()) + ); + + if (existingUser != null) { + throw new RuntimeException("用户名已存在"); + } + + // 创建新用户 + User user = new User(); + user.setUsername(request.getUsername()); + user.setPassword(passwordEncoder.encode(request.getPassword())); + user.setNickname(request.getNickname() != null ? request.getNickname() : request.getUsername()); + + userMapper.insert(user); + + // 生成token + String token = jwtConfig.generateToken(user.getUsername()); + + return new AuthResponse(token, user.getUsername(), user.getNickname()); + } + + public AuthResponse login(LoginRequest request) { + // 验证用户名和密码 + Authentication authentication = authenticationManager.authenticate( + new UsernamePasswordAuthenticationToken(request.getUsername(), request.getPassword()) + ); + + // 获取用户信息 + User user = userMapper.selectOne( + new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper() + .eq(User::getUsername, request.getUsername()) + ); + + if (user == null) { + throw new UsernameNotFoundException("用户不存在"); + } + + // 生成token + String token = jwtConfig.generateToken(user.getUsername()); + + return new AuthResponse(token, user.getUsername(), user.getNickname()); + } +} + + + + + + diff --git a/src/main/java/com/accounting/service/BillService.java b/src/main/java/com/accounting/service/BillService.java new file mode 100644 index 0000000..e03024c --- /dev/null +++ b/src/main/java/com/accounting/service/BillService.java @@ -0,0 +1,204 @@ +package com.accounting.service; + +import com.accounting.dto.BillRequest; +import com.accounting.dto.BillResponse; +import com.accounting.dto.BatchBillRequest; +import com.accounting.entity.Account; +import com.accounting.entity.Bill; +import com.accounting.entity.Category; +import com.accounting.mapper.BillMapper; +import com.accounting.mapper.CategoryMapper; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import org.springframework.beans.BeanUtils; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.List; +import java.util.stream.Collectors; + +@Service +public class BillService { + + @Autowired + private BillMapper billMapper; + + @Autowired + private CategoryMapper categoryMapper; + + @Autowired + private AccountService accountService; + + @Transactional + public BillResponse createBill(BillRequest request, Long userId) { + // 验证分类是否存在 + Category category = categoryMapper.selectById(request.getCategoryId()); + if (category == null) { + throw new RuntimeException("分类不存在"); + } + + // 获取或创建账户 + Account account = accountService.getOrCreateAccount(userId); + + // 创建账单 + Bill bill = new Bill(); + bill.setUserId(userId); + bill.setAccountId(account.getId()); // 自动关联账户 + bill.setCategoryId(request.getCategoryId()); + bill.setAmount(request.getAmount()); + bill.setDescription(request.getDescription()); + bill.setBillDate(request.getBillDate() != null ? request.getBillDate() : LocalDate.now()); + bill.setImageUrl(request.getImageUrl()); + bill.setType(request.getType()); // 设置账单类型 + + billMapper.insert(bill); + + return convertToResponse(bill, category); + } + + @Transactional + public List createBills(BatchBillRequest request, Long userId) { + List responses = new ArrayList<>(); + + // 获取或创建账户 + Account account = accountService.getOrCreateAccount(userId); + + for (BillRequest billRequest : request.getBills()) { + // 验证分类是否存在 + Category category = categoryMapper.selectById(billRequest.getCategoryId()); + if (category == null) { + throw new RuntimeException("分类不存在,分类ID: " + billRequest.getCategoryId()); + } + + // 创建账单 + Bill bill = new Bill(); + bill.setUserId(userId); + bill.setAccountId(account.getId()); // 自动关联账户 + bill.setCategoryId(billRequest.getCategoryId()); + bill.setAmount(billRequest.getAmount()); + bill.setDescription(billRequest.getDescription()); + bill.setBillDate(billRequest.getBillDate() != null ? billRequest.getBillDate() : LocalDate.now()); + bill.setImageUrl(billRequest.getImageUrl()); + bill.setType(billRequest.getType()); // 设置账单类型 + + billMapper.insert(bill); + + responses.add(convertToResponse(bill, category)); + } + + return responses; + } + + @Transactional + public BillResponse updateBill(Long id, BillRequest request, Long userId) { + // 验证账单是否存在且属于当前用户 + Bill bill = billMapper.selectOne( + new LambdaQueryWrapper() + .eq(Bill::getId, id) + .eq(Bill::getUserId, userId) + ); + + if (bill == null) { + throw new RuntimeException("账单不存在或无权限"); + } + + // 验证分类是否存在 + Category category = categoryMapper.selectById(request.getCategoryId()); + if (category == null) { + throw new RuntimeException("分类不存在"); + } + + // 如果账单没有关联账户,则自动关联 + if (bill.getAccountId() == null) { + Account account = accountService.getOrCreateAccount(userId); + bill.setAccountId(account.getId()); + } + + // 更新账单 + bill.setCategoryId(request.getCategoryId()); + bill.setAmount(request.getAmount()); + bill.setDescription(request.getDescription()); + if (request.getBillDate() != null) { + bill.setBillDate(request.getBillDate()); + } + if (request.getImageUrl() != null) { + bill.setImageUrl(request.getImageUrl()); + } + bill.setType(request.getType()); // 更新账单类型 + + billMapper.updateById(bill); + + return convertToResponse(bill, category); + } + + @Transactional + public void deleteBill(Long id, Long userId) { + Bill bill = billMapper.selectOne( + new LambdaQueryWrapper() + .eq(Bill::getId, id) + .eq(Bill::getUserId, userId) + ); + + if (bill == null) { + throw new RuntimeException("账单不存在或无权限"); + } + + billMapper.deleteById(id); + } + + public BillResponse getBill(Long id, Long userId) { + Bill bill = billMapper.selectOne( + new LambdaQueryWrapper() + .eq(Bill::getId, id) + .eq(Bill::getUserId, userId) + ); + + if (bill == null) { + throw new RuntimeException("账单不存在或无权限"); + } + + Category category = categoryMapper.selectById(bill.getCategoryId()); + return convertToResponse(bill, category); + } + + public List getBills(Long userId, LocalDate startDate, LocalDate endDate) { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper() + .eq(Bill::getUserId, userId) + .orderByDesc(Bill::getBillDate) + .orderByDesc(Bill::getCreateTime); + + if (startDate != null) { + wrapper.ge(Bill::getBillDate, startDate); + } + if (endDate != null) { + wrapper.le(Bill::getBillDate, endDate); + } + + List bills = billMapper.selectList(wrapper); + + return bills.stream().map(bill -> { + Category category = categoryMapper.selectById(bill.getCategoryId()); + return convertToResponse(bill, category); + }).collect(Collectors.toList()); + } + + private BillResponse convertToResponse(Bill bill, Category category) { + BillResponse response = new BillResponse(); + BeanUtils.copyProperties(bill, response); + if (category != null) { + response.setCategoryName(category.getName()); + response.setCategoryIcon(category.getIcon()); + // 如果账单有自己的类型,则使用账单的类型,否则使用分类的类型 + if (response.getType() == null) { + response.setType(category.getType()); + } + } + return response; + } +} + + + + diff --git a/src/main/java/com/accounting/service/BudgetService.java b/src/main/java/com/accounting/service/BudgetService.java new file mode 100644 index 0000000..f89e0d6 --- /dev/null +++ b/src/main/java/com/accounting/service/BudgetService.java @@ -0,0 +1,191 @@ +package com.accounting.service; + +import com.accounting.dto.BudgetRequest; +import com.accounting.dto.BudgetResponse; +import com.accounting.dto.BudgetSettlementResponse; +import com.accounting.dto.StatisticsResponse; +import com.accounting.entity.Budget; +import com.accounting.mapper.BudgetMapper; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.time.LocalDate; + +@Service +public class BudgetService { + + @Autowired + private BudgetMapper budgetMapper; + + @Autowired + private StatisticsService statisticsService; + + /** + * 获取或创建预算(如果不存在则创建默认0) + */ + @Transactional + public Budget getOrCreateBudget(Long userId, int year, int month) { + Budget budget = budgetMapper.selectOne( + new LambdaQueryWrapper() + .eq(Budget::getUserId, userId) + .eq(Budget::getYear, year) + .eq(Budget::getMonth, month) + ); + + if (budget == null) { + budget = new Budget(); + budget.setUserId(userId); + budget.setYear(year); + budget.setMonth(month); + budget.setAmount(BigDecimal.ZERO); + budgetMapper.insert(budget); + } + + return budget; + } + + /** + * 获取预算 + */ + public Budget getBudget(Long userId, int year, int month) { + return budgetMapper.selectOne( + new LambdaQueryWrapper() + .eq(Budget::getUserId, userId) + .eq(Budget::getYear, year) + .eq(Budget::getMonth, month) + ); + } + + /** + * 设置/更新预算 + */ + @Transactional + public BudgetResponse setBudget(Long userId, BudgetRequest request) { + int year = request.getYear(); + int month = request.getMonth(); + BigDecimal amount = request.getAmount(); + + if (amount == null || amount.compareTo(BigDecimal.ZERO) < 0) { + throw new RuntimeException("预算金额必须大于等于0"); + } + + Budget budget = getOrCreateBudget(userId, year, month); + budget.setAmount(amount); + budgetMapper.updateById(budget); + + return getBudgetWithStatistics(userId, year, month); + } + + /** + * 获取预算及统计信息 + */ + public BudgetResponse getBudgetWithStatistics(Long userId, int year, int month) { + Budget budget = getOrCreateBudget(userId, year, month); + + BudgetResponse response = new BudgetResponse(); + response.setId(budget.getId()); + response.setYear(budget.getYear()); + response.setMonth(budget.getMonth()); + response.setAmount(budget.getAmount()); + + // 获取本月支出统计 + StatisticsResponse stats = statisticsService.getMonthlyStatistics(userId, year, month); + BigDecimal usedAmount = stats.getTotalExpense() != null ? stats.getTotalExpense() : BigDecimal.ZERO; + response.setUsedAmount(usedAmount); + + // 计算剩余预算 + BigDecimal remainingAmount = budget.getAmount().subtract(usedAmount); + response.setRemainingAmount(remainingAmount); + + // 计算剩余日均 + LocalDate today = LocalDate.now(); + LocalDate monthStart = LocalDate.of(year, month, 1); + LocalDate monthEnd = monthStart.withDayOfMonth(monthStart.lengthOfMonth()); + + int remainingDays = 0; + if (year == today.getYear() && month == today.getMonthValue()) { + // 当前月份 + remainingDays = monthEnd.getDayOfMonth() - today.getDayOfMonth() + 1; + if (remainingDays < 0) { + remainingDays = 0; + } + } else if (year < today.getYear() || (year == today.getYear() && month < today.getMonthValue())) { + // 过去的月份 + remainingDays = 0; + } else { + // 未来的月份 + remainingDays = monthEnd.getDayOfMonth(); + } + + if (remainingDays > 0) { + BigDecimal remainingDaily = remainingAmount.divide(BigDecimal.valueOf(remainingDays), 2, RoundingMode.HALF_UP); + response.setRemainingDaily(remainingDaily); + } else { + response.setRemainingDaily(BigDecimal.ZERO); + } + + return response; + } + + /** + * 获取上月预算结算信息 + */ + public BudgetSettlementResponse getLastMonthSettlement(Long userId) { + LocalDate today = LocalDate.now(); + LocalDate lastMonth = today.minusMonths(1); + int year = lastMonth.getYear(); + int month = lastMonth.getMonthValue(); + + Budget budget = getBudget(userId, year, month); + BigDecimal budgetAmount = budget != null && budget.getAmount() != null ? budget.getAmount() : BigDecimal.ZERO; + + // 获取上月支出统计 + StatisticsResponse stats = statisticsService.getMonthlyStatistics(userId, year, month); + BigDecimal actualExpense = stats.getTotalExpense() != null ? stats.getTotalExpense() : BigDecimal.ZERO; + + BudgetSettlementResponse response = new BudgetSettlementResponse(); + response.setYear(year); + response.setMonth(month); + response.setBudgetAmount(budgetAmount); + response.setActualExpense(actualExpense); + + // 判断是否超支 + boolean isOverBudget = actualExpense.compareTo(budgetAmount) > 0; + response.setIsOverBudget(isOverBudget); + + if (isOverBudget) { + BigDecimal overAmount = actualExpense.subtract(budgetAmount); + response.setOverAmount(overAmount); + } else { + response.setOverAmount(BigDecimal.ZERO); + } + + // 计算完成率 + if (budgetAmount.compareTo(BigDecimal.ZERO) > 0) { + BigDecimal completionRate = actualExpense.divide(budgetAmount, 4, RoundingMode.HALF_UP) + .multiply(BigDecimal.valueOf(100)); + response.setCompletionRate(completionRate); + } else { + response.setCompletionRate(null); + } + + return response; + } + + /** + * 检查并创建本月预算(如果不存在则创建默认0) + */ + @Transactional + public void checkAndCreateCurrentMonthBudget(Long userId) { + LocalDate today = LocalDate.now(); + int year = today.getYear(); + int month = today.getMonthValue(); + + getOrCreateBudget(userId, year, month); + } +} + diff --git a/src/main/java/com/accounting/service/CategoryService.java b/src/main/java/com/accounting/service/CategoryService.java new file mode 100644 index 0000000..bb10024 --- /dev/null +++ b/src/main/java/com/accounting/service/CategoryService.java @@ -0,0 +1,118 @@ +package com.accounting.service; + +import com.accounting.dto.CategoryRequest; +import com.accounting.dto.CategoryResponse; +import com.accounting.entity.Category; +import com.accounting.mapper.CategoryMapper; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import org.springframework.beans.BeanUtils; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.stream.Collectors; + +@Service +public class CategoryService { + + @Autowired + private CategoryMapper categoryMapper; + + public List getCategories(Long userId, Integer type) { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper() + .orderByAsc(Category::getSortOrder) + .orderByAsc(Category::getCreateTime); + + if (type != null) { + wrapper.eq(Category::getType, type); + } + + // 获取系统预设分类和用户自定义分类 + wrapper.and(w -> w.isNull(Category::getUserId).or().eq(Category::getUserId, userId)); + + List categories = categoryMapper.selectList(wrapper); + + return categories.stream().map(this::convertToResponse).collect(Collectors.toList()); + } + + @Transactional + public CategoryResponse createCategory(CategoryRequest request, Long userId) { + Category category = new Category(); + category.setUserId(userId); + category.setName(request.getName()); + category.setIcon(request.getIcon() != null ? request.getIcon() : "📦"); + category.setType(request.getType()); + + // 获取最大排序值 + Category maxSort = categoryMapper.selectOne( + new LambdaQueryWrapper() + .eq(Category::getUserId, userId) + .orderByDesc(Category::getSortOrder) + .last("LIMIT 1") + ); + category.setSortOrder(maxSort != null ? maxSort.getSortOrder() + 1 : 1); + + categoryMapper.insert(category); + + return convertToResponse(category); + } + + @Transactional + public CategoryResponse updateCategory(Long id, CategoryRequest request, Long userId) { + Category category = categoryMapper.selectOne( + new LambdaQueryWrapper() + .eq(Category::getId, id) + .eq(Category::getUserId, userId) + ); + + if (category == null) { + throw new RuntimeException("分类不存在或无权限"); + } + + category.setName(request.getName()); + if (request.getIcon() != null) { + category.setIcon(request.getIcon()); + } + category.setType(request.getType()); + + categoryMapper.updateById(category); + + return convertToResponse(category); + } + + @Transactional + public void deleteCategory(Long id, Long userId) { + Category category = categoryMapper.selectOne( + new LambdaQueryWrapper() + .eq(Category::getId, id) + .eq(Category::getUserId, userId) + ); + + if (category == null) { + throw new RuntimeException("分类不存在或无权限"); + } + + // 系统预设分类不能删除 + if (category.getUserId() == null) { + throw new RuntimeException("系统预设分类不能删除"); + } + + categoryMapper.deleteById(id); + } + + private CategoryResponse convertToResponse(Category category) { + CategoryResponse response = new CategoryResponse(); + BeanUtils.copyProperties(category, response); + return response; + } +} + + + + + + + + + diff --git a/src/main/java/com/accounting/service/GoldPriceService.java b/src/main/java/com/accounting/service/GoldPriceService.java new file mode 100644 index 0000000..c4e66d8 --- /dev/null +++ b/src/main/java/com/accounting/service/GoldPriceService.java @@ -0,0 +1,44 @@ +package com.accounting.service; + +import com.accounting.dto.GoldPriceResponse; +import java.time.LocalDate; +import java.util.List; + +public interface GoldPriceService { + + /** + * 从数据库获取最新黄金价格(不触发外部接口调用) + * @param goldId 黄金品种ID + * @return 最新黄金价格响应,可能为 null + */ + GoldPriceResponse getCurrentPrice(String goldId); + + /** + * 主动从外部接口刷新当前黄金价格并写入数据库(供定时任务使用) + * @param goldId 黄金品种ID + */ + void refreshCurrentPrice(String goldId); + + /** + * 获取历史黄金价格 + * @param goldId 黄金品种ID + * @param days 查询天数 + * @return 黄金价格列表 + */ + List getHistoryPrices(String goldId, Integer days); + + /** + * 获取指定日期的所有黄金价格记录 + * @param goldId 黄金品种ID + * @param date 日期 + * @return 指定日期的价格记录列表 + */ + List getPricesByDate(String goldId, LocalDate date); + + /** + * 获取最近有数据的日期 + * @param goldId 黄金品种ID + * @return 最近有数据的日期,可能为 null + */ + LocalDate getLatestDate(String goldId); +} diff --git a/src/main/java/com/accounting/service/K780ApiClient.java b/src/main/java/com/accounting/service/K780ApiClient.java new file mode 100644 index 0000000..b82205d --- /dev/null +++ b/src/main/java/com/accounting/service/K780ApiClient.java @@ -0,0 +1,50 @@ +package com.accounting.service; + +import com.accounting.dto.K780Response; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestTemplate; + +@Slf4j +@Component +public class K780ApiClient { + + private static final String API_BASE_URL = "https://sapi.k780.com"; + private static final String APP = "finance.gold_price"; + private static final String APP_KEY = "78346"; + private static final String SIGN = "1b502c535927b66d9b888a6d4701bf72"; + private static final String FORMAT = "json"; + + private final RestTemplate restTemplate; + private final ObjectMapper objectMapper; + + public K780ApiClient() { + this.restTemplate = new RestTemplate(); + this.objectMapper = new ObjectMapper(); + } + + /** + * 从K780 API获取黄金价格 + * @param goldId 黄金品种ID + * @return K780Response + */ + public K780Response fetchGoldPrice(String goldId) { + try { + String url = String.format("%s/?app=%s&goldid=%s&appkey=%s&sign=%s&format=%s", + API_BASE_URL, APP, goldId, APP_KEY, SIGN, FORMAT); + + log.info("Fetching gold price from K780 API: {}", url); + + String response = restTemplate.getForObject(url, String.class); + K780Response k780Response = objectMapper.readValue(response, K780Response.class); + + log.info("K780 API response: {}", k780Response); + + return k780Response; + } catch (Exception e) { + log.error("Failed to fetch gold price from K780 API", e); + throw new RuntimeException("获取黄金价格失败: " + e.getMessage(), e); + } + } +} diff --git a/src/main/java/com/accounting/service/OcrService.java b/src/main/java/com/accounting/service/OcrService.java new file mode 100644 index 0000000..c943dba --- /dev/null +++ b/src/main/java/com/accounting/service/OcrService.java @@ -0,0 +1,76 @@ +package com.accounting.service; + +import com.accounting.entity.OcrRecord; +import com.accounting.mapper.OcrRecordMapper; +import com.accounting.util.FileUtil; +import com.accounting.util.OcrAmountParser; +import com.aliyun.ocr_api20210707.Client; +import com.aliyun.ocr_api20210707.models.RecognizeGeneralRequest; +import com.aliyun.ocr_api20210707.models.RecognizeGeneralResponse; +import com.aliyun.teaopenapi.models.Config; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import org.springframework.web.multipart.MultipartFile; + +import java.io.ByteArrayInputStream; +import java.io.File; +import java.io.InputStream; +import java.nio.file.Files; +import java.util.Base64; +import java.util.List; + +@Service +public class OcrService { + + @Autowired + private Client ocrClient; + + @Autowired + private FileUtil fileUtil; + + @Autowired + private OcrRecordMapper ocrRecordMapper; + + /** + * 识别图片中的文字并解析金额等信息 + */ + public List recognizeImage(MultipartFile file, Long userId) throws Exception { + // 保存文件 + String imageUrl = fileUtil.saveFile(file); + + // 读取文件并转换为InputStream + String fullPath = fileUtil.getFullPath(imageUrl); + File imageFile = new File(fullPath); + byte[] fileBytes = Files.readAllBytes(imageFile.toPath()); + InputStream inputStream = new ByteArrayInputStream(fileBytes); + + // 调用阿里云OCR API + RecognizeGeneralRequest request = new RecognizeGeneralRequest() + .setBody(inputStream); + + RecognizeGeneralResponse response = ocrClient.recognizeGeneral(request); + +// String body = "{\"data\":\"{\\\"algo_version\\\":\\\"\\\",\\\"content\\\":\\\"中 拼多多平台商户 -9.39 拼 12月2日13:14 拼 拼多多平台商户 -46.90 1o 进。 12月2日13:14 1 拼多多平台商户 -35.12 拼 12月2日13:13 拼多多平台商户 -4.01 拼 12月2日13:13 中 拼多多平台商户 -33.47 拼 12月1日09:48 拼 拼多多平台商户 -22.10 Re 12月1日09:48 中 拼多多平台商户 -42.00 拼 12月1日09:47 拼多多平台商户 -12.40 拼 12月1日09:16 \\\",\\\"height\\\":1761,\\\"orgHeight\\\":1761,\\\"orgWidth\\\":1080,\\\"prism_version\\\":\\\"1.0.9\\\",\\\"prism_wnum\\\":39,\\\"prism_wordsInfo\\\":[{\\\"angle\\\":-90,\\\"direction\\\":0,\\\"height\\\":35,\\\"pos\\\":[{\\\"x\\\":117,\\\"y\\\":86},{\\\"x\\\":153,\\\"y\\\":86},{\\\"x\\\":153,\\\"y\\\":117},{\\\"x\\\":117,\\\"y\\\":117}],\\\"prob\\\":94,\\\"width\\\":30,\\\"word\\\":\\\"中\\\",\\\"x\\\":120,\\\"y\\\":83},{\\\"angle\\\":-89,\\\"direction\\\":0,\\\"height\\\":330,\\\"pos\\\":[{\\\"x\\\":208,\\\"y\\\":71},{\\\"x\\\":539,\\\"y\\\":71},{\\\"x\\\":539,\\\"y\\\":123},{\\\"x\\\":208,\\\"y\\\":123}],\\\"prob\\\":99,\\\"width\\\":52,\\\"word\\\":\\\"拼多多平台商户\\\",\\\"x\\\":347,\\\"y\\\":-67},{\\\"angle\\\":-89,\\\"direction\\\":0,\\\"height\\\":113,\\\"pos\\\":[{\\\"x\\\":925,\\\"y\\\":78},{\\\"x\\\":1039,\\\"y\\\":79},{\\\"x\\\":1038,\\\"y\\\":119},{\\\"x\\\":925,\\\"y\\\":118}],\\\"prob\\\":99,\\\"width\\\":40,\\\"word\\\":\\\"-9.39\\\",\\\"x\\\":962,\\\"y\\\":42},{\\\"angle\\\":-90,\\\"direction\\\":0,\\\"height\\\":40,\\\"pos\\\":[{\\\"x\\\":98,\\\"y\\\":104},{\\\"x\\\":138,\\\"y\\\":104},{\\\"x\\\":138,\\\"y\\\":135},{\\\"x\\\":98,\\\"y\\\":135}],\\\"prob\\\":99,\\\"width\\\":30,\\\"word\\\":\\\"拼\\\",\\\"x\\\":103,\\\"y\\\":99},{\\\"angle\\\":-89,\\\"direction\\\":0,\\\"height\\\":236,\\\"pos\\\":[{\\\"x\\\":212,\\\"y\\\":147},{\\\"x\\\":448,\\\"y\\\":147},{\\\"x\\\":448,\\\"y\\\":187},{\\\"x\\\":212,\\\"y\\\":187}],\\\"prob\\\":99,\\\"width\\\":39,\\\"word\\\":\\\"12月2日13:14\\\",\\\"x\\\":310,\\\"y\\\":49},{\\\"angle\\\":-90,\\\"direction\\\":0,\\\"height\\\":23,\\\"pos\\\":[{\\\"x\\\":93,\\\"y\\\":318},{\\\"x\\\":117,\\\"y\\\":318},{\\\"x\\\":117,\\\"y\\\":348},{\\\"x\\\":93,\\\"y\\\":348}],\\\"prob\\\":99,\\\"width\\\":30,\\\"word\\\":\\\"拼\\\",\\\"x\\\":90,\\\"y\\\":321},{\\\"angle\\\":0,\\\"direction\\\":0,\\\"height\\\":52,\\\"pos\\\":[{\\\"x\\\":208,\\\"y\\\":287},{\\\"x\\\":538,\\\"y\\\":287},{\\\"x\\\":538,\\\"y\\\":339},{\\\"x\\\":208,\\\"y\\\":340}],\\\"prob\\\":99,\\\"width\\\":330,\\\"word\\\":\\\"拼多多平台商户\\\",\\\"x\\\":208,\\\"y\\\":287},{\\\"angle\\\":-88,\\\"direction\\\":0,\\\"height\\\":145,\\\"pos\\\":[{\\\"x\\\":891,\\\"y\\\":294},{\\\"x\\\":1036,\\\"y\\\":298},{\\\"x\\\":1035,\\\"y\\\":334},{\\\"x\\\":890,\\\"y\\\":330}],\\\"prob\\\":99,\\\"width\\\":35,\\\"word\\\":\\\"-46.90\\\",\\\"x\\\":945,\\\"y\\\":241},{\\\"angle\\\":-90,\\\"direction\\\":0,\\\"height\\\":23,\\\"pos\\\":[{\\\"x\\\":75,\\\"y\\\":340},{\\\"x\\\":99,\\\"y\\\":340},{\\\"x\\\":99,\\\"y\\\":369},{\\\"x\\\":75,\\\"y\\\":369}],\\\"prob\\\":69,\\\"width\\\":28,\\\"word\\\":\\\"1o\\\",\\\"x\\\":73,\\\"y\\\":343},{\\\"angle\\\":-90,\\\"direction\\\":0,\\\"height\\\":40,\\\"pos\\\":[{\\\"x\\\":98,\\\"y\\\":329},{\\\"x\\\":139,\\\"y\\\":329},{\\\"x\\\":139,\\\"y\\\":361},{\\\"x\\\":98,\\\"y\\\":361}],\\\"prob\\\":91,\\\"width\\\":31,\\\"word\\\":\\\"进。\\\",\\\"x\\\":103,\\\"y\\\":325},{\\\"angle\\\":-89,\\\"direction\\\":0,\\\"height\\\":236,\\\"pos\\\":[{\\\"x\\\":212,\\\"y\\\":362},{\\\"x\\\":449,\\\"y\\\":363},{\\\"x\\\":449,\\\"y\\\":404},{\\\"x\\\":212,\\\"y\\\":403}],\\\"prob\\\":99,\\\"width\\\":40,\\\"word\\\":\\\"12月2日13:14\\\",\\\"x\\\":310,\\\"y\\\":265},{\\\"angle\\\":-90,\\\"direction\\\":0,\\\"height\\\":42,\\\"pos\\\":[{\\\"x\\\":105,\\\"y\\\":522},{\\\"x\\\":148,\\\"y\\\":522},{\\\"x\\\":148,\\\"y\\\":556},{\\\"x\\\":105,\\\"y\\\":556}],\\\"prob\\\":75,\\\"width\\\":33,\\\"word\\\":\\\"1\\\",\\\"x\\\":109,\\\"y\\\":517},{\\\"angle\\\":-89,\\\"direction\\\":0,\\\"height\\\":329,\\\"pos\\\":[{\\\"x\\\":208,\\\"y\\\":503},{\\\"x\\\":538,\\\"y\\\":504},{\\\"x\\\":538,\\\"y\\\":556},{\\\"x\\\":208,\\\"y\\\":555}],\\\"prob\\\":99,\\\"width\\\":51,\\\"word\\\":\\\"拼多多平台商户\\\",\\\"x\\\":347,\\\"y\\\":365},{\\\"angle\\\":-89,\\\"direction\\\":0,\\\"height\\\":141,\\\"pos\\\":[{\\\"x\\\":898,\\\"y\\\":510},{\\\"x\\\":1040,\\\"y\\\":511},{\\\"x\\\":1039,\\\"y\\\":551},{\\\"x\\\":898,\\\"y\\\":550}],\\\"prob\\\":99,\\\"width\\\":40,\\\"word\\\":\\\"-35.12\\\",\\\"x\\\":949,\\\"y\\\":459},{\\\"angle\\\":-90,\\\"direction\\\":0,\\\"height\\\":36,\\\"pos\\\":[{\\\"x\\\":82,\\\"y\\\":544},{\\\"x\\\":118,\\\"y\\\":544},{\\\"x\\\":118,\\\"y\\\":574},{\\\"x\\\":82,\\\"y\\\":574}],\\\"prob\\\":99,\\\"width\\\":30,\\\"word\\\":\\\"拼\\\",\\\"x\\\":85,\\\"y\\\":540},{\\\"angle\\\":-89,\\\"direction\\\":0,\\\"height\\\":235,\\\"pos\\\":[{\\\"x\\\":212,\\\"y\\\":579},{\\\"x\\\":447,\\\"y\\\":580},{\\\"x\\\":447,\\\"y\\\":620},{\\\"x\\\":212,\\\"y\\\":619}],\\\"prob\\\":99,\\\"width\\\":39,\\\"word\\\":\\\"12月2日13:13\\\",\\\"x\\\":310,\\\"y\\\":482},{\\\"angle\\\":-89,\\\"direction\\\":0,\\\"height\\\":329,\\\"pos\\\":[{\\\"x\\\":209,\\\"y\\\":717},{\\\"x\\\":538,\\\"y\\\":721},{\\\"x\\\":538,\\\"y\\\":773},{\\\"x\\\":208,\\\"y\\\":769}],\\\"prob\\\":99,\\\"width\\\":52,\\\"word\\\":\\\"拼多多平台商户\\\",\\\"x\\\":347,\\\"y\\\":580},{\\\"angle\\\":-88,\\\"direction\\\":0,\\\"height\\\":113,\\\"pos\\\":[{\\\"x\\\":921,\\\"y\\\":727},{\\\"x\\\":1035,\\\"y\\\":730},{\\\"x\\\":1034,\\\"y\\\":765},{\\\"x\\\":920,\\\"y\\\":762}],\\\"prob\\\":99,\\\"width\\\":35,\\\"word\\\":\\\"-4.01\\\",\\\"x\\\":960,\\\"y\\\":689},{\\\"angle\\\":-90,\\\"direction\\\":0,\\\"height\\\":50,\\\"pos\\\":[{\\\"x\\\":78,\\\"y\\\":757},{\\\"x\\\":128,\\\"y\\\":757},{\\\"x\\\":128,\\\"y\\\":787},{\\\"x\\\":78,\\\"y\\\":787}],\\\"prob\\\":99,\\\"width\\\":29,\\\"word\\\":\\\"拼\\\",\\\"x\\\":88,\\\"y\\\":747},{\\\"angle\\\":-89,\\\"direction\\\":0,\\\"height\\\":234,\\\"pos\\\":[{\\\"x\\\":213,\\\"y\\\":795},{\\\"x\\\":447,\\\"y\\\":796},{\\\"x\\\":447,\\\"y\\\":835},{\\\"x\\\":212,\\\"y\\\":835}],\\\"prob\\\":99,\\\"width\\\":39,\\\"word\\\":\\\"12月2日13:13\\\",\\\"x\\\":310,\\\"y\\\":698},{\\\"angle\\\":-90,\\\"direction\\\":0,\\\"height\\\":35,\\\"pos\\\":[{\\\"x\\\":118,\\\"y\\\":951},{\\\"x\\\":154,\\\"y\\\":951},{\\\"x\\\":154,\\\"y\\\":982},{\\\"x\\\":118,\\\"y\\\":982}],\\\"prob\\\":85,\\\"width\\\":31,\\\"word\\\":\\\"中\\\",\\\"x\\\":120,\\\"y\\\":948},{\\\"angle\\\":-89,\\\"direction\\\":0,\\\"height\\\":329,\\\"pos\\\":[{\\\"x\\\":208,\\\"y\\\":933},{\\\"x\\\":537,\\\"y\\\":937},{\\\"x\\\":537,\\\"y\\\":989},{\\\"x\\\":207,\\\"y\\\":986}],\\\"prob\\\":99,\\\"width\\\":52,\\\"word\\\":\\\"拼多多平台商户\\\",\\\"x\\\":346,\\\"y\\\":796},{\\\"angle\\\":-89,\\\"direction\\\":0,\\\"height\\\":141,\\\"pos\\\":[{\\\"x\\\":898,\\\"y\\\":941},{\\\"x\\\":1040,\\\"y\\\":943},{\\\"x\\\":1039,\\\"y\\\":983},{\\\"x\\\":897,\\\"y\\\":982}],\\\"prob\\\":99,\\\"width\\\":40,\\\"word\\\":\\\"-33.47\\\",\\\"x\\\":948,\\\"y\\\":891},{\\\"angle\\\":-90,\\\"direction\\\":0,\\\"height\\\":55,\\\"pos\\\":[{\\\"x\\\":74,\\\"y\\\":975},{\\\"x\\\":130,\\\"y\\\":975},{\\\"x\\\":130,\\\"y\\\":1007},{\\\"x\\\":74,\\\"y\\\":1007}],\\\"prob\\\":93,\\\"width\\\":31,\\\"word\\\":\\\"拼\\\",\\\"x\\\":86,\\\"y\\\":963},{\\\"angle\\\":-89,\\\"direction\\\":0,\\\"height\\\":238,\\\"pos\\\":[{\\\"x\\\":212,\\\"y\\\":1011},{\\\"x\\\":450,\\\"y\\\":1012},{\\\"x\\\":450,\\\"y\\\":1051},{\\\"x\\\":212,\\\"y\\\":1050}],\\\"prob\\\":99,\\\"width\\\":39,\\\"word\\\":\\\"12月1日09:48\\\",\\\"x\\\":311,\\\"y\\\":912},{\\\"angle\\\":-90,\\\"direction\\\":0,\\\"height\\\":34,\\\"pos\\\":[{\\\"x\\\":84,\\\"y\\\":1183},{\\\"x\\\":119,\\\"y\\\":1183},{\\\"x\\\":119,\\\"y\\\":1214},{\\\"x\\\":84,\\\"y\\\":1214}],\\\"prob\\\":99,\\\"width\\\":30,\\\"word\\\":\\\"拼\\\",\\\"x\\\":86,\\\"y\\\":1181},{\\\"angle\\\":-89,\\\"direction\\\":0,\\\"height\\\":330,\\\"pos\\\":[{\\\"x\\\":208,\\\"y\\\":1151},{\\\"x\\\":538,\\\"y\\\":1152},{\\\"x\\\":538,\\\"y\\\":1203},{\\\"x\\\":207,\\\"y\\\":1202}],\\\"prob\\\":99,\\\"width\\\":51,\\\"word\\\":\\\"拼多多平台商户\\\",\\\"x\\\":347,\\\"y\\\":1012},{\\\"angle\\\":-89,\\\"direction\\\":0,\\\"height\\\":140,\\\"pos\\\":[{\\\"x\\\":898,\\\"y\\\":1157},{\\\"x\\\":1039,\\\"y\\\":1157},{\\\"x\\\":1039,\\\"y\\\":1200},{\\\"x\\\":898,\\\"y\\\":1200}],\\\"prob\\\":99,\\\"width\\\":42,\\\"word\\\":\\\"-22.10\\\",\\\"x\\\":947,\\\"y\\\":1108},{\\\"angle\\\":-90,\\\"direction\\\":0,\\\"height\\\":24,\\\"pos\\\":[{\\\"x\\\":106,\\\"y\\\":1199},{\\\"x\\\":131,\\\"y\\\":1199},{\\\"x\\\":131,\\\"y\\\":1230},{\\\"x\\\":106,\\\"y\\\":1230}],\\\"prob\\\":82,\\\"width\\\":30,\\\"word\\\":\\\"Re\\\",\\\"x\\\":103,\\\"y\\\":1202},{\\\"angle\\\":-89,\\\"direction\\\":0,\\\"height\\\":247,\\\"pos\\\":[{\\\"x\\\":212,\\\"y\\\":1227},{\\\"x\\\":460,\\\"y\\\":1228},{\\\"x\\\":460,\\\"y\\\":1266},{\\\"x\\\":212,\\\"y\\\":1266}],\\\"prob\\\":99,\\\"width\\\":38,\\\"word\\\":\\\"12月1日09:48\\\",\\\"x\\\":317,\\\"y\\\":1123},{\\\"angle\\\":-90,\\\"direction\\\":0,\\\"height\\\":34,\\\"pos\\\":[{\\\"x\\\":119,\\\"y\\\":1383},{\\\"x\\\":153,\\\"y\\\":1383},{\\\"x\\\":153,\\\"y\\\":1413},{\\\"x\\\":119,\\\"y\\\":1413}],\\\"prob\\\":92,\\\"width\\\":30,\\\"word\\\":\\\"中\\\",\\\"x\\\":121,\\\"y\\\":1381},{\\\"angle\\\":0,\\\"direction\\\":0,\\\"height\\\":51,\\\"pos\\\":[{\\\"x\\\":208,\\\"y\\\":1368},{\\\"x\\\":539,\\\"y\\\":1366},{\\\"x\\\":539,\\\"y\\\":1418},{\\\"x\\\":208,\\\"y\\\":1419}],\\\"prob\\\":99,\\\"width\\\":331,\\\"word\\\":\\\"拼多多平台商户\\\",\\\"x\\\":208,\\\"y\\\":1367},{\\\"angle\\\":-89,\\\"direction\\\":0,\\\"height\\\":145,\\\"pos\\\":[{\\\"x\\\":890,\\\"y\\\":1375},{\\\"x\\\":1035,\\\"y\\\":1377},{\\\"x\\\":1035,\\\"y\\\":1413},{\\\"x\\\":889,\\\"y\\\":1411}],\\\"prob\\\":99,\\\"width\\\":35,\\\"word\\\":\\\"-42.00\\\",\\\"x\\\":944,\\\"y\\\":1321},{\\\"angle\\\":-90,\\\"direction\\\":0,\\\"height\\\":48,\\\"pos\\\":[{\\\"x\\\":78,\\\"y\\\":1405},{\\\"x\\\":127,\\\"y\\\":1405},{\\\"x\\\":127,\\\"y\\\":1435},{\\\"x\\\":78,\\\"y\\\":1435}],\\\"prob\\\":99,\\\"width\\\":30,\\\"word\\\":\\\"拼\\\",\\\"x\\\":87,\\\"y\\\":1396},{\\\"angle\\\":0,\\\"direction\\\":0,\\\"height\\\":44,\\\"pos\\\":[{\\\"x\\\":211,\\\"y\\\":1441},{\\\"x\\\":450,\\\"y\\\":1441},{\\\"x\\\":450,\\\"y\\\":1485},{\\\"x\\\":212,\\\"y\\\":1486}],\\\"prob\\\":99,\\\"width\\\":238,\\\"word\\\":\\\"12月1日09:47\\\",\\\"x\\\":212,\\\"y\\\":1441},{\\\"angle\\\":-89,\\\"direction\\\":0,\\\"height\\\":329,\\\"pos\\\":[{\\\"x\\\":208,\\\"y\\\":1582},{\\\"x\\\":538,\\\"y\\\":1583},{\\\"x\\\":538,\\\"y\\\":1635},{\\\"x\\\":208,\\\"y\\\":1634}],\\\"prob\\\":99,\\\"width\\\":51,\\\"word\\\":\\\"拼多多平台商户\\\",\\\"x\\\":347,\\\"y\\\":1443},{\\\"angle\\\":0,\\\"direction\\\":0,\\\"height\\\":40,\\\"pos\\\":[{\\\"x\\\":898,\\\"y\\\":1591},{\\\"x\\\":1038,\\\"y\\\":1591},{\\\"x\\\":1038,\\\"y\\\":1631},{\\\"x\\\":898,\\\"y\\\":1631}],\\\"prob\\\":99,\\\"width\\\":140,\\\"word\\\":\\\"-12.40\\\",\\\"x\\\":898,\\\"y\\\":1591},{\\\"angle\\\":-90,\\\"direction\\\":0,\\\"height\\\":40,\\\"pos\\\":[{\\\"x\\\":97,\\\"y\\\":1619},{\\\"x\\\":138,\\\"y\\\":1619},{\\\"x\\\":138,\\\"y\\\":1649},{\\\"x\\\":97,\\\"y\\\":1649}],\\\"prob\\\":99,\\\"width\\\":29,\\\"word\\\":\\\"拼\\\",\\\"x\\\":103,\\\"y\\\":1613},{\\\"angle\\\":0,\\\"direction\\\":0,\\\"height\\\":39,\\\"pos\\\":[{\\\"x\\\":212,\\\"y\\\":1660},{\\\"x\\\":449,\\\"y\\\":1659},{\\\"x\\\":449,\\\"y\\\":1699},{\\\"x\\\":212,\\\"y\\\":1699}],\\\"prob\\\":99,\\\"width\\\":237,\\\"word\\\":\\\"12月1日09:16\\\",\\\"x\\\":212,\\\"y\\\":1659}],\\\"width\\\":1080}\",\"requestId\":\"077630B9-150F-54D5-8FFE-DDC9D5A9E172\"}"; + // 获取OCR结果 + String ocrResultJson = com.aliyun.teautil.Common.toJSONString(response.body); + + // 解析金额等信息 + List resultList = OcrAmountParser.parse(ocrResultJson); + + resultList.forEach(parseResult -> { + // 保存OCR记录 + OcrRecord ocrRecord = new OcrRecord(); + ocrRecord.setUserId(userId); + ocrRecord.setImageUrl(imageUrl); + ocrRecord.setOcrResult(ocrResultJson); + ocrRecord.setParsedAmount(parseResult.getAmount()); + ocrRecord.setParsedMerchant(parseResult.getMerchant()); + ocrRecord.setParsedDate(parseResult.getDate()); + ocrRecord.setConfidence(parseResult.getConfidence()); + ocrRecordMapper.insert(ocrRecord); + }); + + + return resultList; + } +} \ No newline at end of file diff --git a/src/main/java/com/accounting/service/StatisticsService.java b/src/main/java/com/accounting/service/StatisticsService.java new file mode 100644 index 0000000..1664065 --- /dev/null +++ b/src/main/java/com/accounting/service/StatisticsService.java @@ -0,0 +1,117 @@ +package com.accounting.service; + +import com.accounting.dto.StatisticsResponse; +import com.accounting.entity.Bill; +import com.accounting.entity.Category; +import com.accounting.mapper.BillMapper; +import com.accounting.mapper.CategoryMapper; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +@Service +public class StatisticsService { + + @Autowired + private BillMapper billMapper; + + @Autowired + private CategoryMapper categoryMapper; + + public StatisticsResponse getDailyStatistics(Long userId, LocalDate startDate, LocalDate endDate) { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper() + .eq(Bill::getUserId, userId) + .ge(Bill::getBillDate, startDate) + .le(Bill::getBillDate, endDate); + + List bills = billMapper.selectList(wrapper); + + StatisticsResponse response = new StatisticsResponse(); + response.setTotalIncome(BigDecimal.ZERO); + response.setTotalExpense(BigDecimal.ZERO); + + // 按日期分组统计 + Map dailyMap = new HashMap<>(); + + // 按分类统计 + Map categoryMap = new HashMap<>(); + + for (Bill bill : bills) { + Category category = categoryMapper.selectById(bill.getCategoryId()); + if (category == null) continue; + + // 统计总收入/支出 + if (category.getType() == 2) { // 收入 + response.setTotalIncome(response.getTotalIncome().add(bill.getAmount())); + } else { // 支出 + response.setTotalExpense(response.getTotalExpense().add(bill.getAmount())); + } + + // 按日期统计 + String dateStr = bill.getBillDate().format(DateTimeFormatter.ofPattern("yyyy-MM-dd")); + StatisticsResponse.DailyStatistics daily = dailyMap.computeIfAbsent(dateStr, k -> { + StatisticsResponse.DailyStatistics d = new StatisticsResponse.DailyStatistics(); + d.setDate(k); + d.setIncome(BigDecimal.ZERO); + d.setExpense(BigDecimal.ZERO); + return d; + }); + + if (category.getType() == 2) { + daily.setIncome(daily.getIncome().add(bill.getAmount())); + } else { + daily.setExpense(daily.getExpense().add(bill.getAmount())); + } + + // 按分类统计 + StatisticsResponse.CategoryStatistics catStat = categoryMap.computeIfAbsent( + bill.getCategoryId(), + k -> { + StatisticsResponse.CategoryStatistics cs = new StatisticsResponse.CategoryStatistics(); + cs.setCategoryId(category.getId()); + cs.setCategoryName(category.getName()); + cs.setCategoryIcon(category.getIcon()); + cs.setAmount(BigDecimal.ZERO); + cs.setType(category.getType()); + return cs; + } + ); + catStat.setAmount(catStat.getAmount().add(bill.getAmount())); + } + + response.setBalance(response.getTotalIncome().subtract(response.getTotalExpense())); + response.setDailyStatistics(new ArrayList<>(dailyMap.values())); + response.setCategoryStatistics(new ArrayList<>(categoryMap.values())); + + return response; + } + + public StatisticsResponse getWeeklyStatistics(Long userId, LocalDate weekStart) { + LocalDate weekEnd = weekStart.plusDays(6); + return getDailyStatistics(userId, weekStart, weekEnd); + } + + public StatisticsResponse getMonthlyStatistics(Long userId, int year, int month) { + LocalDate startDate = LocalDate.of(year, month, 1); + LocalDate endDate = startDate.withDayOfMonth(startDate.lengthOfMonth()); + return getDailyStatistics(userId, startDate, endDate); + } +} + + + + + + + + + diff --git a/src/main/java/com/accounting/service/impl/GoldPriceServiceImpl.java b/src/main/java/com/accounting/service/impl/GoldPriceServiceImpl.java new file mode 100644 index 0000000..53cd48a --- /dev/null +++ b/src/main/java/com/accounting/service/impl/GoldPriceServiceImpl.java @@ -0,0 +1,220 @@ +package com.accounting.service.impl; + +import com.accounting.dto.GoldPriceResponse; +import com.accounting.dto.K780Response; +import com.accounting.dto.K780Result; +import com.accounting.entity.GoldPrice; +import com.accounting.mapper.GoldPriceMapper; +import com.accounting.service.GoldPriceService; +import com.accounting.service.K780ApiClient; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.List; +import java.util.stream.Collectors; + +@Slf4j +@Service +@RequiredArgsConstructor +public class GoldPriceServiceImpl implements GoldPriceService { + + private final K780ApiClient k780ApiClient; + private final GoldPriceMapper goldPriceMapper; + + private static final DateTimeFormatter DATE_TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); + private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd"); + + @Override + @Transactional(readOnly = true) + public GoldPriceResponse getCurrentPrice(String goldId) { + // 仅从数据库读取最新记录 + GoldPrice latest = goldPriceMapper.selectOne( + new LambdaQueryWrapper() + .eq(GoldPrice::getGoldId, goldId) + .orderByDesc(GoldPrice::getPriceDate, GoldPrice::getUpdateTime) + .last("LIMIT 1") + ); + if (latest == null) { + return null; + } + return convertToResponse(latest); + } + + @Override + @Transactional + public void refreshCurrentPrice(String goldId) { + log.info("Starting refreshCurrentPrice for goldId={}", goldId); + + K780Response response = k780ApiClient.fetchGoldPrice(goldId); + + if (!"1".equals(response.getSuccess()) || response.getResult() == null) { + throw new RuntimeException("获取黄金价格失败"); + } + + K780Result result = response.getResult(); + + // 记录API返回的所有数据,用于调试 + if (result.getDtList() != null) { + log.info("API returned {} gold price records", result.getDtList().size()); + result.getDtList().forEach((id, data) -> { + log.info("Gold price data - goldId: {}, uptime: {}, lastPrice: {}", + id, data.getUptime(), data.getLastPrice()); + }); + } + + K780Result.GoldPriceData goldPriceData = extractGoldPriceData(result, goldId); + log.info("Extracted gold price data - goldId: {}, uptime: {}, lastPrice: {}", + goldPriceData.getGoldId(), goldPriceData.getUptime(), goldPriceData.getLastPrice()); + + GoldPrice goldPrice = convertToEntity(goldPriceData); + log.info("Converted to entity - goldId: {}, updateTime: {}, priceDate: {}", + goldPrice.getGoldId(), goldPrice.getUpdateTime(), goldPrice.getPriceDate()); + + // 防重复插入:检查是否已存在相同时间点的数据 + int existingCount = goldPriceMapper.countByGoldIdAndUpdateTime(goldId, goldPrice.getUpdateTime()); + if (existingCount > 0) { + log.warn("Gold price data already exists for goldId={}, updateTime={}, skipping insert", + goldId, goldPrice.getUpdateTime()); + return; + } + + // 插入新记录 每15分钟查询一次结果并存储 + goldPrice.setCreateTime(LocalDateTime.now()); + goldPriceMapper.insert(goldPrice); + + log.info("Successfully inserted gold price - id: {}, goldId: {}, priceDate: {}, updateTime: {}, createTime: {}", + goldPrice.getId(), goldPrice.getGoldId(), goldPrice.getPriceDate(), + goldPrice.getUpdateTime(), goldPrice.getCreateTime()); + } + + @Override + public List getHistoryPrices(String goldId, Integer days) { + int limit = (days != null && days > 0) ? Math.min(days, 30) : 30; + List prices = goldPriceMapper.selectHistoryByGoldId(goldId, limit); + return prices.stream() + .map(this::convertToResponse) + .collect(Collectors.toList()); + } + + @Override + @Transactional(readOnly = true) + public List getPricesByDate(String goldId, LocalDate date) { + List prices = goldPriceMapper.selectByDate(goldId, date); + return prices.stream() + .map(this::convertToResponse) + .collect(Collectors.toList()); + } + + @Override + @Transactional(readOnly = true) + public LocalDate getLatestDate(String goldId) { + return goldPriceMapper.selectLatestDate(goldId); + } + + private K780Result.GoldPriceData extractGoldPriceData(K780Result result, String goldId) { + if (result.getDtList() == null || result.getDtList().isEmpty()) { + throw new RuntimeException("黄金价格数据为空"); + } + + // 尝试通过提供的goldId获取数据 + K780Result.GoldPriceData data = result.getDtList().get(goldId); + if (data == null) { + // 如果没找到指定goldId的数据,则取第一个可用数据 + data = result.getDtList().values().stream().findFirst() + .orElseThrow(() -> new RuntimeException("未找到黄金价格数据")); + } + + return data; + } + + private GoldPrice convertToEntity(K780Result.GoldPriceData data) { + GoldPrice entity = new GoldPrice(); + + // 确保 goldId 不为 null + String goldId = data.getGoldId(); + if (goldId == null || goldId.isEmpty()) { + throw new RuntimeException("黄金品种ID不能为空"); + } + entity.setGoldId(goldId); + + entity.setGoldName(data.getVarietynm()); + entity.setPrice(parseBigDecimal(data.getLastPrice())); + entity.setPriceChange(parseBigDecimal(data.getChangePrice())); + entity.setOpenPrice(parseBigDecimal(data.getOpenPrice())); + entity.setHighPrice(parseBigDecimal(data.getHighPrice())); + entity.setLowPrice(parseBigDecimal(data.getLowPrice())); + entity.setYesterdayClose(parseBigDecimal(data.getYesyPrice())); + + // 处理涨跌百分比 + String changeMargin = data.getChangeMargin(); + if (changeMargin != null && !changeMargin.isEmpty() && !"-".equals(changeMargin)) { + // 移除百分号并转换 + String marginWithoutPercent = changeMargin.replace("%", ""); + entity.setPriceChangePercent(parseBigDecimal(marginWithoutPercent)); + } + + // 解析更新时间 + if (data.getUptime() != null && !data.getUptime().isEmpty()) { + try { + LocalDateTime parsedTime = LocalDateTime.parse(data.getUptime(), DATE_TIME_FORMATTER); + entity.setUpdateTime(parsedTime); + entity.setPriceDate(parsedTime.toLocalDate()); + log.debug("Successfully parsed update time: {} -> {}", data.getUptime(), parsedTime); + } catch (Exception e) { + log.warn("Failed to parse update time: {}, error: {}, using current time instead", + data.getUptime(), e.getMessage()); + LocalDateTime now = LocalDateTime.now(); + entity.setUpdateTime(now); + entity.setPriceDate(now.toLocalDate()); + } + } else { + log.warn("Uptime is null or empty, using current time"); + LocalDateTime now = LocalDateTime.now(); + entity.setUpdateTime(now); + entity.setPriceDate(now.toLocalDate()); + } + + return entity; + } + + private GoldPriceResponse convertToResponse(GoldPrice entity) { + GoldPriceResponse response = new GoldPriceResponse(); + response.setGoldId(entity.getGoldId()); + response.setGoldName(entity.getGoldName()); + response.setPrice(entity.getPrice()); + response.setPriceChange(entity.getPriceChange()); + response.setPriceChangePercent(entity.getPriceChangePercent()); + response.setHighPrice(entity.getHighPrice()); + response.setLowPrice(entity.getLowPrice()); + response.setOpenPrice(entity.getOpenPrice()); + response.setYesterdayClose(entity.getYesterdayClose()); + + if (entity.getUpdateTime() != null) { + response.setUpdateTime(entity.getUpdateTime().format(DATE_TIME_FORMATTER)); + } + if (entity.getPriceDate() != null) { + response.setPriceDate(entity.getPriceDate().format(DATE_FORMATTER)); + } + + return response; + } + + private BigDecimal parseBigDecimal(String value) { + if (value == null || value.isEmpty() || "-".equals(value)) { + return null; + } + try { + return new BigDecimal(value); + } catch (NumberFormatException e) { + log.warn("Failed to parse BigDecimal: {}", value); + return null; + } + } +} \ No newline at end of file diff --git a/src/main/java/com/accounting/util/FileUtil.java b/src/main/java/com/accounting/util/FileUtil.java new file mode 100644 index 0000000..13ee041 --- /dev/null +++ b/src/main/java/com/accounting/util/FileUtil.java @@ -0,0 +1,114 @@ +package com.accounting.util; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; +import org.springframework.web.multipart.MultipartFile; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.util.UUID; + +@Component +public class FileUtil { + + @Value("${file.upload.path}") + private String uploadPath; + + private static final String[] ALLOWED_EXTENSIONS = {".jpg", ".jpeg", ".png", ".bmp"}; + private static final long MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB + + /** + * 保存上传的文件 + */ + public String saveFile(MultipartFile file) throws IOException { + // 验证文件 + validateFile(file); + + // 创建上传目录(按日期分类) + String dateDir = LocalDate.now().format(DateTimeFormatter.ofPattern("yyyy/MM/dd")); + String fullPath = uploadPath + dateDir; + Path path = Paths.get(fullPath); + if (!Files.exists(path)) { + Files.createDirectories(path); + } + + // 生成文件名 + String originalFilename = file.getOriginalFilename(); + String extension = ""; + if (originalFilename != null && originalFilename.contains(".")) { + extension = originalFilename.substring(originalFilename.lastIndexOf(".")); + } + String filename = UUID.randomUUID().toString() + extension; + + // 保存文件 + Path filePath = path.resolve(filename); + file.transferTo(filePath.toFile()); + + // 返回相对路径(用于数据库存储) + return dateDir + "/" + filename; + } + + /** + * 验证文件 + */ + private void validateFile(MultipartFile file) { + if (file == null || file.isEmpty()) { + throw new IllegalArgumentException("文件不能为空"); + } + + if (file.getSize() > MAX_FILE_SIZE) { + throw new IllegalArgumentException("文件大小不能超过5MB"); + } + + String originalFilename = file.getOriginalFilename(); + if (originalFilename == null) { + throw new IllegalArgumentException("文件名不能为空"); + } + + String extension = originalFilename.substring(originalFilename.lastIndexOf(".")).toLowerCase(); + boolean allowed = false; + for (String allowedExt : ALLOWED_EXTENSIONS) { + if (extension.equals(allowedExt)) { + allowed = true; + break; + } + } + + if (!allowed) { + throw new IllegalArgumentException("不支持的文件格式,仅支持: " + String.join(", ", ALLOWED_EXTENSIONS)); + } + } + + /** + * 获取文件的完整路径 + */ + public String getFullPath(String relativePath) { + return uploadPath + relativePath; + } + + /** + * 删除文件 + */ + public boolean deleteFile(String relativePath) { + try { + Path path = Paths.get(uploadPath + relativePath); + return Files.deleteIfExists(path); + } catch (IOException e) { + return false; + } + } +} + + + + + + + + + diff --git a/src/main/java/com/accounting/util/OcrAmountParser.java b/src/main/java/com/accounting/util/OcrAmountParser.java new file mode 100644 index 0000000..1af45bf --- /dev/null +++ b/src/main/java/com/accounting/util/OcrAmountParser.java @@ -0,0 +1,466 @@ +package com.accounting.util; + +import com.alibaba.fastjson2.JSON; +import com.alibaba.fastjson2.JSONArray; +import com.alibaba.fastjson2.JSONObject; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.time.format.DateTimeParseException; +import java.util.*; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +public class OcrAmountParser { + + // 金额正则表达式:匹配 ¥100.00、100.00元、100元、100.00 等格式 + private static final Pattern AMOUNT_PATTERN = Pattern.compile( + "[¥¥]?\\s*(\\d{1,10}(\\.\\d{1,2})?)\\s*[元]?" + ); + + + + // 日期正则表达式:匹配字符串中是否含有月、日或天 + private static final Pattern UNION_DATE_PATTERN = Pattern.compile(".*(今天|昨天).*"); + + + // 日期正则表达式:匹配 12月2日13:14 这样的格式 + private static final Pattern DATE_PATTERN = Pattern.compile( + "(\\d{1,2})月(\\d{1,2})日\\s*(\\d{1,2})[::](\\d{1,2})" + ); + + // 日期正则表达式:匹配 12-11 13:14 这样的格式 + private static final Pattern DATE_PATTERN_DASH = Pattern.compile( + "(\\d{1,2})-(\\d{1,2})\\s*(\\d{1,2})[::](\\d{1,2})" + ); + + // 商户名称关键词(常见支付平台) + private static final String[] MERCHANT_KEYWORDS = { + "微信支付", "支付宝", "收款", "付款", "商户", "商家", "店铺", "超市", "餐厅", "饭店" + }; + + public static class ParseResult { + private BigDecimal amount; + private String merchant; + private LocalDateTime date; // 改为LocalDateTime以支持时间 + private BigDecimal confidence; + + public ParseResult(BigDecimal amount, String merchant, LocalDateTime date, BigDecimal confidence) { + this.amount = amount; + this.merchant = merchant; + this.date = date; + this.confidence = confidence; + } + + public ParseResult() { + + } + + public void setAmount(BigDecimal amount) { + this.amount = amount; + } + + public void setMerchant(String merchant) { + this.merchant = merchant; + } + + public void setDate(LocalDateTime date) { + this.date = date; + } + + public void setConfidence(BigDecimal confidence) { + this.confidence = confidence; + } + + public BigDecimal getAmount() { return amount; } + public String getMerchant() { return merchant; } + public LocalDateTime getDate() { return date; } + public BigDecimal getConfidence() { return confidence; } + } + + /** + * 验证字符串是否为有效日期格式 + */ + private static boolean isValidDate(String dateStr) { + if (dateStr == null || dateStr.trim().isEmpty()) { + return false; + } + // 匹配今天、昨天 + if (UNION_DATE_PATTERN.matcher(dateStr.trim()).matches()){ + return true; + } + // 匹配12-11 13:14 + if (DATE_PATTERN_DASH.matcher(dateStr.trim()).matches()){ + return true; + } + // 匹配12月11日 13:14 + if (DATE_PATTERN.matcher(dateStr.trim()).matches()){ + return true; + } + return false; + } + + /** + * 将字符串转换为BigDecimal + * @param moneyStr 金额字符串 + * @return BigDecimal对象,如果转换失败则返回null + */ + private static BigDecimal parseMoneyString(String moneyStr) { + if (moneyStr == null || moneyStr.trim().isEmpty()) { + return null; + } + + try { + // 判断正负号 + String cleanStr = moneyStr.trim(); + boolean isNegative = cleanStr.startsWith("-"); + + // 移除可能的前缀符号 (+/-) + if (cleanStr.startsWith("+") || cleanStr.startsWith("-")) { + cleanStr = cleanStr.substring(1); + } + + // 使用现有的金额正则表达式匹配 + Matcher matcher = AMOUNT_PATTERN.matcher(cleanStr); + if (matcher.find()) { + String amountStr = matcher.group(1); + BigDecimal amount = new BigDecimal(amountStr); + // 应用正负号 + return isNegative ? amount.negate() : amount; + } + } catch (Exception e) { + // 转换失败 + e.printStackTrace(); + } + return null; + } + + /** + * 解析日期字符串为LocalDateTime对象 + * @param dateStr 解析3中日期字符串,例如:"12月2日13:14" "12-11 13:14" "今天 13:14" "昨天 13:14" + * @return LocalDateTime对象 + */ + private static LocalDateTime parseDateTimeString(String dateStr) { + if (dateStr == null || dateStr.trim().isEmpty()) { + return LocalDateTime.now(); + } + + //先处理今天、昨天 + if (UNION_DATE_PATTERN.matcher(dateStr.trim()).matches()){ + String trimmedDateStr = dateStr.trim(); + LocalDateTime baseDate; + + if (trimmedDateStr.startsWith("今天")) { + baseDate = LocalDateTime.now(); + } else if (trimmedDateStr.startsWith("昨天")) { + baseDate = LocalDateTime.now().minusDays(1); + } else { + return null; + } + + // 提取时间部分 + String timePart = trimmedDateStr.substring(2).trim(); // 去掉"今天"/"昨天" + if (!timePart.isEmpty()) { + String[] timeParts = timePart.split("[::]"); + if (timeParts.length >= 2) { + try { + int hour = Integer.parseInt(timeParts[0]); + int minute = Integer.parseInt(timeParts[1]); + return baseDate.withHour(hour).withMinute(minute).withSecond(0).withNano(0); + } catch (NumberFormatException e) { + // 时间解析失败,返回基础日期 + return baseDate; + } + } + } + return baseDate; + } + + //处理 12月2日13:14 + if (DATE_PATTERN.matcher(dateStr.trim()).matches()){ + try { + Matcher matcher = DATE_PATTERN.matcher(dateStr.trim()); + if (matcher.find()) { + int month = Integer.parseInt(matcher.group(1)); + int day = Integer.parseInt(matcher.group(2)); + int hour = Integer.parseInt(matcher.group(3)); + int minute = Integer.parseInt(matcher.group(4)); + + // 使用当前年份 + int year = java.time.Year.now().getValue(); + return LocalDateTime.of(year, month, day, hour, minute); + } + } catch (Exception e) { + e.printStackTrace(); + } + } + + if (DATE_PATTERN_DASH.matcher(dateStr.trim()).matches()){ + try { + Matcher matcher = DATE_PATTERN_DASH.matcher(dateStr.trim()); + if (matcher.find()) { + int month = Integer.parseInt(matcher.group(1)); + int day = Integer.parseInt(matcher.group(2)); + int hour = Integer.parseInt(matcher.group(3)); + int minute = Integer.parseInt(matcher.group(4)); + + // 使用当前年份 + int year = java.time.Year.now().getValue(); + return LocalDateTime.of(year, month, day, hour, minute); + } + } catch (Exception e) { + e.printStackTrace(); + } + } + + return null; + } + + /** + * 解析OCR识别结果,提取金额、商户名称、日期等信息 + * 重构OCR识别结果的处理逻辑,大概思想是先识别时间 时间可能有多种格式 微信的格式为 12-12 13:14 支付宝的格式为 今天 13:14 昨天 13:14 + * 其他平台的格式为 12月2日13:14 等等 识别出日期以后 日期之前的3-4个值就可能是此笔支付的其他数据 一般的格式为 [商户/描述] [金额] 可能存在的[分类] [时间] + * 所以在识别出日期后 查看前3-4个值中是否有类似金额的值 + */ + public static List parse(String ocrResultJson) { + try { + JSONObject jsonObject = JSON.parseObject(ocrResultJson); + System.out.println(); + String data = jsonObject.getString("data"); + if (data == null) { + + return List.of(new ParseResult(null, null, null, BigDecimal.ZERO)); + } + + JSONObject dataObject = JSON.parseObject(data); + String content = dataObject.getString("content"); +// String content = "下午2:01 0.3K/s必 5G ra HD ID 4G 10 C 49 D < Q搜索交易记录 搜索 全部 支出 转账 退款 订单筛选 ¥198 ¥3,092.83 ¥0.00 收支分析 设置支出预算> C 五华区皓月千里便利店 -4.20 日用百货 今天 13:30 扫收钱码付款-给快乐 -11.00 餐饮美食 今天 12:22 余额宝-收益发放 0.19 投资理财 今天 04:47 2000406014951497 -20.00 餐饮美食 昨天 22:14 扫收钱码付款-给扫码点单店主 -5.00 公共服务 昨天 21:44 蜜雪冰城920749店 -2.18 餐饮美食 TA "; + System.out.println(content); + String[] split = content.split(" "); + + System.out.println("split:"+ split); + System.out.println("split.size:"+ split.length); + + ArrayList> signList = new ArrayList<>(); + + System.out.println("开始解析"); + for (int i = 1; i < split.length; i++) { + String currentLine = split[i].trim(); + + // 安全地获取 i+1, i+2, i+3 位置的值 + String iPlusOne = null; + String iPlusTwo = null; + String iPlusThree = null; + + iPlusOne = split[i - 1].trim(); + if (i > 1) { + iPlusTwo = split[i - 2].trim(); + } + if (i > 2) { + iPlusThree = split[i - 3].trim(); + } + + // 检查当前行是否符合日期格式 + if (isValidDate(currentLine)) { + Map signMap = new HashMap<>(); + + //判断前三个值是否包含了金额 + + //plusOne是金额 + if (iPlusOne != null && ((iPlusOne.startsWith("+") || iPlusOne.startsWith("-")) + || AMOUNT_PATTERN.matcher(Objects.requireNonNull(iPlusOne).trim()).matches())) { + if (UNION_DATE_PATTERN.matcher(currentLine).matches()){ + // 检查 i+1 是否在数组范围内 + if (i + 1 < split.length) { + signMap.put("data", currentLine + " " + split[i+1].trim()); + } else { + signMap.put("data", currentLine); + } + }else { + signMap.put("data", currentLine); + } + signMap.put("money", iPlusOne); + signMap.put("content", iPlusTwo); + signList.add(signMap); + } + + //plusTwo是金额 + if (iPlusTwo != null && ((iPlusTwo.startsWith("+") || iPlusTwo.startsWith("-")) + || AMOUNT_PATTERN.matcher(Objects.requireNonNull(iPlusTwo).trim()).matches())) { + if (UNION_DATE_PATTERN.matcher(currentLine).matches()){ + // 检查 i+1 是否在数组范围内 + if (i + 1 < split.length) { + signMap.put("data", currentLine + " " + split[i+1].trim()); + } else { + signMap.put("data", currentLine); + } + }else { + signMap.put("data", currentLine); + } + signMap.put("money", iPlusTwo); + signMap.put("content", iPlusThree); + signList.add(signMap); + + } + + //plusThree是金额 + if (iPlusThree != null && ((iPlusThree.startsWith("+") || iPlusThree.startsWith("-")) + || AMOUNT_PATTERN.matcher(Objects.requireNonNull(iPlusThree).trim()).matches())) { + if (UNION_DATE_PATTERN.matcher(currentLine).matches()){ + // 检查 i+1 是否在数组范围内 + if (i + 1 < split.length) { + signMap.put("data", currentLine + " " + split[i+1].trim()); + } else { + signMap.put("data", currentLine); + } + }else { + signMap.put("data", currentLine); + } + signMap.put("money", iPlusThree); + // 检查 i-4 是否在数组范围内 + if (i - 4 >= 0) { + signMap.put("content", split[i - 4].trim()); + } + signList.add(signMap); + } + } + } + + System.out.println("识别结束,开始对结果进行处理"); + System.out.println("signList:"+signList); + + //识别完成,对识别结果进行处理 + + ArrayList parseList = new ArrayList<>(); + signList.forEach(signMap -> { + ParseResult result = new ParseResult(); + if (signMap.containsKey("money")){ + result.setAmount(parseMoneyString(signMap.get("money"))); + } + if (signMap.containsKey("content")){ + result.setMerchant(signMap.get("content")); + } + if (signMap.containsKey("data")){ + result.setDate(parseDateTimeString(signMap.get("data"))); + } + + parseList.add(result); + }); + + return parseList; + } catch (Exception e) { + throw new RuntimeException("解析过程中出错,请重试或联系管理员,报错信息:"+e); + } + } + + /** + * 从OCR结果中提取文本内容 + */ + private static String extractContent(JSONObject data) { + StringBuilder content = new StringBuilder(); + + // 尝试获取prism_wordsInfo字段(通用文字识别) + JSONArray wordsInfo = data.getJSONArray("prism_wordsInfo"); + if (wordsInfo != null) { + for (int i = 0; i < wordsInfo.size(); i++) { + JSONObject word = wordsInfo.getJSONObject(i); + String wordStr = word.getString("word"); + if (wordStr != null) { + content.append(wordStr).append(" "); + } + } + } + + // 如果没有prism_wordsInfo,尝试获取content字段 + if (content.length() == 0) { + String contentStr = data.getString("content"); + if (contentStr != null) { + content.append(contentStr); + } + } + + return content.toString().trim(); + } + + /** + * 解析金额 + */ + private static BigDecimal parseAmount(String content) { + Matcher matcher = AMOUNT_PATTERN.matcher(content); + + // 查找所有匹配的金额,取最大的(通常是实际支付金额) + BigDecimal maxAmount = null; + while (matcher.find()) { + String amountStr = matcher.group(1); + try { + BigDecimal amount = new BigDecimal(amountStr); + if (maxAmount == null || amount.compareTo(maxAmount) > 0) { + maxAmount = amount; + } + } catch (NumberFormatException e) { + // 忽略解析失败的金额 + } + } + + return maxAmount; + } + + /** + * 解析日期 + */ + private static LocalDate parseDate(String content) { + Matcher matcher = DATE_PATTERN.matcher(content); + if (matcher.find()) { + try { + int year = Integer.parseInt(matcher.group(1)); + int month = Integer.parseInt(matcher.group(2)); + int day = Integer.parseInt(matcher.group(3)); + return LocalDate.of(year, month, day); + } catch (Exception e) { + // 解析失败,返回null + } + } + return null; + } + + /** + * 解析商户名称 + */ + private static String parseMerchant(String content) { + // 查找包含商户关键词的行 + String[] lines = content.split("\n"); + for (String line : lines) { + for (String keyword : MERCHANT_KEYWORDS) { + if (line.contains(keyword)) { + // 提取商户名称(去除关键词本身) + String merchant = line.replace(keyword, "").trim(); + if (!merchant.isEmpty() && merchant.length() < 50) { + return merchant; + } + } + } + } + + // 如果没有找到,返回第一行非金额非日期的文本 + for (String line : lines) { + line = line.trim(); + if (!line.isEmpty() && !AMOUNT_PATTERN.matcher(line).find() && + !DATE_PATTERN.matcher(line).find() && line.length() < 50) { + return line; + } + } + + return null; + } +} + + + + + + + + + diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml new file mode 100644 index 0000000..6a9c17a --- /dev/null +++ b/src/main/resources/application.yml @@ -0,0 +1,85 @@ +spring: + application: + name: accounting-backend + datasource: + driver-class-name: com.mysql.cj.jdbc.Driver + url: jdbc:mysql://103.36.220.231:3306/accounting_db?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai + username: root + password: mysql_jP65Fc + servlet: + multipart: + max-file-size: 5MB + max-request-size: 10MB + jackson: + date-format: yyyy-MM-dd HH:mm:ss + time-zone: GMT+8 + +# MyBatis Plus配置 +mybatis-plus: + mapper-locations: classpath:mapper/*.xml + type-aliases-package: com.accounting.entity + configuration: + map-underscore-to-camel-case: true + log-impl: org.apache.ibatis.logging.stdout.StdOutImpl + global-config: + db-config: + id-type: auto + logic-delete-field: deleted + logic-delete-value: 1 + logic-not-delete-value: 0 + +# JWT配置 +jwt: + secret: accounting-secret-key-2024-change-in-production-accounting-secret-key-2024-change-in-production-accounting-secret-key-2024-change-in-production + expiration: 86400000 # 24小时,单位毫秒 + +# 阿里云OCR配置 +aliyun: + ocr: + access-key-id: ${ALIYUN_ACCESS_KEY_ID:LTAI5tDCJuB9YgLx4KeJwc9C} + access-key-secret: ${ALIYUN_ACCESS_KEY_SECRET:aitimi6EtVsLQJ8S40bqa5nZrGfGRR} + endpoint: ocr-api.cn-hangzhou.aliyuncs.com + +# 文件上传配置 +file: + upload: + path: ${user.home}/accounting/uploads/ + +# Knife4j配置 +knife4j: + enable: true + openapi: + title: 记账应用API文档 + description: 记账应用后端API接口文档 + version: 1.0.0 + contact: + name: Accounting API + license: Apache 2.0 + license-url: https://www.apache.org/licenses/LICENSE-2.0.html + servers: + - url: http://localhost:8080 + description: 本地开发环境 + +# K780 API配置 +k780: + api-url: https://sapi.k780.com + app-key: 78346 + sign: 1b502c535927b66d9b888a6d4701bf72 + timeout: 5000 + +server: + port: 12345 + +logging: + level: + com.accounting: debug + org.springframework.security: debug + + + + + + + + + diff --git a/src/main/resources/db/migration_remove_gold_price_unique_constraint.sql b/src/main/resources/db/migration_remove_gold_price_unique_constraint.sql new file mode 100644 index 0000000..218663c --- /dev/null +++ b/src/main/resources/db/migration_remove_gold_price_unique_constraint.sql @@ -0,0 +1,5 @@ +-- 删除 gold_price 表的唯一约束 uk_gold_date +-- 原因:需要支持每15分钟查询一次,同一天需要存储多条记录 +-- 执行时间:2026-01-23 + +ALTER TABLE `gold_price` DROP INDEX `uk_gold_date`; diff --git a/src/main/resources/db/schema.sql b/src/main/resources/db/schema.sql new file mode 100644 index 0000000..bd13c74 --- /dev/null +++ b/src/main/resources/db/schema.sql @@ -0,0 +1,145 @@ +-- 创建数据库 +CREATE DATABASE IF NOT EXISTS accounting_db DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +USE accounting_db; + +-- 用户表 +CREATE TABLE IF NOT EXISTS `user` ( + `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '用户ID', + `username` VARCHAR(50) NOT NULL UNIQUE COMMENT '用户名', + `password` VARCHAR(255) NOT NULL COMMENT '密码(加密)', + `nickname` VARCHAR(50) COMMENT '昵称', + `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + PRIMARY KEY (`id`), + INDEX `idx_username` (`username`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户表'; + +-- 分类表 +CREATE TABLE IF NOT EXISTS `category` ( + `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '分类ID', + `user_id` BIGINT COMMENT '用户ID(NULL表示系统预设分类)', + `name` VARCHAR(50) NOT NULL COMMENT '分类名称', + `icon` VARCHAR(100) COMMENT '图标', + `type` TINYINT NOT NULL COMMENT '类型:1-支出,2-收入', + `sort_order` INT DEFAULT 0 COMMENT '排序顺序', + `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + PRIMARY KEY (`id`), + INDEX `idx_user_id` (`user_id`), + INDEX `idx_type` (`type`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='分类表'; + +-- 账单表 +CREATE TABLE IF NOT EXISTS `bill` ( + `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '账单ID', + `user_id` BIGINT NOT NULL COMMENT '用户ID', + `account_id` BIGINT COMMENT '账户ID', + `category_id` BIGINT NOT NULL COMMENT '分类ID', + `amount` DECIMAL(10,2) NOT NULL COMMENT '金额', + `description` VARCHAR(255) COMMENT '描述', + `bill_date` DATE NOT NULL COMMENT '账单日期', + `image_url` VARCHAR(500) COMMENT '图片URL', + `type` TINYINT COMMENT '类型:1-支出,2-收入', + `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + PRIMARY KEY (`id`), + INDEX `idx_user_id` (`user_id`), + INDEX `idx_account_id` (`account_id`), + INDEX `idx_category_id` (`category_id`), + INDEX `idx_bill_date` (`bill_date`), + FOREIGN KEY (`user_id`) REFERENCES `user` (`id`) ON DELETE CASCADE, + FOREIGN KEY (`account_id`) REFERENCES `account` (`id`) ON DELETE RESTRICT, + FOREIGN KEY (`category_id`) REFERENCES `category` (`id`) ON DELETE RESTRICT +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='账单表'; + +-- 账户表 +CREATE TABLE IF NOT EXISTS `account` ( + `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '账户ID', + `user_id` BIGINT NOT NULL COMMENT '用户ID', + `name` VARCHAR(50) NOT NULL DEFAULT '默认账户' COMMENT '账户名称', + `initial_balance` DECIMAL(10,2) NOT NULL DEFAULT 0.00 COMMENT '初始余额', + `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + PRIMARY KEY (`id`), + INDEX `idx_user_id` (`user_id`), + FOREIGN KEY (`user_id`) REFERENCES `user` (`id`) ON DELETE CASCADE, + UNIQUE KEY `uk_user_account` (`user_id`) COMMENT '每个用户只有一个账户' +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='账户表'; + +-- 预算表 +CREATE TABLE IF NOT EXISTS `budget` ( + `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '预算ID', + `user_id` BIGINT NOT NULL COMMENT '用户ID', + `year` INT NOT NULL COMMENT '年份', + `month` INT NOT NULL COMMENT '月份(1-12)', + `amount` DECIMAL(10,2) NOT NULL DEFAULT 0.00 COMMENT '预算金额', + `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + PRIMARY KEY (`id`), + INDEX `idx_user_id` (`user_id`), + INDEX `idx_year_month` (`year`, `month`), + FOREIGN KEY (`user_id`) REFERENCES `user` (`id`) ON DELETE CASCADE, + UNIQUE KEY `uk_user_year_month` (`user_id`, `year`, `month`) COMMENT '每个用户每个月只有一个预算' +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='预算表'; + +-- OCR记录表 +CREATE TABLE IF NOT EXISTS `ocr_record` ( + `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT 'OCR记录ID', + `bill_id` BIGINT COMMENT '账单ID(识别成功后关联)', + `user_id` BIGINT NOT NULL COMMENT '用户ID', + `image_url` VARCHAR(500) NOT NULL COMMENT '图片URL', + `ocr_result` TEXT COMMENT 'OCR识别结果(JSON格式)', + `parsed_amount` DECIMAL(10,2) COMMENT '解析出的金额', + `parsed_merchant` VARCHAR(255) COMMENT '解析出的商户名称', + `parsed_date` DATE COMMENT '解析出的日期', + `confidence` DECIMAL(5,2) COMMENT '置信度', + `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + PRIMARY KEY (`id`), + INDEX `idx_bill_id` (`bill_id`), + INDEX `idx_user_id` (`user_id`), + FOREIGN KEY (`bill_id`) REFERENCES `bill` (`id`) ON DELETE SET NULL, + FOREIGN KEY (`user_id`) REFERENCES `user` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='OCR记录表'; + +-- 黄金价格记录表 +CREATE TABLE IF NOT EXISTS `gold_price` ( + `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '记录ID', + `gold_id` VARCHAR(20) NOT NULL COMMENT '黄金品种ID', + `gold_name` VARCHAR(50) COMMENT '黄金品种名称', + `price` DECIMAL(10,2) NOT NULL COMMENT '当前价格', + `price_change` DECIMAL(10,2) COMMENT '涨跌额', + `price_change_percent` DECIMAL(5,2) COMMENT '涨跌百分比', + `high_price` DECIMAL(10,2) COMMENT '最高价', + `low_price` DECIMAL(10,2) COMMENT '最低价', + `open_price` DECIMAL(10,2) COMMENT '开盘价', + `yesterday_close` DECIMAL(10,2) COMMENT '昨收价', + `update_time` DATETIME NOT NULL COMMENT 'API返回的更新时间', + `price_date` DATE NOT NULL COMMENT '价格日期', + `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '记录创建时间', + PRIMARY KEY (`id`), + INDEX `idx_gold_id` (`gold_id`), + INDEX `idx_price_date` (`price_date`), + INDEX `idx_gold_date` (`gold_id`, `price_date`) COMMENT '索引:用于按日期查询' +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='黄金价格记录表'; + +-- 插入预设分类(支出) +INSERT INTO `category` (`user_id`, `name`, `icon`, `type`, `sort_order`) VALUES +(NULL, '餐饮', '🍔', 1, 1), +(NULL, '交通', '🚗', 1, 2), +(NULL, '购物', '🛍️', 1, 3), +(NULL, '娱乐', '🎬', 1, 4), +(NULL, '医疗', '🏥', 1, 5), +(NULL, '教育', '📚', 1, 6), +(NULL, '住房', '🏠', 1, 7), +(NULL, '水电', '💡', 1, 8), +(NULL, '通讯', '📱', 1, 9), +(NULL, '其他', '📦', 1, 10); + +-- 插入预设分类(收入) +INSERT INTO `category` (`user_id`, `name`, `icon`, `type`, `sort_order`) VALUES +(NULL, '工资', '💰', 2, 1), +(NULL, '奖金', '🎁', 2, 2), +(NULL, '投资', '📈', 2, 3), +(NULL, '兼职', '💼', 2, 4), +(NULL, '其他', '📦', 2, 5); diff --git a/src/main/resources/mapper/.gitkeep b/src/main/resources/mapper/.gitkeep new file mode 100644 index 0000000..e69de29