Compare commits

...

6 Commits

57 changed files with 1726 additions and 521 deletions

29
pom.xml
View File

@@ -37,6 +37,35 @@
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<!-- 参数校验 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<!-- 仅用于学习的可选鉴权演示(不影响主流程) -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>0.12.3</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>0.12.3</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
<version>0.12.3</version>
<scope>runtime</scope>
</dependency>
<!-- 测试 -->
<dependency>

View File

@@ -7,25 +7,31 @@
=========|_|==============|___/=/_/_/_/
:: Spring Boot :: (v3.2.0)
2026-03-06T17:09:02.230Z INFO 1154290 --- [springboot-demo] [ main] com.example.demo.DemoApplication : Starting DemoApplication v0.0.1-SNAPSHOT using Java 21.0.10 with PID 1154290 (/home/llm/projects/springboot-demo/target/demo-0.0.1-SNAPSHOT.jar started by llm in /home/llm/projects/springboot-demo)
2026-03-06T17:09:02.380Z INFO 1154290 --- [springboot-demo] [ main] com.example.demo.DemoApplication : No active profile set, falling back to 1 default profile: "default"
2026-03-06T17:10:05.868Z INFO 1154290 --- [springboot-demo] [ main] o.s.b.w.embedded.tomcat.TomcatWebServer : Tomcat initialized with port 8082 (http)
2026-03-06T17:10:06.093Z INFO 1154290 --- [springboot-demo] [ main] o.apache.catalina.core.StandardService : Starting service [Tomcat]
2026-03-06T17:10:06.093Z INFO 1154290 --- [springboot-demo] [ main] o.apache.catalina.core.StandardEngine : Starting Servlet engine: [Apache Tomcat/10.1.16]
2026-03-06T17:10:07.996Z INFO 1154290 --- [springb2026-03-06T17:10:12.808Z INFO 1153691 --- [springboot-demo] [ main] o.s.b.a.e.web.EndpointLinksResolver : Exposing 1 endpoint(s) beneath base path '/actuator'
2026-03-06T17:10:14.838Z INFO 1153691 --- [springboot-demo] [ main] o.s.b.w.embedded.tomcat.TomcatWebServer : Tomcat start2026-03-06T17:10:23.314Z INFO 1154290 --- [springboot-demo] [ main] o.s.b.a.w.s.WelcomePageHandlerMapping : Adding welcome page: class path resource [static/index.html]
2026-03-06T17:10:40.414Z INFO 1154290 --- [springboot-demo] [ main] o.s.b.a.e.web.EndpointLinksResolver : Exposing 1 endpoint(s) beneath base path '/actuator'
2026-03-06T17:10:41.395Z INFO 1154290 --- [springboot-demo] [ main] o.s.b.w.embedded.tomcat.TomcatWebServer : Tomcat started on port 8082 (http) with context path ''
2026-03-06T17:10:41.541Z INFO 1154290 --- [springboot-demo] [ main] com.example.demo.DemoApplication : Started DemoApplication in 116.974 seconds (process running for 133.101)
2026-03-07T08:12:47.326Z INFO 1374194 --- [springboot-demo] [ main] com.example.demo.DemoApplication : Starting DemoApplication v0.0.1-SNAPSHOT using Java 21.0.10 with PID 1374194 (/home/llm/projects/springboot-demo/target/demo-0.0.1-SNAPSHOT.jar started by llm in /home/llm/projects/springboot-demo)
2026-03-07T08:12:47.356Z INFO 1374194 --- [springboot-demo] [ main] com.example.demo.DemoApplication : No active profile set, falling back to 1 default profile: "default"
2026-03-07T08:13:00.941Z INFO 1374194 --- [springboot-demo] [ main] o.s.b.w.embedded.tomcat.TomcatWebServer : Tomcat initialized with port 8082 (http)
2026-03-07T08:13:01.016Z INFO 1374194 --- [springboot-demo] [ main] o.apache.catalina.core.StandardService : Starting service [Tomcat]
2026-03-07T08:13:01.020Z INFO 1374194 --- [springboot-demo] [ main] o.apache.catalina.core.StandardEngine : Starting Servlet engine: [Apache Tomcat/10.1.16]
2026-03-07T08:13:01.808Z INFO 1374194 --- [springboot-demo] [ main] o.a.c.c.C.[Tomcat].[localhost].[/] : Initializing Spring embedded WebApplicationContext
2026-03-07T08:13:01.814Z INFO 1374194 --- [springboot-demo] [ main] w.s.c.ServletWebServerApplicationContext : Root WebApplicationContext: initialization completed in 13966 ms
2026-03-07T08:13:07.411Z INFO 1374194 --- [springboot-demo] [ main] o.s.b.a.w.s.WelcomePageHandlerMapping : Adding welcome page: class path resource [static/index.html]
2026-03-07T08:13:15.547Z INFO 1374194 --- [springboot-demo] [ main] o.s.b.a.e.web.EndpointLinksResolver : Exposing 1 endpoint(s) beneath base path '/actuator'
2026-03-07T08:13:16.328Z INFO 1374194 --- [springboot-demo] [ main] o.s.b.w.embedded.tomcat.TomcatWebServer : Tomcat started on port 8082 (http) with context path ''
2026-03-07T08:13:16.450Z INFO 1374194 --- [springboot-demo] [ main] com.example.demo.DemoApplication : Started DemoApplication in 33.141 seconds (process running for 36.573)
[EventListener] Spring Boot 应用启动完成!
2026-03-06T17:11:05.601Z INFO 1154290 --- [springboot-demo] [nio-8082-exec-1] o.a.c.c.C.[Tomcat].[localhost].[/] : Initializing Spring DispatcherServlet 'dispatcherServlet'
2026-03-06T17:11:05.601Z INFO 1154290 --- [springboot-demo] [nio-8082-exec-1] o.s.web.servlet.DispatcherServlet : Initializing Servlet 'dispatcherServlet'
2026-03-06T17:11:05.619Z INFO 1154290 --- [springboot-demo] [nio-8082-exec-1] o.s.web.servlet.DispatcherServlet : Completed initialization in 13 ms
2026-03-07T08:13:47.186Z INFO 1374194 --- [springboot-demo] [nio-8082-exec-1] o.a.c.c.C.[Tomcat].[localhost].[/] : Initializing Spring DispatcherServlet 'dispatcherServlet'
2026-03-07T08:13:47.186Z INFO 1374194 --- [springboot-demo] [nio-8082-exec-1] o.s.web.servlet.DispatcherServlet : Initializing Servlet 'dispatcherServlet'
2026-03-07T08:13:47.195Z INFO 1374194 --- [springboot-demo] [nio-8082-exec-1] o.s.web.servlet.DispatcherServlet : Completed initialization in 4 ms
[AOP-Before] Controller 方法开始: root
参数: []
[AOP-AfterReturning] 方法返回: root
返回值: {aop=https://spring.xiaoxiaoluohao.indevs.in/aop, learn=https://spring.xiaoxiaoluohao.indevs.in/learn, api=https://spring.xiaoxiaoluohao.indevs.in/api/users, message=欢迎来到 Spring Boot 学习脚手架!}
[AOP-Performance] LearnController.root() 执行耗时: 18ms
[AOP-Performance] LearnController.root() 执行耗时: 8ms
[AOP-Before] Controller 方法开始: root
参数: []
[AOP-AfterReturning] 方法返回: root
返回值: {aop=https://spring.xiaoxiaoluohao.indevs.in/aop, learn=https://spring.xiaoxiaoluohao.indevs.in/learn, api=https://spring.xiaoxiaoluohao.indevs.in/api/users, message=欢迎来到 Spring Boot 学习脚手架!}
[AOP-Performance] LearnController.root() 执行耗时: 3ms
[AOP-Before] Controller 方法开始: root
参数: []
[AOP-AfterReturning] 方法返回: root
@@ -35,4 +41,56 @@
参数: []
[AOP-AfterReturning] 方法返回: root
返回值: {aop=https://spring.xiaoxiaoluohao.indevs.in/aop, learn=https://spring.xiaoxiaoluohao.indevs.in/learn, api=https://spring.xiaoxiaoluohao.indevs.in/api/users, message=欢迎来到 Spring Boot 学习脚手架!}
[AOP-Performance] LearnController.root() 执行耗时: 3ms
[AOP-Performance] LearnController.root() 执行耗时: 10ms
[AOP-Before] Controller 方法开始: getAllUsers
参数: []
[AOP-After] Service 方法结束: findAll
[AOP-Performance] UserService.findAll() 执行耗时: 2ms
[AOP-AfterReturning] 方法返回: getAllUsers
返回值: [com.example.demo.model.User@3616c07a, com.example.demo.model.User@a6b1d0f, com.example.demo.model.User@7935776c]
[AOP-Performance] UserController.getAllUsers() 执行耗时: 5ms
[AOP-Before] Controller 方法开始: info
参数: []
[AOP-AfterReturning] 方法返回: info
返回值: {app=springboot-demo, endpoints=[Ljava.lang.String;@6c7b5bf1, message=欢迎学习 Spring Boot}
[AOP-Performance] LearnController.info() 执行耗时: 2ms
[AOP-Before] Controller 方法开始: index
参数: []
[AOP-AfterReturning] 方法返回: index
返回值: {endpoints=[Ljava.lang.String;@1b97f799, topics=[Ljava.lang.String;@1fa86033, message=Spring Boot 学习中心}
[AOP-Performance] AopEventController.index() 执行耗时: 11ms
[AOP-Before] Controller 方法开始: root
参数: []
[AOP-AfterReturning] 方法返回: root
返回值: {aop=https://spring.xiaoxiaoluohao.indevs.in/aop, learn=https://spring.xiaoxiaoluohao.indevs.in/learn, api=https://spring.xiaoxiaoluohao.indevs.in/api/users, message=欢迎来到 Spring Boot 学习脚手架!}
[AOP-Performance] LearnController.root() 执行耗时: 6ms
[AOP-Before] Controller 方法开始: index
参数: []
[AOP-AfterReturning] 方法返回: index
返回值: {endpoints=[Ljava.lang.String;@63de5cba, topics=[Ljava.lang.String;@67e1d419, message=Spring Boot 学习中心}
[AOP-Performance] AopEventController.index() 执行耗时: 1ms
[AOP-Before] Controller 方法开始: root
参数: []
[AOP-AfterReturning] 方法返回: root
返回值: {aop=https://spring.xiaoxiaoluohao.indevs.in/aop, learn=https://spring.xiaoxiaoluohao.indevs.in/learn, api=https://spring.xiaoxiaoluohao.indevs.in/api/users, message=欢迎来到 Spring Boot 学习脚手架!}
[AOP-Performance] LearnController.root() 执行耗时: 0ms
[AOP-Before] Controller 方法开始: root
参数: []
[AOP-AfterReturning] 方法返回: root
返回值: {aop=https://spring.xiaoxiaoluohao.indevs.in/aop, learn=https://spring.xiaoxiaoluohao.indevs.in/learn, api=https://spring.xiaoxiaoluohao.indevs.in/api/users, message=欢迎来到 Spring Boot 学习脚手架!}
[AOP-Performance] LearnController.root() 执行耗时: 0ms
[AOP-Before] Controller 方法开始: root
参数: []
[AOP-AfterReturning] 方法返回: root
返回值: {aop=https://spring.xiaoxiaoluohao.indevs.in/aop, learn=https://spring.xiaoxiaoluohao.indevs.in/learn, api=https://spring.xiaoxiaoluohao.indevs.in/api/users, message=欢迎来到 Spring Boot 学习脚手架!}
[AOP-Performance] LearnController.root() 执行耗时: 0ms
[AOP-Before] Controller 方法开始: root
参数: []
[AOP-AfterReturning] 方法返回: root
返回值: {aop=https://spring.xiaoxiaoluohao.indevs.in/aop, learn=https://spring.xiaoxiaoluohao.indevs.in/learn, api=https://spring.xiaoxiaoluohao.indevs.in/api/users, message=欢迎来到 Spring Boot 学习脚手架!}
[AOP-Performance] LearnController.root() 执行耗时: 2ms
[AOP-Before] Controller 方法开始: index
参数: []
[AOP-AfterReturning] 方法返回: index
返回值: {endpoints=[Ljava.lang.String;@22e35681, topics=[Ljava.lang.String;@2214743, message=Spring Boot 学习中心}
[AOP-Performance] AopEventController.index() 执行耗时: 0ms

View File

@@ -0,0 +1,22 @@
package com.example.demo.common;
import java.time.Instant;
public record ApiResponse<T>(
int code,
String message,
T data,
Instant timestamp
) {
public static <T> ApiResponse<T> ok(T data) {
return new ApiResponse<>(0, "success", data, Instant.now());
}
public static <T> ApiResponse<T> ok(String message, T data) {
return new ApiResponse<>(0, message, data, Instant.now());
}
public static ApiResponse<Void> fail(int code, String message) {
return new ApiResponse<>(code, message, null, Instant.now());
}
}

View File

@@ -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;
}
/**
* 学习首页

View File

@@ -45,7 +45,9 @@ public class LearnController {
"POST /learn/body - JSON 请求体示例",
"GET /learn/path/{id} - 路径变量示例",
"GET /learn/header - 请求头示例",
"GET /learn/cookie - Cookie 示例"
"GET /learn/cookie - Cookie 示例",
"POST /api/auth/login - 学习用 JWT 登录",
"GET /api/secure/me - 受保护接口(需 Bearer Token"
});
return info;
}

View File

@@ -1,64 +1,62 @@
package com.example.demo.controller;
import com.example.demo.common.ApiResponse;
import com.example.demo.dto.UserRequest;
import com.example.demo.dto.UserStatsResponse;
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<User> getAllUsers() {
return userService.findAll();
public ApiResponse<List<User>> getAllUsers() {
return ApiResponse.ok(userService.findAll());
}
// GET /api/users/{id} - 获取单个用户
@GetMapping("/{id}")
public User getUserById(@PathVariable Long id) {
return userService.findById(id);
@GetMapping("/stats")
public ApiResponse<UserStatsResponse> getUserStats() {
return ApiResponse.ok(userService.getStats());
}
// POST /api/users - 创建用户
@PostMapping
public User createUser(@RequestBody User user) {
return userService.save(user);
}
// PUT /api/users/{id} - 更新用户
@PutMapping("/{id}")
public User updateUser(@PathVariable Long id, @RequestBody User user) {
user.setId(id);
return userService.save(user);
}
// DELETE /api/users/{id} - 删除用户
@DeleteMapping("/{id}")
public String deleteUser(@PathVariable Long id) {
userService.delete(id);
return "用户 " + id + " 已删除";
}
// GET /api/users/search?name=xxx - 搜索用户
@GetMapping("/search")
public List<User> searchUsers(@RequestParam String name) {
return userService.findByName(name);
public ApiResponse<List<User>> searchUsers(@RequestParam(required = false) String keyword,
@RequestParam(required = false) String name) {
String term = keyword != null && !keyword.isBlank() ? keyword : name;
return ApiResponse.ok(userService.search(term));
}
}
@GetMapping("/{id}")
public ApiResponse<User> getUserById(@PathVariable Long id) {
return ApiResponse.ok(userService.findById(id));
}
@PostMapping
public ApiResponse<User> createUser(@Valid @RequestBody UserRequest request) {
User user = new User(null, request.name(), request.email(), request.age());
return ApiResponse.ok("User created successfully", userService.create(user));
}
@PutMapping("/{id}")
public ApiResponse<User> updateUser(@PathVariable Long id, @Valid @RequestBody UserRequest request) {
User user = new User(id, request.name(), request.email(), request.age());
return ApiResponse.ok("User updated successfully", userService.update(id, user));
}
@DeleteMapping("/{id}")
public ApiResponse<Void> deleteUser(@PathVariable Long id) {
userService.delete(id);
return ApiResponse.ok("User deleted successfully", null);
}
}

View File

@@ -0,0 +1,45 @@
package com.example.demo.controller.auth;
import com.example.demo.common.ApiResponse;
import com.example.demo.dto.auth.LoginRequest;
import com.example.demo.security.LearningJwtUtil;
import jakarta.validation.Valid;
import org.springframework.web.bind.annotation.*;
import java.util.Map;
@RestController
@RequestMapping("/api/auth")
public class LearningAuthController {
private final LearningJwtUtil jwtUtil;
public LearningAuthController(LearningJwtUtil jwtUtil) {
this.jwtUtil = jwtUtil;
}
@PostMapping("/login")
public ApiResponse<Map<String, Object>> login(@Valid @RequestBody LoginRequest req) {
// 学习演示:仅做最小账号检查
if (!(("admin".equals(req.username()) && "admin123".equals(req.password()))
|| ("user".equals(req.username()) && "user123".equals(req.password())))) {
return new ApiResponse<>(401, "用户名或密码错误", null, java.time.Instant.now());
}
String token = jwtUtil.generateToken(req.username());
return ApiResponse.ok(Map.of(
"token", token,
"type", "Bearer",
"username", req.username(),
"tip", "在请求头中加入 Authorization: Bearer <token> 访问 /api/secure/**"
));
}
@GetMapping("/mode")
public ApiResponse<Map<String, Object>> mode() {
return ApiResponse.ok(Map.of(
"mode", "learning-jwt",
"protectedPath", "/api/secure/**",
"defaultAccounts", "admin/admin123, user/user123"
));
}
}

View File

@@ -0,0 +1,23 @@
package com.example.demo.controller.auth;
import com.example.demo.common.ApiResponse;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.Map;
@RestController
@RequestMapping("/api/secure")
public class SecureDemoController {
@GetMapping("/me")
public ApiResponse<Map<String, Object>> me(Authentication authentication) {
return ApiResponse.ok(Map.of(
"principal", authentication.getName(),
"authorities", authentication.getAuthorities(),
"message", "你已通过学习用 JWT 鉴权"
));
}
}

View File

@@ -0,0 +1,24 @@
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;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
public record UserRequest(
@NotBlank(message = "Name is required")
@Size(max = 40, message = "Name must be at most 40 characters")
String name,
@NotBlank(message = "Email is required")
@Email(message = "Email format is invalid")
String email,
@NotNull(message = "Age is required")
@Min(value = 1, message = "Age must be at least 1")
@Max(value = 120, message = "Age must be at most 120")
Integer age
) {
}

View File

@@ -0,0 +1,9 @@
package com.example.demo.dto;
public record UserStatsResponse(
long totalUsers,
long adults,
long underThirty,
double averageAge
) {
}

View File

@@ -0,0 +1,8 @@
package com.example.demo.dto.auth;
import jakarta.validation.constraints.NotBlank;
public record LoginRequest(
@NotBlank(message = "用户名不能为空") String username,
@NotBlank(message = "密码不能为空") String password
) {}

View File

@@ -0,0 +1,7 @@
package com.example.demo.exception;
public class DuplicateEmailException extends RuntimeException {
public DuplicateEmailException(String message) {
super(message);
}
}

View File

@@ -0,0 +1,46 @@
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.time.Instant;
import java.util.HashMap;
import java.util.Map;
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(ResourceNotFoundException.class)
public ResponseEntity<ApiResponse<Void>> handleNotFound(ResourceNotFoundException exception) {
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(ApiResponse.fail(404, exception.getMessage()));
}
@ExceptionHandler(DuplicateEmailException.class)
public ResponseEntity<ApiResponse<Void>> handleDuplicateEmail(DuplicateEmailException exception) {
return ResponseEntity.status(HttpStatus.CONFLICT)
.body(ApiResponse.fail(409, exception.getMessage()));
}
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ApiResponse<Map<String, String>>> handleValidation(MethodArgumentNotValidException exception) {
Map<String, String> errors = new HashMap<>();
for (FieldError error : exception.getBindingResult().getFieldErrors()) {
errors.put(error.getField(), error.getDefaultMessage());
}
return ResponseEntity.badRequest()
.body(new ApiResponse<>(400, "Validation failed", errors, Instant.now()));
}
@ExceptionHandler(Exception.class)
public ResponseEntity<ApiResponse<Void>> handleAny(Exception exception) {
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(ApiResponse.fail(500, "Unexpected server error"));
}
}

View File

@@ -0,0 +1,7 @@
package com.example.demo.exception;
public class ResourceNotFoundException extends RuntimeException {
public ResourceNotFoundException(String message) {
super(message);
}
}

View File

@@ -0,0 +1,62 @@
package com.example.demo.security;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
import java.util.List;
@Component
public class LearningJwtFilter extends OncePerRequestFilter {
private final LearningJwtUtil jwtUtil;
public LearningJwtFilter(LearningJwtUtil jwtUtil) {
this.jwtUtil = jwtUtil;
}
@Override
protected boolean shouldNotFilter(HttpServletRequest request) {
return !request.getRequestURI().startsWith("/api/secure/");
}
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
throws ServletException, IOException {
String auth = request.getHeader("Authorization");
if (!StringUtils.hasText(auth) || !auth.startsWith("Bearer ")) {
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
response.setContentType("application/json;charset=UTF-8");
response.getWriter().write("{\"code\":401,\"message\":\"缺少或非法 Authorization\"}");
return;
}
String token = auth.substring(7);
if (!jwtUtil.validate(token)) {
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
response.setContentType("application/json;charset=UTF-8");
response.getWriter().write("{\"code\":401,\"message\":\"Token 无效或过期\"}");
return;
}
String username = jwtUtil.username(token);
var authToken = new UsernamePasswordAuthenticationToken(
username,
null,
List.of(new SimpleGrantedAuthority("ROLE_USER"))
);
authToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(authToken);
filterChain.doFilter(request, response);
}
}

View File

@@ -0,0 +1,54 @@
package com.example.demo.security;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.security.Keys;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import javax.crypto.SecretKey;
import java.nio.charset.StandardCharsets;
import java.util.Date;
import java.util.Map;
@Component
public class LearningJwtUtil {
@Value("${learning.auth.jwt.secret}")
private String secret;
@Value("${learning.auth.jwt.expiration:86400000}")
private long expiration;
private SecretKey key() {
return Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8));
}
public String generateToken(String username) {
Date now = new Date();
return Jwts.builder()
.claims(Map.of("username", username))
.subject(username)
.issuedAt(now)
.expiration(new Date(now.getTime() + expiration))
.signWith(key(), Jwts.SIG.HS256)
.compact();
}
public boolean validate(String token) {
try {
parse(token);
return true;
} catch (Exception e) {
return false;
}
}
public String username(String token) {
return parse(token).getSubject();
}
private Claims parse(String token) {
return Jwts.parser().verifyWith(key()).build().parseSignedClaims(token).getPayload();
}
}

View File

@@ -0,0 +1,38 @@
package com.example.demo.security;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
@Configuration
@ConditionalOnProperty(name = "learning.auth.enabled", havingValue = "true", matchIfMissing = true)
public class LearningSecurityConfig {
private final LearningJwtFilter learningJwtFilter;
public LearningSecurityConfig(LearningJwtFilter learningJwtFilter) {
this.learningJwtFilter = learningJwtFilter;
}
@Bean
public SecurityFilterChain learningSecurityFilterChain(HttpSecurity http) throws Exception {
http
.csrf(AbstractHttpConfigurer::disable)
.sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
.requestMatchers(
"/", "/home", "/learn/**", "/aop/**", "/api/users/**", "/api/health",
"/api/auth/**", "/actuator/**", "/index.html", "/users.html", "/aop.html", "/events.html"
).permitAll()
.requestMatchers("/api/secure/**").authenticated()
.anyRequest().permitAll()
)
.addFilterBefore(learningJwtFilter, UsernamePasswordAuthenticationFilter.class);
return http.build();
}
}

View File

@@ -1,69 +1,112 @@
package com.example.demo.service;
import com.example.demo.dto.UserStatsResponse;
import com.example.demo.exception.DuplicateEmailException;
import com.example.demo.exception.ResourceNotFoundException;
import com.example.demo.model.User;
import org.springframework.stereotype.Service;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.CopyOnWriteArrayList;
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<User> users = new ArrayList<>();
private final List<User> users = new CopyOnWriteArrayList<>();
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));
users.add(new User(idGenerator.getAndIncrement(), "Alice Chen", "alice@example.com", 25));
users.add(new User(idGenerator.getAndIncrement(), "Brandon Li", "brandon@example.com", 30));
users.add(new User(idGenerator.getAndIncrement(), "Carol Wang", "carol@example.com", 28));
}
public List<User> findAll() {
return new ArrayList<>(users);
return users.stream()
.sorted((left, right) -> Long.compare(left.getId(), right.getId()))
.collect(Collectors.toList());
}
public User findById(Long id) {
return users.stream()
.filter(u -> u.getId().equals(id))
.filter(user -> user.getId().equals(id))
.findFirst()
.orElse(null);
.orElseThrow(() -> new ResourceNotFoundException("User not found: id=" + id));
}
public List<User> findByName(String name) {
public List<User> search(String keyword) {
if (keyword == null || keyword.isBlank()) {
return findAll();
}
String normalizedKeyword = keyword.trim().toLowerCase();
return users.stream()
.filter(u -> u.getName().contains(name))
.filter(user -> user.getName().toLowerCase().contains(normalizedKeyword)
|| user.getEmail().toLowerCase().contains(normalizedKeyword))
.sorted((left, right) -> Long.compare(left.getId(), right.getId()))
.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) {
ensureEmailAvailable(user.getEmail(), null);
User normalized = normalize(user, idGenerator.getAndIncrement());
users.add(normalized);
return normalized;
}
public User update(Long id, User user) {
findById(id);
ensureEmailAvailable(user.getEmail(), id);
User normalized = normalize(user, id);
for (int index = 0; index < users.size(); index++) {
if (users.get(index).getId().equals(id)) {
users.set(index, normalized);
return normalized;
}
}
return user;
throw new ResourceNotFoundException("User not found: id=" + id);
}
public void delete(Long id) {
users.removeIf(u -> u.getId().equals(id));
boolean removed = users.removeIf(user -> user.getId().equals(id));
if (!removed) {
throw new ResourceNotFoundException("User not found: id=" + id);
}
}
}
public UserStatsResponse getStats() {
long totalUsers = users.size();
long adults = users.stream().filter(user -> user.getAge() >= 18).count();
long underThirty = users.stream().filter(user -> user.getAge() < 30).count();
double averageAge = users.stream()
.mapToInt(User::getAge)
.average()
.orElse(0.0);
return new UserStatsResponse(totalUsers, adults, underThirty, averageAge);
}
private void ensureEmailAvailable(String email, Long currentUserId) {
String normalizedEmail = email == null ? "" : email.trim().toLowerCase();
boolean exists = users.stream()
.anyMatch(user -> user.getEmail().equalsIgnoreCase(normalizedEmail)
&& (currentUserId == null || !user.getId().equals(currentUserId)));
if (exists) {
throw new DuplicateEmailException("A user with email " + normalizedEmail + " already exists.");
}
}
private User normalize(User user, Long id) {
return new User(
id,
user.getName().trim(),
user.getEmail().trim().toLowerCase(),
user.getAge()
);
}
}

View File

@@ -1,2 +1,10 @@
server.port=8082
spring.application.name=springboot-demo
# 学习友好:默认只保护 /api/secure/**
learning.auth.enabled=true
learning.auth.jwt.secret=demo-learning-secret-key-demo-learning-secret-key
learning.auth.jwt.expiration=86400000
# 避免默认生成密码干扰学习输出
spring.autoconfigure.exclude=org.springframework.boot.autoconfigure.security.servlet.UserDetailsServiceAutoConfiguration

View File

@@ -28,6 +28,8 @@
.nav { margin-bottom: 20px; }
.nav a { margin-right: 15px; color: #6DB33F; text-decoration: none; }
.nav a:hover { text-decoration: underline; }
.lab { background:#fff7e6; border-left:4px solid #fa8c16; padding:15px; border-radius:8px; margin:15px 0; }
.lab h4 { color:#ad6800; margin-bottom:8px; }
</style>
</head>
<body>
@@ -38,11 +40,24 @@
</div>
<h1>🔪 AOP 切面编程</h1>
<div class="lab">
<h4>🧪 实验任务卡AOP</h4>
<label style="display:block;margin-bottom:8px;"><input id="aopTaskDone" type="checkbox" onchange="toggleAopTaskDone(this)"> 本任务我已经完成</label>
<ul style="padding-left:20px;line-height:1.8;">
<li>目标:观察同一请求如何触发 Before/After/Around 通知</li>
<li>步骤1调用用户接口 <code>/api/users</code></li>
<li>步骤2回到本页点击“刷新统计数据”</li>
<li>预期:统计里能看到 Controller/Service 方法耗时累积</li>
<li>常见坑:只看页面不看控制台,容易错过切面日志</li>
</ul>
</div>
<div class="card">
<h3>📊 实时性能统计</h3>
<p>AOP 自动统计所有 Controller 和 Service 方法的执行时间</p>
<button class="btn btn-primary" onclick="loadStats()">刷新统计数据</button>
<button class="btn btn-info" onclick="demoValidationError()">演示校验失败</button>
<div class="result-box" id="statsResult">点击按钮查看...</div>
</div>
@@ -167,11 +182,41 @@ execution(* com.example.demo.service.*.save*(..))
<p style="margin-top: 30px;"><a href="/">← 返回学习中心</a></p>
<script>
const AOP_TASK_KEY = 'task.aop.done';
function toggleAopTaskDone(el) {
localStorage.setItem(AOP_TASK_KEY, el.checked ? '1' : '0');
}
function initAopTaskState() {
const done = localStorage.getItem(AOP_TASK_KEY) === '1';
const checkbox = document.getElementById('aopTaskDone');
if (checkbox) checkbox.checked = done;
}
async function demoValidationError() {
const box = document.getElementById('statsResult');
box.textContent = '发送错误示例请求中...';
try {
const res = await fetch('/api/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: '', email: 'bad', age: 999 })
});
const data = await res.json();
box.textContent = JSON.stringify(data, null, 2);
} catch (e) {
box.textContent = '错误: ' + e.message;
}
}
async function loadStats() {
const res = await fetch('/aop/stats');
const data = await res.json();
document.getElementById('statsResult').textContent = JSON.stringify(data, null, 2);
}
initAopTaskState();
</script>
</body>
</html>

View File

@@ -30,6 +30,8 @@
.nav { margin-bottom: 20px; }
.nav a { margin-right: 15px; color: #6DB33F; text-decoration: none; }
.nav a:hover { text-decoration: underline; }
.lab { background:#fff7e6; border-left:4px solid #fa8c16; padding:15px; border-radius:8px; margin:15px 0; }
.lab h4 { color:#ad6800; margin-bottom:8px; }
</style>
</head>
<body>
@@ -40,6 +42,18 @@
</div>
<h1>📡 Spring 事件机制</h1>
<div class="lab">
<h4>🧪 实验任务卡(事件)</h4>
<label style="display:block;margin-bottom:8px;"><input id="eventTaskDone" type="checkbox" onchange="toggleEventTaskDone(this)"> 本任务我已经完成</label>
<ul style="padding-left:20px;line-height:1.8;">
<li>目标:体验发布者与监听者解耦</li>
<li>步骤1输入 userId/userName点击“发布登录事件”</li>
<li>步骤2重复发布不同用户比较返回结果</li>
<li>预期:接口立即返回;监听处理在日志中可观察</li>
<li>常见坑:把事件当同步 RPC忽略异步监听特性</li>
</ul>
</div>
<div class="card">
<h3>🎉 事件发布演示</h3>
@@ -48,6 +62,7 @@
<input type="text" id="userName" placeholder="用户名" value="张三" style="padding: 10px; border: 1px solid #ddd; border-radius: 4px; width: 150px;">
<input type="number" id="userId" placeholder="用户ID" value="1" style="padding: 10px; border: 1px solid #ddd; border-radius: 4px; width: 100px;">
<button class="btn btn-primary" onclick="publishEvent()">发布登录事件</button>
<button class="btn btn-warning" onclick="demoEventError()">演示参数错误</button>
</div>
<div class="result-box" id="eventResult">等待事件发布...</div>
</div>
@@ -187,6 +202,29 @@ public class AopEventController {
<p style="margin-top: 30px;"><a href="/">← 返回学习中心</a></p>
<script>
const EVENT_TASK_KEY = 'task.event.done';
function toggleEventTaskDone(el) {
localStorage.setItem(EVENT_TASK_KEY, el.checked ? '1' : '0');
}
function initEventTaskState() {
const done = localStorage.getItem(EVENT_TASK_KEY) === '1';
const checkbox = document.getElementById('eventTaskDone');
if (checkbox) checkbox.checked = done;
}
async function demoEventError() {
const resultBox = document.getElementById('eventResult');
try {
const res = await fetch('/aop/event/publish?userName=', { method: 'POST' });
const data = await res.json();
resultBox.textContent = JSON.stringify(data, null, 2);
} catch (e) {
resultBox.textContent = '错误: ' + e.message;
}
}
async function publishEvent() {
const userId = document.getElementById('userId').value;
const userName = document.getElementById('userName').value;
@@ -208,6 +246,8 @@ public class AopEventController {
document.getElementById('eventResult').textContent = '错误: ' + e.message;
}
}
initEventTaskState();
</script>
</body>
</html>

View File

@@ -1,221 +1,161 @@
<!DOCTYPE html>
<html lang="zh-CN">
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Spring Boot 学习中心</title>
<title>Spring Boot Learning Hub</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; max-width: 900px; margin: 0 auto; padding: 20px; background: #f5f5f5; }
h1 { color: #6DB33F; text-align: center; margin: 30px 0; font-size: 2.5em; }
h2 { color: #333; border-bottom: 3px solid #6DB33F; padding-bottom: 10px; margin: 20px 0 15px; }
.card { background: white; padding: 25px; margin: 15px 0; border-radius: 12px; box-shadow: 0 2px 8px rgba(0,0,0,0.1); }
.card h3 { color: #6DB33F; margin-bottom: 15px; font-size: 1.3em; }
.btn-group { display: flex; flex-wrap: wrap; gap: 10px; margin: 15px 0; }
.btn { display: inline-block; padding: 12px 24px; border-radius: 8px; text-decoration: none; font-weight: 500; transition: all 0.3s; }
.btn-primary { background: #6DB33F; color: white; }
.btn-primary:hover { background: #5da32f; transform: translateY(-2px); }
.btn-secondary { background: #333; color: white; }
.btn-secondary:hover { background: #444; }
.btn-info { background: #17a2b8; color: white; }
.btn-info:hover { background: #138496; }
.btn-warning { background: #ffc107; color: #333; }
.btn-warning:hover { background: #e0a800; }
code { background: #f0f0f0; padding: 2px 8px; border-radius: 4px; font-family: 'Fira Code', monospace; font-size: 14px; }
pre { background: #2d2d2d; color: #f8f8f2; padding: 20px; border-radius: 8px; overflow-x: auto; margin: 15px 0; }
pre code { background: none; color: inherit; }
ul { line-height: 2; padding-left: 20px; }
.feature-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); gap: 15px; }
.feature-item { background: #f8f9fa; padding: 20px; border-radius: 8px; border-left: 4px solid #6DB33F; transition: all 0.3s; }
.feature-item:hover { transform: translateY(-3px); box-shadow: 0 4px 12px rgba(0,0,0,0.1); }
.feature-item h4 { color: #333; margin-bottom: 8px; font-size: 1.1em; }
.feature-item p { color: #666; font-size: 14px; margin: 0; }
.feature-item a { color: inherit; text-decoration: none; }
.api-test { background: #f8f9fa; padding: 15px; margin: 10px 0; border-radius: 8px; }
.api-test input, .api-test select { padding: 10px; border: 1px solid #ddd; border-radius: 4px; margin: 5px; }
.api-test button { padding: 10px 20px; background: #6DB33F; color: white; border: none; border-radius: 4px; cursor: pointer; }
.api-test button:hover { background: #5da32f; }
#result { background: #2d2d2d; color: #f8f8f2; padding: 15px; border-radius: 8px; margin-top: 10px; white-space: pre-wrap; font-family: monospace; font-size: 14px; }
.footer { text-align: center; margin-top: 40px; padding: 20px; color: #666; border-top: 1px solid #ddd; }
.nav-links { display: flex; justify-content: center; gap: 15px; margin-bottom: 30px; }
.nav-links a { padding: 10px 20px; background: white; border-radius: 8px; text-decoration: none; color: #6DB33F; font-weight: 500; box-shadow: 0 2px 4px rgba(0,0,0,0.1); }
.nav-links a:hover { background: #6DB33F; color: white; }
.nav-links a.active { background: #6DB33F; color: white; }
:root {
--bg: radial-gradient(circle at top, #eef9f0 0%, #eff4ff 45%, #ffffff 100%);
--card: rgba(255,255,255,0.92);
--text: #132238;
--muted: #62788f;
--green: #3f8f2c;
--blue: #0f6db5;
--line: #dfebf6;
--shadow: 0 20px 48px rgba(16, 39, 74, 0.12);
}
* { box-sizing: border-box; }
body {
margin: 0;
font-family: "Segoe UI", "PingFang SC", sans-serif;
background: var(--bg);
color: var(--text);
}
.page {
max-width: 1120px;
margin: 0 auto;
padding: 32px 18px 48px;
}
.hero {
background: var(--card);
border: 1px solid rgba(255,255,255,0.8);
border-radius: 28px;
box-shadow: var(--shadow);
padding: 32px;
margin-bottom: 24px;
}
.eyebrow {
display: inline-flex;
padding: 8px 14px;
border-radius: 999px;
background: rgba(63, 143, 44, 0.12);
color: #2d6e20;
font-size: 12px;
font-weight: 700;
letter-spacing: .06em;
text-transform: uppercase;
}
h1 {
margin: 18px 0 12px;
font-size: clamp(36px, 6vw, 58px);
line-height: 1.04;
}
p {
color: var(--muted);
line-height: 1.7;
}
.hero-actions {
display: flex;
flex-wrap: wrap;
gap: 12px;
margin-top: 20px;
}
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 12px 18px;
border-radius: 14px;
font-weight: 700;
text-decoration: none;
}
.btn-primary { background: linear-gradient(135deg, var(--green), #57b83f); color: #fff; }
.btn-secondary { background: rgba(15, 109, 181, 0.08); color: var(--blue); }
.grid {
display: grid;
gap: 18px;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
}
.card {
background: var(--card);
border: 1px solid rgba(255,255,255,0.8);
border-radius: 24px;
box-shadow: var(--shadow);
padding: 24px;
}
.card h2 {
margin: 0 0 10px;
font-size: 24px;
}
.card code {
display: inline-block;
margin-top: 14px;
padding: 6px 10px;
border-radius: 10px;
background: rgba(15, 109, 181, 0.08);
color: var(--blue);
font-size: 13px;
}
.quick-links {
display: grid;
gap: 10px;
margin-top: 12px;
}
.quick-links a {
text-decoration: none;
color: var(--text);
background: rgba(255,255,255,0.8);
border: 1px solid var(--line);
border-radius: 16px;
padding: 14px 16px;
font-weight: 600;
}
</style>
</head>
<body>
<h1>🍃 Spring Boot 学习中心</h1>
<div class="nav-links">
<a href="/" class="active">首页</a>
<a href="/users.html">用户管理</a>
<a href="/aop.html">AOP 切面</a>
<a href="/events.html">事件机制</a>
</div>
<div class="card">
<h3>📚 学习模块</h3>
<div class="feature-grid">
<a href="/users.html" class="feature-item">
<h4>👥 用户管理</h4>
<p>RESTful API 设计、CRUD 操作、参数绑定</p>
</a>
<a href="/aop.html" class="feature-item">
<h4>🔪 AOP 切面编程</h4>
<p>日志记录、性能监控、限流控制</p>
</a>
<a href="/events.html" class="feature-item">
<h4>📡 事件机制</h4>
<p>发布/订阅模式、解耦业务逻辑</p>
</a>
<div class="page">
<section class="hero">
<span class="eyebrow">Spring Boot practice</span>
<h1>Explore web, validation, security, and AOP from one clean hub.</h1>
<p>
This project now highlights the most useful demos directly from the homepage so you can jump into the
user management flow, auth example, AOP pages, and health endpoints without decoding broken text first.
</p>
<div class="hero-actions">
<a class="btn btn-primary" href="/users.html">Open user management</a>
<a class="btn btn-secondary" href="/learn">View auth demo</a>
<a class="btn btn-secondary" href="/actuator/health">Check health</a>
</div>
</div>
<h2>🔗 快速链接</h2>
<div class="card">
<div class="btn-group">
<a class="btn btn-primary" href="/learn">API 接口列表</a>
<a class="btn btn-info" href="/api/users">用户 JSON</a>
<a class="btn btn-secondary" href="/actuator/health">健康检查</a>
</div>
</div>
<h2>🧪 接口测试</h2>
<div class="card">
<h3>GET 参数示例</h3>
<div class="api-test">
<input type="text" id="param-name" placeholder="姓名" value="张三">
<input type="number" id="param-age" placeholder="年龄" value="25">
<button onclick="testParams()">测试</button>
<div id="result-params"></div>
</div>
<p><code>GET /learn/params?name=xxx&age=18</code></p>
</div>
<div class="card">
<h3>路径变量示例</h3>
<div class="api-test">
<input type="text" id="path-id" placeholder="ID" value="123">
<button onclick="testPath()">测试</button>
<div id="result-path"></div>
</div>
<p><code>GET /learn/path/{id}</code></p>
</div>
<div class="card">
<h3>POST JSON 示例</h3>
<div class="api-test">
<input type="text" id="post-data" placeholder='JSON 数据' value='{"name":"test","value":123}' style="width: 300px;">
<button onclick="testPost()">测试</button>
<div id="result-post"></div>
</div>
<p><code>POST /learn/body</code></p>
</div>
<h2>📖 学习路径</h2>
<div class="card">
<h3>1. IOC 容器</h3>
<ul>
<li><code>@Component</code>, <code>@Service</code>, <code>@Repository</code>, <code>@Controller</code></li>
<li><code>@Autowired</code> 依赖注入</li>
<li><code>@Configuration</code> + <code>@Bean</code> 配置类</li>
</ul>
</div>
<div class="card">
<h3>2. Web 开发</h3>
<ul>
<li><code>@RestController</code> = <code>@Controller</code> + <code>@ResponseBody</code></li>
<li><code>@RequestMapping</code>, <code>@GetMapping</code>, <code>@PostMapping</code></li>
<li><code>@PathVariable</code>, <code>@RequestParam</code>, <code>@RequestBody</code></li>
</ul>
</div>
<div class="card">
<h3>3. AOP 切面编程</h3>
<pre><code>@Aspect
@Component
public class LoggingAspect {
@Before("execution(* com.example.*.*(..))")
public void logBefore(JoinPoint jp) {
System.out.println("方法调用: " + jp.getSignature());
}
}</code></pre>
</div>
<div class="card">
<h3>4. 事件机制</h3>
<pre><code>// 发布事件
@Autowired
ApplicationEventPublisher publisher;
publisher.publishEvent(new UserEvent(...));
</section>
// 监听事件
@EventListener
public void onEvent(UserEvent event) {
// 处理事件
}</code></pre>
</div>
<h2>📁 项目结构</h2>
<div class="card">
<pre><code>├── src/main/java/com/example/demo/
├── DemoApplication.java # 启动类
├── controller/ # 控制器层
├── LearnController.java # 学习示例
├── UserController.java # 用户 API
└── AopEventController.java
│ ├── service/ # 业务逻辑层
├── model/ # 实体类
├── aop/ # AOP 切面
│ ├── LoggingAspect.java
├── PerformanceAspect.java
└── RateLimitAspect.java
└── event/ # 事件机制
├── UserEventPublisher.java
└── UserEventListener.java
├── src/main/resources/
├── static/ # 静态资源
│ │ ├── index.html
│ │ ├── users.html
│ │ ├── aop.html
│ │ └── events.html
│ └── application.properties # 配置文件
└── pom.xml # Maven 配置</code></pre>
</div>
<div class="footer">
<p>🍃 Spring Boot 学习脚手架 | <a href="https://spring.io" style="color: #6DB33F;">Spring 官网</a></p>
</div>
<script>
async function testParams() {
const name = document.getElementById('param-name').value;
const age = document.getElementById('param-age').value;
const res = await fetch(`/learn/params?name=${encodeURIComponent(name)}&age=${age}`);
const data = await res.json();
document.getElementById('result-params').innerHTML = '<div id="result">' + JSON.stringify(data, null, 2) + '</div>';
}
async function testPath() {
const id = document.getElementById('path-id').value;
const res = await fetch(`/learn/path/${id}`);
const data = await res.json();
document.getElementById('result-path').innerHTML = '<div id="result">' + JSON.stringify(data, null, 2) + '</div>';
}
async function testPost() {
const data = document.getElementById('post-data').value;
const res = await fetch('/learn/body', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: data
});
const result = await res.json();
document.getElementById('result-post').innerHTML = '<div id="result">' + JSON.stringify(result, null, 2) + '</div>';
}
</script>
<section class="grid">
<article class="card">
<h2>User Management</h2>
<p>CRUD, validation, duplicate email protection, search, and stats cards in one interactive page.</p>
<code>/users.html</code>
</article>
<article class="card">
<h2>AOP Showcase</h2>
<p>Review logging, performance tracing, and rate limiting demonstrations built with Spring AOP.</p>
<code>/aop.html</code>
</article>
<article class="card">
<h2>Event Flow</h2>
<p>See how controller actions can publish events and decouple downstream reactions.</p>
<code>/events.html</code>
</article>
<article class="card">
<h2>Quick Links</h2>
<div class="quick-links">
<a href="/api/users">Open `/api/users`</a>
<a href="/api/users/stats">Open `/api/users/stats`</a>
<a href="/learn">Open `/learn`</a>
<a href="/actuator/health">Open `/actuator/health`</a>
</div>
</article>
</section>
</div>
</body>
</html>
</html>

View File

@@ -1,225 +1,466 @@
<!DOCTYPE html>
<html lang="zh-CN">
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>用户管理 - Spring Boot</title>
<title>User Management Lab</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; max-width: 900px; margin: 0 auto; padding: 20px; background: #f5f5f5; }
h1 { color: #6DB33F; margin: 20px 0; }
h2 { color: #333; margin: 20px 0 10px; }
.card { background: white; padding: 20px; margin: 15px 0; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); }
table { width: 100%; border-collapse: collapse; margin: 15px 0; }
th, td { border: 1px solid #ddd; padding: 12px; text-align: left; }
th { background: #6DB33F; color: white; }
tr:nth-child(even) { background: #f9f9f9; }
tr:hover { background: #f0f0f0; }
.btn { display: inline-block; padding: 8px 16px; border-radius: 4px; text-decoration: none; font-size: 14px; cursor: pointer; border: none; }
.btn-primary { background: #6DB33F; color: white; }
.btn-primary:hover { background: #5da32f; }
.btn-danger { background: #dc3545; color: white; }
.btn-danger:hover { background: #c82333; }
.btn-secondary { background: #6c757d; color: white; }
.btn-success { background: #28a745; color: white; }
.form-group { margin: 15px 0; }
.form-group label { display: block; margin-bottom: 5px; font-weight: bold; }
.form-group input { width: 100%; padding: 10px; border: 1px solid #ddd; border-radius: 4px; font-size: 14px; }
.modal { display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.5); }
.modal.active { display: flex; justify-content: center; align-items: center; }
.modal-content { background: white; padding: 30px; border-radius: 8px; width: 400px; }
.modal-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; }
.modal-header h3 { margin: 0; }
.close-btn { background: none; border: none; font-size: 24px; cursor: pointer; }
.btn-group { display: flex; gap: 10px; margin-top: 20px; }
.tip { background: #e7f3ff; padding: 15px; border-radius: 4px; margin: 15px 0; border-left: 4px solid #6DB33F; }
code { background: #f0f0f0; padding: 2px 6px; border-radius: 3px; }
pre { background: #2d2d2d; color: #f8f8f2; padding: 15px; border-radius: 4px; overflow-x: auto; }
:root {
--bg: linear-gradient(135deg, #f4fff2 0%, #eef6ff 100%);
--card: rgba(255, 255, 255, 0.94);
--line: #dde7f3;
--text: #123;
--muted: #5c7289;
--green: #3f8f2c;
--blue: #0f6db5;
--red: #d64545;
--shadow: 0 18px 45px rgba(17, 47, 80, 0.12);
}
* { box-sizing: border-box; }
body {
margin: 0;
font-family: "Segoe UI", "PingFang SC", sans-serif;
background: var(--bg);
color: var(--text);
}
.page {
max-width: 1120px;
margin: 0 auto;
padding: 28px 18px 48px;
}
.card {
background: var(--card);
border: 1px solid rgba(255,255,255,0.8);
border-radius: 24px;
box-shadow: var(--shadow);
backdrop-filter: blur(12px);
}
.hero {
display: grid;
gap: 18px;
grid-template-columns: minmax(0, 1.6fr) minmax(280px, 0.9fr);
margin-bottom: 22px;
}
.hero-main,
.hero-side,
.table-card,
.tool-card {
padding: 24px;
}
.eyebrow {
display: inline-flex;
padding: 8px 14px;
border-radius: 999px;
background: rgba(63, 143, 44, 0.12);
color: #2d6e20;
font-size: 12px;
font-weight: 700;
letter-spacing: .06em;
text-transform: uppercase;
}
h1 {
margin: 18px 0 12px;
font-size: clamp(32px, 5vw, 50px);
line-height: 1.05;
}
h2, h3 { margin: 0; }
p { color: var(--muted); line-height: 1.7; }
.hero-actions,
.toolbar,
.modal-actions {
display: flex;
gap: 10px;
flex-wrap: wrap;
}
.btn {
border: 0;
border-radius: 14px;
padding: 12px 18px;
font-weight: 700;
cursor: pointer;
}
.btn-primary { background: linear-gradient(135deg, var(--green), #59b83f); color: #fff; }
.btn-secondary { background: rgba(15, 109, 181, 0.08); color: var(--blue); }
.btn-danger { background: rgba(214, 69, 69, 0.12); color: var(--red); }
.stats {
display: grid;
gap: 16px;
grid-template-columns: repeat(4, minmax(0, 1fr));
margin-bottom: 22px;
}
.stat {
padding: 18px 20px;
}
.stat small {
color: var(--muted);
font-size: 12px;
text-transform: uppercase;
letter-spacing: .08em;
}
.stat strong {
display: block;
margin-top: 10px;
font-size: 34px;
}
.workspace {
display: grid;
gap: 18px;
grid-template-columns: minmax(0, 1.45fr) minmax(280px, 0.8fr);
}
.section-head {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
margin-bottom: 16px;
}
.pill {
display: inline-flex;
align-items: center;
padding: 6px 12px;
border-radius: 999px;
background: rgba(63, 143, 44, 0.1);
color: #2d6e20;
font-size: 12px;
font-weight: 700;
}
table {
width: 100%;
border-collapse: collapse;
}
th, td {
padding: 13px 12px;
border-bottom: 1px solid var(--line);
text-align: left;
}
th {
color: var(--muted);
font-size: 12px;
text-transform: uppercase;
letter-spacing: .08em;
}
tr:last-child td { border-bottom: 0; }
.segment {
display: inline-flex;
padding: 5px 10px;
border-radius: 999px;
background: rgba(15, 109, 181, 0.08);
color: var(--blue);
font-size: 12px;
font-weight: 700;
}
label {
display: grid;
gap: 8px;
font-size: 14px;
font-weight: 700;
}
input {
width: 100%;
padding: 12px 14px;
border-radius: 14px;
border: 1px solid var(--line);
font: inherit;
}
input:focus {
outline: 2px solid rgba(15, 109, 181, 0.18);
border-color: rgba(15, 109, 181, 0.45);
}
.status {
min-height: 22px;
margin-top: 12px;
color: var(--muted);
font-size: 14px;
}
.status.error { color: var(--red); }
.status.success { color: #2d6e20; }
.empty {
border: 1px dashed var(--line);
border-radius: 18px;
text-align: center;
padding: 28px;
color: var(--muted);
}
.modal {
position: fixed;
inset: 0;
display: none;
justify-content: center;
align-items: center;
background: rgba(13, 26, 46, 0.5);
padding: 18px;
}
.modal.active { display: flex; }
.modal-card {
width: min(100%, 460px);
padding: 24px;
}
.close {
border: 0;
background: transparent;
font-size: 24px;
color: var(--muted);
cursor: pointer;
}
@media (max-width: 920px) {
.hero, .stats, .workspace { grid-template-columns: 1fr; }
}
</style>
</head>
<body>
<h1>👥 用户管理 - RESTful API 示例</h1>
<div class="card">
<div style="display: flex; justify-content: space-between; align-items: center;">
<h2>用户列表</h2>
<button class="btn btn-primary" onclick="openModal()">+ 添加用户</button>
</div>
<table id="userTable">
<thead>
<tr>
<th>ID</th>
<th>姓名</th>
<th>邮箱</th>
<th>年龄</th>
<th>操作</th>
</tr>
</thead>
<tbody></tbody>
</table>
</div>
<div class="card">
<h2>📖 学习要点</h2>
<div class="tip">
<strong>RESTful API 设计:</strong>
<ul style="margin-top: 10px; padding-left: 20px;">
<li><code>GET /api/users</code> - 获取所有用户</li>
<li><code>GET /api/users/{id}</code> - 获取单个用户</li>
<li><code>POST /api/users</code> - 创建用户</li>
<li><code>PUT /api/users/{id}</code> - 更新用户</li>
<li><code>DELETE /api/users/{id}</code> - 删除用户</li>
</ul>
</div>
<h3>Controller 代码示例</h3>
<pre><code>@RestController
@RequestMapping("/api/users")
public class UserController {
@GetMapping
public List&lt;User&gt; getAllUsers() { ... }
@GetMapping("/{id}")
public User getUserById(@PathVariable Long id) { ... }
@PostMapping
public User createUser(@RequestBody User user) { ... }
@PutMapping("/{id}")
public User updateUser(@PathVariable Long id, @RequestBody User user) { ... }
@DeleteMapping("/{id}")
public String deleteUser(@PathVariable Long id) { ... }
}</code></pre>
</div>
<div class="card">
<h2>🔧 Spring 注解说明</h2>
<table>
<tr><th>注解</th><th>说明</th></tr>
<tr><td><code>@RestController</code></td><td>= @Controller + @ResponseBody</td></tr>
<tr><td><code>@RequestMapping</code></td><td>定义路由映射</td></tr>
<tr><td><code>@GetMapping</code></td><td>GET 请求映射</td></tr>
<tr><td><code>@PostMapping</code></td><td>POST 请求映射</td></tr>
<tr><td><code>@PathVariable</code></td><td>获取路径变量</td></tr>
<tr><td><code>@RequestBody</code></td><td>获取请求体 JSON</td></tr>
<tr><td><code>@RequestParam</code></td><td>获取查询参数</td></tr>
</table>
</div>
<p><a href="/">← 返回学习中心</a></p>
<!-- 添加/编辑用户模态框 -->
<div class="modal" id="userModal">
<div class="modal-content">
<div class="modal-header">
<h3 id="modalTitle">添加用户</h3>
<button class="close-btn" onclick="closeModal()">&times;</button>
<div class="page">
<section class="hero">
<div class="card hero-main">
<span class="eyebrow">Spring Boot demo</span>
<h1>User management that feels production-ready.</h1>
<p>This page now uses backend stats, keyword search, and clear API error handling instead of a static CRUD example.</p>
<div class="hero-actions">
<button class="btn btn-primary" onclick="openCreateModal()">Create user</button>
<button class="btn btn-secondary" onclick="refreshDashboard()">Refresh</button>
<button class="btn btn-secondary" onclick="window.location.href='/'">Back home</button>
</div>
<form id="userForm">
<input type="hidden" id="userId">
<div class="form-group">
<label>姓名</label>
<input type="text" id="userName" required>
</div>
<div class="card hero-side">
<h3>Highlights</h3>
<p>Duplicate emails return a `409`. Validation errors are displayed inline. Search matches both names and emails.</p>
</div>
</section>
<section class="stats">
<div class="card stat"><small>Total users</small><strong id="totalUsers">0</strong></div>
<div class="card stat"><small>Adults</small><strong id="adultUsers">0</strong></div>
<div class="card stat"><small>Under 30</small><strong id="youngUsers">0</strong></div>
<div class="card stat"><small>Average age</small><strong id="averageAge">0.0</strong></div>
</section>
<section class="workspace">
<div class="card table-card">
<div class="section-head">
<div>
<h2>User directory</h2>
<p>Search results stay in sync with the API.</p>
</div>
<div class="form-group">
<label>邮箱</label>
<input type="email" id="userEmail" required>
<span class="pill" id="resultCount">0 users</span>
</div>
<div id="tableArea"></div>
</div>
<div class="card tool-card">
<div class="section-head">
<div>
<h2>Search</h2>
<p>Use a keyword to match names or emails.</p>
</div>
<div class="form-group">
<label>年龄</label>
<input type="number" id="userAge" required>
</div>
<div class="btn-group">
<button type="submit" class="btn btn-primary">保存</button>
<button type="button" class="btn btn-secondary" onclick="closeModal()">取消</button>
</div>
<form onsubmit="searchUsers(event)">
<label>
Keyword
<input id="searchInput" type="text" placeholder="Try: alice or example.com">
</label>
<div class="toolbar" style="margin-top:14px;">
<button class="btn btn-primary" type="submit">Search</button>
<button class="btn btn-secondary" type="button" onclick="clearSearch()">Clear</button>
</div>
</form>
<p>The frontend now explains server-side validation and duplicate email errors instead of silently failing.</p>
<div class="status" id="pageStatus"></div>
</div>
</section>
</div>
<div class="modal" id="userModal">
<div class="card modal-card">
<div class="section-head">
<div>
<h2 id="modalTitle">Create user</h2>
<p id="modalSubtitle">Add a new user to the in-memory store.</p>
</div>
<button class="close" onclick="closeModal()" aria-label="Close">&times;</button>
</div>
<form id="userForm">
<input type="hidden" id="userId">
<label>Name<input id="userName" maxlength="40" required></label>
<label>Email<input id="userEmail" type="email" maxlength="80" required></label>
<label>Age<input id="userAge" type="number" min="1" max="120" required></label>
<div class="status" id="formStatus"></div>
<div class="modal-actions">
<button class="btn btn-primary" type="submit">Save</button>
<button class="btn btn-secondary" type="button" onclick="closeModal()">Cancel</button>
</div>
</form>
</div>
<script>
// 加载用户列表
async function loadUsers() {
const res = await fetch('/api/users');
const users = await res.json();
const tbody = document.querySelector('#userTable tbody');
tbody.innerHTML = users.map(u => `
<tr>
<td>${u.id}</td>
<td>${u.name}</td>
<td>${u.email}</td>
<td>${u.age}</td>
<td>
<button class="btn btn-primary" onclick="editUser(${u.id}, '${u.name}', '${u.email}', ${u.age})">编辑</button>
<button class="btn btn-danger" onclick="deleteUser(${u.id})">删除</button>
</td>
</tr>
`).join('');
</div>
<script>
const state = { users: [] };
async function request(url, options) {
const response = await fetch(url, options);
const payload = await response.json();
if (!response.ok || payload.code !== 0) {
const error = new Error(payload.message || 'Request failed');
error.details = payload.data;
throw error;
}
// 打开模态框
function openModal() {
document.getElementById('userModal').classList.add('active');
document.getElementById('modalTitle').textContent = '添加用户';
document.getElementById('userForm').reset();
document.getElementById('userId').value = '';
return payload;
}
function setStatus(id, message, type = '') {
const el = document.getElementById(id);
el.textContent = message;
el.className = type ? `status ${type}` : 'status';
}
function renderStats(stats) {
document.getElementById('totalUsers').textContent = stats.totalUsers;
document.getElementById('adultUsers').textContent = stats.adults;
document.getElementById('youngUsers').textContent = stats.underThirty;
document.getElementById('averageAge').textContent = Number(stats.averageAge).toFixed(1);
}
function renderTable() {
const area = document.getElementById('tableArea');
document.getElementById('resultCount').textContent = `${state.users.length} users`;
if (!state.users.length) {
area.innerHTML = '<div class="empty">No users matched the current query.</div>';
return;
}
// 关闭模态框
function closeModal() {
document.getElementById('userModal').classList.remove('active');
area.innerHTML = `
<table>
<thead>
<tr><th>ID</th><th>Name</th><th>Email</th><th>Age</th><th>Segment</th><th>Actions</th></tr>
</thead>
<tbody>
${state.users.map((user) => `
<tr>
<td>${user.id}</td>
<td>${user.name}</td>
<td>${user.email}</td>
<td>${user.age}</td>
<td><span class="segment">${user.age >= 18 ? 'Adult' : 'Minor'}</span></td>
<td>
<button class="btn btn-secondary" onclick="openEditModal(${user.id})">Edit</button>
<button class="btn btn-danger" onclick="removeUser(${user.id})">Delete</button>
</td>
</tr>
`).join('')}
</tbody>
</table>
`;
}
function openCreateModal() {
document.getElementById('userForm').reset();
document.getElementById('userId').value = '';
document.getElementById('modalTitle').textContent = 'Create user';
document.getElementById('modalSubtitle').textContent = 'Add a new user to the in-memory store.';
setStatus('formStatus', '');
document.getElementById('userModal').classList.add('active');
}
function openEditModal(id) {
const user = state.users.find((item) => item.id === id);
if (!user) return;
document.getElementById('userId').value = user.id;
document.getElementById('userName').value = user.name;
document.getElementById('userEmail').value = user.email;
document.getElementById('userAge').value = user.age;
document.getElementById('modalTitle').textContent = 'Edit user';
document.getElementById('modalSubtitle').textContent = 'Update the user and keep the email unique.';
setStatus('formStatus', '');
document.getElementById('userModal').classList.add('active');
}
function closeModal() {
document.getElementById('userModal').classList.remove('active');
}
async function refreshDashboard(message = 'Dashboard updated.', type = 'success') {
try {
const [usersPayload, statsPayload] = await Promise.all([
request('/api/users'),
request('/api/users/stats')
]);
state.users = usersPayload.data;
renderStats(statsPayload.data);
renderTable();
setStatus('pageStatus', message, type);
} catch (error) {
setStatus('pageStatus', error.message, 'error');
}
// 编辑用户
function editUser(id, name, email, age) {
document.getElementById('userModal').classList.add('active');
document.getElementById('modalTitle').textContent = '编辑用户';
document.getElementById('userId').value = id;
document.getElementById('userName').value = name;
document.getElementById('userEmail').value = email;
document.getElementById('userAge').value = age;
}
async function searchUsers(event) {
event.preventDefault();
const keyword = document.getElementById('searchInput').value.trim();
try {
if (!keyword) {
await refreshDashboard('Showing all users.', 'success');
return;
}
const payload = await request(`/api/users/search?keyword=${encodeURIComponent(keyword)}`);
state.users = payload.data;
renderTable();
setStatus('pageStatus', `Search completed for "${keyword}".`, 'success');
} catch (error) {
setStatus('pageStatus', error.message, 'error');
}
// 保存用户
document.getElementById('userForm').addEventListener('submit', async (e) => {
e.preventDefault();
const id = document.getElementById('userId').value;
const user = {
name: document.getElementById('userName').value,
email: document.getElementById('userEmail').value,
age: parseInt(document.getElementById('userAge').value)
};
}
async function clearSearch() {
document.getElementById('searchInput').value = '';
await refreshDashboard('Showing all users.', 'success');
}
async function removeUser(id) {
if (!confirm(`Delete user #${id}?`)) return;
try {
await request(`/api/users/${id}`, { method: 'DELETE' });
await refreshDashboard('User deleted successfully.', 'success');
} catch (error) {
setStatus('pageStatus', error.message, 'error');
}
}
document.getElementById('userForm').addEventListener('submit', async (event) => {
event.preventDefault();
const id = document.getElementById('userId').value;
const payload = {
name: document.getElementById('userName').value,
email: document.getElementById('userEmail').value,
age: Number(document.getElementById('userAge').value)
};
try {
if (id) {
await fetch(`/api/users/${id}`, {
await request(`/api/users/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ...user, id: parseInt(id) })
body: JSON.stringify(payload)
});
closeModal();
await refreshDashboard('User updated successfully.', 'success');
} else {
await fetch('/api/users', {
await request('/api/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(user)
body: JSON.stringify(payload)
});
closeModal();
await refreshDashboard('User created successfully.', 'success');
}
closeModal();
loadUsers();
});
// 删除用户
async function deleteUser(id) {
if (confirm('确定删除此用户?')) {
await fetch(`/api/users/${id}`, { method: 'DELETE' });
loadUsers();
} catch (error) {
if (error.details && typeof error.details === 'object') {
const firstIssue = Object.values(error.details)[0];
setStatus('formStatus', String(firstIssue), 'error');
} else {
setStatus('formStatus', error.message, 'error');
}
}
// 初始化
loadUsers();
</script>
});
refreshDashboard('Latest users loaded.', 'success');
</script>
</body>
</html>
</html>

View File

@@ -0,0 +1,54 @@
package com.example.demo.controller;
import com.fasterxml.jackson.databind.ObjectMapper;
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 java.util.Map;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@SpringBootTest
@AutoConfigureMockMvc
class AuthFlowTest {
@Autowired
private MockMvc mockMvc;
@Autowired
private ObjectMapper objectMapper;
@Test
void secureEndpointShouldRejectWithoutToken() throws Exception {
mockMvc.perform(get("/api/secure/me"))
.andExpect(status().isUnauthorized())
.andExpect(jsonPath("$.code").value(401));
}
@Test
void shouldAccessSecureEndpointWithValidToken() throws Exception {
String loginReq = objectMapper.writeValueAsString(Map.of("username", "admin", "password", "admin123"));
String loginResp = mockMvc.perform(post("/api/auth/login")
.contentType(MediaType.APPLICATION_JSON)
.content(loginReq))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0))
.andReturn().getResponse().getContentAsString();
String token = objectMapper.readTree(loginResp).path("data").path("token").asText();
mockMvc.perform(get("/api/secure/me")
.header("Authorization", "Bearer " + token))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data.principal").value("admin"));
}
}

View File

@@ -0,0 +1,91 @@
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.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@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": "Demo User",
"email": "demo-user@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("Demo User"));
}
@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));
}
@Test
void shouldRejectDuplicateEmail() throws Exception {
String json = """
{
"name": "Another User",
"email": "alice@example.com",
"age": 29
}
""";
mockMvc.perform(post("/api/users")
.contentType(MediaType.APPLICATION_JSON)
.content(json))
.andExpect(status().isConflict())
.andExpect(jsonPath("$.code").value(409));
}
@Test
void shouldExposeUserStats() throws Exception {
mockMvc.perform(get("/api/users/stats"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data.totalUsers").isNumber())
.andExpect(jsonPath("$.data.adults").isNumber())
.andExpect(jsonPath("$.data.averageAge").isNumber());
}
}

View File

@@ -1,2 +1,10 @@
server.port=8082
spring.application.name=springboot-demo
# 学习友好:默认只保护 /api/secure/**
learning.auth.enabled=true
learning.auth.jwt.secret=demo-learning-secret-key-demo-learning-secret-key
learning.auth.jwt.expiration=86400000
# 避免默认生成密码干扰学习输出
spring.autoconfigure.exclude=org.springframework.boot.autoconfigure.security.servlet.UserDetailsServiceAutoConfiguration

Binary file not shown.

View File

@@ -28,6 +28,8 @@
.nav { margin-bottom: 20px; }
.nav a { margin-right: 15px; color: #6DB33F; text-decoration: none; }
.nav a:hover { text-decoration: underline; }
.lab { background:#fff7e6; border-left:4px solid #fa8c16; padding:15px; border-radius:8px; margin:15px 0; }
.lab h4 { color:#ad6800; margin-bottom:8px; }
</style>
</head>
<body>
@@ -38,11 +40,24 @@
</div>
<h1>🔪 AOP 切面编程</h1>
<div class="lab">
<h4>🧪 实验任务卡AOP</h4>
<label style="display:block;margin-bottom:8px;"><input id="aopTaskDone" type="checkbox" onchange="toggleAopTaskDone(this)"> 本任务我已经完成</label>
<ul style="padding-left:20px;line-height:1.8;">
<li>目标:观察同一请求如何触发 Before/After/Around 通知</li>
<li>步骤1调用用户接口 <code>/api/users</code></li>
<li>步骤2回到本页点击“刷新统计数据”</li>
<li>预期:统计里能看到 Controller/Service 方法耗时累积</li>
<li>常见坑:只看页面不看控制台,容易错过切面日志</li>
</ul>
</div>
<div class="card">
<h3>📊 实时性能统计</h3>
<p>AOP 自动统计所有 Controller 和 Service 方法的执行时间</p>
<button class="btn btn-primary" onclick="loadStats()">刷新统计数据</button>
<button class="btn btn-info" onclick="demoValidationError()">演示校验失败</button>
<div class="result-box" id="statsResult">点击按钮查看...</div>
</div>
@@ -167,11 +182,41 @@ execution(* com.example.demo.service.*.save*(..))
<p style="margin-top: 30px;"><a href="/">← 返回学习中心</a></p>
<script>
const AOP_TASK_KEY = 'task.aop.done';
function toggleAopTaskDone(el) {
localStorage.setItem(AOP_TASK_KEY, el.checked ? '1' : '0');
}
function initAopTaskState() {
const done = localStorage.getItem(AOP_TASK_KEY) === '1';
const checkbox = document.getElementById('aopTaskDone');
if (checkbox) checkbox.checked = done;
}
async function demoValidationError() {
const box = document.getElementById('statsResult');
box.textContent = '发送错误示例请求中...';
try {
const res = await fetch('/api/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: '', email: 'bad', age: 999 })
});
const data = await res.json();
box.textContent = JSON.stringify(data, null, 2);
} catch (e) {
box.textContent = '错误: ' + e.message;
}
}
async function loadStats() {
const res = await fetch('/aop/stats');
const data = await res.json();
document.getElementById('statsResult').textContent = JSON.stringify(data, null, 2);
}
initAopTaskState();
</script>
</body>
</html>

View File

@@ -30,6 +30,8 @@
.nav { margin-bottom: 20px; }
.nav a { margin-right: 15px; color: #6DB33F; text-decoration: none; }
.nav a:hover { text-decoration: underline; }
.lab { background:#fff7e6; border-left:4px solid #fa8c16; padding:15px; border-radius:8px; margin:15px 0; }
.lab h4 { color:#ad6800; margin-bottom:8px; }
</style>
</head>
<body>
@@ -40,6 +42,18 @@
</div>
<h1>📡 Spring 事件机制</h1>
<div class="lab">
<h4>🧪 实验任务卡(事件)</h4>
<label style="display:block;margin-bottom:8px;"><input id="eventTaskDone" type="checkbox" onchange="toggleEventTaskDone(this)"> 本任务我已经完成</label>
<ul style="padding-left:20px;line-height:1.8;">
<li>目标:体验发布者与监听者解耦</li>
<li>步骤1输入 userId/userName点击“发布登录事件”</li>
<li>步骤2重复发布不同用户比较返回结果</li>
<li>预期:接口立即返回;监听处理在日志中可观察</li>
<li>常见坑:把事件当同步 RPC忽略异步监听特性</li>
</ul>
</div>
<div class="card">
<h3>🎉 事件发布演示</h3>
@@ -48,6 +62,7 @@
<input type="text" id="userName" placeholder="用户名" value="张三" style="padding: 10px; border: 1px solid #ddd; border-radius: 4px; width: 150px;">
<input type="number" id="userId" placeholder="用户ID" value="1" style="padding: 10px; border: 1px solid #ddd; border-radius: 4px; width: 100px;">
<button class="btn btn-primary" onclick="publishEvent()">发布登录事件</button>
<button class="btn btn-warning" onclick="demoEventError()">演示参数错误</button>
</div>
<div class="result-box" id="eventResult">等待事件发布...</div>
</div>
@@ -187,6 +202,29 @@ public class AopEventController {
<p style="margin-top: 30px;"><a href="/">← 返回学习中心</a></p>
<script>
const EVENT_TASK_KEY = 'task.event.done';
function toggleEventTaskDone(el) {
localStorage.setItem(EVENT_TASK_KEY, el.checked ? '1' : '0');
}
function initEventTaskState() {
const done = localStorage.getItem(EVENT_TASK_KEY) === '1';
const checkbox = document.getElementById('eventTaskDone');
if (checkbox) checkbox.checked = done;
}
async function demoEventError() {
const resultBox = document.getElementById('eventResult');
try {
const res = await fetch('/aop/event/publish?userName=', { method: 'POST' });
const data = await res.json();
resultBox.textContent = JSON.stringify(data, null, 2);
} catch (e) {
resultBox.textContent = '错误: ' + e.message;
}
}
async function publishEvent() {
const userId = document.getElementById('userId').value;
const userName = document.getElementById('userName').value;
@@ -208,6 +246,8 @@ public class AopEventController {
document.getElementById('eventResult').textContent = '错误: ' + e.message;
}
}
initEventTaskState();
</script>
</body>
</html>

View File

@@ -68,6 +68,10 @@
<h4>📡 事件机制</h4>
<p>发布/订阅模式、解耦业务逻辑</p>
</a>
<a href="/learn" class="feature-item">
<h4>🔐 鉴权演示(学习用)</h4>
<p>最小 JWT 流程:登录、携带 Token、访问受保护接口</p>
</a>
</div>
</div>

View File

@@ -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 => `
<tr>
@@ -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', {

Binary file not shown.

View File

@@ -1,3 +0,0 @@
artifactId=demo
groupId=com.example
version=0.0.1-SNAPSHOT

View File

@@ -1,15 +1,25 @@
com/example/demo/controller/auth/SecureDemoController.class
com/example/demo/exception/ResourceNotFoundException.class
com/example/demo/event/UserEventPublisher.class
com/example/demo/exception/GlobalExceptionHandler.class
com/example/demo/dto/auth/LoginRequest.class
com/example/demo/DemoApplication.class
com/example/demo/controller/PageController.class
com/example/demo/dto/UserRequest.class
com/example/demo/security/LearningJwtUtil.class
com/example/demo/security/LearningSecurityConfig.class
com/example/demo/model/UserEvent$Type.class
com/example/demo/model/User.class
com/example/demo/service/UserService.class
com/example/demo/aop/PerformanceAspect.class
com/example/demo/aop/RateLimited.class
com/example/demo/model/UserEvent.class
com/example/demo/aop/LoggingAspect.class
com/example/demo/event/UserEventPublisher.class
com/example/demo/controller/auth/LearningAuthController.class
com/example/demo/common/ApiResponse.class
com/example/demo/controller/AopEventController.class
com/example/demo/controller/LearnController.class
com/example/demo/DemoApplication.class
com/example/demo/event/UserEventListener.class
com/example/demo/controller/PageController.class
com/example/demo/controller/UserController.class
com/example/demo/model/UserEvent$Type.class
com/example/demo/model/User.class
com/example/demo/aop/RateLimitAspect.class
com/example/demo/service/UserService.class
com/example/demo/aop/PerformanceAspect.class
com/example/demo/security/LearningJwtFilter.class

View File

@@ -1,14 +1,24 @@
/home/llm/projects/springboot-demo/src/main/java/com/example/demo/security/LearningSecurityConfig.java
/home/llm/projects/springboot-demo/src/main/java/com/example/demo/controller/auth/SecureDemoController.java
/home/llm/projects/springboot-demo/src/main/java/com/example/demo/controller/UserController.java
/home/llm/projects/springboot-demo/src/main/java/com/example/demo/model/User.java
/home/llm/projects/springboot-demo/src/main/java/com/example/demo/aop/LoggingAspect.java
/home/llm/projects/springboot-demo/src/main/java/com/example/demo/security/LearningJwtUtil.java
/home/llm/projects/springboot-demo/src/main/java/com/example/demo/controller/PageController.java
/home/llm/projects/springboot-demo/src/main/java/com/example/demo/aop/RateLimited.java
/home/llm/projects/springboot-demo/src/main/java/com/example/demo/aop/PerformanceAspect.java
/home/llm/projects/springboot-demo/src/main/java/com/example/demo/service/UserService.java
/home/llm/projects/springboot-demo/src/main/java/com/example/demo/exception/ResourceNotFoundException.java
/home/llm/projects/springboot-demo/src/main/java/com/example/demo/controller/AopEventController.java
/home/llm/projects/springboot-demo/src/main/java/com/example/demo/exception/GlobalExceptionHandler.java
/home/llm/projects/springboot-demo/src/main/java/com/example/demo/event/UserEventPublisher.java
/home/llm/projects/springboot-demo/src/main/java/com/example/demo/dto/auth/LoginRequest.java
/home/llm/projects/springboot-demo/src/main/java/com/example/demo/dto/UserRequest.java
/home/llm/projects/springboot-demo/src/main/java/com/example/demo/common/ApiResponse.java
/home/llm/projects/springboot-demo/src/main/java/com/example/demo/security/LearningJwtFilter.java
/home/llm/projects/springboot-demo/src/main/java/com/example/demo/DemoApplication.java
/home/llm/projects/springboot-demo/src/main/java/com/example/demo/event/UserEventListener.java
/home/llm/projects/springboot-demo/src/main/java/com/example/demo/controller/LearnController.java
/home/llm/projects/springboot-demo/src/main/java/com/example/demo/controller/auth/LearningAuthController.java
/home/llm/projects/springboot-demo/src/main/java/com/example/demo/model/UserEvent.java
/home/llm/projects/springboot-demo/src/main/java/com/example/demo/aop/RateLimitAspect.java

View File

@@ -0,0 +1,2 @@
com/example/demo/controller/UserControllerTest.class
com/example/demo/controller/AuthFlowTest.class

View File

@@ -0,0 +1,2 @@
/home/llm/projects/springboot-demo/src/test/java/com/example/demo/controller/AuthFlowTest.java
/home/llm/projects/springboot-demo/src/test/java/com/example/demo/controller/UserControllerTest.java

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,4 @@
-------------------------------------------------------------------------------
Test set: com.example.demo.controller.AuthFlowTest
-------------------------------------------------------------------------------
Tests run: 2, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.666 s -- in com.example.demo.controller.AuthFlowTest

View File

@@ -0,0 +1,4 @@
-------------------------------------------------------------------------------
Test set: com.example.demo.controller.UserControllerTest
-------------------------------------------------------------------------------
Tests run: 3, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 24.99 s -- in com.example.demo.controller.UserControllerTest