[Spring Boot] Expense API 实现
项目地址:expense-api
项目简介
最近跟着视频做的一个 spring boot 的项目,包含了比较简单的记账功能的实现(只限 API 部分),具体实现的功能有:
- 记账(expenses API)
- 类型(categories API)
- 用户(登录/注册/更新/删除 API)
整体的验证是通过 JWT Token 去实现的,没有实现管理员权限。
使用了:
- java17
- spring boot 3.4.1
- json web token 0.12.6
- map struct 1.6.3
- lombok 1.18.36
- lombok map struct binding 0.2.0
- docker
运行方式可以直接跑根目录下的 start.sh
,脚本会运行 mvnw clean package
指令打包 spring boot jar 文件。然后运行对应的 docker compose 文件生成对应的容器:
bash
❯ docker ps
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
a9fd6ebf70ec expense-tracker-springboot-expense-tracker-api "java -jar /expense_..." 14 hours ago Up 14 hours 0.0.0.0:8080->8080/tcp springboot-expense-tracker-api
fc2f4f82f581 mysql:8.3.0 "docker-entrypoint.s..." 14 hours ago Up 14 hours (healthy) 0.0.0.0:3306->3306/tcp, 33060/tcp mysql-expense-tracker
然后可以通过 postman 导入存在根目录下的 Expense Manager API.postman_collection.json
进行 API 的测试:

总体来说这次是把之前断断续续学的 spring boot 3 通过做项目的方式进行了一个整合,并且简单的学习了一下 DTO 模式和基于 JWT 实现的用户验证。之前在 [spring] rest api security 中学习的是使用默认的 Spring Security 进行用户验证,这次使用了 CustomUserDetailsService
,可以使用更加灵活的数据库结构。
项目的数据关系如下:

⚠️:task
是做的 demo 对象,和实际的项目没什么关系
基础结构
简单的梳理一下项目中用的各种项目结构和模式
MVC 结构
MVC 是一个非常传统的数据结构了,具体作用如下:
-
Model,数据和业务逻辑层
model 层负责和数据库的交互,对数据的业务处理、数据的验证、数据的操作之类数据相关的部分
-
View,即 UI 部分
前后端分离的话这部分选择很多,不仅仅单指网页端
比如说手机 app、电脑 app,需要联网操作和 API 进行数据交互的,都是 view 层
以网页来说,现在最流行的就是 React/View/Angular,如果前后端不分离的话,目前最流行的应该是 thymeleaf
-
Controller,负责 Model 和 View 层的交互
controller 主要会接受请求,并且返回 response
这个项目里实现的是 Model 和 Controller 部分的内容。controller 部分的代码比较直接简单,以 category
为例:
java
@RestController
@RequestMapping("/categories")
@RequiredArgsConstructor
public class CategoryController {
private final CategoryService categoryService;
private final CategoryMapper categoryMapper;
@ResponseStatus(HttpStatus.CREATED)
@PostMapping
public CategoryResponse createCategory(@RequestBody CategoryRequest categoryRequest) {
CategoryDTO categoryDTO = categoryMapper.mapToCategoryDTO(categoryRequest);
categoryDTO = categoryService.saveCategory(categoryDTO);
return categoryMapper.mapToCategoryResponse(categoryDTO);
}
@GetMapping
public List<CategoryResponse> readCategories() {
List<CategoryDTO> list = categoryService.getAllCategories();
return list.stream().map(categoryMapper::mapToCategoryResponse).collect(Collectors.toList());
}
@ResponseStatus(HttpStatus.NO_CONTENT)
@DeleteMapping("/{categoryId}")
public void deleteCategory(@PathVariable String categoryId) {
categoryService.deleteCategory(categoryId);
}
}
其中 @PostMapping
, @DeleteMapping
分别对应的是 HTTP 请求中的 method,即 CRUD 的操作。具体的操作则是通过调用 service 层中对应的方法去实现,controller 并不在乎。最终将 service 层中返回的数据,并通过 DTO 进行 mapping,作为 response 返回给用户。
而在比较新的 spring boot 项目中,Model 层的耦合度较高,因此也会使用其他的不同模式进行实现,这个项目中使用的就是 entity+service+repository 的实现去解决这个问题
entity
entity 表现了数据结构
这个数据结构即使 Java 数据的结构,也是数据库中的对应结构,如:
java
@Entity
@Table(name = "tbl_categories")
@Data
@AllArgsConstructor
@NoArgsConstructor
@Builder
public class CategoryEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "category_id", unique = true)
private String categoryId;
@Column(unique = true)
@NotBlank(message = "Category name must not be empty.")
@Size(min = 3, message = "Category name must be at least 3 characters.")
private String name;
private String description;
@Column(name = "category_icon")
private String categoryIcon;
@Column(name = "created_at", nullable = false, updatable = false)
@CreationTimestamp
private Timestamp createdAt;
@Column(name = "updated_at")
@UpdateTimestamp
private Timestamp updatedAt;
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "user_id", nullable = false)
@OnDelete(action = OnDeleteAction.CASCADE)
private User user;
}
entity 完成了 POJO 与数据库的 table 数据的映射
除此之外,需要注意的几个点有:
-
entity 本身不应该包含任何的业务逻辑
-
entity 最好不要直接暴露给 API
考虑到
user
作为使用情况,用户在登录/注册的情况下可以选择使用用户名+密码的搭配,但是在获取用户信息的时候显然是不需要获得这个信息的,因此密码这个信息就不应该包含在在 json 中传给调用 API 的用户的确可以使用
@JsonIgnore
将密码从 parse json 中这个过程中去除掉,这个的问题是,在使用register
和login
这样的 endpoint 也会把密码去掉,那么登陆的功能也就无法实现现在一个比较流行的模式是使用 DTO 去实现
Service 层模式
service 层主要负责负责业务逻辑的处理,包括但不限于:
-
将获取的 DTO 转换成对应的 entity
这也包含一些关联数据的映射,以
expense
为例,它是有一个对user
的关联的。因此在 service 层时,就可以讲关联数据进行映射 -
数据验证及转换
数据验证简单的可以通过
@Entity
去实现,稍微复杂的还是需要手动验证,比如根据不同的市场判断最大最小值、时区的转换,将数据转成 big decimal 进行存储------我们项目里也有一个类似的逻辑,目前对于数据的精度和范围要求比较高,直接使用 JavaScript 的整数类型会造成 overflow,所以最后采取了 string 转换+使用 decimal.js 进行计算的方法去解决。这时候后端就需要将前端传来的字符串转化成 big decimal 进行存储 -
处理异常
这个就不多赘述了
service 的实现大体如下:
java
@Service
@RequiredArgsConstructor
public class ExpenseServiceImpl implements ExpenseService {
private final ExpenseRepository expenseRepo;
private final UserService userService;
private final CategoryRepository categoryRepository;
private final ExpenseMapper expenseMapper;
@Override
public List<ExpenseDTO> getAllExpenses(Pageable page) {
List<ExpenseEntity> expenseList = expenseRepo.findByUserId(userService.getLoggedInUser().getId(), page).toList();
return expenseList.stream().map(expenseMapper::mapToExpenseDTO).collect(Collectors.toList());
}
@Override
public ExpenseDTO getExpenseById(String expenseId) {
ExpenseEntity existingExpense = getExpenseEntity(expenseId);
return expenseMapper.mapToExpenseDTO(existingExpense);
}
private ExpenseEntity getExpenseEntity(String expenseId) {
Optional<ExpenseEntity> expense = expenseRepo.findByUserIdAndExpenseId(userService.getLoggedInUser().getId(), expenseId);
if (expense.isEmpty()) {
throw new ResourceNotFoundException("Expense is not found for the id " + expenseId);
}
return expense.get();
}
@Override
public void deleteExpenseById(String expenseId) {
ExpenseEntity expense = getExpenseEntity(expenseId);
expenseRepo.delete(expense);
}
@Override
public ExpenseDTO saveExpenseDetails(ExpenseDTO expenseDTO) {
// check the existence of category
Optional<CategoryEntity> optionalCategory = categoryRepository.findByUserIdAndCategoryId(userService.getLoggedInUser()
.getId(), expenseDTO.getCategoryId());
if (optionalCategory.isEmpty()) {
throw new ResourceNotFoundException("Category not found for the id " + expenseDTO.getCategoryId());
}
expenseDTO.setExpenseId(UUID.randomUUID().toString());
// map to entity object
ExpenseEntity newExpense = expenseMapper.mapToExpenseEntity(expenseDTO);
newExpense.setCategory(optionalCategory.get());
newExpense.setUser(userService.getLoggedInUser());
newExpense = expenseRepo.save(newExpense);
return expenseMapper.mapToExpenseDTO(newExpense);
}
@Override
public ExpenseDTO updateExpenseDetails(String expenseId, ExpenseDTO expenseDTO) {
ExpenseEntity existingExpense = getExpenseEntity(expenseId);
if (expenseDTO.getCategoryId() != null) {
String categoryId = expenseDTO.getCategoryId();
Optional<CategoryEntity> optionalCategory = categoryRepository.findByUserIdAndCategoryId(userService.getLoggedInUser()
.getId(), categoryId);
if (optionalCategory.isEmpty()) {
throw new ResourceNotFoundException("Category not found for the id" + categoryId);
}
existingExpense.setCategory(optionalCategory.get());
}
Optional.ofNullable(expenseDTO.getName()).ifPresent(existingExpense::setName);
Optional.ofNullable(expenseDTO.getDescription()).ifPresent(existingExpense::setDescription);
Optional.ofNullable(expenseDTO.getAmount()).ifPresent(existingExpense::setAmount);
Optional.ofNullable(expenseDTO.getDate()).ifPresent(existingExpense::setDate);
existingExpense = expenseRepo.save(existingExpense);
return expenseMapper.mapToExpenseDTO(existingExpense);
}
@Override
public List<ExpenseDTO> readByCategory(String category, Pageable page) {
Optional<CategoryEntity> optionalCategory = categoryRepository.findByNameAndUserId(category, userService.getLoggedInUser()
.getId());
if (optionalCategory.isEmpty()) {
throw new ResourceNotFoundException("Category not found for the name " + category);
}
return expenseRepo.findByUserIdAndCategoryId(userService.getLoggedInUser().getId(), optionalCategory.get()
.getId(), page).toList().stream().map(expenseMapper::mapToExpenseDTO).collect(Collectors.toList());
}
@Override
public List<ExpenseDTO> readByName(String name, Pageable page) {
List <ExpenseEntity> list = expenseRepo.findByUserIdAndNameContaining(userService.getLoggedInUser().getId(), name, page).toList();
return list.stream().map(expenseMapper::mapToExpenseDTO).collect(Collectors.toList());
}
@Override
public List<ExpenseDTO> readByDate(Date startDate, Date endDate, Pageable page) {
if (startDate == null) {
startDate = new Date(0);
}
if (endDate == null) {
endDate = new Date(System.currentTimeMillis());
}
return expenseRepo.findByUserIdAndDateBetween(userService.getLoggedInUser().getId(), startDate, endDate, page)
.toList().stream().map(expenseMapper::mapToExpenseDTO).collect(Collectors.toList());
}
}
大多数情况下 service 在实现的时候会采取新建一个 interface 定义大多数需要的方法,然后再实现 impl,主要的原因也是因为一个 service 可以有不同的实现,可以根据具体的业务调用对应的实现------让 spring 自己去判断调用合适的实现
Repository 模式
repository 则主要负责对数据库进行管理、交流
大多数情况下使用默认的方法就够了,偶尔也会需要重写一下 query
实现大体如下:
java
// for more details: https://docs.spring.io/spring-data/jpa/reference/jpa/query-methods.html
@Repository
public interface ExpenseRepository extends JpaRepository<ExpenseEntity, Long> {
// // SELECT * FROM tbl_expenses WHERE category=?
// Page<Expense> findByCategory(String category, Pageable page);
// // SELECT * FROM tbl_expenses WHERE name LIKE '%keyword%'
// Page<Expense> findByNameContaining(String keyword, Pageable page);
// // SELECT * FROM tbl_expenses WHERE date BETWEEN 'startDate AND 'endDate'
// Page<Expense> findByDateBetween(Date startDate, Date endDate, Pageable page);
// SELECT * FROM tbl_expenses WHERE user_id=? AND category=?
Page<ExpenseEntity> findByUserIdAndCategory(Long userId, String category, Pageable page);
Page<ExpenseEntity> findByUserIdAndCategoryId(Long userId, Long categoryId, Pageable page);
// SELECT * FROM tbl_expenses WHERE user_id=? AND name LIKE '%keyword%'
Page<ExpenseEntity> findByUserIdAndNameContaining(Long userId, String keyword, Pageable page);
// SELECT * FROM tbl_expenses WHERE user_id=? AND date BETWEEN 'startDate AND 'endDate'
Page<ExpenseEntity> findByUserIdAndDateBetween(Long userId, Date startDate, Date endDate, Pageable page);
// SELECT * FROM tbl_expenses WHERE user_id=?
Page<ExpenseEntity> findByUserId(Long userId, Pageable page);
// SELECT * FROM tbl_expenses WHERE user_id=? AND id=?
Optional<ExpenseEntity> findByUserIdAndExpenseId(Long userId, String expenseId);
}
PS:需要使用 interface 去 extend 其他的 repository,这里是 JpaRepository
,不同的数据库 extend 不同的 repository,mongo 的则是 extend MongoRepository
DTO 模式
DTO 全称 Data Transfer Object,顾名思义是在不同层级中对数据进行转换,因此它在 Model 和 Controller 中用的都比较频繁
它主要的作用在 Entity 中提到了,就是为了将数据处理/转换成合适的格式
之前 DTO 用的是 Lombok 提供的 @Builder
的工厂模式实现的,官方文档下的使用方式为:
java
Person.builder()
.name("Adam Savage")
.city("San Francisco")
.job("Mythbusters")
.job("Unchained Reaction")
.build();
不过相对而言这种转换方式还是比较麻烦的,最终跟着教程熟悉了一下 mapstruct 的使用方式,具体的 mapper 实现为:
java
@Mapper(componentModel = "spring")
public interface CategoryMapper {
CategoryMapper INSTANCE = Mappers.getMapper(CategoryMapper.class);
CategoryEntity mapToCategoryEntity(CategoryDTO categoryDTO);
CategoryDTO mapToCategoryDTO(CategoryEntity categoryEntity);
@Mapping(target = "categoryIcon", source = "categoryRequest.icon")
CategoryDTO mapToCategoryDTO(CategoryRequest categoryRequest);
CategoryResponse mapToCategoryResponse(CategoryDTO categoryDTO);
}
需要注意的是,每次修改完代码需要重新 build 一下 maven 项目,这样才能够重新生成对应的 CategoryMapper
需要注意的是,这里的 target
是 CategoryDTO
,这也是转换结果中的属性。与之相对的 source
就是 CategoryRequest
用户验证
使用的是 jwt 验证方式,基于 jwt 的验证因为是无状态的,所以从 token 中获取用户名后就会添加到 userDetails 中,具体的认证是通过 signature 实现的,如下:

用户名之类的信息其实是公开的,具体验证 signature 的方法,则是需要获取 secret,通过加密/解密进行对比验证。因此对于使用 jwt token 进行验证的 app 来说,这个 secret/private key 是非常重要的
一旦 secret/private key 泄露,那么就可以通过它去获取对应的 signature,从而绕过验证
util
util 主要实现的就是 jwt 相关的部分,主要包活生成 jwt token 和验证 jwt token
java
@Component
public class JwtTokenUtil {
private static final int JWT_TOKEN_VALIDITY = 5 * 60 * 60;
@Value("${jwt.secret}")
private String secret;
private SecretKey getSignInKey() {
byte[] keyBytes = Decoders.BASE64.decode(secret);
return Keys.hmacShaKeyFor(keyBytes);
}
public String generateToken(UserDetails userDetails) {
Map<String, Object> claims = new HashMap<>();
return Jwts.builder()
.claims(claims)
.subject(userDetails.getUsername())
.issuedAt(new Date(System.currentTimeMillis()))
.expiration(new Date(System.currentTimeMillis() + JWT_TOKEN_VALIDITY * 1000))
.signWith(getSignInKey(), Jwts.SIG.HS256)
.compact();
}
public String getUsernameFromToken(String jwtToken) {
return getClaimFromToken(jwtToken, Claims::getSubject);
}
private <T> T getClaimFromToken(String token, Function<Claims, T> claimsResolver) {
if (token.startsWith("Bearer ")) {
token = token.substring(7).trim(); // Remove 'Bearer ' and trim any extra spaces
}
final Claims claims = Jwts.parser()
.verifyWith(getSignInKey())
.build()
.parseSignedClaims(token)
.getPayload();
return claimsResolver.apply(claims);
}
public boolean validateToken(String jwtToken, UserDetails userDetails) {
final String username = getUsernameFromToken(jwtToken);
return username.equals(userDetails.getUsername()) && !isTokenExpired(jwtToken);
}
private boolean isTokenExpired(String jwtToken) {
final Date expiration = getExpirationDateFromToken(jwtToken);
return expiration.before(new Date());
}
private Date getExpirationDateFromToken(String jwtToken) {
return getClaimFromToken(jwtToken, Claims::getExpiration);
}
}
授权
本项目里没有使用 spring boot 自带的 userDetails
,而是使用自定义的实现,需要注意的是,自定义的类也需要实现 UserDetailsService
,具体地说是 loadUserByUsername
这个方法:
java
@Service
public class CustomUserDetailsService implements UserDetailsService {
private final UserRepository userRepository;
public CustomUserDetailsService(UserRepository userRepository) {
this.userRepository = userRepository;
}
@Override
public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException {
System.out.println("email: " + email);
User existingUser = userRepository.findByEmail(email)
.orElseThrow(() -> new UsernameNotFoundException("User not found for the email: " + email));
return new org.springframework.security.core.userdetails.User(existingUser.getEmail(), existingUser.getPassword(), new ArrayList<>());
}
}
之后在使用原本的 userDetails
的地方用 CustomUserDetailsService
即可
需要注意的点是:
loadUserByUsername
方法实际上是通过用户名获取数据库中的用户,包括用户密码- 这个实现是基于 jwt 验证实现,用的是 stateful/session-based 验证,则会导致别人只需要知道用户名,就能够顺利通过验证
完成用户登录验证后,则需要返回一个 jwt token,具体实现在 controller 中:
java
@PostMapping("/login")
public ResponseEntity<JwtResponse> login(@RequestBody AuthModel authModel) throws Exception {
authenticate(authModel.getEmail(), authModel.getPassword());
// used in stateful authentication
// SecurityContextHolder.getContext().setAuthentication(authentication);
// generate the jwt token
final UserDetails userDetails = userDetailsService.loadUserByUsername(authModel.getEmail());
final String token = jwtTokenUtil.generateToken(userDetails);
return new ResponseEntity<>(new JwtResponse(token), HttpStatus.OK);
}
验证
验证部分使用的是 jwt,具体的实现通过以下两个部分:
-
实现 jwt token 的 filter
这一步负责:
-
从 header 中获取 jwt token,并且对其进行数据处理------移除
Bearer
这个前缀 -
从获取的 jwt token 中获取用户名和过期时间(expiration date),如果用户名与当前
userDetails
中获取的用户名不符,那么用户便授权失败;如果用户当前登陆时间已过期,那么用户验证失败
代码实现如下:
javapublic class JwtRequestFilter extends OncePerRequestFilter { @Autowired private JwtTokenUtil jwtTokenUtil; @Autowired private CustomUserDetailsService userDetailsService; @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { final String requestTokenHeader = request.getHeader("Authorization"); String jwtToken = null; String username = null; if (requestTokenHeader != null && requestTokenHeader.startsWith("Bearer ")) { jwtToken = requestTokenHeader.substring(7); try { username = jwtTokenUtil.getUsernameFromToken(jwtToken); } catch (IllegalArgumentException e) { throw new RuntimeException("Unable to get JWT Token."); } catch (ExpiredJwtException e) { throw new RuntimeException("Jwt token has expired."); } } if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) { UserDetails userDetails = userDetailsService.loadUserByUsername(username); if (jwtTokenUtil.validateToken(jwtToken, userDetails)) { UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities()); authToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); SecurityContextHolder.getContext().setAuthentication(authToken); } } filterChain.doFilter(request, response); } }
-
-
添加 jwt filter
这一步是在 Security Config 中添加,主要添加在
SecurityFilterChain
中,具体代码如下:java@Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http.csrf(AbstractHttpConfigurer::disable) .authorizeHttpRequests(authorizationManagerRequestMatcherRegistry -> authorizationManagerRequestMatcherRegistry .requestMatchers("/login/**", "/register/**").permitAll() .anyRequest().authenticated()) // ---- 注意这里 ---- .sessionManagement(httpSecuritySessionManagementConfigurer -> httpSecuritySessionManagementConfigurer.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) // ---- 注意这里 ---- .addFilterBefore(authenticationJwtTokenFilter(), UsernamePasswordAuthenticationFilter.class) .httpBasic(Customizer.withDefaults()); return http.build(); }