From a8889cf4329ccb6c570c85e5822fa4a41b27589e Mon Sep 17 00:00:00 2001 From: ni ziyi <310925901@qq.com> Date: Fri, 12 Dec 2025 16:37:27 +0800 Subject: [PATCH] =?UTF-8?q?=E9=A1=B9=E7=9B=AE=E5=88=9D=E5=A7=8B=E5=8C=96-?= =?UTF-8?q?=E5=90=8E=E7=AB=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .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/SecurityConfig.java | 78 ++++ .../config/UserDetailsServiceImpl.java | 45 +++ .../accounting/controller/AuthController.java | 44 +++ .../accounting/controller/BillController.java | 88 +++++ .../controller/CategoryController.java | 77 ++++ .../accounting/controller/OcrController.java | 59 +++ .../controller/StatisticsController.java | 74 ++++ .../java/com/accounting/dto/AuthResponse.java | 25 ++ .../java/com/accounting/dto/BillRequest.java | 27 ++ .../java/com/accounting/dto/BillResponse.java | 27 ++ .../com/accounting/dto/CategoryRequest.java | 25 ++ .../com/accounting/dto/CategoryResponse.java | 24 ++ .../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 ++ src/main/java/com/accounting/entity/Bill.java | 38 ++ .../java/com/accounting/entity/Category.java | 38 ++ .../java/com/accounting/entity/OcrRecord.java | 44 +++ src/main/java/com/accounting/entity/User.java | 34 ++ .../com/accounting/mapper/BillMapper.java | 18 + .../com/accounting/mapper/CategoryMapper.java | 18 + .../accounting/mapper/OcrRecordMapper.java | 18 + .../com/accounting/mapper/UserMapper.java | 18 + .../com/accounting/service/AuthService.java | 86 +++++ .../com/accounting/service/BillService.java | 155 ++++++++ .../accounting/service/CategoryService.java | 118 ++++++ .../com/accounting/service/OcrService.java | 76 ++++ .../accounting/service/StatisticsService.java | 117 ++++++ .../java/com/accounting/util/FileUtil.java | 114 ++++++ .../com/accounting/util/OcrAmountParser.java | 345 ++++++++++++++++++ src/main/resources/application.yml | 78 ++++ src/main/resources/db/schema.sql | 91 +++++ src/main/resources/mapper/.gitkeep | 0 41 files changed, 2660 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/SecurityConfig.java create mode 100644 src/main/java/com/accounting/config/UserDetailsServiceImpl.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/CategoryController.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/AuthResponse.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/CategoryRequest.java create mode 100644 src/main/java/com/accounting/dto/CategoryResponse.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/Bill.java create mode 100644 src/main/java/com/accounting/entity/Category.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/BillMapper.java create mode 100644 src/main/java/com/accounting/mapper/CategoryMapper.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/service/AuthService.java create mode 100644 src/main/java/com/accounting/service/BillService.java create mode 100644 src/main/java/com/accounting/service/CategoryService.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/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/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/SecurityConfig.java b/src/main/java/com/accounting/config/SecurityConfig.java new file mode 100644 index 0000000..45b36d3 --- /dev/null +++ b/src/main/java/com/accounting/config/SecurityConfig.java @@ -0,0 +1,78 @@ +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("/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("*")); + configuration.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "DELETE", "OPTIONS")); + configuration.setAllowedHeaders(Arrays.asList("*")); + configuration.setAllowCredentials(false); + 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/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..7f2f425 --- /dev/null +++ b/src/main/java/com/accounting/controller/BillController.java @@ -0,0 +1,88 @@ +package com.accounting.controller; + +import com.accounting.dto.BillRequest; +import com.accounting.dto.BillResponse; +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 = "更新账单") + @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/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/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/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/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/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/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/Bill.java b/src/main/java/com/accounting/entity/Bill.java new file mode 100644 index 0000000..775d763 --- /dev/null +++ b/src/main/java/com/accounting/entity/Bill.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.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 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/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/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/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/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/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/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..44d6e9b --- /dev/null +++ b/src/main/java/com/accounting/service/BillService.java @@ -0,0 +1,155 @@ +package com.accounting.service; + +import com.accounting.dto.BillRequest; +import com.accounting.dto.BillResponse; +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.List; +import java.util.stream.Collectors; + +@Service +public class BillService { + + @Autowired + private BillMapper billMapper; + + @Autowired + private CategoryMapper categoryMapper; + + @Transactional + public BillResponse createBill(BillRequest request, Long userId) { + // 验证分类是否存在 + Category category = categoryMapper.selectById(request.getCategoryId()); + if (category == null) { + throw new RuntimeException("分类不存在"); + } + + // 创建账单 + Bill bill = new Bill(); + bill.setUserId(userId); + 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 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("分类不存在"); + } + + // 更新账单 + 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/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/OcrService.java b/src/main/java/com/accounting/service/OcrService.java new file mode 100644 index 0000000..30173fa --- /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(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/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..ef39807 --- /dev/null +++ b/src/main/java/com/accounting/util/OcrAmountParser.java @@ -0,0 +1,345 @@ +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.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +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})" + ); + + // 商户名称关键词(常见支付平台) + 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; + } + return UNION_DATE_PATTERN.matcher(dateStr.trim()).matches(); + } + + /** + * 将字符串转换为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 日期字符串,例如:"12月2日13:14" + * @return LocalDateTime对象 + */ + private static LocalDateTime parseDateTimeString(String dateStr) { + if (dateStr == null || dateStr.trim().isEmpty()) { + return null; + } + + 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(); + } + return null; + } + + /** + * 解析OCR识别结果,提取金额、商户名称、日期等信息 + */ + 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[] split = content.split(" "); + + ArrayList> signList = new ArrayList<>(); + + for (int i = 1; i < split.length; i++) { + String currentLine = split[i].trim(); + String previousLine = split[i-1].trim(); + + // 安全地获取 i+1, i+2, i+3 位置的值 + String iPlusOne = null; + String iPlusTwo = null; + String iPlusThree = null; + + if (i + 1 < split.length) { + iPlusOne = split[i + 1].trim(); + } + if (i + 2 < split.length) { + iPlusTwo = split[i + 2].trim(); + } + if (i + 3 < split.length) { + iPlusThree = split[i + 3].trim(); + } + + // 检查当前行是否以+或-开头 + if (currentLine.startsWith("+") || currentLine.startsWith("-")) { + Map signMap = new HashMap<>(); + signMap.put("money", currentLine); + signMap.put("content", previousLine); + + // 检查 i+1, i+2, i+3 哪个是日期格式 + String dateValue = null; + if (i + 1 < split.length && isValidDate(iPlusOne)) { + dateValue = iPlusOne; + } else if (i + 2 < split.length && isValidDate(iPlusTwo)) { + dateValue = iPlusTwo; + } else if (i + 3 < split.length && isValidDate(iPlusThree)) { + dateValue = iPlusThree; + } + + if (dateValue != null) { + signMap.put("date", dateValue); + } + signList.add(signMap); + } + } + + //识别完成,对识别结果进行处理 + + 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(parseMerchant(signMap.get("content"))); + } + if (signMap.containsKey("date")){ + result.setDate(parseDateTimeString(signMap.get("date"))); + } + + 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..09a8576 --- /dev/null +++ b/src/main/resources/application.yml @@ -0,0 +1,78 @@ +spring: + application: + name: accounting-backend + datasource: + driver-class-name: com.mysql.cj.jdbc.Driver + url: jdbc:mysql://45.207.192.237/accounting_db?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai + username: root + password: mysql_4DBzGc + 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: 本地开发环境 + +server: + port: 8080 + +logging: + level: + com.accounting: debug + org.springframework.security: debug + + + + + + + + + diff --git a/src/main/resources/db/schema.sql b/src/main/resources/db/schema.sql new file mode 100644 index 0000000..03c96a7 --- /dev/null +++ b/src/main/resources/db/schema.sql @@ -0,0 +1,91 @@ +-- 创建数据库 +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', + `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_category_id` (`category_id`), + INDEX `idx_bill_date` (`bill_date`), + FOREIGN KEY (`user_id`) REFERENCES `user` (`id`) ON DELETE CASCADE, + FOREIGN KEY (`category_id`) REFERENCES `category` (`id`) ON DELETE RESTRICT +) 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记录表'; + +-- 插入预设分类(支出) +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