diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..498c250 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +target/surefire-reports/*.dumpstream diff --git a/src/main/java/com/example/demo/common/ApiResponse.java b/src/main/java/com/example/demo/common/ApiResponse.java index a38e218..56315f2 100644 --- a/src/main/java/com/example/demo/common/ApiResponse.java +++ b/src/main/java/com/example/demo/common/ApiResponse.java @@ -16,7 +16,7 @@ public record ApiResponse( return new ApiResponse<>(0, message, data, Instant.now()); } - public static ApiResponse fail(int code, String message) { + 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/AdvancedLabController.java b/src/main/java/com/example/demo/controller/AdvancedLabController.java new file mode 100644 index 0000000..8093f49 --- /dev/null +++ b/src/main/java/com/example/demo/controller/AdvancedLabController.java @@ -0,0 +1,180 @@ +package com.example.demo.controller; + +import com.example.demo.common.ApiResponse; +import com.example.demo.security.LearningJwtUtil; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.method.HandlerMethod; +import org.springframework.web.servlet.mvc.method.RequestMappingInfo; +import org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping; + +import java.lang.annotation.Annotation; +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.time.Duration; +import java.time.Instant; +import java.util.Arrays; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.atomic.AtomicInteger; + +@RestController +@RequestMapping("/api/lab") +public class AdvancedLabController { + + private final RequestMappingHandlerMapping handlerMapping; + private final LearningJwtUtil jwtUtil; + + public AdvancedLabController( + @Qualifier("requestMappingHandlerMapping") RequestMappingHandlerMapping handlerMapping, + LearningJwtUtil jwtUtil + ) { + this.handlerMapping = handlerMapping; + this.jwtUtil = jwtUtil; + } + + @GetMapping("/reflection/routes") + public ApiResponse> reflectionRoutes() { + List> routes = handlerMapping.getHandlerMethods().entrySet().stream() + .map(this::toRouteView) + .sorted((left, right) -> left.get("path").toString().compareTo(right.get("path").toString())) + .toList(); + + List> classSummaries = List.of( + summarizeClass(UserController.class), + summarizeClass(LearnController.class), + summarizeClass(AopEventController.class), + summarizeClass(com.example.demo.controller.auth.LearningAuthController.class) + ); + + return ApiResponse.ok(Map.of( + "routeCount", routes.size(), + "routes", routes, + "classSummaries", classSummaries, + "tip", "Use reflection to connect annotations, method signatures, and runtime route registration." + )); + } + + @GetMapping("/reflection/user-model") + public ApiResponse> reflectUserModel() { + List> fields = Arrays.stream(com.example.demo.model.User.class.getDeclaredFields()) + .map(this::toFieldView) + .toList(); + + return ApiResponse.ok(Map.of( + "className", com.example.demo.model.User.class.getName(), + "fieldCount", fields.size(), + "fields", fields, + "tip", "Reflection makes frameworks possible because metadata can be discovered at runtime." + )); + } + + @GetMapping("/concurrency/simulate") + public ApiResponse> simulateConcurrency( + @RequestParam(defaultValue = "12") int tasks, + @RequestParam(defaultValue = "4") int poolSize + ) { + int safeTasks = Math.max(1, Math.min(tasks, 32)); + int safePoolSize = Math.max(1, Math.min(poolSize, 8)); + ExecutorService executor = Executors.newFixedThreadPool(safePoolSize); + AtomicInteger counter = new AtomicInteger(); + Instant start = Instant.now(); + + try { + List>> jobs = createJobs(safeTasks, counter, executor); + CompletableFuture.allOf(jobs.toArray(CompletableFuture[]::new)).join(); + List> results = jobs.stream() + .map(CompletableFuture::join) + .toList(); + + return ApiResponse.ok(Map.of( + "tasks", safeTasks, + "poolSize", safePoolSize, + "finalCounter", counter.get(), + "durationMs", Duration.between(start, Instant.now()).toMillis(), + "sample", results.stream().limit(5).toList(), + "tip", "AtomicInteger keeps the shared counter safe when many tasks run in parallel." + )); + } finally { + executor.shutdownNow(); + } + } + + @GetMapping("/jwt/claims") + public ApiResponse> jwtClaims(@RequestHeader("Authorization") String authorization) { + String token = authorization.substring(7); + return ApiResponse.ok(Map.of( + "subject", jwtUtil.username(token), + "claims", jwtUtil.claims(token), + "tip", "JWT claims can be parsed without a database lookup, which is one reason token auth scales well." + )); + } + + private List>> createJobs(int tasks, AtomicInteger counter, ExecutorService executor) { + return java.util.stream.IntStream.range(0, tasks) + .mapToObj(index -> CompletableFuture.supplyAsync(() -> { + int current = counter.incrementAndGet(); + try { + Thread.sleep(20L + (index % 3) * 10L); + } catch (InterruptedException exception) { + Thread.currentThread().interrupt(); + } + return Map.of( + "task", index, + "thread", Thread.currentThread().getName(), + "counterAfterIncrement", current + ); + }, executor)) + .toList(); + } + + private Map toRouteView(Map.Entry entry) { + HandlerMethod handlerMethod = entry.getValue(); + return Map.of( + "path", entry.getKey().getPatternValues().toString(), + "methods", entry.getKey().getMethodsCondition().getMethods().toString(), + "handler", handlerMethod.getBeanType().getSimpleName() + "#" + handlerMethod.getMethod().getName(), + "parameters", Arrays.stream(handlerMethod.getMethod().getParameters()) + .map(parameter -> parameter.getType().getSimpleName()) + .toList() + ); + } + + private Map summarizeClass(Class type) { + List> methods = Arrays.stream(type.getDeclaredMethods()) + .filter(method -> !method.isSynthetic()) + .map(this::toMethodView) + .toList(); + + return Map.of( + "className", type.getSimpleName(), + "annotations", Arrays.stream(type.getAnnotations()).map(annotation -> annotation.annotationType().getSimpleName()).toList(), + "methodCount", methods.size(), + "methods", methods + ); + } + + private Map toMethodView(Method method) { + return Map.of( + "name", method.getName(), + "returnType", method.getReturnType().getSimpleName(), + "parameterCount", method.getParameterCount(), + "annotations", Arrays.stream(method.getAnnotations()).map(Annotation::annotationType).map(Class::getSimpleName).toList() + ); + } + + private Map toFieldView(Field field) { + return Map.of( + "name", field.getName(), + "type", field.getType().getSimpleName(), + "annotations", Arrays.stream(field.getAnnotations()).map(Annotation::annotationType).map(Class::getSimpleName).toList() + ); + } +} diff --git a/src/main/java/com/example/demo/controller/auth/LearningAuthController.java b/src/main/java/com/example/demo/controller/auth/LearningAuthController.java index a276005..8e07158 100644 --- a/src/main/java/com/example/demo/controller/auth/LearningAuthController.java +++ b/src/main/java/com/example/demo/controller/auth/LearningAuthController.java @@ -4,7 +4,14 @@ 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 org.springframework.http.HttpHeaders; +import org.springframework.util.StringUtils; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; import java.util.Map; @@ -19,27 +26,48 @@ public class LearningAuthController { } @PostMapping("/login") - public ApiResponse> 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()); + public ApiResponse> login(@Valid @RequestBody LoginRequest request) { + if (!(("admin".equals(request.username()) && "admin123".equals(request.password())) + || ("user".equals(request.username()) && "user123".equals(request.password())))) { + return new ApiResponse<>(401, "Invalid demo credentials", null, java.time.Instant.now()); } - String token = jwtUtil.generateToken(req.username()); + + String token = jwtUtil.generateToken(request.username()); return ApiResponse.ok(Map.of( - "token", token, - "type", "Bearer", - "username", req.username(), - "tip", "在请求头中加入 Authorization: Bearer 访问 /api/secure/**" + "token", token, + "type", "Bearer", + "username", request.username(), + "tip", "Attach Authorization: Bearer when calling protected lab APIs." )); } @GetMapping("/mode") public ApiResponse> mode() { return ApiResponse.ok(Map.of( - "mode", "learning-jwt", - "protectedPath", "/api/secure/**", - "defaultAccounts", "admin/admin123, user/user123" + "mode", "learning-jwt", + "protectedPaths", new String[]{"/api/users/**", "/aop/**", "/api/lab/**", "/learn/**", "/api/secure/**"}, + "defaultAccounts", new String[]{"admin/admin123", "user/user123"}, + "note", "Use this demo login before opening the advanced labs on a public VPS." + )); + } + + @GetMapping("/introspect") + public ApiResponse> introspect( + @RequestHeader(value = HttpHeaders.AUTHORIZATION, required = false) String authorization + ) { + if (!StringUtils.hasText(authorization) || !authorization.startsWith("Bearer ")) { + return ApiResponse.fail(401, "Missing bearer token"); + } + + String token = authorization.substring(7); + if (!jwtUtil.validate(token)) { + return ApiResponse.fail(401, "Token is invalid or expired"); + } + + return ApiResponse.ok(Map.of( + "subject", jwtUtil.username(token), + "claims", jwtUtil.claims(token), + "tip", "JWT is self-contained: the server can read claims without storing a session row for each request." )); } } diff --git a/src/main/java/com/example/demo/security/LearningJwtFilter.java b/src/main/java/com/example/demo/security/LearningJwtFilter.java index 2644f39..6c50136 100644 --- a/src/main/java/com/example/demo/security/LearningJwtFilter.java +++ b/src/main/java/com/example/demo/security/LearningJwtFilter.java @@ -26,37 +26,46 @@ public class LearningJwtFilter extends OncePerRequestFilter { @Override protected boolean shouldNotFilter(HttpServletRequest request) { - return !request.getRequestURI().startsWith("/api/secure/"); + String uri = request.getRequestURI(); + boolean learnRoute = "/learn".equals(uri) || uri.startsWith("/learn/"); + return !(uri.startsWith("/api/secure/") + || uri.equals("/api/users") + || uri.startsWith("/api/users/") + || uri.startsWith("/aop/") + || uri.startsWith("/api/lab/") + || learnRoute); } @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) - throws ServletException, IOException { + 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\"}"); + writeUnauthorized(response, "Missing or invalid Authorization header"); 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 无效或过期\"}"); + writeUnauthorized(response, "Token is invalid or expired"); return; } String username = jwtUtil.username(token); var authToken = new UsernamePasswordAuthenticationToken( - username, - null, - List.of(new SimpleGrantedAuthority("ROLE_USER")) + username, + null, + List.of(new SimpleGrantedAuthority("ROLE_USER")) ); authToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); SecurityContextHolder.getContext().setAuthentication(authToken); filterChain.doFilter(request, response); } + + private void writeUnauthorized(HttpServletResponse response, String message) throws IOException { + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + response.setContentType("application/json;charset=UTF-8"); + response.getWriter().write("{\"code\":401,\"message\":\"" + message + "\"}"); + } } diff --git a/src/main/java/com/example/demo/security/LearningJwtUtil.java b/src/main/java/com/example/demo/security/LearningJwtUtil.java index 647d811..f1c6960 100644 --- a/src/main/java/com/example/demo/security/LearningJwtUtil.java +++ b/src/main/java/com/example/demo/security/LearningJwtUtil.java @@ -48,6 +48,10 @@ public class LearningJwtUtil { return parse(token).getSubject(); } + public Map claims(String token) { + return Map.copyOf(parse(token)); + } + private Claims parse(String token) { return Jwts.parser().verifyWith(key()).build().parseSignedClaims(token).getPayload(); } diff --git a/src/main/java/com/example/demo/security/LearningSecurityConfig.java b/src/main/java/com/example/demo/security/LearningSecurityConfig.java index 9aef34b..9a525c9 100644 --- a/src/main/java/com/example/demo/security/LearningSecurityConfig.java +++ b/src/main/java/com/example/demo/security/LearningSecurityConfig.java @@ -26,10 +26,10 @@ public class LearningSecurityConfig { .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" + "/", "/home", "/api/auth/**", "/actuator/health", "/index.html", "/access.html", + "/users.html", "/aop.html", "/events.html", "/advanced.html" ).permitAll() - .requestMatchers("/api/secure/**").authenticated() + .requestMatchers("/api/secure/**", "/api/users/**", "/aop/**", "/api/lab/**", "/learn/**").authenticated() .anyRequest().permitAll() ) .addFilterBefore(learningJwtFilter, UsernamePasswordAuthenticationFilter.class); diff --git a/src/main/resources/static/access.html b/src/main/resources/static/access.html new file mode 100644 index 0000000..2101683 --- /dev/null +++ b/src/main/resources/static/access.html @@ -0,0 +1,389 @@ + + + + + + Spring Learning Login + + + +
+
+
+

+

+
+ + + + +
+
+ +
+
+
+

+

+ +
+ + +
+
+ + +
+ +
+ + + +
+ +
+

+        
+ + +
+
+ + + + + diff --git a/src/main/resources/static/aop.html b/src/main/resources/static/aop.html index beefbe3..6e34579 100644 --- a/src/main/resources/static/aop.html +++ b/src/main/resources/static/aop.html @@ -1,5 +1,5 @@ - + @@ -114,39 +114,37 @@
-
AOP Lab
-

Make cross-cutting behavior visible instead of abstract.

-

- This page focuses on the parts students usually miss: where advice wraps controller and service methods, - how timing is collected, and why a validation failure still counts as observed runtime behavior. -

+
+

+

-
Live Runs
-

Run requests and inspect collected metrics

-

The buttons below help you create real traffic, then inspect the aspect output without switching tools.

+
+

+

- - - - + + + +
- What to observe in the console -

Look for controller and service timing lines. Compare success and failure paths to see how around advice still records method duration.

+ +

- What to inspect in code -

Read PerformanceAspect first, then compare it with the user endpoints that it wraps.

+ +

-
Run one of the experiments above to inspect live JSON output.
+

         
+ diff --git a/src/main/resources/static/events.html b/src/main/resources/static/events.html index 16bdcad..d853654 100644 --- a/src/main/resources/static/events.html +++ b/src/main/resources/static/events.html @@ -1,5 +1,5 @@ - + @@ -129,54 +129,52 @@
-
Event Lab
-

Understand publisher-listener decoupling through a visible event timeline.

-

- This page helps you observe what usually stays hidden: the request can finish quickly while listeners keep - reacting in the background. Publish multiple event types and then inspect the shared event history. -

+
+

+

-
Live Event Console
-

Publish events and inspect history

-

Use the form to publish events, then reload history to inspect type, user, detail, and timestamp.

+
+

+

- - - + + +
- Interpretation hint -

If the controller returns immediately but history keeps growing, you are seeing decoupled follow-up behavior in action.

+ +

-
Publish an event or load history to inspect live output.
+

         
+ diff --git a/src/main/resources/static/index.html b/src/main/resources/static/index.html index 39b4888..eda85bf 100644 --- a/src/main/resources/static/index.html +++ b/src/main/resources/static/index.html @@ -1,5 +1,5 @@ - + @@ -192,41 +192,39 @@
-
Spring Boot Learning Cockpit
-

Use one workspace to understand MVC, validation, security, AOP, and application events.

-

- This homepage is now a guided study cockpit. Instead of only linking to pages, it explains the request - path, suggests experiment sequences, and lets you call real endpoints to observe how the demo behaves. -

+
+

+

-
Core labs4
-
Major layers6
-
Interactive pages4
-
Tested backend paths2+
+
5
+
6
+
4
+
2+
-
Start Here
-

Recommended study order

+
+

- 1. Learn endpoint binding -

Hit the live API explorer below and compare query, path, header, cookie, and JSON body patterns.

+ +

- 2. Study users and validation -

Open the user lab to inspect CRUD, duplicate email handling, and aggregate stats.

+ +

- 3. Watch cross-cutting behavior -

Move to AOP and events to see timing, rate limiting, and listener history.

+ +

@@ -235,77 +233,88 @@
-
Architecture
-

How one request travels through the demo

+
+

-
BrowserForm submit or fetch call starts the request.
-
SecurityJWT demo routes decide whether the request is public or protected.
-
ControllerSpring binds params, body, headers, and cookies into method arguments.
-
ServiceBusiness rules run, such as duplicate email checks or stats calculation.
-
AOP / EventsCross-cutting timing or event listeners react without cluttering core logic.
-
ResponseStructured JSON or HTML returns to the page and becomes visible feedback.
+
+
+
+
+
+
-
Live Explorer
-

Call real endpoints without leaving the page

-

Use these controls to inspect real JSON responses while you read the code. This makes the project easier to connect to concrete behavior.

+
+

+

- - - - - + + + + + + +
+

- +
- +
- - + +
-
Endpoint output
-
Select an experiment above to load live output.
+
+

                 
-
Experiment 1
-

Trace validation

-

Open the user lab, create a user, then repeat with the same email. Compare the frontend error with `DuplicateEmailException` and the global exception handler.

+
+

+

UserController UserService @@ -350,9 +359,9 @@
-
Experiment 2
-

Trace AOP timing

-

Load users and stats several times, then call `/aop/aop/stats`. Watch how controller and service methods accumulate timing and call count data.

+
+

+

PerformanceAspect @Around @@ -361,9 +370,9 @@
-
Experiment 3
-

Trace event decoupling

-

Publish CREATED and LOGIN events, then reload event history. This shows how the request can finish while listeners keep handling side effects.

+
+

+

Publisher Listener @@ -374,35 +383,263 @@
+ diff --git a/src/main/resources/static/learning-shell.js b/src/main/resources/static/learning-shell.js new file mode 100644 index 0000000..de74ec7 --- /dev/null +++ b/src/main/resources/static/learning-shell.js @@ -0,0 +1,329 @@ +(function () { + const STORAGE = { + token: "learning-demo-token", + username: "learning-demo-username", + language: "learning-demo-language" + }; + + const TEXT = { + zh: { + brand: "Spring \u5b66\u4e60\u5de5\u4f5c\u53f0", + home: "\u9996\u9875", + access: "\u767b\u5f55\u9875", + loginReady: "\u5df2\u767b\u5f55\uff0c\u53ef\u8bbf\u95ee\u53d7\u4fdd\u62a4\u5b9e\u9a8c", + loginMissing: "\u672a\u767b\u5f55\uff0c\u53d7\u4fdd\u62a4\u5b9e\u9a8c\u4f1a\u8fd4\u56de 401", + currentUser: "\u5f53\u524d\u7528\u6237", + logout: "\u9000\u51fa\u767b\u5f55", + login: "\u53bb\u767b\u5f55", + languageToggle: "EN", + unauthorized: "\u672a\u767b\u5f55\u6216\u4ee4\u724c\u5df2\u5931\u6548\uff0c\u8bf7\u5148\u6253\u5f00\u767b\u5f55\u9875\u5b8c\u6210\u6f14\u793a\u767b\u5f55\u3002", + requestFailed: "\u8bf7\u6c42\u5931\u8d25\uff0c\u8bf7\u7a0d\u540e\u91cd\u8bd5\u3002" + }, + en: { + brand: "Spring Learning Workspace", + home: "Home", + access: "Login", + loginReady: "Authenticated, protected labs are available", + loginMissing: "Not logged in, protected labs will return 401", + currentUser: "User", + logout: "Logout", + login: "Login", + languageToggle: "\u4e2d\u6587", + unauthorized: "Not logged in or token expired. Open the login page first.", + requestFailed: "Request failed. Please try again." + } + }; + + function normalizeLanguage(language) { + return language === "en" ? "en" : "zh"; + } + + function getLanguage() { + return normalizeLanguage(localStorage.getItem(STORAGE.language)); + } + + function setLanguage(language) { + const normalized = normalizeLanguage(language); + localStorage.setItem(STORAGE.language, normalized); + document.documentElement.lang = normalized === "zh" ? "zh-CN" : "en"; + window.dispatchEvent(new CustomEvent("learning-language-changed", { + detail: { language: normalized } + })); + } + + function t(key) { + const lang = getLanguage(); + return (TEXT[lang] && TEXT[lang][key]) || key; + } + + function getToken() { + return localStorage.getItem(STORAGE.token) || ""; + } + + function getUsername() { + return localStorage.getItem(STORAGE.username) || ""; + } + + function isLoggedIn() { + return Boolean(getToken()); + } + + function saveAuth(token, username) { + localStorage.setItem(STORAGE.token, token || ""); + localStorage.setItem(STORAGE.username, username || ""); + window.dispatchEvent(new CustomEvent("learning-auth-changed", { + detail: { loggedIn: isLoggedIn(), username: getUsername() } + })); + } + + function clearAuth() { + localStorage.removeItem(STORAGE.token); + localStorage.removeItem(STORAGE.username); + window.dispatchEvent(new CustomEvent("learning-auth-changed", { + detail: { loggedIn: false, username: "" } + })); + } + + function ensureStyle() { + if (document.getElementById("learning-shell-style")) { + return; + } + + const style = document.createElement("style"); + style.id = "learning-shell-style"; + style.textContent = [ + ".learning-shell {", + " max-width: 1380px;", + " margin: 18px auto 0;", + " padding: 0 24px;", + "}", + ".learning-shell-card {", + " display: flex;", + " justify-content: space-between;", + " align-items: center;", + " gap: 14px;", + " flex-wrap: wrap;", + " padding: 14px 18px;", + " border-radius: 22px;", + " border: 1px solid rgba(216, 228, 240, 0.9);", + " background: rgba(255, 255, 255, 0.88);", + " box-shadow: 0 12px 28px rgba(18, 32, 51, 0.08);", + " backdrop-filter: blur(10px);", + "}", + ".learning-shell-brand {", + " display: flex;", + " flex-direction: column;", + " gap: 4px;", + "}", + ".learning-shell-brand strong {", + " font-size: 15px;", + " color: #122033;", + "}", + ".learning-shell-brand span {", + " font-size: 13px;", + " color: #5d7288;", + "}", + ".learning-shell-actions {", + " display: flex;", + " align-items: center;", + " gap: 10px;", + " flex-wrap: wrap;", + "}", + ".learning-shell-link,", + ".learning-shell-button,", + ".learning-shell-badge {", + " display: inline-flex;", + " align-items: center;", + " justify-content: center;", + " min-height: 36px;", + " padding: 0 14px;", + " border-radius: 999px;", + " text-decoration: none;", + " font-size: 13px;", + " font-weight: 700;", + "}", + ".learning-shell-link {", + " color: #0f67b5;", + " background: rgba(15, 103, 181, 0.09);", + "}", + ".learning-shell-button {", + " border: 0;", + " cursor: pointer;", + " color: #fff;", + " background: linear-gradient(135deg, #177245, #35a465);", + "}", + ".learning-shell-button.alt {", + " color: #0f67b5;", + " background: rgba(15, 103, 181, 0.09);", + "}", + ".learning-shell-badge {", + " color: #33485e;", + " background: rgba(18, 32, 51, 0.06);", + "}", + "@media (max-width: 780px) {", + " .learning-shell {", + " padding: 0 14px;", + " }", + "}" + ].join("\n"); + document.head.appendChild(style); + } + + function mountShell(options) { + ensureStyle(); + + if (document.querySelector(".learning-shell")) { + if (options && typeof options.onLanguageChange === "function") { + options.onLanguageChange(getLanguage()); + } + return; + } + + const shell = document.createElement("div"); + shell.className = "learning-shell"; + shell.innerHTML = [ + '
', + '
', + ' ', + ' ', + "
", + '
', + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + "
", + "
" + ].join(""); + + const firstPage = document.querySelector(".page"); + if (firstPage) { + document.body.insertBefore(shell, firstPage); + } else { + document.body.insertBefore(shell, document.body.firstChild); + } + + const els = { + brand: shell.querySelector('[data-role="brand"]'), + status: shell.querySelector('[data-role="status"]'), + home: shell.querySelector('[data-role="home"]'), + access: shell.querySelector('[data-role="access"]'), + user: shell.querySelector('[data-role="user"]'), + language: shell.querySelector('[data-role="language"]'), + login: shell.querySelector('[data-role="login"]'), + logout: shell.querySelector('[data-role="logout"]') + }; + + function render() { + const loggedIn = isLoggedIn(); + const username = getUsername(); + + els.brand.textContent = t("brand"); + els.status.textContent = loggedIn ? t("loginReady") : t("loginMissing"); + els.home.textContent = t("home"); + els.access.textContent = t("access"); + els.user.textContent = loggedIn ? t("currentUser") + ": " + username : t("loginMissing"); + els.language.textContent = t("languageToggle"); + els.login.textContent = t("login"); + els.logout.textContent = t("logout"); + els.login.style.display = loggedIn ? "none" : "inline-flex"; + els.logout.style.display = loggedIn ? "inline-flex" : "none"; + + if (options && typeof options.onLanguageChange === "function") { + options.onLanguageChange(getLanguage()); + } + if (options && typeof options.onAuthChange === "function") { + options.onAuthChange({ loggedIn: loggedIn, username: username }); + } + } + + els.language.addEventListener("click", function () { + setLanguage(getLanguage() === "zh" ? "en" : "zh"); + }); + + els.login.addEventListener("click", function () { + window.location.href = "/access.html"; + }); + + els.logout.addEventListener("click", function () { + clearAuth(); + render(); + }); + + window.addEventListener("learning-auth-changed", render); + window.addEventListener("learning-language-changed", render); + render(); + } + + async function fetchWithAuth(url, options) { + const requestOptions = options || {}; + const headers = new Headers(requestOptions.headers || {}); + const token = getToken(); + + if (token && !headers.has("Authorization")) { + headers.set("Authorization", "Bearer " + token); + } + + return fetch(url, Object.assign({}, requestOptions, { headers: headers })); + } + + async function requestJson(url, options) { + const response = await fetchWithAuth(url, options); + const contentType = response.headers.get("content-type") || ""; + const payload = contentType.includes("application/json") + ? await response.json() + : await response.text(); + + if (!response.ok) { + const error = new Error( + typeof payload === "object" && payload && payload.message + ? payload.message + : t("requestFailed") + ); + error.status = response.status; + error.payload = payload; + error.details = typeof payload === "object" && payload ? payload.data : null; + throw error; + } + + if ( + typeof payload === "object" && + payload && + Object.prototype.hasOwnProperty.call(payload, "code") && + payload.code !== 0 + ) { + const error = new Error(payload.message || t("requestFailed")); + error.status = payload.code; + error.payload = payload; + error.details = payload.data; + throw error; + } + + return payload; + } + + function describeError(error) { + if (error && Number(error.status) === 401) { + return t("unauthorized"); + } + return error && error.message ? error.message : t("requestFailed"); + } + + setLanguage(getLanguage()); + + window.learningShell = { + mountShell: mountShell, + getLanguage: getLanguage, + setLanguage: setLanguage, + getToken: getToken, + getUsername: getUsername, + isLoggedIn: isLoggedIn, + saveAuth: saveAuth, + clearAuth: clearAuth, + fetchWithAuth: fetchWithAuth, + requestJson: requestJson, + describeError: describeError + }; +})(); diff --git a/src/main/resources/static/users.html b/src/main/resources/static/users.html index 63e1452..7c4fbf4 100644 --- a/src/main/resources/static/users.html +++ b/src/main/resources/static/users.html @@ -1,5 +1,5 @@ - + @@ -212,57 +212,58 @@
- Spring Boot demo -

User management that feels production-ready.

-

This page now uses backend stats, keyword search, and clear API error handling instead of a static CRUD example.

+ +

+

- - - + + + +
-

Highlights

-

Duplicate emails return a `409`. Validation errors are displayed inline. Search matches both names and emails.

+

+

-
Total users0
-
Adults0
-
Under 300
-
Average age0.0
+
0
+
0
+
0
+
0.0
-

User directory

-

Search results stay in sync with the API.

+

+

- 0 users +
-

Search

-

Use a keyword to match names or emails.

+

+

- - + +
-

The frontend now explains server-side validation and duplicate email errors instead of silently failing.

+

@@ -272,195 +273,389 @@
+ diff --git a/src/test/java/com/example/demo/controller/AdvancedLabControllerTest.java b/src/test/java/com/example/demo/controller/AdvancedLabControllerTest.java new file mode 100644 index 0000000..f89a25d --- /dev/null +++ b/src/test/java/com/example/demo/controller/AdvancedLabControllerTest.java @@ -0,0 +1,66 @@ +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 AdvancedLabControllerTest { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + @Test + void concurrencyLabShouldReflectSingleTaskBatch() throws Exception { + mockMvc.perform(get("/api/lab/concurrency/simulate") + .param("tasks", "5") + .param("poolSize", "2") + .header("Authorization", bearerToken())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.tasks").value(5)) + .andExpect(jsonPath("$.data.poolSize").value(2)) + .andExpect(jsonPath("$.data.finalCounter").value(5)); + } + + @Test + void advancedLabShouldRejectAnonymousRequests() throws Exception { + mockMvc.perform(get("/api/lab/reflection/routes")) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.code").value(401)); + } + + private String bearerToken() 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(); + return "Bearer " + token; + } +} diff --git a/src/test/java/com/example/demo/controller/AuthFlowTest.java b/src/test/java/com/example/demo/controller/AuthFlowTest.java index 89e0882..8370e24 100644 --- a/src/test/java/com/example/demo/controller/AuthFlowTest.java +++ b/src/test/java/com/example/demo/controller/AuthFlowTest.java @@ -10,6 +10,8 @@ import org.springframework.test.web.servlet.MockMvc; import java.util.Map; +import static org.hamcrest.Matchers.containsString; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; 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; @@ -34,6 +36,40 @@ class AuthFlowTest { @Test void shouldAccessSecureEndpointWithValidToken() throws Exception { + mockMvc.perform(get("/api/secure/me") + .header("Authorization", bearerToken())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.principal").value("admin")); + } + + @Test + void modeEndpointShouldDescribeProtectedRoutes() throws Exception { + mockMvc.perform(get("/api/auth/mode")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.mode").value("learning-jwt")) + .andExpect(jsonPath("$.data.protectedPaths").isArray()); + } + + @Test + void introspectShouldDecodeClaimsForValidToken() throws Exception { + mockMvc.perform(get("/api/auth/introspect") + .header("Authorization", bearerToken())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.subject").value("admin")) + .andExpect(jsonPath("$.data.claims.username").value("admin")); + } + + @Test + void shouldServeLearningShellWithoutToken() throws Exception { + mockMvc.perform(get("/learning-shell.js")) + .andExpect(status().isOk()) + .andExpect(content().string(containsString("window.learningShell"))); + } + + private String bearerToken() throws Exception { String loginReq = objectMapper.writeValueAsString(Map.of("username", "admin", "password", "admin123")); String loginResp = mockMvc.perform(post("/api/auth/login") @@ -41,14 +77,11 @@ class AuthFlowTest { .content(loginReq)) .andExpect(status().isOk()) .andExpect(jsonPath("$.code").value(0)) - .andReturn().getResponse().getContentAsString(); + .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")); + return "Bearer " + token; } } diff --git a/src/test/java/com/example/demo/controller/UserControllerTest.java b/src/test/java/com/example/demo/controller/UserControllerTest.java index 3df39c7..0d23ec8 100644 --- a/src/test/java/com/example/demo/controller/UserControllerTest.java +++ b/src/test/java/com/example/demo/controller/UserControllerTest.java @@ -1,5 +1,6 @@ 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; @@ -19,9 +20,13 @@ class UserControllerTest { @Autowired private MockMvc mockMvc; + @Autowired + private ObjectMapper objectMapper; + @Test void shouldListUsersWithApiResponseWrapper() throws Exception { - mockMvc.perform(get("/api/users")) + mockMvc.perform(get("/api/users") + .header("Authorization", bearerToken())) .andExpect(status().isOk()) .andExpect(jsonPath("$.code").value(0)) .andExpect(jsonPath("$.data").isArray()); @@ -38,6 +43,7 @@ class UserControllerTest { """; mockMvc.perform(post("/api/users") + .header("Authorization", bearerToken()) .contentType(MediaType.APPLICATION_JSON) .content(json)) .andExpect(status().isOk()) @@ -56,6 +62,7 @@ class UserControllerTest { """; mockMvc.perform(post("/api/users") + .header("Authorization", bearerToken()) .contentType(MediaType.APPLICATION_JSON) .content(json)) .andExpect(status().isBadRequest()) @@ -73,6 +80,7 @@ class UserControllerTest { """; mockMvc.perform(post("/api/users") + .header("Authorization", bearerToken()) .contentType(MediaType.APPLICATION_JSON) .content(json)) .andExpect(status().isConflict()) @@ -81,11 +89,38 @@ class UserControllerTest { @Test void shouldExposeUserStats() throws Exception { - mockMvc.perform(get("/api/users/stats")) + mockMvc.perform(get("/api/users/stats") + .header("Authorization", bearerToken())) .andExpect(status().isOk()) .andExpect(jsonPath("$.code").value(0)) .andExpect(jsonPath("$.data.totalUsers").isNumber()) .andExpect(jsonPath("$.data.adults").isNumber()) .andExpect(jsonPath("$.data.averageAge").isNumber()); } + + @Test + void shouldRejectUserEndpointsWithoutToken() throws Exception { + mockMvc.perform(get("/api/users")) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.code").value(401)); + } + + private String bearerToken() throws Exception { + String loginReq = objectMapper.writeValueAsString(java.util.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(); + return "Bearer " + token; + } } diff --git a/target/classes/com/example/demo/aop/LoggingAspect.class b/target/classes/com/example/demo/aop/LoggingAspect.class index 2436ce8..38e113f 100644 Binary files a/target/classes/com/example/demo/aop/LoggingAspect.class and b/target/classes/com/example/demo/aop/LoggingAspect.class differ diff --git a/target/classes/com/example/demo/aop/PerformanceAspect.class b/target/classes/com/example/demo/aop/PerformanceAspect.class index c9081f4..670aaff 100644 Binary files a/target/classes/com/example/demo/aop/PerformanceAspect.class and b/target/classes/com/example/demo/aop/PerformanceAspect.class differ diff --git a/target/classes/com/example/demo/aop/RateLimitAspect.class b/target/classes/com/example/demo/aop/RateLimitAspect.class index adbca1d..25ba5ad 100644 Binary files a/target/classes/com/example/demo/aop/RateLimitAspect.class and b/target/classes/com/example/demo/aop/RateLimitAspect.class differ diff --git a/target/classes/com/example/demo/common/ApiResponse.class b/target/classes/com/example/demo/common/ApiResponse.class index b54b3a1..51106a5 100644 Binary files a/target/classes/com/example/demo/common/ApiResponse.class and b/target/classes/com/example/demo/common/ApiResponse.class differ diff --git a/target/classes/com/example/demo/controller/AdvancedLabController.class b/target/classes/com/example/demo/controller/AdvancedLabController.class new file mode 100644 index 0000000..3120bda Binary files /dev/null and b/target/classes/com/example/demo/controller/AdvancedLabController.class differ diff --git a/target/classes/com/example/demo/controller/AopEventController$1.class b/target/classes/com/example/demo/controller/AopEventController$1.class new file mode 100644 index 0000000..ea478f4 Binary files /dev/null and b/target/classes/com/example/demo/controller/AopEventController$1.class differ diff --git a/target/classes/com/example/demo/controller/AopEventController.class b/target/classes/com/example/demo/controller/AopEventController.class index 57ddac5..3a0bccf 100644 Binary files a/target/classes/com/example/demo/controller/AopEventController.class and b/target/classes/com/example/demo/controller/AopEventController.class differ diff --git a/target/classes/com/example/demo/controller/LearnController.class b/target/classes/com/example/demo/controller/LearnController.class index 8994ac4..beaa941 100644 Binary files a/target/classes/com/example/demo/controller/LearnController.class and b/target/classes/com/example/demo/controller/LearnController.class differ diff --git a/target/classes/com/example/demo/controller/UserController.class b/target/classes/com/example/demo/controller/UserController.class index f2794a8..834cdb9 100644 Binary files a/target/classes/com/example/demo/controller/UserController.class and b/target/classes/com/example/demo/controller/UserController.class differ diff --git a/target/classes/com/example/demo/controller/auth/LearningAuthController.class b/target/classes/com/example/demo/controller/auth/LearningAuthController.class index 703dc36..5dce467 100644 Binary files a/target/classes/com/example/demo/controller/auth/LearningAuthController.class and b/target/classes/com/example/demo/controller/auth/LearningAuthController.class differ diff --git a/target/classes/com/example/demo/dto/UserRequest.class b/target/classes/com/example/demo/dto/UserRequest.class index 26858f5..a6a7479 100644 Binary files a/target/classes/com/example/demo/dto/UserRequest.class and b/target/classes/com/example/demo/dto/UserRequest.class differ diff --git a/target/classes/com/example/demo/dto/UserStatsResponse.class b/target/classes/com/example/demo/dto/UserStatsResponse.class new file mode 100644 index 0000000..73320ee Binary files /dev/null and b/target/classes/com/example/demo/dto/UserStatsResponse.class differ diff --git a/target/classes/com/example/demo/dto/auth/LoginRequest.class b/target/classes/com/example/demo/dto/auth/LoginRequest.class index 5215006..689c1cf 100644 Binary files a/target/classes/com/example/demo/dto/auth/LoginRequest.class and b/target/classes/com/example/demo/dto/auth/LoginRequest.class differ diff --git a/target/classes/com/example/demo/event/UserEventListener.class b/target/classes/com/example/demo/event/UserEventListener.class index a549985..aba6961 100644 Binary files a/target/classes/com/example/demo/event/UserEventListener.class and b/target/classes/com/example/demo/event/UserEventListener.class differ diff --git a/target/classes/com/example/demo/event/UserEventPublisher.class b/target/classes/com/example/demo/event/UserEventPublisher.class index 18c2f45..5847fa4 100644 Binary files a/target/classes/com/example/demo/event/UserEventPublisher.class and b/target/classes/com/example/demo/event/UserEventPublisher.class differ diff --git a/target/classes/com/example/demo/exception/DuplicateEmailException.class b/target/classes/com/example/demo/exception/DuplicateEmailException.class new file mode 100644 index 0000000..a5bb0ed Binary files /dev/null and b/target/classes/com/example/demo/exception/DuplicateEmailException.class differ diff --git a/target/classes/com/example/demo/exception/GlobalExceptionHandler.class b/target/classes/com/example/demo/exception/GlobalExceptionHandler.class index f7eaab4..3959f3a 100644 Binary files a/target/classes/com/example/demo/exception/GlobalExceptionHandler.class and b/target/classes/com/example/demo/exception/GlobalExceptionHandler.class differ diff --git a/target/classes/com/example/demo/security/LearningJwtFilter.class b/target/classes/com/example/demo/security/LearningJwtFilter.class index 6a8dc46..624cc39 100644 Binary files a/target/classes/com/example/demo/security/LearningJwtFilter.class and b/target/classes/com/example/demo/security/LearningJwtFilter.class differ diff --git a/target/classes/com/example/demo/security/LearningJwtUtil.class b/target/classes/com/example/demo/security/LearningJwtUtil.class index bfc5299..998d6ed 100644 Binary files a/target/classes/com/example/demo/security/LearningJwtUtil.class and b/target/classes/com/example/demo/security/LearningJwtUtil.class differ diff --git a/target/classes/com/example/demo/security/LearningSecurityConfig.class b/target/classes/com/example/demo/security/LearningSecurityConfig.class index bd64957..e6fcd81 100644 Binary files a/target/classes/com/example/demo/security/LearningSecurityConfig.class and b/target/classes/com/example/demo/security/LearningSecurityConfig.class differ diff --git a/target/classes/com/example/demo/service/UserService.class b/target/classes/com/example/demo/service/UserService.class index 186beb2..d667423 100644 Binary files a/target/classes/com/example/demo/service/UserService.class and b/target/classes/com/example/demo/service/UserService.class differ diff --git a/target/classes/static/access.html b/target/classes/static/access.html new file mode 100644 index 0000000..2101683 --- /dev/null +++ b/target/classes/static/access.html @@ -0,0 +1,389 @@ + + + + + + Spring Learning Login + + + +
+
+
+

+

+
+ + + + +
+
+ +
+
+
+

+

+ +
+ + +
+
+ + +
+ +
+ + + +
+ +
+

+        
+ + +
+
+ + + + + diff --git a/target/classes/static/aop.html b/target/classes/static/aop.html index 89167ea..6e34579 100644 --- a/target/classes/static/aop.html +++ b/target/classes/static/aop.html @@ -3,220 +3,347 @@ - AOP 切面编程 - Spring Boot + Spring AOP Lab - - -

🔪 AOP 切面编程

- -
-

🧪 实验任务卡(AOP)

- -
    -
  • 目标:观察同一请求如何触发 Before/After/Around 通知
  • -
  • 步骤1:调用用户接口 /api/users
  • -
  • 步骤2:回到本页点击“刷新统计数据”
  • -
  • 预期:统计里能看到 Controller/Service 方法耗时累积
  • -
  • 常见坑:只看页面不看控制台,容易错过切面日志
  • -
-
- -
-

📊 实时性能统计

-

AOP 自动统计所有 Controller 和 Service 方法的执行时间

- - -
点击按钮查看...
-
- -

📚 AOP 核心概念

- -
-

1. 什么是 AOP?

-

AOP (Aspect-Oriented Programming) 面向切面编程,是将横切关注点业务逻辑分离的编程范式。

-
- 横切关注点:日志、事务、安全、性能监控等,散布在多个模块中的公共功能。 +
+
+
+

+

+
+ + + +
+
+ +
+ + +
+
+

+

+ +
+ + + + +
+ +
+
+ +

+
+
+ +

+
+
+ +

+        
- -
-

2. 核心术语

- - - - - - - -
术语说明
Aspect (切面)横切关注点的模块化封装
JoinPoint (连接点)程序执行的某个点(方法调用、异常抛出等)
Pointcut (切入点)匹配连接点的表达式
Advice (通知)在连接点执行的动作
Weaving (织入)将切面应用到目标对象的过程
-
- -
-

3. 五种通知类型

- - - - - - - -
注解执行时机用途
@Before方法执行前参数校验、权限检查
@After方法执行后(无论成功或异常)资源清理
@AfterReturning方法成功返回后结果处理、日志记录
@AfterThrowing方法抛出异常后异常处理、错误日志
@Around环绕方法执行(最强大)性能统计、事务管理
-
- -

💻 代码示例

- -
-

日志切面示例

-
@Aspect
-@Component
-public class LoggingAspect {
-    
-    // 切入点:匹配所有 Controller 方法
-    @Pointcut("execution(* com.example.demo.controller.*.*(..))")
-    public void controllerMethods() {}
-    
-    // 前置通知
-    @Before("controllerMethods()")
-    public void logBefore(JoinPoint jp) {
-        System.out.println("[AOP-Before] 方法开始: " + jp.getSignature().getName());
-    }
-    
-    // 返回通知
-    @AfterReturning(pointcut = "controllerMethods()", returning = "result")
-    public void logAfterReturning(JoinPoint jp, Object result) {
-        System.out.println("[AOP-AfterReturning] 返回值: " + result);
-    }
-}
-
- -
-

性能监控切面 (@Around)

-
@Aspect
-@Component
-public class PerformanceAspect {
-    
-    @Around("execution(* com.example.demo..*.*(..))")
-    public Object measureTime(ProceedingJoinPoint pjp) throws Throwable {
-        long start = System.currentTimeMillis();
-        
-        try {
-            Object result = pjp.proceed(); // 执行目标方法
-            long duration = System.currentTimeMillis() - start;
-            System.out.println("[AOP] " + pjp.getSignature() + " 耗时: " + duration + "ms");
-            return result;
-        } catch (Throwable e) {
-            System.out.println("[AOP] 方法异常: " + e.getMessage());
-            throw e;
+
+ + + + const box = document.getElementById("resultBox"); + if (!box.dataset.state || box.dataset.state === "idle") { + box.textContent = text.placeholder; + box.dataset.state = "idle"; + } + } + + async function renderRequest(path, options) { + const box = document.getElementById("resultBox"); + const text = pageText(); + box.textContent = text.loadingPrefix + path + " ..."; + box.dataset.state = "loading"; + + try { + const data = await window.learningShell.requestJson(path, options || {}); + box.textContent = JSON.stringify(data, null, 2); + box.dataset.state = "live"; + } catch (error) { + box.textContent = text.requestFailedPrefix + window.learningShell.describeError(error); + box.dataset.state = "live"; + } + } + + async function loadUsers() { + await renderRequest("/api/users"); + } + + async function loadUserStats() { + await renderRequest("/api/users/stats"); + } + + async function loadAopStats() { + await renderRequest("/aop/aop/stats"); + } + + async function sendInvalidUser() { + await renderRequest("/api/users", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name: "", email: "bad", age: 999 }) + }); + } + + window.learningShell.mountShell({ onLanguageChange: renderLanguage }); + renderLanguage(); + - \ No newline at end of file + diff --git a/target/classes/static/events.html b/target/classes/static/events.html index 60f8858..d853654 100644 --- a/target/classes/static/events.html +++ b/target/classes/static/events.html @@ -3,251 +3,357 @@ - 事件机制 - Spring Boot + Spring Event Lab - - -

📡 Spring 事件机制

- -
-

🧪 实验任务卡(事件)

- -
    -
  • 目标:体验发布者与监听者解耦
  • -
  • 步骤1:输入 userId/userName,点击“发布登录事件”
  • -
  • 步骤2:重复发布不同用户,比较返回结果
  • -
  • 预期:接口立即返回;监听处理在日志中可观察
  • -
  • 常见坑:把事件当同步 RPC,忽略异步监听特性
  • -
-
- -
-

🎉 事件发布演示

-

模拟用户登录事件,观察事件发布和监听过程

-
- - - - +
+
+
+

+

+
+ + + +
-
等待事件发布...
-
- -

🔄 事件机制流程

- -
- 发布者 - - ApplicationEventPublisher - - 事件 - - 监听者 -
- -
- 核心优势: -
    -
  • 解耦:发布者和监听者互不依赖
  • -
  • 扩展:新增监听器无需修改发布者
  • -
  • 异步:耗时操作不阻塞主流程
  • -
-
- -

💻 代码实现

- -
-

1. 定义事件

-
public class UserEvent {
-    public enum Type { CREATED, UPDATED, DELETED, LOGIN }
-    
-    private Type type;
-    private Long userId;
-    private String userName;
-    private LocalDateTime timestamp;
-    
-    // constructor, getters...
-}
-
- -
-

2. 发布事件

-
@Component
-public class UserEventPublisher {
-    
-    @Autowired
-    private ApplicationEventPublisher eventPublisher;
-    
-    public void publishUserLogin(Long userId, String userName) {
-        UserEvent event = new UserEvent(
-            UserEvent.Type.LOGIN,
-            userId,
-            userName,
-            "用户登录成功"
-        );
-        eventPublisher.publishEvent(event);
-    }
-}
-
- -
-

3. 监听事件

-
@Component
-public class UserEventListener {
-    
-    // 基础监听
-    @EventListener
-    public void handleUserEvent(UserEvent event) {
-        System.out.println("收到事件: " + event.getType());
-    }
-    
-    // 条件监听 - 只处理登录事件
-    @EventListener(condition = "#event.type == T(com.example.demo.model.UserEvent$Type).LOGIN")
-    public void handleLogin(UserEvent event) {
-        System.out.println("用户登录: " + event.getUserName());
-    }
-    
-    // 异步监听 - 不阻塞主流程
-    @Async
-    @EventListener
-    public void sendWelcomeEmail(UserEvent event) {
-        // 发送邮件...
-    }
-}
-
- -
-

4. 控制器中使用

-
@RestController
-@RequestMapping("/aop")
-public class AopEventController {
-    
-    @Autowired
-    private UserEventPublisher eventPublisher;
-    
-    @PostMapping("/event/publish")
-    public Map<String, Object> publishEvent(
-            @RequestParam Long userId,
-            @RequestParam String userName) {
-        
-        // 发布事件
-        eventPublisher.publishUserLogin(userId, userName);
-        
-        return Map.of(
-            "message", "事件已发布",
-            "userId", userId,
-            "userName", userName
-        );
-    }
-}
-
- -

🎯 应用场景

- -
- - - - - - -
场景事件类型处理逻辑
用户注册UserCreatedEvent发送欢迎邮件、初始化数据
订单创建OrderCreatedEvent扣库存、发送通知
支付成功PaymentSuccessEvent更新订单状态、发送短信
用户登录UserLoginEvent记录登录日志、更新在线状态
-
- -
-

💡 最佳实践

-
    -
  • 事件类应该是不可变的(只读属性)
  • -
  • 使用 @Async 处理耗时操作
  • -
  • 避免在监听器中抛出异常
  • -
  • 使用 condition 过滤不需要的事件
  • -
  • 复杂场景考虑使用消息队列(RabbitMQ/Kafka)
  • -
-
- -

← 返回学习中心

- - + + const box = document.getElementById("resultBox"); + if (!box.dataset.state || box.dataset.state === "idle") { + box.textContent = text.placeholder; + box.dataset.state = "idle"; + } + } + + async function renderRequest(path, options) { + const box = document.getElementById("resultBox"); + const text = pageText(); + box.textContent = text.loadingPrefix + path + " ..."; + box.dataset.state = "loading"; + + try { + const data = await window.learningShell.requestJson(path, options || {}); + box.textContent = JSON.stringify(data, null, 2); + box.dataset.state = "live"; + } catch (error) { + box.textContent = text.requestFailedPrefix + window.learningShell.describeError(error); + box.dataset.state = "live"; + } + } + + async function publishEvent() { + const type = document.getElementById("eventType").value; + const userId = document.getElementById("userId").value.trim() || "21"; + const userName = document.getElementById("userName").value.trim() || "observer-demo"; + const params = new URLSearchParams({ userId: userId, userName: userName, eventType: type }); + await renderRequest("/aop/event/publish?" + params.toString(), { method: "POST" }); + } + + async function loadHistory() { + await renderRequest("/aop/event/history"); + } + + async function loadInfo() { + await renderRequest("/aop/event"); + } + + window.learningShell.mountShell({ onLanguageChange: renderLanguage }); + renderLanguage(); + - \ No newline at end of file + diff --git a/target/classes/static/index.html b/target/classes/static/index.html index e5e1b38..eda85bf 100644 --- a/target/classes/static/index.html +++ b/target/classes/static/index.html @@ -3,223 +3,643 @@ - Spring Boot 学习中心 + Spring Boot Learning Cockpit -

🍃 Spring Boot 学习中心

- - - -
-

📚 学习模块

-
- -

👥 用户管理

-

RESTful API 设计、CRUD 操作、参数绑定

-
- -

🔪 AOP 切面编程

-

日志记录、性能监控、限流控制

-
- -

📡 事件机制

-

发布/订阅模式、解耦业务逻辑

-
- -

🔐 鉴权演示(学习用)

-

最小 JWT 流程:登录、携带 Token、访问受保护接口

-
+
+
+
+
+
+

+

+
+ + + + + +
+
+
5
+
6
+
4
+
2+
+
+
+
+
+
+

+
+
+ +

+
+
+ +

+
+
+ +

+
+
+
+
-
- -

🔗 快速链接

- - -

🧪 接口测试

- -
-

GET 参数示例

-
- - - -
-
-

GET /learn/params?name=xxx&age=18

-
- -
-

路径变量示例

-
- - -
-
-

GET /learn/path/{id}

-
- -
-

POST JSON 示例

-
- - -
-
-

POST /learn/body

-
- -

📖 学习路径

- -
-

1. IOC 容器

-
    -
  • @Component, @Service, @Repository, @Controller
  • -
  • @Autowired 依赖注入
  • -
  • @Configuration + @Bean 配置类
  • -
-
- -
-

2. Web 开发

-
    -
  • @RestController = @Controller + @ResponseBody
  • -
  • @RequestMapping, @GetMapping, @PostMapping
  • -
  • @PathVariable, @RequestParam, @RequestBody
  • -
-
- -
-

3. AOP 切面编程

-
@Aspect
-@Component
-public class LoggingAspect {
-    @Before("execution(* com.example.*.*(..))")
-    public void logBefore(JoinPoint jp) {
-        System.out.println("方法调用: " + jp.getSignature());
-    }
-}
-
- -
-

4. 事件机制

-
// 发布事件
-@Autowired
-ApplicationEventPublisher publisher;
-publisher.publishEvent(new UserEvent(...));
+    
 
-// 监听事件
-@EventListener
-public void onEvent(UserEvent event) {
-    // 处理事件
-}
+
+
+

+
+
+
+
+
+
+
+
+
+ +
+ + +
+
+
+

+

+ +
+ + + + + + + +
+

+ +
+
+
+ + +
+
+
+
+ + +
+
+
+
+ + +
+
+
+
+ + +
+ +
+
+

+                
+
+ +
+
+

+

+
+ UserController + UserService + GlobalExceptionHandler +
+
+ +
+
+

+

+
+ PerformanceAspect + @Around + Cross-cutting +
+
+ +
+
+

+

+
+ Publisher + Listener + @Async +
+
+
- -

📁 项目结构

-
-
├── 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 配置
-
- - - - + + } + + async function publishEvent() { + const type = document.getElementById("eventType").value; + const userId = document.getElementById("eventUserId").value.trim() || "99"; + const userName = document.getElementById("eventUserName").value.trim() || "learning-user"; + const params = new URLSearchParams({ + userId: userId, + userName: userName, + eventType: type + }); + await loadEndpoint("/aop/event/publish?" + params.toString(), { method: "POST" }); + } + + window.learningShell.mountShell({ onLanguageChange: renderLanguage }); + renderLanguage(); + - \ No newline at end of file + diff --git a/target/classes/static/learning-shell.js b/target/classes/static/learning-shell.js new file mode 100644 index 0000000..de74ec7 --- /dev/null +++ b/target/classes/static/learning-shell.js @@ -0,0 +1,329 @@ +(function () { + const STORAGE = { + token: "learning-demo-token", + username: "learning-demo-username", + language: "learning-demo-language" + }; + + const TEXT = { + zh: { + brand: "Spring \u5b66\u4e60\u5de5\u4f5c\u53f0", + home: "\u9996\u9875", + access: "\u767b\u5f55\u9875", + loginReady: "\u5df2\u767b\u5f55\uff0c\u53ef\u8bbf\u95ee\u53d7\u4fdd\u62a4\u5b9e\u9a8c", + loginMissing: "\u672a\u767b\u5f55\uff0c\u53d7\u4fdd\u62a4\u5b9e\u9a8c\u4f1a\u8fd4\u56de 401", + currentUser: "\u5f53\u524d\u7528\u6237", + logout: "\u9000\u51fa\u767b\u5f55", + login: "\u53bb\u767b\u5f55", + languageToggle: "EN", + unauthorized: "\u672a\u767b\u5f55\u6216\u4ee4\u724c\u5df2\u5931\u6548\uff0c\u8bf7\u5148\u6253\u5f00\u767b\u5f55\u9875\u5b8c\u6210\u6f14\u793a\u767b\u5f55\u3002", + requestFailed: "\u8bf7\u6c42\u5931\u8d25\uff0c\u8bf7\u7a0d\u540e\u91cd\u8bd5\u3002" + }, + en: { + brand: "Spring Learning Workspace", + home: "Home", + access: "Login", + loginReady: "Authenticated, protected labs are available", + loginMissing: "Not logged in, protected labs will return 401", + currentUser: "User", + logout: "Logout", + login: "Login", + languageToggle: "\u4e2d\u6587", + unauthorized: "Not logged in or token expired. Open the login page first.", + requestFailed: "Request failed. Please try again." + } + }; + + function normalizeLanguage(language) { + return language === "en" ? "en" : "zh"; + } + + function getLanguage() { + return normalizeLanguage(localStorage.getItem(STORAGE.language)); + } + + function setLanguage(language) { + const normalized = normalizeLanguage(language); + localStorage.setItem(STORAGE.language, normalized); + document.documentElement.lang = normalized === "zh" ? "zh-CN" : "en"; + window.dispatchEvent(new CustomEvent("learning-language-changed", { + detail: { language: normalized } + })); + } + + function t(key) { + const lang = getLanguage(); + return (TEXT[lang] && TEXT[lang][key]) || key; + } + + function getToken() { + return localStorage.getItem(STORAGE.token) || ""; + } + + function getUsername() { + return localStorage.getItem(STORAGE.username) || ""; + } + + function isLoggedIn() { + return Boolean(getToken()); + } + + function saveAuth(token, username) { + localStorage.setItem(STORAGE.token, token || ""); + localStorage.setItem(STORAGE.username, username || ""); + window.dispatchEvent(new CustomEvent("learning-auth-changed", { + detail: { loggedIn: isLoggedIn(), username: getUsername() } + })); + } + + function clearAuth() { + localStorage.removeItem(STORAGE.token); + localStorage.removeItem(STORAGE.username); + window.dispatchEvent(new CustomEvent("learning-auth-changed", { + detail: { loggedIn: false, username: "" } + })); + } + + function ensureStyle() { + if (document.getElementById("learning-shell-style")) { + return; + } + + const style = document.createElement("style"); + style.id = "learning-shell-style"; + style.textContent = [ + ".learning-shell {", + " max-width: 1380px;", + " margin: 18px auto 0;", + " padding: 0 24px;", + "}", + ".learning-shell-card {", + " display: flex;", + " justify-content: space-between;", + " align-items: center;", + " gap: 14px;", + " flex-wrap: wrap;", + " padding: 14px 18px;", + " border-radius: 22px;", + " border: 1px solid rgba(216, 228, 240, 0.9);", + " background: rgba(255, 255, 255, 0.88);", + " box-shadow: 0 12px 28px rgba(18, 32, 51, 0.08);", + " backdrop-filter: blur(10px);", + "}", + ".learning-shell-brand {", + " display: flex;", + " flex-direction: column;", + " gap: 4px;", + "}", + ".learning-shell-brand strong {", + " font-size: 15px;", + " color: #122033;", + "}", + ".learning-shell-brand span {", + " font-size: 13px;", + " color: #5d7288;", + "}", + ".learning-shell-actions {", + " display: flex;", + " align-items: center;", + " gap: 10px;", + " flex-wrap: wrap;", + "}", + ".learning-shell-link,", + ".learning-shell-button,", + ".learning-shell-badge {", + " display: inline-flex;", + " align-items: center;", + " justify-content: center;", + " min-height: 36px;", + " padding: 0 14px;", + " border-radius: 999px;", + " text-decoration: none;", + " font-size: 13px;", + " font-weight: 700;", + "}", + ".learning-shell-link {", + " color: #0f67b5;", + " background: rgba(15, 103, 181, 0.09);", + "}", + ".learning-shell-button {", + " border: 0;", + " cursor: pointer;", + " color: #fff;", + " background: linear-gradient(135deg, #177245, #35a465);", + "}", + ".learning-shell-button.alt {", + " color: #0f67b5;", + " background: rgba(15, 103, 181, 0.09);", + "}", + ".learning-shell-badge {", + " color: #33485e;", + " background: rgba(18, 32, 51, 0.06);", + "}", + "@media (max-width: 780px) {", + " .learning-shell {", + " padding: 0 14px;", + " }", + "}" + ].join("\n"); + document.head.appendChild(style); + } + + function mountShell(options) { + ensureStyle(); + + if (document.querySelector(".learning-shell")) { + if (options && typeof options.onLanguageChange === "function") { + options.onLanguageChange(getLanguage()); + } + return; + } + + const shell = document.createElement("div"); + shell.className = "learning-shell"; + shell.innerHTML = [ + '
', + '
', + ' ', + ' ', + "
", + '
', + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + "
", + "
" + ].join(""); + + const firstPage = document.querySelector(".page"); + if (firstPage) { + document.body.insertBefore(shell, firstPage); + } else { + document.body.insertBefore(shell, document.body.firstChild); + } + + const els = { + brand: shell.querySelector('[data-role="brand"]'), + status: shell.querySelector('[data-role="status"]'), + home: shell.querySelector('[data-role="home"]'), + access: shell.querySelector('[data-role="access"]'), + user: shell.querySelector('[data-role="user"]'), + language: shell.querySelector('[data-role="language"]'), + login: shell.querySelector('[data-role="login"]'), + logout: shell.querySelector('[data-role="logout"]') + }; + + function render() { + const loggedIn = isLoggedIn(); + const username = getUsername(); + + els.brand.textContent = t("brand"); + els.status.textContent = loggedIn ? t("loginReady") : t("loginMissing"); + els.home.textContent = t("home"); + els.access.textContent = t("access"); + els.user.textContent = loggedIn ? t("currentUser") + ": " + username : t("loginMissing"); + els.language.textContent = t("languageToggle"); + els.login.textContent = t("login"); + els.logout.textContent = t("logout"); + els.login.style.display = loggedIn ? "none" : "inline-flex"; + els.logout.style.display = loggedIn ? "inline-flex" : "none"; + + if (options && typeof options.onLanguageChange === "function") { + options.onLanguageChange(getLanguage()); + } + if (options && typeof options.onAuthChange === "function") { + options.onAuthChange({ loggedIn: loggedIn, username: username }); + } + } + + els.language.addEventListener("click", function () { + setLanguage(getLanguage() === "zh" ? "en" : "zh"); + }); + + els.login.addEventListener("click", function () { + window.location.href = "/access.html"; + }); + + els.logout.addEventListener("click", function () { + clearAuth(); + render(); + }); + + window.addEventListener("learning-auth-changed", render); + window.addEventListener("learning-language-changed", render); + render(); + } + + async function fetchWithAuth(url, options) { + const requestOptions = options || {}; + const headers = new Headers(requestOptions.headers || {}); + const token = getToken(); + + if (token && !headers.has("Authorization")) { + headers.set("Authorization", "Bearer " + token); + } + + return fetch(url, Object.assign({}, requestOptions, { headers: headers })); + } + + async function requestJson(url, options) { + const response = await fetchWithAuth(url, options); + const contentType = response.headers.get("content-type") || ""; + const payload = contentType.includes("application/json") + ? await response.json() + : await response.text(); + + if (!response.ok) { + const error = new Error( + typeof payload === "object" && payload && payload.message + ? payload.message + : t("requestFailed") + ); + error.status = response.status; + error.payload = payload; + error.details = typeof payload === "object" && payload ? payload.data : null; + throw error; + } + + if ( + typeof payload === "object" && + payload && + Object.prototype.hasOwnProperty.call(payload, "code") && + payload.code !== 0 + ) { + const error = new Error(payload.message || t("requestFailed")); + error.status = payload.code; + error.payload = payload; + error.details = payload.data; + throw error; + } + + return payload; + } + + function describeError(error) { + if (error && Number(error.status) === 401) { + return t("unauthorized"); + } + return error && error.message ? error.message : t("requestFailed"); + } + + setLanguage(getLanguage()); + + window.learningShell = { + mountShell: mountShell, + getLanguage: getLanguage, + setLanguage: setLanguage, + getToken: getToken, + getUsername: getUsername, + isLoggedIn: isLoggedIn, + saveAuth: saveAuth, + clearAuth: clearAuth, + fetchWithAuth: fetchWithAuth, + requestJson: requestJson, + describeError: describeError + }; +})(); diff --git a/target/classes/static/users.html b/target/classes/static/users.html index 8a2a40c..7c4fbf4 100644 --- a/target/classes/static/users.html +++ b/target/classes/static/users.html @@ -3,224 +3,659 @@ - 用户管理 - Spring Boot + User Management Lab -

👥 用户管理 - RESTful API 示例

- -
-
-

用户列表

- -
- - - - - - - - - - - -
ID姓名邮箱年龄操作
-
- -
-

📖 学习要点

-
- RESTful API 设计: -
    -
  • GET /api/users - 获取所有用户
  • -
  • GET /api/users/{id} - 获取单个用户
  • -
  • POST /api/users - 创建用户
  • -
  • PUT /api/users/{id} - 更新用户
  • -
  • DELETE /api/users/{id} - 删除用户
  • -
-
- -

Controller 代码示例

-
@RestController
-@RequestMapping("/api/users")
-public class UserController {
-    
-    @GetMapping
-    public List<User> 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) { ... }
-}
-
- -
-

🔧 Spring 注解说明

- - - - - - - - - -
注解说明
@RestController= @Controller + @ResponseBody
@RequestMapping定义路由映射
@GetMappingGET 请求映射
@PostMappingPOST 请求映射
@PathVariable获取路径变量
@RequestBody获取请求体 JSON
@RequestParam获取查询参数
-
- -

← 返回学习中心

- - -