diff --git a/pom.xml b/pom.xml index 977b95e..4f7ec91 100644 --- a/pom.xml +++ b/pom.xml @@ -37,6 +37,12 @@ org.springframework.boot spring-boot-starter-actuator + + + + org.springframework.boot + spring-boot-starter-validation + diff --git a/src/main/java/com/example/demo/common/ApiResponse.java b/src/main/java/com/example/demo/common/ApiResponse.java new file mode 100644 index 0000000..a38e218 --- /dev/null +++ b/src/main/java/com/example/demo/common/ApiResponse.java @@ -0,0 +1,22 @@ +package com.example.demo.common; + +import java.time.Instant; + +public record ApiResponse( + int code, + String message, + T data, + Instant timestamp +) { + public static ApiResponse ok(T data) { + return new ApiResponse<>(0, "success", data, Instant.now()); + } + + public static ApiResponse ok(String message, T data) { + return new ApiResponse<>(0, message, data, Instant.now()); + } + + public static ApiResponse fail(int code, String message) { + return new ApiResponse<>(code, message, null, Instant.now()); + } +} diff --git a/src/main/java/com/example/demo/controller/AopEventController.java b/src/main/java/com/example/demo/controller/AopEventController.java index ac2ec67..18232b6 100644 --- a/src/main/java/com/example/demo/controller/AopEventController.java +++ b/src/main/java/com/example/demo/controller/AopEventController.java @@ -6,7 +6,6 @@ import com.example.demo.aop.RateLimited; import com.example.demo.event.UserEventPublisher; import com.example.demo.model.User; import com.example.demo.service.UserService; -import org.springframework.beans.factory.annotation.Autowired; import org.springframework.web.bind.annotation.*; import java.util.HashMap; @@ -20,17 +19,20 @@ import java.util.Map; @RequestMapping("/aop") public class AopEventController { - @Autowired - private UserService userService; + private final UserService userService; + private final UserEventPublisher eventPublisher; + private final PerformanceAspect performanceAspect; + private final RateLimitAspect rateLimitAspect; - @Autowired - private UserEventPublisher eventPublisher; - - @Autowired - private PerformanceAspect performanceAspect; - - @Autowired - private RateLimitAspect rateLimitAspect; + public AopEventController(UserService userService, + UserEventPublisher eventPublisher, + PerformanceAspect performanceAspect, + RateLimitAspect rateLimitAspect) { + this.userService = userService; + this.eventPublisher = eventPublisher; + this.performanceAspect = performanceAspect; + this.rateLimitAspect = rateLimitAspect; + } /** * 学习首页 diff --git a/src/main/java/com/example/demo/controller/UserController.java b/src/main/java/com/example/demo/controller/UserController.java index 758083d..e1e7461 100644 --- a/src/main/java/com/example/demo/controller/UserController.java +++ b/src/main/java/com/example/demo/controller/UserController.java @@ -1,64 +1,54 @@ package com.example.demo.controller; +import com.example.demo.common.ApiResponse; +import com.example.demo.dto.UserRequest; import com.example.demo.model.User; import com.example.demo.service.UserService; -import org.springframework.beans.factory.annotation.Autowired; +import jakarta.validation.Valid; import org.springframework.web.bind.annotation.*; import java.util.List; -/** - * 用户控制器 - RESTful API 示例 - * - * 学习点: - * - @RestController: 组合了 @Controller 和 @ResponseBody - * - @RequestMapping: 路由映射 - * - @PathVariable: 路径变量 - * - @RequestParam: 查询参数 - * - @RequestBody: 请求体 - */ @RestController @RequestMapping("/api/users") public class UserController { - @Autowired - private UserService userService; + private final UserService userService; + + public UserController(UserService userService) { + this.userService = userService; + } - // GET /api/users - 获取所有用户 @GetMapping - public List getAllUsers() { - return userService.findAll(); + public ApiResponse> getAllUsers() { + return ApiResponse.ok(userService.findAll()); } - // GET /api/users/{id} - 获取单个用户 @GetMapping("/{id}") - public User getUserById(@PathVariable Long id) { - return userService.findById(id); + public ApiResponse getUserById(@PathVariable Long id) { + return ApiResponse.ok(userService.findById(id)); } - // POST /api/users - 创建用户 @PostMapping - public User createUser(@RequestBody User user) { - return userService.save(user); + public ApiResponse createUser(@Valid @RequestBody UserRequest req) { + User user = new User(null, req.name(), req.email(), req.age()); + return ApiResponse.ok("创建成功", userService.create(user)); } - // PUT /api/users/{id} - 更新用户 @PutMapping("/{id}") - public User updateUser(@PathVariable Long id, @RequestBody User user) { - user.setId(id); - return userService.save(user); + public ApiResponse updateUser(@PathVariable Long id, @Valid @RequestBody UserRequest req) { + User user = new User(id, req.name(), req.email(), req.age()); + return ApiResponse.ok("更新成功", userService.update(id, user)); } - // DELETE /api/users/{id} - 删除用户 @DeleteMapping("/{id}") - public String deleteUser(@PathVariable Long id) { + public ApiResponse deleteUser(@PathVariable Long id) { userService.delete(id); - return "用户 " + id + " 已删除"; + return ApiResponse.ok("删除成功", null); } - // GET /api/users/search?name=xxx - 搜索用户 @GetMapping("/search") - public List searchUsers(@RequestParam String name) { - return userService.findByName(name); + public ApiResponse> searchUsers(@RequestParam String name) { + return ApiResponse.ok(userService.findByName(name)); } -} \ No newline at end of file +} diff --git a/src/main/java/com/example/demo/dto/UserRequest.java b/src/main/java/com/example/demo/dto/UserRequest.java new file mode 100644 index 0000000..1c3fdf8 --- /dev/null +++ b/src/main/java/com/example/demo/dto/UserRequest.java @@ -0,0 +1,19 @@ +package com.example.demo.dto; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; + +public record UserRequest( + @NotBlank(message = "姓名不能为空") + String name, + + @NotBlank(message = "邮箱不能为空") + @Email(message = "邮箱格式不正确") + String email, + + @Min(value = 1, message = "年龄最小为 1") + @Max(value = 120, message = "年龄最大为 120") + Integer age +) {} diff --git a/src/main/java/com/example/demo/exception/GlobalExceptionHandler.java b/src/main/java/com/example/demo/exception/GlobalExceptionHandler.java new file mode 100644 index 0000000..2e54f79 --- /dev/null +++ b/src/main/java/com/example/demo/exception/GlobalExceptionHandler.java @@ -0,0 +1,38 @@ +package com.example.demo.exception; + +import com.example.demo.common.ApiResponse; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.validation.FieldError; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +import java.util.HashMap; +import java.util.Map; + +@RestControllerAdvice +public class GlobalExceptionHandler { + + @ExceptionHandler(ResourceNotFoundException.class) + public ResponseEntity> handleNotFound(ResourceNotFoundException e) { + return ResponseEntity.status(HttpStatus.NOT_FOUND) + .body(ApiResponse.fail(404, e.getMessage())); + } + + @ExceptionHandler(MethodArgumentNotValidException.class) + public ResponseEntity>> handleValidation(MethodArgumentNotValidException e) { + Map errors = new HashMap<>(); + for (FieldError error : e.getBindingResult().getFieldErrors()) { + errors.put(error.getField(), error.getDefaultMessage()); + } + return ResponseEntity.badRequest() + .body(new ApiResponse<>(400, "参数校验失败", errors, java.time.Instant.now())); + } + + @ExceptionHandler(Exception.class) + public ResponseEntity> handleAny(Exception e) { + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) + .body(ApiResponse.fail(500, "服务器内部错误: " + e.getMessage())); + } +} diff --git a/src/main/java/com/example/demo/exception/ResourceNotFoundException.java b/src/main/java/com/example/demo/exception/ResourceNotFoundException.java new file mode 100644 index 0000000..d3ba1b7 --- /dev/null +++ b/src/main/java/com/example/demo/exception/ResourceNotFoundException.java @@ -0,0 +1,7 @@ +package com.example.demo.exception; + +public class ResourceNotFoundException extends RuntimeException { + public ResourceNotFoundException(String message) { + super(message); + } +} diff --git a/src/main/java/com/example/demo/service/UserService.java b/src/main/java/com/example/demo/service/UserService.java index e68dbb5..7009f12 100644 --- a/src/main/java/com/example/demo/service/UserService.java +++ b/src/main/java/com/example/demo/service/UserService.java @@ -1,5 +1,6 @@ package com.example.demo.service; +import com.example.demo.exception.ResourceNotFoundException; import com.example.demo.model.User; import org.springframework.stereotype.Service; @@ -8,23 +9,13 @@ import java.util.List; import java.util.concurrent.atomic.AtomicLong; import java.util.stream.Collectors; -/** - * 用户服务 - 业务逻辑层 - * - * 学习点: - * - @Service: 标记为服务层组件,自动注册为 Bean - * - 依赖注入:Controller 通过 @Autowired 注入此服务 - * - 分层架构:Controller -> Service -> Repository - */ @Service public class UserService { - // 内存存储(演示用,实际项目用数据库) private final List users = new ArrayList<>(); private final AtomicLong idGenerator = new AtomicLong(1); public UserService() { - // 初始化一些测试数据 users.add(new User(idGenerator.getAndIncrement(), "张三", "zhangsan@example.com", 25)); users.add(new User(idGenerator.getAndIncrement(), "李四", "lisi@example.com", 30)); users.add(new User(idGenerator.getAndIncrement(), "王五", "wangwu@example.com", 28)); @@ -38,7 +29,7 @@ public class UserService { return users.stream() .filter(u -> u.getId().equals(id)) .findFirst() - .orElse(null); + .orElseThrow(() -> new ResourceNotFoundException("用户不存在: id=" + id)); } public List findByName(String name) { @@ -47,23 +38,28 @@ public class UserService { .collect(Collectors.toList()); } - public User save(User user) { - if (user.getId() == null) { - user.setId(idGenerator.getAndIncrement()); - users.add(user); - } else { - // 更新 - for (int i = 0; i < users.size(); i++) { - if (users.get(i).getId().equals(user.getId())) { - users.set(i, user); - break; - } - } - } + public User create(User user) { + user.setId(idGenerator.getAndIncrement()); + users.add(user); return user; } - public void delete(Long id) { - users.removeIf(u -> u.getId().equals(id)); + public User update(Long id, User user) { + findById(id); + user.setId(id); + for (int i = 0; i < users.size(); i++) { + if (users.get(i).getId().equals(id)) { + users.set(i, user); + return user; + } + } + throw new ResourceNotFoundException("用户不存在: id=" + id); } -} \ No newline at end of file + + public void delete(Long id) { + boolean removed = users.removeIf(u -> u.getId().equals(id)); + if (!removed) { + throw new ResourceNotFoundException("用户不存在: id=" + id); + } + } +} diff --git a/src/main/resources/static/users.html b/src/main/resources/static/users.html index 56a30e5..8a2a40c 100644 --- a/src/main/resources/static/users.html +++ b/src/main/resources/static/users.html @@ -143,7 +143,8 @@ public class UserController { // 加载用户列表 async function loadUsers() { const res = await fetch('/api/users'); - const users = await res.json(); + const payload = await res.json(); + const users = payload.data || []; const tbody = document.querySelector('#userTable tbody'); tbody.innerHTML = users.map(u => ` @@ -196,7 +197,7 @@ public class UserController { await fetch(`/api/users/${id}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ ...user, id: parseInt(id) }) + body: JSON.stringify(user) }); } else { await fetch('/api/users', { diff --git a/src/test/java/com/example/demo/controller/UserControllerTest.java b/src/test/java/com/example/demo/controller/UserControllerTest.java new file mode 100644 index 0000000..fb41930 --- /dev/null +++ b/src/test/java/com/example/demo/controller/UserControllerTest.java @@ -0,0 +1,62 @@ +package com.example.demo.controller; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +@SpringBootTest +@AutoConfigureMockMvc +class UserControllerTest { + + @Autowired + private MockMvc mockMvc; + + @Test + void shouldListUsersWithApiResponseWrapper() throws Exception { + mockMvc.perform(get("/api/users")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data").isArray()); + } + + @Test + void shouldCreateUser() throws Exception { + String json = """ + { + \"name\": \"测试用户\", + \"email\": \"test@example.com\", + \"age\": 22 + } + """; + + mockMvc.perform(post("/api/users") + .contentType(MediaType.APPLICATION_JSON) + .content(json)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.name").value("测试用户")); + } + + @Test + void shouldRejectInvalidUser() throws Exception { + String json = """ + { + \"name\": \"\", + \"email\": \"bad-mail\", + \"age\": 222 + } + """; + + mockMvc.perform(post("/api/users") + .contentType(MediaType.APPLICATION_JSON) + .content(json)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(400)); + } +}