feat: 初始化收支管理系统

This commit is contained in:
wangshuo
2026-08-30 14:36:22 +08:00
commit 733ed4bb86
79 changed files with 5808 additions and 0 deletions
@@ -0,0 +1,22 @@
package com.example.finance;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
/**
* 应用程序主启动类。
* <p>Spring Boot 应用的入口点,负责引导和启动整个应用程序。</p>
*/
@SpringBootApplication
public class MyApplication {
/**
* 应用程序主入口方法。
*
* @param args 命令行启动参数
*/
public static void main(String[] args) {
// 启动 Spring Boot 应用
SpringApplication.run(MyApplication.class, args);
}
}
@@ -0,0 +1,41 @@
package com.example.finance.common;
/**
* 业务异常类。
* <p>用于表示业务逻辑中的可预期异常,携带自定义状态码,便于全局异常处理器返回对应的错误信息。</p>
*/
public class BusinessException extends RuntimeException {
/** 错误状态码 */
private final int code;
/**
* 构造业务异常(默认状态码 400)。
*
* @param message 异常消息
*/
public BusinessException(String message) {
super(message);
this.code = 400;
}
/**
* 构造业务异常(自定义状态码)。
*
* @param code 错误状态码
* @param message 异常消息
*/
public BusinessException(int code, String message) {
super(message);
this.code = code;
}
/**
* 获取错误状态码。
*
* @return 错误状态码
*/
public int getCode() {
return code;
}
}
@@ -0,0 +1,51 @@
package com.example.finance.common;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
/**
* 全局异常处理器。
* <p>统一捕获并处理控制器层抛出的各类异常,将其转换为标准化的 JSON 响应返回给前端。</p>
*/
@RestControllerAdvice
public class GlobalExceptionHandler {
/**
* 处理业务异常。
*
* @param e 业务异常对象
* @return 包含错误状态码和消息的响应结果
*/
@ExceptionHandler(BusinessException.class)
public Result<Void> handleBusinessException(BusinessException e) {
return Result.error(e.getCode(), e.getMessage());
}
/**
* 处理参数校验异常。
* <p>当请求参数校验不通过时,提取第一个校验错误信息并返回。</p>
*
* @param e 参数校验异常对象
* @return 包含 400 状态码和校验错误消息的响应结果
*/
@ExceptionHandler(MethodArgumentNotValidException.class)
public Result<Void> handleValidationException(MethodArgumentNotValidException e) {
// 提取第一个校验错误信息
String message = e.getBindingResult().getAllErrors().get(0).getDefaultMessage();
return Result.error(400, message);
}
/**
* 处理未捕获的通用异常。
*
* @param e 异常对象
* @return 包含 500 状态码和错误详情的响应结果
*/
@ExceptionHandler(Exception.class)
public Result<Void> handleException(Exception e) {
// 打印异常堆栈信息,便于排查问题
e.printStackTrace();
return Result.error(500, "服务器内部错误: " + e.getMessage());
}
}
@@ -0,0 +1,39 @@
package com.example.finance.common;
import lombok.Data;
import java.util.List;
/**
* 分页查询结果封装类。
* <p>用于封装分页查询的返回结果,包含数据列表、总记录数、当前页码和每页大小。</p>
*
* @param <T> 分页记录数据的泛型类型
*/
@Data
public class PageResult<T> {
/** 当前页的数据列表 */
private List<T> records;
/** 总记录数 */
private long total;
/** 当前页码 */
private long page;
/** 每页大小 */
private long size;
/**
* 构造分页结果。
*
* @param records 当前页的数据列表
* @param total 总记录数
* @param page 当前页码
* @param size 每页大小
*/
public PageResult(List<T> records, long total, long page, long size) {
this.records = records;
this.total = total;
this.page = page;
this.size = size;
}
}
@@ -0,0 +1,71 @@
package com.example.finance.common;
import lombok.Data;
/**
* 统一响应结果封装类。
* <p>用于封装所有接口返回的数据,包含状态码、消息和数据体,便于前端统一处理。</p>
*
* @param <T> 响应数据的泛型类型
*/
@Data
public class Result<T> {
/** 响应状态码 */
private int code;
/** 响应消息 */
private String message;
/** 响应数据 */
private T data;
/**
* 返回成功结果(携带数据)。
*
* @param data 响应数据
* @param <T> 数据类型
* @return 包含数据的成功响应结果
*/
public static <T> Result<T> success(T data) {
Result<T> result = new Result<>();
result.setCode(200);
result.setMessage("success");
result.setData(data);
return result;
}
/**
* 返回成功结果(不携带数据)。
*
* @param <T> 数据类型
* @return 不包含数据的成功响应结果
*/
public static <T> Result<T> success() {
return success(null);
}
/**
* 返回错误结果(自定义状态码和消息)。
*
* @param code 错误状态码
* @param message 错误消息
* @param <T> 数据类型
* @return 错误响应结果
*/
public static <T> Result<T> error(int code, String message) {
Result<T> result = new Result<>();
result.setCode(code);
result.setMessage(message);
return result;
}
/**
* 返回错误结果(使用默认状态码 500)。
*
* @param message 错误消息
* @param <T> 数据类型
* @return 错误响应结果
*/
public static <T> Result<T> error(String message) {
return error(500, message);
}
}
@@ -0,0 +1,20 @@
package com.example.finance.config;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
/**
* JWT 配置类。
* <p>从配置文件中读取以 "jwt" 为前缀的 JWT 相关配置项,包括密钥和过期时间。</p>
*/
@Data
@Component
@ConfigurationProperties(prefix = "jwt")
public class JwtConfig {
/** JWT 签名密钥 */
private String secret;
/** JWT 过期时间(毫秒) */
private long expiration;
}
@@ -0,0 +1,29 @@
package com.example.finance.config;
import io.swagger.v3.oas.models.OpenAPI;
import io.swagger.v3.oas.models.info.Info;
import io.swagger.v3.oas.models.Components;
import io.swagger.v3.oas.models.security.SecurityRequirement;
import io.swagger.v3.oas.models.security.SecurityScheme;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class Knife4jConfig {
@Bean
public OpenAPI customOpenAPI() {
return new OpenAPI()
.info(new Info()
.title("收支管理系统 API")
.version("1.0.0")
.description("个人收支管理系统后端接口文档"))
.addSecurityItem(new SecurityRequirement().addList("Bearer"))
.components(new Components()
.addSecuritySchemes("Bearer",
new SecurityScheme()
.type(SecurityScheme.Type.HTTP)
.scheme("bearer")
.bearerFormat("JWT")));
}
}
@@ -0,0 +1,30 @@
package com.example.finance.config;
import com.baomidou.mybatisplus.annotation.DbType;
import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor;
import com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor;
import org.mybatis.spring.annotation.MapperScan;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/**
* MyBatis-Plus 配置类。
* <p>配置 MyBatis-Plus 的 Mapper 扫描路径和分页插件等。</p>
*/
@Configuration
@MapperScan("com.example.finance.mapper")
public class MybatisPlusConfig {
/**
* 注册 MyBatis-Plus 拦截器,配置 MySQL 分页插件。
*
* @return MyBatis-Plus 拦截器实例
*/
@Bean
public MybatisPlusInterceptor mybatisPlusInterceptor() {
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
// 添加 MySQL 分页拦截器
interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL));
return interceptor;
}
}
@@ -0,0 +1,56 @@
package com.example.finance.config;
import com.example.finance.interceptor.JwtInterceptor;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.CorsRegistry;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
/**
* Web MVC 配置类。
* <p>配置拦截器、跨域策略等 Web 层相关设置。</p>
*/
@Configuration
public class WebConfig implements WebMvcConfigurer {
private final JwtInterceptor jwtInterceptor;
/**
* 构造方法,注入 JWT 拦截器。
*
* @param jwtInterceptor JWT 拦截器实例
*/
public WebConfig(JwtInterceptor jwtInterceptor) {
this.jwtInterceptor = jwtInterceptor;
}
/**
* 配置拦截器规则。
* <p>对 /api/** 路径下的请求启用 JWT 认证,排除登录和注册接口。</p>
*
* @param registry 拦截器注册器
*/
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(jwtInterceptor)
.addPathPatterns("/api/**")
// 登录和注册接口不需要认证
.excludePathPatterns("/api/user/login", "/api/user/register");
}
/**
* 配置跨域策略。
* <p>允许所有来源的跨域请求,支持常用 HTTP 方法,并允许携带凭据。</p>
*
* @param registry 跨域配置注册器
*/
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/**")
.allowedOriginPatterns("*")
.allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS")
.allowedHeaders("*")
.allowCredentials(true)
.maxAge(3600);
}
}
@@ -0,0 +1,106 @@
package com.example.finance.controller;
import com.example.finance.common.Result;
import com.example.finance.dto.CategoryRequest;
import com.example.finance.service.CategoryService;
import com.example.finance.vo.CategoryVO;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.Valid;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
/**
* 分类控制器
*
* <p>提供分类的增删改查接口,支持按类型筛选</p>
*/
@RestController
@RequestMapping("/api/category")
@Tag(name = "分类管理", description = "收支分类的增删改查相关接口")
public class CategoryController {
private final CategoryService categoryService;
public CategoryController(CategoryService categoryService) {
this.categoryService = categoryService;
}
/**
* 获取分类列表
*
* @param request HTTP请求,从中获取用户ID
* @param type 分类类型(可选),用于按类型筛选
* @return 分类列表
*/
@GetMapping("/list")
@Operation(summary = "获取分类列表", description = "获取当前用户的全部分类,支持按类型筛选")
public Result<List<CategoryVO>> list(HttpServletRequest request,
@Parameter(description = "分类类型,可选值:income/expense") @RequestParam(required = false) String type) {
Long userId = (Long) request.getAttribute("userId");
if (type != null && !type.isEmpty()) {
return Result.success(categoryService.listByType(userId, type));
}
return Result.success(categoryService.list(userId));
}
/**
* 创建分类
*
* @param request HTTP请求,从中获取用户ID
* @param categoryRequest 分类请求参数
* @return 操作结果
*/
@PostMapping
@Operation(summary = "创建分类", description = "为当前用户创建新的收支分类")
public Result<Void> create(HttpServletRequest request, @RequestBody @Valid CategoryRequest categoryRequest) {
Long userId = (Long) request.getAttribute("userId");
categoryService.create(userId, categoryRequest);
return Result.success();
}
/**
* 更新分类
*
* @param request HTTP请求,从中获取用户ID
* @param id 分类ID
* @param categoryRequest 分类请求参数
* @return 操作结果
*/
@PutMapping("/{id}")
@Operation(summary = "更新分类", description = "根据分类ID更新分类信息")
public Result<Void> update(HttpServletRequest request,
@Parameter(description = "分类ID") @PathVariable Long id,
@RequestBody @Valid CategoryRequest categoryRequest) {
Long userId = (Long) request.getAttribute("userId");
categoryService.update(userId, id, categoryRequest);
return Result.success();
}
/**
* 删除分类
*
* @param request HTTP请求,从中获取用户ID
* @param id 分类ID
* @return 操作结果
*/
@DeleteMapping("/{id}")
@Operation(summary = "删除分类", description = "根据分类ID删除指定分类")
public Result<Void> delete(HttpServletRequest request,
@Parameter(description = "分类ID") @PathVariable Long id) {
Long userId = (Long) request.getAttribute("userId");
categoryService.delete(userId, id);
return Result.success();
}
}
@@ -0,0 +1,80 @@
package com.example.finance.controller;
import com.example.finance.common.Result;
import com.example.finance.dto.StatisticsQuery;
import com.example.finance.service.StatisticsService;
import com.example.finance.vo.CategoryStatisticsVO;
import com.example.finance.vo.MonthlyStatisticsVO;
import com.example.finance.vo.TrendStatisticsVO;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.Valid;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
/**
* 统计控制器
*
* <p>提供月度统计、分类统计、趋势统计等数据统计接口</p>
*/
@RestController
@RequestMapping("/api/statistics")
@Tag(name = "数据统计", description = "收支数据的月度统计、分类统计及趋势分析接口")
public class StatisticsController {
private final StatisticsService statisticsService;
public StatisticsController(StatisticsService statisticsService) {
this.statisticsService = statisticsService;
}
/**
* 获取月度统计数据
*
* @param request HTTP请求,从中获取用户ID
* @param query 统计查询参数
* @return 月度统计结果
*/
@GetMapping("/monthly")
@Operation(summary = "获取月度统计数据", description = "获取当前用户指定月份的收支统计汇总数据")
public Result<MonthlyStatisticsVO> monthly(HttpServletRequest request, @Valid
@Parameter(description = "统计查询参数") StatisticsQuery query) {
Long userId = (Long) request.getAttribute("userId");
return Result.success(statisticsService.getMonthlyStatistics(userId, query));
}
/**
* 获取分类统计数据
*
* @param request HTTP请求,从中获取用户ID
* @param query 统计查询参数
* @return 分类统计列表
*/
@GetMapping("/category")
@Operation(summary = "获取分类统计数据", description = "获取当前用户指定月份按分类维度的统计数据")
public Result<List<CategoryStatisticsVO>> category(HttpServletRequest request, @Valid
@Parameter(description = "统计查询参数") StatisticsQuery query) {
Long userId = (Long) request.getAttribute("userId");
return Result.success(statisticsService.getCategoryStatistics(userId, query));
}
/**
* 获取趋势统计数据
*
* @param request HTTP请求,从中获取用户ID
* @param query 统计查询参数
* @return 趋势统计列表
*/
@GetMapping("/trend")
@Operation(summary = "获取趋势统计数据", description = "获取当前用户指定时间范围内的收支趋势数据")
public Result<List<TrendStatisticsVO>> trend(HttpServletRequest request, @Valid
@Parameter(description = "统计查询参数") StatisticsQuery query) {
Long userId = (Long) request.getAttribute("userId");
return Result.success(statisticsService.getTrendStatistics(userId, query));
}
}
@@ -0,0 +1,160 @@
package com.example.finance.controller;
import com.alibaba.excel.EasyExcel;
import com.example.finance.common.PageResult;
import com.example.finance.common.Result;
import com.example.finance.dto.TransactionQuery;
import com.example.finance.dto.TransactionRequest;
import com.example.finance.service.TransactionService;
import com.example.finance.vo.TransactionExportVO;
import com.example.finance.vo.TransactionVO;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import jakarta.validation.Valid;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.io.IOException;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.List;
import java.util.Map;
/**
* 交易记录控制器
*
* <p>提供交易记录的分页查询、新增、更新、删除接口</p>
*/
@RestController
@RequestMapping("/api/transaction")
@Tag(name = "交易记录管理", description = "收支交易记录的增删改查相关接口")
public class TransactionController {
private final TransactionService transactionService;
public TransactionController(TransactionService transactionService) {
this.transactionService = transactionService;
}
/**
* 分页查询交易记录
*
* @param request HTTP请求,从中获取用户ID
* @param query 分页查询参数
* @return 分页交易记录列表
*/
@GetMapping("/page")
@Operation(summary = "分页查询交易记录", description = "根据查询条件分页获取当前用户的交易记录")
public Result<PageResult<TransactionVO>> page(HttpServletRequest request,
@Parameter(description = "分页查询参数") TransactionQuery query) {
Long userId = (Long) request.getAttribute("userId");
return Result.success(transactionService.page(userId, query));
}
/**
* 新增交易记录
*
* @param request HTTP请求,从中获取用户ID
* @param transactionRequest 交易记录请求参数
* @return 操作结果
*/
@PostMapping
@Operation(summary = "新增交易记录", description = "为当前用户新增一条交易记录")
public Result<Void> create(HttpServletRequest request, @RequestBody @Valid TransactionRequest transactionRequest) {
Long userId = (Long) request.getAttribute("userId");
transactionService.create(userId, transactionRequest);
return Result.success();
}
/**
* 更新交易记录
*
* @param request HTTP请求,从中获取用户ID
* @param id 交易记录ID
* @param transactionRequest 交易记录请求参数
* @return 操作结果
*/
@PutMapping("/{id}")
@Operation(summary = "更新交易记录", description = "根据交易记录ID更新交易信息")
public Result<Void> update(HttpServletRequest request,
@Parameter(description = "交易记录ID") @PathVariable Long id,
@RequestBody @Valid TransactionRequest transactionRequest) {
Long userId = (Long) request.getAttribute("userId");
transactionService.update(userId, id, transactionRequest);
return Result.success();
}
/**
* 删除交易记录
*
* @param request HTTP请求,从中获取用户ID
* @param id 交易记录ID
* @return 操作结果
*/
@DeleteMapping("/{id}")
@Operation(summary = "删除交易记录", description = "根据交易记录ID删除指定交易记录")
public Result<Void> delete(HttpServletRequest request,
@Parameter(description = "交易记录ID") @PathVariable Long id) {
Long userId = (Long) request.getAttribute("userId");
transactionService.delete(userId, id);
return Result.success();
}
/**
* 切换交易记录的"计入统计"标记
*
* @param request HTTP请求,从中获取用户ID
* @param id 交易记录ID
* @param body 请求体,包含 includeInStats 字段
* @return 操作结果
*/
@PutMapping("/{id}/stats")
@Operation(summary = "切换计入统计标记", description = "切换交易记录是否计入统计(用于表格中开关)")
public Result<Void> toggleStats(HttpServletRequest request,
@Parameter(description = "交易记录ID") @PathVariable Long id,
@RequestBody Map<String, Boolean> body) {
Long userId = (Long) request.getAttribute("userId");
Boolean includeInStats = body != null ? body.get("includeInStats") : null;
transactionService.toggleStats(userId, id, includeInStats);
return Result.success();
}
/**
* 导出交易记录为 Excel 文件
*
* @param request HTTP请求,从中获取用户ID
* @param response HTTP响应,用于输出文件流
* @param query 查询条件
* @throws IOException 文件输出异常
*/
@GetMapping("/export")
@Operation(summary = "导出交易记录", description = "根据查询条件导出交易记录为 Excel 文件")
public void export(HttpServletRequest request,
HttpServletResponse response,
@Parameter(description = "查询条件") TransactionQuery query) throws IOException {
Long userId = (Long) request.getAttribute("userId");
// 设置响应头
response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
response.setCharacterEncoding("utf-8");
String fileName = URLEncoder.encode("交易记录_" + LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMdd_HHmmss")), StandardCharsets.UTF_8).replaceAll("\\+", "%20");
response.setHeader("Content-Disposition", "attachment;filename=" + fileName + ".xlsx");
// 查询数据并写入 Excel
List<TransactionExportVO> data = transactionService.export(userId, query);
EasyExcel.write(response.getOutputStream(), TransactionExportVO.class)
.sheet("交易记录")
.doWrite(data);
}
}
@@ -0,0 +1,72 @@
package com.example.finance.controller;
import com.example.finance.common.Result;
import com.example.finance.dto.LoginRequest;
import com.example.finance.dto.RegisterRequest;
import com.example.finance.service.UserService;
import com.example.finance.vo.LoginVO;
import com.example.finance.vo.UserVO;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.Valid;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
/**
* 用户控制器
*
* <p>提供用户登录、注册、获取用户信息等接口</p>
*/
@RestController
@RequestMapping("/api/user")
@Tag(name = "用户管理", description = "用户登录、注册及信息查询相关接口")
public class UserController {
private final UserService userService;
public UserController(UserService userService) {
this.userService = userService;
}
/**
* 用户登录
*
* @param request 登录请求参数
* @return 登录结果,包含用户信息和令牌
*/
@PostMapping("/login")
@Operation(summary = "用户登录", description = "通过用户名和密码进行登录认证")
public Result<LoginVO> login(@RequestBody @Valid LoginRequest request) {
return Result.success(userService.login(request));
}
/**
* 用户注册
*
* @param request 注册请求参数
* @return 操作结果
*/
@PostMapping("/register")
@Operation(summary = "用户注册", description = "新用户注册账号")
public Result<Void> register(@RequestBody @Valid RegisterRequest request) {
userService.register(request);
return Result.success();
}
/**
* 获取当前用户信息
*
* @param request HTTP请求,从中获取用户ID
* @return 用户信息
*/
@GetMapping("/info")
@Operation(summary = "获取当前用户信息", description = "获取已登录用户的详细信息")
public Result<UserVO> info(HttpServletRequest request) {
Long userId = (Long) request.getAttribute("userId");
return Result.success(userService.getUserInfo(userId));
}
}
@@ -0,0 +1,25 @@
package com.example.finance.dto;
import jakarta.validation.constraints.NotBlank;
import lombok.Data;
/**
* 分类请求DTO,用于接收创建或更新分类时提交的信息
*/
@Data
public class CategoryRequest {
/** 分类名称,不能为空 */
@NotBlank(message = "分类名称不能为空")
private String name;
/** 分类类型(收入/支出),不能为空 */
@NotBlank(message = "分类类型不能为空")
private String type;
/** 分类图标,可选 */
private String icon;
/** 排序序号,可选 */
private Integer sortOrder;
}
@@ -0,0 +1,20 @@
package com.example.finance.dto;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
import lombok.Data;
/**
* 登录请求DTO,用于接收用户登录时提交的用户名和密码
*/
@Data
public class LoginRequest {
/** 用户名,不能为空 */
@NotBlank(message = "用户名不能为空")
private String username;
/** 密码,不能为空 */
@NotBlank(message = "密码不能为空")
private String password;
}
@@ -0,0 +1,25 @@
package com.example.finance.dto;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
import lombok.Data;
/**
* 注册请求DTO,用于接收用户注册时提交的信息
*/
@Data
public class RegisterRequest {
/** 用户名,长度3-50个字符 */
@NotBlank(message = "用户名不能为空")
@Size(min = 3, max = 50, message = "用户名长度3-50个字符")
private String username;
/** 密码,长度6-50个字符 */
@NotBlank(message = "密码不能为空")
@Size(min = 6, max = 50, message = "密码长度6-50个字符")
private String password;
/** 昵称,可选 */
private String nickname;
}
@@ -0,0 +1,19 @@
package com.example.finance.dto;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
/**
* 统计查询DTO,用于接收统计报表查询时的年月参数
*/
@Data
public class StatisticsQuery {
/** 查询年份,不能为空 */
@NotNull(message = "年份不能为空")
private Integer year;
/** 查询月份,不能为空 */
@NotNull(message = "月份不能为空")
private Integer month;
}
@@ -0,0 +1,36 @@
package com.example.finance.dto;
import lombok.Data;
import java.util.List;
/**
* 交易查询DTO,用于接收交易记录分页查询的筛选条件
*/
@Data
public class TransactionQuery {
/** 当前页码,默认第1页 */
private Integer page = 1;
/** 每页条数,默认20条 */
private Integer size = 20;
/** 交易类型筛选(收入/支出),支持多选 */
private List<String> types;
/** 分类ID筛选,支持多选 */
private List<Long> categoryIds;
/** 查询起始时间 */
private String startTime;
/** 查询结束时间 */
private String endTime;
/** 支付方式筛选(WECHAT/ALIPAY/CASH/BANK),支持多选 */
private List<String> paymentMethods;
/** 是否计入统计筛选:null-全部,true-仅计入,false-仅不计入 */
private Boolean includeInStats;
}
@@ -0,0 +1,42 @@
package com.example.finance.dto;
import jakarta.validation.constraints.DecimalMin;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDateTime;
/**
* 交易请求DTO,用于接收创建或更新交易记录时提交的信息
*/
@Data
public class TransactionRequest {
/** 分类ID,不能为空 */
@NotNull(message = "分类不能为空")
private Long categoryId;
/** 交易类型(收入/支出),不能为空 */
@NotBlank(message = "类型不能为空")
private String type;
/** 交易金额,必须大于0 */
@NotNull(message = "金额不能为空")
@DecimalMin(value = "0.01", message = "金额必须大于0")
private BigDecimal amount;
/** 支付方式,可选 */
private String paymentMethod;
/** 备注,可选 */
private String remark;
/** 交易时间,不能为空 */
@NotNull(message = "交易时间不能为空")
private LocalDateTime transactionTime;
/** 是否计入统计,默认 true(借款、转账等可设为 false) */
private Boolean includeInStats;
}
@@ -0,0 +1,38 @@
package com.example.finance.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;
/**
* 分类实体类,对应数据库表 t_category,用于记录收支分类信息
*/
@Data
@TableName("t_category")
public class Category {
/** 分类ID,主键自增 */
@TableId(type = IdType.AUTO)
private Long id;
/** 所属用户ID */
private Long userId;
/** 分类名称 */
private String name;
/** 分类类型(收入/支出) */
private String type;
/** 分类图标 */
private String icon;
/** 排序序号 */
private Integer sortOrder;
/** 创建时间 */
private LocalDateTime createdAt;
}
@@ -0,0 +1,51 @@
package com.example.finance.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;
/**
* 交易记录实体类,对应数据库表 t_transaction,用于记录每笔收支交易
*/
@Data
@TableName("t_transaction")
public class Transaction {
/** 交易ID,主键自增 */
@TableId(type = IdType.AUTO)
private Long id;
/** 所属用户ID */
private Long userId;
/** 所属分类ID */
private Long categoryId;
/** 交易类型(收入/支出) */
private String type;
/** 交易金额 */
private BigDecimal amount;
/** 支付方式 */
private String paymentMethod;
/** 备注 */
private String remark;
/** 交易时间 */
private LocalDateTime transactionTime;
/** 是否计入统计:true-计入,false-不计入(如借款、账户间转账等) */
private Boolean includeInStats;
/** 创建时间 */
private LocalDateTime createdAt;
/** 更新时间 */
private LocalDateTime updatedAt;
}
@@ -0,0 +1,35 @@
package com.example.finance.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;
/**
* 用户实体类,对应数据库表 t_user
*/
@Data
@TableName("t_user")
public class User {
/** 用户ID,主键自增 */
@TableId(type = IdType.AUTO)
private Long id;
/** 用户名 */
private String username;
/** 密码哈希值 */
private String passwordHash;
/** 昵称 */
private String nickname;
/** 创建时间 */
private LocalDateTime createdAt;
/** 更新时间 */
private LocalDateTime updatedAt;
}
@@ -0,0 +1,75 @@
package com.example.finance.interceptor;
import com.example.finance.util.JwtUtil;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.HandlerInterceptor;
import java.util.HashMap;
import java.util.Map;
/**
* JWT 认证拦截器。
* <p>在请求到达控制器之前,从 Authorization 请求头中提取并验证 JWT Token,
* 验证通过后将用户 ID 存入请求属性,验证失败则返回 401 响应。</p>
*/
@Component
public class JwtInterceptor implements HandlerInterceptor {
private final JwtUtil jwtUtil;
private final ObjectMapper objectMapper;
/**
* 构造方法,注入 JWT 工具类和 JSON 序列化工具。
*
* @param jwtUtil JWT 工具类实例
* @param objectMapper Jackson 对象映射器实例
*/
public JwtInterceptor(JwtUtil jwtUtil, ObjectMapper objectMapper) {
this.jwtUtil = jwtUtil;
this.objectMapper = objectMapper;
}
/**
* 请求预处理方法,在控制器执行前进行 JWT 认证。
* <p>处理流程:放行 OPTIONS 预检请求 -> 提取 Token -> 验证 Token -> 存入用户 ID 或返回 401。</p>
*
* @param request HTTP 请求对象
* @param response HTTP 响应对象
* @param handler 处理器对象
* @return 认证通过返回 true,否则返回 false
* @throws Exception 处理过程中可能抛出的异常
*/
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
// 放行 CORS 预检请求
if ("OPTIONS".equalsIgnoreCase(request.getMethod())) {
return true;
}
// 从 Authorization 请求头中提取 Token
String token = request.getHeader("Authorization");
if (token != null && token.startsWith("Bearer ")) {
// 去除 "Bearer " 前缀
token = token.substring(7);
}
// Token 为空或验证不通过时,返回 401 未授权响应
if (token == null || !jwtUtil.validateToken(token)) {
response.setContentType("application/json;charset=UTF-8");
response.setStatus(401);
Map<String, Object> result = new HashMap<>();
result.put("code", 401);
result.put("message", "未登录或Token已过期");
response.getWriter().write(objectMapper.writeValueAsString(result));
return false;
}
// Token 验证通过,将用户 ID 存入请求属性,供后续控制器使用
Long userId = jwtUtil.getUserId(token);
request.setAttribute("userId", userId);
return true;
}
}
@@ -0,0 +1,12 @@
package com.example.finance.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.example.finance.entity.Category;
import org.apache.ibatis.annotations.Mapper;
/**
* 分类Mapper接口,继承MyBatis-Plus的BaseMapper,提供分类表的基础CRUD操作
*/
@Mapper
public interface CategoryMapper extends BaseMapper<Category> {
}
@@ -0,0 +1,12 @@
package com.example.finance.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.example.finance.entity.Transaction;
import org.apache.ibatis.annotations.Mapper;
/**
* 交易记录Mapper接口,继承MyBatis-Plus的BaseMapper,提供交易记录表的基础CRUD操作
*/
@Mapper
public interface TransactionMapper extends BaseMapper<Transaction> {
}
@@ -0,0 +1,12 @@
package com.example.finance.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.example.finance.entity.User;
import org.apache.ibatis.annotations.Mapper;
/**
* 用户Mapper接口,继承MyBatis-Plus的BaseMapper,提供用户表的基础CRUD操作
*/
@Mapper
public interface UserMapper extends BaseMapper<User> {
}
@@ -0,0 +1,54 @@
package com.example.finance.service;
import com.example.finance.dto.CategoryRequest;
import com.example.finance.vo.CategoryVO;
import java.util.List;
/**
* 分类服务接口,提供收支分类的增删改查操作。
*/
public interface CategoryService {
/**
* 获取用户的所有分类列表
*
* @param userId 用户ID
* @return 分类列表
*/
List<CategoryVO> list(Long userId);
/**
* 按类型(收入/支出)获取用户的分类列表
*
* @param userId 用户ID
* @param type 分类类型(如 INCOME、EXPENSE)
* @return 分类列表
*/
List<CategoryVO> listByType(Long userId, String type);
/**
* 创建新分类
*
* @param userId 用户ID
* @param request 分类请求参数(名称、类型、图标等)
*/
void create(Long userId, CategoryRequest request);
/**
* 更新分类信息
*
* @param userId 用户ID
* @param id 分类ID
* @param request 分类请求参数
*/
void update(Long userId, Long id, CategoryRequest request);
/**
* 删除分类
*
* @param userId 用户ID
* @param id 分类ID
*/
void delete(Long userId, Long id);
}
@@ -0,0 +1,41 @@
package com.example.finance.service;
import com.example.finance.dto.StatisticsQuery;
import com.example.finance.vo.CategoryStatisticsVO;
import com.example.finance.vo.MonthlyStatisticsVO;
import com.example.finance.vo.TrendStatisticsVO;
import java.util.List;
/**
* 统计服务接口,提供月度收支统计、分类统计和趋势分析功能。
*/
public interface StatisticsService {
/**
* 获取月度收支统计(总收入、总支出、结余、交易笔数)
*
* @param userId 用户ID
* @param query 统计查询条件(年份、月份)
* @return 月度统计结果
*/
MonthlyStatisticsVO getMonthlyStatistics(Long userId, StatisticsQuery query);
/**
* 获取分类维度的统计数据(各分类的金额、笔数、占比)
*
* @param userId 用户ID
* @param query 统计查询条件(年份、月份)
* @return 分类统计列表
*/
List<CategoryStatisticsVO> getCategoryStatistics(Long userId, StatisticsQuery query);
/**
* 获取每日收支趋势统计数据
*
* @param userId 用户ID
* @param query 统计查询条件(年份、月份)
* @return 按日排列的趋势统计列表
*/
List<TrendStatisticsVO> getTrendStatistics(Long userId, StatisticsQuery query);
}
@@ -0,0 +1,67 @@
package com.example.finance.service;
import com.example.finance.common.PageResult;
import com.example.finance.dto.TransactionQuery;
import com.example.finance.dto.TransactionRequest;
import com.example.finance.vo.TransactionExportVO;
import com.example.finance.vo.TransactionVO;
import java.util.List;
/**
* 交易记录服务接口,提供收支记录的分页查询、增删改操作。
*/
public interface TransactionService {
/**
* 分页查询交易记录
*
* @param userId 用户ID
* @param query 查询条件(类型、分类、时间范围、分页参数等)
* @return 分页结果
*/
PageResult<TransactionVO> page(Long userId, TransactionQuery query);
/**
* 创建交易记录
*
* @param userId 用户ID
* @param request 交易请求参数(金额、分类、类型等)
*/
void create(Long userId, TransactionRequest request);
/**
* 更新交易记录
*
* @param userId 用户ID
* @param id 交易记录ID
* @param request 交易请求参数
*/
void update(Long userId, Long id, TransactionRequest request);
/**
* 删除交易记录
*
* @param userId 用户ID
* @param id 交易记录ID
*/
void delete(Long userId, Long id);
/**
* 导出交易记录
*
* @param userId 用户ID
* @param query 查询条件(类型、分类、时间范围等)
* @return 导出视图对象列表
*/
List<TransactionExportVO> export(Long userId, TransactionQuery query);
/**
* 切换单条交易记录的"计入统计"标记
*
* @param userId 用户ID
* @param id 交易记录ID
* @param includeInStats 新的标记值
*/
void toggleStats(Long userId, Long id, Boolean includeInStats);
}
@@ -0,0 +1,35 @@
package com.example.finance.service;
import com.example.finance.dto.LoginRequest;
import com.example.finance.dto.RegisterRequest;
import com.example.finance.vo.LoginVO;
import com.example.finance.vo.UserVO;
/**
* 用户服务接口,提供用户登录、注册和信息查询等操作。
*/
public interface UserService {
/**
* 用户登录
*
* @param request 登录请求参数(用户名、密码)
* @return 登录结果,包含 Token 和用户信息
*/
LoginVO login(LoginRequest request);
/**
* 用户注册
*
* @param request 注册请求参数(用户名、密码、昵称等)
*/
void register(RegisterRequest request);
/**
* 获取用户信息
*
* @param userId 用户ID
* @return 用户信息视图对象
*/
UserVO getUserInfo(Long userId);
}
@@ -0,0 +1,129 @@
package com.example.finance.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.example.finance.common.BusinessException;
import com.example.finance.dto.CategoryRequest;
import com.example.finance.entity.Category;
import com.example.finance.mapper.CategoryMapper;
import com.example.finance.service.CategoryService;
import com.example.finance.vo.CategoryVO;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
import java.util.stream.Collectors;
/**
* 分类服务实现类,处理收支分类的增删改查业务逻辑。
*/
@Service
public class CategoryServiceImpl implements CategoryService {
private final CategoryMapper categoryMapper;
public CategoryServiceImpl(CategoryMapper categoryMapper) {
this.categoryMapper = categoryMapper;
}
/**
* {@inheritDoc}
*
* 查询用户所有分类,按排序字段和ID升序排列。
*/
@Override
public List<CategoryVO> list(Long userId) {
List<Category> categories = categoryMapper.selectList(new LambdaQueryWrapper<Category>()
.eq(Category::getUserId, userId)
.orderByAsc(Category::getSortOrder)
.orderByAsc(Category::getId));
return categories.stream().map(this::toVO).collect(Collectors.toList());
}
/**
* {@inheritDoc}
*
* 按分类类型筛选用户分类列表,按排序字段和ID升序排列。
*/
@Override
public List<CategoryVO> listByType(Long userId, String type) {
List<Category> categories = categoryMapper.selectList(new LambdaQueryWrapper<Category>()
.eq(Category::getUserId, userId)
.eq(Category::getType, type)
.orderByAsc(Category::getSortOrder)
.orderByAsc(Category::getId));
return categories.stream().map(this::toVO).collect(Collectors.toList());
}
/**
* {@inheritDoc}
*
* 创建新分类,若未指定排序值则默认为0。
*/
@Override
@Transactional
public void create(Long userId, CategoryRequest request) {
Category category = new Category();
category.setUserId(userId);
category.setName(request.getName());
category.setType(request.getType());
category.setIcon(request.getIcon());
// 排序值未提供时默认为0
category.setSortOrder(request.getSortOrder() != null ? request.getSortOrder() : 0);
categoryMapper.insert(category);
}
/**
* {@inheritDoc}
*
* 更新分类信息,需校验分类归属当前用户。
*/
@Override
@Transactional
public void update(Long userId, Long id, CategoryRequest request) {
Category category = categoryMapper.selectById(id);
// 校验分类是否存在且属于当前用户
if (category == null || !category.getUserId().equals(userId)) {
throw new BusinessException("分类不存在");
}
category.setName(request.getName());
category.setType(request.getType());
category.setIcon(request.getIcon());
if (request.getSortOrder() != null) {
category.setSortOrder(request.getSortOrder());
}
categoryMapper.updateById(category);
}
/**
* {@inheritDoc}
*
* 删除分类,需校验分类归属当前用户。
*/
@Override
@Transactional
public void delete(Long userId, Long id) {
Category category = categoryMapper.selectById(id);
// 校验分类是否存在且属于当前用户
if (category == null || !category.getUserId().equals(userId)) {
throw new BusinessException("分类不存在");
}
categoryMapper.deleteById(id);
}
/**
* 将分类实体转换为视图对象
*
* @param category 分类实体
* @return 分类视图对象
*/
private CategoryVO toVO(Category category) {
CategoryVO vo = new CategoryVO();
vo.setId(category.getId());
vo.setName(category.getName());
vo.setType(category.getType());
vo.setIcon(category.getIcon());
vo.setSortOrder(category.getSortOrder());
vo.setCreatedAt(category.getCreatedAt());
return vo;
}
}
@@ -0,0 +1,189 @@
package com.example.finance.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.example.finance.dto.StatisticsQuery;
import com.example.finance.entity.Category;
import com.example.finance.entity.Transaction;
import com.example.finance.mapper.CategoryMapper;
import com.example.finance.mapper.TransactionMapper;
import com.example.finance.service.StatisticsService;
import com.example.finance.vo.CategoryStatisticsVO;
import com.example.finance.vo.MonthlyStatisticsVO;
import com.example.finance.vo.TrendStatisticsVO;
import org.springframework.stereotype.Service;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.LocalTime;
import java.time.YearMonth;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
/**
* 统计服务实现类,提供月度收支汇总、分类占比分析和每日收支趋势统计等业务逻辑。
*/
@Service
public class StatisticsServiceImpl implements StatisticsService {
private final TransactionMapper transactionMapper;
private final CategoryMapper categoryMapper;
public StatisticsServiceImpl(TransactionMapper transactionMapper, CategoryMapper categoryMapper) {
this.transactionMapper = transactionMapper;
this.categoryMapper = categoryMapper;
}
/**
* {@inheritDoc}
*
* 遍历指定月份的所有交易记录,分别累加收入和支出,计算结余和交易笔数。
*/
@Override
public MonthlyStatisticsVO getMonthlyStatistics(Long userId, StatisticsQuery query) {
List<Transaction> transactions = getMonthTransactions(userId, query);
MonthlyStatisticsVO vo = new MonthlyStatisticsVO();
BigDecimal totalIncome = BigDecimal.ZERO;
BigDecimal totalExpense = BigDecimal.ZERO;
// 按交易类型分别累加收入和支出
for (Transaction t : transactions) {
if ("INCOME".equals(t.getType())) {
totalIncome = totalIncome.add(t.getAmount());
} else {
totalExpense = totalExpense.add(t.getAmount());
}
}
vo.setTotalIncome(totalIncome);
vo.setTotalExpense(totalExpense);
vo.setBalance(totalIncome.subtract(totalExpense));
vo.setTransactionCount(transactions.size());
return vo;
}
/**
* {@inheritDoc}
*
* 按分类ID分组统计交易金额、笔数,并计算各分类占总金额的百分比,结果按金额降序排列。
*/
@Override
public List<CategoryStatisticsVO> getCategoryStatistics(Long userId, StatisticsQuery query) {
List<Transaction> transactions = getMonthTransactions(userId, query);
// 按分类ID分组
Map<Long, List<Transaction>> grouped = transactions.stream()
.collect(Collectors.groupingBy(Transaction::getCategoryId));
// 批量查询分类信息
List<Long> categoryIds = new ArrayList<>(grouped.keySet());
Map<Long, Category> categoryMap = Map.of();
if (!categoryIds.isEmpty()) {
List<Category> categories = categoryMapper.selectBatchIds(categoryIds);
categoryMap = categories.stream().collect(Collectors.toMap(Category::getId, c -> c));
}
// 计算总金额,用于后续计算百分比
BigDecimal totalAmount = transactions.stream()
.map(Transaction::getAmount)
.reduce(BigDecimal.ZERO, BigDecimal::add);
// 构建每个分类的统计结果
Map<Long, Category> finalCategoryMap = categoryMap;
return grouped.entrySet().stream()
.map(entry -> {
List<Transaction> list = entry.getValue();
Category category = finalCategoryMap.get(entry.getKey());
BigDecimal amount = list.stream()
.map(Transaction::getAmount)
.reduce(BigDecimal.ZERO, BigDecimal::add);
CategoryStatisticsVO vo = new CategoryStatisticsVO();
vo.setCategoryId(entry.getKey());
vo.setCategoryName(category != null ? category.getName() : "未知");
vo.setCategoryIcon(category != null ? category.getIcon() : "");
vo.setType(category != null ? category.getType() : "");
vo.setAmount(amount);
vo.setCount(list.size());
// 计算占比百分比,保留两位小数,四舍五入
if (totalAmount.compareTo(BigDecimal.ZERO) > 0) {
vo.setPercentage(amount.multiply(BigDecimal.valueOf(100))
.divide(totalAmount, 2, RoundingMode.HALF_UP));
} else {
vo.setPercentage(BigDecimal.ZERO);
}
return vo;
})
// 按金额降序排列
.sorted((a, b) -> b.getAmount().compareTo(a.getAmount()))
.collect(Collectors.toList());
}
/**
* {@inheritDoc}
*
* 统计指定月份每天的收入和支出,返回按日期排列的趋势数据列表。
*/
@Override
public List<TrendStatisticsVO> getTrendStatistics(Long userId, StatisticsQuery query) {
YearMonth yearMonth = YearMonth.of(query.getYear(), query.getMonth());
int daysInMonth = yearMonth.lengthOfMonth();
// 初始化每日收入和支出为零
Map<Integer, BigDecimal> incomeMap = new HashMap<>();
Map<Integer, BigDecimal> expenseMap = new HashMap<>();
for (int i = 1; i <= daysInMonth; i++) {
incomeMap.put(i, BigDecimal.ZERO);
expenseMap.put(i, BigDecimal.ZERO);
}
// 累加每日交易金额
List<Transaction> transactions = getMonthTransactions(userId, query);
for (Transaction t : transactions) {
int day = t.getTransactionTime().getDayOfMonth();
if ("INCOME".equals(t.getType())) {
incomeMap.merge(day, t.getAmount(), BigDecimal::add);
} else {
expenseMap.merge(day, t.getAmount(), BigDecimal::add);
}
}
// 组装每日趋势数据
List<TrendStatisticsVO> result = new ArrayList<>();
for (int i = 1; i <= daysInMonth; i++) {
TrendStatisticsVO vo = new TrendStatisticsVO();
vo.setDate(String.format("%d-%02d-%02d", query.getYear(), query.getMonth(), i));
vo.setIncome(incomeMap.get(i));
vo.setExpense(expenseMap.get(i));
result.add(vo);
}
return result;
}
/**
* 查询指定月份内用户的所有交易记录(仅包含计入统计的记录)
*
* @param userId 用户ID
* @param query 统计查询条件(年份、月份)
* @return 该月份的交易记录列表
*/
private List<Transaction> getMonthTransactions(Long userId, StatisticsQuery query) {
YearMonth yearMonth = YearMonth.of(query.getYear(), query.getMonth());
// 计算月份的起止时间
LocalDateTime startTime = yearMonth.atDay(1).atStartOfDay();
LocalDateTime endTime = yearMonth.atEndOfMonth().atTime(LocalTime.MAX);
return transactionMapper.selectList(new LambdaQueryWrapper<Transaction>()
.eq(Transaction::getUserId, userId)
.ge(Transaction::getTransactionTime, startTime)
.le(Transaction::getTransactionTime, endTime)
// 仅统计计入统计的记录(排除借款、账户间转账等)
.and(w -> w.eq(Transaction::getIncludeInStats, true)
.or().isNull(Transaction::getIncludeInStats)));
}
}
@@ -0,0 +1,318 @@
package com.example.finance.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.example.finance.common.BusinessException;
import com.example.finance.common.PageResult;
import com.example.finance.dto.TransactionQuery;
import com.example.finance.dto.TransactionRequest;
import com.example.finance.entity.Category;
import com.example.finance.entity.Transaction;
import com.example.finance.mapper.CategoryMapper;
import com.example.finance.mapper.TransactionMapper;
import com.example.finance.service.TransactionService;
import com.example.finance.vo.TransactionExportVO;
import com.example.finance.vo.TransactionVO;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
/**
* 交易记录服务实现类,处理收支记录的分页查询、创建、更新和删除等业务逻辑。
*/
@Service
public class TransactionServiceImpl implements TransactionService {
private final TransactionMapper transactionMapper;
private final CategoryMapper categoryMapper;
public TransactionServiceImpl(TransactionMapper transactionMapper, CategoryMapper categoryMapper) {
this.transactionMapper = transactionMapper;
this.categoryMapper = categoryMapper;
}
/**
* {@inheritDoc}
*
* 支持按类型、分类、时间范围进行条件查询,结果按交易时间降序排列,
* 同时批量关联分类信息以填充分类名称和图标。
*/
@Override
public PageResult<TransactionVO> page(Long userId, TransactionQuery query) {
LambdaQueryWrapper<Transaction> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(Transaction::getUserId, userId);
// 按交易类型筛选(多选)
if (query.getTypes() != null && !query.getTypes().isEmpty()) {
wrapper.in(Transaction::getType, query.getTypes());
}
// 按分类ID筛选(多选)
if (query.getCategoryIds() != null && !query.getCategoryIds().isEmpty()) {
wrapper.in(Transaction::getCategoryId, query.getCategoryIds());
}
// 按起始时间筛选
if (StringUtils.hasText(query.getStartTime())) {
wrapper.ge(Transaction::getTransactionTime,
LocalDateTime.parse(query.getStartTime(), DateTimeFormatter.ISO_LOCAL_DATE_TIME));
}
// 按结束时间筛选
if (StringUtils.hasText(query.getEndTime())) {
wrapper.le(Transaction::getTransactionTime,
LocalDateTime.parse(query.getEndTime(), DateTimeFormatter.ISO_LOCAL_DATE_TIME));
}
// 按支付方式筛选(多选)
if (query.getPaymentMethods() != null && !query.getPaymentMethods().isEmpty()) {
wrapper.in(Transaction::getPaymentMethod, query.getPaymentMethods());
}
// 按是否计入统计筛选
if (query.getIncludeInStats() != null) {
wrapper.eq(Transaction::getIncludeInStats, query.getIncludeInStats());
}
// 按交易时间降序排列
wrapper.orderByDesc(Transaction::getTransactionTime);
Page<Transaction> page = new Page<>(query.getPage(), query.getSize());
Page<Transaction> result = transactionMapper.selectPage(page, wrapper);
// 批量查询关联的分类信息,避免 N+1 查询问题
List<Long> categoryIds = result.getRecords().stream()
.map(Transaction::getCategoryId)
.distinct()
.collect(Collectors.toList());
Map<Long, Category> categoryMap = Map.of();
if (!categoryIds.isEmpty()) {
List<Category> categories = categoryMapper.selectBatchIds(categoryIds);
categoryMap = categories.stream().collect(Collectors.toMap(Category::getId, c -> c));
}
// 将交易记录转换为视图对象,并填充分类名称和图标
Map<Long, Category> finalCategoryMap = categoryMap;
List<TransactionVO> voList = result.getRecords().stream()
.map(t -> toVO(t, finalCategoryMap.get(t.getCategoryId())))
.collect(Collectors.toList());
return new PageResult<>(voList, result.getTotal(), result.getCurrent(), result.getSize());
}
/**
* {@inheritDoc}
*
* 创建交易记录前需校验分类是否存在且归属当前用户。
*/
@Override
@Transactional
public void create(Long userId, TransactionRequest request) {
// 校验分类是否存在且属于当前用户
Category category = categoryMapper.selectById(request.getCategoryId());
if (category == null || !category.getUserId().equals(userId)) {
throw new BusinessException("分类不存在");
}
Transaction transaction = new Transaction();
transaction.setUserId(userId);
transaction.setCategoryId(request.getCategoryId());
transaction.setType(request.getType());
transaction.setAmount(request.getAmount());
transaction.setPaymentMethod(request.getPaymentMethod());
transaction.setRemark(request.getRemark());
transaction.setTransactionTime(request.getTransactionTime());
transaction.setIncludeInStats(request.getIncludeInStats() == null ? Boolean.TRUE : request.getIncludeInStats());
transactionMapper.insert(transaction);
}
/**
* {@inheritDoc}
*
* 更新交易记录前需校验记录归属和分类归属。
*/
@Override
@Transactional
public void update(Long userId, Long id, TransactionRequest request) {
// 校验交易记录是否存在且属于当前用户
Transaction transaction = transactionMapper.selectById(id);
if (transaction == null || !transaction.getUserId().equals(userId)) {
throw new BusinessException("记录不存在");
}
// 校验分类是否存在且属于当前用户
Category category = categoryMapper.selectById(request.getCategoryId());
if (category == null || !category.getUserId().equals(userId)) {
throw new BusinessException("分类不存在");
}
transaction.setCategoryId(request.getCategoryId());
transaction.setType(request.getType());
transaction.setAmount(request.getAmount());
transaction.setPaymentMethod(request.getPaymentMethod());
transaction.setRemark(request.getRemark());
transaction.setTransactionTime(request.getTransactionTime());
transaction.setIncludeInStats(request.getIncludeInStats() == null ? Boolean.TRUE : request.getIncludeInStats());
transactionMapper.updateById(transaction);
}
/**
* {@inheritDoc}
*
* 删除交易记录前需校验记录归属当前用户。
*/
@Override
@Transactional
public void delete(Long userId, Long id) {
Transaction transaction = transactionMapper.selectById(id);
if (transaction == null || !transaction.getUserId().equals(userId)) {
throw new BusinessException("记录不存在");
}
transactionMapper.deleteById(id);
}
/**
* {@inheritDoc}
*
* 切换单条交易记录的"计入统计"标记,用于表格中直接开关。
*/
@Override
@Transactional
public void toggleStats(Long userId, Long id, Boolean includeInStats) {
Transaction transaction = transactionMapper.selectById(id);
if (transaction == null || !transaction.getUserId().equals(userId)) {
throw new BusinessException("记录不存在");
}
transaction.setIncludeInStats(includeInStats == null ? Boolean.TRUE : includeInStats);
transactionMapper.updateById(transaction);
}
/**
* 将交易实体和关联分类转换为视图对象
*
* @param t 交易实体
* @param category 关联的分类实体(可为 null)
* @return 交易视图对象
*/
private TransactionVO toVO(Transaction t, Category category) {
TransactionVO vo = new TransactionVO();
vo.setId(t.getId());
vo.setCategoryId(t.getCategoryId());
vo.setCategoryName(category != null ? category.getName() : "");
vo.setCategoryIcon(category != null ? category.getIcon() : "");
vo.setType(t.getType());
vo.setAmount(t.getAmount());
vo.setPaymentMethod(t.getPaymentMethod());
vo.setRemark(t.getRemark());
vo.setTransactionTime(t.getTransactionTime());
vo.setIncludeInStats(t.getIncludeInStats() == null ? Boolean.TRUE : t.getIncludeInStats());
vo.setCreatedAt(t.getCreatedAt());
return vo;
}
/**
* {@inheritDoc}
*
* 根据查询条件导出交易记录,不分页,返回所有匹配的记录。
*/
@Override
public List<TransactionExportVO> export(Long userId, TransactionQuery query) {
LambdaQueryWrapper<Transaction> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(Transaction::getUserId, userId);
// 按交易类型筛选(多选)
if (query.getTypes() != null && !query.getTypes().isEmpty()) {
wrapper.in(Transaction::getType, query.getTypes());
}
// 按分类ID筛选(多选)
if (query.getCategoryIds() != null && !query.getCategoryIds().isEmpty()) {
wrapper.in(Transaction::getCategoryId, query.getCategoryIds());
}
// 按起始时间筛选
if (StringUtils.hasText(query.getStartTime())) {
wrapper.ge(Transaction::getTransactionTime,
LocalDateTime.parse(query.getStartTime(), DateTimeFormatter.ISO_LOCAL_DATE_TIME));
}
// 按结束时间筛选
if (StringUtils.hasText(query.getEndTime())) {
wrapper.le(Transaction::getTransactionTime,
LocalDateTime.parse(query.getEndTime(), DateTimeFormatter.ISO_LOCAL_DATE_TIME));
}
// 按支付方式筛选(多选)
if (query.getPaymentMethods() != null && !query.getPaymentMethods().isEmpty()) {
wrapper.in(Transaction::getPaymentMethod, query.getPaymentMethods());
}
// 按是否计入统计筛选
if (query.getIncludeInStats() != null) {
wrapper.eq(Transaction::getIncludeInStats, query.getIncludeInStats());
}
// 按交易时间降序排列
wrapper.orderByDesc(Transaction::getTransactionTime);
List<Transaction> transactions = transactionMapper.selectList(wrapper);
// 批量查询关联的分类信息
List<Long> categoryIds = transactions.stream()
.map(Transaction::getCategoryId)
.distinct()
.collect(Collectors.toList());
Map<Long, Category> categoryMap = Map.of();
if (!categoryIds.isEmpty()) {
List<Category> categories = categoryMapper.selectBatchIds(categoryIds);
categoryMap = categories.stream().collect(Collectors.toMap(Category::getId, c -> c));
}
// 转换为导出视图对象
Map<Long, Category> finalCategoryMap = categoryMap;
return transactions.stream()
.map(t -> toExportVO(t, finalCategoryMap.get(t.getCategoryId())))
.collect(Collectors.toList());
}
/**
* 将交易实体和关联分类转换为导出视图对象
*
* @param t 交易实体
* @param category 关联的分类实体(可为 null)
* @return 交易导出视图对象
*/
private TransactionExportVO toExportVO(Transaction t, Category category) {
TransactionExportVO vo = new TransactionExportVO();
Boolean include = t.getIncludeInStats() == null ? Boolean.TRUE : t.getIncludeInStats();
if (include) {
vo.setType(t.getType().equals("INCOME") ? "收入" : "支出");
} else {
vo.setType("不计入统计");
}
vo.setCategoryName(category != null ? category.getName() : "");
vo.setAmount(String.format("%.2f", t.getAmount()));
vo.setPaymentMethod(getPaymentMethodLabel(t.getPaymentMethod()));
vo.setRemark(t.getRemark() != null ? t.getRemark() : "");
vo.setTransactionTime(t.getTransactionTime() != null
? t.getTransactionTime().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"))
: "");
vo.setIncludeInStats(include ? "是" : "否");
return vo;
}
/**
* 将支付方式编码转换为中文标签
*
* @param code 支付方式编码
* @return 中文标签
*/
private String getPaymentMethodLabel(String code) {
if (code == null) return "";
return switch (code) {
case "WECHAT" -> "微信";
case "ALIPAY" -> "支付宝";
case "CASH" -> "现金";
case "BANK" -> "银行卡";
default -> code;
};
}
}
@@ -0,0 +1,114 @@
package com.example.finance.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.example.finance.common.BusinessException;
import com.example.finance.dto.LoginRequest;
import com.example.finance.dto.RegisterRequest;
import com.example.finance.entity.User;
import com.example.finance.mapper.UserMapper;
import com.example.finance.service.UserService;
import com.example.finance.util.JwtUtil;
import com.example.finance.vo.LoginVO;
import com.example.finance.vo.UserVO;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
/**
* 用户服务实现类,处理用户登录认证、注册和信息查询等业务逻辑。
*/
@Service
public class UserServiceImpl implements UserService {
private final UserMapper userMapper;
private final JwtUtil jwtUtil;
private final BCryptPasswordEncoder passwordEncoder = new BCryptPasswordEncoder();
public UserServiceImpl(UserMapper userMapper, JwtUtil jwtUtil) {
this.userMapper = userMapper;
this.jwtUtil = jwtUtil;
}
/**
* {@inheritDoc}
*
* 根据用户名查询用户,校验密码后生成 JWT Token 返回。
*/
@Override
public LoginVO login(LoginRequest request) {
// 根据用户名查询用户
User user = userMapper.selectOne(new LambdaQueryWrapper<User>()
.eq(User::getUsername, request.getUsername()));
if (user == null) {
throw new BusinessException("用户名或密码错误");
}
// 校验密码是否匹配
if (!passwordEncoder.matches(request.getPassword(), user.getPasswordHash())) {
throw new BusinessException("用户名或密码错误");
}
// 生成 JWT Token
String token = jwtUtil.generateToken(user.getId(), user.getUsername());
LoginVO vo = new LoginVO();
vo.setToken(token);
vo.setUser(toUserVO(user));
return vo;
}
/**
* {@inheritDoc}
*
* 校验用户名是否已存在,不存在则创建新用户并对密码进行 BCrypt 加密。
*/
@Override
@Transactional
public void register(RegisterRequest request) {
// 检查用户名是否已被注册
User existing = userMapper.selectOne(new LambdaQueryWrapper<User>()
.eq(User::getUsername, request.getUsername()));
if (existing != null) {
throw new BusinessException("用户名已存在");
}
// 构建新用户并加密密码
User user = new User();
user.setUsername(request.getUsername());
user.setPasswordHash(passwordEncoder.encode(request.getPassword()));
// 如果未提供昵称,则使用用户名作为默认昵称
user.setNickname(request.getNickname() != null ? request.getNickname() : request.getUsername());
userMapper.insert(user);
}
/**
* {@inheritDoc}
*
* 根据用户ID查询用户信息,不存在则抛出异常。
*/
@Override
public UserVO getUserInfo(Long userId) {
User user = userMapper.selectById(userId);
if (user == null) {
throw new BusinessException("用户不存在");
}
return toUserVO(user);
}
/**
* 将用户实体转换为视图对象
*
* @param user 用户实体
* @return 用户视图对象
*/
private UserVO toUserVO(User user) {
UserVO vo = new UserVO();
vo.setId(user.getId());
vo.setUsername(user.getUsername());
vo.setNickname(user.getNickname());
vo.setCreatedAt(user.getCreatedAt());
return vo;
}
}
@@ -0,0 +1,96 @@
package com.example.finance.util;
import com.example.finance.config.JwtConfig;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.security.Keys;
import org.springframework.stereotype.Component;
import javax.crypto.SecretKey;
import java.nio.charset.StandardCharsets;
import java.util.Date;
/**
* JWT 工具类。
* <p>提供 JWT Token 的生成、解析、验证等功能。</p>
*/
@Component
public class JwtUtil {
/** HMAC 签名密钥 */
private final SecretKey key;
/** Token 过期时间(毫秒) */
private final long expiration;
/**
* 构造方法,根据配置初始化签名密钥和过期时间。
*
* @param jwtConfig JWT 配置对象
*/
public JwtUtil(JwtConfig jwtConfig) {
this.key = Keys.hmacShaKeyFor(jwtConfig.getSecret().getBytes(StandardCharsets.UTF_8));
this.expiration = jwtConfig.getExpiration();
}
/**
* 生成 JWT Token。
* <p>将用户 ID 作为 subject,用户名作为自定义 claim,并设置签发时间和过期时间。</p>
*
* @param userId 用户 ID
* @param username 用户名
* @return 生成的 JWT Token 字符串
*/
public String generateToken(Long userId, String username) {
Date now = new Date();
Date expiryDate = new Date(now.getTime() + expiration);
return Jwts.builder()
.subject(String.valueOf(userId))
.claim("username", username)
.issuedAt(now)
.expiration(expiryDate)
.signWith(key)
.compact();
}
/**
* 解析 JWT Token,提取声明信息。
*
* @param token JWT Token 字符串
* @return Token 中的声明(Claims)对象
*/
public Claims parseToken(String token) {
return Jwts.parser()
.verifyWith(key)
.build()
.parseSignedClaims(token)
.getPayload();
}
/**
* 从 Token 中提取用户 ID。
*
* @param token JWT Token 字符串
* @return 用户 ID
*/
public Long getUserId(String token) {
Claims claims = parseToken(token);
return Long.parseLong(claims.getSubject());
}
/**
* 验证 Token 是否有效。
*
* @param token JWT Token 字符串
* @return Token 有效返回 true,否则返回 false
*/
public boolean validateToken(String token) {
try {
parseToken(token);
return true;
} catch (Exception e) {
// Token 解析失败(过期、签名无效等)
return false;
}
}
}
@@ -0,0 +1,33 @@
package com.example.finance.vo;
import lombok.Data;
import java.math.BigDecimal;
/**
* 分类统计视图对象,用于展示各分类的收支占比情况
*/
@Data
public class CategoryStatisticsVO {
/** 分类ID */
private Long categoryId;
/** 分类名称 */
private String categoryName;
/** 分类图标 */
private String categoryIcon;
/** 交易类型(收入/支出) */
private String type;
/** 该分类下的总金额 */
private BigDecimal amount;
/** 该分类金额占总金额的百分比 */
private BigDecimal percentage;
/** 该分类下的交易笔数 */
private Integer count;
}
@@ -0,0 +1,30 @@
package com.example.finance.vo;
import lombok.Data;
import java.time.LocalDateTime;
/**
* 分类视图对象,用于向前端返回分类信息
*/
@Data
public class CategoryVO {
/** 分类ID */
private Long id;
/** 分类名称 */
private String name;
/** 分类类型(收入/支出) */
private String type;
/** 分类图标 */
private String icon;
/** 排序序号 */
private Integer sortOrder;
/** 创建时间 */
private LocalDateTime createdAt;
}
@@ -0,0 +1,16 @@
package com.example.finance.vo;
import lombok.Data;
/**
* 登录响应视图对象,包含认证令牌和用户基本信息
*/
@Data
public class LoginVO {
/** JWT认证令牌 */
private String token;
/** 用户信息 */
private UserVO user;
}
@@ -0,0 +1,24 @@
package com.example.finance.vo;
import lombok.Data;
import java.math.BigDecimal;
/**
* 月度统计视图对象,用于展示某月的收支汇总信息
*/
@Data
public class MonthlyStatisticsVO {
/** 当月总收入 */
private BigDecimal totalIncome;
/** 当月总支出 */
private BigDecimal totalExpense;
/** 当月结余(收入减支出) */
private BigDecimal balance;
/** 当月交易笔数 */
private Integer transactionCount;
}
@@ -0,0 +1,40 @@
package com.example.finance.vo;
import com.alibaba.excel.annotation.ExcelProperty;
import com.alibaba.excel.annotation.write.style.ColumnWidth;
import lombok.Data;
/**
* 交易记录导出视图对象,用于 Excel 导出
*/
@Data
public class TransactionExportVO {
@ExcelProperty("交易类型")
@ColumnWidth(12)
private String type;
@ExcelProperty("分类名称")
@ColumnWidth(15)
private String categoryName;
@ExcelProperty("金额")
@ColumnWidth(12)
private String amount;
@ExcelProperty("支付方式")
@ColumnWidth(12)
private String paymentMethod;
@ExcelProperty("备注")
@ColumnWidth(30)
private String remark;
@ExcelProperty("交易时间")
@ColumnWidth(20)
private String transactionTime;
@ExcelProperty("计入统计")
@ColumnWidth(10)
private String includeInStats;
}
@@ -0,0 +1,46 @@
package com.example.finance.vo;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDateTime;
/**
* 交易记录视图对象,用于向前端返回交易详情(包含关联的分类信息)
*/
@Data
public class TransactionVO {
/** 交易ID */
private Long id;
/** 分类ID */
private Long categoryId;
/** 分类名称 */
private String categoryName;
/** 分类图标 */
private String categoryIcon;
/** 交易类型(收入/支出) */
private String type;
/** 交易金额 */
private BigDecimal amount;
/** 支付方式 */
private String paymentMethod;
/** 备注 */
private String remark;
/** 交易时间 */
private LocalDateTime transactionTime;
/** 是否计入统计 */
private Boolean includeInStats;
/** 创建时间 */
private LocalDateTime createdAt;
}
@@ -0,0 +1,21 @@
package com.example.finance.vo;
import lombok.Data;
import java.math.BigDecimal;
/**
* 趋势统计视图对象,用于展示按日/月的收支趋势变化
*/
@Data
public class TrendStatisticsVO {
/** 日期(如具体某天或某月) */
private String date;
/** 当日/当月收入 */
private BigDecimal income;
/** 当日/当月支出 */
private BigDecimal expense;
}
@@ -0,0 +1,24 @@
package com.example.finance.vo;
import lombok.Data;
import java.time.LocalDateTime;
/**
* 用户视图对象,用于向前端返回用户基本信息(不包含敏感数据)
*/
@Data
public class UserVO {
/** 用户ID */
private Long id;
/** 用户名 */
private String username;
/** 昵称 */
private String nickname;
/** 创建时间 */
private LocalDateTime createdAt;
}
+39
View File
@@ -0,0 +1,39 @@
spring:
application:
name: my-app
datasource:
url: ${DB_URL}
username: ${DB_USERNAME}
password: ${DB_PASSWORD}
driver-class-name: com.mysql.cj.jdbc.Driver
mybatis-plus:
mapper-locations: classpath:mapper/*.xml
type-aliases-package: com.example.finance.entity
global-config:
db-config:
id-type: auto
configuration:
map-underscore-to-camel-case: true
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
jwt:
secret: finance-app-secret-key-2026-a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6
expiration: 86400000
springdoc:
api-docs:
enabled: true
path: /v3/api-docs
swagger-ui:
enabled: true
path: /swagger-ui.html
packages-to-scan: com.example.finance.controller
knife4j:
enable: true
setting:
language: zh_cn
server:
port: 8080
+34
View File
@@ -0,0 +1,34 @@
<?xml version="1.0" encoding="UTF-8"?>
<configuration>
<!-- 控制台输出 -->
<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern>
<charset>UTF-8</charset>
</encoder>
</appender>
<!-- 文件输出 - 按天滚动 -->
<appender name="FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
<file>logs/finance-app.log</file>
<rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
<fileNamePattern>logs/finance-app.%d{yyyy-MM-dd}.log</fileNamePattern>
<maxHistory>30</maxHistory>
</rollingPolicy>
<encoder>
<pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern>
<charset>UTF-8</charset>
</encoder>
</appender>
<!-- 应用日志级别 -->
<logger name="com.example.finance" level="INFO"/>
<logger name="org.springframework" level="INFO"/>
<logger name="org.mybatis" level="INFO"/>
<!-- 根日志级别 -->
<root level="INFO">
<appender-ref ref="CONSOLE"/>
<appender-ref ref="FILE"/>
</root>
</configuration>
+41
View File
@@ -0,0 +1,41 @@
-- 用户表:存储系统用户的基本信息和登录凭证
CREATE TABLE IF NOT EXISTS t_user (
id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '用户ID,主键自增',
username VARCHAR(50) NOT NULL UNIQUE COMMENT '用户名,唯一标识,用于登录',
password_hash VARCHAR(255) NOT NULL COMMENT '密码哈希值,使用BCrypt加密存储',
nickname VARCHAR(50) COMMENT '用户昵称,显示名称',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间'
);
-- 分类表:存储收支分类信息,支持用户自定义
CREATE TABLE IF NOT EXISTS t_category (
id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '分类ID,主键自增',
user_id BIGINT NOT NULL COMMENT '所属用户ID',
name VARCHAR(50) NOT NULL COMMENT '分类名称',
type VARCHAR(10) NOT NULL COMMENT '分类类型:INCOME-收入,EXPENSE-支出',
icon VARCHAR(50) COMMENT '分类图标,可选',
sort_order INT NOT NULL DEFAULT 0 COMMENT '排序序号,数值越小越靠前',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
INDEX idx_user_id (user_id) COMMENT '用户ID索引',
INDEX idx_user_type (user_id, type) COMMENT '用户ID+类型联合索引'
);
-- 收支记录表:存储每一笔收入或支出的详细信息
CREATE TABLE IF NOT EXISTS t_transaction (
id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '记录ID,主键自增',
user_id BIGINT NOT NULL COMMENT '所属用户ID',
category_id BIGINT NOT NULL COMMENT '所属分类ID',
type VARCHAR(10) NOT NULL COMMENT '记录类型:INCOME-收入,EXPENSE-支出',
amount DECIMAL(12, 2) NOT NULL COMMENT '金额,精确到分',
payment_method VARCHAR(30) COMMENT '支付方式:WECHAT-微信,ALIPAY-支付宝,CASH-现金,BANK-银行卡',
remark VARCHAR(255) COMMENT '备注说明',
transaction_time DATETIME NOT NULL COMMENT '交易发生时间',
include_in_stats TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否计入统计:1-计入,0-不计入(如借款、转账等)',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
INDEX idx_user_id (user_id) COMMENT '用户ID索引',
INDEX idx_category_id (category_id) COMMENT '分类ID索引',
INDEX idx_transaction_time (transaction_time) COMMENT '交易时间索引',
INDEX idx_user_time (user_id, transaction_time) COMMENT '用户ID+交易时间联合索引'
);
@@ -0,0 +1,15 @@
package com.example.finance;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;
/**
* 应用启动测试
*/
@SpringBootTest
class MyApplicationTests {
@Test
void contextLoads() {
}
}