commit a8889cf4329ccb6c570c85e5822fa4a41b27589e
Author: ni ziyi <310925901@qq.com>
Date: Fri Dec 12 16:37:27 2025 +0800
项目初始化-后端
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