first commit
This commit is contained in:
+45
@@ -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/
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
package com.accounting.config;
|
||||
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
@Configuration
|
||||
@ConfigurationProperties(prefix = "k780")
|
||||
public class K780Config {
|
||||
private String apiUrl = "https://sapi.k780.com";
|
||||
private String appKey = "78346";
|
||||
private String sign = "1b502c535927b66d9b888a6d4701bf72";
|
||||
private int timeout = 5000;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package com.accounting.config;
|
||||
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.http.client.SimpleClientHttpRequestFactory;
|
||||
import org.springframework.web.client.RestTemplate;
|
||||
|
||||
@Configuration
|
||||
public class RestTemplateConfig {
|
||||
|
||||
@Bean
|
||||
public RestTemplate restTemplate() {
|
||||
SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory();
|
||||
factory.setConnectTimeout(5000);
|
||||
factory.setReadTimeout(5000);
|
||||
return new RestTemplate(factory);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package com.accounting.config;
|
||||
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.lang.NonNull;
|
||||
import org.springframework.scheduling.annotation.EnableScheduling;
|
||||
import org.springframework.scheduling.annotation.SchedulingConfigurer;
|
||||
import org.springframework.scheduling.config.ScheduledTaskRegistrar;
|
||||
|
||||
import java.util.concurrent.Executor;
|
||||
import java.util.concurrent.Executors;
|
||||
|
||||
@Configuration
|
||||
@EnableScheduling
|
||||
public class SchedulerConfig implements SchedulingConfigurer {
|
||||
|
||||
/**
|
||||
* 配置定时任务为单线程执行,避免并发问题
|
||||
*/
|
||||
@Override
|
||||
public void configureTasks(@NonNull ScheduledTaskRegistrar taskRegistrar) {
|
||||
taskRegistrar.setScheduler(taskExecutor());
|
||||
}
|
||||
|
||||
@Bean(destroyMethod = "shutdown")
|
||||
public Executor taskExecutor() {
|
||||
return Executors.newSingleThreadScheduledExecutor(r -> {
|
||||
Thread thread = new Thread(r, "gold-price-scheduler");
|
||||
thread.setDaemon(true);
|
||||
return thread;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
package com.accounting.config;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.security.authentication.AuthenticationManager;
|
||||
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
|
||||
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
|
||||
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
||||
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
|
||||
import org.springframework.security.config.http.SessionCreationPolicy;
|
||||
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.security.web.SecurityFilterChain;
|
||||
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
|
||||
import org.springframework.web.cors.CorsConfiguration;
|
||||
import org.springframework.web.cors.CorsConfigurationSource;
|
||||
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
|
||||
|
||||
import java.util.Arrays;
|
||||
|
||||
@Configuration
|
||||
@EnableWebSecurity
|
||||
@EnableMethodSecurity
|
||||
public class SecurityConfig {
|
||||
|
||||
@Autowired
|
||||
private JwtAuthenticationFilter jwtAuthenticationFilter;
|
||||
|
||||
@Bean
|
||||
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
|
||||
http
|
||||
.csrf(csrf -> csrf.disable())
|
||||
.cors(cors -> cors.configurationSource(corsConfigurationSource()))
|
||||
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
|
||||
.authorizeHttpRequests(auth -> auth
|
||||
.requestMatchers("/api/auth/**").permitAll()
|
||||
.requestMatchers("/api/gold-price/**").permitAll()
|
||||
.requestMatchers("/doc.html", "/swagger-ui/**", "/v3/api-docs/**", "/webjars/**").permitAll()
|
||||
.anyRequest().authenticated()
|
||||
)
|
||||
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
|
||||
|
||||
return http.build();
|
||||
}
|
||||
|
||||
@Bean
|
||||
public PasswordEncoder passwordEncoder() {
|
||||
return new BCryptPasswordEncoder();
|
||||
}
|
||||
|
||||
@Bean
|
||||
public AuthenticationManager authenticationManager(AuthenticationConfiguration authConfig) throws Exception {
|
||||
return authConfig.getAuthenticationManager();
|
||||
}
|
||||
|
||||
@Bean
|
||||
public CorsConfigurationSource corsConfigurationSource() {
|
||||
CorsConfiguration configuration = new CorsConfiguration();
|
||||
// 允许你的前端域名访问,根据实际情况修改
|
||||
configuration.setAllowedOrigins(Arrays.asList(
|
||||
"https://accounting.aqroid.cn",
|
||||
"http://localhost:5173"
|
||||
));
|
||||
configuration.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "DELETE", "OPTIONS", "HEAD"));
|
||||
configuration.setAllowedHeaders(Arrays.asList("*"));
|
||||
configuration.setAllowCredentials(true); // 允许携带认证信息
|
||||
configuration.setMaxAge(3600L);
|
||||
|
||||
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
|
||||
source.registerCorsConfiguration("/**", configuration);
|
||||
return source;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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<>()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
package com.accounting.controller;
|
||||
|
||||
import com.accounting.dto.AccountRequest;
|
||||
import com.accounting.dto.AccountResponse;
|
||||
import com.accounting.entity.User;
|
||||
import com.accounting.mapper.UserMapper;
|
||||
import com.accounting.service.AccountService;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.core.userdetails.UserDetails;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
@Tag(name = "账户管理", description = "账户管理接口")
|
||||
@RestController
|
||||
@RequestMapping("/api/accounts")
|
||||
public class AccountController {
|
||||
|
||||
@Autowired
|
||||
private AccountService accountService;
|
||||
|
||||
@Autowired
|
||||
private UserMapper userMapper;
|
||||
|
||||
@Operation(summary = "获取账户信息")
|
||||
@GetMapping
|
||||
public ResponseEntity<AccountResponse> getAccount(Authentication authentication) {
|
||||
Long userId = getUserId(authentication);
|
||||
AccountResponse response = accountService.getAccountBalance(userId);
|
||||
return ResponseEntity.ok(response);
|
||||
}
|
||||
|
||||
@Operation(summary = "更新账户信息")
|
||||
@PutMapping
|
||||
public ResponseEntity<AccountResponse> updateAccount(
|
||||
@RequestBody AccountRequest request,
|
||||
Authentication authentication) {
|
||||
Long userId = getUserId(authentication);
|
||||
AccountResponse response = accountService.updateAccount(userId, request);
|
||||
return ResponseEntity.ok(response);
|
||||
}
|
||||
|
||||
@Operation(summary = "更新账户余额")
|
||||
@PutMapping("/balance")
|
||||
public ResponseEntity<AccountResponse> updateAccountBalance(
|
||||
@RequestBody AccountRequest request,
|
||||
Authentication authentication) {
|
||||
Long userId = getUserId(authentication);
|
||||
AccountResponse response = accountService.updateAccountBalance(userId, request.getInitialBalance());
|
||||
return ResponseEntity.ok(response);
|
||||
}
|
||||
|
||||
private Long getUserId(Authentication authentication) {
|
||||
UserDetails userDetails = (UserDetails) authentication.getPrincipal();
|
||||
String username = userDetails.getUsername();
|
||||
User user = userMapper.selectOne(
|
||||
new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<User>()
|
||||
.eq(User::getUsername, username)
|
||||
);
|
||||
return user != null ? user.getId() : null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
package com.accounting.controller;
|
||||
|
||||
import com.accounting.dto.BillRequest;
|
||||
import com.accounting.dto.BillResponse;
|
||||
import com.accounting.dto.BatchBillRequest;
|
||||
import com.accounting.entity.User;
|
||||
import com.accounting.mapper.UserMapper;
|
||||
import com.accounting.service.BillService;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.validation.Valid;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.format.annotation.DateTimeFormat;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.core.userdetails.UserDetails;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
|
||||
@Tag(name = "账单管理", description = "账单CRUD接口")
|
||||
@RestController
|
||||
@RequestMapping("/api/bills")
|
||||
public class BillController {
|
||||
|
||||
@Autowired
|
||||
private BillService billService;
|
||||
|
||||
@Autowired
|
||||
private UserMapper userMapper;
|
||||
|
||||
@Operation(summary = "创建账单")
|
||||
@PostMapping
|
||||
public ResponseEntity<BillResponse> createBill(@Valid @RequestBody BillRequest request, Authentication authentication) {
|
||||
Long userId = getUserId(authentication);
|
||||
BillResponse response = billService.createBill(request, userId);
|
||||
return ResponseEntity.ok(response);
|
||||
}
|
||||
|
||||
@Operation(summary = "批量创建账单")
|
||||
@PostMapping("/batch")
|
||||
public ResponseEntity<List<BillResponse>> createBills(@Valid @RequestBody BatchBillRequest request, Authentication authentication) {
|
||||
Long userId = getUserId(authentication);
|
||||
List<BillResponse> responses = billService.createBills(request, userId);
|
||||
return ResponseEntity.ok(responses);
|
||||
}
|
||||
|
||||
@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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
package com.accounting.controller;
|
||||
|
||||
import com.accounting.dto.BudgetRequest;
|
||||
import com.accounting.dto.BudgetResponse;
|
||||
import com.accounting.dto.BudgetSettlementResponse;
|
||||
import com.accounting.entity.User;
|
||||
import com.accounting.mapper.UserMapper;
|
||||
import com.accounting.service.BudgetService;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.core.userdetails.UserDetails;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.time.LocalDate;
|
||||
|
||||
@Tag(name = "预算管理", description = "预算管理接口")
|
||||
@RestController
|
||||
@RequestMapping("/api/budgets")
|
||||
public class BudgetController {
|
||||
|
||||
@Autowired
|
||||
private BudgetService budgetService;
|
||||
|
||||
@Autowired
|
||||
private UserMapper userMapper;
|
||||
|
||||
@Operation(summary = "获取当前月份的预算信息")
|
||||
@GetMapping
|
||||
public ResponseEntity<BudgetResponse> getBudget(Authentication authentication) {
|
||||
Long userId = getUserId(authentication);
|
||||
LocalDate today = LocalDate.now();
|
||||
BudgetResponse response = budgetService.getBudgetWithStatistics(userId, today.getYear(), today.getMonthValue());
|
||||
return ResponseEntity.ok(response);
|
||||
}
|
||||
|
||||
@Operation(summary = "获取上月预算结算信息")
|
||||
@GetMapping("/settlement")
|
||||
public ResponseEntity<BudgetSettlementResponse> getBudgetSettlement(Authentication authentication) {
|
||||
Long userId = getUserId(authentication);
|
||||
BudgetSettlementResponse response = budgetService.getLastMonthSettlement(userId);
|
||||
return ResponseEntity.ok(response);
|
||||
}
|
||||
|
||||
@Operation(summary = "设置/更新预算")
|
||||
@PutMapping
|
||||
public ResponseEntity<BudgetResponse> setBudget(
|
||||
@RequestBody BudgetRequest request,
|
||||
Authentication authentication) {
|
||||
Long userId = getUserId(authentication);
|
||||
BudgetResponse response = budgetService.setBudget(userId, request);
|
||||
return ResponseEntity.ok(response);
|
||||
}
|
||||
|
||||
@Operation(summary = "更新当前月份预算")
|
||||
@PutMapping("/current")
|
||||
public ResponseEntity<BudgetResponse> updateCurrentBudget(
|
||||
@RequestBody BudgetRequest request,
|
||||
Authentication authentication) {
|
||||
Long userId = getUserId(authentication);
|
||||
LocalDate today = LocalDate.now();
|
||||
request.setYear(today.getYear());
|
||||
request.setMonth(today.getMonthValue());
|
||||
BudgetResponse response = budgetService.setBudget(userId, request);
|
||||
return ResponseEntity.ok(response);
|
||||
}
|
||||
|
||||
private Long getUserId(Authentication authentication) {
|
||||
UserDetails userDetails = (UserDetails) authentication.getPrincipal();
|
||||
String username = userDetails.getUsername();
|
||||
User user = userMapper.selectOne(
|
||||
new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<User>()
|
||||
.eq(User::getUsername, username)
|
||||
);
|
||||
return user != null ? user.getId() : null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
package com.accounting.controller;
|
||||
|
||||
import com.accounting.dto.GoldPriceResponse;
|
||||
import com.accounting.service.GoldPriceService;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@Tag(name = "黄金价格", description = "黄金价格查询接口")
|
||||
@RestController
|
||||
@RequestMapping("/api/gold-price")
|
||||
@RequiredArgsConstructor
|
||||
public class GoldPriceController {
|
||||
|
||||
private final GoldPriceService goldPriceService;
|
||||
|
||||
private static final String DEFAULT_GOLD_ID = "1053";
|
||||
|
||||
@Operation(summary = "获取当前黄金价格")
|
||||
@GetMapping("/current")
|
||||
public ResponseEntity<Map<String, Object>> getCurrentPrice(
|
||||
@RequestParam(required = false, defaultValue = DEFAULT_GOLD_ID) String goldId) {
|
||||
try {
|
||||
GoldPriceResponse data = goldPriceService.getCurrentPrice(goldId);
|
||||
Map<String, Object> response = new HashMap<>();
|
||||
response.put("code", 200);
|
||||
if (data == null) {
|
||||
response.put("message", "暂无黄金价格数据,请稍后再试");
|
||||
response.put("data", null);
|
||||
} else {
|
||||
response.put("message", "success");
|
||||
response.put("data", data);
|
||||
}
|
||||
return ResponseEntity.ok(response);
|
||||
} catch (Exception e) {
|
||||
Map<String, Object> response = new HashMap<>();
|
||||
response.put("code", 500);
|
||||
response.put("message", e.getMessage());
|
||||
response.put("data", null);
|
||||
return ResponseEntity.ok(response);
|
||||
}
|
||||
}
|
||||
|
||||
@Operation(summary = "获取历史黄金价格")
|
||||
@GetMapping("/history")
|
||||
public ResponseEntity<Map<String, Object>> getHistoryPrices(
|
||||
@RequestParam(required = false, defaultValue = DEFAULT_GOLD_ID) String goldId,
|
||||
@RequestParam(required = false, defaultValue = "30") Integer days) {
|
||||
try {
|
||||
List<GoldPriceResponse> data = goldPriceService.getHistoryPrices(goldId, days);
|
||||
Map<String, Object> response = new HashMap<>();
|
||||
response.put("code", 200);
|
||||
response.put("message", "success");
|
||||
response.put("data", data);
|
||||
return ResponseEntity.ok(response);
|
||||
} catch (Exception e) {
|
||||
Map<String, Object> response = new HashMap<>();
|
||||
response.put("code", 500);
|
||||
response.put("message", e.getMessage());
|
||||
response.put("data", null);
|
||||
return ResponseEntity.ok(response);
|
||||
}
|
||||
}
|
||||
|
||||
@Operation(summary = "获取指定日期的黄金价格记录")
|
||||
@GetMapping("/by-date")
|
||||
public ResponseEntity<Map<String, Object>> getPricesByDate(
|
||||
@RequestParam(required = false, defaultValue = DEFAULT_GOLD_ID) String goldId,
|
||||
@RequestParam(required = true) String date) {
|
||||
try {
|
||||
LocalDate localDate = LocalDate.parse(date, DateTimeFormatter.ofPattern("yyyy-MM-dd"));
|
||||
List<GoldPriceResponse> data = goldPriceService.getPricesByDate(goldId, localDate);
|
||||
Map<String, Object> response = new HashMap<>();
|
||||
response.put("code", 200);
|
||||
response.put("message", "success");
|
||||
response.put("data", data);
|
||||
return ResponseEntity.ok(response);
|
||||
} catch (Exception e) {
|
||||
Map<String, Object> response = new HashMap<>();
|
||||
response.put("code", 500);
|
||||
response.put("message", e.getMessage());
|
||||
response.put("data", null);
|
||||
return ResponseEntity.ok(response);
|
||||
}
|
||||
}
|
||||
|
||||
@Operation(summary = "获取最近有数据的日期")
|
||||
@GetMapping("/latest-date")
|
||||
public ResponseEntity<Map<String, Object>> getLatestDate(
|
||||
@RequestParam(required = false, defaultValue = DEFAULT_GOLD_ID) String goldId) {
|
||||
try {
|
||||
LocalDate latestDate = goldPriceService.getLatestDate(goldId);
|
||||
Map<String, Object> response = new HashMap<>();
|
||||
response.put("code", 200);
|
||||
response.put("message", "success");
|
||||
if (latestDate != null) {
|
||||
response.put("data", latestDate.format(DateTimeFormatter.ofPattern("yyyy-MM-dd")));
|
||||
} else {
|
||||
response.put("data", null);
|
||||
}
|
||||
return ResponseEntity.ok(response);
|
||||
} catch (Exception e) {
|
||||
Map<String, Object> response = new HashMap<>();
|
||||
response.put("code", 500);
|
||||
response.put("message", e.getMessage());
|
||||
response.put("data", null);
|
||||
return ResponseEntity.ok(response);
|
||||
}
|
||||
}
|
||||
|
||||
@Operation(summary = "立即刷新黄金价格")
|
||||
@PostMapping("/refresh")
|
||||
public ResponseEntity<Map<String, Object>> refreshPrice(
|
||||
@RequestParam(required = false, defaultValue = DEFAULT_GOLD_ID) String goldId) {
|
||||
try {
|
||||
goldPriceService.refreshCurrentPrice(goldId);
|
||||
// 刷新后返回最新的价格
|
||||
GoldPriceResponse data = goldPriceService.getCurrentPrice(goldId);
|
||||
Map<String, Object> response = new HashMap<>();
|
||||
response.put("code", 200);
|
||||
response.put("message", "刷新成功");
|
||||
response.put("data", data);
|
||||
return ResponseEntity.ok(response);
|
||||
} catch (Exception e) {
|
||||
Map<String, Object> response = new HashMap<>();
|
||||
response.put("code", 500);
|
||||
response.put("message", e.getMessage());
|
||||
response.put("data", null);
|
||||
return ResponseEntity.ok(response);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
package com.accounting.dto;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
|
||||
@Data
|
||||
public class AccountRequest {
|
||||
private String name;
|
||||
|
||||
private BigDecimal initialBalance;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
package com.accounting.dto;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Data
|
||||
public class AccountResponse {
|
||||
private Long id;
|
||||
|
||||
private String name;
|
||||
|
||||
private BigDecimal initialBalance;
|
||||
|
||||
private BigDecimal balance; // 计算后的余额(初始余额 + 收入 - 支出)
|
||||
|
||||
private BigDecimal totalIncome; // 总收入
|
||||
|
||||
private BigDecimal totalExpense; // 总支出
|
||||
|
||||
private LocalDateTime createTime;
|
||||
|
||||
private LocalDateTime updateTime;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
package com.accounting.dto;
|
||||
|
||||
import jakarta.validation.Valid;
|
||||
import jakarta.validation.constraints.NotEmpty;
|
||||
import java.util.List;
|
||||
|
||||
public class BatchBillRequest {
|
||||
@NotEmpty(message = "账单列表不能为空")
|
||||
@Valid
|
||||
private List<BillRequest> bills;
|
||||
|
||||
public List<BillRequest> getBills() {
|
||||
return bills;
|
||||
}
|
||||
|
||||
public void setBills(List<BillRequest> bills) {
|
||||
this.bills = bills;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package com.accounting.dto;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
|
||||
@Data
|
||||
public class BudgetRequest {
|
||||
private Integer year;
|
||||
|
||||
private Integer month;
|
||||
|
||||
private BigDecimal amount;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
package com.accounting.dto;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
|
||||
@Data
|
||||
public class BudgetResponse {
|
||||
private Long id;
|
||||
|
||||
private Integer year;
|
||||
|
||||
private Integer month;
|
||||
|
||||
private BigDecimal amount; // 预算金额
|
||||
|
||||
private BigDecimal usedAmount; // 已用金额(本月支出)
|
||||
|
||||
private BigDecimal remainingAmount; // 剩余预算
|
||||
|
||||
private BigDecimal remainingDaily; // 剩余日均
|
||||
}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
package com.accounting.dto;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
|
||||
@Data
|
||||
public class BudgetSettlementResponse {
|
||||
private Integer year; // 年份
|
||||
|
||||
private Integer month; // 月份
|
||||
|
||||
private BigDecimal budgetAmount; // 预算金额
|
||||
|
||||
private BigDecimal actualExpense; // 实际支出
|
||||
|
||||
private Boolean isOverBudget; // 是否超支
|
||||
|
||||
private BigDecimal overAmount; // 超支金额(如果超支)
|
||||
|
||||
private BigDecimal completionRate; // 完成率(实际支出/预算金额,如果预算为0则为null)
|
||||
}
|
||||
|
||||
@@ -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-收入
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
package com.accounting.dto;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
|
||||
@Data
|
||||
public class GoldPriceResponse {
|
||||
private String goldId;
|
||||
|
||||
private String goldName;
|
||||
|
||||
private BigDecimal price;
|
||||
|
||||
private BigDecimal priceChange;
|
||||
|
||||
private BigDecimal priceChangePercent;
|
||||
|
||||
private BigDecimal highPrice;
|
||||
|
||||
private BigDecimal lowPrice;
|
||||
|
||||
private BigDecimal openPrice;
|
||||
|
||||
private BigDecimal yesterdayClose;
|
||||
|
||||
private String updateTime;
|
||||
|
||||
private String priceDate;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package com.accounting.dto;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
public class K780Response {
|
||||
@JsonProperty("success")
|
||||
private String success;
|
||||
|
||||
@JsonProperty("result")
|
||||
private K780Result result;
|
||||
|
||||
@JsonProperty("msg")
|
||||
private String message;
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package com.accounting.dto;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
@Data
|
||||
public class K780Result {
|
||||
@JsonProperty("dtQuery")
|
||||
private String dtQuery;
|
||||
|
||||
@JsonProperty("dtCount")
|
||||
private String dtCount;
|
||||
|
||||
@JsonProperty("dtList")
|
||||
private Map<String, GoldPriceData> dtList;
|
||||
|
||||
@Data
|
||||
public static class GoldPriceData {
|
||||
@JsonProperty("goldid")
|
||||
private String goldId;
|
||||
|
||||
@JsonProperty("variety")
|
||||
private String variety;
|
||||
|
||||
@JsonProperty("varietynm")
|
||||
private String varietynm;
|
||||
|
||||
@JsonProperty("last_price")
|
||||
private String lastPrice;
|
||||
|
||||
@JsonProperty("buy_price")
|
||||
private String buyPrice;
|
||||
|
||||
@JsonProperty("sell_price")
|
||||
private String sellPrice;
|
||||
|
||||
@JsonProperty("open_price")
|
||||
private String openPrice;
|
||||
|
||||
@JsonProperty("yesy_price")
|
||||
private String yesyPrice;
|
||||
|
||||
@JsonProperty("high_price")
|
||||
private String highPrice;
|
||||
|
||||
@JsonProperty("low_price")
|
||||
private String lowPrice;
|
||||
|
||||
@JsonProperty("change_price")
|
||||
private String changePrice;
|
||||
|
||||
@JsonProperty("change_margin")
|
||||
private String changeMargin;
|
||||
|
||||
@JsonProperty("uptime")
|
||||
private String uptime;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
package com.accounting.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.Data;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Data
|
||||
@TableName("account")
|
||||
public class Account {
|
||||
@TableId(type = IdType.AUTO)
|
||||
private Long id;
|
||||
|
||||
private Long userId;
|
||||
|
||||
private String name;
|
||||
|
||||
private BigDecimal initialBalance;
|
||||
|
||||
private LocalDateTime createTime;
|
||||
|
||||
private LocalDateTime updateTime;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
package com.accounting.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.Data;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Data
|
||||
@TableName("bill")
|
||||
public class Bill {
|
||||
@TableId(type = IdType.AUTO)
|
||||
private Long id;
|
||||
|
||||
private Long userId;
|
||||
|
||||
private Long accountId;
|
||||
|
||||
private Long categoryId;
|
||||
|
||||
private BigDecimal amount;
|
||||
|
||||
private String description;
|
||||
|
||||
private LocalDate billDate;
|
||||
|
||||
private String imageUrl;
|
||||
|
||||
/**
|
||||
* 账单类型:1-支出,2-收入
|
||||
*/
|
||||
private Integer type;
|
||||
|
||||
private LocalDateTime createTime;
|
||||
|
||||
private LocalDateTime updateTime;
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package com.accounting.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.Data;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Data
|
||||
@TableName("budget")
|
||||
public class Budget {
|
||||
@TableId(type = IdType.AUTO)
|
||||
private Long id;
|
||||
|
||||
private Long userId;
|
||||
|
||||
private Integer year;
|
||||
|
||||
private Integer month;
|
||||
|
||||
private BigDecimal amount;
|
||||
|
||||
private LocalDateTime createTime;
|
||||
|
||||
private LocalDateTime updateTime;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
package com.accounting.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.Data;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Data
|
||||
@TableName("gold_price")
|
||||
public class GoldPrice {
|
||||
@TableId(type = IdType.AUTO)
|
||||
private Long id;
|
||||
|
||||
private String goldId;
|
||||
|
||||
private String goldName;
|
||||
|
||||
private BigDecimal price;
|
||||
|
||||
private BigDecimal priceChange;
|
||||
|
||||
private BigDecimal priceChangePercent;
|
||||
|
||||
private BigDecimal highPrice;
|
||||
|
||||
private BigDecimal lowPrice;
|
||||
|
||||
private BigDecimal openPrice;
|
||||
|
||||
private BigDecimal yesterdayClose;
|
||||
|
||||
private LocalDateTime updateTime;
|
||||
|
||||
private LocalDate priceDate;
|
||||
|
||||
private LocalDateTime createTime;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
package com.accounting.mapper;
|
||||
|
||||
import com.accounting.entity.Account;
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
|
||||
@Mapper
|
||||
public interface AccountMapper extends BaseMapper<Account> {
|
||||
}
|
||||
|
||||
@@ -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> {
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
package com.accounting.mapper;
|
||||
|
||||
import com.accounting.entity.Budget;
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
|
||||
@Mapper
|
||||
public interface BudgetMapper extends BaseMapper<Budget> {
|
||||
}
|
||||
|
||||
@@ -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> {
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
package com.accounting.mapper;
|
||||
|
||||
import com.accounting.entity.GoldPrice;
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
|
||||
@Mapper
|
||||
public interface GoldPriceMapper extends BaseMapper<GoldPrice> {
|
||||
|
||||
@Select("SELECT * FROM gold_price WHERE gold_id = #{goldId} ORDER BY price_date DESC LIMIT #{limit}")
|
||||
List<GoldPrice> selectHistoryByGoldId(@Param("goldId") String goldId, @Param("limit") Integer limit);
|
||||
|
||||
@Select("SELECT * FROM gold_price WHERE gold_id = #{goldId} AND price_date = #{date} ORDER BY update_time ASC")
|
||||
List<GoldPrice> selectByDate(@Param("goldId") String goldId, @Param("date") LocalDate date);
|
||||
|
||||
@Select("SELECT MAX(price_date) FROM gold_price WHERE gold_id = #{goldId}")
|
||||
LocalDate selectLatestDate(@Param("goldId") String goldId);
|
||||
|
||||
/**
|
||||
* 检查指定时间点是否已存在数据(用于防重复插入)
|
||||
* @param goldId 黄金品种ID
|
||||
* @param updateTime 更新时间
|
||||
* @return 存在的记录数
|
||||
*/
|
||||
@Select("SELECT COUNT(*) FROM gold_price WHERE gold_id = #{goldId} AND update_time = #{updateTime}")
|
||||
int countByGoldIdAndUpdateTime(@Param("goldId") String goldId, @Param("updateTime") java.time.LocalDateTime updateTime);
|
||||
}
|
||||
@@ -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> {
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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> {
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
package com.accounting.schedule;
|
||||
|
||||
import com.accounting.service.GoldPriceService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
@Slf4j
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class GoldPriceScheduler {
|
||||
|
||||
private static final String DEFAULT_GOLD_ID = "1053";
|
||||
|
||||
private final GoldPriceService goldPriceService;
|
||||
|
||||
@Scheduled(cron = "0 */15 * * * ?", zone = "Asia/Shanghai")
|
||||
public void fetchPeriodicPrice() {
|
||||
runJob("periodic");
|
||||
}
|
||||
|
||||
private void runJob(String tag) {
|
||||
try {
|
||||
log.info("Gold price scheduled job [{}] start", tag);
|
||||
goldPriceService.refreshCurrentPrice(DEFAULT_GOLD_ID);
|
||||
log.info("Gold price scheduled job [{}] success", tag);
|
||||
} catch (Exception e) {
|
||||
log.error("Gold price scheduled job [{}] failed", tag, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
package com.accounting.service;
|
||||
|
||||
import com.accounting.dto.AccountRequest;
|
||||
import com.accounting.dto.AccountResponse;
|
||||
import com.accounting.entity.Account;
|
||||
import com.accounting.entity.Bill;
|
||||
import com.accounting.mapper.AccountMapper;
|
||||
import com.accounting.mapper.BillMapper;
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import org.springframework.beans.BeanUtils;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.List;
|
||||
|
||||
@Service
|
||||
public class AccountService {
|
||||
|
||||
@Autowired
|
||||
private AccountMapper accountMapper;
|
||||
|
||||
@Autowired
|
||||
private BillMapper billMapper;
|
||||
|
||||
/**
|
||||
* 获取或创建用户账户(如果不存在则创建默认账户)
|
||||
*/
|
||||
@Transactional
|
||||
public Account getOrCreateAccount(Long userId) {
|
||||
Account account = accountMapper.selectOne(
|
||||
new LambdaQueryWrapper<Account>()
|
||||
.eq(Account::getUserId, userId)
|
||||
);
|
||||
|
||||
if (account == null) {
|
||||
account = new Account();
|
||||
account.setUserId(userId);
|
||||
account.setName("默认账户");
|
||||
account.setInitialBalance(BigDecimal.ZERO);
|
||||
accountMapper.insert(account);
|
||||
}
|
||||
|
||||
return account;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户账户
|
||||
*/
|
||||
public Account getAccount(Long userId) {
|
||||
return accountMapper.selectOne(
|
||||
new LambdaQueryWrapper<Account>()
|
||||
.eq(Account::getUserId, userId)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新账户信息(主要是初始余额)
|
||||
*/
|
||||
@Transactional
|
||||
public AccountResponse updateAccount(Long userId, AccountRequest request) {
|
||||
Account account = getOrCreateAccount(userId);
|
||||
|
||||
if (request.getName() != null) {
|
||||
account.setName(request.getName());
|
||||
}
|
||||
if (request.getInitialBalance() != null) {
|
||||
account.setInitialBalance(request.getInitialBalance());
|
||||
}
|
||||
|
||||
accountMapper.updateById(account);
|
||||
|
||||
return getAccountBalance(userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新账户余额(仅更新初始余额)
|
||||
*/
|
||||
@Transactional
|
||||
public AccountResponse updateAccountBalance(Long userId, BigDecimal initialBalance) {
|
||||
Account account = getOrCreateAccount(userId);
|
||||
account.setInitialBalance(initialBalance);
|
||||
accountMapper.updateById(account);
|
||||
return getAccountBalance(userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算账户余额(初始余额 + 收入总额 - 支出总额)
|
||||
*/
|
||||
public BigDecimal calculateBalance(Long accountId) {
|
||||
Account account = accountMapper.selectById(accountId);
|
||||
if (account == null) {
|
||||
return BigDecimal.ZERO;
|
||||
}
|
||||
|
||||
BigDecimal initialBalance = account.getInitialBalance() != null ? account.getInitialBalance() : BigDecimal.ZERO;
|
||||
|
||||
// 查询该账户的所有账单
|
||||
List<Bill> bills = billMapper.selectList(
|
||||
new LambdaQueryWrapper<Bill>()
|
||||
.eq(Bill::getAccountId, accountId)
|
||||
);
|
||||
|
||||
BigDecimal totalIncome = BigDecimal.ZERO;
|
||||
BigDecimal totalExpense = BigDecimal.ZERO;
|
||||
|
||||
for (Bill bill : bills) {
|
||||
if (bill.getType() != null && bill.getAmount() != null) {
|
||||
if (bill.getType() == 2) { // 收入
|
||||
totalIncome = totalIncome.add(bill.getAmount());
|
||||
} else if (bill.getType() == 1) { // 支出
|
||||
totalExpense = totalExpense.add(bill.getAmount().abs());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return initialBalance.add(totalIncome).subtract(totalExpense);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取账户余额信息(包含余额、总收入、总支出)
|
||||
*/
|
||||
public AccountResponse getAccountBalance(Long userId) {
|
||||
Account account = getOrCreateAccount(userId);
|
||||
|
||||
AccountResponse response = new AccountResponse();
|
||||
BeanUtils.copyProperties(account, response);
|
||||
|
||||
BigDecimal balance = calculateBalance(account.getId());
|
||||
response.setBalance(balance);
|
||||
|
||||
// 计算总收入
|
||||
List<Bill> incomeBills = billMapper.selectList(
|
||||
new LambdaQueryWrapper<Bill>()
|
||||
.eq(Bill::getAccountId, account.getId())
|
||||
.eq(Bill::getType, 2) // 收入
|
||||
);
|
||||
BigDecimal totalIncome = incomeBills.stream()
|
||||
.map(Bill::getAmount)
|
||||
.filter(amount -> amount != null)
|
||||
.reduce(BigDecimal.ZERO, BigDecimal::add);
|
||||
response.setTotalIncome(totalIncome);
|
||||
|
||||
// 计算总支出
|
||||
List<Bill> expenseBills = billMapper.selectList(
|
||||
new LambdaQueryWrapper<Bill>()
|
||||
.eq(Bill::getAccountId, account.getId())
|
||||
.eq(Bill::getType, 1) // 支出
|
||||
);
|
||||
BigDecimal totalExpense = expenseBills.stream()
|
||||
.map(Bill::getAmount)
|
||||
.map(BigDecimal::abs)
|
||||
.filter(amount -> amount != null)
|
||||
.reduce(BigDecimal.ZERO, BigDecimal::add);
|
||||
response.setTotalExpense(totalExpense);
|
||||
|
||||
return response;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
package com.accounting.service;
|
||||
|
||||
import com.accounting.dto.BillRequest;
|
||||
import com.accounting.dto.BillResponse;
|
||||
import com.accounting.dto.BatchBillRequest;
|
||||
import com.accounting.entity.Account;
|
||||
import com.accounting.entity.Bill;
|
||||
import com.accounting.entity.Category;
|
||||
import com.accounting.mapper.BillMapper;
|
||||
import com.accounting.mapper.CategoryMapper;
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import org.springframework.beans.BeanUtils;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@Service
|
||||
public class BillService {
|
||||
|
||||
@Autowired
|
||||
private BillMapper billMapper;
|
||||
|
||||
@Autowired
|
||||
private CategoryMapper categoryMapper;
|
||||
|
||||
@Autowired
|
||||
private AccountService accountService;
|
||||
|
||||
@Transactional
|
||||
public BillResponse createBill(BillRequest request, Long userId) {
|
||||
// 验证分类是否存在
|
||||
Category category = categoryMapper.selectById(request.getCategoryId());
|
||||
if (category == null) {
|
||||
throw new RuntimeException("分类不存在");
|
||||
}
|
||||
|
||||
// 获取或创建账户
|
||||
Account account = accountService.getOrCreateAccount(userId);
|
||||
|
||||
// 创建账单
|
||||
Bill bill = new Bill();
|
||||
bill.setUserId(userId);
|
||||
bill.setAccountId(account.getId()); // 自动关联账户
|
||||
bill.setCategoryId(request.getCategoryId());
|
||||
bill.setAmount(request.getAmount());
|
||||
bill.setDescription(request.getDescription());
|
||||
bill.setBillDate(request.getBillDate() != null ? request.getBillDate() : LocalDate.now());
|
||||
bill.setImageUrl(request.getImageUrl());
|
||||
bill.setType(request.getType()); // 设置账单类型
|
||||
|
||||
billMapper.insert(bill);
|
||||
|
||||
return convertToResponse(bill, category);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public List<BillResponse> createBills(BatchBillRequest request, Long userId) {
|
||||
List<BillResponse> responses = new ArrayList<>();
|
||||
|
||||
// 获取或创建账户
|
||||
Account account = accountService.getOrCreateAccount(userId);
|
||||
|
||||
for (BillRequest billRequest : request.getBills()) {
|
||||
// 验证分类是否存在
|
||||
Category category = categoryMapper.selectById(billRequest.getCategoryId());
|
||||
if (category == null) {
|
||||
throw new RuntimeException("分类不存在,分类ID: " + billRequest.getCategoryId());
|
||||
}
|
||||
|
||||
// 创建账单
|
||||
Bill bill = new Bill();
|
||||
bill.setUserId(userId);
|
||||
bill.setAccountId(account.getId()); // 自动关联账户
|
||||
bill.setCategoryId(billRequest.getCategoryId());
|
||||
bill.setAmount(billRequest.getAmount());
|
||||
bill.setDescription(billRequest.getDescription());
|
||||
bill.setBillDate(billRequest.getBillDate() != null ? billRequest.getBillDate() : LocalDate.now());
|
||||
bill.setImageUrl(billRequest.getImageUrl());
|
||||
bill.setType(billRequest.getType()); // 设置账单类型
|
||||
|
||||
billMapper.insert(bill);
|
||||
|
||||
responses.add(convertToResponse(bill, category));
|
||||
}
|
||||
|
||||
return responses;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public BillResponse updateBill(Long id, BillRequest request, Long userId) {
|
||||
// 验证账单是否存在且属于当前用户
|
||||
Bill bill = billMapper.selectOne(
|
||||
new LambdaQueryWrapper<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("分类不存在");
|
||||
}
|
||||
|
||||
// 如果账单没有关联账户,则自动关联
|
||||
if (bill.getAccountId() == null) {
|
||||
Account account = accountService.getOrCreateAccount(userId);
|
||||
bill.setAccountId(account.getId());
|
||||
}
|
||||
|
||||
// 更新账单
|
||||
bill.setCategoryId(request.getCategoryId());
|
||||
bill.setAmount(request.getAmount());
|
||||
bill.setDescription(request.getDescription());
|
||||
if (request.getBillDate() != null) {
|
||||
bill.setBillDate(request.getBillDate());
|
||||
}
|
||||
if (request.getImageUrl() != null) {
|
||||
bill.setImageUrl(request.getImageUrl());
|
||||
}
|
||||
bill.setType(request.getType()); // 更新账单类型
|
||||
|
||||
billMapper.updateById(bill);
|
||||
|
||||
return convertToResponse(bill, category);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void deleteBill(Long id, Long userId) {
|
||||
Bill bill = billMapper.selectOne(
|
||||
new LambdaQueryWrapper<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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
package com.accounting.service;
|
||||
|
||||
import com.accounting.dto.BudgetRequest;
|
||||
import com.accounting.dto.BudgetResponse;
|
||||
import com.accounting.dto.BudgetSettlementResponse;
|
||||
import com.accounting.dto.StatisticsResponse;
|
||||
import com.accounting.entity.Budget;
|
||||
import com.accounting.mapper.BudgetMapper;
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.math.RoundingMode;
|
||||
import java.time.LocalDate;
|
||||
|
||||
@Service
|
||||
public class BudgetService {
|
||||
|
||||
@Autowired
|
||||
private BudgetMapper budgetMapper;
|
||||
|
||||
@Autowired
|
||||
private StatisticsService statisticsService;
|
||||
|
||||
/**
|
||||
* 获取或创建预算(如果不存在则创建默认0)
|
||||
*/
|
||||
@Transactional
|
||||
public Budget getOrCreateBudget(Long userId, int year, int month) {
|
||||
Budget budget = budgetMapper.selectOne(
|
||||
new LambdaQueryWrapper<Budget>()
|
||||
.eq(Budget::getUserId, userId)
|
||||
.eq(Budget::getYear, year)
|
||||
.eq(Budget::getMonth, month)
|
||||
);
|
||||
|
||||
if (budget == null) {
|
||||
budget = new Budget();
|
||||
budget.setUserId(userId);
|
||||
budget.setYear(year);
|
||||
budget.setMonth(month);
|
||||
budget.setAmount(BigDecimal.ZERO);
|
||||
budgetMapper.insert(budget);
|
||||
}
|
||||
|
||||
return budget;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取预算
|
||||
*/
|
||||
public Budget getBudget(Long userId, int year, int month) {
|
||||
return budgetMapper.selectOne(
|
||||
new LambdaQueryWrapper<Budget>()
|
||||
.eq(Budget::getUserId, userId)
|
||||
.eq(Budget::getYear, year)
|
||||
.eq(Budget::getMonth, month)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置/更新预算
|
||||
*/
|
||||
@Transactional
|
||||
public BudgetResponse setBudget(Long userId, BudgetRequest request) {
|
||||
int year = request.getYear();
|
||||
int month = request.getMonth();
|
||||
BigDecimal amount = request.getAmount();
|
||||
|
||||
if (amount == null || amount.compareTo(BigDecimal.ZERO) < 0) {
|
||||
throw new RuntimeException("预算金额必须大于等于0");
|
||||
}
|
||||
|
||||
Budget budget = getOrCreateBudget(userId, year, month);
|
||||
budget.setAmount(amount);
|
||||
budgetMapper.updateById(budget);
|
||||
|
||||
return getBudgetWithStatistics(userId, year, month);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取预算及统计信息
|
||||
*/
|
||||
public BudgetResponse getBudgetWithStatistics(Long userId, int year, int month) {
|
||||
Budget budget = getOrCreateBudget(userId, year, month);
|
||||
|
||||
BudgetResponse response = new BudgetResponse();
|
||||
response.setId(budget.getId());
|
||||
response.setYear(budget.getYear());
|
||||
response.setMonth(budget.getMonth());
|
||||
response.setAmount(budget.getAmount());
|
||||
|
||||
// 获取本月支出统计
|
||||
StatisticsResponse stats = statisticsService.getMonthlyStatistics(userId, year, month);
|
||||
BigDecimal usedAmount = stats.getTotalExpense() != null ? stats.getTotalExpense() : BigDecimal.ZERO;
|
||||
response.setUsedAmount(usedAmount);
|
||||
|
||||
// 计算剩余预算
|
||||
BigDecimal remainingAmount = budget.getAmount().subtract(usedAmount);
|
||||
response.setRemainingAmount(remainingAmount);
|
||||
|
||||
// 计算剩余日均
|
||||
LocalDate today = LocalDate.now();
|
||||
LocalDate monthStart = LocalDate.of(year, month, 1);
|
||||
LocalDate monthEnd = monthStart.withDayOfMonth(monthStart.lengthOfMonth());
|
||||
|
||||
int remainingDays = 0;
|
||||
if (year == today.getYear() && month == today.getMonthValue()) {
|
||||
// 当前月份
|
||||
remainingDays = monthEnd.getDayOfMonth() - today.getDayOfMonth() + 1;
|
||||
if (remainingDays < 0) {
|
||||
remainingDays = 0;
|
||||
}
|
||||
} else if (year < today.getYear() || (year == today.getYear() && month < today.getMonthValue())) {
|
||||
// 过去的月份
|
||||
remainingDays = 0;
|
||||
} else {
|
||||
// 未来的月份
|
||||
remainingDays = monthEnd.getDayOfMonth();
|
||||
}
|
||||
|
||||
if (remainingDays > 0) {
|
||||
BigDecimal remainingDaily = remainingAmount.divide(BigDecimal.valueOf(remainingDays), 2, RoundingMode.HALF_UP);
|
||||
response.setRemainingDaily(remainingDaily);
|
||||
} else {
|
||||
response.setRemainingDaily(BigDecimal.ZERO);
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取上月预算结算信息
|
||||
*/
|
||||
public BudgetSettlementResponse getLastMonthSettlement(Long userId) {
|
||||
LocalDate today = LocalDate.now();
|
||||
LocalDate lastMonth = today.minusMonths(1);
|
||||
int year = lastMonth.getYear();
|
||||
int month = lastMonth.getMonthValue();
|
||||
|
||||
Budget budget = getBudget(userId, year, month);
|
||||
BigDecimal budgetAmount = budget != null && budget.getAmount() != null ? budget.getAmount() : BigDecimal.ZERO;
|
||||
|
||||
// 获取上月支出统计
|
||||
StatisticsResponse stats = statisticsService.getMonthlyStatistics(userId, year, month);
|
||||
BigDecimal actualExpense = stats.getTotalExpense() != null ? stats.getTotalExpense() : BigDecimal.ZERO;
|
||||
|
||||
BudgetSettlementResponse response = new BudgetSettlementResponse();
|
||||
response.setYear(year);
|
||||
response.setMonth(month);
|
||||
response.setBudgetAmount(budgetAmount);
|
||||
response.setActualExpense(actualExpense);
|
||||
|
||||
// 判断是否超支
|
||||
boolean isOverBudget = actualExpense.compareTo(budgetAmount) > 0;
|
||||
response.setIsOverBudget(isOverBudget);
|
||||
|
||||
if (isOverBudget) {
|
||||
BigDecimal overAmount = actualExpense.subtract(budgetAmount);
|
||||
response.setOverAmount(overAmount);
|
||||
} else {
|
||||
response.setOverAmount(BigDecimal.ZERO);
|
||||
}
|
||||
|
||||
// 计算完成率
|
||||
if (budgetAmount.compareTo(BigDecimal.ZERO) > 0) {
|
||||
BigDecimal completionRate = actualExpense.divide(budgetAmount, 4, RoundingMode.HALF_UP)
|
||||
.multiply(BigDecimal.valueOf(100));
|
||||
response.setCompletionRate(completionRate);
|
||||
} else {
|
||||
response.setCompletionRate(null);
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查并创建本月预算(如果不存在则创建默认0)
|
||||
*/
|
||||
@Transactional
|
||||
public void checkAndCreateCurrentMonthBudget(Long userId) {
|
||||
LocalDate today = LocalDate.now();
|
||||
int year = today.getYear();
|
||||
int month = today.getMonthValue();
|
||||
|
||||
getOrCreateBudget(userId, year, month);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
package com.accounting.service;
|
||||
|
||||
import com.accounting.dto.GoldPriceResponse;
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
|
||||
public interface GoldPriceService {
|
||||
|
||||
/**
|
||||
* 从数据库获取最新黄金价格(不触发外部接口调用)
|
||||
* @param goldId 黄金品种ID
|
||||
* @return 最新黄金价格响应,可能为 null
|
||||
*/
|
||||
GoldPriceResponse getCurrentPrice(String goldId);
|
||||
|
||||
/**
|
||||
* 主动从外部接口刷新当前黄金价格并写入数据库(供定时任务使用)
|
||||
* @param goldId 黄金品种ID
|
||||
*/
|
||||
void refreshCurrentPrice(String goldId);
|
||||
|
||||
/**
|
||||
* 获取历史黄金价格
|
||||
* @param goldId 黄金品种ID
|
||||
* @param days 查询天数
|
||||
* @return 黄金价格列表
|
||||
*/
|
||||
List<GoldPriceResponse> getHistoryPrices(String goldId, Integer days);
|
||||
|
||||
/**
|
||||
* 获取指定日期的所有黄金价格记录
|
||||
* @param goldId 黄金品种ID
|
||||
* @param date 日期
|
||||
* @return 指定日期的价格记录列表
|
||||
*/
|
||||
List<GoldPriceResponse> getPricesByDate(String goldId, LocalDate date);
|
||||
|
||||
/**
|
||||
* 获取最近有数据的日期
|
||||
* @param goldId 黄金品种ID
|
||||
* @return 最近有数据的日期,可能为 null
|
||||
*/
|
||||
LocalDate getLatestDate(String goldId);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package com.accounting.service;
|
||||
|
||||
import com.accounting.dto.K780Response;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.client.RestTemplate;
|
||||
|
||||
@Slf4j
|
||||
@Component
|
||||
public class K780ApiClient {
|
||||
|
||||
private static final String API_BASE_URL = "https://sapi.k780.com";
|
||||
private static final String APP = "finance.gold_price";
|
||||
private static final String APP_KEY = "78346";
|
||||
private static final String SIGN = "1b502c535927b66d9b888a6d4701bf72";
|
||||
private static final String FORMAT = "json";
|
||||
|
||||
private final RestTemplate restTemplate;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
public K780ApiClient() {
|
||||
this.restTemplate = new RestTemplate();
|
||||
this.objectMapper = new ObjectMapper();
|
||||
}
|
||||
|
||||
/**
|
||||
* 从K780 API获取黄金价格
|
||||
* @param goldId 黄金品种ID
|
||||
* @return K780Response
|
||||
*/
|
||||
public K780Response fetchGoldPrice(String goldId) {
|
||||
try {
|
||||
String url = String.format("%s/?app=%s&goldid=%s&appkey=%s&sign=%s&format=%s",
|
||||
API_BASE_URL, APP, goldId, APP_KEY, SIGN, FORMAT);
|
||||
|
||||
log.info("Fetching gold price from K780 API: {}", url);
|
||||
|
||||
String response = restTemplate.getForObject(url, String.class);
|
||||
K780Response k780Response = objectMapper.readValue(response, K780Response.class);
|
||||
|
||||
log.info("K780 API response: {}", k780Response);
|
||||
|
||||
return k780Response;
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to fetch gold price from K780 API", e);
|
||||
throw new RuntimeException("获取黄金价格失败: " + e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
package com.accounting.service.impl;
|
||||
|
||||
import com.accounting.dto.GoldPriceResponse;
|
||||
import com.accounting.dto.K780Response;
|
||||
import com.accounting.dto.K780Result;
|
||||
import com.accounting.entity.GoldPrice;
|
||||
import com.accounting.mapper.GoldPriceMapper;
|
||||
import com.accounting.service.GoldPriceService;
|
||||
import com.accounting.service.K780ApiClient;
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.List;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class GoldPriceServiceImpl implements GoldPriceService {
|
||||
|
||||
private final K780ApiClient k780ApiClient;
|
||||
private final GoldPriceMapper goldPriceMapper;
|
||||
|
||||
private static final DateTimeFormatter DATE_TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
|
||||
private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd");
|
||||
|
||||
@Override
|
||||
@Transactional(readOnly = true)
|
||||
public GoldPriceResponse getCurrentPrice(String goldId) {
|
||||
// 仅从数据库读取最新记录
|
||||
GoldPrice latest = goldPriceMapper.selectOne(
|
||||
new LambdaQueryWrapper<GoldPrice>()
|
||||
.eq(GoldPrice::getGoldId, goldId)
|
||||
.orderByDesc(GoldPrice::getPriceDate, GoldPrice::getUpdateTime)
|
||||
.last("LIMIT 1")
|
||||
);
|
||||
if (latest == null) {
|
||||
return null;
|
||||
}
|
||||
return convertToResponse(latest);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public void refreshCurrentPrice(String goldId) {
|
||||
log.info("Starting refreshCurrentPrice for goldId={}", goldId);
|
||||
|
||||
K780Response response = k780ApiClient.fetchGoldPrice(goldId);
|
||||
|
||||
if (!"1".equals(response.getSuccess()) || response.getResult() == null) {
|
||||
throw new RuntimeException("获取黄金价格失败");
|
||||
}
|
||||
|
||||
K780Result result = response.getResult();
|
||||
|
||||
// 记录API返回的所有数据,用于调试
|
||||
if (result.getDtList() != null) {
|
||||
log.info("API returned {} gold price records", result.getDtList().size());
|
||||
result.getDtList().forEach((id, data) -> {
|
||||
log.info("Gold price data - goldId: {}, uptime: {}, lastPrice: {}",
|
||||
id, data.getUptime(), data.getLastPrice());
|
||||
});
|
||||
}
|
||||
|
||||
K780Result.GoldPriceData goldPriceData = extractGoldPriceData(result, goldId);
|
||||
log.info("Extracted gold price data - goldId: {}, uptime: {}, lastPrice: {}",
|
||||
goldPriceData.getGoldId(), goldPriceData.getUptime(), goldPriceData.getLastPrice());
|
||||
|
||||
GoldPrice goldPrice = convertToEntity(goldPriceData);
|
||||
log.info("Converted to entity - goldId: {}, updateTime: {}, priceDate: {}",
|
||||
goldPrice.getGoldId(), goldPrice.getUpdateTime(), goldPrice.getPriceDate());
|
||||
|
||||
// 防重复插入:检查是否已存在相同时间点的数据
|
||||
int existingCount = goldPriceMapper.countByGoldIdAndUpdateTime(goldId, goldPrice.getUpdateTime());
|
||||
if (existingCount > 0) {
|
||||
log.warn("Gold price data already exists for goldId={}, updateTime={}, skipping insert",
|
||||
goldId, goldPrice.getUpdateTime());
|
||||
return;
|
||||
}
|
||||
|
||||
// 插入新记录 每15分钟查询一次结果并存储
|
||||
goldPrice.setCreateTime(LocalDateTime.now());
|
||||
goldPriceMapper.insert(goldPrice);
|
||||
|
||||
log.info("Successfully inserted gold price - id: {}, goldId: {}, priceDate: {}, updateTime: {}, createTime: {}",
|
||||
goldPrice.getId(), goldPrice.getGoldId(), goldPrice.getPriceDate(),
|
||||
goldPrice.getUpdateTime(), goldPrice.getCreateTime());
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<GoldPriceResponse> getHistoryPrices(String goldId, Integer days) {
|
||||
int limit = (days != null && days > 0) ? Math.min(days, 30) : 30;
|
||||
List<GoldPrice> prices = goldPriceMapper.selectHistoryByGoldId(goldId, limit);
|
||||
return prices.stream()
|
||||
.map(this::convertToResponse)
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(readOnly = true)
|
||||
public List<GoldPriceResponse> getPricesByDate(String goldId, LocalDate date) {
|
||||
List<GoldPrice> prices = goldPriceMapper.selectByDate(goldId, date);
|
||||
return prices.stream()
|
||||
.map(this::convertToResponse)
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(readOnly = true)
|
||||
public LocalDate getLatestDate(String goldId) {
|
||||
return goldPriceMapper.selectLatestDate(goldId);
|
||||
}
|
||||
|
||||
private K780Result.GoldPriceData extractGoldPriceData(K780Result result, String goldId) {
|
||||
if (result.getDtList() == null || result.getDtList().isEmpty()) {
|
||||
throw new RuntimeException("黄金价格数据为空");
|
||||
}
|
||||
|
||||
// 尝试通过提供的goldId获取数据
|
||||
K780Result.GoldPriceData data = result.getDtList().get(goldId);
|
||||
if (data == null) {
|
||||
// 如果没找到指定goldId的数据,则取第一个可用数据
|
||||
data = result.getDtList().values().stream().findFirst()
|
||||
.orElseThrow(() -> new RuntimeException("未找到黄金价格数据"));
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
private GoldPrice convertToEntity(K780Result.GoldPriceData data) {
|
||||
GoldPrice entity = new GoldPrice();
|
||||
|
||||
// 确保 goldId 不为 null
|
||||
String goldId = data.getGoldId();
|
||||
if (goldId == null || goldId.isEmpty()) {
|
||||
throw new RuntimeException("黄金品种ID不能为空");
|
||||
}
|
||||
entity.setGoldId(goldId);
|
||||
|
||||
entity.setGoldName(data.getVarietynm());
|
||||
entity.setPrice(parseBigDecimal(data.getLastPrice()));
|
||||
entity.setPriceChange(parseBigDecimal(data.getChangePrice()));
|
||||
entity.setOpenPrice(parseBigDecimal(data.getOpenPrice()));
|
||||
entity.setHighPrice(parseBigDecimal(data.getHighPrice()));
|
||||
entity.setLowPrice(parseBigDecimal(data.getLowPrice()));
|
||||
entity.setYesterdayClose(parseBigDecimal(data.getYesyPrice()));
|
||||
|
||||
// 处理涨跌百分比
|
||||
String changeMargin = data.getChangeMargin();
|
||||
if (changeMargin != null && !changeMargin.isEmpty() && !"-".equals(changeMargin)) {
|
||||
// 移除百分号并转换
|
||||
String marginWithoutPercent = changeMargin.replace("%", "");
|
||||
entity.setPriceChangePercent(parseBigDecimal(marginWithoutPercent));
|
||||
}
|
||||
|
||||
// 解析更新时间
|
||||
if (data.getUptime() != null && !data.getUptime().isEmpty()) {
|
||||
try {
|
||||
LocalDateTime parsedTime = LocalDateTime.parse(data.getUptime(), DATE_TIME_FORMATTER);
|
||||
entity.setUpdateTime(parsedTime);
|
||||
entity.setPriceDate(parsedTime.toLocalDate());
|
||||
log.debug("Successfully parsed update time: {} -> {}", data.getUptime(), parsedTime);
|
||||
} catch (Exception e) {
|
||||
log.warn("Failed to parse update time: {}, error: {}, using current time instead",
|
||||
data.getUptime(), e.getMessage());
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
entity.setUpdateTime(now);
|
||||
entity.setPriceDate(now.toLocalDate());
|
||||
}
|
||||
} else {
|
||||
log.warn("Uptime is null or empty, using current time");
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
entity.setUpdateTime(now);
|
||||
entity.setPriceDate(now.toLocalDate());
|
||||
}
|
||||
|
||||
return entity;
|
||||
}
|
||||
|
||||
private GoldPriceResponse convertToResponse(GoldPrice entity) {
|
||||
GoldPriceResponse response = new GoldPriceResponse();
|
||||
response.setGoldId(entity.getGoldId());
|
||||
response.setGoldName(entity.getGoldName());
|
||||
response.setPrice(entity.getPrice());
|
||||
response.setPriceChange(entity.getPriceChange());
|
||||
response.setPriceChangePercent(entity.getPriceChangePercent());
|
||||
response.setHighPrice(entity.getHighPrice());
|
||||
response.setLowPrice(entity.getLowPrice());
|
||||
response.setOpenPrice(entity.getOpenPrice());
|
||||
response.setYesterdayClose(entity.getYesterdayClose());
|
||||
|
||||
if (entity.getUpdateTime() != null) {
|
||||
response.setUpdateTime(entity.getUpdateTime().format(DATE_TIME_FORMATTER));
|
||||
}
|
||||
if (entity.getPriceDate() != null) {
|
||||
response.setPriceDate(entity.getPriceDate().format(DATE_FORMATTER));
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
private BigDecimal parseBigDecimal(String value) {
|
||||
if (value == null || value.isEmpty() || "-".equals(value)) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return new BigDecimal(value);
|
||||
} catch (NumberFormatException e) {
|
||||
log.warn("Failed to parse BigDecimal: {}", value);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,466 @@
|
||||
package com.accounting.util;
|
||||
|
||||
import com.alibaba.fastjson2.JSON;
|
||||
import com.alibaba.fastjson2.JSONArray;
|
||||
import com.alibaba.fastjson2.JSONObject;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.time.format.DateTimeParseException;
|
||||
import java.util.*;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
public class OcrAmountParser {
|
||||
|
||||
// 金额正则表达式:匹配 ¥100.00、100.00元、100元、100.00 等格式
|
||||
private static final Pattern AMOUNT_PATTERN = Pattern.compile(
|
||||
"[¥¥]?\\s*(\\d{1,10}(\\.\\d{1,2})?)\\s*[元]?"
|
||||
);
|
||||
|
||||
|
||||
|
||||
// 日期正则表达式:匹配字符串中是否含有月、日或天
|
||||
private static final Pattern UNION_DATE_PATTERN = Pattern.compile(".*(今天|昨天).*");
|
||||
|
||||
|
||||
// 日期正则表达式:匹配 12月2日13:14 这样的格式
|
||||
private static final Pattern DATE_PATTERN = Pattern.compile(
|
||||
"(\\d{1,2})月(\\d{1,2})日\\s*(\\d{1,2})[::](\\d{1,2})"
|
||||
);
|
||||
|
||||
// 日期正则表达式:匹配 12-11 13:14 这样的格式
|
||||
private static final Pattern DATE_PATTERN_DASH = Pattern.compile(
|
||||
"(\\d{1,2})-(\\d{1,2})\\s*(\\d{1,2})[::](\\d{1,2})"
|
||||
);
|
||||
|
||||
// 商户名称关键词(常见支付平台)
|
||||
private static final String[] MERCHANT_KEYWORDS = {
|
||||
"微信支付", "支付宝", "收款", "付款", "商户", "商家", "店铺", "超市", "餐厅", "饭店"
|
||||
};
|
||||
|
||||
public static class ParseResult {
|
||||
private BigDecimal amount;
|
||||
private String merchant;
|
||||
private LocalDateTime date; // 改为LocalDateTime以支持时间
|
||||
private BigDecimal confidence;
|
||||
|
||||
public ParseResult(BigDecimal amount, String merchant, LocalDateTime date, BigDecimal confidence) {
|
||||
this.amount = amount;
|
||||
this.merchant = merchant;
|
||||
this.date = date;
|
||||
this.confidence = confidence;
|
||||
}
|
||||
|
||||
public ParseResult() {
|
||||
|
||||
}
|
||||
|
||||
public void setAmount(BigDecimal amount) {
|
||||
this.amount = amount;
|
||||
}
|
||||
|
||||
public void setMerchant(String merchant) {
|
||||
this.merchant = merchant;
|
||||
}
|
||||
|
||||
public void setDate(LocalDateTime date) {
|
||||
this.date = date;
|
||||
}
|
||||
|
||||
public void setConfidence(BigDecimal confidence) {
|
||||
this.confidence = confidence;
|
||||
}
|
||||
|
||||
public BigDecimal getAmount() { return amount; }
|
||||
public String getMerchant() { return merchant; }
|
||||
public LocalDateTime getDate() { return date; }
|
||||
public BigDecimal getConfidence() { return confidence; }
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证字符串是否为有效日期格式
|
||||
*/
|
||||
private static boolean isValidDate(String dateStr) {
|
||||
if (dateStr == null || dateStr.trim().isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
// 匹配今天、昨天
|
||||
if (UNION_DATE_PATTERN.matcher(dateStr.trim()).matches()){
|
||||
return true;
|
||||
}
|
||||
// 匹配12-11 13:14
|
||||
if (DATE_PATTERN_DASH.matcher(dateStr.trim()).matches()){
|
||||
return true;
|
||||
}
|
||||
// 匹配12月11日 13:14
|
||||
if (DATE_PATTERN.matcher(dateStr.trim()).matches()){
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 将字符串转换为BigDecimal
|
||||
* @param moneyStr 金额字符串
|
||||
* @return BigDecimal对象,如果转换失败则返回null
|
||||
*/
|
||||
private static BigDecimal parseMoneyString(String moneyStr) {
|
||||
if (moneyStr == null || moneyStr.trim().isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
// 判断正负号
|
||||
String cleanStr = moneyStr.trim();
|
||||
boolean isNegative = cleanStr.startsWith("-");
|
||||
|
||||
// 移除可能的前缀符号 (+/-)
|
||||
if (cleanStr.startsWith("+") || cleanStr.startsWith("-")) {
|
||||
cleanStr = cleanStr.substring(1);
|
||||
}
|
||||
|
||||
// 使用现有的金额正则表达式匹配
|
||||
Matcher matcher = AMOUNT_PATTERN.matcher(cleanStr);
|
||||
if (matcher.find()) {
|
||||
String amountStr = matcher.group(1);
|
||||
BigDecimal amount = new BigDecimal(amountStr);
|
||||
// 应用正负号
|
||||
return isNegative ? amount.negate() : amount;
|
||||
}
|
||||
} catch (Exception e) {
|
||||
// 转换失败
|
||||
e.printStackTrace();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析日期字符串为LocalDateTime对象
|
||||
* @param dateStr 解析3中日期字符串,例如:"12月2日13:14" "12-11 13:14" "今天 13:14" "昨天 13:14"
|
||||
* @return LocalDateTime对象
|
||||
*/
|
||||
private static LocalDateTime parseDateTimeString(String dateStr) {
|
||||
if (dateStr == null || dateStr.trim().isEmpty()) {
|
||||
return LocalDateTime.now();
|
||||
}
|
||||
|
||||
//先处理今天、昨天
|
||||
if (UNION_DATE_PATTERN.matcher(dateStr.trim()).matches()){
|
||||
String trimmedDateStr = dateStr.trim();
|
||||
LocalDateTime baseDate;
|
||||
|
||||
if (trimmedDateStr.startsWith("今天")) {
|
||||
baseDate = LocalDateTime.now();
|
||||
} else if (trimmedDateStr.startsWith("昨天")) {
|
||||
baseDate = LocalDateTime.now().minusDays(1);
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 提取时间部分
|
||||
String timePart = trimmedDateStr.substring(2).trim(); // 去掉"今天"/"昨天"
|
||||
if (!timePart.isEmpty()) {
|
||||
String[] timeParts = timePart.split("[::]");
|
||||
if (timeParts.length >= 2) {
|
||||
try {
|
||||
int hour = Integer.parseInt(timeParts[0]);
|
||||
int minute = Integer.parseInt(timeParts[1]);
|
||||
return baseDate.withHour(hour).withMinute(minute).withSecond(0).withNano(0);
|
||||
} catch (NumberFormatException e) {
|
||||
// 时间解析失败,返回基础日期
|
||||
return baseDate;
|
||||
}
|
||||
}
|
||||
}
|
||||
return baseDate;
|
||||
}
|
||||
|
||||
//处理 12月2日13:14
|
||||
if (DATE_PATTERN.matcher(dateStr.trim()).matches()){
|
||||
try {
|
||||
Matcher matcher = DATE_PATTERN.matcher(dateStr.trim());
|
||||
if (matcher.find()) {
|
||||
int month = Integer.parseInt(matcher.group(1));
|
||||
int day = Integer.parseInt(matcher.group(2));
|
||||
int hour = Integer.parseInt(matcher.group(3));
|
||||
int minute = Integer.parseInt(matcher.group(4));
|
||||
|
||||
// 使用当前年份
|
||||
int year = java.time.Year.now().getValue();
|
||||
return LocalDateTime.of(year, month, day, hour, minute);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
e.printStackTrace();
|
||||
}
|
||||
}
|
||||
|
||||
if (DATE_PATTERN_DASH.matcher(dateStr.trim()).matches()){
|
||||
try {
|
||||
Matcher matcher = DATE_PATTERN_DASH.matcher(dateStr.trim());
|
||||
if (matcher.find()) {
|
||||
int month = Integer.parseInt(matcher.group(1));
|
||||
int day = Integer.parseInt(matcher.group(2));
|
||||
int hour = Integer.parseInt(matcher.group(3));
|
||||
int minute = Integer.parseInt(matcher.group(4));
|
||||
|
||||
// 使用当前年份
|
||||
int year = java.time.Year.now().getValue();
|
||||
return LocalDateTime.of(year, month, day, hour, minute);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
e.printStackTrace();
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析OCR识别结果,提取金额、商户名称、日期等信息
|
||||
* 重构OCR识别结果的处理逻辑,大概思想是先识别时间 时间可能有多种格式 微信的格式为 12-12 13:14 支付宝的格式为 今天 13:14 昨天 13:14
|
||||
* 其他平台的格式为 12月2日13:14 等等 识别出日期以后 日期之前的3-4个值就可能是此笔支付的其他数据 一般的格式为 [商户/描述] [金额] 可能存在的[分类] [时间]
|
||||
* 所以在识别出日期后 查看前3-4个值中是否有类似金额的值
|
||||
*/
|
||||
public static List<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 content = "下午2:01 0.3K/s必 5G ra HD ID 4G 10 C 49 D < Q搜索交易记录 搜索 全部 支出 转账 退款 订单筛选 ¥198 ¥3,092.83 ¥0.00 收支分析 设置支出预算> C 五华区皓月千里便利店 -4.20 日用百货 今天 13:30 扫收钱码付款-给快乐 -11.00 餐饮美食 今天 12:22 余额宝-收益发放 0.19 投资理财 今天 04:47 2000406014951497 -20.00 餐饮美食 昨天 22:14 扫收钱码付款-给扫码点单店主 -5.00 公共服务 昨天 21:44 蜜雪冰城920749店 -2.18 餐饮美食 TA ";
|
||||
System.out.println(content);
|
||||
String[] split = content.split(" ");
|
||||
|
||||
System.out.println("split:"+ split);
|
||||
System.out.println("split.size:"+ split.length);
|
||||
|
||||
ArrayList<Map<String,String>> signList = new ArrayList<>();
|
||||
|
||||
System.out.println("开始解析");
|
||||
for (int i = 1; i < split.length; i++) {
|
||||
String currentLine = split[i].trim();
|
||||
|
||||
// 安全地获取 i+1, i+2, i+3 位置的值
|
||||
String iPlusOne = null;
|
||||
String iPlusTwo = null;
|
||||
String iPlusThree = null;
|
||||
|
||||
iPlusOne = split[i - 1].trim();
|
||||
if (i > 1) {
|
||||
iPlusTwo = split[i - 2].trim();
|
||||
}
|
||||
if (i > 2) {
|
||||
iPlusThree = split[i - 3].trim();
|
||||
}
|
||||
|
||||
// 检查当前行是否符合日期格式
|
||||
if (isValidDate(currentLine)) {
|
||||
Map<String, String> signMap = new HashMap<>();
|
||||
|
||||
//判断前三个值是否包含了金额
|
||||
|
||||
//plusOne是金额
|
||||
if (iPlusOne != null && ((iPlusOne.startsWith("+") || iPlusOne.startsWith("-"))
|
||||
|| AMOUNT_PATTERN.matcher(Objects.requireNonNull(iPlusOne).trim()).matches())) {
|
||||
if (UNION_DATE_PATTERN.matcher(currentLine).matches()){
|
||||
// 检查 i+1 是否在数组范围内
|
||||
if (i + 1 < split.length) {
|
||||
signMap.put("data", currentLine + " " + split[i+1].trim());
|
||||
} else {
|
||||
signMap.put("data", currentLine);
|
||||
}
|
||||
}else {
|
||||
signMap.put("data", currentLine);
|
||||
}
|
||||
signMap.put("money", iPlusOne);
|
||||
signMap.put("content", iPlusTwo);
|
||||
signList.add(signMap);
|
||||
}
|
||||
|
||||
//plusTwo是金额
|
||||
if (iPlusTwo != null && ((iPlusTwo.startsWith("+") || iPlusTwo.startsWith("-"))
|
||||
|| AMOUNT_PATTERN.matcher(Objects.requireNonNull(iPlusTwo).trim()).matches())) {
|
||||
if (UNION_DATE_PATTERN.matcher(currentLine).matches()){
|
||||
// 检查 i+1 是否在数组范围内
|
||||
if (i + 1 < split.length) {
|
||||
signMap.put("data", currentLine + " " + split[i+1].trim());
|
||||
} else {
|
||||
signMap.put("data", currentLine);
|
||||
}
|
||||
}else {
|
||||
signMap.put("data", currentLine);
|
||||
}
|
||||
signMap.put("money", iPlusTwo);
|
||||
signMap.put("content", iPlusThree);
|
||||
signList.add(signMap);
|
||||
|
||||
}
|
||||
|
||||
//plusThree是金额
|
||||
if (iPlusThree != null && ((iPlusThree.startsWith("+") || iPlusThree.startsWith("-"))
|
||||
|| AMOUNT_PATTERN.matcher(Objects.requireNonNull(iPlusThree).trim()).matches())) {
|
||||
if (UNION_DATE_PATTERN.matcher(currentLine).matches()){
|
||||
// 检查 i+1 是否在数组范围内
|
||||
if (i + 1 < split.length) {
|
||||
signMap.put("data", currentLine + " " + split[i+1].trim());
|
||||
} else {
|
||||
signMap.put("data", currentLine);
|
||||
}
|
||||
}else {
|
||||
signMap.put("data", currentLine);
|
||||
}
|
||||
signMap.put("money", iPlusThree);
|
||||
// 检查 i-4 是否在数组范围内
|
||||
if (i - 4 >= 0) {
|
||||
signMap.put("content", split[i - 4].trim());
|
||||
}
|
||||
signList.add(signMap);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
System.out.println("识别结束,开始对结果进行处理");
|
||||
System.out.println("signList:"+signList);
|
||||
|
||||
//识别完成,对识别结果进行处理
|
||||
|
||||
ArrayList<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(signMap.get("content"));
|
||||
}
|
||||
if (signMap.containsKey("data")){
|
||||
result.setDate(parseDateTimeString(signMap.get("data")));
|
||||
}
|
||||
|
||||
parseList.add(result);
|
||||
});
|
||||
|
||||
return parseList;
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException("解析过程中出错,请重试或联系管理员,报错信息:"+e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从OCR结果中提取文本内容
|
||||
*/
|
||||
private static String extractContent(JSONObject data) {
|
||||
StringBuilder content = new StringBuilder();
|
||||
|
||||
// 尝试获取prism_wordsInfo字段(通用文字识别)
|
||||
JSONArray wordsInfo = data.getJSONArray("prism_wordsInfo");
|
||||
if (wordsInfo != null) {
|
||||
for (int i = 0; i < wordsInfo.size(); i++) {
|
||||
JSONObject word = wordsInfo.getJSONObject(i);
|
||||
String wordStr = word.getString("word");
|
||||
if (wordStr != null) {
|
||||
content.append(wordStr).append(" ");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 如果没有prism_wordsInfo,尝试获取content字段
|
||||
if (content.length() == 0) {
|
||||
String contentStr = data.getString("content");
|
||||
if (contentStr != null) {
|
||||
content.append(contentStr);
|
||||
}
|
||||
}
|
||||
|
||||
return content.toString().trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析金额
|
||||
*/
|
||||
private static BigDecimal parseAmount(String content) {
|
||||
Matcher matcher = AMOUNT_PATTERN.matcher(content);
|
||||
|
||||
// 查找所有匹配的金额,取最大的(通常是实际支付金额)
|
||||
BigDecimal maxAmount = null;
|
||||
while (matcher.find()) {
|
||||
String amountStr = matcher.group(1);
|
||||
try {
|
||||
BigDecimal amount = new BigDecimal(amountStr);
|
||||
if (maxAmount == null || amount.compareTo(maxAmount) > 0) {
|
||||
maxAmount = amount;
|
||||
}
|
||||
} catch (NumberFormatException e) {
|
||||
// 忽略解析失败的金额
|
||||
}
|
||||
}
|
||||
|
||||
return maxAmount;
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析日期
|
||||
*/
|
||||
private static LocalDate parseDate(String content) {
|
||||
Matcher matcher = DATE_PATTERN.matcher(content);
|
||||
if (matcher.find()) {
|
||||
try {
|
||||
int year = Integer.parseInt(matcher.group(1));
|
||||
int month = Integer.parseInt(matcher.group(2));
|
||||
int day = Integer.parseInt(matcher.group(3));
|
||||
return LocalDate.of(year, month, day);
|
||||
} catch (Exception e) {
|
||||
// 解析失败,返回null
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析商户名称
|
||||
*/
|
||||
private static String parseMerchant(String content) {
|
||||
// 查找包含商户关键词的行
|
||||
String[] lines = content.split("\n");
|
||||
for (String line : lines) {
|
||||
for (String keyword : MERCHANT_KEYWORDS) {
|
||||
if (line.contains(keyword)) {
|
||||
// 提取商户名称(去除关键词本身)
|
||||
String merchant = line.replace(keyword, "").trim();
|
||||
if (!merchant.isEmpty() && merchant.length() < 50) {
|
||||
return merchant;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 如果没有找到,返回第一行非金额非日期的文本
|
||||
for (String line : lines) {
|
||||
line = line.trim();
|
||||
if (!line.isEmpty() && !AMOUNT_PATTERN.matcher(line).find() &&
|
||||
!DATE_PATTERN.matcher(line).find() && line.length() < 50) {
|
||||
return line;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
spring:
|
||||
application:
|
||||
name: accounting-backend
|
||||
datasource:
|
||||
driver-class-name: com.mysql.cj.jdbc.Driver
|
||||
url: jdbc:mysql://103.36.220.231:3306/accounting_db?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai
|
||||
username: root
|
||||
password: mysql_jP65Fc
|
||||
servlet:
|
||||
multipart:
|
||||
max-file-size: 5MB
|
||||
max-request-size: 10MB
|
||||
jackson:
|
||||
date-format: yyyy-MM-dd HH:mm:ss
|
||||
time-zone: GMT+8
|
||||
|
||||
# MyBatis Plus配置
|
||||
mybatis-plus:
|
||||
mapper-locations: classpath:mapper/*.xml
|
||||
type-aliases-package: com.accounting.entity
|
||||
configuration:
|
||||
map-underscore-to-camel-case: true
|
||||
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
|
||||
global-config:
|
||||
db-config:
|
||||
id-type: auto
|
||||
logic-delete-field: deleted
|
||||
logic-delete-value: 1
|
||||
logic-not-delete-value: 0
|
||||
|
||||
# JWT配置
|
||||
jwt:
|
||||
secret: accounting-secret-key-2024-change-in-production-accounting-secret-key-2024-change-in-production-accounting-secret-key-2024-change-in-production
|
||||
expiration: 86400000 # 24小时,单位毫秒
|
||||
|
||||
# 阿里云OCR配置
|
||||
aliyun:
|
||||
ocr:
|
||||
access-key-id: ${ALIYUN_ACCESS_KEY_ID:LTAI5tDCJuB9YgLx4KeJwc9C}
|
||||
access-key-secret: ${ALIYUN_ACCESS_KEY_SECRET:aitimi6EtVsLQJ8S40bqa5nZrGfGRR}
|
||||
endpoint: ocr-api.cn-hangzhou.aliyuncs.com
|
||||
|
||||
# 文件上传配置
|
||||
file:
|
||||
upload:
|
||||
path: ${user.home}/accounting/uploads/
|
||||
|
||||
# Knife4j配置
|
||||
knife4j:
|
||||
enable: true
|
||||
openapi:
|
||||
title: 记账应用API文档
|
||||
description: 记账应用后端API接口文档
|
||||
version: 1.0.0
|
||||
contact:
|
||||
name: Accounting API
|
||||
license: Apache 2.0
|
||||
license-url: https://www.apache.org/licenses/LICENSE-2.0.html
|
||||
servers:
|
||||
- url: http://localhost:8080
|
||||
description: 本地开发环境
|
||||
|
||||
# K780 API配置
|
||||
k780:
|
||||
api-url: https://sapi.k780.com
|
||||
app-key: 78346
|
||||
sign: 1b502c535927b66d9b888a6d4701bf72
|
||||
timeout: 5000
|
||||
|
||||
server:
|
||||
port: 12345
|
||||
|
||||
logging:
|
||||
level:
|
||||
com.accounting: debug
|
||||
org.springframework.security: debug
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
-- 删除 gold_price 表的唯一约束 uk_gold_date
|
||||
-- 原因:需要支持每15分钟查询一次,同一天需要存储多条记录
|
||||
-- 执行时间:2026-01-23
|
||||
|
||||
ALTER TABLE `gold_price` DROP INDEX `uk_gold_date`;
|
||||
@@ -0,0 +1,145 @@
|
||||
-- 创建数据库
|
||||
CREATE DATABASE IF NOT EXISTS accounting_db DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
USE accounting_db;
|
||||
|
||||
-- 用户表
|
||||
CREATE TABLE IF NOT EXISTS `user` (
|
||||
`id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '用户ID',
|
||||
`username` VARCHAR(50) NOT NULL UNIQUE COMMENT '用户名',
|
||||
`password` VARCHAR(255) NOT NULL COMMENT '密码(加密)',
|
||||
`nickname` VARCHAR(50) COMMENT '昵称',
|
||||
`create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
`update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||
PRIMARY KEY (`id`),
|
||||
INDEX `idx_username` (`username`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户表';
|
||||
|
||||
-- 分类表
|
||||
CREATE TABLE IF NOT EXISTS `category` (
|
||||
`id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '分类ID',
|
||||
`user_id` BIGINT COMMENT '用户ID(NULL表示系统预设分类)',
|
||||
`name` VARCHAR(50) NOT NULL COMMENT '分类名称',
|
||||
`icon` VARCHAR(100) COMMENT '图标',
|
||||
`type` TINYINT NOT NULL COMMENT '类型:1-支出,2-收入',
|
||||
`sort_order` INT DEFAULT 0 COMMENT '排序顺序',
|
||||
`create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
`update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||
PRIMARY KEY (`id`),
|
||||
INDEX `idx_user_id` (`user_id`),
|
||||
INDEX `idx_type` (`type`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='分类表';
|
||||
|
||||
-- 账单表
|
||||
CREATE TABLE IF NOT EXISTS `bill` (
|
||||
`id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '账单ID',
|
||||
`user_id` BIGINT NOT NULL COMMENT '用户ID',
|
||||
`account_id` BIGINT COMMENT '账户ID',
|
||||
`category_id` BIGINT NOT NULL COMMENT '分类ID',
|
||||
`amount` DECIMAL(10,2) NOT NULL COMMENT '金额',
|
||||
`description` VARCHAR(255) COMMENT '描述',
|
||||
`bill_date` DATE NOT NULL COMMENT '账单日期',
|
||||
`image_url` VARCHAR(500) COMMENT '图片URL',
|
||||
`type` TINYINT COMMENT '类型:1-支出,2-收入',
|
||||
`create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
`update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||
PRIMARY KEY (`id`),
|
||||
INDEX `idx_user_id` (`user_id`),
|
||||
INDEX `idx_account_id` (`account_id`),
|
||||
INDEX `idx_category_id` (`category_id`),
|
||||
INDEX `idx_bill_date` (`bill_date`),
|
||||
FOREIGN KEY (`user_id`) REFERENCES `user` (`id`) ON DELETE CASCADE,
|
||||
FOREIGN KEY (`account_id`) REFERENCES `account` (`id`) ON DELETE RESTRICT,
|
||||
FOREIGN KEY (`category_id`) REFERENCES `category` (`id`) ON DELETE RESTRICT
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='账单表';
|
||||
|
||||
-- 账户表
|
||||
CREATE TABLE IF NOT EXISTS `account` (
|
||||
`id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '账户ID',
|
||||
`user_id` BIGINT NOT NULL COMMENT '用户ID',
|
||||
`name` VARCHAR(50) NOT NULL DEFAULT '默认账户' COMMENT '账户名称',
|
||||
`initial_balance` DECIMAL(10,2) NOT NULL DEFAULT 0.00 COMMENT '初始余额',
|
||||
`create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
`update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||
PRIMARY KEY (`id`),
|
||||
INDEX `idx_user_id` (`user_id`),
|
||||
FOREIGN KEY (`user_id`) REFERENCES `user` (`id`) ON DELETE CASCADE,
|
||||
UNIQUE KEY `uk_user_account` (`user_id`) COMMENT '每个用户只有一个账户'
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='账户表';
|
||||
|
||||
-- 预算表
|
||||
CREATE TABLE IF NOT EXISTS `budget` (
|
||||
`id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '预算ID',
|
||||
`user_id` BIGINT NOT NULL COMMENT '用户ID',
|
||||
`year` INT NOT NULL COMMENT '年份',
|
||||
`month` INT NOT NULL COMMENT '月份(1-12)',
|
||||
`amount` DECIMAL(10,2) NOT NULL DEFAULT 0.00 COMMENT '预算金额',
|
||||
`create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
`update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||
PRIMARY KEY (`id`),
|
||||
INDEX `idx_user_id` (`user_id`),
|
||||
INDEX `idx_year_month` (`year`, `month`),
|
||||
FOREIGN KEY (`user_id`) REFERENCES `user` (`id`) ON DELETE CASCADE,
|
||||
UNIQUE KEY `uk_user_year_month` (`user_id`, `year`, `month`) COMMENT '每个用户每个月只有一个预算'
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='预算表';
|
||||
|
||||
-- OCR记录表
|
||||
CREATE TABLE IF NOT EXISTS `ocr_record` (
|
||||
`id` BIGINT NOT NULL AUTO_INCREMENT COMMENT 'OCR记录ID',
|
||||
`bill_id` BIGINT COMMENT '账单ID(识别成功后关联)',
|
||||
`user_id` BIGINT NOT NULL COMMENT '用户ID',
|
||||
`image_url` VARCHAR(500) NOT NULL COMMENT '图片URL',
|
||||
`ocr_result` TEXT COMMENT 'OCR识别结果(JSON格式)',
|
||||
`parsed_amount` DECIMAL(10,2) COMMENT '解析出的金额',
|
||||
`parsed_merchant` VARCHAR(255) COMMENT '解析出的商户名称',
|
||||
`parsed_date` DATE COMMENT '解析出的日期',
|
||||
`confidence` DECIMAL(5,2) COMMENT '置信度',
|
||||
`create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
PRIMARY KEY (`id`),
|
||||
INDEX `idx_bill_id` (`bill_id`),
|
||||
INDEX `idx_user_id` (`user_id`),
|
||||
FOREIGN KEY (`bill_id`) REFERENCES `bill` (`id`) ON DELETE SET NULL,
|
||||
FOREIGN KEY (`user_id`) REFERENCES `user` (`id`) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='OCR记录表';
|
||||
|
||||
-- 黄金价格记录表
|
||||
CREATE TABLE IF NOT EXISTS `gold_price` (
|
||||
`id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '记录ID',
|
||||
`gold_id` VARCHAR(20) NOT NULL COMMENT '黄金品种ID',
|
||||
`gold_name` VARCHAR(50) COMMENT '黄金品种名称',
|
||||
`price` DECIMAL(10,2) NOT NULL COMMENT '当前价格',
|
||||
`price_change` DECIMAL(10,2) COMMENT '涨跌额',
|
||||
`price_change_percent` DECIMAL(5,2) COMMENT '涨跌百分比',
|
||||
`high_price` DECIMAL(10,2) COMMENT '最高价',
|
||||
`low_price` DECIMAL(10,2) COMMENT '最低价',
|
||||
`open_price` DECIMAL(10,2) COMMENT '开盘价',
|
||||
`yesterday_close` DECIMAL(10,2) COMMENT '昨收价',
|
||||
`update_time` DATETIME NOT NULL COMMENT 'API返回的更新时间',
|
||||
`price_date` DATE NOT NULL COMMENT '价格日期',
|
||||
`create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '记录创建时间',
|
||||
PRIMARY KEY (`id`),
|
||||
INDEX `idx_gold_id` (`gold_id`),
|
||||
INDEX `idx_price_date` (`price_date`),
|
||||
INDEX `idx_gold_date` (`gold_id`, `price_date`) COMMENT '索引:用于按日期查询'
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='黄金价格记录表';
|
||||
|
||||
-- 插入预设分类(支出)
|
||||
INSERT INTO `category` (`user_id`, `name`, `icon`, `type`, `sort_order`) VALUES
|
||||
(NULL, '餐饮', '🍔', 1, 1),
|
||||
(NULL, '交通', '🚗', 1, 2),
|
||||
(NULL, '购物', '🛍️', 1, 3),
|
||||
(NULL, '娱乐', '🎬', 1, 4),
|
||||
(NULL, '医疗', '🏥', 1, 5),
|
||||
(NULL, '教育', '📚', 1, 6),
|
||||
(NULL, '住房', '🏠', 1, 7),
|
||||
(NULL, '水电', '💡', 1, 8),
|
||||
(NULL, '通讯', '📱', 1, 9),
|
||||
(NULL, '其他', '📦', 1, 10);
|
||||
|
||||
-- 插入预设分类(收入)
|
||||
INSERT INTO `category` (`user_id`, `name`, `icon`, `type`, `sort_order`) VALUES
|
||||
(NULL, '工资', '💰', 2, 1),
|
||||
(NULL, '奖金', '🎁', 2, 2),
|
||||
(NULL, '投资', '📈', 2, 3),
|
||||
(NULL, '兼职', '💼', 2, 4),
|
||||
(NULL, '其他', '📦', 2, 5);
|
||||
Reference in New Issue
Block a user