调整结构
This commit is contained in:
@@ -0,0 +1,17 @@
|
||||
package com.example.training.common;
|
||||
|
||||
/**
|
||||
* 统一接口响应结构。
|
||||
*
|
||||
* @param <T> 响应数据类型
|
||||
*/
|
||||
public record ApiResponse<T>(int code, String message, T data) {
|
||||
|
||||
public static <T> ApiResponse<T> success(T data) {
|
||||
return new ApiResponse<>(200, "success", data);
|
||||
}
|
||||
|
||||
public static <T> ApiResponse<T> failure(int code, String message) {
|
||||
return new ApiResponse<>(code, message, null);
|
||||
}
|
||||
}
|
||||
@@ -15,4 +15,5 @@ public class MybatisPlusConfig {
|
||||
interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL));
|
||||
return interceptor;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
package com.example.training.config;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.UUID;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
import com.example.training.util.RequestIdContext;
|
||||
import jakarta.servlet.FilterChain;
|
||||
import jakarta.servlet.ServletException;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import org.springframework.web.filter.OncePerRequestFilter;
|
||||
|
||||
public class RequestIdFilter extends OncePerRequestFilter {
|
||||
|
||||
private static final Pattern SAFE_REQUEST_ID = Pattern.compile("[A-Za-z0-9._-]{1,64}");
|
||||
|
||||
@Override
|
||||
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
|
||||
FilterChain filterChain) throws ServletException, IOException {
|
||||
String requestId = request.getHeader(RequestIdContext.HEADER_NAME);
|
||||
if (requestId == null || !SAFE_REQUEST_ID.matcher(requestId).matches()) {
|
||||
requestId = UUID.randomUUID().toString();
|
||||
}
|
||||
RequestIdContext.set(requestId);
|
||||
response.setHeader(RequestIdContext.HEADER_NAME, requestId);
|
||||
try {
|
||||
filterChain.doFilter(request, response);
|
||||
} finally {
|
||||
RequestIdContext.clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package com.example.training.config;
|
||||
|
||||
import org.springframework.boot.web.servlet.FilterRegistrationBean;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
@Configuration
|
||||
public class WebConfig {
|
||||
|
||||
@Bean
|
||||
public FilterRegistrationBean<RequestIdFilter> requestIdFilter() {
|
||||
FilterRegistrationBean<RequestIdFilter> registration = new FilterRegistrationBean<>();
|
||||
registration.setFilter(new RequestIdFilter());
|
||||
registration.addUrlPatterns("/*");
|
||||
registration.setOrder(-100);
|
||||
return registration;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package com.example.training.constant;
|
||||
|
||||
public enum ErrorCode {
|
||||
|
||||
VALIDATION_FAILED(400001, "请求参数校验失败"),
|
||||
USER_NOT_FOUND(404001, "用户不存在"),
|
||||
DUPLICATE_EMAIL(409001, "该邮箱已被使用,请更换后重试"),
|
||||
INTERNAL_ERROR(500001, "服务暂时不可用,请稍后重试");
|
||||
|
||||
private final int code;
|
||||
private final String message;
|
||||
|
||||
ErrorCode(int code, String message) {
|
||||
this.code = code;
|
||||
this.message = message;
|
||||
}
|
||||
|
||||
public int getCode() {
|
||||
return code;
|
||||
}
|
||||
|
||||
public String getMessage() {
|
||||
return message;
|
||||
}
|
||||
}
|
||||
@@ -3,17 +3,18 @@ package com.example.training.controller;
|
||||
import java.time.OffsetDateTime;
|
||||
import java.time.ZoneOffset;
|
||||
|
||||
import com.example.training.common.ApiResponse;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/health")
|
||||
@RequestMapping("/api/v1/health")
|
||||
public class HealthController {
|
||||
|
||||
@GetMapping
|
||||
public HealthResponse health() {
|
||||
return new HealthResponse("UP", "training-backend", OffsetDateTime.now(ZoneOffset.UTC));
|
||||
public ApiResponse<HealthResponse> health() {
|
||||
return ApiResponse.success(new HealthResponse("UP", "training-backend", OffsetDateTime.now(ZoneOffset.UTC)));
|
||||
}
|
||||
|
||||
public record HealthResponse(String status, String service, OffsetDateTime timestamp) {
|
||||
|
||||
@@ -1,22 +1,22 @@
|
||||
package com.example.training.controller;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import com.example.training.domain.User;
|
||||
import com.example.training.common.ApiResponse;
|
||||
import com.example.training.constant.ErrorCode;
|
||||
import com.example.training.domain.dto.BulkStatusRequest;
|
||||
import com.example.training.domain.dto.UserRequest;
|
||||
import com.example.training.domain.entity.User;
|
||||
import com.example.training.domain.vo.BulkStatusVo;
|
||||
import com.example.training.domain.vo.UserPageVo;
|
||||
import com.example.training.domain.vo.UserSummaryVo;
|
||||
import com.example.training.domain.vo.UserVo;
|
||||
import com.example.training.service.UserService;
|
||||
import jakarta.validation.Valid;
|
||||
import jakarta.validation.constraints.Email;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.NotEmpty;
|
||||
import jakarta.validation.constraints.Pattern;
|
||||
import jakarta.validation.constraints.Size;
|
||||
import org.springframework.dao.DataIntegrityViolationException;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||
import org.springframework.web.bind.annotation.ExceptionHandler;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PatchMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
@@ -29,7 +29,7 @@ import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/users")
|
||||
@RequestMapping("/api/v1/users")
|
||||
public class UserController {
|
||||
|
||||
private final UserService userService;
|
||||
@@ -39,91 +39,55 @@ public class UserController {
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
public UserPage list(
|
||||
public ApiResponse<UserPageVo> list(
|
||||
@RequestParam(defaultValue = "") String keyword,
|
||||
@RequestParam(defaultValue = "") String role,
|
||||
@RequestParam(defaultValue = "") String status,
|
||||
@RequestParam(defaultValue = "0") int page,
|
||||
@RequestParam(defaultValue = "50") int size) {
|
||||
int safePage = Math.max(page, 0);
|
||||
int safeSize = Math.min(Math.max(size, 1), 100);
|
||||
Page<User> resultPage = userService.pageUsers(keyword, role, status, safePage, safeSize);
|
||||
return new UserPage(resultPage.getRecords().stream().map(UserResponse::from).toList(),
|
||||
resultPage.getTotal(), (int) resultPage.getCurrent() - 1, (int) resultPage.getSize());
|
||||
@RequestParam(defaultValue = "1") int page,
|
||||
@RequestParam(defaultValue = "50") int pageSize) {
|
||||
int safePage = Math.max(page, 1);
|
||||
int safePageSize = Math.min(Math.max(pageSize, 1), 100);
|
||||
Page<User> resultPage = userService.pageUsers(keyword, role, status, safePage, safePageSize);
|
||||
UserPageVo data = new UserPageVo(resultPage.getRecords().stream().map(UserVo::from).toList(),
|
||||
resultPage.getTotal(), safePage, safePageSize);
|
||||
return ApiResponse.success(data);
|
||||
}
|
||||
|
||||
@GetMapping("/summary")
|
||||
public UserSummary summary() {
|
||||
public ApiResponse<UserSummaryVo> summary() {
|
||||
UserService.UserSummary summary = userService.summarizeUsers();
|
||||
return new UserSummary(summary.total(), summary.active(), summary.inactive(), summary.admins());
|
||||
return ApiResponse.success(new UserSummaryVo(summary.total(), summary.active(),
|
||||
summary.inactive(), summary.admins()));
|
||||
}
|
||||
|
||||
@PatchMapping("/status")
|
||||
public BulkStatusResponse updateStatuses(@Valid @RequestBody BulkStatusRequest request) {
|
||||
public ApiResponse<BulkStatusVo> updateStatuses(@Valid @RequestBody BulkStatusRequest request) {
|
||||
int updated = userService.updateStatuses(request.ids(), request.status());
|
||||
return new BulkStatusResponse(updated, request.status());
|
||||
return ApiResponse.success(new BulkStatusVo(updated, request.status()));
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
public ResponseEntity<UserResponse> create(@Valid @RequestBody UserRequest request) {
|
||||
public ResponseEntity<ApiResponse<UserVo>> create(@Valid @RequestBody UserRequest request) {
|
||||
User user = userService.createUser(request.fullName(), request.email(),
|
||||
request.role(), request.status());
|
||||
return ResponseEntity.status(HttpStatus.CREATED).body(UserResponse.from(user));
|
||||
return ResponseEntity.status(HttpStatus.CREATED).body(ApiResponse.success(UserVo.from(user)));
|
||||
}
|
||||
|
||||
@PutMapping("/{id}")
|
||||
public UserResponse update(@PathVariable Long id, @Valid @RequestBody UserRequest request) {
|
||||
public ApiResponse<UserVo> update(@PathVariable Long id, @Valid @RequestBody UserRequest request) {
|
||||
User user = userService.updateUser(id, request.fullName(), request.email(),
|
||||
request.role(), request.status());
|
||||
if (user == null) {
|
||||
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "用户不存在");
|
||||
throw new ResponseStatusException(HttpStatus.NOT_FOUND, ErrorCode.USER_NOT_FOUND.getMessage());
|
||||
}
|
||||
return UserResponse.from(user);
|
||||
return ApiResponse.success(UserVo.from(user));
|
||||
}
|
||||
|
||||
@DeleteMapping("/{id}")
|
||||
public ResponseEntity<Void> delete(@PathVariable Long id) {
|
||||
public ApiResponse<Void> delete(@PathVariable Long id) {
|
||||
if (!userService.deleteUser(id)) {
|
||||
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "用户不存在");
|
||||
throw new ResponseStatusException(HttpStatus.NOT_FOUND, ErrorCode.USER_NOT_FOUND.getMessage());
|
||||
}
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
|
||||
@ExceptionHandler(DataIntegrityViolationException.class)
|
||||
ResponseEntity<ApiError> handleDuplicateEmail() {
|
||||
return ResponseEntity.status(HttpStatus.CONFLICT)
|
||||
.body(new ApiError("该邮箱已被使用,请更换后重试"));
|
||||
}
|
||||
|
||||
public record UserRequest(
|
||||
@NotBlank @Size(max = 80) String fullName,
|
||||
@NotBlank @Email @Size(max = 160) String email,
|
||||
@NotBlank @Size(max = 40) String role,
|
||||
@NotBlank @Size(max = 20) String status) {
|
||||
}
|
||||
|
||||
public record BulkStatusRequest(
|
||||
@NotEmpty @Size(max = 100) List<Long> ids,
|
||||
@NotBlank @Pattern(regexp = "ACTIVE|INACTIVE") String status) {
|
||||
}
|
||||
|
||||
public record UserPage(List<UserResponse> items, long total, int page, int size) {
|
||||
}
|
||||
|
||||
public record UserSummary(long total, long active, long inactive, long admins) {
|
||||
}
|
||||
|
||||
public record BulkStatusResponse(int updated, String status) {
|
||||
}
|
||||
|
||||
public record UserResponse(Long id, String fullName, String email, String role,
|
||||
String status, LocalDateTime createdAt, LocalDateTime updatedAt) {
|
||||
static UserResponse from(User user) {
|
||||
return new UserResponse(user.getId(), user.getFullName(), user.getEmail(),
|
||||
user.getRole(), user.getStatus(), user.getCreatedAt(), user.getUpdatedAt());
|
||||
}
|
||||
}
|
||||
|
||||
public record ApiError(String message) {
|
||||
return ApiResponse.success(null);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,57 +1,16 @@
|
||||
package com.example.training.domain;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableField;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
|
||||
@TableName("users")
|
||||
public class User {
|
||||
|
||||
@TableId(value = "id", type = IdType.AUTO)
|
||||
private Long id;
|
||||
|
||||
@TableField("full_name")
|
||||
private String fullName;
|
||||
|
||||
private String email;
|
||||
private String role;
|
||||
private String status;
|
||||
|
||||
@TableField("created_at")
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
@TableField("updated_at")
|
||||
private LocalDateTime updatedAt;
|
||||
/**
|
||||
* 兼容旧包路径的过渡类型,业务代码统一使用 domain.entity.User。
|
||||
*/
|
||||
@Deprecated
|
||||
public class User extends com.example.training.domain.entity.User {
|
||||
|
||||
public User() {
|
||||
super();
|
||||
}
|
||||
|
||||
public User(String fullName, String email, String role, String status) {
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
this.fullName = fullName;
|
||||
this.email = email;
|
||||
this.role = role;
|
||||
this.status = status;
|
||||
this.createdAt = now;
|
||||
this.updatedAt = now;
|
||||
}
|
||||
|
||||
public Long getId() { return id; }
|
||||
public String getFullName() { return fullName; }
|
||||
public String getEmail() { return email; }
|
||||
public String getRole() { return role; }
|
||||
public String getStatus() { return status; }
|
||||
public LocalDateTime getCreatedAt() { return createdAt; }
|
||||
public LocalDateTime getUpdatedAt() { return updatedAt; }
|
||||
|
||||
public void update(String fullName, String email, String role, String status) {
|
||||
this.fullName = fullName;
|
||||
this.email = email;
|
||||
this.role = role;
|
||||
this.status = status;
|
||||
this.updatedAt = LocalDateTime.now();
|
||||
super(fullName, email, role, status);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
package com.example.training.domain.dto;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import jakarta.validation.constraints.NotEmpty;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.Pattern;
|
||||
import jakarta.validation.constraints.Size;
|
||||
|
||||
public record BulkStatusRequest(
|
||||
@NotEmpty(message = "用户 ID 不能为空") @Size(max = 100, message = "单次最多更新 100 个用户") List<Long> ids,
|
||||
@NotBlank(message = "状态不能为空") @Pattern(regexp = "ACTIVE|INACTIVE", message = "状态不合法") String status) {
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package com.example.training.domain.dto;
|
||||
|
||||
import jakarta.validation.constraints.Email;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.Pattern;
|
||||
import jakarta.validation.constraints.Size;
|
||||
|
||||
public record UserRequest(
|
||||
@NotBlank(message = "姓名不能为空") @Size(max = 80, message = "姓名长度不能超过 80 个字符") String fullName,
|
||||
@NotBlank(message = "邮箱不能为空") @Email(message = "邮箱格式不正确") @Size(max = 160, message = "邮箱长度不能超过 160 个字符") String email,
|
||||
@NotBlank(message = "角色不能为空") @Pattern(regexp = "Admin|Manager|Member|Viewer", message = "角色不合法") String role,
|
||||
@NotBlank(message = "状态不能为空") @Pattern(regexp = "ACTIVE|INACTIVE", message = "状态不合法") String status) {
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package com.example.training.domain.entity;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableField;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
|
||||
@TableName("USERS")
|
||||
public class User {
|
||||
|
||||
@TableId(value = "id", type = IdType.AUTO)
|
||||
private Long id;
|
||||
|
||||
@TableField("full_name")
|
||||
private String fullName;
|
||||
|
||||
private String email;
|
||||
private String role;
|
||||
private String status;
|
||||
|
||||
@TableField("created_at")
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
@TableField("updated_at")
|
||||
private LocalDateTime updatedAt;
|
||||
|
||||
public User() {
|
||||
}
|
||||
|
||||
public User(String fullName, String email, String role, String status) {
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
this.fullName = fullName;
|
||||
this.email = email;
|
||||
this.role = role;
|
||||
this.status = status;
|
||||
this.createdAt = now;
|
||||
this.updatedAt = now;
|
||||
}
|
||||
|
||||
public Long getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public String getFullName() {
|
||||
return fullName;
|
||||
}
|
||||
|
||||
public String getEmail() {
|
||||
return email;
|
||||
}
|
||||
|
||||
public String getRole() {
|
||||
return role;
|
||||
}
|
||||
|
||||
public String getStatus() {
|
||||
return status;
|
||||
}
|
||||
|
||||
public LocalDateTime getCreatedAt() {
|
||||
return createdAt;
|
||||
}
|
||||
|
||||
public LocalDateTime getUpdatedAt() {
|
||||
return updatedAt;
|
||||
}
|
||||
|
||||
public void update(String fullName, String email, String role, String status) {
|
||||
this.fullName = fullName;
|
||||
this.email = email;
|
||||
this.role = role;
|
||||
this.status = status;
|
||||
this.updatedAt = LocalDateTime.now();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
package com.example.training.domain.vo;
|
||||
|
||||
public record BulkStatusVo(int updated, String status) {
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package com.example.training.domain.vo;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public record UserPageVo(List<UserVo> items, long total, int page, int pageSize) {
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
package com.example.training.domain.vo;
|
||||
|
||||
public record UserSummaryVo(long total, long active, long inactive, long admins) {
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package com.example.training.domain.vo;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
import com.example.training.domain.entity.User;
|
||||
|
||||
public record UserVo(Long id, String fullName, String email, String role,
|
||||
String status, LocalDateTime createdAt, LocalDateTime updatedAt) {
|
||||
|
||||
public static UserVo from(User user) {
|
||||
return new UserVo(user.getId(), user.getFullName(), user.getEmail(), user.getRole(),
|
||||
user.getStatus(), user.getCreatedAt(), user.getUpdatedAt());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package com.example.training.exception;
|
||||
|
||||
import com.example.training.common.ApiResponse;
|
||||
import com.example.training.constant.ErrorCode;
|
||||
import com.example.training.util.RequestIdContext;
|
||||
import jakarta.validation.ConstraintViolationException;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.dao.DataIntegrityViolationException;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.MethodArgumentNotValidException;
|
||||
import org.springframework.web.bind.annotation.ExceptionHandler;
|
||||
import org.springframework.web.bind.annotation.RestControllerAdvice;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
@RestControllerAdvice
|
||||
public class GlobalExceptionHandler {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class);
|
||||
|
||||
@ExceptionHandler(MethodArgumentNotValidException.class)
|
||||
public ResponseEntity<ApiResponse<Void>> handleValidation(MethodArgumentNotValidException exception) {
|
||||
String message = exception.getBindingResult().getFieldErrors().stream()
|
||||
.findFirst()
|
||||
.map(error -> error.getDefaultMessage())
|
||||
.orElse(ErrorCode.VALIDATION_FAILED.getMessage());
|
||||
return ResponseEntity.badRequest().body(ApiResponse.failure(
|
||||
ErrorCode.VALIDATION_FAILED.getCode(), message));
|
||||
}
|
||||
|
||||
@ExceptionHandler(ConstraintViolationException.class)
|
||||
public ResponseEntity<ApiResponse<Void>> handleConstraintViolation() {
|
||||
return ResponseEntity.badRequest().body(ApiResponse.failure(
|
||||
ErrorCode.VALIDATION_FAILED.getCode(), ErrorCode.VALIDATION_FAILED.getMessage()));
|
||||
}
|
||||
|
||||
@ExceptionHandler(DataIntegrityViolationException.class)
|
||||
public ResponseEntity<ApiResponse<Void>> handleDataIntegrityViolation() {
|
||||
return ResponseEntity.status(409).body(ApiResponse.failure(
|
||||
ErrorCode.DUPLICATE_EMAIL.getCode(), ErrorCode.DUPLICATE_EMAIL.getMessage()));
|
||||
}
|
||||
|
||||
@ExceptionHandler(ResponseStatusException.class)
|
||||
public ResponseEntity<ApiResponse<Void>> handleResponseStatus(ResponseStatusException exception) {
|
||||
int code = exception.getStatusCode().value() == 404
|
||||
? ErrorCode.USER_NOT_FOUND.getCode()
|
||||
: ErrorCode.VALIDATION_FAILED.getCode();
|
||||
String message = exception.getStatusCode().value() == 404
|
||||
? ErrorCode.USER_NOT_FOUND.getMessage()
|
||||
: exception.getReason();
|
||||
return ResponseEntity.status(exception.getStatusCode()).body(ApiResponse.failure(code, message));
|
||||
}
|
||||
|
||||
@ExceptionHandler(Exception.class)
|
||||
public ResponseEntity<ApiResponse<Void>> handleUnexpectedException(Exception exception) {
|
||||
log.error("请求处理失败,requestId={}", RequestIdContext.current(), exception);
|
||||
return ResponseEntity.internalServerError().body(ApiResponse.failure(
|
||||
ErrorCode.INTERNAL_ERROR.getCode(), ErrorCode.INTERNAL_ERROR.getMessage()));
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
package com.example.training.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.example.training.domain.User;
|
||||
import com.example.training.domain.entity.User;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
|
||||
@Mapper
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
package com.example.training.service;
|
||||
|
||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import com.example.training.domain.User;
|
||||
import com.example.training.domain.entity.User;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
|
||||
@@ -3,9 +3,12 @@ package com.example.training.service.impl;
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
|
||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import com.example.training.domain.User;
|
||||
import com.example.training.domain.entity.User;
|
||||
import com.example.training.mapper.UserMapper;
|
||||
import com.example.training.service.UserService;
|
||||
import com.example.training.util.RequestIdContext;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
@@ -15,6 +18,8 @@ import java.util.List;
|
||||
@Service
|
||||
public class UserServiceImpl implements UserService {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(UserServiceImpl.class);
|
||||
|
||||
private final UserMapper userMapper;
|
||||
|
||||
public UserServiceImpl(UserMapper userMapper) {
|
||||
@@ -24,7 +29,7 @@ public class UserServiceImpl implements UserService {
|
||||
@Override
|
||||
@Transactional(readOnly = true)
|
||||
public Page<User> pageUsers(String keyword, String role, String status, int page, int size) {
|
||||
Page<User> requestPage = new Page<>(page + 1L, size);
|
||||
Page<User> requestPage = new Page<>(Math.max(page, 1), Math.min(Math.max(size, 1), 100));
|
||||
LambdaQueryWrapper<User> query = new LambdaQueryWrapper<>();
|
||||
if (keyword != null && !keyword.isBlank()) {
|
||||
String trimmedKeyword = keyword.trim();
|
||||
@@ -65,7 +70,10 @@ public class UserServiceImpl implements UserService {
|
||||
update.in(User::getId, ids)
|
||||
.set(User::getStatus, status)
|
||||
.set(User::getUpdatedAt, LocalDateTime.now());
|
||||
return userMapper.update(null, update);
|
||||
int updated = userMapper.update(null, update);
|
||||
log.info("批量更新用户状态,requestId={},updated={},status={}",
|
||||
RequestIdContext.current(), updated, status);
|
||||
return updated;
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -73,6 +81,7 @@ public class UserServiceImpl implements UserService {
|
||||
public User createUser(String fullName, String email, String role, String status) {
|
||||
User user = new User(normalize(fullName), normalizeEmail(email), role, status);
|
||||
userMapper.insert(user);
|
||||
log.info("创建用户,requestId={},userId={}", RequestIdContext.current(), user.getId());
|
||||
return user;
|
||||
}
|
||||
|
||||
@@ -85,6 +94,7 @@ public class UserServiceImpl implements UserService {
|
||||
}
|
||||
user.update(normalize(fullName), normalizeEmail(email), role, status);
|
||||
userMapper.updateById(user);
|
||||
log.info("更新用户,requestId={},userId={}", RequestIdContext.current(), id);
|
||||
return user;
|
||||
}
|
||||
|
||||
@@ -94,7 +104,10 @@ public class UserServiceImpl implements UserService {
|
||||
if (userMapper.selectById(id) == null) {
|
||||
return false;
|
||||
}
|
||||
return userMapper.deleteById(id) > 0;
|
||||
boolean deleted = userMapper.deleteById(id) > 0;
|
||||
log.info("删除用户,requestId={},userId={},deleted={}",
|
||||
RequestIdContext.current(), id, deleted);
|
||||
return deleted;
|
||||
}
|
||||
|
||||
private static String normalize(String value) {
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
package com.example.training.util;
|
||||
|
||||
import org.slf4j.MDC;
|
||||
|
||||
public final class RequestIdContext {
|
||||
|
||||
public static final String HEADER_NAME = "X-Request-Id";
|
||||
private static final String MDC_KEY = "requestId";
|
||||
|
||||
private RequestIdContext() {
|
||||
}
|
||||
|
||||
public static void set(String requestId) {
|
||||
MDC.put(MDC_KEY, requestId);
|
||||
}
|
||||
|
||||
public static String current() {
|
||||
return MDC.get(MDC_KEY);
|
||||
}
|
||||
|
||||
public static void clear() {
|
||||
MDC.remove(MDC_KEY);
|
||||
}
|
||||
}
|
||||
@@ -15,8 +15,7 @@ spring:
|
||||
validation-timeout: ${MYSQL_POOL_VALIDATION_TIMEOUT_MS:5000}
|
||||
sql:
|
||||
init:
|
||||
mode: always
|
||||
continue-on-error: false
|
||||
mode: never
|
||||
mybatis-plus:
|
||||
configuration:
|
||||
map-underscore-to-camel-case: true
|
||||
@@ -36,3 +35,5 @@ logging:
|
||||
level:
|
||||
com.example.training: ${LOGGING_LEVEL_COM_EXAMPLE_TRAINING:INFO}
|
||||
com.baomidou.mybatisplus: INFO
|
||||
pattern:
|
||||
level: "%5p [requestId:%X{requestId}]"
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id BIGINT NOT NULL AUTO_INCREMENT,
|
||||
full_name VARCHAR(80) NOT NULL,
|
||||
email VARCHAR(160) NOT NULL,
|
||||
role VARCHAR(40) NOT NULL,
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE',
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_users_email (email),
|
||||
KEY idx_users_full_name (full_name),
|
||||
KEY idx_users_status (status)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
@@ -7,13 +7,17 @@ import org.springframework.test.context.ActiveProfiles;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
|
||||
import com.example.training.controller.HealthController;
|
||||
import com.example.training.config.WebConfig;
|
||||
import org.springframework.context.annotation.Import;
|
||||
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header;
|
||||
|
||||
@WebMvcTest(HealthController.class)
|
||||
@ActiveProfiles("test")
|
||||
@Import(WebConfig.class)
|
||||
class TrainingApplicationTests {
|
||||
|
||||
@Autowired
|
||||
@@ -21,9 +25,12 @@ class TrainingApplicationTests {
|
||||
|
||||
@Test
|
||||
void healthEndpointReturnsUp() throws Exception {
|
||||
mockMvc.perform(get("/api/health"))
|
||||
mockMvc.perform(get("/api/v1/health").header("X-Request-Id", "health-test-1"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.status").value("UP"))
|
||||
.andExpect(jsonPath("$.service").value("training-backend"));
|
||||
.andExpect(jsonPath("$.code").value(200))
|
||||
.andExpect(jsonPath("$.message").value("success"))
|
||||
.andExpect(jsonPath("$.data.status").value("UP"))
|
||||
.andExpect(jsonPath("$.data.service").value("training-backend"))
|
||||
.andExpect(header().string("X-Request-Id", "health-test-1"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,39 +31,53 @@ class UserControllerTests {
|
||||
|
||||
@Test
|
||||
void listPassesRoleAndStatusFilters() throws Exception {
|
||||
when(userService.pageUsers("晓", "Admin", "ACTIVE", 0, 50)).thenReturn(new Page<>(1, 50));
|
||||
when(userService.pageUsers("晓", "Admin", "ACTIVE", 1, 50)).thenReturn(new Page<>(1, 50));
|
||||
|
||||
mockMvc.perform(get("/api/users")
|
||||
mockMvc.perform(get("/api/v1/users")
|
||||
.param("keyword", "晓")
|
||||
.param("role", "Admin")
|
||||
.param("status", "ACTIVE"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.total").value(0));
|
||||
.andExpect(jsonPath("$.code").value(200))
|
||||
.andExpect(jsonPath("$.data.total").value(0))
|
||||
.andExpect(jsonPath("$.data.page").value(1))
|
||||
.andExpect(jsonPath("$.data.pageSize").value(50));
|
||||
|
||||
verify(userService).pageUsers("晓", "Admin", "ACTIVE", 0, 50);
|
||||
verify(userService).pageUsers("晓", "Admin", "ACTIVE", 1, 50);
|
||||
}
|
||||
|
||||
@Test
|
||||
void summaryReturnsOverviewCounts() throws Exception {
|
||||
when(userService.summarizeUsers()).thenReturn(new UserService.UserSummary(12, 9, 3, 2));
|
||||
|
||||
mockMvc.perform(get("/api/users/summary"))
|
||||
mockMvc.perform(get("/api/v1/users/summary"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.total").value(12))
|
||||
.andExpect(jsonPath("$.active").value(9))
|
||||
.andExpect(jsonPath("$.inactive").value(3))
|
||||
.andExpect(jsonPath("$.admins").value(2));
|
||||
.andExpect(jsonPath("$.data.total").value(12))
|
||||
.andExpect(jsonPath("$.data.active").value(9))
|
||||
.andExpect(jsonPath("$.data.inactive").value(3))
|
||||
.andExpect(jsonPath("$.data.admins").value(2));
|
||||
}
|
||||
|
||||
@Test
|
||||
void bulkStatusUpdatesSelectedUsers() throws Exception {
|
||||
when(userService.updateStatuses(anyList(), eq("INACTIVE"))).thenReturn(2);
|
||||
|
||||
mockMvc.perform(patch("/api/users/status")
|
||||
mockMvc.perform(patch("/api/v1/users/status")
|
||||
.contentType("application/json")
|
||||
.content("{\"ids\":[4,7],\"status\":\"INACTIVE\"}"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.updated").value(2))
|
||||
.andExpect(jsonPath("$.status").value("INACTIVE"));
|
||||
.andExpect(jsonPath("$.code").value(200))
|
||||
.andExpect(jsonPath("$.data.updated").value(2))
|
||||
.andExpect(jsonPath("$.data.status").value("INACTIVE"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void invalidUserRequestReturnsStandardError() throws Exception {
|
||||
mockMvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post("/api/v1/users")
|
||||
.contentType("application/json")
|
||||
.content("{\"fullName\":\"\",\"email\":\"invalid\",\"role\":\"Unknown\",\"status\":\"BROKEN\"}"))
|
||||
.andExpect(status().isBadRequest())
|
||||
.andExpect(jsonPath("$.code").value(400001))
|
||||
.andExpect(jsonPath("$.data").doesNotExist());
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user