项目初始化-后端

This commit is contained in:
ni ziyi 2025-12-12 16:37:27 +08:00
commit a8889cf432
41 changed files with 2660 additions and 0 deletions

45
.gitignore vendored Normal file
View File

@ -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/

119
BACKEND_FILES_CHECKLIST.md Normal file
View File

@ -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

165
pom.xml Normal file
View File

@ -0,0 +1,165 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.1.5</version>
<relativePath/>
</parent>
<groupId>com.accounting</groupId>
<artifactId>accounting-backend</artifactId>
<version>1.0.0</version>
<name>Accounting Backend</name>
<description>记账应用后端服务</description>
<properties>
<java.version>17</java.version>
<mybatis-plus.version>3.5.4</mybatis-plus.version>
<jwt.version>0.12.3</jwt.version>
<aliyun-sdk-core.version>4.6.4</aliyun-sdk-core.version>
<aliyun-sdk-ocr.version>3.1.3</aliyun-sdk-ocr.version>
<knife4j.version>4.3.0</knife4j.version>
<aliyun-tea.version>0.2.8</aliyun-tea.version>
</properties>
<dependencies>
<!-- Spring Boot Web -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- Spring Boot Security -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<!-- MyBatis Plus -->
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
<version>${mybatis-plus.version}</version>
</dependency>
<!-- MySQL Driver -->
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
<scope>runtime</scope>
</dependency>
<!-- JWT -->
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>${jwt.version}</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>${jwt.version}</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
<version>${jwt.version}</version>
<scope>runtime</scope>
</dependency>
<!-- 阿里云OCR SDK -->
<dependency>
<groupId>com.aliyun</groupId>
<artifactId>ocr_api20210707</artifactId>
<version>${aliyun-sdk-ocr.version}</version>
</dependency>
<dependency>
<groupId>com.aliyun</groupId>
<artifactId>tea-openapi</artifactId>
<version>0.2.8</version>
</dependency>
<dependency>
<groupId>com.aliyun</groupId>
<artifactId>tea-console</artifactId>
<version>0.0.1</version>
</dependency>
<dependency>
<groupId>com.aliyun</groupId>
<artifactId>tea-util</artifactId>
<version>0.2.21</version>
</dependency>
<!-- Lombok -->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<!-- Validation -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<!-- Knife4j API文档 -->
<dependency>
<groupId>com.github.xiaoymin</groupId>
<artifactId>knife4j-openapi3-jakarta-spring-boot-starter</artifactId>
<version>${knife4j.version}</version>
</dependency>
<!-- 文件上传 -->
<dependency>
<groupId>commons-io</groupId>
<artifactId>commons-io</artifactId>
<version>2.11.0</version>
</dependency>
<!-- FastJSON2 -->
<dependency>
<groupId>com.alibaba.fastjson2</groupId>
<artifactId>fastjson2</artifactId>
<version>2.0.43</version>
</dependency>
<!-- Test -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<excludes>
<exclude>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</exclude>
</excludes>
</configuration>
</plugin>
</plugins>
</build>
</project>

View File

@ -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);
}
}

View File

@ -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);
}
}

View File

@ -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);
}
}

View File

@ -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<String, Object> claims = new HashMap<>();
claims.put("username", username);
return createToken(claims, username);
}
private String createToken(Map<String, Object> 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));
}
}

View File

@ -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;
}
}

View File

@ -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<User>()
.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<>()
);
}
}

View File

@ -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<AuthResponse> register(@Valid @RequestBody RegisterRequest request) {
AuthResponse response = authService.register(request);
return ResponseEntity.ok(response);
}
@Operation(summary = "用户登录")
@PostMapping("/login")
public ResponseEntity<AuthResponse> login(@Valid @RequestBody LoginRequest request) {
AuthResponse response = authService.login(request);
return ResponseEntity.ok(response);
}
}

View File

@ -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<BillResponse> 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<BillResponse> 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<Void> deleteBill(@PathVariable Long id, Authentication authentication) {
Long userId = getUserId(authentication);
billService.deleteBill(id, userId);
return ResponseEntity.ok().build();
}
@Operation(summary = "获取账单详情")
@GetMapping("/{id}")
public ResponseEntity<BillResponse> 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<List<BillResponse>> 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<BillResponse> 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<User>()
.eq(User::getUsername, username)
);
return user != null ? user.getId() : null;
}
}

View File

@ -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<List<CategoryResponse>> getCategories(
@RequestParam(required = false) Integer type,
Authentication authentication) {
Long userId = getUserId(authentication);
List<CategoryResponse> categories = categoryService.getCategories(userId, type);
return ResponseEntity.ok(categories);
}
@Operation(summary = "创建分类")
@PostMapping
public ResponseEntity<CategoryResponse> 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<CategoryResponse> 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<Void> 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<User>()
.eq(User::getUsername, username)
);
return user != null ? user.getId() : null;
}
}

View File

@ -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<OcrAmountParser.ParseResult> 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<User>()
.eq(User::getUsername, username)
);
if (user == null) {
throw new RuntimeException("用户不存在");
}
Long userId = user.getId();
// 调用OCR服务
List<OcrAmountParser.ParseResult> parseResults = ocrService.recognizeImage(file, userId);
return parseResults;
}
}

View File

@ -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<StatisticsResponse> 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<StatisticsResponse> 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<StatisticsResponse> 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<User>()
.eq(User::getUsername, username)
);
return user != null ? user.getId() : null;
}
}

View File

@ -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;
}
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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-收入
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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;
}
}

View File

@ -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;
}

View File

@ -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> categoryStatistics; // 分类统计
private List<DailyStatistics> 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;
}
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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<Bill> {
}

View File

@ -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<Category> {
}

View File

@ -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<OcrRecord> {
}

View File

@ -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<User> {
}

View File

@ -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<User>()
.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<User>()
.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());
}
}

View File

@ -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<Bill>()
.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<Bill>()
.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<Bill>()
.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<BillResponse> getBills(Long userId, LocalDate startDate, LocalDate endDate) {
LambdaQueryWrapper<Bill> wrapper = new LambdaQueryWrapper<Bill>()
.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<Bill> 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;
}
}

View File

@ -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<CategoryResponse> getCategories(Long userId, Integer type) {
LambdaQueryWrapper<Category> wrapper = new LambdaQueryWrapper<Category>()
.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<Category> 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<Category>()
.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<Category>()
.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<Category>()
.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;
}
}

File diff suppressed because one or more lines are too long

View File

@ -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<Bill> wrapper = new LambdaQueryWrapper<Bill>()
.eq(Bill::getUserId, userId)
.ge(Bill::getBillDate, startDate)
.le(Bill::getBillDate, endDate);
List<Bill> bills = billMapper.selectList(wrapper);
StatisticsResponse response = new StatisticsResponse();
response.setTotalIncome(BigDecimal.ZERO);
response.setTotalExpense(BigDecimal.ZERO);
// 按日期分组统计
Map<String, StatisticsResponse.DailyStatistics> dailyMap = new HashMap<>();
// 按分类统计
Map<Long, StatisticsResponse.CategoryStatistics> 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);
}
}

View File

@ -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;
}
}
}

View File

@ -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.00100.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日1314 这样的格式
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日1314"
* @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<ParseResult> 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<Map<String,String>> 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<String, String> 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<ParseResult> 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;
}
}

View File

@ -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

View File

@ -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 '用户IDNULL表示系统预设分类',
`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);

View File